f5b632ed89
Reviewed-on: #397 Co-authored-by: miteruzo <miteruzo@naver.com> Co-committed-by: miteruzo <miteruzo@naver.com>
255 行
6.9 KiB
TypeScript
255 行
6.9 KiB
TypeScript
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
|
|
import { cn } from '@/lib/utils'
|
|
import { useEffect, useRef, useState } from 'react'
|
|
|
|
import type { FC } from 'react'
|
|
|
|
type Props = {
|
|
text: string
|
|
className?: string
|
|
truncateOnMobile?: boolean
|
|
title?: string }
|
|
|
|
const DESKTOP_MARQUEE_MEDIA =
|
|
'(min-width: 768px) and (hover: hover) and (pointer: fine) and (prefers-reduced-motion: no-preference)'
|
|
const MARQUEE_START_DELAY_MS = 1600
|
|
const MARQUEE_END_HOLD_MS = 2400
|
|
const MARQUEE_SCROLL_PX_PER_SECOND = 43
|
|
const MIN_MARQUEE_OVERFLOW_PX = 1
|
|
|
|
|
|
const ResponsiveMarqueeText: FC<Props> = (
|
|
{ text, className, truncateOnMobile = false, title },
|
|
) => {
|
|
const behaviourSettings = useClientBehaviourSettings ()
|
|
const animationMode = behaviourSettings.animation ?? 'normal'
|
|
const outerRef = useRef<HTMLSpanElement | null> (null)
|
|
const staticRef = useRef<HTMLSpanElement | null> (null)
|
|
const animatedRef = useRef<HTMLSpanElement | null> (null)
|
|
const animationRef = useRef<Animation | null> (null)
|
|
const timeoutRef = useRef<number | null> (null)
|
|
const rafRef = useRef<number | null> (null)
|
|
const [overflowPx, setOverflowPx] = useState (0)
|
|
const [desktopMarqueeEnabled, setDesktopMarqueeEnabled] = useState (false)
|
|
const [active, setActive] = useState (false)
|
|
const [marqueeVisible, setMarqueeVisible] = useState (false)
|
|
|
|
useEffect (() => {
|
|
const outer = outerRef.current
|
|
const inner = staticRef.current
|
|
|
|
if (!(outer) || !(inner))
|
|
return
|
|
|
|
const measure = () => {
|
|
const nextOverflow = Math.max (0, Math.ceil (inner.scrollWidth - outer.clientWidth))
|
|
setOverflowPx (prev => Math.abs (prev - nextOverflow) <= 1 ? prev : nextOverflow)
|
|
}
|
|
|
|
measure ()
|
|
|
|
const resizeObserver =
|
|
typeof ResizeObserver === 'undefined'
|
|
? null
|
|
: new ResizeObserver (() => {
|
|
measure ()
|
|
})
|
|
|
|
resizeObserver?.observe (outer)
|
|
resizeObserver?.observe (inner)
|
|
addEventListener ('resize', measure)
|
|
|
|
return () => {
|
|
resizeObserver?.disconnect ()
|
|
removeEventListener ('resize', measure)
|
|
}
|
|
}, [text])
|
|
|
|
useEffect (() => {
|
|
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function')
|
|
return
|
|
|
|
const media = window.matchMedia (DESKTOP_MARQUEE_MEDIA)
|
|
const update = () => {
|
|
setDesktopMarqueeEnabled (media.matches)
|
|
}
|
|
|
|
update ()
|
|
media.addEventListener ('change', update)
|
|
|
|
return () => {
|
|
media.removeEventListener ('change', update)
|
|
}
|
|
}, [])
|
|
|
|
useEffect (() => {
|
|
const clearScheduled = () => {
|
|
if (timeoutRef.current != null)
|
|
{
|
|
clearTimeout (timeoutRef.current)
|
|
timeoutRef.current = null
|
|
}
|
|
if (rafRef.current != null)
|
|
{
|
|
cancelAnimationFrame (rafRef.current)
|
|
rafRef.current = null
|
|
}
|
|
animationRef.current?.cancel ()
|
|
animationRef.current = null
|
|
}
|
|
|
|
const animated = animatedRef.current
|
|
const canMarquee = (
|
|
active
|
|
&& animationMode !== 'off'
|
|
&& desktopMarqueeEnabled
|
|
&& overflowPx >= MIN_MARQUEE_OVERFLOW_PX
|
|
&& animated != null)
|
|
|
|
const resetAnimated = () => {
|
|
const node = animatedRef.current
|
|
|
|
animationRef.current?.cancel ()
|
|
animationRef.current = null
|
|
node?.getAnimations?.().forEach (animation => {
|
|
animation.cancel ()
|
|
})
|
|
|
|
if (!(node))
|
|
return
|
|
|
|
node.style.transition = 'none'
|
|
node.style.transform = 'translateX(0)'
|
|
}
|
|
|
|
if (!canMarquee)
|
|
{
|
|
clearScheduled ()
|
|
resetAnimated ()
|
|
setMarqueeVisible (false)
|
|
return
|
|
}
|
|
|
|
let cancelled = false
|
|
|
|
const sleep = (ms: number) =>
|
|
new Promise<void> (resolve => {
|
|
timeoutRef.current = window.setTimeout (() => {
|
|
timeoutRef.current = null
|
|
resolve ()
|
|
}, ms)
|
|
})
|
|
|
|
const runLoop = async () => {
|
|
while (!cancelled)
|
|
{
|
|
resetAnimated ()
|
|
setMarqueeVisible (true)
|
|
|
|
await sleep (MARQUEE_START_DELAY_MS)
|
|
if (cancelled || !(animatedRef.current))
|
|
break
|
|
|
|
const moveDurationMs =
|
|
overflowPx / MARQUEE_SCROLL_PX_PER_SECOND * 1000
|
|
const node = animatedRef.current
|
|
|
|
if (typeof node.animate === 'function')
|
|
{
|
|
const animation = node.animate (
|
|
[
|
|
{ transform: 'translateX(0)' },
|
|
{ transform: `translateX(-${ overflowPx }px)` },
|
|
],
|
|
{ duration: moveDurationMs, easing: 'linear', fill: 'forwards' })
|
|
animationRef.current = animation
|
|
try
|
|
{
|
|
await animation.finished
|
|
}
|
|
catch
|
|
{
|
|
break
|
|
}
|
|
|
|
node.style.transform = `translateX(-${ overflowPx }px)`
|
|
animation.cancel ()
|
|
if (animationRef.current === animation)
|
|
animationRef.current = null
|
|
}
|
|
else
|
|
{
|
|
node.style.transition = `transform ${ moveDurationMs }ms linear`
|
|
await new Promise<void> (resolve => {
|
|
rafRef.current = requestAnimationFrame (() => {
|
|
rafRef.current = null
|
|
node.style.transform = `translateX(-${ overflowPx }px)`
|
|
resolve ()
|
|
})
|
|
})
|
|
await sleep (moveDurationMs)
|
|
node.style.transition = 'none'
|
|
node.style.transform = `translateX(-${ overflowPx }px)`
|
|
}
|
|
|
|
if (cancelled)
|
|
break
|
|
|
|
await sleep (MARQUEE_END_HOLD_MS)
|
|
if (cancelled)
|
|
break
|
|
|
|
resetAnimated ()
|
|
}
|
|
}
|
|
|
|
void runLoop ()
|
|
|
|
return () => {
|
|
cancelled = true
|
|
clearScheduled ()
|
|
resetAnimated ()
|
|
setMarqueeVisible (false)
|
|
}
|
|
}, [active, animationMode, desktopMarqueeEnabled, overflowPx, text])
|
|
|
|
return (
|
|
<span
|
|
ref={outerRef}
|
|
title={title ?? text}
|
|
onMouseEnter={() => setActive (true)}
|
|
onMouseLeave={() => setActive (false)}
|
|
onFocus={() => setActive (true)}
|
|
onBlur={() => setActive (false)}
|
|
className={cn (
|
|
'tag-marquee inline-block max-w-full min-w-0 align-bottom',
|
|
(truncateOnMobile
|
|
? 'overflow-hidden text-ellipsis whitespace-nowrap'
|
|
: 'whitespace-normal [overflow-wrap:anywhere]'),
|
|
'md:overflow-hidden md:whitespace-nowrap',
|
|
className)}>
|
|
<span
|
|
ref={staticRef}
|
|
className={cn (
|
|
'tag-marquee__static block max-w-full',
|
|
(truncateOnMobile
|
|
? 'overflow-hidden text-ellipsis whitespace-nowrap'
|
|
: 'whitespace-normal [overflow-wrap:anywhere]'),
|
|
'md:overflow-hidden md:text-ellipsis md:whitespace-nowrap',
|
|
marqueeVisible && 'md:opacity-0')}>
|
|
{text}
|
|
</span>
|
|
{overflowPx >= MIN_MARQUEE_OVERFLOW_PX && (
|
|
<span
|
|
ref={animatedRef}
|
|
aria-hidden="true"
|
|
className={cn (
|
|
'tag-marquee__animated hidden md:block',
|
|
marqueeVisible ? 'md:opacity-100' : 'md:opacity-0')}>
|
|
{text}
|
|
</span>)}
|
|
</span>)
|
|
}
|
|
|
|
export default ResponsiveMarqueeText
|