zoom/pan with trackpad to "crop" image
Hunter
Sunday, October 19th, 2025 at 11:06 PM
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) => {