mirror of https://github.com/msgbyte/tailchat
You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
191 lines
4.7 KiB
TypeScript
191 lines
4.7 KiB
TypeScript
import React, {
|
|
PropsWithChildren,
|
|
useImperativeHandle,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from 'react';
|
|
import { ResizeWatcher } from './ResizeWatcher';
|
|
import { useMemoizedFn, useDebounceFn, usePrevious } from 'ahooks';
|
|
import type { IsForward, Vector } from './types';
|
|
import clsx from 'clsx';
|
|
|
|
export interface ScrollerRef {
|
|
scrollTo: (position: Vector) => void;
|
|
scrollToBottom: () => void;
|
|
}
|
|
|
|
type ScrollerProps = PropsWithChildren<{
|
|
className?: string;
|
|
style?: React.CSSProperties;
|
|
innerStyle?: React.CSSProperties;
|
|
isLock?: boolean;
|
|
scrollingClassName?: string;
|
|
scrollBehavior?: ScrollBehavior;
|
|
onScroll?: (
|
|
position: Vector,
|
|
detail: {
|
|
forward: IsForward;
|
|
isUserScrolling: boolean;
|
|
isMouseDown: boolean;
|
|
}
|
|
) => void;
|
|
onScrollEnd?: (position: Vector) => void;
|
|
onContainerResize?: (info: {
|
|
containerSize: Vector;
|
|
position: Vector;
|
|
}) => void;
|
|
}>;
|
|
|
|
const DEFAULT_POS = { x: 0, y: 0 };
|
|
|
|
/**
|
|
* 滚动状态管理组件
|
|
*/
|
|
export const Scroller = React.forwardRef<ScrollerRef, ScrollerProps>(
|
|
(props, ref) => {
|
|
const { scrollBehavior = 'auto' } = props;
|
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
|
const innerRef = useRef<HTMLDivElement>(null);
|
|
const style = useMemo(() => {
|
|
if (props.isLock ?? false) {
|
|
return { ...props.style, overflow: 'hidden' };
|
|
}
|
|
|
|
return { ...props.style, overflow: 'auto' };
|
|
}, [props.isLock]);
|
|
|
|
const getPosition = useMemoizedFn(() => {
|
|
if (!wrapperRef.current) {
|
|
return DEFAULT_POS;
|
|
}
|
|
return {
|
|
x: wrapperRef.current.scrollLeft,
|
|
y: wrapperRef.current.scrollTop,
|
|
};
|
|
});
|
|
|
|
const getContainerSize = useMemoizedFn(() => {
|
|
if (!wrapperRef.current) {
|
|
return DEFAULT_POS;
|
|
}
|
|
return {
|
|
x: wrapperRef.current.clientWidth,
|
|
y: wrapperRef.current.clientHeight,
|
|
};
|
|
});
|
|
|
|
useImperativeHandle(ref, () => ({
|
|
scrollTo: (position) => {
|
|
wrapperRef.current?.scrollTo({
|
|
left: position.x,
|
|
top: position.y,
|
|
behavior: scrollBehavior,
|
|
});
|
|
},
|
|
scrollToBottom: () => {
|
|
wrapperRef.current?.scrollTo({
|
|
left: getPosition().x,
|
|
top: wrapperRef.current.scrollHeight - getContainerSize().y,
|
|
behavior: scrollBehavior,
|
|
});
|
|
},
|
|
}));
|
|
|
|
const [isScroll, setIsScroll] = useState(false);
|
|
const [isMouseDown, setIsMouseDown] = useState(false);
|
|
const { run: setIsScrollLazy } = useDebounceFn(
|
|
(val) => {
|
|
setIsScroll(val);
|
|
},
|
|
{
|
|
leading: false,
|
|
trailing: true,
|
|
wait: 300,
|
|
}
|
|
);
|
|
|
|
const { run: handleEndScrollLazy } = useDebounceFn(
|
|
() => {
|
|
setIsScroll(false);
|
|
if (props.onScrollEnd) {
|
|
props.onScrollEnd(getPosition());
|
|
}
|
|
},
|
|
{
|
|
leading: false,
|
|
trailing: true,
|
|
wait: 300,
|
|
}
|
|
);
|
|
|
|
const handleWheel = useMemoizedFn(() => {
|
|
setIsScroll(true);
|
|
setIsScrollLazy(false);
|
|
});
|
|
|
|
const handleMouseDown = useMemoizedFn(() => {
|
|
setIsMouseDown(true);
|
|
});
|
|
|
|
const handleMouseUp = useMemoizedFn(() => {
|
|
setIsMouseDown(false);
|
|
});
|
|
|
|
const prevPosition = usePrevious(getPosition()) ?? DEFAULT_POS;
|
|
const handleScroll = useMemoizedFn(() => {
|
|
const isUserScrolling = isScroll || isMouseDown;
|
|
const currentPosition = getPosition();
|
|
const forward = {
|
|
x: currentPosition.x > prevPosition.x,
|
|
y: currentPosition.y > prevPosition.y,
|
|
};
|
|
setIsScroll(true);
|
|
handleEndScrollLazy();
|
|
if (props.onScroll) {
|
|
props.onScroll(currentPosition, {
|
|
forward,
|
|
isUserScrolling,
|
|
isMouseDown: isMouseDown,
|
|
});
|
|
}
|
|
});
|
|
|
|
const handleResize = useMemoizedFn(() => {
|
|
if (props.onContainerResize) {
|
|
props.onContainerResize({
|
|
containerSize: getContainerSize(),
|
|
position: getPosition(),
|
|
});
|
|
}
|
|
});
|
|
|
|
return (
|
|
<ResizeWatcher wrapperStyle={{ height: '100%' }} onResize={handleResize}>
|
|
<div
|
|
key="scroller"
|
|
className={clsx(props.className, 'scroller', {
|
|
[props.scrollingClassName ?? 'scrolling']: isScroll,
|
|
})}
|
|
style={style}
|
|
ref={wrapperRef}
|
|
onWheel={handleWheel}
|
|
onMouseDown={handleMouseDown}
|
|
onMouseUp={handleMouseUp}
|
|
onScroll={handleScroll}
|
|
>
|
|
<div
|
|
className="scroller-inner"
|
|
key="scroller-inner"
|
|
style={props.innerStyle}
|
|
ref={innerRef}
|
|
>
|
|
{props.children}
|
|
</div>
|
|
</div>
|
|
</ResizeWatcher>
|
|
);
|
|
}
|
|
);
|
|
Scroller.displayName = 'Scroller';
|