// SPDX-License-Identifier: MIT // Copyright 2026 David Ronai // For the React Three Fiber 10 alpha, whose Canvas takes a WebGPU renderer through `renderer`. // On stable R3F 9, use ../UltraText.tsx instead. import { useEffect, useState } from 'react' import { Canvas, useFrame, useThree, type CanvasProps } from '@react-three/fiber' import { WebGPURenderer } from 'three/webgpu' import { DomText, type DomTextOptions } from 'ultra-text' /** A WebGPU canvas with a continuous render loop for observed text changes. */ export function UltraTextCanvas({ children, ...props }: Omit) { return ( { const renderer = new WebGPURenderer({ canvas: canvas as HTMLCanvasElement, antialias: true, }) renderer.domElement.setAttribute('aria-hidden', 'true') await renderer.init() return renderer }} > {children} ) } type UltraTextProps = { source: HTMLElement | null /** Keep this object stable; changing it replaces the capture. */ options?: Omit } /** Captures visible HTML; static motion is the default for this helper. */ export function UltraText({ source, options }: UltraTextProps) { const renderer = useThree((state) => state.renderer) const [text, setText] = useState(null) useEffect(() => { setText(null) if (!source) return const controller = new AbortController() const onError = options?.onError ?? console.error let owned: DomText | null = null void (async () => { if (!(renderer instanceof WebGPURenderer)) { throw new Error('UltraText requires UltraTextCanvas or WebGPURenderer') } await document.fonts.ready if (controller.signal.aborted) return const next = await DomText.create(renderer, source, { pixelsPerUnit: 100, textDepth: 0, panelDepth: -0.01, rectDepth: -0.005, ...options, motion: { enabled: false, strength: 18 / (options?.pixelsPerUnit ?? 100), ...options?.motion, }, signal: controller.signal, onError, }) if (controller.signal.aborted) { next.dispose() return } owned = next setText(next) })().catch((error: unknown) => { if (!controller.signal.aborted) onError(error) }) return () => { controller.abort() owned?.removeFromParent() owned?.dispose() } }, [renderer, source, options]) // Keep the retained group centered after observers update its dimensions. useFrame(() => { if (text) text.position.set(-text.width / 2, -text.height / 2, 0) }) return text ? : null }