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.
Files changed (32) hide show
  1. package/README.md +43 -2
  2. package/dist/components/bar-picker/index.d.ts +61 -0
  3. package/dist/components/bar-picker/index.js +139 -0
  4. package/dist/components/command-dock/index.d.ts +118 -0
  5. package/dist/components/command-dock/index.js +239 -0
  6. package/dist/components/day-grid/index.d.ts +87 -0
  7. package/dist/components/day-grid/index.js +251 -0
  8. package/dist/components/feedback/index.d.ts +135 -0
  9. package/dist/components/feedback/index.js +283 -0
  10. package/dist/components/flow/flow-paths.d.ts +94 -0
  11. package/dist/components/flow/flow-paths.js +270 -0
  12. package/dist/components/flow/index.d.ts +235 -0
  13. package/dist/components/flow/index.js +597 -0
  14. package/dist/components/kpi/index.d.ts +211 -0
  15. package/dist/components/kpi/index.js +243 -0
  16. package/dist/components/lane-board/index.d.ts +125 -0
  17. package/dist/components/lane-board/index.js +691 -0
  18. package/dist/components/planet/index.d.ts +132 -0
  19. package/dist/components/planet/index.js +653 -0
  20. package/dist/components/planet/land.d.ts +8 -0
  21. package/dist/components/planet/land.js +122 -0
  22. package/dist/components/range-meter/index.d.ts +70 -0
  23. package/dist/components/range-meter/index.js +170 -0
  24. package/dist/components/rolling-number/index.d.ts +46 -0
  25. package/dist/components/rolling-number/index.js +81 -0
  26. package/dist/components/tilt-card/index.d.ts +60 -0
  27. package/dist/components/tilt-card/index.js +114 -0
  28. package/dist/index.d.ts +12 -0
  29. package/dist/index.js +12 -0
  30. package/dist/lib/cn.d.ts +3 -0
  31. package/dist/lib/cn.js +6 -0
  32. 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 };