From f8a5f9aa6da98f3b937a9d86d098e9b737810210 Mon Sep 17 00:00:00 2001 From: "Steven (Zen)" Date: Sat, 15 Aug 2026 12:54:12 -0600 Subject: [PATCH] =?UTF-8?q?add=20virtual=20scrolling=20=E2=80=94=20only=20?= =?UTF-8?q?render=20visible=20cards,=20massive=20memory=20reduction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- index.html | 118 +++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 93 insertions(+), 25 deletions(-) diff --git a/index.html b/index.html index 113a9a1..a351652 100644 --- a/index.html +++ b/index.html @@ -177,6 +177,12 @@ let currentLibrary = null; let currentFolder = null; // null = all let currentImageId = null; +// --- Virtual scroller state --- +const OVERSCAN = 2; // extra rows to render above/below viewport +let vCols = 1; +let vRowHeight = 0; +let vScrollRAF = null; + // Init: load library list async function init() { const res = await fetch('/api/libraries'); @@ -274,6 +280,7 @@ function filterImages() { function renderGrid() { const grid = document.getElementById('grid'); const empty = document.getElementById('empty'); + const container = document.getElementById('grid-container'); const stats = document.getElementById('stats'); stats.textContent = `${filteredImages.length} items`; @@ -286,35 +293,95 @@ function renderGrid() { empty.style.display = 'none'; grid.style.display = 'grid'; - grid.innerHTML = ''; + container.scrollTop = 0; - filteredImages.forEach(img => { - const card = document.createElement('div'); - card.className = 'card'; - card.onclick = () => openLightbox(img); + // Measure column count and row height from CSS grid + // We do one measurement pass with a dummy card first + function measureGrid() { + const thumbSize = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--thumb-size')) || 180; + const gridWidth = grid.clientWidth || container.clientWidth - 32; + vCols = Math.max(1, Math.floor((gridWidth + 12) / (thumbSize + 12))); + // Card height: thumb aspect 16/10 + info ~60px + vRowHeight = Math.round((thumbSize * 10 / 16)) + 62 + 12; // thumb-h + info + gap + } + measureGrid(); - const thumbSrc = img._thumbnail - ? `/api/thumbnail/${currentLibrary}/${img.id}` - : null; + // Detach scroll listener before wiping grid + container.onscroll = null; - const extIcon = extToIcon(img.ext); + function renderVisible() { + measureGrid(); + const totalRows = Math.ceil(filteredImages.length / vCols); + const totalHeight = totalRows * vRowHeight; - card.innerHTML = ` -
- ${thumbSrc - ? `${escHtml(img.name)}` - : ''} - ${extIcon} - ${img.ext || '?'} -
-
-
${escHtml(img.name)}
-
${img.ext?.toUpperCase() || ''} ${img.duration ? '· ' + fmtDuration(img.duration) : ''}
- ${img.tags?.length ? `
${img.tags.map(t => `${escHtml(t)}`).join('')}
` : ''} -
- `; - grid.appendChild(card); - }); + const scrollTop = container.scrollTop; + const viewHeight = container.clientHeight; + + const firstVisRow = Math.max(0, Math.floor(scrollTop / vRowHeight) - OVERSCAN); + const lastVisRow = Math.min(totalRows - 1, Math.ceil((scrollTop + viewHeight) / vRowHeight) + OVERSCAN); + + const firstIdx = firstVisRow * vCols; + const lastIdx = Math.min(filteredImages.length - 1, (lastVisRow + 1) * vCols - 1); + + // Spacer above + const topH = firstVisRow * vRowHeight; + // Spacer below + const bottomH = Math.max(0, (totalRows - lastVisRow - 1) * vRowHeight); + + grid.innerHTML = ''; + + // Top spacer + if (topH > 0) { + const spacer = document.createElement('div'); + spacer.style.cssText = `grid-column: 1 / -1; height: ${topH}px; pointer-events: none;`; + grid.appendChild(spacer); + } + + // Render only visible cards + for (let i = firstIdx; i <= lastIdx; i++) { + const img = filteredImages[i]; + const card = document.createElement('div'); + card.className = 'card'; + card.onclick = () => openLightbox(img); + + const thumbSrc = img._thumbnail ? `/api/thumbnail/${currentLibrary}/${img.id}` : null; + const extIcon = extToIcon(img.ext); + + card.innerHTML = ` +
+ ${thumbSrc + ? `${escHtml(img.name)}` + : ''} + ${extIcon} + ${img.ext || '?'} +
+
+
${escHtml(img.name)}
+
${img.ext?.toUpperCase() || ''} ${img.duration ? '· ' + fmtDuration(img.duration) : ''}
+ ${img.tags?.length ? `
${img.tags.map(t => `${escHtml(t)}`).join('')}
` : ''} +
+ `; + grid.appendChild(card); + } + + // Bottom spacer + if (bottomH > 0) { + const spacer = document.createElement('div'); + spacer.style.cssText = `grid-column: 1 / -1; height: ${bottomH}px; pointer-events: none;`; + grid.appendChild(spacer); + } + } + + renderVisible(); + + // Re-render on scroll (throttled via rAF) + container.onscroll = () => { + if (vScrollRAF) return; + vScrollRAF = requestAnimationFrame(() => { + vScrollRAF = null; + renderVisible(); + }); + }; } function openLightbox(img) { @@ -359,6 +426,7 @@ document.addEventListener('keydown', e => { if (e.key === 'Escape') closeLightbo function updateThumbSize(val) { document.documentElement.style.setProperty('--thumb-size', val + 'px'); + if (filteredImages.length > 0) renderGrid(); } function extToIcon(ext) {