diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index dadce703a..bde9a606b 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cce3629d5..eab78541e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/apps/build-docs/package.json b/apps/build-docs/package.json index c2d4cd9fe..8e3329b73 100644 --- a/apps/build-docs/package.json +++ b/apps/build-docs/package.json @@ -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" } diff --git a/apps/client/package.json b/apps/client/package.json index 61cd4b377..91eaad1bc 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -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", diff --git a/apps/client/src/layouts/desktop_layout.tsx b/apps/client/src/layouts/desktop_layout.tsx index 3128581a9..50dc05d99 100644 --- a/apps/client/src/layouts/desktop_layout.tsx +++ b/apps/client/src/layouts/desktop_layout.tsx @@ -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() .child() - .child(new SpacerWidget(0, 1)) + .child() .child() .child() .child() @@ -185,14 +184,14 @@ export default class DesktopLayout { launcherPane = new FlexContainer("row") .css("height", "53px") .class("horizontal") - .child(new LauncherContainer(true)) + .child() .child(); } else { launcherPane = new FlexContainer("column") .css("width", "53px") .class("vertical") .child() - .child(new LauncherContainer(false)) + .child() .child(); } diff --git a/apps/client/src/layouts/mobile_layout.tsx b/apps/client/src/layouts/mobile_layout.tsx index 0c2b3e7ea..99c460024 100644 --- a/apps/client/src/layouts/mobile_layout.tsx +++ b/apps/client/src/layouts/mobile_layout.tsx @@ -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 = ` - -
- -
    -`; - -interface LinkOptions { - showTooltip: boolean; - showNoteIcon: boolean; -} - -export default class BookmarkFolderWidget extends RightDropdownButtonWidget { - private note: FNote; - private $parentNote!: JQuery; - private $childrenNotes!: JQuery; - declare $dropdownContent: JQuery; - - 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 { - 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($("
  • ").append((await linkService.createLink(childNote.noteId, linkOptions)).addClass("note-link"))); - } - } - - refreshIcon(): void {} -} diff --git a/apps/client/src/widgets/buttons/button_from_note.ts b/apps/client/src/widgets/buttons/button_from_note.ts deleted file mode 100644 index 64e680257..000000000 --- a/apps/client/src/widgets/buttons/button_from_note.ts +++ /dev/null @@ -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(); - } - } -} diff --git a/apps/client/src/widgets/buttons/calendar.ts b/apps/client/src/widgets/buttons/calendar.ts deleted file mode 100644 index 296699882..000000000 --- a/apps/client/src/widgets/buttons/calendar.ts +++ /dev/null @@ -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 = ` -
    - - -
    -
    - - - - - - -
    - -
    - - - - - -
    -
    - -
    -
    -
    `; - -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; - private $weekHeader!: JQuery; - private $monthSelect!: JQuery; - private $yearSelect!: JQuery; - private $next!: JQuery; - private $previous!: JQuery; - private $nextYear!: JQuery; - private $previousYear!: JQuery; - private monthDropdown!: Dropdown; - // stored in ISO 1–7 - private firstDayOfWeekISO!: number; - private weekCalculationOptions!: WeekCalculationOptions; - private activeDate: Dayjs | null = null; - private todaysDate!: Dayjs; - private date!: Dayjs; - private weekNoteEnable: boolean = false; - private weekNotes: string[] = []; - - constructor(title: string = "", icon: string = "") { - super(title, icon, DROPDOWN_TPL, "calendar-dropdown-menu"); - } - - doRender() { - super.doRender(); - - this.$month = this.$dropdownContent.find('[data-calendar-area="month"]'); - this.$weekHeader = this.$dropdownContent.find(".calendar-week"); - - this.manageFirstDayOfWeek(); - this.initWeekCalculation(); - - // Month navigation - this.$monthSelect = this.$dropdownContent.find('[data-calendar-input="month"]'); - this.$monthSelect.on("show.bs.dropdown", (e) => { - // Don't trigger dropdownShown() at widget level when the month selection dropdown is shown, since it would cause a redundant refresh. - e.stopPropagation(); - }); - this.monthDropdown = Dropdown.getOrCreateInstance(this.$monthSelect[0]); - this.$dropdownContent.find('[data-calendar-input="month-list"] button').on("click", (e) => { - const target = e.target as HTMLElement; - const value = target.dataset.value; - if (value) { - this.date = this.date.month(parseInt(value)); - this.createMonth(); - } - }); - - this.$next = this.$dropdownContent.find('[data-calendar-toggle="next"]'); - this.$next.on("click", () => { - this.date = this.date.add(1, 'month'); - this.createMonth(); - }); - this.$previous = this.$dropdownContent.find('[data-calendar-toggle="previous"]'); - this.$previous.on("click", () => { - this.date = this.date.subtract(1, 'month'); - this.createMonth(); - }); - - // Year navigation - this.$yearSelect = this.$dropdownContent.find('[data-calendar-input="year"]'); - this.$yearSelect.on("input", (e) => { - const target = e.target as HTMLInputElement; - this.date = this.date.year(parseInt(target.value)); - this.createMonth(); - }); - - this.$nextYear = this.$dropdownContent.find('[data-calendar-toggle="nextYear"]'); - this.$nextYear.on("click", () => { - this.date = this.date.add(1, 'year'); - this.createMonth(); - }); - - this.$previousYear = this.$dropdownContent.find('[data-calendar-toggle="previousYear"]'); - this.$previousYear.on("click", () => { - this.date = this.date.subtract(1, 'year'); - this.createMonth(); - }); - - // Date click - this.$dropdownContent.on("click", ".calendar-date", async (ev) => { - const date = $(ev.target).closest(".calendar-date").attr("data-calendar-date"); - if (date) { - const note = await dateNoteService.getDayNote(date); - if (note) { - appContext.tabManager.getActiveContext()?.setNote(note.noteId); - this.dropdown?.hide(); - } else { - toastService.showError(t("calendar.cannot_find_day_note")); - } - } - ev.stopPropagation(); - }); - - // Week click - this.$dropdownContent.on("click", ".calendar-week-number", async (ev) => { - if (!this.weekNoteEnable) { - return; - } - - const week = $(ev.target).closest(".calendar-week-number").attr("data-calendar-week-number"); - - if (week) { - const note = await dateNoteService.getWeekNote(week); - - if (note) { - appContext.tabManager.getActiveContext()?.setNote(note.noteId); - this.dropdown?.hide(); - } else { - toastService.showError(t("calendar.cannot_find_week_note")); - } - } - - ev.stopPropagation(); - }); - - // Handle click events for the entire calendar widget - this.$dropdownContent.on("click", (e) => { - const $target = $(e.target); - - // Keep dropdown open when clicking on month select button or year selector area - if ($target.closest('.btn.dropdown-toggle.select-button').length) { - e.stopPropagation(); - return; - } - - // Hide dropdown for all other cases - this.monthDropdown.hide(); - // Prevent dismissing the calendar popup by clicking on an empty space inside it. - e.stopPropagation(); - }); - } - - private async getWeekNoteEnable() { - const noteId = await server.get(`search/${encodeURIComponent('#calendarRoot')}`); - if (noteId.length === 0) { - this.weekNoteEnable = false; - return; - } - const noteAttributes = await server.get(`notes/${noteId}/attributes`); - this.weekNoteEnable = noteAttributes.some(a => a.name === 'enableWeekNote'); - } - - // Store firstDayOfWeek as ISO (1–7) - manageFirstDayOfWeek() { - const rawFirstDayOfWeek = options.getInt("firstDayOfWeek") || 0; - this.firstDayOfWeekISO = rawFirstDayOfWeek === 0 ? 7 : rawFirstDayOfWeek; - - let localeDaysOfWeek = [...DAYS_OF_WEEK]; - const shifted = localeDaysOfWeek.splice(0, rawFirstDayOfWeek); - localeDaysOfWeek = ['', ...localeDaysOfWeek, ...shifted]; - this.$weekHeader.html(localeDaysOfWeek.map((el) => `${el}`).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(`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 = $("") - .addClass("calendar-date") - .attr("data-calendar-date", this.date.local().format('YYYY-MM-DD')); - const $date = $("").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 = $("").addClass("calendar-date"); - if (this.weekNotes.includes(weekNoteId)) { - $newWeekNumber.addClass("calendar-date-exists").attr("data-href", `#root/${weekNoteId}`); - } - } else { - $newWeekNumber = $("").addClass("calendar-week-number-disabled"); - } - - $newWeekNumber.addClass("calendar-week-number").attr("data-calendar-week-number", weekNoteId); - $newWeekNumber.append($("").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(); - } -} diff --git a/apps/client/src/widgets/buttons/command_button.ts b/apps/client/src/widgets/buttons/command_button.ts deleted file mode 100644 index 49b147d35..000000000 --- a/apps/client/src/widgets/buttons/command_button.ts +++ /dev/null @@ -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) => void; -type CommandOrCallback = CommandNames | (() => CommandNames); - -interface CommandButtonWidgetSettings extends AbstractButtonWidgetSettings { - command?: CommandOrCallback; - onClick?: ClickHandler; - buttonNoteIdProvider?: ButtonNoteIdProvider | null; -} - -export default class CommandButtonWidget extends AbstractButtonWidget { - 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; - } -} diff --git a/apps/client/src/widgets/buttons/history_navigation.ts b/apps/client/src/widgets/buttons/history_navigation.ts deleted file mode 100644 index 74eaf6acc..000000000 --- a/apps/client/src/widgets/buttons/history_navigation.ts +++ /dev/null @@ -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[] = []; - - 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) => { - if (item && item.command && this.webContents) { - const idx = parseInt(item.command, 10); - this.webContents.navigationHistory.goToIndex(idx); - } - } - }); - } - - activeNoteChangedEvent() { - this.refresh(); - } -} diff --git a/apps/client/src/widgets/buttons/launcher/note_launcher.ts b/apps/client/src/widgets/buttons/launcher/note_launcher.ts deleted file mode 100644 index 00ba956a1..000000000 --- a/apps/client/src/widgets/buttons/launcher/note_launcher.ts +++ /dev/null @@ -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 { - 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; - } -} diff --git a/apps/client/src/widgets/buttons/launcher/script_launcher.ts b/apps/client/src/widgets/buttons/launcher/script_launcher.ts deleted file mode 100644 index 8a91e08b1..000000000 --- a/apps/client/src/widgets/buttons/launcher/script_launcher.ts +++ /dev/null @@ -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(); - } - } - } -} diff --git a/apps/client/src/widgets/buttons/launcher/today_launcher.ts b/apps/client/src/widgets/buttons/launcher/today_launcher.ts deleted file mode 100644 index 7e203bb7b..000000000 --- a/apps/client/src/widgets/buttons/launcher/today_launcher.ts +++ /dev/null @@ -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; - } -} diff --git a/apps/client/src/widgets/buttons/open_note_button_widget.ts b/apps/client/src/widgets/buttons/open_note_button_widget.ts deleted file mode 100644 index c0a4c6334..000000000 --- a/apps/client/src/widgets/buttons/open_note_button_widget.ts +++ /dev/null @@ -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 - } -} diff --git a/apps/client/src/widgets/buttons/protected_session_status.ts b/apps/client/src/widgets/buttons/protected_session_status.ts deleted file mode 100644 index e5dde7d3d..000000000 --- a/apps/client/src/widgets/buttons/protected_session_status.ts +++ /dev/null @@ -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(); - } -} diff --git a/apps/client/src/widgets/buttons/right_dropdown_button.ts b/apps/client/src/widgets/buttons/right_dropdown_button.ts deleted file mode 100644 index 45915c254..000000000 --- a/apps/client/src/widgets/buttons/right_dropdown_button.ts +++ /dev/null @@ -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*/` - -`; - -export default class RightDropdownButtonWidget extends BasicWidget { - protected iconClass: string; - protected title: string; - protected dropdownTpl: string; - protected settings: { titlePlacement: PopoverPlacement }; - protected $dropdownMenu!: JQuery; - protected dropdown!: Dropdown; - protected $tooltip!: JQuery; - protected tooltip!: Tooltip; - private dropdownClass?: string; - public $dropdownContent!: JQuery; - - 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 {} -} diff --git a/apps/client/src/widgets/containers/launcher.ts b/apps/client/src/widgets/containers/launcher.ts deleted file mode 100644 index e1bfc5a8b..000000000 --- a/apps/client/src/widgets/containers/launcher.ts +++ /dev/null @@ -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}"`); - } - } -} diff --git a/apps/client/src/widgets/containers/launcher_container.ts b/apps/client/src/widgets/containers/launcher_container.ts deleted file mode 100644 index f684d4e6b..000000000 --- a/apps/client/src/widgets/containers/launcher_container.ts +++ /dev/null @@ -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 { - 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(); - } - } -} diff --git a/apps/client/src/widgets/launch_bar/BookmarkButtons.css b/apps/client/src/widgets/launch_bar/BookmarkButtons.css new file mode 100644 index 000000000..b38ba59c0 --- /dev/null +++ b/apps/client/src/widgets/launch_bar/BookmarkButtons.css @@ -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; +} \ No newline at end of file diff --git a/apps/client/src/widgets/launch_bar/BookmarkButtons.tsx b/apps/client/src/widgets/launch_bar/BookmarkButtons.tsx new file mode 100644 index 000000000..f3b88c7aa --- /dev/null +++ b/apps/client/src/widgets/launch_bar/BookmarkButtons.tsx @@ -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(() => ({ + display: "flex", + flexDirection: isHorizontalLayout ? "row" : "column", + contain: "none" + }), [ isHorizontalLayout ]); + const childNotes = useChildNotes(PARENT_NOTE_ID); + + return ( +
    + {childNotes?.map(childNote => )} +
    + ) +} + +function SingleBookmark({ note }: { note: FNote }) { + const [ bookmarkFolder ] = useNoteLabelBoolean(note, "bookmarkFolder"); + return bookmarkFolder + ? + : note.noteId} /> +} + +function BookmarkFolder({ note }: { note: FNote }) { + const { icon, title } = useLauncherIconAndTitle(note); + const childNotes = useChildNotes(note.noteId); + + return ( + +
    +
    + +
    + +
      + {childNotes.map(childNote => ( +
    • + +
    • + ))} +
    +
    +
    + ) +} diff --git a/apps/client/src/widgets/launch_bar/Calendar.tsx b/apps/client/src/widgets/launch_bar/Calendar.tsx new file mode 100644 index 000000000..f081eee8c --- /dev/null +++ b/apps/client/src/widgets/launch_bar/Calendar.tsx @@ -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): void; + onWeekClicked?: (week: string, e: TargetedMouseEvent) => 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 ( + <> + +
    + {firstDayISO !== firstDayOfWeekISO && } + + +
    + + ) +} + +function CalendarWeekHeader({ rawFirstDayOfWeek }: { rawFirstDayOfWeek: number }) { + let localeDaysOfWeek = [...DAYS_OF_WEEK]; + const shifted = localeDaysOfWeek.splice(0, rawFirstDayOfWeek); + localeDaysOfWeek = ['', ...localeDaysOfWeek, ...shifted]; + + return ( +
    + {localeDaysOfWeek.map(dayOfWeek => {dayOfWeek})} +
    + ) +} + +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(); + + useEffect(() => { + server.get(`special-notes/notes-for-month/${prevMonth}`).then(setDateNotesForPrevMonth); + }, [ date ]); + + return ( + <> + + {dates.map(date => )} + + ) +} + +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(); + + useEffect(() => { + server.get(`special-notes/notes-for-month/${curMonthString}`).then(setDateNotesForCurMonth); + }, [ date ]); + + while (dateCursor.month() === currentMonth) { + const weekNumber = getWeekNumber(dateCursor, firstDayOfWeekISO); + if (dateCursor.isoWeekday() === firstDayOfWeekISO) { + items.push() + } + + items.push() + 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(); + + useEffect(() => { + server.get(`special-notes/notes-for-month/${nextMonth}`).then(setDateNotesForNextMonth); + }, [ date ]); + + return dates.map(date => ( + + )); +} + +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 ( +
    onDateClicked(dateString, e)} + > + + {date.date()} + + + ); +} + +function CalendarWeek({ date, weekNumber, weekNotes, onWeekClicked }: { weekNumber: number, weekNotes: string[] } & Pick) { + 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 ( + onWeekClicked(weekString, e)} + >{weekNumber} + ) + } + + return ( + {weekNumber}); +} + +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"); +} diff --git a/apps/client/src/stylesheets/calendar.css b/apps/client/src/widgets/launch_bar/CalendarWidget.css similarity index 95% rename from apps/client/src/stylesheets/calendar.css rename to apps/client/src/widgets/launch_bar/CalendarWidget.css index 314439846..a47d02627 100644 --- a/apps/client/src/stylesheets/calendar.css +++ b/apps/client/src/widgets/launch_bar/CalendarWidget.css @@ -10,7 +10,6 @@ .calendar-dropdown-widget { margin: 0 auto; - overflow: hidden; width: 100%; } @@ -174,4 +173,13 @@ background-color: var(--hover-item-background-color); color: var(--hover-item-text-color); text-decoration: underline; +} + +.calendar-dropdown-widget .form-control { + padding: 0; +} + +.calendar-dropdown-widget .calendar-month-selector .dropdown-menu { + left: 50%; + transform: translateX(-50%); } \ No newline at end of file diff --git a/apps/client/src/widgets/launch_bar/CalendarWidget.tsx b/apps/client/src/widgets/launch_bar/CalendarWidget.tsx new file mode 100644 index 000000000..197267223 --- /dev/null +++ b/apps/client/src/widgets/launch_bar/CalendarWidget.tsx @@ -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>(); + const [ date, setDate ] = useState(); + const dropdownRef = useRef(null); + const [ enableWeekNotes, setEnableWeekNotes ] = useState(false); + const [ weekNotes, setWeekNotes ] = useState([]); + const calendarRootRef = useRef(); + + 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(`attribute-values/weekNote`).then(setWeekNotes); + } + } + + return ( + { + 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 &&
    + + { + 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} + /> +
    } +
    + ) +} + +interface CalendarHeaderProps { + date: Dayjs; + setDate: Dispatch>; +} + +function CalendarHeader(props: CalendarHeaderProps) { + return ( +
    + + +
    + ) +} + +function CalendarMonthSelector({ date, setDate }: CalendarHeaderProps) { + const months = useMemo(() => ( + Array.from(MONTHS.entries().map(([ index, text ]) => ({ + index: index.toString(), text + }))) + ), []); + + return ( +
    + + setDate(date.set("month", parseInt(index, 10)))} + buttonProps={{ "data-calendar-input": "month" }} + dropdownOptions={{ display: "static" }} + /> + +
    + ); +} + +function CalendarYearSelector({ date, setDate }: CalendarHeaderProps) { + return ( +
    + + { + const year = parseInt(newValue, 10); + if (!Number.isNaN(year)) { + setDate(date.set("year", year)); + } + }} + data-calendar-input="year" + /> + +
    + ) +} + +function AdjustDateButton({ date, setDate, unit, direction }: CalendarHeaderProps & { + direction: "prev" | "next", + unit: "month" | "year" +}) { + return ( + { + e.stopPropagation(); + const newDate = direction === "prev" ? date.subtract(1, unit) : date.add(1, unit); + setDate(newDate); + }} + /> + ) +} diff --git a/apps/client/src/widgets/launch_bar/GenericButtons.tsx b/apps/client/src/widgets/launch_bar/GenericButtons.tsx new file mode 100644 index 000000000..9af775760 --- /dev/null +++ b/apps/client/src/widgets/launch_bar/GenericButtons.tsx @@ -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; + 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 ( + { + evt.preventDefault(); + const targetNoteId = await getTargetNoteId(launcherNote); + if (targetNoteId) { + link_context_menu.openContextMenu(targetNoteId, evt); + } + }} + /> + ) +} diff --git a/apps/client/src/widgets/launch_bar/HistoryNavigation.tsx b/apps/client/src/widgets/launch_bar/HistoryNavigation.tsx new file mode 100644 index 000000000..f9ea51c57 --- /dev/null +++ b/apps/client/src/widgets/launch_bar/HistoryNavigation.tsx @@ -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(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 ( + { + e.preventDefault(); + + const webContents = webContentsRef.current; + if (!webContents || webContents.navigationHistory.length() < 2) { + return; + } + + let items: MenuCommandItem[] = []; + + 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) => { + if (item && item.command && webContents) { + const idx = parseInt(item.command, 10); + webContents.navigationHistory.goToIndex(idx); + } + } + }); + }} + /> + ) +} diff --git a/apps/client/src/widgets/launch_bar/LauncherContainer.tsx b/apps/client/src/widgets/launch_bar/LauncherContainer.tsx new file mode 100644 index 000000000..26a502a8a --- /dev/null +++ b/apps/client/src/widgets/launch_bar/LauncherContainer.tsx @@ -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 ( +
    { + if ((e.target as HTMLElement).closest(".dropdown-menu")) return; + onWheelHorizontalScroll(e); + } : undefined} + > + + {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 + })} + +
    + ) +} + +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 ; + case "note": + return ; + case "script": + return ; + case "customWidget": + return ; + 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 + 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 ; + case "bookmarks": + return ; + case "protectedSession": + return ; + case "syncStatus": + return ; + case "backInHistoryButton": + return + case "forwardInHistoryButton": + return + case "todayInJournal": + return + case "quickSearch": + return + case "aiChatLauncher": + return + default: + throw new Error(`Unrecognized builtin widget ${builtinWidget} for launcher ${note.noteId} "${note.title}"`); + } +} + +function useLauncherChildNotes() { + const [ visibleLaunchersRoot, setVisibleLaunchersRoot ] = useState(); + const [ childNotes, setChildNotes ] = useState(); + + // 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; +} diff --git a/apps/client/src/widgets/launch_bar/LauncherDefinitions.tsx b/apps/client/src/widgets/launch_bar/LauncherDefinitions.tsx new file mode 100644 index 000000000..a0c379b22 --- /dev/null +++ b/apps/client/src/widgets/launch_bar/LauncherDefinitions.tsx @@ -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 && ( + + ) +} + +// 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 ( + { + 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 ( + + ) +} + +export function AiChatButton({ launcherNote }: LauncherNoteProps) { + const [ aiEnabled ] = useTriliumOptionBool("aiEnabled"); + const { icon, title } = useLauncherIconAndTitle(launcherNote); + + return aiEnabled && ( + + ) +} + +export function TodayLauncher({ launcherNote }: LauncherNoteProps) { + return ( + { + 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 ( +
    + {isEnabled && } +
    + ) +} + +export function CustomWidget({ launcherNote }: LauncherNoteProps) { + const [ widgetNote ] = useNoteRelationTarget(launcherNote, "widget"); + const [ widget, setWidget ] = useState(); + 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 ( +
    + {widget && } +
    + ) +} + +export function LegacyWidgetRenderer({ widget }: { widget: BasicWidget }) { + const [ widgetEl ] = useLegacyWidget(() => widget, { + noteContext: appContext.tabManager.getActiveContext() ?? undefined + }); + + return widgetEl; +} diff --git a/apps/client/src/widgets/launch_bar/ProtectedSessionStatusWidget.tsx b/apps/client/src/widgets/launch_bar/ProtectedSessionStatusWidget.tsx new file mode 100644 index 000000000..539643d4f --- /dev/null +++ b/apps/client/src/widgets/launch_bar/ProtectedSessionStatusWidget.tsx @@ -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 ? ( + + ) : ( + + ) + ) +} + +function useProtectedSessionAvailable() { + const [ protectedSessionAvailable, setProtectedSessionAvailable ] = useState(protected_session_holder.isProtectedSessionAvailable()); + useTriliumEvent("protectedSessionStarted", () => { + setProtectedSessionAvailable(protected_session_holder.isProtectedSessionAvailable()); + }); + return protectedSessionAvailable; +} diff --git a/apps/client/src/widgets/launch_bar/SpacerWidget.tsx b/apps/client/src/widgets/launch_bar/SpacerWidget.tsx new file mode 100644 index 000000000..5f89369c2 --- /dev/null +++ b/apps/client/src/widgets/launch_bar/SpacerWidget.tsx @@ -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 ( +
    { + e.preventDefault(); + contextMenu.show({ + 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); + } + } + }); + }} + /> + ) +} diff --git a/apps/client/src/widgets/launch_bar/SyncStatus.css b/apps/client/src/widgets/launch_bar/SyncStatus.css new file mode 100644 index 000000000..dc9795e6a --- /dev/null +++ b/apps/client/src/widgets/launch_bar/SyncStatus.css @@ -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; +} \ No newline at end of file diff --git a/apps/client/src/widgets/launch_bar/SyncStatus.tsx b/apps/client/src/widgets/launch_bar/SyncStatus.tsx new file mode 100644 index 000000000..3cf8ab777 --- /dev/null +++ b/apps/client/src/widgets/launch_bar/SyncStatus.tsx @@ -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 = { + 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(null); + const [ syncServerHost ] = useTriliumOption("syncServerHost"); + useStaticTooltip(spanRef, { + html: true + // TODO: Placement + }); + + return (syncServerHost && +
    +
    + { + if (syncState === "in-progress") return; + sync.syncNow(); + }} + > + {hasChanges && ( + + )} + +
    +
    + ) +} + +function useSyncStatus() { + const [ syncState, setSyncState ] = useState("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; +} diff --git a/apps/client/src/widgets/launch_bar/launch_bar_widgets.tsx b/apps/client/src/widgets/launch_bar/launch_bar_widgets.tsx new file mode 100644 index 000000000..e3f219c93 --- /dev/null +++ b/apps/client/src/widgets/launch_bar/launch_bar_widgets.tsx @@ -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) { + const { isHorizontalLayout } = useContext(LaunchBarContext); + + return ( + + ) +} + +export function LaunchBarDropdownButton({ children, icon, dropdownOptions, ...props }: Pick & { icon: string }) { + const { isHorizontalLayout } = useContext(LaunchBarContext); + + return ( + } + titlePosition={isHorizontalLayout ? "bottom" : "right"} + titleOptions={{ animation: false }} + dropdownOptions={{ + ...dropdownOptions, + popperConfig: { + placement: isHorizontalLayout ? "bottom" : "right" + } + }} + {...props} + >{children} + ) +} + +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 ?? "") + }; +} diff --git a/apps/client/src/widgets/quick_search_launcher.ts b/apps/client/src/widgets/quick_search_launcher.ts deleted file mode 100644 index f64d1ca1d..000000000 --- a/apps/client/src/widgets/quick_search_launcher.ts +++ /dev/null @@ -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. - * - *

    - * 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(); - } -} diff --git a/apps/client/src/widgets/react/ActionButton.tsx b/apps/client/src/widgets/react/ActionButton.tsx index 28489005d..ba5430f38 100644 --- a/apps/client/src/widgets/react/ActionButton.tsx +++ b/apps/client/src/widgets/react/ActionButton.tsx @@ -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, "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(null); const [ keyboardShortcut, setKeyboardShortcut ] = useState(); 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