{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"webgl-text","type":"registry:component","title":"Webgl Text","description":"A WebGL plane that mirrors text.","meta":{"pro":false},"docs":"Usage: https://atelier-ui.com/r/webgl-text.md\nDocs: https://atelier-ui.com/docs/foundation/primitive/webgl-text","dependencies":["three","@types/three","@react-three/fiber"],"registryDependencies":["https://atelier-ui.com/r/webgl-portal.json","https://atelier-ui.com/r/agent-rules.json"],"files":[{"path":"registry/webgl-text/webgl-text.tsx","type":"registry:component","target":"components/webgl-text/webgl-text.tsx","content":"\"use client\"\n\nimport { useThree } from \"@react-three/fiber\"\nimport {\n    type ComponentRef,\n    cloneElement,\n    type RefObject,\n    useEffect,\n    useLayoutEffect,\n    useMemo,\n    useRef,\n} from \"react\"\nimport { CanvasTexture, type Mesh, type Texture } from \"three\"\nimport { useDomPlane } from \"../../hooks/use-dom-plane\"\nimport { type Pointer, usePointerUv } from \"../../hooks/use-pointer-uv\"\nimport { type RenderProp, useRender } from \"../../hooks/use-render\"\nimport { webglTeleport } from \"../webgl-portal/webgl-portal\"\n\nexport type { Pointer }\n\ntype WebglTextProps = {\n    children: string\n    webglEnabled?: boolean\n    render?: RenderProp\n    material?: (map: Texture, pointer: Pointer) => React.ReactNode\n    zIndex?: number\n    segments?: number\n    pixelRatio?: number\n    /**\n     * Re-measures the DOM rect every frame so the plane follows animated parents (motion, parallax).\n     * Costs one layout read per frame, so only enable it when needed.\n     */\n    autoReflow?: boolean\n}\n\ntype PlaneProps = {\n    el: RefObject<ComponentRef<\"span\"> | null>\n    segments: number\n    material?: (map: Texture, pointer: Pointer) => React.ReactNode\n    pointer: Pointer\n    zIndex: number\n    autoReflow: boolean\n    pixelRatio: number\n}\n\ntype PaintedLine = {\n    text: string\n    x: number\n    baseline: number\n}\n\n// Groups characters into visual lines from their rendered rects, so wrapped\n// text paints exactly where the browser laid it out.\nfunction measureLines(el: HTMLElement, origin: DOMRect, ascent: number, fontHeight: number) {\n    const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT)\n    const range = document.createRange()\n    const lines: PaintedLine[] = []\n    let current: PaintedLine | null = null\n    let lineTop = 0\n\n    let textNode = walker.nextNode()\n    while (textNode) {\n        const text = textNode.nodeValue ?? \"\"\n        for (let offset = 0; offset < text.length; offset++) {\n            const character = text[offset]\n            const whitespace = /\\s/.test(character)\n            range.setStart(textNode, offset)\n            range.setEnd(textNode, offset + 1)\n            const rect = range.getBoundingClientRect()\n\n            // Collapsed whitespace (line breaks, repeated spaces) has no box.\n            if (whitespace && rect.width === 0) continue\n\n            if (!current || Math.abs(rect.top - lineTop) > fontHeight / 2) {\n                const top = rect.top + (rect.height - fontHeight) / 2\n                current = {\n                    text: \"\",\n                    x: rect.left - origin.left,\n                    baseline: top + ascent - origin.top,\n                }\n                lines.push(current)\n                lineTop = rect.top\n            }\n            current.text += whitespace ? \" \" : character\n        }\n        textNode = walker.nextNode()\n    }\n\n    return lines\n}\n\n// Paints the content of the text on a canvas, mirroring its computed CSS typography so it looks identical to the DOM element.\nfunction paint(el: HTMLElement, canvas: HTMLCanvasElement, rect: DOMRect, pixelRatio: number) {\n    const ctx = canvas.getContext(\"2d\")\n    if (!ctx) return\n\n    const dpr = Math.min(pixelRatio, window.devicePixelRatio || 1)\n    const { fontFamily, fontSize, fontWeight, fontStyle, letterSpacing, color } =\n        getComputedStyle(el)\n\n    canvas.width = Math.max(1, Math.ceil(rect.width * dpr))\n    canvas.height = Math.max(1, Math.ceil(rect.height * dpr))\n\n    ctx.setTransform(dpr, 0, 0, dpr, 0, 0)\n    ctx.clearRect(0, 0, rect.width, rect.height)\n    ctx.font = `${fontStyle} ${fontWeight} ${fontSize} ${fontFamily}`\n    ctx.letterSpacing = letterSpacing\n    ctx.fillStyle = color\n    ctx.textBaseline = \"alphabetic\"\n\n    const probe = ctx.measureText(\"Hg\")\n    const ascent = probe.fontBoundingBoxAscent\n    const fontHeight = probe.fontBoundingBoxAscent + probe.fontBoundingBoxDescent\n\n    for (const line of measureLines(el, rect, ascent, fontHeight)) {\n        ctx.fillText(line.text, line.x, line.baseline)\n    }\n}\n\nfunction textRect(el: HTMLElement) {\n    const range = document.createRange()\n    range.selectNodeContents(el)\n    const rect = range.getBoundingClientRect()\n    return rect.width > 0 && rect.height > 0 ? rect : el.getBoundingClientRect()\n}\n\nfunction Plane({ el, segments, material, pointer, zIndex, autoReflow, pixelRatio }: PlaneProps) {\n    const mesh = useRef<Mesh>(null)\n    const size = useThree((s) => s.size)\n    const measureBounds = useDomPlane(el, mesh, { autoReflow, getRect: textRect })\n\n    const { canvas, texture } = useMemo(() => {\n        const canvas = document.createElement(\"canvas\")\n        const texture = new CanvasTexture(canvas)\n        return { canvas, texture }\n    }, [])\n\n    useEffect(() => {\n        return () => {\n            texture.dispose()\n        }\n    }, [texture])\n\n    useLayoutEffect(() => {\n        const target = el.current\n        if (!target) return\n\n        const measure = () => {\n            const rect = measureBounds()\n            if (!rect) return\n            const prevWidth = canvas.width\n            const prevHeight = canvas.height\n            paint(target, canvas, rect, pixelRatio)\n\n            // WebGL2 texture storage is immutable: a resized canvas can't be\n            // uploaded into the old allocation, so drop it and let three\n            // recreate the texture at the new size.\n            if (canvas.width !== prevWidth || canvas.height !== prevHeight) texture.dispose()\n            texture.needsUpdate = true\n        }\n\n        measure()\n        document.fonts.ready.then(measure)\n\n        // ResizeObserver never fires for inline elements (they have no box),\n        // so document.body is watched too to catch layout-affecting resizes.\n        const ro = new ResizeObserver(measure)\n        ro.observe(target)\n        ro.observe(document.body)\n\n        const mo = new MutationObserver(measure)\n        mo.observe(target, {\n            characterData: true,\n            childList: true,\n            attributes: true,\n            subtree: true,\n        })\n        mo.observe(document.documentElement, { attributes: true })\n        mo.observe(document.body, { attributes: true })\n        const scheme = window.matchMedia(\"(prefers-color-scheme: dark)\")\n        scheme.addEventListener(\"change\", measure)\n\n        return () => {\n            ro.disconnect()\n            mo.disconnect()\n            scheme.removeEventListener(\"change\", measure)\n        }\n    }, [el, canvas, texture, pixelRatio, size, measureBounds])\n\n    return (\n        <mesh ref={mesh} renderOrder={zIndex}>\n            <planeGeometry args={[1, 1, segments, segments]} />\n            {material ? (\n                material(texture, pointer)\n            ) : (\n                <meshBasicMaterial map={texture} transparent />\n            )}\n        </mesh>\n    )\n}\n\nexport function WebglText({\n    children,\n    material,\n    webglEnabled = true,\n    segments = 1,\n    render,\n    zIndex = 0,\n    autoReflow = false,\n    pixelRatio = 2,\n}: WebglTextProps) {\n    const el = useRef<ComponentRef<\"span\">>(null)\n    const pointer = usePointerUv(el, { enabled: webglEnabled, getRect: textRect })\n\n    const element = useRender({\n        render,\n        defaultElement: <span />,\n        props: { ref: el, children },\n    })\n\n    // Force opacity:0 to win when WebGL is on, so a consumer can't accidentally\n    // un-hide the DOM fallback through their render element's style.\n    const host = webglEnabled\n        ? cloneElement(element, { style: { ...element.props.style, opacity: 0 } })\n        : element\n\n    return (\n        <>\n            {host}\n\n            {webglEnabled && (\n                <webglTeleport.In>\n                    <Plane\n                        el={el}\n                        segments={segments}\n                        material={material}\n                        pointer={pointer}\n                        zIndex={zIndex}\n                        autoReflow={autoReflow}\n                        pixelRatio={pixelRatio}\n                    />\n                </webglTeleport.In>\n            )}\n        </>\n    )\n}\n"},{"path":"registry/hooks/use-render.ts","type":"registry:hook","target":"hooks/use-render.ts","content":"// biome-ignore-all lint/suspicious/noExplicitAny: prop merging is inherently dynamic\n/**\n * Inspired by Base UI's `useRender` + `mergeProps`, intentionally simplified for this\n * library's scope at the moment.\n *\n * Chosen over polymorphic prop: cleaner TypeScript, integrates better with other\n * component (Next/Image, design systems, third-party UI libraries)\n *\n * @see https://base-ui.com/react/utils/use-render\n * @see https://base-ui.com/react/utils/merge-props\n */\nimport { cloneElement, isValidElement, type ReactElement, type Ref } from \"react\"\n\ntype AnyProps = Record<string, any>\n\ntype RenderFunction<S> = (props: AnyProps, state: S) => ReactElement\n\nexport type RenderProp<S = void> = ReactElement | RenderFunction<S>\n\ntype UseRenderOptions<S> = {\n    render: RenderProp<S> | undefined\n    props: AnyProps\n    state?: S\n    defaultElement: ReactElement\n}\n\nexport function useRender<S = void>(options: UseRenderOptions<S>): ReactElement<AnyProps> {\n    const { render, props, state, defaultElement } = options\n    const target = render ?? defaultElement\n\n    // Function form: consumer wires props themselves, no merging needed.\n    if (typeof target === \"function\") {\n        return target(props, state as S) as ReactElement<AnyProps>\n    }\n\n    // Element form: clone and merge our internal props with whatever the consumer set on the element.\n    const targetProps = (isValidElement(target) ? target.props : {}) as AnyProps\n    return cloneElement(target, mergeProps(props, targetProps)) as ReactElement<AnyProps>\n}\n\nfunction mergeProps(internal: AnyProps, external: AnyProps): AnyProps {\n    const merged: AnyProps = { ...internal }\n\n    for (const key in external) {\n        const internalValue = internal[key]\n        const externalValue = external[key]\n\n        if (key === \"className\" && typeof externalValue === \"string\") {\n            merged[key] = [internalValue, externalValue].filter(Boolean).join(\" \")\n        } else if (key === \"style\" && externalValue && typeof externalValue === \"object\") {\n            merged[key] = { ...internalValue, ...externalValue }\n        } else if (key === \"ref\") {\n            merged[key] = composeRefs(internalValue, externalValue)\n        } else if (\n            key.startsWith(\"on\") &&\n            typeof internalValue === \"function\" &&\n            typeof externalValue === \"function\"\n        ) {\n            // External handler runs first so consumers can stopPropagation before our logic fires.\n            merged[key] = chainFunctions(externalValue, internalValue)\n        } else {\n            merged[key] = externalValue\n        }\n    }\n\n    return merged\n}\n\nfunction chainFunctions(...fns: Array<(...args: any[]) => void>) {\n    return (...args: any[]) => {\n        for (const fn of fns) fn(...args)\n    }\n}\n\nfunction composeRefs<T>(...refs: Array<Ref<T> | undefined>) {\n    return (node: T) => {\n        for (const ref of refs) {\n            if (typeof ref === \"function\") ref(node)\n            else if (ref != null) (ref as { current: T | null }).current = node\n        }\n    }\n}\n"},{"path":"registry/hooks/use-pointer-uv.ts","type":"registry:hook","target":"hooks/use-pointer-uv.ts","content":"import { type RefObject, useEffect, useMemo } from \"react\"\nimport { Vector2 } from \"three\"\n\nexport type Pointer = {\n    uv: Vector2\n    texUv: Vector2\n    repeat: Vector2\n    hover: number\n}\n\ntype UsePointerUvOptions = {\n    enabled: boolean\n    /**\n     * Maps element UVs into cropped texture UVs when object-fit trims the\n     * media. Defaults to identity, so `texUv` mirrors `uv`.\n     */\n    uvFit?: RefObject<{ x: number; y: number }>\n    getRect?: (el: HTMLElement) => DOMRect\n}\n\n/**\n * Tracks the cursor over a DOM element as normalized UVs, mutated in place so\n * shader materials can read it every frame without re-rendering React.\n */\nexport function usePointerUv(\n    el: RefObject<HTMLElement | null>,\n    { enabled, uvFit, getRect }: UsePointerUvOptions,\n): Pointer {\n    const pointer = useMemo<Pointer>(() => {\n        return {\n            uv: new Vector2(0.5, 0.5),\n            texUv: new Vector2(0.5, 0.5),\n            repeat: new Vector2(1, 1),\n            hover: 0,\n        }\n    }, [])\n\n    useEffect(() => {\n        if (!enabled) return\n        const target = el.current\n        if (!target) return\n\n        /*\n         * Pointer events still fire on the DOM element through opacity:0,\n         * so the browser tells us when the cursor is over it.\n         */\n        const onMove = (event: PointerEvent) => {\n            const rect = getRect ? getRect(target) : target.getBoundingClientRect()\n            const x = (event.clientX - rect.left) / rect.width\n            const y = 1 - (event.clientY - rect.top) / rect.height\n            const fit = uvFit?.current ?? { x: 1, y: 1 }\n            pointer.uv.set(x, y)\n            pointer.texUv.set(x * fit.x + (1 - fit.x) / 2, y * fit.y + (1 - fit.y) / 2)\n        }\n\n        const onEnter = () => (pointer.hover = 1)\n        const onLeave = () => (pointer.hover = 0)\n\n        target.addEventListener(\"pointermove\", onMove)\n        target.addEventListener(\"pointerenter\", onEnter)\n        target.addEventListener(\"pointerleave\", onLeave)\n\n        /*\n         * Hover in too fast and pointerenter fires before these listeners\n         * attach, so seed hover from the live :hover state instead.\n         */\n        if (target.matches(\":hover\")) pointer.hover = 1\n\n        return () => {\n            target.removeEventListener(\"pointermove\", onMove)\n            target.removeEventListener(\"pointerenter\", onEnter)\n            target.removeEventListener(\"pointerleave\", onLeave)\n        }\n    }, [enabled, el, pointer, uvFit, getRect])\n\n    return pointer\n}\n"},{"path":"registry/hooks/use-dom-plane.ts","type":"registry:hook","target":"hooks/use-dom-plane.ts","content":"import { useFrame, useThree } from \"@react-three/fiber\"\nimport { type RefObject, useCallback, useRef } from \"react\"\nimport type { Mesh } from \"three\"\n\ntype UseDomPlaneOptions = {\n    /**\n     * Re-measures the DOM rect every frame so the plane follows animated\n     * parents (motion, parallax). Costs one layout read per frame.\n     */\n    autoReflow: boolean\n    fitScale?: RefObject<{ x: number; y: number }>\n    getRect?: (el: HTMLElement) => DOMRect\n}\n\n/**\n * Positions and scales a mesh to cover a DOM element on the shared canvas.\n * Scroll is applied every frame. Layout changes are not observed here: the\n * caller calls `measureBounds` when the element resizes or repaints.\n */\nexport function useDomPlane(\n    el: RefObject<HTMLElement | null>,\n    mesh: RefObject<Mesh | null>,\n    { autoReflow, fitScale, getRect }: UseDomPlaneOptions,\n) {\n    const size = useThree((state) => state.size)\n    const viewport = useThree((state) => state.viewport)\n    const bounds = useRef({ x: 0, y: 0, width: 0, height: 0 })\n\n    const measureBounds = useCallback(() => {\n        const target = el.current\n        if (!target) return null\n\n        /*\n         * Rect in document coords so viewport position later needs only\n         * window.scrollX/Y, instead of re-measuring bounds every render.\n         */\n        const rect = getRect ? getRect(target) : target.getBoundingClientRect()\n        bounds.current.x = rect.left + window.scrollX\n        bounds.current.y = rect.top + window.scrollY\n        bounds.current.width = rect.width\n        bounds.current.height = rect.height\n        return rect\n    }, [el, getRect])\n\n    useFrame(() => {\n        const m = mesh.current\n        if (!m) return\n        const pxToWorld = viewport.height / size.height\n        const fit = fitScale?.current ?? { x: 1, y: 1 }\n\n        const transitioning = document.documentElement.hasAttribute(\"data-atelier-transitioning\")\n\n        if ((autoReflow || transitioning) && el.current) {\n            const rect = getRect ? getRect(el.current) : el.current.getBoundingClientRect()\n            m.position.x = (rect.left + rect.width / 2 - size.width / 2) * pxToWorld\n            m.position.y = -(rect.top + rect.height / 2 - size.height / 2) * pxToWorld\n            m.scale.x = rect.width * pxToWorld * fit.x\n            m.scale.y = rect.height * pxToWorld * fit.y\n            return\n        }\n\n        const { x, y, width, height } = bounds.current\n        m.position.x = (x + width / 2 - window.scrollX - size.width / 2) * pxToWorld\n        m.position.y = -(y + height / 2 - window.scrollY - size.height / 2) * pxToWorld\n        m.scale.x = width * pxToWorld * fit.x\n        m.scale.y = height * pxToWorld * fit.y\n    })\n\n    return measureBounds\n}\n"}]}