// 自绘滚动条容器:原生滚动条彻底隐藏(零占位——出现/消失永不挤版面,也绕开 Blink // 对滚动条伪元素动态切换不重绘的 quirk),自绘 thumb 悬浮在内衬/列间隔上。 // 行为契约:鼠标移入滚动区立即现身;移出 / 停止滚动 800ms 后淡出;thumb 可拖拽。 // 用法:外层 .jc-sa 接管原容器的布局角色(className 传 jc-sa--fill / jc-sa--full), // 原滚动容器的类名整体挪到 vpClassName(视口 = 真正滚动的元素,样式不变); // inset = thumb 距右缘 px(负值 = 伸出容器、悬进列间 gutter)。 import { useCallback, useEffect, useRef } from "react"; const HIDE_MS = 800; export default function ScrollArea({ className = "", vpClassName = "", inset = 3, scrollRef, children }: { className?: string; vpClassName?: string; inset?: number; scrollRef?: React.Ref; // 上层需要直接操作滚动位置时(如会话流自动滚底) children: React.ReactNode; }) { const vpRef = useRef(null); const thRef = useRef(null); const hideT = useRef(0); const st = useRef({ inside: false, dragging: false, startY: 0, startTop: 0 }); // 按滚动几何同步 thumb 高度与位置;不可滚时整体隐藏 const sync = useCallback(() => { const vp = vpRef.current, th = thRef.current; if (!vp || !th) return; const { scrollTop, scrollHeight, clientHeight } = vp; if (scrollHeight <= clientHeight + 1) { th.style.display = "none"; return; } th.style.display = ""; const h = Math.max(28, Math.round((clientHeight / scrollHeight) * clientHeight)); const y = Math.round((scrollTop / (scrollHeight - clientHeight)) * (clientHeight - h)); th.style.height = `${h}px`; th.style.transform = `translateY(${y}px)`; }, []); // 移出/停止滚动后延时淡出(悬停内 / 拖拽中不隐藏) const hideSoon = useCallback(() => { window.clearTimeout(hideT.current); hideT.current = window.setTimeout(() => { if (!st.current.inside && !st.current.dragging) thRef.current?.classList.remove("on"); }, HIDE_MS); }, []); const show = useCallback(() => { sync(); thRef.current?.classList.add("on"); hideSoon(); }, [sync, hideSoon]); useEffect(() => { const vp = vpRef.current; if (!vp) return; const ro = new ResizeObserver(sync); // 面板拖宽/视口变化时校准 ro.observe(vp); sync(); return () => { ro.disconnect(); window.clearTimeout(hideT.current); }; }, [sync]); // 标准 ref 转发(写入外部 ref.current 是 ref 的本职),compiler 规则误报 const setVp = useCallback((el: HTMLDivElement | null) => { vpRef.current = el; if (typeof scrollRef === "function") scrollRef(el); else if (scrollRef) (scrollRef as React.MutableRefObject).current = el; }, [scrollRef]); // thumb 拖拽滚动 const onThumbDown = (e: React.PointerEvent) => { const vp = vpRef.current; if (!vp) return; e.preventDefault(); st.current = { ...st.current, dragging: true, startY: e.clientY, startTop: vp.scrollTop }; thRef.current?.classList.add("on"); const move = (ev: PointerEvent) => { const { scrollHeight, clientHeight } = vp; const h = thRef.current?.offsetHeight ?? 28; vp.scrollTop = st.current.startTop + (ev.clientY - st.current.startY) * ((scrollHeight - clientHeight) / Math.max(1, clientHeight - h)); }; const up = () => { st.current.dragging = false; window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", up); hideSoon(); }; window.addEventListener("pointermove", move); window.addEventListener("pointerup", up); }; return (
{ st.current.inside = true; show(); }} onPointerLeave={() => { st.current.inside = false; hideSoon(); }} >
{children}
); }