mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 22:03:13 -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
|
||||
// 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 MIN_DURATION_MIN = 15; // a zero-height block is invisible and unselectable
|
||||
export const DAY_MIN = 24 * 60;
|
||||
|
|
@ -74,6 +82,11 @@ export function formatRange(startMin, 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,
|
||||
// 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 —
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { showToast } from '../components/toast.js';
|
|||
import { t } from '../i18n.js';
|
||||
import {
|
||||
HOUR_PX, pxToMinutes, minutesToPx, rangeFromDrag, moveRange, resizeRange,
|
||||
toLocalStamp, formatRange, canMoveAcrossDays, editsWholeSeries,
|
||||
toLocalStamp, formatRange, canMoveAcrossDays, editsWholeSeries, isDrag,
|
||||
} 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());
|
||||
|
|
@ -225,7 +225,7 @@ export async function render(container) {
|
|||
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>`;
|
||||
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);
|
||||
seenTargets.set(target.key, target);
|
||||
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
|
||||
// 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.
|
||||
const bg = allScreens ? colorForTarget(target.key) : (ev.color || '#3B82F6');
|
||||
const tall = duration * 28 >= 34;
|
||||
block.style.cssText = `position:absolute;top:${topOffset}px;left:2px;right:2px;height:${Math.max(20, duration * 28)}px;
|
||||
background:${bg};border-radius:3px;padding:2px 4px;font-size:10px;color:white;overflow:hidden;cursor:pointer;z-index:1;opacity:0.9;
|
||||
const tall = duration * HOUR_PX >= 34;
|
||||
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:grab;z-index:1;opacity:0.92;
|
||||
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');
|
||||
|
|
@ -276,10 +276,11 @@ export async function render(container) {
|
|||
block.onclick = (e) => { if (dragState && dragState.moved) return; editSchedule(ev); };
|
||||
// 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.
|
||||
if (duration * 28 >= 24) {
|
||||
if (duration * HOUR_PX >= 22) {
|
||||
const grip = document.createElement('div');
|
||||
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);
|
||||
}
|
||||
cell.appendChild(block);
|
||||
|
|
@ -353,6 +354,7 @@ export async function render(container) {
|
|||
const startMin = gridMinutesFromEvent(e, cal);
|
||||
if (startMin == null) return;
|
||||
|
||||
const origin = { x: e.clientX, y: e.clientY };
|
||||
if (block && block._ev) {
|
||||
const ev = block._ev;
|
||||
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();
|
||||
dragState = {
|
||||
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,
|
||||
evStart, evEnd, dayIdx: dayColumnOf(cell),
|
||||
};
|
||||
} 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.addEventListener('pointermove', (e) => {
|
||||
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);
|
||||
if (now == null) return;
|
||||
dragState.moved = true;
|
||||
let range, day = dragState.dayIdx;
|
||||
if (dragState.kind === 'create') {
|
||||
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));
|
||||
});
|
||||
|
||||
const resetDragChrome = (st) => {
|
||||
cal.style.touchAction = '';
|
||||
cal.style.cursor = '';
|
||||
if (st && st.block) st.block.style.opacity = '';
|
||||
};
|
||||
const finish = async (e) => {
|
||||
const st = dragState;
|
||||
dragState = null;
|
||||
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; }
|
||||
const { range, day } = st.pending;
|
||||
const dayDate = new Date(currentWeekStart);
|
||||
|
|
@ -426,7 +443,7 @@ export async function render(container) {
|
|||
loadCalendar();
|
||||
};
|
||||
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.
|
||||
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);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
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