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 <section> 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JW6ghFMDtnRYuYNzZhb47M
This commit is contained in:
Claude
2026-06-19 09:30:41 +00:00
parent 293986d1f6
commit 081b2b68d8
2 changed files with 273 additions and 92 deletions
+267 -92
View File
@@ -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<FileItem>} All loaded photo items */
items: [],
@@ -25,18 +33,28 @@ const photosView = {
exhausted: false,
/** @type {Set<string>} 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<string, string>} fileId → thumbnail URL (persists across re-renders) */
_videoThumbCache: new Map(),
/** @type {number} Items already rendered in the DOM */
_renderedCount: 0,
/** @type {Map<string, PhotoGroup>} 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 <section> 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 <section>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 += `<div class="photos-day-header" data-group="${this._escAttr(label)}">${this._escHtml(label)}<span class="photos-day-count">${files.length}</span></div>`;
html += '<div class="photos-grid">';
for (const file of files) html += this._renderTile(file);
html += '</div>';
/** @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 += '<div class="photos-sentinel"></div>';
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 =
`<div class="photos-day-header" data-group="${this._escAttr(label)}">${this._escHtml(label)}<span class="photos-day-count">${files.length}</span></div>` +
`<div class="photos-grid">${tilesHtml}</div>`;
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 =
`<div class="photos-day-header" data-group="${this._escAttr(label)}">${this._escHtml(label)}<span class="photos-day-count">${files.length}</span></div>` +
`<div class="photos-grid" style="min-height:${this._estimateHeight(files.length)}px"></div>`;
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 (<video> + <canvas>) to
// extract a frame, show it immediately, and upload to the server
// for permanent caching. Zero server-side dependencies (no ffmpeg).
/** Attach error handlers to video tile images; on failure, extract a
* frame from the video using the browser's built-in codec. */
/** @param {number} [startIndex=0] When > 0, only process video tiles
* for items[startIndex..] — avoids re-scanning the entire DOM. */
_setupVideoThumbnails(startIndex = 0) {
const tiles = /** @type {NodeListOf<HTMLDivElement>} */ (this._container?.querySelectorAll('.photo-tile[data-mime^="video/"]'));
const newIds = startIndex > 0 ? new Set(this.items.slice(startIndex).map((f) => f.id)) : null;
/** Attach error handlers to video tile images within a freshly
* materialized grid; on failure, extract a frame from the video using
* the browser's built-in codec.
* @param {ParentNode} [scope] Subtree to scan; defaults to the whole container.
*/
_setupVideoThumbnails(scope) {
const root = scope || this._container;
const tiles = /** @type {NodeListOf<HTMLDivElement>|undefined} */ (root?.querySelectorAll('.photo-tile[data-mime^="video/"]'));
if (!tiles) return;
for (const tile of tiles) {
const fileId = tile.dataset.id;
if (!fileId) continue;
if (newIds && !newIds.has(fileId)) continue;
if (this._videoThumbCache.has(fileId)) continue;
const img = tile.querySelector('img');
@@ -526,7 +698,6 @@ const photosView = {
if (trashed.size > 0) {
this.items = this.items.filter((f) => !trashed.has(f.id));
for (const id of trashed) this.selected.delete(id);
this._renderedCount = 0;
this._renderFull();
}
// Refresh (or hide) the bar to reflect any items left selected.
@@ -571,9 +742,13 @@ const photosView = {
},
_destroyObserver() {
if (this._observer) {
this._observer.disconnect();
this._observer = null;
if (this._materializeObserver) {
this._materializeObserver.disconnect();
this._materializeObserver = null;
}
if (this._sentinelObserver) {
this._sentinelObserver.disconnect();
this._sentinelObserver = null;
}
},