mirror of
https://github.com/imfing/hextra.git
synced 2025-10-13 20:41:52 -04:00

- Introduced minimum and maximum scale limits for zoom functionality to prevent excessive scaling. - Enhanced gesture state management for pinch and drag interactions, improving user experience during zoom. - Updated event handling to ensure smoother transitions between single and multi-touch interactions, including better tap detection logic. - Adjusted logic to maintain consistent pan behavior during zoom adjustments, ensuring a more intuitive interaction.
509 lines
16 KiB
JavaScript
509 lines
16 KiB
JavaScript
(function () {
|
|
'use strict';
|
|
|
|
function createOverlayFromTarget(targetImg) {
|
|
const src = targetImg.currentSrc || targetImg.src;
|
|
const alt = targetImg.alt || "";
|
|
if (!src) return;
|
|
|
|
const rect = targetImg.getBoundingClientRect();
|
|
|
|
const overlay = document.createElement("div");
|
|
overlay.className = "hextra-zoom-image-overlay";
|
|
overlay.setAttribute("role", "dialog");
|
|
overlay.setAttribute("aria-modal", "true");
|
|
overlay.setAttribute("aria-label", alt || "Zoomed image");
|
|
|
|
const img = document.createElement("img");
|
|
img.className = "hextra-zoom-image loading";
|
|
img.src = src;
|
|
if (alt) img.alt = alt;
|
|
|
|
// Center-origin positioning for cleaner transforms
|
|
const startCX = rect.left + rect.width / 2;
|
|
const startCY = rect.top + rect.height / 2;
|
|
img.style.position = "fixed";
|
|
img.style.left = startCX + "px";
|
|
img.style.top = startCY + "px";
|
|
img.style.width = rect.width + "px";
|
|
img.style.height = rect.height + "px";
|
|
img.style.transformOrigin = "center center";
|
|
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);
|
|
|
|
// Image loaded handler
|
|
img.addEventListener('load', function () {
|
|
img.classList.remove('loading');
|
|
img.classList.add('loaded');
|
|
});
|
|
|
|
// Gesture state management
|
|
let isPinching = false;
|
|
let isDragging = false;
|
|
let isInteracting = false;
|
|
let pinchEndTimer = null;
|
|
|
|
const pointers = new Map(); // pointerId -> {x, y, startX, startY}
|
|
const SCALE_MIN = 1;
|
|
const SCALE_MAX = 5;
|
|
|
|
let gestureState = {
|
|
scale: 1,
|
|
panX: 0,
|
|
panY: 0,
|
|
startScale: 1,
|
|
startPanX: 0,
|
|
startPanY: 0,
|
|
initialDistance: 0,
|
|
initialMidpointX: 0,
|
|
initialMidpointY: 0,
|
|
dragStartX: 0,
|
|
dragStartY: 0,
|
|
dragPanX: 0,
|
|
dragPanY: 0
|
|
};
|
|
|
|
// Utility functions
|
|
function getDistance(p1, p2) {
|
|
const dx = p2.x - p1.x;
|
|
const dy = p2.y - p1.y;
|
|
return Math.sqrt(dx * dx + dy * dy);
|
|
}
|
|
|
|
function getMidpoint(p1, p2) {
|
|
return {
|
|
x: (p1.x + p2.x) / 2,
|
|
y: (p1.y + p2.y) / 2
|
|
};
|
|
}
|
|
|
|
function setInteracting(value) {
|
|
isInteracting = value;
|
|
if (value) {
|
|
overlay.classList.add('interacting');
|
|
} else {
|
|
overlay.classList.remove('interacting');
|
|
}
|
|
}
|
|
|
|
// Pointer event handlers
|
|
let tapCandidate = false;
|
|
let tapStartX = 0;
|
|
let tapStartY = 0;
|
|
let tapStartTime = 0;
|
|
|
|
function onPointerDown(e) {
|
|
e.preventDefault();
|
|
|
|
if (typeof overlay.setPointerCapture === 'function') {
|
|
try {
|
|
overlay.setPointerCapture(e.pointerId);
|
|
} catch (err) {
|
|
// ignore pointer capture failures (e.g. Safari)
|
|
}
|
|
}
|
|
|
|
const pointerData = {
|
|
x: e.clientX,
|
|
y: e.clientY,
|
|
startX: e.clientX,
|
|
startY: e.clientY
|
|
};
|
|
|
|
pointers.set(e.pointerId, pointerData);
|
|
|
|
if (pointers.size === 1) {
|
|
isDragging = false;
|
|
if (gestureState.scale > SCALE_MIN) {
|
|
setInteracting(true);
|
|
}
|
|
|
|
// Set drag baseline so a single finger can pan when zoomed
|
|
gestureState.dragStartX = e.clientX;
|
|
gestureState.dragStartY = e.clientY;
|
|
gestureState.dragPanX = gestureState.panX;
|
|
gestureState.dragPanY = gestureState.panY;
|
|
|
|
tapCandidate = true;
|
|
tapStartX = e.clientX;
|
|
tapStartY = e.clientY;
|
|
tapStartTime = (typeof performance !== 'undefined' ? performance.now() : Date.now());
|
|
} else if (pointers.size === 2) {
|
|
isPinching = true;
|
|
isDragging = false;
|
|
setInteracting(true);
|
|
tapCandidate = false;
|
|
|
|
const pts = Array.from(pointers.values());
|
|
gestureState.initialDistance = getDistance(pts[0], pts[1]) || 1;
|
|
gestureState.startScale = gestureState.scale;
|
|
gestureState.startPanX = gestureState.panX;
|
|
gestureState.startPanY = gestureState.panY;
|
|
|
|
const midpoint = getMidpoint(pts[0], pts[1]);
|
|
gestureState.initialMidpointX = midpoint.x;
|
|
gestureState.initialMidpointY = midpoint.y;
|
|
|
|
if (pinchEndTimer) {
|
|
clearTimeout(pinchEndTimer);
|
|
pinchEndTimer = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
function onPointerMove(e) {
|
|
const pointer = pointers.get(e.pointerId);
|
|
if (!pointer) return;
|
|
|
|
e.preventDefault();
|
|
|
|
pointer.x = e.clientX;
|
|
pointer.y = e.clientY;
|
|
|
|
if (pointers.size === 2) {
|
|
const pts = Array.from(pointers.values());
|
|
const currentDistance = getDistance(pts[0], pts[1]);
|
|
if (!currentDistance) return;
|
|
|
|
const currentMidpoint = getMidpoint(pts[0], pts[1]);
|
|
|
|
const distanceRatio = currentDistance / (gestureState.initialDistance || currentDistance);
|
|
let nextScale = gestureState.startScale * distanceRatio;
|
|
nextScale = Math.max(SCALE_MIN, Math.min(SCALE_MAX, nextScale));
|
|
|
|
const totalStart = baseScale * gestureState.startScale;
|
|
const totalNext = baseScale * nextScale;
|
|
|
|
let nextPanX = gestureState.panX;
|
|
let nextPanY = gestureState.panY;
|
|
|
|
if (totalStart > 0) {
|
|
const startOffsetX = gestureState.initialMidpointX - final.cx - gestureState.startPanX;
|
|
const startOffsetY = gestureState.initialMidpointY - final.cy - gestureState.startPanY;
|
|
const currentOffsetX = currentMidpoint.x - final.cx;
|
|
const currentOffsetY = currentMidpoint.y - final.cy;
|
|
const ratio = totalNext / totalStart;
|
|
|
|
nextPanX = currentOffsetX - ratio * startOffsetX;
|
|
nextPanY = currentOffsetY - ratio * startOffsetY;
|
|
}
|
|
|
|
gestureState.scale = nextScale;
|
|
gestureState.panX = nextPanX;
|
|
gestureState.panY = nextPanY;
|
|
|
|
tapCandidate = false;
|
|
applyTransform();
|
|
} else if (pointers.size === 1) {
|
|
const moveX = pointer.x - gestureState.dragStartX;
|
|
const moveY = pointer.y - gestureState.dragStartY;
|
|
const dragThreshold = 6;
|
|
|
|
if (!isDragging) {
|
|
const distanceSq = moveX * moveX + moveY * moveY;
|
|
if (gestureState.scale > SCALE_MIN && distanceSq > dragThreshold * dragThreshold) {
|
|
isDragging = true;
|
|
tapCandidate = false;
|
|
setInteracting(true);
|
|
gestureState.dragPanX = gestureState.panX;
|
|
gestureState.dragPanY = gestureState.panY;
|
|
}
|
|
}
|
|
|
|
if (isDragging) {
|
|
gestureState.panX = gestureState.dragPanX + moveX;
|
|
gestureState.panY = gestureState.dragPanY + moveY;
|
|
applyTransform();
|
|
} else {
|
|
const cancelTapThreshold = 10;
|
|
if (
|
|
Math.abs(pointer.x - tapStartX) > cancelTapThreshold ||
|
|
Math.abs(pointer.y - tapStartY) > cancelTapThreshold
|
|
) {
|
|
tapCandidate = false;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function onPointerUp(e) {
|
|
if (typeof overlay.releasePointerCapture === 'function') {
|
|
try {
|
|
overlay.releasePointerCapture(e.pointerId);
|
|
} catch (err) {
|
|
// ignore release failures
|
|
}
|
|
}
|
|
|
|
pointers.delete(e.pointerId);
|
|
|
|
if (pointers.size === 0) {
|
|
const now = (typeof performance !== 'undefined' ? performance.now() : Date.now());
|
|
const duration = now - tapStartTime;
|
|
const shouldClose = tapCandidate && !isPinching && !isDragging && duration < 300;
|
|
|
|
if (shouldClose) {
|
|
close();
|
|
}
|
|
|
|
tapCandidate = false;
|
|
isDragging = false;
|
|
|
|
if (isPinching) {
|
|
pinchEndTimer = setTimeout(() => {
|
|
isPinching = false;
|
|
}, 180);
|
|
} else {
|
|
isPinching = false;
|
|
}
|
|
|
|
gestureState.startScale = gestureState.scale;
|
|
gestureState.startPanX = gestureState.panX;
|
|
gestureState.startPanY = gestureState.panY;
|
|
|
|
setTimeout(() => setInteracting(false), 120);
|
|
} else if (pointers.size === 1) {
|
|
isPinching = false;
|
|
isDragging = false;
|
|
|
|
const remaining = Array.from(pointers.values())[0];
|
|
remaining.startX = remaining.x;
|
|
remaining.startY = remaining.y;
|
|
|
|
gestureState.dragStartX = remaining.x;
|
|
gestureState.dragStartY = remaining.y;
|
|
gestureState.dragPanX = gestureState.panX;
|
|
gestureState.dragPanY = gestureState.panY;
|
|
|
|
if (gestureState.scale <= SCALE_MIN) {
|
|
setTimeout(() => setInteracting(false), 120);
|
|
}
|
|
}
|
|
}
|
|
|
|
function onPointerCancel(e) {
|
|
onPointerUp(e);
|
|
}
|
|
|
|
// Mouse wheel zoom and scroll handling
|
|
function onWheel(e) {
|
|
e.preventDefault();
|
|
|
|
// If it's a regular scroll (not pinch), dismiss the overlay
|
|
if (!e.ctrlKey && !e.metaKey) {
|
|
// Regular scroll - dismiss overlay gracefully
|
|
close();
|
|
return;
|
|
}
|
|
|
|
// Handle trackpad pinch (ctrl+wheel or cmd+wheel on Mac)
|
|
const delta = e.deltaY;
|
|
const scaleFactor = 0.01;
|
|
const zoomSpeed = Math.exp(-delta * scaleFactor);
|
|
|
|
const prevScale = gestureState.scale;
|
|
const unclamped = prevScale * zoomSpeed;
|
|
const nextScale = Math.max(SCALE_MIN, Math.min(SCALE_MAX, unclamped));
|
|
|
|
if (Math.abs(nextScale - prevScale) > 0.0001) {
|
|
const totalPrev = baseScale * prevScale;
|
|
const totalNext = baseScale * nextScale;
|
|
|
|
if (totalPrev > 0) {
|
|
const anchorOffsetX = e.clientX - final.cx;
|
|
const anchorOffsetY = e.clientY - final.cy;
|
|
const ratio = totalNext / totalPrev;
|
|
|
|
gestureState.panX = anchorOffsetX + (gestureState.panX - anchorOffsetX) * ratio;
|
|
gestureState.panY = anchorOffsetY + (gestureState.panY - anchorOffsetY) * ratio;
|
|
}
|
|
}
|
|
|
|
gestureState.scale = nextScale;
|
|
gestureState.startScale = nextScale;
|
|
gestureState.startPanX = gestureState.panX;
|
|
gestureState.startPanY = gestureState.panY;
|
|
|
|
setInteracting(true);
|
|
applyTransform();
|
|
setTimeout(() => setInteracting(false), 150);
|
|
}
|
|
|
|
// Keyboard navigation
|
|
function onKeyDown(e) {
|
|
if (e.key === "Escape") {
|
|
close();
|
|
} else if (e.key === "+" || e.key === "=") {
|
|
gestureState.scale = Math.min(SCALE_MAX, gestureState.scale * 1.2);
|
|
gestureState.startScale = gestureState.scale;
|
|
gestureState.startPanX = gestureState.panX;
|
|
gestureState.startPanY = gestureState.panY;
|
|
applyTransform();
|
|
} else if (e.key === "-") {
|
|
gestureState.scale = Math.max(SCALE_MIN, gestureState.scale / 1.2);
|
|
gestureState.startScale = gestureState.scale;
|
|
gestureState.startPanX = gestureState.panX;
|
|
gestureState.startPanY = gestureState.panY;
|
|
applyTransform();
|
|
} else if (e.key === "0") {
|
|
gestureState.scale = 1;
|
|
gestureState.panX = 0;
|
|
gestureState.panY = 0;
|
|
gestureState.startScale = gestureState.scale;
|
|
gestureState.startPanX = 0;
|
|
gestureState.startPanY = 0;
|
|
applyTransform();
|
|
}
|
|
}
|
|
|
|
// Apply transforms
|
|
let final = computeFinal();
|
|
let baseScale = final.scale;
|
|
|
|
function computeFinal() {
|
|
const vv = window.visualViewport;
|
|
const vw = vv ? vv.width : window.innerWidth;
|
|
const vh = vv ? vv.height : window.innerHeight;
|
|
const vx = vv ? vv.offsetLeft : 0;
|
|
const vy = vv ? vv.offsetTop : 0;
|
|
|
|
const margin = 20;
|
|
const maxW = Math.min(vw - margin * 2, 1200);
|
|
const maxH = vh - margin * 2;
|
|
|
|
const scale = Math.min(maxW / rect.width, maxH / rect.height, 2);
|
|
const centerX = vx + vw / 2;
|
|
const centerY = vy + vh / 2;
|
|
|
|
return { cx: centerX, cy: centerY, scale };
|
|
}
|
|
|
|
function applyTransform() {
|
|
img.style.left = final.cx + "px";
|
|
img.style.top = final.cy + "px";
|
|
|
|
overlay.style.transform = "none";
|
|
|
|
const totalScale = baseScale * gestureState.scale;
|
|
const transform = `translate3d(-50%, -50%, 0) translate3d(${gestureState.panX}px, ${gestureState.panY}px, 0) scale(${totalScale})`;
|
|
img.style.transform = transform;
|
|
}
|
|
|
|
// Close function
|
|
function close(immediate = false) {
|
|
overlay.classList.add("closing");
|
|
|
|
// Animate back to original position
|
|
img.style.left = startCX + "px";
|
|
img.style.top = startCY + "px";
|
|
img.style.transform = `translate3d(-50%, -50%, 0) scale(1)`;
|
|
|
|
// Cleanup event listeners
|
|
cleanup();
|
|
|
|
if (immediate) {
|
|
overlay.remove();
|
|
return;
|
|
}
|
|
|
|
// Remove after animation
|
|
const done = () => overlay.remove();
|
|
overlay.addEventListener("transitionend", done, { once: true });
|
|
img.addEventListener("transitionend", done, { once: true });
|
|
|
|
// Fallback removal
|
|
setTimeout(() => {
|
|
if (overlay.parentNode) {
|
|
overlay.remove();
|
|
}
|
|
}, 400);
|
|
}
|
|
|
|
function cleanup() {
|
|
window.removeEventListener("keydown", onKeyDown, true);
|
|
overlay.removeEventListener("wheel", onWheel);
|
|
overlay.removeEventListener("pointermove", onPointerMove);
|
|
overlay.removeEventListener("pointerdown", onPointerDown);
|
|
overlay.removeEventListener("pointerup", onPointerUp);
|
|
overlay.removeEventListener("pointercancel", onPointerCancel);
|
|
window.removeEventListener("resize", onResize);
|
|
if (window.visualViewport) {
|
|
window.visualViewport.removeEventListener("resize", onResize);
|
|
window.visualViewport.removeEventListener("scroll", onResize);
|
|
}
|
|
if (pinchEndTimer) {
|
|
clearTimeout(pinchEndTimer);
|
|
}
|
|
}
|
|
|
|
// Handle viewport changes
|
|
function onResize() {
|
|
final = computeFinal();
|
|
baseScale = final.scale;
|
|
gestureState.startScale = gestureState.scale;
|
|
gestureState.startPanX = gestureState.panX;
|
|
gestureState.startPanY = gestureState.panY;
|
|
applyTransform();
|
|
}
|
|
|
|
// Setup event listeners
|
|
overlay.addEventListener("wheel", onWheel, { passive: false });
|
|
overlay.addEventListener("pointermove", onPointerMove);
|
|
overlay.addEventListener("pointerdown", onPointerDown);
|
|
overlay.addEventListener("pointerup", onPointerUp);
|
|
overlay.addEventListener("pointercancel", onPointerCancel);
|
|
window.addEventListener("keydown", onKeyDown, true);
|
|
window.addEventListener("resize", onResize, { passive: true });
|
|
|
|
if (window.visualViewport) {
|
|
window.visualViewport.addEventListener("resize", onResize, { passive: true });
|
|
window.visualViewport.addEventListener("scroll", onResize, { passive: true });
|
|
}
|
|
|
|
// Add to DOM
|
|
document.body.appendChild(overlay);
|
|
|
|
// Trigger opening animation
|
|
requestAnimationFrame(() => {
|
|
overlay.classList.add("show");
|
|
applyTransform();
|
|
});
|
|
}
|
|
|
|
// Initialize after DOM ready
|
|
function init() {
|
|
const container = document.querySelector(".content");
|
|
if (!container) return;
|
|
|
|
container.addEventListener("click", function (e) {
|
|
const target = e.target;
|
|
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.hasAttribute('data-no-zoom')) return;
|
|
|
|
if (e.defaultPrevented) return;
|
|
if (typeof e.button === 'number' && e.button !== 0) return;
|
|
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
|
|
|
const interactiveParent = target.closest('a[href], button, [role="button"], summary, label');
|
|
if (interactiveParent && interactiveParent !== target) {
|
|
return;
|
|
}
|
|
|
|
e.preventDefault();
|
|
|
|
createOverlayFromTarget(target);
|
|
}, true);
|
|
}
|
|
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', init, { once: true });
|
|
} else {
|
|
init();
|
|
}
|
|
})();
|