Lazy-load place photos
Only preload photos in view as well as the next one(s), not all of them
This commit is contained in:
@@ -123,11 +123,11 @@ export default class PlacePhotosCarousel extends Component {
|
|||||||
{{#if photo.thumbUrl}}
|
{{#if photo.thumbUrl}}
|
||||||
<source
|
<source
|
||||||
media="(max-width: 768px)"
|
media="(max-width: 768px)"
|
||||||
srcset={{photo.thumbUrl}}
|
data-srcset={{photo.thumbUrl}}
|
||||||
/>
|
/>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
<img
|
<img
|
||||||
src={{photo.url}}
|
data-src={{photo.url}}
|
||||||
class="place-header-photo landscape"
|
class="place-header-photo landscape"
|
||||||
alt={{@name}}
|
alt={{@name}}
|
||||||
{{fadeInImage photo.url}}
|
{{fadeInImage photo.url}}
|
||||||
@@ -136,7 +136,7 @@ export default class PlacePhotosCarousel extends Component {
|
|||||||
{{else}}
|
{{else}}
|
||||||
{{! Portrait uses thumb everywhere if available }}
|
{{! Portrait uses thumb everywhere if available }}
|
||||||
<img
|
<img
|
||||||
src={{if photo.thumbUrl photo.thumbUrl photo.url}}
|
data-src={{if photo.thumbUrl photo.thumbUrl photo.url}}
|
||||||
class="place-header-photo portrait"
|
class="place-header-photo portrait"
|
||||||
alt={{@name}}
|
alt={{@name}}
|
||||||
{{fadeInImage (if photo.thumbUrl photo.thumbUrl photo.url)}}
|
{{fadeInImage (if photo.thumbUrl photo.thumbUrl photo.url)}}
|
||||||
|
|||||||
@@ -7,6 +7,24 @@ export default modifier((element, [url]) => {
|
|||||||
element.classList.remove('loaded');
|
element.classList.remove('loaded');
|
||||||
element.classList.remove('loaded-instant');
|
element.classList.remove('loaded-instant');
|
||||||
|
|
||||||
|
let observer;
|
||||||
|
|
||||||
|
const handleLoad = () => {
|
||||||
|
// Only apply the fade-in animation if it wasn't already loaded instantly
|
||||||
|
if (!element.classList.contains('loaded-instant')) {
|
||||||
|
element.classList.add('loaded');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
element.addEventListener('load', handleLoad);
|
||||||
|
|
||||||
|
const loadWhenVisible = (entries, obs) => {
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
// Stop observing once we start loading
|
||||||
|
obs.unobserve(element);
|
||||||
|
|
||||||
|
// Check if the image is already in the browser cache
|
||||||
// Create an off-DOM image to reliably check cache status
|
// Create an off-DOM image to reliably check cache status
|
||||||
// without waiting for the actual DOM element to load it
|
// without waiting for the actual DOM element to load it
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
@@ -15,16 +33,43 @@ export default modifier((element, [url]) => {
|
|||||||
if (img.complete) {
|
if (img.complete) {
|
||||||
// Already in browser cache, skip the animation
|
// Already in browser cache, skip the animation
|
||||||
element.classList.add('loaded-instant');
|
element.classList.add('loaded-instant');
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleLoad = () => {
|
// If this image is inside a <picture> tag, we also need to swap <source> tags
|
||||||
element.classList.add('loaded');
|
const parent = element.parentElement;
|
||||||
|
if (parent && parent.tagName === 'PICTURE') {
|
||||||
|
const sources = parent.querySelectorAll('source');
|
||||||
|
sources.forEach((source) => {
|
||||||
|
if (source.dataset.srcset) {
|
||||||
|
source.srcset = source.dataset.srcset;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Swap data-src to src to trigger the actual network fetch (or render from cache)
|
||||||
|
if (element.dataset.src) {
|
||||||
|
element.src = element.dataset.src;
|
||||||
|
} else {
|
||||||
|
// Fallback if data-src wasn't used but the modifier was called
|
||||||
|
element.src = url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
element.addEventListener('load', handleLoad);
|
// Setup Intersection Observer to only load when the image enters the viewport
|
||||||
|
observer = new IntersectionObserver(loadWhenVisible, {
|
||||||
|
root: null, // Use the viewport as the root
|
||||||
|
rootMargin: '100px 100%', // Load one full viewport width ahead/behind
|
||||||
|
threshold: 0, // Trigger immediately when any part enters the expanded margin
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(element);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
element.removeEventListener('load', handleLoad);
|
element.removeEventListener('load', handleLoad);
|
||||||
|
if (observer) {
|
||||||
|
observer.disconnect();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ module('Integration | Component | place-photos-carousel', function (hooks) {
|
|||||||
assert.dom('.carousel-slide').exists({ count: 1 }, 'it renders one slide');
|
assert.dom('.carousel-slide').exists({ count: 1 }, 'it renders one slide');
|
||||||
assert
|
assert
|
||||||
.dom('img.place-header-photo')
|
.dom('img.place-header-photo')
|
||||||
.hasAttribute('src', 'photo1.jpg', 'it renders the photo');
|
.hasAttribute('data-src', 'photo1.jpg', 'it sets the data-src correctly');
|
||||||
|
|
||||||
// There should be no chevrons when there's only 1 photo
|
// There should be no chevrons when there's only 1 photo
|
||||||
assert
|
assert
|
||||||
|
|||||||
Reference in New Issue
Block a user