UNPKG

v-calendar

Version:

A calendar and date picker plugin for Vue.js.

955 lines (847 loc) 24.6 kB
import { type ExtractPropTypes, type PropType, ref, computed, provide, inject, watch, nextTick, } from 'vue'; import { propsDef as basePropsDef, emitsDef, createCalendar } from './calendar'; import type { CalendarDay } from '../utils/page'; import { createGuid, on } from '../utils/helpers'; import { type EventConfig, type Event, createEvent as _createEvent, } from '../utils/calendar/event'; import CalendarCellPopover from '../components/CalendarGrid/CalendarCellPopover.vue'; import { roundDate, MS_PER_HOUR } from '../utils/date/helpers'; import { DateRange, DateRangeContext } from '../utils/date/range'; type GridState = | 'NORMAL' | 'CREATE_MONITOR' | 'DRAG_MONITOR' | 'RESIZE_MONITOR'; export type GridStateEvent = | 'GRID_CURSOR_DOWN' | 'GRID_CURSOR_DOWN_SHIFT' | 'GRID_CURSOR_MOVE' | 'GRID_CURSOR_MOVE_SHIFT' | 'GRID_CURSOR_UP' | 'GRID_CURSOR_UP_SHIFT' | 'EVENT_CURSOR_DOWN' | 'EVENT_CURSOR_DOWN_SHIFT' | 'EVENT_CURSOR_MOVE' | 'EVENT_CURSOR_MOVE_SHIFT' | 'EVENT_RESIZE_START_CURSOR_DOWN' | 'EVENT_RESIZE_START_CURSOR_DOWN_SHIFT' | 'EVENT_RESIZE_END_CURSOR_DOWN' | 'EVENT_RESIZE_END_CURSOR_DOWN_SHIFT' | 'ESCAPE'; export interface Point { x: number; y: number; } export interface DragOffset { weekdays: number; weeks: number; ms: number; } export interface ResizeOffset { weekdays: number; weeks: number; ms: number; } export interface DragOriginState { position: number; date: Date; day: CalendarDay; event: Event; eventSelected: boolean; ms: number; } export interface ResizeOriginState { position: number; day: CalendarDay; event: Event; isWeekly: boolean; isStart: boolean; isNew: boolean; ms: number; } export interface CreateOriginState { position: number; date: Date; day: CalendarDay; isWeekly: boolean; } // #region Messages type MessageType = | 'event-create-begin' | 'event-create-end' | 'event-resize-begin' | 'event-resize-update' | 'event-resize-end' | 'event-move-begin' | 'event-move-update' | 'event-move-end' | 'event-remove'; class Messages { static _emit: Function; static EventCreateBegin(event: Event) { return new CancellableEventMessage(this._emit, 'event-create-begin', event); } static EventCreateEnd(event: Event) { return new EventMessage(this._emit, 'event-create-end', event); } static EventResizeBegin(event: Event) { return new CancellableEventMessage(this._emit, 'event-resize-begin', event); } static EventResizeUpdate(event: Event, offset: ResizeOffset) { return new EventResizeMessage( this._emit, 'event-resize-update', event, offset, ); } static EventResizeEnd(event: Event) { return new EventMessage(this._emit, 'event-resize-end', event); } static EventMoveBegin(event: Event) { return new CancellableEventMessage(this._emit, 'event-move-begin', event); } static EventMoveUpdate(event: Event, offset: DragOffset) { return new EventMoveMessage(this._emit, 'event-move-update', event, offset); } static EventMoveEnd(event: Event) { return new EventMessage(this._emit, 'event-move-end', event); } static EventRemove(event: Event) { return new CancellableEventMessage(this._emit, 'event-remove', event); } } class BaseMessage { private emit: Function; type: MessageType; constructor(emit: Function, type: MessageType) { this.emit = emit; this.type = type; } send() { this.emit(this.type, this); return this; } async sendAsync() { this.emit(this.type, this); await nextTick(); return this; } } class EventMessage<T extends Event | EventConfig> extends BaseMessage { event: T; constructor(emit: Function, type: MessageType, event: T) { super(emit, type); this.event = event; } } class CancellableEventMessage< T extends Event | EventConfig, > extends EventMessage<T> { cancel = false; } class EventResizeMessage extends CancellableEventMessage<Event> { offset?: ResizeOffset; constructor( emit: Function, type: MessageType, event: Event, offset?: ResizeOffset, ) { super(emit, type, event); this.offset = offset; } } class EventMoveMessage extends CancellableEventMessage<Event> { offset?: DragOffset; constructor( emit: Function, type: MessageType, event: Event, offset?: ResizeOffset, ) { super(emit, type, event); this.offset = offset; } } // #endregion Messages export const emits = [ ...emitsDef, 'day-header-click', 'event-create-begin', 'event-create-end', 'event-resize-begin', 'event-resize-update', 'event-resize-end', 'event-move-begin', 'event-move-update', 'event-move-end', 'event-remove', ]; const SNAP_MINUTES = 15; const PIXELS_PER_HOUR = 50; const contextKey = '__vc_grid_context__'; export const propsDef = { ...basePropsDef, events: { type: Object as PropType<EventConfig[]>, default: () => [], }, }; export type CalendarGridProps = Readonly<ExtractPropTypes<typeof propsDef>>; export type CalendarGridContext = ReturnType<typeof createCalendarGrid>; type IBoundingRect = Pick<Element, 'getBoundingClientRect' | 'contains'>; export function createCalendarGrid( props: CalendarGridProps, { emit, slots }: any, ) { const calendar = createCalendar(props, { emit, slots }); const cellPopoverRef = ref<typeof CalendarCellPopover>(); const dailyGridRef = ref<IBoundingRect | null>(null); const weeklyGridRef = ref<IBoundingRect | null>(null); let activeGridRef = ref<IBoundingRect | null>(null); Messages._emit = emit; const { view, isDaily, isMonthly, pages, locale, move, onDayFocusin } = calendar; const page = computed(() => pages.value[0]); const days = computed(() => page.value.viewDays); const weeks = computed(() => page.value.viewWeeks); const dayColumns = computed(() => { if (isDaily.value) return 1; return weeks.value[0].days.length; }); const dayRows = computed(() => { if (isMonthly.value) return weeks.value.length; return 1; }); const snapMinutes = ref(SNAP_MINUTES); const snapMs = computed(() => snapMinutes.value * 60 * 10000); const pixelsPerHour = ref(PIXELS_PER_HOUR); const state = ref<GridState>('NORMAL'); const fill = ref('light'); const eventsMap = ref<Record<any, Event>>({}); const events = computed(() => Object.values(eventsMap.value)); const detailEvent = ref<Event | null>(null); const createOrigin = ref<CreateOriginState | null>(null); const resizing = ref(false); let resizeOrigin: ResizeOriginState | null = null; const dragging = ref(false); let dragOrigin: DragOriginState | null = null; const isTouch = ref(false); const active = computed(() => resizing.value || dragging.value); const selectedEvents = computed(() => events.value.filter(e => e.selected)); const selectedEventsCount = computed(() => selectedEvents.value.length); const hasSelectedEvents = computed(() => selectedEventsCount.value > 0); const eventsContext = computed(() => { const ctx = new DateRangeContext(); events.value.forEach(evt => { ctx.render(evt, evt.range, days.value); }); return ctx; }); const gridStyle = computed(() => { return { height: `${24 * pixelsPerHour.value}px`, }; }); function getEventContext() { return { locale, days, dayRows, dayColumns, isDaily, isMonthly, snapMinutes: snapMinutes.value, pixelsPerHour: pixelsPerHour.value, }; } // #region Event details function showCellPopover(event: Event) { setTimeout(() => { if (isDaily.value || !cellPopoverRef.value) return; cellPopoverRef.value.show(event); }, 10); } function updateCellPopover(event: Event) { if (!cellPopoverRef.value) return; cellPopoverRef.value.update(event); } function hideCellPopover() { if (isDaily.value || !cellPopoverRef.value) return; cellPopoverRef.value.hide(); } function popoverVisible() { return !!cellPopoverRef.value && cellPopoverRef.value.isVisible(); } // #endregion Cell details // #region Util function createEventFromExisting(config: EventConfig, range?: DateRange) { const event = events.value.find(e => e.key === config.key); const ctx = getEventContext(); if (event != null) { const { selected } = event; return _createEvent( { ...config, selected, }, ctx, ); } return _createEvent(config, ctx); } function createNewEvent(date: Date, isWeekly: boolean) { const event = _createEvent( { key: createGuid(), start: date, end: date, allDay: isWeekly, }, getEventContext(), ); const msg = Messages.EventCreateBegin(event).send(); if (msg.cancel || !msg.event) return; eventsMap.value[event.key] = event; return event; } function removeEvent(event: Event) { const msg = Messages.EventRemove(event).send(); if (msg.cancel) return; delete eventsMap.value[event.key]; hideCellPopover(); } function getEventsFromProps() { return props.events.reduce((map, config) => { map[config.key] = map[config.key] || createEventFromExisting(config); return map; }, {} as Record<any, Event>); } function getMsFromPosition(position: number) { const hours = Math.max(Math.min(position / pixelsPerHour.value, 24), 0); return hours * MS_PER_HOUR; } function getDateFromPosition( position: number, day: CalendarDay, offsetMs = 0, snapMs = 0, ) { const startTime = day.startDate.getTime(); const ms = getMsFromPosition(position); const date = roundDate(startTime + ms + offsetMs, snapMs); return date; } const getPositionFromMouseEvent = ( gridEl: IBoundingRect | null, event: MouseEvent, ): Point => { if (gridEl == null) return { x: 0, y: 0 }; const rect = gridEl.getBoundingClientRect(); const x = event.clientX - rect.left; const y = event.clientY - rect.top; return { x, y }; }; const getPositionFromTouchEvent = ( gridEl: IBoundingRect | null, event: TouchEvent, ): Point => { if (gridEl == null) return { x: 0, y: 0 }; const rect = gridEl.getBoundingClientRect(); const touch = event.targetTouches[0] || event.changedTouches[0]; const x = touch.clientX - rect.left; const y = touch.clientY - rect.top; return { x, y }; }; const getPositionFromUIEvent = ( gridEl: IBoundingRect | null, event: UIEvent, ): Point => { if (event.type.startsWith('touch')) return getPositionFromTouchEvent(gridEl, event as TouchEvent); return getPositionFromMouseEvent(gridEl, event as MouseEvent); }; const getDayFromPosition = (el: IBoundingRect | null, { x, y }: any) => { if (el == null) return days.value[0]; const rect = el.getBoundingClientRect(); const dayWidth = rect.width / dayColumns.value; const dayHeight = rect.height / dayRows.value; const xNorm = Math.max(Math.min(x, rect.width), 0); const yNorm = Math.max(Math.min(y, rect.height), 0); const xIdx = Math.min(Math.floor(xNorm / dayWidth), dayColumns.value - 1); const yIdx = Math.min(Math.floor(yNorm / dayHeight), dayRows.value - 1); const idx = xIdx + yIdx * dayColumns.value; return days.value[idx]; }; // #endregion Util // #region Cell Operations function forSelectedEvents(fn: (event: Event) => void) { selectedEvents.value.forEach(e => fn(e)); } function deselectAllEvents() { forSelectedEvents(cell => (cell.selected = false)); } // #endregion Cell Operations // #region Resizing function startResizingEvents( position: number, day: CalendarDay, event: Event, isStart: boolean, isNew: boolean, ) { if (active.value) return; resizing.value = true; event.selected = true; const isWeekly = activeGridRef.value === weeklyGridRef.value; const ms = getMsFromPosition(position); resizeOrigin = { position, day, event, isWeekly, isStart, isNew, ms, }; forSelectedEvents(event => { const msg = Messages.EventResizeBegin(event).send(); if (msg.cancel) return; event.startResize(day, isStart); }); } function updateResizingEvents(position: number, day: CalendarDay) { if (!resizing.value || !resizeOrigin) return; const offset: ResizeOffset = { weeks: 0, weekdays: 0, ms: 0 }; if (resizeOrigin.isWeekly) { offset.weeks = day.weekPosition - resizeOrigin.day.weekPosition; offset.weekdays = day.weekdayPosition - resizeOrigin.day.weekdayPosition; } else { offset.ms = getMsFromPosition(position) - resizeOrigin.ms; } forSelectedEvents(event => { const msg = Messages.EventResizeUpdate(event, offset).send(); if (msg.cancel) return; event.updateResize(offset); }); } function stopResizingEvents() { if (!resizing.value || !resizeOrigin) return; forSelectedEvents(event => { Messages.EventResizeEnd(event); if (resizeOrigin!.isNew && event === resizeOrigin!.event) { Messages.EventCreateEnd(event).send(); showCellPopover(event); } event.stopResize(); }); resizing.value = false; resizeOrigin = null; } // #endregion Resizing // #region Dragging function startDraggingEvents( position: number, day: CalendarDay, event: Event, ) { if (active.value) return; dragging.value = true; const date = getDateFromPosition(position, day, 0, 0); const eventSelected = event.selected; event.selected = true; const ms = getMsFromPosition(position); dragOrigin = { position, date, day, event, eventSelected, ms, }; selectedEvents.value.forEach(event => { const msg = Messages.EventMoveBegin(event).send(); if (msg.cancel) return; event.startDrag(day); }); } function updateDraggingEvents(position: number, day: CalendarDay) { if (!dragging.value || !dragOrigin) return; const offset = { weeks: day.weekPosition - dragOrigin.day.weekPosition, weekdays: day.weekdayPosition - dragOrigin.day.weekdayPosition, ms: getMsFromPosition(position) - dragOrigin.ms, }; forSelectedEvents(event => { const msg = Messages.EventMoveUpdate(event, offset).send(); if (msg.cancel) return; event.updateDrag(offset); }); } function stopDraggingEvents() { if (!dragging.value || !dragOrigin) return; dragging.value = false; dragOrigin = null; forSelectedEvents(event => { Messages.EventMoveEnd(event).send(); event.stopDrag(); }); } // #endregion Dragging // #region Watchers function refreshEventsFromProps() { eventsMap.value = getEventsFromProps(); } watch( () => props.events, () => { refreshEventsFromProps(); }, { deep: true, }, ); watch([view], () => { deselectAllEvents(); }); // #endregion Watchers // #region State management function handleNormalEvent( gse: GridStateEvent, day: CalendarDay, position: number, evt: Event | undefined, ) { switch (gse) { case 'GRID_CURSOR_DOWN': case 'GRID_CURSOR_DOWN_SHIFT': { createOrigin.value = { isWeekly: activeGridRef.value === weeklyGridRef.value, date: getDateFromPosition(position, day), position, day, }; state.value = 'CREATE_MONITOR'; break; } case 'EVENT_CURSOR_DOWN': { if (!evt) return; if (!evt.selected) deselectAllEvents(); startDraggingEvents(position, day, evt); state.value = 'DRAG_MONITOR'; break; } case 'EVENT_CURSOR_DOWN_SHIFT': { if (!evt) return; startDraggingEvents(position, day, evt); state.value = 'DRAG_MONITOR'; break; } case 'EVENT_RESIZE_START_CURSOR_DOWN': { if (!evt) return; if (!evt.selected) deselectAllEvents(); startResizingEvents(position, day, evt, true, false); state.value = 'RESIZE_MONITOR'; break; } case 'EVENT_RESIZE_START_CURSOR_DOWN_SHIFT': { if (!evt) return; startResizingEvents(position, day, evt, true, false); state.value = 'RESIZE_MONITOR'; break; } case 'EVENT_RESIZE_END_CURSOR_DOWN': { if (!evt) return; if (!evt.selected) deselectAllEvents(); startResizingEvents(position, day, evt, false, false); state.value = 'RESIZE_MONITOR'; break; } case 'EVENT_RESIZE_END_CURSOR_DOWN_SHIFT': { if (!evt) return; startResizingEvents(position, day, evt, false, false); state.value = 'RESIZE_MONITOR'; break; } } } function handleCreateMonitorEvent(gse: GridStateEvent, day: CalendarDay) { if (!createOrigin.value) return; switch (gse) { case 'ESCAPE': { deselectAllEvents(); break; } case 'GRID_CURSOR_UP': case 'GRID_CURSOR_UP_SHIFT': { deselectAllEvents(); if (!popoverVisible()) { const { position, isWeekly } = createOrigin.value; const date = getDateFromPosition(position, day); const evt = createNewEvent(date, isWeekly); if (evt) { evt.selected = true; Messages.EventCreateEnd(evt).send(); showCellPopover(evt); } } state.value = 'NORMAL'; break; } case 'EVENT_CURSOR_MOVE': case 'EVENT_CURSOR_MOVE_SHIFT': case 'GRID_CURSOR_MOVE': case 'GRID_CURSOR_MOVE_SHIFT': { // if (isTouch.value || isMonthly.value) { // if (isTouch.value) { // state.value = 'NORMAL'; // return; // } deselectAllEvents(); const { position, isWeekly } = createOrigin.value; const date = getDateFromPosition(position, day); const evt = createNewEvent(date, isWeekly); if (evt) { startResizingEvents(position, day, evt, false, true); updateResizingEvents(position, day); state.value = 'RESIZE_MONITOR'; } break; } } } function handleResizeMonitorEvent( event: GridStateEvent, position: number, day: CalendarDay, ) { if (!resizeOrigin) return; switch (event) { case 'EVENT_CURSOR_MOVE': case 'EVENT_CURSOR_MOVE_SHIFT': case 'GRID_CURSOR_MOVE': case 'GRID_CURSOR_MOVE_SHIFT': { updateResizingEvents(position, day); if (!resizeOrigin.isNew) { updateCellPopover(resizeOrigin.event); } break; } case 'GRID_CURSOR_UP': { if (position === resizeOrigin.position) { deselectAllEvents(); resizeOrigin.event.selected = true; } stopResizingEvents(); state.value = 'NORMAL'; break; } case 'GRID_CURSOR_UP_SHIFT': { stopResizingEvents(); state.value = 'NORMAL'; break; } } } function handleDragMonitorEvent( event: GridStateEvent, day: CalendarDay, position: number, ) { if (!dragOrigin) return; switch (event) { case 'GRID_CURSOR_MOVE': case 'GRID_CURSOR_MOVE_SHIFT': { updateDraggingEvents(position, day); updateCellPopover(dragOrigin.event); break; } case 'GRID_CURSOR_UP': { const origin = dragOrigin; stopDraggingEvents(); if (position === origin.position) { deselectAllEvents(); origin.event.selected = true; showCellPopover(origin.event); } state.value = 'NORMAL'; break; } case 'GRID_CURSOR_UP_SHIFT': { stopDraggingEvents(); state.value = 'NORMAL'; break; } } } function updateState( gse: GridStateEvent, day: CalendarDay, position: number, evt: Event | undefined = undefined, ) { switch (state.value) { case 'NORMAL': { handleNormalEvent(gse, day, position, evt); break; } case 'CREATE_MONITOR': { handleCreateMonitorEvent(gse, day); break; } case 'RESIZE_MONITOR': { handleResizeMonitorEvent(gse, position, day); break; } case 'DRAG_MONITOR': { handleDragMonitorEvent(gse, day, position); break; } } } const setActiveGrid = (event: MouseEvent | TouchEvent) => { activeGridRef = [dailyGridRef, weeklyGridRef].find( ref => ref.value && ref.value.contains(event.currentTarget as Node), ) || ref(null); }; const handleEvent = ( stateEvent: GridStateEvent, event: MouseEvent | TouchEvent | KeyboardEvent, evt: Event | undefined = undefined, ) => { if (!activeGridRef.value) return; if (event.type.startsWith('touch')) { isTouch.value = true; } else if (isTouch.value) { return; } const eventName = ( event.shiftKey ? `${stateEvent}_SHIFT` : stateEvent ) as GridStateEvent; const position = getPositionFromUIEvent(activeGridRef.value, event); const day = getDayFromPosition(activeGridRef.value, position); updateState(eventName, day, position.y, evt); if (stateEvent === 'GRID_CURSOR_DOWN') { onDayFocusin(day, null); } }; const startMonitoringGridMove = () => { const offMove = on(window, 'mousemove', event => { handleEvent('GRID_CURSOR_MOVE', event as MouseEvent); }); const offUp = on(window, 'mouseup', event => { handleEvent('GRID_CURSOR_UP', event as MouseEvent); offMove(); offUp(); }); }; // #endregion State management refreshEventsFromProps(); const context = { ...calendar, dailyGridRef, weeklyGridRef, cellPopoverRef, dayColumns, dayRows, snapMinutes, snapMs, pixelsPerHour, isTouch, events, eventsMap, selectedEvents, hasSelectedEvents, eventsContext, detailEvent, resizing, dragging, gridStyle, fill, page, days, weeks, // Methods removeEvent, // Event handlers onDayNumberClick(day: CalendarDay) { emit('day-header-click', day); move(day, { view: 'daily' }); }, onGridEscapeKeydown() { updateState('ESCAPE', days.value[0], 0); }, // Mouse event handlers onGridMouseDown(event: MouseEvent) { setActiveGrid(event); handleEvent('GRID_CURSOR_DOWN', event); startMonitoringGridMove(); }, onEventMouseDown(event: MouseEvent, evt: Event) { setActiveGrid(event); handleEvent('EVENT_CURSOR_DOWN', event, evt); }, onEventResizeStartMouseDown(event: MouseEvent, evt: Event) { setActiveGrid(event); handleEvent('EVENT_RESIZE_START_CURSOR_DOWN', event, evt); }, onEventResizeEndMouseDown(event: MouseEvent, evt: Event) { setActiveGrid(event); handleEvent('EVENT_RESIZE_END_CURSOR_DOWN', event, evt); }, // Touch event handlers onGridTouchStart(event: TouchEvent) { setActiveGrid(event); handleEvent('GRID_CURSOR_DOWN', event); }, onGridTouchMove(event: TouchEvent) { handleEvent('GRID_CURSOR_MOVE', event); }, onGridTouchEnd(event: TouchEvent) { handleEvent('GRID_CURSOR_UP', event); }, onEventTouchStart(event: TouchEvent, evt: Event) { setActiveGrid(event); handleEvent('EVENT_CURSOR_DOWN', event, evt); }, onEventTouchMove(event: TouchEvent, evt: Event) { handleEvent('GRID_CURSOR_MOVE', event, evt); }, onEventTouchEnd(event: TouchEvent, evt: Event) { handleEvent('GRID_CURSOR_UP', event, evt); }, onEventResizeStartTouchStart(event: TouchEvent, evt: Event) { setActiveGrid(event); handleEvent('EVENT_RESIZE_START_CURSOR_DOWN', event, evt); }, onEventResizeEndTouchStart(event: TouchEvent, evt: Event) { setActiveGrid(event); handleEvent('EVENT_RESIZE_END_CURSOR_DOWN', event, evt); }, }; provide(contextKey, context); return context; } export function useCalendarGrid() { const context = inject<CalendarGridContext>(contextKey); if (!context) { throw new Error( 'Calendar context missing. Please verify this component is nested within a valid context provider.', ); } return context; }