feat(components): 添加 ScrollArea 组件

- 实现了一个自定义的滚动区域组件,支持垂直和水平滚动条
- 添加了滚动条的拖动功能和鼠标滚轮支持
- 集成了 ResizeObserver 和 MutationObserver 以自动更新滚动维度
- 提供了多种配置选项,包括全尺寸、灵活扩展、标准化滚动等
This commit is contained in:
Vixalie
2025-08-06 22:46:25 +08:00
parent 35b18eab0f
commit f1a20d28d0

View File

@@ -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<ScrollBarProps> = (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<HTMLDivElement, MouseEvent> = (e) => {
e.preventDefault();
setIsDragging(true);
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
};
onCleanup(() => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
});
return (
<div
ref={track}
class={cx(
props.axis === 'y'
? 'col-2 row-1 relative overflow-hidden'
: 'row-2 col-1 relative overflow-hidden',
)}>
<div
ref={thumb}
on:mousedown={hadnleMouseDown}
style={thumbStyle()}
class={cx(
'absolute rounded-sm bg-neutral/70 cursor-pointer',
props.axis === 'y' ? 'w-full aspect-1/3' : 'h-full aspect-3/1',
)}
/>
</div>
);
};
interface ScrollAreaProps {
enableY?: boolean;
enableX?: boolean;
fullSize?: boolean;
normalizedScroll?: boolean;
flexExtended?: boolean;
extendClass?: JSX.HTMLAttributes<HTMLDivElement>['class'];
}
const ScrollArea: ParentComponent<ScrollAreaProps> = (props) => {
const mergedProps = mergeProps<ParentProps<ScrollAreaProps>[]>(
{
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<HTMLDivElement, WheelEvent> = (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 (
<div
class={cx(
'grid min-w-0 min-h-0 overflow-hidden',
mergedProps.fullSize && 'size-full',
!mergedProps.fullSize && mergedProps.flexExtended && 'flex-1',
mergedProps.extendClass,
)}>
<div ref={scrollContainer} class="col-1 row-1 overflow-hidden" on:wheel={handleWheeling}>
{mergedProps.children}
</div>
<Show when={mergedProps.enableY && yScrollNeeded()}>
<ScrollBar
containerRef={scrollContainer}
scrollPos={scrollTop()}
scrollMax={maxVerticalScroll()}
axis="y"
/>
</Show>
<Show when={mergedProps.enableX && xScrollNeeded()}>
<ScrollBar
containerRef={scrollContainer}
scrollPos={scrollLeft()}
scrollMax={maxHorizontalScroll()}
axis="x"
/>
</Show>
</div>
);
};
export default ScrollArea;