add virtual scrolling — only render visible cards, massive memory reduction
This commit is contained in:
parent
075f585612
commit
f8a5f9aa6d
118
index.html
118
index.html
@ -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) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user