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:
@ -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);
|
||||
|
||||
Reference in New Issue
Block a user