fix pan clamping

Hunter
Monday, October 20th, 2025 at 1:34 PM

script.js+22-13

script.js
199  		return { maxPanX: 0, maxPanY: 0 };
200  	}
201  
    -	// Pan coordinates are in the image's original coordinate system (before rotation)
202 +	// Pan coordinates are in the image's original coordinate system (before scale and rotation)
203  	// So we need to calculate bounds based on the original image dimensions
204  	const isRotated90or270 = imageData.rotation % 180 !== 0;
205  

210  	const effectiveContainerWidth = isRotated90or270 ? containerHeight : containerWidth;
211  	const effectiveContainerHeight = isRotated90or270 ? containerWidth : containerHeight;
212  
    -	// Calculate actual rendered size with both base scale and user scale
213 +	// Pan values are in pre-scale image space, but the CSS transform scales them
214 +	// So we need to calculate bounds in pre-scale space
215 +	// The image's natural size minus the container size (in pre-scale space) gives us the overhang
216  	const totalScale = imageData.baseScale * imageData.userScale;
    -	const renderedWidth = imageData.naturalWidth * totalScale;
    -	const renderedHeight = imageData.naturalHeight * totalScale;
217 +	const containerWidthInImageSpace = effectiveContainerWidth / totalScale;
218 +	const containerHeightInImageSpace = effectiveContainerHeight / totalScale;
219  
    -	// Calculate maximum pan in each direction
    -	const maxPanX = Math.max(0, (renderedWidth - effectiveContainerWidth) / 2);
    -	const maxPanY = Math.max(0, (renderedHeight - effectiveContainerHeight) / 2);
220 +	// Calculate maximum pan in each direction (in pre-scale image space)
221 +	const maxPanX = Math.max(0, (imageData.naturalWidth - containerWidthInImageSpace) / 2);
222 +	const maxPanY = Math.max(0, (imageData.naturalHeight - containerHeightInImageSpace) / 2);
223  
224  	return { maxPanX, maxPanY };
225  }

449  		if (e.ctrlKey) {
450  			// Zoom at cursor position
451  			const rect = container.getBoundingClientRect();
    -			const cursorX = e.clientX - rect.left - rect.width / 2;
    -			const cursorY = e.clientY - rect.top - rect.height / 2;
452 +			const cursorX = e.clientX - rect.left - rect.width / 2; // Screen pixels from center
453 +			const cursorY = e.clientY - rect.top - rect.height / 2; // Screen pixels from center
454  
455  			const oldUserScale = imageData.userScale;
456  			const oldTotalScale = imageData.baseScale * oldUserScale;

458  			const newUserScale = Math.max(1, Math.min(5, oldUserScale * (1 + zoomDelta)));
459  			const newTotalScale = imageData.baseScale * newUserScale;
460  
461 +			// Convert cursor position to pre-scale image space
462 +			const cursorXInImageSpace = cursorX / oldTotalScale;
463 +			const cursorYInImageSpace = cursorY / oldTotalScale;
464 +
465  			// Adjust pan to keep the point under cursor fixed during zoom
466 +			// Pan is in pre-scale image space, so we work entirely in that space
467  			const scaleRatio = newTotalScale / oldTotalScale;
    -			imageData.panX = cursorX * (1 - scaleRatio) + imageData.panX * scaleRatio;
    -			imageData.panY = cursorY * (1 - scaleRatio) + imageData.panY * scaleRatio;
468 +			imageData.panX = cursorXInImageSpace * (1 - scaleRatio) + imageData.panX * scaleRatio;
469 +			imageData.panY = cursorYInImageSpace * (1 - scaleRatio) + imageData.panY * scaleRatio;
470  			imageData.userScale = newUserScale;
471  
472  			// Clamp after zooming to prevent whitespace

482  			const rotatedDeltaX = e.deltaX * cos - e.deltaY * sin;
483  			const rotatedDeltaY = e.deltaX * sin + e.deltaY * cos;
484  
    -			imageData.panX -= rotatedDeltaX;
    -			imageData.panY -= rotatedDeltaY;
485 +			// Convert screen-space delta to pre-scale image space
486 +			const totalScale = imageData.baseScale * imageData.userScale;
487 +			imageData.panX -= rotatedDeltaX / totalScale;
488 +			imageData.panY -= rotatedDeltaY / totalScale;
489  
490  			// Clamp pan to prevent whitespace
491  			clampPan(imageData);