@eventcatalog/core 5.0.0-beta.7 → 5.0.0-beta.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +227 -128
- package/dist/analytics/analytics.cjs +18 -3
- package/dist/analytics/analytics.d.cts +1 -1
- package/dist/analytics/analytics.d.ts +1 -1
- package/dist/analytics/analytics.js +3 -2
- package/dist/analytics/log-build.cjs +70 -34
- package/dist/analytics/log-build.js +5 -4
- package/dist/analytics/telemetry.cjs +42 -0
- package/dist/analytics/telemetry.d.cts +20 -0
- package/dist/analytics/telemetry.d.ts +20 -0
- package/dist/analytics/telemetry.js +8 -0
- package/dist/{chunk-3VBB2ZMP.js → chunk-4CFZUHGG.js} +9 -3
- package/dist/{chunk-PIYJM642.js → chunk-AWOO63ME.js} +70 -44
- package/dist/chunk-CPW6G62V.js +17 -0
- package/dist/{chunk-MDWB5HLO.js → chunk-ENAG6UH7.js} +5 -0
- package/dist/{chunk-FARBIZ7Y.js → chunk-EUNQJO3J.js} +1 -1
- package/dist/{chunk-S7ODHVII.js → chunk-GAHKCOQM.js} +1 -1
- package/dist/{chunk-BOKAG5XC.js → chunk-JQMYHZWI.js} +58 -35
- package/dist/{chunk-SJJKWZYZ.js → chunk-TZVDMWQX.js} +1 -1
- package/dist/constants.cjs +1 -1
- package/dist/constants.js +1 -1
- package/dist/eventcatalog.cjs +207 -123
- package/dist/eventcatalog.config.d.cts +55 -0
- package/dist/eventcatalog.config.d.ts +55 -0
- package/dist/eventcatalog.js +39 -19
- 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/federation/federate.js +4 -4
- 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 +3 -3
- package/dist/{chunk-AYNZXUB5.js → chunk-ZEY2XUJN.js} +7 -7
|
@@ -1,13 +1,26 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
memo,
|
|
4
|
+
useCallback,
|
|
5
|
+
useContext,
|
|
6
|
+
useEffect,
|
|
7
|
+
useRef,
|
|
8
|
+
useState,
|
|
9
|
+
type PointerEvent as ReactPointerEvent,
|
|
10
|
+
type ReactNode,
|
|
11
|
+
} from 'react';
|
|
12
|
+
import { createPortal } from 'react-dom';
|
|
2
13
|
import { useInternalNode, useReactFlow, useStore, ViewportPortal, type Node, type XYPosition } from '@xyflow/react';
|
|
3
|
-
import { Check, MessageCircle, RotateCcw, Trash2, X } from 'lucide-react';
|
|
14
|
+
import { Bot, Check, Maximize2, ClipboardPaste, Copy, CopyPlus, MessageCircle, Pencil, RotateCcw, Trash2, X } from 'lucide-react';
|
|
4
15
|
import { getAbsolutePosition } from '../grouping';
|
|
5
16
|
import type { Author, CommentAnchor, Thread } from '../hooks/use-comments';
|
|
17
|
+
import { isWrittenOnCanvas } from '../node-types';
|
|
6
18
|
import { STATUS } from './status';
|
|
19
|
+
import Picture from './Picture';
|
|
7
20
|
|
|
8
21
|
const initials = (name: string) => name.slice(0, 2).toUpperCase();
|
|
9
22
|
|
|
10
|
-
const timeAgo = (timestamp: number) => {
|
|
23
|
+
export const timeAgo = (timestamp: number) => {
|
|
11
24
|
const seconds = Math.round((Date.now() - timestamp) / 1000);
|
|
12
25
|
if (seconds < 60) return 'just now';
|
|
13
26
|
const minutes = Math.round(seconds / 60);
|
|
@@ -38,37 +51,86 @@ const DRAG_THRESHOLD_PX = 4;
|
|
|
38
51
|
const panelClass =
|
|
39
52
|
'nowheel nodrag nopan absolute z-40 w-72 rounded-xl border shadow-xl bg-[rgb(var(--ec-card-bg))] border-[rgb(var(--ec-page-border))] text-[rgb(var(--ec-page-text))]';
|
|
40
53
|
|
|
54
|
+
/** The people in a thread, in the order they joined it (the pin shows the first few, like Figma) */
|
|
55
|
+
const participantsOf = (thread: Thread): Author[] => {
|
|
56
|
+
const seen = new Map<string, Author>([[thread.author.name, thread.author]]);
|
|
57
|
+
for (const message of thread.messages) if (!seen.has(message.author.name)) seen.set(message.author.name, message.author);
|
|
58
|
+
return [...seen.values()];
|
|
59
|
+
};
|
|
60
|
+
const MAX_PIN_AVATARS = 3;
|
|
61
|
+
/** How long the pointer rests on a pin before it opens its preview (passing over pins leaves them closed) */
|
|
62
|
+
const PREVIEW_DELAY_MS = 500;
|
|
63
|
+
const repliesLabel = (count: number) => (count === 1 ? '1 reply' : `${count} replies`);
|
|
64
|
+
|
|
65
|
+
function Avatar({ author, muted, size = 'h-6 w-6' }: { author: Author; muted?: boolean; size?: string }) {
|
|
66
|
+
return (
|
|
67
|
+
<span
|
|
68
|
+
title={author.name}
|
|
69
|
+
className={`relative flex ${size} shrink-0 items-center justify-center rounded-full text-[10px] font-semibold text-white ring-2 ring-[rgb(var(--ec-card-bg))]`}
|
|
70
|
+
style={{ backgroundColor: muted ? 'rgb(var(--ec-page-text-muted))' : author.color }}
|
|
71
|
+
>
|
|
72
|
+
{author.agent ? <Bot size={13} /> : initials(author.name)}
|
|
73
|
+
<Picture src={author.picture} className={`absolute inset-0 h-full w-full ${muted ? 'opacity-50 grayscale' : ''}`} />
|
|
74
|
+
</span>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
41
78
|
/**
|
|
42
|
-
* A comment pin
|
|
43
|
-
* (
|
|
79
|
+
* A comment pin, like Figma's: a speech bubble with the people in the thread, that opens into a preview of it
|
|
80
|
+
* (who started it, when, what they said and how many replies) once the pointer rests on it. Click opens the
|
|
81
|
+
* thread, dragging moves it: `onDrag` gets the screen point the pin's tip (its bottom left corner) is over,
|
|
82
|
+
* keeping where on the pin it was grabbed.
|
|
44
83
|
*/
|
|
45
84
|
function Pin({
|
|
46
85
|
author,
|
|
86
|
+
thread,
|
|
47
87
|
active,
|
|
88
|
+
selected,
|
|
48
89
|
resolved,
|
|
49
90
|
onClick,
|
|
50
91
|
onDrag,
|
|
51
92
|
}: {
|
|
52
93
|
author: Author;
|
|
94
|
+
/** The thread it's for (a new comment being written has none yet) */
|
|
95
|
+
thread?: Thread;
|
|
53
96
|
active?: boolean;
|
|
97
|
+
/** Part of what's selected on the canvas (e.g. after selecting everything) */
|
|
98
|
+
selected?: boolean;
|
|
54
99
|
resolved?: boolean;
|
|
55
100
|
onClick?: () => void;
|
|
56
101
|
onDrag?: (tip: XYPosition, phase: 'move' | 'end') => void;
|
|
57
102
|
}) {
|
|
58
103
|
const drag = useRef<{ start: XYPosition; grab: XYPosition; moved: boolean } | null>(null);
|
|
104
|
+
const [hovered, setHovered] = useState(false);
|
|
105
|
+
const hoverTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
|
|
106
|
+
useEffect(() => () => clearTimeout(hoverTimer.current), []);
|
|
107
|
+
const [pressed, setPressed] = useState(false);
|
|
59
108
|
const tipOf = (event: { clientX: number; clientY: number }) => ({
|
|
60
109
|
x: event.clientX - drag.current!.grab.x,
|
|
61
110
|
y: event.clientY - drag.current!.grab.y,
|
|
62
111
|
});
|
|
112
|
+
const people = thread ? participantsOf(thread) : [author];
|
|
113
|
+
const [first, ...rest] = thread?.messages ?? [];
|
|
114
|
+
// The preview shows while the pointer is over the pin, not while the thread is open or the pin is dragged
|
|
115
|
+
const expanded = !!thread && !!first && hovered && !active && !pressed;
|
|
63
116
|
|
|
64
117
|
return (
|
|
65
118
|
<button
|
|
119
|
+
onPointerEnter={() => {
|
|
120
|
+
clearTimeout(hoverTimer.current);
|
|
121
|
+
hoverTimer.current = setTimeout(() => setHovered(true), PREVIEW_DELAY_MS);
|
|
122
|
+
}}
|
|
123
|
+
onPointerLeave={() => {
|
|
124
|
+
clearTimeout(hoverTimer.current);
|
|
125
|
+
setHovered(false);
|
|
126
|
+
}}
|
|
66
127
|
onPointerDown={(event) => {
|
|
67
128
|
if (event.button !== 0) return;
|
|
68
129
|
// Keeps pointer events coming while the pointer is outside the pin (throws if the pointer has gone)
|
|
69
130
|
try {
|
|
70
131
|
event.currentTarget.setPointerCapture(event.pointerId);
|
|
71
132
|
} catch {}
|
|
133
|
+
setPressed(true);
|
|
72
134
|
const rect = event.currentTarget.getBoundingClientRect();
|
|
73
135
|
drag.current = {
|
|
74
136
|
start: { x: event.clientX, y: event.clientY },
|
|
@@ -86,21 +148,62 @@ function Pin({
|
|
|
86
148
|
}}
|
|
87
149
|
onPointerUp={(event) => {
|
|
88
150
|
const current = drag.current;
|
|
151
|
+
setPressed(false);
|
|
89
152
|
if (!current) return;
|
|
90
153
|
if (current.moved) onDrag?.(tipOf(event), 'end');
|
|
91
154
|
else onClick?.();
|
|
92
155
|
drag.current = null;
|
|
93
156
|
}}
|
|
94
|
-
onPointerCancel={() =>
|
|
157
|
+
onPointerCancel={() => {
|
|
158
|
+
drag.current = null;
|
|
159
|
+
setPressed(false);
|
|
160
|
+
}}
|
|
95
161
|
// Keyboard activation (pointer clicks are handled on pointer up)
|
|
96
162
|
onClick={(event) => event.detail === 0 && onClick?.()}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
|
|
163
|
+
aria-label={
|
|
164
|
+
thread ? `Comment by ${thread.author.name}${rest.length ? `, ${repliesLabel(rest.length)}` : ''}` : 'New comment'
|
|
165
|
+
}
|
|
166
|
+
className={`block cursor-grab rounded-[18px] rounded-bl-none border p-1 text-left shadow-lg transition-[box-shadow,transform] active:cursor-grabbing bg-[rgb(var(--ec-card-bg))] border-[rgb(var(--ec-page-border))] text-[rgb(var(--ec-page-text))] ${
|
|
167
|
+
active ? 'ring-2 ring-[rgb(var(--ec-accent))]' : ''
|
|
168
|
+
} ${expanded ? 'w-64' : ''}`}
|
|
169
|
+
// Selected: a ring in the theme's button colour (strong in light and dark themes)
|
|
170
|
+
style={selected ? { boxShadow: '0 0 0 2px rgb(var(--ec-card-bg)), 0 0 0 4px rgb(var(--ec-button-bg))' } : undefined}
|
|
102
171
|
>
|
|
103
|
-
{
|
|
172
|
+
{expanded ? (
|
|
173
|
+
<span className="block space-y-1 px-1.5 py-1">
|
|
174
|
+
<span className="flex items-center gap-2">
|
|
175
|
+
<span className="flex -space-x-0.5">
|
|
176
|
+
{people.slice(0, MAX_PIN_AVATARS).map((person) => (
|
|
177
|
+
<Avatar key={person.name} author={person} muted={resolved} size="h-5 w-5" />
|
|
178
|
+
))}
|
|
179
|
+
</span>
|
|
180
|
+
<span className="min-w-0 truncate text-xs font-semibold">{thread.author.name}</span>
|
|
181
|
+
<span className="shrink-0 text-[11px] text-[rgb(var(--ec-page-text-muted))]">{timeAgo(first.createdAt)}</span>
|
|
182
|
+
</span>
|
|
183
|
+
<span className="line-clamp-3 block whitespace-pre-wrap break-words text-xs">{first.text}</span>
|
|
184
|
+
{(rest.length > 0 || resolved) && (
|
|
185
|
+
<span className="flex items-center gap-2 text-[11px] font-medium text-[rgb(var(--ec-page-text-muted))]">
|
|
186
|
+
{rest.length > 0 && <span>{repliesLabel(rest.length)}</span>}
|
|
187
|
+
{resolved && (
|
|
188
|
+
<span className={`flex items-center gap-0.5 ${STATUS.success.text}`}>
|
|
189
|
+
<Check size={11} /> Resolved
|
|
190
|
+
</span>
|
|
191
|
+
)}
|
|
192
|
+
</span>
|
|
193
|
+
)}
|
|
194
|
+
</span>
|
|
195
|
+
) : (
|
|
196
|
+
<span className="flex -space-x-1">
|
|
197
|
+
{people.slice(0, MAX_PIN_AVATARS).map((person) => (
|
|
198
|
+
<Avatar key={person.name} author={person} muted={resolved} />
|
|
199
|
+
))}
|
|
200
|
+
{people.length > MAX_PIN_AVATARS && (
|
|
201
|
+
<span className="flex h-6 w-6 items-center justify-center rounded-full text-[10px] font-semibold ring-2 ring-[rgb(var(--ec-card-bg))] bg-[rgb(var(--ec-page-border))]">
|
|
202
|
+
+{people.length - MAX_PIN_AVATARS}
|
|
203
|
+
</span>
|
|
204
|
+
)}
|
|
205
|
+
</span>
|
|
206
|
+
)}
|
|
104
207
|
</button>
|
|
105
208
|
);
|
|
106
209
|
}
|
|
@@ -161,27 +264,106 @@ function IconButton({ title, onClick, children }: { title: string; onClick: () =
|
|
|
161
264
|
);
|
|
162
265
|
}
|
|
163
266
|
|
|
267
|
+
/** Room kept between a thread's box and the edges of the canvas */
|
|
268
|
+
const PANEL_MARGIN_PX = 12;
|
|
269
|
+
/** A thread's box is at most this tall: longer threads scroll (or open bigger, in a dialog) */
|
|
270
|
+
const PANEL_MAX_HEIGHT_PX = 360;
|
|
271
|
+
/** What drags a thread's box around (its header) */
|
|
272
|
+
const DragPanelContext = createContext<((event: ReactPointerEvent<HTMLElement>) => void) | null>(null);
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* The box a thread (or a new comment) opens in, beside its pin. It opens moved up as far as needed to be fully
|
|
276
|
+
* on the canvas, and can be dragged around by its header to see more of the canvas or of a long thread. Where it's
|
|
277
|
+
* been moved is only here, and goes when it closes.
|
|
278
|
+
*/
|
|
279
|
+
function FloatingPanel({ children }: { children: ReactNode }) {
|
|
280
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
281
|
+
const [offset, setOffset] = useState<XYPosition>({ x: 0, y: 0 });
|
|
282
|
+
const offsetRef = useRef(offset);
|
|
283
|
+
offsetRef.current = offset;
|
|
284
|
+
|
|
285
|
+
// Opened low on the canvas, or grown past the bottom of it (replies): moved up so its bottom (and the reply box)
|
|
286
|
+
// can be seen
|
|
287
|
+
useEffect(() => {
|
|
288
|
+
const element = ref.current;
|
|
289
|
+
if (!element) return;
|
|
290
|
+
const fit = () => {
|
|
291
|
+
const rect = element.getBoundingClientRect();
|
|
292
|
+
const canvas = element.closest('.react-flow')?.getBoundingClientRect() ?? { top: 0, bottom: window.innerHeight };
|
|
293
|
+
const up = Math.min(rect.bottom - (canvas.bottom - PANEL_MARGIN_PX), rect.top - (canvas.top + PANEL_MARGIN_PX));
|
|
294
|
+
if (up > 0) setOffset((current) => ({ x: current.x, y: current.y - up }));
|
|
295
|
+
};
|
|
296
|
+
const observer = new ResizeObserver(fit);
|
|
297
|
+
observer.observe(element);
|
|
298
|
+
return () => observer.disconnect();
|
|
299
|
+
}, []);
|
|
300
|
+
|
|
301
|
+
// Pin and box are counter-scaled with the zoom, so the box moves by as much as the pointer does on screen
|
|
302
|
+
const startDrag = useCallback((event: ReactPointerEvent<HTMLElement>) => {
|
|
303
|
+
if (event.button !== 0 || (event.target as Element).closest('button')) return;
|
|
304
|
+
event.preventDefault();
|
|
305
|
+
const element = event.currentTarget;
|
|
306
|
+
element.setPointerCapture(event.pointerId);
|
|
307
|
+
const start = { x: event.clientX, y: event.clientY };
|
|
308
|
+
const from = offsetRef.current;
|
|
309
|
+
const move = (moveEvent: PointerEvent) =>
|
|
310
|
+
setOffset({ x: from.x + moveEvent.clientX - start.x, y: from.y + moveEvent.clientY - start.y });
|
|
311
|
+
const end = () => {
|
|
312
|
+
element.removeEventListener('pointermove', move);
|
|
313
|
+
element.removeEventListener('pointerup', end);
|
|
314
|
+
element.removeEventListener('pointercancel', end);
|
|
315
|
+
};
|
|
316
|
+
element.addEventListener('pointermove', move);
|
|
317
|
+
element.addEventListener('pointerup', end);
|
|
318
|
+
element.addEventListener('pointercancel', end);
|
|
319
|
+
}, []);
|
|
320
|
+
|
|
321
|
+
return (
|
|
322
|
+
<div
|
|
323
|
+
ref={ref}
|
|
324
|
+
className={`${panelClass} flex flex-col`}
|
|
325
|
+
style={{
|
|
326
|
+
left: `calc(100% + 8px + ${offset.x}px)`,
|
|
327
|
+
top: offset.y,
|
|
328
|
+
maxHeight: PANEL_MAX_HEIGHT_PX,
|
|
329
|
+
}}
|
|
330
|
+
>
|
|
331
|
+
<DragPanelContext.Provider value={startDrag}>{children}</DragPanelContext.Provider>
|
|
332
|
+
</div>
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
|
|
164
336
|
function ThreadPopover({
|
|
165
337
|
thread,
|
|
166
338
|
onReply,
|
|
167
339
|
onResolve,
|
|
168
340
|
onDelete,
|
|
169
341
|
onClose,
|
|
342
|
+
onExpand,
|
|
170
343
|
}: {
|
|
171
344
|
thread: Thread;
|
|
172
345
|
onReply: (text: string) => void;
|
|
173
346
|
onResolve: (resolved: boolean) => void;
|
|
174
347
|
onDelete: () => void;
|
|
175
348
|
onClose: () => void;
|
|
349
|
+
/** Open it bigger, in a dialog (not there when it's already in one) */
|
|
350
|
+
onExpand?: () => void;
|
|
176
351
|
}) {
|
|
177
352
|
const listRef = useRef<HTMLDivElement>(null);
|
|
353
|
+
const startDrag = useContext(DragPanelContext);
|
|
178
354
|
useEffect(() => {
|
|
179
355
|
listRef.current?.scrollTo({ top: listRef.current.scrollHeight });
|
|
180
356
|
}, [thread.messages.length]);
|
|
181
357
|
|
|
182
358
|
return (
|
|
183
359
|
<>
|
|
184
|
-
<div
|
|
360
|
+
<div
|
|
361
|
+
onPointerDown={startDrag ?? undefined}
|
|
362
|
+
title={startDrag ? 'Drag to move' : undefined}
|
|
363
|
+
className={`flex shrink-0 select-none items-center justify-between border-b px-3 py-2 border-[rgb(var(--ec-page-border))] ${
|
|
364
|
+
startDrag ? 'cursor-move' : ''
|
|
365
|
+
}`}
|
|
366
|
+
>
|
|
185
367
|
<span className="text-xs font-semibold">{thread.resolved ? 'Resolved thread' : 'Thread'}</span>
|
|
186
368
|
<span className="flex items-center gap-0.5">
|
|
187
369
|
<IconButton title={thread.resolved ? 'Reopen' : 'Resolve'} onClick={() => onResolve(!thread.resolved)}>
|
|
@@ -190,19 +372,25 @@ function ThreadPopover({
|
|
|
190
372
|
<IconButton title="Delete thread" onClick={onDelete}>
|
|
191
373
|
<Trash2 size={14} />
|
|
192
374
|
</IconButton>
|
|
375
|
+
{onExpand && (
|
|
376
|
+
<IconButton title="Open bigger" onClick={onExpand}>
|
|
377
|
+
<Maximize2 size={14} />
|
|
378
|
+
</IconButton>
|
|
379
|
+
)}
|
|
193
380
|
<IconButton title="Close" onClick={onClose}>
|
|
194
381
|
<X size={14} />
|
|
195
382
|
</IconButton>
|
|
196
383
|
</span>
|
|
197
384
|
</div>
|
|
198
|
-
<div ref={listRef} className="
|
|
385
|
+
<div ref={listRef} className="min-h-0 flex-1 space-y-3 overflow-y-auto px-3 py-3">
|
|
199
386
|
{thread.messages.map((message) => (
|
|
200
387
|
<div key={message.id} className="flex gap-2">
|
|
201
388
|
<span
|
|
202
|
-
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-[10px] font-semibold text-white"
|
|
389
|
+
className="relative flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-[10px] font-semibold text-white"
|
|
203
390
|
style={{ backgroundColor: message.author.color }}
|
|
204
391
|
>
|
|
205
|
-
{initials(message.author.name)}
|
|
392
|
+
{message.author.agent ? <Bot size={13} /> : initials(message.author.name)}
|
|
393
|
+
<Picture src={message.author.picture} />
|
|
206
394
|
</span>
|
|
207
395
|
<div className="min-w-0">
|
|
208
396
|
<p className="text-xs">
|
|
@@ -214,13 +402,62 @@ function ThreadPopover({
|
|
|
214
402
|
</div>
|
|
215
403
|
))}
|
|
216
404
|
</div>
|
|
217
|
-
<div className="border-t border-[rgb(var(--ec-page-border))]">
|
|
405
|
+
<div className="shrink-0 border-t border-[rgb(var(--ec-page-border))]">
|
|
218
406
|
<Composer placeholder="Reply" onSubmit={onReply} onCancel={onClose} />
|
|
219
407
|
</div>
|
|
220
408
|
</>
|
|
221
409
|
);
|
|
222
410
|
}
|
|
223
411
|
|
|
412
|
+
/** A thread opened bigger, in a dialog over the page (for long threads) */
|
|
413
|
+
function ThreadDialog({
|
|
414
|
+
thread,
|
|
415
|
+
onReply,
|
|
416
|
+
onResolve,
|
|
417
|
+
onDelete,
|
|
418
|
+
onShrink,
|
|
419
|
+
onClose,
|
|
420
|
+
}: {
|
|
421
|
+
thread: Thread;
|
|
422
|
+
onReply: (text: string) => void;
|
|
423
|
+
onResolve: (resolved: boolean) => void;
|
|
424
|
+
onDelete: () => void;
|
|
425
|
+
/** Back to the box beside its pin */
|
|
426
|
+
onShrink: () => void;
|
|
427
|
+
onClose: () => void;
|
|
428
|
+
}) {
|
|
429
|
+
useEffect(() => {
|
|
430
|
+
const onKeyDown = (event: KeyboardEvent) => event.key === 'Escape' && onShrink();
|
|
431
|
+
window.addEventListener('keydown', onKeyDown);
|
|
432
|
+
return () => window.removeEventListener('keydown', onKeyDown);
|
|
433
|
+
}, [onShrink]);
|
|
434
|
+
return (
|
|
435
|
+
<div
|
|
436
|
+
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
|
|
437
|
+
// It's in the canvas's React tree (a portal), so its events would reach the canvas: they stop here (clicks in
|
|
438
|
+
// the dialog stop at it)
|
|
439
|
+
onClick={(event) => {
|
|
440
|
+
event.stopPropagation();
|
|
441
|
+
onShrink();
|
|
442
|
+
}}
|
|
443
|
+
onPointerDown={(event) => event.stopPropagation()}
|
|
444
|
+
onMouseDown={(event) => event.stopPropagation()}
|
|
445
|
+
onDoubleClick={(event) => event.stopPropagation()}
|
|
446
|
+
onContextMenu={(event) => event.stopPropagation()}
|
|
447
|
+
onWheel={(event) => event.stopPropagation()}
|
|
448
|
+
>
|
|
449
|
+
<div
|
|
450
|
+
role="dialog"
|
|
451
|
+
aria-label="Comment thread"
|
|
452
|
+
onClick={(event) => event.stopPropagation()}
|
|
453
|
+
className="flex max-h-[80vh] w-full max-w-xl flex-col rounded-xl border shadow-2xl bg-[rgb(var(--ec-card-bg))] border-[rgb(var(--ec-page-border))] text-[rgb(var(--ec-page-text))]"
|
|
454
|
+
>
|
|
455
|
+
<ThreadPopover thread={thread} onReply={onReply} onResolve={onResolve} onDelete={onDelete} onClose={onClose} />
|
|
456
|
+
</div>
|
|
457
|
+
</div>
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
|
|
224
461
|
/** Where a pin goes on the canvas, following only the node it's on (if it's on one), counter-scaled */
|
|
225
462
|
function PinAt({ anchor, zoom, zIndex, children }: { anchor: CommentAnchor; zoom: number; zIndex: number; children: ReactNode }) {
|
|
226
463
|
const node = useInternalNode(anchor.nodeId ?? '');
|
|
@@ -228,8 +465,8 @@ function PinAt({ anchor, zoom, zIndex, children }: { anchor: CommentAnchor; zoom
|
|
|
228
465
|
const { x, y } = origin && anchor.offset ? { x: origin.x + anchor.offset.x, y: origin.y + anchor.offset.y } : anchor.position;
|
|
229
466
|
return (
|
|
230
467
|
<div className="absolute" style={{ left: x, top: y, transform: `scale(${1 / zoom})`, transformOrigin: '0 0', zIndex }}>
|
|
231
|
-
{/* The pin's tip (bottom left corner) sits on the anchor point */}
|
|
232
|
-
<div className="nopan nodrag nowheel pointer-events-auto absolute
|
|
468
|
+
{/* The pin's tip (bottom left corner) sits on the anchor point, and it grows up and right as it opens */}
|
|
469
|
+
<div className="nopan nodrag nowheel pointer-events-auto absolute bottom-0 left-0">{children}</div>
|
|
233
470
|
</div>
|
|
234
471
|
);
|
|
235
472
|
}
|
|
@@ -253,10 +490,16 @@ export const CommentLayer = memo(function CommentLayer({
|
|
|
253
490
|
onDelete,
|
|
254
491
|
onMove,
|
|
255
492
|
onMoveDraft,
|
|
493
|
+
selectedIds,
|
|
494
|
+
selectionOffset,
|
|
256
495
|
}: {
|
|
257
496
|
threads: Thread[];
|
|
258
497
|
author: Author;
|
|
259
498
|
openThreadId: string | null;
|
|
499
|
+
/** Threads that are part of what's selected */
|
|
500
|
+
selectedIds: ReadonlySet<string>;
|
|
501
|
+
/** How far what's selected has been dragged: selected pins not on a node move with it */
|
|
502
|
+
selectionOffset: XYPosition | null;
|
|
260
503
|
draft: CommentAnchor | null;
|
|
261
504
|
showResolved: boolean;
|
|
262
505
|
onOpen: (threadId: string | null) => void;
|
|
@@ -272,6 +515,9 @@ export const CommentLayer = memo(function CommentLayer({
|
|
|
272
515
|
const { screenToFlowPosition, getInternalNode } = useReactFlow();
|
|
273
516
|
// A pin being dragged stays here until it's dropped, then moves for everyone
|
|
274
517
|
const [moving, setMoving] = useState<{ threadId: string; anchor: CommentAnchor } | null>(null);
|
|
518
|
+
// The open thread, shown bigger in a dialog
|
|
519
|
+
const [expandedId, setExpandedId] = useState<string | null>(null);
|
|
520
|
+
const expanded = expandedId && expandedId === openThreadId ? threads.find((thread) => thread.id === expandedId) : undefined;
|
|
275
521
|
|
|
276
522
|
// While dragging, a pin follows the pointer. Dropped on a node it attaches to it (and moves with it),
|
|
277
523
|
// anywhere else it stays on the canvas.
|
|
@@ -299,13 +545,21 @@ export const CommentLayer = memo(function CommentLayer({
|
|
|
299
545
|
return (
|
|
300
546
|
<PinAt
|
|
301
547
|
key={thread.id}
|
|
302
|
-
anchor={
|
|
548
|
+
anchor={
|
|
549
|
+
moving?.threadId === thread.id
|
|
550
|
+
? moving.anchor
|
|
551
|
+
: selectionOffset && !thread.nodeId && selectedIds.has(thread.id)
|
|
552
|
+
? { position: { x: thread.position.x + selectionOffset.x, y: thread.position.y + selectionOffset.y } }
|
|
553
|
+
: thread
|
|
554
|
+
}
|
|
303
555
|
zoom={zoom}
|
|
304
556
|
zIndex={isOpen ? 2 : 1}
|
|
305
557
|
>
|
|
306
558
|
<Pin
|
|
307
559
|
author={thread.author}
|
|
560
|
+
thread={thread}
|
|
308
561
|
active={isOpen}
|
|
562
|
+
selected={selectedIds.has(thread.id)}
|
|
309
563
|
resolved={thread.resolved}
|
|
310
564
|
onClick={() => onOpen(isOpen ? null : thread.id)}
|
|
311
565
|
onDrag={(tip, phase) => {
|
|
@@ -314,27 +568,48 @@ export const CommentLayer = memo(function CommentLayer({
|
|
|
314
568
|
setMoving(null);
|
|
315
569
|
}}
|
|
316
570
|
/>
|
|
317
|
-
{
|
|
318
|
-
|
|
571
|
+
{/* (not while it's open bigger, in the dialog) */}
|
|
572
|
+
{isOpen && expanded?.id !== thread.id && (
|
|
573
|
+
<FloatingPanel>
|
|
319
574
|
<ThreadPopover
|
|
320
575
|
thread={thread}
|
|
321
576
|
onReply={(text) => onReply(thread.id, text)}
|
|
322
577
|
onResolve={(resolved) => onResolve(thread.id, resolved)}
|
|
323
578
|
onDelete={() => onDelete(thread.id)}
|
|
324
579
|
onClose={() => onOpen(null)}
|
|
580
|
+
onExpand={() => setExpandedId(thread.id)}
|
|
325
581
|
/>
|
|
326
|
-
</
|
|
582
|
+
</FloatingPanel>
|
|
327
583
|
)}
|
|
328
584
|
</PinAt>
|
|
329
585
|
);
|
|
330
586
|
})}
|
|
331
587
|
|
|
588
|
+
{expanded &&
|
|
589
|
+
createPortal(
|
|
590
|
+
<ThreadDialog
|
|
591
|
+
thread={expanded}
|
|
592
|
+
onReply={(text) => onReply(expanded.id, text)}
|
|
593
|
+
onResolve={(resolved) => onResolve(expanded.id, resolved)}
|
|
594
|
+
onDelete={() => {
|
|
595
|
+
setExpandedId(null);
|
|
596
|
+
onDelete(expanded.id);
|
|
597
|
+
}}
|
|
598
|
+
onShrink={() => setExpandedId(null)}
|
|
599
|
+
onClose={() => {
|
|
600
|
+
setExpandedId(null);
|
|
601
|
+
onOpen(null);
|
|
602
|
+
}}
|
|
603
|
+
/>,
|
|
604
|
+
document.body
|
|
605
|
+
)}
|
|
606
|
+
|
|
332
607
|
{draft && (
|
|
333
608
|
<PinAt anchor={draft} zoom={zoom} zIndex={3}>
|
|
334
609
|
<Pin author={author} active onDrag={(tip, phase) => onMoveDraft(anchorFor(tip, phase))} />
|
|
335
|
-
<
|
|
610
|
+
<FloatingPanel>
|
|
336
611
|
<Composer placeholder="Add a comment" onSubmit={onCreate} onCancel={onCancelDraft} />
|
|
337
|
-
</
|
|
612
|
+
</FloatingPanel>
|
|
338
613
|
</PinAt>
|
|
339
614
|
)}
|
|
340
615
|
</div>
|
|
@@ -347,12 +622,24 @@ export type ContextMenuState = { x: number; y: number; flowPosition: XYPosition;
|
|
|
347
622
|
export const CanvasContextMenu = memo(function CanvasContextMenu({
|
|
348
623
|
menu,
|
|
349
624
|
onAddComment,
|
|
625
|
+
onEditDetails,
|
|
626
|
+
onCopy,
|
|
627
|
+
onDuplicate,
|
|
628
|
+
onPaste,
|
|
350
629
|
onDeleteNode,
|
|
351
630
|
onClose,
|
|
352
631
|
}: {
|
|
353
632
|
menu: ContextMenuState;
|
|
354
633
|
onAddComment: () => void;
|
|
355
|
-
|
|
634
|
+
/** Open the node's details in the panel (not for notes, which are edited on the canvas) */
|
|
635
|
+
onEditDetails: () => void;
|
|
636
|
+
/** Copy the node (or the selection it's in) */
|
|
637
|
+
onCopy: () => void;
|
|
638
|
+
onDuplicate: () => void;
|
|
639
|
+
/** Paste what was last copied where the menu was opened, if anything was */
|
|
640
|
+
onPaste?: () => void;
|
|
641
|
+
/** Delete the node (or the selection it's in) */
|
|
642
|
+
onDeleteNode: () => void;
|
|
356
643
|
onClose: () => void;
|
|
357
644
|
}) {
|
|
358
645
|
useEffect(() => {
|
|
@@ -375,6 +662,7 @@ export const CanvasContextMenu = memo(function CanvasContextMenu({
|
|
|
375
662
|
|
|
376
663
|
const itemClass =
|
|
377
664
|
'flex w-full items-center gap-2 rounded px-2.5 py-1.5 text-left text-xs hover:bg-[rgb(var(--ec-page-border)/0.5)]';
|
|
665
|
+
const shortcutClass = 'ml-auto text-[rgb(var(--ec-page-text-muted))]';
|
|
378
666
|
|
|
379
667
|
return (
|
|
380
668
|
<div
|
|
@@ -386,12 +674,39 @@ export const CanvasContextMenu = memo(function CanvasContextMenu({
|
|
|
386
674
|
<button className={itemClass} onClick={onAddComment}>
|
|
387
675
|
<MessageCircle size={14} />
|
|
388
676
|
Add comment
|
|
389
|
-
<span className=
|
|
677
|
+
<span className={shortcutClass}>C</span>
|
|
390
678
|
</button>
|
|
391
679
|
{menu.node && (
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
680
|
+
<>
|
|
681
|
+
{!isWrittenOnCanvas(menu.node.type) && (
|
|
682
|
+
<button className={itemClass} onClick={onEditDetails}>
|
|
683
|
+
<Pencil size={14} />
|
|
684
|
+
Edit details
|
|
685
|
+
</button>
|
|
686
|
+
)}
|
|
687
|
+
<button className={itemClass} onClick={onCopy}>
|
|
688
|
+
<Copy size={14} />
|
|
689
|
+
Copy
|
|
690
|
+
<span className={shortcutClass}>⌘C</span>
|
|
691
|
+
</button>
|
|
692
|
+
<button className={itemClass} onClick={onDuplicate}>
|
|
693
|
+
<CopyPlus size={14} />
|
|
694
|
+
Duplicate
|
|
695
|
+
<span className={shortcutClass}>⌘D</span>
|
|
696
|
+
</button>
|
|
697
|
+
<div className="my-1 h-px bg-[rgb(var(--ec-page-border))]" />
|
|
698
|
+
<button className={`${itemClass} ${STATUS.danger.text}`} onClick={onDeleteNode}>
|
|
699
|
+
<Trash2 size={14} />
|
|
700
|
+
Delete
|
|
701
|
+
<span className={shortcutClass}>⌫</span>
|
|
702
|
+
</button>
|
|
703
|
+
</>
|
|
704
|
+
)}
|
|
705
|
+
{!menu.node && onPaste && (
|
|
706
|
+
<button className={itemClass} onClick={onPaste}>
|
|
707
|
+
<ClipboardPaste size={14} />
|
|
708
|
+
Paste here
|
|
709
|
+
<span className={shortcutClass}>⌘V</span>
|
|
395
710
|
</button>
|
|
396
711
|
)}
|
|
397
712
|
</div>
|