mirror of
https://github.com/TriliumNext/Trilium.git
synced 2025-12-10 03:53:37 -06:00
Port launch bar to React (#7934)
This commit is contained in:
commit
6c31a1788a
@ -10,7 +10,6 @@ import FlexContainer from "../widgets/containers/flex_container.js";
|
||||
import FloatingButtons from "../widgets/FloatingButtons.jsx";
|
||||
import GlobalMenu from "../widgets/buttons/global_menu.jsx";
|
||||
import HighlightsListWidget from "../widgets/highlights_list.js";
|
||||
import LauncherContainer from "../widgets/containers/launcher_container.js";
|
||||
import LeftPaneContainer from "../widgets/containers/left_pane_container.js";
|
||||
import LeftPaneToggle from "../widgets/buttons/left_pane_toggle.js";
|
||||
import MovePaneButton from "../widgets/buttons/move_pane_button.js";
|
||||
@ -30,7 +29,6 @@ import ScrollingContainer from "../widgets/containers/scrolling_container.js";
|
||||
import ScrollPadding from "../widgets/scroll_padding.js";
|
||||
import SearchResult from "../widgets/search_result.jsx";
|
||||
import SharedInfo from "../widgets/shared_info.jsx";
|
||||
import SpacerWidget from "../widgets/spacer.js";
|
||||
import SplitNoteContainer from "../widgets/containers/split_note_container.js";
|
||||
import SqlResults from "../widgets/sql_result.js";
|
||||
import SqlTableSchemas from "../widgets/sql_table_schemas.js";
|
||||
@ -43,8 +41,9 @@ import UploadAttachmentsDialog from "../widgets/dialogs/upload_attachments.js";
|
||||
import utils from "../services/utils.js";
|
||||
import WatchedFileUpdateStatusWidget from "../widgets/watched_file_update_status.js";
|
||||
import NoteDetail from "../widgets/NoteDetail.jsx";
|
||||
import RightPanelWidget from "../widgets/sidebar/RightPanelWidget.jsx";
|
||||
import PromotedAttributes from "../widgets/PromotedAttributes.jsx";
|
||||
import SpacerWidget from "../widgets/launch_bar/SpacerWidget.jsx";
|
||||
import LauncherContainer from "../widgets/launch_bar/LauncherContainer.jsx";
|
||||
|
||||
export default class DesktopLayout {
|
||||
|
||||
@ -125,7 +124,7 @@ export default class DesktopLayout {
|
||||
.cssBlock(".title-row > * { margin: 5px; }")
|
||||
.child(<NoteIconWidget />)
|
||||
.child(<NoteTitleWidget />)
|
||||
.child(new SpacerWidget(0, 1))
|
||||
.child(<SpacerWidget baseSize={0} growthFactor={1} />)
|
||||
.child(<MovePaneButton direction="left" />)
|
||||
.child(<MovePaneButton direction="right" />)
|
||||
.child(<ClosePaneButton />)
|
||||
@ -185,14 +184,14 @@ export default class DesktopLayout {
|
||||
launcherPane = new FlexContainer("row")
|
||||
.css("height", "53px")
|
||||
.class("horizontal")
|
||||
.child(new LauncherContainer(true))
|
||||
.child(<LauncherContainer isHorizontalLayout={true} />)
|
||||
.child(<GlobalMenu isHorizontalLayout={true} />);
|
||||
} else {
|
||||
launcherPane = new FlexContainer("column")
|
||||
.css("width", "53px")
|
||||
.class("vertical")
|
||||
.child(<GlobalMenu isHorizontalLayout={false} />)
|
||||
.child(new LauncherContainer(false))
|
||||
.child(<LauncherContainer isHorizontalLayout={false} />)
|
||||
.child(<LeftPaneToggle isHorizontalLayout={false} />);
|
||||
}
|
||||
|
||||
|
||||
@ -6,7 +6,6 @@ import FilePropertiesTab from "../widgets/ribbon/FilePropertiesTab.jsx";
|
||||
import FlexContainer from "../widgets/containers/flex_container.js";
|
||||
import FloatingButtons from "../widgets/FloatingButtons.jsx";
|
||||
import GlobalMenuWidget from "../widgets/buttons/global_menu.js";
|
||||
import LauncherContainer from "../widgets/containers/launcher_container.js";
|
||||
import MobileDetailMenu from "../widgets/mobile_widgets/mobile_detail_menu.js";
|
||||
import NoteList from "../widgets/collections/NoteList.jsx";
|
||||
import NoteTitleWidget from "../widgets/note_title.js";
|
||||
@ -30,6 +29,7 @@ import NoteDetail from "../widgets/NoteDetail.jsx";
|
||||
import MobileEditorToolbar from "../widgets/type_widgets/text/mobile_editor_toolbar.jsx";
|
||||
import PromotedAttributes from "../widgets/PromotedAttributes.jsx";
|
||||
import SplitNoteContainer from "../widgets/containers/split_note_container.js";
|
||||
import LauncherContainer from "../widgets/launch_bar/LauncherContainer.jsx";
|
||||
|
||||
const MOBILE_CSS = `
|
||||
<style>
|
||||
@ -183,7 +183,7 @@ export default class MobileLayout {
|
||||
.child(new FlexContainer("row")
|
||||
.class("horizontal")
|
||||
.css("height", "53px")
|
||||
.child(new LauncherContainer(true))
|
||||
.child(<LauncherContainer isHorizontalLayout />)
|
||||
.child(<GlobalMenuWidget isHorizontalLayout />)
|
||||
.id("launcher-pane"))
|
||||
)
|
||||
|
||||
@ -36,10 +36,16 @@ export async function executeBundle(bundle: Bundle, originEntity?: Entity | null
|
||||
}.call(apiContext);
|
||||
} catch (e: any) {
|
||||
const note = await froca.getNote(bundle.noteId);
|
||||
|
||||
const message = `Execution of JS note "${note?.title}" with ID ${bundle.noteId} failed with error: ${e?.message}`;
|
||||
showError(message);
|
||||
logError(message);
|
||||
toastService.showPersistent({
|
||||
title: t("toast.bundle-error.title"),
|
||||
icon: "bx bx-error-circle",
|
||||
message: t("toast.bundle-error.message", {
|
||||
id: note?.noteId,
|
||||
title: note?.title,
|
||||
message: e.message
|
||||
})
|
||||
});
|
||||
logError("Widget initialization failed: ", e);
|
||||
}
|
||||
}
|
||||
|
||||
@ -103,7 +109,7 @@ async function getWidgetBundlesByParent() {
|
||||
const note = await froca.getNote(noteId);
|
||||
toastService.showPersistent({
|
||||
title: t("toast.bundle-error.title"),
|
||||
icon: "alert",
|
||||
icon: "bx bx-error-circle",
|
||||
message: t("toast.bundle-error.message", {
|
||||
id: noteId,
|
||||
title: note?.title,
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import utils from "./utils.js";
|
||||
|
||||
type ElementType = HTMLElement | Document;
|
||||
type Handler = (e: KeyboardEvent) => void;
|
||||
export type Handler = (e: KeyboardEvent) => void;
|
||||
|
||||
export interface ShortcutBinding {
|
||||
element: HTMLElement | Document;
|
||||
|
||||
@ -97,7 +97,7 @@ export function showError(message: string, delay = 10000) {
|
||||
console.log(utils.now(), "error: ", message);
|
||||
|
||||
toast({
|
||||
icon: "alert",
|
||||
icon: "bx bx-error-circle",
|
||||
message: message,
|
||||
autohide: true,
|
||||
delay
|
||||
@ -109,7 +109,7 @@ function showErrorTitleAndMessage(title: string, message: string, delay = 10000)
|
||||
|
||||
toast({
|
||||
title: title,
|
||||
icon: "alert",
|
||||
icon: "bx bx-error-circle",
|
||||
message: message,
|
||||
autohide: true,
|
||||
delay
|
||||
|
||||
@ -150,7 +150,7 @@ export function isMac() {
|
||||
|
||||
export const hasTouchBar = (isMac() && isElectron());
|
||||
|
||||
function isCtrlKey(evt: KeyboardEvent | MouseEvent | JQuery.ClickEvent | JQuery.ContextMenuEvent | JQuery.TriggeredEvent | React.PointerEvent<HTMLCanvasElement> | JQueryEventObject) {
|
||||
export function isCtrlKey(evt: KeyboardEvent | MouseEvent | JQuery.ClickEvent | JQuery.ContextMenuEvent | JQuery.TriggeredEvent | React.PointerEvent<HTMLCanvasElement> | JQueryEventObject) {
|
||||
return (!isMac() && evt.ctrlKey) || (isMac() && evt.metaKey);
|
||||
}
|
||||
|
||||
@ -236,7 +236,7 @@ export function isIOS() {
|
||||
return /iPad|iPhone|iPod/.test(navigator.userAgent);
|
||||
}
|
||||
|
||||
function isDesktop() {
|
||||
export function isDesktop() {
|
||||
return (
|
||||
window.glob?.device === "desktop" ||
|
||||
// window.glob.device is not available in setup
|
||||
|
||||
@ -212,7 +212,8 @@ body[dir=ltr] #launcher-container {
|
||||
}
|
||||
|
||||
#launcher-pane .launcher-button,
|
||||
#launcher-pane .dropdown {
|
||||
#launcher-pane .right-dropdown-widget,
|
||||
#launcher-pane .global-menu {
|
||||
width: calc(var(--launcher-pane-size) - (var(--launcher-pane-button-margin) * 2)) !important;
|
||||
height: calc(var(--launcher-pane-size) - (var(--launcher-pane-button-margin) * 2)) !important;
|
||||
margin: var(--launcher-pane-button-gap) var(--launcher-pane-button-margin);
|
||||
|
||||
@ -174,11 +174,11 @@ export class TypedBasicWidget<T extends TypedComponent<any>> extends TypedCompon
|
||||
froca.getNote(noteId, true).then((note) => {
|
||||
toastService.showPersistent({
|
||||
title: t("toast.widget-error.title"),
|
||||
icon: "alert",
|
||||
icon: "bx bx-error-circle",
|
||||
message: t("toast.widget-error.message-custom", {
|
||||
id: noteId,
|
||||
title: note?.title,
|
||||
message: e.message
|
||||
message: e.message || e.toString()
|
||||
})
|
||||
});
|
||||
});
|
||||
@ -187,9 +187,9 @@ export class TypedBasicWidget<T extends TypedComponent<any>> extends TypedCompon
|
||||
|
||||
toastService.showPersistent({
|
||||
title: t("toast.widget-error.title"),
|
||||
icon: "alert",
|
||||
icon: "bx bx-error-circle",
|
||||
message: t("toast.widget-error.message-unknown", {
|
||||
message: e.message
|
||||
message: e.message || e.toString()
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
@ -1,78 +0,0 @@
|
||||
import FlexContainer from "./containers/flex_container.js";
|
||||
import OpenNoteButtonWidget from "./buttons/open_note_button_widget.js";
|
||||
import BookmarkFolderWidget from "./buttons/bookmark_folder.js";
|
||||
import froca from "../services/froca.js";
|
||||
import utils from "../services/utils.js";
|
||||
import type { EventData } from "../components/app_context.js";
|
||||
import type Component from "../components/component.js";
|
||||
|
||||
interface BookmarkButtonsSettings {
|
||||
titlePlacement?: string;
|
||||
}
|
||||
|
||||
export default class BookmarkButtons extends FlexContainer<Component> {
|
||||
private settings: BookmarkButtonsSettings;
|
||||
private noteIds: string[];
|
||||
|
||||
constructor(isHorizontalLayout: boolean) {
|
||||
super(isHorizontalLayout ? "row" : "column");
|
||||
|
||||
this.contentSized();
|
||||
this.settings = {};
|
||||
this.noteIds = [];
|
||||
}
|
||||
|
||||
async refresh(): Promise<void> {
|
||||
this.$widget.empty();
|
||||
this.children = [];
|
||||
this.noteIds = [];
|
||||
|
||||
const bookmarkParentNote = await froca.getNote("_lbBookmarks");
|
||||
|
||||
if (!bookmarkParentNote) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const note of await bookmarkParentNote.getChildNotes()) {
|
||||
this.noteIds.push(note.noteId);
|
||||
|
||||
let buttonWidget: OpenNoteButtonWidget | BookmarkFolderWidget = note.isLabelTruthy("bookmarkFolder")
|
||||
? new BookmarkFolderWidget(note)
|
||||
: new OpenNoteButtonWidget(note).class("launcher-button");
|
||||
|
||||
if (this.settings.titlePlacement) {
|
||||
if (!("settings" in buttonWidget)) {
|
||||
(buttonWidget as any).settings = {};
|
||||
}
|
||||
|
||||
(buttonWidget as any).settings.titlePlacement = this.settings.titlePlacement;
|
||||
}
|
||||
|
||||
this.child(buttonWidget);
|
||||
|
||||
this.$widget.append(buttonWidget.render());
|
||||
|
||||
buttonWidget.refreshIcon();
|
||||
}
|
||||
|
||||
utils.reloadTray();
|
||||
}
|
||||
|
||||
initialRenderCompleteEvent(): void {
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">): void {
|
||||
if (loadResults.getBranchRows().find((branch) => branch.parentNoteId === "_lbBookmarks")) {
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
if (loadResults.getAttributeRows().find((attr) =>
|
||||
attr.type === "label" &&
|
||||
attr.name && ["iconClass", "workspaceIconClass", "bookmarkFolder"].includes(attr.name) &&
|
||||
attr.noteId && this.noteIds.includes(attr.noteId)
|
||||
)) {
|
||||
this.refresh();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,26 +0,0 @@
|
||||
import type { EventData } from "../../components/app_context.js";
|
||||
import type FNote from "../../entities/fnote.js";
|
||||
import options from "../../services/options.js";
|
||||
import CommandButtonWidget from "./command_button.js";
|
||||
|
||||
export default class AiChatButton extends CommandButtonWidget {
|
||||
|
||||
constructor(note: FNote) {
|
||||
super();
|
||||
|
||||
this.command("createAiChat")
|
||||
.title(() => note.title)
|
||||
.icon(() => note.getIcon())
|
||||
.class("launcher-button");
|
||||
}
|
||||
|
||||
isEnabled() {
|
||||
return options.get("aiEnabled") === "true";
|
||||
}
|
||||
|
||||
entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
|
||||
if (loadResults.isOptionReloaded("aiEnabled")) {
|
||||
this.refresh();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,88 +0,0 @@
|
||||
import RightDropdownButtonWidget from "./right_dropdown_button.js";
|
||||
import linkService from "../../services/link.js";
|
||||
import utils from "../../services/utils.js";
|
||||
import type FNote from "../../entities/fnote.js";
|
||||
|
||||
const DROPDOWN_TPL = `
|
||||
<div class="bookmark-folder-widget">
|
||||
<style>
|
||||
.bookmark-folder-widget {
|
||||
min-width: 400px;
|
||||
max-height: 500px;
|
||||
padding: 7px 15px 0 15px;
|
||||
font-size: 1.2rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.bookmark-folder-widget ul {
|
||||
padding: 0;
|
||||
list-style-type: none;
|
||||
}
|
||||
|
||||
.bookmark-folder-widget .note-link {
|
||||
display: block;
|
||||
padding: 5px 10px 5px 5px;
|
||||
}
|
||||
|
||||
.bookmark-folder-widget .note-link:hover {
|
||||
background-color: var(--accented-background-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.dropdown-menu .bookmark-folder-widget a:hover {
|
||||
text-decoration: none;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.bookmark-folder-widget li .note-link {
|
||||
padding-inline-start: 35px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="parent-note"></div>
|
||||
|
||||
<ul class="children-notes"></ul>
|
||||
</div>`;
|
||||
|
||||
interface LinkOptions {
|
||||
showTooltip: boolean;
|
||||
showNoteIcon: boolean;
|
||||
}
|
||||
|
||||
export default class BookmarkFolderWidget extends RightDropdownButtonWidget {
|
||||
private note: FNote;
|
||||
private $parentNote!: JQuery<HTMLElement>;
|
||||
private $childrenNotes!: JQuery<HTMLElement>;
|
||||
declare $dropdownContent: JQuery<HTMLElement>;
|
||||
|
||||
constructor(note: FNote) {
|
||||
super(utils.escapeHtml(note.title), note.getIcon(), DROPDOWN_TPL);
|
||||
|
||||
this.note = note;
|
||||
}
|
||||
|
||||
doRender(): void {
|
||||
super.doRender();
|
||||
|
||||
this.$parentNote = this.$dropdownContent.find(".parent-note");
|
||||
this.$childrenNotes = this.$dropdownContent.find(".children-notes");
|
||||
}
|
||||
|
||||
async dropdownShown(): Promise<void> {
|
||||
this.$parentNote.empty();
|
||||
this.$childrenNotes.empty();
|
||||
|
||||
const linkOptions: LinkOptions = {
|
||||
showTooltip: false,
|
||||
showNoteIcon: true
|
||||
};
|
||||
|
||||
this.$parentNote.append((await linkService.createLink(this.note.noteId, linkOptions)).addClass("note-link"));
|
||||
|
||||
for (const childNote of await this.note.getChildNotes()) {
|
||||
this.$childrenNotes.append($("<li>").append((await linkService.createLink(childNote.noteId, linkOptions)).addClass("note-link")));
|
||||
}
|
||||
}
|
||||
|
||||
refreshIcon(): void {}
|
||||
}
|
||||
@ -1,63 +0,0 @@
|
||||
import froca from "../../services/froca.js";
|
||||
import attributeService from "../../services/attributes.js";
|
||||
import CommandButtonWidget from "./command_button.js";
|
||||
import type { EventData } from "../../components/app_context.js";
|
||||
|
||||
export type ButtonNoteIdProvider = () => string;
|
||||
|
||||
export default class ButtonFromNoteWidget extends CommandButtonWidget {
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.settings.buttonNoteIdProvider = null;
|
||||
}
|
||||
|
||||
buttonNoteIdProvider(provider: ButtonNoteIdProvider) {
|
||||
this.settings.buttonNoteIdProvider = provider;
|
||||
return this;
|
||||
}
|
||||
|
||||
doRender() {
|
||||
super.doRender();
|
||||
|
||||
this.updateIcon();
|
||||
}
|
||||
|
||||
updateIcon() {
|
||||
if (!this.settings.buttonNoteIdProvider) {
|
||||
console.error(`buttonNoteId for '${this.componentId}' is not defined.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const buttonNoteId = this.settings.buttonNoteIdProvider();
|
||||
|
||||
if (!buttonNoteId) {
|
||||
console.error(`buttonNoteId for '${this.componentId}' is not defined.`);
|
||||
return;
|
||||
}
|
||||
|
||||
froca.getNote(buttonNoteId).then((note) => {
|
||||
const icon = note?.getIcon();
|
||||
if (icon) {
|
||||
this.settings.icon = icon;
|
||||
}
|
||||
|
||||
this.refreshIcon();
|
||||
});
|
||||
}
|
||||
|
||||
entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
|
||||
// TODO: this seems incorrect
|
||||
//@ts-ignore
|
||||
const buttonNote = froca.getNoteFromCache(this.buttonNoteIdProvider());
|
||||
|
||||
if (!buttonNote) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (loadResults.getAttributeRows(this.componentId).find((attr) => attr.type === "label" && attr.name === "iconClass" && attributeService.isAffecting(attr, buttonNote))) {
|
||||
this.updateIcon();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,420 +0,0 @@
|
||||
import { t } from "../../services/i18n.js";
|
||||
import dateNoteService from "../../services/date_notes.js";
|
||||
import server from "../../services/server.js";
|
||||
import appContext from "../../components/app_context.js";
|
||||
import RightDropdownButtonWidget from "./right_dropdown_button.js";
|
||||
import toastService from "../../services/toast.js";
|
||||
import options from "../../services/options.js";
|
||||
import { Dropdown } from "bootstrap";
|
||||
import type { EventData } from "../../components/app_context.js";
|
||||
import { dayjs, type Dayjs } from "@triliumnext/commons";
|
||||
import "../../stylesheets/calendar.css";
|
||||
import type { AttributeRow, OptionDefinitions } from "@triliumnext/commons";
|
||||
|
||||
const MONTHS = [
|
||||
t("calendar.january"),
|
||||
t("calendar.february"),
|
||||
t("calendar.march"),
|
||||
t("calendar.april"),
|
||||
t("calendar.may"),
|
||||
t("calendar.june"),
|
||||
t("calendar.july"),
|
||||
t("calendar.august"),
|
||||
t("calendar.september"),
|
||||
t("calendar.october"),
|
||||
t("calendar.november"),
|
||||
t("calendar.december")
|
||||
];
|
||||
|
||||
const DROPDOWN_TPL = `
|
||||
<div class="calendar-dropdown-widget">
|
||||
<style>
|
||||
.calendar-dropdown-widget {
|
||||
width: 400px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="calendar-header">
|
||||
<div class="calendar-month-selector">
|
||||
<button class="calendar-btn tn-tool-button bx bx-chevron-left" data-calendar-toggle="previous"></button>
|
||||
|
||||
<button class="btn dropdown-toggle select-button" type="button"
|
||||
data-bs-toggle="dropdown" data-bs-auto-close="true"
|
||||
aria-expanded="false"
|
||||
data-calendar-input="month"></button>
|
||||
<ul class="dropdown-menu" data-calendar-input="month-list">
|
||||
${Object.entries(MONTHS)
|
||||
.map(([i, month]) => `<li><button class="dropdown-item" data-value=${i}>${month}</button></li>`)
|
||||
.join("")}
|
||||
</ul>
|
||||
|
||||
<button class="calendar-btn tn-tool-button bx bx-chevron-right" data-calendar-toggle="next"></button>
|
||||
</div>
|
||||
|
||||
<div class="calendar-year-selector">
|
||||
<button class="calendar-btn tn-tool-button bx bx-chevron-left" data-calendar-toggle="previousYear"></button>
|
||||
|
||||
<input type="number" min="1900" max="2999" step="1" data-calendar-input="year" />
|
||||
|
||||
<button class="calendar-btn tn-tool-button bx bx-chevron-right" data-calendar-toggle="nextYear"></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="calendar-week"></div>
|
||||
<div class="calendar-body" data-calendar-area="month"></div>
|
||||
</div>`;
|
||||
|
||||
const DAYS_OF_WEEK = [
|
||||
t("calendar.sun"),
|
||||
t("calendar.mon"),
|
||||
t("calendar.tue"),
|
||||
t("calendar.wed"),
|
||||
t("calendar.thu"),
|
||||
t("calendar.fri"),
|
||||
t("calendar.sat")
|
||||
];
|
||||
|
||||
interface DateNotesForMonth {
|
||||
[date: string]: string;
|
||||
}
|
||||
|
||||
interface WeekCalculationOptions {
|
||||
firstWeekType: number;
|
||||
minDaysInFirstWeek: number;
|
||||
}
|
||||
|
||||
export default class CalendarWidget extends RightDropdownButtonWidget {
|
||||
private $month!: JQuery<HTMLElement>;
|
||||
private $weekHeader!: JQuery<HTMLElement>;
|
||||
private $monthSelect!: JQuery<HTMLElement>;
|
||||
private $yearSelect!: JQuery<HTMLElement>;
|
||||
private $next!: JQuery<HTMLElement>;
|
||||
private $previous!: JQuery<HTMLElement>;
|
||||
private $nextYear!: JQuery<HTMLElement>;
|
||||
private $previousYear!: JQuery<HTMLElement>;
|
||||
private monthDropdown!: Dropdown;
|
||||
// stored in ISO 1–7
|
||||
private firstDayOfWeekISO!: number;
|
||||
private weekCalculationOptions!: WeekCalculationOptions;
|
||||
private activeDate: Dayjs | null = null;
|
||||
private todaysDate!: Dayjs;
|
||||
private date!: Dayjs;
|
||||
private weekNoteEnable: boolean = false;
|
||||
private weekNotes: string[] = [];
|
||||
|
||||
constructor(title: string = "", icon: string = "") {
|
||||
super(title, icon, DROPDOWN_TPL, "calendar-dropdown-menu");
|
||||
}
|
||||
|
||||
doRender() {
|
||||
super.doRender();
|
||||
|
||||
this.$month = this.$dropdownContent.find('[data-calendar-area="month"]');
|
||||
this.$weekHeader = this.$dropdownContent.find(".calendar-week");
|
||||
|
||||
this.manageFirstDayOfWeek();
|
||||
this.initWeekCalculation();
|
||||
|
||||
// Month navigation
|
||||
this.$monthSelect = this.$dropdownContent.find('[data-calendar-input="month"]');
|
||||
this.$monthSelect.on("show.bs.dropdown", (e) => {
|
||||
// Don't trigger dropdownShown() at widget level when the month selection dropdown is shown, since it would cause a redundant refresh.
|
||||
e.stopPropagation();
|
||||
});
|
||||
this.monthDropdown = Dropdown.getOrCreateInstance(this.$monthSelect[0]);
|
||||
this.$dropdownContent.find('[data-calendar-input="month-list"] button').on("click", (e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const value = target.dataset.value;
|
||||
if (value) {
|
||||
this.date = this.date.month(parseInt(value));
|
||||
this.createMonth();
|
||||
}
|
||||
});
|
||||
|
||||
this.$next = this.$dropdownContent.find('[data-calendar-toggle="next"]');
|
||||
this.$next.on("click", () => {
|
||||
this.date = this.date.add(1, 'month');
|
||||
this.createMonth();
|
||||
});
|
||||
this.$previous = this.$dropdownContent.find('[data-calendar-toggle="previous"]');
|
||||
this.$previous.on("click", () => {
|
||||
this.date = this.date.subtract(1, 'month');
|
||||
this.createMonth();
|
||||
});
|
||||
|
||||
// Year navigation
|
||||
this.$yearSelect = this.$dropdownContent.find('[data-calendar-input="year"]');
|
||||
this.$yearSelect.on("input", (e) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
this.date = this.date.year(parseInt(target.value));
|
||||
this.createMonth();
|
||||
});
|
||||
|
||||
this.$nextYear = this.$dropdownContent.find('[data-calendar-toggle="nextYear"]');
|
||||
this.$nextYear.on("click", () => {
|
||||
this.date = this.date.add(1, 'year');
|
||||
this.createMonth();
|
||||
});
|
||||
|
||||
this.$previousYear = this.$dropdownContent.find('[data-calendar-toggle="previousYear"]');
|
||||
this.$previousYear.on("click", () => {
|
||||
this.date = this.date.subtract(1, 'year');
|
||||
this.createMonth();
|
||||
});
|
||||
|
||||
// Date click
|
||||
this.$dropdownContent.on("click", ".calendar-date", async (ev) => {
|
||||
const date = $(ev.target).closest(".calendar-date").attr("data-calendar-date");
|
||||
if (date) {
|
||||
const note = await dateNoteService.getDayNote(date);
|
||||
if (note) {
|
||||
appContext.tabManager.getActiveContext()?.setNote(note.noteId);
|
||||
this.dropdown?.hide();
|
||||
} else {
|
||||
toastService.showError(t("calendar.cannot_find_day_note"));
|
||||
}
|
||||
}
|
||||
ev.stopPropagation();
|
||||
});
|
||||
|
||||
// Week click
|
||||
this.$dropdownContent.on("click", ".calendar-week-number", async (ev) => {
|
||||
if (!this.weekNoteEnable) {
|
||||
return;
|
||||
}
|
||||
|
||||
const week = $(ev.target).closest(".calendar-week-number").attr("data-calendar-week-number");
|
||||
|
||||
if (week) {
|
||||
const note = await dateNoteService.getWeekNote(week);
|
||||
|
||||
if (note) {
|
||||
appContext.tabManager.getActiveContext()?.setNote(note.noteId);
|
||||
this.dropdown?.hide();
|
||||
} else {
|
||||
toastService.showError(t("calendar.cannot_find_week_note"));
|
||||
}
|
||||
}
|
||||
|
||||
ev.stopPropagation();
|
||||
});
|
||||
|
||||
// Handle click events for the entire calendar widget
|
||||
this.$dropdownContent.on("click", (e) => {
|
||||
const $target = $(e.target);
|
||||
|
||||
// Keep dropdown open when clicking on month select button or year selector area
|
||||
if ($target.closest('.btn.dropdown-toggle.select-button').length) {
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
// Hide dropdown for all other cases
|
||||
this.monthDropdown.hide();
|
||||
// Prevent dismissing the calendar popup by clicking on an empty space inside it.
|
||||
e.stopPropagation();
|
||||
});
|
||||
}
|
||||
|
||||
private async getWeekNoteEnable() {
|
||||
const noteId = await server.get<string[]>(`search/${encodeURIComponent('#calendarRoot')}`);
|
||||
if (noteId.length === 0) {
|
||||
this.weekNoteEnable = false;
|
||||
return;
|
||||
}
|
||||
const noteAttributes = await server.get<AttributeRow[]>(`notes/${noteId}/attributes`);
|
||||
this.weekNoteEnable = noteAttributes.some(a => a.name === 'enableWeekNote');
|
||||
}
|
||||
|
||||
// Store firstDayOfWeek as ISO (1–7)
|
||||
manageFirstDayOfWeek() {
|
||||
const rawFirstDayOfWeek = options.getInt("firstDayOfWeek") || 0;
|
||||
this.firstDayOfWeekISO = rawFirstDayOfWeek === 0 ? 7 : rawFirstDayOfWeek;
|
||||
|
||||
let localeDaysOfWeek = [...DAYS_OF_WEEK];
|
||||
const shifted = localeDaysOfWeek.splice(0, rawFirstDayOfWeek);
|
||||
localeDaysOfWeek = ['', ...localeDaysOfWeek, ...shifted];
|
||||
this.$weekHeader.html(localeDaysOfWeek.map((el) => `<span>${el}</span>`).join(''));
|
||||
}
|
||||
|
||||
initWeekCalculation() {
|
||||
this.weekCalculationOptions = {
|
||||
firstWeekType: options.getInt("firstWeekOfYear") || 0,
|
||||
minDaysInFirstWeek: options.getInt("minDaysInFirstWeek") || 4
|
||||
};
|
||||
}
|
||||
|
||||
getWeekStartDate(date: Dayjs): Dayjs {
|
||||
const currentISO = date.isoWeekday();
|
||||
const diff = (currentISO - this.firstDayOfWeekISO + 7) % 7;
|
||||
return date.clone().subtract(diff, "day").startOf("day");
|
||||
}
|
||||
|
||||
getWeekNumber(date: Dayjs): number {
|
||||
const weekStart = this.getWeekStartDate(date);
|
||||
return weekStart.isoWeek();
|
||||
}
|
||||
|
||||
async dropdownShown() {
|
||||
await this.getWeekNoteEnable();
|
||||
this.weekNotes = await server.get<string[]>(`attribute-values/weekNote`);
|
||||
this.init(appContext.tabManager.getActiveContextNote()?.getOwnedLabelValue("dateNote") ?? null);
|
||||
}
|
||||
|
||||
init(activeDate: string | null) {
|
||||
this.activeDate = activeDate ? dayjs(`${activeDate}T12:00:00`) : null;
|
||||
this.todaysDate = dayjs();
|
||||
this.date = dayjs(this.activeDate || this.todaysDate).startOf('month');
|
||||
this.createMonth();
|
||||
}
|
||||
|
||||
createDay(dateNotesForMonth: DateNotesForMonth, num: number) {
|
||||
const $newDay = $("<a>")
|
||||
.addClass("calendar-date")
|
||||
.attr("data-calendar-date", this.date.local().format('YYYY-MM-DD'));
|
||||
const $date = $("<span>").html(String(num));
|
||||
const dateNoteId = dateNotesForMonth[this.date.local().format('YYYY-MM-DD')];
|
||||
|
||||
if (dateNoteId) {
|
||||
$newDay.addClass("calendar-date-exists").attr("data-href", `#root/${dateNoteId}`);
|
||||
}
|
||||
|
||||
if (this.date.isSame(this.activeDate, 'day')) $newDay.addClass("calendar-date-active");
|
||||
if (this.date.isSame(this.todaysDate, 'day')) $newDay.addClass("calendar-date-today");
|
||||
|
||||
$newDay.append($date);
|
||||
return $newDay;
|
||||
}
|
||||
|
||||
createWeekNumber(weekNumber: number) {
|
||||
const weekNoteId = this.date.local().format('YYYY-') + 'W' + String(weekNumber).padStart(2, '0');
|
||||
let $newWeekNumber;
|
||||
|
||||
if (this.weekNoteEnable) {
|
||||
$newWeekNumber = $("<a>").addClass("calendar-date");
|
||||
if (this.weekNotes.includes(weekNoteId)) {
|
||||
$newWeekNumber.addClass("calendar-date-exists").attr("data-href", `#root/${weekNoteId}`);
|
||||
}
|
||||
} else {
|
||||
$newWeekNumber = $("<span>").addClass("calendar-week-number-disabled");
|
||||
}
|
||||
|
||||
$newWeekNumber.addClass("calendar-week-number").attr("data-calendar-week-number", weekNoteId);
|
||||
$newWeekNumber.append($("<span>").html(String(weekNumber)));
|
||||
return $newWeekNumber;
|
||||
}
|
||||
|
||||
// Use isoWeekday() consistently
|
||||
private getPrevMonthDays(firstDayISO: number): { weekNumber: number, dates: Dayjs[] } {
|
||||
const prevMonthLastDay = this.date.subtract(1, 'month').endOf('month');
|
||||
const daysToAdd = (firstDayISO - this.firstDayOfWeekISO + 7) % 7;
|
||||
const dates: Dayjs[] = [];
|
||||
|
||||
const firstDay = this.date.startOf('month');
|
||||
const weekNumber = this.getWeekNumber(firstDay);
|
||||
|
||||
// Get dates from previous month
|
||||
for (let i = daysToAdd - 1; i >= 0; i--) {
|
||||
dates.push(prevMonthLastDay.subtract(i, 'day'));
|
||||
}
|
||||
|
||||
return { weekNumber, dates };
|
||||
}
|
||||
|
||||
private getNextMonthDays(lastDayISO: number): Dayjs[] {
|
||||
const nextMonthFirstDay = this.date.add(1, 'month').startOf('month');
|
||||
const dates: Dayjs[] = [];
|
||||
|
||||
const lastDayOfUserWeek = ((this.firstDayOfWeekISO + 6 - 1) % 7) + 1; // ISO wrap
|
||||
const daysToAdd = (lastDayOfUserWeek - lastDayISO + 7) % 7;
|
||||
|
||||
for (let i = 0; i < daysToAdd; i++) {
|
||||
dates.push(nextMonthFirstDay.add(i, 'day'));
|
||||
}
|
||||
return dates;
|
||||
}
|
||||
|
||||
async createMonth() {
|
||||
const month = this.date.format('YYYY-MM');
|
||||
const dateNotesForMonth: DateNotesForMonth = await server.get(`special-notes/notes-for-month/${month}`);
|
||||
|
||||
this.$month.empty();
|
||||
|
||||
const firstDay = this.date.startOf('month');
|
||||
const firstDayISO = firstDay.isoWeekday();
|
||||
|
||||
// Previous month filler
|
||||
if (firstDayISO !== this.firstDayOfWeekISO) {
|
||||
const { weekNumber, dates } = this.getPrevMonthDays(firstDayISO);
|
||||
const prevMonth = this.date.subtract(1, 'month').format('YYYY-MM');
|
||||
const dateNotesForPrevMonth: DateNotesForMonth = await server.get(`special-notes/notes-for-month/${prevMonth}`);
|
||||
|
||||
const $weekNumber = this.createWeekNumber(weekNumber);
|
||||
this.$month.append($weekNumber);
|
||||
|
||||
dates.forEach(date => {
|
||||
const tempDate = this.date;
|
||||
this.date = date;
|
||||
const $day = this.createDay(dateNotesForPrevMonth, date.date());
|
||||
$day.addClass('calendar-date-prev-month');
|
||||
this.$month.append($day);
|
||||
this.date = tempDate;
|
||||
});
|
||||
}
|
||||
|
||||
const currentMonth = this.date.month();
|
||||
|
||||
// Main month
|
||||
while (this.date.month() === currentMonth) {
|
||||
const weekNumber = this.getWeekNumber(this.date);
|
||||
if (this.date.isoWeekday() === this.firstDayOfWeekISO) {
|
||||
const $weekNumber = this.createWeekNumber(weekNumber);
|
||||
this.$month.append($weekNumber);
|
||||
}
|
||||
|
||||
const $day = this.createDay(dateNotesForMonth, this.date.date());
|
||||
this.$month.append($day);
|
||||
this.date = this.date.add(1, 'day');
|
||||
}
|
||||
// while loop trips over and day is at 30/31, bring it back
|
||||
this.date = this.date.startOf('month').subtract(1, 'month');
|
||||
|
||||
// Add dates from next month
|
||||
const lastDayOfMonth = this.date.endOf('month');
|
||||
const lastDayISO = lastDayOfMonth.isoWeekday();
|
||||
const lastDayOfUserWeek = ((this.firstDayOfWeekISO + 6 - 1) % 7) + 1;
|
||||
|
||||
if (lastDayISO !== lastDayOfUserWeek) {
|
||||
const dates = this.getNextMonthDays(lastDayISO);
|
||||
const nextMonth = this.date.add(1, 'month').format('YYYY-MM');
|
||||
const dateNotesForNextMonth: DateNotesForMonth = await server.get(`special-notes/notes-for-month/${nextMonth}`);
|
||||
|
||||
dates.forEach(date => {
|
||||
const tempDate = this.date;
|
||||
this.date = date;
|
||||
const $day = this.createDay(dateNotesForNextMonth, date.date());
|
||||
$day.addClass('calendar-date-next-month');
|
||||
this.$month.append($day);
|
||||
this.date = tempDate;
|
||||
});
|
||||
}
|
||||
|
||||
this.$monthSelect.text(MONTHS[this.date.month()]);
|
||||
this.$yearSelect.val(this.date.year());
|
||||
}
|
||||
|
||||
async entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
|
||||
const WEEK_OPTIONS: (keyof OptionDefinitions)[] = [
|
||||
"firstDayOfWeek",
|
||||
"firstWeekOfYear",
|
||||
"minDaysInFirstWeek",
|
||||
];
|
||||
if (!WEEK_OPTIONS.some(opt => loadResults.getOptionNames().includes(opt))) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.manageFirstDayOfWeek();
|
||||
this.initWeekCalculation();
|
||||
this.createMonth();
|
||||
}
|
||||
}
|
||||
@ -1,73 +0,0 @@
|
||||
import { ActionKeyboardShortcut } from "@triliumnext/commons";
|
||||
import type { CommandNames } from "../../components/app_context.js";
|
||||
import keyboardActionsService from "../../services/keyboard_actions.js";
|
||||
import AbstractButtonWidget, { type AbstractButtonWidgetSettings } from "./abstract_button.js";
|
||||
import type { ButtonNoteIdProvider } from "./button_from_note.js";
|
||||
|
||||
let actions: ActionKeyboardShortcut[];
|
||||
|
||||
keyboardActionsService.getActions().then((as) => (actions = as));
|
||||
|
||||
// TODO: Is this actually used?
|
||||
export type ClickHandler = (widget: CommandButtonWidget, e: JQuery.ClickEvent<any, any, any, any>) => void;
|
||||
type CommandOrCallback = CommandNames | (() => CommandNames);
|
||||
|
||||
interface CommandButtonWidgetSettings extends AbstractButtonWidgetSettings {
|
||||
command?: CommandOrCallback;
|
||||
onClick?: ClickHandler;
|
||||
buttonNoteIdProvider?: ButtonNoteIdProvider | null;
|
||||
}
|
||||
|
||||
export default class CommandButtonWidget extends AbstractButtonWidget<CommandButtonWidgetSettings> {
|
||||
constructor() {
|
||||
super();
|
||||
this.settings = {
|
||||
titlePlacement: "right",
|
||||
title: null,
|
||||
icon: null,
|
||||
onContextMenu: null
|
||||
};
|
||||
}
|
||||
|
||||
doRender() {
|
||||
super.doRender();
|
||||
|
||||
if (this.settings.command) {
|
||||
this.$widget.on("click", () => {
|
||||
this.tooltip.hide();
|
||||
|
||||
if (this._command) {
|
||||
this.triggerCommand(this._command);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.warn(`Button widget '${this.componentId}' has no defined command`, this.settings);
|
||||
}
|
||||
}
|
||||
|
||||
getTitle() {
|
||||
const title = super.getTitle();
|
||||
|
||||
const action = actions.find((act) => act.actionName === this._command);
|
||||
|
||||
if (action?.effectiveShortcuts && action.effectiveShortcuts.length > 0) {
|
||||
return `${title} (${action.effectiveShortcuts.join(", ")})`;
|
||||
} else {
|
||||
return title;
|
||||
}
|
||||
}
|
||||
|
||||
onClick(handler: ClickHandler) {
|
||||
this.settings.onClick = handler;
|
||||
return this;
|
||||
}
|
||||
|
||||
command(command: CommandOrCallback) {
|
||||
this.settings.command = command;
|
||||
return this;
|
||||
}
|
||||
|
||||
get _command() {
|
||||
return typeof this.settings.command === "function" ? this.settings.command() : this.settings.command;
|
||||
}
|
||||
}
|
||||
@ -1,90 +0,0 @@
|
||||
import utils from "../../services/utils.js";
|
||||
import contextMenu, { MenuCommandItem } from "../../menus/context_menu.js";
|
||||
import treeService from "../../services/tree.js";
|
||||
import ButtonFromNoteWidget from "./button_from_note.js";
|
||||
import type FNote from "../../entities/fnote.js";
|
||||
import type { CommandNames } from "../../components/app_context.js";
|
||||
import type { WebContents } from "electron";
|
||||
import link from "../../services/link.js";
|
||||
|
||||
export default class HistoryNavigationButton extends ButtonFromNoteWidget {
|
||||
private webContents?: WebContents;
|
||||
|
||||
constructor(launcherNote: FNote, command: string) {
|
||||
super();
|
||||
|
||||
this.title(() => launcherNote.title)
|
||||
.icon(() => launcherNote.getIcon())
|
||||
.command(() => command as CommandNames)
|
||||
.titlePlacement("right")
|
||||
.buttonNoteIdProvider(() => launcherNote.noteId)
|
||||
.onContextMenu((e) => { if (e) this.showContextMenu(e); })
|
||||
.class("launcher-button");
|
||||
}
|
||||
|
||||
doRender() {
|
||||
super.doRender();
|
||||
|
||||
if (utils.isElectron()) {
|
||||
this.webContents = utils.dynamicRequire("@electron/remote").getCurrentWebContents();
|
||||
|
||||
// without this, the history is preserved across frontend reloads
|
||||
this.webContents?.clearHistory();
|
||||
|
||||
this.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
async showContextMenu(e: JQuery.ContextMenuEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
if (!this.webContents || this.webContents.navigationHistory.length() < 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
let items: MenuCommandItem<string>[] = [];
|
||||
|
||||
const history = this.webContents.navigationHistory.getAllEntries();
|
||||
const activeIndex = this.webContents.navigationHistory.getActiveIndex();
|
||||
|
||||
for (const idx in history) {
|
||||
const { notePath } = link.parseNavigationStateFromUrl(history[idx].url);
|
||||
if (!notePath) continue;
|
||||
|
||||
const title = await treeService.getNotePathTitle(notePath);
|
||||
|
||||
items.push({
|
||||
title,
|
||||
command: idx,
|
||||
uiIcon:
|
||||
parseInt(idx) === activeIndex
|
||||
? "bx bx-radio-circle-marked" // compare with type coercion!
|
||||
: parseInt(idx) < activeIndex
|
||||
? "bx bx-left-arrow-alt"
|
||||
: "bx bx-right-arrow-alt"
|
||||
});
|
||||
}
|
||||
|
||||
items.reverse();
|
||||
|
||||
if (items.length > 20) {
|
||||
items = items.slice(0, 50);
|
||||
}
|
||||
|
||||
contextMenu.show({
|
||||
x: e.pageX,
|
||||
y: e.pageY,
|
||||
items,
|
||||
selectMenuItemHandler: (item: MenuCommandItem<string>) => {
|
||||
if (item && item.command && this.webContents) {
|
||||
const idx = parseInt(item.command, 10);
|
||||
this.webContents.navigationHistory.goToIndex(idx);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
activeNoteChangedEvent() {
|
||||
this.refresh();
|
||||
}
|
||||
}
|
||||
@ -1,99 +0,0 @@
|
||||
import { t } from "../../../services/i18n.js";
|
||||
import AbstractLauncher from "./abstract_launcher.js";
|
||||
import dialogService from "../../../services/dialog.js";
|
||||
import appContext from "../../../components/app_context.js";
|
||||
import utils from "../../../services/utils.js";
|
||||
import linkContextMenuService from "../../../menus/link_context_menu.js";
|
||||
import type FNote from "../../../entities/fnote.js";
|
||||
|
||||
// we're intentionally displaying the launcher title and icon instead of the target,
|
||||
// e.g. you want to make launchers to 2 mermaid diagrams which both have mermaid icon (ok),
|
||||
// but on the launchpad you want them distinguishable.
|
||||
// for titles, the note titles may follow a different scheme than maybe desirable on the launchpad
|
||||
// another reason is the discrepancy between what user sees on the launchpad and in the config (esp. icons).
|
||||
// The only downside is more work in setting up the typical case
|
||||
// where you actually want to have both title and icon in sync, but for those cases there are bookmarks
|
||||
export default class NoteLauncher extends AbstractLauncher {
|
||||
constructor(launcherNote: FNote) {
|
||||
super(launcherNote);
|
||||
|
||||
this.title(() => this.launcherNote.title)
|
||||
.icon(() => this.launcherNote.getIcon())
|
||||
.onClick((widget, evt) => this.launch(evt))
|
||||
.onAuxClick((widget, evt) => this.launch(evt))
|
||||
.onContextMenu(async (evt) => {
|
||||
let targetNoteId = await Promise.resolve(this.getTargetNoteId());
|
||||
|
||||
if (!targetNoteId || !evt) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hoistedNoteId = this.getHoistedNoteId();
|
||||
|
||||
linkContextMenuService.openContextMenu(targetNoteId, evt, {}, hoistedNoteId);
|
||||
});
|
||||
}
|
||||
|
||||
async launch(evt?: JQuery.ClickEvent | JQuery.ContextMenuEvent | JQuery.TriggeredEvent) {
|
||||
// await because subclass overrides can be async
|
||||
const targetNoteId = await this.getTargetNoteId();
|
||||
if (!targetNoteId || evt?.which === 3) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hoistedNoteId = await this.getHoistedNoteId();
|
||||
if (!hoistedNoteId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!evt) {
|
||||
// keyboard shortcut
|
||||
// TODO: Fix once tabManager is ported.
|
||||
//@ts-ignore
|
||||
await appContext.tabManager.openInSameTab(targetNoteId, hoistedNoteId);
|
||||
} else {
|
||||
const ctrlKey = utils.isCtrlKey(evt);
|
||||
const activate = evt.shiftKey ? true : false;
|
||||
|
||||
if ((evt.which === 1 && ctrlKey) || evt.which === 2) {
|
||||
// TODO: Fix once tabManager is ported.
|
||||
//@ts-ignore
|
||||
await appContext.tabManager.openInNewTab(targetNoteId, hoistedNoteId, activate);
|
||||
} else {
|
||||
// TODO: Fix once tabManager is ported.
|
||||
//@ts-ignore
|
||||
await appContext.tabManager.openInSameTab(targetNoteId, hoistedNoteId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getTargetNoteId(): void | string | Promise<string | undefined> {
|
||||
const targetNoteId = this.launcherNote.getRelationValue("target");
|
||||
|
||||
if (!targetNoteId) {
|
||||
dialogService.info(t("note_launcher.this_launcher_doesnt_define_target_note"));
|
||||
return;
|
||||
}
|
||||
|
||||
return targetNoteId;
|
||||
}
|
||||
|
||||
getHoistedNoteId() {
|
||||
return this.launcherNote.getRelationValue("hoistedNote") || appContext.tabManager.getActiveContext()?.hoistedNoteId;
|
||||
}
|
||||
|
||||
getTitle() {
|
||||
const shortcuts = this.launcherNote
|
||||
.getLabels("keyboardShortcut")
|
||||
.map((l) => l.value)
|
||||
.filter((v) => !!v)
|
||||
.join(", ");
|
||||
|
||||
let title = super.getTitle();
|
||||
if (shortcuts) {
|
||||
title += ` (${shortcuts})`;
|
||||
}
|
||||
|
||||
return title;
|
||||
}
|
||||
}
|
||||
@ -1,23 +0,0 @@
|
||||
import type FNote from "../../../entities/fnote.js";
|
||||
import AbstractLauncher from "./abstract_launcher.js";
|
||||
|
||||
export default class ScriptLauncher extends AbstractLauncher {
|
||||
constructor(launcherNote: FNote) {
|
||||
super(launcherNote);
|
||||
|
||||
this.title(() => this.launcherNote.title)
|
||||
.icon(() => this.launcherNote.getIcon())
|
||||
.onClick(() => this.launch());
|
||||
}
|
||||
|
||||
async launch() {
|
||||
if (this.launcherNote.isLabelTruthy("scriptInLauncherContent")) {
|
||||
await this.launcherNote.executeScript();
|
||||
} else {
|
||||
const script = await this.launcherNote.getRelationTarget("script");
|
||||
if (script) {
|
||||
await script.executeScript();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,15 +0,0 @@
|
||||
import NoteLauncher from "./note_launcher.js";
|
||||
import dateNotesService from "../../../services/date_notes.js";
|
||||
import appContext from "../../../components/app_context.js";
|
||||
|
||||
export default class TodayLauncher extends NoteLauncher {
|
||||
async getTargetNoteId() {
|
||||
const todayNote = await dateNotesService.getTodayNote();
|
||||
|
||||
return todayNote?.noteId;
|
||||
}
|
||||
|
||||
getHoistedNoteId() {
|
||||
return appContext.tabManager.getActiveContext()?.hoistedNoteId;
|
||||
}
|
||||
}
|
||||
@ -1,49 +0,0 @@
|
||||
import OnClickButtonWidget from "./onclick_button.js";
|
||||
import linkContextMenuService from "../../menus/link_context_menu.js";
|
||||
import utils from "../../services/utils.js";
|
||||
import appContext from "../../components/app_context.js";
|
||||
import type FNote from "../../entities/fnote.js";
|
||||
|
||||
export default class OpenNoteButtonWidget extends OnClickButtonWidget {
|
||||
|
||||
private noteToOpen: FNote;
|
||||
|
||||
constructor(noteToOpen: FNote) {
|
||||
super();
|
||||
|
||||
this.noteToOpen = noteToOpen;
|
||||
|
||||
this.title(() => utils.escapeHtml(this.noteToOpen.title))
|
||||
.icon(() => this.noteToOpen.getIcon())
|
||||
.onClick((widget, evt) => this.launch(evt))
|
||||
.onAuxClick((widget, evt) => this.launch(evt))
|
||||
.onContextMenu((evt) => {
|
||||
if (evt) {
|
||||
linkContextMenuService.openContextMenu(this.noteToOpen.noteId, evt);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async launch(evt: JQuery.ClickEvent | JQuery.TriggeredEvent | JQuery.ContextMenuEvent) {
|
||||
if (evt.which === 3) {
|
||||
return;
|
||||
}
|
||||
const hoistedNoteId = this.getHoistedNoteId();
|
||||
const ctrlKey = utils.isCtrlKey(evt);
|
||||
|
||||
if ((evt.which === 1 && ctrlKey) || evt.which === 2) {
|
||||
const activate = evt.shiftKey ? true : false;
|
||||
await appContext.tabManager.openInNewTab(this.noteToOpen.noteId, hoistedNoteId, activate);
|
||||
} else {
|
||||
await appContext.tabManager.openInSameTab(this.noteToOpen.noteId);
|
||||
}
|
||||
}
|
||||
|
||||
getHoistedNoteId() {
|
||||
return this.noteToOpen.getRelationValue("hoistedNote") || appContext.tabManager.getActiveContext()?.hoistedNoteId;
|
||||
}
|
||||
|
||||
initialRenderCompleteEvent() {
|
||||
// we trigger refresh above
|
||||
}
|
||||
}
|
||||
@ -1,21 +0,0 @@
|
||||
import { t } from "../../services/i18n.js";
|
||||
import protectedSessionHolder from "../../services/protected_session_holder.js";
|
||||
import CommandButtonWidget from "./command_button.js";
|
||||
|
||||
export default class ProtectedSessionStatusWidget extends CommandButtonWidget {
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.class("launcher-button");
|
||||
|
||||
this.settings.icon = () => (protectedSessionHolder.isProtectedSessionAvailable() ? "bx-check-shield" : "bx-shield-quarter");
|
||||
|
||||
this.settings.title = () => (protectedSessionHolder.isProtectedSessionAvailable() ? t("protected_session_status.active") : t("protected_session_status.inactive"));
|
||||
|
||||
this.settings.command = () => (protectedSessionHolder.isProtectedSessionAvailable() ? "leaveProtectedSession" : "enterProtectedSession");
|
||||
}
|
||||
|
||||
protectedSessionStartedEvent() {
|
||||
this.refreshIcon();
|
||||
}
|
||||
}
|
||||
@ -1,86 +0,0 @@
|
||||
import { handleRightToLeftPlacement } from "../../services/utils.js";
|
||||
import BasicWidget from "../basic_widget.js";
|
||||
import { Tooltip, Dropdown } from "bootstrap";
|
||||
type PopoverPlacement = Tooltip.PopoverPlacement;
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="dropdown right-dropdown-widget">
|
||||
<button type="button" data-bs-toggle="dropdown"
|
||||
aria-haspopup="true" aria-expanded="false"
|
||||
class="bx right-dropdown-button launcher-button">
|
||||
<div class="tooltip-trigger"></div>
|
||||
</button>
|
||||
|
||||
<div class="dropdown-menu"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
export default class RightDropdownButtonWidget extends BasicWidget {
|
||||
protected iconClass: string;
|
||||
protected title: string;
|
||||
protected dropdownTpl: string;
|
||||
protected settings: { titlePlacement: PopoverPlacement };
|
||||
protected $dropdownMenu!: JQuery<HTMLElement>;
|
||||
protected dropdown!: Dropdown;
|
||||
protected $tooltip!: JQuery<HTMLElement>;
|
||||
protected tooltip!: Tooltip;
|
||||
private dropdownClass?: string;
|
||||
public $dropdownContent!: JQuery<HTMLElement>;
|
||||
|
||||
constructor(title: string, iconClass: string, dropdownTpl: string, dropdownClass?: string) {
|
||||
super();
|
||||
|
||||
this.iconClass = iconClass;
|
||||
this.title = title;
|
||||
this.dropdownTpl = dropdownTpl;
|
||||
this.dropdownClass = dropdownClass;
|
||||
|
||||
this.settings = {
|
||||
titlePlacement: "right"
|
||||
};
|
||||
}
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
this.$dropdownMenu = this.$widget.find(".dropdown-menu");
|
||||
if (this.dropdownClass) {
|
||||
this.$dropdownMenu.addClass(this.dropdownClass);
|
||||
}
|
||||
this.dropdown = Dropdown.getOrCreateInstance(this.$widget.find("[data-bs-toggle='dropdown']")[0], {
|
||||
popperConfig: {
|
||||
placement: this.settings.titlePlacement,
|
||||
}
|
||||
});
|
||||
|
||||
this.$tooltip = this.$widget.find(".tooltip-trigger").attr("title", this.title);
|
||||
this.tooltip = new Tooltip(this.$tooltip[0], {
|
||||
placement: handleRightToLeftPlacement(this.settings.titlePlacement),
|
||||
fallbackPlacements: [ handleRightToLeftPlacement(this.settings.titlePlacement) ]
|
||||
});
|
||||
|
||||
this.$widget
|
||||
.find(".right-dropdown-button")
|
||||
.addClass(this.iconClass)
|
||||
.on("click", () => this.tooltip.hide())
|
||||
.on("mouseenter", () => this.tooltip.show())
|
||||
.on("mouseleave", () => this.tooltip.hide());
|
||||
|
||||
this.$widget.on("show.bs.dropdown", async () => {
|
||||
await this.dropdownShown();
|
||||
|
||||
const rect = this.$dropdownMenu[0].getBoundingClientRect();
|
||||
const windowHeight = $(window).height() || 0;
|
||||
const pixelsToBottom = windowHeight - rect.bottom;
|
||||
|
||||
if (pixelsToBottom < 0) {
|
||||
this.$dropdownMenu.css("top", pixelsToBottom);
|
||||
}
|
||||
});
|
||||
|
||||
this.$dropdownContent = $(this.dropdownTpl);
|
||||
this.$widget.find(".dropdown-menu").append(this.$dropdownContent);
|
||||
}
|
||||
|
||||
// to be overridden
|
||||
async dropdownShown(): Promise<void> {}
|
||||
}
|
||||
@ -1,133 +0,0 @@
|
||||
import CalendarWidget from "../buttons/calendar.js";
|
||||
import SpacerWidget from "../spacer.js";
|
||||
import BookmarkButtons from "../bookmark_buttons.js";
|
||||
import ProtectedSessionStatusWidget from "../buttons/protected_session_status.js";
|
||||
import SyncStatusWidget from "../sync_status.js";
|
||||
import BasicWidget from "../basic_widget.js";
|
||||
import NoteLauncher from "../buttons/launcher/note_launcher.js";
|
||||
import ScriptLauncher from "../buttons/launcher/script_launcher.js";
|
||||
import CommandButtonWidget from "../buttons/command_button.js";
|
||||
import utils from "../../services/utils.js";
|
||||
import TodayLauncher from "../buttons/launcher/today_launcher.js";
|
||||
import HistoryNavigationButton from "../buttons/history_navigation.js";
|
||||
import QuickSearchLauncherWidget from "../quick_search_launcher.js";
|
||||
import type FNote from "../../entities/fnote.js";
|
||||
import type { CommandNames } from "../../components/app_context.js";
|
||||
import AiChatButton from "../buttons/ai_chat_button.js";
|
||||
|
||||
interface InnerWidget extends BasicWidget {
|
||||
settings?: {
|
||||
titlePlacement: "bottom";
|
||||
};
|
||||
}
|
||||
|
||||
export default class LauncherWidget extends BasicWidget {
|
||||
private innerWidget!: InnerWidget;
|
||||
private isHorizontalLayout: boolean;
|
||||
|
||||
constructor(isHorizontalLayout: boolean) {
|
||||
super();
|
||||
|
||||
this.isHorizontalLayout = isHorizontalLayout;
|
||||
}
|
||||
|
||||
isEnabled() {
|
||||
return this.innerWidget.isEnabled();
|
||||
}
|
||||
|
||||
doRender() {
|
||||
this.$widget = this.innerWidget.render();
|
||||
}
|
||||
|
||||
async initLauncher(note: FNote) {
|
||||
if (note.type !== "launcher") {
|
||||
throw new Error(`Note '${note.noteId}' '${note.title}' is not a launcher even though it's in the launcher subtree`);
|
||||
}
|
||||
|
||||
if (!utils.isDesktop() && note.isLabelTruthy("desktopOnly")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const launcherType = note.getLabelValue("launcherType");
|
||||
|
||||
if (glob.TRILIUM_SAFE_MODE && launcherType === "customWidget") {
|
||||
return false;
|
||||
}
|
||||
|
||||
let widget: BasicWidget;
|
||||
if (launcherType === "command") {
|
||||
widget = this.initCommandLauncherWidget(note).class("launcher-button");
|
||||
} else if (launcherType === "note") {
|
||||
widget = new NoteLauncher(note).class("launcher-button");
|
||||
} else if (launcherType === "script") {
|
||||
widget = new ScriptLauncher(note).class("launcher-button");
|
||||
} else if (launcherType === "customWidget") {
|
||||
widget = await this.initCustomWidget(note);
|
||||
} else if (launcherType === "builtinWidget") {
|
||||
widget = this.initBuiltinWidget(note);
|
||||
} else {
|
||||
throw new Error(`Unrecognized launcher type '${launcherType}' for launcher '${note.noteId}' title '${note.title}'`);
|
||||
}
|
||||
|
||||
if (!widget) {
|
||||
throw new Error(`Unknown initialization error for note '${note.noteId}', title '${note.title}'`);
|
||||
}
|
||||
|
||||
this.child(widget);
|
||||
this.innerWidget = widget as InnerWidget;
|
||||
if (this.isHorizontalLayout && this.innerWidget.settings) {
|
||||
this.innerWidget.settings.titlePlacement = "bottom";
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
initCommandLauncherWidget(note: FNote) {
|
||||
return new CommandButtonWidget()
|
||||
.title(() => note.title)
|
||||
.icon(() => note.getIcon())
|
||||
.command(() => note.getLabelValue("command") as CommandNames);
|
||||
}
|
||||
|
||||
async initCustomWidget(note: FNote) {
|
||||
const widget = await note.getRelationTarget("widget");
|
||||
|
||||
if (widget) {
|
||||
return await widget.executeScript();
|
||||
} else {
|
||||
throw new Error(`Custom widget of launcher '${note.noteId}' '${note.title}' is not defined.`);
|
||||
}
|
||||
}
|
||||
|
||||
initBuiltinWidget(note: FNote) {
|
||||
const builtinWidget = note.getLabelValue("builtinWidget");
|
||||
switch (builtinWidget) {
|
||||
case "calendar":
|
||||
return new CalendarWidget(note.title, note.getIcon());
|
||||
case "spacer":
|
||||
// || has to be inside since 0 is a valid value
|
||||
const baseSize = parseInt(note.getLabelValue("baseSize") || "40");
|
||||
const growthFactor = parseInt(note.getLabelValue("growthFactor") || "100");
|
||||
|
||||
return new SpacerWidget(baseSize, growthFactor);
|
||||
case "bookmarks":
|
||||
return new BookmarkButtons(this.isHorizontalLayout);
|
||||
case "protectedSession":
|
||||
return new ProtectedSessionStatusWidget();
|
||||
case "syncStatus":
|
||||
return new SyncStatusWidget();
|
||||
case "backInHistoryButton":
|
||||
return new HistoryNavigationButton(note, "backInNoteHistory");
|
||||
case "forwardInHistoryButton":
|
||||
return new HistoryNavigationButton(note, "forwardInNoteHistory");
|
||||
case "todayInJournal":
|
||||
return new TodayLauncher(note);
|
||||
case "quickSearch":
|
||||
return new QuickSearchLauncherWidget(this.isHorizontalLayout);
|
||||
case "aiChatLauncher":
|
||||
return new AiChatButton(note);
|
||||
default:
|
||||
throw new Error(`Unrecognized builtin widget ${builtinWidget} for launcher ${note.noteId} "${note.title}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,78 +0,0 @@
|
||||
import FlexContainer from "./flex_container.js";
|
||||
import froca from "../../services/froca.js";
|
||||
import appContext, { type EventData } from "../../components/app_context.js";
|
||||
import LauncherWidget from "./launcher.js";
|
||||
import utils from "../../services/utils.js";
|
||||
|
||||
export default class LauncherContainer extends FlexContainer<LauncherWidget> {
|
||||
private isHorizontalLayout: boolean;
|
||||
|
||||
constructor(isHorizontalLayout: boolean) {
|
||||
super(isHorizontalLayout ? "row" : "column");
|
||||
|
||||
this.id("launcher-container");
|
||||
this.filling();
|
||||
this.isHorizontalLayout = isHorizontalLayout;
|
||||
|
||||
this.load();
|
||||
}
|
||||
|
||||
async load() {
|
||||
await froca.initializedPromise;
|
||||
|
||||
const visibleLaunchersRootId = utils.isMobile() ? "_lbMobileVisibleLaunchers" : "_lbVisibleLaunchers";
|
||||
const visibleLaunchersRoot = await froca.getNote(visibleLaunchersRootId, true);
|
||||
|
||||
if (!visibleLaunchersRoot) {
|
||||
console.log("Visible launchers root note doesn't exist.");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const newChildren: LauncherWidget[] = [];
|
||||
|
||||
for (const launcherNote of await visibleLaunchersRoot.getChildNotes()) {
|
||||
try {
|
||||
const launcherWidget = new LauncherWidget(this.isHorizontalLayout);
|
||||
const success = await launcherWidget.initLauncher(launcherNote);
|
||||
|
||||
if (success) {
|
||||
newChildren.push(launcherWidget);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
this.children = [];
|
||||
this.child(...newChildren);
|
||||
|
||||
this.$widget.empty();
|
||||
this.renderChildren();
|
||||
|
||||
await this.handleEventInChildren("initialRenderComplete", {});
|
||||
|
||||
const activeContext = appContext.tabManager.getActiveContext();
|
||||
|
||||
if (activeContext) {
|
||||
await this.handleEvent("setNoteContext", {
|
||||
noteContext: activeContext
|
||||
});
|
||||
|
||||
if (activeContext.notePath) {
|
||||
await this.handleEvent("noteSwitched", {
|
||||
noteContext: activeContext,
|
||||
notePath: activeContext.notePath
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
|
||||
if (loadResults.getBranchRows().find((branch) => branch.parentNoteId && froca.getNoteFromCache(branch.parentNoteId)?.isLaunchBarConfig())) {
|
||||
// changes in note placement require reload of all launchers, all other changes are handled by individual
|
||||
// launchers
|
||||
this.load();
|
||||
}
|
||||
}
|
||||
}
|
||||
31
apps/client/src/widgets/launch_bar/BookmarkButtons.css
Normal file
31
apps/client/src/widgets/launch_bar/BookmarkButtons.css
Normal file
@ -0,0 +1,31 @@
|
||||
.bookmark-folder-widget {
|
||||
min-width: 400px;
|
||||
max-height: 500px;
|
||||
padding: 7px 15px 0 15px;
|
||||
font-size: 1.2rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.bookmark-folder-widget ul {
|
||||
padding: 0;
|
||||
list-style-type: none;
|
||||
}
|
||||
|
||||
.bookmark-folder-widget .note-link {
|
||||
display: block;
|
||||
padding: 5px 10px 5px 5px;
|
||||
}
|
||||
|
||||
.bookmark-folder-widget .note-link:hover {
|
||||
background-color: var(--accented-background-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.dropdown-menu .bookmark-folder-widget a:hover:not(.disabled) {
|
||||
text-decoration: none;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
.bookmark-folder-widget li .note-link {
|
||||
padding-inline-start: 35px;
|
||||
}
|
||||
59
apps/client/src/widgets/launch_bar/BookmarkButtons.tsx
Normal file
59
apps/client/src/widgets/launch_bar/BookmarkButtons.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
import { useContext, useMemo } from "preact/hooks";
|
||||
import { LaunchBarContext, LaunchBarDropdownButton, useLauncherIconAndTitle } from "./launch_bar_widgets";
|
||||
import { CSSProperties } from "preact";
|
||||
import type FNote from "../../entities/fnote";
|
||||
import { useChildNotes, useNoteLabelBoolean } from "../react/hooks";
|
||||
import "./BookmarkButtons.css";
|
||||
import NoteLink from "../react/NoteLink";
|
||||
import { CustomNoteLauncher } from "./GenericButtons";
|
||||
|
||||
const PARENT_NOTE_ID = "_lbBookmarks";
|
||||
|
||||
export default function BookmarkButtons() {
|
||||
const { isHorizontalLayout } = useContext(LaunchBarContext);
|
||||
const style = useMemo<CSSProperties>(() => ({
|
||||
display: "flex",
|
||||
flexDirection: isHorizontalLayout ? "row" : "column",
|
||||
contain: "none"
|
||||
}), [ isHorizontalLayout ]);
|
||||
const childNotes = useChildNotes(PARENT_NOTE_ID);
|
||||
|
||||
return (
|
||||
<div style={style}>
|
||||
{childNotes?.map(childNote => <SingleBookmark key={childNote.noteId} note={childNote} />)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SingleBookmark({ note }: { note: FNote }) {
|
||||
const [ bookmarkFolder ] = useNoteLabelBoolean(note, "bookmarkFolder");
|
||||
return bookmarkFolder
|
||||
? <BookmarkFolder note={note} />
|
||||
: <CustomNoteLauncher launcherNote={note} getTargetNoteId={() => note.noteId} />
|
||||
}
|
||||
|
||||
function BookmarkFolder({ note }: { note: FNote }) {
|
||||
const { icon, title } = useLauncherIconAndTitle(note);
|
||||
const childNotes = useChildNotes(note.noteId);
|
||||
|
||||
return (
|
||||
<LaunchBarDropdownButton
|
||||
icon={icon}
|
||||
title={title}
|
||||
>
|
||||
<div className="bookmark-folder-widget">
|
||||
<div className="parent-note">
|
||||
<NoteLink notePath={note.noteId} noPreview showNoteIcon containerClassName="note-link" noTnLink />
|
||||
</div>
|
||||
|
||||
<ul className="children-notes">
|
||||
{childNotes.map(childNote => (
|
||||
<li key={childNote.noteId}>
|
||||
<NoteLink notePath={childNote.noteId} noPreview showNoteIcon containerClassName="note-link" noTnLink />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</LaunchBarDropdownButton>
|
||||
)
|
||||
}
|
||||
221
apps/client/src/widgets/launch_bar/Calendar.tsx
Normal file
221
apps/client/src/widgets/launch_bar/Calendar.tsx
Normal file
@ -0,0 +1,221 @@
|
||||
import { useTriliumOptionInt } from "../react/hooks";
|
||||
import clsx from "clsx";
|
||||
import server from "../../services/server";
|
||||
import { TargetedMouseEvent, VNode } from "preact";
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
import { Dayjs } from "@triliumnext/commons";
|
||||
import { t } from "../../services/i18n";
|
||||
|
||||
interface DateNotesForMonth {
|
||||
[date: string]: string;
|
||||
}
|
||||
|
||||
const DAYS_OF_WEEK = [
|
||||
t("calendar.sun"),
|
||||
t("calendar.mon"),
|
||||
t("calendar.tue"),
|
||||
t("calendar.wed"),
|
||||
t("calendar.thu"),
|
||||
t("calendar.fri"),
|
||||
t("calendar.sat")
|
||||
];
|
||||
|
||||
interface DateRangeInfo {
|
||||
weekNumbers: number[];
|
||||
dates: Dayjs[];
|
||||
}
|
||||
|
||||
export interface CalendarArgs {
|
||||
date: Dayjs;
|
||||
todaysDate: Dayjs;
|
||||
activeDate: Dayjs | null;
|
||||
onDateClicked(date: string, e: TargetedMouseEvent<HTMLAnchorElement>): void;
|
||||
onWeekClicked?: (week: string, e: TargetedMouseEvent<HTMLAnchorElement>) => void;
|
||||
weekNotes: string[];
|
||||
}
|
||||
|
||||
export default function Calendar(args: CalendarArgs) {
|
||||
const [ rawFirstDayOfWeek ] = useTriliumOptionInt("firstDayOfWeek");
|
||||
const firstDayOfWeekISO = (rawFirstDayOfWeek === 0 ? 7 : rawFirstDayOfWeek);
|
||||
|
||||
const date = args.date;
|
||||
const firstDay = date.startOf('month');
|
||||
const firstDayISO = firstDay.isoWeekday();
|
||||
const monthInfo = getMonthInformation(date, firstDayISO, firstDayOfWeekISO);
|
||||
|
||||
return (
|
||||
<>
|
||||
<CalendarWeekHeader rawFirstDayOfWeek={rawFirstDayOfWeek} />
|
||||
<div className="calendar-body" data-calendar-area="month">
|
||||
{firstDayISO !== firstDayOfWeekISO && <PreviousMonthDays info={monthInfo.prevMonth} {...args} />}
|
||||
<CurrentMonthDays firstDayOfWeekISO={firstDayOfWeekISO} {...args} />
|
||||
<NextMonthDays dates={monthInfo.nextMonth.dates} {...args} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function CalendarWeekHeader({ rawFirstDayOfWeek }: { rawFirstDayOfWeek: number }) {
|
||||
let localeDaysOfWeek = [...DAYS_OF_WEEK];
|
||||
const shifted = localeDaysOfWeek.splice(0, rawFirstDayOfWeek);
|
||||
localeDaysOfWeek = ['', ...localeDaysOfWeek, ...shifted];
|
||||
|
||||
return (
|
||||
<div className="calendar-week">
|
||||
{localeDaysOfWeek.map(dayOfWeek => <span key={dayOfWeek}>{dayOfWeek}</span>)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PreviousMonthDays({ date, info: { dates, weekNumbers }, ...args }: { date: Dayjs, info: DateRangeInfo } & CalendarArgs) {
|
||||
const prevMonth = date.subtract(1, 'month').format('YYYY-MM');
|
||||
const [ dateNotesForPrevMonth, setDateNotesForPrevMonth ] = useState<DateNotesForMonth>();
|
||||
|
||||
useEffect(() => {
|
||||
server.get<DateNotesForMonth>(`special-notes/notes-for-month/${prevMonth}`).then(setDateNotesForPrevMonth);
|
||||
}, [ date ]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<CalendarWeek date={date} weekNumber={weekNumbers[0]} {...args} />
|
||||
{dates.map(date => <CalendarDay key={date.toISOString()} date={date} dateNotesForMonth={dateNotesForPrevMonth} className="calendar-date-prev-month" {...args} />)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function CurrentMonthDays({ date, firstDayOfWeekISO, ...args }: { date: Dayjs, firstDayOfWeekISO: number } & CalendarArgs) {
|
||||
let dateCursor = date;
|
||||
const currentMonth = date.month();
|
||||
const items: VNode[] = [];
|
||||
const curMonthString = date.format('YYYY-MM');
|
||||
const [ dateNotesForCurMonth, setDateNotesForCurMonth ] = useState<DateNotesForMonth>();
|
||||
|
||||
useEffect(() => {
|
||||
server.get<DateNotesForMonth>(`special-notes/notes-for-month/${curMonthString}`).then(setDateNotesForCurMonth);
|
||||
}, [ date ]);
|
||||
|
||||
while (dateCursor.month() === currentMonth) {
|
||||
const weekNumber = getWeekNumber(dateCursor, firstDayOfWeekISO);
|
||||
if (dateCursor.isoWeekday() === firstDayOfWeekISO) {
|
||||
items.push(<CalendarWeek key={`${dateCursor.year()}-W${weekNumber}`} date={dateCursor} weekNumber={weekNumber} {...args}/>)
|
||||
}
|
||||
|
||||
items.push(<CalendarDay key={dateCursor.toISOString()} date={dateCursor} dateNotesForMonth={dateNotesForCurMonth} {...args} />)
|
||||
dateCursor = dateCursor.add(1, "day");
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
function NextMonthDays({ date, dates, ...args }: { date: Dayjs, dates: Dayjs[] } & CalendarArgs) {
|
||||
const nextMonth = date.add(1, 'month').format('YYYY-MM');
|
||||
const [ dateNotesForNextMonth, setDateNotesForNextMonth ] = useState<DateNotesForMonth>();
|
||||
|
||||
useEffect(() => {
|
||||
server.get<DateNotesForMonth>(`special-notes/notes-for-month/${nextMonth}`).then(setDateNotesForNextMonth);
|
||||
}, [ date ]);
|
||||
|
||||
return dates.map(date => (
|
||||
<CalendarDay key={date.toISOString()} date={date} dateNotesForMonth={dateNotesForNextMonth} className="calendar-date-next-month" {...args} />
|
||||
));
|
||||
}
|
||||
|
||||
function CalendarDay({ date, dateNotesForMonth, className, activeDate, todaysDate, onDateClicked }: { date: Dayjs, dateNotesForMonth?: DateNotesForMonth, className?: string } & CalendarArgs) {
|
||||
const dateString = date.local().format('YYYY-MM-DD');
|
||||
const dateNoteId = dateNotesForMonth?.[dateString];
|
||||
return (
|
||||
<a
|
||||
className={clsx("calendar-date", className,
|
||||
dateNoteId && "calendar-date-exists",
|
||||
date.isSame(activeDate, "day") && "calendar-date-active",
|
||||
date.isSame(todaysDate, "day") && "calendar-date-today"
|
||||
)}
|
||||
data-calendar-date={date.local().format("YYYY-MM-DD")}
|
||||
data-href={dateNoteId && `#root/${dateNoteId}`}
|
||||
onClick={(e) => onDateClicked(dateString, e)}
|
||||
>
|
||||
<span>
|
||||
{date.date()}
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarWeek({ date, weekNumber, weekNotes, onWeekClicked }: { weekNumber: number, weekNotes: string[] } & Pick<CalendarArgs, "date" | "onWeekClicked">) {
|
||||
const localDate = date.local();
|
||||
|
||||
// Handle case where week is in between years.
|
||||
let year = localDate.year();
|
||||
if (localDate.month() === 11 && weekNumber === 1) year++;
|
||||
|
||||
const weekString = `${year}-W${String(weekNumber).padStart(2, '0')}`;
|
||||
|
||||
if (onWeekClicked) {
|
||||
return (
|
||||
<a
|
||||
className={clsx("calendar-week-number", "calendar-date",
|
||||
weekNotes.includes(weekString) && "calendar-date-exists")}
|
||||
data-calendar-week-number={weekNumber}
|
||||
data-date={date.local().format("YYYY-MM-DD")}
|
||||
onClick={(e) => onWeekClicked(weekString, e)}
|
||||
>{weekNumber}</a>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className="calendar-week-number calendar-week-number-disabled"
|
||||
data-calendar-week-number={weekNumber}
|
||||
>{weekNumber}</span>);
|
||||
}
|
||||
|
||||
export function getMonthInformation(date: Dayjs, firstDayISO: number, firstDayOfWeekISO: number) {
|
||||
return {
|
||||
prevMonth: getPrevMonthDays(date, firstDayISO, firstDayOfWeekISO),
|
||||
nextMonth: getNextMonthDays(date, firstDayOfWeekISO)
|
||||
}
|
||||
}
|
||||
|
||||
function getPrevMonthDays(date: Dayjs, firstDayISO: number, firstDayOfWeekISO: number): DateRangeInfo {
|
||||
const prevMonthLastDay = date.subtract(1, 'month').endOf('month');
|
||||
const daysToAdd = (firstDayISO - firstDayOfWeekISO + 7) % 7;
|
||||
const dates: Dayjs[] = [];
|
||||
|
||||
const firstDay = date.startOf('month');
|
||||
const weekNumber = getWeekNumber(firstDay, firstDayOfWeekISO);
|
||||
|
||||
// Get dates from previous month
|
||||
for (let i = daysToAdd - 1; i >= 0; i--) {
|
||||
dates.push(prevMonthLastDay.subtract(i, 'day'));
|
||||
}
|
||||
|
||||
return { weekNumbers: [ weekNumber ], dates };
|
||||
}
|
||||
|
||||
function getNextMonthDays(date: Dayjs, firstDayOfWeekISO: number): DateRangeInfo {
|
||||
const lastDayOfMonth = date.endOf('month');
|
||||
const lastDayISO = lastDayOfMonth.isoWeekday();
|
||||
const lastDayOfUserWeek = ((firstDayOfWeekISO + 6 - 1) % 7) + 1;
|
||||
const nextMonthFirstDay = date.add(1, 'month').startOf('month');
|
||||
const dates: Dayjs[] = [];
|
||||
|
||||
if (lastDayISO !== lastDayOfUserWeek) {
|
||||
const daysToAdd = (lastDayOfUserWeek - lastDayISO + 7) % 7;
|
||||
|
||||
for (let i = 0; i < daysToAdd; i++) {
|
||||
dates.push(nextMonthFirstDay.add(i, 'day'));
|
||||
}
|
||||
}
|
||||
return { weekNumbers: [], dates };
|
||||
}
|
||||
|
||||
export function getWeekNumber(date: Dayjs, firstDayOfWeekISO: number): number {
|
||||
const weekStart = getWeekStartDate(date, firstDayOfWeekISO);
|
||||
return weekStart.isoWeek();
|
||||
}
|
||||
|
||||
function getWeekStartDate(date: Dayjs, firstDayOfWeekISO: number): Dayjs {
|
||||
const currentISO = date.isoWeekday();
|
||||
const diff = (currentISO - firstDayOfWeekISO + 7) % 7;
|
||||
return date.clone().subtract(diff, "day").startOf("day");
|
||||
}
|
||||
@ -10,7 +10,6 @@
|
||||
|
||||
.calendar-dropdown-widget {
|
||||
margin: 0 auto;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@ -174,4 +173,13 @@
|
||||
background-color: var(--hover-item-background-color);
|
||||
color: var(--hover-item-text-color);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.calendar-dropdown-widget .form-control {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.calendar-dropdown-widget .calendar-month-selector .dropdown-menu {
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
188
apps/client/src/widgets/launch_bar/CalendarWidget.tsx
Normal file
188
apps/client/src/widgets/launch_bar/CalendarWidget.tsx
Normal file
@ -0,0 +1,188 @@
|
||||
import { Dispatch, StateUpdater, useMemo, useRef, useState } from "preact/hooks";
|
||||
import FNote from "../../entities/fnote";
|
||||
import { LaunchBarDropdownButton, LauncherNoteProps, useLauncherIconAndTitle } from "./launch_bar_widgets";
|
||||
import { Dayjs, dayjs } from "@triliumnext/commons";
|
||||
import appContext from "../../components/app_context";
|
||||
import "./CalendarWidget.css";
|
||||
import Calendar, { CalendarArgs } from "./Calendar";
|
||||
import ActionButton from "../react/ActionButton";
|
||||
import { t } from "../../services/i18n";
|
||||
import FormDropdownList from "../react/FormDropdownList";
|
||||
import FormTextBox from "../react/FormTextBox";
|
||||
import toast from "../../services/toast";
|
||||
import date_notes from "../../services/date_notes";
|
||||
import { Dropdown } from "bootstrap";
|
||||
import search from "../../services/search";
|
||||
import server from "../../services/server";
|
||||
|
||||
const MONTHS = [
|
||||
t("calendar.january"),
|
||||
t("calendar.february"),
|
||||
t("calendar.march"),
|
||||
t("calendar.april"),
|
||||
t("calendar.may"),
|
||||
t("calendar.june"),
|
||||
t("calendar.july"),
|
||||
t("calendar.august"),
|
||||
t("calendar.september"),
|
||||
t("calendar.october"),
|
||||
t("calendar.november"),
|
||||
t("calendar.december")
|
||||
];
|
||||
|
||||
export default function CalendarWidget({ launcherNote }: LauncherNoteProps) {
|
||||
const { title, icon } = useLauncherIconAndTitle(launcherNote);
|
||||
const [ calendarArgs, setCalendarArgs ] = useState<Pick<CalendarArgs, "activeDate" | "todaysDate">>();
|
||||
const [ date, setDate ] = useState<Dayjs>();
|
||||
const dropdownRef = useRef<Dropdown>(null);
|
||||
const [ enableWeekNotes, setEnableWeekNotes ] = useState(false);
|
||||
const [ weekNotes, setWeekNotes ] = useState<string[]>([]);
|
||||
const calendarRootRef = useRef<FNote>();
|
||||
|
||||
async function checkEnableWeekNotes() {
|
||||
if (!calendarRootRef.current) {
|
||||
const notes = await search.searchForNotes("#calendarRoot");
|
||||
if (!notes.length) return;
|
||||
calendarRootRef.current = notes[0];
|
||||
}
|
||||
|
||||
if (!calendarRootRef.current) return;
|
||||
|
||||
const enableWeekNotes = calendarRootRef.current.hasLabel("enableWeekNote");
|
||||
setEnableWeekNotes(enableWeekNotes);
|
||||
|
||||
if (enableWeekNotes) {
|
||||
server.get<string[]>(`attribute-values/weekNote`).then(setWeekNotes);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<LaunchBarDropdownButton
|
||||
icon={icon} title={title}
|
||||
onShown={async () => {
|
||||
const dateNote = appContext.tabManager.getActiveContextNote()?.getOwnedLabelValue("dateNote");
|
||||
const activeDate = dateNote ? dayjs(`${dateNote}T12:00:00`) : null
|
||||
const todaysDate = dayjs();
|
||||
setCalendarArgs({
|
||||
activeDate,
|
||||
todaysDate,
|
||||
});
|
||||
setDate(dayjs(activeDate || todaysDate).startOf('month'));
|
||||
try {
|
||||
await checkEnableWeekNotes();
|
||||
} catch (e: unknown) {
|
||||
// Non-critical.
|
||||
}
|
||||
}}
|
||||
dropdownRef={dropdownRef}
|
||||
dropdownOptions={{
|
||||
autoClose: "outside"
|
||||
}}
|
||||
>
|
||||
{calendarArgs && date && <div className="calendar-dropdown-widget" style={{ width: 400 }}>
|
||||
<CalendarHeader date={date} setDate={setDate} />
|
||||
<Calendar
|
||||
date={date}
|
||||
onDateClicked={async (date, e) => {
|
||||
const note = await date_notes.getDayNote(date);
|
||||
if (note) {
|
||||
appContext.tabManager.getActiveContext()?.setNote(note.noteId);
|
||||
dropdownRef.current?.hide();
|
||||
} else {
|
||||
toast.showError(t("calendar.cannot_find_day_note"));
|
||||
}
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onWeekClicked={enableWeekNotes ? async (week, e) => {
|
||||
const note = await date_notes.getWeekNote(week);
|
||||
if (note) {
|
||||
appContext.tabManager.getActiveContext()?.setNote(note.noteId);
|
||||
dropdownRef.current?.hide();
|
||||
} else {
|
||||
toast.showError(t("calendar.cannot_find_week_note"));
|
||||
}
|
||||
e.stopPropagation();
|
||||
} : undefined}
|
||||
weekNotes={weekNotes}
|
||||
{...calendarArgs}
|
||||
/>
|
||||
</div>}
|
||||
</LaunchBarDropdownButton>
|
||||
)
|
||||
}
|
||||
|
||||
interface CalendarHeaderProps {
|
||||
date: Dayjs;
|
||||
setDate: Dispatch<StateUpdater<Dayjs | undefined>>;
|
||||
}
|
||||
|
||||
function CalendarHeader(props: CalendarHeaderProps) {
|
||||
return (
|
||||
<div className="calendar-header">
|
||||
<CalendarMonthSelector {...props} />
|
||||
<CalendarYearSelector {...props} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CalendarMonthSelector({ date, setDate }: CalendarHeaderProps) {
|
||||
const months = useMemo(() => (
|
||||
Array.from(MONTHS.entries().map(([ index, text ]) => ({
|
||||
index: index.toString(), text
|
||||
})))
|
||||
), []);
|
||||
|
||||
return (
|
||||
<div className="calendar-month-selector">
|
||||
<AdjustDateButton date={date} setDate={setDate} direction="prev" unit="month" />
|
||||
<FormDropdownList
|
||||
values={months} currentValue={date.month().toString()}
|
||||
keyProperty="index" titleProperty="text"
|
||||
onChange={(index) => setDate(date.set("month", parseInt(index, 10)))}
|
||||
buttonProps={{ "data-calendar-input": "month" }}
|
||||
dropdownOptions={{ display: "static" }}
|
||||
/>
|
||||
<AdjustDateButton date={date} setDate={setDate} direction="next" unit="month" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarYearSelector({ date, setDate }: CalendarHeaderProps) {
|
||||
return (
|
||||
<div className="calendar-year-selector">
|
||||
<AdjustDateButton date={date} setDate={setDate} direction="prev" unit="year" />
|
||||
<FormTextBox
|
||||
type="number"
|
||||
min="1900" max="2999" step="1"
|
||||
currentValue={date.year().toString()}
|
||||
onChange={(newValue) => {
|
||||
const year = parseInt(newValue, 10);
|
||||
if (!Number.isNaN(year)) {
|
||||
setDate(date.set("year", year));
|
||||
}
|
||||
}}
|
||||
data-calendar-input="year"
|
||||
/>
|
||||
<AdjustDateButton date={date} setDate={setDate} direction="next" unit="year" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AdjustDateButton({ date, setDate, unit, direction }: CalendarHeaderProps & {
|
||||
direction: "prev" | "next",
|
||||
unit: "month" | "year"
|
||||
}) {
|
||||
return (
|
||||
<ActionButton
|
||||
icon={direction === "prev" ? "bx bx-chevron-left" : "bx bx-chevron-right" }
|
||||
className="calendar-btn tn-tool-button"
|
||||
noIconActionClass
|
||||
text=""
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const newDate = direction === "prev" ? date.subtract(1, unit) : date.add(1, unit);
|
||||
setDate(newDate);
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
55
apps/client/src/widgets/launch_bar/GenericButtons.tsx
Normal file
55
apps/client/src/widgets/launch_bar/GenericButtons.tsx
Normal file
@ -0,0 +1,55 @@
|
||||
import { useCallback } from "preact/hooks";
|
||||
import appContext from "../../components/app_context";
|
||||
import FNote from "../../entities/fnote";
|
||||
import link_context_menu from "../../menus/link_context_menu";
|
||||
import { escapeHtml, isCtrlKey } from "../../services/utils";
|
||||
import { useGlobalShortcut, useNoteLabel } from "../react/hooks";
|
||||
import { LaunchBarActionButton, useLauncherIconAndTitle } from "./launch_bar_widgets";
|
||||
|
||||
export function CustomNoteLauncher({ launcherNote, getTargetNoteId, getHoistedNoteId }: {
|
||||
launcherNote: FNote;
|
||||
getTargetNoteId: (launcherNote: FNote) => string | null | Promise<string | null>;
|
||||
getHoistedNoteId?: (launcherNote: FNote) => string | null;
|
||||
keyboardShortcut?: string;
|
||||
}) {
|
||||
const { icon, title } = useLauncherIconAndTitle(launcherNote);
|
||||
|
||||
const launch = useCallback(async (evt: MouseEvent | KeyboardEvent) => {
|
||||
if (evt.which === 3) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetNoteId = await getTargetNoteId(launcherNote);
|
||||
if (!targetNoteId) return;
|
||||
|
||||
const hoistedNoteIdWithDefault = getHoistedNoteId?.(launcherNote) || appContext.tabManager.getActiveContext()?.hoistedNoteId;
|
||||
const ctrlKey = isCtrlKey(evt);
|
||||
|
||||
if ((evt.which === 1 && ctrlKey) || evt.which === 2) {
|
||||
const activate = evt.shiftKey ? true : false;
|
||||
await appContext.tabManager.openInNewTab(targetNoteId, hoistedNoteIdWithDefault, activate);
|
||||
} else {
|
||||
await appContext.tabManager.openInSameTab(targetNoteId);
|
||||
}
|
||||
}, [ launcherNote, getTargetNoteId, getHoistedNoteId ]);
|
||||
|
||||
// Keyboard shortcut.
|
||||
const [ shortcut ] = useNoteLabel(launcherNote, "keyboardShortcut");
|
||||
useGlobalShortcut(shortcut, launch);
|
||||
|
||||
return (
|
||||
<LaunchBarActionButton
|
||||
icon={icon}
|
||||
text={escapeHtml(title)}
|
||||
onClick={launch}
|
||||
onAuxClick={launch}
|
||||
onContextMenu={async evt => {
|
||||
evt.preventDefault();
|
||||
const targetNoteId = await getTargetNoteId(launcherNote);
|
||||
if (targetNoteId) {
|
||||
link_context_menu.openContextMenu(targetNoteId, evt);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
86
apps/client/src/widgets/launch_bar/HistoryNavigation.tsx
Normal file
86
apps/client/src/widgets/launch_bar/HistoryNavigation.tsx
Normal file
@ -0,0 +1,86 @@
|
||||
import { useEffect, useRef } from "preact/hooks";
|
||||
import FNote from "../../entities/fnote";
|
||||
import { dynamicRequire, isElectron } from "../../services/utils";
|
||||
import { LaunchBarActionButton, useLauncherIconAndTitle } from "./launch_bar_widgets";
|
||||
import type { WebContents } from "electron";
|
||||
import contextMenu, { MenuCommandItem } from "../../menus/context_menu";
|
||||
import tree from "../../services/tree";
|
||||
import link from "../../services/link";
|
||||
|
||||
interface HistoryNavigationProps {
|
||||
launcherNote: FNote;
|
||||
command: "backInNoteHistory" | "forwardInNoteHistory";
|
||||
}
|
||||
|
||||
const HISTORY_LIMIT = 20;
|
||||
|
||||
export default function HistoryNavigationButton({ launcherNote, command }: HistoryNavigationProps) {
|
||||
const { icon, title } = useLauncherIconAndTitle(launcherNote);
|
||||
const webContentsRef = useRef<WebContents>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isElectron()) {
|
||||
const webContents = dynamicRequire("@electron/remote").getCurrentWebContents();
|
||||
// without this, the history is preserved across frontend reloads
|
||||
webContents?.clearHistory();
|
||||
webContentsRef.current = webContents;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<LaunchBarActionButton
|
||||
icon={icon}
|
||||
text={title}
|
||||
triggerCommand={command}
|
||||
onContextMenu={async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const webContents = webContentsRef.current;
|
||||
if (!webContents || webContents.navigationHistory.length() < 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
let items: MenuCommandItem<string>[] = [];
|
||||
|
||||
const history = webContents.navigationHistory.getAllEntries();
|
||||
const activeIndex = webContents.navigationHistory.getActiveIndex();
|
||||
|
||||
for (const idx in history) {
|
||||
const { notePath } = link.parseNavigationStateFromUrl(history[idx].url);
|
||||
if (!notePath) continue;
|
||||
|
||||
const title = await tree.getNotePathTitle(notePath);
|
||||
|
||||
items.push({
|
||||
title,
|
||||
command: idx,
|
||||
uiIcon:
|
||||
parseInt(idx) === activeIndex
|
||||
? "bx bx-radio-circle-marked" // compare with type coercion!
|
||||
: parseInt(idx) < activeIndex
|
||||
? "bx bx-left-arrow-alt"
|
||||
: "bx bx-right-arrow-alt"
|
||||
});
|
||||
}
|
||||
|
||||
items.reverse();
|
||||
|
||||
if (items.length > HISTORY_LIMIT) {
|
||||
items = items.slice(0, HISTORY_LIMIT);
|
||||
}
|
||||
|
||||
contextMenu.show({
|
||||
x: e.pageX,
|
||||
y: e.pageY,
|
||||
items,
|
||||
selectMenuItemHandler: (item: MenuCommandItem<string>) => {
|
||||
if (item && item.command && webContents) {
|
||||
const idx = parseInt(item.command, 10);
|
||||
webContents.navigationHistory.goToIndex(idx);
|
||||
}
|
||||
}
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
128
apps/client/src/widgets/launch_bar/LauncherContainer.tsx
Normal file
128
apps/client/src/widgets/launch_bar/LauncherContainer.tsx
Normal file
@ -0,0 +1,128 @@
|
||||
import { useCallback, useLayoutEffect, useState } from "preact/hooks";
|
||||
import FNote from "../../entities/fnote";
|
||||
import froca from "../../services/froca";
|
||||
import { isDesktop, isMobile } from "../../services/utils";
|
||||
import CalendarWidget from "./CalendarWidget";
|
||||
import SpacerWidget from "./SpacerWidget";
|
||||
import BookmarkButtons from "./BookmarkButtons";
|
||||
import ProtectedSessionStatusWidget from "./ProtectedSessionStatusWidget";
|
||||
import SyncStatus from "./SyncStatus";
|
||||
import HistoryNavigationButton from "./HistoryNavigation";
|
||||
import { AiChatButton, CommandButton, CustomWidget, NoteLauncher, QuickSearchLauncherWidget, ScriptLauncher, TodayLauncher } from "./LauncherDefinitions";
|
||||
import { useTriliumEvent } from "../react/hooks";
|
||||
import { onWheelHorizontalScroll } from "../widget_utils";
|
||||
import { LaunchBarContext } from "./launch_bar_widgets";
|
||||
|
||||
export default function LauncherContainer({ isHorizontalLayout }: { isHorizontalLayout: boolean }) {
|
||||
const childNotes = useLauncherChildNotes();
|
||||
|
||||
return (
|
||||
<div
|
||||
id="launcher-container"
|
||||
style={{
|
||||
display: "flex",
|
||||
flexGrow: 1,
|
||||
flexDirection: isHorizontalLayout ? "row" : "column"
|
||||
}}
|
||||
onWheel={isHorizontalLayout ? (e) => {
|
||||
if ((e.target as HTMLElement).closest(".dropdown-menu")) return;
|
||||
onWheelHorizontalScroll(e);
|
||||
} : undefined}
|
||||
>
|
||||
<LaunchBarContext.Provider value={{
|
||||
isHorizontalLayout
|
||||
}}>
|
||||
{childNotes?.map(childNote => {
|
||||
if (childNote.type !== "launcher") {
|
||||
throw new Error(`Note '${childNote.noteId}' '${childNote.title}' is not a launcher even though it's in the launcher subtree`);
|
||||
}
|
||||
|
||||
if (!isDesktop() && childNote.isLabelTruthy("desktopOnly")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return <Launcher key={childNote.noteId} note={childNote} isHorizontalLayout={isHorizontalLayout} />
|
||||
})}
|
||||
</LaunchBarContext.Provider>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Launcher({ note, isHorizontalLayout }: { note: FNote, isHorizontalLayout: boolean }) {
|
||||
const launcherType = note.getLabelValue("launcherType");
|
||||
if (glob.TRILIUM_SAFE_MODE && launcherType === "customWidget") return;
|
||||
|
||||
switch (launcherType) {
|
||||
case "command":
|
||||
return <CommandButton launcherNote={note} />;
|
||||
case "note":
|
||||
return <NoteLauncher launcherNote={note} />;
|
||||
case "script":
|
||||
return <ScriptLauncher launcherNote={note} />;
|
||||
case "customWidget":
|
||||
return <CustomWidget launcherNote={note} />;
|
||||
case "builtinWidget":
|
||||
return initBuiltinWidget(note, isHorizontalLayout);
|
||||
default:
|
||||
throw new Error(`Unrecognized launcher type '${launcherType}' for launcher '${note.noteId}' title '${note.title}'`);
|
||||
}
|
||||
}
|
||||
|
||||
function initBuiltinWidget(note: FNote, isHorizontalLayout: boolean) {
|
||||
const builtinWidget = note.getLabelValue("builtinWidget");
|
||||
switch (builtinWidget) {
|
||||
case "calendar":
|
||||
return <CalendarWidget launcherNote={note} />
|
||||
case "spacer":
|
||||
// || has to be inside since 0 is a valid value
|
||||
const baseSize = parseInt(note.getLabelValue("baseSize") || "40");
|
||||
const growthFactor = parseInt(note.getLabelValue("growthFactor") || "100");
|
||||
|
||||
return <SpacerWidget baseSize={baseSize} growthFactor={growthFactor} />;
|
||||
case "bookmarks":
|
||||
return <BookmarkButtons />;
|
||||
case "protectedSession":
|
||||
return <ProtectedSessionStatusWidget />;
|
||||
case "syncStatus":
|
||||
return <SyncStatus />;
|
||||
case "backInHistoryButton":
|
||||
return <HistoryNavigationButton launcherNote={note} command="backInNoteHistory" />
|
||||
case "forwardInHistoryButton":
|
||||
return <HistoryNavigationButton launcherNote={note} command="forwardInNoteHistory" />
|
||||
case "todayInJournal":
|
||||
return <TodayLauncher launcherNote={note} />
|
||||
case "quickSearch":
|
||||
return <QuickSearchLauncherWidget />
|
||||
case "aiChatLauncher":
|
||||
return <AiChatButton launcherNote={note} />
|
||||
default:
|
||||
throw new Error(`Unrecognized builtin widget ${builtinWidget} for launcher ${note.noteId} "${note.title}"`);
|
||||
}
|
||||
}
|
||||
|
||||
function useLauncherChildNotes() {
|
||||
const [ visibleLaunchersRoot, setVisibleLaunchersRoot ] = useState<FNote | undefined | null>();
|
||||
const [ childNotes, setChildNotes ] = useState<FNote[]>();
|
||||
|
||||
// Load the root note.
|
||||
useLayoutEffect(() => {
|
||||
const visibleLaunchersRootId = isMobile() ? "_lbMobileVisibleLaunchers" : "_lbVisibleLaunchers";
|
||||
froca.getNote(visibleLaunchersRootId, true).then(setVisibleLaunchersRoot);
|
||||
}, []);
|
||||
|
||||
// Load the children.
|
||||
const refresh = useCallback(() => {
|
||||
if (!visibleLaunchersRoot) return;
|
||||
visibleLaunchersRoot.getChildNotes().then(setChildNotes);
|
||||
}, [ visibleLaunchersRoot, setChildNotes ]);
|
||||
useLayoutEffect(refresh, [ visibleLaunchersRoot ]);
|
||||
|
||||
// React to position changes.
|
||||
useTriliumEvent("entitiesReloaded", ({loadResults}) => {
|
||||
if (loadResults.getBranchRows().find((branch) => branch.parentNoteId && froca.getNoteFromCache(branch.parentNoteId)?.isLaunchBarConfig())) {
|
||||
refresh();
|
||||
}
|
||||
});
|
||||
|
||||
return childNotes;
|
||||
}
|
||||
160
apps/client/src/widgets/launch_bar/LauncherDefinitions.tsx
Normal file
160
apps/client/src/widgets/launch_bar/LauncherDefinitions.tsx
Normal file
@ -0,0 +1,160 @@
|
||||
import { useCallback, useContext, useEffect, useMemo, useState } from "preact/hooks";
|
||||
import { useGlobalShortcut, useLegacyWidget, useNoteLabel, useNoteRelationTarget, useTriliumOptionBool } from "../react/hooks";
|
||||
import { ParentComponent } from "../react/react_utils";
|
||||
import BasicWidget from "../basic_widget";
|
||||
import FNote from "../../entities/fnote";
|
||||
import QuickSearchWidget from "../quick_search";
|
||||
import { getErrorMessage, isMobile } from "../../services/utils";
|
||||
import date_notes from "../../services/date_notes";
|
||||
import { CustomNoteLauncher } from "./GenericButtons";
|
||||
import { LaunchBarActionButton, LaunchBarContext, LauncherNoteProps, useLauncherIconAndTitle } from "./launch_bar_widgets";
|
||||
import dialog from "../../services/dialog";
|
||||
import { t } from "../../services/i18n";
|
||||
import appContext, { CommandNames } from "../../components/app_context";
|
||||
import toast from "../../services/toast";
|
||||
|
||||
export function CommandButton({ launcherNote }: LauncherNoteProps) {
|
||||
const { icon, title } = useLauncherIconAndTitle(launcherNote);
|
||||
const [ command ] = useNoteLabel(launcherNote, "command");
|
||||
|
||||
return command && (
|
||||
<LaunchBarActionButton
|
||||
icon={icon}
|
||||
text={title}
|
||||
triggerCommand={command as CommandNames}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// we're intentionally displaying the launcher title and icon instead of the target,
|
||||
// e.g. you want to make launchers to 2 mermaid diagrams which both have mermaid icon (ok),
|
||||
// but on the launchpad you want them distinguishable.
|
||||
// for titles, the note titles may follow a different scheme than maybe desirable on the launchpad
|
||||
// another reason is the discrepancy between what user sees on the launchpad and in the config (esp. icons).
|
||||
// The only downside is more work in setting up the typical case
|
||||
// where you actually want to have both title and icon in sync, but for those cases there are bookmarks
|
||||
export function NoteLauncher({ launcherNote, ...restProps }: { launcherNote: FNote, hoistedNoteId?: string }) {
|
||||
return (
|
||||
<CustomNoteLauncher
|
||||
launcherNote={launcherNote}
|
||||
getTargetNoteId={(launcherNote) => {
|
||||
const targetNoteId = launcherNote.getRelationValue("target");
|
||||
if (!targetNoteId) {
|
||||
dialog.info(t("note_launcher.this_launcher_doesnt_define_target_note"));
|
||||
return null;
|
||||
}
|
||||
return targetNoteId;
|
||||
}}
|
||||
getHoistedNoteId={launcherNote => launcherNote.getRelationValue("hoistedNote")}
|
||||
{...restProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ScriptLauncher({ launcherNote }: LauncherNoteProps) {
|
||||
const { icon, title } = useLauncherIconAndTitle(launcherNote);
|
||||
|
||||
const launch = useCallback(async () => {
|
||||
if (launcherNote.isLabelTruthy("scriptInLauncherContent")) {
|
||||
await launcherNote.executeScript();
|
||||
} else {
|
||||
const script = await launcherNote.getRelationTarget("script");
|
||||
if (script) {
|
||||
await script.executeScript();
|
||||
}
|
||||
}
|
||||
}, [ launcherNote ]);
|
||||
|
||||
// Keyboard shortcut.
|
||||
const [ shortcut ] = useNoteLabel(launcherNote, "keyboardShortcut");
|
||||
useGlobalShortcut(shortcut, launch);
|
||||
|
||||
return (
|
||||
<LaunchBarActionButton
|
||||
icon={icon}
|
||||
text={title}
|
||||
onClick={launch}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function AiChatButton({ launcherNote }: LauncherNoteProps) {
|
||||
const [ aiEnabled ] = useTriliumOptionBool("aiEnabled");
|
||||
const { icon, title } = useLauncherIconAndTitle(launcherNote);
|
||||
|
||||
return aiEnabled && (
|
||||
<LaunchBarActionButton
|
||||
icon={icon}
|
||||
text={title}
|
||||
triggerCommand="createAiChat"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function TodayLauncher({ launcherNote }: LauncherNoteProps) {
|
||||
return (
|
||||
<CustomNoteLauncher
|
||||
launcherNote={launcherNote}
|
||||
getTargetNoteId={async () => {
|
||||
const todayNote = await date_notes.getTodayNote();
|
||||
return todayNote?.noteId ?? null;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function QuickSearchLauncherWidget() {
|
||||
const { isHorizontalLayout } = useContext(LaunchBarContext);
|
||||
const widget = useMemo(() => new QuickSearchWidget(), []);
|
||||
const parentComponent = useContext(ParentComponent) as BasicWidget | null;
|
||||
const isEnabled = isHorizontalLayout && !isMobile();
|
||||
parentComponent?.contentSized();
|
||||
|
||||
return (
|
||||
<div>
|
||||
{isEnabled && <LegacyWidgetRenderer widget={widget} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function CustomWidget({ launcherNote }: LauncherNoteProps) {
|
||||
const [ widgetNote ] = useNoteRelationTarget(launcherNote, "widget");
|
||||
const [ widget, setWidget ] = useState<BasicWidget>();
|
||||
const parentComponent = useContext(ParentComponent) as BasicWidget | null;
|
||||
parentComponent?.contentSized();
|
||||
|
||||
useEffect(() => {
|
||||
(async function() {
|
||||
let widget: BasicWidget;
|
||||
try {
|
||||
widget = await widgetNote?.executeScript();
|
||||
} catch (e) {
|
||||
toast.showError(t("toast.bundle-error.message", {
|
||||
id: widgetNote?.noteId,
|
||||
title: widgetNote?.title,
|
||||
message: getErrorMessage(e)
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (widgetNote && widget instanceof BasicWidget) {
|
||||
widget._noteId = widgetNote.noteId;
|
||||
}
|
||||
setWidget(widget);
|
||||
})();
|
||||
}, [ widgetNote ]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{widget && <LegacyWidgetRenderer widget={widget} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function LegacyWidgetRenderer({ widget }: { widget: BasicWidget }) {
|
||||
const [ widgetEl ] = useLegacyWidget(() => widget, {
|
||||
noteContext: appContext.tabManager.getActiveContext() ?? undefined
|
||||
});
|
||||
|
||||
return widgetEl;
|
||||
}
|
||||
@ -0,0 +1,33 @@
|
||||
import { useState } from "preact/hooks";
|
||||
import protected_session_holder from "../../services/protected_session_holder";
|
||||
import { LaunchBarActionButton } from "./launch_bar_widgets";
|
||||
import { useTriliumEvent } from "../react/hooks";
|
||||
import { t } from "../../services/i18n";
|
||||
|
||||
export default function ProtectedSessionStatusWidget() {
|
||||
const protectedSessionAvailable = useProtectedSessionAvailable();
|
||||
|
||||
return (
|
||||
protectedSessionAvailable ? (
|
||||
<LaunchBarActionButton
|
||||
icon="bx bx-check-shield"
|
||||
text={t("protected_session_status.active")}
|
||||
triggerCommand="leaveProtectedSession"
|
||||
/>
|
||||
) : (
|
||||
<LaunchBarActionButton
|
||||
icon="bx bx-shield-quarter"
|
||||
text={t("protected_session_status.inactive")}
|
||||
triggerCommand="enterProtectedSession"
|
||||
/>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function useProtectedSessionAvailable() {
|
||||
const [ protectedSessionAvailable, setProtectedSessionAvailable ] = useState(protected_session_holder.isProtectedSessionAvailable());
|
||||
useTriliumEvent("protectedSessionStarted", () => {
|
||||
setProtectedSessionAvailable(protected_session_holder.isProtectedSessionAvailable());
|
||||
});
|
||||
return protectedSessionAvailable;
|
||||
}
|
||||
35
apps/client/src/widgets/launch_bar/SpacerWidget.tsx
Normal file
35
apps/client/src/widgets/launch_bar/SpacerWidget.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
import appContext, { CommandNames } from "../../components/app_context";
|
||||
import contextMenu from "../../menus/context_menu";
|
||||
import { t } from "../../services/i18n";
|
||||
import { isMobile } from "../../services/utils";
|
||||
|
||||
interface SpacerWidgetProps {
|
||||
baseSize?: number;
|
||||
growthFactor?: number;
|
||||
}
|
||||
|
||||
export default function SpacerWidget({ baseSize, growthFactor }: SpacerWidgetProps) {
|
||||
return (
|
||||
<div
|
||||
className="spacer"
|
||||
style={{
|
||||
flexBasis: baseSize ?? 0,
|
||||
flexGrow: growthFactor ?? 1000,
|
||||
flexShrink: 1000
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
contextMenu.show<CommandNames>({
|
||||
x: e.pageX,
|
||||
y: e.pageY,
|
||||
items: [{ title: t("spacer.configure_launchbar"), command: "showLaunchBarSubtree", uiIcon: "bx " + (isMobile() ? "bx-mobile" : "bx-sidebar") }],
|
||||
selectMenuItemHandler: ({ command }) => {
|
||||
if (command) {
|
||||
appContext.triggerCommand(command);
|
||||
}
|
||||
}
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
26
apps/client/src/widgets/launch_bar/SyncStatus.css
Normal file
26
apps/client/src/widgets/launch_bar/SyncStatus.css
Normal file
@ -0,0 +1,26 @@
|
||||
.sync-status {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.sync-status .sync-status-icon {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
top: -5px;
|
||||
font-size: 110%;
|
||||
}
|
||||
|
||||
.sync-status .sync-status-sub-icon {
|
||||
font-size: 40%;
|
||||
position: absolute;
|
||||
inset-inline-start: 0;
|
||||
top: 16px;
|
||||
}
|
||||
|
||||
.sync-status .sync-status-icon span {
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.sync-status-icon:not(.sync-status-in-progress):hover {
|
||||
background-color: var(--hover-item-background-color);
|
||||
cursor: pointer;
|
||||
}
|
||||
118
apps/client/src/widgets/launch_bar/SyncStatus.tsx
Normal file
118
apps/client/src/widgets/launch_bar/SyncStatus.tsx
Normal file
@ -0,0 +1,118 @@
|
||||
import { useEffect, useRef, useState } from "preact/hooks";
|
||||
import "./SyncStatus.css";
|
||||
import { t } from "../../services/i18n";
|
||||
import clsx from "clsx";
|
||||
import { escapeQuotes } from "../../services/utils";
|
||||
import { useStaticTooltip, useTriliumOption } from "../react/hooks";
|
||||
import sync from "../../services/sync";
|
||||
import ws, { subscribeToMessages, unsubscribeToMessage } from "../../services/ws";
|
||||
import { WebSocketMessage } from "@triliumnext/commons";
|
||||
|
||||
type SyncState = "unknown" | "in-progress"
|
||||
| "connected-with-changes" | "connected-no-changes"
|
||||
| "disconnected-with-changes" | "disconnected-no-changes";
|
||||
|
||||
interface StateMapping {
|
||||
title: string;
|
||||
icon: string;
|
||||
hasChanges?: boolean;
|
||||
}
|
||||
|
||||
const STATE_MAPPINGS: Record<SyncState, StateMapping> = {
|
||||
unknown: {
|
||||
title: t("sync_status.unknown"),
|
||||
icon: "bx bx-time"
|
||||
},
|
||||
"connected-with-changes": {
|
||||
title: t("sync_status.connected_with_changes"),
|
||||
icon: "bx bx-wifi",
|
||||
hasChanges: true
|
||||
},
|
||||
"connected-no-changes": {
|
||||
title: t("sync_status.connected_no_changes"),
|
||||
icon: "bx bx-wifi"
|
||||
},
|
||||
"disconnected-with-changes": {
|
||||
title: t("sync_status.disconnected_with_changes"),
|
||||
icon: "bx bx-wifi-off",
|
||||
hasChanges: true
|
||||
},
|
||||
"disconnected-no-changes": {
|
||||
title: t("sync_status.disconnected_no_changes"),
|
||||
icon: "bx bx-wifi-off"
|
||||
},
|
||||
"in-progress": {
|
||||
title: t("sync_status.in_progress"),
|
||||
icon: "bx bx-analyse bx-spin"
|
||||
}
|
||||
};
|
||||
|
||||
export default function SyncStatus() {
|
||||
const syncState = useSyncStatus();
|
||||
const { title, icon, hasChanges } = STATE_MAPPINGS[syncState];
|
||||
const spanRef = useRef<HTMLSpanElement>(null);
|
||||
const [ syncServerHost ] = useTriliumOption("syncServerHost");
|
||||
useStaticTooltip(spanRef, {
|
||||
html: true
|
||||
// TODO: Placement
|
||||
});
|
||||
|
||||
return (syncServerHost &&
|
||||
<div class="sync-status-widget launcher-button">
|
||||
<div class="sync-status">
|
||||
<span
|
||||
ref={spanRef}
|
||||
className={clsx("sync-status-icon", `sync-status-${syncState}`, icon)}
|
||||
title={escapeQuotes(title)}
|
||||
onClick={() => {
|
||||
if (syncState === "in-progress") return;
|
||||
sync.syncNow();
|
||||
}}
|
||||
>
|
||||
{hasChanges && (
|
||||
<span class="bx bxs-star sync-status-sub-icon"></span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function useSyncStatus() {
|
||||
const [ syncState, setSyncState ] = useState<SyncState>("unknown");
|
||||
|
||||
useEffect(() => {
|
||||
let lastSyncedPush: number;
|
||||
|
||||
function onMessage(message: WebSocketMessage) {
|
||||
// First, read last synced push.
|
||||
if ("lastSyncedPush" in message) {
|
||||
lastSyncedPush = message.lastSyncedPush;
|
||||
} else if ("data" in message && message.data && "lastSyncedPush" in message.data && lastSyncedPush !== undefined) {
|
||||
lastSyncedPush = message.data.lastSyncedPush;
|
||||
}
|
||||
|
||||
// Determine if all changes were pushed.
|
||||
const allChangesPushed = lastSyncedPush === ws.getMaxKnownEntityChangeSyncId();
|
||||
|
||||
let syncState: SyncState = "unknown";
|
||||
if (message.type === "sync-pull-in-progress") {
|
||||
syncState = "in-progress";
|
||||
} else if (message.type === "sync-push-in-progress") {
|
||||
syncState = "in-progress";
|
||||
} else if (message.type === "sync-finished") {
|
||||
syncState = allChangesPushed ? "connected-no-changes" : "connected-with-changes";
|
||||
} else if (message.type === "sync-failed") {
|
||||
syncState = allChangesPushed ? "disconnected-no-changes" : "disconnected-with-changes";
|
||||
} else if (message.type === "frontend-update") {
|
||||
lastSyncedPush = message.data.lastSyncedPush;
|
||||
}
|
||||
setSyncState(syncState);
|
||||
}
|
||||
|
||||
subscribeToMessages(onMessage);
|
||||
return () => unsubscribeToMessage(onMessage);
|
||||
}, []);
|
||||
|
||||
return syncState;
|
||||
}
|
||||
67
apps/client/src/widgets/launch_bar/launch_bar_widgets.tsx
Normal file
67
apps/client/src/widgets/launch_bar/launch_bar_widgets.tsx
Normal file
@ -0,0 +1,67 @@
|
||||
import { createContext } from "preact";
|
||||
import FNote from "../../entities/fnote";
|
||||
import { escapeHtml } from "../../services/utils";
|
||||
import ActionButton, { ActionButtonProps } from "../react/ActionButton";
|
||||
import Dropdown, { DropdownProps } from "../react/Dropdown";
|
||||
import { useNoteLabel, useNoteProperty } from "../react/hooks";
|
||||
import Icon from "../react/Icon";
|
||||
import { useContext } from "preact/hooks";
|
||||
|
||||
export const LaunchBarContext = createContext<{
|
||||
isHorizontalLayout: boolean;
|
||||
}>({
|
||||
isHorizontalLayout: false
|
||||
})
|
||||
|
||||
export interface LauncherNoteProps {
|
||||
/** The corresponding {@link FNote} of type {@code launcher} in the hidden subtree of this launcher. Generally this launcher note holds information about the launcher via labels and relations, but also the title and the icon of the launcher. Not to be confused with the target note, which is specific to some launchers. */
|
||||
launcherNote: FNote;
|
||||
}
|
||||
|
||||
export function LaunchBarActionButton(props: Omit<ActionButtonProps, "className" | "noIconActionClass" | "titlePosition">) {
|
||||
const { isHorizontalLayout } = useContext(LaunchBarContext);
|
||||
|
||||
return (
|
||||
<ActionButton
|
||||
className="button-widget launcher-button"
|
||||
noIconActionClass
|
||||
titlePosition={isHorizontalLayout ? "bottom" : "right"}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function LaunchBarDropdownButton({ children, icon, dropdownOptions, ...props }: Pick<DropdownProps, "title" | "children" | "onShown" | "dropdownOptions" | "dropdownRef"> & { icon: string }) {
|
||||
const { isHorizontalLayout } = useContext(LaunchBarContext);
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
className="right-dropdown-widget"
|
||||
buttonClassName="right-dropdown-button launcher-button"
|
||||
hideToggleArrow
|
||||
text={<Icon icon={icon} />}
|
||||
titlePosition={isHorizontalLayout ? "bottom" : "right"}
|
||||
titleOptions={{ animation: false }}
|
||||
dropdownOptions={{
|
||||
...dropdownOptions,
|
||||
popperConfig: {
|
||||
placement: isHorizontalLayout ? "bottom" : "right"
|
||||
}
|
||||
}}
|
||||
{...props}
|
||||
>{children}</Dropdown>
|
||||
)
|
||||
}
|
||||
|
||||
export function useLauncherIconAndTitle(note: FNote) {
|
||||
const title = useNoteProperty(note, "title");
|
||||
|
||||
// React to changes.
|
||||
useNoteLabel(note, "iconClass");
|
||||
useNoteLabel(note, "workspaceIconClass");
|
||||
|
||||
return {
|
||||
icon: note.getIcon(),
|
||||
title: escapeHtml(title ?? "")
|
||||
};
|
||||
}
|
||||
@ -1,34 +0,0 @@
|
||||
import utils from "../services/utils.js";
|
||||
import QuickSearchWidget from "./quick_search.js";
|
||||
|
||||
/**
|
||||
* Similar to the {@link QuickSearchWidget} but meant to be included inside the launcher bar.
|
||||
*
|
||||
* <p>
|
||||
* Adds specific tweaks such as:
|
||||
*
|
||||
* - Hiding the widget on mobile.
|
||||
*/
|
||||
export default class QuickSearchLauncherWidget extends QuickSearchWidget {
|
||||
|
||||
private isHorizontalLayout: boolean;
|
||||
|
||||
constructor(isHorizontalLayout: boolean) {
|
||||
super();
|
||||
this.isHorizontalLayout = isHorizontalLayout;
|
||||
}
|
||||
|
||||
isEnabled() {
|
||||
if (!this.isHorizontalLayout) {
|
||||
// The quick search widget is added somewhere else on the vertical layout.
|
||||
return false;
|
||||
}
|
||||
|
||||
if (utils.isMobile()) {
|
||||
// The widget takes too much spaces to be included in the mobile layout.
|
||||
return false;
|
||||
}
|
||||
|
||||
return super.isEnabled();
|
||||
}
|
||||
}
|
||||
@ -2,13 +2,13 @@ import { useEffect, useRef, useState } from "preact/hooks";
|
||||
import { CommandNames } from "../../components/app_context";
|
||||
import { useStaticTooltip } from "./hooks";
|
||||
import keyboard_actions from "../../services/keyboard_actions";
|
||||
import { HTMLAttributes } from "preact";
|
||||
|
||||
export interface ActionButtonProps {
|
||||
export interface ActionButtonProps extends Pick<HTMLAttributes<HTMLButtonElement>, "onClick" | "onAuxClick" | "onContextMenu"> {
|
||||
text: string;
|
||||
titlePosition?: "top" | "right" | "bottom" | "left";
|
||||
icon: string;
|
||||
className?: string;
|
||||
onClick?: (e: MouseEvent) => void;
|
||||
triggerCommand?: CommandNames;
|
||||
noIconActionClass?: boolean;
|
||||
frame?: boolean;
|
||||
@ -16,14 +16,15 @@ export interface ActionButtonProps {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function ActionButton({ text, icon, className, onClick, triggerCommand, titlePosition, noIconActionClass, frame, active, disabled }: ActionButtonProps) {
|
||||
export default function ActionButton({ text, icon, className, triggerCommand, titlePosition, noIconActionClass, frame, active, disabled, ...restProps }: ActionButtonProps) {
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const [ keyboardShortcut, setKeyboardShortcut ] = useState<string[]>();
|
||||
|
||||
useStaticTooltip(buttonRef, {
|
||||
title: keyboardShortcut?.length ? `${text} (${keyboardShortcut?.join(",")})` : text,
|
||||
placement: titlePosition ?? "bottom",
|
||||
fallbackPlacements: [ titlePosition ?? "bottom" ]
|
||||
fallbackPlacements: [ titlePosition ?? "bottom" ],
|
||||
animation: false
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@ -35,8 +36,8 @@ export default function ActionButton({ text, icon, className, onClick, triggerCo
|
||||
return <button
|
||||
ref={buttonRef}
|
||||
class={`${className ?? ""} ${!noIconActionClass ? "icon-action" : "btn"} ${icon} ${frame ? "btn btn-primary" : ""} ${disabled ? "disabled" : ""} ${active ? "active" : ""}`}
|
||||
onClick={onClick}
|
||||
data-trigger-command={triggerCommand}
|
||||
disabled={disabled}
|
||||
{...restProps}
|
||||
/>;
|
||||
}
|
||||
|
||||
@ -1,16 +1,22 @@
|
||||
import { Dropdown as BootstrapDropdown } from "bootstrap";
|
||||
import { ComponentChildren } from "preact";
|
||||
import { Dropdown as BootstrapDropdown, Tooltip } from "bootstrap";
|
||||
import { ComponentChildren, HTMLAttributes } from "preact";
|
||||
import { CSSProperties, HTMLProps } from "preact/compat";
|
||||
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
|
||||
import { useUniqueName } from "./hooks";
|
||||
import { MutableRef, useCallback, useEffect, useRef, useState } from "preact/hooks";
|
||||
import { useTooltip, useUniqueName } from "./hooks";
|
||||
|
||||
type DataAttributes = {
|
||||
[key: `data-${string}`]: string | number | boolean | undefined;
|
||||
};
|
||||
|
||||
export interface DropdownProps extends Pick<HTMLProps<HTMLDivElement>, "id" | "className"> {
|
||||
buttonClassName?: string;
|
||||
buttonProps?: Partial<HTMLAttributes<HTMLButtonElement> & DataAttributes>;
|
||||
isStatic?: boolean;
|
||||
children: ComponentChildren;
|
||||
title?: string;
|
||||
dropdownContainerStyle?: CSSProperties;
|
||||
dropdownContainerClassName?: string;
|
||||
dropdownContainerRef?: MutableRef<HTMLDivElement | null>;
|
||||
hideToggleArrow?: boolean;
|
||||
/** If set to true, then the dropdown button will be considered an icon action (without normal border and sized for icons only). */
|
||||
iconAction?: boolean;
|
||||
@ -21,29 +27,53 @@ export interface DropdownProps extends Pick<HTMLProps<HTMLDivElement>, "id" | "c
|
||||
forceShown?: boolean;
|
||||
onShown?: () => void;
|
||||
onHidden?: () => void;
|
||||
dropdownOptions?: Partial<BootstrapDropdown.Options>;
|
||||
dropdownRef?: MutableRef<BootstrapDropdown | null>;
|
||||
titlePosition?: "top" | "right" | "bottom" | "left";
|
||||
titleOptions?: Partial<Tooltip.Options>;
|
||||
}
|
||||
|
||||
export default function Dropdown({ id, className, buttonClassName, isStatic, children, title, text, dropdownContainerStyle, dropdownContainerClassName, hideToggleArrow, iconAction, disabled, noSelectButtonStyle, noDropdownListStyle, forceShown, onShown: externalOnShown, onHidden: externalOnHidden }: DropdownProps) {
|
||||
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
||||
export default function Dropdown({ id, className, buttonClassName, isStatic, children, title, text, dropdownContainerStyle, dropdownContainerClassName, dropdownContainerRef: externalContainerRef, hideToggleArrow, iconAction, disabled, noSelectButtonStyle, noDropdownListStyle, forceShown, onShown: externalOnShown, onHidden: externalOnHidden, dropdownOptions, buttonProps, dropdownRef, titlePosition, titleOptions }: DropdownProps) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
const dropdownContainerRef = useRef<HTMLUListElement | null>(null);
|
||||
|
||||
const { showTooltip, hideTooltip } = useTooltip(containerRef, {
|
||||
...titleOptions,
|
||||
placement: titlePosition ?? "bottom",
|
||||
fallbackPlacements: [ titlePosition ?? "bottom" ],
|
||||
trigger: "manual"
|
||||
});
|
||||
|
||||
const [ shown, setShown ] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!triggerRef.current) return;
|
||||
if (!triggerRef.current || !dropdownContainerRef.current) return;
|
||||
|
||||
const dropdown = BootstrapDropdown.getOrCreateInstance(triggerRef.current);
|
||||
const dropdown = BootstrapDropdown.getOrCreateInstance(triggerRef.current, dropdownOptions);
|
||||
if (dropdownRef) {
|
||||
dropdownRef.current = dropdown;
|
||||
}
|
||||
if (forceShown) {
|
||||
dropdown.show();
|
||||
setShown(true);
|
||||
}
|
||||
return () => dropdown.dispose();
|
||||
|
||||
// React to popup container size changes, which can affect the positioning.
|
||||
const resizeObserver = new ResizeObserver(() => dropdown.update());
|
||||
resizeObserver.observe(dropdownContainerRef.current);
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
dropdown.dispose();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onShown = useCallback(() => {
|
||||
setShown(true);
|
||||
externalOnShown?.();
|
||||
}, [])
|
||||
hideTooltip();
|
||||
}, [ hideTooltip ])
|
||||
|
||||
const onHidden = useCallback(() => {
|
||||
setShown(false);
|
||||
@ -51,23 +81,32 @@ export default function Dropdown({ id, className, buttonClassName, isStatic, chi
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dropdownRef.current) return;
|
||||
if (!containerRef.current) return;
|
||||
if (externalContainerRef) externalContainerRef.current = containerRef.current;
|
||||
|
||||
const $dropdown = $(dropdownRef.current);
|
||||
$dropdown.on("show.bs.dropdown", onShown);
|
||||
$dropdown.on("hide.bs.dropdown", onHidden);
|
||||
const $dropdown = $(containerRef.current);
|
||||
$dropdown.on("show.bs.dropdown", (e) => {
|
||||
// Stop propagation causing multiple shows for nested dropdowns.
|
||||
e.stopPropagation();
|
||||
onShown();
|
||||
});
|
||||
$dropdown.on("hide.bs.dropdown", (e) => {
|
||||
// Stop propagation causing multiple hides for nested dropdowns.
|
||||
e.stopPropagation();
|
||||
onHidden();
|
||||
});
|
||||
|
||||
// Add proper cleanup
|
||||
return () => {
|
||||
$dropdown.off("show.bs.dropdown", onShown);
|
||||
$dropdown.off("hide.bs.dropdown", onHidden);
|
||||
};
|
||||
}, []); // Add dependency array
|
||||
}, [ onShown, onHidden ]);
|
||||
|
||||
const ariaId = useUniqueName("button");
|
||||
|
||||
return (
|
||||
<div ref={dropdownRef} class={`dropdown ${className ?? ""}`} style={{ display: "flex" }}>
|
||||
<div ref={containerRef} class={`dropdown ${className ?? ""}`} style={{ display: "flex" }} title={title}>
|
||||
<button
|
||||
className={`${iconAction ? "icon-action" : "btn"} ${!noSelectButtonStyle ? "select-button" : ""} ${buttonClassName ?? ""} ${!hideToggleArrow ? "dropdown-toggle" : ""}`}
|
||||
ref={triggerRef}
|
||||
@ -76,9 +115,11 @@ export default function Dropdown({ id, className, buttonClassName, isStatic, chi
|
||||
data-bs-display={ isStatic ? "static" : undefined }
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
title={title}
|
||||
id={id ?? ariaId}
|
||||
disabled={disabled}
|
||||
onMouseOver={() => showTooltip()}
|
||||
onMouseLeave={() => hideTooltip()}
|
||||
{...buttonProps}
|
||||
>
|
||||
{text}
|
||||
<span className="caret"></span>
|
||||
@ -88,6 +129,7 @@ export default function Dropdown({ id, className, buttonClassName, isStatic, chi
|
||||
class={`dropdown-menu ${isStatic ? "static" : ""} ${dropdownContainerClassName ?? ""} ${!noDropdownListStyle ? "tn-dropdown-list" : ""}`}
|
||||
style={dropdownContainerStyle}
|
||||
aria-labelledby={ariaId}
|
||||
ref={dropdownContainerRef}
|
||||
>
|
||||
{shown && children}
|
||||
</ul>
|
||||
|
||||
@ -4,6 +4,7 @@ import { useImperativeSearchHighlighlighting, useTriliumEvent } from "./hooks";
|
||||
|
||||
interface NoteLinkOpts {
|
||||
className?: string;
|
||||
containerClassName?: string;
|
||||
notePath: string | string[];
|
||||
showNotePath?: boolean;
|
||||
showNoteIcon?: boolean;
|
||||
@ -17,7 +18,7 @@ interface NoteLinkOpts {
|
||||
noContextMenu?: boolean;
|
||||
}
|
||||
|
||||
export default function NoteLink({ className, notePath, showNotePath, showNoteIcon, style, noPreview, noTnLink, highlightedTokens, title, viewScope, noContextMenu }: NoteLinkOpts) {
|
||||
export default function NoteLink({ className, containerClassName, notePath, showNotePath, showNoteIcon, style, noPreview, noTnLink, highlightedTokens, title, viewScope, noContextMenu }: NoteLinkOpts) {
|
||||
const stringifiedNotePath = Array.isArray(notePath) ? notePath.join("/") : notePath;
|
||||
const noteId = stringifiedNotePath.split("/").at(-1);
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
@ -71,6 +72,5 @@ export default function NoteLink({ className, notePath, showNotePath, showNoteIc
|
||||
$linkEl?.addClass(className);
|
||||
}
|
||||
|
||||
return <span ref={ref} />
|
||||
|
||||
return <span className={containerClassName} ref={ref} />;
|
||||
}
|
||||
|
||||
@ -20,9 +20,10 @@ import options, { type OptionValue } from "../../services/options";
|
||||
import protected_session_holder from "../../services/protected_session_holder";
|
||||
import SpacedUpdate from "../../services/spaced_update";
|
||||
import toast, { ToastOptions } from "../../services/toast";
|
||||
import utils, { escapeRegExp, reloadFrontendApp } from "../../services/utils";
|
||||
import utils, { escapeRegExp, randomString, reloadFrontendApp } from "../../services/utils";
|
||||
import server from "../../services/server";
|
||||
import { removeIndividualBinding } from "../../services/shortcuts";
|
||||
import shortcuts, { Handler, removeIndividualBinding } from "../../services/shortcuts";
|
||||
import froca from "../../services/froca";
|
||||
|
||||
export function useTriliumEvent<T extends EventNames>(eventName: T, handler: (data: EventData<T>) => void) {
|
||||
const parentComponent = useContext(ParentComponent);
|
||||
@ -369,6 +370,16 @@ export function useNoteRelation(note: FNote | undefined | null, relationName: Re
|
||||
] as const;
|
||||
}
|
||||
|
||||
export function useNoteRelationTarget(note: FNote, relationName: RelationNames) {
|
||||
const [ targetNote, setTargetNote ] = useState<FNote | null>();
|
||||
|
||||
useEffect(() => {
|
||||
note.getRelationTarget(relationName).then(setTargetNote);
|
||||
}, [ note ]);
|
||||
|
||||
return [ targetNote ] as const;
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows a React component to read or write a note's label while also reacting to changes in value.
|
||||
*
|
||||
@ -622,6 +633,8 @@ export function useTooltip(elRef: RefObject<HTMLElement>, config: Partial<Toolti
|
||||
return { showTooltip, hideTooltip };
|
||||
}
|
||||
|
||||
let tooltips = new Set<Tooltip>();
|
||||
|
||||
/**
|
||||
* Similar to {@link useTooltip}, but doesn't expose methods to imperatively hide or show the tooltip.
|
||||
*
|
||||
@ -634,7 +647,17 @@ export function useStaticTooltip(elRef: RefObject<Element>, config?: Partial<Too
|
||||
if (!elRef?.current || !hasTooltip) return;
|
||||
|
||||
const tooltip = Tooltip.getOrCreateInstance(elRef.current, config);
|
||||
elRef.current.addEventListener("show.bs.tooltip", () => {
|
||||
// Hide all the other tooltips.
|
||||
for (const otherTooltip of tooltips) {
|
||||
if (otherTooltip === tooltip) continue;
|
||||
otherTooltip.hide();
|
||||
}
|
||||
});
|
||||
tooltips.add(tooltip);
|
||||
|
||||
return () => {
|
||||
tooltips.delete(tooltip);
|
||||
tooltip.dispose();
|
||||
// workaround for https://github.com/twbs/bootstrap/issues/37474
|
||||
(tooltip as any)._activeTrigger = {};
|
||||
@ -789,6 +812,21 @@ export function useKeyboardShortcuts(scope: "code-detail" | "text-detail", conta
|
||||
}, [ scope, containerRef, parentComponent, ntxId ]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a global shortcut. Internally it uses the shortcut service and assigns a random namespace to make it unique.
|
||||
*
|
||||
* @param keyboardShortcut the keyboard shortcut combination to register.
|
||||
* @param handler the corresponding handler to be called when the keyboard shortcut is invoked by the user.
|
||||
*/
|
||||
export function useGlobalShortcut(keyboardShortcut: string | null | undefined, handler: Handler) {
|
||||
useEffect(() => {
|
||||
if (!keyboardShortcut) return;
|
||||
const namespace = randomString(10);
|
||||
shortcuts.bindGlobalShortcut(keyboardShortcut, handler, namespace);
|
||||
return () => shortcuts.removeGlobalShortcut(namespace);
|
||||
}, [ keyboardShortcut, handler ]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates that the current note is in read-only mode, while an editing mode is available,
|
||||
* and provides a way to switch to editing mode.
|
||||
@ -836,3 +874,16 @@ async function isNoteReadOnly(note: FNote, noteContext: NoteContext) {
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function useChildNotes(parentNoteId: string) {
|
||||
const [ childNotes, setChildNotes ] = useState<FNote[]>([]);
|
||||
useEffect(() => {
|
||||
(async function() {
|
||||
const parentNote = await froca.getNote(parentNoteId);
|
||||
const childNotes = await parentNote?.getChildNotes();
|
||||
setChildNotes(childNotes ?? []);
|
||||
})();
|
||||
}, [ parentNoteId ]);
|
||||
|
||||
return childNotes;
|
||||
}
|
||||
|
||||
@ -1,43 +0,0 @@
|
||||
import { t } from "../services/i18n.js";
|
||||
import BasicWidget from "./basic_widget.js";
|
||||
import contextMenu from "../menus/context_menu.js";
|
||||
import appContext, { type CommandNames } from "../components/app_context.js";
|
||||
import utils from "../services/utils.js";
|
||||
|
||||
const TPL = /*html*/`<div class="spacer"></div>`;
|
||||
|
||||
export default class SpacerWidget extends BasicWidget {
|
||||
private baseSize: number;
|
||||
private growthFactor: number;
|
||||
|
||||
constructor(baseSize = 0, growthFactor = 1000) {
|
||||
super();
|
||||
|
||||
this.baseSize = baseSize;
|
||||
this.growthFactor = growthFactor;
|
||||
}
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
this.$widget.css("flex-basis", this.baseSize);
|
||||
this.$widget.css("flex-grow", this.growthFactor);
|
||||
this.$widget.css("flex-shrink", 1000);
|
||||
|
||||
this.$widget.on("contextmenu", (e) => {
|
||||
this.$widget.tooltip("hide");
|
||||
|
||||
contextMenu.show<CommandNames>({
|
||||
x: e.pageX,
|
||||
y: e.pageY,
|
||||
items: [{ title: t("spacer.configure_launchbar"), command: "showLaunchBarSubtree", uiIcon: "bx " + (utils.isMobile() ? "bx-mobile" : "bx-sidebar") }],
|
||||
selectMenuItemHandler: ({ command }) => {
|
||||
if (command) {
|
||||
appContext.triggerCommand(command);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return false; // blocks default browser right click menu
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -1,146 +0,0 @@
|
||||
import { t } from "../services/i18n.js";
|
||||
import BasicWidget from "./basic_widget.js";
|
||||
import ws from "../services/ws.js";
|
||||
import options from "../services/options.js";
|
||||
import syncService from "../services/sync.js";
|
||||
import { escapeQuotes, handleRightToLeftPlacement } from "../services/utils.js";
|
||||
import { Tooltip } from "bootstrap";
|
||||
import { WebSocketMessage } from "@triliumnext/commons";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="sync-status-widget launcher-button">
|
||||
<style>
|
||||
.sync-status-widget {
|
||||
}
|
||||
|
||||
.sync-status {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.sync-status .sync-status-icon {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
top: -5px;
|
||||
font-size: 110%;
|
||||
}
|
||||
|
||||
.sync-status .sync-status-sub-icon {
|
||||
font-size: 40%;
|
||||
position: absolute;
|
||||
inset-inline-start: 0;
|
||||
top: 16px;
|
||||
}
|
||||
|
||||
.sync-status .sync-status-icon span {
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.sync-status-icon:not(.sync-status-in-progress):hover {
|
||||
background-color: var(--hover-item-background-color);
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="sync-status">
|
||||
<span class="sync-status-icon sync-status-unknown bx bx-time"
|
||||
data-bs-toggle="tooltip"
|
||||
title="${escapeQuotes(t("sync_status.unknown"))}">
|
||||
</span>
|
||||
<span class="sync-status-icon sync-status-connected-with-changes bx bx-wifi"
|
||||
data-bs-toggle="tooltip"
|
||||
title="${escapeQuotes(t("sync_status.connected_with_changes"))}">
|
||||
<span class="bx bxs-star sync-status-sub-icon"></span>
|
||||
</span>
|
||||
<span class="sync-status-icon sync-status-connected-no-changes bx bx-wifi"
|
||||
data-bs-toggle="tooltip"
|
||||
title="${escapeQuotes(t("sync_status.connected_no_changes"))}">
|
||||
</span>
|
||||
<span class="sync-status-icon sync-status-disconnected-with-changes bx bx-wifi-off"
|
||||
data-bs-toggle="tooltip"
|
||||
title="${escapeQuotes(t("sync_status.disconnected_with_changes"))}">
|
||||
<span class="bx bxs-star sync-status-sub-icon"></span>
|
||||
</span>
|
||||
<span class="sync-status-icon sync-status-disconnected-no-changes bx bx-wifi-off"
|
||||
data-bs-toggle="tooltip"
|
||||
title="${escapeQuotes(t("sync_status.disconnected_no_changes"))}">
|
||||
</span>
|
||||
<span class="sync-status-icon sync-status-in-progress bx bx-analyse bx-spin"
|
||||
data-bs-toggle="tooltip"
|
||||
title="${escapeQuotes(t("sync_status.in_progress"))}">
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
export default class SyncStatusWidget extends BasicWidget {
|
||||
|
||||
syncState: "unknown" | "in-progress" | "connected" | "disconnected";
|
||||
allChangesPushed: boolean;
|
||||
lastSyncedPush!: number;
|
||||
settings: {
|
||||
// TriliumNextTODO: narrow types and use TitlePlacement Type
|
||||
titlePlacement: string;
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.syncState = "unknown";
|
||||
this.allChangesPushed = false;
|
||||
this.settings = {
|
||||
titlePlacement: "right"
|
||||
};
|
||||
}
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
this.$widget.hide();
|
||||
|
||||
this.$widget.find(".sync-status-icon:not(.sync-status-in-progress)").on("click", () => syncService.syncNow());
|
||||
|
||||
ws.subscribeToMessages((message) => this.processMessage(message));
|
||||
}
|
||||
|
||||
showIcon(className: string) {
|
||||
if (!options.get("syncServerHost")) {
|
||||
this.toggleInt(false);
|
||||
return;
|
||||
}
|
||||
|
||||
Tooltip.getOrCreateInstance(this.$widget.find(`.sync-status-${className}`)[0], {
|
||||
html: true,
|
||||
placement: handleRightToLeftPlacement(this.settings.titlePlacement),
|
||||
fallbackPlacements: [ handleRightToLeftPlacement(this.settings.titlePlacement) ]
|
||||
});
|
||||
|
||||
this.$widget.show();
|
||||
this.$widget.find(".sync-status-icon").hide();
|
||||
this.$widget.find(`.sync-status-${className}`).show();
|
||||
}
|
||||
|
||||
processMessage(message: WebSocketMessage) {
|
||||
if (message.type === "sync-pull-in-progress") {
|
||||
this.syncState = "in-progress";
|
||||
this.lastSyncedPush = message.lastSyncedPush;
|
||||
} else if (message.type === "sync-push-in-progress") {
|
||||
this.syncState = "in-progress";
|
||||
this.lastSyncedPush = message.lastSyncedPush;
|
||||
} else if (message.type === "sync-finished") {
|
||||
this.syncState = "connected";
|
||||
this.lastSyncedPush = message.lastSyncedPush;
|
||||
} else if (message.type === "sync-failed") {
|
||||
this.syncState = "disconnected";
|
||||
this.lastSyncedPush = message.lastSyncedPush;
|
||||
} else if (message.type === "frontend-update") {
|
||||
this.lastSyncedPush = message.data.lastSyncedPush;
|
||||
}
|
||||
|
||||
this.allChangesPushed = this.lastSyncedPush === ws.getMaxKnownEntityChangeSyncId();
|
||||
|
||||
if (["unknown", "in-progress"].includes(this.syncState)) {
|
||||
this.showIcon(this.syncState);
|
||||
} else {
|
||||
this.showIcon(`${this.syncState}-${this.allChangesPushed ? "no-changes" : "with-changes"}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -8,11 +8,11 @@ test("Opens and activate a note from launcher Bar", async ({ page, context }) =>
|
||||
await app.goto();
|
||||
await app.closeAllTabs();
|
||||
|
||||
const mapButton = app.launcherBar.locator(".launcher-button.bx-search.visible");
|
||||
const mapButton = app.launcherBar.locator(".launcher-button.bx-search");
|
||||
await expect(mapButton).toBeVisible();
|
||||
|
||||
await page.keyboard.down('Control');
|
||||
await page.keyboard.down('Shift');
|
||||
await page.keyboard.down('Control');
|
||||
await page.keyboard.down('Shift');
|
||||
|
||||
await mapButton.click();
|
||||
|
||||
@ -23,7 +23,7 @@ test("Opens and activate a note from launcher Bar", async ({ page, context }) =>
|
||||
await expect(tabs).toHaveCount(2);
|
||||
|
||||
const secondTab = tabs.nth(1);
|
||||
await expect(secondTab).toHaveAttribute('active', '');
|
||||
await expect(secondTab).toHaveAttribute('active', '');
|
||||
});
|
||||
|
||||
test("Opens and activate a note from note tree", async ({ page, context }) => {
|
||||
@ -31,8 +31,8 @@ test("Opens and activate a note from note tree", async ({ page, context }) => {
|
||||
await app.goto();
|
||||
await app.closeAllTabs();
|
||||
|
||||
await page.keyboard.down('Control');
|
||||
await page.keyboard.down('Shift');
|
||||
await page.keyboard.down('Control');
|
||||
await page.keyboard.down('Shift');
|
||||
|
||||
await app.clickNoteOnNoteTreeByTitle(NOTE_TITLE);
|
||||
|
||||
@ -43,5 +43,5 @@ test("Opens and activate a note from note tree", async ({ page, context }) => {
|
||||
await expect(tabs).toHaveCount(2);
|
||||
|
||||
const secondTab = tabs.nth(1);
|
||||
await expect(secondTab).toHaveAttribute('active', '');
|
||||
});
|
||||
await expect(secondTab).toHaveAttribute('active', '');
|
||||
});
|
||||
|
||||
2
apps/server/src/assets/doc_notes/en/User Guide/!!!meta.json
generated
vendored
2
apps/server/src/assets/doc_notes/en/User Guide/!!!meta.json
generated
vendored
File diff suppressed because one or more lines are too long
701
apps/server/src/assets/doc_notes/en/User Guide/User Guide/Collections/Calendar.html
generated
vendored
701
apps/server/src/assets/doc_notes/en/User Guide/User Guide/Collections/Calendar.html
generated
vendored
@ -6,416 +6,395 @@
|
||||
a start date and optionally an end date, as an event.</p>
|
||||
<p>The Calendar view has multiple display modes:</p>
|
||||
<ul>
|
||||
<li data-list-item-id="e304b5ae296d7f47b813dcd8cf2dbba42">Week view, where all the 7 days of the week (or 5 if the weekends are
|
||||
<li>Week view, where all the 7 days of the week (or 5 if the weekends are
|
||||
hidden) are displayed in columns. This mode allows entering and displaying
|
||||
time-specific events, not just all-day events.</li>
|
||||
<li data-list-item-id="ea8d70da10fa78ede209782b485e2de49">Month view, where the entire month is displayed and all-day events can
|
||||
<li>Month view, where the entire month is displayed and all-day events can
|
||||
be inserted. Both time-specific events and all-day events are listed.</li>
|
||||
<li
|
||||
data-list-item-id="e057acab031bcf780ce3055e534ab2d61">Year view, which displays the entire year for quick reference.</li>
|
||||
<li
|
||||
data-list-item-id="e5528ab7e56ada969592f5d35896a4808">List view, which displays all the events of a given month in sequence.</li>
|
||||
<li>Year view, which displays the entire year for quick reference.</li>
|
||||
<li>List view, which displays all the events of a given month in sequence.</li>
|
||||
</ul>
|
||||
<p>Unlike other Collection view types, the Calendar view also allows some
|
||||
kind of interaction, such as moving events around as well as creating new
|
||||
ones.</p>
|
||||
<h2>Creating a calendar</h2>
|
||||
<figure class="table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th> </th>
|
||||
<th> </th>
|
||||
<th> </th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>1</td>
|
||||
<td>
|
||||
<img src="2_Calendar_image.png">
|
||||
</td>
|
||||
<td>The Calendar View works only for Collection note types. To create a new
|
||||
note, right click on the note tree on the left and select Insert note after,
|
||||
or Insert child note and then select <em>Collection</em>.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>2</td>
|
||||
<td>
|
||||
<img src="3_Calendar_image.png">
|
||||
</td>
|
||||
<td>Once created, the “View type” of the Collection needs changed to “Calendar”,
|
||||
by selecting the “Collection Properties” tab in the ribbon.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</figure>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th></th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>1</td>
|
||||
<td>
|
||||
<img src="2_Calendar_image.png">
|
||||
</td>
|
||||
<td>The Calendar View works only for Collection note types. To create a new
|
||||
note, right click on the note tree on the left and select Insert note after,
|
||||
or Insert child note and then select <em>Collection</em>.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>2</td>
|
||||
<td>
|
||||
<img src="3_Calendar_image.png">
|
||||
</td>
|
||||
<td>Once created, the “View type” of the Collection needs changed to “Calendar”,
|
||||
by selecting the “Collection Properties” tab in the ribbon.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2>Creating a new event/note</h2>
|
||||
<ul>
|
||||
<li data-list-item-id="ed78c7591e6504413d89bf60a172ba7fa">Clicking on a day will create a new child note and assign it to that particular
|
||||
<li>Clicking on a day will create a new child note and assign it to that particular
|
||||
day.
|
||||
<ul>
|
||||
<li data-list-item-id="e9313832f770bbe02f6dbfdce57cb040c">You will be asked for the name of the new note. If the popup is dismissed
|
||||
<li>You will be asked for the name of the new note. If the popup is dismissed
|
||||
by pressing the close button or escape, then the note will not be created.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li data-list-item-id="e154d333887462e26c059d7b3218ffd10">It's possible to drag across multiple days to set both the start and end
|
||||
<li>It's possible to drag across multiple days to set both the start and end
|
||||
date of a particular note.
|
||||
<br>
|
||||
<img src="Calendar_image.png">
|
||||
</li>
|
||||
<li data-list-item-id="e8685250a40c8a75bf4a44ba3c4218495">Creating new notes from the calendar will respect the <code>~child:template</code> relation
|
||||
<li>Creating new notes from the calendar will respect the <code>~child:template</code> relation
|
||||
if set on the Collection note.</li>
|
||||
</ul>
|
||||
<h2>Interacting with events</h2>
|
||||
<ul>
|
||||
<li data-list-item-id="ef3868587f3133abbb6a43f1e7ba73df4">Hovering the mouse over an event will display information about the note.
|
||||
<li>Hovering the mouse over an event will display information about the note.
|
||||
<br>
|
||||
<img src="7_Calendar_image.png">
|
||||
</li>
|
||||
<li data-list-item-id="e0349fc14f198e77629a20fce7b910cbd">Left clicking the event will open a <a class="reference-link" href="#root/_help_ZjLYv08Rp3qC">Quick edit</a> to
|
||||
<li>Left clicking the event will open a <a class="reference-link" href="#root/_help_ZjLYv08Rp3qC">Quick edit</a> to
|
||||
edit the note in a popup while allowing easy return to the calendar by
|
||||
just dismissing the popup.
|
||||
<ul>
|
||||
<li data-list-item-id="e40934ee8189ac129ce9a62f1a34f90fd">Middle clicking will open the note in a new tab.</li>
|
||||
<li data-list-item-id="ed673e7b92f8713cfa950d6030deeb658">Right click will offer more options including opening the note in a new
|
||||
<li>Middle clicking will open the note in a new tab.</li>
|
||||
<li>Right click will offer more options including opening the note in a new
|
||||
split or window.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li data-list-item-id="e42434d681c96273852be7111b2464e66">Drag and drop an event on the calendar to move it to another day.</li>
|
||||
<li
|
||||
data-list-item-id="ec6982b2bdebadd048f01b2c9d5204375">The length of an event can be changed by placing the mouse to the right
|
||||
<li>Drag and drop an event on the calendar to move it to another day.</li>
|
||||
<li>The length of an event can be changed by placing the mouse to the right
|
||||
edge of the event and dragging the mouse around.</li>
|
||||
</ul>
|
||||
<h2>Interaction on mobile</h2>
|
||||
<p>When Trilium is on mobile, the interaction with the calendar is slightly
|
||||
different:</p>
|
||||
<ul>
|
||||
<li data-list-item-id="e9eccdc329cafb680914a2fea2bf3b967">Clicking on an event triggers the contextual menu, including the option
|
||||
to open in <a class="reference-link" href="#root/pOsGYCXsbNQG/gh7bpGYxajRS/wArbEsdSae6g/_help_ZjLYv08Rp3qC">Quick edit</a>.</li>
|
||||
<li
|
||||
data-list-item-id="e095306ea6783bd216f3848b3d5b6bcba">To insert a new event, touch and hold the empty space. When successful,
|
||||
<li>Clicking on an event triggers the contextual menu, including the option
|
||||
to open in <a class="reference-link" href="#root/_help_ZjLYv08Rp3qC">Quick edit</a>.</li>
|
||||
<li>To insert a new event, touch and hold the empty space. When successful,
|
||||
the empty space will become colored to indicate the selection.
|
||||
<ul>
|
||||
<li data-list-item-id="e678eb6949bb0dcb609880b66f4c218b8">Before releasing, drag across multiple spaces to create multi-day events.</li>
|
||||
<li
|
||||
data-list-item-id="eb77c1f5e90700a75c40fd0dab32a9266">When released, a prompt will appear to enter the note title.</li>
|
||||
<li>Before releasing, drag across multiple spaces to create multi-day events.</li>
|
||||
<li>When released, a prompt will appear to enter the note title.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li data-list-item-id="e824cd65431fa336433aa1e4186c1578d">To move an existing event, touch and hold the event until the empty space
|
||||
near it will become colored.
|
||||
<ul>
|
||||
<li data-list-item-id="e13dbb47246890b5f93404cf3e34feb3f">At this point the event can be dragged across other days on the calendar.</li>
|
||||
<li
|
||||
data-list-item-id="e011cdfec5a11fc783c5cc0acb2b704e1">Or the event can be resized by tapping on the small circle to the right
|
||||
end of the event.</li>
|
||||
<li data-list-item-id="ea38d2059d0736ad6d172bbb9f1cde4aa">To exit out of editing mode, simply tap the empty space anywhere on the
|
||||
calendar.</li>
|
||||
</li>
|
||||
<li>To move an existing event, touch and hold the event until the empty space
|
||||
near it will become colored.
|
||||
<ul>
|
||||
<li>At this point the event can be dragged across other days on the calendar.</li>
|
||||
<li>Or the event can be resized by tapping on the small circle to the right
|
||||
end of the event.</li>
|
||||
<li>To exit out of editing mode, simply tap the empty space anywhere on the
|
||||
calendar.</li>
|
||||
</ul>
|
||||
</li>
|
||||
</li>
|
||||
</ul>
|
||||
<h2>Configuring the calendar view</h2>
|
||||
<p>In the <em>Collections</em> tab in the <a class="reference-link" href="#root/_help_BlN9DFI679QC">Ribbon</a>,
|
||||
it's possible to adjust the following:</p>
|
||||
<ul>
|
||||
<li data-list-item-id="e5226aa5761f9b2fd47d0c38c252e7f74">Hide weekends from the week view.</li>
|
||||
<li data-list-item-id="e62c9e62cfbc2d6e2e4587cfce90143a0">Display week numbers on the calendar.</li>
|
||||
<li>Hide weekends from the week view.</li>
|
||||
<li>Display week numbers on the calendar.</li>
|
||||
</ul>
|
||||
<h2>Configuring the calendar using attributes</h2>
|
||||
<p>The following attributes can be added to the Collection type:</p>
|
||||
<figure
|
||||
class="table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>#calendar:hideWeekends</code>
|
||||
</td>
|
||||
<td>When present (regardless of value), it will hide Saturday and Sundays
|
||||
from the calendar.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>#calendar:weekNumbers</code>
|
||||
</td>
|
||||
<td>When present (regardless of value), it will show the number of the week
|
||||
on the calendar.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>#calendar:initialDate</code>
|
||||
</td>
|
||||
<td>Change the date the calendar opens on. When not present, the calendar
|
||||
opens on the current date.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>#calendar:view</code>
|
||||
</td>
|
||||
<td>
|
||||
<p>Which view to display in the calendar:</p>
|
||||
<ul>
|
||||
<li data-list-item-id="e2cd230dc41f41fe91ee74d7d1fa87372"><code>timeGridWeek</code> for the <em>week</em> view;</li>
|
||||
<li data-list-item-id="eee1dba4c6cc51ebd53d0a0dd52044cd6"><code>dayGridMonth</code> for the <em>month</em> view;</li>
|
||||
<li data-list-item-id="ed8721a76a1865dac882415f662ed45b9"><code>multiMonthYear</code> for the <em>year</em> view;</li>
|
||||
<li data-list-item-id="edf09a13759102d98dac34c33eb690c05"><code>listMonth</code> for the <em>list</em> view.</li>
|
||||
</ul>
|
||||
<p>Any other value will be dismissed and the default view (month) will be
|
||||
used instead.</p>
|
||||
<p>The value of this label is automatically updated when changing the view
|
||||
using the UI buttons.</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>~child:template</code>
|
||||
</td>
|
||||
<td>Defines the template for newly created notes in the calendar (via dragging
|
||||
or clicking).</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</figure>
|
||||
<p>In addition, the first day of the week can be either Sunday or Monday
|
||||
and can be adjusted from the application settings.</p>
|
||||
<h2>Configuring the calendar events using attributes</h2>
|
||||
<p>For each note of the calendar, the following attributes can be used:</p>
|
||||
<figure
|
||||
class="table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>#startDate</code>
|
||||
</td>
|
||||
<td>The date the event starts, which will display it in the calendar. The
|
||||
format is <code>YYYY-MM-DD</code> (year, month and day separated by a minus
|
||||
sign).</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>#endDate</code>
|
||||
</td>
|
||||
<td>Similar to <code>startDate</code>, mentions the end date if the event spans
|
||||
across multiple days. The date is inclusive, so the end day is also considered.
|
||||
The attribute can be missing for single-day events.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>#startTime</code>
|
||||
</td>
|
||||
<td>The time the event starts at. If this value is missing, then the event
|
||||
is considered a full-day event. The format is <code>HH:MM</code> (hours in
|
||||
24-hour format and minutes).</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>#endTime</code>
|
||||
</td>
|
||||
<td>Similar to <code>startTime</code>, it mentions the time at which the event
|
||||
ends (in relation with <code>endDate</code> if present, or <code>startDate</code>).</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>#color</code>
|
||||
</td>
|
||||
<td>Displays the event with a specified color (named such as <code>red</code>, <code>gray</code> or
|
||||
hex such as <code>#FF0000</code>). This will also change the color of the
|
||||
note in other places such as the note tree.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>#calendar:color</code>
|
||||
</td>
|
||||
<td>
|
||||
<p><strong>❌️ Removed since v0.100.0. Use </strong><code><strong>#color</strong></code><strong> instead.</strong>
|
||||
</p>
|
||||
<p>Similar to <code>#color</code>, but applies the color only for the event
|
||||
in the calendar and not for other places such as the note tree.</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>#iconClass</code>
|
||||
</td>
|
||||
<td>If present, the icon of the note will be displayed to the left of the
|
||||
event title.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>#calendar:title</code>
|
||||
</td>
|
||||
<td>Changes the title of an event to point to an attribute of the note other
|
||||
than the title, can either a label or a relation (without the <code>#</code> or <code>~</code> symbol).
|
||||
See <em>Use-cases</em> for more information.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>#calendar:displayedAttributes</code>
|
||||
</td>
|
||||
<td>Allows displaying the value of one or more attributes in the calendar
|
||||
like this:
|
||||
<br>
|
||||
<br>
|
||||
<img src="9_Calendar_image.png">
|
||||
<br>
|
||||
<br><code>#weight="70" #Mood="Good" #calendar:displayedAttributes="weight,Mood"</code>
|
||||
<br>
|
||||
<br>It can also be used with relations, case in which it will display the
|
||||
title of the target note:
|
||||
<br>
|
||||
<br><code>~assignee=@My assignee #calendar:displayedAttributes="assignee"</code>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>#calendar:startDate</code>
|
||||
</td>
|
||||
<td>Allows using a different label to represent the start date, other than <code>startDate</code> (e.g. <code>expiryDate</code>).
|
||||
The label name <strong>must not be</strong> prefixed with <code>#</code>.
|
||||
If the label is not defined for a note, the default will be used instead.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>#calendar:endDate</code>
|
||||
</td>
|
||||
<td>Similar to <code>#calendar:startDate</code>, allows changing the attribute
|
||||
which is being used to read the end date.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>#calendar:startTime</code>
|
||||
</td>
|
||||
<td>Similar to <code>#calendar:startDate</code>, allows changing the attribute
|
||||
which is being used to read the start time.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>#calendar:endTime</code>
|
||||
</td>
|
||||
<td>Similar to <code>#calendar:startDate</code>, allows changing the attribute
|
||||
which is being used to read the end time.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</figure>
|
||||
<h2>How the calendar works</h2>
|
||||
<p>
|
||||
<img src="11_Calendar_image.png">
|
||||
</p>
|
||||
<p>The calendar displays all the child notes of the Collection that have
|
||||
a <code>#startDate</code>. An <code>#endDate</code> can optionally be added.</p>
|
||||
<p>If editing the start date and end date from the note itself is desirable,
|
||||
the following attributes can be added to the Collection note:</p><pre><code class="language-text-x-trilium-auto">#viewType=calendar #label:startDate(inheritable)="promoted,alias=Start Date,single,date"
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>#calendar:hideWeekends</code>
|
||||
</td>
|
||||
<td>When present (regardless of value), it will hide Saturday and Sundays
|
||||
from the calendar.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>#calendar:weekNumbers</code>
|
||||
</td>
|
||||
<td>When present (regardless of value), it will show the number of the week
|
||||
on the calendar.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>#calendar:initialDate</code>
|
||||
</td>
|
||||
<td>Change the date the calendar opens on. When not present, the calendar
|
||||
opens on the current date.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>#calendar:view</code>
|
||||
</td>
|
||||
<td>
|
||||
<p>Which view to display in the calendar:</p>
|
||||
<ul>
|
||||
<li data-list-item-id="e2cd230dc41f41fe91ee74d7d1fa87372"><code>timeGridWeek</code> for the <em>week</em> view;</li>
|
||||
<li data-list-item-id="eee1dba4c6cc51ebd53d0a0dd52044cd6"><code>dayGridMonth</code> for the <em>month</em> view;</li>
|
||||
<li data-list-item-id="ed8721a76a1865dac882415f662ed45b9"><code>multiMonthYear</code> for the <em>year</em> view;</li>
|
||||
<li data-list-item-id="edf09a13759102d98dac34c33eb690c05"><code>listMonth</code> for the <em>list</em> view.</li>
|
||||
</ul>
|
||||
<p>Any other value will be dismissed and the default view (month) will be
|
||||
used instead.</p>
|
||||
<p>The value of this label is automatically updated when changing the view
|
||||
using the UI buttons.</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>~child:template</code>
|
||||
</td>
|
||||
<td>Defines the template for newly created notes in the calendar (via dragging
|
||||
or clicking).</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p>In addition, the first day of the week can be either Sunday or Monday
|
||||
and can be adjusted from the application settings.</p>
|
||||
<h2>Configuring the calendar events using attributes</h2>
|
||||
<p>For each note of the calendar, the following attributes can be used:</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>#startDate</code>
|
||||
</td>
|
||||
<td>The date the event starts, which will display it in the calendar. The
|
||||
format is <code>YYYY-MM-DD</code> (year, month and day separated by a minus
|
||||
sign).</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>#endDate</code>
|
||||
</td>
|
||||
<td>Similar to <code>startDate</code>, mentions the end date if the event spans
|
||||
across multiple days. The date is inclusive, so the end day is also considered.
|
||||
The attribute can be missing for single-day events.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>#startTime</code>
|
||||
</td>
|
||||
<td>The time the event starts at. If this value is missing, then the event
|
||||
is considered a full-day event. The format is <code>HH:MM</code> (hours in
|
||||
24-hour format and minutes).</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>#endTime</code>
|
||||
</td>
|
||||
<td>Similar to <code>startTime</code>, it mentions the time at which the event
|
||||
ends (in relation with <code>endDate</code> if present, or <code>startDate</code>).</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>#color</code>
|
||||
</td>
|
||||
<td>Displays the event with a specified color (named such as <code>red</code>, <code>gray</code> or
|
||||
hex such as <code>#FF0000</code>). This will also change the color of the
|
||||
note in other places such as the note tree.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>#calendar:color</code>
|
||||
</td>
|
||||
<td><strong>❌️ Removed since v0.100.0. Use</strong> <code>**#color**</code> <strong>instead.</strong>
|
||||
<br>
|
||||
<br>Similar to <code>#color</code>, but applies the color only for the event
|
||||
in the calendar and not for other places such as the note tree.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>#iconClass</code>
|
||||
</td>
|
||||
<td>If present, the icon of the note will be displayed to the left of the
|
||||
event title.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>#calendar:title</code>
|
||||
</td>
|
||||
<td>Changes the title of an event to point to an attribute of the note other
|
||||
than the title, can either a label or a relation (without the <code>#</code> or <code>~</code> symbol).
|
||||
See <em>Use-cases</em> for more information.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>#calendar:displayedAttributes</code>
|
||||
</td>
|
||||
<td>Allows displaying the value of one or more attributes in the calendar
|
||||
like this:
|
||||
<br>
|
||||
<br>
|
||||
<img src="9_Calendar_image.png">
|
||||
<br>
|
||||
<br><code>#weight="70" #Mood="Good" #calendar:displayedAttributes="weight,Mood"</code>
|
||||
<br>
|
||||
<br>It can also be used with relations, case in which it will display the
|
||||
title of the target note:
|
||||
<br>
|
||||
<br><code>~assignee=@My assignee #calendar:displayedAttributes="assignee"</code>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>#calendar:startDate</code>
|
||||
</td>
|
||||
<td>Allows using a different label to represent the start date, other than <code>startDate</code> (e.g. <code>expiryDate</code>).
|
||||
The label name <strong>must not be</strong> prefixed with <code>#</code>.
|
||||
If the label is not defined for a note, the default will be used instead.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>#calendar:endDate</code>
|
||||
</td>
|
||||
<td>Similar to <code>#calendar:startDate</code>, allows changing the attribute
|
||||
which is being used to read the end date.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>#calendar:startTime</code>
|
||||
</td>
|
||||
<td>Similar to <code>#calendar:startDate</code>, allows changing the attribute
|
||||
which is being used to read the start time.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>#calendar:endTime</code>
|
||||
</td>
|
||||
<td>Similar to <code>#calendar:startDate</code>, allows changing the attribute
|
||||
which is being used to read the end time.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2>How the calendar works</h2>
|
||||
<p>
|
||||
<img src="11_Calendar_image.png">
|
||||
</p>
|
||||
<p>The calendar displays all the child notes of the Collection that have
|
||||
a <code>#startDate</code>. An <code>#endDate</code> can optionally be added.</p>
|
||||
<p>If editing the start date and end date from the note itself is desirable,
|
||||
the following attributes can be added to the Collection note:</p><pre><code class="language-text-x-trilium-auto">#viewType=calendar #label:startDate(inheritable)="promoted,alias=Start Date,single,date"
|
||||
#label:endDate(inheritable)="promoted,alias=End Date,single,date"
|
||||
#hidePromotedAttributes </code></pre>
|
||||
<p>This will result in:</p>
|
||||
<p>
|
||||
<img src="10_Calendar_image.png">
|
||||
</p>
|
||||
<p>When not used in a Journal, the calendar is recursive. That is, it will
|
||||
look for events not just in its child notes but also in the children of
|
||||
these child notes.</p>
|
||||
<h2>Use-cases</h2>
|
||||
<h3>Using with the Journal / calendar</h3>
|
||||
<p>It is possible to integrate the calendar view into the Journal with day
|
||||
notes. In order to do so change the note type of the Journal note (calendar
|
||||
root) to Collection and then select the Calendar View.</p>
|
||||
<p>Based on the <code>#calendarRoot</code> (or <code>#workspaceCalendarRoot</code>)
|
||||
attribute, the calendar will know that it's in a calendar and apply the
|
||||
following:</p>
|
||||
<ul>
|
||||
<li data-list-item-id="ef8dd9e289abf6cb973bd379d219170ae">The calendar events are now rendered based on their <code>dateNote</code> attribute
|
||||
rather than <code>startDate</code>.</li>
|
||||
<li data-list-item-id="e35671ad2eee19bba69bb31f3f33cce37">Interactive editing such as dragging over an empty era or resizing an
|
||||
event is no longer possible.</li>
|
||||
<li data-list-item-id="e8930686b36fb7fa36d123f9f202cbc75">Clicking on the empty space on a date will automatically open that day's
|
||||
note or create it if it does not exist.</li>
|
||||
<li data-list-item-id="edc838e74ed3479fe2f0fbfaef462dbbb">Direct children of a day note will be displayed on the calendar despite
|
||||
not having a <code>dateNote</code> attribute. Children of the child notes
|
||||
will not be displayed.</li>
|
||||
</ul>
|
||||
<p>
|
||||
<img src="8_Calendar_image.png" width="1217"
|
||||
height="724">
|
||||
</p>
|
||||
<h3>Using a different attribute as event title</h3>
|
||||
<p>By default, events are displayed on the calendar by their note title.
|
||||
However, it is possible to configure a different attribute to be displayed
|
||||
instead.</p>
|
||||
<p>To do so, assign <code>#calendar:title</code> to the child note (not the
|
||||
calendar/Collection note), with the value being <code>name</code> where <code>name</code> can
|
||||
be any label (make not to add the <code>#</code> prefix). The attribute can
|
||||
also come through inheritance such as a template attribute. If the note
|
||||
does not have the requested label, the title of the note will be used instead.</p>
|
||||
<figure
|
||||
class="table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th> </th>
|
||||
<th> </th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><pre><code class="language-text-x-trilium-auto">#startDate=2025-02-11 #endDate=2025-02-13 #name="My vacation" #calendar:title="name"</code></pre>
|
||||
</td>
|
||||
<td>
|
||||
<p> </p>
|
||||
<figure class="image image-style-align-center">
|
||||
<img style="aspect-ratio:445/124;" src="5_Calendar_image.png"
|
||||
width="445" height="124">
|
||||
</figure>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</figure>
|
||||
<h3>Using a relation attribute as event title</h3>
|
||||
<p>Similarly to using an attribute, use <code>#calendar:title</code> and set
|
||||
it to <code>name</code> where <code>name</code> is the name of the relation
|
||||
to use.</p>
|
||||
<p>Moreover, if there are more relations of the same name, they will be displayed
|
||||
as multiple events coming from the same note.</p>
|
||||
<figure class="table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th> </th>
|
||||
<th> </th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><pre><code class="language-text-x-trilium-auto">#startDate=2025-02-14 #endDate=2025-02-15 ~for=@John Smith ~for=@Jane Doe #calendar:title="for"</code></pre>
|
||||
</td>
|
||||
<td>
|
||||
<img src="6_Calendar_image.png" width="294"
|
||||
height="151">
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</figure>
|
||||
<p>Note that it's even possible to have a <code>#calendar:title</code> on the
|
||||
target note (e.g. “John Smith”) which will try to render an attribute of
|
||||
it. Note that it's not possible to use a relation here as well for safety
|
||||
reasons (an accidental recursion of attributes could cause the application
|
||||
to loop infinitely).</p>
|
||||
<figure class="table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th> </th>
|
||||
<th> </th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><pre><code class="language-text-x-trilium-auto">#calendar:title="shortName" #shortName="John S."</code></pre>
|
||||
</td>
|
||||
<td>
|
||||
<figure class="image image-style-align-center">
|
||||
<img style="aspect-ratio:296/150;" src="1_Calendar_image.png"
|
||||
width="296" height="150">
|
||||
</figure>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</figure>
|
||||
<p>This will result in:</p>
|
||||
<p>
|
||||
<img src="10_Calendar_image.png">
|
||||
</p>
|
||||
<p>When not used in a Journal, the calendar is recursive. That is, it will
|
||||
look for events not just in its child notes but also in the children of
|
||||
these child notes.</p>
|
||||
<h2>Use-cases</h2>
|
||||
<h3>Using with the Journal / calendar</h3>
|
||||
<p>It is possible to integrate the calendar view into the Journal with day
|
||||
notes. In order to do so change the note type of the Journal note (calendar
|
||||
root) to Collection and then select the Calendar View.</p>
|
||||
<p>Based on the <code>#calendarRoot</code> (or <code>#workspaceCalendarRoot</code>)
|
||||
attribute, the calendar will know that it's in a calendar and apply the
|
||||
following:</p>
|
||||
<ul>
|
||||
<li>The calendar events are now rendered based on their <code>dateNote</code> attribute
|
||||
rather than <code>startDate</code>.</li>
|
||||
<li>Interactive editing such as dragging over an empty era or resizing an
|
||||
event is no longer possible.</li>
|
||||
<li>Clicking on the empty space on a date will automatically open that day's
|
||||
note or create it if it does not exist.</li>
|
||||
<li>Direct children of a day note will be displayed on the calendar despite
|
||||
not having a <code>dateNote</code> attribute. Children of the child notes
|
||||
will not be displayed.</li>
|
||||
</ul>
|
||||
<img src="8_Calendar_image.png" width="1217"
|
||||
height="724">
|
||||
|
||||
<h3>Using a different attribute as event title</h3>
|
||||
<p>By default, events are displayed on the calendar by their note title.
|
||||
However, it is possible to configure a different attribute to be displayed
|
||||
instead.</p>
|
||||
<p>To do so, assign <code>#calendar:title</code> to the child note (not the
|
||||
calendar/Collection note), with the value being <code>name</code> where <code>name</code> can
|
||||
be any label (make not to add the <code>#</code> prefix). The attribute can
|
||||
also come through inheritance such as a template attribute. If the note
|
||||
does not have the requested label, the title of the note will be used instead.</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th> </th>
|
||||
<th> </th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><pre><code class="language-text-x-trilium-auto">#startDate=2025-02-11 #endDate=2025-02-13 #name="My vacation" #calendar:title="name"</code></pre>
|
||||
</td>
|
||||
<td>
|
||||
<p> </p>
|
||||
<figure class="image image-style-align-center">
|
||||
<img style="aspect-ratio:445/124;" src="5_Calendar_image.png"
|
||||
width="445" height="124">
|
||||
</figure>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>Using a relation attribute as event title</h3>
|
||||
<p>Similarly to using an attribute, use <code>#calendar:title</code> and set
|
||||
it to <code>name</code> where <code>name</code> is the name of the relation
|
||||
to use.</p>
|
||||
<p>Moreover, if there are more relations of the same name, they will be displayed
|
||||
as multiple events coming from the same note.</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th> </th>
|
||||
<th> </th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><pre><code class="language-text-x-trilium-auto">#startDate=2025-02-14 #endDate=2025-02-15 ~for=@John Smith ~for=@Jane Doe #calendar:title="for"</code></pre>
|
||||
</td>
|
||||
<td>
|
||||
<img src="6_Calendar_image.png" width="294"
|
||||
height="151">
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p>Note that it's even possible to have a <code>#calendar:title</code> on the
|
||||
target note (e.g. “John Smith”) which will try to render an attribute of
|
||||
it. Note that it's not possible to use a relation here as well for safety
|
||||
reasons (an accidental recursion of attributes could cause the application
|
||||
to loop infinitely).</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th> </th>
|
||||
<th> </th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><pre><code class="language-text-x-trilium-auto">#calendar:title="shortName" #shortName="John S."</code></pre>
|
||||
</td>
|
||||
<td>
|
||||
<figure class="image image-style-align-center">
|
||||
<img style="aspect-ratio:296/150;" src="1_Calendar_image.png"
|
||||
width="296" height="150">
|
||||
</figure>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
33
apps/server/src/assets/doc_notes/en/User Guide/User Guide/Scripting/Frontend Basics/Launch Bar Widgets.html
generated
vendored
Normal file
33
apps/server/src/assets/doc_notes/en/User Guide/User Guide/Scripting/Frontend Basics/Launch Bar Widgets.html
generated
vendored
Normal file
@ -0,0 +1,33 @@
|
||||
<p>Launch bar widgets are a subset of <a class="reference-link" href="#root/pOsGYCXsbNQG/CdNpE2pqjmI6/yIhgI5H7A2Sm/_help_MgibgPcfeuGz">Custom Widgets</a> that
|
||||
can be used to render custom buttons and widgets inside the <a class="reference-link"
|
||||
href="#root/pOsGYCXsbNQG/gh7bpGYxajRS/Vc8PjrjAGuOp/_help_xYmIYSP6wE3F">Launch Bar</a>.</p>
|
||||
<h2>Creating a launch bar widget</h2>
|
||||
<p>Unlike <a class="reference-link" href="#root/pOsGYCXsbNQG/CdNpE2pqjmI6/yIhgI5H7A2Sm/_help_MgibgPcfeuGz">Custom Widgets</a>,
|
||||
the process of setting up a launch bar widget is slightly different:</p>
|
||||
<ol>
|
||||
<li data-list-item-id="e3111af31ab8707d93fb9e7feb1ac804d">Create a Code note of type <em>JavaScript (front-end)</em>.
|
||||
<ul>
|
||||
<li data-list-item-id="ea71ac173fc302483b5f571fd8bbd4142">The script itself uses the same concepts as <a class="reference-link"
|
||||
href="#root/pOsGYCXsbNQG/CdNpE2pqjmI6/yIhgI5H7A2Sm/_help_MgibgPcfeuGz">Custom Widgets</a>,
|
||||
including the use of a <code spellcheck="false">NoteContextAwareWidget</code> or
|
||||
a <code spellcheck="false">BasicWidget</code> (according to needs).</li>
|
||||
<li
|
||||
data-list-item-id="e06c05a8bdfaa11ad4214ccc5405f50cc">As examples, see <a class="reference-link" href="#root/pOsGYCXsbNQG/CdNpE2pqjmI6/yIhgI5H7A2Sm/4Gn3psZKsfSm/_help_IPArqVfDQ4We">Note Title Widget</a> and
|
||||
<a
|
||||
class="reference-link" href="#root/pOsGYCXsbNQG/CdNpE2pqjmI6/yIhgI5H7A2Sm/4Gn3psZKsfSm/_help_gcI7RPbaNSh3">Analog Watch</a>.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li data-list-item-id="e31368cfb0655cfc527347b9dcbfa7d17">Don't set <code spellcheck="false">#widget</code>, as that attribute is
|
||||
reserved for <a class="reference-link" href="#root/pOsGYCXsbNQG/CdNpE2pqjmI6/yIhgI5H7A2Sm/_help_MgibgPcfeuGz">Custom Widgets</a>.</li>
|
||||
<li
|
||||
data-list-item-id="e26f51e3ad87cebfa6c72504dab691804">In the <a class="reference-link" href="#root/pOsGYCXsbNQG/gh7bpGYxajRS/Vc8PjrjAGuOp/_help_x3i7MxGccDuM">Global menu</a>,
|
||||
select <em>Configure launchbar</em>.</li>
|
||||
<li data-list-item-id="ebd6a4fab8be5557cb958d6bf87b65d84">In the <em>Visible Launchers</em> section, select <em>Add a custom widget</em>.</li>
|
||||
<li
|
||||
data-list-item-id="ef1cb61670f561ad918be4f072d325bc7">Give the newly created launcher a name (and optionally a name).</li>
|
||||
<li
|
||||
data-list-item-id="e0b141f895a6a9973f31a71ba99471a49">In the <a class="reference-link" href="#root/pOsGYCXsbNQG/tC7s2alapj8V/zEY4DaJG4YT5/_help_OFXdgB2nNk1F">Promoted Attributes</a> section,
|
||||
modify the <em>widget</em> field to point to the newly created note.</li>
|
||||
<li
|
||||
data-list-item-id="e5218927546ad96070b3028534e93131b">Refresh the UI. </li>
|
||||
</ol>
|
||||
84
apps/server/src/assets/doc_notes/en/User Guide/User Guide/Scripting/Frontend Basics/Launch Bar Widgets/Analog Watch.html
generated
vendored
Normal file
84
apps/server/src/assets/doc_notes/en/User Guide/User Guide/Scripting/Frontend Basics/Launch Bar Widgets/Analog Watch.html
generated
vendored
Normal file
@ -0,0 +1,84 @@
|
||||
<figure class="image">
|
||||
<img style="aspect-ratio:1007/94;" src="Analog Watch_image.png"
|
||||
width="1007" height="94">
|
||||
</figure>
|
||||
<p>This is a more intricate example of a basic widget, which displays an
|
||||
analog watch in the launch bar. Unlike note-context aware widgets, basic
|
||||
widgets don't react to note navigation.</p><pre><code class="language-application-javascript-env-frontend">const TPL = `
|
||||
<div class="analog-watch" style="
|
||||
position: relative;
|
||||
height: 38px;
|
||||
width: 38px;
|
||||
border-radius: 50%;
|
||||
background: white;
|
||||
border: 2px solid #444;
|
||||
flex-shrink: 0;
|
||||
">
|
||||
<!-- hour hand -->
|
||||
<div class="hand hour" style="
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 3px;
|
||||
height: 10px;
|
||||
background: #333;
|
||||
transform-origin: bottom center;
|
||||
"></div>
|
||||
|
||||
<!-- minute hand -->
|
||||
<div class="hand minute" style="
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 2px;
|
||||
height: 13px;
|
||||
background: #111;
|
||||
transform-origin: bottom center;
|
||||
"></div>
|
||||
|
||||
<!-- second hand -->
|
||||
<div class="hand second" style="
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 1px;
|
||||
height: 15px;
|
||||
background: red;
|
||||
transform-origin: bottom center;
|
||||
"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
class AnalogWatchWidget extends api.BasicWidget {
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
|
||||
const hourHand = this.$widget.find('.hand.hour')[0];
|
||||
const minuteHand = this.$widget.find('.hand.minute')[0];
|
||||
const secondHand = this.$widget.find('.hand.second')[0];
|
||||
|
||||
const update = () => {
|
||||
const now = new Date();
|
||||
const sec = now.getSeconds();
|
||||
const min = now.getMinutes();
|
||||
const hour = now.getHours();
|
||||
|
||||
const secDeg = sec * 6;
|
||||
const minDeg = min * 6 + sec * 0.1;
|
||||
const hourDeg = (hour % 12) * 30 + min * 0.5;
|
||||
|
||||
secondHand.style.transform = `translate(-50%, -100%) rotate(${secDeg}deg)`;
|
||||
minuteHand.style.transform = `translate(-50%, -100%) rotate(${minDeg}deg)`;
|
||||
hourHand.style.transform = `translate(-50%, -100%) rotate(${hourDeg}deg)`;
|
||||
};
|
||||
|
||||
update();
|
||||
this._interval = setInterval(update, 1000);
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
if (this._interval) clearInterval(this._interval);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new AnalogWatchWidget();</code></pre>
|
||||
BIN
apps/server/src/assets/doc_notes/en/User Guide/User Guide/Scripting/Frontend Basics/Launch Bar Widgets/Analog Watch_image.png
generated
vendored
Normal file
BIN
apps/server/src/assets/doc_notes/en/User Guide/User Guide/Scripting/Frontend Basics/Launch Bar Widgets/Analog Watch_image.png
generated
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
31
apps/server/src/assets/doc_notes/en/User Guide/User Guide/Scripting/Frontend Basics/Launch Bar Widgets/Note Title Widget.html
generated
vendored
Normal file
31
apps/server/src/assets/doc_notes/en/User Guide/User Guide/Scripting/Frontend Basics/Launch Bar Widgets/Note Title Widget.html
generated
vendored
Normal file
@ -0,0 +1,31 @@
|
||||
<figure class="image">
|
||||
<img style="aspect-ratio:1007/94;" src="Note Title Widget_image.png"
|
||||
width="1007" height="94">
|
||||
</figure>
|
||||
<p>This is an example of a note context-aware widget, which reacts to the
|
||||
currently opened note and refreshes automatically as the user navigates
|
||||
through the notes.</p>
|
||||
<p>In this example, the title of the note is displayed. It works best on
|
||||
the <a href="#root/pOsGYCXsbNQG/gh7bpGYxajRS/Vc8PjrjAGuOp/_help_x0JgW8UqGXvq">horizontal layout</a>.</p><pre><code class="language-application-javascript-env-frontend">const TPL = `\
|
||||
<div style="
|
||||
display: flex;
|
||||
height: 53px;
|
||||
width: fit-content;
|
||||
font-size: 0.75em;
|
||||
contain: none;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
padding: 0 1em;
|
||||
"></div>`;
|
||||
|
||||
class NoteTitleWidget extends api.NoteContextAwareWidget {
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
}
|
||||
|
||||
async refreshWithNote(note) {
|
||||
this.$widget.text(note.title);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new NoteTitleWidget();</code></pre>
|
||||
BIN
apps/server/src/assets/doc_notes/en/User Guide/User Guide/Scripting/Frontend Basics/Launch Bar Widgets/Note Title Widget_image.png
generated
vendored
Normal file
BIN
apps/server/src/assets/doc_notes/en/User Guide/User Guide/Scripting/Frontend Basics/Launch Bar Widgets/Note Title Widget_image.png
generated
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
2
docs/Developer Guide/!!!meta.json
vendored
2
docs/Developer Guide/!!!meta.json
vendored
@ -1,6 +1,6 @@
|
||||
{
|
||||
"formatVersion": 2,
|
||||
"appVersion": "0.99.5",
|
||||
"appVersion": "0.100.0",
|
||||
"files": [
|
||||
{
|
||||
"isClone": false,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
# Documentation
|
||||
There are multiple types of documentation for Trilium:<img class="image-style-align-right" src="api/images/MKeODWYVO3J3/Documentation_image.png" width="205" height="162">
|
||||
There are multiple types of documentation for Trilium:<img class="image-style-align-right" src="api/images/hOX4EFIkAwyJ/Documentation_image.png" width="205" height="162">
|
||||
|
||||
* The _User Guide_ represents the user-facing documentation. This documentation can be browsed by users directly from within Trilium, by pressing <kbd>F1</kbd>.
|
||||
* The _Developer's Guide_ represents a set of Markdown documents that present the internals of Trilium, for developers.
|
||||
|
||||
172
docs/User Guide/!!!meta.json
vendored
172
docs/User Guide/!!!meta.json
vendored
@ -1,6 +1,6 @@
|
||||
{
|
||||
"formatVersion": 2,
|
||||
"appVersion": "0.99.5",
|
||||
"appVersion": "0.100.0",
|
||||
"files": [
|
||||
{
|
||||
"isClone": false,
|
||||
@ -15645,6 +15645,176 @@
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"isClone": false,
|
||||
"noteId": "4Gn3psZKsfSm",
|
||||
"notePath": [
|
||||
"pOsGYCXsbNQG",
|
||||
"CdNpE2pqjmI6",
|
||||
"yIhgI5H7A2Sm",
|
||||
"4Gn3psZKsfSm"
|
||||
],
|
||||
"title": "Launch Bar Widgets",
|
||||
"notePosition": 30,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
"mime": "text/html",
|
||||
"attributes": [
|
||||
{
|
||||
"type": "label",
|
||||
"name": "iconClass",
|
||||
"value": "bx bx-dock-left",
|
||||
"isInheritable": false,
|
||||
"position": 30
|
||||
},
|
||||
{
|
||||
"type": "label",
|
||||
"name": "shareAlias",
|
||||
"value": "launch-bar-widgets",
|
||||
"isInheritable": false,
|
||||
"position": 40
|
||||
},
|
||||
{
|
||||
"type": "relation",
|
||||
"name": "internalLink",
|
||||
"value": "MgibgPcfeuGz",
|
||||
"isInheritable": false,
|
||||
"position": 50
|
||||
},
|
||||
{
|
||||
"type": "relation",
|
||||
"name": "internalLink",
|
||||
"value": "xYmIYSP6wE3F",
|
||||
"isInheritable": false,
|
||||
"position": 60
|
||||
},
|
||||
{
|
||||
"type": "relation",
|
||||
"name": "internalLink",
|
||||
"value": "x3i7MxGccDuM",
|
||||
"isInheritable": false,
|
||||
"position": 70
|
||||
},
|
||||
{
|
||||
"type": "relation",
|
||||
"name": "internalLink",
|
||||
"value": "OFXdgB2nNk1F",
|
||||
"isInheritable": false,
|
||||
"position": 80
|
||||
},
|
||||
{
|
||||
"type": "relation",
|
||||
"name": "internalLink",
|
||||
"value": "IPArqVfDQ4We",
|
||||
"isInheritable": false,
|
||||
"position": 90
|
||||
},
|
||||
{
|
||||
"type": "relation",
|
||||
"name": "internalLink",
|
||||
"value": "gcI7RPbaNSh3",
|
||||
"isInheritable": false,
|
||||
"position": 100
|
||||
}
|
||||
],
|
||||
"format": "markdown",
|
||||
"dataFileName": "Launch Bar Widgets.md",
|
||||
"attachments": [],
|
||||
"dirFileName": "Launch Bar Widgets",
|
||||
"children": [
|
||||
{
|
||||
"isClone": false,
|
||||
"noteId": "IPArqVfDQ4We",
|
||||
"notePath": [
|
||||
"pOsGYCXsbNQG",
|
||||
"CdNpE2pqjmI6",
|
||||
"yIhgI5H7A2Sm",
|
||||
"4Gn3psZKsfSm",
|
||||
"IPArqVfDQ4We"
|
||||
],
|
||||
"title": "Note Title Widget",
|
||||
"notePosition": 10,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
"mime": "text/html",
|
||||
"attributes": [
|
||||
{
|
||||
"type": "label",
|
||||
"name": "shareAlias",
|
||||
"value": "note-title",
|
||||
"isInheritable": false,
|
||||
"position": 30
|
||||
},
|
||||
{
|
||||
"type": "label",
|
||||
"name": "shareAlias",
|
||||
"value": "note-title-widget",
|
||||
"isInheritable": false,
|
||||
"position": 40
|
||||
},
|
||||
{
|
||||
"type": "relation",
|
||||
"name": "internalLink",
|
||||
"value": "x0JgW8UqGXvq",
|
||||
"isInheritable": false,
|
||||
"position": 50
|
||||
}
|
||||
],
|
||||
"format": "markdown",
|
||||
"dataFileName": "Note Title Widget.md",
|
||||
"attachments": [
|
||||
{
|
||||
"attachmentId": "hgXS32zcBfVp",
|
||||
"title": "image.png",
|
||||
"role": "image",
|
||||
"mime": "image/png",
|
||||
"position": 10,
|
||||
"dataFileName": "Note Title Widget_image.png"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"isClone": false,
|
||||
"noteId": "gcI7RPbaNSh3",
|
||||
"notePath": [
|
||||
"pOsGYCXsbNQG",
|
||||
"CdNpE2pqjmI6",
|
||||
"yIhgI5H7A2Sm",
|
||||
"4Gn3psZKsfSm",
|
||||
"gcI7RPbaNSh3"
|
||||
],
|
||||
"title": "Analog Watch",
|
||||
"notePosition": 20,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
"mime": "text/html",
|
||||
"attributes": [
|
||||
{
|
||||
"type": "label",
|
||||
"name": "shareAlias",
|
||||
"value": "analog-watch",
|
||||
"isInheritable": false,
|
||||
"position": 30
|
||||
}
|
||||
],
|
||||
"format": "markdown",
|
||||
"dataFileName": "Analog Watch.md",
|
||||
"attachments": [
|
||||
{
|
||||
"attachmentId": "49vpwjjOEAm7",
|
||||
"title": "image.png",
|
||||
"role": "image",
|
||||
"mime": "image/png",
|
||||
"position": 10,
|
||||
"dataFileName": "Analog Watch_image.png"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@ -76,7 +76,7 @@ For each note of the calendar, the following attributes can be used:
|
||||
| `#startTime` | The time the event starts at. If this value is missing, then the event is considered a full-day event. The format is `HH:MM` (hours in 24-hour format and minutes). |
|
||||
| `#endTime` | Similar to `startTime`, it mentions the time at which the event ends (in relation with `endDate` if present, or `startDate`). |
|
||||
| `#color` | Displays the event with a specified color (named such as `red`, `gray` or hex such as `#FF0000`). This will also change the color of the note in other places such as the note tree. |
|
||||
| `#calendar:color` | **❌️ Removed since v0.100.0. Use** `**#color**` **instead.**<br><br>Similar to `#color`, but applies the color only for the event in the calendar and not for other places such as the note tree. |
|
||||
| `#calendar:color` | **❌️ Removed since v0.100.0. Use** `**#color**` **instead.** <br> <br>Similar to `#color`, but applies the color only for the event in the calendar and not for other places such as the note tree. |
|
||||
| `#iconClass` | If present, the icon of the note will be displayed to the left of the event title. |
|
||||
| `#calendar:title` | Changes the title of an event to point to an attribute of the note other than the title, can either a label or a relation (without the `#` or `~` symbol). See _Use-cases_ for more information. |
|
||||
| `#calendar:displayedAttributes` | Allows displaying the value of one or more attributes in the calendar like this: <br> <br> <br> <br>`#weight="70" #Mood="Good" #calendar:displayedAttributes="weight,Mood"` <br> <br>It can also be used with relations, case in which it will display the title of the target note: <br> <br>`~assignee=@My assignee #calendar:displayedAttributes="assignee"` |
|
||||
|
||||
16
docs/User Guide/User Guide/Scripting/Frontend Basics/Launch Bar Widgets.md
vendored
Normal file
16
docs/User Guide/User Guide/Scripting/Frontend Basics/Launch Bar Widgets.md
vendored
Normal file
@ -0,0 +1,16 @@
|
||||
# Launch Bar Widgets
|
||||
Launch bar widgets are a subset of <a class="reference-link" href="Custom%20Widgets.md">Custom Widgets</a> that can be used to render custom buttons and widgets inside the <a class="reference-link" href="../../Basic%20Concepts%20and%20Features/UI%20Elements/Launch%20Bar.md">Launch Bar</a>.
|
||||
|
||||
## Creating a launch bar widget
|
||||
|
||||
Unlike <a class="reference-link" href="Custom%20Widgets.md">Custom Widgets</a>, the process of setting up a launch bar widget is slightly different:
|
||||
|
||||
1. Create a Code note of type _JavaScript (front-end)_.
|
||||
* The script itself uses the same concepts as <a class="reference-link" href="Custom%20Widgets.md">Custom Widgets</a>, including the use of a `NoteContextAwareWidget` or a `BasicWidget` (according to needs).
|
||||
* As examples, see <a class="reference-link" href="Launch%20Bar%20Widgets/Note%20Title%20Widget.md">Note Title Widget</a> and <a class="reference-link" href="Launch%20Bar%20Widgets/Analog%20Watch.md">Analog Watch</a>.
|
||||
2. Don't set `#widget`, as that attribute is reserved for <a class="reference-link" href="Custom%20Widgets.md">Custom Widgets</a>.
|
||||
3. In the <a class="reference-link" href="../../Basic%20Concepts%20and%20Features/UI%20Elements/Global%20menu.md">Global menu</a>, select _Configure launchbar_.
|
||||
4. In the _Visible Launchers_ section, select _Add a custom widget_.
|
||||
5. Give the newly created launcher a name (and optionally a name).
|
||||
6. In the <a class="reference-link" href="../../Advanced%20Usage/Attributes/Promoted%20Attributes.md">Promoted Attributes</a> section, modify the _widget_ field to point to the newly created note.
|
||||
7. Refresh the UI.
|
||||
85
docs/User Guide/User Guide/Scripting/Frontend Basics/Launch Bar Widgets/Analog Watch.md
vendored
Normal file
85
docs/User Guide/User Guide/Scripting/Frontend Basics/Launch Bar Widgets/Analog Watch.md
vendored
Normal file
@ -0,0 +1,85 @@
|
||||
# Analog Watch
|
||||
<figure class="image"><img style="aspect-ratio:1007/94;" src="Analog Watch_image.png" width="1007" height="94"></figure>
|
||||
|
||||
This is a more intricate example of a basic widget, which displays an analog watch in the launch bar. Unlike note-context aware widgets, basic widgets don't react to note navigation.
|
||||
|
||||
```javascript
|
||||
const TPL = `
|
||||
<div class="analog-watch" style="
|
||||
position: relative;
|
||||
height: 38px;
|
||||
width: 38px;
|
||||
border-radius: 50%;
|
||||
background: white;
|
||||
border: 2px solid #444;
|
||||
flex-shrink: 0;
|
||||
">
|
||||
<!-- hour hand -->
|
||||
<div class="hand hour" style="
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 3px;
|
||||
height: 10px;
|
||||
background: #333;
|
||||
transform-origin: bottom center;
|
||||
"></div>
|
||||
|
||||
<!-- minute hand -->
|
||||
<div class="hand minute" style="
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 2px;
|
||||
height: 13px;
|
||||
background: #111;
|
||||
transform-origin: bottom center;
|
||||
"></div>
|
||||
|
||||
<!-- second hand -->
|
||||
<div class="hand second" style="
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 1px;
|
||||
height: 15px;
|
||||
background: red;
|
||||
transform-origin: bottom center;
|
||||
"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
class AnalogWatchWidget extends api.BasicWidget {
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
|
||||
const hourHand = this.$widget.find('.hand.hour')[0];
|
||||
const minuteHand = this.$widget.find('.hand.minute')[0];
|
||||
const secondHand = this.$widget.find('.hand.second')[0];
|
||||
|
||||
const update = () => {
|
||||
const now = new Date();
|
||||
const sec = now.getSeconds();
|
||||
const min = now.getMinutes();
|
||||
const hour = now.getHours();
|
||||
|
||||
const secDeg = sec * 6;
|
||||
const minDeg = min * 6 + sec * 0.1;
|
||||
const hourDeg = (hour % 12) * 30 + min * 0.5;
|
||||
|
||||
secondHand.style.transform = `translate(-50%, -100%) rotate(${secDeg}deg)`;
|
||||
minuteHand.style.transform = `translate(-50%, -100%) rotate(${minDeg}deg)`;
|
||||
hourHand.style.transform = `translate(-50%, -100%) rotate(${hourDeg}deg)`;
|
||||
};
|
||||
|
||||
update();
|
||||
this._interval = setInterval(update, 1000);
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
if (this._interval) clearInterval(this._interval);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new AnalogWatchWidget();
|
||||
```
|
||||
BIN
docs/User Guide/User Guide/Scripting/Frontend Basics/Launch Bar Widgets/Analog Watch_image.png
vendored
Normal file
BIN
docs/User Guide/User Guide/Scripting/Frontend Basics/Launch Bar Widgets/Analog Watch_image.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
32
docs/User Guide/User Guide/Scripting/Frontend Basics/Launch Bar Widgets/Note Title Widget.md
vendored
Normal file
32
docs/User Guide/User Guide/Scripting/Frontend Basics/Launch Bar Widgets/Note Title Widget.md
vendored
Normal file
@ -0,0 +1,32 @@
|
||||
# Note Title Widget
|
||||
<figure class="image"><img style="aspect-ratio:1007/94;" src="Note Title Widget_image.png" width="1007" height="94"></figure>
|
||||
|
||||
This is an example of a note context-aware widget, which reacts to the currently opened note and refreshes automatically as the user navigates through the notes.
|
||||
|
||||
In this example, the title of the note is displayed. It works best on the [horizontal layout](../../../Basic%20Concepts%20and%20Features/UI%20Elements/Vertical%20and%20horizontal%20layout.md).
|
||||
|
||||
```javascript
|
||||
const TPL = `\
|
||||
<div style="
|
||||
display: flex;
|
||||
height: 53px;
|
||||
width: fit-content;
|
||||
font-size: 0.75em;
|
||||
contain: none;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
padding: 0 1em;
|
||||
"></div>`;
|
||||
|
||||
class NoteTitleWidget extends api.NoteContextAwareWidget {
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
}
|
||||
|
||||
async refreshWithNote(note) {
|
||||
this.$widget.text(note.title);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new NoteTitleWidget();
|
||||
```
|
||||
BIN
docs/User Guide/User Guide/Scripting/Frontend Basics/Launch Bar Widgets/Note Title Widget_image.png
vendored
Normal file
BIN
docs/User Guide/User Guide/Scripting/Frontend Basics/Launch Bar Widgets/Note Title Widget_image.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
@ -24,6 +24,11 @@ type Labels = {
|
||||
orderBy: string;
|
||||
orderDirection: string;
|
||||
|
||||
// Launch bar
|
||||
bookmarkFolder: boolean;
|
||||
command: string;
|
||||
keyboardShortcut: string;
|
||||
|
||||
// Collection-specific
|
||||
viewType: string;
|
||||
status: string;
|
||||
@ -55,7 +60,11 @@ type Labels = {
|
||||
*/
|
||||
type Relations = [
|
||||
"searchScript",
|
||||
"ancestor"
|
||||
"ancestor",
|
||||
|
||||
// Launcher-specific
|
||||
"target",
|
||||
"widget"
|
||||
];
|
||||
|
||||
export type LabelNames = keyof Labels;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user