Compare commits

...

1 Commits

Author SHA1 Message Date
Innei b3e330b089 feat: integrate VirtualList component across multiple features 2025-12-26 19:53:09 +08:00
14 changed files with 627 additions and 103 deletions
+2 -2
View File
@@ -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 [];
+192 -90
View File
@@ -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>
);
},
+67
View File
@@ -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;
+310
View File
@@ -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';
+1 -1
View File
@@ -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';