feat: long-press drag-to-create calendar events on mobile

Tap and hold (~500ms) on an empty time slot to activate, then drag to
select time range. Opens calendar event modal with pre-filled times.
Includes haptic vibration feedback on activation. Normal tap and scroll
gestures are not affected.

v1.73.0

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
mARTin 2026-03-25 02:29:19 +01:00
parent 39934d23bf
commit 98a47a62de
2 changed files with 127 additions and 2 deletions

View File

@ -1,6 +1,6 @@
{ {
"name": "my-weekly-todo-list", "name": "my-weekly-todo-list",
"version": "1.72.2", "version": "1.73.0",
"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

@ -2223,7 +2223,7 @@ export default function WeeklyView() {
initialEndTime?: string; initialEndTime?: string;
}>({ isOpen: false }); }>({ isOpen: false });
// Slot drag-to-create calendar event state // Slot drag-to-create calendar event state (mouse + touch)
const slotDragJustEndedRef = useRef(false); const slotDragJustEndedRef = useRef(false);
const slotDragRef = useRef<{ const slotDragRef = useRef<{
active: boolean; active: boolean;
@ -2237,6 +2237,15 @@ export default function WeeklyView() {
startSlot: string; startSlot: string;
endSlot: string; endSlot: string;
} | null>(null); } | null>(null);
// Touch long-press state for mobile drag-to-create
const touchLongPressRef = useRef<{
timerId: ReturnType<typeof setTimeout>;
startX: number;
startY: number;
date: Date;
slot: string;
activated: boolean;
} | null>(null);
// Calendar event resize/drag state // Calendar event resize/drag state
const [eventDragState, setEventDragState] = useState<{ const [eventDragState, setEventDragState] = useState<{
@ -3960,6 +3969,86 @@ 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 on document
useEffect(() => {
const handleTouchMove = (e: TouchEvent) => {
const lp = touchLongPressRef.current;
if (!lp) return;
const touch = e.touches[0];
if (!lp.activated) {
// Before long-press fires: cancel if finger moves too much (scrolling)
if (Math.abs(touch.clientX - lp.startX) > 10 || Math.abs(touch.clientY - lp.startY) > 10) {
clearTimeout(lp.timerId);
touchLongPressRef.current = null;
}
return;
}
// Long-press active: prevent scrolling and track slot under finger
e.preventDefault();
const el = document.elementFromPoint(touch.clientX, touch.clientY);
const slotEl = el?.closest('[data-slot]') as HTMLElement | null;
if (slotEl) {
const slot = slotEl.getAttribute('data-slot');
if (slot) {
const drag = slotDragRef.current;
if (drag) {
drag.currentSlot = slot;
const dateStr = `${drag.date.getFullYear()}-${String(drag.date.getMonth() + 1).padStart(2, '0')}-${String(drag.date.getDate()).padStart(2, '0')}`;
const startSlot = drag.startSlot <= slot ? drag.startSlot : slot;
const endSlot = drag.startSlot <= slot ? slot : drag.startSlot;
setSlotDragSelection({ dateStr, startSlot, endSlot });
}
}
}
};
const handleTouchEnd = () => {
const lp = touchLongPressRef.current;
if (lp) {
clearTimeout(lp.timerId);
touchLongPressRef.current = null;
}
const drag = slotDragRef.current;
slotDragRef.current = null;
if (!drag || !drag.active) {
setSlotDragSelection(null);
return;
}
setSlotDragSelection(null);
slotDragJustEndedRef.current = true;
setTimeout(() => { slotDragJustEndedRef.current = false; }, 300);
const startSlot = drag.startSlot <= drag.currentSlot ? drag.startSlot : drag.currentSlot;
const endSlot = drag.startSlot <= drag.currentSlot ? drag.currentSlot : drag.startSlot;
const [eh, em] = endSlot.split(':').map(Number);
const endMinutes = eh * 60 + em + effectiveCellDurationRef.current;
const endH = Math.floor(endMinutes / 60);
const endM = endMinutes % 60;
const endTime = `${String(endH).padStart(2, '0')}:${String(endM).padStart(2, '0')}`;
setCalendarEventModal({
isOpen: true,
initialDate: drag.date,
initialStartTime: startSlot,
initialEndTime: endTime,
});
};
// Use passive: false for touchmove so we can preventDefault after long-press activates
document.addEventListener('touchmove', handleTouchMove, { passive: false });
document.addEventListener('touchend', handleTouchEnd);
document.addEventListener('touchcancel', handleTouchEnd);
return () => {
document.removeEventListener('touchmove', handleTouchMove);
document.removeEventListener('touchend', handleTouchEnd);
document.removeEventListener('touchcancel', handleTouchEnd);
};
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// 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') => {
if (!pendingRecurringDrag) return; if (!pendingRecurringDrag) return;
@ -7856,6 +7945,42 @@ export default function WeeklyView() {
startY: e.clientY, startY: e.clientY,
}; };
}} }}
onTouchStart={(e) => {
if (isProtected || isOccupiedByAnyTask) return;
const target = e.target as HTMLElement;
if (target.closest('.calendar-event-block, .grid-task-block, .task-input-slot')) return;
const touch = e.touches[0];
const slotDate = new Date(date);
const slotName = slot;
// Cancel any existing long-press
if (touchLongPressRef.current) {
clearTimeout(touchLongPressRef.current.timerId);
}
const timerId = setTimeout(() => {
// Long-press activated — vibrate for feedback
if (navigator.vibrate) navigator.vibrate(50);
slotDragRef.current = {
active: true,
date: slotDate,
startSlot: slotName,
currentSlot: slotName,
startY: touch.clientY,
};
if (touchLongPressRef.current) {
touchLongPressRef.current.activated = true;
}
const ds = `${slotDate.getFullYear()}-${String(slotDate.getMonth() + 1).padStart(2, '0')}-${String(slotDate.getDate()).padStart(2, '0')}`;
setSlotDragSelection({ dateStr: ds, startSlot: slotName, endSlot: slotName });
}, 500);
touchLongPressRef.current = {
timerId,
startX: touch.clientX,
startY: touch.clientY,
date: slotDate,
slot: slotName,
activated: false,
};
}}
onDragOver={(e) => onDragOver={(e) =>
!isProtected && !isOccupiedByTask && !isProtected && !isOccupiedByTask &&
handleDragOver(e, date.getDay(), slot) handleDragOver(e, date.getDay(), slot)