feat: create event with click and drag
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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')
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user