@vllnt/ui 0.1.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.
@@ -0,0 +1,568 @@
1
+ import '@xyflow/react/dist/style.css';
2
+ import { memo, useEffect, useCallback, Component, useState } from 'react';
3
+ import { ReactFlow, Background, BackgroundVariant, ReactFlowProvider, useReactFlow, getNodesBounds, getViewportForBounds } from '@xyflow/react';
4
+ import { clsx } from 'clsx';
5
+ import { twMerge } from 'tailwind-merge';
6
+ import { useTheme } from 'next-themes';
7
+ import { Plus, Minus, Move, Maximize2, X, Copy, Check, Loader2, AlertTriangle, RefreshCw } from 'lucide-react';
8
+ import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
9
+ import { createPortal } from 'react-dom';
10
+ import { toPng } from 'html-to-image';
11
+
12
+ // src/components/flow-diagram/flow-diagram.tsx
13
+ function cn(...inputs) {
14
+ return twMerge(clsx(inputs));
15
+ }
16
+ var BUTTON_CLASS = "flex h-8 w-8 items-center justify-center rounded hover:bg-muted transition-colors disabled:opacity-50 disabled:cursor-not-allowed";
17
+ function ControlButton({
18
+ disabled,
19
+ icon,
20
+ label,
21
+ onClick,
22
+ title
23
+ }) {
24
+ return /* @__PURE__ */ jsx(
25
+ "button",
26
+ {
27
+ "aria-label": label,
28
+ className: BUTTON_CLASS,
29
+ disabled,
30
+ onClick,
31
+ title,
32
+ type: "button",
33
+ children: icon
34
+ }
35
+ );
36
+ }
37
+ function getCopyIcon(status) {
38
+ switch (status) {
39
+ case "copying":
40
+ return /* @__PURE__ */ jsx(Loader2, { className: "h-4 w-4 animate-spin" });
41
+ case "success":
42
+ return /* @__PURE__ */ jsx(Check, { className: "h-4 w-4 text-green-500" });
43
+ case "error":
44
+ return /* @__PURE__ */ jsx(X, { className: "h-4 w-4 text-destructive" });
45
+ case "idle":
46
+ case void 0:
47
+ return /* @__PURE__ */ jsx(Copy, { className: "h-4 w-4" });
48
+ }
49
+ }
50
+ function getCopyTitle(status) {
51
+ switch (status) {
52
+ case "copying":
53
+ return "Copying...";
54
+ case "success":
55
+ return "Copied!";
56
+ case "error":
57
+ return "Copy failed";
58
+ case "idle":
59
+ case void 0:
60
+ return "Copy as image";
61
+ }
62
+ }
63
+ var FlowControls = memo(function FlowControls2({
64
+ className,
65
+ copyStatus,
66
+ onCopy,
67
+ onFitView,
68
+ onFullscreen,
69
+ onZoomIn,
70
+ onZoomOut,
71
+ showCopy = false,
72
+ showFullscreen = false
73
+ }) {
74
+ return /* @__PURE__ */ jsxs(
75
+ "div",
76
+ {
77
+ className: cn(
78
+ "absolute bottom-4 left-4 z-10 flex flex-col gap-1 rounded-md border border-border bg-background/90 p-1 backdrop-blur-sm",
79
+ className
80
+ ),
81
+ children: [
82
+ /* @__PURE__ */ jsx(
83
+ ControlButton,
84
+ {
85
+ icon: /* @__PURE__ */ jsx(Plus, { className: "h-4 w-4" }),
86
+ label: "Zoom in",
87
+ onClick: onZoomIn,
88
+ title: "Zoom in"
89
+ }
90
+ ),
91
+ /* @__PURE__ */ jsx(
92
+ ControlButton,
93
+ {
94
+ icon: /* @__PURE__ */ jsx(Minus, { className: "h-4 w-4" }),
95
+ label: "Zoom out",
96
+ onClick: onZoomOut,
97
+ title: "Zoom out"
98
+ }
99
+ ),
100
+ /* @__PURE__ */ jsx("div", { className: "h-px bg-border" }),
101
+ /* @__PURE__ */ jsx(
102
+ ControlButton,
103
+ {
104
+ icon: /* @__PURE__ */ jsx(Move, { className: "h-4 w-4" }),
105
+ label: "Fit view",
106
+ onClick: onFitView,
107
+ title: "Fit view"
108
+ }
109
+ ),
110
+ showCopy && onCopy ? /* @__PURE__ */ jsxs(Fragment, { children: [
111
+ /* @__PURE__ */ jsx("div", { className: "h-px bg-border" }),
112
+ /* @__PURE__ */ jsx(
113
+ ControlButton,
114
+ {
115
+ disabled: copyStatus === "copying",
116
+ icon: getCopyIcon(copyStatus),
117
+ label: getCopyTitle(copyStatus),
118
+ onClick: onCopy,
119
+ title: getCopyTitle(copyStatus)
120
+ }
121
+ )
122
+ ] }) : null,
123
+ showFullscreen && onFullscreen ? /* @__PURE__ */ jsxs(Fragment, { children: [
124
+ /* @__PURE__ */ jsx("div", { className: "h-px bg-border" }),
125
+ /* @__PURE__ */ jsx(
126
+ ControlButton,
127
+ {
128
+ icon: /* @__PURE__ */ jsx(Maximize2, { className: "h-4 w-4" }),
129
+ label: "Fullscreen",
130
+ onClick: onFullscreen,
131
+ title: "Toggle fullscreen"
132
+ }
133
+ )
134
+ ] }) : null
135
+ ]
136
+ }
137
+ );
138
+ });
139
+ var REACT_FLOW_CLASS = [
140
+ // Node container styling — no !important on bg/color so inline styles take precedence
141
+ "[&_.react-flow__node]:rounded-md",
142
+ "[&_.react-flow__node]:border",
143
+ "[&_.react-flow__node]:border-border",
144
+ "[&_.react-flow__node]:bg-card",
145
+ "[&_.react-flow__node]:px-4",
146
+ "[&_.react-flow__node]:py-2",
147
+ "[&_.react-flow__node]:shadow-sm",
148
+ // Node text styling — defaults, overridable by inline style.color
149
+ "[&_.react-flow__node]:text-sm",
150
+ "[&_.react-flow__node]:text-card-foreground",
151
+ "[&_.react-flow__node]:font-medium",
152
+ // Edge styling
153
+ "[&_.react-flow__edge-path]:!stroke-muted-foreground",
154
+ "[&_.react-flow__edge-path]:!stroke-2",
155
+ // Arrow marker styling
156
+ "[&_.react-flow__arrowhead_polyline]:!fill-muted-foreground",
157
+ "[&_.react-flow__arrowhead_polyline]:!stroke-muted-foreground"
158
+ ].join(" ");
159
+ var FlowCanvas = memo(function FlowCanvas2({
160
+ allowCopy,
161
+ allowFullscreen,
162
+ className,
163
+ copyStatus,
164
+ edges,
165
+ fitView,
166
+ fitViewOptions,
167
+ height,
168
+ isFullscreen,
169
+ nodes,
170
+ onCopy,
171
+ onFitView,
172
+ onFullscreen,
173
+ onNodeClick,
174
+ onZoomIn,
175
+ onZoomOut,
176
+ showControls,
177
+ title
178
+ }) {
179
+ const { resolvedTheme } = useTheme();
180
+ const colorMode = resolvedTheme === "dark" ? "dark" : "light";
181
+ return /* @__PURE__ */ jsxs("div", { className: cn("relative w-full", className), children: [
182
+ title ? /* @__PURE__ */ jsx("div", { className: "mb-2 text-sm font-medium text-foreground", children: title }) : null,
183
+ /* @__PURE__ */ jsx(
184
+ "div",
185
+ {
186
+ className: "relative w-full rounded-lg border border-border bg-background overflow-hidden",
187
+ style: { height: isFullscreen ? "100%" : height },
188
+ children: /* @__PURE__ */ jsxs(
189
+ ReactFlow,
190
+ {
191
+ className: REACT_FLOW_CLASS,
192
+ colorMode,
193
+ edges,
194
+ elementsSelectable: false,
195
+ fitView,
196
+ fitViewOptions: fitViewOptions ?? { padding: 0.2 },
197
+ nodes,
198
+ nodesConnectable: false,
199
+ nodesDraggable: false,
200
+ onNodeClick,
201
+ panOnScroll: true,
202
+ proOptions: { hideAttribution: true },
203
+ zoomOnScroll: true,
204
+ children: [
205
+ /* @__PURE__ */ jsx(
206
+ Background,
207
+ {
208
+ className: "!bg-background [&>pattern>circle]:fill-muted-foreground/20",
209
+ gap: 16,
210
+ size: 1,
211
+ variant: BackgroundVariant.Dots
212
+ }
213
+ ),
214
+ showControls ? /* @__PURE__ */ jsx(
215
+ FlowControls,
216
+ {
217
+ copyStatus,
218
+ onCopy,
219
+ onFitView,
220
+ onFullscreen,
221
+ onZoomIn,
222
+ onZoomOut,
223
+ showCopy: allowCopy,
224
+ showFullscreen: allowFullscreen
225
+ }
226
+ ) : null
227
+ ]
228
+ }
229
+ )
230
+ }
231
+ )
232
+ ] });
233
+ });
234
+ var FlowErrorBoundary = class extends Component {
235
+ constructor(props) {
236
+ super(props);
237
+ this.handleRetry = () => {
238
+ this.setState({ error: null, hasError: false });
239
+ };
240
+ this.state = { error: null, hasError: false };
241
+ }
242
+ static getDerivedStateFromError(error) {
243
+ return { error, hasError: true };
244
+ }
245
+ componentDidCatch(error, errorInfo) {
246
+ console.error("[FlowDiagram] Error caught by boundary:", error, errorInfo);
247
+ this.props.onError?.(error, errorInfo);
248
+ }
249
+ render() {
250
+ const { children, className, fallback, height = 400 } = this.props;
251
+ const { error, hasError } = this.state;
252
+ if (hasError) {
253
+ if (fallback) {
254
+ return fallback;
255
+ }
256
+ return /* @__PURE__ */ jsxs(
257
+ "div",
258
+ {
259
+ className: cn(
260
+ "flex flex-col items-center justify-center gap-4 rounded-lg border border-border bg-muted/50 p-8 text-center",
261
+ className
262
+ ),
263
+ style: { height },
264
+ children: [
265
+ /* @__PURE__ */ jsx("div", { className: "flex h-12 w-12 items-center justify-center rounded-full bg-destructive/10", children: /* @__PURE__ */ jsx(AlertTriangle, { className: "h-6 w-6 text-destructive" }) }),
266
+ /* @__PURE__ */ jsxs("div", { className: "space-y-2", children: [
267
+ /* @__PURE__ */ jsx("h3", { className: "text-sm font-medium text-foreground", children: "Failed to render diagram" }),
268
+ /* @__PURE__ */ jsx("p", { className: "text-sm text-muted-foreground", children: error?.message ?? "An unexpected error occurred while rendering the flow diagram." })
269
+ ] }),
270
+ /* @__PURE__ */ jsxs(
271
+ "button",
272
+ {
273
+ className: "inline-flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90",
274
+ onClick: this.handleRetry,
275
+ type: "button",
276
+ children: [
277
+ /* @__PURE__ */ jsx(RefreshCw, { className: "h-4 w-4" }),
278
+ "Try again"
279
+ ]
280
+ }
281
+ )
282
+ ]
283
+ }
284
+ );
285
+ }
286
+ return children;
287
+ }
288
+ };
289
+ var FlowFullscreen = memo(function FlowFullscreen2({
290
+ children,
291
+ isOpen,
292
+ onClose
293
+ }) {
294
+ useEffect(() => {
295
+ if (!isOpen) return;
296
+ const handleKeyDown = (e) => {
297
+ if (e.key === "Escape") {
298
+ onClose();
299
+ }
300
+ };
301
+ document.addEventListener("keydown", handleKeyDown);
302
+ return () => {
303
+ document.removeEventListener("keydown", handleKeyDown);
304
+ };
305
+ }, [isOpen, onClose]);
306
+ if (!isOpen) return null;
307
+ if (typeof document === "undefined") return null;
308
+ return createPortal(
309
+ /* @__PURE__ */ jsxs(
310
+ "div",
311
+ {
312
+ "aria-label": "Flow diagram fullscreen view",
313
+ "aria-modal": "true",
314
+ className: cn(
315
+ "fixed inset-0 z-[9999] flex flex-col bg-background",
316
+ "animate-in fade-in duration-200"
317
+ ),
318
+ role: "dialog",
319
+ children: [
320
+ /* @__PURE__ */ jsx("div", { className: "flex h-12 items-center justify-end border-b border-border px-4", children: /* @__PURE__ */ jsx(
321
+ "button",
322
+ {
323
+ "aria-label": "Close fullscreen",
324
+ className: "flex h-8 w-8 items-center justify-center rounded hover:bg-muted transition-colors",
325
+ onClick: onClose,
326
+ title: "Close fullscreen (Esc)",
327
+ type: "button",
328
+ children: /* @__PURE__ */ jsx(X, { className: "h-5 w-5" })
329
+ }
330
+ ) }),
331
+ /* @__PURE__ */ jsx("div", { className: "flex-1 overflow-hidden", children })
332
+ ]
333
+ }
334
+ ),
335
+ document.body
336
+ );
337
+ });
338
+ var MIN_ZOOM = 0.1;
339
+ var MAX_ZOOM = 2;
340
+ var ZOOM_STEP = 0.2;
341
+ function useZoomControls(reactFlow) {
342
+ const [zoom, setZoom] = useState(1);
343
+ const zoomIn = useCallback(() => {
344
+ const newZoom = Math.min(zoom + ZOOM_STEP, MAX_ZOOM);
345
+ setZoom(newZoom);
346
+ void reactFlow.zoomTo(newZoom, { duration: 200 });
347
+ }, [zoom, reactFlow]);
348
+ const zoomOut = useCallback(() => {
349
+ const newZoom = Math.max(zoom - ZOOM_STEP, MIN_ZOOM);
350
+ setZoom(newZoom);
351
+ void reactFlow.zoomTo(newZoom, { duration: 200 });
352
+ }, [zoom, reactFlow]);
353
+ const fitView = useCallback(() => {
354
+ void reactFlow.fitView({ duration: 200, padding: 0.2 });
355
+ }, [reactFlow]);
356
+ useEffect(() => {
357
+ const viewport = reactFlow.getViewport();
358
+ setZoom(viewport.zoom);
359
+ }, [reactFlow]);
360
+ return { fitView, zoom, zoomIn, zoomOut };
361
+ }
362
+ function useFullscreenState(allowFullscreen) {
363
+ const [isFullscreen, setIsFullscreen] = useState(false);
364
+ const toggleFullscreen = useCallback(() => {
365
+ if (!allowFullscreen) return;
366
+ setIsFullscreen((previous) => !previous);
367
+ }, [allowFullscreen]);
368
+ const closeFullscreen = useCallback(() => {
369
+ setIsFullscreen(false);
370
+ }, []);
371
+ useEffect(() => {
372
+ if (!isFullscreen) return;
373
+ const handleKeyDown = (e) => {
374
+ if (e.key === "Escape") {
375
+ closeFullscreen();
376
+ }
377
+ };
378
+ document.addEventListener("keydown", handleKeyDown);
379
+ return () => {
380
+ document.removeEventListener("keydown", handleKeyDown);
381
+ };
382
+ }, [isFullscreen, closeFullscreen]);
383
+ useEffect(() => {
384
+ document.body.style.overflow = isFullscreen ? "hidden" : "";
385
+ return () => {
386
+ document.body.style.overflow = "";
387
+ };
388
+ }, [isFullscreen]);
389
+ return { closeFullscreen, isFullscreen, toggleFullscreen };
390
+ }
391
+ var IMAGE_WIDTH = 1024;
392
+ var IMAGE_HEIGHT = 768;
393
+ var COPY_SUCCESS_DURATION = 2e3;
394
+ async function captureFlowImage(reactFlow) {
395
+ const nodes = reactFlow.getNodes();
396
+ if (nodes.length === 0) {
397
+ throw new Error("Cannot copy: no nodes in diagram");
398
+ }
399
+ const nodesBounds = getNodesBounds(nodes);
400
+ const viewport = getViewportForBounds(
401
+ nodesBounds,
402
+ IMAGE_WIDTH,
403
+ IMAGE_HEIGHT,
404
+ 0.5,
405
+ 2,
406
+ 0.2
407
+ );
408
+ const flowElement = document.querySelector(".react-flow__viewport");
409
+ if (!flowElement || !(flowElement instanceof HTMLElement)) {
410
+ throw new Error("Cannot copy: flow viewport element not found");
411
+ }
412
+ const dataUrl = await toPng(flowElement, {
413
+ backgroundColor: "white",
414
+ height: IMAGE_HEIGHT,
415
+ style: {
416
+ height: String(IMAGE_HEIGHT),
417
+ transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.zoom})`,
418
+ width: String(IMAGE_WIDTH)
419
+ },
420
+ width: IMAGE_WIDTH
421
+ });
422
+ const response = await fetch(dataUrl);
423
+ if (!response.ok) {
424
+ throw new Error("Failed to fetch image data");
425
+ }
426
+ return response.blob();
427
+ }
428
+ function useCopyToClipboard(reactFlow) {
429
+ const [copyStatus, setCopyStatus] = useState("idle");
430
+ const copyToClipboard = useCallback(async () => {
431
+ setCopyStatus("copying");
432
+ try {
433
+ const blob = await captureFlowImage(reactFlow);
434
+ const clipboardItem = new ClipboardItem({ ["image/png"]: blob });
435
+ await navigator.clipboard.write([clipboardItem]);
436
+ setCopyStatus("success");
437
+ setTimeout(() => {
438
+ setCopyStatus("idle");
439
+ }, COPY_SUCCESS_DURATION);
440
+ } catch (error) {
441
+ console.error("[FlowDiagram] Copy failed:", error);
442
+ setCopyStatus("error");
443
+ setTimeout(() => {
444
+ setCopyStatus("idle");
445
+ }, COPY_SUCCESS_DURATION);
446
+ }
447
+ }, [reactFlow]);
448
+ return { copyStatus, copyToClipboard };
449
+ }
450
+ function useFlowDiagram(options = {}) {
451
+ const { allowFullscreen = true } = options;
452
+ const reactFlow = useReactFlow();
453
+ const { fitView, zoom, zoomIn, zoomOut } = useZoomControls(reactFlow);
454
+ const { closeFullscreen, isFullscreen, toggleFullscreen } = useFullscreenState(allowFullscreen);
455
+ const { copyStatus, copyToClipboard } = useCopyToClipboard(reactFlow);
456
+ return {
457
+ closeFullscreen,
458
+ copyStatus,
459
+ copyToClipboard,
460
+ fitView,
461
+ isFullscreen,
462
+ toggleFullscreen,
463
+ zoom,
464
+ zoomIn,
465
+ zoomOut
466
+ };
467
+ }
468
+ function validateFlowData(nodes, edges) {
469
+ if (nodes.length === 0 && edges.length > 0) {
470
+ console.warn(
471
+ "[FlowDiagram] Edges provided without nodes - edges will not render"
472
+ );
473
+ }
474
+ const nodeIds = new Set(nodes.map((n) => n.id));
475
+ const invalidEdges = edges.filter(
476
+ (e) => !nodeIds.has(e.source) || !nodeIds.has(e.target)
477
+ );
478
+ if (invalidEdges.length > 0) {
479
+ console.warn(
480
+ `[FlowDiagram] ${invalidEdges.length} edge(s) reference non-existent nodes:`,
481
+ invalidEdges.map((e) => `${e.id}: ${e.source} -> ${e.target}`)
482
+ );
483
+ }
484
+ const nodesWithoutPosition = nodes.filter((n) => n.position === void 0);
485
+ if (nodesWithoutPosition.length > 0) {
486
+ console.warn(
487
+ `[FlowDiagram] ${nodesWithoutPosition.length} node(s) missing position:`,
488
+ nodesWithoutPosition.map((n) => n.id)
489
+ );
490
+ }
491
+ }
492
+ var FlowDiagramInner = memo(function FlowDiagramInner2({
493
+ allowCopy = false,
494
+ allowFullscreen = true,
495
+ className,
496
+ edges,
497
+ fitView = true,
498
+ fitViewOptions,
499
+ height = 400,
500
+ nodes,
501
+ onNodeClick,
502
+ showControls = true,
503
+ title
504
+ }) {
505
+ useEffect(() => {
506
+ validateFlowData(nodes, edges);
507
+ }, [nodes, edges]);
508
+ const {
509
+ closeFullscreen,
510
+ copyStatus,
511
+ copyToClipboard,
512
+ fitView: handleFitView,
513
+ isFullscreen,
514
+ toggleFullscreen,
515
+ zoomIn,
516
+ zoomOut
517
+ } = useFlowDiagram({ allowFullscreen });
518
+ const handleNodeClick = useCallback(
519
+ (_event, node) => {
520
+ onNodeClick?.(node);
521
+ },
522
+ [onNodeClick]
523
+ );
524
+ const handleCopy = useCallback(() => {
525
+ void copyToClipboard();
526
+ }, [copyToClipboard]);
527
+ const canvasProps = {
528
+ allowCopy,
529
+ allowFullscreen,
530
+ className,
531
+ copyStatus,
532
+ edges,
533
+ fitView,
534
+ fitViewOptions,
535
+ height,
536
+ isFullscreen,
537
+ nodes,
538
+ onCopy: allowCopy ? handleCopy : void 0,
539
+ onFitView: handleFitView,
540
+ onFullscreen: allowFullscreen ? toggleFullscreen : void 0,
541
+ onNodeClick: onNodeClick ? handleNodeClick : void 0,
542
+ onZoomIn: zoomIn,
543
+ onZoomOut: zoomOut,
544
+ showControls,
545
+ title
546
+ };
547
+ if (isFullscreen) {
548
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
549
+ /* @__PURE__ */ jsx(
550
+ "div",
551
+ {
552
+ className: cn(
553
+ "rounded-lg border border-border bg-muted/50",
554
+ className
555
+ ),
556
+ style: { height }
557
+ }
558
+ ),
559
+ /* @__PURE__ */ jsx(FlowFullscreen, { isOpen: isFullscreen, onClose: closeFullscreen, children: /* @__PURE__ */ jsx(FlowCanvas, { ...canvasProps }) })
560
+ ] });
561
+ }
562
+ return /* @__PURE__ */ jsx(FlowCanvas, { ...canvasProps });
563
+ });
564
+ var FlowDiagram = memo(function FlowDiagram2(props) {
565
+ return /* @__PURE__ */ jsx(FlowErrorBoundary, { height: props.height, children: /* @__PURE__ */ jsx(ReactFlowProvider, { children: /* @__PURE__ */ jsx(FlowDiagramInner, { ...props }) }) });
566
+ });
567
+
568
+ export { FlowControls, FlowDiagram, FlowErrorBoundary, FlowFullscreen, cn, useFlowDiagram };
@@ -0,0 +1 @@
1
+ export { FlowControls, FlowDiagram, FlowErrorBoundary, FlowFullscreen, useFlowDiagram } from './chunk-XRV5RSYH.js';