mirror of
https://github.com/zadam/trilium.git
synced 2025-11-18 06:24:30 +01:00
367 lines
15 KiB
TypeScript
367 lines
15 KiB
TypeScript
import { DateSelectArg, EventChangeArg, EventMountArg, EventSourceFuncArg, LocaleInput, PluginDef } from "@fullcalendar/core/index.js";
|
|
import { ViewModeProps } from "../interface";
|
|
import Calendar from "./calendar";
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks";
|
|
import "./index.css";
|
|
import { useNoteLabel, useNoteLabelBoolean, useResizeObserver, useSpacedUpdate, useTriliumEvent, useTriliumOption, useTriliumOptionInt } from "../../react/hooks";
|
|
import { DISPLAYABLE_LOCALE_IDS } from "@triliumnext/commons";
|
|
import { Calendar as FullCalendar } from "@fullcalendar/core";
|
|
import { parseStartEndDateFromEvent, parseStartEndTimeFromEvent } from "./utils";
|
|
import dialog from "../../../services/dialog";
|
|
import { t } from "../../../services/i18n";
|
|
import { buildEvents, buildEventsForCalendar } from "./event_builder";
|
|
import { changeEvent, newEvent } from "./api";
|
|
import froca from "../../../services/froca";
|
|
import date_notes from "../../../services/date_notes";
|
|
import appContext from "../../../components/app_context";
|
|
import { DateClickArg } from "@fullcalendar/interaction";
|
|
import FNote from "../../../entities/fnote";
|
|
import Button, { ButtonGroup } from "../../react/Button";
|
|
import ActionButton from "../../react/ActionButton";
|
|
import { RefObject } from "preact";
|
|
import TouchBar, { TouchBarButton, TouchBarLabel, TouchBarSegmentedControl, TouchBarSpacer } from "../../react/TouchBar";
|
|
import { openCalendarContextMenu } from "./context_menu";
|
|
|
|
interface CalendarViewData {
|
|
|
|
}
|
|
|
|
interface CalendarViewData {
|
|
type: string;
|
|
name: string;
|
|
previousText: string;
|
|
nextText: string;
|
|
}
|
|
|
|
const CALENDAR_VIEWS = [
|
|
{
|
|
type: "timeGridWeek",
|
|
name: t("calendar.week"),
|
|
previousText: t("calendar.week_previous"),
|
|
nextText: t("calendar.week_next")
|
|
},
|
|
{
|
|
type: "dayGridMonth",
|
|
name: t("calendar.month"),
|
|
previousText: t("calendar.month_previous"),
|
|
nextText: t("calendar.month_next")
|
|
},
|
|
{
|
|
type: "multiMonthYear",
|
|
name: t("calendar.year"),
|
|
previousText: t("calendar.year_previous"),
|
|
nextText: t("calendar.year_next")
|
|
},
|
|
{
|
|
type: "listMonth",
|
|
name: t("calendar.list"),
|
|
previousText: t("calendar.month_previous"),
|
|
nextText: t("calendar.month_next")
|
|
}
|
|
]
|
|
|
|
const SUPPORTED_CALENDAR_VIEW_TYPE = CALENDAR_VIEWS.map(v => v.type);
|
|
|
|
// Here we hard-code the imports in order to ensure that they are embedded by webpack without having to load all the languages.
|
|
export const LOCALE_MAPPINGS: Record<DISPLAYABLE_LOCALE_IDS, (() => Promise<{ default: LocaleInput }>) | null> = {
|
|
de: () => import("@fullcalendar/core/locales/de"),
|
|
es: () => import("@fullcalendar/core/locales/es"),
|
|
fr: () => import("@fullcalendar/core/locales/fr"),
|
|
it: () => import("@fullcalendar/core/locales/it"),
|
|
cn: () => import("@fullcalendar/core/locales/zh-cn"),
|
|
tw: () => import("@fullcalendar/core/locales/zh-tw"),
|
|
ro: () => import("@fullcalendar/core/locales/ro"),
|
|
ru: () => import("@fullcalendar/core/locales/ru"),
|
|
ja: () => import("@fullcalendar/core/locales/ja"),
|
|
pt: () => import("@fullcalendar/core/locales/pt"),
|
|
"pt_br": () => import("@fullcalendar/core/locales/pt-br"),
|
|
uk: () => import("@fullcalendar/core/locales/uk"),
|
|
en: null,
|
|
"en_rtl": null,
|
|
ar: () => import("@fullcalendar/core/locales/ar")
|
|
};
|
|
|
|
export default function CalendarView({ note, noteIds }: ViewModeProps<CalendarViewData>) {
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const calendarRef = useRef<FullCalendar>(null);
|
|
|
|
const [ calendarRoot ] = useNoteLabelBoolean(note, "calendarRoot");
|
|
const [ workspaceCalendarRoot ] = useNoteLabelBoolean(note, "workspaceCalendarRoot");
|
|
const [ firstDayOfWeek ] = useTriliumOptionInt("firstDayOfWeek");
|
|
const [ hideWeekends ] = useNoteLabelBoolean(note, "calendar:hideWeekends");
|
|
const [ weekNumbers ] = useNoteLabelBoolean(note, "calendar:weekNumbers");
|
|
const [ calendarView, setCalendarView ] = useNoteLabel(note, "calendar:view");
|
|
const [ initialDate ] = useNoteLabel(note, "calendar:initialDate");
|
|
const initialView = useRef(calendarView);
|
|
const viewSpacedUpdate = useSpacedUpdate(() => setCalendarView(initialView.current));
|
|
useResizeObserver(containerRef, () => calendarRef.current?.updateSize());
|
|
const isCalendarRoot = (calendarRoot || workspaceCalendarRoot);
|
|
const isEditable = !isCalendarRoot;
|
|
const eventBuilder = useMemo(() => {
|
|
if (!isCalendarRoot) {
|
|
return async () => await buildEvents(noteIds);
|
|
} else {
|
|
return async (e: EventSourceFuncArg) => await buildEventsForCalendar(note, e);
|
|
}
|
|
}, [isCalendarRoot, noteIds]);
|
|
|
|
const plugins = usePlugins(isEditable, isCalendarRoot);
|
|
const locale = useLocale();
|
|
|
|
const { eventDidMount } = useEventDisplayCustomization(note);
|
|
const editingProps = useEditing(note, isEditable, isCalendarRoot);
|
|
|
|
// React to changes.
|
|
useTriliumEvent("entitiesReloaded", ({ loadResults }) => {
|
|
if (loadResults.getNoteIds().some(noteId => noteIds.includes(noteId)) // note title change.
|
|
|| loadResults.getAttributeRows().some((a) => noteIds.includes(a.noteId ?? ""))) // subnote change.
|
|
{
|
|
calendarRef.current?.refetchEvents();
|
|
}
|
|
});
|
|
|
|
return (plugins &&
|
|
<div className="calendar-view" ref={containerRef} tabIndex={100}>
|
|
<CalendarHeader calendarRef={calendarRef} />
|
|
<Calendar
|
|
events={eventBuilder}
|
|
calendarRef={calendarRef}
|
|
plugins={plugins}
|
|
initialView={initialView.current && SUPPORTED_CALENDAR_VIEW_TYPE.includes(initialView.current) ? initialView.current : "dayGridMonth"}
|
|
headerToolbar={false}
|
|
firstDay={firstDayOfWeek ?? 0}
|
|
weekends={!hideWeekends}
|
|
weekNumbers={weekNumbers}
|
|
height="90%"
|
|
nowIndicator
|
|
handleWindowResize={false}
|
|
initialDate={initialDate || undefined}
|
|
locale={locale}
|
|
{...editingProps}
|
|
eventDidMount={eventDidMount}
|
|
viewDidMount={({ view }) => {
|
|
if (initialView.current !== view.type) {
|
|
initialView.current = view.type;
|
|
viewSpacedUpdate.scheduleUpdate();
|
|
}
|
|
}}
|
|
/>
|
|
<CalendarTouchBar calendarRef={calendarRef} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CalendarHeader({ calendarRef }: { calendarRef: RefObject<FullCalendar> }) {
|
|
const { title, viewType: currentViewType } = useOnDatesSet(calendarRef);
|
|
const currentViewData = CALENDAR_VIEWS.find(v => calendarRef.current && v.type === currentViewType);
|
|
|
|
return (
|
|
<div className="calendar-header">
|
|
<span className="title">{title}</span>
|
|
<ButtonGroup>
|
|
{CALENDAR_VIEWS.map(viewData => (
|
|
<Button
|
|
text={viewData.name.toLocaleLowerCase()}
|
|
className={currentViewType === viewData.type ? "active" : ""}
|
|
onClick={() => calendarRef.current?.changeView(viewData.type)}
|
|
/>
|
|
))}
|
|
</ButtonGroup>
|
|
<Button text={t("calendar.today").toLocaleLowerCase()} onClick={() => calendarRef.current?.today()} />
|
|
<ButtonGroup>
|
|
<ActionButton icon="bx bx-chevron-left" text={currentViewData?.previousText ?? ""} frame onClick={() => calendarRef.current?.prev()} />
|
|
<ActionButton icon="bx bx-chevron-right" text={currentViewData?.nextText ?? ""} frame onClick={() => calendarRef.current?.next()} />
|
|
</ButtonGroup>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function usePlugins(isEditable: boolean, isCalendarRoot: boolean) {
|
|
const [ plugins, setPlugins ] = useState<PluginDef[]>();
|
|
|
|
useEffect(() => {
|
|
async function loadPlugins() {
|
|
const plugins: PluginDef[] = [];
|
|
plugins.push((await import("@fullcalendar/daygrid")).default);
|
|
plugins.push((await import("@fullcalendar/timegrid")).default);
|
|
plugins.push((await import("@fullcalendar/list")).default);
|
|
plugins.push((await import("@fullcalendar/multimonth")).default);
|
|
if (isEditable || isCalendarRoot) {
|
|
plugins.push((await import("@fullcalendar/interaction")).default);
|
|
}
|
|
setPlugins(plugins);
|
|
}
|
|
|
|
loadPlugins();
|
|
}, [ isEditable, isCalendarRoot ]);
|
|
|
|
return plugins;
|
|
}
|
|
|
|
function useLocale() {
|
|
const [ formattingLocale ] = useTriliumOption("formattingLocale");
|
|
const [ calendarLocale, setCalendarLocale ] = useState<LocaleInput>();
|
|
|
|
useEffect(() => {
|
|
const correspondingLocale = LOCALE_MAPPINGS[formattingLocale];
|
|
if (correspondingLocale) {
|
|
correspondingLocale().then((locale) => setCalendarLocale(locale.default));
|
|
} else {
|
|
setCalendarLocale(undefined);
|
|
}
|
|
});
|
|
|
|
return calendarLocale;
|
|
}
|
|
|
|
function useEditing(note: FNote, isEditable: boolean, isCalendarRoot: boolean) {
|
|
const onCalendarSelection = useCallback(async (e: DateSelectArg) => {
|
|
const { startDate, endDate } = parseStartEndDateFromEvent(e);
|
|
if (!startDate) return;
|
|
const { startTime, endTime } = parseStartEndTimeFromEvent(e);
|
|
|
|
// Ask for the title
|
|
const title = await dialog.prompt({ message: t("relation_map.enter_title_of_new_note"), defaultValue: t("relation_map.default_new_note_title") });
|
|
if (!title?.trim()) {
|
|
return;
|
|
}
|
|
|
|
newEvent(note, { title, startDate, endDate, startTime, endTime });
|
|
}, [ note ]);
|
|
|
|
const onEventChange = useCallback(async (e: EventChangeArg) => {
|
|
const { startDate, endDate } = parseStartEndDateFromEvent(e.event);
|
|
if (!startDate) return;
|
|
|
|
const { startTime, endTime } = parseStartEndTimeFromEvent(e.event);
|
|
const note = await froca.getNote(e.event.extendedProps.noteId);
|
|
if (!note) return;
|
|
changeEvent(note, { startDate, endDate, startTime, endTime });
|
|
}, []);
|
|
|
|
// Called upon when clicking the day number in the calendar, opens or creates the day note but only if in a calendar root.
|
|
const onDateClick = useCallback(async (e: DateClickArg) => {
|
|
const eventNote = await date_notes.getDayNote(e.dateStr);
|
|
if (eventNote) {
|
|
appContext.triggerCommand("openInPopup", { noteIdOrPath: eventNote.noteId });
|
|
}
|
|
}, []);
|
|
|
|
return {
|
|
select: onCalendarSelection,
|
|
eventChange: onEventChange,
|
|
dateClick: isCalendarRoot ? onDateClick : undefined,
|
|
editable: isEditable,
|
|
selectable: isEditable
|
|
};
|
|
}
|
|
|
|
function useEventDisplayCustomization(parentNote: FNote) {
|
|
const eventDidMount = useCallback((e: EventMountArg) => {
|
|
const { iconClass, promotedAttributes } = e.event.extendedProps;
|
|
|
|
// Prepend the icon to the title, if any.
|
|
if (iconClass) {
|
|
let titleContainer;
|
|
switch (e.view.type) {
|
|
case "timeGridWeek":
|
|
case "dayGridMonth":
|
|
titleContainer = e.el.querySelector(".fc-event-title");
|
|
break;
|
|
case "multiMonthYear":
|
|
break;
|
|
case "listMonth":
|
|
titleContainer = e.el.querySelector(".fc-list-event-title a");
|
|
break;
|
|
}
|
|
|
|
if (titleContainer) {
|
|
const icon = /*html*/`<span class="${iconClass}"></span> `;
|
|
titleContainer.insertAdjacentHTML("afterbegin", icon);
|
|
}
|
|
}
|
|
|
|
// Append promoted attributes to the end of the event container.
|
|
if (promotedAttributes) {
|
|
let promotedAttributesHtml = "";
|
|
for (const [name, value] of promotedAttributes) {
|
|
promotedAttributesHtml = promotedAttributesHtml + /*html*/`\
|
|
<div class="promoted-attribute">
|
|
<span class="promoted-attribute-name">${name}</span>: <span class="promoted-attribute-value">${value}</span>
|
|
</div>`;
|
|
}
|
|
|
|
let mainContainer;
|
|
switch (e.view.type) {
|
|
case "timeGridWeek":
|
|
case "dayGridMonth":
|
|
mainContainer = e.el.querySelector(".fc-event-main");
|
|
break;
|
|
case "multiMonthYear":
|
|
break;
|
|
case "listMonth":
|
|
mainContainer = e.el.querySelector(".fc-list-event-title");
|
|
break;
|
|
}
|
|
$(mainContainer ?? e.el).append($(promotedAttributesHtml));
|
|
}
|
|
|
|
e.el.addEventListener("contextmenu", (contextMenuEvent) => {
|
|
const noteId = e.event.extendedProps.noteId;
|
|
openCalendarContextMenu(contextMenuEvent, noteId, parentNote);
|
|
});
|
|
}, []);
|
|
return { eventDidMount };
|
|
}
|
|
|
|
function CalendarTouchBar({ calendarRef }: { calendarRef: RefObject<FullCalendar> }) {
|
|
const { title, viewType } = useOnDatesSet(calendarRef);
|
|
|
|
return (
|
|
<TouchBar>
|
|
<TouchBarSegmentedControl
|
|
mode="single"
|
|
segments={CALENDAR_VIEWS.map(({ name }) => ({
|
|
label: name,
|
|
}))}
|
|
selectedIndex={CALENDAR_VIEWS.findIndex(v => v.type === viewType) ?? 0}
|
|
onChange={(selectedIndex) => calendarRef.current?.changeView(CALENDAR_VIEWS[selectedIndex].type)}
|
|
/>
|
|
|
|
<TouchBarSpacer size="flexible" />
|
|
<TouchBarLabel label={title ?? ""} />
|
|
<TouchBarSpacer size="flexible" />
|
|
|
|
<TouchBarButton
|
|
label={t("calendar.today")}
|
|
click={() => calendarRef.current?.today()}
|
|
/>
|
|
<TouchBarButton
|
|
icon="NSImageNameTouchBarGoBackTemplate"
|
|
click={() => calendarRef.current?.prev()}
|
|
/>
|
|
<TouchBarButton
|
|
icon="NSImageNameTouchBarGoForwardTemplate"
|
|
click={() => calendarRef.current?.next()}
|
|
/>
|
|
</TouchBar>
|
|
);
|
|
}
|
|
|
|
function useOnDatesSet(calendarRef: RefObject<FullCalendar>) {
|
|
const [ title, setTitle ] = useState<string>();
|
|
const [ viewType ,setViewType ] = useState<string>();
|
|
useEffect(() => {
|
|
const api = calendarRef.current;
|
|
if (!api) return;
|
|
const handler = () => {
|
|
setTitle(api.view.title);
|
|
setViewType(api.view.type);
|
|
};
|
|
handler();
|
|
api.on("datesSet", handler);
|
|
return () => api.off("datesSet", handler);
|
|
}, [calendarRef]);
|
|
return { title, viewType };
|
|
}
|