Skip to content

Instantly share code, notes, and snippets.

@themorgantown
Last active March 19, 2025 15:35
Show Gist options
  • Save themorgantown/1317060efc0fa6782b9b2306e2805998 to your computer and use it in GitHub Desktop.
Save themorgantown/1317060efc0fa6782b9b2306e2805998 to your computer and use it in GitHub Desktop.
// element - DOMHTMLElement that triggered this function being called
// event - event that triggered this function being called
function pinchablev2(hypeDocument, element, event) {
// if it's 'pinchable', let it resize outside of hype scene
// User configurable settings
var PINCHABLE_CONFIG = {
maxScale: 4, // Maximum zoom level
minScale: 1, // Minimum zoom level (enforced strictly)
resetTimeout: 10000, // Auto-reset timeout in milliseconds (10 seconds)
doubleTapResetDelay: 300, // Maximum delay between taps to register as double-tap in milliseconds
snapThreshold: 0.05, // How close to scale 1 before snapping back to original size
// For scales between 1 and 1.5, allow dragging offscreen up to a percentage of the image size (33% default)
maxOffscreenDistance: 800, // Fallback fixed px value for scales outside 1 - 1.5
maxOffscreenPercentage: 0.33, // 33% of the image's width/height for scales between 1 and 1.5
bounceDelay: 300, // Delay in ms before bouncing back an out-of-bounds element
debug: false // Enable/disable debug console logs
};
// Get all elements with the "pinchable" class
var pinchableElements = document.querySelectorAll('.pinchable');
// Apply functionality to each pinchable element
pinchableElements.forEach(function(element) {
// Each element gets its own scope of variables for proper isolation
var scale = 1;
var lastScale = 1;
var lastX = 0, lastY = 0;
var startX = 0, startY = 0;
var initialDistance = 0;
var lastTap = 0;
var resetTimer = null;
var lastInteraction = 0;
var bounceTimer = null;
var isPanning = false;
var initialTransform = {
translateX: 0,
translateY: 0,
scale: 1
};
var initialCssPosition = {
left: null,
top: null,
hasPositioning: false
};
var usesCssPositioning = false;
// Add pending transform object to store calculations during touch motion
var pendingTransform = {
scale: 1,
translateX: 0,
translateY: 0,
hasPending: false
};
// Get initial position and transform
function initializeElementState() {
const style = window.getComputedStyle(element);
// Check for CSS positioning
const left = style.left;
const top = style.top;
if (left && left !== 'auto' && top && top !== 'auto') {
initialCssPosition.left = parseInt(left, 10);
initialCssPosition.top = parseInt(top, 10);
initialCssPosition.hasPositioning = true;
usesCssPositioning = true;
if (PINCHABLE_CONFIG.debug) {
console.log('Initial CSS position detected:', initialCssPosition);
}
}
// Check for transforms
const transform = style.transform || style.webkitTransform;
if (transform !== 'none') {
// Try to parse using DOMMatrix if supported
try {
const matrix = new DOMMatrix(transform);
if (matrix.m11 === matrix.m22) {
initialTransform.scale = matrix.m11;
lastScale = matrix.m11;
scale = matrix.m11;
}
initialTransform.translateX = matrix.e || 0;
initialTransform.translateY = matrix.f || 0;
lastX = initialTransform.translateX;
lastY = initialTransform.translateY;
} catch (e) {
// Fallback: Parse transform string manually
if (transform.includes('translateX')) {
const translateXMatch = transform.match(/translateX\(([^)]+)\)/);
if (translateXMatch && translateXMatch[1]) {
initialTransform.translateX = parseFloat(translateXMatch[1]);
lastX = initialTransform.translateX;
}
}
if (transform.includes('translateY')) {
const translateYMatch = transform.match(/translateY\(([^)]+)\)/);
if (translateYMatch && translateYMatch[1]) {
initialTransform.translateY = parseFloat(translateYMatch[1]);
lastY = initialTransform.translateY;
}
}
if (transform.includes('scale')) {
const scaleMatch = transform.match(/scale\(([^)]+)\)/);
if (scaleMatch && scaleMatch[1]) {
initialTransform.scale = parseFloat(scaleMatch[1]);
lastScale = initialTransform.scale;
scale = initialTransform.scale;
}
}
}
if (PINCHABLE_CONFIG.debug) {
console.log('Initial transform detected:', initialTransform);
}
}
}
function resetTransform() {
scale = initialTransform.scale;
lastScale = initialTransform.scale;
if (usesCssPositioning) {
// Reset to initial CSS positioning
lastX = 0;
lastY = 0;
element.style.left = `${initialCssPosition.left}px`;
element.style.top = `${initialCssPosition.top}px`;
element.style.transform = `scale(${initialTransform.scale})`;
} else {
// Reset to initial transform
lastX = initialTransform.translateX;
lastY = initialTransform.translateY;
element.style.transform = `translate(${initialTransform.translateX}px, ${initialTransform.translateY}px) scale(${initialTransform.scale})`;
}
if (resetTimer) {
clearTimeout(resetTimer);
resetTimer = null;
}
// Reset pending transform
pendingTransform.hasPending = false;
pendingTransform.scale = initialTransform.scale;
pendingTransform.translateX = usesCssPositioning ? 0 : initialTransform.translateX;
pendingTransform.translateY = usesCssPositioning ? 0 : initialTransform.translateY;
}
// New function to apply transformations after touch interaction ends
function applyTransformation() {
if (pendingTransform.hasPending) {
scale = pendingTransform.scale;
// Enforce minimum scale strictly
scale = Math.max(scale, PINCHABLE_CONFIG.minScale);
lastX = pendingTransform.translateX;
lastY = pendingTransform.translateY;
if (usesCssPositioning) {
element.style.left = `${initialCssPosition.left + lastX}px`;
element.style.top = `${initialCssPosition.top + lastY}px`;
element.style.transform = `scale(${scale})`;
} else {
element.style.transform = `translate(${lastX}px, ${lastY}px) scale(${scale})`;
}
lastScale = scale;
pendingTransform.hasPending = false;
}
}
function startResetTimer() {
lastInteraction = Date.now();
if (resetTimer) {
clearTimeout(resetTimer);
}
resetTimer = setTimeout(function() {
resetTransform();
}, PINCHABLE_CONFIG.resetTimeout);
}
// Check if element is out of bounds and correct its position if needed
function checkBounds() {
if (scale <= initialTransform.scale) return; // Only apply bounds when zoomed in beyond initial scale
const rect = element.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
// Determine allowed offscreen distance based on scale
let allowedDistanceX, allowedDistanceY;
if (scale >= 1 && scale <= 1.5) {
allowedDistanceX = element.offsetWidth * PINCHABLE_CONFIG.maxOffscreenPercentage;
allowedDistanceY = element.offsetHeight * PINCHABLE_CONFIG.maxOffscreenPercentage;
} else {
allowedDistanceX = PINCHABLE_CONFIG.maxOffscreenDistance;
allowedDistanceY = PINCHABLE_CONFIG.maxOffscreenDistance;
}
let newX = lastX;
let newY = lastY;
let needsBounce = false;
// Check horizontal bounds
if (rect.left > allowedDistanceX) {
newX -= (rect.left - allowedDistanceX);
needsBounce = true;
}
if (rect.right < viewportWidth - allowedDistanceX) {
newX -= (rect.right - (viewportWidth - allowedDistanceX));
needsBounce = true;
}
// Check vertical bounds
if (rect.top > allowedDistanceY) {
newY -= (rect.top - allowedDistanceY);
needsBounce = true;
}
if (rect.bottom < viewportHeight - allowedDistanceY) {
newY -= (rect.bottom - (viewportHeight - allowedDistanceY));
needsBounce = true;
}
// Apply bounce if needed
if (needsBounce) {
lastX = newX;
lastY = newY;
// Add transition for smooth bounce
element.style.transition = 'transform 0.3s ease-out, left 0.3s ease-out, top 0.3s ease-out';
window.requestAnimationFrame(function() {
if (usesCssPositioning) {
element.style.left = `${initialCssPosition.left + newX}px`;
element.style.top = `${initialCssPosition.top + newY}px`;
element.style.transform = `scale(${scale})`;
} else {
element.style.transform = `translate(${newX}px, ${newY}px) scale(${scale})`;
}
});
// Remove transition after animation completes
setTimeout(() => {
element.style.transition = '';
}, 300);
}
}
// Schedule a bounds check with delay
function scheduleBoundsCheck() {
if (bounceTimer) {
clearTimeout(bounceTimer);
}
bounceTimer = setTimeout(checkBounds, PINCHABLE_CONFIG.bounceDelay);
}
// Initialize by capturing the initial state
initializeElementState();
element.addEventListener("touchstart", function(e) {
e.preventDefault();
// Clear any active transition when user touches
element.style.transition = '';
const currentTime = new Date().getTime();
const tapLength = currentTime - lastTap;
// Double-tap detection for reset
if (tapLength < PINCHABLE_CONFIG.doubleTapResetDelay && tapLength > 0 && e.touches.length === 1) {
resetTransform();
return;
}
lastTap = currentTime;
if (e.touches.length === 1) {
// For CSS positioning, use the current transformed position
if (usesCssPositioning) {
const left = parseInt(element.style.left || initialCssPosition.left, 10);
const top = parseInt(element.style.top || initialCssPosition.top, 10);
startX = e.touches[0].pageX - (lastX || 0);
startY = e.touches[0].pageY - (lastY || 0);
} else {
startX = e.touches[0].pageX - lastX;
startY = e.touches[0].pageY - lastY;
}
isPanning = true;
} else if (e.touches.length === 2) {
var touch1 = e.touches[0];
var touch2 = e.touches[1];
initialDistance = Math.hypot(touch2.pageX - touch1.pageX, touch2.pageY - touch1.pageY);
isPanning = false;
// Initialize pending transform with current values
pendingTransform.scale = scale;
pendingTransform.translateX = lastX;
pendingTransform.translateY = lastY;
}
startResetTimer();
});
element.addEventListener("touchmove", function(e) {
e.preventDefault();
lastInteraction = Date.now();
if (bounceTimer) {
clearTimeout(bounceTimer);
bounceTimer = null;
}
if (e.touches.length === 1) {
// Allow panning when zoomed in beyond initial scale
if (scale > initialTransform.scale * 1.01) {
isPanning = true;
// Store calculated position in pendingTransform
pendingTransform.translateX = e.touches[0].pageX - startX;
pendingTransform.translateY = e.touches[0].pageY - startY;
pendingTransform.hasPending = true;
// During active touch motion, update visual feedback using requestAnimationFrame
window.requestAnimationFrame(function() {
if (usesCssPositioning) {
element.style.left = `${initialCssPosition.left + pendingTransform.translateX}px`;
element.style.top = `${initialCssPosition.top + pendingTransform.translateY}px`;
} else {
element.style.transform = `translate(${pendingTransform.translateX}px, ${pendingTransform.translateY}px) scale(${scale})`;
}
});
startResetTimer();
}
} else if (e.touches.length === 2) {
var touch1 = e.touches[0];
var touch2 = e.touches[1];
var currentDistance = Math.hypot(touch2.pageX - touch1.pageX, touch2.pageY - touch1.pageY);
// Calculate the new scale but store in pendingTransform
if (initialDistance > 0) {
pendingTransform.scale = Math.min(
PINCHABLE_CONFIG.maxScale,
Math.max(PINCHABLE_CONFIG.minScale, lastScale * (currentDistance / initialDistance))
);
pendingTransform.hasPending = true;
// Calculate center point between the two touches
const centerX = (touch1.pageX + touch2.pageX) / 2;
const centerY = (touch1.pageY + touch2.pageY) / 2;
// Get element's position relative to the viewport
const rect = element.getBoundingClientRect();
const elementCenterX = rect.left + rect.width/2;
const elementCenterY = rect.top + rect.height/2;
// Reset to initial position if at or below initial scale
if (pendingTransform.scale <= initialTransform.scale) {
if (usesCssPositioning) {
pendingTransform.translateX = 0;
pendingTransform.translateY = 0;
} else {
pendingTransform.translateX = initialTransform.translateX;
pendingTransform.translateY = initialTransform.translateY;
}
}
// During pinch zoom, provide visual feedback using requestAnimationFrame
window.requestAnimationFrame(function() {
if (usesCssPositioning) {
element.style.left = `${initialCssPosition.left + pendingTransform.translateX}px`;
element.style.top = `${initialCssPosition.top + pendingTransform.translateY}px`;
element.style.transform = `scale(${pendingTransform.scale})`;
} else {
element.style.transform = `translate(${pendingTransform.translateX}px, ${pendingTransform.translateY}px) scale(${pendingTransform.scale})`;
}
});
startResetTimer();
}
}
});
element.addEventListener("touchend", function(e) {
initialDistance = 0;
// Apply pending transformations if any
if (pendingTransform.hasPending) {
applyTransformation();
lastScale = scale;
}
// Snap back if scale is very close to initial scale
if (scale < (initialTransform.scale + PINCHABLE_CONFIG.snapThreshold) &&
scale > (initialTransform.scale - PINCHABLE_CONFIG.snapThreshold)) {
resetTransform();
} else {
startResetTimer();
// Check bounds when panning stops
if (isPanning && scale > initialTransform.scale) {
scheduleBoundsCheck();
}
isPanning = false;
}
// if the element has been scaled below specified threshold, reset it after 500ms
// this is to prevent the element from being too small to interact with
if (scale < initialTransform.scale * 0.9) {
setTimeout(() => {
resetTransform();
}, 500);
}
});
// Handle touch cancellation: start the reset timer on cancellation
element.addEventListener("touchcancel", function(e) {
e.preventDefault();
startResetTimer();
});
});
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment