@eventcatalog/core 5.0.0-beta.11 → 5.0.0-beta.12

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 (61) hide show
  1. package/README.md +1 -1
  2. package/dist/analytics/analytics.cjs +1 -1
  3. package/dist/analytics/analytics.js +2 -2
  4. package/dist/analytics/log-build.cjs +1 -1
  5. package/dist/analytics/log-build.js +3 -3
  6. package/dist/{chunk-76DAAJLV.js → chunk-BWC4YKW3.js} +1 -1
  7. package/dist/{chunk-4YFM63DS.js → chunk-CX2VB7AI.js} +1 -1
  8. package/dist/{chunk-MFEMKQZ6.js → chunk-FY6MSP6J.js} +1 -1
  9. package/dist/{chunk-LQZ7IG7B.js → chunk-QB7X34OD.js} +1 -1
  10. package/dist/{chunk-WM4QYMIA.js → chunk-TSY5CC27.js} +1 -1
  11. package/dist/constants.cjs +1 -1
  12. package/dist/constants.js +1 -1
  13. package/dist/eventcatalog.cjs +1 -1
  14. package/dist/eventcatalog.js +5 -5
  15. package/dist/generate.cjs +1 -1
  16. package/dist/generate.js +3 -3
  17. package/dist/utils/cli-logger.cjs +1 -1
  18. package/dist/utils/cli-logger.js +2 -2
  19. package/eventcatalog/src/components/ChatPanel/ChatPanel.tsx +2 -0
  20. package/eventcatalog/src/components/MDX/NodeGraph/AstroNodeGraph.tsx +34 -3
  21. package/eventcatalog/src/components/MDX/NodeGraph/NodeGraph.astro +5 -1
  22. package/eventcatalog/src/components/NavigationProgress.astro +7 -0
  23. package/eventcatalog/src/features/mcp/apps/architecture-diagram/node-chat.tsx +6 -45
  24. package/eventcatalog/src/features/mcp/apps/generated/canvas.html +67 -67
  25. package/eventcatalog/src/features/mcp/apps/generated/viewer.html +69 -69
  26. package/eventcatalog/src/features/studio/agent-choreography.ts +8 -6
  27. package/eventcatalog/src/features/studio/api/canvases-api.ts +64 -15
  28. package/eventcatalog/src/features/studio/api/client.ts +6 -1
  29. package/eventcatalog/src/features/studio/canvas-actions.ts +140 -22
  30. package/eventcatalog/src/features/studio/canvas-doc.ts +165 -10
  31. package/eventcatalog/src/features/studio/catalog.ts +15 -10
  32. package/eventcatalog/src/features/studio/components/CanvasControls.tsx +54 -89
  33. package/eventcatalog/src/features/studio/components/CanvasHeader.tsx +43 -26
  34. package/eventcatalog/src/features/studio/components/CanvasStatusMenu.tsx +8 -11
  35. package/eventcatalog/src/features/studio/components/Comments.tsx +15 -8
  36. package/eventcatalog/src/features/studio/components/DeleteCanvasDialog.tsx +27 -43
  37. package/eventcatalog/src/features/studio/components/Dialog.tsx +60 -0
  38. package/eventcatalog/src/features/studio/components/LeftPanel.tsx +32 -4
  39. package/eventcatalog/src/features/studio/components/OpenInStudioDialog.tsx +79 -0
  40. package/eventcatalog/src/features/studio/components/Presence.tsx +47 -38
  41. package/eventcatalog/src/features/studio/components/StudioDesigner.tsx +323 -99
  42. package/eventcatalog/src/features/studio/components/canvas-nodes.tsx +33 -17
  43. package/eventcatalog/src/features/studio/flow-state.ts +29 -0
  44. package/eventcatalog/src/features/studio/from-visualiser.ts +138 -0
  45. package/eventcatalog/src/features/studio/grouping.ts +4 -0
  46. package/eventcatalog/src/features/studio/hooks/use-canvas-webmcp.ts +1 -1
  47. package/eventcatalog/src/features/studio/hooks/use-counter-scale.ts +20 -0
  48. package/eventcatalog/src/features/studio/hooks/use-follow-camera.ts +8 -3
  49. package/eventcatalog/src/features/studio/hooks/use-studio-flow.ts +67 -13
  50. package/eventcatalog/src/features/studio/layout.ts +29 -15
  51. package/eventcatalog/src/features/studio/level-layout.ts +145 -0
  52. package/eventcatalog/src/features/studio/levels.ts +82 -27
  53. package/eventcatalog/src/features/studio/limits.ts +2 -0
  54. package/eventcatalog/src/features/studio/node-types.ts +44 -4
  55. package/eventcatalog/src/features/studio/pages/index.astro +2 -1
  56. package/eventcatalog/src/features/studio/server/canvas-mcp.ts +1 -1
  57. package/eventcatalog/src/features/studio/server/runtime.ts +12 -4
  58. package/eventcatalog/src/utils/node-graphs/domain-levels-node-graph.ts +3 -3
  59. package/eventcatalog/src/utils/node-graphs/node-resource.ts +67 -0
  60. package/eventcatalog/src/utils/node-graphs/systems-node-graph.ts +1 -1
  61. package/package.json +2 -2
@@ -1,8 +1,8 @@
1
- import { createContext, memo, useCallback, useContext, useRef, useState } from 'react';
1
+ import { createContext, memo, useCallback, useContext, useMemo, useRef, useState } from 'react';
2
2
  import { NodeResizeControl, NodeResizer, ResizeControlVariant, useStoreApi, type NodeProps, type NodeTypes } from '@xyflow/react';
3
- import { DomainCardNode, nodeComponents, SystemGroupNode } from '@eventcatalog/visualiser';
3
+ import { diagramNodeComponents, DomainCardNode, memoNode, SystemGroupNode } from '@eventcatalog/visualiser';
4
4
  import { GROUP_PADDING } from '../grouping';
5
- import { getNodeName, GROUP_TYPES, nodeDefinitions } from '../node-types';
5
+ import { getNodeName, GROUP_TYPES } from '../node-types';
6
6
 
7
7
  /** How the canvas draws each kind of node: the visualiser's nodes, notes and text written on the canvas, and resizable containers */
8
8
 
@@ -14,9 +14,6 @@ export const UpdateNodeDataContext = createContext<UpdateNodeData>(() => {});
14
14
  // Which container something dragged would drop into, to highlight it
15
15
  export const DropTargetContext = createContext<string | null>(null);
16
16
 
17
- // The visualiser types its node components' props as nodes (Node<Data>) rather than React Flow's NodeProps, so
18
- // they don't type-check as NodeTypes, though that's what React Flow renders them with
19
- const visualiserNodes = nodeComponents as unknown as NodeTypes;
20
17
  const SystemGroupComponent: NodeTypes[string] = SystemGroupNode;
21
18
 
22
19
  /** The handles that set a node's width, on either side (its height follows what's written in it) */
@@ -167,6 +164,18 @@ const TextNode = memo(function TextNode({ id, data, selected }: NodeProps) {
167
164
  );
168
165
  });
169
166
 
167
+ /** A domain inside another one is a subdomain (badged as one, like the visualiser), unless its data says otherwise */
168
+ const useDomainData = ({ type, data, parentId }: Pick<NodeProps, 'type' | 'data' | 'parentId'>) =>
169
+ useMemo(() => {
170
+ const isDomain = type === GROUP_TYPES.domain || type === 'context-domain';
171
+ return isDomain && parentId && data.subdomain === undefined ? { ...data, subdomain: true } : data;
172
+ }, [type, data, parentId]);
173
+
174
+ /** A domain card, badged as a subdomain when it's in another domain */
175
+ const DomainCard = memo(function DomainCard(props: NodeProps) {
176
+ return <DomainCardNode {...props} data={useDomainData(props)} />;
177
+ });
178
+
170
179
  /**
171
180
  * A domain or system as a container: resizable when selected, highlighted when something's dragged over it.
172
181
  * Like frames in design tools, it's moved and selected by its header, and dragging inside it draws a selection
@@ -174,6 +183,7 @@ const TextNode = memo(function TextNode({ id, data, selected }: NodeProps) {
174
183
  */
175
184
  const GroupNode = memo(function GroupNode(props: NodeProps) {
176
185
  const isDropTarget = useContext(DropTargetContext) === props.id;
186
+ const data = useDomainData(props);
177
187
  const store = useStoreApi();
178
188
  // Where what's inside has to stay (with room around it and for the header), in the container's parent's
179
189
  // coordinates: found when a resize starts, so it can't be resized smaller than what's in it
@@ -224,7 +234,7 @@ const GroupNode = memo(function GroupNode(props: NodeProps) {
224
234
  lineClassName="!border-[rgb(var(--ec-accent))]"
225
235
  handleClassName="!h-2.5 !w-2.5 !rounded-sm !border-2 !bg-[rgb(var(--ec-card-bg))] !border-[rgb(var(--ec-accent))]"
226
236
  />
227
- <SystemGroupComponent {...props} />
237
+ <SystemGroupComponent {...props} data={data} />
228
238
  {/* The header (and the badge on its border) */}
229
239
  <div className="ec-group-grip absolute inset-x-0 -top-3 h-[60px] cursor-grab" />
230
240
  {isDropTarget ? (
@@ -239,17 +249,23 @@ const GroupNode = memo(function GroupNode(props: NodeProps) {
239
249
  );
240
250
  });
241
251
 
242
- export const nodeTypes: NodeTypes = {
243
- ...Object.fromEntries(
244
- nodeDefinitions.map((definition) => [
245
- definition.type,
246
- definition.type === 'note' ? StickyNote : definition.type === 'text' ? TextNode : visualiserNodes[definition.type],
247
- ])
248
- ),
249
- // Only dropped in from the catalog
250
- system: visualiserNodes.system,
251
- 'context-domain': DomainCardNode,
252
+ const components: NodeTypes = {
253
+ // Everything the visualiser draws, drawn the same (so a diagram's levels look as they do there). It types its
254
+ // components' props as nodes (Node<Data>) rather than React Flow's NodeProps, which they're rendered with.
255
+ ...(diagramNodeComponents as unknown as NodeTypes),
256
+ // Written on the canvas
257
+ note: StickyNote,
258
+ text: TextNode,
259
+ // Actors look like the visualiser's actors on its architecture diagrams
260
+ actor: diagramNodeComponents['context-actor'] as NodeTypes[string],
261
+ 'context-domain': DomainCard,
252
262
  // Domains and systems as containers
253
263
  [GROUP_TYPES.domain]: GroupNode,
254
264
  [GROUP_TYPES.system]: GroupNode,
255
265
  };
266
+
267
+ // React Flow gives every node its position, which changes on every frame it moves (dragged, in a dragged container,
268
+ // or gliding to a layout): like the visualiser's, nodes render again only when something else changes
269
+ export const nodeTypes: NodeTypes = Object.fromEntries(
270
+ Object.entries(components).map(([type, component]) => [type, memoNode(component)])
271
+ );
@@ -52,6 +52,35 @@ export const patchShared = <T extends { id: string }>(
52
52
  return [...(removed.size ? patched.filter((item) => !removed.has(item.id)) : patched), ...added];
53
53
  };
54
54
 
55
+ /** Whether two plain values (what nodes and edges hold) are the same, comparing what's in them */
56
+ const isSame = (a: unknown, b: unknown): boolean => {
57
+ if (Object.is(a, b)) return true;
58
+ if (!a || !b || typeof a !== 'object' || typeof b !== 'object' || Array.isArray(a) !== Array.isArray(b)) return false;
59
+ const keys = Object.keys(a);
60
+ return (
61
+ keys.length === Object.keys(b).length &&
62
+ keys.every((key) => isSame((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key]))
63
+ );
64
+ };
65
+
66
+ /**
67
+ * Items worked out again (e.g. a level arranged again), keeping each one that hasn't changed as the object already
68
+ * shown (so React Flow doesn't re-render it), and the local-only state of the ones that have. Returns `previous`
69
+ * itself when nothing changed.
70
+ */
71
+ export const keepUnchanged = <T extends { id: string }>(previous: T[], next: T[], localKeys: readonly (keyof T)[]): T[] => {
72
+ const shown = new Map(previous.map((item) => [item.id, item]));
73
+ const local = new Set<PropertyKey>(localKeys);
74
+ const kept = next.map((item) => {
75
+ const before = shown.get(item.id);
76
+ if (!before) return item;
77
+ const keys = new Set([...Object.keys(before), ...Object.keys(item)]) as Set<keyof T>;
78
+ const same = [...keys].every((key) => local.has(key) || isSame(before[key], item[key]));
79
+ return same ? before : withLocalState(item, before, localKeys);
80
+ });
81
+ return kept.length === previous.length && kept.every((item, index) => item === previous[index]) ? previous : kept;
82
+ };
83
+
55
84
  /** Where nodes being dragged are, by id, until they're dropped (and saved) */
56
85
  export type Moves = ReadonlyMap<string, XYPosition>;
57
86
 
@@ -0,0 +1,138 @@
1
+ import type { Edge, Node } from '@xyflow/react';
2
+ import type { VisualiserSnapshot } from '@eventcatalog/visualiser';
3
+ import { getNodeCatalogResource } from '@utils/node-graphs/node-resource';
4
+ import type { DiagramLevelsSpec, NewCanvasEdges, NodeSpecs } from './canvas-actions';
5
+ import { DEFAULT_NODE_SIZE, isCatalogCollection, isGroupType } from './node-types';
6
+
7
+ /**
8
+ * A canvas started from a diagram (the visualiser's "Open in Studio"): the catalog resources and actors it shows,
9
+ * where they are on it (the same top-left corners), in the domains and systems they're shown in, and the
10
+ * connections between them, drawn along the same routes. Other nodes (e.g. flow steps, users, entities) are left
11
+ * out, with their connections. Its levels 1 and 2 come as the diagram shows them, for Studio to show them the same.
12
+ */
13
+
14
+ // Actors (people and roles) aren't catalog resources: they go on the canvas as Studio's actors, by the name shown
15
+ const ACTOR_TYPES = new Set(['context-actor', 'actor']);
16
+ const actorNameOf = (node: VisualiserSnapshot['nodes'][number]) =>
17
+ node.type && ACTOR_TYPES.has(node.type) && typeof node.data.name === 'string' ? node.data.name : undefined;
18
+
19
+ export const canvasFromVisualiser = (
20
+ snapshot: VisualiserSnapshot
21
+ ): { nodes: NodeSpecs; edges: NewCanvasEdges; levels: DiagramLevelsSpec } => {
22
+ const byId = new Map(snapshot.nodes.map((node) => [node.id, node]));
23
+ const depthOf = (node: VisualiserSnapshot['nodes'][number]): number => {
24
+ const parent = node.parentId ? byId.get(node.parentId) : undefined;
25
+ return parent ? 1 + depthOf(parent) : 0;
26
+ };
27
+
28
+ const containers = new Set<string>();
29
+ const parentOf = new Map<string, string>();
30
+ const nodes = [...snapshot.nodes]
31
+ // Containers before what's in them (specs refer to containers given before them)
32
+ .sort((a, b) => depthOf(a) - depthOf(b))
33
+ .flatMap((node) => {
34
+ const resource = getNodeCatalogResource(node);
35
+ const fromCatalog =
36
+ resource && isCatalogCollection(resource.collection) ? { collection: resource.collection, id: resource.id } : undefined;
37
+ const actorName = actorNameOf(node);
38
+ if (!fromCatalog && !actorName) return [];
39
+ // Domains and systems shown as boxes are containers on the canvas
40
+ const container = isGroupType(node.type);
41
+ // In the innermost container shown that's on the canvas
42
+ let inside: string | undefined;
43
+ for (let parent = node.parentId; parent && !inside; parent = byId.get(parent)?.parentId) {
44
+ if (containers.has(parent)) inside = parent;
45
+ }
46
+ if (container) containers.add(node.id);
47
+ if (inside) parentOf.set(node.id, inside);
48
+ // Where it is: its top-left corner, as cards are put by their centre at the size they're created at (and
49
+ // render at the size they need from there), and containers at their size
50
+ const left = node.center.x - node.width / 2;
51
+ const top = node.center.y - node.height / 2;
52
+ return [
53
+ {
54
+ ref: node.id,
55
+ ...(fromCatalog ? { resource: fromCatalog } : { type: 'actor', name: actorName }),
56
+ ...(container
57
+ ? { x: node.center.x, y: node.center.y, container: true, width: node.width, height: node.height }
58
+ : { x: left + DEFAULT_NODE_SIZE.width / 2, y: top + DEFAULT_NODE_SIZE.height / 2 }),
59
+ ...(inside && { inside }),
60
+ },
61
+ ];
62
+ });
63
+
64
+ // Connections between nodes on the canvas, not between a container and what's in it (it holds it instead)
65
+ const onCanvas = new Set(nodes.map((node) => node.ref));
66
+ const isInside = (nodeId: string, containerId: string) => {
67
+ for (let parent = parentOf.get(nodeId); parent; parent = parentOf.get(parent)) if (parent === containerId) return true;
68
+ return false;
69
+ };
70
+ const edges = snapshot.edges
71
+ .filter(({ source, target }) => onCanvas.has(source) && onCanvas.has(target) && source !== target)
72
+ .filter(({ source, target }) => !isInside(source, target) && !isInside(target, source))
73
+ .map(({ source, target, label, route }) => ({ from: source, to: target, ...(label && { label }), ...(route && { route }) }));
74
+
75
+ const levels = {
76
+ ...(snapshot.levels[1] && { 1: levelFromVisualiser(snapshot.levels[1], nodes) }),
77
+ ...(snapshot.levels[2] && { 2: levelFromVisualiser(snapshot.levels[2], nodes) }),
78
+ ...(Object.keys(snapshot.unavailableLevels).length > 0 && { unavailable: snapshot.unavailableLevels }),
79
+ };
80
+ return { nodes, edges, levels };
81
+ };
82
+
83
+ const sameResource = (a?: { collection: string; id: string }, b?: { collection: string; id: string }) =>
84
+ !!a && !!b && a.collection === b.collection && a.id === b.id;
85
+ const sizeOf = (node: Node) => ({
86
+ width: typeof node.style?.width === 'number' ? node.style.width : (node.width ?? node.measured?.width),
87
+ height: typeof node.style?.height === 'number' ? node.style.height : (node.height ?? node.measured?.height),
88
+ });
89
+
90
+ /**
91
+ * One of the diagram's levels, as it shows it: each node stands for the canvas node with the same id (on level 3),
92
+ * or the same catalog resource (e.g. a system shown as a card on level 1 and as a container on level 3), so moving
93
+ * between levels glides from one to the other. Nodes only on the level keep their own id.
94
+ */
95
+ const levelFromVisualiser = (level: { nodes: Node[]; edges: Edge[] }, specs: NodeSpecs) => {
96
+ const used = new Set<string>();
97
+ const refs = new Map<string, string>();
98
+ for (const node of level.nodes) {
99
+ const resource = getNodeCatalogResource(node);
100
+ const ref = [
101
+ specs.find((spec) => spec.ref === node.id)?.ref,
102
+ specs.find((spec) => sameResource(spec.resource, resource))?.ref,
103
+ ].find((candidate) => candidate && !used.has(candidate));
104
+ refs.set(node.id, ref ?? node.id);
105
+ used.add(ref ?? node.id);
106
+ }
107
+ return {
108
+ nodes: level.nodes.map((node) => {
109
+ const { width, height } = sizeOf(node);
110
+ // The diagram marks the resource whose page it's on ("Viewing"): a canvas isn't on one
111
+ const { isFocused: _, ...data } = node.data as Record<string, unknown>;
112
+ return {
113
+ ref: refs.get(node.id)!,
114
+ type: node.type ?? 'default',
115
+ data,
116
+ ...(node.parentId && refs.has(node.parentId) && { parent: refs.get(node.parentId) }),
117
+ x: node.position.x,
118
+ y: node.position.y,
119
+ ...(isGroupType(node.type) && width && height && { width, height }),
120
+ };
121
+ }),
122
+ edges: level.edges.flatMap((edge) => {
123
+ const [from, to] = [refs.get(edge.source), refs.get(edge.target)];
124
+ if (!from || !to) return [];
125
+ return [
126
+ {
127
+ from,
128
+ to,
129
+ ...(typeof edge.label === 'string' && { label: edge.label }),
130
+ ...(edge.type && { type: edge.type }),
131
+ ...(edge.style && { style: edge.style as Record<string, unknown> }),
132
+ ...(edge.markerEnd && { markerEnd: edge.markerEnd }),
133
+ ...(edge.data && { data: edge.data }),
134
+ },
135
+ ];
136
+ }),
137
+ };
138
+ };
@@ -78,6 +78,10 @@ export const findGroupAtPoint = (point: XYPosition, nodes: Node[]): Node | undef
78
78
  .sort((a, b) => getDepth(b, lookup) - getDepth(a, lookup))[0];
79
79
  };
80
80
 
81
+ /** The container something added goes in: the one asked for (by id), else the one it's dropped on */
82
+ export const containerFor = (point: XYPosition, nodes: Node[], inside?: string): Node | undefined =>
83
+ inside ? nodes.find((node) => node.id === inside && isGroupType(node.type)) : findGroupAtPoint(point, nodes);
84
+
81
85
  /** A node moved into (or out of, without a parentId) a container, staying in the same place on the canvas */
82
86
  export const withParent = (node: Node, parentId: string | undefined, nodes: Node[]): Node => {
83
87
  const lookup = byId(nodes);
@@ -3,9 +3,9 @@ import { z } from 'zod';
3
3
  import type * as Y from 'yjs';
4
4
  import { initializeWebMCPPolyfill } from '@mcp-b/webmcp-polyfill';
5
5
  import { replyToThread, setThreadResolved, type Author, CANVAS_STATUSES, setCanvasStatus } from '../canvas-doc';
6
+ import { CATALOG_COLLECTIONS } from '../node-types';
6
7
  import {
7
8
  ADD_TO_CANVAS_DESCRIPTION,
8
- CATALOG_COLLECTIONS,
9
9
  describeCanvas,
10
10
  edgeSpecsSchema,
11
11
  nodeSpecsSchema,
@@ -0,0 +1,20 @@
1
+ import { useLayoutEffect, type RefObject } from 'react';
2
+ import { useStoreApi } from '@xyflow/react';
3
+
4
+ /** 1 / zoom, for things drawn in the canvas's coordinates that stay the same size on screen (`scale(var(...))`) */
5
+ export const COUNTER_SCALE = '--studio-counter-scale';
6
+
7
+ /**
8
+ * Keeps COUNTER_SCALE on an element as the canvas zooms, outside React: what's counter-scaled with it (e.g. comment
9
+ * pins) stays the same size on screen without re-rendering on every frame of a zoom
10
+ */
11
+ export function useCounterScale(ref: RefObject<HTMLElement | null>) {
12
+ const store = useStoreApi();
13
+ useLayoutEffect(() => {
14
+ const apply = (zoom: number) => ref.current?.style.setProperty(COUNTER_SCALE, String(1 / zoom));
15
+ apply(store.getState().transform[2]);
16
+ return store.subscribe((state, previous) => {
17
+ if (state.transform[2] !== previous.transform[2]) apply(state.transform[2]);
18
+ });
19
+ }, [store, ref]);
20
+ }
@@ -1,5 +1,6 @@
1
1
  import { useCallback, useEffect, useRef } from 'react';
2
2
  import { getViewportForBounds, useReactFlow, useStore, type Rect } from '@xyflow/react';
3
+ import { DIAGRAM_FIT_VIEW_OPTIONS } from '@eventcatalog/visualiser';
3
4
  import type { Peer } from './presence-store';
4
5
 
5
6
  /**
@@ -30,10 +31,13 @@ const inView = (rect: Rect, view: Rect, margin: number) =>
30
31
 
31
32
  export function useFollowCamera({
32
33
  enabled,
34
+ fitsContent,
33
35
  agentsWorking,
34
36
  getAgents,
35
37
  }: {
36
38
  enabled: boolean;
39
+ /** Whether it fits the canvas's content when it arrives (the canvas people edit, not a level, fitted on its own) */
40
+ fitsContent: boolean;
37
41
  /** Whether any agent is working on the canvas (has a pointer on it) */
38
42
  agentsWorking: boolean;
39
43
  /** The agents working, with where their pointers are now (read when the camera checks, not on every move) */
@@ -61,11 +65,12 @@ export function useFollowCamera({
61
65
  hasFitContent.current = false;
62
66
  return;
63
67
  }
64
- if (hasFitContent.current || !width) return;
68
+ if (hasFitContent.current || !width || !fitsContent) return;
65
69
  hasFitContent.current = true;
66
- const frame = requestAnimationFrame(() => void fitView({ padding: PADDING, maxZoom: MAX_ZOOM, duration: 300 }));
70
+ // Fitted like the visualiser fits a diagram
71
+ const frame = requestAnimationFrame(() => void fitView({ ...DIAGRAM_FIT_VIEW_OPTIONS, duration: 300 }));
67
72
  return () => cancelAnimationFrame(frame);
68
- }, [hasNodes, width]);
73
+ }, [hasNodes, width, fitsContent]);
69
74
 
70
75
  const follow = useCallback(() => {
71
76
  const { enabled, getAgents } = latest.current;
@@ -25,7 +25,9 @@ import {
25
25
  fitGroupToChildren,
26
26
  getCanvasMaps,
27
27
  moveNode,
28
+ placeNode,
28
29
  changesNodesOrEdges,
30
+ getStructureKey,
29
31
  getCanvasStatus,
30
32
  readMeta,
31
33
  reopenIfDecided,
@@ -34,6 +36,10 @@ import {
34
36
  setMeta,
35
37
  type Author,
36
38
  type CanvasStatus,
39
+ type DiagramLevels,
40
+ type LevelPlace,
41
+ readLevelLayout,
42
+ setLevelPlaces,
37
43
  type StatusChange,
38
44
  setNodeParent,
39
45
  type RenderedSizes,
@@ -41,7 +47,7 @@ import {
41
47
  } from '../canvas-doc';
42
48
  import { applyLayout } from '../agent-choreography';
43
49
  import { keepDragged, LOCAL_EDGE_KEYS, LOCAL_NODE_KEYS, patchShared, withPositions } from '../flow-state';
44
- import { findGroupAtPoint, toRelativePosition, type NodePreview } from '../grouping';
50
+ import { containerFor, toRelativePosition, type NodePreview } from '../grouping';
45
51
  import type { LayoutResult } from '../layout';
46
52
  import { canGoInContainer, getNodeDefinition, isGroupType } from '../node-types';
47
53
  import { startToolSync, type ToolSync } from '../tool-sync';
@@ -67,6 +73,10 @@ const LOCAL_ORIGIN = Symbol('studio-local');
67
73
  export const BROWSER_AGENT_ORIGIN = Symbol('studio-browser-agent');
68
74
  /** Status changes: synced, but not undone (undo takes back edits, not decisions about the canvas) */
69
75
  const STATUS_ORIGIN = Symbol('studio-status');
76
+ /** Where L1 and L2 put what's new on them, kept so it stays there: synced, not undone (nobody did it) */
77
+ const LEVEL_PLACEMENT_ORIGIN = Symbol('studio-level-placement');
78
+
79
+ const NO_LEVEL_LAYOUTS = { 1: new Map(), 2: new Map() } as const;
70
80
 
71
81
  export type Status = 'connecting' | 'connected' | 'disconnected';
72
82
  export type Transport = 'websocket' | 'tools';
@@ -121,6 +131,13 @@ export function useStudioFlow({
121
131
  const [doc, setDoc] = useState<Y.Doc | null>(null);
122
132
  const [history, setHistory] = useState({ canUndo: false, canRedo: false });
123
133
  const [title, setTitle] = useState<string | undefined>();
134
+ // L1 and L2 of the diagram the canvas was opened from, if it was
135
+ const [diagramLevels, setDiagramLevels] = useState<DiagramLevels | undefined>();
136
+ // What's on the canvas and how it's connected (getStructureKey): worked out when the document's nodes or edges
137
+ // change, which drags don't do (they're in presence until the drop)
138
+ const [structureKey, setStructureKey] = useState('');
139
+ // Where things are on L1 and L2, as people arranged them
140
+ const [levelLayouts, setLevelLayouts] = useState<Record<1 | 2, ReadonlyMap<string, LevelPlace>>>(NO_LEVEL_LAYOUTS);
124
141
  const [canvasStatus, setCanvasStatusState] = useState<{ status: CanvasStatus; history: StatusChange[] }>(NEW_CANVAS_STATUS);
125
142
  const [transport, setTransport] = useState<Transport>('websocket');
126
143
  // Someone deleted the canvas: the server closed it for everyone, and won't open it again
@@ -139,16 +156,29 @@ export function useStudioFlow({
139
156
  setEdges([]);
140
157
 
141
158
  const doc = new Y.Doc();
142
- const { nodes: yNodes, edges: yEdges, threads: yThreads, meta: yMeta } = getCanvasMaps(doc);
159
+ const { nodes: yNodes, edges: yEdges, threads: yThreads, meta: yMeta, levelLayouts: yLevelLayouts } = getCanvasMaps(doc);
160
+ const syncLevelLayouts = (event: Y.YMapEvent<LevelPlace>) => {
161
+ const changed = new Set([...event.keysChanged].map((key) => Number(key.split(':')[0]) as 1 | 2));
162
+ setLevelLayouts((current) => {
163
+ const next = { ...current };
164
+ changed.forEach((level) => (next[level] = readLevelLayout(doc, level)));
165
+ return next;
166
+ });
167
+ };
168
+ yLevelLayouts.observe(syncLevelLayouts);
169
+ setLevelLayouts(NO_LEVEL_LAYOUTS);
143
170
  const syncMeta = (event: Y.YMapEvent<unknown>) => {
144
171
  const meta = readMeta(doc);
145
172
  setTitle(meta.title);
173
+ if (event.keysChanged.has('diagramLevels')) setDiagramLevels(meta.diagramLevels);
146
174
  if (event.keysChanged.has('status') || event.keysChanged.has('statusHistory'))
147
175
  setCanvasStatusState({ status: getCanvasStatus(meta), history: meta.statusHistory ?? [] });
148
176
  if (event.keysChanged.has('layoutAt')) onLayoutRef.current?.();
149
177
  };
150
178
  yMeta.observe(syncMeta);
151
179
  setTitle(undefined);
180
+ setStructureKey('');
181
+ setDiagramLevels(undefined);
152
182
  setCanvasStatusState(NEW_CANVAS_STATUS);
153
183
  // Changing an accepted (or rejected) canvas here makes it a draft again, for everyone. Changes from others arrive
154
184
  // already reopened by whoever made them.
@@ -169,6 +199,12 @@ export function useStudioFlow({
169
199
  );
170
200
  };
171
201
  doc.on('afterTransaction', reopenOnEdit);
202
+ const syncStructure = (transaction: Y.Transaction) => {
203
+ // Drops and resizes here only move and size things (putting something in a container isn't LOCAL_ORIGIN)
204
+ if (transaction.origin !== LOCAL_ORIGIN && changesNodesOrEdges(transaction))
205
+ setStructureKey(getStructureKey(Array.from(yNodes.values()), Array.from(yEdges.values())));
206
+ };
207
+ doc.on('afterTransaction', syncStructure);
172
208
  // One presence for whichever connection is used
173
209
  const awareness = new Awareness(doc);
174
210
  awareness.setLocalStateField('user', user.current);
@@ -249,7 +285,7 @@ export function useStudioFlow({
249
285
  // A socket that isn't let in within a few seconds (blocked, or never answered) is given up for tool calls
250
286
  if (syncViaTools) fallback = setTimeout(() => !socketConnected && syncThroughTools(), SOCKET_TIMEOUT_MS);
251
287
  // Undo only takes back your own changes: other people's arrive with the provider as origin, agents' with theirs
252
- const undoManager = new Y.UndoManager([yNodes, yEdges, yThreads], {
288
+ const undoManager = new Y.UndoManager([yNodes, yEdges, yThreads, yLevelLayouts], {
253
289
  captureTimeout: 500,
254
290
  trackedOrigins: new Set([null, LOCAL_ORIGIN]),
255
291
  });
@@ -329,9 +365,11 @@ export function useStudioFlow({
329
365
  sender.destroy();
330
366
  stopToolSync?.();
331
367
  yNodes.unobserve(syncNodes);
368
+ yLevelLayouts.unobserve(syncLevelLayouts);
332
369
  yEdges.unobserve(syncEdges);
333
370
  yMeta.unobserve(syncMeta);
334
371
  doc.off('afterTransaction', reopenOnEdit);
372
+ doc.off('afterTransaction', syncStructure);
335
373
  presenceStore.destroy();
336
374
  undoManager.destroy();
337
375
  provider.destroy();
@@ -384,10 +422,10 @@ export function useStudioFlow({
384
422
  dragged.current.forEach((position, id) => moveNode(doc, id, position));
385
423
  // Containers as they grew around what was dragged, and what else is in them (moved the other way if they grew up
386
424
  // or left). Dragged nodes are where React Flow dropped them, already in their containers' grown coordinates.
387
- dragPreview.current.forEach(({ position, width, height }, id) => {
388
- const node = getCanvasMaps(doc).nodes.get(id);
389
- if (node) getCanvasMaps(doc).nodes.set(id, { ...node, position, ...(width !== undefined && { width, height }) });
390
- });
425
+ // Only what actually moved or grew is written (the preview has everything in those containers)
426
+ dragPreview.current.forEach(({ position, width, height }, id) =>
427
+ placeNode(doc, id, position, width !== undefined && height !== undefined ? { width, height } : undefined)
428
+ );
391
429
  dragPreview.current = new Map();
392
430
  dragged.current.clear();
393
431
  const shared = sharedRef.current;
@@ -474,12 +512,18 @@ export function useStudioFlow({
474
512
  * on the canvas, and returns edges to add with it (e.g. a catalog resource's relationships).
475
513
  */
476
514
  const insertNode = useCallback(
477
- (type: string, data: Record<string, unknown>, center: XYPosition, connect?: (id: string, existing: Node[]) => Edge[]) => {
515
+ (
516
+ type: string,
517
+ data: Record<string, unknown>,
518
+ center: XYPosition,
519
+ connect?: (id: string, existing: Node[]) => Edge[],
520
+ inside?: string
521
+ ) => {
478
522
  const built = buildNode(type, data, center);
479
523
  withDoc((doc) => {
480
524
  const existing = Array.from(getCanvasMaps(doc).nodes.values());
481
- // Dropped on a container: it goes in it
482
- const group = canGoInContainer(type) ? findGroupAtPoint(center, existing) : undefined;
525
+ // In the container asked for, else the one it's dropped on
526
+ const group = canGoInContainer(type) ? containerFor(center, existing, inside) : undefined;
483
527
  const node = group
484
528
  ? { ...built, parentId: group.id, position: toRelativePosition(built.position, group.id, existing) }
485
529
  : built;
@@ -491,11 +535,11 @@ export function useStudioFlow({
491
535
  []
492
536
  );
493
537
 
494
- /** Add a node of a registry type, centred on a canvas position */
538
+ /** Add a node of a registry type, centred on a canvas position (in a container, if asked) */
495
539
  const addNode = useCallback(
496
- (type: string, center: XYPosition) => {
540
+ (type: string, center: XYPosition, inside?: string) => {
497
541
  const definition = getNodeDefinition(type);
498
- return definition ? insertNode(type, definition.createData(), center) : undefined;
542
+ return definition ? insertNode(type, definition.createData(), center, undefined, inside) : undefined;
499
543
  },
500
544
  [insertNode]
501
545
  );
@@ -510,6 +554,12 @@ export function useStudioFlow({
510
554
 
511
555
  /** Moves nodes (and resizes containers) from a layout in one change, so it undoes in one go */
512
556
  const applyPositions = useCallback((layout: LayoutResult) => withDoc((doc) => applyLayout(doc, layout)), []);
557
+ /** Puts things where they go on L1 or L2: moved there by someone (undone like any edit), or placed by the level */
558
+ const setLevelLayout = useCallback(
559
+ (level: 1 | 2, places: ReadonlyMap<string, LevelPlace>, by: 'person' | 'level' = 'person') =>
560
+ withDoc((doc) => setLevelPlaces(doc, level, places), by === 'level' ? LEVEL_PLACEMENT_ORIGIN : null),
561
+ []
562
+ );
513
563
 
514
564
  /** Adds ready-made nodes and edges (e.g. a container with what's in it) in one change */
515
565
  const insertNodes = useCallback(
@@ -572,9 +622,13 @@ export function useStudioFlow({
572
622
  updateNodeData,
573
623
  deleteNodes,
574
624
  applyPositions,
625
+ levelLayouts,
626
+ setLevelLayout,
575
627
  insertNodes,
576
628
  setParent,
577
629
  title,
630
+ diagramLevels,
631
+ structureKey,
578
632
  rename,
579
633
  canvasStatus,
580
634
  changeStatus,
@@ -1,5 +1,6 @@
1
1
  import type { Edge, Node, XYPosition } from '@xyflow/react';
2
2
  import { layoutWithElk } from '@eventcatalog/visualiser/layout';
3
+ import { getEdgeRoute, type EdgeRoute } from './canvas-doc';
3
4
  import { isGroupType, isOnLevel } from './node-types';
4
5
 
5
6
  /**
@@ -17,26 +18,39 @@ export const withGroupSizes = (nodes: Node[]) =>
17
18
  return { ...node, width, height, style };
18
19
  });
19
20
 
20
- export type LayoutResult = Map<string, { position: XYPosition; size?: { width: number; height: number } }>;
21
+ export type LayoutResult = {
22
+ /** Where each node goes (relative to its container, if it's in one), and containers' new sizes */
23
+ nodes: Map<string, { position: XYPosition; size?: { width: number; height: number } }>;
24
+ /** How each connection is drawn (around the nodes), by edge id */
25
+ routes: Map<string, EdgeRoute>;
26
+ };
21
27
 
22
28
  /**
23
- * New positions (relative to their container, if they're in one) for the nodes on a canvas, and new sizes for
24
- * containers. Only these are kept for the canvas people edit: the edge routes ELK draws would be out of date
25
- * as soon as someone moves a node. Notes added on L1 or L2 aren't on this canvas, so they stay where they are.
29
+ * The canvas laid out: new positions for its nodes, new sizes for containers, and routes for the connections
30
+ * (drawn like the visualiser's: around the nodes, following them when they're moved, and as a plain step once
31
+ * they've moved too far for it). Notes added on L1 or L2 aren't on this canvas, so they stay where they are.
26
32
  */
27
33
  export const getLayoutPositions = async (nodes: Node[], edges: Edge[]): Promise<LayoutResult> => {
28
34
  const onCanvas = nodes.filter((node) => isOnLevel(node, 3));
29
- if (onCanvas.length === 0) return new Map();
35
+ if (onCanvas.length === 0) return { nodes: new Map(), routes: new Map() };
30
36
  const ids = new Set(onCanvas.map((node) => node.id));
31
37
  const between = edges.filter((edge) => ids.has(edge.source) && ids.has(edge.target));
32
- const laidOut = withGroupSizes((await layoutGraph(onCanvas, between)).nodes);
33
- return new Map(
34
- laidOut.map((node) => [
35
- node.id,
36
- {
37
- position: node.position,
38
- ...(isGroupType(node.type) && node.width && node.height && { size: { width: node.width, height: node.height } }),
39
- },
40
- ])
41
- );
38
+ const laidOut = await layoutGraph(onCanvas, between);
39
+ return {
40
+ nodes: new Map(
41
+ withGroupSizes(laidOut.nodes).map((node) => [
42
+ node.id,
43
+ {
44
+ position: node.position,
45
+ ...(isGroupType(node.type) && node.width && node.height && { size: { width: node.width, height: node.height } }),
46
+ },
47
+ ])
48
+ ),
49
+ routes: new Map(
50
+ laidOut.edges.flatMap((edge) => {
51
+ const route = getEdgeRoute(edge);
52
+ return route ? [[edge.id, route] as const] : [];
53
+ })
54
+ ),
55
+ };
42
56
  };