@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.
Files changed (95) hide show
  1. package/LICENSE +227 -128
  2. package/dist/analytics/analytics.cjs +18 -3
  3. package/dist/analytics/analytics.d.cts +1 -1
  4. package/dist/analytics/analytics.d.ts +1 -1
  5. package/dist/analytics/analytics.js +3 -2
  6. package/dist/analytics/log-build.cjs +70 -34
  7. package/dist/analytics/log-build.js +5 -4
  8. package/dist/analytics/telemetry.cjs +42 -0
  9. package/dist/analytics/telemetry.d.cts +20 -0
  10. package/dist/analytics/telemetry.d.ts +20 -0
  11. package/dist/analytics/telemetry.js +8 -0
  12. package/dist/{chunk-3VBB2ZMP.js → chunk-4CFZUHGG.js} +9 -3
  13. package/dist/{chunk-PIYJM642.js → chunk-AWOO63ME.js} +70 -44
  14. package/dist/chunk-CPW6G62V.js +17 -0
  15. package/dist/{chunk-MDWB5HLO.js → chunk-ENAG6UH7.js} +5 -0
  16. package/dist/{chunk-FARBIZ7Y.js → chunk-EUNQJO3J.js} +1 -1
  17. package/dist/{chunk-S7ODHVII.js → chunk-GAHKCOQM.js} +1 -1
  18. package/dist/{chunk-BOKAG5XC.js → chunk-JQMYHZWI.js} +58 -35
  19. package/dist/{chunk-SJJKWZYZ.js → chunk-TZVDMWQX.js} +1 -1
  20. package/dist/constants.cjs +1 -1
  21. package/dist/constants.js +1 -1
  22. package/dist/eventcatalog.cjs +207 -123
  23. package/dist/eventcatalog.config.d.cts +55 -0
  24. package/dist/eventcatalog.config.d.ts +55 -0
  25. package/dist/eventcatalog.js +39 -19
  26. package/dist/features.cjs +8 -2
  27. package/dist/features.d.cts +3 -1
  28. package/dist/features.d.ts +3 -1
  29. package/dist/features.js +5 -3
  30. package/dist/federation/federate.js +4 -4
  31. package/dist/generate.cjs +1 -1
  32. package/dist/generate.js +3 -3
  33. package/dist/utils/cli-logger.cjs +1 -1
  34. package/dist/utils/cli-logger.js +2 -2
  35. package/dist/utils/license-status.cjs +70 -44
  36. package/dist/utils/license-status.d.cts +14 -5
  37. package/dist/utils/license-status.d.ts +14 -5
  38. package/dist/utils/license-status.js +1 -1
  39. package/eventcatalog/integrations/studio-server.mjs +15 -3
  40. package/eventcatalog/src/components/Header.astro +3 -3
  41. package/eventcatalog/src/features/integrations/eventcatalog-features.ts +10 -2
  42. package/eventcatalog/src/features/mcp/apps/canvas/shared.ts +9 -1
  43. package/eventcatalog/src/features/mcp/apps/canvas/view.tsx +9 -2
  44. package/eventcatalog/src/features/mcp/apps/generated/canvas.html +70 -69
  45. package/eventcatalog/src/features/mcp/apps/generated/viewer.html +69 -69
  46. package/eventcatalog/src/features/mcp/mcp-server.ts +5 -3
  47. package/eventcatalog/src/features/studio/agent-choreography.ts +8 -5
  48. package/eventcatalog/src/features/studio/canvas-actions.ts +148 -21
  49. package/eventcatalog/src/features/studio/canvas-doc.ts +138 -9
  50. package/eventcatalog/src/features/studio/canvas-preview.ts +53 -0
  51. package/eventcatalog/src/features/studio/catalog-resources.ts +5 -2
  52. package/eventcatalog/src/features/studio/clipboard.ts +97 -0
  53. package/eventcatalog/src/features/studio/components/CanvasControls.tsx +4 -3
  54. package/eventcatalog/src/features/studio/components/CanvasHeader.tsx +39 -22
  55. package/eventcatalog/src/features/studio/components/CanvasStatusMenu.tsx +117 -0
  56. package/eventcatalog/src/features/studio/components/Comments.tsx +345 -30
  57. package/eventcatalog/src/features/studio/components/ConnectAgentDialog.tsx +140 -76
  58. package/eventcatalog/src/features/studio/components/ConnectionsChooser.tsx +144 -0
  59. package/eventcatalog/src/features/studio/components/ContainerChooser.tsx +1 -1
  60. package/eventcatalog/src/features/studio/components/DragGhost.tsx +115 -0
  61. package/eventcatalog/src/features/studio/components/JoinForm.tsx +0 -24
  62. package/eventcatalog/src/features/studio/components/LeftPanel.tsx +131 -35
  63. package/eventcatalog/src/features/studio/components/Picture.tsx +21 -0
  64. package/eventcatalog/src/features/studio/components/Presence.tsx +2 -0
  65. package/eventcatalog/src/features/studio/components/PropertiesPanel.tsx +187 -50
  66. package/eventcatalog/src/features/studio/components/ShareDialog.tsx +260 -0
  67. package/eventcatalog/src/features/studio/components/StudioDesigner.tsx +442 -40
  68. package/eventcatalog/src/features/studio/components/canvas-nodes.tsx +198 -22
  69. package/eventcatalog/src/features/studio/components/icons.ts +6 -1
  70. package/eventcatalog/src/features/studio/components/status.ts +30 -1
  71. package/eventcatalog/src/features/studio/edges.ts +2 -0
  72. package/eventcatalog/src/features/studio/grouping.ts +95 -2
  73. package/eventcatalog/src/features/studio/hooks/presence-store.ts +5 -1
  74. package/eventcatalog/src/features/studio/hooks/use-canvas-webmcp.ts +13 -3
  75. package/eventcatalog/src/features/studio/hooks/use-comments.ts +8 -1
  76. package/eventcatalog/src/features/studio/hooks/use-follow-camera.ts +7 -2
  77. package/eventcatalog/src/features/studio/hooks/use-studio-flow.ts +174 -27
  78. package/eventcatalog/src/features/studio/identity.ts +35 -0
  79. package/eventcatalog/src/features/studio/layout.ts +7 -4
  80. package/eventcatalog/src/features/studio/levels.ts +138 -72
  81. package/eventcatalog/src/features/studio/node-types.ts +35 -3
  82. package/eventcatalog/src/features/studio/pages/[id].astro +7 -2
  83. package/eventcatalog/src/features/studio/pages/index.astro +555 -2
  84. package/eventcatalog/src/features/studio/pages/new.astro +24 -0
  85. package/eventcatalog/src/features/studio/pages/wake.ts +15 -0
  86. package/eventcatalog/src/features/studio/server/canvas-mcp.ts +48 -13
  87. package/eventcatalog/src/features/studio/server/runtime.ts +193 -14
  88. package/eventcatalog/src/features/studio/server/sign-in.ts +18 -0
  89. package/eventcatalog/src/features/studio/server/storage.ts +110 -0
  90. package/eventcatalog/src/features/studio/studio.css +184 -0
  91. package/eventcatalog/src/layouts/VerticalSideBarLayout.astro +1 -1
  92. package/eventcatalog/src/utils/feature.ts +7 -3
  93. package/eventcatalog/src/utils/node-graphs/export-mermaid.ts +0 -2
  94. package/package.json +3 -3
  95. package/dist/{chunk-AYNZXUB5.js → chunk-ZEY2XUJN.js} +7 -7
@@ -1,13 +1,26 @@
1
- import { memo, useEffect, useRef, useState, type ReactNode } from 'react';
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. Click opens it, dragging moves it: `onDrag` gets the screen point the pin's tip
43
- * (its bottom left corner) is over, keeping where on the pin it was grabbed.
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={() => (drag.current = null)}
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
- className={`flex h-8 w-8 cursor-grab items-center justify-center rounded-full rounded-bl-none border-2 border-[rgb(var(--ec-card-bg))] text-[11px] font-semibold text-white shadow-md transition-transform hover:scale-110 active:cursor-grabbing ${
98
- active ? 'scale-110 ring-2 ring-[rgb(var(--ec-accent))]' : ''
99
- }`}
100
- // Resolved threads are greyed out, in the theme's muted colour
101
- style={{ backgroundColor: resolved ? 'rgb(var(--ec-page-text-muted))' : author.color }}
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
- {initials(author.name)}
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 className="flex items-center justify-between border-b px-3 py-2 border-[rgb(var(--ec-page-border))]">
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="max-h-72 space-y-3 overflow-y-auto px-3 py-3">
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 left-0 -top-8">{children}</div>
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={moving?.threadId === thread.id ? moving.anchor : thread}
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
- {isOpen && (
318
- <div className={panelClass} style={{ left: 40, top: 0 }}>
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
- </div>
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
- <div className={panelClass} style={{ left: 40, top: 0 }}>
610
+ <FloatingPanel>
336
611
  <Composer placeholder="Add a comment" onSubmit={onCreate} onCancel={onCancelDraft} />
337
- </div>
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
- onDeleteNode: (id: string) => void;
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="ml-auto text-[rgb(var(--ec-page-text-muted))]">C</span>
677
+ <span className={shortcutClass}>C</span>
390
678
  </button>
391
679
  {menu.node && (
392
- <button className={`${itemClass} ${STATUS.danger.text}`} onClick={() => onDeleteNode(menu.node!.id)}>
393
- <Trash2 size={14} />
394
- Delete
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>