From c4fe4d9de6aa17a6afaa3128ee22a34cfca02803 Mon Sep 17 00:00:00 2001 From: Git Agent Date: Fri, 28 Aug 2026 19:08:34 +0200 Subject: [PATCH] =?UTF-8?q?fix(web):=20lecteur=20mobile=20=E2=80=94=20stab?= =?UTF-8?q?iliser=20le=20pinch-zoom=20(ancrage=20du=20point=20m=C3=A9dian?= =?UTF-8?q?=20sous=20les=20doigts=20avec=20restauration=20du=20scroll=20pa?= =?UTF-8?q?r=20page,=20gel=20du=20commit=20vertical=20via=20data-reader-pi?= =?UTF-8?q?nch-active=20pendant=20le=20pincement,=20preventDefault=20sur?= =?UTF-8?q?=20touchstart=20et=20touch-action:=20pan-x=20pan-y=20pour=20blo?= =?UTF-8?q?quer=20le=20zoom=20natif=20=E2=80=94=20correctif=20bug=20#43)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/pages/ReaderPage.tsx | 14 +++- apps/web/src/reader/CbzReader.tsx | 3 + apps/web/src/reader/PdfReader.tsx | 3 + apps/web/src/reader/ReaderShell.tsx | 85 ++++++++++++++++++++--- apps/web/src/reader/readerRuntime.test.ts | 16 +++++ apps/web/src/styles/app.css | 1 + 6 files changed, 110 insertions(+), 12 deletions(-) diff --git a/apps/web/src/pages/ReaderPage.tsx b/apps/web/src/pages/ReaderPage.tsx index 003e721..1e112cd 100644 --- a/apps/web/src/pages/ReaderPage.tsx +++ b/apps/web/src/pages/ReaderPage.tsx @@ -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(`[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) diff --git a/apps/web/src/reader/CbzReader.tsx b/apps/web/src/reader/CbzReader.tsx index b035d40..33bf655 100644 --- a/apps/web/src/reader/CbzReader.tsx +++ b/apps/web/src/reader/CbzReader.tsx @@ -246,6 +246,7 @@ export function CbzReader({ 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(); @@ -427,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) => { @@ -438,6 +440,7 @@ 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); diff --git a/apps/web/src/reader/PdfReader.tsx b/apps/web/src/reader/PdfReader.tsx index 8996bc1..edf720e 100644 --- a/apps/web/src/reader/PdfReader.tsx +++ b/apps/web/src/reader/PdfReader.tsx @@ -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); }; diff --git a/apps/web/src/reader/ReaderShell.tsx b/apps/web/src/reader/ReaderShell.tsx index 20479b5..47f023a 100644 --- a/apps/web/src/reader/ReaderShell.tsx +++ b/apps/web/src/reader/ReaderShell.tsx @@ -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(`[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(null); const headerRef = useRef(null); @@ -54,7 +112,7 @@ export function ReaderShell({ title, backHref, error, onRetry, controls, zoomCon const headerHideTimerRef = useRef(undefined); const tapStartRef = useRef(null); const activeTouchPointsRef = useRef(new Map()); - const pinchRef = useRef<{ distance: number; zoom: number } | null>(null); + const pinchRef = useRef(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); diff --git a/apps/web/src/reader/readerRuntime.test.ts b/apps/web/src/reader/readerRuntime.test.ts index ef1ed0c..f00569e 100644 --- a/apps/web/src/reader/readerRuntime.test.ts +++ b/apps/web/src/reader/readerRuntime.test.ts @@ -278,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", () => { diff --git a/apps/web/src/styles/app.css b/apps/web/src/styles/app.css index 4f6abbb..31faea7 100644 --- a/apps/web/src/styles/app.css +++ b/apps/web/src/styles/app.css @@ -1023,6 +1023,7 @@ main.app-main-reader .reader-page { min-width: 0; padding: 0; overflow: auto; + touch-action: pan-x pan-y; } .reader-content {