@eventcatalog/core 5.0.0-beta.12 → 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 (82) hide show
  1. package/dist/analytics/analytics.cjs +1 -1
  2. package/dist/analytics/analytics.js +2 -2
  3. package/dist/analytics/log-build.cjs +1 -1
  4. package/dist/analytics/log-build.js +4 -4
  5. package/dist/catalog-runtime.cjs +1 -1
  6. package/dist/{chunk-FY6MSP6J.js → chunk-BO2HHVZ3.js} +1 -1
  7. package/dist/{chunk-CX2VB7AI.js → chunk-JPT3QC5V.js} +1 -1
  8. package/dist/{chunk-BWC4YKW3.js → chunk-PLTLW45Q.js} +5 -5
  9. package/dist/{chunk-TSY5CC27.js → chunk-T75DOVFP.js} +1 -1
  10. package/dist/{chunk-QB7X34OD.js → chunk-XA5W6DXX.js} +1 -1
  11. package/dist/constants.cjs +1 -1
  12. package/dist/constants.js +1 -1
  13. package/dist/eventcatalog.cjs +2 -2
  14. package/dist/eventcatalog.js +14 -14
  15. package/dist/federation/federate.js +3 -3
  16. package/dist/generate.cjs +1 -1
  17. package/dist/generate.js +3 -3
  18. package/dist/runtime-dependencies/@eventcatalog__visualiser__studio-fonts.css.mjs +3 -0
  19. package/dist/runtime-dependencies/@eventcatalog__visualiser__studio.css.mjs +3 -0
  20. package/dist/runtime-dependencies/@eventcatalog__visualiser__studio.mjs +3 -0
  21. package/dist/runtime-dependencies/manifest.json +3 -0
  22. package/dist/utils/cli-logger.cjs +1 -1
  23. package/dist/utils/cli-logger.js +2 -2
  24. package/eventcatalog/src/components/MDX/NodeGraph/AstroNodeGraph.tsx +11 -3
  25. package/eventcatalog/src/features/mcp/apps/canvas/shared.ts +3 -3
  26. package/eventcatalog/src/features/mcp/apps/canvas/styles.css +3 -1
  27. package/eventcatalog/src/features/mcp/apps/canvas/view.tsx +7 -5
  28. package/eventcatalog/src/features/mcp/apps/generated/canvas.html +133 -71
  29. package/eventcatalog/src/features/mcp/apps/generated/viewer.html +66 -66
  30. package/eventcatalog/src/features/studio/agent-choreography.ts +5 -4
  31. package/eventcatalog/src/features/studio/agents.ts +28 -0
  32. package/eventcatalog/src/features/studio/api/canvases-api.ts +1 -1
  33. package/eventcatalog/src/features/studio/canvas-actions.ts +22 -14
  34. package/eventcatalog/src/features/studio/canvas-preview.ts +1 -2
  35. package/eventcatalog/src/features/studio/catalog.ts +1 -4
  36. package/eventcatalog/src/features/studio/components/CanvasHeader.tsx +246 -115
  37. package/eventcatalog/src/features/studio/components/CanvasStatusMenu.tsx +7 -3
  38. package/eventcatalog/src/features/studio/components/ConnectAgentDialog.tsx +272 -119
  39. package/eventcatalog/src/features/studio/components/ConnectionsChooser.tsx +1 -2
  40. package/eventcatalog/src/features/studio/components/DeleteCanvasDialog.tsx +1 -1
  41. package/eventcatalog/src/features/studio/components/JoinForm.tsx +101 -26
  42. package/eventcatalog/src/features/studio/components/LeftPanel.tsx +102 -241
  43. package/eventcatalog/src/features/studio/components/NodeDetails.tsx +183 -0
  44. package/eventcatalog/src/features/studio/components/OpenInStudioDialog.tsx +1 -1
  45. package/eventcatalog/src/features/studio/components/Presence.tsx +6 -8
  46. package/eventcatalog/src/features/studio/components/ShareDialog.tsx +277 -141
  47. package/eventcatalog/src/features/studio/components/ShortcutsDialog.tsx +41 -0
  48. package/eventcatalog/src/features/studio/components/StudioDesigner.tsx +1005 -354
  49. package/eventcatalog/src/features/studio/document-name.ts +7 -0
  50. package/eventcatalog/src/features/studio/from-visualiser.ts +1 -1
  51. package/eventcatalog/src/features/studio/hooks/presence-sender.ts +19 -2
  52. package/eventcatalog/src/features/studio/hooks/use-canvas-webmcp.ts +9 -3
  53. package/eventcatalog/src/features/studio/hooks/use-comments.ts +2 -2
  54. package/eventcatalog/src/features/studio/hooks/use-studio-flow.ts +162 -23
  55. package/eventcatalog/src/features/studio/identity.ts +23 -1
  56. package/eventcatalog/src/features/studio/pages/[id].astro +2 -1
  57. package/eventcatalog/src/features/studio/pages/index.astro +1 -2
  58. package/eventcatalog/src/features/studio/server/canvas-mcp.ts +7 -22
  59. package/eventcatalog/src/features/studio/server/runtime.ts +4 -5
  60. package/package.json +3 -3
  61. package/eventcatalog/src/features/studio/canvas-doc.ts +0 -569
  62. package/eventcatalog/src/features/studio/clipboard.ts +0 -97
  63. package/eventcatalog/src/features/studio/components/CanvasControls.tsx +0 -119
  64. package/eventcatalog/src/features/studio/components/Comments.tsx +0 -777
  65. package/eventcatalog/src/features/studio/components/ContainerChooser.tsx +0 -145
  66. package/eventcatalog/src/features/studio/components/DragGhost.tsx +0 -115
  67. package/eventcatalog/src/features/studio/components/Picture.tsx +0 -21
  68. package/eventcatalog/src/features/studio/components/PropertiesPanel.tsx +0 -212
  69. package/eventcatalog/src/features/studio/components/canvas-nodes.tsx +0 -271
  70. package/eventcatalog/src/features/studio/components/icons.ts +0 -53
  71. package/eventcatalog/src/features/studio/components/status.ts +0 -53
  72. package/eventcatalog/src/features/studio/edges.ts +0 -56
  73. package/eventcatalog/src/features/studio/flow-state.ts +0 -121
  74. package/eventcatalog/src/features/studio/grouping.ts +0 -230
  75. package/eventcatalog/src/features/studio/hooks/use-counter-scale.ts +0 -20
  76. package/eventcatalog/src/features/studio/layout.ts +0 -56
  77. package/eventcatalog/src/features/studio/level-layout.ts +0 -145
  78. package/eventcatalog/src/features/studio/levels.ts +0 -337
  79. package/eventcatalog/src/features/studio/node-types.ts +0 -245
  80. package/eventcatalog/src/features/studio/placement.ts +0 -197
  81. package/eventcatalog/src/features/studio/studio.css +0 -273
  82. package/dist/{chunk-KHACCTW2.js → chunk-ZEY2XUJN.js} +4 -4
@@ -8,27 +8,28 @@ import {
8
8
  type Connection,
9
9
  type Edge,
10
10
  type Node,
11
+ type FitViewOptions,
11
12
  type NodeChange,
12
13
  type XYPosition,
13
14
  } from '@xyflow/react';
14
15
  import { DiagramBackground, edgeTypes, getLegend, LegendPanel, pauseEdgeAnimations } from '@eventcatalog/visualiser';
15
16
  import { useHotkeys } from 'react-hotkeys-hook';
16
17
  import { getCatalogConnections, indexCatalog, planWithConnections, type ConnectionGroup } from '../canvas-actions';
17
- import { getDiagramLevel, getDiagramLevelUnavailable, isDecided, type LevelPlace } from '../canvas-doc';
18
- import { CLIPBOARD_TYPE, copyNodes, pasteNodes, readClipboard, type ClipboardContent } from '../clipboard';
19
- import { keepUnchanged, LOCAL_EDGE_KEYS, LOCAL_NODE_KEYS } from '../flow-state';
20
- import type { CatalogRelation, CatalogResource } from '../catalog-resources';
21
18
  import {
22
- buildContainer,
23
- buildContainerWithContents,
24
- canBeContainer,
25
- catalogNodeData,
26
- getCatalogLink,
27
- getContents,
28
- getRelatedEdges,
29
- } from '../catalog';
30
- import {
31
- findDropTarget,
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,
32
33
  containerFor,
33
34
  findGroupAtPoint,
34
35
  getAbsolutePosition,
@@ -37,15 +38,16 @@ import {
37
38
  sizeOf,
38
39
  sortByHierarchy,
39
40
  toRelativePosition,
40
- } from '../grouping';
41
- import type { Peer } from '../hooks/presence-store';
42
- import { useCanvasWebMcp } from '../hooks/use-canvas-webmcp';
43
- import { useStudioFlow } from '../hooks/use-studio-flow';
44
- import { useComments, type CommentAnchor, type Thread } from '../hooks/use-comments';
45
- import { getLayoutPositions, layoutGraph } from '../layout';
46
- import { getLevelGraph, getLevelUnavailableReason, LEVELS, type Level } from '../levels';
47
- import { arrangeLevel, getLevelNodeSize, levelPlaces, withDiagramRoutes } from '../level-layout';
48
- import {
41
+ getLayoutPositions,
42
+ layoutGraph,
43
+ getLevelGraph,
44
+ getLevelUnavailableReason,
45
+ LEVELS,
46
+ type Level,
47
+ arrangeLevel,
48
+ getLevelNodeSize,
49
+ levelPlaces,
50
+ withDiagramRoutes,
49
51
  canGoInContainer,
50
52
  getNodeDefinition,
51
53
  getNodeLabel,
@@ -54,24 +56,76 @@ import {
54
56
  isOnLevel,
55
57
  isShownOnLevel,
56
58
  isWrittenOnCanvas,
57
- } from '../node-types';
59
+ getResource,
60
+ DropTargetContext,
61
+ nodeTypes,
62
+ UpdateNodeDataContext,
63
+ anchorAt,
64
+ ContextMenu,
65
+ canvasToModel,
66
+ modelToCanvas,
67
+ toDiagramFile,
68
+ readDiagramFile,
69
+ pickDiagramFile,
70
+ downloadFile,
71
+ fileNameFor,
72
+ DIAGRAM_FILE_EXTENSION,
73
+ canvasImage,
74
+ EmptyCanvas,
75
+ type ModelCanvasContent,
76
+ CommentLayer,
77
+ CommentList,
78
+ getAnchorPosition,
79
+ timeAgo,
80
+ type ContextMenuState,
81
+ CANVAS_STATUS_LOOK,
82
+ ContainerChooser,
83
+ type ContainerChoice,
84
+ CanvasControls,
85
+ ViewControls,
86
+ FIT_VIEW_OPTIONS,
87
+ EditButton,
88
+ LockedContext,
89
+ StartWritingContext,
90
+ EditingContext,
91
+ HotspotLayer,
92
+ HotspotList,
93
+ topSeverity,
94
+ isGroupType,
95
+ type Hotspot,
96
+ type HotspotDraft,
97
+ type HotspotFields,
98
+ type HotspotSeverity,
99
+ type HotspotSpot,
100
+ } from '@eventcatalog/visualiser/studio';
101
+ import { X } from 'lucide-react';
102
+ import type { CatalogRelation, CatalogResource } from '../catalog-resources';
103
+ import {
104
+ buildContainer,
105
+ buildContainerWithContents,
106
+ canBeContainer,
107
+ catalogNodeData,
108
+ getCatalogLink,
109
+ getContents,
110
+ getRelatedEdges,
111
+ } from '../catalog';
112
+ import type { Peer } from '../hooks/presence-store';
113
+ import { useCanvasWebMcp } from '../hooks/use-canvas-webmcp';
114
+ import { useStudioFlow } from '../hooks/use-studio-flow';
115
+ import { useComments, type CommentAnchor, type Thread } from '../hooks/use-comments';
58
116
  import type { ToolSync } from '../tool-sync';
59
- import CanvasControls, { FIT_VIEW_OPTIONS } from './CanvasControls';
60
117
  import CanvasHeader from './CanvasHeader';
61
- import { DropTargetContext, nodeTypes, UpdateNodeDataContext } from './canvas-nodes';
62
- import { anchorAt, CanvasContextMenu, CommentLayer, getAnchorPosition, timeAgo, type ContextMenuState } from './Comments';
63
- import { CANVAS_STATUS_LOOK } from './status';
64
118
  import ConnectAgentDialog, { enableWebMcp, isRelayEnabled, isWebMcpEnabled, loadRelay } from './ConnectAgentDialog';
65
- import ContainerChooser, { type ContainerChoice } from './ContainerChooser';
66
119
  import ConnectionsChooser, { type ConnectionsChoice } from './ConnectionsChooser';
67
120
  import JoinForm from './JoinForm';
68
- import { colorForName, getStoredName, onStoredNameChange, storeName } from '../identity';
69
- 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';
70
123
  import { AgentActivity, RemotePresence, ViewportSharer } from './Presence';
71
- import PropertiesPanel from './PropertiesPanel';
124
+ import NodeDetails, { type NodeConnection } from './NodeDetails';
72
125
  import ShareDialog from './ShareDialog';
73
126
  import DeleteCanvasDialog from './DeleteCanvasDialog';
74
- import { deleteCanvasThroughApi } from '../api/client';
127
+ import { createCanvasThroughApi, deleteCanvasThroughApi } from '../api/client';
128
+ import ShortcutsDialog from './ShortcutsDialog';
75
129
 
76
130
  type CanvasProps = {
77
131
  canvasId: string;
@@ -96,10 +150,12 @@ type CanvasProps = {
96
150
  syncViaTools?: ToolSync;
97
151
  /** Follow agents' changes with the camera from the start (e.g. inside a chat, where you watch an agent work) */
98
152
  followChanges?: boolean;
153
+ /** Starts with the palette folded away (e.g. in a chat, where there's little room) */
154
+ paletteCollapsed?: boolean;
99
155
  /** Told what's selected (e.g. a chat attaches it to the conversation, so you can ask about it) */
100
156
  onSelectionChange?: (selected: SelectedNode[]) => void;
101
- /** Join with this name instead of asking for one (e.g. inside a chat, where the agent or host knows who it is) */
102
- 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 };
103
159
  /** Signed in with the catalog's sign-in (SSO): you're shown by this name and picture, and can't change them */
104
160
  signedInAs?: { name: string; picture?: string };
105
161
  resources: CatalogResource[];
@@ -178,8 +234,58 @@ const asLevelNote = (node: Node) => {
178
234
 
179
235
  const structureOf = (nodes: Node[]) => nodes.map((node) => `${node.id}:${node.type}:${node.parentId ?? ''}`).join('|');
180
236
 
181
- export default function StudioDesigner({ defaultName, signedInAs, ...props }: CanvasProps) {
182
- 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);
183
289
 
184
290
  const saveName = useCallback((next: string) => {
185
291
  setName(next);
@@ -187,19 +293,41 @@ export default function StudioDesigner({ defaultName, signedInAs, ...props }: Ca
187
293
  }, []);
188
294
  // Renamed on the Studio page or another canvas in this browser
189
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
+ }, []);
190
302
 
303
+ if (asAgent) {
304
+ return (
305
+ <ReactFlowProvider>
306
+ <Canvas {...props} name={asAgent.name} chosenColor={asAgent.color} agent onRecolor={() => {}} />
307
+ </ReactFlowProvider>
308
+ );
309
+ }
191
310
  if (signedInAs) {
192
311
  return (
193
312
  <ReactFlowProvider>
194
- <Canvas {...props} name={signedInAs.name} picture={signedInAs.picture} />
313
+ <Canvas {...props} name={signedInAs.name} picture={signedInAs.picture} chosenColor={chosenColor} onRecolor={recolor} />
195
314
  </ReactFlowProvider>
196
315
  );
197
316
  }
198
- 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
+ );
199
327
 
200
328
  return (
201
329
  <ReactFlowProvider>
202
- <Canvas {...props} name={name} onRename={saveName} />
330
+ <Canvas {...props} name={name} onRename={saveName} chosenColor={chosenColor} onRecolor={recolor} />
203
331
  </ReactFlowProvider>
204
332
  );
205
333
  }
@@ -220,19 +348,28 @@ function Canvas({
220
348
  keptInMemory = false,
221
349
  syncViaTools,
222
350
  followChanges = false,
351
+ paletteCollapsed,
223
352
  onSelectionChange,
224
353
  resources,
225
354
  relations,
226
355
  name,
227
356
  picture,
357
+ agent,
228
358
  onRename,
229
- }: Omit<CanvasProps, 'defaultName' | 'signedInAs'> & {
359
+ chosenColor,
360
+ onRecolor,
361
+ }: Omit<CanvasProps, 'asAgent' | 'signedInAs'> & {
230
362
  name: string;
231
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;
232
366
  /** Not given when you're signed in (your name is the one you signed in with) */
233
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;
234
371
  }) {
235
- const color = colorForName(name);
372
+ const color = chosenColor ?? colorForName(name);
236
373
  const canvasRef = useRef<HTMLDivElement>(null);
237
374
 
238
375
  // Nodes glide to their new place after a layout (anyone's) or a level change (not while editing: it would make dragging lag)
@@ -259,12 +396,25 @@ function Canvas({
259
396
  name,
260
397
  color,
261
398
  picture,
399
+ agent,
262
400
  syncViaTools,
263
401
  onLayout: glide,
264
402
  onContainerResize: animateContainers,
265
403
  });
266
- 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
+ );
267
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
+ );
268
418
  const { screenToFlowPosition, flowToScreenPosition, setCenter, getZoom, fitView } = useReactFlow();
269
419
 
270
420
  const catalog = useMemo(() => indexCatalog(resources, relations), [resources, relations]);
@@ -387,7 +537,7 @@ function Canvas({
387
537
  const choose = useStableCallback((choice: ContainerChoice) => {
388
538
  if (!chooser) return;
389
539
  if (choice.as === 'card') addCard(chooser.resource, chooser.at);
390
- else void addContainer(chooser.resource, chooser.at, choice.withContents);
540
+ else void addContainer(chooser.resource, chooser.at, !!choice.withContents);
391
541
  setChooser(null);
392
542
  });
393
543
  const cancelChooser = useCallback(() => setChooser(null), []);
@@ -418,10 +568,20 @@ function Canvas({
418
568
  };
419
569
  }, [chooser, catalog, keysOnCanvas]);
420
570
 
421
- /** 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 */
422
572
  const addNoteAt = (center: XYPosition) => {
423
573
  const data = getNodeDefinition('note')!.createData();
424
- 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);
425
585
  };
426
586
  /**
427
587
  * Where something added at a point goes (see Landing). On L1 and L2 it's centred where it's dropped, in the
@@ -464,14 +624,14 @@ function Canvas({
464
624
  centring.current.set(id, { level, size });
465
625
  flow.setLevelLayout(level, new Map([[id, centredOn(center, size)]]));
466
626
  };
467
- const addComponentAt = (type: string, point: XYPosition) => {
468
- if (type === 'note') return addNoteAt(point);
469
- const at = landingAt(point, type);
470
- keepOnLevel(at, flow.addNode(type, at.center, at.inside));
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));
471
632
  };
472
633
 
473
634
  const addComponent = useStableCallback((type: string) => addComponentAt(type, canvasCenter()));
474
- const addNote = useStableCallback(() => addNoteAt(canvasCenter()));
475
635
  const addResourceFromPanel = useStableCallback((key: string) => addResource(key, canvasCenter()));
476
636
 
477
637
  const onDrop = useStableCallback((event: DragEvent) => {
@@ -592,10 +752,6 @@ function Canvas({
592
752
 
593
753
  // Dragging something into a container puts it in it, out of one takes it out
594
754
  const [dropTargetId, setDropTargetId] = useState<string | null>(null);
595
- const dragRoots = (dragged: Node[]) => {
596
- const ids = new Set(dragged.map((node) => node.id));
597
- return dragged.filter((node) => !node.parentId || !ids.has(node.parentId));
598
- };
599
755
  // While something's dragged, the containers around it grow to fit it (see previewContainerGrowth): the canvas
600
756
  // when the drag started, and those containers, innermost first
601
757
  const dragStart = useRef<{ nodes: Map<string, Node>; containers: string[] } | null>(null);
@@ -642,15 +798,17 @@ function Canvas({
642
798
  dragged.forEach((node) => now.set(node.id, { ...now.get(node.id)!, position: node.position }));
643
799
  flow.showDragPreview(previewContainerGrowth(started.containers, new Set(dragged.map((node) => node.id)), started.nodes, now));
644
800
  };
645
- const onNodeDrag = useStableCallback((_: unknown, __: Node, dragged: Node[]) => {
801
+ const onNodeDrag = useStableCallback((_: unknown, held: Node, dragged: Node[]) => {
646
802
  growContainers(dragged);
647
803
  moveThreadsWith(dragged);
648
- const [root] = dragRoots(dragged);
649
- const current = root && nodesRef.current.find((node) => node.id === root.id);
650
- const target = root ? findDropTarget(root.id, nodesRef.current) : undefined;
651
- 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);
652
810
  });
653
- const onNodeDragStop = useStableCallback((_: unknown, __: Node, dragged: Node[]) => {
811
+ const onNodeDragStop = useStableCallback((_: unknown, held: Node, dragged: Node[]) => {
654
812
  setInteraction('dragging', false);
655
813
  setDropTargetId(null);
656
814
  dragStart.current = null;
@@ -668,12 +826,17 @@ function Canvas({
668
826
  }
669
827
  threadsDrag.current = null;
670
828
  setThreadsOffset(null);
671
- // The containers it grew are saved with the drop; dropped in another container (or out of one), it moves there
672
- for (const root of dragRoots(dragged)) {
673
- const current = nodesRef.current.find((node) => node.id === root.id);
674
- const target = findDropTarget(root.id, nodesRef.current);
675
- if (current && target?.id !== current.parentId) flow.setParent(root.id, target?.id);
676
- }
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) })));
677
840
  });
678
841
 
679
842
  // ---- Levels of detail ----
@@ -750,6 +913,8 @@ function Canvas({
750
913
  if (cancelled) return;
751
914
  setLevelGraph({ level, ...laidOut });
752
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;
753
918
  });
754
919
  };
755
920
  // Straight away when switching levels (or from the diagram); after changes settle while one is shown
@@ -764,8 +929,10 @@ function Canvas({
764
929
  };
765
930
  }, [level, levelKey, flow.diagramLevels, levelLayout]);
766
931
 
767
- // Comments: right click > Add comment, or C for comment mode then click (like Figma)
768
- 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';
769
936
  const [draft, setDraft] = useState<CommentAnchor | null>(null);
770
937
  const [openThreadId, setOpenThreadId] = useState<string | null>(null);
771
938
  const [showResolved, setShowResolved] = useState(false);
@@ -777,10 +944,31 @@ function Canvas({
777
944
  const threadsShown = () => comments.threads.filter((thread) => showResolved || !thread.resolved);
778
945
  const [contextMenu, setContextMenu] = useState<ContextMenuState>(null);
779
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
+
780
968
  const changeLevel = useStableCallback((next: Level) => {
781
969
  if (next === level) return;
782
970
  glide();
783
- setCommentMode(false);
971
+ setTool('select');
784
972
  setDraft(null);
785
973
  setOpenThreadId(null);
786
974
  // What's selected may not be on the next level (e.g. a note added on this one)
@@ -795,7 +983,7 @@ function Canvas({
795
983
  const levelHasNodes = !!levelGraph?.nodes.length;
796
984
  useEffect(() => {
797
985
  if (shownLevel === undefined || (shownLevel !== 3 && !levelHasNodes)) return;
798
- const frame = requestAnimationFrame(() => void fitView({ ...FIT_VIEW_OPTIONS, duration: 450 }));
986
+ const frame = requestAnimationFrame(() => void fitView({ ...fitOptions, duration: 450 }));
799
987
  return () => cancelAnimationFrame(frame);
800
988
  }, [shownLevel, levelHasNodes]);
801
989
 
@@ -808,18 +996,22 @@ function Canvas({
808
996
  if (level !== 3) {
809
997
  const content =
810
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
+ );
811
1003
  const laidOut = await layoutGraph(
812
- content.nodes.map((node) => ({ ...node, selected: false })),
1004
+ content.nodes.map((node) => ({ ...node, selected: false, ...(drawn.get(node.id) && { measured: drawn.get(node.id) }) })),
813
1005
  content.edges
814
1006
  );
815
1007
  glide();
816
1008
  flow.setLevelLayout(level, levelPlaces(laidOut.nodes));
817
- setTimeout(() => void fitView({ ...FIT_VIEW_OPTIONS, duration: 450 }), 50);
1009
+ setTimeout(() => void fitView({ ...fitOptions, duration: 450 }), 50);
818
1010
  return;
819
1011
  }
820
1012
  const positions = await getLayoutPositions(nodesRef.current, edgesRef.current);
821
1013
  flow.applyPositions(positions);
822
- setTimeout(() => void fitView({ ...FIT_VIEW_OPTIONS, duration: 450 }), 50);
1014
+ setTimeout(() => void fitView({ ...fitOptions, duration: 450 }), 50);
823
1015
  });
824
1016
  const onReorder = useCallback(() => void reorder(), [reorder]);
825
1017
 
@@ -855,13 +1047,146 @@ function Canvas({
855
1047
  // What's shown only changes with what's on it (not as nodes are dragged, on any level)
856
1048
  const shownStructure = levelShown ? structureOf(levelShown.nodes) : structure;
857
1049
  const legend = useMemo(() => getLegend(shownNodes.filter((node) => !isWrittenOnCanvas(node.type))), [shownStructure]);
858
- const visibleNodes = useMemo(
859
- () =>
860
- hiddenLegendKeys.length === 0
861
- ? shownNodes
862
- : shownNodes.map((node) => (hiddenLegendKeys.includes(node.type ?? '') ? { ...node, hidden: true } : node)),
863
- [shownNodes, hiddenLegendKeys]
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]
864
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));
865
1190
  /**
866
1191
  * Changes on L1 and L2. The level's notes are on the canvas, so changes to them go there; removing something
867
1192
  * removes it from the canvas (the level is drawn from it); and moving or resizing something arranges the level,
@@ -911,19 +1236,23 @@ function Canvas({
911
1236
  const onCanvas = new Set(nodesRef.current.map((node) => node.id));
912
1237
  if (onCanvas.has(connection.source) && onCanvas.has(connection.target)) flow.onConnect(connection);
913
1238
  });
914
- // Message edges' envelopes only move on what's selected (the edge, or a node at either end): any moving
915
- // 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
916
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({}), []);
917
1247
  const levelEdges = levelShown?.edges;
918
1248
  const shownEdges = useMemo(() => {
919
1249
  const edges = levelEdges ?? flow.edges;
920
- const focused = new Set(selectedKey ? selectedKey.split(',') : []);
921
1250
  return edges.map((edge) =>
922
- 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
923
1252
  ? withoutEnvelope(edge)
924
1253
  : edge
925
1254
  );
926
- }, [levelEdges, flow.edges, selectedKey]);
1255
+ }, [levelEdges, flow.edges, hovered]);
927
1256
  // A canvas opened from a diagram has the levels the diagram has (until what's on it changes)
928
1257
  const unavailableLevels = useMemo(() => {
929
1258
  const reason = (level: 1 | 2) => {
@@ -952,9 +1281,91 @@ function Canvas({
952
1281
  const [following, setFollowing] = useState(followChanges);
953
1282
  const toggleFollowing = useCallback(() => setFollowing((on) => !on), []);
954
1283
 
955
- const [shareOpen, setShareOpen] = useState(false);
956
- 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'), []);
957
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
+ }, []);
958
1369
  const [deleteOpen, setDeleteOpen] = useState(false);
959
1370
  const openDelete = useCallback(() => setDeleteOpen(true), []);
960
1371
  const closeDelete = useCallback(() => setDeleteOpen(false), []);
@@ -1011,11 +1422,26 @@ function Canvas({
1011
1422
  { preventDefault: true }
1012
1423
  );
1013
1424
  // On every level: notes are changed on L1 and L2 too (and the levels show what's undone on the canvas)
1014
- useHotkeys('mod+z', () => flow.undo(), { preventDefault: true });
1015
- useHotkeys(['mod+shift+z', 'mod+y'], () => flow.redo(), { preventDefault: true });
1016
- 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 });
1017
1441
  useHotkeys('escape', () => {
1018
- setCommentMode(false);
1442
+ setHotspotDraft(null);
1443
+ setOpenHotspot(null);
1444
+ setTool('select');
1019
1445
  setDraft(null);
1020
1446
  setOpenThreadId(null);
1021
1447
  clearSelectedThreads();
@@ -1033,20 +1459,48 @@ function Canvas({
1033
1459
  comments.deleteThreads([...selectedThreads]);
1034
1460
  clearSelectedThreads();
1035
1461
  });
1036
- 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
+ });
1037
1480
 
1038
1481
  const startComment = (anchor: CommentAnchor) => {
1039
1482
  setOpenThreadId(null);
1040
1483
  setDraft(anchor);
1041
- setCommentMode(false);
1484
+ setTool('select');
1042
1485
  };
1043
1486
 
1044
1487
  const flowPositionOf = (event: { clientX: number; clientY: number }) =>
1045
1488
  screenToFlowPosition({ x: event.clientX, y: event.clientY });
1046
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 });
1047
1492
  const onPaneClick = useStableCallback((event: ReactMouseEvent) => {
1048
1493
  clearSelectedThreads();
1494
+ if (tool === 'hand') return;
1049
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
+ }
1050
1504
  // A click inside a container (not on something in it) selects it: its body lets the pointer through so you can
1051
1505
  // drag a selection box in it, so the click lands on the canvas
1052
1506
  // (after React Flow clears the selection, which it does once this returns)
@@ -1062,21 +1516,27 @@ function Canvas({
1062
1516
  if (!isWrittenOnCanvas(node.type) && nodesRef.current.some((other) => other.id === node.id)) setEditingId(node.id);
1063
1517
  });
1064
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)
1065
1520
  const onCanvasDoubleClick = useStableCallback((event: ReactMouseEvent) => {
1066
- if (!editable || !(event.target as Element).classList?.contains('react-flow__pane')) return;
1067
- const container = findGroupAtPoint(flowPositionOf(event), nodesRef.current);
1068
- 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));
1069
1525
  });
1070
1526
 
1071
1527
  const onNodeClick = useStableCallback((event: ReactMouseEvent, node: Node) => {
1072
1528
  // Clicking one node selects just it (with shift or ⌘, it's added to what's selected)
1073
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) });
1074
1534
  if (commentMode && editable) startComment(anchorAt(flowPositionOf(event), node, lookupNodes()));
1075
1535
  });
1076
1536
 
1077
1537
  const openContextMenu = useStableCallback((event: ReactMouseEvent | MouseEvent, node?: Node) => {
1078
1538
  event.preventDefault();
1079
- if (!editable) return;
1539
+ if (!editable || lockedRef.current) return;
1080
1540
  setContextMenu({ x: event.clientX, y: event.clientY, flowPosition: flowPositionOf(event), node });
1081
1541
  });
1082
1542
  const onPaneContextMenu = useCallback((event: ReactMouseEvent | MouseEvent) => openContextMenu(event), [openContextMenu]);
@@ -1097,6 +1557,26 @@ function Canvas({
1097
1557
  if (contextMenu?.node) setEditingId(contextMenu.node.id);
1098
1558
  setContextMenu(null);
1099
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
+ });
1100
1580
  const copyFromMenu = useStableCallback(() => {
1101
1581
  // Through the copy event, so it's copied like ⌘C (to the system clipboard too)
1102
1582
  copyOnly.current = menuTargets();
@@ -1119,6 +1599,7 @@ function Canvas({
1119
1599
  });
1120
1600
 
1121
1601
  const openThread = useStableCallback((thread: Thread) => {
1602
+ setListingComments(false);
1122
1603
  const { x, y } = getAnchorPosition(thread, lookupNodes());
1123
1604
  // Leave room for the thread, which opens to the right of its pin
1124
1605
  void setCenter(x + 150, y, { zoom: getZoom(), duration: 300 });
@@ -1126,6 +1607,18 @@ function Canvas({
1126
1607
  setOpenThreadId(thread.id);
1127
1608
  });
1128
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
+ );
1129
1622
  const openThreadById = useCallback((id: string | null) => {
1130
1623
  setDraft(null);
1131
1624
  setOpenThreadId(id);
@@ -1157,15 +1650,37 @@ function Canvas({
1157
1650
  })
1158
1651
  );
1159
1652
  }, [selectedKey]);
1160
- // The node whose details are being edited in the panel: opened by double clicking it, or "Edit details" in its
1161
- // 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.
1162
1655
  const [editingId, setEditingId] = useState<string | null>(null);
1163
1656
  const editing = editingId ? flow.nodes.find((node) => node.id === editingId && !isWrittenOnCanvas(node.type)) : undefined;
1164
- // What the panel shows (not the node's position, so dragging it doesn't re-render the panel)
1657
+ // What the card shows (not the node's position, so dragging it doesn't re-render the card)
1165
1658
  const inspected = useMemo(
1166
1659
  () => (editing ? { id: editing.id, type: editing.type, data: editing.data } : null),
1167
1660
  [editing?.id, editing?.type, editing?.data]
1168
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
+ });
1169
1684
  const closePanel = useCallback(() => setEditingId(null), []);
1170
1685
  // An accepted or rejected canvas, and when it was decided
1171
1686
  const { status: canvasStatus, history: statusHistory } = flow.canvasStatus;
@@ -1176,264 +1691,400 @@ function Canvas({
1176
1691
  return (
1177
1692
  <UpdateNodeDataContext.Provider value={flow.updateNodeData}>
1178
1693
  <DropTargetContext.Provider value={dropTargetId}>
1179
- <div className="flex h-full min-h-0 text-[rgb(var(--ec-page-text))]">
1180
- <LeftPanel
1181
- level={level}
1182
- onShowAll={editInL3}
1183
- resources={resources}
1184
- keysOnCanvas={keysOnCanvas}
1185
- onAddComponent={addComponent}
1186
- onAddResource={addResourceFromPanel}
1187
- threads={comments.threads}
1188
- showResolved={showResolved}
1189
- onToggleResolved={toggleResolved}
1190
- onOpenThread={openThread}
1191
- />
1192
-
1193
- <div
1194
- ref={canvasRef}
1195
- className={`eventcatalog-visualizer studio-canvas relative flex-1 min-w-0 ${commentMode ? 'comment-mode' : ''} ${
1196
- transitioning ? 'is-layout-transition' : ''
1197
- }`}
1198
- onPointerMove={trackPointer}
1199
- onPointerLeave={clearPointer}
1200
- onDoubleClick={onCanvasDoubleClick}
1201
- >
1202
- <ReactFlow
1203
- nodes={visibleNodes}
1204
- edges={shownEdges}
1205
- nodeTypes={nodeTypes}
1206
- edgeTypes={edgeTypes}
1207
- nodesDraggable
1208
- nodesConnectable
1209
- elementsSelectable
1210
- onNodesChange={editable ? flow.onNodesChange : onLevelNodesChange}
1211
- onEdgesChange={editable ? flow.onEdgesChange : undefined}
1212
- onConnect={editable ? flow.onConnect : onLevelConnect}
1213
- // Drag either end of a connection to another node (dropped anywhere else, it stays where it was)
1214
- edgesReconnectable={editable}
1215
- onReconnect={editable ? flow.onReconnect : undefined}
1216
- reconnectRadius={RECONNECT_RADIUS}
1217
- onDragOver={onDragOver}
1218
- onMoveStart={onMoveStart}
1219
- onMoveEnd={onMoveEnd}
1220
- onNodeDragStart={onNodeDragStart}
1221
- onNodeDrag={onNodeDrag}
1222
- onNodeDragStop={onNodeDragStop}
1223
- onDrop={onDrop}
1224
- onPaneClick={onPaneClick}
1225
- onNodeClick={onNodeClick}
1226
- onNodeDoubleClick={onNodeDoubleClick}
1227
- onError={onFlowError}
1228
- onSelectionStart={clearSelectedThreads}
1229
- onPaneContextMenu={onPaneContextMenu}
1230
- onNodeContextMenu={onNodeContextMenu}
1231
- connectionLineType={ConnectionLineType.SmoothStep}
1232
- connectionRadius={40}
1233
- deleteKeyCode={DELETE_KEYS}
1234
- multiSelectionKeyCode={MULTI_SELECTION_KEYS}
1235
- selectionOnDrag
1236
- // Read only levels can't select: dragging pans them
1237
- panOnDrag={PAN_BUTTONS}
1238
- panOnScroll
1239
- zoomActivationKeyCode={ZOOM_KEYS}
1240
- zoomOnDoubleClick={false}
1241
- minZoom={0.1}
1242
- maxZoom={2}
1243
- >
1244
- <DiagramBackground />
1245
- <LegendPanel legend={legend} hiddenKeys={hiddenLegendKeys} onLegendClick={toggleLegendKey} />
1246
- <ViewportSharer onChange={flow.setViewport} />
1247
- {/* Pointers, selections and comments are on the canvas people edit (L3) */}
1248
- {editable && <RemotePresence store={flow.presence} clientId={flow.clientId} />}
1249
- {editable && (
1250
- <CommentLayer
1251
- threads={comments.threads}
1252
- author={author}
1253
- openThreadId={openThreadId}
1254
- draft={draft}
1255
- showResolved={showResolved}
1256
- onOpen={openThreadById}
1257
- onCreate={createThread}
1258
- onCancelDraft={cancelDraft}
1259
- onReply={comments.reply}
1260
- onResolve={comments.setResolved}
1261
- onMove={comments.moveThread}
1262
- onMoveDraft={setDraft}
1263
- onDelete={deleteThread}
1264
- selectedIds={selectedThreads}
1265
- selectionOffset={threadsOffset}
1266
- />
1267
- )}
1268
- </ReactFlow>
1269
-
1270
- <AgentActivity
1271
- store={flow.presence}
1272
- clientId={flow.clientId}
1273
- enabled={editable}
1274
- following={following}
1275
- onToggleFollowing={toggleFollowing}
1276
- pauseRef={pauseCamera}
1277
- />
1278
-
1279
- {!editable && (
1280
- <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))]">
1281
- <span>
1282
- <span className="font-semibold">L{level}</span> · {LEVELS.find((entry) => entry.level === level)?.description}
1283
- </span>
1284
- <button onClick={editInL3} className="font-semibold text-[rgb(var(--ec-accent))] hover:underline">
1285
- Everything is on L3
1286
- </button>
1287
- </div>
1288
- )}
1289
-
1290
- {/* Accepted or rejected: still editable, but changing it makes it a draft again (for everyone) */}
1291
- {editable && decided && (
1292
- <div
1293
- 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}`}
1294
- >
1295
- <span className="truncate">
1296
- <span className="font-semibold">{CANVAS_STATUS_LOOK[decided.status].label}</span>
1297
- {decided.change && ` by ${decided.change.by.name} · ${timeAgo(decided.change.at)}`}
1298
- {decided.change?.note && ` · “${decided.change.note}”`} · changing it moves it back to Draft
1299
- </span>
1300
- </div>
1301
- )}
1302
-
1303
- <CanvasControls
1304
- level={level}
1305
- onLevelChange={changeLevel}
1306
- unavailableLevels={unavailableLevels}
1307
- editable={editable}
1308
- onReorder={onReorder}
1309
- canUndo={flow.canUndo}
1310
- canRedo={flow.canRedo}
1311
- onUndo={flow.undo}
1312
- onRedo={flow.redo}
1313
- commentMode={commentMode}
1314
- onToggleCommentMode={toggleCommentMode}
1315
- onAddNote={addNote}
1316
- />
1317
-
1318
- <CanvasContextMenu
1319
- menu={contextMenu}
1320
- onAddComment={commentFromMenu}
1321
- onEditDetails={editFromMenu}
1322
- onCopy={copyFromMenu}
1323
- onDuplicate={duplicateFromMenu}
1324
- onPaste={lastCopied ? pasteFromMenu : undefined}
1325
- onDeleteNode={deleteFromMenu}
1326
- onClose={closeContextMenu}
1327
- />
1328
-
1329
- <CanvasHeader
1330
- title={flow.title}
1331
- onRetitle={flow.rename}
1332
- status={flow.status}
1333
- transport={flow.transport}
1334
- presence={flow.presence}
1335
- clientId={flow.clientId}
1336
- onJumpTo={jumpTo}
1337
- onRename={onRename}
1338
- onConnectAgent={openConnect}
1339
- onShare={openShare}
1340
- webMcp={webMcp}
1341
- shareUrl={shareUrl}
1342
- newCanvasUrl={newCanvasUrl}
1343
- canvasStatus={flow.canvasStatus.status}
1344
- statusHistory={flow.canvasStatus.history}
1345
- onStatusChange={flow.changeStatus}
1346
- onDelete={canvasesApiUrl ? openDelete : undefined}
1347
- />
1348
-
1349
- {flow.deleted && !leaving && (
1350
- <div className="absolute inset-0 z-40 flex items-center justify-center bg-[rgb(var(--ec-page-bg)/0.85)] p-4">
1351
- <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))]">
1352
- <h2 className="text-base font-semibold">This canvas was deleted</h2>
1353
- <p className="text-sm text-[rgb(var(--ec-page-text-muted))]">
1354
- Someone deleted it, so it can't be opened or changed any more.
1355
- </p>
1356
- {studioUrl && (
1357
- <a
1358
- href={studioUrl}
1359
- 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}`}
1360
1823
  >
1361
- Back to Studio
1362
- </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
+ />
1363
2021
  )}
1364
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
+ )}
1365
2084
  </div>
1366
- )}
1367
-
1368
- {/* Only on an empty canvas (not over comments people have left on it) */}
1369
- {flow.status === 'connected' && flow.nodes.length === 0 && comments.threads.length === 0 && !draft && (
1370
- <div className="pointer-events-none absolute inset-0 flex items-center justify-center">
1371
- <p className="text-sm text-[rgb(var(--ec-page-text-muted))]">
1372
- Drag components or catalog resources from the left onto the canvas, then share the link to work on it together.
1373
- </p>
1374
- </div>
1375
- )}
1376
- </div>
1377
-
1378
- {chooser && (
1379
- <ContainerChooser
1380
- resource={chooser.resource}
1381
- contents={chooserContents}
1382
- position={flowToScreenPosition(chooser.at.point)}
1383
- onChoose={choose}
1384
- onCancel={cancelChooser}
1385
- />
1386
- )}
1387
- {connectionsChooser && (
1388
- <ConnectionsChooser
1389
- resource={connectionsChooser.resource}
1390
- groups={connectionsChooser.groups}
1391
- position={flowToScreenPosition(connectionsChooser.at.point)}
1392
- onChoose={chooseConnections}
1393
- onCancel={cancelConnectionsChooser}
1394
- />
1395
- )}
1396
- {shareOpen && (
1397
- <ShareDialog
1398
- keptInMemory={keptInMemory}
1399
- url={agentLink.canvasUrl}
1400
- title={flow.title}
1401
- presence={flow.presence}
1402
- clientId={flow.clientId}
1403
- onRename={onRename}
1404
- onJumpTo={jumpTo}
1405
- onConnectAgent={openConnect}
1406
- onClose={closeShare}
1407
- />
1408
- )}
1409
- {deleteOpen && (
1410
- <DeleteCanvasDialog
1411
- title={flow.title}
1412
- presence={flow.presence}
1413
- clientId={flow.clientId}
1414
- onDelete={deleteCanvas}
1415
- onClose={closeDelete}
1416
- />
1417
- )}
1418
- {connectOpen && (
1419
- <ConnectAgentDialog
1420
- link={agentLink}
1421
- webMcp={webMcp}
1422
- relayScriptPath={relayScriptPath}
1423
- onEnableWebMcp={turnOnWebMcp}
1424
- onClose={closeConnect}
1425
- />
1426
- )}
1427
- {inspected && (
1428
- <PropertiesPanel
1429
- key={inspected.id}
1430
- node={inspected}
1431
- onChange={flow.updateNodeData}
1432
- onDelete={flow.deleteNodes}
1433
- onClose={closePanel}
1434
- />
1435
- )}
1436
- </div>
2085
+ </LockedContext.Provider>
2086
+ </StartWritingContext.Provider>
2087
+ </EditingContext.Provider>
1437
2088
  </DropTargetContext.Provider>
1438
2089
  </UpdateNodeDataContext.Provider>
1439
2090
  );