@eventcatalog/core 5.0.0-beta.11 → 5.0.0-beta.13
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/README.md +1 -1
- package/dist/analytics/analytics.cjs +1 -1
- package/dist/analytics/analytics.js +2 -2
- package/dist/analytics/log-build.cjs +1 -1
- package/dist/analytics/log-build.js +4 -4
- package/dist/catalog-runtime.cjs +1 -1
- package/dist/{chunk-MFEMKQZ6.js → chunk-BO2HHVZ3.js} +1 -1
- package/dist/{chunk-4YFM63DS.js → chunk-JPT3QC5V.js} +1 -1
- package/dist/{chunk-76DAAJLV.js → chunk-PLTLW45Q.js} +5 -5
- package/dist/{chunk-WM4QYMIA.js → chunk-T75DOVFP.js} +1 -1
- package/dist/{chunk-LQZ7IG7B.js → chunk-XA5W6DXX.js} +1 -1
- package/dist/constants.cjs +1 -1
- package/dist/constants.js +1 -1
- package/dist/eventcatalog.cjs +2 -2
- package/dist/eventcatalog.js +14 -14
- package/dist/federation/federate.js +3 -3
- package/dist/generate.cjs +1 -1
- package/dist/generate.js +3 -3
- package/dist/runtime-dependencies/@eventcatalog__visualiser__studio-fonts.css.mjs +3 -0
- package/dist/runtime-dependencies/@eventcatalog__visualiser__studio.css.mjs +3 -0
- package/dist/runtime-dependencies/@eventcatalog__visualiser__studio.mjs +3 -0
- package/dist/runtime-dependencies/manifest.json +3 -0
- package/dist/utils/cli-logger.cjs +1 -1
- package/dist/utils/cli-logger.js +2 -2
- package/eventcatalog/src/components/ChatPanel/ChatPanel.tsx +2 -0
- package/eventcatalog/src/components/MDX/NodeGraph/AstroNodeGraph.tsx +42 -3
- package/eventcatalog/src/components/MDX/NodeGraph/NodeGraph.astro +5 -1
- package/eventcatalog/src/components/NavigationProgress.astro +7 -0
- package/eventcatalog/src/features/mcp/apps/architecture-diagram/node-chat.tsx +6 -45
- package/eventcatalog/src/features/mcp/apps/canvas/shared.ts +3 -3
- package/eventcatalog/src/features/mcp/apps/canvas/styles.css +3 -1
- package/eventcatalog/src/features/mcp/apps/canvas/view.tsx +7 -5
- package/eventcatalog/src/features/mcp/apps/generated/canvas.html +132 -70
- package/eventcatalog/src/features/mcp/apps/generated/viewer.html +71 -71
- package/eventcatalog/src/features/studio/agent-choreography.ts +13 -10
- package/eventcatalog/src/features/studio/agents.ts +28 -0
- package/eventcatalog/src/features/studio/api/canvases-api.ts +64 -15
- package/eventcatalog/src/features/studio/api/client.ts +6 -1
- package/eventcatalog/src/features/studio/canvas-actions.ts +160 -34
- package/eventcatalog/src/features/studio/canvas-preview.ts +1 -2
- package/eventcatalog/src/features/studio/catalog.ts +16 -14
- package/eventcatalog/src/features/studio/components/CanvasHeader.tsx +263 -115
- package/eventcatalog/src/features/studio/components/CanvasStatusMenu.tsx +15 -14
- package/eventcatalog/src/features/studio/components/ConnectAgentDialog.tsx +272 -119
- package/eventcatalog/src/features/studio/components/ConnectionsChooser.tsx +1 -2
- package/eventcatalog/src/features/studio/components/DeleteCanvasDialog.tsx +28 -44
- package/eventcatalog/src/features/studio/components/Dialog.tsx +60 -0
- package/eventcatalog/src/features/studio/components/JoinForm.tsx +101 -26
- package/eventcatalog/src/features/studio/components/LeftPanel.tsx +119 -230
- package/eventcatalog/src/features/studio/components/NodeDetails.tsx +183 -0
- package/eventcatalog/src/features/studio/components/OpenInStudioDialog.tsx +79 -0
- package/eventcatalog/src/features/studio/components/Presence.tsx +52 -45
- package/eventcatalog/src/features/studio/components/ShareDialog.tsx +277 -141
- package/eventcatalog/src/features/studio/components/ShortcutsDialog.tsx +41 -0
- package/eventcatalog/src/features/studio/components/StudioDesigner.tsx +1286 -411
- package/eventcatalog/src/features/studio/document-name.ts +7 -0
- package/eventcatalog/src/features/studio/from-visualiser.ts +138 -0
- package/eventcatalog/src/features/studio/hooks/presence-sender.ts +19 -2
- package/eventcatalog/src/features/studio/hooks/use-canvas-webmcp.ts +9 -3
- package/eventcatalog/src/features/studio/hooks/use-comments.ts +2 -2
- package/eventcatalog/src/features/studio/hooks/use-follow-camera.ts +8 -3
- package/eventcatalog/src/features/studio/hooks/use-studio-flow.ts +223 -30
- package/eventcatalog/src/features/studio/identity.ts +23 -1
- package/eventcatalog/src/features/studio/limits.ts +2 -0
- package/eventcatalog/src/features/studio/pages/[id].astro +2 -1
- package/eventcatalog/src/features/studio/pages/index.astro +2 -2
- package/eventcatalog/src/features/studio/server/canvas-mcp.ts +8 -23
- package/eventcatalog/src/features/studio/server/runtime.ts +16 -9
- package/eventcatalog/src/utils/node-graphs/domain-levels-node-graph.ts +3 -3
- package/eventcatalog/src/utils/node-graphs/node-resource.ts +67 -0
- package/eventcatalog/src/utils/node-graphs/systems-node-graph.ts +1 -1
- package/package.json +3 -3
- package/eventcatalog/src/features/studio/canvas-doc.ts +0 -414
- package/eventcatalog/src/features/studio/clipboard.ts +0 -97
- package/eventcatalog/src/features/studio/components/CanvasControls.tsx +0 -154
- package/eventcatalog/src/features/studio/components/Comments.tsx +0 -770
- package/eventcatalog/src/features/studio/components/ContainerChooser.tsx +0 -145
- package/eventcatalog/src/features/studio/components/DragGhost.tsx +0 -115
- package/eventcatalog/src/features/studio/components/Picture.tsx +0 -21
- package/eventcatalog/src/features/studio/components/PropertiesPanel.tsx +0 -212
- package/eventcatalog/src/features/studio/components/canvas-nodes.tsx +0 -255
- package/eventcatalog/src/features/studio/components/icons.ts +0 -53
- package/eventcatalog/src/features/studio/components/status.ts +0 -53
- package/eventcatalog/src/features/studio/edges.ts +0 -56
- package/eventcatalog/src/features/studio/flow-state.ts +0 -92
- package/eventcatalog/src/features/studio/grouping.ts +0 -226
- package/eventcatalog/src/features/studio/layout.ts +0 -42
- package/eventcatalog/src/features/studio/levels.ts +0 -282
- package/eventcatalog/src/features/studio/node-types.ts +0 -205
- package/eventcatalog/src/features/studio/placement.ts +0 -197
- package/eventcatalog/src/features/studio/studio.css +0 -273
- package/dist/{chunk-KHACCTW2.js → chunk-ZEY2XUJN.js} +4 -4
|
@@ -1,20 +1,104 @@
|
|
|
1
1
|
import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type MouseEvent as ReactMouseEvent } from 'react';
|
|
2
2
|
import {
|
|
3
|
-
|
|
3
|
+
applyNodeChanges,
|
|
4
4
|
ConnectionLineType,
|
|
5
5
|
ReactFlow,
|
|
6
6
|
ReactFlowProvider,
|
|
7
7
|
useReactFlow,
|
|
8
|
+
type Connection,
|
|
8
9
|
type Edge,
|
|
9
10
|
type Node,
|
|
11
|
+
type FitViewOptions,
|
|
10
12
|
type NodeChange,
|
|
11
13
|
type XYPosition,
|
|
12
14
|
} from '@xyflow/react';
|
|
13
|
-
import { edgeTypes } from '@eventcatalog/visualiser';
|
|
15
|
+
import { DiagramBackground, edgeTypes, getLegend, LegendPanel, pauseEdgeAnimations } from '@eventcatalog/visualiser';
|
|
14
16
|
import { useHotkeys } from 'react-hotkeys-hook';
|
|
15
17
|
import { getCatalogConnections, indexCatalog, planWithConnections, type ConnectionGroup } from '../canvas-actions';
|
|
16
|
-
import {
|
|
17
|
-
|
|
18
|
+
import {
|
|
19
|
+
getDiagramLevel,
|
|
20
|
+
getDiagramLevelUnavailable,
|
|
21
|
+
isDecided,
|
|
22
|
+
type LevelPlace,
|
|
23
|
+
CLIPBOARD_TYPE,
|
|
24
|
+
copyNodes,
|
|
25
|
+
pasteNodes,
|
|
26
|
+
readClipboard,
|
|
27
|
+
type ClipboardContent,
|
|
28
|
+
keepUnchanged,
|
|
29
|
+
LOCAL_EDGE_KEYS,
|
|
30
|
+
LOCAL_NODE_KEYS,
|
|
31
|
+
findDropTargets,
|
|
32
|
+
placeDropped,
|
|
33
|
+
containerFor,
|
|
34
|
+
findGroupAtPoint,
|
|
35
|
+
getAbsolutePosition,
|
|
36
|
+
getAbsoluteRect,
|
|
37
|
+
previewContainerGrowth,
|
|
38
|
+
sizeOf,
|
|
39
|
+
sortByHierarchy,
|
|
40
|
+
toRelativePosition,
|
|
41
|
+
getLayoutPositions,
|
|
42
|
+
layoutGraph,
|
|
43
|
+
getLevelGraph,
|
|
44
|
+
getLevelUnavailableReason,
|
|
45
|
+
LEVELS,
|
|
46
|
+
type Level,
|
|
47
|
+
arrangeLevel,
|
|
48
|
+
getLevelNodeSize,
|
|
49
|
+
levelPlaces,
|
|
50
|
+
withDiagramRoutes,
|
|
51
|
+
canGoInContainer,
|
|
52
|
+
getNodeDefinition,
|
|
53
|
+
getNodeLabel,
|
|
54
|
+
getNodeName,
|
|
55
|
+
getNoteLevel,
|
|
56
|
+
isOnLevel,
|
|
57
|
+
isShownOnLevel,
|
|
58
|
+
isWrittenOnCanvas,
|
|
59
|
+
getResource,
|
|
60
|
+
DropTargetContext,
|
|
61
|
+
nodeTypes,
|
|
62
|
+
UpdateNodeDataContext,
|
|
63
|
+
anchorAt,
|
|
64
|
+
ContextMenu,
|
|
65
|
+
canvasToModel,
|
|
66
|
+
modelToCanvas,
|
|
67
|
+
toDiagramFile,
|
|
68
|
+
readDiagramFile,
|
|
69
|
+
pickDiagramFile,
|
|
70
|
+
downloadFile,
|
|
71
|
+
fileNameFor,
|
|
72
|
+
DIAGRAM_FILE_EXTENSION,
|
|
73
|
+
canvasImage,
|
|
74
|
+
EmptyCanvas,
|
|
75
|
+
type ModelCanvasContent,
|
|
76
|
+
CommentLayer,
|
|
77
|
+
CommentList,
|
|
78
|
+
getAnchorPosition,
|
|
79
|
+
timeAgo,
|
|
80
|
+
type ContextMenuState,
|
|
81
|
+
CANVAS_STATUS_LOOK,
|
|
82
|
+
ContainerChooser,
|
|
83
|
+
type ContainerChoice,
|
|
84
|
+
CanvasControls,
|
|
85
|
+
ViewControls,
|
|
86
|
+
FIT_VIEW_OPTIONS,
|
|
87
|
+
EditButton,
|
|
88
|
+
LockedContext,
|
|
89
|
+
StartWritingContext,
|
|
90
|
+
EditingContext,
|
|
91
|
+
HotspotLayer,
|
|
92
|
+
HotspotList,
|
|
93
|
+
topSeverity,
|
|
94
|
+
isGroupType,
|
|
95
|
+
type Hotspot,
|
|
96
|
+
type HotspotDraft,
|
|
97
|
+
type HotspotFields,
|
|
98
|
+
type HotspotSeverity,
|
|
99
|
+
type HotspotSpot,
|
|
100
|
+
} from '@eventcatalog/visualiser/studio';
|
|
101
|
+
import { X } from 'lucide-react';
|
|
18
102
|
import type { CatalogRelation, CatalogResource } from '../catalog-resources';
|
|
19
103
|
import {
|
|
20
104
|
buildContainer,
|
|
@@ -25,39 +109,23 @@ import {
|
|
|
25
109
|
getContents,
|
|
26
110
|
getRelatedEdges,
|
|
27
111
|
} from '../catalog';
|
|
28
|
-
import {
|
|
29
|
-
findDropTarget,
|
|
30
|
-
findGroupAtPoint,
|
|
31
|
-
getAbsolutePosition,
|
|
32
|
-
previewContainerGrowth,
|
|
33
|
-
sizeOf,
|
|
34
|
-
sortByHierarchy,
|
|
35
|
-
toRelativePosition,
|
|
36
|
-
} from '../grouping';
|
|
37
112
|
import type { Peer } from '../hooks/presence-store';
|
|
38
113
|
import { useCanvasWebMcp } from '../hooks/use-canvas-webmcp';
|
|
39
114
|
import { useStudioFlow } from '../hooks/use-studio-flow';
|
|
40
115
|
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
116
|
import type { ToolSync } from '../tool-sync';
|
|
45
|
-
import CanvasControls, { FIT_VIEW_OPTIONS } from './CanvasControls';
|
|
46
117
|
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
118
|
import ConnectAgentDialog, { enableWebMcp, isRelayEnabled, isWebMcpEnabled, loadRelay } from './ConnectAgentDialog';
|
|
51
|
-
import ContainerChooser, { type ContainerChoice } from './ContainerChooser';
|
|
52
119
|
import ConnectionsChooser, { type ConnectionsChoice } from './ConnectionsChooser';
|
|
53
120
|
import JoinForm from './JoinForm';
|
|
54
|
-
import { colorForName, getStoredName, onStoredNameChange, storeName } from '../identity';
|
|
55
|
-
import LeftPanel, { CATALOG_DRAG_TYPE, COMPONENT_DRAG_TYPE } from './LeftPanel';
|
|
121
|
+
import { colorForName, getStoredColor, getStoredName, onStoredNameChange, storeColor, storeName } from '../identity';
|
|
122
|
+
import LeftPanel, { CATALOG_DRAG_TYPE, COMPONENT_DRAG_TYPE, type LeftPanelTab } from './LeftPanel';
|
|
56
123
|
import { AgentActivity, RemotePresence, ViewportSharer } from './Presence';
|
|
57
|
-
import
|
|
124
|
+
import NodeDetails, { type NodeConnection } from './NodeDetails';
|
|
58
125
|
import ShareDialog from './ShareDialog';
|
|
59
126
|
import DeleteCanvasDialog from './DeleteCanvasDialog';
|
|
60
|
-
import { deleteCanvasThroughApi } from '../api/client';
|
|
127
|
+
import { createCanvasThroughApi, deleteCanvasThroughApi } from '../api/client';
|
|
128
|
+
import ShortcutsDialog from './ShortcutsDialog';
|
|
61
129
|
|
|
62
130
|
type CanvasProps = {
|
|
63
131
|
canvasId: string;
|
|
@@ -82,10 +150,12 @@ type CanvasProps = {
|
|
|
82
150
|
syncViaTools?: ToolSync;
|
|
83
151
|
/** Follow agents' changes with the camera from the start (e.g. inside a chat, where you watch an agent work) */
|
|
84
152
|
followChanges?: boolean;
|
|
153
|
+
/** Starts with the palette folded away (e.g. in a chat, where there's little room) */
|
|
154
|
+
paletteCollapsed?: boolean;
|
|
85
155
|
/** Told what's selected (e.g. a chat attaches it to the conversation, so you can ask about it) */
|
|
86
156
|
onSelectionChange?: (selected: SelectedNode[]) => void;
|
|
87
|
-
/** Join
|
|
88
|
-
|
|
157
|
+
/** Join as this AI agent, with its name, colour and logo, instead of asking who you are (e.g. inside a chat, as the chat's agent) */
|
|
158
|
+
asAgent?: { name: string; color: string };
|
|
89
159
|
/** Signed in with the catalog's sign-in (SSO): you're shown by this name and picture, and can't change them */
|
|
90
160
|
signedInAs?: { name: string; picture?: string };
|
|
91
161
|
resources: CatalogResource[];
|
|
@@ -96,8 +166,30 @@ export type SelectedNode = { id: string; name: string; type: string; catalogReso
|
|
|
96
166
|
|
|
97
167
|
/** How long a level (L1, L2) waits for changes to settle before it's laid out again */
|
|
98
168
|
const LEVEL_RELAYOUT_MS = 300;
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Where something added goes. While L1 or L2 is shown it's added to the canvas people edit (L3), below what's on it
|
|
172
|
+
* or in the container it's dropped in, and kept on the level where it's dropped.
|
|
173
|
+
*/
|
|
174
|
+
/** Where a node of this size goes to be centred on a point */
|
|
175
|
+
const centredOn = (center: XYPosition, size: { width: number; height: number }): LevelPlace => ({
|
|
176
|
+
x: center.x - size.width / 2,
|
|
177
|
+
y: center.y - size.height / 2,
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
type Landing = {
|
|
181
|
+
/** Where it was dropped (or added), in what's shown */
|
|
182
|
+
point: XYPosition;
|
|
183
|
+
/** Where it's centred on the canvas, and the container it goes in there (else the one at that point, if any) */
|
|
184
|
+
center: XYPosition;
|
|
185
|
+
inside?: string;
|
|
186
|
+
/**
|
|
187
|
+
* Where it's centred on the level it was dropped on (in the container's coordinates when it's in one), and the
|
|
188
|
+
* size it's drawn at there before it's measured
|
|
189
|
+
*/
|
|
190
|
+
onLevel?: { level: 1 | 2; center: XYPosition; size: { width: number; height: number } };
|
|
191
|
+
};
|
|
99
192
|
const DELETE_KEYS = ['Backspace', 'Delete'];
|
|
100
|
-
const LEVEL_FIT_VIEW_OPTIONS = { ...FIT_VIEW_OPTIONS, maxZoom: 1 };
|
|
101
193
|
const NO_THREADS: ReadonlySet<string> = new Set();
|
|
102
194
|
// React Flow's warnings, except that a handle has no node: the left panel's drag previews draw nodes off the canvas
|
|
103
195
|
const onFlowError = (code: string, message: string) => {
|
|
@@ -142,8 +234,58 @@ const asLevelNote = (node: Node) => {
|
|
|
142
234
|
|
|
143
235
|
const structureOf = (nodes: Node[]) => nodes.map((node) => `${node.id}:${node.type}:${node.parentId ?? ''}`).join('|');
|
|
144
236
|
|
|
145
|
-
|
|
146
|
-
|
|
237
|
+
// A node as shown with what it says about it (hidden by the legend, glowing with a hotspot, lit as a drop target):
|
|
238
|
+
// one copy per node and look, so a node keeps its identity while others move (React Flow re-renders a node whose
|
|
239
|
+
// object is new: copying them on every drag frame re-rendered every node with a hotspot)
|
|
240
|
+
const shownAs = new WeakMap<Node, Map<string, Node>>();
|
|
241
|
+
const asShown = (node: Node, hidden: boolean, className: string) => {
|
|
242
|
+
const key = `${hidden}|${className}`;
|
|
243
|
+
let looks = shownAs.get(node);
|
|
244
|
+
if (!looks) shownAs.set(node, (looks = new Map()));
|
|
245
|
+
let shown = looks.get(key);
|
|
246
|
+
if (!shown) {
|
|
247
|
+
if (looks.size > 4) looks.clear();
|
|
248
|
+
looks.set(key, (shown = { ...node, ...(hidden && { hidden: true }), ...(className && { className }) }));
|
|
249
|
+
}
|
|
250
|
+
return shown;
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* The drawing tools, as Studio on the web's: selecting (V), the hand (H) to move around, and what's put where the
|
|
255
|
+
* canvas is next clicked: a sticky note (N), text (T), a comment (C) or a hotspot (A)
|
|
256
|
+
*/
|
|
257
|
+
type CanvasTool = 'select' | 'hand' | 'text' | 'note' | 'comment' | 'hotspot';
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* A diagram file opened into a new canvas: kept in this tab until the new canvas has loaded (empty), then put on it.
|
|
261
|
+
* By the new canvas's id.
|
|
262
|
+
*/
|
|
263
|
+
const openedKey = (canvasId: string) => `eventcatalog-studio-opened:${canvasId}`;
|
|
264
|
+
const keepOpened = (canvasId: string, { nodes, edges, threads, hotspots, levelLayouts }: ModelCanvasContent) => {
|
|
265
|
+
try {
|
|
266
|
+
sessionStorage.setItem(
|
|
267
|
+
openedKey(canvasId),
|
|
268
|
+
JSON.stringify({ nodes, edges, threads, hotspots, levelLayouts: { 1: [...levelLayouts[1]], 2: [...levelLayouts[2]] } })
|
|
269
|
+
);
|
|
270
|
+
return true;
|
|
271
|
+
} catch {
|
|
272
|
+
return false;
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
const takeOpened = (canvasId: string): ModelCanvasContent | undefined => {
|
|
276
|
+
try {
|
|
277
|
+
const raw = sessionStorage.getItem(openedKey(canvasId));
|
|
278
|
+
if (!raw) return undefined;
|
|
279
|
+
sessionStorage.removeItem(openedKey(canvasId));
|
|
280
|
+
const saved = JSON.parse(raw);
|
|
281
|
+
return { ...saved, levelLayouts: { 1: new Map(saved.levelLayouts[1]), 2: new Map(saved.levelLayouts[2]) } };
|
|
282
|
+
} catch {
|
|
283
|
+
return undefined;
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
export default function StudioDesigner({ asAgent, signedInAs, ...props }: CanvasProps) {
|
|
288
|
+
const [name, setName] = useState(getStoredName);
|
|
147
289
|
|
|
148
290
|
const saveName = useCallback((next: string) => {
|
|
149
291
|
setName(next);
|
|
@@ -151,19 +293,41 @@ export default function StudioDesigner({ defaultName, signedInAs, ...props }: Ca
|
|
|
151
293
|
}, []);
|
|
152
294
|
// Renamed on the Studio page or another canvas in this browser
|
|
153
295
|
useEffect(() => onStoredNameChange((next) => next && setName(next)), []);
|
|
296
|
+
// The colour picked (kept in this browser), else the one that follows from the name
|
|
297
|
+
const [chosenColor, setChosenColor] = useState(getStoredColor);
|
|
298
|
+
const recolor = useCallback((next: string) => {
|
|
299
|
+
setChosenColor(next);
|
|
300
|
+
storeColor(next);
|
|
301
|
+
}, []);
|
|
154
302
|
|
|
303
|
+
if (asAgent) {
|
|
304
|
+
return (
|
|
305
|
+
<ReactFlowProvider>
|
|
306
|
+
<Canvas {...props} name={asAgent.name} chosenColor={asAgent.color} agent onRecolor={() => {}} />
|
|
307
|
+
</ReactFlowProvider>
|
|
308
|
+
);
|
|
309
|
+
}
|
|
155
310
|
if (signedInAs) {
|
|
156
311
|
return (
|
|
157
312
|
<ReactFlowProvider>
|
|
158
|
-
<Canvas {...props} name={signedInAs.name} picture={signedInAs.picture} />
|
|
313
|
+
<Canvas {...props} name={signedInAs.name} picture={signedInAs.picture} chosenColor={chosenColor} onRecolor={recolor} />
|
|
159
314
|
</ReactFlowProvider>
|
|
160
315
|
);
|
|
161
316
|
}
|
|
162
|
-
if (!name)
|
|
317
|
+
if (!name)
|
|
318
|
+
return (
|
|
319
|
+
<JoinForm
|
|
320
|
+
color={chosenColor}
|
|
321
|
+
onJoin={(me) => {
|
|
322
|
+
recolor(me.color);
|
|
323
|
+
saveName(me.name);
|
|
324
|
+
}}
|
|
325
|
+
/>
|
|
326
|
+
);
|
|
163
327
|
|
|
164
328
|
return (
|
|
165
329
|
<ReactFlowProvider>
|
|
166
|
-
<Canvas {...props} name={name} onRename={saveName} />
|
|
330
|
+
<Canvas {...props} name={name} onRename={saveName} chosenColor={chosenColor} onRecolor={recolor} />
|
|
167
331
|
</ReactFlowProvider>
|
|
168
332
|
);
|
|
169
333
|
}
|
|
@@ -184,19 +348,28 @@ function Canvas({
|
|
|
184
348
|
keptInMemory = false,
|
|
185
349
|
syncViaTools,
|
|
186
350
|
followChanges = false,
|
|
351
|
+
paletteCollapsed,
|
|
187
352
|
onSelectionChange,
|
|
188
353
|
resources,
|
|
189
354
|
relations,
|
|
190
355
|
name,
|
|
191
356
|
picture,
|
|
357
|
+
agent,
|
|
192
358
|
onRename,
|
|
193
|
-
|
|
359
|
+
chosenColor,
|
|
360
|
+
onRecolor,
|
|
361
|
+
}: Omit<CanvasProps, 'asAgent' | 'signedInAs'> & {
|
|
194
362
|
name: string;
|
|
195
363
|
picture?: string;
|
|
364
|
+
/** You're here as an AI agent (shown with its logo, and can't change who you are) */
|
|
365
|
+
agent?: boolean;
|
|
196
366
|
/** Not given when you're signed in (your name is the one you signed in with) */
|
|
197
367
|
onRename?: (name: string) => void;
|
|
368
|
+
/** The colour you picked (else it follows from your name) */
|
|
369
|
+
chosenColor?: string | null;
|
|
370
|
+
onRecolor: (color: string) => void;
|
|
198
371
|
}) {
|
|
199
|
-
const color = colorForName(name);
|
|
372
|
+
const color = chosenColor ?? colorForName(name);
|
|
200
373
|
const canvasRef = useRef<HTMLDivElement>(null);
|
|
201
374
|
|
|
202
375
|
// Nodes glide to their new place after a layout (anyone's) or a level change (not while editing: it would make dragging lag)
|
|
@@ -223,12 +396,25 @@ function Canvas({
|
|
|
223
396
|
name,
|
|
224
397
|
color,
|
|
225
398
|
picture,
|
|
399
|
+
agent,
|
|
226
400
|
syncViaTools,
|
|
227
401
|
onLayout: glide,
|
|
228
402
|
onContainerResize: animateContainers,
|
|
229
403
|
});
|
|
230
|
-
const author = useMemo(
|
|
404
|
+
const author = useMemo(
|
|
405
|
+
() => ({ name, color, ...(picture && { picture }), ...(agent && { agent }) }),
|
|
406
|
+
[name, color, picture, agent]
|
|
407
|
+
);
|
|
231
408
|
const comments = useComments(flow.doc, author);
|
|
409
|
+
// Locked (looked at, not changed): for everyone on it, by whoever locked it
|
|
410
|
+
const locked = !!flow.lockedBy;
|
|
411
|
+
const lockedRef = useRef(locked);
|
|
412
|
+
lockedRef.current = locked;
|
|
413
|
+
// Fitted clear of the palette on the left (shown unless the canvas is locked)
|
|
414
|
+
const fitOptions = useMemo<FitViewOptions>(
|
|
415
|
+
() => (locked ? FIT_VIEW_OPTIONS : { ...FIT_VIEW_OPTIONS, padding: { ...FIT_VIEW_OPTIONS.padding, left: '304px' } }),
|
|
416
|
+
[locked]
|
|
417
|
+
);
|
|
232
418
|
const { screenToFlowPosition, flowToScreenPosition, setCenter, getZoom, fitView } = useReactFlow();
|
|
233
419
|
|
|
234
420
|
const catalog = useMemo(() => indexCatalog(resources, relations), [resources, relations]);
|
|
@@ -238,6 +424,13 @@ function Canvas({
|
|
|
238
424
|
const edgesRef = useRef(flow.edges);
|
|
239
425
|
edgesRef.current = flow.edges;
|
|
240
426
|
const lookupNodes = () => new Map(nodesRef.current.map((node) => [node.id, node]));
|
|
427
|
+
/** Selects just these nodes, changing only those whose selection changes (each change copies its node) */
|
|
428
|
+
const select = (ids: ReadonlySet<string>) => {
|
|
429
|
+
const changes: NodeChange[] = nodesRef.current.flatMap((node) =>
|
|
430
|
+
!!node.selected === ids.has(node.id) ? [] : [{ type: 'select' as const, id: node.id, selected: ids.has(node.id) }]
|
|
431
|
+
);
|
|
432
|
+
if (changes.length) flow.onNodesChange(changes);
|
|
433
|
+
};
|
|
241
434
|
|
|
242
435
|
// Agents in the browser (WebMCP) work on this canvas through the page, as the user's agent. Browsers without
|
|
243
436
|
// WebMCP of their own get it through the polyfill once someone turns it on (Connect agent).
|
|
@@ -254,8 +447,7 @@ function Canvas({
|
|
|
254
447
|
resources,
|
|
255
448
|
catalog,
|
|
256
449
|
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) }))),
|
|
450
|
+
select: (ids) => select(new Set(ids)),
|
|
259
451
|
fitView: (ids) => void fitView({ nodes: ids?.map((id) => ({ id })), duration: 400, padding: 0.3, maxZoom: 1.2 }),
|
|
260
452
|
reorder: () => reorder(),
|
|
261
453
|
});
|
|
@@ -279,27 +471,35 @@ function Canvas({
|
|
|
279
471
|
// ---- Adding things ----
|
|
280
472
|
|
|
281
473
|
// 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;
|
|
474
|
+
const [chooser, setChooser] = useState<{ resource: CatalogResource; at: Landing } | null>(null);
|
|
283
475
|
// A service or message that connects to things not on the canvas yet: asked whether to bring them too
|
|
284
476
|
const [connectionsChooser, setConnectionsChooser] = useState<{
|
|
285
477
|
resource: CatalogResource;
|
|
286
|
-
|
|
478
|
+
at: Landing;
|
|
287
479
|
groups: ConnectionGroup[];
|
|
288
480
|
} | null>(null);
|
|
289
481
|
|
|
290
482
|
// A catalog resource is added with edges to the related resources already on the canvas
|
|
291
|
-
const addResource =
|
|
483
|
+
const addResource = (key: string, point: XYPosition) => {
|
|
292
484
|
const resource = catalog.resourcesByKey.get(key);
|
|
293
485
|
if (!resource) return;
|
|
294
|
-
|
|
486
|
+
const at = landingAt(point, resource.node.type);
|
|
487
|
+
if (canBeContainer(resource)) return setChooser({ resource, at });
|
|
295
488
|
const groups = getCatalogConnections(resource, catalog, keysOnCanvas);
|
|
296
|
-
if (groups.length > 0) return setConnectionsChooser({ resource,
|
|
297
|
-
addCard(resource,
|
|
298
|
-
}
|
|
489
|
+
if (groups.length > 0) return setConnectionsChooser({ resource, at, groups });
|
|
490
|
+
addCard(resource, at);
|
|
491
|
+
};
|
|
299
492
|
|
|
300
|
-
const addCard = (resource: CatalogResource,
|
|
301
|
-
|
|
302
|
-
|
|
493
|
+
const addCard = (resource: CatalogResource, at: Landing) =>
|
|
494
|
+
keepOnLevel(
|
|
495
|
+
at,
|
|
496
|
+
flow.insertNode(
|
|
497
|
+
resource.node.type,
|
|
498
|
+
catalogNodeData(resource),
|
|
499
|
+
at.center,
|
|
500
|
+
(id, existing) => getRelatedEdges(resource, id, existing, catalog.relationsByKey),
|
|
501
|
+
at.inside
|
|
502
|
+
)
|
|
303
503
|
);
|
|
304
504
|
|
|
305
505
|
// Nodes waiting to be shown before the canvas is laid out around them
|
|
@@ -315,38 +515,42 @@ function Canvas({
|
|
|
315
515
|
}, [flow.nodes]);
|
|
316
516
|
|
|
317
517
|
/** A domain or system as a container, with everything it holds in the catalog inside if asked */
|
|
318
|
-
const addContainer = async (resource: CatalogResource,
|
|
518
|
+
const addContainer = async (resource: CatalogResource, at: Landing, withContents: boolean) => {
|
|
319
519
|
const existing = nodesRef.current;
|
|
320
520
|
const built = withContents
|
|
321
|
-
? await buildContainerWithContents(resource, center, catalog, existing)
|
|
322
|
-
: { nodes: [buildContainer(resource, center)], edges: [] };
|
|
521
|
+
? await buildContainerWithContents(resource, at.center, catalog, existing)
|
|
522
|
+
: { nodes: [buildContainer(resource, at.center)], edges: [] };
|
|
323
523
|
// Dropped in another container (e.g. a system in its domain): it goes in it
|
|
324
|
-
const outer =
|
|
524
|
+
const outer = containerFor(at.center, existing, at.inside);
|
|
325
525
|
const nodes = outer
|
|
326
526
|
? built.nodes.map((node) =>
|
|
327
527
|
node.parentId ? node : { ...node, parentId: outer.id, position: toRelativePosition(node.position, outer.id, existing) }
|
|
328
528
|
)
|
|
329
529
|
: built.nodes;
|
|
330
|
-
// With its contents, the canvas is laid out again to make room for it all
|
|
331
|
-
|
|
530
|
+
// With its contents, the canvas is laid out again to make room for it all (not when added on a level: it's
|
|
531
|
+
// below everything on the canvas, and nothing on the level moves)
|
|
532
|
+
if (withContents && existing.length > 0 && !at.onLevel) reorderOnceShown.current = nodes.map((node) => node.id);
|
|
332
533
|
flow.insertNodes(nodes, built.edges);
|
|
534
|
+
keepOnLevel(at, built.nodes.find((node) => !node.parentId)?.id);
|
|
333
535
|
};
|
|
334
536
|
|
|
335
537
|
const choose = useStableCallback((choice: ContainerChoice) => {
|
|
336
538
|
if (!chooser) return;
|
|
337
|
-
if (choice.as === 'card') addCard(chooser.resource, chooser.
|
|
338
|
-
else void addContainer(chooser.resource, chooser.
|
|
539
|
+
if (choice.as === 'card') addCard(chooser.resource, chooser.at);
|
|
540
|
+
else void addContainer(chooser.resource, chooser.at, !!choice.withContents);
|
|
339
541
|
setChooser(null);
|
|
340
542
|
});
|
|
341
543
|
const cancelChooser = useCallback(() => setChooser(null), []);
|
|
342
544
|
|
|
343
545
|
const chooseConnections = useStableCallback((choice: ConnectionsChoice) => {
|
|
344
546
|
if (!connectionsChooser) return;
|
|
345
|
-
const { resource,
|
|
346
|
-
if (choice.as === 'card') addCard(resource,
|
|
547
|
+
const { resource, at } = connectionsChooser;
|
|
548
|
+
if (choice.as === 'card') addCard(resource, at);
|
|
347
549
|
else {
|
|
348
|
-
const { nodes, edges } = planWithConnections(nodesRef.current, resource, center, choice.include, catalog);
|
|
550
|
+
const { nodes, edges } = planWithConnections(nodesRef.current, resource, at.center, choice.include, catalog, at.inside);
|
|
349
551
|
flow.insertNodes(nodes, edges);
|
|
552
|
+
// What it connects to is placed next to it on the level
|
|
553
|
+
keepOnLevel(at, nodes.find((node) => getCatalogLink(node)?.key === resource.key)?.id);
|
|
350
554
|
}
|
|
351
555
|
setConnectionsChooser(null);
|
|
352
556
|
});
|
|
@@ -364,31 +568,71 @@ function Canvas({
|
|
|
364
568
|
};
|
|
365
569
|
}, [chooser, catalog, keysOnCanvas]);
|
|
366
570
|
|
|
367
|
-
/** A sticky note on the level shown (it's only shown on that level) */
|
|
571
|
+
/** A sticky note on the level shown (it's only shown on that level), written on straight away */
|
|
368
572
|
const addNoteAt = (center: XYPosition) => {
|
|
369
573
|
const data = getNodeDefinition('note')!.createData();
|
|
370
|
-
flow.insertNode('note', levelRef.current === 3 ? data : { ...data, level: levelRef.current }, center);
|
|
574
|
+
const id = flow.insertNode('note', levelRef.current === 3 ? data : { ...data, level: levelRef.current }, center);
|
|
575
|
+
if (id) setStartWriting(id);
|
|
576
|
+
};
|
|
577
|
+
// The note or text just put on the canvas: written on straight away (see canvas-nodes)
|
|
578
|
+
const [startWriting, setStartWriting] = useState<string | null>(null);
|
|
579
|
+
/** Text written on the canvas, its first line where it's put (on the level shown, as notes are) */
|
|
580
|
+
const writeTextAt = (point: XYPosition) => {
|
|
581
|
+
const size = getNodeDefinition('text')!.defaultSize!;
|
|
582
|
+
const data = levelRef.current === 3 ? { text: '' } : { text: '', level: levelRef.current };
|
|
583
|
+
const id = flow.insertNode('text', data, { x: point.x + size.width / 2 - 4, y: point.y + size.height / 2 - 16 });
|
|
584
|
+
if (id) setStartWriting(id);
|
|
371
585
|
};
|
|
372
586
|
/**
|
|
373
|
-
* Where something added
|
|
374
|
-
*
|
|
587
|
+
* Where something added at a point goes (see Landing). On L1 and L2 it's centred where it's dropped, in the
|
|
588
|
+
* container there (and in the same container on the canvas). Something the level doesn't show (a message on L1)
|
|
589
|
+
* goes to L3, where it's shown.
|
|
375
590
|
*/
|
|
376
|
-
const
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
const
|
|
380
|
-
if (
|
|
381
|
-
|
|
382
|
-
const
|
|
383
|
-
const
|
|
384
|
-
|
|
591
|
+
const landingAt = (point: XYPosition, type: string | undefined): Landing => {
|
|
592
|
+
const level = levelRef.current;
|
|
593
|
+
if (level === 3) return { point, center: point };
|
|
594
|
+
const shownHere = isShownOnLevel(type, level);
|
|
595
|
+
if (!shownHere) changeLevel(3);
|
|
596
|
+
// The container it's dropped in on the level, which the canvas has too (levels keep the canvas's ids)
|
|
597
|
+
const shown = shownHere && levelGraphRef.current?.level === level ? levelGraphRef.current.nodes : [];
|
|
598
|
+
const container = canGoInContainer(type) ? findGroupAtPoint(point, shown) : undefined;
|
|
599
|
+
const canvas = nodesRef.current;
|
|
600
|
+
const inside = container && containerFor(point, canvas, container.id) ? container.id : undefined;
|
|
601
|
+
// On the canvas: below what's in that container, or below everything
|
|
602
|
+
const lookup = new Map(canvas.map((node) => [node.id, node]));
|
|
603
|
+
const beside = canvas.filter((node) => (inside ? node.parentId === inside : !node.parentId && isOnLevel(node, 3)));
|
|
604
|
+
const rects = (beside.length ? beside : inside ? [lookup.get(inside)!] : []).map((node) => getAbsoluteRect(node, lookup));
|
|
605
|
+
const center = rects.length
|
|
606
|
+
? {
|
|
607
|
+
x: (Math.min(...rects.map((rect) => rect.x)) + Math.max(...rects.map((rect) => rect.x + rect.width))) / 2,
|
|
608
|
+
// Below them, or in the middle of an empty container
|
|
609
|
+
y: beside.length ? Math.max(...rects.map((rect) => rect.y + rect.height)) + 160 : rects[0].y + rects[0].height / 2,
|
|
610
|
+
}
|
|
611
|
+
: { x: 0, y: 0 };
|
|
612
|
+
if (!shownHere) return { point, center };
|
|
613
|
+
const origin =
|
|
614
|
+
container && inside ? getAbsolutePosition(container, new Map(shown.map((node) => [node.id, node]))) : { x: 0, y: 0 };
|
|
615
|
+
const size = getLevelNodeSize(type, level);
|
|
616
|
+
return { point, center, inside, onLevel: { level, center: { x: point.x - origin.x, y: point.y - origin.y }, size } };
|
|
617
|
+
};
|
|
618
|
+
// Added on a level, until they're measured, with the size they were centred at: centred again at their real size
|
|
619
|
+
const centring = useRef(new Map<string, { level: 1 | 2; size: { width: number; height: number } }>());
|
|
620
|
+
/** Keeps what was added where it was dropped on the level (undone with adding it) */
|
|
621
|
+
const keepOnLevel = (at: Landing, id: string | undefined) => {
|
|
622
|
+
if (!id || !at.onLevel) return;
|
|
623
|
+
const { level, center, size } = at.onLevel;
|
|
624
|
+
centring.current.set(id, { level, size });
|
|
625
|
+
flow.setLevelLayout(level, new Map([[id, centredOn(center, size)]]));
|
|
626
|
+
};
|
|
627
|
+
/** A component (its type, or its key in the components list: the kinds of data store share a type) */
|
|
628
|
+
const addComponentAt = (component: string, point: XYPosition) => {
|
|
629
|
+
if (component === 'note') return addNoteAt(point);
|
|
630
|
+
const at = landingAt(point, getNodeDefinition(component)?.type ?? component);
|
|
631
|
+
keepOnLevel(at, flow.addNode(component, at.center, at.inside));
|
|
385
632
|
};
|
|
386
|
-
const addComponentAt = (type: string, center: XYPosition) =>
|
|
387
|
-
type === 'note' ? addNoteAt(center) : flow.addNode(type, centerOnCanvas(center));
|
|
388
633
|
|
|
389
634
|
const addComponent = useStableCallback((type: string) => addComponentAt(type, canvasCenter()));
|
|
390
|
-
const
|
|
391
|
-
const addResourceFromPanel = useStableCallback((key: string) => addResource(key, centerOnCanvas(canvasCenter())));
|
|
635
|
+
const addResourceFromPanel = useStableCallback((key: string) => addResource(key, canvasCenter()));
|
|
392
636
|
|
|
393
637
|
const onDrop = useStableCallback((event: DragEvent) => {
|
|
394
638
|
event.preventDefault();
|
|
@@ -396,7 +640,7 @@ function Canvas({
|
|
|
396
640
|
const type = event.dataTransfer.getData(COMPONENT_DRAG_TYPE);
|
|
397
641
|
const resourceKey = event.dataTransfer.getData(CATALOG_DRAG_TYPE);
|
|
398
642
|
if (type) addComponentAt(type, position);
|
|
399
|
-
if (resourceKey) addResource(resourceKey,
|
|
643
|
+
if (resourceKey) addResource(resourceKey, position);
|
|
400
644
|
});
|
|
401
645
|
|
|
402
646
|
// ---- Pointer, panning and dragging ----
|
|
@@ -486,8 +730,13 @@ function Canvas({
|
|
|
486
730
|
// While the canvas is panned, zoomed or dragged, message edges stop animating (a class, not state, so nothing re-renders)
|
|
487
731
|
const interactions = useRef({ moving: false, dragging: false });
|
|
488
732
|
const setInteraction = useCallback((kind: 'moving' | 'dragging', on: boolean) => {
|
|
733
|
+
const wasActive = interactions.current.moving || interactions.current.dragging;
|
|
489
734
|
interactions.current[kind] = on;
|
|
490
|
-
|
|
735
|
+
const active = interactions.current.moving || interactions.current.dragging;
|
|
736
|
+
if (active === wasActive) return;
|
|
737
|
+
canvasRef.current?.classList.toggle('ec-interaction-active', active);
|
|
738
|
+
// (hiding them doesn't stop the envelopes' SMIL)
|
|
739
|
+
pauseEdgeAnimations(canvasRef.current, active);
|
|
491
740
|
}, []);
|
|
492
741
|
|
|
493
742
|
// The camera follows agents unless people move it themselves (set by AgentActivity)
|
|
@@ -503,10 +752,6 @@ function Canvas({
|
|
|
503
752
|
|
|
504
753
|
// Dragging something into a container puts it in it, out of one takes it out
|
|
505
754
|
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
755
|
// While something's dragged, the containers around it grow to fit it (see previewContainerGrowth): the canvas
|
|
511
756
|
// when the drag started, and those containers, innermost first
|
|
512
757
|
const dragStart = useRef<{ nodes: Map<string, Node>; containers: string[] } | null>(null);
|
|
@@ -553,15 +798,17 @@ function Canvas({
|
|
|
553
798
|
dragged.forEach((node) => now.set(node.id, { ...now.get(node.id)!, position: node.position }));
|
|
554
799
|
flow.showDragPreview(previewContainerGrowth(started.containers, new Set(dragged.map((node) => node.id)), started.nodes, now));
|
|
555
800
|
};
|
|
556
|
-
const onNodeDrag = useStableCallback((_: unknown,
|
|
801
|
+
const onNodeDrag = useStableCallback((_: unknown, held: Node, dragged: Node[]) => {
|
|
557
802
|
growContainers(dragged);
|
|
558
803
|
moveThreadsWith(dragged);
|
|
559
|
-
const
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
804
|
+
const moves = findDropTargets(
|
|
805
|
+
held.id,
|
|
806
|
+
dragged.map((node) => node.id),
|
|
807
|
+
nodesRef.current
|
|
808
|
+
);
|
|
809
|
+
setDropTargetId(moves.find(({ target }) => target)?.target?.id ?? null);
|
|
563
810
|
});
|
|
564
|
-
const onNodeDragStop = useStableCallback((_: unknown,
|
|
811
|
+
const onNodeDragStop = useStableCallback((_: unknown, held: Node, dragged: Node[]) => {
|
|
565
812
|
setInteraction('dragging', false);
|
|
566
813
|
setDropTargetId(null);
|
|
567
814
|
dragStart.current = null;
|
|
@@ -579,56 +826,113 @@ function Canvas({
|
|
|
579
826
|
}
|
|
580
827
|
threadsDrag.current = null;
|
|
581
828
|
setThreadsOffset(null);
|
|
582
|
-
// The containers it grew are saved with the drop; dropped in another container (or out of one), it moves there
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
829
|
+
// The containers it grew are saved with the drop; dropped in another container (or out of one), it moves there,
|
|
830
|
+
// and what's selected with it goes too, into free space there (gliding off anything it was dropped on)
|
|
831
|
+
const moves = findDropTargets(
|
|
832
|
+
held.id,
|
|
833
|
+
dragged.map((node) => node.id),
|
|
834
|
+
nodesRef.current
|
|
835
|
+
);
|
|
836
|
+
if (!moves.length) return;
|
|
837
|
+
const { places, nudged } = placeDropped(nodesRef.current, moves);
|
|
838
|
+
if (nudged) glide();
|
|
839
|
+
flow.setParents(moves.map(({ id, target }) => ({ id, parentId: target?.id, at: places.get(id) })));
|
|
588
840
|
});
|
|
589
841
|
|
|
590
842
|
// ---- Levels of detail ----
|
|
591
843
|
|
|
592
|
-
// Like EventCatalog's diagrams: L3 is the canvas
|
|
593
|
-
|
|
844
|
+
// Like EventCatalog's diagrams: L3 is everything on the canvas, L1 and L2 less detailed views of it, arranged and
|
|
845
|
+
// edited on their own (what's done there changes the canvas: a system card is the system). A canvas opened
|
|
846
|
+
// from a diagram opens at the level it was shown at (?level=1 or 2), once: reloading opens the canvas (L3).
|
|
847
|
+
const [level, setLevel] = useState<Level>(() => {
|
|
848
|
+
const url = new URL(window.location.href);
|
|
849
|
+
const asked = Number(url.searchParams.get('level'));
|
|
850
|
+
if (!url.searchParams.has('level')) return 3;
|
|
851
|
+
url.searchParams.delete('level');
|
|
852
|
+
window.history.replaceState(window.history.state, '', url);
|
|
853
|
+
return asked === 1 || asked === 2 ? asked : 3;
|
|
854
|
+
});
|
|
594
855
|
const levelRef = useRef(level);
|
|
595
856
|
levelRef.current = level;
|
|
857
|
+
// L3 is the canvas itself: comments and other people's pointers are there (L1 and L2 are arranged on their own)
|
|
596
858
|
const editable = level === 3;
|
|
597
|
-
const [levelGraph,
|
|
859
|
+
const [levelGraph, setLevelGraphState] = useState<{ level: Level; nodes: Node[]; edges: Edge[] } | null>(null);
|
|
860
|
+
// The level shown as it is now (for changes to it, and saving where things are put)
|
|
861
|
+
const levelGraphRef = useRef(levelGraph);
|
|
862
|
+
/** Changes to the level shown (moved, selected, measured), as they happen */
|
|
863
|
+
const updateLevelGraph = useCallback((next: { level: Level; nodes: Node[]; edges: Edge[] }) => {
|
|
864
|
+
levelGraphRef.current = next;
|
|
865
|
+
setLevelGraphState(next);
|
|
866
|
+
}, []);
|
|
867
|
+
/**
|
|
868
|
+
* Shows a level (arranged again), keeping what hasn't changed on it as it's shown (so it doesn't render again), and
|
|
869
|
+
* what's selected and nodes' measured sizes (not measured again). Containers come before what's in them, as React
|
|
870
|
+
* Flow needs (the canvas's order can have them after).
|
|
871
|
+
*/
|
|
872
|
+
const setLevelGraph = useCallback((next: { level: Level; nodes: Node[]; edges: Edge[] } | null) => {
|
|
873
|
+
const previous = levelGraphRef.current;
|
|
874
|
+
const same = previous && next && previous.level === next.level ? previous : null;
|
|
875
|
+
const shown = next && {
|
|
876
|
+
...next,
|
|
877
|
+
nodes: sortByHierarchy(same ? keepUnchanged(same.nodes, next.nodes, LOCAL_NODE_KEYS) : next.nodes),
|
|
878
|
+
edges: same ? keepUnchanged(same.edges, next.edges, LOCAL_EDGE_KEYS) : next.edges,
|
|
879
|
+
};
|
|
880
|
+
levelGraphRef.current = shown;
|
|
881
|
+
setLevelGraphState(shown);
|
|
882
|
+
}, []);
|
|
598
883
|
|
|
599
884
|
// What a level shows depends on what's on the canvas and how it's connected, and names (not positions). Notes
|
|
600
885
|
// aren't laid out with it: adding, moving or writing one doesn't lay the level out again.
|
|
601
|
-
|
|
602
|
-
|
|
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('|')}`;
|
|
886
|
+
// Also whether a canvas opened from a diagram still has the diagram's levels: only worked out when needed
|
|
887
|
+
const levelKey = level === 3 ? '' : flow.structureKey;
|
|
608
888
|
const shownLevelRef = useRef<Level | undefined>(undefined);
|
|
609
889
|
shownLevelRef.current = levelGraph?.level;
|
|
890
|
+
const levelLayout = level === 3 ? undefined : flow.levelLayouts[level];
|
|
610
891
|
useEffect(() => {
|
|
611
892
|
if (level === 3) return setLevelGraph(null);
|
|
612
893
|
let cancelled = false;
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
(
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
894
|
+
const show = () => {
|
|
895
|
+
// A canvas opened from a diagram shows the diagram's levels as it did, until what's on the canvas changes
|
|
896
|
+
const fromDiagram = getDiagramLevel(flow.diagramLevels, level, flow.structureKey);
|
|
897
|
+
const diagram = flow.diagramLevels?.[level];
|
|
898
|
+
const derived = fromDiagram ? undefined : getLevelGraph(nodesRef.current, edgesRef.current, level);
|
|
899
|
+
const content = fromDiagram ?? { ...derived!, edges: withDiagramRoutes(derived!.edges, diagram?.edges) };
|
|
900
|
+
// Where things are: as arranged on the level, else as the diagram had them, and anything new next to what it
|
|
901
|
+
// connects to (kept there, so nothing moves later)
|
|
902
|
+
const arranged = arrangeLevel(content, flow.levelLayouts[level], diagram && levelPlaces(diagram.nodes));
|
|
903
|
+
if (arranged) {
|
|
904
|
+
setLevelGraph({ level, ...arranged.graph });
|
|
905
|
+
if (arranged.placed.size) flow.setLevelLayout(level, arranged.placed, 'level');
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
908
|
+
// Nothing on it has a place yet: laid out with the visualiser's layout once, and kept
|
|
909
|
+
void layoutGraph(
|
|
910
|
+
content.nodes.map((node) => ({ ...node, selected: false })),
|
|
911
|
+
content.edges
|
|
912
|
+
).then((laidOut) => {
|
|
913
|
+
if (cancelled) return;
|
|
914
|
+
setLevelGraph({ level, ...laidOut });
|
|
915
|
+
flow.setLevelLayout(level, levelPlaces(laidOut.nodes), 'level');
|
|
916
|
+
// (with sizes guessed from the nodes' types: laid out again once they're drawn, see below)
|
|
917
|
+
if (!flow.presence?.getPeers().length) relayoutWhenMeasured.current = level;
|
|
918
|
+
});
|
|
919
|
+
};
|
|
920
|
+
// Straight away when switching levels (or from the diagram); after changes settle while one is shown
|
|
921
|
+
if (shownLevelRef.current !== level || getDiagramLevel(flow.diagramLevels, level, flow.structureKey)) {
|
|
922
|
+
show();
|
|
923
|
+
return () => void (cancelled = true);
|
|
924
|
+
}
|
|
925
|
+
const timer = setTimeout(show, LEVEL_RELAYOUT_MS);
|
|
624
926
|
return () => {
|
|
625
927
|
cancelled = true;
|
|
626
928
|
clearTimeout(timer);
|
|
627
929
|
};
|
|
628
|
-
}, [level, levelKey]);
|
|
930
|
+
}, [level, levelKey, flow.diagramLevels, levelLayout]);
|
|
629
931
|
|
|
630
|
-
//
|
|
631
|
-
const [
|
|
932
|
+
// The tool picked (see CanvasTool): comments are right click > Add comment, or C then click (like Figma)
|
|
933
|
+
const [tool, setTool] = useState<CanvasTool>('select');
|
|
934
|
+
const commentMode = tool === 'comment';
|
|
935
|
+
const placing = tool === 'text' || tool === 'note';
|
|
632
936
|
const [draft, setDraft] = useState<CommentAnchor | null>(null);
|
|
633
937
|
const [openThreadId, setOpenThreadId] = useState<string | null>(null);
|
|
634
938
|
const [showResolved, setShowResolved] = useState(false);
|
|
@@ -640,10 +944,31 @@ function Canvas({
|
|
|
640
944
|
const threadsShown = () => comments.threads.filter((thread) => showResolved || !thread.resolved);
|
|
641
945
|
const [contextMenu, setContextMenu] = useState<ContextMenuState>(null);
|
|
642
946
|
|
|
947
|
+
// A level's first layout is worked out before its nodes have been drawn, so with sizes guessed from their types: a
|
|
948
|
+
// little off once they're drawn at their real sizes. Once they all have been (measured), it's laid out again with
|
|
949
|
+
// them, as Reorder would, gliding there. Only that first time, and not while others are on the canvas: it would
|
|
950
|
+
// move what they're looking at (the level stays as first laid out, and Reorder tidies it)
|
|
951
|
+
const relayoutWhenMeasured = useRef<1 | 2 | null>(null);
|
|
952
|
+
useEffect(() => {
|
|
953
|
+
const pending = relayoutWhenMeasured.current;
|
|
954
|
+
if (!pending || levelGraph?.level !== pending) return;
|
|
955
|
+
if (!levelGraph.nodes.every((node) => node.measured?.width && node.measured?.height)) return;
|
|
956
|
+
relayoutWhenMeasured.current = null;
|
|
957
|
+
void layoutGraph(
|
|
958
|
+
levelGraph.nodes.map((node) => ({ ...node, selected: false })),
|
|
959
|
+
levelGraph.edges
|
|
960
|
+
).then((laidOut) => {
|
|
961
|
+
if (levelRef.current !== pending) return;
|
|
962
|
+
glide();
|
|
963
|
+
flow.setLevelLayout(pending, levelPlaces(laidOut.nodes), 'level');
|
|
964
|
+
setTimeout(() => void fitView({ ...fitOptions, duration: 450 }), 50);
|
|
965
|
+
});
|
|
966
|
+
}, [levelGraph]);
|
|
967
|
+
|
|
643
968
|
const changeLevel = useStableCallback((next: Level) => {
|
|
644
969
|
if (next === level) return;
|
|
645
970
|
glide();
|
|
646
|
-
|
|
971
|
+
setTool('select');
|
|
647
972
|
setDraft(null);
|
|
648
973
|
setOpenThreadId(null);
|
|
649
974
|
// What's selected may not be on the next level (e.g. a note added on this one)
|
|
@@ -652,22 +977,41 @@ function Canvas({
|
|
|
652
977
|
});
|
|
653
978
|
const editInL3 = useCallback(() => changeLevel(3), [changeLevel]);
|
|
654
979
|
|
|
655
|
-
// Fit the camera to a level once it's laid out (and to the canvas when going back to L3)
|
|
980
|
+
// Fit the camera to a level once it's laid out (and to the canvas when going back to L3). A level shown before the
|
|
981
|
+
// canvas has arrived (opened at ?level=) is laid out empty first: it's fitted once it has something to show.
|
|
656
982
|
const shownLevel = level === 3 ? 3 : levelGraph?.level;
|
|
983
|
+
const levelHasNodes = !!levelGraph?.nodes.length;
|
|
657
984
|
useEffect(() => {
|
|
658
|
-
if (shownLevel === undefined) return;
|
|
659
|
-
|
|
660
|
-
const options = shownLevel === 3 ? FIT_VIEW_OPTIONS : LEVEL_FIT_VIEW_OPTIONS;
|
|
661
|
-
const frame = requestAnimationFrame(() => void fitView({ ...options, duration: 450 }));
|
|
985
|
+
if (shownLevel === undefined || (shownLevel !== 3 && !levelHasNodes)) return;
|
|
986
|
+
const frame = requestAnimationFrame(() => void fitView({ ...fitOptions, duration: 450 }));
|
|
662
987
|
return () => cancelAnimationFrame(frame);
|
|
663
|
-
}, [shownLevel]);
|
|
988
|
+
}, [shownLevel, levelHasNodes]);
|
|
664
989
|
|
|
665
|
-
/**
|
|
990
|
+
/**
|
|
991
|
+
* Lays the canvas out with the visualiser's layout, for everyone on it (everyone's nodes glide there). On L1 or
|
|
992
|
+
* L2 it lays out just that level (one step to undo), leaving the canvas as it is.
|
|
993
|
+
*/
|
|
666
994
|
const reorder = useStableCallback(async () => {
|
|
667
|
-
|
|
995
|
+
const level = levelRef.current;
|
|
996
|
+
if (level !== 3) {
|
|
997
|
+
const content =
|
|
998
|
+
getDiagramLevel(flow.diagramLevels, level, flow.structureKey) ?? getLevelGraph(nodesRef.current, edgesRef.current, level);
|
|
999
|
+
// As they're drawn on the level (its cards and boxes aren't the canvas's), where they have been
|
|
1000
|
+
const drawn = new Map(
|
|
1001
|
+
(levelGraphRef.current?.level === level ? levelGraphRef.current.nodes : []).map((node) => [node.id, node.measured])
|
|
1002
|
+
);
|
|
1003
|
+
const laidOut = await layoutGraph(
|
|
1004
|
+
content.nodes.map((node) => ({ ...node, selected: false, ...(drawn.get(node.id) && { measured: drawn.get(node.id) }) })),
|
|
1005
|
+
content.edges
|
|
1006
|
+
);
|
|
1007
|
+
glide();
|
|
1008
|
+
flow.setLevelLayout(level, levelPlaces(laidOut.nodes));
|
|
1009
|
+
setTimeout(() => void fitView({ ...fitOptions, duration: 450 }), 50);
|
|
1010
|
+
return;
|
|
1011
|
+
}
|
|
668
1012
|
const positions = await getLayoutPositions(nodesRef.current, edgesRef.current);
|
|
669
1013
|
flow.applyPositions(positions);
|
|
670
|
-
setTimeout(() => void fitView({ ...
|
|
1014
|
+
setTimeout(() => void fitView({ ...fitOptions, duration: 450 }), 50);
|
|
671
1015
|
});
|
|
672
1016
|
const onReorder = useCallback(() => void reorder(), [reorder]);
|
|
673
1017
|
|
|
@@ -692,27 +1036,234 @@ function Canvas({
|
|
|
692
1036
|
const notes = sortedNodes.filter((node) => getNoteLevel(node) === levelShown.level);
|
|
693
1037
|
return notes.length ? [...levelShown.nodes, ...notes.map(asLevelNote)] : levelShown.nodes;
|
|
694
1038
|
}, [levelShown, sortedNodes]);
|
|
695
|
-
|
|
1039
|
+
|
|
1040
|
+
// The legend, as the visualiser's: how many of each kind of node is shown (not what's written on the canvas), and
|
|
1041
|
+
// clicking a kind hides or shows it. Hiding only changes what this person sees.
|
|
1042
|
+
const [hiddenLegendKeys, setHiddenLegendKeys] = useState<string[]>([]);
|
|
1043
|
+
const toggleLegendKey = useCallback(
|
|
1044
|
+
(key: string) => setHiddenLegendKeys((keys) => (keys.includes(key) ? keys.filter((other) => other !== key) : [...keys, key])),
|
|
1045
|
+
[]
|
|
1046
|
+
);
|
|
1047
|
+
// What's shown only changes with what's on it (not as nodes are dragged, on any level)
|
|
1048
|
+
const shownStructure = levelShown ? structureOf(levelShown.nodes) : structure;
|
|
1049
|
+
const legend = useMemo(() => getLegend(shownNodes.filter((node) => !isWrittenOnCanvas(node.type))), [shownStructure]);
|
|
1050
|
+
const shownNodesRef = useRef(shownNodes);
|
|
1051
|
+
shownNodesRef.current = shownNodes;
|
|
1052
|
+
// The nodes shown now (on L1 and L2 they're the level's, where they are there)
|
|
1053
|
+
const shownLookup = () => new Map<string, Node>(shownNodesRef.current.map((node) => [node.id, node]));
|
|
1054
|
+
|
|
1055
|
+
// ---- Hotspots: what's flagged for people to know (risks, problems, the key parts), on nodes or places ----
|
|
1056
|
+
|
|
1057
|
+
const [hotspotDraft, setHotspotDraft] = useState<HotspotDraft | null>(null);
|
|
1058
|
+
const [openHotspot, setOpenHotspot] = useState<string | null>(null);
|
|
1059
|
+
const [listingHotspots, setListingHotspots] = useState(false);
|
|
1060
|
+
// The node a hotspot being dragged would go on: it lights up in the hotspot's colour
|
|
1061
|
+
const [hotspotDropTarget, setHotspotDropTarget] = useState<{ nodeId: string; severity: HotspotSeverity } | null>(null);
|
|
1062
|
+
// Hotspots where they show on this level: on L3 on their node (or at their place); on L1 and L2 on their node if
|
|
1063
|
+
// it's shown, else on what it's in that is (so they're found from the overview). From what's on the canvas and the
|
|
1064
|
+
// level, read through refs: the nodes arrays are new on every drag frame, and which node holds which doesn't
|
|
1065
|
+
// change while things move.
|
|
1066
|
+
const hotspotSpots = useMemo((): HotspotSpot[] => {
|
|
1067
|
+
const canvas = new Map(nodesRef.current.map((node) => [node.id, node]));
|
|
1068
|
+
const shown = new Set(shownNodesRef.current.map((node) => node.id));
|
|
1069
|
+
const onNodes = new Map<string, Hotspot[]>();
|
|
1070
|
+
const holding = new Set<string>();
|
|
1071
|
+
const places: HotspotSpot[] = [];
|
|
1072
|
+
// The node itself if it's shown, else the nearest container of it that is
|
|
1073
|
+
const shownAround = (id: string | undefined) => {
|
|
1074
|
+
while (id && !shown.has(id)) id = canvas.get(id)?.parentId;
|
|
1075
|
+
return id;
|
|
1076
|
+
};
|
|
1077
|
+
for (const hotspot of flow.hotspots) {
|
|
1078
|
+
let target = hotspot.nodeId && canvas.has(hotspot.nodeId) ? hotspot.nodeId : undefined;
|
|
1079
|
+
if (!target) {
|
|
1080
|
+
if ((hotspot.level ?? 3) === level)
|
|
1081
|
+
places.push({ key: `place:${hotspot.id}`, position: hotspot.position, hotspots: [hotspot] });
|
|
1082
|
+
continue;
|
|
1083
|
+
}
|
|
1084
|
+
const own = target;
|
|
1085
|
+
target = shownAround(own);
|
|
1086
|
+
// Not in anything shown (e.g. a message on L2): on what it's connected to (its sender first), or what that's in
|
|
1087
|
+
if (!target) {
|
|
1088
|
+
const linked = flow.edges
|
|
1089
|
+
.filter((edge) => edge.source === own || edge.target === own)
|
|
1090
|
+
.sort((a) => (a.target === own ? -1 : 1));
|
|
1091
|
+
target = linked.map((edge) => shownAround(edge.source === own ? edge.target : edge.source)).find(Boolean);
|
|
1092
|
+
}
|
|
1093
|
+
if (!target) continue;
|
|
1094
|
+
if (target !== hotspot.nodeId) holding.add(target);
|
|
1095
|
+
onNodes.set(target, [...(onNodes.get(target) ?? []), hotspot]);
|
|
1096
|
+
}
|
|
1097
|
+
return [
|
|
1098
|
+
...[...onNodes].map(([nodeId, hotspots]) => ({ key: `node:${nodeId}`, nodeId, hotspots, inside: holding.has(nodeId) })),
|
|
1099
|
+
...places,
|
|
1100
|
+
];
|
|
1101
|
+
}, [flow.hotspots, structure, shownStructure, flow.edges, level]);
|
|
1102
|
+
|
|
1103
|
+
const visibleNodes = useMemo(() => {
|
|
1104
|
+
// A node with hotspots glows in the colour of the most important (see studio.css)
|
|
1105
|
+
const glowing = new Map(
|
|
1106
|
+
hotspotSpots.flatMap((spot) => (spot.nodeId ? [[spot.nodeId, topSeverity(spot.hotspots)] as const] : []))
|
|
1107
|
+
);
|
|
1108
|
+
const dropOn = hotspotDropTarget?.nodeId;
|
|
1109
|
+
if (!hiddenLegendKeys.length && !glowing.size && !dropOn) return shownNodes;
|
|
1110
|
+
return shownNodes.map((node) => {
|
|
1111
|
+
const hidden = hiddenLegendKeys.includes(node.type ?? '');
|
|
1112
|
+
const severity = glowing.get(node.id);
|
|
1113
|
+
const target = node.id === dropOn;
|
|
1114
|
+
if (!hidden && !severity && !target) return node;
|
|
1115
|
+
const classes = [
|
|
1116
|
+
node.className,
|
|
1117
|
+
severity && `studio-hotspot studio-hotspot-${severity}`,
|
|
1118
|
+
target && `studio-hotspot-drop studio-hotspot-drop-${hotspotDropTarget!.severity}`,
|
|
1119
|
+
];
|
|
1120
|
+
return asShown(node, hidden, classes.filter(Boolean).join(' '));
|
|
1121
|
+
});
|
|
1122
|
+
}, [shownNodes, hiddenLegendKeys, hotspotSpots, hotspotDropTarget]);
|
|
1123
|
+
|
|
1124
|
+
const startHotspot = useStableCallback((at: HotspotDraft) => {
|
|
1125
|
+
setOpenHotspot(null);
|
|
1126
|
+
setDraft(null);
|
|
1127
|
+
setHotspotDraft(at);
|
|
1128
|
+
setTool('select');
|
|
1129
|
+
});
|
|
1130
|
+
const createHotspot = useStableCallback((fields: HotspotFields) => {
|
|
1131
|
+
if (!hotspotDraft) return;
|
|
1132
|
+
flow.createHotspot({
|
|
1133
|
+
...fields,
|
|
1134
|
+
...(hotspotDraft.nodeId ? { nodeId: hotspotDraft.nodeId } : hotspotDraft.level && { level: hotspotDraft.level }),
|
|
1135
|
+
position: hotspotDraft.position,
|
|
1136
|
+
author,
|
|
1137
|
+
});
|
|
1138
|
+
setHotspotDraft(null);
|
|
1139
|
+
});
|
|
1140
|
+
const cancelHotspot = useCallback(() => setHotspotDraft(null), []);
|
|
1141
|
+
const deleteHotspot = useStableCallback((id: string) => flow.deleteHotspot(id));
|
|
1142
|
+
// Dragged onto a node (they move with it), or to a place; open where it lands if it was open
|
|
1143
|
+
const moveHotspots = useStableCallback((ids: string[], to: { nodeId?: string; position: XYPosition }) => {
|
|
1144
|
+
const at = to.nodeId ? {} : placeLevel();
|
|
1145
|
+
ids.forEach((id) =>
|
|
1146
|
+
flow.updateHotspot(id, { nodeId: to.nodeId, position: to.position, level: 'level' in at ? at.level : undefined })
|
|
1147
|
+
);
|
|
1148
|
+
setOpenHotspot((open) => (open ? (to.nodeId ? `node:${to.nodeId}` : ids.length === 1 ? `place:${ids[0]}` : null) : open));
|
|
1149
|
+
});
|
|
1150
|
+
// From the list (or inside a node on L1 and L2): the canvas goes to it, and it opens
|
|
1151
|
+
const goToHotspot = useStableCallback((hotspot: Hotspot) => {
|
|
1152
|
+
// On a node shown here, it's here; on another, on L3; at a place, on its level
|
|
1153
|
+
const shownHere = hotspot.nodeId && shownLookup().has(hotspot.nodeId);
|
|
1154
|
+
const target = shownHere ? levelRef.current : hotspot.nodeId ? 3 : (hotspot.level ?? 3);
|
|
1155
|
+
if (target !== levelRef.current) changeLevel(target);
|
|
1156
|
+
const nodes = shownHere ? shownLookup() : lookupNodes();
|
|
1157
|
+
const node = hotspot.nodeId ? nodes.get(hotspot.nodeId) : undefined;
|
|
1158
|
+
const at = node ? getAbsolutePosition(node, nodes) : hotspot.position;
|
|
1159
|
+
const width = node ? (node.measured?.width ?? node.width ?? 0) : 0;
|
|
1160
|
+
void setCenter(at.x + width / 2 + 120, at.y + 80, { zoom: Math.max(getZoom(), 0.8), duration: 300 });
|
|
1161
|
+
setHotspotDraft(null);
|
|
1162
|
+
setOpenHotspot(node ? `node:${node.id}` : `place:${hotspot.id}`);
|
|
1163
|
+
setListingHotspots(false);
|
|
1164
|
+
});
|
|
1165
|
+
const nameOfNode = useStableCallback((id: string) => {
|
|
1166
|
+
const node = nodesRef.current.find((candidate) => candidate.id === id);
|
|
1167
|
+
return node ? getNodeName(node.type, node.data, id) : undefined;
|
|
1168
|
+
});
|
|
1169
|
+
const toggleHotspotList = useCallback(() => {
|
|
1170
|
+
setListingComments(false);
|
|
1171
|
+
setListingHotspots((shown) => !shown);
|
|
1172
|
+
}, []);
|
|
1173
|
+
const hotspotsCount = flow.hotspots.length;
|
|
1174
|
+
const hotspotsSeverity = topSeverity(flow.hotspots);
|
|
1175
|
+
// What the bottom left controls show (the same objects while that's the same: they're memoised)
|
|
1176
|
+
const hotspotsControl = useMemo(
|
|
1177
|
+
() => ({ count: hotspotsCount, severity: hotspotsSeverity, onToggle: toggleHotspotList, active: listingHotspots }),
|
|
1178
|
+
[hotspotsCount, hotspotsSeverity, toggleHotspotList, listingHotspots]
|
|
1179
|
+
);
|
|
1180
|
+
|
|
1181
|
+
// A node's details open from its Edit button (and by double clicking it): one selected node on the canvas
|
|
1182
|
+
const selectedNodes = visibleNodes.filter((node) => node.selected);
|
|
1183
|
+
const toEdit =
|
|
1184
|
+
selectedNodes.length === 1 &&
|
|
1185
|
+
!isWrittenOnCanvas(selectedNodes[0].type) &&
|
|
1186
|
+
nodesRef.current.some((node) => node.id === selectedNodes[0].id)
|
|
1187
|
+
? selectedNodes[0]
|
|
1188
|
+
: undefined;
|
|
1189
|
+
const editSelected = useStableCallback(() => toEdit && setEditingId(toEdit.id));
|
|
1190
|
+
/**
|
|
1191
|
+
* Changes on L1 and L2. The level's notes are on the canvas, so changes to them go there; removing something
|
|
1192
|
+
* removes it from the canvas (the level is drawn from it); and moving or resizing something arranges the level,
|
|
1193
|
+
* kept when it's dropped.
|
|
1194
|
+
*/
|
|
696
1195
|
const onLevelNodesChange = useStableCallback((changes: NodeChange[]) => {
|
|
697
|
-
const
|
|
698
|
-
const
|
|
1196
|
+
const level = levelRef.current;
|
|
1197
|
+
const current = levelGraphRef.current;
|
|
1198
|
+
if (level === 3 || !current) return;
|
|
1199
|
+
const notes = new Set(nodesRef.current.filter((node) => getNoteLevel(node) === level).map((node) => node.id));
|
|
1200
|
+
const isNote = (change: NodeChange) => 'id' in change && notes.has(change.id);
|
|
1201
|
+
const ofNotes = changes.filter((change) => change.type !== 'add' && isNote(change));
|
|
699
1202
|
if (ofNotes.length) flow.onNodesChange(ofNotes);
|
|
1203
|
+
|
|
1204
|
+
const ofLevel = changes.filter((change) => !isNote(change) && change.type !== 'add');
|
|
1205
|
+
const onCanvas = new Set(nodesRef.current.map((node) => node.id));
|
|
1206
|
+
const removed = ofLevel.flatMap((change) => (change.type === 'remove' && onCanvas.has(change.id) ? [change.id] : []));
|
|
1207
|
+
if (removed.length) flow.deleteNodes(removed);
|
|
1208
|
+
const arranging = ofLevel.filter((change) => change.type !== 'remove');
|
|
1209
|
+
if (!arranging.length) return;
|
|
1210
|
+
const nodes = applyNodeChanges(arranging, current.nodes);
|
|
1211
|
+
updateLevelGraph({ ...current, nodes });
|
|
1212
|
+
const done = new Set(
|
|
1213
|
+
arranging.flatMap((change) =>
|
|
1214
|
+
(change.type === 'position' && change.dragging === false) || (change.type === 'dimensions' && change.resizing === false)
|
|
1215
|
+
? [change.id]
|
|
1216
|
+
: []
|
|
1217
|
+
)
|
|
1218
|
+
);
|
|
1219
|
+
if (done.size) flow.setLevelLayout(level, levelPlaces(nodes.filter((node) => done.has(node.id))));
|
|
1220
|
+
// Something just added, now it's measured: centred where it was dropped, from where it is now (a container it's in
|
|
1221
|
+
// may have grown around it), in one step to undo with adding it
|
|
1222
|
+
const measured = arranging.flatMap((change) => {
|
|
1223
|
+
const dropped = change.type === 'dimensions' && change.dimensions && centring.current.get(change.id);
|
|
1224
|
+
const node = dropped && nodes.find((candidate) => candidate.id === change.id);
|
|
1225
|
+
if (!dropped || !node) return [];
|
|
1226
|
+
centring.current.delete(change.id);
|
|
1227
|
+
const { width, height } = change.dimensions!;
|
|
1228
|
+
const { x, y } = node.position;
|
|
1229
|
+
const place = { x: x + (dropped.size.width - width) / 2, y: y + (dropped.size.height - height) / 2 };
|
|
1230
|
+
return dropped.level === level ? [[change.id, place] as const] : [];
|
|
1231
|
+
});
|
|
1232
|
+
if (measured.length) flow.setLevelLayout(level, new Map(measured));
|
|
1233
|
+
});
|
|
1234
|
+
/** Connecting two things on L1 or L2 connects them on the canvas (e.g. a relationship between two systems) */
|
|
1235
|
+
const onLevelConnect = useStableCallback((connection: Connection) => {
|
|
1236
|
+
const onCanvas = new Set(nodesRef.current.map((node) => node.id));
|
|
1237
|
+
if (onCanvas.has(connection.source) && onCanvas.has(connection.target)) flow.onConnect(connection);
|
|
700
1238
|
});
|
|
701
|
-
// Message edges' envelopes only move
|
|
702
|
-
// envelope
|
|
1239
|
+
// Message edges' envelopes only move under the pointer (the connection, or a node at either end): any moving
|
|
1240
|
+
// envelope needs a main-thread frame on every frame (one selected node's cost ~200–300 ms of every 3 seconds and
|
|
1241
|
+
// dropped frames), which an editing canvas can't afford at idle
|
|
1242
|
+
// (on L1 and L2, the level's edges: the level itself changes on every frame something on it is dragged)
|
|
1243
|
+
const [hovered, setHovered] = useState<{ node?: string; edge?: string }>({});
|
|
1244
|
+
const onNodeMouseEnter = useCallback((_: ReactMouseEvent, node: Node) => setHovered({ node: node.id }), []);
|
|
1245
|
+
const onEdgeMouseEnter = useCallback((_: ReactMouseEvent, edge: Edge) => setHovered({ edge: edge.id }), []);
|
|
1246
|
+
const onHoverEnd = useCallback(() => setHovered({}), []);
|
|
1247
|
+
const levelEdges = levelShown?.edges;
|
|
703
1248
|
const shownEdges = useMemo(() => {
|
|
704
|
-
const edges =
|
|
705
|
-
const focused = new Set(selectedKey ? selectedKey.split(',') : []);
|
|
1249
|
+
const edges = levelEdges ?? flow.edges;
|
|
706
1250
|
return edges.map((edge) =>
|
|
707
|
-
edge.type === 'animated' &&
|
|
1251
|
+
edge.type === 'animated' && edge.id !== hovered.edge && edge.source !== hovered.node && edge.target !== hovered.node
|
|
708
1252
|
? withoutEnvelope(edge)
|
|
709
1253
|
: edge
|
|
710
1254
|
);
|
|
711
|
-
}, [
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
1255
|
+
}, [levelEdges, flow.edges, hovered]);
|
|
1256
|
+
// A canvas opened from a diagram has the levels the diagram has (until what's on it changes)
|
|
1257
|
+
const unavailableLevels = useMemo(() => {
|
|
1258
|
+
const reason = (level: 1 | 2) => {
|
|
1259
|
+
if (getDiagramLevel(flow.diagramLevels, level, flow.structureKey)) return undefined;
|
|
1260
|
+
return (
|
|
1261
|
+
getDiagramLevelUnavailable(flow.diagramLevels, level, flow.structureKey) ??
|
|
1262
|
+
getLevelUnavailableReason(nodesRef.current, level)
|
|
1263
|
+
);
|
|
1264
|
+
};
|
|
1265
|
+
return { 1: reason(1), 2: reason(2) };
|
|
1266
|
+
}, [structure, flow.structureKey, flow.diagramLevels]);
|
|
716
1267
|
|
|
717
1268
|
// ---- People and agents ----
|
|
718
1269
|
|
|
@@ -730,9 +1281,91 @@ function Canvas({
|
|
|
730
1281
|
const [following, setFollowing] = useState(followChanges);
|
|
731
1282
|
const toggleFollowing = useCallback(() => setFollowing((on) => !on), []);
|
|
732
1283
|
|
|
733
|
-
const [shareOpen, setShareOpen] = useState(false);
|
|
734
|
-
const openShare = useCallback(() => setShareOpen(
|
|
1284
|
+
const [shareOpen, setShareOpen] = useState<false | 'choose' | 'invite'>(false);
|
|
1285
|
+
const openShare = useCallback(() => setShareOpen('choose'), []);
|
|
1286
|
+
const openLiveCollaboration = useCallback(() => setShareOpen('invite'), []);
|
|
735
1287
|
const closeShare = useCallback(() => setShareOpen(false), []);
|
|
1288
|
+
const [helpOpen, setHelpOpen] = useState(false);
|
|
1289
|
+
const openHelp = useCallback(() => setHelpOpen(true), []);
|
|
1290
|
+
const closeHelp = useCallback(() => setHelpOpen(false), []);
|
|
1291
|
+
|
|
1292
|
+
// ---- Files and images (as Studio on the web's: the same .eventcatalog files, so diagrams move between them) ----
|
|
1293
|
+
|
|
1294
|
+
// A short message at the top (a file that couldn't be opened or saved)
|
|
1295
|
+
const [notice, setNotice] = useState<string>();
|
|
1296
|
+
useEffect(() => {
|
|
1297
|
+
if (!notice) return;
|
|
1298
|
+
const timer = setTimeout(() => setNotice(undefined), 6000);
|
|
1299
|
+
return () => clearTimeout(timer);
|
|
1300
|
+
}, [notice]);
|
|
1301
|
+
/** The canvas as a diagram (the model a .eventcatalog file holds) */
|
|
1302
|
+
const canvasModel = () =>
|
|
1303
|
+
canvasToModel(
|
|
1304
|
+
nodesRef.current,
|
|
1305
|
+
edgesRef.current,
|
|
1306
|
+
flow.levelLayouts,
|
|
1307
|
+
{ title: flow.title || 'Untitled canvas', resources: [], relations: [] },
|
|
1308
|
+
comments.threads,
|
|
1309
|
+
flow.hotspots
|
|
1310
|
+
);
|
|
1311
|
+
const saveToFile = useStableCallback(() => {
|
|
1312
|
+
const text = toDiagramFile(canvasModel());
|
|
1313
|
+
downloadFile({ name: fileNameFor(flow.title || 'canvas', DIAGRAM_FILE_EXTENSION), mimeType: 'application/json', text });
|
|
1314
|
+
});
|
|
1315
|
+
const exportImage = useStableCallback(async () => {
|
|
1316
|
+
const image = canvasRef.current && (await canvasImage(canvasRef.current));
|
|
1317
|
+
if (!image) return setNotice('Nothing to export yet: put something on the canvas first.');
|
|
1318
|
+
downloadFile({ name: fileNameFor(flow.title || 'canvas', '.png'), mimeType: 'image/png', base64: image.split(',')[1] });
|
|
1319
|
+
});
|
|
1320
|
+
/**
|
|
1321
|
+
* A .eventcatalog file (from Studio on the web, or saved here), opened on this canvas while it's empty, else as a
|
|
1322
|
+
* new canvas (this one stays as it is)
|
|
1323
|
+
*/
|
|
1324
|
+
const openFile = useStableCallback(async () => {
|
|
1325
|
+
if (!canvasesApiUrl) return;
|
|
1326
|
+
const file = await pickDiagramFile();
|
|
1327
|
+
if (!file) return;
|
|
1328
|
+
const opened = await readDiagramFile(file);
|
|
1329
|
+
if ('error' in opened) return setNotice(opened.error);
|
|
1330
|
+
try {
|
|
1331
|
+
const content = await modelToCanvas(opened.model);
|
|
1332
|
+
if (!nodesRef.current.length && !comments.threads.length && !flow.hotspots.length) {
|
|
1333
|
+
flow.addContent(content);
|
|
1334
|
+
if (!flow.title || flow.title === 'Untitled canvas') flow.rename(opened.model.title);
|
|
1335
|
+
setTimeout(() => void fitView({ ...fitOptions, duration: 450 }), 100);
|
|
1336
|
+
return;
|
|
1337
|
+
}
|
|
1338
|
+
const created = await createCanvasThroughApi(canvasesApiUrl, { title: opened.model.title, createdBy: name });
|
|
1339
|
+
if (!keepOpened(created.id, content))
|
|
1340
|
+
return setNotice("Couldn't open it in a new canvas here (this browser's storage is blocked).");
|
|
1341
|
+
window.location.assign(created.url);
|
|
1342
|
+
} catch (error) {
|
|
1343
|
+
setNotice(error instanceof Error ? error.message : "Couldn't open that file.");
|
|
1344
|
+
}
|
|
1345
|
+
});
|
|
1346
|
+
// A file opened into this canvas (just made for it): put on it once it's loaded, if it's still empty
|
|
1347
|
+
useEffect(() => {
|
|
1348
|
+
if (!flow.synced) return;
|
|
1349
|
+
const content = takeOpened(canvasId);
|
|
1350
|
+
if (!content || nodesRef.current.length) return;
|
|
1351
|
+
flow.addContent(content);
|
|
1352
|
+
setTimeout(() => void fitView({ ...fitOptions, duration: 450 }), 100);
|
|
1353
|
+
}, [flow.synced, canvasId]);
|
|
1354
|
+
useHotkeys('mod+o', () => void openFile(), { preventDefault: true, enabled: !!canvasesApiUrl });
|
|
1355
|
+
useHotkeys('mod+s', () => saveToFile(), { preventDefault: true });
|
|
1356
|
+
useHotkeys('mod+shift+e', () => void exportImage(), { preventDefault: true });
|
|
1357
|
+
// ? for help (by the character typed, so it works on every keyboard layout)
|
|
1358
|
+
useEffect(() => {
|
|
1359
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
1360
|
+
if ((event.target as HTMLElement)?.closest?.('input, textarea, [contenteditable]')) return;
|
|
1361
|
+
if (event.key === '?') {
|
|
1362
|
+
event.preventDefault();
|
|
1363
|
+
setHelpOpen(true);
|
|
1364
|
+
}
|
|
1365
|
+
};
|
|
1366
|
+
document.addEventListener('keydown', onKeyDown);
|
|
1367
|
+
return () => document.removeEventListener('keydown', onKeyDown);
|
|
1368
|
+
}, []);
|
|
736
1369
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
|
737
1370
|
const openDelete = useCallback(() => setDeleteOpen(true), []);
|
|
738
1371
|
const closeDelete = useCallback(() => setDeleteOpen(false), []);
|
|
@@ -789,11 +1422,26 @@ function Canvas({
|
|
|
789
1422
|
{ preventDefault: true }
|
|
790
1423
|
);
|
|
791
1424
|
// 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
|
-
|
|
1425
|
+
useHotkeys('mod+z', () => !lockedRef.current && flow.undo(), { preventDefault: true });
|
|
1426
|
+
useHotkeys(['mod+shift+z', 'mod+y'], () => !lockedRef.current && flow.redo(), { preventDefault: true });
|
|
1427
|
+
// Drawing tools' keys: V to select, H for the hand, N, T, C and A to put something down, 1-3 for the levels,
|
|
1428
|
+
// Shift+1 to fit
|
|
1429
|
+
useHotkeys('n', () => pickTool('note'));
|
|
1430
|
+
useHotkeys('t', () => pickTool('text'));
|
|
1431
|
+
useHotkeys('c', () => pickTool('comment'));
|
|
1432
|
+
// A for attention: a hotspot
|
|
1433
|
+
useHotkeys('a', () => pickTool('hotspot'));
|
|
1434
|
+
useHotkeys('v', () => setTool('select'));
|
|
1435
|
+
useHotkeys('h', () => setTool('hand'));
|
|
1436
|
+
useHotkeys(['1', '2', '3'], (event) => {
|
|
1437
|
+
const next = Number(event.code.replace('Digit', '')) as Level;
|
|
1438
|
+
if (next === 3 || !unavailableLevels[next]) changeLevel(next);
|
|
1439
|
+
});
|
|
1440
|
+
useHotkeys('shift+1', () => void fitView({ ...fitOptions, duration: 500 }), { useKey: false });
|
|
795
1441
|
useHotkeys('escape', () => {
|
|
796
|
-
|
|
1442
|
+
setHotspotDraft(null);
|
|
1443
|
+
setOpenHotspot(null);
|
|
1444
|
+
setTool('select');
|
|
797
1445
|
setDraft(null);
|
|
798
1446
|
setOpenThreadId(null);
|
|
799
1447
|
clearSelectedThreads();
|
|
@@ -811,20 +1459,48 @@ function Canvas({
|
|
|
811
1459
|
comments.deleteThreads([...selectedThreads]);
|
|
812
1460
|
clearSelectedThreads();
|
|
813
1461
|
});
|
|
814
|
-
|
|
1462
|
+
/** A tool that puts something down: picked, or put back to selecting if it was already */
|
|
1463
|
+
const pickTool = useStableCallback((next: 'text' | 'note' | 'comment' | 'hotspot') => {
|
|
1464
|
+
// (comments can be left on a locked canvas: reviewing it changes nothing on it)
|
|
1465
|
+
if (lockedRef.current && next !== 'comment') return;
|
|
1466
|
+
// Comments are pinned to the canvas people edit (L3)
|
|
1467
|
+
if (next === 'comment' && levelRef.current !== 3) changeLevel(3);
|
|
1468
|
+
setTool((current) => (current === next ? 'select' : next));
|
|
1469
|
+
});
|
|
1470
|
+
const toggleCommentMode = useCallback(() => pickTool('comment'), [pickTool]);
|
|
1471
|
+
const toggleNoteMode = useCallback(() => pickTool('note'), [pickTool]);
|
|
1472
|
+
const toggleTextMode = useCallback(() => pickTool('text'), [pickTool]);
|
|
1473
|
+
const toggleHotspotMode = useCallback(() => pickTool('hotspot'), [pickTool]);
|
|
1474
|
+
const selectTool = useCallback(() => setTool('select'), []);
|
|
1475
|
+
const handTool = useCallback(() => setTool('hand'), []);
|
|
1476
|
+
const toggleLock = useStableCallback(() => {
|
|
1477
|
+
setTool('select');
|
|
1478
|
+
flow.setLocked(lockedRef.current ? null : name);
|
|
1479
|
+
});
|
|
815
1480
|
|
|
816
1481
|
const startComment = (anchor: CommentAnchor) => {
|
|
817
1482
|
setOpenThreadId(null);
|
|
818
1483
|
setDraft(anchor);
|
|
819
|
-
|
|
1484
|
+
setTool('select');
|
|
820
1485
|
};
|
|
821
1486
|
|
|
822
1487
|
const flowPositionOf = (event: { clientX: number; clientY: number }) =>
|
|
823
1488
|
screenToFlowPosition({ x: event.clientX, y: event.clientY });
|
|
824
1489
|
|
|
1490
|
+
// A place on L1 or L2 is on that level (they're laid out on their own): what's put there says so
|
|
1491
|
+
const placeLevel = () => (levelRef.current === 3 ? {} : { level: levelRef.current as 1 | 2 });
|
|
825
1492
|
const onPaneClick = useStableCallback((event: ReactMouseEvent) => {
|
|
826
1493
|
clearSelectedThreads();
|
|
1494
|
+
if (tool === 'hand') return;
|
|
827
1495
|
if (commentMode && editable) return startComment(anchorAt(flowPositionOf(event)));
|
|
1496
|
+
// Flagging: a hotspot where it's clicked
|
|
1497
|
+
if (tool === 'hotspot') return startHotspot({ position: flowPositionOf(event), ...placeLevel() });
|
|
1498
|
+
// The text and note tools: put where it's clicked, then back to selecting
|
|
1499
|
+
if (placing) {
|
|
1500
|
+
setTool('select');
|
|
1501
|
+
if (tool === 'text') return writeTextAt(flowPositionOf(event));
|
|
1502
|
+
return addNoteAt(flowPositionOf(event));
|
|
1503
|
+
}
|
|
828
1504
|
// A click inside a container (not on something in it) selects it: its body lets the pointer through so you can
|
|
829
1505
|
// drag a selection box in it, so the click lands on the canvas
|
|
830
1506
|
// (after React Flow clears the selection, which it does once this returns)
|
|
@@ -836,24 +1512,31 @@ function Canvas({
|
|
|
836
1512
|
|
|
837
1513
|
// Double clicking a node edits its details (notes are edited on the canvas, by double clicking them too)
|
|
838
1514
|
const onNodeDoubleClick = useStableCallback((_: ReactMouseEvent, node: Node) => {
|
|
839
|
-
|
|
1515
|
+
// On L1 and L2 too: what's there is on the canvas (a system card is the system)
|
|
1516
|
+
if (!isWrittenOnCanvas(node.type) && nodesRef.current.some((other) => other.id === node.id)) setEditingId(node.id);
|
|
840
1517
|
});
|
|
841
1518
|
// ...and inside a container (not on something in it): its body lets the pointer through, so it's on the canvas
|
|
1519
|
+
// ...and anywhere else on the canvas writes there, as on Excalidraw's (and Studio on the web's)
|
|
842
1520
|
const onCanvasDoubleClick = useStableCallback((event: ReactMouseEvent) => {
|
|
843
|
-
if (!
|
|
844
|
-
const container = findGroupAtPoint(flowPositionOf(event), nodesRef.current);
|
|
845
|
-
if (container) setEditingId(container.id);
|
|
1521
|
+
if (!(event.target as Element).classList?.contains('react-flow__pane')) return;
|
|
1522
|
+
const container = editable ? findGroupAtPoint(flowPositionOf(event), nodesRef.current) : undefined;
|
|
1523
|
+
if (container) return setEditingId(container.id);
|
|
1524
|
+
if (!lockedRef.current) writeTextAt(flowPositionOf(event));
|
|
846
1525
|
});
|
|
847
1526
|
|
|
848
1527
|
const onNodeClick = useStableCallback((event: ReactMouseEvent, node: Node) => {
|
|
849
1528
|
// Clicking one node selects just it (with shift or ⌘, it's added to what's selected)
|
|
850
1529
|
if (!event.shiftKey && !event.metaKey) clearSelectedThreads();
|
|
1530
|
+
// Not on notes and text: they're written on, not what's being flagged or commented on
|
|
1531
|
+
if (isWrittenOnCanvas(node.type)) return;
|
|
1532
|
+
// A hotspot on it (it shows on the node, wherever it goes, on every level it's on)
|
|
1533
|
+
if (tool === 'hotspot') return startHotspot({ nodeId: node.id, position: flowPositionOf(event) });
|
|
851
1534
|
if (commentMode && editable) startComment(anchorAt(flowPositionOf(event), node, lookupNodes()));
|
|
852
1535
|
});
|
|
853
1536
|
|
|
854
1537
|
const openContextMenu = useStableCallback((event: ReactMouseEvent | MouseEvent, node?: Node) => {
|
|
855
1538
|
event.preventDefault();
|
|
856
|
-
if (!editable) return;
|
|
1539
|
+
if (!editable || lockedRef.current) return;
|
|
857
1540
|
setContextMenu({ x: event.clientX, y: event.clientY, flowPosition: flowPositionOf(event), node });
|
|
858
1541
|
});
|
|
859
1542
|
const onPaneContextMenu = useCallback((event: ReactMouseEvent | MouseEvent) => openContextMenu(event), [openContextMenu]);
|
|
@@ -874,6 +1557,26 @@ function Canvas({
|
|
|
874
1557
|
if (contextMenu?.node) setEditingId(contextMenu.node.id);
|
|
875
1558
|
setContextMenu(null);
|
|
876
1559
|
});
|
|
1560
|
+
/** A sticky note where the menu was opened, or to the right of the node right clicked (about it) */
|
|
1561
|
+
const noteFromMenu = useStableCallback(() => {
|
|
1562
|
+
if (!contextMenu) return;
|
|
1563
|
+
const node = contextMenu.node && lookupNodes().get(contextMenu.node.id);
|
|
1564
|
+
if (node) {
|
|
1565
|
+
const at = getAbsolutePosition(node, lookupNodes());
|
|
1566
|
+
const width = node.measured?.width ?? node.width ?? 240;
|
|
1567
|
+
addNoteAt({ x: at.x + width + 140, y: at.y + 88 });
|
|
1568
|
+
} else addNoteAt(contextMenu.flowPosition);
|
|
1569
|
+
setContextMenu(null);
|
|
1570
|
+
});
|
|
1571
|
+
const hotspotFromMenu = useStableCallback(() => {
|
|
1572
|
+
if (!contextMenu) return;
|
|
1573
|
+
startHotspot({ ...(contextMenu.node ? { nodeId: contextMenu.node.id } : placeLevel()), position: contextMenu.flowPosition });
|
|
1574
|
+
setContextMenu(null);
|
|
1575
|
+
});
|
|
1576
|
+
const reorderFromMenu = useStableCallback(() => {
|
|
1577
|
+
setContextMenu(null);
|
|
1578
|
+
onReorder();
|
|
1579
|
+
});
|
|
877
1580
|
const copyFromMenu = useStableCallback(() => {
|
|
878
1581
|
// Through the copy event, so it's copied like ⌘C (to the system clipboard too)
|
|
879
1582
|
copyOnly.current = menuTargets();
|
|
@@ -896,6 +1599,7 @@ function Canvas({
|
|
|
896
1599
|
});
|
|
897
1600
|
|
|
898
1601
|
const openThread = useStableCallback((thread: Thread) => {
|
|
1602
|
+
setListingComments(false);
|
|
899
1603
|
const { x, y } = getAnchorPosition(thread, lookupNodes());
|
|
900
1604
|
// Leave room for the thread, which opens to the right of its pin
|
|
901
1605
|
void setCenter(x + 150, y, { zoom: getZoom(), duration: 300 });
|
|
@@ -903,6 +1607,18 @@ function Canvas({
|
|
|
903
1607
|
setOpenThreadId(thread.id);
|
|
904
1608
|
});
|
|
905
1609
|
const toggleResolved = useCallback(() => setShowResolved((show) => !show), []);
|
|
1610
|
+
const [panelTab, setPanelTab] = useState<LeftPanelTab>('components');
|
|
1611
|
+
// All the comments, listed over the bottom left controls (as Studio on the web's): clicking one goes to it
|
|
1612
|
+
const [listingComments, setListingComments] = useState(false);
|
|
1613
|
+
const toggleCommentList = useCallback(() => {
|
|
1614
|
+
setListingHotspots(false);
|
|
1615
|
+
setListingComments((shown) => !shown);
|
|
1616
|
+
}, []);
|
|
1617
|
+
const openComments = comments.threads.filter((thread) => !thread.resolved).length;
|
|
1618
|
+
const commentsControl = useMemo(
|
|
1619
|
+
() => ({ open: openComments, onToggle: toggleCommentList, active: listingComments }),
|
|
1620
|
+
[openComments, toggleCommentList, listingComments]
|
|
1621
|
+
);
|
|
906
1622
|
const openThreadById = useCallback((id: string | null) => {
|
|
907
1623
|
setDraft(null);
|
|
908
1624
|
setOpenThreadId(id);
|
|
@@ -934,16 +1650,37 @@ function Canvas({
|
|
|
934
1650
|
})
|
|
935
1651
|
);
|
|
936
1652
|
}, [selectedKey]);
|
|
937
|
-
// The node whose details are being edited in
|
|
938
|
-
// menu (selecting a node doesn't open it). Notes are edited on the canvas.
|
|
1653
|
+
// The node whose details are being edited in its card: opened with the edit button over it, by double clicking it,
|
|
1654
|
+
// or "Edit details" in its menu (selecting a node doesn't open it). Notes are edited on the canvas.
|
|
939
1655
|
const [editingId, setEditingId] = useState<string | null>(null);
|
|
940
|
-
const editing =
|
|
941
|
-
|
|
942
|
-
// What the panel shows (not the node's position, so dragging it doesn't re-render the panel)
|
|
1656
|
+
const editing = editingId ? flow.nodes.find((node) => node.id === editingId && !isWrittenOnCanvas(node.type)) : undefined;
|
|
1657
|
+
// What the card shows (not the node's position, so dragging it doesn't re-render the card)
|
|
943
1658
|
const inspected = useMemo(
|
|
944
1659
|
() => (editing ? { id: editing.id, type: editing.type, data: editing.data } : null),
|
|
945
1660
|
[editing?.id, editing?.type, editing?.data]
|
|
946
1661
|
);
|
|
1662
|
+
// The domain or system it's in
|
|
1663
|
+
const parent = editing?.parentId ? flow.nodes.find((node) => node.id === editing.parentId) : undefined;
|
|
1664
|
+
const inspectedParent = parent ? String(getResource(parent.type, parent.data).name ?? '') || undefined : undefined;
|
|
1665
|
+
// What it's connected to, for its card
|
|
1666
|
+
const inspectedConnections = useMemo((): NodeConnection[] => {
|
|
1667
|
+
if (!editingId) return [];
|
|
1668
|
+
const byId = new Map(flow.nodes.map((node) => [node.id, node]));
|
|
1669
|
+
return flow.edges.flatMap((edge) => {
|
|
1670
|
+
const outgoing = edge.source === editingId;
|
|
1671
|
+
if (!outgoing && edge.target !== editingId) return [];
|
|
1672
|
+
const other = byId.get(outgoing ? edge.target : edge.source);
|
|
1673
|
+
if (!other || isWrittenOnCanvas(other.type)) return [];
|
|
1674
|
+
const name = String(getResource(other.type, other.data).name ?? '') || getNodeLabel(other.type);
|
|
1675
|
+
return [{ id: other.id, name, type: other.type, label: typeof edge.label === 'string' ? edge.label : undefined, outgoing }];
|
|
1676
|
+
});
|
|
1677
|
+
}, [editingId, flow.nodes, flow.edges]);
|
|
1678
|
+
// Goes to a node it's connected to, and shows its card
|
|
1679
|
+
const openConnected = useStableCallback((id: string) => {
|
|
1680
|
+
setEditingId(id);
|
|
1681
|
+
select(new Set([id]));
|
|
1682
|
+
void fitView({ nodes: [{ id }], duration: 400, padding: 0.3, maxZoom: 1.2 });
|
|
1683
|
+
});
|
|
947
1684
|
const closePanel = useCallback(() => setEditingId(null), []);
|
|
948
1685
|
// An accepted or rejected canvas, and when it was decided
|
|
949
1686
|
const { status: canvasStatus, history: statusHistory } = flow.canvasStatus;
|
|
@@ -954,262 +1691,400 @@ function Canvas({
|
|
|
954
1691
|
return (
|
|
955
1692
|
<UpdateNodeDataContext.Provider value={flow.updateNodeData}>
|
|
956
1693
|
<DropTargetContext.Provider value={dropTargetId}>
|
|
957
|
-
<
|
|
958
|
-
<
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
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))]"
|
|
1694
|
+
<EditingContext.Provider value={flow.sendEditing}>
|
|
1695
|
+
<StartWritingContext.Provider value={startWriting}>
|
|
1696
|
+
<LockedContext.Provider value={locked}>
|
|
1697
|
+
<div className="flex h-full min-h-0 text-[rgb(var(--ec-page-text))]">
|
|
1698
|
+
<div
|
|
1699
|
+
ref={canvasRef}
|
|
1700
|
+
className={`eventcatalog-visualizer studio-canvas relative flex-1 min-w-0 ${commentMode ? 'comment-mode' : ''} ${
|
|
1701
|
+
transitioning ? 'is-layout-transition' : ''
|
|
1702
|
+
} ${placing || tool === 'comment' || tool === 'hotspot' ? 'is-placing' : ''} ${tool === 'hand' ? 'is-panning' : ''}`}
|
|
1703
|
+
onPointerMove={trackPointer}
|
|
1704
|
+
onPointerLeave={clearPointer}
|
|
1705
|
+
onDoubleClick={onCanvasDoubleClick}
|
|
1706
|
+
>
|
|
1707
|
+
<ReactFlow
|
|
1708
|
+
nodes={visibleNodes}
|
|
1709
|
+
edges={shownEdges}
|
|
1710
|
+
onNodeMouseEnter={onNodeMouseEnter}
|
|
1711
|
+
onNodeMouseLeave={onHoverEnd}
|
|
1712
|
+
onEdgeMouseEnter={onEdgeMouseEnter}
|
|
1713
|
+
onEdgeMouseLeave={onHoverEnd}
|
|
1714
|
+
nodeTypes={nodeTypes}
|
|
1715
|
+
edgeTypes={edgeTypes}
|
|
1716
|
+
nodesDraggable={!locked && tool !== 'hand'}
|
|
1717
|
+
nodesConnectable={!locked && tool !== 'hand'}
|
|
1718
|
+
elementsSelectable={tool !== 'hand'}
|
|
1719
|
+
onNodesChange={editable ? flow.onNodesChange : onLevelNodesChange}
|
|
1720
|
+
onEdgesChange={editable ? flow.onEdgesChange : undefined}
|
|
1721
|
+
onConnect={editable ? flow.onConnect : onLevelConnect}
|
|
1722
|
+
// Drag either end of a connection to another node (dropped anywhere else, it stays where it was)
|
|
1723
|
+
edgesReconnectable={editable && !locked}
|
|
1724
|
+
onReconnect={editable ? flow.onReconnect : undefined}
|
|
1725
|
+
reconnectRadius={RECONNECT_RADIUS}
|
|
1726
|
+
onDragOver={locked ? undefined : onDragOver}
|
|
1727
|
+
onMoveStart={onMoveStart}
|
|
1728
|
+
onMoveEnd={onMoveEnd}
|
|
1729
|
+
onNodeDragStart={onNodeDragStart}
|
|
1730
|
+
onNodeDrag={onNodeDrag}
|
|
1731
|
+
onNodeDragStop={onNodeDragStop}
|
|
1732
|
+
onDrop={locked ? undefined : onDrop}
|
|
1733
|
+
onPaneClick={onPaneClick}
|
|
1734
|
+
onNodeClick={onNodeClick}
|
|
1735
|
+
onNodeDoubleClick={onNodeDoubleClick}
|
|
1736
|
+
onError={onFlowError}
|
|
1737
|
+
onSelectionStart={clearSelectedThreads}
|
|
1738
|
+
onPaneContextMenu={onPaneContextMenu}
|
|
1739
|
+
onNodeContextMenu={onNodeContextMenu}
|
|
1740
|
+
connectionLineType={ConnectionLineType.SmoothStep}
|
|
1741
|
+
connectionRadius={40}
|
|
1742
|
+
deleteKeyCode={locked ? null : DELETE_KEYS}
|
|
1743
|
+
multiSelectionKeyCode={MULTI_SELECTION_KEYS}
|
|
1744
|
+
selectionOnDrag={tool !== 'hand'}
|
|
1745
|
+
// The hand drags the canvas around; otherwise dragging selects (space, the middle button or scrolling pan)
|
|
1746
|
+
panOnDrag={tool === 'hand' ? true : PAN_BUTTONS}
|
|
1747
|
+
panOnScroll
|
|
1748
|
+
zoomActivationKeyCode={ZOOM_KEYS}
|
|
1749
|
+
zoomOnDoubleClick={false}
|
|
1750
|
+
minZoom={0.1}
|
|
1751
|
+
maxZoom={2}
|
|
1752
|
+
>
|
|
1753
|
+
<DiagramBackground />
|
|
1754
|
+
{/* Above the selected node's top right corner: its details, to edit them */}
|
|
1755
|
+
{toEdit && <EditButton nodeId={toEdit.id} locked={locked} onClick={editSelected} />}
|
|
1756
|
+
<LegendPanel legend={legend} hiddenKeys={hiddenLegendKeys} onLegendClick={toggleLegendKey} />
|
|
1757
|
+
<ViewportSharer onChange={flow.setViewport} />
|
|
1758
|
+
{/* Pointers, selections and comments are on the canvas people edit (L3) */}
|
|
1759
|
+
{editable && <RemotePresence store={flow.presence} clientId={flow.clientId} />}
|
|
1760
|
+
{editable && (
|
|
1761
|
+
<CommentLayer
|
|
1762
|
+
threads={comments.threads}
|
|
1763
|
+
author={author}
|
|
1764
|
+
openThreadId={openThreadId}
|
|
1765
|
+
draft={draft}
|
|
1766
|
+
showResolved={showResolved}
|
|
1767
|
+
onOpen={openThreadById}
|
|
1768
|
+
onCreate={createThread}
|
|
1769
|
+
onCancelDraft={cancelDraft}
|
|
1770
|
+
onReply={comments.reply}
|
|
1771
|
+
onResolve={comments.setResolved}
|
|
1772
|
+
onMove={comments.moveThread}
|
|
1773
|
+
onMoveDraft={setDraft}
|
|
1774
|
+
onDelete={deleteThread}
|
|
1775
|
+
selectedIds={selectedThreads}
|
|
1776
|
+
selectionOffset={threadsOffset}
|
|
1777
|
+
/>
|
|
1778
|
+
)}
|
|
1779
|
+
{/* Hotspots on every level (on L1 and L2, on what they're in) */}
|
|
1780
|
+
<HotspotLayer
|
|
1781
|
+
spots={hotspotSpots}
|
|
1782
|
+
openKey={openHotspot}
|
|
1783
|
+
draft={hotspotDraft}
|
|
1784
|
+
locked={locked}
|
|
1785
|
+
movable={!locked}
|
|
1786
|
+
selectedIds={null}
|
|
1787
|
+
onMove={moveHotspots}
|
|
1788
|
+
onDropTarget={setHotspotDropTarget}
|
|
1789
|
+
onOpen={setOpenHotspot}
|
|
1790
|
+
onCreate={createHotspot}
|
|
1791
|
+
onCancelDraft={cancelHotspot}
|
|
1792
|
+
onUpdate={flow.updateHotspot}
|
|
1793
|
+
onDelete={deleteHotspot}
|
|
1794
|
+
onGo={goToHotspot}
|
|
1795
|
+
/>
|
|
1796
|
+
</ReactFlow>
|
|
1797
|
+
|
|
1798
|
+
<AgentActivity
|
|
1799
|
+
store={flow.presence}
|
|
1800
|
+
clientId={flow.clientId}
|
|
1801
|
+
enabled={editable}
|
|
1802
|
+
following={following}
|
|
1803
|
+
onToggleFollowing={toggleFollowing}
|
|
1804
|
+
pauseRef={pauseCamera}
|
|
1805
|
+
/>
|
|
1806
|
+
|
|
1807
|
+
{!editable && (
|
|
1808
|
+
<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))]">
|
|
1809
|
+
<span>
|
|
1810
|
+
<span className="font-semibold">L{level}</span> ·{' '}
|
|
1811
|
+
{LEVELS.find((entry) => entry.level === level)?.description}
|
|
1812
|
+
</span>
|
|
1813
|
+
<button onClick={editInL3} className="font-semibold text-[rgb(var(--ec-accent))] hover:underline">
|
|
1814
|
+
Everything is on L3
|
|
1815
|
+
</button>
|
|
1816
|
+
</div>
|
|
1817
|
+
)}
|
|
1818
|
+
|
|
1819
|
+
{/* Accepted or rejected: still editable, but changing it makes it a draft again (for everyone) */}
|
|
1820
|
+
{editable && decided && (
|
|
1821
|
+
<div
|
|
1822
|
+
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}`}
|
|
1136
1823
|
>
|
|
1137
|
-
|
|
1138
|
-
|
|
1824
|
+
<span className="truncate">
|
|
1825
|
+
<span className="font-semibold">{CANVAS_STATUS_LOOK[decided.status].label}</span>
|
|
1826
|
+
{decided.change && ` by ${decided.change.by.name} · ${timeAgo(decided.change.at)}`}
|
|
1827
|
+
{decided.change?.note && ` · “${decided.change.note}”`} · changing it moves it back to Draft
|
|
1828
|
+
</span>
|
|
1829
|
+
</div>
|
|
1830
|
+
)}
|
|
1831
|
+
|
|
1832
|
+
{/* At the top, as drawing tools have their tools (and Studio on the web has them) */}
|
|
1833
|
+
<CanvasControls
|
|
1834
|
+
level={level}
|
|
1835
|
+
onLevelChange={changeLevel}
|
|
1836
|
+
unavailableLevels={unavailableLevels}
|
|
1837
|
+
editable={editable}
|
|
1838
|
+
locked={locked}
|
|
1839
|
+
onToggleLock={toggleLock}
|
|
1840
|
+
lockedBy={flow.lockedBy}
|
|
1841
|
+
selectMode={tool === 'select'}
|
|
1842
|
+
onSelectMode={selectTool}
|
|
1843
|
+
handMode={tool === 'hand'}
|
|
1844
|
+
onHandMode={handTool}
|
|
1845
|
+
noteMode={tool === 'note'}
|
|
1846
|
+
onToggleNoteMode={toggleNoteMode}
|
|
1847
|
+
textMode={tool === 'text'}
|
|
1848
|
+
onToggleTextMode={toggleTextMode}
|
|
1849
|
+
commentMode={commentMode}
|
|
1850
|
+
onToggleCommentMode={toggleCommentMode}
|
|
1851
|
+
hotspotMode={tool === 'hotspot'}
|
|
1852
|
+
onToggleHotspotMode={toggleHotspotMode}
|
|
1853
|
+
/>
|
|
1854
|
+
|
|
1855
|
+
{/* The palette: components, the catalog and the comments, under the canvas's title */}
|
|
1856
|
+
{!locked && (
|
|
1857
|
+
<LeftPanel
|
|
1858
|
+
level={level}
|
|
1859
|
+
onShowAll={editInL3}
|
|
1860
|
+
resources={resources}
|
|
1861
|
+
keysOnCanvas={keysOnCanvas}
|
|
1862
|
+
onAddComponent={addComponent}
|
|
1863
|
+
onAddResource={addResourceFromPanel}
|
|
1864
|
+
tab={panelTab}
|
|
1865
|
+
onTabChange={setPanelTab}
|
|
1866
|
+
startCollapsed={paletteCollapsed}
|
|
1867
|
+
/>
|
|
1868
|
+
)}
|
|
1869
|
+
|
|
1870
|
+
{/* All the hotspots, most important first, over the bottom left controls: clicking one goes to it */}
|
|
1871
|
+
{listingComments && (
|
|
1872
|
+
<div className="absolute bottom-[64px] left-3 z-20 flex max-h-[min(480px,calc(100%-140px))] w-80 flex-col overflow-hidden rounded-xl border border-[rgb(var(--ec-page-border))] bg-[rgb(var(--ec-card-bg))] shadow-xl">
|
|
1873
|
+
<div className="flex flex-none items-center justify-between border-b border-[rgb(var(--ec-page-border))] px-3 py-2 text-sm font-semibold">
|
|
1874
|
+
Comments
|
|
1875
|
+
<button
|
|
1876
|
+
type="button"
|
|
1877
|
+
aria-label="Close"
|
|
1878
|
+
onClick={toggleCommentList}
|
|
1879
|
+
className="rounded p-1 text-[rgb(var(--ec-page-text-muted))] hover:bg-[rgb(var(--ec-content-hover))]"
|
|
1880
|
+
>
|
|
1881
|
+
<X className="h-4 w-4" />
|
|
1882
|
+
</button>
|
|
1883
|
+
</div>
|
|
1884
|
+
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto pt-2">
|
|
1885
|
+
<CommentList
|
|
1886
|
+
threads={comments.threads}
|
|
1887
|
+
showResolved={showResolved}
|
|
1888
|
+
onToggleResolved={toggleResolved}
|
|
1889
|
+
onOpen={openThread}
|
|
1890
|
+
/>
|
|
1891
|
+
</div>
|
|
1892
|
+
</div>
|
|
1893
|
+
)}
|
|
1894
|
+
{listingHotspots && (
|
|
1895
|
+
<div className="absolute bottom-[64px] left-3 z-20 flex max-h-[min(480px,calc(100%-140px))] w-80 flex-col overflow-hidden rounded-xl border border-[rgb(var(--ec-page-border))] bg-[rgb(var(--ec-card-bg))] shadow-xl">
|
|
1896
|
+
<div className="flex flex-none items-center justify-between border-b border-[rgb(var(--ec-page-border))] px-3 py-2 text-sm font-semibold">
|
|
1897
|
+
Hotspots
|
|
1898
|
+
<button
|
|
1899
|
+
type="button"
|
|
1900
|
+
aria-label="Close"
|
|
1901
|
+
onClick={toggleHotspotList}
|
|
1902
|
+
className="rounded p-1 text-[rgb(var(--ec-page-text-muted))] hover:bg-[rgb(var(--ec-content-hover))]"
|
|
1903
|
+
>
|
|
1904
|
+
<X className="h-4 w-4" />
|
|
1905
|
+
</button>
|
|
1906
|
+
</div>
|
|
1907
|
+
<div className="min-h-0 flex-1 overflow-y-auto">
|
|
1908
|
+
<HotspotList hotspots={flow.hotspots} nameOf={nameOfNode} onOpen={goToHotspot} />
|
|
1909
|
+
</div>
|
|
1910
|
+
</div>
|
|
1911
|
+
)}
|
|
1912
|
+
|
|
1913
|
+
{/* Bottom left: zoom, undo and redo (as Excalidraw's), fitting and reordering, the comments and hotspots */}
|
|
1914
|
+
<div className="absolute bottom-3 left-3 z-10 flex items-center gap-2">
|
|
1915
|
+
<ViewControls
|
|
1916
|
+
canUndo={flow.canUndo && !locked}
|
|
1917
|
+
canRedo={flow.canRedo && !locked}
|
|
1918
|
+
onUndo={flow.undo}
|
|
1919
|
+
onRedo={flow.redo}
|
|
1920
|
+
onReorder={onReorder}
|
|
1921
|
+
reorderLabel={
|
|
1922
|
+
editable ? "Reorder diagram (the visualiser's layout)" : `Reorder L${level} (the visualiser's layout)`
|
|
1923
|
+
}
|
|
1924
|
+
locked={locked}
|
|
1925
|
+
fitOptions={fitOptions}
|
|
1926
|
+
comments={commentsControl}
|
|
1927
|
+
hotspots={hotspotsControl}
|
|
1928
|
+
/>
|
|
1929
|
+
</div>
|
|
1930
|
+
|
|
1931
|
+
<ContextMenu
|
|
1932
|
+
menu={contextMenu}
|
|
1933
|
+
onAddComment={commentFromMenu}
|
|
1934
|
+
onAddHotspot={hotspotFromMenu}
|
|
1935
|
+
onAddNote={noteFromMenu}
|
|
1936
|
+
onEditDetails={editFromMenu}
|
|
1937
|
+
onCopy={copyFromMenu}
|
|
1938
|
+
onDuplicate={duplicateFromMenu}
|
|
1939
|
+
onPaste={lastCopied ? pasteFromMenu : undefined}
|
|
1940
|
+
onReorder={reorderFromMenu}
|
|
1941
|
+
onDeleteNode={deleteFromMenu}
|
|
1942
|
+
onClose={closeContextMenu}
|
|
1943
|
+
/>
|
|
1944
|
+
|
|
1945
|
+
<CanvasHeader
|
|
1946
|
+
title={flow.title}
|
|
1947
|
+
onRetitle={flow.rename}
|
|
1948
|
+
status={flow.status}
|
|
1949
|
+
transport={flow.transport}
|
|
1950
|
+
presence={flow.presence}
|
|
1951
|
+
clientId={flow.clientId}
|
|
1952
|
+
onJumpTo={jumpTo}
|
|
1953
|
+
me={author}
|
|
1954
|
+
onRename={onRename}
|
|
1955
|
+
onRecolor={onRecolor}
|
|
1956
|
+
onConnectAgent={openConnect}
|
|
1957
|
+
onShare={openShare}
|
|
1958
|
+
onOpenFile={canvasesApiUrl ? openFile : undefined}
|
|
1959
|
+
onSaveToFile={saveToFile}
|
|
1960
|
+
onExportImage={exportImage}
|
|
1961
|
+
onLiveCollaboration={openLiveCollaboration}
|
|
1962
|
+
onHelp={openHelp}
|
|
1963
|
+
webMcp={webMcp}
|
|
1964
|
+
shareUrl={shareUrl}
|
|
1965
|
+
newCanvasUrl={newCanvasUrl}
|
|
1966
|
+
canvasStatus={flow.canvasStatus.status}
|
|
1967
|
+
statusHistory={flow.canvasStatus.history}
|
|
1968
|
+
onStatusChange={flow.changeStatus}
|
|
1969
|
+
onDelete={canvasesApiUrl ? openDelete : undefined}
|
|
1970
|
+
/>
|
|
1971
|
+
|
|
1972
|
+
{flow.deleted && !leaving && (
|
|
1973
|
+
<div className="absolute inset-0 z-40 flex items-center justify-center bg-[rgb(var(--ec-page-bg)/0.85)] p-4">
|
|
1974
|
+
<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))]">
|
|
1975
|
+
<h2 className="text-base font-semibold">This canvas was deleted</h2>
|
|
1976
|
+
<p className="text-sm text-[rgb(var(--ec-page-text-muted))]">
|
|
1977
|
+
Someone deleted it, so it can't be opened or changed any more.
|
|
1978
|
+
</p>
|
|
1979
|
+
{studioUrl && (
|
|
1980
|
+
<a
|
|
1981
|
+
href={studioUrl}
|
|
1982
|
+
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))]"
|
|
1983
|
+
>
|
|
1984
|
+
Back to Studio
|
|
1985
|
+
</a>
|
|
1986
|
+
)}
|
|
1987
|
+
</div>
|
|
1988
|
+
</div>
|
|
1989
|
+
)}
|
|
1990
|
+
|
|
1991
|
+
{/* Only on an empty canvas (not over comments people have left on it): what it's for, and what to do */}
|
|
1992
|
+
{flow.status === 'connected' &&
|
|
1993
|
+
flow.nodes.length === 0 &&
|
|
1994
|
+
comments.threads.length === 0 &&
|
|
1995
|
+
flow.hotspots.length === 0 &&
|
|
1996
|
+
!draft && (
|
|
1997
|
+
<EmptyCanvas
|
|
1998
|
+
// Just the hints to the palette, levels and Share (the catalog is around it, not a blank page)
|
|
1999
|
+
welcome={false}
|
|
2000
|
+
onOpen={canvasesApiUrl ? openFile : undefined}
|
|
2001
|
+
onHelp={openHelp}
|
|
2002
|
+
onConnect={openConnect}
|
|
2003
|
+
palette={!locked}
|
|
2004
|
+
live
|
|
2005
|
+
/>
|
|
2006
|
+
)}
|
|
2007
|
+
|
|
2008
|
+
{/* The node being edited: its card, next to it (as Studio on the web's) */}
|
|
2009
|
+
{inspected && (
|
|
2010
|
+
<NodeDetails
|
|
2011
|
+
key={inspected.id}
|
|
2012
|
+
node={inspected}
|
|
2013
|
+
parentName={inspectedParent}
|
|
2014
|
+
connections={inspectedConnections}
|
|
2015
|
+
onOpenNode={openConnected}
|
|
2016
|
+
containerRef={canvasRef}
|
|
2017
|
+
locked={locked}
|
|
2018
|
+
onChange={flow.updateNodeData}
|
|
2019
|
+
onClose={closePanel}
|
|
2020
|
+
/>
|
|
1139
2021
|
)}
|
|
1140
2022
|
</div>
|
|
2023
|
+
|
|
2024
|
+
{chooser && (
|
|
2025
|
+
<ContainerChooser
|
|
2026
|
+
kind={chooser.resource.collection === 'domains' ? 'domain' : 'system'}
|
|
2027
|
+
name={chooser.resource.name}
|
|
2028
|
+
contents={chooserContents}
|
|
2029
|
+
position={flowToScreenPosition(chooser.at.point)}
|
|
2030
|
+
onChoose={choose}
|
|
2031
|
+
onCancel={cancelChooser}
|
|
2032
|
+
/>
|
|
2033
|
+
)}
|
|
2034
|
+
{connectionsChooser && (
|
|
2035
|
+
<ConnectionsChooser
|
|
2036
|
+
resource={connectionsChooser.resource}
|
|
2037
|
+
groups={connectionsChooser.groups}
|
|
2038
|
+
position={flowToScreenPosition(connectionsChooser.at.point)}
|
|
2039
|
+
onChoose={chooseConnections}
|
|
2040
|
+
onCancel={cancelConnectionsChooser}
|
|
2041
|
+
/>
|
|
2042
|
+
)}
|
|
2043
|
+
{shareOpen && (
|
|
2044
|
+
<ShareDialog
|
|
2045
|
+
initialView={shareOpen}
|
|
2046
|
+
keptInMemory={keptInMemory}
|
|
2047
|
+
url={agentLink.canvasUrl}
|
|
2048
|
+
title={flow.title}
|
|
2049
|
+
presence={flow.presence}
|
|
2050
|
+
clientId={flow.clientId}
|
|
2051
|
+
onRename={onRename}
|
|
2052
|
+
onJumpTo={jumpTo}
|
|
2053
|
+
onConnectAgent={openConnect}
|
|
2054
|
+
onClose={closeShare}
|
|
2055
|
+
/>
|
|
2056
|
+
)}
|
|
2057
|
+
{helpOpen && <ShortcutsDialog onClose={closeHelp} />}
|
|
2058
|
+
{notice && (
|
|
2059
|
+
<div
|
|
2060
|
+
role="status"
|
|
2061
|
+
className="absolute left-1/2 top-[72px] z-30 max-w-md -translate-x-1/2 rounded-lg border border-[rgb(var(--ec-page-border))] bg-[rgb(var(--ec-card-bg))] px-4 py-2 text-sm shadow-lg"
|
|
2062
|
+
>
|
|
2063
|
+
{notice}
|
|
2064
|
+
</div>
|
|
2065
|
+
)}
|
|
2066
|
+
{deleteOpen && (
|
|
2067
|
+
<DeleteCanvasDialog
|
|
2068
|
+
title={flow.title}
|
|
2069
|
+
presence={flow.presence}
|
|
2070
|
+
clientId={flow.clientId}
|
|
2071
|
+
onDelete={deleteCanvas}
|
|
2072
|
+
onClose={closeDelete}
|
|
2073
|
+
/>
|
|
2074
|
+
)}
|
|
2075
|
+
{connectOpen && (
|
|
2076
|
+
<ConnectAgentDialog
|
|
2077
|
+
link={agentLink}
|
|
2078
|
+
webMcp={webMcp}
|
|
2079
|
+
relayScriptPath={relayScriptPath}
|
|
2080
|
+
onEnableWebMcp={turnOnWebMcp}
|
|
2081
|
+
onClose={closeConnect}
|
|
2082
|
+
/>
|
|
2083
|
+
)}
|
|
1141
2084
|
</div>
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
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>
|
|
2085
|
+
</LockedContext.Provider>
|
|
2086
|
+
</StartWritingContext.Provider>
|
|
2087
|
+
</EditingContext.Provider>
|
|
1213
2088
|
</DropTargetContext.Provider>
|
|
1214
2089
|
</UpdateNodeDataContext.Provider>
|
|
1215
2090
|
);
|