Merge pull request 'perf(reader): virtual rendering avec IntersectionObserver en mode scroll' (#16) from perf/reader-virtual-rendering into main
All checks were successful
Deploy / deploy (push) Successful in 2m49s

Reviewed-on: #16
This commit was merged in pull request #16.
This commit is contained in:
2026-03-15 18:51:26 +01:00
2 changed files with 97 additions and 93 deletions

View File

@@ -1,15 +1,26 @@
<template> <template>
<div class="infinite-reader" ref="containerRef"> <div class="infinite-reader" ref="containerRef">
<div v-for="(page, index) in pages" :key="index" class="page-wrapper" :data-page-index="index"> <div v-for="(page, index) in pages" :key="index"
<ReaderPage class="page-wrapper" :data-page-index="index">
v-if="isPageInWindow(index) && page?.url"
<!-- Pas d'URL : spinner de chargement -->
<div v-if="!page?.url" class="loading">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
<!-- Hors de la zone de rendu : placeholder dimensionné -->
<div v-else-if="!mountedPageIndices.has(index)"
class="page-placeholder"
:style="{ height: getPlaceholderHeight(page) + 'px' }" />
<!-- Dans la zone : composant complet -->
<ReaderPage v-else
:page-data="page" :page-data="page"
:page-number="index + 1" :page-number="index + 1"
:zoom="zoom" :zoom="zoom"
:double-page-mode="doublePageMode" :double-page-mode="doublePageMode"
loading="eager" :window-width="windowWidth"
/> loading="lazy" />
<div v-else class="page-placeholder" :style="getPlaceholderStyle(page)" />
</div> </div>
<!-- Bouton flottant pour revenir en haut --> <!-- Bouton flottant pour revenir en haut -->
@@ -38,29 +49,10 @@
</template> </template>
<script setup> <script setup>
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue'; import { nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue';
import { useHeaderStore } from '../../../../shared/stores/headerStore'; import { useHeaderStore } from '../../../../shared/stores/headerStore';
import ReaderPage from './ReaderPage.vue'; import ReaderPage from './ReaderPage.vue';
const WINDOW_SIZE = 3;
const currentVisibleIndex = ref(0); // initialisé via prop initialPage dans onMounted
const isPageInWindow = (index) => Math.abs(index - currentVisibleIndex.value) <= WINDOW_SIZE;
const getPlaceholderStyle = (page) => {
if (page?.dimensions?.width && page?.dimensions?.height) {
const maxW = windowWidth.value < 1200
? windowWidth.value * 0.95
: 1200;
return {
aspectRatio: `${page.dimensions.width} / ${page.dimensions.height}`,
width: '100%',
maxWidth: `${Math.min(page.dimensions.width, maxW)}px`,
};
}
return { height: '800px', width: '100%' };
};
const props = defineProps({ const props = defineProps({
pages: { pages: {
type: Array, type: Array,
@@ -73,10 +65,6 @@ import ReaderPage from './ReaderPage.vue';
doublePageMode: { doublePageMode: {
type: String, type: String,
required: true required: true
},
initialPage: {
type: Number,
default: 0
} }
}); });
@@ -85,6 +73,8 @@ import ReaderPage from './ReaderPage.vue';
const headerStore = useHeaderStore(); const headerStore = useHeaderStore();
const containerRef = ref(null); const containerRef = ref(null);
const observer = ref(null); const observer = ref(null);
const visibilityObserver = ref(null);
const mountedPageIndices = reactive(new Set());
const windowWidth = ref(window.innerWidth); const windowWidth = ref(window.innerWidth);
// État unique pour tous les boutons flottants avec timer de 3 secondes // État unique pour tous les boutons flottants avec timer de 3 secondes
@@ -96,34 +86,54 @@ import ReaderPage from './ReaderPage.vue';
let scrollDirection = 'down'; let scrollDirection = 'down';
const observeIntersection = entries => { const observeIntersection = entries => {
const intersectingIndices = entries entries.forEach(entry => {
.filter(e => e.isIntersecting) if (entry.isIntersecting) {
.map(e => parseInt(e.target.getAttribute('data-page-index'))); const pageIndex = parseInt(entry.target.getAttribute('data-page-index'));
emit('pageVisible', pageIndex);
if (intersectingIndices.length > 0) { }
const minIdx = Math.min(...intersectingIndices); });
currentVisibleIndex.value = minIdx;
emit('pageVisible', minIdx);
}
}; };
const setupIntersectionObserver = () => { // Calcul de la hauteur du placeholder — miroir exact du maxWidth de ReaderPage
if (observer.value) { const getPlaceholderHeight = (page) => {
observer.value.disconnect(); const dims = page?.dimensions;
} if (!dims?.width || !dims?.height) return 800;
const displayWidth = windowWidth.value < 1200
? Math.min(dims.width, windowWidth.value * 0.95)
: Math.min(dims.width, 1200);
return Math.round((dims.height / dims.width) * displayWidth);
};
const setupObservers = () => {
observer.value?.disconnect();
visibilityObserver.value?.disconnect();
observer.value = new IntersectionObserver(observeIntersection, { observer.value = new IntersectionObserver(observeIntersection, {
root: null, root: null,
threshold: 0.5 threshold: 0.5
}); });
nextTick(() => { visibilityObserver.value = new IntersectionObserver(
const pageElements = containerRef.value?.querySelectorAll('.page-wrapper'); (entries) => {
if (pageElements) { entries.forEach(entry => {
pageElements.forEach(element => { const idx = parseInt(entry.target.getAttribute('data-page-index'));
observer.value.observe(element); if (entry.isIntersecting) {
mountedPageIndices.add(idx);
} else {
mountedPageIndices.delete(idx);
}
}); });
} },
{ root: null, rootMargin: '1000px 0px', threshold: 0 }
);
nextTick(() => {
const els = containerRef.value?.querySelectorAll('.page-wrapper');
els?.forEach((el, i) => {
el.setAttribute('data-page-index', i);
observer.value.observe(el);
visibilityObserver.value.observe(el);
});
}); });
}; };
@@ -207,21 +217,16 @@ import ReaderPage from './ReaderPage.vue';
// Fonction pour revenir en haut de la page // Fonction pour revenir en haut de la page
const scrollToTop = () => { const scrollToTop = () => {
console.log('scrollToTop appelée'); // Debug
// Réinitialiser le timer lors du clic // Réinitialiser le timer lors du clic
resetButtonsTimer(); resetButtonsTimer();
// Stratégie 1: Scroll sur le conteneur direct // Stratégie 1: Scroll sur le conteneur direct
if (containerRef.value) { if (containerRef.value) {
console.log('containerRef trouvé, scrollTop actuel:', containerRef.value.scrollTop); // Debug
if (containerRef.value.scrollTop > 0) { if (containerRef.value.scrollTop > 0) {
containerRef.value.scrollTo({ containerRef.value.scrollTo({
top: 0, top: 0,
behavior: 'smooth' behavior: 'smooth'
}); });
console.log('Scroll sur containerRef effectué'); // Debug
return; return;
} }
} }
@@ -231,7 +236,6 @@ import ReaderPage from './ReaderPage.vue';
while (currentElement) { while (currentElement) {
const styles = window.getComputedStyle(currentElement); const styles = window.getComputedStyle(currentElement);
if (styles.overflowY === 'auto' || styles.overflowY === 'scroll' || currentElement.scrollTop > 0) { if (styles.overflowY === 'auto' || styles.overflowY === 'scroll' || currentElement.scrollTop > 0) {
console.log('Conteneur avec scroll trouvé:', currentElement.className, 'scrollTop:', currentElement.scrollTop); // Debug
currentElement.scrollTo({ currentElement.scrollTo({
top: 0, top: 0,
behavior: 'smooth' behavior: 'smooth'
@@ -242,7 +246,6 @@ import ReaderPage from './ReaderPage.vue';
} }
// Stratégie 3: Scroll sur la fenêtre entière // Stratégie 3: Scroll sur la fenêtre entière
console.log('Scroll sur window, scrollY actuel:', window.scrollY); // Debug
window.scrollTo({ window.scrollTo({
top: 0, top: 0,
behavior: 'smooth' behavior: 'smooth'
@@ -258,7 +261,8 @@ import ReaderPage from './ReaderPage.vue';
watch( watch(
() => props.pages, () => props.pages,
() => { () => {
setupIntersectionObserver(); mountedPageIndices.clear();
setupObservers();
}, },
{ immediate: true } { immediate: true }
); );
@@ -277,8 +281,7 @@ import ReaderPage from './ReaderPage.vue';
}; };
onMounted(() => { onMounted(() => {
currentVisibleIndex.value = props.initialPage; setupObservers();
setupIntersectionObserver();
// Activer l'auto-hide du header si la largeur < 1200px // Activer l'auto-hide du header si la largeur < 1200px
if (windowWidth.value < 1200) { if (windowWidth.value < 1200) {
@@ -298,9 +301,8 @@ import ReaderPage from './ReaderPage.vue';
}); });
onUnmounted(() => { onUnmounted(() => {
if (observer.value) { observer.value?.disconnect();
observer.value.disconnect(); visibilityObserver.value?.disconnect();
}
// Désactiver l'auto-hide du header en quittant // Désactiver l'auto-hide du header en quittant
headerStore.disableAutoHide(); headerStore.disableAutoHide();
@@ -335,25 +337,34 @@ import ReaderPage from './ReaderPage.vue';
} }
.page-placeholder { .page-placeholder {
@apply flex justify-center; @apply w-full;
background: transparent; max-width: 1200px;
min-height: 400px;
} }
.loading,
.error { .error {
@apply text-red-500 text-xl bg-red-500/10 rounded-lg flex items-center justify-center min-h-[400px]; @apply flex items-center justify-center min-h-[400px];
width: 95vw; /* Largeur adaptative selon la taille d'écran */
width: 95vw; /* Mobile : 95% de la largeur */
} }
@screen sm { @screen sm {
.loading,
.error { .error {
width: 80vw; width: 80vw; /* Tablette : 80% de la largeur */
} }
} }
@screen lg { @screen lg {
.loading,
.error { .error {
width: 70vw; width: 70vw; /* Desktop : 70% de la largeur */
} }
} }
.error {
@apply text-red-500 text-xl bg-red-500/10 rounded-lg;
}
</style> </style>

View File

@@ -15,7 +15,6 @@
:alt="`Page ${pageNumber} (Double page)`" :alt="`Page ${pageNumber} (Double page)`"
class="page-image rotated" class="page-image rotated"
:style="doublePageRotatedStyle" :style="doublePageRotatedStyle"
:loading="loading"
@load="handleImageLoad" @load="handleImageLoad"
ref="imageRef" /> ref="imageRef" />
<div class="rotation-hint"> <div class="rotation-hint">
@@ -34,7 +33,6 @@
:alt="`Page ${pageNumber} (Double page)`" :alt="`Page ${pageNumber} (Double page)`"
class="page-image scrollable" class="page-image scrollable"
:style="doublePageScrollStyle" :style="doublePageScrollStyle"
:loading="loading"
@load="handleImageLoad" @load="handleImageLoad"
ref="imageRef" /> ref="imageRef" />
</div> </div>
@@ -54,7 +52,6 @@
:alt="`Page ${pageNumber}`" :alt="`Page ${pageNumber}`"
class="page-image" class="page-image"
:style="imageStyle" :style="imageStyle"
:loading="loading"
@load="handleImageLoad" @load="handleImageLoad"
ref="imageRef" /> ref="imageRef" />
</div> </div>
@@ -82,9 +79,9 @@ import { useReaderStore } from '../../application/store/readerStore';
default: 'rotate', // 'rotate', 'scroll', 'normal' default: 'rotate', // 'rotate', 'scroll', 'normal'
validator: (value) => ['rotate', 'scroll', 'normal'].includes(value) validator: (value) => ['rotate', 'scroll', 'normal'].includes(value)
}, },
loading: { windowWidth: {
type: String, type: Number,
default: 'lazy', default: null
} }
}); });
@@ -103,8 +100,11 @@ import { useReaderStore } from '../../application/store/readerStore';
const scrollContainerRef = ref(null); const scrollContainerRef = ref(null);
const naturalWidth = ref(0); const naturalWidth = ref(0);
const naturalHeight = ref(0); const naturalHeight = ref(0);
const windowWidth = ref(window.innerWidth); const localWindowWidth = ref(window.innerWidth);
const isMobile = computed(() => windowWidth.value < 768); const effectiveWindowWidth = computed(() =>
props.windowWidth !== null ? props.windowWidth : localWindowWidth.value
);
const isMobile = computed(() => effectiveWindowWidth.value < 768);
const imageLoaded = ref(false); const imageLoaded = ref(false);
const imageSource = computed(() => { const imageSource = computed(() => {
@@ -123,17 +123,13 @@ import { useReaderStore } from '../../application/store/readerStore';
// Utiliser d'abord les dimensions de l'API si disponibles // Utiliser d'abord les dimensions de l'API si disponibles
if (props.pageData?.dimensions?.width && props.pageData?.dimensions?.height) { if (props.pageData?.dimensions?.width && props.pageData?.dimensions?.height) {
const ratio = props.pageData.dimensions.width / props.pageData.dimensions.height; const ratio = props.pageData.dimensions.width / props.pageData.dimensions.height;
const isDouble = ratio > threshold; return ratio > threshold;
console.log(`API Dimensions - Page ${props.pageNumber}: ${props.pageData.dimensions.width}x${props.pageData.dimensions.height}, ratio: ${ratio.toFixed(2)}, isDouble: ${isDouble}`);
return isDouble;
} }
// Fallback sur les dimensions naturelles de l'image (seulement si l'image est chargée) // Fallback sur les dimensions naturelles de l'image (seulement si l'image est chargée)
if (imageLoaded.value && naturalWidth.value && naturalHeight.value) { if (imageLoaded.value && naturalWidth.value && naturalHeight.value) {
const ratio = naturalWidth.value / naturalHeight.value; const ratio = naturalWidth.value / naturalHeight.value;
const isDouble = ratio > threshold; return ratio > threshold;
console.log(`Natural Dimensions - Page ${props.pageNumber}: ${naturalWidth.value}x${naturalHeight.value}, ratio: ${ratio.toFixed(2)}, isDouble: ${isDouble}`);
return isDouble;
} }
return false; return false;
@@ -144,7 +140,6 @@ import { useReaderStore } from '../../application/store/readerStore';
naturalWidth.value = imageRef.value.naturalWidth; naturalWidth.value = imageRef.value.naturalWidth;
naturalHeight.value = imageRef.value.naturalHeight; naturalHeight.value = imageRef.value.naturalHeight;
imageLoaded.value = true; imageLoaded.value = true;
console.log(`Image loaded - Page ${props.pageNumber}: ${naturalWidth.value}x${naturalHeight.value}`);
// Positionner le scroll à droite si c'est le mode scroll // Positionner le scroll à droite si c'est le mode scroll
if (props.doublePageMode === 'scroll' && scrollContainerRef.value) { if (props.doublePageMode === 'scroll' && scrollContainerRef.value) {
@@ -195,7 +190,7 @@ import { useReaderStore } from '../../application/store/readerStore';
if (!width || !height) return null; if (!width || !height) return null;
const availableWidth = windowWidth.value; const availableWidth = effectiveWindowWidth.value;
// Si la largeur disponible est < 1200px : utiliser 95% de la largeur // Si la largeur disponible est < 1200px : utiliser 95% de la largeur
if (availableWidth < 1200) { if (availableWidth < 1200) {
@@ -244,7 +239,7 @@ import { useReaderStore } from '../../application/store/readerStore';
if (!width || !height) return {}; if (!width || !height) return {};
// En mode rotation : maximiser l'utilisation de l'espace // En mode rotation : maximiser l'utilisation de l'espace
const availableWidth = windowWidth.value; const availableWidth = effectiveWindowWidth.value;
const availableHeight = window.innerHeight - 100; // Laisser un peu d'espace pour les contrôles const availableHeight = window.innerHeight - 100; // Laisser un peu d'espace pour les contrôles
// Après rotation, la largeur originale devient la hauteur affichée // Après rotation, la largeur originale devient la hauteur affichée
@@ -294,20 +289,18 @@ import { useReaderStore } from '../../application/store/readerStore';
}; };
}); });
// Gestion du redimensionnement de la fenêtre let ownResizeHandler = null;
const handleResize = () => {
windowWidth.value = window.innerWidth;
};
onMounted(() => { onMounted(() => {
if (imageRef.value && imageRef.value.complete) { if (props.windowWidth === null) {
handleImageLoad(); ownResizeHandler = () => { localWindowWidth.value = window.innerWidth; };
window.addEventListener('resize', ownResizeHandler, { passive: true });
} }
window.addEventListener('resize', handleResize); if (imageRef.value?.complete) handleImageLoad();
}); });
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener('resize', handleResize); if (ownResizeHandler) window.removeEventListener('resize', ownResizeHandler);
}); });
</script> </script>