Coves frontend - a photon fork
1<script lang="ts" generics="T">
2 import { browser } from '$app/environment'
3 import { Expandable } from 'mono-svelte'
4 import { debounce } from 'mono-svelte/util/time'
5 import { type Snippet, onDestroy, onMount, untrack } from 'svelte'
6 import type { HTMLAttributes } from 'svelte/elements'
7 import { innerHeight } from 'svelte/reactivity/window'
8 import { settings } from '../settings.svelte'
9
10 interface Props extends HTMLAttributes<HTMLDivElement> {
11 items: T[]
12 estimatedHeight?: number
13 overscan?: number
14 item: Snippet<[number]>
15 restore?: {
16 itemHeights: (number | null)[]
17 }
18 initialOffset?: number
19 debounceResize?: number
20 useWindow?: boolean
21 height?: number
22 }
23
24 let {
25 items,
26 estimatedHeight = 100,
27 overscan = 6,
28 item: itemSnippet,
29 initialOffset = 0,
30 restore = $bindable(),
31 debounceResize = 100,
32 useWindow = true,
33 height = 0,
34 ...rest
35 }: Props = $props()
36
37 let initialRender = false
38
39 export function scrollToIndex(index: number, useWindow: boolean = false) {
40 const targetPx = cumulativeItemHeights[index] - (initialOffset || 0)
41 if (targetPx < (innerHeight.current ?? 0)) return
42 scrollY = targetPx
43 if (useWindow && browser) {
44 requestAnimationFrame(() => {
45 window.scrollTo({ behavior: 'instant', top: scrollY })
46 })
47 }
48 }
49
50 onDestroy(() => {
51 restore = {
52 itemHeights: itemHeights,
53 }
54 })
55
56 let virtualListEl = $state<HTMLElement>()
57
58 let itemHeights = $state<(number | null)[]>([
59 ...(restore?.itemHeights ?? Array(items.length).fill(null)),
60 ])
61
62 let cumulativeItemHeights = $derived.by<number[]>(() => {
63 let cumulation = new Array(itemHeights.length)
64 let sum = 0
65
66 for (let i = 0; i < itemHeights.length; i++) {
67 const height = itemHeights[i] || estimatedHeight
68 sum += height
69 cumulation[i] = sum
70 }
71
72 return cumulation
73 })
74
75 let scrollY = $state(0)
76 let viewportHeight = $state(0)
77 let visibleItems = $state<{ index: number; offset: number }[]>([])
78
79 $effect.pre(() => {
80 if (items.length > itemHeights.length) {
81 const missing = items.length - itemHeights.length
82 itemHeights = [...itemHeights, ...Array(missing).fill(null)]
83 }
84 })
85
86 $effect(() => {
87 if (items.length) {
88 untrack(() => {
89 visibleItems = updateVisibleItems()
90 })
91 }
92 })
93
94 function findFirstVisibleIndex(
95 scrollTop: number,
96 cumulativeHeights: number[],
97 ): number {
98 let low = 0
99 let high = cumulativeHeights.length - 1
100 let mid = 0
101
102 while (low <= high) {
103 mid = Math.floor((low + high) / 2)
104
105 if (cumulativeHeights[mid] <= scrollTop) low = mid + 1
106 else high = mid - 1
107 }
108
109 return low
110 }
111
112 function updateVisibleItems() {
113 if (!virtualListEl) return []
114
115 viewportHeight = innerHeight?.current ?? 1000
116 const scrollTop = scrollY - initialOffset
117
118 let newVisibleItems: { index: number; offset: number }[] = []
119
120 const firstIndex = findFirstVisibleIndex(scrollTop, cumulativeItemHeights)
121
122 const startIndex = Math.max(0, firstIndex - overscan)
123
124 let i = startIndex
125 let offset = i == 0 ? 0 : cumulativeItemHeights[i - 1]
126
127 while (i < items.length) {
128 newVisibleItems.push({ index: i, offset: offset })
129 const height = itemHeights[i] || estimatedHeight
130 offset += height
131
132 if (offset > scrollTop + viewportHeight + overscan * estimatedHeight)
133 break
134
135 i++
136 }
137
138 return newVisibleItems ?? []
139 }
140
141 function resizeObserver(node: HTMLElement) {
142 observer.observe(node)
143
144 return {
145 destroy() {
146 observer.unobserve(node)
147 },
148 }
149 }
150
151 const debouncedUpdate = debounce((entries: ResizeObserverEntry[]) => {
152 for (const entry of entries) {
153 const indexAttr = entry.target.getAttribute('data-index')
154 if (indexAttr === null) continue
155 const index = Number(indexAttr)
156 if (isNaN(index)) continue
157
158 const newHeight = entry.contentRect.height
159 if (itemHeights[index] !== newHeight) {
160 itemHeights[index] = newHeight
161 if (!initialRender) visibleItems = updateVisibleItems()
162 }
163 }
164 }, debounceResize)
165
166 const observer = new ResizeObserver((entries) => {
167 debouncedUpdate(entries)
168 })
169
170 onDestroy(() => {
171 observer.disconnect()
172 })
173
174 // Scroll position changes (only every few px)
175 let oldScroll = $state(0)
176 $effect(() => {
177 const currentScrollY = scrollY ?? 0
178 untrack(() => {
179 if (Math.abs(currentScrollY - oldScroll) > estimatedHeight) {
180 visibleItems = updateVisibleItems()
181 oldScroll = currentScrollY
182 }
183 })
184 })
185
186 onMount(() => {
187 if (virtualListEl && browser) {
188 Array.from(virtualListEl.children).forEach((node) => {
189 const indexAttr = node.getAttribute('data-index')
190 if (indexAttr === null) return
191 const index = Number(indexAttr)
192 if (isNaN(index)) return
193
194 const newHeight = node.getBoundingClientRect().height
195 if (itemHeights[index] !== newHeight) {
196 itemHeights[index] = newHeight
197 }
198 })
199
200 untrack(() => {
201 visibleItems = updateVisibleItems()
202 initialRender = false
203 })
204 }
205 })
206</script>
207
208<svelte:window
209 bind:scrollY={
210 () => (useWindow ? scrollY : 0),
211 (v) => {
212 if (useWindow) scrollY = v
213 }
214 }
215/>
216
217<div
218 bind:this={virtualListEl}
219 style="position: relative; height: {height ||
220 cumulativeItemHeights[visibleItems?.[visibleItems.length - 1]?.index]}px;"
221 {...rest}
222 id="feed"
223 onscroll={() => {
224 if (!useWindow) scrollY = virtualListEl?.scrollTop ?? 0
225 }}
226>
227 <div
228 style="height: {cumulativeItemHeights[visibleItems?.[0]?.index - 1] ||
229 0}px; border: 0 !important;"
230 ></div>
231 {#each visibleItems as item (item.index)}
232 <div
233 data-index={item.index}
234 class="post-container fix-divide group/virtual"
235 use:resizeObserver
236 >
237 {@render itemSnippet(item.index)}
238 </div>
239 {/each}
240</div>
241{#if settings.debugInfo}
242 <Expandable>
243 {#snippet title()}
244 Debug
245 {/snippet}
246 <pre>
247 Virtual list debug info
248
249 List items: {items.length}
250 Rendering items: {visibleItems?.length} ({visibleItems?.[0]
251 ?.index} - {visibleItems?.[visibleItems?.length - 1]?.index})
252 Viewport height: {viewportHeight}
253 Current scroll position: {scrollY}
254 Container height: {cumulativeItemHeights[
255 visibleItems?.[visibleItems?.length - 1]?.index
256 ]}
257 Overscan: {overscan}
258 Guess item height: {estimatedHeight}
259 Bumpscosity: {Math.floor(Math.random() * 5000)}
260 Restore data: {JSON.stringify(restore)}
261 </pre>
262 </Expandable>
263{/if}
264
265<style>
266 .fix-divide:nth-child(2) {
267 border-top: 0;
268 }
269</style>