Coves frontend - a photon fork
at main 269 lines 6.8 kB view raw
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>