import { Calendar, DateSelectArg, EventClickArg, EventDropArg, EventMountArg, EventSourceInput } from '@fullcalendar/core'; import { EventResizeDoneArg } from '@fullcalendar/interaction'; import dayGridPlugin from '@fullcalendar/daygrid'; import interactionPlugin from '@fullcalendar/interaction'; import multiMonthPlugin from '@fullcalendar/multimonth'; import timeGrid from '@fullcalendar/timegrid'; import FullCalendar from '@fullcalendar/react'; import { IReactionDisposer, action, computed, makeObservable, observable, reaction, untracked } from 'mobx'; import { observer } from 'mobx-react'; import * as React from 'react'; import { dateRangeStrToDates } from '../../../../ClientUtils'; import { Doc } from '../../../../fields/Doc'; import { Id } from '../../../../fields/FieldSymbols'; import { BoolCast, StrCast } from '../../../../fields/Types'; import { DocServer } from '../../../DocServer'; import { DragManager } from '../../../util/DragManager'; import { CollectionSubView, SubCollectionViewProps } from '../../collections/CollectionSubView'; import { ContextMenu } from '../../ContextMenu'; import { DocumentView } from '../DocumentView'; import { OpenWhere } from '../OpenWhere'; import './CalendarBox.scss'; import { DateField } from '../../../../fields/DateField'; import { undoable } from '../../../util/UndoManager'; import { DocumentType } from '../../../documents/DocumentTypes'; import { truncate } from 'fs/promises'; type CalendarView = 'multiMonth' | 'dayGridMonth' | 'timeGridWeek' | 'timeGridDay'; @observer export class CalendarBox extends CollectionSubView() { _calendarRef: FullCalendar | null = null; _calendar: Calendar | undefined; _observer: ResizeObserver | undefined; _eventsDisposer: IReactionDisposer | undefined; _selectDisposer: IReactionDisposer | undefined; _isMultiMonth: boolean | undefined; @observable _multiMonth = 0; constructor(props: SubCollectionViewProps) { super(props); makeObservable(this); } @computed get calTypeFieldKey() { return this.fieldKey + '_calendarType'; } componentDidMount(): void { this.Document.$calendar = ''; // needed only to make the keyvalue view look nice. this._props.setContentViewBox?.(this); this._eventsDisposer = reaction( () => ({ events: this.calendarEvents }), ({ events }) => this._calendar?.setOption('events', events), { fireImmediately: true } ); this._selectDisposer = reaction( () => ({ initialDate: this.dateSelect }), ({ initialDate }) => { const state = this._calendar?.getCurrentData(); state && this._calendar?.dispatch({ type: 'CHANGE_DATE', dateMarker: state.dateEnv.createMarker(initialDate.start), }); setTimeout(() => initialDate.start.toISOString() !== initialDate.end.toISOString() && this._calendar?.select(initialDate.start, initialDate.end)); }, { fireImmediately: true } ); } componentWillUnmount(): void { this._eventsDisposer?.(); this._selectDisposer?.(); } @computed get calendarEvents(): EventSourceInput | undefined { return this.childDocs.map(doc => { // const { start, end } = dateRangeStrToDates(StrCast(doc.$task_dateRange)); const isCompleted = BoolCast(doc.$task_completed); const rangeStr = StrCast(doc.$task_dateRange); const [startStr, endStr] = rangeStr.split('|'); let start: string | Date, end: string | Date; if (BoolCast(doc.$task_allDay)) { start = startStr; end = endStr; } else { ({ start, end } = dateRangeStrToDates(rangeStr)); } return { title: StrCast(doc.title), start, end, groupId: doc[Id], startEditable: true, endEditable: true, allDay: BoolCast(doc.$task_allDay), classNames: ['mother', isCompleted ? 'completed-task' : ''], // will determine the style editable: true, // subject to change in the future backgroundColor: this.eventToColor(doc), borderColor: this.eventToColor(doc), color: 'white', extendedProps: { description: StrCast(doc.description), }, }; }); } @computed get dateRangeStrDates() { return dateRangeStrToDates(StrCast(this.Document._calendar_dateRange)); } get dateSelect() { return dateRangeStrToDates(StrCast(this.Document._calendar_date)); } // Choose a calendar view based on the date range @computed get calendarViewType(): CalendarView { if (this.dataDoc[this.calTypeFieldKey]) return StrCast(this.dataDoc[this.calTypeFieldKey]) as CalendarView; if (this._isMultiMonth) return 'multiMonth'; const { start, end } = this.dateRangeStrDates; if (start.getFullYear() !== end.getFullYear() || start.getMonth() !== end.getMonth()) return 'multiMonth'; if (Math.abs(start.getDay() - end.getDay()) > 7) return 'dayGridMonth'; return 'timeGridWeek'; } // TODO: Return a different color based on the event type eventToColor = (event: Doc): string => { return StrCast(event.type) === DocumentType.TASK ? '#20B2AA' // teal for tasks : 'red'; }; // eslint-disable-next-line @typescript-eslint/no-unused-vars internalDocDrop = (e: Event, de: DragManager.DropEvent, docDragData: DragManager.DocumentDragData) => { if (!super.onInternalDrop(e, de)) return false; de.complete.docDragData?.droppedDocuments.forEach(doc => { const today = new Date().toISOString(); if (!doc.$task_dateRange) doc.$task_dateRange = `${today}|${today}`; }); return true; }; onInternalDrop = (e: Event, de: DragManager.DropEvent): boolean => { if (de.complete.docDragData?.droppedDocuments.length) return this.internalDocDrop(e, de, de.complete.docDragData); return false; }; handleEventDrop = undoable((arg: EventDropArg | EventResizeDoneArg) => { const doc = DocServer.GetCachedRefField(arg.event._def.groupId ?? ''); // doc && arg.event.start && (doc.$task_dateRange = arg.event.start?.toString() + '|' + (arg.event.end ?? arg.event.start).toString()); if (!doc || !arg.event.start) return; // get the new start and end dates const startDate = new Date(arg.event.start); const endDate = new Date(arg.event.end ?? arg.event.start); // update date range, time range, and all day status doc.$task_dateRange = `${startDate.toISOString()}|${endDate.toISOString()}`; const allDayStatus = arg.event.allDay ?? false; if (doc.$task_allDay !== allDayStatus) { doc.$task_allDay = allDayStatus; } if (doc.$task_allDay) { delete doc.$task_startTime; delete doc.$task_endTime; } else { doc.$task_startTime = new DateField(startDate); doc.$task_endTime = new DateField(endDate); } }, 'change event date'); handleEventClick = (arg: EventClickArg) => { const doc = DocServer.GetCachedRefField(arg.event._def.groupId ?? ''); if (doc) { DocumentView.showDocument(doc, { openLocation: OpenWhere.lightboxAlways }); arg.jsEvent.stopPropagation(); } }; handleEventContextMenu = (pageX: number, pageY: number, docid: string) => { const doc = DocServer.GetCachedRefField(docid ?? ''); if (doc) { const cm = ContextMenu.Instance; cm.addItem({ description: 'Show Metadata', event: () => this._props.addDocTab(doc, OpenWhere.addRightKeyvalue), icon: 'table-columns' }); cm.displayMenu(pageX - 15, pageY - 15, undefined, undefined); } }; // https://fullcalendar.io @computed get renderCalendar() { const availableWidth = this._props.PanelWidth() / (this._props.DocumentView?.().UIBtnScaling ?? 1); const btn = (text: string, view: string | (() => void), hint: string) => ({ text, hint, click: typeof view === 'string' ? () => this._calendarRef?.getApi().changeView(view) : view }); return ( (this._calendarRef = r as FullCalendar)} customButtons={{ nowBtn: btn('Now', () => this._calendarRef?.getApi().gotoDate(new Date()), 'Go to Today'), multiBtn: btn('M+', 'multiMonth', 'Multiple Month View'), monthBtn: btn('M', 'dayGridMonth', 'Month View'), weekBtn: btn('W', 'timeGridWeek', 'Week View'), dayBtn: btn('D', 'timeGridDay', 'Day View'), }} headerToolbar={ availableWidth > 450 ? { left: 'prev,next nowBtn', center: 'title', right: 'multiBtn monthBtn weekBtn dayBtn', } : availableWidth > 300 ? { left: 'prev,next', center: 'title', right: '', } : { left: '', center: 'title', right: '', } } selectable={true} initialView={this.calendarViewType === 'multiMonth' ? undefined : this.calendarViewType} views={{ multiMonth: { type: 'multiMonth', duration: { months: 12 }, }, }} initialDate={untracked(() => this.dateSelect.start)} navLinks={true} editable={true} // expandRows={true} // handleWindowResize={true} displayEventTime={false} displayEventEnd={false} plugins={[multiMonthPlugin, dayGridPlugin, timeGrid, interactionPlugin]} aspectRatio={this._props.PanelWidth() / this._props.PanelHeight()} weekends={true} events={this.calendarEvents} eventClick={this.handleEventClick} eventDrop={this.handleEventDrop} eventResize={this.handleEventDrop} unselectAuto={false} // unselect={() => {}} select={(info: DateSelectArg) => { const start = dateRangeStrToDates(info.startStr).start.toISOString(); const end = info.allDay ? start : dateRangeStrToDates(info.endStr).start.toISOString(); this.Document._calendar_date = start + '|' + end; }} // eventContent={() => { // return null; // }} eventDidMount={(arg: EventMountArg) => { const doc = DocServer.GetCachedRefField(arg.event._def.groupId ?? ''); if (!doc) return; if (doc.type === DocumentType.TASK) { const checkButton = document.createElement('button'); checkButton.innerText = doc.$task_completed ? '✅' : '⬜'; checkButton.style.position = 'absolute'; checkButton.style.right = '5px'; checkButton.style.top = '50%'; checkButton.style.transform = 'translateY(-50%)'; checkButton.style.background = 'transparent'; checkButton.style.border = 'none'; checkButton.style.cursor = 'pointer'; checkButton.style.fontSize = '18px'; checkButton.style.zIndex = '1000'; checkButton.style.padding = '0'; checkButton.style.margin = '0'; checkButton.onclick = ev => { ev.stopPropagation(); doc.$task_completed = !doc.$task_completed; this._calendar?.refetchEvents(); }; arg.el.style.position = 'relative'; arg.el.appendChild(checkButton); } arg.el.addEventListener('pointerdown', ev => ev.button && ev.stopPropagation()); if (navigator.userAgent.includes('Macintosh')) { arg.el.addEventListener('pointerup', ev => { ev.button && ev.stopPropagation(); ev.button && this.handleEventContextMenu(ev.pageX, ev.pageY, arg.event._def.groupId); }); } arg.el.addEventListener('contextmenu', ev => { if (!navigator.userAgent.includes('Macintosh')) { this.handleEventContextMenu(ev.pageX, ev.pageY, arg.event._def.groupId); } ev.stopPropagation(); ev.preventDefault(); }); }} // for dragging and dropping (mirror) eventDragStart={arg => { const mirror = arg.el.cloneNode(true) as HTMLElement; const rect = arg.el.getBoundingClientRect(); mirror.style.position = 'fixed'; mirror.style.pointerEvents = 'none'; mirror.style.opacity = '0.8'; mirror.style.zIndex = '10000'; mirror.classList.add('custom-drag-mirror'); mirror.style.width = `${rect.width}px`; mirror.style.height = `${rect.height}px`; document.body.appendChild(mirror); const moveListener = (ev: MouseEvent) => { mirror.style.left = `${ev.clientX}px`; mirror.style.top = `${ev.clientY}px`; }; window.addEventListener('mousemove', moveListener); // hide the actual box arg.el.style.visibility = 'hidden'; arg.el.style.opacity = '0'; (arg.el as any)._mirrorElement = mirror; (arg.el as any)._moveListener = moveListener; }} eventDragStop={arg => { const el = arg.el as any; const mirror = el._mirrorElement; const moveListener = el._moveListener; // show the actual box el.style.visibility = 'visible'; el.style.opacity = '1'; if (mirror) document.body.removeChild(mirror); if (moveListener) window.removeEventListener('mousemove', moveListener); }} /> ); } setRef = (r: HTMLDivElement | null) => { this.createDashEventsTarget(r); this.fixWheelEvents(r, this._props.isContentActive); }; render() { const scale = this._props.ScreenToLocalTransform().Scale; const scaledWidth = this._props.PanelWidth(); const scaledHeight = this._props.PanelHeight(); return (
{ setTimeout( action(() => { const cname = (e.nativeEvent.target as HTMLButtonElement)?.className ?? ''; if (cname.includes('multiMonth')) this.dataDoc[this.calTypeFieldKey] = 'multiMonth'; if (cname.includes('dayGridMonth')) this.dataDoc[this.calTypeFieldKey] = 'dayGridMonth'; if (cname.includes('timeGridWeek')) this.dataDoc[this.calTypeFieldKey] = 'timeGridWeek'; if (cname.includes('timeGridDay')) this.dataDoc[this.calTypeFieldKey] = 'timeGridDay'; }) ); }}>
{this.renderCalendar}
); } }