refactor(client/ts): use filtered generics for context menu commands

This commit is contained in:
Elian Doran 2024-12-22 19:31:29 +02:00
parent 19652fbbce
commit b01725101d
No known key found for this signature in database
9 changed files with 115 additions and 50 deletions

View File

@ -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<CommandData>;
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<NoteDetailWidget>) => 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<T, ValueType> = { [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<T extends CommandData> = keyof Pick<CommandMappings, FilterByValueType<CommandMappings, T>>;
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<K extends CommandNames>(name: K, data: CommandMappings[K] = {}) {
triggerCommand<K extends CommandNames>(name: K, _data?: CommandMappings[K]) {
const data = _data || {};
for (const executor of this.components) {
const fun = (executor as any)[`${name}Command`];

View File

@ -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<unknown> | undefined | null {
triggerCommand<K extends CommandNames>(name: string, _data?: CommandMappings[K]): Promise<unknown> | undefined | null {
const data = _data || {};
const fun = (this as any)[`${name}Command`];
if (fun) {

View File

@ -1,7 +1,8 @@
import { MenuCommandItem } from "../menus/context_menu.js";
import { CommandNames } from "./app_context.js";
type ListenerReturnType = void | Promise<void>;
export interface SelectMenuItemEventListener {
selectMenuItemHandler(item: MenuCommandItem): ListenerReturnType;
export interface SelectMenuItemEventListener<T extends CommandNames> {
selectMenuItemHandler(item: MenuCommandItem<T>): ListenerReturnType;
}

View File

@ -1,39 +1,39 @@
import { CommandNames } from '../components/app_context.js';
import keyboardActionService from '../services/keyboard_actions.js';
interface ContextMenuOptions {
interface ContextMenuOptions<T extends CommandNames> {
x: number;
y: number;
orientation?: "left";
selectMenuItemHandler: MenuHandler;
items: MenuItem[];
selectMenuItemHandler: MenuHandler<T>;
items: MenuItem<T>[];
}
interface MenuSeparatorItem {
title: "----"
}
export interface MenuCommandItem {
export interface MenuCommandItem<T extends CommandNames> {
title: string;
command?: CommandNames;
command?: T;
type?: string;
uiIcon?: string;
templateNoteId?: string;
enabled?: boolean;
handler?: MenuHandler;
items?: MenuItem[];
handler?: MenuHandler<T>;
items?: MenuItem<T>[] | null;
shortcut?: string;
spellingSuggestion?: string;
}
export type MenuItem = MenuCommandItem | MenuSeparatorItem;
export type MenuHandler = (item: MenuCommandItem, e: JQuery.MouseDownEvent<HTMLElement, undefined, HTMLElement, HTMLElement>) => void;
export type MenuItem<T extends CommandNames> = MenuCommandItem<T> | MenuSeparatorItem;
export type MenuHandler<T extends CommandNames> = (item: MenuCommandItem<T>, e: JQuery.MouseDownEvent<HTMLElement, undefined, HTMLElement, HTMLElement>) => void;
class ContextMenu {
private $widget!: JQuery<HTMLElement>;
private dateContextMenuOpenedMs: number;
private options?: ContextMenuOptions;
private options?: ContextMenuOptions<any>;
constructor() {
this.$widget = $("#context-menu-container");
@ -43,7 +43,7 @@ class ContextMenu {
$(document).on('click', () => this.hide());
}
async show(options: ContextMenuOptions) {
async show<T extends CommandNames>(options: ContextMenuOptions<T>) {
this.options = options;
if (this.$widget.hasClass("show")) {
@ -119,7 +119,7 @@ class ContextMenu {
}).addClass("show");
}
addItems($parent: JQuery<HTMLElement>, items: MenuItem[]) {
addItems($parent: JQuery<HTMLElement>, items: MenuItem<any>[]) {
for (const item of items) {
if (!item) {
continue;

View File

@ -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<CommandNames>[] = [];
if (params.misspelledWord) {
for (const suggestion of params.dictionarySuggestions) {

View File

@ -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<ContextMenuCommandData>;
export default class LauncherContextMenu implements SelectMenuItemEventListener<LauncherCommandNames> {
private treeWidget: NoteTreeWidget;
private node: Node;
@ -26,7 +29,7 @@ export default class LauncherContextMenu implements SelectMenuItemEventListener
})
}
async getMenuItems(): Promise<MenuItem[]> {
async getMenuItems(): Promise<MenuItem<LauncherCommandNames>[]> {
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<LauncherCommandNames> | 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<LauncherCommandNames>) {
if (!command) {
return;
}

View File

@ -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<ContextMenuCommandData>;
export default class TreeContextMenu implements SelectMenuItemEventListener<TreeCommandNames> {
private treeWidget: NoteTreeWidget;
private node: Node;
@ -37,7 +39,7 @@ export default class TreeContextMenu implements SelectMenuItemEventListener {
})
}
async getMenuItems(): Promise<MenuItem[]> {
async getMenuItems(): Promise<MenuItem<TreeCommandNames>[]> {
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<TreeCommandNames> | null)[] = [
{ title: `${t("tree-context-menu.open-in-a-new-tab")} <kbd>Ctrl+Click</kbd>`, 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")} <kbd data-command="searchInSubtree"></kbd>`, 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<TreeCommandNames>) {
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<TreeCommandNames>(command, {
node: this.node,
notePath: notePath,
noteId: this.node.data.noteId,

View File

@ -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<ContextMenuCommandData>;
async function getNoteTypeItems(command?: NoteTypeCommandNames) {
const items: MenuItem<NoteTypeCommandNames>[] = [
{ 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" },

View File

@ -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($('<h6 class="dropdown-header">').append(t("note_type_chooser.templates")));
} else {
const commandItem = (noteType as MenuCommandItem)
const commandItem = (noteType as MenuCommandItem<CommandNames>)
this.$noteTypeDropdown.append(
$('<a class="dropdown-item" tabindex="0">')
.attr("data-note-type", commandItem.type || "")