使用Virtua替换@tanstack/react-virtual来实现虚拟列表
This commit is contained in:
@@ -18,7 +18,6 @@
|
|||||||
"@streamdown/code": "^1.0.2",
|
"@streamdown/code": "^1.0.2",
|
||||||
"@streamdown/math": "^1.0.2",
|
"@streamdown/math": "^1.0.2",
|
||||||
"@streamdown/mermaid": "^1.0.2",
|
"@streamdown/mermaid": "^1.0.2",
|
||||||
"@tanstack/react-virtual": "^3.13.23",
|
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||||
"@tauri-apps/plugin-opener": "^2",
|
"@tauri-apps/plugin-opener": "^2",
|
||||||
@@ -54,7 +53,8 @@
|
|||||||
"streamdown": "^2.2.0",
|
"streamdown": "^2.2.0",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
"use-stick-to-bottom": "^1.1.3"
|
"use-stick-to-bottom": "^1.1.3",
|
||||||
|
"virtua": "^0.48.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.1.18",
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
|
|||||||
23
pnpm-lock.yaml
generated
23
pnpm-lock.yaml
generated
@@ -32,9 +32,6 @@ importers:
|
|||||||
'@streamdown/mermaid':
|
'@streamdown/mermaid':
|
||||||
specifier: ^1.0.2
|
specifier: ^1.0.2
|
||||||
version: 1.0.2(react@19.2.4)
|
version: 1.0.2(react@19.2.4)
|
||||||
'@tanstack/react-virtual':
|
|
||||||
specifier: ^3.13.23
|
|
||||||
version: 3.13.23(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
|
||||||
'@tauri-apps/api':
|
'@tauri-apps/api':
|
||||||
specifier: ^2
|
specifier: ^2
|
||||||
version: 2.10.1
|
version: 2.10.1
|
||||||
@@ -143,6 +140,9 @@ importers:
|
|||||||
use-stick-to-bottom:
|
use-stick-to-bottom:
|
||||||
specifier: ^1.1.3
|
specifier: ^1.1.3
|
||||||
version: 1.1.3(react@19.2.4)
|
version: 1.1.3(react@19.2.4)
|
||||||
|
virtua:
|
||||||
|
specifier: ^0.48.8
|
||||||
|
version: 0.48.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@tailwindcss/postcss':
|
'@tailwindcss/postcss':
|
||||||
specifier: ^4.1.18
|
specifier: ^4.1.18
|
||||||
@@ -2474,15 +2474,6 @@ packages:
|
|||||||
'@tailwindcss/postcss@4.1.18':
|
'@tailwindcss/postcss@4.1.18':
|
||||||
resolution: {integrity: sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g==}
|
resolution: {integrity: sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g==}
|
||||||
|
|
||||||
'@tanstack/react-virtual@3.13.23':
|
|
||||||
resolution: {integrity: sha512-XnMRnHQ23piOVj2bzJqHrRrLg4r+F86fuBcwteKfbIjJrtGxb4z7tIvPVAe4B+4UVwo9G4Giuz5fmapcrnZ0OQ==}
|
|
||||||
peerDependencies:
|
|
||||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
|
||||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
|
||||||
|
|
||||||
'@tanstack/virtual-core@3.13.23':
|
|
||||||
resolution: {integrity: sha512-zSz2Z2HNyLjCplANTDyl3BcdQJc2k1+yyFoKhNRmCr7V7dY8o8q5m8uFTI1/Pg1kL+Hgrz6u3Xo6eFUB7l66cg==}
|
|
||||||
|
|
||||||
'@tauri-apps/api@2.10.1':
|
'@tauri-apps/api@2.10.1':
|
||||||
resolution: {integrity: sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==}
|
resolution: {integrity: sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==}
|
||||||
|
|
||||||
@@ -9098,14 +9089,6 @@ snapshots:
|
|||||||
postcss: 8.5.6
|
postcss: 8.5.6
|
||||||
tailwindcss: 4.1.18
|
tailwindcss: 4.1.18
|
||||||
|
|
||||||
'@tanstack/react-virtual@3.13.23(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
|
||||||
dependencies:
|
|
||||||
'@tanstack/virtual-core': 3.13.23
|
|
||||||
react: 19.2.4
|
|
||||||
react-dom: 19.2.4(react@19.2.4)
|
|
||||||
|
|
||||||
'@tanstack/virtual-core@3.13.23': {}
|
|
||||||
|
|
||||||
'@tauri-apps/api@2.10.1': {}
|
'@tauri-apps/api@2.10.1': {}
|
||||||
|
|
||||||
'@tauri-apps/cli-darwin-arm64@2.10.0':
|
'@tauri-apps/cli-darwin-arm64@2.10.0':
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
} from "react"
|
} from "react"
|
||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { useVirtualizer } from "@tanstack/react-virtual"
|
import { Virtualizer, type VirtualizerHandle } from "virtua"
|
||||||
import { CheckCheck, ChevronRight, Download, Loader2, Plus } from "lucide-react"
|
import { CheckCheck, ChevronRight, Download, Loader2, Plus } from "lucide-react"
|
||||||
import { useFolderContext } from "@/contexts/folder-context"
|
import { useFolderContext } from "@/contexts/folder-context"
|
||||||
import { useTabContext } from "@/contexts/tab-context"
|
import { useTabContext } from "@/contexts/tab-context"
|
||||||
@@ -71,7 +71,6 @@ type FlatItem =
|
|||||||
| { type: "header"; status: ConversationStatus; count: number }
|
| { type: "header"; status: ConversationStatus; count: number }
|
||||||
| { type: "conversation"; conversation: DbConversationSummary }
|
| { type: "conversation"; conversation: DbConversationSummary }
|
||||||
|
|
||||||
const HEADER_HEIGHT = 32
|
|
||||||
const CARD_HEIGHT = 62
|
const CARD_HEIGHT = 62
|
||||||
|
|
||||||
const GroupHeader = memo(function GroupHeader({
|
const GroupHeader = memo(function GroupHeader({
|
||||||
@@ -209,8 +208,7 @@ export function SidebarConversationList({
|
|||||||
|
|
||||||
const scrollToActiveRef = useRef<() => void>(() => {})
|
const scrollToActiveRef = useRef<() => void>(() => {})
|
||||||
const pendingScrollRef = useRef(false)
|
const pendingScrollRef = useRef(false)
|
||||||
const virtualizerRef =
|
const virtualizerRef = useRef<VirtualizerHandle>(null)
|
||||||
useRef<ReturnType<typeof useVirtualizer<HTMLDivElement, Element>>>(null)
|
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
scrollToActive() {
|
scrollToActive() {
|
||||||
@@ -272,22 +270,6 @@ export function SidebarConversationList({
|
|||||||
)
|
)
|
||||||
const reviewConversationCount = reviewConversations.length
|
const reviewConversationCount = reviewConversations.length
|
||||||
|
|
||||||
const virtualizer = useVirtualizer({
|
|
||||||
count: flatItems.length,
|
|
||||||
getScrollElement: () => scrollContainerRef.current,
|
|
||||||
estimateSize: (index) =>
|
|
||||||
flatItems[index].type === "header" ? HEADER_HEIGHT : CARD_HEIGHT,
|
|
||||||
getItemKey: (index) => {
|
|
||||||
const item = flatItems[index]
|
|
||||||
return item.type === "header"
|
|
||||||
? `header-${item.status}`
|
|
||||||
: `conv-${item.conversation.id}`
|
|
||||||
},
|
|
||||||
overscan: 5,
|
|
||||||
})
|
|
||||||
|
|
||||||
virtualizerRef.current = virtualizer
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
scrollToActiveRef.current = () => {
|
scrollToActiveRef.current = () => {
|
||||||
if (!selectedConversation) return
|
if (!selectedConversation) return
|
||||||
@@ -312,7 +294,7 @@ export function SidebarConversationList({
|
|||||||
if (index >= 0) {
|
if (index >= 0) {
|
||||||
virtualizerRef.current?.scrollToIndex(index, {
|
virtualizerRef.current?.scrollToIndex(index, {
|
||||||
align: "center",
|
align: "center",
|
||||||
behavior: "smooth",
|
smooth: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -494,33 +476,20 @@ export function SidebarConversationList({
|
|||||||
ref={scrollContainerRef}
|
ref={scrollContainerRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex-1 min-h-0 overflow-y-auto px-1.5",
|
"flex-1 min-h-0 overflow-y-auto px-1.5",
|
||||||
|
"[overflow-anchor:none]",
|
||||||
"[&::-webkit-scrollbar]:w-1.5",
|
"[&::-webkit-scrollbar]:w-1.5",
|
||||||
"[&::-webkit-scrollbar-thumb]:rounded-full",
|
"[&::-webkit-scrollbar-thumb]:rounded-full",
|
||||||
"[&::-webkit-scrollbar-thumb]:bg-border"
|
"[&::-webkit-scrollbar-thumb]:bg-border"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div
|
<Virtualizer ref={virtualizerRef} itemSize={CARD_HEIGHT}>
|
||||||
style={{
|
{flatItems.map((item) => {
|
||||||
height: virtualizer.getTotalSize(),
|
const key =
|
||||||
position: "relative",
|
item.type === "header"
|
||||||
width: "100%",
|
? `header-${item.status}`
|
||||||
}}
|
: `conv-${item.conversation.id}`
|
||||||
>
|
|
||||||
{virtualizer.getVirtualItems().map((virtualRow) => {
|
|
||||||
const item = flatItems[virtualRow.index]
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div key={key}>
|
||||||
key={virtualRow.key}
|
|
||||||
data-index={virtualRow.index}
|
|
||||||
ref={virtualizer.measureElement}
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
width: "100%",
|
|
||||||
transform: `translateY(${virtualRow.start}px)`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{item.type === "header" ? (
|
{item.type === "header" ? (
|
||||||
item.status === "pending_review" ? (
|
item.status === "pending_review" ? (
|
||||||
<PendingReviewHeader
|
<PendingReviewHeader
|
||||||
@@ -563,7 +532,7 @@ export function SidebarConversationList({
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</Virtualizer>
|
||||||
</div>
|
</div>
|
||||||
</ContextMenuTrigger>
|
</ContextMenuTrigger>
|
||||||
<ContextMenuContent>
|
<ContextMenuContent>
|
||||||
|
|||||||
@@ -376,8 +376,6 @@ export function MessageListView({
|
|||||||
getItemKey={(item) => item.key}
|
getItemKey={(item) => item.key}
|
||||||
renderItem={renderThreadItem}
|
renderItem={renderThreadItem}
|
||||||
emptyState={emptyState}
|
emptyState={emptyState}
|
||||||
estimateSize={100}
|
|
||||||
overscan={10}
|
|
||||||
/>
|
/>
|
||||||
<MessageThreadScrollButton />
|
<MessageThreadScrollButton />
|
||||||
</MessageThread>
|
</MessageThread>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useCallback, useEffect } from "react"
|
import { useMemo } from "react"
|
||||||
import type { ReactNode } from "react"
|
import type { CSSProperties, ReactNode, RefObject } from "react"
|
||||||
import { useVirtualizer } from "@tanstack/react-virtual"
|
import { Virtualizer } from "virtua"
|
||||||
import { useStickToBottomContext } from "use-stick-to-bottom"
|
import { useStickToBottomContext } from "use-stick-to-bottom"
|
||||||
import {
|
import {
|
||||||
MessageThreadContent,
|
MessageThreadContent,
|
||||||
@@ -11,15 +11,29 @@ import {
|
|||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
interface VirtualizedMessageThreadProps<T> {
|
interface VirtualizedMessageThreadProps<T> {
|
||||||
|
/** Data to virtualise — each entry becomes one virtual row. */
|
||||||
items: T[]
|
items: T[]
|
||||||
|
/** Stable key for a given item (used as React key). */
|
||||||
getItemKey: (item: T, index: number) => string
|
getItemKey: (item: T, index: number) => string
|
||||||
|
/** Render the content of one row. */
|
||||||
renderItem: (item: T, index: number) => ReactNode
|
renderItem: (item: T, index: number) => ReactNode
|
||||||
|
/** Shown when `items` is empty. */
|
||||||
emptyState?: ReactNode
|
emptyState?: ReactNode
|
||||||
estimateSize?: number
|
/**
|
||||||
overscan?: number
|
* Hint for the initial height (px) of an unmeasured item.
|
||||||
|
* Virtua auto-measures every item once mounted, so this only
|
||||||
|
* affects the very first paint — omit it if you don't care.
|
||||||
|
*/
|
||||||
|
itemSize?: number
|
||||||
|
/** Vertical gap between items in px. @default 16 */
|
||||||
gap?: number
|
gap?: number
|
||||||
|
/** Vertical padding before the first / after the last item. @default 16 */
|
||||||
|
padding?: number
|
||||||
|
/** Extra className on every item's inner wrapper (the `max-w-3xl` div). */
|
||||||
className?: string
|
className?: string
|
||||||
|
/** Extra className on the MessageThreadContent shell. */
|
||||||
contentClassName?: string
|
contentClassName?: string
|
||||||
|
/** Extra props forwarded to MessageThreadContent. */
|
||||||
contentProps?: Omit<MessageThreadContentProps, "children" | "className">
|
contentProps?: Omit<MessageThreadContentProps, "children" | "className">
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,83 +42,58 @@ export function VirtualizedMessageThread<T>({
|
|||||||
getItemKey,
|
getItemKey,
|
||||||
renderItem,
|
renderItem,
|
||||||
emptyState,
|
emptyState,
|
||||||
estimateSize = 160,
|
itemSize,
|
||||||
overscan = 8,
|
|
||||||
gap = 16,
|
gap = 16,
|
||||||
|
padding = 16,
|
||||||
className,
|
className,
|
||||||
contentClassName,
|
contentClassName,
|
||||||
contentProps,
|
contentProps,
|
||||||
}: VirtualizedMessageThreadProps<T>) {
|
}: VirtualizedMessageThreadProps<T>) {
|
||||||
const { scrollRef } = useStickToBottomContext()
|
const { scrollRef } = useStickToBottomContext()
|
||||||
|
|
||||||
// eslint-disable-next-line react-hooks/incompatible-library
|
// Pre-compute the three possible padding styles so every render reuses
|
||||||
const virtualizer = useVirtualizer({
|
// the same object references (avoids allocating per-item on each frame).
|
||||||
count: items.length,
|
const styles = useMemo(() => {
|
||||||
getScrollElement: () => scrollRef.current,
|
const halfGap = gap / 2
|
||||||
estimateSize: () => estimateSize,
|
return {
|
||||||
overscan,
|
only: { paddingTop: padding, paddingBottom: padding } as CSSProperties,
|
||||||
useAnimationFrameWithResizeObserver: true,
|
first: { paddingTop: padding, paddingBottom: halfGap } as CSSProperties,
|
||||||
isScrollingResetDelay: 100,
|
middle: { paddingTop: halfGap, paddingBottom: halfGap } as CSSProperties,
|
||||||
paddingStart: 16,
|
last: { paddingTop: halfGap, paddingBottom: padding } as CSSProperties,
|
||||||
paddingEnd: 16,
|
}
|
||||||
gap,
|
}, [gap, padding])
|
||||||
getItemKey: (index) => {
|
|
||||||
const item = items[index]
|
|
||||||
return item ? getItemKey(item, index) : index
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// Adjust scroll position when items above the viewport are measured
|
const itemStyle = (index: number, total: number) => {
|
||||||
// differently from their estimates — prevents blank gaps when scrolling up
|
if (total === 1) return styles.only
|
||||||
useEffect(() => {
|
if (index === 0) return styles.first
|
||||||
virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (
|
if (index === total - 1) return styles.last
|
||||||
item,
|
return styles.middle
|
||||||
_delta,
|
}
|
||||||
instance
|
|
||||||
) => item.start < (instance.scrollOffset ?? 0)
|
|
||||||
}, [virtualizer])
|
|
||||||
|
|
||||||
const renderVirtualRow = useCallback(
|
|
||||||
(virtualItem: ReturnType<typeof virtualizer.getVirtualItems>[number]) => {
|
|
||||||
const item = items[virtualItem.index]
|
|
||||||
if (!item) return null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={virtualItem.key}
|
|
||||||
ref={virtualizer.measureElement}
|
|
||||||
data-index={virtualItem.index}
|
|
||||||
className="absolute left-0 top-0 w-full bg-background"
|
|
||||||
style={{
|
|
||||||
transform: `translate3d(0, ${virtualItem.start}px, 0)`,
|
|
||||||
willChange: "transform",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className={cn("mx-auto max-w-3xl px-4", className)}>
|
|
||||||
{renderItem(item, virtualItem.index)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
[className, items, renderItem, virtualizer]
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MessageThreadContent
|
<MessageThreadContent
|
||||||
className={cn("mx-0 max-w-none p-0", contentClassName)}
|
className={cn("mx-0 max-w-none p-0", contentClassName)}
|
||||||
|
scrollClassName="[overflow-anchor:none]"
|
||||||
{...contentProps}
|
{...contentProps}
|
||||||
>
|
>
|
||||||
{items.length === 0 ? (
|
{items.length === 0 ? (
|
||||||
(emptyState ?? null)
|
(emptyState ?? null)
|
||||||
) : (
|
) : (
|
||||||
<div
|
<Virtualizer
|
||||||
className="relative w-full"
|
scrollRef={scrollRef as unknown as RefObject<HTMLElement | null>}
|
||||||
style={{
|
itemSize={itemSize}
|
||||||
height: `${virtualizer.getTotalSize()}px`,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{virtualizer.getVirtualItems().map(renderVirtualRow)}
|
{items.map((item, index) => (
|
||||||
</div>
|
<div
|
||||||
|
key={getItemKey(item, index)}
|
||||||
|
style={itemStyle(index, items.length)}
|
||||||
|
>
|
||||||
|
<div className={cn("mx-auto max-w-3xl px-4", className)}>
|
||||||
|
{renderItem(item, index)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Virtualizer>
|
||||||
)}
|
)}
|
||||||
</MessageThreadContent>
|
</MessageThreadContent>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user