astralyx-ui 0.6.1 → 0.7.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "astralyx-ui",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "type": "module",
5
5
  "description": "309 accessible React components you copy into your repo, with a CLI and registry that resolve what each one needs.",
6
6
  "license": "MIT",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "astralyx-ui",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "homepage": "https://ui.astralyx.dev",
5
5
  "items": [
6
6
  {
@@ -13,7 +13,7 @@
13
13
  {
14
14
  "path": "components/ui/node-canvas.tsx",
15
15
  "type": "registry:ui",
16
- "content": "import {\n useCallback,\n useEffect,\n useId,\n useRef,\n useState,\n type ComponentProps,\n type PointerEvent as ReactPointerEvent,\n type ReactNode,\n} from 'react'\nimport { focusRing, radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A pannable, zoomable canvas of draggable nodes and the edges between them.\n *\n * The substrate for anything graph-shaped: an agent pipeline, a retrieval\n * chain, a build DAG, a state machine. Nothing here knows what a node *means* —\n * `renderNode` draws whatever you like inside the box, and the canvas only owns\n * position, selection and the wires.\n *\n * **Why it is not a `<canvas>`.** Nodes are real DOM, so their contents stay\n * selectable, focusable and readable by a screen reader, and you can put a\n * Badge or a Switch inside one without reimplementing it in a 2D context. Only\n * the edges are drawn, in one SVG layer underneath.\n *\n * **Coordinates.** Nodes are positioned in graph space; the whole layer is\n * moved and scaled by a single `transform` on a wrapper, so panning and zooming\n * cost one style write rather than one per node. `toGraph` converts a pointer\n * position back through that transform — without it, dragging a node while\n * zoomed moves it by the wrong distance, which is the classic bug in a\n * hand-rolled canvas.\n *\n * **Pointer capture, not window listeners.** A drag that leaves the element\n * still needs its moves; capture routes them back without a global handler that\n * outlives the gesture and fires into an unmounted component.\n *\n * **Keyboard.** Every node is a tab stop, arrows nudge the focused node, and\n * shift-arrow nudges it by ten. A graph editor reachable only by mouse is not\n * an editor for everyone, and the arrow-key path is also the precise one.\n */\nexport type CanvasNode = {\n id: string\n x: number\n y: number\n /** Falls back to the canvas-level `nodeWidth`. */\n width?: number\n label?: ReactNode\n /** Anything: a kind, a status, your own payload. Passed back to renderNode. */\n data?: unknown\n /** Off for a terminal step that nothing may follow. Defaults to on. */\n connectable?: boolean\n /** Off to pin a node in place while the rest of the graph stays draggable. */\n draggable?: boolean\n /** Off for a node the graph cannot exist without — a trigger, an entry point. */\n deletable?: boolean\n}\n\nexport type CanvasEdge = {\n id: string\n from: string\n to: string\n label?: ReactNode\n /** Draws dashed and animated — for a path that is speculative or inactive. */\n dashed?: boolean\n}\n\ntype Point = { x: number; y: number }\n\ntype NodeCanvasProps = Omit<ComponentProps<'div'>, 'onSelect'> & {\n nodes: CanvasNode[]\n edges?: CanvasEdge[]\n /** Draws a node's contents. Defaults to its label. */\n renderNode?: (node: CanvasNode, state: { selected: boolean }) => ReactNode\n /** Omit to make the canvas read-only — nodes stay focusable but will not move. */\n onNodesChange?: (nodes: CanvasNode[]) => void\n selectedId?: string | null\n onSelect?: (id: string | null) => void\n /** Uniform node width in graph units. Individual nodes may override it. */\n nodeWidth?: number\n /** Background dot spacing in graph units. `0` turns the grid off. */\n grid?: number\n /** Clamp for the wheel zoom. */\n minZoom?: number\n maxZoom?: number\n /**\n * Wheel over the canvas zooms, and the page stays put.\n *\n * Off restores normal scrolling through the canvas — the right choice for a\n * read-only graph sitting in the middle of a long document, where trapping\n * the wheel is a trap rather than a feature.\n */\n zoomOnWheel?: boolean\n /** Start position and scale of the viewport. */\n defaultPan?: Point\n defaultZoom?: number\n /** Canvas height. Graphs need a stated box; they have no intrinsic one. */\n height?: number | string\n /** Accessible name for the graph region. */\n label?: string\n /** How far an arrow key moves a node, in graph units. */\n nudge?: number\n\n /**\n * Add a node at a point on the canvas — fired by a double-click on empty\n * space, in graph coordinates. Omit and double-click does nothing.\n */\n onAddNode?: (position: Point) => void\n /**\n * Something was dragged in from outside and dropped. `payload` is whatever\n * `NodePalette` (or your own drag source) put on the dataTransfer.\n */\n onDropNode?: (payload: string, position: Point) => void\n /**\n * Wire two nodes together, by dragging from a node's trailing port to\n * another node. Omit and the ports are not rendered.\n */\n onConnect?: (from: string, to: string) => void\n /** Remove a node — fired on Backspace/Delete with a node focused. */\n onRemoveNode?: (id: string) => void\n /**\n * The `+` on a node: create a new node already wired to this one, at a point\n * to its right. Building a chain is the common case by a wide margin, and\n * making it one click beats drag-a-node-then-drag-a-wire every time.\n */\n onAddConnected?: (fromId: string, position: Point) => void\n /** Accessible name for that button. Receives the source node's label. */\n addConnectedLabel?: string\n}\n\n/** The dataTransfer type `NodePalette` writes and the canvas reads. */\nexport const NODE_DRAG_TYPE = 'application/x-astralyx-node'\n\n/**\n * A cubic bezier with horizontal control arms.\n *\n * Arms scale with the horizontal gap so short hops stay tight and long ones\n * bow out, and never fall below a floor — two nodes stacked vertically have\n * almost no horizontal distance, and a straight line between their side ports\n * reads as a glitch rather than a connection.\n */\nfunction edgePath(from: Point, to: Point) {\n const arm = Math.max(40, Math.abs(to.x - from.x) * 0.5)\n return `M ${from.x} ${from.y} C ${from.x + arm} ${from.y}, ${to.x - arm} ${to.y}, ${to.x} ${to.y}`\n}\n\nfunction NodeCanvas({\n nodes,\n edges = [],\n renderNode,\n onNodesChange,\n selectedId,\n onSelect,\n nodeWidth = 180,\n grid = 24,\n minZoom = 0.35,\n maxZoom = 2.5,\n zoomOnWheel = true,\n defaultPan,\n defaultZoom = 1,\n height = 420,\n label = 'Node graph',\n nudge = 8,\n onAddNode,\n onDropNode,\n onConnect,\n onRemoveNode,\n onAddConnected,\n addConnectedLabel = 'Add a connected step',\n className,\n ...props\n}: NodeCanvasProps) {\n const viewportRef = useRef<HTMLDivElement>(null)\n const [pan, setPan] = useState<Point>(defaultPan ?? { x: 40, y: 40 })\n const [zoom, setZoom] = useState(defaultZoom)\n const patternId = useId()\n\n // Gesture state. A ref, not state: it changes on every pointermove and none\n // of it needs to paint on its own.\n const drag = useRef<\n | { kind: 'pan'; pointer: Point; origin: Point }\n | { kind: 'node'; id: string; pointer: Point; origin: Point }\n | null\n >(null)\n\n /**\n * Measured node heights, so an edge lands on the vertical centre of a box\n * whose contents you chose.\n *\n * State, not a ref: the edges are drawn during render from these numbers, and\n * a ref read at render time is both a compiler bail-out and a real bug — a\n * node that grows would keep its wire attached where it used to end.\n */\n const [heights, setHeights] = useState<Record<string, number>>({})\n\n // The wire being dragged out of a port, in graph units. State rather than a\n // ref because it paints on every move.\n const [wire, setWire] = useState<{ from: string; to: Point } | null>(null)\n\n /** Pointer position in graph units, undoing pan and zoom. */\n const toGraph = useCallback(\n (event: { clientX: number; clientY: number }): Point => {\n const box = viewportRef.current?.getBoundingClientRect()\n if (!box) return { x: 0, y: 0 }\n return {\n x: (event.clientX - box.left - pan.x) / zoom,\n y: (event.clientY - box.top - pan.y) / zoom,\n }\n },\n [pan.x, pan.y, zoom],\n )\n\n function moveNode(id: string, next: Point) {\n onNodesChange?.(\n nodes.map((node) => (node.id === id ? { ...node, x: next.x, y: next.y } : node)),\n )\n }\n\n function onPointerDownBackground(event: ReactPointerEvent<HTMLDivElement>) {\n // Only a primary press on the background pans; a press that started on a\n // node is that node's drag.\n if (event.button !== 0) return\n event.currentTarget.setPointerCapture(event.pointerId)\n drag.current = {\n kind: 'pan',\n pointer: { x: event.clientX, y: event.clientY },\n origin: { ...pan },\n }\n onSelect?.(null)\n }\n\n function onPointerMove(event: ReactPointerEvent<HTMLDivElement>) {\n if (wire) {\n setWire({ from: wire.from, to: toGraph(event) })\n return\n }\n\n const gesture = drag.current\n if (!gesture) return\n\n if (gesture.kind === 'pan') {\n setPan({\n x: gesture.origin.x + (event.clientX - gesture.pointer.x),\n y: gesture.origin.y + (event.clientY - gesture.pointer.y),\n })\n return\n }\n\n // Divided by zoom: at 0.5x, a 100px pointer move is 200 graph units.\n moveNode(gesture.id, {\n x: gesture.origin.x + (event.clientX - gesture.pointer.x) / zoom,\n y: gesture.origin.y + (event.clientY - gesture.pointer.y) / zoom,\n })\n }\n\n function endGesture(event: ReactPointerEvent<HTMLDivElement>) {\n if (event.currentTarget.hasPointerCapture(event.pointerId)) {\n event.currentTarget.releasePointerCapture(event.pointerId)\n }\n\n if (wire) {\n // Pointer capture means the event target is the viewport, not whatever is\n // under the cursor — so ask the document what is actually there.\n const under = document.elementFromPoint(event.clientX, event.clientY)\n const dropped = under?.closest<HTMLElement>('[data-node-id]')\n const target = dropped?.dataset.nodeId\n if (target && target !== wire.from) onConnect?.(wire.from, target)\n setWire(null)\n }\n\n drag.current = null\n }\n\n /** Zoom toward the pointer, so the point under the cursor stays put. */\n // Read inside the native listener below, so it can stay attached across\n // zooms instead of being torn down and rebuilt on every wheel tick.\n const view = useRef({ pan, zoom })\n view.current = { pan, zoom }\n\n /**\n * Zoom on wheel, and keep the page still while doing it.\n *\n * Attached natively rather than through `onWheel`, because React registers\n * its wheel listener at the root as **passive** — `preventDefault()` inside a\n * React `onWheel` handler does nothing but log a warning, and the page scrolls\n * away underneath the canvas you are trying to zoom. A non-passive listener on\n * the element itself is the only way to hold the page still.\n *\n * The zoom is anchored to the pointer: the graph point under the cursor is the\n * one that stays put, which is what makes zooming feel like moving a camera\n * rather than resizing a picture.\n */\n useEffect(() => {\n const node = viewportRef.current\n if (!node || !zoomOnWheel) return\n\n function onWheel(event: WheelEvent) {\n // A trackpad pinch arrives as ctrl+wheel; both mean zoom here, and both\n // must be stopped from reaching the page.\n event.preventDefault()\n\n const box = node!.getBoundingClientRect()\n const { pan: currentPan, zoom: currentZoom } = view.current\n\n const next = Math.min(\n maxZoom,\n Math.max(minZoom, currentZoom * (event.deltaY < 0 ? 1.08 : 1 / 1.08)),\n )\n if (next === currentZoom) return\n\n const px = event.clientX - box.left\n const py = event.clientY - box.top\n const ratio = next / currentZoom\n\n setPan({\n x: px - (px - currentPan.x) * ratio,\n y: py - (py - currentPan.y) * ratio,\n })\n setZoom(next)\n }\n\n node.addEventListener('wheel', onWheel, { passive: false })\n return () => node.removeEventListener('wheel', onWheel)\n }, [maxZoom, minZoom, zoomOnWheel])\n\n const widthOf = (node: CanvasNode) => node.width ?? nodeWidth\n // 56 is the height of a one-line node, used until the real one is measured.\n const heightOf = (node: CanvasNode) => heights[node.id] ?? 56\n\n const measure = useCallback((id: string, element: HTMLElement | null) => {\n if (!element) return\n const next = element.offsetHeight\n // Guarded, or every commit schedules another render with the same numbers.\n setHeights((current) => (current[id] === next ? current : { ...current, [id]: next }))\n }, [])\n\n /** Edges leave the right edge of the source and enter the left of the target. */\n function anchors(edge: CanvasEdge) {\n const from = nodes.find((node) => node.id === edge.from)\n const to = nodes.find((node) => node.id === edge.to)\n if (!from || !to) return null\n return {\n start: { x: from.x + widthOf(from), y: from.y + heightOf(from) / 2 },\n end: { x: to.x, y: to.y + heightOf(to) / 2 },\n }\n }\n\n return (\n <div\n data-slot=\"node-canvas\"\n className={cn('relative overflow-hidden', surface, radius.surface, className)}\n style={{ height }}\n {...props}\n >\n <div\n ref={viewportRef}\n role=\"application\"\n aria-label={label}\n className=\"absolute inset-0 cursor-grab touch-none active:cursor-grabbing\"\n onPointerDown={onPointerDownBackground}\n onPointerMove={onPointerMove}\n onPointerUp={endGesture}\n onPointerCancel={endGesture}\n onDoubleClick={(event) => {\n if (!onAddNode) return\n onAddNode(toGraph(event))\n }}\n // Both handlers are required: without `onDragOver` calling\n // preventDefault, the browser refuses the drop and `onDrop` never runs.\n onDragOver={(event) => {\n if (onDropNode) event.preventDefault()\n }}\n onDrop={(event) => {\n if (!onDropNode) return\n event.preventDefault()\n const payload = event.dataTransfer.getData(NODE_DRAG_TYPE)\n if (payload) onDropNode(payload, toGraph(event))\n }}\n >\n {grid > 0 && (\n <svg aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0 h-full w-full\">\n <defs>\n <pattern\n id={patternId}\n width={grid * zoom}\n height={grid * zoom}\n patternUnits=\"userSpaceOnUse\"\n // Offset by the pan so the grid travels with the graph rather\n // than sitting still under it.\n x={pan.x}\n y={pan.y}\n >\n <circle cx={1} cy={1} r={1} className=\"fill-border\" />\n </pattern>\n </defs>\n <rect width=\"100%\" height=\"100%\" fill={`url(#${patternId})`} />\n </svg>\n )}\n\n {/* Edges under nodes, in the same transformed space. `overflow-visible`\n matters: paths routinely run outside the SVG's own box. */}\n <svg\n aria-hidden=\"true\"\n className=\"pointer-events-none absolute inset-0 h-full w-full overflow-visible\"\n >\n <g style={{ transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})` }}>\n {edges.map((edge) => {\n const points = anchors(edge)\n if (!points) return null\n return (\n <path\n key={edge.id}\n d={edgePath(points.start, points.end)}\n fill=\"none\"\n strokeWidth={1.5}\n strokeDasharray={edge.dashed ? '4 4' : undefined}\n className=\"stroke-border\"\n />\n )\n })}\n\n {/* The wire in flight. Drawn from the source port to the pointer,\n so a connection you are making looks like the ones you made. */}\n {wire &&\n (() => {\n const from = nodes.find((node) => node.id === wire.from)\n if (!from) return null\n const start = {\n x: from.x + widthOf(from),\n y: from.y + heightOf(from) / 2,\n }\n return (\n <path\n d={edgePath(start, wire.to)}\n fill=\"none\"\n strokeWidth={1.5}\n strokeDasharray=\"4 4\"\n className=\"stroke-primary\"\n />\n )\n })()}\n </g>\n </svg>\n\n <div\n className=\"absolute top-0 left-0 origin-top-left\"\n style={{ transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})` }}\n >\n {nodes.map((node) => {\n const selected = node.id === selectedId\n return (\n <div\n key={node.id}\n ref={(element) => measure(node.id, element)}\n role=\"button\"\n tabIndex={0}\n aria-pressed={selected}\n data-selected={selected}\n data-node-id={node.id}\n style={{\n position: 'absolute',\n left: node.x,\n top: node.y,\n width: widthOf(node),\n }}\n className={cn(\n 'group bg-card border-border border p-3 text-start text-sm',\n radius.control,\n focusRing,\n onNodesChange && node.draggable !== false && 'cursor-grab active:cursor-grabbing',\n selected ? 'border-primary ring-ring/40 ring-2' : 'hover:border-foreground/25',\n )}\n onPointerDown={(event) => {\n if (event.button !== 0) return\n // The background would otherwise start a pan underneath.\n event.stopPropagation()\n onSelect?.(node.id)\n if (!onNodesChange || node.draggable === false) return\n event.currentTarget.setPointerCapture(event.pointerId)\n drag.current = {\n kind: 'node',\n id: node.id,\n pointer: { x: event.clientX, y: event.clientY },\n origin: { x: node.x, y: node.y },\n }\n }}\n onPointerMove={onPointerMove}\n onPointerUp={endGesture}\n onPointerCancel={endGesture}\n onKeyDown={(event) => {\n if (event.key === 'Enter' || event.key === ' ') {\n event.preventDefault()\n onSelect?.(node.id)\n return\n }\n if (\n onRemoveNode &&\n node.deletable !== false &&\n (event.key === 'Backspace' || event.key === 'Delete')\n ) {\n event.preventDefault()\n onRemoveNode(node.id)\n return\n }\n if (!onNodesChange) return\n\n const step = event.shiftKey ? nudge * 10 : nudge\n const delta =\n event.key === 'ArrowLeft'\n ? { x: -step, y: 0 }\n : event.key === 'ArrowRight'\n ? { x: step, y: 0 }\n : event.key === 'ArrowUp'\n ? { x: 0, y: -step }\n : event.key === 'ArrowDown'\n ? { x: 0, y: step }\n : null\n if (!delta) return\n\n event.preventDefault()\n moveNode(node.id, { x: node.x + delta.x, y: node.y + delta.y })\n }}\n >\n {renderNode ? renderNode(node, { selected }) : node.label}\n\n {onConnect && node.connectable !== false && (\n <>\n {/* Target. A plain div, not a button: it is a drop area for\n a pointer gesture, and the keyboard path to connecting\n belongs in the panel beside the canvas, not here. */}\n <span\n aria-hidden=\"true\"\n className={cn(\n 'bg-card border-border absolute top-1/2 -start-1.5 size-3 -translate-y-1/2 rounded-full border',\n wire && wire.from !== node.id && 'border-primary bg-primary/20',\n )}\n />\n <span\n aria-hidden=\"true\"\n title=\"Drag to connect\"\n className={cn(\n 'bg-card border-border absolute top-1/2 -end-1.5 size-3 -translate-y-1/2 cursor-crosshair rounded-full border',\n 'hover:border-primary hover:bg-primary/20',\n )}\n onPointerDown={(event) => {\n event.stopPropagation()\n event.preventDefault()\n // Capture on the viewport, so the wire keeps tracking\n // once the pointer leaves this 12px dot.\n viewportRef.current?.setPointerCapture(event.pointerId)\n setWire({ from: node.id, to: toGraph(event) })\n }}\n />\n\n {onAddConnected && (\n // A real button, so building a chain has a keyboard path\n // even though dragging a wire does not. Shown on hover,\n // focus, or while selected — a persistent one on every\n // node turns a busy graph into a field of plus signs.\n <button\n type=\"button\"\n aria-label={addConnectedLabel}\n className={cn(\n 'bg-card border-border text-muted-foreground absolute top-1/2 -end-8 flex size-5 -translate-y-1/2',\n 'items-center justify-center rounded-full border text-sm leading-none',\n 'hover:border-primary hover:text-foreground',\n 'opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100',\n 'motion-reduce:transition-none',\n selected && 'opacity-100',\n focusRing,\n )}\n onPointerDown={(event) => event.stopPropagation()}\n onClick={(event) => {\n event.stopPropagation()\n onAddConnected(node.id, {\n x: node.x + widthOf(node) + 80,\n y: node.y,\n })\n }}\n >\n +\n </button>\n )}\n </>\n )}\n </div>\n )\n })}\n </div>\n </div>\n </div>\n )\n}\n\n/**\n * The tray of things you can drag onto a canvas.\n *\n * Uses HTML drag-and-drop rather than the pointer gestures the canvas uses\n * internally, because this drag crosses element boundaries and may leave the\n * window entirely — that is exactly what the native API is for, and it gives\n * the drag image and the cursor affordances for free.\n *\n * Each item is a `<button>`, so the palette is not a mouse-only feature: the\n * canvas takes `onAddNode` for a double-click, and clicking a palette item\n * calls `onPick`, which is the keyboard path to the same result.\n */\nfunction NodePalette({\n items,\n onPick,\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'onSelect'> & {\n items: { id: string; label: ReactNode; hint?: ReactNode }[]\n /** Clicked rather than dragged — place it wherever your layout prefers. */\n onPick?: (id: string) => void\n}) {\n return (\n <div\n data-slot=\"node-palette\"\n className={cn('flex flex-col gap-1.5', className)}\n {...props}\n >\n {items.map((item) => (\n <button\n key={item.id}\n type=\"button\"\n draggable\n onDragStart={(event) => {\n event.dataTransfer.setData(NODE_DRAG_TYPE, item.id)\n event.dataTransfer.effectAllowed = 'copy'\n }}\n onClick={() => onPick?.(item.id)}\n className={cn(\n 'border-border bg-card hover:border-foreground/25 flex cursor-grab flex-col gap-0.5',\n 'border p-2.5 text-start active:cursor-grabbing',\n radius.control,\n focusRing,\n )}\n >\n <span className=\"text-sm font-medium\">{item.label}</span>\n {item.hint && (\n <span className=\"text-muted-foreground text-xs\">{item.hint}</span>\n )}\n </button>\n ))}\n </div>\n )\n}\n\nexport { NodeCanvas, NodePalette, edgePath }\nexport type { NodeCanvasProps }\n"
16
+ "content": "import {\n createContext,\n use,\n useCallback,\n useEffect,\n useId,\n useMemo,\n useRef,\n useState,\n type ComponentProps,\n type ComponentType,\n type PointerEvent as ReactPointerEvent,\n type ReactNode,\n} from 'react'\nimport { focusRing, radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A pannable, zoomable canvas of draggable nodes and the edges between them.\n *\n * The substrate for anything graph-shaped: an agent pipeline, a retrieval\n * chain, a build DAG, a state machine. Nothing here knows what a node *means* —\n * you register node types, and the canvas only owns position, selection and the\n * wires.\n *\n * **Why it is not a `<canvas>`.** Nodes are real DOM, so their contents stay\n * selectable, focusable and readable by a screen reader, and a node can be a\n * whole component — a form, a chart, a query builder — rather than a picture of\n * one. Only the edges are drawn, in one SVG layer underneath.\n *\n * **Event ownership.** A node's contents belong to whoever wrote them, so every\n * gesture here asks *what was actually hit* before acting on it. Dragging skips\n * controls and anything marked `data-nodrag`; the wheel skips a scrollable\n * region; keys are only the canvas's when the node itself has focus, not a\n * field inside it. Without those checks a text input inside a node cannot take\n * a space, a Backspace deletes the node instead of a character, and dragging to\n * select text moves the node — which is why this used to need a\n * `stopPropagation` wrapper that made the region undraggable in exchange.\n *\n * **A drag starts on movement, not on contact.** Nothing is captured until the\n * pointer travels `dragThreshold` pixels. A click stays a click, so buttons,\n * switches and links inside a node work, and the pointer capture that a slider\n * or a colour picker takes for itself is never stolen.\n *\n * **Coordinates.** Nodes are positioned in graph space; the whole layer is\n * moved and scaled by a single `transform` on a wrapper, so panning and zooming\n * cost one style write rather than one per node. `toGraph` converts a pointer\n * position back through that transform — without it, dragging a node while\n * zoomed moves it by the wrong distance, which is the classic bug in a\n * hand-rolled canvas.\n *\n * **Keyboard.** Every node is a tab stop, arrows nudge the focused node, and\n * shift-arrow nudges it by ten. Every edge carries a real button to detach it.\n * A graph editor reachable only by mouse is not an editor for everyone, and the\n * arrow-key path is also the precise one.\n */\nexport type CanvasNode = {\n id: string\n x: number\n y: number\n /** Falls back to the canvas-level `nodeWidth`. `'auto'` sizes to content. */\n width?: number | 'auto'\n /** Which registered `nodeTypes` entry draws this node. */\n type?: string\n label?: ReactNode\n /** Anything: a kind, a status, your own payload. Passed back to the renderer. */\n data?: unknown\n /** Off for a terminal step that nothing may follow. Defaults to on. */\n connectable?: boolean\n /** Off to pin a node in place while the rest of the graph stays draggable. */\n draggable?: boolean\n /** Off for a node the graph cannot exist without — a trigger, an entry point. */\n deletable?: boolean\n /** Stacking order. Selected and dragging nodes rise above it regardless. */\n z?: number\n}\n\nexport type CanvasEdge = {\n id: string\n from: string\n to: string\n label?: ReactNode\n /** Draws dashed — for a path that is speculative or inactive. */\n dashed?: boolean\n /** Off for a connection that must not be detached from the canvas. */\n deletable?: boolean\n}\n\ntype Point = { x: number; y: number }\ntype Size = { width: number; height: number }\n\n/** What a registered node type is rendered with. */\nexport type CanvasNodeProps = {\n node: CanvasNode\n selected: boolean\n /** True while this node is being dragged — for a lifted shadow, say. */\n dragging: boolean\n}\n\nexport type NodeTypes = Record<string, ComponentType<CanvasNodeProps>>\n\ntype NodeCanvasProps = Omit<ComponentProps<'div'>, 'onSelect'> & {\n nodes: CanvasNode[]\n edges?: CanvasEdge[]\n /**\n * The node components, by `node.type`.\n *\n * Write them as ordinary components — anything at all can go inside one, and\n * it can hold its own hooks and state — then refer to them by name from the\n * graph. A string type is what makes a graph serialisable: it survives\n * `JSON.stringify`, comes back from a server, and keys the palette too.\n *\n * Declare the map at module scope. A new object on every render re-registers\n * every type, and a component defined inline is a new component type each\n * time, which unmounts and remounts every node — losing focus mid-keystroke.\n */\n nodeTypes?: NodeTypes\n /** Draws any node without a registered type. Defaults to its label. */\n renderNode?: (node: CanvasNode, state: { selected: boolean }) => ReactNode\n /** Omit to make the canvas read-only — nodes stay focusable but will not move. */\n onNodesChange?: (nodes: CanvasNode[]) => void\n selectedId?: string | null\n onSelect?: (id: string | null) => void\n /** Uniform node width in graph units. Individual nodes may override it. */\n nodeWidth?: number\n /** Background dot spacing in graph units. `0` turns the grid off. */\n grid?: number\n /** Round positions to the grid while dragging and nudging. */\n snapToGrid?: boolean\n /** Clamp for the wheel zoom. */\n minZoom?: number\n maxZoom?: number\n /**\n * Wheel over the canvas zooms, and the page stays put.\n *\n * Off restores normal scrolling through the canvas — the right choice for a\n * read-only graph sitting in the middle of a long document, where trapping\n * the wheel is a trap rather than a feature.\n */\n zoomOnWheel?: boolean\n /** Start position and scale of the viewport. */\n defaultPan?: Point\n defaultZoom?: number\n /** Canvas height. Graphs need a stated box; they have no intrinsic one. */\n height?: number | string\n /** Accessible name for the graph region. */\n label?: string\n /** Names a node for a screen reader, when its contents are not text. */\n nodeLabel?: (node: CanvasNode) => string\n /** How far an arrow key moves a node, in graph units. */\n nudge?: number\n /** Pixels of travel before a press becomes a drag rather than a click. */\n dragThreshold?: number\n\n /**\n * Add a node at a point on the canvas — fired by a double-click on empty\n * space, in graph coordinates. Omit and double-click does nothing.\n */\n onAddNode?: (position: Point) => void\n /**\n * Something was dragged in from outside and dropped. `payload` is whatever\n * `NodePalette` (or your own drag source) put on the dataTransfer.\n */\n onDropNode?: (payload: string, position: Point) => void\n /**\n * Wire two nodes together, by dragging from a node's trailing port to\n * another node. Omit and the ports are not rendered.\n */\n onConnect?: (from: string, to: string) => void\n /** Whether a proposed connection is allowed. Self-links and duplicates are\n * already refused before this is asked. */\n isValidConnection?: (from: string, to: string) => boolean\n /** Remove a node — fired on Backspace/Delete with a node focused. */\n onRemoveNode?: (id: string) => void\n /**\n * Detach a connection — the button on a hovered or focused edge, and\n * Backspace/Delete while that button has focus. Omit and edges are inert.\n */\n onRemoveEdge?: (id: string) => void\n /**\n * The `+` on a node: create a new node already wired to this one, at a point\n * to its right. Building a chain is the common case by a wide margin, and\n * making it one click beats drag-a-node-then-drag-a-wire every time.\n */\n onAddConnected?: (fromId: string, position: Point) => void\n /** Accessible name for that button. Receives the source node's label. */\n addConnectedLabel?: string\n /** Accessible name for an edge's detach button. */\n removeEdgeLabel?: string\n /** Reports pan and zoom, for a caller that wants to persist the viewport. */\n onViewportChange?: (viewport: { pan: Point; zoom: number }) => void\n}\n\n/** The dataTransfer type `NodePalette` writes and the canvas reads. */\nexport const NODE_DRAG_TYPE = 'application/x-astralyx-node'\n\n/**\n * Things a press belongs to rather than to the canvas.\n *\n * A CSS selector rather than a prop on purpose: the element that must not drag\n * is arbitrary-depth JSX the canvas will never see, often inside a component\n * nobody here owns. The DOM is the one channel both sides share at the moment\n * a gesture starts, and `closest` gives containment for free — mark a wrapper\n * and everything inside it is covered.\n */\nconst INTERACTIVE = [\n 'a[href]',\n 'button',\n 'input',\n 'select',\n 'textarea',\n 'audio',\n 'video',\n 'iframe',\n 'label',\n 'summary',\n '[contenteditable=\"\"]',\n '[contenteditable=\"true\"]',\n '[role=\"button\"]',\n '[role=\"checkbox\"]',\n '[role=\"combobox\"]',\n '[role=\"listbox\"]',\n '[role=\"menuitem\"]',\n '[role=\"option\"]',\n '[role=\"radio\"]',\n '[role=\"slider\"]',\n '[role=\"switch\"]',\n '[role=\"tab\"]',\n '[role=\"textbox\"]',\n '[data-nodrag]',\n].join(',')\n\n/**\n * A cubic bezier with horizontal control arms.\n *\n * Arms scale with the horizontal gap so short hops stay tight and long ones\n * bow out, and never fall below a floor — two nodes stacked vertically have\n * almost no horizontal distance, and a straight line between their side ports\n * reads as a glitch rather than a connection.\n */\nfunction edgePath(from: Point, to: Point) {\n const arm = Math.max(40, Math.abs(to.x - from.x) * 0.5)\n return `M ${from.x} ${from.y} C ${from.x + arm} ${from.y}, ${to.x - arm} ${to.y}, ${to.x} ${to.y}`\n}\n\n/**\n * The middle of that curve.\n *\n * The control arms are horizontal mirrors of each other, so at t=0.5 they\n * cancel exactly and the midpoint is the plain average of the two ends — no\n * bezier evaluation needed.\n */\nfunction edgeMidpoint(from: Point, to: Point): Point {\n return { x: (from.x + to.x) / 2, y: (from.y + to.y) / 2 }\n}\n\n/** The nearest match at or above `target`, but never outside `boundary`. */\nfunction closestWithin(target: Element, selector: string, boundary: Element) {\n const hit = target.closest(selector)\n return hit && boundary.contains(hit) ? hit : null\n}\n\n/**\n * Whether a press on `target` should move the node rather than reach its\n * contents.\n *\n * Two policies, and the node picks which by what it renders. Mark an element\n * `data-drag-handle` — a title bar, a grip — and the policy inverts: nothing\n * drags except from inside that element. That is what a node whose whole\n * surface is a form needs, because there is no leftover pixel to grab.\n *\n * Presence, not a selector prop. A selector that matches nothing produces a\n * node that silently cannot be moved, and asking for the handle in props is\n * the prop-gymnastics this API exists to remove: the node already knows which\n * part of itself is the grip, and says so where it draws it.\n */\nfunction canDragFrom(target: Element, node: HTMLElement) {\n const handles = node.querySelectorAll('[data-drag-handle]')\n if (handles.length) {\n for (const handle of handles) if (handle.contains(target)) return true\n return false\n }\n return !closestWithin(target, INTERACTIVE, node)\n}\n\n/**\n * A region inside a node that scrolls, and so owns the wheel.\n *\n * Detected rather than declared, because a log inside a node scrolling the page\n * instead of itself is never what anyone wanted. `data-nowheel` is the manual\n * override for a region the browser has not made scrollable yet — an element\n * that will overflow once its content arrives.\n */\nfunction ownsWheel(target: Element, boundary: Element) {\n if (closestWithin(target, '[data-nowheel]', boundary)) return true\n\n for (let element: Element | null = target; element; element = element.parentElement) {\n if (element === boundary) return false\n if (!(element instanceof HTMLElement)) continue\n const style = getComputedStyle(element)\n const scrolls = /auto|scroll|overlay/\n if (\n (scrolls.test(style.overflowY) && element.scrollHeight > element.clientHeight) ||\n (scrolls.test(style.overflowX) && element.scrollWidth > element.clientWidth)\n ) {\n return true\n }\n }\n return false\n}\n\n/* ------------------------------------------------------------------ context */\n\ntype CanvasContextValue = {\n node: CanvasNode\n selected: boolean\n dragging: boolean\n /** Patch this node in place — its position, or its own `data`. */\n update: (patch: Partial<CanvasNode>) => void\n /** Remove it, if the canvas was given a way to. */\n remove: () => void\n /** Wire it to another node. */\n connect: (toId: string) => void\n}\n\nconst CanvasNodeContext = createContext<CanvasContextValue | null>(null)\n\n/**\n * The node a component is being rendered inside.\n *\n * A registered node type is handed its node as props, but anything nested\n * deeper — a field three components down — would otherwise have to be passed a\n * callback from the top. This is the way back up.\n */\nexport function useCanvasNode() {\n const context = use(CanvasNodeContext)\n if (!context) throw new Error('Must be used inside a NodeCanvas node')\n return context\n}\n\n/* ------------------------------------------------------------------- canvas */\n\nfunction NodeCanvas({\n nodes,\n edges = [],\n nodeTypes,\n renderNode,\n onNodesChange,\n selectedId,\n onSelect,\n nodeWidth = 180,\n grid = 24,\n snapToGrid = false,\n minZoom = 0.35,\n maxZoom = 2.5,\n zoomOnWheel = true,\n defaultPan,\n defaultZoom = 1,\n height = 420,\n label = 'Node graph',\n nodeLabel,\n nudge = 8,\n dragThreshold = 4,\n onAddNode,\n onDropNode,\n onConnect,\n isValidConnection,\n onRemoveNode,\n onRemoveEdge,\n onAddConnected,\n addConnectedLabel = 'Add a connected step',\n removeEdgeLabel = 'Detach this connection',\n onViewportChange,\n className,\n ...props\n}: NodeCanvasProps) {\n const viewportRef = useRef<HTMLDivElement>(null)\n const [pan, setPan] = useState<Point>(defaultPan ?? { x: 40, y: 40 })\n const [zoom, setZoom] = useState(defaultZoom)\n const patternId = useId()\n\n // Gesture state. A ref, not state: it changes on every pointermove and none\n // of it needs to paint on its own. `started` is what separates a click from a\n // drag — nothing moves, and nothing is captured, until the pointer travels.\n const drag = useRef<\n | { kind: 'pan'; pointerId: number; pointer: Point; origin: Point; started: boolean }\n | {\n kind: 'node'\n pointerId: number\n id: string\n pointer: Point\n origin: Point\n started: boolean\n }\n | null\n >(null)\n\n /** Which node is being dragged, for the node's own `dragging` state. */\n const [draggingId, setDraggingId] = useState<string | null>(null)\n\n /**\n * Measured node sizes, so an edge lands on the vertical centre of a box whose\n * contents you chose.\n *\n * State, not a ref: the edges are drawn during render from these numbers, and\n * a ref read at render time is both a compiler bail-out and a real bug — a\n * node that grows would keep its wire attached where it used to end.\n */\n const [sizes, setSizes] = useState<Record<string, Size>>({})\n\n // The wire being dragged out of a port, in graph units. State rather than a\n // ref because it paints on every move.\n const [wire, setWire] = useState<{ from: string; to: Point } | null>(null)\n const [activeEdge, setActiveEdge] = useState<string | null>(null)\n\n /**\n * One observer for every node.\n *\n * A ref callback fires when an element mounts, which the old measurement\n * relied on — but not when its contents grow later, so a node holding a\n * textarea that autosizes, or a section that expands, kept its wires attached\n * where the box used to end. Created lazily inside the callback because\n * `ResizeObserver` does not exist while rendering on the server.\n */\n const observer = useRef<ResizeObserver | null>(null)\n\n const measure = useCallback((id: string, element: HTMLElement | null) => {\n if (!element) return undefined\n\n observer.current ??= new ResizeObserver((entries) => {\n setSizes((current) => {\n let next = current\n for (const entry of entries) {\n const target = entry.target as HTMLElement\n const key = target.dataset.nodeId\n if (!key) continue\n // Border-box size, not `getBoundingClientRect`: the layer is scaled,\n // and a rect would report painted pixels where graph units are wanted.\n const box = entry.borderBoxSize?.[0]\n const width = box ? box.inlineSize : target.offsetWidth\n const height = box ? box.blockSize : target.offsetHeight\n const previous = next[key]\n if (previous && previous.width === width && previous.height === height) continue\n if (next === current) next = { ...current }\n next[key] = { width, height }\n }\n return next\n })\n })\n\n const active = observer.current\n active.observe(element)\n\n // Ref cleanup, so a removed node takes its measurement with it rather than\n // leaving the record to grow across a long editing session. This only runs\n // when the node actually goes: the callback is memoised per node id, so a\n // re-render does not detach and re-attach it.\n return () => {\n active.unobserve(element)\n setSizes((current) => {\n if (!(id in current)) return current\n const next = { ...current }\n delete next[id]\n return next\n })\n }\n }, [])\n\n useEffect(() => {\n const active = observer\n return () => {\n active.current?.disconnect()\n active.current = null\n }\n }, [])\n\n /** Pointer position in graph units, undoing pan and zoom. */\n const toGraph = useCallback(\n (event: { clientX: number; clientY: number }): Point => {\n const box = viewportRef.current?.getBoundingClientRect()\n if (!box) return { x: 0, y: 0 }\n return {\n x: (event.clientX - box.left - pan.x) / zoom,\n y: (event.clientY - box.top - pan.y) / zoom,\n }\n },\n [pan.x, pan.y, zoom],\n )\n\n const widthOf = useCallback(\n (node: CanvasNode) => {\n if (node.width === 'auto') return sizes[node.id]?.width ?? nodeWidth\n return node.width ?? nodeWidth\n },\n [nodeWidth, sizes],\n )\n // 56 is the height of a one-line node, used until the real one is measured.\n const heightOf = useCallback((node: CanvasNode) => sizes[node.id]?.height ?? 56, [sizes])\n\n const snap = useCallback(\n (value: number) => (snapToGrid && grid > 0 ? Math.round(value / grid) * grid : value),\n [grid, snapToGrid],\n )\n\n /** Every position the canvas hands out obeys the grid it draws. */\n const snapPoint = useCallback((point: Point) => ({ x: snap(point.x), y: snap(point.y) }), [snap])\n\n const moveNode = useCallback(\n (id: string, next: Point) => {\n onNodesChange?.(\n nodes.map((node) =>\n node.id === id ? { ...node, x: snap(next.x), y: snap(next.y) } : node,\n ),\n )\n },\n [nodes, onNodesChange, snap],\n )\n\n const patchNode = useCallback(\n (id: string, patch: Partial<CanvasNode>) => {\n onNodesChange?.(nodes.map((node) => (node.id === id ? { ...node, ...patch } : node)))\n },\n [nodes, onNodesChange],\n )\n\n /** Refuse the connections that are never meant, before asking the caller. */\n const requestConnect = useCallback(\n (from: string, to: string) => {\n if (!onConnect || from === to) return\n const target = nodes.find((node) => node.id === to)\n if (!target || target.connectable === false) return\n if (edges.some((edge) => edge.from === from && edge.to === to)) return\n if (isValidConnection && !isValidConnection(from, to)) return\n onConnect(from, to)\n },\n [edges, isValidConnection, nodes, onConnect],\n )\n\n /* ------------------------------------------------------------- gestures */\n\n function onPointerDownBackground(event: ReactPointerEvent<HTMLDivElement>) {\n // Primary or middle button pans; middle is the convention every canvas\n // shares, and it works while a node is under the cursor too.\n if (event.button !== 0 && event.button !== 1) return\n // A second finger, or a second button, must not take over the gesture\n // already in flight — the first one still owns it until it lifts.\n if (drag.current) return\n drag.current = {\n kind: 'pan',\n pointerId: event.pointerId,\n pointer: { x: event.clientX, y: event.clientY },\n origin: { ...pan },\n started: false,\n }\n }\n\n function onPointerMove(event: ReactPointerEvent<HTMLDivElement>) {\n if (wire) {\n setWire({ from: wire.from, to: toGraph(event) })\n return\n }\n\n const gesture = drag.current\n if (!gesture || gesture.pointerId !== event.pointerId) return\n\n // A gesture whose pointerup was eaten — by a native menu, an alt-tab, a\n // right-click mid-drag — would otherwise leave the node stuck to the cursor.\n if (event.buttons === 0) {\n endGesture(event)\n return\n }\n\n const travel = Math.hypot(\n event.clientX - gesture.pointer.x,\n event.clientY - gesture.pointer.y,\n )\n if (!gesture.started) {\n if (travel < dragThreshold) return\n gesture.started = true\n // Captured only now. Taking it on contact is what used to retarget every\n // click to the node, killing buttons and switches inside it — and it stole\n // the capture that a slider or a colour picker takes for its own drag.\n event.currentTarget.setPointerCapture(event.pointerId)\n // Text selection is suppressed here rather than by preventing the\n // pointerdown's default, which would have been simpler and also silently\n // cancels the compatibility mouse events — taking click and dblclick with\n // it, so double-clicking the canvas to add a node would stop working. Any\n // sliver selected before the threshold is cleared with it.\n document.getSelection()?.removeAllRanges()\n event.currentTarget.style.userSelect = 'none'\n if (gesture.kind === 'node') setDraggingId(gesture.id)\n }\n\n if (gesture.kind === 'pan') {\n setPan({\n x: gesture.origin.x + (event.clientX - gesture.pointer.x),\n y: gesture.origin.y + (event.clientY - gesture.pointer.y),\n })\n return\n }\n\n // Divided by zoom: at 0.5x, a 100px pointer move is 200 graph units. The\n // delta is measured from the position at press, never accumulated frame to\n // frame, so snapping cannot make the node drift away from the cursor.\n moveNode(gesture.id, {\n x: gesture.origin.x + (event.clientX - gesture.pointer.x) / zoom,\n y: gesture.origin.y + (event.clientY - gesture.pointer.y) / zoom,\n })\n }\n\n function endGesture(event: ReactPointerEvent<HTMLDivElement>) {\n if (event.currentTarget.hasPointerCapture(event.pointerId)) {\n event.currentTarget.releasePointerCapture(event.pointerId)\n }\n event.currentTarget.style.userSelect = ''\n\n // A drag that ends over something clickable still produces a click. Eat\n // exactly one, in the capture phase, so releasing a node on top of a button\n // does not press it — and drop the listener on the next turn if no click\n // arrives, rather than leaving it armed for the user's next real one.\n if (drag.current?.started) {\n const viewport = event.currentTarget\n const suppress = (click: MouseEvent) => {\n click.preventDefault()\n click.stopPropagation()\n }\n viewport.addEventListener('click', suppress, { capture: true, once: true })\n window.setTimeout(() => viewport.removeEventListener('click', suppress, true), 0)\n }\n\n if (wire) {\n // Pointer capture means the event target is the viewport, not whatever is\n // under the cursor — so ask the document what is actually there. Dropping\n // anywhere on a node counts, not just on its 12px port.\n const under = document.elementFromPoint(event.clientX, event.clientY)\n const dropped = under?.closest<HTMLElement>('[data-node-id]')\n const target = dropped?.dataset.nodeId\n if (target) requestConnect(wire.from, target)\n setWire(null)\n }\n\n const gesture = drag.current\n // A press on the background that never became a drag is a click, and a\n // click on nothing clears the selection. Panning must not.\n if (gesture?.kind === 'pan' && !gesture.started) onSelect?.(null)\n\n drag.current = null\n setDraggingId(null)\n }\n\n /**\n * A gesture the window loses — alt-tab mid-drag, a native menu, a dialog\n * stealing focus — never gets its pointerup, and the node would stay stuck to\n * the cursor on the next hover.\n */\n useEffect(() => {\n function release() {\n drag.current = null\n if (viewportRef.current) viewportRef.current.style.userSelect = ''\n setDraggingId(null)\n setWire(null)\n }\n window.addEventListener('blur', release)\n return () => window.removeEventListener('blur', release)\n }, [])\n\n /** Zoom toward the pointer, so the point under the cursor stays put. */\n // Read inside the native listener below, so it can stay attached across\n // zooms instead of being torn down and rebuilt on every wheel tick. Written\n // from an effect rather than during render, which is not a safe place to\n // mutate a ref.\n const view = useRef({ pan, zoom })\n const report = useRef(onViewportChange)\n useEffect(() => {\n view.current = { pan, zoom }\n report.current = onViewportChange\n })\n\n /**\n * Zoom on wheel, and keep the page still while doing it.\n *\n * Attached natively rather than through `onWheel`, because React registers\n * its wheel listener at the root as **passive** — `preventDefault()` inside a\n * React `onWheel` handler does nothing but log a warning, and the page scrolls\n * away underneath the canvas you are trying to zoom. A non-passive listener on\n * the element itself is the only way to hold the page still.\n *\n * The zoom is anchored to the pointer: the graph point under the cursor is the\n * one that stays put, which is what makes zooming feel like moving a camera\n * rather than resizing a picture.\n */\n useEffect(() => {\n const node = viewportRef.current\n if (!node || !zoomOnWheel) return\n\n function onWheel(event: WheelEvent) {\n // A scrollable region inside a node keeps its own wheel. Checked before\n // anything else, including preventDefault, or the region is frozen.\n const target = event.target\n if (target instanceof Element && ownsWheel(target, node!)) return\n\n // A trackpad pinch arrives as ctrl+wheel; both mean zoom here, and both\n // must be stopped from reaching the page.\n event.preventDefault()\n\n const box = node!.getBoundingClientRect()\n const { pan: currentPan, zoom: currentZoom } = view.current\n\n // Scaled by how far the wheel actually turned. A fixed step per event\n // sends a trackpad — which emits dozens of small inertial events — straight\n // to the clamp, while a notched mouse crawls. Line and page deltas are\n // converted to something pixel-like first.\n const unit = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? box.height : 1\n const delta = event.deltaY * unit\n const next = Math.min(\n maxZoom,\n Math.max(minZoom, currentZoom * Math.exp(-delta * 0.002)),\n )\n if (next === currentZoom) return\n\n const px = event.clientX - box.left\n const py = event.clientY - box.top\n const ratio = next / currentZoom\n\n setPan({\n x: px - (px - currentPan.x) * ratio,\n y: py - (py - currentPan.y) * ratio,\n })\n setZoom(next)\n }\n\n node.addEventListener('wheel', onWheel, { passive: false })\n return () => node.removeEventListener('wheel', onWheel)\n }, [maxZoom, minZoom, zoomOnWheel])\n\n // Reported from an effect rather than from the gesture handlers, so a caller\n // hears about every change, and hears about it once.\n useEffect(() => {\n report.current?.({ pan, zoom })\n }, [pan, zoom])\n\n /** Edges leave the right edge of the source and enter the left of the target. */\n const anchors = useCallback(\n (edge: CanvasEdge) => {\n const from = nodes.find((node) => node.id === edge.from)\n const to = nodes.find((node) => node.id === edge.to)\n if (!from || !to) return null\n return {\n start: { x: from.x + widthOf(from), y: from.y + heightOf(from) / 2 },\n end: { x: to.x, y: to.y + heightOf(to) / 2 },\n }\n },\n [heightOf, nodes, widthOf],\n )\n\n const transform = `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`\n\n return (\n <div\n data-slot=\"node-canvas\"\n className={cn('relative overflow-hidden', surface, radius.surface, className)}\n style={{ height }}\n {...props}\n >\n <div\n ref={viewportRef}\n role=\"application\"\n aria-label={label}\n className=\"absolute inset-0 cursor-grab touch-none active:cursor-grabbing\"\n onPointerDown={onPointerDownBackground}\n onPointerMove={onPointerMove}\n onPointerUp={endGesture}\n onPointerCancel={endGesture}\n onDoubleClick={(event) => {\n if (!onAddNode) return\n // A double-click inside a node is a word being selected, or a control\n // being used. Only empty canvas asks for a new node.\n if (event.target instanceof Element && event.target.closest('[data-node-id]')) return\n onAddNode(snapPoint(toGraph(event)))\n }}\n // Both handlers are required: without `onDragOver` calling\n // preventDefault, the browser refuses the drop and `onDrop` never runs.\n // Only our own payload is claimed, so a file or a text selection dropped\n // into a field inside a node still reaches it.\n onDragOver={(event) => {\n if (onDropNode && event.dataTransfer.types.includes(NODE_DRAG_TYPE)) {\n event.preventDefault()\n }\n }}\n onDrop={(event) => {\n if (!onDropNode) return\n const payload = event.dataTransfer.getData(NODE_DRAG_TYPE)\n if (!payload) return\n event.preventDefault()\n onDropNode(payload, snapPoint(toGraph(event)))\n }}\n >\n {grid > 0 && (\n <svg aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0 h-full w-full\">\n <defs>\n <pattern\n id={patternId}\n width={grid * zoom}\n height={grid * zoom}\n patternUnits=\"userSpaceOnUse\"\n // Offset by the pan so the grid travels with the graph rather\n // than sitting still under it.\n x={pan.x}\n y={pan.y}\n >\n <circle cx={1} cy={1} r={1} className=\"fill-border\" />\n </pattern>\n </defs>\n <rect width=\"100%\" height=\"100%\" fill={`url(#${patternId})`} />\n </svg>\n )}\n\n {/* Edges under nodes, in the same transformed space. `overflow-visible`\n matters: paths routinely run outside the SVG's own box. The layer\n takes no pointer events; each path opts back in, so the canvas\n behind them still pans. */}\n <svg\n aria-hidden=\"true\"\n className=\"pointer-events-none absolute inset-0 h-full w-full overflow-visible\"\n >\n <g style={{ transform }}>\n {edges.map((edge) => {\n const points = anchors(edge)\n if (!points) return null\n const active = activeEdge === edge.id\n return (\n <g key={edge.id}>\n {/* A 16-unit transparent stroke under the visible one: a\n 1.5px curve is a target nobody can hit. */}\n {onRemoveEdge && (\n <path\n d={edgePath(points.start, points.end)}\n fill=\"none\"\n strokeWidth={16}\n stroke=\"transparent\"\n className=\"pointer-events-stroke cursor-pointer\"\n onPointerEnter={() => setActiveEdge(edge.id)}\n onPointerLeave={() =>\n setActiveEdge((current) => (current === edge.id ? null : current))\n }\n />\n )}\n <path\n d={edgePath(points.start, points.end)}\n fill=\"none\"\n strokeWidth={active ? 2 : 1.5}\n strokeDasharray={edge.dashed ? '4 4' : undefined}\n className={cn(\n 'pointer-events-none transition-[stroke] duration-150 ease-out',\n 'motion-reduce:transition-none',\n active ? 'stroke-primary' : 'stroke-border',\n )}\n />\n </g>\n )\n })}\n\n {/* The wire in flight. Drawn from the source port to the pointer,\n so a connection you are making looks like the ones you made. */}\n {wire &&\n (() => {\n const from = nodes.find((node) => node.id === wire.from)\n if (!from) return null\n const start = {\n x: from.x + widthOf(from),\n y: from.y + heightOf(from) / 2,\n }\n return (\n <path\n d={edgePath(start, wire.to)}\n fill=\"none\"\n strokeWidth={1.5}\n strokeDasharray=\"4 4\"\n className=\"stroke-primary\"\n />\n )\n })()}\n </g>\n </svg>\n\n <div className=\"absolute top-0 left-0 origin-top-left\" style={{ transform }}>\n {nodes.map((node) => (\n <CanvasNodeView\n key={node.id}\n node={node}\n nodeTypes={nodeTypes}\n renderNode={renderNode}\n selected={node.id === selectedId}\n dragging={draggingId === node.id}\n editable={Boolean(onNodesChange)}\n width={widthOf(node)}\n nudge={nudge}\n nodeLabel={nodeLabel}\n addConnectedLabel={addConnectedLabel}\n snapPoint={snapPoint}\n measure={measure}\n onSelect={onSelect}\n onRemoveNode={onRemoveNode}\n onAddConnected={onAddConnected}\n connectable={Boolean(onConnect) && node.connectable !== false}\n wiring={Boolean(wire) && wire?.from !== node.id}\n patchNode={patchNode}\n moveNode={moveNode}\n requestConnect={requestConnect}\n onStartWire={(event) => {\n viewportRef.current?.setPointerCapture(event.pointerId)\n setWire({ from: node.id, to: toGraph(event) })\n }}\n onStartDrag={(event, element) => {\n // A second finger, or a second button, must not take over the\n // gesture already in flight.\n if (drag.current) return\n drag.current = {\n kind: 'node',\n pointerId: event.pointerId,\n id: node.id,\n pointer: { x: event.clientX, y: event.clientY },\n origin: { x: node.x, y: node.y },\n started: false,\n }\n element.focus()\n }}\n />\n ))}\n\n {/* Detach buttons ride above the nodes, in graph space so they travel\n and scale with the edge they belong to. Real buttons, so a\n connection can be removed without a pointer at all. */}\n {onRemoveEdge &&\n edges.map((edge) => {\n const points = anchors(edge)\n if (!points || edge.deletable === false) return null\n const middle = edgeMidpoint(points.start, points.end)\n return (\n <button\n key={edge.id}\n type=\"button\"\n aria-label={removeEdgeLabel}\n data-edge-id={edge.id}\n style={{ position: 'absolute', left: middle.x, top: middle.y }}\n className={cn(\n 'bg-card border-border text-muted-foreground flex size-5 -translate-x-1/2 -translate-y-1/2',\n 'items-center justify-center rounded-full border text-xs leading-none',\n 'hover:border-primary hover:text-foreground',\n 'transition-opacity duration-150 ease-out motion-reduce:transition-none',\n activeEdge === edge.id ? 'opacity-100' : 'opacity-0 focus-visible:opacity-100',\n focusRing,\n )}\n onPointerEnter={() => setActiveEdge(edge.id)}\n onPointerLeave={() =>\n setActiveEdge((current) => (current === edge.id ? null : current))\n }\n onFocus={() => setActiveEdge(edge.id)}\n onBlur={() =>\n setActiveEdge((current) => (current === edge.id ? null : current))\n }\n onPointerDown={(event) => event.stopPropagation()}\n onKeyDown={(event) => {\n if (event.key !== 'Backspace' && event.key !== 'Delete') return\n event.preventDefault()\n onRemoveEdge(edge.id)\n }}\n onClick={(event) => {\n event.stopPropagation()\n onRemoveEdge(edge.id)\n }}\n >\n ×\n </button>\n )\n })}\n </div>\n </div>\n </div>\n )\n}\n\n/* --------------------------------------------------------------------- node */\n\ntype CanvasNodeViewProps = {\n node: CanvasNode\n nodeTypes: NodeTypes | undefined\n renderNode: NodeCanvasProps['renderNode']\n selected: boolean\n dragging: boolean\n editable: boolean\n width: number\n nudge: number\n nodeLabel: ((node: CanvasNode) => string) | undefined\n addConnectedLabel: string\n snapPoint: (point: Point) => Point\n connectable: boolean\n wiring: boolean\n measure: (id: string, element: HTMLElement | null) => (() => void) | undefined\n onSelect: ((id: string | null) => void) | undefined\n onRemoveNode: ((id: string) => void) | undefined\n onAddConnected: ((fromId: string, position: Point) => void) | undefined\n patchNode: (id: string, patch: Partial<CanvasNode>) => void\n moveNode: (id: string, next: Point) => void\n requestConnect: (from: string, to: string) => void\n onStartWire: (event: ReactPointerEvent<HTMLElement>) => void\n onStartDrag: (event: ReactPointerEvent<HTMLElement>, element: HTMLElement) => void\n}\n\n/**\n * One node.\n *\n * At module scope, not nested inside `NodeCanvas`: a component declared during\n * render is a new component type on every render, so React would unmount and\n * remount every node's contents each time the canvas pans — losing focus\n * mid-keystroke and resetting anything uncontrolled inside.\n */\nfunction CanvasNodeView({\n node,\n nodeTypes,\n renderNode,\n selected,\n dragging,\n editable,\n width,\n nudge,\n nodeLabel,\n addConnectedLabel,\n snapPoint,\n connectable,\n wiring,\n measure,\n onSelect,\n onRemoveNode,\n onAddConnected,\n patchNode,\n moveNode,\n requestConnect,\n onStartWire,\n onStartDrag,\n}: CanvasNodeViewProps) {\n const Registered = node.type ? nodeTypes?.[node.type] : undefined\n const draggable = editable && node.draggable !== false\n\n // Memoised, so React does not tear the observer down and set it up again on\n // every render — which, with a cleanup attached, would also drop and re-take\n // the measurement each time.\n const attach = useCallback(\n (element: HTMLDivElement | null) => measure(node.id, element),\n [measure, node.id],\n )\n\n const context = useMemo<CanvasContextValue>(\n () => ({\n node,\n selected,\n dragging,\n update: (patch) => patchNode(node.id, patch),\n remove: () => onRemoveNode?.(node.id),\n connect: (toId) => requestConnect(node.id, toId),\n }),\n [dragging, node, onRemoveNode, patchNode, requestConnect, selected],\n )\n\n return (\n <div\n ref={attach}\n // Not `role=\"button\"`. A button's contents are presentational to a screen\n // reader, so every field inside a node vanished from the accessibility\n // tree — and a node whose body is a form is not a button in any case.\n role=\"group\"\n tabIndex={0}\n aria-label={nodeLabel?.(node) ?? (typeof node.label === 'string' ? node.label : undefined)}\n aria-current={selected || undefined}\n data-selected={selected}\n data-dragging={dragging || undefined}\n data-node-id={node.id}\n style={{\n position: 'absolute',\n left: node.x,\n top: node.y,\n width: node.width === 'auto' ? undefined : width,\n // A dragged or selected node rises, so it is never slid underneath a\n // neighbour it happens to be declared before.\n zIndex: dragging ? 2 : selected ? 1 : node.z,\n }}\n className={cn(\n 'group bg-card border-border border p-3 text-start text-sm',\n radius.control,\n focusRing,\n // Only where a press would actually move it — a grab cursor over a text\n // field lies about what the region does, and a node with a handle\n // advertises the grip on the handle instead.\n draggable &&\n 'has-[[data-drag-handle]]:cursor-default [&_[data-drag-handle]]:cursor-grab cursor-grab active:cursor-grabbing',\n selected ? 'border-primary ring-ring/40 ring-2' : 'hover:border-foreground/25',\n dragging && 'shadow-lg',\n )}\n onPointerDown={(event) => {\n if (event.button !== 0) return\n const element = event.currentTarget\n const target = event.target\n if (!(target instanceof Element)) return\n\n // The press belongs to this node either way — selecting it is what\n // pairs the canvas with an inspector beside it.\n event.stopPropagation()\n onSelect?.(node.id)\n\n if (!draggable || !canDragFrom(target, element)) return\n\n onStartDrag(event, element)\n }}\n onKeyDown={(event) => {\n // Keys from a field inside the node are that field's. Without this a\n // space never reaches an input, Backspace deletes the node instead of a\n // character, and the arrows move the node instead of the caret.\n if (event.target !== event.currentTarget) return\n\n if (event.key === 'Enter' || event.key === ' ') {\n event.preventDefault()\n onSelect?.(node.id)\n return\n }\n if (\n onRemoveNode &&\n node.deletable !== false &&\n (event.key === 'Backspace' || event.key === 'Delete')\n ) {\n event.preventDefault()\n onRemoveNode(node.id)\n return\n }\n if (!editable) return\n\n const step = event.shiftKey ? nudge * 10 : nudge\n const delta =\n event.key === 'ArrowLeft'\n ? { x: -step, y: 0 }\n : event.key === 'ArrowRight'\n ? { x: step, y: 0 }\n : event.key === 'ArrowUp'\n ? { x: 0, y: -step }\n : event.key === 'ArrowDown'\n ? { x: 0, y: step }\n : null\n if (!delta) return\n\n event.preventDefault()\n moveNode(node.id, { x: node.x + delta.x, y: node.y + delta.y })\n }}\n >\n <CanvasNodeContext value={context}>\n {Registered ? (\n <Registered node={node} selected={selected} dragging={dragging} />\n ) : renderNode ? (\n renderNode(node, { selected })\n ) : (\n node.label\n )}\n </CanvasNodeContext>\n\n {connectable && (\n <>\n {/* Target. A plain div, not a button: it is a drop area for a pointer\n gesture, and the keyboard path to connecting belongs in the panel\n beside the canvas, not here. */}\n <span\n aria-hidden=\"true\"\n className={cn(\n 'bg-card border-border absolute top-1/2 -start-1.5 size-3 -translate-y-1/2 rounded-full border',\n wiring && 'border-primary bg-primary/20',\n )}\n />\n <span\n aria-hidden=\"true\"\n title=\"Drag to connect\"\n data-nodrag\n className={cn(\n 'bg-card border-border absolute top-1/2 -end-1.5 size-3 -translate-y-1/2 cursor-crosshair rounded-full border',\n 'hover:border-primary hover:bg-primary/20',\n )}\n onPointerDown={(event) => {\n event.stopPropagation()\n event.preventDefault()\n onStartWire(event)\n }}\n />\n\n {onAddConnected && (\n // A real button, so building a chain has a keyboard path even\n // though dragging a wire does not. Shown on hover, focus, or while\n // selected — a persistent one on every node turns a busy graph into\n // a field of plus signs.\n <button\n type=\"button\"\n aria-label={addConnectedLabel}\n className={cn(\n 'bg-card border-border text-muted-foreground absolute top-1/2 -end-8 flex size-5 -translate-y-1/2',\n 'items-center justify-center rounded-full border text-sm leading-none',\n 'hover:border-primary hover:text-foreground',\n 'opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100',\n 'motion-reduce:transition-none',\n selected && 'opacity-100',\n focusRing,\n )}\n onClick={(event) => {\n event.stopPropagation()\n onAddConnected(node.id, snapPoint({ x: node.x + width + 80, y: node.y }))\n }}\n >\n +\n </button>\n )}\n </>\n )}\n </div>\n )\n}\n\n/**\n * The tray of things you can drag onto a canvas.\n *\n * Uses HTML drag-and-drop rather than the pointer gestures the canvas uses\n * internally, because this drag crosses element boundaries and may leave the\n * window entirely — that is exactly what the native API is for, and it gives\n * the drag image and the cursor affordances for free.\n *\n * Each item is a `<button>`, so the palette is not a mouse-only feature: the\n * canvas takes `onAddNode` for a double-click, and clicking a palette item\n * calls `onPick`, which is the keyboard path to the same result.\n */\nfunction NodePalette({\n items,\n onPick,\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'onSelect'> & {\n items: { id: string; label: ReactNode; hint?: ReactNode }[]\n /** Clicked rather than dragged — place it wherever your layout prefers. */\n onPick?: (id: string) => void\n}) {\n return (\n <div\n data-slot=\"node-palette\"\n className={cn('flex flex-col gap-1.5', className)}\n {...props}\n >\n {items.map((item) => (\n <button\n key={item.id}\n type=\"button\"\n draggable\n onDragStart={(event) => {\n event.dataTransfer.setData(NODE_DRAG_TYPE, item.id)\n event.dataTransfer.effectAllowed = 'copy'\n }}\n onClick={() => onPick?.(item.id)}\n className={cn(\n 'border-border bg-card hover:border-foreground/25 flex cursor-grab flex-col gap-0.5',\n 'border p-2.5 text-start active:cursor-grabbing',\n radius.control,\n focusRing,\n )}\n >\n <span className=\"text-sm font-medium\">{item.label}</span>\n {item.hint && (\n <span className=\"text-muted-foreground text-xs\">{item.hint}</span>\n )}\n </button>\n ))}\n </div>\n )\n}\n\nexport { NodeCanvas, NodePalette, edgePath }\nexport type { NodeCanvasProps }\n"
17
17
  }
18
18
  ]
19
19
  }
@@ -8,7 +8,7 @@
8
8
  {
9
9
  "path": "components/primitives/popper.tsx",
10
10
  "type": "registry:primitive",
11
- "content": "import { useCallback, useEffect, useState, type RefObject } from 'react'\n\n/**\n * Position a floating layer against an anchor, without a positioning library.\n *\n * `position: fixed` is deliberate — it takes the layer out of every ancestor's\n * overflow, so a menu inside a scrolling panel is not clipped by it. The cost is\n * that the position has to be recomputed on scroll and resize, which is what the\n * listeners below do.\n *\n * Collision handling flips to the opposite side when the preferred one does not\n * fit, then clamps along the cross axis so the layer stays on screen.\n */\nexport type Side = 'top' | 'right' | 'bottom' | 'left'\nexport type Align = 'start' | 'center' | 'end'\n\ntype PopperOptions = {\n open: boolean\n anchorRef: RefObject<HTMLElement | null>\n floatingRef: RefObject<HTMLElement | null>\n side?: Side\n align?: Align\n /** Gap between anchor and layer, in px. */\n offset?: number\n /** Keep at least this much room to the viewport edge. */\n padding?: number\n /** Stretch the layer to the anchor's width — for select and combobox menus. */\n matchAnchorWidth?: boolean\n /**\n * Sides to try, in order, when the preferred one does not fit.\n *\n * Defaults to the opposite side, which is right for a panel hanging off a\n * trigger. A submenu wants a longer chain — right, then left, then above —\n * because a cascading menu near the corner of the viewport can run out of\n * room on both sides.\n */\n fallbackSides?: Side[]\n}\n\nexport type PopperState = {\n style: React.CSSProperties\n side: Side\n}\n\nconst OPPOSITE: Record<Side, Side> = {\n top: 'bottom',\n bottom: 'top',\n left: 'right',\n right: 'left',\n}\n\nfunction place(\n anchor: DOMRect,\n layer: { width: number; height: number },\n side: Side,\n align: Align,\n offset: number,\n) {\n const vertical = side === 'top' || side === 'bottom'\n\n const main =\n side === 'bottom'\n ? anchor.bottom + offset\n : side === 'top'\n ? anchor.top - layer.height - offset\n : side === 'right'\n ? anchor.right + offset\n : anchor.left - layer.width - offset\n\n const size = vertical ? layer.width : layer.height\n const start = vertical ? anchor.left : anchor.top\n const extent = vertical ? anchor.width : anchor.height\n\n const cross =\n align === 'start'\n ? start\n : align === 'end'\n ? start + extent - size\n : start + extent / 2 - size / 2\n\n return vertical ? { top: main, left: cross } : { top: cross, left: main }\n}\n\nfunction fits(\n position: { top: number; left: number },\n layer: { width: number; height: number },\n padding: number,\n) {\n return (\n position.top >= padding &&\n position.left >= padding &&\n position.top + layer.height <= window.innerHeight - padding &&\n position.left + layer.width <= window.innerWidth - padding\n )\n}\n\nexport function usePopper({\n open,\n anchorRef,\n floatingRef,\n side = 'bottom',\n align = 'center',\n offset = 6,\n padding = 8,\n matchAnchorWidth = false,\n fallbackSides,\n}: PopperOptions): PopperState {\n // `fallbackSides` is normally a literal array, so a new identity arrives on\n // every render. Comparing its contents keeps the callback stable without\n // asking every caller to memoise the array.\n const fallbackKey = fallbackSides?.join() ?? ''\n const [state, setState] = useState<PopperState>({\n // Hidden until measured, or the layer flashes at 0,0 on first paint.\n style: { position: 'fixed', top: 0, left: 0, visibility: 'hidden' },\n side,\n })\n\n const update = useCallback(() => {\n const anchor = anchorRef.current\n const floating = floatingRef.current\n if (!anchor || !floating) return\n\n const anchorRect = anchor.getBoundingClientRect()\n const layer = {\n width: floating.offsetWidth,\n height: floating.offsetHeight,\n }\n\n let resolved = side\n let position = place(anchorRect, layer, side, align, offset)\n\n if (!fits(position, layer, padding)) {\n // First side that fits wins; if none do, the clamp below keeps the\n // preferred placement on screen rather than leaving it half outside.\n for (const candidate of fallbackSides ?? [OPPOSITE[side]]) {\n const next = place(anchorRect, layer, candidate, align, offset)\n if (fits(next, layer, padding)) {\n resolved = candidate\n position = next\n break\n }\n }\n }\n\n // Clamp whatever side won, so a layer wider than the gap still stays on\n // screen rather than running off the edge.\n const top = Math.min(\n Math.max(position.top, padding),\n Math.max(padding, window.innerHeight - layer.height - padding),\n )\n const left = Math.min(\n Math.max(position.left, padding),\n Math.max(padding, window.innerWidth - layer.width - padding),\n )\n\n setState({\n side: resolved,\n style: {\n position: 'fixed',\n top,\n left,\n visibility: 'visible',\n ...(matchAnchorWidth ? { width: anchorRect.width } : null),\n maxHeight: `calc(100vh - ${padding * 2}px)`,\n },\n })\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [\n anchorRef,\n floatingRef,\n side,\n align,\n offset,\n padding,\n matchAnchorWidth,\n fallbackKey,\n ])\n\n useEffect(() => {\n if (!open) return\n\n update()\n\n // `true` for capture: catches scrolling in any ancestor, not just the page.\n window.addEventListener('scroll', update, true)\n window.addEventListener('resize', update)\n\n const observer = new ResizeObserver(update)\n if (floatingRef.current) observer.observe(floatingRef.current)\n if (anchorRef.current) observer.observe(anchorRef.current)\n\n return () => {\n window.removeEventListener('scroll', update, true)\n window.removeEventListener('resize', update)\n observer.disconnect()\n }\n }, [open, update, anchorRef, floatingRef])\n\n return state\n}\n"
11
+ "content": "import { useCallback, useEffect, useState, type RefObject } from 'react'\n\n/**\n * Position a floating layer against an anchor, without a positioning library.\n *\n * `position: fixed` is deliberate — it takes the layer out of every ancestor's\n * overflow, so a menu inside a scrolling panel is not clipped by it. The cost is\n * that the position has to be recomputed on scroll and resize, which is what the\n * listeners below do.\n *\n * Collision handling flips to the opposite side when the preferred one does not\n * fit, then clamps along the cross axis so the layer stays on screen.\n */\nexport type Side = 'top' | 'right' | 'bottom' | 'left'\nexport type Align = 'start' | 'center' | 'end'\n\ntype PopperOptions = {\n open: boolean\n anchorRef: RefObject<HTMLElement | null>\n floatingRef: RefObject<HTMLElement | null>\n side?: Side\n align?: Align\n /** Gap between anchor and layer, in px. */\n offset?: number\n /** Keep at least this much room to the viewport edge. */\n padding?: number\n /** Stretch the layer to the anchor's width — for select and combobox menus. */\n matchAnchorWidth?: boolean\n /**\n * Sides to try, in order, when the preferred one does not fit.\n *\n * Defaults to the opposite side, which is right for a panel hanging off a\n * trigger. A submenu wants a longer chain — right, then left, then above —\n * because a cascading menu near the corner of the viewport can run out of\n * room on both sides.\n */\n fallbackSides?: Side[]\n}\n\nexport type PopperState = {\n style: React.CSSProperties\n side: Side\n}\n\nconst OPPOSITE: Record<Side, Side> = {\n top: 'bottom',\n bottom: 'top',\n left: 'right',\n right: 'left',\n}\n\nfunction place(\n anchor: DOMRect,\n layer: { width: number; height: number },\n side: Side,\n align: Align,\n offset: number,\n) {\n const vertical = side === 'top' || side === 'bottom'\n\n const main =\n side === 'bottom'\n ? anchor.bottom + offset\n : side === 'top'\n ? anchor.top - layer.height - offset\n : side === 'right'\n ? anchor.right + offset\n : anchor.left - layer.width - offset\n\n const size = vertical ? layer.width : layer.height\n const start = vertical ? anchor.left : anchor.top\n const extent = vertical ? anchor.width : anchor.height\n\n const cross =\n align === 'start'\n ? start\n : align === 'end'\n ? start + extent - size\n : start + extent / 2 - size / 2\n\n return vertical ? { top: main, left: cross } : { top: cross, left: main }\n}\n\nfunction fits(\n position: { top: number; left: number },\n layer: { width: number; height: number },\n padding: number,\n) {\n return (\n position.top >= padding &&\n position.left >= padding &&\n position.top + layer.height <= window.innerHeight - padding &&\n position.left + layer.width <= window.innerWidth - padding\n )\n}\n\n/**\n * Where a `position: fixed` element's coordinates are actually measured from.\n *\n * Normally the viewport — which is why every number above can be a viewport\n * number. But a `transform`, `filter` or `perspective` on any ancestor makes\n * *that element* the containing block instead, and the layer lands at the\n * ancestor's origin plus our viewport offset, which can be an entire screen\n * away. `NodeCanvas` is exactly this shape: its node layer is one transformed\n * div, so a Select or a Popover inside a node was positioned off-canvas.\n *\n * Returns the origin to subtract. `null` means the viewport, the common case,\n * where the numbers are already right and nothing is adjusted at all.\n */\nfunction fixedOrigin(element: HTMLElement) {\n for (let parent = element.parentElement; parent; parent = parent.parentElement) {\n const style = getComputedStyle(parent)\n if (\n style.transform !== 'none' ||\n style.filter !== 'none' ||\n style.perspective !== 'none' ||\n style.contain.includes('paint')\n ) {\n // The containing block is the padding box, so a border on that ancestor\n // shifts the origin by its own width.\n const box = parent.getBoundingClientRect()\n return {\n top: box.top + parseFloat(style.borderTopWidth),\n left: box.left + parseFloat(style.borderLeftWidth),\n }\n }\n }\n return null\n}\n\nexport function usePopper({\n open,\n anchorRef,\n floatingRef,\n side = 'bottom',\n align = 'center',\n offset = 6,\n padding = 8,\n matchAnchorWidth = false,\n fallbackSides,\n}: PopperOptions): PopperState {\n // `fallbackSides` is normally a literal array, so a new identity arrives on\n // every render. Comparing its contents keeps the callback stable without\n // asking every caller to memoise the array.\n const fallbackKey = fallbackSides?.join() ?? ''\n const [state, setState] = useState<PopperState>({\n // Hidden until measured, or the layer flashes at 0,0 on first paint.\n style: { position: 'fixed', top: 0, left: 0, visibility: 'hidden' },\n side,\n })\n\n const update = useCallback(() => {\n const anchor = anchorRef.current\n const floating = floatingRef.current\n if (!anchor || !floating) return\n\n const anchorRect = anchor.getBoundingClientRect()\n\n // Painted size, not layout size. The two are the same everywhere except\n // under a scaling ancestor, and every comparison below is against the\n // viewport — which is painted space, the space `anchorRect` is already in.\n const floatingRect = floating.getBoundingClientRect()\n const layer = { width: floatingRect.width, height: floatingRect.height }\n\n // How much that ancestor scales us by, read off the element itself: the\n // transformed ancestor is often a zero-size positioned div, so its own box\n // cannot be measured, but this ratio always can.\n const scaleX = floating.offsetWidth ? floatingRect.width / floating.offsetWidth : 1\n const scaleY = floating.offsetHeight ? floatingRect.height / floating.offsetHeight : 1\n\n let resolved = side\n let position = place(anchorRect, layer, side, align, offset)\n\n if (!fits(position, layer, padding)) {\n // First side that fits wins; if none do, the clamp below keeps the\n // preferred placement on screen rather than leaving it half outside.\n for (const candidate of fallbackSides ?? [OPPOSITE[side]]) {\n const next = place(anchorRect, layer, candidate, align, offset)\n if (fits(next, layer, padding)) {\n resolved = candidate\n position = next\n break\n }\n }\n }\n\n // Clamp whatever side won, so a layer wider than the gap still stays on\n // screen rather than running off the edge.\n const top = Math.min(\n Math.max(position.top, padding),\n Math.max(padding, window.innerHeight - layer.height - padding),\n )\n const left = Math.min(\n Math.max(position.left, padding),\n Math.max(padding, window.innerWidth - layer.width - padding),\n )\n\n // Everything above is a viewport number. Convert to the space the layer is\n // actually positioned in — the same numbers when that is the viewport.\n const origin = fixedOrigin(floating)\n\n setState({\n side: resolved,\n style: {\n position: 'fixed',\n top: origin ? (top - origin.top) / scaleY : top,\n left: origin ? (left - origin.left) / scaleX : left,\n visibility: 'visible',\n // Divided too: a width the anchor's painted width, once the ancestor\n // scales it, is that width again.\n ...(matchAnchorWidth ? { width: anchorRect.width / scaleX } : null),\n maxHeight: origin\n ? (window.innerHeight - padding * 2) / scaleY\n : `calc(100vh - ${padding * 2}px)`,\n },\n })\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [\n anchorRef,\n floatingRef,\n side,\n align,\n offset,\n padding,\n matchAnchorWidth,\n fallbackKey,\n ])\n\n useEffect(() => {\n if (!open) return\n\n update()\n\n // `true` for capture: catches scrolling in any ancestor, not just the page.\n window.addEventListener('scroll', update, true)\n window.addEventListener('resize', update)\n\n const observer = new ResizeObserver(update)\n if (floatingRef.current) observer.observe(floatingRef.current)\n if (anchorRef.current) observer.observe(anchorRef.current)\n\n return () => {\n window.removeEventListener('scroll', update, true)\n window.removeEventListener('resize', update)\n observer.disconnect()\n }\n }, [open, update, anchorRef, floatingRef])\n\n return state\n}\n"
12
12
  }
13
13
  ]
14
14
  }