Remplace le rendu de tous les composants ReaderPage par un système de virtual rendering : seules les pages dans la zone ±1000px du viewport sont montées, les autres sont remplacées par un placeholder dimensionné. - InfiniteReader : ajout visibilityObserver + mountedPageIndices (Set réactif), helper getPlaceholderHeight(), suppression de 5 console.log - ReaderPage : prop windowWidth injectable depuis le parent, listener resize conditionnel, suppression de 3 console.log de debug
436 lines
15 KiB
Vue
436 lines
15 KiB
Vue
<template>
|
|
<div
|
|
class="page-container"
|
|
:style="containerStyle"
|
|
>
|
|
<div v-if="!pageData" class="error">Aucune donnée d'image disponible</div>
|
|
<div v-else-if="!pageData.url" class="error">URL de l'image manquante</div>
|
|
|
|
<!-- Affichage spécial pour les doubles pages sur mobile -->
|
|
<div v-else-if="isDoublePage && isMobile && doublePageMode !== 'normal'" class="double-page-mobile">
|
|
<!-- Mode rotation automatique -->
|
|
<div v-if="doublePageMode === 'rotate'" class="double-page-rotated">
|
|
<img
|
|
:src="imageSource"
|
|
:alt="`Page ${pageNumber} (Double page)`"
|
|
class="page-image rotated"
|
|
:style="doublePageRotatedStyle"
|
|
@load="handleImageLoad"
|
|
ref="imageRef" />
|
|
<div class="rotation-hint">
|
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
|
</svg>
|
|
<span>Tournez votre appareil pour une meilleure lecture</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Mode défilement horizontal -->
|
|
<div v-else-if="doublePageMode === 'scroll'" class="double-page-scroll">
|
|
<div class="scroll-container" ref="scrollContainerRef">
|
|
<img
|
|
:src="imageSource"
|
|
:alt="`Page ${pageNumber} (Double page)`"
|
|
class="page-image scrollable"
|
|
:style="doublePageScrollStyle"
|
|
@load="handleImageLoad"
|
|
ref="imageRef" />
|
|
</div>
|
|
<div class="scroll-hint">
|
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16l-4-4m0 0l4-4m-4 4h18" />
|
|
</svg>
|
|
<span>Glissez horizontalement (commence par la droite)</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Affichage normal pour les pages simples, sur desktop, ou mode normal forcé -->
|
|
<img
|
|
v-else
|
|
:src="imageSource"
|
|
:alt="`Page ${pageNumber}`"
|
|
class="page-image"
|
|
:style="imageStyle"
|
|
@load="handleImageLoad"
|
|
ref="imageRef" />
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
|
import { useReaderStore } from '../../application/store/readerStore';
|
|
|
|
const props = defineProps({
|
|
pageData: {
|
|
type: Object,
|
|
required: true
|
|
},
|
|
pageNumber: {
|
|
type: Number,
|
|
required: true
|
|
},
|
|
zoom: {
|
|
type: Number,
|
|
required: true
|
|
},
|
|
doublePageMode: {
|
|
type: String,
|
|
default: 'rotate', // 'rotate', 'scroll', 'normal'
|
|
validator: (value) => ['rotate', 'scroll', 'normal'].includes(value)
|
|
},
|
|
windowWidth: {
|
|
type: Number,
|
|
default: null
|
|
}
|
|
});
|
|
|
|
const store = useReaderStore();
|
|
|
|
// En mode single : zoom via la propriété CSS `zoom` (affecte le layout → scrollbars naturelles)
|
|
// En mode infinite : zoom via transform: scale (pas d'impact layout souhaité)
|
|
const containerStyle = computed(() => {
|
|
if (store.readingMode === 'single') {
|
|
return { zoom: props.zoom };
|
|
}
|
|
return { transform: `scale(${props.zoom})` };
|
|
});
|
|
|
|
const imageRef = ref(null);
|
|
const scrollContainerRef = ref(null);
|
|
const naturalWidth = ref(0);
|
|
const naturalHeight = ref(0);
|
|
const localWindowWidth = ref(window.innerWidth);
|
|
const effectiveWindowWidth = computed(() =>
|
|
props.windowWidth !== null ? props.windowWidth : localWindowWidth.value
|
|
);
|
|
const isMobile = computed(() => effectiveWindowWidth.value < 768);
|
|
const imageLoaded = ref(false);
|
|
|
|
const imageSource = computed(() => {
|
|
return props.pageData?.url ?? '';
|
|
});
|
|
|
|
// Détection des doubles pages basée sur le ratio largeur/hauteur et les dimensions API
|
|
const isDoublePage = computed(() => {
|
|
// Ne pas détecter si la détection automatique est désactivée
|
|
if (!store.doublePageSettings.autoDetect) {
|
|
return false;
|
|
}
|
|
|
|
const threshold = store.doublePageSettings.detectionThreshold || 1.2;
|
|
|
|
// Utiliser d'abord les dimensions de l'API si disponibles
|
|
if (props.pageData?.dimensions?.width && props.pageData?.dimensions?.height) {
|
|
const ratio = props.pageData.dimensions.width / props.pageData.dimensions.height;
|
|
return ratio > threshold;
|
|
}
|
|
|
|
// Fallback sur les dimensions naturelles de l'image (seulement si l'image est chargée)
|
|
if (imageLoaded.value && naturalWidth.value && naturalHeight.value) {
|
|
const ratio = naturalWidth.value / naturalHeight.value;
|
|
return ratio > threshold;
|
|
}
|
|
|
|
return false;
|
|
});
|
|
|
|
const handleImageLoad = () => {
|
|
if (imageRef.value) {
|
|
naturalWidth.value = imageRef.value.naturalWidth;
|
|
naturalHeight.value = imageRef.value.naturalHeight;
|
|
imageLoaded.value = true;
|
|
|
|
// Positionner le scroll à droite si c'est le mode scroll
|
|
if (props.doublePageMode === 'scroll' && scrollContainerRef.value) {
|
|
nextTick(() => {
|
|
scrollContainerRef.value.scrollLeft = scrollContainerRef.value.scrollWidth - scrollContainerRef.value.clientWidth;
|
|
});
|
|
}
|
|
}
|
|
};
|
|
|
|
// Réinitialiser les dimensions quand on change de page
|
|
const resetDimensions = () => {
|
|
naturalWidth.value = 0;
|
|
naturalHeight.value = 0;
|
|
imageLoaded.value = false;
|
|
};
|
|
|
|
// Watcher pour détecter les changements de page
|
|
watch(
|
|
() => props.pageData,
|
|
(newPageData, oldPageData) => {
|
|
// Réinitialiser les dimensions si c'est une nouvelle page
|
|
if (newPageData?.id !== oldPageData?.id) {
|
|
resetDimensions();
|
|
}
|
|
},
|
|
{ immediate: true }
|
|
);
|
|
|
|
// Watcher pour détecter les changements de numéro de page
|
|
watch(
|
|
() => props.pageNumber,
|
|
() => {
|
|
resetDimensions();
|
|
}
|
|
);
|
|
|
|
// Calculer la largeur maximale en fonction de la largeur disponible
|
|
const maxWidth = computed(() => {
|
|
// Utiliser les dimensions API en priorité
|
|
let width = naturalWidth.value;
|
|
let height = naturalHeight.value;
|
|
|
|
if (props.pageData?.dimensions?.width && props.pageData?.dimensions?.height) {
|
|
width = props.pageData.dimensions.width;
|
|
height = props.pageData.dimensions.height;
|
|
}
|
|
|
|
if (!width || !height) return null;
|
|
|
|
const availableWidth = effectiveWindowWidth.value;
|
|
|
|
// Si la largeur disponible est < 1200px : utiliser 95% de la largeur
|
|
if (availableWidth < 1200) {
|
|
return Math.min(width, availableWidth * 0.95);
|
|
}
|
|
|
|
// Si la largeur disponible est >= 1200px : limiter à 1200px maximum
|
|
return Math.min(width, 1200);
|
|
});
|
|
|
|
const imageStyle = computed(() => {
|
|
// Mode simple : laisser CSS contraindre les deux dimensions proportionnellement
|
|
if (store.readingMode === 'single') {
|
|
return {
|
|
maxWidth: '100%',
|
|
maxHeight: '100%',
|
|
width: 'auto',
|
|
height: 'auto',
|
|
};
|
|
}
|
|
|
|
// Mode scroll : fixer la largeur, hauteur libre
|
|
const style = {
|
|
height: 'auto',
|
|
maxWidth: '100%',
|
|
};
|
|
|
|
if (maxWidth.value) {
|
|
style.width = `${maxWidth.value}px`;
|
|
}
|
|
|
|
return style;
|
|
});
|
|
|
|
// Styles spéciaux pour les doubles pages
|
|
const doublePageRotatedStyle = computed(() => {
|
|
let width = naturalWidth.value;
|
|
let height = naturalHeight.value;
|
|
|
|
// Utiliser les dimensions API si disponibles
|
|
if (props.pageData?.dimensions?.width && props.pageData?.dimensions?.height) {
|
|
width = props.pageData.dimensions.width;
|
|
height = props.pageData.dimensions.height;
|
|
}
|
|
|
|
if (!width || !height) return {};
|
|
|
|
// En mode rotation : maximiser l'utilisation de l'espace
|
|
const availableWidth = effectiveWindowWidth.value;
|
|
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
|
|
// et la hauteur originale devient la largeur affichée
|
|
const rotatedWidth = height; // Hauteur de l'image devient largeur après rotation
|
|
const rotatedHeight = width; // Largeur de l'image devient hauteur après rotation
|
|
|
|
// Calculer le facteur d'échelle pour remplir l'écran
|
|
const scaleByWidth = availableWidth * 0.98 / rotatedWidth;
|
|
const scaleByHeight = availableHeight * 0.95 / rotatedHeight;
|
|
const scaleFactor = Math.min(scaleByWidth, scaleByHeight);
|
|
|
|
return {
|
|
width: `${width * scaleFactor}px`,
|
|
height: `${height * scaleFactor}px`,
|
|
maxWidth: 'none',
|
|
maxHeight: 'none',
|
|
transform: 'rotate(90deg)',
|
|
transformOrigin: 'center center'
|
|
};
|
|
});
|
|
|
|
const doublePageScrollStyle = computed(() => {
|
|
let width = naturalWidth.value;
|
|
let height = naturalHeight.value;
|
|
|
|
// Utiliser les dimensions API si disponibles
|
|
if (props.pageData?.dimensions?.width && props.pageData?.dimensions?.height) {
|
|
width = props.pageData.dimensions.width;
|
|
height = props.pageData.dimensions.height;
|
|
}
|
|
|
|
if (!width || !height) return {};
|
|
|
|
// Mode scroll : remplir la hauteur, permettre le défilement horizontal
|
|
const availableHeight = window.innerHeight - 80; // Espace pour les contrôles
|
|
|
|
// Échelle basée sur la hauteur pour remplir l'écran verticalement
|
|
const scaleFactor = (availableHeight * 0.95) / height;
|
|
|
|
return {
|
|
width: `${width * scaleFactor}px`,
|
|
height: `${height * scaleFactor}px`,
|
|
maxWidth: 'none',
|
|
maxHeight: 'none',
|
|
minWidth: '150vw' // Assurer un débordement horizontal pour le scroll
|
|
};
|
|
});
|
|
|
|
let ownResizeHandler = null;
|
|
|
|
onMounted(() => {
|
|
if (props.windowWidth === null) {
|
|
ownResizeHandler = () => { localWindowWidth.value = window.innerWidth; };
|
|
window.addEventListener('resize', ownResizeHandler, { passive: true });
|
|
}
|
|
if (imageRef.value?.complete) handleImageLoad();
|
|
});
|
|
|
|
onUnmounted(() => {
|
|
if (ownResizeHandler) window.removeEventListener('resize', ownResizeHandler);
|
|
});
|
|
</script>
|
|
|
|
<style lang="postcss" scoped>
|
|
.page-container {
|
|
@apply flex items-center justify-center;
|
|
transform-origin: center;
|
|
@apply p-0 sm:p-2;
|
|
}
|
|
|
|
.page-image {
|
|
@apply object-contain;
|
|
/* La largeur et max-height sont gérées par imageStyle selon le mode */
|
|
max-width: 100%;
|
|
}
|
|
|
|
/* Styles pour les doubles pages sur mobile */
|
|
.double-page-mobile {
|
|
@apply w-full h-full flex flex-col items-center justify-center relative;
|
|
/* Utiliser tout l'espace disponible */
|
|
min-height: 100vh;
|
|
min-width: 100vw;
|
|
}
|
|
|
|
.double-page-rotated {
|
|
@apply relative flex items-center justify-center;
|
|
/* Espace pour la rotation - utiliser tout l'espace */
|
|
width: 100vw;
|
|
height: 100vh;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.double-page-rotated .page-image.rotated {
|
|
@apply origin-center;
|
|
/* Animation fluide pour la rotation */
|
|
transition: transform 0.3s ease-in-out;
|
|
/* Permettre le débordement contrôlé */
|
|
position: relative;
|
|
z-index: 1;
|
|
}
|
|
|
|
.double-page-scroll {
|
|
@apply w-full h-full flex flex-col items-center;
|
|
/* Utiliser toute la hauteur disponible */
|
|
min-height: 100vh;
|
|
}
|
|
|
|
.scroll-container {
|
|
@apply overflow-x-auto overflow-y-hidden w-full flex items-center justify-start;
|
|
/* Utiliser toute la hauteur et largeur */
|
|
height: 100vh;
|
|
width: 100vw;
|
|
/* Barres de défilement personnalisées */
|
|
scrollbar-width: thin;
|
|
scrollbar-color: #4F46E5 #E5E7EB;
|
|
/* Défilement fluide */
|
|
scroll-behavior: smooth;
|
|
}
|
|
|
|
.scroll-container::-webkit-scrollbar {
|
|
height: 12px; /* Plus visible sur mobile */
|
|
}
|
|
|
|
.scroll-container::-webkit-scrollbar-track {
|
|
@apply bg-gray-200 rounded;
|
|
}
|
|
|
|
.scroll-container::-webkit-scrollbar-thumb {
|
|
@apply bg-blue-600 rounded hover:bg-blue-700;
|
|
}
|
|
|
|
.double-page-scroll .page-image.scrollable {
|
|
@apply flex-shrink-0;
|
|
/* Centrer verticalement */
|
|
margin: auto 0;
|
|
}
|
|
|
|
/* Hints pour guider l'utilisateur - ajuster la position */
|
|
.rotation-hint,
|
|
.scroll-hint {
|
|
@apply absolute top-8 left-4 bg-black bg-opacity-70 text-white px-3 py-2 rounded-lg text-sm flex items-center gap-2 z-10;
|
|
/* Animation de disparition */
|
|
animation: fadeOut 4s ease-in-out 2s forwards;
|
|
/* S'assurer qu'ils sont visibles par-dessus tout */
|
|
z-index: 9999;
|
|
}
|
|
|
|
@keyframes fadeOut {
|
|
0%, 50% {
|
|
opacity: 1;
|
|
}
|
|
100% {
|
|
opacity: 0;
|
|
pointer-events: none;
|
|
}
|
|
}
|
|
|
|
.error {
|
|
@apply text-red-500 text-xl;
|
|
}
|
|
|
|
/* Responsive ajustements */
|
|
@media (orientation: landscape) and (max-width: 768px) {
|
|
.double-page-rotated .page-image.rotated {
|
|
/* En mode paysage mobile, ajuster la rotation pour optimiser l'espace */
|
|
transform: none !important;
|
|
}
|
|
|
|
.rotation-hint {
|
|
display: none;
|
|
}
|
|
|
|
/* En paysage, utiliser le mode adaptatif automatiquement */
|
|
.double-page-rotated {
|
|
@apply flex items-center justify-center;
|
|
width: 100vw;
|
|
height: 100vh;
|
|
}
|
|
}
|
|
|
|
/* Spécifique pour les écrans très petits */
|
|
@media (max-width: 480px) {
|
|
.rotation-hint,
|
|
.scroll-hint {
|
|
@apply text-xs px-2 py-1;
|
|
top: 4px;
|
|
left: 4px;
|
|
}
|
|
}
|
|
</style>
|
|
|
|
|