Files

370 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: containerRef.value,
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: containerRef.value, 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;
}
.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>