address iOS/Safari touch event bug (occurs when zoomed in quickly tapping grid, then image)

Hunter
Wednesday, October 22nd, 2025 at 4:54 PM

script.js+73-8

script.js
353  	});
354  
355  	// Unified pointer start handler
    -	function handlePointerStart(clientX, clientY, isTouch = false) {
356 +	function handlePointerStart(clientX, clientY, isTouch = false, touchIdentifier = null) {
357  		// Clear any existing timers from other images
358  		clearDragState();
359  

368  			startXCell: imageData.xCell,
369  			startYCell: imageData.yCell,
370  			isPanMode: false, // Will be set to true after long press
    -			isTouch: isTouch
371 +			isTouch: isTouch,
372 +			timerId: null, // Store timer ID to ensure we only activate the correct timer
373 +			touchIdentifier: touchIdentifier, // Track which touch this drag belongs to
374 +			hasMoved: false // Track if any movement has occurred
375  		};
376  
377  		container.classList.add('dragging');
378  
379  		// For touch, set up long press timer to enable pan mode
380  		if (isTouch) {
    -			longPressTimer = setTimeout(() => {
    -				if (dragState && dragState.image === imageData &&
381 +			const timerId = setTimeout(() => {
382 +				// Only activate pan mode if:
383 +				// 1. This drag state is still active
384 +				// 2. This drag state is for this specific image
385 +				// 3. The image hasn't moved to a new cell
386 +				// 4. This is the timer that was created for this drag state
387 +				if (dragState &&
388 +				    dragState.image === imageData &&
389  				    dragState.startXCell === imageData.xCell &&
    -				    dragState.startYCell === imageData.yCell) {
390 +				    dragState.startYCell === imageData.yCell &&
391 +				    dragState.timerId === timerId) {
392  					// User held for 0.5 seconds without moving to a new cell - enable pan mode
393  					dragState.isPanMode = true;
394  					dragState.initialPanX = imageData.panX;

397  					container.classList.add('pan-mode');
398  				}
399  			}, 500);
400 +			dragState.timerId = timerId;
401 +			longPressTimer = timerId;
402  		}
403  	}
404  

413  			// Single touch - start drag (or long press for pan)
414  			e.preventDefault();
415  			const touch = e.touches[0];
    -			handlePointerStart(touch.clientX, touch.clientY, true);
416 +			handlePointerStart(touch.clientX, touch.clientY, true, touch.identifier);
417  		} else if (e.touches.length === 2) {
418  			// Two fingers - prepare for pinch/pan
419  			e.preventDefault();

724  		const dx = clientX - dragState.startX;
725  		const dy = clientY - dragState.startY;
726  
727 +		// Safari on iOS can send the first touchmove with stale coordinates when zoomed
728 +		// Validate that the first move is reasonable by checking if it would move more than 1 cell
729 +		if (dragState.isTouch && !dragState.hasMoved) {
730 +			const cellSize = getCellSize();
731 +			const dxCells = Math.abs(Math.round(dx / cellSize.width));
732 +			const dyCells = Math.abs(Math.round(dy / cellSize.height));
733 +
734 +			// If the first move would jump more than 1 cell in either direction,
735 +			// it's likely stale coordinates from a previous tap - reset start position
736 +			if (dxCells > 1 || dyCells > 1) {
737 +				dragState.startX = clientX;
738 +				dragState.startY = clientY;
739 +				dragState.hasMoved = true;
740 +				return; // Don't process this move event
741 +			}
742 +			dragState.hasMoved = true;
743 +		}
744 +
745  		if (dragState.isPanMode) {
746  			// Pan mode - move the image within its container
747  			const imageData = dragState.image;

844  document.addEventListener('touchmove', (e) => {
845  	// Only handle global drag/resize, not image-specific multi-touch
846  	if ((dragState || resizeState) && e.touches.length === 1) {
    -		e.preventDefault();
847  		const touch = e.touches[0];
848 +
849 +		// For drag operations, verify this touch matches the one that started the drag
850 +		if (dragState && dragState.touchIdentifier !== null &&
851 +		    touch.identifier !== dragState.touchIdentifier) {
852 +			// This is a different touch - ignore it
853 +			return;
854 +		}
855 +
856 +		e.preventDefault();
857  		handleMove(touch.clientX, touch.clientY);
858  	}
859  }, { passive: false });
860  
    -document.addEventListener('touchend', () => {
861 +document.addEventListener('touchend', (e) => {
862 +	// If we have an active drag state, only end it if the touch that's ending
863 +	// matches the touch that started the drag
864 +	if (dragState && dragState.touchIdentifier !== null && e.changedTouches.length > 0) {
865 +		let matchingTouchEnded = false;
866 +		for (let i = 0; i < e.changedTouches.length; i++) {
867 +			if (e.changedTouches[i].identifier === dragState.touchIdentifier) {
868 +				matchingTouchEnded = true;
869 +				break;
870 +			}
871 +		}
872 +		// Only end the drag if the matching touch ended
873 +		if (!matchingTouchEnded) {
874 +			return;
875 +		}
876 +	}
877 +
878  	handleEnd();
879 +
880 +	// Clear any lingering timers even if there's no active drag state
881 +	if (longPressTimer) {
882 +		clearTimeout(longPressTimer);
883 +		longPressTimer = null;
884 +	}
885  });
886  
887  document.addEventListener('touchcancel', () => {
888  	handleEnd();
889 +
890 +	if (longPressTimer) {
891 +		clearTimeout(longPressTimer);
892 +		longPressTimer = null;
893 +	}
894  });
895  
896  // Update all image positions when window resizes (for responsive scaling)