mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 22:01:09 +02:00
🐛 fix(calendar): improve drag & drop and resize behavior
- Add hasMoved state to prevent accidental updates on simple clicks - Only persist changes when actual drag/resize movement occurred - Add Escape key handler to cancel drag/resize operations - Improve visual feedback during resize with dashed outline - Fix click handler to properly ignore clicks after drag/resize 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
0e5d923faf
commit
b01153ba87
3 changed files with 430 additions and 101 deletions
|
|
@ -19,15 +19,18 @@
|
|||
const HOUR_HEIGHT = 60; // pixels per hour
|
||||
const SNAP_MINUTES = 15; // snap to 15-minute intervals
|
||||
|
||||
// Generate hours (0-23 or 7-23 depending on setting)
|
||||
// Generate hours (filtered based on settings)
|
||||
let allHours = Array.from({ length: 24 }, (_, i) => i);
|
||||
let hours = $derived(
|
||||
settingsStore.hideEarlyHours ? allHours.filter((h) => h >= 7) : allHours
|
||||
settingsStore.filterHoursEnabled
|
||||
? allHours.filter((h) => h >= settingsStore.dayStartHour && h < settingsStore.dayEndHour)
|
||||
: allHours
|
||||
);
|
||||
|
||||
// Calculate visible hours range for positioning
|
||||
let firstVisibleHour = $derived(settingsStore.hideEarlyHours ? 7 : 0);
|
||||
let totalVisibleHours = $derived(24 - firstVisibleHour);
|
||||
let firstVisibleHour = $derived(settingsStore.filterHoursEnabled ? settingsStore.dayStartHour : 0);
|
||||
let lastVisibleHour = $derived(settingsStore.filterHoursEnabled ? settingsStore.dayEndHour : 24);
|
||||
let totalVisibleHours = $derived(lastVisibleHour - firstVisibleHour);
|
||||
|
||||
// Helper to convert minutes to percentage position (accounting for hidden hours)
|
||||
function minutesToPercent(minutes: number): number {
|
||||
|
|
@ -58,6 +61,20 @@
|
|||
eventsStore.getEventsForDay(viewStore.currentDate).filter((e) => e.isAllDay)
|
||||
);
|
||||
|
||||
// Get display mode for an event (per-event override takes precedence over global setting)
|
||||
function getEventDisplayMode(event: any): 'header' | 'block' {
|
||||
return event.metadata?.allDayDisplayMode || settingsStore.allDayDisplayMode;
|
||||
}
|
||||
|
||||
// Split all-day events by display mode
|
||||
let headerAllDayEvents = $derived(
|
||||
allDayEvents.filter(e => getEventDisplayMode(e) === 'header')
|
||||
);
|
||||
|
||||
let blockAllDayEvents = $derived(
|
||||
allDayEvents.filter(e => getEventDisplayMode(e) === 'block')
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// Drag & Drop State
|
||||
// ============================================================================
|
||||
|
|
@ -79,6 +96,9 @@
|
|||
let resizePreviewTop = $state(0);
|
||||
let resizePreviewHeight = $state(0);
|
||||
|
||||
// Track if we actually moved during drag/resize (to prevent click on simple mousedown/up)
|
||||
let hasMoved = $state(false);
|
||||
|
||||
// ============================================================================
|
||||
// Helper Functions
|
||||
// ============================================================================
|
||||
|
|
@ -115,6 +135,7 @@
|
|||
|
||||
isDragging = true;
|
||||
draggedEvent = event;
|
||||
hasMoved = false;
|
||||
dragPreviewTop = minutesToPercent(startMinutes);
|
||||
dragPreviewHeight = (duration / (totalVisibleHours * 60)) * 100;
|
||||
|
||||
|
|
@ -125,22 +146,23 @@
|
|||
function handleDragMove(e: PointerEvent) {
|
||||
if (!isDragging || !draggedEvent) return;
|
||||
|
||||
hasMoved = true;
|
||||
const mouseMinutes = getMinutesFromY(e.clientY);
|
||||
const newStartMinutes = snapToGrid(mouseMinutes - dragOffsetMinutes);
|
||||
const clampedMinutes = Math.max(firstVisibleHour * 60, Math.min(newStartMinutes, 24 * 60 - 15));
|
||||
const clampedMinutes = Math.max(firstVisibleHour * 60, Math.min(newStartMinutes, lastVisibleHour * 60 - 15));
|
||||
|
||||
dragPreviewTop = minutesToPercent(clampedMinutes);
|
||||
}
|
||||
|
||||
function handleDragEnd(e: PointerEvent) {
|
||||
if (!isDragging || !draggedEvent) {
|
||||
if (!isDragging || !draggedEvent || !hasMoved) {
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
const mouseMinutes = getMinutesFromY(e.clientY);
|
||||
const newStartMinutes = snapToGrid(mouseMinutes - dragOffsetMinutes);
|
||||
const clampedMinutes = Math.max(0, Math.min(newStartMinutes, 24 * 60 - 30));
|
||||
const clampedMinutes = Math.max(firstVisibleHour * 60, Math.min(newStartMinutes, lastVisibleHour * 60 - 30));
|
||||
|
||||
const start = typeof draggedEvent.startTime === 'string' ? parseISO(draggedEvent.startTime) : draggedEvent.startTime;
|
||||
const end = typeof draggedEvent.endTime === 'string' ? parseISO(draggedEvent.endTime) : draggedEvent.endTime;
|
||||
|
|
@ -173,6 +195,7 @@
|
|||
isResizing = true;
|
||||
resizeEvent = event;
|
||||
resizeEdge = edge;
|
||||
hasMoved = false;
|
||||
|
||||
const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
|
||||
const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime;
|
||||
|
|
@ -191,6 +214,7 @@
|
|||
function handleResizeMove(e: PointerEvent) {
|
||||
if (!isResizing || !resizeEvent || !resizeOriginalStart || !resizeOriginalEnd) return;
|
||||
|
||||
hasMoved = true;
|
||||
const mouseMinutes = getMinutesFromY(e.clientY);
|
||||
const snappedMinutes = snapToGrid(mouseMinutes);
|
||||
|
||||
|
|
@ -204,13 +228,13 @@
|
|||
resizePreviewHeight = ((origEndMinutes - clampedStart) / (totalVisibleHours * 60)) * 100;
|
||||
} else {
|
||||
const newEndMinutes = Math.max(snappedMinutes, origStartMinutes + SNAP_MINUTES);
|
||||
const clampedEnd = Math.min(24 * 60, newEndMinutes);
|
||||
const clampedEnd = Math.min(lastVisibleHour * 60, newEndMinutes);
|
||||
resizePreviewHeight = ((clampedEnd - origStartMinutes) / (totalVisibleHours * 60)) * 100;
|
||||
}
|
||||
}
|
||||
|
||||
function handleResizeEnd(e: PointerEvent) {
|
||||
if (!isResizing || !resizeEvent || !resizeOriginalStart || !resizeOriginalEnd) {
|
||||
if (!isResizing || !resizeEvent || !resizeOriginalStart || !resizeOriginalEnd || !hasMoved) {
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
|
|
@ -225,12 +249,12 @@
|
|||
let newEnd = new Date(resizeOriginalEnd);
|
||||
|
||||
if (resizeEdge === 'top') {
|
||||
const newStartMinutes = Math.max(0, Math.min(snappedMinutes, origEndMinutes - SNAP_MINUTES));
|
||||
const newStartMinutes = Math.max(firstVisibleHour * 60, Math.min(snappedMinutes, origEndMinutes - SNAP_MINUTES));
|
||||
newStart = setHours(new Date(viewStore.currentDate), Math.floor(newStartMinutes / 60));
|
||||
newStart = setMinutes(newStart, newStartMinutes % 60);
|
||||
newStart.setSeconds(0, 0);
|
||||
} else {
|
||||
const newEndMinutes = Math.min(24 * 60, Math.max(snappedMinutes, origStartMinutes + SNAP_MINUTES));
|
||||
const newEndMinutes = Math.min(lastVisibleHour * 60, Math.max(snappedMinutes, origStartMinutes + SNAP_MINUTES));
|
||||
newEnd = setHours(new Date(viewStore.currentDate), Math.floor(newEndMinutes / 60));
|
||||
newEnd = setMinutes(newEnd, newEndMinutes % 60);
|
||||
newEnd.setSeconds(0, 0);
|
||||
|
|
@ -251,12 +275,29 @@
|
|||
resizeEvent = null;
|
||||
resizeOriginalStart = null;
|
||||
resizeOriginalEnd = null;
|
||||
hasMoved = false;
|
||||
document.removeEventListener('pointermove', handleDragMove);
|
||||
document.removeEventListener('pointerup', handleDragEnd);
|
||||
document.removeEventListener('pointermove', handleResizeMove);
|
||||
document.removeEventListener('pointerup', handleResizeEnd);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Keyboard Handling
|
||||
// ============================================================================
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' && (isDragging || isResizing)) {
|
||||
e.preventDefault();
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
// Add global keydown listener
|
||||
$effect(() => {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Event Styling
|
||||
// ============================================================================
|
||||
|
|
@ -277,10 +318,11 @@
|
|||
}
|
||||
|
||||
function handleEventClick(event: any, e: MouseEvent) {
|
||||
// Don't navigate if dragging or resizing
|
||||
if (isDragging || isResizing) {
|
||||
// Don't navigate if dragging or resizing, or if we moved
|
||||
if (isDragging || isResizing || hasMoved) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setTimeout(() => { hasMoved = false; }, 100);
|
||||
return;
|
||||
}
|
||||
goto(`/event/${event.id}`);
|
||||
|
|
@ -297,14 +339,14 @@
|
|||
</script>
|
||||
|
||||
<div class="day-view">
|
||||
<!-- All-day events -->
|
||||
{#if allDayEvents.length > 0}
|
||||
<!-- Header-style all-day events -->
|
||||
{#if headerAllDayEvents.length > 0}
|
||||
<div class="all-day-section">
|
||||
<div class="time-gutter">
|
||||
<span class="all-day-label">Ganztägig</span>
|
||||
</div>
|
||||
<div class="all-day-events">
|
||||
{#each allDayEvents as event}
|
||||
{#each headerAllDayEvents as event}
|
||||
<button
|
||||
class="all-day-event"
|
||||
style="background-color: {calendarsStore.getColor(event.calendarId)}"
|
||||
|
|
@ -340,7 +382,18 @@
|
|||
></button>
|
||||
{/each}
|
||||
|
||||
<!-- Events -->
|
||||
<!-- Block-style all-day events -->
|
||||
{#each blockAllDayEvents as event}
|
||||
<button
|
||||
class="all-day-block-event"
|
||||
style="background-color: {calendarsStore.getColor(event.calendarId)}"
|
||||
onclick={(e) => handleEventClick(event, e)}
|
||||
>
|
||||
<span class="event-title">{event.title}</span>
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
<!-- Timed events -->
|
||||
{#each timedEvents as event}
|
||||
{@const isBeingDragged = isDragging && draggedEvent?.id === event.id}
|
||||
{@const isBeingResized = isResizing && resizeEvent?.id === event.id}
|
||||
|
|
@ -395,7 +448,6 @@
|
|||
.day-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
}
|
||||
|
||||
.all-day-section {
|
||||
|
|
@ -426,10 +478,41 @@
|
|||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Block-style all-day events (displayed as full-day blocks in the grid) */
|
||||
.all-day-block-event {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 4px;
|
||||
right: 4px;
|
||||
bottom: 0;
|
||||
padding: 8px 12px;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
z-index: 0;
|
||||
opacity: 0.3;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.all-day-block-event:hover {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.all-day-block-event .event-title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.time-grid {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
|
||||
}
|
||||
|
||||
.time-column {
|
||||
|
|
@ -458,8 +541,6 @@
|
|||
flex: 1;
|
||||
position: relative;
|
||||
border-left: 1px solid hsl(var(--color-border));
|
||||
/* Fixed height for percentage positioning to work */
|
||||
height: calc(24 * var(--hour-height));
|
||||
}
|
||||
|
||||
.day-column.today {
|
||||
|
|
@ -499,9 +580,11 @@
|
|||
|
||||
.event-card.resizing {
|
||||
cursor: ns-resize;
|
||||
opacity: 0.9;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
|
||||
opacity: 0.85;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
|
||||
z-index: 100;
|
||||
outline: 2px dashed rgba(255, 255, 255, 0.6);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* Resize Handles */
|
||||
|
|
|
|||
|
|
@ -45,15 +45,18 @@
|
|||
settingsStore.showOnlyWeekdays ? allDays.filter((day) => !isWeekend(day)) : allDays
|
||||
);
|
||||
|
||||
// Generate hours (0-23 or 7-23 depending on setting)
|
||||
// Generate hours (filtered based on settings)
|
||||
let allHours = Array.from({ length: 24 }, (_, i) => i);
|
||||
let hours = $derived(
|
||||
settingsStore.hideEarlyHours ? allHours.filter((h) => h >= 7) : allHours
|
||||
settingsStore.filterHoursEnabled
|
||||
? allHours.filter((h) => h >= settingsStore.dayStartHour && h < settingsStore.dayEndHour)
|
||||
: allHours
|
||||
);
|
||||
|
||||
// Calculate visible hours range for positioning
|
||||
let firstVisibleHour = $derived(settingsStore.hideEarlyHours ? 7 : 0);
|
||||
let totalVisibleHours = $derived(24 - firstVisibleHour);
|
||||
let firstVisibleHour = $derived(settingsStore.filterHoursEnabled ? settingsStore.dayStartHour : 0);
|
||||
let lastVisibleHour = $derived(settingsStore.filterHoursEnabled ? settingsStore.dayEndHour : 24);
|
||||
let totalVisibleHours = $derived(lastVisibleHour - firstVisibleHour);
|
||||
|
||||
// Helper to convert minutes to percentage position (accounting for hidden hours)
|
||||
function minutesToPercent(minutes: number): number {
|
||||
|
|
@ -100,6 +103,9 @@
|
|||
let resizePreviewTop = $state(0);
|
||||
let resizePreviewHeight = $state(0);
|
||||
|
||||
// Track if we actually moved during drag/resize (to prevent click on simple mousedown/up)
|
||||
let hasMoved = $state(false);
|
||||
|
||||
// Reference to the days container for position calculations
|
||||
let daysContainerEl: HTMLDivElement;
|
||||
|
||||
|
|
@ -111,6 +117,25 @@
|
|||
return eventsStore.getEventsForDay(day).filter((e) => e.isAllDay);
|
||||
}
|
||||
|
||||
// Get display mode for an event (per-event override takes precedence over global setting)
|
||||
function getEventDisplayMode(event: any): 'header' | 'block' {
|
||||
return event.metadata?.allDayDisplayMode || settingsStore.allDayDisplayMode;
|
||||
}
|
||||
|
||||
// Split all-day events by display mode
|
||||
function getHeaderAllDayEventsForDay(day: Date) {
|
||||
return getAllDayEventsForDay(day).filter(e => getEventDisplayMode(e) === 'header');
|
||||
}
|
||||
|
||||
function getBlockAllDayEventsForDay(day: Date) {
|
||||
return getAllDayEventsForDay(day).filter(e => getEventDisplayMode(e) === 'block');
|
||||
}
|
||||
|
||||
// Check if there are any all-day events to show in header
|
||||
let hasAnyHeaderAllDayEvents = $derived(
|
||||
days.some(day => getHeaderAllDayEventsForDay(day).length > 0)
|
||||
);
|
||||
|
||||
function getEventStyle(event: any) {
|
||||
const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
|
||||
const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime;
|
||||
|
|
@ -132,10 +157,11 @@
|
|||
}
|
||||
|
||||
function handleEventClick(event: any, e: MouseEvent) {
|
||||
// Don't navigate if we just finished dragging or resizing
|
||||
if (isDragging || isResizing) {
|
||||
// Don't navigate if we just finished dragging or resizing, or if we moved
|
||||
if (isDragging || isResizing || hasMoved) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setTimeout(() => { hasMoved = false; }, 100);
|
||||
return;
|
||||
}
|
||||
goto(`/event/${event.id}`);
|
||||
|
|
@ -186,6 +212,7 @@
|
|||
|
||||
isDragging = true;
|
||||
draggedEvent = event;
|
||||
hasMoved = false;
|
||||
|
||||
const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
|
||||
const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime;
|
||||
|
|
@ -208,12 +235,14 @@
|
|||
function handleDragMove(e: PointerEvent) {
|
||||
if (!isDragging || !draggedEvent) return;
|
||||
|
||||
hasMoved = true;
|
||||
|
||||
// Calculate new position
|
||||
const newDay = getDayFromX(e.clientX);
|
||||
const newMinutes = getMinutesFromY(e.clientY) - dragOffsetMinutes;
|
||||
|
||||
// Clamp to valid range (firstVisibleHour to 23:45)
|
||||
const clampedMinutes = Math.max(firstVisibleHour * 60, Math.min(24 * 60 - 15, newMinutes));
|
||||
// Clamp to valid range (firstVisibleHour to lastVisibleHour)
|
||||
const clampedMinutes = Math.max(firstVisibleHour * 60, Math.min(lastVisibleHour * 60 - 15, newMinutes));
|
||||
|
||||
// Update preview
|
||||
dragPreviewTop = minutesToPercent(clampedMinutes);
|
||||
|
|
@ -226,9 +255,10 @@
|
|||
document.removeEventListener('pointermove', handleDragMove);
|
||||
document.removeEventListener('pointerup', handleDragEnd);
|
||||
|
||||
if (!isDragging || !draggedEvent || !dragTargetDay) {
|
||||
if (!isDragging || !draggedEvent || !dragTargetDay || !hasMoved) {
|
||||
isDragging = false;
|
||||
draggedEvent = null;
|
||||
hasMoved = false;
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -258,6 +288,7 @@
|
|||
isDragging = false;
|
||||
draggedEvent = null;
|
||||
dragTargetDay = null;
|
||||
hasMoved = false;
|
||||
}
|
||||
|
||||
// ========== Resize Functions ==========
|
||||
|
|
@ -269,6 +300,7 @@
|
|||
isResizing = true;
|
||||
resizeEvent = event;
|
||||
resizeEdge = edge;
|
||||
hasMoved = false;
|
||||
|
||||
const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
|
||||
const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime;
|
||||
|
|
@ -289,13 +321,14 @@
|
|||
function handleResizeMove(e: PointerEvent) {
|
||||
if (!isResizing || !resizeEvent || !resizeOriginalStart || !resizeOriginalEnd) return;
|
||||
|
||||
hasMoved = true;
|
||||
const currentMinutes = getMinutesFromY(e.clientY);
|
||||
const originalStartMinutes = resizeOriginalStart.getHours() * 60 + resizeOriginalStart.getMinutes();
|
||||
const originalEndMinutes = resizeOriginalEnd.getHours() * 60 + resizeOriginalEnd.getMinutes();
|
||||
|
||||
if (resizeEdge === 'bottom') {
|
||||
// Resize from bottom - change end time
|
||||
const newEndMinutes = Math.max(originalStartMinutes + 15, Math.min(24 * 60, currentMinutes));
|
||||
const newEndMinutes = Math.max(originalStartMinutes + 15, Math.min(lastVisibleHour * 60, currentMinutes));
|
||||
const newDuration = newEndMinutes - originalStartMinutes;
|
||||
resizePreviewHeight = (newDuration / (totalVisibleHours * 60)) * 100;
|
||||
} else {
|
||||
|
|
@ -311,9 +344,12 @@
|
|||
document.removeEventListener('pointermove', handleResizeMove);
|
||||
document.removeEventListener('pointerup', handleResizeEnd);
|
||||
|
||||
if (!isResizing || !resizeEvent || !resizeOriginalStart || !resizeOriginalEnd) {
|
||||
if (!isResizing || !resizeEvent || !resizeOriginalStart || !resizeOriginalEnd || !hasMoved) {
|
||||
isResizing = false;
|
||||
resizeEvent = null;
|
||||
resizeOriginalStart = null;
|
||||
resizeOriginalEnd = null;
|
||||
hasMoved = false;
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -325,13 +361,13 @@
|
|||
let newEnd = resizeOriginalEnd;
|
||||
|
||||
if (resizeEdge === 'bottom') {
|
||||
const newEndMinutes = Math.max(originalStartMinutes + 15, Math.min(24 * 60, currentMinutes));
|
||||
const newEndMinutes = Math.max(originalStartMinutes + 15, Math.min(lastVisibleHour * 60, currentMinutes));
|
||||
const newHours = Math.floor(newEndMinutes / 60);
|
||||
const newMins = newEndMinutes % 60;
|
||||
newEnd = setHours(new Date(resizeOriginalEnd), newHours);
|
||||
newEnd = setMinutes(newEnd, newMins);
|
||||
} else {
|
||||
const newStartMinutes = Math.max(0, Math.min(originalEndMinutes - 15, currentMinutes));
|
||||
const newStartMinutes = Math.max(firstVisibleHour * 60, Math.min(originalEndMinutes - 15, currentMinutes));
|
||||
const newHours = Math.floor(newStartMinutes / 60);
|
||||
const newMins = newStartMinutes % 60;
|
||||
newStart = setHours(new Date(resizeOriginalStart), newHours);
|
||||
|
|
@ -349,28 +385,56 @@
|
|||
resizeEvent = null;
|
||||
resizeOriginalStart = null;
|
||||
resizeOriginalEnd = null;
|
||||
hasMoved = false;
|
||||
}
|
||||
|
||||
// ========== Keyboard Handling ==========
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' && (isDragging || isResizing)) {
|
||||
e.preventDefault();
|
||||
document.removeEventListener('pointermove', handleDragMove);
|
||||
document.removeEventListener('pointerup', handleDragEnd);
|
||||
document.removeEventListener('pointermove', handleResizeMove);
|
||||
document.removeEventListener('pointerup', handleResizeEnd);
|
||||
isDragging = false;
|
||||
draggedEvent = null;
|
||||
dragTargetDay = null;
|
||||
isResizing = false;
|
||||
resizeEvent = null;
|
||||
resizeOriginalStart = null;
|
||||
resizeOriginalEnd = null;
|
||||
hasMoved = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="multi-day-view" class:compact={columnClass === 'compact'} class:very-compact={columnClass === 'very-compact'}>
|
||||
<!-- All-day events row -->
|
||||
<div class="all-day-row">
|
||||
<div class="time-gutter"></div>
|
||||
{#each days as day}
|
||||
<div class="all-day-cell">
|
||||
{#each getAllDayEventsForDay(day) as event}
|
||||
<button
|
||||
class="all-day-event"
|
||||
style="background-color: {calendarsStore.getColor(event.calendarId)}"
|
||||
onclick={(e) => handleEventClick(event, e)}
|
||||
title={event.title}
|
||||
>
|
||||
{event.title}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<!-- All-day events row (only shown when there are header-mode all-day events) -->
|
||||
{#if hasAnyHeaderAllDayEvents}
|
||||
<div class="all-day-row">
|
||||
<div class="time-gutter"></div>
|
||||
{#each days as day}
|
||||
<div class="all-day-cell">
|
||||
{#each getHeaderAllDayEventsForDay(day) as event}
|
||||
<button
|
||||
class="all-day-event"
|
||||
style="background-color: {calendarsStore.getColor(event.calendarId)}"
|
||||
onclick={(e) => handleEventClick(event, e)}
|
||||
title={event.title}
|
||||
>
|
||||
{event.title}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Day headers -->
|
||||
<div class="day-headers">
|
||||
|
|
@ -406,7 +470,19 @@
|
|||
></button>
|
||||
{/each}
|
||||
|
||||
<!-- Events -->
|
||||
<!-- Block-style all-day events -->
|
||||
{#each getBlockAllDayEventsForDay(day) as event (event.id)}
|
||||
<button
|
||||
class="all-day-block-event"
|
||||
style="background-color: {calendarsStore.getColor(event.calendarId)}"
|
||||
onclick={(e) => handleEventClick(event, e)}
|
||||
title={event.title}
|
||||
>
|
||||
<span class="event-title">{event.title}</span>
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
<!-- Timed events -->
|
||||
{#each getEventsForDay(day) as event (event.id)}
|
||||
{@const isBeingDragged = isDragging && draggedEvent?.id === event.id}
|
||||
{@const isBeingResized = isResizing && resizeEvent?.id === event.id}
|
||||
|
|
@ -480,8 +556,6 @@
|
|||
.multi-day-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
|
||||
}
|
||||
|
||||
.all-day-row {
|
||||
|
|
@ -519,6 +593,50 @@
|
|||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
/* Block-style all-day events (displayed as full-day blocks in the grid) */
|
||||
.all-day-block-event {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 2px;
|
||||
right: 2px;
|
||||
bottom: 0;
|
||||
padding: 4px 6px;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
z-index: 0;
|
||||
opacity: 0.3;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.compact .all-day-block-event,
|
||||
.very-compact .all-day-block-event {
|
||||
left: 1px;
|
||||
right: 1px;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
|
||||
.all-day-block-event:hover {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.all-day-block-event .event-title {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.compact .all-day-block-event .event-title,
|
||||
.very-compact .all-day-block-event .event-title {
|
||||
font-size: 0.6rem;
|
||||
}
|
||||
|
||||
.day-headers {
|
||||
display: flex;
|
||||
border-bottom: 1px solid hsl(var(--color-border));
|
||||
|
|
@ -589,7 +707,6 @@
|
|||
.time-grid {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
|
||||
}
|
||||
|
||||
.time-column {
|
||||
|
|
@ -672,8 +789,11 @@
|
|||
}
|
||||
|
||||
.event-card.resizing {
|
||||
opacity: 0.9;
|
||||
opacity: 0.85;
|
||||
z-index: 100;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
|
||||
outline: 2px dashed rgba(255, 255, 255, 0.6);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.event-card.drag-ghost {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
import { calendarsStore } from '$lib/stores/calendars.svelte';
|
||||
import { settingsStore } from '$lib/stores/settings.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import QuickEventOverlay from '$lib/components/event/QuickEventOverlay.svelte';
|
||||
import {
|
||||
format,
|
||||
eachDayOfInterval,
|
||||
|
|
@ -46,15 +47,18 @@
|
|||
getWeek(viewStore.viewRange.start, { weekStartsOn: settingsStore.weekStartsOn })
|
||||
);
|
||||
|
||||
// Generate hours (0-23 or 7-23 depending on setting)
|
||||
// Generate hours (filtered based on settings)
|
||||
let allHours = Array.from({ length: 24 }, (_, i) => i);
|
||||
let hours = $derived(
|
||||
settingsStore.hideEarlyHours ? allHours.filter((h) => h >= 7) : allHours
|
||||
settingsStore.filterHoursEnabled
|
||||
? allHours.filter((h) => h >= settingsStore.dayStartHour && h < settingsStore.dayEndHour)
|
||||
: allHours
|
||||
);
|
||||
|
||||
// Calculate visible hours range for positioning
|
||||
let firstVisibleHour = $derived(settingsStore.hideEarlyHours ? 7 : 0);
|
||||
let totalVisibleHours = $derived(24 - firstVisibleHour);
|
||||
let firstVisibleHour = $derived(settingsStore.filterHoursEnabled ? settingsStore.dayStartHour : 0);
|
||||
let lastVisibleHour = $derived(settingsStore.filterHoursEnabled ? settingsStore.dayEndHour : 24);
|
||||
let totalVisibleHours = $derived(lastVisibleHour - firstVisibleHour);
|
||||
|
||||
// Helper to convert minutes to percentage position (accounting for hidden hours)
|
||||
function minutesToPercent(minutes: number): number {
|
||||
|
|
@ -94,6 +98,14 @@
|
|||
let resizePreviewTop = $state(0);
|
||||
let resizePreviewHeight = $state(0);
|
||||
|
||||
// Track if we actually moved during drag/resize (to prevent click on simple mousedown/up)
|
||||
let hasMoved = $state(false);
|
||||
|
||||
// Quick Event Overlay State
|
||||
let showQuickEvent = $state(false);
|
||||
let quickEventStartTime = $state<Date | null>(null);
|
||||
let quickEventPosition = $state({ x: 0, y: 0 });
|
||||
|
||||
// Reference to the days container for position calculations
|
||||
let daysContainerEl: HTMLDivElement;
|
||||
|
||||
|
|
@ -105,6 +117,25 @@
|
|||
return eventsStore.getEventsForDay(day).filter((e) => e.isAllDay);
|
||||
}
|
||||
|
||||
// Get display mode for an event (per-event override takes precedence over global setting)
|
||||
function getEventDisplayMode(event: any): 'header' | 'block' {
|
||||
return event.metadata?.allDayDisplayMode || settingsStore.allDayDisplayMode;
|
||||
}
|
||||
|
||||
// Split all-day events by display mode
|
||||
function getHeaderAllDayEventsForDay(day: Date) {
|
||||
return getAllDayEventsForDay(day).filter(e => getEventDisplayMode(e) === 'header');
|
||||
}
|
||||
|
||||
function getBlockAllDayEventsForDay(day: Date) {
|
||||
return getAllDayEventsForDay(day).filter(e => getEventDisplayMode(e) === 'block');
|
||||
}
|
||||
|
||||
// Check if there are any all-day events to show in header
|
||||
let hasAnyHeaderAllDayEvents = $derived(
|
||||
days.some(day => getHeaderAllDayEventsForDay(day).length > 0)
|
||||
);
|
||||
|
||||
function getEventStyle(event: any) {
|
||||
const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
|
||||
const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime;
|
||||
|
|
@ -126,22 +157,33 @@
|
|||
}
|
||||
|
||||
function handleEventClick(event: any, e: MouseEvent) {
|
||||
// Don't navigate if we just finished dragging or resizing
|
||||
if (isDragging || isResizing) {
|
||||
// Don't navigate if we just finished dragging or resizing, or if we moved
|
||||
if (isDragging || isResizing || hasMoved) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// Reset hasMoved after a short delay to allow for the next clean click
|
||||
setTimeout(() => { hasMoved = false; }, 100);
|
||||
return;
|
||||
}
|
||||
goto(`/event/${event.id}`);
|
||||
}
|
||||
|
||||
function handleSlotClick(day: Date, hour: number) {
|
||||
function handleSlotClick(day: Date, hour: number, e: MouseEvent) {
|
||||
// Don't create new event if dragging
|
||||
if (isDragging || isResizing) return;
|
||||
|
||||
const startTime = new Date(day);
|
||||
startTime.setHours(hour, 0, 0, 0);
|
||||
goto(`/event/new?start=${startTime.toISOString()}`);
|
||||
|
||||
// Show quick event overlay at click position
|
||||
quickEventStartTime = startTime;
|
||||
quickEventPosition = { x: e.clientX, y: e.clientY };
|
||||
showQuickEvent = true;
|
||||
}
|
||||
|
||||
function closeQuickEvent() {
|
||||
showQuickEvent = false;
|
||||
quickEventStartTime = null;
|
||||
}
|
||||
|
||||
// ========== Drag & Drop Functions ==========
|
||||
|
|
@ -180,6 +222,7 @@
|
|||
|
||||
isDragging = true;
|
||||
draggedEvent = event;
|
||||
hasMoved = false;
|
||||
|
||||
const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
|
||||
const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime;
|
||||
|
|
@ -202,12 +245,14 @@
|
|||
function handleDragMove(e: PointerEvent) {
|
||||
if (!isDragging || !draggedEvent) return;
|
||||
|
||||
hasMoved = true;
|
||||
|
||||
// Calculate new position
|
||||
const newDay = getDayFromX(e.clientX);
|
||||
const newMinutes = getMinutesFromY(e.clientY) - dragOffsetMinutes;
|
||||
|
||||
// Clamp to valid range (firstVisibleHour to 23:45)
|
||||
const clampedMinutes = Math.max(firstVisibleHour * 60, Math.min(24 * 60 - 15, newMinutes));
|
||||
// Clamp to valid range (firstVisibleHour to lastVisibleHour)
|
||||
const clampedMinutes = Math.max(firstVisibleHour * 60, Math.min(lastVisibleHour * 60 - 15, newMinutes));
|
||||
|
||||
// Update preview
|
||||
dragPreviewTop = minutesToPercent(clampedMinutes);
|
||||
|
|
@ -220,9 +265,10 @@
|
|||
document.removeEventListener('pointermove', handleDragMove);
|
||||
document.removeEventListener('pointerup', handleDragEnd);
|
||||
|
||||
if (!isDragging || !draggedEvent || !dragTargetDay) {
|
||||
if (!isDragging || !draggedEvent || !dragTargetDay || !hasMoved) {
|
||||
isDragging = false;
|
||||
draggedEvent = null;
|
||||
hasMoved = false;
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -252,6 +298,7 @@
|
|||
isDragging = false;
|
||||
draggedEvent = null;
|
||||
dragTargetDay = null;
|
||||
hasMoved = false;
|
||||
}
|
||||
|
||||
// ========== Resize Functions ==========
|
||||
|
|
@ -263,6 +310,7 @@
|
|||
isResizing = true;
|
||||
resizeEvent = event;
|
||||
resizeEdge = edge;
|
||||
hasMoved = false;
|
||||
|
||||
const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
|
||||
const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime;
|
||||
|
|
@ -283,13 +331,14 @@
|
|||
function handleResizeMove(e: PointerEvent) {
|
||||
if (!isResizing || !resizeEvent || !resizeOriginalStart || !resizeOriginalEnd) return;
|
||||
|
||||
hasMoved = true;
|
||||
const currentMinutes = getMinutesFromY(e.clientY);
|
||||
const originalStartMinutes = resizeOriginalStart.getHours() * 60 + resizeOriginalStart.getMinutes();
|
||||
const originalEndMinutes = resizeOriginalEnd.getHours() * 60 + resizeOriginalEnd.getMinutes();
|
||||
|
||||
if (resizeEdge === 'bottom') {
|
||||
// Resize from bottom - change end time
|
||||
const newEndMinutes = Math.max(originalStartMinutes + 15, Math.min(24 * 60, currentMinutes));
|
||||
const newEndMinutes = Math.max(originalStartMinutes + 15, Math.min(lastVisibleHour * 60, currentMinutes));
|
||||
const newDuration = newEndMinutes - originalStartMinutes;
|
||||
resizePreviewHeight = (newDuration / (totalVisibleHours * 60)) * 100;
|
||||
} else {
|
||||
|
|
@ -305,9 +354,12 @@
|
|||
document.removeEventListener('pointermove', handleResizeMove);
|
||||
document.removeEventListener('pointerup', handleResizeEnd);
|
||||
|
||||
if (!isResizing || !resizeEvent || !resizeOriginalStart || !resizeOriginalEnd) {
|
||||
if (!isResizing || !resizeEvent || !resizeOriginalStart || !resizeOriginalEnd || !hasMoved) {
|
||||
isResizing = false;
|
||||
resizeEvent = null;
|
||||
resizeOriginalStart = null;
|
||||
resizeOriginalEnd = null;
|
||||
hasMoved = false;
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -319,13 +371,13 @@
|
|||
let newEnd = resizeOriginalEnd;
|
||||
|
||||
if (resizeEdge === 'bottom') {
|
||||
const newEndMinutes = Math.max(originalStartMinutes + 15, Math.min(24 * 60, currentMinutes));
|
||||
const newEndMinutes = Math.max(originalStartMinutes + 15, Math.min(lastVisibleHour * 60, currentMinutes));
|
||||
const newHours = Math.floor(newEndMinutes / 60);
|
||||
const newMins = newEndMinutes % 60;
|
||||
newEnd = setHours(new Date(resizeOriginalEnd), newHours);
|
||||
newEnd = setMinutes(newEnd, newMins);
|
||||
} else {
|
||||
const newStartMinutes = Math.max(0, Math.min(originalEndMinutes - 15, currentMinutes));
|
||||
const newStartMinutes = Math.max(firstVisibleHour * 60, Math.min(originalEndMinutes - 15, currentMinutes));
|
||||
const newHours = Math.floor(newStartMinutes / 60);
|
||||
const newMins = newStartMinutes % 60;
|
||||
newStart = setHours(new Date(resizeOriginalStart), newHours);
|
||||
|
|
@ -343,7 +395,37 @@
|
|||
resizeEvent = null;
|
||||
resizeOriginalStart = null;
|
||||
resizeOriginalEnd = null;
|
||||
hasMoved = false;
|
||||
}
|
||||
|
||||
// ========== Keyboard Handling ==========
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
// Cancel drag/resize on Escape
|
||||
if (e.key === 'Escape') {
|
||||
if (isDragging || isResizing) {
|
||||
e.preventDefault();
|
||||
document.removeEventListener('pointermove', handleDragMove);
|
||||
document.removeEventListener('pointerup', handleDragEnd);
|
||||
document.removeEventListener('pointermove', handleResizeMove);
|
||||
document.removeEventListener('pointerup', handleResizeEnd);
|
||||
isDragging = false;
|
||||
draggedEvent = null;
|
||||
dragTargetDay = null;
|
||||
isResizing = false;
|
||||
resizeEvent = null;
|
||||
resizeOriginalStart = null;
|
||||
resizeOriginalEnd = null;
|
||||
hasMoved = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add global keydown listener
|
||||
$effect(() => {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="week-view">
|
||||
|
|
@ -354,27 +436,29 @@
|
|||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- All-day events row -->
|
||||
<div class="all-day-row">
|
||||
<div class="time-gutter">
|
||||
{#if settingsStore.showWeekNumbers}
|
||||
<span class="week-label">KW {weekNumber}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#each days as day}
|
||||
<div class="all-day-cell">
|
||||
{#each getAllDayEventsForDay(day) as event}
|
||||
<button
|
||||
class="all-day-event"
|
||||
style="background-color: {calendarsStore.getColor(event.calendarId)}"
|
||||
onclick={() => goto(`/event/${event.id}`)}
|
||||
>
|
||||
{event.title}
|
||||
</button>
|
||||
{/each}
|
||||
<!-- All-day events row (only shown when there are header-mode all-day events) -->
|
||||
{#if hasAnyHeaderAllDayEvents}
|
||||
<div class="all-day-row">
|
||||
<div class="time-gutter">
|
||||
{#if settingsStore.showWeekNumbers}
|
||||
<span class="week-label">KW {weekNumber}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#each days as day}
|
||||
<div class="all-day-cell">
|
||||
{#each getHeaderAllDayEventsForDay(day) as event}
|
||||
<button
|
||||
class="all-day-event"
|
||||
style="background-color: {calendarsStore.getColor(event.calendarId)}"
|
||||
onclick={() => goto(`/event/${event.id}`)}
|
||||
>
|
||||
{event.title}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Day headers -->
|
||||
<div class="day-headers">
|
||||
|
|
@ -405,12 +489,23 @@
|
|||
{#each hours as hour}
|
||||
<button
|
||||
class="hour-slot"
|
||||
onclick={() => handleSlotClick(day, hour)}
|
||||
onclick={(e) => handleSlotClick(day, hour, e)}
|
||||
aria-label={`${format(day, 'EEEE', { locale: currentDateLocale })} ${settingsStore.formatHour(hour)}`}
|
||||
></button>
|
||||
{/each}
|
||||
|
||||
<!-- Events -->
|
||||
<!-- Block-style all-day events -->
|
||||
{#each getBlockAllDayEventsForDay(day) as event (event.id)}
|
||||
<button
|
||||
class="all-day-block-event"
|
||||
style="background-color: {calendarsStore.getColor(event.calendarId)}"
|
||||
onclick={() => goto(`/event/${event.id}`)}
|
||||
>
|
||||
<span class="event-title">{event.title}</span>
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
<!-- Timed events -->
|
||||
{#each getEventsForDay(day) as event (event.id)}
|
||||
{@const isBeingDragged = isDragging && draggedEvent?.id === event.id}
|
||||
{@const isBeingResized = isResizing && resizeEvent?.id === event.id}
|
||||
|
|
@ -479,8 +574,6 @@
|
|||
.week-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
|
||||
}
|
||||
|
||||
.week-number-indicator {
|
||||
|
|
@ -514,6 +607,38 @@
|
|||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Block-style all-day events (displayed as full-day blocks in the grid) */
|
||||
.all-day-block-event {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 2px;
|
||||
right: 2px;
|
||||
bottom: 0;
|
||||
padding: 4px 6px;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
z-index: 0;
|
||||
opacity: 0.3;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.all-day-block-event:hover {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.all-day-block-event .event-title {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.day-headers {
|
||||
display: flex;
|
||||
border-bottom: 1px solid hsl(var(--color-border));
|
||||
|
|
@ -568,8 +693,6 @@
|
|||
.time-grid {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
|
||||
|
||||
}
|
||||
|
||||
.time-column {
|
||||
|
|
@ -645,8 +768,11 @@
|
|||
}
|
||||
|
||||
.event-card.resizing {
|
||||
opacity: 0.9;
|
||||
opacity: 0.85;
|
||||
z-index: 100;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
|
||||
outline: 2px dashed rgba(255, 255, 255, 0.6);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.event-card.drag-ghost {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue