feat(image-zoom): improve zoom interactions and tap detection

- Added `will-change: transform` to CSS for better performance during zoom.
- Enhanced JavaScript to support tap detection for closing the zoom overlay with minimal movement.
- Updated zoom behavior to ensure scaling occurs from the center of the overlay.
- Refined event handling to prevent unintended interactions and improve user experience.
This commit is contained in:
Xin
2025-09-11 23:20:06 +01:00
parent 9e50415b94
commit be49fe6f57
2 changed files with 53 additions and 37 deletions

View File

@@ -16,6 +16,7 @@
/* Prevent iOS bounce */ /* Prevent iOS bounce */
position: fixed; position: fixed;
overflow: hidden; overflow: hidden;
will-change: transform;
} }
.hextra-zoom-image-overlay.show { .hextra-zoom-image-overlay.show {
@@ -68,7 +69,7 @@
} }
} }
.content img:not([data-no-zoom]) { .content img:not([data-no-zoom]):not(.not-prose img) {
cursor: zoom-in; cursor: zoom-in;
} }

View File

@@ -30,6 +30,8 @@
img.style.transformOrigin = "center center"; img.style.transformOrigin = "center center";
img.style.transform = "translate3d(-50%, -50%, 0) scale(1)"; img.style.transform = "translate3d(-50%, -50%, 0) scale(1)";
// Ensure overlay scales from center when zooming the whole overlay
overlay.style.transformOrigin = "center center";
overlay.appendChild(img); overlay.appendChild(img);
// Image loaded handler // Image loaded handler
@@ -81,6 +83,11 @@
} }
// Pointer event handlers // Pointer event handlers
let tapCandidate = false;
let tapStartX = 0;
let tapStartY = 0;
let tapStartTime = 0;
function onPointerDown(e) { function onPointerDown(e) {
e.preventDefault(); e.preventDefault();
@@ -97,6 +104,12 @@
setInteracting(true); setInteracting(true);
gestureState.lastPanX = gestureState.panX; gestureState.lastPanX = gestureState.panX;
gestureState.lastPanY = gestureState.panY; gestureState.lastPanY = gestureState.panY;
// Tap detection setup
tapCandidate = true;
tapStartX = e.clientX;
tapStartY = e.clientY;
tapStartTime = (typeof performance !== 'undefined' ? performance.now() : Date.now());
} else if (pointers.size === 2) { } else if (pointers.size === 2) {
// Two touches - start pinch // Two touches - start pinch
isDragging = false; isDragging = false;
@@ -150,6 +163,8 @@
gestureState.panY = gestureState.lastPanY + panDeltaY; gestureState.panY = gestureState.lastPanY + panDeltaY;
} }
// Any multi-touch movement cancels tap
tapCandidate = false;
applyTransform(); applyTransform();
} else if (isDragging && pointers.size === 1) { } else if (isDragging && pointers.size === 1) {
// Handle drag/pan // Handle drag/pan
@@ -159,6 +174,12 @@
gestureState.panX = gestureState.lastPanX + deltaX; gestureState.panX = gestureState.lastPanX + deltaX;
gestureState.panY = gestureState.lastPanY + deltaY; gestureState.panY = gestureState.lastPanY + deltaY;
// Significant movement cancels tap
const moveThreshold = 10;
if (Math.abs(pointer.x - tapStartX) > moveThreshold || Math.abs(pointer.y - tapStartY) > moveThreshold) {
tapCandidate = false;
}
applyTransform(); applyTransform();
} }
} }
@@ -170,19 +191,13 @@
// All pointers released // All pointers released
isDragging = false; isDragging = false;
setInteracting(false); setInteracting(false);
// Tap-to-close when there was minimal movement and short duration
// Check if it was a tap (no significant movement) const now = (typeof performance !== 'undefined' ? performance.now() : Date.now());
const pointer = e; const duration = now - tapStartTime;
const moveThreshold = 10; if (tapCandidate && !isPinching && duration < 300) {
const timeSinceDown = e.timeStamp;
// If minimal movement and not pinching, treat as tap to close
if (!isPinching && Math.abs(pointer.clientX - e.clientX) < moveThreshold &&
Math.abs(pointer.clientY - e.clientY) < moveThreshold) {
// Click on image or overlay should close
close(); close();
} }
tapCandidate = false;
if (isPinching) { if (isPinching) {
pinchEndTimer = setTimeout(() => { pinchEndTimer = setTimeout(() => {
isPinching = false; isPinching = false;
@@ -220,9 +235,13 @@
const scaleFactor = 0.01; const scaleFactor = 0.01;
const zoomSpeed = Math.exp(-delta * scaleFactor); const zoomSpeed = Math.exp(-delta * scaleFactor);
// Minimum scale is 1 (original zoom level), don't allow zooming out smaller const prevScale = gestureState.scale;
gestureState.scale = Math.max(1, Math.min(5, gestureState.scale * zoomSpeed)); const unclamped = prevScale * zoomSpeed;
const nextScale = Math.max(1, Math.min(5, unclamped));
// Effective scale ratio (applied), use it to anchor zoom around cursor
const f = prevScale === 0 ? 1 : (nextScale / prevScale);
if (f !== 1) {
// Zoom towards mouse position // Zoom towards mouse position
const rect = img.getBoundingClientRect(); const rect = img.getBoundingClientRect();
const centerX = rect.left + rect.width / 2; const centerX = rect.left + rect.width / 2;
@@ -230,10 +249,12 @@
const offsetX = e.clientX - centerX; const offsetX = e.clientX - centerX;
const offsetY = e.clientY - centerY; const offsetY = e.clientY - centerY;
// Adjust pan to zoom towards cursor // Adjust pan to keep cursor's point stable
const scaleDiff = gestureState.scale - (gestureState.scale / zoomSpeed); gestureState.panX -= offsetX * (f - 1);
gestureState.panX -= offsetX * scaleDiff * 0.1; gestureState.panY -= offsetY * (f - 1);
gestureState.panY -= offsetY * scaleDiff * 0.1; }
gestureState.scale = nextScale;
setInteracting(true); setInteracting(true);
applyTransform(); applyTransform();
@@ -283,7 +304,11 @@
function applyTransform() { function applyTransform() {
img.style.left = final.cx + "px"; img.style.left = final.cx + "px";
img.style.top = final.cy + "px"; img.style.top = final.cy + "px";
const totalScale = baseScale * gestureState.scale;
const overlayScale = Math.max(1, gestureState.scale);
overlay.style.transform = overlayScale > 1 ? `scale(${overlayScale})` : "none";
const totalScale = baseScale;
const transform = `translate3d(-50%, -50%, 0) translate3d(${gestureState.panX}px, ${gestureState.panY}px, 0) scale(${totalScale})`; const transform = `translate3d(-50%, -50%, 0) translate3d(${gestureState.panX}px, ${gestureState.panY}px, 0) scale(${totalScale})`;
img.style.transform = transform; img.style.transform = transform;
} }
@@ -342,11 +367,6 @@
applyTransform(); applyTransform();
} }
// Prevent click propagation on image
img.addEventListener("click", function (e) {
e.stopPropagation();
});
// Setup event listeners // Setup event listeners
overlay.addEventListener("wheel", onWheel, { passive: false }); overlay.addEventListener("wheel", onWheel, { passive: false });
overlay.addEventListener("pointermove", onPointerMove); overlay.addEventListener("pointermove", onPointerMove);
@@ -361,13 +381,6 @@
window.visualViewport.addEventListener("scroll", onResize, { passive: true }); window.visualViewport.addEventListener("scroll", onResize, { passive: true });
} }
// Click outside to close (on overlay background)
overlay.addEventListener("click", function (e) {
if (e.target === overlay) {
close();
}
});
// Add to DOM // Add to DOM
document.body.appendChild(overlay); document.body.appendChild(overlay);
@@ -386,6 +399,8 @@
container.addEventListener("click", function (e) { container.addEventListener("click", function (e) {
const target = e.target; const target = e.target;
if (!(target instanceof HTMLImageElement)) return; if (!(target instanceof HTMLImageElement)) return;
// Only allow images inside `.content` that are NOT within a `.not-prose` block
if (target.closest('.not-prose')) return;
if (target.dataset.noZoom === "" || target.dataset.noZoom === "true") return; if (target.dataset.noZoom === "" || target.dataset.noZoom === "true") return;
e.preventDefault(); e.preventDefault();