|
35 | 35 | let previewIndex = $state(-1); |
36 | 36 | let previewSrc = $state<string>(''); |
37 | 37 |
|
| 38 | + // Viewport windowing: at 10k+ wallpapers, mounting every card freezes the |
| 39 | + // renderer. We measure the scroll container, compute how many rows fit, |
| 40 | + // and only mount the visible slice (plus a small buffer). Spacer divs at |
| 41 | + // top/bottom preserve scrollbar position so scrolling feels native. |
| 42 | + const MIN_CARD_WIDTH = 180; // matches grid's minmax(180px, 1fr) |
| 43 | + const CARD_GAP = 8; // matches gap-2 |
| 44 | + const NAME_ROW_HEIGHT = 28; // TagPicker + name row |
| 45 | + const BUFFER_ROWS = 3; // rows rendered above + below the viewport |
| 46 | +
|
| 47 | + let scrollContainer = $state<HTMLDivElement | null>(null); |
| 48 | + let scrollTop = $state(0); |
| 49 | + let containerHeight = $state(600); |
| 50 | + let containerWidth = $state(800); |
| 51 | +
|
38 | 52 | onMount(() => { |
39 | 53 | loadWallpapers(); |
40 | 54 | }); |
41 | 55 |
|
| 56 | + $effect(() => { |
| 57 | + if (!scrollContainer) return; |
| 58 | + const measure = () => { |
| 59 | + if (!scrollContainer) return; |
| 60 | + containerHeight = scrollContainer.clientHeight; |
| 61 | + containerWidth = scrollContainer.clientWidth; |
| 62 | + }; |
| 63 | + measure(); |
| 64 | + const ro = new ResizeObserver(measure); |
| 65 | + ro.observe(scrollContainer); |
| 66 | + return () => ro.disconnect(); |
| 67 | + }); |
| 68 | +
|
| 69 | + function handleScroll(e: Event) { |
| 70 | + scrollTop = (e.currentTarget as HTMLDivElement).scrollTop; |
| 71 | + } |
| 72 | +
|
42 | 73 | async function loadWallpapers() { |
43 | 74 | isLoading = true; |
44 | 75 | try { |
|
80 | 111 | })() |
81 | 112 | ); |
82 | 113 |
|
| 114 | + // Available width is container minus horizontal padding (p-3 = 12px each side). |
| 115 | + let innerWidth = $derived(Math.max(0, containerWidth - 24)); |
| 116 | + let columns = $derived( |
| 117 | + Math.max( |
| 118 | + 1, |
| 119 | + Math.floor((innerWidth + CARD_GAP) / (MIN_CARD_WIDTH + CARD_GAP)) |
| 120 | + ) |
| 121 | + ); |
| 122 | + let cardWidth = $derived( |
| 123 | + columns > 0 |
| 124 | + ? (innerWidth - (columns - 1) * CARD_GAP) / columns |
| 125 | + : MIN_CARD_WIDTH |
| 126 | + ); |
| 127 | + // aspect-video thumb (16:9) + name row + bottom gap = one row's pitch. |
| 128 | + let rowHeight = $derived( |
| 129 | + Math.round((cardWidth * 9) / 16) + NAME_ROW_HEIGHT + CARD_GAP |
| 130 | + ); |
| 131 | + let totalRows = $derived(Math.ceil(filtered.length / columns)); |
| 132 | + let firstVisibleRow = $derived( |
| 133 | + Math.max(0, Math.floor(scrollTop / rowHeight) - BUFFER_ROWS) |
| 134 | + ); |
| 135 | + let lastVisibleRow = $derived( |
| 136 | + Math.min( |
| 137 | + totalRows, |
| 138 | + Math.ceil((scrollTop + containerHeight) / rowHeight) + BUFFER_ROWS |
| 139 | + ) |
| 140 | + ); |
| 141 | + let visibleStart = $derived(firstVisibleRow * columns); |
| 142 | + let visibleEnd = $derived( |
| 143 | + Math.min(filtered.length, lastVisibleRow * columns) |
| 144 | + ); |
| 145 | + let visibleSlice = $derived(filtered.slice(visibleStart, visibleEnd)); |
| 146 | + let topSpacer = $derived(firstVisibleRow * rowHeight); |
| 147 | + let bottomSpacer = $derived( |
| 148 | + Math.max(0, (totalRows - lastVisibleRow) * rowHeight) |
| 149 | + ); |
| 150 | +
|
83 | 151 | function selectWallpaper(path: string) { |
84 | 152 | setWallpaperPath(path); |
85 | 153 | setActiveTab('editor'); |
|
202 | 270 | > |
203 | 271 | </div> |
204 | 272 |
|
205 | | - <div class="flex-1 overflow-y-auto p-3"> |
| 273 | + <div |
| 274 | + class="flex-1 overflow-y-auto p-3" |
| 275 | + bind:this={scrollContainer} |
| 276 | + onscroll={handleScroll} |
| 277 | + > |
206 | 278 | {#if isLoading} |
207 | 279 | <LoadingState message="Scanning ~/Wallpapers…" /> |
208 | 280 | {:else if filtered.length === 0} |
|
247 | 319 | </EmptyState> |
248 | 320 | {/if} |
249 | 321 | {:else} |
| 322 | + {#if topSpacer > 0} |
| 323 | + <div aria-hidden="true" style="height: {topSpacer}px"></div> |
| 324 | + {/if} |
250 | 325 | <div |
251 | 326 | class="grid grid-cols-[repeat(auto-fill,minmax(180px,1fr))] gap-2" |
252 | 327 | > |
253 | | - {#each filtered as wp, i (wp.path)} |
| 328 | + {#each visibleSlice as wp, sliceIdx (wp.path)} |
| 329 | + {@const i = visibleStart + sliceIdx} |
254 | 330 | <div |
255 | 331 | class="bg-bg-surface border-border hover:border-border-focus group relative border transition-colors duration-100" |
256 | 332 | > |
|
331 | 407 | </div> |
332 | 408 | {/each} |
333 | 409 | </div> |
| 410 | + {#if bottomSpacer > 0} |
| 411 | + <div aria-hidden="true" style="height: {bottomSpacer}px"></div> |
| 412 | + {/if} |
334 | 413 | {/if} |
335 | 414 | </div> |
336 | 415 | </div> |
|
0 commit comments