first pass at mobile interaction

Hunter
Wednesday, October 22nd, 2025 at 2:37 PM

script.js+283-27
style.css+1 

script.js
 24  let dragState = null;
 25  let resizeState = null;
 26  let highestZIndex = 0;
 27 +let touchState = null; // For tracking multi-touch gestures
 28 +let longPressTimer = null; // For detecting long press to enable pan mode
 29  
 30  // Helper function to calculate image dimensions from aspect ratio
 31  function calculateImageDimensions(aspectRatio) {

325  		bringToFront(container);
326  	});
327  
328 +	// Unified pointer start handler
329 +	function handlePointerStart(clientX, clientY, isTouch = false) {
330 +		// Clear any existing timers from other images
331 +		if (longPressTimer) {
332 +			clearTimeout(longPressTimer);
333 +			longPressTimer = null;
334 +		}
335 +
336 +		// Lock cursor to move during drag operation
337 +		document.body.style.cursor = 'move';
338 +		document.body.classList.add('dragging');
339 +
340 +		dragState = {
341 +			image: imageData,
342 +			startX: clientX,
343 +			startY: clientY,
344 +			startXCell: imageData.xCell,
345 +			startYCell: imageData.yCell,
346 +			isPanMode: false, // Will be set to true after long press
347 +			isTouch: isTouch
348 +		};
349 +
350 +		container.classList.add('dragging');
351 +
352 +		// For touch, set up long press timer to enable pan mode
353 +		if (isTouch) {
354 +			longPressTimer = setTimeout(() => {
355 +				if (dragState && dragState.image === imageData &&
356 +				    dragState.startXCell === imageData.xCell &&
357 +				    dragState.startYCell === imageData.yCell) {
358 +					// User held for 0.5 seconds without moving to a new cell - enable pan mode
359 +					dragState.isPanMode = true;
360 +					dragState.initialPanX = imageData.panX;
361 +					dragState.initialPanY = imageData.panY;
362 +				}
363 +			}, 500);
364 +		}
365 +	}
366 +
367 +	// Touch event handlers for mobile
368 +	container.addEventListener('touchstart', (e) => {
369 +		if (e.target.classList.contains('resize-handle')) return;
370 +
371 +		// Bring to front on touch
372 +		bringToFront(container);
373 +
374 +		if (e.touches.length === 1) {
375 +			// Single touch - start drag (or long press for pan)
376 +			e.preventDefault();
377 +			const touch = e.touches[0];
378 +			handlePointerStart(touch.clientX, touch.clientY, true);
379 +		} else if (e.touches.length === 2) {
380 +			// Two fingers - prepare for pinch/pan
381 +			e.preventDefault();
382 +
383 +			// Cancel any ongoing drag and long press timer
384 +			if (longPressTimer) {
385 +				clearTimeout(longPressTimer);
386 +				longPressTimer = null;
387 +			}
388 +			if (dragState) {
389 +				dragState.image.container.classList.remove('dragging');
390 +				dragState = null;
391 +				document.body.style.cursor = '';
392 +				document.body.classList.remove('dragging');
393 +			}
394 +
395 +			const touch1 = e.touches[0];
396 +			const touch2 = e.touches[1];
397 +
398 +			// Calculate initial distance for pinch detection
399 +			const dx = touch2.clientX - touch1.clientX;
400 +			const dy = touch2.clientY - touch1.clientY;
401 +			const distance = Math.sqrt(dx * dx + dy * dy);
402 +
403 +			// Calculate center point
404 +			const centerX = (touch1.clientX + touch2.clientX) / 2;
405 +			const centerY = (touch1.clientY + touch2.clientY) / 2;
406 +
407 +			touchState = {
408 +				image: imageData,
409 +				initialDistance: distance,
410 +				lastDistance: distance,
411 +				initialScale: imageData.userScale,
412 +				lastCenterX: centerX,
413 +				lastCenterY: centerY,
414 +				lastPanX: imageData.panX,
415 +				lastPanY: imageData.panY
416 +			};
417 +		}
418 +	}, { passive: false });
419 +
420 +	container.addEventListener('touchmove', (e) => {
421 +		if (e.touches.length === 2 && touchState && touchState.image === imageData) {
422 +			// Two finger pinch/pan
423 +			e.preventDefault();
424 +
425 +			const touch1 = e.touches[0];
426 +			const touch2 = e.touches[1];
427 +
428 +			// Calculate current distance
429 +			const dx = touch2.clientX - touch1.clientX;
430 +			const dy = touch2.clientY - touch1.clientY;
431 +			const distance = Math.sqrt(dx * dx + dy * dy);
432 +
433 +			// Calculate center point
434 +			const centerX = (touch1.clientX + touch2.clientX) / 2;
435 +			const centerY = (touch1.clientY + touch2.clientY) / 2;
436 +
437 +			// Detect if this is primarily a pinch or a pan
438 +			const distanceChange = Math.abs(distance - touchState.lastDistance);
439 +			const centerMoveX = centerX - touchState.lastCenterX;
440 +			const centerMoveY = centerY - touchState.lastCenterY;
441 +			const centerMovement = Math.sqrt(centerMoveX * centerMoveX + centerMoveY * centerMoveY);
442 +
443 +			// If distance changed significantly more than center moved, treat as pinch
444 +			if (distanceChange > centerMovement * 0.5) {
445 +				// Pinch zoom
446 +				const rect = container.getBoundingClientRect();
447 +				const cursorX = centerX - rect.left - rect.width / 2;
448 +				const cursorY = centerY - rect.top - rect.height / 2;
449 +
450 +				const oldUserScale = imageData.userScale;
451 +				const oldTotalScale = imageData.baseScale * oldUserScale;
452 +				const scaleFactor = distance / touchState.initialDistance;
453 +				const newUserScale = Math.max(1, Math.min(5, touchState.initialScale * scaleFactor));
454 +				const newTotalScale = imageData.baseScale * newUserScale;
455 +
456 +				// Transform cursor position to account for rotation
457 +				const angle = -imageData.rotation * Math.PI / 180;
458 +				const cos = Math.cos(angle);
459 +				const sin = Math.sin(angle);
460 +				const rotatedCursorX = cursorX * cos - cursorY * sin;
461 +				const rotatedCursorY = cursorX * sin + cursorY * cos;
462 +
463 +				// Calculate new pan to keep zoom centered on pinch point
464 +				imageData.panX = rotatedCursorX * (1/newTotalScale - 1/oldTotalScale) + touchState.lastPanX;
465 +				imageData.panY = rotatedCursorY * (1/newTotalScale - 1/oldTotalScale) + touchState.lastPanY;
466 +				imageData.userScale = newUserScale;
467 +
468 +				clampPan(imageData);
469 +				touchState.lastPanX = imageData.panX;
470 +				touchState.lastPanY = imageData.panY;
471 +			} else {
472 +				// Two-finger pan
473 +				const deltaX = centerMoveX;
474 +				const deltaY = centerMoveY;
475 +
476 +				// Transform deltas to account for image rotation
477 +				const angle = -imageData.rotation * Math.PI / 180;
478 +				const cos = Math.cos(angle);
479 +				const sin = Math.sin(angle);
480 +				const rotatedDeltaX = deltaX * cos - deltaY * sin;
481 +				const rotatedDeltaY = deltaX * sin + deltaY * cos;
482 +
483 +				// Convert screen-space delta to pre-scale image space
484 +				// Note: positive delta means moving fingers right/down, which should move image right/down
485 +				const totalScale = imageData.baseScale * imageData.userScale;
486 +				imageData.panX = touchState.lastPanX + rotatedDeltaX / totalScale;
487 +				imageData.panY = touchState.lastPanY + rotatedDeltaY / totalScale;
488 +
489 +				clampPan(imageData);
490 +				touchState.lastPanX = imageData.panX;
491 +				touchState.lastPanY = imageData.panY;
492 +			}
493 +
494 +			touchState.lastDistance = distance;
495 +			touchState.lastCenterX = centerX;
496 +			touchState.lastCenterY = centerY;
497 +
498 +			updateImagePosition(imageData);
499 +		}
500 +	}, { passive: false });
501 +
502 +	container.addEventListener('touchend', () => {
503 +		if (touchState && touchState.image === imageData) {
504 +			touchState = null;
505 +		}
506 +	}, { passive: false });
507 +
508 +	container.addEventListener('touchcancel', () => {
509 +		if (touchState && touchState.image === imageData) {
510 +			touchState = null;
511 +		}
512 +	}, { passive: false });
513 +
514  	// Moving / Deleting / Duplicating
515  	container.addEventListener('mousedown', (e) => {
516  		if (e.target.classList.contains('resize-handle')) return;

619  			return;
620  		}
621  
    -		// Lock cursor to move during drag operation
    -		document.body.style.cursor = 'move';
    -		document.body.classList.add('dragging');
    -
    -		dragState = {
    -			image: imageData,
    -			startX: e.clientX,
    -			startY: e.clientY,
    -			startXCell: imageData.xCell,
    -			startYCell: imageData.yCell
    -		};
    -		container.classList.add('dragging');
622 +		handlePointerStart(e.clientX, e.clientY);
623  	});
624  
625  	// Resizing

645  			};
646  			container.classList.add('resizing');
647  		});
648 +
649 +		// Touch support for resize handles
650 +		handle.addEventListener('touchstart', (e) => {
651 +			e.preventDefault();
652 +			e.stopPropagation();
653 +
654 +			const touch = e.touches[0];
655 +			document.body.classList.add('resizing');
656 +
657 +			resizeState = {
658 +				image: imageData,
659 +				direction: handle.dataset.direction,
660 +				startX: touch.clientX,
661 +				startY: touch.clientY,
662 +				startXCell: imageData.xCell,
663 +				startYCell: imageData.yCell,
664 +				startWidthCells: imageData.widthCells,
665 +				startHeightCells: imageData.heightCells
666 +			};
667 +			container.classList.add('resizing');
668 +		}, { passive: false });
669  	});
670  
671  	// Pan and Zoom with wheel events (macOS trackpad gestures)

734  	}, { passive: false });
735  }
736  
    -document.addEventListener('mousemove', (e) => {
737 +function handleMove(clientX, clientY) {
738  	if (dragState) {
    -		const dx = e.clientX - dragState.startX;
    -		const dy = e.clientY - dragState.startY;
739 +		const dx = clientX - dragState.startX;
740 +		const dy = clientY - dragState.startY;
741  
    -		const cellSize = getCellSize();
    -		const dxCells = Math.round(dx / cellSize.width);
    -		const dyCells = Math.round(dy / cellSize.height);
742 +		if (dragState.isPanMode) {
743 +			// Pan mode - move the image within its container
744 +			const imageData = dragState.image;
745  
    -		const newXCell = Math.max(0, Math.min(GRID_COLS - dragState.image.widthCells, dragState.startXCell + dxCells));
    -		const newYCell = Math.max(0, Math.min(GRID_ROWS - dragState.image.heightCells, dragState.startYCell + dyCells));
746 +			// Transform deltas to account for image rotation
747 +			const angle = -imageData.rotation * Math.PI / 180;
748 +			const cos = Math.cos(angle);
749 +			const sin = Math.sin(angle);
750 +			const rotatedDeltaX = dx * cos - dy * sin;
751 +			const rotatedDeltaY = dx * sin + dy * cos;
752  
    -		dragState.image.xCell = newXCell;
    -		dragState.image.yCell = newYCell;
    -		updateImagePosition(dragState.image);
753 +			// Convert screen-space delta to pre-scale image space
754 +			const totalScale = imageData.baseScale * imageData.userScale;
755 +			imageData.panX = dragState.initialPanX + rotatedDeltaX / totalScale;
756 +			imageData.panY = dragState.initialPanY + rotatedDeltaY / totalScale;
757 +
758 +			clampPan(imageData);
759 +			updateImagePosition(imageData);
760 +		} else {
761 +			// Normal drag mode - move the image container on the grid
762 +			const cellSize = getCellSize();
763 +			const dxCells = Math.round(dx / cellSize.width);
764 +			const dyCells = Math.round(dy / cellSize.height);
765 +
766 +			const newXCell = Math.max(0, Math.min(GRID_COLS - dragState.image.widthCells, dragState.startXCell + dxCells));
767 +			const newYCell = Math.max(0, Math.min(GRID_ROWS - dragState.image.heightCells, dragState.startYCell + dyCells));
768 +
769 +			// If the image moved to a new cell, cancel the long press timer
770 +			if (dragState.isTouch && longPressTimer &&
771 +			    (newXCell !== dragState.startXCell || newYCell !== dragState.startYCell)) {
772 +				clearTimeout(longPressTimer);
773 +				longPressTimer = null;
774 +			}
775 +
776 +			dragState.image.xCell = newXCell;
777 +			dragState.image.yCell = newYCell;
778 +			updateImagePosition(dragState.image);
779 +		}
780  	}
781  
782  	if (resizeState) {
    -		const dx = e.clientX - resizeState.startX;
    -		const dy = e.clientY - resizeState.startY;
783 +		const dx = clientX - resizeState.startX;
784 +		const dy = clientY - resizeState.startY;
785  
786  		const cellSize = getCellSize();
787  		const dxCells = Math.round(dx / cellSize.width);

828  		img.heightCells = newH;
829  		updateImagePosition(img);
830  	}
    -});
831 +}
832  
    -document.addEventListener('mouseup', () => {
833 +function handleEnd() {
834 +	if (longPressTimer) {
835 +		clearTimeout(longPressTimer);
836 +		longPressTimer = null;
837 +	}
838  	if (dragState) {
839  		dragState.image.container.classList.remove('dragging');
840 +		dragState.image.container.style.opacity = '';
841  		dragState = null;
842  		// Restore the default cursor
843  		document.body.style.cursor = '';

850  		document.body.style.cursor = '';
851  		document.body.classList.remove('resizing');
852  	}
853 +}
854 +
855 +document.addEventListener('mousemove', (e) => {
856 +	handleMove(e.clientX, e.clientY);
857 +});
858 +
859 +document.addEventListener('mouseup', () => {
860 +	handleEnd();
861 +});
862 +
863 +document.addEventListener('touchmove', (e) => {
864 +	// Only handle global drag/resize, not image-specific multi-touch
865 +	if ((dragState || resizeState) && e.touches.length === 1) {
866 +		e.preventDefault();
867 +		const touch = e.touches[0];
868 +		handleMove(touch.clientX, touch.clientY);
869 +	}
870 +}, { passive: false });
871 +
872 +document.addEventListener('touchend', () => {
873 +	handleEnd();
874 +});
875 +
876 +document.addEventListener('touchcancel', () => {
877 +	handleEnd();
878  });
879  
880  // Update all image positions when window resizes (for responsive scaling)
style.css
199  	outline-offset: -2px;
200  	transition: outline-color 0.2s;
201  	background: var(--page);
202 +	touch-action: none;
203  }
204  
205  .image-container::after {