"use client"; import { useEffect, useRef, useState, type CSSProperties } from "react"; import { useReducedMotion } from "../motion/Preferences.tsx"; import { useLatest, type SignatureTone } from "./internal.ts"; import { FLAP_CHARACTERS, flapPath } from "./flap.ts"; export { FLAP_CHARACTERS, flapPath } from "./flap.ts"; function fit( text: string, length: number, align: "left" | "right", characters: string, ) { const caseless = characters === characters.toUpperCase(); const chars = [...(caseless ? text.toUpperCase() : text)].slice(0, length); const room = Array(length - chars.length).fill(" "); return align === "right" ? [...room, ...chars] : [...chars, ...room]; } const FALL = [ { transform: "rotateX(0deg)", filter: "brightness(1)" }, { transform: "rotateX(-90deg)", filter: "brightness(0.55)" }, ]; const LAND = [ { transform: "rotateX(90deg)", filter: "brightness(0.55)" }, { transform: "rotateX(0deg)", filter: "brightness(1)" }, ]; export function SplitFlap({ value, length, align = "left", characters = FLAP_CHARACTERS, flip = 70, stagger = 35, label, live = false, tone, onSettle, className = "", style, }: { value: string; length?: number; align?: "left" | "right"; /** The drum, in the order the flaps fall. Other characters arrive in one flip. */ characters?: string; /** Milliseconds for one flap to fall. */ flip?: number; /** Milliseconds between neighbouring cells starting. */ stagger?: number; label?: string; live?: boolean; tone?: SignatureTone; onSettle?: (value: string) => void; className?: string; style?: CSSProperties; }) { const reduce = useReducedMotion(); const cells = length ?? [...value].length; const [initial] = useState(() => fit(value, cells, align, characters)); const cellsRef = useRef<(HTMLSpanElement | null)[]>([]); const shownRef = useRef(initial); const settleRef = useLatest(onSettle); useEffect(() => { const target = fit(value, cells, align, characters); const shown = shownRef.current; const glyphs = (i: number) => cellsRef.current[i]?.querySelectorAll(".mizu-flap-glyph"); const show = (i: number, ch: string) => { shown[i] = ch; glyphs(i)?.forEach((g) => (g.textContent = ch)); }; const animating = !reduce && typeof Element !== "undefined" && "animate" in Element.prototype; if (!animating) { target.forEach((ch, i) => show(i, ch)); settleRef.current?.(value); return; } let stopped = false; const running = new Set(); const run = async (i: number) => { await new Promise((r) => setTimeout(r, i * stagger)); for (const next of flapPath(shown[i] ?? " ", target[i]!, characters)) { const nodes = glyphs(i); if (stopped || !nodes) return; const [top, bottom, fall, land] = [...nodes]; const from = shown[i] ?? " "; top!.textContent = next; bottom!.textContent = from; fall!.textContent = from; land!.textContent = next; const falling = fall!.parentElement!.animate(FALL, { duration: flip / 2, easing: "cubic-bezier(0.55, 0, 1, 0.45)", fill: "forwards", }); const landing = land!.parentElement!.animate(LAND, { duration: flip / 2, delay: flip / 2, easing: "cubic-bezier(0.2, 1.6, 0.5, 1)", fill: "both", }); running.add(falling).add(landing); try { await landing.finished; } catch { return; } finally { running.delete(falling); running.delete(landing); falling.cancel(); landing.cancel(); } show(i, next); } }; void Promise.all(target.map((_, i) => run(i))).then(() => { if (!stopped) settleRef.current?.(value); }); return () => { stopped = true; running.forEach((a) => a.cancel()); shown.forEach((ch, i) => show(i, ch)); }; }, [value, cells, align, characters, flip, stagger, reduce, settleRef]); const text = [...value].slice(0, cells).join("").trim(); return ( {label ? `${label}: ${text}` : text} ); }