mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2025-12-31 23:09:09 +08:00
266 lines
7.7 KiB
TypeScript
266 lines
7.7 KiB
TypeScript
import {
|
|
AnimatePresence,
|
|
HTMLMotionProps,
|
|
TargetAndTransition,
|
|
Transition,
|
|
motion
|
|
} from 'motion/react'
|
|
import {
|
|
forwardRef,
|
|
useCallback,
|
|
useEffect,
|
|
useImperativeHandle,
|
|
useMemo,
|
|
useState
|
|
} from 'react'
|
|
|
|
function cn(...classes: (string | undefined | null | boolean)[]): string {
|
|
return classes.filter(Boolean).join(' ')
|
|
}
|
|
|
|
export interface RotatingTextRef {
|
|
next: () => void
|
|
previous: () => void
|
|
jumpTo: (index: number) => void
|
|
reset: () => void
|
|
}
|
|
|
|
export interface RotatingTextProps
|
|
extends Omit<
|
|
HTMLMotionProps<'span'>,
|
|
'children' | 'transition' | 'initial' | 'animate' | 'exit'
|
|
> {
|
|
texts: string[]
|
|
transition?: Transition
|
|
initial?: TargetAndTransition
|
|
animate?: TargetAndTransition
|
|
exit?: TargetAndTransition
|
|
animatePresenceMode?: 'sync' | 'wait'
|
|
animatePresenceInitial?: boolean
|
|
rotationInterval?: number
|
|
staggerDuration?: number
|
|
staggerFrom?: 'first' | 'last' | 'center' | 'random' | number
|
|
loop?: boolean
|
|
auto?: boolean
|
|
splitBy?: string
|
|
onNext?: (index: number) => void
|
|
mainClassName?: string
|
|
splitLevelClassName?: string
|
|
elementLevelClassName?: string
|
|
}
|
|
|
|
const RotatingText = forwardRef<RotatingTextRef, RotatingTextProps>(
|
|
(
|
|
{
|
|
texts,
|
|
transition = { type: 'spring', damping: 25, stiffness: 300 },
|
|
initial = { y: '100%', opacity: 0 },
|
|
animate = { y: 0, opacity: 1 },
|
|
exit = { y: '-120%', opacity: 0 },
|
|
animatePresenceMode = 'wait',
|
|
animatePresenceInitial = false,
|
|
rotationInterval = 2000,
|
|
staggerDuration = 0,
|
|
staggerFrom = 'first',
|
|
loop = true,
|
|
auto = true,
|
|
splitBy = 'characters',
|
|
onNext,
|
|
mainClassName,
|
|
splitLevelClassName,
|
|
elementLevelClassName,
|
|
...rest
|
|
},
|
|
ref
|
|
) => {
|
|
const [currentTextIndex, setCurrentTextIndex] = useState<number>(0)
|
|
|
|
const splitIntoCharacters = (text: string): string[] => {
|
|
return Array.from(text)
|
|
}
|
|
|
|
const elements = useMemo(() => {
|
|
const currentText: string = texts[currentTextIndex]
|
|
if (splitBy === 'characters') {
|
|
const words = currentText.split(' ')
|
|
return words.map((word, i) => ({
|
|
characters: splitIntoCharacters(word),
|
|
needsSpace: i !== words.length - 1
|
|
}))
|
|
}
|
|
if (splitBy === 'words') {
|
|
return currentText.split(' ').map((word, i, arr) => ({
|
|
characters: [word],
|
|
needsSpace: i !== arr.length - 1
|
|
}))
|
|
}
|
|
if (splitBy === 'lines') {
|
|
return currentText.split('\n').map((line, i, arr) => ({
|
|
characters: [line],
|
|
needsSpace: i !== arr.length - 1
|
|
}))
|
|
}
|
|
|
|
return currentText.split(splitBy).map((part, i, arr) => ({
|
|
characters: [part],
|
|
needsSpace: i !== arr.length - 1
|
|
}))
|
|
}, [texts, currentTextIndex, splitBy])
|
|
|
|
const getStaggerDelay = useCallback(
|
|
(index: number, totalChars: number): number => {
|
|
const total = totalChars
|
|
if (staggerFrom === 'first') return index * staggerDuration
|
|
if (staggerFrom === 'last') return (total - 1 - index) * staggerDuration
|
|
if (staggerFrom === 'center') {
|
|
const center = Math.floor(total / 2)
|
|
return Math.abs(center - index) * staggerDuration
|
|
}
|
|
if (staggerFrom === 'random') {
|
|
const randomIndex = Math.floor(Math.random() * total)
|
|
return Math.abs(randomIndex - index) * staggerDuration
|
|
}
|
|
return Math.abs((staggerFrom as number) - index) * staggerDuration
|
|
},
|
|
[staggerFrom, staggerDuration]
|
|
)
|
|
|
|
const handleIndexChange = useCallback(
|
|
(newIndex: number) => {
|
|
setCurrentTextIndex(newIndex)
|
|
if (onNext) onNext(newIndex)
|
|
},
|
|
[onNext]
|
|
)
|
|
|
|
const next = useCallback(() => {
|
|
const nextIndex =
|
|
currentTextIndex === texts.length - 1
|
|
? loop
|
|
? 0
|
|
: currentTextIndex
|
|
: currentTextIndex + 1
|
|
if (nextIndex !== currentTextIndex) {
|
|
handleIndexChange(nextIndex)
|
|
}
|
|
}, [currentTextIndex, texts.length, loop, handleIndexChange])
|
|
|
|
const previous = useCallback(() => {
|
|
const prevIndex =
|
|
currentTextIndex === 0
|
|
? loop
|
|
? texts.length - 1
|
|
: currentTextIndex
|
|
: currentTextIndex - 1
|
|
if (prevIndex !== currentTextIndex) {
|
|
handleIndexChange(prevIndex)
|
|
}
|
|
}, [currentTextIndex, texts.length, loop, handleIndexChange])
|
|
|
|
const jumpTo = useCallback(
|
|
(index: number) => {
|
|
const validIndex = Math.max(0, Math.min(index, texts.length - 1))
|
|
if (validIndex !== currentTextIndex) {
|
|
handleIndexChange(validIndex)
|
|
}
|
|
},
|
|
[texts.length, currentTextIndex, handleIndexChange]
|
|
)
|
|
|
|
const reset = useCallback(() => {
|
|
if (currentTextIndex !== 0) {
|
|
handleIndexChange(0)
|
|
}
|
|
}, [currentTextIndex, handleIndexChange])
|
|
|
|
useImperativeHandle(
|
|
ref,
|
|
() => ({
|
|
next,
|
|
previous,
|
|
jumpTo,
|
|
reset
|
|
}),
|
|
[next, previous, jumpTo, reset]
|
|
)
|
|
|
|
useEffect(() => {
|
|
if (!auto) return
|
|
const intervalId = setInterval(next, rotationInterval)
|
|
return () => clearInterval(intervalId)
|
|
}, [next, rotationInterval, auto])
|
|
|
|
return (
|
|
<motion.span
|
|
className={cn(
|
|
'flex flex-wrap whitespace-pre-wrap relative',
|
|
mainClassName
|
|
)}
|
|
{...rest}
|
|
layout
|
|
transition={transition}
|
|
>
|
|
<span className="sr-only">{texts[currentTextIndex]}</span>
|
|
<AnimatePresence
|
|
mode={animatePresenceMode}
|
|
initial={animatePresenceInitial}
|
|
>
|
|
<motion.div
|
|
key={currentTextIndex}
|
|
className={cn(
|
|
splitBy === 'lines'
|
|
? 'flex flex-col w-full'
|
|
: 'flex flex-wrap whitespace-pre-wrap relative'
|
|
)}
|
|
layout
|
|
aria-hidden="true"
|
|
initial={initial as HTMLMotionProps<'div'>['initial']}
|
|
animate={animate as HTMLMotionProps<'div'>['animate']}
|
|
exit={exit as HTMLMotionProps<'div'>['exit']}
|
|
>
|
|
{elements.map((wordObj, wordIndex, array) => {
|
|
const previousCharsCount = array
|
|
.slice(0, wordIndex)
|
|
.reduce((sum, word) => sum + word.characters.length, 0)
|
|
return (
|
|
<span
|
|
key={wordIndex}
|
|
className={cn('inline-flex', splitLevelClassName)}
|
|
>
|
|
{wordObj.characters.map((char, charIndex) => (
|
|
<motion.span
|
|
key={charIndex}
|
|
initial={initial as HTMLMotionProps<'span'>['initial']}
|
|
animate={animate as HTMLMotionProps<'span'>['animate']}
|
|
exit={exit as HTMLMotionProps<'span'>['exit']}
|
|
transition={{
|
|
...transition,
|
|
delay: getStaggerDelay(
|
|
previousCharsCount + charIndex,
|
|
array.reduce(
|
|
(sum, word) => sum + word.characters.length,
|
|
0
|
|
)
|
|
)
|
|
}}
|
|
className={cn('inline-block', elementLevelClassName)}
|
|
>
|
|
{char}
|
|
</motion.span>
|
|
))}
|
|
{wordObj.needsSpace && (
|
|
<span className="whitespace-pre"> </span>
|
|
)}
|
|
</span>
|
|
)
|
|
})}
|
|
</motion.div>
|
|
</AnimatePresence>
|
|
</motion.span>
|
|
)
|
|
}
|
|
)
|
|
|
|
RotatingText.displayName = 'RotatingText'
|
|
export default RotatingText
|