Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: lazy load thumbnail list elements using IntersectionObserver #2461

Open
wants to merge 12 commits into
base: master
Choose a base branch
from
Open
170 changes: 158 additions & 12 deletions packages/portal/src/components/item/ItemMediaThumbnails.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,48 @@
class="media-thumbnails"
>
<ol
class="d-flex flex-row flex-lg-column mb-0 pl-0"
ref="mediaThumbnailsList"
class="d-flex flex-row flex-lg-column pl-0"
>
<li
v-for="(resource, index) in resources"
ref="mediaThumbnails"
:key="index"
v-if="skeletonBefore"
ref="thumbnailSkeletonBefore"
class="thumbnail-skeleton-before"
:style="{
'--skeletonheightbefore': calculateSkeletonHeight(resources.slice(0, firstRenderedResourceIndex)),
'--skeletonwidthbefore': calculateSkeletonWidth(resources.slice(0, firstRenderedResourceIndex))
}"
data-qa="item media thumbnail skeleton before"
/>

<!-- The ref array does not guarantee the same order as the source array. This causes issues when prepending resources.
Workaround: Read list elements from the list parent's children to get them at the actual rendered index. -->
<!-- Unique key for each resource to prevent prepended resources reusing existing elements and causing jumpiness -->
<li
v-for="(resource, index) in resourcesToRender"
:key="resource.about"
:aria-setsize="resources.length"
:aria-posinset="firstRenderedResourceIndex + index + 1"
>
<ItemMediaThumbnail
:offset="index"
:offset="firstRenderedResourceIndex + index"
class="d-flex-inline mr-3 mr-lg-auto"
:class="{ 'selected': index === selectedIndex }"
:resource="resource"
:edm-type="edmType"
:lazy="true"
/>
</li>
<li
v-if="skeletonAfter"
ref="thumbnailSkeletonAfter"
class="thumbnail-skeleton-after"
:style="{
'--skeletonheightafter': calculateSkeletonHeight(resources.slice(lastRenderedResourceIndex, resources.length - 1)),
'--skeletonwidthafter': calculateSkeletonWidth(resources.slice(lastRenderedResourceIndex, resources.length - 1))
}"
data-qa="item media thumbnail skeleton after"
/>
</ol>
</div>
</transition>
Expand All @@ -33,6 +60,8 @@
import useScrollTo from '@/composables/scrollTo.js';
import ItemMediaThumbnail from './ItemMediaThumbnail.vue';

const perPage = 5;

export default {
name: 'ItemMediaThumbnails',

Expand All @@ -50,13 +79,36 @@
setup() {
const { page, resources } = useItemMediaPresentation();
const { scrollElementToCentre } = useScrollTo();
return { page, resources, scrollElementToCentre };
},

return { page, resources, scrollElementToCentre };
data() {
return {
resourcesToRender: !!this.resources && (this.page <= perPage ? this.resources.slice(0, perPage) :
this.resources.slice(Math.max(this.page - perPage, 0), Math.min(this.page + perPage, this.resources.length))),
skeletonObserver: null
};
},

computed: {
firstRenderedResourceIndex() {
return this.resources?.findIndex(resource => resource === this.resourcesToRender[0]);
},

lastRenderedResourceIndex() {
return this.resources?.findIndex(resource => resource === this.resourcesToRender[this.resourcesToRender.length - 1]);
},

selectedIndex() {
return this.page - 1;
return this.page - this.firstRenderedResourceIndex - 1;
},

skeletonBefore() {
return this.page > perPage;
},

skeletonAfter() {
return this.lastRenderedResourceIndex < this.resources?.length - 1;
}
},

Expand All @@ -66,10 +118,6 @@
}
},

created() {
window.addEventListener('resize', this.handleWindowResize);
},

mounted() {
if (this.page > 1) {
this.$nextTick(() => {
Expand All @@ -78,6 +126,12 @@
this.updateThumbnailScroll('instant');
});
}

if (this.skeletonBefore || this.skeletonAfter) {
this.observeSkeleton();
}

window.addEventListener('resize', this.handleWindowResize);
},

destroyed() {
Expand All @@ -91,12 +145,86 @@

updateThumbnailScroll(behavior = 'smooth') {
this.scrollElementToCentre(
this.$refs.mediaThumbnails?.[this.selectedIndex],
// + 1 to account for the skeleton li
this.$refs.mediaThumbnailsList.children?.[this.skeletonBefore ? this.selectedIndex + 1 : this.selectedIndex],
{
behavior,
container: this.$refs.mediaThumbnailsContainer
}
);
},

observeSkeleton() {
// Render all list items when IntersectionObserver not fully supported
if (!('IntersectionObserver' in window) ||
!('IntersectionObserverEntry' in window)) {
this.resourcesToRender = this.resources;
return;
}

this.skeletonObserver = new IntersectionObserver(
(entries) => {
entries.forEach(async(entry) => {
// intersectionRatio is supported by older browsers, isIntersecting is not always
if (entry.isIntersecting || entry.intersectionRatio > 0) {
if (entry.target === thumbnailSkeletonBefore) {
this.resourcesToRender = this.resources.slice(Math.max(this.firstRenderedResourceIndex - perPage, 0), this.firstRenderedResourceIndex).concat(this.resourcesToRender);
}

if (entry.target === thumbnailSkeletonAfter) {
this.resourcesToRender = this.resourcesToRender.concat(this.resources.slice(this.lastRenderedResourceIndex + 1, this.lastRenderedResourceIndex + perPage + 1));
}

await this.$nextTick();
// refresh observing the list item to see if after pre/appending still intersecting (This can happen when scrolling fast and far)
this.skeletonObserver.unobserve(entry.target);
this.skeletonObserver.observe(entry.target);
}
});
},
{ root: this.$refs.mediaThumbnailsContainer }
);

const thumbnailSkeletonBefore = this.$refs.thumbnailSkeletonBefore;
const thumbnailSkeletonAfter = this.$refs.thumbnailSkeletonAfter;

if (thumbnailSkeletonBefore) {
this.skeletonObserver.observe(thumbnailSkeletonBefore);
}
if (thumbnailSkeletonAfter) {
this.skeletonObserver.observe(thumbnailSkeletonAfter);
}
},

calculateSkeletonHeight(skeletonResources) {
const skeletonHeight = skeletonResources.reduce((accumulatedHeight, resource) => {
let imageHeight;
if (resource.ebucoreHeight && resource.ebucoreWidth) {
imageHeight = (resource.ebucoreHeight / resource.ebucoreWidth) * 176; // CSS width 11rem
} else {
imageHeight = 80; // CSS min-height 5rem
}
const renderedHeight = imageHeight < 480 ? imageHeight : 480;
return accumulatedHeight + renderedHeight + 16; // add 16px margin
}, 0);

return `${Math.round(skeletonHeight)}px`;
},

calculateSkeletonWidth(skeletonResources) {
const skeletonWidth = skeletonResources.reduce((accumulatedWidth, resource) => {
const cssHeight = window.innerWidth < 768 ? 58 : 124; // CSS height bp-small 3.625rem, bp-medium 7.75rem
let imageWidth;
if (resource.ebucoreHeight && resource.ebucoreWidth) {
imageWidth = (resource.ebucoreWidth / resource.ebucoreHeight) * cssHeight;
} else {
imageWidth = 48; // CSS min-width 3rem
}
const renderedWidth = imageWidth < 200 ? imageWidth : 200;
return accumulatedWidth + renderedWidth + 16; // add 16px margin
}, 0);

return `${Math.round(skeletonWidth)}px`;
}
}
};
Expand All @@ -117,6 +245,24 @@
overflow-x: auto;
scrollbar-width: thin;

.thumbnail-skeleton-before {
width: var(--skeletonwidthbefore);

@media (min-width: $bp-large) {
width: auto;
height: var(--skeletonheightbefore);
}
}

.thumbnail-skeleton-after {
width: var(--skeletonwidthafter);

@media (min-width: $bp-large) {
width: auto;
height: var(--skeletonheightafter);
}
}

li {
list-style-type: none;
flex-shrink: 0;
Expand Down
Loading
Loading