Skip to content

Commit cae41e4

Browse files
committed
improve visual snapping boundaries
1 parent 8973be9 commit cae41e4

2 files changed

Lines changed: 27 additions & 13 deletions

File tree

resources/js/packages/ui/src/FullCalendar/TimeEntryCalendar.vue

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -235,8 +235,8 @@ function handleDateSelect(arg: { start: Date; end: Date }) {
235235
.utc()
236236
.tz(getUserTimezone(), true);
237237
const endLocal = getDayJsInstance()(arg.end.toISOString()).utc().tz(getUserTimezone(), true);
238-
const snappedStart = snapToGrid(startLocal, snap);
239-
let snappedEnd = snapToGrid(endLocal, snap);
238+
const snappedStart = snapStartToGrid(startLocal, snap);
239+
let snappedEnd = snapEndToGrid(endLocal, snap);
240240
if (!snappedEnd.isAfter(snappedStart)) {
241241
snappedEnd = snappedStart.add(snap, 'minute');
242242
}
@@ -255,10 +255,17 @@ function handleEventClick(arg: EventClickArg) {
255255
showEditTimeEntryModal.value = true;
256256
}
257257
258-
// Snap a dayjs time to the nearest snap interval boundary
259-
function snapToGrid(time: Dayjs, snapMinutes: number): Dayjs {
258+
// Snap a dayjs time down to the previous snap boundary (for start times)
259+
function snapStartToGrid(time: Dayjs, snapMinutes: number): Dayjs {
260260
const minutes = time.hour() * 60 + time.minute();
261-
const snapped = Math.round(minutes / snapMinutes) * snapMinutes;
261+
const snapped = Math.floor(minutes / snapMinutes) * snapMinutes;
262+
return time.startOf('day').add(snapped, 'minute');
263+
}
264+
265+
// Snap a dayjs time up to the next snap boundary (for end times)
266+
function snapEndToGrid(time: Dayjs, snapMinutes: number): Dayjs {
267+
const minutes = time.hour() * 60 + time.minute();
268+
const snapped = Math.ceil(minutes / snapMinutes) * snapMinutes;
262269
return time.startOf('day').add(snapped, 'minute');
263270
}
264271
@@ -291,7 +298,7 @@ async function handleEventDrop(arg: EventDropArg) {
291298
.utc()
292299
.tz(getUserTimezone(), true)
293300
.second(0);
294-
const snappedStart = snapToGrid(startLocal, snap);
301+
const snappedStart = snapStartToGrid(startLocal, snap);
295302
const durationMs = getLocalizedDayJs(timeEntry.end).diff(getLocalizedDayJs(timeEntry.start));
296303
const snappedEnd = snappedStart.add(durationMs, 'millisecond');
297304
// Set FC event to snapped position immediately to avoid flash
@@ -325,8 +332,8 @@ async function handleEventResize(arg: EventChangeArg) {
325332
const startChanged = !newStartLocal.isSame(origStartLocal, 'minute');
326333
327334
// Snap only the changed edge once, reuse for both setDates and API update
328-
const snappedStart = startChanged ? snapToGrid(newStartLocal, snap) : null;
329-
const snappedEnd = !startChanged && !ext.isRunning ? snapToGrid(newEndLocal, snap) : null;
335+
const snappedStart = startChanged ? snapStartToGrid(newStartLocal, snap) : null;
336+
const snappedEnd = !startChanged && !ext.isRunning ? snapEndToGrid(newEndLocal, snap) : null;
330337
331338
// Set FC event to snapped position immediately to avoid flash.
332339
// Use the original event date for the edge that wasn't resized.
@@ -747,6 +754,10 @@ onUnmounted(() => {
747754
border: 1px solid var(--primary);
748755
}
749756
757+
.fullcalendar :deep(.fc-event-mirror) {
758+
pointer-events: none;
759+
}
760+
750761
.fullcalendar :deep(.fc-scrollgrid) {
751762
border: 1px solid var(--border);
752763
border-left: 1px solid transparent;

resources/js/packages/ui/src/FullCalendar/useVisualSnap.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ export function useVisualSnap({
3232
function findMirrorHarness(calendarEl: HTMLElement) {
3333
const mirror = calendarEl.querySelector('.fc-event-mirror') as HTMLElement | null;
3434
const harness = mirror?.closest('.fc-timegrid-event-harness') as HTMLElement | null;
35+
if (harness) {
36+
harness.style.pointerEvents = 'none';
37+
}
3538
return { mirror, harness };
3639
}
3740

@@ -81,8 +84,8 @@ export function useVisualSnap({
8184

8285
const top = parseFloat(harness.style.top) || 0;
8386
const endPos = -(parseFloat(harness.style.bottom) || 0);
84-
const snappedTop = Math.round(top / snapPx) * snapPx;
85-
const snappedEnd = Math.round(endPos / snapPx) * snapPx;
87+
const snappedTop = Math.floor(top / snapPx) * snapPx;
88+
const snappedEnd = Math.ceil(endPos / snapPx) * snapPx;
8689
const clampedEnd = Math.max(snappedTop + snapPx, snappedEnd);
8790
harness.style.top = snappedTop + 'px';
8891
harness.style.bottom = -clampedEnd + 'px';
@@ -99,7 +102,7 @@ export function useVisualSnap({
99102
const top = parseFloat(harness.style.top) || 0;
100103
const endPos = -(parseFloat(harness.style.bottom) || 0);
101104
const height = endPos - top;
102-
const snappedTop = Math.round(top / snapPx) * snapPx;
105+
const snappedTop = Math.floor(top / snapPx) * snapPx;
103106
harness.style.top = snappedTop + 'px';
104107
harness.style.bottom = -(snappedTop + height) + 'px';
105108
});
@@ -135,12 +138,12 @@ export function useVisualSnap({
135138
}
136139

137140
if (resizeEdge === 'bottom') {
138-
const snappedEnd = Math.round(endPos / snapPx) * snapPx;
141+
const snappedEnd = Math.ceil(endPos / snapPx) * snapPx;
139142
const clampedEnd = Math.max(top + snapPx, snappedEnd);
140143
harness.style.bottom = -clampedEnd + 'px';
141144
if (mirror) updateMirrorDurationLabel(mirror, top, clampedEnd, snapPx);
142145
} else if (resizeEdge === 'top') {
143-
const snappedTop = Math.round(top / snapPx) * snapPx;
146+
const snappedTop = Math.floor(top / snapPx) * snapPx;
144147
const clampedTop = Math.min(endPos - snapPx, snappedTop);
145148
harness.style.top = clampedTop + 'px';
146149
if (mirror) updateMirrorDurationLabel(mirror, clampedTop, endPos, snapPx);

0 commit comments

Comments
 (0)