Files
Mangarr/assets/vue/app/domain/reader/presentation/components/InfiniteReader.vue
ext.jeremy.guillot@maxicoffee.domains 74f033f5d1 perf(reader): windowing + eager loading sur l'InfiniteReader
- Windowing côté rendu : seules les pages dans une fenêtre de ±3 autour
  de la page visible sont montées en tant que ReaderPage ; les autres
  sont remplacées par des placeholders dimensionnés via aspect-ratio CSS
  pour maintenir la hauteur de scroll sans saut
- IntersectionObserver utilise le minimum des indices intersectants pour
  éviter que les entrées simultanées au chargement ne décalent la fenêtre
- Prop initialPage passé depuis ChapterReader pour ancrer la fenêtre sur
  la page courante dès le montage
- loading="eager" sur les ReaderPage montés (le windowing est le mécanisme
  de lazy-loading, pas l'attribut HTML natif)
- Prop loading bindé sur les 3 balises <img> de ReaderPage
2026-03-15 17:46:00 +01:00

360 lines
12 KiB
Vue

<template>
<div class="infinite-reader" ref="containerRef">
<div v-for="(page, index) in pages" :key="index" class="page-wrapper" :data-page-index="index">
<ReaderPage
v-if="isPageInWindow(index) && page?.url"
:page-data="page"
:page-number="index + 1"
:zoom="zoom"
:double-page-mode="doublePageMode"
loading="eager"
/>
<div v-else class="page-placeholder" :style="getPlaceholderStyle(page)" />
</div>
<!-- Bouton flottant pour revenir en haut -->
<Transition
enter-active-class="transition-all duration-300 ease-out"
leave-active-class="transition-all duration-300 ease-in"
enter-from-class="opacity-0 translate-y-5 scale-75"
enter-to-class="opacity-100 translate-y-0 scale-100"
leave-from-class="opacity-100 translate-y-0 scale-100"
leave-to-class="opacity-0 translate-y-5 scale-75"
>
<button
v-show="showFloatingButtons"
@click="scrollToTop"
class="fixed bottom-6 right-6 z-[9999] bg-gray-800 hover:bg-gray-700 text-white hover:text-green-500 flex flex-col items-center justify-center w-12 h-12 rounded shadow-lg transition-colors duration-200"
title="Revenir en haut"
type="button"
>
<svg class="w-5 h-5 sm:w-6 sm:h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 10l7-7m0 0l7 7m-7-7v18" />
</svg>
<span class="text-xs hidden sm:inline">Haut</span>
</button>
</Transition>
</div>
</template>
<script setup>
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import { useHeaderStore } from '../../../../shared/stores/headerStore';
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({
pages: {
type: Array,
required: true
},
zoom: {
type: Number,
required: true
},
doublePageMode: {
type: String,
required: true
},
initialPage: {
type: Number,
default: 0
}
});
const emit = defineEmits(['pageVisible', 'buttonsVisibilityChange']);
const headerStore = useHeaderStore();
const containerRef = ref(null);
const observer = ref(null);
const windowWidth = ref(window.innerWidth);
// État unique pour tous les boutons flottants avec timer de 3 secondes
const showFloatingButtons = ref(false);
let buttonsTimer = null;
// Variables pour détecter la direction du scroll
let lastScrollTop = 0;
let scrollDirection = 'down';
const observeIntersection = entries => {
const intersectingIndices = entries
.filter(e => e.isIntersecting)
.map(e => parseInt(e.target.getAttribute('data-page-index')));
if (intersectingIndices.length > 0) {
const minIdx = Math.min(...intersectingIndices);
currentVisibleIndex.value = minIdx;
emit('pageVisible', minIdx);
}
};
const setupIntersectionObserver = () => {
if (observer.value) {
observer.value.disconnect();
}
observer.value = new IntersectionObserver(observeIntersection, {
root: null,
threshold: 0.5
});
nextTick(() => {
const pageElements = containerRef.value?.querySelectorAll('.page-wrapper');
if (pageElements) {
pageElements.forEach(element => {
observer.value.observe(element);
});
}
});
};
// Fonction unique pour gérer la visibilité de tous les boutons flottants
const showButtonsWithTimer = () => {
showFloatingButtons.value = true;
emit('buttonsVisibilityChange', true);
// Réinitialiser le timer à chaque fois
clearTimeout(buttonsTimer);
buttonsTimer = setTimeout(() => {
showFloatingButtons.value = false;
emit('buttonsVisibilityChange', false);
}, 3000); // 3 secondes
};
const hideButtonsImmediately = () => {
showFloatingButtons.value = false;
emit('buttonsVisibilityChange', false);
clearTimeout(buttonsTimer);
};
// Fonction exposée pour réinitialiser le timer depuis l'extérieur
const resetButtonsTimer = () => {
if (showFloatingButtons.value) {
clearTimeout(buttonsTimer);
buttonsTimer = setTimeout(() => {
showFloatingButtons.value = false;
emit('buttonsVisibilityChange', false);
}, 3000);
} else {
showButtonsWithTimer();
}
};
// Gestion du scroll pour tous les boutons flottants et le header
const handleScroll = () => {
let scrollTop = 0;
// Vérifier le scroll sur le conteneur direct
if (containerRef.value && containerRef.value.scrollTop > 0) {
scrollTop = containerRef.value.scrollTop;
} else {
// Vérifier le scroll sur les conteneurs parents
let currentElement = containerRef.value?.parentElement;
while (currentElement && scrollTop === 0) {
if (currentElement.scrollTop > 0) {
scrollTop = currentElement.scrollTop;
break;
}
currentElement = currentElement.parentElement;
}
// Vérifier le scroll sur la fenêtre
if (scrollTop === 0) {
scrollTop = window.scrollY;
}
}
// Détecter la direction du scroll
if (scrollTop > lastScrollTop) {
scrollDirection = 'down';
} else if (scrollTop < lastScrollTop) {
scrollDirection = 'up';
}
// Gestion du header auto-hide (header : seulement si largeur < 1200px, toolbar : toujours)
headerStore.updateScrollDirection(scrollTop);
// Gestion de la visibilité des boutons flottants (même condition pour tous)
// Afficher si on scroll et qu'on est à plus de 300px
if (scrollTop > 300) {
showButtonsWithTimer();
} else if (scrollTop <= 100) {
// Masquer immédiatement si on est en haut de page
hideButtonsImmediately();
}
// Sauvegarder la position actuelle pour la prochaine comparaison
lastScrollTop = scrollTop;
};
// Fonction pour revenir en haut de la page
const scrollToTop = () => {
console.log('scrollToTop appelée'); // Debug
// Réinitialiser le timer lors du clic
resetButtonsTimer();
// Stratégie 1: Scroll sur le conteneur direct
if (containerRef.value) {
console.log('containerRef trouvé, scrollTop actuel:', containerRef.value.scrollTop); // Debug
if (containerRef.value.scrollTop > 0) {
containerRef.value.scrollTo({
top: 0,
behavior: 'smooth'
});
console.log('Scroll sur containerRef effectué'); // Debug
return;
}
}
// Stratégie 2: Chercher le conteneur parent avec scroll
let currentElement = containerRef.value?.parentElement;
while (currentElement) {
const styles = window.getComputedStyle(currentElement);
if (styles.overflowY === 'auto' || styles.overflowY === 'scroll' || currentElement.scrollTop > 0) {
console.log('Conteneur avec scroll trouvé:', currentElement.className, 'scrollTop:', currentElement.scrollTop); // Debug
currentElement.scrollTo({
top: 0,
behavior: 'smooth'
});
return;
}
currentElement = currentElement.parentElement;
}
// Stratégie 3: Scroll sur la fenêtre entière
console.log('Scroll sur window, scrollY actuel:', window.scrollY); // Debug
window.scrollTo({
top: 0,
behavior: 'smooth'
});
};
// Exposer la fonction pour le parent
defineExpose({
resetButtonsTimer,
showButtonsWithTimer
});
watch(
() => props.pages,
() => {
setupIntersectionObserver();
},
{ immediate: true }
);
// Gestion du redimensionnement de la fenêtre
const handleResize = () => {
const newWidth = window.innerWidth;
windowWidth.value = newWidth;
// Activer/désactiver l'auto-hide selon la largeur
if (newWidth < 1200) {
headerStore.enableAutoHide();
} else {
headerStore.disableAutoHide();
}
};
onMounted(() => {
currentVisibleIndex.value = props.initialPage;
setupIntersectionObserver();
// Activer l'auto-hide du header si la largeur < 1200px
if (windowWidth.value < 1200) {
headerStore.enableAutoHide();
}
// Ajouter l'écouteur de scroll sur le conteneur
if (containerRef.value) {
containerRef.value.addEventListener('scroll', handleScroll, { passive: true });
}
// Ajouter l'écouteur de scroll sur la fenêtre
window.addEventListener('scroll', handleScroll, { passive: true });
// Ajouter l'écouteur de redimensionnement
window.addEventListener('resize', handleResize, { passive: true });
});
onUnmounted(() => {
if (observer.value) {
observer.value.disconnect();
}
// Désactiver l'auto-hide du header en quittant
headerStore.disableAutoHide();
// Nettoyer les timers
clearTimeout(buttonsTimer);
// Nettoyer l'écouteur de scroll du conteneur
if (containerRef.value) {
containerRef.value.removeEventListener('scroll', handleScroll);
}
// Nettoyer l'écouteur de scroll de la fenêtre
window.removeEventListener('scroll', handleScroll);
// Nettoyer l'écouteur de redimensionnement
window.removeEventListener('resize', handleResize);
});
</script>
<style lang="postcss" scoped>
.infinite-reader {
@apply flex-1 flex flex-col items-center overflow-y-auto relative min-h-0;
/* Réduction du padding sur mobile */
@apply py-2 sm:py-8;
scroll-behavior: smooth;
}
.page-wrapper {
@apply w-full flex justify-center;
@apply mb-2 sm:mb-4 px-1 sm:px-4;
}
.page-placeholder {
@apply flex justify-center;
background: transparent;
}
.error {
@apply text-red-500 text-xl bg-red-500/10 rounded-lg flex items-center justify-center min-h-[400px];
width: 95vw;
}
@screen sm {
.error {
width: 80vw;
}
}
@screen lg {
.error {
width: 70vw;
}
}
</style>