@eventcatalog/core 5.0.0-beta.12 → 5.0.0-beta.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +13 -12
- package/dist/analytics/analytics.cjs +1 -1
- package/dist/analytics/analytics.js +2 -2
- package/dist/analytics/log-build.cjs +1 -1
- package/dist/analytics/log-build.js +3 -3
- package/dist/catalog-runtime.cjs +1 -1
- package/dist/{chunk-TSY5CC27.js → chunk-4KE3EUDJ.js} +1 -1
- package/dist/{chunk-FY6MSP6J.js → chunk-I3XDOICA.js} +1 -1
- package/dist/{chunk-QB7X34OD.js → chunk-OFK7U7BX.js} +1 -1
- package/dist/{chunk-CX2VB7AI.js → chunk-PI5YNZZZ.js} +1 -1
- package/dist/{chunk-BWC4YKW3.js → chunk-WY6M3RPT.js} +1 -1
- package/dist/constants.cjs +1 -1
- package/dist/constants.js +1 -1
- package/dist/eventcatalog.cjs +11 -6
- package/dist/eventcatalog.js +14 -9
- 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/astro.config.mjs +13 -1
- package/eventcatalog/integrations/skip-startup-reload.mjs +31 -0
- package/eventcatalog/integrations/stable-dependency-order.mjs +29 -0
- package/eventcatalog/src/components/CatalogGraph/CatalogForceGraph.tsx +39 -0
- package/eventcatalog/src/components/MDX/NodeGraph/AstroNodeGraph.tsx +11 -3
- package/eventcatalog/src/components/Search/SearchDataLoader.astro +16 -28
- package/eventcatalog/src/components/SideNav/NestedSideBar/index.tsx +37 -76
- package/eventcatalog/src/components/SideNav/NestedSideBar/storage.ts +2 -0
- package/eventcatalog/src/components/SideNav/NestedSideBar/utils.ts +122 -1
- package/eventcatalog/src/components/SideNav/SideNav.astro +9 -1
- 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 +133 -71
- package/eventcatalog/src/features/mcp/apps/generated/viewer.html +66 -66
- package/eventcatalog/src/features/studio/agent-choreography.ts +5 -4
- package/eventcatalog/src/features/studio/agents.ts +28 -0
- package/eventcatalog/src/features/studio/api/canvases-api.ts +1 -1
- package/eventcatalog/src/features/studio/canvas-actions.ts +22 -14
- package/eventcatalog/src/features/studio/canvas-preview.ts +1 -2
- package/eventcatalog/src/features/studio/catalog.ts +1 -4
- package/eventcatalog/src/features/studio/components/CanvasHeader.tsx +246 -115
- package/eventcatalog/src/features/studio/components/CanvasStatusMenu.tsx +7 -3
- 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 +1 -1
- package/eventcatalog/src/features/studio/components/JoinForm.tsx +101 -26
- package/eventcatalog/src/features/studio/components/LeftPanel.tsx +102 -241
- package/eventcatalog/src/features/studio/components/NodeDetails.tsx +183 -0
- package/eventcatalog/src/features/studio/components/OpenInStudioDialog.tsx +1 -1
- package/eventcatalog/src/features/studio/components/Presence.tsx +6 -8
- 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 +1005 -354
- package/eventcatalog/src/features/studio/document-name.ts +7 -0
- package/eventcatalog/src/features/studio/from-visualiser.ts +1 -1
- 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-studio-flow.ts +162 -23
- package/eventcatalog/src/features/studio/identity.ts +23 -1
- package/eventcatalog/src/features/studio/pages/[id].astro +2 -1
- package/eventcatalog/src/features/studio/pages/index.astro +1 -2
- package/eventcatalog/src/features/studio/server/canvas-mcp.ts +7 -22
- package/eventcatalog/src/features/studio/server/runtime.ts +4 -5
- package/package.json +3 -3
- package/eventcatalog/src/features/studio/canvas-doc.ts +0 -569
- package/eventcatalog/src/features/studio/clipboard.ts +0 -97
- package/eventcatalog/src/features/studio/components/CanvasControls.tsx +0 -119
- package/eventcatalog/src/features/studio/components/Comments.tsx +0 -777
- 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 -271
- 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 -121
- package/eventcatalog/src/features/studio/grouping.ts +0 -230
- package/eventcatalog/src/features/studio/hooks/use-counter-scale.ts +0 -20
- package/eventcatalog/src/features/studio/layout.ts +0 -56
- package/eventcatalog/src/features/studio/level-layout.ts +0 -145
- package/eventcatalog/src/features/studio/levels.ts +0 -337
- package/eventcatalog/src/features/studio/node-types.ts +0 -245
- package/eventcatalog/src/features/studio/placement.ts +0 -197
- package/eventcatalog/src/features/studio/studio.css +0 -273
|
@@ -8,27 +8,28 @@ import {
|
|
|
8
8
|
type Connection,
|
|
9
9
|
type Edge,
|
|
10
10
|
type Node,
|
|
11
|
+
type FitViewOptions,
|
|
11
12
|
type NodeChange,
|
|
12
13
|
type XYPosition,
|
|
13
14
|
} from '@xyflow/react';
|
|
14
15
|
import { DiagramBackground, edgeTypes, getLegend, LegendPanel, pauseEdgeAnimations } from '@eventcatalog/visualiser';
|
|
15
16
|
import { useHotkeys } from 'react-hotkeys-hook';
|
|
16
17
|
import { getCatalogConnections, indexCatalog, planWithConnections, type ConnectionGroup } from '../canvas-actions';
|
|
17
|
-
import { getDiagramLevel, getDiagramLevelUnavailable, isDecided, type LevelPlace } from '../canvas-doc';
|
|
18
|
-
import { CLIPBOARD_TYPE, copyNodes, pasteNodes, readClipboard, type ClipboardContent } from '../clipboard';
|
|
19
|
-
import { keepUnchanged, LOCAL_EDGE_KEYS, LOCAL_NODE_KEYS } from '../flow-state';
|
|
20
|
-
import type { CatalogRelation, CatalogResource } from '../catalog-resources';
|
|
21
18
|
import {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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,
|
|
32
33
|
containerFor,
|
|
33
34
|
findGroupAtPoint,
|
|
34
35
|
getAbsolutePosition,
|
|
@@ -37,15 +38,16 @@ import {
|
|
|
37
38
|
sizeOf,
|
|
38
39
|
sortByHierarchy,
|
|
39
40
|
toRelativePosition,
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
41
|
+
getLayoutPositions,
|
|
42
|
+
layoutGraph,
|
|
43
|
+
getLevelGraph,
|
|
44
|
+
getLevelUnavailableReason,
|
|
45
|
+
LEVELS,
|
|
46
|
+
type Level,
|
|
47
|
+
arrangeLevel,
|
|
48
|
+
getLevelNodeSize,
|
|
49
|
+
levelPlaces,
|
|
50
|
+
withDiagramRoutes,
|
|
49
51
|
canGoInContainer,
|
|
50
52
|
getNodeDefinition,
|
|
51
53
|
getNodeLabel,
|
|
@@ -54,24 +56,76 @@ import {
|
|
|
54
56
|
isOnLevel,
|
|
55
57
|
isShownOnLevel,
|
|
56
58
|
isWrittenOnCanvas,
|
|
57
|
-
|
|
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';
|
|
102
|
+
import type { CatalogRelation, CatalogResource } from '../catalog-resources';
|
|
103
|
+
import {
|
|
104
|
+
buildContainer,
|
|
105
|
+
buildContainerWithContents,
|
|
106
|
+
canBeContainer,
|
|
107
|
+
catalogNodeData,
|
|
108
|
+
getCatalogLink,
|
|
109
|
+
getContents,
|
|
110
|
+
getRelatedEdges,
|
|
111
|
+
} from '../catalog';
|
|
112
|
+
import type { Peer } from '../hooks/presence-store';
|
|
113
|
+
import { useCanvasWebMcp } from '../hooks/use-canvas-webmcp';
|
|
114
|
+
import { useStudioFlow } from '../hooks/use-studio-flow';
|
|
115
|
+
import { useComments, type CommentAnchor, type Thread } from '../hooks/use-comments';
|
|
58
116
|
import type { ToolSync } from '../tool-sync';
|
|
59
|
-
import CanvasControls, { FIT_VIEW_OPTIONS } from './CanvasControls';
|
|
60
117
|
import CanvasHeader from './CanvasHeader';
|
|
61
|
-
import { DropTargetContext, nodeTypes, UpdateNodeDataContext } from './canvas-nodes';
|
|
62
|
-
import { anchorAt, CanvasContextMenu, CommentLayer, getAnchorPosition, timeAgo, type ContextMenuState } from './Comments';
|
|
63
|
-
import { CANVAS_STATUS_LOOK } from './status';
|
|
64
118
|
import ConnectAgentDialog, { enableWebMcp, isRelayEnabled, isWebMcpEnabled, loadRelay } from './ConnectAgentDialog';
|
|
65
|
-
import ContainerChooser, { type ContainerChoice } from './ContainerChooser';
|
|
66
119
|
import ConnectionsChooser, { type ConnectionsChoice } from './ConnectionsChooser';
|
|
67
120
|
import JoinForm from './JoinForm';
|
|
68
|
-
import { colorForName, getStoredName, onStoredNameChange, storeName } from '../identity';
|
|
69
|
-
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';
|
|
70
123
|
import { AgentActivity, RemotePresence, ViewportSharer } from './Presence';
|
|
71
|
-
import
|
|
124
|
+
import NodeDetails, { type NodeConnection } from './NodeDetails';
|
|
72
125
|
import ShareDialog from './ShareDialog';
|
|
73
126
|
import DeleteCanvasDialog from './DeleteCanvasDialog';
|
|
74
|
-
import { deleteCanvasThroughApi } from '../api/client';
|
|
127
|
+
import { createCanvasThroughApi, deleteCanvasThroughApi } from '../api/client';
|
|
128
|
+
import ShortcutsDialog from './ShortcutsDialog';
|
|
75
129
|
|
|
76
130
|
type CanvasProps = {
|
|
77
131
|
canvasId: string;
|
|
@@ -96,10 +150,12 @@ type CanvasProps = {
|
|
|
96
150
|
syncViaTools?: ToolSync;
|
|
97
151
|
/** Follow agents' changes with the camera from the start (e.g. inside a chat, where you watch an agent work) */
|
|
98
152
|
followChanges?: boolean;
|
|
153
|
+
/** Starts with the palette folded away (e.g. in a chat, where there's little room) */
|
|
154
|
+
paletteCollapsed?: boolean;
|
|
99
155
|
/** Told what's selected (e.g. a chat attaches it to the conversation, so you can ask about it) */
|
|
100
156
|
onSelectionChange?: (selected: SelectedNode[]) => void;
|
|
101
|
-
/** Join
|
|
102
|
-
|
|
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 };
|
|
103
159
|
/** Signed in with the catalog's sign-in (SSO): you're shown by this name and picture, and can't change them */
|
|
104
160
|
signedInAs?: { name: string; picture?: string };
|
|
105
161
|
resources: CatalogResource[];
|
|
@@ -178,8 +234,58 @@ const asLevelNote = (node: Node) => {
|
|
|
178
234
|
|
|
179
235
|
const structureOf = (nodes: Node[]) => nodes.map((node) => `${node.id}:${node.type}:${node.parentId ?? ''}`).join('|');
|
|
180
236
|
|
|
181
|
-
|
|
182
|
-
|
|
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);
|
|
183
289
|
|
|
184
290
|
const saveName = useCallback((next: string) => {
|
|
185
291
|
setName(next);
|
|
@@ -187,19 +293,41 @@ export default function StudioDesigner({ defaultName, signedInAs, ...props }: Ca
|
|
|
187
293
|
}, []);
|
|
188
294
|
// Renamed on the Studio page or another canvas in this browser
|
|
189
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
|
+
}, []);
|
|
190
302
|
|
|
303
|
+
if (asAgent) {
|
|
304
|
+
return (
|
|
305
|
+
<ReactFlowProvider>
|
|
306
|
+
<Canvas {...props} name={asAgent.name} chosenColor={asAgent.color} agent onRecolor={() => {}} />
|
|
307
|
+
</ReactFlowProvider>
|
|
308
|
+
);
|
|
309
|
+
}
|
|
191
310
|
if (signedInAs) {
|
|
192
311
|
return (
|
|
193
312
|
<ReactFlowProvider>
|
|
194
|
-
<Canvas {...props} name={signedInAs.name} picture={signedInAs.picture} />
|
|
313
|
+
<Canvas {...props} name={signedInAs.name} picture={signedInAs.picture} chosenColor={chosenColor} onRecolor={recolor} />
|
|
195
314
|
</ReactFlowProvider>
|
|
196
315
|
);
|
|
197
316
|
}
|
|
198
|
-
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
|
+
);
|
|
199
327
|
|
|
200
328
|
return (
|
|
201
329
|
<ReactFlowProvider>
|
|
202
|
-
<Canvas {...props} name={name} onRename={saveName} />
|
|
330
|
+
<Canvas {...props} name={name} onRename={saveName} chosenColor={chosenColor} onRecolor={recolor} />
|
|
203
331
|
</ReactFlowProvider>
|
|
204
332
|
);
|
|
205
333
|
}
|
|
@@ -220,19 +348,28 @@ function Canvas({
|
|
|
220
348
|
keptInMemory = false,
|
|
221
349
|
syncViaTools,
|
|
222
350
|
followChanges = false,
|
|
351
|
+
paletteCollapsed,
|
|
223
352
|
onSelectionChange,
|
|
224
353
|
resources,
|
|
225
354
|
relations,
|
|
226
355
|
name,
|
|
227
356
|
picture,
|
|
357
|
+
agent,
|
|
228
358
|
onRename,
|
|
229
|
-
|
|
359
|
+
chosenColor,
|
|
360
|
+
onRecolor,
|
|
361
|
+
}: Omit<CanvasProps, 'asAgent' | 'signedInAs'> & {
|
|
230
362
|
name: string;
|
|
231
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;
|
|
232
366
|
/** Not given when you're signed in (your name is the one you signed in with) */
|
|
233
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;
|
|
234
371
|
}) {
|
|
235
|
-
const color = colorForName(name);
|
|
372
|
+
const color = chosenColor ?? colorForName(name);
|
|
236
373
|
const canvasRef = useRef<HTMLDivElement>(null);
|
|
237
374
|
|
|
238
375
|
// Nodes glide to their new place after a layout (anyone's) or a level change (not while editing: it would make dragging lag)
|
|
@@ -259,12 +396,25 @@ function Canvas({
|
|
|
259
396
|
name,
|
|
260
397
|
color,
|
|
261
398
|
picture,
|
|
399
|
+
agent,
|
|
262
400
|
syncViaTools,
|
|
263
401
|
onLayout: glide,
|
|
264
402
|
onContainerResize: animateContainers,
|
|
265
403
|
});
|
|
266
|
-
const author = useMemo(
|
|
404
|
+
const author = useMemo(
|
|
405
|
+
() => ({ name, color, ...(picture && { picture }), ...(agent && { agent }) }),
|
|
406
|
+
[name, color, picture, agent]
|
|
407
|
+
);
|
|
267
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
|
+
);
|
|
268
418
|
const { screenToFlowPosition, flowToScreenPosition, setCenter, getZoom, fitView } = useReactFlow();
|
|
269
419
|
|
|
270
420
|
const catalog = useMemo(() => indexCatalog(resources, relations), [resources, relations]);
|
|
@@ -387,7 +537,7 @@ function Canvas({
|
|
|
387
537
|
const choose = useStableCallback((choice: ContainerChoice) => {
|
|
388
538
|
if (!chooser) return;
|
|
389
539
|
if (choice.as === 'card') addCard(chooser.resource, chooser.at);
|
|
390
|
-
else void addContainer(chooser.resource, chooser.at, choice.withContents);
|
|
540
|
+
else void addContainer(chooser.resource, chooser.at, !!choice.withContents);
|
|
391
541
|
setChooser(null);
|
|
392
542
|
});
|
|
393
543
|
const cancelChooser = useCallback(() => setChooser(null), []);
|
|
@@ -418,10 +568,20 @@ function Canvas({
|
|
|
418
568
|
};
|
|
419
569
|
}, [chooser, catalog, keysOnCanvas]);
|
|
420
570
|
|
|
421
|
-
/** 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 */
|
|
422
572
|
const addNoteAt = (center: XYPosition) => {
|
|
423
573
|
const data = getNodeDefinition('note')!.createData();
|
|
424
|
-
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);
|
|
425
585
|
};
|
|
426
586
|
/**
|
|
427
587
|
* Where something added at a point goes (see Landing). On L1 and L2 it's centred where it's dropped, in the
|
|
@@ -464,14 +624,14 @@ function Canvas({
|
|
|
464
624
|
centring.current.set(id, { level, size });
|
|
465
625
|
flow.setLevelLayout(level, new Map([[id, centredOn(center, size)]]));
|
|
466
626
|
};
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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));
|
|
471
632
|
};
|
|
472
633
|
|
|
473
634
|
const addComponent = useStableCallback((type: string) => addComponentAt(type, canvasCenter()));
|
|
474
|
-
const addNote = useStableCallback(() => addNoteAt(canvasCenter()));
|
|
475
635
|
const addResourceFromPanel = useStableCallback((key: string) => addResource(key, canvasCenter()));
|
|
476
636
|
|
|
477
637
|
const onDrop = useStableCallback((event: DragEvent) => {
|
|
@@ -592,10 +752,6 @@ function Canvas({
|
|
|
592
752
|
|
|
593
753
|
// Dragging something into a container puts it in it, out of one takes it out
|
|
594
754
|
const [dropTargetId, setDropTargetId] = useState<string | null>(null);
|
|
595
|
-
const dragRoots = (dragged: Node[]) => {
|
|
596
|
-
const ids = new Set(dragged.map((node) => node.id));
|
|
597
|
-
return dragged.filter((node) => !node.parentId || !ids.has(node.parentId));
|
|
598
|
-
};
|
|
599
755
|
// While something's dragged, the containers around it grow to fit it (see previewContainerGrowth): the canvas
|
|
600
756
|
// when the drag started, and those containers, innermost first
|
|
601
757
|
const dragStart = useRef<{ nodes: Map<string, Node>; containers: string[] } | null>(null);
|
|
@@ -642,15 +798,17 @@ function Canvas({
|
|
|
642
798
|
dragged.forEach((node) => now.set(node.id, { ...now.get(node.id)!, position: node.position }));
|
|
643
799
|
flow.showDragPreview(previewContainerGrowth(started.containers, new Set(dragged.map((node) => node.id)), started.nodes, now));
|
|
644
800
|
};
|
|
645
|
-
const onNodeDrag = useStableCallback((_: unknown,
|
|
801
|
+
const onNodeDrag = useStableCallback((_: unknown, held: Node, dragged: Node[]) => {
|
|
646
802
|
growContainers(dragged);
|
|
647
803
|
moveThreadsWith(dragged);
|
|
648
|
-
const
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
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);
|
|
652
810
|
});
|
|
653
|
-
const onNodeDragStop = useStableCallback((_: unknown,
|
|
811
|
+
const onNodeDragStop = useStableCallback((_: unknown, held: Node, dragged: Node[]) => {
|
|
654
812
|
setInteraction('dragging', false);
|
|
655
813
|
setDropTargetId(null);
|
|
656
814
|
dragStart.current = null;
|
|
@@ -668,12 +826,17 @@ function Canvas({
|
|
|
668
826
|
}
|
|
669
827
|
threadsDrag.current = null;
|
|
670
828
|
setThreadsOffset(null);
|
|
671
|
-
// The containers it grew are saved with the drop; dropped in another container (or out of one), it moves there
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
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) })));
|
|
677
840
|
});
|
|
678
841
|
|
|
679
842
|
// ---- Levels of detail ----
|
|
@@ -750,6 +913,8 @@ function Canvas({
|
|
|
750
913
|
if (cancelled) return;
|
|
751
914
|
setLevelGraph({ level, ...laidOut });
|
|
752
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;
|
|
753
918
|
});
|
|
754
919
|
};
|
|
755
920
|
// Straight away when switching levels (or from the diagram); after changes settle while one is shown
|
|
@@ -764,8 +929,10 @@ function Canvas({
|
|
|
764
929
|
};
|
|
765
930
|
}, [level, levelKey, flow.diagramLevels, levelLayout]);
|
|
766
931
|
|
|
767
|
-
//
|
|
768
|
-
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';
|
|
769
936
|
const [draft, setDraft] = useState<CommentAnchor | null>(null);
|
|
770
937
|
const [openThreadId, setOpenThreadId] = useState<string | null>(null);
|
|
771
938
|
const [showResolved, setShowResolved] = useState(false);
|
|
@@ -777,10 +944,31 @@ function Canvas({
|
|
|
777
944
|
const threadsShown = () => comments.threads.filter((thread) => showResolved || !thread.resolved);
|
|
778
945
|
const [contextMenu, setContextMenu] = useState<ContextMenuState>(null);
|
|
779
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
|
+
|
|
780
968
|
const changeLevel = useStableCallback((next: Level) => {
|
|
781
969
|
if (next === level) return;
|
|
782
970
|
glide();
|
|
783
|
-
|
|
971
|
+
setTool('select');
|
|
784
972
|
setDraft(null);
|
|
785
973
|
setOpenThreadId(null);
|
|
786
974
|
// What's selected may not be on the next level (e.g. a note added on this one)
|
|
@@ -795,7 +983,7 @@ function Canvas({
|
|
|
795
983
|
const levelHasNodes = !!levelGraph?.nodes.length;
|
|
796
984
|
useEffect(() => {
|
|
797
985
|
if (shownLevel === undefined || (shownLevel !== 3 && !levelHasNodes)) return;
|
|
798
|
-
const frame = requestAnimationFrame(() => void fitView({ ...
|
|
986
|
+
const frame = requestAnimationFrame(() => void fitView({ ...fitOptions, duration: 450 }));
|
|
799
987
|
return () => cancelAnimationFrame(frame);
|
|
800
988
|
}, [shownLevel, levelHasNodes]);
|
|
801
989
|
|
|
@@ -808,18 +996,22 @@ function Canvas({
|
|
|
808
996
|
if (level !== 3) {
|
|
809
997
|
const content =
|
|
810
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
|
+
);
|
|
811
1003
|
const laidOut = await layoutGraph(
|
|
812
|
-
content.nodes.map((node) => ({ ...node, selected: false })),
|
|
1004
|
+
content.nodes.map((node) => ({ ...node, selected: false, ...(drawn.get(node.id) && { measured: drawn.get(node.id) }) })),
|
|
813
1005
|
content.edges
|
|
814
1006
|
);
|
|
815
1007
|
glide();
|
|
816
1008
|
flow.setLevelLayout(level, levelPlaces(laidOut.nodes));
|
|
817
|
-
setTimeout(() => void fitView({ ...
|
|
1009
|
+
setTimeout(() => void fitView({ ...fitOptions, duration: 450 }), 50);
|
|
818
1010
|
return;
|
|
819
1011
|
}
|
|
820
1012
|
const positions = await getLayoutPositions(nodesRef.current, edgesRef.current);
|
|
821
1013
|
flow.applyPositions(positions);
|
|
822
|
-
setTimeout(() => void fitView({ ...
|
|
1014
|
+
setTimeout(() => void fitView({ ...fitOptions, duration: 450 }), 50);
|
|
823
1015
|
});
|
|
824
1016
|
const onReorder = useCallback(() => void reorder(), [reorder]);
|
|
825
1017
|
|
|
@@ -855,13 +1047,146 @@ function Canvas({
|
|
|
855
1047
|
// What's shown only changes with what's on it (not as nodes are dragged, on any level)
|
|
856
1048
|
const shownStructure = levelShown ? structureOf(levelShown.nodes) : structure;
|
|
857
1049
|
const legend = useMemo(() => getLegend(shownNodes.filter((node) => !isWrittenOnCanvas(node.type))), [shownStructure]);
|
|
858
|
-
const
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
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]
|
|
864
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));
|
|
865
1190
|
/**
|
|
866
1191
|
* Changes on L1 and L2. The level's notes are on the canvas, so changes to them go there; removing something
|
|
867
1192
|
* removes it from the canvas (the level is drawn from it); and moving or resizing something arranges the level,
|
|
@@ -911,19 +1236,23 @@ function Canvas({
|
|
|
911
1236
|
const onCanvas = new Set(nodesRef.current.map((node) => node.id));
|
|
912
1237
|
if (onCanvas.has(connection.source) && onCanvas.has(connection.target)) flow.onConnect(connection);
|
|
913
1238
|
});
|
|
914
|
-
// Message edges' envelopes only move
|
|
915
|
-
// 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
|
|
916
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({}), []);
|
|
917
1247
|
const levelEdges = levelShown?.edges;
|
|
918
1248
|
const shownEdges = useMemo(() => {
|
|
919
1249
|
const edges = levelEdges ?? flow.edges;
|
|
920
|
-
const focused = new Set(selectedKey ? selectedKey.split(',') : []);
|
|
921
1250
|
return edges.map((edge) =>
|
|
922
|
-
edge.type === 'animated' &&
|
|
1251
|
+
edge.type === 'animated' && edge.id !== hovered.edge && edge.source !== hovered.node && edge.target !== hovered.node
|
|
923
1252
|
? withoutEnvelope(edge)
|
|
924
1253
|
: edge
|
|
925
1254
|
);
|
|
926
|
-
}, [levelEdges, flow.edges,
|
|
1255
|
+
}, [levelEdges, flow.edges, hovered]);
|
|
927
1256
|
// A canvas opened from a diagram has the levels the diagram has (until what's on it changes)
|
|
928
1257
|
const unavailableLevels = useMemo(() => {
|
|
929
1258
|
const reason = (level: 1 | 2) => {
|
|
@@ -952,9 +1281,91 @@ function Canvas({
|
|
|
952
1281
|
const [following, setFollowing] = useState(followChanges);
|
|
953
1282
|
const toggleFollowing = useCallback(() => setFollowing((on) => !on), []);
|
|
954
1283
|
|
|
955
|
-
const [shareOpen, setShareOpen] = useState(false);
|
|
956
|
-
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'), []);
|
|
957
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
|
+
}, []);
|
|
958
1369
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
|
959
1370
|
const openDelete = useCallback(() => setDeleteOpen(true), []);
|
|
960
1371
|
const closeDelete = useCallback(() => setDeleteOpen(false), []);
|
|
@@ -1011,11 +1422,26 @@ function Canvas({
|
|
|
1011
1422
|
{ preventDefault: true }
|
|
1012
1423
|
);
|
|
1013
1424
|
// On every level: notes are changed on L1 and L2 too (and the levels show what's undone on the canvas)
|
|
1014
|
-
useHotkeys('mod+z', () => flow.undo(), { preventDefault: true });
|
|
1015
|
-
useHotkeys(['mod+shift+z', 'mod+y'], () => flow.redo(), { preventDefault: true });
|
|
1016
|
-
|
|
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 });
|
|
1017
1441
|
useHotkeys('escape', () => {
|
|
1018
|
-
|
|
1442
|
+
setHotspotDraft(null);
|
|
1443
|
+
setOpenHotspot(null);
|
|
1444
|
+
setTool('select');
|
|
1019
1445
|
setDraft(null);
|
|
1020
1446
|
setOpenThreadId(null);
|
|
1021
1447
|
clearSelectedThreads();
|
|
@@ -1033,20 +1459,48 @@ function Canvas({
|
|
|
1033
1459
|
comments.deleteThreads([...selectedThreads]);
|
|
1034
1460
|
clearSelectedThreads();
|
|
1035
1461
|
});
|
|
1036
|
-
|
|
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
|
+
});
|
|
1037
1480
|
|
|
1038
1481
|
const startComment = (anchor: CommentAnchor) => {
|
|
1039
1482
|
setOpenThreadId(null);
|
|
1040
1483
|
setDraft(anchor);
|
|
1041
|
-
|
|
1484
|
+
setTool('select');
|
|
1042
1485
|
};
|
|
1043
1486
|
|
|
1044
1487
|
const flowPositionOf = (event: { clientX: number; clientY: number }) =>
|
|
1045
1488
|
screenToFlowPosition({ x: event.clientX, y: event.clientY });
|
|
1046
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 });
|
|
1047
1492
|
const onPaneClick = useStableCallback((event: ReactMouseEvent) => {
|
|
1048
1493
|
clearSelectedThreads();
|
|
1494
|
+
if (tool === 'hand') return;
|
|
1049
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
|
+
}
|
|
1050
1504
|
// A click inside a container (not on something in it) selects it: its body lets the pointer through so you can
|
|
1051
1505
|
// drag a selection box in it, so the click lands on the canvas
|
|
1052
1506
|
// (after React Flow clears the selection, which it does once this returns)
|
|
@@ -1062,21 +1516,27 @@ function Canvas({
|
|
|
1062
1516
|
if (!isWrittenOnCanvas(node.type) && nodesRef.current.some((other) => other.id === node.id)) setEditingId(node.id);
|
|
1063
1517
|
});
|
|
1064
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)
|
|
1065
1520
|
const onCanvasDoubleClick = useStableCallback((event: ReactMouseEvent) => {
|
|
1066
|
-
if (!
|
|
1067
|
-
const container = findGroupAtPoint(flowPositionOf(event), nodesRef.current);
|
|
1068
|
-
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));
|
|
1069
1525
|
});
|
|
1070
1526
|
|
|
1071
1527
|
const onNodeClick = useStableCallback((event: ReactMouseEvent, node: Node) => {
|
|
1072
1528
|
// Clicking one node selects just it (with shift or ⌘, it's added to what's selected)
|
|
1073
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) });
|
|
1074
1534
|
if (commentMode && editable) startComment(anchorAt(flowPositionOf(event), node, lookupNodes()));
|
|
1075
1535
|
});
|
|
1076
1536
|
|
|
1077
1537
|
const openContextMenu = useStableCallback((event: ReactMouseEvent | MouseEvent, node?: Node) => {
|
|
1078
1538
|
event.preventDefault();
|
|
1079
|
-
if (!editable) return;
|
|
1539
|
+
if (!editable || lockedRef.current) return;
|
|
1080
1540
|
setContextMenu({ x: event.clientX, y: event.clientY, flowPosition: flowPositionOf(event), node });
|
|
1081
1541
|
});
|
|
1082
1542
|
const onPaneContextMenu = useCallback((event: ReactMouseEvent | MouseEvent) => openContextMenu(event), [openContextMenu]);
|
|
@@ -1097,6 +1557,26 @@ function Canvas({
|
|
|
1097
1557
|
if (contextMenu?.node) setEditingId(contextMenu.node.id);
|
|
1098
1558
|
setContextMenu(null);
|
|
1099
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
|
+
});
|
|
1100
1580
|
const copyFromMenu = useStableCallback(() => {
|
|
1101
1581
|
// Through the copy event, so it's copied like ⌘C (to the system clipboard too)
|
|
1102
1582
|
copyOnly.current = menuTargets();
|
|
@@ -1119,6 +1599,7 @@ function Canvas({
|
|
|
1119
1599
|
});
|
|
1120
1600
|
|
|
1121
1601
|
const openThread = useStableCallback((thread: Thread) => {
|
|
1602
|
+
setListingComments(false);
|
|
1122
1603
|
const { x, y } = getAnchorPosition(thread, lookupNodes());
|
|
1123
1604
|
// Leave room for the thread, which opens to the right of its pin
|
|
1124
1605
|
void setCenter(x + 150, y, { zoom: getZoom(), duration: 300 });
|
|
@@ -1126,6 +1607,18 @@ function Canvas({
|
|
|
1126
1607
|
setOpenThreadId(thread.id);
|
|
1127
1608
|
});
|
|
1128
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
|
+
);
|
|
1129
1622
|
const openThreadById = useCallback((id: string | null) => {
|
|
1130
1623
|
setDraft(null);
|
|
1131
1624
|
setOpenThreadId(id);
|
|
@@ -1157,15 +1650,37 @@ function Canvas({
|
|
|
1157
1650
|
})
|
|
1158
1651
|
);
|
|
1159
1652
|
}, [selectedKey]);
|
|
1160
|
-
// The node whose details are being edited in
|
|
1161
|
-
// 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.
|
|
1162
1655
|
const [editingId, setEditingId] = useState<string | null>(null);
|
|
1163
1656
|
const editing = editingId ? flow.nodes.find((node) => node.id === editingId && !isWrittenOnCanvas(node.type)) : undefined;
|
|
1164
|
-
// What the
|
|
1657
|
+
// What the card shows (not the node's position, so dragging it doesn't re-render the card)
|
|
1165
1658
|
const inspected = useMemo(
|
|
1166
1659
|
() => (editing ? { id: editing.id, type: editing.type, data: editing.data } : null),
|
|
1167
1660
|
[editing?.id, editing?.type, editing?.data]
|
|
1168
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
|
+
});
|
|
1169
1684
|
const closePanel = useCallback(() => setEditingId(null), []);
|
|
1170
1685
|
// An accepted or rejected canvas, and when it was decided
|
|
1171
1686
|
const { status: canvasStatus, history: statusHistory } = flow.canvasStatus;
|
|
@@ -1176,264 +1691,400 @@ function Canvas({
|
|
|
1176
1691
|
return (
|
|
1177
1692
|
<UpdateNodeDataContext.Provider value={flow.updateNodeData}>
|
|
1178
1693
|
<DropTargetContext.Provider value={dropTargetId}>
|
|
1179
|
-
<
|
|
1180
|
-
<
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
onReorder={onReorder}
|
|
1309
|
-
canUndo={flow.canUndo}
|
|
1310
|
-
canRedo={flow.canRedo}
|
|
1311
|
-
onUndo={flow.undo}
|
|
1312
|
-
onRedo={flow.redo}
|
|
1313
|
-
commentMode={commentMode}
|
|
1314
|
-
onToggleCommentMode={toggleCommentMode}
|
|
1315
|
-
onAddNote={addNote}
|
|
1316
|
-
/>
|
|
1317
|
-
|
|
1318
|
-
<CanvasContextMenu
|
|
1319
|
-
menu={contextMenu}
|
|
1320
|
-
onAddComment={commentFromMenu}
|
|
1321
|
-
onEditDetails={editFromMenu}
|
|
1322
|
-
onCopy={copyFromMenu}
|
|
1323
|
-
onDuplicate={duplicateFromMenu}
|
|
1324
|
-
onPaste={lastCopied ? pasteFromMenu : undefined}
|
|
1325
|
-
onDeleteNode={deleteFromMenu}
|
|
1326
|
-
onClose={closeContextMenu}
|
|
1327
|
-
/>
|
|
1328
|
-
|
|
1329
|
-
<CanvasHeader
|
|
1330
|
-
title={flow.title}
|
|
1331
|
-
onRetitle={flow.rename}
|
|
1332
|
-
status={flow.status}
|
|
1333
|
-
transport={flow.transport}
|
|
1334
|
-
presence={flow.presence}
|
|
1335
|
-
clientId={flow.clientId}
|
|
1336
|
-
onJumpTo={jumpTo}
|
|
1337
|
-
onRename={onRename}
|
|
1338
|
-
onConnectAgent={openConnect}
|
|
1339
|
-
onShare={openShare}
|
|
1340
|
-
webMcp={webMcp}
|
|
1341
|
-
shareUrl={shareUrl}
|
|
1342
|
-
newCanvasUrl={newCanvasUrl}
|
|
1343
|
-
canvasStatus={flow.canvasStatus.status}
|
|
1344
|
-
statusHistory={flow.canvasStatus.history}
|
|
1345
|
-
onStatusChange={flow.changeStatus}
|
|
1346
|
-
onDelete={canvasesApiUrl ? openDelete : undefined}
|
|
1347
|
-
/>
|
|
1348
|
-
|
|
1349
|
-
{flow.deleted && !leaving && (
|
|
1350
|
-
<div className="absolute inset-0 z-40 flex items-center justify-center bg-[rgb(var(--ec-page-bg)/0.85)] p-4">
|
|
1351
|
-
<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))]">
|
|
1352
|
-
<h2 className="text-base font-semibold">This canvas was deleted</h2>
|
|
1353
|
-
<p className="text-sm text-[rgb(var(--ec-page-text-muted))]">
|
|
1354
|
-
Someone deleted it, so it can't be opened or changed any more.
|
|
1355
|
-
</p>
|
|
1356
|
-
{studioUrl && (
|
|
1357
|
-
<a
|
|
1358
|
-
href={studioUrl}
|
|
1359
|
-
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}`}
|
|
1360
1823
|
>
|
|
1361
|
-
|
|
1362
|
-
|
|
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
|
+
/>
|
|
1363
2021
|
)}
|
|
1364
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
|
+
)}
|
|
1365
2084
|
</div>
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
{flow.status === 'connected' && flow.nodes.length === 0 && comments.threads.length === 0 && !draft && (
|
|
1370
|
-
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
|
1371
|
-
<p className="text-sm text-[rgb(var(--ec-page-text-muted))]">
|
|
1372
|
-
Drag components or catalog resources from the left onto the canvas, then share the link to work on it together.
|
|
1373
|
-
</p>
|
|
1374
|
-
</div>
|
|
1375
|
-
)}
|
|
1376
|
-
</div>
|
|
1377
|
-
|
|
1378
|
-
{chooser && (
|
|
1379
|
-
<ContainerChooser
|
|
1380
|
-
resource={chooser.resource}
|
|
1381
|
-
contents={chooserContents}
|
|
1382
|
-
position={flowToScreenPosition(chooser.at.point)}
|
|
1383
|
-
onChoose={choose}
|
|
1384
|
-
onCancel={cancelChooser}
|
|
1385
|
-
/>
|
|
1386
|
-
)}
|
|
1387
|
-
{connectionsChooser && (
|
|
1388
|
-
<ConnectionsChooser
|
|
1389
|
-
resource={connectionsChooser.resource}
|
|
1390
|
-
groups={connectionsChooser.groups}
|
|
1391
|
-
position={flowToScreenPosition(connectionsChooser.at.point)}
|
|
1392
|
-
onChoose={chooseConnections}
|
|
1393
|
-
onCancel={cancelConnectionsChooser}
|
|
1394
|
-
/>
|
|
1395
|
-
)}
|
|
1396
|
-
{shareOpen && (
|
|
1397
|
-
<ShareDialog
|
|
1398
|
-
keptInMemory={keptInMemory}
|
|
1399
|
-
url={agentLink.canvasUrl}
|
|
1400
|
-
title={flow.title}
|
|
1401
|
-
presence={flow.presence}
|
|
1402
|
-
clientId={flow.clientId}
|
|
1403
|
-
onRename={onRename}
|
|
1404
|
-
onJumpTo={jumpTo}
|
|
1405
|
-
onConnectAgent={openConnect}
|
|
1406
|
-
onClose={closeShare}
|
|
1407
|
-
/>
|
|
1408
|
-
)}
|
|
1409
|
-
{deleteOpen && (
|
|
1410
|
-
<DeleteCanvasDialog
|
|
1411
|
-
title={flow.title}
|
|
1412
|
-
presence={flow.presence}
|
|
1413
|
-
clientId={flow.clientId}
|
|
1414
|
-
onDelete={deleteCanvas}
|
|
1415
|
-
onClose={closeDelete}
|
|
1416
|
-
/>
|
|
1417
|
-
)}
|
|
1418
|
-
{connectOpen && (
|
|
1419
|
-
<ConnectAgentDialog
|
|
1420
|
-
link={agentLink}
|
|
1421
|
-
webMcp={webMcp}
|
|
1422
|
-
relayScriptPath={relayScriptPath}
|
|
1423
|
-
onEnableWebMcp={turnOnWebMcp}
|
|
1424
|
-
onClose={closeConnect}
|
|
1425
|
-
/>
|
|
1426
|
-
)}
|
|
1427
|
-
{inspected && (
|
|
1428
|
-
<PropertiesPanel
|
|
1429
|
-
key={inspected.id}
|
|
1430
|
-
node={inspected}
|
|
1431
|
-
onChange={flow.updateNodeData}
|
|
1432
|
-
onDelete={flow.deleteNodes}
|
|
1433
|
-
onClose={closePanel}
|
|
1434
|
-
/>
|
|
1435
|
-
)}
|
|
1436
|
-
</div>
|
|
2085
|
+
</LockedContext.Provider>
|
|
2086
|
+
</StartWritingContext.Provider>
|
|
2087
|
+
</EditingContext.Provider>
|
|
1437
2088
|
</DropTargetContext.Provider>
|
|
1438
2089
|
</UpdateNodeDataContext.Provider>
|
|
1439
2090
|
);
|