trx-cli 0.1.10

A Modern Cross-Platform Package Manager TUI
"use client";

import { useEffect, useRef, useState } from "react";

const SCRAMBLE_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789<>/\\{}[]|!?@#$-=+";

/** Deterministic placeholder so SSR and the first client render match (avoids hydration errors). */
function deterministicScramble(text: string): string {
  return text
    .split("")
    .map((c, i) =>
      c === " "
        ? " "
        : SCRAMBLE_CHARS[(i * 17 + c.charCodeAt(0) * 31) % SCRAMBLE_CHARS.length]!
    )
    .join("");
}

interface ScrambleTextProps {
  text: string;
  /** Duration of the scramble animation in ms */
  duration?: number;
  /** Extra delay after the element enters the viewport */
  delay?: number;
  className?: string;
  style?: React.CSSProperties;
}

export function ScrambleText({
  text,
  duration = 650,
  delay = 0,
  className,
  style,
}: ScrambleTextProps) {
  // Start with a deterministic “scramble” so server HTML matches the client; randomness only runs after mount in rAF.
  const [display, setDisplay] = useState(() => deterministicScramble(text));

  const containerRef = useRef<HTMLSpanElement>(null);
  const rafRef = useRef<number>(0);
  const timerRef = useRef<ReturnType<typeof setTimeout>>(null!);

  useEffect(() => {
    const el = containerRef.current;
    if (!el) return;

    const startScramble = () => {
      timerRef.current = setTimeout(() => {
        const startTime = performance.now();

        const animate = (now: number) => {
          const elapsed = now - startTime;
          const progress = Math.min(elapsed / duration, 1);
          // Reveal characters left → right using eased progress
          const revealCount = Math.floor(
            (1 - Math.pow(1 - progress, 2)) * text.length
          );

          setDisplay(
            text
              .split("")
              .map((char, i) => {
                if (char === " ") return " ";
                if (i < revealCount) return char;
                return SCRAMBLE_CHARS[
                  Math.floor(Math.random() * SCRAMBLE_CHARS.length)
                ]!;
              })
              .join("")
          );

          if (progress < 1) {
            rafRef.current = requestAnimationFrame(animate);
          }
        };

        rafRef.current = requestAnimationFrame(animate);
      }, delay);
    };

    // Trigger only when element enters viewport
    const observer = new IntersectionObserver(
      (entries) => {
        if (entries[0]?.isIntersecting) {
          observer.disconnect();
          startScramble();
        }
      },
      { threshold: 0.3 }
    );

    observer.observe(el);

    return () => {
      observer.disconnect();
      clearTimeout(timerRef.current);
      cancelAnimationFrame(rafRef.current);
    };
  }, [text, duration, delay]);

  return (
    <span ref={containerRef} className={className} style={style}>
      {display}
    </span>
  );
}