panelui-native 0.16.0 → 0.20.1
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/lib/module/components/bottom-sheet/index.js +38 -10
- package/lib/module/components/bottom-sheet/index.js.map +1 -1
- package/lib/module/components/checkbox/index.js +24 -11
- package/lib/module/components/checkbox/index.js.map +1 -1
- package/lib/module/components/field/index.js +337 -0
- package/lib/module/components/field/index.js.map +1 -0
- package/lib/module/components/flow/flow-paths.js +378 -0
- package/lib/module/components/flow/flow-paths.js.map +1 -0
- package/lib/module/components/flow/index.js +1598 -0
- package/lib/module/components/flow/index.js.map +1 -0
- package/lib/module/components/form/index.js +77 -0
- package/lib/module/components/form/index.js.map +1 -0
- package/lib/module/components/form/use-field.js +50 -0
- package/lib/module/components/form/use-field.js.map +1 -0
- package/lib/module/components/form/use-form.js +217 -0
- package/lib/module/components/form/use-form.js.map +1 -0
- package/lib/module/components/frame/index.js +124 -22
- package/lib/module/components/frame/index.js.map +1 -1
- package/lib/module/components/otp-input/index.js +303 -0
- package/lib/module/components/otp-input/index.js.map +1 -0
- package/lib/module/components/rating/index.js +339 -0
- package/lib/module/components/rating/index.js.map +1 -0
- package/lib/module/components/signature/index.js +500 -0
- package/lib/module/components/signature/index.js.map +1 -0
- package/lib/module/components/slider/index.js +14 -1
- package/lib/module/components/slider/index.js.map +1 -1
- package/lib/module/icons/index.js +247 -0
- package/lib/module/icons/index.js.map +1 -1
- package/lib/module/index.js +7 -1
- package/lib/module/index.js.map +1 -1
- package/lib/typescript/src/components/bottom-sheet/index.d.ts +15 -1
- package/lib/typescript/src/components/bottom-sheet/index.d.ts.map +1 -1
- package/lib/typescript/src/components/checkbox/index.d.ts +8 -0
- package/lib/typescript/src/components/checkbox/index.d.ts.map +1 -1
- package/lib/typescript/src/components/field/index.d.ts +94 -0
- package/lib/typescript/src/components/field/index.d.ts.map +1 -0
- package/lib/typescript/src/components/flow/flow-paths.d.ts +89 -0
- package/lib/typescript/src/components/flow/flow-paths.d.ts.map +1 -0
- package/lib/typescript/src/components/flow/index.d.ts +310 -0
- package/lib/typescript/src/components/flow/index.d.ts.map +1 -0
- package/lib/typescript/src/components/form/index.d.ts +64 -0
- package/lib/typescript/src/components/form/index.d.ts.map +1 -0
- package/lib/typescript/src/components/form/use-field.d.ts +16 -0
- package/lib/typescript/src/components/form/use-field.d.ts.map +1 -0
- package/lib/typescript/src/components/form/use-form.d.ts +37 -0
- package/lib/typescript/src/components/form/use-form.d.ts.map +1 -0
- package/lib/typescript/src/components/frame/index.d.ts +39 -2
- package/lib/typescript/src/components/frame/index.d.ts.map +1 -1
- package/lib/typescript/src/components/otp-input/index.d.ts +141 -0
- package/lib/typescript/src/components/otp-input/index.d.ts.map +1 -0
- package/lib/typescript/src/components/rating/index.d.ts +122 -0
- package/lib/typescript/src/components/rating/index.d.ts.map +1 -0
- package/lib/typescript/src/components/signature/index.d.ts +254 -0
- package/lib/typescript/src/components/signature/index.d.ts.map +1 -0
- package/lib/typescript/src/components/slider/index.d.ts +6 -0
- package/lib/typescript/src/components/slider/index.d.ts.map +1 -1
- package/lib/typescript/src/icons/index.d.ts +25 -0
- package/lib/typescript/src/icons/index.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +8 -2
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/package.json +9 -1
- package/src/components/bottom-sheet/index.tsx +50 -12
- package/src/components/checkbox/index.tsx +40 -12
- package/src/components/field/index.tsx +329 -0
- package/src/components/flow/flow-paths.ts +348 -0
- package/src/components/flow/index.tsx +1924 -0
- package/src/components/form/index.tsx +85 -0
- package/src/components/form/use-field.ts +65 -0
- package/src/components/form/use-form.ts +252 -0
- package/src/components/frame/index.tsx +162 -14
- package/src/components/otp-input/index.tsx +396 -0
- package/src/components/rating/index.tsx +416 -0
- package/src/components/signature/index.tsx +681 -0
- package/src/components/slider/index.tsx +20 -1
- package/src/icons/index.tsx +185 -0
- package/src/index.ts +78 -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 };
|