Merge remote-tracking branch 'origin/main' into chore/cleanup_regroup

This commit is contained in:
Elian Doran 2025-12-06 19:18:23 +02:00
commit c05c58c82b
No known key found for this signature in database
81 changed files with 3381 additions and 2870 deletions

View File

@ -90,7 +90,7 @@ jobs:
GPG_SIGNING_KEY: ${{ secrets.GPG_SIGN_KEY }}
- name: Publish release
uses: softprops/action-gh-release@v2.4.2
uses: softprops/action-gh-release@v2.5.0
if: ${{ github.event_name != 'pull_request' }}
with:
make_latest: false
@ -131,7 +131,7 @@ jobs:
arch: ${{ matrix.arch }}
- name: Publish release
uses: softprops/action-gh-release@v2.4.2
uses: softprops/action-gh-release@v2.5.0
if: ${{ github.event_name != 'pull_request' }}
with:
make_latest: false

View File

@ -127,7 +127,7 @@ jobs:
path: upload
- name: Publish stable release
uses: softprops/action-gh-release@v2.4.2
uses: softprops/action-gh-release@v2.5.0
with:
draft: false
body_path: docs/Release Notes/Release Notes/${{ github.ref_name }}.md

View File

@ -11,11 +11,11 @@
"license": "AGPL-3.0-only",
"packageManager": "pnpm@10.24.0",
"devDependencies": {
"@redocly/cli": "2.12.0",
"@redocly/cli": "2.12.3",
"archiver": "7.0.1",
"fs-extra": "11.3.2",
"react": "19.2.0",
"react-dom": "19.2.0",
"react": "19.2.1",
"react-dom": "19.2.1",
"typedoc": "0.28.15",
"typedoc-plugin-missing-exports": "4.1.2"
}

View File

@ -53,7 +53,7 @@
"leaflet-gpx": "2.2.0",
"mark.js": "8.11.1",
"marked": "17.0.1",
"mermaid": "11.12.1",
"mermaid": "11.12.2",
"mind-elixir": "5.3.7",
"normalize.css": "8.0.1",
"panzoom": "9.4.3",
@ -72,7 +72,7 @@
"@types/leaflet": "1.9.21",
"@types/leaflet-gpx": "1.3.8",
"@types/mark.js": "8.11.12",
"@types/reveal.js": "5.2.1",
"@types/reveal.js": "5.2.2",
"@types/tabulator-tables": "6.3.0",
"copy-webpack-plugin": "13.0.1",
"happy-dom": "20.0.11",

View File

@ -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} />);
}

View File

@ -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"))
)

View File

@ -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,

View File

@ -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;

View File

@ -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

View File

@ -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

View File

@ -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);

View File

@ -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()
})
});
}

View File

@ -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();
}
}
}

View File

@ -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();
}
}
}

View File

@ -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 {}
}

View File

@ -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();
}
}
}

View File

@ -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 17
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 (17)
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();
}
}

View File

@ -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;
}
}

View File

@ -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();
}
}

View File

@ -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;
}
}

View File

@ -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();
}
}
}
}

View File

@ -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;
}
}

View File

@ -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
}
}

View File

@ -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();
}
}

View File

@ -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> {}
}

View File

@ -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}"`);
}
}
}

View File

@ -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();
}
}
}

View 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;
}

View 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>
)
}

View 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");
}

View File

@ -10,7 +10,6 @@
.calendar-dropdown-widget {
margin: 0 auto;
overflow: hidden;
width: 100%;
}
@ -175,3 +174,12 @@
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%);
}

View 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);
}}
/>
)
}

View 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);
}
}}
/>
)
}

View 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);
}
}
});
}}
/>
)
}

View 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;
}

View 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;
}

View File

@ -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;
}

View 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);
}
}
});
}}
/>
)
}

View 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;
}

View 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;
}

View 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 ?? "")
};
}

View File

@ -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();
}
}

View File

@ -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}
/>;
}

View File

@ -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>

View File

@ -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} />;
}

View File

@ -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;
}

View File

@ -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
});
}
}

View File

@ -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"}`);
}
}
}

View File

@ -35,7 +35,7 @@
"@triliumnext/commons": "workspace:*",
"@triliumnext/server": "workspace:*",
"copy-webpack-plugin": "13.0.1",
"electron": "38.7.2",
"electron": "39.2.6",
"@electron-forge/cli": "7.10.2",
"@electron-forge/maker-deb": "7.10.2",
"@electron-forge/maker-dmg": "7.10.2",

View File

@ -12,7 +12,7 @@
"@triliumnext/desktop": "workspace:*",
"@types/fs-extra": "11.0.4",
"copy-webpack-plugin": "13.0.1",
"electron": "38.7.2",
"electron": "39.2.6",
"fs-extra": "11.3.2"
},
"scripts": {

View File

@ -8,7 +8,7 @@ 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');

View File

@ -81,11 +81,11 @@
"debounce": "3.0.0",
"debug": "4.4.3",
"ejs": "3.1.10",
"electron": "38.7.2",
"electron": "39.2.6",
"electron-debug": "4.1.0",
"electron-window-state": "5.0.3",
"escape-html": "1.0.3",
"express": "5.2.0",
"express": "5.2.1",
"express-http-proxy": "2.1.2",
"express-openid-connect": "2.19.3",
"express-rate-limit": "8.2.1",
@ -110,7 +110,7 @@
"multer": "2.0.2",
"normalize-strings": "1.1.1",
"ollama": "0.6.3",
"openai": "6.9.1",
"openai": "6.10.0",
"rand-token": "1.0.1",
"safe-compare": "1.1.4",
"sanitize-filename": "1.6.3",
@ -126,7 +126,7 @@
"tmp": "0.2.5",
"turndown": "7.2.2",
"unescape": "1.0.1",
"vite": "7.2.4",
"vite": "7.2.6",
"ws": "8.18.3",
"xml2js": "0.6.2",
"yauzl": "3.2.0"

File diff suppressed because one or more lines are too long

View File

@ -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>&nbsp;</th>
<th>&nbsp;</th>
<th>&nbsp;</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&nbsp;<a class="reference-link" href="#root/_help_ZjLYv08Rp3qC">Quick edit</a>&nbsp;to
<li>Left clicking the event will open a&nbsp;<a class="reference-link" href="#root/_help_ZjLYv08Rp3qC">Quick edit</a>&nbsp;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&nbsp;<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&nbsp;<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&nbsp;<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:&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
<br>
<br>
<img src="9_Calendar_image.png">&nbsp;&nbsp;&nbsp;
<br>
<br><code>#weight="70" #Mood="Good" #calendar:displayedAttributes="weight,Mood"</code>&nbsp;&nbsp;&nbsp;
<br>
<br>It can also be used with relations, case in which it will display the
title of the target note:&nbsp;&nbsp;&nbsp;&nbsp;
<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:&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
<br>
<br>
<img src="9_Calendar_image.png">&nbsp;&nbsp;&nbsp;
<br>
<br><code>#weight="70" #Mood="Good" #calendar:displayedAttributes="weight,Mood"</code>&nbsp;&nbsp;&nbsp;
<br>
<br>It can also be used with relations, case in which it will display the
title of the target note:&nbsp;&nbsp;&nbsp;&nbsp;
<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>&nbsp;</th>
<th>&nbsp;</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>&nbsp;</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>&nbsp;</th>
<th>&nbsp;</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 &nbsp;of attributes could cause the application
to loop infinitely).</p>
<figure class="table">
<table>
<thead>
<tr>
<th>&nbsp;</th>
<th>&nbsp;</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>&nbsp;</th>
<th>&nbsp;</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>&nbsp;</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>&nbsp;</th>
<th>&nbsp;</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 &nbsp;of attributes could cause the application
to loop infinitely).</p>
<table>
<thead>
<tr>
<th>&nbsp;</th>
<th>&nbsp;</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>

View File

@ -0,0 +1,33 @@
<p>Launch bar widgets are a subset of&nbsp;<a class="reference-link" href="#root/pOsGYCXsbNQG/CdNpE2pqjmI6/yIhgI5H7A2Sm/_help_MgibgPcfeuGz">Custom Widgets</a>&nbsp;that
can be used to render custom buttons and widgets inside the&nbsp;<a class="reference-link"
href="#root/pOsGYCXsbNQG/gh7bpGYxajRS/Vc8PjrjAGuOp/_help_xYmIYSP6wE3F">Launch Bar</a>.</p>
<h2>Creating a launch bar widget</h2>
<p>Unlike&nbsp;<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&nbsp;<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&nbsp;<a class="reference-link" href="#root/pOsGYCXsbNQG/CdNpE2pqjmI6/yIhgI5H7A2Sm/4Gn3psZKsfSm/_help_IPArqVfDQ4We">Note Title Widget</a>&nbsp;and&nbsp;
<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&nbsp;<a class="reference-link" href="#root/pOsGYCXsbNQG/CdNpE2pqjmI6/yIhgI5H7A2Sm/_help_MgibgPcfeuGz">Custom Widgets</a>.</li>
<li
data-list-item-id="e26f51e3ad87cebfa6c72504dab691804">In the&nbsp;<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&nbsp;<a class="reference-link" href="#root/pOsGYCXsbNQG/tC7s2alapj8V/zEY4DaJG4YT5/_help_OFXdgB2nNk1F">Promoted Attributes</a>&nbsp;section,
modify the <em>widget</em> field to point to the newly created note.</li>
<li
data-list-item-id="e5218927546ad96070b3028534e93131b">Refresh the UI.&nbsp;</li>
</ol>

View 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 = `
&lt;div class="analog-watch" style="
position: relative;
height: 38px;
width: 38px;
border-radius: 50%;
background: white;
border: 2px solid #444;
flex-shrink: 0;
"&gt;
&lt;!-- hour hand --&gt;
&lt;div class="hand hour" style="
position: absolute;
left: 50%;
top: 50%;
width: 3px;
height: 10px;
background: #333;
transform-origin: bottom center;
"&gt;&lt;/div&gt;
&lt;!-- minute hand --&gt;
&lt;div class="hand minute" style="
position: absolute;
left: 50%;
top: 50%;
width: 2px;
height: 13px;
background: #111;
transform-origin: bottom center;
"&gt;&lt;/div&gt;
&lt;!-- second hand --&gt;
&lt;div class="hand second" style="
position: absolute;
left: 50%;
top: 50%;
width: 1px;
height: 15px;
background: red;
transform-origin: bottom center;
"&gt;&lt;/div&gt;
&lt;/div&gt;
`;
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 = () =&gt; {
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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View 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 = `\
&lt;div style="
display: flex;
height: 53px;
width: fit-content;
font-size: 0.75em;
contain: none;
align-items: center;
flex-shrink: 0;
padding: 0 1em;
"&gt;&lt;/div&gt;`;
class NoteTitleWidget extends api.NoteContextAwareWidget {
doRender() {
this.$widget = $(TPL);
}
async refreshWithNote(note) {
this.$widget.text(note.title);
}
}
module.exports = new NoteTitleWidget();</code></pre>

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@ -22,8 +22,8 @@
"eslint-config-preact": "2.0.0",
"typescript": "5.9.3",
"user-agent-data-types": "0.4.2",
"vite": "7.2.4",
"vitest": "4.0.14"
"vite": "7.2.6",
"vitest": "4.0.15"
},
"eslintConfig": {
"extends": "preact"

View File

@ -89,6 +89,20 @@
"note_types": {
"text_title": "텍스트 노트",
"text_description": "노트는 WYSIWYG 편집기를 사용하며 표, 이미지, 수학 표현식, 구문 강조 기능의 코드 블록을 지원합니다. 특수문자를 사용한 마크다운 유사 구문이나 슬래시(/) 명령으로 텍스트 서식을 빠르게 지정할 수 있습니다.",
"code_title": "코드 노트"
"code_title": "코드 노트",
"title": "당신의 정보를 보여주는 다양한 방법",
"code_description": "대규모 소스 코드나 스크립트 샘플은 전용 편집기를 사용하여 다양한 프로그래밍 언어에 대한 구문 강조 기능과 다양한 색상 테마를 제공합니다.",
"file_title": "파일 노트",
"file_description": "PDF, 이미지, 인앱 어플리케이션 미리보기를 포함한 비디오와 같은 멀티미디어 파일을 저장하세요.",
"canvas_title": "캔버스",
"canvas_description": "excalidraw.com과 동일한 기술을 사용하여 무한한 캔버스에 도형, 이미지, 텍스트를 배치하세요. 다이어그램, 스케치, 시각적 계획에 이상적입니다.",
"mermaid_title": "Mermaid 다이어그램",
"mermaid_description": "Mermaid 구문을 사용하여 순서도(Flowchart), 클래스 및 시퀀스 다이어그램, 간트(Gantt) 차트 등의 다이어그램을 만들어 보세요.",
"mindmap_title": "마인드맵",
"mindmap_description": "생각을 시각적으로 정리하거나 브레인스토밍 세션을 진행하세요.",
"others_list": "기타: <0>노트 맵</0>, <1>관계 맵</1>, <2>저장된 검색</2>, <3>렌더링 노트</3>, <4>웹 뷰</4>."
},
"extensibility_benefits": {
"title": "공유 및 확장성"
}
}

View File

@ -1,6 +1,6 @@
{
"formatVersion": 2,
"appVersion": "0.99.5",
"appVersion": "0.100.0",
"files": [
{
"isClone": false,

View File

@ -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.

View File

@ -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"
}
]
}
]
}
]
},

View File

@ -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>![](9_Calendar_image.png)     <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"` |

View 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.

View 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();
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View 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();
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@ -46,15 +46,15 @@
"@fast-csv/parse": "5.0.5",
"@playwright/test": "1.57.0",
"@triliumnext/server": "workspace:*",
"@types/express": "5.0.5",
"@types/express": "5.0.6",
"@types/node": "24.10.1",
"@vitest/browser-webdriverio": "4.0.14",
"@vitest/coverage-v8": "4.0.14",
"@vitest/ui": "4.0.14",
"@vitest/browser-webdriverio": "4.0.15",
"@vitest/coverage-v8": "4.0.15",
"@vitest/ui": "4.0.15",
"chalk": "5.6.2",
"cross-env": "10.1.0",
"dpdm": "3.14.0",
"esbuild": "0.27.0",
"esbuild": "0.27.1",
"eslint": "9.39.1",
"eslint-config-preact": "2.0.0",
"eslint-config-prettier": "10.1.8",
@ -69,11 +69,11 @@
"tslib": "2.8.1",
"tsx": "4.21.0",
"typescript": "~5.9.0",
"typescript-eslint": "8.48.0",
"typescript-eslint": "8.48.1",
"upath": "2.0.1",
"vite": "7.2.4",
"vite": "7.2.6",
"vite-plugin-dts": "~4.5.0",
"vitest": "4.0.14"
"vitest": "4.0.15"
},
"license": "AGPL-3.0-only",
"author": {
@ -96,7 +96,7 @@
"@ckeditor/ckeditor5-code-block": "patches/@ckeditor__ckeditor5-code-block.patch"
},
"overrides": {
"mermaid": "11.12.1",
"mermaid": "11.12.2",
"preact": "10.28.0",
"roughjs": "4.6.6",
"@types/express-serve-static-core": "5.1.0",

View File

@ -25,9 +25,9 @@
"@ckeditor/ckeditor5-inspector": ">=4.1.0",
"@ckeditor/ckeditor5-package-tools": "5.0.1",
"@typescript-eslint/eslint-plugin": "~8.48.0",
"@typescript-eslint/parser": "8.48.0",
"@vitest/browser": "4.0.14",
"@vitest/coverage-istanbul": "4.0.14",
"@typescript-eslint/parser": "8.48.1",
"@vitest/browser": "4.0.15",
"@vitest/coverage-istanbul": "4.0.15",
"ckeditor5": "47.3.0",
"eslint": "9.39.1",
"eslint-config-ckeditor5": ">=9.1.0",
@ -38,7 +38,7 @@
"ts-node": "10.9.2",
"typescript": "5.9.3",
"vite-plugin-svgo": "~2.0.0",
"vitest": "4.0.14",
"vitest": "4.0.15",
"webdriverio": "9.21.0"
},
"peerDependencies": {

View File

@ -26,9 +26,9 @@
"@ckeditor/ckeditor5-inspector": ">=4.1.0",
"@ckeditor/ckeditor5-package-tools": "5.0.1",
"@typescript-eslint/eslint-plugin": "~8.48.0",
"@typescript-eslint/parser": "8.48.0",
"@vitest/browser": "4.0.14",
"@vitest/coverage-istanbul": "4.0.14",
"@typescript-eslint/parser": "8.48.1",
"@vitest/browser": "4.0.15",
"@vitest/coverage-istanbul": "4.0.15",
"ckeditor5": "47.3.0",
"eslint": "9.39.1",
"eslint-config-ckeditor5": ">=9.1.0",
@ -39,7 +39,7 @@
"ts-node": "10.9.2",
"typescript": "5.9.3",
"vite-plugin-svgo": "~2.0.0",
"vitest": "4.0.14",
"vitest": "4.0.15",
"webdriverio": "9.21.0"
},
"peerDependencies": {

View File

@ -28,9 +28,9 @@
"@ckeditor/ckeditor5-inspector": ">=4.1.0",
"@ckeditor/ckeditor5-package-tools": "5.0.1",
"@typescript-eslint/eslint-plugin": "~8.48.0",
"@typescript-eslint/parser": "8.48.0",
"@vitest/browser": "4.0.14",
"@vitest/coverage-istanbul": "4.0.14",
"@typescript-eslint/parser": "8.48.1",
"@vitest/browser": "4.0.15",
"@vitest/coverage-istanbul": "4.0.15",
"ckeditor5": "47.3.0",
"eslint": "9.39.1",
"eslint-config-ckeditor5": ">=9.1.0",
@ -41,7 +41,7 @@
"ts-node": "10.9.2",
"typescript": "5.9.3",
"vite-plugin-svgo": "~2.0.0",
"vitest": "4.0.14",
"vitest": "4.0.15",
"webdriverio": "9.21.0"
},
"peerDependencies": {

View File

@ -28,9 +28,9 @@
"@ckeditor/ckeditor5-inspector": ">=4.1.0",
"@ckeditor/ckeditor5-package-tools": "5.0.1",
"@typescript-eslint/eslint-plugin": "~8.48.0",
"@typescript-eslint/parser": "8.48.0",
"@vitest/browser": "4.0.14",
"@vitest/coverage-istanbul": "4.0.14",
"@typescript-eslint/parser": "8.48.1",
"@vitest/browser": "4.0.15",
"@vitest/coverage-istanbul": "4.0.15",
"ckeditor5": "47.3.0",
"eslint": "9.39.1",
"eslint-config-ckeditor5": ">=9.1.0",
@ -41,7 +41,7 @@
"ts-node": "10.9.2",
"typescript": "5.9.3",
"vite-plugin-svgo": "~2.0.0",
"vitest": "4.0.14",
"vitest": "4.0.15",
"webdriverio": "9.21.0"
},
"peerDependencies": {

View File

@ -28,9 +28,9 @@
"@ckeditor/ckeditor5-inspector": ">=4.1.0",
"@ckeditor/ckeditor5-package-tools": "5.0.1",
"@typescript-eslint/eslint-plugin": "~8.48.0",
"@typescript-eslint/parser": "8.48.0",
"@vitest/browser": "4.0.14",
"@vitest/coverage-istanbul": "4.0.14",
"@typescript-eslint/parser": "8.48.1",
"@vitest/browser": "4.0.15",
"@vitest/coverage-istanbul": "4.0.15",
"ckeditor5": "47.3.0",
"eslint": "9.39.1",
"eslint-config-ckeditor5": ">=9.1.0",
@ -41,7 +41,7 @@
"ts-node": "10.9.2",
"typescript": "5.9.3",
"vite-plugin-svgo": "~2.0.0",
"vitest": "4.0.14",
"vitest": "4.0.15",
"webdriverio": "9.21.0"
},
"peerDependencies": {

View File

@ -16,7 +16,7 @@
"ckeditor5-premium-features": "47.3.0"
},
"devDependencies": {
"@smithy/middleware-retry": "4.4.12",
"@smithy/middleware-retry": "4.4.14",
"@types/jquery": "3.5.33"
}
}

View File

@ -17,30 +17,30 @@
"@codemirror/legacy-modes": "6.5.2",
"@codemirror/search": "6.5.11",
"@codemirror/view": "6.38.8",
"@fsegurai/codemirror-theme-abcdef": "6.2.2",
"@fsegurai/codemirror-theme-abyss": "6.2.2",
"@fsegurai/codemirror-theme-android-studio": "6.2.2",
"@fsegurai/codemirror-theme-andromeda": "6.2.2",
"@fsegurai/codemirror-theme-basic-dark": "6.2.2",
"@fsegurai/codemirror-theme-basic-light": "6.2.2",
"@fsegurai/codemirror-theme-cobalt2": "6.0.2",
"@fsegurai/codemirror-theme-forest": "6.2.2",
"@fsegurai/codemirror-theme-github-dark": "6.2.2",
"@fsegurai/codemirror-theme-github-light": "6.2.2",
"@fsegurai/codemirror-theme-gruvbox-dark": "6.2.2",
"@fsegurai/codemirror-theme-gruvbox-light": "6.2.2",
"@fsegurai/codemirror-theme-material-dark": "6.2.2",
"@fsegurai/codemirror-theme-material-light": "6.2.2",
"@fsegurai/codemirror-theme-monokai": "6.2.2",
"@fsegurai/codemirror-theme-nord": "6.2.2",
"@fsegurai/codemirror-theme-palenight": "6.2.2",
"@fsegurai/codemirror-theme-solarized-dark": "6.2.2",
"@fsegurai/codemirror-theme-solarized-light": "6.2.2",
"@fsegurai/codemirror-theme-tokyo-night-day": "6.2.2",
"@fsegurai/codemirror-theme-tokyo-night-storm": "6.2.2",
"@fsegurai/codemirror-theme-volcano": "6.2.2",
"@fsegurai/codemirror-theme-vscode-dark": "6.2.2",
"@fsegurai/codemirror-theme-vscode-light": "6.2.2",
"@fsegurai/codemirror-theme-abcdef": "6.2.3",
"@fsegurai/codemirror-theme-abyss": "6.2.3",
"@fsegurai/codemirror-theme-android-studio": "6.2.3",
"@fsegurai/codemirror-theme-andromeda": "6.2.3",
"@fsegurai/codemirror-theme-basic-dark": "6.2.3",
"@fsegurai/codemirror-theme-basic-light": "6.2.3",
"@fsegurai/codemirror-theme-cobalt2": "6.0.3",
"@fsegurai/codemirror-theme-forest": "6.2.3",
"@fsegurai/codemirror-theme-github-dark": "6.2.3",
"@fsegurai/codemirror-theme-github-light": "6.2.3",
"@fsegurai/codemirror-theme-gruvbox-dark": "6.2.3",
"@fsegurai/codemirror-theme-gruvbox-light": "6.2.3",
"@fsegurai/codemirror-theme-material-dark": "6.2.3",
"@fsegurai/codemirror-theme-material-light": "6.2.3",
"@fsegurai/codemirror-theme-monokai": "6.2.3",
"@fsegurai/codemirror-theme-nord": "6.2.3",
"@fsegurai/codemirror-theme-palenight": "6.2.3",
"@fsegurai/codemirror-theme-solarized-dark": "6.2.3",
"@fsegurai/codemirror-theme-solarized-light": "6.2.3",
"@fsegurai/codemirror-theme-tokyo-night-day": "6.2.3",
"@fsegurai/codemirror-theme-tokyo-night-storm": "6.2.3",
"@fsegurai/codemirror-theme-volcano": "6.2.3",
"@fsegurai/codemirror-theme-vscode-dark": "6.2.3",
"@fsegurai/codemirror-theme-vscode-light": "6.2.3",
"@replit/codemirror-indentation-markers": "6.5.3",
"@replit/codemirror-lang-nix": "6.0.1",
"@replit/codemirror-vim": "6.3.0",

View File

@ -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;

View File

@ -27,15 +27,15 @@
"boxicons": "2.1.4",
"fuse.js": "7.1.0",
"katex": "0.16.25",
"mermaid": "11.12.0"
"mermaid": "11.12.2"
},
"devDependencies": {
"@digitak/esrun": "3.2.26",
"@triliumnext/ckeditor5": "workspace:*",
"@typescript-eslint/eslint-plugin": "8.48.0",
"@typescript-eslint/parser": "8.48.0",
"@typescript-eslint/eslint-plugin": "8.48.1",
"@typescript-eslint/parser": "8.48.1",
"dotenv": "17.2.3",
"esbuild": "0.27.0",
"esbuild": "0.27.1",
"eslint": "9.39.1",
"highlight.js": "11.11.1",
"typescript": "5.9.3"

1916
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff