mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 22:03:13 -06:00
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
120 lines
5.9 KiB
JavaScript
120 lines
5.9 KiB
JavaScript
// Geometry and time maths for the week calendar's direct manipulation (drag to create, drag to
|
||
// move, drag to resize). Kept apart from the view so the arithmetic — the part that silently
|
||
// produces a schedule an hour off, or one that ends before it starts — is testable without a
|
||
// browser.
|
||
//
|
||
// The grid is 24 rows of HOUR_PX pixels, one column per weekday. A block's vertical position is
|
||
// therefore a pure function of minutes-since-midnight, and vice versa.
|
||
|
||
// 28px/hour made a 15-minute block SEVEN pixels tall — legible, but not something you can
|
||
// reliably grab, and its resize grip would have covered the whole block. 44 keeps a full day on
|
||
// screen on a laptop while making the smallest schedule an 11px target.
|
||
export const HOUR_PX = 44;
|
||
|
||
// A pointer must travel this far before a press counts as a drag. Without it, the 1px of movement
|
||
// in an ordinary click turns every click into a drag and swallows click-to-edit — so the calendar
|
||
// would feel broken in the most common interaction of all.
|
||
export const DRAG_THRESHOLD_PX = 4;
|
||
export const SNAP_MIN = 15; // what a drag rounds to; matches how people actually schedule
|
||
export const MIN_DURATION_MIN = 15; // a zero-height block is invisible and unselectable
|
||
export const DAY_MIN = 24 * 60;
|
||
|
||
export const minutesToPx = (min) => (min / 60) * HOUR_PX;
|
||
export const pxToMinutes = (px) => (px / HOUR_PX) * 60;
|
||
|
||
// Round to the nearest SNAP_MIN. Nearest rather than floor: dragging to 10:58 should give 11:00,
|
||
// not 10:45, because the pointer is a blunt instrument and people aim at the line.
|
||
export function snapMinutes(min, snap = SNAP_MIN) {
|
||
return Math.round(min / snap) * snap;
|
||
}
|
||
|
||
// Clamp a dragged range into a valid one: inside the day, at least MIN_DURATION_MIN long, and
|
||
// never inverted. Returns {startMin, endMin}.
|
||
export function clampRange(startMin, endMin) {
|
||
let s = Math.max(0, Math.min(DAY_MIN - MIN_DURATION_MIN, Math.round(startMin)));
|
||
let e = Math.round(endMin);
|
||
if (e < s + MIN_DURATION_MIN) e = s + MIN_DURATION_MIN; // dragging up past the start, or a click
|
||
if (e > DAY_MIN) { e = DAY_MIN; s = Math.min(s, e - MIN_DURATION_MIN); }
|
||
return { startMin: s, endMin: e };
|
||
}
|
||
|
||
// A drag that started at anchorMin and is currently at pointerMin, in either direction. Outlook
|
||
// lets you drag upward from the anchor and treats the anchor as the END; so do we.
|
||
export function rangeFromDrag(anchorMin, pointerMin) {
|
||
const a = snapMinutes(anchorMin);
|
||
const b = snapMinutes(pointerMin);
|
||
return clampRange(Math.min(a, b), Math.max(a, b));
|
||
}
|
||
|
||
// Move a block of fixed length so it now STARTS at startMin, without letting it run off the end
|
||
// of the day (it slides back instead of being silently truncated — a move must not change length).
|
||
export function moveRange(startMin, durationMin) {
|
||
const dur = Math.max(MIN_DURATION_MIN, Math.round(durationMin));
|
||
let s = snapMinutes(Math.max(0, startMin));
|
||
if (s + dur > DAY_MIN) s = DAY_MIN - dur;
|
||
return { startMin: Math.max(0, s), endMin: Math.max(0, s) + dur };
|
||
}
|
||
|
||
// Resize by dragging the bottom edge: the start is fixed, the end follows the pointer.
|
||
export function resizeRange(startMin, pointerMin) {
|
||
return clampRange(startMin, snapMinutes(pointerMin));
|
||
}
|
||
|
||
const pad = (n) => String(n).padStart(2, '0');
|
||
|
||
// The wire format the API stores: a LOCAL 'YYYY-MM-DDTHH:MM:00'. Deliberately not toISOString(),
|
||
// which converts to UTC and would shift every schedule by the browser's offset — the same class of
|
||
// bug as storing a schedule in the wrong zone.
|
||
export function toLocalStamp(date, minutes) {
|
||
const d = new Date(date.getTime());
|
||
d.setHours(0, 0, 0, 0);
|
||
d.setMinutes(minutes);
|
||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:00`;
|
||
}
|
||
|
||
export function formatRange(startMin, endMin) {
|
||
const fmt = (m) => {
|
||
const h24 = Math.floor(m / 60) % 24, mm = m % 60;
|
||
const ampm = h24 < 12 ? 'AM' : 'PM';
|
||
const h12 = h24 % 12 === 0 ? 12 : h24 % 12;
|
||
return `${h12}:${pad(mm)} ${ampm}`;
|
||
};
|
||
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;
|
||
}
|
||
|
||
// Which day a schedule occupies is NOT always its date. A one-off sits on the date in start_time,
|
||
// so dragging it sideways is a real date change. A RECURRING one appears on whatever days its rule
|
||
// expands to, so dragging an instance sideways is a change to the RULE (BYDAY), not to a time —
|
||
// a different operation with different consequences for every other instance. Refuse it here and
|
||
// send the user to the dialog rather than silently rewriting a recurrence from a mouse gesture.
|
||
export function canMoveAcrossDays(ev) {
|
||
return !(ev && ev.recurrence);
|
||
}
|
||
|
||
// Dragging a recurring event's TIME still edits the whole series, since the series has one
|
||
// time-of-day. That is worth confirming out loud rather than assuming.
|
||
export function editsWholeSeries(ev) {
|
||
return !!(ev && ev.recurrence);
|
||
}
|