{
	"$schema": "https://shadcn-svelte.com/schema/registry-item.json",
	"name": "canvas",
	"title": "Canvas",
	"type": "registry:ui",
	"description": "An infinite pannable, zoomable field with draggable nodes and connectors.",
	"dependencies": [
		"@lucide/svelte@^0.554.0"
	],
	"devDependencies": [
		"@lucide/svelte@^0.544.0"
	],
	"files": [
		{
			"content": "<script lang=\"ts\">\r\n\timport { cn } from '$UTILS$';\r\n\timport { untrack, type Snippet } from 'svelte';\r\n\timport {\r\n\t\tclamp,\r\n\t\tnodeBounds,\r\n\t\tsetCanvasContext,\r\n\t\ttype CanvasPoint,\r\n\t\ttype CanvasRect\r\n\t} from './ctx.svelte';\r\n\r\n\tlet {\r\n\t\tx = $bindable(0),\r\n\t\ty = $bindable(0),\r\n\t\tzoom = $bindable(1),\r\n\t\tminZoom = 0.2,\r\n\t\tmaxZoom = 3,\r\n\t\tzoomSpeed = 0.0015,\r\n\t\tgrid = 'dots',\r\n\t\tgridSize = 24,\r\n\t\tsnap = 0,\r\n\t\tpannable = true,\r\n\t\tzoomable = true,\r\n\t\tpanOnMiddleClick = true,\r\n\t\tclass: className,\r\n\t\tonpan,\r\n\t\tonzoom,\r\n\t\tchildren,\r\n\t\t...rest\r\n\t}: {\r\n\t\t/** Pan offset in screen pixels. */\r\n\t\tx?: number;\r\n\t\ty?: number;\r\n\t\tzoom?: number;\r\n\t\tminZoom?: number;\r\n\t\tmaxZoom?: number;\r\n\t\tzoomSpeed?: number;\r\n\t\tgrid?: 'dots' | 'lines' | 'none';\r\n\t\tgridSize?: number;\r\n\t\t/** Grid step nodes snap to while dragging. 0 disables snapping. */\r\n\t\tsnap?: number;\r\n\t\tpannable?: boolean;\r\n\t\tzoomable?: boolean;\r\n\t\tpanOnMiddleClick?: boolean;\r\n\t\tclass?: string;\r\n\t\tonpan?: (offset: CanvasPoint) => void;\r\n\t\tonzoom?: (zoom: number) => void;\r\n\t\tchildren: Snippet;\r\n\t\t[key: string]: unknown;\r\n\t} = $props();\r\n\r\n\tlet viewport = $state<HTMLDivElement | null>(null);\r\n\tlet overlay = $state<HTMLDivElement | null>(null);\r\n\tlet panning = $state(false);\r\n\tlet spaceHeld = $state(false);\r\n\tlet nodes = $state<Record<string, CanvasRect>>({});\r\n\r\n\tlet panStart: { x: number; y: number; originX: number; originY: number } | null = null;\r\n\r\n\tfunction toCanvas(clientX: number, clientY: number): CanvasPoint {\r\n\t\tconst rect = viewport?.getBoundingClientRect();\r\n\t\tif (!rect) return { x: 0, y: 0 };\r\n\t\treturn { x: (clientX - rect.left - x) / zoom, y: (clientY - rect.top - y) / zoom };\r\n\t}\r\n\r\n\tfunction toScreen(point: CanvasPoint): CanvasPoint {\r\n\t\treturn { x: point.x * zoom + x, y: point.y * zoom + y };\r\n\t}\r\n\r\n\tfunction panBy(dx: number, dy: number) {\r\n\t\tx += dx;\r\n\t\ty += dy;\r\n\t\tonpan?.({ x, y });\r\n\t}\r\n\r\n\tfunction panTo(nextX: number, nextY: number) {\r\n\t\tx = nextX;\r\n\t\ty = nextY;\r\n\t\tonpan?.({ x, y });\r\n\t}\r\n\r\n\t/**\r\n\t * Scales around a viewport-relative origin so the point under the cursor\r\n\t * stays put. Defaults to the centre when no origin is given.\r\n\t */\r\n\tfunction zoomTo(next: number, origin?: CanvasPoint) {\r\n\t\tconst rect = viewport?.getBoundingClientRect();\r\n\t\tconst target = clamp(next, minZoom, maxZoom);\r\n\t\tif (target === zoom) return;\r\n\r\n\t\tconst pivot = origin ?? { x: (rect?.width ?? 0) / 2, y: (rect?.height ?? 0) / 2 };\r\n\t\tconst ratio = target / zoom;\r\n\r\n\t\tx = pivot.x - (pivot.x - x) * ratio;\r\n\t\ty = pivot.y - (pivot.y - y) * ratio;\r\n\t\tzoom = target;\r\n\r\n\t\tonzoom?.(zoom);\r\n\t\tonpan?.({ x, y });\r\n\t}\r\n\r\n\tfunction zoomBy(factor: number, origin?: CanvasPoint) {\r\n\t\tzoomTo(zoom * factor, origin);\r\n\t}\r\n\r\n\tfunction fitView(padding = 48) {\r\n\t\tconst rect = viewport?.getBoundingClientRect();\r\n\t\tconst bounds = nodeBounds(nodes);\r\n\t\tif (!rect || !bounds || bounds.width === 0 || bounds.height === 0) return;\r\n\r\n\t\tconst next = clamp(\r\n\t\t\tMath.min(\r\n\t\t\t\t(rect.width - padding * 2) / bounds.width,\r\n\t\t\t\t(rect.height - padding * 2) / bounds.height\r\n\t\t\t),\r\n\t\t\tminZoom,\r\n\t\t\tmaxZoom\r\n\t\t);\r\n\r\n\t\tzoom = next;\r\n\t\tx = rect.width / 2 - (bounds.x + bounds.width / 2) * next;\r\n\t\ty = rect.height / 2 - (bounds.y + bounds.height / 2) * next;\r\n\r\n\t\tonzoom?.(zoom);\r\n\t\tonpan?.({ x, y });\r\n\t}\r\n\r\n\tfunction reset() {\r\n\t\tzoom = 1;\r\n\t\tpanTo(0, 0);\r\n\t\tonzoom?.(zoom);\r\n\t}\r\n\r\n\tsetCanvasContext({\r\n\t\tget x() {\r\n\t\t\treturn x;\r\n\t\t},\r\n\t\tget y() {\r\n\t\t\treturn y;\r\n\t\t},\r\n\t\tget zoom() {\r\n\t\t\treturn zoom;\r\n\t\t},\r\n\t\tget minZoom() {\r\n\t\t\treturn minZoom;\r\n\t\t},\r\n\t\tget maxZoom() {\r\n\t\t\treturn maxZoom;\r\n\t\t},\r\n\t\tget snap() {\r\n\t\t\treturn snap;\r\n\t\t},\r\n\t\tget panning() {\r\n\t\t\treturn panning;\r\n\t\t},\r\n\t\tget viewport() {\r\n\t\t\treturn viewport;\r\n\t\t},\r\n\t\tget overlay() {\r\n\t\t\treturn overlay;\r\n\t\t},\r\n\t\tget nodes() {\r\n\t\t\treturn nodes;\r\n\t\t},\r\n\t\ttoCanvas,\r\n\t\ttoScreen,\r\n\t\tpanBy,\r\n\t\tpanTo,\r\n\t\tzoomBy,\r\n\t\tzoomTo,\r\n\t\tfitView,\r\n\t\treset,\r\n\t\t// Nodes register from an effect that also writes to `nodes`, so the read\r\n\t\t// has to be untracked or the two would trigger each other forever.\r\n\t\tregisterNode: (id, rect) => {\r\n\t\t\tnodes = { ...untrack(() => nodes), [id]: rect };\r\n\t\t},\r\n\t\tunregisterNode: (id) => {\r\n\t\t\tconst { [id]: _removed, ...remaining } = untrack(() => nodes);\r\n\t\t\tnodes = remaining;\r\n\t\t}\r\n\t});\r\n\r\n\tfunction handleWheel(e: WheelEvent) {\r\n\t\tif (!zoomable) return;\r\n\t\te.preventDefault();\r\n\r\n\t\tconst rect = viewport!.getBoundingClientRect();\r\n\t\tconst origin = { x: e.clientX - rect.left, y: e.clientY - rect.top };\r\n\t\t// Exponential so a trackpad and a mouse wheel feel the same at any scale.\r\n\t\tzoomTo(zoom * Math.exp(-e.deltaY * zoomSpeed), origin);\r\n\t}\r\n\r\n\tfunction handlePointerDown(e: PointerEvent) {\r\n\t\tif (!pannable) return;\r\n\r\n\t\tconst middle = panOnMiddleClick && e.button === 1;\r\n\t\tconst left = e.button === 0;\r\n\t\tif (!middle && !left) return;\r\n\r\n\t\t// A left drag only pans from the background, so nodes keep their own drag.\r\n\t\tif (left && e.target !== e.currentTarget && !spaceHeld) return;\r\n\r\n\t\te.preventDefault();\r\n\t\tpanning = true;\r\n\t\tpanStart = { x: e.clientX, y: e.clientY, originX: x, originY: y };\r\n\t\t(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);\r\n\t}\r\n\r\n\tfunction handlePointerMove(e: PointerEvent) {\r\n\t\tif (!panning || !panStart) return;\r\n\t\tpanTo(panStart.originX + (e.clientX - panStart.x), panStart.originY + (e.clientY - panStart.y));\r\n\t}\r\n\r\n\tfunction handlePointerUp(e: PointerEvent) {\r\n\t\tif (!panning) return;\r\n\t\tpanning = false;\r\n\t\tpanStart = null;\r\n\t\t(e.currentTarget as HTMLElement).releasePointerCapture?.(e.pointerId);\r\n\t}\r\n\r\n\tfunction handleKeydown(e: KeyboardEvent) {\r\n\t\tif (e.code === 'Space' && !spaceHeld) {\r\n\t\t\tspaceHeld = true;\r\n\t\t\te.preventDefault();\r\n\t\t}\r\n\t}\r\n\r\n\tconst gridStyle = $derived.by(() => {\r\n\t\tif (grid === 'none') return '';\r\n\r\n\t\tconst step = gridSize * zoom;\r\n\t\tconst position = `${x}px ${y}px`;\r\n\r\n\t\tif (grid === 'lines') {\r\n\t\t\treturn [\r\n\t\t\t\t`background-image: linear-gradient(to right, currentColor 1px, transparent 1px), linear-gradient(to bottom, currentColor 1px, transparent 1px)`,\r\n\t\t\t\t`background-size: ${step}px ${step}px`,\r\n\t\t\t\t`background-position: ${position}`\r\n\t\t\t].join(';');\r\n\t\t}\r\n\r\n\t\tconst dot = clamp(zoom, 0.5, 2);\r\n\t\treturn [\r\n\t\t\t`background-image: radial-gradient(currentColor ${dot}px, transparent ${dot}px)`,\r\n\t\t\t`background-size: ${step}px ${step}px`,\r\n\t\t\t`background-position: ${position}`\r\n\t\t].join(';');\r\n\t});\r\n</script>\r\n\r\n<svelte:window\r\n\tonkeydown={handleKeydown}\r\n\tonkeyup={(e) => {\r\n\t\tif (e.code === 'Space') spaceHeld = false;\r\n\t}}\r\n/>\r\n\r\n<div\r\n\tbind:this={viewport}\r\n\trole=\"application\"\r\n\taria-label=\"Canvas\"\r\n\ttabindex=\"-1\"\r\n\tonwheel={handleWheel}\r\n\tonpointerdown={handlePointerDown}\r\n\tonpointermove={handlePointerMove}\r\n\tonpointerup={handlePointerUp}\r\n\tonpointercancel={handlePointerUp}\r\n\tclass={cn(\r\n\t\t'relative size-full touch-none overflow-hidden overscroll-none bg-background select-none',\r\n\t\tpannable && (panning ? 'cursor-grabbing' : spaceHeld ? 'cursor-grab' : 'cursor-default'),\r\n\t\tclassName\r\n\t)}\r\n\t{...rest}\r\n>\r\n\t{#if grid !== 'none'}\r\n\t\t<!-- The grid lives in the background layer, so panning it is free. -->\r\n\t\t<div class=\"pointer-events-none absolute inset-0 text-border\" style={gridStyle}></div>\r\n\t{/if}\r\n\r\n\t<!-- The layer itself is transparent to pointers so clicks on empty space reach\r\n\t     the viewport and start a pan. Children opt back in individually. -->\r\n\t<div\r\n\t\tclass=\"pointer-events-none absolute left-0 top-0 origin-top-left\"\r\n\t\tstyle:transform=\"translate({x}px, {y}px) scale({zoom})\"\r\n\t>\r\n\t\t{@render children()}\r\n\t</div>\r\n\r\n\t<!-- Controls and the minimap move themselves in here, so they keep their own\r\n\t     size and position no matter how the canvas is panned or zoomed. -->\r\n\t<div bind:this={overlay} class=\"pointer-events-none absolute inset-0 z-20\"></div>\r\n</div>\r\n",
			"type": "registry:ui",
			"target": "canvas/canvas.svelte"
		},
		{
			"content": "<script lang=\"ts\">\r\n\timport { cn } from '$UTILS$';\r\n\timport { onDestroy, type Snippet } from 'svelte';\r\n\timport { getCanvasContext, snapTo, type CanvasPoint } from './ctx.svelte';\r\n\r\n\tconst uid = $props.id();\r\n\r\n\tlet {\r\n\t\tid = uid,\r\n\t\tx = $bindable(0),\r\n\t\ty = $bindable(0),\r\n\t\tselected = $bindable(false),\r\n\t\tdraggable = true,\r\n\t\tselectable = true,\r\n\t\twidth,\r\n\t\theight,\r\n\t\tclass: className,\r\n\t\tondragstart,\r\n\t\tondrag,\r\n\t\tondragend,\r\n\t\tchildren,\r\n\t\t...rest\r\n\t}: {\r\n\t\tid?: string;\r\n\t\t/** Position in canvas space. */\r\n\t\tx?: number;\r\n\t\ty?: number;\r\n\t\tselected?: boolean;\r\n\t\tdraggable?: boolean;\r\n\t\tselectable?: boolean;\r\n\t\twidth?: number;\r\n\t\theight?: number;\r\n\t\tclass?: string;\r\n\t\tondragstart?: (position: CanvasPoint) => void;\r\n\t\tondrag?: (position: CanvasPoint) => void;\r\n\t\tondragend?: (position: CanvasPoint) => void;\r\n\t\tchildren: Snippet;\r\n\t\t[key: string]: unknown;\r\n\t} = $props();\r\n\r\n\tconst canvas = getCanvasContext('Canvas.Node');\r\n\r\n\tlet measuredWidth = $state(0);\r\n\tlet measuredHeight = $state(0);\r\n\tlet dragging = $state(false);\r\n\tlet start: { pointerX: number; pointerY: number; x: number; y: number } | null = null;\r\n\r\n\t// Keep the shared node map in sync so fitView and the minimap have real boxes.\r\n\t// Removal is left to onDestroy: an effect cleanup would deregister and\r\n\t// re-register on every pointer move while dragging.\r\n\t$effect(() => {\r\n\t\tcanvas.registerNode(id, {\r\n\t\t\tx,\r\n\t\t\ty,\r\n\t\t\twidth: width ?? measuredWidth,\r\n\t\t\theight: height ?? measuredHeight\r\n\t\t});\r\n\t});\r\n\r\n\tonDestroy(() => canvas.unregisterNode(id));\r\n\r\n\t/**\r\n\t * Controls inside a node have to keep working. Capturing the pointer would\r\n\t * retarget the click to the node, and preventDefault would block focus, so\r\n\t * a press that starts on one is left alone entirely.\r\n\t */\r\n\tconst INTERACTIVE =\r\n\t\t'button, a, input, textarea, select, [contenteditable=\"true\"], [data-no-drag]';\r\n\r\n\tfunction handlePointerDown(e: PointerEvent) {\r\n\t\tif (e.button !== 0) return;\r\n\r\n\t\tconst hit = (e.target as HTMLElement | null)?.closest(INTERACTIVE);\r\n\t\tif (hit && hit !== e.currentTarget) return;\r\n\r\n\t\tif (selectable) selected = true;\r\n\t\tif (!draggable) return;\r\n\r\n\t\t// Stop the canvas from treating this as a background drag.\r\n\t\te.stopPropagation();\r\n\t\te.preventDefault();\r\n\r\n\t\tdragging = true;\r\n\t\tstart = { pointerX: e.clientX, pointerY: e.clientY, x, y };\r\n\t\t(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);\r\n\t\tondragstart?.({ x, y });\r\n\t}\r\n\r\n\tfunction handlePointerMove(e: PointerEvent) {\r\n\t\tif (!dragging || !start) return;\r\n\r\n\t\t// Screen deltas have to be divided by the zoom to stay under the cursor.\r\n\t\tconst nextX = start.x + (e.clientX - start.pointerX) / canvas.zoom;\r\n\t\tconst nextY = start.y + (e.clientY - start.pointerY) / canvas.zoom;\r\n\r\n\t\tx = snapTo(nextX, canvas.snap);\r\n\t\ty = snapTo(nextY, canvas.snap);\r\n\t\tondrag?.({ x, y });\r\n\t}\r\n\r\n\tfunction handlePointerUp(e: PointerEvent) {\r\n\t\tif (!dragging) return;\r\n\t\tdragging = false;\r\n\t\tstart = null;\r\n\t\t(e.currentTarget as HTMLElement).releasePointerCapture?.(e.pointerId);\r\n\t\tondragend?.({ x, y });\r\n\t}\r\n\r\n\tfunction handleKeydown(e: KeyboardEvent) {\r\n\t\tif (!draggable) return;\r\n\r\n\t\tconst step = e.shiftKey ? 10 : canvas.snap || 1;\r\n\t\tconst moves: Record<string, [number, number]> = {\r\n\t\t\tArrowLeft: [-step, 0],\r\n\t\t\tArrowRight: [step, 0],\r\n\t\t\tArrowUp: [0, -step],\r\n\t\t\tArrowDown: [0, step]\r\n\t\t};\r\n\r\n\t\tconst move = moves[e.key];\r\n\t\tif (!move) return;\r\n\r\n\t\te.preventDefault();\r\n\t\tx += move[0];\r\n\t\ty += move[1];\r\n\t\tondrag?.({ x, y });\r\n\t}\r\n</script>\r\n\r\n<div\r\n\tbind:clientWidth={measuredWidth}\r\n\tbind:clientHeight={measuredHeight}\r\n\trole=\"button\"\r\n\ttabindex=\"0\"\r\n\taria-pressed={selected}\r\n\tonpointerdown={handlePointerDown}\r\n\tonpointermove={handlePointerMove}\r\n\tonpointerup={handlePointerUp}\r\n\tonpointercancel={handlePointerUp}\r\n\tonkeydown={handleKeydown}\r\n\tstyle:transform=\"translate({x}px, {y}px)\"\r\n\tstyle:width={width ? `${width}px` : undefined}\r\n\tstyle:height={height ? `${height}px` : undefined}\r\n\tclass={cn(\r\n\t\t'pointer-events-auto absolute left-0 top-0 touch-none outline-none',\r\n\t\tdraggable && (dragging ? 'cursor-grabbing' : 'cursor-grab'),\r\n\t\tselected && 'z-10',\r\n\t\tclassName\r\n\t)}\r\n\t{...rest}\r\n>\r\n\t{@render children()}\r\n</div>\r\n",
			"type": "registry:ui",
			"target": "canvas/canvas-node.svelte"
		},
		{
			"content": "<script lang=\"ts\">\r\n\timport { cn } from '$UTILS$';\r\n\timport type { Snippet } from 'svelte';\r\n\timport { edgePath, getCanvasContext, type CanvasPoint, type EdgePathType } from './ctx.svelte';\r\n\r\n\tconst uid = $props.id();\r\n\tconst markerId = `canvas-edge-arrow-${uid}`;\r\n\r\n\tlet {\r\n\t\tfrom,\r\n\t\tto,\r\n\t\tfromNode,\r\n\t\ttoNode,\r\n\t\ttype = 'bezier',\r\n\t\tstrokeWidth = 2,\r\n\t\tanimated = false,\r\n\t\tarrow = true,\r\n\t\tselected = false,\r\n\t\tclass: className,\r\n\t\tlabel,\r\n\t\t...rest\r\n\t}: {\r\n\t\t/** Explicit endpoints in canvas space. Ignored when the node variants are set. */\r\n\t\tfrom?: CanvasPoint;\r\n\t\tto?: CanvasPoint;\r\n\t\t/** Anchor to a registered node instead — right edge to left edge. */\r\n\t\tfromNode?: string;\r\n\t\ttoNode?: string;\r\n\t\ttype?: EdgePathType;\r\n\t\tstrokeWidth?: number;\r\n\t\tanimated?: boolean;\r\n\t\tarrow?: boolean;\r\n\t\tselected?: boolean;\r\n\t\tclass?: string;\r\n\t\tlabel?: Snippet;\r\n\t\t[key: string]: unknown;\r\n\t} = $props();\r\n\r\n\tconst canvas = getCanvasContext('Canvas.Edge');\r\n\r\n\t/** Right edge of the source node, left edge of the target — the usual graph flow. */\r\n\tfunction anchor(id: string | undefined, side: 'source' | 'target') {\r\n\t\tif (!id) return null;\r\n\t\tconst rect = canvas.nodes[id];\r\n\t\tif (!rect) return null;\r\n\r\n\t\treturn {\r\n\t\t\tx: side === 'source' ? rect.x + rect.width : rect.x,\r\n\t\t\ty: rect.y + rect.height / 2\r\n\t\t};\r\n\t}\r\n\r\n\tconst start = $derived(anchor(fromNode, 'source') ?? from ?? { x: 0, y: 0 });\r\n\tconst end = $derived(anchor(toNode, 'target') ?? to ?? { x: 0, y: 0 });\r\n\tconst d = $derived(edgePath(start, end, type));\r\n\tconst midpoint = $derived({ x: (start.x + end.x) / 2, y: (start.y + end.y) / 2 });\r\n</script>\r\n\r\n<!-- A 1x1 SVG with visible overflow lets the path use raw canvas coordinates\r\n     without every edge having to size itself to the graph. -->\r\n<svg\r\n\twidth=\"1\"\r\n\theight=\"1\"\r\n\tclass={cn('pointer-events-none absolute left-0 top-0 overflow-visible', className)}\r\n\taria-hidden=\"true\"\r\n\t{...rest}\r\n>\r\n\t{#if arrow}\r\n\t\t<defs>\r\n\t\t\t<marker\r\n\t\t\t\tid={markerId}\r\n\t\t\t\tviewBox=\"0 0 10 10\"\r\n\t\t\t\trefX=\"9\"\r\n\t\t\t\trefY=\"5\"\r\n\t\t\t\tmarkerWidth=\"6\"\r\n\t\t\t\tmarkerHeight=\"6\"\r\n\t\t\t\tmarkerUnits=\"strokeWidth\"\r\n\t\t\t\torient=\"auto-start-reverse\"\r\n\t\t\t>\r\n\t\t\t\t<path d=\"M0 0 L10 5 L0 10 z\" fill=\"currentColor\" />\r\n\t\t\t</marker>\r\n\t\t</defs>\r\n\t{/if}\r\n\r\n\t<path\r\n\t\t{d}\r\n\t\tfill=\"none\"\r\n\t\tstroke=\"currentColor\"\r\n\t\tstroke-width={strokeWidth}\r\n\t\tstroke-linecap=\"round\"\r\n\t\tmarker-end={arrow ? `url(#${markerId})` : undefined}\r\n\t\tclass={cn(\r\n\t\t\t'text-muted-foreground/60 transition-colors',\r\n\t\t\tselected && 'text-primary',\r\n\t\t\tanimated && 'animate-[canvas-edge-dash_1s_linear_infinite] [stroke-dasharray:6_4]'\r\n\t\t)}\r\n\t/>\r\n</svg>\r\n\r\n{#if label}\r\n\t<div\r\n\t\tclass=\"pointer-events-auto absolute left-0 top-0\"\r\n\t\tstyle:transform=\"translate({midpoint.x}px, {midpoint.y}px) translate(-50%, -50%)\"\r\n\t>\r\n\t\t<span class=\"rounded-full border bg-background px-2 py-0.5 text-[10px] text-muted-foreground\">\r\n\t\t\t{@render label()}\r\n\t\t</span>\r\n\t</div>\r\n{/if}\r\n\r\n<style>\r\n\t/* Global so the Tailwind arbitrary animation can reference it by name. */\r\n\t@keyframes -global-canvas-edge-dash {\r\n\t\tto {\r\n\t\t\tstroke-dashoffset: -10;\r\n\t\t}\r\n\t}\r\n</style>\r\n",
			"type": "registry:ui",
			"target": "canvas/canvas-edge.svelte"
		},
		{
			"content": "<script lang=\"ts\">\r\n\timport { cn } from '$UTILS$';\r\n\timport { Maximize, Minus, Plus, RotateCcw } from '@lucide/svelte';\r\n\timport { getCanvasContext } from './ctx.svelte';\r\n\r\n\tlet {\r\n\t\tposition = 'bottom-left',\r\n\t\tstep = 1.25,\r\n\t\tshowZoom = true,\r\n\t\tshowFit = true,\r\n\t\tshowReset = true,\r\n\t\tclass: className,\r\n\t\t...rest\r\n\t}: {\r\n\t\tposition?: 'bottom-left' | 'bottom-right' | 'top-left' | 'top-right';\r\n\t\tstep?: number;\r\n\t\tshowZoom?: boolean;\r\n\t\tshowFit?: boolean;\r\n\t\tshowReset?: boolean;\r\n\t\tclass?: string;\r\n\t\t[key: string]: unknown;\r\n\t} = $props();\r\n\r\n\tconst canvas = getCanvasContext('Canvas.Controls');\r\n\r\n\tlet el = $state<HTMLDivElement | null>(null);\r\n\r\n\t// Written inside Canvas.Root's children, rendered in its overlay — otherwise\r\n\t// the controls would pan and scale along with the content.\r\n\t$effect(() => {\r\n\t\tif (el && canvas.overlay && el.parentElement !== canvas.overlay) {\r\n\t\t\tcanvas.overlay.appendChild(el);\r\n\t\t}\r\n\t});\r\n\r\n\tconst POSITION = {\r\n\t\t'bottom-left': 'bottom-3 left-3',\r\n\t\t'bottom-right': 'bottom-3 right-3',\r\n\t\t'top-left': 'top-3 left-3',\r\n\t\t'top-right': 'top-3 right-3'\r\n\t};\r\n\r\n\tconst button =\r\n\t\t'flex size-8 items-center justify-center rounded-md transition-colors hover:bg-accent disabled:pointer-events-none disabled:opacity-40';\r\n</script>\r\n\r\n<div\r\n\tbind:this={el}\r\n\trole=\"group\"\r\n\taria-label=\"Canvas controls\"\r\n\tclass={cn(\r\n\t\t'pointer-events-auto absolute z-20 flex items-center gap-1 rounded-lg border bg-popover p-1 text-popover-foreground shadow-sm',\r\n\t\tPOSITION[position],\r\n\t\tclassName\r\n\t)}\r\n\t{...rest}\r\n>\r\n\t{#if showZoom}\r\n\t\t<button\r\n\t\t\ttype=\"button\"\r\n\t\t\taria-label=\"Zoom out\"\r\n\t\t\ttitle=\"Zoom out\"\r\n\t\t\tdisabled={canvas.zoom <= canvas.minZoom}\r\n\t\t\tonclick={() => canvas.zoomBy(1 / step)}\r\n\t\t\tclass={button}\r\n\t\t>\r\n\t\t\t<Minus class=\"size-4\" />\r\n\t\t</button>\r\n\r\n\t\t<span class=\"w-12 text-center font-mono text-xs tabular-nums text-muted-foreground\">\r\n\t\t\t{Math.round(canvas.zoom * 100)}%\r\n\t\t</span>\r\n\r\n\t\t<button\r\n\t\t\ttype=\"button\"\r\n\t\t\taria-label=\"Zoom in\"\r\n\t\t\ttitle=\"Zoom in\"\r\n\t\t\tdisabled={canvas.zoom >= canvas.maxZoom}\r\n\t\t\tonclick={() => canvas.zoomBy(step)}\r\n\t\t\tclass={button}\r\n\t\t>\r\n\t\t\t<Plus class=\"size-4\" />\r\n\t\t</button>\r\n\t{/if}\r\n\r\n\t{#if showFit}\r\n\t\t<button\r\n\t\t\ttype=\"button\"\r\n\t\t\taria-label=\"Fit to content\"\r\n\t\t\ttitle=\"Fit to content\"\r\n\t\t\tonclick={() => canvas.fitView()}\r\n\t\t\tclass={button}\r\n\t\t>\r\n\t\t\t<Maximize class=\"size-4\" />\r\n\t\t</button>\r\n\t{/if}\r\n\r\n\t{#if showReset}\r\n\t\t<button\r\n\t\t\ttype=\"button\"\r\n\t\t\taria-label=\"Reset view\"\r\n\t\t\ttitle=\"Reset view\"\r\n\t\t\tonclick={() => canvas.reset()}\r\n\t\t\tclass={button}\r\n\t\t>\r\n\t\t\t<RotateCcw class=\"size-4\" />\r\n\t\t</button>\r\n\t{/if}\r\n</div>\r\n",
			"type": "registry:ui",
			"target": "canvas/canvas-controls.svelte"
		},
		{
			"content": "<script lang=\"ts\">\r\n\timport { cn } from '$UTILS$';\r\n\timport { getCanvasContext, nodeBounds } from './ctx.svelte';\r\n\r\n\tlet {\r\n\t\tposition = 'bottom-right',\r\n\t\twidth = 160,\r\n\t\theight = 110,\r\n\t\tpadding = 12,\r\n\t\tinteractive = true,\r\n\t\tclass: className,\r\n\t\t...rest\r\n\t}: {\r\n\t\tposition?: 'bottom-left' | 'bottom-right' | 'top-left' | 'top-right';\r\n\t\twidth?: number;\r\n\t\theight?: number;\r\n\t\tpadding?: number;\r\n\t\t/** Click to centre the viewport on that spot. */\r\n\t\tinteractive?: boolean;\r\n\t\tclass?: string;\r\n\t\t[key: string]: unknown;\r\n\t} = $props();\r\n\r\n\tconst canvas = getCanvasContext('Canvas.Minimap');\r\n\r\n\tlet el = $state<HTMLDivElement | null>(null);\r\n\r\n\t// Rendered in the canvas overlay so it never pans or scales with the content.\r\n\t$effect(() => {\r\n\t\tif (el && canvas.overlay && el.parentElement !== canvas.overlay) {\r\n\t\t\tcanvas.overlay.appendChild(el);\r\n\t\t}\r\n\t});\r\n\r\n\tconst POSITION = {\r\n\t\t'bottom-left': 'bottom-3 left-3',\r\n\t\t'bottom-right': 'bottom-3 right-3',\r\n\t\t'top-left': 'top-3 left-3',\r\n\t\t'top-right': 'top-3 right-3'\r\n\t};\r\n\r\n\t/** The visible region of the canvas, in canvas space. */\r\n\tconst viewRect = $derived.by(() => {\r\n\t\tconst rect = canvas.viewport?.getBoundingClientRect();\r\n\t\tif (!rect) return { x: 0, y: 0, width: 0, height: 0 };\r\n\r\n\t\treturn {\r\n\t\t\tx: -canvas.x / canvas.zoom,\r\n\t\t\ty: -canvas.y / canvas.zoom,\r\n\t\t\twidth: rect.width / canvas.zoom,\r\n\t\t\theight: rect.height / canvas.zoom\r\n\t\t};\r\n\t});\r\n\r\n\t/** Fit the nodes *and* the current viewport, so the map never loses either. */\r\n\tconst world = $derived.by(() => {\r\n\t\tconst bounds = nodeBounds(canvas.nodes);\r\n\t\tconst view = viewRect;\r\n\t\tif (!bounds) return view.width ? view : { x: 0, y: 0, width: 1, height: 1 };\r\n\r\n\t\tconst minX = Math.min(bounds.x, view.x);\r\n\t\tconst minY = Math.min(bounds.y, view.y);\r\n\t\tconst maxX = Math.max(bounds.x + bounds.width, view.x + view.width);\r\n\t\tconst maxY = Math.max(bounds.y + bounds.height, view.y + view.height);\r\n\r\n\t\treturn { x: minX, y: minY, width: maxX - minX || 1, height: maxY - minY || 1 };\r\n\t});\r\n\r\n\tconst scale = $derived(\r\n\t\tMath.min((width - padding * 2) / world.width, (height - padding * 2) / world.height)\r\n\t);\r\n\r\n\tconst project = (x: number, y: number) => ({\r\n\t\tx: (x - world.x) * scale + padding,\r\n\t\ty: (y - world.y) * scale + padding\r\n\t});\r\n\r\n\tconst viewPoint = $derived(project(viewRect.x, viewRect.y));\r\n\r\n\tfunction handleClick(e: MouseEvent) {\r\n\t\tif (!interactive) return;\r\n\r\n\t\tconst rect = (e.currentTarget as HTMLElement).getBoundingClientRect();\r\n\t\tconst viewportRect = canvas.viewport?.getBoundingClientRect();\r\n\t\tif (!viewportRect) return;\r\n\r\n\t\t// Undo the projection to find the canvas point that was clicked.\r\n\t\tconst target = {\r\n\t\t\tx: (e.clientX - rect.left - padding) / scale + world.x,\r\n\t\t\ty: (e.clientY - rect.top - padding) / scale + world.y\r\n\t\t};\r\n\r\n\t\tcanvas.panTo(\r\n\t\t\tviewportRect.width / 2 - target.x * canvas.zoom,\r\n\t\t\tviewportRect.height / 2 - target.y * canvas.zoom\r\n\t\t);\r\n\t}\r\n</script>\r\n\r\n<div\r\n\tbind:this={el}\r\n\tclass={cn(\r\n\t\t'pointer-events-auto absolute z-20 overflow-hidden rounded-lg border bg-popover/90 shadow-sm backdrop-blur',\r\n\t\tPOSITION[position],\r\n\t\tinteractive && 'cursor-pointer',\r\n\t\tclassName\r\n\t)}\r\n\tstyle:width=\"{width}px\"\r\n\tstyle:height=\"{height}px\"\r\n\trole={interactive ? 'button' : 'img'}\r\n\taria-label=\"Canvas minimap\"\r\n\ttabindex={interactive ? 0 : undefined}\r\n\tonclick={handleClick}\r\n\tonkeydown={(e) => {\r\n\t\tif (e.key === 'Enter') canvas.fitView();\r\n\t}}\r\n\t{...rest}\r\n>\r\n\t<svg {width} {height} class=\"block\">\r\n\t\t{#each Object.entries(canvas.nodes) as [id, node] (id)}\r\n\t\t\t{@const p = project(node.x, node.y)}\r\n\t\t\t<rect\r\n\t\t\t\tx={p.x}\r\n\t\t\t\ty={p.y}\r\n\t\t\t\twidth={Math.max(node.width * scale, 2)}\r\n\t\t\t\theight={Math.max(node.height * scale, 2)}\r\n\t\t\t\trx=\"2\"\r\n\t\t\t\tclass=\"fill-muted-foreground/40\"\r\n\t\t\t/>\r\n\t\t{/each}\r\n\r\n\t\t<rect\r\n\t\t\tx={viewPoint.x}\r\n\t\t\ty={viewPoint.y}\r\n\t\t\twidth={viewRect.width * scale}\r\n\t\t\theight={viewRect.height * scale}\r\n\t\t\trx=\"2\"\r\n\t\t\tclass=\"fill-primary/10 stroke-primary\"\r\n\t\t\tstroke-width=\"1\"\r\n\t\t/>\r\n\t</svg>\r\n</div>\r\n",
			"type": "registry:ui",
			"target": "canvas/canvas-minimap.svelte"
		},
		{
			"content": "import { getContext, setContext } from 'svelte';\r\n\r\nconst CANVAS_CTX_KEY = Symbol('canvas-ctx');\r\n\r\nexport type CanvasPoint = { x: number; y: number };\r\nexport type CanvasRect = { x: number; y: number; width: number; height: number };\r\n\r\nexport type CanvasContext = {\r\n\t/** Pan offset in screen pixels. */\r\n\treadonly x: number;\r\n\treadonly y: number;\r\n\t/** Scale factor. 1 means one canvas unit is one CSS pixel. */\r\n\treadonly zoom: number;\r\n\treadonly minZoom: number;\r\n\treadonly maxZoom: number;\r\n\treadonly snap: number;\r\n\treadonly panning: boolean;\r\n\treadonly viewport: HTMLElement | null;\r\n\t/** Un-transformed layer above the canvas, for chrome that must not pan or scale. */\r\n\treadonly overlay: HTMLElement | null;\r\n\t/** Bounding box of every registered node, in canvas space. */\r\n\treadonly nodes: Record<string, CanvasRect>;\r\n\r\n\t/** Converts client (screen) coordinates into canvas space. */\r\n\ttoCanvas: (clientX: number, clientY: number) => CanvasPoint;\r\n\t/** Converts canvas coordinates into client (screen) space. */\r\n\ttoScreen: (point: CanvasPoint) => CanvasPoint;\r\n\r\n\tpanBy: (dx: number, dy: number) => void;\r\n\tpanTo: (x: number, y: number) => void;\r\n\tzoomBy: (factor: number, origin?: CanvasPoint) => void;\r\n\tzoomTo: (zoom: number, origin?: CanvasPoint) => void;\r\n\tfitView: (padding?: number) => void;\r\n\treset: () => void;\r\n\r\n\tregisterNode: (id: string, rect: CanvasRect) => void;\r\n\tunregisterNode: (id: string) => void;\r\n};\r\n\r\nexport function setCanvasContext(ctx: CanvasContext) {\r\n\tsetContext(CANVAS_CTX_KEY, ctx);\r\n\treturn ctx;\r\n}\r\n\r\nexport function getCanvasContext(component = 'This component') {\r\n\tconst ctx = getContext<CanvasContext | undefined>(CANVAS_CTX_KEY);\r\n\tif (!ctx) throw new Error(`${component} must be used inside a Canvas.Root`);\r\n\treturn ctx;\r\n}\r\n\r\nexport const clamp = (value: number, min: number, max: number) =>\r\n\tMath.min(Math.max(value, min), max);\r\n\r\nexport function snapTo(value: number, step: number) {\r\n\treturn step > 0 ? Math.round(value / step) * step : value;\r\n}\r\n\r\n/** Union of every node box, or null when there is nothing to fit. */\r\nexport function nodeBounds(nodes: Record<string, CanvasRect>): CanvasRect | null {\r\n\tconst list = Object.values(nodes);\r\n\tif (list.length === 0) return null;\r\n\r\n\tlet minX = Infinity;\r\n\tlet minY = Infinity;\r\n\tlet maxX = -Infinity;\r\n\tlet maxY = -Infinity;\r\n\r\n\tfor (const node of list) {\r\n\t\tminX = Math.min(minX, node.x);\r\n\t\tminY = Math.min(minY, node.y);\r\n\t\tmaxX = Math.max(maxX, node.x + node.width);\r\n\t\tmaxY = Math.max(maxY, node.y + node.height);\r\n\t}\r\n\r\n\treturn { x: minX, y: minY, width: maxX - minX, height: maxY - minY };\r\n}\r\n\r\nexport type EdgePathType = 'bezier' | 'smoothstep' | 'straight';\r\n\r\n/**\r\n * Builds an edge path between two canvas points. Bezier and smoothstep both\r\n * leave horizontally, which is what makes node graphs read left to right.\r\n */\r\nexport function edgePath(from: CanvasPoint, to: CanvasPoint, type: EdgePathType = 'bezier') {\r\n\tconst round = (n: number) => Math.round(n * 10) / 10;\r\n\tconst x1 = round(from.x);\r\n\tconst y1 = round(from.y);\r\n\tconst x2 = round(to.x);\r\n\tconst y2 = round(to.y);\r\n\r\n\tif (type === 'straight') return `M${x1} ${y1} L${x2} ${y2}`;\r\n\r\n\tif (type === 'smoothstep') {\r\n\t\tconst midX = round((x1 + x2) / 2);\r\n\t\tconst radius = Math.min(12, Math.abs(x2 - x1) / 2, Math.abs(y2 - y1) / 2);\r\n\t\tif (radius < 1) return `M${x1} ${y1} L${x2} ${y2}`;\r\n\r\n\t\tconst dirY = y2 > y1 ? 1 : -1;\r\n\t\tconst dirX = x2 > x1 ? 1 : -1;\r\n\t\treturn [\r\n\t\t\t`M${x1} ${y1}`,\r\n\t\t\t`L${round(midX - radius * dirX)} ${y1}`,\r\n\t\t\t`Q${midX} ${y1} ${midX} ${round(y1 + radius * dirY)}`,\r\n\t\t\t`L${midX} ${round(y2 - radius * dirY)}`,\r\n\t\t\t`Q${midX} ${y2} ${round(midX + radius * dirX)} ${y2}`,\r\n\t\t\t`L${x2} ${y2}`\r\n\t\t].join(' ');\r\n\t}\r\n\r\n\tconst curve = Math.max(40, Math.abs(x2 - x1) * 0.5);\r\n\treturn `M${x1} ${y1} C${round(x1 + curve)} ${y1}, ${round(x2 - curve)} ${y2}, ${x2} ${y2}`;\r\n}\r\n",
			"type": "registry:ui",
			"target": "canvas/ctx.svelte.ts"
		},
		{
			"content": "import Canvas from './canvas.svelte';\r\nimport CanvasNode from './canvas-node.svelte';\r\nimport CanvasEdge from './canvas-edge.svelte';\r\nimport CanvasControls from './canvas-controls.svelte';\r\nimport CanvasMinimap from './canvas-minimap.svelte';\r\n\r\nexport {\r\n\tCanvas,\r\n\tCanvasNode,\r\n\tCanvasEdge,\r\n\tCanvasControls,\r\n\tCanvasMinimap,\r\n\t//\r\n\tCanvas as Root,\r\n\tCanvasNode as Node,\r\n\tCanvasEdge as Edge,\r\n\tCanvasControls as Controls,\r\n\tCanvasMinimap as Minimap\r\n};\r\n\r\nexport { getCanvasContext, edgePath, nodeBounds, snapTo } from './ctx.svelte';\r\nexport type { CanvasContext, CanvasPoint, CanvasRect, EdgePathType } from './ctx.svelte';\r\n",
			"type": "registry:ui",
			"target": "canvas/index.ts"
		}
	]
}