mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 13:53:12 -06:00
Make the calendar's gestures work on a touchscreen
The drag gestures did nothing on a phone. touch-action was set to none only once the pointer had already travelled far enough to count as a drag, and by then it is too late: a browser decides at touch-START whether a gesture scrolls the page, so the page scrolled, the pointer stream was cancelled, and the block never moved. The rule that works for a mouse cannot work for a finger. Touch now arms by HOLDING. A press that stays put for a moment takes the gesture over — at which point scrolling is suppressed and the block dims — while a press that moves first is left alone as the scroll it plainly is. Everything that is not a drag still scrolls exactly as a phone user expects. A mouse or pen is unchanged and arms as soon as it has travelled. Tapping empty space now creates a default one-hour slot at that time. On a phone that is the only practical way to create, since drawing a range with a finger is awkward, and on a desktop it is a shortcut worth having anyway. The arming rule is a function rather than a pointerType check at each site, so the touch and mouse paths cannot drift apart, and it is tested — including that the hold is long enough to mean intent without feeling stuck. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
This commit is contained in:
parent
d2d7911efb
commit
ce7d8642fa
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Reference in a new issue