Files
Mangarr/assets/vue/app/domain/reader/presentation/components/ChapterReader.vue
ext.jeremy.guillot@maxicoffee.domains 74f033f5d1 perf(reader): windowing + eager loading sur l'InfiniteReader
- Windowing côté rendu : seules les pages dans une fenêtre de ±3 autour
  de la page visible sont montées en tant que ReaderPage ; les autres
  sont remplacées par des placeholders dimensionnés via aspect-ratio CSS
  pour maintenir la hauteur de scroll sans saut
- IntersectionObserver utilise le minimum des indices intersectants pour
  éviter que les entrées simultanées au chargement ne décalent la fenêtre
- Prop initialPage passé depuis ChapterReader pour ancrer la fenêtre sur
  la page courante dès le montage
- loading="eager" sur les ReaderPage montés (le windowing est le mécanisme
  de lazy-loading, pas l'attribut HTML natif)
- Prop loading bindé sur les 3 balises <img> de ReaderPage
2026-03-15 17:46:00 +01:00

181 lines
5.6 KiB
Vue

<template>
<div class="chapter-reader" :class="{ rtl: store.readingDirection === 'rtl' }">
<div v-if="store.isLoading" class="loading">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
<div v-else-if="store.error" class="error">
{{ store.error }}
</div>
<div v-else class="reader-content">
<template v-if="store.readingMode === 'single'">
<SingleModeReader
:page-data="store.currentPageData"
:page-number="store.currentPage + 1"
:zoom="store.zoom"
:double-page-mode="store.effectiveDoublePageMode"
@button-click="showButtonsWithTimer" />
</template>
<template v-else>
<InfiniteReader
:pages="store.pages"
:zoom="store.zoom"
:double-page-mode="store.effectiveDoublePageMode"
:initial-page="store.currentPage"
@page-visible="store.handlePageVisible"
ref="infiniteReaderRef" />
</template>
</div>
</div>
</template>
<script setup>
import { onMounted, onUnmounted, ref, watch } from 'vue';
import { useHeaderStore } from '../../../../shared/stores/headerStore';
import { useUserPreferencesStore } from '../../../../domain/setting/application/store/userPreferencesStore';
import { useReaderStore } from '../../application/store/readerStore';
import InfiniteReader from './InfiniteReader.vue';
import SingleModeReader from './SingleModeReader.vue';
const props = defineProps({
chapterId: {
type: String,
required: true
},
availableChapters: {
type: Array,
default: () => []
}
});
const store = useReaderStore();
const headerStore = useHeaderStore();
const prefs = useUserPreferencesStore();
const infiniteReaderRef = ref(null);
// Actions de l'interface lecteur
const toggleReadingMode = () => {
const newMode = store.readingMode === 'single' ? 'infinite' : 'single';
store.setReadingMode(newMode);
prefs.setReadingMode(newMode === 'infinite' ? 'scroll' : 'single');
if (newMode === 'single') {
headerStore.disableAutoHide();
headerStore.disableReaderToolbarAutoHide();
} else {
headerStore.enableReaderToolbarAutoHide();
headerStore.enableAutoHide();
showButtonsWithTimer();
}
};
const toggleReadingDirection = () => {
const newDir = store.readingDirection === 'ltr' ? 'rtl' : 'ltr';
store.setReadingDirection(newDir);
prefs.setReadingDirection(newDir);
};
const zoomIn = () => store.setZoom(Math.min(store.zoom + 0.1, 2));
const zoomOut = () => store.setZoom(Math.max(store.zoom - 0.1, 0.5));
const handleZoomChange = (zoom) => store.setZoom(zoom);
const handleDoublePageModeChange = (mode) => store.setDoublePageMode(mode);
const handleDoublePageAutoDetectChange = (enabled) => store.setDoublePageAutoDetect(enabled);
const handleDetectionThresholdChange = (threshold) => store.setDoublePageDetectionThreshold(threshold);
const handleResetPreferences = () => store.resetPreferences();
const showButtonsWithTimer = () => {
if (store.readingMode === 'infinite' && infiniteReaderRef.value) {
infiniteReaderRef.value.showButtonsWithTimer();
}
};
const resetButtonsTimer = () => {
if (store.readingMode === 'infinite' && infiniteReaderRef.value) {
infiniteReaderRef.value.resetButtonsTimer();
}
};
const handleKeyPress = event => {
if (store.readingMode === 'single') {
if (event.key === 'ArrowRight') {
store.nextPage();
} else if (event.key === 'ArrowLeft') {
store.previousPage();
}
}
};
watch(
() => props.chapterId,
newId => {
if (newId) {
store.loadChapter(newId);
}
},
{ immediate: true }
);
onMounted(() => {
store.loadPreferences();
window.addEventListener('keydown', handleKeyPress);
if (prefs.autoHideHeaderReader) {
headerStore.enableAutoHide();
}
if (store.readingMode === 'infinite') {
headerStore.enableReaderToolbarAutoHide();
}
if (prefs.autoFullscreen && document.documentElement.requestFullscreen) {
document.documentElement.requestFullscreen().catch(() => {});
}
});
onUnmounted(() => {
window.removeEventListener('keydown', handleKeyPress);
headerStore.disableAutoHide();
headerStore.disableReaderToolbarAutoHide();
});
defineExpose({
toggleReadingMode,
toggleReadingDirection,
zoomIn,
zoomOut,
handleZoomChange,
handleDoublePageModeChange,
handleDoublePageAutoDetectChange,
handleDetectionThresholdChange,
handleResetPreferences,
resetButtonsTimer,
showButtonsWithTimer,
});
</script>
<style lang="postcss" scoped>
.chapter-reader {
@apply w-full h-full flex flex-col bg-gray-900 text-white;
}
.loading {
@apply flex items-center justify-center h-full;
}
.error {
@apply text-red-500 text-xl;
}
.reader-content {
@apply w-full flex-1 flex flex-col min-h-0;
}
.rtl {
direction: rtl;
}
</style>