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)
This commit is contained in:
@ -5,7 +5,7 @@ import { CbzReader } from "../reader/CbzReader";
|
|||||||
import { EpubReader } from "../reader/EpubReader";
|
import { EpubReader } from "../reader/EpubReader";
|
||||||
import { pageLocator, parseCbrPageLocator, parseCbzPageLocator, parsePdfPageLocator, pdfPagePercent } from "../reader/locators";
|
import { pageLocator, parseCbrPageLocator, parseCbzPageLocator, parsePdfPageLocator, pdfPagePercent } from "../reader/locators";
|
||||||
import { PdfReader } from "../reader/PdfReader";
|
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 { clampReaderZoom, READER_ZOOM_DEFAULT, READER_ZOOM_STEP } from "../reader/readerLayout";
|
||||||
import { majorityVisiblePage, type ReaderMode } from "../reader/readerScroll";
|
import { majorityVisiblePage, type ReaderMode } from "../reader/readerScroll";
|
||||||
import { useReaderPreferences } from "../reader/useReaderPreferences";
|
import { useReaderPreferences } from "../reader/useReaderPreferences";
|
||||||
@ -159,7 +159,7 @@ export function ReaderPage({ bookId }: { bookId: number }) {
|
|||||||
setMode("horizontal");
|
setMode("horizontal");
|
||||||
saveReaderMode("horizontal");
|
saveReaderMode("horizontal");
|
||||||
}, [currentVisiblePage, page, saveReaderMode]);
|
}, [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 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 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;
|
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(() => {
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
if (!stage) return;
|
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.scrollLeft = Math.max(0, stage.scrollWidth * scrollRatioX - stage.clientWidth / 2);
|
||||||
stage.scrollTop = Math.max(0, (stage.scrollHeight - stage.clientHeight) * scrollRatioY);
|
stage.scrollTop = Math.max(0, (stage.scrollHeight - stage.clientHeight) * scrollRatioY);
|
||||||
});
|
});
|
||||||
@ -179,7 +187,7 @@ export function ReaderPage({ bookId }: { bookId: number }) {
|
|||||||
supportsZoom
|
supportsZoom
|
||||||
? {
|
? {
|
||||||
zoom,
|
zoom,
|
||||||
onZoomChange: (nextZoom) => changeZoom(nextZoom),
|
onZoomChange: (nextZoom, anchor) => changeZoom(nextZoom, anchor),
|
||||||
onZoomOut: () => changeZoom((currentZoom) => currentZoom - READER_ZOOM_STEP),
|
onZoomOut: () => changeZoom((currentZoom) => currentZoom - READER_ZOOM_STEP),
|
||||||
onZoomIn: () => changeZoom((currentZoom) => currentZoom + READER_ZOOM_STEP),
|
onZoomIn: () => changeZoom((currentZoom) => currentZoom + READER_ZOOM_STEP),
|
||||||
onZoomReset: () => changeZoom(READER_ZOOM_DEFAULT)
|
onZoomReset: () => changeZoom(READER_ZOOM_DEFAULT)
|
||||||
|
|||||||
@ -246,6 +246,7 @@ export function CbzReader({
|
|||||||
const commitVisiblePage = useCallback(
|
const commitVisiblePage = useCallback(
|
||||||
(stage: HTMLElement, allowInitialCommit = false) => {
|
(stage: HTMLElement, allowInitialCommit = false) => {
|
||||||
if (!pages || !verticalTrackingReadyRef.current) return;
|
if (!pages || !verticalTrackingReadyRef.current) return;
|
||||||
|
if (stage.dataset.readerPinchActive === "true") return;
|
||||||
if (!allowInitialCommit && !verticalUserScrollRef.current) return;
|
if (!allowInitialCommit && !verticalUserScrollRef.current) return;
|
||||||
if (allowInitialCommit && verticalInitialSyncDoneRef.current) return;
|
if (allowInitialCommit && verticalInitialSyncDoneRef.current) return;
|
||||||
const stageRect = stage.getBoundingClientRect();
|
const stageRect = stage.getBoundingClientRect();
|
||||||
@ -427,6 +428,7 @@ export function CbzReader({
|
|||||||
if (!stage) return;
|
if (!stage) return;
|
||||||
let frameId = 0;
|
let frameId = 0;
|
||||||
const markUserScroll = () => {
|
const markUserScroll = () => {
|
||||||
|
if (stage.dataset.readerPinchActive === "true") return;
|
||||||
verticalUserScrollRef.current = true;
|
verticalUserScrollRef.current = true;
|
||||||
};
|
};
|
||||||
const markUserScrollKey = (event: KeyboardEvent) => {
|
const markUserScrollKey = (event: KeyboardEvent) => {
|
||||||
@ -438,6 +440,7 @@ export function CbzReader({
|
|||||||
commitVisiblePage(stage);
|
commitVisiblePage(stage);
|
||||||
};
|
};
|
||||||
const onScroll = () => {
|
const onScroll = () => {
|
||||||
|
if (stage.dataset.readerPinchActive === "true") return;
|
||||||
if (verticalTrackingReadyRef.current) verticalUserScrollRef.current = true;
|
if (verticalTrackingReadyRef.current) verticalUserScrollRef.current = true;
|
||||||
if (frameId) return;
|
if (frameId) return;
|
||||||
frameId = requestAnimationFrame(updateVisiblePage);
|
frameId = requestAnimationFrame(updateVisiblePage);
|
||||||
|
|||||||
@ -190,6 +190,7 @@ export function PdfReader({ url, page, backHref, zoom, mode, onPageCommit, onCon
|
|||||||
const commitVisiblePage = useCallback(
|
const commitVisiblePage = useCallback(
|
||||||
(stage: HTMLElement, allowInitialCommit = false) => {
|
(stage: HTMLElement, allowInitialCommit = false) => {
|
||||||
if (!verticalTrackingReadyRef.current) return;
|
if (!verticalTrackingReadyRef.current) return;
|
||||||
|
if (stage.dataset.readerPinchActive === "true") return;
|
||||||
if (!allowInitialCommit && !verticalUserScrollRef.current) return;
|
if (!allowInitialCommit && !verticalUserScrollRef.current) return;
|
||||||
if (allowInitialCommit && verticalInitialSyncDoneRef.current) return;
|
if (allowInitialCommit && verticalInitialSyncDoneRef.current) return;
|
||||||
const stageRect = stage.getBoundingClientRect();
|
const stageRect = stage.getBoundingClientRect();
|
||||||
@ -330,6 +331,7 @@ export function PdfReader({ url, page, backHref, zoom, mode, onPageCommit, onCon
|
|||||||
if (!stage) return;
|
if (!stage) return;
|
||||||
let frameId = 0;
|
let frameId = 0;
|
||||||
const markUserScroll = () => {
|
const markUserScroll = () => {
|
||||||
|
if (stage.dataset.readerPinchActive === "true") return;
|
||||||
verticalUserScrollRef.current = true;
|
verticalUserScrollRef.current = true;
|
||||||
};
|
};
|
||||||
const markUserScrollKey = (event: KeyboardEvent) => {
|
const markUserScrollKey = (event: KeyboardEvent) => {
|
||||||
@ -341,6 +343,7 @@ export function PdfReader({ url, page, backHref, zoom, mode, onPageCommit, onCon
|
|||||||
commitVisiblePage(stage);
|
commitVisiblePage(stage);
|
||||||
};
|
};
|
||||||
const onScroll = () => {
|
const onScroll = () => {
|
||||||
|
if (stage.dataset.readerPinchActive === "true") return;
|
||||||
if (frameId) return;
|
if (frameId) return;
|
||||||
frameId = requestAnimationFrame(updateVisiblePage);
|
frameId = requestAnimationFrame(updateVisiblePage);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -25,7 +25,7 @@ export type ReaderControls = {
|
|||||||
|
|
||||||
export type ReaderZoomControls = {
|
export type ReaderZoomControls = {
|
||||||
zoom: number;
|
zoom: number;
|
||||||
onZoomChange: (zoom: number) => void;
|
onZoomChange: (zoom: number, anchor?: ReaderZoomAnchor) => void;
|
||||||
onZoomOut: () => void;
|
onZoomOut: () => void;
|
||||||
onZoomIn: () => void;
|
onZoomIn: () => void;
|
||||||
onZoomReset: () => void;
|
onZoomReset: () => void;
|
||||||
@ -47,6 +47,64 @@ type ReaderShellProps = {
|
|||||||
children: ReactNode;
|
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) {
|
export function ReaderShell({ title, backHref, error, onRetry, controls, zoomControls, modeControls, children }: ReaderShellProps) {
|
||||||
const readerRef = useRef<HTMLDivElement>(null);
|
const readerRef = useRef<HTMLDivElement>(null);
|
||||||
const headerRef = useRef<HTMLElement>(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 headerHideTimerRef = useRef<number | undefined>(undefined);
|
||||||
const tapStartRef = useRef<ReaderPoint | null>(null);
|
const tapStartRef = useRef<ReaderPoint | null>(null);
|
||||||
const activeTouchPointsRef = useRef(new Map<number, ReaderPoint>());
|
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 [nativeFullscreen, setNativeFullscreen] = useState(false);
|
||||||
const [fallbackFullscreen, setFallbackFullscreen] = useState(false);
|
const [fallbackFullscreen, setFallbackFullscreen] = useState(false);
|
||||||
const [headerVisible, setHeaderVisible] = useState(true);
|
const [headerVisible, setHeaderVisible] = useState(true);
|
||||||
@ -178,26 +236,35 @@ export function ReaderShell({ title, backHref, error, onRetry, controls, zoomCon
|
|||||||
const stage = stageRef.current;
|
const stage = stageRef.current;
|
||||||
if (!stage || !zoomControls) return;
|
if (!stage || !zoomControls) return;
|
||||||
|
|
||||||
const touchPoint = (touch: Touch): ReaderPoint => ({ x: touch.clientX, y: touch.clientY });
|
|
||||||
const startPinch = (event: TouchEvent) => {
|
const startPinch = (event: TouchEvent) => {
|
||||||
if (!isMobileReaderViewport() || event.touches.length !== 2) {
|
if (!isMobileReaderViewport() || event.touches.length !== 2) {
|
||||||
pinchRef.current = null;
|
pinchRef.current = null;
|
||||||
|
stage.removeAttribute("data-reader-pinch-active");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const distance = readerPointDistance(touchPoint(event.touches[0]), touchPoint(event.touches[1]));
|
event.preventDefault();
|
||||||
pinchRef.current = { distance, zoom: zoomControls.zoom };
|
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) => {
|
const movePinch = (event: TouchEvent) => {
|
||||||
if (!pinchRef.current || event.touches.length !== 2) return;
|
if (!pinchRef.current || event.touches.length !== 2) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const distance = readerPointDistance(touchPoint(event.touches[0]), touchPoint(event.touches[1]));
|
const midpoint = readerTouchMidpoint(event.touches[0], event.touches[1]);
|
||||||
zoomControls.onZoomChange(readerPinchZoom(pinchRef.current.zoom, pinchRef.current.distance, distance));
|
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) => {
|
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("touchmove", movePinch, { passive: false });
|
||||||
stage.addEventListener("touchend", endPinch);
|
stage.addEventListener("touchend", endPinch);
|
||||||
stage.addEventListener("touchcancel", endPinch);
|
stage.addEventListener("touchcancel", endPinch);
|
||||||
|
|||||||
@ -278,6 +278,22 @@ describe("reader runtime helpers", () => {
|
|||||||
expect(source).toContain("onPointerUp={handleReaderPointerUp}");
|
expect(source).toContain("onPointerUp={handleReaderPointerUp}");
|
||||||
expect(source).toContain("readerPinchZoom");
|
expect(source).toContain("readerPinchZoom");
|
||||||
expect(source).toContain("zoomControls.onZoomChange");
|
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", () => {
|
it("detects fullscreen support and active fullscreen element", () => {
|
||||||
|
|||||||
@ -1023,6 +1023,7 @@ main.app-main-reader .reader-page {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
touch-action: pan-x pan-y;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reader-content {
|
.reader-content {
|
||||||
|
|||||||
Reference in New Issue
Block a user