370 lines
12 KiB
Vue
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>
|