zoom/pan with trackpad to "crop" image

Hunter
Sunday, October 19th, 2025 at 11:06 PM

index.html+187-17

index.html
 77  			background: var(--color-bg-page);
 78  		}
 79  
    -		.image-container:hover {
    -			outline-color: var(--color-accent);
 80 +		.image-container::after {
 81 +			content: '';
 82 +			position: absolute;
 83 +			top: 0;
 84 +			left: 0;
 85 +			right: 0;
 86 +			bottom: 0;
 87 +			pointer-events: none;
 88 +			border: 2px solid transparent;
 89 +			transition: border-color 0.2s;
 90 +			z-index: 1;
 91 +			overflow: hidden;
 92 +		}
 93 +
 94 +		.image-container .image-wrapper {
 95 +			position: absolute;
 96 +			top: 0;
 97 +			left: 0;
 98 +			right: 0;
 99 +			bottom: 0;
100 +			overflow: hidden;
101 +		}
102 +
103 +		.image-container:hover::after {
104 +			border-color: var(--color-accent);
105  		}
106  
107  		.image-container.dragging {
108  			opacity: 0.7;
    -			outline-color: var(--color-accent);
109  		}
110  
    -		.image-container.resizing {
    -			outline-color: var(--color-accent);
111 +		.image-container.dragging::after {
112 +			border-color: var(--color-accent);
113 +		}
114 +
115 +		.image-container.resizing::after {
116 +			border-color: var(--color-accent);
117  		}
118  
119  		body.resizing *,

122  		}
123  
124  		.image-container img {
    -			width: 100%;
    -			height: 100%;
    -			object-fit: cover;
125 +			position: absolute;
126 +			top: 50%;
127 +			left: 50%;
128  			display: block;
129  			pointer-events: none;
130 +			transform-origin: center center;
131 +			transition: none;
132  		}
133  
134  		.resize-handle {

136  			background: var(--color-accent);
137  			opacity: 0;
138  			transition: opacity 0.2s;
139 +			z-index: 2;
140  		}
141  
142  		.image-container:hover .resize-handle {

152  		.resize-handle.corner::before {
153  			content: '';
154  			position: absolute;
    -			top: 0;
    -			left: 0;
    -			width: 100%;
    -			height: 100%;
155 +			width: 10px;
156 +			height: 10px;
157  			background: var(--color-accent);
158  			border-radius: 50%;
159  		}

166  		.resize-handle.s { bottom: -5px; left: 5px; right: 5px; height: 10px; cursor: s-resize; }
167  		.resize-handle.e { right: -5px; top: 5px; bottom: 5px; width: 10px; cursor: e-resize; }
168  		.resize-handle.w { left: -5px; top: 5px; bottom: 5px; width: 10px; cursor: w-resize; }
169 +
170  		.resize-handle.ne { top: -5px; right: -5px; cursor: ne-resize; }
171 +		.resize-handle.ne::before { top: 0; right: 0; }
172 +
173  		.resize-handle.nw { top: -5px; left: -5px; cursor: nw-resize; }
174 +		.resize-handle.nw::before { top: 0; left: 0; }
175 +
176  		.resize-handle.se { bottom: -5px; right: -5px; cursor: se-resize; }
177 +		.resize-handle.se::before { bottom: 0; right: 0; }
178 +
179  		.resize-handle.sw { bottom: -5px; left: -5px; cursor: sw-resize; }
180 +		.resize-handle.sw::before { bottom: 0; left: 0; }
181  
182  		@media print {
183  			* {

238  				background: var(--color-white) !important;
239  			}
240  
241 +			.image-container .image-wrapper {
242 +				position: absolute !important;
243 +				top: 0 !important;
244 +				left: 0 !important;
245 +				right: 0 !important;
246 +				bottom: 0 !important;
247 +				overflow: hidden !important;
248 +			}
249 +
250  			.image-container img {
    -				width: 100% !important;
    -				height: 100% !important;
    -				object-fit: cover !important;
251 +				position: absolute !important;
252 +				top: 50% !important;
253 +				left: 50% !important;
254 +				display: block !important;
255 +				pointer-events: none !important;
256 +				/* Preserve the transform from the screen view */
257  			}
258  
259  			.resize-handle {

397  				container.style.zIndex = zIndex;
398  			}
399  
400 +			const wrapper = document.createElement('div');
401 +			wrapper.className = 'image-wrapper';
402  			const img = document.createElement('img');
403  			img.src = src;
    -			container.appendChild(img);
404 +			wrapper.appendChild(img);
405 +			container.appendChild(wrapper);
406  
407  			// Add resize handles
408  			const handles = ['n', 's', 'e', 'w', 'ne', 'nw', 'se', 'sw'];

413  				container.appendChild(handle);
414  			});
415  
    -			const imageData = { container, xCell, yCell, widthCells, heightCells };
416 +			const imageData = {
417 +				container,
418 +				xCell,
419 +				yCell,
420 +				widthCells,
421 +				heightCells,
422 +				// Image positioning within container (in pixels, relative to center)
423 +				panX: 0,
424 +				panY: 0,
425 +				userScale: 1,  // user zoom level (1-5)
426 +				// Store natural image dimensions for calculations
427 +				naturalWidth: 0,
428 +				naturalHeight: 0,
429 +				baseScale: 1  // scale needed to cover container
430 +			};
431 +
432 +			// Calculate dimensions and scale once image loads
433 +			img.onload = () => {
434 +				imageData.naturalWidth = img.naturalWidth;
435 +				imageData.naturalHeight = img.naturalHeight;
436 +
437 +				// Calculate base scale to cover container (mimics object-fit: cover)
438 +				const containerWidth = imageData.widthCells * CELL_SIZE_PX;
439 +				const containerHeight = imageData.heightCells * CELL_SIZE_PX;
440 +				const scaleX = containerWidth / img.naturalWidth;
441 +				const scaleY = containerHeight / img.naturalHeight;
442 +				imageData.baseScale = Math.max(scaleX, scaleY);
443 +
444 +				updateImagePosition(imageData);
445 +			};
446  			images.push(imageData);
447  
448  			updateImagePosition(imageData);

451  			setupImageHandlers(imageData);
452  		}
453  
454 +		function calculatePanBounds(imageData) {
455 +			const containerWidth = imageData.widthCells * CELL_SIZE_PX;
456 +			const containerHeight = imageData.heightCells * CELL_SIZE_PX;
457 +
458 +			if (imageData.naturalWidth === 0 || imageData.naturalHeight === 0) {
459 +				return { maxPanX: 0, maxPanY: 0 };
460 +			}
461 +
462 +			// Calculate actual rendered size with both base scale and user scale
463 +			const totalScale = imageData.baseScale * imageData.userScale;
464 +			const renderedWidth = imageData.naturalWidth * totalScale;
465 +			const renderedHeight = imageData.naturalHeight * totalScale;
466 +
467 +			// Calculate maximum pan in each direction
468 +			const maxPanX = Math.max(0, (renderedWidth - containerWidth) / 2);
469 +			const maxPanY = Math.max(0, (renderedHeight - containerHeight) / 2);
470 +
471 +			return { maxPanX, maxPanY };
472 +		}
473 +
474 +		function clampPan(imageData) {
475 +			const { maxPanX, maxPanY } = calculatePanBounds(imageData);
476 +			imageData.panX = Math.max(-maxPanX, Math.min(maxPanX, imageData.panX));
477 +			imageData.panY = Math.max(-maxPanY, Math.min(maxPanY, imageData.panY));
478 +		}
479 +
480  		function updateImagePosition(img) {
481  			img.container.style.left = img.xCell * CELL_SIZE_PX + 'px';
482  			img.container.style.top = img.yCell * CELL_SIZE_PX + 'px';
483  			img.container.style.width = img.widthCells * CELL_SIZE_PX + 'px';
484  			img.container.style.height = img.heightCells * CELL_SIZE_PX + 'px';
485 +
486 +			// Recalculate baseScale if container size changed
487 +			if (img.naturalWidth > 0 && img.naturalHeight > 0) {
488 +				const containerWidth = img.widthCells * CELL_SIZE_PX;
489 +				const containerHeight = img.heightCells * CELL_SIZE_PX;
490 +				const scaleX = containerWidth / img.naturalWidth;
491 +				const scaleY = containerHeight / img.naturalHeight;
492 +				img.baseScale = Math.max(scaleX, scaleY);
493 +
494 +				// Reclamp pan after recalculating base scale
495 +				clampPan(img);
496 +			}
497 +
498 +			// Apply image positioning and scale using transform
499 +			const imgElement = img.container.querySelector('img');
500 +			if (imgElement) {
501 +				const totalScale = img.baseScale * img.userScale;
502 +				// Transform: translate from center (-50%, -50%), then pan, then scale
503 +				imgElement.style.transform = `translate(-50%, -50%) translate(${img.panX}px, ${img.panY}px) scale(${totalScale})`;
504 +			}
505  		}
506  
507  		function setupImageHandlers(imageData) {

561  					container.classList.add('resizing');
562  				});
563  			});
564 +
565 +			// Pan and Zoom with wheel events (macOS trackpad gestures)
566 +			container.addEventListener('wheel', (e) => {
567 +				// Don't interfere with dragging or resizing
568 +				if (dragState || resizeState) return;
569 +
570 +				e.preventDefault();
571 +				e.stopPropagation();
572 +
573 +				// Detect pinch zoom (ctrlKey is set for pinch gestures on macOS trackpad)
574 +				if (e.ctrlKey) {
575 +					// Zoom under cursor
576 +					const rect = container.getBoundingClientRect();
577 +					const mouseX = e.clientX - rect.left;
578 +					const mouseY = e.clientY - rect.top;
579 +
580 +					// Calculate mouse position relative to container center
581 +					const centerX = rect.width / 2;
582 +					const centerY = rect.height / 2;
583 +					const offsetX = mouseX - centerX;
584 +					const offsetY = mouseY - centerY;
585 +
586 +					const oldUserScale = imageData.userScale;
587 +					const zoomDelta = -e.deltaY * 0.01;
588 +					const newUserScale = Math.max(1, Math.min(5, oldUserScale * (1 + zoomDelta)));
589 +
590 +					// Adjust pan to keep point under cursor stationary
591 +					const scaleFactor = newUserScale / oldUserScale - 1;
592 +					imageData.panX -= offsetX * scaleFactor;
593 +					imageData.panY -= offsetY * scaleFactor;
594 +					imageData.userScale = newUserScale;
595 +
596 +					// Clamp after zooming to prevent whitespace
597 +					clampPan(imageData);
598 +				} else {
599 +					// Pan (two-finger scroll on macOS trackpad)
600 +					imageData.panX -= e.deltaX;
601 +					imageData.panY -= e.deltaY;
602 +
603 +					// Clamp pan to prevent whitespace
604 +					clampPan(imageData);
605 +				}
606 +
607 +				updateImagePosition(imageData);
608 +			}, { passive: false });
609  		}
610  
611  		document.addEventListener('mousemove', (e) => {