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

@@ -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>