@eventcatalog/core 5.0.0-beta.4 → 5.0.0-beta.6
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/dist/analytics/analytics.cjs +1 -1
- package/dist/analytics/analytics.js +2 -2
- package/dist/analytics/log-build.cjs +2 -2
- package/dist/analytics/log-build.js +4 -4
- package/dist/{chunk-TOMV5YJH.js → chunk-56CD2NH6.js} +1 -1
- package/dist/{chunk-M7OVJHPV.js → chunk-DE3QHVJP.js} +1 -1
- package/dist/{chunk-Q7FBYUCZ.js → chunk-L2IRFOE7.js} +1 -1
- package/dist/{chunk-TQLRG35T.js → chunk-PIYJM642.js} +1 -1
- package/dist/{chunk-VA77ENFQ.js → chunk-QBP3BBSV.js} +1 -1
- package/dist/{chunk-PL3T5TWF.js → chunk-XMPFHLK4.js} +2 -2
- package/dist/constants.cjs +1 -1
- package/dist/constants.js +1 -1
- package/dist/eventcatalog.cjs +2 -2
- package/dist/eventcatalog.js +6 -6
- package/dist/generate.cjs +1 -1
- package/dist/generate.js +3 -3
- package/dist/runtime-dependencies/@hocuspocus__provider.mjs +3 -0
- package/dist/runtime-dependencies/@hocuspocus__server.mjs +3 -0
- package/dist/runtime-dependencies/@mcp-b__webmcp-local-relay.mjs +3 -0
- package/dist/runtime-dependencies/@mcp-b__webmcp-polyfill.mjs +3 -0
- package/dist/runtime-dependencies/manifest.json +8 -0
- package/dist/runtime-dependencies/react-hotkeys-hook.mjs +3 -0
- package/dist/runtime-dependencies/y-protocols.mjs +3 -0
- package/dist/runtime-dependencies/y-protocols__awareness.mjs +3 -0
- package/dist/runtime-dependencies/yjs.mjs +3 -0
- package/dist/utils/cli-logger.cjs +1 -1
- package/dist/utils/cli-logger.js +2 -2
- package/dist/utils/license-status.cjs +1 -1
- package/dist/utils/license-status.js +1 -1
- package/eventcatalog/astro.config.mjs +2 -0
- package/eventcatalog/integrations/studio-server.mjs +17 -0
- package/eventcatalog/src/features/integrations/eventcatalog-features.ts +20 -1
- package/eventcatalog/src/features/mcp/apps/canvas/shared.ts +39 -0
- package/eventcatalog/src/features/mcp/apps/canvas/styles.css +22 -0
- package/eventcatalog/src/features/mcp/apps/canvas/view.tsx +269 -0
- package/eventcatalog/src/features/mcp/apps/generated/canvas.html +209 -0
- package/eventcatalog/src/features/mcp/apps/generated/viewer.html +2 -2
- package/eventcatalog/src/features/mcp/apps/shared/app-view.tsx +2 -1
- package/eventcatalog/src/features/mcp/mcp-apps.ts +6 -3
- package/eventcatalog/src/features/mcp/mcp-server.ts +17 -4
- package/eventcatalog/src/features/studio/agent-choreography.ts +378 -0
- package/eventcatalog/src/features/studio/canvas-actions.ts +287 -0
- package/eventcatalog/src/features/studio/canvas-doc.ts +280 -0
- package/eventcatalog/src/features/studio/catalog-resources.ts +152 -0
- package/eventcatalog/src/features/studio/catalog.ts +171 -0
- package/eventcatalog/src/features/studio/components/CanvasControls.tsx +153 -0
- package/eventcatalog/src/features/studio/components/CanvasHeader.tsx +332 -0
- package/eventcatalog/src/features/studio/components/Comments.tsx +455 -0
- package/eventcatalog/src/features/studio/components/ConnectAgentDialog.tsx +217 -0
- package/eventcatalog/src/features/studio/components/ContainerChooser.tsx +145 -0
- package/eventcatalog/src/features/studio/components/JoinForm.tsx +58 -0
- package/eventcatalog/src/features/studio/components/LeftPanel.tsx +218 -0
- package/eventcatalog/src/features/studio/components/Presence.tsx +284 -0
- package/eventcatalog/src/features/studio/components/PropertiesPanel.tsx +75 -0
- package/eventcatalog/src/features/studio/components/StudioDesigner.tsx +760 -0
- package/eventcatalog/src/features/studio/components/canvas-nodes.tsx +79 -0
- package/eventcatalog/src/features/studio/components/icons.ts +48 -0
- package/eventcatalog/src/features/studio/components/status.ts +22 -0
- package/eventcatalog/src/features/studio/edges.ts +54 -0
- package/eventcatalog/src/features/studio/flow-state.ts +92 -0
- package/eventcatalog/src/features/studio/grouping.ts +133 -0
- package/eventcatalog/src/features/studio/hooks/presence-sender.ts +46 -0
- package/eventcatalog/src/features/studio/hooks/presence-store.ts +115 -0
- package/eventcatalog/src/features/studio/hooks/use-canvas-webmcp.ts +337 -0
- package/eventcatalog/src/features/studio/hooks/use-comments.ts +47 -0
- package/eventcatalog/src/features/studio/hooks/use-follow-camera.ts +99 -0
- package/eventcatalog/src/features/studio/hooks/use-studio-flow.ts +423 -0
- package/eventcatalog/src/features/studio/layout.ts +39 -0
- package/eventcatalog/src/features/studio/levels.ts +216 -0
- package/eventcatalog/src/features/studio/node-types.ts +173 -0
- package/eventcatalog/src/features/studio/pages/[id].astro +38 -0
- package/eventcatalog/src/features/studio/pages/index.astro +8 -0
- package/eventcatalog/src/features/studio/pages/webmcp-relay.ts +27 -0
- package/eventcatalog/src/features/studio/placement.ts +197 -0
- package/eventcatalog/src/features/studio/server/canvas-mcp.ts +475 -0
- package/eventcatalog/src/features/studio/server/runtime.ts +215 -0
- package/eventcatalog/src/features/studio/server/tool-icons.ts +30 -0
- package/eventcatalog/src/features/studio/studio.css +89 -0
- package/eventcatalog/src/features/studio/tool-sync.ts +75 -0
- package/eventcatalog/src/layouts/VerticalSideBarLayout.astro +9 -2
- package/eventcatalog/src/utils/feature.ts +4 -0
- package/eventcatalog/src/utils/trial.ts +1 -1
- package/package.json +9 -2
- package/eventcatalog/public/studio-bg.png +0 -0
- package/eventcatalog/src/components/Studio/StudioPageModal.tsx +0 -150
- package/eventcatalog/src/pages/studio.astro +0 -316
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import type { Node, XYPosition } from '@xyflow/react';
|
|
2
|
+
import type * as Y from 'yjs';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { buildNode, getCanvasMaps, readCanvas, updateNodeData } from './canvas-doc';
|
|
5
|
+
import type { CatalogRelation, CatalogResource } from './catalog-resources';
|
|
6
|
+
import { buildContainer, canBeContainer, catalogNodeData, containerTypeFor, getCatalogLink, indexRelations } from './catalog';
|
|
7
|
+
import { getAbsolutePosition, sizeOf } from './grouping';
|
|
8
|
+
import { placeNodes, type Link } from './placement';
|
|
9
|
+
import {
|
|
10
|
+
COMPONENT_TYPES,
|
|
11
|
+
getNodeDefinition,
|
|
12
|
+
getNodeLabel,
|
|
13
|
+
getNodeName,
|
|
14
|
+
getResource,
|
|
15
|
+
isGroupType,
|
|
16
|
+
updateResource,
|
|
17
|
+
} from './node-types';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* What agents can do on a canvas, shared by the MCP tools (on the server) and the WebMCP tools (in the
|
|
21
|
+
* browser), so both describe and change a canvas the same way.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export const CATALOG_COLLECTIONS = [
|
|
25
|
+
'domains',
|
|
26
|
+
'systems',
|
|
27
|
+
'services',
|
|
28
|
+
'events',
|
|
29
|
+
'commands',
|
|
30
|
+
'queries',
|
|
31
|
+
'channels',
|
|
32
|
+
'containers',
|
|
33
|
+
] as const;
|
|
34
|
+
|
|
35
|
+
// ---- Inputs ----
|
|
36
|
+
|
|
37
|
+
export const nodeSpecsSchema = z
|
|
38
|
+
.array(
|
|
39
|
+
z.object({
|
|
40
|
+
ref: z.string().optional().describe('Your name for this node, to use in edges'),
|
|
41
|
+
resource: z
|
|
42
|
+
.object({ collection: z.enum(CATALOG_COLLECTIONS), id: z.string() })
|
|
43
|
+
.optional()
|
|
44
|
+
.describe('A catalog resource to add (latest version)'),
|
|
45
|
+
type: z.enum(COMPONENT_TYPES).optional().describe('For a new component: what it is'),
|
|
46
|
+
name: z.string().optional().describe('For a new component: its name'),
|
|
47
|
+
summary: z.string().optional().describe('For a new component: what it does. For a note: its text'),
|
|
48
|
+
container: z
|
|
49
|
+
.boolean()
|
|
50
|
+
.optional()
|
|
51
|
+
.describe(
|
|
52
|
+
'For a domain or system from the catalog: add it as a container that other nodes sit inside, rather than a card'
|
|
53
|
+
),
|
|
54
|
+
inside: z
|
|
55
|
+
.string()
|
|
56
|
+
.optional()
|
|
57
|
+
.describe('A ref (or node id) of a container (a domain or system added as one) to put this node inside'),
|
|
58
|
+
x: z.number().optional(),
|
|
59
|
+
y: z.number().optional(),
|
|
60
|
+
})
|
|
61
|
+
)
|
|
62
|
+
.min(1);
|
|
63
|
+
|
|
64
|
+
export const edgeSpecsSchema = z.array(
|
|
65
|
+
z.object({
|
|
66
|
+
from: z.string().describe('A ref from nodes, or the id of a node already on the canvas'),
|
|
67
|
+
to: z.string().describe('A ref from nodes, or the id of a node already on the canvas'),
|
|
68
|
+
label: z.string().optional().describe('Defaults to EventCatalog wording, e.g. "publishes event"'),
|
|
69
|
+
})
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
export const ADD_TO_CANVAS_DESCRIPTION = [
|
|
73
|
+
'Adds nodes (and connections between them) to the canvas, live for everyone on it.',
|
|
74
|
+
'A node is either a catalog resource (resource: { collection, id }), shown with its real name and summary and automatically connected to the related resources already on the canvas (e.g. a service to the events it publishes), or a new component (type, name, summary) for things that are not in the catalog yet, like a proposed service.',
|
|
75
|
+
'Give each node a ref to connect new nodes to each other in edges.',
|
|
76
|
+
'Domains and systems can be containers (container: true for catalog ones, or type domain-group / system-group for new ones): put services, messages and data stores inside one with inside (its ref or node id), e.g. a domain container with its system containers inside, and their services inside those.',
|
|
77
|
+
'Leave out x/y and each node is placed like an architect would: next to what it connects to (in edges, or as a related catalog resource), following the flow left to right (producers on the left of their messages, consumers on the right), lined up with it, and stacked when several connect to the same node. Nothing already on the canvas moves, and nodes connected to nothing go below what is there.',
|
|
78
|
+
"Give x/y (a node's centre, like the positions from reading the canvas) only to put a node somewhere specific. Nodes are about 240 wide and 110 tall: leave about 200 between connected nodes for the labels on their connections.",
|
|
79
|
+
].join(' ');
|
|
80
|
+
|
|
81
|
+
// ---- Catalog ----
|
|
82
|
+
|
|
83
|
+
export type CatalogIndex = { resourcesByKey: Map<string, CatalogResource>; relationsByKey: Map<string, CatalogRelation[]> };
|
|
84
|
+
|
|
85
|
+
export const indexCatalog = (resources: CatalogResource[], relations: CatalogRelation[]): CatalogIndex => ({
|
|
86
|
+
resourcesByKey: new Map(resources.map((resource) => [resource.key, resource])),
|
|
87
|
+
relationsByKey: indexRelations(relations),
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// ---- Reading ----
|
|
91
|
+
|
|
92
|
+
type NodeLookup = Map<string, Node>;
|
|
93
|
+
const lookupOf = (node: Node, lookup?: NodeLookup) => lookup ?? new Map([[node.id, node]]);
|
|
94
|
+
|
|
95
|
+
/** Something on the canvas, as an agent sees it */
|
|
96
|
+
export const describeNode = (node: Node, lookup?: NodeLookup) => {
|
|
97
|
+
const resource = getResource(node.type, node.data);
|
|
98
|
+
const link = getCatalogLink(node);
|
|
99
|
+
return {
|
|
100
|
+
id: node.id,
|
|
101
|
+
type: getNodeLabel(node.type),
|
|
102
|
+
...(node.type !== 'note' && { name: resource.name }),
|
|
103
|
+
...(resource.summary ? { summary: resource.summary } : {}),
|
|
104
|
+
...(node.type === 'note' ? { text: node.data.text } : {}),
|
|
105
|
+
...(link ? { catalogResource: link.key.replace(':', '/'), version: link.version } : {}),
|
|
106
|
+
/** The node's centre on the canvas, like the x/y agents give */
|
|
107
|
+
position: (({ x, y }) => ({ x: Math.round(x), y: Math.round(y) }))(nodeCenter(node, lookup)),
|
|
108
|
+
size: sizeOf(node),
|
|
109
|
+
...(isGroupType(node.type) && { container: true }),
|
|
110
|
+
...(node.parentId && { inside: node.parentId }),
|
|
111
|
+
};
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/** A node's centre on the canvas (nodes in containers are positioned relative to them) */
|
|
115
|
+
export const nodeCenter = (node: Node, lookup?: NodeLookup) => {
|
|
116
|
+
const { width, height } = sizeOf(node);
|
|
117
|
+
const position = getAbsolutePosition(node, lookupOf(node, lookup));
|
|
118
|
+
return { x: position.x + width / 2, y: position.y + height / 2 };
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/** Where a node's position goes for it to be centred on a canvas point (agents give centres) */
|
|
122
|
+
export const topLeftFor = (node: Node, center: XYPosition, lookup?: NodeLookup) => {
|
|
123
|
+
const { width, height } = sizeOf(node);
|
|
124
|
+
const parent = node.parentId ? lookup?.get(node.parentId) : undefined;
|
|
125
|
+
const parentPosition = parent ? getAbsolutePosition(parent, lookupOf(parent, lookup)) : { x: 0, y: 0 };
|
|
126
|
+
return { x: center.x - width / 2 - parentPosition.x, y: center.y - height / 2 - parentPosition.y };
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
/** Everything on a canvas, as an agent sees it */
|
|
130
|
+
export const describeCanvas = (doc: Y.Doc) => {
|
|
131
|
+
const canvas = readCanvas(doc);
|
|
132
|
+
const lookup = new Map(canvas.nodes.map((node) => [node.id, node]));
|
|
133
|
+
const names = new Map(canvas.nodes.map((node) => [node.id, getNodeName(node.type, node.data, node.id)]));
|
|
134
|
+
return {
|
|
135
|
+
title: canvas.meta.title,
|
|
136
|
+
nodes: canvas.nodes.map((node) => describeNode(node, lookup)),
|
|
137
|
+
connections: canvas.edges.map((edge) => ({
|
|
138
|
+
id: edge.id,
|
|
139
|
+
from: edge.source,
|
|
140
|
+
to: edge.target,
|
|
141
|
+
description: `${names.get(edge.source)} ${String(edge.label || '→').replace(/\s+/g, ' ')} ${names.get(edge.target)}`,
|
|
142
|
+
})),
|
|
143
|
+
comments: canvas.threads.map((thread) => ({
|
|
144
|
+
threadId: thread.id,
|
|
145
|
+
...(thread.nodeId ? { onNode: thread.nodeId } : { position: thread.position }),
|
|
146
|
+
resolved: thread.resolved,
|
|
147
|
+
messages: thread.messages.map((message) => ({ from: message.author.name, text: message.text })),
|
|
148
|
+
})),
|
|
149
|
+
};
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
// ---- Changing ----
|
|
153
|
+
|
|
154
|
+
export type NodeSpecs = z.output<typeof nodeSpecsSchema>;
|
|
155
|
+
export type EdgeSpecs = z.output<typeof edgeSpecsSchema>;
|
|
156
|
+
/**
|
|
157
|
+
* A node to add: built and placed, with the catalog resource it stands for (to connect it to related ones).
|
|
158
|
+
* Its node's position is relative to its container, as the canvas is now; `canvasPosition` is where it goes on
|
|
159
|
+
* the canvas, which stays the same when containers grow to fit what's added (and move what's in them).
|
|
160
|
+
*/
|
|
161
|
+
export type PlannedNode = { ref?: string; node: Node; name: string; resource?: CatalogResource; canvasPosition: XYPosition };
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Builds the nodes to add (catalog resources, as cards or containers, or new components), and places them:
|
|
165
|
+
* where asked, or next to what they connect to (the edges asked for, and related catalog resources)
|
|
166
|
+
*/
|
|
167
|
+
export const planNodes = (existing: Node[], specs: NodeSpecs, catalog: CatalogIndex, edges: EdgeSpecs = []) => {
|
|
168
|
+
const lookup: NodeLookup = new Map(existing.map((node) => [node.id, node]));
|
|
169
|
+
const refs = new Map<string, Node>();
|
|
170
|
+
const planned: PlannedNode[] = [];
|
|
171
|
+
const toPlace: string[] = [];
|
|
172
|
+
const errors: string[] = [];
|
|
173
|
+
const hasPosition = (spec: { x?: number; y?: number }) => spec.x !== undefined && spec.y !== undefined;
|
|
174
|
+
const centerOf = (spec: { x?: number; y?: number }) => (hasPosition(spec) ? { x: spec.x!, y: spec.y! } : { x: 0, y: 0 });
|
|
175
|
+
|
|
176
|
+
// Inside a container, at the canvas point asked for (relative to the container)
|
|
177
|
+
const putInside = (node: Node, parent: Node, spec: { x?: number; y?: number }): Node => {
|
|
178
|
+
if (!hasPosition(spec)) return { ...node, parentId: parent.id };
|
|
179
|
+
const { width, height } = sizeOf(node);
|
|
180
|
+
const parentPosition = getAbsolutePosition(parent, lookup);
|
|
181
|
+
return {
|
|
182
|
+
...node,
|
|
183
|
+
parentId: parent.id,
|
|
184
|
+
position: { x: spec.x! - width / 2 - parentPosition.x, y: spec.y! - height / 2 - parentPosition.y },
|
|
185
|
+
};
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
for (const spec of specs) {
|
|
189
|
+
let built: { node: Node; name: string; resource?: CatalogResource } | undefined;
|
|
190
|
+
if (spec.resource) {
|
|
191
|
+
const resource = catalog.resourcesByKey.get(`${spec.resource.collection}:${spec.resource.id}`);
|
|
192
|
+
if (!resource) {
|
|
193
|
+
errors.push(`No ${spec.resource.collection} resource "${spec.resource.id}" in the catalog`);
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
const asContainer = spec.container && canBeContainer(resource);
|
|
197
|
+
const node = asContainer
|
|
198
|
+
? buildContainer(resource, centerOf(spec))
|
|
199
|
+
: buildNode(resource.node.type, catalogNodeData(resource), centerOf(spec));
|
|
200
|
+
built = { node, name: resource.name, resource };
|
|
201
|
+
} else {
|
|
202
|
+
const definition = getNodeDefinition(spec.type);
|
|
203
|
+
if (!definition) {
|
|
204
|
+
errors.push(`Give each node a resource, or a type (one of ${COMPONENT_TYPES.join(', ')})`);
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
const data =
|
|
208
|
+
definition.type === 'note'
|
|
209
|
+
? { ...definition.createData(), text: spec.summary ?? spec.name ?? '' }
|
|
210
|
+
: updateResource(definition.type, definition.createData(), {
|
|
211
|
+
...(spec.name && { name: spec.name }),
|
|
212
|
+
...(spec.summary && { summary: spec.summary }),
|
|
213
|
+
});
|
|
214
|
+
const node = buildNode(definition.type, data, centerOf(spec));
|
|
215
|
+
built = { node, name: String(getResource(node.type, node.data).name ?? definition.label) };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (spec.inside) {
|
|
219
|
+
const parent = refs.get(spec.inside) ?? lookup.get(spec.inside);
|
|
220
|
+
if (parent && isGroupType(parent.type)) built.node = putInside(built.node, parent, spec);
|
|
221
|
+
else errors.push(`"${spec.inside}" is not a container on the canvas (a domain or system added as a container)`);
|
|
222
|
+
}
|
|
223
|
+
if (!hasPosition(spec)) toPlace.push(built.node.id);
|
|
224
|
+
lookup.set(built.node.id, built.node);
|
|
225
|
+
if (spec.ref) refs.set(spec.ref, built.node);
|
|
226
|
+
planned.push({ ref: spec.ref, ...built, canvasPosition: getAbsolutePosition(built.node, lookup) });
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// What the new nodes connect to: the edges asked for, and related catalog resources (as they'll be connected)
|
|
230
|
+
const links = new Map<string, Link>();
|
|
231
|
+
const link = (source: string, target: string) => links.set(`${source}>${target}`, { source, target });
|
|
232
|
+
const nodeIdOf = (refOrId: string) => refs.get(refOrId)?.id ?? refOrId;
|
|
233
|
+
edges.forEach((edge) => link(nodeIdOf(edge.from), nodeIdOf(edge.to)));
|
|
234
|
+
const nodesByKey = new Map<string, Node[]>();
|
|
235
|
+
for (const node of lookup.values()) {
|
|
236
|
+
const key = getCatalogLink(node)?.key;
|
|
237
|
+
if (key) nodesByKey.set(key, [...(nodesByKey.get(key) ?? []), node]);
|
|
238
|
+
}
|
|
239
|
+
for (const { node, resource } of planned) {
|
|
240
|
+
for (const relation of resource ? (catalog.relationsByKey.get(resource.key) ?? []) : []) {
|
|
241
|
+
const outgoing = relation.source === resource?.key;
|
|
242
|
+
for (const other of nodesByKey.get(outgoing ? relation.target : relation.source) ?? []) {
|
|
243
|
+
// Containers hold what they contain, rather than connect to it
|
|
244
|
+
if (relation.label === 'contains' && (isGroupType(node.type) || isGroupType(other.type))) continue;
|
|
245
|
+
if (outgoing) link(node.id, other.id);
|
|
246
|
+
else link(other.id, node.id);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const positions = placeNodes([...lookup.values()], toPlace, [...links.values()]);
|
|
252
|
+
const canvasPositions = new Map(planned.map((entry) => [entry.node.id, positions.get(entry.node.id) ?? entry.canvasPosition]));
|
|
253
|
+
for (const entry of planned) {
|
|
254
|
+
const canvasPosition = positions.get(entry.node.id);
|
|
255
|
+
if (!canvasPosition) continue;
|
|
256
|
+
const { parentId } = entry.node;
|
|
257
|
+
const parent = parentId ? lookup.get(parentId) : undefined;
|
|
258
|
+
const origin = parentId
|
|
259
|
+
? (canvasPositions.get(parentId) ?? (parent ? getAbsolutePosition(parent, lookup) : { x: 0, y: 0 }))
|
|
260
|
+
: { x: 0, y: 0 };
|
|
261
|
+
entry.canvasPosition = canvasPosition;
|
|
262
|
+
entry.node = { ...entry.node, position: { x: canvasPosition.x - origin.x, y: canvasPosition.y - origin.y } };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return { planned, errors };
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
/** Edges given by refs (or ids of nodes already on the canvas), as node ids */
|
|
269
|
+
export const resolveEdgeRefs = (edges: EdgeSpecs, planned: PlannedNode[]) => {
|
|
270
|
+
const refs = new Map(planned.flatMap((entry) => (entry.ref ? [[entry.ref, entry.node.id] as const] : [])));
|
|
271
|
+
return edges.map((edge) => ({ ...edge, from: refs.get(edge.from) ?? edge.from, to: refs.get(edge.to) ?? edge.to }));
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
/** Renames or re-describes a node. Catalog resources keep their catalog name and summary. */
|
|
275
|
+
export const updateCanvasNode = (doc: Y.Doc, { nodeId, name, summary }: { nodeId: string; name?: string; summary?: string }) =>
|
|
276
|
+
doc.transact(() => {
|
|
277
|
+
const node = getCanvasMaps(doc).nodes.get(nodeId);
|
|
278
|
+
if (!node) return { error: `No node "${nodeId}" on the canvas` };
|
|
279
|
+
if (!getCatalogLink(node)) {
|
|
280
|
+
updateNodeData(doc, nodeId, (data) =>
|
|
281
|
+
node.type === 'note'
|
|
282
|
+
? { ...data, text: summary ?? name ?? data.text }
|
|
283
|
+
: updateResource(node.type, data, { ...(name !== undefined && { name }), ...(summary !== undefined && { summary }) })
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
return { updated: nodeId };
|
|
287
|
+
});
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import * as Y from 'yjs';
|
|
2
|
+
import type { Connection, Edge, Node, XYPosition } from '@xyflow/react';
|
|
3
|
+
import { createEdge } 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 = { name: string; color: string; agent?: boolean };
|
|
13
|
+
export type Message = { id: string; text: string; author: Author; createdAt: number };
|
|
14
|
+
/** Where a comment is pinned: a canvas position, or a node (and the offset from its top left) so it moves with it */
|
|
15
|
+
export type CommentAnchor = { position: XYPosition; nodeId?: string; offset?: XYPosition };
|
|
16
|
+
export type Thread = CommentAnchor & { id: string; author: Author; createdAt: number; resolved: boolean; messages: Message[] };
|
|
17
|
+
export type CanvasMeta = {
|
|
18
|
+
title?: string;
|
|
19
|
+
createdAt?: number;
|
|
20
|
+
createdBy?: string;
|
|
21
|
+
/** When the canvas was last laid out, so everyone's canvas glides the nodes into place */
|
|
22
|
+
layoutAt?: number;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// Canvases' documents on the collaboration server are named `design:<canvasId>` (scripts and peers join by it)
|
|
26
|
+
const DOCUMENT_PREFIX = 'design:';
|
|
27
|
+
export const canvasDocumentName = (canvasId: string) => `${DOCUMENT_PREFIX}${canvasId}`;
|
|
28
|
+
export const canvasIdFromDocumentName = (name: string) =>
|
|
29
|
+
name.startsWith(DOCUMENT_PREFIX) ? name.slice(DOCUMENT_PREFIX.length) : undefined;
|
|
30
|
+
|
|
31
|
+
export const shortId = () => crypto.randomUUID().slice(0, 8);
|
|
32
|
+
|
|
33
|
+
export const getCanvasMaps = (doc: Y.Doc) => ({
|
|
34
|
+
nodes: doc.getMap<Node>('nodes'),
|
|
35
|
+
edges: doc.getMap<Edge>('edges'),
|
|
36
|
+
threads: doc.getMap<Y.Map<unknown>>('threads'),
|
|
37
|
+
meta: doc.getMap<unknown>('meta'),
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// Only the design is shared. Selection, measured sizes and drag state stay with each user.
|
|
41
|
+
export const toSharedNode = ({ id, type, position, data, width, height, parentId, zIndex }: Node): Node => ({
|
|
42
|
+
id,
|
|
43
|
+
type,
|
|
44
|
+
position,
|
|
45
|
+
data,
|
|
46
|
+
...(width ? { width } : {}),
|
|
47
|
+
...(height ? { height } : {}),
|
|
48
|
+
// Inside a container (its position is then relative to the container)
|
|
49
|
+
...(parentId ? { parentId } : {}),
|
|
50
|
+
...(zIndex !== undefined ? { zIndex } : {}),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
/** A new node of a type, centred on a canvas position */
|
|
54
|
+
export const buildNode = (type: string, data: Record<string, unknown>, center: XYPosition): Node => {
|
|
55
|
+
const definition = getNodeDefinition(type);
|
|
56
|
+
const size = getNodeSize(type);
|
|
57
|
+
return toSharedNode({
|
|
58
|
+
id: `${type}-${shortId()}`,
|
|
59
|
+
type,
|
|
60
|
+
position: { x: center.x - size.width / 2, y: center.y - size.height / 2 },
|
|
61
|
+
data,
|
|
62
|
+
...(definition?.defaultSize ? size : {}),
|
|
63
|
+
// Containers sit behind what's in them and the connections between them
|
|
64
|
+
...(isGroupType(type) && { zIndex: -1 }),
|
|
65
|
+
});
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// ---- Meta ----
|
|
69
|
+
|
|
70
|
+
export const readMeta = (doc: Y.Doc) => getCanvasMaps(doc).meta.toJSON() as CanvasMeta;
|
|
71
|
+
|
|
72
|
+
export const setMeta = (doc: Y.Doc, meta: Partial<CanvasMeta>) =>
|
|
73
|
+
doc.transact(() => {
|
|
74
|
+
const map = getCanvasMaps(doc).meta;
|
|
75
|
+
Object.entries(meta).forEach(([key, value]) => (value === undefined ? map.delete(key) : map.set(key, value)));
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// ---- Nodes and edges ----
|
|
79
|
+
|
|
80
|
+
export const addNodes = (doc: Y.Doc, nodes: Node[], edges: Edge[] = []) =>
|
|
81
|
+
doc.transact(() => {
|
|
82
|
+
const maps = getCanvasMaps(doc);
|
|
83
|
+
nodes.forEach((node) => maps.nodes.set(node.id, toSharedNode(node)));
|
|
84
|
+
edges.forEach((edge) => maps.edges.set(edge.id, edge));
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
export const moveNode = (doc: Y.Doc, id: string, position: XYPosition) => {
|
|
88
|
+
const { nodes } = getCanvasMaps(doc);
|
|
89
|
+
const node = nodes.get(id);
|
|
90
|
+
if (node) nodes.set(id, { ...node, position });
|
|
91
|
+
return !!node;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
export const resizeNode = (doc: Y.Doc, id: string, size: { width: number; height: number }) => {
|
|
95
|
+
const { nodes } = getCanvasMaps(doc);
|
|
96
|
+
const node = nodes.get(id);
|
|
97
|
+
if (node) nodes.set(id, { ...node, width: size.width, height: size.height });
|
|
98
|
+
return !!node;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/** Grow a container to fit what's in it (moving them clear of its header if needed), never shrinking it */
|
|
102
|
+
export const fitGroupToChildren = (doc: Y.Doc, groupId: string) =>
|
|
103
|
+
doc.transact(() => {
|
|
104
|
+
const { nodes } = getCanvasMaps(doc);
|
|
105
|
+
const fit = fitGroup(groupId, Array.from(nodes.values()));
|
|
106
|
+
const group = nodes.get(groupId);
|
|
107
|
+
if (!fit || !group) return;
|
|
108
|
+
if (fit.shift.x || fit.shift.y) {
|
|
109
|
+
// Children move right/down and the container left/up by the same amount: nothing moves on the canvas
|
|
110
|
+
nodes.forEach((child, id) => {
|
|
111
|
+
if (child.parentId === groupId)
|
|
112
|
+
nodes.set(id, { ...child, position: { x: child.position.x + fit.shift.x, y: child.position.y + fit.shift.y } });
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
nodes.set(groupId, {
|
|
116
|
+
...group,
|
|
117
|
+
position: { x: group.position.x - fit.shift.x, y: group.position.y - fit.shift.y },
|
|
118
|
+
width: fit.width,
|
|
119
|
+
height: fit.height,
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
/** Put a node in a container (or take it out, without a parentId), keeping it where it is on the canvas */
|
|
124
|
+
export const setNodeParent = (doc: Y.Doc, nodeId: string, parentId: string | undefined) =>
|
|
125
|
+
doc.transact(() => {
|
|
126
|
+
const { nodes } = getCanvasMaps(doc);
|
|
127
|
+
const node = nodes.get(nodeId);
|
|
128
|
+
if (!node || node.parentId === parentId) return false;
|
|
129
|
+
nodes.set(nodeId, toSharedNode(withParent(node, parentId, Array.from(nodes.values()))));
|
|
130
|
+
if (parentId) fitGroupToChildren(doc, parentId);
|
|
131
|
+
return true;
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
export const updateNodeData = (doc: Y.Doc, id: string, updater: (data: Record<string, unknown>) => Record<string, unknown>) => {
|
|
135
|
+
const { nodes } = getCanvasMaps(doc);
|
|
136
|
+
const node = nodes.get(id);
|
|
137
|
+
if (node) nodes.set(id, { ...node, data: updater(node.data) });
|
|
138
|
+
return !!node;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/** Connect two nodes with an EventCatalog-labelled edge. Returns the edge, or why it couldn't be made. */
|
|
142
|
+
export const connectNodes = (
|
|
143
|
+
doc: Y.Doc,
|
|
144
|
+
connection: Pick<Connection, 'source' | 'target'> & Partial<Connection>,
|
|
145
|
+
label?: string
|
|
146
|
+
): Edge | { error: string } => {
|
|
147
|
+
const { nodes, edges } = getCanvasMaps(doc);
|
|
148
|
+
const source = nodes.get(connection.source);
|
|
149
|
+
const target = nodes.get(connection.target);
|
|
150
|
+
if (!source) return { error: `Node "${connection.source}" is not on the canvas` };
|
|
151
|
+
if (!target) return { error: `Node "${connection.target}" is not on the canvas` };
|
|
152
|
+
if (source.id === target.id) return { error: 'A node cannot be connected to itself' };
|
|
153
|
+
const existing = Array.from(edges.values()).find((edge) => edge.source === source.id && edge.target === target.id);
|
|
154
|
+
if (existing) return existing;
|
|
155
|
+
const edge = createEdge(
|
|
156
|
+
{
|
|
157
|
+
source: source.id,
|
|
158
|
+
target: target.id,
|
|
159
|
+
sourceHandle: connection.sourceHandle ?? null,
|
|
160
|
+
targetHandle: connection.targetHandle ?? null,
|
|
161
|
+
},
|
|
162
|
+
source.type,
|
|
163
|
+
target.type
|
|
164
|
+
);
|
|
165
|
+
const created = label === undefined ? edge : { ...edge, label };
|
|
166
|
+
edges.set(created.id, created);
|
|
167
|
+
return created;
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
export const deleteEdges = (doc: Y.Doc, ids: string[]) =>
|
|
171
|
+
doc.transact(() => ids.forEach((id) => getCanvasMaps(doc).edges.delete(id)));
|
|
172
|
+
|
|
173
|
+
/** Delete nodes (containers with what's in them), the edges to them, and leave their comments where they were */
|
|
174
|
+
export const deleteNodes = (doc: Y.Doc, ids: string[]) =>
|
|
175
|
+
doc.transact(() => {
|
|
176
|
+
const { nodes, edges, threads } = getCanvasMaps(doc);
|
|
177
|
+
const all = Array.from(nodes.values());
|
|
178
|
+
const lookup = new Map(all.map((node) => [node.id, node]));
|
|
179
|
+
const removed = withDescendants(ids, all);
|
|
180
|
+
threads.forEach((thread) => {
|
|
181
|
+
const nodeId = thread.get('nodeId') as string | undefined;
|
|
182
|
+
const node = nodeId && removed.has(nodeId) ? nodes.get(nodeId) : undefined;
|
|
183
|
+
if (!node) return;
|
|
184
|
+
const offset = (thread.get('offset') as XYPosition) ?? { x: 0, y: 0 };
|
|
185
|
+
const position = getAbsolutePosition(node, lookup);
|
|
186
|
+
thread.set('position', { x: position.x + offset.x, y: position.y + offset.y });
|
|
187
|
+
thread.delete('nodeId');
|
|
188
|
+
thread.delete('offset');
|
|
189
|
+
});
|
|
190
|
+
removed.forEach((id) => nodes.delete(id));
|
|
191
|
+
edges.forEach((edge, id) => {
|
|
192
|
+
if (removed.has(edge.source) || removed.has(edge.target)) edges.delete(id);
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// ---- Comments ----
|
|
197
|
+
// Each thread is a nested Y.Map and its messages a Y.Array, so concurrent replies (and resolving while
|
|
198
|
+
// someone replies) all merge.
|
|
199
|
+
|
|
200
|
+
export const readThreads = (doc: Y.Doc) =>
|
|
201
|
+
Array.from(getCanvasMaps(doc).threads.values())
|
|
202
|
+
.map((thread) => thread.toJSON() as Thread)
|
|
203
|
+
.sort((a, b) => a.createdAt - b.createdAt);
|
|
204
|
+
|
|
205
|
+
const anchorEntries = (anchor: CommentAnchor): [string, unknown][] => [
|
|
206
|
+
['position', anchor.position],
|
|
207
|
+
...(anchor.nodeId && anchor.offset
|
|
208
|
+
? ([
|
|
209
|
+
['nodeId', anchor.nodeId],
|
|
210
|
+
['offset', anchor.offset],
|
|
211
|
+
] as [string, unknown][])
|
|
212
|
+
: []),
|
|
213
|
+
];
|
|
214
|
+
|
|
215
|
+
export const createThread = (doc: Y.Doc, anchor: CommentAnchor, text: string, author: Author) => {
|
|
216
|
+
const id = shortId();
|
|
217
|
+
const now = Date.now();
|
|
218
|
+
const messages = new Y.Array<Message>();
|
|
219
|
+
messages.push([{ id: shortId(), text, author, createdAt: now }]);
|
|
220
|
+
const thread = new Y.Map<unknown>([
|
|
221
|
+
['id', id],
|
|
222
|
+
...anchorEntries(anchor),
|
|
223
|
+
['author', author],
|
|
224
|
+
['createdAt', now],
|
|
225
|
+
['resolved', false],
|
|
226
|
+
['messages', messages],
|
|
227
|
+
]);
|
|
228
|
+
getCanvasMaps(doc).threads.set(id, thread);
|
|
229
|
+
return id;
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const getThread = (doc: Y.Doc, id: string) => getCanvasMaps(doc).threads.get(id);
|
|
233
|
+
|
|
234
|
+
export const replyToThread = (doc: Y.Doc, threadId: string, text: string, author: Author) => {
|
|
235
|
+
const messages = getThread(doc, threadId)?.get('messages') as Y.Array<Message> | undefined;
|
|
236
|
+
messages?.push([{ id: shortId(), text, author, createdAt: Date.now() }]);
|
|
237
|
+
return !!messages;
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
export const setThreadResolved = (doc: Y.Doc, threadId: string, resolved: boolean) => {
|
|
241
|
+
const thread = getThread(doc, threadId);
|
|
242
|
+
thread?.set('resolved', resolved);
|
|
243
|
+
return !!thread;
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
/** Move a thread's pin, attaching it to a node (anchor.nodeId) or to the canvas */
|
|
247
|
+
export const moveThread = (doc: Y.Doc, threadId: string, anchor: CommentAnchor) => {
|
|
248
|
+
const thread = getThread(doc, threadId);
|
|
249
|
+
if (!thread) return false;
|
|
250
|
+
doc.transact(() => {
|
|
251
|
+
thread.set('position', anchor.position);
|
|
252
|
+
if (anchor.nodeId && anchor.offset) {
|
|
253
|
+
thread.set('nodeId', anchor.nodeId);
|
|
254
|
+
thread.set('offset', anchor.offset);
|
|
255
|
+
} else {
|
|
256
|
+
thread.delete('nodeId');
|
|
257
|
+
thread.delete('offset');
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
return true;
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
export const deleteThread = (doc: Y.Doc, threadId: string) => {
|
|
264
|
+
const { threads } = getCanvasMaps(doc);
|
|
265
|
+
const exists = threads.has(threadId);
|
|
266
|
+
threads.delete(threadId);
|
|
267
|
+
return exists;
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
// ---- Reading a whole canvas ----
|
|
271
|
+
|
|
272
|
+
export const readCanvas = (doc: Y.Doc) => {
|
|
273
|
+
const { nodes, edges } = getCanvasMaps(doc);
|
|
274
|
+
return {
|
|
275
|
+
meta: readMeta(doc),
|
|
276
|
+
nodes: Array.from(nodes.values()),
|
|
277
|
+
edges: Array.from(edges.values()),
|
|
278
|
+
threads: readThreads(doc),
|
|
279
|
+
};
|
|
280
|
+
};
|