fixed gallery
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user