@eventcatalog/core 5.0.0-beta.1 → 5.0.0-beta.10
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 +71 -35
- 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-K2ACDO7Y.js → chunk-2LBDRHE4.js} +1 -1
- package/dist/{chunk-TQLRG35T.js → chunk-AWOO63ME.js} +71 -45
- package/dist/chunk-CPW6G62V.js +17 -0
- package/dist/{chunk-UDS3SYLZ.js → chunk-CRKJBDN7.js} +56 -33
- package/dist/{chunk-Q6SD5SNQ.js → chunk-EM36OL6A.js} +1 -1
- package/dist/{chunk-S2QGT7ZF.js → chunk-IONYKKOY.js} +1 -1
- package/dist/{chunk-KYJZJLBD.js → chunk-LJMXOEEC.js} +9 -3
- package/dist/constants.cjs +1 -1
- package/dist/constants.js +1 -1
- package/dist/eventcatalog.cjs +204 -124
- package/dist/eventcatalog.config.d.cts +59 -1
- package/dist/eventcatalog.config.d.ts +59 -1
- package/dist/eventcatalog.js +30 -11
- package/dist/federation/federate.js +2 -2
- package/dist/generate.cjs +1 -1
- package/dist/generate.js +3 -3
- package/dist/runtime-dependencies/@hocuspocus__provider.mjs +3 -0
- package/dist/runtime-dependencies/@hocuspocus__server.mjs +3 -0
- package/dist/runtime-dependencies/@mcp-b__webmcp-local-relay.mjs +3 -0
- package/dist/runtime-dependencies/@mcp-b__webmcp-polyfill.mjs +3 -0
- package/dist/runtime-dependencies/manifest.json +8 -1
- package/dist/runtime-dependencies/react-hotkeys-hook.mjs +3 -0
- package/dist/runtime-dependencies/y-protocols.mjs +3 -0
- package/dist/runtime-dependencies/y-protocols__awareness.mjs +3 -0
- package/dist/runtime-dependencies/yjs.mjs +3 -0
- package/dist/utils/cli-logger.cjs +1 -1
- package/dist/utils/cli-logger.js +2 -2
- package/dist/utils/license-status.cjs +71 -45
- 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/astro.config.mjs +5 -0
- package/eventcatalog/integrations/storage.mjs +20 -0
- package/eventcatalog/integrations/studio-server.mjs +32 -0
- package/eventcatalog/src/components/Grids/utils.tsx +0 -23
- package/eventcatalog/src/components/Header.astro +3 -3
- package/eventcatalog/src/components/MDX/CodeGroup/CodeGroup.tsx +6 -1
- package/eventcatalog/src/components/SchemaExplorer/SchemaDetailsPanel.tsx +12 -4
- package/eventcatalog/src/components/SideNav/NestedSideBar/index.tsx +16 -7
- package/eventcatalog/src/components/SideNav/NestedSideBar/utils.ts +10 -0
- package/eventcatalog/src/components/Tables/Discover/DiscoverTable.tsx +4 -62
- package/eventcatalog/src/components/Tables/Discover/columns.tsx +17 -150
- package/eventcatalog/src/components/Tables/Resources/ResourcesTable.tsx +425 -0
- package/eventcatalog/src/components/Tables/RowActionsMenu.tsx +157 -0
- package/eventcatalog/src/components/Tables/TablePagination.tsx +82 -0
- package/eventcatalog/src/features/ai/chat-api.ts +3 -3
- package/eventcatalog/src/features/auth/middleware/middleware-auth.ts +10 -0
- package/eventcatalog/src/features/integrations/eventcatalog-features.ts +34 -1
- package/eventcatalog/src/features/mcp/apps/canvas/shared.ts +47 -0
- package/eventcatalog/src/features/mcp/apps/canvas/styles.css +22 -0
- package/eventcatalog/src/features/mcp/apps/canvas/view.tsx +276 -0
- package/eventcatalog/src/features/mcp/apps/generated/canvas.html +210 -0
- package/eventcatalog/src/features/mcp/apps/generated/viewer.html +75 -74
- package/eventcatalog/src/features/mcp/apps/shared/app-view.tsx +2 -1
- package/eventcatalog/src/features/mcp/mcp-apps.ts +6 -3
- package/eventcatalog/src/features/mcp/mcp-server.ts +36 -5
- package/eventcatalog/src/features/server/start.ts +25 -0
- package/eventcatalog/src/features/storage/database.ts +88 -0
- package/eventcatalog/src/features/storage/migrations.ts +85 -0
- package/eventcatalog/src/features/storage/sql/database.sql +5 -0
- package/eventcatalog/src/features/storage/sql/migrations/0001-studio-canvases.sql +12 -0
- package/eventcatalog/src/features/storage/sql/migrations.sql +15 -0
- package/eventcatalog/src/features/storage/sql.ts +28 -0
- package/eventcatalog/src/features/studio/agent-choreography.ts +381 -0
- package/eventcatalog/src/features/studio/api/canvases-api.ts +170 -0
- package/eventcatalog/src/features/studio/api/canvases.ts +25 -0
- package/eventcatalog/src/features/studio/api/client.ts +35 -0
- package/eventcatalog/src/features/studio/canvas-actions.ts +414 -0
- package/eventcatalog/src/features/studio/canvas-doc.ts +414 -0
- package/eventcatalog/src/features/studio/canvas-preview.ts +53 -0
- package/eventcatalog/src/features/studio/catalog-resources.ts +155 -0
- package/eventcatalog/src/features/studio/catalog.ts +171 -0
- package/eventcatalog/src/features/studio/clipboard.ts +97 -0
- package/eventcatalog/src/features/studio/components/CanvasControls.tsx +154 -0
- package/eventcatalog/src/features/studio/components/CanvasHeader.tsx +370 -0
- package/eventcatalog/src/features/studio/components/CanvasStatusMenu.tsx +117 -0
- package/eventcatalog/src/features/studio/components/Comments.tsx +770 -0
- package/eventcatalog/src/features/studio/components/ConnectAgentDialog.tsx +281 -0
- package/eventcatalog/src/features/studio/components/ConnectionsChooser.tsx +144 -0
- package/eventcatalog/src/features/studio/components/ContainerChooser.tsx +145 -0
- package/eventcatalog/src/features/studio/components/DeleteCanvasDialog.tsx +81 -0
- package/eventcatalog/src/features/studio/components/DragGhost.tsx +115 -0
- package/eventcatalog/src/features/studio/components/JoinForm.tsx +34 -0
- package/eventcatalog/src/features/studio/components/LeftPanel.tsx +314 -0
- package/eventcatalog/src/features/studio/components/Picture.tsx +21 -0
- package/eventcatalog/src/features/studio/components/Presence.tsx +286 -0
- package/eventcatalog/src/features/studio/components/PropertiesPanel.tsx +212 -0
- package/eventcatalog/src/features/studio/components/ShareDialog.tsx +260 -0
- package/eventcatalog/src/features/studio/components/StudioDesigner.tsx +1216 -0
- package/eventcatalog/src/features/studio/components/StudioNotice.astro +24 -0
- package/eventcatalog/src/features/studio/components/canvas-nodes.tsx +255 -0
- package/eventcatalog/src/features/studio/components/icons.ts +53 -0
- package/eventcatalog/src/features/studio/components/status.ts +53 -0
- package/eventcatalog/src/features/studio/edges.ts +56 -0
- package/eventcatalog/src/features/studio/flow-state.ts +92 -0
- package/eventcatalog/src/features/studio/grouping.ts +226 -0
- package/eventcatalog/src/features/studio/hooks/presence-sender.ts +46 -0
- package/eventcatalog/src/features/studio/hooks/presence-store.ts +119 -0
- package/eventcatalog/src/features/studio/hooks/use-canvas-webmcp.ts +347 -0
- package/eventcatalog/src/features/studio/hooks/use-comments.ts +54 -0
- package/eventcatalog/src/features/studio/hooks/use-follow-camera.ts +104 -0
- package/eventcatalog/src/features/studio/hooks/use-studio-flow.ts +589 -0
- package/eventcatalog/src/features/studio/identity.ts +37 -0
- package/eventcatalog/src/features/studio/layout.ts +42 -0
- package/eventcatalog/src/features/studio/levels.ts +282 -0
- package/eventcatalog/src/features/studio/node-types.ts +205 -0
- package/eventcatalog/src/features/studio/pages/[id].astro +68 -0
- package/eventcatalog/src/features/studio/pages/index.astro +784 -0
- package/eventcatalog/src/features/studio/pages/webmcp-relay.ts +27 -0
- package/eventcatalog/src/features/studio/placement.ts +197 -0
- package/eventcatalog/src/features/studio/server/canvas-mcp.ts +510 -0
- package/eventcatalog/src/features/studio/server/runtime.ts +491 -0
- package/eventcatalog/src/features/studio/server/sign-in.ts +18 -0
- package/eventcatalog/src/features/studio/server/sql/storage.sql +18 -0
- package/eventcatalog/src/features/studio/server/storage.ts +90 -0
- package/eventcatalog/src/features/studio/server/tool-icons.ts +30 -0
- package/eventcatalog/src/features/studio/studio.css +273 -0
- package/eventcatalog/src/features/studio/tool-sync.ts +75 -0
- package/eventcatalog/src/layouts/VerticalSideBarLayout.astro +10 -5
- package/eventcatalog/src/pages/architecture/[type]/[id]/[version]/_index.data.spec.ts +52 -63
- package/eventcatalog/src/pages/architecture/[type]/[id]/[version]/_index.data.ts +34 -46
- package/eventcatalog/src/pages/architecture/[type]/[id]/[version]/index.astro +11 -29
- package/eventcatalog/src/pages/docs/[type]/[id]/[version]/resources/_ResourcesPage.astro +79 -0
- package/eventcatalog/src/pages/docs/[type]/[id]/[version]/resources/_index.data.spec.ts +32 -14
- package/eventcatalog/src/pages/docs/[type]/[id]/[version]/resources/_index.data.ts +63 -58
- package/eventcatalog/src/pages/docs/[type]/[id]/[version]/resources/index.astro +2 -62
- package/eventcatalog/src/pages/docs/services/[id]/[version]/resources/index.astro +13 -0
- package/eventcatalog/src/stores/sidebar-store/builders/domain.ts +24 -42
- package/eventcatalog/src/stores/sidebar-store/builders/flow.ts +2 -0
- package/eventcatalog/src/stores/sidebar-store/builders/message.ts +2 -0
- package/eventcatalog/src/stores/sidebar-store/builders/service.ts +23 -23
- package/eventcatalog/src/stores/sidebar-store/builders/shared.ts +13 -0
- package/eventcatalog/src/stores/sidebar-store/builders/system.ts +18 -27
- package/eventcatalog/src/stores/sidebar-store/state.ts +5 -0
- package/eventcatalog/src/styles/tailwind.css +2 -2
- package/eventcatalog/src/utils/collections/channels.ts +56 -1
- package/eventcatalog/src/utils/collections/flows.ts +50 -1
- package/eventcatalog/src/utils/collections/message-connections.ts +60 -0
- package/eventcatalog/src/utils/collections/message-triggers.ts +2 -1
- package/eventcatalog/src/utils/collections/resource-owners.ts +193 -0
- package/eventcatalog/src/utils/collections/resources.ts +310 -0
- package/eventcatalog/src/utils/collections/util.ts +31 -0
- package/eventcatalog/src/utils/feature.ts +8 -0
- package/eventcatalog/src/utils/git.ts +11 -5
- package/eventcatalog/src/utils/node-graphs/export-mermaid.ts +0 -2
- package/eventcatalog/src/utils/trial.ts +1 -1
- package/package.json +17 -12
- package/dist/runtime-dependencies/shelljs.mjs +0 -3
- package/eventcatalog/public/studio-bg.png +0 -0
- package/eventcatalog/src/components/Grids/DomainGrid.tsx +0 -838
- package/eventcatalog/src/components/Grids/MessageGrid.tsx +0 -344
- package/eventcatalog/src/components/Grids/SystemGrid.tsx +0 -185
- package/eventcatalog/src/components/Grids/components.tsx +0 -170
- package/eventcatalog/src/components/Grids/message-link.ts +0 -38
- package/eventcatalog/src/components/Studio/StudioPageModal.tsx +0 -150
- package/eventcatalog/src/components/Tables/SystemResources/SystemResourcesTable.tsx +0 -294
- package/eventcatalog/src/pages/docs/[type]/[id]/[version]/resources/_resources.spec.ts +0 -65
- package/eventcatalog/src/pages/docs/[type]/[id]/[version]/resources/_resources.ts +0 -46
- package/eventcatalog/src/pages/studio.astro +0 -316
- package/dist/{chunk-AYNZXUB5.js → chunk-KHACCTW2.js} +3 -3
|
@@ -0,0 +1,589 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import * as Y from 'yjs';
|
|
3
|
+
import { HocuspocusProvider } from '@hocuspocus/provider';
|
|
4
|
+
import { Awareness } from 'y-protocols/awareness';
|
|
5
|
+
import {
|
|
6
|
+
applyEdgeChanges,
|
|
7
|
+
applyNodeChanges,
|
|
8
|
+
type Connection,
|
|
9
|
+
type Edge,
|
|
10
|
+
type EdgeChange,
|
|
11
|
+
type Node,
|
|
12
|
+
type NodeChange,
|
|
13
|
+
type XYPosition,
|
|
14
|
+
} from '@xyflow/react';
|
|
15
|
+
import {
|
|
16
|
+
addNodes,
|
|
17
|
+
buildNode,
|
|
18
|
+
CANVAS_DELETED_REASON,
|
|
19
|
+
canvasDocumentName,
|
|
20
|
+
connectNodes,
|
|
21
|
+
deleteEdges,
|
|
22
|
+
deleteNodes as deleteNodesInDoc,
|
|
23
|
+
reconnectEdge,
|
|
24
|
+
fitContainersAround,
|
|
25
|
+
fitGroupToChildren,
|
|
26
|
+
getCanvasMaps,
|
|
27
|
+
moveNode,
|
|
28
|
+
changesNodesOrEdges,
|
|
29
|
+
getCanvasStatus,
|
|
30
|
+
readMeta,
|
|
31
|
+
reopenIfDecided,
|
|
32
|
+
resizeNode,
|
|
33
|
+
setCanvasStatus,
|
|
34
|
+
setMeta,
|
|
35
|
+
type Author,
|
|
36
|
+
type CanvasStatus,
|
|
37
|
+
type StatusChange,
|
|
38
|
+
setNodeParent,
|
|
39
|
+
type RenderedSizes,
|
|
40
|
+
updateNodeData as updateNodeDataInDoc,
|
|
41
|
+
} from '../canvas-doc';
|
|
42
|
+
import { applyLayout } from '../agent-choreography';
|
|
43
|
+
import { keepDragged, LOCAL_EDGE_KEYS, LOCAL_NODE_KEYS, patchShared, withPositions } from '../flow-state';
|
|
44
|
+
import { findGroupAtPoint, toRelativePosition, type NodePreview } from '../grouping';
|
|
45
|
+
import type { LayoutResult } from '../layout';
|
|
46
|
+
import { canGoInContainer, getNodeDefinition, isGroupType } from '../node-types';
|
|
47
|
+
import { startToolSync, type ToolSync } from '../tool-sync';
|
|
48
|
+
import { createPresenceSender, type PresenceSender } from './presence-sender';
|
|
49
|
+
import { createPresenceStore, type PresenceStore } from './presence-store';
|
|
50
|
+
|
|
51
|
+
export type { Peer } from './presence-store';
|
|
52
|
+
|
|
53
|
+
/** How long the WebSocket gets to connect before a view that can sync through tool calls switches to them */
|
|
54
|
+
const SOCKET_TIMEOUT_MS = 4000;
|
|
55
|
+
/**
|
|
56
|
+
* After someone drops what they were dragging, how long until it goes back to where the document has it, if the
|
|
57
|
+
* drop doesn't arrive (they left mid-drag). The drop normally arrives first, or within a few milliseconds.
|
|
58
|
+
*/
|
|
59
|
+
const DROP_SETTLE_MS = 500;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Changes made here that are already shown here (nodes dragged and resized with React Flow): the document
|
|
63
|
+
* observers skip them, and undo includes them
|
|
64
|
+
*/
|
|
65
|
+
const LOCAL_ORIGIN = Symbol('studio-local');
|
|
66
|
+
/** Changes an agent working through this tab (WebMCP) makes: synced like anyone's, and not in the user's undo */
|
|
67
|
+
export const BROWSER_AGENT_ORIGIN = Symbol('studio-browser-agent');
|
|
68
|
+
/** Status changes: synced, but not undone (undo takes back edits, not decisions about the canvas) */
|
|
69
|
+
const STATUS_ORIGIN = Symbol('studio-status');
|
|
70
|
+
|
|
71
|
+
export type Status = 'connecting' | 'connected' | 'disconnected';
|
|
72
|
+
export type Transport = 'websocket' | 'tools';
|
|
73
|
+
|
|
74
|
+
type Shared = { doc: Y.Doc; awareness: Awareness; undoManager: Y.UndoManager; sender: PresenceSender };
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* A React Flow design kept in a Yjs document (one map of nodes, one of edges), synced through Hocuspocus.
|
|
78
|
+
* Presence (who is here, their pointer and what they have selected) goes over Yjs awareness, in a store
|
|
79
|
+
* (see presence-store) rather than React state.
|
|
80
|
+
*
|
|
81
|
+
* Nodes being dragged go to everyone with our presence (with the pointer, about 30 times a second), and only
|
|
82
|
+
* where they're dropped is saved in the document: one change per drag, so the document doesn't grow with every
|
|
83
|
+
* step of every drag, and undo takes back the whole drag. Others show where we're dragging them as we do.
|
|
84
|
+
*/
|
|
85
|
+
const NEW_CANVAS_STATUS = { status: 'draft' as CanvasStatus, history: [] as StatusChange[] };
|
|
86
|
+
|
|
87
|
+
export function useStudioFlow({
|
|
88
|
+
canvasId,
|
|
89
|
+
socketUrl,
|
|
90
|
+
name,
|
|
91
|
+
color,
|
|
92
|
+
picture,
|
|
93
|
+
syncViaTools,
|
|
94
|
+
onLayout,
|
|
95
|
+
onContainerResize,
|
|
96
|
+
}: {
|
|
97
|
+
canvasId: string;
|
|
98
|
+
/** e.g. wss://catalog.example.com/_eventcatalog/studio */
|
|
99
|
+
socketUrl: string;
|
|
100
|
+
name: string;
|
|
101
|
+
color: string;
|
|
102
|
+
/** From your sign-in provider, when you're signed in */
|
|
103
|
+
picture?: string;
|
|
104
|
+
/** Sync through MCP tool calls when the WebSocket can't connect (e.g. inside a chat's sandbox) */
|
|
105
|
+
syncViaTools?: ToolSync;
|
|
106
|
+
/** Someone laid the canvas out (us included): called as the new positions arrive, so they can glide there */
|
|
107
|
+
onLayout?: () => void;
|
|
108
|
+
/**
|
|
109
|
+
* A container changed size (it grew to fit what's in it, or someone else resized it): called as it arrives, so
|
|
110
|
+
* it can grow smoothly. Not for our own resizing, which follows the pointer.
|
|
111
|
+
*/
|
|
112
|
+
onContainerResize?: () => void;
|
|
113
|
+
}) {
|
|
114
|
+
const sharedRef = useRef<Shared | null>(null);
|
|
115
|
+
const [nodes, setNodes] = useState<Node[]>([]);
|
|
116
|
+
const [edges, setEdges] = useState<Edge[]>([]);
|
|
117
|
+
const [status, setStatus] = useState<Status>('connecting');
|
|
118
|
+
const [presence, setPresence] = useState<PresenceStore | null>(null);
|
|
119
|
+
const [clientId, setClientId] = useState<number | null>(null);
|
|
120
|
+
// Exposed so other shared state (e.g. comments) can live in the same document
|
|
121
|
+
const [doc, setDoc] = useState<Y.Doc | null>(null);
|
|
122
|
+
const [history, setHistory] = useState({ canUndo: false, canRedo: false });
|
|
123
|
+
const [title, setTitle] = useState<string | undefined>();
|
|
124
|
+
const [canvasStatus, setCanvasStatusState] = useState<{ status: CanvasStatus; history: StatusChange[] }>(NEW_CANVAS_STATUS);
|
|
125
|
+
const [transport, setTransport] = useState<Transport>('websocket');
|
|
126
|
+
// Someone deleted the canvas: the server closed it for everyone, and won't open it again
|
|
127
|
+
const [deleted, setDeleted] = useState(false);
|
|
128
|
+
const user = useRef<Author>({ name, color });
|
|
129
|
+
user.current = { name, color, ...(picture && { picture }) };
|
|
130
|
+
const onLayoutRef = useRef(onLayout);
|
|
131
|
+
onLayoutRef.current = onLayout;
|
|
132
|
+
const onContainerResizeRef = useRef(onContainerResize);
|
|
133
|
+
onContainerResizeRef.current = onContainerResize;
|
|
134
|
+
|
|
135
|
+
useEffect(() => {
|
|
136
|
+
// A new connection starts from what the server has. Clear anything left from a previous one,
|
|
137
|
+
// since an empty document never fires an update that would remove it.
|
|
138
|
+
setNodes([]);
|
|
139
|
+
setEdges([]);
|
|
140
|
+
|
|
141
|
+
const doc = new Y.Doc();
|
|
142
|
+
const { nodes: yNodes, edges: yEdges, threads: yThreads, meta: yMeta } = getCanvasMaps(doc);
|
|
143
|
+
const syncMeta = (event: Y.YMapEvent<unknown>) => {
|
|
144
|
+
const meta = readMeta(doc);
|
|
145
|
+
setTitle(meta.title);
|
|
146
|
+
if (event.keysChanged.has('status') || event.keysChanged.has('statusHistory'))
|
|
147
|
+
setCanvasStatusState({ status: getCanvasStatus(meta), history: meta.statusHistory ?? [] });
|
|
148
|
+
if (event.keysChanged.has('layoutAt')) onLayoutRef.current?.();
|
|
149
|
+
};
|
|
150
|
+
yMeta.observe(syncMeta);
|
|
151
|
+
setTitle(undefined);
|
|
152
|
+
setCanvasStatusState(NEW_CANVAS_STATUS);
|
|
153
|
+
// Changing an accepted (or rejected) canvas here makes it a draft again, for everyone. Changes from others arrive
|
|
154
|
+
// already reopened by whoever made them.
|
|
155
|
+
const reopenOnEdit = (transaction: Y.Transaction) => {
|
|
156
|
+
if (!transaction.local || transaction.origin === STATUS_ORIGIN) return;
|
|
157
|
+
if (!changesNodesOrEdges(transaction)) return;
|
|
158
|
+
// Recorded against whoever changed it: this person, or their agent in the browser (WebMCP)
|
|
159
|
+
const by =
|
|
160
|
+
transaction.origin === BROWSER_AGENT_ORIGIN
|
|
161
|
+
? { name: `${user.current.name}'s agent`, color: user.current.color, agent: true }
|
|
162
|
+
: user.current;
|
|
163
|
+
queueMicrotask(() =>
|
|
164
|
+
doc.transact(() => {
|
|
165
|
+
reopenIfDecided(doc, by);
|
|
166
|
+
// The first thing put on a canvas started it (opening a new one to look around doesn't)
|
|
167
|
+
if (!readMeta(doc).createdAt) setMeta(doc, { createdAt: Date.now(), createdBy: by.name });
|
|
168
|
+
}, STATUS_ORIGIN)
|
|
169
|
+
);
|
|
170
|
+
};
|
|
171
|
+
doc.on('afterTransaction', reopenOnEdit);
|
|
172
|
+
// One presence for whichever connection is used
|
|
173
|
+
const awareness = new Awareness(doc);
|
|
174
|
+
awareness.setLocalStateField('user', user.current);
|
|
175
|
+
const presenceStore = createPresenceStore(awareness);
|
|
176
|
+
setPresence(presenceStore);
|
|
177
|
+
const sender = createPresenceSender(awareness);
|
|
178
|
+
// Let in by the server, not just open: with sign-in on, it refuses a socket without a session after it opens.
|
|
179
|
+
// `letIn` is for this connection; `socketConnected` stays once the socket has worked (no falling back after).
|
|
180
|
+
let socketConnected = false;
|
|
181
|
+
let letIn = false;
|
|
182
|
+
let stopToolSync: (() => void) | undefined;
|
|
183
|
+
let fallback: ReturnType<typeof setTimeout> | undefined;
|
|
184
|
+
// Declared before the provider: it reports its first status while it's being created
|
|
185
|
+
let socketStatus: Status = 'connecting';
|
|
186
|
+
let canvasDeleted = false;
|
|
187
|
+
const onCanvasDeleted = () => {
|
|
188
|
+
canvasDeleted = true;
|
|
189
|
+
clearTimeout(fallback);
|
|
190
|
+
stopToolSync?.();
|
|
191
|
+
provider.disconnect();
|
|
192
|
+
setDeleted(true);
|
|
193
|
+
setStatus('disconnected');
|
|
194
|
+
};
|
|
195
|
+
// Some views can't use the WebSocket (a chat's sandbox may block it, or open it without the person's session):
|
|
196
|
+
// sync through tool calls instead
|
|
197
|
+
const syncThroughTools = () => {
|
|
198
|
+
if (!syncViaTools || stopToolSync) return;
|
|
199
|
+
clearTimeout(fallback);
|
|
200
|
+
setTransport('tools');
|
|
201
|
+
stopToolSync = startToolSync(doc, awareness, { canvasId, sync: syncViaTools, onStatus: setStatus });
|
|
202
|
+
// After the tools take over, so the socket closing doesn't show as offline for a moment
|
|
203
|
+
provider.disconnect();
|
|
204
|
+
};
|
|
205
|
+
const provider = new HocuspocusProvider({
|
|
206
|
+
url: socketUrl,
|
|
207
|
+
name: canvasDocumentName(canvasId),
|
|
208
|
+
document: doc,
|
|
209
|
+
awareness,
|
|
210
|
+
onStatus: ({ status }) => {
|
|
211
|
+
socketStatus = status as Status;
|
|
212
|
+
if (status !== 'connected') letIn = false;
|
|
213
|
+
// Open isn't connected until the server lets us in (onAuthenticated)
|
|
214
|
+
if (!stopToolSync) setStatus(status === 'connected' && !letIn ? 'connecting' : (status as Status));
|
|
215
|
+
},
|
|
216
|
+
onAuthenticated: () => {
|
|
217
|
+
socketConnected = letIn = true;
|
|
218
|
+
if (!stopToolSync) setStatus('connected');
|
|
219
|
+
},
|
|
220
|
+
// Deleted while it was open here
|
|
221
|
+
onClose: ({ event }) => {
|
|
222
|
+
if (event.reason === CANVAS_DELETED_REASON) onCanvasDeleted();
|
|
223
|
+
},
|
|
224
|
+
// Not signed in (or the session ran out): a chat's view syncs through its tools, a page shows it's offline
|
|
225
|
+
// (coming back to the tab tries again, e.g. after signing in again elsewhere). Or the canvas was deleted.
|
|
226
|
+
onAuthenticationFailed: ({ reason }) => {
|
|
227
|
+
if (reason === CANVAS_DELETED_REASON) return onCanvasDeleted();
|
|
228
|
+
if (syncViaTools) return syncThroughTools();
|
|
229
|
+
provider.disconnect();
|
|
230
|
+
setStatus('disconnected');
|
|
231
|
+
},
|
|
232
|
+
});
|
|
233
|
+
setTransport('websocket');
|
|
234
|
+
setDeleted(false);
|
|
235
|
+
|
|
236
|
+
// Back online, or back on the tab: reconnect now rather than when the next retry is due
|
|
237
|
+
const reconnect = () => {
|
|
238
|
+
if (!stopToolSync && !canvasDeleted && socketStatus === 'disconnected') void provider.connect();
|
|
239
|
+
};
|
|
240
|
+
// Away from the tab: our pointer goes from everyone's canvas
|
|
241
|
+
const onVisibilityChange = () => {
|
|
242
|
+
if (document.visibilityState === 'visible') return reconnect();
|
|
243
|
+
sender.send({ pointer: null });
|
|
244
|
+
sender.flush();
|
|
245
|
+
};
|
|
246
|
+
window.addEventListener('online', reconnect);
|
|
247
|
+
document.addEventListener('visibilitychange', onVisibilityChange);
|
|
248
|
+
|
|
249
|
+
// A socket that isn't let in within a few seconds (blocked, or never answered) is given up for tool calls
|
|
250
|
+
if (syncViaTools) fallback = setTimeout(() => !socketConnected && syncThroughTools(), SOCKET_TIMEOUT_MS);
|
|
251
|
+
// Undo only takes back your own changes: other people's arrive with the provider as origin, agents' with theirs
|
|
252
|
+
const undoManager = new Y.UndoManager([yNodes, yEdges, yThreads], {
|
|
253
|
+
captureTimeout: 500,
|
|
254
|
+
trackedOrigins: new Set([null, LOCAL_ORIGIN]),
|
|
255
|
+
});
|
|
256
|
+
const updateHistory = () =>
|
|
257
|
+
setHistory({ canUndo: undoManager.undoStack.length > 0, canRedo: undoManager.redoStack.length > 0 });
|
|
258
|
+
undoManager.on('stack-item-added', updateHistory);
|
|
259
|
+
undoManager.on('stack-item-popped', updateHistory);
|
|
260
|
+
undoManager.on('stack-cleared', updateHistory);
|
|
261
|
+
setHistory({ canUndo: false, canRedo: false });
|
|
262
|
+
sharedRef.current = { doc, awareness, undoManager, sender };
|
|
263
|
+
setClientId(doc.clientID);
|
|
264
|
+
setDoc(doc);
|
|
265
|
+
|
|
266
|
+
// Where others are dragging nodes, from their presence, until they drop them
|
|
267
|
+
let remoteMoves = new Map<string, XYPosition>();
|
|
268
|
+
const settling = new Set<ReturnType<typeof setTimeout>>();
|
|
269
|
+
const syncMoves = (_: unknown, origin: unknown) => {
|
|
270
|
+
if (origin === 'local') return;
|
|
271
|
+
const next = new Map<string, XYPosition>();
|
|
272
|
+
awareness.getStates().forEach((state, id) => {
|
|
273
|
+
if (id === doc.clientID || !state.moving) return;
|
|
274
|
+
Object.entries(state.moving as Record<string, XYPosition>).forEach(([nodeId, position]) => next.set(nodeId, position));
|
|
275
|
+
});
|
|
276
|
+
const moved = new Map([...next].filter(([id, { x, y }]) => remoteMoves.get(id)?.x !== x || remoteMoves.get(id)?.y !== y));
|
|
277
|
+
const dropped = [...remoteMoves.keys()].filter((id) => !next.has(id));
|
|
278
|
+
remoteMoves = next;
|
|
279
|
+
if (moved.size) setNodes((local) => withPositions(local, moved));
|
|
280
|
+
if (dropped.length === 0) return;
|
|
281
|
+
// The drop is saved in the document. If it doesn't arrive, back to where the document has it.
|
|
282
|
+
const settle = setTimeout(() => {
|
|
283
|
+
settling.delete(settle);
|
|
284
|
+
const saved = dropped.flatMap((id) => {
|
|
285
|
+
const node = remoteMoves.has(id) ? undefined : yNodes.get(id);
|
|
286
|
+
return node ? [[id, node.position] as const] : [];
|
|
287
|
+
});
|
|
288
|
+
setNodes((local) => withPositions(local, new Map(saved)));
|
|
289
|
+
}, DROP_SETTLE_MS);
|
|
290
|
+
settling.add(settle);
|
|
291
|
+
};
|
|
292
|
+
awareness.on('change', syncMoves);
|
|
293
|
+
|
|
294
|
+
// Only what changed is replaced, so React Flow re-renders just those nodes
|
|
295
|
+
const syncNodes = (event: Y.YMapEvent<Node>) => {
|
|
296
|
+
if (event.transaction.origin === LOCAL_ORIGIN) return;
|
|
297
|
+
// A node someone was dragging changed in the document: they dropped it there
|
|
298
|
+
event.keysChanged.forEach((id) => remoteMoves.delete(id));
|
|
299
|
+
const containerResized = [...event.changes.keys].some(([id, change]) => {
|
|
300
|
+
const next = yNodes.get(id);
|
|
301
|
+
const previous = change.oldValue as Node | undefined;
|
|
302
|
+
return (
|
|
303
|
+
change.action === 'update' &&
|
|
304
|
+
isGroupType(next?.type) &&
|
|
305
|
+
(next?.width !== previous?.width || next?.height !== previous?.height)
|
|
306
|
+
);
|
|
307
|
+
});
|
|
308
|
+
if (containerResized) onContainerResizeRef.current?.();
|
|
309
|
+
setNodes((local) =>
|
|
310
|
+
keepDragged(
|
|
311
|
+
patchShared(local, event.keysChanged, (id) => yNodes.get(id), LOCAL_NODE_KEYS),
|
|
312
|
+
local
|
|
313
|
+
)
|
|
314
|
+
);
|
|
315
|
+
};
|
|
316
|
+
const syncEdges = (event: Y.YMapEvent<Edge>) => {
|
|
317
|
+
if (event.transaction.origin === LOCAL_ORIGIN) return;
|
|
318
|
+
setEdges((local) => patchShared(local, event.keysChanged, (id) => yEdges.get(id), LOCAL_EDGE_KEYS));
|
|
319
|
+
};
|
|
320
|
+
yNodes.observe(syncNodes);
|
|
321
|
+
yEdges.observe(syncEdges);
|
|
322
|
+
|
|
323
|
+
return () => {
|
|
324
|
+
clearTimeout(fallback);
|
|
325
|
+
settling.forEach(clearTimeout);
|
|
326
|
+
window.removeEventListener('online', reconnect);
|
|
327
|
+
document.removeEventListener('visibilitychange', onVisibilityChange);
|
|
328
|
+
awareness.off('change', syncMoves);
|
|
329
|
+
sender.destroy();
|
|
330
|
+
stopToolSync?.();
|
|
331
|
+
yNodes.unobserve(syncNodes);
|
|
332
|
+
yEdges.unobserve(syncEdges);
|
|
333
|
+
yMeta.unobserve(syncMeta);
|
|
334
|
+
doc.off('afterTransaction', reopenOnEdit);
|
|
335
|
+
presenceStore.destroy();
|
|
336
|
+
undoManager.destroy();
|
|
337
|
+
provider.destroy();
|
|
338
|
+
doc.destroy();
|
|
339
|
+
sharedRef.current = null;
|
|
340
|
+
setDoc(null);
|
|
341
|
+
setPresence(null);
|
|
342
|
+
};
|
|
343
|
+
}, [canvasId, socketUrl, syncViaTools]);
|
|
344
|
+
|
|
345
|
+
useEffect(() => {
|
|
346
|
+
sharedRef.current?.awareness.setLocalStateField('user', user.current);
|
|
347
|
+
}, [name, color, picture]);
|
|
348
|
+
|
|
349
|
+
// Tell others what we have selected so they can see it
|
|
350
|
+
const selectionKey = useMemo(
|
|
351
|
+
() =>
|
|
352
|
+
nodes
|
|
353
|
+
.filter((node) => node.selected)
|
|
354
|
+
.map((node) => node.id)
|
|
355
|
+
.join(','),
|
|
356
|
+
[nodes]
|
|
357
|
+
);
|
|
358
|
+
useEffect(() => {
|
|
359
|
+
sharedRef.current?.sender.send({ selection: selectionKey ? selectionKey.split(',') : [] });
|
|
360
|
+
}, [selectionKey]);
|
|
361
|
+
|
|
362
|
+
// The sizes nodes are rendered at here, for fitting containers to what's in them
|
|
363
|
+
const latestNodes = useRef(nodes);
|
|
364
|
+
latestNodes.current = nodes;
|
|
365
|
+
const renderedSizes = (): RenderedSizes =>
|
|
366
|
+
new Map(
|
|
367
|
+
latestNodes.current.flatMap((node) =>
|
|
368
|
+
node.measured?.width && node.measured.height
|
|
369
|
+
? [[node.id, { width: node.measured.width, height: node.measured.height }] as const]
|
|
370
|
+
: []
|
|
371
|
+
)
|
|
372
|
+
);
|
|
373
|
+
|
|
374
|
+
const withDoc = <T>(fn: (doc: Y.Doc) => T, origin: unknown = null) => {
|
|
375
|
+
const doc = sharedRef.current?.doc;
|
|
376
|
+
return doc ? doc.transact(() => fn(doc), origin) : undefined;
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
// Where the nodes being dragged are, until they're dropped, and the containers around them as they've grown
|
|
380
|
+
const dragged = useRef(new Map<string, XYPosition>());
|
|
381
|
+
const dragPreview = useRef(new Map<string, NodePreview>());
|
|
382
|
+
/** Saves where dragged nodes were dropped (in the transaction it's called in), and stops showing them as dragged */
|
|
383
|
+
const saveDrop = (doc: Y.Doc) => {
|
|
384
|
+
dragged.current.forEach((position, id) => moveNode(doc, id, position));
|
|
385
|
+
// Containers as they grew around what was dragged, and what else is in them (moved the other way if they grew up
|
|
386
|
+
// or left). Dragged nodes are where React Flow dropped them, already in their containers' grown coordinates.
|
|
387
|
+
dragPreview.current.forEach(({ position, width, height }, id) => {
|
|
388
|
+
const node = getCanvasMaps(doc).nodes.get(id);
|
|
389
|
+
if (node) getCanvasMaps(doc).nodes.set(id, { ...node, position, ...(width !== undefined && { width, height }) });
|
|
390
|
+
});
|
|
391
|
+
dragPreview.current = new Map();
|
|
392
|
+
dragged.current.clear();
|
|
393
|
+
const shared = sharedRef.current;
|
|
394
|
+
shared?.sender.send({ moving: null });
|
|
395
|
+
// After the drop is in the document (it's sent as the transaction ends)
|
|
396
|
+
queueMicrotask(() => shared?.sender.flush());
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
const onNodesChange = useCallback((changes: NodeChange[]) => {
|
|
400
|
+
setNodes((local) => applyNodeChanges(changes, local));
|
|
401
|
+
let dropped = false;
|
|
402
|
+
const resized: { id: string; dimensions: { width: number; height: number }; attributes: true | 'width' | 'height' }[] = [];
|
|
403
|
+
const resizeEnded: string[] = [];
|
|
404
|
+
for (const change of changes) {
|
|
405
|
+
if (change.type === 'position' && change.position) {
|
|
406
|
+
dragged.current.set(change.id, change.position);
|
|
407
|
+
if (!change.dragging) dropped = true;
|
|
408
|
+
}
|
|
409
|
+
// Containers resized with their handles
|
|
410
|
+
if (change.type === 'dimensions' && change.setAttributes && change.dimensions)
|
|
411
|
+
resized.push({ id: change.id, dimensions: change.dimensions, attributes: change.setAttributes });
|
|
412
|
+
if (change.type === 'dimensions' && change.resizing === false) resizeEnded.push(change.id);
|
|
413
|
+
}
|
|
414
|
+
// While dragging, nodes move here every frame and go to everyone with our presence; where they're dropped is saved
|
|
415
|
+
if (dropped) {
|
|
416
|
+
// Each drag is its own step to undo
|
|
417
|
+
sharedRef.current?.undoManager.stopCapturing();
|
|
418
|
+
withDoc(saveDrop, LOCAL_ORIGIN);
|
|
419
|
+
} else if (dragged.current.size) sharedRef.current?.sender.send({ moving: Object.fromEntries(dragged.current) });
|
|
420
|
+
if (resized.length)
|
|
421
|
+
withDoc(
|
|
422
|
+
(doc) => resized.forEach(({ id, dimensions, attributes }) => resizeNode(doc, id, dimensions, attributes)),
|
|
423
|
+
LOCAL_ORIGIN
|
|
424
|
+
);
|
|
425
|
+
// Resized inside a container (e.g. a system in a domain): the containers around it grow to fit it. Not as
|
|
426
|
+
// LOCAL_ORIGIN, so they grow here too.
|
|
427
|
+
if (resizeEnded.length) withDoc((doc) => resizeEnded.forEach((id) => fitContainersAround(doc, id, renderedSizes())));
|
|
428
|
+
// Removing a node also removes what's in it, its edges, and moves its comments: those come back through the observers
|
|
429
|
+
const removed = changes.filter((change) => change.type === 'remove').map((change) => change.id);
|
|
430
|
+
if (removed.length) withDoc((doc) => deleteNodesInDoc(doc, removed));
|
|
431
|
+
}, []);
|
|
432
|
+
|
|
433
|
+
const onEdgesChange = useCallback((changes: EdgeChange[]) => {
|
|
434
|
+
setEdges((local) => applyEdgeChanges(changes, local));
|
|
435
|
+
const removed = changes.filter((change) => change.type === 'remove').map((change) => change.id);
|
|
436
|
+
if (removed.length) withDoc((doc) => deleteEdges(doc, removed));
|
|
437
|
+
}, []);
|
|
438
|
+
|
|
439
|
+
const onConnect = useCallback((connection: Connection) => {
|
|
440
|
+
withDoc((doc) => connectNodes(doc, connection));
|
|
441
|
+
}, []);
|
|
442
|
+
|
|
443
|
+
/** A connection's end dragged to another node */
|
|
444
|
+
const onReconnect = useCallback((edge: Edge, connection: Connection) => {
|
|
445
|
+
withDoc((doc) => reconnectEdge(doc, edge.id, connection));
|
|
446
|
+
}, []);
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Containers growing while something's dragged in them, and what's in them, shown here only until it's dropped
|
|
450
|
+
* (see previewContainerGrowth). The drop saves them as shown, in the same change.
|
|
451
|
+
*/
|
|
452
|
+
const showDragPreview = useCallback((previews: Map<string, NodePreview>) => {
|
|
453
|
+
dragPreview.current = previews;
|
|
454
|
+
setNodes((local) => {
|
|
455
|
+
let next: Node[] | undefined;
|
|
456
|
+
local.forEach((node, index) => {
|
|
457
|
+
const preview = previews.get(node.id);
|
|
458
|
+
if (
|
|
459
|
+
!preview ||
|
|
460
|
+
(node.position.x === preview.position.x &&
|
|
461
|
+
node.position.y === preview.position.y &&
|
|
462
|
+
(preview.width === undefined || (node.width === preview.width && node.height === preview.height)))
|
|
463
|
+
)
|
|
464
|
+
return;
|
|
465
|
+
next ??= [...local];
|
|
466
|
+
next[index] = { ...node, ...preview };
|
|
467
|
+
});
|
|
468
|
+
return next ?? local;
|
|
469
|
+
});
|
|
470
|
+
}, []);
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Add a node centred on a canvas position. `connect` gets the new node's id and the nodes already
|
|
474
|
+
* on the canvas, and returns edges to add with it (e.g. a catalog resource's relationships).
|
|
475
|
+
*/
|
|
476
|
+
const insertNode = useCallback(
|
|
477
|
+
(type: string, data: Record<string, unknown>, center: XYPosition, connect?: (id: string, existing: Node[]) => Edge[]) => {
|
|
478
|
+
const built = buildNode(type, data, center);
|
|
479
|
+
withDoc((doc) => {
|
|
480
|
+
const existing = Array.from(getCanvasMaps(doc).nodes.values());
|
|
481
|
+
// Dropped on a container: it goes in it
|
|
482
|
+
const group = canGoInContainer(type) ? findGroupAtPoint(center, existing) : undefined;
|
|
483
|
+
const node = group
|
|
484
|
+
? { ...built, parentId: group.id, position: toRelativePosition(built.position, group.id, existing) }
|
|
485
|
+
: built;
|
|
486
|
+
addNodes(doc, [node], connect?.(node.id, existing) ?? []);
|
|
487
|
+
if (group) fitGroupToChildren(doc, group.id, renderedSizes());
|
|
488
|
+
});
|
|
489
|
+
return built.id;
|
|
490
|
+
},
|
|
491
|
+
[]
|
|
492
|
+
);
|
|
493
|
+
|
|
494
|
+
/** Add a node of a registry type, centred on a canvas position */
|
|
495
|
+
const addNode = useCallback(
|
|
496
|
+
(type: string, center: XYPosition) => {
|
|
497
|
+
const definition = getNodeDefinition(type);
|
|
498
|
+
return definition ? insertNode(type, definition.createData(), center) : undefined;
|
|
499
|
+
},
|
|
500
|
+
[insertNode]
|
|
501
|
+
);
|
|
502
|
+
|
|
503
|
+
const updateNodeData = useCallback(
|
|
504
|
+
(id: string, updater: (data: Record<string, unknown>) => Record<string, unknown>) =>
|
|
505
|
+
withDoc((doc) => updateNodeDataInDoc(doc, id, updater)),
|
|
506
|
+
[]
|
|
507
|
+
);
|
|
508
|
+
|
|
509
|
+
const deleteNodes = useCallback((ids: string[]) => withDoc((doc) => deleteNodesInDoc(doc, ids)), []);
|
|
510
|
+
|
|
511
|
+
/** Moves nodes (and resizes containers) from a layout in one change, so it undoes in one go */
|
|
512
|
+
const applyPositions = useCallback((layout: LayoutResult) => withDoc((doc) => applyLayout(doc, layout)), []);
|
|
513
|
+
|
|
514
|
+
/** Adds ready-made nodes and edges (e.g. a container with what's in it) in one change */
|
|
515
|
+
const insertNodes = useCallback(
|
|
516
|
+
(nodes: Node[], edges: Edge[] = []) =>
|
|
517
|
+
withDoc((doc) => {
|
|
518
|
+
addNodes(doc, nodes, edges);
|
|
519
|
+
// Containers already on the canvas that got something new inside grow to fit it
|
|
520
|
+
const added = new Set(nodes.map((node) => node.id));
|
|
521
|
+
new Set(nodes.flatMap((node) => (node.parentId && !added.has(node.parentId) ? [node.parentId] : []))).forEach((groupId) =>
|
|
522
|
+
fitGroupToChildren(doc, groupId, renderedSizes())
|
|
523
|
+
);
|
|
524
|
+
}),
|
|
525
|
+
[]
|
|
526
|
+
);
|
|
527
|
+
|
|
528
|
+
/** Puts a node in a container (or takes it out), e.g. after it's dragged there */
|
|
529
|
+
const setParent = useCallback(
|
|
530
|
+
(nodeId: string, parentId: string | undefined) =>
|
|
531
|
+
withDoc((doc) => {
|
|
532
|
+
// Where it was dropped goes first, so it's moved into (or out of) the container from there
|
|
533
|
+
if (dragged.current.size) saveDrop(doc);
|
|
534
|
+
return setNodeParent(doc, nodeId, parentId, renderedSizes());
|
|
535
|
+
}),
|
|
536
|
+
[]
|
|
537
|
+
);
|
|
538
|
+
|
|
539
|
+
/** Changes the canvas's status for everyone (recorded with who did it, and why if given) */
|
|
540
|
+
const changeStatus = useCallback(
|
|
541
|
+
(status: CanvasStatus, note?: string) => withDoc((doc) => setCanvasStatus(doc, status, user.current, note), STATUS_ORIGIN),
|
|
542
|
+
[]
|
|
543
|
+
);
|
|
544
|
+
const rename = useCallback((next: string) => withDoc((doc) => setMeta(doc, { title: next || undefined })), []);
|
|
545
|
+
|
|
546
|
+
const undo = useCallback(() => sharedRef.current?.undoManager.undo(), []);
|
|
547
|
+
const redo = useCallback(() => sharedRef.current?.undoManager.redo(), []);
|
|
548
|
+
|
|
549
|
+
/** Our pointer on the canvas, for everyone (sent about 30 times a second, so call it on every move) */
|
|
550
|
+
const setPointer = useCallback((pointer: XYPosition | null) => {
|
|
551
|
+
sharedRef.current?.sender.send({ pointer });
|
|
552
|
+
}, []);
|
|
553
|
+
|
|
554
|
+
const setViewport = useCallback((viewport: { x: number; y: number; zoom: number }) => {
|
|
555
|
+
sharedRef.current?.sender.send({ viewport });
|
|
556
|
+
}, []);
|
|
557
|
+
|
|
558
|
+
return {
|
|
559
|
+
nodes,
|
|
560
|
+
edges,
|
|
561
|
+
status,
|
|
562
|
+
presence,
|
|
563
|
+
clientId,
|
|
564
|
+
doc,
|
|
565
|
+
onNodesChange,
|
|
566
|
+
onEdgesChange,
|
|
567
|
+
onConnect,
|
|
568
|
+
onReconnect,
|
|
569
|
+
showDragPreview,
|
|
570
|
+
addNode,
|
|
571
|
+
insertNode,
|
|
572
|
+
updateNodeData,
|
|
573
|
+
deleteNodes,
|
|
574
|
+
applyPositions,
|
|
575
|
+
insertNodes,
|
|
576
|
+
setParent,
|
|
577
|
+
title,
|
|
578
|
+
rename,
|
|
579
|
+
canvasStatus,
|
|
580
|
+
changeStatus,
|
|
581
|
+
transport,
|
|
582
|
+
deleted,
|
|
583
|
+
undo,
|
|
584
|
+
redo,
|
|
585
|
+
...history,
|
|
586
|
+
setPointer,
|
|
587
|
+
setViewport,
|
|
588
|
+
};
|
|
589
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
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
|
+
/** Names people give are at most this long */
|
|
5
|
+
export const MAX_NAME_LENGTH = 60;
|
|
6
|
+
|
|
7
|
+
export const getStoredName = () => {
|
|
8
|
+
try {
|
|
9
|
+
return localStorage.getItem(NAME_KEY);
|
|
10
|
+
} catch {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/** Whether it was kept (storage can be blocked, e.g. in some private windows) */
|
|
16
|
+
export const storeName = (name: string) => {
|
|
17
|
+
try {
|
|
18
|
+
localStorage.setItem(NAME_KEY, name);
|
|
19
|
+
return true;
|
|
20
|
+
} catch {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** Your name changing in another tab (the event only fires in the other tabs) */
|
|
26
|
+
export const onStoredNameChange = (listener: (name: string | null) => void) => {
|
|
27
|
+
const handle = (event: StorageEvent) => event.key === NAME_KEY && listener(event.newValue);
|
|
28
|
+
window.addEventListener('storage', handle);
|
|
29
|
+
return () => window.removeEventListener('storage', handle);
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// Same name, same colour across reloads
|
|
33
|
+
export const colorForName = (name: string) => {
|
|
34
|
+
let hash = 0;
|
|
35
|
+
for (const char of name) hash = (hash * 31 + char.charCodeAt(0)) | 0;
|
|
36
|
+
return COLORS[Math.abs(hash) % COLORS.length];
|
|
37
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { Edge, Node, XYPosition } from '@xyflow/react';
|
|
2
|
+
import { layoutWithElk } from '@eventcatalog/visualiser/layout';
|
|
3
|
+
import { isGroupType, isOnLevel } from './node-types';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Lays a canvas out with the same ELK layout as EventCatalog's visualiser (left to right, edges routed
|
|
7
|
+
* around nodes, containers laid out with what's in them and sized to fit). Nodes are sized as rendered when
|
|
8
|
+
* they have been (in the browser), or by their type.
|
|
9
|
+
*/
|
|
10
|
+
export const layoutGraph = (nodes: Node[], edges: Edge[]) => layoutWithElk({ nodes, edges });
|
|
11
|
+
|
|
12
|
+
/** The layout sizes containers with style.width/height; the canvas sizes them with width/height */
|
|
13
|
+
export const withGroupSizes = (nodes: Node[]) =>
|
|
14
|
+
nodes.map((node) => {
|
|
15
|
+
const { width, height, ...style } = node.style ?? {};
|
|
16
|
+
if (typeof width !== 'number' || typeof height !== 'number') return node;
|
|
17
|
+
return { ...node, width, height, style };
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
export type LayoutResult = Map<string, { position: XYPosition; size?: { width: number; height: number } }>;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* New positions (relative to their container, if they're in one) for the nodes on a canvas, and new sizes for
|
|
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. Notes added on L1 or L2 aren't on this canvas, so they stay where they are.
|
|
26
|
+
*/
|
|
27
|
+
export const getLayoutPositions = async (nodes: Node[], edges: Edge[]): Promise<LayoutResult> => {
|
|
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);
|
|
33
|
+
return new Map(
|
|
34
|
+
laidOut.map((node) => [
|
|
35
|
+
node.id,
|
|
36
|
+
{
|
|
37
|
+
position: node.position,
|
|
38
|
+
...(isGroupType(node.type) && node.width && node.height && { size: { width: node.width, height: node.height } }),
|
|
39
|
+
},
|
|
40
|
+
])
|
|
41
|
+
);
|
|
42
|
+
};
|