@nomideusz/svelte-calendar 0.14.1 → 0.15.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,7 +8,8 @@
8
8
  • Timed events are absolutely positioned by start/end; overlaps share the
9
9
  column via union-find lanes.
10
10
  • Drag-to-move (vertical = time, horizontal = day), top/bottom resize
11
- handles, drag-to-create with ghost preview — all through ctx.drag /
11
+ grips (hit zone limited to the centered grip so short events stay
12
+ grab-to-move), drag-to-create with ghost preview — all through ctx.drag /
12
13
  ctx.commitDrag so Calendar's validation (min/max duration, blocked,
13
14
  disabled) applies on drop.
14
15
  • Now-line across today's column only; auto-scrolls to the current time.
@@ -20,20 +21,8 @@ import { createClock } from "../../core/clock.svelte.js";
20
21
  import { DAY_MS, HOUR_MS, sod, addDaysMs } from "../../core/time.js";
21
22
  import { startOfWeek as sowFn, isAllDay, isMultiDay, segmentForDay } from "../../core/time.js";
22
23
  import { fmtH, fmtTime, fmtDuration, weekdayShort, weekdayLong } from "../../core/locale.js";
23
- let {
24
- mode = "week",
25
- mondayStart = true,
26
- locale,
27
- height = 520,
28
- events = [],
29
- style = "",
30
- focusDate,
31
- oneventclick,
32
- oneventcreate,
33
- selectedEventId = null,
34
- readOnly = false,
35
- visibleHours
36
- } = $props();
24
+ let { mode = "week", mondayStart = true, locale, height = 520, events = [], style = "", focusDate, oneventclick, oneventcreate, selectedEventId = null, readOnly = false, visibleHours } = $props();
25
+ // ── Context ────────────────────────────────────────
37
26
  const ctx = useCalendarContext();
38
27
  const L = $derived(ctx.labels);
39
28
  const clock = createClock(ctx.timezone);
@@ -51,6 +40,7 @@ const autoHeight = $derived(ctx.autoHeight);
51
40
  const oneventhover = $derived(ctx.oneventhover);
52
41
  const disabledSet = $derived(ctx.disabledSet);
53
42
  const SNAP_MS = $derived(ctx.snapInterval * 6e4);
43
+ // ── Config ─────────────────────────────────────────
54
44
  const HOUR_H = 48;
55
45
  const GUTTER_W = 48;
56
46
  const MIN_COL_W = $derived(ctx.minColumnWidth);
@@ -61,261 +51,313 @@ const hourCount = $derived(Math.max(1, endHour - startHour));
61
51
  const gridHeight = $derived(hourCount * HOUR_H);
62
52
  let scrollEl;
63
53
  let colsEl;
54
+ // ── Week window ────────────────────────────────────
55
+ // The engine (viewState.range) already computes the week window; fall back
56
+ // to a local computation when running headless.
64
57
  const todayMs = $derived(clock.today);
58
+ // Day mode is the same grid at one column (viewState.mode covers in-Calendar
59
+ // use where the view id decides; the prop covers headless/direct use).
65
60
  const singleDay = $derived(mode === "day" || viewState?.mode === "day");
66
61
  const customDays = $derived(singleDay ? 1 : viewState?.dayCount ?? 7);
62
+ // Calendar's chrome date label renders exactly when showDates is on, and in
63
+ // day mode it already names the visible day — drop the column header so the
64
+ // date never appears twice. Headless or showDates=false hosts keep it; a
65
+ // custom dayHeader snippet always renders.
67
66
  const hideDayHead = $derived(singleDay && showDates && !!viewState && !dayHeaderSnippet);
68
67
  const weekStartMs = $derived.by(() => {
69
- const r = viewState?.range;
70
- if (r) return sod(r.start.getTime());
71
- const f = focusDate?.getTime() ?? todayMs;
72
- return customDays === 7 ? sowFn(f, mondayStart) : sod(f);
68
+ const r = viewState?.range;
69
+ if (r) return sod(r.start.getTime());
70
+ const f = focusDate?.getTime() ?? todayMs;
71
+ return customDays === 7 ? sowFn(f, mondayStart) : sod(f);
73
72
  });
74
73
  const weekEndMs = $derived(addDaysMs(weekStartMs, customDays));
75
74
  const weekHasToday = $derived(todayMs >= weekStartMs && todayMs < weekEndMs);
76
75
  const dayCols = $derived.by(() => {
77
- const cols = [];
78
- for (let d = 0; d < customDays; d++) {
79
- const ms = addDaysMs(weekStartMs, d);
80
- const date = new Date(ms);
81
- const dow = date.getDay();
82
- const isoDay = dow === 0 ? 7 : dow;
83
- if (hideDays?.includes(isoDay)) continue;
84
- cols.push({
85
- ms,
86
- isToday: ms === todayMs,
87
- isPast: equalDays ? false : ms < todayMs,
88
- isWeekend: dow === 0 || dow === 6,
89
- isDisabled: disabledSet.has(ms),
90
- isoDay,
91
- dayNum: date.getDate()
92
- });
93
- }
94
- return cols;
76
+ const cols = [];
77
+ for (let d = 0; d < customDays; d++) {
78
+ const ms = addDaysMs(weekStartMs, d);
79
+ const date = new Date(ms);
80
+ const dow = date.getDay();
81
+ const isoDay = dow === 0 ? 7 : dow;
82
+ if (hideDays?.includes(isoDay)) continue;
83
+ cols.push({
84
+ ms,
85
+ isToday: ms === todayMs,
86
+ isPast: equalDays ? false : ms < todayMs,
87
+ isWeekend: dow === 0 || dow === 6,
88
+ isDisabled: disabledSet.has(ms),
89
+ isoDay,
90
+ dayNum: date.getDate()
91
+ });
92
+ }
93
+ return cols;
95
94
  });
96
95
  const innerMinWidth = $derived(GUTTER_W + dayCols.length * MIN_COL_W);
96
+ // ── Load range: visible week ±7 days ───────────────
97
97
  $effect(() => {
98
- if (!loadRangeCtx) return;
99
- const rangeStart = new Date(weekStartMs - 7 * DAY_MS);
100
- const rangeEnd = new Date(weekEndMs + 7 * DAY_MS);
101
- loadRangeCtx.set({ start: rangeStart, end: rangeEnd });
102
- return () => loadRangeCtx.set(null);
98
+ if (!loadRangeCtx) return;
99
+ const rangeStart = new Date(weekStartMs - 7 * DAY_MS);
100
+ const rangeEnd = new Date(weekEndMs + 7 * DAY_MS);
101
+ loadRangeCtx.set({
102
+ start: rangeStart,
103
+ end: rangeEnd
104
+ });
105
+ return () => loadRangeCtx.set(null);
103
106
  });
107
+ // ── All-day / multi-day segments per day ───────────
104
108
  const allDayByDay = $derived.by(() => {
105
- const map = /* @__PURE__ */ new Map();
106
- for (const day of dayCols) {
107
- const segs = [];
108
- for (const ev of events) {
109
- if (!isAllDay(ev) && !isMultiDay(ev)) continue;
110
- const seg = segmentForDay(ev, day.ms);
111
- if (seg) segs.push(seg);
112
- }
113
- if (segs.length) map.set(day.ms, segs);
114
- }
115
- return map;
109
+ const map = new Map();
110
+ for (const day of dayCols) {
111
+ const segs = [];
112
+ for (const ev of events) {
113
+ if (!isAllDay(ev) && !isMultiDay(ev)) continue;
114
+ const seg = segmentForDay(ev, day.ms);
115
+ if (seg) segs.push(seg);
116
+ }
117
+ if (segs.length) map.set(day.ms, segs);
118
+ }
119
+ return map;
116
120
  });
117
121
  const hasAllDayRow = $derived(allDayByDay.size > 0);
118
122
  let adExpanded = $state({});
123
+ // ── Timed event layout (union-find lanes, vertical) ─
124
+ // The dragged (move-mode) event keeps its lane at the original time, dimmed,
125
+ // while the ghost tracks the pointer; resize tracks the tentative payload
126
+ // in place.
119
127
  const movingId = $derived(drag?.active && drag.mode === "move" ? drag.payload?.eventId ?? null : null);
120
128
  const movingEvent = $derived(movingId ? events.find((e) => e.id === movingId) ?? null : null);
121
129
  const layoutByDay = $derived.by(() => {
122
- const rsP = drag?.active && (drag.mode === "resize-start" || drag.mode === "resize-end") ? drag.payload : null;
123
- const map = /* @__PURE__ */ new Map();
124
- for (const day of dayCols) {
125
- let find = function(i) {
126
- while (par[i] !== i) {
127
- par[i] = par[par[i]];
128
- i = par[i];
129
- }
130
- return i;
131
- };
132
- const dayEnd = day.ms + DAY_MS;
133
- const bandStart = day.ms + startHour * HOUR_MS;
134
- const bandEnd = day.ms + endHour * HOUR_MS;
135
- const infos = [];
136
- for (const ev of events) {
137
- if (isAllDay(ev) || isMultiDay(ev)) continue;
138
- const isResizing = rsP?.eventId === ev.id;
139
- const s0 = isResizing ? rsP.start.getTime() : ev.start.getTime();
140
- const e0 = isResizing ? rsP.end.getTime() : ev.end.getTime();
141
- if (s0 >= dayEnd || e0 <= day.ms) continue;
142
- const sMs = Math.max(s0, bandStart);
143
- const eMs = Math.min(e0, bandEnd);
144
- if (eMs <= sMs) continue;
145
- infos.push({ ev, startMs: sMs, endMs: eMs, isResizing, isMoving: ev.id === movingId, col: 0, totalCols: 1 });
146
- }
147
- infos.sort((a, b) => a.startMs - b.startMs || b.endMs - a.endMs);
148
- const par = infos.map((_, i) => i);
149
- for (let i = 0; i < infos.length; i++) {
150
- for (let j = i + 1; j < infos.length; j++) {
151
- if (infos[j].startMs < infos[i].endMs) par[find(i)] = find(j);
152
- else break;
153
- }
154
- }
155
- const groups = /* @__PURE__ */ new Map();
156
- for (let i = 0; i < infos.length; i++) {
157
- const root = find(i);
158
- if (!groups.has(root)) groups.set(root, []);
159
- groups.get(root).push(i);
160
- }
161
- for (const [, indices] of groups) {
162
- const lanes = [];
163
- for (const idx of indices) {
164
- const inf = infos[idx];
165
- let lane = 0;
166
- for (let r = 0; r < lanes.length; r++) {
167
- if (lanes[r] <= inf.startMs) {
168
- lane = r;
169
- lanes[r] = inf.endMs;
170
- break;
171
- }
172
- lane = r + 1;
173
- }
174
- if (lane >= lanes.length) lanes.push(inf.endMs);
175
- infos[idx].col = lane;
176
- }
177
- for (const idx of indices) infos[idx].totalCols = lanes.length;
178
- }
179
- map.set(
180
- day.ms,
181
- infos.map((inf) => ({
182
- ev: inf.ev,
183
- top: ((inf.startMs - day.ms) / HOUR_MS - startHour) * HOUR_H,
184
- height: Math.max(24, (inf.endMs - inf.startMs) / HOUR_MS * HOUR_H),
185
- col: inf.col,
186
- totalCols: inf.totalCols,
187
- isResizing: inf.isResizing,
188
- isMoving: inf.isMoving
189
- }))
190
- );
191
- }
192
- return map;
130
+ const rsP = drag?.active && (drag.mode === "resize-start" || drag.mode === "resize-end") ? drag.payload : null;
131
+ const map = new Map();
132
+ for (const day of dayCols) {
133
+ const dayEnd = day.ms + DAY_MS;
134
+ const bandStart = day.ms + startHour * HOUR_MS;
135
+ const bandEnd = day.ms + endHour * HOUR_MS;
136
+ const infos = [];
137
+ for (const ev of events) {
138
+ if (isAllDay(ev) || isMultiDay(ev)) continue;
139
+ // The event being moved stays laned at its ORIGINAL slot, dimmed:
140
+ // the ghost shows where it is going, the faded block where it came
141
+ // from, and neither the drop target nor its neighbours re-lane
142
+ // under the pointer mid-drag.
143
+ const isResizing = rsP?.eventId === ev.id;
144
+ const s0 = isResizing ? rsP.start.getTime() : ev.start.getTime();
145
+ const e0 = isResizing ? rsP.end.getTime() : ev.end.getTime();
146
+ if (s0 >= dayEnd || e0 <= day.ms) continue;
147
+ const sMs = Math.max(s0, bandStart);
148
+ const eMs = Math.min(e0, bandEnd);
149
+ // Entirely outside the visible hour band — skip, don't paint a sliver
150
+ if (eMs <= sMs) continue;
151
+ infos.push({
152
+ ev,
153
+ startMs: sMs,
154
+ endMs: eMs,
155
+ isResizing,
156
+ isMoving: ev.id === movingId,
157
+ col: 0,
158
+ totalCols: 1
159
+ });
160
+ }
161
+ infos.sort((a, b) => a.startMs - b.startMs || b.endMs - a.endMs);
162
+ // Union-find overlap groups (same algorithm as MobileDay)
163
+ const par = infos.map((_, i) => i);
164
+ function find(i) {
165
+ while (par[i] !== i) {
166
+ par[i] = par[par[i]];
167
+ i = par[i];
168
+ }
169
+ return i;
170
+ }
171
+ for (let i = 0; i < infos.length; i++) {
172
+ for (let j = i + 1; j < infos.length; j++) {
173
+ if (infos[j].startMs < infos[i].endMs) par[find(i)] = find(j);
174
+ else break;
175
+ }
176
+ }
177
+ const groups = new Map();
178
+ for (let i = 0; i < infos.length; i++) {
179
+ const root = find(i);
180
+ if (!groups.has(root)) groups.set(root, []);
181
+ groups.get(root).push(i);
182
+ }
183
+ for (const [, indices] of groups) {
184
+ const lanes = [];
185
+ for (const idx of indices) {
186
+ const inf = infos[idx];
187
+ let lane = 0;
188
+ for (let r = 0; r < lanes.length; r++) {
189
+ if (lanes[r] <= inf.startMs) {
190
+ lane = r;
191
+ lanes[r] = inf.endMs;
192
+ break;
193
+ }
194
+ lane = r + 1;
195
+ }
196
+ if (lane >= lanes.length) lanes.push(inf.endMs);
197
+ infos[idx].col = lane;
198
+ }
199
+ for (const idx of indices) infos[idx].totalCols = lanes.length;
200
+ }
201
+ map.set(day.ms, infos.map((inf) => ({
202
+ ev: inf.ev,
203
+ top: ((inf.startMs - day.ms) / HOUR_MS - startHour) * HOUR_H,
204
+ height: Math.max(24, (inf.endMs - inf.startMs) / HOUR_MS * HOUR_H),
205
+ col: inf.col,
206
+ totalCols: inf.totalCols,
207
+ isResizing: inf.isResizing,
208
+ isMoving: inf.isMoving
209
+ })));
210
+ }
211
+ return map;
193
212
  });
213
+ // ── Now markers (separate from layout — 1Hz tick must not re-lane) ──
194
214
  const nowIds = $derived.by(() => {
195
- const now = clock.tick;
196
- const s = /* @__PURE__ */ new Set();
197
- for (const ev of events) {
198
- if (ev.start.getTime() <= now && ev.end.getTime() > now) s.add(ev.id);
199
- }
200
- return s;
215
+ const now = clock.tick;
216
+ const s = new Set();
217
+ for (const ev of events) {
218
+ if (ev.start.getTime() <= now && ev.end.getTime() > now) s.add(ev.id);
219
+ }
220
+ return s;
201
221
  });
202
222
  const nowFracHour = $derived((clock.tick - clock.today) / HOUR_MS);
223
+ /** Y offset of the now-line, or null when outside visibleHours */
203
224
  const nowY = $derived.by(() => {
204
- if (nowFracHour < startHour || nowFracHour > endHour) return null;
205
- return (nowFracHour - startHour) * HOUR_H;
225
+ if (nowFracHour < startHour || nowFracHour > endHour) return null;
226
+ return (nowFracHour - startHour) * HOUR_H;
206
227
  });
207
- const weekIsEmpty = $derived(
208
- !events.some((ev) => ev.start.getTime() < weekEndMs && ev.end.getTime() > weekStartMs)
209
- );
228
+ // ── Empty week ─────────────────────────────────────
229
+ const weekIsEmpty = $derived(!events.some((ev) => ev.start.getTime() < weekEndMs && ev.end.getTime() > weekStartMs));
230
+ // ── Auto-scroll (mount + week change) ──────────────
231
+ // Week with today → current time minus ~1h of context; otherwise the
232
+ // visibleHours start or 08:00, whichever is later.
210
233
  $effect(() => {
211
- void weekStartMs;
212
- const el = scrollEl;
213
- if (!el) return;
214
- untrack(() => {
215
- let targetHour;
216
- if (weekHasToday) {
217
- const clamped = Math.min(Math.max(nowFracHour, startHour), endHour);
218
- targetHour = Math.max(startHour, clamped - 1);
219
- } else {
220
- targetHour = Math.max(startHour, Math.min(8, endHour - 1));
221
- }
222
- el.scrollTop = (targetHour - startHour) * HOUR_H;
223
- });
234
+ void weekStartMs;
235
+ const el = scrollEl;
236
+ if (!el) return;
237
+ untrack(() => {
238
+ let targetHour;
239
+ if (weekHasToday) {
240
+ const clamped = Math.min(Math.max(nowFracHour, startHour), endHour);
241
+ targetHour = Math.max(startHour, clamped - 1);
242
+ } else {
243
+ targetHour = Math.max(startHour, Math.min(8, endHour - 1));
244
+ }
245
+ el.scrollTop = (targetHour - startHour) * HOUR_H;
246
+ });
224
247
  });
248
+ // ── Pointer coalescing ─────────────────────────────
249
+ // Pointer events fire far faster than the screen repaints (120–1000 Hz on
250
+ // modern mice). Handling each one wrote drag state → Svelte wrote inline
251
+ // styles → the next handler's getBoundingClientRect() forced a synchronous
252
+ // layout: classic read-after-write thrash, and the reason a drag felt heavy
253
+ // on a busy grid. Running at most one handler per frame, off the newest
254
+ // event, with the grid rect measured once inside that frame, removes both.
225
255
  let rafId = 0;
226
256
  let rafRun = null;
227
257
  let rectCache = null;
228
258
  function perFrame(handler) {
229
- return (e) => {
230
- rafRun = () => {
231
- rectCache = colsEl?.getBoundingClientRect() ?? null;
232
- try {
233
- handler(e);
234
- } finally {
235
- rectCache = null;
236
- }
237
- };
238
- if (rafId) return;
239
- rafId = requestAnimationFrame(flushFrame);
240
- };
259
+ return (e) => {
260
+ rafRun = () => {
261
+ rectCache = colsEl?.getBoundingClientRect() ?? null;
262
+ try {
263
+ handler(e);
264
+ } finally {
265
+ rectCache = null;
266
+ }
267
+ };
268
+ if (rafId) return;
269
+ rafId = requestAnimationFrame(flushFrame);
270
+ };
241
271
  }
272
+ /** Run a pending move now — pointerup must not drop the last frame. */
242
273
  function flushFrame() {
243
- if (rafId) {
244
- cancelAnimationFrame(rafId);
245
- rafId = 0;
246
- }
247
- const run = rafRun;
248
- rafRun = null;
249
- run?.();
274
+ if (rafId) {
275
+ cancelAnimationFrame(rafId);
276
+ rafId = 0;
277
+ }
278
+ const run = rafRun;
279
+ rafRun = null;
280
+ run?.();
250
281
  }
251
282
  function cancelFrame() {
252
- if (rafId) {
253
- cancelAnimationFrame(rafId);
254
- rafId = 0;
255
- }
256
- rafRun = null;
283
+ if (rafId) {
284
+ cancelAnimationFrame(rafId);
285
+ rafId = 0;
286
+ }
287
+ rafRun = null;
257
288
  }
289
+ // ── Geometry helpers ───────────────────────────────
258
290
  function colsRect() {
259
- return rectCache ?? colsEl.getBoundingClientRect();
291
+ return rectCache ?? colsEl.getBoundingClientRect();
260
292
  }
293
+ /** Pointer X → index into dayCols (clamped) */
261
294
  function pointerDayIndex(clientX) {
262
- const r = colsRect();
263
- const n = dayCols.length;
264
- if (n === 0) return 0;
265
- const w = r.width / n;
266
- return Math.max(0, Math.min(n - 1, Math.floor((clientX - r.left) / w)));
295
+ const r = colsRect();
296
+ const n = dayCols.length;
297
+ if (n === 0) return 0;
298
+ const w = r.width / n;
299
+ return Math.max(0, Math.min(n - 1, Math.floor((clientX - r.left) / w)));
267
300
  }
301
+ /** Pointer Y → fractional hour (unclamped) */
268
302
  function pointerHour(clientY) {
269
- return startHour + (clientY - colsRect().top) / HOUR_H;
303
+ return startHour + (clientY - colsRect().top) / HOUR_H;
270
304
  }
305
+ /** Pointer → epoch ms (day from X, time from Y, clamped into the band) */
271
306
  function pointerTimeMs(clientX, clientY) {
272
- const dayMs = dayCols[pointerDayIndex(clientX)]?.ms ?? weekStartMs;
273
- const hour = Math.min(Math.max(pointerHour(clientY), startHour), endHour);
274
- return dayMs + hour * HOUR_MS;
307
+ const dayMs = dayCols[pointerDayIndex(clientX)]?.ms ?? weekStartMs;
308
+ const hour = Math.min(Math.max(pointerHour(clientY), startHour), endHour);
309
+ return dayMs + hour * HOUR_MS;
275
310
  }
311
+ /** Clamp a timestamp into the visible band of a specific day */
276
312
  function clampToDayBand(ms, dayMs) {
277
- return Math.max(dayMs + startHour * HOUR_MS, Math.min(dayMs + endHour * HOUR_MS, ms));
313
+ return Math.max(dayMs + startHour * HOUR_MS, Math.min(dayMs + endHour * HOUR_MS, ms));
278
314
  }
315
+ // ── Blocked slot helpers ───────────────────────────
279
316
  function isBlockedAt(dayMs, hour) {
280
- if (!blockedSlots?.length) return false;
281
- const jsDay = new Date(dayMs).getDay();
282
- const isoDay = jsDay === 0 ? 7 : jsDay;
283
- return blockedSlots.some((slot) => {
284
- if (slot.day && slot.day !== isoDay) return false;
285
- return hour >= slot.start && hour < slot.end;
286
- });
317
+ if (!blockedSlots?.length) return false;
318
+ const jsDay = new Date(dayMs).getDay();
319
+ const isoDay = jsDay === 0 ? 7 : jsDay;
320
+ return blockedSlots.some((slot) => {
321
+ if (slot.day && slot.day !== isoDay) return false;
322
+ return hour >= slot.start && hour < slot.end;
323
+ });
287
324
  }
288
325
  function blockedRangeLabel(dayMs, slotStart, slotEnd) {
289
- return `${fmtTime(new Date(dayMs + slotStart * HOUR_MS), locale)} \u2013 ${fmtTime(new Date(dayMs + slotEnd * HOUR_MS), locale)}`;
326
+ return `${fmtTime(new Date(dayMs + slotStart * HOUR_MS), locale)} – ${fmtTime(new Date(dayMs + slotEnd * HOUR_MS), locale)}`;
290
327
  }
328
+ // ── Status label (aria) ────────────────────────────
291
329
  function statusText(ev) {
292
- if (ev.status === "cancelled") return ` (${L.cancelled})`;
293
- if (ev.status === "tentative") return ` (${L.tentative})`;
294
- if (ev.status === "full") return ` (${L.full})`;
295
- if (ev.status === "limited") return ` (${L.limited})`;
296
- return "";
330
+ if (ev.status === "cancelled") return ` (${L.cancelled})`;
331
+ if (ev.status === "tentative") return ` (${L.tentative})`;
332
+ if (ev.status === "full") return ` (${L.full})`;
333
+ if (ev.status === "limited") return ` (${L.limited})`;
334
+ return "";
297
335
  }
298
336
  function ghostForDay(dayMs) {
299
- if (!drag?.active || !drag.payload) return null;
300
- const mode2 = drag.mode;
301
- if (mode2 !== "move" && mode2 !== "create") return null;
302
- const s = drag.payload.start.getTime();
303
- const e = drag.payload.end.getTime();
304
- const bandS = dayMs + startHour * HOUR_MS;
305
- const bandE = dayMs + endHour * HOUR_MS;
306
- const cs = Math.max(s, bandS);
307
- const ce = Math.min(e, bandE);
308
- if (ce <= cs) return null;
309
- return {
310
- top: ((cs - dayMs) / HOUR_MS - startHour) * HOUR_H,
311
- height: Math.max(12, (ce - cs) / HOUR_MS * HOUR_H),
312
- start: drag.payload.start,
313
- end: drag.payload.end,
314
- create: mode2 === "create",
315
- // Only the segment containing the start shows the readout
316
- showTime: cs === Math.max(s, dayMs)
317
- };
337
+ if (!drag?.active || !drag.payload) return null;
338
+ const mode = drag.mode;
339
+ if (mode !== "move" && mode !== "create") return null;
340
+ const s = drag.payload.start.getTime();
341
+ const e = drag.payload.end.getTime();
342
+ const bandS = dayMs + startHour * HOUR_MS;
343
+ const bandE = dayMs + endHour * HOUR_MS;
344
+ const cs = Math.max(s, bandS);
345
+ const ce = Math.min(e, bandE);
346
+ if (ce <= cs) return null;
347
+ return {
348
+ top: ((cs - dayMs) / HOUR_MS - startHour) * HOUR_H,
349
+ height: Math.max(12, (ce - cs) / HOUR_MS * HOUR_H),
350
+ start: drag.payload.start,
351
+ end: drag.payload.end,
352
+ create: mode === "create",
353
+ // Only the segment containing the start shows the readout
354
+ showTime: cs === Math.max(s, dayMs)
355
+ };
318
356
  }
357
+ // ── Drag-to-create (mouse: sweep; touch: long-press) ──
358
+ // Mouse movement below CREATE_THRESHOLD stays a plain click. On touch a
359
+ // vertical drag must remain a scroll, so create only arms after a ~350ms
360
+ // still hold (moving > 8px first cancels it).
319
361
  const CREATE_THRESHOLD = 4;
320
362
  const LONG_PRESS_MS = 350;
321
363
  const LONG_PRESS_TOLERANCE = 8;
@@ -326,116 +368,122 @@ let crAnchorMs = 0;
326
368
  let crDayMs = 0;
327
369
  let crStarted = false;
328
370
  let longPressTimer = null;
371
+ // touch-action can't change mid-gesture, so once a touch drag-create/resize
372
+ // is live we block native scrolling with a non-passive touchmove listener.
329
373
  function blockTouchScroll(e) {
330
- e.preventDefault();
374
+ e.preventDefault();
331
375
  }
332
376
  function addTouchScrollBlock() {
333
- window.addEventListener("touchmove", blockTouchScroll, { passive: false });
377
+ window.addEventListener("touchmove", blockTouchScroll, { passive: false });
334
378
  }
335
379
  function removeTouchScrollBlock() {
336
- window.removeEventListener("touchmove", blockTouchScroll);
380
+ window.removeEventListener("touchmove", blockTouchScroll);
337
381
  }
338
382
  function clearLongPress() {
339
- if (longPressTimer !== null) {
340
- clearTimeout(longPressTimer);
341
- longPressTimer = null;
342
- }
383
+ if (longPressTimer !== null) {
384
+ clearTimeout(longPressTimer);
385
+ longPressTimer = null;
386
+ }
343
387
  }
344
388
  function startColsCreate() {
345
- if (!drag) return;
346
- crStarted = true;
347
- crAnchorMs = clampToDayBand(Math.floor(crAnchorMs / SNAP_MS) * SNAP_MS, crDayMs);
348
- drag.beginCreate(new Date(crAnchorMs), new Date(crAnchorMs + SNAP_MS));
349
- addTouchScrollBlock();
389
+ if (!drag) return;
390
+ crStarted = true;
391
+ crAnchorMs = clampToDayBand(Math.floor(crAnchorMs / SNAP_MS) * SNAP_MS, crDayMs);
392
+ drag.beginCreate(new Date(crAnchorMs), new Date(crAnchorMs + SNAP_MS));
393
+ addTouchScrollBlock();
350
394
  }
351
395
  function onColsPointerDown(e) {
352
- if (e.button !== 0 || !drag || !oneventcreate || readOnly) return;
353
- if (e.target.closest(".tw-ev, .tw-ghost")) return;
354
- const day = dayCols[pointerDayIndex(e.clientX)];
355
- if (!day || day.isDisabled) return;
356
- if (isBlockedAt(day.ms, Math.min(Math.max(pointerHour(e.clientY), startHour), endHour))) return;
357
- crStartX = e.clientX;
358
- crStartY = e.clientY;
359
- crDayMs = day.ms;
360
- crAnchorMs = clampToDayBand(
361
- day.ms + Math.max(pointerHour(e.clientY), startHour) * HOUR_MS,
362
- day.ms
363
- );
364
- crStarted = false;
365
- if (e.pointerType === "touch") {
366
- longPressTimer = setTimeout(() => {
367
- longPressTimer = null;
368
- startColsCreate();
369
- }, LONG_PRESS_MS);
370
- }
371
- window.addEventListener("pointermove", onColsCreateMove);
372
- window.addEventListener("pointerup", onColsCreateUp, { once: true });
373
- window.addEventListener("pointercancel", onColsCreateCancel, { once: true });
396
+ if (e.button !== 0 || !drag || !oneventcreate || readOnly) return;
397
+ if (e.target.closest(".tw-ev, .tw-ghost")) return;
398
+ const day = dayCols[pointerDayIndex(e.clientX)];
399
+ if (!day || day.isDisabled) return;
400
+ // Never arm a create on a blocked region (Calendar validates on commit too)
401
+ if (isBlockedAt(day.ms, Math.min(Math.max(pointerHour(e.clientY), startHour), endHour))) return;
402
+ crStartX = e.clientX;
403
+ crStartY = e.clientY;
404
+ crDayMs = day.ms;
405
+ crAnchorMs = clampToDayBand(day.ms + Math.max(pointerHour(e.clientY), startHour) * HOUR_MS, day.ms);
406
+ crStarted = false;
407
+ if (e.pointerType === "touch") {
408
+ longPressTimer = setTimeout(() => {
409
+ longPressTimer = null;
410
+ startColsCreate();
411
+ }, LONG_PRESS_MS);
412
+ }
413
+ window.addEventListener("pointermove", onColsCreateMove);
414
+ window.addEventListener("pointerup", onColsCreateUp, { once: true });
415
+ window.addEventListener("pointercancel", onColsCreateCancel, { once: true });
374
416
  }
375
417
  const onColsCreateMove = perFrame((e) => {
376
- if (!drag) return;
377
- if (!crStarted) {
378
- if (longPressTimer !== null) {
379
- const moved = Math.hypot(e.clientX - crStartX, e.clientY - crStartY);
380
- if (moved > LONG_PRESS_TOLERANCE) cleanupColsCreate();
381
- return;
382
- }
383
- if (e.pointerType === "touch") return;
384
- if (Math.abs(e.clientY - crStartY) < CREATE_THRESHOLD) return;
385
- startColsCreate();
386
- }
387
- const raw = crDayMs + pointerHour(e.clientY) * HOUR_MS;
388
- const snapped = clampToDayBand(Math.round(raw / SNAP_MS) * SNAP_MS, crDayMs);
389
- drag.updatePointer(
390
- new Date(Math.min(crAnchorMs, snapped)),
391
- new Date(Math.max(crAnchorMs + SNAP_MS, snapped))
392
- );
418
+ if (!drag) return;
419
+ if (!crStarted) {
420
+ if (longPressTimer !== null) {
421
+ // Finger moved before the hold completed → it's a scroll
422
+ const moved = Math.hypot(e.clientX - crStartX, e.clientY - crStartY);
423
+ if (moved > LONG_PRESS_TOLERANCE) cleanupColsCreate();
424
+ return;
425
+ }
426
+ if (e.pointerType === "touch") return;
427
+ if (Math.abs(e.clientY - crStartY) < CREATE_THRESHOLD) return;
428
+ startColsCreate();
429
+ }
430
+ const raw = crDayMs + pointerHour(e.clientY) * HOUR_MS;
431
+ const snapped = clampToDayBand(Math.round(raw / SNAP_MS) * SNAP_MS, crDayMs);
432
+ drag.updatePointer(new Date(Math.min(crAnchorMs, snapped)), new Date(Math.max(crAnchorMs + SNAP_MS, snapped)));
393
433
  });
394
434
  function cleanupColsCreate() {
395
- cancelFrame();
396
- clearLongPress();
397
- removeTouchScrollBlock();
398
- window.removeEventListener("pointermove", onColsCreateMove);
399
- window.removeEventListener("pointerup", onColsCreateUp);
400
- window.removeEventListener("pointercancel", onColsCreateCancel);
401
- crStarted = false;
435
+ cancelFrame();
436
+ clearLongPress();
437
+ removeTouchScrollBlock();
438
+ window.removeEventListener("pointermove", onColsCreateMove);
439
+ window.removeEventListener("pointerup", onColsCreateUp);
440
+ window.removeEventListener("pointercancel", onColsCreateCancel);
441
+ crStarted = false;
402
442
  }
403
443
  function onColsCreateUp() {
404
- if (crStarted) flushFrame();
405
- if (drag && crStarted) {
406
- suppressColsClick = true;
407
- commitDragCtx?.();
408
- setTimeout(() => {
409
- suppressColsClick = false;
410
- }, 0);
411
- }
412
- cleanupColsCreate();
444
+ // Commit the newest pointer position, not the last one that made it to a
445
+ // frame. Only once the gesture is live — flushing a pending frame before
446
+ // that could start (and instantly commit) a drag the user meant as a click.
447
+ if (crStarted) flushFrame();
448
+ if (drag && crStarted) {
449
+ // The click event (if any) fires synchronously after pointerup
450
+ suppressColsClick = true;
451
+ commitDragCtx?.();
452
+ setTimeout(() => {
453
+ suppressColsClick = false;
454
+ }, 0);
455
+ }
456
+ cleanupColsCreate();
413
457
  }
414
458
  function onColsCreateCancel() {
415
- if (drag && crStarted) drag.cancel();
416
- cleanupColsCreate();
459
+ if (drag && crStarted) drag.cancel();
460
+ cleanupColsCreate();
417
461
  }
418
462
  function onColsContextMenu(e) {
419
- if (crStarted || longPressTimer !== null) e.preventDefault();
463
+ // Long-press on touch fires contextmenu on some platforms — keep it
464
+ // from interrupting an armed or pending drag-create.
465
+ if (crStarted || longPressTimer !== null) e.preventDefault();
420
466
  }
467
+ // ── Click-to-create (plain click → default slot) ───
421
468
  function handleColsClick(e) {
422
- if (suppressColsClick) {
423
- suppressColsClick = false;
424
- return;
425
- }
426
- if (!oneventcreate || readOnly) return;
427
- if (e.target.closest(".tw-ev, .tw-ghost")) return;
428
- const day = dayCols[pointerDayIndex(e.clientX)];
429
- if (!day || day.isDisabled) return;
430
- const hour = Math.min(Math.max(pointerHour(e.clientY), startHour), endHour);
431
- if (isBlockedAt(day.ms, hour)) return;
432
- const startMs = clampToDayBand(
433
- Math.floor((day.ms + hour * HOUR_MS) / SNAP_MS) * SNAP_MS,
434
- day.ms
435
- );
436
- const durMin = minDuration ?? 60;
437
- oneventcreate({ start: new Date(startMs), end: new Date(startMs + durMin * 6e4) });
469
+ if (suppressColsClick) {
470
+ suppressColsClick = false;
471
+ return;
472
+ }
473
+ if (!oneventcreate || readOnly) return;
474
+ if (e.target.closest(".tw-ev, .tw-ghost")) return;
475
+ const day = dayCols[pointerDayIndex(e.clientX)];
476
+ if (!day || day.isDisabled) return;
477
+ const hour = Math.min(Math.max(pointerHour(e.clientY), startHour), endHour);
478
+ if (isBlockedAt(day.ms, hour)) return;
479
+ const startMs = clampToDayBand(Math.floor((day.ms + hour * HOUR_MS) / SNAP_MS) * SNAP_MS, day.ms);
480
+ const durMin = minDuration ?? 60;
481
+ oneventcreate({
482
+ start: new Date(startMs),
483
+ end: new Date(startMs + durMin * 6e4)
484
+ });
438
485
  }
486
+ // ── Drag-to-move (vertical = time, horizontal = day) ──
439
487
  const DRAG_THRESHOLD = 5;
440
488
  let evDragStartX = 0;
441
489
  let evDragStartY = 0;
@@ -443,134 +491,145 @@ let evGrabOffsetMs = 0;
443
491
  let evDragStarted = false;
444
492
  let evDragMovable = false;
445
493
  let evDragEvent = null;
494
+ // The block's rect when the pointer went down — a click hands it to the
495
+ // host as the anchor for whatever it opens.
496
+ let evAnchor;
446
497
  function onEventPointerDown(e, ev) {
447
- if (e.button !== 0) return;
448
- e.stopPropagation();
449
- evDragMovable = !!drag && !readOnly && !ev.data?.readOnly;
450
- evDragStartX = e.clientX;
451
- evDragStartY = e.clientY;
452
- evGrabOffsetMs = pointerTimeMs(e.clientX, e.clientY) - ev.start.getTime();
453
- evDragStarted = false;
454
- evDragEvent = ev;
455
- window.addEventListener("pointermove", onEvMove);
456
- window.addEventListener("pointerup", onEvUp, { once: true });
457
- window.addEventListener("pointercancel", onEvCancel, { once: true });
498
+ if (e.button !== 0) return;
499
+ e.stopPropagation();
500
+ evAnchor = e.currentTarget.getBoundingClientRect();
501
+ // Even non-movable events (readOnly view, per-event readOnly) go through
502
+ // the pointerup path so a plain click still opens them.
503
+ evDragMovable = !!drag && !readOnly && !ev.data?.readOnly;
504
+ evDragStartX = e.clientX;
505
+ evDragStartY = e.clientY;
506
+ evGrabOffsetMs = pointerTimeMs(e.clientX, e.clientY) - ev.start.getTime();
507
+ evDragStarted = false;
508
+ evDragEvent = ev;
509
+ window.addEventListener("pointermove", onEvMove);
510
+ window.addEventListener("pointerup", onEvUp, { once: true });
511
+ window.addEventListener("pointercancel", onEvCancel, { once: true });
458
512
  }
459
513
  const onEvMove = perFrame((e) => {
460
- const ev = evDragEvent;
461
- if (!drag || !ev || !evDragMovable) return;
462
- if (!evDragStarted) {
463
- const moved = Math.abs(e.clientX - evDragStartX) + Math.abs(e.clientY - evDragStartY);
464
- if (moved < DRAG_THRESHOLD) return;
465
- evDragStarted = true;
466
- drag.beginMove(ev.id, ev.start, ev.end);
467
- }
468
- const duration = ev.end.getTime() - ev.start.getTime();
469
- const raw = pointerTimeMs(e.clientX, e.clientY) - evGrabOffsetMs;
470
- const snapped = Math.round(raw / SNAP_MS) * SNAP_MS;
471
- drag.updatePointer(new Date(snapped), new Date(snapped + duration));
514
+ const ev = evDragEvent;
515
+ if (!drag || !ev || !evDragMovable) return;
516
+ if (!evDragStarted) {
517
+ const moved = Math.abs(e.clientX - evDragStartX) + Math.abs(e.clientY - evDragStartY);
518
+ if (moved < DRAG_THRESHOLD) return;
519
+ evDragStarted = true;
520
+ drag.beginMove(ev.id, ev.start, ev.end);
521
+ }
522
+ const duration = ev.end.getTime() - ev.start.getTime();
523
+ const raw = pointerTimeMs(e.clientX, e.clientY) - evGrabOffsetMs;
524
+ const snapped = Math.round(raw / SNAP_MS) * SNAP_MS;
525
+ drag.updatePointer(new Date(snapped), new Date(snapped + duration));
472
526
  });
473
527
  function cleanupEvDrag() {
474
- cancelFrame();
475
- window.removeEventListener("pointermove", onEvMove);
476
- window.removeEventListener("pointerup", onEvUp);
477
- window.removeEventListener("pointercancel", onEvCancel);
478
- evDragStarted = false;
479
- evDragMovable = false;
480
- evDragEvent = null;
528
+ cancelFrame();
529
+ window.removeEventListener("pointermove", onEvMove);
530
+ window.removeEventListener("pointerup", onEvUp);
531
+ window.removeEventListener("pointercancel", onEvCancel);
532
+ evDragStarted = false;
533
+ evDragMovable = false;
534
+ evDragEvent = null;
481
535
  }
482
536
  function onEvUp() {
483
- if (evDragStarted) flushFrame();
484
- if (!evDragStarted && evDragEvent) {
485
- oneventclick?.(evDragEvent);
486
- } else if (evDragStarted && drag) {
487
- suppressColsClick = true;
488
- commitDragCtx?.();
489
- setTimeout(() => {
490
- suppressColsClick = false;
491
- }, 0);
492
- }
493
- cleanupEvDrag();
537
+ if (evDragStarted) flushFrame();
538
+ if (!evDragStarted && evDragEvent) {
539
+ oneventclick?.(evDragEvent, evAnchor);
540
+ } else if (evDragStarted && drag) {
541
+ // If the pointer ends off the block, the synthesized click lands on
542
+ // the grid — suppress the click-to-create it would trigger.
543
+ suppressColsClick = true;
544
+ commitDragCtx?.();
545
+ setTimeout(() => {
546
+ suppressColsClick = false;
547
+ }, 0);
548
+ }
549
+ cleanupEvDrag();
494
550
  }
495
551
  function onEvCancel() {
496
- if (drag && evDragStarted) drag.cancel();
497
- cleanupEvDrag();
552
+ if (drag && evDragStarted) drag.cancel();
553
+ cleanupEvDrag();
498
554
  }
555
+ // ── Resize (top/bottom edge handles) ───────────────
499
556
  let rsStartY = 0;
500
557
  let rsStarted = false;
501
558
  let rsEdge = "end";
502
559
  let rsEvent = null;
503
560
  function onResizePointerDown(e, ev, edge) {
504
- if (e.button !== 0 || !drag || readOnly || ev.data?.readOnly) return;
505
- e.stopPropagation();
506
- rsStartY = e.clientY;
507
- rsStarted = false;
508
- rsEdge = edge;
509
- rsEvent = ev;
510
- window.addEventListener("pointermove", onResizeMove);
511
- window.addEventListener("pointerup", onResizeUp, { once: true });
512
- window.addEventListener("pointercancel", onResizeCancel, { once: true });
561
+ if (e.button !== 0 || !drag || readOnly || ev.data?.readOnly) return;
562
+ e.stopPropagation();
563
+ evAnchor = e.currentTarget.parentElement?.getBoundingClientRect();
564
+ rsStartY = e.clientY;
565
+ rsStarted = false;
566
+ rsEdge = edge;
567
+ rsEvent = ev;
568
+ window.addEventListener("pointermove", onResizeMove);
569
+ window.addEventListener("pointerup", onResizeUp, { once: true });
570
+ window.addEventListener("pointercancel", onResizeCancel, { once: true });
513
571
  }
514
572
  const onResizeMove = perFrame((e) => {
515
- const ev = rsEvent;
516
- if (!drag || !ev) return;
517
- if (!rsStarted) {
518
- if (Math.abs(e.clientY - rsStartY) < CREATE_THRESHOLD) return;
519
- rsStarted = true;
520
- drag.beginResize(ev.id, rsEdge, ev.start, ev.end);
521
- addTouchScrollBlock();
522
- }
523
- const evDayMs = sod(ev.start.getTime());
524
- const raw = evDayMs + pointerHour(e.clientY) * HOUR_MS;
525
- const snapped = clampToDayBand(Math.round(raw / SNAP_MS) * SNAP_MS, evDayMs);
526
- if (rsEdge === "end") {
527
- const end = Math.max(snapped, ev.start.getTime() + SNAP_MS);
528
- drag.updatePointer(ev.start, new Date(end));
529
- } else {
530
- const start = Math.min(snapped, ev.end.getTime() - SNAP_MS);
531
- drag.updatePointer(new Date(start), ev.end);
532
- }
573
+ const ev = rsEvent;
574
+ if (!drag || !ev) return;
575
+ if (!rsStarted) {
576
+ if (Math.abs(e.clientY - rsStartY) < CREATE_THRESHOLD) return;
577
+ rsStarted = true;
578
+ drag.beginResize(ev.id, rsEdge, ev.start, ev.end);
579
+ addTouchScrollBlock();
580
+ }
581
+ const evDayMs = sod(ev.start.getTime());
582
+ const raw = evDayMs + pointerHour(e.clientY) * HOUR_MS;
583
+ const snapped = clampToDayBand(Math.round(raw / SNAP_MS) * SNAP_MS, evDayMs);
584
+ if (rsEdge === "end") {
585
+ const end = Math.max(snapped, ev.start.getTime() + SNAP_MS);
586
+ drag.updatePointer(ev.start, new Date(end));
587
+ } else {
588
+ const start = Math.min(snapped, ev.end.getTime() - SNAP_MS);
589
+ drag.updatePointer(new Date(start), ev.end);
590
+ }
533
591
  });
534
592
  function cleanupResize() {
535
- cancelFrame();
536
- removeTouchScrollBlock();
537
- window.removeEventListener("pointermove", onResizeMove);
538
- window.removeEventListener("pointerup", onResizeUp);
539
- window.removeEventListener("pointercancel", onResizeCancel);
540
- rsStarted = false;
541
- rsEvent = null;
593
+ cancelFrame();
594
+ removeTouchScrollBlock();
595
+ window.removeEventListener("pointermove", onResizeMove);
596
+ window.removeEventListener("pointerup", onResizeUp);
597
+ window.removeEventListener("pointercancel", onResizeCancel);
598
+ rsStarted = false;
599
+ rsEvent = null;
542
600
  }
543
601
  function onResizeUp() {
544
- if (rsStarted) flushFrame();
545
- if (drag && rsStarted) {
546
- suppressColsClick = true;
547
- commitDragCtx?.();
548
- setTimeout(() => {
549
- suppressColsClick = false;
550
- }, 0);
551
- } else if (rsEvent && !rsStarted) {
552
- oneventclick?.(rsEvent);
553
- }
554
- cleanupResize();
602
+ if (rsStarted) flushFrame();
603
+ if (drag && rsStarted) {
604
+ // If the pointer ends off the block, the synthesized click lands on
605
+ // the grid — suppress the click-to-create it would trigger.
606
+ suppressColsClick = true;
607
+ commitDragCtx?.();
608
+ setTimeout(() => {
609
+ suppressColsClick = false;
610
+ }, 0);
611
+ } else if (rsEvent && !rsStarted) {
612
+ oneventclick?.(rsEvent, evAnchor);
613
+ }
614
+ cleanupResize();
555
615
  }
556
616
  function onResizeCancel() {
557
- if (drag && rsStarted) drag.cancel();
558
- cleanupResize();
617
+ if (drag && rsStarted) drag.cancel();
618
+ cleanupResize();
559
619
  }
620
+ // ── Escape cancels any in-flight drag ──────────────
560
621
  function onWindowKeydown(e) {
561
- if (e.key !== "Escape" || !drag?.active) return;
562
- drag.cancel();
563
- cleanupColsCreate();
564
- cleanupEvDrag();
565
- cleanupResize();
566
- suppressColsClick = true;
567
- window.addEventListener(
568
- "pointerup",
569
- () => setTimeout(() => {
570
- suppressColsClick = false;
571
- }, 0),
572
- { once: true }
573
- );
622
+ if (e.key !== "Escape" || !drag?.active) return;
623
+ drag.cancel();
624
+ cleanupColsCreate();
625
+ cleanupEvDrag();
626
+ cleanupResize();
627
+ // Suppress the click the trailing pointerup would synthesize on the
628
+ // grid (it would otherwise create an event).
629
+ suppressColsClick = true;
630
+ window.addEventListener("pointerup", () => setTimeout(() => {
631
+ suppressColsClick = false;
632
+ }, 0), { once: true });
574
633
  }
575
634
  </script>
576
635
 
@@ -762,7 +821,8 @@ function onWindowKeydown(e) {
762
821
  aria-label="{p.ev.title}, {fmtTime(p.ev.start, locale)} – {fmtTime(p.ev.end, locale)}, {fmtDuration(p.ev.start, p.ev.end)}{statusText(p.ev)}{isCurrent ? ` (${L.inProgress})` : ''}"
763
822
  onpointerdown={(e) => onEventPointerDown(e, p.ev)}
764
823
  onpointerenter={() => oneventhover?.(p.ev)}
765
- onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); oneventclick?.(p.ev); } }}
824
+ onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); oneventclick?.(p.ev, e.currentTarget.getBoundingClientRect()); } }}
825
+
766
826
  >
767
827
  <div class="tw-ev-stripe" aria-hidden="true"></div>
768
828
  <div class="tw-ev-body">
@@ -1334,10 +1394,16 @@ function onWindowKeydown(e) {
1334
1394
  }
1335
1395
 
1336
1396
  /* ─── Resize handles ─────────────────────────────── */
1397
+ /* Resizing lives only on the centered grip column. The old full-width
1398
+ edge bands (12–20px of inward slop each) covered short events
1399
+ entirely — min block height is 24px, so every grab meant to move
1400
+ started a resize instead. The grip is the visible affordance;
1401
+ everything else on the block drags to move. */
1337
1402
  .tw-ev-handle {
1338
1403
  position: absolute;
1339
- left: 0;
1340
- right: 0;
1404
+ left: 50%;
1405
+ transform: translateX(-50%);
1406
+ width: 44px;
1341
1407
  height: 8px;
1342
1408
  z-index: 2;
1343
1409
  cursor: ns-resize;
@@ -1377,6 +1443,8 @@ function onWindowKeydown(e) {
1377
1443
  .tw-ev:focus-visible .tw-ev-handle::after,
1378
1444
  .tw-ev--resizing .tw-ev-handle::after,
1379
1445
  .tw-ev--selected .tw-ev-handle::after { opacity: 0.55; }
1446
+ /* Pointer on the grip column itself: brighten so the hit zone reads */
1447
+ .tw-ev-handle:hover::after { opacity: 0.9; }
1380
1448
  /* Coarse pointers can't hover — show the grips persistently */
1381
1449
  @media (hover: none) {
1382
1450
  .tw-ev-handle::after { opacity: 0.55; }