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 });