From 2bc77a0bb1425b7344490138b1722dcc5cb8b817 Mon Sep 17 00:00:00 2001 From: Diocrafts Date: Sat, 7 Mar 2026 20:46:04 +0100 Subject: [PATCH] perf(photos): append-only render eliminates DOM rebuild on scroll MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the innerHTML full-rebuild in _render() with two paths: - _renderFull(): used for first load, group-mode change, and deletions - _appendBatch(n): append-only for infinite-scroll pages — O(batch) instead of O(total). Existing nodes are never destroyed, eliminating the visual flash and unnecessary DOM churn. Also: - Extract _renderTile() helper (DRY tile HTML generation) - Extract _observeSentinel() helper - Scope _setupVideoThumbnails(startIndex) to only process new tiles - Add data-group attribute on headers for efficient CSS.escape lookup - Fix stale WebP references in comments (now JPEG) - Add virtual scrolling idea to TODO-LIST.md for future evaluation --- TODO-LIST.md | 4 + static/js/features/library/photos.js | 161 +++++++++++++++++++-------- 2 files changed, 117 insertions(+), 48 deletions(-) diff --git a/TODO-LIST.md b/TODO-LIST.md index 6ca4fef1..1f1d96b7 100755 --- a/TODO-LIST.md +++ b/TODO-LIST.md @@ -34,6 +34,10 @@ This document contains the task list for the development of OxiCloud, a minimali - [x] Implement multiple file uploads - [ ] Add progress indicators for long operations - [x] Implement UI notifications for events +- [ ] Photos timeline: virtual scrolling + - Solo mantener en el DOM las filas visibles en el viewport + un margen. Al hacer scroll, reciclar los nodos que salen por arriba para los que entran por abajo. + - Ventajas: Funciona perfectamente con 50,000 fotos. Uso de memoria constante. + - Excesiva para ahora — evaluar cuando el volumen de fotos lo justifique. ## Phase 2: Authentication and Multi-User diff --git a/static/js/features/library/photos.js b/static/js/features/library/photos.js index 7935557e..9b293a13 100755 --- a/static/js/features/library/photos.js +++ b/static/js/features/library/photos.js @@ -30,6 +30,8 @@ const photosView = { _activeDecodes: 0, /** @type {Array} Pending video decode queue */ _decodeQueue: [], + /** @type {number} Items already rendered in the DOM */ + _renderedCount: 0, PAGE_SIZE: 200, @@ -66,7 +68,8 @@ const photosView = { this.nextCursor = null; this.exhausted = false; this.selected.clear(); - this._render(); + this._renderedCount = 0; + this._container.innerHTML = ''; this._loadPage(); }, @@ -84,7 +87,8 @@ const photosView = { if (this.groupMode === mode) return; this.groupMode = mode; localStorage.setItem('oxicloud-photos-group', mode); - this._render(); + this._renderedCount = 0; + this._renderFull(); }, /** Fetch a page of photos from the API */ @@ -92,6 +96,7 @@ const photosView = { if (this.loading || this.exhausted) return; this.loading = true; this._showLoading(true); + const prevCount = this.items.length; try { let url = `/api/photos?limit=${this.PAGE_SIZE}`; @@ -125,16 +130,24 @@ const photosView = { } finally { this.loading = false; this._showLoading(false); - this._render(); + if (prevCount === 0) { + this._renderFull(); + } else { + this._appendBatch(prevCount); + } } }, - /** Render the full timeline from this.items */ - _render() { + // ── Rendering ─────────────────────────────────────────────────── + // Two render paths: + // _renderFull() — full DOM rebuild (first load, group-mode change, delete) + // _appendBatch(n) — append-only for infinite-scroll pages (O(batch)) + + /** Full DOM rebuild — first load, group-mode switch, or after deletions. */ + _renderFull() { if (!this._container) return; this._destroyObserver(); - // Set group mode class on container this._container.classList.remove('photos-group-daily', 'photos-group-monthly', 'photos-group-yearly'); this._container.classList.add(`photos-group-${this.groupMode}`); @@ -142,57 +155,104 @@ const photosView = { this._renderEmpty(); return; } - if (this.items.length === 0) return; - // Group by selected mode const groups = this._groupItems(this.items); let html = this._renderToolbar(); for (const [label, files] of groups) { - html += `
${this._escHtml(label)}${files.length}
`; + html += `
${this._escHtml(label)}${files.length}
`; html += '
'; - for (const file of files) { - const isVideo = file.mime_type && file.mime_type.startsWith('video/'); - const selected = this.selected.has(file.id) ? ' selected' : ''; - // For videos with a cached local thumb, use it directly; - // avoids the 204 → error → re-decode cycle on re-renders. - const cachedThumb = isVideo && this._videoThumbCache.has(file.id) - ? this._videoThumbCache.get(file.id) - : null; - const thumbUrl = cachedThumb || `/api/files/${file.id}/thumbnail/preview`; - html += `
`; - html += `
`; - html += `${this._escAttr(file.name)}`; - if (isVideo) { - html += `
`; - } - html += `
`; - } + for (const file of files) html += this._renderTile(file); html += '
'; } - // Sentinel for infinite scroll html += '
'; - this._container.innerHTML = html; - - // Attach click handlers via delegation this._container.onclick = (e) => this._handleClick(e); + this._renderedCount = this.items.length; + this._observeSentinel(); + this._setupVideoThumbnails(); + }, - // Observe sentinel for infinite scroll + /** Append-only render for infinite scroll — inserts only the items + * from this.items[startIndex..] without destroying existing DOM. + * Complexity: O(batch) instead of O(total_items). */ + _appendBatch(startIndex) { + if (!this._container) return; + this._destroyObserver(); + + const newItems = this.items.slice(startIndex); + if (newItems.length === 0) { + this._observeSentinel(); + return; + } + + const newGroups = this._groupItems(newItems); const sentinel = this._container.querySelector('.photos-sentinel'); + if (!sentinel) { + // Fallback: sentinel missing — full rebuild + this._renderedCount = 0; + this._renderFull(); + return; + } + + for (const [label, files] of newGroups) { + let tilesHtml = ''; + for (const file of files) tilesHtml += this._renderTile(file); + + // Does this date-group already exist in the DOM? + const existingHeader = this._container.querySelector( + `.photos-day-header[data-group="${CSS.escape(label)}"]` + ); + + if (existingHeader) { + // Append tiles to existing grid and update count badge + const grid = existingHeader.nextElementSibling; + if (grid && grid.classList.contains('photos-grid')) { + grid.insertAdjacentHTML('beforeend', tilesHtml); + const countSpan = existingHeader.querySelector('.photos-day-count'); + if (countSpan) countSpan.textContent = grid.children.length; + } + } else { + // New group — insert header + grid before sentinel + const sectionHtml = + `
${this._escHtml(label)}${files.length}
` + + `
${tilesHtml}
`; + sentinel.insertAdjacentHTML('beforebegin', sectionHtml); + } + } + + this._renderedCount = this.items.length; + this._observeSentinel(); + this._setupVideoThumbnails(startIndex); + }, + + /** Generate HTML for a single photo/video tile */ + _renderTile(file) { + const isVideo = file.mime_type && file.mime_type.startsWith('video/'); + const selected = this.selected.has(file.id) ? ' selected' : ''; + const cachedThumb = isVideo && this._videoThumbCache.has(file.id) + ? this._videoThumbCache.get(file.id) : null; + const thumbUrl = cachedThumb || `/api/files/${file.id}/thumbnail/preview`; + let h = `
`; + h += `
`; + h += `${this._escAttr(file.name)}`; + if (isVideo) h += `
`; + h += `
`; + return h; + }, + + /** (Re-)observe the sentinel element for infinite scroll */ + _observeSentinel() { + this._destroyObserver(); + const sentinel = this._container?.querySelector('.photos-sentinel'); if (sentinel && !this.exhausted) { this._observer = new IntersectionObserver((entries) => { - if (entries[0].isIntersecting) { - this._loadPage(); - } + if (entries[0].isIntersecting) this._loadPage(); }, { rootMargin: '400px' }); this._observer.observe(sentinel); } - - // Set up client-side video thumbnail generation - this._setupVideoThumbnails(); }, // ── Client-side video thumbnail generation ────────────────────── @@ -202,18 +262,22 @@ const photosView = { /** Attach error handlers to video tile images; on failure, extract a * frame from the video using the browser's built-in codec. */ - _setupVideoThumbnails() { + /** @param {number} [startIndex=0] When > 0, only process video tiles + * for items[startIndex..] — avoids re-scanning the entire DOM. */ + _setupVideoThumbnails(startIndex = 0) { const tiles = this._container.querySelectorAll('.photo-tile[data-mime^="video/"]'); - for (const tile of tiles) { - const img = tile.querySelector('img'); - if (!img) continue; - const fileId = tile.dataset.id; + const newIds = startIndex > 0 + ? new Set(this.items.slice(startIndex).map(f => f.id)) + : null; - // Skip if already cached locally (URL was set during render) + for (const tile of tiles) { + const fileId = tile.dataset.id; + if (newIds && !newIds.has(fileId)) continue; if (this._videoThumbCache.has(fileId)) continue; - // The server returns 204 for videos without a cached thumbnail, - // which causes the to fire 'error'. + const img = tile.querySelector('img'); + if (!img) continue; + img.addEventListener('error', () => { this._enqueueVideoThumbnail(tile, img); }, { once: true }); @@ -241,7 +305,7 @@ const photosView = { }, /** Extract a single frame from a video and display it as the tile - * thumbnail, then upload the WebP to the server for caching. */ + * thumbnail, then upload the JPEG to the server for caching. */ _generateVideoThumbnail(tile, img) { const fileId = tile.dataset.id; const video = document.createElement('video'); @@ -299,7 +363,7 @@ const photosView = { if (resp.ok) { // Switch from blob URL to server URL so the blob // can be garbage-collected and future loads use - // the permanently cached WebP from the server. + // the permanently cached JPEG from the server. const serverUrl = `/api/files/${fileId}/thumbnail/preview?v=1`; this._videoThumbCache.set(fileId, serverUrl); } @@ -457,7 +521,8 @@ const photosView = { this.items = this.items.filter(f => !this.selected.has(f.id)); this.selected.clear(); this._hideSelectionBar(); - this._render(); + this._renderedCount = 0; + this._renderFull(); }; bar.querySelector('#photos-sel-download').onclick = async () => {