@eventcatalog/core 5.0.0-beta.6 → 5.0.0-beta.8
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/dist/analytics/analytics.cjs +1 -1
- package/dist/analytics/analytics.js +2 -2
- package/dist/analytics/log-build.cjs +3 -1
- package/dist/analytics/log-build.js +4 -4
- package/dist/{chunk-XMPFHLK4.js → chunk-4W55ZOOS.js} +2 -2
- package/dist/{chunk-PIYJM642.js → chunk-AWOO63ME.js} +70 -44
- package/dist/{chunk-QBP3BBSV.js → chunk-C6T2AAFR.js} +1 -1
- package/dist/{chunk-MDWB5HLO.js → chunk-ENAG6UH7.js} +5 -0
- package/dist/{chunk-56CD2NH6.js → chunk-PU25TOOJ.js} +1 -1
- package/dist/{chunk-L2IRFOE7.js → chunk-RDIZJ7UT.js} +1 -1
- package/dist/{chunk-DE3QHVJP.js → chunk-T4G3VVZ4.js} +1 -1
- package/dist/constants.cjs +1 -1
- package/dist/constants.js +1 -1
- package/dist/eventcatalog.cjs +138 -90
- package/dist/eventcatalog.config.d.cts +47 -0
- package/dist/eventcatalog.config.d.ts +47 -0
- package/dist/eventcatalog.js +30 -11
- package/dist/features.cjs +8 -2
- package/dist/features.d.cts +3 -1
- package/dist/features.d.ts +3 -1
- package/dist/features.js +5 -3
- package/dist/generate.cjs +1 -1
- package/dist/generate.js +3 -3
- package/dist/utils/cli-logger.cjs +1 -1
- package/dist/utils/cli-logger.js +2 -2
- package/dist/utils/license-status.cjs +70 -44
- package/dist/utils/license-status.d.cts +14 -5
- package/dist/utils/license-status.d.ts +14 -5
- package/dist/utils/license-status.js +1 -1
- package/eventcatalog/integrations/studio-server.mjs +15 -3
- package/eventcatalog/src/components/Header.astro +3 -3
- package/eventcatalog/src/features/integrations/eventcatalog-features.ts +10 -2
- package/eventcatalog/src/features/mcp/apps/canvas/shared.ts +9 -1
- package/eventcatalog/src/features/mcp/apps/canvas/view.tsx +9 -2
- package/eventcatalog/src/features/mcp/apps/generated/canvas.html +70 -69
- package/eventcatalog/src/features/mcp/apps/generated/viewer.html +69 -69
- package/eventcatalog/src/features/mcp/mcp-server.ts +5 -3
- package/eventcatalog/src/features/studio/agent-choreography.ts +8 -5
- package/eventcatalog/src/features/studio/canvas-actions.ts +148 -21
- package/eventcatalog/src/features/studio/canvas-doc.ts +138 -9
- package/eventcatalog/src/features/studio/canvas-preview.ts +53 -0
- package/eventcatalog/src/features/studio/catalog-resources.ts +5 -2
- package/eventcatalog/src/features/studio/clipboard.ts +97 -0
- package/eventcatalog/src/features/studio/components/CanvasControls.tsx +4 -3
- package/eventcatalog/src/features/studio/components/CanvasHeader.tsx +39 -22
- package/eventcatalog/src/features/studio/components/CanvasStatusMenu.tsx +117 -0
- package/eventcatalog/src/features/studio/components/Comments.tsx +345 -30
- package/eventcatalog/src/features/studio/components/ConnectAgentDialog.tsx +140 -76
- package/eventcatalog/src/features/studio/components/ConnectionsChooser.tsx +144 -0
- package/eventcatalog/src/features/studio/components/ContainerChooser.tsx +1 -1
- package/eventcatalog/src/features/studio/components/DragGhost.tsx +115 -0
- package/eventcatalog/src/features/studio/components/JoinForm.tsx +0 -24
- package/eventcatalog/src/features/studio/components/LeftPanel.tsx +131 -35
- package/eventcatalog/src/features/studio/components/Picture.tsx +21 -0
- package/eventcatalog/src/features/studio/components/Presence.tsx +2 -0
- package/eventcatalog/src/features/studio/components/PropertiesPanel.tsx +187 -50
- package/eventcatalog/src/features/studio/components/ShareDialog.tsx +260 -0
- package/eventcatalog/src/features/studio/components/StudioDesigner.tsx +442 -40
- package/eventcatalog/src/features/studio/components/canvas-nodes.tsx +198 -22
- package/eventcatalog/src/features/studio/components/icons.ts +6 -1
- package/eventcatalog/src/features/studio/components/status.ts +30 -1
- package/eventcatalog/src/features/studio/edges.ts +2 -0
- package/eventcatalog/src/features/studio/grouping.ts +95 -2
- package/eventcatalog/src/features/studio/hooks/presence-store.ts +5 -1
- package/eventcatalog/src/features/studio/hooks/use-canvas-webmcp.ts +13 -3
- package/eventcatalog/src/features/studio/hooks/use-comments.ts +8 -1
- package/eventcatalog/src/features/studio/hooks/use-follow-camera.ts +7 -2
- package/eventcatalog/src/features/studio/hooks/use-studio-flow.ts +174 -27
- package/eventcatalog/src/features/studio/identity.ts +35 -0
- package/eventcatalog/src/features/studio/layout.ts +7 -4
- package/eventcatalog/src/features/studio/levels.ts +138 -72
- package/eventcatalog/src/features/studio/node-types.ts +35 -3
- package/eventcatalog/src/features/studio/pages/[id].astro +7 -2
- package/eventcatalog/src/features/studio/pages/index.astro +555 -2
- package/eventcatalog/src/features/studio/pages/new.astro +24 -0
- package/eventcatalog/src/features/studio/pages/wake.ts +15 -0
- package/eventcatalog/src/features/studio/server/canvas-mcp.ts +48 -13
- package/eventcatalog/src/features/studio/server/runtime.ts +193 -14
- package/eventcatalog/src/features/studio/server/sign-in.ts +18 -0
- package/eventcatalog/src/features/studio/server/storage.ts +110 -0
- package/eventcatalog/src/features/studio/studio.css +184 -0
- package/eventcatalog/src/layouts/VerticalSideBarLayout.astro +1 -1
- package/eventcatalog/src/utils/feature.ts +7 -3
- package/eventcatalog/src/utils/node-graphs/export-mermaid.ts +0 -2
- package/package.json +4 -4
|
@@ -7,11 +7,14 @@ import {
|
|
|
7
7
|
useReactFlow,
|
|
8
8
|
type Edge,
|
|
9
9
|
type Node,
|
|
10
|
+
type NodeChange,
|
|
10
11
|
type XYPosition,
|
|
11
12
|
} from '@xyflow/react';
|
|
12
13
|
import { edgeTypes } from '@eventcatalog/visualiser';
|
|
13
14
|
import { useHotkeys } from 'react-hotkeys-hook';
|
|
14
|
-
import { indexCatalog } from '../canvas-actions';
|
|
15
|
+
import { getCatalogConnections, indexCatalog, planWithConnections, type ConnectionGroup } from '../canvas-actions';
|
|
16
|
+
import { isDecided } from '../canvas-doc';
|
|
17
|
+
import { CLIPBOARD_TYPE, copyNodes, pasteNodes, readClipboard, type ClipboardContent } from '../clipboard';
|
|
15
18
|
import type { CatalogRelation, CatalogResource } from '../catalog-resources';
|
|
16
19
|
import {
|
|
17
20
|
buildContainer,
|
|
@@ -22,25 +25,37 @@ import {
|
|
|
22
25
|
getContents,
|
|
23
26
|
getRelatedEdges,
|
|
24
27
|
} from '../catalog';
|
|
25
|
-
import {
|
|
28
|
+
import {
|
|
29
|
+
findDropTarget,
|
|
30
|
+
findGroupAtPoint,
|
|
31
|
+
getAbsolutePosition,
|
|
32
|
+
previewContainerGrowth,
|
|
33
|
+
sizeOf,
|
|
34
|
+
sortByHierarchy,
|
|
35
|
+
toRelativePosition,
|
|
36
|
+
} from '../grouping';
|
|
26
37
|
import type { Peer } from '../hooks/presence-store';
|
|
27
38
|
import { useCanvasWebMcp } from '../hooks/use-canvas-webmcp';
|
|
28
39
|
import { useStudioFlow } from '../hooks/use-studio-flow';
|
|
29
40
|
import { useComments, type CommentAnchor, type Thread } from '../hooks/use-comments';
|
|
30
41
|
import { getLayoutPositions, layoutGraph } from '../layout';
|
|
31
42
|
import { getLevelGraph, getLevelUnavailableReason, LEVELS, type Level } from '../levels';
|
|
32
|
-
import { getNodeLabel, getNodeName } from '../node-types';
|
|
43
|
+
import { getNodeDefinition, getNodeLabel, getNodeName, getNoteLevel, isOnLevel, isWrittenOnCanvas } from '../node-types';
|
|
33
44
|
import type { ToolSync } from '../tool-sync';
|
|
34
45
|
import CanvasControls, { FIT_VIEW_OPTIONS } from './CanvasControls';
|
|
35
46
|
import CanvasHeader from './CanvasHeader';
|
|
36
47
|
import { DropTargetContext, nodeTypes, UpdateNodeDataContext } from './canvas-nodes';
|
|
37
|
-
import { anchorAt, CanvasContextMenu, CommentLayer, getAnchorPosition, type ContextMenuState } from './Comments';
|
|
48
|
+
import { anchorAt, CanvasContextMenu, CommentLayer, getAnchorPosition, timeAgo, type ContextMenuState } from './Comments';
|
|
49
|
+
import { CANVAS_STATUS_LOOK } from './status';
|
|
38
50
|
import ConnectAgentDialog, { enableWebMcp, isRelayEnabled, isWebMcpEnabled, loadRelay } from './ConnectAgentDialog';
|
|
39
51
|
import ContainerChooser, { type ContainerChoice } from './ContainerChooser';
|
|
40
|
-
import
|
|
52
|
+
import ConnectionsChooser, { type ConnectionsChoice } from './ConnectionsChooser';
|
|
53
|
+
import JoinForm from './JoinForm';
|
|
54
|
+
import { colorForName, getStoredName, onStoredNameChange, storeName } from '../identity';
|
|
41
55
|
import LeftPanel, { CATALOG_DRAG_TYPE, COMPONENT_DRAG_TYPE } from './LeftPanel';
|
|
42
56
|
import { AgentActivity, RemotePresence, ViewportSharer } from './Presence';
|
|
43
57
|
import PropertiesPanel from './PropertiesPanel';
|
|
58
|
+
import ShareDialog from './ShareDialog';
|
|
44
59
|
|
|
45
60
|
type CanvasProps = {
|
|
46
61
|
canvasId: string;
|
|
@@ -55,6 +70,8 @@ type CanvasProps = {
|
|
|
55
70
|
mcpUrl?: string;
|
|
56
71
|
/** The WebMCP local relay's script, when this page can offer it (not inside a chat) */
|
|
57
72
|
relayScriptPath?: string;
|
|
73
|
+
/** Canvases are only kept in memory, until the server restarts (`studio.storage` is "memory") */
|
|
74
|
+
keptInMemory?: boolean;
|
|
58
75
|
/** Sync through MCP tool calls when the WebSocket can't connect (e.g. inside a chat's sandbox) */
|
|
59
76
|
syncViaTools?: ToolSync;
|
|
60
77
|
/** Follow agents' changes with the camera from the start (e.g. inside a chat, where you watch an agent work) */
|
|
@@ -63,6 +80,8 @@ type CanvasProps = {
|
|
|
63
80
|
onSelectionChange?: (selected: SelectedNode[]) => void;
|
|
64
81
|
/** Join with this name instead of asking for one (e.g. inside a chat, where the agent or host knows who it is) */
|
|
65
82
|
defaultName?: string;
|
|
83
|
+
/** Signed in with the catalog's sign-in (SSO): you're shown by this name and picture, and can't change them */
|
|
84
|
+
signedInAs?: { name: string; picture?: string };
|
|
66
85
|
resources: CatalogResource[];
|
|
67
86
|
relations: CatalogRelation[];
|
|
68
87
|
};
|
|
@@ -72,6 +91,12 @@ export type SelectedNode = { id: string; name: string; type: string; catalogReso
|
|
|
72
91
|
/** How long a level (L1, L2) waits for changes to settle before it's laid out again */
|
|
73
92
|
const LEVEL_RELAYOUT_MS = 300;
|
|
74
93
|
const DELETE_KEYS = ['Backspace', 'Delete'];
|
|
94
|
+
const LEVEL_FIT_VIEW_OPTIONS = { ...FIT_VIEW_OPTIONS, maxZoom: 1 };
|
|
95
|
+
const NO_THREADS: ReadonlySet<string> = new Set();
|
|
96
|
+
// React Flow's warnings, except that a handle has no node: the left panel's drag previews draw nodes off the canvas
|
|
97
|
+
const onFlowError = (code: string, message: string) => {
|
|
98
|
+
if (code !== '010') console.warn(message);
|
|
99
|
+
};
|
|
75
100
|
const MULTI_SELECTION_KEYS = ['Meta', 'Shift'];
|
|
76
101
|
/**
|
|
77
102
|
* Like design tools: dragging on the canvas draws a box to select what's inside it, and the canvas pans with
|
|
@@ -79,6 +104,8 @@ const MULTI_SELECTION_KEYS = ['Meta', 'Shift'];
|
|
|
79
104
|
* zooms. The right button stays for the context menu.
|
|
80
105
|
*/
|
|
81
106
|
const PAN_BUTTONS = [1];
|
|
107
|
+
/** How close to a connection's end you grab it to move it */
|
|
108
|
+
const RECONNECT_RADIUS = 14;
|
|
82
109
|
const ZOOM_KEYS = ['Meta', 'Control'];
|
|
83
110
|
const DEFAULT_SOCKET_PATH = '/_eventcatalog/studio';
|
|
84
111
|
|
|
@@ -98,16 +125,34 @@ const withoutEnvelope = (edge: Edge) => {
|
|
|
98
125
|
};
|
|
99
126
|
|
|
100
127
|
/** Ids, types and containers: what changes when nodes are added, removed or moved in or out of containers */
|
|
128
|
+
// Notes on L1 and L2 can be moved and selected there, unlike the rest of the level (made once per note, so a note
|
|
129
|
+
// keeps its identity until it changes)
|
|
130
|
+
const levelNotes = new WeakMap<Node, Node>();
|
|
131
|
+
const asLevelNote = (node: Node) => {
|
|
132
|
+
let note = levelNotes.get(node);
|
|
133
|
+
if (!note) levelNotes.set(node, (note = { ...node, draggable: true, selectable: true }));
|
|
134
|
+
return note;
|
|
135
|
+
};
|
|
136
|
+
|
|
101
137
|
const structureOf = (nodes: Node[]) => nodes.map((node) => `${node.id}:${node.type}:${node.parentId ?? ''}`).join('|');
|
|
102
138
|
|
|
103
|
-
export default function StudioDesigner({ defaultName, ...props }: CanvasProps) {
|
|
139
|
+
export default function StudioDesigner({ defaultName, signedInAs, ...props }: CanvasProps) {
|
|
104
140
|
const [name, setName] = useState(() => getStoredName() ?? defaultName ?? null);
|
|
105
141
|
|
|
106
142
|
const saveName = useCallback((next: string) => {
|
|
107
143
|
setName(next);
|
|
108
144
|
storeName(next);
|
|
109
145
|
}, []);
|
|
110
|
-
|
|
146
|
+
// Renamed on the Studio page or another canvas in this browser
|
|
147
|
+
useEffect(() => onStoredNameChange((next) => next && setName(next)), []);
|
|
148
|
+
|
|
149
|
+
if (signedInAs) {
|
|
150
|
+
return (
|
|
151
|
+
<ReactFlowProvider>
|
|
152
|
+
<Canvas {...props} name={signedInAs.name} picture={signedInAs.picture} />
|
|
153
|
+
</ReactFlowProvider>
|
|
154
|
+
);
|
|
155
|
+
}
|
|
111
156
|
if (!name) return <JoinForm onJoin={saveName} />;
|
|
112
157
|
|
|
113
158
|
return (
|
|
@@ -128,14 +173,21 @@ function Canvas({
|
|
|
128
173
|
shareUrl,
|
|
129
174
|
mcpUrl = '/docs/mcp',
|
|
130
175
|
relayScriptPath,
|
|
176
|
+
keptInMemory = false,
|
|
131
177
|
syncViaTools,
|
|
132
178
|
followChanges = false,
|
|
133
179
|
onSelectionChange,
|
|
134
180
|
resources,
|
|
135
181
|
relations,
|
|
136
182
|
name,
|
|
183
|
+
picture,
|
|
137
184
|
onRename,
|
|
138
|
-
}: Omit<CanvasProps, 'defaultName'> & {
|
|
185
|
+
}: Omit<CanvasProps, 'defaultName' | 'signedInAs'> & {
|
|
186
|
+
name: string;
|
|
187
|
+
picture?: string;
|
|
188
|
+
/** Not given when you're signed in (your name is the one you signed in with) */
|
|
189
|
+
onRename?: (name: string) => void;
|
|
190
|
+
}) {
|
|
139
191
|
const color = colorForName(name);
|
|
140
192
|
const canvasRef = useRef<HTMLDivElement>(null);
|
|
141
193
|
|
|
@@ -148,15 +200,26 @@ function Canvas({
|
|
|
148
200
|
transitionTimer.current = setTimeout(() => setTransitioning(false), 600);
|
|
149
201
|
}, []);
|
|
150
202
|
|
|
203
|
+
// Containers grow smoothly when they're fitted to what's in them (or someone else resizes them). Set on the
|
|
204
|
+
// element rather than in state, so it doesn't re-render the canvas.
|
|
205
|
+
const resizeTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
|
206
|
+
const animateContainers = useCallback(() => {
|
|
207
|
+
canvasRef.current?.classList.add('is-container-resize');
|
|
208
|
+
clearTimeout(resizeTimer.current);
|
|
209
|
+
resizeTimer.current = setTimeout(() => canvasRef.current?.classList.remove('is-container-resize'), 400);
|
|
210
|
+
}, []);
|
|
211
|
+
|
|
151
212
|
const flow = useStudioFlow({
|
|
152
213
|
canvasId,
|
|
153
214
|
socketUrl: socketUrl ?? toSocketUrl(socketPath),
|
|
154
215
|
name,
|
|
155
216
|
color,
|
|
217
|
+
picture,
|
|
156
218
|
syncViaTools,
|
|
157
219
|
onLayout: glide,
|
|
220
|
+
onContainerResize: animateContainers,
|
|
158
221
|
});
|
|
159
|
-
const author = useMemo(() => ({ name, color }), [name, color]);
|
|
222
|
+
const author = useMemo(() => ({ name, color, ...(picture && { picture }) }), [name, color, picture]);
|
|
160
223
|
const comments = useComments(flow.doc, author);
|
|
161
224
|
const { screenToFlowPosition, flowToScreenPosition, setCenter, getZoom, fitView } = useReactFlow();
|
|
162
225
|
|
|
@@ -209,12 +272,20 @@ function Canvas({
|
|
|
209
272
|
|
|
210
273
|
// A domain or system dropped on the canvas: asked whether it's a card or a container first
|
|
211
274
|
const [chooser, setChooser] = useState<{ resource: CatalogResource; center: XYPosition } | null>(null);
|
|
275
|
+
// A service or message that connects to things not on the canvas yet: asked whether to bring them too
|
|
276
|
+
const [connectionsChooser, setConnectionsChooser] = useState<{
|
|
277
|
+
resource: CatalogResource;
|
|
278
|
+
center: XYPosition;
|
|
279
|
+
groups: ConnectionGroup[];
|
|
280
|
+
} | null>(null);
|
|
212
281
|
|
|
213
282
|
// A catalog resource is added with edges to the related resources already on the canvas
|
|
214
283
|
const addResource = useStableCallback((key: string, center: XYPosition = canvasCenter()) => {
|
|
215
284
|
const resource = catalog.resourcesByKey.get(key);
|
|
216
285
|
if (!resource) return;
|
|
217
286
|
if (canBeContainer(resource)) return setChooser({ resource, center });
|
|
287
|
+
const groups = getCatalogConnections(resource, catalog, keysOnCanvas);
|
|
288
|
+
if (groups.length > 0) return setConnectionsChooser({ resource, center, groups });
|
|
218
289
|
addCard(resource, center);
|
|
219
290
|
});
|
|
220
291
|
|
|
@@ -261,6 +332,18 @@ function Canvas({
|
|
|
261
332
|
});
|
|
262
333
|
const cancelChooser = useCallback(() => setChooser(null), []);
|
|
263
334
|
|
|
335
|
+
const chooseConnections = useStableCallback((choice: ConnectionsChoice) => {
|
|
336
|
+
if (!connectionsChooser) return;
|
|
337
|
+
const { resource, center } = connectionsChooser;
|
|
338
|
+
if (choice.as === 'card') addCard(resource, center);
|
|
339
|
+
else {
|
|
340
|
+
const { nodes, edges } = planWithConnections(nodesRef.current, resource, center, choice.include, catalog);
|
|
341
|
+
flow.insertNodes(nodes, edges);
|
|
342
|
+
}
|
|
343
|
+
setConnectionsChooser(null);
|
|
344
|
+
});
|
|
345
|
+
const cancelConnectionsChooser = useCallback(() => setConnectionsChooser(null), []);
|
|
346
|
+
|
|
264
347
|
// What a domain or system dropped as a container would bring with it
|
|
265
348
|
const chooserContents = useMemo(() => {
|
|
266
349
|
if (!chooser) return { systems: 0, services: 0 };
|
|
@@ -273,26 +356,118 @@ function Canvas({
|
|
|
273
356
|
};
|
|
274
357
|
}, [chooser, catalog, keysOnCanvas]);
|
|
275
358
|
|
|
276
|
-
|
|
277
|
-
const
|
|
359
|
+
/** A sticky note on the level shown (it's only shown on that level) */
|
|
360
|
+
const addNoteAt = (center: XYPosition) => {
|
|
361
|
+
const data = getNodeDefinition('note')!.createData();
|
|
362
|
+
flow.insertNode('note', levelRef.current === 3 ? data : { ...data, level: levelRef.current }, center);
|
|
363
|
+
};
|
|
364
|
+
/**
|
|
365
|
+
* Where something added while L1 or L2 is shown goes: L1 and L2 are views of the canvas people edit (L3), so it's
|
|
366
|
+
* added there (shown on L3), below what's on it
|
|
367
|
+
*/
|
|
368
|
+
const centerOnCanvas = (center: XYPosition) => {
|
|
369
|
+
if (levelRef.current === 3) return center;
|
|
370
|
+
changeLevel(3);
|
|
371
|
+
const roots = nodesRef.current.filter((node) => !node.parentId && isOnLevel(node, 3));
|
|
372
|
+
if (roots.length === 0) return { x: 0, y: 0 };
|
|
373
|
+
const left = Math.min(...roots.map((node) => node.position.x));
|
|
374
|
+
const right = Math.max(...roots.map((node) => node.position.x + sizeOf(node).width));
|
|
375
|
+
const bottom = Math.max(...roots.map((node) => node.position.y + sizeOf(node).height));
|
|
376
|
+
return { x: (left + right) / 2, y: bottom + 160 };
|
|
377
|
+
};
|
|
378
|
+
const addComponentAt = (type: string, center: XYPosition) =>
|
|
379
|
+
type === 'note' ? addNoteAt(center) : flow.addNode(type, centerOnCanvas(center));
|
|
380
|
+
|
|
381
|
+
const addComponent = useStableCallback((type: string) => addComponentAt(type, canvasCenter()));
|
|
382
|
+
const addNote = useStableCallback(() => addNoteAt(canvasCenter()));
|
|
383
|
+
const addResourceFromPanel = useStableCallback((key: string) => addResource(key, centerOnCanvas(canvasCenter())));
|
|
278
384
|
|
|
279
385
|
const onDrop = useStableCallback((event: DragEvent) => {
|
|
280
386
|
event.preventDefault();
|
|
281
387
|
const position = screenToFlowPosition({ x: event.clientX, y: event.clientY });
|
|
282
388
|
const type = event.dataTransfer.getData(COMPONENT_DRAG_TYPE);
|
|
283
389
|
const resourceKey = event.dataTransfer.getData(CATALOG_DRAG_TYPE);
|
|
284
|
-
if (type)
|
|
285
|
-
if (resourceKey) addResource(resourceKey, position);
|
|
390
|
+
if (type) addComponentAt(type, position);
|
|
391
|
+
if (resourceKey) addResource(resourceKey, centerOnCanvas(position));
|
|
286
392
|
});
|
|
287
393
|
|
|
288
394
|
// ---- Pointer, panning and dragging ----
|
|
289
395
|
|
|
290
396
|
// React Flow's node drag and panning (d3) stop mousemove from propagating, so follow the
|
|
291
397
|
// pointer with pointer events, and with dragover while something is dragged in from the palette
|
|
398
|
+
// Where the pointer is on the canvas (while it's over it), for pasting there
|
|
399
|
+
const pointerAt = useRef<XYPosition | null>(null);
|
|
292
400
|
const trackPointer = useStableCallback((event: { clientX: number; clientY: number }) => {
|
|
293
|
-
|
|
401
|
+
pointerAt.current = screenToFlowPosition({ x: event.clientX, y: event.clientY });
|
|
402
|
+
flow.setPointer(pointerAt.current);
|
|
294
403
|
});
|
|
295
|
-
const clearPointer = useCallback(() =>
|
|
404
|
+
const clearPointer = useCallback(() => {
|
|
405
|
+
pointerAt.current = null;
|
|
406
|
+
flow.setPointer(null);
|
|
407
|
+
}, [flow.setPointer]);
|
|
408
|
+
|
|
409
|
+
// ---- Copy and paste ----
|
|
410
|
+
|
|
411
|
+
// Pasting again without moving the pointer off the canvas cascades the copies, like design tools do
|
|
412
|
+
const pastes = useRef(0);
|
|
413
|
+
const selectedIds = () => nodesRef.current.filter((node) => node.selected).map((node) => node.id);
|
|
414
|
+
// What was last copied here (for "Paste here" in the context menu, which can't read the clipboard without asking)
|
|
415
|
+
const [lastCopied, setLastCopied] = useState<ClipboardContent | null>(null);
|
|
416
|
+
// Nodes to copy instead of the selection (copying from the context menu)
|
|
417
|
+
const copyOnly = useRef<string[] | null>(null);
|
|
418
|
+
|
|
419
|
+
/** Adds copies (as one change, so one undo takes them back) and selects them instead of what was selected */
|
|
420
|
+
const paste = useStableCallback((content: ClipboardContent, place: { at: XYPosition } | { offset: XYPosition }) => {
|
|
421
|
+
const { nodes, edges } = pasteNodes(content, nodesRef.current, place);
|
|
422
|
+
flow.onNodesChange(selectedIds().map((id) => ({ type: 'select', id, selected: false })));
|
|
423
|
+
flow.insertNodes(nodes, edges);
|
|
424
|
+
flow.onNodesChange(nodes.map((node) => ({ type: 'select', id: node.id, selected: true })));
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
useEffect(() => {
|
|
428
|
+
// Copying, cutting and pasting text in a field (or a note being edited) works as usual
|
|
429
|
+
const isTyping = () => {
|
|
430
|
+
const active = document.activeElement;
|
|
431
|
+
return (
|
|
432
|
+
active instanceof HTMLElement && (active.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(active.tagName))
|
|
433
|
+
);
|
|
434
|
+
};
|
|
435
|
+
// Text selected on the page (e.g. in a panel) is copied as text, like anywhere else
|
|
436
|
+
const hasTextSelected = () => !!window.getSelection()?.toString();
|
|
437
|
+
const copy = (event: ClipboardEvent) => {
|
|
438
|
+
const fromMenu = copyOnly.current !== null;
|
|
439
|
+
const ids = copyOnly.current ?? selectedIds();
|
|
440
|
+
copyOnly.current = null;
|
|
441
|
+
if (levelRef.current !== 3 || isTyping() || !event.clipboardData || (!fromMenu && hasTextSelected())) return;
|
|
442
|
+
const content = copyNodes(nodesRef.current, edgesRef.current, ids);
|
|
443
|
+
if (!content) return;
|
|
444
|
+
event.preventDefault();
|
|
445
|
+
event.clipboardData.setData(CLIPBOARD_TYPE, JSON.stringify(content));
|
|
446
|
+
// Other apps get a list of what was copied
|
|
447
|
+
event.clipboardData.setData('text/plain', content.nodes.map((node) => getNodeName(node.type, node.data)).join('\n'));
|
|
448
|
+
pastes.current = 0;
|
|
449
|
+
setLastCopied(content);
|
|
450
|
+
if (event.type === 'cut') flow.deleteNodes(ids);
|
|
451
|
+
};
|
|
452
|
+
const onPaste = (event: ClipboardEvent) => {
|
|
453
|
+
if (levelRef.current !== 3 || isTyping()) return;
|
|
454
|
+
const content =
|
|
455
|
+
readClipboard(event.clipboardData?.getData(CLIPBOARD_TYPE)) ?? readClipboard(event.clipboardData?.getData('text/plain'));
|
|
456
|
+
if (!content) return;
|
|
457
|
+
event.preventDefault();
|
|
458
|
+
pastes.current += 1;
|
|
459
|
+
const step = 40 * pastes.current;
|
|
460
|
+
paste(content, pointerAt.current ? { at: pointerAt.current } : { offset: { x: step, y: step } });
|
|
461
|
+
};
|
|
462
|
+
document.addEventListener('copy', copy);
|
|
463
|
+
document.addEventListener('cut', copy);
|
|
464
|
+
document.addEventListener('paste', onPaste);
|
|
465
|
+
return () => {
|
|
466
|
+
document.removeEventListener('copy', copy);
|
|
467
|
+
document.removeEventListener('cut', copy);
|
|
468
|
+
document.removeEventListener('paste', onPaste);
|
|
469
|
+
};
|
|
470
|
+
}, [paste, flow.deleteNodes]);
|
|
296
471
|
|
|
297
472
|
const onDragOver = useStableCallback((event: DragEvent) => {
|
|
298
473
|
event.preventDefault();
|
|
@@ -324,11 +499,55 @@ function Canvas({
|
|
|
324
499
|
const ids = new Set(dragged.map((node) => node.id));
|
|
325
500
|
return dragged.filter((node) => !node.parentId || !ids.has(node.parentId));
|
|
326
501
|
};
|
|
327
|
-
|
|
502
|
+
// While something's dragged, the containers around it grow to fit it (see previewContainerGrowth): the canvas
|
|
503
|
+
// when the drag started, and those containers, innermost first
|
|
504
|
+
const dragStart = useRef<{ nodes: Map<string, Node>; containers: string[] } | null>(null);
|
|
505
|
+
const onNodeDragStart = useStableCallback((_: unknown, __: Node, dragged: Node[]) => {
|
|
328
506
|
pauseCamera.current();
|
|
329
507
|
setInteraction('dragging', true);
|
|
330
|
-
|
|
508
|
+
const lookup = lookupNodes();
|
|
509
|
+
const containers: string[] = [];
|
|
510
|
+
for (const node of dragged) {
|
|
511
|
+
for (let id = lookup.get(node.id)?.parentId; id; id = lookup.get(id)?.parentId) {
|
|
512
|
+
if (!containers.includes(id)) containers.push(id);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
// Innermost first: deeper containers before the ones they're in
|
|
516
|
+
const depth = (id: string) => {
|
|
517
|
+
let level = 0;
|
|
518
|
+
for (let parent = lookup.get(id)?.parentId; parent; parent = lookup.get(parent)?.parentId) level++;
|
|
519
|
+
return level;
|
|
520
|
+
};
|
|
521
|
+
containers.sort((a, b) => depth(b) - depth(a));
|
|
522
|
+
dragStart.current = containers.length ? { nodes: lookup, containers } : null;
|
|
523
|
+
threadsDrag.current = selectedThreads.size && dragged[0] ? { id: dragged[0].id, from: canvasPositionOf(dragged[0]) } : null;
|
|
524
|
+
});
|
|
525
|
+
// Where a dragged node is on the canvas (its position is relative to its container)
|
|
526
|
+
const canvasPositionOf = (node: Node) => {
|
|
527
|
+
const lookup = lookupNodes();
|
|
528
|
+
lookup.set(node.id, { ...lookup.get(node.id)!, position: node.position });
|
|
529
|
+
return getAbsolutePosition(lookup.get(node.id)!, lookup);
|
|
530
|
+
};
|
|
531
|
+
// Selected comments move with what's dragged: how far it's moved from where it was
|
|
532
|
+
const threadsDrag = useRef<{ id: string; from: XYPosition } | null>(null);
|
|
533
|
+
const moveThreadsWith = (dragged: Node[]) => {
|
|
534
|
+
const drag = threadsDrag.current;
|
|
535
|
+
const node = drag && dragged.find((candidate) => candidate.id === drag.id);
|
|
536
|
+
if (!drag || !node) return;
|
|
537
|
+
const at = canvasPositionOf(node);
|
|
538
|
+
setThreadsOffset({ x: at.x - drag.from.x, y: at.y - drag.from.y });
|
|
539
|
+
};
|
|
540
|
+
const growContainers = (dragged: Node[]) => {
|
|
541
|
+
const started = dragStart.current;
|
|
542
|
+
if (!started) return;
|
|
543
|
+
// Where the dragged nodes are now (the canvas's own nodes can be a frame behind)
|
|
544
|
+
const now = lookupNodes();
|
|
545
|
+
dragged.forEach((node) => now.set(node.id, { ...now.get(node.id)!, position: node.position }));
|
|
546
|
+
flow.showDragPreview(previewContainerGrowth(started.containers, new Set(dragged.map((node) => node.id)), started.nodes, now));
|
|
547
|
+
};
|
|
331
548
|
const onNodeDrag = useStableCallback((_: unknown, __: Node, dragged: Node[]) => {
|
|
549
|
+
growContainers(dragged);
|
|
550
|
+
moveThreadsWith(dragged);
|
|
332
551
|
const [root] = dragRoots(dragged);
|
|
333
552
|
const current = root && nodesRef.current.find((node) => node.id === root.id);
|
|
334
553
|
const target = root ? findDropTarget(root.id, nodesRef.current) : undefined;
|
|
@@ -337,6 +556,22 @@ function Canvas({
|
|
|
337
556
|
const onNodeDragStop = useStableCallback((_: unknown, __: Node, dragged: Node[]) => {
|
|
338
557
|
setInteraction('dragging', false);
|
|
339
558
|
setDropTargetId(null);
|
|
559
|
+
dragStart.current = null;
|
|
560
|
+
// Selected comments not on a node are saved where they moved to
|
|
561
|
+
if (threadsDrag.current && threadsOffset) {
|
|
562
|
+
const offset = threadsOffset;
|
|
563
|
+
comments.moveThreads(
|
|
564
|
+
comments.threads
|
|
565
|
+
.filter((thread) => selectedThreads.has(thread.id) && !thread.nodeId)
|
|
566
|
+
.map((thread) => ({
|
|
567
|
+
threadId: thread.id,
|
|
568
|
+
anchor: { position: { x: thread.position.x + offset.x, y: thread.position.y + offset.y } },
|
|
569
|
+
}))
|
|
570
|
+
);
|
|
571
|
+
}
|
|
572
|
+
threadsDrag.current = null;
|
|
573
|
+
setThreadsOffset(null);
|
|
574
|
+
// The containers it grew are saved with the drop; dropped in another container (or out of one), it moves there
|
|
340
575
|
for (const root of dragRoots(dragged)) {
|
|
341
576
|
const current = nodesRef.current.find((node) => node.id === root.id);
|
|
342
577
|
const target = findDropTarget(root.id, nodesRef.current);
|
|
@@ -353,13 +588,15 @@ function Canvas({
|
|
|
353
588
|
const editable = level === 3;
|
|
354
589
|
const [levelGraph, setLevelGraph] = useState<{ level: Level; nodes: Node[]; edges: Edge[] } | null>(null);
|
|
355
590
|
|
|
356
|
-
// What a level shows depends on what's on the canvas and how it's connected, and names (not positions)
|
|
591
|
+
// What a level shows depends on what's on the canvas and how it's connected, and names (not positions). Notes
|
|
592
|
+
// aren't laid out with it: adding, moving or writing one doesn't lay the level out again.
|
|
357
593
|
const levelKey =
|
|
358
594
|
level === 3
|
|
359
595
|
? ''
|
|
360
|
-
: `${flow.nodes
|
|
361
|
-
.
|
|
362
|
-
.
|
|
596
|
+
: `${flow.nodes
|
|
597
|
+
.filter((node) => node.type !== 'note')
|
|
598
|
+
.map((node) => `${node.id}:${node.type}:${node.parentId ?? ''}:${getNodeName(node.type, node.data)}`)
|
|
599
|
+
.join('|')}#${flow.edges.map((edge) => `${edge.source}>${edge.target}:${String(edge.label ?? '')}`).join('|')}`;
|
|
363
600
|
const shownLevelRef = useRef<Level | undefined>(undefined);
|
|
364
601
|
shownLevelRef.current = levelGraph?.level;
|
|
365
602
|
useEffect(() => {
|
|
@@ -387,6 +624,12 @@ function Canvas({
|
|
|
387
624
|
const [draft, setDraft] = useState<CommentAnchor | null>(null);
|
|
388
625
|
const [openThreadId, setOpenThreadId] = useState<string | null>(null);
|
|
389
626
|
const [showResolved, setShowResolved] = useState(false);
|
|
627
|
+
// Comments that are part of the selection (selecting everything selects them too): only here, like what nodes
|
|
628
|
+
// are selected. While what's selected is dragged, the ones not on a node move with it by this much.
|
|
629
|
+
const [selectedThreads, setSelectedThreads] = useState<ReadonlySet<string>>(NO_THREADS);
|
|
630
|
+
const clearSelectedThreads = useCallback(() => setSelectedThreads((current) => (current.size ? NO_THREADS : current)), []);
|
|
631
|
+
const [threadsOffset, setThreadsOffset] = useState<XYPosition | null>(null);
|
|
632
|
+
const threadsShown = () => comments.threads.filter((thread) => showResolved || !thread.resolved);
|
|
390
633
|
const [contextMenu, setContextMenu] = useState<ContextMenuState>(null);
|
|
391
634
|
|
|
392
635
|
const changeLevel = useStableCallback((next: Level) => {
|
|
@@ -395,6 +638,8 @@ function Canvas({
|
|
|
395
638
|
setCommentMode(false);
|
|
396
639
|
setDraft(null);
|
|
397
640
|
setOpenThreadId(null);
|
|
641
|
+
// What's selected may not be on the next level (e.g. a note added on this one)
|
|
642
|
+
flow.onNodesChange(selectedIds().map((id) => ({ type: 'select', id, selected: false })));
|
|
398
643
|
setLevel(next);
|
|
399
644
|
});
|
|
400
645
|
const editInL3 = useCallback(() => changeLevel(3), [changeLevel]);
|
|
@@ -403,7 +648,9 @@ function Canvas({
|
|
|
403
648
|
const shownLevel = level === 3 ? 3 : levelGraph?.level;
|
|
404
649
|
useEffect(() => {
|
|
405
650
|
if (shownLevel === undefined) return;
|
|
406
|
-
|
|
651
|
+
// Like the visualiser, a level isn't zoomed in past its real size
|
|
652
|
+
const options = shownLevel === 3 ? FIT_VIEW_OPTIONS : LEVEL_FIT_VIEW_OPTIONS;
|
|
653
|
+
const frame = requestAnimationFrame(() => void fitView({ ...options, duration: 450 }));
|
|
407
654
|
return () => cancelAnimationFrame(frame);
|
|
408
655
|
}, [shownLevel]);
|
|
409
656
|
|
|
@@ -430,7 +677,19 @@ function Canvas({
|
|
|
430
677
|
return hierarchyOrder.flatMap((id) => byId.get(id) ?? []);
|
|
431
678
|
}, [flow.nodes, hierarchyOrder]);
|
|
432
679
|
const levelShown = level !== 3 && levelGraph?.level === level ? levelGraph : null;
|
|
433
|
-
|
|
680
|
+
// Sticky notes are shown on the level they were added on: on L1 and L2, over the level's layout
|
|
681
|
+
const shownNodes = useMemo(() => {
|
|
682
|
+
if (!levelShown)
|
|
683
|
+
return sortedNodes.every((node) => isOnLevel(node, 3)) ? sortedNodes : sortedNodes.filter((node) => isOnLevel(node, 3));
|
|
684
|
+
const notes = sortedNodes.filter((node) => getNoteLevel(node) === levelShown.level);
|
|
685
|
+
return notes.length ? [...levelShown.nodes, ...notes.map(asLevelNote)] : levelShown.nodes;
|
|
686
|
+
}, [levelShown, sortedNodes]);
|
|
687
|
+
/** On L1 and L2, only changes to the level's notes go to the canvas (its other nodes are drawn from it) */
|
|
688
|
+
const onLevelNodesChange = useStableCallback((changes: NodeChange[]) => {
|
|
689
|
+
const notes = new Set(nodesRef.current.filter((node) => getNoteLevel(node) === levelRef.current).map((node) => node.id));
|
|
690
|
+
const ofNotes = changes.filter((change) => change.type !== 'add' && notes.has(change.id));
|
|
691
|
+
if (ofNotes.length) flow.onNodesChange(ofNotes);
|
|
692
|
+
});
|
|
434
693
|
// Message edges' envelopes only move on what's selected (the edge, or a node at either end): any moving
|
|
435
694
|
// envelope at all costs a style recalc and layout every frame, which an editing canvas can't afford at idle
|
|
436
695
|
const shownEdges = useMemo(() => {
|
|
@@ -463,6 +722,9 @@ function Canvas({
|
|
|
463
722
|
const [following, setFollowing] = useState(followChanges);
|
|
464
723
|
const toggleFollowing = useCallback(() => setFollowing((on) => !on), []);
|
|
465
724
|
|
|
725
|
+
const [shareOpen, setShareOpen] = useState(false);
|
|
726
|
+
const openShare = useCallback(() => setShareOpen(true), []);
|
|
727
|
+
const closeShare = useCallback(() => setShareOpen(false), []);
|
|
466
728
|
const [connectOpen, setConnectOpen] = useState(false);
|
|
467
729
|
const openConnect = useCallback(() => setConnectOpen(true), []);
|
|
468
730
|
const closeConnect = useCallback(() => setConnectOpen(false), []);
|
|
@@ -484,19 +746,33 @@ function Canvas({
|
|
|
484
746
|
'mod+a',
|
|
485
747
|
() => {
|
|
486
748
|
if (!onCanvas()) return;
|
|
487
|
-
flow.onNodesChange(
|
|
749
|
+
flow.onNodesChange(
|
|
750
|
+
nodesRef.current.filter((node) => isOnLevel(node, 3)).map((node) => ({ type: 'select', id: node.id, selected: true }))
|
|
751
|
+
);
|
|
488
752
|
flow.onEdgesChange(edgesRef.current.map((edge) => ({ type: 'select', id: edge.id, selected: true })));
|
|
753
|
+
// ...and the comments shown on it
|
|
754
|
+
setSelectedThreads(new Set(threadsShown().map((thread) => thread.id)));
|
|
755
|
+
},
|
|
756
|
+
{ preventDefault: true }
|
|
757
|
+
);
|
|
758
|
+
useHotkeys(
|
|
759
|
+
'mod+d',
|
|
760
|
+
() => {
|
|
761
|
+
const content = onCanvas() && copyNodes(nodesRef.current, edgesRef.current, selectedIds());
|
|
762
|
+
if (content) paste(content, { offset: { x: 40, y: 40 } });
|
|
489
763
|
},
|
|
490
764
|
{ preventDefault: true }
|
|
491
765
|
);
|
|
492
|
-
|
|
493
|
-
useHotkeys(
|
|
766
|
+
// On every level: notes are changed on L1 and L2 too (and the levels show what's undone on the canvas)
|
|
767
|
+
useHotkeys('mod+z', () => flow.undo(), { preventDefault: true });
|
|
768
|
+
useHotkeys(['mod+shift+z', 'mod+y'], () => flow.redo(), { preventDefault: true });
|
|
494
769
|
useHotkeys('c', () => onCanvas() && setCommentMode((on) => !on));
|
|
495
770
|
useHotkeys('escape', () => {
|
|
496
771
|
setCommentMode(false);
|
|
497
772
|
setDraft(null);
|
|
498
773
|
setOpenThreadId(null);
|
|
499
|
-
|
|
774
|
+
clearSelectedThreads();
|
|
775
|
+
setEditingId(null);
|
|
500
776
|
flow.onNodesChange(
|
|
501
777
|
nodesRef.current.filter((node) => node.selected).map((node) => ({ type: 'select', id: node.id, selected: false }))
|
|
502
778
|
);
|
|
@@ -504,6 +780,12 @@ function Canvas({
|
|
|
504
780
|
edgesRef.current.filter((edge) => edge.selected).map((edge) => ({ type: 'select', id: edge.id, selected: false }))
|
|
505
781
|
);
|
|
506
782
|
});
|
|
783
|
+
// Selected comments are deleted with what's selected (React Flow deletes the nodes and edges)
|
|
784
|
+
useHotkeys(DELETE_KEYS, () => {
|
|
785
|
+
if (!onCanvas() || selectedThreads.size === 0) return;
|
|
786
|
+
comments.deleteThreads([...selectedThreads]);
|
|
787
|
+
clearSelectedThreads();
|
|
788
|
+
});
|
|
507
789
|
const toggleCommentMode = useCallback(() => setCommentMode((on) => !on), []);
|
|
508
790
|
|
|
509
791
|
const startComment = (anchor: CommentAnchor) => {
|
|
@@ -516,12 +798,31 @@ function Canvas({
|
|
|
516
798
|
screenToFlowPosition({ x: event.clientX, y: event.clientY });
|
|
517
799
|
|
|
518
800
|
const onPaneClick = useStableCallback((event: ReactMouseEvent) => {
|
|
801
|
+
clearSelectedThreads();
|
|
519
802
|
if (commentMode && editable) return startComment(anchorAt(flowPositionOf(event)));
|
|
803
|
+
// A click inside a container (not on something in it) selects it: its body lets the pointer through so you can
|
|
804
|
+
// drag a selection box in it, so the click lands on the canvas
|
|
805
|
+
// (after React Flow clears the selection, which it does once this returns)
|
|
806
|
+
const container = editable ? findGroupAtPoint(flowPositionOf(event), nodesRef.current) : undefined;
|
|
807
|
+
if (container) queueMicrotask(() => flow.onNodesChange([{ type: 'select', id: container.id, selected: true }]));
|
|
520
808
|
setDraft(null);
|
|
521
809
|
setOpenThreadId(null);
|
|
522
810
|
});
|
|
523
811
|
|
|
812
|
+
// Double clicking a node edits its details (notes are edited on the canvas, by double clicking them too)
|
|
813
|
+
const onNodeDoubleClick = useStableCallback((_: ReactMouseEvent, node: Node) => {
|
|
814
|
+
if (editable && !isWrittenOnCanvas(node.type)) setEditingId(node.id);
|
|
815
|
+
});
|
|
816
|
+
// ...and inside a container (not on something in it): its body lets the pointer through, so it's on the canvas
|
|
817
|
+
const onCanvasDoubleClick = useStableCallback((event: ReactMouseEvent) => {
|
|
818
|
+
if (!editable || !(event.target as Element).classList?.contains('react-flow__pane')) return;
|
|
819
|
+
const container = findGroupAtPoint(flowPositionOf(event), nodesRef.current);
|
|
820
|
+
if (container) setEditingId(container.id);
|
|
821
|
+
});
|
|
822
|
+
|
|
524
823
|
const onNodeClick = useStableCallback((event: ReactMouseEvent, node: Node) => {
|
|
824
|
+
// Clicking one node selects just it (with shift or ⌘, it's added to what's selected)
|
|
825
|
+
if (!event.shiftKey && !event.metaKey) clearSelectedThreads();
|
|
525
826
|
if (commentMode && editable) startComment(anchorAt(flowPositionOf(event), node, lookupNodes()));
|
|
526
827
|
});
|
|
527
828
|
|
|
@@ -537,8 +838,35 @@ function Canvas({
|
|
|
537
838
|
if (contextMenu) startComment(anchorAt(contextMenu.flowPosition, contextMenu.node, lookupNodes()));
|
|
538
839
|
setContextMenu(null);
|
|
539
840
|
});
|
|
540
|
-
|
|
541
|
-
|
|
841
|
+
/** The node right clicked, or the whole selection if it's part of it */
|
|
842
|
+
const menuTargets = () => {
|
|
843
|
+
const node = contextMenu?.node;
|
|
844
|
+
if (!node) return [];
|
|
845
|
+
const selected = selectedIds();
|
|
846
|
+
return selected.includes(node.id) ? selected : [node.id];
|
|
847
|
+
};
|
|
848
|
+
const editFromMenu = useStableCallback(() => {
|
|
849
|
+
if (contextMenu?.node) setEditingId(contextMenu.node.id);
|
|
850
|
+
setContextMenu(null);
|
|
851
|
+
});
|
|
852
|
+
const copyFromMenu = useStableCallback(() => {
|
|
853
|
+
// Through the copy event, so it's copied like ⌘C (to the system clipboard too)
|
|
854
|
+
copyOnly.current = menuTargets();
|
|
855
|
+
document.execCommand('copy');
|
|
856
|
+
copyOnly.current = null;
|
|
857
|
+
setContextMenu(null);
|
|
858
|
+
});
|
|
859
|
+
const duplicateFromMenu = useStableCallback(() => {
|
|
860
|
+
const content = copyNodes(nodesRef.current, edgesRef.current, menuTargets());
|
|
861
|
+
if (content) paste(content, { offset: { x: 40, y: 40 } });
|
|
862
|
+
setContextMenu(null);
|
|
863
|
+
});
|
|
864
|
+
const pasteFromMenu = useStableCallback(() => {
|
|
865
|
+
if (lastCopied && contextMenu) paste(lastCopied, { at: contextMenu.flowPosition });
|
|
866
|
+
setContextMenu(null);
|
|
867
|
+
});
|
|
868
|
+
const deleteFromMenu = useStableCallback(() => {
|
|
869
|
+
flow.deleteNodes(menuTargets());
|
|
542
870
|
setContextMenu(null);
|
|
543
871
|
});
|
|
544
872
|
|
|
@@ -556,7 +884,8 @@ function Canvas({
|
|
|
556
884
|
}, []);
|
|
557
885
|
const createThread = useStableCallback((text: string) => {
|
|
558
886
|
if (!draft) return;
|
|
559
|
-
|
|
887
|
+
// Posted, it's a pin like the others (not opened): click it to see or reply to it
|
|
888
|
+
comments.createThread(draft, text);
|
|
560
889
|
setDraft(null);
|
|
561
890
|
});
|
|
562
891
|
const cancelDraft = useCallback(() => setDraft(null), []);
|
|
@@ -580,11 +909,22 @@ function Canvas({
|
|
|
580
909
|
})
|
|
581
910
|
);
|
|
582
911
|
}, [selectedKey]);
|
|
583
|
-
|
|
912
|
+
// The node whose details are being edited in the panel: opened by double clicking it, or "Edit details" in its
|
|
913
|
+
// menu (selecting a node doesn't open it). Notes are edited on the canvas.
|
|
914
|
+
const [editingId, setEditingId] = useState<string | null>(null);
|
|
915
|
+
const editing =
|
|
916
|
+
editable && editingId ? flow.nodes.find((node) => node.id === editingId && !isWrittenOnCanvas(node.type)) : undefined;
|
|
917
|
+
// What the panel shows (not the node's position, so dragging it doesn't re-render the panel)
|
|
584
918
|
const inspected = useMemo(
|
|
585
|
-
() => (
|
|
586
|
-
[
|
|
919
|
+
() => (editing ? { id: editing.id, type: editing.type, data: editing.data } : null),
|
|
920
|
+
[editing?.id, editing?.type, editing?.data]
|
|
587
921
|
);
|
|
922
|
+
const closePanel = useCallback(() => setEditingId(null), []);
|
|
923
|
+
// An accepted or rejected canvas, and when it was decided
|
|
924
|
+
const { status: canvasStatus, history: statusHistory } = flow.canvasStatus;
|
|
925
|
+
const decided = isDecided(canvasStatus)
|
|
926
|
+
? { status: canvasStatus, change: statusHistory.findLast((change) => change.status === canvasStatus) }
|
|
927
|
+
: null;
|
|
588
928
|
|
|
589
929
|
return (
|
|
590
930
|
<UpdateNodeDataContext.Provider value={flow.updateNodeData}>
|
|
@@ -594,7 +934,7 @@ function Canvas({
|
|
|
594
934
|
resources={resources}
|
|
595
935
|
keysOnCanvas={keysOnCanvas}
|
|
596
936
|
onAddComponent={addComponent}
|
|
597
|
-
onAddResource={
|
|
937
|
+
onAddResource={addResourceFromPanel}
|
|
598
938
|
threads={comments.threads}
|
|
599
939
|
showResolved={showResolved}
|
|
600
940
|
onToggleResolved={toggleResolved}
|
|
@@ -608,6 +948,7 @@ function Canvas({
|
|
|
608
948
|
}`}
|
|
609
949
|
onPointerMove={trackPointer}
|
|
610
950
|
onPointerLeave={clearPointer}
|
|
951
|
+
onDoubleClick={onCanvasDoubleClick}
|
|
611
952
|
>
|
|
612
953
|
<ReactFlow
|
|
613
954
|
nodes={shownNodes}
|
|
@@ -617,18 +958,25 @@ function Canvas({
|
|
|
617
958
|
nodesDraggable={editable}
|
|
618
959
|
nodesConnectable={editable}
|
|
619
960
|
elementsSelectable={editable}
|
|
620
|
-
onNodesChange={editable ? flow.onNodesChange :
|
|
961
|
+
onNodesChange={editable ? flow.onNodesChange : onLevelNodesChange}
|
|
621
962
|
onEdgesChange={editable ? flow.onEdgesChange : undefined}
|
|
622
963
|
onConnect={flow.onConnect}
|
|
964
|
+
// Drag either end of a connection to another node (dropped anywhere else, it stays where it was)
|
|
965
|
+
edgesReconnectable={editable}
|
|
966
|
+
onReconnect={editable ? flow.onReconnect : undefined}
|
|
967
|
+
reconnectRadius={RECONNECT_RADIUS}
|
|
623
968
|
onDragOver={onDragOver}
|
|
624
969
|
onMoveStart={onMoveStart}
|
|
625
970
|
onMoveEnd={onMoveEnd}
|
|
626
971
|
onNodeDragStart={onNodeDragStart}
|
|
627
|
-
onNodeDrag={
|
|
628
|
-
onNodeDragStop={
|
|
972
|
+
onNodeDrag={onNodeDrag}
|
|
973
|
+
onNodeDragStop={onNodeDragStop}
|
|
629
974
|
onDrop={onDrop}
|
|
630
975
|
onPaneClick={onPaneClick}
|
|
631
976
|
onNodeClick={onNodeClick}
|
|
977
|
+
onNodeDoubleClick={onNodeDoubleClick}
|
|
978
|
+
onError={onFlowError}
|
|
979
|
+
onSelectionStart={clearSelectedThreads}
|
|
632
980
|
onPaneContextMenu={onPaneContextMenu}
|
|
633
981
|
onNodeContextMenu={onNodeContextMenu}
|
|
634
982
|
connectionLineType={ConnectionLineType.SmoothStep}
|
|
@@ -663,6 +1011,8 @@ function Canvas({
|
|
|
663
1011
|
onMove={comments.moveThread}
|
|
664
1012
|
onMoveDraft={setDraft}
|
|
665
1013
|
onDelete={deleteThread}
|
|
1014
|
+
selectedIds={selectedThreads}
|
|
1015
|
+
selectionOffset={threadsOffset}
|
|
666
1016
|
/>
|
|
667
1017
|
)}
|
|
668
1018
|
</ReactFlow>
|
|
@@ -680,7 +1030,7 @@ function Canvas({
|
|
|
680
1030
|
<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))]">
|
|
681
1031
|
<span>
|
|
682
1032
|
<span className="font-semibold">L{level}</span> · {LEVELS.find((entry) => entry.level === level)?.description} ·
|
|
683
|
-
read only
|
|
1033
|
+
read only, sticky notes can be added
|
|
684
1034
|
</span>
|
|
685
1035
|
<button onClick={editInL3} className="font-semibold text-[rgb(var(--ec-accent))] hover:underline">
|
|
686
1036
|
Edit in L3
|
|
@@ -688,6 +1038,19 @@ function Canvas({
|
|
|
688
1038
|
</div>
|
|
689
1039
|
)}
|
|
690
1040
|
|
|
1041
|
+
{/* Accepted or rejected: still editable, but changing it makes it a draft again (for everyone) */}
|
|
1042
|
+
{editable && decided && (
|
|
1043
|
+
<div
|
|
1044
|
+
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}`}
|
|
1045
|
+
>
|
|
1046
|
+
<span className="truncate">
|
|
1047
|
+
<span className="font-semibold">{CANVAS_STATUS_LOOK[decided.status].label}</span>
|
|
1048
|
+
{decided.change && ` by ${decided.change.by.name} · ${timeAgo(decided.change.at)}`}
|
|
1049
|
+
{decided.change?.note && ` · “${decided.change.note}”`} · changing it moves it back to Draft
|
|
1050
|
+
</span>
|
|
1051
|
+
</div>
|
|
1052
|
+
)}
|
|
1053
|
+
|
|
691
1054
|
<CanvasControls
|
|
692
1055
|
level={level}
|
|
693
1056
|
onLevelChange={changeLevel}
|
|
@@ -706,6 +1069,10 @@ function Canvas({
|
|
|
706
1069
|
<CanvasContextMenu
|
|
707
1070
|
menu={contextMenu}
|
|
708
1071
|
onAddComment={commentFromMenu}
|
|
1072
|
+
onEditDetails={editFromMenu}
|
|
1073
|
+
onCopy={copyFromMenu}
|
|
1074
|
+
onDuplicate={duplicateFromMenu}
|
|
1075
|
+
onPaste={lastCopied ? pasteFromMenu : undefined}
|
|
709
1076
|
onDeleteNode={deleteFromMenu}
|
|
710
1077
|
onClose={closeContextMenu}
|
|
711
1078
|
/>
|
|
@@ -720,12 +1087,17 @@ function Canvas({
|
|
|
720
1087
|
onJumpTo={jumpTo}
|
|
721
1088
|
onRename={onRename}
|
|
722
1089
|
onConnectAgent={openConnect}
|
|
1090
|
+
onShare={openShare}
|
|
723
1091
|
webMcp={webMcp}
|
|
724
1092
|
shareUrl={shareUrl}
|
|
725
1093
|
newCanvasUrl={newCanvasUrl}
|
|
1094
|
+
canvasStatus={flow.canvasStatus.status}
|
|
1095
|
+
statusHistory={flow.canvasStatus.history}
|
|
1096
|
+
onStatusChange={flow.changeStatus}
|
|
726
1097
|
/>
|
|
727
1098
|
|
|
728
|
-
{
|
|
1099
|
+
{/* Only on an empty canvas (not over comments people have left on it) */}
|
|
1100
|
+
{flow.status === 'connected' && flow.nodes.length === 0 && comments.threads.length === 0 && !draft && (
|
|
729
1101
|
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
|
730
1102
|
<p className="text-sm text-[rgb(var(--ec-page-text-muted))]">
|
|
731
1103
|
Drag components or catalog resources from the left onto the canvas, then share the link to work on it together.
|
|
@@ -743,6 +1115,28 @@ function Canvas({
|
|
|
743
1115
|
onCancel={cancelChooser}
|
|
744
1116
|
/>
|
|
745
1117
|
)}
|
|
1118
|
+
{connectionsChooser && (
|
|
1119
|
+
<ConnectionsChooser
|
|
1120
|
+
resource={connectionsChooser.resource}
|
|
1121
|
+
groups={connectionsChooser.groups}
|
|
1122
|
+
position={flowToScreenPosition(connectionsChooser.center)}
|
|
1123
|
+
onChoose={chooseConnections}
|
|
1124
|
+
onCancel={cancelConnectionsChooser}
|
|
1125
|
+
/>
|
|
1126
|
+
)}
|
|
1127
|
+
{shareOpen && (
|
|
1128
|
+
<ShareDialog
|
|
1129
|
+
keptInMemory={keptInMemory}
|
|
1130
|
+
url={agentLink.canvasUrl}
|
|
1131
|
+
title={flow.title}
|
|
1132
|
+
presence={flow.presence}
|
|
1133
|
+
clientId={flow.clientId}
|
|
1134
|
+
onRename={onRename}
|
|
1135
|
+
onJumpTo={jumpTo}
|
|
1136
|
+
onConnectAgent={openConnect}
|
|
1137
|
+
onClose={closeShare}
|
|
1138
|
+
/>
|
|
1139
|
+
)}
|
|
746
1140
|
{connectOpen && (
|
|
747
1141
|
<ConnectAgentDialog
|
|
748
1142
|
link={agentLink}
|
|
@@ -752,7 +1146,15 @@ function Canvas({
|
|
|
752
1146
|
onClose={closeConnect}
|
|
753
1147
|
/>
|
|
754
1148
|
)}
|
|
755
|
-
{inspected &&
|
|
1149
|
+
{inspected && (
|
|
1150
|
+
<PropertiesPanel
|
|
1151
|
+
key={inspected.id}
|
|
1152
|
+
node={inspected}
|
|
1153
|
+
onChange={flow.updateNodeData}
|
|
1154
|
+
onDelete={flow.deleteNodes}
|
|
1155
|
+
onClose={closePanel}
|
|
1156
|
+
/>
|
|
1157
|
+
)}
|
|
756
1158
|
</div>
|
|
757
1159
|
</DropTargetContext.Provider>
|
|
758
1160
|
</UpdateNodeDataContext.Provider>
|