mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
7a7a9f5e6b
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.
179 lines
6.2 KiB
TypeScript
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>
|
|
);
|
|
}
|