From f1a20d28d0d0f2f9e296abed6a1d81bb66c3c043 Mon Sep 17 00:00:00 2001 From: Vixalie Date: Wed, 6 Aug 2025 22:46:25 +0800 Subject: [PATCH] =?UTF-8?q?feat(components):=20=E6=B7=BB=E5=8A=A0=20Scroll?= =?UTF-8?q?Area=20=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现了一个自定义的滚动区域组件,支持垂直和水平滚动条 - 添加了滚动条的拖动功能和鼠标滚轮支持 - 集成了 ResizeObserver 和 MutationObserver 以自动更新滚动维度 - 提供了多种配置选项,包括全尺寸、灵活扩展、标准化滚动等 --- src/components/ScrollArea.tsx | 246 ++++++++++++++++++++++++++++++++++ 1 file changed, 246 insertions(+) create mode 100644 src/components/ScrollArea.tsx 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;