Files
OpenSign/apps/OpenSign/src/hook/usePdfPinchZoom.js
T
2026-05-15 15:44:29 +00:00

184 lines
7.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useRef } from "react";
const MIN_SCALE = 1.0;
const MAX_SCALE = 4.0;
// Calculate straight-line distance between two touch points
function getDistance(t1, t2) {
const dx = t1.clientX - t2.clientX;
const dy = t1.clientY - t2.clientY;
return Math.sqrt(dx * dx + dy * dy);
}
// Calculate the midpoint (center) between two touch points
function getMidpoint(t1, t2) {
return {
x: (t1.clientX + t2.clientX) / 2,
y: (t1.clientY + t2.clientY) / 2
};
}
// Clamp a value between min and max bounds
function clamp(val, min, max) {
return Math.min(Math.max(val, min), max);
}
/**
* usePdfPinchZoom
*
* Handles two-finger pinch-to-zoom on the PDF scroll container.
*
* Key design decision — flicker-free zooming:
* During the gesture, we mutate the transform div's style DIRECTLY via DOM
* (zero React re-renders). Only on touchEnd do we call setScale() once,
* committing the final value to React state. This eliminates the flicker
* that occurs when setScale() fires on every touchmove frame and forces
* the entire PDF tree (Document, Pages, Widgets) to re-render mid-gesture.
*
* @param {React.RefObject} containerRef - Ref to the outer scrollable div (scrollRef in RenderPdf)
* @param {number} scale - Current scale value from React state
* @param {function} setScale - React state setter for scale
* @param {function} setZoomPercent - React state setter for zoom % display
*/
export default function usePdfPinchZoom(
containerRef,
scale,
setScale,
setZoomPercent
) {
// Keep a mutable ref in sync with scale prop so event handlers always
// read the latest value without being re-registered on every scale change
const scaleRef = useRef(scale);
// Stores the pending requestAnimationFrame id (null = none pending)
const rafRef = useRef(null);
// Sync scaleRef whenever the scale prop changes from outside (e.g. zoom buttons)
useEffect(() => {
scaleRef.current = scale;
}, [scale]);
useEffect(() => {
const el = containerRef?.current;
if (!el) return;
// Locate the inner content div that has `transform: scale(...)` applied.
// We use an explicit data attribute (data-pdf-transform-target) set on that
// div in RenderPdf.jsx — more reliable than sniffing [style*='transform']
// which could accidentally match other elements.
const getTransformTarget = () =>
el.querySelector("[data-pdf-transform-target]");
// Mutable pinch state — stored as a plain object (not refs/state) because
// we only need it inside event handlers; no React re-render required.
const pinch = {
active: false, // Whether a two-finger gesture is in progress
startDist: 0, // Finger distance at gesture start
startScale: 1, // Scale value at gesture start
startMid: { x: 0, y: 0 }, // Midpoint between fingers at gesture start (viewport coords)
startScrollLeft: 0, // Container scrollLeft at gesture start
startScrollTop: 0, // Container scrollTop at gesture start
currentScale: 1 // Latest scale reached during the gesture
};
// touchstart: record initial finger positions and scroll state
function onTouchStart(e) {
// Only activate for exactly two fingers
if (e.touches.length !== 2) return;
const t1 = e.touches[0];
const t2 = e.touches[1];
pinch.active = true;
pinch.startDist = getDistance(t1, t2);
pinch.startScale = scaleRef.current;
pinch.currentScale = scaleRef.current;
pinch.startMid = getMidpoint(t1, t2);
// Snapshot scroll position so we can recompute it proportionally as scale changes
pinch.startScrollLeft = el.scrollLeft;
pinch.startScrollTop = el.scrollTop;
}
// touchmove: update the DOM transform directly — NO React state updates here
function onTouchMove(e) {
if (!pinch.active || e.touches.length !== 2) return;
// Prevent browser's native scroll/zoom from interfering
e.preventDefault();
const t1 = e.touches[0];
const t2 = e.touches[1];
// Derive new scale from how much the finger distance has changed
const dist = getDistance(t1, t2);
const ratio = dist / pinch.startDist;
const newScale = clamp(pinch.startScale * ratio, MIN_SCALE, MAX_SCALE);
// Skip tiny changes to avoid unnecessary DOM writes and rAF scheduling
if (Math.abs(newScale - pinch.currentScale) < 0.008) return;
pinch.currentScale = newScale;
// Cancel any previously queued frame before scheduling a new one
// (ensures we only process the most recent touch position per frame)
if (rafRef.current) cancelAnimationFrame(rafRef.current);
rafRef.current = requestAnimationFrame(() => {
const transformTarget = getTransformTarget();
if (!transformTarget) return;
// ✅ KEY — mutate the DOM directly, bypassing React entirely.
// This is what prevents flickering: no state update = no re-render
// of <Document>, <Page> components, or widgets during the gesture.
transformTarget.style.transform = `scale(${newScale})`;
// Recompute scroll so the pinch midpoint stays anchored on screen.
// Formula: new scroll = (old scroll × scaleFactor) + (midpoint offset × (scaleFactor - 1))
const rect = el.getBoundingClientRect();
const midRelX = pinch.startMid.x - rect.left;
const midRelY = pinch.startMid.y - rect.top;
const scaleFactor = newScale / pinch.startScale;
el.scrollLeft =
pinch.startScrollLeft * scaleFactor + midRelX * (scaleFactor - 1);
el.scrollTop =
pinch.startScrollTop * scaleFactor + midRelY * (scaleFactor - 1);
});
}
// touchend / touchcancel: commit the final scale to React state exactly once
function onTouchEnd(e) {
// If a second finger is still down, the gesture is still active — wait
if (e.touches.length >= 2) return;
if (!pinch.active) return;
pinch.active = false;
const finalScale = pinch.currentScale;
// ✅ Single setScale call per gesture — triggers exactly one React re-render
// to sync props with what the DOM already shows visually. No visible change,
// no flicker, but state is now correctly updated for the rest of the app.
if (finalScale !== scaleRef.current) {
setScale(finalScale);
if (setZoomPercent) {
// Display value e.g. 150 for 1.5x zoom
setZoomPercent(Math.round(finalScale * 100));
}
}
}
// Attach all listeners. touchmove must be non-passive so e.preventDefault() works.
el.addEventListener("touchstart", onTouchStart, { passive: false });
el.addEventListener("touchmove", onTouchMove, { passive: false });
el.addEventListener("touchend", onTouchEnd, { passive: true });
el.addEventListener("touchcancel", onTouchEnd, { passive: true });
// Cleanup: remove listeners and cancel any in-flight animation frame on unmount
return () => {
el.removeEventListener("touchstart", onTouchStart);
el.removeEventListener("touchmove", onTouchMove);
el.removeEventListener("touchend", onTouchEnd);
el.removeEventListener("touchcancel", onTouchEnd);
if (rafRef.current) cancelAnimationFrame(rafRef.current);
};
}, [containerRef, setScale, setZoomPercent]);
}