En mode scroll infini, le zoom était appliqué via transform: scale() qui n'affecte pas le flux de mise en page. Les pages se chevauchaient visuellement quand le zoom était modifié. Passage à la propriété CSS zoom dans les deux modes pour un comportement de layout correct. Met aussi à jour le calcul de hauteur des placeholders pour inclure le facteur de zoom et éviter les sauts de layout lors du chargement paresseux.
432 lines
14 KiB
Vue
432 lines
14 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();
|
|
|
|
// zoom via la propriété CSS `zoom` dans les deux modes (affecte le layout → pas de chevauchement en mode scroll)
|
|
const containerStyle = computed(() => {
|
|
return { zoom: 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>
|
|
|
|
|