mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-15 06:43:27 -06:00
Make the calendar's blocks easy to grab and move
Direct manipulation existed but was awkward, and one part of it was outright broken. A drag was recognised on ANY pointer movement, so the pixel or two of travel in an ordinary click counted as a drag and suppressed click-to-edit — the most common interaction on the calendar would have felt broken. A press now has to travel a few pixels before it becomes a drag. At 28px per hour a fifteen-minute block was seven pixels tall. Legible, but not something a pointer can reliably hit, and its resize grip would have covered the whole block. Rows are 44px, which makes the smallest block an 11px target while still fitting a full day on a laptop screen; a test pins both halves of that trade so neither can be tuned away silently. That height had been written as a bare 28 in five places in the view that all had to agree with the module — it is now one constant. The rest is feedback. A block shows a grab cursor, dims while it is being moved so it is clear what is travelling, and its grip is taller with a visible edge. While dragging, the grid switches to a grabbing cursor and suppresses touch scrolling, so the gesture works on a touchscreen instead of panning the page. Pointer capture is released and the chrome reset on every exit path, including a cancelled drag. 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
98bde220ff
commit
d2d7911efb
|
|
@ -6,7 +6,15 @@
|
||||||
// The grid is 24 rows of HOUR_PX pixels, one column per weekday. A block's vertical position is
|
// 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.
|
// therefore a pure function of minutes-since-midnight, and vice versa.
|
||||||
|
|
||||||
export const HOUR_PX = 28;
|
// 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 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 MIN_DURATION_MIN = 15; // a zero-height block is invisible and unselectable
|
||||||
export const DAY_MIN = 24 * 60;
|
export const DAY_MIN = 24 * 60;
|
||||||
|
|
@ -74,6 +82,11 @@ export function formatRange(startMin, endMin) {
|
||||||
return `${fmt(startMin)} – ${fmt(endMin)}`;
|
return `${fmt(startMin)} – ${fmt(endMin)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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,
|
// 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
|
// 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 —
|
// expands to, so dragging an instance sideways is a change to the RULE (BYDAY), not to a time —
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { showToast } from '../components/toast.js';
|
||||||
import { t } from '../i18n.js';
|
import { t } from '../i18n.js';
|
||||||
import {
|
import {
|
||||||
HOUR_PX, pxToMinutes, minutesToPx, rangeFromDrag, moveRange, resizeRange,
|
HOUR_PX, pxToMinutes, minutesToPx, rangeFromDrag, moveRange, resizeRange,
|
||||||
toLocalStamp, formatRange, canMoveAcrossDays, editsWholeSeries,
|
toLocalStamp, formatRange, canMoveAcrossDays, editsWholeSeries, isDrag,
|
||||||
} from '../lib/schedule-grid.js';
|
} 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());
|
const API = (url, opts = {}) => fetch('/api' + url, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(r => r.json());
|
||||||
|
|
@ -225,7 +225,7 @@ export async function render(container) {
|
||||||
for (const h of HOURS) {
|
for (const h of HOURS) {
|
||||||
html += `<div style="padding:4px 8px;font-size:10px;color:var(--text-muted);border-bottom:1px solid var(--border);text-align:right">${h === 0 ? t('schedule.hour_12am') : h < 12 ? h + t('schedule.hour_am') : h === 12 ? t('schedule.hour_12pm') : (h - 12) + t('schedule.hour_pm')}</div>`;
|
html += `<div style="padding:4px 8px;font-size:10px;color:var(--text-muted);border-bottom:1px solid var(--border);text-align:right">${h === 0 ? t('schedule.hour_12am') : h < 12 ? h + t('schedule.hour_am') : h === 12 ? t('schedule.hour_12pm') : (h - 12) + t('schedule.hour_pm')}</div>`;
|
||||||
for (let d = 0; d < 7; d++) {
|
for (let d = 0; d < 7; d++) {
|
||||||
html += `<div style="position:relative;min-height:28px;border-bottom:1px solid var(--border);border-left:1px solid var(--border);background:var(--bg-primary)" data-hour="${h}" data-day="${d}"></div>`;
|
html += `<div style="position:relative;min-height:${HOUR_PX}px;height:${HOUR_PX}px;border-bottom:1px solid var(--border);border-left:1px solid var(--border);background:var(--bg-primary)" data-hour="${h}" data-day="${d}"></div>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -247,14 +247,14 @@ export async function render(container) {
|
||||||
const target = targetOf(ev);
|
const target = targetOf(ev);
|
||||||
seenTargets.set(target.key, target);
|
seenTargets.set(target.key, target);
|
||||||
const block = document.createElement('div');
|
const block = document.createElement('div');
|
||||||
const topOffset = (startHour - Math.floor(startHour)) * 28;
|
const topOffset = (startHour - Math.floor(startHour)) * HOUR_PX;
|
||||||
// In all-screens mode colour identifies WHO the block is for, so several targets share
|
// In all-screens mode colour identifies WHO the block is for, so several targets share
|
||||||
// one grid and stay tellable apart. On a single screen the schedule's own colour is
|
// one grid and stay tellable apart. On a single screen the schedule's own colour is
|
||||||
// kept — there is only one target, so colour is free to mean something else.
|
// kept — there is only one target, so colour is free to mean something else.
|
||||||
const bg = allScreens ? colorForTarget(target.key) : (ev.color || '#3B82F6');
|
const bg = allScreens ? colorForTarget(target.key) : (ev.color || '#3B82F6');
|
||||||
const tall = duration * 28 >= 34;
|
const tall = duration * HOUR_PX >= 34;
|
||||||
block.style.cssText = `position:absolute;top:${topOffset}px;left:2px;right:2px;height:${Math.max(20, duration * 28)}px;
|
block.style.cssText = `position:absolute;top:${topOffset}px;left:2px;right:2px;height:${Math.max(18, duration * HOUR_PX)}px;
|
||||||
background:${bg};border-radius:3px;padding:2px 4px;font-size:10px;color:white;overflow:hidden;cursor:pointer;z-index:1;opacity:0.9;
|
background:${bg};border-radius:3px;padding:2px 4px;font-size:10px;color:white;overflow:hidden;cursor:grab;z-index:1;opacity:0.92;
|
||||||
line-height:1.25;${isGroupSchedule ? 'border:1.5px dashed rgba(255,255,255,0.65);' : ''}`;
|
line-height:1.25;${isGroupSchedule ? 'border:1.5px dashed rgba(255,255,255,0.65);' : ''}`;
|
||||||
|
|
||||||
const label = ev.title || ev.playlist_name || ev.content_name || ev.widget_name || t('schedule.scheduled_label');
|
const label = ev.title || ev.playlist_name || ev.content_name || ev.widget_name || t('schedule.scheduled_label');
|
||||||
|
|
@ -276,10 +276,11 @@ export async function render(container) {
|
||||||
block.onclick = (e) => { if (dragState && dragState.moved) return; editSchedule(ev); };
|
block.onclick = (e) => { if (dragState && dragState.moved) return; editSchedule(ev); };
|
||||||
// Bottom grip: the affordance that makes a block resizable rather than only movable.
|
// Bottom grip: the affordance that makes a block resizable rather than only movable.
|
||||||
// Hidden on very short blocks, where a grip would cover the whole thing.
|
// Hidden on very short blocks, where a grip would cover the whole thing.
|
||||||
if (duration * 28 >= 24) {
|
if (duration * HOUR_PX >= 22) {
|
||||||
const grip = document.createElement('div');
|
const grip = document.createElement('div');
|
||||||
grip.className = 'sched-resize-grip';
|
grip.className = 'sched-resize-grip';
|
||||||
grip.style.cssText = 'position:absolute;left:0;right:0;bottom:0;height:6px;cursor:ns-resize;';
|
grip.style.cssText = 'position:absolute;left:0;right:0;bottom:0;height:10px;cursor:ns-resize;'
|
||||||
|
+ 'background:linear-gradient(to bottom,transparent,rgba(0,0,0,.28));';
|
||||||
block.appendChild(grip);
|
block.appendChild(grip);
|
||||||
}
|
}
|
||||||
cell.appendChild(block);
|
cell.appendChild(block);
|
||||||
|
|
@ -353,6 +354,7 @@ export async function render(container) {
|
||||||
const startMin = gridMinutesFromEvent(e, cal);
|
const startMin = gridMinutesFromEvent(e, cal);
|
||||||
if (startMin == null) return;
|
if (startMin == null) return;
|
||||||
|
|
||||||
|
const origin = { x: e.clientX, y: e.clientY };
|
||||||
if (block && block._ev) {
|
if (block && block._ev) {
|
||||||
const ev = block._ev;
|
const ev = block._ev;
|
||||||
const s = new Date(ev.instance_start || ev.start_time);
|
const s = new Date(ev.instance_start || ev.start_time);
|
||||||
|
|
@ -361,21 +363,29 @@ export async function render(container) {
|
||||||
const evEnd = en.getHours() * 60 + en.getMinutes();
|
const evEnd = en.getHours() * 60 + en.getMinutes();
|
||||||
dragState = {
|
dragState = {
|
||||||
kind: e.target.classList.contains('sched-resize-grip') ? 'resize' : 'move',
|
kind: e.target.classList.contains('sched-resize-grip') ? 'resize' : 'move',
|
||||||
ev, block, refCell: cell, moved: false,
|
ev, block, refCell: cell, moved: false, origin,
|
||||||
grabOffset: startMin - evStart,
|
grabOffset: startMin - evStart,
|
||||||
evStart, evEnd, dayIdx: dayColumnOf(cell),
|
evStart, evEnd, dayIdx: dayColumnOf(cell),
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
dragState = { kind: 'create', anchorMin: startMin, refCell: cell, moved: false, dayIdx: dayColumnOf(cell) };
|
dragState = { kind: 'create', anchorMin: startMin, refCell: cell, moved: false, origin, dayIdx: dayColumnOf(cell) };
|
||||||
}
|
}
|
||||||
cal.setPointerCapture?.(e.pointerId);
|
cal.setPointerCapture?.(e.pointerId);
|
||||||
});
|
});
|
||||||
|
|
||||||
cal.addEventListener('pointermove', (e) => {
|
cal.addEventListener('pointermove', (e) => {
|
||||||
if (!dragState) return;
|
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.
|
||||||
|
if (!dragState.moved) {
|
||||||
|
if (!isDrag(e.clientX - dragState.origin.x, e.clientY - dragState.origin.y)) return;
|
||||||
|
dragState.moved = true;
|
||||||
|
cal.style.touchAction = 'none'; // stop a touch drag scrolling the page
|
||||||
|
cal.style.cursor = dragState.kind === 'resize' ? 'ns-resize' : 'grabbing';
|
||||||
|
if (dragState.block) dragState.block.style.opacity = '0.35'; // show what is being moved
|
||||||
|
}
|
||||||
const now = gridMinutesFromEvent(e, cal);
|
const now = gridMinutesFromEvent(e, cal);
|
||||||
if (now == null) return;
|
if (now == null) return;
|
||||||
dragState.moved = true;
|
|
||||||
let range, day = dragState.dayIdx;
|
let range, day = dragState.dayIdx;
|
||||||
if (dragState.kind === 'create') {
|
if (dragState.kind === 'create') {
|
||||||
range = rangeFromDrag(dragState.anchorMin, now);
|
range = rangeFromDrag(dragState.anchorMin, now);
|
||||||
|
|
@ -393,10 +403,17 @@ export async function render(container) {
|
||||||
showGhost(cal, day, range.startMin, range.endMin, formatRange(range.startMin, range.endMin));
|
showGhost(cal, day, range.startMin, range.endMin, formatRange(range.startMin, range.endMin));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const resetDragChrome = (st) => {
|
||||||
|
cal.style.touchAction = '';
|
||||||
|
cal.style.cursor = '';
|
||||||
|
if (st && st.block) st.block.style.opacity = '';
|
||||||
|
};
|
||||||
const finish = async (e) => {
|
const finish = async (e) => {
|
||||||
const st = dragState;
|
const st = dragState;
|
||||||
dragState = null;
|
dragState = null;
|
||||||
clearGhost();
|
clearGhost();
|
||||||
|
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 || !st.pending || !st.moved) { setTimeout(() => { if (!dragState) { /* let click through */ } }, 0); return; }
|
||||||
const { range, day } = st.pending;
|
const { range, day } = st.pending;
|
||||||
const dayDate = new Date(currentWeekStart);
|
const dayDate = new Date(currentWeekStart);
|
||||||
|
|
@ -426,7 +443,7 @@ export async function render(container) {
|
||||||
loadCalendar();
|
loadCalendar();
|
||||||
};
|
};
|
||||||
cal.addEventListener('pointerup', finish);
|
cal.addEventListener('pointerup', finish);
|
||||||
cal.addEventListener('pointercancel', () => { dragState = null; clearGhost(); });
|
cal.addEventListener('pointercancel', () => { const st = dragState; dragState = null; clearGhost(); resetDragChrome(st); });
|
||||||
|
|
||||||
// Right-click: act on what is under the pointer, like every calendar people already use.
|
// Right-click: act on what is under the pointer, like every calendar people already use.
|
||||||
cal.addEventListener('contextmenu', (e) => {
|
cal.addEventListener('contextmenu', (e) => {
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,28 @@ test('editing a repeating schedule is flagged as editing the series', async () =
|
||||||
assert.equal(G.editsWholeSeries({}), false);
|
assert.equal(G.editsWholeSeries({}), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('THE CLICK TRAP: a jiggle is not a drag', async () => {
|
||||||
|
// Every click carries a pixel or two of movement. Treating that as a drag would suppress
|
||||||
|
// click-to-edit — the most-used interaction on the calendar — and read as "clicking is broken".
|
||||||
|
assert.equal(G.isDrag(0, 0), false, 'a still click');
|
||||||
|
assert.equal(G.isDrag(1, 1), false, 'ordinary hand tremor');
|
||||||
|
assert.equal(G.isDrag(2, 2), false, 'still inside the threshold');
|
||||||
|
assert.equal(G.isDrag(0, 6), true, 'a deliberate pull IS a drag');
|
||||||
|
assert.equal(G.isDrag(-6, 0), true, 'in any direction');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a 15-minute block is big enough to actually grab', async () => {
|
||||||
|
// At the old 28px/hour it was 7px tall — legible but not a usable pointer target, and its
|
||||||
|
// resize grip would have covered the entire block.
|
||||||
|
assert.ok(G.minutesToPx(G.MIN_DURATION_MIN) >= 10,
|
||||||
|
`smallest block is ${G.minutesToPx(G.MIN_DURATION_MIN)}px`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a whole day still fits a laptop screen', async () => {
|
||||||
|
// The other half of the trade: taller rows must not turn the week view into a scrolling chore.
|
||||||
|
assert.ok(24 * G.HOUR_PX <= 1100, `full day is ${24 * G.HOUR_PX}px`);
|
||||||
|
});
|
||||||
|
|
||||||
test('the drag readout is human, not 24h minutes', async () => {
|
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(540, 630), '9:00 AM – 10:30 AM');
|
||||||
assert.equal(G.formatRange(0, 45), '12:00 AM – 12:45 AM');
|
assert.equal(G.formatRange(0, 45), '12:00 AM – 12:45 AM');
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue