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:
parent
a6ca8a2c9a
commit
5a5569cf2c
@@ -13,7 +13,14 @@ export const useReaderStore = defineStore('reader', {
|
||||
error: null,
|
||||
pages: [],
|
||||
totalPages: 0,
|
||||
loadedPages: new Set() // Garder une trace des pages déjà chargées
|
||||
loadedPages: new Set(), // Garder une trace des pages déjà chargées
|
||||
|
||||
// Paramètres pour les doubles pages
|
||||
doublePageSettings: {
|
||||
autoDetect: true,
|
||||
mobileMode: 'rotate', // 'rotate', 'scroll', 'normal'
|
||||
detectionThreshold: 1.2 // Ratio largeur/hauteur pour détecter une double page
|
||||
}
|
||||
}),
|
||||
|
||||
getters: {
|
||||
@@ -21,7 +28,24 @@ export const useReaderStore = defineStore('reader', {
|
||||
isLastPage: state => state.currentPage === state.totalPages - 1,
|
||||
currentPageData: state => state.pages[state.currentPage],
|
||||
hasPreviousChapter: state => Boolean(state.currentChapter?.navigation?.previousChapter),
|
||||
hasNextChapter: state => Boolean(state.currentChapter?.navigation?.nextChapter)
|
||||
hasNextChapter: state => Boolean(state.currentChapter?.navigation?.nextChapter),
|
||||
|
||||
// Getters pour les doubles pages
|
||||
effectiveDoublePageMode: (state) => {
|
||||
// Si la détection automatique est désactivée, retourner 'normal'
|
||||
if (!state.doublePageSettings.autoDetect) {
|
||||
return 'normal';
|
||||
}
|
||||
return state.doublePageSettings.mobileMode;
|
||||
},
|
||||
|
||||
// Préférences sauvegardées dans localStorage
|
||||
savedPreferences: (state) => ({
|
||||
readingMode: state.readingMode,
|
||||
readingDirection: state.readingDirection,
|
||||
zoom: state.zoom,
|
||||
doublePageSettings: state.doublePageSettings
|
||||
})
|
||||
},
|
||||
|
||||
actions: {
|
||||
@@ -145,6 +169,7 @@ export const useReaderStore = defineStore('reader', {
|
||||
if (mode === this.readingMode) return;
|
||||
|
||||
this.readingMode = mode;
|
||||
this.savePreferences();
|
||||
|
||||
// S'assurer que la page courante est chargée
|
||||
await this.loadPageData(this.currentPage);
|
||||
@@ -157,10 +182,44 @@ export const useReaderStore = defineStore('reader', {
|
||||
|
||||
setReadingDirection(direction) {
|
||||
this.readingDirection = direction;
|
||||
this.savePreferences();
|
||||
},
|
||||
|
||||
setZoom(level) {
|
||||
this.zoom = level;
|
||||
setZoom(zoom) {
|
||||
this.zoom = Math.max(0.5, Math.min(2, zoom));
|
||||
this.savePreferences();
|
||||
},
|
||||
|
||||
// Nouvelles actions pour les doubles pages
|
||||
setDoublePageMode(mode) {
|
||||
if (['rotate', 'scroll', 'normal'].includes(mode)) {
|
||||
this.doublePageSettings.mobileMode = mode;
|
||||
this.savePreferences();
|
||||
}
|
||||
},
|
||||
|
||||
setDoublePageAutoDetect(enabled) {
|
||||
this.doublePageSettings.autoDetect = enabled;
|
||||
this.savePreferences();
|
||||
},
|
||||
|
||||
setDoublePageDetectionThreshold(threshold) {
|
||||
this.doublePageSettings.detectionThreshold = Math.max(1.0, Math.min(3.0, threshold));
|
||||
this.savePreferences();
|
||||
},
|
||||
|
||||
updateDoublePageSettings(settings) {
|
||||
this.doublePageSettings = {
|
||||
...this.doublePageSettings,
|
||||
...settings
|
||||
};
|
||||
this.savePreferences();
|
||||
},
|
||||
|
||||
async goToNextChapter() {
|
||||
if (this.currentChapter?.navigation?.nextChapter) {
|
||||
await this.loadChapter(this.currentChapter.navigation.nextChapter);
|
||||
}
|
||||
},
|
||||
|
||||
async goToPreviousChapter() {
|
||||
@@ -175,10 +234,60 @@ export const useReaderStore = defineStore('reader', {
|
||||
}
|
||||
},
|
||||
|
||||
async goToNextChapter() {
|
||||
if (this.currentChapter?.navigation?.nextChapter) {
|
||||
await this.loadChapter(this.currentChapter.navigation.nextChapter);
|
||||
// Gestion de la persistance des préférences
|
||||
savePreferences() {
|
||||
try {
|
||||
const preferences = {
|
||||
readingMode: this.readingMode,
|
||||
readingDirection: this.readingDirection,
|
||||
zoom: this.zoom,
|
||||
doublePageSettings: this.doublePageSettings
|
||||
};
|
||||
localStorage.setItem('mangarr-reader-preferences', JSON.stringify(preferences));
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la sauvegarde des préférences:', error);
|
||||
}
|
||||
},
|
||||
|
||||
loadPreferences() {
|
||||
try {
|
||||
const stored = localStorage.getItem('mangarr-reader-preferences');
|
||||
if (stored) {
|
||||
const preferences = JSON.parse(stored);
|
||||
|
||||
// Appliquer les préférences sauvegardées
|
||||
if (preferences.readingMode) this.readingMode = preferences.readingMode;
|
||||
if (preferences.readingDirection) this.readingDirection = preferences.readingDirection;
|
||||
if (typeof preferences.zoom === 'number') this.zoom = preferences.zoom;
|
||||
|
||||
// Migration: si l'ancien doublePageMode existe, le migrer vers mobileMode
|
||||
if (preferences.doublePageMode && ['rotate', 'scroll', 'normal'].includes(preferences.doublePageMode)) {
|
||||
this.doublePageSettings.mobileMode = preferences.doublePageMode;
|
||||
}
|
||||
|
||||
if (preferences.doublePageSettings) {
|
||||
this.doublePageSettings = {
|
||||
...this.doublePageSettings,
|
||||
...preferences.doublePageSettings
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur lors du chargement des préférences:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// Réinitialiser les préférences
|
||||
resetPreferences() {
|
||||
this.readingMode = 'single';
|
||||
this.readingDirection = 'ltr';
|
||||
this.zoom = 1;
|
||||
this.doublePageSettings = {
|
||||
autoDetect: true,
|
||||
mobileMode: 'rotate',
|
||||
detectionThreshold: 1.2
|
||||
};
|
||||
this.savePreferences();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -16,34 +16,54 @@
|
||||
:is-first-page="store.isFirstPage"
|
||||
:is-last-page="store.isLastPage"
|
||||
:available-chapters="availableChapters"
|
||||
:settings-open="settingsOpen"
|
||||
@previous="store.previousPage"
|
||||
@next="store.nextPage"
|
||||
@chapter-selected="handleChapterSelected" />
|
||||
@chapter-selected="handleChapterSelected"
|
||||
@toggle-settings="toggleSettings" />
|
||||
|
||||
<template v-if="store.readingMode === 'single'">
|
||||
<SingleModeReader
|
||||
:page-data="store.currentPageData"
|
||||
:page-number="store.currentPage + 1"
|
||||
:zoom="store.zoom" />
|
||||
:zoom="store.zoom"
|
||||
:double-page-mode="store.effectiveDoublePageMode"
|
||||
@button-click="showButtonsWithTimer" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<InfiniteReader :pages="store.pages" :zoom="store.zoom" @page-visible="store.handlePageVisible" />
|
||||
<InfiniteReader
|
||||
:pages="store.pages"
|
||||
:zoom="store.zoom"
|
||||
:double-page-mode="store.effectiveDoublePageMode"
|
||||
@page-visible="store.handlePageVisible"
|
||||
@buttons-visibility-change="handleButtonsVisibilityChange"
|
||||
ref="infiniteReaderRef" />
|
||||
</template>
|
||||
|
||||
<ReaderSettings
|
||||
:reading-mode="store.readingMode"
|
||||
:reading-direction="store.readingDirection"
|
||||
:zoom="store.zoom"
|
||||
:double-page-mode="store.effectiveDoublePageMode"
|
||||
:double-page-settings="store.doublePageSettings"
|
||||
:visible="showFloatingButtons"
|
||||
:force-open="store.readingMode === 'single' ? settingsOpen : null"
|
||||
@toggle-reading-mode="toggleReadingMode"
|
||||
@toggle-reading-direction="toggleReadingDirection"
|
||||
@zoom-in="zoomIn"
|
||||
@zoom-out="zoomOut" />
|
||||
@zoom-out="zoomOut"
|
||||
@zoom-change="handleZoomChange"
|
||||
@double-page-mode-change="handleDoublePageModeChange"
|
||||
@double-page-auto-detect-change="handleDoublePageAutoDetectChange"
|
||||
@detection-threshold-change="handleDetectionThresholdChange"
|
||||
@reset-preferences="handleResetPreferences"
|
||||
@button-click="resetButtonsTimer" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, onUnmounted, watch } from 'vue';
|
||||
import { onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
import { useHeaderStore } from '../../../../shared/stores/headerStore';
|
||||
import { useReaderStore } from '../../application/store/readerStore';
|
||||
import InfiniteReader from './InfiniteReader.vue';
|
||||
@@ -65,34 +85,110 @@ import SingleModeReader from './SingleModeReader.vue';
|
||||
const store = useReaderStore();
|
||||
const headerStore = useHeaderStore();
|
||||
|
||||
// Référence vers InfiniteReader pour accéder à ses méthodes
|
||||
const infiniteReaderRef = ref(null);
|
||||
|
||||
// État pour la visibilité des boutons (géré par InfiniteReader en mode infini, localement en mode simple)
|
||||
const showFloatingButtons = ref(false);
|
||||
const settingsOpen = ref(false); // Nouvel état pour gérer l'ouverture des paramètres
|
||||
let localButtonsTimer = null;
|
||||
|
||||
// Actions de l'interface lecteur
|
||||
const toggleReadingMode = () => {
|
||||
const newMode = store.readingMode === 'single' ? 'infinite' : 'single';
|
||||
store.setReadingMode(newMode);
|
||||
|
||||
// Désactiver l'auto-hide si on passe en mode single
|
||||
// Gérer la visibilité selon le mode
|
||||
if (newMode === 'single') {
|
||||
headerStore.disableAutoHide();
|
||||
// En mode simple : toujours visible
|
||||
showFloatingButtons.value = true;
|
||||
clearTimeout(localButtonsTimer); // Annuler tout timer local
|
||||
} else {
|
||||
// En mode infini : utiliser la logique d'InfiniteReader
|
||||
showButtonsWithTimer();
|
||||
}
|
||||
};
|
||||
|
||||
const toggleReadingDirection = () => {
|
||||
store.setReadingDirection(store.readingDirection === 'ltr' ? 'rtl' : 'ltr');
|
||||
resetButtonsTimer();
|
||||
};
|
||||
|
||||
const zoomIn = () => {
|
||||
store.setZoom(Math.min(store.zoom + 0.1, 2));
|
||||
resetButtonsTimer();
|
||||
};
|
||||
|
||||
const zoomOut = () => {
|
||||
store.setZoom(Math.max(store.zoom - 0.1, 0.5));
|
||||
resetButtonsTimer();
|
||||
};
|
||||
|
||||
const handleZoomChange = (zoom) => {
|
||||
store.setZoom(zoom);
|
||||
resetButtonsTimer();
|
||||
};
|
||||
|
||||
// Fonctions pour les doubles pages
|
||||
const handleDoublePageModeChange = (mode) => {
|
||||
store.setDoublePageMode(mode);
|
||||
resetButtonsTimer();
|
||||
};
|
||||
|
||||
const handleDoublePageAutoDetectChange = (enabled) => {
|
||||
store.setDoublePageAutoDetect(enabled);
|
||||
resetButtonsTimer();
|
||||
};
|
||||
|
||||
const handleDetectionThresholdChange = (threshold) => {
|
||||
store.setDoublePageDetectionThreshold(threshold);
|
||||
resetButtonsTimer();
|
||||
};
|
||||
|
||||
const handleResetPreferences = () => {
|
||||
store.resetPreferences();
|
||||
resetButtonsTimer();
|
||||
};
|
||||
|
||||
// Fonction pour afficher les boutons avec timer (avec fallback pour mode simple)
|
||||
const showButtonsWithTimer = () => {
|
||||
if (store.readingMode === 'infinite' && infiniteReaderRef.value) {
|
||||
// Mode infini : utiliser la logique d'InfiniteReader
|
||||
infiniteReaderRef.value.showButtonsWithTimer();
|
||||
} else {
|
||||
// Mode simple : toujours visible, pas de timer
|
||||
showFloatingButtons.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
// Fonction centralisée pour réinitialiser le timer
|
||||
const resetButtonsTimer = () => {
|
||||
if (store.readingMode === 'infinite' && infiniteReaderRef.value) {
|
||||
// Mode infini : utiliser la logique d'InfiniteReader
|
||||
infiniteReaderRef.value.resetButtonsTimer();
|
||||
} else {
|
||||
// Mode simple : toujours visible, pas de timer
|
||||
showFloatingButtons.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
// Gestionnaire pour les changements de visibilité des boutons
|
||||
const handleButtonsVisibilityChange = (visible) => {
|
||||
if (store.readingMode === 'infinite') {
|
||||
showFloatingButtons.value = visible;
|
||||
}
|
||||
// En mode simple, on ignore les changements et on reste toujours visible
|
||||
};
|
||||
|
||||
const handleKeyPress = event => {
|
||||
if (store.readingMode === 'single') {
|
||||
if (event.key === 'ArrowRight') {
|
||||
store.nextPage();
|
||||
showButtonsWithTimer(); // Afficher les boutons lors de la navigation clavier
|
||||
} else if (event.key === 'ArrowLeft') {
|
||||
store.previousPage();
|
||||
showButtonsWithTimer(); // Afficher les boutons lors de la navigation clavier
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -101,6 +197,13 @@ import SingleModeReader from './SingleModeReader.vue';
|
||||
// La navigation est déjà gérée par le ChapterSelector via le store
|
||||
// Cette fonction est là pour d'éventuelles actions supplémentaires
|
||||
console.log('Chapitre sélectionné:', chapterId);
|
||||
resetButtonsTimer();
|
||||
};
|
||||
|
||||
// Gestion des paramètres via le bouton intégré
|
||||
const toggleSettings = () => {
|
||||
settingsOpen.value = !settingsOpen.value;
|
||||
resetButtonsTimer(); // Réinitialiser le timer lors de l'interaction
|
||||
};
|
||||
|
||||
watch(
|
||||
@@ -114,13 +217,21 @@ import SingleModeReader from './SingleModeReader.vue';
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
// Charger les préférences sauvegardées
|
||||
store.loadPreferences();
|
||||
|
||||
window.addEventListener('keydown', handleKeyPress);
|
||||
|
||||
// Afficher les boutons au démarrage
|
||||
showButtonsWithTimer();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', handleKeyPress);
|
||||
// S'assurer que l'auto-hide est désactivé en quittant le lecteur
|
||||
headerStore.disableAutoHide();
|
||||
// Nettoyer le timer local
|
||||
clearTimeout(localButtonsTimer);
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<div v-else-if="page?.error" class="error">
|
||||
{{ page.error }}
|
||||
</div>
|
||||
<ReaderPage v-else-if="page?.base64Content" :page-data="page" :page-number="index + 1" :zoom="zoom" />
|
||||
<ReaderPage v-else-if="page?.base64Content" :page-data="page" :page-number="index + 1" :zoom="zoom" :double-page-mode="doublePageMode" />
|
||||
</div>
|
||||
|
||||
<!-- Navigation en bas -->
|
||||
@@ -30,7 +30,7 @@
|
||||
leave-to-class="opacity-0 translate-y-5 scale-75"
|
||||
>
|
||||
<button
|
||||
v-show="showScrollToTop"
|
||||
v-show="showFloatingButtons"
|
||||
@click="scrollToTop"
|
||||
class="fixed bottom-6 right-6 z-[9999] bg-blue-600 hover:bg-blue-700 text-white w-12 h-12 rounded-full shadow-lg hover:shadow-xl flex items-center justify-center transition-all duration-200 hover:scale-110 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||
title="Revenir en haut"
|
||||
@@ -58,18 +58,23 @@ import ReaderPage from './ReaderPage.vue';
|
||||
zoom: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
doublePageMode: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['pageVisible']);
|
||||
const emit = defineEmits(['pageVisible', 'buttonsVisibilityChange']);
|
||||
|
||||
const headerStore = useHeaderStore();
|
||||
const containerRef = ref(null);
|
||||
const observer = ref(null);
|
||||
const windowWidth = ref(window.innerWidth);
|
||||
|
||||
// État pour le bouton scroll to top
|
||||
const showScrollToTop = ref(false);
|
||||
// État unique pour tous les boutons flottants avec timer de 3 secondes
|
||||
const showFloatingButtons = ref(false);
|
||||
let buttonsTimer = null;
|
||||
|
||||
// Variables pour détecter la direction du scroll
|
||||
let lastScrollTop = 0;
|
||||
@@ -105,7 +110,38 @@ import ReaderPage from './ReaderPage.vue';
|
||||
});
|
||||
};
|
||||
|
||||
// Gestion du scroll pour le bouton "revenir en haut" et le header
|
||||
// Fonction unique pour gérer la visibilité de tous les boutons flottants
|
||||
const showButtonsWithTimer = () => {
|
||||
showFloatingButtons.value = true;
|
||||
emit('buttonsVisibilityChange', true);
|
||||
// Réinitialiser le timer à chaque fois
|
||||
clearTimeout(buttonsTimer);
|
||||
buttonsTimer = setTimeout(() => {
|
||||
showFloatingButtons.value = false;
|
||||
emit('buttonsVisibilityChange', false);
|
||||
}, 3000); // 3 secondes
|
||||
};
|
||||
|
||||
const hideButtonsImmediately = () => {
|
||||
showFloatingButtons.value = false;
|
||||
emit('buttonsVisibilityChange', false);
|
||||
clearTimeout(buttonsTimer);
|
||||
};
|
||||
|
||||
// Fonction exposée pour réinitialiser le timer depuis l'extérieur
|
||||
const resetButtonsTimer = () => {
|
||||
if (showFloatingButtons.value) {
|
||||
clearTimeout(buttonsTimer);
|
||||
buttonsTimer = setTimeout(() => {
|
||||
showFloatingButtons.value = false;
|
||||
emit('buttonsVisibilityChange', false);
|
||||
}, 3000);
|
||||
} else {
|
||||
showButtonsWithTimer();
|
||||
}
|
||||
};
|
||||
|
||||
// Gestion du scroll pour tous les boutons flottants et le header
|
||||
const handleScroll = () => {
|
||||
let scrollTop = 0;
|
||||
|
||||
@@ -141,23 +177,26 @@ import ReaderPage from './ReaderPage.vue';
|
||||
headerStore.updateScrollDirection(scrollTop);
|
||||
}
|
||||
|
||||
// Mise à jour de la visibilité du bouton
|
||||
// Afficher si on scroll vers le bas et qu'on est à plus de 300px
|
||||
// Masquer si on scroll vers le haut ou qu'on est en haut de page
|
||||
if (scrollDirection === 'down' && scrollTop > 300) {
|
||||
showScrollToTop.value = true;
|
||||
} else if (scrollDirection === 'up' || scrollTop <= 100) {
|
||||
showScrollToTop.value = false;
|
||||
// Gestion de la visibilité des boutons flottants (même condition pour tous)
|
||||
// Afficher si on scroll et qu'on est à plus de 300px
|
||||
if (scrollTop > 300) {
|
||||
showButtonsWithTimer();
|
||||
} else if (scrollTop <= 100) {
|
||||
// Masquer immédiatement si on est en haut de page
|
||||
hideButtonsImmediately();
|
||||
}
|
||||
|
||||
// Sauvegarder la position actuelle pour la prochaine comparaison
|
||||
lastScrollTop = scrollTop;
|
||||
};
|
||||
|
||||
// Fonction pour revenir en haut de la page
|
||||
// Fonction pour revenir en haut de la page
|
||||
const scrollToTop = () => {
|
||||
console.log('scrollToTop appelée'); // Debug
|
||||
|
||||
// Réinitialiser le timer lors du clic
|
||||
resetButtonsTimer();
|
||||
|
||||
// Stratégie 1: Scroll sur le conteneur direct
|
||||
if (containerRef.value) {
|
||||
console.log('containerRef trouvé, scrollTop actuel:', containerRef.value.scrollTop); // Debug
|
||||
@@ -195,6 +234,12 @@ import ReaderPage from './ReaderPage.vue';
|
||||
});
|
||||
};
|
||||
|
||||
// Exposer la fonction pour le parent
|
||||
defineExpose({
|
||||
resetButtonsTimer,
|
||||
showButtonsWithTimer
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.pages,
|
||||
() => {
|
||||
@@ -244,6 +289,9 @@ import ReaderPage from './ReaderPage.vue';
|
||||
// Désactiver l'auto-hide du header en quittant
|
||||
headerStore.disableAutoHide();
|
||||
|
||||
// Nettoyer les timers
|
||||
clearTimeout(buttonsTimer);
|
||||
|
||||
// Nettoyer l'écouteur de scroll du conteneur
|
||||
if (containerRef.value) {
|
||||
containerRef.value.removeEventListener('scroll', handleScroll);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="reader-controls">
|
||||
<button @click="onPrevious" :disabled="isFirstPage">
|
||||
<button @click="onPrevious" :disabled="isFirstPage" class="nav-button">
|
||||
<ChevronLeftIcon class="h-6 w-6" />
|
||||
</button>
|
||||
|
||||
@@ -14,9 +14,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button @click="onNext" :disabled="isLastPage">
|
||||
<ChevronRightIcon class="h-6 w-6" />
|
||||
</button>
|
||||
<div class="controls-right">
|
||||
<!-- Bouton paramètres intégré -->
|
||||
<button @click="onToggleSettings" class="settings-button" :class="{ 'active': settingsOpen }" title="Paramètres">
|
||||
<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="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 100 4m0-4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 100 4m0-4v2m0-6V4" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button @click="onNext" :disabled="isLastPage" class="nav-button">
|
||||
<ChevronRightIcon class="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -44,14 +53,19 @@ import ChapterSelector from './ChapterSelector.vue';
|
||||
availableChapters: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
settingsOpen: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['previous', 'next', 'chapter-selected']);
|
||||
const emit = defineEmits(['previous', 'next', 'chapter-selected', 'toggle-settings']);
|
||||
|
||||
const onPrevious = () => emit('previous');
|
||||
const onNext = () => emit('next');
|
||||
const onChapterSelected = (chapterId) => emit('chapter-selected', chapterId);
|
||||
const onToggleSettings = () => emit('toggle-settings');
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
@@ -59,23 +73,79 @@ import ChapterSelector from './ChapterSelector.vue';
|
||||
@apply flex items-center justify-between p-4 bg-gray-800;
|
||||
}
|
||||
|
||||
/* Responsive pour reader-controls */
|
||||
@media (max-width: 480px) {
|
||||
.reader-controls {
|
||||
padding: 0.5rem;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.controls-center {
|
||||
@apply flex flex-col items-center space-y-2;
|
||||
}
|
||||
|
||||
/* Responsive pour controls-center */
|
||||
@media (max-width: 480px) {
|
||||
.controls-center {
|
||||
gap: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.controls-right {
|
||||
@apply flex items-center gap-2;
|
||||
}
|
||||
|
||||
/* Responsive pour controls-right */
|
||||
@media (max-width: 480px) {
|
||||
.controls-right {
|
||||
gap: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.page-info {
|
||||
@apply text-lg font-medium;
|
||||
}
|
||||
|
||||
.chapter-selector-wrapper {
|
||||
@apply min-w-[200px];
|
||||
@apply min-w-[120px] max-w-[200px];
|
||||
}
|
||||
|
||||
button {
|
||||
/* Responsive pour chapter-selector-wrapper */
|
||||
@media (max-width: 480px) {
|
||||
.chapter-selector-wrapper {
|
||||
min-width: 100px;
|
||||
max-width: 60vw;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-button {
|
||||
@apply px-4 py-2 bg-gray-700 rounded hover:bg-gray-600 transition-colors;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
/* Responsive pour nav-button */
|
||||
@media (max-width: 480px) {
|
||||
.nav-button {
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-button:disabled {
|
||||
@apply opacity-50 cursor-not-allowed;
|
||||
}
|
||||
|
||||
.settings-button {
|
||||
@apply px-3 py-2 bg-gray-700 hover:bg-gray-600 rounded transition-colors duration-200 flex items-center justify-center;
|
||||
}
|
||||
|
||||
/* Responsive pour settings-button */
|
||||
@media (max-width: 480px) {
|
||||
.settings-button {
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-button.active {
|
||||
@apply bg-blue-600 hover:bg-blue-700;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
|
||||
@@ -1,21 +1,185 @@
|
||||
<template>
|
||||
<div class="reader-settings">
|
||||
<button @click="onToggleReadingMode">
|
||||
{{ readingMode === 'single' ? 'Mode Infini' : 'Mode Simple' }}
|
||||
</button>
|
||||
<button @click="onToggleReadingDirection">
|
||||
{{ readingDirection === 'ltr' ? 'RTL' : 'LTR' }}
|
||||
</button>
|
||||
<div class="zoom-controls">
|
||||
<button @click="onZoomOut">-</button>
|
||||
<span>{{ Math.round(zoom * 100) }}%</span>
|
||||
<button @click="onZoomIn">+</button>
|
||||
</div>
|
||||
<!-- Bouton pour ouvrir/fermer les paramètres -->
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-300 ease-out"
|
||||
leave-active-class="transition-all duration-300 ease-in"
|
||||
enter-from-class="opacity-0 translate-y-5 scale-75"
|
||||
enter-to-class="opacity-100 translate-y-0 scale-100"
|
||||
leave-from-class="opacity-100 translate-y-0 scale-100"
|
||||
leave-to-class="opacity-0 translate-y-5 scale-75"
|
||||
>
|
||||
<button
|
||||
v-show="visible"
|
||||
@click="toggleSettings"
|
||||
class="settings-toggle"
|
||||
:class="{ 'active': effectiveIsOpen }"
|
||||
:data-external-control="forceOpen !== null"
|
||||
title="Paramètres du lecteur"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 100 4m0-4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 100 4m0-4v2m0-6V4" />
|
||||
</svg>
|
||||
</button>
|
||||
</Transition>
|
||||
|
||||
<!-- Panel des paramètres -->
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-300 ease-out"
|
||||
leave-active-class="transition-all duration-300 ease-in"
|
||||
enter-from-class="opacity-0 translate-y-4 scale-95"
|
||||
enter-to-class="opacity-100 translate-y-0 scale-100"
|
||||
leave-from-class="opacity-100 translate-y-0 scale-100"
|
||||
leave-to-class="opacity-0 translate-y-4 scale-95"
|
||||
>
|
||||
<div v-show="effectiveIsOpen" class="settings-panel" :data-external-control="forceOpen !== null" ref="panelRef">
|
||||
<!-- Paramètres de base -->
|
||||
<div class="settings-section">
|
||||
<h3 class="section-title">Mode de lecture</h3>
|
||||
<div class="setting-group">
|
||||
<button
|
||||
@click="onToggleReadingMode"
|
||||
class="setting-button"
|
||||
:class="{ 'active': readingMode === 'infinite' }"
|
||||
>
|
||||
<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 6h16M4 12h16m-7 6h7" />
|
||||
</svg>
|
||||
{{ readingMode === 'single' ? 'Mode Infini' : 'Mode Simple' }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="onToggleReadingDirection"
|
||||
class="setting-button"
|
||||
:class="{ 'active': readingDirection === 'rtl' }"
|
||||
>
|
||||
<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>
|
||||
{{ readingDirection === 'ltr' ? 'RTL' : 'LTR' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contrôles du zoom -->
|
||||
<div class="settings-section">
|
||||
<h3 class="section-title">Zoom</h3>
|
||||
<div class="zoom-controls">
|
||||
<button @click="onZoomOut" class="zoom-button" :disabled="zoom <= 0.5">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 12H4" />
|
||||
</svg>
|
||||
</button>
|
||||
<span class="zoom-display">{{ Math.round(zoom * 100) }}%</span>
|
||||
<button @click="onZoomIn" class="zoom-button" :disabled="zoom >= 2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
:value="zoom"
|
||||
@input="onZoomChange($event.target.value)"
|
||||
min="0.5"
|
||||
max="2"
|
||||
step="0.1"
|
||||
class="zoom-slider"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Paramètres des doubles pages -->
|
||||
<div class="settings-section" v-if="isMobile">
|
||||
<h3 class="section-title">
|
||||
<svg class="w-5 h-5 inline mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
Doubles pages (Mobile)
|
||||
</h3>
|
||||
|
||||
<!-- Activation/désactivation -->
|
||||
<div class="setting-item">
|
||||
<label class="setting-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="doublePageSettings.autoDetect"
|
||||
@change="onDoublePageAutoDetectChange($event.target.checked)"
|
||||
class="setting-checkbox"
|
||||
/>
|
||||
<span>Détection automatique</span>
|
||||
</label>
|
||||
<p class="setting-description">
|
||||
Détecter et optimiser automatiquement l'affichage des doubles pages sur mobile
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Mode d'affichage (si la détection automatique est activée) -->
|
||||
<div v-if="doublePageSettings.autoDetect" class="setting-item">
|
||||
<label class="setting-label">Mode d'affichage</label>
|
||||
<select
|
||||
:value="doublePageMode"
|
||||
@change="onDoublePageModeChange($event.target.value)"
|
||||
class="setting-select"
|
||||
>
|
||||
<option value="rotate">Rotation suggérée</option>
|
||||
<option value="scroll">Défilement horizontal</option>
|
||||
<option value="normal">Affichage normal</option>
|
||||
</select>
|
||||
|
||||
<!-- Descriptions des modes -->
|
||||
<p class="setting-description">
|
||||
<span v-if="doublePageMode === 'rotate'">
|
||||
Suggère de tourner l'appareil pour une meilleure lecture
|
||||
</span>
|
||||
<span v-else-if="doublePageMode === 'scroll'">
|
||||
Permet le défilement horizontal pour naviguer dans la page (commence à droite)
|
||||
</span>
|
||||
<span v-else>
|
||||
Affichage standard sans optimisation spéciale
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Seuil de détection -->
|
||||
<div v-if="doublePageSettings.autoDetect" class="setting-item">
|
||||
<label class="setting-label">
|
||||
Sensibilité de détection: {{ doublePageSettings.detectionThreshold.toFixed(1) }}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
:value="doublePageSettings.detectionThreshold"
|
||||
@input="onDetectionThresholdChange($event.target.value)"
|
||||
min="1.0"
|
||||
max="2.5"
|
||||
step="0.1"
|
||||
class="setting-slider"
|
||||
/>
|
||||
<p class="setting-description">
|
||||
Plus la valeur est faible, plus la détection est sensible (1.4 recommandé)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="settings-section">
|
||||
<div class="setting-actions">
|
||||
<button @click="onResetPreferences" class="action-button reset">
|
||||
<svg class="w-4 h-4" 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>
|
||||
Réinitialiser
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
readingMode: {
|
||||
type: String,
|
||||
required: true
|
||||
@@ -27,27 +191,302 @@
|
||||
zoom: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
doublePageMode: {
|
||||
type: String,
|
||||
default: 'rotate'
|
||||
},
|
||||
doublePageSettings: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
autoDetect: true,
|
||||
mobileOnly: true,
|
||||
detectionThreshold: 1.4
|
||||
})
|
||||
},
|
||||
// Visibilité contrôlée par le parent
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
// Contrôle externe de l'ouverture (pour le bouton intégré)
|
||||
forceOpen: {
|
||||
type: Boolean,
|
||||
default: null // null = pas de contrôle externe, true/false = contrôle externe
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['toggleReadingMode', 'toggleReadingDirection', 'zoomIn', 'zoomOut']);
|
||||
const emit = defineEmits([
|
||||
'toggleReadingMode',
|
||||
'toggleReadingDirection',
|
||||
'zoomIn',
|
||||
'zoomOut',
|
||||
'zoomChange',
|
||||
'doublePageModeChange',
|
||||
'doublePageAutoDetectChange',
|
||||
'detectionThresholdChange',
|
||||
'resetPreferences',
|
||||
'buttonClick' // Signaler l'interaction au parent
|
||||
]);
|
||||
|
||||
const onToggleReadingMode = () => emit('toggleReadingMode');
|
||||
const onToggleReadingDirection = () => emit('toggleReadingDirection');
|
||||
const onZoomIn = () => emit('zoomIn');
|
||||
const onZoomOut = () => emit('zoomOut');
|
||||
const isOpen = ref(false);
|
||||
const isMobile = computed(() => window.innerWidth < 768);
|
||||
const panelRef = ref(null);
|
||||
|
||||
// Computed pour gérer l'état d'ouverture (interne ou externe)
|
||||
const effectiveIsOpen = computed(() => {
|
||||
// Si forceOpen est défini (true/false), on l'utilise
|
||||
if (props.forceOpen !== null) {
|
||||
return props.forceOpen;
|
||||
}
|
||||
// Sinon, on utilise l'état interne
|
||||
return isOpen.value;
|
||||
});
|
||||
|
||||
const toggleSettings = () => {
|
||||
// Si on est en contrôle externe, ne pas permettre le toggle via le bouton flottant
|
||||
if (props.forceOpen !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
isOpen.value = !isOpen.value;
|
||||
// Signaler l'interaction au parent
|
||||
emit('buttonClick');
|
||||
};
|
||||
|
||||
// Fonction pour fermer le panel (utilisée par les clics externes et internes)
|
||||
const closePanel = () => {
|
||||
if (props.forceOpen !== null) {
|
||||
// Mode externe : émettre l'événement pour que le parent gère la fermeture
|
||||
emit('buttonClick');
|
||||
} else {
|
||||
// Mode interne : fermer directement
|
||||
isOpen.value = false;
|
||||
emit('buttonClick');
|
||||
}
|
||||
};
|
||||
|
||||
// Gestion des clics en dehors du panel
|
||||
const handleClickOutside = (event) => {
|
||||
if (effectiveIsOpen.value && panelRef.value && !panelRef.value.contains(event.target)) {
|
||||
// Vérifier que le clic n'est pas sur le bouton de toggle
|
||||
const settingsButton = document.querySelector('.settings-toggle, .settings-button');
|
||||
if (settingsButton && settingsButton.contains(event.target)) {
|
||||
return; // Laisser le bouton gérer le toggle
|
||||
}
|
||||
|
||||
closePanel();
|
||||
}
|
||||
};
|
||||
|
||||
// Watcher pour empêcher la fermeture du bouton quand le panel est ouvert
|
||||
watch(
|
||||
() => effectiveIsOpen.value,
|
||||
(newIsOpen) => {
|
||||
if (newIsOpen || !newIsOpen) {
|
||||
// Signaler l'interaction à chaque changement
|
||||
emit('buttonClick');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Cycle de vie des event listeners
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleClickOutside, true);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleClickOutside, true);
|
||||
});
|
||||
|
||||
// Méthodes des événements (toutes signalent l'interaction)
|
||||
const onToggleReadingMode = () => {
|
||||
emit('toggleReadingMode');
|
||||
emit('buttonClick');
|
||||
};
|
||||
const onToggleReadingDirection = () => {
|
||||
emit('toggleReadingDirection');
|
||||
emit('buttonClick');
|
||||
};
|
||||
const onZoomIn = () => {
|
||||
emit('zoomIn');
|
||||
emit('buttonClick');
|
||||
};
|
||||
const onZoomOut = () => {
|
||||
emit('zoomOut');
|
||||
emit('buttonClick');
|
||||
};
|
||||
const onZoomChange = (value) => {
|
||||
emit('zoomChange', parseFloat(value));
|
||||
emit('buttonClick');
|
||||
};
|
||||
const onDoublePageModeChange = (mode) => {
|
||||
emit('doublePageModeChange', mode);
|
||||
emit('buttonClick');
|
||||
};
|
||||
const onDoublePageAutoDetectChange = (enabled) => {
|
||||
emit('doublePageAutoDetectChange', enabled);
|
||||
emit('buttonClick');
|
||||
};
|
||||
const onDetectionThresholdChange = (threshold) => {
|
||||
emit('detectionThresholdChange', parseFloat(threshold));
|
||||
emit('buttonClick');
|
||||
};
|
||||
const onResetPreferences = () => {
|
||||
emit('resetPreferences');
|
||||
emit('buttonClick');
|
||||
isOpen.value = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.reader-settings {
|
||||
@apply flex items-center justify-center gap-4 p-4 bg-gray-800;
|
||||
@apply relative;
|
||||
}
|
||||
|
||||
.settings-toggle {
|
||||
@apply fixed top-20 right-4 z-50 w-12 h-12 bg-gray-800 hover:bg-gray-700 text-white rounded-full shadow-lg flex items-center justify-center transition-all duration-200;
|
||||
@apply focus:outline-none focus:ring-2 focus:ring-blue-500;
|
||||
}
|
||||
|
||||
/* Masquer le bouton flottant si on est en contrôle externe */
|
||||
.settings-toggle[data-external-control="true"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.settings-toggle.active {
|
||||
@apply bg-blue-600 hover:bg-blue-700;
|
||||
}
|
||||
|
||||
.settings-panel {
|
||||
@apply fixed top-36 right-4 z-40 w-80 max-w-[calc(100vw-2rem)] bg-gray-800 rounded-lg shadow-xl border border-gray-700 max-h-[80vh] overflow-y-auto;
|
||||
}
|
||||
|
||||
/* Responsive pour settings-panel */
|
||||
@media (max-width: 480px) {
|
||||
.settings-panel {
|
||||
width: 90vw;
|
||||
max-width: calc(100vw - 1rem);
|
||||
right: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Position adaptative pour le contrôle externe (bouton intégré) */
|
||||
.settings-panel[data-external-control="true"] {
|
||||
@apply top-32 left-1/2 right-auto;
|
||||
transform: translateX(-50%);
|
||||
/* S'assurer qu'il ne couvre pas les contrôles */
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.settings-section {
|
||||
@apply p-4 border-b border-gray-700 last:border-b-0;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
@apply text-white font-semibold text-lg mb-3 flex items-center;
|
||||
}
|
||||
|
||||
.setting-group {
|
||||
@apply flex flex-col gap-2;
|
||||
}
|
||||
|
||||
.setting-button {
|
||||
@apply flex items-center gap-2 px-3 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg transition-colors duration-200 text-sm;
|
||||
}
|
||||
|
||||
.setting-button.active {
|
||||
@apply bg-blue-600 hover:bg-blue-700;
|
||||
}
|
||||
|
||||
/* Contrôles du zoom */
|
||||
.zoom-controls {
|
||||
@apply flex items-center gap-2;
|
||||
@apply flex items-center gap-3 mb-2;
|
||||
}
|
||||
|
||||
button {
|
||||
@apply px-4 py-2 bg-gray-700 rounded hover:bg-gray-600 transition-colors;
|
||||
.zoom-button {
|
||||
@apply w-8 h-8 bg-gray-700 hover:bg-gray-600 disabled:bg-gray-800 disabled:cursor-not-allowed text-white rounded flex items-center justify-center transition-colors;
|
||||
}
|
||||
|
||||
.zoom-display {
|
||||
@apply text-white font-mono text-sm min-w-[3rem] text-center;
|
||||
}
|
||||
|
||||
.zoom-slider {
|
||||
@apply w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer;
|
||||
}
|
||||
|
||||
.zoom-slider::-webkit-slider-thumb {
|
||||
@apply appearance-none w-4 h-4 bg-blue-600 rounded-full cursor-pointer;
|
||||
}
|
||||
|
||||
.zoom-slider::-moz-range-thumb {
|
||||
@apply w-4 h-4 bg-blue-600 rounded-full cursor-pointer border-none;
|
||||
}
|
||||
|
||||
/* Paramètres des doubles pages */
|
||||
.setting-item {
|
||||
@apply mb-4 last:mb-0;
|
||||
}
|
||||
|
||||
.setting-label {
|
||||
@apply flex items-center gap-2 text-white font-medium text-sm mb-2 cursor-pointer;
|
||||
}
|
||||
|
||||
.setting-checkbox {
|
||||
@apply w-4 h-4 text-blue-600 bg-gray-700 border-gray-600 rounded focus:ring-blue-500 focus:ring-2;
|
||||
}
|
||||
|
||||
.setting-select {
|
||||
@apply w-full bg-gray-700 border border-gray-600 text-white text-sm rounded-lg px-3 py-2 focus:ring-blue-500 focus:border-blue-500;
|
||||
}
|
||||
|
||||
.setting-slider {
|
||||
@apply w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer mb-2;
|
||||
}
|
||||
|
||||
.setting-slider::-webkit-slider-thumb {
|
||||
@apply appearance-none w-4 h-4 bg-blue-600 rounded-full cursor-pointer;
|
||||
}
|
||||
|
||||
.setting-slider::-moz-range-thumb {
|
||||
@apply w-4 h-4 bg-blue-600 rounded-full cursor-pointer border-none;
|
||||
}
|
||||
|
||||
.setting-description {
|
||||
@apply text-gray-400 text-xs leading-relaxed;
|
||||
}
|
||||
|
||||
/* Actions */
|
||||
.setting-actions {
|
||||
@apply flex gap-2;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
@apply flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-colors;
|
||||
}
|
||||
|
||||
.action-button.reset {
|
||||
@apply bg-red-600 hover:bg-red-700 text-white;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.settings-panel {
|
||||
@apply right-2 w-72;
|
||||
}
|
||||
|
||||
.settings-toggle {
|
||||
@apply right-2;
|
||||
}
|
||||
}
|
||||
|
||||
/* Pour les très petits écrans */
|
||||
@media (max-width: 480px) {
|
||||
.settings-toggle {
|
||||
right: 0.25rem;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
:page-data="pageData"
|
||||
:page-number="pageNumber"
|
||||
:zoom="zoom"
|
||||
:double-page-mode="doublePageMode"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -63,9 +64,15 @@ const props = defineProps({
|
||||
zoom: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
doublePageMode: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['buttonClick']);
|
||||
|
||||
const store = useReaderStore();
|
||||
|
||||
// État pour afficher les indicateurs de navigation
|
||||
@@ -93,6 +100,7 @@ const goToPrevious = async () => {
|
||||
await store.goToPreviousChapter();
|
||||
}
|
||||
showNavigationHints.value = true;
|
||||
emit('buttonClick'); // Signaler l'interaction pour afficher les boutons
|
||||
clearTimeout(hintTimeout);
|
||||
hintTimeout = setTimeout(() => {
|
||||
showNavigationHints.value = false;
|
||||
@@ -110,6 +118,7 @@ const goToNext = async () => {
|
||||
// Le store va charger le chapitre suivant et se positionner automatiquement à la première page
|
||||
}
|
||||
showNavigationHints.value = true;
|
||||
emit('buttonClick'); // Signaler l'interaction pour afficher les boutons
|
||||
clearTimeout(hintTimeout);
|
||||
hintTimeout = setTimeout(() => {
|
||||
showNavigationHints.value = false;
|
||||
@@ -145,6 +154,8 @@ const hideRightHint = () => {
|
||||
@apply relative w-full h-full flex items-center justify-center;
|
||||
/* Suppression des marges sur mobile */
|
||||
@apply p-0 sm:p-2;
|
||||
/* Ajouter des marges en haut et en bas pour l'espace des contrôles et paramètres */
|
||||
@apply py-8 sm:py-12;
|
||||
}
|
||||
|
||||
.page-navigation-wrapper {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<html lang="" class="h-full">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
||||
<title>{% block title %}Mangarr{% endblock %}</title>
|
||||
<link rel="icon"
|
||||
href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 128 128%22><text y=%221.2em%22 font-size=%2296%22>⚫️</text></svg>">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
||||
<title>Vue App</title>
|
||||
{{ encore_entry_link_tags('vue-app') }}
|
||||
</head>
|
||||
|
||||
Reference in New Issue
Block a user