Compare commits
11 Commits
5fda6b8895
...
cf8779781f
| Author | SHA1 | Date | |
|---|---|---|---|
| cf8779781f | |||
| 452a4b6455 | |||
| 1d0b389c1e | |||
| a9996cf15f | |||
| c4fe4d9de6 | |||
| 01723c6eb7 | |||
| 7b3b687035 | |||
| 9a5e78af72 | |||
| 03c8657df9 | |||
| 5f63faa648 | |||
| ab77d129d3 |
10
README.md
10
README.md
@ -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`.
|
||||
|
||||
18
apps/web/src/pages/HomePageProgressScroll.test.ts
Normal file
18
apps/web/src/pages/HomePageProgressScroll.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@ -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)
|
||||
|
||||
@ -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.`)}
|
||||
/>
|
||||
|
||||
@ -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);
|
||||
};
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
@ -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;
|
||||
|
||||
Reference in New Issue
Block a user