@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.
- package/README.md +1 -1
- package/dist/analytics/analytics.cjs +1 -1
- package/dist/analytics/analytics.js +2 -2
- package/dist/analytics/log-build.cjs +1 -1
- package/dist/analytics/log-build.js +4 -4
- package/dist/catalog-runtime.cjs +1 -1
- package/dist/{chunk-MFEMKQZ6.js → chunk-BO2HHVZ3.js} +1 -1
- package/dist/{chunk-4YFM63DS.js → chunk-JPT3QC5V.js} +1 -1
- package/dist/{chunk-76DAAJLV.js → chunk-PLTLW45Q.js} +5 -5
- package/dist/{chunk-WM4QYMIA.js → chunk-T75DOVFP.js} +1 -1
- package/dist/{chunk-LQZ7IG7B.js → chunk-XA5W6DXX.js} +1 -1
- package/dist/constants.cjs +1 -1
- package/dist/constants.js +1 -1
- package/dist/eventcatalog.cjs +2 -2
- package/dist/eventcatalog.js +14 -14
- package/dist/federation/federate.js +3 -3
- package/dist/generate.cjs +1 -1
- package/dist/generate.js +3 -3
- package/dist/runtime-dependencies/@eventcatalog__visualiser__studio-fonts.css.mjs +3 -0
- package/dist/runtime-dependencies/@eventcatalog__visualiser__studio.css.mjs +3 -0
- package/dist/runtime-dependencies/@eventcatalog__visualiser__studio.mjs +3 -0
- package/dist/runtime-dependencies/manifest.json +3 -0
- package/dist/utils/cli-logger.cjs +1 -1
- package/dist/utils/cli-logger.js +2 -2
- package/eventcatalog/src/components/ChatPanel/ChatPanel.tsx +2 -0
- package/eventcatalog/src/components/MDX/NodeGraph/AstroNodeGraph.tsx +42 -3
- package/eventcatalog/src/components/MDX/NodeGraph/NodeGraph.astro +5 -1
- package/eventcatalog/src/components/NavigationProgress.astro +7 -0
- package/eventcatalog/src/features/mcp/apps/architecture-diagram/node-chat.tsx +6 -45
- package/eventcatalog/src/features/mcp/apps/canvas/shared.ts +3 -3
- package/eventcatalog/src/features/mcp/apps/canvas/styles.css +3 -1
- package/eventcatalog/src/features/mcp/apps/canvas/view.tsx +7 -5
- package/eventcatalog/src/features/mcp/apps/generated/canvas.html +132 -70
- package/eventcatalog/src/features/mcp/apps/generated/viewer.html +71 -71
- package/eventcatalog/src/features/studio/agent-choreography.ts +13 -10
- package/eventcatalog/src/features/studio/agents.ts +28 -0
- package/eventcatalog/src/features/studio/api/canvases-api.ts +64 -15
- package/eventcatalog/src/features/studio/api/client.ts +6 -1
- package/eventcatalog/src/features/studio/canvas-actions.ts +160 -34
- package/eventcatalog/src/features/studio/canvas-preview.ts +1 -2
- package/eventcatalog/src/features/studio/catalog.ts +16 -14
- package/eventcatalog/src/features/studio/components/CanvasHeader.tsx +263 -115
- package/eventcatalog/src/features/studio/components/CanvasStatusMenu.tsx +15 -14
- package/eventcatalog/src/features/studio/components/ConnectAgentDialog.tsx +272 -119
- package/eventcatalog/src/features/studio/components/ConnectionsChooser.tsx +1 -2
- package/eventcatalog/src/features/studio/components/DeleteCanvasDialog.tsx +28 -44
- package/eventcatalog/src/features/studio/components/Dialog.tsx +60 -0
- package/eventcatalog/src/features/studio/components/JoinForm.tsx +101 -26
- package/eventcatalog/src/features/studio/components/LeftPanel.tsx +119 -230
- package/eventcatalog/src/features/studio/components/NodeDetails.tsx +183 -0
- package/eventcatalog/src/features/studio/components/OpenInStudioDialog.tsx +79 -0
- package/eventcatalog/src/features/studio/components/Presence.tsx +52 -45
- package/eventcatalog/src/features/studio/components/ShareDialog.tsx +277 -141
- package/eventcatalog/src/features/studio/components/ShortcutsDialog.tsx +41 -0
- package/eventcatalog/src/features/studio/components/StudioDesigner.tsx +1286 -411
- package/eventcatalog/src/features/studio/document-name.ts +7 -0
- package/eventcatalog/src/features/studio/from-visualiser.ts +138 -0
- package/eventcatalog/src/features/studio/hooks/presence-sender.ts +19 -2
- package/eventcatalog/src/features/studio/hooks/use-canvas-webmcp.ts +9 -3
- package/eventcatalog/src/features/studio/hooks/use-comments.ts +2 -2
- package/eventcatalog/src/features/studio/hooks/use-follow-camera.ts +8 -3
- package/eventcatalog/src/features/studio/hooks/use-studio-flow.ts +223 -30
- package/eventcatalog/src/features/studio/identity.ts +23 -1
- package/eventcatalog/src/features/studio/limits.ts +2 -0
- package/eventcatalog/src/features/studio/pages/[id].astro +2 -1
- package/eventcatalog/src/features/studio/pages/index.astro +2 -2
- package/eventcatalog/src/features/studio/server/canvas-mcp.ts +8 -23
- package/eventcatalog/src/features/studio/server/runtime.ts +16 -9
- package/eventcatalog/src/utils/node-graphs/domain-levels-node-graph.ts +3 -3
- package/eventcatalog/src/utils/node-graphs/node-resource.ts +67 -0
- package/eventcatalog/src/utils/node-graphs/systems-node-graph.ts +1 -1
- package/package.json +3 -3
- package/eventcatalog/src/features/studio/canvas-doc.ts +0 -414
- package/eventcatalog/src/features/studio/clipboard.ts +0 -97
- package/eventcatalog/src/features/studio/components/CanvasControls.tsx +0 -154
- package/eventcatalog/src/features/studio/components/Comments.tsx +0 -770
- package/eventcatalog/src/features/studio/components/ContainerChooser.tsx +0 -145
- package/eventcatalog/src/features/studio/components/DragGhost.tsx +0 -115
- package/eventcatalog/src/features/studio/components/Picture.tsx +0 -21
- package/eventcatalog/src/features/studio/components/PropertiesPanel.tsx +0 -212
- package/eventcatalog/src/features/studio/components/canvas-nodes.tsx +0 -255
- package/eventcatalog/src/features/studio/components/icons.ts +0 -53
- package/eventcatalog/src/features/studio/components/status.ts +0 -53
- package/eventcatalog/src/features/studio/edges.ts +0 -56
- package/eventcatalog/src/features/studio/flow-state.ts +0 -92
- package/eventcatalog/src/features/studio/grouping.ts +0 -226
- package/eventcatalog/src/features/studio/layout.ts +0 -42
- package/eventcatalog/src/features/studio/levels.ts +0 -282
- package/eventcatalog/src/features/studio/node-types.ts +0 -205
- package/eventcatalog/src/features/studio/placement.ts +0 -197
- package/eventcatalog/src/features/studio/studio.css +0 -273
- 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.
|
|
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/
|
|
145
|
-
"@eventcatalog/
|
|
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
|
-
};
|