first pass at mobile interaction
Hunter
Wednesday, October 22nd, 2025 at 2:37 PM
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)
199 outline-offset: -2px;
200 transition: outline-color 0.2s;
201 background: var(--page);
202 + touch-action: none;
203 }
204
205 .image-container::after {