Merge branch 'main' into feat/volume-chapter-grouping
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user