perf(reader): windowing + eager loading sur l'InfiniteReader #14
41
TASK.md
41
TASK.md
@@ -75,6 +75,47 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [Perf] Reader — Lazy-loading des pages (InfiniteReader)
|
||||||
|
|
||||||
|
**Problème :** `readerStore.js` charge toutes les pages avec `itemsPerPage=9999`. `InfiniteReader.vue` monte tous les composants `ReaderPage` simultanément dans le DOM. Sur un chapitre de 200 pages, cela représente 200 composants actifs et autant d'images pré-chargées.
|
||||||
|
|
||||||
|
- [ ] Implémenter un `IntersectionObserver` sur les wrappers de page pour ne charger les images qu'au moment où elles entrent dans le viewport (`loading="lazy"` ou src conditionnel)
|
||||||
|
- [ ] Limiter le nombre de composants montés simultanément (virtualisation ou windowing) : ne rendre que les pages proches de la page courante (ex. fenêtre de ±3 pages)
|
||||||
|
- [ ] Adapter `readerStore.js` : remplacer `itemsPerPage=9999` par la vraie pagination côté API si la virtualisation le justifie, sinon conserver le fetch unique mais différer le rendu
|
||||||
|
- [ ] Vérifier que le mode `single` n'est pas impacté (il affiche déjà une seule page)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [Bug] Reader — N+1 requêtes SQL dans `getChapterContext()`
|
||||||
|
|
||||||
|
**Problème :** `LegacyChapterRepository::getChapterContext()` émet 5 requêtes SQL pour un seul chargement : la requête principale + 2 doublons dans `getPreviousChapterId()` / `getNextChapterId()` (chacune re-fetche le chapitre courant) + les 2 requêtes de navigation.
|
||||||
|
|
||||||
|
- [ ] Refactorer `getPreviousChapterId()` et `getNextChapterId()` pour accepter l'entité `ChapterEntity` déjà chargée en paramètre (au lieu de re-fetcher par ID)
|
||||||
|
- [ ] Appeler ces méthodes depuis `getChapterContext()` en passant l'entité déjà disponible
|
||||||
|
- [ ] Résultat attendu : 3 requêtes maximum (1 pour le chapitre courant + 1 prev + 1 next), idéalement 1 seule avec une requête SQL combinée
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [Bug] Reader — Division par zéro dans `ChapterPagesResponse::getTotalPages()`
|
||||||
|
|
||||||
|
**Problème :** `ceil($totalItems / $itemsPerPage)` crashe si `itemsPerPage = 0`. Le test existant documente le bug avec un TODO et assert un HTTP 500 au lieu de corriger.
|
||||||
|
|
||||||
|
- [ ] Ajouter une validation dans `ChapterPagesProvider` : rejeter la requête avec HTTP 400 si `itemsPerPage <= 0`
|
||||||
|
- [ ] Corriger le test `GetChapterPagesTest` pour vérifier HTTP 400 (et non 500)
|
||||||
|
- [ ] Supprimer le commentaire TODO du test une fois corrigé
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [Bug] Reader — `totalPages` toujours égal à 0 dans `ChapterContext`
|
||||||
|
|
||||||
|
**Problème :** `LegacyChapterRepository::getChapterContext()` hardcode `totalPages: 0`. La méthode `getTotalPagesForChapter()` existe mais n'est jamais appelée depuis `GetChapterContextHandler`.
|
||||||
|
|
||||||
|
- [ ] Appeler `getTotalPagesForChapter()` dans `getChapterContext()` (ou dans le handler) pour calculer le vrai nombre de pages
|
||||||
|
- [ ] Vérifier que la valeur est correctement sérialisée dans la réponse API Platform (`ChapterContextResponse`)
|
||||||
|
- [ ] Adapter les tests existants qui pourraient asserter `totalPages: 0`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [Style] Page conversion CBR → CBZ — Simplification UI + notifications toast
|
## [Style] Page conversion CBR → CBZ — Simplification UI + notifications toast
|
||||||
|
|
||||||
**Objectif :** Revoir le style de la page de conversion CBR → CBZ pour le simplifier, et remplacer le message statique "Conversion réussie" par les notifications toast de l'application.
|
**Objectif :** Revoir le style de la page de conversion CBR → CBZ pour le simplifier, et remplacer le message statique "Conversion réussie" par les notifications toast de l'application.
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
:pages="store.pages"
|
:pages="store.pages"
|
||||||
:zoom="store.zoom"
|
:zoom="store.zoom"
|
||||||
:double-page-mode="store.effectiveDoublePageMode"
|
:double-page-mode="store.effectiveDoublePageMode"
|
||||||
|
:initial-page="store.currentPage"
|
||||||
@page-visible="store.handlePageVisible"
|
@page-visible="store.handlePageVisible"
|
||||||
ref="infiniteReaderRef" />
|
ref="infiniteReaderRef" />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="infinite-reader" ref="containerRef">
|
<div class="infinite-reader" ref="containerRef">
|
||||||
<div v-for="(page, index) in pages" :key="index" class="page-wrapper">
|
<div v-for="(page, index) in pages" :key="index" class="page-wrapper" :data-page-index="index">
|
||||||
<div v-if="!page?.url" class="loading">
|
<ReaderPage
|
||||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
v-if="isPageInWindow(index) && page?.url"
|
||||||
</div>
|
:page-data="page"
|
||||||
<ReaderPage v-else :page-data="page" :page-number="index + 1" :zoom="zoom" :double-page-mode="doublePageMode" loading="lazy" />
|
:page-number="index + 1"
|
||||||
|
:zoom="zoom"
|
||||||
|
:double-page-mode="doublePageMode"
|
||||||
|
loading="eager"
|
||||||
|
/>
|
||||||
|
<div v-else class="page-placeholder" :style="getPlaceholderStyle(page)" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Bouton flottant pour revenir en haut -->
|
<!-- Bouton flottant pour revenir en haut -->
|
||||||
@@ -37,6 +42,25 @@
|
|||||||
import { useHeaderStore } from '../../../../shared/stores/headerStore';
|
import { useHeaderStore } from '../../../../shared/stores/headerStore';
|
||||||
import ReaderPage from './ReaderPage.vue';
|
import ReaderPage from './ReaderPage.vue';
|
||||||
|
|
||||||
|
const WINDOW_SIZE = 3;
|
||||||
|
const currentVisibleIndex = ref(0); // initialisé via prop initialPage dans onMounted
|
||||||
|
|
||||||
|
const isPageInWindow = (index) => Math.abs(index - currentVisibleIndex.value) <= WINDOW_SIZE;
|
||||||
|
|
||||||
|
const getPlaceholderStyle = (page) => {
|
||||||
|
if (page?.dimensions?.width && page?.dimensions?.height) {
|
||||||
|
const maxW = windowWidth.value < 1200
|
||||||
|
? windowWidth.value * 0.95
|
||||||
|
: 1200;
|
||||||
|
return {
|
||||||
|
aspectRatio: `${page.dimensions.width} / ${page.dimensions.height}`,
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: `${Math.min(page.dimensions.width, maxW)}px`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { height: '800px', width: '100%' };
|
||||||
|
};
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
pages: {
|
pages: {
|
||||||
type: Array,
|
type: Array,
|
||||||
@@ -49,6 +73,10 @@ import ReaderPage from './ReaderPage.vue';
|
|||||||
doublePageMode: {
|
doublePageMode: {
|
||||||
type: String,
|
type: String,
|
||||||
required: true
|
required: true
|
||||||
|
},
|
||||||
|
initialPage: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -68,12 +96,15 @@ import ReaderPage from './ReaderPage.vue';
|
|||||||
let scrollDirection = 'down';
|
let scrollDirection = 'down';
|
||||||
|
|
||||||
const observeIntersection = entries => {
|
const observeIntersection = entries => {
|
||||||
entries.forEach(entry => {
|
const intersectingIndices = entries
|
||||||
if (entry.isIntersecting) {
|
.filter(e => e.isIntersecting)
|
||||||
const pageIndex = parseInt(entry.target.getAttribute('data-page-index'));
|
.map(e => parseInt(e.target.getAttribute('data-page-index')));
|
||||||
emit('pageVisible', pageIndex);
|
|
||||||
}
|
if (intersectingIndices.length > 0) {
|
||||||
});
|
const minIdx = Math.min(...intersectingIndices);
|
||||||
|
currentVisibleIndex.value = minIdx;
|
||||||
|
emit('pageVisible', minIdx);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const setupIntersectionObserver = () => {
|
const setupIntersectionObserver = () => {
|
||||||
@@ -89,8 +120,7 @@ import ReaderPage from './ReaderPage.vue';
|
|||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
const pageElements = containerRef.value?.querySelectorAll('.page-wrapper');
|
const pageElements = containerRef.value?.querySelectorAll('.page-wrapper');
|
||||||
if (pageElements) {
|
if (pageElements) {
|
||||||
pageElements.forEach((element, index) => {
|
pageElements.forEach(element => {
|
||||||
element.setAttribute('data-page-index', index);
|
|
||||||
observer.value.observe(element);
|
observer.value.observe(element);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -247,6 +277,7 @@ import ReaderPage from './ReaderPage.vue';
|
|||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
currentVisibleIndex.value = props.initialPage;
|
||||||
setupIntersectionObserver();
|
setupIntersectionObserver();
|
||||||
|
|
||||||
// Activer l'auto-hide du header si la largeur < 1200px
|
// Activer l'auto-hide du header si la largeur < 1200px
|
||||||
@@ -303,29 +334,26 @@ import ReaderPage from './ReaderPage.vue';
|
|||||||
@apply mb-2 sm:mb-4 px-1 sm:px-4;
|
@apply mb-2 sm:mb-4 px-1 sm:px-4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading,
|
.page-placeholder {
|
||||||
|
@apply flex justify-center;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
.error {
|
.error {
|
||||||
@apply flex items-center justify-center min-h-[400px];
|
@apply text-red-500 text-xl bg-red-500/10 rounded-lg flex items-center justify-center min-h-[400px];
|
||||||
/* Largeur adaptative selon la taille d'écran */
|
width: 95vw;
|
||||||
width: 95vw; /* Mobile : 95% de la largeur */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@screen sm {
|
@screen sm {
|
||||||
.loading,
|
|
||||||
.error {
|
.error {
|
||||||
width: 80vw; /* Tablette : 80% de la largeur */
|
width: 80vw;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@screen lg {
|
@screen lg {
|
||||||
.loading,
|
|
||||||
.error {
|
.error {
|
||||||
width: 70vw; /* Desktop : 70% de la largeur */
|
width: 70vw;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.error {
|
|
||||||
@apply text-red-500 text-xl bg-red-500/10 rounded-lg;
|
|
||||||
}
|
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
:alt="`Page ${pageNumber} (Double page)`"
|
:alt="`Page ${pageNumber} (Double page)`"
|
||||||
class="page-image rotated"
|
class="page-image rotated"
|
||||||
:style="doublePageRotatedStyle"
|
:style="doublePageRotatedStyle"
|
||||||
|
:loading="loading"
|
||||||
@load="handleImageLoad"
|
@load="handleImageLoad"
|
||||||
ref="imageRef" />
|
ref="imageRef" />
|
||||||
<div class="rotation-hint">
|
<div class="rotation-hint">
|
||||||
@@ -33,6 +34,7 @@
|
|||||||
:alt="`Page ${pageNumber} (Double page)`"
|
:alt="`Page ${pageNumber} (Double page)`"
|
||||||
class="page-image scrollable"
|
class="page-image scrollable"
|
||||||
:style="doublePageScrollStyle"
|
:style="doublePageScrollStyle"
|
||||||
|
:loading="loading"
|
||||||
@load="handleImageLoad"
|
@load="handleImageLoad"
|
||||||
ref="imageRef" />
|
ref="imageRef" />
|
||||||
</div>
|
</div>
|
||||||
@@ -52,6 +54,7 @@
|
|||||||
:alt="`Page ${pageNumber}`"
|
:alt="`Page ${pageNumber}`"
|
||||||
class="page-image"
|
class="page-image"
|
||||||
:style="imageStyle"
|
:style="imageStyle"
|
||||||
|
:loading="loading"
|
||||||
@load="handleImageLoad"
|
@load="handleImageLoad"
|
||||||
ref="imageRef" />
|
ref="imageRef" />
|
||||||
</div>
|
</div>
|
||||||
@@ -78,6 +81,10 @@ import { useReaderStore } from '../../application/store/readerStore';
|
|||||||
type: String,
|
type: String,
|
||||||
default: 'rotate', // 'rotate', 'scroll', 'normal'
|
default: 'rotate', // 'rotate', 'scroll', 'normal'
|
||||||
validator: (value) => ['rotate', 'scroll', 'normal'].includes(value)
|
validator: (value) => ['rotate', 'scroll', 'normal'].includes(value)
|
||||||
|
},
|
||||||
|
loading: {
|
||||||
|
type: String,
|
||||||
|
default: 'lazy',
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user