gpt2api / web /src /components /ui /text-3d-flip.tsx
jiayi.xie
Deploy chatgpt2api to HF Space (port 7860)
99a7ebb
Raw
History Blame Contribute Delete
7.07 kB
"use client"
import React, {
memo,
useCallback,
useEffect,
useMemo,
useRef,
type ElementType,
} from "react"
import {
useAnimate,
type AnimationOptions,
type ValueAnimationTransition,
} from "motion/react"
import { cn } from "@/lib/utils"
const HAS_SEGMENTER = typeof Intl !== "undefined" && "Segmenter" in Intl
const splitIntoCharacters = (text: string): string[] => {
if (HAS_SEGMENTER) {
const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" })
return Array.from(segmenter.segment(text), ({ segment }) => segment)
}
return Array.from(text)
}
const extractTextFromChildren = (children: React.ReactNode): string => {
if (children == null) return ""
if (typeof children === "string") return children
if (typeof children === "number") return String(children)
if (Array.isArray(children)) {
return children.map(extractTextFromChildren).join("")
}
if (React.isValidElement(children)) {
const props = children.props as Record<string, unknown>
const childText = props.children as React.ReactNode
if (childText != null) {
return extractTextFromChildren(childText)
}
}
return ""
}
const ROTATION_MAP = {
top: "rotateX(90deg)",
right: "rotateY(90deg)",
bottom: "rotateX(-90deg)",
left: "rotateY(-90deg)",
} as const
const DEFAULT_TRANSITION: ValueAnimationTransition = {
type: "spring",
damping: 30,
stiffness: 300,
}
interface Text3DFlipProps {
children: React.ReactNode
as?: ElementType
className?: string
textClassName?: string
flipTextClassName?: string
staggerDuration?: number
staggerFrom?: "first" | "last" | "center" | number | "random"
transition?: ValueAnimationTransition | AnimationOptions
rotateDirection?: "top" | "right" | "bottom" | "left"
}
const Text3DFlip = ({
children,
as: ElementTag = "p",
className,
textClassName,
flipTextClassName,
staggerDuration = 0.05,
staggerFrom = "first",
transition = DEFAULT_TRANSITION,
rotateDirection = "right",
...props
}: Text3DFlipProps) => {
const isAnimatingRef = useRef(false)
const isMountedRef = useRef(false)
const [scope, animate] = useAnimate()
const rotationTransform = ROTATION_MAP[rotateDirection]
useEffect(() => {
isMountedRef.current = true
return () => {
isMountedRef.current = false
isAnimatingRef.current = false
}
}, [])
const text = useMemo(() => {
try {
return extractTextFromChildren(children)
} catch {
return ""
}
}, [children])
const characters = useMemo(() => {
const words = text.split(" ")
return words.map((word, i) => ({
characters: splitIntoCharacters(word),
needsSpace: i !== words.length - 1,
}))
}, [text])
const charOffsets = useMemo(() => {
const offsets = [0]
for (const word of characters) {
offsets.push(offsets.at(-1)! + word.characters.length)
}
return offsets
}, [characters])
const getStaggerDelay = useCallback(
(index: number, totalChars: number) => {
if (staggerFrom === "first") return index * staggerDuration
if (staggerFrom === "last")
return (totalChars - 1 - index) * staggerDuration
if (staggerFrom === "center") {
const center = Math.floor(totalChars / 2)
return Math.abs(center - index) * staggerDuration
}
if (staggerFrom === "random") {
const randomIndex = Math.floor(Math.random() * totalChars)
return Math.abs(randomIndex - index) * staggerDuration
}
return Math.abs(staggerFrom - index) * staggerDuration
},
[staggerFrom, staggerDuration]
)
const handleHoverStart = useCallback(async () => {
if (isAnimatingRef.current) return
isAnimatingRef.current = true
try {
const totalChars = characters.reduce(
(sum, word) => sum + word.characters.length,
0
)
const delays = Array.from({ length: totalChars }, (_, i) =>
getStaggerDelay(i, totalChars)
)
await animate(
".text-3d-flip-char",
{ transform: rotationTransform },
{
...transition,
delay: (i: number) => delays[i],
}
)
if (!isMountedRef.current) return
await animate(
".text-3d-flip-char",
{ transform: "rotateX(0deg) rotateY(0deg)" },
{ duration: 0 }
)
} finally {
if (isMountedRef.current) {
isAnimatingRef.current = false
}
}
}, [characters, transition, getStaggerDelay, rotationTransform, animate])
return (
<ElementTag
className={cn("relative flex flex-wrap", className)}
onMouseEnter={handleHoverStart}
ref={scope}
{...props}
>
<span className="sr-only">{text}</span>
{characters.map((wordObj, wordIndex) => (
<span key={wordIndex} className="inline-flex">
{wordObj.characters.map((char, charIndex) => (
<CharBox
key={charOffsets[wordIndex] + charIndex}
char={char}
textClassName={textClassName}
flipTextClassName={flipTextClassName}
rotateDirection={rotateDirection}
/>
))}
{wordObj.needsSpace && <span className="whitespace-pre"> </span>}
</span>
))}
</ElementTag>
)
}
interface CharBoxProps {
char: string
textClassName?: string
flipTextClassName?: string
rotateDirection: "top" | "right" | "bottom" | "left"
}
const SECOND_FACE_TRANSFORMS = {
top: "rotateX(-90deg) translateZ(0.5lh)",
right:
"rotateY(90deg) translateX(50%) rotateY(-90deg) translateX(-50%) rotateY(-90deg) translateX(50%)",
bottom: "rotateX(90deg) translateZ(0.5lh)",
left: "rotateY(90deg) translateX(50%) rotateY(-90deg) translateX(50%) rotateY(-90deg) translateX(50%)",
} as const
const FRONT_FACE_TRANSFORMS = {
top: "translateZ(0.5lh)",
bottom: "translateZ(0.5lh)",
left: "rotateY(90deg) translateX(50%) rotateY(-90deg)",
right: "rotateY(-90deg) translateX(50%) rotateY(90deg)",
} as const
const CONTAINER_TRANSFORMS = {
top: "translateZ(-0.5lh)",
bottom: "translateZ(-0.5lh)",
left: "rotateY(90deg) translateX(50%) rotateY(-90deg)",
right: "rotateY(90deg) translateX(50%) rotateY(-90deg)",
} as const
const CharBox = memo(
({
char,
textClassName,
flipTextClassName,
rotateDirection,
}: CharBoxProps) => (
<span
className="text-3d-flip-char inline transform-3d"
style={{ transform: CONTAINER_TRANSFORMS[rotateDirection] }}
>
<span
className={cn("relative h-[1lh] backface-hidden", textClassName)}
style={{ transform: FRONT_FACE_TRANSFORMS[rotateDirection] }}
>
{char}
</span>
<span
className={cn(
"absolute top-0 left-0 h-[1lh] backface-hidden",
flipTextClassName
)}
style={{ transform: SECOND_FACE_TRANSFORMS[rotateDirection] }}
>
{char}
</span>
</span>
)
)
CharBox.displayName = "CharBox"
Text3DFlip.displayName = "Text3DFlip"
export default Text3DFlip