pointer.js (16.1 KB)
1 // Per-image mouse, touch, and wheel handling: move, resize, rotate, pan, zoom 2 3 function setupImageHandlers(imageData) { 4 const container = imageData.container; 5 6 // Bring to front on hover, except within a selection, where it would shuffle the group's paint order 7 container.addEventListener('mouseenter', () => { 8 if (isSelected(imageData)) return; 9 bringToFront(container); 10 }); 11 12 // Unified pointer start handler 13 function handlePointerStart(clientX, clientY, isTouch = false, touchIdentifier = null) { 14 // Clear any existing timers from other images 15 clearDragState(); 16 17 document.body.style.cursor = 'grabbing'; 18 document.body.classList.add('dragging'); 19 20 // Dragging a selected image moves the whole selection 21 const group = isSelected(imageData) ? Array.from(selectedImages) : [imageData]; 22 bringGroupToFront(group); 23 const bounds = selectionBounds(group); 24 25 dragState = { 26 image: imageData, 27 group: group.map(img => ({ image: img, startXCell: img.xCell, startYCell: img.yCell })), 28 minDx: -bounds.minX, 29 maxDx: GRID_COLS - bounds.maxX, 30 minDy: -bounds.minY, 31 maxDy: GRID_ROWS - bounds.maxY, 32 startX: clientX, 33 startY: clientY, 34 startXCell: imageData.xCell, 35 startYCell: imageData.yCell, 36 isPanMode: false, // Will be set to true after long press 37 isTouch: isTouch, 38 timerId: null, // Store timer ID to ensure we only activate the correct timer 39 touchIdentifier: touchIdentifier, // Track which touch this drag belongs to 40 hasMoved: false // Track if any movement has occurred 41 }; 42 43 group.forEach(img => img.container.classList.add('dragging')); 44 45 // For touch, set up long press timer to enable pan mode (single images only) 46 if (isTouch && group.length === 1) { 47 const timerId = setTimeout(() => { 48 // Only activate pan mode if: 49 // 1. This drag state is still active 50 // 2. This drag state is for this specific image 51 // 3. The image hasn't moved to a new cell 52 // 4. This is the timer that was created for this drag state 53 if (dragState && 54 dragState.image === imageData && 55 dragState.startXCell === imageData.xCell && 56 dragState.startYCell === imageData.yCell && 57 dragState.timerId === timerId) { 58 // User held for 0.5 seconds without moving to a new cell - enable pan mode 59 dragState.isPanMode = true; 60 dragState.initialPanX = imageData.panX; 61 dragState.initialPanY = imageData.panY; 62 // Add visual feedback class 63 container.classList.add('pan-mode'); 64 } 65 }, 500); 66 dragState.timerId = timerId; 67 longPressTimer = timerId; 68 } 69 } 70 71 // Touch event handlers for mobile 72 container.addEventListener('touchstart', (e) => { 73 if (e.target.classList.contains('resize-handle')) return; 74 75 // Bring to front on touch; a selected image is raised with its group instead 76 if (isSelected(imageData)) { 77 bringGroupToFront(Array.from(selectedImages)); 78 } else { 79 bringToFront(container); 80 clearSelection(); 81 } 82 83 if (e.touches.length === 1) { 84 // Single touch - start drag (or long press for pan) 85 e.preventDefault(); 86 const touch = e.touches[0]; 87 handlePointerStart(touch.clientX, touch.clientY, true, touch.identifier); 88 } else if (e.touches.length === 2) { 89 // Two fingers - prepare for pinch/pan 90 e.preventDefault(); 91 92 // Cancel any ongoing drag 93 clearDragState(); 94 95 const touch1 = e.touches[0]; 96 const touch2 = e.touches[1]; 97 98 // Calculate initial distance for pinch detection 99 const dx = touch2.clientX - touch1.clientX; 100 const dy = touch2.clientY - touch1.clientY; 101 const distance = Math.sqrt(dx * dx + dy * dy); 102 103 // Calculate center point 104 const centerX = (touch1.clientX + touch2.clientX) / 2; 105 const centerY = (touch1.clientY + touch2.clientY) / 2; 106 107 touchState = { 108 image: imageData, 109 initialDistance: distance, 110 lastDistance: distance, 111 initialScale: imageData.userScale, 112 lastCenterX: centerX, 113 lastCenterY: centerY, 114 lastPanX: imageData.panX, 115 lastPanY: imageData.panY 116 }; 117 } 118 }, { passive: false }); 119 120 container.addEventListener('touchmove', (e) => { 121 if (e.touches.length === 2 && touchState && touchState.image === imageData) { 122 // Two finger pinch/pan 123 e.preventDefault(); 124 125 const touch1 = e.touches[0]; 126 const touch2 = e.touches[1]; 127 128 // Calculate current distance 129 const dx = touch2.clientX - touch1.clientX; 130 const dy = touch2.clientY - touch1.clientY; 131 const distance = Math.sqrt(dx * dx + dy * dy); 132 133 // Calculate center point 134 const centerX = (touch1.clientX + touch2.clientX) / 2; 135 const centerY = (touch1.clientY + touch2.clientY) / 2; 136 137 // Detect if this is primarily a pinch or a pan 138 const distanceChange = Math.abs(distance - touchState.lastDistance); 139 const centerMoveX = centerX - touchState.lastCenterX; 140 const centerMoveY = centerY - touchState.lastCenterY; 141 const centerMovement = Math.sqrt(centerMoveX * centerMoveX + centerMoveY * centerMoveY); 142 143 // If distance changed significantly more than center moved, treat as pinch 144 if (distanceChange > centerMovement * 0.5) { 145 // Pinch zoom 146 const rect = container.getBoundingClientRect(); 147 const cursorX = centerX - rect.left - rect.width / 2; 148 const cursorY = centerY - rect.top - rect.height / 2; 149 150 const oldUserScale = imageData.userScale; 151 const oldTotalScale = imageData.baseScale * oldUserScale; 152 const scaleFactor = distance / touchState.initialDistance; 153 const newUserScale = Math.max(1, Math.min(5, touchState.initialScale * scaleFactor)); 154 const newTotalScale = imageData.baseScale * newUserScale; 155 156 // Restore previous pan state and apply zoom adjustment 157 imageData.panX = touchState.lastPanX; 158 imageData.panY = touchState.lastPanY; 159 adjustPanForZoom(imageData, cursorX, cursorY, oldTotalScale, newTotalScale); 160 imageData.userScale = newUserScale; 161 162 clampPan(imageData); 163 touchState.lastPanX = imageData.panX; 164 touchState.lastPanY = imageData.panY; 165 } else { 166 // Two-finger pan 167 imageData.panX = touchState.lastPanX; 168 imageData.panY = touchState.lastPanY; 169 applyPanDelta(imageData, centerMoveX, centerMoveY); 170 171 clampPan(imageData); 172 touchState.lastPanX = imageData.panX; 173 touchState.lastPanY = imageData.panY; 174 } 175 176 touchState.lastDistance = distance; 177 touchState.lastCenterX = centerX; 178 touchState.lastCenterY = centerY; 179 180 updateImagePosition(imageData); 181 } 182 }, { passive: false }); 183 184 container.addEventListener('touchend', () => { 185 if (touchState && touchState.image === imageData) { 186 touchState = null; 187 } 188 }, { passive: false }); 189 190 container.addEventListener('touchcancel', () => { 191 if (touchState && touchState.image === imageData) { 192 touchState = null; 193 } 194 }, { passive: false }); 195 196 // Moving / Deleting / Duplicating 197 container.addEventListener('mousedown', (e) => { 198 if (e.target.classList.contains('resize-handle')) return; 199 200 e.preventDefault(); 201 202 // Clicking outside the selection drops it; clicking inside acts on the whole selection 203 if (!isSelected(imageData)) clearSelection(); 204 const usingSelection = isSelected(imageData); 205 const group = usingSelection ? Array.from(selectedImages) : [imageData]; 206 207 // Shift-click to delete 208 if (e.shiftKey) { 209 group.forEach(deleteImage); 210 return; 211 } 212 213 // Option-click (or Alt-click on Windows/Linux) to rotate 214 if (e.altKey) { 215 rotateImages(group); 216 return; 217 } 218 219 // Cmd-click (or Ctrl-click on Windows/Linux) to duplicate 220 if (e.metaKey || e.ctrlKey) { 221 // Offset the copies 1 cell down-right, pulled back in if that would leave the grid 222 const bounds = selectionBounds(group); 223 let dx = Math.min(1, GRID_COLS - bounds.maxX); 224 let dy = Math.min(1, GRID_ROWS - bounds.maxY); 225 226 // No room to offset - fall back to the top-left corner 227 if (dx === 0 && dy === 0) { 228 dx = -bounds.minX; 229 dy = -bounds.minY; 230 } 231 232 const copies = group.map(img => duplicateImage(img, img.xCell + dx, img.yCell + dy)); 233 // The copies land under the cursor, so they inherit the selection 234 if (usingSelection) setSelection(copies); 235 return; 236 } 237 238 handlePointerStart(e.clientX, e.clientY); 239 }); 240 241 // Resizing - unified handler for mouse and touch 242 function startResize(clientX, clientY, direction, cursorStyle = null) { 243 if (cursorStyle) { 244 document.body.style.cursor = cursorStyle; 245 } 246 document.body.classList.add('resizing'); 247 248 resizeState = { 249 image: imageData, 250 direction: direction, 251 startX: clientX, 252 startY: clientY, 253 startXCell: imageData.xCell, 254 startYCell: imageData.yCell, 255 startWidthCells: imageData.widthCells, 256 startHeightCells: imageData.heightCells 257 }; 258 container.classList.add('resizing'); 259 } 260 261 container.querySelectorAll('.resize-handle').forEach(handle => { 262 handle.addEventListener('mousedown', (e) => { 263 e.preventDefault(); 264 e.stopPropagation(); 265 const cursorStyle = window.getComputedStyle(handle).cursor; 266 startResize(e.clientX, e.clientY, handle.dataset.direction, cursorStyle); 267 }); 268 269 handle.addEventListener('touchstart', (e) => { 270 e.preventDefault(); 271 e.stopPropagation(); 272 const touch = e.touches[0]; 273 startResize(touch.clientX, touch.clientY, handle.dataset.direction); 274 }, { passive: false }); 275 }); 276 277 // Pan and Zoom with wheel events (macOS trackpad gestures) 278 container.addEventListener('wheel', (e) => { 279 e.preventDefault(); 280 e.stopPropagation(); 281 282 // Don't interfere with dragging or resizing 283 if (dragState || resizeState) return; 284 285 // Detect pinch zoom (ctrlKey is set for pinch gestures on macOS trackpad) 286 if (e.ctrlKey) { 287 // Zoom at cursor position 288 const rect = container.getBoundingClientRect(); 289 const cursorX = e.clientX - rect.left - rect.width / 2; 290 const cursorY = e.clientY - rect.top - rect.height / 2; 291 292 const oldUserScale = imageData.userScale; 293 const oldTotalScale = imageData.baseScale * oldUserScale; 294 const zoomDelta = -e.deltaY * 0.01; 295 const newUserScale = Math.max(1, Math.min(5, oldUserScale * (1 + zoomDelta))); 296 const newTotalScale = imageData.baseScale * newUserScale; 297 298 adjustPanForZoom(imageData, cursorX, cursorY, oldTotalScale, newTotalScale); 299 imageData.userScale = newUserScale; 300 301 clampPan(imageData); 302 } else { 303 // Pan (two-finger scroll on macOS trackpad) 304 applyPanDelta(imageData, -e.deltaX, -e.deltaY); 305 clampPan(imageData); 306 } 307 308 updateImagePosition(imageData); 309 }, { passive: false }); 310 } 311 312 // Helper to clear drag state and timers 313 function clearDragState() { 314 if (longPressTimer) { 315 clearTimeout(longPressTimer); 316 longPressTimer = null; 317 } 318 if (dragState) { 319 dragState.group.forEach(({ image }) => { 320 image.container.classList.remove('dragging'); 321 image.container.classList.remove('pan-mode'); 322 }); 323 dragState = null; 324 document.body.style.cursor = ''; 325 document.body.classList.remove('dragging'); 326 } 327 } 328 329 function handleMove(clientX, clientY) { 330 if (dragState) { 331 const dx = clientX - dragState.startX; 332 const dy = clientY - dragState.startY; 333 334 // Safari on iOS can send the first touchmove with stale coordinates when zoomed 335 // Validate that the first move is reasonable by checking if it would move more than 1 cell 336 if (dragState.isTouch && !dragState.hasMoved) { 337 const cellSize = getCellSize(); 338 const dxCells = Math.abs(Math.round(dx / cellSize.width)); 339 const dyCells = Math.abs(Math.round(dy / cellSize.height)); 340 341 // If the first move would jump more than 1 cell in either direction, 342 // it's likely stale coordinates from a previous tap - reset start position 343 if (dxCells > 1 || dyCells > 1) { 344 dragState.startX = clientX; 345 dragState.startY = clientY; 346 dragState.hasMoved = true; 347 return; // Don't process this move event 348 } 349 dragState.hasMoved = true; 350 } 351 352 if (dragState.isPanMode) { 353 // Pan mode - move the image within its container 354 const imageData = dragState.image; 355 imageData.panX = dragState.initialPanX; 356 imageData.panY = dragState.initialPanY; 357 applyPanDelta(imageData, dx, dy); 358 clampPan(imageData); 359 updateImagePosition(imageData); 360 } else { 361 // Normal drag mode - move the image container(s) on the grid 362 const cellSize = getCellSize(); 363 // Clamped so the selection's bounding box stays on the grid 364 const dxCells = Math.max(dragState.minDx, Math.min(dragState.maxDx, Math.round(dx / cellSize.width))); 365 const dyCells = Math.max(dragState.minDy, Math.min(dragState.maxDy, Math.round(dy / cellSize.height))); 366 367 // If the image moved to a new cell, cancel the long press timer 368 if (dragState.isTouch && longPressTimer && (dxCells !== 0 || dyCells !== 0)) { 369 clearTimeout(longPressTimer); 370 longPressTimer = null; 371 } 372 373 dragState.group.forEach(({ image, startXCell, startYCell }) => { 374 image.xCell = startXCell + dxCells; 375 image.yCell = startYCell + dyCells; 376 updateImagePosition(image); 377 }); 378 } 379 } 380 381 if (resizeState) { 382 const dx = clientX - resizeState.startX; 383 const dy = clientY - resizeState.startY; 384 385 const cellSize = getCellSize(); 386 const dxCells = Math.round(dx / cellSize.width); 387 const dyCells = Math.round(dy / cellSize.height); 388 389 const dir = resizeState.direction; 390 const img = resizeState.image; 391 392 let newX = img.xCell; 393 let newY = img.yCell; 394 let newW = img.widthCells; 395 let newH = img.heightCells; 396 397 if (dir.includes('e')) { 398 const proposedW = Math.max(1, resizeState.startWidthCells + dxCells); 399 // Clamp to grid boundary 400 newW = Math.min(proposedW, GRID_COLS - resizeState.startXCell); 401 } 402 if (dir.includes('w')) { 403 const delta = Math.min(dxCells, resizeState.startWidthCells - 1); 404 const proposedX = resizeState.startXCell + delta; 405 // Clamp to grid boundary 406 const clampedX = Math.max(0, proposedX); 407 newX = clampedX; 408 newW = resizeState.startWidthCells - (clampedX - resizeState.startXCell); 409 } 410 if (dir.includes('s')) { 411 const proposedH = Math.max(1, resizeState.startHeightCells + dyCells); 412 // Clamp to grid boundary 413 newH = Math.min(proposedH, GRID_ROWS - resizeState.startYCell); 414 } 415 if (dir.includes('n')) { 416 const delta = Math.min(dyCells, resizeState.startHeightCells - 1); 417 const proposedY = resizeState.startYCell + delta; 418 // Clamp to grid boundary 419 const clampedY = Math.max(0, proposedY); 420 newY = clampedY; 421 newH = resizeState.startHeightCells - (clampedY - resizeState.startYCell); 422 } 423 424 img.xCell = newX; 425 img.yCell = newY; 426 img.widthCells = newW; 427 img.heightCells = newH; 428 updateImagePosition(img); 429 } 430 } 431 432 function handleEnd() { 433 clearDragState(); 434 if (resizeState) { 435 resizeState.image.container.classList.remove('resizing'); 436 resizeState = null; 437 document.body.style.cursor = ''; 438 document.body.classList.remove('resizing'); 439 } 440 } 441 442 document.addEventListener('mousemove', (e) => { 443 handleMove(e.clientX, e.clientY); 444 }); 445 446 document.addEventListener('mouseup', () => { 447 handleEnd(); 448 }); 449 450 document.addEventListener('touchmove', (e) => { 451 // Only handle global drag/resize, not image-specific multi-touch 452 if ((dragState || resizeState) && e.touches.length === 1) { 453 const touch = e.touches[0]; 454 455 // For drag operations, verify this touch matches the one that started the drag 456 if (dragState && dragState.touchIdentifier !== null && 457 touch.identifier !== dragState.touchIdentifier) { 458 // This is a different touch - ignore it 459 return; 460 } 461 462 e.preventDefault(); 463 handleMove(touch.clientX, touch.clientY); 464 } 465 }, { passive: false }); 466 467 document.addEventListener('touchend', (e) => { 468 // If we have an active drag state, only end it if the touch that's ending 469 // matches the touch that started the drag 470 if (dragState && dragState.touchIdentifier !== null && e.changedTouches.length > 0) { 471 let matchingTouchEnded = false; 472 for (let i = 0; i < e.changedTouches.length; i++) { 473 if (e.changedTouches[i].identifier === dragState.touchIdentifier) { 474 matchingTouchEnded = true; 475 break; 476 } 477 } 478 // Only end the drag if the matching touch ended 479 if (!matchingTouchEnded) { 480 return; 481 } 482 } 483 484 handleEnd(); 485 486 // Clear any lingering timers even if there's no active drag state 487 if (longPressTimer) { 488 clearTimeout(longPressTimer); 489 longPressTimer = null; 490 } 491 }); 492 493 document.addEventListener('touchcancel', () => { 494 handleEnd(); 495 496 if (longPressTimer) { 497 clearTimeout(longPressTimer); 498 longPressTimer = null; 499 } 500 }); 501 502 // Intercept wheel events at the document level to prevent page scroll/zoom when the cursor is over an image container. 503 document.addEventListener('wheel', (e) => { 504 const hoveredImage = images.find(img => img.container.contains(e.target) || img.container === e.target); 505 if (hoveredImage) { 506 e.preventDefault(); 507 } 508 }, { passive: false });