En mode scroll infini, le zoom était appliqué via transform: scale() qui n'affecte pas le flux de mise en page. Les pages se chevauchaient visuellement quand le zoom était modifié. Passage à la propriété CSS zoom dans les deux modes pour un comportement de layout correct. Met aussi à jour le calcul de hauteur des placeholders pour inclure le facteur de zoom et éviter les sauts de layout lors du chargement paresseux.
371 lines
12 KiB
Vue
371 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">
|
|
|
|
<!-- 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-number="index + 1"
|
|
:zoom="zoom"
|
|
:double-page-mode="doublePageMode"
|
|
:window-width="windowWidth"
|
|
loading="lazy" />
|
|
</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, reactive, ref, watch } from 'vue';
|
|
import { useHeaderStore } from '../../../../shared/stores/headerStore';
|
|
import ReaderPage from './ReaderPage.vue';
|
|
|
|
const props = defineProps({
|
|
pages: {
|
|
type: Array,
|
|
required: true
|
|
},
|
|
zoom: {
|
|
type: Number,
|
|
required: true
|
|
},
|
|
doublePageMode: {
|
|
type: String,
|
|
required: true
|
|
}
|
|
});
|
|
|
|
const emit = defineEmits(['pageVisible', 'buttonsVisibilityChange']);
|
|
|
|
const headerStore = useHeaderStore();
|
|
const containerRef = ref(null);
|
|
const observer = ref(null);
|
|
const visibilityObserver = ref(null);
|
|
const mountedPageIndices = reactive(new Set());
|
|
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 => {
|
|
entries.forEach(entry => {
|
|
if (entry.isIntersecting) {
|
|
const pageIndex = parseInt(entry.target.getAttribute('data-page-index'));
|
|
emit('pageVisible', pageIndex);
|
|
}
|
|
});
|
|
};
|
|
|
|
// Calcul de la hauteur du placeholder — miroir exact du maxWidth de ReaderPage, zoom inclus
|
|
const getPlaceholderHeight = (page) => {
|
|
const dims = page?.dimensions;
|
|
if (!dims?.width || !dims?.height) return Math.round(800 * props.zoom);
|
|
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 * props.zoom);
|
|
};
|
|
|
|
const setupObservers = () => {
|
|
observer.value?.disconnect();
|
|
visibilityObserver.value?.disconnect();
|
|
|
|
observer.value = new IntersectionObserver(observeIntersection, {
|
|
root: null,
|
|
threshold: 0.5
|
|
});
|
|
|
|
visibilityObserver.value = new IntersectionObserver(
|
|
(entries) => {
|
|
entries.forEach(entry => {
|
|
const idx = parseInt(entry.target.getAttribute('data-page-index'));
|
|
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);
|
|
});
|
|
});
|
|
};
|
|
|
|
// 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 = () => {
|
|
// Réinitialiser le timer lors du clic
|
|
resetButtonsTimer();
|
|
|
|
// Stratégie 1: Scroll sur le conteneur direct
|
|
if (containerRef.value) {
|
|
if (containerRef.value.scrollTop > 0) {
|
|
containerRef.value.scrollTo({
|
|
top: 0,
|
|
behavior: 'smooth'
|
|
});
|
|
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) {
|
|
currentElement.scrollTo({
|
|
top: 0,
|
|
behavior: 'smooth'
|
|
});
|
|
return;
|
|
}
|
|
currentElement = currentElement.parentElement;
|
|
}
|
|
|
|
// Stratégie 3: Scroll sur la fenêtre entière
|
|
window.scrollTo({
|
|
top: 0,
|
|
behavior: 'smooth'
|
|
});
|
|
};
|
|
|
|
// Exposer la fonction pour le parent
|
|
defineExpose({
|
|
resetButtonsTimer,
|
|
showButtonsWithTimer
|
|
});
|
|
|
|
watch(
|
|
() => props.pages,
|
|
() => {
|
|
mountedPageIndices.clear();
|
|
setupObservers();
|
|
},
|
|
{ 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(() => {
|
|
setupObservers();
|
|
|
|
// 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(() => {
|
|
observer.value?.disconnect();
|
|
visibilityObserver.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 w-full;
|
|
max-width: 1200px;
|
|
min-height: 400px;
|
|
}
|
|
|
|
.loading,
|
|
.error {
|
|
@apply flex items-center justify-center min-h-[400px];
|
|
/* Largeur adaptative selon la taille d'écran */
|
|
width: 95vw; /* Mobile : 95% de la largeur */
|
|
}
|
|
|
|
@screen sm {
|
|
.loading,
|
|
.error {
|
|
width: 80vw; /* Tablette : 80% de la largeur */
|
|
}
|
|
}
|
|
|
|
@screen lg {
|
|
.loading,
|
|
.error {
|
|
width: 70vw; /* Desktop : 70% de la largeur */
|
|
}
|
|
}
|
|
|
|
.error {
|
|
@apply text-red-500 text-xl bg-red-500/10 rounded-lg;
|
|
}
|
|
|
|
</style>
|