@devfellowship/components 3.1.0 → 3.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/dist/canvas.cjs +305 -0
- package/dist/canvas.d.cts +175 -0
- package/dist/canvas.d.ts +175 -0
- package/dist/canvas.js +273 -0
- package/dist/styles/canvas.css +31 -0
- package/package.json +19 -2
package/dist/canvas.cjs
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
|
|
30
|
+
// src/canvas/index.ts
|
|
31
|
+
var canvas_exports = {};
|
|
32
|
+
__export(canvas_exports, {
|
|
33
|
+
DEFAULT_NODE_HEIGHT: () => DEFAULT_NODE_HEIGHT,
|
|
34
|
+
DEFAULT_NODE_SEPARATION: () => DEFAULT_NODE_SEPARATION,
|
|
35
|
+
DEFAULT_NODE_WIDTH: () => DEFAULT_NODE_WIDTH,
|
|
36
|
+
DEFAULT_RANK_SEPARATION: () => DEFAULT_RANK_SEPARATION,
|
|
37
|
+
FlowCanvas: () => FlowCanvas,
|
|
38
|
+
layoutGraph: () => layoutGraph
|
|
39
|
+
});
|
|
40
|
+
module.exports = __toCommonJS(canvas_exports);
|
|
41
|
+
|
|
42
|
+
// src/canvas/FlowCanvas.tsx
|
|
43
|
+
var import_react = require("react");
|
|
44
|
+
var import_react2 = require("@xyflow/react");
|
|
45
|
+
|
|
46
|
+
// src/lib/utils.ts
|
|
47
|
+
var import_clsx = require("clsx");
|
|
48
|
+
var import_tailwind_merge = require("tailwind-merge");
|
|
49
|
+
function cn(...inputs) {
|
|
50
|
+
return (0, import_tailwind_merge.twMerge)((0, import_clsx.clsx)(inputs));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// src/canvas/layout.ts
|
|
54
|
+
var import_dagre = __toESM(require("@dagrejs/dagre"), 1);
|
|
55
|
+
var DEFAULT_NODE_WIDTH = 240;
|
|
56
|
+
var DEFAULT_NODE_HEIGHT = 202;
|
|
57
|
+
var DEFAULT_NODE_SEPARATION = 48;
|
|
58
|
+
var DEFAULT_RANK_SEPARATION = 120;
|
|
59
|
+
function layoutGraph(nodes, edges, options = {}) {
|
|
60
|
+
const {
|
|
61
|
+
direction = "LR",
|
|
62
|
+
nodeWidth = DEFAULT_NODE_WIDTH,
|
|
63
|
+
nodeHeight = DEFAULT_NODE_HEIGHT,
|
|
64
|
+
nodeSeparation = DEFAULT_NODE_SEPARATION,
|
|
65
|
+
rankSeparation = DEFAULT_RANK_SEPARATION
|
|
66
|
+
} = options;
|
|
67
|
+
const known = new Set(nodes.map((n) => n.id));
|
|
68
|
+
const usable = edges.filter((e) => known.has(e.source) && known.has(e.target));
|
|
69
|
+
const g = new import_dagre.default.graphlib.Graph();
|
|
70
|
+
g.setDefaultEdgeLabel(() => ({}));
|
|
71
|
+
g.setGraph({ rankdir: direction, nodesep: nodeSeparation, ranksep: rankSeparation, marginx: 24, marginy: 24 });
|
|
72
|
+
for (const n of nodes) g.setNode(n.id, { width: nodeWidth, height: nodeHeight });
|
|
73
|
+
for (const e of usable) g.setEdge(e.source, e.target);
|
|
74
|
+
import_dagre.default.layout(g);
|
|
75
|
+
const rfNodes = nodes.map((n) => {
|
|
76
|
+
const laid = g.node(n.id);
|
|
77
|
+
return {
|
|
78
|
+
id: n.id,
|
|
79
|
+
type: "flowCanvasCard",
|
|
80
|
+
position: {
|
|
81
|
+
x: laid ? laid.x - nodeWidth / 2 : 0,
|
|
82
|
+
y: laid ? laid.y - nodeHeight / 2 : 0
|
|
83
|
+
},
|
|
84
|
+
data: { node: n },
|
|
85
|
+
// Explicit width/height, not just post-mount measurement: React Flow's
|
|
86
|
+
// MiniMap only draws a node rect once the node "has dimensions"
|
|
87
|
+
// (`measured` OR explicit width/height — see @xyflow/system
|
|
88
|
+
// nodeHasDimensions). Without this the minimap renders zero rects and
|
|
89
|
+
// reads as an empty panel.
|
|
90
|
+
width: nodeWidth,
|
|
91
|
+
height: nodeHeight
|
|
92
|
+
};
|
|
93
|
+
});
|
|
94
|
+
const pairCounts = /* @__PURE__ */ new Map();
|
|
95
|
+
for (const e of usable) {
|
|
96
|
+
const key = pairKey(e);
|
|
97
|
+
pairCounts.set(key, (pairCounts.get(key) ?? 0) + 1);
|
|
98
|
+
}
|
|
99
|
+
const pairSeen = /* @__PURE__ */ new Map();
|
|
100
|
+
const rfEdges = usable.map((e) => {
|
|
101
|
+
const key = pairKey(e);
|
|
102
|
+
const pairIndex = pairSeen.get(key) ?? 0;
|
|
103
|
+
pairSeen.set(key, pairIndex + 1);
|
|
104
|
+
return {
|
|
105
|
+
id: e.id,
|
|
106
|
+
source: e.source,
|
|
107
|
+
target: e.target,
|
|
108
|
+
label: e.label,
|
|
109
|
+
type: "flowCanvasChip",
|
|
110
|
+
data: { pairIndex, pairCount: pairCounts.get(key) ?? 1 }
|
|
111
|
+
};
|
|
112
|
+
});
|
|
113
|
+
return { nodes: rfNodes, edges: rfEdges };
|
|
114
|
+
}
|
|
115
|
+
function pairKey(e) {
|
|
116
|
+
return [e.source, e.target].sort().join("|");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// src/canvas/FlowCanvas.tsx
|
|
120
|
+
var import_jsx_runtime = require("react/jsx-runtime");
|
|
121
|
+
var DEFAULT_CARD_CONTENT_HEIGHT = 130;
|
|
122
|
+
var MINIMAP_NODE_COLOR = "#E07A4A";
|
|
123
|
+
var MINIMAP_MASK_COLOR = "rgba(10,9,8,0.72)";
|
|
124
|
+
var MINIMAP_BG_COLOR = "#141210";
|
|
125
|
+
var FlowCanvasContext = (0, import_react.createContext)(null);
|
|
126
|
+
function useFlowCanvas() {
|
|
127
|
+
const ctx = (0, import_react.useContext)(FlowCanvasContext);
|
|
128
|
+
if (!ctx) throw new Error("FlowCanvas internals rendered outside of <FlowCanvas>");
|
|
129
|
+
return ctx;
|
|
130
|
+
}
|
|
131
|
+
function FlowCanvasCard({ data }) {
|
|
132
|
+
const { renderCard, renderCaption, clickable, nodeWidth, cardContentHeight, horizontal, testIdPrefix } = useFlowCanvas();
|
|
133
|
+
const node = data.node;
|
|
134
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
|
|
135
|
+
"div",
|
|
136
|
+
{
|
|
137
|
+
className: cn(
|
|
138
|
+
"overflow-hidden rounded-lg border border-s-brand-ring bg-card shadow-lg shadow-black/40",
|
|
139
|
+
clickable && "cursor-pointer"
|
|
140
|
+
),
|
|
141
|
+
style: { width: nodeWidth },
|
|
142
|
+
"data-testid": `${testIdPrefix}-node`,
|
|
143
|
+
"data-node-id": node.id,
|
|
144
|
+
children: [
|
|
145
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
146
|
+
import_react2.Handle,
|
|
147
|
+
{
|
|
148
|
+
type: "target",
|
|
149
|
+
position: horizontal ? import_react2.Position.Left : import_react2.Position.Top,
|
|
150
|
+
className: "!h-2 !w-2 !border-none !bg-primary"
|
|
151
|
+
}
|
|
152
|
+
),
|
|
153
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
154
|
+
"div",
|
|
155
|
+
{
|
|
156
|
+
className: "flex w-full items-center justify-center overflow-hidden border-b border-border bg-muted",
|
|
157
|
+
style: { height: cardContentHeight },
|
|
158
|
+
"data-testid": `${testIdPrefix}-node-content`,
|
|
159
|
+
children: renderCard(node)
|
|
160
|
+
}
|
|
161
|
+
),
|
|
162
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "px-3 py-2 text-xs", "data-testid": `${testIdPrefix}-node-caption`, children: renderCaption ? renderCaption(node) : /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [
|
|
163
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "truncate font-semibold text-foreground", title: node.title, children: node.title }),
|
|
164
|
+
node.subtitle ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "truncate font-mono text-muted-foreground", title: node.subtitle, children: node.subtitle }) : null
|
|
165
|
+
] }) }),
|
|
166
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
167
|
+
import_react2.Handle,
|
|
168
|
+
{
|
|
169
|
+
type: "source",
|
|
170
|
+
position: horizontal ? import_react2.Position.Right : import_react2.Position.Bottom,
|
|
171
|
+
className: "!h-2 !w-2 !border-none !bg-primary"
|
|
172
|
+
}
|
|
173
|
+
)
|
|
174
|
+
]
|
|
175
|
+
}
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
function FlowCanvasChipEdge({
|
|
179
|
+
id,
|
|
180
|
+
sourceX,
|
|
181
|
+
sourceY,
|
|
182
|
+
targetX,
|
|
183
|
+
targetY,
|
|
184
|
+
sourcePosition,
|
|
185
|
+
targetPosition,
|
|
186
|
+
label,
|
|
187
|
+
data
|
|
188
|
+
}) {
|
|
189
|
+
const { testIdPrefix } = useFlowCanvas();
|
|
190
|
+
const [edgePath, labelX, labelY] = (0, import_react2.getSmoothStepPath)({
|
|
191
|
+
sourceX,
|
|
192
|
+
sourceY,
|
|
193
|
+
targetX,
|
|
194
|
+
targetY,
|
|
195
|
+
sourcePosition,
|
|
196
|
+
targetPosition
|
|
197
|
+
});
|
|
198
|
+
const pairIndex = data?.pairIndex ?? 0;
|
|
199
|
+
const pairCount = data?.pairCount ?? 1;
|
|
200
|
+
const offsetY = pairCount > 1 ? (pairIndex - (pairCount - 1) / 2) * 22 : 0;
|
|
201
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [
|
|
202
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react2.BaseEdge, { id, path: edgePath, style: { stroke: "var(--s-brand-ring)", strokeWidth: 1.5 } }),
|
|
203
|
+
label ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react2.EdgeLabelRenderer, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
204
|
+
"div",
|
|
205
|
+
{
|
|
206
|
+
className: "nodrag nopan pointer-events-none absolute rounded-md border border-s-brand-ring bg-popover px-1.5 py-0.5 text-[10px] font-semibold text-foreground",
|
|
207
|
+
style: { transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY + offsetY}px)` },
|
|
208
|
+
"data-testid": `${testIdPrefix}-edge-label`,
|
|
209
|
+
children: label
|
|
210
|
+
}
|
|
211
|
+
) }) : null
|
|
212
|
+
] });
|
|
213
|
+
}
|
|
214
|
+
var nodeTypes = { flowCanvasCard: FlowCanvasCard };
|
|
215
|
+
var edgeTypes = { flowCanvasChip: FlowCanvasChipEdge };
|
|
216
|
+
function FlowCanvas({
|
|
217
|
+
nodes,
|
|
218
|
+
edges,
|
|
219
|
+
renderCard,
|
|
220
|
+
renderCaption,
|
|
221
|
+
onNodeClick,
|
|
222
|
+
direction = "LR",
|
|
223
|
+
nodeWidth = DEFAULT_NODE_WIDTH,
|
|
224
|
+
nodeHeight = DEFAULT_NODE_HEIGHT,
|
|
225
|
+
cardContentHeight = DEFAULT_CARD_CONTENT_HEIGHT,
|
|
226
|
+
nodeSeparation,
|
|
227
|
+
rankSeparation,
|
|
228
|
+
colorMode = "dark",
|
|
229
|
+
miniMap = true,
|
|
230
|
+
controls = true,
|
|
231
|
+
background = true,
|
|
232
|
+
testIdPrefix = "flow",
|
|
233
|
+
className,
|
|
234
|
+
ariaLabel = "Flow canvas"
|
|
235
|
+
}) {
|
|
236
|
+
const graph = (0, import_react.useMemo)(
|
|
237
|
+
() => layoutGraph(nodes, edges, { direction, nodeWidth, nodeHeight, nodeSeparation, rankSeparation }),
|
|
238
|
+
[nodes, edges, direction, nodeWidth, nodeHeight, nodeSeparation, rankSeparation]
|
|
239
|
+
);
|
|
240
|
+
const byId = (0, import_react.useMemo)(() => new Map(nodes.map((n) => [n.id, n])), [nodes]);
|
|
241
|
+
const ctx = (0, import_react.useMemo)(
|
|
242
|
+
() => ({
|
|
243
|
+
renderCard,
|
|
244
|
+
renderCaption,
|
|
245
|
+
clickable: Boolean(onNodeClick),
|
|
246
|
+
nodeWidth,
|
|
247
|
+
cardContentHeight,
|
|
248
|
+
horizontal: direction === "LR",
|
|
249
|
+
testIdPrefix
|
|
250
|
+
}),
|
|
251
|
+
[renderCard, renderCaption, onNodeClick, nodeWidth, cardContentHeight, direction, testIdPrefix]
|
|
252
|
+
);
|
|
253
|
+
const miniMapColors = typeof miniMap === "object" ? miniMap : {};
|
|
254
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(FlowCanvasContext.Provider, { value: ctx, children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
255
|
+
"div",
|
|
256
|
+
{
|
|
257
|
+
className: cn(
|
|
258
|
+
"h-[70vh] min-h-[520px] w-full overflow-hidden rounded-lg border border-border bg-card",
|
|
259
|
+
className
|
|
260
|
+
),
|
|
261
|
+
"data-testid": `${testIdPrefix}-canvas`,
|
|
262
|
+
role: "region",
|
|
263
|
+
"aria-label": ariaLabel,
|
|
264
|
+
children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
|
|
265
|
+
import_react2.ReactFlow,
|
|
266
|
+
{
|
|
267
|
+
nodes: graph.nodes,
|
|
268
|
+
edges: graph.edges,
|
|
269
|
+
nodeTypes,
|
|
270
|
+
edgeTypes,
|
|
271
|
+
colorMode,
|
|
272
|
+
fitView: true,
|
|
273
|
+
proOptions: { hideAttribution: true },
|
|
274
|
+
onNodeClick: onNodeClick ? (event, rfNode) => {
|
|
275
|
+
const node = byId.get(rfNode.id);
|
|
276
|
+
if (node) onNodeClick(node, event);
|
|
277
|
+
} : void 0,
|
|
278
|
+
children: [
|
|
279
|
+
background ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react2.Background, {}) : null,
|
|
280
|
+
controls ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react2.Controls, { showInteractive: false }) : null,
|
|
281
|
+
miniMap ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
282
|
+
import_react2.MiniMap,
|
|
283
|
+
{
|
|
284
|
+
pannable: true,
|
|
285
|
+
zoomable: true,
|
|
286
|
+
nodeColor: miniMapColors.nodeColor ?? MINIMAP_NODE_COLOR,
|
|
287
|
+
maskColor: miniMapColors.maskColor ?? MINIMAP_MASK_COLOR,
|
|
288
|
+
bgColor: miniMapColors.bgColor ?? MINIMAP_BG_COLOR
|
|
289
|
+
}
|
|
290
|
+
) : null
|
|
291
|
+
]
|
|
292
|
+
}
|
|
293
|
+
)
|
|
294
|
+
}
|
|
295
|
+
) });
|
|
296
|
+
}
|
|
297
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
298
|
+
0 && (module.exports = {
|
|
299
|
+
DEFAULT_NODE_HEIGHT,
|
|
300
|
+
DEFAULT_NODE_SEPARATION,
|
|
301
|
+
DEFAULT_NODE_WIDTH,
|
|
302
|
+
DEFAULT_RANK_SEPARATION,
|
|
303
|
+
FlowCanvas,
|
|
304
|
+
layoutGraph
|
|
305
|
+
});
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { ReactNode, MouseEvent } from 'react';
|
|
3
|
+
import { Node, Edge } from '@xyflow/react';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A node the canvas can lay out and draw a card for.
|
|
7
|
+
*
|
|
8
|
+
* The canvas reads exactly three things: `id` (identity, edge endpoints, React
|
|
9
|
+
* keys), `title` and `subtitle` (the caption). Everything else a lens needs
|
|
10
|
+
* travels in `data`, which the canvas NEVER inspects — it only hands it back to
|
|
11
|
+
* `renderCard` and `onNodeClick`. That opacity is the whole point: a card can
|
|
12
|
+
* draw a low-fidelity wireframe, a real screenshot, a coverage heat cell or a
|
|
13
|
+
* diff badge, and the canvas cannot tell the difference.
|
|
14
|
+
*/
|
|
15
|
+
interface FlowCanvasNode<TData = unknown> {
|
|
16
|
+
/** Stable identity. Edges point at it and React keys off it. */
|
|
17
|
+
id: string;
|
|
18
|
+
/** First caption line. */
|
|
19
|
+
title: string;
|
|
20
|
+
/** Second caption line, rendered monospaced. Typically a route or a path. */
|
|
21
|
+
subtitle?: string;
|
|
22
|
+
/** Opaque to the canvas. Handed back to `renderCard` and `onNodeClick`. */
|
|
23
|
+
data?: TData;
|
|
24
|
+
}
|
|
25
|
+
/** A directed edge between two `FlowCanvasNode.id`s. */
|
|
26
|
+
interface FlowCanvasEdge {
|
|
27
|
+
/** Stable identity. Two edges between the same pair must still differ here. */
|
|
28
|
+
id: string;
|
|
29
|
+
source: string;
|
|
30
|
+
target: string;
|
|
31
|
+
/** Drawn as a chip on the path midpoint. Omit for an unlabelled edge. */
|
|
32
|
+
label?: string;
|
|
33
|
+
}
|
|
34
|
+
/** Layout direction. `LR` reads as a journey, `TB` as a hierarchy. */
|
|
35
|
+
type FlowCanvasDirection = "LR" | "TB";
|
|
36
|
+
/**
|
|
37
|
+
* MiniMap colours. React Flow's MiniMap takes colour STRINGS (it writes them
|
|
38
|
+
* onto SVG `fill`/`stroke` attributes), so it cannot take a Tailwind class or a
|
|
39
|
+
* CSS custom property reliably. The defaults are the DFL sand/amber values
|
|
40
|
+
* written out literally for that reason — not because the tokens were ignored.
|
|
41
|
+
*/
|
|
42
|
+
interface FlowCanvasMiniMapColors {
|
|
43
|
+
nodeColor?: string;
|
|
44
|
+
maskColor?: string;
|
|
45
|
+
bgColor?: string;
|
|
46
|
+
}
|
|
47
|
+
interface FlowCanvasProps<TData = unknown> {
|
|
48
|
+
/** The graph. Positions are computed here — do not pre-position. */
|
|
49
|
+
nodes: FlowCanvasNode<TData>[];
|
|
50
|
+
edges: FlowCanvasEdge[];
|
|
51
|
+
/**
|
|
52
|
+
* THE CARD-CONTENT SLOT. Fills the card's content area, above the caption.
|
|
53
|
+
*
|
|
54
|
+
* This is the seam that keeps the canvas reusable: the consuming lens decides
|
|
55
|
+
* what a card shows. Return a sketch, an `<img>`, a chart, a placeholder —
|
|
56
|
+
* the canvas renders the frame around it and nothing else.
|
|
57
|
+
*/
|
|
58
|
+
renderCard: (node: FlowCanvasNode<TData>) => ReactNode;
|
|
59
|
+
/**
|
|
60
|
+
* Optional override for the caption under the card content. The default
|
|
61
|
+
* renders `title` over `subtitle`, which is what makes every lens's canvas
|
|
62
|
+
* read as the same map. Override it when a lens needs a badge or a marker
|
|
63
|
+
* in the caption row.
|
|
64
|
+
*/
|
|
65
|
+
renderCaption?: (node: FlowCanvasNode<TData>) => ReactNode;
|
|
66
|
+
/**
|
|
67
|
+
* Click handler for a card. The node comes first because that is what a lens
|
|
68
|
+
* acts on; the DOM event is there for modifier keys.
|
|
69
|
+
*
|
|
70
|
+
* When omitted the cards are not interactive and no pointer affordance is
|
|
71
|
+
* drawn — a canvas whose cards do nothing must not look clickable.
|
|
72
|
+
*/
|
|
73
|
+
onNodeClick?: (node: FlowCanvasNode<TData>, event: MouseEvent) => void;
|
|
74
|
+
/** @default "LR" */
|
|
75
|
+
direction?: FlowCanvasDirection;
|
|
76
|
+
/** Card width in px. Also the box dagre lays out around. @default 240 */
|
|
77
|
+
nodeWidth?: number;
|
|
78
|
+
/** Card height in px, caption included. @default 202 */
|
|
79
|
+
nodeHeight?: number;
|
|
80
|
+
/** Height of the card-content area in px. @default 130 */
|
|
81
|
+
cardContentHeight?: number;
|
|
82
|
+
/** Horizontal gap between siblings in a rank. @default 48 */
|
|
83
|
+
nodeSeparation?: number;
|
|
84
|
+
/** Gap between ranks. @default 120 */
|
|
85
|
+
rankSeparation?: number;
|
|
86
|
+
/** @default "dark" */
|
|
87
|
+
colorMode?: "dark" | "light" | "system";
|
|
88
|
+
/** `false` hides the MiniMap. An object overrides its colours. @default true */
|
|
89
|
+
miniMap?: boolean | FlowCanvasMiniMapColors;
|
|
90
|
+
/** @default true */
|
|
91
|
+
controls?: boolean;
|
|
92
|
+
/** @default true */
|
|
93
|
+
background?: boolean;
|
|
94
|
+
/**
|
|
95
|
+
* Prefix for every `data-testid` this component emits:
|
|
96
|
+
* `<prefix>-canvas`, `<prefix>-node`, `<prefix>-node-content`,
|
|
97
|
+
* `<prefix>-node-caption`, `<prefix>-edge-label`.
|
|
98
|
+
*
|
|
99
|
+
* A page with two canvases needs two prefixes, and a consumer whose suite
|
|
100
|
+
* already names the canvas can keep its selectors. @default "flow"
|
|
101
|
+
*/
|
|
102
|
+
testIdPrefix?: string;
|
|
103
|
+
/** Applied to the canvas frame (the bordered box), not to the viewport. */
|
|
104
|
+
className?: string;
|
|
105
|
+
/** Accessible name for the canvas region. @default "Flow canvas" */
|
|
106
|
+
ariaLabel?: string;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* FlowCanvas — a spatial, auto-laid-out graph of cards.
|
|
111
|
+
*
|
|
112
|
+
* ONE CANVAS, N LENSES. The canvas owns the map: layout, ranking, edge routing,
|
|
113
|
+
* the card frame, the caption, the minimap and the controls. The consuming lens
|
|
114
|
+
* owns what a card SHOWS (`renderCard`) and what a click DOES (`onNodeClick`).
|
|
115
|
+
* The canvas never inspects `node.data`, so a lens can put a low-fidelity
|
|
116
|
+
* wireframe in one card and a production screenshot in the next without the
|
|
117
|
+
* canvas learning about either. Duplicate the lens; never duplicate the canvas.
|
|
118
|
+
*
|
|
119
|
+
* ## Peer dependencies
|
|
120
|
+
* `@xyflow/react` and `@dagrejs/dagre` are OPTIONAL peer dependencies —
|
|
121
|
+
* install them only if you import this entry point. Consumers of
|
|
122
|
+
* `@devfellowship/components` who never draw a canvas pay nothing.
|
|
123
|
+
*
|
|
124
|
+
* ## Stylesheets the consumer must import
|
|
125
|
+
* ```css
|
|
126
|
+
* @import "@xyflow/react/dist/style.css"; /* React Flow's own base styles */
|
|
127
|
+
* @import "@devfellowship/components/canvas.css"; /* DS skin for its chrome */
|
|
128
|
+
* ```
|
|
129
|
+
*
|
|
130
|
+
* @example
|
|
131
|
+
* ```tsx
|
|
132
|
+
* <FlowCanvas
|
|
133
|
+
* nodes={screens.map((s) => ({ id: s.id, title: s.name, subtitle: s.route, data: s }))}
|
|
134
|
+
* edges={transitions}
|
|
135
|
+
* renderCard={(node) => <WireframePreview screen={node.data} />}
|
|
136
|
+
* onNodeClick={(node) => select(node.id)}
|
|
137
|
+
* />
|
|
138
|
+
* ```
|
|
139
|
+
*/
|
|
140
|
+
declare function FlowCanvas<TData = unknown>({ nodes, edges, renderCard, renderCaption, onNodeClick, direction, nodeWidth, nodeHeight, cardContentHeight, nodeSeparation, rankSeparation, colorMode, miniMap, controls, background, testIdPrefix, className, ariaLabel, }: FlowCanvasProps<TData>): React.JSX.Element;
|
|
141
|
+
|
|
142
|
+
declare const DEFAULT_NODE_WIDTH = 240;
|
|
143
|
+
declare const DEFAULT_NODE_HEIGHT = 202;
|
|
144
|
+
declare const DEFAULT_NODE_SEPARATION = 48;
|
|
145
|
+
declare const DEFAULT_RANK_SEPARATION = 120;
|
|
146
|
+
interface LayoutOptions {
|
|
147
|
+
direction?: FlowCanvasDirection;
|
|
148
|
+
nodeWidth?: number;
|
|
149
|
+
nodeHeight?: number;
|
|
150
|
+
nodeSeparation?: number;
|
|
151
|
+
rankSeparation?: number;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Directional auto-layout for the flow canvas.
|
|
155
|
+
*
|
|
156
|
+
* Ranks nodes by dagre's layered algorithm, which computes rank AND in-rank
|
|
157
|
+
* order at once, so it also minimises edge crossings between ranks. The naive
|
|
158
|
+
* alternative — BFS level to column, insertion order to row — overlaps boxes as
|
|
159
|
+
* soon as the number of nodes per level varies.
|
|
160
|
+
*
|
|
161
|
+
* ⚠️ Do NOT inject a synthetic "super-source" edge to pull entry nodes to the
|
|
162
|
+
* front. It pushes genuine entry nodes one rank to the RIGHT of disconnected
|
|
163
|
+
* orphan nodes, which keep dagre's default rank 0 — so an orphan renders as if
|
|
164
|
+
* it came BEFORE the entry point. Plain ranking from the real edges already
|
|
165
|
+
* gives every node with no incoming edge rank 0, which is the correct result.
|
|
166
|
+
*
|
|
167
|
+
* The function is PURE and side-effect free, which is what makes it testable
|
|
168
|
+
* without a DOM: same graph in, same coordinates out.
|
|
169
|
+
*/
|
|
170
|
+
declare function layoutGraph<TData>(nodes: FlowCanvasNode<TData>[], edges: FlowCanvasEdge[], options?: LayoutOptions): {
|
|
171
|
+
nodes: Node[];
|
|
172
|
+
edges: Edge[];
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
export { DEFAULT_NODE_HEIGHT, DEFAULT_NODE_SEPARATION, DEFAULT_NODE_WIDTH, DEFAULT_RANK_SEPARATION, FlowCanvas, type FlowCanvasDirection, type FlowCanvasEdge, type FlowCanvasMiniMapColors, type FlowCanvasNode, type FlowCanvasProps, type LayoutOptions, layoutGraph };
|
package/dist/canvas.d.ts
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { ReactNode, MouseEvent } from 'react';
|
|
3
|
+
import { Node, Edge } from '@xyflow/react';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A node the canvas can lay out and draw a card for.
|
|
7
|
+
*
|
|
8
|
+
* The canvas reads exactly three things: `id` (identity, edge endpoints, React
|
|
9
|
+
* keys), `title` and `subtitle` (the caption). Everything else a lens needs
|
|
10
|
+
* travels in `data`, which the canvas NEVER inspects — it only hands it back to
|
|
11
|
+
* `renderCard` and `onNodeClick`. That opacity is the whole point: a card can
|
|
12
|
+
* draw a low-fidelity wireframe, a real screenshot, a coverage heat cell or a
|
|
13
|
+
* diff badge, and the canvas cannot tell the difference.
|
|
14
|
+
*/
|
|
15
|
+
interface FlowCanvasNode<TData = unknown> {
|
|
16
|
+
/** Stable identity. Edges point at it and React keys off it. */
|
|
17
|
+
id: string;
|
|
18
|
+
/** First caption line. */
|
|
19
|
+
title: string;
|
|
20
|
+
/** Second caption line, rendered monospaced. Typically a route or a path. */
|
|
21
|
+
subtitle?: string;
|
|
22
|
+
/** Opaque to the canvas. Handed back to `renderCard` and `onNodeClick`. */
|
|
23
|
+
data?: TData;
|
|
24
|
+
}
|
|
25
|
+
/** A directed edge between two `FlowCanvasNode.id`s. */
|
|
26
|
+
interface FlowCanvasEdge {
|
|
27
|
+
/** Stable identity. Two edges between the same pair must still differ here. */
|
|
28
|
+
id: string;
|
|
29
|
+
source: string;
|
|
30
|
+
target: string;
|
|
31
|
+
/** Drawn as a chip on the path midpoint. Omit for an unlabelled edge. */
|
|
32
|
+
label?: string;
|
|
33
|
+
}
|
|
34
|
+
/** Layout direction. `LR` reads as a journey, `TB` as a hierarchy. */
|
|
35
|
+
type FlowCanvasDirection = "LR" | "TB";
|
|
36
|
+
/**
|
|
37
|
+
* MiniMap colours. React Flow's MiniMap takes colour STRINGS (it writes them
|
|
38
|
+
* onto SVG `fill`/`stroke` attributes), so it cannot take a Tailwind class or a
|
|
39
|
+
* CSS custom property reliably. The defaults are the DFL sand/amber values
|
|
40
|
+
* written out literally for that reason — not because the tokens were ignored.
|
|
41
|
+
*/
|
|
42
|
+
interface FlowCanvasMiniMapColors {
|
|
43
|
+
nodeColor?: string;
|
|
44
|
+
maskColor?: string;
|
|
45
|
+
bgColor?: string;
|
|
46
|
+
}
|
|
47
|
+
interface FlowCanvasProps<TData = unknown> {
|
|
48
|
+
/** The graph. Positions are computed here — do not pre-position. */
|
|
49
|
+
nodes: FlowCanvasNode<TData>[];
|
|
50
|
+
edges: FlowCanvasEdge[];
|
|
51
|
+
/**
|
|
52
|
+
* THE CARD-CONTENT SLOT. Fills the card's content area, above the caption.
|
|
53
|
+
*
|
|
54
|
+
* This is the seam that keeps the canvas reusable: the consuming lens decides
|
|
55
|
+
* what a card shows. Return a sketch, an `<img>`, a chart, a placeholder —
|
|
56
|
+
* the canvas renders the frame around it and nothing else.
|
|
57
|
+
*/
|
|
58
|
+
renderCard: (node: FlowCanvasNode<TData>) => ReactNode;
|
|
59
|
+
/**
|
|
60
|
+
* Optional override for the caption under the card content. The default
|
|
61
|
+
* renders `title` over `subtitle`, which is what makes every lens's canvas
|
|
62
|
+
* read as the same map. Override it when a lens needs a badge or a marker
|
|
63
|
+
* in the caption row.
|
|
64
|
+
*/
|
|
65
|
+
renderCaption?: (node: FlowCanvasNode<TData>) => ReactNode;
|
|
66
|
+
/**
|
|
67
|
+
* Click handler for a card. The node comes first because that is what a lens
|
|
68
|
+
* acts on; the DOM event is there for modifier keys.
|
|
69
|
+
*
|
|
70
|
+
* When omitted the cards are not interactive and no pointer affordance is
|
|
71
|
+
* drawn — a canvas whose cards do nothing must not look clickable.
|
|
72
|
+
*/
|
|
73
|
+
onNodeClick?: (node: FlowCanvasNode<TData>, event: MouseEvent) => void;
|
|
74
|
+
/** @default "LR" */
|
|
75
|
+
direction?: FlowCanvasDirection;
|
|
76
|
+
/** Card width in px. Also the box dagre lays out around. @default 240 */
|
|
77
|
+
nodeWidth?: number;
|
|
78
|
+
/** Card height in px, caption included. @default 202 */
|
|
79
|
+
nodeHeight?: number;
|
|
80
|
+
/** Height of the card-content area in px. @default 130 */
|
|
81
|
+
cardContentHeight?: number;
|
|
82
|
+
/** Horizontal gap between siblings in a rank. @default 48 */
|
|
83
|
+
nodeSeparation?: number;
|
|
84
|
+
/** Gap between ranks. @default 120 */
|
|
85
|
+
rankSeparation?: number;
|
|
86
|
+
/** @default "dark" */
|
|
87
|
+
colorMode?: "dark" | "light" | "system";
|
|
88
|
+
/** `false` hides the MiniMap. An object overrides its colours. @default true */
|
|
89
|
+
miniMap?: boolean | FlowCanvasMiniMapColors;
|
|
90
|
+
/** @default true */
|
|
91
|
+
controls?: boolean;
|
|
92
|
+
/** @default true */
|
|
93
|
+
background?: boolean;
|
|
94
|
+
/**
|
|
95
|
+
* Prefix for every `data-testid` this component emits:
|
|
96
|
+
* `<prefix>-canvas`, `<prefix>-node`, `<prefix>-node-content`,
|
|
97
|
+
* `<prefix>-node-caption`, `<prefix>-edge-label`.
|
|
98
|
+
*
|
|
99
|
+
* A page with two canvases needs two prefixes, and a consumer whose suite
|
|
100
|
+
* already names the canvas can keep its selectors. @default "flow"
|
|
101
|
+
*/
|
|
102
|
+
testIdPrefix?: string;
|
|
103
|
+
/** Applied to the canvas frame (the bordered box), not to the viewport. */
|
|
104
|
+
className?: string;
|
|
105
|
+
/** Accessible name for the canvas region. @default "Flow canvas" */
|
|
106
|
+
ariaLabel?: string;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* FlowCanvas — a spatial, auto-laid-out graph of cards.
|
|
111
|
+
*
|
|
112
|
+
* ONE CANVAS, N LENSES. The canvas owns the map: layout, ranking, edge routing,
|
|
113
|
+
* the card frame, the caption, the minimap and the controls. The consuming lens
|
|
114
|
+
* owns what a card SHOWS (`renderCard`) and what a click DOES (`onNodeClick`).
|
|
115
|
+
* The canvas never inspects `node.data`, so a lens can put a low-fidelity
|
|
116
|
+
* wireframe in one card and a production screenshot in the next without the
|
|
117
|
+
* canvas learning about either. Duplicate the lens; never duplicate the canvas.
|
|
118
|
+
*
|
|
119
|
+
* ## Peer dependencies
|
|
120
|
+
* `@xyflow/react` and `@dagrejs/dagre` are OPTIONAL peer dependencies —
|
|
121
|
+
* install them only if you import this entry point. Consumers of
|
|
122
|
+
* `@devfellowship/components` who never draw a canvas pay nothing.
|
|
123
|
+
*
|
|
124
|
+
* ## Stylesheets the consumer must import
|
|
125
|
+
* ```css
|
|
126
|
+
* @import "@xyflow/react/dist/style.css"; /* React Flow's own base styles */
|
|
127
|
+
* @import "@devfellowship/components/canvas.css"; /* DS skin for its chrome */
|
|
128
|
+
* ```
|
|
129
|
+
*
|
|
130
|
+
* @example
|
|
131
|
+
* ```tsx
|
|
132
|
+
* <FlowCanvas
|
|
133
|
+
* nodes={screens.map((s) => ({ id: s.id, title: s.name, subtitle: s.route, data: s }))}
|
|
134
|
+
* edges={transitions}
|
|
135
|
+
* renderCard={(node) => <WireframePreview screen={node.data} />}
|
|
136
|
+
* onNodeClick={(node) => select(node.id)}
|
|
137
|
+
* />
|
|
138
|
+
* ```
|
|
139
|
+
*/
|
|
140
|
+
declare function FlowCanvas<TData = unknown>({ nodes, edges, renderCard, renderCaption, onNodeClick, direction, nodeWidth, nodeHeight, cardContentHeight, nodeSeparation, rankSeparation, colorMode, miniMap, controls, background, testIdPrefix, className, ariaLabel, }: FlowCanvasProps<TData>): React.JSX.Element;
|
|
141
|
+
|
|
142
|
+
declare const DEFAULT_NODE_WIDTH = 240;
|
|
143
|
+
declare const DEFAULT_NODE_HEIGHT = 202;
|
|
144
|
+
declare const DEFAULT_NODE_SEPARATION = 48;
|
|
145
|
+
declare const DEFAULT_RANK_SEPARATION = 120;
|
|
146
|
+
interface LayoutOptions {
|
|
147
|
+
direction?: FlowCanvasDirection;
|
|
148
|
+
nodeWidth?: number;
|
|
149
|
+
nodeHeight?: number;
|
|
150
|
+
nodeSeparation?: number;
|
|
151
|
+
rankSeparation?: number;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Directional auto-layout for the flow canvas.
|
|
155
|
+
*
|
|
156
|
+
* Ranks nodes by dagre's layered algorithm, which computes rank AND in-rank
|
|
157
|
+
* order at once, so it also minimises edge crossings between ranks. The naive
|
|
158
|
+
* alternative — BFS level to column, insertion order to row — overlaps boxes as
|
|
159
|
+
* soon as the number of nodes per level varies.
|
|
160
|
+
*
|
|
161
|
+
* ⚠️ Do NOT inject a synthetic "super-source" edge to pull entry nodes to the
|
|
162
|
+
* front. It pushes genuine entry nodes one rank to the RIGHT of disconnected
|
|
163
|
+
* orphan nodes, which keep dagre's default rank 0 — so an orphan renders as if
|
|
164
|
+
* it came BEFORE the entry point. Plain ranking from the real edges already
|
|
165
|
+
* gives every node with no incoming edge rank 0, which is the correct result.
|
|
166
|
+
*
|
|
167
|
+
* The function is PURE and side-effect free, which is what makes it testable
|
|
168
|
+
* without a DOM: same graph in, same coordinates out.
|
|
169
|
+
*/
|
|
170
|
+
declare function layoutGraph<TData>(nodes: FlowCanvasNode<TData>[], edges: FlowCanvasEdge[], options?: LayoutOptions): {
|
|
171
|
+
nodes: Node[];
|
|
172
|
+
edges: Edge[];
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
export { DEFAULT_NODE_HEIGHT, DEFAULT_NODE_SEPARATION, DEFAULT_NODE_WIDTH, DEFAULT_RANK_SEPARATION, FlowCanvas, type FlowCanvasDirection, type FlowCanvasEdge, type FlowCanvasMiniMapColors, type FlowCanvasNode, type FlowCanvasProps, type LayoutOptions, layoutGraph };
|
package/dist/canvas.js
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
// src/canvas/FlowCanvas.tsx
|
|
2
|
+
import { createContext, useContext, useMemo } from "react";
|
|
3
|
+
import {
|
|
4
|
+
ReactFlow,
|
|
5
|
+
Background,
|
|
6
|
+
Controls,
|
|
7
|
+
MiniMap,
|
|
8
|
+
Handle,
|
|
9
|
+
Position,
|
|
10
|
+
BaseEdge,
|
|
11
|
+
EdgeLabelRenderer,
|
|
12
|
+
getSmoothStepPath
|
|
13
|
+
} from "@xyflow/react";
|
|
14
|
+
|
|
15
|
+
// src/lib/utils.ts
|
|
16
|
+
import { clsx } from "clsx";
|
|
17
|
+
import { twMerge } from "tailwind-merge";
|
|
18
|
+
function cn(...inputs) {
|
|
19
|
+
return twMerge(clsx(inputs));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// src/canvas/layout.ts
|
|
23
|
+
import dagre from "@dagrejs/dagre";
|
|
24
|
+
var DEFAULT_NODE_WIDTH = 240;
|
|
25
|
+
var DEFAULT_NODE_HEIGHT = 202;
|
|
26
|
+
var DEFAULT_NODE_SEPARATION = 48;
|
|
27
|
+
var DEFAULT_RANK_SEPARATION = 120;
|
|
28
|
+
function layoutGraph(nodes, edges, options = {}) {
|
|
29
|
+
const {
|
|
30
|
+
direction = "LR",
|
|
31
|
+
nodeWidth = DEFAULT_NODE_WIDTH,
|
|
32
|
+
nodeHeight = DEFAULT_NODE_HEIGHT,
|
|
33
|
+
nodeSeparation = DEFAULT_NODE_SEPARATION,
|
|
34
|
+
rankSeparation = DEFAULT_RANK_SEPARATION
|
|
35
|
+
} = options;
|
|
36
|
+
const known = new Set(nodes.map((n) => n.id));
|
|
37
|
+
const usable = edges.filter((e) => known.has(e.source) && known.has(e.target));
|
|
38
|
+
const g = new dagre.graphlib.Graph();
|
|
39
|
+
g.setDefaultEdgeLabel(() => ({}));
|
|
40
|
+
g.setGraph({ rankdir: direction, nodesep: nodeSeparation, ranksep: rankSeparation, marginx: 24, marginy: 24 });
|
|
41
|
+
for (const n of nodes) g.setNode(n.id, { width: nodeWidth, height: nodeHeight });
|
|
42
|
+
for (const e of usable) g.setEdge(e.source, e.target);
|
|
43
|
+
dagre.layout(g);
|
|
44
|
+
const rfNodes = nodes.map((n) => {
|
|
45
|
+
const laid = g.node(n.id);
|
|
46
|
+
return {
|
|
47
|
+
id: n.id,
|
|
48
|
+
type: "flowCanvasCard",
|
|
49
|
+
position: {
|
|
50
|
+
x: laid ? laid.x - nodeWidth / 2 : 0,
|
|
51
|
+
y: laid ? laid.y - nodeHeight / 2 : 0
|
|
52
|
+
},
|
|
53
|
+
data: { node: n },
|
|
54
|
+
// Explicit width/height, not just post-mount measurement: React Flow's
|
|
55
|
+
// MiniMap only draws a node rect once the node "has dimensions"
|
|
56
|
+
// (`measured` OR explicit width/height — see @xyflow/system
|
|
57
|
+
// nodeHasDimensions). Without this the minimap renders zero rects and
|
|
58
|
+
// reads as an empty panel.
|
|
59
|
+
width: nodeWidth,
|
|
60
|
+
height: nodeHeight
|
|
61
|
+
};
|
|
62
|
+
});
|
|
63
|
+
const pairCounts = /* @__PURE__ */ new Map();
|
|
64
|
+
for (const e of usable) {
|
|
65
|
+
const key = pairKey(e);
|
|
66
|
+
pairCounts.set(key, (pairCounts.get(key) ?? 0) + 1);
|
|
67
|
+
}
|
|
68
|
+
const pairSeen = /* @__PURE__ */ new Map();
|
|
69
|
+
const rfEdges = usable.map((e) => {
|
|
70
|
+
const key = pairKey(e);
|
|
71
|
+
const pairIndex = pairSeen.get(key) ?? 0;
|
|
72
|
+
pairSeen.set(key, pairIndex + 1);
|
|
73
|
+
return {
|
|
74
|
+
id: e.id,
|
|
75
|
+
source: e.source,
|
|
76
|
+
target: e.target,
|
|
77
|
+
label: e.label,
|
|
78
|
+
type: "flowCanvasChip",
|
|
79
|
+
data: { pairIndex, pairCount: pairCounts.get(key) ?? 1 }
|
|
80
|
+
};
|
|
81
|
+
});
|
|
82
|
+
return { nodes: rfNodes, edges: rfEdges };
|
|
83
|
+
}
|
|
84
|
+
function pairKey(e) {
|
|
85
|
+
return [e.source, e.target].sort().join("|");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// src/canvas/FlowCanvas.tsx
|
|
89
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
90
|
+
var DEFAULT_CARD_CONTENT_HEIGHT = 130;
|
|
91
|
+
var MINIMAP_NODE_COLOR = "#E07A4A";
|
|
92
|
+
var MINIMAP_MASK_COLOR = "rgba(10,9,8,0.72)";
|
|
93
|
+
var MINIMAP_BG_COLOR = "#141210";
|
|
94
|
+
var FlowCanvasContext = createContext(null);
|
|
95
|
+
function useFlowCanvas() {
|
|
96
|
+
const ctx = useContext(FlowCanvasContext);
|
|
97
|
+
if (!ctx) throw new Error("FlowCanvas internals rendered outside of <FlowCanvas>");
|
|
98
|
+
return ctx;
|
|
99
|
+
}
|
|
100
|
+
function FlowCanvasCard({ data }) {
|
|
101
|
+
const { renderCard, renderCaption, clickable, nodeWidth, cardContentHeight, horizontal, testIdPrefix } = useFlowCanvas();
|
|
102
|
+
const node = data.node;
|
|
103
|
+
return /* @__PURE__ */ jsxs(
|
|
104
|
+
"div",
|
|
105
|
+
{
|
|
106
|
+
className: cn(
|
|
107
|
+
"overflow-hidden rounded-lg border border-s-brand-ring bg-card shadow-lg shadow-black/40",
|
|
108
|
+
clickable && "cursor-pointer"
|
|
109
|
+
),
|
|
110
|
+
style: { width: nodeWidth },
|
|
111
|
+
"data-testid": `${testIdPrefix}-node`,
|
|
112
|
+
"data-node-id": node.id,
|
|
113
|
+
children: [
|
|
114
|
+
/* @__PURE__ */ jsx(
|
|
115
|
+
Handle,
|
|
116
|
+
{
|
|
117
|
+
type: "target",
|
|
118
|
+
position: horizontal ? Position.Left : Position.Top,
|
|
119
|
+
className: "!h-2 !w-2 !border-none !bg-primary"
|
|
120
|
+
}
|
|
121
|
+
),
|
|
122
|
+
/* @__PURE__ */ jsx(
|
|
123
|
+
"div",
|
|
124
|
+
{
|
|
125
|
+
className: "flex w-full items-center justify-center overflow-hidden border-b border-border bg-muted",
|
|
126
|
+
style: { height: cardContentHeight },
|
|
127
|
+
"data-testid": `${testIdPrefix}-node-content`,
|
|
128
|
+
children: renderCard(node)
|
|
129
|
+
}
|
|
130
|
+
),
|
|
131
|
+
/* @__PURE__ */ jsx("div", { className: "px-3 py-2 text-xs", "data-testid": `${testIdPrefix}-node-caption`, children: renderCaption ? renderCaption(node) : /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
132
|
+
/* @__PURE__ */ jsx("div", { className: "truncate font-semibold text-foreground", title: node.title, children: node.title }),
|
|
133
|
+
node.subtitle ? /* @__PURE__ */ jsx("div", { className: "truncate font-mono text-muted-foreground", title: node.subtitle, children: node.subtitle }) : null
|
|
134
|
+
] }) }),
|
|
135
|
+
/* @__PURE__ */ jsx(
|
|
136
|
+
Handle,
|
|
137
|
+
{
|
|
138
|
+
type: "source",
|
|
139
|
+
position: horizontal ? Position.Right : Position.Bottom,
|
|
140
|
+
className: "!h-2 !w-2 !border-none !bg-primary"
|
|
141
|
+
}
|
|
142
|
+
)
|
|
143
|
+
]
|
|
144
|
+
}
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
function FlowCanvasChipEdge({
|
|
148
|
+
id,
|
|
149
|
+
sourceX,
|
|
150
|
+
sourceY,
|
|
151
|
+
targetX,
|
|
152
|
+
targetY,
|
|
153
|
+
sourcePosition,
|
|
154
|
+
targetPosition,
|
|
155
|
+
label,
|
|
156
|
+
data
|
|
157
|
+
}) {
|
|
158
|
+
const { testIdPrefix } = useFlowCanvas();
|
|
159
|
+
const [edgePath, labelX, labelY] = getSmoothStepPath({
|
|
160
|
+
sourceX,
|
|
161
|
+
sourceY,
|
|
162
|
+
targetX,
|
|
163
|
+
targetY,
|
|
164
|
+
sourcePosition,
|
|
165
|
+
targetPosition
|
|
166
|
+
});
|
|
167
|
+
const pairIndex = data?.pairIndex ?? 0;
|
|
168
|
+
const pairCount = data?.pairCount ?? 1;
|
|
169
|
+
const offsetY = pairCount > 1 ? (pairIndex - (pairCount - 1) / 2) * 22 : 0;
|
|
170
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
171
|
+
/* @__PURE__ */ jsx(BaseEdge, { id, path: edgePath, style: { stroke: "var(--s-brand-ring)", strokeWidth: 1.5 } }),
|
|
172
|
+
label ? /* @__PURE__ */ jsx(EdgeLabelRenderer, { children: /* @__PURE__ */ jsx(
|
|
173
|
+
"div",
|
|
174
|
+
{
|
|
175
|
+
className: "nodrag nopan pointer-events-none absolute rounded-md border border-s-brand-ring bg-popover px-1.5 py-0.5 text-[10px] font-semibold text-foreground",
|
|
176
|
+
style: { transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY + offsetY}px)` },
|
|
177
|
+
"data-testid": `${testIdPrefix}-edge-label`,
|
|
178
|
+
children: label
|
|
179
|
+
}
|
|
180
|
+
) }) : null
|
|
181
|
+
] });
|
|
182
|
+
}
|
|
183
|
+
var nodeTypes = { flowCanvasCard: FlowCanvasCard };
|
|
184
|
+
var edgeTypes = { flowCanvasChip: FlowCanvasChipEdge };
|
|
185
|
+
function FlowCanvas({
|
|
186
|
+
nodes,
|
|
187
|
+
edges,
|
|
188
|
+
renderCard,
|
|
189
|
+
renderCaption,
|
|
190
|
+
onNodeClick,
|
|
191
|
+
direction = "LR",
|
|
192
|
+
nodeWidth = DEFAULT_NODE_WIDTH,
|
|
193
|
+
nodeHeight = DEFAULT_NODE_HEIGHT,
|
|
194
|
+
cardContentHeight = DEFAULT_CARD_CONTENT_HEIGHT,
|
|
195
|
+
nodeSeparation,
|
|
196
|
+
rankSeparation,
|
|
197
|
+
colorMode = "dark",
|
|
198
|
+
miniMap = true,
|
|
199
|
+
controls = true,
|
|
200
|
+
background = true,
|
|
201
|
+
testIdPrefix = "flow",
|
|
202
|
+
className,
|
|
203
|
+
ariaLabel = "Flow canvas"
|
|
204
|
+
}) {
|
|
205
|
+
const graph = useMemo(
|
|
206
|
+
() => layoutGraph(nodes, edges, { direction, nodeWidth, nodeHeight, nodeSeparation, rankSeparation }),
|
|
207
|
+
[nodes, edges, direction, nodeWidth, nodeHeight, nodeSeparation, rankSeparation]
|
|
208
|
+
);
|
|
209
|
+
const byId = useMemo(() => new Map(nodes.map((n) => [n.id, n])), [nodes]);
|
|
210
|
+
const ctx = useMemo(
|
|
211
|
+
() => ({
|
|
212
|
+
renderCard,
|
|
213
|
+
renderCaption,
|
|
214
|
+
clickable: Boolean(onNodeClick),
|
|
215
|
+
nodeWidth,
|
|
216
|
+
cardContentHeight,
|
|
217
|
+
horizontal: direction === "LR",
|
|
218
|
+
testIdPrefix
|
|
219
|
+
}),
|
|
220
|
+
[renderCard, renderCaption, onNodeClick, nodeWidth, cardContentHeight, direction, testIdPrefix]
|
|
221
|
+
);
|
|
222
|
+
const miniMapColors = typeof miniMap === "object" ? miniMap : {};
|
|
223
|
+
return /* @__PURE__ */ jsx(FlowCanvasContext.Provider, { value: ctx, children: /* @__PURE__ */ jsx(
|
|
224
|
+
"div",
|
|
225
|
+
{
|
|
226
|
+
className: cn(
|
|
227
|
+
"h-[70vh] min-h-[520px] w-full overflow-hidden rounded-lg border border-border bg-card",
|
|
228
|
+
className
|
|
229
|
+
),
|
|
230
|
+
"data-testid": `${testIdPrefix}-canvas`,
|
|
231
|
+
role: "region",
|
|
232
|
+
"aria-label": ariaLabel,
|
|
233
|
+
children: /* @__PURE__ */ jsxs(
|
|
234
|
+
ReactFlow,
|
|
235
|
+
{
|
|
236
|
+
nodes: graph.nodes,
|
|
237
|
+
edges: graph.edges,
|
|
238
|
+
nodeTypes,
|
|
239
|
+
edgeTypes,
|
|
240
|
+
colorMode,
|
|
241
|
+
fitView: true,
|
|
242
|
+
proOptions: { hideAttribution: true },
|
|
243
|
+
onNodeClick: onNodeClick ? (event, rfNode) => {
|
|
244
|
+
const node = byId.get(rfNode.id);
|
|
245
|
+
if (node) onNodeClick(node, event);
|
|
246
|
+
} : void 0,
|
|
247
|
+
children: [
|
|
248
|
+
background ? /* @__PURE__ */ jsx(Background, {}) : null,
|
|
249
|
+
controls ? /* @__PURE__ */ jsx(Controls, { showInteractive: false }) : null,
|
|
250
|
+
miniMap ? /* @__PURE__ */ jsx(
|
|
251
|
+
MiniMap,
|
|
252
|
+
{
|
|
253
|
+
pannable: true,
|
|
254
|
+
zoomable: true,
|
|
255
|
+
nodeColor: miniMapColors.nodeColor ?? MINIMAP_NODE_COLOR,
|
|
256
|
+
maskColor: miniMapColors.maskColor ?? MINIMAP_MASK_COLOR,
|
|
257
|
+
bgColor: miniMapColors.bgColor ?? MINIMAP_BG_COLOR
|
|
258
|
+
}
|
|
259
|
+
) : null
|
|
260
|
+
]
|
|
261
|
+
}
|
|
262
|
+
)
|
|
263
|
+
}
|
|
264
|
+
) });
|
|
265
|
+
}
|
|
266
|
+
export {
|
|
267
|
+
DEFAULT_NODE_HEIGHT,
|
|
268
|
+
DEFAULT_NODE_SEPARATION,
|
|
269
|
+
DEFAULT_NODE_WIDTH,
|
|
270
|
+
DEFAULT_RANK_SEPARATION,
|
|
271
|
+
FlowCanvas,
|
|
272
|
+
layoutGraph
|
|
273
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/* DS skin for React Flow's own chrome, used by <FlowCanvas> from
|
|
2
|
+
@devfellowship/components/canvas.
|
|
3
|
+
|
|
4
|
+
React Flow ships its Controls and MiniMap with a light-grey default look that
|
|
5
|
+
fights the DFL sand palette. These rules re-point that chrome at the DS
|
|
6
|
+
surface tokens. They live here, beside the component, so a second consumer of
|
|
7
|
+
FlowCanvas gets the same canvas rather than an unskinned one.
|
|
8
|
+
|
|
9
|
+
IMPORT ORDER MATTERS — this sheet overrides React Flow's base styles, so it
|
|
10
|
+
must come after them:
|
|
11
|
+
|
|
12
|
+
@import "@xyflow/react/dist/style.css";
|
|
13
|
+
@import "@devfellowship/components/canvas.css";
|
|
14
|
+
|
|
15
|
+
Deliberately NOT part of theme.css: these selectors are only meaningful on a
|
|
16
|
+
page that renders a canvas, and the design system must not push React Flow
|
|
17
|
+
rules into every consumer's stylesheet. */
|
|
18
|
+
|
|
19
|
+
.react-flow__controls-button {
|
|
20
|
+
background: var(--s-surface-raised);
|
|
21
|
+
border-bottom: 1px solid var(--s-border-subtle);
|
|
22
|
+
color: var(--s-ink-secondary);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.react-flow__controls-button:hover {
|
|
26
|
+
background: var(--s-surface-elevated);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.react-flow__controls-button svg {
|
|
30
|
+
fill: currentColor;
|
|
31
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@devfellowship/components",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.2.0",
|
|
4
4
|
"description": "DFL Design System — UI components, hooks, utils and providers",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": [
|
|
@@ -32,6 +32,12 @@
|
|
|
32
32
|
"import": "./dist/testing.js",
|
|
33
33
|
"require": "./dist/testing.cjs"
|
|
34
34
|
},
|
|
35
|
+
"./canvas": {
|
|
36
|
+
"types": "./dist/canvas.d.ts",
|
|
37
|
+
"import": "./dist/canvas.js",
|
|
38
|
+
"require": "./dist/canvas.cjs"
|
|
39
|
+
},
|
|
40
|
+
"./canvas.css": "./dist/styles/canvas.css",
|
|
35
41
|
"./styles": "./dist/styles/theme.css",
|
|
36
42
|
"./shadcn": "./dist/styles/shadcn.css",
|
|
37
43
|
"./tokens": "./dist/styles/tokens.css",
|
|
@@ -58,10 +64,13 @@
|
|
|
58
64
|
"storybook": "storybook dev -p 6006",
|
|
59
65
|
"build-storybook": "storybook build",
|
|
60
66
|
"lint:no-docs": "node scripts/check-no-storybook-docs.mjs",
|
|
61
|
-
"check:cli-offline": "node scripts/check-cli-bundle-offline.mjs"
|
|
67
|
+
"check:cli-offline": "node scripts/check-cli-bundle-offline.mjs",
|
|
68
|
+
"check:canvas-isolated": "node scripts/check-canvas-entry-isolated.mjs"
|
|
62
69
|
},
|
|
63
70
|
"peerDependencies": {
|
|
71
|
+
"@dagrejs/dagre": ">=3.0.0",
|
|
64
72
|
"@supabase/supabase-js": ">=2.0.0",
|
|
73
|
+
"@xyflow/react": ">=12.0.0",
|
|
65
74
|
"react": ">=18.0.0",
|
|
66
75
|
"react-dom": ">=18.0.0",
|
|
67
76
|
"tailwindcss": ">=4.0.0"
|
|
@@ -72,11 +81,18 @@
|
|
|
72
81
|
},
|
|
73
82
|
"@supabase/supabase-js": {
|
|
74
83
|
"optional": true
|
|
84
|
+
},
|
|
85
|
+
"@xyflow/react": {
|
|
86
|
+
"optional": true
|
|
87
|
+
},
|
|
88
|
+
"@dagrejs/dagre": {
|
|
89
|
+
"optional": true
|
|
75
90
|
}
|
|
76
91
|
},
|
|
77
92
|
"devDependencies": {
|
|
78
93
|
"@changesets/changelog-github": "^0.7.0",
|
|
79
94
|
"@changesets/cli": "^2.31.0",
|
|
95
|
+
"@dagrejs/dagre": "^3.1.1",
|
|
80
96
|
"@storybook/addon-a11y": "^9.1.20",
|
|
81
97
|
"@storybook/addon-themes": "^9.1.20",
|
|
82
98
|
"@storybook/react": "^9.1.20",
|
|
@@ -89,6 +105,7 @@
|
|
|
89
105
|
"@types/react": "^18.3.28",
|
|
90
106
|
"@types/react-dom": "^18.3.7",
|
|
91
107
|
"@vitejs/plugin-react": "^4.7.0",
|
|
108
|
+
"@xyflow/react": "^12.11.3",
|
|
92
109
|
"jsdom": "^26.1.0",
|
|
93
110
|
"react": "^18.3.0",
|
|
94
111
|
"react-dom": "^18.3.0",
|