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

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 (90) hide show
  1. package/LICENSE +13 -12
  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/catalog-runtime.cjs +1 -1
  7. package/dist/{chunk-TSY5CC27.js → chunk-4KE3EUDJ.js} +1 -1
  8. package/dist/{chunk-FY6MSP6J.js → chunk-I3XDOICA.js} +1 -1
  9. package/dist/{chunk-QB7X34OD.js → chunk-OFK7U7BX.js} +1 -1
  10. package/dist/{chunk-CX2VB7AI.js → chunk-PI5YNZZZ.js} +1 -1
  11. package/dist/{chunk-BWC4YKW3.js → chunk-WY6M3RPT.js} +1 -1
  12. package/dist/constants.cjs +1 -1
  13. package/dist/constants.js +1 -1
  14. package/dist/eventcatalog.cjs +11 -6
  15. package/dist/eventcatalog.js +14 -9
  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/astro.config.mjs +13 -1
  25. package/eventcatalog/integrations/skip-startup-reload.mjs +31 -0
  26. package/eventcatalog/integrations/stable-dependency-order.mjs +29 -0
  27. package/eventcatalog/src/components/CatalogGraph/CatalogForceGraph.tsx +39 -0
  28. package/eventcatalog/src/components/MDX/NodeGraph/AstroNodeGraph.tsx +11 -3
  29. package/eventcatalog/src/components/Search/SearchDataLoader.astro +16 -28
  30. package/eventcatalog/src/components/SideNav/NestedSideBar/index.tsx +37 -76
  31. package/eventcatalog/src/components/SideNav/NestedSideBar/storage.ts +2 -0
  32. package/eventcatalog/src/components/SideNav/NestedSideBar/utils.ts +122 -1
  33. package/eventcatalog/src/components/SideNav/SideNav.astro +9 -1
  34. package/eventcatalog/src/features/mcp/apps/canvas/shared.ts +3 -3
  35. package/eventcatalog/src/features/mcp/apps/canvas/styles.css +3 -1
  36. package/eventcatalog/src/features/mcp/apps/canvas/view.tsx +7 -5
  37. package/eventcatalog/src/features/mcp/apps/generated/canvas.html +133 -71
  38. package/eventcatalog/src/features/mcp/apps/generated/viewer.html +66 -66
  39. package/eventcatalog/src/features/studio/agent-choreography.ts +5 -4
  40. package/eventcatalog/src/features/studio/agents.ts +28 -0
  41. package/eventcatalog/src/features/studio/api/canvases-api.ts +1 -1
  42. package/eventcatalog/src/features/studio/canvas-actions.ts +22 -14
  43. package/eventcatalog/src/features/studio/canvas-preview.ts +1 -2
  44. package/eventcatalog/src/features/studio/catalog.ts +1 -4
  45. package/eventcatalog/src/features/studio/components/CanvasHeader.tsx +246 -115
  46. package/eventcatalog/src/features/studio/components/CanvasStatusMenu.tsx +7 -3
  47. package/eventcatalog/src/features/studio/components/ConnectAgentDialog.tsx +272 -119
  48. package/eventcatalog/src/features/studio/components/ConnectionsChooser.tsx +1 -2
  49. package/eventcatalog/src/features/studio/components/DeleteCanvasDialog.tsx +1 -1
  50. package/eventcatalog/src/features/studio/components/JoinForm.tsx +101 -26
  51. package/eventcatalog/src/features/studio/components/LeftPanel.tsx +102 -241
  52. package/eventcatalog/src/features/studio/components/NodeDetails.tsx +183 -0
  53. package/eventcatalog/src/features/studio/components/OpenInStudioDialog.tsx +1 -1
  54. package/eventcatalog/src/features/studio/components/Presence.tsx +6 -8
  55. package/eventcatalog/src/features/studio/components/ShareDialog.tsx +277 -141
  56. package/eventcatalog/src/features/studio/components/ShortcutsDialog.tsx +41 -0
  57. package/eventcatalog/src/features/studio/components/StudioDesigner.tsx +1005 -354
  58. package/eventcatalog/src/features/studio/document-name.ts +7 -0
  59. package/eventcatalog/src/features/studio/from-visualiser.ts +1 -1
  60. package/eventcatalog/src/features/studio/hooks/presence-sender.ts +19 -2
  61. package/eventcatalog/src/features/studio/hooks/use-canvas-webmcp.ts +9 -3
  62. package/eventcatalog/src/features/studio/hooks/use-comments.ts +2 -2
  63. package/eventcatalog/src/features/studio/hooks/use-studio-flow.ts +162 -23
  64. package/eventcatalog/src/features/studio/identity.ts +23 -1
  65. package/eventcatalog/src/features/studio/pages/[id].astro +2 -1
  66. package/eventcatalog/src/features/studio/pages/index.astro +1 -2
  67. package/eventcatalog/src/features/studio/server/canvas-mcp.ts +7 -22
  68. package/eventcatalog/src/features/studio/server/runtime.ts +4 -5
  69. package/package.json +3 -3
  70. package/eventcatalog/src/features/studio/canvas-doc.ts +0 -569
  71. package/eventcatalog/src/features/studio/clipboard.ts +0 -97
  72. package/eventcatalog/src/features/studio/components/CanvasControls.tsx +0 -119
  73. package/eventcatalog/src/features/studio/components/Comments.tsx +0 -777
  74. package/eventcatalog/src/features/studio/components/ContainerChooser.tsx +0 -145
  75. package/eventcatalog/src/features/studio/components/DragGhost.tsx +0 -115
  76. package/eventcatalog/src/features/studio/components/Picture.tsx +0 -21
  77. package/eventcatalog/src/features/studio/components/PropertiesPanel.tsx +0 -212
  78. package/eventcatalog/src/features/studio/components/canvas-nodes.tsx +0 -271
  79. package/eventcatalog/src/features/studio/components/icons.ts +0 -53
  80. package/eventcatalog/src/features/studio/components/status.ts +0 -53
  81. package/eventcatalog/src/features/studio/edges.ts +0 -56
  82. package/eventcatalog/src/features/studio/flow-state.ts +0 -121
  83. package/eventcatalog/src/features/studio/grouping.ts +0 -230
  84. package/eventcatalog/src/features/studio/hooks/use-counter-scale.ts +0 -20
  85. package/eventcatalog/src/features/studio/layout.ts +0 -56
  86. package/eventcatalog/src/features/studio/level-layout.ts +0 -145
  87. package/eventcatalog/src/features/studio/levels.ts +0 -337
  88. package/eventcatalog/src/features/studio/node-types.ts +0 -245
  89. package/eventcatalog/src/features/studio/placement.ts +0 -197
  90. package/eventcatalog/src/features/studio/studio.css +0 -273
@@ -1,569 +0,0 @@
1
- import * as Y from 'yjs';
2
- import type { Connection, Edge, Node, XYPosition } from '@xyflow/react';
3
- import type { EdgeRoute } from '@eventcatalog/visualiser/layout';
4
- import { createEdge, getEdgeLabel } from './edges';
5
- import { fitGroup, getAbsolutePosition, withDescendants, withParent } from './grouping';
6
- import { getNodeDefinition, getNodeName, getNodeSize, isGroupType } from './node-types';
7
-
8
- /**
9
- * Everything a canvas holds, as operations on its Yjs document. The same functions run in the
10
- * browser (people, WebMCP) and on the server (MCP tools), so humans and agents edit a canvas the same way.
11
- */
12
-
13
- export type Author = {
14
- name: string;
15
- color: string;
16
- agent?: boolean;
17
- /** A picture from their sign-in provider (SSO), shown over their initials */
18
- picture?: string;
19
- };
20
- export type Message = { id: string; text: string; author: Author; createdAt: number };
21
- /** Where a comment is pinned: a canvas position, or a node (and the offset from its top left) so it moves with it */
22
- export type CommentAnchor = { position: XYPosition; nodeId?: string; offset?: XYPosition };
23
- export type Thread = CommentAnchor & { id: string; author: Author; createdAt: number; resolved: boolean; messages: Message[] };
24
- /** Where a canvas's design is: being worked on, up for review, agreed, or decided against */
25
- export const CANVAS_STATUSES = ['draft', 'proposed', 'accepted', 'rejected'] as const;
26
- export type CanvasStatus = (typeof CANVAS_STATUSES)[number];
27
- /** A canvas's status being changed: by whom, when, and why (e.g. "Agreed in the architecture review") */
28
- export type StatusChange = { status: CanvasStatus; by: Author; at: number; note?: string };
29
- export type CanvasMeta = {
30
- title?: string;
31
- /** Draft until someone changes it */
32
- status?: CanvasStatus;
33
- /** Every status change, oldest first (the last few hundred) */
34
- statusHistory?: StatusChange[];
35
- createdAt?: number;
36
- createdBy?: string;
37
- /** When the canvas was last laid out, so everyone's canvas glides the nodes into place */
38
- layoutAt?: number;
39
- /** L1 and L2 as the diagram the canvas was opened from showed them (see DiagramLevels) */
40
- diagramLevels?: DiagramLevels;
41
- };
42
-
43
- /** A level of detail as a diagram showed it: its nodes (laid out) and edges, by canvas node ids where they're the same */
44
- export type DiagramLevel = { nodes: Node[]; edges: Edge[] };
45
-
46
- /**
47
- * L1 and L2 of the diagram a canvas was opened from (the visualiser's "Open in Studio"), shown as they were while the
48
- * canvas's structure is what was opened (`structure`, from getStructureKey): moving nodes keeps them, changing what's
49
- * on the canvas or how it's connected doesn't (the levels are then worked out from the canvas)
50
- */
51
- export type DiagramLevels = {
52
- structure: string;
53
- 1?: DiagramLevel;
54
- 2?: DiagramLevel;
55
- /** Levels the diagram doesn't have, and why (as the diagram says) */
56
- unavailable?: { 1?: string; 2?: string };
57
- };
58
-
59
- // Canvases' documents on the collaboration server are named `design:<canvasId>` (scripts and peers join by it)
60
- const DOCUMENT_PREFIX = 'design:';
61
- export const canvasDocumentName = (canvasId: string) => `${DOCUMENT_PREFIX}${canvasId}`;
62
- export const canvasIdFromDocumentName = (name: string) =>
63
- name.startsWith(DOCUMENT_PREFIX) ? name.slice(DOCUMENT_PREFIX.length) : undefined;
64
- /** Why the collaboration server refuses a canvas that was deleted (told to anyone who still has it open) */
65
- export const CANVAS_DELETED_REASON = 'canvas-deleted';
66
-
67
- export const shortId = () => crypto.randomUUID().slice(0, 8);
68
-
69
- export const getCanvasMaps = (doc: Y.Doc) => ({
70
- nodes: doc.getMap<Node>('nodes'),
71
- edges: doc.getMap<Edge>('edges'),
72
- threads: doc.getMap<Y.Map<unknown>>('threads'),
73
- meta: doc.getMap<unknown>('meta'),
74
- /** Where things are on L1 and L2 (see LevelPlace), one entry per node and level (`1:<node id>`) */
75
- levelLayouts: doc.getMap<LevelPlace>('levelLayouts'),
76
- });
77
-
78
- /**
79
- * Where a node is on L1 or L2, as people arranged it there (or as the diagram it was opened from had it): its
80
- * top-left corner, relative to the container it's shown in on that level, and a container's size
81
- */
82
- export type LevelPlace = { x: number; y: number; width?: number; height?: number };
83
-
84
- /** The places on a level, by node id */
85
- export const readLevelLayout = (doc: Y.Doc, level: 1 | 2) => {
86
- const prefix = `${level}:`;
87
- const places = new Map<string, LevelPlace>();
88
- getCanvasMaps(doc).levelLayouts.forEach((place, key) => {
89
- if (key.startsWith(prefix)) places.set(key.slice(prefix.length), place);
90
- });
91
- return places;
92
- };
93
-
94
- const samePlace = (a: LevelPlace | undefined, b: LevelPlace) =>
95
- !!a && a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height;
96
-
97
- /**
98
- * Puts nodes where they go on a level (in one transaction). Places that haven't changed aren't written: everyone
99
- * looking at the level works out the same places for what's new on it, and each write re-arranges the level for
100
- * everyone else.
101
- */
102
- export const setLevelPlaces = (doc: Y.Doc, level: 1 | 2, places: ReadonlyMap<string, LevelPlace>) =>
103
- doc.transact(() => {
104
- const { levelLayouts } = getCanvasMaps(doc);
105
- places.forEach((place, id) => {
106
- const key = `${level}:${id}`;
107
- if (!samePlace(levelLayouts.get(key), place)) levelLayouts.set(key, place);
108
- });
109
- });
110
-
111
- // Only the design is shared. Selection, measured sizes and drag state stay with each user.
112
- export const toSharedNode = ({ id, type, position, data, width, height, parentId, zIndex }: Node): Node => ({
113
- id,
114
- type,
115
- position,
116
- data,
117
- ...(width ? { width } : {}),
118
- ...(height ? { height } : {}),
119
- // Inside a container (its position is then relative to the container)
120
- ...(parentId ? { parentId } : {}),
121
- ...(zIndex !== undefined ? { zIndex } : {}),
122
- });
123
-
124
- /** A new node of a type, centred on a canvas position */
125
- export const buildNode = (type: string, data: Record<string, unknown>, center: XYPosition): Node => {
126
- const definition = getNodeDefinition(type);
127
- const size = getNodeSize(type);
128
- return toSharedNode({
129
- id: `${type}-${shortId()}`,
130
- type,
131
- position: { x: center.x - size.width / 2, y: center.y - size.height / 2 },
132
- data,
133
- ...(definition?.defaultSize ? (definition.autoHeight ? { width: size.width } : size) : {}),
134
- // Containers sit behind what's in them and the connections between them
135
- ...(isGroupType(type) && { zIndex: -1 }),
136
- });
137
- };
138
-
139
- // ---- Meta ----
140
-
141
- export const readMeta = (doc: Y.Doc) => getCanvasMaps(doc).meta.toJSON() as CanvasMeta;
142
-
143
- export const setMeta = (doc: Y.Doc, meta: Partial<CanvasMeta>) =>
144
- doc.transact(() => {
145
- const map = getCanvasMaps(doc).meta;
146
- Object.entries(meta).forEach(([key, value]) => (value === undefined ? map.delete(key) : map.set(key, value)));
147
- });
148
-
149
- // ---- Status ----
150
-
151
- const MAX_STATUS_HISTORY = 200;
152
-
153
- export const getCanvasStatus = (meta: CanvasMeta): CanvasStatus => meta.status ?? 'draft';
154
-
155
- /** Changes a canvas's status (and records who did it). Returns false if it already had that status. */
156
- export const setCanvasStatus = (doc: Y.Doc, status: CanvasStatus, by: Author, note?: string) => {
157
- let changed = false;
158
- doc.transact(() => {
159
- const meta = readMeta(doc);
160
- if (getCanvasStatus(meta) === status) return;
161
- const change: StatusChange = { status, by, at: Date.now(), ...(note?.trim() && { note: note.trim() }) };
162
- const map = getCanvasMaps(doc).meta;
163
- map.set('status', status);
164
- map.set('statusHistory', [...(meta.statusHistory ?? []), change].slice(-MAX_STATUS_HISTORY));
165
- changed = true;
166
- });
167
- return changed;
168
- };
169
-
170
- /** Accepted and rejected canvases are decided: changing what's on them makes them a draft again */
171
- export const isDecided = (status: CanvasStatus) => status === 'accepted' || status === 'rejected';
172
-
173
- /** A decided canvas whose nodes or edges were changed goes back to draft, recorded as changed by `by` */
174
- export const reopenIfDecided = (doc: Y.Doc, by: Author) => {
175
- const status = getCanvasStatus(readMeta(doc));
176
- if (isDecided(status)) setCanvasStatus(doc, 'draft', by, `Changed after it was ${status}`);
177
- };
178
-
179
- /**
180
- * Runs `fn` (which may change the canvas), and if it changed nodes or edges, sends a decided canvas back to draft.
181
- * Comments don't count: reviewing an accepted design keeps it accepted.
182
- */
183
- export const reopeningIfEdited = <T>(doc: Y.Doc, by: Author, fn: () => T): T => {
184
- let result!: T;
185
- // In one transaction (the one it's already in, if it is): what it changed is known before it ends
186
- doc.transact((transaction) => {
187
- result = fn();
188
- if (changesNodesOrEdges(transaction)) reopenIfDecided(doc, by);
189
- });
190
- return result;
191
- };
192
-
193
- /** Whether a transaction changed the canvas's nodes or edges (not just its comments or meta) */
194
- export const changesNodesOrEdges = (transaction: Y.Transaction) => {
195
- const { nodes, edges } = getCanvasMaps(transaction.doc);
196
- return [...transaction.changed.keys()].some((type) => type === (nodes as unknown) || type === (edges as unknown));
197
- };
198
-
199
- // ---- Nodes and edges ----
200
-
201
- export const addNodes = (doc: Y.Doc, nodes: Node[], edges: Edge[] = []) =>
202
- doc.transact(() => {
203
- const maps = getCanvasMaps(doc);
204
- nodes.forEach((node) => maps.nodes.set(node.id, toSharedNode(node)));
205
- edges.forEach((edge) => maps.edges.set(edge.id, edge));
206
- });
207
-
208
- /**
209
- * Moves a node, and resizes it if a size is given, in one write. Nodes are only written when they change: every
210
- * write is sent to everyone, re-renders the node for them, and is kept in the document (and the undo stack), even
211
- * when it writes what was already there.
212
- */
213
- export const placeNode = (doc: Y.Doc, id: string, position: XYPosition, size?: { width: number; height: number }) => {
214
- const { nodes } = getCanvasMaps(doc);
215
- const node = nodes.get(id);
216
- if (!node) return false;
217
- const moved = node.position.x !== position.x || node.position.y !== position.y;
218
- const resized = !!size && (node.width !== size.width || node.height !== size.height);
219
- if (moved || resized) nodes.set(id, { ...node, position, ...(size && { width: size.width, height: size.height }) });
220
- return true;
221
- };
222
-
223
- export const moveNode = (doc: Y.Doc, id: string, position: XYPosition) => placeNode(doc, id, position);
224
-
225
- /** Resize a node: both sides, or just its width or height (e.g. text, whose height follows what's in it) */
226
- export const resizeNode = (
227
- doc: Y.Doc,
228
- id: string,
229
- size: { width: number; height: number },
230
- attributes: true | 'width' | 'height' = true
231
- ) => {
232
- const { nodes } = getCanvasMaps(doc);
233
- const node = nodes.get(id);
234
- const width = attributes !== 'height' && node?.width !== size.width;
235
- const height = attributes !== 'width' && node?.height !== size.height;
236
- if (node && (width || height))
237
- nodes.set(id, {
238
- ...node,
239
- ...(attributes !== 'height' && { width: size.width }),
240
- ...(attributes !== 'width' && { height: size.height }),
241
- });
242
- return !!node;
243
- };
244
-
245
- /** Grow a container to fit what's in it (moving them clear of its header if needed), never shrinking it */
246
- /**
247
- * The sizes nodes are rendered at (in the browser), by id. The document doesn't have them, so without them
248
- * containers are fitted to the size each type renders at at most.
249
- */
250
- export type RenderedSizes = ReadonlyMap<string, { width: number; height: number }>;
251
-
252
- const withRenderedSizes = (nodes: Node[], sizes?: RenderedSizes) =>
253
- sizes ? nodes.map((node) => (sizes.has(node.id) ? { ...node, measured: sizes.get(node.id) } : node)) : nodes;
254
-
255
- export const fitGroupToChildren = (doc: Y.Doc, groupId: string, sizes?: RenderedSizes) =>
256
- doc.transact(() => {
257
- const { nodes } = getCanvasMaps(doc);
258
- const fit = fitGroup(groupId, withRenderedSizes(Array.from(nodes.values()), sizes));
259
- const group = nodes.get(groupId);
260
- if (!fit || !group) return;
261
- if (fit.shift.x || fit.shift.y) {
262
- // Children move right/down and the container left/up by the same amount: nothing moves on the canvas
263
- nodes.forEach((child, id) => {
264
- if (child.parentId === groupId)
265
- nodes.set(id, { ...child, position: { x: child.position.x + fit.shift.x, y: child.position.y + fit.shift.y } });
266
- });
267
- }
268
- nodes.set(groupId, {
269
- ...group,
270
- position: { x: group.position.x - fit.shift.x, y: group.position.y - fit.shift.y },
271
- width: fit.width,
272
- height: fit.height,
273
- });
274
- });
275
-
276
- /** The containers a node is in grow to fit it (innermost first), e.g. after it's added or resized */
277
- export const fitContainersAround = (doc: Y.Doc, nodeId: string, sizes?: RenderedSizes) =>
278
- doc.transact(() => {
279
- const { nodes } = getCanvasMaps(doc);
280
- for (let id = nodes.get(nodeId)?.parentId; id; id = nodes.get(id)?.parentId) fitGroupToChildren(doc, id, sizes);
281
- });
282
-
283
- /** Put a node in a container (or take it out, without a parentId), keeping it where it is on the canvas */
284
- export const setNodeParent = (doc: Y.Doc, nodeId: string, parentId: string | undefined, sizes?: RenderedSizes) =>
285
- doc.transact(() => {
286
- const { nodes } = getCanvasMaps(doc);
287
- const node = nodes.get(nodeId);
288
- if (!node || node.parentId === parentId) return false;
289
- nodes.set(nodeId, toSharedNode(withParent(node, parentId, Array.from(nodes.values()))));
290
- if (parentId) fitGroupToChildren(doc, parentId, sizes);
291
- return true;
292
- });
293
-
294
- export const updateNodeData = (doc: Y.Doc, id: string, updater: (data: Record<string, unknown>) => Record<string, unknown>) => {
295
- const { nodes } = getCanvasMaps(doc);
296
- const node = nodes.get(id);
297
- if (node) nodes.set(id, { ...node, data: updater(node.data) });
298
- return !!node;
299
- };
300
-
301
- /**
302
- * How a connection is drawn, as the visualiser's layout routes it (in data.route, which the visualiser's edges
303
- * are drawn along): the points it goes through, where its label goes, and where its nodes were (top-left) when
304
- * it was routed, for it to follow them when they move
305
- */
306
- export type { EdgeRoute };
307
-
308
- /** An edge's route, if it has one */
309
- export const getEdgeRoute = (edge: Pick<Edge, 'data'>) => (edge.data as { route?: EdgeRoute } | undefined)?.route;
310
-
311
- /** Routes connections (by edge id), in one transaction. Connections not in it lose any route they had. */
312
- export const setEdgeRoutes = (doc: Y.Doc, routes: ReadonlyMap<string, EdgeRoute>) =>
313
- doc.transact(() => {
314
- const { edges } = getCanvasMaps(doc);
315
- edges.forEach((edge, id) => {
316
- const route = routes.get(id);
317
- if (route) edges.set(id, { ...edge, data: { ...edge.data, route } });
318
- else if (getEdgeRoute(edge)) {
319
- const { route: _, ...data } = edge.data!;
320
- edges.set(id, { ...edge, data });
321
- }
322
- });
323
- });
324
-
325
- /**
326
- * What's on a canvas and how it's connected, not where: its nodes (not notes), what they're in and called, and its
327
- * connections. The same for the same canvas wherever it's worked out (the server, any browser).
328
- */
329
- export const getStructureKey = (nodes: Node[], edges: Edge[]) => {
330
- const parts = [
331
- ...nodes
332
- .filter((node) => node.type !== 'note')
333
- .map((node) => `${node.id}:${node.type}:${node.parentId ?? ''}:${getNodeName(node.type, node.data)}`)
334
- .sort(),
335
- '#',
336
- ...edges.map((edge) => `${edge.source}>${edge.target}:${String(edge.label ?? '')}`).sort(),
337
- ];
338
- // FNV-1a, so it's short enough to keep in the canvas
339
- let hash = 0x811c9dc5;
340
- for (const char of parts.join('|')) hash = Math.imul(hash ^ char.charCodeAt(0), 0x01000193);
341
- return `${parts.length}-${(hash >>> 0).toString(36)}`;
342
- };
343
-
344
- /** The diagram's L1 or L2 while the canvas still has the structure it was opened with (`structure`, its key now) */
345
- export const getDiagramLevel = (levels: DiagramLevels | undefined, level: 1 | 2, structure: string) =>
346
- levels?.[level] && !levels.unavailable?.[level] && levels.structure === structure ? levels[level] : undefined;
347
-
348
- /** Why the diagram doesn't have a level, while the canvas still has the structure it was opened with */
349
- export const getDiagramLevelUnavailable = (levels: DiagramLevels | undefined, level: 1 | 2, structure: string) =>
350
- levels?.unavailable?.[level] && levels.structure === structure ? levels.unavailable[level] : undefined;
351
-
352
- /** What a new canvas starts with: its nodes, the connections between them (by node id) with their routes, and the
353
- * levels of the diagram it's opened from */
354
- export type CanvasContent = {
355
- nodes: Node[];
356
- connections: { from: string; to: string; label?: string; route?: Pick<EdgeRoute, 'points' | 'label'> }[];
357
- levels?: Pick<DiagramLevels, 1 | 2 | 'unavailable'>;
358
- };
359
-
360
- /**
361
- * Puts a new canvas's content on it (in one transaction), with its containers fitted to what's in them. Routes
362
- * are from where the nodes are put.
363
- */
364
- export const addCanvasContent = (doc: Y.Doc, { nodes, connections, levels }: CanvasContent) =>
365
- doc.transact(() => {
366
- addNodes(doc, nodes);
367
- nodes.forEach((node) => node.parentId && fitContainersAround(doc, node.id));
368
- const placed = new Map(getCanvasMaps(doc).nodes.entries());
369
- const routes = new Map<string, EdgeRoute>();
370
- connections.forEach(({ from, to, label, route }) => {
371
- const edge = connectNodes(doc, { source: from, target: to }, label);
372
- const [source, target] = [placed.get(from), placed.get(to)];
373
- if (!route || 'error' in edge || !source || !target) return;
374
- routes.set(edge.id, {
375
- ...route,
376
- source: getAbsolutePosition(source, placed),
377
- target: getAbsolutePosition(target, placed),
378
- });
379
- });
380
- if (routes.size) setEdgeRoutes(doc, routes);
381
- if (levels) {
382
- const maps = getCanvasMaps(doc);
383
- const structure = getStructureKey(Array.from(maps.nodes.values()), Array.from(maps.edges.values()));
384
- setMeta(doc, { diagramLevels: { structure, ...levels } });
385
- }
386
- });
387
-
388
- /** Connect two nodes with an EventCatalog-labelled edge. Returns the edge, or why it couldn't be made. */
389
- export const connectNodes = (
390
- doc: Y.Doc,
391
- connection: Pick<Connection, 'source' | 'target'> & Partial<Connection>,
392
- label?: string
393
- ): Edge | { error: string } => {
394
- const { nodes, edges } = getCanvasMaps(doc);
395
- const source = nodes.get(connection.source);
396
- const target = nodes.get(connection.target);
397
- if (!source) return { error: `Node "${connection.source}" is not on the canvas` };
398
- if (!target) return { error: `Node "${connection.target}" is not on the canvas` };
399
- if (source.id === target.id) return { error: 'A node cannot be connected to itself' };
400
- const existing = Array.from(edges.values()).find((edge) => edge.source === source.id && edge.target === target.id);
401
- if (existing) return existing;
402
- const edge = createEdge(
403
- {
404
- source: source.id,
405
- target: target.id,
406
- sourceHandle: connection.sourceHandle ?? null,
407
- targetHandle: connection.targetHandle ?? null,
408
- },
409
- source.type,
410
- target.type
411
- );
412
- const created = label === undefined ? edge : { ...edge, label };
413
- edges.set(created.id, created);
414
- return created;
415
- };
416
-
417
- /**
418
- * Moves a connection to new ends (dragged to another node), keeping it the same connection. Its label follows
419
- * the new ends if it was EventCatalog's wording, and is kept if someone wrote it.
420
- */
421
- export const reconnectEdge = (doc: Y.Doc, edgeId: string, connection: Connection): Edge | { error: string } =>
422
- doc.transact(() => {
423
- const { nodes, edges } = getCanvasMaps(doc);
424
- const edge = edges.get(edgeId);
425
- const source = nodes.get(connection.source);
426
- const target = nodes.get(connection.target);
427
- if (!edge) return { error: `Connection "${edgeId}" is not on the canvas` };
428
- if (!source || !target) return { error: 'Connections join two nodes on the canvas' };
429
- if (source.id === target.id) return { error: 'A node cannot be connected to itself' };
430
- const duplicate = Array.from(edges.values()).find(
431
- (other) => other.id !== edgeId && other.source === source.id && other.target === target.id
432
- );
433
- if (duplicate) return { error: 'Those nodes are already connected' };
434
-
435
- const defaultLabel = getEdgeLabel(nodes.get(edge.source)?.type, nodes.get(edge.target)?.type);
436
- const moved = createEdge(connection, source.type, target.type);
437
- const reconnected: Edge = {
438
- ...moved,
439
- id: edge.id,
440
- ...(edge.label !== undefined && edge.label !== defaultLabel && { label: edge.label }),
441
- };
442
- edges.set(edgeId, reconnected);
443
- return reconnected;
444
- });
445
-
446
- export const deleteEdges = (doc: Y.Doc, ids: string[]) =>
447
- doc.transact(() => ids.forEach((id) => getCanvasMaps(doc).edges.delete(id)));
448
-
449
- /** Delete nodes (containers with what's in them), the edges to them, and leave their comments where they were */
450
- export const deleteNodes = (doc: Y.Doc, ids: string[]) =>
451
- doc.transact(() => {
452
- const { nodes, edges, threads, levelLayouts } = getCanvasMaps(doc);
453
- const all = Array.from(nodes.values());
454
- const lookup = new Map(all.map((node) => [node.id, node]));
455
- const removed = withDescendants(ids, all);
456
- threads.forEach((thread) => {
457
- const nodeId = thread.get('nodeId') as string | undefined;
458
- const node = nodeId && removed.has(nodeId) ? nodes.get(nodeId) : undefined;
459
- if (!node) return;
460
- const offset = (thread.get('offset') as XYPosition) ?? { x: 0, y: 0 };
461
- const position = getAbsolutePosition(node, lookup);
462
- thread.set('position', { x: position.x + offset.x, y: position.y + offset.y });
463
- thread.delete('nodeId');
464
- thread.delete('offset');
465
- });
466
- removed.forEach((id) => {
467
- nodes.delete(id);
468
- // Where it was on L1 and L2 (undoing the delete puts it back there)
469
- levelLayouts.delete(`1:${id}`);
470
- levelLayouts.delete(`2:${id}`);
471
- });
472
- edges.forEach((edge, id) => {
473
- if (removed.has(edge.source) || removed.has(edge.target)) edges.delete(id);
474
- });
475
- });
476
-
477
- // ---- Comments ----
478
- // Each thread is a nested Y.Map and its messages a Y.Array, so concurrent replies (and resolving while
479
- // someone replies) all merge.
480
-
481
- export const readThreads = (doc: Y.Doc) =>
482
- Array.from(getCanvasMaps(doc).threads.values())
483
- .map((thread) => thread.toJSON() as Thread)
484
- .sort((a, b) => a.createdAt - b.createdAt);
485
-
486
- const anchorEntries = (anchor: CommentAnchor): [string, unknown][] => [
487
- ['position', anchor.position],
488
- ...(anchor.nodeId && anchor.offset
489
- ? ([
490
- ['nodeId', anchor.nodeId],
491
- ['offset', anchor.offset],
492
- ] as [string, unknown][])
493
- : []),
494
- ];
495
-
496
- export const createThread = (doc: Y.Doc, anchor: CommentAnchor, text: string, author: Author) => {
497
- const id = shortId();
498
- const now = Date.now();
499
- const messages = new Y.Array<Message>();
500
- messages.push([{ id: shortId(), text, author, createdAt: now }]);
501
- const thread = new Y.Map<unknown>([
502
- ['id', id],
503
- ...anchorEntries(anchor),
504
- ['author', author],
505
- ['createdAt', now],
506
- ['resolved', false],
507
- ['messages', messages],
508
- ]);
509
- getCanvasMaps(doc).threads.set(id, thread);
510
- return id;
511
- };
512
-
513
- const getThread = (doc: Y.Doc, id: string) => getCanvasMaps(doc).threads.get(id);
514
-
515
- export const replyToThread = (doc: Y.Doc, threadId: string, text: string, author: Author) => {
516
- const messages = getThread(doc, threadId)?.get('messages') as Y.Array<Message> | undefined;
517
- messages?.push([{ id: shortId(), text, author, createdAt: Date.now() }]);
518
- return !!messages;
519
- };
520
-
521
- export const setThreadResolved = (doc: Y.Doc, threadId: string, resolved: boolean) => {
522
- const thread = getThread(doc, threadId);
523
- thread?.set('resolved', resolved);
524
- return !!thread;
525
- };
526
-
527
- /** Move a thread's pin, attaching it to a node (anchor.nodeId) or to the canvas */
528
- export const moveThread = (doc: Y.Doc, threadId: string, anchor: CommentAnchor) => {
529
- const thread = getThread(doc, threadId);
530
- if (!thread) return false;
531
- doc.transact(() => {
532
- thread.set('position', anchor.position);
533
- if (anchor.nodeId && anchor.offset) {
534
- thread.set('nodeId', anchor.nodeId);
535
- thread.set('offset', anchor.offset);
536
- } else {
537
- thread.delete('nodeId');
538
- thread.delete('offset');
539
- }
540
- });
541
- return true;
542
- };
543
-
544
- export const deleteThread = (doc: Y.Doc, threadId: string) => {
545
- const { threads } = getCanvasMaps(doc);
546
- const exists = threads.has(threadId);
547
- threads.delete(threadId);
548
- return exists;
549
- };
550
-
551
- /** Deletes comment threads in one change (one undo brings them all back) */
552
- export const deleteThreads = (doc: Y.Doc, threadIds: string[]) =>
553
- doc.transact(() => threadIds.forEach((id) => deleteThread(doc, id)));
554
-
555
- /** Moves comment threads in one change, e.g. ones that moved with what was dragged */
556
- export const moveThreads = (doc: Y.Doc, moves: { threadId: string; anchor: CommentAnchor }[]) =>
557
- doc.transact(() => moves.forEach(({ threadId, anchor }) => moveThread(doc, threadId, anchor)));
558
-
559
- // ---- Reading a whole canvas ----
560
-
561
- export const readCanvas = (doc: Y.Doc) => {
562
- const { nodes, edges } = getCanvasMaps(doc);
563
- return {
564
- meta: readMeta(doc),
565
- nodes: Array.from(nodes.values()),
566
- edges: Array.from(edges.values()),
567
- threads: readThreads(doc),
568
- };
569
- };
@@ -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
- };