mirror of
				https://github.com/imfing/hextra.git
				synced 2025-10-31 12:34:53 -04:00 
			
		
		
		
	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:
		| @@ -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; | ||||||
| } | } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -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,20 +235,26 @@ | |||||||
|       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)); | ||||||
|  |  | ||||||
|       // Zoom towards mouse position |       // Effective scale ratio (applied), use it to anchor zoom around cursor | ||||||
|       const rect = img.getBoundingClientRect(); |       const f = prevScale === 0 ? 1 : (nextScale / prevScale); | ||||||
|       const centerX = rect.left + rect.width / 2; |       if (f !== 1) { | ||||||
|       const centerY = rect.top + rect.height / 2; |         // Zoom towards mouse position | ||||||
|       const offsetX = e.clientX - centerX; |         const rect = img.getBoundingClientRect(); | ||||||
|       const offsetY = e.clientY - centerY; |         const centerX = rect.left + rect.width / 2; | ||||||
|  |         const centerY = rect.top + rect.height / 2; | ||||||
|  |         const offsetX = e.clientX - centerX; | ||||||
|  |         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(); | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user
	 Xin
					Xin