@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,1216 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type MouseEvent as ReactMouseEvent } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
Background,
|
|
4
|
+
ConnectionLineType,
|
|
5
|
+
ReactFlow,
|
|
6
|
+
ReactFlowProvider,
|
|
7
|
+
useReactFlow,
|
|
8
|
+
type Edge,
|
|
9
|
+
type Node,
|
|
10
|
+
type NodeChange,
|
|
11
|
+
type XYPosition,
|
|
12
|
+
} from '@xyflow/react';
|
|
13
|
+
import { edgeTypes } from '@eventcatalog/visualiser';
|
|
14
|
+
import { useHotkeys } from 'react-hotkeys-hook';
|
|
15
|
+
import { getCatalogConnections, indexCatalog, planWithConnections, type ConnectionGroup } from '../canvas-actions';
|
|
16
|
+
import { isDecided } from '../canvas-doc';
|
|
17
|
+
import { CLIPBOARD_TYPE, copyNodes, pasteNodes, readClipboard, type ClipboardContent } from '../clipboard';
|
|
18
|
+
import type { CatalogRelation, CatalogResource } from '../catalog-resources';
|
|
19
|
+
import {
|
|
20
|
+
buildContainer,
|
|
21
|
+
buildContainerWithContents,
|
|
22
|
+
canBeContainer,
|
|
23
|
+
catalogNodeData,
|
|
24
|
+
getCatalogLink,
|
|
25
|
+
getContents,
|
|
26
|
+
getRelatedEdges,
|
|
27
|
+
} from '../catalog';
|
|
28
|
+
import {
|
|
29
|
+
findDropTarget,
|
|
30
|
+
findGroupAtPoint,
|
|
31
|
+
getAbsolutePosition,
|
|
32
|
+
previewContainerGrowth,
|
|
33
|
+
sizeOf,
|
|
34
|
+
sortByHierarchy,
|
|
35
|
+
toRelativePosition,
|
|
36
|
+
} from '../grouping';
|
|
37
|
+
import type { Peer } from '../hooks/presence-store';
|
|
38
|
+
import { useCanvasWebMcp } from '../hooks/use-canvas-webmcp';
|
|
39
|
+
import { useStudioFlow } from '../hooks/use-studio-flow';
|
|
40
|
+
import { useComments, type CommentAnchor, type Thread } from '../hooks/use-comments';
|
|
41
|
+
import { getLayoutPositions, layoutGraph } from '../layout';
|
|
42
|
+
import { getLevelGraph, getLevelUnavailableReason, LEVELS, type Level } from '../levels';
|
|
43
|
+
import { getNodeDefinition, getNodeLabel, getNodeName, getNoteLevel, isOnLevel, isWrittenOnCanvas } from '../node-types';
|
|
44
|
+
import type { ToolSync } from '../tool-sync';
|
|
45
|
+
import CanvasControls, { FIT_VIEW_OPTIONS } from './CanvasControls';
|
|
46
|
+
import CanvasHeader from './CanvasHeader';
|
|
47
|
+
import { DropTargetContext, nodeTypes, UpdateNodeDataContext } from './canvas-nodes';
|
|
48
|
+
import { anchorAt, CanvasContextMenu, CommentLayer, getAnchorPosition, timeAgo, type ContextMenuState } from './Comments';
|
|
49
|
+
import { CANVAS_STATUS_LOOK } from './status';
|
|
50
|
+
import ConnectAgentDialog, { enableWebMcp, isRelayEnabled, isWebMcpEnabled, loadRelay } from './ConnectAgentDialog';
|
|
51
|
+
import ContainerChooser, { type ContainerChoice } from './ContainerChooser';
|
|
52
|
+
import ConnectionsChooser, { type ConnectionsChoice } from './ConnectionsChooser';
|
|
53
|
+
import JoinForm from './JoinForm';
|
|
54
|
+
import { colorForName, getStoredName, onStoredNameChange, storeName } from '../identity';
|
|
55
|
+
import LeftPanel, { CATALOG_DRAG_TYPE, COMPONENT_DRAG_TYPE } from './LeftPanel';
|
|
56
|
+
import { AgentActivity, RemotePresence, ViewportSharer } from './Presence';
|
|
57
|
+
import PropertiesPanel from './PropertiesPanel';
|
|
58
|
+
import ShareDialog from './ShareDialog';
|
|
59
|
+
import DeleteCanvasDialog from './DeleteCanvasDialog';
|
|
60
|
+
import { deleteCanvasThroughApi } from '../api/client';
|
|
61
|
+
|
|
62
|
+
type CanvasProps = {
|
|
63
|
+
canvasId: string;
|
|
64
|
+
/** Path of the collaboration socket on this site, or `socketUrl` when embedded elsewhere (e.g. an MCP App) */
|
|
65
|
+
socketPath?: string;
|
|
66
|
+
socketUrl?: string;
|
|
67
|
+
/** Where "New canvas" goes (hidden when not given, e.g. inside a chat) */
|
|
68
|
+
newCanvasUrl?: string;
|
|
69
|
+
/** The Studio page (where you go after deleting the canvas, or finding it deleted) */
|
|
70
|
+
studioUrl?: string;
|
|
71
|
+
/** The Studio API's canvases on this site: deleting the canvas is offered when given (not inside a chat) */
|
|
72
|
+
canvasesApiUrl?: string;
|
|
73
|
+
/** The canvas's link, for Share (defaults to this page) */
|
|
74
|
+
shareUrl?: string;
|
|
75
|
+
/** The EventCatalog MCP server agents connect to (a path on this site, or a URL) */
|
|
76
|
+
mcpUrl?: string;
|
|
77
|
+
/** The WebMCP local relay's script, when this page can offer it (not inside a chat) */
|
|
78
|
+
relayScriptPath?: string;
|
|
79
|
+
/** Canvases are only kept in memory, until the server restarts (`storage` is "memory") */
|
|
80
|
+
keptInMemory?: boolean;
|
|
81
|
+
/** Sync through MCP tool calls when the WebSocket can't connect (e.g. inside a chat's sandbox) */
|
|
82
|
+
syncViaTools?: ToolSync;
|
|
83
|
+
/** Follow agents' changes with the camera from the start (e.g. inside a chat, where you watch an agent work) */
|
|
84
|
+
followChanges?: boolean;
|
|
85
|
+
/** Told what's selected (e.g. a chat attaches it to the conversation, so you can ask about it) */
|
|
86
|
+
onSelectionChange?: (selected: SelectedNode[]) => void;
|
|
87
|
+
/** Join with this name instead of asking for one (e.g. inside a chat, where the agent or host knows who it is) */
|
|
88
|
+
defaultName?: string;
|
|
89
|
+
/** Signed in with the catalog's sign-in (SSO): you're shown by this name and picture, and can't change them */
|
|
90
|
+
signedInAs?: { name: string; picture?: string };
|
|
91
|
+
resources: CatalogResource[];
|
|
92
|
+
relations: CatalogRelation[];
|
|
93
|
+
};
|
|
94
|
+
/** A selected node, as told to whoever embeds the canvas */
|
|
95
|
+
export type SelectedNode = { id: string; name: string; type: string; catalogResource?: string };
|
|
96
|
+
|
|
97
|
+
/** How long a level (L1, L2) waits for changes to settle before it's laid out again */
|
|
98
|
+
const LEVEL_RELAYOUT_MS = 300;
|
|
99
|
+
const DELETE_KEYS = ['Backspace', 'Delete'];
|
|
100
|
+
const LEVEL_FIT_VIEW_OPTIONS = { ...FIT_VIEW_OPTIONS, maxZoom: 1 };
|
|
101
|
+
const NO_THREADS: ReadonlySet<string> = new Set();
|
|
102
|
+
// React Flow's warnings, except that a handle has no node: the left panel's drag previews draw nodes off the canvas
|
|
103
|
+
const onFlowError = (code: string, message: string) => {
|
|
104
|
+
if (code !== '010') console.warn(message);
|
|
105
|
+
};
|
|
106
|
+
const MULTI_SELECTION_KEYS = ['Meta', 'Shift'];
|
|
107
|
+
/**
|
|
108
|
+
* Like design tools: dragging on the canvas draws a box to select what's inside it, and the canvas pans with
|
|
109
|
+
* space + drag, the middle mouse button, or scrolling (two fingers on a trackpad). Pinch, or cmd / ctrl + scroll,
|
|
110
|
+
* zooms. The right button stays for the context menu.
|
|
111
|
+
*/
|
|
112
|
+
const PAN_BUTTONS = [1];
|
|
113
|
+
/** How close to a connection's end you grab it to move it */
|
|
114
|
+
const RECONNECT_RADIUS = 14;
|
|
115
|
+
const ZOOM_KEYS = ['Meta', 'Control'];
|
|
116
|
+
const DEFAULT_SOCKET_PATH = '/_eventcatalog/studio';
|
|
117
|
+
|
|
118
|
+
/** A function that keeps its identity across renders and always calls the latest version (for React Flow's props) */
|
|
119
|
+
const useStableCallback = <Args extends unknown[], Result>(fn: (...args: Args) => Result) => {
|
|
120
|
+
const latest = useRef(fn);
|
|
121
|
+
latest.current = fn;
|
|
122
|
+
return useCallback((...args: Args) => latest.current(...args), []);
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
// A message edge drawn as a plain labelled edge (kept per edge, so React Flow sees the same edge until it changes)
|
|
126
|
+
const staticEdges = new WeakMap<Edge, Edge>();
|
|
127
|
+
const withoutEnvelope = (edge: Edge) => {
|
|
128
|
+
let drawn = staticEdges.get(edge);
|
|
129
|
+
if (!drawn) staticEdges.set(edge, (drawn = { ...edge, type: 'smoothstep' }));
|
|
130
|
+
return drawn;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
/** Ids, types and containers: what changes when nodes are added, removed or moved in or out of containers */
|
|
134
|
+
// Notes on L1 and L2 can be moved and selected there, unlike the rest of the level (made once per note, so a note
|
|
135
|
+
// keeps its identity until it changes)
|
|
136
|
+
const levelNotes = new WeakMap<Node, Node>();
|
|
137
|
+
const asLevelNote = (node: Node) => {
|
|
138
|
+
let note = levelNotes.get(node);
|
|
139
|
+
if (!note) levelNotes.set(node, (note = { ...node, draggable: true, selectable: true }));
|
|
140
|
+
return note;
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const structureOf = (nodes: Node[]) => nodes.map((node) => `${node.id}:${node.type}:${node.parentId ?? ''}`).join('|');
|
|
144
|
+
|
|
145
|
+
export default function StudioDesigner({ defaultName, signedInAs, ...props }: CanvasProps) {
|
|
146
|
+
const [name, setName] = useState(() => getStoredName() ?? defaultName ?? null);
|
|
147
|
+
|
|
148
|
+
const saveName = useCallback((next: string) => {
|
|
149
|
+
setName(next);
|
|
150
|
+
storeName(next);
|
|
151
|
+
}, []);
|
|
152
|
+
// Renamed on the Studio page or another canvas in this browser
|
|
153
|
+
useEffect(() => onStoredNameChange((next) => next && setName(next)), []);
|
|
154
|
+
|
|
155
|
+
if (signedInAs) {
|
|
156
|
+
return (
|
|
157
|
+
<ReactFlowProvider>
|
|
158
|
+
<Canvas {...props} name={signedInAs.name} picture={signedInAs.picture} />
|
|
159
|
+
</ReactFlowProvider>
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
if (!name) return <JoinForm onJoin={saveName} />;
|
|
163
|
+
|
|
164
|
+
return (
|
|
165
|
+
<ReactFlowProvider>
|
|
166
|
+
<Canvas {...props} name={name} onRename={saveName} />
|
|
167
|
+
</ReactFlowProvider>
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const toSocketUrl = (socketPath = DEFAULT_SOCKET_PATH) =>
|
|
172
|
+
`${window.location.protocol === 'https:' ? 'wss' : 'ws'}://${window.location.host}${socketPath}`;
|
|
173
|
+
|
|
174
|
+
function Canvas({
|
|
175
|
+
canvasId,
|
|
176
|
+
socketPath,
|
|
177
|
+
socketUrl,
|
|
178
|
+
newCanvasUrl,
|
|
179
|
+
studioUrl,
|
|
180
|
+
canvasesApiUrl,
|
|
181
|
+
shareUrl,
|
|
182
|
+
mcpUrl = '/docs/mcp',
|
|
183
|
+
relayScriptPath,
|
|
184
|
+
keptInMemory = false,
|
|
185
|
+
syncViaTools,
|
|
186
|
+
followChanges = false,
|
|
187
|
+
onSelectionChange,
|
|
188
|
+
resources,
|
|
189
|
+
relations,
|
|
190
|
+
name,
|
|
191
|
+
picture,
|
|
192
|
+
onRename,
|
|
193
|
+
}: Omit<CanvasProps, 'defaultName' | 'signedInAs'> & {
|
|
194
|
+
name: string;
|
|
195
|
+
picture?: string;
|
|
196
|
+
/** Not given when you're signed in (your name is the one you signed in with) */
|
|
197
|
+
onRename?: (name: string) => void;
|
|
198
|
+
}) {
|
|
199
|
+
const color = colorForName(name);
|
|
200
|
+
const canvasRef = useRef<HTMLDivElement>(null);
|
|
201
|
+
|
|
202
|
+
// Nodes glide to their new place after a layout (anyone's) or a level change (not while editing: it would make dragging lag)
|
|
203
|
+
const [transitioning, setTransitioning] = useState(false);
|
|
204
|
+
const transitionTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
|
205
|
+
const glide = useCallback(() => {
|
|
206
|
+
setTransitioning(true);
|
|
207
|
+
clearTimeout(transitionTimer.current);
|
|
208
|
+
transitionTimer.current = setTimeout(() => setTransitioning(false), 600);
|
|
209
|
+
}, []);
|
|
210
|
+
|
|
211
|
+
// Containers grow smoothly when they're fitted to what's in them (or someone else resizes them). Set on the
|
|
212
|
+
// element rather than in state, so it doesn't re-render the canvas.
|
|
213
|
+
const resizeTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
|
214
|
+
const animateContainers = useCallback(() => {
|
|
215
|
+
canvasRef.current?.classList.add('is-container-resize');
|
|
216
|
+
clearTimeout(resizeTimer.current);
|
|
217
|
+
resizeTimer.current = setTimeout(() => canvasRef.current?.classList.remove('is-container-resize'), 400);
|
|
218
|
+
}, []);
|
|
219
|
+
|
|
220
|
+
const flow = useStudioFlow({
|
|
221
|
+
canvasId,
|
|
222
|
+
socketUrl: socketUrl ?? toSocketUrl(socketPath),
|
|
223
|
+
name,
|
|
224
|
+
color,
|
|
225
|
+
picture,
|
|
226
|
+
syncViaTools,
|
|
227
|
+
onLayout: glide,
|
|
228
|
+
onContainerResize: animateContainers,
|
|
229
|
+
});
|
|
230
|
+
const author = useMemo(() => ({ name, color, ...(picture && { picture }) }), [name, color, picture]);
|
|
231
|
+
const comments = useComments(flow.doc, author);
|
|
232
|
+
const { screenToFlowPosition, flowToScreenPosition, setCenter, getZoom, fitView } = useReactFlow();
|
|
233
|
+
|
|
234
|
+
const catalog = useMemo(() => indexCatalog(resources, relations), [resources, relations]);
|
|
235
|
+
|
|
236
|
+
const nodesRef = useRef(flow.nodes);
|
|
237
|
+
nodesRef.current = flow.nodes;
|
|
238
|
+
const edgesRef = useRef(flow.edges);
|
|
239
|
+
edgesRef.current = flow.edges;
|
|
240
|
+
const lookupNodes = () => new Map(nodesRef.current.map((node) => [node.id, node]));
|
|
241
|
+
|
|
242
|
+
// Agents in the browser (WebMCP) work on this canvas through the page, as the user's agent. Browsers without
|
|
243
|
+
// WebMCP of their own get it through the polyfill once someone turns it on (Connect agent).
|
|
244
|
+
const [webMcpEnabled, setWebMcpEnabled] = useState(isWebMcpEnabled);
|
|
245
|
+
const turnOnWebMcp = useCallback(() => {
|
|
246
|
+
enableWebMcp();
|
|
247
|
+
setWebMcpEnabled(true);
|
|
248
|
+
}, []);
|
|
249
|
+
const webMcp = useCanvasWebMcp({
|
|
250
|
+
allowPolyfill: webMcpEnabled,
|
|
251
|
+
doc: flow.doc,
|
|
252
|
+
presence: flow.presence,
|
|
253
|
+
getPeers: () => flow.presence?.getPeers() ?? [],
|
|
254
|
+
resources,
|
|
255
|
+
catalog,
|
|
256
|
+
agent: { name: `${name}'s agent`, color, agent: true },
|
|
257
|
+
select: (ids) =>
|
|
258
|
+
flow.onNodesChange(nodesRef.current.map((node) => ({ type: 'select', id: node.id, selected: ids.includes(node.id) }))),
|
|
259
|
+
fitView: (ids) => void fitView({ nodes: ids?.map((id) => ({ id })), duration: 400, padding: 0.3, maxZoom: 1.2 }),
|
|
260
|
+
reorder: () => reorder(),
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
// Recomputed only when nodes are added, removed or change container, not as they move
|
|
264
|
+
const structure = structureOf(flow.nodes);
|
|
265
|
+
const catalogKeys = flow.nodes
|
|
266
|
+
.map((node) => getCatalogLink(node)?.key)
|
|
267
|
+
.filter(Boolean)
|
|
268
|
+
.join('\n');
|
|
269
|
+
const keysOnCanvas = useMemo(() => new Set(catalogKeys ? catalogKeys.split('\n') : []), [catalogKeys]);
|
|
270
|
+
|
|
271
|
+
/** Middle of the visible canvas, spread out so repeated adds don't land on top of each other */
|
|
272
|
+
const canvasCenter = () => {
|
|
273
|
+
const rect = canvasRef.current!.getBoundingClientRect();
|
|
274
|
+
const step = nodesRef.current.length % 6;
|
|
275
|
+
const offset = { x: ((step % 3) - 1) * 240, y: (Math.floor(step / 3) - 0.5) * 160 };
|
|
276
|
+
return screenToFlowPosition({ x: rect.left + rect.width / 2 + offset.x, y: rect.top + rect.height / 2 + offset.y });
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
// ---- Adding things ----
|
|
280
|
+
|
|
281
|
+
// A domain or system dropped on the canvas: asked whether it's a card or a container first
|
|
282
|
+
const [chooser, setChooser] = useState<{ resource: CatalogResource; center: XYPosition } | null>(null);
|
|
283
|
+
// A service or message that connects to things not on the canvas yet: asked whether to bring them too
|
|
284
|
+
const [connectionsChooser, setConnectionsChooser] = useState<{
|
|
285
|
+
resource: CatalogResource;
|
|
286
|
+
center: XYPosition;
|
|
287
|
+
groups: ConnectionGroup[];
|
|
288
|
+
} | null>(null);
|
|
289
|
+
|
|
290
|
+
// A catalog resource is added with edges to the related resources already on the canvas
|
|
291
|
+
const addResource = useStableCallback((key: string, center: XYPosition = canvasCenter()) => {
|
|
292
|
+
const resource = catalog.resourcesByKey.get(key);
|
|
293
|
+
if (!resource) return;
|
|
294
|
+
if (canBeContainer(resource)) return setChooser({ resource, center });
|
|
295
|
+
const groups = getCatalogConnections(resource, catalog, keysOnCanvas);
|
|
296
|
+
if (groups.length > 0) return setConnectionsChooser({ resource, center, groups });
|
|
297
|
+
addCard(resource, center);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
const addCard = (resource: CatalogResource, center: XYPosition) =>
|
|
301
|
+
flow.insertNode(resource.node.type, catalogNodeData(resource), center, (id, existing) =>
|
|
302
|
+
getRelatedEdges(resource, id, existing, catalog.relationsByKey)
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
// Nodes waiting to be shown before the canvas is laid out around them
|
|
306
|
+
const reorderOnceShown = useRef<string[] | null>(null);
|
|
307
|
+
useEffect(() => {
|
|
308
|
+
const waiting = reorderOnceShown.current;
|
|
309
|
+
if (!waiting) return;
|
|
310
|
+
const shown = new Set(flow.nodes.map((node) => node.id));
|
|
311
|
+
if (!waiting.every((id) => shown.has(id))) return;
|
|
312
|
+
reorderOnceShown.current = null;
|
|
313
|
+
// A frame later, so they've been measured
|
|
314
|
+
requestAnimationFrame(() => void reorder());
|
|
315
|
+
}, [flow.nodes]);
|
|
316
|
+
|
|
317
|
+
/** A domain or system as a container, with everything it holds in the catalog inside if asked */
|
|
318
|
+
const addContainer = async (resource: CatalogResource, center: XYPosition, withContents: boolean) => {
|
|
319
|
+
const existing = nodesRef.current;
|
|
320
|
+
const built = withContents
|
|
321
|
+
? await buildContainerWithContents(resource, center, catalog, existing)
|
|
322
|
+
: { nodes: [buildContainer(resource, center)], edges: [] };
|
|
323
|
+
// Dropped in another container (e.g. a system in its domain): it goes in it
|
|
324
|
+
const outer = findGroupAtPoint(center, existing);
|
|
325
|
+
const nodes = outer
|
|
326
|
+
? built.nodes.map((node) =>
|
|
327
|
+
node.parentId ? node : { ...node, parentId: outer.id, position: toRelativePosition(node.position, outer.id, existing) }
|
|
328
|
+
)
|
|
329
|
+
: built.nodes;
|
|
330
|
+
// With its contents, the canvas is laid out again to make room for it all
|
|
331
|
+
if (withContents && existing.length > 0) reorderOnceShown.current = nodes.map((node) => node.id);
|
|
332
|
+
flow.insertNodes(nodes, built.edges);
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
const choose = useStableCallback((choice: ContainerChoice) => {
|
|
336
|
+
if (!chooser) return;
|
|
337
|
+
if (choice.as === 'card') addCard(chooser.resource, chooser.center);
|
|
338
|
+
else void addContainer(chooser.resource, chooser.center, choice.withContents);
|
|
339
|
+
setChooser(null);
|
|
340
|
+
});
|
|
341
|
+
const cancelChooser = useCallback(() => setChooser(null), []);
|
|
342
|
+
|
|
343
|
+
const chooseConnections = useStableCallback((choice: ConnectionsChoice) => {
|
|
344
|
+
if (!connectionsChooser) return;
|
|
345
|
+
const { resource, center } = connectionsChooser;
|
|
346
|
+
if (choice.as === 'card') addCard(resource, center);
|
|
347
|
+
else {
|
|
348
|
+
const { nodes, edges } = planWithConnections(nodesRef.current, resource, center, choice.include, catalog);
|
|
349
|
+
flow.insertNodes(nodes, edges);
|
|
350
|
+
}
|
|
351
|
+
setConnectionsChooser(null);
|
|
352
|
+
});
|
|
353
|
+
const cancelConnectionsChooser = useCallback(() => setConnectionsChooser(null), []);
|
|
354
|
+
|
|
355
|
+
// What a domain or system dropped as a container would bring with it
|
|
356
|
+
const chooserContents = useMemo(() => {
|
|
357
|
+
if (!chooser) return { systems: 0, services: 0 };
|
|
358
|
+
const inside = (resource: CatalogResource): CatalogResource[] =>
|
|
359
|
+
getContents(resource, catalog).flatMap((child) => [child, ...(canBeContainer(child) ? inside(child) : [])]);
|
|
360
|
+
const missing = inside(chooser.resource).filter((child) => !keysOnCanvas.has(child.key));
|
|
361
|
+
return {
|
|
362
|
+
systems: missing.filter((child) => child.collection === 'systems').length,
|
|
363
|
+
services: missing.filter((child) => child.collection === 'services').length,
|
|
364
|
+
};
|
|
365
|
+
}, [chooser, catalog, keysOnCanvas]);
|
|
366
|
+
|
|
367
|
+
/** A sticky note on the level shown (it's only shown on that level) */
|
|
368
|
+
const addNoteAt = (center: XYPosition) => {
|
|
369
|
+
const data = getNodeDefinition('note')!.createData();
|
|
370
|
+
flow.insertNode('note', levelRef.current === 3 ? data : { ...data, level: levelRef.current }, center);
|
|
371
|
+
};
|
|
372
|
+
/**
|
|
373
|
+
* Where something added while L1 or L2 is shown goes: L1 and L2 are views of the canvas people edit (L3), so it's
|
|
374
|
+
* added there (shown on L3), below what's on it
|
|
375
|
+
*/
|
|
376
|
+
const centerOnCanvas = (center: XYPosition) => {
|
|
377
|
+
if (levelRef.current === 3) return center;
|
|
378
|
+
changeLevel(3);
|
|
379
|
+
const roots = nodesRef.current.filter((node) => !node.parentId && isOnLevel(node, 3));
|
|
380
|
+
if (roots.length === 0) return { x: 0, y: 0 };
|
|
381
|
+
const left = Math.min(...roots.map((node) => node.position.x));
|
|
382
|
+
const right = Math.max(...roots.map((node) => node.position.x + sizeOf(node).width));
|
|
383
|
+
const bottom = Math.max(...roots.map((node) => node.position.y + sizeOf(node).height));
|
|
384
|
+
return { x: (left + right) / 2, y: bottom + 160 };
|
|
385
|
+
};
|
|
386
|
+
const addComponentAt = (type: string, center: XYPosition) =>
|
|
387
|
+
type === 'note' ? addNoteAt(center) : flow.addNode(type, centerOnCanvas(center));
|
|
388
|
+
|
|
389
|
+
const addComponent = useStableCallback((type: string) => addComponentAt(type, canvasCenter()));
|
|
390
|
+
const addNote = useStableCallback(() => addNoteAt(canvasCenter()));
|
|
391
|
+
const addResourceFromPanel = useStableCallback((key: string) => addResource(key, centerOnCanvas(canvasCenter())));
|
|
392
|
+
|
|
393
|
+
const onDrop = useStableCallback((event: DragEvent) => {
|
|
394
|
+
event.preventDefault();
|
|
395
|
+
const position = screenToFlowPosition({ x: event.clientX, y: event.clientY });
|
|
396
|
+
const type = event.dataTransfer.getData(COMPONENT_DRAG_TYPE);
|
|
397
|
+
const resourceKey = event.dataTransfer.getData(CATALOG_DRAG_TYPE);
|
|
398
|
+
if (type) addComponentAt(type, position);
|
|
399
|
+
if (resourceKey) addResource(resourceKey, centerOnCanvas(position));
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
// ---- Pointer, panning and dragging ----
|
|
403
|
+
|
|
404
|
+
// React Flow's node drag and panning (d3) stop mousemove from propagating, so follow the
|
|
405
|
+
// pointer with pointer events, and with dragover while something is dragged in from the palette
|
|
406
|
+
// Where the pointer is on the canvas (while it's over it), for pasting there
|
|
407
|
+
const pointerAt = useRef<XYPosition | null>(null);
|
|
408
|
+
const trackPointer = useStableCallback((event: { clientX: number; clientY: number }) => {
|
|
409
|
+
pointerAt.current = screenToFlowPosition({ x: event.clientX, y: event.clientY });
|
|
410
|
+
flow.setPointer(pointerAt.current);
|
|
411
|
+
});
|
|
412
|
+
const clearPointer = useCallback(() => {
|
|
413
|
+
pointerAt.current = null;
|
|
414
|
+
flow.setPointer(null);
|
|
415
|
+
}, [flow.setPointer]);
|
|
416
|
+
|
|
417
|
+
// ---- Copy and paste ----
|
|
418
|
+
|
|
419
|
+
// Pasting again without moving the pointer off the canvas cascades the copies, like design tools do
|
|
420
|
+
const pastes = useRef(0);
|
|
421
|
+
const selectedIds = () => nodesRef.current.filter((node) => node.selected).map((node) => node.id);
|
|
422
|
+
// What was last copied here (for "Paste here" in the context menu, which can't read the clipboard without asking)
|
|
423
|
+
const [lastCopied, setLastCopied] = useState<ClipboardContent | null>(null);
|
|
424
|
+
// Nodes to copy instead of the selection (copying from the context menu)
|
|
425
|
+
const copyOnly = useRef<string[] | null>(null);
|
|
426
|
+
|
|
427
|
+
/** Adds copies (as one change, so one undo takes them back) and selects them instead of what was selected */
|
|
428
|
+
const paste = useStableCallback((content: ClipboardContent, place: { at: XYPosition } | { offset: XYPosition }) => {
|
|
429
|
+
const { nodes, edges } = pasteNodes(content, nodesRef.current, place);
|
|
430
|
+
flow.onNodesChange(selectedIds().map((id) => ({ type: 'select', id, selected: false })));
|
|
431
|
+
flow.insertNodes(nodes, edges);
|
|
432
|
+
flow.onNodesChange(nodes.map((node) => ({ type: 'select', id: node.id, selected: true })));
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
useEffect(() => {
|
|
436
|
+
// Copying, cutting and pasting text in a field (or a note being edited) works as usual
|
|
437
|
+
const isTyping = () => {
|
|
438
|
+
const active = document.activeElement;
|
|
439
|
+
return (
|
|
440
|
+
active instanceof HTMLElement && (active.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(active.tagName))
|
|
441
|
+
);
|
|
442
|
+
};
|
|
443
|
+
// Text selected on the page (e.g. in a panel) is copied as text, like anywhere else
|
|
444
|
+
const hasTextSelected = () => !!window.getSelection()?.toString();
|
|
445
|
+
const copy = (event: ClipboardEvent) => {
|
|
446
|
+
const fromMenu = copyOnly.current !== null;
|
|
447
|
+
const ids = copyOnly.current ?? selectedIds();
|
|
448
|
+
copyOnly.current = null;
|
|
449
|
+
if (levelRef.current !== 3 || isTyping() || !event.clipboardData || (!fromMenu && hasTextSelected())) return;
|
|
450
|
+
const content = copyNodes(nodesRef.current, edgesRef.current, ids);
|
|
451
|
+
if (!content) return;
|
|
452
|
+
event.preventDefault();
|
|
453
|
+
event.clipboardData.setData(CLIPBOARD_TYPE, JSON.stringify(content));
|
|
454
|
+
// Other apps get a list of what was copied
|
|
455
|
+
event.clipboardData.setData('text/plain', content.nodes.map((node) => getNodeName(node.type, node.data)).join('\n'));
|
|
456
|
+
pastes.current = 0;
|
|
457
|
+
setLastCopied(content);
|
|
458
|
+
if (event.type === 'cut') flow.deleteNodes(ids);
|
|
459
|
+
};
|
|
460
|
+
const onPaste = (event: ClipboardEvent) => {
|
|
461
|
+
if (levelRef.current !== 3 || isTyping()) return;
|
|
462
|
+
const content =
|
|
463
|
+
readClipboard(event.clipboardData?.getData(CLIPBOARD_TYPE)) ?? readClipboard(event.clipboardData?.getData('text/plain'));
|
|
464
|
+
if (!content) return;
|
|
465
|
+
event.preventDefault();
|
|
466
|
+
pastes.current += 1;
|
|
467
|
+
const step = 40 * pastes.current;
|
|
468
|
+
paste(content, pointerAt.current ? { at: pointerAt.current } : { offset: { x: step, y: step } });
|
|
469
|
+
};
|
|
470
|
+
document.addEventListener('copy', copy);
|
|
471
|
+
document.addEventListener('cut', copy);
|
|
472
|
+
document.addEventListener('paste', onPaste);
|
|
473
|
+
return () => {
|
|
474
|
+
document.removeEventListener('copy', copy);
|
|
475
|
+
document.removeEventListener('cut', copy);
|
|
476
|
+
document.removeEventListener('paste', onPaste);
|
|
477
|
+
};
|
|
478
|
+
}, [paste, flow.deleteNodes]);
|
|
479
|
+
|
|
480
|
+
const onDragOver = useStableCallback((event: DragEvent) => {
|
|
481
|
+
event.preventDefault();
|
|
482
|
+
event.dataTransfer.dropEffect = 'move';
|
|
483
|
+
trackPointer(event);
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
// While the canvas is panned, zoomed or dragged, message edges stop animating (a class, not state, so nothing re-renders)
|
|
487
|
+
const interactions = useRef({ moving: false, dragging: false });
|
|
488
|
+
const setInteraction = useCallback((kind: 'moving' | 'dragging', on: boolean) => {
|
|
489
|
+
interactions.current[kind] = on;
|
|
490
|
+
canvasRef.current?.classList.toggle('ec-interaction-active', interactions.current.moving || interactions.current.dragging);
|
|
491
|
+
}, []);
|
|
492
|
+
|
|
493
|
+
// The camera follows agents unless people move it themselves (set by AgentActivity)
|
|
494
|
+
const pauseCamera = useRef<() => void>(() => {});
|
|
495
|
+
const onMoveStart = useCallback(
|
|
496
|
+
(event: MouseEvent | TouchEvent | null) => {
|
|
497
|
+
if (event) pauseCamera.current();
|
|
498
|
+
setInteraction('moving', true);
|
|
499
|
+
},
|
|
500
|
+
[setInteraction]
|
|
501
|
+
);
|
|
502
|
+
const onMoveEnd = useCallback(() => setInteraction('moving', false), [setInteraction]);
|
|
503
|
+
|
|
504
|
+
// Dragging something into a container puts it in it, out of one takes it out
|
|
505
|
+
const [dropTargetId, setDropTargetId] = useState<string | null>(null);
|
|
506
|
+
const dragRoots = (dragged: Node[]) => {
|
|
507
|
+
const ids = new Set(dragged.map((node) => node.id));
|
|
508
|
+
return dragged.filter((node) => !node.parentId || !ids.has(node.parentId));
|
|
509
|
+
};
|
|
510
|
+
// While something's dragged, the containers around it grow to fit it (see previewContainerGrowth): the canvas
|
|
511
|
+
// when the drag started, and those containers, innermost first
|
|
512
|
+
const dragStart = useRef<{ nodes: Map<string, Node>; containers: string[] } | null>(null);
|
|
513
|
+
const onNodeDragStart = useStableCallback((_: unknown, __: Node, dragged: Node[]) => {
|
|
514
|
+
pauseCamera.current();
|
|
515
|
+
setInteraction('dragging', true);
|
|
516
|
+
const lookup = lookupNodes();
|
|
517
|
+
const containers: string[] = [];
|
|
518
|
+
for (const node of dragged) {
|
|
519
|
+
for (let id = lookup.get(node.id)?.parentId; id; id = lookup.get(id)?.parentId) {
|
|
520
|
+
if (!containers.includes(id)) containers.push(id);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
// Innermost first: deeper containers before the ones they're in
|
|
524
|
+
const depth = (id: string) => {
|
|
525
|
+
let level = 0;
|
|
526
|
+
for (let parent = lookup.get(id)?.parentId; parent; parent = lookup.get(parent)?.parentId) level++;
|
|
527
|
+
return level;
|
|
528
|
+
};
|
|
529
|
+
containers.sort((a, b) => depth(b) - depth(a));
|
|
530
|
+
dragStart.current = containers.length ? { nodes: lookup, containers } : null;
|
|
531
|
+
threadsDrag.current = selectedThreads.size && dragged[0] ? { id: dragged[0].id, from: canvasPositionOf(dragged[0]) } : null;
|
|
532
|
+
});
|
|
533
|
+
// Where a dragged node is on the canvas (its position is relative to its container)
|
|
534
|
+
const canvasPositionOf = (node: Node) => {
|
|
535
|
+
const lookup = lookupNodes();
|
|
536
|
+
lookup.set(node.id, { ...lookup.get(node.id)!, position: node.position });
|
|
537
|
+
return getAbsolutePosition(lookup.get(node.id)!, lookup);
|
|
538
|
+
};
|
|
539
|
+
// Selected comments move with what's dragged: how far it's moved from where it was
|
|
540
|
+
const threadsDrag = useRef<{ id: string; from: XYPosition } | null>(null);
|
|
541
|
+
const moveThreadsWith = (dragged: Node[]) => {
|
|
542
|
+
const drag = threadsDrag.current;
|
|
543
|
+
const node = drag && dragged.find((candidate) => candidate.id === drag.id);
|
|
544
|
+
if (!drag || !node) return;
|
|
545
|
+
const at = canvasPositionOf(node);
|
|
546
|
+
setThreadsOffset({ x: at.x - drag.from.x, y: at.y - drag.from.y });
|
|
547
|
+
};
|
|
548
|
+
const growContainers = (dragged: Node[]) => {
|
|
549
|
+
const started = dragStart.current;
|
|
550
|
+
if (!started) return;
|
|
551
|
+
// Where the dragged nodes are now (the canvas's own nodes can be a frame behind)
|
|
552
|
+
const now = lookupNodes();
|
|
553
|
+
dragged.forEach((node) => now.set(node.id, { ...now.get(node.id)!, position: node.position }));
|
|
554
|
+
flow.showDragPreview(previewContainerGrowth(started.containers, new Set(dragged.map((node) => node.id)), started.nodes, now));
|
|
555
|
+
};
|
|
556
|
+
const onNodeDrag = useStableCallback((_: unknown, __: Node, dragged: Node[]) => {
|
|
557
|
+
growContainers(dragged);
|
|
558
|
+
moveThreadsWith(dragged);
|
|
559
|
+
const [root] = dragRoots(dragged);
|
|
560
|
+
const current = root && nodesRef.current.find((node) => node.id === root.id);
|
|
561
|
+
const target = root ? findDropTarget(root.id, nodesRef.current) : undefined;
|
|
562
|
+
setDropTargetId(target && target.id !== current?.parentId ? target.id : null);
|
|
563
|
+
});
|
|
564
|
+
const onNodeDragStop = useStableCallback((_: unknown, __: Node, dragged: Node[]) => {
|
|
565
|
+
setInteraction('dragging', false);
|
|
566
|
+
setDropTargetId(null);
|
|
567
|
+
dragStart.current = null;
|
|
568
|
+
// Selected comments not on a node are saved where they moved to
|
|
569
|
+
if (threadsDrag.current && threadsOffset) {
|
|
570
|
+
const offset = threadsOffset;
|
|
571
|
+
comments.moveThreads(
|
|
572
|
+
comments.threads
|
|
573
|
+
.filter((thread) => selectedThreads.has(thread.id) && !thread.nodeId)
|
|
574
|
+
.map((thread) => ({
|
|
575
|
+
threadId: thread.id,
|
|
576
|
+
anchor: { position: { x: thread.position.x + offset.x, y: thread.position.y + offset.y } },
|
|
577
|
+
}))
|
|
578
|
+
);
|
|
579
|
+
}
|
|
580
|
+
threadsDrag.current = null;
|
|
581
|
+
setThreadsOffset(null);
|
|
582
|
+
// The containers it grew are saved with the drop; dropped in another container (or out of one), it moves there
|
|
583
|
+
for (const root of dragRoots(dragged)) {
|
|
584
|
+
const current = nodesRef.current.find((node) => node.id === root.id);
|
|
585
|
+
const target = findDropTarget(root.id, nodesRef.current);
|
|
586
|
+
if (current && target?.id !== current.parentId) flow.setParent(root.id, target?.id);
|
|
587
|
+
}
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
// ---- Levels of detail ----
|
|
591
|
+
|
|
592
|
+
// Like EventCatalog's diagrams: L3 is the canvas people edit, L1 and L2 read only views of it
|
|
593
|
+
const [level, setLevel] = useState<Level>(3);
|
|
594
|
+
const levelRef = useRef(level);
|
|
595
|
+
levelRef.current = level;
|
|
596
|
+
const editable = level === 3;
|
|
597
|
+
const [levelGraph, setLevelGraph] = useState<{ level: Level; nodes: Node[]; edges: Edge[] } | null>(null);
|
|
598
|
+
|
|
599
|
+
// What a level shows depends on what's on the canvas and how it's connected, and names (not positions). Notes
|
|
600
|
+
// aren't laid out with it: adding, moving or writing one doesn't lay the level out again.
|
|
601
|
+
const levelKey =
|
|
602
|
+
level === 3
|
|
603
|
+
? ''
|
|
604
|
+
: `${flow.nodes
|
|
605
|
+
.filter((node) => node.type !== 'note')
|
|
606
|
+
.map((node) => `${node.id}:${node.type}:${node.parentId ?? ''}:${getNodeName(node.type, node.data)}`)
|
|
607
|
+
.join('|')}#${flow.edges.map((edge) => `${edge.source}>${edge.target}:${String(edge.label ?? '')}`).join('|')}`;
|
|
608
|
+
const shownLevelRef = useRef<Level | undefined>(undefined);
|
|
609
|
+
shownLevelRef.current = levelGraph?.level;
|
|
610
|
+
useEffect(() => {
|
|
611
|
+
if (level === 3) return setLevelGraph(null);
|
|
612
|
+
let cancelled = false;
|
|
613
|
+
// Straight away when switching levels; after changes settle while one is shown
|
|
614
|
+
const timer = setTimeout(
|
|
615
|
+
() => {
|
|
616
|
+
const collapsed = getLevelGraph(nodesRef.current, edgesRef.current, level);
|
|
617
|
+
void layoutGraph(
|
|
618
|
+
collapsed.nodes.map((node) => ({ ...node, selected: false })),
|
|
619
|
+
collapsed.edges
|
|
620
|
+
).then((laidOut) => !cancelled && setLevelGraph({ level, ...laidOut }));
|
|
621
|
+
},
|
|
622
|
+
shownLevelRef.current === level ? LEVEL_RELAYOUT_MS : 0
|
|
623
|
+
);
|
|
624
|
+
return () => {
|
|
625
|
+
cancelled = true;
|
|
626
|
+
clearTimeout(timer);
|
|
627
|
+
};
|
|
628
|
+
}, [level, levelKey]);
|
|
629
|
+
|
|
630
|
+
// Comments: right click > Add comment, or C for comment mode then click (like Figma)
|
|
631
|
+
const [commentMode, setCommentMode] = useState(false);
|
|
632
|
+
const [draft, setDraft] = useState<CommentAnchor | null>(null);
|
|
633
|
+
const [openThreadId, setOpenThreadId] = useState<string | null>(null);
|
|
634
|
+
const [showResolved, setShowResolved] = useState(false);
|
|
635
|
+
// Comments that are part of the selection (selecting everything selects them too): only here, like what nodes
|
|
636
|
+
// are selected. While what's selected is dragged, the ones not on a node move with it by this much.
|
|
637
|
+
const [selectedThreads, setSelectedThreads] = useState<ReadonlySet<string>>(NO_THREADS);
|
|
638
|
+
const clearSelectedThreads = useCallback(() => setSelectedThreads((current) => (current.size ? NO_THREADS : current)), []);
|
|
639
|
+
const [threadsOffset, setThreadsOffset] = useState<XYPosition | null>(null);
|
|
640
|
+
const threadsShown = () => comments.threads.filter((thread) => showResolved || !thread.resolved);
|
|
641
|
+
const [contextMenu, setContextMenu] = useState<ContextMenuState>(null);
|
|
642
|
+
|
|
643
|
+
const changeLevel = useStableCallback((next: Level) => {
|
|
644
|
+
if (next === level) return;
|
|
645
|
+
glide();
|
|
646
|
+
setCommentMode(false);
|
|
647
|
+
setDraft(null);
|
|
648
|
+
setOpenThreadId(null);
|
|
649
|
+
// What's selected may not be on the next level (e.g. a note added on this one)
|
|
650
|
+
flow.onNodesChange(selectedIds().map((id) => ({ type: 'select', id, selected: false })));
|
|
651
|
+
setLevel(next);
|
|
652
|
+
});
|
|
653
|
+
const editInL3 = useCallback(() => changeLevel(3), [changeLevel]);
|
|
654
|
+
|
|
655
|
+
// Fit the camera to a level once it's laid out (and to the canvas when going back to L3)
|
|
656
|
+
const shownLevel = level === 3 ? 3 : levelGraph?.level;
|
|
657
|
+
useEffect(() => {
|
|
658
|
+
if (shownLevel === undefined) return;
|
|
659
|
+
// Like the visualiser, a level isn't zoomed in past its real size
|
|
660
|
+
const options = shownLevel === 3 ? FIT_VIEW_OPTIONS : LEVEL_FIT_VIEW_OPTIONS;
|
|
661
|
+
const frame = requestAnimationFrame(() => void fitView({ ...options, duration: 450 }));
|
|
662
|
+
return () => cancelAnimationFrame(frame);
|
|
663
|
+
}, [shownLevel]);
|
|
664
|
+
|
|
665
|
+
/** Lays the canvas out with the visualiser's layout, for everyone on it (everyone's nodes glide there) */
|
|
666
|
+
const reorder = useStableCallback(async () => {
|
|
667
|
+
if (levelRef.current !== 3) setLevel(3);
|
|
668
|
+
const positions = await getLayoutPositions(nodesRef.current, edgesRef.current);
|
|
669
|
+
flow.applyPositions(positions);
|
|
670
|
+
setTimeout(() => void fitView({ ...FIT_VIEW_OPTIONS, duration: 450 }), 50);
|
|
671
|
+
});
|
|
672
|
+
const onReorder = useCallback(() => void reorder(), [reorder]);
|
|
673
|
+
|
|
674
|
+
const selected = flow.nodes.filter((node) => node.selected);
|
|
675
|
+
const selectedKey = selected.map((node) => node.id).join(',');
|
|
676
|
+
|
|
677
|
+
// React Flow needs containers before what's in them: only re-sorted when the structure changes
|
|
678
|
+
const hierarchyOrder = useMemo(() => {
|
|
679
|
+
const sorted = sortByHierarchy(nodesRef.current);
|
|
680
|
+
return sorted.every((node, index) => node === nodesRef.current[index]) ? null : sorted.map((node) => node.id);
|
|
681
|
+
}, [structure]);
|
|
682
|
+
const sortedNodes = useMemo(() => {
|
|
683
|
+
if (!hierarchyOrder) return flow.nodes;
|
|
684
|
+
const byId = new Map(flow.nodes.map((node) => [node.id, node]));
|
|
685
|
+
return hierarchyOrder.flatMap((id) => byId.get(id) ?? []);
|
|
686
|
+
}, [flow.nodes, hierarchyOrder]);
|
|
687
|
+
const levelShown = level !== 3 && levelGraph?.level === level ? levelGraph : null;
|
|
688
|
+
// Sticky notes are shown on the level they were added on: on L1 and L2, over the level's layout
|
|
689
|
+
const shownNodes = useMemo(() => {
|
|
690
|
+
if (!levelShown)
|
|
691
|
+
return sortedNodes.every((node) => isOnLevel(node, 3)) ? sortedNodes : sortedNodes.filter((node) => isOnLevel(node, 3));
|
|
692
|
+
const notes = sortedNodes.filter((node) => getNoteLevel(node) === levelShown.level);
|
|
693
|
+
return notes.length ? [...levelShown.nodes, ...notes.map(asLevelNote)] : levelShown.nodes;
|
|
694
|
+
}, [levelShown, sortedNodes]);
|
|
695
|
+
/** On L1 and L2, only changes to the level's notes go to the canvas (its other nodes are drawn from it) */
|
|
696
|
+
const onLevelNodesChange = useStableCallback((changes: NodeChange[]) => {
|
|
697
|
+
const notes = new Set(nodesRef.current.filter((node) => getNoteLevel(node) === levelRef.current).map((node) => node.id));
|
|
698
|
+
const ofNotes = changes.filter((change) => change.type !== 'add' && notes.has(change.id));
|
|
699
|
+
if (ofNotes.length) flow.onNodesChange(ofNotes);
|
|
700
|
+
});
|
|
701
|
+
// Message edges' envelopes only move on what's selected (the edge, or a node at either end): any moving
|
|
702
|
+
// envelope at all costs a style recalc and layout every frame, which an editing canvas can't afford at idle
|
|
703
|
+
const shownEdges = useMemo(() => {
|
|
704
|
+
const edges = levelShown ? levelShown.edges : flow.edges;
|
|
705
|
+
const focused = new Set(selectedKey ? selectedKey.split(',') : []);
|
|
706
|
+
return edges.map((edge) =>
|
|
707
|
+
edge.type === 'animated' && !edge.selected && !focused.has(edge.source) && !focused.has(edge.target)
|
|
708
|
+
? withoutEnvelope(edge)
|
|
709
|
+
: edge
|
|
710
|
+
);
|
|
711
|
+
}, [levelShown, flow.edges, selectedKey]);
|
|
712
|
+
const unavailableLevels = useMemo(
|
|
713
|
+
() => ({ 1: getLevelUnavailableReason(nodesRef.current, 1), 2: getLevelUnavailableReason(nodesRef.current, 2) }),
|
|
714
|
+
[structure]
|
|
715
|
+
);
|
|
716
|
+
|
|
717
|
+
// ---- People and agents ----
|
|
718
|
+
|
|
719
|
+
/** Jump to where someone is: their view (and zoom), or for agents, what they last worked on */
|
|
720
|
+
const jumpTo = useStableCallback((person: Peer) => {
|
|
721
|
+
const peer = flow.presence?.getPeers().find((candidate) => candidate.clientId === person.clientId) ?? person;
|
|
722
|
+
if (levelRef.current !== 3) changeLevel(3);
|
|
723
|
+
if (peer.viewport) {
|
|
724
|
+
void setCenter(peer.viewport.x, peer.viewport.y, { zoom: peer.viewport.zoom, duration: 500 });
|
|
725
|
+
} else if (peer.pointer) {
|
|
726
|
+
void setCenter(peer.pointer.x, peer.pointer.y, { zoom: Math.max(getZoom(), 1), duration: 500 });
|
|
727
|
+
}
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
const [following, setFollowing] = useState(followChanges);
|
|
731
|
+
const toggleFollowing = useCallback(() => setFollowing((on) => !on), []);
|
|
732
|
+
|
|
733
|
+
const [shareOpen, setShareOpen] = useState(false);
|
|
734
|
+
const openShare = useCallback(() => setShareOpen(true), []);
|
|
735
|
+
const closeShare = useCallback(() => setShareOpen(false), []);
|
|
736
|
+
const [deleteOpen, setDeleteOpen] = useState(false);
|
|
737
|
+
const openDelete = useCallback(() => setDeleteOpen(true), []);
|
|
738
|
+
const closeDelete = useCallback(() => setDeleteOpen(false), []);
|
|
739
|
+
// Deleted here: on the way to Studio. Set before asking, since the server closes the canvas before it answers
|
|
740
|
+
// (the canvas closing as it's deleted isn't news)
|
|
741
|
+
const [leaving, setLeaving] = useState(false);
|
|
742
|
+
const deleteCanvas = useStableCallback(async () => {
|
|
743
|
+
if (!canvasesApiUrl) return;
|
|
744
|
+
setLeaving(true);
|
|
745
|
+
try {
|
|
746
|
+
await deleteCanvasThroughApi(canvasesApiUrl, canvasId);
|
|
747
|
+
} catch (error) {
|
|
748
|
+
setLeaving(false);
|
|
749
|
+
throw error;
|
|
750
|
+
}
|
|
751
|
+
window.location.assign(studioUrl ?? '/');
|
|
752
|
+
});
|
|
753
|
+
const [connectOpen, setConnectOpen] = useState(false);
|
|
754
|
+
const openConnect = useCallback(() => setConnectOpen(true), []);
|
|
755
|
+
const closeConnect = useCallback(() => setConnectOpen(false), []);
|
|
756
|
+
useEffect(() => {
|
|
757
|
+
if (relayScriptPath && isRelayEnabled()) loadRelay(relayScriptPath);
|
|
758
|
+
}, [relayScriptPath]);
|
|
759
|
+
const agentLink = {
|
|
760
|
+
canvasId,
|
|
761
|
+
title: flow.title,
|
|
762
|
+
canvasUrl: shareUrl ?? window.location.href,
|
|
763
|
+
mcpUrl: new URL(mcpUrl, window.location.origin).href,
|
|
764
|
+
};
|
|
765
|
+
|
|
766
|
+
// ---- Comments ----
|
|
767
|
+
|
|
768
|
+
// Keyboard shortcuts (react-hotkeys-hook: "mod" is ⌘ on a Mac and Ctrl elsewhere; ignored while typing in a field)
|
|
769
|
+
const onCanvas = () => levelRef.current === 3;
|
|
770
|
+
useHotkeys(
|
|
771
|
+
'mod+a',
|
|
772
|
+
() => {
|
|
773
|
+
if (!onCanvas()) return;
|
|
774
|
+
flow.onNodesChange(
|
|
775
|
+
nodesRef.current.filter((node) => isOnLevel(node, 3)).map((node) => ({ type: 'select', id: node.id, selected: true }))
|
|
776
|
+
);
|
|
777
|
+
flow.onEdgesChange(edgesRef.current.map((edge) => ({ type: 'select', id: edge.id, selected: true })));
|
|
778
|
+
// ...and the comments shown on it
|
|
779
|
+
setSelectedThreads(new Set(threadsShown().map((thread) => thread.id)));
|
|
780
|
+
},
|
|
781
|
+
{ preventDefault: true }
|
|
782
|
+
);
|
|
783
|
+
useHotkeys(
|
|
784
|
+
'mod+d',
|
|
785
|
+
() => {
|
|
786
|
+
const content = onCanvas() && copyNodes(nodesRef.current, edgesRef.current, selectedIds());
|
|
787
|
+
if (content) paste(content, { offset: { x: 40, y: 40 } });
|
|
788
|
+
},
|
|
789
|
+
{ preventDefault: true }
|
|
790
|
+
);
|
|
791
|
+
// On every level: notes are changed on L1 and L2 too (and the levels show what's undone on the canvas)
|
|
792
|
+
useHotkeys('mod+z', () => flow.undo(), { preventDefault: true });
|
|
793
|
+
useHotkeys(['mod+shift+z', 'mod+y'], () => flow.redo(), { preventDefault: true });
|
|
794
|
+
useHotkeys('c', () => onCanvas() && setCommentMode((on) => !on));
|
|
795
|
+
useHotkeys('escape', () => {
|
|
796
|
+
setCommentMode(false);
|
|
797
|
+
setDraft(null);
|
|
798
|
+
setOpenThreadId(null);
|
|
799
|
+
clearSelectedThreads();
|
|
800
|
+
setEditingId(null);
|
|
801
|
+
flow.onNodesChange(
|
|
802
|
+
nodesRef.current.filter((node) => node.selected).map((node) => ({ type: 'select', id: node.id, selected: false }))
|
|
803
|
+
);
|
|
804
|
+
flow.onEdgesChange(
|
|
805
|
+
edgesRef.current.filter((edge) => edge.selected).map((edge) => ({ type: 'select', id: edge.id, selected: false }))
|
|
806
|
+
);
|
|
807
|
+
});
|
|
808
|
+
// Selected comments are deleted with what's selected (React Flow deletes the nodes and edges)
|
|
809
|
+
useHotkeys(DELETE_KEYS, () => {
|
|
810
|
+
if (!onCanvas() || selectedThreads.size === 0) return;
|
|
811
|
+
comments.deleteThreads([...selectedThreads]);
|
|
812
|
+
clearSelectedThreads();
|
|
813
|
+
});
|
|
814
|
+
const toggleCommentMode = useCallback(() => setCommentMode((on) => !on), []);
|
|
815
|
+
|
|
816
|
+
const startComment = (anchor: CommentAnchor) => {
|
|
817
|
+
setOpenThreadId(null);
|
|
818
|
+
setDraft(anchor);
|
|
819
|
+
setCommentMode(false);
|
|
820
|
+
};
|
|
821
|
+
|
|
822
|
+
const flowPositionOf = (event: { clientX: number; clientY: number }) =>
|
|
823
|
+
screenToFlowPosition({ x: event.clientX, y: event.clientY });
|
|
824
|
+
|
|
825
|
+
const onPaneClick = useStableCallback((event: ReactMouseEvent) => {
|
|
826
|
+
clearSelectedThreads();
|
|
827
|
+
if (commentMode && editable) return startComment(anchorAt(flowPositionOf(event)));
|
|
828
|
+
// A click inside a container (not on something in it) selects it: its body lets the pointer through so you can
|
|
829
|
+
// drag a selection box in it, so the click lands on the canvas
|
|
830
|
+
// (after React Flow clears the selection, which it does once this returns)
|
|
831
|
+
const container = editable ? findGroupAtPoint(flowPositionOf(event), nodesRef.current) : undefined;
|
|
832
|
+
if (container) queueMicrotask(() => flow.onNodesChange([{ type: 'select', id: container.id, selected: true }]));
|
|
833
|
+
setDraft(null);
|
|
834
|
+
setOpenThreadId(null);
|
|
835
|
+
});
|
|
836
|
+
|
|
837
|
+
// Double clicking a node edits its details (notes are edited on the canvas, by double clicking them too)
|
|
838
|
+
const onNodeDoubleClick = useStableCallback((_: ReactMouseEvent, node: Node) => {
|
|
839
|
+
if (editable && !isWrittenOnCanvas(node.type)) setEditingId(node.id);
|
|
840
|
+
});
|
|
841
|
+
// ...and inside a container (not on something in it): its body lets the pointer through, so it's on the canvas
|
|
842
|
+
const onCanvasDoubleClick = useStableCallback((event: ReactMouseEvent) => {
|
|
843
|
+
if (!editable || !(event.target as Element).classList?.contains('react-flow__pane')) return;
|
|
844
|
+
const container = findGroupAtPoint(flowPositionOf(event), nodesRef.current);
|
|
845
|
+
if (container) setEditingId(container.id);
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
const onNodeClick = useStableCallback((event: ReactMouseEvent, node: Node) => {
|
|
849
|
+
// Clicking one node selects just it (with shift or ⌘, it's added to what's selected)
|
|
850
|
+
if (!event.shiftKey && !event.metaKey) clearSelectedThreads();
|
|
851
|
+
if (commentMode && editable) startComment(anchorAt(flowPositionOf(event), node, lookupNodes()));
|
|
852
|
+
});
|
|
853
|
+
|
|
854
|
+
const openContextMenu = useStableCallback((event: ReactMouseEvent | MouseEvent, node?: Node) => {
|
|
855
|
+
event.preventDefault();
|
|
856
|
+
if (!editable) return;
|
|
857
|
+
setContextMenu({ x: event.clientX, y: event.clientY, flowPosition: flowPositionOf(event), node });
|
|
858
|
+
});
|
|
859
|
+
const onPaneContextMenu = useCallback((event: ReactMouseEvent | MouseEvent) => openContextMenu(event), [openContextMenu]);
|
|
860
|
+
const onNodeContextMenu = useCallback((event: ReactMouseEvent, node: Node) => openContextMenu(event, node), [openContextMenu]);
|
|
861
|
+
const closeContextMenu = useCallback(() => setContextMenu(null), []);
|
|
862
|
+
const commentFromMenu = useStableCallback(() => {
|
|
863
|
+
if (contextMenu) startComment(anchorAt(contextMenu.flowPosition, contextMenu.node, lookupNodes()));
|
|
864
|
+
setContextMenu(null);
|
|
865
|
+
});
|
|
866
|
+
/** The node right clicked, or the whole selection if it's part of it */
|
|
867
|
+
const menuTargets = () => {
|
|
868
|
+
const node = contextMenu?.node;
|
|
869
|
+
if (!node) return [];
|
|
870
|
+
const selected = selectedIds();
|
|
871
|
+
return selected.includes(node.id) ? selected : [node.id];
|
|
872
|
+
};
|
|
873
|
+
const editFromMenu = useStableCallback(() => {
|
|
874
|
+
if (contextMenu?.node) setEditingId(contextMenu.node.id);
|
|
875
|
+
setContextMenu(null);
|
|
876
|
+
});
|
|
877
|
+
const copyFromMenu = useStableCallback(() => {
|
|
878
|
+
// Through the copy event, so it's copied like ⌘C (to the system clipboard too)
|
|
879
|
+
copyOnly.current = menuTargets();
|
|
880
|
+
document.execCommand('copy');
|
|
881
|
+
copyOnly.current = null;
|
|
882
|
+
setContextMenu(null);
|
|
883
|
+
});
|
|
884
|
+
const duplicateFromMenu = useStableCallback(() => {
|
|
885
|
+
const content = copyNodes(nodesRef.current, edgesRef.current, menuTargets());
|
|
886
|
+
if (content) paste(content, { offset: { x: 40, y: 40 } });
|
|
887
|
+
setContextMenu(null);
|
|
888
|
+
});
|
|
889
|
+
const pasteFromMenu = useStableCallback(() => {
|
|
890
|
+
if (lastCopied && contextMenu) paste(lastCopied, { at: contextMenu.flowPosition });
|
|
891
|
+
setContextMenu(null);
|
|
892
|
+
});
|
|
893
|
+
const deleteFromMenu = useStableCallback(() => {
|
|
894
|
+
flow.deleteNodes(menuTargets());
|
|
895
|
+
setContextMenu(null);
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
const openThread = useStableCallback((thread: Thread) => {
|
|
899
|
+
const { x, y } = getAnchorPosition(thread, lookupNodes());
|
|
900
|
+
// Leave room for the thread, which opens to the right of its pin
|
|
901
|
+
void setCenter(x + 150, y, { zoom: getZoom(), duration: 300 });
|
|
902
|
+
setDraft(null);
|
|
903
|
+
setOpenThreadId(thread.id);
|
|
904
|
+
});
|
|
905
|
+
const toggleResolved = useCallback(() => setShowResolved((show) => !show), []);
|
|
906
|
+
const openThreadById = useCallback((id: string | null) => {
|
|
907
|
+
setDraft(null);
|
|
908
|
+
setOpenThreadId(id);
|
|
909
|
+
}, []);
|
|
910
|
+
const createThread = useStableCallback((text: string) => {
|
|
911
|
+
if (!draft) return;
|
|
912
|
+
// Posted, it's a pin like the others (not opened): click it to see or reply to it
|
|
913
|
+
comments.createThread(draft, text);
|
|
914
|
+
setDraft(null);
|
|
915
|
+
});
|
|
916
|
+
const cancelDraft = useCallback(() => setDraft(null), []);
|
|
917
|
+
const deleteThread = useStableCallback((id: string) => {
|
|
918
|
+
comments.deleteThread(id);
|
|
919
|
+
setOpenThreadId(null);
|
|
920
|
+
});
|
|
921
|
+
|
|
922
|
+
// ---- Selection ----
|
|
923
|
+
|
|
924
|
+
useEffect(() => {
|
|
925
|
+
onSelectionChange?.(
|
|
926
|
+
selected.map((node) => {
|
|
927
|
+
const link = getCatalogLink(node);
|
|
928
|
+
return {
|
|
929
|
+
id: node.id,
|
|
930
|
+
name: getNodeName(node.type, node.data, node.id),
|
|
931
|
+
type: getNodeLabel(node.type),
|
|
932
|
+
...(link && { catalogResource: `${link.key.replace(':', '/')} v${link.version}` }),
|
|
933
|
+
};
|
|
934
|
+
})
|
|
935
|
+
);
|
|
936
|
+
}, [selectedKey]);
|
|
937
|
+
// The node whose details are being edited in the panel: opened by double clicking it, or "Edit details" in its
|
|
938
|
+
// menu (selecting a node doesn't open it). Notes are edited on the canvas.
|
|
939
|
+
const [editingId, setEditingId] = useState<string | null>(null);
|
|
940
|
+
const editing =
|
|
941
|
+
editable && editingId ? flow.nodes.find((node) => node.id === editingId && !isWrittenOnCanvas(node.type)) : undefined;
|
|
942
|
+
// What the panel shows (not the node's position, so dragging it doesn't re-render the panel)
|
|
943
|
+
const inspected = useMemo(
|
|
944
|
+
() => (editing ? { id: editing.id, type: editing.type, data: editing.data } : null),
|
|
945
|
+
[editing?.id, editing?.type, editing?.data]
|
|
946
|
+
);
|
|
947
|
+
const closePanel = useCallback(() => setEditingId(null), []);
|
|
948
|
+
// An accepted or rejected canvas, and when it was decided
|
|
949
|
+
const { status: canvasStatus, history: statusHistory } = flow.canvasStatus;
|
|
950
|
+
const decided = isDecided(canvasStatus)
|
|
951
|
+
? { status: canvasStatus, change: statusHistory.findLast((change) => change.status === canvasStatus) }
|
|
952
|
+
: null;
|
|
953
|
+
|
|
954
|
+
return (
|
|
955
|
+
<UpdateNodeDataContext.Provider value={flow.updateNodeData}>
|
|
956
|
+
<DropTargetContext.Provider value={dropTargetId}>
|
|
957
|
+
<div className="flex h-full min-h-0 text-[rgb(var(--ec-page-text))]">
|
|
958
|
+
<LeftPanel
|
|
959
|
+
resources={resources}
|
|
960
|
+
keysOnCanvas={keysOnCanvas}
|
|
961
|
+
onAddComponent={addComponent}
|
|
962
|
+
onAddResource={addResourceFromPanel}
|
|
963
|
+
threads={comments.threads}
|
|
964
|
+
showResolved={showResolved}
|
|
965
|
+
onToggleResolved={toggleResolved}
|
|
966
|
+
onOpenThread={openThread}
|
|
967
|
+
/>
|
|
968
|
+
|
|
969
|
+
<div
|
|
970
|
+
ref={canvasRef}
|
|
971
|
+
className={`eventcatalog-visualizer studio-canvas relative flex-1 min-w-0 ${commentMode ? 'comment-mode' : ''} ${
|
|
972
|
+
transitioning ? 'is-layout-transition' : ''
|
|
973
|
+
}`}
|
|
974
|
+
onPointerMove={trackPointer}
|
|
975
|
+
onPointerLeave={clearPointer}
|
|
976
|
+
onDoubleClick={onCanvasDoubleClick}
|
|
977
|
+
>
|
|
978
|
+
<ReactFlow
|
|
979
|
+
nodes={shownNodes}
|
|
980
|
+
edges={shownEdges}
|
|
981
|
+
nodeTypes={nodeTypes}
|
|
982
|
+
edgeTypes={edgeTypes}
|
|
983
|
+
nodesDraggable={editable}
|
|
984
|
+
nodesConnectable={editable}
|
|
985
|
+
elementsSelectable={editable}
|
|
986
|
+
onNodesChange={editable ? flow.onNodesChange : onLevelNodesChange}
|
|
987
|
+
onEdgesChange={editable ? flow.onEdgesChange : undefined}
|
|
988
|
+
onConnect={flow.onConnect}
|
|
989
|
+
// Drag either end of a connection to another node (dropped anywhere else, it stays where it was)
|
|
990
|
+
edgesReconnectable={editable}
|
|
991
|
+
onReconnect={editable ? flow.onReconnect : undefined}
|
|
992
|
+
reconnectRadius={RECONNECT_RADIUS}
|
|
993
|
+
onDragOver={onDragOver}
|
|
994
|
+
onMoveStart={onMoveStart}
|
|
995
|
+
onMoveEnd={onMoveEnd}
|
|
996
|
+
onNodeDragStart={onNodeDragStart}
|
|
997
|
+
onNodeDrag={onNodeDrag}
|
|
998
|
+
onNodeDragStop={onNodeDragStop}
|
|
999
|
+
onDrop={onDrop}
|
|
1000
|
+
onPaneClick={onPaneClick}
|
|
1001
|
+
onNodeClick={onNodeClick}
|
|
1002
|
+
onNodeDoubleClick={onNodeDoubleClick}
|
|
1003
|
+
onError={onFlowError}
|
|
1004
|
+
onSelectionStart={clearSelectedThreads}
|
|
1005
|
+
onPaneContextMenu={onPaneContextMenu}
|
|
1006
|
+
onNodeContextMenu={onNodeContextMenu}
|
|
1007
|
+
connectionLineType={ConnectionLineType.SmoothStep}
|
|
1008
|
+
connectionRadius={40}
|
|
1009
|
+
deleteKeyCode={DELETE_KEYS}
|
|
1010
|
+
multiSelectionKeyCode={MULTI_SELECTION_KEYS}
|
|
1011
|
+
selectionOnDrag={editable}
|
|
1012
|
+
// Read only levels can't select: dragging pans them
|
|
1013
|
+
panOnDrag={editable ? PAN_BUTTONS : true}
|
|
1014
|
+
panOnScroll
|
|
1015
|
+
zoomActivationKeyCode={ZOOM_KEYS}
|
|
1016
|
+
zoomOnDoubleClick={false}
|
|
1017
|
+
minZoom={0.1}
|
|
1018
|
+
maxZoom={2}
|
|
1019
|
+
>
|
|
1020
|
+
<Background gap={10} bgColor="rgb(var(--ec-page-bg))" color="rgb(var(--ec-page-border))" />
|
|
1021
|
+
<ViewportSharer onChange={flow.setViewport} />
|
|
1022
|
+
{/* Pointers, selections and comments are on the canvas people edit (L3) */}
|
|
1023
|
+
{editable && <RemotePresence store={flow.presence} clientId={flow.clientId} />}
|
|
1024
|
+
{editable && (
|
|
1025
|
+
<CommentLayer
|
|
1026
|
+
threads={comments.threads}
|
|
1027
|
+
author={author}
|
|
1028
|
+
openThreadId={openThreadId}
|
|
1029
|
+
draft={draft}
|
|
1030
|
+
showResolved={showResolved}
|
|
1031
|
+
onOpen={openThreadById}
|
|
1032
|
+
onCreate={createThread}
|
|
1033
|
+
onCancelDraft={cancelDraft}
|
|
1034
|
+
onReply={comments.reply}
|
|
1035
|
+
onResolve={comments.setResolved}
|
|
1036
|
+
onMove={comments.moveThread}
|
|
1037
|
+
onMoveDraft={setDraft}
|
|
1038
|
+
onDelete={deleteThread}
|
|
1039
|
+
selectedIds={selectedThreads}
|
|
1040
|
+
selectionOffset={threadsOffset}
|
|
1041
|
+
/>
|
|
1042
|
+
)}
|
|
1043
|
+
</ReactFlow>
|
|
1044
|
+
|
|
1045
|
+
<AgentActivity
|
|
1046
|
+
store={flow.presence}
|
|
1047
|
+
clientId={flow.clientId}
|
|
1048
|
+
enabled={editable}
|
|
1049
|
+
following={following}
|
|
1050
|
+
onToggleFollowing={toggleFollowing}
|
|
1051
|
+
pauseRef={pauseCamera}
|
|
1052
|
+
/>
|
|
1053
|
+
|
|
1054
|
+
{!editable && (
|
|
1055
|
+
<div className="absolute left-1/2 top-16 z-10 flex -translate-x-1/2 items-center gap-3 rounded-full border px-4 py-1.5 text-xs shadow-sm bg-[rgb(var(--ec-accent-subtle))] border-[rgb(var(--ec-accent)/0.3)] text-[rgb(var(--ec-page-text))]">
|
|
1056
|
+
<span>
|
|
1057
|
+
<span className="font-semibold">L{level}</span> · {LEVELS.find((entry) => entry.level === level)?.description} ·
|
|
1058
|
+
read only, sticky notes can be added
|
|
1059
|
+
</span>
|
|
1060
|
+
<button onClick={editInL3} className="font-semibold text-[rgb(var(--ec-accent))] hover:underline">
|
|
1061
|
+
Edit in L3
|
|
1062
|
+
</button>
|
|
1063
|
+
</div>
|
|
1064
|
+
)}
|
|
1065
|
+
|
|
1066
|
+
{/* Accepted or rejected: still editable, but changing it makes it a draft again (for everyone) */}
|
|
1067
|
+
{editable && decided && (
|
|
1068
|
+
<div
|
|
1069
|
+
className={`absolute left-1/2 top-16 z-10 flex max-w-[calc(100%-2rem)] -translate-x-1/2 items-center gap-2 rounded-full border px-4 py-1.5 text-xs shadow-sm ${CANVAS_STATUS_LOOK[decided.status].pill}`}
|
|
1070
|
+
>
|
|
1071
|
+
<span className="truncate">
|
|
1072
|
+
<span className="font-semibold">{CANVAS_STATUS_LOOK[decided.status].label}</span>
|
|
1073
|
+
{decided.change && ` by ${decided.change.by.name} · ${timeAgo(decided.change.at)}`}
|
|
1074
|
+
{decided.change?.note && ` · “${decided.change.note}”`} · changing it moves it back to Draft
|
|
1075
|
+
</span>
|
|
1076
|
+
</div>
|
|
1077
|
+
)}
|
|
1078
|
+
|
|
1079
|
+
<CanvasControls
|
|
1080
|
+
level={level}
|
|
1081
|
+
onLevelChange={changeLevel}
|
|
1082
|
+
unavailableLevels={unavailableLevels}
|
|
1083
|
+
editable={editable}
|
|
1084
|
+
onReorder={onReorder}
|
|
1085
|
+
canUndo={flow.canUndo}
|
|
1086
|
+
canRedo={flow.canRedo}
|
|
1087
|
+
onUndo={flow.undo}
|
|
1088
|
+
onRedo={flow.redo}
|
|
1089
|
+
commentMode={commentMode}
|
|
1090
|
+
onToggleCommentMode={toggleCommentMode}
|
|
1091
|
+
onAddNote={addNote}
|
|
1092
|
+
/>
|
|
1093
|
+
|
|
1094
|
+
<CanvasContextMenu
|
|
1095
|
+
menu={contextMenu}
|
|
1096
|
+
onAddComment={commentFromMenu}
|
|
1097
|
+
onEditDetails={editFromMenu}
|
|
1098
|
+
onCopy={copyFromMenu}
|
|
1099
|
+
onDuplicate={duplicateFromMenu}
|
|
1100
|
+
onPaste={lastCopied ? pasteFromMenu : undefined}
|
|
1101
|
+
onDeleteNode={deleteFromMenu}
|
|
1102
|
+
onClose={closeContextMenu}
|
|
1103
|
+
/>
|
|
1104
|
+
|
|
1105
|
+
<CanvasHeader
|
|
1106
|
+
title={flow.title}
|
|
1107
|
+
onRetitle={flow.rename}
|
|
1108
|
+
status={flow.status}
|
|
1109
|
+
transport={flow.transport}
|
|
1110
|
+
presence={flow.presence}
|
|
1111
|
+
clientId={flow.clientId}
|
|
1112
|
+
onJumpTo={jumpTo}
|
|
1113
|
+
onRename={onRename}
|
|
1114
|
+
onConnectAgent={openConnect}
|
|
1115
|
+
onShare={openShare}
|
|
1116
|
+
webMcp={webMcp}
|
|
1117
|
+
shareUrl={shareUrl}
|
|
1118
|
+
newCanvasUrl={newCanvasUrl}
|
|
1119
|
+
canvasStatus={flow.canvasStatus.status}
|
|
1120
|
+
statusHistory={flow.canvasStatus.history}
|
|
1121
|
+
onStatusChange={flow.changeStatus}
|
|
1122
|
+
onDelete={canvasesApiUrl ? openDelete : undefined}
|
|
1123
|
+
/>
|
|
1124
|
+
|
|
1125
|
+
{flow.deleted && !leaving && (
|
|
1126
|
+
<div className="absolute inset-0 z-40 flex items-center justify-center bg-[rgb(var(--ec-page-bg)/0.85)] p-4">
|
|
1127
|
+
<div className="max-w-sm space-y-2 rounded-xl border p-5 text-center shadow-xl bg-[rgb(var(--ec-card-bg))] border-[rgb(var(--ec-page-border))]">
|
|
1128
|
+
<h2 className="text-base font-semibold">This canvas was deleted</h2>
|
|
1129
|
+
<p className="text-sm text-[rgb(var(--ec-page-text-muted))]">
|
|
1130
|
+
Someone deleted it, so it can't be opened or changed any more.
|
|
1131
|
+
</p>
|
|
1132
|
+
{studioUrl && (
|
|
1133
|
+
<a
|
|
1134
|
+
href={studioUrl}
|
|
1135
|
+
className="mt-2 inline-flex rounded-lg px-4 py-2 text-sm font-medium bg-[rgb(var(--ec-button-bg))] text-[rgb(var(--ec-button-text))] hover:bg-[rgb(var(--ec-button-bg-hover))]"
|
|
1136
|
+
>
|
|
1137
|
+
Back to Studio
|
|
1138
|
+
</a>
|
|
1139
|
+
)}
|
|
1140
|
+
</div>
|
|
1141
|
+
</div>
|
|
1142
|
+
)}
|
|
1143
|
+
|
|
1144
|
+
{/* Only on an empty canvas (not over comments people have left on it) */}
|
|
1145
|
+
{flow.status === 'connected' && flow.nodes.length === 0 && comments.threads.length === 0 && !draft && (
|
|
1146
|
+
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
|
1147
|
+
<p className="text-sm text-[rgb(var(--ec-page-text-muted))]">
|
|
1148
|
+
Drag components or catalog resources from the left onto the canvas, then share the link to work on it together.
|
|
1149
|
+
</p>
|
|
1150
|
+
</div>
|
|
1151
|
+
)}
|
|
1152
|
+
</div>
|
|
1153
|
+
|
|
1154
|
+
{chooser && (
|
|
1155
|
+
<ContainerChooser
|
|
1156
|
+
resource={chooser.resource}
|
|
1157
|
+
contents={chooserContents}
|
|
1158
|
+
position={flowToScreenPosition(chooser.center)}
|
|
1159
|
+
onChoose={choose}
|
|
1160
|
+
onCancel={cancelChooser}
|
|
1161
|
+
/>
|
|
1162
|
+
)}
|
|
1163
|
+
{connectionsChooser && (
|
|
1164
|
+
<ConnectionsChooser
|
|
1165
|
+
resource={connectionsChooser.resource}
|
|
1166
|
+
groups={connectionsChooser.groups}
|
|
1167
|
+
position={flowToScreenPosition(connectionsChooser.center)}
|
|
1168
|
+
onChoose={chooseConnections}
|
|
1169
|
+
onCancel={cancelConnectionsChooser}
|
|
1170
|
+
/>
|
|
1171
|
+
)}
|
|
1172
|
+
{shareOpen && (
|
|
1173
|
+
<ShareDialog
|
|
1174
|
+
keptInMemory={keptInMemory}
|
|
1175
|
+
url={agentLink.canvasUrl}
|
|
1176
|
+
title={flow.title}
|
|
1177
|
+
presence={flow.presence}
|
|
1178
|
+
clientId={flow.clientId}
|
|
1179
|
+
onRename={onRename}
|
|
1180
|
+
onJumpTo={jumpTo}
|
|
1181
|
+
onConnectAgent={openConnect}
|
|
1182
|
+
onClose={closeShare}
|
|
1183
|
+
/>
|
|
1184
|
+
)}
|
|
1185
|
+
{deleteOpen && (
|
|
1186
|
+
<DeleteCanvasDialog
|
|
1187
|
+
title={flow.title}
|
|
1188
|
+
presence={flow.presence}
|
|
1189
|
+
clientId={flow.clientId}
|
|
1190
|
+
onDelete={deleteCanvas}
|
|
1191
|
+
onClose={closeDelete}
|
|
1192
|
+
/>
|
|
1193
|
+
)}
|
|
1194
|
+
{connectOpen && (
|
|
1195
|
+
<ConnectAgentDialog
|
|
1196
|
+
link={agentLink}
|
|
1197
|
+
webMcp={webMcp}
|
|
1198
|
+
relayScriptPath={relayScriptPath}
|
|
1199
|
+
onEnableWebMcp={turnOnWebMcp}
|
|
1200
|
+
onClose={closeConnect}
|
|
1201
|
+
/>
|
|
1202
|
+
)}
|
|
1203
|
+
{inspected && (
|
|
1204
|
+
<PropertiesPanel
|
|
1205
|
+
key={inspected.id}
|
|
1206
|
+
node={inspected}
|
|
1207
|
+
onChange={flow.updateNodeData}
|
|
1208
|
+
onDelete={flow.deleteNodes}
|
|
1209
|
+
onClose={closePanel}
|
|
1210
|
+
/>
|
|
1211
|
+
)}
|
|
1212
|
+
</div>
|
|
1213
|
+
</DropTargetContext.Provider>
|
|
1214
|
+
</UpdateNodeDataContext.Provider>
|
|
1215
|
+
);
|
|
1216
|
+
}
|