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