Last active
March 19, 2025 15:35
-
-
Save themorgantown/1317060efc0fa6782b9b2306e2805998 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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