commit c70ab1274ba9a9ae4b1270a574937edbd97b518d
parent e62d7a633f4734ab679c4bb449e8084f4efc19dc
Author: Hunter
Date:   Wed, 22 Oct 2025 14:37:11 -0400

first pass at mobile interaction

Diffstat:
Mscript.js | 310++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mstyle.css | 1+
2 files changed, 284 insertions(+), 27 deletions(-)

diff --git a/script.js b/script.js @@ -24,6 +24,8 @@ let images = []; let dragState = null; let resizeState = null; let highestZIndex = 0; +let touchState = null; // For tracking multi-touch gestures +let longPressTimer = null; // For detecting long press to enable pan mode // Helper function to calculate image dimensions from aspect ratio function calculateImageDimensions(aspectRatio) { @@ -323,6 +325,192 @@ function setupImageHandlers(imageData) { bringToFront(container); }); + // Unified pointer start handler + function handlePointerStart(clientX, clientY, isTouch = false) { + // Clear any existing timers from other images + if (longPressTimer) { + clearTimeout(longPressTimer); + longPressTimer = null; + } + + // Lock cursor to move during drag operation + document.body.style.cursor = 'move'; + document.body.classList.add('dragging'); + + dragState = { + image: imageData, + startX: clientX, + startY: clientY, + startXCell: imageData.xCell, + startYCell: imageData.yCell, + isPanMode: false, // Will be set to true after long press + isTouch: isTouch + }; + + container.classList.add('dragging'); + + // For touch, set up long press timer to enable pan mode + if (isTouch) { + longPressTimer = setTimeout(() => { + if (dragState && dragState.image === imageData && + dragState.startXCell === imageData.xCell && + dragState.startYCell === imageData.yCell) { + // User held for 0.5 seconds without moving to a new cell - enable pan mode + dragState.isPanMode = true; + dragState.initialPanX = imageData.panX; + dragState.initialPanY = imageData.panY; + } + }, 500); + } + } + + // Touch event handlers for mobile + container.addEventListener('touchstart', (e) => { + if (e.target.classList.contains('resize-handle')) return; + + // Bring to front on touch + bringToFront(container); + + if (e.touches.length === 1) { + // Single touch - start drag (or long press for pan) + e.preventDefault(); + const touch = e.touches[0]; + handlePointerStart(touch.clientX, touch.clientY, true); + } else if (e.touches.length === 2) { + // Two fingers - prepare for pinch/pan + e.preventDefault(); + + // Cancel any ongoing drag and long press timer + if (longPressTimer) { + clearTimeout(longPressTimer); + longPressTimer = null; + } + if (dragState) { + dragState.image.container.classList.remove('dragging'); + dragState = null; + document.body.style.cursor = ''; + document.body.classList.remove('dragging'); + } + + const touch1 = e.touches[0]; + const touch2 = e.touches[1]; + + // Calculate initial distance for pinch detection + const dx = touch2.clientX - touch1.clientX; + const dy = touch2.clientY - touch1.clientY; + const distance = Math.sqrt(dx * dx + dy * dy); + + // Calculate center point + const centerX = (touch1.clientX + touch2.clientX) / 2; + const centerY = (touch1.clientY + touch2.clientY) / 2; + + touchState = { + image: imageData, + initialDistance: distance, + lastDistance: distance, + initialScale: imageData.userScale, + lastCenterX: centerX, + lastCenterY: centerY, + lastPanX: imageData.panX, + lastPanY: imageData.panY + }; + } + }, { passive: false }); + + container.addEventListener('touchmove', (e) => { + if (e.touches.length === 2 && touchState && touchState.image === imageData) { + // Two finger pinch/pan + e.preventDefault(); + + const touch1 = e.touches[0]; + const touch2 = e.touches[1]; + + // Calculate current distance + const dx = touch2.clientX - touch1.clientX; + const dy = touch2.clientY - touch1.clientY; + const distance = Math.sqrt(dx * dx + dy * dy); + + // Calculate center point + const centerX = (touch1.clientX + touch2.clientX) / 2; + const centerY = (touch1.clientY + touch2.clientY) / 2; + + // Detect if this is primarily a pinch or a pan + const distanceChange = Math.abs(distance - touchState.lastDistance); + const centerMoveX = centerX - touchState.lastCenterX; + const centerMoveY = centerY - touchState.lastCenterY; + const centerMovement = Math.sqrt(centerMoveX * centerMoveX + centerMoveY * centerMoveY); + + // If distance changed significantly more than center moved, treat as pinch + if (distanceChange > centerMovement * 0.5) { + // Pinch zoom + const rect = container.getBoundingClientRect(); + const cursorX = centerX - rect.left - rect.width / 2; + const cursorY = centerY - rect.top - rect.height / 2; + + const oldUserScale = imageData.userScale; + const oldTotalScale = imageData.baseScale * oldUserScale; + const scaleFactor = distance / touchState.initialDistance; + const newUserScale = Math.max(1, Math.min(5, touchState.initialScale * scaleFactor)); + const newTotalScale = imageData.baseScale * newUserScale; + + // Transform cursor position to account for rotation + const angle = -imageData.rotation * Math.PI / 180; + const cos = Math.cos(angle); + const sin = Math.sin(angle); + const rotatedCursorX = cursorX * cos - cursorY * sin; + const rotatedCursorY = cursorX * sin + cursorY * cos; + + // Calculate new pan to keep zoom centered on pinch point + imageData.panX = rotatedCursorX * (1/newTotalScale - 1/oldTotalScale) + touchState.lastPanX; + imageData.panY = rotatedCursorY * (1/newTotalScale - 1/oldTotalScale) + touchState.lastPanY; + imageData.userScale = newUserScale; + + clampPan(imageData); + touchState.lastPanX = imageData.panX; + touchState.lastPanY = imageData.panY; + } else { + // Two-finger pan + const deltaX = centerMoveX; + const deltaY = centerMoveY; + + // Transform deltas to account for image rotation + const angle = -imageData.rotation * Math.PI / 180; + const cos = Math.cos(angle); + const sin = Math.sin(angle); + const rotatedDeltaX = deltaX * cos - deltaY * sin; + const rotatedDeltaY = deltaX * sin + deltaY * cos; + + // Convert screen-space delta to pre-scale image space + // Note: positive delta means moving fingers right/down, which should move image right/down + const totalScale = imageData.baseScale * imageData.userScale; + imageData.panX = touchState.lastPanX + rotatedDeltaX / totalScale; + imageData.panY = touchState.lastPanY + rotatedDeltaY / totalScale; + + clampPan(imageData); + touchState.lastPanX = imageData.panX; + touchState.lastPanY = imageData.panY; + } + + touchState.lastDistance = distance; + touchState.lastCenterX = centerX; + touchState.lastCenterY = centerY; + + updateImagePosition(imageData); + } + }, { passive: false }); + + container.addEventListener('touchend', () => { + if (touchState && touchState.image === imageData) { + touchState = null; + } + }, { passive: false }); + + container.addEventListener('touchcancel', () => { + if (touchState && touchState.image === imageData) { + touchState = null; + } + }, { passive: false }); + // Moving / Deleting / Duplicating container.addEventListener('mousedown', (e) => { if (e.target.classList.contains('resize-handle')) return; @@ -431,18 +619,7 @@ function setupImageHandlers(imageData) { return; } - // 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'); + handlePointerStart(e.clientX, e.clientY); }); // Resizing @@ -468,6 +645,27 @@ function setupImageHandlers(imageData) { }; container.classList.add('resizing'); }); + + // Touch support for resize handles + handle.addEventListener('touchstart', (e) => { + e.preventDefault(); + e.stopPropagation(); + + const touch = e.touches[0]; + document.body.classList.add('resizing'); + + resizeState = { + image: imageData, + direction: handle.dataset.direction, + startX: touch.clientX, + startY: touch.clientY, + startXCell: imageData.xCell, + startYCell: imageData.yCell, + startWidthCells: imageData.widthCells, + startHeightCells: imageData.heightCells + }; + container.classList.add('resizing'); + }, { passive: false }); }); // Pan and Zoom with wheel events (macOS trackpad gestures) @@ -536,26 +734,54 @@ function setupImageHandlers(imageData) { }, { passive: false }); } -document.addEventListener('mousemove', (e) => { +function handleMove(clientX, clientY) { if (dragState) { - const dx = e.clientX - dragState.startX; - const dy = e.clientY - dragState.startY; + const dx = clientX - dragState.startX; + const dy = clientY - dragState.startY; - const cellSize = getCellSize(); - const dxCells = Math.round(dx / cellSize.width); - const dyCells = Math.round(dy / cellSize.height); + if (dragState.isPanMode) { + // Pan mode - move the image within its container + const imageData = dragState.image; - 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)); + // Transform deltas to account for image rotation + const angle = -imageData.rotation * Math.PI / 180; + const cos = Math.cos(angle); + const sin = Math.sin(angle); + const rotatedDeltaX = dx * cos - dy * sin; + const rotatedDeltaY = dx * sin + dy * cos; - dragState.image.xCell = newXCell; - dragState.image.yCell = newYCell; - updateImagePosition(dragState.image); + // Convert screen-space delta to pre-scale image space + const totalScale = imageData.baseScale * imageData.userScale; + imageData.panX = dragState.initialPanX + rotatedDeltaX / totalScale; + imageData.panY = dragState.initialPanY + rotatedDeltaY / totalScale; + + clampPan(imageData); + updateImagePosition(imageData); + } else { + // Normal drag mode - move the image container on the grid + const cellSize = getCellSize(); + const dxCells = Math.round(dx / cellSize.width); + const dyCells = Math.round(dy / cellSize.height); + + 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)); + + // If the image moved to a new cell, cancel the long press timer + if (dragState.isTouch && longPressTimer && + (newXCell !== dragState.startXCell || newYCell !== dragState.startYCell)) { + clearTimeout(longPressTimer); + longPressTimer = null; + } + + dragState.image.xCell = newXCell; + dragState.image.yCell = newYCell; + updateImagePosition(dragState.image); + } } if (resizeState) { - const dx = e.clientX - resizeState.startX; - const dy = e.clientY - resizeState.startY; + const dx = clientX - resizeState.startX; + const dy = clientY - resizeState.startY; const cellSize = getCellSize(); const dxCells = Math.round(dx / cellSize.width); @@ -602,11 +828,16 @@ document.addEventListener('mousemove', (e) => { img.heightCells = newH; updateImagePosition(img); } -}); +} -document.addEventListener('mouseup', () => { +function handleEnd() { + if (longPressTimer) { + clearTimeout(longPressTimer); + longPressTimer = null; + } if (dragState) { dragState.image.container.classList.remove('dragging'); + dragState.image.container.style.opacity = ''; dragState = null; // Restore the default cursor document.body.style.cursor = ''; @@ -619,6 +850,31 @@ document.addEventListener('mouseup', () => { document.body.style.cursor = ''; document.body.classList.remove('resizing'); } +} + +document.addEventListener('mousemove', (e) => { + handleMove(e.clientX, e.clientY); +}); + +document.addEventListener('mouseup', () => { + handleEnd(); +}); + +document.addEventListener('touchmove', (e) => { + // Only handle global drag/resize, not image-specific multi-touch + if ((dragState || resizeState) && e.touches.length === 1) { + e.preventDefault(); + const touch = e.touches[0]; + handleMove(touch.clientX, touch.clientY); + } +}, { passive: false }); + +document.addEventListener('touchend', () => { + handleEnd(); +}); + +document.addEventListener('touchcancel', () => { + handleEnd(); }); // Update all image positions when window resizes (for responsive scaling) diff --git a/style.css b/style.css @@ -199,6 +199,7 @@ body { outline-offset: -2px; transition: outline-color 0.2s; background: var(--page); + touch-action: none; } .image-container::after {