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 = ( { text, className, truncateOnMobile = false, title }, ) => { const behaviourSettings = useClientBehaviourSettings () const animationMode = behaviourSettings.animation ?? 'normal' const outerRef = useRef (null) const staticRef = useRef (null) const animatedRef = useRef (null) const animationRef = useRef (null) const timeoutRef = useRef (null) const rafRef = useRef (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 (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 (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 ( 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)}> {text} {overflowPx >= MIN_MARQUEE_OVERFLOW_PX && ( )} ) } export default ResponsiveMarqueeText