diff --git a/src/components/ScrollArea.tsx b/src/components/ScrollArea.tsx new file mode 100644 index 0000000..899d8d4 --- /dev/null +++ b/src/components/ScrollArea.tsx @@ -0,0 +1,246 @@ +import cx from 'clsx'; +import { clamp } from 'es-toolkit'; +import { + Component, + createMemo, + createSignal, + JSX, + mergeProps, + onCleanup, + onMount, + ParentComponent, + ParentProps, + Show, +} from 'solid-js'; + +interface ScrollBarProps { + containerRef: HTMLDivElement; + scrollPos: number; + scrollMax: number; + axis: 'x' | 'y'; +} + +const ScrollBar: Component = (props) => { + let track!: HTMLDivElement; + let thumb!: HTMLDivElement; + + const [thumbPos, setThumbPos] = createSignal(0); + const [isDragging, setIsDragging] = createSignal(false); + + const thumbPos = createMemo(() => { + if (props.scrollMax <= 0) { + return 0; + } + + const scrollPercent = props.scrollPos / props.scrollMax; + const thumbSize = props.axis === 'y' ? thumb.clientHeight : thumb.clientWidth; + const trackSize = props.axis === 'y' ? track.clientHeight : track.clientWidth; + + if (trackSize < thumbSize) { + return 0; + } + + return scrollPercent * (trackSize - thumbSize); + }); + const thumbStyle = createMemo(() => ({ + [props.axis === 'y' ? 'top' : 'left']: `${thumbPos()}px`, + })); + + const handleMouseMove = (e: MouseEvent) => { + e.preventDefault(); + if (!isDragging()) return; + + const trackRect = track.getBoundingClientRect(); + const thumbRect = thumb.getBoundingClientRect(); + let offset = 0; + if (props.axis === 'y') { + offset = e.clientY - trackRect.top - thumbRect.height / 2; + } else { + offset = e.clientX - trackRect.left - thumbRect.width / 2; + } + + const maxThumbPos = + props.axis === 'y' + ? track.clientHeight - thumbRect.height + : track.clientWidth - thumbRect.width; + + const newThumbPos = clamp(offset, 0, maxThumbPos); + setThumbPos(newThumbPos); + + const scrollPercentage = newThumbPos / maxThumbPos; + + if (props.axis === 'y') { + props.containerRef.scrollTop = + scrollPercentage * (props.containerRef.scrollHeight - props.containerRef.clientHeight); + } else { + props.containerRef.scrollLeft = + scrollPercentage * (props.containerRef.scrollWidth - props.containerRef.clientWidth); + } + }; + const handleMouseUp = () => { + setIsDragging(false); + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + const hadnleMouseDown: JSX.EventHandler = (e) => { + e.preventDefault(); + setIsDragging(true); + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + }; + + onCleanup(() => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }); + + return ( +
+
+
+ ); +}; + +interface ScrollAreaProps { + enableY?: boolean; + enableX?: boolean; + fullSize?: boolean; + normalizedScroll?: boolean; + flexExtended?: boolean; + extendClass?: JSX.HTMLAttributes['class']; +} + +const ScrollArea: ParentComponent = (props) => { + const mergedProps = mergeProps[]>( + { + enableY: false, + enableX: false, + fullSize: false, + normalizedScroll: false, + flexExtended: false, + extendClass: '', + }, + props, + ); + + let scrollContainer!: HTMLDivElement; + + const [yScrollNeeded, setYScrollNeeded] = createSignal(false); + const [xScrollNeeded, setXScrollNeeded] = createSignal(false); + + const [scrollTop, setScrollTop] = createSignal(0); + const [scrollLeft, setScrollLeft] = createSignal(0); + const [scrollHeight, setScrollHeight] = createSignal(0); + const [scrollWidth, setScrollWidth] = createSignal(0); + const [clientHeight, setClientHeight] = createSignal(0); + const [clientWidth, setClientWidth] = createSignal(0); + const maxVerticalScroll = createMemo(() => scrollWidth() - clientWidth()); + const maxHorizontalScroll = createMemo(() => scrollHeight() - clientHeight()); + + const updateScrollDimensions = () => { + setScrollTop(scrollContainer.scrollTop); + setScrollLeft(scrollContainer.scrollLeft); + setScrollHeight(scrollContainer.scrollHeight); + setScrollWidth(scrollContainer.scrollWidth); + setClientHeight(scrollContainer.clientHeight); + setClientWidth(scrollContainer.clientWidth); + + setYScrollNeeded(scrollContainer.scrollHeight > scrollContainer.clientHeight); + setXScrollNeeded(scrollContainer.scrollWidth > scrollContainer.clientWidth); + }; + + onMount(() => { + const resizeObserver = new ResizeObserver(updateScrollDimensions); + resizeObserver.observe(scrollContainer); + const mutationObserver = new MutationObserver(updateScrollDimensions); + mutationObserver.observe(scrollContainer, { + childList: true, + subtree: true, + attributes: true, + }); + + onCleanup(() => { + if (resizeObserver) { + resizeObserver.unobserve(scrollContainer); + resizeObserver.disconnect(); + } + if (mutationObserver) mutationObserver.disconnect(); + }); + }); + + const handleWheeling: JSX.EventHandler = (evt) => { + let deltaX = 0; + let deltaY = 0; + + if (mergedProps.enableX) { + deltaX = evt.deltaX; + const normalizedDeltaX = mergedProps.normalizedScroll ? clamp(deltaX, -1, 1) * 30 : deltaX; + const newScrollLeft = scrollContainer.scrollLeft + normalizedDeltaX; + scrollContainer.scrollLeft = clamp( + newScrollLeft, + 0, + scrollContainer.scrollWidth - scrollContainer.clientWidth, + ); + } + if (mergedProps.enableY) { + deltaY = evt.deltaY; + const normalizedDeltaY = mergedProps.normalizedScroll ? clamp(deltaY, -1, 1) * 30 : deltaY; + const newScrollTop = scrollContainer.scrollTop + normalizedDeltaY; + scrollContainer.scrollTop = clamp( + newScrollTop, + 0, + scrollContainer.scrollHeight - scrollContainer.clientHeight, + ); + } + + if (mergedProps.enableY) setScrollTop(scrollContainer.scrollTop); + if (mergedProps.enableX) setScrollLeft(scrollContainer.scrollLeft); + updateScrollDimensions(); + }; + + return ( +
+
+ {mergedProps.children} +
+ + + + + + +
+ ); +}; + +export default ScrollArea;