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:
parent
a3a55c9575
commit
b6e5303665
@ -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": {
|
||||||
|
|||||||
@ -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') => {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user