326 lines
10 KiB
TypeScript
326 lines
10 KiB
TypeScript
import {
|
|
type PointerEvent as ReactPointerEvent,
|
|
type RefObject,
|
|
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 ScrollbarOverlayProps {
|
|
containerRef: RefObject<HTMLElement | null>
|
|
targetSelector: 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 ScrollbarOverlay({
|
|
containerRef,
|
|
targetSelector,
|
|
minThumbSize = 28,
|
|
}: ScrollbarOverlayProps) {
|
|
const trackXRef = useRef<HTMLDivElement | null>(null)
|
|
const trackYRef = useRef<HTMLDivElement | null>(null)
|
|
const dragStateRef = useRef<DragState | null>(null)
|
|
const targetRef = useRef<HTMLElement | null>(null)
|
|
const [scrollbar, setScrollbar] = useState<ScrollbarState>(INITIAL_STATE)
|
|
|
|
const bindTarget = useCallback(() => {
|
|
const container = containerRef.current
|
|
if (!container) {
|
|
targetRef.current = null
|
|
return null
|
|
}
|
|
|
|
const target = container.querySelector<HTMLElement>(targetSelector)
|
|
targetRef.current = target
|
|
return target
|
|
}, [containerRef, targetSelector])
|
|
|
|
const updateScrollbar = useCallback(() => {
|
|
const target = targetRef.current ?? bindTarget()
|
|
const trackX = trackXRef.current
|
|
const trackY = trackYRef.current
|
|
if (!target || !trackX || !trackY) return
|
|
|
|
const {
|
|
clientWidth,
|
|
clientHeight,
|
|
scrollWidth,
|
|
scrollHeight,
|
|
scrollLeft,
|
|
scrollTop,
|
|
} = target
|
|
|
|
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 })
|
|
}, [bindTarget, minThumbSize])
|
|
|
|
useEffect(() => {
|
|
const container = containerRef.current
|
|
if (!container) return
|
|
|
|
let cleanupTargetScroll: (() => void) | null = null
|
|
let resizeObserver: ResizeObserver | null = null
|
|
let mutationObserver: MutationObserver | null = null
|
|
let rafId = 0
|
|
|
|
const scheduleUpdate = () => {
|
|
cancelAnimationFrame(rafId)
|
|
rafId = window.requestAnimationFrame(() => {
|
|
updateScrollbar()
|
|
})
|
|
}
|
|
|
|
const attachTarget = () => {
|
|
cleanupTargetScroll?.()
|
|
resizeObserver?.disconnect()
|
|
|
|
const target = bindTarget()
|
|
if (!target) {
|
|
targetRef.current = null
|
|
return
|
|
}
|
|
|
|
const handleScroll = () => {
|
|
scheduleUpdate()
|
|
}
|
|
|
|
target.addEventListener('scroll', handleScroll, { passive: true })
|
|
cleanupTargetScroll = () => {
|
|
target.removeEventListener('scroll', handleScroll)
|
|
}
|
|
|
|
resizeObserver = new ResizeObserver(() => {
|
|
scheduleUpdate()
|
|
})
|
|
resizeObserver.observe(target)
|
|
scheduleUpdate()
|
|
}
|
|
|
|
attachTarget()
|
|
|
|
mutationObserver = new MutationObserver(() => {
|
|
attachTarget()
|
|
scheduleUpdate()
|
|
})
|
|
mutationObserver.observe(container, {
|
|
childList: true,
|
|
subtree: true,
|
|
})
|
|
|
|
window.addEventListener('resize', scheduleUpdate)
|
|
|
|
return () => {
|
|
cancelAnimationFrame(rafId)
|
|
cleanupTargetScroll?.()
|
|
resizeObserver?.disconnect()
|
|
mutationObserver?.disconnect()
|
|
window.removeEventListener('resize', scheduleUpdate)
|
|
}
|
|
}, [bindTarget, containerRef, 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 target = targetRef.current
|
|
const track = axis === 'x' ? trackXRef.current : trackYRef.current
|
|
const axisState = scrollbar[axis]
|
|
if (!target || !track) return
|
|
|
|
const trackSize = axis === 'x' ? track.clientWidth : track.clientHeight
|
|
const maxThumbOffset = Math.max(0, trackSize - axisState.thumbSize)
|
|
if (maxThumbOffset <= 0) {
|
|
if (axis === 'x') target.scrollLeft = 0
|
|
else target.scrollTop = 0
|
|
return
|
|
}
|
|
|
|
const progress = Math.max(0, Math.min(nextThumbOffset, maxThumbOffset)) / maxThumbOffset
|
|
if (axis === 'x') {
|
|
target.scrollLeft = progress * Math.max(0, target.scrollWidth - target.clientWidth)
|
|
return
|
|
}
|
|
target.scrollTop = progress * Math.max(0, target.scrollHeight - target.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 scrollbar--overlay">
|
|
<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"
|
|
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"
|
|
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 ScrollbarOverlay
|