fixed gallery

This commit is contained in:
2026-02-22 17:09:34 +01:00
parent 48e2055b6a
commit 5c97488e80
33 changed files with 2062 additions and 550 deletions

View File

@@ -14,8 +14,9 @@
}
})();
var MAX_DOM_CARDS_FOR_VIRTUAL_HINT = 220;
var MAX_DOM_CARDS_FOR_VIRTUAL_HINT = 200;
var LOAD_TRIGGER_MARGIN = '900px';
var VIRTUAL_OBSERVER_MARGIN = '800px';
function toArray(list) {
return Array.prototype.slice.call(list || []);
@@ -28,6 +29,14 @@
return next ? next.getAttribute('href') : null;
}
function buildCursorUrl(endpoint, cursor, limit) {
if (!endpoint) return null;
var url = new URL(endpoint, window.location.href);
if (cursor) url.searchParams.set('cursor', cursor);
if (limit) url.searchParams.set('limit', limit);
return url.toString();
}
function setSkeleton(root, active, count) {
var box = root.querySelector('[data-gallery-skeleton]');
if (!box) return;
@@ -81,39 +90,39 @@
});
}
function applyVirtualizationHints(root) {
var grid = root.querySelector('[data-gallery-grid]');
if (!grid) return;
var cards = toArray(grid.querySelectorAll('.nova-card'));
if (cards.length <= MAX_DOM_CARDS_FOR_VIRTUAL_HINT) {
cards.forEach(function (card) {
card.style.contentVisibility = '';
card.style.containIntrinsicSize = '';
});
return;
}
var viewportTop = window.scrollY;
var viewportBottom = viewportTop + window.innerHeight;
cards.forEach(function (card) {
var rect = card.getBoundingClientRect();
var top = rect.top + viewportTop;
var bottom = rect.bottom + viewportTop;
var farAbove = bottom < viewportTop - 1400;
var farBelow = top > viewportBottom + 2600;
if (farAbove || farBelow) {
var h = Math.max(160, rect.height || 220);
card.style.contentVisibility = 'auto';
card.style.containIntrinsicSize = Math.round(h) + 'px';
} else {
card.style.contentVisibility = '';
card.style.containIntrinsicSize = '';
}
function activateBlurPreviews(root) {
var imgs = toArray(root.querySelectorAll('img[data-blur-preview]'));
imgs.forEach(function (img) {
if (img.complete && img.naturalWidth > 0) { img.classList.add('is-loaded'); return; }
img.addEventListener('load', function () { img.classList.add('is-loaded'); }, { once: true });
img.addEventListener('error', function () { img.classList.add('is-loaded'); }, { once: true });
});
}
// Create an IntersectionObserver that applies content-visibility hints
// to cards that leave the viewport. Using an observer avoids calling
// getBoundingClientRect() in a scroll handler (which forces layout).
// entry.boundingClientRect gives us the last rendered height without
// triggering a synchronous layout recalculation.
function makeVirtualObserver() {
if (!('IntersectionObserver' in window)) return null;
return new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
var card = entry.target;
if (entry.isIntersecting) {
card.style.contentVisibility = '';
card.style.containIntrinsicSize = '';
} else {
// Capture the last-known rendered height before hiding.
// 'auto <h>px' keeps browser-managed width while reserving fixed height.
var h = Math.max(160, Math.round(entry.boundingClientRect.height) || 220);
card.style.contentVisibility = 'auto';
card.style.containIntrinsicSize = 'auto ' + h + 'px';
}
});
}, { root: null, rootMargin: VIRTUAL_OBSERVER_MARGIN, threshold: 0 });
}
function extractAndAppendCards(root, html) {
var parser = new DOMParser();
var doc = parser.parseFromString(html, 'text/html');
@@ -145,13 +154,40 @@
if (!grid) return;
root.classList.add('is-enhanced');
activateBlurPreviews(root);
var state = {
loading: false,
nextUrl: queryNextPageUrl(root),
cursorEndpoint: (root.dataset && root.dataset.galleryCursorEndpoint) || null,
cursor: (root.dataset && root.dataset.galleryCursor) || null,
limit: (root.dataset && parseInt(root.dataset.galleryLimit, 10)) || 40,
done: false
};
// virtualObserver is created lazily the first time card count exceeds
// MAX_DOM_CARDS_FOR_VIRTUAL_HINT. Once active it watches every card.
var virtualObserver = null;
// Call after appending new cards. newCards is the array of freshly added
// elements (pass [] on the initial render). When the threshold is first
// crossed all existing cards are swept; thereafter only newCards are added.
function checkVirtualization(newCards) {
var allCards = toArray(grid.querySelectorAll('.nova-card'));
if (allCards.length <= MAX_DOM_CARDS_FOR_VIRTUAL_HINT) return;
if (!virtualObserver) {
// First time crossing the threshold — create observer and observe all.
virtualObserver = makeVirtualObserver();
if (!virtualObserver) return;
allCards.forEach(function (card) { virtualObserver.observe(card); });
return;
}
// Observer already running — just wire in newly appended cards.
newCards.forEach(function (card) { virtualObserver.observe(card); });
}
function relayout() {
// Apply masonry synchronously first — the card already has inline aspect-ratio
// set from image dimensions, so getBoundingClientRect() returns the correct
@@ -167,21 +203,18 @@
if (!GRID_V2_ENABLED) {
applyMasonry(root);
}
applyVirtualizationHints(root);
});
}
var rafId = null;
function onScrollOrResize() {
if (rafId) return;
rafId = window.requestAnimationFrame(function () {
rafId = null;
applyVirtualizationHints(root);
// Virtualization check runs after the initial render too, in case the
// page was loaded mid-scroll with many pre-rendered cards.
checkVirtualization([]);
});
}
async function loadNextPage() {
if (state.loading || state.done || !state.nextUrl) return;
if (state.loading || state.done) return;
var fetchUrl = (state.cursorEndpoint && state.cursor)
? buildCursorUrl(state.cursorEndpoint, state.cursor, state.limit)
: state.nextUrl;
if (!fetchUrl) return;
state.loading = true;
var sampleCards = toArray(grid.querySelectorAll('.nova-card'));
@@ -189,7 +222,7 @@
setSkeleton(root, true, skeletonCount);
try {
var response = await window.fetch(state.nextUrl, {
var response = await window.fetch(fetchUrl, {
credentials: 'same-origin',
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
@@ -201,6 +234,7 @@
if (!state.nextUrl || result.appended === 0) {
state.done = true;
}
activateBlurPreviews(root);
// Animate appended cards
var appendedCards = toArray(grid.querySelectorAll('.nova-card')).slice(-result.appended);
@@ -212,6 +246,7 @@
});
relayout();
checkVirtualization(appendedCards);
// After new cards appended, move the trigger to remain one-row-before-last.
placeTrigger();
} catch (e) {
@@ -275,10 +310,8 @@
window.addEventListener('resize', function () {
relayout();
onScrollOrResize();
placeTrigger();
}, { passive: true });
window.addEventListener('scroll', onScrollOrResize, { passive: true });
relayout();
placeTrigger();

View File

@@ -14,8 +14,9 @@
}
})();
var MAX_DOM_CARDS_FOR_VIRTUAL_HINT = 220;
var MAX_DOM_CARDS_FOR_VIRTUAL_HINT = 200;
var LOAD_TRIGGER_MARGIN = '900px';
var VIRTUAL_OBSERVER_MARGIN = '800px';
function toArray(list) {
return Array.prototype.slice.call(list || []);
@@ -28,6 +29,14 @@
return next ? next.getAttribute('href') : null;
}
function buildCursorUrl(endpoint, cursor, limit) {
if (!endpoint) return null;
var url = new URL(endpoint, window.location.href);
if (cursor) url.searchParams.set('cursor', cursor);
if (limit) url.searchParams.set('limit', limit);
return url.toString();
}
function setSkeleton(root, active, count) {
var box = root.querySelector('[data-gallery-skeleton]');
if (!box) return;
@@ -81,39 +90,39 @@
});
}
function applyVirtualizationHints(root) {
var grid = root.querySelector('[data-gallery-grid]');
if (!grid) return;
var cards = toArray(grid.querySelectorAll('.nova-card'));
if (cards.length <= MAX_DOM_CARDS_FOR_VIRTUAL_HINT) {
cards.forEach(function (card) {
card.style.contentVisibility = '';
card.style.containIntrinsicSize = '';
});
return;
}
var viewportTop = window.scrollY;
var viewportBottom = viewportTop + window.innerHeight;
cards.forEach(function (card) {
var rect = card.getBoundingClientRect();
var top = rect.top + viewportTop;
var bottom = rect.bottom + viewportTop;
var farAbove = bottom < viewportTop - 1400;
var farBelow = top > viewportBottom + 2600;
if (farAbove || farBelow) {
var h = Math.max(160, rect.height || 220);
card.style.contentVisibility = 'auto';
card.style.containIntrinsicSize = Math.round(h) + 'px';
} else {
card.style.contentVisibility = '';
card.style.containIntrinsicSize = '';
}
function activateBlurPreviews(root) {
var imgs = toArray(root.querySelectorAll('img[data-blur-preview]'));
imgs.forEach(function (img) {
if (img.complete && img.naturalWidth > 0) { img.classList.add('is-loaded'); return; }
img.addEventListener('load', function () { img.classList.add('is-loaded'); }, { once: true });
img.addEventListener('error', function () { img.classList.add('is-loaded'); }, { once: true });
});
}
// Create an IntersectionObserver that applies content-visibility hints
// to cards that leave the viewport. Using an observer avoids calling
// getBoundingClientRect() in a scroll handler (which forces layout).
// entry.boundingClientRect gives us the last rendered height without
// triggering a synchronous layout recalculation.
function makeVirtualObserver() {
if (!('IntersectionObserver' in window)) return null;
return new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
var card = entry.target;
if (entry.isIntersecting) {
card.style.contentVisibility = '';
card.style.containIntrinsicSize = '';
} else {
// Capture the last-known rendered height before hiding.
// 'auto <h>px' keeps browser-managed width while reserving fixed height.
var h = Math.max(160, Math.round(entry.boundingClientRect.height) || 220);
card.style.contentVisibility = 'auto';
card.style.containIntrinsicSize = 'auto ' + h + 'px';
}
});
}, { root: null, rootMargin: VIRTUAL_OBSERVER_MARGIN, threshold: 0 });
}
function extractAndAppendCards(root, html) {
var parser = new DOMParser();
var doc = parser.parseFromString(html, 'text/html');
@@ -145,13 +154,40 @@
if (!grid) return;
root.classList.add('is-enhanced');
activateBlurPreviews(root);
var state = {
loading: false,
nextUrl: queryNextPageUrl(root),
cursorEndpoint: (root.dataset && root.dataset.galleryCursorEndpoint) || null,
cursor: (root.dataset && root.dataset.galleryCursor) || null,
limit: (root.dataset && parseInt(root.dataset.galleryLimit, 10)) || 40,
done: false
};
// virtualObserver is created lazily the first time card count exceeds
// MAX_DOM_CARDS_FOR_VIRTUAL_HINT. Once active it watches every card.
var virtualObserver = null;
// Call after appending new cards. newCards is the array of freshly added
// elements (pass [] on the initial render). When the threshold is first
// crossed all existing cards are swept; thereafter only newCards are added.
function checkVirtualization(newCards) {
var allCards = toArray(grid.querySelectorAll('.nova-card'));
if (allCards.length <= MAX_DOM_CARDS_FOR_VIRTUAL_HINT) return;
if (!virtualObserver) {
// First time crossing the threshold — create observer and observe all.
virtualObserver = makeVirtualObserver();
if (!virtualObserver) return;
allCards.forEach(function (card) { virtualObserver.observe(card); });
return;
}
// Observer already running — just wire in newly appended cards.
newCards.forEach(function (card) { virtualObserver.observe(card); });
}
function relayout() {
// Apply masonry synchronously first — the card already has inline aspect-ratio
// set from image dimensions, so getBoundingClientRect() returns the correct
@@ -167,21 +203,18 @@
if (!GRID_V2_ENABLED) {
applyMasonry(root);
}
applyVirtualizationHints(root);
});
}
var rafId = null;
function onScrollOrResize() {
if (rafId) return;
rafId = window.requestAnimationFrame(function () {
rafId = null;
applyVirtualizationHints(root);
// Virtualization check runs after the initial render too, in case the
// page was loaded mid-scroll with many pre-rendered cards.
checkVirtualization([]);
});
}
async function loadNextPage() {
if (state.loading || state.done || !state.nextUrl) return;
if (state.loading || state.done) return;
var fetchUrl = (state.cursorEndpoint && state.cursor)
? buildCursorUrl(state.cursorEndpoint, state.cursor, state.limit)
: state.nextUrl;
if (!fetchUrl) return;
state.loading = true;
var sampleCards = toArray(grid.querySelectorAll('.nova-card'));
@@ -189,7 +222,7 @@
setSkeleton(root, true, skeletonCount);
try {
var response = await window.fetch(state.nextUrl, {
var response = await window.fetch(fetchUrl, {
credentials: 'same-origin',
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
@@ -201,6 +234,7 @@
if (!state.nextUrl || result.appended === 0) {
state.done = true;
}
activateBlurPreviews(root);
// Animate appended cards
var appendedCards = toArray(grid.querySelectorAll('.nova-card')).slice(-result.appended);
@@ -212,6 +246,7 @@
});
relayout();
checkVirtualization(appendedCards);
// After new cards appended, move the trigger to remain one-row-before-last.
placeTrigger();
} catch (e) {
@@ -275,10 +310,8 @@
window.addEventListener('resize', function () {
relayout();
onScrollOrResize();
placeTrigger();
}, { passive: true });
window.addEventListener('scroll', onScrollOrResize, { passive: true });
relayout();
placeTrigger();