add virtual scrolling — only render visible cards, massive memory reduction

This commit is contained in:
Steven (Zen) 2026-08-15 12:54:12 -06:00
parent 075f585612
commit f8a5f9aa6d

View File

@ -177,6 +177,12 @@ let currentLibrary = null;
let currentFolder = null; // null = all let currentFolder = null; // null = all
let currentImageId = null; 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 // Init: load library list
async function init() { async function init() {
const res = await fetch('/api/libraries'); const res = await fetch('/api/libraries');
@ -274,6 +280,7 @@ function filterImages() {
function renderGrid() { function renderGrid() {
const grid = document.getElementById('grid'); const grid = document.getElementById('grid');
const empty = document.getElementById('empty'); const empty = document.getElementById('empty');
const container = document.getElementById('grid-container');
const stats = document.getElementById('stats'); const stats = document.getElementById('stats');
stats.textContent = `${filteredImages.length} items`; stats.textContent = `${filteredImages.length} items`;
@ -286,35 +293,95 @@ function renderGrid() {
empty.style.display = 'none'; empty.style.display = 'none';
grid.style.display = 'grid'; grid.style.display = 'grid';
grid.innerHTML = ''; container.scrollTop = 0;
filteredImages.forEach(img => { // Measure column count and row height from CSS grid
const card = document.createElement('div'); // We do one measurement pass with a dummy card first
card.className = 'card'; function measureGrid() {
card.onclick = () => openLightbox(img); 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 // Detach scroll listener before wiping grid
? `/api/thumbnail/${currentLibrary}/${img.id}` container.onscroll = null;
: null;
const extIcon = extToIcon(img.ext); function renderVisible() {
measureGrid();
const totalRows = Math.ceil(filteredImages.length / vCols);
const totalHeight = totalRows * vRowHeight;
card.innerHTML = ` const scrollTop = container.scrollTop;
<div class="thumb-wrap"> const viewHeight = container.clientHeight;
${thumbSrc
? `<img src="${thumbSrc}" loading="lazy" alt="${escHtml(img.name)}" onerror="this.style.display='none';this.nextElementSibling.style.display='flex'">` const firstVisRow = Math.max(0, Math.floor(scrollTop / vRowHeight) - OVERSCAN);
: ''} const lastVisRow = Math.min(totalRows - 1, Math.ceil((scrollTop + viewHeight) / vRowHeight) + OVERSCAN);
<span class="thumb-placeholder" style="${thumbSrc ? 'display:none' : ''}">${extIcon}</span>
<span class="ext-badge">${img.ext || '?'}</span> const firstIdx = firstVisRow * vCols;
</div> const lastIdx = Math.min(filteredImages.length - 1, (lastVisRow + 1) * vCols - 1);
<div class="card-info">
<div class="card-name" title="${escHtml(img.name)}">${escHtml(img.name)}</div> // Spacer above
<div class="card-meta">${img.ext?.toUpperCase() || ''} ${img.duration ? '· ' + fmtDuration(img.duration) : ''}</div> const topH = firstVisRow * vRowHeight;
${img.tags?.length ? `<div class="card-tags">${img.tags.map(t => `<span class="tag">${escHtml(t)}</span>`).join('')}</div>` : ''} // Spacer below
</div> const bottomH = Math.max(0, (totalRows - lastVisRow - 1) * vRowHeight);
`;
grid.appendChild(card); 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 = `
<div class="thumb-wrap">
${thumbSrc
? `<img src="${thumbSrc}" alt="${escHtml(img.name)}" onerror="this.style.display='none';this.nextElementSibling.style.display='flex'">`
: ''}
<span class="thumb-placeholder" style="${thumbSrc ? 'display:none' : ''}">${extIcon}</span>
<span class="ext-badge">${img.ext || '?'}</span>
</div>
<div class="card-info">
<div class="card-name" title="${escHtml(img.name)}">${escHtml(img.name)}</div>
<div class="card-meta">${img.ext?.toUpperCase() || ''} ${img.duration ? '· ' + fmtDuration(img.duration) : ''}</div>
${img.tags?.length ? `<div class="card-tags">${img.tags.map(t => `<span class="tag">${escHtml(t)}</span>`).join('')}</div>` : ''}
</div>
`;
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) { function openLightbox(img) {
@ -359,6 +426,7 @@ document.addEventListener('keydown', e => { if (e.key === 'Escape') closeLightbo
function updateThumbSize(val) { function updateThumbSize(val) {
document.documentElement.style.setProperty('--thumb-size', val + 'px'); document.documentElement.style.setProperty('--thumb-size', val + 'px');
if (filteredImages.length > 0) renderGrid();
} }
function extToIcon(ext) { function extToIcon(ext) {