mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-14 03:30:19 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b3e330b089 |
+2
-2
@@ -216,6 +216,7 @@
|
||||
"@serwist/next": "^9.3.0",
|
||||
"@t3-oss/env-nextjs": "^0.13.10",
|
||||
"@tanstack/react-query": "^5.90.12",
|
||||
"@tanstack/react-virtual": "^3.13.13",
|
||||
"@trpc/client": "^11.8.1",
|
||||
"@trpc/next": "^11.8.1",
|
||||
"@trpc/react-query": "^11.8.1",
|
||||
@@ -374,7 +375,6 @@
|
||||
"@testing-library/react": "^16.3.1",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/async-retry": "^1.4.9",
|
||||
"@types/bcryptjs": "^3.0.0",
|
||||
"@types/chroma-js": "^3.1.2",
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
"@types/debug": "^4.1.12",
|
||||
@@ -451,4 +451,4 @@
|
||||
"access": "public",
|
||||
"registry": "https://registry.npmjs.org"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
import { Flexbox } from '@lobehub/ui';
|
||||
import isEqual from 'fast-deep-equal';
|
||||
import { memo, useCallback, useEffect, useRef } from 'react';
|
||||
import { VList, type VListHandle } from 'virtua';
|
||||
import { VList, type VListHandle } from '@/components/VirtualList';
|
||||
|
||||
import SkeletonList from '@/features/NavPanel/components/SkeletonList';
|
||||
import TopicEmpty from '@/features/TopicEmpty';
|
||||
@@ -153,6 +153,7 @@ const Content = memo<ContentProps>(({ open, searchKeyword }) => {
|
||||
return (
|
||||
<VList
|
||||
bufferSize={typeof window !== 'undefined' ? window.innerHeight : 0}
|
||||
itemSize={ITEM_HEIGHT}
|
||||
onScroll={handleScroll}
|
||||
ref={virtuaRef}
|
||||
style={{ height: '100%' }}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { Flexbox } from '@lobehub/ui';
|
||||
import isEqual from 'fast-deep-equal';
|
||||
import { memo, useCallback, useEffect, useRef } from 'react';
|
||||
import { VList, type VListHandle } from 'virtua';
|
||||
import { VList, type VListHandle } from '@/components/VirtualList';
|
||||
|
||||
import SkeletonList from '@/features/NavPanel/components/SkeletonList';
|
||||
import TopicEmpty from '@/features/TopicEmpty';
|
||||
@@ -153,6 +153,7 @@ const Content = memo<ContentProps>(({ open, searchKeyword }) => {
|
||||
return (
|
||||
<VList
|
||||
bufferSize={typeof window !== 'undefined' ? window.innerHeight : 0}
|
||||
itemSize={ITEM_HEIGHT}
|
||||
onScroll={handleScroll}
|
||||
ref={virtuaRef}
|
||||
style={{ height: '100%' }}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { Flexbox } from '@lobehub/ui';
|
||||
import isEqual from 'fast-deep-equal';
|
||||
import { memo } from 'react';
|
||||
import { VList } from 'virtua';
|
||||
import { VList } from '@/components/VirtualList';
|
||||
|
||||
import AgentSelectionEmpty from '@/features/AgentSelectionEmpty';
|
||||
import SkeletonList from '@/features/NavPanel/components/SkeletonList';
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { Flexbox } from '@lobehub/ui';
|
||||
import { memo, useCallback, useMemo, useRef } from 'react';
|
||||
import { VList, type VListHandle } from 'virtua';
|
||||
import { VList, type VListHandle } from '@/components/VirtualList';
|
||||
|
||||
import SkeletonList from '@/features/NavPanel/components/SkeletonList';
|
||||
import PageEmpty from '@/features/PageEmpty';
|
||||
@@ -15,6 +15,8 @@ interface ContentProps {
|
||||
searchKeyword: string;
|
||||
}
|
||||
|
||||
const ITEM_HEIGHT = 44;
|
||||
|
||||
const Content = memo<ContentProps>(({ searchKeyword }) => {
|
||||
const virtuaRef = useRef<VListHandle>(null);
|
||||
const fetchedCountRef = useRef(-1);
|
||||
@@ -70,6 +72,7 @@ const Content = memo<ContentProps>(({ searchKeyword }) => {
|
||||
return (
|
||||
<VList
|
||||
bufferSize={typeof window !== 'undefined' ? window.innerHeight : 0}
|
||||
itemSize={ITEM_HEIGHT}
|
||||
onScroll={handleScroll}
|
||||
ref={virtuaRef}
|
||||
style={{ height: '100%' }}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ActionIcon, Button, Dropdown, Flexbox, Icon, Text, TooltipGroup } from
|
||||
import type { ItemType } from 'antd/es/menu/interface';
|
||||
import isEqual from 'fast-deep-equal';
|
||||
import { ArrowDownUpIcon, ChevronDown, LucideCheck } from 'lucide-react';
|
||||
import type { ComponentProps } from 'react';
|
||||
import { memo, useCallback, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -11,6 +12,7 @@ import { useGlobalStore } from '@/store/global';
|
||||
import { systemStatusSelectors } from '@/store/global/selectors';
|
||||
|
||||
import ModelItem from './ModelItem';
|
||||
import RecycleList, { type RecycleListSlotProps } from '@/components/RecycleList';
|
||||
|
||||
interface DisabledModelsProps {
|
||||
activeTab: string;
|
||||
@@ -25,6 +27,9 @@ enum SortType {
|
||||
ReleasedAtDesc = 'releasedAtDesc',
|
||||
}
|
||||
|
||||
const DISABLED_MODEL_ITEM_HEIGHT = 72;
|
||||
const DISABLED_MODEL_LIST_MAX_HEIGHT = 480;
|
||||
|
||||
const DisabledModels = memo<DisabledModelsProps>(({ activeTab }) => {
|
||||
const { t } = useTranslation('modelProvider');
|
||||
|
||||
@@ -103,6 +108,15 @@ const DisabledModels = memo<DisabledModelsProps>(({ activeTab }) => {
|
||||
|
||||
const displayModels = showMore ? sortedDisabledModels : sortedDisabledModels.slice(0, 10);
|
||||
|
||||
const virtualListHeight = useMemo(
|
||||
() =>
|
||||
Math.min(
|
||||
sortedDisabledModels.length * DISABLED_MODEL_ITEM_HEIGHT,
|
||||
DISABLED_MODEL_LIST_MAX_HEIGHT,
|
||||
),
|
||||
[sortedDisabledModels.length],
|
||||
);
|
||||
|
||||
return (
|
||||
filteredDisabledModels.length > 0 && (
|
||||
<Flexbox>
|
||||
@@ -170,9 +184,19 @@ const DisabledModels = memo<DisabledModelsProps>(({ activeTab }) => {
|
||||
)}
|
||||
</Flexbox>
|
||||
<TooltipGroup>
|
||||
{displayModels.map((item) => (
|
||||
<ModelItem {...item} key={item.id} />
|
||||
))}
|
||||
{showMore ? (
|
||||
<RecycleList<DisabledModelItemProps>
|
||||
estimateItemSize={DISABLED_MODEL_ITEM_HEIGHT}
|
||||
height={virtualListHeight}
|
||||
items={sortedDisabledModels as DisabledModelItemProps[]}
|
||||
overscan={1}
|
||||
style={{ overscrollBehavior: 'contain' }}
|
||||
>
|
||||
<DisabledModelSlot />
|
||||
</RecycleList>
|
||||
) : (
|
||||
displayModels.map((item) => <ModelItem {...item} key={item.id} />)
|
||||
)}
|
||||
</TooltipGroup>
|
||||
{!showMore && sortedDisabledModels.length > 10 && (
|
||||
<Button
|
||||
@@ -192,3 +216,9 @@ const DisabledModels = memo<DisabledModelsProps>(({ activeTab }) => {
|
||||
});
|
||||
|
||||
export default DisabledModels;
|
||||
|
||||
type DisabledModelItemProps = ComponentProps<typeof ModelItem>;
|
||||
const DisabledModelSlot = ({ item }: Partial<RecycleListSlotProps<DisabledModelItemProps>>) => {
|
||||
if (!item) return null;
|
||||
return <ModelItem {...item} />;
|
||||
};
|
||||
|
||||
@@ -93,6 +93,16 @@ const ModelItem = memo<ModelItemProps>(
|
||||
const [checked, setChecked] = useState(enabled);
|
||||
const [showConfig, setShowConfig] = useState(false);
|
||||
|
||||
// NOTE: This component can be "recycled" (reused by virtual list slots),
|
||||
// so we must sync internal state when the bound model changes.
|
||||
React.useEffect(() => {
|
||||
setChecked(enabled);
|
||||
}, [enabled, id]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setShowConfig(false);
|
||||
}, [id]);
|
||||
|
||||
const formatPricing = (): string[] => {
|
||||
if (!pricing) return [];
|
||||
|
||||
|
||||
@@ -49,6 +49,16 @@ const useStyles = createStyles(({ css, token }) => ({
|
||||
`,
|
||||
}));
|
||||
|
||||
type TooltipStyles = ReturnType<typeof useStyles>['styles'];
|
||||
|
||||
const DEFAULT_TOOLTIP_STYLES = {
|
||||
root: { pointerEvents: 'none' },
|
||||
} as const satisfies ComponentProps<typeof Tooltip>['styles'];
|
||||
|
||||
const FUNCTION_CALL_TOOLTIP_STYLES = {
|
||||
root: { maxWidth: 'unset', pointerEvents: 'none' },
|
||||
} as const satisfies ComponentProps<typeof Tooltip>['styles'];
|
||||
|
||||
interface ModelInfoTagsProps extends ModelAbilities {
|
||||
contextWindowTokens?: number | null;
|
||||
directionReverse?: boolean;
|
||||
@@ -63,41 +73,173 @@ interface ModelInfoTagsProps extends ModelAbilities {
|
||||
withTooltip?: boolean;
|
||||
}
|
||||
|
||||
interface FeatureTagsProps extends Pick<
|
||||
ModelAbilities,
|
||||
'files' | 'imageOutput' | 'vision' | 'video' | 'functionCall' | 'reasoning' | 'search'
|
||||
> {
|
||||
placement: 'top' | 'right';
|
||||
tagClassName: string;
|
||||
withTooltip: boolean;
|
||||
}
|
||||
|
||||
interface FeatureTagItemProps {
|
||||
className: string;
|
||||
color: Parameters<typeof Tag>[0]['color'];
|
||||
enabled: boolean | undefined;
|
||||
icon: Parameters<typeof Icon>[0]['icon'];
|
||||
placement: 'top' | 'right';
|
||||
title: string;
|
||||
tooltipStyles?: ComponentProps<typeof Tooltip>['styles'];
|
||||
withTooltip: boolean;
|
||||
}
|
||||
|
||||
const FeatureTagItem = memo<FeatureTagItemProps>(
|
||||
({ className, color, enabled, icon, placement, title, tooltipStyles, withTooltip }) => {
|
||||
if (!enabled) return null;
|
||||
|
||||
const tag = (
|
||||
<Tag className={className} color={color} size={'small'}>
|
||||
<Icon icon={icon} />
|
||||
</Tag>
|
||||
);
|
||||
|
||||
if (!withTooltip) return tag;
|
||||
|
||||
return (
|
||||
<Tooltip placement={placement} styles={tooltipStyles ?? DEFAULT_TOOLTIP_STYLES} title={title}>
|
||||
{tag}
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const FeatureTags = memo<FeatureTagsProps>(
|
||||
({
|
||||
files,
|
||||
functionCall,
|
||||
imageOutput,
|
||||
placement,
|
||||
reasoning,
|
||||
search,
|
||||
tagClassName,
|
||||
video,
|
||||
vision,
|
||||
withTooltip,
|
||||
}) => {
|
||||
const { t } = useTranslation('components');
|
||||
|
||||
return (
|
||||
<>
|
||||
<FeatureTagItem
|
||||
className={tagClassName}
|
||||
color={'success'}
|
||||
enabled={files}
|
||||
icon={LucidePaperclip}
|
||||
placement={placement}
|
||||
title={t('ModelSelect.featureTag.file')}
|
||||
withTooltip={withTooltip}
|
||||
/>
|
||||
<FeatureTagItem
|
||||
className={tagClassName}
|
||||
color={'success'}
|
||||
enabled={imageOutput}
|
||||
icon={LucideImage}
|
||||
placement={placement}
|
||||
title={t('ModelSelect.featureTag.imageOutput')}
|
||||
withTooltip={withTooltip}
|
||||
/>
|
||||
<FeatureTagItem
|
||||
className={tagClassName}
|
||||
color={'success'}
|
||||
enabled={vision}
|
||||
icon={LucideEye}
|
||||
placement={placement}
|
||||
title={t('ModelSelect.featureTag.vision')}
|
||||
withTooltip={withTooltip}
|
||||
/>
|
||||
<FeatureTagItem
|
||||
className={tagClassName}
|
||||
color={'magenta'}
|
||||
enabled={video}
|
||||
icon={Video}
|
||||
placement={placement}
|
||||
title={t('ModelSelect.featureTag.video')}
|
||||
withTooltip={withTooltip}
|
||||
/>
|
||||
<FeatureTagItem
|
||||
className={tagClassName}
|
||||
color={'info'}
|
||||
enabled={functionCall}
|
||||
icon={ToyBrick}
|
||||
placement={placement}
|
||||
title={t('ModelSelect.featureTag.functionCall')}
|
||||
tooltipStyles={FUNCTION_CALL_TOOLTIP_STYLES}
|
||||
withTooltip={withTooltip}
|
||||
/>
|
||||
<FeatureTagItem
|
||||
className={tagClassName}
|
||||
color={'purple'}
|
||||
enabled={reasoning}
|
||||
icon={AtomIcon}
|
||||
placement={placement}
|
||||
title={t('ModelSelect.featureTag.reasoning')}
|
||||
withTooltip={withTooltip}
|
||||
/>
|
||||
<FeatureTagItem
|
||||
className={tagClassName}
|
||||
color={'cyan'}
|
||||
enabled={search}
|
||||
icon={LucideGlobe}
|
||||
placement={placement}
|
||||
title={t('ModelSelect.featureTag.search')}
|
||||
withTooltip={withTooltip}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const Context = memo(
|
||||
({
|
||||
contextWindowTokens,
|
||||
withTooltip,
|
||||
placement,
|
||||
styles,
|
||||
}: {
|
||||
contextWindowTokens: number;
|
||||
placement: 'top' | 'right';
|
||||
styles: TooltipStyles;
|
||||
withTooltip: boolean;
|
||||
}) => {
|
||||
const { t } = useTranslation('components');
|
||||
const tokensText = contextWindowTokens === 0 ? '∞' : formatTokenNumber(contextWindowTokens);
|
||||
|
||||
const tag = (
|
||||
<Tag className={styles.token} size={'small'}>
|
||||
{contextWindowTokens === 0 ? <Infinity size={17} strokeWidth={1.6} /> : tokensText}
|
||||
</Tag>
|
||||
);
|
||||
|
||||
if (!withTooltip) return tag;
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
placement={placement}
|
||||
// styles={styles}
|
||||
title={t('ModelSelect.featureTag.tokens', {
|
||||
tokens: contextWindowTokens === 0 ? '∞' : numeral(contextWindowTokens).format('0,0'),
|
||||
})}
|
||||
>
|
||||
{tag}
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const ModelInfoTags = memo<ModelInfoTagsProps>(
|
||||
({ directionReverse, placement = 'top', withTooltip = true, ...model }) => {
|
||||
const { t } = useTranslation('components');
|
||||
const { styles } = useStyles();
|
||||
|
||||
const renderTag = (
|
||||
enabled: boolean | undefined,
|
||||
getTitle: () => string,
|
||||
color: Parameters<typeof Tag>[0]['color'],
|
||||
icon: Parameters<typeof Icon>[0]['icon'],
|
||||
className = styles.tag,
|
||||
tooltipStyles?: ComponentProps<typeof Tooltip>['styles'],
|
||||
) => {
|
||||
if (!enabled) return null;
|
||||
|
||||
const tag = (
|
||||
<Tag className={className} color={color} size={'small'}>
|
||||
<Icon icon={icon} />
|
||||
</Tag>
|
||||
);
|
||||
|
||||
if (!withTooltip) return tag;
|
||||
|
||||
const title = getTitle();
|
||||
return (
|
||||
<Tooltip
|
||||
placement={placement}
|
||||
styles={tooltipStyles ?? { root: { pointerEvents: 'none' } }}
|
||||
title={title}
|
||||
>
|
||||
{tag}
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Flexbox
|
||||
className={TAG_CLASSNAME}
|
||||
@@ -106,66 +248,26 @@ export const ModelInfoTags = memo<ModelInfoTagsProps>(
|
||||
style={{ marginLeft: 'auto' }}
|
||||
width={'fit-content'}
|
||||
>
|
||||
{renderTag(model.files, () => t('ModelSelect.featureTag.file'), 'success', LucidePaperclip)}
|
||||
{renderTag(
|
||||
model.imageOutput,
|
||||
() => t('ModelSelect.featureTag.imageOutput'),
|
||||
'success',
|
||||
LucideImage,
|
||||
<FeatureTags
|
||||
files={model.files}
|
||||
functionCall={model.functionCall}
|
||||
imageOutput={model.imageOutput}
|
||||
placement={placement}
|
||||
reasoning={model.reasoning}
|
||||
search={model.search}
|
||||
tagClassName={styles.tag}
|
||||
video={model.video}
|
||||
vision={model.vision}
|
||||
withTooltip={withTooltip}
|
||||
/>
|
||||
{typeof model.contextWindowTokens === 'number' && (
|
||||
<Context
|
||||
contextWindowTokens={model.contextWindowTokens}
|
||||
placement={placement}
|
||||
styles={styles}
|
||||
withTooltip={withTooltip}
|
||||
/>
|
||||
)}
|
||||
{renderTag(model.vision, () => t('ModelSelect.featureTag.vision'), 'success', LucideEye)}
|
||||
{renderTag(model.video, () => t('ModelSelect.featureTag.video'), 'magenta', Video)}
|
||||
{renderTag(
|
||||
model.functionCall,
|
||||
() => t('ModelSelect.featureTag.functionCall'),
|
||||
'info',
|
||||
ToyBrick,
|
||||
styles.tag,
|
||||
{
|
||||
root: { maxWidth: 'unset', pointerEvents: 'none' },
|
||||
},
|
||||
)}
|
||||
{renderTag(
|
||||
model.reasoning,
|
||||
() => t('ModelSelect.featureTag.reasoning'),
|
||||
'purple',
|
||||
AtomIcon,
|
||||
)}
|
||||
{renderTag(model.search, () => t('ModelSelect.featureTag.search'), 'cyan', LucideGlobe)}
|
||||
{typeof model.contextWindowTokens === 'number' &&
|
||||
(() => {
|
||||
const tokensText =
|
||||
model.contextWindowTokens === 0 ? '∞' : formatTokenNumber(model.contextWindowTokens);
|
||||
|
||||
const tag = (
|
||||
<Tag className={styles.token} size={'small'}>
|
||||
{model.contextWindowTokens === 0 ? (
|
||||
<Infinity size={17} strokeWidth={1.6} />
|
||||
) : (
|
||||
tokensText
|
||||
)}
|
||||
</Tag>
|
||||
);
|
||||
|
||||
if (!withTooltip) return tag;
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
placement={placement}
|
||||
styles={{
|
||||
root: { maxWidth: 'unset', pointerEvents: 'none' },
|
||||
}}
|
||||
title={t('ModelSelect.featureTag.tokens', {
|
||||
tokens:
|
||||
model.contextWindowTokens === 0
|
||||
? '∞'
|
||||
: numeral(model.contextWindowTokens).format('0,0'),
|
||||
})}
|
||||
>
|
||||
{tag}
|
||||
</Tooltip>
|
||||
);
|
||||
})()}
|
||||
</Flexbox>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
'use client';
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { VList } from '@/components/VirtualList';
|
||||
|
||||
export interface RecycleListSlotProps<T> {
|
||||
index: number;
|
||||
item: T;
|
||||
}
|
||||
|
||||
type SlotProps<P extends object> = P & { children: React.ReactElement };
|
||||
|
||||
/**
|
||||
* A minimal Slot implementation (similar to Radix Slot):
|
||||
* it clones the only child and injects props into it.
|
||||
*/
|
||||
const Slot = <P extends object>({ children, ...props }: SlotProps<P>) => {
|
||||
if (!React.isValidElement(children)) return null;
|
||||
return React.cloneElement(children, props as any);
|
||||
};
|
||||
|
||||
interface RecycleListProps<T> {
|
||||
children: React.ReactElement<Partial<RecycleListSlotProps<T>>>;
|
||||
estimateItemSize: number;
|
||||
height: number;
|
||||
items: T[];
|
||||
overscan?: number;
|
||||
style?: React.CSSProperties;
|
||||
width?: React.CSSProperties['width'];
|
||||
}
|
||||
|
||||
const RecycleList = <T,>({
|
||||
children,
|
||||
estimateItemSize,
|
||||
height,
|
||||
items,
|
||||
overscan = 10,
|
||||
style,
|
||||
width = '100%',
|
||||
}: RecycleListProps<T>) => {
|
||||
const mergedStyle = useMemo<React.CSSProperties>(
|
||||
() => ({
|
||||
height,
|
||||
width,
|
||||
...style,
|
||||
}),
|
||||
[height, style, width],
|
||||
);
|
||||
|
||||
return (
|
||||
<VList
|
||||
bufferSize={overscan * estimateItemSize}
|
||||
data={items}
|
||||
itemSize={estimateItemSize}
|
||||
style={mergedStyle}
|
||||
>
|
||||
{(item, index) => (
|
||||
<Slot<RecycleListSlotProps<T>> index={index} item={item}>
|
||||
{children}
|
||||
</Slot>
|
||||
)}
|
||||
</VList>
|
||||
);
|
||||
};
|
||||
|
||||
export default RecycleList;
|
||||
@@ -0,0 +1,310 @@
|
||||
'use client';
|
||||
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import React, {
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from 'react';
|
||||
|
||||
export interface ScrollToIndexOptions {
|
||||
align?: 'start' | 'center' | 'end' | 'nearest';
|
||||
offset?: number;
|
||||
smooth?: boolean;
|
||||
}
|
||||
|
||||
export interface VListHandle {
|
||||
readonly scrollOffset: number;
|
||||
readonly scrollSize: number;
|
||||
readonly viewportSize: number;
|
||||
findItemIndex: (offset: number) => number;
|
||||
getItemOffset: (index: number) => number;
|
||||
getItemSize: (index: number) => number;
|
||||
scrollBy: (offset: number) => void;
|
||||
scrollTo: (offset: number) => void;
|
||||
scrollToIndex: (index: number, opts?: ScrollToIndexOptions) => void;
|
||||
}
|
||||
|
||||
interface ViewportComponentAttributes
|
||||
extends Pick<
|
||||
React.HTMLAttributes<HTMLElement>,
|
||||
'className' | 'style' | 'id' | 'role' | 'tabIndex' | 'onKeyDown' | 'onWheel'
|
||||
>,
|
||||
React.AriaAttributes {}
|
||||
|
||||
export interface CustomItemComponentProps {
|
||||
children: ReactNode;
|
||||
index: number;
|
||||
style: React.CSSProperties;
|
||||
}
|
||||
|
||||
type CustomItemComponent = React.ComponentType<CustomItemComponentProps>;
|
||||
type VListChildRenderer<T> = (data: T, index: number) => ReactElement;
|
||||
|
||||
export interface VListProps<T = unknown> extends ViewportComponentAttributes {
|
||||
bufferSize?: number;
|
||||
children: ReactNode | VListChildRenderer<T>;
|
||||
data?: ArrayLike<T>;
|
||||
horizontal?: boolean;
|
||||
item?: keyof JSX.IntrinsicElements | CustomItemComponent;
|
||||
itemSize?: number;
|
||||
keepMounted?: readonly number[];
|
||||
onScroll?: (offset: number) => void;
|
||||
onScrollEnd?: () => void;
|
||||
cache?: unknown;
|
||||
shift?: boolean;
|
||||
ssrCount?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_BUFFER_SIZE = 200;
|
||||
const DEFAULT_ESTIMATE_SIZE = 40;
|
||||
const SCROLL_END_DELAY_MS = 150;
|
||||
|
||||
const VList = forwardRef<VListHandle, VListProps<any>>(
|
||||
(
|
||||
{
|
||||
bufferSize = DEFAULT_BUFFER_SIZE,
|
||||
children,
|
||||
className,
|
||||
data,
|
||||
horizontal = false,
|
||||
item: ItemComponent = 'div',
|
||||
itemSize,
|
||||
keepMounted,
|
||||
onScroll,
|
||||
onScrollEnd,
|
||||
style,
|
||||
cache: _cache,
|
||||
shift: _shift,
|
||||
ssrCount: _ssrCount,
|
||||
...rest
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const parentRef = useRef<HTMLDivElement | null>(null);
|
||||
const scrollEndTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const { items, renderItem } = useMemo(() => {
|
||||
if (typeof children === 'function') {
|
||||
const resolvedData = data ? (Array.isArray(data) ? data : Array.from(data)) : [];
|
||||
return {
|
||||
items: resolvedData,
|
||||
renderItem: (index: number) => {
|
||||
const item = resolvedData[index];
|
||||
if (item === undefined) return null;
|
||||
return (children as VListChildRenderer<any>)(item, index);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const resolvedChildren = React.Children.toArray(children);
|
||||
|
||||
return {
|
||||
items: resolvedChildren,
|
||||
renderItem: (index: number) => resolvedChildren[index] ?? null,
|
||||
};
|
||||
}, [children, data]);
|
||||
|
||||
const estimateSize = useCallback(
|
||||
() => itemSize ?? DEFAULT_ESTIMATE_SIZE,
|
||||
[itemSize],
|
||||
);
|
||||
|
||||
const overscan = useMemo(() => {
|
||||
const estimate = itemSize ?? DEFAULT_ESTIMATE_SIZE;
|
||||
if (estimate <= 0) return 0;
|
||||
return Math.max(0, Math.ceil(bufferSize / estimate));
|
||||
}, [bufferSize, itemSize]);
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: items.length,
|
||||
estimateSize,
|
||||
getScrollElement: () => parentRef.current,
|
||||
horizontal,
|
||||
overscan,
|
||||
});
|
||||
|
||||
const virtualItems = rowVirtualizer.getVirtualItems();
|
||||
const measurements = rowVirtualizer.getMeasurements();
|
||||
const totalSize = rowVirtualizer.getTotalSize();
|
||||
|
||||
const itemsToRender = useMemo(() => {
|
||||
if (!keepMounted?.length) return virtualItems;
|
||||
|
||||
const map = new Map<number, (typeof virtualItems)[number]>();
|
||||
|
||||
virtualItems.forEach((item) => {
|
||||
map.set(item.index, item);
|
||||
});
|
||||
|
||||
keepMounted.forEach((index) => {
|
||||
const measurement = measurements[index];
|
||||
if (measurement) map.set(index, measurement);
|
||||
});
|
||||
|
||||
return Array.from(map.values()).sort((a, b) => a.index - b.index);
|
||||
}, [keepMounted, measurements, virtualItems]);
|
||||
|
||||
const handleScroll = useCallback(
|
||||
(event: React.UIEvent<HTMLDivElement>) => {
|
||||
const target = event.currentTarget;
|
||||
const offset = horizontal ? target.scrollLeft : target.scrollTop;
|
||||
|
||||
onScroll?.(offset);
|
||||
|
||||
if (!onScrollEnd) return;
|
||||
|
||||
if (scrollEndTimerRef.current) {
|
||||
clearTimeout(scrollEndTimerRef.current);
|
||||
}
|
||||
|
||||
scrollEndTimerRef.current = setTimeout(() => {
|
||||
scrollEndTimerRef.current = null;
|
||||
onScrollEnd();
|
||||
}, SCROLL_END_DELAY_MS);
|
||||
},
|
||||
[horizontal, onScroll, onScrollEnd],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (scrollEndTimerRef.current) {
|
||||
clearTimeout(scrollEndTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
get scrollOffset() {
|
||||
const element = parentRef.current;
|
||||
if (!element) return 0;
|
||||
return horizontal ? element.scrollLeft : element.scrollTop;
|
||||
},
|
||||
get scrollSize() {
|
||||
const element = parentRef.current;
|
||||
const viewportSize = element
|
||||
? horizontal
|
||||
? element.clientWidth
|
||||
: element.clientHeight
|
||||
: 0;
|
||||
return Math.max(rowVirtualizer.getTotalSize(), viewportSize);
|
||||
},
|
||||
get viewportSize() {
|
||||
const element = parentRef.current;
|
||||
if (!element) return 0;
|
||||
return horizontal ? element.clientWidth : element.clientHeight;
|
||||
},
|
||||
findItemIndex: (offset: number) => {
|
||||
const item = rowVirtualizer.getVirtualItemForOffset(offset);
|
||||
return item?.index ?? 0;
|
||||
},
|
||||
getItemOffset: (index: number) => {
|
||||
const offsetInfo = rowVirtualizer.getOffsetForIndex(index, 'start');
|
||||
return offsetInfo?.[0] ?? 0;
|
||||
},
|
||||
getItemSize: (index: number) => {
|
||||
const measurement = rowVirtualizer.getMeasurements()[index];
|
||||
if (measurement) return measurement.size;
|
||||
return itemSize ?? DEFAULT_ESTIMATE_SIZE;
|
||||
},
|
||||
scrollBy: (offset: number) => {
|
||||
rowVirtualizer.scrollBy(offset);
|
||||
},
|
||||
scrollTo: (offset: number) => {
|
||||
rowVirtualizer.scrollToOffset(offset, { align: 'start' });
|
||||
},
|
||||
scrollToIndex: (index: number, opts?: ScrollToIndexOptions) => {
|
||||
const align = opts?.align ?? 'start';
|
||||
const behavior = opts?.smooth ? 'smooth' : 'auto';
|
||||
const mappedAlign = align === 'nearest' ? 'auto' : align;
|
||||
|
||||
rowVirtualizer.scrollToIndex(index, { align: mappedAlign, behavior });
|
||||
|
||||
if (opts?.offset) {
|
||||
rowVirtualizer.scrollBy(opts.offset, { behavior });
|
||||
}
|
||||
},
|
||||
}),
|
||||
[horizontal, itemSize, rowVirtualizer],
|
||||
);
|
||||
|
||||
const mergedStyle = useMemo<React.CSSProperties>(
|
||||
() => ({
|
||||
contain: 'strict',
|
||||
display: horizontal ? 'inline-block' : 'block',
|
||||
height: '100%',
|
||||
overflowX: horizontal ? 'auto' : 'hidden',
|
||||
overflowY: horizontal ? 'hidden' : 'auto',
|
||||
width: '100%',
|
||||
...style,
|
||||
}),
|
||||
[horizontal, style],
|
||||
);
|
||||
|
||||
const innerStyle = useMemo<React.CSSProperties>(
|
||||
() => ({
|
||||
contain: 'size style',
|
||||
flex: 'none',
|
||||
height: horizontal ? '100%' : totalSize,
|
||||
overflowAnchor: 'none',
|
||||
position: 'relative',
|
||||
width: horizontal ? totalSize : '100%',
|
||||
}),
|
||||
[horizontal, totalSize],
|
||||
);
|
||||
|
||||
const renderItemNode = useCallback(
|
||||
(virtualRow: (typeof virtualItems)[number]) => {
|
||||
const Item = ItemComponent as React.ElementType;
|
||||
const isIntrinsic = typeof ItemComponent === 'string';
|
||||
const offsetStyle: React.CSSProperties = {
|
||||
left: 0,
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
transform: horizontal
|
||||
? `translateX(${virtualRow.start}px)`
|
||||
: `translateY(${virtualRow.start}px)`,
|
||||
height: horizontal ? '100%' : undefined,
|
||||
width: horizontal ? undefined : '100%',
|
||||
};
|
||||
|
||||
const itemNode = renderItem(virtualRow.index);
|
||||
if (!itemNode) return null;
|
||||
|
||||
const itemKey =
|
||||
React.isValidElement(itemNode) && itemNode.key !== null ? itemNode.key : virtualRow.key;
|
||||
|
||||
const props = {
|
||||
'data-index': virtualRow.index,
|
||||
ref: rowVirtualizer.measureElement,
|
||||
style: offsetStyle,
|
||||
...(isIntrinsic ? null : { index: virtualRow.index }),
|
||||
};
|
||||
|
||||
return (
|
||||
<Item key={itemKey} {...props}>
|
||||
{itemNode}
|
||||
</Item>
|
||||
);
|
||||
},
|
||||
[horizontal, ItemComponent, renderItem, rowVirtualizer],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={className} onScroll={handleScroll} ref={parentRef} style={mergedStyle} {...rest}>
|
||||
<div style={innerStyle}>{itemsToRender.map(renderItemNode)}</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
VList.displayName = 'VList';
|
||||
|
||||
export { VList };
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import isEqual from 'fast-deep-equal';
|
||||
import { type ReactElement, type ReactNode, memo, useCallback, useEffect, useRef } from 'react';
|
||||
import { VList, type VListHandle } from 'virtua';
|
||||
import { VList, type VListHandle } from '@/components/VirtualList';
|
||||
|
||||
import WideScreenContainer from '../../../WideScreenContainer';
|
||||
import { useConversationStore, virtuaListSelectors } from '../../store';
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
useMemo,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import type { VListHandle } from 'virtua';
|
||||
import type { VListHandle } from '@/components/VirtualList';
|
||||
|
||||
import BubblesLoading from '@/components/BubblesLoading';
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
} from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { VListHandle } from 'virtua';
|
||||
import type { VListHandle } from '@/components/VirtualList';
|
||||
|
||||
import { useSessionStore } from '@/store/session';
|
||||
import { sessionSelectors } from '@/store/session/selectors';
|
||||
|
||||
@@ -5,7 +5,7 @@ import { createStyles } from 'antd-style';
|
||||
import { rgba } from 'polished';
|
||||
import { type DragEvent, memo, useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { VList, type VListHandle } from 'virtua';
|
||||
import { VList, type VListHandle } from '@/components/VirtualList';
|
||||
|
||||
import { useDragActive } from '@/app/[variants]/(main)/resource/features/DndContextWrapper';
|
||||
import { useFolderPath } from '@/app/[variants]/(main)/resource/features/hooks/useFolderPath';
|
||||
|
||||
Reference in New Issue
Block a user