@eventcatalog/core 5.0.0-beta.7 → 5.0.0-beta.9
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/LICENSE +227 -128
- package/dist/analytics/analytics.cjs +18 -3
- package/dist/analytics/analytics.d.cts +1 -1
- package/dist/analytics/analytics.d.ts +1 -1
- package/dist/analytics/analytics.js +3 -2
- package/dist/analytics/log-build.cjs +70 -34
- package/dist/analytics/log-build.js +5 -4
- package/dist/analytics/telemetry.cjs +42 -0
- package/dist/analytics/telemetry.d.cts +20 -0
- package/dist/analytics/telemetry.d.ts +20 -0
- package/dist/analytics/telemetry.js +8 -0
- package/dist/{chunk-3VBB2ZMP.js → chunk-4CFZUHGG.js} +9 -3
- package/dist/{chunk-PIYJM642.js → chunk-AWOO63ME.js} +70 -44
- package/dist/chunk-CPW6G62V.js +17 -0
- package/dist/{chunk-MDWB5HLO.js → chunk-ENAG6UH7.js} +5 -0
- package/dist/{chunk-FARBIZ7Y.js → chunk-EUNQJO3J.js} +1 -1
- package/dist/{chunk-S7ODHVII.js → chunk-GAHKCOQM.js} +1 -1
- package/dist/{chunk-BOKAG5XC.js → chunk-JQMYHZWI.js} +58 -35
- package/dist/{chunk-SJJKWZYZ.js → chunk-TZVDMWQX.js} +1 -1
- package/dist/constants.cjs +1 -1
- package/dist/constants.js +1 -1
- package/dist/eventcatalog.cjs +207 -123
- package/dist/eventcatalog.config.d.cts +55 -0
- package/dist/eventcatalog.config.d.ts +55 -0
- package/dist/eventcatalog.js +39 -19
- package/dist/features.cjs +8 -2
- package/dist/features.d.cts +3 -1
- package/dist/features.d.ts +3 -1
- package/dist/features.js +5 -3
- package/dist/federation/federate.js +4 -4
- 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/dist/utils/license-status.cjs +70 -44
- package/dist/utils/license-status.d.cts +14 -5
- package/dist/utils/license-status.d.ts +14 -5
- package/dist/utils/license-status.js +1 -1
- package/eventcatalog/integrations/studio-server.mjs +15 -3
- package/eventcatalog/src/components/Header.astro +3 -3
- package/eventcatalog/src/features/integrations/eventcatalog-features.ts +10 -2
- package/eventcatalog/src/features/mcp/apps/canvas/shared.ts +9 -1
- package/eventcatalog/src/features/mcp/apps/canvas/view.tsx +9 -2
- package/eventcatalog/src/features/mcp/apps/generated/canvas.html +70 -69
- package/eventcatalog/src/features/mcp/apps/generated/viewer.html +69 -69
- package/eventcatalog/src/features/mcp/mcp-server.ts +5 -3
- package/eventcatalog/src/features/studio/agent-choreography.ts +8 -5
- package/eventcatalog/src/features/studio/canvas-actions.ts +148 -21
- package/eventcatalog/src/features/studio/canvas-doc.ts +138 -9
- package/eventcatalog/src/features/studio/canvas-preview.ts +53 -0
- package/eventcatalog/src/features/studio/catalog-resources.ts +5 -2
- package/eventcatalog/src/features/studio/clipboard.ts +97 -0
- package/eventcatalog/src/features/studio/components/CanvasControls.tsx +4 -3
- package/eventcatalog/src/features/studio/components/CanvasHeader.tsx +39 -22
- package/eventcatalog/src/features/studio/components/CanvasStatusMenu.tsx +117 -0
- package/eventcatalog/src/features/studio/components/Comments.tsx +345 -30
- package/eventcatalog/src/features/studio/components/ConnectAgentDialog.tsx +140 -76
- package/eventcatalog/src/features/studio/components/ConnectionsChooser.tsx +144 -0
- package/eventcatalog/src/features/studio/components/ContainerChooser.tsx +1 -1
- package/eventcatalog/src/features/studio/components/DragGhost.tsx +115 -0
- package/eventcatalog/src/features/studio/components/JoinForm.tsx +0 -24
- package/eventcatalog/src/features/studio/components/LeftPanel.tsx +131 -35
- package/eventcatalog/src/features/studio/components/Picture.tsx +21 -0
- package/eventcatalog/src/features/studio/components/Presence.tsx +2 -0
- package/eventcatalog/src/features/studio/components/PropertiesPanel.tsx +187 -50
- package/eventcatalog/src/features/studio/components/ShareDialog.tsx +260 -0
- package/eventcatalog/src/features/studio/components/StudioDesigner.tsx +442 -40
- package/eventcatalog/src/features/studio/components/canvas-nodes.tsx +198 -22
- package/eventcatalog/src/features/studio/components/icons.ts +6 -1
- package/eventcatalog/src/features/studio/components/status.ts +30 -1
- package/eventcatalog/src/features/studio/edges.ts +2 -0
- package/eventcatalog/src/features/studio/grouping.ts +95 -2
- package/eventcatalog/src/features/studio/hooks/presence-store.ts +5 -1
- package/eventcatalog/src/features/studio/hooks/use-canvas-webmcp.ts +13 -3
- package/eventcatalog/src/features/studio/hooks/use-comments.ts +8 -1
- package/eventcatalog/src/features/studio/hooks/use-follow-camera.ts +7 -2
- package/eventcatalog/src/features/studio/hooks/use-studio-flow.ts +174 -27
- package/eventcatalog/src/features/studio/identity.ts +35 -0
- package/eventcatalog/src/features/studio/layout.ts +7 -4
- package/eventcatalog/src/features/studio/levels.ts +138 -72
- package/eventcatalog/src/features/studio/node-types.ts +35 -3
- package/eventcatalog/src/features/studio/pages/[id].astro +7 -2
- package/eventcatalog/src/features/studio/pages/index.astro +555 -2
- package/eventcatalog/src/features/studio/pages/new.astro +24 -0
- package/eventcatalog/src/features/studio/pages/wake.ts +15 -0
- package/eventcatalog/src/features/studio/server/canvas-mcp.ts +48 -13
- package/eventcatalog/src/features/studio/server/runtime.ts +193 -14
- package/eventcatalog/src/features/studio/server/sign-in.ts +18 -0
- package/eventcatalog/src/features/studio/server/storage.ts +110 -0
- package/eventcatalog/src/features/studio/studio.css +184 -0
- package/eventcatalog/src/layouts/VerticalSideBarLayout.astro +1 -1
- package/eventcatalog/src/utils/feature.ts +7 -3
- package/eventcatalog/src/utils/node-graphs/export-mermaid.ts +0 -2
- package/package.json +3 -3
- package/dist/{chunk-AYNZXUB5.js → chunk-ZEY2XUJN.js} +7 -7
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { createContext, memo, useCallback, useContext } from 'react';
|
|
2
|
-
import { NodeResizer, type NodeProps, type NodeTypes } from '@xyflow/react';
|
|
3
|
-
import { DomainCardNode,
|
|
4
|
-
import {
|
|
1
|
+
import { createContext, memo, useCallback, useContext, useRef, useState } from 'react';
|
|
2
|
+
import { NodeResizeControl, NodeResizer, ResizeControlVariant, useStoreApi, type NodeProps, type NodeTypes } from '@xyflow/react';
|
|
3
|
+
import { DomainCardNode, nodeComponents, SystemGroupNode } from '@eventcatalog/visualiser';
|
|
4
|
+
import { GROUP_PADDING } from '../grouping';
|
|
5
|
+
import { getNodeName, GROUP_TYPES, nodeDefinitions } from '../node-types';
|
|
5
6
|
|
|
6
|
-
/** How the canvas draws each kind of node: the visualiser's nodes,
|
|
7
|
+
/** How the canvas draws each kind of node: the visualiser's nodes, notes and text written on the canvas, and resizable containers */
|
|
7
8
|
|
|
8
9
|
export type UpdateNodeData = (id: string, updater: (data: Record<string, unknown>) => Record<string, unknown>) => void;
|
|
9
10
|
|
|
@@ -18,17 +19,152 @@ export const DropTargetContext = createContext<string | null>(null);
|
|
|
18
19
|
const visualiserNodes = nodeComponents as unknown as NodeTypes;
|
|
19
20
|
const SystemGroupComponent: NodeTypes[string] = SystemGroupNode;
|
|
20
21
|
|
|
21
|
-
|
|
22
|
+
/** The handles that set a node's width, on either side (its height follows what's written in it) */
|
|
23
|
+
function WidthHandles() {
|
|
24
|
+
return (
|
|
25
|
+
<>
|
|
26
|
+
{/* Horizontal only: otherwise React Flow saves the height too, and it stops growing with what's written */}
|
|
27
|
+
<NodeResizeControl
|
|
28
|
+
position="left"
|
|
29
|
+
variant={ResizeControlVariant.Line}
|
|
30
|
+
resizeDirection="horizontal"
|
|
31
|
+
minWidth={80}
|
|
32
|
+
className="studio-width-handle"
|
|
33
|
+
/>
|
|
34
|
+
<NodeResizeControl
|
|
35
|
+
position="right"
|
|
36
|
+
variant={ResizeControlVariant.Line}
|
|
37
|
+
resizeDirection="horizontal"
|
|
38
|
+
minWidth={80}
|
|
39
|
+
className="studio-width-handle"
|
|
40
|
+
/>
|
|
41
|
+
</>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* What's written on a note or text, and writing it: Enter starts a new line, Escape or ⌘/Ctrl + Enter (or clicking
|
|
47
|
+
* away) finishes, and it's saved for everyone then
|
|
48
|
+
*/
|
|
49
|
+
function Writing({
|
|
50
|
+
id,
|
|
51
|
+
text,
|
|
52
|
+
editing,
|
|
53
|
+
onDone,
|
|
54
|
+
placeholder,
|
|
55
|
+
className,
|
|
56
|
+
}: {
|
|
57
|
+
id: string;
|
|
58
|
+
text: string;
|
|
59
|
+
editing: boolean;
|
|
60
|
+
onDone: () => void;
|
|
61
|
+
placeholder: string;
|
|
62
|
+
className: string;
|
|
63
|
+
}) {
|
|
22
64
|
const updateNodeData = useContext(UpdateNodeDataContext);
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
65
|
+
// The box grows with what's written (the node's height follows it)
|
|
66
|
+
const fit = (area: HTMLTextAreaElement) => {
|
|
67
|
+
area.style.height = 'auto';
|
|
68
|
+
area.style.height = `${area.scrollHeight}px`;
|
|
69
|
+
};
|
|
70
|
+
if (!editing) return <p className={className}>{text || <span className="opacity-50">{placeholder}</span>}</p>;
|
|
71
|
+
return (
|
|
72
|
+
<textarea
|
|
73
|
+
ref={(area) => {
|
|
74
|
+
if (area) fit(area);
|
|
75
|
+
}}
|
|
76
|
+
autoFocus
|
|
77
|
+
defaultValue={text}
|
|
78
|
+
placeholder={placeholder}
|
|
79
|
+
onFocus={(event) => event.currentTarget.select()}
|
|
80
|
+
onInput={(event) => fit(event.currentTarget)}
|
|
81
|
+
onBlur={(event) => {
|
|
82
|
+
const value = event.currentTarget.value;
|
|
83
|
+
onDone();
|
|
84
|
+
if (value !== text) updateNodeData(id, (current) => ({ ...current, text: value }));
|
|
85
|
+
}}
|
|
86
|
+
onKeyDown={(event) => {
|
|
87
|
+
if (event.key === 'Escape' || (event.key === 'Enter' && (event.metaKey || event.ctrlKey))) event.currentTarget.blur();
|
|
88
|
+
}}
|
|
89
|
+
className={`nodrag nopan nowheel block w-full resize-none overflow-hidden bg-transparent outline-none ${className}`}
|
|
90
|
+
/>
|
|
26
91
|
);
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** A sticky note's colours: soft paper colours, readable in light and dark themes (the names are the visualiser's) */
|
|
95
|
+
const NOTE_COLORS: Record<string, { fill: string; border: string }> = {
|
|
96
|
+
yellow: { fill: '#fef9c3', border: '#fde047' },
|
|
97
|
+
blue: { fill: '#dbeafe', border: '#93c5fd' },
|
|
98
|
+
green: { fill: '#dcfce7', border: '#86efac' },
|
|
99
|
+
pink: { fill: '#fce7f3', border: '#f9a8d4' },
|
|
100
|
+
purple: { fill: '#ede9fe', border: '#c4b5fd' },
|
|
101
|
+
gray: { fill: '#f3f4f6', border: '#d1d5db' },
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* A sticky note: a few lines on paper, about as wide as a card and as tall as what's on it. Double click it to
|
|
106
|
+
* write; when it's selected, its colour can be changed and its width set.
|
|
107
|
+
*/
|
|
108
|
+
const StickyNote = memo(function StickyNote({ id, data, selected }: NodeProps) {
|
|
109
|
+
const updateNodeData = useContext(UpdateNodeDataContext);
|
|
110
|
+
const [editing, setEditing] = useState(false);
|
|
111
|
+
const colorName = typeof data.color === 'string' && NOTE_COLORS[data.color] ? data.color : 'yellow';
|
|
112
|
+
const color = NOTE_COLORS[colorName];
|
|
113
|
+
return (
|
|
114
|
+
<div
|
|
115
|
+
className="studio-note"
|
|
116
|
+
style={{ background: color.fill, borderColor: color.border }}
|
|
117
|
+
onDoubleClick={() => setEditing(true)}
|
|
118
|
+
>
|
|
119
|
+
{selected && !editing && (
|
|
120
|
+
<>
|
|
121
|
+
<WidthHandles />
|
|
122
|
+
<div className="nodrag nopan studio-note-colors" role="radiogroup" aria-label="Note colour">
|
|
123
|
+
{Object.entries(NOTE_COLORS).map(([name, { fill, border }]) => (
|
|
124
|
+
<button
|
|
125
|
+
key={name}
|
|
126
|
+
role="radio"
|
|
127
|
+
aria-checked={name === colorName}
|
|
128
|
+
title={name.charAt(0).toUpperCase() + name.slice(1)}
|
|
129
|
+
onClick={() => updateNodeData(id, (current) => ({ ...current, color: name }))}
|
|
130
|
+
className="studio-note-color"
|
|
131
|
+
style={{ background: fill, borderColor: name === colorName ? 'rgb(var(--ec-page-text))' : border }}
|
|
132
|
+
/>
|
|
133
|
+
))}
|
|
134
|
+
</div>
|
|
135
|
+
</>
|
|
136
|
+
)}
|
|
137
|
+
<Writing
|
|
138
|
+
id={id}
|
|
139
|
+
text={String(data.text ?? '')}
|
|
140
|
+
editing={editing}
|
|
141
|
+
onDone={() => setEditing(false)}
|
|
142
|
+
placeholder="Double-click to write a note"
|
|
143
|
+
className="studio-note-body"
|
|
144
|
+
/>
|
|
145
|
+
</div>
|
|
146
|
+
);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Text written straight on the canvas (titles, labels, notes to self): double click it to write. Its width is set
|
|
151
|
+
* with the handles on either side; its height follows what's written.
|
|
152
|
+
*/
|
|
153
|
+
const TextNode = memo(function TextNode({ id, data, selected }: NodeProps) {
|
|
154
|
+
const [editing, setEditing] = useState(false);
|
|
155
|
+
return (
|
|
156
|
+
<div className="studio-text" onDoubleClick={() => setEditing(true)}>
|
|
157
|
+
{selected && !editing && <WidthHandles />}
|
|
158
|
+
<Writing
|
|
159
|
+
id={id}
|
|
160
|
+
text={String(data.text ?? '')}
|
|
161
|
+
editing={editing}
|
|
162
|
+
onDone={() => setEditing(false)}
|
|
163
|
+
placeholder="Double-click to write"
|
|
164
|
+
className="studio-text-body"
|
|
165
|
+
/>
|
|
166
|
+
</div>
|
|
30
167
|
);
|
|
31
|
-
return <Note {...props} onTextChange={onTextChange} onColorChange={onColorChange} />;
|
|
32
168
|
});
|
|
33
169
|
|
|
34
170
|
/**
|
|
@@ -38,10 +174,51 @@ const NoteNode = memo(function NoteNode(props: NodeProps<NoteNodeType>) {
|
|
|
38
174
|
*/
|
|
39
175
|
const GroupNode = memo(function GroupNode(props: NodeProps) {
|
|
40
176
|
const isDropTarget = useContext(DropTargetContext) === props.id;
|
|
177
|
+
const store = useStoreApi();
|
|
178
|
+
// Where what's inside has to stay (with room around it and for the header), in the container's parent's
|
|
179
|
+
// coordinates: found when a resize starts, so it can't be resized smaller than what's in it
|
|
180
|
+
const contents = useRef<{ left: number; top: number; right: number; bottom: number } | null>(null);
|
|
181
|
+
const onResizeStart = useCallback(() => {
|
|
182
|
+
const { nodeLookup } = store.getState();
|
|
183
|
+
const container = nodeLookup.get(props.id);
|
|
184
|
+
const children = [...nodeLookup.values()].filter((node) => node.parentId === props.id);
|
|
185
|
+
if (!container || children.length === 0) {
|
|
186
|
+
contents.current = null;
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
const { x, y } = container.position;
|
|
190
|
+
const size = (node: (typeof children)[number]) => ({
|
|
191
|
+
width: node.measured.width ?? node.width ?? 0,
|
|
192
|
+
height: node.measured.height ?? node.height ?? 0,
|
|
193
|
+
});
|
|
194
|
+
const own = size(container);
|
|
195
|
+
// A side already closer in than that (e.g. something placed in the padding) can still move out, just not in
|
|
196
|
+
contents.current = {
|
|
197
|
+
left: Math.max(x + Math.min(...children.map((node) => node.position.x)) - GROUP_PADDING.left, x),
|
|
198
|
+
top: Math.max(y + Math.min(...children.map((node) => node.position.y)) - GROUP_PADDING.top, y),
|
|
199
|
+
right: Math.min(
|
|
200
|
+
x + Math.max(...children.map((node) => node.position.x + size(node).width)) + GROUP_PADDING.right,
|
|
201
|
+
x + own.width
|
|
202
|
+
),
|
|
203
|
+
bottom: Math.min(
|
|
204
|
+
y + Math.max(...children.map((node) => node.position.y + size(node).height)) + GROUP_PADDING.bottom,
|
|
205
|
+
y + own.height
|
|
206
|
+
),
|
|
207
|
+
};
|
|
208
|
+
}, [store, props.id]);
|
|
209
|
+
const shouldResize = useCallback(
|
|
210
|
+
(_: unknown, { x, y, width, height }: { x: number; y: number; width: number; height: number }) => {
|
|
211
|
+
const bounds = contents.current;
|
|
212
|
+
return !bounds || (x <= bounds.left && y <= bounds.top && x + width >= bounds.right && y + height >= bounds.bottom);
|
|
213
|
+
},
|
|
214
|
+
[]
|
|
215
|
+
);
|
|
41
216
|
return (
|
|
42
217
|
<div style={{ width: '100%', height: '100%', position: 'relative' }}>
|
|
43
218
|
<NodeResizer
|
|
44
219
|
isVisible={props.selected}
|
|
220
|
+
onResizeStart={onResizeStart}
|
|
221
|
+
shouldResize={shouldResize}
|
|
45
222
|
minWidth={240}
|
|
46
223
|
minHeight={160}
|
|
47
224
|
lineClassName="!border-[rgb(var(--ec-accent))]"
|
|
@@ -50,14 +227,13 @@ const GroupNode = memo(function GroupNode(props: NodeProps) {
|
|
|
50
227
|
<SystemGroupComponent {...props} />
|
|
51
228
|
{/* The header (and the badge on its border) */}
|
|
52
229
|
<div className="ec-group-grip absolute inset-x-0 -top-3 h-[60px] cursor-grab" />
|
|
53
|
-
{
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
/>
|
|
230
|
+
{isDropTarget ? (
|
|
231
|
+
// What's dragged over it goes in it when it's dropped
|
|
232
|
+
<div className="ec-drop-target">
|
|
233
|
+
<span className="ec-drop-target-label">Drop to add to {getNodeName(props.type, props.data, 'this container')}</span>
|
|
234
|
+
</div>
|
|
235
|
+
) : (
|
|
236
|
+
props.selected && <div className="ec-group-selected" />
|
|
61
237
|
)}
|
|
62
238
|
</div>
|
|
63
239
|
);
|
|
@@ -67,7 +243,7 @@ export const nodeTypes: NodeTypes = {
|
|
|
67
243
|
...Object.fromEntries(
|
|
68
244
|
nodeDefinitions.map((definition) => [
|
|
69
245
|
definition.type,
|
|
70
|
-
definition.type === 'note' ?
|
|
246
|
+
definition.type === 'note' ? StickyNote : definition.type === 'text' ? TextNode : visualiserNodes[definition.type],
|
|
71
247
|
])
|
|
72
248
|
),
|
|
73
249
|
// Only dropped in from the catalog
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
Search,
|
|
12
12
|
ServerIcon,
|
|
13
13
|
StickyNote,
|
|
14
|
+
Type,
|
|
14
15
|
User,
|
|
15
16
|
Zap,
|
|
16
17
|
} from 'lucide-react';
|
|
@@ -22,17 +23,21 @@ export type NodeIcon = { icon: LucideIcon; className: string };
|
|
|
22
23
|
export const NODE_ICONS: Record<string, NodeIcon> = {
|
|
23
24
|
[GROUP_TYPES.domain]: { icon: Boxes, className: 'text-yellow-500' },
|
|
24
25
|
[GROUP_TYPES.system]: { icon: Group, className: 'text-violet-600' },
|
|
26
|
+
// Catalog domains and systems added as cards
|
|
27
|
+
'context-domain': { icon: Boxes, className: 'text-yellow-500' },
|
|
28
|
+
system: { icon: Group, className: 'text-violet-600' },
|
|
25
29
|
service: { icon: ServerIcon, className: 'text-pink-600' },
|
|
26
30
|
event: { icon: Zap, className: 'text-orange-600' },
|
|
27
31
|
command: { icon: Mail, className: 'text-blue-600' },
|
|
28
32
|
query: { icon: Search, className: 'text-green-600' },
|
|
29
33
|
channel: { icon: ArrowRightLeft, className: 'text-gray-600' },
|
|
30
34
|
data: { icon: Database, className: 'text-blue-600' },
|
|
31
|
-
view: { icon: MonitorSmartphone, className: 'text-
|
|
35
|
+
view: { icon: MonitorSmartphone, className: 'text-sky-600' },
|
|
32
36
|
externalSystem: { icon: Globe, className: 'text-purple-600' },
|
|
33
37
|
agent: { icon: Bot, className: 'text-violet-600' },
|
|
34
38
|
actor: { icon: User, className: 'text-sky-600' },
|
|
35
39
|
note: { icon: StickyNote, className: 'text-yellow-500' },
|
|
40
|
+
text: { icon: Type, className: 'text-[rgb(var(--ec-icon-color))]' },
|
|
36
41
|
};
|
|
37
42
|
|
|
38
43
|
/** The catalog's collections, in the order the catalog list shows them, with the icon of the node each becomes */
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { CanvasStatus } from '../canvas-doc';
|
|
1
2
|
import type { Status } from '../hooks/use-studio-flow';
|
|
2
3
|
|
|
3
4
|
/**
|
|
@@ -6,7 +7,7 @@ import type { Status } from '../hooks/use-studio-flow';
|
|
|
6
7
|
*/
|
|
7
8
|
export const STATUS = {
|
|
8
9
|
success: { text: 'text-[rgb(var(--ec-badge-color-green-text))]', dot: 'bg-[rgb(var(--ec-badge-color-green-text))]' },
|
|
9
|
-
warning: { dot: 'bg-[rgb(var(--ec-badge-color-yellow-text))]' },
|
|
10
|
+
warning: { text: 'text-[rgb(var(--ec-badge-color-yellow-text))]', dot: 'bg-[rgb(var(--ec-badge-color-yellow-text))]' },
|
|
10
11
|
danger: {
|
|
11
12
|
text: 'text-[rgb(var(--ec-badge-color-red-text))]',
|
|
12
13
|
dot: 'bg-[rgb(var(--ec-badge-color-red-text))]',
|
|
@@ -20,3 +21,31 @@ export const CONNECTION_DOT: Record<Status, string> = {
|
|
|
20
21
|
connecting: STATUS.warning.dot,
|
|
21
22
|
disconnected: STATUS.danger.dot,
|
|
22
23
|
};
|
|
24
|
+
|
|
25
|
+
/** How each canvas status looks and what it means (from the theme's badge colours) */
|
|
26
|
+
export const CANVAS_STATUS_LOOK: Record<CanvasStatus, { label: string; description: string; pill: string; dot: string }> = {
|
|
27
|
+
draft: {
|
|
28
|
+
label: 'Draft',
|
|
29
|
+
description: 'Being worked on',
|
|
30
|
+
pill: 'bg-[rgb(var(--ec-badge-color-gray-background))] text-[rgb(var(--ec-badge-color-gray-text))] border-[rgb(var(--ec-badge-color-gray-text)/0.25)]',
|
|
31
|
+
dot: 'bg-[rgb(var(--ec-badge-color-gray-text))]',
|
|
32
|
+
},
|
|
33
|
+
proposed: {
|
|
34
|
+
label: 'Proposed',
|
|
35
|
+
description: 'Ready for review',
|
|
36
|
+
pill: 'bg-[rgb(var(--ec-badge-color-yellow-background))] text-[rgb(var(--ec-badge-color-yellow-text))] border-[rgb(var(--ec-badge-color-yellow-text)/0.3)]',
|
|
37
|
+
dot: 'bg-[rgb(var(--ec-badge-color-yellow-text))]',
|
|
38
|
+
},
|
|
39
|
+
accepted: {
|
|
40
|
+
label: 'Accepted',
|
|
41
|
+
description: 'The agreed design',
|
|
42
|
+
pill: 'bg-[rgb(var(--ec-badge-color-green-background))] text-[rgb(var(--ec-badge-color-green-text))] border-[rgb(var(--ec-badge-color-green-text)/0.3)]',
|
|
43
|
+
dot: 'bg-[rgb(var(--ec-badge-color-green-text))]',
|
|
44
|
+
},
|
|
45
|
+
rejected: {
|
|
46
|
+
label: 'Rejected',
|
|
47
|
+
description: 'Decided against, kept for the record',
|
|
48
|
+
pill: 'bg-[rgb(var(--ec-badge-color-red-background))] text-[rgb(var(--ec-badge-color-red-text))] border-[rgb(var(--ec-badge-color-red-text)/0.3)]',
|
|
49
|
+
dot: 'bg-[rgb(var(--ec-badge-color-red-text))]',
|
|
50
|
+
},
|
|
51
|
+
};
|
|
@@ -24,6 +24,8 @@ export const getEdgeLabel = (sourceType = '', targetType = ''): string => {
|
|
|
24
24
|
const targetCollection = MESSAGE_COLLECTIONS[targetType];
|
|
25
25
|
|
|
26
26
|
if (PRODUCERS.includes(sourceType) && targetCollection) return PRODUCER_LABELS[targetCollection];
|
|
27
|
+
// One message leading to another (e.g. an event that causes a command)
|
|
28
|
+
if (sourceCollection && targetCollection) return 'triggers';
|
|
27
29
|
if (sourceCollection && targetType === 'channel') return 'sent to';
|
|
28
30
|
if (sourceCollection) return MESSAGE_LABELS[sourceCollection];
|
|
29
31
|
if (sourceType === 'channel') return 'routes to';
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Node, XYPosition } from '@xyflow/react';
|
|
2
|
-
import { getNodeSize, isGroupType } from './node-types';
|
|
2
|
+
import { canGoInContainer, getNodeSize, isGroupType } from './node-types';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Containers: domains and systems shown as boxes that other nodes sit in.
|
|
@@ -60,7 +60,8 @@ export const sortByHierarchy = (nodes: Node[]): Node[] => {
|
|
|
60
60
|
export const findDropTarget = (nodeId: string, nodes: Node[]): Node | undefined => {
|
|
61
61
|
const lookup = byId(nodes);
|
|
62
62
|
const node = lookup.get(nodeId);
|
|
63
|
-
|
|
63
|
+
// Sticky notes go anywhere, never in a container (one that's in one is taken out when it's next moved)
|
|
64
|
+
if (!node || !canGoInContainer(node.type)) return undefined;
|
|
64
65
|
const rect = getAbsoluteRect(node, lookup);
|
|
65
66
|
const center = { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
|
|
66
67
|
return nodes
|
|
@@ -131,3 +132,95 @@ export const fitGroup = (groupId: string, nodes: Node[]) => {
|
|
|
131
132
|
height: Math.max(current.height, Math.max(...rects.map((rect) => rect.y + rect.height)) + shift.y + GROUP_PADDING.bottom),
|
|
132
133
|
};
|
|
133
134
|
};
|
|
135
|
+
|
|
136
|
+
/** How far a dragged node's middle can go past a container's edge and still be kept in it (growing it) */
|
|
137
|
+
export const KEEP_IN_CONTAINER_MARGIN = 160;
|
|
138
|
+
|
|
139
|
+
type Size = { width: number; height: number };
|
|
140
|
+
/** A node's place and size as shown while something's dragged */
|
|
141
|
+
export type NodePreview = { position: XYPosition; width?: number; height?: number };
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Containers growing to fit what's being dragged in them, on every side, as a preview of where things will be.
|
|
145
|
+
* `containerIds` are the containers around what's dragged, innermost first. `start` is the canvas when the drag
|
|
146
|
+
* started, and `now` the canvas as shown (with the dragged nodes where they are now). A dragged node is kept in a
|
|
147
|
+
* container while its middle is within `KEEP_IN_CONTAINER_MARGIN` of where the container was, and dropping it
|
|
148
|
+
* further away takes it out, so the container goes back to its size. A container growing up or left moves, and
|
|
149
|
+
* what's in it moves the other way, so nothing moves on the canvas.
|
|
150
|
+
*
|
|
151
|
+
* Returns, by id, where containers and the nodes in them that aren't dragged go (positions relative to their
|
|
152
|
+
* containers), and their sizes for containers.
|
|
153
|
+
*/
|
|
154
|
+
export const previewContainerGrowth = (
|
|
155
|
+
containerIds: string[],
|
|
156
|
+
draggedIds: ReadonlySet<string>,
|
|
157
|
+
start: ReadonlyMap<string, Node>,
|
|
158
|
+
now: ReadonlyMap<string, Node>
|
|
159
|
+
): Map<string, NodePreview> => {
|
|
160
|
+
const previews = new Map<string, NodePreview>();
|
|
161
|
+
// Where each container's top left was on the canvas when the drag started (moving a container grown up or
|
|
162
|
+
// left is offset by what's in it moving the other way, so only its own shift changes it)
|
|
163
|
+
const startOrigin = (id: string) => getAbsolutePosition(start.get(id)!, start as Map<string, Node>);
|
|
164
|
+
const shifts = new Map<string, XYPosition>();
|
|
165
|
+
|
|
166
|
+
for (const id of containerIds) {
|
|
167
|
+
const container = start.get(id);
|
|
168
|
+
if (!container) continue;
|
|
169
|
+
const origin = startOrigin(id);
|
|
170
|
+
const startSize = sizeOf(container);
|
|
171
|
+
// Everything in it, in its coordinates when the drag started
|
|
172
|
+
const items: { rect: Rect }[] = [];
|
|
173
|
+
for (const node of start.values()) {
|
|
174
|
+
if (node.parentId !== id) continue;
|
|
175
|
+
if (draggedIds.has(node.id)) {
|
|
176
|
+
const current = now.get(node.id);
|
|
177
|
+
if (!current) continue;
|
|
178
|
+
const at = getAbsoluteRect(current, now as Map<string, Node>);
|
|
179
|
+
const middle = { x: at.x + at.width / 2, y: at.y + at.height / 2 };
|
|
180
|
+
const kept =
|
|
181
|
+
middle.x >= origin.x - KEEP_IN_CONTAINER_MARGIN &&
|
|
182
|
+
middle.x <= origin.x + startSize.width + KEEP_IN_CONTAINER_MARGIN &&
|
|
183
|
+
middle.y >= origin.y - KEEP_IN_CONTAINER_MARGIN &&
|
|
184
|
+
middle.y <= origin.y + startSize.height + KEEP_IN_CONTAINER_MARGIN;
|
|
185
|
+
if (kept) items.push({ rect: { ...at, x: at.x - origin.x, y: at.y - origin.y } });
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
// A container inside it that's grown: where it's grown to
|
|
189
|
+
const inner = previews.get(node.id);
|
|
190
|
+
const innerShift = shifts.get(node.id) ?? { x: 0, y: 0 };
|
|
191
|
+
items.push({
|
|
192
|
+
rect: {
|
|
193
|
+
x: node.position.x - innerShift.x,
|
|
194
|
+
y: node.position.y - innerShift.y,
|
|
195
|
+
...(inner?.width && inner.height ? { width: inner.width, height: inner.height } : sizeOf(node)),
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const shift = { x: 0, y: 0 };
|
|
201
|
+
let width = startSize.width;
|
|
202
|
+
let height = startSize.height;
|
|
203
|
+
if (items.length > 0) {
|
|
204
|
+
shift.x = Math.max(0, GROUP_PADDING.left - Math.min(...items.map(({ rect }) => rect.x)));
|
|
205
|
+
shift.y = Math.max(0, GROUP_PADDING.top - Math.min(...items.map(({ rect }) => rect.y)));
|
|
206
|
+
width =
|
|
207
|
+
Math.max(startSize.width, Math.max(...items.map(({ rect }) => rect.x + rect.width)) + GROUP_PADDING.right) + shift.x;
|
|
208
|
+
height =
|
|
209
|
+
Math.max(startSize.height, Math.max(...items.map(({ rect }) => rect.y + rect.height)) + GROUP_PADDING.bottom) + shift.y;
|
|
210
|
+
}
|
|
211
|
+
shifts.set(id, shift);
|
|
212
|
+
previews.set(id, {
|
|
213
|
+
position: { x: container.position.x - shift.x, y: container.position.y - shift.y },
|
|
214
|
+
width,
|
|
215
|
+
height,
|
|
216
|
+
});
|
|
217
|
+
// What's in it moves the other way. Not what's dragged: React Flow places it against the container as it is.
|
|
218
|
+
for (const node of start.values()) {
|
|
219
|
+
if (node.parentId !== id || draggedIds.has(node.id)) continue;
|
|
220
|
+
const own = previews.get(node.id);
|
|
221
|
+
const base = own?.position ?? node.position;
|
|
222
|
+
previews.set(node.id, { ...own, position: { x: base.x + shift.x, y: base.y + shift.y } });
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return previews;
|
|
226
|
+
};
|
|
@@ -6,6 +6,8 @@ export type Peer = {
|
|
|
6
6
|
clientId: number;
|
|
7
7
|
name: string;
|
|
8
8
|
color: string;
|
|
9
|
+
/** A picture from their sign-in provider (SSO), shown over their initials */
|
|
10
|
+
picture?: string;
|
|
9
11
|
/** An AI agent (joined over MCP, or working through this tab with WebMCP), rather than a person */
|
|
10
12
|
agent?: boolean;
|
|
11
13
|
/** What an agent is doing, e.g. "Adding OrderService" */
|
|
@@ -60,7 +62,9 @@ const toPeers = (awareness: Awareness): Peer[] => {
|
|
|
60
62
|
|
|
61
63
|
// What getPeople() changes on: who people are, what agents are doing, and whether they're on the canvas (a pointer)
|
|
62
64
|
const identity = (peer: Peer) =>
|
|
63
|
-
[peer.clientId, peer.name, peer.color, peer.agent ? 1 : 0, peer.activity ?? '', peer.pointer ? 1 : 0].join(
|
|
65
|
+
[peer.clientId, peer.name, peer.color, peer.picture ?? '', peer.agent ? 1 : 0, peer.activity ?? '', peer.pointer ? 1 : 0].join(
|
|
66
|
+
'|'
|
|
67
|
+
);
|
|
64
68
|
|
|
65
69
|
export function createPresenceStore(awareness: Awareness): PresenceStore {
|
|
66
70
|
const listeners = new Set<Listener>();
|
|
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react';
|
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import type * as Y from 'yjs';
|
|
4
4
|
import { initializeWebMCPPolyfill } from '@mcp-b/webmcp-polyfill';
|
|
5
|
-
import { replyToThread, setThreadResolved, type Author } from '../canvas-doc';
|
|
5
|
+
import { replyToThread, setThreadResolved, type Author, CANVAS_STATUSES, setCanvasStatus } from '../canvas-doc';
|
|
6
6
|
import {
|
|
7
7
|
ADD_TO_CANVAS_DESCRIPTION,
|
|
8
8
|
CATALOG_COLLECTIONS,
|
|
@@ -243,11 +243,12 @@ const createCanvasTools = (get: () => CanvasWebMcpOptions, getStage: () => Agent
|
|
|
243
243
|
name: 'update_node',
|
|
244
244
|
title: 'Update a node',
|
|
245
245
|
description:
|
|
246
|
-
'Renames, re-describes or moves a node (x/y: its new centre). Catalog resources keep their catalog name and
|
|
246
|
+
'Renames, re-describes, re-versions or moves a node (x/y: its new centre). Catalog resources keep their catalog name, summary and version. For notes, summary is the note text.',
|
|
247
247
|
input: z.object({
|
|
248
248
|
nodeId: z.string(),
|
|
249
249
|
name: z.string().optional(),
|
|
250
250
|
summary: z.string().optional(),
|
|
251
|
+
version: z.string().optional().describe('e.g. 1.0.0'),
|
|
251
252
|
x: z.number().optional(),
|
|
252
253
|
y: z.number().optional(),
|
|
253
254
|
}),
|
|
@@ -290,10 +291,19 @@ const createCanvasTools = (get: () => CanvasWebMcpOptions, getStage: () => Agent
|
|
|
290
291
|
return { laidOut: true };
|
|
291
292
|
},
|
|
292
293
|
}),
|
|
294
|
+
defineTool({
|
|
295
|
+
name: 'set_canvas_status',
|
|
296
|
+
title: 'Set the canvas status',
|
|
297
|
+
description:
|
|
298
|
+
"Changes the canvas's status: draft (being worked on, where every canvas starts), proposed (ready for review), accepted (the agreed design) or rejected (decided against). Only change it when the user asks you to. Changing the nodes or connections on an accepted or rejected canvas makes it a draft again.",
|
|
299
|
+
input: z.object({ status: z.enum(CANVAS_STATUSES), note: z.string().optional() }),
|
|
300
|
+
execute: ({ status, note }) => withDoc((doc) => ({ status, changed: setCanvasStatus(doc, status, get().agent, note) })),
|
|
301
|
+
}),
|
|
293
302
|
defineTool({
|
|
294
303
|
name: 'add_comment',
|
|
295
304
|
title: 'Comment on the canvas',
|
|
296
|
-
description:
|
|
305
|
+
description:
|
|
306
|
+
"Starts a comment thread pinned to a node (nodeId) or a spot (x, y). Only use it when the user asks you to comment (e.g. to review the canvas). Don't use comments to greet people or ask the user questions: talk to them in the conversation.",
|
|
297
307
|
input: z.object({ text: z.string(), nodeId: z.string().optional(), x: z.number().optional(), y: z.number().optional() }),
|
|
298
308
|
execute: async (input) => {
|
|
299
309
|
const stage = getStage();
|
|
@@ -3,8 +3,10 @@ import type * as Y from 'yjs';
|
|
|
3
3
|
import {
|
|
4
4
|
createThread as createThreadInDoc,
|
|
5
5
|
deleteThread as deleteThreadInDoc,
|
|
6
|
+
deleteThreads as deleteThreadsInDoc,
|
|
6
7
|
getCanvasMaps,
|
|
7
8
|
moveThread as moveThreadInDoc,
|
|
9
|
+
moveThreads as moveThreadsInDoc,
|
|
8
10
|
readThreads,
|
|
9
11
|
replyToThread,
|
|
10
12
|
setThreadResolved,
|
|
@@ -42,6 +44,11 @@ export function useComments(doc: Y.Doc | null, author: Author) {
|
|
|
42
44
|
[doc]
|
|
43
45
|
);
|
|
44
46
|
const deleteThread = useCallback((threadId: string) => doc && deleteThreadInDoc(doc, threadId), [doc]);
|
|
47
|
+
const deleteThreads = useCallback((threadIds: string[]) => doc && deleteThreadsInDoc(doc, threadIds), [doc]);
|
|
48
|
+
const moveThreads = useCallback(
|
|
49
|
+
(moves: { threadId: string; anchor: CommentAnchor }[]) => doc && moveThreadsInDoc(doc, moves),
|
|
50
|
+
[doc]
|
|
51
|
+
);
|
|
45
52
|
|
|
46
|
-
return { threads, createThread, reply, setResolved, moveThread, deleteThread };
|
|
53
|
+
return { threads, createThread, reply, setResolved, moveThread, moveThreads, deleteThread, deleteThreads };
|
|
47
54
|
}
|
|
@@ -53,10 +53,15 @@ export function useFollowCamera({
|
|
|
53
53
|
pausedUntil.current = Date.now() + PAUSE_AFTER_USER_MS;
|
|
54
54
|
}, []);
|
|
55
55
|
|
|
56
|
-
// A canvas that opens empty and fills in (it syncs after it opens) fits once its content arrives
|
|
56
|
+
// A canvas that opens empty and fills in (it syncs after it opens) fits once its content arrives, and so does an
|
|
57
|
+
// empty canvas when something's added to it (rather than keeping however far out it was zoomed)
|
|
57
58
|
const hasFitContent = useRef(false);
|
|
58
59
|
useEffect(() => {
|
|
59
|
-
if (
|
|
60
|
+
if (!hasNodes) {
|
|
61
|
+
hasFitContent.current = false;
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (hasFitContent.current || !width) return;
|
|
60
65
|
hasFitContent.current = true;
|
|
61
66
|
const frame = requestAnimationFrame(() => void fitView({ padding: PADDING, maxZoom: MAX_ZOOM, duration: 300 }));
|
|
62
67
|
return () => cancelAnimationFrame(frame);
|