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

@@ -13,7 +13,14 @@ export const useReaderStore = defineStore('reader', {
error: null, error: null,
pages: [], pages: [],
totalPages: 0, 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: { getters: {
@@ -21,7 +28,24 @@ export const useReaderStore = defineStore('reader', {
isLastPage: state => state.currentPage === state.totalPages - 1, isLastPage: state => state.currentPage === state.totalPages - 1,
currentPageData: state => state.pages[state.currentPage], currentPageData: state => state.pages[state.currentPage],
hasPreviousChapter: state => Boolean(state.currentChapter?.navigation?.previousChapter), 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: { actions: {
@@ -145,6 +169,7 @@ export const useReaderStore = defineStore('reader', {
if (mode === this.readingMode) return; if (mode === this.readingMode) return;
this.readingMode = mode; this.readingMode = mode;
this.savePreferences();
// S'assurer que la page courante est chargée // S'assurer que la page courante est chargée
await this.loadPageData(this.currentPage); await this.loadPageData(this.currentPage);
@@ -157,10 +182,44 @@ export const useReaderStore = defineStore('reader', {
setReadingDirection(direction) { setReadingDirection(direction) {
this.readingDirection = direction; this.readingDirection = direction;
this.savePreferences();
}, },
setZoom(level) { setZoom(zoom) {
this.zoom = level; 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() { async goToPreviousChapter() {
@@ -175,10 +234,60 @@ export const useReaderStore = defineStore('reader', {
} }
}, },
async goToNextChapter() { // Gestion de la persistance des préférences
if (this.currentChapter?.navigation?.nextChapter) { savePreferences() {
await this.loadChapter(this.currentChapter.navigation.nextChapter); 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();
} }
} }
}); });

View File

@@ -16,34 +16,54 @@
:is-first-page="store.isFirstPage" :is-first-page="store.isFirstPage"
:is-last-page="store.isLastPage" :is-last-page="store.isLastPage"
:available-chapters="availableChapters" :available-chapters="availableChapters"
:settings-open="settingsOpen"
@previous="store.previousPage" @previous="store.previousPage"
@next="store.nextPage" @next="store.nextPage"
@chapter-selected="handleChapterSelected" /> @chapter-selected="handleChapterSelected"
@toggle-settings="toggleSettings" />
<template v-if="store.readingMode === 'single'"> <template v-if="store.readingMode === 'single'">
<SingleModeReader <SingleModeReader
:page-data="store.currentPageData" :page-data="store.currentPageData"
:page-number="store.currentPage + 1" :page-number="store.currentPage + 1"
:zoom="store.zoom" /> :zoom="store.zoom"
:double-page-mode="store.effectiveDoublePageMode"
@button-click="showButtonsWithTimer" />
</template> </template>
<template v-else> <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> </template>
<ReaderSettings <ReaderSettings
:reading-mode="store.readingMode" :reading-mode="store.readingMode"
:reading-direction="store.readingDirection" :reading-direction="store.readingDirection"
:zoom="store.zoom" :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-mode="toggleReadingMode"
@toggle-reading-direction="toggleReadingDirection" @toggle-reading-direction="toggleReadingDirection"
@zoom-in="zoomIn" @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>
</div> </div>
</template> </template>
<script setup> <script setup>
import { onMounted, onUnmounted, watch } from 'vue'; import { onMounted, onUnmounted, ref, watch } from 'vue';
import { useHeaderStore } from '../../../../shared/stores/headerStore'; import { useHeaderStore } from '../../../../shared/stores/headerStore';
import { useReaderStore } from '../../application/store/readerStore'; import { useReaderStore } from '../../application/store/readerStore';
import InfiniteReader from './InfiniteReader.vue'; import InfiniteReader from './InfiniteReader.vue';
@@ -65,34 +85,110 @@ import SingleModeReader from './SingleModeReader.vue';
const store = useReaderStore(); const store = useReaderStore();
const headerStore = useHeaderStore(); 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 toggleReadingMode = () => {
const newMode = store.readingMode === 'single' ? 'infinite' : 'single'; const newMode = store.readingMode === 'single' ? 'infinite' : 'single';
store.setReadingMode(newMode); store.setReadingMode(newMode);
// Désactiver l'auto-hide si on passe en mode single // Gérer la visibilité selon le mode
if (newMode === 'single') { if (newMode === 'single') {
headerStore.disableAutoHide(); 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 = () => { const toggleReadingDirection = () => {
store.setReadingDirection(store.readingDirection === 'ltr' ? 'rtl' : 'ltr'); store.setReadingDirection(store.readingDirection === 'ltr' ? 'rtl' : 'ltr');
resetButtonsTimer();
}; };
const zoomIn = () => { const zoomIn = () => {
store.setZoom(Math.min(store.zoom + 0.1, 2)); store.setZoom(Math.min(store.zoom + 0.1, 2));
resetButtonsTimer();
}; };
const zoomOut = () => { const zoomOut = () => {
store.setZoom(Math.max(store.zoom - 0.1, 0.5)); 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 => { const handleKeyPress = event => {
if (store.readingMode === 'single') { if (store.readingMode === 'single') {
if (event.key === 'ArrowRight') { if (event.key === 'ArrowRight') {
store.nextPage(); store.nextPage();
showButtonsWithTimer(); // Afficher les boutons lors de la navigation clavier
} else if (event.key === 'ArrowLeft') { } else if (event.key === 'ArrowLeft') {
store.previousPage(); 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 // La navigation est déjà gérée par le ChapterSelector via le store
// Cette fonction est là pour d'éventuelles actions supplémentaires // Cette fonction est là pour d'éventuelles actions supplémentaires
console.log('Chapitre sélectionné:', chapterId); 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( watch(
@@ -114,13 +217,21 @@ import SingleModeReader from './SingleModeReader.vue';
); );
onMounted(() => { onMounted(() => {
// Charger les préférences sauvegardées
store.loadPreferences();
window.addEventListener('keydown', handleKeyPress); window.addEventListener('keydown', handleKeyPress);
// Afficher les boutons au démarrage
showButtonsWithTimer();
}); });
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener('keydown', handleKeyPress); window.removeEventListener('keydown', handleKeyPress);
// S'assurer que l'auto-hide est désactivé en quittant le lecteur // S'assurer que l'auto-hide est désactivé en quittant le lecteur
headerStore.disableAutoHide(); headerStore.disableAutoHide();
// Nettoyer le timer local
clearTimeout(localButtonsTimer);
}); });
</script> </script>

View File

@@ -12,7 +12,7 @@
<div v-else-if="page?.error" class="error"> <div v-else-if="page?.error" class="error">
{{ page.error }} {{ page.error }}
</div> </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> </div>
<!-- Navigation en bas --> <!-- Navigation en bas -->
@@ -30,7 +30,7 @@
leave-to-class="opacity-0 translate-y-5 scale-75" leave-to-class="opacity-0 translate-y-5 scale-75"
> >
<button <button
v-show="showScrollToTop" v-show="showFloatingButtons"
@click="scrollToTop" @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" 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" title="Revenir en haut"
@@ -58,18 +58,23 @@ import ReaderPage from './ReaderPage.vue';
zoom: { zoom: {
type: Number, type: Number,
required: true required: true
},
doublePageMode: {
type: String,
required: true
} }
}); });
const emit = defineEmits(['pageVisible']); const emit = defineEmits(['pageVisible', 'buttonsVisibilityChange']);
const headerStore = useHeaderStore(); const headerStore = useHeaderStore();
const containerRef = ref(null); const containerRef = ref(null);
const observer = ref(null); const observer = ref(null);
const windowWidth = ref(window.innerWidth); const windowWidth = ref(window.innerWidth);
// État pour le bouton scroll to top // État unique pour tous les boutons flottants avec timer de 3 secondes
const showScrollToTop = ref(false); const showFloatingButtons = ref(false);
let buttonsTimer = null;
// Variables pour détecter la direction du scroll // Variables pour détecter la direction du scroll
let lastScrollTop = 0; 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 = () => { const handleScroll = () => {
let scrollTop = 0; let scrollTop = 0;
@@ -141,23 +177,26 @@ import ReaderPage from './ReaderPage.vue';
headerStore.updateScrollDirection(scrollTop); headerStore.updateScrollDirection(scrollTop);
} }
// Mise à jour de la visibilité du bouton // Gestion de la visibilité des boutons flottants (même condition pour tous)
// Afficher si on scroll vers le bas et qu'on est à plus de 300px // Afficher si on scroll et qu'on est à plus de 300px
// Masquer si on scroll vers le haut ou qu'on est en haut de page if (scrollTop > 300) {
if (scrollDirection === 'down' && scrollTop > 300) { showButtonsWithTimer();
showScrollToTop.value = true; } else if (scrollTop <= 100) {
} else if (scrollDirection === 'up' || scrollTop <= 100) { // Masquer immédiatement si on est en haut de page
showScrollToTop.value = false; hideButtonsImmediately();
} }
// Sauvegarder la position actuelle pour la prochaine comparaison // Sauvegarder la position actuelle pour la prochaine comparaison
lastScrollTop = scrollTop; lastScrollTop = scrollTop;
}; };
// Fonction pour revenir en haut de la page // Fonction pour revenir en haut de la page
const scrollToTop = () => { const scrollToTop = () => {
console.log('scrollToTop appelée'); // Debug console.log('scrollToTop appelée'); // Debug
// Réinitialiser le timer lors du clic
resetButtonsTimer();
// Stratégie 1: Scroll sur le conteneur direct // Stratégie 1: Scroll sur le conteneur direct
if (containerRef.value) { if (containerRef.value) {
console.log('containerRef trouvé, scrollTop actuel:', containerRef.value.scrollTop); // Debug 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( watch(
() => props.pages, () => props.pages,
() => { () => {
@@ -244,6 +289,9 @@ import ReaderPage from './ReaderPage.vue';
// Désactiver l'auto-hide du header en quittant // Désactiver l'auto-hide du header en quittant
headerStore.disableAutoHide(); headerStore.disableAutoHide();
// Nettoyer les timers
clearTimeout(buttonsTimer);
// Nettoyer l'écouteur de scroll du conteneur // Nettoyer l'écouteur de scroll du conteneur
if (containerRef.value) { if (containerRef.value) {
containerRef.value.removeEventListener('scroll', handleScroll); containerRef.value.removeEventListener('scroll', handleScroll);

View File

@@ -1,6 +1,6 @@
<template> <template>
<div class="reader-controls"> <div class="reader-controls">
<button @click="onPrevious" :disabled="isFirstPage"> <button @click="onPrevious" :disabled="isFirstPage" class="nav-button">
<ChevronLeftIcon class="h-6 w-6" /> <ChevronLeftIcon class="h-6 w-6" />
</button> </button>
@@ -14,9 +14,18 @@
</div> </div>
</div> </div>
<button @click="onNext" :disabled="isLastPage"> <div class="controls-right">
<ChevronRightIcon class="h-6 w-6" /> <!-- Bouton paramètres intégré -->
</button> <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> </div>
</template> </template>
@@ -44,14 +53,19 @@ import ChapterSelector from './ChapterSelector.vue';
availableChapters: { availableChapters: {
type: Array, type: Array,
default: () => [] 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 onPrevious = () => emit('previous');
const onNext = () => emit('next'); const onNext = () => emit('next');
const onChapterSelected = (chapterId) => emit('chapter-selected', chapterId); const onChapterSelected = (chapterId) => emit('chapter-selected', chapterId);
const onToggleSettings = () => emit('toggle-settings');
</script> </script>
<style lang="postcss" scoped> <style lang="postcss" scoped>
@@ -59,23 +73,79 @@ import ChapterSelector from './ChapterSelector.vue';
@apply flex items-center justify-between p-4 bg-gray-800; @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 { .controls-center {
@apply flex flex-col items-center space-y-2; @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 { .page-info {
@apply text-lg font-medium; @apply text-lg font-medium;
} }
.chapter-selector-wrapper { .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; @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; @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> </style>

View File

@@ -2,6 +2,47 @@
<div class="page-container" :style="{ transform: `scale(${zoom})` }"> <div class="page-container" :style="{ transform: `scale(${zoom})` }">
<div v-if="!pageData" class="error">Aucune donnée d'image disponible</div> <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> <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 <img
v-else v-else
:src="imageSource" :src="imageSource"
@@ -14,7 +55,8 @@
</template> </template>
<script setup> <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({ const props = defineProps({
pageData: { pageData: {
@@ -28,13 +70,22 @@
zoom: { zoom: {
type: Number, type: Number,
required: true 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 imageRef = ref(null);
const scrollContainerRef = ref(null);
const naturalWidth = ref(0); const naturalWidth = ref(0);
const naturalHeight = ref(0); const naturalHeight = ref(0);
const windowWidth = ref(window.innerWidth); const windowWidth = ref(window.innerWidth);
const isMobile = computed(() => windowWidth.value < 768);
const imageLoaded = ref(false);
const imageSource = computed(() => { const imageSource = computed(() => {
if (!props.pageData?.base64Content || !props.pageData?.mimeType) { if (!props.pageData?.base64Content || !props.pageData?.mimeType) {
@@ -43,26 +94,99 @@
return `data:${props.pageData.mimeType};base64,${props.pageData.base64Content}`; 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 = () => { const handleImageLoad = () => {
if (imageRef.value) { if (imageRef.value) {
naturalWidth.value = imageRef.value.naturalWidth; naturalWidth.value = imageRef.value.naturalWidth;
naturalHeight.value = imageRef.value.naturalHeight; 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(() => { 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; const availableWidth = windowWidth.value;
// Si la largeur disponible est < 1200px : utiliser 95% de la largeur // Si la largeur disponible est < 1200px : utiliser 95% de la largeur
if (availableWidth < 1200) { 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 // Si la largeur disponible est >= 1200px : limiter à 1200px maximum
return Math.min(naturalWidth.value, 1200); return Math.min(width, 1200);
}); });
const imageStyle = computed(() => { 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 // Gestion du redimensionnement de la fenêtre
const handleResize = () => { const handleResize = () => {
windowWidth.value = window.innerWidth; windowWidth.value = window.innerWidth;
@@ -107,9 +295,119 @@
max-height: 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 { .error {
@apply text-red-500 text-xl; @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> </style>

View File

@@ -1,21 +1,185 @@
<template> <template>
<div class="reader-settings"> <div class="reader-settings">
<button @click="onToggleReadingMode"> <!-- Bouton pour ouvrir/fermer les paramètres -->
{{ readingMode === 'single' ? 'Mode Infini' : 'Mode Simple' }} <Transition
</button> enter-active-class="transition-all duration-300 ease-out"
<button @click="onToggleReadingDirection"> leave-active-class="transition-all duration-300 ease-in"
{{ readingDirection === 'ltr' ? 'RTL' : 'LTR' }} enter-from-class="opacity-0 translate-y-5 scale-75"
</button> enter-to-class="opacity-100 translate-y-0 scale-100"
<div class="zoom-controls"> leave-from-class="opacity-100 translate-y-0 scale-100"
<button @click="onZoomOut">-</button> leave-to-class="opacity-0 translate-y-5 scale-75"
<span>{{ Math.round(zoom * 100) }}%</span> >
<button @click="onZoomIn">+</button> <button
</div> 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> </div>
</template> </template>
<script setup> <script setup>
defineProps({ import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
const props = defineProps({
readingMode: { readingMode: {
type: String, type: String,
required: true required: true
@@ -27,27 +191,302 @@
zoom: { zoom: {
type: Number, type: Number,
required: true 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 isOpen = ref(false);
const onToggleReadingDirection = () => emit('toggleReadingDirection'); const isMobile = computed(() => window.innerWidth < 768);
const onZoomIn = () => emit('zoomIn'); const panelRef = ref(null);
const onZoomOut = () => emit('zoomOut');
// 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> </script>
<style lang="postcss" scoped> <style lang="postcss" scoped>
.reader-settings { .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 { .zoom-controls {
@apply flex items-center gap-2; @apply flex items-center gap-3 mb-2;
} }
button { .zoom-button {
@apply px-4 py-2 bg-gray-700 rounded hover:bg-gray-600 transition-colors; @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> </style>

View File

@@ -17,6 +17,7 @@
:page-data="pageData" :page-data="pageData"
:page-number="pageNumber" :page-number="pageNumber"
:zoom="zoom" :zoom="zoom"
:double-page-mode="doublePageMode"
/> />
</div> </div>
@@ -63,9 +64,15 @@ const props = defineProps({
zoom: { zoom: {
type: Number, type: Number,
required: true required: true
},
doublePageMode: {
type: String,
required: true
} }
}); });
const emit = defineEmits(['buttonClick']);
const store = useReaderStore(); const store = useReaderStore();
// État pour afficher les indicateurs de navigation // État pour afficher les indicateurs de navigation
@@ -93,6 +100,7 @@ const goToPrevious = async () => {
await store.goToPreviousChapter(); await store.goToPreviousChapter();
} }
showNavigationHints.value = true; showNavigationHints.value = true;
emit('buttonClick'); // Signaler l'interaction pour afficher les boutons
clearTimeout(hintTimeout); clearTimeout(hintTimeout);
hintTimeout = setTimeout(() => { hintTimeout = setTimeout(() => {
showNavigationHints.value = false; 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 // Le store va charger le chapitre suivant et se positionner automatiquement à la première page
} }
showNavigationHints.value = true; showNavigationHints.value = true;
emit('buttonClick'); // Signaler l'interaction pour afficher les boutons
clearTimeout(hintTimeout); clearTimeout(hintTimeout);
hintTimeout = setTimeout(() => { hintTimeout = setTimeout(() => {
showNavigationHints.value = false; showNavigationHints.value = false;
@@ -145,6 +154,8 @@ const hideRightHint = () => {
@apply relative w-full h-full flex items-center justify-center; @apply relative w-full h-full flex items-center justify-center;
/* Suppression des marges sur mobile */ /* Suppression des marges sur mobile */
@apply p-0 sm:p-2; @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 { .page-navigation-wrapper {

View File

@@ -2,6 +2,7 @@
<html lang="" class="h-full"> <html lang="" class="h-full">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>{% block title %}Mangarr{% endblock %}</title> <title>{% block title %}Mangarr{% endblock %}</title>
<link rel="icon" <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>"> 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>">

View File

@@ -2,7 +2,7 @@
<html> <html>
<head> <head>
<meta charset="UTF-8"> <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> <title>Vue App</title>
{{ encore_entry_link_tags('vue-app') }} {{ encore_entry_link_tags('vue-app') }}
</head> </head>
@@ -10,4 +10,4 @@
<div id="vue-app"></div> <div id="vue-app"></div>
{{ encore_entry_script_tags('vue-app') }} {{ encore_entry_script_tags('vue-app') }}
</body> </body>
</html> </html>