Merge pull request #210 from Coffey-Labs/feat/drag-reschedule
Drag an event to move it, and its edge to resize it
This commit is contained in:
+19
-1
@@ -638,7 +638,25 @@ work:
|
|||||||
*This and future* is not offered: the server refuses an occurrence that belongs
|
*This and future* is not offered: the server refuses an occurrence that belongs
|
||||||
to such a change, and where it does, ihasmail says so and offers the series.
|
to such a change, and where it does, ihasmail says so and offers the series.
|
||||||
|
|
||||||
Events are edited in the editor rather than dragged around the grid.
|
**Events are dragged.** In the day and week grids an event moves by dragging
|
||||||
|
it and changes length by dragging its bottom edge, both snapping to fifteen
|
||||||
|
minutes; in the month grid it moves to another day and keeps the time it had.
|
||||||
|
The editor is still there and still does everything a drag cannot.
|
||||||
|
|
||||||
|
- **A recurring event asks which dates it means**, the same question the menu
|
||||||
|
asks, and goes through the same path — so a date the server will only change
|
||||||
|
as part of a whole series offers that rather than failing.
|
||||||
|
- **Only where it can be saved.** A read-only calendar offers no drag, and
|
||||||
|
neither does a birthday: it is derived from a contact and there is nothing on
|
||||||
|
the server to move.
|
||||||
|
- **Invitations are not sent.** A drag is a scheduling gesture, and mailing
|
||||||
|
every guest on each nudge of a block is not what the hand was asking for. A
|
||||||
|
change that should go out with notice goes through the editor.
|
||||||
|
- The new time is worked out **in the event's own frame** rather than through
|
||||||
|
an instant: its stored wall clock is what moves, and its time zone is not
|
||||||
|
touched. Computing a new time from the reader's local hours and then
|
||||||
|
re-expressing it in the event's zone converts twice, and the two do not
|
||||||
|
cancel.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
canDragEvent,
|
||||||
|
formatDuration,
|
||||||
|
MIN_DURATION_MINUTES,
|
||||||
|
movedBy,
|
||||||
|
movedToDay,
|
||||||
|
pixelsToMinutes,
|
||||||
|
resizedBy,
|
||||||
|
snap,
|
||||||
|
movePatch,
|
||||||
|
moveToDayPatch,
|
||||||
|
resizePatch,
|
||||||
|
SNAP_MINUTES,
|
||||||
|
} from "@/lib/eventDrag";
|
||||||
|
import { BIRTHDAY_ID_PREFIX } from "@/lib/birthdays";
|
||||||
|
import type { CalendarEvent } from "@/jmap/types";
|
||||||
|
|
||||||
|
const at = (h: number, m = 0, d = 4) => new Date(2026, 8, d, h, m, 0, 0);
|
||||||
|
const span = (from: Date, to: Date) => ({ start: from, end: to });
|
||||||
|
const hhmm = (d: Date) => `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
||||||
|
const ymd = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||||
|
|
||||||
|
describe("snap", () => {
|
||||||
|
it("rounds to the nearest quarter hour", () => {
|
||||||
|
expect(snap(0)).toBe(0);
|
||||||
|
expect(snap(7)).toBe(0);
|
||||||
|
expect(snap(8)).toBe(15);
|
||||||
|
expect(snap(22)).toBe(15);
|
||||||
|
expect(snap(23)).toBe(30);
|
||||||
|
expect(snap(-8)).toBe(-15);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takes another slot when asked", () => {
|
||||||
|
expect(snap(20, 30)).toBe(30);
|
||||||
|
expect(snap(14, 30)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("movedBy", () => {
|
||||||
|
it("moves both ends, so the length does not change", () => {
|
||||||
|
const out = movedBy(span(at(14), at(15)), 30);
|
||||||
|
expect(hhmm(out.start)).toBe("14:30");
|
||||||
|
expect(hhmm(out.end)).toBe("15:30");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("snaps the drag rather than taking it literally", () => {
|
||||||
|
const out = movedBy(span(at(14), at(15)), 7);
|
||||||
|
expect(hhmm(out.start)).toBe("14:00");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves backwards too", () => {
|
||||||
|
const out = movedBy(span(at(14), at(15)), -60);
|
||||||
|
expect(hhmm(out.start)).toBe("13:00");
|
||||||
|
expect(hhmm(out.end)).toBe("14:00");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carries an event across midnight without losing its length", () => {
|
||||||
|
const out = movedBy(span(at(23, 30), at(23, 45)), 60);
|
||||||
|
expect(ymd(out.start)).toBe("2026-09-05");
|
||||||
|
expect(hhmm(out.start)).toBe("00:30");
|
||||||
|
expect(out.end.getTime() - out.start.getTime()).toBe(15 * 60_000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("movedToDay", () => {
|
||||||
|
it("keeps the time of day, which is what the month grid is not asking about", () => {
|
||||||
|
// Dragged from Friday to Monday: still at two o'clock.
|
||||||
|
const out = movedToDay(span(at(14), at(15, 30)), new Date(2026, 8, 7));
|
||||||
|
expect(ymd(out.start)).toBe("2026-09-07");
|
||||||
|
expect(hhmm(out.start)).toBe("14:00");
|
||||||
|
expect(hhmm(out.end)).toBe("15:30");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a length that spans days", () => {
|
||||||
|
const out = movedToDay(span(at(14, 0, 4), at(10, 0, 6)), new Date(2026, 8, 20));
|
||||||
|
expect(ymd(out.start)).toBe("2026-09-20");
|
||||||
|
expect(ymd(out.end)).toBe("2026-09-22");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves across a month boundary", () => {
|
||||||
|
const out = movedToDay(span(at(9), at(10)), new Date(2026, 9, 1));
|
||||||
|
expect(ymd(out.start)).toBe("2026-10-01");
|
||||||
|
expect(hhmm(out.start)).toBe("09:00");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resizedBy", () => {
|
||||||
|
it("moves the end and leaves the start alone", () => {
|
||||||
|
const out = resizedBy(span(at(14), at(15)), 30);
|
||||||
|
expect(hhmm(out.start)).toBe("14:00");
|
||||||
|
expect(hhmm(out.end)).toBe("15:30");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps at one slot rather than refusing the drag", () => {
|
||||||
|
// A drag that goes too far is still a drag; stopping is what the reader
|
||||||
|
// sees happening while they do it.
|
||||||
|
const out = resizedBy(span(at(14), at(15)), -600);
|
||||||
|
expect(out.end.getTime() - out.start.getTime()).toBe(MIN_DURATION_MINUTES * 60_000);
|
||||||
|
expect(hhmm(out.end)).toBe("14:15");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never lets the end cross the start", () => {
|
||||||
|
for (const delta of [-60, -120, -1000]) {
|
||||||
|
const out = resizedBy(span(at(9), at(9, 30)), delta);
|
||||||
|
expect(out.end.getTime()).toBeGreaterThan(out.start.getTime());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatDuration", () => {
|
||||||
|
it("writes the shapes the wire expects", () => {
|
||||||
|
expect(formatDuration(3600)).toBe("PT1H");
|
||||||
|
expect(formatDuration(5400)).toBe("PT1H30M");
|
||||||
|
expect(formatDuration(900)).toBe("PT15M");
|
||||||
|
expect(formatDuration(86400)).toBe("P1D");
|
||||||
|
expect(formatDuration(90000)).toBe("P1DT1H");
|
||||||
|
expect(formatDuration(0)).toBe("PT0S");
|
||||||
|
expect(formatDuration(45)).toBe("PT45S");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the patch a drag sends, computed in the event's own frame", () => {
|
||||||
|
/*
|
||||||
|
* The bug this shape exists to prevent: working the new time out from the
|
||||||
|
* reader's local hours and then re-expressing it in the event's zone
|
||||||
|
* converts twice, and the two do not cancel. An event two hours from the
|
||||||
|
* reader jumped two hours the first time it was dragged and then sat still.
|
||||||
|
* None of these functions touches a zone at all.
|
||||||
|
*/
|
||||||
|
it("moves the stored start by the snapped delta", () => {
|
||||||
|
expect(movePatch("2026-09-04T14:00:00", 30)).toEqual({ start: "2026-09-04T14:30:00" });
|
||||||
|
expect(movePatch("2026-09-04T14:00:00", -60)).toEqual({ start: "2026-09-04T13:00:00" });
|
||||||
|
expect(movePatch("2026-09-04T14:00:00", 7)).toEqual({ start: "2026-09-04T14:00:00" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carries a move across midnight and across a month", () => {
|
||||||
|
expect(movePatch("2026-09-30T23:30:00", 60)).toEqual({ start: "2026-10-01T00:30:00" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never sends a duration for a move, so the length is left alone", () => {
|
||||||
|
expect(movePatch("2026-09-04T14:00:00", 30).duration).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the time of day when moving to another date", () => {
|
||||||
|
expect(moveToDayPatch("2026-09-04T14:30:00", new Date(2026, 8, 10))).toEqual({ start: "2026-09-10T14:30:00" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never sends a start for a resize, so the zone question does not arise", () => {
|
||||||
|
const patch = resizePatch(3600, 60);
|
||||||
|
expect(patch).toEqual({ duration: "PT2H" });
|
||||||
|
expect(patch.start).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps a resize at one slot", () => {
|
||||||
|
expect(resizePatch(3600, -600)).toEqual({ duration: "PT15M" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says nothing at all about a start it cannot read", () => {
|
||||||
|
expect(movePatch("not a date", 30)).toEqual({});
|
||||||
|
expect(moveToDayPatch("", new Date(2026, 8, 10))).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("canDragEvent", () => {
|
||||||
|
const writable = { myRights: { mayWriteAll: true } };
|
||||||
|
const readonly = { myRights: { mayWriteAll: false, mayWriteOwn: false } };
|
||||||
|
const event = { id: "e1" } as CalendarEvent;
|
||||||
|
|
||||||
|
it("allows a normal event on a calendar you can write to", () => {
|
||||||
|
expect(canDragEvent(event, writable)).toBe(true);
|
||||||
|
expect(canDragEvent(event, { myRights: { mayWriteOwn: true } })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a birthday, which is derived and has nothing to move", () => {
|
||||||
|
expect(canDragEvent({ id: `${BIRTHDAY_ID_PREFIX}c1:2026` } as CalendarEvent, writable)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a calendar you cannot write to, and one that is not there", () => {
|
||||||
|
expect(canDragEvent(event, readonly)).toBe(false);
|
||||||
|
expect(canDragEvent(event, undefined)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses nothing at all", () => {
|
||||||
|
expect(canDragEvent(null, writable)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("pixelsToMinutes", () => {
|
||||||
|
it("converts against the grid's own scale", () => {
|
||||||
|
expect(pixelsToMinutes(48, 48)).toBe(60);
|
||||||
|
expect(pixelsToMinutes(24, 48)).toBe(30);
|
||||||
|
expect(pixelsToMinutes(-48, 48)).toBe(-60);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says nothing rather than dividing by zero before the grid is measured", () => {
|
||||||
|
expect(pixelsToMinutes(100, 0)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips through snap to the slot the pointer is over", () => {
|
||||||
|
expect(snap(pixelsToMinutes(10, 48))).toBe(15);
|
||||||
|
expect(snap(pixelsToMinutes(2, 48))).toBe(0);
|
||||||
|
expect(SNAP_MINUTES).toBe(15);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
/**
|
||||||
|
* Moving and resizing an event by dragging it.
|
||||||
|
*
|
||||||
|
* The arithmetic lives here, away from the grids and under test, for the same
|
||||||
|
* reason the swipe thresholds do: the numbers are the whole thing, and a
|
||||||
|
* mistake in them moves somebody's meeting to the wrong hour rather than
|
||||||
|
* merely looking wrong.
|
||||||
|
*
|
||||||
|
* Nothing here talks to the server or knows what a scope is. It answers one
|
||||||
|
* question — given an event and a gesture, what are the new start and end —
|
||||||
|
* and the caller decides whether it is allowed to save that.
|
||||||
|
*/
|
||||||
|
import { addMinutes } from "./dates";
|
||||||
|
import { isBirthdayEvent } from "./birthdays";
|
||||||
|
import type { CalendarEvent } from "@/jmap/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fifteen minutes, which is the smallest slot anybody schedules against and
|
||||||
|
* the largest that still lands where the pointer looks like it is.
|
||||||
|
*/
|
||||||
|
export const SNAP_MINUTES = 15;
|
||||||
|
|
||||||
|
/** An event has to keep some length; dragging its end past its start is not a request. */
|
||||||
|
export const MIN_DURATION_MINUTES = 15;
|
||||||
|
|
||||||
|
/** Round a count of minutes to the nearest slot, away from zero on a tie. */
|
||||||
|
export function snap(minutes: number, slot: number = SNAP_MINUTES): number {
|
||||||
|
return Math.round(minutes / slot) * slot;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Span {
|
||||||
|
start: Date;
|
||||||
|
end: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moved by a number of minutes, keeping its length.
|
||||||
|
*
|
||||||
|
* Both ends move together: dragging the middle of an event is asking for it to
|
||||||
|
* happen at another time, not to become a different length.
|
||||||
|
*/
|
||||||
|
export function movedBy(span: Span, deltaMinutes: number): Span {
|
||||||
|
const delta = snap(deltaMinutes);
|
||||||
|
return { start: addMinutes(span.start, delta), end: addMinutes(span.end, delta) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moved to another day, keeping its time of day and its length.
|
||||||
|
*
|
||||||
|
* This is the month grid, where a cell is a day and nothing finer. An event
|
||||||
|
* dragged from Tuesday to Friday should still be at two o'clock; changing the
|
||||||
|
* hour as well would be answering a question nobody asked.
|
||||||
|
*/
|
||||||
|
export function movedToDay(span: Span, day: Date): Span {
|
||||||
|
const length = span.end.getTime() - span.start.getTime();
|
||||||
|
const start = new Date(day.getFullYear(), day.getMonth(), day.getDate(), span.start.getHours(), span.start.getMinutes(), 0, 0);
|
||||||
|
return { start, end: new Date(start.getTime() + length) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resized from its end, never shorter than one slot.
|
||||||
|
*
|
||||||
|
* The floor is a clamp rather than a refusal: a drag that goes too far is
|
||||||
|
* still a drag, and stopping at fifteen minutes is what the reader sees
|
||||||
|
* happening while they do it.
|
||||||
|
*/
|
||||||
|
export function resizedBy(span: Span, deltaMinutes: number): Span {
|
||||||
|
const end = addMinutes(span.end, snap(deltaMinutes));
|
||||||
|
const minimum = addMinutes(span.start, MIN_DURATION_MINUTES);
|
||||||
|
return { start: span.start, end: end.getTime() < minimum.getTime() ? minimum : end };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seconds, as an ISO 8601 duration — the shape `duration` takes on the wire. */
|
||||||
|
export function formatDuration(seconds: number): string {
|
||||||
|
const total = Math.max(0, Math.round(seconds));
|
||||||
|
const days = Math.floor(total / 86400);
|
||||||
|
const hours = Math.floor((total % 86400) / 3600);
|
||||||
|
const minutes = Math.floor((total % 3600) / 60);
|
||||||
|
const secs = total % 60;
|
||||||
|
if (!total) return "PT0S";
|
||||||
|
const time = [hours && `${hours}H`, minutes && `${minutes}M`, secs && `${secs}S`].filter(Boolean).join("");
|
||||||
|
return `P${days ? `${days}D` : ""}${time ? `T${time}` : ""}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The patch a move or a resize sends.
|
||||||
|
*
|
||||||
|
* **Computed in the event's own frame, never through an instant.** An event
|
||||||
|
* carries a wall-clock `start` and a `timeZone`, and the grid draws it at the
|
||||||
|
* reader's local time. Working out a new time from those local hours and then
|
||||||
|
* re-expressing it in the event's zone converts twice, and the two conversions
|
||||||
|
* do not cancel: an event in a zone two hours from the reader's moved two
|
||||||
|
* hours the first time it was dragged, and then sat still, because after that
|
||||||
|
* its stored time and the reader's happened to agree.
|
||||||
|
*
|
||||||
|
* Parsing the stored string into its parts and adding minutes to those parts
|
||||||
|
* touches no zone at all, so there is nothing to get wrong. The zone itself is
|
||||||
|
* left exactly as it was: dragging an event is not a claim about where it
|
||||||
|
* happens.
|
||||||
|
*/
|
||||||
|
function parseStored(start: string): Date | null {
|
||||||
|
const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?/.exec(start ?? "");
|
||||||
|
if (!m) return null;
|
||||||
|
return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]), Number(m[4]), Number(m[5]), Number(m[6] ?? 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatStored(d: Date): string {
|
||||||
|
const p = (n: number) => String(n).padStart(2, "0");
|
||||||
|
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DragPatch {
|
||||||
|
start?: string;
|
||||||
|
duration?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Moved by a number of minutes, in the event's own frame. */
|
||||||
|
export function movePatch(storedStart: string, deltaMinutes: number): DragPatch {
|
||||||
|
const base = parseStored(storedStart);
|
||||||
|
if (!base) return {};
|
||||||
|
return { start: formatStored(addMinutes(base, snap(deltaMinutes))) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Moved to another date, keeping the time of day it already had. */
|
||||||
|
export function moveToDayPatch(storedStart: string, day: Date): DragPatch {
|
||||||
|
const base = parseStored(storedStart);
|
||||||
|
if (!base) return {};
|
||||||
|
const moved = new Date(day.getFullYear(), day.getMonth(), day.getDate(), base.getHours(), base.getMinutes(), base.getSeconds(), 0);
|
||||||
|
return { start: formatStored(moved) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resized from its end. Only the duration moves, so the start -- and with it
|
||||||
|
* the whole question of zones -- is not touched at all.
|
||||||
|
*/
|
||||||
|
export function resizePatch(currentSeconds: number, deltaMinutes: number): DragPatch {
|
||||||
|
const seconds = Math.max(MIN_DURATION_MINUTES * 60, currentSeconds + snap(deltaMinutes) * 60);
|
||||||
|
return { duration: formatDuration(seconds) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this event can be dragged at all.
|
||||||
|
*
|
||||||
|
* Three separate reasons it might not be, and they are checked here so no grid
|
||||||
|
* has to remember all three:
|
||||||
|
*
|
||||||
|
* - **A birthday is derived**, not stored. There is nothing on the server to
|
||||||
|
* move, and the date belongs to a contact rather than to a calendar.
|
||||||
|
* - **The calendar may be read-only** — someone else's, shared without write
|
||||||
|
* rights. This is the same question the popover asks before offering Edit.
|
||||||
|
* - **An event with no calendar** has nowhere to be saved.
|
||||||
|
*/
|
||||||
|
export function canDragEvent(event: CalendarEvent | null | undefined, calendar: { myRights?: { mayWriteAll?: boolean; mayWriteOwn?: boolean } } | undefined): boolean {
|
||||||
|
if (!event || isBirthdayEvent(event.id)) return false;
|
||||||
|
if (!calendar) return false;
|
||||||
|
return Boolean(calendar.myRights?.mayWriteAll || calendar.myRights?.mayWriteOwn);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How far the pointer moved, in minutes, given a grid's pixels-per-hour. */
|
||||||
|
export function pixelsToMinutes(deltaPixels: number, hourHeight: number): number {
|
||||||
|
if (!hourHeight) return 0;
|
||||||
|
return (deltaPixels / hourHeight) * 60;
|
||||||
|
}
|
||||||
@@ -1771,3 +1771,11 @@ button.dp-open:disabled { cursor: default; opacity: .5; }
|
|||||||
.mode-switch button { padding: 6px 14px; background: none; border: 0; color: var(--fg-muted); cursor: pointer; font: inherit; }
|
.mode-switch button { padding: 6px 14px; background: none; border: 0; color: var(--fg-muted); cursor: pointer; font: inherit; }
|
||||||
.mode-switch button.active { background: var(--accent-soft); color: var(--accent-soft-fg); }
|
.mode-switch button.active { background: var(--accent-soft); color: var(--accent-soft-fg); }
|
||||||
.mode-switch button:disabled { opacity: .45; cursor: not-allowed; }
|
.mode-switch button:disabled { opacity: .45; cursor: not-allowed; }
|
||||||
|
|
||||||
|
/* Dragging an event to another time or day. */
|
||||||
|
.ev-block.draggable, .ev-chip.draggable { cursor: grab; touch-action: none; }
|
||||||
|
.ev-block.dragging, .ev-chip.dragging { cursor: grabbing; opacity: .75; box-shadow: var(--shadow-2); z-index: 5; }
|
||||||
|
.ev-block { position: absolute; }
|
||||||
|
.ev-resize { position: absolute; left: 0; right: 0; bottom: 0; height: 8px; cursor: ns-resize; touch-action: none; }
|
||||||
|
.ev-resize::after { content: ""; position: absolute; left: 50%; bottom: 2px; width: 18px; height: 2px; margin-left: -9px; border-radius: 2px; background: currentColor; opacity: 0; }
|
||||||
|
.ev-block:hover .ev-resize::after { opacity: .5; }
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useLocation } from "wouter";
|
|||||||
import { ChevronLeft, ChevronRight, Plus, Calendar as CalIcon } from "lucide-react";
|
import { ChevronLeft, ChevronRight, Plus, Calendar as CalIcon } from "lucide-react";
|
||||||
import { useCalendar, participantAddresses, type EventInstance } from "@/store/calendar";
|
import { useCalendar, participantAddresses, type EventInstance } from "@/store/calendar";
|
||||||
import { useSettings } from "@/store/settings";
|
import { useSettings } from "@/store/settings";
|
||||||
import { addDays, addMonths, DAY_MS, endOfDay, isSameDay, isToday, monthGrid, roundToNext, startOfDay, startOfWeek, toLocalDateOnly, weekDays } from "@/lib/dates";
|
import { addDays, addMonths, DAY_MS, endOfDay, isSameDay, isToday, monthGrid, roundToNext, startOfDay, startOfWeek, toLocalDateOnly, weekDays} from "@/lib/dates";
|
||||||
import { useSwipeNav } from "@/lib/touch";
|
import { useSwipeNav } from "@/lib/touch";
|
||||||
import { formatMonthYear, formatTime } from "@/lib/format";
|
import { formatMonthYear, formatTime } from "@/lib/format";
|
||||||
import { formatDate, formatDateLong, formatDayMonth, formatHourLabel, formatWeekday, formatWeekdayDate } from "@/lib/datetime";
|
import { formatDate, formatDateLong, formatDayMonth, formatHourLabel, formatWeekday, formatWeekdayDate } from "@/lib/datetime";
|
||||||
@@ -13,6 +13,9 @@ import { EventPopover } from "./EventPopover";
|
|||||||
import { EventEditor, type EditorInit } from "./EventEditor";
|
import { EventEditor, type EditorInit } from "./EventEditor";
|
||||||
import type { Anchor } from "@/ui/popover";
|
import type { Anchor } from "@/ui/popover";
|
||||||
import { CalendarContextMenu, eventColor, type CalendarContext } from "./CalendarContextMenu";
|
import { CalendarContextMenu, eventColor, type CalendarContext } from "./CalendarContextMenu";
|
||||||
|
import { toast } from "@/ui/toast";
|
||||||
|
import { askEditScope, droppedMessage, runScoped } from "./scope";
|
||||||
|
import { canDragEvent, moveToDayPatch, movePatch, pixelsToMinutes, resizePatch, snap, type DragPatch } from "@/lib/eventDrag";
|
||||||
import { t as translate } from "@/lib/i18n";
|
import { t as translate } from "@/lib/i18n";
|
||||||
|
|
||||||
type View = "month" | "week" | "day" | "agenda";
|
type View = "month" | "week" | "day" | "agenda";
|
||||||
@@ -110,6 +113,34 @@ export function CalendarView({ view: viewParam, date }: { view?: string; date?:
|
|||||||
ignore: ".cal-toolbar, .ev-chip, .ev-block, .agenda-ev",
|
ignore: ".cal-toolbar, .ev-chip, .ev-block, .agenda-ev",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Saving a move or a resize.
|
||||||
|
*
|
||||||
|
* The same path a menu edit takes: ask which of a series it is meant for,
|
||||||
|
* then run it through `runScoped` so a date the server will only change as
|
||||||
|
* part of a whole series offers that rather than failing. Invitations are
|
||||||
|
* not sent -- a drag is a scheduling gesture, and mailing every guest on
|
||||||
|
* each nudge of a block is not what the hand was asking for. The editor is
|
||||||
|
* still where a change goes out with notice.
|
||||||
|
*/
|
||||||
|
const commitDrag = useCallback(
|
||||||
|
async (inst: EventInstance, patch: DragPatch) => {
|
||||||
|
const ev = inst.event;
|
||||||
|
if (!patch.start && !patch.duration) return;
|
||||||
|
const scope = await askEditScope(ev);
|
||||||
|
if (!scope) return;
|
||||||
|
try {
|
||||||
|
const dropped = await runScoped(scope, (sc) => cal.updateEvent(ev, { ...patch }, false, sc));
|
||||||
|
if (!dropped) return;
|
||||||
|
const message = droppedMessage(dropped);
|
||||||
|
if (message) toast.success(message);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error((err as Error).message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[cal],
|
||||||
|
);
|
||||||
|
|
||||||
const openNew = useCallback(
|
const openNew = useCallback(
|
||||||
(start?: Date, end?: Date, allDay = false) => {
|
(start?: Date, end?: Date, allDay = false) => {
|
||||||
const s = start ?? roundToNext(new Date(), 30);
|
const s = start ?? roundToNext(new Date(), 30);
|
||||||
@@ -183,8 +214,8 @@ export function CalendarView({ view: viewParam, date }: { view?: string; date?:
|
|||||||
{!isMobile && <button className="btn btn-primary btn-sm" onClick={() => openNew()}><Plus size={16} /> {translate("Event")}</button>}
|
{!isMobile && <button className="btn btn-primary btn-sm" onClick={() => openNew()}><Plus size={16} /> {translate("Event")}</button>}
|
||||||
</div>
|
</div>
|
||||||
{cal.error && <div className="error-box" style={{ margin: 12 }}>{cal.error}</div>}
|
{cal.error && <div className="error-box" style={{ margin: 12 }}>{cal.error}</div>}
|
||||||
{effectiveView === "month" && <MonthView anchor={anchor} weekStart={weekStart} onDay={(d) => go("day", d)} onEvent={onEvent} onEventContext={onEventContext} onSlotContext={onSlotContext} onCreate={(d) => openNew(new Date(d.getTime() + 9 * 3600_000))} />}
|
{effectiveView === "month" && <MonthView onDragCommit={(i, patch) => void commitDrag(i, patch)} anchor={anchor} weekStart={weekStart} onDay={(d) => go("day", d)} onEvent={onEvent} onEventContext={onEventContext} onSlotContext={onSlotContext} onCreate={(d) => openNew(new Date(d.getTime() + 9 * 3600_000))} />}
|
||||||
{(effectiveView === "week" || effectiveView === "day") && <TimeGrid days={effectiveView === "week" ? weekDays(anchor, weekStart) : [anchor]} onEvent={onEvent} onEventContext={onEventContext} onSlotContext={onSlotContext} onCreate={(s, e, allDay) => openNew(s, e, allDay)} onDayHeader={(d) => go("day", d)} workStart={settings.workDayStart} workEnd={settings.workDayEnd} />}
|
{(effectiveView === "week" || effectiveView === "day") && <TimeGrid onDragCommit={(i, patch) => void commitDrag(i, patch)} days={effectiveView === "week" ? weekDays(anchor, weekStart) : [anchor]} onEvent={onEvent} onEventContext={onEventContext} onSlotContext={onSlotContext} onCreate={(s, e, allDay) => openNew(s, e, allDay)} onDayHeader={(d) => go("day", d)} workStart={settings.workDayStart} workEnd={settings.workDayEnd} />}
|
||||||
{effectiveView === "agenda" && <AgendaView start={anchor} onEvent={onEvent} onEventContext={onEventContext} />}
|
{effectiveView === "agenda" && <AgendaView start={anchor} onEvent={onEvent} onEventContext={onEventContext} />}
|
||||||
{ctx && <CalendarContextMenu ctx={ctx} onClose={() => setCtx(null)} onOpen={(inst, a) => setPopover({ inst, anchor: a })} onEdit={(inst) => setEditor({ event: inst.event, start: inst.start, end: inst.end, allDay: inst.allDay })} onCreate={(s, e, allDay) => { setCtx(null); openNew(s, e, allDay); }} />}
|
{ctx && <CalendarContextMenu ctx={ctx} onClose={() => setCtx(null)} onOpen={(inst, a) => setPopover({ inst, anchor: a })} onEdit={(inst) => setEditor({ event: inst.event, start: inst.start, end: inst.end, allDay: inst.allDay })} onCreate={(s, e, allDay) => { setCtx(null); openNew(s, e, allDay); }} />}
|
||||||
{isMobile && <button className="fab" aria-label={translate("New event")} onClick={() => openNew()}><Plus size={24} /></button>}
|
{isMobile && <button className="fab" aria-label={translate("New event")} onClick={() => openNew()}><Plus size={24} /></button>}
|
||||||
@@ -199,13 +230,61 @@ export function CalendarView({ view: viewParam, date }: { view?: string; date?:
|
|||||||
type EvCtx = (i: EventInstance, e: React.MouseEvent) => void;
|
type EvCtx = (i: EventInstance, e: React.MouseEvent) => void;
|
||||||
type SlotCtx = (start: Date, end: Date, allDay: boolean, e: React.MouseEvent) => void;
|
type SlotCtx = (start: Date, end: Date, allDay: boolean, e: React.MouseEvent) => void;
|
||||||
|
|
||||||
function MonthView({ anchor, weekStart, onDay, onEvent, onEventContext, onSlotContext, onCreate }: { anchor: Date; weekStart: number; onDay: (d: Date) => void; onEvent: (i: EventInstance, el: Element) => void; onEventContext: EvCtx; onSlotContext: SlotCtx; onCreate: (d: Date) => void }) {
|
function MonthView({ anchor, weekStart, onDay, onEvent, onEventContext, onSlotContext, onCreate, onDragCommit }: { anchor: Date; weekStart: number; onDay: (d: Date) => void; onEvent: (i: EventInstance, el: Element) => void; onEventContext: EvCtx; onSlotContext: SlotCtx; onCreate: (d: Date) => void; onDragCommit: (i: EventInstance, patch: DragPatch) => void }) {
|
||||||
const cal = useCalendar();
|
const cal = useCalendar();
|
||||||
const grid = useMemo(() => monthGrid(anchor, weekStart), [anchor, weekStart]);
|
const grid = useMemo(() => monthGrid(anchor, weekStart), [anchor, weekStart]);
|
||||||
const instances = cal.instancesIn(grid[0]!, addDays(grid[41]!, 1));
|
const instances = cal.instancesIn(grid[0]!, addDays(grid[41]!, 1));
|
||||||
const weeks = [...Array(6)].map((_, w) => grid.slice(w * 7, w * 7 + 7));
|
const weeks = [...Array(6)].map((_, w) => grid.slice(w * 7, w * 7 + 7));
|
||||||
const dow = weeks[0]!.map((d) => formatWeekday(d));
|
const dow = weeks[0]!.map((d) => formatWeekday(d));
|
||||||
const maxPer = 4;
|
const maxPer = 4;
|
||||||
|
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) => {
|
||||||
|
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;
|
||||||
|
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 date = cell?.dataset.date ?? null;
|
||||||
|
if (date) landedOn = date;
|
||||||
|
if (!draggedRef.current) draggedRef.current = true;
|
||||||
|
setDraggingKey(inst.key);
|
||||||
|
};
|
||||||
|
const finish = () => {
|
||||||
|
el.removeEventListener("pointermove", onPointerMove);
|
||||||
|
el.removeEventListener("pointerup", finish);
|
||||||
|
el.removeEventListener("pointercancel", finish);
|
||||||
|
try {
|
||||||
|
el.releasePointerCapture(e.pointerId);
|
||||||
|
} catch {
|
||||||
|
/* already released */
|
||||||
|
}
|
||||||
|
setDraggingKey(null);
|
||||||
|
// Built from the parts rather than parsed: `new Date("2026-09-04")` is
|
||||||
|
// read as UTC and lands on the day before wherever the offset is negative.
|
||||||
|
const parts = landedOn?.split("-").map(Number);
|
||||||
|
const target = parts && parts.length === 3 ? new Date(parts[0]!, parts[1]! - 1, parts[2]!) : null;
|
||||||
|
if (target && !isSameDay(target, inst.start)) {
|
||||||
|
onDragCommit(inst, moveToDayPatch(inst.event.start, target));
|
||||||
|
}
|
||||||
|
window.setTimeout(() => (draggedRef.current = false), 0);
|
||||||
|
};
|
||||||
|
el.addEventListener("pointermove", onPointerMove);
|
||||||
|
el.addEventListener("pointerup", finish);
|
||||||
|
el.addEventListener("pointercancel", finish);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="month-grid">
|
<div className="month-grid">
|
||||||
<div className="dow-row">{dow.map((d) => <div key={d}>{d}</div>)}</div>
|
<div className="dow-row">{dow.map((d) => <div key={d}>{d}</div>)}</div>
|
||||||
@@ -216,9 +295,9 @@ function MonthView({ anchor, weekStart, onDay, onEvent, onEventContext, onSlotCo
|
|||||||
const evs = instances.filter((i) => i.start < dayEnd && i.end > d);
|
const evs = instances.filter((i) => i.start < dayEnd && i.end > d);
|
||||||
const shown = evs.slice(0, maxPer);
|
const shown = evs.slice(0, maxPer);
|
||||||
return (
|
return (
|
||||||
<div key={d.toISOString()} 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)}>
|
<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>
|
<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)} />)}
|
{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>}
|
{evs.length > maxPer && <span className="more" onClick={(e) => { e.stopPropagation(); onDay(d); }}>{translate("+{n} more", { n: evs.length - maxPer })}</span>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -248,11 +327,11 @@ function useEventColor() {
|
|||||||
return (inst: EventInstance) => eventColor(inst.event, inst.calendar?.color, categories);
|
return (inst: EventInstance) => eventColor(inst.event, inst.calendar?.color, categories);
|
||||||
}
|
}
|
||||||
|
|
||||||
function EventChip({ inst, day, onClick, onContext }: { inst: EventInstance; day: Date; onClick: (el: Element) => void; onContext?: (e: React.MouseEvent) => void }) {
|
function EventChip({ inst, day, onClick, onContext, onDragStart, dragging, suppressClick }: { inst: EventInstance; day: Date; onClick: (el: Element) => void; onContext?: (e: React.MouseEvent) => void; onDragStart?: (e: React.PointerEvent) => void; dragging?: boolean; suppressClick?: () => boolean }) {
|
||||||
const color = useEventColor()(inst);
|
const color = useEventColor()(inst);
|
||||||
const spansDay = inst.allDay || inst.end.getTime() - inst.start.getTime() >= DAY_MS || !isSameDay(inst.start, inst.end) && inst.start < day;
|
const spansDay = inst.allDay || inst.end.getTime() - inst.start.getTime() >= DAY_MS || !isSameDay(inst.start, inst.end) && inst.start < day;
|
||||||
return (
|
return (
|
||||||
<div className={`ev-chip ${spansDay ? "" : "timed"} ${statusClass(inst)}`} style={{ background: color, borderColor: color }} onClick={(e) => { e.stopPropagation(); onClick(e.currentTarget); }} onContextMenu={onContext} title={inst.event.title ?? ""}>
|
<div className={`ev-chip ${spansDay ? "" : "timed"} ${statusClass(inst)} ${dragging ? "dragging" : ""} ${onDragStart ? "draggable" : ""}`} style={{ background: color, borderColor: color }} onPointerDown={onDragStart} onClick={(e) => { e.stopPropagation(); if (suppressClick?.()) return; onClick(e.currentTarget); }} onContextMenu={onContext} title={inst.event.title ?? ""}>
|
||||||
{!spansDay && <span className="ev-dot" style={{ background: color }} />}
|
{!spansDay && <span className="ev-dot" style={{ background: color }} />}
|
||||||
{!spansDay && <span className="ev-time">{formatTime(inst.start)}</span>}
|
{!spansDay && <span className="ev-time">{formatTime(inst.start)}</span>}
|
||||||
<span className="truncate">{inst.event.title || "(untitled)"}</span>
|
<span className="truncate">{inst.event.title || "(untitled)"}</span>
|
||||||
@@ -262,7 +341,7 @@ function EventChip({ inst, day, onClick, onContext }: { inst: EventInstance; day
|
|||||||
|
|
||||||
/* ---------------- Week / Day ---------------- */
|
/* ---------------- Week / Day ---------------- */
|
||||||
|
|
||||||
function TimeGrid({ days, onEvent, onEventContext, onSlotContext, onCreate, onDayHeader, workStart, workEnd }: { days: Date[]; onEvent: (i: EventInstance, el: Element) => void; onEventContext: EvCtx; onSlotContext: SlotCtx; onCreate: (s: Date, e: Date, allDay: boolean) => void; onDayHeader: (d: Date) => void; workStart: number; workEnd: number }) {
|
function TimeGrid({ days, onEvent, onEventContext, onSlotContext, onCreate, onDayHeader, onDragCommit, workStart, workEnd }: { days: Date[]; onEvent: (i: EventInstance, el: Element) => void; onEventContext: EvCtx; onSlotContext: SlotCtx; onCreate: (s: Date, e: Date, allDay: boolean) => void; onDayHeader: (d: Date) => void; onDragCommit: (i: EventInstance, patch: DragPatch) => void; workStart: number; workEnd: number }) {
|
||||||
const cal = useCalendar();
|
const cal = useCalendar();
|
||||||
const colorOf = useEventColor();
|
const colorOf = useEventColor();
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -271,6 +350,53 @@ function TimeGrid({ days, onEvent, onEventContext, onSlotContext, onCreate, onDa
|
|||||||
const instances = cal.instancesIn(start, end);
|
const instances = cal.instancesIn(start, end);
|
||||||
const [now, setNow] = useState(new Date());
|
const [now, setNow] = useState(new Date());
|
||||||
const [drag, setDrag] = useState<{ day: Date; startMin: number; endMin: number } | null>(null);
|
const [drag, setDrag] = useState<{ day: Date; startMin: number; endMin: number } | null>(null);
|
||||||
|
/*
|
||||||
|
* Dragging an event, as opposed to dragging out a new one on empty grid --
|
||||||
|
* which is what `drag` above is. Held as a delta in minutes rather than as a
|
||||||
|
* 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);
|
||||||
|
/* 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 beginDrag = (inst: EventInstance, mode: "move" | "resize", 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 startY = e.clientY;
|
||||||
|
let delta = 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 });
|
||||||
|
};
|
||||||
|
const finish = () => {
|
||||||
|
el.removeEventListener("pointermove", onPointerMove);
|
||||||
|
el.removeEventListener("pointerup", finish);
|
||||||
|
el.removeEventListener("pointercancel", finish);
|
||||||
|
try {
|
||||||
|
el.releasePointerCapture(e.pointerId);
|
||||||
|
} catch {
|
||||||
|
/* already released, which is fine */
|
||||||
|
}
|
||||||
|
setMoving(null);
|
||||||
|
if (delta !== 0) {
|
||||||
|
const seconds = (inst.end.getTime() - inst.start.getTime()) / 1000;
|
||||||
|
onDragCommit(inst, mode === "move" ? movePatch(inst.event.start, delta) : resizePatch(seconds, delta));
|
||||||
|
}
|
||||||
|
// Cleared after the click that follows this pointerup has been swallowed.
|
||||||
|
window.setTimeout(() => (draggedRef.current = false), 0);
|
||||||
|
};
|
||||||
|
el.addEventListener("pointermove", onPointerMove);
|
||||||
|
el.addEventListener("pointerup", finish);
|
||||||
|
el.addEventListener("pointercancel", finish);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const t = window.setInterval(() => setNow(new Date()), 60_000);
|
const t = window.setInterval(() => setNow(new Date()), 60_000);
|
||||||
return () => window.clearInterval(t);
|
return () => window.clearInterval(t);
|
||||||
@@ -354,7 +480,27 @@ function TimeGrid({ days, onEvent, onEventContext, onSlotContext, onCreate, onDa
|
|||||||
{evs.map(({ inst, top, height, left, width }) => {
|
{evs.map(({ inst, top, height, left, width }) => {
|
||||||
const color = colorOf(inst);
|
const color = colorOf(inst);
|
||||||
return (
|
return (
|
||||||
<div key={inst.key} className={`ev-block ${statusClass(inst)}`} style={{ top, height: Math.max(height, 18), left: `${left}%`, width: `calc(${width}% - 3px)`, background: color }} onClick={(e) => { e.stopPropagation(); onEvent(inst, e.currentTarget); }} onContextMenu={(e) => onEventContext(inst, e)} title={inst.event.title ?? ""}>
|
<div
|
||||||
|
key={inst.key}
|
||||||
|
className={`ev-block ${statusClass(inst)} ${moving?.key === inst.key ? "dragging" : ""} ${canDragEvent(inst.event, inst.calendar) ? "draggable" : ""}`}
|
||||||
|
style={{
|
||||||
|
top: top + (moving?.key === inst.key && moving.mode === "move" ? (moving.deltaMin / 60) * HOUR_H : 0),
|
||||||
|
height: Math.max(height + (moving?.key === inst.key && moving.mode === "resize" ? (moving.deltaMin / 60) * HOUR_H : 0), 18),
|
||||||
|
left: `${left}%`,
|
||||||
|
width: `calc(${width}% - 3px)`,
|
||||||
|
background: color,
|
||||||
|
}}
|
||||||
|
onPointerDown={(e) => beginDrag(inst, "move", e)}
|
||||||
|
onClick={(e) => { e.stopPropagation(); if (draggedRef.current) return; onEvent(inst, e.currentTarget); }}
|
||||||
|
onContextMenu={(e) => onEventContext(inst, e)}
|
||||||
|
title={inst.event.title ?? ""}
|
||||||
|
>
|
||||||
|
{canDragEvent(inst.event, inst.calendar) && (
|
||||||
|
/* 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-title">{inst.event.title || "(untitled)"}</div>
|
<div className="ev-title">{inst.event.title || "(untitled)"}</div>
|
||||||
{height > 30 && <div className="ev-time">{formatTime(inst.start)} – {formatTime(inst.end)}</div>}
|
{height > 30 && <div className="ev-time">{formatTime(inst.start)} – {formatTime(inst.end)}</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user