返回 presentation-ai
scroll-list.tsx
根目录 / src / components / ui / scroll-list.tsx
1 "use client";
2
3 import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
4 import {
5 useEffect,
6 useMemo,
7 useRef,
8 useState,
9 type CSSProperties,
10 type ReactNode,
11 } from "react";
12
13 import { cn } from "@/lib/utils";
14 import { ScrollBar } from "./scroll-area";
15
16 type ScrollListVirtualItem<TItem> = {
17 height: number;
18 index: number;
19 item: TItem;
20 key: string;
21 top: number;
22 };
23
24 export type ScrollListRange = {
25 scrollTop: number;
26 viewportHeight: number;
27 };
28
29 type PositionedScrollListItem<TItem> = ScrollListVirtualItem<TItem> & {
30 bottom: number;
31 };
32
33 type ScrollListProps<TItem> = {
34 className?: string;
35 contentClassName?: string;
36 contentStyle?: CSSProperties;
37 gap?: number;
38 getItemHeight: (item: TItem, index: number) => number;
39 getItemKey: (item: TItem, index: number) => string;
40 items: readonly TItem[];
41 onRangeChange?: (range: ScrollListRange) => void;
42 overscan?: number;
43 paddingBottom?: number;
44 renderItem: (virtualItem: ScrollListVirtualItem<TItem>) => ReactNode;
45 };
46
47 function getPositionedItems<TItem>(
48 items: readonly TItem[],
49 getItemHeight: (item: TItem, index: number) => number,
50 getItemKey: (item: TItem, index: number) => string,
51 gap: number,
52 ): {
53 positionedItems: PositionedScrollListItem<TItem>[];
54 totalMeasuredHeight: number;
55 } {
56 const positionedItems: PositionedScrollListItem<TItem>[] = [];
57 let top = 0;
58
59 items.forEach((item, index) => {
60 const height = getItemHeight(item, index);
61 const key = getItemKey(item, index);
62 const bottom = top + height;
63
64 positionedItems.push({
65 bottom,
66 height,
67 index,
68 item,
69 key,
70 top,
71 });
72
73 top = bottom + gap;
74 });
75
76 return {
77 positionedItems,
78 totalMeasuredHeight: Math.max(0, top - gap),
79 };
80 }
81
82 export function ScrollList<TItem>({
83 className,
84 contentClassName,
85 contentStyle,
86 gap = 0,
87 getItemHeight,
88 getItemKey,
89 items,
90 onRangeChange,
91 overscan = 0,
92 paddingBottom = 0,
93 renderItem,
94 }: ScrollListProps<TItem>) {
95 const viewportRef = useRef<HTMLDivElement | null>(null);
96 const onRangeChangeRef = useRef(onRangeChange);
97 const [scrollTop, setScrollTop] = useState(0);
98 const [viewportHeight, setViewportHeight] = useState(0);
99 const { positionedItems, totalMeasuredHeight } = useMemo(
100 () => getPositionedItems(items, getItemHeight, getItemKey, gap),
101 [gap, getItemHeight, getItemKey, items],
102 );
103 const visibleItems = useMemo(() => {
104 const visibleStart = Math.max(0, scrollTop - overscan);
105 const visibleEnd = scrollTop + viewportHeight + overscan;
106
107 return positionedItems.filter(
108 (item) => item.bottom + gap >= visibleStart && item.top <= visibleEnd,
109 );
110 }, [gap, overscan, positionedItems, scrollTop, viewportHeight]);
111
112 useEffect(() => {
113 onRangeChangeRef.current = onRangeChange;
114 }, [onRangeChange]);
115
116 useEffect(() => {
117 const viewport = viewportRef.current;
118 if (!viewport) return;
119
120 const updateViewportHeight = () => {
121 setViewportHeight((height) =>
122 height === viewport.clientHeight ? height : viewport.clientHeight,
123 );
124 };
125
126 updateViewportHeight();
127
128 const observer = new ResizeObserver(updateViewportHeight);
129 observer.observe(viewport);
130
131 return () => observer.disconnect();
132 }, []);
133
134 useEffect(() => {
135 onRangeChangeRef.current?.({ scrollTop, viewportHeight });
136 }, [scrollTop, viewportHeight]);
137
138 return (
139 <ScrollAreaPrimitive.Root
140 className={cn("relative h-full overflow-hidden", className)}
141 >
142 <ScrollAreaPrimitive.Viewport
143 ref={viewportRef}
144 className="h-full w-full"
145 onScroll={(event) => {
146 const nextScrollTop = event.currentTarget.scrollTop;
147 setScrollTop((currentScrollTop) =>
148 currentScrollTop === nextScrollTop
149 ? currentScrollTop
150 : nextScrollTop,
151 );
152 }}
153 >
154 <div
155 className={cn("relative", contentClassName)}
156 style={{
157 ...contentStyle,
158 height: totalMeasuredHeight + paddingBottom,
159 }}
160 >
161 {visibleItems.map((virtualItem) => (
162 <div
163 key={virtualItem.key}
164 className="absolute right-0 left-0"
165 style={{
166 height: virtualItem.height,
167 transform: `translateY(${virtualItem.top}px)`,
168 }}
169 >
170 {renderItem(virtualItem)}
171 </div>
172 ))}
173 </div>
174 </ScrollAreaPrimitive.Viewport>
175 <ScrollBar />
176 <ScrollAreaPrimitive.Corner />
177 </ScrollAreaPrimitive.Root>
178 );
179 }
180
180 lines Plain Text