{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"pixel-trail","type":"registry:component","title":"Pixel Trail","description":"A trail of pixels that follows the cursor.","meta":{"pro":false},"docs":"Usage: https://atelier-ui.com/r/pixel-trail.md\nDocs: https://atelier-ui.com/docs/components/cursor/pixel-trail","dependencies":[],"registryDependencies":["https://atelier-ui.com/r/agent-rules.json"],"files":[{"path":"registry/pixel-trail/pixel-trail.tsx","type":"registry:component","target":"components/pixel-trail/pixel-trail.tsx","content":"\"use client\"\n\nimport { type ComponentRef, useEffect, useRef } from \"react\"\nimport { useFrameLoop } from \"../../hooks/use-frame-loop\"\n\nexport type PixelTrailProps = {\n    mode?: \"color\" | \"sample\"\n    color?: string\n    imageSelector?: string\n    lightenSample?: number\n    pixelSize?: number\n    trailRadius?: number\n    lifetime?: number\n    fade?: number\n    className?: string\n}\n\ntype ColorSampler = (screenX: number, screenY: number) => string | null\n\ntype Pixel = {\n    posX: number\n    posY: number\n    lifetime: number\n    fade: number\n    color: string\n}\n\nfunction getContext(canvas: HTMLCanvasElement) {\n    const ctx = canvas.getContext(\"2d\")\n    if (!ctx) throw new Error(\"Canvas 2D context not supported\")\n    return ctx\n}\n\nfunction toRGB(data: Uint8ClampedArray, offset: number, lightenSample: number) {\n    const red = Math.min(255, data[offset] + lightenSample)\n    const green = Math.min(255, data[offset + 1] + lightenSample)\n    const blue = Math.min(255, data[offset + 2] + lightenSample)\n    return `rgb(${red},${green},${blue})`\n}\n\nfunction clamp(value: number, min: number, max: number) {\n    return Math.max(min, Math.min(value, max))\n}\n\nfunction createImageSampler(img: HTMLImageElement, lightenSample: number): ColorSampler {\n    const offscreen = document.createElement(\"canvas\")\n    offscreen.width = img.naturalWidth\n    offscreen.height = img.naturalHeight\n\n    try {\n        const ctx = getContext(offscreen)\n        ctx.drawImage(img, 0, 0)\n        const imageData = ctx.getImageData(0, 0, offscreen.width, offscreen.height)\n\n        return (screenX, screenY) => {\n            const rect = img.getBoundingClientRect()\n            if (\n                screenX < rect.left ||\n                screenX > rect.right ||\n                screenY < rect.top ||\n                screenY > rect.bottom\n            ) {\n                return null\n            }\n\n            const pixelX = clamp(\n                Math.floor(((screenX - rect.left) / rect.width) * offscreen.width),\n                0,\n                offscreen.width - 1,\n            )\n\n            const pixelY = clamp(\n                Math.floor(((screenY - rect.top) / rect.height) * offscreen.height),\n                0,\n                offscreen.height - 1,\n            )\n\n            const offset = (pixelY * imageData.width + pixelX) * 4\n            return toRGB(imageData.data, offset, lightenSample)\n        }\n    } catch {\n        return () => null\n    }\n}\n\nexport function PixelTrail({\n    mode = \"color\",\n    color = \"#000000\",\n    imageSelector = \"img\",\n    lightenSample = 20,\n    pixelSize = 20,\n    trailRadius = 2,\n    lifetime = 1,\n    fade = 0.5,\n    className,\n}: PixelTrailProps) {\n    const canvasRef = useRef<ComponentRef<\"canvas\">>(null)\n    const canvasSizeRef = useRef({ width: 0, height: 0 })\n    const pixelsRef = useRef<Pixel[]>([])\n\n    useFrameLoop((_, delta) => {\n        if (!canvasRef.current) return\n        const ctx = getContext(canvasRef.current)\n\n        const { width, height } = canvasSizeRef.current\n\n        const pixels = pixelsRef.current\n\n        ctx.clearRect(0, 0, width, height)\n\n        for (let i = pixels.length - 1; i >= 0; i--) {\n            pixels[i].fade += delta\n            if (pixels[i].fade >= pixels[i].lifetime) {\n                pixels[i] = pixels[pixels.length - 1]\n                pixels.pop()\n            }\n        }\n\n        for (const pixel of pixels) {\n            const remaining = pixel.lifetime - pixel.fade\n            ctx.globalAlpha = fade > 0 ? Math.min(1, remaining / fade) : 1\n            ctx.fillStyle = pixel.color\n            ctx.fillRect(pixel.posX, pixel.posY, pixelSize, pixelSize)\n        }\n        ctx.globalAlpha = 1\n    })\n\n    useEffect(() => {\n        const canvas = canvasRef.current\n        if (!canvas) return\n        const ctx = getContext(canvas)\n        const samplers: ColorSampler[] = []\n\n        function rebuildSamplers() {\n            samplers.length = 0\n\n            if (mode !== \"sample\") return\n\n            document.querySelectorAll<HTMLImageElement>(imageSelector).forEach((img) => {\n                if (img.complete && img.naturalWidth > 0) {\n                    samplers.push(createImageSampler(img, lightenSample))\n                }\n            })\n        }\n\n        rebuildSamplers()\n\n        if (mode === \"sample\") {\n            document.querySelectorAll<HTMLImageElement>(imageSelector).forEach((img) => {\n                if (!img.complete) {\n                    img.addEventListener(\"load\", () => rebuildSamplers(), { once: true })\n                }\n            })\n        }\n\n        const getColorAt = (screenX: number, screenY: number): string => {\n            for (const sampler of samplers) {\n                const sampled = sampler(screenX, screenY)\n                if (sampled) return sampled\n            }\n            return color\n        }\n\n        const addPixels = (clientX: number, clientY: number) => {\n            const rect = canvas.getBoundingClientRect()\n            const localX = clientX - rect.left\n            const localY = clientY - rect.top\n\n            const gridX = Math.floor(localX / pixelSize)\n            const gridY = Math.floor(localY / pixelSize)\n\n            for (let offsetX = -trailRadius; offsetX <= trailRadius; offsetX++) {\n                for (let offsetY = -trailRadius; offsetY <= trailRadius; offsetY++) {\n                    const isOutsideCircle =\n                        offsetX * offsetX + offsetY * offsetY > trailRadius * trailRadius\n                    const isSkipped = trailRadius > 0 && Math.random() < 0.75\n\n                    if (!isOutsideCircle && !isSkipped) {\n                        const posX = (gridX + offsetX) * pixelSize\n                        const posY = (gridY + offsetY) * pixelSize\n\n                        pixelsRef.current.push({\n                            posX,\n                            posY,\n                            lifetime: lifetime * (0.2 + Math.random() ** 2 * 0.8),\n                            fade: 0,\n                            color: getColorAt(\n                                posX + rect.left + pixelSize * 0.5,\n                                posY + rect.top + pixelSize * 0.5,\n                            ),\n                        })\n                    }\n                }\n            }\n        }\n\n        const resize = () => {\n            const dpr = window.devicePixelRatio || 1\n            canvasSizeRef.current.width = canvas.clientWidth\n            canvasSizeRef.current.height = canvas.clientHeight\n            canvas.width = canvasSizeRef.current.width * dpr\n            canvas.height = canvasSizeRef.current.height * dpr\n            ctx.setTransform(dpr, 0, 0, dpr, 0, 0)\n        }\n\n        const onMouseMove = (event: MouseEvent) => addPixels(event.clientX, event.clientY)\n        const onTouchMove = (event: TouchEvent) => {\n            for (let index = 0; index < event.touches.length; index++) {\n                addPixels(event.touches[index].clientX, event.touches[index].clientY)\n            }\n        }\n\n        const resizeObserver = new ResizeObserver(resize)\n        resizeObserver.observe(canvas)\n        window.addEventListener(\"mousemove\", onMouseMove)\n        window.addEventListener(\"touchmove\", onTouchMove, { passive: true })\n        resize()\n\n        return () => {\n            resizeObserver.disconnect()\n            window.removeEventListener(\"mousemove\", onMouseMove)\n            window.removeEventListener(\"touchmove\", onTouchMove)\n        }\n    }, [mode, color, imageSelector, lightenSample, pixelSize, trailRadius, lifetime, fade])\n\n    return (\n        <div className={`pointer-events-none ${className ?? \"\"}`}>\n            <canvas\n                ref={canvasRef}\n                style={{\n                    display: \"block\",\n                    width: \"100%\",\n                    height: \"100%\",\n                }}\n            />\n        </div>\n    )\n}\n"},{"path":"registry/hooks/use-frame-loop.ts","type":"registry:hook","target":"hooks/use-frame-loop.ts","content":"import { useEffect, useRef } from \"react\"\n\nconst DELTA_MAX = 0.1\n\ntype FrameLoopCallback = (time: number, delta: number) => void\n\nexport function useFrameLoop(callback: FrameLoopCallback, interval?: number) {\n    const ref = useRef(callback)\n    ref.current = callback\n\n    useEffect(() => {\n        let frameId = 0\n        let lastTime = 0\n        let lastTick = 0\n\n        const tick = (now: number) => {\n            frameId = requestAnimationFrame(tick)\n\n            if (interval && now - lastTick < interval) return\n            if (interval) lastTick = now\n\n            const time = now * 0.001\n            const delta = lastTime ? Math.min(time - lastTime, DELTA_MAX) : 0\n            lastTime = time\n\n            ref.current(time, delta)\n        }\n\n        frameId = requestAnimationFrame(tick)\n\n        return () => {\n            cancelAnimationFrame(frameId)\n        }\n    }, [interval])\n}\n"}]}