@eventcatalog/core 5.0.0-beta.1 → 5.0.0-beta.11

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 (172) hide show
  1. package/LICENSE +227 -128
  2. package/README.md +4 -4
  3. package/dist/analytics/analytics.cjs +18 -3
  4. package/dist/analytics/analytics.d.cts +1 -1
  5. package/dist/analytics/analytics.d.ts +1 -1
  6. package/dist/analytics/analytics.js +3 -2
  7. package/dist/analytics/log-build.cjs +71 -35
  8. package/dist/analytics/log-build.js +5 -4
  9. package/dist/analytics/telemetry.cjs +42 -0
  10. package/dist/analytics/telemetry.d.cts +20 -0
  11. package/dist/analytics/telemetry.d.ts +20 -0
  12. package/dist/analytics/telemetry.js +8 -0
  13. package/dist/{chunk-KYJZJLBD.js → chunk-4YFM63DS.js} +9 -3
  14. package/dist/{chunk-UDS3SYLZ.js → chunk-76DAAJLV.js} +56 -33
  15. package/dist/{chunk-TQLRG35T.js → chunk-AWOO63ME.js} +71 -45
  16. package/dist/chunk-CPW6G62V.js +17 -0
  17. package/dist/{chunk-K2ACDO7Y.js → chunk-LQZ7IG7B.js} +1 -1
  18. package/dist/{chunk-S2QGT7ZF.js → chunk-MFEMKQZ6.js} +1 -1
  19. package/dist/{chunk-Q6SD5SNQ.js → chunk-WM4QYMIA.js} +1 -1
  20. package/dist/constants.cjs +1 -1
  21. package/dist/constants.js +1 -1
  22. package/dist/eventcatalog.cjs +204 -124
  23. package/dist/eventcatalog.config.d.cts +59 -1
  24. package/dist/eventcatalog.config.d.ts +59 -1
  25. package/dist/eventcatalog.js +30 -11
  26. package/dist/federation/federate.js +2 -2
  27. package/dist/generate.cjs +1 -1
  28. package/dist/generate.js +3 -3
  29. package/dist/runtime-dependencies/@hocuspocus__provider.mjs +3 -0
  30. package/dist/runtime-dependencies/@hocuspocus__server.mjs +3 -0
  31. package/dist/runtime-dependencies/@mcp-b__webmcp-local-relay.mjs +3 -0
  32. package/dist/runtime-dependencies/@mcp-b__webmcp-polyfill.mjs +3 -0
  33. package/dist/runtime-dependencies/manifest.json +8 -1
  34. package/dist/runtime-dependencies/react-hotkeys-hook.mjs +3 -0
  35. package/dist/runtime-dependencies/y-protocols.mjs +3 -0
  36. package/dist/runtime-dependencies/y-protocols__awareness.mjs +3 -0
  37. package/dist/runtime-dependencies/yjs.mjs +3 -0
  38. package/dist/utils/cli-logger.cjs +1 -1
  39. package/dist/utils/cli-logger.js +2 -2
  40. package/dist/utils/license-status.cjs +71 -45
  41. package/dist/utils/license-status.d.cts +14 -5
  42. package/dist/utils/license-status.d.ts +14 -5
  43. package/dist/utils/license-status.js +1 -1
  44. package/eventcatalog/astro.config.mjs +5 -0
  45. package/eventcatalog/integrations/storage.mjs +20 -0
  46. package/eventcatalog/integrations/studio-server.mjs +32 -0
  47. package/eventcatalog/src/components/Grids/utils.tsx +0 -23
  48. package/eventcatalog/src/components/Header.astro +3 -3
  49. package/eventcatalog/src/components/MDX/CodeGroup/CodeGroup.tsx +6 -1
  50. package/eventcatalog/src/components/SchemaExplorer/SchemaDetailsPanel.tsx +12 -4
  51. package/eventcatalog/src/components/SideNav/NestedSideBar/index.tsx +16 -7
  52. package/eventcatalog/src/components/SideNav/NestedSideBar/utils.ts +10 -0
  53. package/eventcatalog/src/components/Tables/Discover/DiscoverTable.tsx +4 -62
  54. package/eventcatalog/src/components/Tables/Discover/columns.tsx +17 -150
  55. package/eventcatalog/src/components/Tables/Resources/ResourcesTable.tsx +425 -0
  56. package/eventcatalog/src/components/Tables/RowActionsMenu.tsx +157 -0
  57. package/eventcatalog/src/components/Tables/TablePagination.tsx +82 -0
  58. package/eventcatalog/src/features/ai/chat-api.ts +3 -3
  59. package/eventcatalog/src/features/auth/middleware/middleware-auth.ts +10 -0
  60. package/eventcatalog/src/features/integrations/eventcatalog-features.ts +34 -1
  61. package/eventcatalog/src/features/mcp/apps/canvas/shared.ts +47 -0
  62. package/eventcatalog/src/features/mcp/apps/canvas/styles.css +22 -0
  63. package/eventcatalog/src/features/mcp/apps/canvas/view.tsx +276 -0
  64. package/eventcatalog/src/features/mcp/apps/generated/canvas.html +210 -0
  65. package/eventcatalog/src/features/mcp/apps/generated/viewer.html +75 -74
  66. package/eventcatalog/src/features/mcp/apps/shared/app-view.tsx +2 -1
  67. package/eventcatalog/src/features/mcp/mcp-apps.ts +6 -3
  68. package/eventcatalog/src/features/mcp/mcp-server.ts +36 -5
  69. package/eventcatalog/src/features/server/start.ts +25 -0
  70. package/eventcatalog/src/features/storage/database.ts +88 -0
  71. package/eventcatalog/src/features/storage/migrations.ts +85 -0
  72. package/eventcatalog/src/features/storage/sql/database.sql +5 -0
  73. package/eventcatalog/src/features/storage/sql/migrations/0001-studio-canvases.sql +12 -0
  74. package/eventcatalog/src/features/storage/sql/migrations.sql +15 -0
  75. package/eventcatalog/src/features/storage/sql.ts +28 -0
  76. package/eventcatalog/src/features/studio/agent-choreography.ts +381 -0
  77. package/eventcatalog/src/features/studio/api/canvases-api.ts +170 -0
  78. package/eventcatalog/src/features/studio/api/canvases.ts +25 -0
  79. package/eventcatalog/src/features/studio/api/client.ts +35 -0
  80. package/eventcatalog/src/features/studio/canvas-actions.ts +414 -0
  81. package/eventcatalog/src/features/studio/canvas-doc.ts +414 -0
  82. package/eventcatalog/src/features/studio/canvas-preview.ts +53 -0
  83. package/eventcatalog/src/features/studio/catalog-resources.ts +155 -0
  84. package/eventcatalog/src/features/studio/catalog.ts +171 -0
  85. package/eventcatalog/src/features/studio/clipboard.ts +97 -0
  86. package/eventcatalog/src/features/studio/components/CanvasControls.tsx +154 -0
  87. package/eventcatalog/src/features/studio/components/CanvasHeader.tsx +370 -0
  88. package/eventcatalog/src/features/studio/components/CanvasStatusMenu.tsx +117 -0
  89. package/eventcatalog/src/features/studio/components/Comments.tsx +770 -0
  90. package/eventcatalog/src/features/studio/components/ConnectAgentDialog.tsx +281 -0
  91. package/eventcatalog/src/features/studio/components/ConnectionsChooser.tsx +144 -0
  92. package/eventcatalog/src/features/studio/components/ContainerChooser.tsx +145 -0
  93. package/eventcatalog/src/features/studio/components/DeleteCanvasDialog.tsx +81 -0
  94. package/eventcatalog/src/features/studio/components/DragGhost.tsx +115 -0
  95. package/eventcatalog/src/features/studio/components/JoinForm.tsx +34 -0
  96. package/eventcatalog/src/features/studio/components/LeftPanel.tsx +314 -0
  97. package/eventcatalog/src/features/studio/components/Picture.tsx +21 -0
  98. package/eventcatalog/src/features/studio/components/Presence.tsx +286 -0
  99. package/eventcatalog/src/features/studio/components/PropertiesPanel.tsx +212 -0
  100. package/eventcatalog/src/features/studio/components/ShareDialog.tsx +260 -0
  101. package/eventcatalog/src/features/studio/components/StudioDesigner.tsx +1216 -0
  102. package/eventcatalog/src/features/studio/components/StudioNotice.astro +24 -0
  103. package/eventcatalog/src/features/studio/components/canvas-nodes.tsx +255 -0
  104. package/eventcatalog/src/features/studio/components/icons.ts +53 -0
  105. package/eventcatalog/src/features/studio/components/status.ts +53 -0
  106. package/eventcatalog/src/features/studio/edges.ts +56 -0
  107. package/eventcatalog/src/features/studio/flow-state.ts +92 -0
  108. package/eventcatalog/src/features/studio/grouping.ts +226 -0
  109. package/eventcatalog/src/features/studio/hooks/presence-sender.ts +46 -0
  110. package/eventcatalog/src/features/studio/hooks/presence-store.ts +119 -0
  111. package/eventcatalog/src/features/studio/hooks/use-canvas-webmcp.ts +347 -0
  112. package/eventcatalog/src/features/studio/hooks/use-comments.ts +54 -0
  113. package/eventcatalog/src/features/studio/hooks/use-follow-camera.ts +104 -0
  114. package/eventcatalog/src/features/studio/hooks/use-studio-flow.ts +589 -0
  115. package/eventcatalog/src/features/studio/identity.ts +37 -0
  116. package/eventcatalog/src/features/studio/layout.ts +42 -0
  117. package/eventcatalog/src/features/studio/levels.ts +282 -0
  118. package/eventcatalog/src/features/studio/node-types.ts +205 -0
  119. package/eventcatalog/src/features/studio/pages/[id].astro +68 -0
  120. package/eventcatalog/src/features/studio/pages/index.astro +784 -0
  121. package/eventcatalog/src/features/studio/pages/webmcp-relay.ts +27 -0
  122. package/eventcatalog/src/features/studio/placement.ts +197 -0
  123. package/eventcatalog/src/features/studio/server/canvas-mcp.ts +510 -0
  124. package/eventcatalog/src/features/studio/server/runtime.ts +491 -0
  125. package/eventcatalog/src/features/studio/server/sign-in.ts +18 -0
  126. package/eventcatalog/src/features/studio/server/sql/storage.sql +18 -0
  127. package/eventcatalog/src/features/studio/server/storage.ts +90 -0
  128. package/eventcatalog/src/features/studio/server/tool-icons.ts +30 -0
  129. package/eventcatalog/src/features/studio/studio.css +273 -0
  130. package/eventcatalog/src/features/studio/tool-sync.ts +75 -0
  131. package/eventcatalog/src/layouts/VerticalSideBarLayout.astro +10 -5
  132. package/eventcatalog/src/pages/architecture/[type]/[id]/[version]/_index.data.spec.ts +52 -63
  133. package/eventcatalog/src/pages/architecture/[type]/[id]/[version]/_index.data.ts +34 -46
  134. package/eventcatalog/src/pages/architecture/[type]/[id]/[version]/index.astro +11 -29
  135. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/resources/_ResourcesPage.astro +79 -0
  136. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/resources/_index.data.spec.ts +32 -14
  137. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/resources/_index.data.ts +63 -58
  138. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/resources/index.astro +2 -62
  139. package/eventcatalog/src/pages/docs/services/[id]/[version]/resources/index.astro +13 -0
  140. package/eventcatalog/src/stores/sidebar-store/builders/domain.ts +24 -42
  141. package/eventcatalog/src/stores/sidebar-store/builders/flow.ts +2 -0
  142. package/eventcatalog/src/stores/sidebar-store/builders/message.ts +2 -0
  143. package/eventcatalog/src/stores/sidebar-store/builders/service.ts +23 -23
  144. package/eventcatalog/src/stores/sidebar-store/builders/shared.ts +13 -0
  145. package/eventcatalog/src/stores/sidebar-store/builders/system.ts +18 -27
  146. package/eventcatalog/src/stores/sidebar-store/state.ts +5 -0
  147. package/eventcatalog/src/styles/tailwind.css +2 -2
  148. package/eventcatalog/src/utils/collections/channels.ts +56 -1
  149. package/eventcatalog/src/utils/collections/flows.ts +50 -1
  150. package/eventcatalog/src/utils/collections/message-connections.ts +60 -0
  151. package/eventcatalog/src/utils/collections/message-triggers.ts +2 -1
  152. package/eventcatalog/src/utils/collections/resource-owners.ts +193 -0
  153. package/eventcatalog/src/utils/collections/resources.ts +310 -0
  154. package/eventcatalog/src/utils/collections/util.ts +31 -0
  155. package/eventcatalog/src/utils/feature.ts +8 -0
  156. package/eventcatalog/src/utils/git.ts +11 -5
  157. package/eventcatalog/src/utils/node-graphs/export-mermaid.ts +0 -2
  158. package/eventcatalog/src/utils/trial.ts +1 -1
  159. package/package.json +17 -12
  160. package/dist/runtime-dependencies/shelljs.mjs +0 -3
  161. package/eventcatalog/public/studio-bg.png +0 -0
  162. package/eventcatalog/src/components/Grids/DomainGrid.tsx +0 -838
  163. package/eventcatalog/src/components/Grids/MessageGrid.tsx +0 -344
  164. package/eventcatalog/src/components/Grids/SystemGrid.tsx +0 -185
  165. package/eventcatalog/src/components/Grids/components.tsx +0 -170
  166. package/eventcatalog/src/components/Grids/message-link.ts +0 -38
  167. package/eventcatalog/src/components/Studio/StudioPageModal.tsx +0 -150
  168. package/eventcatalog/src/components/Tables/SystemResources/SystemResourcesTable.tsx +0 -294
  169. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/resources/_resources.spec.ts +0 -65
  170. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/resources/_resources.ts +0 -46
  171. package/eventcatalog/src/pages/studio.astro +0 -316
  172. package/dist/{chunk-AYNZXUB5.js → chunk-KHACCTW2.js} +3 -3
@@ -0,0 +1,171 @@
1
+ import type { Edge, Node } from '@xyflow/react';
2
+ import type { CatalogRelation, CatalogResource } from './catalog-resources';
3
+ import { buildNode } from './canvas-doc';
4
+ import { createEdge } from './edges';
5
+ import { layoutGraph, withGroupSizes } from './layout';
6
+ import { GROUP_TYPES, isGroupType } from './node-types';
7
+
8
+ /**
9
+ * Catalog resources on a canvas: the node for a resource, its connections to related resources already there,
10
+ * and domains and systems as containers with what they hold in the catalog.
11
+ */
12
+
13
+ export type CatalogLink = { key: string; url: string; version: string };
14
+
15
+ /** The node data for a resource, tagged with the catalog resource it came from */
16
+ export const catalogNodeData = (resource: CatalogResource) => ({
17
+ ...resource.node.data,
18
+ catalog: { key: resource.key, url: resource.url, version: resource.version } satisfies CatalogLink,
19
+ });
20
+
21
+ export const getCatalogLink = (node: Pick<Node, 'data'>) => node.data?.catalog as CatalogLink | undefined;
22
+
23
+ export const indexRelations = (relations: CatalogRelation[]) => {
24
+ const byKey = new Map<string, CatalogRelation[]>();
25
+ for (const relation of relations) {
26
+ for (const key of [relation.source, relation.target]) byKey.set(key, [...(byKey.get(key) ?? []), relation]);
27
+ }
28
+ return byKey;
29
+ };
30
+
31
+ /**
32
+ * Edges from a newly dropped resource to the related resources already on the canvas. A container doesn't get
33
+ * "contains" edges: what it contains sits inside it.
34
+ */
35
+ export const getRelatedEdges = (
36
+ resource: CatalogResource,
37
+ nodeId: string,
38
+ existing: Node[],
39
+ relationsByKey: Map<string, CatalogRelation[]>,
40
+ nodeType = resource.node.type
41
+ ): Edge[] => {
42
+ const edges: Edge[] = [];
43
+ for (const relation of relationsByKey.get(resource.key) ?? []) {
44
+ const isSource = relation.source === resource.key;
45
+ const otherKey = isSource ? relation.target : relation.source;
46
+
47
+ for (const other of existing.filter((node) => getCatalogLink(node)?.key === otherKey)) {
48
+ if (relation.label === 'contains' && (isGroupType(nodeType) || isGroupType(other.type))) continue;
49
+ const [source, target] = isSource ? [{ id: nodeId, type: nodeType }, other] : [other, { id: nodeId, type: nodeType }];
50
+ const edge = createEdge(
51
+ { source: source.id, target: target.id, sourceHandle: null, targetHandle: null },
52
+ source.type,
53
+ target.type
54
+ );
55
+ edges.push(relation.label ? { ...edge, label: relation.label } : edge);
56
+ }
57
+ }
58
+ return edges;
59
+ };
60
+
61
+ // ---- Domains and systems as containers ----
62
+
63
+ export const canBeContainer = (resource: CatalogResource) =>
64
+ resource.collection === 'domains' || resource.collection === 'systems';
65
+
66
+ export const containerTypeFor = (resource: CatalogResource) =>
67
+ resource.collection === 'domains' ? GROUP_TYPES.domain : GROUP_TYPES.system;
68
+
69
+ /** A container's data: the domain or system it stands for, and where it is in the catalog */
70
+ export const catalogGroupData = (resource: CatalogResource) => ({
71
+ [resource.collection === 'domains' ? 'domain' : 'system']: {
72
+ id: resource.id,
73
+ name: resource.name,
74
+ version: resource.version,
75
+ summary: resource.summary,
76
+ },
77
+ catalog: { key: resource.key, url: resource.url, version: resource.version } satisfies CatalogLink,
78
+ });
79
+
80
+ /** What a domain or system contains in the catalog: a domain's systems and services, a system's services */
81
+ export const getContents = (
82
+ resource: CatalogResource,
83
+ catalog: { resourcesByKey: Map<string, CatalogResource>; relationsByKey: Map<string, CatalogRelation[]> }
84
+ ) =>
85
+ (catalog.relationsByKey.get(resource.key) ?? [])
86
+ .filter((relation) => relation.source === resource.key && relation.label === 'contains')
87
+ .map((relation) => catalog.resourcesByKey.get(relation.target))
88
+ .filter((child): child is CatalogResource => !!child);
89
+
90
+ /** A domain or system as an (empty) container, centred on a point */
91
+ export const buildContainer = (resource: CatalogResource, center: { x: number; y: number }): Node =>
92
+ buildNode(containerTypeFor(resource), catalogGroupData(resource), center);
93
+
94
+ /**
95
+ * A domain or system as a container with everything it holds in the catalog: its systems (as containers too)
96
+ * and services, and the messages and data stores of those services, so every level of detail (L1 to L3) has
97
+ * something to show. Laid out with the visualiser's layout and centred on a point, with the connections between
98
+ * them and to related resources already on the canvas. Resources already on the canvas aren't added again.
99
+ */
100
+ export const buildContainerWithContents = async (
101
+ resource: CatalogResource,
102
+ center: { x: number; y: number },
103
+ catalog: { resourcesByKey: Map<string, CatalogResource>; relationsByKey: Map<string, CatalogRelation[]> },
104
+ existing: Node[]
105
+ ): Promise<{ nodes: Node[]; edges: Edge[] }> => {
106
+ const onCanvas = new Set(existing.map((node) => getCatalogLink(node)?.key).filter(Boolean));
107
+ const root = buildContainer(resource, { x: 0, y: 0 });
108
+ const nodes: Node[] = [root];
109
+ const added = (key: string) => onCanvas.has(key) || nodes.some((node) => getCatalogLink(node)?.key === key);
110
+ const services: { resource: CatalogResource; parentId: string }[] = [];
111
+
112
+ // Its systems (as containers, with their services) and services
113
+ const fill = (parent: CatalogResource, parentId: string) => {
114
+ for (const child of getContents(parent, catalog)) {
115
+ if (added(child.key)) continue;
116
+ if (canBeContainer(child)) {
117
+ const container = { ...buildContainer(child, { x: 0, y: 0 }), parentId };
118
+ nodes.push(container);
119
+ fill(child, container.id);
120
+ } else {
121
+ nodes.push({ ...buildNode(child.node.type, catalogNodeData(child), { x: 0, y: 0 }), parentId });
122
+ if (child.collection === 'services') services.push({ resource: child, parentId });
123
+ }
124
+ }
125
+ };
126
+ fill(resource, root.id);
127
+
128
+ // The messages its services send and receive (in the domain or system dropped), and their data stores (next to them)
129
+ for (const service of services) {
130
+ for (const relation of catalog.relationsByKey.get(service.resource.key) ?? []) {
131
+ if (relation.label === 'contains') continue;
132
+ const other = catalog.resourcesByKey.get(relation.source === service.resource.key ? relation.target : relation.source);
133
+ if (!other || added(other.key) || canBeContainer(other) || other.collection === 'services') continue;
134
+ const parentId = other.collection === 'containers' ? service.parentId : root.id;
135
+ nodes.push({ ...buildNode(other.node.type, catalogNodeData(other), { x: 0, y: 0 }), parentId });
136
+ }
137
+ }
138
+
139
+ if (nodes.length === 1) return { nodes: [buildContainer(resource, center)], edges: [] };
140
+
141
+ // Connections between them, and to related resources already on the canvas
142
+ const inside: Edge[] = [];
143
+ const outside: Edge[] = [];
144
+ const placed = [...existing];
145
+ for (const node of nodes) {
146
+ const link = getCatalogLink(node);
147
+ const related = link && catalog.resourcesByKey.get(link.key);
148
+ if (related) {
149
+ for (const edge of getRelatedEdges(related, node.id, placed, catalog.relationsByKey, node.type)) {
150
+ (existing.some((other) => other.id === edge.source || other.id === edge.target) ? outside : inside).push(edge);
151
+ }
152
+ }
153
+ placed.push(node);
154
+ }
155
+
156
+ // Laid out with its connections; containers are sized to fit what's in them
157
+ const laidOut = withGroupSizes((await layoutGraph(nodes, inside)).nodes);
158
+ const top = laidOut.find((node) => node.id === root.id)!;
159
+ const offset = { x: center.x - (top.width ?? 0) / 2 - top.position.x, y: center.y - (top.height ?? 0) / 2 - top.position.y };
160
+ return {
161
+ nodes: laidOut.map((node) => {
162
+ const original = nodes.find((candidate) => candidate.id === node.id)!;
163
+ return {
164
+ ...original,
165
+ position: node.parentId ? node.position : { x: node.position.x + offset.x, y: node.position.y + offset.y },
166
+ ...(isGroupType(node.type) && { width: node.width, height: node.height }),
167
+ };
168
+ }),
169
+ edges: [...inside, ...outside],
170
+ };
171
+ };
@@ -0,0 +1,97 @@
1
+ import type { Edge, Node, XYPosition } from '@xyflow/react';
2
+ import { shortId, toSharedNode } from './canvas-doc';
3
+ import { findGroupAtPoint, getAbsolutePosition, sizeOf, sortByHierarchy, withDescendants } from './grouping';
4
+ import { canGoInContainer } from './node-types';
5
+
6
+ /**
7
+ * Copying and pasting nodes, within a canvas or between canvases (through the system clipboard). What's copied is
8
+ * the selected nodes, everything inside selected containers, and the connections between them. Comments aren't.
9
+ */
10
+
11
+ /** The clipboard type Studio reads first (other apps get a plain text list of what was copied) */
12
+ export const CLIPBOARD_TYPE = 'application/x-eventcatalog-studio+json';
13
+ const MARKER = 'eventcatalog-studio/nodes@1';
14
+
15
+ export type ClipboardContent = { marker: typeof MARKER; nodes: Node[]; edges: Edge[] };
16
+
17
+ /** What's copied when these nodes are selected, or null if there's nothing to copy */
18
+ export const copyNodes = (nodes: Node[], edges: Edge[], selectedIds: string[]): ClipboardContent | null => {
19
+ if (selectedIds.length === 0) return null;
20
+ const lookup = new Map(nodes.map((node) => [node.id, node]));
21
+ const ids = withDescendants(selectedIds, nodes);
22
+ const copied = sortByHierarchy(nodes.filter((node) => ids.has(node.id))).map((node) => {
23
+ const shared = toSharedNode(node);
24
+ // Copied without its container: where it is on the canvas
25
+ if (shared.parentId && !ids.has(shared.parentId)) {
26
+ const { parentId: _, ...rest } = shared;
27
+ return { ...rest, position: getAbsolutePosition(node, lookup) };
28
+ }
29
+ return shared;
30
+ });
31
+ return {
32
+ marker: MARKER,
33
+ nodes: copied,
34
+ edges: edges.filter((edge) => ids.has(edge.source) && ids.has(edge.target)).map(({ selected: _, ...edge }) => edge),
35
+ };
36
+ };
37
+
38
+ /** Copied content read back from the clipboard, if it's Studio's */
39
+ export const readClipboard = (text: string | undefined): ClipboardContent | null => {
40
+ if (!text) return null;
41
+ try {
42
+ const content = JSON.parse(text) as Partial<ClipboardContent>;
43
+ return content.marker === MARKER && Array.isArray(content.nodes) && Array.isArray(content.edges)
44
+ ? (content as ClipboardContent)
45
+ : null;
46
+ } catch {
47
+ return null;
48
+ }
49
+ };
50
+
51
+ /**
52
+ * Copies of the content to add to the canvas, with new ids: centred on a point (e.g. the pointer), or moved by an
53
+ * offset from where they were copied. Copies that land in a container on the canvas go inside it.
54
+ */
55
+ export const pasteNodes = (
56
+ content: ClipboardContent,
57
+ existing: Node[],
58
+ place: { at: XYPosition } | { offset: XYPosition }
59
+ ): { nodes: Node[]; edges: Edge[] } => {
60
+ const ids = new Map(content.nodes.map((node) => [node.id, `${node.type ?? 'node'}-${shortId()}`]));
61
+ const roots = content.nodes.filter((node) => !node.parentId);
62
+ const offset = 'offset' in place ? place.offset : offsetToCentre(roots, place.at);
63
+
64
+ const lookup = new Map(existing.map((node) => [node.id, node]));
65
+ const nodes = content.nodes.map((node): Node => {
66
+ const id = ids.get(node.id)!;
67
+ if (node.parentId) return { ...node, id, parentId: ids.get(node.parentId) };
68
+ const position = { x: node.position.x + offset.x, y: node.position.y + offset.y };
69
+ const { width, height } = sizeOf(node);
70
+ const container = canGoInContainer(node.type)
71
+ ? findGroupAtPoint({ x: position.x + width / 2, y: position.y + height / 2 }, existing)
72
+ : undefined;
73
+ if (!container) return { ...node, id, position };
74
+ const origin = getAbsolutePosition(container, lookup);
75
+ return { ...node, id, parentId: container.id, position: { x: position.x - origin.x, y: position.y - origin.y } };
76
+ });
77
+
78
+ return {
79
+ nodes,
80
+ edges: content.edges.map((edge) => ({
81
+ ...edge,
82
+ id: `edge-${shortId()}`,
83
+ source: ids.get(edge.source)!,
84
+ target: ids.get(edge.target)!,
85
+ })),
86
+ };
87
+ };
88
+
89
+ /** How far to move nodes so the middle of them is at a point */
90
+ const offsetToCentre = (roots: Node[], at: XYPosition): XYPosition => {
91
+ if (roots.length === 0) return { x: 0, y: 0 };
92
+ const left = Math.min(...roots.map((node) => node.position.x));
93
+ const top = Math.min(...roots.map((node) => node.position.y));
94
+ const right = Math.max(...roots.map((node) => node.position.x + sizeOf(node).width));
95
+ const bottom = Math.max(...roots.map((node) => node.position.y + sizeOf(node).height));
96
+ return { x: at.x - (left + right) / 2, y: at.y - (top + bottom) / 2 };
97
+ };
@@ -0,0 +1,154 @@
1
+ import { memo, type ReactNode } from 'react';
2
+ import { useReactFlow, useStore } from '@xyflow/react';
3
+ import { Maximize, MessageCircle, Redo, SendToBack, StickyNote, Undo, ZoomIn, ZoomOut } from 'lucide-react';
4
+ import { LEVELS, type Level } from '../levels';
5
+
6
+ // The canvas's bottom bar: layout, view, levels of detail, undo, comments and notes
7
+
8
+ export const FIT_VIEW_OPTIONS = { padding: { top: '80px', right: '60px', bottom: '100px', left: '60px' } } as const;
9
+
10
+ function ControlButton({
11
+ label,
12
+ onClick,
13
+ disabled,
14
+ active,
15
+ children,
16
+ }: {
17
+ label: string;
18
+ onClick: () => void;
19
+ disabled?: boolean;
20
+ active?: boolean;
21
+ children: ReactNode;
22
+ }) {
23
+ return (
24
+ <button
25
+ title={label}
26
+ aria-label={label}
27
+ onClick={onClick}
28
+ disabled={disabled}
29
+ className={`rounded-full p-2 transition-colors ${
30
+ disabled
31
+ ? 'cursor-not-allowed opacity-40'
32
+ : active
33
+ ? 'bg-[rgb(var(--ec-accent-subtle))] text-[rgb(var(--ec-accent))]'
34
+ : 'text-[rgb(var(--ec-icon-color))] hover:bg-[rgb(var(--ec-page-border)/0.5)] hover:text-[rgb(var(--ec-icon-hover))]'
35
+ }`}
36
+ >
37
+ {children}
38
+ </button>
39
+ );
40
+ }
41
+
42
+ /** Isolated, so the bar only re-renders when the zoom changes (not on every pan) */
43
+ function ZoomLevel() {
44
+ const zoom = useStore((state) => Math.round(state.transform[2] * 100));
45
+ return <div className="w-12 text-center text-xs font-medium tabular-nums text-[rgb(var(--ec-page-text-muted))]">{zoom}%</div>;
46
+ }
47
+
48
+ const Divider = () => <div className="mx-1 h-6 w-px bg-[rgb(var(--ec-page-border))]" />;
49
+
50
+ function LevelSwitch({
51
+ level,
52
+ onChange,
53
+ unavailable,
54
+ }: {
55
+ level: Level;
56
+ onChange: (level: Level) => void;
57
+ unavailable: Partial<Record<Level, string | undefined>>;
58
+ }) {
59
+ return (
60
+ <div role="group" aria-label="Level of detail" className="flex items-center gap-0.5">
61
+ {LEVELS.map(({ level: value, description }) => {
62
+ const reason = unavailable[value];
63
+ const active = value === level;
64
+ return (
65
+ <button
66
+ key={value}
67
+ title={`Level ${value}: ${description}${reason ? `. ${reason}` : ''}`}
68
+ // Stays focusable when unavailable, so its title can say why
69
+ aria-disabled={!!reason}
70
+ aria-pressed={active}
71
+ onClick={() => !reason && onChange(value)}
72
+ className={`h-8 min-w-8 rounded-full px-2 text-xs font-semibold tabular-nums transition-colors ${
73
+ reason
74
+ ? 'cursor-not-allowed opacity-40'
75
+ : active
76
+ ? 'bg-[rgb(var(--ec-accent-subtle))] text-[rgb(var(--ec-accent))]'
77
+ : 'text-[rgb(var(--ec-page-text-muted))] hover:bg-[rgb(var(--ec-page-border)/0.5)]'
78
+ }`}
79
+ >
80
+ L{value}
81
+ </button>
82
+ );
83
+ })}
84
+ </div>
85
+ );
86
+ }
87
+
88
+ export default memo(function CanvasControls({
89
+ level,
90
+ onLevelChange,
91
+ unavailableLevels,
92
+ editable,
93
+ onReorder,
94
+ canUndo,
95
+ canRedo,
96
+ onUndo,
97
+ onRedo,
98
+ commentMode,
99
+ onToggleCommentMode,
100
+ onAddNote,
101
+ }: {
102
+ level: Level;
103
+ onLevelChange: (level: Level) => void;
104
+ unavailableLevels: Partial<Record<Level, string | undefined>>;
105
+ /** L1 and L2 are read only views of the canvas */
106
+ editable: boolean;
107
+ onReorder: () => void;
108
+ canUndo: boolean;
109
+ canRedo: boolean;
110
+ onUndo: () => void;
111
+ onRedo: () => void;
112
+ commentMode: boolean;
113
+ onToggleCommentMode: () => void;
114
+ onAddNote: () => void;
115
+ }) {
116
+ const { fitView, zoomIn, zoomOut } = useReactFlow();
117
+
118
+ return (
119
+ <div className="absolute bottom-4 left-1/2 z-10 -translate-x-1/2">
120
+ <div className="flex items-center gap-1 rounded-full border px-2 py-1 shadow-lg bg-[rgb(var(--ec-card-bg))] border-[rgb(var(--ec-page-border))]">
121
+ <ControlButton label="Reorder diagram (the visualiser's layout)" onClick={onReorder} disabled={!editable}>
122
+ <SendToBack className="h-4 w-4" />
123
+ </ControlButton>
124
+ <ControlButton label="Fit view" onClick={() => void fitView({ ...FIT_VIEW_OPTIONS, duration: 500 })}>
125
+ <Maximize className="h-4 w-4" />
126
+ </ControlButton>
127
+ <ControlButton label="Zoom out" onClick={() => void zoomOut({ duration: 200 })}>
128
+ <ZoomOut className="h-4 w-4" />
129
+ </ControlButton>
130
+ <ZoomLevel />
131
+ <ControlButton label="Zoom in" onClick={() => void zoomIn({ duration: 200 })}>
132
+ <ZoomIn className="h-4 w-4" />
133
+ </ControlButton>
134
+ <Divider />
135
+ <LevelSwitch level={level} onChange={onLevelChange} unavailable={unavailableLevels} />
136
+ <Divider />
137
+ <ControlButton label="Undo (⌘Z)" onClick={onUndo} disabled={!canUndo}>
138
+ <Undo className="h-4 w-4" />
139
+ </ControlButton>
140
+ <ControlButton label="Redo (⇧⌘Z)" onClick={onRedo} disabled={!canRedo}>
141
+ <Redo className="h-4 w-4" />
142
+ </ControlButton>
143
+ <Divider />
144
+ <ControlButton label="Comment (C)" onClick={onToggleCommentMode} active={commentMode} disabled={!editable}>
145
+ <MessageCircle className="h-4 w-4" />
146
+ </ControlButton>
147
+ {/* Notes can be added on every level, and are shown on the level they're added on */}
148
+ <ControlButton label={editable ? 'Add sticky note' : `Add sticky note to L${level}`} onClick={onAddNote}>
149
+ <StickyNote className="h-4 w-4 text-yellow-500" />
150
+ </ControlButton>
151
+ </div>
152
+ </div>
153
+ );
154
+ });