Files
Mangarr/assets/vue/app/domain/reader/presentation/components/ReaderPage.vue

414 lines
14 KiB
Vue

<template>
<div class="page-container" :style="{ transform: `scale(${zoom})` }">
<div v-if="!pageData" class="error">Aucune donnée d'image disponible</div>
<div v-else-if="!pageData.base64Content" class="error">Contenu de l'image manquant</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)
}
});
const store = useReaderStore();
const imageRef = ref(null);
const scrollContainerRef = ref(null);
const naturalWidth = ref(0);
const naturalHeight = ref(0);
const windowWidth = ref(window.innerWidth);
const isMobile = computed(() => windowWidth.value < 768);
const imageLoaded = ref(false);
const imageSource = computed(() => {
if (!props.pageData?.base64Content || !props.pageData?.mimeType) {
return '';
}
return `data:${props.pageData.mimeType};base64,${props.pageData.base64Content}`;
});
// 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;
const isDouble = 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)
if (imageLoaded.value && naturalWidth.value && naturalHeight.value) {
const ratio = naturalWidth.value / naturalHeight.value;
const isDouble = ratio > threshold;
console.log(`Natural Dimensions - Page ${props.pageNumber}: ${naturalWidth.value}x${naturalHeight.value}, ratio: ${ratio.toFixed(2)}, isDouble: ${isDouble}`);
return isDouble;
}
return false;
});
const handleImageLoad = () => {
if (imageRef.value) {
naturalWidth.value = imageRef.value.naturalWidth;
naturalHeight.value = imageRef.value.naturalHeight;
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
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 = windowWidth.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(() => {
if (!maxWidth.value) return {};
return {
width: `${maxWidth.value}px`,
height: 'auto',
maxWidth: '100%'
};
});
// 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 = windowWidth.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
};
});
// Gestion du redimensionnement de la fenêtre
const handleResize = () => {
windowWidth.value = window.innerWidth;
};
onMounted(() => {
if (imageRef.value && imageRef.value.complete) {
handleImageLoad();
}
window.addEventListener('resize', handleResize);
});
onUnmounted(() => {
window.removeEventListener('resize', handleResize);
});
</script>
<style lang="postcss" scoped>
.page-container {
@apply flex-1 flex items-center justify-center overflow-hidden;
transform-origin: center;
/* Réduction des marges sur mobile */
@apply p-0 sm:p-2;
}
.page-image {
@apply object-contain;
/* La largeur est gérée par le JavaScript, on garde juste les contraintes max */
max-width: 100%;
max-height: 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>