mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-08-06 01:14:36 +08:00
227 lines
7.4 KiB
TypeScript
227 lines
7.4 KiB
TypeScript
import { useEffect, useRef, useState } from "react";
|
||
import { animate, motion, useInView, useMotionValue, useReducedMotion, useTransform, type HTMLMotionProps } from "motion/react";
|
||
|
||
import { cn } from "@/lib/utils";
|
||
|
||
const DEFAULT_COLORS = ["#c679c4", "#fa3d1d", "#ffb005", "#e1e1fe", "#0358f7"];
|
||
const BAND_HALF = 17;
|
||
const SWEEP_START = -BAND_HALF;
|
||
const SWEEP_END = 100 + BAND_HALF;
|
||
|
||
const sweepEase = (t: number) => (t < 0.5 ? 4 * t ** 3 : 1 - (-2 * t + 2) ** 3 / 2);
|
||
|
||
function buildGradient(pos: number, colors: string[], textColor: string) {
|
||
const bandStart = pos - BAND_HALF;
|
||
const bandEnd = pos + BAND_HALF;
|
||
|
||
if (bandStart >= 100) {
|
||
return `linear-gradient(90deg, ${textColor}, ${textColor})`;
|
||
}
|
||
const n = colors.length;
|
||
const parts: string[] = [];
|
||
|
||
if (bandStart > 0) parts.push(`${textColor} 0%`, `${textColor} ${bandStart.toFixed(2)}%`);
|
||
|
||
colors.forEach((c, i) => {
|
||
const pct = n === 1 ? pos : bandStart + (i / (n - 1)) * BAND_HALF * 2;
|
||
parts.push(`${c} ${pct.toFixed(2)}%`);
|
||
});
|
||
|
||
if (bandEnd < 100) parts.push(`transparent ${bandEnd.toFixed(2)}%`, `transparent 100%`);
|
||
|
||
return `linear-gradient(90deg, ${parts.join(", ")})`;
|
||
}
|
||
|
||
function measureWidths(el: HTMLElement, texts: string[]) {
|
||
const ghost = el.cloneNode() as HTMLElement;
|
||
Object.assign(ghost.style, {
|
||
position: "absolute",
|
||
visibility: "hidden",
|
||
pointerEvents: "none",
|
||
width: "auto",
|
||
whiteSpace: "nowrap",
|
||
});
|
||
el.parentElement!.appendChild(ghost);
|
||
const widths = texts.map((t) => {
|
||
ghost.textContent = t;
|
||
return ghost.getBoundingClientRect().width;
|
||
});
|
||
ghost.remove();
|
||
return widths;
|
||
}
|
||
|
||
/**
|
||
* Props for {@link DiaTextReveal}.
|
||
*/
|
||
export interface DiaTextRevealProps extends Omit<HTMLMotionProps<"span">, "ref" | "children" | "style" | "animate" | "transition" | "color"> {
|
||
/**
|
||
* Text to reveal. Pass multiple strings to rotate when {@link DiaTextRevealProps.repeat} is `true`.
|
||
*/
|
||
text: string | string[];
|
||
/**
|
||
* Colors sampled across the moving gradient band. Defaults to a built-in palette.
|
||
*/
|
||
colors?: string[];
|
||
/**
|
||
* CSS color for revealed text after the sweep and for leading/trailing regions during the animation.
|
||
* @defaultValue `"var(--foreground)"`
|
||
*/
|
||
textColor?: string;
|
||
/**
|
||
* Duration of one sweep pass, in seconds.
|
||
* @defaultValue `1.5`
|
||
*/
|
||
duration?: number;
|
||
/**
|
||
* Delay before the sweep starts, in seconds.
|
||
* @defaultValue `0`
|
||
*/
|
||
delay?: number;
|
||
/**
|
||
* When `text` is an array, replay the sweep and advance to the next string after each completion.
|
||
* @defaultValue `false`
|
||
*/
|
||
repeat?: boolean;
|
||
/**
|
||
* Pause between cycles when {@link DiaTextRevealProps.repeat} is `true`, in seconds.
|
||
* @defaultValue `0.5`
|
||
*/
|
||
repeatDelay?: number;
|
||
/**
|
||
* If `true`, the animation starts only after the element enters the viewport.
|
||
* @defaultValue `true`
|
||
*/
|
||
startOnView?: boolean;
|
||
/**
|
||
* Passed to `useInView`: if `true`, in-view detection fires at most once (no replay on scroll-back).
|
||
* @defaultValue `true`
|
||
*/
|
||
once?: boolean;
|
||
/**
|
||
* Additional class names for the animated `span` (e.g. typography utilities).
|
||
*/
|
||
className?: string;
|
||
/**
|
||
* When `text` has multiple entries, use the widest string’s width for layout instead of animating width per line.
|
||
* @defaultValue `false`
|
||
*/
|
||
fixedWidth?: boolean;
|
||
}
|
||
|
||
export function DiaTextReveal({ text, colors = DEFAULT_COLORS, textColor = "var(--foreground)", duration = 1.5, delay = 0, repeat = false, repeatDelay = 0.5, startOnView = true, once = true, className, fixedWidth = false, ...props }: DiaTextRevealProps) {
|
||
const texts = Array.isArray(text) ? text : [text];
|
||
const isMulti = texts.length > 1;
|
||
const prefersReducedMotion = useReducedMotion();
|
||
|
||
const spanRef = useRef<HTMLSpanElement>(null);
|
||
const optsRef = useRef({
|
||
colors,
|
||
textColor,
|
||
duration,
|
||
delay,
|
||
repeat,
|
||
repeatDelay,
|
||
texts,
|
||
});
|
||
optsRef.current = {
|
||
colors,
|
||
textColor,
|
||
duration,
|
||
delay,
|
||
repeat,
|
||
repeatDelay,
|
||
texts,
|
||
};
|
||
|
||
const indexRef = useRef(0);
|
||
const hasPlayedRef = useRef(false);
|
||
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||
const playRef = useRef<() => void>(null!);
|
||
const stopRef = useRef<(() => void) | null>(null);
|
||
|
||
const [activeIndex, setActiveIndex] = useState(0);
|
||
const [measuredWidths, setMeasuredWidths] = useState<number[]>([]);
|
||
|
||
const sweepPos = useMotionValue(SWEEP_START);
|
||
|
||
const backgroundImage = useTransform(sweepPos, (pos) => buildGradient(pos, optsRef.current.colors, optsRef.current.textColor));
|
||
|
||
const isInView = useInView(spanRef, { once, amount: 0.1 });
|
||
|
||
useEffect(() => {
|
||
const el = spanRef.current;
|
||
if (!el || !isMulti) return;
|
||
setMeasuredWidths(measureWidths(el, texts));
|
||
}, [Array.isArray(text) ? text.join("\0") : text]);
|
||
|
||
playRef.current = () => {
|
||
const { duration, delay, repeat, repeatDelay, texts } = optsRef.current;
|
||
|
||
sweepPos.set(SWEEP_START);
|
||
|
||
const controls = animate(sweepPos, SWEEP_END, {
|
||
duration,
|
||
delay,
|
||
ease: sweepEase,
|
||
onComplete() {
|
||
if (!repeat) return;
|
||
timerRef.current = setTimeout(() => {
|
||
const next = (indexRef.current + 1) % texts.length;
|
||
indexRef.current = next;
|
||
setActiveIndex(next);
|
||
playRef.current();
|
||
}, repeatDelay * 1000);
|
||
},
|
||
});
|
||
|
||
stopRef.current = () => controls.stop();
|
||
};
|
||
|
||
useEffect(() => {
|
||
if (prefersReducedMotion) {
|
||
sweepPos.set(SWEEP_END);
|
||
return;
|
||
}
|
||
if (startOnView && !isInView) return;
|
||
if (once && hasPlayedRef.current) return;
|
||
hasPlayedRef.current = true;
|
||
playRef.current();
|
||
|
||
return () => {
|
||
stopRef.current?.();
|
||
clearTimeout(timerRef.current);
|
||
};
|
||
}, [isInView, startOnView, once, prefersReducedMotion, sweepPos]);
|
||
|
||
const fixedW = isMulti && fixedWidth && measuredWidths.length > 0 ? Math.max(...measuredWidths) : undefined;
|
||
|
||
const animatedW = isMulti && !fixedWidth && measuredWidths[activeIndex] != null ? measuredWidths[activeIndex] : undefined;
|
||
|
||
return (
|
||
<motion.span
|
||
ref={spanRef}
|
||
className={cn("align-bottom leading-[100%] text-inherit", className)}
|
||
style={{
|
||
transform: "translateY(-2px)",
|
||
color: "transparent",
|
||
backgroundClip: "text",
|
||
WebkitBackgroundClip: "text",
|
||
backgroundSize: "100% 100%",
|
||
backgroundImage,
|
||
...(isMulti && {
|
||
display: "inline-block",
|
||
overflow: "hidden",
|
||
whiteSpace: "nowrap",
|
||
verticalAlign: "text-center",
|
||
...(fixedW != null && { width: fixedW }),
|
||
}),
|
||
}}
|
||
animate={animatedW != null ? { width: animatedW } : undefined}
|
||
transition={{ duration: 0.4, ease: [0.4, 0, 0.2, 1] }}
|
||
{...props}
|
||
>
|
||
{texts[activeIndex]}
|
||
</motion.span>
|
||
);
|
||
}
|