feat: mise à jour des règles de configuration pour l'API Platform et ajout de nouveaux composants pour le lecteur, incluant la gestion des pages infinies et des contrôles de lecture
This commit is contained in:
parent
d123166dcb
commit
346fede878
@@ -12,7 +12,8 @@ export const useReaderStore = defineStore('reader', {
|
||||
isLoading: false,
|
||||
error: null,
|
||||
pages: [],
|
||||
totalPages: 0
|
||||
totalPages: 0,
|
||||
loadedPages: new Set() // Garder une trace des pages déjà chargées
|
||||
}),
|
||||
|
||||
getters: {
|
||||
@@ -38,11 +39,17 @@ export const useReaderStore = defineStore('reader', {
|
||||
// Initialiser le tableau avec des placeholders
|
||||
this.pages = new Array(pagesData.totalItems).fill(null);
|
||||
this.totalPages = pagesData.totalItems;
|
||||
this.loadedPages.clear();
|
||||
|
||||
// Charger la première page
|
||||
if (this.totalPages > 0) {
|
||||
this.currentPage = 0;
|
||||
await this.loadCurrentPageData();
|
||||
await this.loadPageData(0);
|
||||
|
||||
// En mode infini, précharger les premières pages
|
||||
if (this.readingMode === 'infinite') {
|
||||
await this.preloadNextPages(0);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.error = error.message;
|
||||
@@ -51,23 +58,23 @@ export const useReaderStore = defineStore('reader', {
|
||||
}
|
||||
},
|
||||
|
||||
async loadCurrentPageData() {
|
||||
if (!this.currentChapter) {
|
||||
async loadPageData(pageIndex) {
|
||||
if (!this.currentChapter || pageIndex < 0 || pageIndex >= this.totalPages) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.currentPage < 0 || this.currentPage >= this.totalPages) {
|
||||
// Si la page est déjà chargée, ne rien faire
|
||||
if (this.loadedPages.has(pageIndex)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pageNumber = this.currentPage + 1; // Convertir en 1-based pour l'API
|
||||
const pageNumber = pageIndex + 1; // Convertir en 1-based pour l'API
|
||||
|
||||
if (this.pages[this.currentPage]?.base64Content) {
|
||||
return;
|
||||
}
|
||||
// Marquer la page comme en cours de chargement
|
||||
const newPages = [...this.pages];
|
||||
newPages[pageIndex] = { loading: true };
|
||||
this.pages = newPages;
|
||||
|
||||
this.isLoading = true;
|
||||
this.error = null;
|
||||
try {
|
||||
const repository = new ApiChapterRepository();
|
||||
const pageData = await repository.getChapterPage(this.currentChapter.id, pageNumber);
|
||||
@@ -77,39 +84,73 @@ export const useReaderStore = defineStore('reader', {
|
||||
throw new Error("Données de page invalides reçues de l'API");
|
||||
}
|
||||
|
||||
// Créer une nouvelle référence du tableau pour déclencher la réactivité
|
||||
const newPages = [...this.pages];
|
||||
newPages[this.currentPage] = {
|
||||
// Mettre à jour la page
|
||||
const updatedPages = [...this.pages];
|
||||
updatedPages[pageIndex] = {
|
||||
id: pageData.id,
|
||||
pageNumber: pageData.pageNumber,
|
||||
base64Content: pageData.base64Content,
|
||||
mimeType: pageData.mimeType,
|
||||
dimensions: pageData.dimensions
|
||||
};
|
||||
this.pages = newPages;
|
||||
this.pages = updatedPages;
|
||||
this.loadedPages.add(pageIndex);
|
||||
} catch (error) {
|
||||
this.error = error.message;
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
console.error(`Erreur lors du chargement de la page ${pageNumber}:`, error);
|
||||
// Marquer la page comme en erreur
|
||||
const errorPages = [...this.pages];
|
||||
errorPages[pageIndex] = { error: error.message };
|
||||
this.pages = errorPages;
|
||||
}
|
||||
},
|
||||
|
||||
async preloadNextPages(startIndex, count = 3) {
|
||||
const promises = [];
|
||||
for (let i = 1; i <= count; i++) {
|
||||
const pageIndex = startIndex + i;
|
||||
if (pageIndex < this.totalPages) {
|
||||
promises.push(this.loadPageData(pageIndex));
|
||||
}
|
||||
}
|
||||
await Promise.all(promises);
|
||||
},
|
||||
|
||||
async handlePageVisible(pageIndex) {
|
||||
if (pageIndex !== this.currentPage) {
|
||||
this.currentPage = pageIndex;
|
||||
// Précharger les pages suivantes
|
||||
if (this.readingMode === 'infinite') {
|
||||
await this.preloadNextPages(pageIndex);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async nextPage() {
|
||||
if (!this.isLastPage) {
|
||||
this.currentPage++;
|
||||
await this.loadCurrentPageData();
|
||||
await this.loadPageData(this.currentPage);
|
||||
}
|
||||
},
|
||||
|
||||
async previousPage() {
|
||||
if (!this.isFirstPage) {
|
||||
this.currentPage--;
|
||||
await this.loadCurrentPageData();
|
||||
await this.loadPageData(this.currentPage);
|
||||
}
|
||||
},
|
||||
|
||||
setReadingMode(mode) {
|
||||
async setReadingMode(mode) {
|
||||
if (mode === this.readingMode) return;
|
||||
|
||||
this.readingMode = mode;
|
||||
|
||||
// S'assurer que la page courante est chargée
|
||||
await this.loadPageData(this.currentPage);
|
||||
|
||||
// Si on passe en mode infini, précharger les pages suivantes
|
||||
if (mode === 'infinite') {
|
||||
await this.preloadNextPages(this.currentPage);
|
||||
}
|
||||
},
|
||||
|
||||
setReadingDirection(direction) {
|
||||
|
||||
@@ -9,43 +9,44 @@
|
||||
</div>
|
||||
|
||||
<div v-else class="reader-content">
|
||||
<div class="reader-controls">
|
||||
<button @click="store.previousPage" :disabled="store.isFirstPage">
|
||||
<ChevronLeftIcon class="h-6 w-6" />
|
||||
</button>
|
||||
<div class="page-info"> {{ store.currentPage + 1 }} / {{ store.totalPages }} </div>
|
||||
<button @click="store.nextPage" :disabled="store.isLastPage">
|
||||
<ChevronRightIcon class="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
<ReaderControls
|
||||
v-if="store.readingMode === 'single'"
|
||||
:current-page="store.currentPage"
|
||||
:total-pages="store.totalPages"
|
||||
:is-first-page="store.isFirstPage"
|
||||
:is-last-page="store.isLastPage"
|
||||
@previous="store.previousPage"
|
||||
@next="store.nextPage" />
|
||||
|
||||
<div class="page-container" :style="{ transform: `scale(${store.zoom})` }">
|
||||
<div v-if="!store.currentPageData" class="error"> Aucune donnée d'image disponible </div>
|
||||
<div v-else-if="!store.currentPageData.base64Content" class="error"> Contenu de l'image manquant </div>
|
||||
<img v-else :src="imageSource" :alt="`Page ${store.currentPage + 1}`" class="page-image" />
|
||||
</div>
|
||||
<template v-if="store.readingMode === 'single'">
|
||||
<ReaderPage
|
||||
:page-data="store.currentPageData"
|
||||
:page-number="store.currentPage + 1"
|
||||
:zoom="store.zoom" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<InfiniteReader :pages="store.pages" :zoom="store.zoom" @page-visible="store.handlePageVisible" />
|
||||
</template>
|
||||
|
||||
<div class="reader-settings">
|
||||
<button @click="toggleReadingMode">
|
||||
{{ store.readingMode === 'single' ? 'Mode Infini' : 'Mode Simple' }}
|
||||
</button>
|
||||
<button @click="toggleReadingDirection">
|
||||
{{ store.readingDirection === 'ltr' ? 'RTL' : 'LTR' }}
|
||||
</button>
|
||||
<div class="zoom-controls">
|
||||
<button @click="zoomOut">-</button>
|
||||
<span>{{ Math.round(store.zoom * 100) }}%</span>
|
||||
<button @click="zoomIn">+</button>
|
||||
</div>
|
||||
</div>
|
||||
<ReaderSettings
|
||||
:reading-mode="store.readingMode"
|
||||
:reading-direction="store.readingDirection"
|
||||
:zoom="store.zoom"
|
||||
@toggle-reading-mode="toggleReadingMode"
|
||||
@toggle-reading-direction="toggleReadingDirection"
|
||||
@zoom-in="zoomIn"
|
||||
@zoom-out="zoomOut" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, onUnmounted, computed, watch } from 'vue';
|
||||
import { onMounted, onUnmounted, watch } from 'vue';
|
||||
import { useReaderStore } from '../../application/store/readerStore';
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/vue/24/outline';
|
||||
import ReaderControls from './ReaderControls.vue';
|
||||
import ReaderPage from './ReaderPage.vue';
|
||||
import ReaderSettings from './ReaderSettings.vue';
|
||||
import InfiniteReader from './InfiniteReader.vue';
|
||||
|
||||
const props = defineProps({
|
||||
chapterId: {
|
||||
@@ -56,13 +57,6 @@
|
||||
|
||||
const store = useReaderStore();
|
||||
|
||||
const imageSource = computed(() => {
|
||||
if (!store.currentPageData?.base64Content || !store.currentPageData?.mimeType) {
|
||||
return '';
|
||||
}
|
||||
return `data:${store.currentPageData.mimeType};base64,${store.currentPageData.base64Content}`;
|
||||
});
|
||||
|
||||
const toggleReadingMode = () => {
|
||||
store.setReadingMode(store.readingMode === 'single' ? 'infinite' : 'single');
|
||||
};
|
||||
@@ -80,10 +74,12 @@
|
||||
};
|
||||
|
||||
const handleKeyPress = event => {
|
||||
if (event.key === 'ArrowRight') {
|
||||
store.nextPage();
|
||||
} else if (event.key === 'ArrowLeft') {
|
||||
store.previousPage();
|
||||
if (store.readingMode === 'single') {
|
||||
if (event.key === 'ArrowRight') {
|
||||
store.nextPage();
|
||||
} else if (event.key === 'ArrowLeft') {
|
||||
store.previousPage();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -123,39 +119,6 @@
|
||||
@apply w-full h-full flex flex-col;
|
||||
}
|
||||
|
||||
.reader-controls {
|
||||
@apply flex items-center justify-between p-4 bg-gray-800;
|
||||
}
|
||||
|
||||
.page-info {
|
||||
@apply text-lg font-medium;
|
||||
}
|
||||
|
||||
.page-container {
|
||||
@apply flex-1 flex items-center justify-center overflow-hidden;
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.page-image {
|
||||
@apply max-w-full max-h-full object-contain;
|
||||
}
|
||||
|
||||
.reader-settings {
|
||||
@apply flex items-center justify-center gap-4 p-4 bg-gray-800;
|
||||
}
|
||||
|
||||
.zoom-controls {
|
||||
@apply flex items-center gap-2;
|
||||
}
|
||||
|
||||
button {
|
||||
@apply px-4 py-2 bg-gray-700 rounded hover:bg-gray-600 transition-colors;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
@apply opacity-50 cursor-not-allowed;
|
||||
}
|
||||
|
||||
.rtl {
|
||||
direction: rtl;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
<template>
|
||||
<div class="infinite-reader" ref="containerRef">
|
||||
<div v-for="(page, index) in pages" :key="index" class="page-wrapper">
|
||||
<div v-if="page?.loading" class="loading">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
<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" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue';
|
||||
import { nextTick } from 'vue';
|
||||
import ReaderPage from './ReaderPage.vue';
|
||||
|
||||
const props = defineProps({
|
||||
pages: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
zoom: {
|
||||
type: Number,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['pageVisible']);
|
||||
|
||||
const containerRef = ref(null);
|
||||
const observer = ref(null);
|
||||
|
||||
const observeIntersection = entries => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
const pageIndex = parseInt(entry.target.getAttribute('data-page-index'));
|
||||
emit('pageVisible', pageIndex);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const setupIntersectionObserver = () => {
|
||||
if (observer.value) {
|
||||
observer.value.disconnect();
|
||||
}
|
||||
|
||||
observer.value = new IntersectionObserver(observeIntersection, {
|
||||
root: null,
|
||||
threshold: 0.5
|
||||
});
|
||||
|
||||
nextTick(() => {
|
||||
const pageElements = containerRef.value?.querySelectorAll('.page-wrapper');
|
||||
if (pageElements) {
|
||||
pageElements.forEach((element, index) => {
|
||||
element.setAttribute('data-page-index', index);
|
||||
observer.value.observe(element);
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.pages,
|
||||
() => {
|
||||
setupIntersectionObserver();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
setupIntersectionObserver();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (observer.value) {
|
||||
observer.value.disconnect();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.infinite-reader {
|
||||
@apply flex-1 flex flex-col items-center overflow-y-auto py-8;
|
||||
height: calc(100vh - 8rem);
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
.page-wrapper {
|
||||
@apply w-full flex justify-center min-h-[200px] mb-4;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.error {
|
||||
@apply flex items-center justify-center;
|
||||
width: 70vw;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.error {
|
||||
@apply text-red-500 text-xl bg-red-500/10 rounded-lg;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<div class="reader-controls">
|
||||
<button @click="onPrevious" :disabled="isFirstPage">
|
||||
<ChevronLeftIcon class="h-6 w-6" />
|
||||
</button>
|
||||
<div class="page-info"> {{ currentPage + 1 }} / {{ totalPages }} </div>
|
||||
<button @click="onNext" :disabled="isLastPage">
|
||||
<ChevronRightIcon class="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/vue/24/outline';
|
||||
|
||||
defineProps({
|
||||
currentPage: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
totalPages: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
isFirstPage: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
isLastPage: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['previous', 'next']);
|
||||
|
||||
const onPrevious = () => emit('previous');
|
||||
const onNext = () => emit('next');
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.reader-controls {
|
||||
@apply flex items-center justify-between p-4 bg-gray-800;
|
||||
}
|
||||
|
||||
.page-info {
|
||||
@apply text-lg font-medium;
|
||||
}
|
||||
|
||||
button {
|
||||
@apply px-4 py-2 bg-gray-700 rounded hover:bg-gray-600 transition-colors;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
@apply opacity-50 cursor-not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<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>
|
||||
<img
|
||||
v-else
|
||||
:src="imageSource"
|
||||
:alt="`Page ${pageNumber}`"
|
||||
class="page-image"
|
||||
:style="imageStyle"
|
||||
@load="handleImageLoad"
|
||||
ref="imageRef" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, onMounted } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
pageData: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
pageNumber: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
zoom: {
|
||||
type: Number,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const imageRef = ref(null);
|
||||
const naturalWidth = ref(0);
|
||||
const naturalHeight = ref(0);
|
||||
|
||||
const imageSource = computed(() => {
|
||||
if (!props.pageData?.base64Content || !props.pageData?.mimeType) {
|
||||
return '';
|
||||
}
|
||||
return `data:${props.pageData.mimeType};base64,${props.pageData.base64Content}`;
|
||||
});
|
||||
|
||||
const handleImageLoad = () => {
|
||||
if (imageRef.value) {
|
||||
naturalWidth.value = imageRef.value.naturalWidth;
|
||||
naturalHeight.value = imageRef.value.naturalHeight;
|
||||
}
|
||||
};
|
||||
|
||||
// Calculer la largeur maximale en fonction de la hauteur de la fenêtre
|
||||
const maxWidth = computed(() => {
|
||||
if (!naturalWidth.value || !naturalHeight.value) return null;
|
||||
|
||||
// On prend 70% de la largeur de la fenêtre comme largeur maximale
|
||||
const maxWidthPx = window.innerWidth * 0.7;
|
||||
|
||||
// Si l'image est plus petite que la largeur maximale, on garde sa taille naturelle
|
||||
if (naturalWidth.value <= maxWidthPx) {
|
||||
return naturalWidth.value;
|
||||
}
|
||||
|
||||
return maxWidthPx;
|
||||
});
|
||||
|
||||
const imageStyle = computed(() => {
|
||||
if (!maxWidth.value) return {};
|
||||
|
||||
return {
|
||||
width: `${maxWidth.value}px`,
|
||||
height: 'auto',
|
||||
maxWidth: '100%'
|
||||
};
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (imageRef.value && imageRef.value.complete) {
|
||||
handleImageLoad();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.page-container {
|
||||
@apply flex-1 flex items-center justify-center overflow-hidden;
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.page-image {
|
||||
@apply max-w-full max-h-full object-contain;
|
||||
}
|
||||
|
||||
.error {
|
||||
@apply text-red-500 text-xl;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,53 @@
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
readingMode: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
readingDirection: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
zoom: {
|
||||
type: Number,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['toggleReadingMode', 'toggleReadingDirection', 'zoomIn', 'zoomOut']);
|
||||
|
||||
const onToggleReadingMode = () => emit('toggleReadingMode');
|
||||
const onToggleReadingDirection = () => emit('toggleReadingDirection');
|
||||
const onZoomIn = () => emit('zoomIn');
|
||||
const onZoomOut = () => emit('zoomOut');
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.reader-settings {
|
||||
@apply flex items-center justify-center gap-4 p-4 bg-gray-800;
|
||||
}
|
||||
|
||||
.zoom-controls {
|
||||
@apply flex items-center gap-2;
|
||||
}
|
||||
|
||||
button {
|
||||
@apply px-4 py-2 bg-gray-700 rounded hover:bg-gray-600 transition-colors;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user