@eventcatalog/core 5.6.1 → 5.7.1
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 +1 -1
- package/dist/analytics/log-build.js +3 -3
- package/dist/{chunk-LVOGLB6C.js → chunk-36XVRD36.js} +1 -1
- package/dist/{chunk-N7LDFUEB.js → chunk-AXWTR2FV.js} +1 -1
- package/dist/{chunk-QJXVAM76.js → chunk-CPF5WE3R.js} +1 -1
- package/dist/{chunk-2NALBBWH.js → chunk-GRC67WHC.js} +1 -1
- package/dist/{chunk-L63ED43X.js → chunk-HZ3XZKUT.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 +596 -122
- package/eventcatalog/src/components/ChatPanel/ChatPanelButton.tsx +3 -2
- package/eventcatalog/src/components/ChatPanel/assistant-events.ts +37 -0
- package/eventcatalog/src/components/MDX/NodeGraph/AstroNodeGraph.tsx +21 -1
- package/eventcatalog/src/components/MDX/NodeGraph/NodeGraph.astro +2 -1
- package/eventcatalog/src/components/MDX/ResourceRef/ResourceRef.astro +2 -34
- package/eventcatalog/src/features/ai/chat-api.ts +24 -5
- package/eventcatalog/src/features/mcp/apps/generated/canvas.html +75 -74
- package/eventcatalog/src/features/mcp/apps/generated/viewer.html +73 -73
- package/eventcatalog/src/features/studio/agent-choreography.ts +84 -16
- package/eventcatalog/src/features/studio/api/canvases-api.ts +3 -0
- package/eventcatalog/src/features/studio/canvas-actions.ts +330 -15
- package/eventcatalog/src/features/studio/catalog.ts +5 -3
- package/eventcatalog/src/features/studio/components/CanvasHeader.tsx +14 -0
- package/eventcatalog/src/features/studio/components/NodeDetails.tsx +4 -0
- package/eventcatalog/src/features/studio/components/StudioDesigner.tsx +165 -18
- package/eventcatalog/src/features/studio/hooks/use-studio-flow.ts +9 -4
- package/eventcatalog/src/features/studio/pages/[id].astro +2 -0
- package/eventcatalog/src/features/studio/server/assistant-tools.ts +207 -0
- package/eventcatalog/src/features/studio/server/canvas-agent.ts +27 -0
- package/eventcatalog/src/features/studio/server/canvas-mcp.ts +2 -14
- package/eventcatalog/src/pages/docs/[type]/[id]/[version]/index.astro +4 -0
- package/eventcatalog/src/utils/node-graphs/compact-visualiser-graph.ts +14 -0
- package/eventcatalog/src/utils/node-graphs/node-resource.ts +26 -0
- package/eventcatalog/src/utils/resource-reference-icons.ts +37 -0
- package/package.json +7 -7
|
@@ -97,15 +97,17 @@ export const buildContainer = (resource: CatalogResource, center: { x: number; y
|
|
|
97
97
|
* A domain or system as a container with everything it holds in the catalog: its systems (as containers too)
|
|
98
98
|
* and services, and the messages and data stores of those services, so every level of detail (L1 to L3) has
|
|
99
99
|
* something to show. Laid out with the visualiser's layout and centred on a point, with the connections between
|
|
100
|
-
* them and to related resources already on the canvas. Resources already on the canvas aren't added again
|
|
100
|
+
* them and to related resources already on the canvas. Resources already on the canvas aren't added again, nor
|
|
101
|
+
* any in `alsoAdding` (by catalog key: added some other way at the same time, e.g. by an agent in the same call).
|
|
101
102
|
*/
|
|
102
103
|
export const buildContainerWithContents = async (
|
|
103
104
|
resource: CatalogResource,
|
|
104
105
|
center: { x: number; y: number },
|
|
105
106
|
catalog: { resourcesByKey: Map<string, CatalogResource>; relationsByKey: Map<string, CatalogRelation[]> },
|
|
106
|
-
existing: Node[]
|
|
107
|
+
existing: Node[],
|
|
108
|
+
alsoAdding: ReadonlySet<string> = new Set()
|
|
107
109
|
): Promise<{ nodes: Node[]; edges: Edge[] }> => {
|
|
108
|
-
const onCanvas = new Set(existing.map((node) => getCatalogLink(node)?.key).filter(Boolean));
|
|
110
|
+
const onCanvas = new Set([...existing.map((node) => getCatalogLink(node)?.key).filter(Boolean), ...alsoAdding]);
|
|
109
111
|
const root = buildContainer(resource, { x: 0, y: 0 });
|
|
110
112
|
const nodes: Node[] = [root];
|
|
111
113
|
const added = (key: string) => onCanvas.has(key) || nodes.some((node) => getCatalogLink(node)?.key === key);
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
Plug,
|
|
11
11
|
Plus,
|
|
12
12
|
Share2,
|
|
13
|
+
Sparkles,
|
|
13
14
|
Trash2,
|
|
14
15
|
Users,
|
|
15
16
|
} from 'lucide-react';
|
|
@@ -440,6 +441,7 @@ export default memo(function CanvasHeader({
|
|
|
440
441
|
statusHistory,
|
|
441
442
|
onStatusChange,
|
|
442
443
|
onDelete,
|
|
444
|
+
onDrawWithAi,
|
|
443
445
|
}: {
|
|
444
446
|
title?: string;
|
|
445
447
|
onRetitle: (title: string) => void;
|
|
@@ -470,6 +472,8 @@ export default memo(function CanvasHeader({
|
|
|
470
472
|
onHelp: () => void;
|
|
471
473
|
/** Ask to delete the canvas (not given where it can't be, e.g. inside a chat) */
|
|
472
474
|
onDelete?: () => void;
|
|
475
|
+
/** Opens the assistant to draw on the canvas (not given where there isn't one, e.g. inside a chat) */
|
|
476
|
+
onDrawWithAi?: () => void;
|
|
473
477
|
}) {
|
|
474
478
|
const peers = usePeople(presence);
|
|
475
479
|
const agentsHere = peers.some((peer) => peer.agent);
|
|
@@ -505,6 +509,16 @@ export default memo(function CanvasHeader({
|
|
|
505
509
|
</div>
|
|
506
510
|
)}
|
|
507
511
|
<ProfileMenu me={me} onRename={onRename} onRecolor={onRecolor} />
|
|
512
|
+
{onDrawWithAi && (
|
|
513
|
+
<button
|
|
514
|
+
onClick={onDrawWithAi}
|
|
515
|
+
title="Describe what to diagram and the assistant draws it here"
|
|
516
|
+
className={`${barClass} gap-2 px-3 text-sm font-medium text-[rgb(var(--ec-page-text))] transition-colors hover:bg-[rgb(var(--ec-accent-subtle))]`}
|
|
517
|
+
>
|
|
518
|
+
<Sparkles size={16} className="text-[rgb(var(--ec-accent))]" aria-hidden />
|
|
519
|
+
Draw with AI
|
|
520
|
+
</button>
|
|
521
|
+
)}
|
|
508
522
|
<button
|
|
509
523
|
onClick={onShare}
|
|
510
524
|
className="pointer-events-auto inline-flex h-[38px] items-center gap-2 rounded-[9px] bg-[rgb(var(--ec-page-text))] px-4 text-sm font-semibold text-[rgb(var(--ec-page-bg))] shadow-sm transition-colors hover:bg-[rgb(var(--ec-page-text)/0.85)]"
|
|
@@ -30,6 +30,7 @@ export default memo(function NodeDetails({
|
|
|
30
30
|
connections,
|
|
31
31
|
onOpenNode,
|
|
32
32
|
containerRef,
|
|
33
|
+
nameEmpty,
|
|
33
34
|
locked,
|
|
34
35
|
onChange,
|
|
35
36
|
onClose,
|
|
@@ -43,6 +44,8 @@ export default memo(function NodeDetails({
|
|
|
43
44
|
onOpenNode: (id: string) => void;
|
|
44
45
|
/** The canvas, to put the card next to the node */
|
|
45
46
|
containerRef: RefObject<HTMLElement | null>;
|
|
47
|
+
/** Its name starts empty, to type it straight in (a node just made: it keeps its name if nothing's typed) */
|
|
48
|
+
nameEmpty?: boolean;
|
|
46
49
|
locked: boolean;
|
|
47
50
|
onChange: UpdateNodeData;
|
|
48
51
|
onClose: () => void;
|
|
@@ -112,6 +115,7 @@ export default memo(function NodeDetails({
|
|
|
112
115
|
onSaved={showSaved}
|
|
113
116
|
placeholder={`Name this ${label.toLowerCase()}`}
|
|
114
117
|
className="ec-editable-title"
|
|
118
|
+
startEmpty={nameEmpty}
|
|
115
119
|
required
|
|
116
120
|
/>
|
|
117
121
|
</Field>
|
|
@@ -98,6 +98,12 @@ import {
|
|
|
98
98
|
type HotspotSeverity,
|
|
99
99
|
type HotspotSpot,
|
|
100
100
|
RemeasureHandles,
|
|
101
|
+
ConnectChooser,
|
|
102
|
+
isCardType,
|
|
103
|
+
newCardData,
|
|
104
|
+
notPlaceholder,
|
|
105
|
+
useConnectNew,
|
|
106
|
+
type NewLink,
|
|
101
107
|
} from '@eventcatalog/visualiser/studio';
|
|
102
108
|
import { X } from 'lucide-react';
|
|
103
109
|
import type { CatalogRelation, CatalogResource } from '../catalog-resources';
|
|
@@ -127,6 +133,12 @@ import ShareDialog from './ShareDialog';
|
|
|
127
133
|
import DeleteCanvasDialog from './DeleteCanvasDialog';
|
|
128
134
|
import { createCanvasThroughApi, deleteCanvasThroughApi } from '../api/client';
|
|
129
135
|
import ShortcutsDialog from './ShortcutsDialog';
|
|
136
|
+
import {
|
|
137
|
+
ASSISTANT_DRAWING_EVENT,
|
|
138
|
+
CANVAS_FOCUS_EVENT,
|
|
139
|
+
CANVAS_HOTSPOT_EVENT,
|
|
140
|
+
OPEN_CHAT_EVENT,
|
|
141
|
+
} from '../../../components/ChatPanel/assistant-events';
|
|
130
142
|
|
|
131
143
|
type CanvasProps = {
|
|
132
144
|
canvasId: string;
|
|
@@ -159,6 +171,8 @@ type CanvasProps = {
|
|
|
159
171
|
asAgent?: { name: string; color: string };
|
|
160
172
|
/** Signed in with the catalog's sign-in (SSO): you're shown by this name and picture, and can't change them */
|
|
161
173
|
signedInAs?: { name: string; picture?: string };
|
|
174
|
+
/** The catalog's assistant (Ask AI) is on the page: "Draw with AI" opens it to draw on the canvas */
|
|
175
|
+
assistant?: boolean;
|
|
162
176
|
resources: CatalogResource[];
|
|
163
177
|
relations: CatalogRelation[];
|
|
164
178
|
};
|
|
@@ -192,6 +206,7 @@ type Landing = {
|
|
|
192
206
|
};
|
|
193
207
|
const DELETE_KEYS = ['Backspace', 'Delete'];
|
|
194
208
|
const NO_THREADS: ReadonlySet<string> = new Set();
|
|
209
|
+
const NO_HOTSPOTS: ReadonlySet<string> = new Set();
|
|
195
210
|
// React Flow's warnings, except that a handle has no node: the left panel's drag previews draw nodes off the canvas
|
|
196
211
|
const onFlowError = (code: string, message: string) => {
|
|
197
212
|
if (code !== '010') console.warn(message);
|
|
@@ -333,6 +348,9 @@ export default function StudioDesigner({ asAgent, signedInAs, ...props }: Canvas
|
|
|
333
348
|
);
|
|
334
349
|
}
|
|
335
350
|
|
|
351
|
+
// The assistant's panel (ChatPanelButton, in the page header) opens on this; on a canvas it draws on it
|
|
352
|
+
const openAssistant = () => window.dispatchEvent(new Event(OPEN_CHAT_EVENT));
|
|
353
|
+
|
|
336
354
|
const toSocketUrl = (socketPath = DEFAULT_SOCKET_PATH) =>
|
|
337
355
|
`${window.location.protocol === 'https:' ? 'wss' : 'ws'}://${window.location.host}${socketPath}`;
|
|
338
356
|
|
|
@@ -347,6 +365,7 @@ function Canvas({
|
|
|
347
365
|
mcpUrl = '/docs/mcp',
|
|
348
366
|
relayScriptPath,
|
|
349
367
|
keptInMemory = false,
|
|
368
|
+
assistant = false,
|
|
350
369
|
syncViaTools,
|
|
351
370
|
followChanges = false,
|
|
352
371
|
paletteCollapsed,
|
|
@@ -625,11 +644,23 @@ function Canvas({
|
|
|
625
644
|
centring.current.set(id, { level, size });
|
|
626
645
|
flow.setLevelLayout(level, new Map([[id, centredOn(center, size)]]));
|
|
627
646
|
};
|
|
628
|
-
/**
|
|
629
|
-
|
|
630
|
-
|
|
647
|
+
/**
|
|
648
|
+
* A component (its type, or its key in the components list: the kinds of data store share a type), connected to
|
|
649
|
+
* the node a connection was dragged from when there is one
|
|
650
|
+
*/
|
|
651
|
+
const addComponentAt = (component: string, point: XYPosition, link?: NewLink) => {
|
|
652
|
+
if (component === 'note') return void addNoteAt(point);
|
|
631
653
|
const at = landingAt(point, getNodeDefinition(component)?.type ?? component);
|
|
632
|
-
|
|
654
|
+
const id = flow.addNode(component, at.center, at.inside, link);
|
|
655
|
+
keepOnLevel(at, id);
|
|
656
|
+
return id;
|
|
657
|
+
};
|
|
658
|
+
/** A new domain or system as a card (the visualiser's, as Studio on the web makes them), connected to `link`'s node */
|
|
659
|
+
const addCardAt = (type: 'context-domain' | 'system', point: XYPosition, link: NewLink) => {
|
|
660
|
+
const at = landingAt(point, type);
|
|
661
|
+
const id = flow.insertNode(type, newCardData(type), at.center, undefined, at.inside, link);
|
|
662
|
+
keepOnLevel(at, id);
|
|
663
|
+
return id;
|
|
633
664
|
};
|
|
634
665
|
|
|
635
666
|
const addComponent = useStableCallback((type: string) => addComponentAt(type, canvasCenter()));
|
|
@@ -728,6 +759,21 @@ function Canvas({
|
|
|
728
759
|
trackPointer(event);
|
|
729
760
|
});
|
|
730
761
|
|
|
762
|
+
// ---- Connecting to something new: a connection let go of on empty canvas ----
|
|
763
|
+
|
|
764
|
+
// A placeholder where it was let go of, connected to the node it came from, until what it is is chosen beside it
|
|
765
|
+
const connect = useConnectNew({
|
|
766
|
+
canvasRef,
|
|
767
|
+
getNode: (id) => nodesRef.current.find((node) => node.id === id),
|
|
768
|
+
getLevel: () => levelRef.current,
|
|
769
|
+
locked,
|
|
770
|
+
onMake: (option, center, link) => {
|
|
771
|
+
const id = isCardType(option.type) ? addCardAt(option.type, center, link) : addComponentAt(option.key, center, link);
|
|
772
|
+
// Selected, and its details open to name it, once it's on the canvas
|
|
773
|
+
if (id) editOnceShown.current = id;
|
|
774
|
+
},
|
|
775
|
+
});
|
|
776
|
+
|
|
731
777
|
// While the canvas is panned, zoomed or dragged, message edges stop animating (a class, not state, so nothing re-renders)
|
|
732
778
|
const interactions = useRef({ moving: false, dragging: false });
|
|
733
779
|
const setInteraction = useCallback((kind: 'moving' | 'dragging', on: boolean) => {
|
|
@@ -940,7 +986,13 @@ function Canvas({
|
|
|
940
986
|
// Comments that are part of the selection (selecting everything selects them too): only here, like what nodes
|
|
941
987
|
// are selected. While what's selected is dragged, the ones not on a node move with it by this much.
|
|
942
988
|
const [selectedThreads, setSelectedThreads] = useState<ReadonlySet<string>>(NO_THREADS);
|
|
943
|
-
|
|
989
|
+
// Hotspots too (selecting everything selects them, and deleting it deletes them)
|
|
990
|
+
const [selectedHotspots, setSelectedHotspots] = useState<ReadonlySet<string>>(NO_HOTSPOTS);
|
|
991
|
+
/** Comments and hotspots stop being part of the selection */
|
|
992
|
+
const clearSelectedPins = useCallback(() => {
|
|
993
|
+
setSelectedThreads((current) => (current.size ? NO_THREADS : current));
|
|
994
|
+
setSelectedHotspots((current) => (current.size ? NO_HOTSPOTS : current));
|
|
995
|
+
}, []);
|
|
944
996
|
const [threadsOffset, setThreadsOffset] = useState<XYPosition | null>(null);
|
|
945
997
|
const threadsShown = () => comments.threads.filter((thread) => showResolved || !thread.resolved);
|
|
946
998
|
const [contextMenu, setContextMenu] = useState<ContextMenuState>(null);
|
|
@@ -972,6 +1024,7 @@ function Canvas({
|
|
|
972
1024
|
setTool('select');
|
|
973
1025
|
setDraft(null);
|
|
974
1026
|
setOpenThreadId(null);
|
|
1027
|
+
connect.cancel();
|
|
975
1028
|
// What's selected may not be on the next level (e.g. a note added on this one)
|
|
976
1029
|
flow.onNodesChange(selectedIds().map((id) => ({ type: 'select', id, selected: false })));
|
|
977
1030
|
setLevel(next);
|
|
@@ -1193,7 +1246,8 @@ function Canvas({
|
|
|
1193
1246
|
* removes it from the canvas (the level is drawn from it); and moving or resizing something arranges the level,
|
|
1194
1247
|
* kept when it's dropped.
|
|
1195
1248
|
*/
|
|
1196
|
-
const onLevelNodesChange = useStableCallback((
|
|
1249
|
+
const onLevelNodesChange = useStableCallback((allChanges: NodeChange[]) => {
|
|
1250
|
+
const changes = allChanges.filter(notPlaceholder);
|
|
1197
1251
|
const level = levelRef.current;
|
|
1198
1252
|
const current = levelGraphRef.current;
|
|
1199
1253
|
if (level === 3 || !current) return;
|
|
@@ -1232,6 +1286,11 @@ function Canvas({
|
|
|
1232
1286
|
});
|
|
1233
1287
|
if (measured.length) flow.setLevelLayout(level, new Map(measured));
|
|
1234
1288
|
});
|
|
1289
|
+
/** Changes on the canvas (L3), apart from the placeholder's */
|
|
1290
|
+
const onCanvasNodesChange = useStableCallback((allChanges: NodeChange[]) => {
|
|
1291
|
+
const changes = allChanges.filter(notPlaceholder);
|
|
1292
|
+
if (changes.length) flow.onNodesChange(changes);
|
|
1293
|
+
});
|
|
1235
1294
|
/** Connecting two things on L1 or L2 connects them on the canvas (e.g. a relationship between two systems) */
|
|
1236
1295
|
const onLevelConnect = useStableCallback((connection: Connection) => {
|
|
1237
1296
|
const onCanvas = new Set(nodesRef.current.map((node) => node.id));
|
|
@@ -1254,6 +1313,13 @@ function Canvas({
|
|
|
1254
1313
|
: edge
|
|
1255
1314
|
);
|
|
1256
1315
|
}, [levelEdges, flow.edges, hovered]);
|
|
1316
|
+
// With the placeholder, while a connection let go of on empty canvas is waiting to be told what it connects to
|
|
1317
|
+
const { placeholder } = connect;
|
|
1318
|
+
const flowNodes = useMemo(
|
|
1319
|
+
() => (placeholder ? [...visibleNodes, placeholder.node] : visibleNodes),
|
|
1320
|
+
[visibleNodes, placeholder]
|
|
1321
|
+
);
|
|
1322
|
+
const flowEdges = useMemo(() => (placeholder ? [...shownEdges, placeholder.edge] : shownEdges), [shownEdges, placeholder]);
|
|
1257
1323
|
// A canvas opened from a diagram has the levels the diagram has (until what's on it changes)
|
|
1258
1324
|
const unavailableLevels = useMemo(() => {
|
|
1259
1325
|
const reason = (level: 1 | 2) => {
|
|
@@ -1281,6 +1347,57 @@ function Canvas({
|
|
|
1281
1347
|
|
|
1282
1348
|
const [following, setFollowing] = useState(followChanges);
|
|
1283
1349
|
const toggleFollowing = useCallback(() => setFollowing((on) => !on), []);
|
|
1350
|
+
// Asking the assistant (Ask AI) to draw on this canvas follows it while it works
|
|
1351
|
+
useEffect(() => {
|
|
1352
|
+
const followAssistant = (event: Event) => {
|
|
1353
|
+
if ((event as CustomEvent<{ canvasId?: string }>).detail?.canvasId === canvasId) setFollowing(true);
|
|
1354
|
+
};
|
|
1355
|
+
window.addEventListener(ASSISTANT_DRAWING_EVENT, followAssistant);
|
|
1356
|
+
return () => window.removeEventListener(ASSISTANT_DRAWING_EVENT, followAssistant);
|
|
1357
|
+
}, [canvasId]);
|
|
1358
|
+
|
|
1359
|
+
// The assistant taking you to part of the canvas (showOnCanvas, a walkthrough's steps): zoomed to, and selected. On
|
|
1360
|
+
// the canvas people edit (L3), where everything is.
|
|
1361
|
+
const focusOn = useStableCallback((ids: string[]) => {
|
|
1362
|
+
const nodes = lookupNodes();
|
|
1363
|
+
const found = ids.filter((id) => nodes.has(id));
|
|
1364
|
+
if (!found.length) return;
|
|
1365
|
+
const changingLevel = levelRef.current !== 3;
|
|
1366
|
+
if (changingLevel) changeLevel(3);
|
|
1367
|
+
// You're looking around now: following the agent would take the camera back to its pointer a moment later
|
|
1368
|
+
setFollowing(false);
|
|
1369
|
+
// Once the level's switched (and drawn), if it was
|
|
1370
|
+
setTimeout(
|
|
1371
|
+
() => {
|
|
1372
|
+
select(new Set(found));
|
|
1373
|
+
void fitView({ nodes: found.map((id) => ({ id })), duration: 600, padding: 0.35, maxZoom: 1.2 });
|
|
1374
|
+
},
|
|
1375
|
+
changingLevel ? 400 : 0
|
|
1376
|
+
);
|
|
1377
|
+
});
|
|
1378
|
+
useEffect(() => {
|
|
1379
|
+
const focus = (event: Event) => {
|
|
1380
|
+
const { canvasId: on, nodeIds } = (event as CustomEvent<{ canvasId?: string; nodeIds?: string[] }>).detail ?? {};
|
|
1381
|
+
if (on === canvasId && nodeIds?.length) focusOn(nodeIds);
|
|
1382
|
+
};
|
|
1383
|
+
window.addEventListener(CANVAS_FOCUS_EVENT, focus);
|
|
1384
|
+
return () => window.removeEventListener(CANVAS_FOCUS_EVENT, focus);
|
|
1385
|
+
}, [canvasId, focusOn]);
|
|
1386
|
+
// ...and to a hotspot it flagged, opened
|
|
1387
|
+
const showHotspot = useStableCallback((hotspotId: string) => {
|
|
1388
|
+
const hotspot = flow.hotspots.find((candidate) => candidate.id === hotspotId);
|
|
1389
|
+
if (!hotspot) return;
|
|
1390
|
+
setFollowing(false);
|
|
1391
|
+
goToHotspot(hotspot);
|
|
1392
|
+
});
|
|
1393
|
+
useEffect(() => {
|
|
1394
|
+
const show = (event: Event) => {
|
|
1395
|
+
const { canvasId: on, hotspotId } = (event as CustomEvent<{ canvasId?: string; hotspotId?: string }>).detail ?? {};
|
|
1396
|
+
if (on === canvasId && hotspotId) showHotspot(hotspotId);
|
|
1397
|
+
};
|
|
1398
|
+
window.addEventListener(CANVAS_HOTSPOT_EVENT, show);
|
|
1399
|
+
return () => window.removeEventListener(CANVAS_HOTSPOT_EVENT, show);
|
|
1400
|
+
}, [canvasId, showHotspot]);
|
|
1284
1401
|
|
|
1285
1402
|
const [shareOpen, setShareOpen] = useState<false | 'choose' | 'invite'>(false);
|
|
1286
1403
|
const openShare = useCallback(() => setShareOpen('choose'), []);
|
|
@@ -1411,6 +1528,8 @@ function Canvas({
|
|
|
1411
1528
|
flow.onEdgesChange(edgesRef.current.map((edge) => ({ type: 'select', id: edge.id, selected: true })));
|
|
1412
1529
|
// ...and the comments shown on it
|
|
1413
1530
|
setSelectedThreads(new Set(threadsShown().map((thread) => thread.id)));
|
|
1531
|
+
// ...and its hotspots (the ones on L1 and L2's own places are on those levels)
|
|
1532
|
+
setSelectedHotspots(new Set(flow.hotspots.filter((hotspot) => !hotspot.level).map((hotspot) => hotspot.id)));
|
|
1414
1533
|
},
|
|
1415
1534
|
{ preventDefault: true }
|
|
1416
1535
|
);
|
|
@@ -1445,7 +1564,7 @@ function Canvas({
|
|
|
1445
1564
|
setTool('select');
|
|
1446
1565
|
setDraft(null);
|
|
1447
1566
|
setOpenThreadId(null);
|
|
1448
|
-
|
|
1567
|
+
clearSelectedPins();
|
|
1449
1568
|
setEditingId(null);
|
|
1450
1569
|
flow.onNodesChange(
|
|
1451
1570
|
nodesRef.current.filter((node) => node.selected).map((node) => ({ type: 'select', id: node.id, selected: false }))
|
|
@@ -1454,11 +1573,16 @@ function Canvas({
|
|
|
1454
1573
|
edgesRef.current.filter((edge) => edge.selected).map((edge) => ({ type: 'select', id: edge.id, selected: false }))
|
|
1455
1574
|
);
|
|
1456
1575
|
});
|
|
1457
|
-
// Selected comments are deleted with what's selected (React Flow deletes the nodes and edges)
|
|
1576
|
+
// Selected comments and hotspots are deleted with what's selected (React Flow deletes the nodes and edges)
|
|
1458
1577
|
useHotkeys(DELETE_KEYS, () => {
|
|
1459
|
-
if (!onCanvas() || selectedThreads.size === 0) return;
|
|
1460
|
-
comments.deleteThreads([...selectedThreads]);
|
|
1461
|
-
|
|
1578
|
+
if (!onCanvas() || (selectedThreads.size === 0 && selectedHotspots.size === 0)) return;
|
|
1579
|
+
if (selectedThreads.size) comments.deleteThreads([...selectedThreads]);
|
|
1580
|
+
// (hotspots, like nodes, stay on a locked canvas)
|
|
1581
|
+
if (!locked) {
|
|
1582
|
+
selectedHotspots.forEach((id) => flow.deleteHotspot(id));
|
|
1583
|
+
if (selectedHotspots.size) setOpenHotspot(null);
|
|
1584
|
+
}
|
|
1585
|
+
clearSelectedPins();
|
|
1462
1586
|
});
|
|
1463
1587
|
/** A tool that puts something down: picked, or put back to selecting if it was already */
|
|
1464
1588
|
const pickTool = useStableCallback((next: 'text' | 'note' | 'comment' | 'hotspot') => {
|
|
@@ -1491,7 +1615,9 @@ function Canvas({
|
|
|
1491
1615
|
// A place on L1 or L2 is on that level (they're laid out on their own): what's put there says so
|
|
1492
1616
|
const placeLevel = () => (levelRef.current === 3 ? {} : { level: levelRef.current as 1 | 2 });
|
|
1493
1617
|
const onPaneClick = useStableCallback((event: ReactMouseEvent) => {
|
|
1494
|
-
|
|
1618
|
+
clearSelectedPins();
|
|
1619
|
+
// An open hotspot closes on a click anywhere else on the canvas
|
|
1620
|
+
setOpenHotspot(null);
|
|
1495
1621
|
if (tool === 'hand') return;
|
|
1496
1622
|
if (commentMode && editable) return startComment(anchorAt(flowPositionOf(event)));
|
|
1497
1623
|
// Flagging: a hotspot where it's clicked
|
|
@@ -1527,7 +1653,8 @@ function Canvas({
|
|
|
1527
1653
|
|
|
1528
1654
|
const onNodeClick = useStableCallback((event: ReactMouseEvent, node: Node) => {
|
|
1529
1655
|
// Clicking one node selects just it (with shift or ⌘, it's added to what's selected)
|
|
1530
|
-
if (!event.shiftKey && !event.metaKey)
|
|
1656
|
+
if (!event.shiftKey && !event.metaKey) clearSelectedPins();
|
|
1657
|
+
setOpenHotspot(null);
|
|
1531
1658
|
// Not on notes and text: they're written on, not what's being flagged or commented on
|
|
1532
1659
|
if (isWrittenOnCanvas(node.type)) return;
|
|
1533
1660
|
// A hotspot on it (it shows on the node, wherever it goes, on every level it's on)
|
|
@@ -1654,6 +1781,20 @@ function Canvas({
|
|
|
1654
1781
|
// The node whose details are being edited in its card: opened with the edit button over it, by double clicking it,
|
|
1655
1782
|
// or "Edit details" in its menu (selecting a node doesn't open it). Notes are edited on the canvas.
|
|
1656
1783
|
const [editingId, setEditingId] = useState<string | null>(null);
|
|
1784
|
+
// Made at the end of a connection: selected and its details opened once it's on the canvas (looked for as what's on
|
|
1785
|
+
// it changes, not as things move), its name empty to type it straight in. NodeDetails is keyed by the node, so its
|
|
1786
|
+
// name starts empty when its card opens, and only then (the next card opened, even its own again, doesn't).
|
|
1787
|
+
const editOnceShown = useRef<string | null>(null);
|
|
1788
|
+
const [justMadeId, setJustMadeId] = useState<string | null>(null);
|
|
1789
|
+
useEffect(() => {
|
|
1790
|
+
const id = editOnceShown.current;
|
|
1791
|
+
if (!id || !nodesRef.current.some((node) => node.id === id)) return;
|
|
1792
|
+
editOnceShown.current = null;
|
|
1793
|
+
select(new Set([id]));
|
|
1794
|
+
setEditingId(id);
|
|
1795
|
+
setJustMadeId(id);
|
|
1796
|
+
}, [flow.structureKey]);
|
|
1797
|
+
useEffect(() => setJustMadeId((id) => (id === editingId ? id : null)), [editingId]);
|
|
1657
1798
|
const editing = editingId ? flow.nodes.find((node) => node.id === editingId && !isWrittenOnCanvas(node.type)) : undefined;
|
|
1658
1799
|
// What the card shows (not the node's position, so dragging it doesn't re-render the card)
|
|
1659
1800
|
const inspected = useMemo(
|
|
@@ -1706,8 +1847,8 @@ function Canvas({
|
|
|
1706
1847
|
onDoubleClick={onCanvasDoubleClick}
|
|
1707
1848
|
>
|
|
1708
1849
|
<ReactFlow
|
|
1709
|
-
nodes={
|
|
1710
|
-
edges={
|
|
1850
|
+
nodes={flowNodes}
|
|
1851
|
+
edges={flowEdges}
|
|
1711
1852
|
onNodeMouseEnter={onNodeMouseEnter}
|
|
1712
1853
|
onNodeMouseLeave={onHoverEnd}
|
|
1713
1854
|
onEdgeMouseEnter={onEdgeMouseEnter}
|
|
@@ -1717,12 +1858,15 @@ function Canvas({
|
|
|
1717
1858
|
nodesDraggable={!locked && tool !== 'hand'}
|
|
1718
1859
|
nodesConnectable={!locked && tool !== 'hand'}
|
|
1719
1860
|
elementsSelectable={tool !== 'hand'}
|
|
1720
|
-
onNodesChange={editable ?
|
|
1861
|
+
onNodesChange={editable ? onCanvasNodesChange : onLevelNodesChange}
|
|
1721
1862
|
onEdgesChange={editable ? flow.onEdgesChange : undefined}
|
|
1722
1863
|
onConnect={editable ? flow.onConnect : onLevelConnect}
|
|
1864
|
+
onConnectEnd={connect.onConnectEnd}
|
|
1723
1865
|
// Drag either end of a connection to another node (dropped anywhere else, it stays where it was)
|
|
1724
1866
|
edgesReconnectable={editable && !locked}
|
|
1725
1867
|
onReconnect={editable ? flow.onReconnect : undefined}
|
|
1868
|
+
onReconnectStart={connect.onReconnectStart}
|
|
1869
|
+
onReconnectEnd={connect.onReconnectEnd}
|
|
1726
1870
|
reconnectRadius={RECONNECT_RADIUS}
|
|
1727
1871
|
onDragOver={locked ? undefined : onDragOver}
|
|
1728
1872
|
onMoveStart={onMoveStart}
|
|
@@ -1735,7 +1879,7 @@ function Canvas({
|
|
|
1735
1879
|
onNodeClick={onNodeClick}
|
|
1736
1880
|
onNodeDoubleClick={onNodeDoubleClick}
|
|
1737
1881
|
onError={onFlowError}
|
|
1738
|
-
onSelectionStart={
|
|
1882
|
+
onSelectionStart={clearSelectedPins}
|
|
1739
1883
|
onPaneContextMenu={onPaneContextMenu}
|
|
1740
1884
|
onNodeContextMenu={onNodeContextMenu}
|
|
1741
1885
|
connectionLineType={ConnectionLineType.SmoothStep}
|
|
@@ -1785,7 +1929,7 @@ function Canvas({
|
|
|
1785
1929
|
draft={hotspotDraft}
|
|
1786
1930
|
locked={locked}
|
|
1787
1931
|
movable={!locked}
|
|
1788
|
-
selectedIds={null}
|
|
1932
|
+
selectedIds={selectedHotspots.size ? selectedHotspots : null}
|
|
1789
1933
|
onMove={moveHotspots}
|
|
1790
1934
|
onDropTarget={setHotspotDropTarget}
|
|
1791
1935
|
onOpen={setOpenHotspot}
|
|
@@ -1969,6 +2113,7 @@ function Canvas({
|
|
|
1969
2113
|
statusHistory={flow.canvasStatus.history}
|
|
1970
2114
|
onStatusChange={flow.changeStatus}
|
|
1971
2115
|
onDelete={canvasesApiUrl ? openDelete : undefined}
|
|
2116
|
+
onDrawWithAi={assistant ? openAssistant : undefined}
|
|
1972
2117
|
/>
|
|
1973
2118
|
|
|
1974
2119
|
{flow.deleted && !leaving && (
|
|
@@ -2016,6 +2161,7 @@ function Canvas({
|
|
|
2016
2161
|
connections={inspectedConnections}
|
|
2017
2162
|
onOpenNode={openConnected}
|
|
2018
2163
|
containerRef={canvasRef}
|
|
2164
|
+
nameEmpty={inspected.id === justMadeId}
|
|
2019
2165
|
locked={locked}
|
|
2020
2166
|
onChange={flow.updateNodeData}
|
|
2021
2167
|
onClose={closePanel}
|
|
@@ -2023,6 +2169,7 @@ function Canvas({
|
|
|
2023
2169
|
)}
|
|
2024
2170
|
</div>
|
|
2025
2171
|
|
|
2172
|
+
{connect.chooser && <ConnectChooser {...connect.chooser} />}
|
|
2026
2173
|
{chooser && (
|
|
2027
2174
|
<ContainerChooser
|
|
2028
2175
|
kind={chooser.resource.collection === 'domains' ? 'domain' : 'system'}
|
|
@@ -55,6 +55,8 @@ import {
|
|
|
55
55
|
canGoInContainer,
|
|
56
56
|
getNodeDefinition,
|
|
57
57
|
isGroupType,
|
|
58
|
+
linkConnection,
|
|
59
|
+
type NewLink,
|
|
58
60
|
createHotspot as createHotspotInDoc,
|
|
59
61
|
updateHotspot as updateHotspotInDoc,
|
|
60
62
|
deleteHotspot as deleteHotspotInDoc,
|
|
@@ -599,7 +601,8 @@ export function useStudioFlow({
|
|
|
599
601
|
|
|
600
602
|
/**
|
|
601
603
|
* Add a node centred on a canvas position. `connect` gets the new node's id and the nodes already
|
|
602
|
-
* on the canvas, and returns edges to add with it (e.g. a catalog resource's relationships).
|
|
604
|
+
* on the canvas, and returns edges to add with it (e.g. a catalog resource's relationships). `link` connects it to
|
|
605
|
+
* the node a connection was dragged from (in the same change, so one undo takes both back).
|
|
603
606
|
*/
|
|
604
607
|
const insertNode = useCallback(
|
|
605
608
|
(
|
|
@@ -607,7 +610,8 @@ export function useStudioFlow({
|
|
|
607
610
|
data: Record<string, unknown>,
|
|
608
611
|
center: XYPosition,
|
|
609
612
|
connect?: (id: string, existing: Node[]) => Edge[],
|
|
610
|
-
inside?: string
|
|
613
|
+
inside?: string,
|
|
614
|
+
link?: NewLink
|
|
611
615
|
) => {
|
|
612
616
|
const built = buildNode(type, data, center);
|
|
613
617
|
withDoc((doc) => {
|
|
@@ -618,6 +622,7 @@ export function useStudioFlow({
|
|
|
618
622
|
? { ...built, parentId: group.id, position: toRelativePosition(built.position, group.id, existing) }
|
|
619
623
|
: built;
|
|
620
624
|
addNodes(doc, [node], connect?.(node.id, existing) ?? []);
|
|
625
|
+
if (link) connectNodes(doc, linkConnection(node.id, link));
|
|
621
626
|
if (group) fitGroupToChildren(doc, group.id, renderedSizes());
|
|
622
627
|
});
|
|
623
628
|
return built.id;
|
|
@@ -627,9 +632,9 @@ export function useStudioFlow({
|
|
|
627
632
|
|
|
628
633
|
/** Add a node of a component (its type, or its key in the components list), centred on a canvas position (in a container, if asked) */
|
|
629
634
|
const addNode = useCallback(
|
|
630
|
-
(component: string, center: XYPosition, inside?: string) => {
|
|
635
|
+
(component: string, center: XYPosition, inside?: string, link?: NewLink) => {
|
|
631
636
|
const definition = getNodeDefinition(component);
|
|
632
|
-
return definition ? insertNode(definition.type, definition.createData(), center, undefined, inside) : undefined;
|
|
637
|
+
return definition ? insertNode(definition.type, definition.createData(), center, undefined, inside, link) : undefined;
|
|
633
638
|
},
|
|
634
639
|
[insertNode]
|
|
635
640
|
);
|
|
@@ -11,6 +11,7 @@ import { getCatalogResources } from '../catalog-resources';
|
|
|
11
11
|
import { startStudio, STUDIO_SOCKET_PATH } from '../server/runtime';
|
|
12
12
|
import { getSignedInUser, isSignedIn } from '../server/sign-in';
|
|
13
13
|
import { CANVASES_API_PATH } from '../api/canvases-api';
|
|
14
|
+
import { isEventCatalogChatVisible } from '@utils/feature';
|
|
14
15
|
|
|
15
16
|
export const prerender = false;
|
|
16
17
|
|
|
@@ -59,6 +60,7 @@ const signedInAs = getSignedInUser(Astro.locals);
|
|
|
59
60
|
mcpUrl={buildUrl('/docs/mcp', true)}
|
|
60
61
|
relayScriptPath={buildUrl('/webmcp-relay/embed.js', true)}
|
|
61
62
|
keptInMemory={keptInMemory}
|
|
63
|
+
assistant={isEventCatalogChatVisible()}
|
|
62
64
|
signedInAs={signedInAs}
|
|
63
65
|
resources={resources}
|
|
64
66
|
relations={relations}
|