diff --git a/frontend/js/lib/schedule-grid.js b/frontend/js/lib/schedule-grid.js index 5d6cb9f..3d02c13 100644 --- a/frontend/js/lib/schedule-grid.js +++ b/frontend/js/lib/schedule-grid.js @@ -82,6 +82,22 @@ export function formatRange(startMin, endMin) { return `${fmt(startMin)} – ${fmt(endMin)}`; } +// How long a touch must be held before it becomes a drag. A touchscreen cannot use the mouse +// rule: the browser decides at touch-START whether the gesture is a page scroll, so a drag that +// only declares itself after the finger moves has already lost — the page scrolls and the pointer +// stream is cancelled. Holding still first is the signal that this is a drag and not a scroll. +export const LONG_PRESS_MS = 350; + +// Touch arms by holding; a mouse or pen arms as soon as it has travelled. Returning the mode +// rather than branching on pointerType at each site keeps the two paths from drifting apart. +export function dragArmMode(pointerType) { + return pointerType === 'touch' ? 'longpress' : 'immediate'; +} + +// A default slot for "I tapped a time" rather than dragging one out — the whole gesture on a +// phone, where dragging out a range is awkward. +export const DEFAULT_NEW_MIN = 60; + // Has the pointer moved far enough to mean "drag" rather than "click"? export function isDrag(dx, dy, threshold = DRAG_THRESHOLD_PX) { return Math.hypot(dx, dy) >= threshold; diff --git a/frontend/js/views/schedule.js b/frontend/js/views/schedule.js index 4e25553..224b53c 100644 --- a/frontend/js/views/schedule.js +++ b/frontend/js/views/schedule.js @@ -4,6 +4,7 @@ import { t } from '../i18n.js'; import { HOUR_PX, pxToMinutes, minutesToPx, rangeFromDrag, moveRange, resizeRange, toLocalStamp, formatRange, canMoveAcrossDays, editsWholeSeries, isDrag, + dragArmMode, LONG_PRESS_MS, DEFAULT_NEW_MIN, } from '../lib/schedule-grid.js'; const API = (url, opts = {}) => fetch('/api' + url, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(r => r.json()); @@ -370,6 +371,24 @@ export async function render(container) { } else { dragState = { kind: 'create', anchorMin: startMin, refCell: cell, moved: false, origin, dayIdx: dayColumnOf(cell) }; } + // A touchscreen cannot arm the way a mouse does. The browser decides at touch-START + // whether this gesture scrolls the page; by the time a finger has moved far enough to look + // like a drag, scrolling has already begun and the pointer stream is cancelled. So on touch + // we wait for a HOLD, and only then take the gesture over. Everything else still scrolls + // normally, which is what a phone user expects a calendar to do. + dragState.armMode = dragArmMode(e.pointerType); + if (dragState.armMode === 'longpress') { + dragState.armed = false; + dragState.longPressTimer = setTimeout(() => { + if (!dragState) return; + dragState.armed = true; + cal.style.touchAction = 'none'; // taken over — now the page must NOT scroll + if (dragState.block) dragState.block.style.opacity = '0.35'; + if (navigator.vibrate) { try { navigator.vibrate(10); } catch (_) { /* optional */ } } + }, LONG_PRESS_MS); + } else { + dragState.armed = true; + } cal.setPointerCapture?.(e.pointerId); }); @@ -377,8 +396,15 @@ export async function render(container) { if (!dragState) return; // Ignore the jitter of an ordinary click. Until the pointer has actually travelled, this // is still a click and must stay one, or click-to-edit never fires. + const travelled = isDrag(e.clientX - dragState.origin.x, e.clientY - dragState.origin.y); + // Touch that moves BEFORE the hold completes is the user scrolling. Let go of it entirely + // rather than fighting the browser for the gesture. + if (dragState.armMode === 'longpress' && !dragState.armed) { + if (travelled) { clearTimeout(dragState.longPressTimer); dragState = null; clearGhost(); } + return; + } if (!dragState.moved) { - if (!isDrag(e.clientX - dragState.origin.x, e.clientY - dragState.origin.y)) return; + if (!travelled) return; dragState.moved = true; cal.style.touchAction = 'none'; // stop a touch drag scrolling the page cal.style.cursor = dragState.kind === 'resize' ? 'ns-resize' : 'grabbing'; @@ -412,9 +438,20 @@ export async function render(container) { const st = dragState; dragState = null; clearGhost(); + if (st) clearTimeout(st.longPressTimer); resetDragChrome(st); try { cal.releasePointerCapture?.(e.pointerId); } catch (_) { /* already released */ } - if (!st || !st.pending || !st.moved) { setTimeout(() => { if (!dragState) { /* let click through */ } }, 0); return; } + if (!st) return; + // A tap or click on empty space with no drag still means "put something here" — and on a + // phone it is the ONLY create gesture, since dragging out a range with a finger is awkward. + if (!st.moved && st.kind === 'create' && st.anchorMin != null) { + const d0 = new Date(currentWeekStart); + d0.setDate(d0.getDate() + (st.dayIdx || 0)); + const start = Math.floor(st.anchorMin / 15) * 15; + openCreateAt(d0, start, Math.min(start + DEFAULT_NEW_MIN, 24 * 60)); + return; + } + if (!st.pending || !st.moved) return; // a tap on a block: let the click handler edit it const { range, day } = st.pending; const dayDate = new Date(currentWeekStart); dayDate.setDate(dayDate.getDate() + day); diff --git a/server/test/schedule-grid-math.test.js b/server/test/schedule-grid-math.test.js index cdee415..b3d066d 100644 --- a/server/test/schedule-grid-math.test.js +++ b/server/test/schedule-grid-math.test.js @@ -127,6 +127,34 @@ test('a whole day still fits a laptop screen', async () => { assert.ok(24 * G.HOUR_PX <= 1100, `full day is ${24 * G.HOUR_PX}px`); }); +test('MOBILE: touch arms by HOLDING, a mouse arms by moving', async () => { + // The two cannot share a rule. A browser decides at touch-start whether a gesture scrolls the + // page, so a touch drag that only declares itself after the finger moves has already lost — the + // page scrolls and the pointer stream is cancelled. That is exactly why the first version did + // nothing on a phone: it set touch-action only AFTER the move threshold. + assert.equal(G.dragArmMode('touch'), 'longpress'); + assert.equal(G.dragArmMode('mouse'), 'immediate'); + assert.equal(G.dragArmMode('pen'), 'immediate'); + assert.equal(G.dragArmMode(undefined), 'immediate', 'unknown input behaves like a mouse'); +}); + +test('the hold is long enough to mean intent, short enough not to feel stuck', async () => { + assert.ok(G.LONG_PRESS_MS >= 250 && G.LONG_PRESS_MS <= 600, `${G.LONG_PRESS_MS}ms`); +}); + +test('a tap with no drag still yields a sensible slot', async () => { + // On a phone this is the only create gesture — dragging a range with a finger is awkward. + assert.equal(G.DEFAULT_NEW_MIN, 60); + const r = G.clampRange(9 * 60, 9 * 60 + G.DEFAULT_NEW_MIN); + assert.equal(r.endMin - r.startMin, 60); +}); + +test('a tap late in the day does not produce an invalid slot', async () => { + const start = 23 * 60 + 30; + const r = G.clampRange(start, Math.min(start + G.DEFAULT_NEW_MIN, G.DAY_MIN)); + assert.ok(r.endMin <= G.DAY_MIN && r.endMin > r.startMin); +}); + test('the drag readout is human, not 24h minutes', async () => { assert.equal(G.formatRange(540, 630), '9:00 AM – 10:30 AM'); assert.equal(G.formatRange(0, 45), '12:00 AM – 12:45 AM');