address iOS/Safari touch event bug (occurs when zoomed in quickly tapping grid, then image)
Hunter
Wednesday, October 22nd, 2025 at 4:54 PM
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)