panelui-web 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +43 -2
- package/dist/components/bar-picker/index.d.ts +61 -0
- package/dist/components/bar-picker/index.js +139 -0
- package/dist/components/command-dock/index.d.ts +118 -0
- package/dist/components/command-dock/index.js +239 -0
- package/dist/components/day-grid/index.d.ts +87 -0
- package/dist/components/day-grid/index.js +251 -0
- package/dist/components/feedback/index.d.ts +135 -0
- package/dist/components/feedback/index.js +283 -0
- package/dist/components/flow/flow-paths.d.ts +94 -0
- package/dist/components/flow/flow-paths.js +270 -0
- package/dist/components/flow/index.d.ts +235 -0
- package/dist/components/flow/index.js +597 -0
- package/dist/components/kpi/index.d.ts +211 -0
- package/dist/components/kpi/index.js +243 -0
- package/dist/components/lane-board/index.d.ts +125 -0
- package/dist/components/lane-board/index.js +691 -0
- package/dist/components/planet/index.d.ts +132 -0
- package/dist/components/planet/index.js +653 -0
- package/dist/components/planet/land.d.ts +8 -0
- package/dist/components/planet/land.js +122 -0
- package/dist/components/range-meter/index.d.ts +70 -0
- package/dist/components/range-meter/index.js +170 -0
- package/dist/components/rolling-number/index.d.ts +46 -0
- package/dist/components/rolling-number/index.js +81 -0
- package/dist/components/tilt-card/index.d.ts +60 -0
- package/dist/components/tilt-card/index.js +114 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +12 -0
- package/dist/lib/cn.d.ts +3 -0
- package/dist/lib/cn.js +6 -0
- package/package.json +72 -4
|
@@ -0,0 +1,597 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
/**
|
|
4
|
+
* Flow — a canvas of nodes joined by edges, that you pan, zoom and rearrange.
|
|
5
|
+
*
|
|
6
|
+
* ```tsx
|
|
7
|
+
* <Flow>
|
|
8
|
+
* <Flow.Background variant="dots" />
|
|
9
|
+
* <Flow.Node id="source" position={{ x: 24, y: 40 }}>…</Flow.Node>
|
|
10
|
+
* <Flow.Node id="build" position={{ x: 24, y: 200 }}>…</Flow.Node>
|
|
11
|
+
* <Flow.Edge from="source" to="build" dashed animated arrow />
|
|
12
|
+
* <Flow.Controls />
|
|
13
|
+
* </Flow>
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* ## The same parts as the app
|
|
17
|
+
*
|
|
18
|
+
* The parts and their props are the app library's Flow, so a graph written
|
|
19
|
+
* for one reads the same in the other. What differs is the hand: here the
|
|
20
|
+
* pane is dragged with a mouse, zoomed with a pinch or with Control and the
|
|
21
|
+
* wheel, and every node can be reached and moved from the keyboard.
|
|
22
|
+
*
|
|
23
|
+
* ## Where the positions live
|
|
24
|
+
*
|
|
25
|
+
* A node's `position` is where it starts. From then on the canvas holds it:
|
|
26
|
+
* a drag moves it there, and `onNodeDragEnd` says where it came to rest. Pass
|
|
27
|
+
* a new `position` and the node goes to it, which is how nodes are driven
|
|
28
|
+
* from outside.
|
|
29
|
+
*
|
|
30
|
+
* Edges are drawn from the boxes of the nodes they join, measured off the
|
|
31
|
+
* page. An edge therefore follows a node whatever is inside it, and redraws
|
|
32
|
+
* as either end is dragged.
|
|
33
|
+
*
|
|
34
|
+
* ## Three gestures, one canvas
|
|
35
|
+
*
|
|
36
|
+
* The pane pans, a node drags, and a handle draws a new connection. Each is
|
|
37
|
+
* taken by the innermost thing under the pointer, which is what a hand
|
|
38
|
+
* expects.
|
|
39
|
+
*/
|
|
40
|
+
import { createContext, useCallback, useContext, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, } from 'react';
|
|
41
|
+
import { createPortal } from 'react-dom';
|
|
42
|
+
import { tv } from 'tailwind-variants';
|
|
43
|
+
import { anchorOf, arrivalDirection, arrowHeadPath, autoSides, bezierPath, edgePath, } from './flow-paths.js';
|
|
44
|
+
const flowVariants = tv({
|
|
45
|
+
slots: {
|
|
46
|
+
root: 'relative h-96 w-full cursor-grab touch-none select-none overflow-hidden rounded-2xl border bg-background text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring active:cursor-grabbing',
|
|
47
|
+
underlay: 'pointer-events-none absolute inset-0 text-muted-foreground/35',
|
|
48
|
+
layer: 'absolute left-0 top-0 origin-top-left will-change-transform',
|
|
49
|
+
overlay: 'pointer-events-none absolute inset-0 *:pointer-events-auto',
|
|
50
|
+
node: 'absolute left-0 top-0 z-10 cursor-grab rounded-2xl border bg-card text-card-foreground shadow-sm outline-none transition-shadow focus-visible:ring-2 focus-visible:ring-ring active:cursor-grabbing',
|
|
51
|
+
group: 'absolute left-0 top-0 cursor-grab rounded-3xl border border-dashed bg-muted/40 p-3 pt-8 outline-none focus-visible:ring-2 focus-visible:ring-ring active:cursor-grabbing',
|
|
52
|
+
groupLabel: 'absolute start-4 top-2.5 text-xs font-medium uppercase tracking-wider text-muted-foreground',
|
|
53
|
+
handle: 'absolute z-20 size-3 -translate-x-1/2 -translate-y-1/2 cursor-crosshair rounded-full border-2 border-background bg-muted-foreground outline-none transition-[scale,background-color] hover:scale-150 hover:bg-foreground focus-visible:ring-2 focus-visible:ring-ring',
|
|
54
|
+
edge: 'pointer-events-none absolute left-0 top-0 size-px overflow-visible text-[var(--pu-color)]',
|
|
55
|
+
controls: 'absolute flex flex-col divide-y divide-border overflow-hidden rounded-xl border bg-card text-card-foreground shadow-sm',
|
|
56
|
+
control: 'flex size-9 cursor-pointer items-center justify-center text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:bg-accent focus-visible:text-foreground active:bg-muted [&_svg]:size-4',
|
|
57
|
+
minimap: 'absolute overflow-hidden rounded-xl border bg-card/90 shadow-sm backdrop-blur-sm',
|
|
58
|
+
},
|
|
59
|
+
variants: {
|
|
60
|
+
color: {
|
|
61
|
+
primary: { edge: '[--pu-color:var(--primary)]' },
|
|
62
|
+
info: { edge: '[--pu-color:var(--info)]' },
|
|
63
|
+
success: { edge: '[--pu-color:var(--success)]' },
|
|
64
|
+
warning: { edge: '[--pu-color:var(--warning)]' },
|
|
65
|
+
destructive: { edge: '[--pu-color:var(--destructive)]' },
|
|
66
|
+
// The colour of a hairline that is meant to be seen but not looked at.
|
|
67
|
+
muted: { edge: '[--pu-color:var(--muted-foreground)]' },
|
|
68
|
+
},
|
|
69
|
+
selected: { true: { node: 'z-20 ring-2 ring-ring' } },
|
|
70
|
+
position: {
|
|
71
|
+
'top-left': { controls: 'left-3 top-3', minimap: 'left-3 top-3' },
|
|
72
|
+
'top-right': { controls: 'right-3 top-3', minimap: 'right-3 top-3' },
|
|
73
|
+
'bottom-left': { controls: 'bottom-3 left-3', minimap: 'bottom-3 left-3' },
|
|
74
|
+
'bottom-right': { controls: 'bottom-3 right-3', minimap: 'bottom-3 right-3' },
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
const FlowContext = createContext(null);
|
|
79
|
+
const NodeContext = createContext(null);
|
|
80
|
+
function useFlow(part) {
|
|
81
|
+
const flow = useContext(FlowContext);
|
|
82
|
+
if (!flow)
|
|
83
|
+
throw new Error(`${part} must be used inside a <Flow>.`);
|
|
84
|
+
return flow;
|
|
85
|
+
}
|
|
86
|
+
const clamp = (value, low, high) => Math.min(high, Math.max(low, value));
|
|
87
|
+
const OPPOSITE = {
|
|
88
|
+
top: 'bottom',
|
|
89
|
+
bottom: 'top',
|
|
90
|
+
left: 'right',
|
|
91
|
+
right: 'left',
|
|
92
|
+
};
|
|
93
|
+
function FlowRoot({ defaultViewport = { x: 0, y: 0, zoom: 1 }, minZoom = 0.3, maxZoom = 2.5, panOnDrag = true, zoomOnPinch = true, fitViewOnMount = false, fitViewPadding = 48, onViewportChange, onNodeDragEnd, onConnect, isValidConnection, label = 'Diagram', className, children, onKeyDown, ...props }) {
|
|
94
|
+
const slots = flowVariants();
|
|
95
|
+
const root = useRef(null);
|
|
96
|
+
const [viewport, setViewportState] = useState(defaultViewport);
|
|
97
|
+
const [rects, setRects] = useState({});
|
|
98
|
+
const [handles, setHandles] = useState({});
|
|
99
|
+
const [locked, setLocked] = useState(false);
|
|
100
|
+
const [size, setSize] = useState({ width: 0, height: 0 });
|
|
101
|
+
const [underlay, setUnderlay] = useState(null);
|
|
102
|
+
const [overlay, setOverlay] = useState(null);
|
|
103
|
+
/** A connection being drawn: where it leaves from and where the pointer is. */
|
|
104
|
+
const [wire, setWire] = useState(null);
|
|
105
|
+
const live = useRef({ viewport, rects, size, locked });
|
|
106
|
+
live.current = { viewport, rects, size, locked };
|
|
107
|
+
const callbacks = useRef({ onViewportChange, onConnect, isValidConnection });
|
|
108
|
+
callbacks.current = { onViewportChange, onConnect, isValidConnection };
|
|
109
|
+
const setViewport = useCallback((next) => {
|
|
110
|
+
setViewportState(next);
|
|
111
|
+
callbacks.current.onViewportChange?.(next);
|
|
112
|
+
}, []);
|
|
113
|
+
/** A point on the page, as a point on the graph. */
|
|
114
|
+
const toGraph = useCallback((clientX, clientY) => {
|
|
115
|
+
const box = root.current?.getBoundingClientRect();
|
|
116
|
+
const { x, y, zoom } = live.current.viewport;
|
|
117
|
+
return { x: (clientX - (box?.left ?? 0) - x) / zoom, y: (clientY - (box?.top ?? 0) - y) / zoom };
|
|
118
|
+
}, []);
|
|
119
|
+
/** Zooms about a point on the canvas, so that point stays where it is. */
|
|
120
|
+
const zoomAbout = useCallback((factor, px, py) => {
|
|
121
|
+
const { x, y, zoom } = live.current.viewport;
|
|
122
|
+
const next = clamp(zoom * factor, minZoom, maxZoom);
|
|
123
|
+
const ratio = next / zoom;
|
|
124
|
+
setViewport({ x: px - (px - x) * ratio, y: py - (py - y) * ratio, zoom: next });
|
|
125
|
+
}, [minZoom, maxZoom, setViewport]);
|
|
126
|
+
const zoomBy = useCallback((factor) => {
|
|
127
|
+
const { width, height } = live.current.size;
|
|
128
|
+
zoomAbout(factor, width / 2, height / 2);
|
|
129
|
+
}, [zoomAbout]);
|
|
130
|
+
const fit = useCallback(() => {
|
|
131
|
+
const boxes = Object.values(live.current.rects).filter((box) => box.width > 0);
|
|
132
|
+
const { width, height } = live.current.size;
|
|
133
|
+
if (boxes.length === 0 || width === 0)
|
|
134
|
+
return;
|
|
135
|
+
const left = Math.min(...boxes.map((box) => box.x));
|
|
136
|
+
const top = Math.min(...boxes.map((box) => box.y));
|
|
137
|
+
const right = Math.max(...boxes.map((box) => box.x + box.width));
|
|
138
|
+
const bottom = Math.max(...boxes.map((box) => box.y + box.height));
|
|
139
|
+
const zoom = clamp(Math.min((width - fitViewPadding * 2) / Math.max(1, right - left), (height - fitViewPadding * 2) / Math.max(1, bottom - top), 1), minZoom, maxZoom);
|
|
140
|
+
setViewport({
|
|
141
|
+
x: (width - (right - left) * zoom) / 2 - left * zoom,
|
|
142
|
+
y: (height - (bottom - top) * zoom) / 2 - top * zoom,
|
|
143
|
+
zoom,
|
|
144
|
+
});
|
|
145
|
+
}, [fitViewPadding, minZoom, maxZoom, setViewport]);
|
|
146
|
+
// The canvas's own size, for fitting and for the minimap.
|
|
147
|
+
useLayoutEffect(() => {
|
|
148
|
+
const node = root.current;
|
|
149
|
+
if (!node)
|
|
150
|
+
return;
|
|
151
|
+
const observer = new ResizeObserver(() => {
|
|
152
|
+
setSize({ width: node.clientWidth, height: node.clientHeight });
|
|
153
|
+
});
|
|
154
|
+
observer.observe(node);
|
|
155
|
+
return () => observer.disconnect();
|
|
156
|
+
}, []);
|
|
157
|
+
// Fitted once, when every node has been measured and the canvas has a size.
|
|
158
|
+
const fitted = useRef(false);
|
|
159
|
+
useEffect(() => {
|
|
160
|
+
if (!fitViewOnMount || fitted.current || size.width === 0)
|
|
161
|
+
return;
|
|
162
|
+
const boxes = Object.values(rects);
|
|
163
|
+
if (boxes.length === 0 || boxes.some((box) => box.width === 0))
|
|
164
|
+
return;
|
|
165
|
+
fitted.current = true;
|
|
166
|
+
fit();
|
|
167
|
+
}, [fitViewOnMount, rects, size, fit]);
|
|
168
|
+
// Control with the wheel, which is also what a trackpad pinch sends. The
|
|
169
|
+
// plain wheel is left alone: a diagram in a page must not trap its scroll.
|
|
170
|
+
useEffect(() => {
|
|
171
|
+
const node = root.current;
|
|
172
|
+
if (!node || !zoomOnPinch)
|
|
173
|
+
return;
|
|
174
|
+
const onWheel = (event) => {
|
|
175
|
+
if (!event.ctrlKey && !event.metaKey)
|
|
176
|
+
return;
|
|
177
|
+
event.preventDefault();
|
|
178
|
+
if (live.current.locked)
|
|
179
|
+
return;
|
|
180
|
+
const box = node.getBoundingClientRect();
|
|
181
|
+
zoomAbout(Math.exp(-event.deltaY * 0.01), event.clientX - box.left, event.clientY - box.top);
|
|
182
|
+
};
|
|
183
|
+
node.addEventListener('wheel', onWheel, { passive: false });
|
|
184
|
+
return () => node.removeEventListener('wheel', onWheel);
|
|
185
|
+
}, [zoomOnPinch, zoomAbout]);
|
|
186
|
+
/* Panning, and pinching with two fingers. */
|
|
187
|
+
const pointers = useRef(new Map());
|
|
188
|
+
const gesture = useRef(null);
|
|
189
|
+
const onPointerDown = (event) => {
|
|
190
|
+
if (event.target !== event.currentTarget && !event.target.hasAttribute('data-flow-pane'))
|
|
191
|
+
return;
|
|
192
|
+
if (event.button !== 0 || locked)
|
|
193
|
+
return;
|
|
194
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
195
|
+
pointers.current.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
|
196
|
+
gesture.current = null;
|
|
197
|
+
};
|
|
198
|
+
const onPointerMove = (event) => {
|
|
199
|
+
const held = pointers.current.get(event.pointerId);
|
|
200
|
+
if (!held)
|
|
201
|
+
return;
|
|
202
|
+
const all = [...pointers.current.values()];
|
|
203
|
+
if (all.length === 1 && panOnDrag) {
|
|
204
|
+
const { x, y, zoom } = live.current.viewport;
|
|
205
|
+
setViewport({ x: x + event.clientX - held.x, y: y + event.clientY - held.y, zoom });
|
|
206
|
+
}
|
|
207
|
+
pointers.current.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
|
208
|
+
if (all.length === 2 && zoomOnPinch) {
|
|
209
|
+
const [a, b] = [...pointers.current.values()];
|
|
210
|
+
if (!a || !b)
|
|
211
|
+
return;
|
|
212
|
+
const spread = Math.hypot(a.x - b.x, a.y - b.y);
|
|
213
|
+
const box = event.currentTarget.getBoundingClientRect();
|
|
214
|
+
const mid = { x: (a.x + b.x) / 2 - box.left, y: (a.y + b.y) / 2 - box.top };
|
|
215
|
+
if (gesture.current)
|
|
216
|
+
zoomAbout(spread / gesture.current.spread, mid.x, mid.y);
|
|
217
|
+
gesture.current = { ...mid, spread };
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
const onPointerEnd = (event) => {
|
|
221
|
+
pointers.current.delete(event.pointerId);
|
|
222
|
+
gesture.current = null;
|
|
223
|
+
};
|
|
224
|
+
/* Drawing a connection from a handle. */
|
|
225
|
+
const connectFrom = useCallback((source, handle, event) => {
|
|
226
|
+
const box = live.current.rects[source];
|
|
227
|
+
if (!box || live.current.locked)
|
|
228
|
+
return;
|
|
229
|
+
const from = anchorOf(box, handle.side, handle.offset);
|
|
230
|
+
setWire({ from, side: handle.side, to: toGraph(event.clientX, event.clientY) });
|
|
231
|
+
const move = (next) => {
|
|
232
|
+
setWire({ from, side: handle.side, to: toGraph(next.clientX, next.clientY) });
|
|
233
|
+
};
|
|
234
|
+
const up = (next) => {
|
|
235
|
+
window.removeEventListener('pointermove', move);
|
|
236
|
+
window.removeEventListener('pointerup', up);
|
|
237
|
+
window.removeEventListener('pointercancel', up);
|
|
238
|
+
setWire(null);
|
|
239
|
+
if (next.type === 'pointercancel')
|
|
240
|
+
return;
|
|
241
|
+
const under = document.elementFromPoint(next.clientX, next.clientY);
|
|
242
|
+
const onHandle = under?.closest('[data-flow-handle]');
|
|
243
|
+
const onNode = under?.closest('[data-flow-node]');
|
|
244
|
+
const target = onNode?.dataset.flowNode;
|
|
245
|
+
if (!target || target === source || !root.current?.contains(onNode ?? null))
|
|
246
|
+
return;
|
|
247
|
+
const connection = {
|
|
248
|
+
source,
|
|
249
|
+
sourceHandle: handle.id,
|
|
250
|
+
target,
|
|
251
|
+
targetHandle: onHandle?.dataset.flowHandle || undefined,
|
|
252
|
+
};
|
|
253
|
+
if (callbacks.current.isValidConnection?.(connection) === false)
|
|
254
|
+
return;
|
|
255
|
+
callbacks.current.onConnect?.(connection);
|
|
256
|
+
};
|
|
257
|
+
window.addEventListener('pointermove', move);
|
|
258
|
+
window.addEventListener('pointerup', up);
|
|
259
|
+
window.addEventListener('pointercancel', up);
|
|
260
|
+
}, [toGraph]);
|
|
261
|
+
const place = useCallback((id, position) => {
|
|
262
|
+
setRects((current) => {
|
|
263
|
+
const before = current[id];
|
|
264
|
+
if (before && before.x === position.x && before.y === position.y)
|
|
265
|
+
return current;
|
|
266
|
+
return {
|
|
267
|
+
...current,
|
|
268
|
+
[id]: { x: position.x, y: position.y, width: before?.width ?? 0, height: before?.height ?? 0 },
|
|
269
|
+
};
|
|
270
|
+
});
|
|
271
|
+
}, []);
|
|
272
|
+
const measure = useCallback((id, width, height) => {
|
|
273
|
+
setRects((current) => {
|
|
274
|
+
const before = current[id];
|
|
275
|
+
if (!before || (before.width === width && before.height === height))
|
|
276
|
+
return current;
|
|
277
|
+
return { ...current, [id]: { ...before, width, height } };
|
|
278
|
+
});
|
|
279
|
+
}, []);
|
|
280
|
+
const forget = useCallback((id) => {
|
|
281
|
+
setRects((current) => {
|
|
282
|
+
if (!(id in current))
|
|
283
|
+
return current;
|
|
284
|
+
const { [id]: _gone, ...rest } = current;
|
|
285
|
+
return rest;
|
|
286
|
+
});
|
|
287
|
+
}, []);
|
|
288
|
+
const setHandle = useCallback((node, key, entry) => {
|
|
289
|
+
setHandles((current) => {
|
|
290
|
+
const others = (current[node] ?? []).filter((handle) => (handle.id ?? handle.side) !== key);
|
|
291
|
+
return { ...current, [node]: entry ? [...others, entry] : others };
|
|
292
|
+
});
|
|
293
|
+
}, []);
|
|
294
|
+
const context = useMemo(() => ({
|
|
295
|
+
viewport,
|
|
296
|
+
rects,
|
|
297
|
+
handles,
|
|
298
|
+
locked,
|
|
299
|
+
size,
|
|
300
|
+
underlay,
|
|
301
|
+
overlay,
|
|
302
|
+
place,
|
|
303
|
+
measure,
|
|
304
|
+
forget,
|
|
305
|
+
setHandle,
|
|
306
|
+
zoomBy,
|
|
307
|
+
fit,
|
|
308
|
+
setLocked,
|
|
309
|
+
connectFrom,
|
|
310
|
+
onNodeDragEnd,
|
|
311
|
+
}), [
|
|
312
|
+
viewport,
|
|
313
|
+
rects,
|
|
314
|
+
handles,
|
|
315
|
+
locked,
|
|
316
|
+
size,
|
|
317
|
+
underlay,
|
|
318
|
+
overlay,
|
|
319
|
+
place,
|
|
320
|
+
measure,
|
|
321
|
+
forget,
|
|
322
|
+
setHandle,
|
|
323
|
+
zoomBy,
|
|
324
|
+
fit,
|
|
325
|
+
connectFrom,
|
|
326
|
+
onNodeDragEnd,
|
|
327
|
+
]);
|
|
328
|
+
const keys = (event) => {
|
|
329
|
+
onKeyDown?.(event);
|
|
330
|
+
if (event.defaultPrevented || event.target !== event.currentTarget)
|
|
331
|
+
return;
|
|
332
|
+
const pan = {
|
|
333
|
+
ArrowLeft: [40, 0],
|
|
334
|
+
ArrowRight: [-40, 0],
|
|
335
|
+
ArrowUp: [0, 40],
|
|
336
|
+
ArrowDown: [0, -40],
|
|
337
|
+
};
|
|
338
|
+
const by = pan[event.key];
|
|
339
|
+
if (by && !locked) {
|
|
340
|
+
event.preventDefault();
|
|
341
|
+
setViewport({ ...viewport, x: viewport.x + by[0], y: viewport.y + by[1] });
|
|
342
|
+
}
|
|
343
|
+
else if (event.key === '+' || event.key === '=')
|
|
344
|
+
zoomBy(1.3);
|
|
345
|
+
else if (event.key === '-')
|
|
346
|
+
zoomBy(1 / 1.3);
|
|
347
|
+
else if (event.key === '0')
|
|
348
|
+
fit();
|
|
349
|
+
};
|
|
350
|
+
return (_jsx(FlowContext.Provider, { value: context, children: _jsxs("div", { ref: root, role: "application", "aria-label": label, "aria-roledescription": "Diagram", tabIndex: 0, "data-locked": locked ? '' : undefined, className: slots.root({ className }), onPointerDown: onPointerDown, onPointerMove: onPointerMove, onPointerUp: onPointerEnd, onPointerCancel: onPointerEnd, onKeyDown: keys, ...props, children: [_jsx("div", { ref: setUnderlay, "data-flow-pane": "", className: slots.underlay() }), _jsxs("div", { "data-flow-layer": "", className: slots.layer(), style: {
|
|
351
|
+
transform: `translate(${viewport.x.toFixed(2)}px, ${viewport.y.toFixed(2)}px) scale(${viewport.zoom.toFixed(4)})`,
|
|
352
|
+
}, children: [children, wire ? (_jsx("svg", { "aria-hidden": "true", className: flowVariants({ color: 'muted' }).edge(), children: _jsx("path", {
|
|
353
|
+
// It arrives facing back the way it left, which is the shape a
|
|
354
|
+
// wire has while it is still in the hand.
|
|
355
|
+
d: bezierPath(wire.from, wire.side, wire.to, OPPOSITE[wire.side], 0.25), fill: "none", stroke: "currentColor", strokeWidth: 2, strokeDasharray: "6 6", strokeLinecap: "round" }) })) : null] }), _jsx("div", { ref: setOverlay, className: slots.overlay() })] }) }));
|
|
356
|
+
}
|
|
357
|
+
FlowRoot.displayName = 'Flow';
|
|
358
|
+
/** The pattern behind the graph, which moves and scales with it. */
|
|
359
|
+
function FlowBackground({ variant = 'dots', gap = 24, size, className }) {
|
|
360
|
+
const flow = useFlow('Flow.Background');
|
|
361
|
+
const id = useId();
|
|
362
|
+
if (!flow.underlay || variant === 'none')
|
|
363
|
+
return null;
|
|
364
|
+
const { x, y, zoom } = flow.viewport;
|
|
365
|
+
const mark = size ?? (variant === 'dots' ? 1.2 : variant === 'lines' ? 1 : 3);
|
|
366
|
+
return createPortal(_jsxs("svg", { "aria-hidden": "true", className: className ?? 'size-full', children: [_jsxs("pattern", { id: id, width: gap, height: gap, patternUnits: "userSpaceOnUse", patternTransform: `translate(${x.toFixed(2)} ${y.toFixed(2)}) scale(${zoom.toFixed(4)})`, children: [variant === 'dots' ? _jsx("circle", { cx: gap / 2, cy: gap / 2, r: mark, fill: "currentColor" }) : null, variant === 'lines' ? (_jsx("path", { d: `M${gap} 0H0V${gap}`, fill: "none", stroke: "currentColor", strokeWidth: mark, opacity: "0.5" })) : null, variant === 'cross' ? (_jsx("path", { d: `M${gap / 2 - mark} ${gap / 2}h${mark * 2}M${gap / 2} ${gap / 2 - mark}v${mark * 2}`, stroke: "currentColor", strokeWidth: "1", strokeLinecap: "round" })) : null] }), _jsx("rect", { width: "100%", height: "100%", fill: `url(#${id})` })] }), flow.underlay);
|
|
367
|
+
}
|
|
368
|
+
FlowBackground.displayName = 'Flow.Background';
|
|
369
|
+
/** What a node and a group share: a box on the graph that can be moved. */
|
|
370
|
+
function useMovable({ id, position, draggable, onPress, onDelete, moveStep, }) {
|
|
371
|
+
const flow = useFlow('Flow.Node');
|
|
372
|
+
const element = useRef(null);
|
|
373
|
+
const { place, measure, forget } = flow;
|
|
374
|
+
const step = moveStep ?? 24;
|
|
375
|
+
useLayoutEffect(() => {
|
|
376
|
+
place(id, position);
|
|
377
|
+
// Only when the caller's position changes, not on every drawing.
|
|
378
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
379
|
+
}, [id, position.x, position.y, place]);
|
|
380
|
+
useLayoutEffect(() => {
|
|
381
|
+
const node = element.current;
|
|
382
|
+
if (!node)
|
|
383
|
+
return;
|
|
384
|
+
const read = () => measure(id, node.offsetWidth, node.offsetHeight);
|
|
385
|
+
read();
|
|
386
|
+
const observer = new ResizeObserver(read);
|
|
387
|
+
observer.observe(node);
|
|
388
|
+
return () => observer.disconnect();
|
|
389
|
+
}, [id, measure]);
|
|
390
|
+
useEffect(() => () => forget(id), [id, forget]);
|
|
391
|
+
const at = flow.rects[id] ?? { ...position, width: 0, height: 0 };
|
|
392
|
+
const drag = useRef(null);
|
|
393
|
+
const latest = useRef(at);
|
|
394
|
+
latest.current = at;
|
|
395
|
+
const onPointerDown = (event) => {
|
|
396
|
+
if (event.button !== 0)
|
|
397
|
+
return;
|
|
398
|
+
// A handle starts a connection, and a control inside a node is pressed.
|
|
399
|
+
if (event.target.closest('[data-flow-handle], button, a, input, textarea, select, [data-no-drag]')) {
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
// Kept from the pane, and from a group this node may be sitting in.
|
|
403
|
+
event.stopPropagation();
|
|
404
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
405
|
+
drag.current = { x: event.clientX, y: event.clientY, from: { x: at.x, y: at.y }, moved: false };
|
|
406
|
+
};
|
|
407
|
+
const onPointerMove = (event) => {
|
|
408
|
+
const state = drag.current;
|
|
409
|
+
if (!state)
|
|
410
|
+
return;
|
|
411
|
+
const dx = event.clientX - state.x;
|
|
412
|
+
const dy = event.clientY - state.y;
|
|
413
|
+
if (!state.moved && Math.hypot(dx, dy) < 3)
|
|
414
|
+
return;
|
|
415
|
+
state.moved = true;
|
|
416
|
+
if (draggable === false || flow.locked)
|
|
417
|
+
return;
|
|
418
|
+
place(id, { x: state.from.x + dx / flow.viewport.zoom, y: state.from.y + dy / flow.viewport.zoom });
|
|
419
|
+
};
|
|
420
|
+
const onPointerUp = () => {
|
|
421
|
+
const state = drag.current;
|
|
422
|
+
drag.current = null;
|
|
423
|
+
if (!state)
|
|
424
|
+
return;
|
|
425
|
+
if (!state.moved)
|
|
426
|
+
onPress?.();
|
|
427
|
+
else if (draggable !== false && !flow.locked) {
|
|
428
|
+
flow.onNodeDragEnd?.(id, { x: latest.current.x, y: latest.current.y });
|
|
429
|
+
}
|
|
430
|
+
};
|
|
431
|
+
const onKeyDown = (event) => {
|
|
432
|
+
if (event.target !== event.currentTarget)
|
|
433
|
+
return;
|
|
434
|
+
const moves = {
|
|
435
|
+
ArrowLeft: [-1, 0],
|
|
436
|
+
ArrowRight: [1, 0],
|
|
437
|
+
ArrowUp: [0, -1],
|
|
438
|
+
ArrowDown: [0, 1],
|
|
439
|
+
};
|
|
440
|
+
const by = moves[event.key];
|
|
441
|
+
if (by) {
|
|
442
|
+
// Kept from the canvas, which would pan on the same key.
|
|
443
|
+
event.stopPropagation();
|
|
444
|
+
if (draggable === false || flow.locked)
|
|
445
|
+
return;
|
|
446
|
+
event.preventDefault();
|
|
447
|
+
const far = step * (event.shiftKey ? 4 : 1);
|
|
448
|
+
const next = { x: at.x + by[0] * far, y: at.y + by[1] * far };
|
|
449
|
+
place(id, next);
|
|
450
|
+
flow.onNodeDragEnd?.(id, next);
|
|
451
|
+
}
|
|
452
|
+
else if (event.key === 'Enter' || event.key === ' ') {
|
|
453
|
+
if (!onPress)
|
|
454
|
+
return;
|
|
455
|
+
event.preventDefault();
|
|
456
|
+
onPress();
|
|
457
|
+
}
|
|
458
|
+
else if ((event.key === 'Delete' || event.key === 'Backspace') && onDelete) {
|
|
459
|
+
event.preventDefault();
|
|
460
|
+
onDelete();
|
|
461
|
+
}
|
|
462
|
+
};
|
|
463
|
+
return {
|
|
464
|
+
element,
|
|
465
|
+
style: { translate: `${at.x.toFixed(2)}px ${at.y.toFixed(2)}px` },
|
|
466
|
+
handlers: {
|
|
467
|
+
onPointerDown,
|
|
468
|
+
onPointerMove,
|
|
469
|
+
onPointerUp,
|
|
470
|
+
onPointerCancel: () => {
|
|
471
|
+
drag.current = null;
|
|
472
|
+
},
|
|
473
|
+
onKeyDown,
|
|
474
|
+
},
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
/** A box on the graph. Whatever is inside it is yours. */
|
|
478
|
+
function FlowNode({ id, position, draggable = true, selected = false, onPress, onDelete, moveStep, className, style, children, ...props }) {
|
|
479
|
+
const movable = useMovable({ id, position, draggable, onPress, onDelete, moveStep });
|
|
480
|
+
return (_jsx(NodeContext.Provider, { value: id, children: _jsx("div", { ref: movable.element, "data-flow-node": id, tabIndex: 0, "aria-roledescription": "Node", "aria-selected": selected || undefined, className: flowVariants({ selected }).node({ className }), style: { ...movable.style, ...style }, ...movable.handlers, ...props, children: children }) }));
|
|
481
|
+
}
|
|
482
|
+
FlowNode.displayName = 'Flow.Node';
|
|
483
|
+
/**
|
|
484
|
+
* A dashed box round a few things that belong together. It is a node like any
|
|
485
|
+
* other: it is dragged as one, and an edge can be joined to it.
|
|
486
|
+
*/
|
|
487
|
+
function FlowGroup({ id, position, draggable = true, selected: _selected, onPress, onDelete, moveStep, label, className, style, children, ...props }) {
|
|
488
|
+
const movable = useMovable({ id, position, draggable, onPress, onDelete, moveStep });
|
|
489
|
+
return (_jsx(NodeContext.Provider, { value: id, children: _jsxs("div", { ref: movable.element, "data-flow-node": id, tabIndex: 0, "aria-roledescription": "Group", className: flowVariants().group({ className }), style: { ...movable.style, ...style }, ...movable.handlers, ...props, children: [label !== undefined ? _jsx("span", { className: flowVariants().groupLabel(), children: label }) : null, children] }) }));
|
|
490
|
+
}
|
|
491
|
+
FlowGroup.displayName = 'Flow.Group';
|
|
492
|
+
const SIDE_STYLE = {
|
|
493
|
+
top: (offset) => ({ left: `${offset * 100}%`, top: '0%' }),
|
|
494
|
+
bottom: (offset) => ({ left: `${offset * 100}%`, top: '100%' }),
|
|
495
|
+
left: (offset) => ({ left: '0%', top: `${offset * 100}%` }),
|
|
496
|
+
right: (offset) => ({ left: '100%', top: `${offset * 100}%` }),
|
|
497
|
+
};
|
|
498
|
+
/** A point on a node's edge that a connection can be drawn from, or dropped on. */
|
|
499
|
+
function FlowHandle({ id, position = 'right', offset = 0.5, className, style, ...props }) {
|
|
500
|
+
const flow = useFlow('Flow.Handle');
|
|
501
|
+
const node = useContext(NodeContext);
|
|
502
|
+
if (node === null)
|
|
503
|
+
throw new Error('Flow.Handle must be used inside a <Flow.Node>.');
|
|
504
|
+
const { setHandle, connectFrom } = flow;
|
|
505
|
+
useEffect(() => {
|
|
506
|
+
const key = id ?? position;
|
|
507
|
+
setHandle(node, key, { id, side: position, offset });
|
|
508
|
+
return () => setHandle(node, key, null);
|
|
509
|
+
}, [node, id, position, offset, setHandle]);
|
|
510
|
+
return (_jsx("span", { "data-flow-handle": id ?? '', "aria-hidden": "true", className: flowVariants().handle({ className }), style: { ...SIDE_STYLE[position](offset), ...style }, onPointerDown: (event) => {
|
|
511
|
+
if (event.button !== 0)
|
|
512
|
+
return;
|
|
513
|
+
event.stopPropagation();
|
|
514
|
+
event.preventDefault();
|
|
515
|
+
connectFrom(node, { id, side: position, offset }, event);
|
|
516
|
+
}, ...props }));
|
|
517
|
+
}
|
|
518
|
+
FlowHandle.displayName = 'Flow.Handle';
|
|
519
|
+
const endpoint = (reference) => typeof reference === 'string' ? { node: reference } : reference;
|
|
520
|
+
/** A line from one node to another, which follows them as they move. */
|
|
521
|
+
function FlowEdge({ from, to, variant = 'bezier', animated = false, dashed = false, color, width = 2, arrow = false, fromSide, toSide, radius = 8, gap = 20, curvature = 0.25, }) {
|
|
522
|
+
const flow = useFlow('Flow.Edge');
|
|
523
|
+
// Whether motion is wanted is only known in the browser.
|
|
524
|
+
const [still, setStill] = useState(true);
|
|
525
|
+
useEffect(() => {
|
|
526
|
+
const query = window.matchMedia('(prefers-reduced-motion: reduce)');
|
|
527
|
+
const read = () => setStill(query.matches);
|
|
528
|
+
read();
|
|
529
|
+
query.addEventListener('change', read);
|
|
530
|
+
return () => query.removeEventListener('change', read);
|
|
531
|
+
}, []);
|
|
532
|
+
const source = endpoint(from);
|
|
533
|
+
const target = endpoint(to);
|
|
534
|
+
const a = flow.rects[source.node];
|
|
535
|
+
const b = flow.rects[target.node];
|
|
536
|
+
// Nothing to draw until both ends have been measured.
|
|
537
|
+
if (!a || !b || a.width === 0 || b.width === 0)
|
|
538
|
+
return null;
|
|
539
|
+
const named = (node, handle) => handle === undefined ? undefined : flow.handles[node]?.find((entry) => entry.id === handle);
|
|
540
|
+
const out = named(source.node, source.handle);
|
|
541
|
+
const into = named(target.node, target.handle);
|
|
542
|
+
const auto = autoSides(a, b);
|
|
543
|
+
const sideA = fromSide ?? out?.side ?? auto.from;
|
|
544
|
+
const sideB = toSide ?? into?.side ?? auto.to;
|
|
545
|
+
const start = anchorOf(a, sideA, out?.offset ?? 0.5);
|
|
546
|
+
const tip = anchorOf(b, sideB, into?.offset ?? 0.5);
|
|
547
|
+
// The line stops short of the tip when there is an arrowhead to finish it.
|
|
548
|
+
const head = arrow ? 9 : 0;
|
|
549
|
+
const direction = arrivalDirection(variant, start, tip, sideB);
|
|
550
|
+
const length = Math.hypot(direction.x, direction.y) || 1;
|
|
551
|
+
const end = { x: tip.x - (direction.x / length) * head * 0.6, y: tip.y - (direction.y / length) * head * 0.6 };
|
|
552
|
+
const d = edgePath(variant, start, sideA, end, sideB, curvature, radius, gap);
|
|
553
|
+
const dash = dashed || animated ? '6 6' : undefined;
|
|
554
|
+
return (_jsxs("svg", { "aria-hidden": "true", className: flowVariants({ color: color ?? 'muted' }).edge(), children: [_jsx("path", { d: d, fill: "none", stroke: "currentColor", strokeWidth: width, strokeDasharray: dash, strokeLinecap: "round", strokeLinejoin: "round", opacity: color ? 1 : 0.7, children: animated && !still ? (_jsx("animate", { attributeName: "stroke-dashoffset", from: "12", to: "0", dur: "0.6s", repeatCount: "indefinite" })) : null }), arrow ? (_jsx("path", { d: arrowHeadPath(tip, direction.x, direction.y, head), fill: "currentColor", opacity: color ? 1 : 0.7 })) : null] }));
|
|
555
|
+
}
|
|
556
|
+
FlowEdge.displayName = 'Flow.Edge';
|
|
557
|
+
const icon = (path) => (_jsx("svg", { "aria-hidden": "true", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: path }));
|
|
558
|
+
/** Buttons for what a pinch and a drag do, for anyone without either. */
|
|
559
|
+
function FlowControls({ zoom = true, fit = true, lock = true, step = 1.3, position = 'bottom-right', className, ...props }) {
|
|
560
|
+
const flow = useFlow('Flow.Controls');
|
|
561
|
+
const slots = flowVariants({ position });
|
|
562
|
+
if (!flow.overlay)
|
|
563
|
+
return null;
|
|
564
|
+
return createPortal(_jsxs("div", { role: "group", "aria-label": "Canvas controls", className: slots.controls({ className }), ...props, children: [zoom ? (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", "aria-label": "Zoom in", className: slots.control(), onClick: () => flow.zoomBy(step), children: icon(_jsx("path", { d: "M12 5v14M5 12h14" })) }), _jsx("button", { type: "button", "aria-label": "Zoom out", className: slots.control(), onClick: () => flow.zoomBy(1 / step), children: icon(_jsx("path", { d: "M5 12h14" })) })] })) : null, fit ? (_jsx("button", { type: "button", "aria-label": "Fit everything in view", className: slots.control(), onClick: flow.fit, children: icon(_jsx("path", { d: "M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5" })) })) : null, lock ? (_jsx("button", { type: "button", "aria-label": "Lock the canvas", "aria-pressed": flow.locked, className: slots.control(), onClick: () => flow.setLocked(!flow.locked), children: icon(_jsxs(_Fragment, { children: [_jsx("rect", { x: "5", y: "11", width: "14", height: "9", rx: "2" }), _jsx("path", { d: flow.locked ? 'M8 11V8a4 4 0 0 1 8 0v3' : 'M8 11V8a4 4 0 0 1 7.5-2' })] })) })) : null] }), flow.overlay);
|
|
565
|
+
}
|
|
566
|
+
FlowControls.displayName = 'Flow.Controls';
|
|
567
|
+
/** The whole graph, small, with the part in view marked on it. */
|
|
568
|
+
function FlowMiniMap({ width = 120, height = 80, position = 'bottom-left', className, style, ...props }) {
|
|
569
|
+
const flow = useFlow('Flow.MiniMap');
|
|
570
|
+
const slots = flowVariants({ position });
|
|
571
|
+
if (!flow.overlay)
|
|
572
|
+
return null;
|
|
573
|
+
const boxes = Object.values(flow.rects).filter((box) => box.width > 0);
|
|
574
|
+
const { x, y, zoom } = flow.viewport;
|
|
575
|
+
// What the canvas is showing, in the graph's own coordinates.
|
|
576
|
+
const view = { x: -x / zoom, y: -y / zoom, width: flow.size.width / zoom, height: flow.size.height / zoom };
|
|
577
|
+
const all = [...boxes, view];
|
|
578
|
+
const left = Math.min(...all.map((box) => box.x));
|
|
579
|
+
const top = Math.min(...all.map((box) => box.y));
|
|
580
|
+
const right = Math.max(...all.map((box) => box.x + box.width));
|
|
581
|
+
const bottom = Math.max(...all.map((box) => box.y + box.height));
|
|
582
|
+
const pad = 20;
|
|
583
|
+
return createPortal(_jsx("div", { "aria-hidden": "true", className: slots.minimap({ className }), style: { width, height, ...style }, ...props, children: _jsxs("svg", { viewBox: `${(left - pad).toFixed(1)} ${(top - pad).toFixed(1)} ${(right - left + pad * 2).toFixed(1)} ${(bottom - top + pad * 2).toFixed(1)}`, className: "size-full", children: [boxes.map((box, index) => (_jsx("rect", { x: box.x, y: box.y, width: box.width, height: box.height, rx: "10", className: "fill-muted-foreground/40" }, index))), _jsx("rect", { x: view.x, y: view.y, width: view.width, height: view.height, rx: "8", className: "fill-foreground/5 stroke-foreground/50", strokeWidth: "2", vectorEffect: "non-scaling-stroke" })] }) }), flow.overlay);
|
|
584
|
+
}
|
|
585
|
+
FlowMiniMap.displayName = 'Flow.MiniMap';
|
|
586
|
+
export const Flow = Object.assign(FlowRoot, {
|
|
587
|
+
Background: FlowBackground,
|
|
588
|
+
Node: FlowNode,
|
|
589
|
+
Handle: FlowHandle,
|
|
590
|
+
Edge: FlowEdge,
|
|
591
|
+
Group: FlowGroup,
|
|
592
|
+
Controls: FlowControls,
|
|
593
|
+
MiniMap: FlowMiniMap,
|
|
594
|
+
});
|
|
595
|
+
/* By name as well, for a Server Component, which cannot dot into a client
|
|
596
|
+
module. */
|
|
597
|
+
export { FlowBackground, FlowNode, FlowHandle, FlowEdge, FlowGroup, FlowControls, FlowMiniMap };
|