Files
Mangarr/assets/vue/app/domain/reader/presentation/components/ReaderPage.vue
ext.jeremy.guillot@maxicoffee.domains 4da9742f7f perf(reader): virtual rendering avec IntersectionObserver en mode scroll
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
2026-03-15 18:44:51 +01:00

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>