@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
|
@@ -19,20 +19,30 @@ import {
|
|
|
19
19
|
connectNodes,
|
|
20
20
|
deleteEdges,
|
|
21
21
|
deleteNodes as deleteNodesInDoc,
|
|
22
|
+
reconnectEdge,
|
|
23
|
+
fitContainersAround,
|
|
22
24
|
fitGroupToChildren,
|
|
23
25
|
getCanvasMaps,
|
|
24
26
|
moveNode,
|
|
27
|
+
changesNodesOrEdges,
|
|
28
|
+
getCanvasStatus,
|
|
25
29
|
readMeta,
|
|
30
|
+
reopenIfDecided,
|
|
26
31
|
resizeNode,
|
|
32
|
+
setCanvasStatus,
|
|
27
33
|
setMeta,
|
|
34
|
+
type Author,
|
|
35
|
+
type CanvasStatus,
|
|
36
|
+
type StatusChange,
|
|
28
37
|
setNodeParent,
|
|
38
|
+
type RenderedSizes,
|
|
29
39
|
updateNodeData as updateNodeDataInDoc,
|
|
30
40
|
} from '../canvas-doc';
|
|
31
41
|
import { applyLayout } from '../agent-choreography';
|
|
32
42
|
import { keepDragged, LOCAL_EDGE_KEYS, LOCAL_NODE_KEYS, patchShared, withPositions } from '../flow-state';
|
|
33
|
-
import { findGroupAtPoint, toRelativePosition } from '../grouping';
|
|
43
|
+
import { findGroupAtPoint, toRelativePosition, type NodePreview } from '../grouping';
|
|
34
44
|
import type { LayoutResult } from '../layout';
|
|
35
|
-
import { getNodeDefinition } from '../node-types';
|
|
45
|
+
import { canGoInContainer, getNodeDefinition, isGroupType } from '../node-types';
|
|
36
46
|
import { startToolSync, type ToolSync } from '../tool-sync';
|
|
37
47
|
import { createPresenceSender, type PresenceSender } from './presence-sender';
|
|
38
48
|
import { createPresenceStore, type PresenceStore } from './presence-store';
|
|
@@ -54,6 +64,8 @@ const DROP_SETTLE_MS = 500;
|
|
|
54
64
|
const LOCAL_ORIGIN = Symbol('studio-local');
|
|
55
65
|
/** Changes an agent working through this tab (WebMCP) makes: synced like anyone's, and not in the user's undo */
|
|
56
66
|
export const BROWSER_AGENT_ORIGIN = Symbol('studio-browser-agent');
|
|
67
|
+
/** Status changes: synced, but not undone (undo takes back edits, not decisions about the canvas) */
|
|
68
|
+
const STATUS_ORIGIN = Symbol('studio-status');
|
|
57
69
|
|
|
58
70
|
export type Status = 'connecting' | 'connected' | 'disconnected';
|
|
59
71
|
export type Transport = 'websocket' | 'tools';
|
|
@@ -69,23 +81,34 @@ type Shared = { doc: Y.Doc; awareness: Awareness; undoManager: Y.UndoManager; se
|
|
|
69
81
|
* where they're dropped is saved in the document: one change per drag, so the document doesn't grow with every
|
|
70
82
|
* step of every drag, and undo takes back the whole drag. Others show where we're dragging them as we do.
|
|
71
83
|
*/
|
|
84
|
+
const NEW_CANVAS_STATUS = { status: 'draft' as CanvasStatus, history: [] as StatusChange[] };
|
|
85
|
+
|
|
72
86
|
export function useStudioFlow({
|
|
73
87
|
canvasId,
|
|
74
88
|
socketUrl,
|
|
75
89
|
name,
|
|
76
90
|
color,
|
|
91
|
+
picture,
|
|
77
92
|
syncViaTools,
|
|
78
93
|
onLayout,
|
|
94
|
+
onContainerResize,
|
|
79
95
|
}: {
|
|
80
96
|
canvasId: string;
|
|
81
97
|
/** e.g. wss://catalog.example.com/_eventcatalog/studio */
|
|
82
98
|
socketUrl: string;
|
|
83
99
|
name: string;
|
|
84
100
|
color: string;
|
|
101
|
+
/** From your sign-in provider, when you're signed in */
|
|
102
|
+
picture?: string;
|
|
85
103
|
/** Sync through MCP tool calls when the WebSocket can't connect (e.g. inside a chat's sandbox) */
|
|
86
104
|
syncViaTools?: ToolSync;
|
|
87
105
|
/** Someone laid the canvas out (us included): called as the new positions arrive, so they can glide there */
|
|
88
106
|
onLayout?: () => void;
|
|
107
|
+
/**
|
|
108
|
+
* A container changed size (it grew to fit what's in it, or someone else resized it): called as it arrives, so
|
|
109
|
+
* it can grow smoothly. Not for our own resizing, which follows the pointer.
|
|
110
|
+
*/
|
|
111
|
+
onContainerResize?: () => void;
|
|
89
112
|
}) {
|
|
90
113
|
const sharedRef = useRef<Shared | null>(null);
|
|
91
114
|
const [nodes, setNodes] = useState<Node[]>([]);
|
|
@@ -97,11 +120,14 @@ export function useStudioFlow({
|
|
|
97
120
|
const [doc, setDoc] = useState<Y.Doc | null>(null);
|
|
98
121
|
const [history, setHistory] = useState({ canUndo: false, canRedo: false });
|
|
99
122
|
const [title, setTitle] = useState<string | undefined>();
|
|
123
|
+
const [canvasStatus, setCanvasStatusState] = useState<{ status: CanvasStatus; history: StatusChange[] }>(NEW_CANVAS_STATUS);
|
|
100
124
|
const [transport, setTransport] = useState<Transport>('websocket');
|
|
101
|
-
const user = useRef({ name, color });
|
|
102
|
-
user.current = { name, color };
|
|
125
|
+
const user = useRef<Author>({ name, color });
|
|
126
|
+
user.current = { name, color, ...(picture && { picture }) };
|
|
103
127
|
const onLayoutRef = useRef(onLayout);
|
|
104
128
|
onLayoutRef.current = onLayout;
|
|
129
|
+
const onContainerResizeRef = useRef(onContainerResize);
|
|
130
|
+
onContainerResizeRef.current = onContainerResize;
|
|
105
131
|
|
|
106
132
|
useEffect(() => {
|
|
107
133
|
// A new connection starts from what the server has. Clear anything left from a previous one,
|
|
@@ -112,31 +138,81 @@ export function useStudioFlow({
|
|
|
112
138
|
const doc = new Y.Doc();
|
|
113
139
|
const { nodes: yNodes, edges: yEdges, threads: yThreads, meta: yMeta } = getCanvasMaps(doc);
|
|
114
140
|
const syncMeta = (event: Y.YMapEvent<unknown>) => {
|
|
115
|
-
|
|
141
|
+
const meta = readMeta(doc);
|
|
142
|
+
setTitle(meta.title);
|
|
143
|
+
if (event.keysChanged.has('status') || event.keysChanged.has('statusHistory'))
|
|
144
|
+
setCanvasStatusState({ status: getCanvasStatus(meta), history: meta.statusHistory ?? [] });
|
|
116
145
|
if (event.keysChanged.has('layoutAt')) onLayoutRef.current?.();
|
|
117
146
|
};
|
|
118
147
|
yMeta.observe(syncMeta);
|
|
119
148
|
setTitle(undefined);
|
|
149
|
+
setCanvasStatusState(NEW_CANVAS_STATUS);
|
|
150
|
+
// Changing an accepted (or rejected) canvas here makes it a draft again, for everyone. Changes from others arrive
|
|
151
|
+
// already reopened by whoever made them.
|
|
152
|
+
const reopenOnEdit = (transaction: Y.Transaction) => {
|
|
153
|
+
if (!transaction.local || transaction.origin === STATUS_ORIGIN) return;
|
|
154
|
+
if (!changesNodesOrEdges(transaction)) return;
|
|
155
|
+
// Recorded against whoever changed it: this person, or their agent in the browser (WebMCP)
|
|
156
|
+
const by =
|
|
157
|
+
transaction.origin === BROWSER_AGENT_ORIGIN
|
|
158
|
+
? { name: `${user.current.name}'s agent`, color: user.current.color, agent: true }
|
|
159
|
+
: user.current;
|
|
160
|
+
queueMicrotask(() =>
|
|
161
|
+
doc.transact(() => {
|
|
162
|
+
reopenIfDecided(doc, by);
|
|
163
|
+
// The first thing put on a canvas started it (opening a new one to look around doesn't)
|
|
164
|
+
if (!readMeta(doc).createdAt) setMeta(doc, { createdAt: Date.now(), createdBy: by.name });
|
|
165
|
+
}, STATUS_ORIGIN)
|
|
166
|
+
);
|
|
167
|
+
};
|
|
168
|
+
doc.on('afterTransaction', reopenOnEdit);
|
|
120
169
|
// One presence for whichever connection is used
|
|
121
170
|
const awareness = new Awareness(doc);
|
|
122
171
|
awareness.setLocalStateField('user', user.current);
|
|
123
172
|
const presenceStore = createPresenceStore(awareness);
|
|
124
173
|
setPresence(presenceStore);
|
|
125
174
|
const sender = createPresenceSender(awareness);
|
|
175
|
+
// Let in by the server, not just open: with sign-in on, it refuses a socket without a session after it opens.
|
|
176
|
+
// `letIn` is for this connection; `socketConnected` stays once the socket has worked (no falling back after).
|
|
126
177
|
let socketConnected = false;
|
|
178
|
+
let letIn = false;
|
|
127
179
|
let stopToolSync: (() => void) | undefined;
|
|
180
|
+
let fallback: ReturnType<typeof setTimeout> | undefined;
|
|
181
|
+
// Declared before the provider: it reports its first status while it's being created
|
|
182
|
+
let socketStatus: Status = 'connecting';
|
|
183
|
+
// Some views can't use the WebSocket (a chat's sandbox may block it, or open it without the person's session):
|
|
184
|
+
// sync through tool calls instead
|
|
185
|
+
const syncThroughTools = () => {
|
|
186
|
+
if (!syncViaTools || stopToolSync) return;
|
|
187
|
+
clearTimeout(fallback);
|
|
188
|
+
setTransport('tools');
|
|
189
|
+
stopToolSync = startToolSync(doc, awareness, { canvasId, sync: syncViaTools, onStatus: setStatus });
|
|
190
|
+
// After the tools take over, so the socket closing doesn't show as offline for a moment
|
|
191
|
+
provider.disconnect();
|
|
192
|
+
};
|
|
128
193
|
const provider = new HocuspocusProvider({
|
|
129
194
|
url: socketUrl,
|
|
130
195
|
name: canvasDocumentName(canvasId),
|
|
131
196
|
document: doc,
|
|
132
197
|
awareness,
|
|
133
198
|
onStatus: ({ status }) => {
|
|
134
|
-
if (status === 'connected') socketConnected = true;
|
|
135
199
|
socketStatus = status as Status;
|
|
136
|
-
if (
|
|
200
|
+
if (status !== 'connected') letIn = false;
|
|
201
|
+
// Open isn't connected until the server lets us in (onAuthenticated)
|
|
202
|
+
if (!stopToolSync) setStatus(status === 'connected' && !letIn ? 'connecting' : (status as Status));
|
|
203
|
+
},
|
|
204
|
+
onAuthenticated: () => {
|
|
205
|
+
socketConnected = letIn = true;
|
|
206
|
+
if (!stopToolSync) setStatus('connected');
|
|
207
|
+
},
|
|
208
|
+
// Not signed in (or the session ran out): a chat's view syncs through its tools, a page shows it's offline
|
|
209
|
+
// (coming back to the tab tries again, e.g. after signing in again elsewhere)
|
|
210
|
+
onAuthenticationFailed: () => {
|
|
211
|
+
if (syncViaTools) return syncThroughTools();
|
|
212
|
+
provider.disconnect();
|
|
213
|
+
setStatus('disconnected');
|
|
137
214
|
},
|
|
138
215
|
});
|
|
139
|
-
let socketStatus: Status = 'connecting';
|
|
140
216
|
setTransport('websocket');
|
|
141
217
|
|
|
142
218
|
// Back online, or back on the tab: reconnect now rather than when the next retry is due
|
|
@@ -152,15 +228,8 @@ export function useStudioFlow({
|
|
|
152
228
|
window.addEventListener('online', reconnect);
|
|
153
229
|
document.addEventListener('visibilitychange', onVisibilityChange);
|
|
154
230
|
|
|
155
|
-
//
|
|
156
|
-
|
|
157
|
-
? setTimeout(() => {
|
|
158
|
-
if (socketConnected) return;
|
|
159
|
-
provider.disconnect();
|
|
160
|
-
setTransport('tools');
|
|
161
|
-
stopToolSync = startToolSync(doc, awareness, { canvasId, sync: syncViaTools, onStatus: setStatus });
|
|
162
|
-
}, SOCKET_TIMEOUT_MS)
|
|
163
|
-
: undefined;
|
|
231
|
+
// A socket that isn't let in within a few seconds (blocked, or never answered) is given up for tool calls
|
|
232
|
+
if (syncViaTools) fallback = setTimeout(() => !socketConnected && syncThroughTools(), SOCKET_TIMEOUT_MS);
|
|
164
233
|
// Undo only takes back your own changes: other people's arrive with the provider as origin, agents' with theirs
|
|
165
234
|
const undoManager = new Y.UndoManager([yNodes, yEdges, yThreads], {
|
|
166
235
|
captureTimeout: 500,
|
|
@@ -209,6 +278,16 @@ export function useStudioFlow({
|
|
|
209
278
|
if (event.transaction.origin === LOCAL_ORIGIN) return;
|
|
210
279
|
// A node someone was dragging changed in the document: they dropped it there
|
|
211
280
|
event.keysChanged.forEach((id) => remoteMoves.delete(id));
|
|
281
|
+
const containerResized = [...event.changes.keys].some(([id, change]) => {
|
|
282
|
+
const next = yNodes.get(id);
|
|
283
|
+
const previous = change.oldValue as Node | undefined;
|
|
284
|
+
return (
|
|
285
|
+
change.action === 'update' &&
|
|
286
|
+
isGroupType(next?.type) &&
|
|
287
|
+
(next?.width !== previous?.width || next?.height !== previous?.height)
|
|
288
|
+
);
|
|
289
|
+
});
|
|
290
|
+
if (containerResized) onContainerResizeRef.current?.();
|
|
212
291
|
setNodes((local) =>
|
|
213
292
|
keepDragged(
|
|
214
293
|
patchShared(local, event.keysChanged, (id) => yNodes.get(id), LOCAL_NODE_KEYS),
|
|
@@ -234,6 +313,7 @@ export function useStudioFlow({
|
|
|
234
313
|
yNodes.unobserve(syncNodes);
|
|
235
314
|
yEdges.unobserve(syncEdges);
|
|
236
315
|
yMeta.unobserve(syncMeta);
|
|
316
|
+
doc.off('afterTransaction', reopenOnEdit);
|
|
237
317
|
presenceStore.destroy();
|
|
238
318
|
undoManager.destroy();
|
|
239
319
|
provider.destroy();
|
|
@@ -245,8 +325,8 @@ export function useStudioFlow({
|
|
|
245
325
|
}, [canvasId, socketUrl, syncViaTools]);
|
|
246
326
|
|
|
247
327
|
useEffect(() => {
|
|
248
|
-
sharedRef.current?.awareness.setLocalStateField('user',
|
|
249
|
-
}, [name, color]);
|
|
328
|
+
sharedRef.current?.awareness.setLocalStateField('user', user.current);
|
|
329
|
+
}, [name, color, picture]);
|
|
250
330
|
|
|
251
331
|
// Tell others what we have selected so they can see it
|
|
252
332
|
const selectionKey = useMemo(
|
|
@@ -261,16 +341,36 @@ export function useStudioFlow({
|
|
|
261
341
|
sharedRef.current?.sender.send({ selection: selectionKey ? selectionKey.split(',') : [] });
|
|
262
342
|
}, [selectionKey]);
|
|
263
343
|
|
|
344
|
+
// The sizes nodes are rendered at here, for fitting containers to what's in them
|
|
345
|
+
const latestNodes = useRef(nodes);
|
|
346
|
+
latestNodes.current = nodes;
|
|
347
|
+
const renderedSizes = (): RenderedSizes =>
|
|
348
|
+
new Map(
|
|
349
|
+
latestNodes.current.flatMap((node) =>
|
|
350
|
+
node.measured?.width && node.measured.height
|
|
351
|
+
? [[node.id, { width: node.measured.width, height: node.measured.height }] as const]
|
|
352
|
+
: []
|
|
353
|
+
)
|
|
354
|
+
);
|
|
355
|
+
|
|
264
356
|
const withDoc = <T>(fn: (doc: Y.Doc) => T, origin: unknown = null) => {
|
|
265
357
|
const doc = sharedRef.current?.doc;
|
|
266
358
|
return doc ? doc.transact(() => fn(doc), origin) : undefined;
|
|
267
359
|
};
|
|
268
360
|
|
|
269
|
-
// Where the nodes being dragged are, until they're dropped
|
|
361
|
+
// Where the nodes being dragged are, until they're dropped, and the containers around them as they've grown
|
|
270
362
|
const dragged = useRef(new Map<string, XYPosition>());
|
|
363
|
+
const dragPreview = useRef(new Map<string, NodePreview>());
|
|
271
364
|
/** Saves where dragged nodes were dropped (in the transaction it's called in), and stops showing them as dragged */
|
|
272
365
|
const saveDrop = (doc: Y.Doc) => {
|
|
273
366
|
dragged.current.forEach((position, id) => moveNode(doc, id, position));
|
|
367
|
+
// Containers as they grew around what was dragged, and what else is in them (moved the other way if they grew up
|
|
368
|
+
// or left). Dragged nodes are where React Flow dropped them, already in their containers' grown coordinates.
|
|
369
|
+
dragPreview.current.forEach(({ position, width, height }, id) => {
|
|
370
|
+
const node = getCanvasMaps(doc).nodes.get(id);
|
|
371
|
+
if (node) getCanvasMaps(doc).nodes.set(id, { ...node, position, ...(width !== undefined && { width, height }) });
|
|
372
|
+
});
|
|
373
|
+
dragPreview.current = new Map();
|
|
274
374
|
dragged.current.clear();
|
|
275
375
|
const shared = sharedRef.current;
|
|
276
376
|
shared?.sender.send({ moving: null });
|
|
@@ -281,7 +381,8 @@ export function useStudioFlow({
|
|
|
281
381
|
const onNodesChange = useCallback((changes: NodeChange[]) => {
|
|
282
382
|
setNodes((local) => applyNodeChanges(changes, local));
|
|
283
383
|
let dropped = false;
|
|
284
|
-
const resized: { id: string; dimensions: { width: number; height: number } }[] = [];
|
|
384
|
+
const resized: { id: string; dimensions: { width: number; height: number }; attributes: true | 'width' | 'height' }[] = [];
|
|
385
|
+
const resizeEnded: string[] = [];
|
|
285
386
|
for (const change of changes) {
|
|
286
387
|
if (change.type === 'position' && change.position) {
|
|
287
388
|
dragged.current.set(change.id, change.position);
|
|
@@ -289,7 +390,8 @@ export function useStudioFlow({
|
|
|
289
390
|
}
|
|
290
391
|
// Containers resized with their handles
|
|
291
392
|
if (change.type === 'dimensions' && change.setAttributes && change.dimensions)
|
|
292
|
-
resized.push({ id: change.id, dimensions: change.dimensions });
|
|
393
|
+
resized.push({ id: change.id, dimensions: change.dimensions, attributes: change.setAttributes });
|
|
394
|
+
if (change.type === 'dimensions' && change.resizing === false) resizeEnded.push(change.id);
|
|
293
395
|
}
|
|
294
396
|
// While dragging, nodes move here every frame and go to everyone with our presence; where they're dropped is saved
|
|
295
397
|
if (dropped) {
|
|
@@ -297,7 +399,14 @@ export function useStudioFlow({
|
|
|
297
399
|
sharedRef.current?.undoManager.stopCapturing();
|
|
298
400
|
withDoc(saveDrop, LOCAL_ORIGIN);
|
|
299
401
|
} else if (dragged.current.size) sharedRef.current?.sender.send({ moving: Object.fromEntries(dragged.current) });
|
|
300
|
-
if (resized.length)
|
|
402
|
+
if (resized.length)
|
|
403
|
+
withDoc(
|
|
404
|
+
(doc) => resized.forEach(({ id, dimensions, attributes }) => resizeNode(doc, id, dimensions, attributes)),
|
|
405
|
+
LOCAL_ORIGIN
|
|
406
|
+
);
|
|
407
|
+
// Resized inside a container (e.g. a system in a domain): the containers around it grow to fit it. Not as
|
|
408
|
+
// LOCAL_ORIGIN, so they grow here too.
|
|
409
|
+
if (resizeEnded.length) withDoc((doc) => resizeEnded.forEach((id) => fitContainersAround(doc, id, renderedSizes())));
|
|
301
410
|
// Removing a node also removes what's in it, its edges, and moves its comments: those come back through the observers
|
|
302
411
|
const removed = changes.filter((change) => change.type === 'remove').map((change) => change.id);
|
|
303
412
|
if (removed.length) withDoc((doc) => deleteNodesInDoc(doc, removed));
|
|
@@ -313,6 +422,35 @@ export function useStudioFlow({
|
|
|
313
422
|
withDoc((doc) => connectNodes(doc, connection));
|
|
314
423
|
}, []);
|
|
315
424
|
|
|
425
|
+
/** A connection's end dragged to another node */
|
|
426
|
+
const onReconnect = useCallback((edge: Edge, connection: Connection) => {
|
|
427
|
+
withDoc((doc) => reconnectEdge(doc, edge.id, connection));
|
|
428
|
+
}, []);
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Containers growing while something's dragged in them, and what's in them, shown here only until it's dropped
|
|
432
|
+
* (see previewContainerGrowth). The drop saves them as shown, in the same change.
|
|
433
|
+
*/
|
|
434
|
+
const showDragPreview = useCallback((previews: Map<string, NodePreview>) => {
|
|
435
|
+
dragPreview.current = previews;
|
|
436
|
+
setNodes((local) => {
|
|
437
|
+
let next: Node[] | undefined;
|
|
438
|
+
local.forEach((node, index) => {
|
|
439
|
+
const preview = previews.get(node.id);
|
|
440
|
+
if (
|
|
441
|
+
!preview ||
|
|
442
|
+
(node.position.x === preview.position.x &&
|
|
443
|
+
node.position.y === preview.position.y &&
|
|
444
|
+
(preview.width === undefined || (node.width === preview.width && node.height === preview.height)))
|
|
445
|
+
)
|
|
446
|
+
return;
|
|
447
|
+
next ??= [...local];
|
|
448
|
+
next[index] = { ...node, ...preview };
|
|
449
|
+
});
|
|
450
|
+
return next ?? local;
|
|
451
|
+
});
|
|
452
|
+
}, []);
|
|
453
|
+
|
|
316
454
|
/**
|
|
317
455
|
* Add a node centred on a canvas position. `connect` gets the new node's id and the nodes already
|
|
318
456
|
* on the canvas, and returns edges to add with it (e.g. a catalog resource's relationships).
|
|
@@ -323,12 +461,12 @@ export function useStudioFlow({
|
|
|
323
461
|
withDoc((doc) => {
|
|
324
462
|
const existing = Array.from(getCanvasMaps(doc).nodes.values());
|
|
325
463
|
// Dropped on a container: it goes in it
|
|
326
|
-
const group = findGroupAtPoint(center, existing);
|
|
464
|
+
const group = canGoInContainer(type) ? findGroupAtPoint(center, existing) : undefined;
|
|
327
465
|
const node = group
|
|
328
466
|
? { ...built, parentId: group.id, position: toRelativePosition(built.position, group.id, existing) }
|
|
329
467
|
: built;
|
|
330
468
|
addNodes(doc, [node], connect?.(node.id, existing) ?? []);
|
|
331
|
-
if (group) fitGroupToChildren(doc, group.id);
|
|
469
|
+
if (group) fitGroupToChildren(doc, group.id, renderedSizes());
|
|
332
470
|
});
|
|
333
471
|
return built.id;
|
|
334
472
|
},
|
|
@@ -363,7 +501,7 @@ export function useStudioFlow({
|
|
|
363
501
|
// Containers already on the canvas that got something new inside grow to fit it
|
|
364
502
|
const added = new Set(nodes.map((node) => node.id));
|
|
365
503
|
new Set(nodes.flatMap((node) => (node.parentId && !added.has(node.parentId) ? [node.parentId] : []))).forEach((groupId) =>
|
|
366
|
-
fitGroupToChildren(doc, groupId)
|
|
504
|
+
fitGroupToChildren(doc, groupId, renderedSizes())
|
|
367
505
|
);
|
|
368
506
|
}),
|
|
369
507
|
[]
|
|
@@ -375,11 +513,16 @@ export function useStudioFlow({
|
|
|
375
513
|
withDoc((doc) => {
|
|
376
514
|
// Where it was dropped goes first, so it's moved into (or out of) the container from there
|
|
377
515
|
if (dragged.current.size) saveDrop(doc);
|
|
378
|
-
return setNodeParent(doc, nodeId, parentId);
|
|
516
|
+
return setNodeParent(doc, nodeId, parentId, renderedSizes());
|
|
379
517
|
}),
|
|
380
518
|
[]
|
|
381
519
|
);
|
|
382
520
|
|
|
521
|
+
/** Changes the canvas's status for everyone (recorded with who did it, and why if given) */
|
|
522
|
+
const changeStatus = useCallback(
|
|
523
|
+
(status: CanvasStatus, note?: string) => withDoc((doc) => setCanvasStatus(doc, status, user.current, note), STATUS_ORIGIN),
|
|
524
|
+
[]
|
|
525
|
+
);
|
|
383
526
|
const rename = useCallback((next: string) => withDoc((doc) => setMeta(doc, { title: next || undefined })), []);
|
|
384
527
|
|
|
385
528
|
const undo = useCallback(() => sharedRef.current?.undoManager.undo(), []);
|
|
@@ -404,6 +547,8 @@ export function useStudioFlow({
|
|
|
404
547
|
onNodesChange,
|
|
405
548
|
onEdgesChange,
|
|
406
549
|
onConnect,
|
|
550
|
+
onReconnect,
|
|
551
|
+
showDragPreview,
|
|
407
552
|
addNode,
|
|
408
553
|
insertNode,
|
|
409
554
|
updateNodeData,
|
|
@@ -413,6 +558,8 @@ export function useStudioFlow({
|
|
|
413
558
|
setParent,
|
|
414
559
|
title,
|
|
415
560
|
rename,
|
|
561
|
+
canvasStatus,
|
|
562
|
+
changeStatus,
|
|
416
563
|
transport,
|
|
417
564
|
undo,
|
|
418
565
|
redo,
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Who you are in Studio: the name you give on your first visit, kept in this browser and used on every canvas
|
|
2
|
+
const COLORS = ['#ef4444', '#f97316', '#eab308', '#22c55e', '#06b6d4', '#3b82f6', '#8b5cf6', '#ec4899'];
|
|
3
|
+
const NAME_KEY = 'eventcatalog-studio-name';
|
|
4
|
+
|
|
5
|
+
export const getStoredName = () => {
|
|
6
|
+
try {
|
|
7
|
+
return localStorage.getItem(NAME_KEY);
|
|
8
|
+
} catch {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/** Whether it was kept (storage can be blocked, e.g. in some private windows) */
|
|
14
|
+
export const storeName = (name: string) => {
|
|
15
|
+
try {
|
|
16
|
+
localStorage.setItem(NAME_KEY, name);
|
|
17
|
+
return true;
|
|
18
|
+
} catch {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** Your name changing in another tab (the event only fires in the other tabs) */
|
|
24
|
+
export const onStoredNameChange = (listener: (name: string | null) => void) => {
|
|
25
|
+
const handle = (event: StorageEvent) => event.key === NAME_KEY && listener(event.newValue);
|
|
26
|
+
window.addEventListener('storage', handle);
|
|
27
|
+
return () => window.removeEventListener('storage', handle);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// Same name, same colour across reloads
|
|
31
|
+
export const colorForName = (name: string) => {
|
|
32
|
+
let hash = 0;
|
|
33
|
+
for (const char of name) hash = (hash * 31 + char.charCodeAt(0)) | 0;
|
|
34
|
+
return COLORS[Math.abs(hash) % COLORS.length];
|
|
35
|
+
};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Edge, Node, XYPosition } from '@xyflow/react';
|
|
2
2
|
import { layoutWithElk } from '@eventcatalog/visualiser/layout';
|
|
3
|
-
import { isGroupType } from './node-types';
|
|
3
|
+
import { isGroupType, isOnLevel } from './node-types';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Lays a canvas out with the same ELK layout as EventCatalog's visualiser (left to right, edges routed
|
|
@@ -22,11 +22,14 @@ export type LayoutResult = Map<string, { position: XYPosition; size?: { width: n
|
|
|
22
22
|
/**
|
|
23
23
|
* New positions (relative to their container, if they're in one) for the nodes on a canvas, and new sizes for
|
|
24
24
|
* containers. Only these are kept for the canvas people edit: the edge routes ELK draws would be out of date
|
|
25
|
-
* as soon as someone moves a node.
|
|
25
|
+
* as soon as someone moves a node. Notes added on L1 or L2 aren't on this canvas, so they stay where they are.
|
|
26
26
|
*/
|
|
27
27
|
export const getLayoutPositions = async (nodes: Node[], edges: Edge[]): Promise<LayoutResult> => {
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
const onCanvas = nodes.filter((node) => isOnLevel(node, 3));
|
|
29
|
+
if (onCanvas.length === 0) return new Map();
|
|
30
|
+
const ids = new Set(onCanvas.map((node) => node.id));
|
|
31
|
+
const between = edges.filter((edge) => ids.has(edge.source) && ids.has(edge.target));
|
|
32
|
+
const laidOut = withGroupSizes((await layoutGraph(onCanvas, between)).nodes);
|
|
30
33
|
return new Map(
|
|
31
34
|
laidOut.map((node) => [
|
|
32
35
|
node.id,
|