Files
psysonic/src/components/OverlayScrollArea.tsx
T
Frank Stellmacher 7a7a9f5e6b refactor(utils): group utils/ files into topic folders (Phase L, part 1) (#689)
111 of 122 top-level src/utils/ files move into 16 topic folders (audio,
cache, cover, share, server, playback, playlist, deviceSync, waveform,
mix, format, export, changelog, ui, perf, componentHelpers). True
singletons with no cluster stay at the utils/ root.

Pure file-move: a path-aware codemod rewrote 539 relative-import
specifiers across 275 files; no logic touched. The hot-path coverage
gate list (.github/frontend-hot-path-files.txt) is updated to the new
paths for the 11 gated utils files — a mechanical consequence of the
move, not a CI change. tsc is green.
2026-05-14 14:27:44 +02:00

179 lines
6.2 KiB
TypeScript

import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { computeOverlayScrollbarThumbMeta } from '../utils/ui/overlayScrollbarMetrics';
import { bindOverlayScrollbarThumbDrag } from '../utils/ui/overlayScrollbarThumb';
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
export type OverlayScrollRailInset = 'none' | 'mini' | 'panel';
export type OverlayScrollAreaProps = {
children: React.ReactNode;
/** Optional handler on the outer wrapper (e.g. mini queue DnD hit-testing). */
onMouseMove?: React.MouseEventHandler<HTMLDivElement>;
/** Classes on the outer wrapper (e.g. queue-list-wrap, mini-queue-wrap). */
className?: string;
/** Classes on the scrollable viewport (e.g. queue-list, mini-queue). */
viewportClassName?: string;
/** Serialized internally — triggers remeasure + ResizeObserver refresh. */
measureDeps?: ReadonlyArray<unknown>;
/** Vertical inset of the hit rail (align with viewport padding). */
railInset?: OverlayScrollRailInset;
/** e.g. during native DnD — scroll-behavior: auto on the viewport. */
viewportScrollBehaviorAuto?: boolean;
/** Ref to the scrollable element (querySelector, scrollIntoView, etc.). */
viewportRef?: React.Ref<HTMLDivElement>;
/** Ref to the outer wrapper (incl. overlay scrollbar rail). */
wrapRef?: React.Ref<HTMLDivElement>;
/** Optional id on the viewport (e.g. main app scroll for route pages). */
viewportId?: string;
/** Optional wheel handler on the scrollable viewport. */
viewportOnWheel?: React.WheelEventHandler<HTMLDivElement>;
/** Optional touch-move handler on the scrollable viewport. */
viewportOnTouchMove?: React.TouchEventHandler<HTMLDivElement>;
};
const RAIL_INSET_CLASS: Record<OverlayScrollRailInset, string> = {
none: 'overlay-scroll--rail-inset-none',
mini: 'overlay-scroll--rail-inset-mini',
panel: 'overlay-scroll--rail-inset-panel',
};
function assignRef<T>(ref: React.Ref<T> | undefined, value: T) {
if (ref == null) return;
if (typeof ref === 'function') ref(value);
else (ref as { current: T | null }).current = value;
}
export default function OverlayScrollArea({
children,
onMouseMove,
className = '',
viewportClassName = '',
measureDeps = [],
railInset = 'none',
viewportScrollBehaviorAuto = false,
viewportRef: viewportRefProp,
wrapRef: wrapRefProp,
viewportId,
viewportOnWheel,
viewportOnTouchMove,
}: OverlayScrollAreaProps) {
const perfFlags = usePerfProbeFlags();
const wrapRef = useRef<HTMLDivElement | null>(null);
const viewportRef = useRef<HTMLDivElement | null>(null);
/** Coalesce burst scroll events to one thumb update per animation frame. */
const scrollRecomputeRafRef = useRef(0);
const [meta, setMeta] = useState({ thumbH: 0, thumbT: 0, visible: false });
const recompute = useCallback(() => {
const vp = viewportRef.current;
const wrap = wrapRef.current;
const rail = wrap?.querySelector<HTMLElement>('.overlay-scroll__rail');
const trackH =
rail && rail.clientHeight > 0 ? rail.clientHeight : undefined;
setMeta(computeOverlayScrollbarThumbMeta(vp, trackH));
}, []);
const scheduleScrollRecompute = useCallback(() => {
if (scrollRecomputeRafRef.current !== 0) return;
scrollRecomputeRafRef.current = requestAnimationFrame(() => {
scrollRecomputeRafRef.current = 0;
recompute();
});
}, [recompute]);
useEffect(() => () => {
if (scrollRecomputeRafRef.current !== 0) {
cancelAnimationFrame(scrollRecomputeRafRef.current);
scrollRecomputeRafRef.current = 0;
}
}, []);
const measureKey = JSON.stringify(measureDeps ?? []);
useLayoutEffect(() => {
if (perfFlags.disableOverlayScrollbars) return;
if (!meta.visible) return;
const vp = viewportRef.current;
const wrap = wrapRef.current;
const rail = wrap?.querySelector<HTMLElement>('.overlay-scroll__rail');
const th = rail?.clientHeight;
if (!vp || !th || th <= 0) return;
setMeta((prev) => {
const next = computeOverlayScrollbarThumbMeta(vp, th);
if (
prev.thumbH === next.thumbH &&
prev.thumbT === next.thumbT &&
prev.visible === next.visible
) {
return prev;
}
return next;
});
}, [meta.visible, perfFlags.disableOverlayScrollbars]);
useEffect(() => {
if (perfFlags.disableOverlayScrollbars) return;
recompute();
const wrap = wrapRef.current;
const onWinResize = () => recompute();
window.addEventListener('resize', onWinResize);
const ro =
typeof ResizeObserver !== 'undefined' && wrap
? new ResizeObserver(() => recompute())
: null;
if (ro && wrap) ro.observe(wrap);
return () => {
window.removeEventListener('resize', onWinResize);
ro?.disconnect();
};
}, [recompute, measureKey, perfFlags.disableOverlayScrollbars]);
const setViewportNode = (el: HTMLDivElement | null) => {
viewportRef.current = el;
assignRef(viewportRefProp, el);
};
const setWrapNode = (el: HTMLDivElement | null) => {
wrapRef.current = el;
assignRef(wrapRefProp, el);
};
const rootClass = [
'overlay-scroll',
RAIL_INSET_CLASS[railInset],
viewportScrollBehaviorAuto ? 'overlay-scroll--viewport-scroll-auto' : '',
className,
]
.filter(Boolean)
.join(' ');
const viewportClass = ['overlay-scroll__viewport', viewportClassName].filter(Boolean).join(' ');
return (
<div ref={setWrapNode} className={rootClass} onMouseMove={onMouseMove}>
<div
id={viewportId}
ref={setViewportNode}
className={viewportClass}
onScroll={perfFlags.disableOverlayScrollbars ? undefined : scheduleScrollRecompute}
onWheel={viewportOnWheel}
onTouchMove={viewportOnTouchMove}
>
{children}
</div>
{!perfFlags.disableOverlayScrollbars && meta.visible && (
<div className="overlay-scroll__rail" aria-hidden>
<div
className="overlay-scroll__thumb"
style={{
height: `${meta.thumbH}px`,
transform: `translateY(${meta.thumbT}px)`,
}}
onPointerDown={(ev) => bindOverlayScrollbarThumbDrag(ev, viewportRef.current)}
/>
</div>
)}
</div>
);
}