feat(components): 添加 ScrollArea 组件
- 实现了一个自定义的滚动区域组件,支持垂直和水平滚动条 - 添加了滚动条的拖动功能和鼠标滚轮支持 - 集成了 ResizeObserver 和 MutationObserver 以自动更新滚动维度 - 提供了多种配置选项,包括全尺寸、灵活扩展、标准化滚动等
This commit is contained in:
246
src/components/ScrollArea.tsx
Normal file
246
src/components/ScrollArea.tsx
Normal 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;
|
Reference in New Issue
Block a user