Files
PlexPlaylistSync/frontend/components/OverflowMarquee.tsx
T

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;