@exellix/diagrams-toolkit 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +95 -0
- package/package.json +41 -0
- package/src/ConceptSummaryStrip.jsx +75 -0
- package/src/DefaultGraphEdge.jsx +57 -0
- package/src/EmptyState.jsx +16 -0
- package/src/ErrorState.jsx +20 -0
- package/src/FlowCanvas.jsx +103 -0
- package/src/GraphCanvas.jsx +455 -0
- package/src/GraphCanvasZoomControl.jsx +59 -0
- package/src/InspectorShell.jsx +64 -0
- package/src/JsonDocumentPanel.jsx +95 -0
- package/src/ResourceDataTable.jsx +180 -0
- package/src/StatusBadge.jsx +41 -0
- package/src/SyncStateBanner.jsx +58 -0
- package/src/ViewTabStrip.jsx +39 -0
- package/src/ZoomControls.jsx +80 -0
- package/src/index.js +39 -0
- package/src/lib/computeEdgeBezierPath.js +32 -0
- package/src/lib/graphCanvasLayout.js +99 -0
- package/src/lib/graphCanvasZoom.js +33 -0
- package/src/lib/graphViewportResizePan.js +99 -0
- package/src/styles.css +4 -0
package/README.md
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# @exellix/diagrams-toolkit
|
|
2
|
+
|
|
3
|
+
Reusable React UI primitives for graph and diagram authoring: library tables, tabs, inspector shell, JSON panels, and an interactive **GraphCanvas** (pan/zoom, drag, SVG edges).
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @exellix/diagrams-toolkit react react-dom lucide-react
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Peer dependencies: `react`, `react-dom` (^18 or ^19).
|
|
12
|
+
|
|
13
|
+
## Tailwind CSS (required)
|
|
14
|
+
|
|
15
|
+
Components use Tailwind utility classes. Configure your app to scan the package source.
|
|
16
|
+
|
|
17
|
+
**Tailwind v4** (`src/index.css`):
|
|
18
|
+
|
|
19
|
+
```css
|
|
20
|
+
@import "tailwindcss";
|
|
21
|
+
@source "../node_modules/@exellix/diagrams-toolkit/src";
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
**Tailwind v3** (`tailwind.config.js`):
|
|
25
|
+
|
|
26
|
+
```js
|
|
27
|
+
content: ['src/**/*.{js,jsx}', 'node_modules/@exellix/diagrams-toolkit/src/**/*.{js,jsx}'],
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Optional base font helper:
|
|
31
|
+
|
|
32
|
+
```js
|
|
33
|
+
import '@exellix/diagrams-toolkit/styles.css';
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## GraphCanvas example
|
|
37
|
+
|
|
38
|
+
```jsx
|
|
39
|
+
import { useState } from 'react';
|
|
40
|
+
import { GraphCanvas } from '@exellix/diagrams-toolkit';
|
|
41
|
+
|
|
42
|
+
export function Demo() {
|
|
43
|
+
const [nodes, setNodes] = useState([
|
|
44
|
+
{ id: 'a', label: 'Task A', x: 80, y: 80 },
|
|
45
|
+
{ id: 'b', label: 'Task B', x: 400, y: 160 },
|
|
46
|
+
]);
|
|
47
|
+
const [selectedItem, setSelectedItem] = useState(null);
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<div className="h-[480px] flex flex-col">
|
|
51
|
+
<GraphCanvas
|
|
52
|
+
nodes={nodes}
|
|
53
|
+
edges={[{ from: 'a', to: 'b' }]}
|
|
54
|
+
onNodesChange={setNodes}
|
|
55
|
+
selectedItem={selectedItem}
|
|
56
|
+
onSelectItem={setSelectedItem}
|
|
57
|
+
renderNode={(node, { selected, onPointerDown }) => (
|
|
58
|
+
<button
|
|
59
|
+
type="button"
|
|
60
|
+
onPointerDown={onPointerDown}
|
|
61
|
+
className={`absolute p-3 rounded-lg border ${selected ? 'border-indigo-500' : 'border-slate-200'}`}
|
|
62
|
+
style={{ left: node.x, top: node.y }}
|
|
63
|
+
>
|
|
64
|
+
{node.label}
|
|
65
|
+
</button>
|
|
66
|
+
)}
|
|
67
|
+
/>
|
|
68
|
+
</div>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Exports
|
|
74
|
+
|
|
75
|
+
| Export | Purpose |
|
|
76
|
+
|--------|---------|
|
|
77
|
+
| `GraphCanvas` | Full topology canvas (pan, zoom, drag, edges) |
|
|
78
|
+
| `GraphCanvasZoomControl` | Zoom slider + fit view |
|
|
79
|
+
| `DefaultGraphEdge` | Simple SVG edge renderer |
|
|
80
|
+
| `layoutGraphNodesFromTopology` | Auto-layout + in/out edge counts |
|
|
81
|
+
| `computeEdgeBezierPath` | Edge geometry helper |
|
|
82
|
+
| `FlowCanvas` | Legacy simple flow view (deprecated; use `GraphCanvas`) |
|
|
83
|
+
| `ViewTabStrip`, `ResourceDataTable`, `InspectorShell`, … | Shell UI primitives |
|
|
84
|
+
|
|
85
|
+
## Monorepo demo
|
|
86
|
+
|
|
87
|
+
From the workspace root:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
npm run demo:toolkit
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Publish
|
|
94
|
+
|
|
95
|
+
This package ships raw ESM from `src/` (same pattern as other `@exellix/*` libs in the monorepo).
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@exellix/diagrams-toolkit",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Reusable React UI primitives for graph and diagram authoring (tabs, tables, flow canvas, JSON panels).",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=20.19.0 || >=22.12.0"
|
|
8
|
+
},
|
|
9
|
+
"publishConfig": {
|
|
10
|
+
"access": "public",
|
|
11
|
+
"registry": "https://registry.npmjs.org/"
|
|
12
|
+
},
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+ssh://git@github.com/exellix/graphs-studio.git",
|
|
16
|
+
"directory": "packages/diagrams-toolkit"
|
|
17
|
+
},
|
|
18
|
+
"main": "./src/index.js",
|
|
19
|
+
"exports": {
|
|
20
|
+
".": "./src/index.js",
|
|
21
|
+
"./styles.css": "./src/styles.css"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"src"
|
|
25
|
+
],
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"react": "^18.0.0 || ^19.0.0",
|
|
28
|
+
"react-dom": "^18.0.0 || ^19.0.0"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"lucide-react": "^1.21.0"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"react": "^19.2.7",
|
|
35
|
+
"react-dom": "^19.2.7"
|
|
36
|
+
},
|
|
37
|
+
"scripts": {
|
|
38
|
+
"demo": "npm run dev -w diagrams-toolkit-demo",
|
|
39
|
+
"prepublishOnly": "node --test tests/diagrams-toolkit.test.mjs"
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { ChevronDown, ChevronRight } from 'lucide-react';
|
|
3
|
+
import { StatusBadge } from './StatusBadge.jsx';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @param {{
|
|
7
|
+
* title?: string,
|
|
8
|
+
* typeLabel?: string,
|
|
9
|
+
* intentLabel?: string,
|
|
10
|
+
* objective?: string,
|
|
11
|
+
* status?: string,
|
|
12
|
+
* collapsed?: boolean,
|
|
13
|
+
* onToggleCollapsed?: () => void,
|
|
14
|
+
* onOpenConcept?: () => void,
|
|
15
|
+
* className?: string,
|
|
16
|
+
* }} props
|
|
17
|
+
*/
|
|
18
|
+
export function ConceptSummaryStrip({
|
|
19
|
+
title,
|
|
20
|
+
typeLabel,
|
|
21
|
+
intentLabel,
|
|
22
|
+
objective,
|
|
23
|
+
status = 'draft',
|
|
24
|
+
collapsed = false,
|
|
25
|
+
onToggleCollapsed,
|
|
26
|
+
onOpenConcept,
|
|
27
|
+
className = '',
|
|
28
|
+
}) {
|
|
29
|
+
const chipText = typeLabel
|
|
30
|
+
? `${typeLabel}${intentLabel ? ` · ${intentLabel}` : ''}`
|
|
31
|
+
: objective?.slice(0, 60) || title || 'Concept';
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<div
|
|
35
|
+
className={`shrink-0 border-b border-slate-200 bg-white px-3 py-2 flex items-center gap-2 ${className}`}
|
|
36
|
+
>
|
|
37
|
+
{onToggleCollapsed ? (
|
|
38
|
+
<button
|
|
39
|
+
type="button"
|
|
40
|
+
onClick={onToggleCollapsed}
|
|
41
|
+
className="p-1 rounded hover:bg-slate-100 text-slate-500"
|
|
42
|
+
aria-expanded={!collapsed}
|
|
43
|
+
>
|
|
44
|
+
{collapsed ? <ChevronRight size={16} /> : <ChevronDown size={16} />}
|
|
45
|
+
</button>
|
|
46
|
+
) : null}
|
|
47
|
+
<StatusBadge status={status} />
|
|
48
|
+
{collapsed ? (
|
|
49
|
+
<button
|
|
50
|
+
type="button"
|
|
51
|
+
onClick={onOpenConcept}
|
|
52
|
+
className="text-sm text-slate-700 truncate hover:text-indigo-700"
|
|
53
|
+
>
|
|
54
|
+
{chipText}
|
|
55
|
+
</button>
|
|
56
|
+
) : (
|
|
57
|
+
<div className="flex-1 min-w-0 text-sm text-slate-700">
|
|
58
|
+
{title ? <span className="font-medium">{title}</span> : null}
|
|
59
|
+
{objective ? (
|
|
60
|
+
<p className="text-xs text-slate-500 truncate mt-0.5">{objective}</p>
|
|
61
|
+
) : null}
|
|
62
|
+
</div>
|
|
63
|
+
)}
|
|
64
|
+
{onOpenConcept ? (
|
|
65
|
+
<button
|
|
66
|
+
type="button"
|
|
67
|
+
onClick={onOpenConcept}
|
|
68
|
+
className="text-xs font-medium text-indigo-600 hover:text-indigo-800 shrink-0"
|
|
69
|
+
>
|
|
70
|
+
Open concept
|
|
71
|
+
</button>
|
|
72
|
+
) : null}
|
|
73
|
+
</div>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { computeEdgeBezierPath } from './lib/computeEdgeBezierPath.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Simple topology edge with optional label.
|
|
6
|
+
*
|
|
7
|
+
* @param {{
|
|
8
|
+
* edge: { from: string, to: string, label?: string },
|
|
9
|
+
* sourceNode: { x: number, y: number },
|
|
10
|
+
* targetNode: { x: number, y: number },
|
|
11
|
+
* selected?: boolean,
|
|
12
|
+
* filteredOut?: boolean,
|
|
13
|
+
* onClick?: (edge: object) => void,
|
|
14
|
+
* nodeWidth?: number,
|
|
15
|
+
* anchorY?: number,
|
|
16
|
+
* }} props
|
|
17
|
+
*/
|
|
18
|
+
export function DefaultGraphEdge({
|
|
19
|
+
edge,
|
|
20
|
+
sourceNode,
|
|
21
|
+
targetNode,
|
|
22
|
+
selected = false,
|
|
23
|
+
filteredOut = false,
|
|
24
|
+
onClick,
|
|
25
|
+
nodeWidth,
|
|
26
|
+
anchorY,
|
|
27
|
+
}) {
|
|
28
|
+
if (!sourceNode || !targetNode) return null;
|
|
29
|
+
|
|
30
|
+
const { path, midX, midY } = computeEdgeBezierPath(sourceNode, targetNode, { nodeWidth, anchorY });
|
|
31
|
+
const stroke = selected ? '#4f46e5' : '#cbd5e1';
|
|
32
|
+
const marker = selected ? 'selected' : 'normal';
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<g
|
|
36
|
+
className={`cursor-pointer pointer-events-auto edge-element ${filteredOut ? 'opacity-20' : ''}`}
|
|
37
|
+
onClick={(e) => {
|
|
38
|
+
e.stopPropagation();
|
|
39
|
+
onClick?.(edge);
|
|
40
|
+
}}
|
|
41
|
+
>
|
|
42
|
+
<path d={path} fill="none" stroke="transparent" strokeWidth="20" />
|
|
43
|
+
<path
|
|
44
|
+
d={path}
|
|
45
|
+
fill="none"
|
|
46
|
+
stroke={stroke}
|
|
47
|
+
strokeWidth={selected ? '3' : '2'}
|
|
48
|
+
markerEnd={`url(#arrowhead-${marker})`}
|
|
49
|
+
/>
|
|
50
|
+
{edge.label ? (
|
|
51
|
+
<text x={midX} y={midY - 6} fontSize={10} fill="#64748b" textAnchor="middle">
|
|
52
|
+
{edge.label}
|
|
53
|
+
</text>
|
|
54
|
+
) : null}
|
|
55
|
+
</g>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @param {{ title: string, detail?: string, action?: React.ReactNode, className?: string }} props
|
|
5
|
+
*/
|
|
6
|
+
export function EmptyState({ title, detail, action, className = '' }) {
|
|
7
|
+
return (
|
|
8
|
+
<div
|
|
9
|
+
className={`flex flex-col items-center justify-center text-center px-4 py-12 ${className}`}
|
|
10
|
+
>
|
|
11
|
+
<p className="text-sm font-medium text-slate-700">{title}</p>
|
|
12
|
+
{detail ? <p className="text-sm text-slate-500 mt-2 max-w-md">{detail}</p> : null}
|
|
13
|
+
{action ? <div className="mt-4">{action}</div> : null}
|
|
14
|
+
</div>
|
|
15
|
+
);
|
|
16
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { AlertCircle } from 'lucide-react';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @param {{ title: string, detail?: string, action?: React.ReactNode, className?: string }} props
|
|
6
|
+
*/
|
|
7
|
+
export function ErrorState({ title, detail, action, className = '' }) {
|
|
8
|
+
return (
|
|
9
|
+
<div
|
|
10
|
+
className={`flex flex-col items-center justify-center text-center px-4 py-12 bg-slate-100 ${className}`}
|
|
11
|
+
>
|
|
12
|
+
<AlertCircle className="w-8 h-8 text-red-500 mb-3" aria-hidden />
|
|
13
|
+
<p className="text-sm font-medium text-slate-700">{title}</p>
|
|
14
|
+
{detail ? (
|
|
15
|
+
<p className="text-xs font-mono text-red-700 mt-2 break-all max-w-lg">{detail}</p>
|
|
16
|
+
) : null}
|
|
17
|
+
{action ? <div className="mt-4">{action}</div> : null}
|
|
18
|
+
</div>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import React, { useMemo } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @typedef {{ id: string, label: string, role?: string, x?: number, y?: number, meta?: Record<string, unknown> }} FlowNode
|
|
5
|
+
* @typedef {{ id: string, from: string, to: string, label?: string }} FlowEdge
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @deprecated Use {@link GraphCanvas} for interactive pan/zoom/drag canvas.
|
|
10
|
+
* @param {{
|
|
11
|
+
* nodes: FlowNode[],
|
|
12
|
+
* edges: FlowEdge[],
|
|
13
|
+
* selectedId?: string | null,
|
|
14
|
+
* onSelect?: (id: string | null) => void,
|
|
15
|
+
* renderNode?: (node: FlowNode, selected: boolean) => React.ReactNode,
|
|
16
|
+
* className?: string,
|
|
17
|
+
* }} props
|
|
18
|
+
*/
|
|
19
|
+
export function FlowCanvas({
|
|
20
|
+
nodes = [],
|
|
21
|
+
edges = [],
|
|
22
|
+
selectedId = null,
|
|
23
|
+
onSelect,
|
|
24
|
+
renderNode,
|
|
25
|
+
className = '',
|
|
26
|
+
}) {
|
|
27
|
+
const layout = useMemo(() => {
|
|
28
|
+
const positioned = nodes.map((n, i) => ({
|
|
29
|
+
...n,
|
|
30
|
+
x: typeof n.x === 'number' ? n.x : 40 + (i % 3) * 220,
|
|
31
|
+
y: typeof n.y === 'number' ? n.y : 40 + Math.floor(i / 3) * 120,
|
|
32
|
+
}));
|
|
33
|
+
const byId = new Map(positioned.map((n) => [n.id, n]));
|
|
34
|
+
return { positioned, byId };
|
|
35
|
+
}, [nodes]);
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<div className={`relative flex-1 min-h-0 overflow-auto bg-slate-100 ${className}`}>
|
|
39
|
+
<svg className="absolute inset-0 w-full h-full pointer-events-none min-h-[400px]">
|
|
40
|
+
{edges.map((e) => {
|
|
41
|
+
const from = layout.byId.get(e.from);
|
|
42
|
+
const to = layout.byId.get(e.to);
|
|
43
|
+
if (!from || !to) return null;
|
|
44
|
+
const x1 = from.x + 90;
|
|
45
|
+
const y1 = from.y + 28;
|
|
46
|
+
const x2 = to.x + 10;
|
|
47
|
+
const y2 = to.y + 28;
|
|
48
|
+
return (
|
|
49
|
+
<g key={e.id}>
|
|
50
|
+
<line x1={x1} y1={y1} x2={x2} y2={y2} stroke="#94a3b8" strokeWidth={2} markerEnd="url(#arrow)" />
|
|
51
|
+
{e.label ? (
|
|
52
|
+
<text x={(x1 + x2) / 2} y={(y1 + y2) / 2 - 6} fontSize={10} fill="#64748b" textAnchor="middle">
|
|
53
|
+
{e.label}
|
|
54
|
+
</text>
|
|
55
|
+
) : null}
|
|
56
|
+
</g>
|
|
57
|
+
);
|
|
58
|
+
})}
|
|
59
|
+
<defs>
|
|
60
|
+
<marker id="arrow" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">
|
|
61
|
+
<path d="M0,0 L6,3 L0,6 Z" fill="#94a3b8" />
|
|
62
|
+
</marker>
|
|
63
|
+
</defs>
|
|
64
|
+
</svg>
|
|
65
|
+
<div className="relative min-h-[400px] p-4">
|
|
66
|
+
{layout.positioned.map((node) => {
|
|
67
|
+
const selected = node.id === selectedId;
|
|
68
|
+
if (renderNode) {
|
|
69
|
+
return (
|
|
70
|
+
<div
|
|
71
|
+
key={node.id}
|
|
72
|
+
className="absolute"
|
|
73
|
+
style={{ left: node.x, top: node.y }}
|
|
74
|
+
onClick={() => onSelect?.(node.id)}
|
|
75
|
+
>
|
|
76
|
+
{renderNode(node, selected)}
|
|
77
|
+
</div>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
return (
|
|
81
|
+
<button
|
|
82
|
+
key={node.id}
|
|
83
|
+
type="button"
|
|
84
|
+
onClick={() => onSelect?.(node.id)}
|
|
85
|
+
className={`absolute w-[180px] text-left p-3 rounded-lg border shadow-sm transition-colors ${
|
|
86
|
+
selected
|
|
87
|
+
? 'border-indigo-500 bg-indigo-50 ring-2 ring-indigo-200'
|
|
88
|
+
: 'border-slate-200 bg-white hover:border-slate-300'
|
|
89
|
+
}`}
|
|
90
|
+
style={{ left: node.x, top: node.y }}
|
|
91
|
+
>
|
|
92
|
+
<div className="text-xs font-mono text-slate-400 truncate">{node.id}</div>
|
|
93
|
+
<div className="text-sm font-medium text-slate-800 truncate">{node.label}</div>
|
|
94
|
+
{node.role ? (
|
|
95
|
+
<div className="text-[10px] uppercase tracking-wide text-slate-500 mt-1">{node.role}</div>
|
|
96
|
+
) : null}
|
|
97
|
+
</button>
|
|
98
|
+
);
|
|
99
|
+
})}
|
|
100
|
+
</div>
|
|
101
|
+
</div>
|
|
102
|
+
);
|
|
103
|
+
}
|