diff --git a/src/public/app/components/app_context.ts b/src/public/app/components/app_context.ts index 145be6504..b28dda410 100644 --- a/src/public/app/components/app_context.ts +++ b/src/public/app/components/app_context.ts @@ -18,6 +18,8 @@ import NoteDetailWidget from "../widgets/note_detail.js"; import { ResolveOptions } from "../widgets/dialogs/delete_notes.js"; import { PromptDialogOptions } from "../widgets/dialogs/prompt.js"; import { ConfirmWithMessageOptions, ConfirmWithTitleOptions } from "../widgets/dialogs/confirm.js"; +import { Node } from "../services/tree.js"; +import FAttribute from "../entities/fattribute.js"; interface Layout { getRootWidget: (appContext: AppContext) => RootWidget; @@ -31,11 +33,28 @@ interface BeforeUploadListener extends Component { beforeUnloadEvent(): boolean; } +/** + * Base interface for the data/arguments for a given command (see {@link CommandMappings}). + */ interface CommandData { ntxId?: string; } -type CommandMappings = { +/** + * Represents a set of commands that are triggered from the context menu, providing information such as the selected note. + */ +export interface ContextMenuCommandData extends CommandData { + node: Node; + notePath: string; + noteId?: string; + selectedOrActiveBranchIds: any; // TODO: Remove any once type is defined + selectedOrActiveNoteIds: any; // TODO: Remove any once type is defined +} + +/** + * The keys represent the different commands that can be triggered via {@link AppContext#triggerCommand} (first argument), and the values represent the data or arguments definition of the given command. All data for commands must extend {@link CommandData}. + */ +export type CommandMappings = { "api-log-messages": CommandData; focusOnDetail: Required; searchNotes: CommandData & { @@ -73,12 +92,42 @@ type CommandMappings = { openNoteInNewTab: CommandData; openNoteInNewSplit: CommandData; openNoteInNewWindow: CommandData; - openNoteInSplit: CommandData; - openInTab: CommandData; - insertNoteAfter: CommandData; - insertChildNote: CommandData; - convertNoteToAttachment: CommandData; - copyNotePathToClipboard: CommandData; + + openInTab: ContextMenuCommandData; + openNoteInSplit: ContextMenuCommandData; + toggleNoteHoisting: ContextMenuCommandData; + insertNoteAfter: ContextMenuCommandData; + insertChildNote: ContextMenuCommandData; + protectSubtree: ContextMenuCommandData; + unprotectSubtree: ContextMenuCommandData; + openBulkActionsDialog: ContextMenuCommandData; + editBranchPrefix: ContextMenuCommandData; + convertNoteToAttachment: ContextMenuCommandData; + duplicateSubtree: ContextMenuCommandData; + expandSubtree: ContextMenuCommandData; + collapseSubtree: ContextMenuCommandData; + sortChildNotes: ContextMenuCommandData; + copyNotePathToClipboard: ContextMenuCommandData; + recentChangesInSubtree: ContextMenuCommandData; + cutNotesToClipboard: ContextMenuCommandData; + copyNotesToClipboard: ContextMenuCommandData; + pasteNotesFromClipboard: ContextMenuCommandData; + pasteNotesAfterFromClipboard: ContextMenuCommandData; + moveNotesTo: ContextMenuCommandData; + cloneNotesTo: ContextMenuCommandData; + deleteNotes: ContextMenuCommandData; + importIntoNote: ContextMenuCommandData; + exportNote: ContextMenuCommandData; + searchInSubtree: ContextMenuCommandData; + + addNoteLauncher: ContextMenuCommandData; + addScriptLauncher: ContextMenuCommandData; + addWidgetLauncher: ContextMenuCommandData; + addSpacerLauncher: ContextMenuCommandData; + moveLauncherToVisible: ContextMenuCommandData; + moveLauncherToAvailable: ContextMenuCommandData; + resetLauncher: ContextMenuCommandData; + executeInActiveNoteDetailWidget: CommandData & { callback: (value: NoteDetailWidget | PromiseLike) => void }; @@ -92,17 +141,11 @@ type CommandMappings = { showPasswordNotSet: CommandData; showProtectedSessionPasswordDialog: CommandData; closeProtectedSessionPasswordDialog: CommandData; - resetLauncher: CommandData; - addNoteLauncher: CommandData; - addScriptLauncher: CommandData; - addWidgetLauncher: CommandData; - addSpacerLauncher: CommandData; - moveLauncherToVisible: CommandData; - moveLauncherToAvailable: CommandData; - duplicateSubtree: CommandData; - deleteNotes: CommandData; copyImageReferenceToClipboard: CommandData; copyImageToClipboard: CommandData; + updateAttributesList: { + attributes: FAttribute[]; + }; } type EventMappings = { @@ -123,9 +166,19 @@ type EventMappings = { type CommandAndEventMappings = (CommandMappings & EventMappings); +/** + * This type is a discriminated union which contains all the possible commands that can be triggered via {@link AppContext.triggerCommand}. + */ export type CommandNames = keyof CommandMappings; type EventNames = keyof EventMappings; +type FilterByValueType = { [K in keyof T]: T[K] extends ValueType ? K : never; }[keyof T]; + +/** + * Generic which filters {@link CommandNames} to provide only those commands that take in as data the desired implementation of {@link CommandData}. Mostly useful for contextual menu, to enforce consistency in the commands. + */ +export type FilteredCommandNames = keyof Pick>; + class AppContext extends Component { isMainWindow: boolean; @@ -225,9 +278,8 @@ class AppContext extends Component { return this.handleEvent(name, data); } - // TODO: Remove ignore once all commands are mapped out. - //@ts-ignore - triggerCommand(name: K, data: CommandMappings[K] = {}) { + triggerCommand(name: K, _data?: CommandMappings[K]) { + const data = _data || {}; for (const executor of this.components) { const fun = (executor as any)[`${name}Command`]; diff --git a/src/public/app/components/component.ts b/src/public/app/components/component.ts index e92a6137f..2761bfdc6 100644 --- a/src/public/app/components/component.ts +++ b/src/public/app/components/component.ts @@ -1,4 +1,5 @@ import utils from '../services/utils.js'; +import { CommandMappings, CommandNames } from './app_context.js'; /** * Abstract class for all components in the Trilium's frontend. @@ -84,7 +85,8 @@ export default class Component { return promises.length > 0 ? Promise.all(promises) : null; } - triggerCommand(name: string, data = {}): Promise | undefined | null { + triggerCommand(name: string, _data?: CommandMappings[K]): Promise | undefined | null { + const data = _data || {}; const fun = (this as any)[`${name}Command`]; if (fun) { diff --git a/src/public/app/components/events.ts b/src/public/app/components/events.ts index bc60de92f..4445ce8c7 100644 --- a/src/public/app/components/events.ts +++ b/src/public/app/components/events.ts @@ -1,7 +1,8 @@ import { MenuCommandItem } from "../menus/context_menu.js"; +import { CommandNames } from "./app_context.js"; type ListenerReturnType = void | Promise; -export interface SelectMenuItemEventListener { - selectMenuItemHandler(item: MenuCommandItem): ListenerReturnType; +export interface SelectMenuItemEventListener { + selectMenuItemHandler(item: MenuCommandItem): ListenerReturnType; } diff --git a/src/public/app/menus/context_menu.ts b/src/public/app/menus/context_menu.ts index 5bc43e76f..f3bd9d01b 100644 --- a/src/public/app/menus/context_menu.ts +++ b/src/public/app/menus/context_menu.ts @@ -1,39 +1,39 @@ import { CommandNames } from '../components/app_context.js'; import keyboardActionService from '../services/keyboard_actions.js'; -interface ContextMenuOptions { +interface ContextMenuOptions { x: number; y: number; orientation?: "left"; - selectMenuItemHandler: MenuHandler; - items: MenuItem[]; + selectMenuItemHandler: MenuHandler; + items: MenuItem[]; } interface MenuSeparatorItem { title: "----" } -export interface MenuCommandItem { +export interface MenuCommandItem { title: string; - command?: CommandNames; + command?: T; type?: string; uiIcon?: string; templateNoteId?: string; enabled?: boolean; - handler?: MenuHandler; - items?: MenuItem[]; + handler?: MenuHandler; + items?: MenuItem[] | null; shortcut?: string; spellingSuggestion?: string; } -export type MenuItem = MenuCommandItem | MenuSeparatorItem; -export type MenuHandler = (item: MenuCommandItem, e: JQuery.MouseDownEvent) => void; +export type MenuItem = MenuCommandItem | MenuSeparatorItem; +export type MenuHandler = (item: MenuCommandItem, e: JQuery.MouseDownEvent) => void; class ContextMenu { private $widget!: JQuery; private dateContextMenuOpenedMs: number; - private options?: ContextMenuOptions; + private options?: ContextMenuOptions; constructor() { this.$widget = $("#context-menu-container"); @@ -43,7 +43,7 @@ class ContextMenu { $(document).on('click', () => this.hide()); } - async show(options: ContextMenuOptions) { + async show(options: ContextMenuOptions) { this.options = options; if (this.$widget.hasClass("show")) { @@ -119,7 +119,7 @@ class ContextMenu { }).addClass("show"); } - addItems($parent: JQuery, items: MenuItem[]) { + addItems($parent: JQuery, items: MenuItem[]) { for (const item of items) { if (!item) { continue; diff --git a/src/public/app/menus/electron_context_menu.ts b/src/public/app/menus/electron_context_menu.ts index 68bab3636..c8bbb0a41 100644 --- a/src/public/app/menus/electron_context_menu.ts +++ b/src/public/app/menus/electron_context_menu.ts @@ -4,6 +4,7 @@ import zoomService from "../components/zoom.js"; import contextMenu, { MenuItem } from "./context_menu.js"; import { t } from "../services/i18n.js"; import type { BrowserWindow } from "electron"; +import { CommandNames } from "../components/app_context.js"; function setupContextMenu() { const electron = utils.dynamicRequire('electron'); @@ -18,7 +19,7 @@ function setupContextMenu() { const isMac = process.platform === "darwin"; const platformModifier = isMac ? 'Meta' : 'Ctrl'; - const items: MenuItem[] = []; + const items: MenuItem[] = []; if (params.misspelledWord) { for (const suggestion of params.dictionarySuggestions) { diff --git a/src/public/app/menus/launcher_context_menu.ts b/src/public/app/menus/launcher_context_menu.ts index 4b3d48587..d14a56ebd 100644 --- a/src/public/app/menus/launcher_context_menu.ts +++ b/src/public/app/menus/launcher_context_menu.ts @@ -6,8 +6,11 @@ import server from "../services/server.js"; import { t } from '../services/i18n.js'; import type { SelectMenuItemEventListener } from '../components/events.js'; import NoteTreeWidget from '../widgets/note_tree.js'; +import { FilteredCommandNames, ContextMenuCommandData } from '../components/app_context.js'; -export default class LauncherContextMenu implements SelectMenuItemEventListener { +type LauncherCommandNames = FilteredCommandNames; + +export default class LauncherContextMenu implements SelectMenuItemEventListener { private treeWidget: NoteTreeWidget; private node: Node; @@ -26,7 +29,7 @@ export default class LauncherContextMenu implements SelectMenuItemEventListener }) } - async getMenuItems(): Promise { + async getMenuItems(): Promise[]> { const note = this.node.data.noteId ? await froca.getNote(this.node.data.noteId) : null; const parentNoteId = this.node.getParent().data.noteId; @@ -38,7 +41,7 @@ export default class LauncherContextMenu implements SelectMenuItemEventListener const canBeDeleted = !note?.noteId.startsWith("_"); // fixed notes can't be deleted const canBeReset = !canBeDeleted && note?.isLaunchBarConfig(); - const items: (MenuItem | null)[] = [ + const items: (MenuItem | null)[] = [ (isVisibleRoot || isAvailableRoot) ? { title: t("launcher_context_menu.add-note-launcher"), command: 'addNoteLauncher', uiIcon: "bx bx-note" } : null, (isVisibleRoot || isAvailableRoot) ? { title: t("launcher_context_menu.add-script-launcher"), command: 'addScriptLauncher', uiIcon: "bx bx-code-curly" } : null, (isVisibleRoot || isAvailableRoot) ? { title: t("launcher_context_menu.add-custom-widget"), command: 'addWidgetLauncher', uiIcon: "bx bx-customize" } : null, @@ -59,7 +62,7 @@ export default class LauncherContextMenu implements SelectMenuItemEventListener return items.filter(row => row !== null); } - async selectMenuItemHandler({command}: MenuCommandItem) { + async selectMenuItemHandler({command}: MenuCommandItem) { if (!command) { return; } diff --git a/src/public/app/menus/tree_context_menu.ts b/src/public/app/menus/tree_context_menu.ts index c7b8d4a1f..cb1d00c0f 100644 --- a/src/public/app/menus/tree_context_menu.ts +++ b/src/public/app/menus/tree_context_menu.ts @@ -3,7 +3,7 @@ import froca from "../services/froca.js"; import clipboard from '../services/clipboard.js'; import noteCreateService from "../services/note_create.js"; import contextMenu, { MenuCommandItem, MenuItem } from "./context_menu.js"; -import appContext from "../components/app_context.js"; +import appContext, { ContextMenuCommandData, FilteredCommandNames } from "../components/app_context.js"; import noteTypesService from "../services/note_types.js"; import server from "../services/server.js"; import toastService from "../services/toast.js"; @@ -18,7 +18,9 @@ interface ConvertToAttachmentResponse { attachment?: FAttachment; } -export default class TreeContextMenu implements SelectMenuItemEventListener { +type TreeCommandNames = FilteredCommandNames; + +export default class TreeContextMenu implements SelectMenuItemEventListener { private treeWidget: NoteTreeWidget; private node: Node; @@ -37,7 +39,7 @@ export default class TreeContextMenu implements SelectMenuItemEventListener { }) } - async getMenuItems(): Promise { + async getMenuItems(): Promise[]> { const note = this.node.data.noteId ? await froca.getNote(this.node.data.noteId) : null; const branch = froca.getBranch(this.node.data.branchId); const isNotRoot = note?.noteId !== 'root'; @@ -56,7 +58,7 @@ export default class TreeContextMenu implements SelectMenuItemEventListener { const parentNotSearch = !parentNote || parentNote.type !== 'search'; const insertNoteAfterEnabled = isNotRoot && !isHoisted && parentNotSearch; - return [ + const items: (MenuItem | null)[] = [ { title: `${t("tree-context-menu.open-in-a-new-tab")} Ctrl+Click`, command: "openInTab", uiIcon: "bx bx-link-external", enabled: noSelectedNotes }, { title: t("tree-context-menu.open-in-a-new-split"), command: "openNoteInSplit", uiIcon: "bx bx-dock-right", enabled: noSelectedNotes }, @@ -149,10 +151,11 @@ export default class TreeContextMenu implements SelectMenuItemEventListener { { title: `${t("tree-context-menu.search-in-subtree")} `, command: "searchInSubtree", uiIcon: "bx bx-search", enabled: notSearch && noSelectedNotes }, - ].filter(row => row !== null) as MenuItem[]; + ]; + return items.filter(row => row !== null); } - async selectMenuItemHandler({command, type, templateNoteId}: MenuCommandItem) { + async selectMenuItemHandler({command, type, templateNoteId}: MenuCommandItem) { const notePath = treeService.getNotePath(this.node); if (command === 'openInTab') { @@ -210,7 +213,7 @@ export default class TreeContextMenu implements SelectMenuItemEventListener { navigator.clipboard.writeText('#' + notePath); } else if (command) { - this.treeWidget.triggerCommand(command, { + this.treeWidget.triggerCommand(command, { node: this.node, notePath: notePath, noteId: this.node.data.noteId, diff --git a/src/public/app/services/note_types.ts b/src/public/app/services/note_types.ts index 722061f18..0174c78a0 100644 --- a/src/public/app/services/note_types.ts +++ b/src/public/app/services/note_types.ts @@ -2,10 +2,12 @@ import server from "./server.js"; import froca from "./froca.js"; import { t } from "./i18n.js"; import { MenuItem } from "../menus/context_menu.js"; -import { CommandNames } from "../components/app_context.js"; +import { ContextMenuCommandData, FilteredCommandNames } from "../components/app_context.js"; -async function getNoteTypeItems(command?: CommandNames) { - const items: MenuItem[] = [ +type NoteTypeCommandNames = FilteredCommandNames; + +async function getNoteTypeItems(command?: NoteTypeCommandNames) { + const items: MenuItem[] = [ { title: t("note_types.text"), command, type: "text", uiIcon: "bx bx-note" }, { title: t("note_types.code"), command, type: "code", uiIcon: "bx bx-code" }, { title: t("note_types.saved-search"), command, type: "search", uiIcon: "bx bx-file-find" }, diff --git a/src/public/app/widgets/dialogs/note_type_chooser.ts b/src/public/app/widgets/dialogs/note_type_chooser.ts index a330c41f9..00439c749 100644 --- a/src/public/app/widgets/dialogs/note_type_chooser.ts +++ b/src/public/app/widgets/dialogs/note_type_chooser.ts @@ -1,3 +1,4 @@ +import { CommandNames } from "../../components/app_context.js"; import { MenuCommandItem } from "../../menus/context_menu.js"; import { t } from "../../services/i18n.js"; import noteTypesService from "../../services/note_types.js"; @@ -128,7 +129,7 @@ export default class NoteTypeChooserDialog extends BasicWidget { if (noteType.title === '----') { this.$noteTypeDropdown.append($('