feat: create event with click and drag

Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
2026-07-07 21:36:50 -04:00
parent 019b0b6367
commit 992825c291
4 changed files with 168 additions and 19 deletions
+2
View File
@@ -22,6 +22,7 @@ const props = defineProps<{
const emit = defineEmits<{ const emit = defineEmits<{
'event-click': [event: EntityObject]; 'event-click': [event: EntityObject];
'date-click': [date: Date]; 'date-click': [date: Date];
'date-range-select': [range: { start: Date; end: Date }];
'update:current-date': [date: Date]; 'update:current-date': [date: Date];
'update:days-span': [span: DaysViewSpan]; 'update:days-span': [span: DaysViewSpan];
'update:agenda-span': [span: AgendaViewSpan]; 'update:agenda-span': [span: AgendaViewSpan];
@@ -79,6 +80,7 @@ function hidePopup() {
:initial-span="initialDaysSpan" :initial-span="initialDaysSpan"
@event-click="$emit('event-click', $event)" @event-click="$emit('event-click', $event)"
@date-click="$emit('date-click', $event)" @date-click="$emit('date-click', $event)"
@date-range-select="$emit('date-range-select', $event)"
@event-hover="handleEventHover" @event-hover="handleEventHover"
@event-hover-end="hidePopup" @event-hover-end="hidePopup"
@update:span="$emit('update:days-span', $event)" @update:span="$emit('update:days-span', $event)"
+161 -15
View File
@@ -8,7 +8,7 @@ import type { CollectionObject } from '@ChronoManager/models/collection';
import { useChronoInstancesStore } from '@/stores/chronoInstancesStore'; import { useChronoInstancesStore } from '@/stores/chronoInstancesStore';
import type { CalendarInstance } from '@/types/instance'; import type { CalendarInstance } from '@/types/instance';
import ViewControls from './ViewControls.vue'; import ViewControls from './ViewControls.vue';
import { formatEpochTime, formatHour, formatWeekDay } from '@/utils/format'; import { formatEpochTime, formatHour, formatTime, formatWeekDay } from '@/utils/format';
const ALL_DAY_EVENT_HEIGHT = 24; const ALL_DAY_EVENT_HEIGHT = 24;
@@ -21,6 +21,8 @@ const props = defineProps<{
const instancesStore = useChronoInstancesStore(); const instancesStore = useChronoInstancesStore();
const selectedSpan = ref<DaysViewSpan>(props.initialSpan ?? '7d'); const selectedSpan = ref<DaysViewSpan>(props.initialSpan ?? '7d');
const hoveredTime = ref<{ day: string; minutes: number } | null>(null);
const timeSelection = ref<{ day: string; anchor: number; current: number } | null>(null);
const daysCount = computed(() => spanToDays(selectedSpan.value)); const daysCount = computed(() => spanToDays(selectedSpan.value));
const emit = defineEmits<{ const emit = defineEmits<{
@@ -28,6 +30,7 @@ const emit = defineEmits<{
'event-hover': [data: { event: MouseEvent; entity: EntityObject }]; 'event-hover': [data: { event: MouseEvent; entity: EntityObject }];
'event-hover-end': []; 'event-hover-end': [];
'date-click': [date: Date]; 'date-click': [date: Date];
'date-range-select': [range: { start: Date; end: Date }];
'update:span': [span: DaysViewSpan]; 'update:span': [span: DaysViewSpan];
'update:current-date': [date: Date]; 'update:current-date': [date: Date];
}>(); }>();
@@ -113,22 +116,99 @@ function getEventStyle(instance: CalendarInstance) {
}; };
} }
function handleDayClick(event: MouseEvent, day: Date) { function pointerMinutes(event: MouseEvent, allowEndOfDay = false): number {
const target = event.currentTarget as HTMLElement; const target = event.currentTarget as HTMLElement;
const rect = target.getBoundingClientRect(); const rect = target.getBoundingClientRect();
const clickY = event.clientY - rect.top; const pointerY = Math.min(Math.max(event.clientY - rect.top, 0), rect.height);
const minutes = Math.round(((pointerY / rect.height) * 1440) / 15) * 15;
// Calculate the hour based on click position (60px per hour)
const totalMinutes = Math.floor((clickY / 60) * 60); return Math.min(minutes, allowEndOfDay ? 1440 : 1425);
const hours = Math.floor(totalMinutes / 60);
const minutes = Math.round((totalMinutes % 60) / 15) * 15; // Round to nearest 15 min
// Create a new date with the clicked time
const clickedDate = new Date(day);
clickedDate.setHours(hours, minutes, 0, 0);
emit('date-click', clickedDate);
} }
function handleHover(event: MouseEvent, day: Date) {
hoveredTime.value = {
day: day.toISOString(),
minutes: pointerMinutes(event),
};
}
function formatHoverTime(totalMinutes: number): string {
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
const date = new Date(2000, 0, 1, hours, minutes);
return formatTime(date);
}
function dateAtMinutes(day: Date, totalMinutes: number): Date {
const date = new Date(day);
date.setHours(0, totalMinutes, 0, 0);
return date;
}
function selectionBounds(selection: NonNullable<typeof timeSelection.value>) {
const start = Math.min(selection.anchor, selection.current);
const end = selection.anchor === selection.current
? Math.min(start + 15, 1440)
: Math.max(selection.anchor, selection.current);
return { start, end };
}
function handlePointerDown(event: PointerEvent, day: Date) {
if (event.pointerType !== 'mouse' || event.button !== 0) return;
event.preventDefault();
const minutes = pointerMinutes(event);
hoveredTime.value = null;
timeSelection.value = {
day: day.toISOString(),
anchor: minutes,
current: minutes,
};
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
}
function handlePointerUp(event: PointerEvent, day: Date) {
const selection = timeSelection.value;
if (!selection || selection.day !== day.toISOString()) return;
selection.current = pointerMinutes(event, true);
const { start, end } = selectionBounds(selection);
timeSelection.value = null;
if (selection.anchor === selection.current) {
emit('date-click', dateAtMinutes(day, start));
return;
}
emit('date-range-select', {
start: dateAtMinutes(day, start),
end: dateAtMinutes(day, end),
});
}
function handlePointerMove(event: PointerEvent, day: Date) {
if (timeSelection.value?.day === day.toISOString()) {
timeSelection.value.current = pointerMinutes(event, true);
return;
}
handleHover(event, day);
}
function selectionStyle(selection: NonNullable<typeof timeSelection.value>) {
const { start, end } = selectionBounds(selection);
return {
top: `${start / 1440 * 100}%`,
height: `${(end - start) / 1440 * 100}%`,
};
}
function selectionLabel(selection: NonNullable<typeof timeSelection.value>): string {
const { start, end } = selectionBounds(selection);
return `${formatHoverTime(start)} ${formatHoverTime(end)} · ${end - start} min`;
}
</script> </script>
<template> <template>
@@ -191,12 +271,34 @@ function handleDayClick(event: MouseEvent, day: Date) {
</div> </div>
<div class="days-columns"> <div class="days-columns">
<div v-for="day in visibleDates" :key="day.toISOString()" class="day-column" :class="{ 'single-day': daysCount === 1 }"> <div v-for="day in visibleDates" :key="day.toISOString()" class="day-column" :class="{ 'single-day': daysCount === 1 }">
<div class="day-events" @click="handleDayClick($event, day)"> <div
class="day-events"
@pointerdown="handlePointerDown($event, day)"
@pointermove="handlePointerMove($event, day)"
@pointerup="handlePointerUp($event, day)"
@pointercancel="timeSelection = null"
@mouseleave="hoveredTime = null"
>
<div
v-if="hoveredTime?.day === day.toISOString()"
class="hover-time-indicator"
:style="{ top: `${hoveredTime.minutes / 1440 * 100}%` }"
>
<span>{{ formatHoverTime(hoveredTime.minutes) }}</span>
</div>
<div
v-if="timeSelection?.day === day.toISOString()"
class="time-selection-preview"
:style="selectionStyle(timeSelection)"
>
<span>{{ selectionLabel(timeSelection) }}</span>
</div>
<div <div
v-for="instance in getTimedEvents(day)" v-for="instance in getTimedEvents(day)"
:key="instance.key" :key="instance.key"
class="day-event" class="day-event"
:style="getEventStyle(instance)" :style="getEventStyle(instance)"
@pointerdown.stop
@click.stop="emit('event-click', instance.entity)" @click.stop="emit('event-click', instance.entity)"
@mouseenter="emit('event-hover', { event: $event, entity: instance.entity })" @mouseenter="emit('event-hover', { event: $event, entity: instance.entity })"
@mouseleave="emit('event-hover-end')" @mouseleave="emit('event-hover-end')"
@@ -461,6 +563,50 @@ function handleDayClick(event: MouseEvent, day: Date) {
background-color: rgba(var(--v-theme-primary), 0.02); background-color: rgba(var(--v-theme-primary), 0.02);
} }
.hover-time-indicator {
position: absolute;
left: 0;
right: 0;
z-index: 3;
height: 1px;
background-color: rgb(var(--v-theme-primary));
pointer-events: none;
}
.hover-time-indicator span {
position: absolute;
top: 0;
left: 4px;
padding: 1px 4px;
border-radius: 3px;
background-color: rgb(var(--v-theme-primary));
color: rgb(var(--v-theme-on-primary));
font-size: 10px;
line-height: 14px;
transform: translateY(-50%);
}
.time-selection-preview {
position: absolute;
left: 3px;
right: 3px;
z-index: 4;
min-height: 15px;
padding: 3px 5px;
border: 1px solid rgb(var(--v-theme-primary));
border-radius: 4px;
background-color: rgba(var(--v-theme-primary), 0.2);
color: rgb(var(--v-theme-primary));
pointer-events: none;
overflow: hidden;
}
.time-selection-preview span {
font-size: 10px;
font-weight: 600;
white-space: nowrap;
}
.day-event { .day-event {
position: absolute; position: absolute;
left: 4px; left: 4px;
+1
View File
@@ -279,6 +279,7 @@ function selectSectionFromRoute() {
:initial-agenda-view-span="agendaViewSpan" :initial-agenda-view-span="agendaViewSpan"
@event-click="editEvent" @event-click="editEvent"
@date-click="createEventFromDate" @date-click="createEventFromDate"
@date-range-select="createEventFromDate($event.start, $event.end)"
@update:current-date="selectDate" @update:current-date="selectDate"
@update:days-span="handleCalendarDaysViewSpanChange" @update:days-span="handleCalendarDaysViewSpanChange"
@update:agenda-span="handleCalendarAgendaViewSpanChange" @update:agenda-span="handleCalendarAgendaViewSpanChange"
+4 -4
View File
@@ -61,12 +61,12 @@ export const useChronoUiStore = defineStore('chronoUiStore', () => {
showTaskEditor.value = type === 'task' showTaskEditor.value = type === 'task'
} }
function createEventFromDate(date?: Date) { function createEventFromDate(start?: Date, end?: Date) {
const entity = new EntityObject() const entity = new EntityObject()
entity.properties = new EventObject() entity.properties = new EventObject()
if (date) { if (start) {
entity.properties.startsOn = date.toISOString() entity.properties.startsOn = start.toISOString()
entity.properties.endsOn = new Date(date.getTime() + 60 * 60 * 1000).toISOString() entity.properties.endsOn = (end ?? new Date(start.getTime() + 60 * 60 * 1000)).toISOString()
} }
openEntityEditor(entity, 'event', 'edit') openEntityEditor(entity, 'event', 'edit')
} }