diff --git a/apps/client/src/layouts/desktop_layout.tsx b/apps/client/src/layouts/desktop_layout.tsx index 3128581a9..479a163f2 100644 --- a/apps/client/src/layouts/desktop_layout.tsx +++ b/apps/client/src/layouts/desktop_layout.tsx @@ -30,7 +30,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 +42,8 @@ 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"; 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() diff --git a/apps/client/src/services/utils.ts b/apps/client/src/services/utils.ts index 2045cd4d7..e6da60ae5 100644 --- a/apps/client/src/services/utils.ts +++ b/apps/client/src/services/utils.ts @@ -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 | JQueryEventObject) { +export function isCtrlKey(evt: KeyboardEvent | MouseEvent | JQuery.ClickEvent | JQuery.ContextMenuEvent | JQuery.TriggeredEvent | React.PointerEvent | JQueryEventObject) { return (!isMac() && evt.ctrlKey) || (isMac() && evt.metaKey); } diff --git a/apps/client/src/stylesheets/theme-next/shell.css b/apps/client/src/stylesheets/theme-next/shell.css index 77d2a9ba6..fa3cda1e0 100644 --- a/apps/client/src/stylesheets/theme-next/shell.css +++ b/apps/client/src/stylesheets/theme-next/shell.css @@ -212,7 +212,7 @@ body[dir=ltr] #launcher-container { } #launcher-pane .launcher-button, -#launcher-pane .dropdown { +#launcher-pane .right-dropdown-widget { 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); diff --git a/apps/client/src/widgets/bookmark_buttons.ts b/apps/client/src/widgets/bookmark_buttons.ts deleted file mode 100644 index b32393c9a..000000000 --- a/apps/client/src/widgets/bookmark_buttons.ts +++ /dev/null @@ -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 { - private settings: BookmarkButtonsSettings; - private noteIds: string[]; - - constructor(isHorizontalLayout: boolean) { - super(isHorizontalLayout ? "row" : "column"); - - this.contentSized(); - this.settings = {}; - this.noteIds = []; - } - - async refresh(): Promise { - 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(); - } - } -} diff --git a/apps/client/src/widgets/buttons/ai_chat_button.ts b/apps/client/src/widgets/buttons/ai_chat_button.ts deleted file mode 100644 index 5ad3f8033..000000000 --- a/apps/client/src/widgets/buttons/ai_chat_button.ts +++ /dev/null @@ -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(); - } - } -} diff --git a/apps/client/src/widgets/buttons/bookmark_folder.ts b/apps/client/src/widgets/buttons/bookmark_folder.ts deleted file mode 100644 index b749a1ab6..000000000 --- a/apps/client/src/widgets/buttons/bookmark_folder.ts +++ /dev/null @@ -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 = ` -
- - -
- -
    -
    `; - -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 index 296699882..60b372fee 100644 --- a/apps/client/src/widgets/buttons/calendar.ts +++ b/apps/client/src/widgets/buttons/calendar.ts @@ -8,76 +8,8 @@ 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; @@ -110,7 +42,6 @@ export default class CalendarWidget extends RightDropdownButtonWidget { super.doRender(); this.$month = this.$dropdownContent.find('[data-calendar-area="month"]'); - this.$weekHeader = this.$dropdownContent.find(".calendar-week"); this.manageFirstDayOfWeek(); this.initWeekCalculation(); @@ -131,37 +62,6 @@ export default class CalendarWidget extends RightDropdownButtonWidget { } }); - 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"); @@ -226,17 +126,6 @@ export default class CalendarWidget extends RightDropdownButtonWidget { 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, @@ -244,34 +133,13 @@ export default class CalendarWidget extends RightDropdownButtonWidget { }; } - 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); + this.init( ?? 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')); + createDay() { const $date = $("").html(String(num)); const dateNoteId = dateNotesForMonth[this.date.local().format('YYYY-MM-DD')]; @@ -296,113 +164,13 @@ export default class CalendarWidget extends RightDropdownButtonWidget { $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", 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/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.tsx b/apps/client/src/widgets/containers/launcher.tsx new file mode 100644 index 000000000..2e9b6693f --- /dev/null +++ b/apps/client/src/widgets/containers/launcher.tsx @@ -0,0 +1,198 @@ +import SyncStatusWidget from "../sync_status.js"; +import BasicWidget, { wrapReactWidgets } from "../basic_widget.js"; +import utils, { isMobile } from "../../services/utils.js"; +import type FNote from "../../entities/fnote.js"; +import BookmarkButtons from "../launch_bar/BookmarkButtons.jsx"; +import SpacerWidget from "../launch_bar/SpacerWidget.jsx"; +import HistoryNavigationButton from "../launch_bar/HistoryNavigation.jsx"; +import AiChatButton from "../launch_bar/AiChatButton.jsx"; +import ProtectedSessionStatusWidget from "../launch_bar/ProtectedSessionStatusWidget.jsx"; +import { VNode } from "preact"; +import { CommandButton, CustomNoteLauncher, NoteLauncher } from "../launch_bar/GenericButtons.jsx"; +import date_notes from "../../services/date_notes.js"; +import { useLegacyWidget, useNoteContext, useNoteRelation, useNoteRelationTarget } from "../react/hooks.jsx"; +import QuickSearchWidget from "../quick_search.js"; +import { ParentComponent } from "../react/react_utils.jsx"; +import { useContext, useEffect, useMemo, useState } from "preact/hooks"; +import { LaunchBarActionButton, useLauncherIconAndTitle } from "../launch_bar/launch_bar_widgets.jsx"; +import CalendarWidget from "../launch_bar/CalendarWidget.jsx"; + +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 | VNode; + if (launcherType === "command") { + widget = wrapReactWidgets([ ])[0]; + } else if (launcherType === "note") { + widget = wrapReactWidgets([ ])[0]; + } else if (launcherType === "script") { + widget = wrapReactWidgets([ ])[0]; + } else if (launcherType === "customWidget") { + widget = wrapReactWidgets([ ])[0]; + } else if (launcherType === "builtinWidget") { + widget = wrapReactWidgets([ this.initBuiltinWidget(note) ])[0]; + } 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; + } + + 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 + 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 new SyncStatusWidget(); + 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 ScriptLauncher({ launcherNote }: { launcherNote: FNote }) { + const { icon, title } = useLauncherIconAndTitle(launcherNote); + return ( + { + if (launcherNote.isLabelTruthy("scriptInLauncherContent")) { + await launcherNote.executeScript(); + } else { + const script = await launcherNote.getRelationTarget("script"); + if (script) { + await script.executeScript(); + } + } + }} + /> + ) +} + +function TodayLauncher({ launcherNote }: { launcherNote: FNote }) { + return ( + { + const todayNote = await date_notes.getTodayNote(); + return todayNote?.noteId ?? null; + }} + /> + ); +} + +function QuickSearchLauncherWidget({ isHorizontalLayout }: { isHorizontalLayout: boolean }) { + const widget = useMemo(() => new QuickSearchWidget(), []); + const parentComponent = useContext(ParentComponent) as BasicWidget | null; + const isEnabled = isHorizontalLayout && !isMobile(); + parentComponent?.contentSized(); + + return ( +
    + {isEnabled && } +
    + ) +} + +function CustomWidget({ launcherNote }: { launcherNote: FNote }) { + const [ widgetNote ] = useNoteRelationTarget(launcherNote, "widget"); + const [ widget, setWidget ] = useState(); + const parentComponent = useContext(ParentComponent) as BasicWidget | null; + parentComponent?.contentSized(); + + useEffect(() => { + widgetNote?.executeScript().then(setWidget); + }, [ widgetNote ]); + + return ( +
    + {widget && } +
    + ) +} + +function LegacyWidgetRenderer({ widget }: { widget: BasicWidget }) { + const { noteContext } = useNoteContext(); + const [ widgetEl ] = useLegacyWidget(() => widget, { + noteContext + }); + return widgetEl; +} diff --git a/apps/client/src/widgets/launch_bar/AiChatButton.tsx b/apps/client/src/widgets/launch_bar/AiChatButton.tsx new file mode 100644 index 000000000..cd0d05e08 --- /dev/null +++ b/apps/client/src/widgets/launch_bar/AiChatButton.tsx @@ -0,0 +1,16 @@ +import FNote from "../../entities/fnote"; +import { useTriliumOptionBool } from "../react/hooks"; +import { LaunchBarActionButton, useLauncherIconAndTitle } from "./launch_bar_widgets"; + +export default function AiChatButton({ launcherNote }: { launcherNote: FNote }) { + const [ aiEnabled ] = useTriliumOptionBool("aiEnabled"); + const { icon, title } = useLauncherIconAndTitle(launcherNote); + + return aiEnabled && ( + + ) +} 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..676a2a800 --- /dev/null +++ b/apps/client/src/widgets/launch_bar/BookmarkButtons.tsx @@ -0,0 +1,58 @@ +import { useMemo } from "preact/hooks"; +import { LaunchBarDropdownButton, useLauncherIconAndTitle, type LaunchBarWidgetProps } 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({ isHorizontalLayout }: LaunchBarWidgetProps) { + 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..b1753a038 --- /dev/null +++ b/apps/client/src/widgets/launch_bar/Calendar.tsx @@ -0,0 +1,175 @@ +import { useTriliumOptionInt } from "../react/hooks"; +import clsx from "clsx"; +import server from "../../services/server"; +import { 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 default function Calendar({ date }: { date: Dayjs }) { + const [ rawFirstDayOfWeek ] = useTriliumOptionInt("firstDayOfWeek") ?? 0; + const firstDayOfWeekISO = (rawFirstDayOfWeek === 0 ? 7 : rawFirstDayOfWeek); + + const month = date.format('YYYY-MM'); + 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 } }: { date: Dayjs, info: DateRangeInfo }) { + 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 }: { date: Dayjs, firstDayOfWeekISO: number }) { + let dateCursor = date; + const currentMonth = date.month(); + const items: VNode[] = []; + 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 }: { date: Dayjs, dates: Dayjs[] }) { + 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 }: { date: Dayjs, dateNotesForMonth?: DateNotesForMonth, className?: string }) { + return ( +
    + + {date.date()} + + + ); +} + +function CalendarWeek({ weekNumber }: { weekNumber: number }) { + 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 98% rename from apps/client/src/stylesheets/calendar.css rename to apps/client/src/widgets/launch_bar/CalendarWidget.css index 314439846..72249b997 100644 --- a/apps/client/src/stylesheets/calendar.css +++ b/apps/client/src/widgets/launch_bar/CalendarWidget.css @@ -174,4 +174,8 @@ background-color: var(--hover-item-background-color); color: var(--hover-item-text-color); text-decoration: underline; +} + +.calendar-dropdown-widget .form-control { + padding: 0; } \ 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..2fcceb571 --- /dev/null +++ b/apps/client/src/widgets/launch_bar/CalendarWidget.tsx @@ -0,0 +1,129 @@ +import { Dispatch, StateUpdater, useEffect, useMemo, useState } from "preact/hooks"; +import FNote from "../../entities/fnote"; +import { LaunchBarDropdownButton, useLauncherIconAndTitle } from "./launch_bar_widgets"; +import { Dayjs, dayjs } from "@triliumnext/commons"; +import appContext from "../../components/app_context"; +import "./CalendarWidget.css"; +import Calendar from "./Calendar"; +import ActionButton from "../react/ActionButton"; +import { t } from "../../services/i18n"; +import FormDropdownList from "../react/FormDropdownList"; +import FormTextBox from "../react/FormTextBox"; + +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 }: { launcherNote: FNote }) { + const { title, icon } = useLauncherIconAndTitle(launcherNote); + const [ date, setDate ] = useState(); + + return ( + { + const dateNote = appContext.tabManager.getActiveContextNote()?.getOwnedLabelValue("dateNote"); + const activeDate = dateNote ? dayjs(`${dateNote}T12:00:00`) : null; + const todaysDate = dayjs(); + const date = dayjs(activeDate || todaysDate).startOf('month'); + setDate(date); + }} + dropdownOptions={{ + autoClose: "outside" + }} + > + {date &&
    + + +
    } +
    + ) +} + +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 ( +
    + + { + + }} + buttonProps={{ "data-calendar-input": "month" }} + /> + +
    + ); +} + +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..e9759343e --- /dev/null +++ b/apps/client/src/widgets/launch_bar/GenericButtons.tsx @@ -0,0 +1,89 @@ +import appContext, { CommandNames } 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 { useNoteLabel } from "../react/hooks"; +import { LaunchBarActionButton, useLauncherIconAndTitle } from "./launch_bar_widgets"; +import dialog from "../../services/dialog"; +import { t } from "../../services/i18n"; + +export function CommandButton({ launcherNote }: { launcherNote: FNote }) { + const { icon, title } = useLauncherIconAndTitle(launcherNote); + const [ command ] = useNoteLabel(launcherNote, "command"); + + return command && ( + + ) +} + +export function CustomNoteLauncher({ launcherNote, getTargetNoteId, getHoistedNoteId }: { + launcherNote: FNote, + getTargetNoteId: (launcherNote: FNote) => string | null | Promise, + getHoistedNoteId?: (launcherNote: FNote) => string | null +}) { + const { icon, title } = useLauncherIconAndTitle(launcherNote); + + async function launch(evt: MouseEvent) { + 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); + } + } + + return ( + { + evt.preventDefault(); + const targetNoteId = await getTargetNoteId(launcherNote); + if (targetNoteId) { + link_context_menu.openContextMenu(targetNoteId, evt); + } + }} + /> + ) +} + +// 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} + /> + ); +} 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..18dc67f05 --- /dev/null +++ b/apps/client/src/widgets/launch_bar/HistoryNavigation.tsx @@ -0,0 +1,84 @@ +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"; +} + +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 > 20) { + items = items.slice(0, 50); + } + + 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/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/RightDropdownButton.tsx b/apps/client/src/widgets/launch_bar/RightDropdownButton.tsx new file mode 100644 index 000000000..e47b5624c --- /dev/null +++ b/apps/client/src/widgets/launch_bar/RightDropdownButton.tsx @@ -0,0 +1,3 @@ +export default function RightDropdownButton() { + return

    Button goes here.

    ; +} 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/launch_bar_widgets.tsx b/apps/client/src/widgets/launch_bar/launch_bar_widgets.tsx new file mode 100644 index 000000000..d5302adc0 --- /dev/null +++ b/apps/client/src/widgets/launch_bar/launch_bar_widgets.tsx @@ -0,0 +1,46 @@ +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"; + +export interface LaunchBarWidgetProps { + isHorizontalLayout: boolean; +} + +export function LaunchBarActionButton(props: Omit) { + return ( + + ) +} + +export function LaunchBarDropdownButton({ children, icon, ...props }: Pick & { icon: string }) { + return ( + } + {...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/react/ActionButton.tsx b/apps/client/src/widgets/react/ActionButton.tsx index 28489005d..a37f34514 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,7 +16,7 @@ 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(); @@ -35,8 +35,8 @@ export default function ActionButton({ text, icon, className, onClick, triggerCo return