@nomideusz/svelte-calendar 0.7.4 → 0.8.0

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.
Files changed (36) hide show
  1. package/README.md +19 -4
  2. package/dist/calendar/Calendar.svelte +364 -497
  3. package/dist/calendar/Calendar.svelte.d.ts +6 -2
  4. package/dist/core/clock.svelte.js +12 -5
  5. package/dist/core/locale.d.ts +4 -0
  6. package/dist/core/locale.js +4 -0
  7. package/dist/engine/view-state.svelte.d.ts +2 -2
  8. package/dist/engine/view-state.svelte.js +20 -0
  9. package/dist/headless/create-calendar.svelte.js +18 -2
  10. package/dist/headless/types.d.ts +6 -6
  11. package/dist/index.d.ts +2 -0
  12. package/dist/index.js +3 -0
  13. package/dist/primitives/DayHeader.svelte +9 -25
  14. package/dist/primitives/EmptySlot.svelte +15 -31
  15. package/dist/primitives/EventBlock.svelte +33 -61
  16. package/dist/primitives/NowIndicator.svelte +16 -42
  17. package/dist/primitives/TimeGutter.svelte +9 -23
  18. package/dist/views/agenda/Agenda.svelte +3 -10
  19. package/dist/views/agenda/AgendaDay.svelte +195 -167
  20. package/dist/views/agenda/AgendaWeek.svelte +141 -195
  21. package/dist/views/mobile/Mobile.svelte +3 -10
  22. package/dist/views/mobile/MobileDay.svelte +422 -266
  23. package/dist/views/mobile/MobileWeek.svelte +131 -186
  24. package/dist/views/month/MonthGrid.svelte +334 -0
  25. package/dist/views/month/MonthGrid.svelte.d.ts +15 -0
  26. package/dist/views/planner/Planner.svelte +3 -10
  27. package/dist/views/planner/PlannerDay.svelte +645 -549
  28. package/dist/views/planner/PlannerWeek.svelte +299 -412
  29. package/dist/views/shared/EventContent.svelte +16 -0
  30. package/dist/views/shared/EventContent.svelte.d.ts +9 -0
  31. package/dist/views/shared/context.svelte.d.ts +1 -0
  32. package/dist/views/shared/context.svelte.js +1 -0
  33. package/dist/widget/CalendarWidget.svelte +79 -116
  34. package/package.json +3 -2
  35. package/widget/svelte-calendar.css +329 -13
  36. package/widget/widget.js +1945 -1031
@@ -5,552 +5,559 @@
5
5
  The now-line tracks the current time. Drag to scroll through days.
6
6
  Events are positioned as horizontal cards with overlap support.
7
7
  -->
8
- <script lang="ts">
9
- import { onMount, tick, untrack } from 'svelte';
10
- import { useCalendarContext } from '../shared/context.svelte.js';
11
- import { fly } from 'svelte/transition';
12
- import { createClock } from '../../core/clock.svelte.js';
13
- import { createTextMeasure, type ContentFit } from '../../core/measure.js';
14
- import type { TimelineEvent, BlockedSlot } from '../../core/types.js';
15
- import type { DragState } from '../../engine/drag.svelte.js';
16
- import type { ViewState } from '../../engine/view-state.svelte.js';
17
- import { DAY_MS, HOUR_MS, sod } from '../../core/time.js';
18
- import { isAllDay, isMultiDay, segmentForDay } from '../../core/time.js';
19
- import type { DaySegment } from '../../core/time.js';
20
- import { fmtH, fmtTime, weekdayShort } from '../../core/locale.js';
21
- import { getLabels } from '../../core/locale.js';
22
-
23
- const L = $derived(getLabels());
24
-
25
- interface Props {
26
- /** Total height (null = fill parent) */
27
- height?: number | null;
28
- /** Events to render */
29
- events?: TimelineEvent[];
30
- /** Inline style for CSS variable overrides (theme) */
31
- style?: string;
32
- /** The date to centre this view on */
33
- focusDate?: Date;
34
- /** Locale for labels */
35
- locale?: string;
36
- /** Called when the user clicks an event */
37
- oneventclick?: (event: TimelineEvent) => void;
38
- /** Called when the user clicks an empty time slot */
39
- oneventcreate?: (range: { start: Date; end: Date }) => void;
40
- /** Currently selected event ID (for highlight) */
41
- selectedEventId?: string | null;
42
- /** Read-only mode */
43
- readOnly?: boolean;
44
- /** Visible hour range [startHour, endHour) */
45
- visibleHours?: [number, number];
46
- [key: string]: unknown;
47
- }
48
-
49
- let {
50
- height = 520,
51
- events = [],
52
- style = '',
53
- locale,
54
- focusDate,
55
- oneventclick,
56
- oneventcreate,
57
- selectedEventId = null,
58
- readOnly = false,
59
- visibleHours,
60
- }: Props = $props();
61
-
62
- // ── Context (available when inside Calendar) ──
63
- const ctx = useCalendarContext();
64
- const clock = createClock();
65
- const drag = $derived(ctx.drag);
66
- const commitDragCtx = $derived(ctx.commitDrag);
67
- const viewState = $derived(ctx.viewState);
68
- const loadRangeCtx = $derived(ctx.loadRange);
69
- const blockedSlots = $derived(ctx.blockedSlots);
70
- const dayHeaderSnippet = $derived(ctx.dayHeaderSnippet);
71
- const minDuration = $derived(ctx.minDuration);
72
- const autoHeight = $derived(ctx.autoHeight);
73
- const oneventhover = $derived(ctx.oneventhover);
74
- const disabledSet = $derived(ctx.disabledSet);
75
- const SNAP_MS = $derived(ctx.snapInterval * 60_000);
76
-
77
- // ─── State ──────────────────────────────────────────
78
- let following = $state(true);
79
- let scrollDragging = $state(false);
80
- let wasDragging = false;
81
- let el: HTMLDivElement;
82
- let containerW = $state(0);
83
- let containerH = $state(520);
84
- let dragStartX = 0;
85
- let dragScrollStart = 0;
86
- let rafId = 0;
87
-
88
- // ─── Infinite-scroll config ────────────────────────
89
- const BUFFER_DAYS = 7; // days rendered on each side of centre
90
- const EDGE_DAYS = 2; // rebase when this close to an edge
91
- const SHIFT_DAYS = 5; // days to shift per rebase
92
-
93
- // Internal centre drives layout; syncs bidirectionally with focusDate.
94
- const _initMs = untrack(() => sod(focusDate?.getTime() ?? Date.now()));
95
- let internalCenterMs = $state(_initMs);
96
- let lastExternalMs = _initMs;
97
- let rebasing = false;
98
- let visibleDayMs = $state(_initMs);
99
-
100
- // ─── Derived Config ─────────────────────────────────
101
- const startHour = $derived(visibleHours?.[0] ?? 0);
102
- const endHour = $derived(visibleHours?.[1] ?? 24);
103
- const hourCount = $derived(Math.max(1, endHour - startHour));
104
- const DAY_GAP = 2;
105
-
106
- const count = 1 + 2 * BUFFER_DAYS;
107
- const origin = $derived(internalCenterMs - BUFFER_DAYS * DAY_MS);
108
-
109
- // ─── Declare load range for entire visible buffer ────────
110
- $effect(() => {
111
- if (!loadRangeCtx) return;
112
- const rangeStart = new Date(internalCenterMs - BUFFER_DAYS * DAY_MS);
113
- const rangeEnd = new Date(internalCenterMs + (BUFFER_DAYS + 1) * DAY_MS);
114
- loadRangeCtx.set({ start: rangeStart, end: rangeEnd });
115
- return () => loadRangeCtx.set(null);
116
- });
117
-
118
- // Auto-calculate hourWidth minimum 60px so hours stay readable.
119
- // When 24h × 60px exceeds the container, the timeline scrolls horizontally.
120
- const MIN_HOUR_W = 60;
121
- const hourWidth = $derived(containerW > 0 ? Math.max(MIN_HOUR_W, containerW / hourCount) : 110);
122
- const dayWidth = $derived(hourCount * hourWidth);
123
- const totalWidth = $derived(count * (dayWidth + DAY_GAP));
124
-
125
- // ─── Day Layouts ────────────────────────────────────
126
- interface DayLayout {
127
- ms: number;
128
- today: boolean;
129
- past: boolean;
130
- x: number;
131
- }
132
-
133
- const days = $derived.by(() => {
134
- const result: DayLayout[] = [];
135
- for (let i = 0; i < count; i++) {
136
- const ms = origin + i * DAY_MS;
137
- result.push({
138
- ms,
139
- today: ms === clock.today,
140
- past: ms < clock.today,
141
- x: i * (dayWidth + DAY_GAP),
142
- });
143
- }
144
- return result;
145
- });
146
-
147
- // ─── Coordinate Conversion ──────────────────────────
148
- function timeToPx(ms: number): number {
149
- const elapsed = ms - origin;
150
- const dayIndex = Math.floor(elapsed / DAY_MS);
151
- const hourInDay = (elapsed - dayIndex * DAY_MS) / HOUR_MS;
152
- const hourOffset = hourInDay - startHour;
153
- return dayIndex * (dayWidth + DAY_GAP) + hourOffset * hourWidth;
154
- }
155
-
156
- function pxToTime(px: number): number {
157
- const dayStride = dayWidth + DAY_GAP;
158
- const dayIndex = Math.max(0, Math.min(count - 1, Math.floor(px / dayStride)));
159
- const localPx = px - dayIndex * dayStride;
160
- const hour = startHour + localPx / hourWidth;
161
- return origin + dayIndex * DAY_MS + hour * HOUR_MS;
162
- }
163
-
164
- const nowPx = $derived(timeToPx(clock.tick));
165
-
166
- // ─── Separate all-day from timed events ────────────
167
- const timedEvents = $derived(events.filter((ev) => !isAllDay(ev) && !isMultiDay(ev)));
168
- const allDayEvents = $derived.by(() => {
169
- const segs: DaySegment[] = [];
170
- for (const ev of events) {
171
- if (!isAllDay(ev) && !isMultiDay(ev)) continue;
172
- const seg = segmentForDay(ev, visibleDayMs);
173
- if (seg) segs.push(seg);
174
- }
175
- return segs;
176
- });
177
-
178
- // ─── Event Layout ───────────────────────────────────
179
- const CONTENT_TOP = 56;
180
- const ALLDAY_H = 24;
181
- const contentTop = $derived(CONTENT_TOP + (allDayEvents.length > 0 ? ALLDAY_H + 4 : 0));
182
- const EVENT_GAP = 5;
183
- const MIN_EVENT_H = 32;
184
-
185
- // Text measure for smart content fitting
186
- const measure = createTextMeasure({
187
- titleFont: '600 13px system-ui, sans-serif',
188
- secondaryFont: '400 10px system-ui, sans-serif',
189
- tagFont: '500 8px system-ui, sans-serif',
190
- titleLineHeight: 16,
191
- secondaryLineHeight: 13,
192
- contentGap: 6,
193
- });
194
-
195
- interface PositionedEvent {
196
- ev: TimelineEvent;
197
- x: number;
198
- width: number;
199
- row: number;
200
- groupMaxRow: number;
201
- topPx: number;
202
- heightPx: number;
203
- isCurrent: boolean;
204
- isNext: boolean;
205
- isDragged: boolean;
206
- fit: ContentFit;
207
- }
208
-
209
- const positionedEvents = $derived.by(() => {
210
- const now = clock.tick;
211
- const dragP = drag?.active && drag.mode === 'move' ? drag.payload : null;
212
-
213
- const staticEvents: typeof timedEvents = [];
214
- let draggedEv: TimelineEvent | null = null;
215
- for (const ev of timedEvents) {
216
- if (dragP?.eventId === ev.id) draggedEv = ev;
217
- else staticEvents.push(ev);
218
- }
219
-
220
- const sorted = [...staticEvents].sort((a, b) => a.start.getTime() - b.start.getTime());
221
-
222
- // Find the earliest future event on today to mark as "next"
223
- const todayStart = sod(now);
224
- const todayEnd = todayStart + DAY_MS;
225
- let nextEventId: string | null = null;
226
- for (const ev of sorted) {
227
- const s = ev.start.getTime();
228
- const e = ev.end.getTime();
229
- // Must be today, start in the future, and not currently running
230
- if (s >= todayStart && s < todayEnd && s > now && !(s <= now && e > now)) {
231
- nextEventId = ev.id;
232
- break;
233
- }
234
- }
235
-
236
- const infos = sorted.map((ev) => {
237
- const s = ev.start.getTime();
238
- const e = ev.end.getTime();
239
- const x = timeToPx(s);
240
- const xEnd = timeToPx(e);
241
- return {
242
- ev, x, width: Math.max(xEnd - x, 28), row: 0, groupMaxRow: 1,
243
- isCurrent: s <= now && e > now, isNext: ev.id === nextEventId, isDragged: false, startMs: s, endMs: e,
244
- };
245
- });
246
-
247
- // Union-find overlap groups
248
- const par = infos.map((_, i) => i);
249
- function find(i: number): number {
250
- while (par[i] !== i) { par[i] = par[par[i]]; i = par[i]; }
251
- return i;
252
- }
253
- for (let i = 0; i < infos.length; i++) {
254
- for (let j = i + 1; j < infos.length; j++) {
255
- if (infos[j].startMs < infos[i].endMs) par[find(i)] = find(j);
256
- else break;
257
- }
258
- }
259
-
260
- const groups = new Map<number, number[]>();
261
- for (let i = 0; i < infos.length; i++) {
262
- const root = find(i);
263
- if (!groups.has(root)) groups.set(root, []);
264
- groups.get(root)!.push(i);
265
- }
266
-
267
- for (const [, indices] of groups) {
268
- const rows: number[] = [];
269
- for (const idx of indices) {
270
- const inf = infos[idx];
271
- let row = 0;
272
- for (let r = 0; r < rows.length; r++) {
273
- if (rows[r] <= inf.startMs) { row = r; rows[r] = inf.endMs; break; }
274
- row = r + 1;
275
- }
276
- if (row >= rows.length) rows.push(inf.endMs);
277
- infos[idx].row = row;
278
- }
279
- for (const idx of indices) infos[idx].groupMaxRow = rows.length;
280
- }
281
-
282
- const availH = containerH - contentTop - 8;
283
- const result: PositionedEvent[] = infos.map(({ startMs: _s, endMs: _e, ...info }) => {
284
- const laneH = Math.max(MIN_EVENT_H, availH / info.groupMaxRow - EVENT_GAP);
285
- const topPx = contentTop + info.row * (availH / info.groupMaxRow);
286
- // Vertical labels: text runs along lane height, columns stack across duration width.
287
- const fit = measure.fitContent({
288
- title: info.ev.title,
289
- subtitle: info.ev.subtitle,
290
- location: info.ev.location,
291
- time: `${fmtTime(info.ev.start, locale)} ${fmtTime(info.ev.end, locale)}`,
292
- tags: info.ev.tags,
293
- maxWidth: laneH - 16,
294
- maxHeight: info.width - 16,
295
- });
296
- return { ...info, topPx, heightPx: laneH, isNext: info.isNext, fit };
297
- });
298
-
299
- if (draggedEv && dragP) {
300
- const x = timeToPx(dragP.start.getTime());
301
- const xEnd = timeToPx(dragP.end.getTime());
302
- const dragH = Math.max(MIN_EVENT_H, availH - EVENT_GAP);
303
- const dragW = Math.max(xEnd - x, 28);
304
- result.push({
305
- ev: draggedEv, x, width: dragW,
306
- row: 0, groupMaxRow: 1,
307
- topPx: contentTop,
308
- heightPx: dragH,
309
- isCurrent: draggedEv.start.getTime() <= now && draggedEv.end.getTime() > now,
310
- isNext: false,
311
- isDragged: true,
312
- fit: measure.fitContent({
313
- title: draggedEv.title,
314
- subtitle: draggedEv.subtitle,
315
- location: draggedEv.location,
316
- tags: draggedEv.tags,
317
- maxWidth: dragH - 16,
318
- maxHeight: dragW - 16,
319
- }),
320
- });
321
- }
322
-
323
- return result;
324
- });
325
-
326
- // ─── Infinite-scroll helpers ────────────────────────
327
-
328
- /** Detect external focusDate changes (from nav arrows). */
329
- $effect(() => {
330
- const ext = focusDate ? sod(focusDate.getTime()) : clock.today;
331
- if (ext !== lastExternalMs && !rebasing) {
332
- lastExternalMs = ext;
333
- internalCenterMs = ext;
334
- visibleDayMs = ext;
335
- // Landing on today (e.g. header "Today" button) resumes auto-follow.
336
- following = ext === clock.today;
337
- // After DOM update, recenter scroll on focus day.
338
- tick().then(() => {
339
- if (el) {
340
- const focusX = BUFFER_DAYS * (dayWidth + DAY_GAP);
341
- el.scrollLeft = focusX + dayWidth / 2 - el.clientWidth / 2;
342
- }
343
- });
344
- }
345
- });
346
-
347
- /** Check if viewport is near an edge; if so, rebase the window. */
348
- function checkEdges() {
349
- if (!el || !viewState || rebasing) return;
350
- const stride = dayWidth + DAY_GAP;
351
- const threshold = stride * EDGE_DAYS;
352
- const maxScroll = el.scrollWidth - el.clientWidth;
353
-
354
- if (el.scrollLeft < threshold) {
355
- rebase(-1);
356
- } else if (maxScroll > 0 && maxScroll - el.scrollLeft < threshold) {
357
- rebase(1);
358
- }
359
- }
360
-
361
- /** Shift the rendered window by SHIFT_DAYS in the given direction. */
362
- function rebase(direction: number) {
363
- if (rebasing) return;
364
- rebasing = true;
365
-
366
- const shift = SHIFT_DAYS * direction;
367
- const stride = dayWidth + DAY_GAP;
368
- const adj = -shift * stride;
369
-
370
- // Pre-compensate scroll so the viewport stays visually stable.
371
- if (el) el.scrollLeft += adj;
372
- dragScrollStart += adj;
373
-
374
- internalCenterMs += shift * DAY_MS;
375
- lastExternalMs = internalCenterMs;
376
- viewState?.setFocusDate(new Date(internalCenterMs));
377
-
378
- tick().then(() => { rebasing = false; });
379
- }
380
-
381
- /** Push the currently visible centre day to viewState (updates Calendar date label). */
382
- function syncFocusFromScroll() {
383
- if (!el || !viewState || following || rebasing) return;
384
- const centerX = el.scrollLeft + el.clientWidth / 2;
385
- const centerDayMs = sod(pxToTime(centerX));
386
- visibleDayMs = centerDayMs;
387
- if (centerDayMs !== lastExternalMs) {
388
- lastExternalMs = centerDayMs;
389
- viewState.setFocusDate(new Date(centerDayMs));
390
- }
391
- }
392
-
393
- // ─── Lifecycle ──────────────────────────────────────
394
- onMount(() => {
395
- const ro = new ResizeObserver((entries) => {
396
- for (const entry of entries) {
397
- containerW = entry.contentRect.width;
398
- containerH = entry.contentRect.height;
399
- }
400
- });
401
- ro.observe(el);
402
-
403
- function frame() {
404
- if (following && el && !scrollDragging) {
405
- // Ensure today is in the strip.
406
- if (internalCenterMs !== clock.today) {
407
- internalCenterMs = clock.today;
408
- lastExternalMs = clock.today;
409
- viewState?.goToday();
410
- }
411
- visibleDayMs = clock.today;
412
- const todayD = days.find((d) => d.today);
413
- if (todayD) {
414
- el.scrollLeft = todayD.x + dayWidth / 2 - el.clientWidth / 2;
415
- }
416
- } else if (el && !rebasing) {
417
- checkEdges();
418
- syncFocusFromScroll();
419
- }
420
- rafId = requestAnimationFrame(frame);
421
- }
422
- rafId = requestAnimationFrame(frame);
423
- return () => { cancelAnimationFrame(rafId); ro.disconnect(); };
424
- });
425
-
426
- // ─── Drag-to-scroll ─────────────────────────────────
427
- const SCROLL_THRESHOLD = 3;
428
-
429
- function onPointerDown(e: PointerEvent) {
430
- if (e.button !== 0) return;
431
- if (!readOnly) return; // drag-to-scroll only in read-only mode
432
- if ((e.target as HTMLElement).closest('.fs-event')) return;
433
- dragStartX = e.clientX;
434
- dragScrollStart = el.scrollLeft;
435
- window.addEventListener('pointermove', onScrollMove);
436
- window.addEventListener('pointerup', onScrollUp, { once: true });
437
- window.addEventListener('pointercancel', onScrollUp, { once: true });
438
- }
439
-
440
- function onScrollMove(e: PointerEvent) {
441
- const dx = e.clientX - dragStartX;
442
- if (!scrollDragging && Math.abs(dx) >= SCROLL_THRESHOLD) {
443
- scrollDragging = true;
444
- wasDragging = true;
445
- following = false;
446
- }
447
- if (scrollDragging) el.scrollLeft = dragScrollStart - dx;
448
- }
449
-
450
- function onScrollUp() {
451
- window.removeEventListener('pointermove', onScrollMove);
452
- window.removeEventListener('pointerup', onScrollUp);
453
- window.removeEventListener('pointercancel', onScrollUp);
454
- scrollDragging = false;
455
- }
456
-
457
- // ─── Click-to-create ────────────────────────────────
458
- function isBlockedAt(dayMs: number, hour: number): boolean {
459
- if (!blockedSlots?.length) return false;
460
- const jsDay = new Date(dayMs).getDay();
461
- const isoDay = jsDay === 0 ? 7 : jsDay;
462
- return blockedSlots.some(slot => {
463
- if (slot.day && slot.day !== isoDay) return false;
464
- return hour >= slot.start && hour < slot.end;
465
- });
466
- }
467
- function handleTrackClick(e: MouseEvent) {
468
- if (wasDragging) { wasDragging = false; return; }
469
- if (!oneventcreate || readOnly) return;
470
- if ((e.target as HTMLElement).closest('.fs-event')) return;
471
-
472
- const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
473
- const clickX = e.clientX - rect.left + el.scrollLeft;
474
-
475
- for (const d of days) {
476
- if (clickX >= d.x && clickX < d.x + dayWidth) {
477
- // Check disabled dates
478
- if (disabledSet.has(d.ms)) return;
479
- const frac = (clickX - d.x) / dayWidth;
480
- const clickHour = startHour + frac * hourCount;
481
- // Check blocked slots
482
- if (isBlockedAt(d.ms, clickHour)) return;
483
- const hour = Math.floor(clickHour);
484
- const durMin = minDuration ? Math.max(60, minDuration) : 60;
485
- const start = new Date(d.ms + hour * HOUR_MS);
486
- const end = new Date(start.getTime() + durMin * 60_000);
487
- oneventcreate({ start, end });
488
- return;
489
- }
490
- }
491
- }
492
-
493
- // ─── Event drag-to-move ─────────────────────────────
494
- const DRAG_THRESHOLD = 5;
495
- let evDragStartX = 0;
496
- let evDragOriginPx = 0;
497
- let evDragStarted = false;
498
- let evDragging = $state(false);
499
- let evDragId = $state<string | null>(null);
500
- let evDragEvent: TimelineEvent | null = null;
501
-
502
- function onEventPointerDown(e: PointerEvent, ev: TimelineEvent) {
503
- if (e.button !== 0 || !drag || readOnly || ev.data?.readOnly) return;
504
- e.stopPropagation();
505
- evDragStartX = e.clientX;
506
- evDragOriginPx = timeToPx(ev.start.getTime());
507
- evDragStarted = false;
508
- evDragId = ev.id;
509
- evDragEvent = ev;
510
- window.addEventListener('pointermove', onEvMove);
511
- window.addEventListener('pointerup', onEvUp, { once: true });
512
- window.addEventListener('pointercancel', onEvCancel, { once: true });
513
- }
514
-
515
- function onEvMove(e: PointerEvent) {
516
- const ev = evDragEvent;
517
- if (!drag || !ev || evDragId !== ev.id) return;
518
- const dx = e.clientX - evDragStartX;
519
- if (!evDragStarted && Math.abs(dx) < DRAG_THRESHOLD) return;
520
-
521
- if (!evDragStarted) {
522
- evDragStarted = true;
523
- evDragging = true;
524
- drag.beginMove(ev.id, ev.start, ev.end);
525
- }
526
-
527
- const duration = ev.end.getTime() - ev.start.getTime();
528
- const raw = pxToTime(evDragOriginPx + dx);
529
- const snapped = Math.round(raw / SNAP_MS) * SNAP_MS;
530
- drag.updatePointer(new Date(snapped), new Date(snapped + duration));
531
- }
532
-
533
- function cleanupEvDrag() {
534
- window.removeEventListener('pointermove', onEvMove);
535
- window.removeEventListener('pointerup', onEvUp);
536
- window.removeEventListener('pointercancel', onEvCancel);
537
- evDragStarted = false;
538
- evDragging = false;
539
- evDragId = null;
540
- evDragEvent = null;
541
- }
542
-
543
- function onEvUp() {
544
- if (!drag) { cleanupEvDrag(); return; }
545
- if (!evDragStarted && evDragEvent) oneventclick?.(evDragEvent);
546
- else if (evDragStarted) commitDragCtx?.();
547
- cleanupEvDrag();
548
- }
549
-
550
- function onEvCancel() {
551
- if (drag && evDragStarted) drag.cancel();
552
- cleanupEvDrag();
553
- }
8
+ <script lang="ts">import { onMount, tick, untrack } from "svelte";
9
+ import { useCalendarContext } from "../shared/context.svelte.js";
10
+ import EventContent from "../shared/EventContent.svelte";
11
+ import { fly } from "svelte/transition";
12
+ import { createClock } from "../../core/clock.svelte.js";
13
+ import { createTextMeasure } from "../../core/measure.js";
14
+ import { DAY_MS, HOUR_MS, sod } from "../../core/time.js";
15
+ import { isAllDay, isMultiDay, segmentForDay } from "../../core/time.js";
16
+ import { fmtH, fmtTime, weekdayShort } from "../../core/locale.js";
17
+ import { getLabels } from "../../core/locale.js";
18
+ const L = $derived(getLabels());
19
+ let {
20
+ height = 520,
21
+ events = [],
22
+ style = "",
23
+ locale,
24
+ focusDate,
25
+ oneventclick,
26
+ oneventcreate,
27
+ selectedEventId = null,
28
+ readOnly = false,
29
+ visibleHours
30
+ } = $props();
31
+ const ctx = useCalendarContext();
32
+ const clock = createClock();
33
+ const drag = $derived(ctx.drag);
34
+ const commitDragCtx = $derived(ctx.commitDrag);
35
+ const viewState = $derived(ctx.viewState);
36
+ const loadRangeCtx = $derived(ctx.loadRange);
37
+ const blockedSlots = $derived(ctx.blockedSlots);
38
+ const dayHeaderSnippet = $derived(ctx.dayHeaderSnippet);
39
+ const minDuration = $derived(ctx.minDuration);
40
+ const autoHeight = $derived(ctx.autoHeight);
41
+ const oneventhover = $derived(ctx.oneventhover);
42
+ const disabledSet = $derived(ctx.disabledSet);
43
+ const SNAP_MS = $derived(ctx.snapInterval * 6e4);
44
+ let following = $state(true);
45
+ let scrollDragging = $state(false);
46
+ let wasDragging = false;
47
+ let el;
48
+ let containerW = $state(0);
49
+ let containerH = $state(520);
50
+ let dragStartX = 0;
51
+ let dragScrollStart = 0;
52
+ let rafId = 0;
53
+ const BUFFER_DAYS = 7;
54
+ const EDGE_DAYS = 2;
55
+ const SHIFT_DAYS = 5;
56
+ const _initMs = untrack(() => sod(focusDate?.getTime() ?? Date.now()));
57
+ let internalCenterMs = $state(_initMs);
58
+ let lastExternalMs = _initMs;
59
+ let rebasing = false;
60
+ let visibleDayMs = $state(_initMs);
61
+ const startHour = $derived(visibleHours?.[0] ?? 0);
62
+ const endHour = $derived(visibleHours?.[1] ?? 24);
63
+ const hourCount = $derived(Math.max(1, endHour - startHour));
64
+ const DAY_GAP = 2;
65
+ const count = 1 + 2 * BUFFER_DAYS;
66
+ const origin = $derived(internalCenterMs - BUFFER_DAYS * DAY_MS);
67
+ $effect(() => {
68
+ if (!loadRangeCtx) return;
69
+ const rangeStart = new Date(internalCenterMs - BUFFER_DAYS * DAY_MS);
70
+ const rangeEnd = new Date(internalCenterMs + (BUFFER_DAYS + 1) * DAY_MS);
71
+ loadRangeCtx.set({ start: rangeStart, end: rangeEnd });
72
+ return () => loadRangeCtx.set(null);
73
+ });
74
+ const MIN_HOUR_W = 60;
75
+ const hourWidth = $derived(containerW > 0 ? Math.max(MIN_HOUR_W, containerW / hourCount) : 110);
76
+ const dayWidth = $derived(hourCount * hourWidth);
77
+ const totalWidth = $derived(count * (dayWidth + DAY_GAP));
78
+ const days = $derived.by(() => {
79
+ const result = [];
80
+ for (let i = 0; i < count; i++) {
81
+ const ms = origin + i * DAY_MS;
82
+ result.push({
83
+ ms,
84
+ today: ms === clock.today,
85
+ past: ms < clock.today,
86
+ x: i * (dayWidth + DAY_GAP)
87
+ });
88
+ }
89
+ return result;
90
+ });
91
+ function timeToPx(ms) {
92
+ const elapsed = ms - origin;
93
+ const dayIndex = Math.floor(elapsed / DAY_MS);
94
+ const hourInDay = (elapsed - dayIndex * DAY_MS) / HOUR_MS;
95
+ const hourOffset = hourInDay - startHour;
96
+ return dayIndex * (dayWidth + DAY_GAP) + hourOffset * hourWidth;
97
+ }
98
+ function pxToTime(px) {
99
+ const dayStride = dayWidth + DAY_GAP;
100
+ const dayIndex = Math.max(0, Math.min(count - 1, Math.floor(px / dayStride)));
101
+ const localPx = px - dayIndex * dayStride;
102
+ const hour = startHour + localPx / hourWidth;
103
+ return origin + dayIndex * DAY_MS + hour * HOUR_MS;
104
+ }
105
+ const nowPx = $derived(timeToPx(clock.tick));
106
+ const timedEvents = $derived(events.filter((ev) => !isAllDay(ev) && !isMultiDay(ev)));
107
+ const allDayEvents = $derived.by(() => {
108
+ const segs = [];
109
+ for (const ev of events) {
110
+ if (!isAllDay(ev) && !isMultiDay(ev)) continue;
111
+ const seg = segmentForDay(ev, visibleDayMs);
112
+ if (seg) segs.push(seg);
113
+ }
114
+ return segs;
115
+ });
116
+ const CONTENT_TOP = 56;
117
+ const ALLDAY_H = 24;
118
+ const contentTop = $derived(CONTENT_TOP + (allDayEvents.length > 0 ? ALLDAY_H + 4 : 0));
119
+ const EVENT_GAP = 5;
120
+ const MIN_EVENT_H = 32;
121
+ const measure = createTextMeasure({
122
+ titleFont: "600 13px system-ui, sans-serif",
123
+ secondaryFont: "400 10px system-ui, sans-serif",
124
+ tagFont: "500 8px system-ui, sans-serif",
125
+ titleLineHeight: 16,
126
+ secondaryLineHeight: 13,
127
+ contentGap: 6
128
+ });
129
+ const positionedEvents = $derived.by(() => {
130
+ const now = clock.tick;
131
+ const dragP = drag?.active && (drag.mode === "move" || drag.mode === "resize-start" || drag.mode === "resize-end") ? drag.payload : null;
132
+ const staticEvents = [];
133
+ let draggedEv = null;
134
+ for (const ev of timedEvents) {
135
+ if (dragP?.eventId === ev.id) draggedEv = ev;
136
+ else staticEvents.push(ev);
137
+ }
138
+ const sorted = [...staticEvents].sort((a, b) => a.start.getTime() - b.start.getTime());
139
+ const todayStart = sod(now);
140
+ const todayEnd = todayStart + DAY_MS;
141
+ let nextEventId = null;
142
+ for (const ev of sorted) {
143
+ const s = ev.start.getTime();
144
+ const e = ev.end.getTime();
145
+ if (s >= todayStart && s < todayEnd && s > now && !(s <= now && e > now)) {
146
+ nextEventId = ev.id;
147
+ break;
148
+ }
149
+ }
150
+ const infos = sorted.map((ev) => {
151
+ const s = ev.start.getTime();
152
+ const e = ev.end.getTime();
153
+ const x = timeToPx(s);
154
+ const xEnd = timeToPx(e);
155
+ return {
156
+ ev,
157
+ x,
158
+ width: Math.max(xEnd - x, 28),
159
+ row: 0,
160
+ groupMaxRow: 1,
161
+ isCurrent: s <= now && e > now,
162
+ isNext: ev.id === nextEventId,
163
+ isDragged: false,
164
+ startMs: s,
165
+ endMs: e
166
+ };
167
+ });
168
+ const par = infos.map((_, i) => i);
169
+ function find(i) {
170
+ while (par[i] !== i) {
171
+ par[i] = par[par[i]];
172
+ i = par[i];
173
+ }
174
+ return i;
175
+ }
176
+ for (let i = 0; i < infos.length; i++) {
177
+ for (let j = i + 1; j < infos.length; j++) {
178
+ if (infos[j].startMs < infos[i].endMs) par[find(i)] = find(j);
179
+ else break;
180
+ }
181
+ }
182
+ const groups = /* @__PURE__ */ new Map();
183
+ for (let i = 0; i < infos.length; i++) {
184
+ const root = find(i);
185
+ if (!groups.has(root)) groups.set(root, []);
186
+ groups.get(root).push(i);
187
+ }
188
+ for (const [, indices] of groups) {
189
+ const rows = [];
190
+ for (const idx of indices) {
191
+ const inf = infos[idx];
192
+ let row = 0;
193
+ for (let r = 0; r < rows.length; r++) {
194
+ if (rows[r] <= inf.startMs) {
195
+ row = r;
196
+ rows[r] = inf.endMs;
197
+ break;
198
+ }
199
+ row = r + 1;
200
+ }
201
+ if (row >= rows.length) rows.push(inf.endMs);
202
+ infos[idx].row = row;
203
+ }
204
+ for (const idx of indices) infos[idx].groupMaxRow = rows.length;
205
+ }
206
+ const availH = containerH - contentTop - 8;
207
+ const result = infos.map(({ startMs: _s, endMs: _e, ...info }) => {
208
+ const laneH = Math.max(MIN_EVENT_H, availH / info.groupMaxRow - EVENT_GAP);
209
+ const topPx = contentTop + info.row * (availH / info.groupMaxRow);
210
+ const fit = measure.fitContent({
211
+ title: info.ev.title,
212
+ subtitle: info.ev.subtitle,
213
+ location: info.ev.location,
214
+ time: `${fmtTime(info.ev.start, locale)} \u2013 ${fmtTime(info.ev.end, locale)}`,
215
+ tags: info.ev.tags,
216
+ maxWidth: laneH - 16,
217
+ maxHeight: info.width - 16
218
+ });
219
+ return { ...info, topPx, heightPx: laneH, isNext: info.isNext, fit };
220
+ });
221
+ if (draggedEv && dragP) {
222
+ const x = timeToPx(dragP.start.getTime());
223
+ const xEnd = timeToPx(dragP.end.getTime());
224
+ const dragH = Math.max(MIN_EVENT_H, availH - EVENT_GAP);
225
+ const dragW = Math.max(xEnd - x, 28);
226
+ result.push({
227
+ ev: draggedEv,
228
+ x,
229
+ width: dragW,
230
+ row: 0,
231
+ groupMaxRow: 1,
232
+ topPx: contentTop,
233
+ heightPx: dragH,
234
+ isCurrent: draggedEv.start.getTime() <= now && draggedEv.end.getTime() > now,
235
+ isNext: false,
236
+ isDragged: true,
237
+ fit: measure.fitContent({
238
+ title: draggedEv.title,
239
+ subtitle: draggedEv.subtitle,
240
+ location: draggedEv.location,
241
+ tags: draggedEv.tags,
242
+ maxWidth: dragH - 16,
243
+ maxHeight: dragW - 16
244
+ })
245
+ });
246
+ }
247
+ return result;
248
+ });
249
+ $effect(() => {
250
+ const ext = focusDate ? sod(focusDate.getTime()) : clock.today;
251
+ if (ext !== lastExternalMs && !rebasing) {
252
+ lastExternalMs = ext;
253
+ internalCenterMs = ext;
254
+ visibleDayMs = ext;
255
+ following = ext === clock.today;
256
+ tick().then(() => {
257
+ if (el) {
258
+ const focusX = BUFFER_DAYS * (dayWidth + DAY_GAP);
259
+ el.scrollLeft = focusX + dayWidth / 2 - el.clientWidth / 2;
260
+ }
261
+ });
262
+ }
263
+ });
264
+ function checkEdges() {
265
+ if (!el || !viewState || rebasing) return;
266
+ const stride = dayWidth + DAY_GAP;
267
+ const threshold = stride * EDGE_DAYS;
268
+ const maxScroll = el.scrollWidth - el.clientWidth;
269
+ if (el.scrollLeft < threshold) {
270
+ rebase(-1);
271
+ } else if (maxScroll > 0 && maxScroll - el.scrollLeft < threshold) {
272
+ rebase(1);
273
+ }
274
+ }
275
+ function rebase(direction) {
276
+ if (rebasing) return;
277
+ rebasing = true;
278
+ const shift = SHIFT_DAYS * direction;
279
+ const stride = dayWidth + DAY_GAP;
280
+ const adj = -shift * stride;
281
+ if (el) el.scrollLeft += adj;
282
+ dragScrollStart += adj;
283
+ internalCenterMs += shift * DAY_MS;
284
+ lastExternalMs = internalCenterMs;
285
+ viewState?.setFocusDate(new Date(internalCenterMs));
286
+ tick().then(() => {
287
+ rebasing = false;
288
+ });
289
+ }
290
+ function syncFocusFromScroll() {
291
+ if (!el || !viewState || following || rebasing) return;
292
+ const centerX = el.scrollLeft + el.clientWidth / 2;
293
+ const centerDayMs = sod(pxToTime(centerX));
294
+ visibleDayMs = centerDayMs;
295
+ if (centerDayMs !== lastExternalMs) {
296
+ lastExternalMs = centerDayMs;
297
+ viewState.setFocusDate(new Date(centerDayMs));
298
+ }
299
+ }
300
+ onMount(() => {
301
+ const ro = new ResizeObserver((entries) => {
302
+ for (const entry of entries) {
303
+ containerW = entry.contentRect.width;
304
+ containerH = entry.contentRect.height;
305
+ }
306
+ });
307
+ ro.observe(el);
308
+ function frame() {
309
+ if (following && el && !scrollDragging) {
310
+ if (internalCenterMs !== clock.today) {
311
+ internalCenterMs = clock.today;
312
+ lastExternalMs = clock.today;
313
+ viewState?.goToday();
314
+ }
315
+ visibleDayMs = clock.today;
316
+ const todayD = days.find((d) => d.today);
317
+ if (todayD) {
318
+ el.scrollLeft = todayD.x + dayWidth / 2 - el.clientWidth / 2;
319
+ }
320
+ } else if (el && !rebasing) {
321
+ checkEdges();
322
+ syncFocusFromScroll();
323
+ }
324
+ rafId = requestAnimationFrame(frame);
325
+ }
326
+ rafId = requestAnimationFrame(frame);
327
+ return () => {
328
+ cancelAnimationFrame(rafId);
329
+ ro.disconnect();
330
+ };
331
+ });
332
+ const SCROLL_THRESHOLD = 3;
333
+ function onPointerDown(e) {
334
+ if (e.button !== 0) return;
335
+ if (e.target.closest(".fs-event")) return;
336
+ if (readOnly) {
337
+ dragStartX = e.clientX;
338
+ dragScrollStart = el.scrollLeft;
339
+ window.addEventListener("pointermove", onScrollMove);
340
+ window.addEventListener("pointerup", onScrollUp, { once: true });
341
+ window.addEventListener("pointercancel", onScrollUp, { once: true });
342
+ return;
343
+ }
344
+ onCreatePointerDown(e);
345
+ }
346
+ function onScrollMove(e) {
347
+ const dx = e.clientX - dragStartX;
348
+ if (!scrollDragging && Math.abs(dx) >= SCROLL_THRESHOLD) {
349
+ scrollDragging = true;
350
+ wasDragging = true;
351
+ following = false;
352
+ }
353
+ if (scrollDragging) el.scrollLeft = dragScrollStart - dx;
354
+ }
355
+ function onScrollUp() {
356
+ window.removeEventListener("pointermove", onScrollMove);
357
+ window.removeEventListener("pointerup", onScrollUp);
358
+ window.removeEventListener("pointercancel", onScrollUp);
359
+ scrollDragging = false;
360
+ }
361
+ function isBlockedAt(dayMs, hour) {
362
+ if (!blockedSlots?.length) return false;
363
+ const jsDay = new Date(dayMs).getDay();
364
+ const isoDay = jsDay === 0 ? 7 : jsDay;
365
+ return blockedSlots.some((slot) => {
366
+ if (slot.day && slot.day !== isoDay) return false;
367
+ return hour >= slot.start && hour < slot.end;
368
+ });
369
+ }
370
+ function handleTrackClick(e) {
371
+ if (wasDragging) {
372
+ wasDragging = false;
373
+ return;
374
+ }
375
+ if (!oneventcreate || readOnly) return;
376
+ if (e.target.closest(".fs-event")) return;
377
+ const rect = e.currentTarget.getBoundingClientRect();
378
+ const clickX = e.clientX - rect.left + el.scrollLeft;
379
+ for (const d of days) {
380
+ if (clickX >= d.x && clickX < d.x + dayWidth) {
381
+ if (disabledSet.has(d.ms)) return;
382
+ const frac = (clickX - d.x) / dayWidth;
383
+ const clickHour = startHour + frac * hourCount;
384
+ if (isBlockedAt(d.ms, clickHour)) return;
385
+ const hour = Math.floor(clickHour);
386
+ const durMin = minDuration ? Math.max(60, minDuration) : 60;
387
+ const start = new Date(d.ms + hour * HOUR_MS);
388
+ const end = new Date(start.getTime() + durMin * 6e4);
389
+ oneventcreate({ start, end });
390
+ return;
391
+ }
392
+ }
393
+ }
394
+ const CREATE_THRESHOLD = 4;
395
+ let createStartX = 0;
396
+ let createAnchorMs = 0;
397
+ let createStarted = false;
398
+ function trackX(e) {
399
+ return e.clientX - el.getBoundingClientRect().left + el.scrollLeft;
400
+ }
401
+ function onCreatePointerDown(e) {
402
+ if (!drag || !oneventcreate) return;
403
+ createStartX = e.clientX;
404
+ createAnchorMs = pxToTime(trackX(e));
405
+ createStarted = false;
406
+ window.addEventListener("pointermove", onCreateMove);
407
+ window.addEventListener("pointerup", onCreateUp, { once: true });
408
+ window.addEventListener("pointercancel", onCreateCancel, { once: true });
409
+ }
410
+ function onCreateMove(e) {
411
+ if (!drag) return;
412
+ if (!createStarted) {
413
+ if (Math.abs(e.clientX - createStartX) < CREATE_THRESHOLD) return;
414
+ if (disabledSet.has(sod(createAnchorMs))) return;
415
+ createStarted = true;
416
+ wasDragging = true;
417
+ createAnchorMs = Math.floor(createAnchorMs / SNAP_MS) * SNAP_MS;
418
+ drag.beginCreate(new Date(createAnchorMs), new Date(createAnchorMs + SNAP_MS));
419
+ }
420
+ const snapped = Math.round(pxToTime(trackX(e)) / SNAP_MS) * SNAP_MS;
421
+ drag.updatePointer(
422
+ new Date(Math.min(createAnchorMs, snapped)),
423
+ new Date(Math.max(createAnchorMs + SNAP_MS, snapped))
424
+ );
425
+ }
426
+ function cleanupCreateDrag() {
427
+ window.removeEventListener("pointermove", onCreateMove);
428
+ window.removeEventListener("pointerup", onCreateUp);
429
+ window.removeEventListener("pointercancel", onCreateCancel);
430
+ createStarted = false;
431
+ }
432
+ function onCreateUp() {
433
+ if (drag && createStarted) {
434
+ commitDragCtx?.();
435
+ setTimeout(() => {
436
+ wasDragging = false;
437
+ }, 0);
438
+ }
439
+ cleanupCreateDrag();
440
+ }
441
+ function onCreateCancel() {
442
+ if (drag && createStarted) {
443
+ drag.cancel();
444
+ setTimeout(() => {
445
+ wasDragging = false;
446
+ }, 0);
447
+ }
448
+ cleanupCreateDrag();
449
+ }
450
+ const DRAG_THRESHOLD = 5;
451
+ let evDragStartX = 0;
452
+ let evDragOriginPx = 0;
453
+ let evDragStarted = false;
454
+ let evDragging = $state(false);
455
+ let evDragId = $state(null);
456
+ let evDragEvent = null;
457
+ function onEventPointerDown(e, ev) {
458
+ if (e.button !== 0 || !drag || readOnly || ev.data?.readOnly) return;
459
+ e.stopPropagation();
460
+ evDragStartX = e.clientX;
461
+ evDragOriginPx = timeToPx(ev.start.getTime());
462
+ evDragStarted = false;
463
+ evDragId = ev.id;
464
+ evDragEvent = ev;
465
+ window.addEventListener("pointermove", onEvMove);
466
+ window.addEventListener("pointerup", onEvUp, { once: true });
467
+ window.addEventListener("pointercancel", onEvCancel, { once: true });
468
+ }
469
+ function onEvMove(e) {
470
+ const ev = evDragEvent;
471
+ if (!drag || !ev || evDragId !== ev.id) return;
472
+ const dx = e.clientX - evDragStartX;
473
+ if (!evDragStarted && Math.abs(dx) < DRAG_THRESHOLD) return;
474
+ if (!evDragStarted) {
475
+ evDragStarted = true;
476
+ evDragging = true;
477
+ drag.beginMove(ev.id, ev.start, ev.end);
478
+ }
479
+ const duration = ev.end.getTime() - ev.start.getTime();
480
+ const raw = pxToTime(evDragOriginPx + dx);
481
+ const snapped = Math.round(raw / SNAP_MS) * SNAP_MS;
482
+ drag.updatePointer(new Date(snapped), new Date(snapped + duration));
483
+ }
484
+ function cleanupEvDrag() {
485
+ window.removeEventListener("pointermove", onEvMove);
486
+ window.removeEventListener("pointerup", onEvUp);
487
+ window.removeEventListener("pointercancel", onEvCancel);
488
+ evDragStarted = false;
489
+ evDragging = false;
490
+ evDragId = null;
491
+ evDragEvent = null;
492
+ }
493
+ function onEvUp() {
494
+ if (!drag) {
495
+ cleanupEvDrag();
496
+ return;
497
+ }
498
+ if (!evDragStarted && evDragEvent) oneventclick?.(evDragEvent);
499
+ else if (evDragStarted) commitDragCtx?.();
500
+ cleanupEvDrag();
501
+ }
502
+ function onEvCancel() {
503
+ if (drag && evDragStarted) drag.cancel();
504
+ cleanupEvDrag();
505
+ }
506
+ let rsStartX = 0;
507
+ let rsStarted = false;
508
+ let rsEdge = "end";
509
+ let rsEvent = null;
510
+ function onResizePointerDown(e, ev, edge) {
511
+ if (e.button !== 0 || !drag || readOnly || ev.data?.readOnly) return;
512
+ e.stopPropagation();
513
+ rsStartX = e.clientX;
514
+ rsStarted = false;
515
+ rsEdge = edge;
516
+ rsEvent = ev;
517
+ window.addEventListener("pointermove", onResizeMove);
518
+ window.addEventListener("pointerup", onResizeUp, { once: true });
519
+ window.addEventListener("pointercancel", onResizeCancel, { once: true });
520
+ }
521
+ function onResizeMove(e) {
522
+ const ev = rsEvent;
523
+ if (!drag || !ev) return;
524
+ if (!rsStarted) {
525
+ if (Math.abs(e.clientX - rsStartX) < CREATE_THRESHOLD) return;
526
+ rsStarted = true;
527
+ drag.beginResize(ev.id, rsEdge, ev.start, ev.end);
528
+ }
529
+ const snapped = Math.round(pxToTime(trackX(e)) / SNAP_MS) * SNAP_MS;
530
+ if (rsEdge === "end") {
531
+ const end = Math.max(snapped, ev.start.getTime() + SNAP_MS);
532
+ drag.updatePointer(ev.start, new Date(end));
533
+ } else {
534
+ const start = Math.min(snapped, ev.end.getTime() - SNAP_MS);
535
+ drag.updatePointer(new Date(start), ev.end);
536
+ }
537
+ }
538
+ function cleanupResize() {
539
+ window.removeEventListener("pointermove", onResizeMove);
540
+ window.removeEventListener("pointerup", onResizeUp);
541
+ window.removeEventListener("pointercancel", onResizeCancel);
542
+ rsStarted = false;
543
+ rsEvent = null;
544
+ }
545
+ function onResizeUp() {
546
+ if (drag && rsStarted) {
547
+ wasDragging = true;
548
+ commitDragCtx?.();
549
+ setTimeout(() => {
550
+ wasDragging = false;
551
+ }, 0);
552
+ } else if (rsEvent && !rsStarted) {
553
+ oneventclick?.(rsEvent);
554
+ }
555
+ cleanupResize();
556
+ }
557
+ function onResizeCancel() {
558
+ if (drag && rsStarted) drag.cancel();
559
+ cleanupResize();
560
+ }
554
561
  </script>
555
562
 
556
563
  <div class="fs" class:fs--auto={autoHeight} style={style || undefined} style:height={autoHeight ? undefined : (height ? `${height}px` : '100%')} role="region" aria-label={L.dayPlanner}>
@@ -630,6 +637,7 @@
630
637
  class:fs-event--current={p.isCurrent}
631
638
  class:fs-event--next={p.isNext}
632
639
  class:fs-event--dragging={p.isDragged}
640
+ class:fs-event--resizing={p.isDragged && drag?.mode !== 'move'}
633
641
  class:fs-event--readonly={p.ev.data?.readOnly}
634
642
  class:fs-event--cancelled={p.ev.status === 'cancelled'}
635
643
  class:fs-event--tentative={p.ev.status === 'tentative'}
@@ -642,6 +650,7 @@
642
650
  style:--ev-color={p.ev.color ?? 'var(--dt-accent)'}
643
651
  role="button"
644
652
  tabindex="0"
653
+ title={p.ev.title}
645
654
  aria-label="{p.ev.title}{p.ev.status === 'cancelled' ? ' (cancelled)' : ''}{p.ev.status === 'tentative' ? ' (tentative)' : ''}{p.ev.status === 'full' ? ' (full)' : ''}{p.ev.status === 'limited' ? ' (limited)' : ''}{p.isCurrent ? ` (${L.inProgress})` : ''}{p.isNext ? ` (${L.upNext})` : ''}"
646
655
  onpointerdown={(e) => onEventPointerDown(e, p.ev)}
647
656
  onpointerenter={() => oneventhover?.(p.ev)}
@@ -653,6 +662,7 @@
653
662
  {:else if p.isNext}
654
663
  <span class="fs-ev-next-badge" aria-hidden="true">{L.upNext}</span>
655
664
  {/if}
665
+ <EventContent event={p.ev}>
656
666
  {#if p.fit.time}
657
667
  <span class="fs-ev-time">{fmtTime(p.ev.start, locale)} – {fmtTime(p.ev.end, locale)}</span>
658
668
  {/if}
@@ -670,9 +680,41 @@
670
680
  {/each}
671
681
  </span>
672
682
  {/if}
683
+ </EventContent>
673
684
  </div>
685
+ {#if !readOnly && !p.ev.data?.readOnly && !p.isDragged}
686
+ <div
687
+ class="fs-ev-handle fs-ev-handle--start"
688
+ aria-hidden="true"
689
+ onpointerdown={(e) => onResizePointerDown(e, p.ev, 'start')}
690
+ ></div>
691
+ <div
692
+ class="fs-ev-handle fs-ev-handle--end"
693
+ aria-hidden="true"
694
+ onpointerdown={(e) => onResizePointerDown(e, p.ev, 'end')}
695
+ ></div>
696
+ {/if}
674
697
  </div>
675
698
  {/each}
699
+
700
+ <!-- Drag-to-create ghost -->
701
+ {#if !readOnly && drag?.active && drag.mode === 'create' && drag.payload}
702
+ {@const gx = timeToPx(drag.payload.start.getTime())}
703
+ {@const gw = Math.max(timeToPx(drag.payload.end.getTime()) - gx, 8)}
704
+ {@const gh = Math.max(MIN_EVENT_H, containerH - contentTop - 8 - EVENT_GAP)}
705
+ <div
706
+ class="fs-create-ghost"
707
+ style:left="{gx}px"
708
+ style:width="{gw}px"
709
+ style:top="{contentTop}px"
710
+ style:height="{gh}px"
711
+ aria-hidden="true"
712
+ >
713
+ <span class="fs-create-ghost-time">
714
+ {fmtTime(drag.payload.start, locale)} – {fmtTime(drag.payload.end, locale)}
715
+ </span>
716
+ </div>
717
+ {/if}
676
718
  </div>
677
719
  </div>
678
720
 
@@ -975,8 +1017,7 @@
975
1017
  box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--ev-color) 20%, transparent);
976
1018
  }
977
1019
  .fs-event--next {
978
- background: color-mix(in srgb, var(--ev-color) 12%, var(--dt-surface, var(--dt-bg, #ffffff)));
979
- border: 1px dashed color-mix(in srgb, var(--ev-color) 35%, transparent);
1020
+ border-color: color-mix(in srgb, var(--ev-color) 75%, transparent);
980
1021
  }
981
1022
  .fs-event--dragging {
982
1023
  opacity: 0.85;
@@ -986,6 +1027,55 @@
986
1027
  /* fast ease toward the snapped cursor position */
987
1028
  transition: left 80ms ease-out, width 80ms ease-out;
988
1029
  }
1030
+ .fs-event--resizing {
1031
+ cursor: ew-resize;
1032
+ }
1033
+
1034
+ /* ─── Resize handles ─────────────────────────────── */
1035
+ .fs-ev-handle {
1036
+ position: absolute;
1037
+ top: 0;
1038
+ bottom: 0;
1039
+ width: 6px;
1040
+ z-index: 2;
1041
+ cursor: ew-resize;
1042
+ touch-action: none;
1043
+ }
1044
+ .fs-ev-handle--start { left: 0; }
1045
+ .fs-ev-handle--end { right: 0; }
1046
+ .fs-ev-handle::after {
1047
+ content: '';
1048
+ position: absolute;
1049
+ top: 20%;
1050
+ bottom: 20%;
1051
+ left: 2px;
1052
+ width: 2px;
1053
+ border-radius: 2px;
1054
+ background: var(--ev-color);
1055
+ opacity: 0;
1056
+ transition: opacity 120ms;
1057
+ }
1058
+ .fs-event:hover .fs-ev-handle::after { opacity: 0.55; }
1059
+
1060
+ /* ─── Drag-to-create ghost ───────────────────────── */
1061
+ .fs-create-ghost {
1062
+ position: absolute;
1063
+ z-index: 40;
1064
+ border-radius: 6px;
1065
+ background: color-mix(in srgb, var(--dt-accent, #2563eb) 14%, transparent);
1066
+ border: 1px dashed color-mix(in srgb, var(--dt-accent, #2563eb) 60%, transparent);
1067
+ display: flex;
1068
+ align-items: flex-start;
1069
+ justify-content: center;
1070
+ overflow: hidden;
1071
+ pointer-events: none;
1072
+ }
1073
+ .fs-create-ghost-time {
1074
+ font: 600 10px/1 var(--dt-mono, ui-monospace, monospace);
1075
+ color: var(--dt-accent, #2563eb);
1076
+ padding: 6px 4px;
1077
+ white-space: nowrap;
1078
+ }
989
1079
 
990
1080
  @media (prefers-reduced-motion: reduce) {
991
1081
  .fs-event,
@@ -1053,7 +1143,13 @@
1053
1143
  color: var(--dt-text, rgba(0, 0, 0, 0.87));
1054
1144
  overflow: hidden;
1055
1145
  text-overflow: ellipsis;
1056
- white-space: nowrap;
1146
+ /* In vertical writing mode line boxes stack as columns — allow a
1147
+ second column before truncating so overlapping (short) cards keep
1148
+ readable names. Full name is in the title tooltip. */
1149
+ display: -webkit-box;
1150
+ -webkit-box-orient: vertical;
1151
+ -webkit-line-clamp: 2;
1152
+ white-space: normal;
1057
1153
  max-height: 100%;
1058
1154
  flex-shrink: 0;
1059
1155
  }