@eventcatalog/core 5.0.0-beta.10 → 5.0.0-beta.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -5
- 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 +3 -3
- package/dist/{chunk-CRKJBDN7.js → chunk-BWC4YKW3.js} +1 -1
- package/dist/{chunk-LJMXOEEC.js → chunk-CX2VB7AI.js} +1 -1
- package/dist/{chunk-IONYKKOY.js → chunk-FY6MSP6J.js} +1 -1
- package/dist/{chunk-2LBDRHE4.js → chunk-QB7X34OD.js} +1 -1
- package/dist/{chunk-EM36OL6A.js → chunk-TSY5CC27.js} +1 -1
- package/dist/constants.cjs +1 -1
- package/dist/constants.js +1 -1
- package/dist/eventcatalog.cjs +1 -1
- package/dist/eventcatalog.js +5 -5
- package/dist/generate.cjs +1 -1
- package/dist/generate.js +3 -3
- 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 +34 -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/generated/canvas.html +67 -67
- package/eventcatalog/src/features/mcp/apps/generated/viewer.html +69 -69
- package/eventcatalog/src/features/studio/agent-choreography.ts +8 -6
- 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 +140 -22
- package/eventcatalog/src/features/studio/canvas-doc.ts +165 -10
- package/eventcatalog/src/features/studio/catalog.ts +15 -10
- package/eventcatalog/src/features/studio/components/CanvasControls.tsx +54 -89
- package/eventcatalog/src/features/studio/components/CanvasHeader.tsx +43 -26
- package/eventcatalog/src/features/studio/components/CanvasStatusMenu.tsx +8 -11
- package/eventcatalog/src/features/studio/components/Comments.tsx +15 -8
- package/eventcatalog/src/features/studio/components/DeleteCanvasDialog.tsx +27 -43
- package/eventcatalog/src/features/studio/components/Dialog.tsx +60 -0
- package/eventcatalog/src/features/studio/components/LeftPanel.tsx +32 -4
- package/eventcatalog/src/features/studio/components/OpenInStudioDialog.tsx +79 -0
- package/eventcatalog/src/features/studio/components/Presence.tsx +47 -38
- package/eventcatalog/src/features/studio/components/StudioDesigner.tsx +323 -99
- package/eventcatalog/src/features/studio/components/canvas-nodes.tsx +33 -17
- package/eventcatalog/src/features/studio/flow-state.ts +29 -0
- package/eventcatalog/src/features/studio/from-visualiser.ts +138 -0
- package/eventcatalog/src/features/studio/grouping.ts +4 -0
- package/eventcatalog/src/features/studio/hooks/use-canvas-webmcp.ts +1 -1
- package/eventcatalog/src/features/studio/hooks/use-counter-scale.ts +20 -0
- package/eventcatalog/src/features/studio/hooks/use-follow-camera.ts +8 -3
- package/eventcatalog/src/features/studio/hooks/use-studio-flow.ts +67 -13
- package/eventcatalog/src/features/studio/layout.ts +29 -15
- package/eventcatalog/src/features/studio/level-layout.ts +145 -0
- package/eventcatalog/src/features/studio/levels.ts +82 -27
- package/eventcatalog/src/features/studio/limits.ts +2 -0
- package/eventcatalog/src/features/studio/node-types.ts +44 -4
- package/eventcatalog/src/features/studio/pages/index.astro +2 -1
- package/eventcatalog/src/features/studio/server/canvas-mcp.ts +1 -1
- package/eventcatalog/src/features/studio/server/runtime.ts +12 -4
- 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
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { createContext, memo, useCallback, useContext, useRef, useState } from 'react';
|
|
1
|
+
import { createContext, memo, useCallback, useContext, useMemo, useRef, useState } from 'react';
|
|
2
2
|
import { NodeResizeControl, NodeResizer, ResizeControlVariant, useStoreApi, type NodeProps, type NodeTypes } from '@xyflow/react';
|
|
3
|
-
import { DomainCardNode,
|
|
3
|
+
import { diagramNodeComponents, DomainCardNode, memoNode, SystemGroupNode } from '@eventcatalog/visualiser';
|
|
4
4
|
import { GROUP_PADDING } from '../grouping';
|
|
5
|
-
import { getNodeName, GROUP_TYPES
|
|
5
|
+
import { getNodeName, GROUP_TYPES } from '../node-types';
|
|
6
6
|
|
|
7
7
|
/** How the canvas draws each kind of node: the visualiser's nodes, notes and text written on the canvas, and resizable containers */
|
|
8
8
|
|
|
@@ -14,9 +14,6 @@ export const UpdateNodeDataContext = createContext<UpdateNodeData>(() => {});
|
|
|
14
14
|
// Which container something dragged would drop into, to highlight it
|
|
15
15
|
export const DropTargetContext = createContext<string | null>(null);
|
|
16
16
|
|
|
17
|
-
// The visualiser types its node components' props as nodes (Node<Data>) rather than React Flow's NodeProps, so
|
|
18
|
-
// they don't type-check as NodeTypes, though that's what React Flow renders them with
|
|
19
|
-
const visualiserNodes = nodeComponents as unknown as NodeTypes;
|
|
20
17
|
const SystemGroupComponent: NodeTypes[string] = SystemGroupNode;
|
|
21
18
|
|
|
22
19
|
/** The handles that set a node's width, on either side (its height follows what's written in it) */
|
|
@@ -167,6 +164,18 @@ const TextNode = memo(function TextNode({ id, data, selected }: NodeProps) {
|
|
|
167
164
|
);
|
|
168
165
|
});
|
|
169
166
|
|
|
167
|
+
/** A domain inside another one is a subdomain (badged as one, like the visualiser), unless its data says otherwise */
|
|
168
|
+
const useDomainData = ({ type, data, parentId }: Pick<NodeProps, 'type' | 'data' | 'parentId'>) =>
|
|
169
|
+
useMemo(() => {
|
|
170
|
+
const isDomain = type === GROUP_TYPES.domain || type === 'context-domain';
|
|
171
|
+
return isDomain && parentId && data.subdomain === undefined ? { ...data, subdomain: true } : data;
|
|
172
|
+
}, [type, data, parentId]);
|
|
173
|
+
|
|
174
|
+
/** A domain card, badged as a subdomain when it's in another domain */
|
|
175
|
+
const DomainCard = memo(function DomainCard(props: NodeProps) {
|
|
176
|
+
return <DomainCardNode {...props} data={useDomainData(props)} />;
|
|
177
|
+
});
|
|
178
|
+
|
|
170
179
|
/**
|
|
171
180
|
* A domain or system as a container: resizable when selected, highlighted when something's dragged over it.
|
|
172
181
|
* Like frames in design tools, it's moved and selected by its header, and dragging inside it draws a selection
|
|
@@ -174,6 +183,7 @@ const TextNode = memo(function TextNode({ id, data, selected }: NodeProps) {
|
|
|
174
183
|
*/
|
|
175
184
|
const GroupNode = memo(function GroupNode(props: NodeProps) {
|
|
176
185
|
const isDropTarget = useContext(DropTargetContext) === props.id;
|
|
186
|
+
const data = useDomainData(props);
|
|
177
187
|
const store = useStoreApi();
|
|
178
188
|
// Where what's inside has to stay (with room around it and for the header), in the container's parent's
|
|
179
189
|
// coordinates: found when a resize starts, so it can't be resized smaller than what's in it
|
|
@@ -224,7 +234,7 @@ const GroupNode = memo(function GroupNode(props: NodeProps) {
|
|
|
224
234
|
lineClassName="!border-[rgb(var(--ec-accent))]"
|
|
225
235
|
handleClassName="!h-2.5 !w-2.5 !rounded-sm !border-2 !bg-[rgb(var(--ec-card-bg))] !border-[rgb(var(--ec-accent))]"
|
|
226
236
|
/>
|
|
227
|
-
<SystemGroupComponent {...props} />
|
|
237
|
+
<SystemGroupComponent {...props} data={data} />
|
|
228
238
|
{/* The header (and the badge on its border) */}
|
|
229
239
|
<div className="ec-group-grip absolute inset-x-0 -top-3 h-[60px] cursor-grab" />
|
|
230
240
|
{isDropTarget ? (
|
|
@@ -239,17 +249,23 @@ const GroupNode = memo(function GroupNode(props: NodeProps) {
|
|
|
239
249
|
);
|
|
240
250
|
});
|
|
241
251
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
//
|
|
250
|
-
|
|
251
|
-
'context-domain':
|
|
252
|
+
const components: NodeTypes = {
|
|
253
|
+
// Everything the visualiser draws, drawn the same (so a diagram's levels look as they do there). It types its
|
|
254
|
+
// components' props as nodes (Node<Data>) rather than React Flow's NodeProps, which they're rendered with.
|
|
255
|
+
...(diagramNodeComponents as unknown as NodeTypes),
|
|
256
|
+
// Written on the canvas
|
|
257
|
+
note: StickyNote,
|
|
258
|
+
text: TextNode,
|
|
259
|
+
// Actors look like the visualiser's actors on its architecture diagrams
|
|
260
|
+
actor: diagramNodeComponents['context-actor'] as NodeTypes[string],
|
|
261
|
+
'context-domain': DomainCard,
|
|
252
262
|
// Domains and systems as containers
|
|
253
263
|
[GROUP_TYPES.domain]: GroupNode,
|
|
254
264
|
[GROUP_TYPES.system]: GroupNode,
|
|
255
265
|
};
|
|
266
|
+
|
|
267
|
+
// React Flow gives every node its position, which changes on every frame it moves (dragged, in a dragged container,
|
|
268
|
+
// or gliding to a layout): like the visualiser's, nodes render again only when something else changes
|
|
269
|
+
export const nodeTypes: NodeTypes = Object.fromEntries(
|
|
270
|
+
Object.entries(components).map(([type, component]) => [type, memoNode(component)])
|
|
271
|
+
);
|
|
@@ -52,6 +52,35 @@ export const patchShared = <T extends { id: string }>(
|
|
|
52
52
|
return [...(removed.size ? patched.filter((item) => !removed.has(item.id)) : patched), ...added];
|
|
53
53
|
};
|
|
54
54
|
|
|
55
|
+
/** Whether two plain values (what nodes and edges hold) are the same, comparing what's in them */
|
|
56
|
+
const isSame = (a: unknown, b: unknown): boolean => {
|
|
57
|
+
if (Object.is(a, b)) return true;
|
|
58
|
+
if (!a || !b || typeof a !== 'object' || typeof b !== 'object' || Array.isArray(a) !== Array.isArray(b)) return false;
|
|
59
|
+
const keys = Object.keys(a);
|
|
60
|
+
return (
|
|
61
|
+
keys.length === Object.keys(b).length &&
|
|
62
|
+
keys.every((key) => isSame((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key]))
|
|
63
|
+
);
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Items worked out again (e.g. a level arranged again), keeping each one that hasn't changed as the object already
|
|
68
|
+
* shown (so React Flow doesn't re-render it), and the local-only state of the ones that have. Returns `previous`
|
|
69
|
+
* itself when nothing changed.
|
|
70
|
+
*/
|
|
71
|
+
export const keepUnchanged = <T extends { id: string }>(previous: T[], next: T[], localKeys: readonly (keyof T)[]): T[] => {
|
|
72
|
+
const shown = new Map(previous.map((item) => [item.id, item]));
|
|
73
|
+
const local = new Set<PropertyKey>(localKeys);
|
|
74
|
+
const kept = next.map((item) => {
|
|
75
|
+
const before = shown.get(item.id);
|
|
76
|
+
if (!before) return item;
|
|
77
|
+
const keys = new Set([...Object.keys(before), ...Object.keys(item)]) as Set<keyof T>;
|
|
78
|
+
const same = [...keys].every((key) => local.has(key) || isSame(before[key], item[key]));
|
|
79
|
+
return same ? before : withLocalState(item, before, localKeys);
|
|
80
|
+
});
|
|
81
|
+
return kept.length === previous.length && kept.every((item, index) => item === previous[index]) ? previous : kept;
|
|
82
|
+
};
|
|
83
|
+
|
|
55
84
|
/** Where nodes being dragged are, by id, until they're dropped (and saved) */
|
|
56
85
|
export type Moves = ReadonlyMap<string, XYPosition>;
|
|
57
86
|
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import type { Edge, Node } from '@xyflow/react';
|
|
2
|
+
import type { VisualiserSnapshot } from '@eventcatalog/visualiser';
|
|
3
|
+
import { getNodeCatalogResource } from '@utils/node-graphs/node-resource';
|
|
4
|
+
import type { DiagramLevelsSpec, NewCanvasEdges, NodeSpecs } from './canvas-actions';
|
|
5
|
+
import { DEFAULT_NODE_SIZE, isCatalogCollection, isGroupType } from './node-types';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A canvas started from a diagram (the visualiser's "Open in Studio"): the catalog resources and actors it shows,
|
|
9
|
+
* where they are on it (the same top-left corners), in the domains and systems they're shown in, and the
|
|
10
|
+
* connections between them, drawn along the same routes. Other nodes (e.g. flow steps, users, entities) are left
|
|
11
|
+
* out, with their connections. Its levels 1 and 2 come as the diagram shows them, for Studio to show them the same.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// Actors (people and roles) aren't catalog resources: they go on the canvas as Studio's actors, by the name shown
|
|
15
|
+
const ACTOR_TYPES = new Set(['context-actor', 'actor']);
|
|
16
|
+
const actorNameOf = (node: VisualiserSnapshot['nodes'][number]) =>
|
|
17
|
+
node.type && ACTOR_TYPES.has(node.type) && typeof node.data.name === 'string' ? node.data.name : undefined;
|
|
18
|
+
|
|
19
|
+
export const canvasFromVisualiser = (
|
|
20
|
+
snapshot: VisualiserSnapshot
|
|
21
|
+
): { nodes: NodeSpecs; edges: NewCanvasEdges; levels: DiagramLevelsSpec } => {
|
|
22
|
+
const byId = new Map(snapshot.nodes.map((node) => [node.id, node]));
|
|
23
|
+
const depthOf = (node: VisualiserSnapshot['nodes'][number]): number => {
|
|
24
|
+
const parent = node.parentId ? byId.get(node.parentId) : undefined;
|
|
25
|
+
return parent ? 1 + depthOf(parent) : 0;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const containers = new Set<string>();
|
|
29
|
+
const parentOf = new Map<string, string>();
|
|
30
|
+
const nodes = [...snapshot.nodes]
|
|
31
|
+
// Containers before what's in them (specs refer to containers given before them)
|
|
32
|
+
.sort((a, b) => depthOf(a) - depthOf(b))
|
|
33
|
+
.flatMap((node) => {
|
|
34
|
+
const resource = getNodeCatalogResource(node);
|
|
35
|
+
const fromCatalog =
|
|
36
|
+
resource && isCatalogCollection(resource.collection) ? { collection: resource.collection, id: resource.id } : undefined;
|
|
37
|
+
const actorName = actorNameOf(node);
|
|
38
|
+
if (!fromCatalog && !actorName) return [];
|
|
39
|
+
// Domains and systems shown as boxes are containers on the canvas
|
|
40
|
+
const container = isGroupType(node.type);
|
|
41
|
+
// In the innermost container shown that's on the canvas
|
|
42
|
+
let inside: string | undefined;
|
|
43
|
+
for (let parent = node.parentId; parent && !inside; parent = byId.get(parent)?.parentId) {
|
|
44
|
+
if (containers.has(parent)) inside = parent;
|
|
45
|
+
}
|
|
46
|
+
if (container) containers.add(node.id);
|
|
47
|
+
if (inside) parentOf.set(node.id, inside);
|
|
48
|
+
// Where it is: its top-left corner, as cards are put by their centre at the size they're created at (and
|
|
49
|
+
// render at the size they need from there), and containers at their size
|
|
50
|
+
const left = node.center.x - node.width / 2;
|
|
51
|
+
const top = node.center.y - node.height / 2;
|
|
52
|
+
return [
|
|
53
|
+
{
|
|
54
|
+
ref: node.id,
|
|
55
|
+
...(fromCatalog ? { resource: fromCatalog } : { type: 'actor', name: actorName }),
|
|
56
|
+
...(container
|
|
57
|
+
? { x: node.center.x, y: node.center.y, container: true, width: node.width, height: node.height }
|
|
58
|
+
: { x: left + DEFAULT_NODE_SIZE.width / 2, y: top + DEFAULT_NODE_SIZE.height / 2 }),
|
|
59
|
+
...(inside && { inside }),
|
|
60
|
+
},
|
|
61
|
+
];
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// Connections between nodes on the canvas, not between a container and what's in it (it holds it instead)
|
|
65
|
+
const onCanvas = new Set(nodes.map((node) => node.ref));
|
|
66
|
+
const isInside = (nodeId: string, containerId: string) => {
|
|
67
|
+
for (let parent = parentOf.get(nodeId); parent; parent = parentOf.get(parent)) if (parent === containerId) return true;
|
|
68
|
+
return false;
|
|
69
|
+
};
|
|
70
|
+
const edges = snapshot.edges
|
|
71
|
+
.filter(({ source, target }) => onCanvas.has(source) && onCanvas.has(target) && source !== target)
|
|
72
|
+
.filter(({ source, target }) => !isInside(source, target) && !isInside(target, source))
|
|
73
|
+
.map(({ source, target, label, route }) => ({ from: source, to: target, ...(label && { label }), ...(route && { route }) }));
|
|
74
|
+
|
|
75
|
+
const levels = {
|
|
76
|
+
...(snapshot.levels[1] && { 1: levelFromVisualiser(snapshot.levels[1], nodes) }),
|
|
77
|
+
...(snapshot.levels[2] && { 2: levelFromVisualiser(snapshot.levels[2], nodes) }),
|
|
78
|
+
...(Object.keys(snapshot.unavailableLevels).length > 0 && { unavailable: snapshot.unavailableLevels }),
|
|
79
|
+
};
|
|
80
|
+
return { nodes, edges, levels };
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const sameResource = (a?: { collection: string; id: string }, b?: { collection: string; id: string }) =>
|
|
84
|
+
!!a && !!b && a.collection === b.collection && a.id === b.id;
|
|
85
|
+
const sizeOf = (node: Node) => ({
|
|
86
|
+
width: typeof node.style?.width === 'number' ? node.style.width : (node.width ?? node.measured?.width),
|
|
87
|
+
height: typeof node.style?.height === 'number' ? node.style.height : (node.height ?? node.measured?.height),
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* One of the diagram's levels, as it shows it: each node stands for the canvas node with the same id (on level 3),
|
|
92
|
+
* or the same catalog resource (e.g. a system shown as a card on level 1 and as a container on level 3), so moving
|
|
93
|
+
* between levels glides from one to the other. Nodes only on the level keep their own id.
|
|
94
|
+
*/
|
|
95
|
+
const levelFromVisualiser = (level: { nodes: Node[]; edges: Edge[] }, specs: NodeSpecs) => {
|
|
96
|
+
const used = new Set<string>();
|
|
97
|
+
const refs = new Map<string, string>();
|
|
98
|
+
for (const node of level.nodes) {
|
|
99
|
+
const resource = getNodeCatalogResource(node);
|
|
100
|
+
const ref = [
|
|
101
|
+
specs.find((spec) => spec.ref === node.id)?.ref,
|
|
102
|
+
specs.find((spec) => sameResource(spec.resource, resource))?.ref,
|
|
103
|
+
].find((candidate) => candidate && !used.has(candidate));
|
|
104
|
+
refs.set(node.id, ref ?? node.id);
|
|
105
|
+
used.add(ref ?? node.id);
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
nodes: level.nodes.map((node) => {
|
|
109
|
+
const { width, height } = sizeOf(node);
|
|
110
|
+
// The diagram marks the resource whose page it's on ("Viewing"): a canvas isn't on one
|
|
111
|
+
const { isFocused: _, ...data } = node.data as Record<string, unknown>;
|
|
112
|
+
return {
|
|
113
|
+
ref: refs.get(node.id)!,
|
|
114
|
+
type: node.type ?? 'default',
|
|
115
|
+
data,
|
|
116
|
+
...(node.parentId && refs.has(node.parentId) && { parent: refs.get(node.parentId) }),
|
|
117
|
+
x: node.position.x,
|
|
118
|
+
y: node.position.y,
|
|
119
|
+
...(isGroupType(node.type) && width && height && { width, height }),
|
|
120
|
+
};
|
|
121
|
+
}),
|
|
122
|
+
edges: level.edges.flatMap((edge) => {
|
|
123
|
+
const [from, to] = [refs.get(edge.source), refs.get(edge.target)];
|
|
124
|
+
if (!from || !to) return [];
|
|
125
|
+
return [
|
|
126
|
+
{
|
|
127
|
+
from,
|
|
128
|
+
to,
|
|
129
|
+
...(typeof edge.label === 'string' && { label: edge.label }),
|
|
130
|
+
...(edge.type && { type: edge.type }),
|
|
131
|
+
...(edge.style && { style: edge.style as Record<string, unknown> }),
|
|
132
|
+
...(edge.markerEnd && { markerEnd: edge.markerEnd }),
|
|
133
|
+
...(edge.data && { data: edge.data }),
|
|
134
|
+
},
|
|
135
|
+
];
|
|
136
|
+
}),
|
|
137
|
+
};
|
|
138
|
+
};
|
|
@@ -78,6 +78,10 @@ export const findGroupAtPoint = (point: XYPosition, nodes: Node[]): Node | undef
|
|
|
78
78
|
.sort((a, b) => getDepth(b, lookup) - getDepth(a, lookup))[0];
|
|
79
79
|
};
|
|
80
80
|
|
|
81
|
+
/** The container something added goes in: the one asked for (by id), else the one it's dropped on */
|
|
82
|
+
export const containerFor = (point: XYPosition, nodes: Node[], inside?: string): Node | undefined =>
|
|
83
|
+
inside ? nodes.find((node) => node.id === inside && isGroupType(node.type)) : findGroupAtPoint(point, nodes);
|
|
84
|
+
|
|
81
85
|
/** A node moved into (or out of, without a parentId) a container, staying in the same place on the canvas */
|
|
82
86
|
export const withParent = (node: Node, parentId: string | undefined, nodes: Node[]): Node => {
|
|
83
87
|
const lookup = byId(nodes);
|
|
@@ -3,9 +3,9 @@ import { z } from 'zod';
|
|
|
3
3
|
import type * as Y from 'yjs';
|
|
4
4
|
import { initializeWebMCPPolyfill } from '@mcp-b/webmcp-polyfill';
|
|
5
5
|
import { replyToThread, setThreadResolved, type Author, CANVAS_STATUSES, setCanvasStatus } from '../canvas-doc';
|
|
6
|
+
import { CATALOG_COLLECTIONS } from '../node-types';
|
|
6
7
|
import {
|
|
7
8
|
ADD_TO_CANVAS_DESCRIPTION,
|
|
8
|
-
CATALOG_COLLECTIONS,
|
|
9
9
|
describeCanvas,
|
|
10
10
|
edgeSpecsSchema,
|
|
11
11
|
nodeSpecsSchema,
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { useLayoutEffect, type RefObject } from 'react';
|
|
2
|
+
import { useStoreApi } from '@xyflow/react';
|
|
3
|
+
|
|
4
|
+
/** 1 / zoom, for things drawn in the canvas's coordinates that stay the same size on screen (`scale(var(...))`) */
|
|
5
|
+
export const COUNTER_SCALE = '--studio-counter-scale';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Keeps COUNTER_SCALE on an element as the canvas zooms, outside React: what's counter-scaled with it (e.g. comment
|
|
9
|
+
* pins) stays the same size on screen without re-rendering on every frame of a zoom
|
|
10
|
+
*/
|
|
11
|
+
export function useCounterScale(ref: RefObject<HTMLElement | null>) {
|
|
12
|
+
const store = useStoreApi();
|
|
13
|
+
useLayoutEffect(() => {
|
|
14
|
+
const apply = (zoom: number) => ref.current?.style.setProperty(COUNTER_SCALE, String(1 / zoom));
|
|
15
|
+
apply(store.getState().transform[2]);
|
|
16
|
+
return store.subscribe((state, previous) => {
|
|
17
|
+
if (state.transform[2] !== previous.transform[2]) apply(state.transform[2]);
|
|
18
|
+
});
|
|
19
|
+
}, [store, ref]);
|
|
20
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef } from 'react';
|
|
2
2
|
import { getViewportForBounds, useReactFlow, useStore, type Rect } from '@xyflow/react';
|
|
3
|
+
import { DIAGRAM_FIT_VIEW_OPTIONS } from '@eventcatalog/visualiser';
|
|
3
4
|
import type { Peer } from './presence-store';
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -30,10 +31,13 @@ const inView = (rect: Rect, view: Rect, margin: number) =>
|
|
|
30
31
|
|
|
31
32
|
export function useFollowCamera({
|
|
32
33
|
enabled,
|
|
34
|
+
fitsContent,
|
|
33
35
|
agentsWorking,
|
|
34
36
|
getAgents,
|
|
35
37
|
}: {
|
|
36
38
|
enabled: boolean;
|
|
39
|
+
/** Whether it fits the canvas's content when it arrives (the canvas people edit, not a level, fitted on its own) */
|
|
40
|
+
fitsContent: boolean;
|
|
37
41
|
/** Whether any agent is working on the canvas (has a pointer on it) */
|
|
38
42
|
agentsWorking: boolean;
|
|
39
43
|
/** The agents working, with where their pointers are now (read when the camera checks, not on every move) */
|
|
@@ -61,11 +65,12 @@ export function useFollowCamera({
|
|
|
61
65
|
hasFitContent.current = false;
|
|
62
66
|
return;
|
|
63
67
|
}
|
|
64
|
-
if (hasFitContent.current || !width) return;
|
|
68
|
+
if (hasFitContent.current || !width || !fitsContent) return;
|
|
65
69
|
hasFitContent.current = true;
|
|
66
|
-
|
|
70
|
+
// Fitted like the visualiser fits a diagram
|
|
71
|
+
const frame = requestAnimationFrame(() => void fitView({ ...DIAGRAM_FIT_VIEW_OPTIONS, duration: 300 }));
|
|
67
72
|
return () => cancelAnimationFrame(frame);
|
|
68
|
-
}, [hasNodes, width]);
|
|
73
|
+
}, [hasNodes, width, fitsContent]);
|
|
69
74
|
|
|
70
75
|
const follow = useCallback(() => {
|
|
71
76
|
const { enabled, getAgents } = latest.current;
|
|
@@ -25,7 +25,9 @@ import {
|
|
|
25
25
|
fitGroupToChildren,
|
|
26
26
|
getCanvasMaps,
|
|
27
27
|
moveNode,
|
|
28
|
+
placeNode,
|
|
28
29
|
changesNodesOrEdges,
|
|
30
|
+
getStructureKey,
|
|
29
31
|
getCanvasStatus,
|
|
30
32
|
readMeta,
|
|
31
33
|
reopenIfDecided,
|
|
@@ -34,6 +36,10 @@ import {
|
|
|
34
36
|
setMeta,
|
|
35
37
|
type Author,
|
|
36
38
|
type CanvasStatus,
|
|
39
|
+
type DiagramLevels,
|
|
40
|
+
type LevelPlace,
|
|
41
|
+
readLevelLayout,
|
|
42
|
+
setLevelPlaces,
|
|
37
43
|
type StatusChange,
|
|
38
44
|
setNodeParent,
|
|
39
45
|
type RenderedSizes,
|
|
@@ -41,7 +47,7 @@ import {
|
|
|
41
47
|
} from '../canvas-doc';
|
|
42
48
|
import { applyLayout } from '../agent-choreography';
|
|
43
49
|
import { keepDragged, LOCAL_EDGE_KEYS, LOCAL_NODE_KEYS, patchShared, withPositions } from '../flow-state';
|
|
44
|
-
import {
|
|
50
|
+
import { containerFor, toRelativePosition, type NodePreview } from '../grouping';
|
|
45
51
|
import type { LayoutResult } from '../layout';
|
|
46
52
|
import { canGoInContainer, getNodeDefinition, isGroupType } from '../node-types';
|
|
47
53
|
import { startToolSync, type ToolSync } from '../tool-sync';
|
|
@@ -67,6 +73,10 @@ const LOCAL_ORIGIN = Symbol('studio-local');
|
|
|
67
73
|
export const BROWSER_AGENT_ORIGIN = Symbol('studio-browser-agent');
|
|
68
74
|
/** Status changes: synced, but not undone (undo takes back edits, not decisions about the canvas) */
|
|
69
75
|
const STATUS_ORIGIN = Symbol('studio-status');
|
|
76
|
+
/** Where L1 and L2 put what's new on them, kept so it stays there: synced, not undone (nobody did it) */
|
|
77
|
+
const LEVEL_PLACEMENT_ORIGIN = Symbol('studio-level-placement');
|
|
78
|
+
|
|
79
|
+
const NO_LEVEL_LAYOUTS = { 1: new Map(), 2: new Map() } as const;
|
|
70
80
|
|
|
71
81
|
export type Status = 'connecting' | 'connected' | 'disconnected';
|
|
72
82
|
export type Transport = 'websocket' | 'tools';
|
|
@@ -121,6 +131,13 @@ export function useStudioFlow({
|
|
|
121
131
|
const [doc, setDoc] = useState<Y.Doc | null>(null);
|
|
122
132
|
const [history, setHistory] = useState({ canUndo: false, canRedo: false });
|
|
123
133
|
const [title, setTitle] = useState<string | undefined>();
|
|
134
|
+
// L1 and L2 of the diagram the canvas was opened from, if it was
|
|
135
|
+
const [diagramLevels, setDiagramLevels] = useState<DiagramLevels | undefined>();
|
|
136
|
+
// What's on the canvas and how it's connected (getStructureKey): worked out when the document's nodes or edges
|
|
137
|
+
// change, which drags don't do (they're in presence until the drop)
|
|
138
|
+
const [structureKey, setStructureKey] = useState('');
|
|
139
|
+
// Where things are on L1 and L2, as people arranged them
|
|
140
|
+
const [levelLayouts, setLevelLayouts] = useState<Record<1 | 2, ReadonlyMap<string, LevelPlace>>>(NO_LEVEL_LAYOUTS);
|
|
124
141
|
const [canvasStatus, setCanvasStatusState] = useState<{ status: CanvasStatus; history: StatusChange[] }>(NEW_CANVAS_STATUS);
|
|
125
142
|
const [transport, setTransport] = useState<Transport>('websocket');
|
|
126
143
|
// Someone deleted the canvas: the server closed it for everyone, and won't open it again
|
|
@@ -139,16 +156,29 @@ export function useStudioFlow({
|
|
|
139
156
|
setEdges([]);
|
|
140
157
|
|
|
141
158
|
const doc = new Y.Doc();
|
|
142
|
-
const { nodes: yNodes, edges: yEdges, threads: yThreads, meta: yMeta } = getCanvasMaps(doc);
|
|
159
|
+
const { nodes: yNodes, edges: yEdges, threads: yThreads, meta: yMeta, levelLayouts: yLevelLayouts } = getCanvasMaps(doc);
|
|
160
|
+
const syncLevelLayouts = (event: Y.YMapEvent<LevelPlace>) => {
|
|
161
|
+
const changed = new Set([...event.keysChanged].map((key) => Number(key.split(':')[0]) as 1 | 2));
|
|
162
|
+
setLevelLayouts((current) => {
|
|
163
|
+
const next = { ...current };
|
|
164
|
+
changed.forEach((level) => (next[level] = readLevelLayout(doc, level)));
|
|
165
|
+
return next;
|
|
166
|
+
});
|
|
167
|
+
};
|
|
168
|
+
yLevelLayouts.observe(syncLevelLayouts);
|
|
169
|
+
setLevelLayouts(NO_LEVEL_LAYOUTS);
|
|
143
170
|
const syncMeta = (event: Y.YMapEvent<unknown>) => {
|
|
144
171
|
const meta = readMeta(doc);
|
|
145
172
|
setTitle(meta.title);
|
|
173
|
+
if (event.keysChanged.has('diagramLevels')) setDiagramLevels(meta.diagramLevels);
|
|
146
174
|
if (event.keysChanged.has('status') || event.keysChanged.has('statusHistory'))
|
|
147
175
|
setCanvasStatusState({ status: getCanvasStatus(meta), history: meta.statusHistory ?? [] });
|
|
148
176
|
if (event.keysChanged.has('layoutAt')) onLayoutRef.current?.();
|
|
149
177
|
};
|
|
150
178
|
yMeta.observe(syncMeta);
|
|
151
179
|
setTitle(undefined);
|
|
180
|
+
setStructureKey('');
|
|
181
|
+
setDiagramLevels(undefined);
|
|
152
182
|
setCanvasStatusState(NEW_CANVAS_STATUS);
|
|
153
183
|
// Changing an accepted (or rejected) canvas here makes it a draft again, for everyone. Changes from others arrive
|
|
154
184
|
// already reopened by whoever made them.
|
|
@@ -169,6 +199,12 @@ export function useStudioFlow({
|
|
|
169
199
|
);
|
|
170
200
|
};
|
|
171
201
|
doc.on('afterTransaction', reopenOnEdit);
|
|
202
|
+
const syncStructure = (transaction: Y.Transaction) => {
|
|
203
|
+
// Drops and resizes here only move and size things (putting something in a container isn't LOCAL_ORIGIN)
|
|
204
|
+
if (transaction.origin !== LOCAL_ORIGIN && changesNodesOrEdges(transaction))
|
|
205
|
+
setStructureKey(getStructureKey(Array.from(yNodes.values()), Array.from(yEdges.values())));
|
|
206
|
+
};
|
|
207
|
+
doc.on('afterTransaction', syncStructure);
|
|
172
208
|
// One presence for whichever connection is used
|
|
173
209
|
const awareness = new Awareness(doc);
|
|
174
210
|
awareness.setLocalStateField('user', user.current);
|
|
@@ -249,7 +285,7 @@ export function useStudioFlow({
|
|
|
249
285
|
// A socket that isn't let in within a few seconds (blocked, or never answered) is given up for tool calls
|
|
250
286
|
if (syncViaTools) fallback = setTimeout(() => !socketConnected && syncThroughTools(), SOCKET_TIMEOUT_MS);
|
|
251
287
|
// Undo only takes back your own changes: other people's arrive with the provider as origin, agents' with theirs
|
|
252
|
-
const undoManager = new Y.UndoManager([yNodes, yEdges, yThreads], {
|
|
288
|
+
const undoManager = new Y.UndoManager([yNodes, yEdges, yThreads, yLevelLayouts], {
|
|
253
289
|
captureTimeout: 500,
|
|
254
290
|
trackedOrigins: new Set([null, LOCAL_ORIGIN]),
|
|
255
291
|
});
|
|
@@ -329,9 +365,11 @@ export function useStudioFlow({
|
|
|
329
365
|
sender.destroy();
|
|
330
366
|
stopToolSync?.();
|
|
331
367
|
yNodes.unobserve(syncNodes);
|
|
368
|
+
yLevelLayouts.unobserve(syncLevelLayouts);
|
|
332
369
|
yEdges.unobserve(syncEdges);
|
|
333
370
|
yMeta.unobserve(syncMeta);
|
|
334
371
|
doc.off('afterTransaction', reopenOnEdit);
|
|
372
|
+
doc.off('afterTransaction', syncStructure);
|
|
335
373
|
presenceStore.destroy();
|
|
336
374
|
undoManager.destroy();
|
|
337
375
|
provider.destroy();
|
|
@@ -384,10 +422,10 @@ export function useStudioFlow({
|
|
|
384
422
|
dragged.current.forEach((position, id) => moveNode(doc, id, position));
|
|
385
423
|
// Containers as they grew around what was dragged, and what else is in them (moved the other way if they grew up
|
|
386
424
|
// or left). Dragged nodes are where React Flow dropped them, already in their containers' grown coordinates.
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
425
|
+
// Only what actually moved or grew is written (the preview has everything in those containers)
|
|
426
|
+
dragPreview.current.forEach(({ position, width, height }, id) =>
|
|
427
|
+
placeNode(doc, id, position, width !== undefined && height !== undefined ? { width, height } : undefined)
|
|
428
|
+
);
|
|
391
429
|
dragPreview.current = new Map();
|
|
392
430
|
dragged.current.clear();
|
|
393
431
|
const shared = sharedRef.current;
|
|
@@ -474,12 +512,18 @@ export function useStudioFlow({
|
|
|
474
512
|
* on the canvas, and returns edges to add with it (e.g. a catalog resource's relationships).
|
|
475
513
|
*/
|
|
476
514
|
const insertNode = useCallback(
|
|
477
|
-
(
|
|
515
|
+
(
|
|
516
|
+
type: string,
|
|
517
|
+
data: Record<string, unknown>,
|
|
518
|
+
center: XYPosition,
|
|
519
|
+
connect?: (id: string, existing: Node[]) => Edge[],
|
|
520
|
+
inside?: string
|
|
521
|
+
) => {
|
|
478
522
|
const built = buildNode(type, data, center);
|
|
479
523
|
withDoc((doc) => {
|
|
480
524
|
const existing = Array.from(getCanvasMaps(doc).nodes.values());
|
|
481
|
-
//
|
|
482
|
-
const group = canGoInContainer(type) ?
|
|
525
|
+
// In the container asked for, else the one it's dropped on
|
|
526
|
+
const group = canGoInContainer(type) ? containerFor(center, existing, inside) : undefined;
|
|
483
527
|
const node = group
|
|
484
528
|
? { ...built, parentId: group.id, position: toRelativePosition(built.position, group.id, existing) }
|
|
485
529
|
: built;
|
|
@@ -491,11 +535,11 @@ export function useStudioFlow({
|
|
|
491
535
|
[]
|
|
492
536
|
);
|
|
493
537
|
|
|
494
|
-
/** Add a node of a registry type, centred on a canvas position */
|
|
538
|
+
/** Add a node of a registry type, centred on a canvas position (in a container, if asked) */
|
|
495
539
|
const addNode = useCallback(
|
|
496
|
-
(type: string, center: XYPosition) => {
|
|
540
|
+
(type: string, center: XYPosition, inside?: string) => {
|
|
497
541
|
const definition = getNodeDefinition(type);
|
|
498
|
-
return definition ? insertNode(type, definition.createData(), center) : undefined;
|
|
542
|
+
return definition ? insertNode(type, definition.createData(), center, undefined, inside) : undefined;
|
|
499
543
|
},
|
|
500
544
|
[insertNode]
|
|
501
545
|
);
|
|
@@ -510,6 +554,12 @@ export function useStudioFlow({
|
|
|
510
554
|
|
|
511
555
|
/** Moves nodes (and resizes containers) from a layout in one change, so it undoes in one go */
|
|
512
556
|
const applyPositions = useCallback((layout: LayoutResult) => withDoc((doc) => applyLayout(doc, layout)), []);
|
|
557
|
+
/** Puts things where they go on L1 or L2: moved there by someone (undone like any edit), or placed by the level */
|
|
558
|
+
const setLevelLayout = useCallback(
|
|
559
|
+
(level: 1 | 2, places: ReadonlyMap<string, LevelPlace>, by: 'person' | 'level' = 'person') =>
|
|
560
|
+
withDoc((doc) => setLevelPlaces(doc, level, places), by === 'level' ? LEVEL_PLACEMENT_ORIGIN : null),
|
|
561
|
+
[]
|
|
562
|
+
);
|
|
513
563
|
|
|
514
564
|
/** Adds ready-made nodes and edges (e.g. a container with what's in it) in one change */
|
|
515
565
|
const insertNodes = useCallback(
|
|
@@ -572,9 +622,13 @@ export function useStudioFlow({
|
|
|
572
622
|
updateNodeData,
|
|
573
623
|
deleteNodes,
|
|
574
624
|
applyPositions,
|
|
625
|
+
levelLayouts,
|
|
626
|
+
setLevelLayout,
|
|
575
627
|
insertNodes,
|
|
576
628
|
setParent,
|
|
577
629
|
title,
|
|
630
|
+
diagramLevels,
|
|
631
|
+
structureKey,
|
|
578
632
|
rename,
|
|
579
633
|
canvasStatus,
|
|
580
634
|
changeStatus,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Edge, Node, XYPosition } from '@xyflow/react';
|
|
2
2
|
import { layoutWithElk } from '@eventcatalog/visualiser/layout';
|
|
3
|
+
import { getEdgeRoute, type EdgeRoute } from './canvas-doc';
|
|
3
4
|
import { isGroupType, isOnLevel } from './node-types';
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -17,26 +18,39 @@ export const withGroupSizes = (nodes: Node[]) =>
|
|
|
17
18
|
return { ...node, width, height, style };
|
|
18
19
|
});
|
|
19
20
|
|
|
20
|
-
export type LayoutResult =
|
|
21
|
+
export type LayoutResult = {
|
|
22
|
+
/** Where each node goes (relative to its container, if it's in one), and containers' new sizes */
|
|
23
|
+
nodes: Map<string, { position: XYPosition; size?: { width: number; height: number } }>;
|
|
24
|
+
/** How each connection is drawn (around the nodes), by edge id */
|
|
25
|
+
routes: Map<string, EdgeRoute>;
|
|
26
|
+
};
|
|
21
27
|
|
|
22
28
|
/**
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
29
|
+
* The canvas laid out: new positions for its nodes, new sizes for containers, and routes for the connections
|
|
30
|
+
* (drawn like the visualiser's: around the nodes, following them when they're moved, and as a plain step once
|
|
31
|
+
* they've moved too far for it). Notes added on L1 or L2 aren't on this canvas, so they stay where they are.
|
|
26
32
|
*/
|
|
27
33
|
export const getLayoutPositions = async (nodes: Node[], edges: Edge[]): Promise<LayoutResult> => {
|
|
28
34
|
const onCanvas = nodes.filter((node) => isOnLevel(node, 3));
|
|
29
|
-
if (onCanvas.length === 0) return new Map();
|
|
35
|
+
if (onCanvas.length === 0) return { nodes: new Map(), routes: new Map() };
|
|
30
36
|
const ids = new Set(onCanvas.map((node) => node.id));
|
|
31
37
|
const between = edges.filter((edge) => ids.has(edge.source) && ids.has(edge.target));
|
|
32
|
-
const laidOut =
|
|
33
|
-
return
|
|
34
|
-
|
|
35
|
-
node
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
38
|
+
const laidOut = await layoutGraph(onCanvas, between);
|
|
39
|
+
return {
|
|
40
|
+
nodes: new Map(
|
|
41
|
+
withGroupSizes(laidOut.nodes).map((node) => [
|
|
42
|
+
node.id,
|
|
43
|
+
{
|
|
44
|
+
position: node.position,
|
|
45
|
+
...(isGroupType(node.type) && node.width && node.height && { size: { width: node.width, height: node.height } }),
|
|
46
|
+
},
|
|
47
|
+
])
|
|
48
|
+
),
|
|
49
|
+
routes: new Map(
|
|
50
|
+
laidOut.edges.flatMap((edge) => {
|
|
51
|
+
const route = getEdgeRoute(edge);
|
|
52
|
+
return route ? [[edge.id, route] as const] : [];
|
|
53
|
+
})
|
|
54
|
+
),
|
|
55
|
+
};
|
|
42
56
|
};
|