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

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 (92) 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 +4 -4
  6. package/dist/catalog-runtime.cjs +1 -1
  7. package/dist/{chunk-MFEMKQZ6.js → chunk-BO2HHVZ3.js} +1 -1
  8. package/dist/{chunk-4YFM63DS.js → chunk-JPT3QC5V.js} +1 -1
  9. package/dist/{chunk-76DAAJLV.js → chunk-PLTLW45Q.js} +5 -5
  10. package/dist/{chunk-WM4QYMIA.js → chunk-T75DOVFP.js} +1 -1
  11. package/dist/{chunk-LQZ7IG7B.js → chunk-XA5W6DXX.js} +1 -1
  12. package/dist/constants.cjs +1 -1
  13. package/dist/constants.js +1 -1
  14. package/dist/eventcatalog.cjs +2 -2
  15. package/dist/eventcatalog.js +14 -14
  16. package/dist/federation/federate.js +3 -3
  17. package/dist/generate.cjs +1 -1
  18. package/dist/generate.js +3 -3
  19. package/dist/runtime-dependencies/@eventcatalog__visualiser__studio-fonts.css.mjs +3 -0
  20. package/dist/runtime-dependencies/@eventcatalog__visualiser__studio.css.mjs +3 -0
  21. package/dist/runtime-dependencies/@eventcatalog__visualiser__studio.mjs +3 -0
  22. package/dist/runtime-dependencies/manifest.json +3 -0
  23. package/dist/utils/cli-logger.cjs +1 -1
  24. package/dist/utils/cli-logger.js +2 -2
  25. package/eventcatalog/src/components/ChatPanel/ChatPanel.tsx +2 -0
  26. package/eventcatalog/src/components/MDX/NodeGraph/AstroNodeGraph.tsx +42 -3
  27. package/eventcatalog/src/components/MDX/NodeGraph/NodeGraph.astro +5 -1
  28. package/eventcatalog/src/components/NavigationProgress.astro +7 -0
  29. package/eventcatalog/src/features/mcp/apps/architecture-diagram/node-chat.tsx +6 -45
  30. package/eventcatalog/src/features/mcp/apps/canvas/shared.ts +3 -3
  31. package/eventcatalog/src/features/mcp/apps/canvas/styles.css +3 -1
  32. package/eventcatalog/src/features/mcp/apps/canvas/view.tsx +7 -5
  33. package/eventcatalog/src/features/mcp/apps/generated/canvas.html +132 -70
  34. package/eventcatalog/src/features/mcp/apps/generated/viewer.html +71 -71
  35. package/eventcatalog/src/features/studio/agent-choreography.ts +13 -10
  36. package/eventcatalog/src/features/studio/agents.ts +28 -0
  37. package/eventcatalog/src/features/studio/api/canvases-api.ts +64 -15
  38. package/eventcatalog/src/features/studio/api/client.ts +6 -1
  39. package/eventcatalog/src/features/studio/canvas-actions.ts +160 -34
  40. package/eventcatalog/src/features/studio/canvas-preview.ts +1 -2
  41. package/eventcatalog/src/features/studio/catalog.ts +16 -14
  42. package/eventcatalog/src/features/studio/components/CanvasHeader.tsx +263 -115
  43. package/eventcatalog/src/features/studio/components/CanvasStatusMenu.tsx +15 -14
  44. package/eventcatalog/src/features/studio/components/ConnectAgentDialog.tsx +272 -119
  45. package/eventcatalog/src/features/studio/components/ConnectionsChooser.tsx +1 -2
  46. package/eventcatalog/src/features/studio/components/DeleteCanvasDialog.tsx +28 -44
  47. package/eventcatalog/src/features/studio/components/Dialog.tsx +60 -0
  48. package/eventcatalog/src/features/studio/components/JoinForm.tsx +101 -26
  49. package/eventcatalog/src/features/studio/components/LeftPanel.tsx +119 -230
  50. package/eventcatalog/src/features/studio/components/NodeDetails.tsx +183 -0
  51. package/eventcatalog/src/features/studio/components/OpenInStudioDialog.tsx +79 -0
  52. package/eventcatalog/src/features/studio/components/Presence.tsx +52 -45
  53. package/eventcatalog/src/features/studio/components/ShareDialog.tsx +277 -141
  54. package/eventcatalog/src/features/studio/components/ShortcutsDialog.tsx +41 -0
  55. package/eventcatalog/src/features/studio/components/StudioDesigner.tsx +1286 -411
  56. package/eventcatalog/src/features/studio/document-name.ts +7 -0
  57. package/eventcatalog/src/features/studio/from-visualiser.ts +138 -0
  58. package/eventcatalog/src/features/studio/hooks/presence-sender.ts +19 -2
  59. package/eventcatalog/src/features/studio/hooks/use-canvas-webmcp.ts +9 -3
  60. package/eventcatalog/src/features/studio/hooks/use-comments.ts +2 -2
  61. package/eventcatalog/src/features/studio/hooks/use-follow-camera.ts +8 -3
  62. package/eventcatalog/src/features/studio/hooks/use-studio-flow.ts +223 -30
  63. package/eventcatalog/src/features/studio/identity.ts +23 -1
  64. package/eventcatalog/src/features/studio/limits.ts +2 -0
  65. package/eventcatalog/src/features/studio/pages/[id].astro +2 -1
  66. package/eventcatalog/src/features/studio/pages/index.astro +2 -2
  67. package/eventcatalog/src/features/studio/server/canvas-mcp.ts +8 -23
  68. package/eventcatalog/src/features/studio/server/runtime.ts +16 -9
  69. package/eventcatalog/src/utils/node-graphs/domain-levels-node-graph.ts +3 -3
  70. package/eventcatalog/src/utils/node-graphs/node-resource.ts +67 -0
  71. package/eventcatalog/src/utils/node-graphs/systems-node-graph.ts +1 -1
  72. package/package.json +3 -3
  73. package/eventcatalog/src/features/studio/canvas-doc.ts +0 -414
  74. package/eventcatalog/src/features/studio/clipboard.ts +0 -97
  75. package/eventcatalog/src/features/studio/components/CanvasControls.tsx +0 -154
  76. package/eventcatalog/src/features/studio/components/Comments.tsx +0 -770
  77. package/eventcatalog/src/features/studio/components/ContainerChooser.tsx +0 -145
  78. package/eventcatalog/src/features/studio/components/DragGhost.tsx +0 -115
  79. package/eventcatalog/src/features/studio/components/Picture.tsx +0 -21
  80. package/eventcatalog/src/features/studio/components/PropertiesPanel.tsx +0 -212
  81. package/eventcatalog/src/features/studio/components/canvas-nodes.tsx +0 -255
  82. package/eventcatalog/src/features/studio/components/icons.ts +0 -53
  83. package/eventcatalog/src/features/studio/components/status.ts +0 -53
  84. package/eventcatalog/src/features/studio/edges.ts +0 -56
  85. package/eventcatalog/src/features/studio/flow-state.ts +0 -92
  86. package/eventcatalog/src/features/studio/grouping.ts +0 -226
  87. package/eventcatalog/src/features/studio/layout.ts +0 -42
  88. package/eventcatalog/src/features/studio/levels.ts +0 -282
  89. package/eventcatalog/src/features/studio/node-types.ts +0 -205
  90. package/eventcatalog/src/features/studio/placement.ts +0 -197
  91. package/eventcatalog/src/features/studio/studio.css +0 -273
  92. package/dist/{chunk-KHACCTW2.js → chunk-ZEY2XUJN.js} +4 -4
@@ -1,20 +1,104 @@
1
1
  import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type MouseEvent as ReactMouseEvent } from 'react';
2
2
  import {
3
- Background,
3
+ applyNodeChanges,
4
4
  ConnectionLineType,
5
5
  ReactFlow,
6
6
  ReactFlowProvider,
7
7
  useReactFlow,
8
+ type Connection,
8
9
  type Edge,
9
10
  type Node,
11
+ type FitViewOptions,
10
12
  type NodeChange,
11
13
  type XYPosition,
12
14
  } from '@xyflow/react';
13
- import { edgeTypes } from '@eventcatalog/visualiser';
15
+ import { DiagramBackground, edgeTypes, getLegend, LegendPanel, pauseEdgeAnimations } from '@eventcatalog/visualiser';
14
16
  import { useHotkeys } from 'react-hotkeys-hook';
15
17
  import { getCatalogConnections, indexCatalog, planWithConnections, type ConnectionGroup } from '../canvas-actions';
16
- import { isDecided } from '../canvas-doc';
17
- import { CLIPBOARD_TYPE, copyNodes, pasteNodes, readClipboard, type ClipboardContent } from '../clipboard';
18
+ import {
19
+ getDiagramLevel,
20
+ getDiagramLevelUnavailable,
21
+ isDecided,
22
+ type LevelPlace,
23
+ CLIPBOARD_TYPE,
24
+ copyNodes,
25
+ pasteNodes,
26
+ readClipboard,
27
+ type ClipboardContent,
28
+ keepUnchanged,
29
+ LOCAL_EDGE_KEYS,
30
+ LOCAL_NODE_KEYS,
31
+ findDropTargets,
32
+ placeDropped,
33
+ containerFor,
34
+ findGroupAtPoint,
35
+ getAbsolutePosition,
36
+ getAbsoluteRect,
37
+ previewContainerGrowth,
38
+ sizeOf,
39
+ sortByHierarchy,
40
+ toRelativePosition,
41
+ getLayoutPositions,
42
+ layoutGraph,
43
+ getLevelGraph,
44
+ getLevelUnavailableReason,
45
+ LEVELS,
46
+ type Level,
47
+ arrangeLevel,
48
+ getLevelNodeSize,
49
+ levelPlaces,
50
+ withDiagramRoutes,
51
+ canGoInContainer,
52
+ getNodeDefinition,
53
+ getNodeLabel,
54
+ getNodeName,
55
+ getNoteLevel,
56
+ isOnLevel,
57
+ isShownOnLevel,
58
+ isWrittenOnCanvas,
59
+ getResource,
60
+ DropTargetContext,
61
+ nodeTypes,
62
+ UpdateNodeDataContext,
63
+ anchorAt,
64
+ ContextMenu,
65
+ canvasToModel,
66
+ modelToCanvas,
67
+ toDiagramFile,
68
+ readDiagramFile,
69
+ pickDiagramFile,
70
+ downloadFile,
71
+ fileNameFor,
72
+ DIAGRAM_FILE_EXTENSION,
73
+ canvasImage,
74
+ EmptyCanvas,
75
+ type ModelCanvasContent,
76
+ CommentLayer,
77
+ CommentList,
78
+ getAnchorPosition,
79
+ timeAgo,
80
+ type ContextMenuState,
81
+ CANVAS_STATUS_LOOK,
82
+ ContainerChooser,
83
+ type ContainerChoice,
84
+ CanvasControls,
85
+ ViewControls,
86
+ FIT_VIEW_OPTIONS,
87
+ EditButton,
88
+ LockedContext,
89
+ StartWritingContext,
90
+ EditingContext,
91
+ HotspotLayer,
92
+ HotspotList,
93
+ topSeverity,
94
+ isGroupType,
95
+ type Hotspot,
96
+ type HotspotDraft,
97
+ type HotspotFields,
98
+ type HotspotSeverity,
99
+ type HotspotSpot,
100
+ } from '@eventcatalog/visualiser/studio';
101
+ import { X } from 'lucide-react';
18
102
  import type { CatalogRelation, CatalogResource } from '../catalog-resources';
19
103
  import {
20
104
  buildContainer,
@@ -25,39 +109,23 @@ import {
25
109
  getContents,
26
110
  getRelatedEdges,
27
111
  } from '../catalog';
28
- import {
29
- findDropTarget,
30
- findGroupAtPoint,
31
- getAbsolutePosition,
32
- previewContainerGrowth,
33
- sizeOf,
34
- sortByHierarchy,
35
- toRelativePosition,
36
- } from '../grouping';
37
112
  import type { Peer } from '../hooks/presence-store';
38
113
  import { useCanvasWebMcp } from '../hooks/use-canvas-webmcp';
39
114
  import { useStudioFlow } from '../hooks/use-studio-flow';
40
115
  import { useComments, type CommentAnchor, type Thread } from '../hooks/use-comments';
41
- import { getLayoutPositions, layoutGraph } from '../layout';
42
- import { getLevelGraph, getLevelUnavailableReason, LEVELS, type Level } from '../levels';
43
- import { getNodeDefinition, getNodeLabel, getNodeName, getNoteLevel, isOnLevel, isWrittenOnCanvas } from '../node-types';
44
116
  import type { ToolSync } from '../tool-sync';
45
- import CanvasControls, { FIT_VIEW_OPTIONS } from './CanvasControls';
46
117
  import CanvasHeader from './CanvasHeader';
47
- import { DropTargetContext, nodeTypes, UpdateNodeDataContext } from './canvas-nodes';
48
- import { anchorAt, CanvasContextMenu, CommentLayer, getAnchorPosition, timeAgo, type ContextMenuState } from './Comments';
49
- import { CANVAS_STATUS_LOOK } from './status';
50
118
  import ConnectAgentDialog, { enableWebMcp, isRelayEnabled, isWebMcpEnabled, loadRelay } from './ConnectAgentDialog';
51
- import ContainerChooser, { type ContainerChoice } from './ContainerChooser';
52
119
  import ConnectionsChooser, { type ConnectionsChoice } from './ConnectionsChooser';
53
120
  import JoinForm from './JoinForm';
54
- import { colorForName, getStoredName, onStoredNameChange, storeName } from '../identity';
55
- import LeftPanel, { CATALOG_DRAG_TYPE, COMPONENT_DRAG_TYPE } from './LeftPanel';
121
+ import { colorForName, getStoredColor, getStoredName, onStoredNameChange, storeColor, storeName } from '../identity';
122
+ import LeftPanel, { CATALOG_DRAG_TYPE, COMPONENT_DRAG_TYPE, type LeftPanelTab } from './LeftPanel';
56
123
  import { AgentActivity, RemotePresence, ViewportSharer } from './Presence';
57
- import PropertiesPanel from './PropertiesPanel';
124
+ import NodeDetails, { type NodeConnection } from './NodeDetails';
58
125
  import ShareDialog from './ShareDialog';
59
126
  import DeleteCanvasDialog from './DeleteCanvasDialog';
60
- import { deleteCanvasThroughApi } from '../api/client';
127
+ import { createCanvasThroughApi, deleteCanvasThroughApi } from '../api/client';
128
+ import ShortcutsDialog from './ShortcutsDialog';
61
129
 
62
130
  type CanvasProps = {
63
131
  canvasId: string;
@@ -82,10 +150,12 @@ type CanvasProps = {
82
150
  syncViaTools?: ToolSync;
83
151
  /** Follow agents' changes with the camera from the start (e.g. inside a chat, where you watch an agent work) */
84
152
  followChanges?: boolean;
153
+ /** Starts with the palette folded away (e.g. in a chat, where there's little room) */
154
+ paletteCollapsed?: boolean;
85
155
  /** Told what's selected (e.g. a chat attaches it to the conversation, so you can ask about it) */
86
156
  onSelectionChange?: (selected: SelectedNode[]) => void;
87
- /** Join with this name instead of asking for one (e.g. inside a chat, where the agent or host knows who it is) */
88
- defaultName?: string;
157
+ /** Join as this AI agent, with its name, colour and logo, instead of asking who you are (e.g. inside a chat, as the chat's agent) */
158
+ asAgent?: { name: string; color: string };
89
159
  /** Signed in with the catalog's sign-in (SSO): you're shown by this name and picture, and can't change them */
90
160
  signedInAs?: { name: string; picture?: string };
91
161
  resources: CatalogResource[];
@@ -96,8 +166,30 @@ export type SelectedNode = { id: string; name: string; type: string; catalogReso
96
166
 
97
167
  /** How long a level (L1, L2) waits for changes to settle before it's laid out again */
98
168
  const LEVEL_RELAYOUT_MS = 300;
169
+
170
+ /**
171
+ * Where something added goes. While L1 or L2 is shown it's added to the canvas people edit (L3), below what's on it
172
+ * or in the container it's dropped in, and kept on the level where it's dropped.
173
+ */
174
+ /** Where a node of this size goes to be centred on a point */
175
+ const centredOn = (center: XYPosition, size: { width: number; height: number }): LevelPlace => ({
176
+ x: center.x - size.width / 2,
177
+ y: center.y - size.height / 2,
178
+ });
179
+
180
+ type Landing = {
181
+ /** Where it was dropped (or added), in what's shown */
182
+ point: XYPosition;
183
+ /** Where it's centred on the canvas, and the container it goes in there (else the one at that point, if any) */
184
+ center: XYPosition;
185
+ inside?: string;
186
+ /**
187
+ * Where it's centred on the level it was dropped on (in the container's coordinates when it's in one), and the
188
+ * size it's drawn at there before it's measured
189
+ */
190
+ onLevel?: { level: 1 | 2; center: XYPosition; size: { width: number; height: number } };
191
+ };
99
192
  const DELETE_KEYS = ['Backspace', 'Delete'];
100
- const LEVEL_FIT_VIEW_OPTIONS = { ...FIT_VIEW_OPTIONS, maxZoom: 1 };
101
193
  const NO_THREADS: ReadonlySet<string> = new Set();
102
194
  // React Flow's warnings, except that a handle has no node: the left panel's drag previews draw nodes off the canvas
103
195
  const onFlowError = (code: string, message: string) => {
@@ -142,8 +234,58 @@ const asLevelNote = (node: Node) => {
142
234
 
143
235
  const structureOf = (nodes: Node[]) => nodes.map((node) => `${node.id}:${node.type}:${node.parentId ?? ''}`).join('|');
144
236
 
145
- export default function StudioDesigner({ defaultName, signedInAs, ...props }: CanvasProps) {
146
- const [name, setName] = useState(() => getStoredName() ?? defaultName ?? null);
237
+ // A node as shown with what it says about it (hidden by the legend, glowing with a hotspot, lit as a drop target):
238
+ // one copy per node and look, so a node keeps its identity while others move (React Flow re-renders a node whose
239
+ // object is new: copying them on every drag frame re-rendered every node with a hotspot)
240
+ const shownAs = new WeakMap<Node, Map<string, Node>>();
241
+ const asShown = (node: Node, hidden: boolean, className: string) => {
242
+ const key = `${hidden}|${className}`;
243
+ let looks = shownAs.get(node);
244
+ if (!looks) shownAs.set(node, (looks = new Map()));
245
+ let shown = looks.get(key);
246
+ if (!shown) {
247
+ if (looks.size > 4) looks.clear();
248
+ looks.set(key, (shown = { ...node, ...(hidden && { hidden: true }), ...(className && { className }) }));
249
+ }
250
+ return shown;
251
+ };
252
+
253
+ /**
254
+ * The drawing tools, as Studio on the web's: selecting (V), the hand (H) to move around, and what's put where the
255
+ * canvas is next clicked: a sticky note (N), text (T), a comment (C) or a hotspot (A)
256
+ */
257
+ type CanvasTool = 'select' | 'hand' | 'text' | 'note' | 'comment' | 'hotspot';
258
+
259
+ /**
260
+ * A diagram file opened into a new canvas: kept in this tab until the new canvas has loaded (empty), then put on it.
261
+ * By the new canvas's id.
262
+ */
263
+ const openedKey = (canvasId: string) => `eventcatalog-studio-opened:${canvasId}`;
264
+ const keepOpened = (canvasId: string, { nodes, edges, threads, hotspots, levelLayouts }: ModelCanvasContent) => {
265
+ try {
266
+ sessionStorage.setItem(
267
+ openedKey(canvasId),
268
+ JSON.stringify({ nodes, edges, threads, hotspots, levelLayouts: { 1: [...levelLayouts[1]], 2: [...levelLayouts[2]] } })
269
+ );
270
+ return true;
271
+ } catch {
272
+ return false;
273
+ }
274
+ };
275
+ const takeOpened = (canvasId: string): ModelCanvasContent | undefined => {
276
+ try {
277
+ const raw = sessionStorage.getItem(openedKey(canvasId));
278
+ if (!raw) return undefined;
279
+ sessionStorage.removeItem(openedKey(canvasId));
280
+ const saved = JSON.parse(raw);
281
+ return { ...saved, levelLayouts: { 1: new Map(saved.levelLayouts[1]), 2: new Map(saved.levelLayouts[2]) } };
282
+ } catch {
283
+ return undefined;
284
+ }
285
+ };
286
+
287
+ export default function StudioDesigner({ asAgent, signedInAs, ...props }: CanvasProps) {
288
+ const [name, setName] = useState(getStoredName);
147
289
 
148
290
  const saveName = useCallback((next: string) => {
149
291
  setName(next);
@@ -151,19 +293,41 @@ export default function StudioDesigner({ defaultName, signedInAs, ...props }: Ca
151
293
  }, []);
152
294
  // Renamed on the Studio page or another canvas in this browser
153
295
  useEffect(() => onStoredNameChange((next) => next && setName(next)), []);
296
+ // The colour picked (kept in this browser), else the one that follows from the name
297
+ const [chosenColor, setChosenColor] = useState(getStoredColor);
298
+ const recolor = useCallback((next: string) => {
299
+ setChosenColor(next);
300
+ storeColor(next);
301
+ }, []);
154
302
 
303
+ if (asAgent) {
304
+ return (
305
+ <ReactFlowProvider>
306
+ <Canvas {...props} name={asAgent.name} chosenColor={asAgent.color} agent onRecolor={() => {}} />
307
+ </ReactFlowProvider>
308
+ );
309
+ }
155
310
  if (signedInAs) {
156
311
  return (
157
312
  <ReactFlowProvider>
158
- <Canvas {...props} name={signedInAs.name} picture={signedInAs.picture} />
313
+ <Canvas {...props} name={signedInAs.name} picture={signedInAs.picture} chosenColor={chosenColor} onRecolor={recolor} />
159
314
  </ReactFlowProvider>
160
315
  );
161
316
  }
162
- if (!name) return <JoinForm onJoin={saveName} />;
317
+ if (!name)
318
+ return (
319
+ <JoinForm
320
+ color={chosenColor}
321
+ onJoin={(me) => {
322
+ recolor(me.color);
323
+ saveName(me.name);
324
+ }}
325
+ />
326
+ );
163
327
 
164
328
  return (
165
329
  <ReactFlowProvider>
166
- <Canvas {...props} name={name} onRename={saveName} />
330
+ <Canvas {...props} name={name} onRename={saveName} chosenColor={chosenColor} onRecolor={recolor} />
167
331
  </ReactFlowProvider>
168
332
  );
169
333
  }
@@ -184,19 +348,28 @@ function Canvas({
184
348
  keptInMemory = false,
185
349
  syncViaTools,
186
350
  followChanges = false,
351
+ paletteCollapsed,
187
352
  onSelectionChange,
188
353
  resources,
189
354
  relations,
190
355
  name,
191
356
  picture,
357
+ agent,
192
358
  onRename,
193
- }: Omit<CanvasProps, 'defaultName' | 'signedInAs'> & {
359
+ chosenColor,
360
+ onRecolor,
361
+ }: Omit<CanvasProps, 'asAgent' | 'signedInAs'> & {
194
362
  name: string;
195
363
  picture?: string;
364
+ /** You're here as an AI agent (shown with its logo, and can't change who you are) */
365
+ agent?: boolean;
196
366
  /** Not given when you're signed in (your name is the one you signed in with) */
197
367
  onRename?: (name: string) => void;
368
+ /** The colour you picked (else it follows from your name) */
369
+ chosenColor?: string | null;
370
+ onRecolor: (color: string) => void;
198
371
  }) {
199
- const color = colorForName(name);
372
+ const color = chosenColor ?? colorForName(name);
200
373
  const canvasRef = useRef<HTMLDivElement>(null);
201
374
 
202
375
  // Nodes glide to their new place after a layout (anyone's) or a level change (not while editing: it would make dragging lag)
@@ -223,12 +396,25 @@ function Canvas({
223
396
  name,
224
397
  color,
225
398
  picture,
399
+ agent,
226
400
  syncViaTools,
227
401
  onLayout: glide,
228
402
  onContainerResize: animateContainers,
229
403
  });
230
- const author = useMemo(() => ({ name, color, ...(picture && { picture }) }), [name, color, picture]);
404
+ const author = useMemo(
405
+ () => ({ name, color, ...(picture && { picture }), ...(agent && { agent }) }),
406
+ [name, color, picture, agent]
407
+ );
231
408
  const comments = useComments(flow.doc, author);
409
+ // Locked (looked at, not changed): for everyone on it, by whoever locked it
410
+ const locked = !!flow.lockedBy;
411
+ const lockedRef = useRef(locked);
412
+ lockedRef.current = locked;
413
+ // Fitted clear of the palette on the left (shown unless the canvas is locked)
414
+ const fitOptions = useMemo<FitViewOptions>(
415
+ () => (locked ? FIT_VIEW_OPTIONS : { ...FIT_VIEW_OPTIONS, padding: { ...FIT_VIEW_OPTIONS.padding, left: '304px' } }),
416
+ [locked]
417
+ );
232
418
  const { screenToFlowPosition, flowToScreenPosition, setCenter, getZoom, fitView } = useReactFlow();
233
419
 
234
420
  const catalog = useMemo(() => indexCatalog(resources, relations), [resources, relations]);
@@ -238,6 +424,13 @@ function Canvas({
238
424
  const edgesRef = useRef(flow.edges);
239
425
  edgesRef.current = flow.edges;
240
426
  const lookupNodes = () => new Map(nodesRef.current.map((node) => [node.id, node]));
427
+ /** Selects just these nodes, changing only those whose selection changes (each change copies its node) */
428
+ const select = (ids: ReadonlySet<string>) => {
429
+ const changes: NodeChange[] = nodesRef.current.flatMap((node) =>
430
+ !!node.selected === ids.has(node.id) ? [] : [{ type: 'select' as const, id: node.id, selected: ids.has(node.id) }]
431
+ );
432
+ if (changes.length) flow.onNodesChange(changes);
433
+ };
241
434
 
242
435
  // Agents in the browser (WebMCP) work on this canvas through the page, as the user's agent. Browsers without
243
436
  // WebMCP of their own get it through the polyfill once someone turns it on (Connect agent).
@@ -254,8 +447,7 @@ function Canvas({
254
447
  resources,
255
448
  catalog,
256
449
  agent: { name: `${name}'s agent`, color, agent: true },
257
- select: (ids) =>
258
- flow.onNodesChange(nodesRef.current.map((node) => ({ type: 'select', id: node.id, selected: ids.includes(node.id) }))),
450
+ select: (ids) => select(new Set(ids)),
259
451
  fitView: (ids) => void fitView({ nodes: ids?.map((id) => ({ id })), duration: 400, padding: 0.3, maxZoom: 1.2 }),
260
452
  reorder: () => reorder(),
261
453
  });
@@ -279,27 +471,35 @@ function Canvas({
279
471
  // ---- Adding things ----
280
472
 
281
473
  // A domain or system dropped on the canvas: asked whether it's a card or a container first
282
- const [chooser, setChooser] = useState<{ resource: CatalogResource; center: XYPosition } | null>(null);
474
+ const [chooser, setChooser] = useState<{ resource: CatalogResource; at: Landing } | null>(null);
283
475
  // A service or message that connects to things not on the canvas yet: asked whether to bring them too
284
476
  const [connectionsChooser, setConnectionsChooser] = useState<{
285
477
  resource: CatalogResource;
286
- center: XYPosition;
478
+ at: Landing;
287
479
  groups: ConnectionGroup[];
288
480
  } | null>(null);
289
481
 
290
482
  // A catalog resource is added with edges to the related resources already on the canvas
291
- const addResource = useStableCallback((key: string, center: XYPosition = canvasCenter()) => {
483
+ const addResource = (key: string, point: XYPosition) => {
292
484
  const resource = catalog.resourcesByKey.get(key);
293
485
  if (!resource) return;
294
- if (canBeContainer(resource)) return setChooser({ resource, center });
486
+ const at = landingAt(point, resource.node.type);
487
+ if (canBeContainer(resource)) return setChooser({ resource, at });
295
488
  const groups = getCatalogConnections(resource, catalog, keysOnCanvas);
296
- if (groups.length > 0) return setConnectionsChooser({ resource, center, groups });
297
- addCard(resource, center);
298
- });
489
+ if (groups.length > 0) return setConnectionsChooser({ resource, at, groups });
490
+ addCard(resource, at);
491
+ };
299
492
 
300
- const addCard = (resource: CatalogResource, center: XYPosition) =>
301
- flow.insertNode(resource.node.type, catalogNodeData(resource), center, (id, existing) =>
302
- getRelatedEdges(resource, id, existing, catalog.relationsByKey)
493
+ const addCard = (resource: CatalogResource, at: Landing) =>
494
+ keepOnLevel(
495
+ at,
496
+ flow.insertNode(
497
+ resource.node.type,
498
+ catalogNodeData(resource),
499
+ at.center,
500
+ (id, existing) => getRelatedEdges(resource, id, existing, catalog.relationsByKey),
501
+ at.inside
502
+ )
303
503
  );
304
504
 
305
505
  // Nodes waiting to be shown before the canvas is laid out around them
@@ -315,38 +515,42 @@ function Canvas({
315
515
  }, [flow.nodes]);
316
516
 
317
517
  /** A domain or system as a container, with everything it holds in the catalog inside if asked */
318
- const addContainer = async (resource: CatalogResource, center: XYPosition, withContents: boolean) => {
518
+ const addContainer = async (resource: CatalogResource, at: Landing, withContents: boolean) => {
319
519
  const existing = nodesRef.current;
320
520
  const built = withContents
321
- ? await buildContainerWithContents(resource, center, catalog, existing)
322
- : { nodes: [buildContainer(resource, center)], edges: [] };
521
+ ? await buildContainerWithContents(resource, at.center, catalog, existing)
522
+ : { nodes: [buildContainer(resource, at.center)], edges: [] };
323
523
  // Dropped in another container (e.g. a system in its domain): it goes in it
324
- const outer = findGroupAtPoint(center, existing);
524
+ const outer = containerFor(at.center, existing, at.inside);
325
525
  const nodes = outer
326
526
  ? built.nodes.map((node) =>
327
527
  node.parentId ? node : { ...node, parentId: outer.id, position: toRelativePosition(node.position, outer.id, existing) }
328
528
  )
329
529
  : built.nodes;
330
- // With its contents, the canvas is laid out again to make room for it all
331
- if (withContents && existing.length > 0) reorderOnceShown.current = nodes.map((node) => node.id);
530
+ // With its contents, the canvas is laid out again to make room for it all (not when added on a level: it's
531
+ // below everything on the canvas, and nothing on the level moves)
532
+ if (withContents && existing.length > 0 && !at.onLevel) reorderOnceShown.current = nodes.map((node) => node.id);
332
533
  flow.insertNodes(nodes, built.edges);
534
+ keepOnLevel(at, built.nodes.find((node) => !node.parentId)?.id);
333
535
  };
334
536
 
335
537
  const choose = useStableCallback((choice: ContainerChoice) => {
336
538
  if (!chooser) return;
337
- if (choice.as === 'card') addCard(chooser.resource, chooser.center);
338
- else void addContainer(chooser.resource, chooser.center, choice.withContents);
539
+ if (choice.as === 'card') addCard(chooser.resource, chooser.at);
540
+ else void addContainer(chooser.resource, chooser.at, !!choice.withContents);
339
541
  setChooser(null);
340
542
  });
341
543
  const cancelChooser = useCallback(() => setChooser(null), []);
342
544
 
343
545
  const chooseConnections = useStableCallback((choice: ConnectionsChoice) => {
344
546
  if (!connectionsChooser) return;
345
- const { resource, center } = connectionsChooser;
346
- if (choice.as === 'card') addCard(resource, center);
547
+ const { resource, at } = connectionsChooser;
548
+ if (choice.as === 'card') addCard(resource, at);
347
549
  else {
348
- const { nodes, edges } = planWithConnections(nodesRef.current, resource, center, choice.include, catalog);
550
+ const { nodes, edges } = planWithConnections(nodesRef.current, resource, at.center, choice.include, catalog, at.inside);
349
551
  flow.insertNodes(nodes, edges);
552
+ // What it connects to is placed next to it on the level
553
+ keepOnLevel(at, nodes.find((node) => getCatalogLink(node)?.key === resource.key)?.id);
350
554
  }
351
555
  setConnectionsChooser(null);
352
556
  });
@@ -364,31 +568,71 @@ function Canvas({
364
568
  };
365
569
  }, [chooser, catalog, keysOnCanvas]);
366
570
 
367
- /** A sticky note on the level shown (it's only shown on that level) */
571
+ /** A sticky note on the level shown (it's only shown on that level), written on straight away */
368
572
  const addNoteAt = (center: XYPosition) => {
369
573
  const data = getNodeDefinition('note')!.createData();
370
- flow.insertNode('note', levelRef.current === 3 ? data : { ...data, level: levelRef.current }, center);
574
+ const id = flow.insertNode('note', levelRef.current === 3 ? data : { ...data, level: levelRef.current }, center);
575
+ if (id) setStartWriting(id);
576
+ };
577
+ // The note or text just put on the canvas: written on straight away (see canvas-nodes)
578
+ const [startWriting, setStartWriting] = useState<string | null>(null);
579
+ /** Text written on the canvas, its first line where it's put (on the level shown, as notes are) */
580
+ const writeTextAt = (point: XYPosition) => {
581
+ const size = getNodeDefinition('text')!.defaultSize!;
582
+ const data = levelRef.current === 3 ? { text: '' } : { text: '', level: levelRef.current };
583
+ const id = flow.insertNode('text', data, { x: point.x + size.width / 2 - 4, y: point.y + size.height / 2 - 16 });
584
+ if (id) setStartWriting(id);
371
585
  };
372
586
  /**
373
- * Where something added while L1 or L2 is shown goes: L1 and L2 are views of the canvas people edit (L3), so it's
374
- * added there (shown on L3), below what's on it
587
+ * Where something added at a point goes (see Landing). On L1 and L2 it's centred where it's dropped, in the
588
+ * container there (and in the same container on the canvas). Something the level doesn't show (a message on L1)
589
+ * goes to L3, where it's shown.
375
590
  */
376
- const centerOnCanvas = (center: XYPosition) => {
377
- if (levelRef.current === 3) return center;
378
- changeLevel(3);
379
- const roots = nodesRef.current.filter((node) => !node.parentId && isOnLevel(node, 3));
380
- if (roots.length === 0) return { x: 0, y: 0 };
381
- const left = Math.min(...roots.map((node) => node.position.x));
382
- const right = Math.max(...roots.map((node) => node.position.x + sizeOf(node).width));
383
- const bottom = Math.max(...roots.map((node) => node.position.y + sizeOf(node).height));
384
- return { x: (left + right) / 2, y: bottom + 160 };
591
+ const landingAt = (point: XYPosition, type: string | undefined): Landing => {
592
+ const level = levelRef.current;
593
+ if (level === 3) return { point, center: point };
594
+ const shownHere = isShownOnLevel(type, level);
595
+ if (!shownHere) changeLevel(3);
596
+ // The container it's dropped in on the level, which the canvas has too (levels keep the canvas's ids)
597
+ const shown = shownHere && levelGraphRef.current?.level === level ? levelGraphRef.current.nodes : [];
598
+ const container = canGoInContainer(type) ? findGroupAtPoint(point, shown) : undefined;
599
+ const canvas = nodesRef.current;
600
+ const inside = container && containerFor(point, canvas, container.id) ? container.id : undefined;
601
+ // On the canvas: below what's in that container, or below everything
602
+ const lookup = new Map(canvas.map((node) => [node.id, node]));
603
+ const beside = canvas.filter((node) => (inside ? node.parentId === inside : !node.parentId && isOnLevel(node, 3)));
604
+ const rects = (beside.length ? beside : inside ? [lookup.get(inside)!] : []).map((node) => getAbsoluteRect(node, lookup));
605
+ const center = rects.length
606
+ ? {
607
+ x: (Math.min(...rects.map((rect) => rect.x)) + Math.max(...rects.map((rect) => rect.x + rect.width))) / 2,
608
+ // Below them, or in the middle of an empty container
609
+ y: beside.length ? Math.max(...rects.map((rect) => rect.y + rect.height)) + 160 : rects[0].y + rects[0].height / 2,
610
+ }
611
+ : { x: 0, y: 0 };
612
+ if (!shownHere) return { point, center };
613
+ const origin =
614
+ container && inside ? getAbsolutePosition(container, new Map(shown.map((node) => [node.id, node]))) : { x: 0, y: 0 };
615
+ const size = getLevelNodeSize(type, level);
616
+ return { point, center, inside, onLevel: { level, center: { x: point.x - origin.x, y: point.y - origin.y }, size } };
617
+ };
618
+ // Added on a level, until they're measured, with the size they were centred at: centred again at their real size
619
+ const centring = useRef(new Map<string, { level: 1 | 2; size: { width: number; height: number } }>());
620
+ /** Keeps what was added where it was dropped on the level (undone with adding it) */
621
+ const keepOnLevel = (at: Landing, id: string | undefined) => {
622
+ if (!id || !at.onLevel) return;
623
+ const { level, center, size } = at.onLevel;
624
+ centring.current.set(id, { level, size });
625
+ flow.setLevelLayout(level, new Map([[id, centredOn(center, size)]]));
626
+ };
627
+ /** A component (its type, or its key in the components list: the kinds of data store share a type) */
628
+ const addComponentAt = (component: string, point: XYPosition) => {
629
+ if (component === 'note') return addNoteAt(point);
630
+ const at = landingAt(point, getNodeDefinition(component)?.type ?? component);
631
+ keepOnLevel(at, flow.addNode(component, at.center, at.inside));
385
632
  };
386
- const addComponentAt = (type: string, center: XYPosition) =>
387
- type === 'note' ? addNoteAt(center) : flow.addNode(type, centerOnCanvas(center));
388
633
 
389
634
  const addComponent = useStableCallback((type: string) => addComponentAt(type, canvasCenter()));
390
- const addNote = useStableCallback(() => addNoteAt(canvasCenter()));
391
- const addResourceFromPanel = useStableCallback((key: string) => addResource(key, centerOnCanvas(canvasCenter())));
635
+ const addResourceFromPanel = useStableCallback((key: string) => addResource(key, canvasCenter()));
392
636
 
393
637
  const onDrop = useStableCallback((event: DragEvent) => {
394
638
  event.preventDefault();
@@ -396,7 +640,7 @@ function Canvas({
396
640
  const type = event.dataTransfer.getData(COMPONENT_DRAG_TYPE);
397
641
  const resourceKey = event.dataTransfer.getData(CATALOG_DRAG_TYPE);
398
642
  if (type) addComponentAt(type, position);
399
- if (resourceKey) addResource(resourceKey, centerOnCanvas(position));
643
+ if (resourceKey) addResource(resourceKey, position);
400
644
  });
401
645
 
402
646
  // ---- Pointer, panning and dragging ----
@@ -486,8 +730,13 @@ function Canvas({
486
730
  // While the canvas is panned, zoomed or dragged, message edges stop animating (a class, not state, so nothing re-renders)
487
731
  const interactions = useRef({ moving: false, dragging: false });
488
732
  const setInteraction = useCallback((kind: 'moving' | 'dragging', on: boolean) => {
733
+ const wasActive = interactions.current.moving || interactions.current.dragging;
489
734
  interactions.current[kind] = on;
490
- canvasRef.current?.classList.toggle('ec-interaction-active', interactions.current.moving || interactions.current.dragging);
735
+ const active = interactions.current.moving || interactions.current.dragging;
736
+ if (active === wasActive) return;
737
+ canvasRef.current?.classList.toggle('ec-interaction-active', active);
738
+ // (hiding them doesn't stop the envelopes' SMIL)
739
+ pauseEdgeAnimations(canvasRef.current, active);
491
740
  }, []);
492
741
 
493
742
  // The camera follows agents unless people move it themselves (set by AgentActivity)
@@ -503,10 +752,6 @@ function Canvas({
503
752
 
504
753
  // Dragging something into a container puts it in it, out of one takes it out
505
754
  const [dropTargetId, setDropTargetId] = useState<string | null>(null);
506
- const dragRoots = (dragged: Node[]) => {
507
- const ids = new Set(dragged.map((node) => node.id));
508
- return dragged.filter((node) => !node.parentId || !ids.has(node.parentId));
509
- };
510
755
  // While something's dragged, the containers around it grow to fit it (see previewContainerGrowth): the canvas
511
756
  // when the drag started, and those containers, innermost first
512
757
  const dragStart = useRef<{ nodes: Map<string, Node>; containers: string[] } | null>(null);
@@ -553,15 +798,17 @@ function Canvas({
553
798
  dragged.forEach((node) => now.set(node.id, { ...now.get(node.id)!, position: node.position }));
554
799
  flow.showDragPreview(previewContainerGrowth(started.containers, new Set(dragged.map((node) => node.id)), started.nodes, now));
555
800
  };
556
- const onNodeDrag = useStableCallback((_: unknown, __: Node, dragged: Node[]) => {
801
+ const onNodeDrag = useStableCallback((_: unknown, held: Node, dragged: Node[]) => {
557
802
  growContainers(dragged);
558
803
  moveThreadsWith(dragged);
559
- const [root] = dragRoots(dragged);
560
- const current = root && nodesRef.current.find((node) => node.id === root.id);
561
- const target = root ? findDropTarget(root.id, nodesRef.current) : undefined;
562
- setDropTargetId(target && target.id !== current?.parentId ? target.id : null);
804
+ const moves = findDropTargets(
805
+ held.id,
806
+ dragged.map((node) => node.id),
807
+ nodesRef.current
808
+ );
809
+ setDropTargetId(moves.find(({ target }) => target)?.target?.id ?? null);
563
810
  });
564
- const onNodeDragStop = useStableCallback((_: unknown, __: Node, dragged: Node[]) => {
811
+ const onNodeDragStop = useStableCallback((_: unknown, held: Node, dragged: Node[]) => {
565
812
  setInteraction('dragging', false);
566
813
  setDropTargetId(null);
567
814
  dragStart.current = null;
@@ -579,56 +826,113 @@ function Canvas({
579
826
  }
580
827
  threadsDrag.current = null;
581
828
  setThreadsOffset(null);
582
- // The containers it grew are saved with the drop; dropped in another container (or out of one), it moves there
583
- for (const root of dragRoots(dragged)) {
584
- const current = nodesRef.current.find((node) => node.id === root.id);
585
- const target = findDropTarget(root.id, nodesRef.current);
586
- if (current && target?.id !== current.parentId) flow.setParent(root.id, target?.id);
587
- }
829
+ // The containers it grew are saved with the drop; dropped in another container (or out of one), it moves there,
830
+ // and what's selected with it goes too, into free space there (gliding off anything it was dropped on)
831
+ const moves = findDropTargets(
832
+ held.id,
833
+ dragged.map((node) => node.id),
834
+ nodesRef.current
835
+ );
836
+ if (!moves.length) return;
837
+ const { places, nudged } = placeDropped(nodesRef.current, moves);
838
+ if (nudged) glide();
839
+ flow.setParents(moves.map(({ id, target }) => ({ id, parentId: target?.id, at: places.get(id) })));
588
840
  });
589
841
 
590
842
  // ---- Levels of detail ----
591
843
 
592
- // Like EventCatalog's diagrams: L3 is the canvas people edit, L1 and L2 read only views of it
593
- const [level, setLevel] = useState<Level>(3);
844
+ // Like EventCatalog's diagrams: L3 is everything on the canvas, L1 and L2 less detailed views of it, arranged and
845
+ // edited on their own (what's done there changes the canvas: a system card is the system). A canvas opened
846
+ // from a diagram opens at the level it was shown at (?level=1 or 2), once: reloading opens the canvas (L3).
847
+ const [level, setLevel] = useState<Level>(() => {
848
+ const url = new URL(window.location.href);
849
+ const asked = Number(url.searchParams.get('level'));
850
+ if (!url.searchParams.has('level')) return 3;
851
+ url.searchParams.delete('level');
852
+ window.history.replaceState(window.history.state, '', url);
853
+ return asked === 1 || asked === 2 ? asked : 3;
854
+ });
594
855
  const levelRef = useRef(level);
595
856
  levelRef.current = level;
857
+ // L3 is the canvas itself: comments and other people's pointers are there (L1 and L2 are arranged on their own)
596
858
  const editable = level === 3;
597
- const [levelGraph, setLevelGraph] = useState<{ level: Level; nodes: Node[]; edges: Edge[] } | null>(null);
859
+ const [levelGraph, setLevelGraphState] = useState<{ level: Level; nodes: Node[]; edges: Edge[] } | null>(null);
860
+ // The level shown as it is now (for changes to it, and saving where things are put)
861
+ const levelGraphRef = useRef(levelGraph);
862
+ /** Changes to the level shown (moved, selected, measured), as they happen */
863
+ const updateLevelGraph = useCallback((next: { level: Level; nodes: Node[]; edges: Edge[] }) => {
864
+ levelGraphRef.current = next;
865
+ setLevelGraphState(next);
866
+ }, []);
867
+ /**
868
+ * Shows a level (arranged again), keeping what hasn't changed on it as it's shown (so it doesn't render again), and
869
+ * what's selected and nodes' measured sizes (not measured again). Containers come before what's in them, as React
870
+ * Flow needs (the canvas's order can have them after).
871
+ */
872
+ const setLevelGraph = useCallback((next: { level: Level; nodes: Node[]; edges: Edge[] } | null) => {
873
+ const previous = levelGraphRef.current;
874
+ const same = previous && next && previous.level === next.level ? previous : null;
875
+ const shown = next && {
876
+ ...next,
877
+ nodes: sortByHierarchy(same ? keepUnchanged(same.nodes, next.nodes, LOCAL_NODE_KEYS) : next.nodes),
878
+ edges: same ? keepUnchanged(same.edges, next.edges, LOCAL_EDGE_KEYS) : next.edges,
879
+ };
880
+ levelGraphRef.current = shown;
881
+ setLevelGraphState(shown);
882
+ }, []);
598
883
 
599
884
  // What a level shows depends on what's on the canvas and how it's connected, and names (not positions). Notes
600
885
  // aren't laid out with it: adding, moving or writing one doesn't lay the level out again.
601
- const levelKey =
602
- level === 3
603
- ? ''
604
- : `${flow.nodes
605
- .filter((node) => node.type !== 'note')
606
- .map((node) => `${node.id}:${node.type}:${node.parentId ?? ''}:${getNodeName(node.type, node.data)}`)
607
- .join('|')}#${flow.edges.map((edge) => `${edge.source}>${edge.target}:${String(edge.label ?? '')}`).join('|')}`;
886
+ // Also whether a canvas opened from a diagram still has the diagram's levels: only worked out when needed
887
+ const levelKey = level === 3 ? '' : flow.structureKey;
608
888
  const shownLevelRef = useRef<Level | undefined>(undefined);
609
889
  shownLevelRef.current = levelGraph?.level;
890
+ const levelLayout = level === 3 ? undefined : flow.levelLayouts[level];
610
891
  useEffect(() => {
611
892
  if (level === 3) return setLevelGraph(null);
612
893
  let cancelled = false;
613
- // Straight away when switching levels; after changes settle while one is shown
614
- const timer = setTimeout(
615
- () => {
616
- const collapsed = getLevelGraph(nodesRef.current, edgesRef.current, level);
617
- void layoutGraph(
618
- collapsed.nodes.map((node) => ({ ...node, selected: false })),
619
- collapsed.edges
620
- ).then((laidOut) => !cancelled && setLevelGraph({ level, ...laidOut }));
621
- },
622
- shownLevelRef.current === level ? LEVEL_RELAYOUT_MS : 0
623
- );
894
+ const show = () => {
895
+ // A canvas opened from a diagram shows the diagram's levels as it did, until what's on the canvas changes
896
+ const fromDiagram = getDiagramLevel(flow.diagramLevels, level, flow.structureKey);
897
+ const diagram = flow.diagramLevels?.[level];
898
+ const derived = fromDiagram ? undefined : getLevelGraph(nodesRef.current, edgesRef.current, level);
899
+ const content = fromDiagram ?? { ...derived!, edges: withDiagramRoutes(derived!.edges, diagram?.edges) };
900
+ // Where things are: as arranged on the level, else as the diagram had them, and anything new next to what it
901
+ // connects to (kept there, so nothing moves later)
902
+ const arranged = arrangeLevel(content, flow.levelLayouts[level], diagram && levelPlaces(diagram.nodes));
903
+ if (arranged) {
904
+ setLevelGraph({ level, ...arranged.graph });
905
+ if (arranged.placed.size) flow.setLevelLayout(level, arranged.placed, 'level');
906
+ return;
907
+ }
908
+ // Nothing on it has a place yet: laid out with the visualiser's layout once, and kept
909
+ void layoutGraph(
910
+ content.nodes.map((node) => ({ ...node, selected: false })),
911
+ content.edges
912
+ ).then((laidOut) => {
913
+ if (cancelled) return;
914
+ setLevelGraph({ level, ...laidOut });
915
+ flow.setLevelLayout(level, levelPlaces(laidOut.nodes), 'level');
916
+ // (with sizes guessed from the nodes' types: laid out again once they're drawn, see below)
917
+ if (!flow.presence?.getPeers().length) relayoutWhenMeasured.current = level;
918
+ });
919
+ };
920
+ // Straight away when switching levels (or from the diagram); after changes settle while one is shown
921
+ if (shownLevelRef.current !== level || getDiagramLevel(flow.diagramLevels, level, flow.structureKey)) {
922
+ show();
923
+ return () => void (cancelled = true);
924
+ }
925
+ const timer = setTimeout(show, LEVEL_RELAYOUT_MS);
624
926
  return () => {
625
927
  cancelled = true;
626
928
  clearTimeout(timer);
627
929
  };
628
- }, [level, levelKey]);
930
+ }, [level, levelKey, flow.diagramLevels, levelLayout]);
629
931
 
630
- // Comments: right click > Add comment, or C for comment mode then click (like Figma)
631
- const [commentMode, setCommentMode] = useState(false);
932
+ // The tool picked (see CanvasTool): comments are right click > Add comment, or C then click (like Figma)
933
+ const [tool, setTool] = useState<CanvasTool>('select');
934
+ const commentMode = tool === 'comment';
935
+ const placing = tool === 'text' || tool === 'note';
632
936
  const [draft, setDraft] = useState<CommentAnchor | null>(null);
633
937
  const [openThreadId, setOpenThreadId] = useState<string | null>(null);
634
938
  const [showResolved, setShowResolved] = useState(false);
@@ -640,10 +944,31 @@ function Canvas({
640
944
  const threadsShown = () => comments.threads.filter((thread) => showResolved || !thread.resolved);
641
945
  const [contextMenu, setContextMenu] = useState<ContextMenuState>(null);
642
946
 
947
+ // A level's first layout is worked out before its nodes have been drawn, so with sizes guessed from their types: a
948
+ // little off once they're drawn at their real sizes. Once they all have been (measured), it's laid out again with
949
+ // them, as Reorder would, gliding there. Only that first time, and not while others are on the canvas: it would
950
+ // move what they're looking at (the level stays as first laid out, and Reorder tidies it)
951
+ const relayoutWhenMeasured = useRef<1 | 2 | null>(null);
952
+ useEffect(() => {
953
+ const pending = relayoutWhenMeasured.current;
954
+ if (!pending || levelGraph?.level !== pending) return;
955
+ if (!levelGraph.nodes.every((node) => node.measured?.width && node.measured?.height)) return;
956
+ relayoutWhenMeasured.current = null;
957
+ void layoutGraph(
958
+ levelGraph.nodes.map((node) => ({ ...node, selected: false })),
959
+ levelGraph.edges
960
+ ).then((laidOut) => {
961
+ if (levelRef.current !== pending) return;
962
+ glide();
963
+ flow.setLevelLayout(pending, levelPlaces(laidOut.nodes), 'level');
964
+ setTimeout(() => void fitView({ ...fitOptions, duration: 450 }), 50);
965
+ });
966
+ }, [levelGraph]);
967
+
643
968
  const changeLevel = useStableCallback((next: Level) => {
644
969
  if (next === level) return;
645
970
  glide();
646
- setCommentMode(false);
971
+ setTool('select');
647
972
  setDraft(null);
648
973
  setOpenThreadId(null);
649
974
  // What's selected may not be on the next level (e.g. a note added on this one)
@@ -652,22 +977,41 @@ function Canvas({
652
977
  });
653
978
  const editInL3 = useCallback(() => changeLevel(3), [changeLevel]);
654
979
 
655
- // Fit the camera to a level once it's laid out (and to the canvas when going back to L3)
980
+ // Fit the camera to a level once it's laid out (and to the canvas when going back to L3). A level shown before the
981
+ // canvas has arrived (opened at ?level=) is laid out empty first: it's fitted once it has something to show.
656
982
  const shownLevel = level === 3 ? 3 : levelGraph?.level;
983
+ const levelHasNodes = !!levelGraph?.nodes.length;
657
984
  useEffect(() => {
658
- if (shownLevel === undefined) return;
659
- // Like the visualiser, a level isn't zoomed in past its real size
660
- const options = shownLevel === 3 ? FIT_VIEW_OPTIONS : LEVEL_FIT_VIEW_OPTIONS;
661
- const frame = requestAnimationFrame(() => void fitView({ ...options, duration: 450 }));
985
+ if (shownLevel === undefined || (shownLevel !== 3 && !levelHasNodes)) return;
986
+ const frame = requestAnimationFrame(() => void fitView({ ...fitOptions, duration: 450 }));
662
987
  return () => cancelAnimationFrame(frame);
663
- }, [shownLevel]);
988
+ }, [shownLevel, levelHasNodes]);
664
989
 
665
- /** Lays the canvas out with the visualiser's layout, for everyone on it (everyone's nodes glide there) */
990
+ /**
991
+ * Lays the canvas out with the visualiser's layout, for everyone on it (everyone's nodes glide there). On L1 or
992
+ * L2 it lays out just that level (one step to undo), leaving the canvas as it is.
993
+ */
666
994
  const reorder = useStableCallback(async () => {
667
- if (levelRef.current !== 3) setLevel(3);
995
+ const level = levelRef.current;
996
+ if (level !== 3) {
997
+ const content =
998
+ getDiagramLevel(flow.diagramLevels, level, flow.structureKey) ?? getLevelGraph(nodesRef.current, edgesRef.current, level);
999
+ // As they're drawn on the level (its cards and boxes aren't the canvas's), where they have been
1000
+ const drawn = new Map(
1001
+ (levelGraphRef.current?.level === level ? levelGraphRef.current.nodes : []).map((node) => [node.id, node.measured])
1002
+ );
1003
+ const laidOut = await layoutGraph(
1004
+ content.nodes.map((node) => ({ ...node, selected: false, ...(drawn.get(node.id) && { measured: drawn.get(node.id) }) })),
1005
+ content.edges
1006
+ );
1007
+ glide();
1008
+ flow.setLevelLayout(level, levelPlaces(laidOut.nodes));
1009
+ setTimeout(() => void fitView({ ...fitOptions, duration: 450 }), 50);
1010
+ return;
1011
+ }
668
1012
  const positions = await getLayoutPositions(nodesRef.current, edgesRef.current);
669
1013
  flow.applyPositions(positions);
670
- setTimeout(() => void fitView({ ...FIT_VIEW_OPTIONS, duration: 450 }), 50);
1014
+ setTimeout(() => void fitView({ ...fitOptions, duration: 450 }), 50);
671
1015
  });
672
1016
  const onReorder = useCallback(() => void reorder(), [reorder]);
673
1017
 
@@ -692,27 +1036,234 @@ function Canvas({
692
1036
  const notes = sortedNodes.filter((node) => getNoteLevel(node) === levelShown.level);
693
1037
  return notes.length ? [...levelShown.nodes, ...notes.map(asLevelNote)] : levelShown.nodes;
694
1038
  }, [levelShown, sortedNodes]);
695
- /** On L1 and L2, only changes to the level's notes go to the canvas (its other nodes are drawn from it) */
1039
+
1040
+ // The legend, as the visualiser's: how many of each kind of node is shown (not what's written on the canvas), and
1041
+ // clicking a kind hides or shows it. Hiding only changes what this person sees.
1042
+ const [hiddenLegendKeys, setHiddenLegendKeys] = useState<string[]>([]);
1043
+ const toggleLegendKey = useCallback(
1044
+ (key: string) => setHiddenLegendKeys((keys) => (keys.includes(key) ? keys.filter((other) => other !== key) : [...keys, key])),
1045
+ []
1046
+ );
1047
+ // What's shown only changes with what's on it (not as nodes are dragged, on any level)
1048
+ const shownStructure = levelShown ? structureOf(levelShown.nodes) : structure;
1049
+ const legend = useMemo(() => getLegend(shownNodes.filter((node) => !isWrittenOnCanvas(node.type))), [shownStructure]);
1050
+ const shownNodesRef = useRef(shownNodes);
1051
+ shownNodesRef.current = shownNodes;
1052
+ // The nodes shown now (on L1 and L2 they're the level's, where they are there)
1053
+ const shownLookup = () => new Map<string, Node>(shownNodesRef.current.map((node) => [node.id, node]));
1054
+
1055
+ // ---- Hotspots: what's flagged for people to know (risks, problems, the key parts), on nodes or places ----
1056
+
1057
+ const [hotspotDraft, setHotspotDraft] = useState<HotspotDraft | null>(null);
1058
+ const [openHotspot, setOpenHotspot] = useState<string | null>(null);
1059
+ const [listingHotspots, setListingHotspots] = useState(false);
1060
+ // The node a hotspot being dragged would go on: it lights up in the hotspot's colour
1061
+ const [hotspotDropTarget, setHotspotDropTarget] = useState<{ nodeId: string; severity: HotspotSeverity } | null>(null);
1062
+ // Hotspots where they show on this level: on L3 on their node (or at their place); on L1 and L2 on their node if
1063
+ // it's shown, else on what it's in that is (so they're found from the overview). From what's on the canvas and the
1064
+ // level, read through refs: the nodes arrays are new on every drag frame, and which node holds which doesn't
1065
+ // change while things move.
1066
+ const hotspotSpots = useMemo((): HotspotSpot[] => {
1067
+ const canvas = new Map(nodesRef.current.map((node) => [node.id, node]));
1068
+ const shown = new Set(shownNodesRef.current.map((node) => node.id));
1069
+ const onNodes = new Map<string, Hotspot[]>();
1070
+ const holding = new Set<string>();
1071
+ const places: HotspotSpot[] = [];
1072
+ // The node itself if it's shown, else the nearest container of it that is
1073
+ const shownAround = (id: string | undefined) => {
1074
+ while (id && !shown.has(id)) id = canvas.get(id)?.parentId;
1075
+ return id;
1076
+ };
1077
+ for (const hotspot of flow.hotspots) {
1078
+ let target = hotspot.nodeId && canvas.has(hotspot.nodeId) ? hotspot.nodeId : undefined;
1079
+ if (!target) {
1080
+ if ((hotspot.level ?? 3) === level)
1081
+ places.push({ key: `place:${hotspot.id}`, position: hotspot.position, hotspots: [hotspot] });
1082
+ continue;
1083
+ }
1084
+ const own = target;
1085
+ target = shownAround(own);
1086
+ // Not in anything shown (e.g. a message on L2): on what it's connected to (its sender first), or what that's in
1087
+ if (!target) {
1088
+ const linked = flow.edges
1089
+ .filter((edge) => edge.source === own || edge.target === own)
1090
+ .sort((a) => (a.target === own ? -1 : 1));
1091
+ target = linked.map((edge) => shownAround(edge.source === own ? edge.target : edge.source)).find(Boolean);
1092
+ }
1093
+ if (!target) continue;
1094
+ if (target !== hotspot.nodeId) holding.add(target);
1095
+ onNodes.set(target, [...(onNodes.get(target) ?? []), hotspot]);
1096
+ }
1097
+ return [
1098
+ ...[...onNodes].map(([nodeId, hotspots]) => ({ key: `node:${nodeId}`, nodeId, hotspots, inside: holding.has(nodeId) })),
1099
+ ...places,
1100
+ ];
1101
+ }, [flow.hotspots, structure, shownStructure, flow.edges, level]);
1102
+
1103
+ const visibleNodes = useMemo(() => {
1104
+ // A node with hotspots glows in the colour of the most important (see studio.css)
1105
+ const glowing = new Map(
1106
+ hotspotSpots.flatMap((spot) => (spot.nodeId ? [[spot.nodeId, topSeverity(spot.hotspots)] as const] : []))
1107
+ );
1108
+ const dropOn = hotspotDropTarget?.nodeId;
1109
+ if (!hiddenLegendKeys.length && !glowing.size && !dropOn) return shownNodes;
1110
+ return shownNodes.map((node) => {
1111
+ const hidden = hiddenLegendKeys.includes(node.type ?? '');
1112
+ const severity = glowing.get(node.id);
1113
+ const target = node.id === dropOn;
1114
+ if (!hidden && !severity && !target) return node;
1115
+ const classes = [
1116
+ node.className,
1117
+ severity && `studio-hotspot studio-hotspot-${severity}`,
1118
+ target && `studio-hotspot-drop studio-hotspot-drop-${hotspotDropTarget!.severity}`,
1119
+ ];
1120
+ return asShown(node, hidden, classes.filter(Boolean).join(' '));
1121
+ });
1122
+ }, [shownNodes, hiddenLegendKeys, hotspotSpots, hotspotDropTarget]);
1123
+
1124
+ const startHotspot = useStableCallback((at: HotspotDraft) => {
1125
+ setOpenHotspot(null);
1126
+ setDraft(null);
1127
+ setHotspotDraft(at);
1128
+ setTool('select');
1129
+ });
1130
+ const createHotspot = useStableCallback((fields: HotspotFields) => {
1131
+ if (!hotspotDraft) return;
1132
+ flow.createHotspot({
1133
+ ...fields,
1134
+ ...(hotspotDraft.nodeId ? { nodeId: hotspotDraft.nodeId } : hotspotDraft.level && { level: hotspotDraft.level }),
1135
+ position: hotspotDraft.position,
1136
+ author,
1137
+ });
1138
+ setHotspotDraft(null);
1139
+ });
1140
+ const cancelHotspot = useCallback(() => setHotspotDraft(null), []);
1141
+ const deleteHotspot = useStableCallback((id: string) => flow.deleteHotspot(id));
1142
+ // Dragged onto a node (they move with it), or to a place; open where it lands if it was open
1143
+ const moveHotspots = useStableCallback((ids: string[], to: { nodeId?: string; position: XYPosition }) => {
1144
+ const at = to.nodeId ? {} : placeLevel();
1145
+ ids.forEach((id) =>
1146
+ flow.updateHotspot(id, { nodeId: to.nodeId, position: to.position, level: 'level' in at ? at.level : undefined })
1147
+ );
1148
+ setOpenHotspot((open) => (open ? (to.nodeId ? `node:${to.nodeId}` : ids.length === 1 ? `place:${ids[0]}` : null) : open));
1149
+ });
1150
+ // From the list (or inside a node on L1 and L2): the canvas goes to it, and it opens
1151
+ const goToHotspot = useStableCallback((hotspot: Hotspot) => {
1152
+ // On a node shown here, it's here; on another, on L3; at a place, on its level
1153
+ const shownHere = hotspot.nodeId && shownLookup().has(hotspot.nodeId);
1154
+ const target = shownHere ? levelRef.current : hotspot.nodeId ? 3 : (hotspot.level ?? 3);
1155
+ if (target !== levelRef.current) changeLevel(target);
1156
+ const nodes = shownHere ? shownLookup() : lookupNodes();
1157
+ const node = hotspot.nodeId ? nodes.get(hotspot.nodeId) : undefined;
1158
+ const at = node ? getAbsolutePosition(node, nodes) : hotspot.position;
1159
+ const width = node ? (node.measured?.width ?? node.width ?? 0) : 0;
1160
+ void setCenter(at.x + width / 2 + 120, at.y + 80, { zoom: Math.max(getZoom(), 0.8), duration: 300 });
1161
+ setHotspotDraft(null);
1162
+ setOpenHotspot(node ? `node:${node.id}` : `place:${hotspot.id}`);
1163
+ setListingHotspots(false);
1164
+ });
1165
+ const nameOfNode = useStableCallback((id: string) => {
1166
+ const node = nodesRef.current.find((candidate) => candidate.id === id);
1167
+ return node ? getNodeName(node.type, node.data, id) : undefined;
1168
+ });
1169
+ const toggleHotspotList = useCallback(() => {
1170
+ setListingComments(false);
1171
+ setListingHotspots((shown) => !shown);
1172
+ }, []);
1173
+ const hotspotsCount = flow.hotspots.length;
1174
+ const hotspotsSeverity = topSeverity(flow.hotspots);
1175
+ // What the bottom left controls show (the same objects while that's the same: they're memoised)
1176
+ const hotspotsControl = useMemo(
1177
+ () => ({ count: hotspotsCount, severity: hotspotsSeverity, onToggle: toggleHotspotList, active: listingHotspots }),
1178
+ [hotspotsCount, hotspotsSeverity, toggleHotspotList, listingHotspots]
1179
+ );
1180
+
1181
+ // A node's details open from its Edit button (and by double clicking it): one selected node on the canvas
1182
+ const selectedNodes = visibleNodes.filter((node) => node.selected);
1183
+ const toEdit =
1184
+ selectedNodes.length === 1 &&
1185
+ !isWrittenOnCanvas(selectedNodes[0].type) &&
1186
+ nodesRef.current.some((node) => node.id === selectedNodes[0].id)
1187
+ ? selectedNodes[0]
1188
+ : undefined;
1189
+ const editSelected = useStableCallback(() => toEdit && setEditingId(toEdit.id));
1190
+ /**
1191
+ * Changes on L1 and L2. The level's notes are on the canvas, so changes to them go there; removing something
1192
+ * removes it from the canvas (the level is drawn from it); and moving or resizing something arranges the level,
1193
+ * kept when it's dropped.
1194
+ */
696
1195
  const onLevelNodesChange = useStableCallback((changes: NodeChange[]) => {
697
- const notes = new Set(nodesRef.current.filter((node) => getNoteLevel(node) === levelRef.current).map((node) => node.id));
698
- const ofNotes = changes.filter((change) => change.type !== 'add' && notes.has(change.id));
1196
+ const level = levelRef.current;
1197
+ const current = levelGraphRef.current;
1198
+ if (level === 3 || !current) return;
1199
+ const notes = new Set(nodesRef.current.filter((node) => getNoteLevel(node) === level).map((node) => node.id));
1200
+ const isNote = (change: NodeChange) => 'id' in change && notes.has(change.id);
1201
+ const ofNotes = changes.filter((change) => change.type !== 'add' && isNote(change));
699
1202
  if (ofNotes.length) flow.onNodesChange(ofNotes);
1203
+
1204
+ const ofLevel = changes.filter((change) => !isNote(change) && change.type !== 'add');
1205
+ const onCanvas = new Set(nodesRef.current.map((node) => node.id));
1206
+ const removed = ofLevel.flatMap((change) => (change.type === 'remove' && onCanvas.has(change.id) ? [change.id] : []));
1207
+ if (removed.length) flow.deleteNodes(removed);
1208
+ const arranging = ofLevel.filter((change) => change.type !== 'remove');
1209
+ if (!arranging.length) return;
1210
+ const nodes = applyNodeChanges(arranging, current.nodes);
1211
+ updateLevelGraph({ ...current, nodes });
1212
+ const done = new Set(
1213
+ arranging.flatMap((change) =>
1214
+ (change.type === 'position' && change.dragging === false) || (change.type === 'dimensions' && change.resizing === false)
1215
+ ? [change.id]
1216
+ : []
1217
+ )
1218
+ );
1219
+ if (done.size) flow.setLevelLayout(level, levelPlaces(nodes.filter((node) => done.has(node.id))));
1220
+ // Something just added, now it's measured: centred where it was dropped, from where it is now (a container it's in
1221
+ // may have grown around it), in one step to undo with adding it
1222
+ const measured = arranging.flatMap((change) => {
1223
+ const dropped = change.type === 'dimensions' && change.dimensions && centring.current.get(change.id);
1224
+ const node = dropped && nodes.find((candidate) => candidate.id === change.id);
1225
+ if (!dropped || !node) return [];
1226
+ centring.current.delete(change.id);
1227
+ const { width, height } = change.dimensions!;
1228
+ const { x, y } = node.position;
1229
+ const place = { x: x + (dropped.size.width - width) / 2, y: y + (dropped.size.height - height) / 2 };
1230
+ return dropped.level === level ? [[change.id, place] as const] : [];
1231
+ });
1232
+ if (measured.length) flow.setLevelLayout(level, new Map(measured));
1233
+ });
1234
+ /** Connecting two things on L1 or L2 connects them on the canvas (e.g. a relationship between two systems) */
1235
+ const onLevelConnect = useStableCallback((connection: Connection) => {
1236
+ const onCanvas = new Set(nodesRef.current.map((node) => node.id));
1237
+ if (onCanvas.has(connection.source) && onCanvas.has(connection.target)) flow.onConnect(connection);
700
1238
  });
701
- // Message edges' envelopes only move on what's selected (the edge, or a node at either end): any moving
702
- // envelope at all costs a style recalc and layout every frame, which an editing canvas can't afford at idle
1239
+ // Message edges' envelopes only move under the pointer (the connection, or a node at either end): any moving
1240
+ // envelope needs a main-thread frame on every frame (one selected node's cost ~200–300 ms of every 3 seconds and
1241
+ // dropped frames), which an editing canvas can't afford at idle
1242
+ // (on L1 and L2, the level's edges: the level itself changes on every frame something on it is dragged)
1243
+ const [hovered, setHovered] = useState<{ node?: string; edge?: string }>({});
1244
+ const onNodeMouseEnter = useCallback((_: ReactMouseEvent, node: Node) => setHovered({ node: node.id }), []);
1245
+ const onEdgeMouseEnter = useCallback((_: ReactMouseEvent, edge: Edge) => setHovered({ edge: edge.id }), []);
1246
+ const onHoverEnd = useCallback(() => setHovered({}), []);
1247
+ const levelEdges = levelShown?.edges;
703
1248
  const shownEdges = useMemo(() => {
704
- const edges = levelShown ? levelShown.edges : flow.edges;
705
- const focused = new Set(selectedKey ? selectedKey.split(',') : []);
1249
+ const edges = levelEdges ?? flow.edges;
706
1250
  return edges.map((edge) =>
707
- edge.type === 'animated' && !edge.selected && !focused.has(edge.source) && !focused.has(edge.target)
1251
+ edge.type === 'animated' && edge.id !== hovered.edge && edge.source !== hovered.node && edge.target !== hovered.node
708
1252
  ? withoutEnvelope(edge)
709
1253
  : edge
710
1254
  );
711
- }, [levelShown, flow.edges, selectedKey]);
712
- const unavailableLevels = useMemo(
713
- () => ({ 1: getLevelUnavailableReason(nodesRef.current, 1), 2: getLevelUnavailableReason(nodesRef.current, 2) }),
714
- [structure]
715
- );
1255
+ }, [levelEdges, flow.edges, hovered]);
1256
+ // A canvas opened from a diagram has the levels the diagram has (until what's on it changes)
1257
+ const unavailableLevels = useMemo(() => {
1258
+ const reason = (level: 1 | 2) => {
1259
+ if (getDiagramLevel(flow.diagramLevels, level, flow.structureKey)) return undefined;
1260
+ return (
1261
+ getDiagramLevelUnavailable(flow.diagramLevels, level, flow.structureKey) ??
1262
+ getLevelUnavailableReason(nodesRef.current, level)
1263
+ );
1264
+ };
1265
+ return { 1: reason(1), 2: reason(2) };
1266
+ }, [structure, flow.structureKey, flow.diagramLevels]);
716
1267
 
717
1268
  // ---- People and agents ----
718
1269
 
@@ -730,9 +1281,91 @@ function Canvas({
730
1281
  const [following, setFollowing] = useState(followChanges);
731
1282
  const toggleFollowing = useCallback(() => setFollowing((on) => !on), []);
732
1283
 
733
- const [shareOpen, setShareOpen] = useState(false);
734
- const openShare = useCallback(() => setShareOpen(true), []);
1284
+ const [shareOpen, setShareOpen] = useState<false | 'choose' | 'invite'>(false);
1285
+ const openShare = useCallback(() => setShareOpen('choose'), []);
1286
+ const openLiveCollaboration = useCallback(() => setShareOpen('invite'), []);
735
1287
  const closeShare = useCallback(() => setShareOpen(false), []);
1288
+ const [helpOpen, setHelpOpen] = useState(false);
1289
+ const openHelp = useCallback(() => setHelpOpen(true), []);
1290
+ const closeHelp = useCallback(() => setHelpOpen(false), []);
1291
+
1292
+ // ---- Files and images (as Studio on the web's: the same .eventcatalog files, so diagrams move between them) ----
1293
+
1294
+ // A short message at the top (a file that couldn't be opened or saved)
1295
+ const [notice, setNotice] = useState<string>();
1296
+ useEffect(() => {
1297
+ if (!notice) return;
1298
+ const timer = setTimeout(() => setNotice(undefined), 6000);
1299
+ return () => clearTimeout(timer);
1300
+ }, [notice]);
1301
+ /** The canvas as a diagram (the model a .eventcatalog file holds) */
1302
+ const canvasModel = () =>
1303
+ canvasToModel(
1304
+ nodesRef.current,
1305
+ edgesRef.current,
1306
+ flow.levelLayouts,
1307
+ { title: flow.title || 'Untitled canvas', resources: [], relations: [] },
1308
+ comments.threads,
1309
+ flow.hotspots
1310
+ );
1311
+ const saveToFile = useStableCallback(() => {
1312
+ const text = toDiagramFile(canvasModel());
1313
+ downloadFile({ name: fileNameFor(flow.title || 'canvas', DIAGRAM_FILE_EXTENSION), mimeType: 'application/json', text });
1314
+ });
1315
+ const exportImage = useStableCallback(async () => {
1316
+ const image = canvasRef.current && (await canvasImage(canvasRef.current));
1317
+ if (!image) return setNotice('Nothing to export yet: put something on the canvas first.');
1318
+ downloadFile({ name: fileNameFor(flow.title || 'canvas', '.png'), mimeType: 'image/png', base64: image.split(',')[1] });
1319
+ });
1320
+ /**
1321
+ * A .eventcatalog file (from Studio on the web, or saved here), opened on this canvas while it's empty, else as a
1322
+ * new canvas (this one stays as it is)
1323
+ */
1324
+ const openFile = useStableCallback(async () => {
1325
+ if (!canvasesApiUrl) return;
1326
+ const file = await pickDiagramFile();
1327
+ if (!file) return;
1328
+ const opened = await readDiagramFile(file);
1329
+ if ('error' in opened) return setNotice(opened.error);
1330
+ try {
1331
+ const content = await modelToCanvas(opened.model);
1332
+ if (!nodesRef.current.length && !comments.threads.length && !flow.hotspots.length) {
1333
+ flow.addContent(content);
1334
+ if (!flow.title || flow.title === 'Untitled canvas') flow.rename(opened.model.title);
1335
+ setTimeout(() => void fitView({ ...fitOptions, duration: 450 }), 100);
1336
+ return;
1337
+ }
1338
+ const created = await createCanvasThroughApi(canvasesApiUrl, { title: opened.model.title, createdBy: name });
1339
+ if (!keepOpened(created.id, content))
1340
+ return setNotice("Couldn't open it in a new canvas here (this browser's storage is blocked).");
1341
+ window.location.assign(created.url);
1342
+ } catch (error) {
1343
+ setNotice(error instanceof Error ? error.message : "Couldn't open that file.");
1344
+ }
1345
+ });
1346
+ // A file opened into this canvas (just made for it): put on it once it's loaded, if it's still empty
1347
+ useEffect(() => {
1348
+ if (!flow.synced) return;
1349
+ const content = takeOpened(canvasId);
1350
+ if (!content || nodesRef.current.length) return;
1351
+ flow.addContent(content);
1352
+ setTimeout(() => void fitView({ ...fitOptions, duration: 450 }), 100);
1353
+ }, [flow.synced, canvasId]);
1354
+ useHotkeys('mod+o', () => void openFile(), { preventDefault: true, enabled: !!canvasesApiUrl });
1355
+ useHotkeys('mod+s', () => saveToFile(), { preventDefault: true });
1356
+ useHotkeys('mod+shift+e', () => void exportImage(), { preventDefault: true });
1357
+ // ? for help (by the character typed, so it works on every keyboard layout)
1358
+ useEffect(() => {
1359
+ const onKeyDown = (event: KeyboardEvent) => {
1360
+ if ((event.target as HTMLElement)?.closest?.('input, textarea, [contenteditable]')) return;
1361
+ if (event.key === '?') {
1362
+ event.preventDefault();
1363
+ setHelpOpen(true);
1364
+ }
1365
+ };
1366
+ document.addEventListener('keydown', onKeyDown);
1367
+ return () => document.removeEventListener('keydown', onKeyDown);
1368
+ }, []);
736
1369
  const [deleteOpen, setDeleteOpen] = useState(false);
737
1370
  const openDelete = useCallback(() => setDeleteOpen(true), []);
738
1371
  const closeDelete = useCallback(() => setDeleteOpen(false), []);
@@ -789,11 +1422,26 @@ function Canvas({
789
1422
  { preventDefault: true }
790
1423
  );
791
1424
  // On every level: notes are changed on L1 and L2 too (and the levels show what's undone on the canvas)
792
- useHotkeys('mod+z', () => flow.undo(), { preventDefault: true });
793
- useHotkeys(['mod+shift+z', 'mod+y'], () => flow.redo(), { preventDefault: true });
794
- useHotkeys('c', () => onCanvas() && setCommentMode((on) => !on));
1425
+ useHotkeys('mod+z', () => !lockedRef.current && flow.undo(), { preventDefault: true });
1426
+ useHotkeys(['mod+shift+z', 'mod+y'], () => !lockedRef.current && flow.redo(), { preventDefault: true });
1427
+ // Drawing tools' keys: V to select, H for the hand, N, T, C and A to put something down, 1-3 for the levels,
1428
+ // Shift+1 to fit
1429
+ useHotkeys('n', () => pickTool('note'));
1430
+ useHotkeys('t', () => pickTool('text'));
1431
+ useHotkeys('c', () => pickTool('comment'));
1432
+ // A for attention: a hotspot
1433
+ useHotkeys('a', () => pickTool('hotspot'));
1434
+ useHotkeys('v', () => setTool('select'));
1435
+ useHotkeys('h', () => setTool('hand'));
1436
+ useHotkeys(['1', '2', '3'], (event) => {
1437
+ const next = Number(event.code.replace('Digit', '')) as Level;
1438
+ if (next === 3 || !unavailableLevels[next]) changeLevel(next);
1439
+ });
1440
+ useHotkeys('shift+1', () => void fitView({ ...fitOptions, duration: 500 }), { useKey: false });
795
1441
  useHotkeys('escape', () => {
796
- setCommentMode(false);
1442
+ setHotspotDraft(null);
1443
+ setOpenHotspot(null);
1444
+ setTool('select');
797
1445
  setDraft(null);
798
1446
  setOpenThreadId(null);
799
1447
  clearSelectedThreads();
@@ -811,20 +1459,48 @@ function Canvas({
811
1459
  comments.deleteThreads([...selectedThreads]);
812
1460
  clearSelectedThreads();
813
1461
  });
814
- const toggleCommentMode = useCallback(() => setCommentMode((on) => !on), []);
1462
+ /** A tool that puts something down: picked, or put back to selecting if it was already */
1463
+ const pickTool = useStableCallback((next: 'text' | 'note' | 'comment' | 'hotspot') => {
1464
+ // (comments can be left on a locked canvas: reviewing it changes nothing on it)
1465
+ if (lockedRef.current && next !== 'comment') return;
1466
+ // Comments are pinned to the canvas people edit (L3)
1467
+ if (next === 'comment' && levelRef.current !== 3) changeLevel(3);
1468
+ setTool((current) => (current === next ? 'select' : next));
1469
+ });
1470
+ const toggleCommentMode = useCallback(() => pickTool('comment'), [pickTool]);
1471
+ const toggleNoteMode = useCallback(() => pickTool('note'), [pickTool]);
1472
+ const toggleTextMode = useCallback(() => pickTool('text'), [pickTool]);
1473
+ const toggleHotspotMode = useCallback(() => pickTool('hotspot'), [pickTool]);
1474
+ const selectTool = useCallback(() => setTool('select'), []);
1475
+ const handTool = useCallback(() => setTool('hand'), []);
1476
+ const toggleLock = useStableCallback(() => {
1477
+ setTool('select');
1478
+ flow.setLocked(lockedRef.current ? null : name);
1479
+ });
815
1480
 
816
1481
  const startComment = (anchor: CommentAnchor) => {
817
1482
  setOpenThreadId(null);
818
1483
  setDraft(anchor);
819
- setCommentMode(false);
1484
+ setTool('select');
820
1485
  };
821
1486
 
822
1487
  const flowPositionOf = (event: { clientX: number; clientY: number }) =>
823
1488
  screenToFlowPosition({ x: event.clientX, y: event.clientY });
824
1489
 
1490
+ // A place on L1 or L2 is on that level (they're laid out on their own): what's put there says so
1491
+ const placeLevel = () => (levelRef.current === 3 ? {} : { level: levelRef.current as 1 | 2 });
825
1492
  const onPaneClick = useStableCallback((event: ReactMouseEvent) => {
826
1493
  clearSelectedThreads();
1494
+ if (tool === 'hand') return;
827
1495
  if (commentMode && editable) return startComment(anchorAt(flowPositionOf(event)));
1496
+ // Flagging: a hotspot where it's clicked
1497
+ if (tool === 'hotspot') return startHotspot({ position: flowPositionOf(event), ...placeLevel() });
1498
+ // The text and note tools: put where it's clicked, then back to selecting
1499
+ if (placing) {
1500
+ setTool('select');
1501
+ if (tool === 'text') return writeTextAt(flowPositionOf(event));
1502
+ return addNoteAt(flowPositionOf(event));
1503
+ }
828
1504
  // A click inside a container (not on something in it) selects it: its body lets the pointer through so you can
829
1505
  // drag a selection box in it, so the click lands on the canvas
830
1506
  // (after React Flow clears the selection, which it does once this returns)
@@ -836,24 +1512,31 @@ function Canvas({
836
1512
 
837
1513
  // Double clicking a node edits its details (notes are edited on the canvas, by double clicking them too)
838
1514
  const onNodeDoubleClick = useStableCallback((_: ReactMouseEvent, node: Node) => {
839
- if (editable && !isWrittenOnCanvas(node.type)) setEditingId(node.id);
1515
+ // On L1 and L2 too: what's there is on the canvas (a system card is the system)
1516
+ if (!isWrittenOnCanvas(node.type) && nodesRef.current.some((other) => other.id === node.id)) setEditingId(node.id);
840
1517
  });
841
1518
  // ...and inside a container (not on something in it): its body lets the pointer through, so it's on the canvas
1519
+ // ...and anywhere else on the canvas writes there, as on Excalidraw's (and Studio on the web's)
842
1520
  const onCanvasDoubleClick = useStableCallback((event: ReactMouseEvent) => {
843
- if (!editable || !(event.target as Element).classList?.contains('react-flow__pane')) return;
844
- const container = findGroupAtPoint(flowPositionOf(event), nodesRef.current);
845
- if (container) setEditingId(container.id);
1521
+ if (!(event.target as Element).classList?.contains('react-flow__pane')) return;
1522
+ const container = editable ? findGroupAtPoint(flowPositionOf(event), nodesRef.current) : undefined;
1523
+ if (container) return setEditingId(container.id);
1524
+ if (!lockedRef.current) writeTextAt(flowPositionOf(event));
846
1525
  });
847
1526
 
848
1527
  const onNodeClick = useStableCallback((event: ReactMouseEvent, node: Node) => {
849
1528
  // Clicking one node selects just it (with shift or ⌘, it's added to what's selected)
850
1529
  if (!event.shiftKey && !event.metaKey) clearSelectedThreads();
1530
+ // Not on notes and text: they're written on, not what's being flagged or commented on
1531
+ if (isWrittenOnCanvas(node.type)) return;
1532
+ // A hotspot on it (it shows on the node, wherever it goes, on every level it's on)
1533
+ if (tool === 'hotspot') return startHotspot({ nodeId: node.id, position: flowPositionOf(event) });
851
1534
  if (commentMode && editable) startComment(anchorAt(flowPositionOf(event), node, lookupNodes()));
852
1535
  });
853
1536
 
854
1537
  const openContextMenu = useStableCallback((event: ReactMouseEvent | MouseEvent, node?: Node) => {
855
1538
  event.preventDefault();
856
- if (!editable) return;
1539
+ if (!editable || lockedRef.current) return;
857
1540
  setContextMenu({ x: event.clientX, y: event.clientY, flowPosition: flowPositionOf(event), node });
858
1541
  });
859
1542
  const onPaneContextMenu = useCallback((event: ReactMouseEvent | MouseEvent) => openContextMenu(event), [openContextMenu]);
@@ -874,6 +1557,26 @@ function Canvas({
874
1557
  if (contextMenu?.node) setEditingId(contextMenu.node.id);
875
1558
  setContextMenu(null);
876
1559
  });
1560
+ /** A sticky note where the menu was opened, or to the right of the node right clicked (about it) */
1561
+ const noteFromMenu = useStableCallback(() => {
1562
+ if (!contextMenu) return;
1563
+ const node = contextMenu.node && lookupNodes().get(contextMenu.node.id);
1564
+ if (node) {
1565
+ const at = getAbsolutePosition(node, lookupNodes());
1566
+ const width = node.measured?.width ?? node.width ?? 240;
1567
+ addNoteAt({ x: at.x + width + 140, y: at.y + 88 });
1568
+ } else addNoteAt(contextMenu.flowPosition);
1569
+ setContextMenu(null);
1570
+ });
1571
+ const hotspotFromMenu = useStableCallback(() => {
1572
+ if (!contextMenu) return;
1573
+ startHotspot({ ...(contextMenu.node ? { nodeId: contextMenu.node.id } : placeLevel()), position: contextMenu.flowPosition });
1574
+ setContextMenu(null);
1575
+ });
1576
+ const reorderFromMenu = useStableCallback(() => {
1577
+ setContextMenu(null);
1578
+ onReorder();
1579
+ });
877
1580
  const copyFromMenu = useStableCallback(() => {
878
1581
  // Through the copy event, so it's copied like ⌘C (to the system clipboard too)
879
1582
  copyOnly.current = menuTargets();
@@ -896,6 +1599,7 @@ function Canvas({
896
1599
  });
897
1600
 
898
1601
  const openThread = useStableCallback((thread: Thread) => {
1602
+ setListingComments(false);
899
1603
  const { x, y } = getAnchorPosition(thread, lookupNodes());
900
1604
  // Leave room for the thread, which opens to the right of its pin
901
1605
  void setCenter(x + 150, y, { zoom: getZoom(), duration: 300 });
@@ -903,6 +1607,18 @@ function Canvas({
903
1607
  setOpenThreadId(thread.id);
904
1608
  });
905
1609
  const toggleResolved = useCallback(() => setShowResolved((show) => !show), []);
1610
+ const [panelTab, setPanelTab] = useState<LeftPanelTab>('components');
1611
+ // All the comments, listed over the bottom left controls (as Studio on the web's): clicking one goes to it
1612
+ const [listingComments, setListingComments] = useState(false);
1613
+ const toggleCommentList = useCallback(() => {
1614
+ setListingHotspots(false);
1615
+ setListingComments((shown) => !shown);
1616
+ }, []);
1617
+ const openComments = comments.threads.filter((thread) => !thread.resolved).length;
1618
+ const commentsControl = useMemo(
1619
+ () => ({ open: openComments, onToggle: toggleCommentList, active: listingComments }),
1620
+ [openComments, toggleCommentList, listingComments]
1621
+ );
906
1622
  const openThreadById = useCallback((id: string | null) => {
907
1623
  setDraft(null);
908
1624
  setOpenThreadId(id);
@@ -934,16 +1650,37 @@ function Canvas({
934
1650
  })
935
1651
  );
936
1652
  }, [selectedKey]);
937
- // The node whose details are being edited in the panel: opened by double clicking it, or "Edit details" in its
938
- // menu (selecting a node doesn't open it). Notes are edited on the canvas.
1653
+ // The node whose details are being edited in its card: opened with the edit button over it, by double clicking it,
1654
+ // or "Edit details" in its menu (selecting a node doesn't open it). Notes are edited on the canvas.
939
1655
  const [editingId, setEditingId] = useState<string | null>(null);
940
- const editing =
941
- editable && editingId ? flow.nodes.find((node) => node.id === editingId && !isWrittenOnCanvas(node.type)) : undefined;
942
- // What the panel shows (not the node's position, so dragging it doesn't re-render the panel)
1656
+ const editing = editingId ? flow.nodes.find((node) => node.id === editingId && !isWrittenOnCanvas(node.type)) : undefined;
1657
+ // What the card shows (not the node's position, so dragging it doesn't re-render the card)
943
1658
  const inspected = useMemo(
944
1659
  () => (editing ? { id: editing.id, type: editing.type, data: editing.data } : null),
945
1660
  [editing?.id, editing?.type, editing?.data]
946
1661
  );
1662
+ // The domain or system it's in
1663
+ const parent = editing?.parentId ? flow.nodes.find((node) => node.id === editing.parentId) : undefined;
1664
+ const inspectedParent = parent ? String(getResource(parent.type, parent.data).name ?? '') || undefined : undefined;
1665
+ // What it's connected to, for its card
1666
+ const inspectedConnections = useMemo((): NodeConnection[] => {
1667
+ if (!editingId) return [];
1668
+ const byId = new Map(flow.nodes.map((node) => [node.id, node]));
1669
+ return flow.edges.flatMap((edge) => {
1670
+ const outgoing = edge.source === editingId;
1671
+ if (!outgoing && edge.target !== editingId) return [];
1672
+ const other = byId.get(outgoing ? edge.target : edge.source);
1673
+ if (!other || isWrittenOnCanvas(other.type)) return [];
1674
+ const name = String(getResource(other.type, other.data).name ?? '') || getNodeLabel(other.type);
1675
+ return [{ id: other.id, name, type: other.type, label: typeof edge.label === 'string' ? edge.label : undefined, outgoing }];
1676
+ });
1677
+ }, [editingId, flow.nodes, flow.edges]);
1678
+ // Goes to a node it's connected to, and shows its card
1679
+ const openConnected = useStableCallback((id: string) => {
1680
+ setEditingId(id);
1681
+ select(new Set([id]));
1682
+ void fitView({ nodes: [{ id }], duration: 400, padding: 0.3, maxZoom: 1.2 });
1683
+ });
947
1684
  const closePanel = useCallback(() => setEditingId(null), []);
948
1685
  // An accepted or rejected canvas, and when it was decided
949
1686
  const { status: canvasStatus, history: statusHistory } = flow.canvasStatus;
@@ -954,262 +1691,400 @@ function Canvas({
954
1691
  return (
955
1692
  <UpdateNodeDataContext.Provider value={flow.updateNodeData}>
956
1693
  <DropTargetContext.Provider value={dropTargetId}>
957
- <div className="flex h-full min-h-0 text-[rgb(var(--ec-page-text))]">
958
- <LeftPanel
959
- resources={resources}
960
- keysOnCanvas={keysOnCanvas}
961
- onAddComponent={addComponent}
962
- onAddResource={addResourceFromPanel}
963
- threads={comments.threads}
964
- showResolved={showResolved}
965
- onToggleResolved={toggleResolved}
966
- onOpenThread={openThread}
967
- />
968
-
969
- <div
970
- ref={canvasRef}
971
- className={`eventcatalog-visualizer studio-canvas relative flex-1 min-w-0 ${commentMode ? 'comment-mode' : ''} ${
972
- transitioning ? 'is-layout-transition' : ''
973
- }`}
974
- onPointerMove={trackPointer}
975
- onPointerLeave={clearPointer}
976
- onDoubleClick={onCanvasDoubleClick}
977
- >
978
- <ReactFlow
979
- nodes={shownNodes}
980
- edges={shownEdges}
981
- nodeTypes={nodeTypes}
982
- edgeTypes={edgeTypes}
983
- nodesDraggable={editable}
984
- nodesConnectable={editable}
985
- elementsSelectable={editable}
986
- onNodesChange={editable ? flow.onNodesChange : onLevelNodesChange}
987
- onEdgesChange={editable ? flow.onEdgesChange : undefined}
988
- onConnect={flow.onConnect}
989
- // Drag either end of a connection to another node (dropped anywhere else, it stays where it was)
990
- edgesReconnectable={editable}
991
- onReconnect={editable ? flow.onReconnect : undefined}
992
- reconnectRadius={RECONNECT_RADIUS}
993
- onDragOver={onDragOver}
994
- onMoveStart={onMoveStart}
995
- onMoveEnd={onMoveEnd}
996
- onNodeDragStart={onNodeDragStart}
997
- onNodeDrag={onNodeDrag}
998
- onNodeDragStop={onNodeDragStop}
999
- onDrop={onDrop}
1000
- onPaneClick={onPaneClick}
1001
- onNodeClick={onNodeClick}
1002
- onNodeDoubleClick={onNodeDoubleClick}
1003
- onError={onFlowError}
1004
- onSelectionStart={clearSelectedThreads}
1005
- onPaneContextMenu={onPaneContextMenu}
1006
- onNodeContextMenu={onNodeContextMenu}
1007
- connectionLineType={ConnectionLineType.SmoothStep}
1008
- connectionRadius={40}
1009
- deleteKeyCode={DELETE_KEYS}
1010
- multiSelectionKeyCode={MULTI_SELECTION_KEYS}
1011
- selectionOnDrag={editable}
1012
- // Read only levels can't select: dragging pans them
1013
- panOnDrag={editable ? PAN_BUTTONS : true}
1014
- panOnScroll
1015
- zoomActivationKeyCode={ZOOM_KEYS}
1016
- zoomOnDoubleClick={false}
1017
- minZoom={0.1}
1018
- maxZoom={2}
1019
- >
1020
- <Background gap={10} bgColor="rgb(var(--ec-page-bg))" color="rgb(var(--ec-page-border))" />
1021
- <ViewportSharer onChange={flow.setViewport} />
1022
- {/* Pointers, selections and comments are on the canvas people edit (L3) */}
1023
- {editable && <RemotePresence store={flow.presence} clientId={flow.clientId} />}
1024
- {editable && (
1025
- <CommentLayer
1026
- threads={comments.threads}
1027
- author={author}
1028
- openThreadId={openThreadId}
1029
- draft={draft}
1030
- showResolved={showResolved}
1031
- onOpen={openThreadById}
1032
- onCreate={createThread}
1033
- onCancelDraft={cancelDraft}
1034
- onReply={comments.reply}
1035
- onResolve={comments.setResolved}
1036
- onMove={comments.moveThread}
1037
- onMoveDraft={setDraft}
1038
- onDelete={deleteThread}
1039
- selectedIds={selectedThreads}
1040
- selectionOffset={threadsOffset}
1041
- />
1042
- )}
1043
- </ReactFlow>
1044
-
1045
- <AgentActivity
1046
- store={flow.presence}
1047
- clientId={flow.clientId}
1048
- enabled={editable}
1049
- following={following}
1050
- onToggleFollowing={toggleFollowing}
1051
- pauseRef={pauseCamera}
1052
- />
1053
-
1054
- {!editable && (
1055
- <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))]">
1056
- <span>
1057
- <span className="font-semibold">L{level}</span> · {LEVELS.find((entry) => entry.level === level)?.description} ·
1058
- read only, sticky notes can be added
1059
- </span>
1060
- <button onClick={editInL3} className="font-semibold text-[rgb(var(--ec-accent))] hover:underline">
1061
- Edit in L3
1062
- </button>
1063
- </div>
1064
- )}
1065
-
1066
- {/* Accepted or rejected: still editable, but changing it makes it a draft again (for everyone) */}
1067
- {editable && decided && (
1068
- <div
1069
- className={`absolute left-1/2 top-16 z-10 flex max-w-[calc(100%-2rem)] -translate-x-1/2 items-center gap-2 rounded-full border px-4 py-1.5 text-xs shadow-sm ${CANVAS_STATUS_LOOK[decided.status].pill}`}
1070
- >
1071
- <span className="truncate">
1072
- <span className="font-semibold">{CANVAS_STATUS_LOOK[decided.status].label}</span>
1073
- {decided.change && ` by ${decided.change.by.name} · ${timeAgo(decided.change.at)}`}
1074
- {decided.change?.note && ` · “${decided.change.note}”`} · changing it moves it back to Draft
1075
- </span>
1076
- </div>
1077
- )}
1078
-
1079
- <CanvasControls
1080
- level={level}
1081
- onLevelChange={changeLevel}
1082
- unavailableLevels={unavailableLevels}
1083
- editable={editable}
1084
- onReorder={onReorder}
1085
- canUndo={flow.canUndo}
1086
- canRedo={flow.canRedo}
1087
- onUndo={flow.undo}
1088
- onRedo={flow.redo}
1089
- commentMode={commentMode}
1090
- onToggleCommentMode={toggleCommentMode}
1091
- onAddNote={addNote}
1092
- />
1093
-
1094
- <CanvasContextMenu
1095
- menu={contextMenu}
1096
- onAddComment={commentFromMenu}
1097
- onEditDetails={editFromMenu}
1098
- onCopy={copyFromMenu}
1099
- onDuplicate={duplicateFromMenu}
1100
- onPaste={lastCopied ? pasteFromMenu : undefined}
1101
- onDeleteNode={deleteFromMenu}
1102
- onClose={closeContextMenu}
1103
- />
1104
-
1105
- <CanvasHeader
1106
- title={flow.title}
1107
- onRetitle={flow.rename}
1108
- status={flow.status}
1109
- transport={flow.transport}
1110
- presence={flow.presence}
1111
- clientId={flow.clientId}
1112
- onJumpTo={jumpTo}
1113
- onRename={onRename}
1114
- onConnectAgent={openConnect}
1115
- onShare={openShare}
1116
- webMcp={webMcp}
1117
- shareUrl={shareUrl}
1118
- newCanvasUrl={newCanvasUrl}
1119
- canvasStatus={flow.canvasStatus.status}
1120
- statusHistory={flow.canvasStatus.history}
1121
- onStatusChange={flow.changeStatus}
1122
- onDelete={canvasesApiUrl ? openDelete : undefined}
1123
- />
1124
-
1125
- {flow.deleted && !leaving && (
1126
- <div className="absolute inset-0 z-40 flex items-center justify-center bg-[rgb(var(--ec-page-bg)/0.85)] p-4">
1127
- <div className="max-w-sm space-y-2 rounded-xl border p-5 text-center shadow-xl bg-[rgb(var(--ec-card-bg))] border-[rgb(var(--ec-page-border))]">
1128
- <h2 className="text-base font-semibold">This canvas was deleted</h2>
1129
- <p className="text-sm text-[rgb(var(--ec-page-text-muted))]">
1130
- Someone deleted it, so it can't be opened or changed any more.
1131
- </p>
1132
- {studioUrl && (
1133
- <a
1134
- href={studioUrl}
1135
- className="mt-2 inline-flex rounded-lg px-4 py-2 text-sm font-medium bg-[rgb(var(--ec-button-bg))] text-[rgb(var(--ec-button-text))] hover:bg-[rgb(var(--ec-button-bg-hover))]"
1694
+ <EditingContext.Provider value={flow.sendEditing}>
1695
+ <StartWritingContext.Provider value={startWriting}>
1696
+ <LockedContext.Provider value={locked}>
1697
+ <div className="flex h-full min-h-0 text-[rgb(var(--ec-page-text))]">
1698
+ <div
1699
+ ref={canvasRef}
1700
+ className={`eventcatalog-visualizer studio-canvas relative flex-1 min-w-0 ${commentMode ? 'comment-mode' : ''} ${
1701
+ transitioning ? 'is-layout-transition' : ''
1702
+ } ${placing || tool === 'comment' || tool === 'hotspot' ? 'is-placing' : ''} ${tool === 'hand' ? 'is-panning' : ''}`}
1703
+ onPointerMove={trackPointer}
1704
+ onPointerLeave={clearPointer}
1705
+ onDoubleClick={onCanvasDoubleClick}
1706
+ >
1707
+ <ReactFlow
1708
+ nodes={visibleNodes}
1709
+ edges={shownEdges}
1710
+ onNodeMouseEnter={onNodeMouseEnter}
1711
+ onNodeMouseLeave={onHoverEnd}
1712
+ onEdgeMouseEnter={onEdgeMouseEnter}
1713
+ onEdgeMouseLeave={onHoverEnd}
1714
+ nodeTypes={nodeTypes}
1715
+ edgeTypes={edgeTypes}
1716
+ nodesDraggable={!locked && tool !== 'hand'}
1717
+ nodesConnectable={!locked && tool !== 'hand'}
1718
+ elementsSelectable={tool !== 'hand'}
1719
+ onNodesChange={editable ? flow.onNodesChange : onLevelNodesChange}
1720
+ onEdgesChange={editable ? flow.onEdgesChange : undefined}
1721
+ onConnect={editable ? flow.onConnect : onLevelConnect}
1722
+ // Drag either end of a connection to another node (dropped anywhere else, it stays where it was)
1723
+ edgesReconnectable={editable && !locked}
1724
+ onReconnect={editable ? flow.onReconnect : undefined}
1725
+ reconnectRadius={RECONNECT_RADIUS}
1726
+ onDragOver={locked ? undefined : onDragOver}
1727
+ onMoveStart={onMoveStart}
1728
+ onMoveEnd={onMoveEnd}
1729
+ onNodeDragStart={onNodeDragStart}
1730
+ onNodeDrag={onNodeDrag}
1731
+ onNodeDragStop={onNodeDragStop}
1732
+ onDrop={locked ? undefined : onDrop}
1733
+ onPaneClick={onPaneClick}
1734
+ onNodeClick={onNodeClick}
1735
+ onNodeDoubleClick={onNodeDoubleClick}
1736
+ onError={onFlowError}
1737
+ onSelectionStart={clearSelectedThreads}
1738
+ onPaneContextMenu={onPaneContextMenu}
1739
+ onNodeContextMenu={onNodeContextMenu}
1740
+ connectionLineType={ConnectionLineType.SmoothStep}
1741
+ connectionRadius={40}
1742
+ deleteKeyCode={locked ? null : DELETE_KEYS}
1743
+ multiSelectionKeyCode={MULTI_SELECTION_KEYS}
1744
+ selectionOnDrag={tool !== 'hand'}
1745
+ // The hand drags the canvas around; otherwise dragging selects (space, the middle button or scrolling pan)
1746
+ panOnDrag={tool === 'hand' ? true : PAN_BUTTONS}
1747
+ panOnScroll
1748
+ zoomActivationKeyCode={ZOOM_KEYS}
1749
+ zoomOnDoubleClick={false}
1750
+ minZoom={0.1}
1751
+ maxZoom={2}
1752
+ >
1753
+ <DiagramBackground />
1754
+ {/* Above the selected node's top right corner: its details, to edit them */}
1755
+ {toEdit && <EditButton nodeId={toEdit.id} locked={locked} onClick={editSelected} />}
1756
+ <LegendPanel legend={legend} hiddenKeys={hiddenLegendKeys} onLegendClick={toggleLegendKey} />
1757
+ <ViewportSharer onChange={flow.setViewport} />
1758
+ {/* Pointers, selections and comments are on the canvas people edit (L3) */}
1759
+ {editable && <RemotePresence store={flow.presence} clientId={flow.clientId} />}
1760
+ {editable && (
1761
+ <CommentLayer
1762
+ threads={comments.threads}
1763
+ author={author}
1764
+ openThreadId={openThreadId}
1765
+ draft={draft}
1766
+ showResolved={showResolved}
1767
+ onOpen={openThreadById}
1768
+ onCreate={createThread}
1769
+ onCancelDraft={cancelDraft}
1770
+ onReply={comments.reply}
1771
+ onResolve={comments.setResolved}
1772
+ onMove={comments.moveThread}
1773
+ onMoveDraft={setDraft}
1774
+ onDelete={deleteThread}
1775
+ selectedIds={selectedThreads}
1776
+ selectionOffset={threadsOffset}
1777
+ />
1778
+ )}
1779
+ {/* Hotspots on every level (on L1 and L2, on what they're in) */}
1780
+ <HotspotLayer
1781
+ spots={hotspotSpots}
1782
+ openKey={openHotspot}
1783
+ draft={hotspotDraft}
1784
+ locked={locked}
1785
+ movable={!locked}
1786
+ selectedIds={null}
1787
+ onMove={moveHotspots}
1788
+ onDropTarget={setHotspotDropTarget}
1789
+ onOpen={setOpenHotspot}
1790
+ onCreate={createHotspot}
1791
+ onCancelDraft={cancelHotspot}
1792
+ onUpdate={flow.updateHotspot}
1793
+ onDelete={deleteHotspot}
1794
+ onGo={goToHotspot}
1795
+ />
1796
+ </ReactFlow>
1797
+
1798
+ <AgentActivity
1799
+ store={flow.presence}
1800
+ clientId={flow.clientId}
1801
+ enabled={editable}
1802
+ following={following}
1803
+ onToggleFollowing={toggleFollowing}
1804
+ pauseRef={pauseCamera}
1805
+ />
1806
+
1807
+ {!editable && (
1808
+ <div className="absolute left-1/2 top-16 z-10 flex -translate-x-1/2 items-center gap-3 rounded-full border px-4 py-1.5 text-xs shadow-sm bg-[rgb(var(--ec-accent-subtle))] border-[rgb(var(--ec-accent)/0.3)] text-[rgb(var(--ec-page-text))]">
1809
+ <span>
1810
+ <span className="font-semibold">L{level}</span> ·{' '}
1811
+ {LEVELS.find((entry) => entry.level === level)?.description}
1812
+ </span>
1813
+ <button onClick={editInL3} className="font-semibold text-[rgb(var(--ec-accent))] hover:underline">
1814
+ Everything is on L3
1815
+ </button>
1816
+ </div>
1817
+ )}
1818
+
1819
+ {/* Accepted or rejected: still editable, but changing it makes it a draft again (for everyone) */}
1820
+ {editable && decided && (
1821
+ <div
1822
+ className={`absolute left-1/2 top-16 z-10 flex max-w-[calc(100%-2rem)] -translate-x-1/2 items-center gap-2 rounded-full border px-4 py-1.5 text-xs shadow-sm ${CANVAS_STATUS_LOOK[decided.status].pill}`}
1136
1823
  >
1137
- Back to Studio
1138
- </a>
1824
+ <span className="truncate">
1825
+ <span className="font-semibold">{CANVAS_STATUS_LOOK[decided.status].label}</span>
1826
+ {decided.change && ` by ${decided.change.by.name} · ${timeAgo(decided.change.at)}`}
1827
+ {decided.change?.note && ` · “${decided.change.note}”`} · changing it moves it back to Draft
1828
+ </span>
1829
+ </div>
1830
+ )}
1831
+
1832
+ {/* At the top, as drawing tools have their tools (and Studio on the web has them) */}
1833
+ <CanvasControls
1834
+ level={level}
1835
+ onLevelChange={changeLevel}
1836
+ unavailableLevels={unavailableLevels}
1837
+ editable={editable}
1838
+ locked={locked}
1839
+ onToggleLock={toggleLock}
1840
+ lockedBy={flow.lockedBy}
1841
+ selectMode={tool === 'select'}
1842
+ onSelectMode={selectTool}
1843
+ handMode={tool === 'hand'}
1844
+ onHandMode={handTool}
1845
+ noteMode={tool === 'note'}
1846
+ onToggleNoteMode={toggleNoteMode}
1847
+ textMode={tool === 'text'}
1848
+ onToggleTextMode={toggleTextMode}
1849
+ commentMode={commentMode}
1850
+ onToggleCommentMode={toggleCommentMode}
1851
+ hotspotMode={tool === 'hotspot'}
1852
+ onToggleHotspotMode={toggleHotspotMode}
1853
+ />
1854
+
1855
+ {/* The palette: components, the catalog and the comments, under the canvas's title */}
1856
+ {!locked && (
1857
+ <LeftPanel
1858
+ level={level}
1859
+ onShowAll={editInL3}
1860
+ resources={resources}
1861
+ keysOnCanvas={keysOnCanvas}
1862
+ onAddComponent={addComponent}
1863
+ onAddResource={addResourceFromPanel}
1864
+ tab={panelTab}
1865
+ onTabChange={setPanelTab}
1866
+ startCollapsed={paletteCollapsed}
1867
+ />
1868
+ )}
1869
+
1870
+ {/* All the hotspots, most important first, over the bottom left controls: clicking one goes to it */}
1871
+ {listingComments && (
1872
+ <div className="absolute bottom-[64px] left-3 z-20 flex max-h-[min(480px,calc(100%-140px))] w-80 flex-col overflow-hidden rounded-xl border border-[rgb(var(--ec-page-border))] bg-[rgb(var(--ec-card-bg))] shadow-xl">
1873
+ <div className="flex flex-none items-center justify-between border-b border-[rgb(var(--ec-page-border))] px-3 py-2 text-sm font-semibold">
1874
+ Comments
1875
+ <button
1876
+ type="button"
1877
+ aria-label="Close"
1878
+ onClick={toggleCommentList}
1879
+ className="rounded p-1 text-[rgb(var(--ec-page-text-muted))] hover:bg-[rgb(var(--ec-content-hover))]"
1880
+ >
1881
+ <X className="h-4 w-4" />
1882
+ </button>
1883
+ </div>
1884
+ <div className="flex min-h-0 flex-1 flex-col overflow-y-auto pt-2">
1885
+ <CommentList
1886
+ threads={comments.threads}
1887
+ showResolved={showResolved}
1888
+ onToggleResolved={toggleResolved}
1889
+ onOpen={openThread}
1890
+ />
1891
+ </div>
1892
+ </div>
1893
+ )}
1894
+ {listingHotspots && (
1895
+ <div className="absolute bottom-[64px] left-3 z-20 flex max-h-[min(480px,calc(100%-140px))] w-80 flex-col overflow-hidden rounded-xl border border-[rgb(var(--ec-page-border))] bg-[rgb(var(--ec-card-bg))] shadow-xl">
1896
+ <div className="flex flex-none items-center justify-between border-b border-[rgb(var(--ec-page-border))] px-3 py-2 text-sm font-semibold">
1897
+ Hotspots
1898
+ <button
1899
+ type="button"
1900
+ aria-label="Close"
1901
+ onClick={toggleHotspotList}
1902
+ className="rounded p-1 text-[rgb(var(--ec-page-text-muted))] hover:bg-[rgb(var(--ec-content-hover))]"
1903
+ >
1904
+ <X className="h-4 w-4" />
1905
+ </button>
1906
+ </div>
1907
+ <div className="min-h-0 flex-1 overflow-y-auto">
1908
+ <HotspotList hotspots={flow.hotspots} nameOf={nameOfNode} onOpen={goToHotspot} />
1909
+ </div>
1910
+ </div>
1911
+ )}
1912
+
1913
+ {/* Bottom left: zoom, undo and redo (as Excalidraw's), fitting and reordering, the comments and hotspots */}
1914
+ <div className="absolute bottom-3 left-3 z-10 flex items-center gap-2">
1915
+ <ViewControls
1916
+ canUndo={flow.canUndo && !locked}
1917
+ canRedo={flow.canRedo && !locked}
1918
+ onUndo={flow.undo}
1919
+ onRedo={flow.redo}
1920
+ onReorder={onReorder}
1921
+ reorderLabel={
1922
+ editable ? "Reorder diagram (the visualiser's layout)" : `Reorder L${level} (the visualiser's layout)`
1923
+ }
1924
+ locked={locked}
1925
+ fitOptions={fitOptions}
1926
+ comments={commentsControl}
1927
+ hotspots={hotspotsControl}
1928
+ />
1929
+ </div>
1930
+
1931
+ <ContextMenu
1932
+ menu={contextMenu}
1933
+ onAddComment={commentFromMenu}
1934
+ onAddHotspot={hotspotFromMenu}
1935
+ onAddNote={noteFromMenu}
1936
+ onEditDetails={editFromMenu}
1937
+ onCopy={copyFromMenu}
1938
+ onDuplicate={duplicateFromMenu}
1939
+ onPaste={lastCopied ? pasteFromMenu : undefined}
1940
+ onReorder={reorderFromMenu}
1941
+ onDeleteNode={deleteFromMenu}
1942
+ onClose={closeContextMenu}
1943
+ />
1944
+
1945
+ <CanvasHeader
1946
+ title={flow.title}
1947
+ onRetitle={flow.rename}
1948
+ status={flow.status}
1949
+ transport={flow.transport}
1950
+ presence={flow.presence}
1951
+ clientId={flow.clientId}
1952
+ onJumpTo={jumpTo}
1953
+ me={author}
1954
+ onRename={onRename}
1955
+ onRecolor={onRecolor}
1956
+ onConnectAgent={openConnect}
1957
+ onShare={openShare}
1958
+ onOpenFile={canvasesApiUrl ? openFile : undefined}
1959
+ onSaveToFile={saveToFile}
1960
+ onExportImage={exportImage}
1961
+ onLiveCollaboration={openLiveCollaboration}
1962
+ onHelp={openHelp}
1963
+ webMcp={webMcp}
1964
+ shareUrl={shareUrl}
1965
+ newCanvasUrl={newCanvasUrl}
1966
+ canvasStatus={flow.canvasStatus.status}
1967
+ statusHistory={flow.canvasStatus.history}
1968
+ onStatusChange={flow.changeStatus}
1969
+ onDelete={canvasesApiUrl ? openDelete : undefined}
1970
+ />
1971
+
1972
+ {flow.deleted && !leaving && (
1973
+ <div className="absolute inset-0 z-40 flex items-center justify-center bg-[rgb(var(--ec-page-bg)/0.85)] p-4">
1974
+ <div className="max-w-sm space-y-2 rounded-xl border p-5 text-center shadow-xl bg-[rgb(var(--ec-card-bg))] border-[rgb(var(--ec-page-border))]">
1975
+ <h2 className="text-base font-semibold">This canvas was deleted</h2>
1976
+ <p className="text-sm text-[rgb(var(--ec-page-text-muted))]">
1977
+ Someone deleted it, so it can't be opened or changed any more.
1978
+ </p>
1979
+ {studioUrl && (
1980
+ <a
1981
+ href={studioUrl}
1982
+ className="mt-2 inline-flex rounded-lg px-4 py-2 text-sm font-medium bg-[rgb(var(--ec-button-bg))] text-[rgb(var(--ec-button-text))] hover:bg-[rgb(var(--ec-button-bg-hover))]"
1983
+ >
1984
+ Back to Studio
1985
+ </a>
1986
+ )}
1987
+ </div>
1988
+ </div>
1989
+ )}
1990
+
1991
+ {/* Only on an empty canvas (not over comments people have left on it): what it's for, and what to do */}
1992
+ {flow.status === 'connected' &&
1993
+ flow.nodes.length === 0 &&
1994
+ comments.threads.length === 0 &&
1995
+ flow.hotspots.length === 0 &&
1996
+ !draft && (
1997
+ <EmptyCanvas
1998
+ // Just the hints to the palette, levels and Share (the catalog is around it, not a blank page)
1999
+ welcome={false}
2000
+ onOpen={canvasesApiUrl ? openFile : undefined}
2001
+ onHelp={openHelp}
2002
+ onConnect={openConnect}
2003
+ palette={!locked}
2004
+ live
2005
+ />
2006
+ )}
2007
+
2008
+ {/* The node being edited: its card, next to it (as Studio on the web's) */}
2009
+ {inspected && (
2010
+ <NodeDetails
2011
+ key={inspected.id}
2012
+ node={inspected}
2013
+ parentName={inspectedParent}
2014
+ connections={inspectedConnections}
2015
+ onOpenNode={openConnected}
2016
+ containerRef={canvasRef}
2017
+ locked={locked}
2018
+ onChange={flow.updateNodeData}
2019
+ onClose={closePanel}
2020
+ />
1139
2021
  )}
1140
2022
  </div>
2023
+
2024
+ {chooser && (
2025
+ <ContainerChooser
2026
+ kind={chooser.resource.collection === 'domains' ? 'domain' : 'system'}
2027
+ name={chooser.resource.name}
2028
+ contents={chooserContents}
2029
+ position={flowToScreenPosition(chooser.at.point)}
2030
+ onChoose={choose}
2031
+ onCancel={cancelChooser}
2032
+ />
2033
+ )}
2034
+ {connectionsChooser && (
2035
+ <ConnectionsChooser
2036
+ resource={connectionsChooser.resource}
2037
+ groups={connectionsChooser.groups}
2038
+ position={flowToScreenPosition(connectionsChooser.at.point)}
2039
+ onChoose={chooseConnections}
2040
+ onCancel={cancelConnectionsChooser}
2041
+ />
2042
+ )}
2043
+ {shareOpen && (
2044
+ <ShareDialog
2045
+ initialView={shareOpen}
2046
+ keptInMemory={keptInMemory}
2047
+ url={agentLink.canvasUrl}
2048
+ title={flow.title}
2049
+ presence={flow.presence}
2050
+ clientId={flow.clientId}
2051
+ onRename={onRename}
2052
+ onJumpTo={jumpTo}
2053
+ onConnectAgent={openConnect}
2054
+ onClose={closeShare}
2055
+ />
2056
+ )}
2057
+ {helpOpen && <ShortcutsDialog onClose={closeHelp} />}
2058
+ {notice && (
2059
+ <div
2060
+ role="status"
2061
+ className="absolute left-1/2 top-[72px] z-30 max-w-md -translate-x-1/2 rounded-lg border border-[rgb(var(--ec-page-border))] bg-[rgb(var(--ec-card-bg))] px-4 py-2 text-sm shadow-lg"
2062
+ >
2063
+ {notice}
2064
+ </div>
2065
+ )}
2066
+ {deleteOpen && (
2067
+ <DeleteCanvasDialog
2068
+ title={flow.title}
2069
+ presence={flow.presence}
2070
+ clientId={flow.clientId}
2071
+ onDelete={deleteCanvas}
2072
+ onClose={closeDelete}
2073
+ />
2074
+ )}
2075
+ {connectOpen && (
2076
+ <ConnectAgentDialog
2077
+ link={agentLink}
2078
+ webMcp={webMcp}
2079
+ relayScriptPath={relayScriptPath}
2080
+ onEnableWebMcp={turnOnWebMcp}
2081
+ onClose={closeConnect}
2082
+ />
2083
+ )}
1141
2084
  </div>
1142
- )}
1143
-
1144
- {/* Only on an empty canvas (not over comments people have left on it) */}
1145
- {flow.status === 'connected' && flow.nodes.length === 0 && comments.threads.length === 0 && !draft && (
1146
- <div className="pointer-events-none absolute inset-0 flex items-center justify-center">
1147
- <p className="text-sm text-[rgb(var(--ec-page-text-muted))]">
1148
- Drag components or catalog resources from the left onto the canvas, then share the link to work on it together.
1149
- </p>
1150
- </div>
1151
- )}
1152
- </div>
1153
-
1154
- {chooser && (
1155
- <ContainerChooser
1156
- resource={chooser.resource}
1157
- contents={chooserContents}
1158
- position={flowToScreenPosition(chooser.center)}
1159
- onChoose={choose}
1160
- onCancel={cancelChooser}
1161
- />
1162
- )}
1163
- {connectionsChooser && (
1164
- <ConnectionsChooser
1165
- resource={connectionsChooser.resource}
1166
- groups={connectionsChooser.groups}
1167
- position={flowToScreenPosition(connectionsChooser.center)}
1168
- onChoose={chooseConnections}
1169
- onCancel={cancelConnectionsChooser}
1170
- />
1171
- )}
1172
- {shareOpen && (
1173
- <ShareDialog
1174
- keptInMemory={keptInMemory}
1175
- url={agentLink.canvasUrl}
1176
- title={flow.title}
1177
- presence={flow.presence}
1178
- clientId={flow.clientId}
1179
- onRename={onRename}
1180
- onJumpTo={jumpTo}
1181
- onConnectAgent={openConnect}
1182
- onClose={closeShare}
1183
- />
1184
- )}
1185
- {deleteOpen && (
1186
- <DeleteCanvasDialog
1187
- title={flow.title}
1188
- presence={flow.presence}
1189
- clientId={flow.clientId}
1190
- onDelete={deleteCanvas}
1191
- onClose={closeDelete}
1192
- />
1193
- )}
1194
- {connectOpen && (
1195
- <ConnectAgentDialog
1196
- link={agentLink}
1197
- webMcp={webMcp}
1198
- relayScriptPath={relayScriptPath}
1199
- onEnableWebMcp={turnOnWebMcp}
1200
- onClose={closeConnect}
1201
- />
1202
- )}
1203
- {inspected && (
1204
- <PropertiesPanel
1205
- key={inspected.id}
1206
- node={inspected}
1207
- onChange={flow.updateNodeData}
1208
- onDelete={flow.deleteNodes}
1209
- onClose={closePanel}
1210
- />
1211
- )}
1212
- </div>
2085
+ </LockedContext.Provider>
2086
+ </StartWritingContext.Provider>
2087
+ </EditingContext.Provider>
1213
2088
  </DropTargetContext.Provider>
1214
2089
  </UpdateNodeDataContext.Provider>
1215
2090
  );