Files
planet/frontend/src/components/Scrollbar/Scrollbar.tsx
2026-04-14 17:34:04 +08:00

304 lines
9.6 KiB
TypeScript

import {
type PointerEvent as ReactPointerEvent,
type ReactNode,
useCallback,
useEffect,
useRef,
useState,
} from 'react'
type Axis = 'x' | 'y'
interface AxisState {
visible: boolean
dragging: boolean
thumbSize: number
thumbOffset: number
}
interface ScrollbarState {
x: AxisState
y: AxisState
}
interface ScrollbarProps {
children: ReactNode
className?: string
minThumbSize?: number
}
interface DragState {
axis: Axis
pointerId: number
pointerOffset: number
}
const INITIAL_AXIS_STATE: AxisState = {
visible: false,
dragging: false,
thumbSize: 0,
thumbOffset: 0,
}
const INITIAL_STATE: ScrollbarState = {
x: INITIAL_AXIS_STATE,
y: INITIAL_AXIS_STATE,
}
function Scrollbar({
children,
className = '',
minThumbSize = 28,
}: ScrollbarProps) {
const viewportRef = useRef<HTMLDivElement | null>(null)
const trackXRef = useRef<HTMLDivElement | null>(null)
const trackYRef = useRef<HTMLDivElement | null>(null)
const dragStateRef = useRef<DragState | null>(null)
const [scrollbar, setScrollbar] = useState<ScrollbarState>(INITIAL_STATE)
const updateScrollbar = useCallback(() => {
const viewport = viewportRef.current
const trackX = trackXRef.current
const trackY = trackYRef.current
if (!viewport || !trackX || !trackY) return
const {
clientWidth,
clientHeight,
scrollWidth,
scrollHeight,
scrollLeft,
scrollTop,
} = viewport
const hasOverflowX = scrollWidth - clientWidth > 1
const hasOverflowY = scrollHeight - clientHeight > 1
const nextX: AxisState = hasOverflowX && clientWidth > 0 && trackX.clientWidth > 0
? (() => {
const thumbSize = Math.max(minThumbSize, (clientWidth / scrollWidth) * trackX.clientWidth)
const maxThumbOffset = Math.max(0, trackX.clientWidth - thumbSize)
const scrollRange = Math.max(1, scrollWidth - clientWidth)
return {
visible: true,
dragging: dragStateRef.current?.axis === 'x',
thumbSize,
thumbOffset: (scrollLeft / scrollRange) * maxThumbOffset,
}
})()
: { ...INITIAL_AXIS_STATE }
const nextY: AxisState = hasOverflowY && clientHeight > 0 && trackY.clientHeight > 0
? (() => {
const thumbSize = Math.max(minThumbSize, (clientHeight / scrollHeight) * trackY.clientHeight)
const maxThumbOffset = Math.max(0, trackY.clientHeight - thumbSize)
const scrollRange = Math.max(1, scrollHeight - clientHeight)
return {
visible: true,
dragging: dragStateRef.current?.axis === 'y',
thumbSize,
thumbOffset: (scrollTop / scrollRange) * maxThumbOffset,
}
})()
: { ...INITIAL_AXIS_STATE }
setScrollbar({ x: nextX, y: nextY })
}, [minThumbSize])
useEffect(() => {
const viewport = viewportRef.current
const trackX = trackXRef.current
const trackY = trackYRef.current
if (!viewport || !trackX || !trackY) return
let rafId = 0
const scheduleUpdate = () => {
cancelAnimationFrame(rafId)
rafId = window.requestAnimationFrame(() => {
updateScrollbar()
})
}
updateScrollbar()
scheduleUpdate()
const resizeObserver = new ResizeObserver(() => {
scheduleUpdate()
})
const mutationObserver = new MutationObserver(() => {
scheduleUpdate()
})
resizeObserver.observe(viewport)
resizeObserver.observe(trackX)
resizeObserver.observe(trackY)
Array.from(viewport.children).forEach((child) => resizeObserver.observe(child))
mutationObserver.observe(viewport, {
childList: true,
subtree: true,
attributes: true,
characterData: true,
})
viewport.addEventListener('scroll', scheduleUpdate, { passive: true })
window.addEventListener('resize', scheduleUpdate)
return () => {
cancelAnimationFrame(rafId)
resizeObserver.disconnect()
mutationObserver.disconnect()
viewport.removeEventListener('scroll', scheduleUpdate)
window.removeEventListener('resize', scheduleUpdate)
}
}, [children, updateScrollbar])
useEffect(() => {
const stopDragging = () => {
dragStateRef.current = null
document.body.style.userSelect = ''
setScrollbar((previous) => ({
x: { ...previous.x, dragging: false },
y: { ...previous.y, dragging: false },
}))
}
window.addEventListener('pointerup', stopDragging)
window.addEventListener('pointercancel', stopDragging)
return () => {
window.removeEventListener('pointerup', stopDragging)
window.removeEventListener('pointercancel', stopDragging)
}
}, [])
const syncScrollFromThumbOffset = (axis: Axis, nextThumbOffset: number) => {
const viewport = viewportRef.current
const track = axis === 'x' ? trackXRef.current : trackYRef.current
const axisState = scrollbar[axis]
if (!viewport || !track) return
const trackSize = axis === 'x' ? track.clientWidth : track.clientHeight
const maxThumbOffset = Math.max(0, trackSize - axisState.thumbSize)
if (maxThumbOffset <= 0) {
if (axis === 'x') viewport.scrollLeft = 0
else viewport.scrollTop = 0
return
}
const progress = Math.max(0, Math.min(nextThumbOffset, maxThumbOffset)) / maxThumbOffset
if (axis === 'x') {
viewport.scrollLeft = progress * Math.max(0, viewport.scrollWidth - viewport.clientWidth)
return
}
viewport.scrollTop = progress * Math.max(0, viewport.scrollHeight - viewport.clientHeight)
}
const handleThumbPointerDown = (axis: Axis) => (event: ReactPointerEvent<HTMLDivElement>) => {
if (!scrollbar[axis].visible) return
const rect = event.currentTarget.getBoundingClientRect()
dragStateRef.current = {
axis,
pointerId: event.pointerId,
pointerOffset: axis === 'x' ? event.clientX - rect.left : event.clientY - rect.top,
}
setScrollbar((previous) => ({
x: { ...previous.x, dragging: axis === 'x' },
y: { ...previous.y, dragging: axis === 'y' },
}))
document.body.style.userSelect = 'none'
event.currentTarget.setPointerCapture(event.pointerId)
event.stopPropagation()
event.preventDefault()
}
const handleThumbPointerMove = (axis: Axis) => (event: ReactPointerEvent<HTMLDivElement>) => {
const dragState = dragStateRef.current
const track = axis === 'x' ? trackXRef.current : trackYRef.current
if (!dragState || dragState.axis !== axis || dragState.pointerId !== event.pointerId || !track) return
const rect = track.getBoundingClientRect()
const nextThumbOffset = axis === 'x'
? event.clientX - rect.left - dragState.pointerOffset
: event.clientY - rect.top - dragState.pointerOffset
syncScrollFromThumbOffset(axis, nextThumbOffset)
}
const handleThumbPointerUp = (axis: Axis) => (event: ReactPointerEvent<HTMLDivElement>) => {
const dragState = dragStateRef.current
if (!dragState || dragState.axis !== axis || dragState.pointerId !== event.pointerId) return
dragStateRef.current = null
document.body.style.userSelect = ''
setScrollbar((previous) => ({
x: { ...previous.x, dragging: false },
y: { ...previous.y, dragging: false },
}))
event.currentTarget.releasePointerCapture?.(event.pointerId)
}
const handleTrackPointerDown = (axis: Axis) => (event: ReactPointerEvent<HTMLDivElement>) => {
if (!scrollbar[axis].visible || event.target !== event.currentTarget) return
const rect = event.currentTarget.getBoundingClientRect()
const nextThumbOffset = axis === 'x'
? event.clientX - rect.left - scrollbar.x.thumbSize / 2
: event.clientY - rect.top - scrollbar.y.thumbSize / 2
syncScrollFromThumbOffset(axis, nextThumbOffset)
}
const hasCorner = scrollbar.x.visible && scrollbar.y.visible
return (
<div className={['scrollbar', className].filter(Boolean).join(' ')}>
<div ref={viewportRef} className="scrollbar__viewport">
{children}
</div>
<div
ref={trackYRef}
className={`scrollbar__track scrollbar__track--y${scrollbar.y.visible ? ' scrollbar__track--visible' : ''}${scrollbar.y.dragging ? ' scrollbar__track--dragging' : ''}${hasCorner ? ' scrollbar__track--has-corner' : ''}`}
onPointerDown={handleTrackPointerDown('y')}
>
{scrollbar.y.visible ? (
<div
className="scrollbar__thumb scrollbar__thumb--y"
tabIndex={0}
style={{
height: `${scrollbar.y.thumbSize}px`,
transform: `translateY(${scrollbar.y.thumbOffset}px)`,
}}
onPointerDown={handleThumbPointerDown('y')}
onPointerMove={handleThumbPointerMove('y')}
onPointerUp={handleThumbPointerUp('y')}
/>
) : null}
</div>
<div
ref={trackXRef}
className={`scrollbar__track scrollbar__track--x${scrollbar.x.visible ? ' scrollbar__track--visible' : ''}${scrollbar.x.dragging ? ' scrollbar__track--dragging' : ''}${hasCorner ? ' scrollbar__track--has-corner' : ''}`}
onPointerDown={handleTrackPointerDown('x')}
>
{scrollbar.x.visible ? (
<div
className="scrollbar__thumb scrollbar__thumb--x"
tabIndex={0}
style={{
width: `${scrollbar.x.thumbSize}px`,
transform: `translateX(${scrollbar.x.thumbOffset}px)`,
}}
onPointerDown={handleThumbPointerDown('x')}
onPointerMove={handleThumbPointerMove('x')}
onPointerUp={handleThumbPointerUp('x')}
/>
) : null}
</div>
</div>
)
}
export default Scrollbar