From 081b2b68d8a4327d3f5b28360674a6a4e9f0b2d6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 09:30:41 +0000 Subject: [PATCH] perf(photos): virtualize the timeline to bound DOM node count The photos timeline rendered every tile into the DOM and grew it unbounded on infinite scroll, degrading on large libraries. Each date-group is now a
whose grid is materialized (tiles inserted) only while near the viewport and dematerialized (emptied, height frozen as a spacer) once it scrolls away, driven by an IntersectionObserver rooted on the scroll container. DOM nodes stay bounded by a few screens regardless of library size. - Grouping (day/month/year), infinite scroll, multi-select, video thumbnails and fade-in are all preserved. - Selection state and the video-thumbnail cache survive the materialize/dematerialize cycle. - Falls back to full rendering when IntersectionObserver is unavailable. - Spacer heights are estimated from grid geometry and re-estimated on resize. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JW6ghFMDtnRYuYNzZhb47M --- static/css/views/photos.css | 6 + static/js/features/library/photos.js | 359 ++++++++++++++++++++------- 2 files changed, 273 insertions(+), 92 deletions(-) diff --git a/static/css/views/photos.css b/static/css/views/photos.css index 6bd29cd6..33221c9a 100644 --- a/static/css/views/photos.css +++ b/static/css/views/photos.css @@ -8,6 +8,12 @@ display: block; } +/* Virtualized timeline: each date-group is a
; its grid is + materialized (tiles inserted) only while near the viewport — see photos.js. */ +.photos-group { + display: block; +} + /* Toolbar with group mode toggle */ .photos-toolbar { display: flex; diff --git a/static/js/features/library/photos.js b/static/js/features/library/photos.js index f60aae31..9c7b2c7f 100644 --- a/static/js/features/library/photos.js +++ b/static/js/features/library/photos.js @@ -14,6 +14,14 @@ import { photosLightbox } from './photosLightbox.js'; * @typedef {'daily'|'monthly'|'yearly'} PhotoModeEnum */ +/** + * @typedef {Object} PhotoGroup + * @property {string} label + * @property {FileItem[]} files + * @property {HTMLElement} section + * @property {boolean} materialized + */ + const photosView = { /** @type {Array} All loaded photo items */ items: [], @@ -25,18 +33,28 @@ const photosView = { exhausted: false, /** @type {Set} Selected item IDs */ selected: new Set(), - /** @type {IntersectionObserver|null} */ - _observer: null, + /** @type {IntersectionObserver|null} Materializes/dematerializes group tiles by viewport proximity */ + _materializeObserver: null, + /** @type {IntersectionObserver|null} Infinite-scroll trigger on the sentinel */ + _sentinelObserver: null, /** @type {HTMLElement|null} */ _container: null, + /** @type {HTMLElement|null} The infinite-scroll sentinel element */ + _sentinelEl: null, /** @type {boolean} */ _initialized: false, /** @type {PhotoModeEnum} */ groupMode: 'monthly', /** @type {Map} fileId → thumbnail URL (persists across re-renders) */ _videoThumbCache: new Map(), - /** @type {number} Items already rendered in the DOM */ - _renderedCount: 0, + /** @type {Map} group label → group record (DOM + data) */ + _groupData: new Map(), + /** @type {string[]} Ordered group labels (timeline order) */ + _groupOrder: [], + /** @type {(() => void)|null} Debounced window resize handler */ + _resizeHandler: null, + /** @type {number} */ + _resizeTimer: 0, PAGE_SIZE: 200, @@ -73,7 +91,9 @@ const photosView = { this.nextCursor = null; this.exhausted = false; this.selected.clear(); - this._renderedCount = 0; + this._groupData = new Map(); + this._groupOrder = []; + this._destroyObserver(); this._container.innerHTML = ''; this._loadPage(); }, @@ -84,6 +104,7 @@ const photosView = { this._container.classList.remove('active'); } this._destroyObserver(); + this._unbindResize(); this._hideSelectionBar(); }, @@ -97,7 +118,6 @@ const photosView = { if (this.groupMode === mode) return; this.groupMode = mode; localStorage.setItem('oxicloud-photos-group', mode); - this._renderedCount = 0; this._renderFull(); }, @@ -149,15 +169,24 @@ const photosView = { } }, - // ── Rendering ─────────────────────────────────────────────────── - // Two render paths: - // _renderFull() — full DOM rebuild (first load, group-mode change, delete) - // _appendBatch(n) — append-only for infinite-scroll pages (O(batch)) + // ── Virtualized rendering ─────────────────────────────────────── + // The timeline can hold tens of thousands of items, so we never keep + // every tile in the DOM. Each date-group is a
with a header + // (always present, cheap) and a grid that is *materialized* (tiles in + // the DOM) only while near the viewport, and *dematerialized* (emptied, + // its height frozen as a spacer) once it scrolls far away. An + // IntersectionObserver rooted on the scroll container drives the swap, + // so the DOM node count stays bounded by a few screens regardless of + // library size. + // _renderFull() — rebuild the group skeleton (first load, mode switch, delete) + // _appendBatch(n) — append new groups for infinite-scroll pages - /** Full DOM rebuild — first load, group-mode switch, or after deletions. */ + /** Rebuild the group skeleton — first load, group-mode switch, or deletions. */ _renderFull() { if (!this._container) return; this._destroyObserver(); + this._groupData = new Map(); + this._groupOrder = []; this._container.classList.remove('photos-group-daily', 'photos-group-monthly', 'photos-group-yearly'); this._container.classList.add(`photos-group-${this.groupMode}`); @@ -168,77 +197,233 @@ const photosView = { } if (this.items.length === 0) return; - const groups = this._groupItems(this.items); - let html = this._renderToolbar(); + // Toolbar via innerHTML, then append group
s + sentinel as + // real elements so we keep references for the observer. + this._container.innerHTML = this._renderToolbar(); + this._container.onclick = (e) => this._handleClick(e); + const groups = this._groupItems(this.items); for (const [label, files] of groups) { - html += `
${this._escHtml(label)}${files.length}
`; - html += '
'; - for (const file of files) html += this._renderTile(file); - html += '
'; + /** @type {PhotoGroup} */ + const rec = { label, files, section: this._buildGroupEl(label, files), materialized: false }; + this._groupData.set(label, rec); + this._groupOrder.push(label); + this._container.appendChild(rec.section); } - html += '
'; - this._container.innerHTML = html; - this._container.onclick = (e) => this._handleClick(e); - this._fadeInTiles(); - this._renderedCount = this.items.length; - this._observeSentinel(); - this._setupVideoThumbnails(); + const sentinel = document.createElement('div'); + sentinel.className = 'photos-sentinel'; + this._container.appendChild(sentinel); + this._sentinelEl = sentinel; + + this._setupObservers(); + this._eagerMaterialize(); + this._bindResize(); }, - /** 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). + /** Append new groups for an infinite-scroll page without rebuilding the + * existing skeleton. The first new group may continue the previous tail + * label, in which case we merge into it. Complexity: O(new groups). * @param {number} startIndex */ _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; + if (!this._container || !this._sentinelEl) { this._renderFull(); return; } + const newItems = this.items.slice(startIndex); + if (newItems.length === 0) return; + const newGroups = this._groupItems(newItems); 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?.classList.contains('photos-grid')) { - grid.insertAdjacentHTML('beforeend', tilesHtml); - const countSpan = existingHeader.querySelector('.photos-day-count'); - if (countSpan) countSpan.textContent = String(grid.children.length); + const existing = this._groupData.get(label); + if (existing) { + // Continuation of a group already in the timeline. + existing.files = existing.files.concat(files); + const countEl = existing.section.querySelector('.photos-day-count'); + if (countEl) countEl.textContent = String(existing.files.length); + const grid = /** @type {HTMLElement|null} */ (existing.section.querySelector('.photos-grid')); + if (grid) { + if (existing.materialized) { + let tilesHtml = ''; + for (const file of files) tilesHtml += this._renderTile(file); + grid.insertAdjacentHTML('beforeend', tilesHtml); + this._setupVideoThumbnails(grid); + this._fadeInTiles(grid); + } else { + grid.style.minHeight = `${this._estimateHeight(existing.files.length)}px`; + } } } else { - // New group — insert header + grid before sentinel - const sectionHtml = - `
${this._escHtml(label)}${files.length}
` + - `
${tilesHtml}
`; - sentinel.insertAdjacentHTML('beforebegin', sectionHtml); + /** @type {PhotoGroup} */ + const rec = { label, files, section: this._buildGroupEl(label, files), materialized: false }; + this._groupData.set(label, rec); + this._groupOrder.push(label); + this._container.insertBefore(rec.section, this._sentinelEl); + this._materializeObserver?.observe(rec.section); } } + }, - this._renderedCount = this.items.length; - this._observeSentinel(); - this._setupVideoThumbnails(startIndex); - this._fadeInTiles(); + /** Build a dematerialized group section (header + empty grid spacer). + * @param {string} label + * @param {FileItem[]} files + * @returns {HTMLElement} + */ + _buildGroupEl(label, files) { + const section = document.createElement('section'); + section.className = 'photos-group'; + section.dataset.group = label; + section.innerHTML = + `
${this._escHtml(label)}${files.length}
` + + `
`; + return section; + }, + + /** Wire the two IntersectionObservers (materialization + infinite scroll). */ + _setupObservers() { + const root = this._container?.parentElement || null; + + if (!('IntersectionObserver' in window)) { + // Degrade gracefully: render every group (legacy behaviour). + for (const label of this._groupOrder) { + const rec = this._groupData.get(label); + if (rec) this._materializeGroup(rec.section); + } + return; + } + + this._materializeObserver = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + const section = /** @type {HTMLElement} */ (entry.target); + if (entry.isIntersecting) this._materializeGroup(section); + else this._dematerializeGroup(section); + } + }, + { root, rootMargin: '1200px 0px' } + ); + for (const label of this._groupOrder) { + const rec = this._groupData.get(label); + if (rec) this._materializeObserver.observe(rec.section); + } + + if (this._sentinelEl) { + this._sentinelObserver = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting) this._loadPage(); + }, + { root, rootMargin: '600px 0px' } + ); + this._sentinelObserver.observe(this._sentinelEl); + } + }, + + /** Synchronously materialize the first groups within ~1.5 viewports so + * the initial paint has tiles before the observer's first callback. */ + _eagerMaterialize() { + const budget = (this._container?.parentElement?.clientHeight || window.innerHeight) * 1.5; + let acc = 0; + for (const label of this._groupOrder) { + const rec = this._groupData.get(label); + if (!rec) continue; + this._materializeGroup(rec.section); + acc += rec.section.offsetHeight; + if (acc > budget) break; + } + }, + + /** Fill a group's grid with tiles (idempotent). + * @param {HTMLElement} section + */ + _materializeGroup(section) { + const rec = this._groupData.get(section.dataset.group || ''); + if (!rec || rec.materialized) return; + rec.materialized = true; + const grid = /** @type {HTMLElement|null} */ (section.querySelector('.photos-grid')); + if (!grid) return; + let html = ''; + for (const file of rec.files) html += this._renderTile(file); + grid.innerHTML = html; + grid.style.minHeight = ''; + this._setupVideoThumbnails(grid); + this._fadeInTiles(grid); + }, + + /** Empty a group's grid, freezing its current height as a spacer. + * @param {HTMLElement} section + */ + _dematerializeGroup(section) { + const rec = this._groupData.get(section.dataset.group || ''); + if (!rec?.materialized) return; + rec.materialized = false; + const grid = /** @type {HTMLElement|null} */ (section.querySelector('.photos-grid')); + if (!grid) return; + grid.style.minHeight = `${grid.offsetHeight}px`; + grid.innerHTML = ''; + }, + + /** Current grid geometry (columns / gap / square tile px) for the active + * mode, used to estimate off-screen group heights. + * @returns {{cols: number, gap: number, tile: number}} + */ + _gridMetrics() { + const sample = /** @type {HTMLElement|null} */ (this._container?.querySelector('.photos-grid')); + const width = sample?.clientWidth || (this._container?.clientWidth || 1200) - 16; + const mobile = window.matchMedia('(max-width: 768px)').matches; + let min; + let gap; + if (this.groupMode === 'yearly') { + min = mobile ? 80 : 120; + gap = mobile ? 4 : 10; + } else if (this.groupMode === 'monthly') { + min = mobile ? 110 : 180; + gap = mobile ? 2 : 14; + } else { + min = mobile ? 100 : 150; + gap = mobile ? 2 : 12; + } + const cols = Math.max(1, Math.floor((width + gap) / (min + gap))); + const tile = (width - (cols - 1) * gap) / cols; + return { cols, gap, tile }; + }, + + /** Estimated pixel height of a grid holding `count` square tiles. + * @param {number} count + * @returns {number} + */ + _estimateHeight(count) { + const { cols, gap, tile } = this._gridMetrics(); + const rows = Math.max(1, Math.ceil(count / cols)); + return Math.round(rows * tile + (rows - 1) * gap); + }, + + /** Re-estimate spacer heights for dematerialized groups after a resize. */ + _bindResize() { + if (this._resizeHandler) return; + this._resizeHandler = () => { + clearTimeout(this._resizeTimer); + this._resizeTimer = window.setTimeout(() => this._onResize(), 150); + }; + window.addEventListener('resize', this._resizeHandler); + }, + + _onResize() { + if (!this._container?.classList.contains('active')) return; + for (const label of this._groupOrder) { + const rec = this._groupData.get(label); + if (!rec || rec.materialized) continue; + const grid = /** @type {HTMLElement|null} */ (rec.section.querySelector('.photos-grid')); + if (grid) grid.style.minHeight = `${this._estimateHeight(rec.files.length)}px`; + } + }, + + _unbindResize() { + if (this._resizeHandler) { + window.removeEventListener('resize', this._resizeHandler); + this._resizeHandler = null; + } + clearTimeout(this._resizeTimer); }, /** @@ -264,9 +449,11 @@ const photosView = { /** * Fade tiles in as their thumbnails finish loading (kills the pop-in). * Idempotent — only wires images not already marked loaded. + * @param {ParentNode} [scope] Limit to a subtree (a group grid); defaults to the whole container. */ - _fadeInTiles() { - this._container?.querySelectorAll('.photo-tile img:not(.is-loaded)').forEach((el) => { + _fadeInTiles(scope) { + const root = scope || this._container; + root?.querySelectorAll('.photo-tile img:not(.is-loaded)').forEach((el) => { const img = /** @type {HTMLImageElement} */ (el); if (img.complete) { img.classList.add('is-loaded'); @@ -278,40 +465,25 @@ const photosView = { }); }, - /** (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(); - }, - { rootMargin: '400px' } - ); - this._observer.observe(sentinel); - } - }, - // ── Client-side video thumbnail generation ────────────────────── // Uses the browser's native video decoder (