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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/README.md +1 -1
  2. package/dist/analytics/analytics.cjs +1 -1
  3. package/dist/analytics/analytics.js +2 -2
  4. package/dist/analytics/log-build.cjs +1 -1
  5. package/dist/analytics/log-build.js +4 -4
  6. package/dist/catalog-runtime.cjs +1 -1
  7. package/dist/{chunk-MFEMKQZ6.js → chunk-BO2HHVZ3.js} +1 -1
  8. package/dist/{chunk-4YFM63DS.js → chunk-JPT3QC5V.js} +1 -1
  9. package/dist/{chunk-76DAAJLV.js → chunk-PLTLW45Q.js} +5 -5
  10. package/dist/{chunk-WM4QYMIA.js → chunk-T75DOVFP.js} +1 -1
  11. package/dist/{chunk-LQZ7IG7B.js → chunk-XA5W6DXX.js} +1 -1
  12. package/dist/constants.cjs +1 -1
  13. package/dist/constants.js +1 -1
  14. package/dist/eventcatalog.cjs +2 -2
  15. package/dist/eventcatalog.js +14 -14
  16. package/dist/federation/federate.js +3 -3
  17. package/dist/generate.cjs +1 -1
  18. package/dist/generate.js +3 -3
  19. package/dist/runtime-dependencies/@eventcatalog__visualiser__studio-fonts.css.mjs +3 -0
  20. package/dist/runtime-dependencies/@eventcatalog__visualiser__studio.css.mjs +3 -0
  21. package/dist/runtime-dependencies/@eventcatalog__visualiser__studio.mjs +3 -0
  22. package/dist/runtime-dependencies/manifest.json +3 -0
  23. package/dist/utils/cli-logger.cjs +1 -1
  24. package/dist/utils/cli-logger.js +2 -2
  25. package/eventcatalog/src/components/ChatPanel/ChatPanel.tsx +2 -0
  26. package/eventcatalog/src/components/MDX/NodeGraph/AstroNodeGraph.tsx +42 -3
  27. package/eventcatalog/src/components/MDX/NodeGraph/NodeGraph.astro +5 -1
  28. package/eventcatalog/src/components/NavigationProgress.astro +7 -0
  29. package/eventcatalog/src/features/mcp/apps/architecture-diagram/node-chat.tsx +6 -45
  30. package/eventcatalog/src/features/mcp/apps/canvas/shared.ts +3 -3
  31. package/eventcatalog/src/features/mcp/apps/canvas/styles.css +3 -1
  32. package/eventcatalog/src/features/mcp/apps/canvas/view.tsx +7 -5
  33. package/eventcatalog/src/features/mcp/apps/generated/canvas.html +132 -70
  34. package/eventcatalog/src/features/mcp/apps/generated/viewer.html +71 -71
  35. package/eventcatalog/src/features/studio/agent-choreography.ts +13 -10
  36. package/eventcatalog/src/features/studio/agents.ts +28 -0
  37. package/eventcatalog/src/features/studio/api/canvases-api.ts +64 -15
  38. package/eventcatalog/src/features/studio/api/client.ts +6 -1
  39. package/eventcatalog/src/features/studio/canvas-actions.ts +160 -34
  40. package/eventcatalog/src/features/studio/canvas-preview.ts +1 -2
  41. package/eventcatalog/src/features/studio/catalog.ts +16 -14
  42. package/eventcatalog/src/features/studio/components/CanvasHeader.tsx +263 -115
  43. package/eventcatalog/src/features/studio/components/CanvasStatusMenu.tsx +15 -14
  44. package/eventcatalog/src/features/studio/components/ConnectAgentDialog.tsx +272 -119
  45. package/eventcatalog/src/features/studio/components/ConnectionsChooser.tsx +1 -2
  46. package/eventcatalog/src/features/studio/components/DeleteCanvasDialog.tsx +28 -44
  47. package/eventcatalog/src/features/studio/components/Dialog.tsx +60 -0
  48. package/eventcatalog/src/features/studio/components/JoinForm.tsx +101 -26
  49. package/eventcatalog/src/features/studio/components/LeftPanel.tsx +119 -230
  50. package/eventcatalog/src/features/studio/components/NodeDetails.tsx +183 -0
  51. package/eventcatalog/src/features/studio/components/OpenInStudioDialog.tsx +79 -0
  52. package/eventcatalog/src/features/studio/components/Presence.tsx +52 -45
  53. package/eventcatalog/src/features/studio/components/ShareDialog.tsx +277 -141
  54. package/eventcatalog/src/features/studio/components/ShortcutsDialog.tsx +41 -0
  55. package/eventcatalog/src/features/studio/components/StudioDesigner.tsx +1286 -411
  56. package/eventcatalog/src/features/studio/document-name.ts +7 -0
  57. package/eventcatalog/src/features/studio/from-visualiser.ts +138 -0
  58. package/eventcatalog/src/features/studio/hooks/presence-sender.ts +19 -2
  59. package/eventcatalog/src/features/studio/hooks/use-canvas-webmcp.ts +9 -3
  60. package/eventcatalog/src/features/studio/hooks/use-comments.ts +2 -2
  61. package/eventcatalog/src/features/studio/hooks/use-follow-camera.ts +8 -3
  62. package/eventcatalog/src/features/studio/hooks/use-studio-flow.ts +223 -30
  63. package/eventcatalog/src/features/studio/identity.ts +23 -1
  64. package/eventcatalog/src/features/studio/limits.ts +2 -0
  65. package/eventcatalog/src/features/studio/pages/[id].astro +2 -1
  66. package/eventcatalog/src/features/studio/pages/index.astro +2 -2
  67. package/eventcatalog/src/features/studio/server/canvas-mcp.ts +8 -23
  68. package/eventcatalog/src/features/studio/server/runtime.ts +16 -9
  69. package/eventcatalog/src/utils/node-graphs/domain-levels-node-graph.ts +3 -3
  70. package/eventcatalog/src/utils/node-graphs/node-resource.ts +67 -0
  71. package/eventcatalog/src/utils/node-graphs/systems-node-graph.ts +1 -1
  72. package/package.json +3 -3
  73. package/eventcatalog/src/features/studio/canvas-doc.ts +0 -414
  74. package/eventcatalog/src/features/studio/clipboard.ts +0 -97
  75. package/eventcatalog/src/features/studio/components/CanvasControls.tsx +0 -154
  76. package/eventcatalog/src/features/studio/components/Comments.tsx +0 -770
  77. package/eventcatalog/src/features/studio/components/ContainerChooser.tsx +0 -145
  78. package/eventcatalog/src/features/studio/components/DragGhost.tsx +0 -115
  79. package/eventcatalog/src/features/studio/components/Picture.tsx +0 -21
  80. package/eventcatalog/src/features/studio/components/PropertiesPanel.tsx +0 -212
  81. package/eventcatalog/src/features/studio/components/canvas-nodes.tsx +0 -255
  82. package/eventcatalog/src/features/studio/components/icons.ts +0 -53
  83. package/eventcatalog/src/features/studio/components/status.ts +0 -53
  84. package/eventcatalog/src/features/studio/edges.ts +0 -56
  85. package/eventcatalog/src/features/studio/flow-state.ts +0 -92
  86. package/eventcatalog/src/features/studio/grouping.ts +0 -226
  87. package/eventcatalog/src/features/studio/layout.ts +0 -42
  88. package/eventcatalog/src/features/studio/levels.ts +0 -282
  89. package/eventcatalog/src/features/studio/node-types.ts +0 -205
  90. package/eventcatalog/src/features/studio/placement.ts +0 -197
  91. package/eventcatalog/src/features/studio/studio.css +0 -273
  92. package/dist/{chunk-KHACCTW2.js → chunk-ZEY2XUJN.js} +4 -4
@@ -267,7 +267,7 @@ export const buildDomainGraph = ({
267
267
  draggable: false,
268
268
  selectable: false,
269
269
  data: {
270
- domain: { name: d.data.name, version: d.data.version },
270
+ domain: { id: d.data.id, name: d.data.name, version: d.data.version },
271
271
  // The domain viewed may itself be a subdomain of another
272
272
  subdomain: !!parentId || parentDomainOf.has(d.data.id),
273
273
  isFocused: !parentId,
@@ -309,7 +309,7 @@ export const buildDomainGraph = ({
309
309
  position: { x: 0, y: 0 },
310
310
  ...(parentId ? { parentId } : {}),
311
311
  data: {
312
- domain: { name: other.data.name, version: other.data.version },
312
+ domain: { id: other.data.id, name: other.data.name, version: other.data.version },
313
313
  subdomain: !!parentId,
314
314
  isFocused: false,
315
315
  otherDomain: true,
@@ -375,7 +375,7 @@ export const buildDomainGraph = ({
375
375
  type: 'system-group',
376
376
  parentId: domainGroup,
377
377
  position: { x: 0, y: 0 },
378
- data: { system: { name: system.data.name, version: system.data.version } },
378
+ data: { system: { id: system.data.id, name: system.data.name, version: system.data.version } },
379
379
  });
380
380
  }
381
381
  addNodes(graph.nodes);
@@ -0,0 +1,67 @@
1
+ /**
2
+ * The catalog resource a diagram node shows, for the node types the node graphs build. Has no imports so it can
3
+ * be shared by the server and the browser (the visualiser's "Open in Studio", the MCP App views).
4
+ */
5
+
6
+ // The collection each node type shows a resource of
7
+ const NODE_COLLECTIONS: Record<string, string> = {
8
+ service: 'services',
9
+ services: 'services',
10
+ agent: 'agents',
11
+ agents: 'agents',
12
+ event: 'events',
13
+ events: 'events',
14
+ command: 'commands',
15
+ commands: 'commands',
16
+ query: 'queries',
17
+ queries: 'queries',
18
+ channel: 'channels',
19
+ channels: 'channels',
20
+ flow: 'flows',
21
+ flows: 'flows',
22
+ data: 'containers',
23
+ containers: 'containers',
24
+ 'data-products': 'data-products',
25
+ domain: 'domains',
26
+ domains: 'domains',
27
+ 'context-domain': 'domains',
28
+ 'domain-group': 'domains',
29
+ system: 'systems',
30
+ systems: 'systems',
31
+ 'system-group': 'systems',
32
+ };
33
+
34
+ // Where each node type keeps the resource it shows
35
+ const RESOURCE_DATA_KEYS = [
36
+ 'service',
37
+ 'agent',
38
+ 'message',
39
+ 'channel',
40
+ 'data',
41
+ 'dataProduct',
42
+ 'flow',
43
+ 'entity',
44
+ 'domain',
45
+ 'system',
46
+ 'custom',
47
+ ];
48
+
49
+ type DiagramNode = { type?: string; data?: Record<string, unknown> };
50
+ type ResourceDetails = { id?: unknown; version?: unknown; [key: string]: unknown };
51
+
52
+ /** The resource a node shows, as in its data: the fields shown, or the catalog entry's data (if it holds the entry) */
53
+ export const getNodeResourceDetails = (node: DiagramNode): ResourceDetails | undefined => {
54
+ const data = node.data ?? {};
55
+ const resource = RESOURCE_DATA_KEYS.map((key) => data[key]).find(Boolean) as
56
+ | (ResourceDetails & { data?: ResourceDetails })
57
+ | undefined;
58
+ return resource?.data ?? resource;
59
+ };
60
+
61
+ /** The catalog resource a node shows (its collection, id and version), if it shows one */
62
+ export const getNodeCatalogResource = (node: DiagramNode): { collection: string; id: string; version?: string } | undefined => {
63
+ const collection = NODE_COLLECTIONS[node.type ?? ''];
64
+ const details = getNodeResourceDetails(node);
65
+ if (!collection || typeof details?.id !== 'string') return undefined;
66
+ return { collection, id: details.id, ...(typeof details.version === 'string' && { version: details.version }) };
67
+ };
@@ -142,7 +142,7 @@ const wrapNodesInSystemGroup = (systemNodes: any[], system: any) => {
142
142
  draggable: false,
143
143
  selectable: false,
144
144
  data: {
145
- system: { name: system.data.name, version: system.data.version },
145
+ system: { id: system.data.id, name: system.data.name, version: system.data.version },
146
146
  // The system is the resource being viewed on its own diagram
147
147
  isFocused: true,
148
148
  },
package/package.json CHANGED
@@ -7,7 +7,7 @@
7
7
  },
8
8
  "license": "BUSL-1.1",
9
9
  "type": "module",
10
- "version": "5.0.0-beta.11",
10
+ "version": "5.0.0-beta.13",
11
11
  "publishConfig": {
12
12
  "access": "public"
13
13
  },
@@ -141,8 +141,8 @@
141
141
  "yjs": "^13.6.33",
142
142
  "zod": "^4.3.6",
143
143
  "@eventcatalog/linter": "1.2.0-beta.2",
144
- "@eventcatalog/sdk": "2.30.0-beta.1",
145
- "@eventcatalog/visualiser": "^5.0.0-beta.4"
144
+ "@eventcatalog/visualiser": "^5.0.0-beta.6",
145
+ "@eventcatalog/sdk": "2.30.0-beta.1"
146
146
  },
147
147
  "devDependencies": {
148
148
  "@astrojs/check": "^0.9.10",
@@ -1,414 +0,0 @@
1
- import * as Y from 'yjs';
2
- import type { Connection, Edge, Node, XYPosition } from '@xyflow/react';
3
- import { createEdge, getEdgeLabel } from './edges';
4
- import { fitGroup, getAbsolutePosition, withDescendants, withParent } from './grouping';
5
- import { getNodeDefinition, getNodeSize, isGroupType } from './node-types';
6
-
7
- /**
8
- * Everything a canvas holds, as operations on its Yjs document. The same functions run in the
9
- * browser (people, WebMCP) and on the server (MCP tools), so humans and agents edit a canvas the same way.
10
- */
11
-
12
- export type Author = {
13
- name: string;
14
- color: string;
15
- agent?: boolean;
16
- /** A picture from their sign-in provider (SSO), shown over their initials */
17
- picture?: string;
18
- };
19
- export type Message = { id: string; text: string; author: Author; createdAt: number };
20
- /** Where a comment is pinned: a canvas position, or a node (and the offset from its top left) so it moves with it */
21
- export type CommentAnchor = { position: XYPosition; nodeId?: string; offset?: XYPosition };
22
- export type Thread = CommentAnchor & { id: string; author: Author; createdAt: number; resolved: boolean; messages: Message[] };
23
- /** Where a canvas's design is: being worked on, up for review, agreed, or decided against */
24
- export const CANVAS_STATUSES = ['draft', 'proposed', 'accepted', 'rejected'] as const;
25
- export type CanvasStatus = (typeof CANVAS_STATUSES)[number];
26
- /** A canvas's status being changed: by whom, when, and why (e.g. "Agreed in the architecture review") */
27
- export type StatusChange = { status: CanvasStatus; by: Author; at: number; note?: string };
28
- export type CanvasMeta = {
29
- title?: string;
30
- /** Draft until someone changes it */
31
- status?: CanvasStatus;
32
- /** Every status change, oldest first (the last few hundred) */
33
- statusHistory?: StatusChange[];
34
- createdAt?: number;
35
- createdBy?: string;
36
- /** When the canvas was last laid out, so everyone's canvas glides the nodes into place */
37
- layoutAt?: number;
38
- };
39
-
40
- // Canvases' documents on the collaboration server are named `design:<canvasId>` (scripts and peers join by it)
41
- const DOCUMENT_PREFIX = 'design:';
42
- export const canvasDocumentName = (canvasId: string) => `${DOCUMENT_PREFIX}${canvasId}`;
43
- export const canvasIdFromDocumentName = (name: string) =>
44
- name.startsWith(DOCUMENT_PREFIX) ? name.slice(DOCUMENT_PREFIX.length) : undefined;
45
- /** Why the collaboration server refuses a canvas that was deleted (told to anyone who still has it open) */
46
- export const CANVAS_DELETED_REASON = 'canvas-deleted';
47
-
48
- export const shortId = () => crypto.randomUUID().slice(0, 8);
49
-
50
- export const getCanvasMaps = (doc: Y.Doc) => ({
51
- nodes: doc.getMap<Node>('nodes'),
52
- edges: doc.getMap<Edge>('edges'),
53
- threads: doc.getMap<Y.Map<unknown>>('threads'),
54
- meta: doc.getMap<unknown>('meta'),
55
- });
56
-
57
- // Only the design is shared. Selection, measured sizes and drag state stay with each user.
58
- export const toSharedNode = ({ id, type, position, data, width, height, parentId, zIndex }: Node): Node => ({
59
- id,
60
- type,
61
- position,
62
- data,
63
- ...(width ? { width } : {}),
64
- ...(height ? { height } : {}),
65
- // Inside a container (its position is then relative to the container)
66
- ...(parentId ? { parentId } : {}),
67
- ...(zIndex !== undefined ? { zIndex } : {}),
68
- });
69
-
70
- /** A new node of a type, centred on a canvas position */
71
- export const buildNode = (type: string, data: Record<string, unknown>, center: XYPosition): Node => {
72
- const definition = getNodeDefinition(type);
73
- const size = getNodeSize(type);
74
- return toSharedNode({
75
- id: `${type}-${shortId()}`,
76
- type,
77
- position: { x: center.x - size.width / 2, y: center.y - size.height / 2 },
78
- data,
79
- ...(definition?.defaultSize ? (definition.autoHeight ? { width: size.width } : size) : {}),
80
- // Containers sit behind what's in them and the connections between them
81
- ...(isGroupType(type) && { zIndex: -1 }),
82
- });
83
- };
84
-
85
- // ---- Meta ----
86
-
87
- /** Canvas titles are at most this long */
88
- export const MAX_TITLE_LENGTH = 200;
89
-
90
- export const readMeta = (doc: Y.Doc) => getCanvasMaps(doc).meta.toJSON() as CanvasMeta;
91
-
92
- export const setMeta = (doc: Y.Doc, meta: Partial<CanvasMeta>) =>
93
- doc.transact(() => {
94
- const map = getCanvasMaps(doc).meta;
95
- Object.entries(meta).forEach(([key, value]) => (value === undefined ? map.delete(key) : map.set(key, value)));
96
- });
97
-
98
- // ---- Status ----
99
-
100
- const MAX_STATUS_HISTORY = 200;
101
-
102
- export const getCanvasStatus = (meta: CanvasMeta): CanvasStatus => meta.status ?? 'draft';
103
-
104
- /** Changes a canvas's status (and records who did it). Returns false if it already had that status. */
105
- export const setCanvasStatus = (doc: Y.Doc, status: CanvasStatus, by: Author, note?: string) => {
106
- let changed = false;
107
- doc.transact(() => {
108
- const meta = readMeta(doc);
109
- if (getCanvasStatus(meta) === status) return;
110
- const change: StatusChange = { status, by, at: Date.now(), ...(note?.trim() && { note: note.trim() }) };
111
- const map = getCanvasMaps(doc).meta;
112
- map.set('status', status);
113
- map.set('statusHistory', [...(meta.statusHistory ?? []), change].slice(-MAX_STATUS_HISTORY));
114
- changed = true;
115
- });
116
- return changed;
117
- };
118
-
119
- /** Accepted and rejected canvases are decided: changing what's on them makes them a draft again */
120
- export const isDecided = (status: CanvasStatus) => status === 'accepted' || status === 'rejected';
121
-
122
- /** A decided canvas whose nodes or edges were changed goes back to draft, recorded as changed by `by` */
123
- export const reopenIfDecided = (doc: Y.Doc, by: Author) => {
124
- const status = getCanvasStatus(readMeta(doc));
125
- if (isDecided(status)) setCanvasStatus(doc, 'draft', by, `Changed after it was ${status}`);
126
- };
127
-
128
- /**
129
- * Runs `fn` (which may change the canvas), and if it changed nodes or edges, sends a decided canvas back to draft.
130
- * Comments don't count: reviewing an accepted design keeps it accepted.
131
- */
132
- export const reopeningIfEdited = <T>(doc: Y.Doc, by: Author, fn: () => T): T => {
133
- let result!: T;
134
- // In one transaction (the one it's already in, if it is): what it changed is known before it ends
135
- doc.transact((transaction) => {
136
- result = fn();
137
- if (changesNodesOrEdges(transaction)) reopenIfDecided(doc, by);
138
- });
139
- return result;
140
- };
141
-
142
- /** Whether a transaction changed the canvas's nodes or edges (not just its comments or meta) */
143
- export const changesNodesOrEdges = (transaction: Y.Transaction) => {
144
- const { nodes, edges } = getCanvasMaps(transaction.doc);
145
- return [...transaction.changed.keys()].some((type) => type === (nodes as unknown) || type === (edges as unknown));
146
- };
147
-
148
- // ---- Nodes and edges ----
149
-
150
- export const addNodes = (doc: Y.Doc, nodes: Node[], edges: Edge[] = []) =>
151
- doc.transact(() => {
152
- const maps = getCanvasMaps(doc);
153
- nodes.forEach((node) => maps.nodes.set(node.id, toSharedNode(node)));
154
- edges.forEach((edge) => maps.edges.set(edge.id, edge));
155
- });
156
-
157
- export const moveNode = (doc: Y.Doc, id: string, position: XYPosition) => {
158
- const { nodes } = getCanvasMaps(doc);
159
- const node = nodes.get(id);
160
- if (node) nodes.set(id, { ...node, position });
161
- return !!node;
162
- };
163
-
164
- /** Resize a node: both sides, or just its width or height (e.g. text, whose height follows what's in it) */
165
- export const resizeNode = (
166
- doc: Y.Doc,
167
- id: string,
168
- size: { width: number; height: number },
169
- attributes: true | 'width' | 'height' = true
170
- ) => {
171
- const { nodes } = getCanvasMaps(doc);
172
- const node = nodes.get(id);
173
- if (node)
174
- nodes.set(id, {
175
- ...node,
176
- ...(attributes !== 'height' && { width: size.width }),
177
- ...(attributes !== 'width' && { height: size.height }),
178
- });
179
- return !!node;
180
- };
181
-
182
- /** Grow a container to fit what's in it (moving them clear of its header if needed), never shrinking it */
183
- /**
184
- * The sizes nodes are rendered at (in the browser), by id. The document doesn't have them, so without them
185
- * containers are fitted to the size each type renders at at most.
186
- */
187
- export type RenderedSizes = ReadonlyMap<string, { width: number; height: number }>;
188
-
189
- const withRenderedSizes = (nodes: Node[], sizes?: RenderedSizes) =>
190
- sizes ? nodes.map((node) => (sizes.has(node.id) ? { ...node, measured: sizes.get(node.id) } : node)) : nodes;
191
-
192
- export const fitGroupToChildren = (doc: Y.Doc, groupId: string, sizes?: RenderedSizes) =>
193
- doc.transact(() => {
194
- const { nodes } = getCanvasMaps(doc);
195
- const fit = fitGroup(groupId, withRenderedSizes(Array.from(nodes.values()), sizes));
196
- const group = nodes.get(groupId);
197
- if (!fit || !group) return;
198
- if (fit.shift.x || fit.shift.y) {
199
- // Children move right/down and the container left/up by the same amount: nothing moves on the canvas
200
- nodes.forEach((child, id) => {
201
- if (child.parentId === groupId)
202
- nodes.set(id, { ...child, position: { x: child.position.x + fit.shift.x, y: child.position.y + fit.shift.y } });
203
- });
204
- }
205
- nodes.set(groupId, {
206
- ...group,
207
- position: { x: group.position.x - fit.shift.x, y: group.position.y - fit.shift.y },
208
- width: fit.width,
209
- height: fit.height,
210
- });
211
- });
212
-
213
- /** The containers a node is in grow to fit it (innermost first), e.g. after it's added or resized */
214
- export const fitContainersAround = (doc: Y.Doc, nodeId: string, sizes?: RenderedSizes) =>
215
- doc.transact(() => {
216
- const { nodes } = getCanvasMaps(doc);
217
- for (let id = nodes.get(nodeId)?.parentId; id; id = nodes.get(id)?.parentId) fitGroupToChildren(doc, id, sizes);
218
- });
219
-
220
- /** Put a node in a container (or take it out, without a parentId), keeping it where it is on the canvas */
221
- export const setNodeParent = (doc: Y.Doc, nodeId: string, parentId: string | undefined, sizes?: RenderedSizes) =>
222
- doc.transact(() => {
223
- const { nodes } = getCanvasMaps(doc);
224
- const node = nodes.get(nodeId);
225
- if (!node || node.parentId === parentId) return false;
226
- nodes.set(nodeId, toSharedNode(withParent(node, parentId, Array.from(nodes.values()))));
227
- if (parentId) fitGroupToChildren(doc, parentId, sizes);
228
- return true;
229
- });
230
-
231
- export const updateNodeData = (doc: Y.Doc, id: string, updater: (data: Record<string, unknown>) => Record<string, unknown>) => {
232
- const { nodes } = getCanvasMaps(doc);
233
- const node = nodes.get(id);
234
- if (node) nodes.set(id, { ...node, data: updater(node.data) });
235
- return !!node;
236
- };
237
-
238
- /** Connect two nodes with an EventCatalog-labelled edge. Returns the edge, or why it couldn't be made. */
239
- export const connectNodes = (
240
- doc: Y.Doc,
241
- connection: Pick<Connection, 'source' | 'target'> & Partial<Connection>,
242
- label?: string
243
- ): Edge | { error: string } => {
244
- const { nodes, edges } = getCanvasMaps(doc);
245
- const source = nodes.get(connection.source);
246
- const target = nodes.get(connection.target);
247
- if (!source) return { error: `Node "${connection.source}" is not on the canvas` };
248
- if (!target) return { error: `Node "${connection.target}" is not on the canvas` };
249
- if (source.id === target.id) return { error: 'A node cannot be connected to itself' };
250
- const existing = Array.from(edges.values()).find((edge) => edge.source === source.id && edge.target === target.id);
251
- if (existing) return existing;
252
- const edge = createEdge(
253
- {
254
- source: source.id,
255
- target: target.id,
256
- sourceHandle: connection.sourceHandle ?? null,
257
- targetHandle: connection.targetHandle ?? null,
258
- },
259
- source.type,
260
- target.type
261
- );
262
- const created = label === undefined ? edge : { ...edge, label };
263
- edges.set(created.id, created);
264
- return created;
265
- };
266
-
267
- /**
268
- * Moves a connection to new ends (dragged to another node), keeping it the same connection. Its label follows
269
- * the new ends if it was EventCatalog's wording, and is kept if someone wrote it.
270
- */
271
- export const reconnectEdge = (doc: Y.Doc, edgeId: string, connection: Connection): Edge | { error: string } =>
272
- doc.transact(() => {
273
- const { nodes, edges } = getCanvasMaps(doc);
274
- const edge = edges.get(edgeId);
275
- const source = nodes.get(connection.source);
276
- const target = nodes.get(connection.target);
277
- if (!edge) return { error: `Connection "${edgeId}" is not on the canvas` };
278
- if (!source || !target) return { error: 'Connections join two nodes on the canvas' };
279
- if (source.id === target.id) return { error: 'A node cannot be connected to itself' };
280
- const duplicate = Array.from(edges.values()).find(
281
- (other) => other.id !== edgeId && other.source === source.id && other.target === target.id
282
- );
283
- if (duplicate) return { error: 'Those nodes are already connected' };
284
-
285
- const defaultLabel = getEdgeLabel(nodes.get(edge.source)?.type, nodes.get(edge.target)?.type);
286
- const moved = createEdge(connection, source.type, target.type);
287
- const reconnected: Edge = {
288
- ...moved,
289
- id: edge.id,
290
- ...(edge.label !== undefined && edge.label !== defaultLabel && { label: edge.label }),
291
- };
292
- edges.set(edgeId, reconnected);
293
- return reconnected;
294
- });
295
-
296
- export const deleteEdges = (doc: Y.Doc, ids: string[]) =>
297
- doc.transact(() => ids.forEach((id) => getCanvasMaps(doc).edges.delete(id)));
298
-
299
- /** Delete nodes (containers with what's in them), the edges to them, and leave their comments where they were */
300
- export const deleteNodes = (doc: Y.Doc, ids: string[]) =>
301
- doc.transact(() => {
302
- const { nodes, edges, threads } = getCanvasMaps(doc);
303
- const all = Array.from(nodes.values());
304
- const lookup = new Map(all.map((node) => [node.id, node]));
305
- const removed = withDescendants(ids, all);
306
- threads.forEach((thread) => {
307
- const nodeId = thread.get('nodeId') as string | undefined;
308
- const node = nodeId && removed.has(nodeId) ? nodes.get(nodeId) : undefined;
309
- if (!node) return;
310
- const offset = (thread.get('offset') as XYPosition) ?? { x: 0, y: 0 };
311
- const position = getAbsolutePosition(node, lookup);
312
- thread.set('position', { x: position.x + offset.x, y: position.y + offset.y });
313
- thread.delete('nodeId');
314
- thread.delete('offset');
315
- });
316
- removed.forEach((id) => nodes.delete(id));
317
- edges.forEach((edge, id) => {
318
- if (removed.has(edge.source) || removed.has(edge.target)) edges.delete(id);
319
- });
320
- });
321
-
322
- // ---- Comments ----
323
- // Each thread is a nested Y.Map and its messages a Y.Array, so concurrent replies (and resolving while
324
- // someone replies) all merge.
325
-
326
- export const readThreads = (doc: Y.Doc) =>
327
- Array.from(getCanvasMaps(doc).threads.values())
328
- .map((thread) => thread.toJSON() as Thread)
329
- .sort((a, b) => a.createdAt - b.createdAt);
330
-
331
- const anchorEntries = (anchor: CommentAnchor): [string, unknown][] => [
332
- ['position', anchor.position],
333
- ...(anchor.nodeId && anchor.offset
334
- ? ([
335
- ['nodeId', anchor.nodeId],
336
- ['offset', anchor.offset],
337
- ] as [string, unknown][])
338
- : []),
339
- ];
340
-
341
- export const createThread = (doc: Y.Doc, anchor: CommentAnchor, text: string, author: Author) => {
342
- const id = shortId();
343
- const now = Date.now();
344
- const messages = new Y.Array<Message>();
345
- messages.push([{ id: shortId(), text, author, createdAt: now }]);
346
- const thread = new Y.Map<unknown>([
347
- ['id', id],
348
- ...anchorEntries(anchor),
349
- ['author', author],
350
- ['createdAt', now],
351
- ['resolved', false],
352
- ['messages', messages],
353
- ]);
354
- getCanvasMaps(doc).threads.set(id, thread);
355
- return id;
356
- };
357
-
358
- const getThread = (doc: Y.Doc, id: string) => getCanvasMaps(doc).threads.get(id);
359
-
360
- export const replyToThread = (doc: Y.Doc, threadId: string, text: string, author: Author) => {
361
- const messages = getThread(doc, threadId)?.get('messages') as Y.Array<Message> | undefined;
362
- messages?.push([{ id: shortId(), text, author, createdAt: Date.now() }]);
363
- return !!messages;
364
- };
365
-
366
- export const setThreadResolved = (doc: Y.Doc, threadId: string, resolved: boolean) => {
367
- const thread = getThread(doc, threadId);
368
- thread?.set('resolved', resolved);
369
- return !!thread;
370
- };
371
-
372
- /** Move a thread's pin, attaching it to a node (anchor.nodeId) or to the canvas */
373
- export const moveThread = (doc: Y.Doc, threadId: string, anchor: CommentAnchor) => {
374
- const thread = getThread(doc, threadId);
375
- if (!thread) return false;
376
- doc.transact(() => {
377
- thread.set('position', anchor.position);
378
- if (anchor.nodeId && anchor.offset) {
379
- thread.set('nodeId', anchor.nodeId);
380
- thread.set('offset', anchor.offset);
381
- } else {
382
- thread.delete('nodeId');
383
- thread.delete('offset');
384
- }
385
- });
386
- return true;
387
- };
388
-
389
- export const deleteThread = (doc: Y.Doc, threadId: string) => {
390
- const { threads } = getCanvasMaps(doc);
391
- const exists = threads.has(threadId);
392
- threads.delete(threadId);
393
- return exists;
394
- };
395
-
396
- /** Deletes comment threads in one change (one undo brings them all back) */
397
- export const deleteThreads = (doc: Y.Doc, threadIds: string[]) =>
398
- doc.transact(() => threadIds.forEach((id) => deleteThread(doc, id)));
399
-
400
- /** Moves comment threads in one change, e.g. ones that moved with what was dragged */
401
- export const moveThreads = (doc: Y.Doc, moves: { threadId: string; anchor: CommentAnchor }[]) =>
402
- doc.transact(() => moves.forEach(({ threadId, anchor }) => moveThread(doc, threadId, anchor)));
403
-
404
- // ---- Reading a whole canvas ----
405
-
406
- export const readCanvas = (doc: Y.Doc) => {
407
- const { nodes, edges } = getCanvasMaps(doc);
408
- return {
409
- meta: readMeta(doc),
410
- nodes: Array.from(nodes.values()),
411
- edges: Array.from(edges.values()),
412
- threads: readThreads(doc),
413
- };
414
- };
@@ -1,97 +0,0 @@
1
- import type { Edge, Node, XYPosition } from '@xyflow/react';
2
- import { shortId, toSharedNode } from './canvas-doc';
3
- import { findGroupAtPoint, getAbsolutePosition, sizeOf, sortByHierarchy, withDescendants } from './grouping';
4
- import { canGoInContainer } from './node-types';
5
-
6
- /**
7
- * Copying and pasting nodes, within a canvas or between canvases (through the system clipboard). What's copied is
8
- * the selected nodes, everything inside selected containers, and the connections between them. Comments aren't.
9
- */
10
-
11
- /** The clipboard type Studio reads first (other apps get a plain text list of what was copied) */
12
- export const CLIPBOARD_TYPE = 'application/x-eventcatalog-studio+json';
13
- const MARKER = 'eventcatalog-studio/nodes@1';
14
-
15
- export type ClipboardContent = { marker: typeof MARKER; nodes: Node[]; edges: Edge[] };
16
-
17
- /** What's copied when these nodes are selected, or null if there's nothing to copy */
18
- export const copyNodes = (nodes: Node[], edges: Edge[], selectedIds: string[]): ClipboardContent | null => {
19
- if (selectedIds.length === 0) return null;
20
- const lookup = new Map(nodes.map((node) => [node.id, node]));
21
- const ids = withDescendants(selectedIds, nodes);
22
- const copied = sortByHierarchy(nodes.filter((node) => ids.has(node.id))).map((node) => {
23
- const shared = toSharedNode(node);
24
- // Copied without its container: where it is on the canvas
25
- if (shared.parentId && !ids.has(shared.parentId)) {
26
- const { parentId: _, ...rest } = shared;
27
- return { ...rest, position: getAbsolutePosition(node, lookup) };
28
- }
29
- return shared;
30
- });
31
- return {
32
- marker: MARKER,
33
- nodes: copied,
34
- edges: edges.filter((edge) => ids.has(edge.source) && ids.has(edge.target)).map(({ selected: _, ...edge }) => edge),
35
- };
36
- };
37
-
38
- /** Copied content read back from the clipboard, if it's Studio's */
39
- export const readClipboard = (text: string | undefined): ClipboardContent | null => {
40
- if (!text) return null;
41
- try {
42
- const content = JSON.parse(text) as Partial<ClipboardContent>;
43
- return content.marker === MARKER && Array.isArray(content.nodes) && Array.isArray(content.edges)
44
- ? (content as ClipboardContent)
45
- : null;
46
- } catch {
47
- return null;
48
- }
49
- };
50
-
51
- /**
52
- * Copies of the content to add to the canvas, with new ids: centred on a point (e.g. the pointer), or moved by an
53
- * offset from where they were copied. Copies that land in a container on the canvas go inside it.
54
- */
55
- export const pasteNodes = (
56
- content: ClipboardContent,
57
- existing: Node[],
58
- place: { at: XYPosition } | { offset: XYPosition }
59
- ): { nodes: Node[]; edges: Edge[] } => {
60
- const ids = new Map(content.nodes.map((node) => [node.id, `${node.type ?? 'node'}-${shortId()}`]));
61
- const roots = content.nodes.filter((node) => !node.parentId);
62
- const offset = 'offset' in place ? place.offset : offsetToCentre(roots, place.at);
63
-
64
- const lookup = new Map(existing.map((node) => [node.id, node]));
65
- const nodes = content.nodes.map((node): Node => {
66
- const id = ids.get(node.id)!;
67
- if (node.parentId) return { ...node, id, parentId: ids.get(node.parentId) };
68
- const position = { x: node.position.x + offset.x, y: node.position.y + offset.y };
69
- const { width, height } = sizeOf(node);
70
- const container = canGoInContainer(node.type)
71
- ? findGroupAtPoint({ x: position.x + width / 2, y: position.y + height / 2 }, existing)
72
- : undefined;
73
- if (!container) return { ...node, id, position };
74
- const origin = getAbsolutePosition(container, lookup);
75
- return { ...node, id, parentId: container.id, position: { x: position.x - origin.x, y: position.y - origin.y } };
76
- });
77
-
78
- return {
79
- nodes,
80
- edges: content.edges.map((edge) => ({
81
- ...edge,
82
- id: `edge-${shortId()}`,
83
- source: ids.get(edge.source)!,
84
- target: ids.get(edge.target)!,
85
- })),
86
- };
87
- };
88
-
89
- /** How far to move nodes so the middle of them is at a point */
90
- const offsetToCentre = (roots: Node[], at: XYPosition): XYPosition => {
91
- if (roots.length === 0) return { x: 0, y: 0 };
92
- const left = Math.min(...roots.map((node) => node.position.x));
93
- const top = Math.min(...roots.map((node) => node.position.y));
94
- const right = Math.max(...roots.map((node) => node.position.x + sizeOf(node).width));
95
- const bottom = Math.max(...roots.map((node) => node.position.y + sizeOf(node).height));
96
- return { x: at.x - (left + right) / 2, y: at.y - (top + bottom) / 2 };
97
- };