feat: ajout de la gestion des doubles pages pour le lecteur, incluant des paramètres de détection automatique, des modes d'affichage et des préférences sauvegardées. Amélioration de l'interface utilisateur pour intégrer ces nouvelles fonctionnalités.

This commit is contained in:
ext.jeremy.guillot@maxicoffee.domains
2025-07-06 15:55:55 +02:00
parent a6ca8a2c9a
commit 5a5569cf2c
9 changed files with 1150 additions and 63 deletions

View File

@@ -2,6 +2,47 @@
<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"
@@ -14,7 +55,8 @@
</template>
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import { useReaderStore } from '../../application/store/readerStore';
const props = defineProps({
pageData: {
@@ -28,13 +70,22 @@
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) {
@@ -43,26 +94,99 @@
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;
});
}
}
};
// Calculer la largeur maximale en fonction de la largeur disponible
// 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(() => {
if (!naturalWidth.value || !naturalHeight.value) return null;
// 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(naturalWidth.value, availableWidth * 0.95);
return Math.min(width, availableWidth * 0.95);
}
// Si la largeur disponible est >= 1200px : limiter à 1200px maximum
return Math.min(naturalWidth.value, 1200);
return Math.min(width, 1200);
});
const imageStyle = computed(() => {
@@ -75,6 +199,70 @@
};
});
// 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;
@@ -107,9 +295,119 @@
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>