@eventcatalog/core 5.0.0-beta.7 → 5.0.0-beta.8

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 (85) 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 +3 -1
  4. package/dist/analytics/log-build.js +4 -4
  5. package/dist/{chunk-BOKAG5XC.js → chunk-4W55ZOOS.js} +2 -2
  6. package/dist/{chunk-PIYJM642.js → chunk-AWOO63ME.js} +70 -44
  7. package/dist/{chunk-3VBB2ZMP.js → chunk-C6T2AAFR.js} +1 -1
  8. package/dist/{chunk-MDWB5HLO.js → chunk-ENAG6UH7.js} +5 -0
  9. package/dist/{chunk-S7ODHVII.js → chunk-PU25TOOJ.js} +1 -1
  10. package/dist/{chunk-FARBIZ7Y.js → chunk-RDIZJ7UT.js} +1 -1
  11. package/dist/{chunk-SJJKWZYZ.js → chunk-T4G3VVZ4.js} +1 -1
  12. package/dist/constants.cjs +1 -1
  13. package/dist/constants.js +1 -1
  14. package/dist/eventcatalog.cjs +138 -90
  15. package/dist/eventcatalog.config.d.cts +47 -0
  16. package/dist/eventcatalog.config.d.ts +47 -0
  17. package/dist/eventcatalog.js +30 -11
  18. package/dist/features.cjs +8 -2
  19. package/dist/features.d.cts +3 -1
  20. package/dist/features.d.ts +3 -1
  21. package/dist/features.js +5 -3
  22. package/dist/generate.cjs +1 -1
  23. package/dist/generate.js +3 -3
  24. package/dist/utils/cli-logger.cjs +1 -1
  25. package/dist/utils/cli-logger.js +2 -2
  26. package/dist/utils/license-status.cjs +70 -44
  27. package/dist/utils/license-status.d.cts +14 -5
  28. package/dist/utils/license-status.d.ts +14 -5
  29. package/dist/utils/license-status.js +1 -1
  30. package/eventcatalog/integrations/studio-server.mjs +15 -3
  31. package/eventcatalog/src/components/Header.astro +3 -3
  32. package/eventcatalog/src/features/integrations/eventcatalog-features.ts +10 -2
  33. package/eventcatalog/src/features/mcp/apps/canvas/shared.ts +9 -1
  34. package/eventcatalog/src/features/mcp/apps/canvas/view.tsx +9 -2
  35. package/eventcatalog/src/features/mcp/apps/generated/canvas.html +70 -69
  36. package/eventcatalog/src/features/mcp/apps/generated/viewer.html +69 -69
  37. package/eventcatalog/src/features/mcp/mcp-server.ts +5 -3
  38. package/eventcatalog/src/features/studio/agent-choreography.ts +8 -5
  39. package/eventcatalog/src/features/studio/canvas-actions.ts +148 -21
  40. package/eventcatalog/src/features/studio/canvas-doc.ts +138 -9
  41. package/eventcatalog/src/features/studio/canvas-preview.ts +53 -0
  42. package/eventcatalog/src/features/studio/catalog-resources.ts +5 -2
  43. package/eventcatalog/src/features/studio/clipboard.ts +97 -0
  44. package/eventcatalog/src/features/studio/components/CanvasControls.tsx +4 -3
  45. package/eventcatalog/src/features/studio/components/CanvasHeader.tsx +39 -22
  46. package/eventcatalog/src/features/studio/components/CanvasStatusMenu.tsx +117 -0
  47. package/eventcatalog/src/features/studio/components/Comments.tsx +345 -30
  48. package/eventcatalog/src/features/studio/components/ConnectAgentDialog.tsx +140 -76
  49. package/eventcatalog/src/features/studio/components/ConnectionsChooser.tsx +144 -0
  50. package/eventcatalog/src/features/studio/components/ContainerChooser.tsx +1 -1
  51. package/eventcatalog/src/features/studio/components/DragGhost.tsx +115 -0
  52. package/eventcatalog/src/features/studio/components/JoinForm.tsx +0 -24
  53. package/eventcatalog/src/features/studio/components/LeftPanel.tsx +131 -35
  54. package/eventcatalog/src/features/studio/components/Picture.tsx +21 -0
  55. package/eventcatalog/src/features/studio/components/Presence.tsx +2 -0
  56. package/eventcatalog/src/features/studio/components/PropertiesPanel.tsx +187 -50
  57. package/eventcatalog/src/features/studio/components/ShareDialog.tsx +260 -0
  58. package/eventcatalog/src/features/studio/components/StudioDesigner.tsx +442 -40
  59. package/eventcatalog/src/features/studio/components/canvas-nodes.tsx +198 -22
  60. package/eventcatalog/src/features/studio/components/icons.ts +6 -1
  61. package/eventcatalog/src/features/studio/components/status.ts +30 -1
  62. package/eventcatalog/src/features/studio/edges.ts +2 -0
  63. package/eventcatalog/src/features/studio/grouping.ts +95 -2
  64. package/eventcatalog/src/features/studio/hooks/presence-store.ts +5 -1
  65. package/eventcatalog/src/features/studio/hooks/use-canvas-webmcp.ts +13 -3
  66. package/eventcatalog/src/features/studio/hooks/use-comments.ts +8 -1
  67. package/eventcatalog/src/features/studio/hooks/use-follow-camera.ts +7 -2
  68. package/eventcatalog/src/features/studio/hooks/use-studio-flow.ts +174 -27
  69. package/eventcatalog/src/features/studio/identity.ts +35 -0
  70. package/eventcatalog/src/features/studio/layout.ts +7 -4
  71. package/eventcatalog/src/features/studio/levels.ts +138 -72
  72. package/eventcatalog/src/features/studio/node-types.ts +35 -3
  73. package/eventcatalog/src/features/studio/pages/[id].astro +7 -2
  74. package/eventcatalog/src/features/studio/pages/index.astro +555 -2
  75. package/eventcatalog/src/features/studio/pages/new.astro +24 -0
  76. package/eventcatalog/src/features/studio/pages/wake.ts +15 -0
  77. package/eventcatalog/src/features/studio/server/canvas-mcp.ts +48 -13
  78. package/eventcatalog/src/features/studio/server/runtime.ts +193 -14
  79. package/eventcatalog/src/features/studio/server/sign-in.ts +18 -0
  80. package/eventcatalog/src/features/studio/server/storage.ts +110 -0
  81. package/eventcatalog/src/features/studio/studio.css +184 -0
  82. package/eventcatalog/src/layouts/VerticalSideBarLayout.astro +1 -1
  83. package/eventcatalog/src/utils/feature.ts +7 -3
  84. package/eventcatalog/src/utils/node-graphs/export-mermaid.ts +0 -2
  85. package/package.json +4 -4
@@ -19,20 +19,30 @@ import {
19
19
  connectNodes,
20
20
  deleteEdges,
21
21
  deleteNodes as deleteNodesInDoc,
22
+ reconnectEdge,
23
+ fitContainersAround,
22
24
  fitGroupToChildren,
23
25
  getCanvasMaps,
24
26
  moveNode,
27
+ changesNodesOrEdges,
28
+ getCanvasStatus,
25
29
  readMeta,
30
+ reopenIfDecided,
26
31
  resizeNode,
32
+ setCanvasStatus,
27
33
  setMeta,
34
+ type Author,
35
+ type CanvasStatus,
36
+ type StatusChange,
28
37
  setNodeParent,
38
+ type RenderedSizes,
29
39
  updateNodeData as updateNodeDataInDoc,
30
40
  } from '../canvas-doc';
31
41
  import { applyLayout } from '../agent-choreography';
32
42
  import { keepDragged, LOCAL_EDGE_KEYS, LOCAL_NODE_KEYS, patchShared, withPositions } from '../flow-state';
33
- import { findGroupAtPoint, toRelativePosition } from '../grouping';
43
+ import { findGroupAtPoint, toRelativePosition, type NodePreview } from '../grouping';
34
44
  import type { LayoutResult } from '../layout';
35
- import { getNodeDefinition } from '../node-types';
45
+ import { canGoInContainer, getNodeDefinition, isGroupType } from '../node-types';
36
46
  import { startToolSync, type ToolSync } from '../tool-sync';
37
47
  import { createPresenceSender, type PresenceSender } from './presence-sender';
38
48
  import { createPresenceStore, type PresenceStore } from './presence-store';
@@ -54,6 +64,8 @@ const DROP_SETTLE_MS = 500;
54
64
  const LOCAL_ORIGIN = Symbol('studio-local');
55
65
  /** Changes an agent working through this tab (WebMCP) makes: synced like anyone's, and not in the user's undo */
56
66
  export const BROWSER_AGENT_ORIGIN = Symbol('studio-browser-agent');
67
+ /** Status changes: synced, but not undone (undo takes back edits, not decisions about the canvas) */
68
+ const STATUS_ORIGIN = Symbol('studio-status');
57
69
 
58
70
  export type Status = 'connecting' | 'connected' | 'disconnected';
59
71
  export type Transport = 'websocket' | 'tools';
@@ -69,23 +81,34 @@ type Shared = { doc: Y.Doc; awareness: Awareness; undoManager: Y.UndoManager; se
69
81
  * where they're dropped is saved in the document: one change per drag, so the document doesn't grow with every
70
82
  * step of every drag, and undo takes back the whole drag. Others show where we're dragging them as we do.
71
83
  */
84
+ const NEW_CANVAS_STATUS = { status: 'draft' as CanvasStatus, history: [] as StatusChange[] };
85
+
72
86
  export function useStudioFlow({
73
87
  canvasId,
74
88
  socketUrl,
75
89
  name,
76
90
  color,
91
+ picture,
77
92
  syncViaTools,
78
93
  onLayout,
94
+ onContainerResize,
79
95
  }: {
80
96
  canvasId: string;
81
97
  /** e.g. wss://catalog.example.com/_eventcatalog/studio */
82
98
  socketUrl: string;
83
99
  name: string;
84
100
  color: string;
101
+ /** From your sign-in provider, when you're signed in */
102
+ picture?: string;
85
103
  /** Sync through MCP tool calls when the WebSocket can't connect (e.g. inside a chat's sandbox) */
86
104
  syncViaTools?: ToolSync;
87
105
  /** Someone laid the canvas out (us included): called as the new positions arrive, so they can glide there */
88
106
  onLayout?: () => void;
107
+ /**
108
+ * A container changed size (it grew to fit what's in it, or someone else resized it): called as it arrives, so
109
+ * it can grow smoothly. Not for our own resizing, which follows the pointer.
110
+ */
111
+ onContainerResize?: () => void;
89
112
  }) {
90
113
  const sharedRef = useRef<Shared | null>(null);
91
114
  const [nodes, setNodes] = useState<Node[]>([]);
@@ -97,11 +120,14 @@ export function useStudioFlow({
97
120
  const [doc, setDoc] = useState<Y.Doc | null>(null);
98
121
  const [history, setHistory] = useState({ canUndo: false, canRedo: false });
99
122
  const [title, setTitle] = useState<string | undefined>();
123
+ const [canvasStatus, setCanvasStatusState] = useState<{ status: CanvasStatus; history: StatusChange[] }>(NEW_CANVAS_STATUS);
100
124
  const [transport, setTransport] = useState<Transport>('websocket');
101
- const user = useRef({ name, color });
102
- user.current = { name, color };
125
+ const user = useRef<Author>({ name, color });
126
+ user.current = { name, color, ...(picture && { picture }) };
103
127
  const onLayoutRef = useRef(onLayout);
104
128
  onLayoutRef.current = onLayout;
129
+ const onContainerResizeRef = useRef(onContainerResize);
130
+ onContainerResizeRef.current = onContainerResize;
105
131
 
106
132
  useEffect(() => {
107
133
  // A new connection starts from what the server has. Clear anything left from a previous one,
@@ -112,31 +138,81 @@ export function useStudioFlow({
112
138
  const doc = new Y.Doc();
113
139
  const { nodes: yNodes, edges: yEdges, threads: yThreads, meta: yMeta } = getCanvasMaps(doc);
114
140
  const syncMeta = (event: Y.YMapEvent<unknown>) => {
115
- setTitle(readMeta(doc).title);
141
+ const meta = readMeta(doc);
142
+ setTitle(meta.title);
143
+ if (event.keysChanged.has('status') || event.keysChanged.has('statusHistory'))
144
+ setCanvasStatusState({ status: getCanvasStatus(meta), history: meta.statusHistory ?? [] });
116
145
  if (event.keysChanged.has('layoutAt')) onLayoutRef.current?.();
117
146
  };
118
147
  yMeta.observe(syncMeta);
119
148
  setTitle(undefined);
149
+ setCanvasStatusState(NEW_CANVAS_STATUS);
150
+ // Changing an accepted (or rejected) canvas here makes it a draft again, for everyone. Changes from others arrive
151
+ // already reopened by whoever made them.
152
+ const reopenOnEdit = (transaction: Y.Transaction) => {
153
+ if (!transaction.local || transaction.origin === STATUS_ORIGIN) return;
154
+ if (!changesNodesOrEdges(transaction)) return;
155
+ // Recorded against whoever changed it: this person, or their agent in the browser (WebMCP)
156
+ const by =
157
+ transaction.origin === BROWSER_AGENT_ORIGIN
158
+ ? { name: `${user.current.name}'s agent`, color: user.current.color, agent: true }
159
+ : user.current;
160
+ queueMicrotask(() =>
161
+ doc.transact(() => {
162
+ reopenIfDecided(doc, by);
163
+ // The first thing put on a canvas started it (opening a new one to look around doesn't)
164
+ if (!readMeta(doc).createdAt) setMeta(doc, { createdAt: Date.now(), createdBy: by.name });
165
+ }, STATUS_ORIGIN)
166
+ );
167
+ };
168
+ doc.on('afterTransaction', reopenOnEdit);
120
169
  // One presence for whichever connection is used
121
170
  const awareness = new Awareness(doc);
122
171
  awareness.setLocalStateField('user', user.current);
123
172
  const presenceStore = createPresenceStore(awareness);
124
173
  setPresence(presenceStore);
125
174
  const sender = createPresenceSender(awareness);
175
+ // Let in by the server, not just open: with sign-in on, it refuses a socket without a session after it opens.
176
+ // `letIn` is for this connection; `socketConnected` stays once the socket has worked (no falling back after).
126
177
  let socketConnected = false;
178
+ let letIn = false;
127
179
  let stopToolSync: (() => void) | undefined;
180
+ let fallback: ReturnType<typeof setTimeout> | undefined;
181
+ // Declared before the provider: it reports its first status while it's being created
182
+ let socketStatus: Status = 'connecting';
183
+ // Some views can't use the WebSocket (a chat's sandbox may block it, or open it without the person's session):
184
+ // sync through tool calls instead
185
+ const syncThroughTools = () => {
186
+ if (!syncViaTools || stopToolSync) return;
187
+ clearTimeout(fallback);
188
+ setTransport('tools');
189
+ stopToolSync = startToolSync(doc, awareness, { canvasId, sync: syncViaTools, onStatus: setStatus });
190
+ // After the tools take over, so the socket closing doesn't show as offline for a moment
191
+ provider.disconnect();
192
+ };
128
193
  const provider = new HocuspocusProvider({
129
194
  url: socketUrl,
130
195
  name: canvasDocumentName(canvasId),
131
196
  document: doc,
132
197
  awareness,
133
198
  onStatus: ({ status }) => {
134
- if (status === 'connected') socketConnected = true;
135
199
  socketStatus = status as Status;
136
- if (!stopToolSync) setStatus(status as Status);
200
+ if (status !== 'connected') letIn = false;
201
+ // Open isn't connected until the server lets us in (onAuthenticated)
202
+ if (!stopToolSync) setStatus(status === 'connected' && !letIn ? 'connecting' : (status as Status));
203
+ },
204
+ onAuthenticated: () => {
205
+ socketConnected = letIn = true;
206
+ if (!stopToolSync) setStatus('connected');
207
+ },
208
+ // Not signed in (or the session ran out): a chat's view syncs through its tools, a page shows it's offline
209
+ // (coming back to the tab tries again, e.g. after signing in again elsewhere)
210
+ onAuthenticationFailed: () => {
211
+ if (syncViaTools) return syncThroughTools();
212
+ provider.disconnect();
213
+ setStatus('disconnected');
137
214
  },
138
215
  });
139
- let socketStatus: Status = 'connecting';
140
216
  setTransport('websocket');
141
217
 
142
218
  // Back online, or back on the tab: reconnect now rather than when the next retry is due
@@ -152,15 +228,8 @@ export function useStudioFlow({
152
228
  window.addEventListener('online', reconnect);
153
229
  document.addEventListener('visibilitychange', onVisibilityChange);
154
230
 
155
- // Some views can't open the WebSocket (a chat's sandbox may block it): sync through tool calls instead
156
- const fallback = syncViaTools
157
- ? setTimeout(() => {
158
- if (socketConnected) return;
159
- provider.disconnect();
160
- setTransport('tools');
161
- stopToolSync = startToolSync(doc, awareness, { canvasId, sync: syncViaTools, onStatus: setStatus });
162
- }, SOCKET_TIMEOUT_MS)
163
- : undefined;
231
+ // A socket that isn't let in within a few seconds (blocked, or never answered) is given up for tool calls
232
+ if (syncViaTools) fallback = setTimeout(() => !socketConnected && syncThroughTools(), SOCKET_TIMEOUT_MS);
164
233
  // Undo only takes back your own changes: other people's arrive with the provider as origin, agents' with theirs
165
234
  const undoManager = new Y.UndoManager([yNodes, yEdges, yThreads], {
166
235
  captureTimeout: 500,
@@ -209,6 +278,16 @@ export function useStudioFlow({
209
278
  if (event.transaction.origin === LOCAL_ORIGIN) return;
210
279
  // A node someone was dragging changed in the document: they dropped it there
211
280
  event.keysChanged.forEach((id) => remoteMoves.delete(id));
281
+ const containerResized = [...event.changes.keys].some(([id, change]) => {
282
+ const next = yNodes.get(id);
283
+ const previous = change.oldValue as Node | undefined;
284
+ return (
285
+ change.action === 'update' &&
286
+ isGroupType(next?.type) &&
287
+ (next?.width !== previous?.width || next?.height !== previous?.height)
288
+ );
289
+ });
290
+ if (containerResized) onContainerResizeRef.current?.();
212
291
  setNodes((local) =>
213
292
  keepDragged(
214
293
  patchShared(local, event.keysChanged, (id) => yNodes.get(id), LOCAL_NODE_KEYS),
@@ -234,6 +313,7 @@ export function useStudioFlow({
234
313
  yNodes.unobserve(syncNodes);
235
314
  yEdges.unobserve(syncEdges);
236
315
  yMeta.unobserve(syncMeta);
316
+ doc.off('afterTransaction', reopenOnEdit);
237
317
  presenceStore.destroy();
238
318
  undoManager.destroy();
239
319
  provider.destroy();
@@ -245,8 +325,8 @@ export function useStudioFlow({
245
325
  }, [canvasId, socketUrl, syncViaTools]);
246
326
 
247
327
  useEffect(() => {
248
- sharedRef.current?.awareness.setLocalStateField('user', { name, color });
249
- }, [name, color]);
328
+ sharedRef.current?.awareness.setLocalStateField('user', user.current);
329
+ }, [name, color, picture]);
250
330
 
251
331
  // Tell others what we have selected so they can see it
252
332
  const selectionKey = useMemo(
@@ -261,16 +341,36 @@ export function useStudioFlow({
261
341
  sharedRef.current?.sender.send({ selection: selectionKey ? selectionKey.split(',') : [] });
262
342
  }, [selectionKey]);
263
343
 
344
+ // The sizes nodes are rendered at here, for fitting containers to what's in them
345
+ const latestNodes = useRef(nodes);
346
+ latestNodes.current = nodes;
347
+ const renderedSizes = (): RenderedSizes =>
348
+ new Map(
349
+ latestNodes.current.flatMap((node) =>
350
+ node.measured?.width && node.measured.height
351
+ ? [[node.id, { width: node.measured.width, height: node.measured.height }] as const]
352
+ : []
353
+ )
354
+ );
355
+
264
356
  const withDoc = <T>(fn: (doc: Y.Doc) => T, origin: unknown = null) => {
265
357
  const doc = sharedRef.current?.doc;
266
358
  return doc ? doc.transact(() => fn(doc), origin) : undefined;
267
359
  };
268
360
 
269
- // Where the nodes being dragged are, until they're dropped
361
+ // Where the nodes being dragged are, until they're dropped, and the containers around them as they've grown
270
362
  const dragged = useRef(new Map<string, XYPosition>());
363
+ const dragPreview = useRef(new Map<string, NodePreview>());
271
364
  /** Saves where dragged nodes were dropped (in the transaction it's called in), and stops showing them as dragged */
272
365
  const saveDrop = (doc: Y.Doc) => {
273
366
  dragged.current.forEach((position, id) => moveNode(doc, id, position));
367
+ // Containers as they grew around what was dragged, and what else is in them (moved the other way if they grew up
368
+ // or left). Dragged nodes are where React Flow dropped them, already in their containers' grown coordinates.
369
+ dragPreview.current.forEach(({ position, width, height }, id) => {
370
+ const node = getCanvasMaps(doc).nodes.get(id);
371
+ if (node) getCanvasMaps(doc).nodes.set(id, { ...node, position, ...(width !== undefined && { width, height }) });
372
+ });
373
+ dragPreview.current = new Map();
274
374
  dragged.current.clear();
275
375
  const shared = sharedRef.current;
276
376
  shared?.sender.send({ moving: null });
@@ -281,7 +381,8 @@ export function useStudioFlow({
281
381
  const onNodesChange = useCallback((changes: NodeChange[]) => {
282
382
  setNodes((local) => applyNodeChanges(changes, local));
283
383
  let dropped = false;
284
- const resized: { id: string; dimensions: { width: number; height: number } }[] = [];
384
+ const resized: { id: string; dimensions: { width: number; height: number }; attributes: true | 'width' | 'height' }[] = [];
385
+ const resizeEnded: string[] = [];
285
386
  for (const change of changes) {
286
387
  if (change.type === 'position' && change.position) {
287
388
  dragged.current.set(change.id, change.position);
@@ -289,7 +390,8 @@ export function useStudioFlow({
289
390
  }
290
391
  // Containers resized with their handles
291
392
  if (change.type === 'dimensions' && change.setAttributes && change.dimensions)
292
- resized.push({ id: change.id, dimensions: change.dimensions });
393
+ resized.push({ id: change.id, dimensions: change.dimensions, attributes: change.setAttributes });
394
+ if (change.type === 'dimensions' && change.resizing === false) resizeEnded.push(change.id);
293
395
  }
294
396
  // While dragging, nodes move here every frame and go to everyone with our presence; where they're dropped is saved
295
397
  if (dropped) {
@@ -297,7 +399,14 @@ export function useStudioFlow({
297
399
  sharedRef.current?.undoManager.stopCapturing();
298
400
  withDoc(saveDrop, LOCAL_ORIGIN);
299
401
  } else if (dragged.current.size) sharedRef.current?.sender.send({ moving: Object.fromEntries(dragged.current) });
300
- if (resized.length) withDoc((doc) => resized.forEach(({ id, dimensions }) => resizeNode(doc, id, dimensions)), LOCAL_ORIGIN);
402
+ if (resized.length)
403
+ withDoc(
404
+ (doc) => resized.forEach(({ id, dimensions, attributes }) => resizeNode(doc, id, dimensions, attributes)),
405
+ LOCAL_ORIGIN
406
+ );
407
+ // Resized inside a container (e.g. a system in a domain): the containers around it grow to fit it. Not as
408
+ // LOCAL_ORIGIN, so they grow here too.
409
+ if (resizeEnded.length) withDoc((doc) => resizeEnded.forEach((id) => fitContainersAround(doc, id, renderedSizes())));
301
410
  // Removing a node also removes what's in it, its edges, and moves its comments: those come back through the observers
302
411
  const removed = changes.filter((change) => change.type === 'remove').map((change) => change.id);
303
412
  if (removed.length) withDoc((doc) => deleteNodesInDoc(doc, removed));
@@ -313,6 +422,35 @@ export function useStudioFlow({
313
422
  withDoc((doc) => connectNodes(doc, connection));
314
423
  }, []);
315
424
 
425
+ /** A connection's end dragged to another node */
426
+ const onReconnect = useCallback((edge: Edge, connection: Connection) => {
427
+ withDoc((doc) => reconnectEdge(doc, edge.id, connection));
428
+ }, []);
429
+
430
+ /**
431
+ * Containers growing while something's dragged in them, and what's in them, shown here only until it's dropped
432
+ * (see previewContainerGrowth). The drop saves them as shown, in the same change.
433
+ */
434
+ const showDragPreview = useCallback((previews: Map<string, NodePreview>) => {
435
+ dragPreview.current = previews;
436
+ setNodes((local) => {
437
+ let next: Node[] | undefined;
438
+ local.forEach((node, index) => {
439
+ const preview = previews.get(node.id);
440
+ if (
441
+ !preview ||
442
+ (node.position.x === preview.position.x &&
443
+ node.position.y === preview.position.y &&
444
+ (preview.width === undefined || (node.width === preview.width && node.height === preview.height)))
445
+ )
446
+ return;
447
+ next ??= [...local];
448
+ next[index] = { ...node, ...preview };
449
+ });
450
+ return next ?? local;
451
+ });
452
+ }, []);
453
+
316
454
  /**
317
455
  * Add a node centred on a canvas position. `connect` gets the new node's id and the nodes already
318
456
  * on the canvas, and returns edges to add with it (e.g. a catalog resource's relationships).
@@ -323,12 +461,12 @@ export function useStudioFlow({
323
461
  withDoc((doc) => {
324
462
  const existing = Array.from(getCanvasMaps(doc).nodes.values());
325
463
  // Dropped on a container: it goes in it
326
- const group = findGroupAtPoint(center, existing);
464
+ const group = canGoInContainer(type) ? findGroupAtPoint(center, existing) : undefined;
327
465
  const node = group
328
466
  ? { ...built, parentId: group.id, position: toRelativePosition(built.position, group.id, existing) }
329
467
  : built;
330
468
  addNodes(doc, [node], connect?.(node.id, existing) ?? []);
331
- if (group) fitGroupToChildren(doc, group.id);
469
+ if (group) fitGroupToChildren(doc, group.id, renderedSizes());
332
470
  });
333
471
  return built.id;
334
472
  },
@@ -363,7 +501,7 @@ export function useStudioFlow({
363
501
  // Containers already on the canvas that got something new inside grow to fit it
364
502
  const added = new Set(nodes.map((node) => node.id));
365
503
  new Set(nodes.flatMap((node) => (node.parentId && !added.has(node.parentId) ? [node.parentId] : []))).forEach((groupId) =>
366
- fitGroupToChildren(doc, groupId)
504
+ fitGroupToChildren(doc, groupId, renderedSizes())
367
505
  );
368
506
  }),
369
507
  []
@@ -375,11 +513,16 @@ export function useStudioFlow({
375
513
  withDoc((doc) => {
376
514
  // Where it was dropped goes first, so it's moved into (or out of) the container from there
377
515
  if (dragged.current.size) saveDrop(doc);
378
- return setNodeParent(doc, nodeId, parentId);
516
+ return setNodeParent(doc, nodeId, parentId, renderedSizes());
379
517
  }),
380
518
  []
381
519
  );
382
520
 
521
+ /** Changes the canvas's status for everyone (recorded with who did it, and why if given) */
522
+ const changeStatus = useCallback(
523
+ (status: CanvasStatus, note?: string) => withDoc((doc) => setCanvasStatus(doc, status, user.current, note), STATUS_ORIGIN),
524
+ []
525
+ );
383
526
  const rename = useCallback((next: string) => withDoc((doc) => setMeta(doc, { title: next || undefined })), []);
384
527
 
385
528
  const undo = useCallback(() => sharedRef.current?.undoManager.undo(), []);
@@ -404,6 +547,8 @@ export function useStudioFlow({
404
547
  onNodesChange,
405
548
  onEdgesChange,
406
549
  onConnect,
550
+ onReconnect,
551
+ showDragPreview,
407
552
  addNode,
408
553
  insertNode,
409
554
  updateNodeData,
@@ -413,6 +558,8 @@ export function useStudioFlow({
413
558
  setParent,
414
559
  title,
415
560
  rename,
561
+ canvasStatus,
562
+ changeStatus,
416
563
  transport,
417
564
  undo,
418
565
  redo,
@@ -0,0 +1,35 @@
1
+ // Who you are in Studio: the name you give on your first visit, kept in this browser and used on every canvas
2
+ const COLORS = ['#ef4444', '#f97316', '#eab308', '#22c55e', '#06b6d4', '#3b82f6', '#8b5cf6', '#ec4899'];
3
+ const NAME_KEY = 'eventcatalog-studio-name';
4
+
5
+ export const getStoredName = () => {
6
+ try {
7
+ return localStorage.getItem(NAME_KEY);
8
+ } catch {
9
+ return null;
10
+ }
11
+ };
12
+
13
+ /** Whether it was kept (storage can be blocked, e.g. in some private windows) */
14
+ export const storeName = (name: string) => {
15
+ try {
16
+ localStorage.setItem(NAME_KEY, name);
17
+ return true;
18
+ } catch {
19
+ return false;
20
+ }
21
+ };
22
+
23
+ /** Your name changing in another tab (the event only fires in the other tabs) */
24
+ export const onStoredNameChange = (listener: (name: string | null) => void) => {
25
+ const handle = (event: StorageEvent) => event.key === NAME_KEY && listener(event.newValue);
26
+ window.addEventListener('storage', handle);
27
+ return () => window.removeEventListener('storage', handle);
28
+ };
29
+
30
+ // Same name, same colour across reloads
31
+ export const colorForName = (name: string) => {
32
+ let hash = 0;
33
+ for (const char of name) hash = (hash * 31 + char.charCodeAt(0)) | 0;
34
+ return COLORS[Math.abs(hash) % COLORS.length];
35
+ };
@@ -1,6 +1,6 @@
1
1
  import type { Edge, Node, XYPosition } from '@xyflow/react';
2
2
  import { layoutWithElk } from '@eventcatalog/visualiser/layout';
3
- import { isGroupType } from './node-types';
3
+ import { isGroupType, isOnLevel } from './node-types';
4
4
 
5
5
  /**
6
6
  * Lays a canvas out with the same ELK layout as EventCatalog's visualiser (left to right, edges routed
@@ -22,11 +22,14 @@ export type LayoutResult = Map<string, { position: XYPosition; size?: { width: n
22
22
  /**
23
23
  * New positions (relative to their container, if they're in one) for the nodes on a canvas, and new sizes for
24
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.
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.
26
26
  */
27
27
  export const getLayoutPositions = async (nodes: Node[], edges: Edge[]): Promise<LayoutResult> => {
28
- if (nodes.length === 0) return new Map();
29
- const laidOut = withGroupSizes((await layoutGraph(nodes, edges)).nodes);
28
+ const onCanvas = nodes.filter((node) => isOnLevel(node, 3));
29
+ if (onCanvas.length === 0) return new Map();
30
+ const ids = new Set(onCanvas.map((node) => node.id));
31
+ const between = edges.filter((edge) => ids.has(edge.source) && ids.has(edge.target));
32
+ const laidOut = withGroupSizes((await layoutGraph(onCanvas, between)).nodes);
30
33
  return new Map(
31
34
  laidOut.map((node) => [
32
35
  node.id,