This commit is contained in:
2026-04-25 16:36:34 +08:00
commit db90e7579b
1876 changed files with 189777 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
WIP: Warning
可变高度的虚拟列表
专为聊天场景打造的

View File

@@ -0,0 +1,61 @@
import React, { useEffect, useRef } from 'react';
import { useMemoizedFn } from 'ahooks';
type Size = { height: number; width: number };
interface ResizeWatcherProps extends React.PropsWithChildren {
wrapperStyle?: React.CSSProperties;
onResize?: (size: Size) => void;
}
/**
* 当容器大小发生变化时
* 触发回调
*/
export const ResizeWatcher: React.FC<ResizeWatcherProps> = React.memo(
(props) => {
const rootRef = useRef<HTMLDivElement>(null);
const handleResize = useMemoizedFn((size: Size) => {
if (props.onResize) {
props.onResize(size);
}
});
useEffect(() => {
if (!rootRef.current) {
return;
}
const resizeObserver = new ResizeObserver((entries) => {
entries.forEach((entry) => {
const { target, contentRect } = entry;
if (!target.parentElement) {
return;
}
// 使用 contentRect 计算大小以确保不会出现使用clientHeight立即向浏览器请求dom大小导致的性能问题
handleResize({
width: Math.round(contentRect.width),
height: Math.round(contentRect.height),
});
});
});
resizeObserver.observe(rootRef.current);
return () => {
if (resizeObserver && rootRef.current) {
resizeObserver.unobserve(rootRef.current);
resizeObserver.disconnect();
}
};
}, []);
return (
<div style={props.wrapperStyle} ref={rootRef}>
{props.children}
</div>
);
}
);
ResizeWatcher.displayName = 'ResizeWatcher';

View File

@@ -0,0 +1,190 @@
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';

View File

@@ -0,0 +1,22 @@
import React from 'react';
import { ComponentStory, ComponentMeta } from '@storybook/react';
import { VirtualChatList } from '.';
// More on default export: https://storybook.js.org/docs/react/writing-stories/introduction#default-export
export default {
title: 'Tailchat/VirtualChatList',
component: VirtualChatList,
// More on argTypes: https://storybook.js.org/docs/react/api/argtypes
argTypes: {},
} as ComponentMeta<typeof VirtualChatList>;
// More on component templates: https://storybook.js.org/docs/react/writing-stories/introduction#using-args
const Template: ComponentStory<typeof VirtualChatList> = (args) => (
<VirtualChatList {...args} />
);
export const Default = Template.bind({});
// More on args: https://storybook.js.org/docs/react/writing-stories/args
Default.args = {
text: 'fooooo',
};

View File

@@ -0,0 +1,82 @@
import React, { useEffect, useMemo, useRef } from 'react';
import { ResizeWatcher } from './ResizeWatcher';
import { Scroller, ScrollerRef } from './Scroller';
import { useUpdate } from 'ahooks';
interface VirtualChatListProps<ItemType> {
className?: string;
style?: React.CSSProperties;
innerStyle?: React.CSSProperties;
getItemKey?: (item: ItemType) => string;
items: ItemType[];
itemContent: (item: ItemType, index: number) => React.ReactNode;
}
const defaultContainerStyle: React.CSSProperties = {
overflow: 'hidden',
};
const defaultInnerStyle: React.CSSProperties = {
height: '100%',
};
const scrollerStyle: React.CSSProperties = {
height: '100%',
};
const InternalVirtualChatList = <ItemType extends object>(
props: VirtualChatListProps<ItemType>
) => {
const scrollerRef = useRef<ScrollerRef>(null);
const itemHeightCache = useMemo(() => new Map<ItemType, number>(), []);
const forceUpdate = useUpdate();
const style = useMemo(
() => ({
...defaultContainerStyle,
...props.style,
}),
[props.style]
);
const innerStyle = useMemo(
() => ({
...defaultInnerStyle,
...props.innerStyle,
}),
[props.innerStyle]
);
useEffect(() => {
// 挂载后滚动到底部
scrollerRef.current?.scrollToBottom();
}, []);
return (
<div className="virtual-chat-list" style={style}>
<Scroller ref={scrollerRef} style={scrollerStyle} innerStyle={innerStyle}>
{props.items.map((item, i) => (
<div
key={props.getItemKey ? props.getItemKey(item) : i}
className="virtual-chat-list__item"
style={{ height: itemHeightCache.get(item) }}
>
<ResizeWatcher
onResize={(size) => {
itemHeightCache.set(item, size.height);
forceUpdate();
}}
>
{props.itemContent(item, i)}
</ResizeWatcher>
</div>
))}
</Scroller>
</div>
);
};
type VirtualChatListInterface = typeof InternalVirtualChatList & React.FC;
export const VirtualChatList: VirtualChatListInterface = React.memo(
InternalVirtualChatList
) as any;
VirtualChatList.displayName = 'VirtualChatList';

View File

@@ -0,0 +1,9 @@
export interface Vector {
x: number;
y: number;
}
export interface IsForward {
x: boolean;
y: boolean;
}