@eventcatalog/core 5.0.0-beta.5 → 5.0.0-beta.7

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 (84) hide show
  1. package/dist/analytics/analytics.cjs +1 -1
  2. package/dist/analytics/analytics.js +2 -2
  3. package/dist/analytics/log-build.cjs +1 -1
  4. package/dist/analytics/log-build.js +4 -4
  5. package/dist/{chunk-SHHO3JJI.js → chunk-3VBB2ZMP.js} +1 -1
  6. package/dist/{chunk-EBDTVZDW.js → chunk-BOKAG5XC.js} +4 -4
  7. package/dist/{chunk-XQXMSQA5.js → chunk-FARBIZ7Y.js} +1 -1
  8. package/dist/{chunk-43AEF3JE.js → chunk-S7ODHVII.js} +1 -1
  9. package/dist/{chunk-YYMBYOQR.js → chunk-SJJKWZYZ.js} +1 -1
  10. package/dist/constants.cjs +1 -1
  11. package/dist/constants.js +1 -1
  12. package/dist/eventcatalog.cjs +1 -1
  13. package/dist/eventcatalog.js +11 -11
  14. package/dist/federation/federate.js +4 -4
  15. package/dist/generate.cjs +1 -1
  16. package/dist/generate.js +3 -3
  17. package/dist/runtime-dependencies/@hocuspocus__provider.mjs +3 -0
  18. package/dist/runtime-dependencies/@hocuspocus__server.mjs +3 -0
  19. package/dist/runtime-dependencies/@mcp-b__webmcp-local-relay.mjs +3 -0
  20. package/dist/runtime-dependencies/@mcp-b__webmcp-polyfill.mjs +3 -0
  21. package/dist/runtime-dependencies/manifest.json +8 -0
  22. package/dist/runtime-dependencies/react-hotkeys-hook.mjs +3 -0
  23. package/dist/runtime-dependencies/y-protocols.mjs +3 -0
  24. package/dist/runtime-dependencies/y-protocols__awareness.mjs +3 -0
  25. package/dist/runtime-dependencies/yjs.mjs +3 -0
  26. package/dist/utils/cli-logger.cjs +1 -1
  27. package/dist/utils/cli-logger.js +2 -2
  28. package/eventcatalog/astro.config.mjs +2 -0
  29. package/eventcatalog/integrations/studio-server.mjs +17 -0
  30. package/eventcatalog/src/features/integrations/eventcatalog-features.ts +20 -1
  31. package/eventcatalog/src/features/mcp/apps/canvas/shared.ts +39 -0
  32. package/eventcatalog/src/features/mcp/apps/canvas/styles.css +22 -0
  33. package/eventcatalog/src/features/mcp/apps/canvas/view.tsx +269 -0
  34. package/eventcatalog/src/features/mcp/apps/generated/canvas.html +209 -0
  35. package/eventcatalog/src/features/mcp/apps/generated/viewer.html +2 -2
  36. package/eventcatalog/src/features/mcp/apps/shared/app-view.tsx +2 -1
  37. package/eventcatalog/src/features/mcp/mcp-apps.ts +6 -3
  38. package/eventcatalog/src/features/mcp/mcp-server.ts +17 -4
  39. package/eventcatalog/src/features/studio/agent-choreography.ts +378 -0
  40. package/eventcatalog/src/features/studio/canvas-actions.ts +287 -0
  41. package/eventcatalog/src/features/studio/canvas-doc.ts +280 -0
  42. package/eventcatalog/src/features/studio/catalog-resources.ts +152 -0
  43. package/eventcatalog/src/features/studio/catalog.ts +171 -0
  44. package/eventcatalog/src/features/studio/components/CanvasControls.tsx +153 -0
  45. package/eventcatalog/src/features/studio/components/CanvasHeader.tsx +332 -0
  46. package/eventcatalog/src/features/studio/components/Comments.tsx +455 -0
  47. package/eventcatalog/src/features/studio/components/ConnectAgentDialog.tsx +217 -0
  48. package/eventcatalog/src/features/studio/components/ContainerChooser.tsx +145 -0
  49. package/eventcatalog/src/features/studio/components/JoinForm.tsx +58 -0
  50. package/eventcatalog/src/features/studio/components/LeftPanel.tsx +218 -0
  51. package/eventcatalog/src/features/studio/components/Presence.tsx +284 -0
  52. package/eventcatalog/src/features/studio/components/PropertiesPanel.tsx +75 -0
  53. package/eventcatalog/src/features/studio/components/StudioDesigner.tsx +760 -0
  54. package/eventcatalog/src/features/studio/components/canvas-nodes.tsx +79 -0
  55. package/eventcatalog/src/features/studio/components/icons.ts +48 -0
  56. package/eventcatalog/src/features/studio/components/status.ts +22 -0
  57. package/eventcatalog/src/features/studio/edges.ts +54 -0
  58. package/eventcatalog/src/features/studio/flow-state.ts +92 -0
  59. package/eventcatalog/src/features/studio/grouping.ts +133 -0
  60. package/eventcatalog/src/features/studio/hooks/presence-sender.ts +46 -0
  61. package/eventcatalog/src/features/studio/hooks/presence-store.ts +115 -0
  62. package/eventcatalog/src/features/studio/hooks/use-canvas-webmcp.ts +337 -0
  63. package/eventcatalog/src/features/studio/hooks/use-comments.ts +47 -0
  64. package/eventcatalog/src/features/studio/hooks/use-follow-camera.ts +99 -0
  65. package/eventcatalog/src/features/studio/hooks/use-studio-flow.ts +423 -0
  66. package/eventcatalog/src/features/studio/layout.ts +39 -0
  67. package/eventcatalog/src/features/studio/levels.ts +216 -0
  68. package/eventcatalog/src/features/studio/node-types.ts +173 -0
  69. package/eventcatalog/src/features/studio/pages/[id].astro +38 -0
  70. package/eventcatalog/src/features/studio/pages/index.astro +8 -0
  71. package/eventcatalog/src/features/studio/pages/webmcp-relay.ts +27 -0
  72. package/eventcatalog/src/features/studio/placement.ts +197 -0
  73. package/eventcatalog/src/features/studio/server/canvas-mcp.ts +475 -0
  74. package/eventcatalog/src/features/studio/server/runtime.ts +215 -0
  75. package/eventcatalog/src/features/studio/server/tool-icons.ts +30 -0
  76. package/eventcatalog/src/features/studio/studio.css +89 -0
  77. package/eventcatalog/src/features/studio/tool-sync.ts +75 -0
  78. package/eventcatalog/src/layouts/VerticalSideBarLayout.astro +9 -2
  79. package/eventcatalog/src/utils/feature.ts +4 -0
  80. package/package.json +11 -4
  81. package/eventcatalog/public/studio-bg.png +0 -0
  82. package/eventcatalog/src/components/Studio/StudioPageModal.tsx +0 -150
  83. package/eventcatalog/src/pages/studio.astro +0 -316
  84. package/dist/{chunk-ZEY2XUJN.js → chunk-AYNZXUB5.js} +7 -7
@@ -0,0 +1,79 @@
1
+ import { createContext, memo, useCallback, useContext } from 'react';
2
+ import { NodeResizer, type NodeProps, type NodeTypes } from '@xyflow/react';
3
+ import { DomainCardNode, Note, nodeComponents, SystemGroupNode, type NoteNode as NoteNodeType } from '@eventcatalog/visualiser';
4
+ import { GROUP_TYPES, nodeDefinitions } from '../node-types';
5
+
6
+ /** How the canvas draws each kind of node: the visualiser's nodes, editable notes and resizable containers */
7
+
8
+ export type UpdateNodeData = (id: string, updater: (data: Record<string, unknown>) => Record<string, unknown>) => void;
9
+
10
+ // Notes are edited in place, so their callbacks need to reach the shared design
11
+ export const UpdateNodeDataContext = createContext<UpdateNodeData>(() => {});
12
+
13
+ // Which container something dragged would drop into, to highlight it
14
+ export const DropTargetContext = createContext<string | null>(null);
15
+
16
+ // The visualiser types its node components' props as nodes (Node<Data>) rather than React Flow's NodeProps, so
17
+ // they don't type-check as NodeTypes, though that's what React Flow renders them with
18
+ const visualiserNodes = nodeComponents as unknown as NodeTypes;
19
+ const SystemGroupComponent: NodeTypes[string] = SystemGroupNode;
20
+
21
+ const NoteNode = memo(function NoteNode(props: NodeProps<NoteNodeType>) {
22
+ const updateNodeData = useContext(UpdateNodeDataContext);
23
+ const onTextChange = useCallback(
24
+ (id: string, text: string) => updateNodeData(id, (data) => ({ ...data, text })),
25
+ [updateNodeData]
26
+ );
27
+ const onColorChange = useCallback(
28
+ (id: string, color: string) => updateNodeData(id, (data) => ({ ...data, color })),
29
+ [updateNodeData]
30
+ );
31
+ return <Note {...props} onTextChange={onTextChange} onColorChange={onColorChange} />;
32
+ });
33
+
34
+ /**
35
+ * A domain or system as a container: resizable when selected, highlighted when something's dragged over it.
36
+ * Like frames in design tools, it's moved and selected by its header, and dragging inside it draws a selection
37
+ * box (its body lets the pointer through to the canvas: see studio.css).
38
+ */
39
+ const GroupNode = memo(function GroupNode(props: NodeProps) {
40
+ const isDropTarget = useContext(DropTargetContext) === props.id;
41
+ return (
42
+ <div style={{ width: '100%', height: '100%', position: 'relative' }}>
43
+ <NodeResizer
44
+ isVisible={props.selected}
45
+ minWidth={240}
46
+ minHeight={160}
47
+ lineClassName="!border-[rgb(var(--ec-accent))]"
48
+ handleClassName="!h-2.5 !w-2.5 !rounded-sm !border-2 !bg-[rgb(var(--ec-card-bg))] !border-[rgb(var(--ec-accent))]"
49
+ />
50
+ <SystemGroupComponent {...props} />
51
+ {/* The header (and the badge on its border) */}
52
+ <div className="ec-group-grip absolute inset-x-0 -top-3 h-[60px] cursor-grab" />
53
+ {(isDropTarget || props.selected) && (
54
+ <div
55
+ className={`pointer-events-none absolute inset-0 rounded-[14px] ${
56
+ isDropTarget
57
+ ? 'ring-4 ring-[rgb(var(--ec-accent)/0.5)] bg-[rgb(var(--ec-accent)/0.05)]'
58
+ : 'ring-2 ring-[rgb(var(--ec-accent)/0.7)]'
59
+ }`}
60
+ />
61
+ )}
62
+ </div>
63
+ );
64
+ });
65
+
66
+ export const nodeTypes: NodeTypes = {
67
+ ...Object.fromEntries(
68
+ nodeDefinitions.map((definition) => [
69
+ definition.type,
70
+ definition.type === 'note' ? NoteNode : visualiserNodes[definition.type],
71
+ ])
72
+ ),
73
+ // Only dropped in from the catalog
74
+ system: visualiserNodes.system,
75
+ 'context-domain': DomainCardNode,
76
+ // Domains and systems as containers
77
+ [GROUP_TYPES.domain]: GroupNode,
78
+ [GROUP_TYPES.system]: GroupNode,
79
+ };
@@ -0,0 +1,48 @@
1
+ import type { LucideIcon } from 'lucide-react';
2
+ import {
3
+ ArrowRightLeft,
4
+ Bot,
5
+ Boxes,
6
+ Database,
7
+ Globe,
8
+ Group,
9
+ Mail,
10
+ MonitorSmartphone,
11
+ Search,
12
+ ServerIcon,
13
+ StickyNote,
14
+ User,
15
+ Zap,
16
+ } from 'lucide-react';
17
+ import { GROUP_TYPES } from '../node-types';
18
+
19
+ /** How each kind of node, and each catalog collection, looks in the canvas's lists (the colours are each kind's own) */
20
+ export type NodeIcon = { icon: LucideIcon; className: string };
21
+
22
+ export const NODE_ICONS: Record<string, NodeIcon> = {
23
+ [GROUP_TYPES.domain]: { icon: Boxes, className: 'text-yellow-500' },
24
+ [GROUP_TYPES.system]: { icon: Group, className: 'text-violet-600' },
25
+ service: { icon: ServerIcon, className: 'text-pink-600' },
26
+ event: { icon: Zap, className: 'text-orange-600' },
27
+ command: { icon: Mail, className: 'text-blue-600' },
28
+ query: { icon: Search, className: 'text-green-600' },
29
+ channel: { icon: ArrowRightLeft, className: 'text-gray-600' },
30
+ data: { icon: Database, className: 'text-blue-600' },
31
+ view: { icon: MonitorSmartphone, className: 'text-indigo-600' },
32
+ externalSystem: { icon: Globe, className: 'text-purple-600' },
33
+ agent: { icon: Bot, className: 'text-violet-600' },
34
+ actor: { icon: User, className: 'text-sky-600' },
35
+ note: { icon: StickyNote, className: 'text-yellow-500' },
36
+ };
37
+
38
+ /** The catalog's collections, in the order the catalog list shows them, with the icon of the node each becomes */
39
+ export const CATALOG_GROUPS: ({ collection: string; label: string } & NodeIcon)[] = [
40
+ { collection: 'domains', label: 'Domains', ...NODE_ICONS[GROUP_TYPES.domain] },
41
+ { collection: 'systems', label: 'Systems', ...NODE_ICONS[GROUP_TYPES.system] },
42
+ { collection: 'services', label: 'Services', ...NODE_ICONS.service },
43
+ { collection: 'events', label: 'Events', ...NODE_ICONS.event },
44
+ { collection: 'commands', label: 'Commands', ...NODE_ICONS.command },
45
+ { collection: 'queries', label: 'Queries', ...NODE_ICONS.query },
46
+ { collection: 'channels', label: 'Channels', ...NODE_ICONS.channel },
47
+ { collection: 'containers', label: 'Data Stores', ...NODE_ICONS.data },
48
+ ];
@@ -0,0 +1,22 @@
1
+ import type { Status } from '../hooks/use-studio-flow';
2
+
3
+ /**
4
+ * Status and destructive colours for the canvas, from the theme's badge colours (EventCatalog has no status
5
+ * variables), so they follow light, dark and custom themes. Kept here rather than repeated in each component.
6
+ */
7
+ export const STATUS = {
8
+ success: { text: 'text-[rgb(var(--ec-badge-color-green-text))]', dot: 'bg-[rgb(var(--ec-badge-color-green-text))]' },
9
+ warning: { dot: 'bg-[rgb(var(--ec-badge-color-yellow-text))]' },
10
+ danger: {
11
+ text: 'text-[rgb(var(--ec-badge-color-red-text))]',
12
+ dot: 'bg-[rgb(var(--ec-badge-color-red-text))]',
13
+ hover: 'hover:bg-[rgb(var(--ec-badge-color-red-background))]',
14
+ },
15
+ } as const;
16
+
17
+ /** The dot showing whether the canvas is connected */
18
+ export const CONNECTION_DOT: Record<Status, string> = {
19
+ connected: STATUS.success.dot,
20
+ connecting: STATUS.warning.dot,
21
+ disconnected: STATUS.danger.dot,
22
+ };
@@ -0,0 +1,54 @@
1
+ import type { Connection, Edge, EdgeMarker } from '@xyflow/react';
2
+
3
+ /** Connections on a canvas, labelled with EventCatalog's wording (e.g. "publishes event", "subscribed by") */
4
+
5
+ /** Node types that are messages, and their catalog collection */
6
+ export const MESSAGE_COLLECTIONS: Record<string, string> = {
7
+ event: 'events',
8
+ command: 'commands',
9
+ query: 'queries',
10
+ };
11
+
12
+ export const EDGE_COLOR = '#6b7280';
13
+ export const EDGE_MARKER: EdgeMarker = { type: 'arrowclosed', color: EDGE_COLOR, width: 18, height: 18 };
14
+
15
+ const PRODUCERS = ['service', 'agent', 'actor', 'externalSystem', 'view'];
16
+ const PRODUCER_LABELS: Record<string, string> = { commands: 'invokes', events: 'publishes \nevent', queries: 'requests' };
17
+ const MESSAGE_LABELS: Record<string, string> = { commands: 'accepts', events: 'subscribed by', queries: 'accepts' };
18
+
19
+ const getCollection = (sourceType?: string, targetType?: string) =>
20
+ MESSAGE_COLLECTIONS[sourceType ?? ''] ?? MESSAGE_COLLECTIONS[targetType ?? ''];
21
+
22
+ export const getEdgeLabel = (sourceType = '', targetType = ''): string => {
23
+ const sourceCollection = MESSAGE_COLLECTIONS[sourceType];
24
+ const targetCollection = MESSAGE_COLLECTIONS[targetType];
25
+
26
+ if (PRODUCERS.includes(sourceType) && targetCollection) return PRODUCER_LABELS[targetCollection];
27
+ if (sourceCollection && targetType === 'channel') return 'sent to';
28
+ if (sourceCollection) return MESSAGE_LABELS[sourceCollection];
29
+ if (sourceType === 'channel') return 'routes to';
30
+ if (targetType === 'channel') return 'publishes to';
31
+ if (targetType === 'data') return 'writes to';
32
+ if (sourceType === 'data') return 'read by';
33
+ if (sourceType === 'actor') return 'uses';
34
+ if (targetType === 'view') return 'serves';
35
+ if (targetType === 'externalSystem') return 'calls';
36
+ if (targetType === 'actor') return 'notifies';
37
+ return '';
38
+ };
39
+
40
+ /** Message edges get the animated envelope edge from the visualiser, everything else a labelled smooth step edge */
41
+ export const createEdge = (connection: Connection, sourceType?: string, targetType?: string): Edge => {
42
+ const collection = getCollection(sourceType, targetType);
43
+ return {
44
+ id: `edge-${crypto.randomUUID().slice(0, 8)}`,
45
+ source: connection.source,
46
+ target: connection.target,
47
+ sourceHandle: connection.sourceHandle ?? null,
48
+ targetHandle: connection.targetHandle ?? null,
49
+ type: collection ? 'animated' : 'smoothstep',
50
+ label: getEdgeLabel(sourceType, targetType),
51
+ markerEnd: EDGE_MARKER,
52
+ data: collection ? { message: { collection } } : {},
53
+ };
54
+ };
@@ -0,0 +1,92 @@
1
+ import type { Edge, Node, XYPosition } from '@xyflow/react';
2
+
3
+ /**
4
+ * The design (nodes and edges) comes from the shared document; some state stays with each person: what they
5
+ * have selected, what they're dragging and resizing, and the sizes React Flow measured in their browser.
6
+ */
7
+ export const LOCAL_NODE_KEYS = ['selected', 'dragging', 'resizing', 'measured'] as const satisfies readonly (keyof Node)[];
8
+ export const LOCAL_EDGE_KEYS = ['selected'] as const satisfies readonly (keyof Edge)[];
9
+
10
+ /** A shared item with the local-only state of the one already shown. Nothing else is kept from the local one. */
11
+ export const withLocalState = <T extends object>(shared: T, local: T | undefined, localKeys: readonly (keyof T)[]): T => {
12
+ if (!local) return shared;
13
+ const kept: Partial<T> = {};
14
+ for (const key of localKeys) if (local[key] !== undefined) kept[key] = local[key];
15
+ return { ...shared, ...kept };
16
+ };
17
+
18
+ /**
19
+ * Applies changes to shared items (by id) to the items shown, keeping every unchanged item as the same object
20
+ * (so React Flow doesn't re-render it) and the order they were first seen in (new ones go at the end).
21
+ * `getShared` returns an item's shared value, or undefined if it was deleted. Returns `local` itself when
22
+ * nothing changed.
23
+ */
24
+ export const patchShared = <T extends { id: string }>(
25
+ local: T[],
26
+ changedIds: Iterable<string>,
27
+ getShared: (id: string) => T | undefined,
28
+ localKeys: readonly (keyof T)[]
29
+ ): T[] => {
30
+ const indexById = new Map(local.map((item, index) => [item.id, index]));
31
+ let next: T[] | undefined;
32
+ const removed = new Set<string>();
33
+ const added: T[] = [];
34
+
35
+ for (const id of changedIds) {
36
+ const shared = getShared(id);
37
+ const index = indexById.get(id);
38
+ if (!shared) {
39
+ if (index !== undefined) removed.add(id);
40
+ continue;
41
+ }
42
+ if (index === undefined) {
43
+ added.push(shared);
44
+ continue;
45
+ }
46
+ next ??= [...local];
47
+ next[index] = withLocalState(shared, local[index], localKeys);
48
+ }
49
+
50
+ if (!next && removed.size === 0 && added.length === 0) return local;
51
+ const patched = next ?? local;
52
+ return [...(removed.size ? patched.filter((item) => !removed.has(item.id)) : patched), ...added];
53
+ };
54
+
55
+ /** Where nodes being dragged are, by id, until they're dropped (and saved) */
56
+ export type Moves = ReadonlyMap<string, XYPosition>;
57
+
58
+ const samePosition = (a: XYPosition, b: XYPosition) => a.x === b.x && a.y === b.y;
59
+
60
+ /**
61
+ * Nodes moved to the positions given (e.g. where someone else is dragging them), except ones being dragged
62
+ * here. Returns `nodes` itself when none move.
63
+ */
64
+ export const withPositions = (nodes: Node[], positions: Moves): Node[] => {
65
+ if (positions.size === 0) return nodes;
66
+ let next: Node[] | undefined;
67
+ nodes.forEach((node, index) => {
68
+ const position = positions.get(node.id);
69
+ if (!position || node.dragging || samePosition(position, node.position)) return;
70
+ next ??= [...nodes];
71
+ next[index] = { ...node, position };
72
+ });
73
+ return next ?? nodes;
74
+ };
75
+
76
+ /**
77
+ * After shared changes are applied (`next`, from `previous`): nodes being dragged here stay where they're being
78
+ * dragged (when they're dropped, the drop is saved and wins). Returns `next` itself when none are.
79
+ */
80
+ export const keepDragged = (next: Node[], previous: Node[]): Node[] => {
81
+ if (next === previous) return next;
82
+ const dragged = new Map(previous.filter((node) => node.dragging).map((node) => [node.id, node.position]));
83
+ if (dragged.size === 0) return next;
84
+ let kept: Node[] | undefined;
85
+ next.forEach((node, index) => {
86
+ const position = dragged.get(node.id);
87
+ if (!position || samePosition(position, node.position)) return;
88
+ kept ??= [...next];
89
+ kept[index] = { ...node, position };
90
+ });
91
+ return kept ?? next;
92
+ };
@@ -0,0 +1,133 @@
1
+ import type { Node, XYPosition } from '@xyflow/react';
2
+ import { getNodeSize, isGroupType } from './node-types';
3
+
4
+ /**
5
+ * Containers: domains and systems shown as boxes that other nodes sit in.
6
+ * A node inside one has its parentId, and its position is relative to the container.
7
+ */
8
+
9
+ /** Room around what's in a container, and for its header */
10
+ export const GROUP_PADDING = { top: 80, right: 40, bottom: 40, left: 40 };
11
+
12
+ type Rect = { x: number; y: number; width: number; height: number };
13
+
14
+ const byId = (nodes: Node[]) => new Map(nodes.map((node) => [node.id, node]));
15
+
16
+ /** A node's size: as rendered (in the browser), as set (containers, notes), or what its type renders at */
17
+ export const sizeOf = (node: Node) => ({
18
+ width: node.measured?.width ?? node.width ?? getNodeSize(node.type).width,
19
+ height: node.measured?.height ?? node.height ?? getNodeSize(node.type).height,
20
+ });
21
+
22
+ /** Nodes are positioned relative to their container, so walk up the chain to get the canvas position */
23
+ export const getAbsolutePosition = (node: Node, lookup: Map<string, Node>): XYPosition => {
24
+ const parent = node.parentId ? lookup.get(node.parentId) : undefined;
25
+ if (!parent) return node.position;
26
+ const parentPosition = getAbsolutePosition(parent, lookup);
27
+ return { x: parentPosition.x + node.position.x, y: parentPosition.y + node.position.y };
28
+ };
29
+
30
+ export const getAbsoluteRect = (node: Node, lookup: Map<string, Node>): Rect => ({
31
+ ...getAbsolutePosition(node, lookup),
32
+ ...sizeOf(node),
33
+ });
34
+
35
+ const getDepth = (node: Node, lookup: Map<string, Node>): number => {
36
+ const parent = node.parentId ? lookup.get(node.parentId) : undefined;
37
+ return parent ? 1 + getDepth(parent, lookup) : 0;
38
+ };
39
+
40
+ const isDescendantOf = (node: Node, ancestorId: string, lookup: Map<string, Node>): boolean => {
41
+ if (!node.parentId) return false;
42
+ if (node.parentId === ancestorId) return true;
43
+ const parent = lookup.get(node.parentId);
44
+ return parent ? isDescendantOf(parent, ancestorId, lookup) : false;
45
+ };
46
+
47
+ const containsPoint = (rect: Rect, point: XYPosition) =>
48
+ point.x >= rect.x && point.x <= rect.x + rect.width && point.y >= rect.y && point.y <= rect.y + rect.height;
49
+
50
+ /** React Flow needs containers before what's in them in the nodes array */
51
+ export const sortByHierarchy = (nodes: Node[]): Node[] => {
52
+ const lookup = byId(nodes);
53
+ return nodes
54
+ .map((node, index) => ({ node, index, depth: getDepth(node, lookup) }))
55
+ .sort((a, b) => a.depth - b.depth || a.index - b.index)
56
+ .map(({ node }) => node);
57
+ };
58
+
59
+ /** The innermost container under the centre of a node (never itself, or one of its own children) */
60
+ export const findDropTarget = (nodeId: string, nodes: Node[]): Node | undefined => {
61
+ const lookup = byId(nodes);
62
+ const node = lookup.get(nodeId);
63
+ if (!node) return undefined;
64
+ const rect = getAbsoluteRect(node, lookup);
65
+ const center = { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
66
+ return nodes
67
+ .filter((candidate) => isGroupType(candidate.type) && candidate.id !== nodeId && !isDescendantOf(candidate, nodeId, lookup))
68
+ .filter((candidate) => containsPoint(getAbsoluteRect(candidate, lookup), center))
69
+ .sort((a, b) => getDepth(b, lookup) - getDepth(a, lookup))[0];
70
+ };
71
+
72
+ /** The innermost container at a canvas point, for things dropped there */
73
+ export const findGroupAtPoint = (point: XYPosition, nodes: Node[]): Node | undefined => {
74
+ const lookup = byId(nodes);
75
+ return nodes
76
+ .filter((candidate) => isGroupType(candidate.type) && containsPoint(getAbsoluteRect(candidate, lookup), point))
77
+ .sort((a, b) => getDepth(b, lookup) - getDepth(a, lookup))[0];
78
+ };
79
+
80
+ /** A node moved into (or out of, without a parentId) a container, staying in the same place on the canvas */
81
+ export const withParent = (node: Node, parentId: string | undefined, nodes: Node[]): Node => {
82
+ const lookup = byId(nodes);
83
+ const absolute = getAbsolutePosition(node, lookup);
84
+ const parent = parentId ? lookup.get(parentId) : undefined;
85
+ const parentPosition = parent ? getAbsolutePosition(parent, lookup) : { x: 0, y: 0 };
86
+ const { parentId: _, extent: __, ...rest } = node;
87
+ return {
88
+ ...rest,
89
+ ...(parent && { parentId: parent.id }),
90
+ position: { x: absolute.x - parentPosition.x, y: absolute.y - parentPosition.y },
91
+ };
92
+ };
93
+
94
+ /** A canvas point, relative to a container */
95
+ export const toRelativePosition = (point: XYPosition, parentId: string, nodes: Node[]): XYPosition => {
96
+ const lookup = byId(nodes);
97
+ const parent = lookup.get(parentId);
98
+ if (!parent) return point;
99
+ const parentPosition = getAbsolutePosition(parent, lookup);
100
+ return { x: point.x - parentPosition.x, y: point.y - parentPosition.y };
101
+ };
102
+
103
+ /** Nodes and everything inside them (deleted with their container) */
104
+ export const withDescendants = (ids: string[], nodes: Node[]): Set<string> => {
105
+ const lookup = byId(nodes);
106
+ const result = new Set(ids);
107
+ nodes.forEach((node) => {
108
+ if (ids.some((id) => isDescendantOf(node, id, lookup))) result.add(node.id);
109
+ });
110
+ return result;
111
+ };
112
+
113
+ /**
114
+ * The size a container needs to fit what's inside it, never smaller than it is, and how far its children
115
+ * must shift when some sit above or left of its content area (so the header stays clear).
116
+ */
117
+ export const fitGroup = (groupId: string, nodes: Node[]) => {
118
+ const lookup = byId(nodes);
119
+ const group = lookup.get(groupId);
120
+ const children = nodes.filter((node) => node.parentId === groupId);
121
+ if (!group || children.length === 0) return undefined;
122
+ const rects = children.map((child) => ({ ...child.position, ...sizeOf(child) }));
123
+ const shift = {
124
+ x: Math.max(0, GROUP_PADDING.left - Math.min(...rects.map((rect) => rect.x))),
125
+ y: Math.max(0, GROUP_PADDING.top - Math.min(...rects.map((rect) => rect.y))),
126
+ };
127
+ const current = sizeOf(group);
128
+ return {
129
+ shift,
130
+ width: Math.max(current.width, Math.max(...rects.map((rect) => rect.x + rect.width)) + shift.x + GROUP_PADDING.right),
131
+ height: Math.max(current.height, Math.max(...rects.map((rect) => rect.y + rect.height)) + shift.y + GROUP_PADDING.bottom),
132
+ };
133
+ };
@@ -0,0 +1,46 @@
1
+ import type { Awareness } from 'y-protocols/awareness';
2
+
3
+ /** About 30 times a second, like Excalidraw and tldraw: smooth for others, without a message on every mousemove */
4
+ export const PRESENCE_INTERVAL_MS = 33;
5
+
6
+ export type PresenceSender = {
7
+ /** Share these fields of our presence: straight away if nothing was sent lately, otherwise with the next send */
8
+ send: (fields: Record<string, unknown>) => void;
9
+ /** Send anything waiting now (e.g. a node dropped, so the drop isn't behind the drag) */
10
+ flush: () => void;
11
+ destroy: () => void;
12
+ };
13
+
14
+ /**
15
+ * Our presence (pointer, nodes being dragged, selection, view), sent to everyone at most every interval. Awareness
16
+ * sends the whole state on every change, so fields changed together (the pointer and the node it drags) go in one
17
+ * message, and the latest value always goes out (the pointer stops where it really stopped).
18
+ */
19
+ export function createPresenceSender(awareness: Awareness, intervalMs = PRESENCE_INTERVAL_MS): PresenceSender {
20
+ let pending: Record<string, unknown> | undefined;
21
+ let timer: ReturnType<typeof setTimeout> | undefined;
22
+ let lastSent = -Infinity;
23
+
24
+ const flush = () => {
25
+ clearTimeout(timer);
26
+ timer = undefined;
27
+ if (!pending) return;
28
+ const fields = pending;
29
+ pending = undefined;
30
+ lastSent = performance.now();
31
+ awareness.setLocalState({ ...awareness.getLocalState(), ...fields });
32
+ };
33
+
34
+ return {
35
+ send: (fields) => {
36
+ pending = { ...pending, ...fields };
37
+ if (timer) return;
38
+ timer = setTimeout(flush, Math.max(0, intervalMs - (performance.now() - lastSent)));
39
+ },
40
+ flush,
41
+ destroy: () => {
42
+ clearTimeout(timer);
43
+ pending = undefined;
44
+ },
45
+ };
46
+ }
@@ -0,0 +1,115 @@
1
+ import { useSyncExternalStore } from 'react';
2
+ import type { XYPosition } from '@xyflow/react';
3
+ import type { Awareness } from 'y-protocols/awareness';
4
+
5
+ export type Peer = {
6
+ clientId: number;
7
+ name: string;
8
+ color: string;
9
+ /** An AI agent (joined over MCP, or working through this tab with WebMCP), rather than a person */
10
+ agent?: boolean;
11
+ /** What an agent is doing, e.g. "Adding OrderService" */
12
+ activity?: string;
13
+ pointer: XYPosition | null;
14
+ selection: string[];
15
+ /** What they're looking at: the canvas position in the middle of their screen, and their zoom */
16
+ viewport?: { x: number; y: number; zoom: number };
17
+ /** Where a connection they're dragging starts (agents show theirs) */
18
+ connecting?: XYPosition | null;
19
+ };
20
+
21
+ type Listener = () => void;
22
+
23
+ /**
24
+ * Who's on a canvas and what they're doing (pointers, selections, views), from Yjs awareness, kept out of React
25
+ * state: only the components that draw presence subscribe, so a pointer moving doesn't re-render the canvas.
26
+ * Our own pointer, selection and view are sent to others but don't notify here (nothing here draws them).
27
+ */
28
+ export type PresenceStore = {
29
+ subscribe: (listener: Listener) => () => void;
30
+ /** Everyone, including us and an agent working through this tab. Changes whenever anyone does anything. */
31
+ getPeers: () => Peer[];
32
+ /**
33
+ * Everyone, for lists of who's here: changes only when people join, leave or rename, or agents change what
34
+ * they're doing (pointers and views in it may be out of date: read getPeers() for those)
35
+ */
36
+ getPeople: () => Peer[];
37
+ /** Show an agent working through this tab (WebMCP), which only this tab sees */
38
+ setLocalAgent: (agent: Peer | null) => void;
39
+ destroy: () => void;
40
+ };
41
+
42
+ const EMPTY: Peer[] = [];
43
+
44
+ const toPeers = (awareness: Awareness): Peer[] => {
45
+ const peers: Peer[] = [];
46
+ awareness.getStates().forEach((state, clientId) => {
47
+ if (!state.user) return;
48
+ peers.push({
49
+ clientId,
50
+ ...state.user,
51
+ activity: state.activity,
52
+ pointer: state.pointer ?? null,
53
+ selection: state.selection ?? [],
54
+ viewport: state.viewport,
55
+ connecting: state.connecting,
56
+ });
57
+ });
58
+ return peers;
59
+ };
60
+
61
+ // What getPeople() changes on: who people are, what agents are doing, and whether they're on the canvas (a pointer)
62
+ const identity = (peer: Peer) =>
63
+ [peer.clientId, peer.name, peer.color, peer.agent ? 1 : 0, peer.activity ?? '', peer.pointer ? 1 : 0].join('|');
64
+
65
+ export function createPresenceStore(awareness: Awareness): PresenceStore {
66
+ const listeners = new Set<Listener>();
67
+ let remote = toPeers(awareness);
68
+ let localAgent: Peer | null = null;
69
+ let peers = remote;
70
+ let people = remote;
71
+ let ownUser = JSON.stringify(awareness.getLocalState()?.user ?? null);
72
+
73
+ const update = () => {
74
+ peers = localAgent ? [...remote, localAgent] : remote;
75
+ const nextPeople = peers.length ? peers : EMPTY;
76
+ if (nextPeople.map(identity).join() !== people.map(identity).join()) people = nextPeople;
77
+ listeners.forEach((listener) => listener());
78
+ };
79
+
80
+ const onChange = ({ added, updated, removed }: { added: number[]; updated: number[]; removed: number[] }) => {
81
+ const others = [...added, ...updated, ...removed].some((id) => id !== awareness.clientID);
82
+ const user = JSON.stringify(awareness.getLocalState()?.user ?? null);
83
+ // Only our own pointer, selection or view changed: nothing shown here changed
84
+ if (!others && user === ownUser) return;
85
+ ownUser = user;
86
+ remote = toPeers(awareness);
87
+ update();
88
+ };
89
+ awareness.on('change', onChange);
90
+
91
+ return {
92
+ subscribe: (listener) => {
93
+ listeners.add(listener);
94
+ return () => listeners.delete(listener);
95
+ },
96
+ getPeers: () => peers,
97
+ getPeople: () => people,
98
+ setLocalAgent: (agent) => {
99
+ localAgent = agent;
100
+ update();
101
+ },
102
+ destroy: () => {
103
+ awareness.off('change', onChange);
104
+ listeners.clear();
105
+ },
106
+ };
107
+ }
108
+
109
+ const noStore = { subscribe: () => () => {}, getPeers: () => EMPTY, getPeople: () => EMPTY };
110
+
111
+ /** Everyone on the canvas, re-rendering only when people join, leave, rename or start or stop working */
112
+ export const usePeople = (store: PresenceStore | null) => {
113
+ const source = store ?? noStore;
114
+ return useSyncExternalStore(source.subscribe, source.getPeople);
115
+ };