118 lines
3.1 KiB
TypeScript
118 lines
3.1 KiB
TypeScript
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
|
|
|
type OverflowMarqueeProps = {
|
|
children: React.ReactNode;
|
|
className?: string;
|
|
textClassName?: string;
|
|
title?: string;
|
|
speedPxPerSec?: number;
|
|
minDurationSec?: number;
|
|
};
|
|
|
|
type MarqueeMetrics = {
|
|
isOverflowing: boolean;
|
|
overflowPx: number;
|
|
durationSec: number;
|
|
};
|
|
|
|
const DEFAULT_SPEED_PX_PER_SEC = 24;
|
|
const DEFAULT_MIN_DURATION_SEC = 4;
|
|
|
|
const OverflowMarquee: React.FC<OverflowMarqueeProps> = ({
|
|
children,
|
|
className,
|
|
textClassName,
|
|
title,
|
|
speedPxPerSec = DEFAULT_SPEED_PX_PER_SEC,
|
|
minDurationSec = DEFAULT_MIN_DURATION_SEC,
|
|
}) => {
|
|
const containerRef = useRef<HTMLSpanElement>(null);
|
|
const textRef = useRef<HTMLSpanElement>(null);
|
|
const [metrics, setMetrics] = useState<MarqueeMetrics>({
|
|
isOverflowing: false,
|
|
overflowPx: 0,
|
|
durationSec: minDurationSec,
|
|
});
|
|
|
|
const fallbackTitle = useMemo(() => {
|
|
if (title) return title;
|
|
return typeof children === 'string' ? children : undefined;
|
|
}, [children, title]);
|
|
|
|
const recompute = () => {
|
|
const container = containerRef.current;
|
|
const text = textRef.current;
|
|
if (!container || !text) return;
|
|
|
|
// Ensure we measure with current layout.
|
|
const available = container.clientWidth;
|
|
const content = text.scrollWidth;
|
|
const overflowPx = Math.ceil(content - available);
|
|
|
|
if (overflowPx > 1) {
|
|
const durationSec = Math.max(minDurationSec, overflowPx / Math.max(1, speedPxPerSec));
|
|
setMetrics({ isOverflowing: true, overflowPx, durationSec });
|
|
} else {
|
|
// Avoid re-render loops if already not overflowing.
|
|
setMetrics((prev) => (prev.isOverflowing ? { isOverflowing: false, overflowPx: 0, durationSec: minDurationSec } : prev));
|
|
}
|
|
};
|
|
|
|
useLayoutEffect(() => {
|
|
recompute();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [children]);
|
|
|
|
useEffect(() => {
|
|
recompute();
|
|
|
|
const container = containerRef.current;
|
|
const text = textRef.current;
|
|
if (!container || !text) return;
|
|
|
|
const ro = new ResizeObserver(() => recompute());
|
|
ro.observe(container);
|
|
ro.observe(text);
|
|
|
|
const onResize = () => recompute();
|
|
window.addEventListener('resize', onResize);
|
|
|
|
return () => {
|
|
ro.disconnect();
|
|
window.removeEventListener('resize', onResize);
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
const textStyle: React.CSSProperties | undefined = metrics.isOverflowing
|
|
? ({
|
|
['--marquee-distance' as any]: `${metrics.overflowPx}px`,
|
|
['--marquee-duration' as any]: `${metrics.durationSec}s`,
|
|
} satisfies React.CSSProperties)
|
|
: undefined;
|
|
|
|
return (
|
|
<span
|
|
ref={containerRef}
|
|
className={['overflow-marquee', className].filter(Boolean).join(' ')}
|
|
title={fallbackTitle}
|
|
>
|
|
<span
|
|
ref={textRef}
|
|
className={[
|
|
'overflow-marquee__text',
|
|
metrics.isOverflowing ? 'overflow-marquee__text--animate' : '',
|
|
textClassName,
|
|
]
|
|
.filter(Boolean)
|
|
.join(' ')}
|
|
style={textStyle}
|
|
>
|
|
{children}
|
|
</span>
|
|
</span>
|
|
);
|
|
};
|
|
|
|
export default OverflowMarquee;
|