panelui-native 0.16.0 → 0.19.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.
Files changed (66) hide show
  1. package/lib/module/components/bottom-sheet/index.js +38 -10
  2. package/lib/module/components/bottom-sheet/index.js.map +1 -1
  3. package/lib/module/components/checkbox/index.js +24 -11
  4. package/lib/module/components/checkbox/index.js.map +1 -1
  5. package/lib/module/components/field/index.js +337 -0
  6. package/lib/module/components/field/index.js.map +1 -0
  7. package/lib/module/components/flow/flow-paths.js +378 -0
  8. package/lib/module/components/flow/flow-paths.js.map +1 -0
  9. package/lib/module/components/flow/index.js +1598 -0
  10. package/lib/module/components/flow/index.js.map +1 -0
  11. package/lib/module/components/form/index.js +77 -0
  12. package/lib/module/components/form/index.js.map +1 -0
  13. package/lib/module/components/form/use-field.js +50 -0
  14. package/lib/module/components/form/use-field.js.map +1 -0
  15. package/lib/module/components/form/use-form.js +217 -0
  16. package/lib/module/components/form/use-form.js.map +1 -0
  17. package/lib/module/components/frame/index.js +124 -22
  18. package/lib/module/components/frame/index.js.map +1 -1
  19. package/lib/module/components/otp-input/index.js +303 -0
  20. package/lib/module/components/otp-input/index.js.map +1 -0
  21. package/lib/module/components/signature/index.js +500 -0
  22. package/lib/module/components/signature/index.js.map +1 -0
  23. package/lib/module/icons/index.js +218 -0
  24. package/lib/module/icons/index.js.map +1 -1
  25. package/lib/module/index.js +6 -1
  26. package/lib/module/index.js.map +1 -1
  27. package/lib/typescript/src/components/bottom-sheet/index.d.ts +15 -1
  28. package/lib/typescript/src/components/bottom-sheet/index.d.ts.map +1 -1
  29. package/lib/typescript/src/components/checkbox/index.d.ts +8 -0
  30. package/lib/typescript/src/components/checkbox/index.d.ts.map +1 -1
  31. package/lib/typescript/src/components/field/index.d.ts +94 -0
  32. package/lib/typescript/src/components/field/index.d.ts.map +1 -0
  33. package/lib/typescript/src/components/flow/flow-paths.d.ts +89 -0
  34. package/lib/typescript/src/components/flow/flow-paths.d.ts.map +1 -0
  35. package/lib/typescript/src/components/flow/index.d.ts +310 -0
  36. package/lib/typescript/src/components/flow/index.d.ts.map +1 -0
  37. package/lib/typescript/src/components/form/index.d.ts +64 -0
  38. package/lib/typescript/src/components/form/index.d.ts.map +1 -0
  39. package/lib/typescript/src/components/form/use-field.d.ts +16 -0
  40. package/lib/typescript/src/components/form/use-field.d.ts.map +1 -0
  41. package/lib/typescript/src/components/form/use-form.d.ts +37 -0
  42. package/lib/typescript/src/components/form/use-form.d.ts.map +1 -0
  43. package/lib/typescript/src/components/frame/index.d.ts +39 -2
  44. package/lib/typescript/src/components/frame/index.d.ts.map +1 -1
  45. package/lib/typescript/src/components/otp-input/index.d.ts +141 -0
  46. package/lib/typescript/src/components/otp-input/index.d.ts.map +1 -0
  47. package/lib/typescript/src/components/signature/index.d.ts +254 -0
  48. package/lib/typescript/src/components/signature/index.d.ts.map +1 -0
  49. package/lib/typescript/src/icons/index.d.ts +17 -0
  50. package/lib/typescript/src/icons/index.d.ts.map +1 -1
  51. package/lib/typescript/src/index.d.ts +7 -2
  52. package/lib/typescript/src/index.d.ts.map +1 -1
  53. package/package.json +9 -1
  54. package/src/components/bottom-sheet/index.tsx +50 -12
  55. package/src/components/checkbox/index.tsx +40 -12
  56. package/src/components/field/index.tsx +329 -0
  57. package/src/components/flow/flow-paths.ts +348 -0
  58. package/src/components/flow/index.tsx +1924 -0
  59. package/src/components/form/index.tsx +85 -0
  60. package/src/components/form/use-field.ts +65 -0
  61. package/src/components/form/use-form.ts +252 -0
  62. package/src/components/frame/index.tsx +162 -14
  63. package/src/components/otp-input/index.tsx +396 -0
  64. package/src/components/signature/index.tsx +681 -0
  65. package/src/icons/index.tsx +159 -0
  66. package/src/index.ts +72 -0
@@ -0,0 +1,1924 @@
1
+ /**
2
+ * Flow — a canvas of nodes joined by edges, that you pan, pinch and rearrange
3
+ * with a finger.
4
+ *
5
+ * ```tsx
6
+ * <Flow>
7
+ * <Flow.Background variant="dots" />
8
+ * <Flow.Node id="db" position={{ x: 24, y: 40 }}>
9
+ * <Frame>…</Frame>
10
+ * </Flow.Node>
11
+ * <Flow.Node id="web" position={{ x: 220, y: 200 }}>
12
+ * <Frame>…</Frame>
13
+ * </Flow.Node>
14
+ * <Flow.Edge from="web" to="db" dashed animated />
15
+ * <Flow.Controls />
16
+ * </Flow>
17
+ * ```
18
+ *
19
+ * ## Where the positions live
20
+ *
21
+ * Every node's box is kept twice: on the UI thread, where a drag writes it
22
+ * every frame and the node's own transform reads it, and in React state, where
23
+ * the edges are rendered from it. A dragged node therefore never lags its own
24
+ * finger, and the edges attached to it redraw as it moves.
25
+ *
26
+ * The tempting design is one copy — everything on the UI thread, edges
27
+ * animating their own path strings, no renders at all. It does not draw. An
28
+ * animated SVG path in React Native reliably animates its `d` and nothing
29
+ * else, and only while nothing else about it is animated; anything more is
30
+ * dropped with no error, leaving a path that never receives its geometry. So
31
+ * the edges are ordinary elements and cost a render per drag frame. That is a
32
+ * real cost and it is the right trade.
33
+ *
34
+ * JavaScript is told a drag has finished through `onNodeDragEnd`. Positions
35
+ * are otherwise yours to leave alone: pass `position` once and the canvas
36
+ * takes it from there, or keep it in state and pass it back to drive nodes
37
+ * from outside.
38
+ *
39
+ * ## Two gestures, one canvas
40
+ *
41
+ * The pane pans and pinches; a node drags; a handle draws a new connection.
42
+ * They are nested gesture detectors rather than one gesture doing three jobs,
43
+ * so the innermost thing under the finger wins, which is what a finger expects.
44
+ */
45
+ import {
46
+ createContext,
47
+ Fragment,
48
+ useCallback,
49
+ useContext,
50
+ useEffect,
51
+ useMemo,
52
+ useRef,
53
+ useState,
54
+ type ReactNode,
55
+ } from 'react';
56
+ import {
57
+ StyleSheet,
58
+ View,
59
+ type DimensionValue,
60
+ type LayoutChangeEvent,
61
+ type ViewProps,
62
+ type ViewStyle,
63
+ } from 'react-native';
64
+ import { useCSSVariable } from 'uniwind';
65
+ import { Gesture, GestureDetector } from 'react-native-gesture-handler';
66
+ import Animated, {
67
+ runOnJS,
68
+ useAnimatedProps,
69
+ useAnimatedStyle,
70
+ useDerivedValue,
71
+ useSharedValue,
72
+ withRepeat,
73
+ withTiming,
74
+ Easing,
75
+ type SharedValue,
76
+ } from 'react-native-reanimated';
77
+ import Svg, {
78
+ Circle,
79
+ Defs,
80
+ Path,
81
+ Pattern,
82
+ Rect,
83
+ } from 'react-native-svg';
84
+ import { tv } from 'tailwind-variants';
85
+ import { LockIcon, MaximizeIcon, MinusIcon, PlusIcon, UnlockIcon } from '../../icons';
86
+ import { AnimatedPressable } from '../../primitives/animated-pressable';
87
+ import { Text } from '../../primitives/text';
88
+ import { cn } from '../../utils/cn';
89
+ import {
90
+ anchorOf,
91
+ arrivalDirection,
92
+ arrowHeadPath,
93
+ autoSides,
94
+ edgePath,
95
+ type FlowPoint,
96
+ type FlowRect,
97
+ type FlowSide,
98
+ } from './flow-paths';
99
+
100
+ const AnimatedPath = Animated.createAnimatedComponent(Path);
101
+ const AnimatedRect = Animated.createAnimatedComponent(Rect);
102
+
103
+ const SPRING = { duration: 320, easing: Easing.out(Easing.cubic) } as const;
104
+
105
+ /**
106
+ * Without this the transform pivots on the layer's centre, and every zoom
107
+ * throws the graph across the screen.
108
+ */
109
+ const TRANSFORM_ORIGIN = { transformOrigin: 'top left' } as const;
110
+
111
+ /** How many screens of grid to draw around the container in each direction. */
112
+ const GRID_SPAN = 4;
113
+
114
+ /**
115
+ * An SVG is backed by a single texture, and a texture has a maximum size the
116
+ * platform will allocate — past it, nothing is drawn at all rather than
117
+ * something clipped. This keeps the grid comfortably under it at 3× device
118
+ * scale, which is why the grid is the only thing sized in graph space.
119
+ */
120
+ function clampCanvas(value: number): number {
121
+ return Math.max(Math.min(value, 4000), 1);
122
+ }
123
+
124
+ /** How close a finger has to get to a handle for a connection to land. */
125
+ const CONNECT_RADIUS = 44;
126
+
127
+ export type FlowEdgeVariant = 'bezier' | 'smoothstep' | 'step' | 'straight';
128
+
129
+ export interface FlowViewport {
130
+ x: number;
131
+ y: number;
132
+ zoom: number;
133
+ }
134
+
135
+ export interface FlowNodePosition {
136
+ x: number;
137
+ y: number;
138
+ }
139
+
140
+ /** What `onConnect` is handed when a connection lands. */
141
+ export interface FlowConnection {
142
+ /** Node the drag started from. */
143
+ source: string;
144
+ /** Handle it started from, when it started from one. */
145
+ sourceHandle?: string;
146
+ /** Node it was dropped on. */
147
+ target: string;
148
+ /** Handle it was dropped on, when it landed on one. */
149
+ targetHandle?: string;
150
+ }
151
+
152
+ interface HandleEntry {
153
+ key: string;
154
+ node: string;
155
+ id: string;
156
+ side: FlowSide;
157
+ offset: number;
158
+ type: 'source' | 'target' | 'both';
159
+ }
160
+
161
+ interface FlowContextValue {
162
+ /**
163
+ * Every node's box in graph coordinates, on the UI thread — what a drag
164
+ * writes to and what the handle hit-test reads.
165
+ */
166
+ rects: SharedValue<Record<string, FlowRect>>;
167
+ /**
168
+ * The same boxes in React state. Edges are ordinary elements rendered from
169
+ * this, not animated ones, so they need a copy React can see.
170
+ */
171
+ boxes: Record<string, FlowRect>;
172
+ /** Write a node's box to both copies. */
173
+ setNodeRect: (id: string, rect: Partial<FlowRect>) => void;
174
+ /** Forget a node's box, when it unmounts. */
175
+ dropNodeRect: (id: string) => void;
176
+ translateX: SharedValue<number>;
177
+ translateY: SharedValue<number>;
178
+ zoom: SharedValue<number>;
179
+ /** The connection being drawn, in graph coordinates. `active` is 0 or 1. */
180
+ connection: SharedValue<{ active: number; x1: number; y1: number; x2: number; y2: number }>;
181
+ size: SharedValue<{ width: number; height: number }>;
182
+ /**
183
+ * The same size as `size`, in React state. An `<Svg>` needs concrete width
184
+ * and height props, which a shared value cannot supply — this changes once
185
+ * per layout, not once per frame.
186
+ */
187
+ box: { width: number; height: number };
188
+ /** The node and grid layer's size. Both layers share it exactly. */
189
+ layer: { width: number; height: number };
190
+ /**
191
+ * Half the node layer's extent. The layer is positioned at `-origin` and a
192
+ * node translates by its graph position plus this, so graph (0, 0) still
193
+ * lands where the container's top-left is.
194
+ */
195
+ origin: { x: number; y: number };
196
+ minZoom: number;
197
+ maxZoom: number;
198
+ locked: boolean;
199
+ setLocked: (locked: boolean) => void;
200
+ nodeIds: string[];
201
+ handles: HandleEntry[];
202
+ edges: { key: string; props: FlowEdgeProps }[];
203
+ registerNode: (id: string, parent?: string) => void;
204
+ unregisterNode: (id: string) => void;
205
+ registerHandle: (entry: HandleEntry) => void;
206
+ unregisterHandle: (key: string) => void;
207
+ registerEdge: (key: string, props: FlowEdgeProps) => void;
208
+ unregisterEdge: (key: string) => void;
209
+ onConnect?: (connection: FlowConnection) => void;
210
+ isValidConnection?: (connection: FlowConnection) => boolean;
211
+ onNodeDragEnd?: (id: string, position: FlowNodePosition) => void;
212
+ fitView: () => void;
213
+ zoomBy: (factor: number) => void;
214
+ }
215
+
216
+ const FlowContext = createContext<FlowContextValue | null>(null);
217
+
218
+ function useFlow(component: string): FlowContextValue {
219
+ const context = useContext(FlowContext);
220
+ if (!context) {
221
+ throw new Error(`${component} must be used within a <Flow>`);
222
+ }
223
+ return context;
224
+ }
225
+
226
+ /**
227
+ * Which layer a part belongs to. Declared on the component as a static so the
228
+ * root can sort its children without depending on function identity.
229
+ */
230
+ type FlowSlot = 'background' | 'overlay';
231
+
232
+ /** Set by Flow.Group, so a node inside one registers as its child. */
233
+ const FlowGroupContext = createContext<string | null>(null);
234
+
235
+ /** Set by Flow.Node, so a handle knows which node it belongs to. */
236
+ const FlowNodeContext = createContext<string | null>(null);
237
+
238
+ const flowVariants = tv({
239
+ slots: {
240
+ root: 'flex-1 overflow-hidden bg-background',
241
+ controls: 'absolute bottom-4 right-4 overflow-hidden rounded-xl border border-border bg-card',
242
+ control: 'h-10 w-10 items-center justify-center active:bg-muted',
243
+ minimap: 'absolute overflow-hidden rounded-xl border border-border bg-card/90',
244
+ handle: 'absolute h-3.5 w-3.5 rounded-full border-2 border-background bg-muted-foreground',
245
+ },
246
+ });
247
+
248
+ /* -------------------------------------------------------------------------- */
249
+ /* Root */
250
+ /* -------------------------------------------------------------------------- */
251
+
252
+ export interface FlowProps extends Omit<ViewProps, 'children'> {
253
+ className?: string;
254
+ /** Where the canvas starts. `zoom` of 1 is one graph point per screen point. */
255
+ defaultViewport?: FlowViewport;
256
+ /** Closest the canvas will zoom out. */
257
+ minZoom?: number;
258
+ /** Closest it will zoom in. */
259
+ maxZoom?: number;
260
+ /** Drag the empty canvas to move it. */
261
+ panOnDrag?: boolean;
262
+ /** Pinch to zoom. */
263
+ zoomOnPinch?: boolean;
264
+ /**
265
+ * Frame every node once they have all measured themselves. For a graph whose
266
+ * positions come from data and are not laid out against a known screen size.
267
+ */
268
+ fitViewOnMount?: boolean;
269
+ /** Padding left around the graph when fitting, in screen points. */
270
+ fitViewPadding?: number;
271
+ /** The canvas has moved or zoomed. Fired as it happens, on the JS thread. */
272
+ onViewportChange?: (viewport: FlowViewport) => void;
273
+ /** A node was dropped somewhere new. The only time a drag reaches JavaScript. */
274
+ onNodeDragEnd?: (id: string, position: FlowNodePosition) => void;
275
+ /**
276
+ * A connection was drawn between two handles. The canvas never adds the edge
277
+ * itself — the graph is yours, so what a new connection means is yours too.
278
+ */
279
+ onConnect?: (connection: FlowConnection) => void;
280
+ /** Refuse a connection before `onConnect` sees it. */
281
+ isValidConnection?: (connection: FlowConnection) => boolean;
282
+ children?: ReactNode;
283
+ }
284
+
285
+ function FlowRoot({
286
+ className,
287
+ defaultViewport,
288
+ minZoom = 0.3,
289
+ maxZoom = 2.5,
290
+ panOnDrag = true,
291
+ zoomOnPinch = true,
292
+ fitViewOnMount = false,
293
+ fitViewPadding = 48,
294
+ onViewportChange,
295
+ onNodeDragEnd,
296
+ onConnect,
297
+ isValidConnection,
298
+ children,
299
+ ...props
300
+ }: FlowProps) {
301
+ const rects = useSharedValue<Record<string, FlowRect>>({});
302
+ const translateX = useSharedValue(defaultViewport?.x ?? 0);
303
+ const translateY = useSharedValue(defaultViewport?.y ?? 0);
304
+ const zoom = useSharedValue(defaultViewport?.zoom ?? 1);
305
+ const connection = useSharedValue({ active: 0, x1: 0, y1: 0, x2: 0, y2: 0 });
306
+ const size = useSharedValue({ width: 0, height: 0 });
307
+
308
+ const [locked, setLocked] = useState(false);
309
+ const [nodeIds, setNodeIds] = useState<string[]>([]);
310
+ const [handles, setHandles] = useState<HandleEntry[]>([]);
311
+ const [edges, setEdges] = useState<{ key: string; props: FlowEdgeProps }[]>([]);
312
+ const [box, setBox] = useState({ width: 0, height: 0 });
313
+ const [boxes, setBoxes] = useState<Record<string, FlowRect>>({});
314
+ /** The authoritative copy. Both `boxes` and `rects` are written from it. */
315
+ const boxesRef = useRef<Record<string, FlowRect>>({});
316
+
317
+ /**
318
+ * Node geometry is kept twice on purpose: on the UI thread, where a drag
319
+ * writes it every frame and the node's own transform reads it, and in React
320
+ * state, where the edges are rendered from it.
321
+ *
322
+ * The single-copy version — everything on the UI thread, edges animating
323
+ * their own path strings — is the tempting one, and it does not draw. An
324
+ * animated SVG path in React Native only reliably animates its `d`, and only
325
+ * when nothing else about it is animated; anything more and the update is
326
+ * dropped with no error, leaving a path that never receives its geometry.
327
+ * So the edges are plain elements, the way they are in every implementation
328
+ * of this that works, and they cost a render per drag frame. That is a real
329
+ * cost and it is the right trade: a graph that redraws is worth more than
330
+ * one that theoretically would not have to.
331
+ */
332
+ const setNodeRect = useCallback(
333
+ (id: string, patch: Partial<FlowRect>) => {
334
+ const existing = boxesRef.current[id] ?? { x: 0, y: 0, width: 0, height: 0 };
335
+ const next = { ...existing, ...patch };
336
+ if (
337
+ existing.x === next.x &&
338
+ existing.y === next.y &&
339
+ existing.width === next.width &&
340
+ existing.height === next.height
341
+ ) {
342
+ return;
343
+ }
344
+ // Merged against the ref, never against the state updater's argument.
345
+ // A state updater runs during render, and touching a shared value there
346
+ // is both a Reanimated violation and a correctness one: the write can be
347
+ // replayed or dropped, so the two copies drift and the edges end up
348
+ // drawn against positions the nodes are not at.
349
+ boxesRef.current = { ...boxesRef.current, [id]: next };
350
+ rects.value = boxesRef.current;
351
+ setBoxes(boxesRef.current);
352
+ },
353
+ [rects]
354
+ );
355
+
356
+ const dropNodeRect = useCallback(
357
+ (id: string) => {
358
+ if (!(id in boxesRef.current)) return;
359
+ const { [id]: _removed, ...rest } = boxesRef.current;
360
+ boxesRef.current = rest;
361
+ rects.value = rest;
362
+ setBoxes(rest);
363
+ },
364
+ [rects]
365
+ );
366
+
367
+ /**
368
+ * The node layer is given real extent rather than being left the size of the
369
+ * container. A view translated past its parent's bounds still draws, but it
370
+ * stops receiving touches — which is why a node dragged off toward the edge
371
+ * of the canvas would quietly become unmovable. Sizing the layer to the same
372
+ * span as the grid keeps every node inside its parent, where it can be hit.
373
+ */
374
+ const layer = useMemo(
375
+ () => ({
376
+ width: clampCanvas(Math.max(box.width, 320) * GRID_SPAN),
377
+ height: clampCanvas(Math.max(box.height, 480) * GRID_SPAN),
378
+ }),
379
+ [box.height, box.width]
380
+ );
381
+ const origin = useMemo(
382
+ () => ({ x: layer.width / 2, y: layer.height / 2 }),
383
+ [layer.height, layer.width]
384
+ );
385
+
386
+ const viewportChangeRef = useRef(onViewportChange);
387
+ viewportChangeRef.current = onViewportChange;
388
+
389
+ const reportViewport = useCallback((x: number, y: number, z: number) => {
390
+ viewportChangeRef.current?.({ x, y, zoom: z });
391
+ }, []);
392
+
393
+ const registerNode = useCallback((id: string) => {
394
+ setNodeIds((current) => (current.includes(id) ? current : [...current, id]));
395
+ }, []);
396
+
397
+ const unregisterNode = useCallback((id: string) => {
398
+ setNodeIds((current) => current.filter((entry) => entry !== id));
399
+ }, []);
400
+
401
+ const registerHandle = useCallback((entry: HandleEntry) => {
402
+ setHandles((current) => [...current.filter((h) => h.key !== entry.key), entry]);
403
+ }, []);
404
+
405
+ const unregisterHandle = useCallback((key: string) => {
406
+ setHandles((current) => current.filter((h) => h.key !== key));
407
+ }, []);
408
+
409
+ const registerEdge = useCallback((key: string, props: FlowEdgeProps) => {
410
+ setEdges((current) => {
411
+ const rest = current.filter((edge) => edge.key !== key);
412
+ return [...rest, { key, props }];
413
+ });
414
+ }, []);
415
+
416
+ const unregisterEdge = useCallback((key: string) => {
417
+ setEdges((current) => current.filter((edge) => edge.key !== key));
418
+ }, []);
419
+
420
+ const fitView = useCallback(() => {
421
+ const { width, height } = size.value;
422
+ const all = Object.values(rects.value);
423
+ if (!width || !height || all.length === 0) return;
424
+
425
+ let left = Infinity;
426
+ let top = Infinity;
427
+ let right = -Infinity;
428
+ let bottom = -Infinity;
429
+ for (const rect of all) {
430
+ if (rect.width === 0 || rect.height === 0) continue;
431
+ left = Math.min(left, rect.x);
432
+ top = Math.min(top, rect.y);
433
+ right = Math.max(right, rect.x + rect.width);
434
+ bottom = Math.max(bottom, rect.y + rect.height);
435
+ }
436
+ if (!Number.isFinite(left)) return;
437
+
438
+ const graphWidth = Math.max(right - left, 1);
439
+ const graphHeight = Math.max(bottom - top, 1);
440
+ const next = Math.min(
441
+ Math.max(
442
+ Math.min(
443
+ (width - fitViewPadding * 2) / graphWidth,
444
+ (height - fitViewPadding * 2) / graphHeight
445
+ ),
446
+ minZoom
447
+ ),
448
+ maxZoom
449
+ );
450
+
451
+ zoom.value = withTiming(next, SPRING);
452
+ translateX.value = withTiming(width / 2 - (left + graphWidth / 2) * next, SPRING);
453
+ translateY.value = withTiming(height / 2 - (top + graphHeight / 2) * next, SPRING);
454
+ reportViewport(translateX.value, translateY.value, next);
455
+ }, [
456
+ fitViewPadding,
457
+ maxZoom,
458
+ minZoom,
459
+ rects,
460
+ reportViewport,
461
+ size,
462
+ translateX,
463
+ translateY,
464
+ zoom,
465
+ ]);
466
+
467
+ /** Zoom about the middle of the screen, which is where a button press means. */
468
+ const zoomBy = useCallback(
469
+ (factor: number) => {
470
+ const { width, height } = size.value;
471
+ const current = zoom.value;
472
+ const next = Math.min(Math.max(current * factor, minZoom), maxZoom);
473
+ if (next === current) return;
474
+
475
+ const focalX = width / 2;
476
+ const focalY = height / 2;
477
+ const ratio = next / current;
478
+ const x = focalX - (focalX - translateX.value) * ratio;
479
+ const y = focalY - (focalY - translateY.value) * ratio;
480
+
481
+ zoom.value = withTiming(next, SPRING);
482
+ translateX.value = withTiming(x, SPRING);
483
+ translateY.value = withTiming(y, SPRING);
484
+ reportViewport(x, y, next);
485
+ },
486
+ [maxZoom, minZoom, reportViewport, size, translateX, translateY, zoom]
487
+ );
488
+
489
+ // Nodes measure themselves on their first layout pass, so a fit asked for at
490
+ // mount has nothing to fit to yet. One frame later they do.
491
+ const fitOnMount = useRef(fitViewOnMount);
492
+ useEffect(() => {
493
+ if (!fitOnMount.current || nodeIds.length === 0) return;
494
+ const timer = setTimeout(fitView, 32);
495
+ return () => clearTimeout(timer);
496
+ }, [fitView, nodeIds.length]);
497
+
498
+ const panStart = useSharedValue({ x: 0, y: 0 });
499
+ const pinchStart = useSharedValue({ zoom: 1, x: 0, y: 0 });
500
+
501
+ const paneGesture = useMemo(() => {
502
+ const pan = Gesture.Pan()
503
+ .enabled(panOnDrag && !locked)
504
+ .averageTouches(true)
505
+ .onBegin(() => {
506
+ 'worklet';
507
+ panStart.value = { x: translateX.value, y: translateY.value };
508
+ })
509
+ .onUpdate((event) => {
510
+ 'worklet';
511
+ translateX.value = panStart.value.x + event.translationX;
512
+ translateY.value = panStart.value.y + event.translationY;
513
+ })
514
+ .onEnd(() => {
515
+ 'worklet';
516
+ runOnJS(reportViewport)(translateX.value, translateY.value, zoom.value);
517
+ });
518
+
519
+ const pinch = Gesture.Pinch()
520
+ .enabled(zoomOnPinch && !locked)
521
+ .onBegin((event) => {
522
+ 'worklet';
523
+ pinchStart.value = {
524
+ zoom: zoom.value,
525
+ x: translateX.value,
526
+ y: translateY.value,
527
+ };
528
+ // Keep the point under the fingers still: the translation that holds a
529
+ // focal point in place is the one that scales the distance from it.
530
+ connection.value = { ...connection.value };
531
+ panStart.value = { x: event.focalX, y: event.focalY };
532
+ })
533
+ .onUpdate((event) => {
534
+ 'worklet';
535
+ const next = Math.min(
536
+ Math.max(pinchStart.value.zoom * event.scale, minZoom),
537
+ maxZoom
538
+ );
539
+ const ratio = next / pinchStart.value.zoom;
540
+ zoom.value = next;
541
+ translateX.value = event.focalX - (event.focalX - pinchStart.value.x) * ratio;
542
+ translateY.value = event.focalY - (event.focalY - pinchStart.value.y) * ratio;
543
+ })
544
+ .onEnd(() => {
545
+ 'worklet';
546
+ runOnJS(reportViewport)(translateX.value, translateY.value, zoom.value);
547
+ });
548
+
549
+ return Gesture.Simultaneous(pan, pinch);
550
+ }, [
551
+ connection,
552
+ locked,
553
+ maxZoom,
554
+ minZoom,
555
+ panOnDrag,
556
+ panStart,
557
+ pinchStart,
558
+ reportViewport,
559
+ translateX,
560
+ translateY,
561
+ zoom,
562
+ zoomOnPinch,
563
+ ]);
564
+
565
+ const contentStyle = useAnimatedStyle(() => ({
566
+ transform: [
567
+ { translateX: translateX.value },
568
+ { translateY: translateY.value },
569
+ { scale: zoom.value },
570
+ ],
571
+ }));
572
+
573
+ const context = useMemo<FlowContextValue>(
574
+ () => ({
575
+ rects,
576
+ boxes,
577
+ setNodeRect,
578
+ dropNodeRect,
579
+ translateX,
580
+ translateY,
581
+ zoom,
582
+ connection,
583
+ size,
584
+ box,
585
+ layer,
586
+ origin,
587
+ minZoom,
588
+ maxZoom,
589
+ locked,
590
+ setLocked,
591
+ nodeIds,
592
+ handles,
593
+ edges,
594
+ registerNode,
595
+ unregisterNode,
596
+ registerHandle,
597
+ unregisterHandle,
598
+ registerEdge,
599
+ unregisterEdge,
600
+ onConnect,
601
+ isValidConnection,
602
+ onNodeDragEnd,
603
+ fitView,
604
+ zoomBy,
605
+ }),
606
+ [
607
+ boxes,
608
+ setNodeRect,
609
+ dropNodeRect,
610
+ box,
611
+ layer,
612
+ origin,
613
+ connection,
614
+ edges,
615
+ fitView,
616
+ handles,
617
+ registerEdge,
618
+ unregisterEdge,
619
+ isValidConnection,
620
+ locked,
621
+ maxZoom,
622
+ minZoom,
623
+ nodeIds,
624
+ onConnect,
625
+ onNodeDragEnd,
626
+ rects,
627
+ registerHandle,
628
+ registerNode,
629
+ size,
630
+ translateX,
631
+ translateY,
632
+ unregisterHandle,
633
+ unregisterNode,
634
+ zoom,
635
+ zoomBy,
636
+ ]
637
+ );
638
+
639
+ const onLayout = useCallback(
640
+ (event: LayoutChangeEvent) => {
641
+ const { width, height } = event.nativeEvent.layout;
642
+ size.value = { width, height };
643
+ setBox((current) =>
644
+ current.width === width && current.height === height ? current : { width, height }
645
+ );
646
+ },
647
+ [size]
648
+ );
649
+
650
+ // Children are sorted into the layers that actually draw them. Only nodes go
651
+ // inside the transform; the grid and the edges map graph coordinates to
652
+ // screen coordinates themselves, and the controls must not pan away with the
653
+ // graph they control.
654
+ const { background, overlay, canvas } = useMemo(() => splitChildren(children), [children]);
655
+
656
+ return (
657
+ <View
658
+ className={flowVariants().root({ className })}
659
+ onLayout={onLayout}
660
+ collapsable={false}
661
+ accessibilityRole="none"
662
+ {...props}
663
+ >
664
+ <FlowContext.Provider value={context}>
665
+ <GestureDetector gesture={paneGesture}>
666
+ <View collapsable={false} style={{ flex: 1 }}>
667
+ {/* Painted in this order: grid, edges, nodes. Only the nodes are
668
+ transformed — the other two do their own mapping, because an
669
+ SVG large enough to hold a whole graph is larger than the
670
+ platform will allocate a texture for. */}
671
+ {/* One transformed layer holding the grid, the edges and the
672
+ nodes, in that paint order. Everything inside it is in graph
673
+ coordinates and the transform carries the pan and the zoom, so
674
+ nothing inside does any work when the canvas moves. */}
675
+ <Animated.View
676
+ collapsable={false}
677
+ style={[
678
+ {
679
+ position: 'absolute',
680
+ left: -origin.x,
681
+ top: -origin.y,
682
+ width: layer.width,
683
+ height: layer.height,
684
+ // Pivot on graph (0, 0), not on this layer's own corner.
685
+ // The layer starts half its width up and to the left of the
686
+ // container, so scaling about its corner would throw the
687
+ // graph off screen by half the layer on every zoom.
688
+ transformOrigin: [origin.x, origin.y, 0],
689
+ },
690
+ contentStyle,
691
+ ]}
692
+ >
693
+ {background}
694
+ <FlowEdgeLayer />
695
+ {canvas}
696
+ </Animated.View>
697
+ </View>
698
+ </GestureDetector>
699
+ {overlay}
700
+ </FlowContext.Provider>
701
+ </View>
702
+ );
703
+ }
704
+
705
+ /**
706
+ * Sorts what was declared inside `<Flow>` into the three layers that actually
707
+ * draw it.
708
+ *
709
+ * An edge reads best written next to the nodes it joins, but it is an SVG
710
+ * element and has to be inside the canvas's single `<Svg>`; controls and the
711
+ * minimap read best written last, but have to sit outside the transform or
712
+ * they pan away with the graph they control. Rather than make the caller
713
+ * arrange the file to match the paint order, the parts are recognised here.
714
+ *
715
+ * Nested fragments and arrays are flattened on the way, so a `.map()` over
716
+ * edges works exactly as it looks like it should.
717
+ */
718
+ function splitChildren(children: ReactNode): {
719
+ background: ReactNode[];
720
+ overlay: ReactNode[];
721
+ canvas: ReactNode[];
722
+ } {
723
+ const background: ReactNode[] = [];
724
+ const overlay: ReactNode[] = [];
725
+ const canvas: ReactNode[] = [];
726
+
727
+ const visit = (node: ReactNode) => {
728
+ if (node === null || node === undefined || typeof node === 'boolean') return;
729
+ if (Array.isArray(node)) {
730
+ node.forEach(visit);
731
+ return;
732
+ }
733
+ if (typeof node === 'object' && 'type' in node) {
734
+ const type = (node as { type: unknown }).type;
735
+ // Matched on a static marker rather than on the function itself. Identity
736
+ // is the obvious test and the fragile one: it fails the moment two copies
737
+ // of the module exist, and it fails silently — an unrecognised edge would
738
+ // land among the nodes, where it is not an SVG element and draws nothing.
739
+ const slot =
740
+ typeof type === 'function' ? (type as { slot?: FlowSlot }).slot : undefined;
741
+
742
+ if (slot === 'background') {
743
+ background.push(node);
744
+ return;
745
+ }
746
+ if (slot === 'overlay') {
747
+ overlay.push(node);
748
+ return;
749
+ }
750
+ // A fragment is a wrapper the caller used for their own reasons, not a
751
+ // layer — look through it rather than dropping its contents on the floor.
752
+ if (type === Fragment) {
753
+ visit((node as { props?: { children?: ReactNode } }).props?.children);
754
+ return;
755
+ }
756
+ }
757
+ canvas.push(node);
758
+ };
759
+
760
+ visit(children);
761
+ return { background, overlay, canvas };
762
+ }
763
+
764
+ /* -------------------------------------------------------------------------- */
765
+ /* Background */
766
+ /* -------------------------------------------------------------------------- */
767
+
768
+ export interface FlowBackgroundProps {
769
+ /** The mark repeated across the canvas. */
770
+ variant?: 'dots' | 'lines' | 'cross' | 'none';
771
+ /** Points between marks. */
772
+ gap?: number;
773
+ /** How big each mark is drawn. */
774
+ size?: number;
775
+ /** Mark colour. Defaults to a muted theme token. */
776
+ color?: string;
777
+ }
778
+
779
+ /**
780
+ * The grid behind everything. It lives inside the transformed layer, so it
781
+ * pans and scales with the graph — which is the whole point: a grid that
782
+ * stayed put would say the canvas was not moving.
783
+ */
784
+ function FlowBackground({
785
+ variant = 'dots',
786
+ gap = 24,
787
+ size = 1.6,
788
+ color,
789
+ }: FlowBackgroundProps) {
790
+ const { box, layer, origin, translateX, translateY, zoom } = useFlow('Flow.Background');
791
+ const token = useCSSVariable('--color-muted-foreground');
792
+ const tint = color ?? (typeof token === 'string' ? token : '#737373');
793
+ // A pattern is referenced by id, and two canvases on one screen would collide.
794
+ const id = useRef(`panelui-flow-${Math.random().toString(36).slice(2, 7)}`).current;
795
+
796
+ /*
797
+ * The grid is a tile that follows the viewport in whole-cell steps.
798
+ *
799
+ * It is drawn inside the transformed layer, so panning and zooming it costs
800
+ * nothing and the dots stay nailed to the canvas — a grid whose pattern is
801
+ * re-tiled each frame visibly crawls instead of travelling. But a tile fixed
802
+ * in graph space runs out the moment you pan past its edge, and it cannot
803
+ * simply be made enormous: an SVG is one texture, and a texture has a size
804
+ * the platform will not exceed, past which it draws nothing at all.
805
+ *
806
+ * So the tile moves with you, rounded to a whole number of cells. Rounding is
807
+ * what makes the move invisible: shifted by an exact multiple of the grid
808
+ * spacing, the tile is the same picture it was before.
809
+ */
810
+ const follow = useAnimatedStyle(() => {
811
+ // The graph coordinate at the middle of the container. Following the
812
+ // middle rather than the left edge is what makes the tile cover the view
813
+ // at every zoom: centred, it reaches half its width in each direction, and
814
+ // zooming out grows the visible area in both.
815
+ const centreX = (-translateX.value + box.width / 2) / zoom.value;
816
+ const centreY = (-translateY.value + box.height / 2) / zoom.value;
817
+ return {
818
+ transform: [
819
+ { translateX: Math.round(centreX / gap) * gap },
820
+ { translateY: Math.round(centreY / gap) * gap },
821
+ ],
822
+ };
823
+ });
824
+
825
+ if (variant === 'none') return null;
826
+
827
+ /*
828
+ * The grid fills its layer exactly, and the layer is the same box the nodes
829
+ * live in. It used to be an oversized SVG inside a container-sized parent,
830
+ * which the canvas's own `overflow-hidden` then clipped back down — so the
831
+ * dots ran out as soon as you zoomed out far enough to see past the
832
+ * container, which looked like them disappearing.
833
+ */
834
+ const width = layer.width;
835
+ const height = layer.height;
836
+
837
+ return (
838
+ <Animated.View pointerEvents="none" style={[StyleSheet.absoluteFill, follow]}>
839
+ {/* No viewBox: it would rescale the tile whenever the viewport and the
840
+ laid-out size disagreed, and a grid drawn at a different scale from
841
+ the nodes is worse than no grid. The pattern tiles from the layer's
842
+ own origin, which only shifts the dots' phase — invisible. */}
843
+ <Svg pointerEvents="none" width={width} height={height}>
844
+ <Defs>
845
+ <Pattern
846
+ id={id}
847
+ x={0}
848
+ y={0}
849
+ width={gap}
850
+ height={gap}
851
+ patternUnits="userSpaceOnUse"
852
+ >
853
+ {variant === 'dots' ? (
854
+ <Circle cx={gap / 2} cy={gap / 2} r={size} fill={tint} opacity={0.5} />
855
+ ) : null}
856
+ {variant === 'lines' ? (
857
+ <Path
858
+ d={`M0,0 L${gap},0 M0,0 L0,${gap}`}
859
+ stroke={tint}
860
+ strokeWidth={Math.max(size / 2, 0.4)}
861
+ opacity={0.3}
862
+ />
863
+ ) : null}
864
+ {variant === 'cross' ? (
865
+ <Path
866
+ d={`M${gap / 2 - size * 2},${gap / 2} L${gap / 2 + size * 2},${gap / 2} M${gap / 2},${gap / 2 - size * 2} L${gap / 2},${gap / 2 + size * 2}`}
867
+ stroke={tint}
868
+ strokeWidth={Math.max(size / 2, 0.4)}
869
+ opacity={0.42}
870
+ />
871
+ ) : null}
872
+ </Pattern>
873
+ </Defs>
874
+ <Rect x={0} y={0} width={width} height={height} fill={`url(#${id})`} />
875
+ </Svg>
876
+ </Animated.View>
877
+ );
878
+ }
879
+ FlowBackground.displayName = 'Flow.Background';
880
+ FlowBackground.slot = 'background' as FlowSlot;
881
+
882
+ /* -------------------------------------------------------------------------- */
883
+ /* Node */
884
+ /* -------------------------------------------------------------------------- */
885
+
886
+ export interface FlowNodeProps extends Omit<ViewProps, 'children'> {
887
+ /** Identifies the node to edges and to `onNodeDragEnd`. Must be unique. */
888
+ id: string;
889
+ /** Where it starts, in graph coordinates. */
890
+ position: FlowNodePosition;
891
+ className?: string;
892
+ /** Let a finger move it. */
893
+ draggable?: boolean;
894
+ /** Draw the selected ring. */
895
+ selected?: boolean;
896
+ /** Tapping the node — separate from dragging it. */
897
+ onPress?: () => void;
898
+ /** Spoken name. Defaults to the node's id. */
899
+ accessibilityLabel?: string;
900
+ children?: ReactNode;
901
+ }
902
+
903
+ /**
904
+ * One box on the canvas. Its own content is whatever you put inside — a Frame
905
+ * is the usual answer, since a node is a titled card of rows more often than
906
+ * it is anything else.
907
+ */
908
+ function FlowNode({
909
+ id,
910
+ position,
911
+ className,
912
+ draggable = true,
913
+ selected = false,
914
+ onPress,
915
+ accessibilityLabel,
916
+ children,
917
+ ...props
918
+ }: FlowNodeProps) {
919
+ const flow = useFlow('Flow.Node');
920
+ const group = useContext(FlowGroupContext);
921
+ const {
922
+ rects,
923
+ zoom,
924
+ locked,
925
+ origin,
926
+ setNodeRect,
927
+ dropNodeRect,
928
+ registerNode,
929
+ unregisterNode,
930
+ onNodeDragEnd,
931
+ } = flow;
932
+
933
+ useEffect(() => {
934
+ registerNode(id, group ?? undefined);
935
+ return () => unregisterNode(id);
936
+ }, [group, id, registerNode, unregisterNode]);
937
+
938
+ // Writing on every render would fight the drag, snapping a node back to the
939
+ // position it was first given. Only an actual change to the prop moves it.
940
+ const lastPosition = useRef<FlowNodePosition | null>(null);
941
+ useEffect(() => {
942
+ const previous = lastPosition.current;
943
+ if (previous && previous.x === position.x && previous.y === position.y) return;
944
+ lastPosition.current = position;
945
+ setNodeRect(id, { x: position.x, y: position.y });
946
+ }, [id, position, setNodeRect]);
947
+
948
+ useEffect(() => {
949
+ return () => dropNodeRect(id);
950
+ }, [dropNodeRect, id]);
951
+
952
+ const onLayout = useCallback(
953
+ (event: LayoutChangeEvent) => {
954
+ const { width, height } = event.nativeEvent.layout;
955
+ setNodeRect(id, { width, height });
956
+ },
957
+ [id, setNodeRect]
958
+ );
959
+
960
+ const dragEnd = useCallback(
961
+ (x: number, y: number) => onNodeDragEnd?.(id, { x, y }),
962
+ [id, onNodeDragEnd]
963
+ );
964
+
965
+ /**
966
+ * The node itself moves on the UI thread; this tells React where it got to,
967
+ * so the edges attached to it redraw. Called every frame of a drag — which
968
+ * is what it costs to have edges that follow, and what every working
969
+ * implementation of this pays.
970
+ */
971
+ const dragTo = useCallback(
972
+ (x: number, y: number) => setNodeRect(id, { x, y }),
973
+ [id, setNodeRect]
974
+ );
975
+
976
+ const start = useSharedValue({ x: 0, y: 0 });
977
+
978
+ const pressRef = useRef(onPress);
979
+ pressRef.current = onPress;
980
+ const press = useCallback(() => pressRef.current?.(), []);
981
+
982
+ const drag = useMemo(
983
+ () =>
984
+ Gesture.Pan()
985
+ .enabled(draggable && !locked)
986
+ .onBegin(() => {
987
+ 'worklet';
988
+ const rect = rects.value[id];
989
+ start.value = { x: rect?.x ?? 0, y: rect?.y ?? 0 };
990
+ })
991
+ .onUpdate((event) => {
992
+ 'worklet';
993
+ const rect = rects.value[id];
994
+ if (!rect) return;
995
+ // Divided by the zoom, so the node keeps up with the finger rather
996
+ // than with the graph coordinate the finger happens to be over.
997
+ const x = start.value.x + event.translationX / zoom.value;
998
+ const y = start.value.y + event.translationY / zoom.value;
999
+ rects.value = { ...rects.value, [id]: { ...rect, x, y } };
1000
+ runOnJS(dragTo)(x, y);
1001
+ })
1002
+ .onEnd(() => {
1003
+ 'worklet';
1004
+ const rect = rects.value[id];
1005
+ if (rect) runOnJS(dragEnd)(rect.x, rect.y);
1006
+ }),
1007
+ [dragEnd, dragTo, draggable, id, locked, rects, start, zoom]
1008
+ );
1009
+
1010
+ // Raced with the drag rather than layered on top of it. A plain touch handler
1011
+ // would fire at the end of a drag too, so every rearrangement would also read
1012
+ // as a tap on whatever was moved.
1013
+ const gesture = useMemo(
1014
+ () =>
1015
+ onPress
1016
+ ? Gesture.Race(
1017
+ drag,
1018
+ Gesture.Tap().onEnd(() => {
1019
+ 'worklet';
1020
+ runOnJS(press)();
1021
+ })
1022
+ )
1023
+ : drag,
1024
+ [drag, onPress, press]
1025
+ );
1026
+
1027
+ const style = useAnimatedStyle(() => {
1028
+ const rect = rects.value[id];
1029
+ return {
1030
+ transform: [
1031
+ { translateX: (rect?.x ?? 0) + origin.x },
1032
+ { translateY: (rect?.y ?? 0) + origin.y },
1033
+ ],
1034
+ };
1035
+ });
1036
+
1037
+ return (
1038
+ <FlowNodeContext.Provider value={id}>
1039
+ <GestureDetector gesture={gesture}>
1040
+ <Animated.View
1041
+ collapsable={false}
1042
+ onLayout={onLayout}
1043
+ style={[{ position: 'absolute', top: 0, left: 0 }, style]}
1044
+ className={cn(
1045
+ selected && 'rounded-3xl ring-2 ring-ring',
1046
+ className
1047
+ )}
1048
+ accessible
1049
+ accessibilityRole={onPress ? 'button' : 'none'}
1050
+ accessibilityLabel={accessibilityLabel ?? id}
1051
+ accessibilityState={{ selected }}
1052
+ {...props}
1053
+ >
1054
+ {children}
1055
+ </Animated.View>
1056
+ </GestureDetector>
1057
+ </FlowNodeContext.Provider>
1058
+ );
1059
+ }
1060
+ FlowNode.displayName = 'Flow.Node';
1061
+
1062
+ /* -------------------------------------------------------------------------- */
1063
+ /* Handle */
1064
+ /* -------------------------------------------------------------------------- */
1065
+
1066
+ export interface FlowHandleProps {
1067
+ /** Names the handle to an edge, as `"nodeId.handleId"`. */
1068
+ id?: string;
1069
+ /** Which face it sits on. */
1070
+ position?: FlowSide;
1071
+ /**
1072
+ * `source` starts connections, `target` receives them, `both` does either.
1073
+ * A drag from a source can only land on a target, and the other way round.
1074
+ */
1075
+ type?: 'source' | 'target' | 'both';
1076
+ /** Where along the face, 0–1. For more than one handle on a side. */
1077
+ offset?: number;
1078
+ className?: string;
1079
+ /** Draw nothing. The handle still anchors edges and still accepts a drop. */
1080
+ hidden?: boolean;
1081
+ }
1082
+
1083
+ /**
1084
+ * A port on a node — both the point an edge attaches to and the grip a new
1085
+ * connection is dragged from.
1086
+ *
1087
+ * Its position is worked out from the node's box and the face it names, so it
1088
+ * never has to measure itself. That matters: a handle that measured would be
1089
+ * one frame behind the node it sits on, and the edge would trail its own port.
1090
+ */
1091
+ function FlowHandle({
1092
+ id = 'default',
1093
+ position = 'right',
1094
+ type = 'both',
1095
+ offset = 0.5,
1096
+ className,
1097
+ hidden = false,
1098
+ }: FlowHandleProps) {
1099
+ const flow = useFlow('Flow.Handle');
1100
+ const node = useContext(FlowNodeContext);
1101
+ if (!node) {
1102
+ throw new Error('Flow.Handle must be used within a <Flow.Node>');
1103
+ }
1104
+
1105
+ const { rects, zoom, connection, handles, locked, registerHandle, unregisterHandle } = flow;
1106
+ const key = `${node}.${id}`;
1107
+
1108
+ useEffect(() => {
1109
+ registerHandle({ key, node, id, side: position, offset, type });
1110
+ return () => unregisterHandle(key);
1111
+ }, [id, key, node, offset, position, registerHandle, type, unregisterHandle]);
1112
+
1113
+ const connectRef = useRef(flow.onConnect);
1114
+ connectRef.current = flow.onConnect;
1115
+ const validRef = useRef(flow.isValidConnection);
1116
+ validRef.current = flow.isValidConnection;
1117
+
1118
+ const land = useCallback(
1119
+ (target: string, targetHandle: string | undefined) => {
1120
+ const payload: FlowConnection = {
1121
+ source: node,
1122
+ sourceHandle: id,
1123
+ target,
1124
+ targetHandle,
1125
+ };
1126
+ if (validRef.current && !validRef.current(payload)) return;
1127
+ connectRef.current?.(payload);
1128
+ },
1129
+ [id, node]
1130
+ );
1131
+
1132
+ // A snapshot of what a drag can land on, rebuilt only when handles come and
1133
+ // go — the drop test runs on the UI thread and cannot read React state.
1134
+ const targets = useMemo(
1135
+ () =>
1136
+ handles
1137
+ .filter((entry) => entry.node !== node && entry.type !== 'source')
1138
+ .map((entry) => ({
1139
+ node: entry.node,
1140
+ id: entry.id,
1141
+ side: entry.side,
1142
+ offset: entry.offset,
1143
+ })),
1144
+ [handles, node]
1145
+ );
1146
+
1147
+ const connect = useMemo(
1148
+ () =>
1149
+ Gesture.Pan()
1150
+ .enabled(!locked && type !== 'target')
1151
+ .onBegin(() => {
1152
+ 'worklet';
1153
+ const rect = rects.value[node];
1154
+ if (!rect) return;
1155
+ const anchor = anchorOf(rect, position, offset);
1156
+ connection.value = {
1157
+ active: 1,
1158
+ x1: anchor.x,
1159
+ y1: anchor.y,
1160
+ x2: anchor.x,
1161
+ y2: anchor.y,
1162
+ };
1163
+ })
1164
+ .onUpdate((event) => {
1165
+ 'worklet';
1166
+ const current = connection.value;
1167
+ if (current.active === 0) return;
1168
+ connection.value = {
1169
+ ...current,
1170
+ x2: current.x1 + event.translationX / zoom.value,
1171
+ y2: current.y1 + event.translationY / zoom.value,
1172
+ };
1173
+ })
1174
+ .onEnd(() => {
1175
+ 'worklet';
1176
+ const current = connection.value;
1177
+ connection.value = { active: 0, x1: 0, y1: 0, x2: 0, y2: 0 };
1178
+ if (current.active === 0) return;
1179
+
1180
+ const drop = { x: current.x2, y: current.y2 };
1181
+ const reach = CONNECT_RADIUS / zoom.value;
1182
+
1183
+ // Nearest handle wins. A finger is blunt, so the first thing inside
1184
+ // the radius is rarely the one that was meant.
1185
+ let bestNode: string | null = null;
1186
+ let bestHandle: string | undefined;
1187
+ let bestDistance = reach;
1188
+
1189
+ for (let i = 0; i < targets.length; i += 1) {
1190
+ const entry = targets[i]!;
1191
+ const rect = rects.value[entry.node];
1192
+ if (!rect) continue;
1193
+ const anchor = anchorOf(rect, entry.side, entry.offset);
1194
+ const dx = anchor.x - drop.x;
1195
+ const dy = anchor.y - drop.y;
1196
+ const distance = Math.sqrt(dx * dx + dy * dy);
1197
+ if (distance < bestDistance) {
1198
+ bestDistance = distance;
1199
+ bestNode = entry.node;
1200
+ bestHandle = entry.id;
1201
+ }
1202
+ }
1203
+
1204
+ // Nothing close enough to a handle: dropping anywhere on a node is
1205
+ // the friendlier reading of the gesture on a small screen.
1206
+ if (bestNode === null) {
1207
+ const entries = Object.entries(rects.value);
1208
+ for (let i = 0; i < entries.length; i += 1) {
1209
+ const [candidate, rect] = entries[i]!;
1210
+ if (candidate === node) continue;
1211
+ if (
1212
+ drop.x >= rect.x &&
1213
+ drop.x <= rect.x + rect.width &&
1214
+ drop.y >= rect.y &&
1215
+ drop.y <= rect.y + rect.height
1216
+ ) {
1217
+ bestNode = candidate;
1218
+ bestHandle = undefined;
1219
+ break;
1220
+ }
1221
+ }
1222
+ }
1223
+
1224
+ if (bestNode !== null) runOnJS(land)(bestNode, bestHandle);
1225
+ }),
1226
+ [connection, land, locked, node, offset, position, rects, targets, type, zoom]
1227
+ );
1228
+
1229
+ if (hidden) return null;
1230
+
1231
+ // Positioned as a percentage of the node it sits in, so it follows the node's
1232
+ // own size without either of them measuring the other.
1233
+ const along = `${offset * 100}%` as DimensionValue;
1234
+ const placement: ViewStyle =
1235
+ position === 'top'
1236
+ ? { top: -7, left: along, marginLeft: -7 }
1237
+ : position === 'bottom'
1238
+ ? { bottom: -7, left: along, marginLeft: -7 }
1239
+ : position === 'left'
1240
+ ? { left: -7, top: along, marginTop: -7 }
1241
+ : { right: -7, top: along, marginTop: -7 };
1242
+
1243
+ return (
1244
+ <GestureDetector gesture={connect}>
1245
+ <View
1246
+ collapsable={false}
1247
+ hitSlop={12}
1248
+ accessibilityRole="button"
1249
+ accessibilityLabel={`${position} connection point`}
1250
+ style={placement}
1251
+ className={flowVariants().handle({ className })}
1252
+ />
1253
+ </GestureDetector>
1254
+ );
1255
+ }
1256
+ FlowHandle.displayName = 'Flow.Handle';
1257
+
1258
+ /* -------------------------------------------------------------------------- */
1259
+ /* Edges */
1260
+ /* -------------------------------------------------------------------------- */
1261
+
1262
+ export interface FlowEdgeProps {
1263
+ /** Source, as `"nodeId"` or `"nodeId.handleId"`. */
1264
+ from: string;
1265
+ /** Target, same shape. */
1266
+ to: string;
1267
+ /** How the edge is routed. */
1268
+ variant?: FlowEdgeVariant;
1269
+ /**
1270
+ * Mark the edge as carrying something — a request, a build, a dependency
1271
+ * that is live rather than declared. Draws it dashed.
1272
+ */
1273
+ animated?: boolean;
1274
+ /** Draw it broken rather than solid. */
1275
+ dashed?: boolean;
1276
+ /**
1277
+ * Stroke colour. Defaults to the muted-foreground token — an edge is content
1278
+ * rather than chrome, and the border token it would otherwise share with the
1279
+ * nodes is, by design, barely there.
1280
+ */
1281
+ color?: string;
1282
+ /** Stroke width in graph points. */
1283
+ width?: number;
1284
+ /** Put an arrowhead on the target end. */
1285
+ arrow?: boolean;
1286
+ /** Override the face it leaves from. Otherwise worked out from the layout. */
1287
+ fromSide?: FlowSide;
1288
+ /** Override the face it arrives at. */
1289
+ toSide?: FlowSide;
1290
+ /** Corner radius for `smoothstep`. */
1291
+ radius?: number;
1292
+ /** How far the edge steps clear of a node before turning. */
1293
+ gap?: number;
1294
+ /** Curve strength for `bezier`. */
1295
+ curvature?: number;
1296
+ }
1297
+
1298
+ /**
1299
+ * A line between two nodes. It names them rather than coordinates, and works
1300
+ * out its own geometry from wherever they currently are — including which face
1301
+ * to use, which is why a graph the user rearranges does not need re-specifying.
1302
+ *
1303
+ * It draws nothing where it is written. Every edge in a canvas has to end up
1304
+ * inside one `<Svg>`, under every node — an SVG element rendered among the
1305
+ * nodes is not in an SVG at all and silently draws nothing — so an edge
1306
+ * registers itself and the canvas paints it in the right layer. Which means an
1307
+ * edge can be written wherever it reads best: beside the nodes it joins, inside
1308
+ * a group, inside a `.map`, behind a condition.
1309
+ */
1310
+ function FlowEdge(props: FlowEdgeProps) {
1311
+ const { registerEdge, unregisterEdge } = useFlow('Flow.Edge');
1312
+ const key = `${props.from}->${props.to}`;
1313
+
1314
+ // Re-registered whenever any prop changes, so `animated` toggling or a
1315
+ // variant switch reaches the layer that draws it.
1316
+ const json = JSON.stringify(props);
1317
+ useEffect(() => {
1318
+ registerEdge(key, props);
1319
+ return () => unregisterEdge(key);
1320
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1321
+ }, [key, json, registerEdge, unregisterEdge]);
1322
+
1323
+ return null;
1324
+ }
1325
+
1326
+ /** The part that actually draws an edge, inside the canvas's one `<Svg>`. */
1327
+ function FlowEdgePath({
1328
+ from,
1329
+ to,
1330
+ variant = 'bezier',
1331
+ animated = false,
1332
+ dashed = false,
1333
+ color,
1334
+ width = 2,
1335
+ arrow = false,
1336
+ fromSide,
1337
+ toSide,
1338
+ radius = 8,
1339
+ gap = 20,
1340
+ curvature = 0.25,
1341
+ boxes,
1342
+ handles,
1343
+ tint,
1344
+ origin,
1345
+ }: FlowEdgeProps & {
1346
+ boxes: Record<string, FlowRect>;
1347
+ handles: HandleEntry[];
1348
+ tint: string;
1349
+ origin: { x: number; y: number };
1350
+ }) {
1351
+ const source = resolveEnd(from, fromSide, handles);
1352
+ const target = resolveEnd(to, toSide, handles);
1353
+
1354
+ const fromRect = boxes[source.node];
1355
+ const toRect = boxes[target.node];
1356
+ if (!fromRect || !toRect || fromRect.width === 0 || toRect.width === 0) return null;
1357
+
1358
+ const auto = autoSides(fromRect, toRect);
1359
+ const sideA: FlowSide = source.side ?? auto.from;
1360
+ const sideB: FlowSide = target.side ?? auto.to;
1361
+ const rawA = anchorOf(fromRect, sideA, source.offset);
1362
+ const rawB = anchorOf(toRect, sideB, target.offset);
1363
+
1364
+ // Shifted into the layer's own coordinates, which is exactly what a node
1365
+ // does with its translate. Both use the same arithmetic on purpose: an SVG
1366
+ // `viewBox` would have done the shift too, but it also rescales its contents
1367
+ // to fit whenever the viewport and the laid-out size disagree, and an edge
1368
+ // drawn at a slightly different scale from the nodes misses them by a little
1369
+ // everywhere — which looks like bad routing rather than a bad transform.
1370
+ const a = { x: rawA.x + origin.x, y: rawA.y + origin.y };
1371
+ const b = { x: rawB.x + origin.x, y: rawB.y + origin.y };
1372
+
1373
+ const d = edgePath(variant, a, sideA, b, sideB, curvature, radius, gap);
1374
+ const dash = dashed || animated ? 6 : 0;
1375
+ const stroke = color ?? tint;
1376
+
1377
+ const head = arrow
1378
+ ? (() => {
1379
+ const dir = arrivalDirection(variant, a, b, sideB);
1380
+ return arrowHeadPath(b, dir.x, dir.y, Math.max(width * 3.5, 7));
1381
+ })()
1382
+ : null;
1383
+
1384
+ return (
1385
+ <>
1386
+ <Path
1387
+ d={d}
1388
+ fill="none"
1389
+ stroke={stroke}
1390
+ strokeWidth={width}
1391
+ strokeLinecap="round"
1392
+ strokeLinejoin="round"
1393
+ strokeDasharray={dash ? `${dash} ${dash}` : undefined}
1394
+ />
1395
+ {head ? <Path d={head} fill={stroke} /> : null}
1396
+ </>
1397
+ );
1398
+ }
1399
+
1400
+ /**
1401
+ * Splits `"node"` or `"node.handle"` into the node and, when a handle was
1402
+ * named, the face and offset that handle registered.
1403
+ */
1404
+ function resolveEnd(
1405
+ reference: string,
1406
+ explicit: FlowSide | undefined,
1407
+ handles: HandleEntry[]
1408
+ ): { node: string; side: FlowSide | undefined; offset: number } {
1409
+ const dot = reference.indexOf('.');
1410
+ if (dot === -1) {
1411
+ return { node: reference, side: explicit, offset: 0.5 };
1412
+ }
1413
+ const node = reference.slice(0, dot);
1414
+ const entry = handles.find((handle) => handle.key === reference);
1415
+ return {
1416
+ node,
1417
+ side: explicit ?? entry?.side,
1418
+ offset: entry?.offset ?? 0.5,
1419
+ };
1420
+ }
1421
+
1422
+ /**
1423
+ * The one SVG every edge draws into, sitting inside the transformed layer in
1424
+ * graph coordinates — so panning and zooming the canvas moves the edges with
1425
+ * it, for free, and no edge does any work per frame.
1426
+ */
1427
+ function FlowEdgeLayer() {
1428
+ const { boxes, edges, handles, connection, layer, origin } = useFlow('Flow');
1429
+ const token = useCSSVariable('--color-muted-foreground');
1430
+ const tint = typeof token === 'string' ? token : '#878787';
1431
+
1432
+ const lineProps = useAnimatedProps(() => {
1433
+ const current = connection.value;
1434
+ if (current.active === 0) return { d: '' };
1435
+ return {
1436
+ d:
1437
+ `M${current.x1 + origin.x},${current.y1 + origin.y} ` +
1438
+ `L${current.x2 + origin.x},${current.y2 + origin.y}`,
1439
+ };
1440
+ });
1441
+
1442
+ return (
1443
+ <Svg
1444
+ pointerEvents="none"
1445
+ style={{ position: 'absolute', left: 0, top: 0 }}
1446
+ width={layer.width}
1447
+ height={layer.height}
1448
+ >
1449
+ {edges.map((edge) => (
1450
+ <FlowEdgePath
1451
+ key={edge.key}
1452
+ {...edge.props}
1453
+ boxes={boxes}
1454
+ handles={handles}
1455
+ tint={tint}
1456
+ origin={origin}
1457
+ />
1458
+ ))}
1459
+ {/* The connection line is the one path that does animate, because it
1460
+ follows a finger and has nothing else to follow. It animates `d` and
1461
+ only `d`, which is the one thing that works. */}
1462
+ <AnimatedPath
1463
+ animatedProps={lineProps}
1464
+ fill="none"
1465
+ stroke={tint}
1466
+ strokeWidth={2}
1467
+ strokeDasharray="4 4"
1468
+ strokeLinecap="round"
1469
+ />
1470
+ </Svg>
1471
+ );
1472
+ }
1473
+
1474
+ /* -------------------------------------------------------------------------- */
1475
+ /* Group */
1476
+ /* -------------------------------------------------------------------------- */
1477
+
1478
+ export interface FlowGroupProps extends Omit<ViewProps, 'children'> {
1479
+ /** Identifies the group, the same way a node's id does. */
1480
+ id: string;
1481
+ /** Where the container sits. */
1482
+ position: FlowNodePosition;
1483
+ /** How big the container is. Children are positioned inside it. */
1484
+ size: { width: number; height: number };
1485
+ /** Caption drawn along the top edge. */
1486
+ label?: string;
1487
+ className?: string;
1488
+ /** Move the group, and everything in it, with a finger. */
1489
+ draggable?: boolean;
1490
+ children?: ReactNode;
1491
+ }
1492
+
1493
+ /**
1494
+ * A container other nodes sit in and travel with.
1495
+ *
1496
+ * Dragging it offsets every child in the same worklet that moves the group, so
1497
+ * a group of twenty nodes costs one frame's work rather than twenty.
1498
+ */
1499
+ function FlowGroup({
1500
+ id,
1501
+ position,
1502
+ size,
1503
+ label,
1504
+ className,
1505
+ draggable = true,
1506
+ children,
1507
+ ...props
1508
+ }: FlowGroupProps) {
1509
+ const flow = useFlow('Flow.Group');
1510
+ const {
1511
+ rects,
1512
+ zoom,
1513
+ locked,
1514
+ origin,
1515
+ setNodeRect,
1516
+ dropNodeRect,
1517
+ registerNode,
1518
+ unregisterNode,
1519
+ onNodeDragEnd,
1520
+ } = flow;
1521
+
1522
+ useEffect(() => {
1523
+ registerNode(id);
1524
+ return () => unregisterNode(id);
1525
+ }, [id, registerNode, unregisterNode]);
1526
+
1527
+ useEffect(() => {
1528
+ setNodeRect(id, {
1529
+ x: position.x,
1530
+ y: position.y,
1531
+ width: size.width,
1532
+ height: size.height,
1533
+ });
1534
+ return () => dropNodeRect(id);
1535
+ }, [dropNodeRect, id, position.x, position.y, setNodeRect, size.height, size.width]);
1536
+
1537
+ // Which nodes travel with the group, captured when they register rather than
1538
+ // read per frame.
1539
+ const childIds = useMemo(() => collectNodeIds(children), [children]);
1540
+
1541
+ const start = useSharedValue<Record<string, FlowNodePosition>>({});
1542
+
1543
+ const dragEnd = useCallback(
1544
+ (x: number, y: number) => onNodeDragEnd?.(id, { x, y }),
1545
+ [id, onNodeDragEnd]
1546
+ );
1547
+
1548
+ /** Tells React where the group and everything in it got to. */
1549
+ const dragTo = useCallback(
1550
+ (moved: Record<string, FlowNodePosition>) => {
1551
+ for (const [key, next] of Object.entries(moved)) setNodeRect(key, next);
1552
+ },
1553
+ [setNodeRect]
1554
+ );
1555
+
1556
+ const drag = useMemo(
1557
+ () =>
1558
+ Gesture.Pan()
1559
+ .enabled(draggable && !locked)
1560
+ .onBegin(() => {
1561
+ 'worklet';
1562
+ const snapshot: Record<string, FlowNodePosition> = {};
1563
+ const group = rects.value[id];
1564
+ if (group) snapshot[id] = { x: group.x, y: group.y };
1565
+ for (let i = 0; i < childIds.length; i += 1) {
1566
+ const child = rects.value[childIds[i]!];
1567
+ if (child) snapshot[childIds[i]!] = { x: child.x, y: child.y };
1568
+ }
1569
+ start.value = snapshot;
1570
+ })
1571
+ .onUpdate((event) => {
1572
+ 'worklet';
1573
+ const dx = event.translationX / zoom.value;
1574
+ const dy = event.translationY / zoom.value;
1575
+ const next = { ...rects.value };
1576
+ const moved: Record<string, FlowNodePosition> = {};
1577
+ const ids = Object.keys(start.value);
1578
+ for (let i = 0; i < ids.length; i += 1) {
1579
+ const key = ids[i]!;
1580
+ const rect = next[key];
1581
+ const from = start.value[key];
1582
+ if (!rect || !from) continue;
1583
+ const x = from.x + dx;
1584
+ const y = from.y + dy;
1585
+ next[key] = { ...rect, x, y };
1586
+ moved[key] = { x, y };
1587
+ }
1588
+ rects.value = next;
1589
+ runOnJS(dragTo)(moved);
1590
+ })
1591
+ .onEnd(() => {
1592
+ 'worklet';
1593
+ const rect = rects.value[id];
1594
+ if (rect) runOnJS(dragEnd)(rect.x, rect.y);
1595
+ }),
1596
+ [childIds, dragEnd, dragTo, draggable, id, locked, rects, start, zoom]
1597
+ );
1598
+
1599
+ const style = useAnimatedStyle(() => {
1600
+ const rect = rects.value[id];
1601
+ return {
1602
+ transform: [
1603
+ { translateX: (rect?.x ?? 0) + origin.x },
1604
+ { translateY: (rect?.y ?? 0) + origin.y },
1605
+ ],
1606
+ };
1607
+ });
1608
+
1609
+ return (
1610
+ <FlowGroupContext.Provider value={id}>
1611
+ <GestureDetector gesture={drag}>
1612
+ <Animated.View
1613
+ collapsable={false}
1614
+ style={[
1615
+ { position: 'absolute', top: 0, left: 0, width: size.width, height: size.height },
1616
+ style,
1617
+ ]}
1618
+ className={cn(
1619
+ 'rounded-2xl border border-dashed border-border bg-muted/25',
1620
+ className
1621
+ )}
1622
+ accessibilityRole="none"
1623
+ accessibilityLabel={label ?? id}
1624
+ {...props}
1625
+ >
1626
+ {label ? (
1627
+ <Text size="xs" muted className="px-3 pt-2 uppercase tracking-wider">
1628
+ {label}
1629
+ </Text>
1630
+ ) : null}
1631
+ </Animated.View>
1632
+ </GestureDetector>
1633
+ {/* Children are siblings of the container, not descendants of it: a node
1634
+ inside an absolutely-positioned box would be positioned against that
1635
+ box, and every graph coordinate would mean something different
1636
+ depending on which group it happened to be in. */}
1637
+ {children}
1638
+ </FlowGroupContext.Provider>
1639
+ );
1640
+ }
1641
+ FlowGroup.displayName = 'Flow.Group';
1642
+
1643
+ function collectNodeIds(children: ReactNode): string[] {
1644
+ const ids: string[] = [];
1645
+ const list = Array.isArray(children) ? children.flat(Infinity) : [children];
1646
+ for (const child of list) {
1647
+ if (
1648
+ child &&
1649
+ typeof child === 'object' &&
1650
+ 'props' in child &&
1651
+ typeof (child as { props?: { id?: unknown } }).props?.id === 'string'
1652
+ ) {
1653
+ ids.push((child as { props: { id: string } }).props.id);
1654
+ }
1655
+ }
1656
+ return ids;
1657
+ }
1658
+
1659
+ /* -------------------------------------------------------------------------- */
1660
+ /* Controls */
1661
+ /* -------------------------------------------------------------------------- */
1662
+
1663
+ export interface FlowControlsProps {
1664
+ className?: string;
1665
+ /** Show the zoom in and out buttons. */
1666
+ zoom?: boolean;
1667
+ /** Show the fit-to-graph button. */
1668
+ fit?: boolean;
1669
+ /** Show the lock, which freezes panning, zooming and dragging together. */
1670
+ lock?: boolean;
1671
+ /** How much one press of zoom in multiplies the scale by. */
1672
+ step?: number;
1673
+ }
1674
+
1675
+ /**
1676
+ * The button stack in the corner. It sits outside the transformed layer, so it
1677
+ * stays put while the canvas moves under it.
1678
+ *
1679
+ * Worth having even where pinch works: on a phone, pinching to a specific
1680
+ * scale is imprecise, and framing the whole graph by hand is worse.
1681
+ */
1682
+ function FlowControls({
1683
+ className,
1684
+ zoom: showZoom = true,
1685
+ fit = true,
1686
+ lock = true,
1687
+ step = 1.3,
1688
+ }: FlowControlsProps) {
1689
+ const { zoomBy, fitView, locked, setLocked } = useFlow('Flow.Controls');
1690
+ const slots = flowVariants();
1691
+
1692
+ return (
1693
+ <View className={slots.controls({ className })}>
1694
+ {showZoom ? (
1695
+ <>
1696
+ <AnimatedPressable
1697
+ accessibilityRole="button"
1698
+ accessibilityLabel="Zoom in"
1699
+ className={slots.control()}
1700
+ onPress={() => zoomBy(step)}
1701
+ >
1702
+ <PlusIcon size={16} />
1703
+ </AnimatedPressable>
1704
+ <View className="h-px bg-border" />
1705
+ <AnimatedPressable
1706
+ accessibilityRole="button"
1707
+ accessibilityLabel="Zoom out"
1708
+ className={slots.control()}
1709
+ onPress={() => zoomBy(1 / step)}
1710
+ >
1711
+ <MinusIcon size={16} />
1712
+ </AnimatedPressable>
1713
+ </>
1714
+ ) : null}
1715
+ {fit ? (
1716
+ <>
1717
+ <View className="h-px bg-border" />
1718
+ <AnimatedPressable
1719
+ accessibilityRole="button"
1720
+ accessibilityLabel="Fit the whole graph on screen"
1721
+ className={slots.control()}
1722
+ onPress={fitView}
1723
+ >
1724
+ <MaximizeIcon size={16} />
1725
+ </AnimatedPressable>
1726
+ </>
1727
+ ) : null}
1728
+ {lock ? (
1729
+ <>
1730
+ <View className="h-px bg-border" />
1731
+ <AnimatedPressable
1732
+ accessibilityRole="button"
1733
+ accessibilityLabel={locked ? 'Unlock the canvas' : 'Lock the canvas'}
1734
+ accessibilityState={{ selected: locked }}
1735
+ className={slots.control()}
1736
+ onPress={() => setLocked(!locked)}
1737
+ >
1738
+ {locked ? <LockIcon size={16} /> : <UnlockIcon size={16} />}
1739
+ </AnimatedPressable>
1740
+ </>
1741
+ ) : null}
1742
+ </View>
1743
+ );
1744
+ }
1745
+ FlowControls.displayName = 'Flow.Controls';
1746
+ FlowControls.slot = 'overlay' as FlowSlot;
1747
+
1748
+ /* -------------------------------------------------------------------------- */
1749
+ /* MiniMap */
1750
+ /* -------------------------------------------------------------------------- */
1751
+
1752
+ export interface FlowMiniMapProps {
1753
+ className?: string;
1754
+ /** Which corner it sits in. */
1755
+ position?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
1756
+ /** Box size in screen points. */
1757
+ width?: number;
1758
+ height?: number;
1759
+ /** Colour of a node in the map. Defaults to the muted-foreground token. */
1760
+ nodeColor?: string;
1761
+ }
1762
+
1763
+ const MINIMAP_CORNERS = {
1764
+ 'top-left': 'left-4 top-4',
1765
+ 'top-right': 'right-4 top-4',
1766
+ 'bottom-left': 'bottom-4 left-4',
1767
+ 'bottom-right': 'bottom-20 right-4',
1768
+ } as const;
1769
+
1770
+ /**
1771
+ * An overview of the whole graph with the visible region marked on it — for a
1772
+ * canvas bigger than the screen, where panning alone loses you.
1773
+ *
1774
+ * The map's own scale is derived once per frame and shared by every node in it,
1775
+ * so N nodes cost one bounds calculation rather than N.
1776
+ */
1777
+ function FlowMiniMap({
1778
+ className,
1779
+ position = 'bottom-right',
1780
+ width = 120,
1781
+ height = 84,
1782
+ nodeColor,
1783
+ }: FlowMiniMapProps) {
1784
+ const { rects, nodeIds, translateX, translateY, zoom, size } = useFlow('Flow.MiniMap');
1785
+ const token = useCSSVariable('--color-muted-foreground');
1786
+ const tint = nodeColor ?? (typeof token === 'string' ? token : '#737373');
1787
+ const ringToken = useCSSVariable('--color-ring');
1788
+ const ring = typeof ringToken === 'string' ? ringToken : '#737373';
1789
+
1790
+ const padding = 6;
1791
+
1792
+ /** The graph's bounds and the scale that fits them in the box. Once a frame. */
1793
+ const fit = useDerivedValue(() => {
1794
+ let left = Infinity;
1795
+ let top = Infinity;
1796
+ let right = -Infinity;
1797
+ let bottom = -Infinity;
1798
+
1799
+ const all = Object.values(rects.value);
1800
+ for (let i = 0; i < all.length; i += 1) {
1801
+ const rect = all[i]!;
1802
+ if (rect.width === 0) continue;
1803
+ left = Math.min(left, rect.x);
1804
+ top = Math.min(top, rect.y);
1805
+ right = Math.max(right, rect.x + rect.width);
1806
+ bottom = Math.max(bottom, rect.y + rect.height);
1807
+ }
1808
+
1809
+ if (!Number.isFinite(left)) {
1810
+ return { left: 0, top: 0, scale: 1 };
1811
+ }
1812
+
1813
+ // A floor under the graph's size, and a ceiling over the scale. Before
1814
+ // every node has measured, the bounds can be a single small box — and the
1815
+ // scale that fits one box to the map blows every rectangle up until the
1816
+ // map is a solid block of colour.
1817
+ const graphWidth = Math.max(right - left, 200);
1818
+ const graphHeight = Math.max(bottom - top, 200);
1819
+ const scale = Math.min(
1820
+ (width - padding * 2) / graphWidth,
1821
+ (height - padding * 2) / graphHeight,
1822
+ 1
1823
+ );
1824
+ return { left, top, scale };
1825
+ });
1826
+
1827
+ const viewportProps = useAnimatedProps(() => {
1828
+ const { left, top, scale } = fit.value;
1829
+ const screen = size.value;
1830
+ if (screen.width === 0) return { x: 0, y: 0, width: 0, height: 0 };
1831
+
1832
+
1833
+ // The graph rectangle currently on screen, mapped into the map's space.
1834
+ const graphX = -translateX.value / zoom.value;
1835
+ const graphY = -translateY.value / zoom.value;
1836
+ return {
1837
+ x: padding + (graphX - left) * scale,
1838
+ y: padding + (graphY - top) * scale,
1839
+ width: Math.min((screen.width / zoom.value) * scale, width),
1840
+ height: Math.min((screen.height / zoom.value) * scale, height),
1841
+ };
1842
+ });
1843
+
1844
+ return (
1845
+ <View
1846
+ pointerEvents="none"
1847
+ style={{ width, height }}
1848
+ className={cn(
1849
+ flowVariants().minimap(),
1850
+ MINIMAP_CORNERS[position],
1851
+ className
1852
+ )}
1853
+ accessibilityLabel="Graph overview"
1854
+ >
1855
+ <Svg width={width} height={height}>
1856
+ {nodeIds.map((id) => (
1857
+ <MiniMapNode
1858
+ key={id}
1859
+ id={id}
1860
+ rects={rects}
1861
+ fit={fit}
1862
+ padding={padding}
1863
+ color={tint}
1864
+ width={width}
1865
+ height={height}
1866
+ />
1867
+ ))}
1868
+ <AnimatedRect
1869
+ animatedProps={viewportProps}
1870
+ fill="none"
1871
+ stroke={ring}
1872
+ strokeWidth={1}
1873
+ rx={2}
1874
+ />
1875
+ </Svg>
1876
+ </View>
1877
+ );
1878
+ }
1879
+ FlowMiniMap.displayName = 'Flow.MiniMap';
1880
+ FlowMiniMap.slot = 'overlay' as FlowSlot;
1881
+
1882
+ function MiniMapNode({
1883
+ id,
1884
+ rects,
1885
+ fit,
1886
+ padding,
1887
+ color,
1888
+ width,
1889
+ height,
1890
+ }: {
1891
+ id: string;
1892
+ rects: SharedValue<Record<string, FlowRect>>;
1893
+ fit: SharedValue<{ left: number; top: number; scale: number }>;
1894
+ padding: number;
1895
+ color: string;
1896
+ width: number;
1897
+ height: number;
1898
+ }) {
1899
+ const props = useAnimatedProps(() => {
1900
+ const rect = rects.value[id];
1901
+ if (!rect || rect.width === 0) return { x: 0, y: 0, width: 0, height: 0 };
1902
+ const { left, top, scale } = fit.value;
1903
+ return {
1904
+ x: padding + (rect.x - left) * scale,
1905
+ y: padding + (rect.y - top) * scale,
1906
+ width: Math.min(Math.max(rect.width * scale, 2), width),
1907
+ height: Math.min(Math.max(rect.height * scale, 2), height),
1908
+ };
1909
+ });
1910
+
1911
+ return <AnimatedRect animatedProps={props} fill={color} opacity={0.55} rx={1.5} />;
1912
+ }
1913
+
1914
+ export const Flow = Object.assign(FlowRoot, {
1915
+ Background: FlowBackground,
1916
+ Node: FlowNode,
1917
+ Handle: FlowHandle,
1918
+ Edge: FlowEdge,
1919
+ Group: FlowGroup,
1920
+ Controls: FlowControls,
1921
+ MiniMap: FlowMiniMap,
1922
+ });
1923
+
1924
+ export type { FlowSide, FlowRect, FlowPoint };