fix: day column scroll blocked on iOS by DOM addEventListener inside onTouchStart

The slot onTouchStart called attachTouchListeners() which did
document.addEventListener() synchronously — DOM manipulation inside a touch
handler makes iOS Safari uncertain about scroll intent and blocks vertical
scroll on day columns (but not the time column which has no handlers).

Fix: attach all touch listeners ONCE at mount (permanent, passive). The slot
onTouchStart only writes to a ref (zero DOM side effects). The non-passive
drag-tracking listener is still added dynamically but only after the 500ms
long-press actually activates, at which point the user has clearly committed
to a drag gesture rather than a scroll.

v1.77.2
This commit is contained in:
mARTin 2026-03-29 15:47:20 +02:00
parent a3a55c9575
commit b6e5303665
2 changed files with 38 additions and 39 deletions

View File

@ -1,6 +1,6 @@
{ {
"name": "my-weekly-todo-list", "name": "my-weekly-todo-list",
"version": "1.77.1", "version": "1.77.2",
"description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view", "description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {

View File

@ -4106,36 +4106,18 @@ export default function WeeklyView() {
}; };
}, []); // eslint-disable-line react-hooks/exhaustive-deps }, []); // eslint-disable-line react-hooks/exhaustive-deps
// Touch long-press drag-to-create: touchmove + touchend handlers // Touch long-press drag-to-create: listeners are attached ONCE at mount (not per-slot-touch).
// Stored as refs so the onTouchStart can attach/detach them only when needed // This avoids any DOM manipulation inside onTouchStart on slot divs, which can
// (avoids a permanent non-passive touchmove listener that breaks scroll/sticky) // confuse iOS Safari's scroll-intent detection and block vertical scrolling.
const touchMoveHandlerRef = useRef<((e: TouchEvent) => void) | null>(null); // The slot onTouchStart only updates touchLongPressRef (pure ref, no DOM side effects).
const touchMoveDragHandlerRef = useRef<((e: TouchEvent) => void) | null>(null); const longPressDragActiveRef = useRef(false); // true when non-passive drag listener is attached
const touchEndHandlerRef = useRef<(() => void) | null>(null);
const cleanupTouchListeners = useCallback(() => {
if (touchMoveHandlerRef.current) {
document.removeEventListener('touchmove', touchMoveHandlerRef.current);
touchMoveHandlerRef.current = null;
}
if (touchMoveDragHandlerRef.current) {
document.removeEventListener('touchmove', touchMoveDragHandlerRef.current);
touchMoveDragHandlerRef.current = null;
}
if (touchEndHandlerRef.current) {
document.removeEventListener('touchend', touchEndHandlerRef.current);
document.removeEventListener('touchcancel', touchEndHandlerRef.current);
touchEndHandlerRef.current = null;
}
}, []);
// Called when long-press activates: swap passive cancel-listener for non-passive drag-listener
const upgradeToDragListeners = useCallback(() => { const upgradeToDragListeners = useCallback(() => {
if (touchMoveHandlerRef.current) { if (longPressDragActiveRef.current) return;
document.removeEventListener('touchmove', touchMoveHandlerRef.current); longPressDragActiveRef.current = true;
touchMoveHandlerRef.current = null; // Non-passive listener added only when drag is actually active (500ms hold)
}
const handleDragMove = (e: TouchEvent) => { const handleDragMove = (e: TouchEvent) => {
// Long-press active: prevent scrolling and track slot under finger if (!longPressDragActiveRef.current) return;
e.preventDefault(); e.preventDefault();
const touch = e.touches[0]; const touch = e.touches[0];
const el = document.elementFromPoint(touch.clientX, touch.clientY); const el = document.elementFromPoint(touch.clientX, touch.clientY);
@ -4154,22 +4136,31 @@ export default function WeeklyView() {
} }
} }
}; };
touchMoveDragHandlerRef.current = handleDragMove;
document.addEventListener('touchmove', handleDragMove, { passive: false }); document.addEventListener('touchmove', handleDragMove, { passive: false });
// Store for cleanup
(upgradeToDragListeners as any)._handler = handleDragMove;
}, []); }, []);
const attachTouchListeners = useCallback(() => { const cancelDragListeners = useCallback(() => {
cleanupTouchListeners(); if (!longPressDragActiveRef.current) return;
longPressDragActiveRef.current = false;
const handler = (upgradeToDragListeners as any)._handler;
if (handler) {
document.removeEventListener('touchmove', handler);
(upgradeToDragListeners as any)._handler = null;
}
}, [upgradeToDragListeners]);
// Phase 1: passive listener — only cancels long-press if finger moves (no scroll blocking) // Permanent global passive touchmove + touchend listeners (attached once at mount)
useEffect(() => {
const handlePassiveTouchMove = (e: TouchEvent) => { const handlePassiveTouchMove = (e: TouchEvent) => {
const lp = touchLongPressRef.current; const lp = touchLongPressRef.current;
if (!lp || lp.activated) return; if (!lp || lp.activated) return;
const touch = e.touches[0]; const touch = e.touches[0];
// Cancel long-press if finger moves more than 10px (user is scrolling)
if (Math.abs(touch.clientX - lp.startX) > 10 || Math.abs(touch.clientY - lp.startY) > 10) { if (Math.abs(touch.clientX - lp.startX) > 10 || Math.abs(touch.clientY - lp.startY) > 10) {
clearTimeout(lp.timerId); clearTimeout(lp.timerId);
touchLongPressRef.current = null; touchLongPressRef.current = null;
cleanupTouchListeners();
} }
}; };
@ -4179,7 +4170,9 @@ export default function WeeklyView() {
clearTimeout(lp.timerId); clearTimeout(lp.timerId);
touchLongPressRef.current = null; touchLongPressRef.current = null;
} }
cleanupTouchListeners();
// Deactivate drag listener if it was upgraded
cancelDragListeners();
const drag = slotDragRef.current; const drag = slotDragRef.current;
slotDragRef.current = null; slotDragRef.current = null;
@ -4208,12 +4201,18 @@ export default function WeeklyView() {
}); });
}; };
touchMoveHandlerRef.current = handlePassiveTouchMove;
touchEndHandlerRef.current = handleTouchEnd;
document.addEventListener('touchmove', handlePassiveTouchMove, { passive: true }); document.addEventListener('touchmove', handlePassiveTouchMove, { passive: true });
document.addEventListener('touchend', handleTouchEnd); document.addEventListener('touchend', handleTouchEnd, { passive: true });
document.addEventListener('touchcancel', handleTouchEnd); document.addEventListener('touchcancel', handleTouchEnd, { passive: true });
}, [cleanupTouchListeners]); return () => {
document.removeEventListener('touchmove', handlePassiveTouchMove);
document.removeEventListener('touchend', handleTouchEnd);
document.removeEventListener('touchcancel', handleTouchEnd);
};
}, [cancelDragListeners]); // eslint-disable-line react-hooks/exhaustive-deps
// No-op: onTouchStart on slots only updates the ref — no DOM side effects here
const attachTouchListeners = useCallback(() => { /* listeners are permanent, see useEffect above */ }, []);
// Handle recurring event drag confirm (this/all) // Handle recurring event drag confirm (this/all)
const handleRecurringDragConfirm = async (editMode: 'this' | 'future' | 'all') => { const handleRecurringDragConfirm = async (editMode: 'this' | 'future' | 'all') => {