Compare commits

...

11 Commits

Author SHA1 Message Date
cf8779781f docs(readme): documenter la remote Gitea et le modèle de branches
Ajout de l'URL de clone (gitea.anthonybouteiller.ovh/blomios/ReadaBook) et
d'une note sur le git-flow simplifié main/develop/feature.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-28 22:47:37 +02:00
452a4b6455 merge: feature/44-home-progress-scroll dans develop (liste des livres en cours scrollable après 6 éléments — évolution #44) 2026-08-28 22:41:04 +02:00
1d0b389c1e feat(web): accueil — rendre scrollable la liste des livres en cours après 6 éléments visibles (hauteur de .continue-grid bornée à 6 tuiles de 118px, overflow-y auto, scrollbar-gutter stable, tuiles à block-size fixe — évolution #44) 2026-08-28 22:40:59 +02:00
a9996cf15f merge: fix/43-reader-mobile-pinch-jump dans develop (stabilisation du pinch-zoom mobile : ancrage du point médian, gel du suivi vertical, blocage du zoom natif — correctif #43) 2026-08-28 19:08:39 +02:00
c4fe4d9de6 fix(web): lecteur mobile — stabiliser le pinch-zoom (ancrage du point médian sous les doigts avec restauration du scroll par page, gel du commit vertical via data-reader-pinch-active pendant le pincement, preventDefault sur touchstart et touch-action: pan-x pan-y pour bloquer le zoom natif — correctif bug #43) 2026-08-28 19:08:34 +02:00
01723c6eb7 merge: fix/42-reader-mobile-slow-load dans develop (préchargeur CBZ vertical priorisé, concurrency 3 + fenêtre profonde, fin de la saturation réseau Firefox mobile — 4e correctif #42) 2026-08-27 18:06:55 +02:00
7b3b687035 fix(web): lecteur mobile — remplacer le préchargeur CBZ vertical exhaustif (24 requêtes concurrentes sur tout le livre) par un planificateur à priorités (concurrence 3, délai 100ms, pages adjacentes + fenêtre profonde à +90, annulation par génération) et passer la page courante en fetchPriority high pour un chargement rapide des pages sur Firefox mobile (4e correctif bug #42) 2026-08-27 18:06:50 +02:00
9a5e78af72 merge: fix/42-reader-vertical-deep-pages dans develop (préchargeur CBZ vertical concurrent pour les pages profondes — 3e correctif #42) 2026-08-26 17:39:31 +02:00
03c8657df9 fix(web): lecteur mobile — précharger les pages CBZ verticales en profondeur via un préchargeur concurrent (batch RAF des tailles d'images, tracking resynchronisé au scroll) pour éliminer les black screens sur les pages lointaines (3e correctif bug #42) 2026-08-26 17:39:28 +02:00
5f63faa648 merge: fix/42-reader-vertical-black-screen-persistent dans develop (fallback page-sized étendu au PDF vertical mobile — 2e correctif #42) 2026-08-26 17:14:18 +02:00
ab77d129d3 fix(web): lecteur mobile — étendre le fallback page-sized aux frames PDF verticales non rendues (même cellule grid que le canvas, fond papier stable) pour éliminer les black screens d'environ 1s au scroll vertical PDF (2e correctif bug #42) 2026-08-26 17:14:11 +02:00
8 changed files with 347 additions and 19 deletions

View File

@ -2,6 +2,16 @@
ReadaBook est une application locale-first pour cataloguer, rechercher et lire une bibliothèque personnelle de livres EPUB/PDF stockés sur disque. Le MVP livré vise un usage domestique : un administrateur déclare un dossier local, lance un scan, puis les livres deviennent accessibles via un catalogue web et une API locale.
## Dépôt distant
Le dépôt est hébergé sur un Gitea self-hosted et peut être cloné via :
```bash
git clone https://gitea.anthonybouteiller.ovh/blomios/ReadaBook.git
```
Le déploiement git suit un git-flow simplifié : `main` (releases), `develop` (intégration), `feature/*` / `fix/*` (travail en cours).
## Fonctionnalités MVP présentes
- Backend NestJS/Fastify exécutable avec healthcheck `GET /healthz`.

View File

@ -0,0 +1,18 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
describe("home progress list layout", () => {
it("keeps the continue reading list scrollable after six visible books", () => {
const styles = readFileSync(new URL("../styles/app.css", import.meta.url), "utf8");
const continueGrid = styles.match(/\.continue-grid\s*\{[^}]+\}/)?.[0] ?? "";
const continueTile = styles.match(/\.continue-tile\s*\{[^}]+\}/)?.[0] ?? "";
expect(continueGrid).toContain("--continue-visible-rows: 6");
expect(continueGrid).toContain("--continue-tile-block-size: 118px");
expect(continueGrid).toContain("max-height: calc((var(--continue-tile-block-size) * var(--continue-visible-rows)) + (10px * (var(--continue-visible-rows) - 1)))");
expect(continueGrid).toContain("overflow-y: auto");
expect(continueGrid).toContain("scrollbar-gutter: stable");
expect(continueTile).toContain("block-size: var(--continue-tile-block-size)");
expect(continueTile).toContain("overflow: hidden");
});
});

View File

@ -5,7 +5,7 @@ import { CbzReader } from "../reader/CbzReader";
import { EpubReader } from "../reader/EpubReader";
import { pageLocator, parseCbrPageLocator, parseCbzPageLocator, parsePdfPageLocator, pdfPagePercent } from "../reader/locators";
import { PdfReader } from "../reader/PdfReader";
import { ReaderShell, type ReaderControls, type ReaderModeControls, type ReaderZoomControls } from "../reader/ReaderShell";
import { ReaderShell, type ReaderControls, type ReaderModeControls, type ReaderZoomAnchor, type ReaderZoomControls } from "../reader/ReaderShell";
import { clampReaderZoom, READER_ZOOM_DEFAULT, READER_ZOOM_STEP } from "../reader/readerLayout";
import { majorityVisiblePage, type ReaderMode } from "../reader/readerScroll";
import { useReaderPreferences } from "../reader/useReaderPreferences";
@ -159,7 +159,7 @@ export function ReaderPage({ bookId }: { bookId: number }) {
setMode("horizontal");
saveReaderMode("horizontal");
}, [currentVisiblePage, page, saveReaderMode]);
const changeZoom = useCallback((nextZoom: number | ((currentZoom: number) => number)) => {
const changeZoom = useCallback((nextZoom: number | ((currentZoom: number) => number), anchor?: ReaderZoomAnchor) => {
const stage = document.querySelector(".reader-stage") as HTMLElement | null;
const scrollRatioX = stage && stage.scrollWidth > stage.clientWidth ? (stage.scrollLeft + stage.clientWidth / 2) / stage.scrollWidth : 0.5;
const scrollRatioY = stage && stage.scrollHeight > stage.clientHeight ? stage.scrollTop / (stage.scrollHeight - stage.clientHeight) : 0;
@ -169,6 +169,14 @@ export function ReaderPage({ bookId }: { bookId: number }) {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (!stage) return;
if (anchor) {
const target = stage.querySelector<HTMLElement>(`[data-reader-page="${anchor.page}"]`);
if (!target) return;
const rect = target.getBoundingClientRect();
stage.scrollLeft += rect.left + anchor.offsetX - anchor.clientX;
stage.scrollTop += rect.top + anchor.offsetY - anchor.clientY;
return;
}
stage.scrollLeft = Math.max(0, stage.scrollWidth * scrollRatioX - stage.clientWidth / 2);
stage.scrollTop = Math.max(0, (stage.scrollHeight - stage.clientHeight) * scrollRatioY);
});
@ -179,7 +187,7 @@ export function ReaderPage({ bookId }: { bookId: number }) {
supportsZoom
? {
zoom,
onZoomChange: (nextZoom) => changeZoom(nextZoom),
onZoomChange: (nextZoom, anchor) => changeZoom(nextZoom, anchor),
onZoomOut: () => changeZoom((currentZoom) => currentZoom - READER_ZOOM_STEP),
onZoomIn: () => changeZoom((currentZoom) => currentZoom + READER_ZOOM_STEP),
onZoomReset: () => changeZoom(READER_ZOOM_DEFAULT)

View File

@ -6,11 +6,74 @@ import { majorityVisiblePage, type ReaderMode } from "./readerScroll";
import type { ReaderControls } from "./ReaderShell";
type PageCommitStrategy = "immediate" | "queued";
type PreloadedComicImage = {
image: HTMLImageElement;
status: "loading" | "loaded" | "error";
};
type PreloadPriority = "adjacent" | "deep";
type VerticalPreloadScheduler = {
active: number;
generation: number;
timer: number | null;
queuedPages: Set<number>;
queues: Record<PreloadPriority, number[]>;
};
const VERTICAL_ANCHOR_TOLERANCE_PX = 24;
const VERTICAL_ANCHOR_STABLE_FRAMES = 18;
const VERTICAL_ANCHOR_MAX_ATTEMPTS = 180;
const VERTICAL_INITIAL_SYNC_MAX_ATTEMPTS = 180;
const VERTICAL_IMAGE_PRELOAD_CONCURRENCY = 3;
const VERTICAL_IMAGE_PRELOAD_DELAY_MS = 100;
const VERTICAL_IMAGE_PRELOAD_DEEP_OFFSET = 90;
const VERTICAL_IMAGE_PRELOAD_DEEP_SPREAD = [0, 1, -1, 2, -2, 3, -3, 4, -4];
const VERTICAL_IMAGE_PRELOAD_PRIORITIES: PreloadPriority[] = ["adjacent", "deep"];
function scheduleReaderFrame(callback: FrameRequestCallback) {
if (typeof requestAnimationFrame === "function") return requestAnimationFrame(callback);
callback(0);
return 0;
}
function cancelReaderFrame(frameId: number) {
if (frameId && typeof cancelAnimationFrame === "function") cancelAnimationFrame(frameId);
}
function createVerticalPreloadScheduler(): VerticalPreloadScheduler {
return {
active: 0,
generation: 0,
timer: null,
queuedPages: new Set<number>(),
queues: {
adjacent: [],
deep: []
}
};
}
function uniqueReaderPages(pages: Array<number | null>, currentPage: number, pageCount: number) {
const seen = new Set<number>();
return pages
.map((candidate) => (candidate ? clampReaderPage(candidate, pageCount) : null))
.filter((candidate): candidate is number => {
if (!candidate || candidate === currentPage || seen.has(candidate)) return false;
seen.add(candidate);
return true;
});
}
function verticalImagePreloadPlan(currentPage: number, pageCount: number) {
const deepCenter = clampReaderPage(currentPage + VERTICAL_IMAGE_PRELOAD_DEEP_OFFSET, pageCount);
return {
adjacent: uniqueReaderPages([currentPage - 1, currentPage + 1, currentPage + 2], currentPage, pageCount),
deep: uniqueReaderPages(
VERTICAL_IMAGE_PRELOAD_DEEP_SPREAD.map((offset) => deepCenter + offset),
currentPage,
pageCount
)
};
}
export function CbzReader({
bookId,
@ -39,6 +102,10 @@ export function CbzReader({
const verticalInitialSyncDoneRef = useRef(false);
const verticalAnchorAttemptRef = useRef(0);
const verticalAnchorStableFramesRef = useRef(0);
const preloadedImagesRef = useRef(new Map<number, PreloadedComicImage>());
const preloadSchedulerRef = useRef(createVerticalPreloadScheduler());
const pendingImageSizesRef = useRef<Record<number, ReaderSize>>({});
const imageSizeFlushFrameRef = useRef<number | null>(null);
const [pages, setPages] = useState<CbzPagesDto | null>(null);
const [documentError, setDocumentError] = useState<string>();
const [pageError, setPageError] = useState<string>();
@ -91,9 +158,95 @@ export function CbzReader({
if (verticalInitialSyncFrameRef.current !== null) cancelAnimationFrame(verticalInitialSyncFrameRef.current);
verticalInitialSyncFrameRef.current = null;
}, []);
const queueImageSize = useCallback((pageNumber: number, size: ReaderSize) => {
pendingImageSizesRef.current[pageNumber] = size;
if (imageSizeFlushFrameRef.current !== null) return;
imageSizeFlushFrameRef.current = scheduleReaderFrame(() => {
imageSizeFlushFrameRef.current = null;
const pending = pendingImageSizesRef.current;
pendingImageSizesRef.current = {};
setImageSizes((current) => {
let changed = false;
const next = { ...current };
for (const [pageKey, nextSize] of Object.entries(pending)) {
const pageNumber = Number(pageKey);
const currentSize = current[pageNumber];
if (currentSize?.width === nextSize.width && currentSize.height === nextSize.height) continue;
next[pageNumber] = nextSize;
changed = true;
}
return changed ? next : current;
});
});
}, []);
const drainVerticalPreloadQueue = useCallback(() => {
const scheduler = preloadSchedulerRef.current;
scheduler.timer = null;
if (typeof Image === "undefined") return;
const nextPage = () => {
for (const priority of VERTICAL_IMAGE_PRELOAD_PRIORITIES) {
const pageNumber = scheduler.queues[priority].shift();
if (pageNumber) {
scheduler.queuedPages.delete(pageNumber);
return pageNumber;
}
}
return null;
};
while (scheduler.active < VERTICAL_IMAGE_PRELOAD_CONCURRENCY) {
const pageNumber = nextPage();
if (!pageNumber) return;
if (preloadedImagesRef.current.has(pageNumber)) continue;
const generation = scheduler.generation;
const image = new Image();
preloadedImagesRef.current.set(pageNumber, { image, status: "loading" });
scheduler.active += 1;
image.decoding = "async";
image.loading = "eager";
image.onload = () => {
if (generation !== scheduler.generation) return;
scheduler.active -= 1;
preloadedImagesRef.current.set(pageNumber, { image, status: "loaded" });
if (image.naturalWidth > 0 && image.naturalHeight > 0) {
queueImageSize(pageNumber, { width: image.naturalWidth, height: image.naturalHeight });
}
drainVerticalPreloadQueue();
};
image.onerror = () => {
if (generation !== scheduler.generation) return;
scheduler.active -= 1;
preloadedImagesRef.current.set(pageNumber, { image, status: "error" });
drainVerticalPreloadQueue();
};
image.src = api.cbzPageUrl(bookId, pageNumber);
}
}, [bookId, queueImageSize]);
const enqueueVerticalPreload = useCallback(
(plan: Record<PreloadPriority, number[]>) => {
const scheduler = preloadSchedulerRef.current;
for (const priority of VERTICAL_IMAGE_PRELOAD_PRIORITIES) {
if (priority === "deep") {
for (const pageNumber of scheduler.queues.deep) scheduler.queuedPages.delete(pageNumber);
scheduler.queues.deep = [];
}
for (const pageNumber of plan[priority]) {
if (preloadedImagesRef.current.has(pageNumber) || scheduler.queuedPages.has(pageNumber)) continue;
scheduler.queuedPages.add(pageNumber);
scheduler.queues[priority].push(pageNumber);
}
}
if (scheduler.timer !== null || scheduler.queuedPages.size === 0) return;
scheduler.timer = window.setTimeout(drainVerticalPreloadQueue, VERTICAL_IMAGE_PRELOAD_DELAY_MS);
},
[drainVerticalPreloadQueue]
);
const commitVisiblePage = useCallback(
(stage: HTMLElement, allowInitialCommit = false) => {
if (!pages || !verticalTrackingReadyRef.current) return;
if (stage.dataset.readerPinchActive === "true") return;
if (!allowInitialCommit && !verticalUserScrollRef.current) return;
if (allowInitialCommit && verticalInitialSyncDoneRef.current) return;
const stageRect = stage.getBoundingClientRect();
@ -192,8 +345,19 @@ export function CbzReader({
useEffect(() => {
setImageSizes({});
pendingImageSizesRef.current = {};
preloadedImagesRef.current.clear();
const scheduler = preloadSchedulerRef.current;
if (scheduler.timer !== null) window.clearTimeout(scheduler.timer);
preloadSchedulerRef.current = createVerticalPreloadScheduler();
preloadSchedulerRef.current.generation = scheduler.generation + 1;
}, [bookId, retryAttempt]);
useEffect(() => {
if (mode !== "vertical" || !pages || typeof Image === "undefined" || !verticalUserScrollRef.current) return;
enqueueVerticalPreload(verticalImagePreloadPlan(currentPage, pages.pageCount));
}, [currentPage, enqueueVerticalPreload, mode, pages, retryAttempt]);
useEffect(() => {
const frame = frameRef.current;
const stage = frame?.closest(".reader-stage") as HTMLElement | null;
@ -264,6 +428,7 @@ export function CbzReader({
if (!stage) return;
let frameId = 0;
const markUserScroll = () => {
if (stage.dataset.readerPinchActive === "true") return;
verticalUserScrollRef.current = true;
};
const markUserScrollKey = (event: KeyboardEvent) => {
@ -275,6 +440,8 @@ export function CbzReader({
commitVisiblePage(stage);
};
const onScroll = () => {
if (stage.dataset.readerPinchActive === "true") return;
if (verticalTrackingReadyRef.current) verticalUserScrollRef.current = true;
if (frameId) return;
frameId = requestAnimationFrame(updateVisiblePage);
};
@ -297,6 +464,12 @@ export function CbzReader({
useEffect(() => () => clearVerticalAnchorFrame(), [clearVerticalAnchorFrame]);
useEffect(() => () => clearVerticalInitialSync(), [clearVerticalInitialSync]);
useEffect(
() => () => {
if (imageSizeFlushFrameRef.current !== null) cancelReaderFrame(imageSizeFlushFrameRef.current);
},
[]
);
if (documentError) {
return (
@ -348,14 +521,13 @@ export function CbzReader({
<img
src={api.cbzPageUrl(bookId, item.page)}
alt={item.name}
loading="lazy"
fetchPriority={item.page === currentPage ? "high" : "auto"}
loading={item.page === currentPage ? "eager" : "lazy"}
style={verticalImageStyle(item.page)}
onLoad={(event) => {
const { naturalWidth, naturalHeight } = event.currentTarget;
setImageSizes((current) => ({
...current,
[item.page]: { width: naturalWidth, height: naturalHeight }
}));
preloadedImagesRef.current.set(item.page, { image: event.currentTarget, status: "loaded" });
queueImageSize(item.page, { width: naturalWidth, height: naturalHeight });
}}
onError={() => setPageError(`Page ${item.page} indisponible.`)}
/>

View File

@ -190,6 +190,7 @@ export function PdfReader({ url, page, backHref, zoom, mode, onPageCommit, onCon
const commitVisiblePage = useCallback(
(stage: HTMLElement, allowInitialCommit = false) => {
if (!verticalTrackingReadyRef.current) return;
if (stage.dataset.readerPinchActive === "true") return;
if (!allowInitialCommit && !verticalUserScrollRef.current) return;
if (allowInitialCommit && verticalInitialSyncDoneRef.current) return;
const stageRect = stage.getBoundingClientRect();
@ -330,6 +331,7 @@ export function PdfReader({ url, page, backHref, zoom, mode, onPageCommit, onCon
if (!stage) return;
let frameId = 0;
const markUserScroll = () => {
if (stage.dataset.readerPinchActive === "true") return;
verticalUserScrollRef.current = true;
};
const markUserScrollKey = (event: KeyboardEvent) => {
@ -341,6 +343,7 @@ export function PdfReader({ url, page, backHref, zoom, mode, onPageCommit, onCon
commitVisiblePage(stage);
};
const onScroll = () => {
if (stage.dataset.readerPinchActive === "true") return;
if (frameId) return;
frameId = requestAnimationFrame(updateVisiblePage);
};

View File

@ -25,7 +25,7 @@ export type ReaderControls = {
export type ReaderZoomControls = {
zoom: number;
onZoomChange: (zoom: number) => void;
onZoomChange: (zoom: number, anchor?: ReaderZoomAnchor) => void;
onZoomOut: () => void;
onZoomIn: () => void;
onZoomReset: () => void;
@ -47,6 +47,64 @@ type ReaderShellProps = {
children: ReactNode;
};
export type ReaderZoomAnchor = {
page: number;
clientX: number;
clientY: number;
offsetX: number;
offsetY: number;
ratioX: number;
ratioY: number;
};
type ReaderPinchGesture = {
distance: number;
zoom: number;
anchor: ReaderZoomAnchor | null;
};
function readerTouchPoint(touch: Touch): ReaderPoint {
return { x: touch.clientX, y: touch.clientY };
}
function readerTouchMidpoint(first: Touch, second: Touch): ReaderPoint {
return {
x: (first.clientX + second.clientX) / 2,
y: (first.clientY + second.clientY) / 2
};
}
function readerZoomAnchorAt(point: ReaderPoint): ReaderZoomAnchor | null {
const pageElement = document.elementFromPoint(point.x, point.y)?.closest("[data-reader-page]");
if (!(pageElement instanceof HTMLElement)) return null;
const page = Number(pageElement.dataset.readerPage);
const rect = pageElement.getBoundingClientRect();
if (!Number.isFinite(page) || rect.width <= 0 || rect.height <= 0) return null;
return {
page,
clientX: point.x,
clientY: point.y,
offsetX: point.x - rect.left,
offsetY: point.y - rect.top,
ratioX: Math.max(0, Math.min(1, (point.x - rect.left) / rect.width)),
ratioY: Math.max(0, Math.min(1, (point.y - rect.top) / rect.height))
};
}
function restoreReaderZoomAnchor(stage: HTMLElement, anchor: ReaderZoomAnchor) {
const target = stage.querySelector<HTMLElement>(`[data-reader-page="${anchor.page}"]`);
if (!target) return;
const rect = target.getBoundingClientRect();
stage.scrollLeft += rect.left + anchor.offsetX - anchor.clientX;
stage.scrollTop += rect.top + anchor.offsetY - anchor.clientY;
}
function scheduleReaderZoomAnchorRestore(stage: HTMLElement, anchor: ReaderZoomAnchor) {
requestAnimationFrame(() => {
requestAnimationFrame(() => restoreReaderZoomAnchor(stage, anchor));
});
}
export function ReaderShell({ title, backHref, error, onRetry, controls, zoomControls, modeControls, children }: ReaderShellProps) {
const readerRef = useRef<HTMLDivElement>(null);
const headerRef = useRef<HTMLElement>(null);
@ -54,7 +112,7 @@ export function ReaderShell({ title, backHref, error, onRetry, controls, zoomCon
const headerHideTimerRef = useRef<number | undefined>(undefined);
const tapStartRef = useRef<ReaderPoint | null>(null);
const activeTouchPointsRef = useRef(new Map<number, ReaderPoint>());
const pinchRef = useRef<{ distance: number; zoom: number } | null>(null);
const pinchRef = useRef<ReaderPinchGesture | null>(null);
const [nativeFullscreen, setNativeFullscreen] = useState(false);
const [fallbackFullscreen, setFallbackFullscreen] = useState(false);
const [headerVisible, setHeaderVisible] = useState(true);
@ -178,26 +236,35 @@ export function ReaderShell({ title, backHref, error, onRetry, controls, zoomCon
const stage = stageRef.current;
if (!stage || !zoomControls) return;
const touchPoint = (touch: Touch): ReaderPoint => ({ x: touch.clientX, y: touch.clientY });
const startPinch = (event: TouchEvent) => {
if (!isMobileReaderViewport() || event.touches.length !== 2) {
pinchRef.current = null;
stage.removeAttribute("data-reader-pinch-active");
return;
}
const distance = readerPointDistance(touchPoint(event.touches[0]), touchPoint(event.touches[1]));
pinchRef.current = { distance, zoom: zoomControls.zoom };
event.preventDefault();
const midpoint = readerTouchMidpoint(event.touches[0], event.touches[1]);
const distance = readerPointDistance(readerTouchPoint(event.touches[0]), readerTouchPoint(event.touches[1]));
stage.dataset.readerPinchActive = "true";
tapStartRef.current = null;
pinchRef.current = { distance, zoom: zoomControls.zoom, anchor: readerZoomAnchorAt(midpoint) };
};
const movePinch = (event: TouchEvent) => {
if (!pinchRef.current || event.touches.length !== 2) return;
event.preventDefault();
const distance = readerPointDistance(touchPoint(event.touches[0]), touchPoint(event.touches[1]));
zoomControls.onZoomChange(readerPinchZoom(pinchRef.current.zoom, pinchRef.current.distance, distance));
const midpoint = readerTouchMidpoint(event.touches[0], event.touches[1]);
const distance = readerPointDistance(readerTouchPoint(event.touches[0]), readerTouchPoint(event.touches[1]));
const anchor = pinchRef.current.anchor ? { ...pinchRef.current.anchor, clientX: midpoint.x, clientY: midpoint.y } : null;
zoomControls.onZoomChange(readerPinchZoom(pinchRef.current.zoom, pinchRef.current.distance, distance), anchor ?? undefined);
if (anchor) scheduleReaderZoomAnchorRestore(stage, anchor);
};
const endPinch = (event: TouchEvent) => {
if (event.touches.length < 2) pinchRef.current = null;
if (event.touches.length >= 2) return;
pinchRef.current = null;
stage.removeAttribute("data-reader-pinch-active");
};
stage.addEventListener("touchstart", startPinch, { passive: true });
stage.addEventListener("touchstart", startPinch, { passive: false });
stage.addEventListener("touchmove", movePinch, { passive: false });
stage.addEventListener("touchend", endPinch);
stage.addEventListener("touchcancel", endPinch);

View File

@ -222,11 +222,30 @@ describe("reader runtime helpers", () => {
expect(verticalBranch).not.toContain('hidden={!imageSizes[item.page]}');
});
it("keeps unloaded CBZ vertical pages visually covered by a page-sized fallback", () => {
it("keeps CBZ vertical image loading on the rendered page elements", () => {
const source = readFileSync(new URL("./CbzReader.tsx", import.meta.url), "utf8");
expect(source).toContain("VERTICAL_IMAGE_PRELOAD_CONCURRENCY = 3");
expect(source).toContain("VERTICAL_IMAGE_PRELOAD_DELAY_MS = 100");
expect(source).toContain("VERTICAL_IMAGE_PRELOAD_DEEP_OFFSET = 90");
expect(source).toContain('VERTICAL_IMAGE_PRELOAD_PRIORITIES: PreloadPriority[] = ["adjacent", "deep"]');
expect(source).toContain("scheduler.queues.deep = []");
expect(source).toContain("!verticalUserScrollRef.current");
expect(source).toContain("preloadSchedulerRef");
expect(source).toContain("verticalImagePreloadPlan");
expect(source).toContain("new Image()");
expect(source).toContain('fetchPriority={item.page === currentPage ? "high" : "auto"}');
expect(source).toContain('loading={item.page === currentPage ? "eager" : "lazy"}');
expect(source).toContain("queueImageSize");
expect(source).toContain("if (verticalTrackingReadyRef.current) verticalUserScrollRef.current = true");
});
it("keeps unloaded PDF and CBZ vertical pages visually covered by a page-sized fallback", () => {
const styles = readFileSync(new URL("../styles/app.css", import.meta.url), "utf8");
expect(styles).toContain(".pdf-page-frame-vertical > canvas,\n.pdf-page-frame-vertical > .reader-fallback");
expect(styles).toContain(".comic-page-frame-vertical > img,\n.comic-page-frame-vertical > .reader-fallback");
expect(styles).toContain(".comic-page-frame-vertical > .reader-fallback {\n width: 100%;");
expect(styles).toContain(".pdf-page-frame-vertical > .reader-fallback,\n.comic-page-frame-vertical > .reader-fallback {\n width: 100%;");
expect(styles).toContain("min-height: inherit");
expect(styles).toContain("background: #f7f0df");
});
@ -259,6 +278,22 @@ describe("reader runtime helpers", () => {
expect(source).toContain("onPointerUp={handleReaderPointerUp}");
expect(source).toContain("readerPinchZoom");
expect(source).toContain("zoomControls.onZoomChange");
expect(source).toContain("data-reader-pinch-active");
expect(source).toContain("scheduleReaderZoomAnchorRestore");
expect(source).toContain("event.preventDefault()");
});
it("keeps mobile pinch zoom isolated from native zoom and vertical page commits", () => {
const styles = readFileSync(new URL("../styles/app.css", import.meta.url), "utf8");
const shell = readFileSync(new URL("./ReaderShell.tsx", import.meta.url), "utf8");
const pdfReader = readFileSync(new URL("./PdfReader.tsx", import.meta.url), "utf8");
const cbzReader = readFileSync(new URL("./CbzReader.tsx", import.meta.url), "utf8");
expect(styles).toContain("touch-action: pan-x pan-y");
expect(shell).toContain('stage.dataset.readerPinchActive = "true"');
expect(shell).toContain('stage.removeAttribute("data-reader-pinch-active")');
expect(pdfReader).toContain('if (stage.dataset.readerPinchActive === "true") return;');
expect(cbzReader).toContain('if (stage.dataset.readerPinchActive === "true") return;');
});
it("detects fullscreen support and active fullscreen element", () => {

View File

@ -363,6 +363,15 @@ h2 {
gap: 10px;
}
.continue-grid {
--continue-visible-rows: 6;
--continue-tile-block-size: 118px;
max-height: calc((var(--continue-tile-block-size) * var(--continue-visible-rows)) + (10px * (var(--continue-visible-rows) - 1)));
overflow-y: auto;
padding-right: 4px;
scrollbar-gutter: stable;
}
.job-list {
max-height: 350px;
overflow: auto;
@ -409,6 +418,8 @@ h2 {
.continue-tile {
grid-template-columns: 52px minmax(0, 1fr);
align-items: start;
block-size: var(--continue-tile-block-size);
overflow: hidden;
}
.continue-cover {
@ -1023,6 +1034,7 @@ main.app-main-reader .reader-page {
min-width: 0;
padding: 0;
overflow: auto;
touch-action: pan-x pan-y;
}
.reader-content {
@ -1114,11 +1126,14 @@ main.app-main-reader .reader-page {
padding: 0 12px;
}
.pdf-page-frame-vertical > canvas,
.pdf-page-frame-vertical > .reader-fallback,
.comic-page-frame-vertical > img,
.comic-page-frame-vertical > .reader-fallback {
grid-area: 1 / 1;
}
.pdf-page-frame-vertical > .reader-fallback,
.comic-page-frame-vertical > .reader-fallback {
width: 100%;
min-height: inherit;