Skip to content

Commit 5417ff8

Browse files
committed
Add viewport windowing to LocalBrowser for 10k+ wallpapers
LocalBrowser previously mounted every card via {#each filtered} regardless of viewport size. With 10k+ wallpapers in ~/Wallpapers this froze the renderer at startup: 10k cards x (LazyImage + TagPicker + several SVG buttons) = 30k+ Svelte component instances, 150k+ DOM nodes. Replaced with a windowed slice keyed off scroll position. ResizeObserver tracks container size, derived columns/rowHeight compute the visible range, top/bottom spacer divs preserve the full scrollable height so the native scrollbar still feels right. At any scroll position only ~50-70 cards stay mounted regardless of total list size.
1 parent f45706a commit 5417ff8

1 file changed

Lines changed: 81 additions & 2 deletions

File tree

frontend/src/lib/components/local/LocalBrowser.svelte

Lines changed: 81 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,41 @@
3535
let previewIndex = $state(-1);
3636
let previewSrc = $state<string>('');
3737
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+
3852
onMount(() => {
3953
loadWallpapers();
4054
});
4155
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+
4273
async function loadWallpapers() {
4374
isLoading = true;
4475
try {
@@ -80,6 +111,43 @@
80111
})()
81112
);
82113
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+
83151
function selectWallpaper(path: string) {
84152
setWallpaperPath(path);
85153
setActiveTab('editor');
@@ -202,7 +270,11 @@
202270
>
203271
</div>
204272

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+
>
206278
{#if isLoading}
207279
<LoadingState message="Scanning ~/Wallpapers…" />
208280
{:else if filtered.length === 0}
@@ -247,10 +319,14 @@
247319
</EmptyState>
248320
{/if}
249321
{:else}
322+
{#if topSpacer > 0}
323+
<div aria-hidden="true" style="height: {topSpacer}px"></div>
324+
{/if}
250325
<div
251326
class="grid grid-cols-[repeat(auto-fill,minmax(180px,1fr))] gap-2"
252327
>
253-
{#each filtered as wp, i (wp.path)}
328+
{#each visibleSlice as wp, sliceIdx (wp.path)}
329+
{@const i = visibleStart + sliceIdx}
254330
<div
255331
class="bg-bg-surface border-border hover:border-border-focus group relative border transition-colors duration-100"
256332
>
@@ -331,6 +407,9 @@
331407
</div>
332408
{/each}
333409
</div>
410+
{#if bottomSpacer > 0}
411+
<div aria-hidden="true" style="height: {bottomSpacer}px"></div>
412+
{/if}
334413
{/if}
335414
</div>
336415
</div>

0 commit comments

Comments
 (0)