Drag calendar events to another day in the week grid (#400)
A timed event in the week view now moves sideways across the columns as well as up and down, landing on the new day at the hour it was dragged to. All-day chips above the grid drag between days the way month chips do. Both drags count from the day the event was picked up on, so a multi-day event grabbed on its last day moves by the distance dragged, not by its length.
This commit is contained in:
@@ -10,6 +10,8 @@ import {
|
||||
snap,
|
||||
movePatch,
|
||||
moveByDaysPatch,
|
||||
moveAcrossPatch,
|
||||
columnsMoved,
|
||||
dayDelta,
|
||||
resizePatch,
|
||||
SNAP_MINUTES,
|
||||
@@ -180,10 +182,22 @@ describe("the patch a drag sends, computed in the event's own frame", () => {
|
||||
expect(resizePatch(3600, -600)).toEqual({ duration: "PT15M" });
|
||||
});
|
||||
|
||||
it("moves by days and minutes together, as a week-grid drag does", () => {
|
||||
expect(moveAcrossPatch("2026-09-04T14:00:00", 2, 90)).toEqual({ start: "2026-09-06T15:30:00" });
|
||||
expect(moveAcrossPatch("2026-09-04T14:00:00", -1, 0)).toEqual({ start: "2026-09-03T14:00:00" });
|
||||
expect(moveAcrossPatch("2026-09-04T14:00:00", 0, -30)).toEqual({ start: "2026-09-04T13:30:00" });
|
||||
});
|
||||
|
||||
it("adds the days as days, so a clock change does not move the hour", () => {
|
||||
// US clocks go back on 1 November 2026; 14:00 stays 14:00 across it.
|
||||
expect(moveAcrossPatch("2026-10-31T14:00:00", 2, 0)).toEqual({ start: "2026-11-02T14:00:00" });
|
||||
});
|
||||
|
||||
it("says nothing at all about a start it cannot read", () => {
|
||||
expect(movePatch("not a date", 30)).toEqual({});
|
||||
expect(moveByDaysPatch("", 3)).toEqual({});
|
||||
expect(moveByDaysPatch("2026-09-04T14:00:00", Number.NaN)).toEqual({});
|
||||
expect(moveAcrossPatch("not a date", 1, 30)).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -228,3 +242,23 @@ describe("pixelsToMinutes", () => {
|
||||
expect(SNAP_MINUTES).toBe(15);
|
||||
});
|
||||
});
|
||||
|
||||
describe("columnsMoved", () => {
|
||||
it("counts whole columns, to the nearest", () => {
|
||||
expect(columnsMoved(100, 100, 2, 7)).toBe(1);
|
||||
expect(columnsMoved(140, 100, 2, 7)).toBe(1);
|
||||
expect(columnsMoved(160, 100, 2, 7)).toBe(2);
|
||||
expect(columnsMoved(-40, 100, 2, 7)).toBe(0);
|
||||
expect(columnsMoved(-160, 100, 3, 7)).toBe(-2);
|
||||
});
|
||||
|
||||
it("stops at the edges of the week instead of wrapping", () => {
|
||||
expect(columnsMoved(-900, 100, 2, 7)).toBe(-2);
|
||||
expect(columnsMoved(900, 100, 2, 7)).toBe(4);
|
||||
});
|
||||
|
||||
it("never moves sideways in a one-day grid or before it is measured", () => {
|
||||
expect(columnsMoved(500, 100, 0, 1)).toBe(0);
|
||||
expect(columnsMoved(500, 0, 0, 7)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -142,6 +142,35 @@ export function moveByDaysPatch(storedStart: string, days: number): DragPatch {
|
||||
return { start: formatStored(moved) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Moved by whole days and by minutes at once -- the week grid, where a drag
|
||||
* goes sideways to another day and up or down to another hour in the same
|
||||
* gesture.
|
||||
*
|
||||
* The days go first and as days, for the reason moveByDaysPatch gives: a day
|
||||
* added to a wall clock keeps its time of day across a clock change, where
|
||||
* 1440 minutes would not.
|
||||
*/
|
||||
export function moveAcrossPatch(storedStart: string, days: number, deltaMinutes: number): DragPatch {
|
||||
const byDays = days ? moveByDaysPatch(storedStart, days).start : storedStart;
|
||||
if (!byDays) return {};
|
||||
return snap(deltaMinutes) ? movePatch(byDays, deltaMinutes) : { start: byDays };
|
||||
}
|
||||
|
||||
/**
|
||||
* How many columns sideways the pointer has gone, kept inside the grid.
|
||||
*
|
||||
* Counted from the column the drag began in, so an event that crosses
|
||||
* midnight moves by the same amount whichever of its two halves was picked
|
||||
* up. Past the first or last column it stops at the edge rather than
|
||||
* wrapping: the week on screen is the only week a drag can reach.
|
||||
*/
|
||||
export function columnsMoved(deltaPixels: number, columnWidth: number, fromIndex: number, columnCount: number): number {
|
||||
if (!columnWidth || columnCount < 2) return 0;
|
||||
const moved = Math.round(deltaPixels / columnWidth) || 0; // never -0
|
||||
return Math.max(-fromIndex, Math.min(columnCount - 1 - fromIndex, moved));
|
||||
}
|
||||
|
||||
/** Whole days between two local dates, ignoring the time of day on each. */
|
||||
export function dayDelta(from: Date, to: Date): number {
|
||||
const a = new Date(from.getFullYear(), from.getMonth(), from.getDate()).getTime();
|
||||
|
||||
@@ -15,7 +15,7 @@ import type { Anchor } from "@/ui/popover";
|
||||
import { CalendarContextMenu, eventColor, type CalendarContext } from "./CalendarContextMenu";
|
||||
import { toast } from "@/ui/toast";
|
||||
import { askEditScope, droppedMessage, runScoped } from "./scope";
|
||||
import { canDragEvent, dayDelta, moveByDaysPatch, movePatch, pixelsToMinutes, resizePatch, snap, type DragPatch } from "@/lib/calendar/eventDrag";
|
||||
import { canDragEvent, columnsMoved, dayDelta, moveAcrossPatch, moveByDaysPatch, pixelsToMinutes, resizePatch, snap, type DragPatch } from "@/lib/calendar/eventDrag";
|
||||
import { t as translate } from "@/lib/i18n";
|
||||
|
||||
type View = "month" | "week" | "day" | "agenda";
|
||||
@@ -237,16 +237,47 @@ function MonthView({ anchor, weekStart, onDay, onEvent, onEventContext, onSlotCo
|
||||
const weeks = [...Array(6)].map((_, w) => grid.slice(w * 7, w * 7 + 7));
|
||||
const dow = weeks[0]!.map((d) => formatWeekday(d));
|
||||
const maxPer = 4;
|
||||
const chipDrag = useChipDrag(".month-cell", onDragCommit);
|
||||
|
||||
return (
|
||||
<div className="month-grid">
|
||||
<div className="dow-row">{dow.map((d) => <div key={d}>{d}</div>)}</div>
|
||||
{weeks.map((days, wi) => (
|
||||
<div key={wi} className="week-row">
|
||||
{days.map((d) => {
|
||||
const dayEnd = addDays(d, 1);
|
||||
const evs = instances.filter((i) => i.start < dayEnd && i.end > d);
|
||||
const shown = evs.slice(0, maxPer);
|
||||
return (
|
||||
<div key={d.toISOString()} data-date={toLocalDateOnly(d)} className={`month-cell ${d.getMonth() !== anchor.getMonth() ? "other" : ""} ${isToday(d) ? "today" : ""}`} onClick={() => onCreate(d)} onDoubleClick={() => onDay(d)} onContextMenu={(e) => onSlotContext(new Date(d.getTime() + 9 * 3600_000), new Date(d.getTime() + 10 * 3600_000), false, e)}>
|
||||
<span className="day-num" onClick={(e) => { e.stopPropagation(); onDay(d); }}>{d.getDate() === 1 ? formatDayMonth(d) : d.getDate()}</span>
|
||||
{shown.map((i) => <EventChip key={i.key} inst={i} day={d} onClick={(el) => onEvent(i, el)} onContext={(e) => onEventContext(i, e)} onDragStart={canDragEvent(i.event, i.calendar) ? (e) => chipDrag.begin(i, d, e) : undefined} dragging={chipDrag.draggingKey === i.key} suppressClick={() => chipDrag.draggedRef.current} />)}
|
||||
{evs.length > maxPer && <span className="more" onClick={(e) => { e.stopPropagation(); onDay(d); }}>{translate("+{n} more", { n: evs.length - maxPer })}</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
* Dragging a chip to another day: the month grid, and the all-day row above
|
||||
* the week grid. A cell there is a day and nothing finer, so the only question
|
||||
* a drag asks is "which day". The cell under the pointer is found by asking
|
||||
* the document rather than by tracking enter and leave on every cell: one
|
||||
* question at the end beats bookkeeping throughout.
|
||||
*
|
||||
* The move is counted from the day the chip was picked up on, not from the
|
||||
* event's first day, so a three-day event grabbed on its last day and dropped
|
||||
* one cell to the right moves one day, not three.
|
||||
*/
|
||||
function useChipDrag(cellSelector: string, onDragCommit: (i: EventInstance, patch: DragPatch) => void) {
|
||||
const [draggingKey, setDraggingKey] = useState<string | null>(null);
|
||||
const draggedRef = useRef(false);
|
||||
|
||||
/*
|
||||
* A month cell is a day and nothing finer, so the only question a drag here
|
||||
* asks is "which day". The cell under the pointer is found by asking the
|
||||
* document rather than by tracking enter and leave on forty-two cells: one
|
||||
* question at the end beats bookkeeping throughout.
|
||||
*/
|
||||
const beginChipDrag = (inst: EventInstance, e: React.PointerEvent) => {
|
||||
const begin = (inst: EventInstance, grabbedOn: Date, e: React.PointerEvent) => {
|
||||
if (e.button !== 0 && e.pointerType === "mouse") return;
|
||||
if (!canDragEvent(inst.event, inst.calendar)) return;
|
||||
e.stopPropagation();
|
||||
@@ -255,7 +286,7 @@ function MonthView({ anchor, weekStart, onDay, onEvent, onEventContext, onSlotCo
|
||||
el.setPointerCapture(e.pointerId);
|
||||
let landedOn: string | null = null;
|
||||
const onPointerMove = (ev: PointerEvent) => {
|
||||
const cell = document.elementFromPoint(ev.clientX, ev.clientY)?.closest<HTMLElement>(".month-cell");
|
||||
const cell = document.elementFromPoint(ev.clientX, ev.clientY)?.closest<HTMLElement>(cellSelector);
|
||||
const date = cell?.dataset.date ?? null;
|
||||
if (date) landedOn = date;
|
||||
if (!draggedRef.current) draggedRef.current = true;
|
||||
@@ -277,8 +308,8 @@ function MonthView({ anchor, weekStart, onDay, onEvent, onEventContext, onSlotCo
|
||||
const target = parts && parts.length === 3 ? new Date(parts[0]!, parts[1]! - 1, parts[2]!) : null;
|
||||
// How far the hand moved it, in local days -- see moveByDaysPatch for
|
||||
// why the target date itself is the wrong thing to write.
|
||||
if (target && !isSameDay(target, inst.start)) {
|
||||
onDragCommit(inst, moveByDaysPatch(inst.event.start, dayDelta(inst.start, target)));
|
||||
if (target && !isSameDay(target, grabbedOn)) {
|
||||
onDragCommit(inst, moveByDaysPatch(inst.event.start, dayDelta(grabbedOn, target)));
|
||||
}
|
||||
window.setTimeout(() => (draggedRef.current = false), 0);
|
||||
};
|
||||
@@ -287,27 +318,7 @@ function MonthView({ anchor, weekStart, onDay, onEvent, onEventContext, onSlotCo
|
||||
el.addEventListener("pointercancel", finish);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="month-grid">
|
||||
<div className="dow-row">{dow.map((d) => <div key={d}>{d}</div>)}</div>
|
||||
{weeks.map((days, wi) => (
|
||||
<div key={wi} className="week-row">
|
||||
{days.map((d) => {
|
||||
const dayEnd = addDays(d, 1);
|
||||
const evs = instances.filter((i) => i.start < dayEnd && i.end > d);
|
||||
const shown = evs.slice(0, maxPer);
|
||||
return (
|
||||
<div key={d.toISOString()} data-date={toLocalDateOnly(d)} className={`month-cell ${d.getMonth() !== anchor.getMonth() ? "other" : ""} ${isToday(d) ? "today" : ""}`} onClick={() => onCreate(d)} onDoubleClick={() => onDay(d)} onContextMenu={(e) => onSlotContext(new Date(d.getTime() + 9 * 3600_000), new Date(d.getTime() + 10 * 3600_000), false, e)}>
|
||||
<span className="day-num" onClick={(e) => { e.stopPropagation(); onDay(d); }}>{d.getDate() === 1 ? formatDayMonth(d) : d.getDate()}</span>
|
||||
{shown.map((i) => <EventChip key={i.key} inst={i} day={d} onClick={(el) => onEvent(i, el)} onContext={(e) => onEventContext(i, e)} onDragStart={canDragEvent(i.event, i.calendar) ? (e) => beginChipDrag(i, e) : undefined} dragging={draggingKey === i.key} suppressClick={() => draggedRef.current} />)}
|
||||
{evs.length > maxPer && <span className="more" onClick={(e) => { e.stopPropagation(); onDay(d); }}>{translate("+{n} more", { n: evs.length - maxPer })}</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
return { begin, draggingKey, draggedRef };
|
||||
}
|
||||
|
||||
function statusClass(i: EventInstance): string {
|
||||
@@ -358,24 +369,36 @@ function TimeGrid({ days, onEvent, onEventContext, onSlotContext, onCreate, onDa
|
||||
* new time, so the preview is one number and the commit is the same
|
||||
* arithmetic the tests cover.
|
||||
*/
|
||||
const [moving, setMoving] = useState<{ key: string; deltaMin: number; mode: "move" | "resize" } | null>(null);
|
||||
const [moving, setMoving] = useState<{ key: string; deltaMin: number; deltaDays: number; shiftPx: number; mode: "move" | "resize" } | null>(null);
|
||||
/* A drag ends with a pointerup, and a pointerup on the same element is also
|
||||
a click. Without this, letting go of a moved event opens its popover. */
|
||||
const draggedRef = useRef(false);
|
||||
const allDayDrag = useChipDrag(".ad-cell", onDragCommit);
|
||||
|
||||
const beginDrag = (inst: EventInstance, mode: "move" | "resize", e: React.PointerEvent) => {
|
||||
/*
|
||||
* A move goes sideways as well as up and down: across the columns to
|
||||
* another day, keeping whatever hour it was dragged to. The block stays in
|
||||
* its own column while it moves and is drawn shifted by whole columns, so
|
||||
* the preview is two numbers and nothing is re-laid-out until it lands.
|
||||
* A resize only ever changes the end, so it stays vertical.
|
||||
*/
|
||||
const beginDrag = (inst: EventInstance, mode: "move" | "resize", column: number, e: React.PointerEvent) => {
|
||||
if (e.button !== 0 && e.pointerType === "mouse") return;
|
||||
if (!canDragEvent(inst.event, inst.calendar)) return;
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
const el = e.currentTarget as HTMLElement;
|
||||
const startX = e.clientX;
|
||||
const startY = e.clientY;
|
||||
const colWidth = mode === "move" ? el.closest<HTMLElement>(".day-col")?.getBoundingClientRect().width ?? 0 : 0;
|
||||
let delta = 0;
|
||||
let deltaDays = 0;
|
||||
el.setPointerCapture(e.pointerId);
|
||||
const onPointerMove = (ev: PointerEvent) => {
|
||||
delta = snap(pixelsToMinutes(ev.clientY - startY, HOUR_H));
|
||||
if (delta !== 0) draggedRef.current = true;
|
||||
setMoving({ key: inst.key, deltaMin: delta, mode });
|
||||
deltaDays = columnsMoved(ev.clientX - startX, colWidth, column, days.length);
|
||||
if (delta !== 0 || deltaDays !== 0) draggedRef.current = true;
|
||||
setMoving({ key: inst.key, deltaMin: delta, deltaDays, shiftPx: deltaDays * colWidth, mode });
|
||||
};
|
||||
const finish = () => {
|
||||
el.removeEventListener("pointermove", onPointerMove);
|
||||
@@ -387,9 +410,9 @@ function TimeGrid({ days, onEvent, onEventContext, onSlotContext, onCreate, onDa
|
||||
/* already released, which is fine */
|
||||
}
|
||||
setMoving(null);
|
||||
if (delta !== 0) {
|
||||
if (delta !== 0 || deltaDays !== 0) {
|
||||
const seconds = (inst.end.getTime() - inst.start.getTime()) / 1000;
|
||||
onDragCommit(inst, mode === "move" ? movePatch(inst.event.start, delta) : resizePatch(seconds, delta));
|
||||
onDragCommit(inst, mode === "move" ? moveAcrossPatch(inst.event.start, deltaDays, delta) : resizePatch(seconds, delta));
|
||||
}
|
||||
// Cleared after the click that follows this pointerup has been swallowed.
|
||||
window.setTimeout(() => (draggedRef.current = false), 0);
|
||||
@@ -431,8 +454,8 @@ function TimeGrid({ days, onEvent, onEventContext, onSlotContext, onCreate, onDa
|
||||
<div className="week-allday">
|
||||
<div className="ad-label">{translate("all-day")}</div>
|
||||
{days.map((d) => (
|
||||
<div key={d.toISOString()} className="ad-cell" onClick={() => onCreate(d, addDays(d, 1), true)} onContextMenu={(e) => onSlotContext(d, addDays(d, 1), true, e)}>
|
||||
{allDay(d).map((i) => <EventChip key={i.key} inst={i} day={d} onClick={(el) => onEvent(i, el)} onContext={(e) => onEventContext(i, e)} />)}
|
||||
<div key={d.toISOString()} data-date={toLocalDateOnly(d)} className="ad-cell" onClick={() => onCreate(d, addDays(d, 1), true)} onContextMenu={(e) => onSlotContext(d, addDays(d, 1), true, e)}>
|
||||
{allDay(d).map((i) => <EventChip key={i.key} inst={i} day={d} onClick={(el) => onEvent(i, el)} onContext={(e) => onEventContext(i, e)} onDragStart={canDragEvent(i.event, i.calendar) ? (e) => allDayDrag.begin(i, d, e) : undefined} dragging={allDayDrag.draggingKey === i.key} suppressClick={() => allDayDrag.draggedRef.current} />)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -441,7 +464,7 @@ function TimeGrid({ days, onEvent, onEventContext, onSlotContext, onCreate, onDa
|
||||
<div className="time-col">
|
||||
{[...Array(24)].map((_, h) => h > 0 && <span key={h} className="hour-label" style={{ top: h * HOUR_H }}>{formatHourLabel(h)}</span>)}
|
||||
</div>
|
||||
{days.map((d) => {
|
||||
{days.map((d, column) => {
|
||||
const evs = layoutOverlaps(timed(d), d);
|
||||
const today = isToday(d);
|
||||
const nowTop = ((now.getHours() * 60 + now.getMinutes()) / 60) * HOUR_H;
|
||||
@@ -490,9 +513,10 @@ function TimeGrid({ days, onEvent, onEventContext, onSlotContext, onCreate, onDa
|
||||
height: Math.max(height + (moving?.key === inst.key && moving.mode === "resize" ? (moving.deltaMin / 60) * HOUR_H : 0), 18),
|
||||
left: `${left}%`,
|
||||
width: `calc(${width}% - 3px)`,
|
||||
transform: moving?.key === inst.key && moving.shiftPx ? `translateX(${moving.shiftPx}px)` : undefined,
|
||||
background: color,
|
||||
}}
|
||||
onPointerDown={(e) => beginDrag(inst, "move", e)}
|
||||
onPointerDown={(e) => beginDrag(inst, "move", column, e)}
|
||||
onClick={(e) => { e.stopPropagation(); if (draggedRef.current) return; onEvent(inst, e.currentTarget); }}
|
||||
onContextMenu={(e) => onEventContext(inst, e)}
|
||||
title={inst.event.title ?? ""}
|
||||
@@ -501,7 +525,7 @@ function TimeGrid({ days, onEvent, onEventContext, onSlotContext, onCreate, onDa
|
||||
/* Its own element rather than an edge zone on the block,
|
||||
so a thumb has something to aim at and the move drag
|
||||
does not have to guess which one was meant. */
|
||||
<div className="ev-resize" onPointerDown={(e) => beginDrag(inst, "resize", e)} aria-hidden="true" />
|
||||
<div className="ev-resize" onPointerDown={(e) => beginDrag(inst, "resize", column, e)} aria-hidden="true" />
|
||||
)}
|
||||
<div className="ev-title">{inst.event.title || "(untitled)"}</div>
|
||||
{height > 30 && <div className="ev-time">{formatTime(inst.start)} – {formatTime(inst.end)}</div>}
|
||||
|
||||
Reference in New Issue
Block a user