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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/README.md +1 -1
  2. package/dist/analytics/analytics.cjs +1 -1
  3. package/dist/analytics/analytics.js +2 -2
  4. package/dist/analytics/log-build.cjs +1 -1
  5. package/dist/analytics/log-build.js +3 -3
  6. package/dist/{chunk-76DAAJLV.js → chunk-BWC4YKW3.js} +1 -1
  7. package/dist/{chunk-4YFM63DS.js → chunk-CX2VB7AI.js} +1 -1
  8. package/dist/{chunk-MFEMKQZ6.js → chunk-FY6MSP6J.js} +1 -1
  9. package/dist/{chunk-LQZ7IG7B.js → chunk-QB7X34OD.js} +1 -1
  10. package/dist/{chunk-WM4QYMIA.js → chunk-TSY5CC27.js} +1 -1
  11. package/dist/constants.cjs +1 -1
  12. package/dist/constants.js +1 -1
  13. package/dist/eventcatalog.cjs +1 -1
  14. package/dist/eventcatalog.js +5 -5
  15. package/dist/generate.cjs +1 -1
  16. package/dist/generate.js +3 -3
  17. package/dist/utils/cli-logger.cjs +1 -1
  18. package/dist/utils/cli-logger.js +2 -2
  19. package/eventcatalog/src/components/ChatPanel/ChatPanel.tsx +2 -0
  20. package/eventcatalog/src/components/MDX/NodeGraph/AstroNodeGraph.tsx +34 -3
  21. package/eventcatalog/src/components/MDX/NodeGraph/NodeGraph.astro +5 -1
  22. package/eventcatalog/src/components/NavigationProgress.astro +7 -0
  23. package/eventcatalog/src/features/mcp/apps/architecture-diagram/node-chat.tsx +6 -45
  24. package/eventcatalog/src/features/mcp/apps/generated/canvas.html +67 -67
  25. package/eventcatalog/src/features/mcp/apps/generated/viewer.html +69 -69
  26. package/eventcatalog/src/features/studio/agent-choreography.ts +8 -6
  27. package/eventcatalog/src/features/studio/api/canvases-api.ts +64 -15
  28. package/eventcatalog/src/features/studio/api/client.ts +6 -1
  29. package/eventcatalog/src/features/studio/canvas-actions.ts +140 -22
  30. package/eventcatalog/src/features/studio/canvas-doc.ts +165 -10
  31. package/eventcatalog/src/features/studio/catalog.ts +15 -10
  32. package/eventcatalog/src/features/studio/components/CanvasControls.tsx +54 -89
  33. package/eventcatalog/src/features/studio/components/CanvasHeader.tsx +43 -26
  34. package/eventcatalog/src/features/studio/components/CanvasStatusMenu.tsx +8 -11
  35. package/eventcatalog/src/features/studio/components/Comments.tsx +15 -8
  36. package/eventcatalog/src/features/studio/components/DeleteCanvasDialog.tsx +27 -43
  37. package/eventcatalog/src/features/studio/components/Dialog.tsx +60 -0
  38. package/eventcatalog/src/features/studio/components/LeftPanel.tsx +32 -4
  39. package/eventcatalog/src/features/studio/components/OpenInStudioDialog.tsx +79 -0
  40. package/eventcatalog/src/features/studio/components/Presence.tsx +47 -38
  41. package/eventcatalog/src/features/studio/components/StudioDesigner.tsx +323 -99
  42. package/eventcatalog/src/features/studio/components/canvas-nodes.tsx +33 -17
  43. package/eventcatalog/src/features/studio/flow-state.ts +29 -0
  44. package/eventcatalog/src/features/studio/from-visualiser.ts +138 -0
  45. package/eventcatalog/src/features/studio/grouping.ts +4 -0
  46. package/eventcatalog/src/features/studio/hooks/use-canvas-webmcp.ts +1 -1
  47. package/eventcatalog/src/features/studio/hooks/use-counter-scale.ts +20 -0
  48. package/eventcatalog/src/features/studio/hooks/use-follow-camera.ts +8 -3
  49. package/eventcatalog/src/features/studio/hooks/use-studio-flow.ts +67 -13
  50. package/eventcatalog/src/features/studio/layout.ts +29 -15
  51. package/eventcatalog/src/features/studio/level-layout.ts +145 -0
  52. package/eventcatalog/src/features/studio/levels.ts +82 -27
  53. package/eventcatalog/src/features/studio/limits.ts +2 -0
  54. package/eventcatalog/src/features/studio/node-types.ts +44 -4
  55. package/eventcatalog/src/features/studio/pages/index.astro +2 -1
  56. package/eventcatalog/src/features/studio/server/canvas-mcp.ts +1 -1
  57. package/eventcatalog/src/features/studio/server/runtime.ts +12 -4
  58. package/eventcatalog/src/utils/node-graphs/domain-levels-node-graph.ts +3 -3
  59. package/eventcatalog/src/utils/node-graphs/node-resource.ts +67 -0
  60. package/eventcatalog/src/utils/node-graphs/systems-node-graph.ts +1 -1
  61. package/package.json +2 -2
@@ -66,16 +66,21 @@ export const canBeContainer = (resource: CatalogResource) =>
66
66
  export const containerTypeFor = (resource: CatalogResource) =>
67
67
  resource.collection === 'domains' ? GROUP_TYPES.domain : GROUP_TYPES.system;
68
68
 
69
- /** A container's data: the domain or system it stands for, and where it is in the catalog */
70
- export const catalogGroupData = (resource: CatalogResource) => ({
71
- [resource.collection === 'domains' ? 'domain' : 'system']: {
72
- id: resource.id,
73
- name: resource.name,
74
- version: resource.version,
75
- summary: resource.summary,
76
- },
77
- catalog: { key: resource.key, url: resource.url, version: resource.version } satisfies CatalogLink,
78
- });
69
+ /** A container's data: the domain or system it stands for (an external system marked as one), and where it is in the catalog */
70
+ export const catalogGroupData = (resource: CatalogResource) => {
71
+ const key = resource.collection === 'domains' ? 'domain' : 'system';
72
+ const { scope } = (resource.node.data[key] ?? {}) as { scope?: string };
73
+ return {
74
+ [key]: {
75
+ id: resource.id,
76
+ name: resource.name,
77
+ version: resource.version,
78
+ summary: resource.summary,
79
+ ...(scope && { scope }),
80
+ },
81
+ catalog: { key: resource.key, url: resource.url, version: resource.version } satisfies CatalogLink,
82
+ };
83
+ };
79
84
 
80
85
  /** What a domain or system contains in the catalog: a domain's systems and services, a system's services */
81
86
  export const getContents = (
@@ -1,51 +1,17 @@
1
- import { memo, type ReactNode } from 'react';
2
- import { useReactFlow, useStore } from '@xyflow/react';
1
+ import { memo } from 'react';
2
+ import { useReactFlow } from '@xyflow/react';
3
3
  import { Maximize, MessageCircle, Redo, SendToBack, StickyNote, Undo, ZoomIn, ZoomOut } from 'lucide-react';
4
+ import { DIAGRAM_FIT_VIEW_OPTIONS, Toolbar, ToolbarButton, ToolbarDivider, ZoomLevel } from '@eventcatalog/visualiser';
4
5
  import { LEVELS, type Level } from '../levels';
5
6
 
6
- // The canvas's bottom bar: layout, view, levels of detail, undo, comments and notes
7
+ // The canvas's bottom bar: layout, view, levels of detail, undo, comments and notes. Built from the visualiser's
8
+ // toolbar, so it looks and works like a diagram's bar (in the same places: the view on the left, then the levels).
7
9
 
8
- export const FIT_VIEW_OPTIONS = { padding: { top: '80px', right: '60px', bottom: '100px', left: '60px' } } as const;
10
+ // Fitted like the visualiser fits a diagram
11
+ export const FIT_VIEW_OPTIONS = DIAGRAM_FIT_VIEW_OPTIONS;
9
12
 
10
- function ControlButton({
11
- label,
12
- onClick,
13
- disabled,
14
- active,
15
- children,
16
- }: {
17
- label: string;
18
- onClick: () => void;
19
- disabled?: boolean;
20
- active?: boolean;
21
- children: ReactNode;
22
- }) {
23
- return (
24
- <button
25
- title={label}
26
- aria-label={label}
27
- onClick={onClick}
28
- disabled={disabled}
29
- className={`rounded-full p-2 transition-colors ${
30
- disabled
31
- ? 'cursor-not-allowed opacity-40'
32
- : active
33
- ? 'bg-[rgb(var(--ec-accent-subtle))] text-[rgb(var(--ec-accent))]'
34
- : 'text-[rgb(var(--ec-icon-color))] hover:bg-[rgb(var(--ec-page-border)/0.5)] hover:text-[rgb(var(--ec-icon-hover))]'
35
- }`}
36
- >
37
- {children}
38
- </button>
39
- );
40
- }
41
-
42
- /** Isolated, so the bar only re-renders when the zoom changes (not on every pan) */
43
- function ZoomLevel() {
44
- const zoom = useStore((state) => Math.round(state.transform[2] * 100));
45
- return <div className="w-12 text-center text-xs font-medium tabular-nums text-[rgb(var(--ec-page-text-muted))]">{zoom}%</div>;
46
- }
47
-
48
- const Divider = () => <div className="mx-1 h-6 w-px bg-[rgb(var(--ec-page-border))]" />;
13
+ // Why comments can't be added on L1 or L2 (they're pinned to the canvas, L3)
14
+ const EDIT_IN_L3 = 'Only on L3, where comments are pinned';
49
15
 
50
16
  function LevelSwitch({
51
17
  level,
@@ -58,29 +24,18 @@ function LevelSwitch({
58
24
  }) {
59
25
  return (
60
26
  <div role="group" aria-label="Level of detail" className="flex items-center gap-0.5">
61
- {LEVELS.map(({ level: value, description }) => {
62
- const reason = unavailable[value];
63
- const active = value === level;
64
- return (
65
- <button
66
- key={value}
67
- title={`Level ${value}: ${description}${reason ? `. ${reason}` : ''}`}
68
- // Stays focusable when unavailable, so its title can say why
69
- aria-disabled={!!reason}
70
- aria-pressed={active}
71
- onClick={() => !reason && onChange(value)}
72
- className={`h-8 min-w-8 rounded-full px-2 text-xs font-semibold tabular-nums transition-colors ${
73
- reason
74
- ? 'cursor-not-allowed opacity-40'
75
- : active
76
- ? 'bg-[rgb(var(--ec-accent-subtle))] text-[rgb(var(--ec-accent))]'
77
- : 'text-[rgb(var(--ec-page-text-muted))] hover:bg-[rgb(var(--ec-page-border)/0.5)]'
78
- }`}
79
- >
80
- L{value}
81
- </button>
82
- );
83
- })}
27
+ {LEVELS.map(({ level: value, description }) => (
28
+ <ToolbarButton
29
+ key={value}
30
+ label={`Level ${value}: ${description}`}
31
+ hint={unavailable[value]}
32
+ active={value === level}
33
+ disabled={!!unavailable[value]}
34
+ onClick={() => onChange(value)}
35
+ >
36
+ <span className="text-xs font-semibold">L{value}</span>
37
+ </ToolbarButton>
38
+ ))}
84
39
  </div>
85
40
  );
86
41
  }
@@ -102,7 +57,7 @@ export default memo(function CanvasControls({
102
57
  level: Level;
103
58
  onLevelChange: (level: Level) => void;
104
59
  unavailableLevels: Partial<Record<Level, string | undefined>>;
105
- /** L1 and L2 are read only views of the canvas */
60
+ /** L3, the canvas itself (comments are there; L1 and L2 are arranged on their own) */
106
61
  editable: boolean;
107
62
  onReorder: () => void;
108
63
  canUndo: boolean;
@@ -116,39 +71,49 @@ export default memo(function CanvasControls({
116
71
  const { fitView, zoomIn, zoomOut } = useReactFlow();
117
72
 
118
73
  return (
119
- <div className="absolute bottom-4 left-1/2 z-10 -translate-x-1/2">
120
- <div className="flex items-center gap-1 rounded-full border px-2 py-1 shadow-lg bg-[rgb(var(--ec-card-bg))] border-[rgb(var(--ec-page-border))]">
121
- <ControlButton label="Reorder diagram (the visualiser's layout)" onClick={onReorder} disabled={!editable}>
74
+ // Where the visualiser's bar is (React Flow's bottom-center panel)
75
+ <div className="absolute bottom-[15px] left-1/2 z-10 -translate-x-1/2">
76
+ <Toolbar>
77
+ <ToolbarButton
78
+ label={editable ? "Reorder diagram (the visualiser's layout)" : `Reorder L${level} (the visualiser's layout)`}
79
+ onClick={onReorder}
80
+ >
122
81
  <SendToBack className="h-4 w-4" />
123
- </ControlButton>
124
- <ControlButton label="Fit view" onClick={() => void fitView({ ...FIT_VIEW_OPTIONS, duration: 500 })}>
82
+ </ToolbarButton>
83
+ <ToolbarButton label="Fit view" onClick={() => void fitView({ ...FIT_VIEW_OPTIONS, duration: 500 })}>
125
84
  <Maximize className="h-4 w-4" />
126
- </ControlButton>
127
- <ControlButton label="Zoom out" onClick={() => void zoomOut({ duration: 200 })}>
85
+ </ToolbarButton>
86
+ <ToolbarButton label="Zoom out" onClick={() => void zoomOut({ duration: 200 })}>
128
87
  <ZoomOut className="h-4 w-4" />
129
- </ControlButton>
88
+ </ToolbarButton>
130
89
  <ZoomLevel />
131
- <ControlButton label="Zoom in" onClick={() => void zoomIn({ duration: 200 })}>
90
+ <ToolbarButton label="Zoom in" onClick={() => void zoomIn({ duration: 200 })}>
132
91
  <ZoomIn className="h-4 w-4" />
133
- </ControlButton>
134
- <Divider />
92
+ </ToolbarButton>
93
+ <ToolbarDivider />
135
94
  <LevelSwitch level={level} onChange={onLevelChange} unavailable={unavailableLevels} />
136
- <Divider />
137
- <ControlButton label="Undo (⌘Z)" onClick={onUndo} disabled={!canUndo}>
95
+ <ToolbarDivider />
96
+ <ToolbarButton label="Undo (⌘Z)" hint={canUndo ? undefined : 'Nothing to undo'} disabled={!canUndo} onClick={onUndo}>
138
97
  <Undo className="h-4 w-4" />
139
- </ControlButton>
140
- <ControlButton label="Redo (⇧⌘Z)" onClick={onRedo} disabled={!canRedo}>
98
+ </ToolbarButton>
99
+ <ToolbarButton label="Redo (⇧⌘Z)" hint={canRedo ? undefined : 'Nothing to redo'} disabled={!canRedo} onClick={onRedo}>
141
100
  <Redo className="h-4 w-4" />
142
- </ControlButton>
143
- <Divider />
144
- <ControlButton label="Comment (C)" onClick={onToggleCommentMode} active={commentMode} disabled={!editable}>
101
+ </ToolbarButton>
102
+ <ToolbarDivider />
103
+ <ToolbarButton
104
+ label="Comment (C)"
105
+ hint={editable ? undefined : EDIT_IN_L3}
106
+ active={commentMode}
107
+ disabled={!editable}
108
+ onClick={onToggleCommentMode}
109
+ >
145
110
  <MessageCircle className="h-4 w-4" />
146
- </ControlButton>
111
+ </ToolbarButton>
147
112
  {/* Notes can be added on every level, and are shown on the level they're added on */}
148
- <ControlButton label={editable ? 'Add sticky note' : `Add sticky note to L${level}`} onClick={onAddNote}>
113
+ <ToolbarButton label={editable ? 'Add sticky note' : `Add sticky note to L${level}`} onClick={onAddNote}>
149
114
  <StickyNote className="h-4 w-4 text-yellow-500" />
150
- </ControlButton>
151
- </div>
115
+ </ToolbarButton>
116
+ </Toolbar>
152
117
  </div>
153
118
  );
154
119
  });
@@ -1,5 +1,13 @@
1
1
  import { memo, useEffect, useRef, useState, type ReactNode } from 'react';
2
- import { Bot, ChevronDown, Link2, Pencil, Plus, Trash2, UserPlus } from 'lucide-react';
2
+ import { Bot, Link2, MoreVertical, Pencil, Plus, Trash2, UserPlus } from 'lucide-react';
3
+ import {
4
+ DIAGRAM_MENU,
5
+ DIAGRAM_MENU_BUTTON_ICON,
6
+ DIAGRAM_MENU_ITEM,
7
+ DIAGRAM_MENU_ITEM_ICON,
8
+ DIAGRAM_MENU_SEPARATOR,
9
+ DIAGRAM_MENU_TITLE,
10
+ } from '@eventcatalog/visualiser';
3
11
  import { usePeople, type Peer, type PresenceStore } from '../hooks/presence-store';
4
12
  import type { WebMcpStatus } from '../hooks/use-canvas-webmcp';
5
13
  import type { Status, Transport } from '../hooks/use-studio-flow';
@@ -9,17 +17,17 @@ import { CONNECTION_DOT, STATUS } from './status';
9
17
  import Picture from './Picture';
10
18
 
11
19
  /**
12
- * The canvas's header, like Figma or Miro: two small floating bars. On the left the canvas (its title, and a
13
- * menu), on the right who's here, connecting an agent and sharing.
20
+ * The canvas's header: two small bars over the canvas. On the left the canvas (its title, and a menu), on the right
21
+ * who's here, connecting an agent and sharing. They look like the visualiser's menu at the top left of a diagram
22
+ * (its styles, `DIAGRAM_MENU_*`), so a diagram opened in Studio feels the same.
14
23
  */
15
24
 
16
25
  const barClass =
17
- 'pointer-events-auto flex h-10 items-center rounded-xl border shadow-md bg-[rgb(var(--ec-card-bg))] border-[rgb(var(--ec-page-border))]';
26
+ 'pointer-events-auto flex h-[42px] items-center rounded-md border bg-[rgb(var(--ec-card-bg))] border-[rgb(var(--ec-page-border))]';
18
27
  // The header lets the pointer through to the canvas around its bars, so its menus take it back
19
- const menuClass =
20
- 'pointer-events-auto absolute top-full z-50 mt-2 min-w-52 rounded-xl border p-1 shadow-xl bg-[rgb(var(--ec-card-bg))] border-[rgb(var(--ec-page-border))] text-[rgb(var(--ec-page-text))]';
21
- const menuItemLayout = 'flex w-full items-center gap-2 rounded-lg px-2.5 py-1.5 text-left text-xs';
22
- const menuItemClass = `${menuItemLayout} hover:bg-[rgb(var(--ec-page-border)/0.5)]`;
28
+ const popoverClass = 'pointer-events-auto absolute top-full mt-2';
29
+ const menuClass = `${popoverClass} ${DIAGRAM_MENU}`;
30
+ const menuItemClass = `${DIAGRAM_MENU_ITEM} w-full text-left`;
23
31
  const MAX_AVATARS = 4;
24
32
 
25
33
  /** Closes a popover on outside clicks and Escape */
@@ -87,7 +95,7 @@ function CanvasMenu({
87
95
 
88
96
  return (
89
97
  <div ref={menu.ref} className="relative">
90
- <div className={`${barClass} gap-1 pl-3 pr-1`}>
98
+ <div className={`${barClass} gap-1.5 pl-4 pr-1.5`}>
91
99
  <span title={statusLabel} className={`h-2 w-2 shrink-0 rounded-full ${statusColor}`} />
92
100
  <input
93
101
  ref={titleInput}
@@ -101,15 +109,15 @@ function CanvasMenu({
101
109
  }}
102
110
  onKeyDown={(e) => e.key === 'Enter' && e.currentTarget.blur()}
103
111
  size={Math.max(12, (draft ?? title ?? 'Untitled canvas').length)}
104
- className="max-w-64 truncate rounded-md bg-transparent px-1.5 py-1 text-sm font-semibold placeholder:font-semibold placeholder:text-[rgb(var(--ec-page-text-muted))] hover:bg-[rgb(var(--ec-page-border)/0.4)] focus:bg-[rgb(var(--ec-input-bg))] focus:outline-none"
112
+ className={`${DIAGRAM_MENU_TITLE} max-w-64 truncate rounded-md bg-transparent px-1.5 py-1 placeholder:text-[rgb(var(--ec-page-text-muted))] hover:bg-[rgb(var(--ec-page-border)/0.4)] focus:bg-[rgb(var(--ec-input-bg))] focus:outline-none`}
105
113
  />
106
114
  {children}
107
115
  <button
108
116
  aria-label="Canvas menu"
109
117
  onClick={() => menu.setOpen((open) => !open)}
110
- className="rounded-md p-1.5 text-[rgb(var(--ec-icon-color))] hover:bg-[rgb(var(--ec-page-border)/0.5)]"
118
+ className="group rounded-md p-1 hover:bg-[rgb(var(--ec-accent-subtle))]"
111
119
  >
112
- <ChevronDown size={14} />
120
+ <MoreVertical className={DIAGRAM_MENU_BUTTON_ICON} />
113
121
  </button>
114
122
  </div>
115
123
  {menu.open && (
@@ -122,7 +130,7 @@ function CanvasMenu({
122
130
  titleInput.current?.select();
123
131
  }}
124
132
  >
125
- <Pencil size={14} /> Rename canvas
133
+ <Pencil className={DIAGRAM_MENU_ITEM_ICON} /> Rename canvas
126
134
  </button>
127
135
  <button
128
136
  className={menuItemClass}
@@ -131,29 +139,30 @@ function CanvasMenu({
131
139
  menu.setOpen(false);
132
140
  }}
133
141
  >
134
- <Link2 size={14} /> Copy link
142
+ <Link2 className={DIAGRAM_MENU_ITEM_ICON} /> Copy link
135
143
  </button>
136
144
  {newCanvasUrl && (
137
145
  <a className={menuItemClass} href={newCanvasUrl}>
138
- <Plus size={14} /> New canvas
146
+ <Plus className={DIAGRAM_MENU_ITEM_ICON} /> New canvas
139
147
  </a>
140
148
  )}
141
149
  {onDelete && (
142
150
  <>
143
- <div className="my-1 h-px bg-[rgb(var(--ec-page-border))]" />
151
+ <div className={DIAGRAM_MENU_SEPARATOR} />
144
152
  <button
145
- className={`${menuItemLayout} ${STATUS.danger.text} ${STATUS.danger.hover}`}
153
+ // Laid out like the other items, in the theme's danger colour
154
+ className={`flex w-full cursor-pointer items-center gap-2 px-3 py-2 text-left text-xs transition-colors ${STATUS.danger.text} ${STATUS.danger.hover}`}
146
155
  onClick={() => {
147
156
  menu.setOpen(false);
148
157
  onDelete();
149
158
  }}
150
159
  >
151
- <Trash2 size={14} /> Delete canvas
160
+ <Trash2 className="h-3.5 w-3.5 shrink-0" /> Delete canvas
152
161
  </button>
153
162
  </>
154
163
  )}
155
- <div className="my-1 h-px bg-[rgb(var(--ec-page-border))]" />
156
- <p className="flex items-center gap-2 px-2.5 py-1.5 text-[11px] text-[rgb(var(--ec-page-text-muted))]">
164
+ <div className={DIAGRAM_MENU_SEPARATOR} />
165
+ <p className="flex items-center gap-2 px-3 py-1.5 text-[11px] text-[rgb(var(--ec-page-text-muted))]">
157
166
  <span className={`h-1.5 w-1.5 rounded-full ${statusColor}`} />
158
167
  {statusLabel}
159
168
  </p>
@@ -188,7 +197,9 @@ function YouAvatar({ peer, onRename }: { peer: Peer; onRename?: (name: string) =
188
197
  <Avatar peer={peer} />
189
198
  </button>
190
199
  {popover.open && (
191
- <div className={`${menuClass} right-0 w-60 space-y-2 p-3`}>
200
+ <div
201
+ className={`${popoverClass} right-0 z-50 w-60 space-y-2 rounded-lg border p-3 shadow-xl bg-[rgb(var(--ec-page-bg))] border-[rgb(var(--ec-page-border))]`}
202
+ >
192
203
  <label className="block space-y-1.5 text-[11px] font-medium text-[rgb(var(--ec-page-text-muted))]">
193
204
  Your name on this canvas
194
205
  <input
@@ -319,9 +330,11 @@ export default memo(function CanvasHeader({
319
330
  }) {
320
331
  const peers = usePeople(presence);
321
332
  const agentsHere = peers.some((peer) => peer.agent);
333
+ // Nobody else here, and signed in (so not shown as "you" to rename): no list of people
334
+ const showPeople = !!onRename || peers.some((peer) => peer.clientId !== clientId);
322
335
 
323
336
  return (
324
- <div className="pointer-events-none absolute left-3 right-3 top-3 z-10 flex items-start justify-between gap-3">
337
+ <div className="pointer-events-none absolute left-4 right-4 top-4 z-10 flex items-start justify-between gap-3">
325
338
  <CanvasMenu
326
339
  title={title}
327
340
  status={status}
@@ -335,10 +348,14 @@ export default memo(function CanvasHeader({
335
348
  </CanvasMenu>
336
349
 
337
350
  <div className={`${barClass} gap-2 px-1.5`}>
338
- <div className="pl-1">
339
- <People peers={peers} clientId={clientId} onJumpTo={onJumpTo} onRename={onRename} />
340
- </div>
341
- <div className="h-5 w-px bg-[rgb(var(--ec-page-border))]" />
351
+ {showPeople && (
352
+ <>
353
+ <div className="pl-1">
354
+ <People peers={peers} clientId={clientId} onJumpTo={onJumpTo} onRename={onRename} />
355
+ </div>
356
+ <div className="h-5 w-px bg-[rgb(var(--ec-page-border))]" />
357
+ </>
358
+ )}
342
359
  <button
343
360
  onClick={onConnectAgent}
344
361
  title={
@@ -1,5 +1,6 @@
1
1
  import { memo, useEffect, useRef, useState } from 'react';
2
2
  import { Check, ChevronDown } from 'lucide-react';
3
+ import { DIAGRAM_MENU, DIAGRAM_MENU_ITEM, DIAGRAM_MENU_SEPARATOR } from '@eventcatalog/visualiser';
3
4
  import { CANVAS_STATUSES, type CanvasStatus, type StatusChange } from '../canvas-doc';
4
5
  import { timeAgo } from './Comments';
5
6
  import { CANVAS_STATUS_LOOK } from './status';
@@ -58,18 +59,14 @@ export default memo(function CanvasStatusMenu({
58
59
  <ChevronDown size={12} />
59
60
  </button>
60
61
  {open && (
61
- <div className="absolute left-0 top-full z-50 mt-2 w-72 rounded-xl border p-1 shadow-xl bg-[rgb(var(--ec-card-bg))] border-[rgb(var(--ec-page-border))] text-[rgb(var(--ec-page-text))]">
62
- <p className="px-2.5 pb-1 pt-1.5 text-[11px] font-semibold uppercase tracking-wide text-[rgb(var(--ec-page-text-muted))]">
62
+ <div className={`absolute left-0 top-full mt-2 w-72 ${DIAGRAM_MENU}`}>
63
+ <p className="px-3 pb-1 pt-1.5 text-[11px] font-semibold uppercase tracking-wide text-[rgb(var(--ec-page-text-muted))]">
63
64
  Status
64
65
  </p>
65
66
  {CANVAS_STATUSES.map((value) => {
66
67
  const option = CANVAS_STATUS_LOOK[value];
67
68
  return (
68
- <button
69
- key={value}
70
- onClick={() => choose(value)}
71
- className="flex w-full items-center gap-2.5 rounded-lg px-2.5 py-1.5 text-left hover:bg-[rgb(var(--ec-page-border)/0.5)]"
72
- >
69
+ <button key={value} onClick={() => choose(value)} className={`${DIAGRAM_MENU_ITEM} w-full text-left`}>
73
70
  <span className={`h-2 w-2 shrink-0 rounded-full ${option.dot}`} />
74
71
  <span className="min-w-0 flex-1">
75
72
  <span className="block text-xs font-medium">{option.label}</span>
@@ -79,7 +76,7 @@ export default memo(function CanvasStatusMenu({
79
76
  </button>
80
77
  );
81
78
  })}
82
- <div className="px-2.5 py-2">
79
+ <div className="px-3 py-2">
83
80
  <input
84
81
  value={note}
85
82
  onChange={(event) => setNote(event.target.value)}
@@ -90,11 +87,11 @@ export default memo(function CanvasStatusMenu({
90
87
  </div>
91
88
  {recent.length > 0 && (
92
89
  <>
93
- <div className="my-1 h-px bg-[rgb(var(--ec-page-border))]" />
94
- <p className="px-2.5 pb-1 pt-1.5 text-[11px] font-semibold uppercase tracking-wide text-[rgb(var(--ec-page-text-muted))]">
90
+ <div className={DIAGRAM_MENU_SEPARATOR} />
91
+ <p className="px-3 pb-1 pt-1.5 text-[11px] font-semibold uppercase tracking-wide text-[rgb(var(--ec-page-text-muted))]">
95
92
  History
96
93
  </p>
97
- <ul className="max-h-48 space-y-1.5 overflow-y-auto px-2.5 pb-2">
94
+ <ul className="max-h-48 space-y-1.5 overflow-y-auto px-3 pb-2">
98
95
  {recent.map((change) => (
99
96
  <li key={`${change.at}-${change.status}`} className="text-[11px]">
100
97
  <span className="flex items-center gap-1.5">
@@ -10,10 +10,11 @@ import {
10
10
  type ReactNode,
11
11
  } from 'react';
12
12
  import { createPortal } from 'react-dom';
13
- import { useInternalNode, useReactFlow, useStore, ViewportPortal, type Node, type XYPosition } from '@xyflow/react';
13
+ import { useInternalNode, useReactFlow, ViewportPortal, type Node, type XYPosition } from '@xyflow/react';
14
14
  import { Bot, Check, Maximize2, ClipboardPaste, Copy, CopyPlus, MessageCircle, Pencil, RotateCcw, Trash2, X } from 'lucide-react';
15
15
  import { getAbsolutePosition } from '../grouping';
16
16
  import type { Author, CommentAnchor, Thread } from '../hooks/use-comments';
17
+ import { COUNTER_SCALE, useCounterScale } from '../hooks/use-counter-scale';
17
18
  import { isWrittenOnCanvas } from '../node-types';
18
19
  import { STATUS } from './status';
19
20
  import Picture from './Picture';
@@ -458,13 +459,19 @@ function ThreadDialog({
458
459
  );
459
460
  }
460
461
 
461
- /** Where a pin goes on the canvas, following only the node it's on (if it's on one), counter-scaled */
462
- function PinAt({ anchor, zoom, zIndex, children }: { anchor: CommentAnchor; zoom: number; zIndex: number; children: ReactNode }) {
462
+ /**
463
+ * Where a pin goes on the canvas, following only the node it's on (if it's on one), counter-scaled (by the layer's
464
+ * COUNTER_SCALE, so zooming doesn't re-render it)
465
+ */
466
+ function PinAt({ anchor, zIndex, children }: { anchor: CommentAnchor; zIndex: number; children: ReactNode }) {
463
467
  const node = useInternalNode(anchor.nodeId ?? '');
464
468
  const origin = anchor.nodeId && anchor.offset ? node?.internals.positionAbsolute : undefined;
465
469
  const { x, y } = origin && anchor.offset ? { x: origin.x + anchor.offset.x, y: origin.y + anchor.offset.y } : anchor.position;
466
470
  return (
467
- <div className="absolute" style={{ left: x, top: y, transform: `scale(${1 / zoom})`, transformOrigin: '0 0', zIndex }}>
471
+ <div
472
+ className="absolute"
473
+ style={{ left: x, top: y, transform: `scale(var(${COUNTER_SCALE}, 1))`, transformOrigin: '0 0', zIndex }}
474
+ >
468
475
  {/* The pin's tip (bottom left corner) sits on the anchor point, and it grows up and right as it opens */}
469
476
  <div className="nopan nodrag nowheel pointer-events-auto absolute bottom-0 left-0">{children}</div>
470
477
  </div>
@@ -511,7 +518,8 @@ export const CommentLayer = memo(function CommentLayer({
511
518
  onMove: (threadId: string, anchor: CommentAnchor) => void;
512
519
  onMoveDraft: (anchor: CommentAnchor) => void;
513
520
  }) {
514
- const zoom = useStore((state) => state.transform[2]);
521
+ const layer = useRef<HTMLDivElement>(null);
522
+ useCounterScale(layer);
515
523
  const { screenToFlowPosition, getInternalNode } = useReactFlow();
516
524
  // A pin being dragged stays here until it's dropped, then moves for everyone
517
525
  const [moving, setMoving] = useState<{ threadId: string; anchor: CommentAnchor } | null>(null);
@@ -539,7 +547,7 @@ export const CommentLayer = memo(function CommentLayer({
539
547
  return (
540
548
  <ViewportPortal>
541
549
  {/* Above nodes (selected ones are raised to 1000) and other people's pointers */}
542
- <div className="absolute left-0 top-0" style={{ zIndex: 2001 }}>
550
+ <div ref={layer} className="absolute left-0 top-0" style={{ zIndex: 2001 }}>
543
551
  {visible.map((thread) => {
544
552
  const isOpen = thread.id === openThreadId;
545
553
  return (
@@ -552,7 +560,6 @@ export const CommentLayer = memo(function CommentLayer({
552
560
  ? { position: { x: thread.position.x + selectionOffset.x, y: thread.position.y + selectionOffset.y } }
553
561
  : thread
554
562
  }
555
- zoom={zoom}
556
563
  zIndex={isOpen ? 2 : 1}
557
564
  >
558
565
  <Pin
@@ -605,7 +612,7 @@ export const CommentLayer = memo(function CommentLayer({
605
612
  )}
606
613
 
607
614
  {draft && (
608
- <PinAt anchor={draft} zoom={zoom} zIndex={3}>
615
+ <PinAt anchor={draft} zIndex={3}>
609
616
  <Pin author={author} active onDrag={(tip, phase) => onMoveDraft(anchorFor(tip, phase))} />
610
617
  <FloatingPanel>
611
618
  <Composer placeholder="Add a comment" onSubmit={onCreate} onCancel={onCancelDraft} />
@@ -1,5 +1,6 @@
1
- import { useEffect, useState } from 'react';
1
+ import { useState } from 'react';
2
2
  import { usePeople, type PresenceStore } from '../hooks/presence-store';
3
+ import Dialog, { SECONDARY_BUTTON } from './Dialog';
3
4
  import { STATUS } from './status';
4
5
 
5
6
  /** Asks before deleting a canvas: it's deleted for everyone, for good */
@@ -22,12 +23,6 @@ export default function DeleteCanvasDialog({
22
23
  const [deleting, setDeleting] = useState(false);
23
24
  const [error, setError] = useState<string>();
24
25
 
25
- useEffect(() => {
26
- const onKeyDown = (event: KeyboardEvent) => event.key === 'Escape' && !deleting && onClose();
27
- window.addEventListener('keydown', onKeyDown);
28
- return () => window.removeEventListener('keydown', onKeyDown);
29
- }, [onClose, deleting]);
30
-
31
26
  const confirm = async () => {
32
27
  setDeleting(true);
33
28
  setError(undefined);
@@ -40,42 +35,31 @@ export default function DeleteCanvasDialog({
40
35
  };
41
36
 
42
37
  return (
43
- <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onClick={() => !deleting && onClose()}>
44
- <div
45
- role="alertdialog"
46
- aria-labelledby="delete-canvas-title"
47
- onClick={(event) => event.stopPropagation()}
48
- className="w-full max-w-md rounded-xl border shadow-2xl bg-[rgb(var(--ec-card-bg))] border-[rgb(var(--ec-page-border))] text-[rgb(var(--ec-page-text))]"
49
- >
50
- <div className="space-y-2 px-5 py-4">
51
- <h2 id="delete-canvas-title" className="text-base font-semibold">
52
- Delete {title ? `"${title}"` : 'this canvas'}?
53
- </h2>
54
- <p className="text-sm text-[rgb(var(--ec-page-text-muted))]">
55
- It's deleted for everyone, with its comments, and can't be brought back.
56
- {othersHere > 0 &&
57
- ` ${othersHere === 1 ? 'Someone else has' : `${othersHere} others have`} it open: it closes for them too.`}
58
- </p>
59
- {error && <p className={`text-sm ${STATUS.danger.text}`}>{error}</p>}
60
- </div>
61
- <div className="flex justify-end gap-2 border-t px-5 py-4 border-[rgb(var(--ec-page-border))]">
62
- <button
63
- onClick={onClose}
64
- disabled={deleting}
65
- className="rounded-lg border px-4 py-2 text-sm font-medium border-[rgb(var(--ec-page-border))] hover:bg-[rgb(var(--ec-page-border)/0.4)] disabled:opacity-50"
66
- >
67
- Cancel
68
- </button>
69
- <button
70
- autoFocus
71
- onClick={confirm}
72
- disabled={deleting}
73
- className={`rounded-lg px-4 py-2 text-sm font-medium disabled:opacity-60 ${STATUS.danger.button}`}
74
- >
75
- {deleting ? 'Deleting…' : 'Delete canvas'}
76
- </button>
77
- </div>
38
+ <Dialog role="alertdialog" labelledBy="delete-canvas-title" busy={deleting} onClose={onClose}>
39
+ <div className="space-y-2 px-5 py-4">
40
+ <h2 id="delete-canvas-title" className="text-base font-semibold">
41
+ Delete {title ? `"${title}"` : 'this canvas'}?
42
+ </h2>
43
+ <p className="text-sm text-[rgb(var(--ec-page-text-muted))]">
44
+ It's deleted for everyone, with its comments, and can't be brought back.
45
+ {othersHere > 0 &&
46
+ ` ${othersHere === 1 ? 'Someone else has' : `${othersHere} others have`} it open: it closes for them too.`}
47
+ </p>
48
+ {error && <p className={`text-sm ${STATUS.danger.text}`}>{error}</p>}
49
+ </div>
50
+ <div className="flex justify-end gap-2 border-t px-5 py-4 border-[rgb(var(--ec-page-border))]">
51
+ <button onClick={onClose} disabled={deleting} className={SECONDARY_BUTTON}>
52
+ Cancel
53
+ </button>
54
+ <button
55
+ autoFocus
56
+ onClick={confirm}
57
+ disabled={deleting}
58
+ className={`rounded-lg px-4 py-2 text-sm font-medium disabled:opacity-60 ${STATUS.danger.button}`}
59
+ >
60
+ {deleting ? 'Deleting…' : 'Delete canvas'}
61
+ </button>
78
62
  </div>
79
- </div>
63
+ </Dialog>
80
64
  );
81
65
  }
@@ -0,0 +1,60 @@
1
+ import { useEffect, useRef, type ReactNode } from 'react';
2
+
3
+ /**
4
+ * Studio's dialogs: a card over the page, closed by Escape or clicking outside it (not while it's busy). Focus stays
5
+ * in it: anything that moves focus outside (e.g. the menu it was opened from, closing) sends it back to its first
6
+ * field or button.
7
+ */
8
+
9
+ export const SECONDARY_BUTTON =
10
+ 'rounded-lg border px-4 py-2 text-sm font-medium border-[rgb(var(--ec-page-border))] hover:bg-[rgb(var(--ec-page-border)/0.4)] disabled:opacity-50';
11
+ export const PRIMARY_BUTTON =
12
+ '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))] disabled:opacity-60';
13
+
14
+ export default function Dialog({
15
+ labelledBy,
16
+ role = 'dialog',
17
+ busy = false,
18
+ onClose,
19
+ children,
20
+ }: {
21
+ /** The id of its heading */
22
+ labelledBy: string;
23
+ /** `alertdialog` when it asks to confirm something that can't be undone */
24
+ role?: 'dialog' | 'alertdialog';
25
+ /** Doing what was asked: it can't be closed until it's done */
26
+ busy?: boolean;
27
+ onClose: () => void;
28
+ children: ReactNode;
29
+ }) {
30
+ useEffect(() => {
31
+ const onKeyDown = (event: KeyboardEvent) => event.key === 'Escape' && !busy && onClose();
32
+ window.addEventListener('keydown', onKeyDown);
33
+ return () => window.removeEventListener('keydown', onKeyDown);
34
+ }, [onClose, busy]);
35
+
36
+ const card = useRef<HTMLDivElement>(null);
37
+ useEffect(() => {
38
+ const keepFocus = (event: FocusEvent) => {
39
+ if (!card.current || card.current.contains(event.target as globalThis.Node)) return;
40
+ card.current.querySelector<HTMLElement>('input, button')?.focus();
41
+ };
42
+ document.addEventListener('focusin', keepFocus);
43
+ return () => document.removeEventListener('focusin', keepFocus);
44
+ }, []);
45
+
46
+ return (
47
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onClick={() => !busy && onClose()}>
48
+ <div
49
+ ref={card}
50
+ role={role}
51
+ aria-modal="true"
52
+ aria-labelledby={labelledBy}
53
+ onClick={(event) => event.stopPropagation()}
54
+ className="w-full max-w-md rounded-xl border shadow-2xl bg-[rgb(var(--ec-card-bg))] border-[rgb(var(--ec-page-border))] text-[rgb(var(--ec-page-text))]"
55
+ >
56
+ {children}
57
+ </div>
58
+ </div>
59
+ );
60
+ }