diff --git a/apps/client/src/components/app_context.ts b/apps/client/src/components/app_context.ts index f960a76c4..2944005bd 100644 --- a/apps/client/src/components/app_context.ts +++ b/apps/client/src/components/app_context.ts @@ -133,6 +133,8 @@ export type CommandMappings = { hideLeftPane: CommandData; showCpuArchWarning: CommandData; showLeftPane: CommandData; + showAttachments: CommandData; + showSearchHistory: CommandData; hoistNote: CommandData & { noteId: string }; leaveProtectedSession: CommandData; enterProtectedSession: CommandData; @@ -173,7 +175,7 @@ export type CommandMappings = { deleteNotes: ContextMenuCommandData; importIntoNote: ContextMenuCommandData; exportNote: ContextMenuCommandData; - searchInSubtree: ContextMenuCommandData; + searchInSubtree: CommandData & { notePath: string; }; moveNoteUp: ContextMenuCommandData; moveNoteDown: ContextMenuCommandData; moveNoteUpInHierarchy: ContextMenuCommandData; @@ -262,6 +264,7 @@ export type CommandMappings = { closeThisNoteSplit: CommandData; moveThisNoteSplit: CommandData & { isMovingLeft: boolean }; jumpToNote: CommandData; + commandPalette: CommandData; // Geomap deleteFromMap: { noteId: string }; diff --git a/apps/client/src/menus/tree_context_menu.ts b/apps/client/src/menus/tree_context_menu.ts index 40730a51c..8e8f2ed16 100644 --- a/apps/client/src/menus/tree_context_menu.ts +++ b/apps/client/src/menus/tree_context_menu.ts @@ -23,7 +23,7 @@ let lastTargetNode: HTMLElement | null = null; // This will include all commands that implement ContextMenuCommandData, but it will not work if it additional options are added via the `|` operator, // so they need to be added manually. -export type TreeCommandNames = FilteredCommandNames | "openBulkActionsDialog"; +export type TreeCommandNames = FilteredCommandNames | "openBulkActionsDialog" | "searchInSubtree"; export default class TreeContextMenu implements SelectMenuItemEventListener { private treeWidget: NoteTreeWidget; @@ -129,7 +129,7 @@ export default class TreeContextMenu implements SelectMenuItemEventListener`, command: "expandSubtree", uiIcon: "bx bx-expand", enabled: noSelectedNotes }, diff --git a/apps/client/src/services/command_registry.ts b/apps/client/src/services/command_registry.ts new file mode 100644 index 000000000..a6c1000b7 --- /dev/null +++ b/apps/client/src/services/command_registry.ts @@ -0,0 +1,268 @@ +import appContext, { type CommandNames } from "../components/app_context.js"; +import type NoteTreeWidget from "../widgets/note_tree.js"; +import { t, translationsInitializedPromise } from "./i18n.js"; +import keyboardActions, { Action } from "./keyboard_actions.js"; +import utils from "./utils.js"; + +export interface CommandDefinition { + id: string; + name: string; + description?: string; + icon?: string; + shortcut?: string; + commandName?: CommandNames; + handler?: () => Promise | null | undefined | void; + aliases?: string[]; + source?: "manual" | "keyboard-action"; + /** Reference to the original keyboard action for scope checking. */ + keyboardAction?: Action; +} + +class CommandRegistry { + private commands: Map = new Map(); + private aliases: Map = new Map(); + + constructor() { + this.loadCommands(); + } + + private async loadCommands() { + await translationsInitializedPromise; + this.registerDefaultCommands(); + await this.loadKeyboardActionsAsync(); + } + + private registerDefaultCommands() { + this.register({ + id: "export-note", + name: t("command_palette.export_note_title"), + description: t("command_palette.export_note_description"), + icon: "bx bx-export", + handler: () => { + const notePath = appContext.tabManager.getActiveContextNotePath(); + if (notePath) { + appContext.triggerCommand("showExportDialog", { + notePath, + defaultType: "single" + }); + } + } + }); + + this.register({ + id: "show-attachments", + name: t("command_palette.show_attachments_title"), + description: t("command_palette.show_attachments_description"), + icon: "bx bx-paperclip", + handler: () => appContext.triggerCommand("showAttachments") + }); + + // Special search commands with custom logic + this.register({ + id: "search-notes", + name: t("command_palette.search_notes_title"), + description: t("command_palette.search_notes_description"), + icon: "bx bx-search", + handler: () => appContext.triggerCommand("searchNotes", {}) + }); + + this.register({ + id: "search-in-subtree", + name: t("command_palette.search_subtree_title"), + description: t("command_palette.search_subtree_description"), + icon: "bx bx-search-alt", + handler: () => { + const notePath = appContext.tabManager.getActiveContextNotePath(); + if (notePath) { + appContext.triggerCommand("searchInSubtree", { notePath }); + } + } + }); + + this.register({ + id: "show-search-history", + name: t("command_palette.search_history_title"), + description: t("command_palette.search_history_description"), + icon: "bx bx-history", + handler: () => appContext.triggerCommand("showSearchHistory") + }); + + this.register({ + id: "show-launch-bar", + name: t("command_palette.configure_launch_bar_title"), + description: t("command_palette.configure_launch_bar_description"), + icon: "bx bx-sidebar", + handler: () => appContext.triggerCommand("showLaunchBarSubtree") + }); + } + + private async loadKeyboardActionsAsync() { + try { + const actions = await keyboardActions.getActions(); + this.registerKeyboardActions(actions); + } catch (error) { + console.error("Failed to load keyboard actions:", error); + } + } + + private registerKeyboardActions(actions: Action[]) { + for (const action of actions) { + // Skip actions that we've already manually registered + if (this.commands.has(action.actionName)) { + continue; + } + + // Skip actions that don't have a description (likely separators) + if (!action.description) { + continue; + } + + // Skip Electron-only actions if not in Electron environment + if (action.isElectronOnly && !utils.isElectron()) { + continue; + } + + // Get the primary shortcut (first one in the list) + const primaryShortcut = action.effectiveShortcuts?.[0]; + + let name = action.friendlyName; + if (action.scope === "note-tree") { + name = t("command_palette.tree-action-name", { name: action.friendlyName }); + } + + // Create a command definition from the keyboard action + const commandDef: CommandDefinition = { + id: action.actionName, + name, + description: action.description, + icon: action.iconClass, + shortcut: primaryShortcut ? this.formatShortcut(primaryShortcut) : undefined, + commandName: action.actionName as CommandNames, + source: "keyboard-action", + keyboardAction: action + }; + + this.register(commandDef); + } + } + + private formatShortcut(shortcut: string): string { + // Convert electron accelerator format to display format + return shortcut + .replace(/CommandOrControl/g, 'Ctrl') + .replace(/\+/g, ' + '); + } + + register(command: CommandDefinition) { + this.commands.set(command.id, command); + + // Register aliases + if (command.aliases) { + for (const alias of command.aliases) { + this.aliases.set(alias.toLowerCase(), command.id); + } + } + } + + getCommand(id: string): CommandDefinition | undefined { + return this.commands.get(id); + } + + getAllCommands(): CommandDefinition[] { + const commands = Array.from(this.commands.values()); + + // Sort commands by name + commands.sort((a, b) => a.name.localeCompare(b.name)); + + return commands; + } + + searchCommands(query: string): CommandDefinition[] { + const normalizedQuery = query.toLowerCase(); + const results: { command: CommandDefinition; score: number }[] = []; + + for (const command of this.commands.values()) { + let score = 0; + + // Exact match on name + if (command.name.toLowerCase() === normalizedQuery) { + score = 100; + } + // Name starts with query + else if (command.name.toLowerCase().startsWith(normalizedQuery)) { + score = 80; + } + // Name contains query + else if (command.name.toLowerCase().includes(normalizedQuery)) { + score = 60; + } + // Description contains query + else if (command.description?.toLowerCase().includes(normalizedQuery)) { + score = 40; + } + // Check aliases + else if (command.aliases?.some(alias => alias.toLowerCase().includes(normalizedQuery))) { + score = 50; + } + + if (score > 0) { + results.push({ command, score }); + } + } + + // Sort by score (highest first) and then by name + results.sort((a, b) => { + if (a.score !== b.score) { + return b.score - a.score; + } + return a.command.name.localeCompare(b.command.name); + }); + + return results.map(r => r.command); + } + + async executeCommand(commandId: string) { + const command = this.getCommand(commandId); + if (!command) { + console.error(`Command not found: ${commandId}`); + return; + } + + // Execute custom handler if provided + if (command.handler) { + await command.handler(); + return; + } + + // Handle keyboard action with scope-aware execution + if (command.keyboardAction && command.commandName) { + if (command.keyboardAction.scope === "note-tree") { + this.executeWithNoteTreeFocus(command.commandName); + } else { + appContext.triggerCommand(command.commandName); + } + return; + } + + // Fallback for commands without keyboard action reference + if (command.commandName) { + appContext.triggerCommand(command.commandName); + return; + } + + console.error(`Command ${commandId} has no handler or commandName`); + } + + private executeWithNoteTreeFocus(actionName: CommandNames) { + const tree = document.querySelector(".tree-wrapper") as HTMLElement; + if (!tree) { + return; + } + + const treeComponent = appContext.getComponentByEl(tree) as NoteTreeWidget; + treeComponent.triggerCommand(actionName, { ntxId: appContext.tabManager.activeNtxId }); + } +} + +const commandRegistry = new CommandRegistry(); +export default commandRegistry; diff --git a/apps/client/src/services/i18n.ts b/apps/client/src/services/i18n.ts index 25c98fe39..8c95fd053 100644 --- a/apps/client/src/services/i18n.ts +++ b/apps/client/src/services/i18n.ts @@ -6,6 +6,11 @@ import type { Locale } from "@triliumnext/commons"; let locales: Locale[] | null; +/** + * A deferred promise that resolves when translations are initialized. + */ +export let translationsInitializedPromise = $.Deferred(); + export async function initLocale() { const locale = (options.get("locale") as string) || "en"; @@ -19,6 +24,8 @@ export async function initLocale() { }, returnEmptyString: false }); + + translationsInitializedPromise.resolve(); } export function getAvailableLocales() { diff --git a/apps/client/src/services/keyboard_actions.ts b/apps/client/src/services/keyboard_actions.ts index 3cb0ffd33..820de185c 100644 --- a/apps/client/src/services/keyboard_actions.ts +++ b/apps/client/src/services/keyboard_actions.ts @@ -10,6 +10,10 @@ export interface Action { actionName: CommandNames; effectiveShortcuts: string[]; scope: string; + friendlyName: string; + description?: string; + iconClass?: string; + isElectronOnly?: boolean; } const keyboardActionsLoaded = server.get("keyboard-actions").then((actions) => { diff --git a/apps/client/src/services/note_autocomplete.ts b/apps/client/src/services/note_autocomplete.ts index 4cfc614f0..f6ada1967 100644 --- a/apps/client/src/services/note_autocomplete.ts +++ b/apps/client/src/services/note_autocomplete.ts @@ -3,6 +3,7 @@ import appContext from "../components/app_context.js"; import noteCreateService from "./note_create.js"; import froca from "./froca.js"; import { t } from "./i18n.js"; +import commandRegistry from "./command_registry.js"; import type { MentionFeedObjectItem } from "@triliumnext/ckeditor5"; // this key needs to have this value, so it's hit by the tooltip @@ -29,9 +30,12 @@ export interface Suggestion { notePathTitle?: string; notePath?: string; highlightedNotePathTitle?: string; - action?: string | "create-note" | "search-notes" | "external-link"; + action?: string | "create-note" | "search-notes" | "external-link" | "command"; parentNoteId?: string; icon?: string; + commandId?: string; + commandDescription?: string; + commandShortcut?: string; } interface Options { @@ -44,6 +48,8 @@ interface Options { hideGoToSelectedNoteButton?: boolean; /** If set, hides all right-side buttons in the autocomplete dropdown */ hideAllButtons?: boolean; + /** If set, enables command palette mode */ + isCommandPalette?: boolean; } async function autocompleteSourceForCKEditor(queryText: string) { @@ -73,6 +79,31 @@ async function autocompleteSourceForCKEditor(queryText: string) { } async function autocompleteSource(term: string, cb: (rows: Suggestion[]) => void, options: Options = {}) { + // Check if we're in command mode + if (options.isCommandPalette && term.startsWith(">")) { + const commandQuery = term.substring(1).trim(); + + // Get commands (all if no query, filtered if query provided) + const commands = commandQuery.length === 0 + ? commandRegistry.getAllCommands() + : commandRegistry.searchCommands(commandQuery); + + // Convert commands to suggestions + const commandSuggestions: Suggestion[] = commands.map(cmd => ({ + action: "command", + commandId: cmd.id, + noteTitle: cmd.name, + notePathTitle: `>${cmd.name}`, + highlightedNotePathTitle: cmd.name, + commandDescription: cmd.description, + commandShortcut: cmd.shortcut, + icon: cmd.icon + })); + + cb(commandSuggestions); + return; + } + const fastSearch = options.fastSearch === false ? false : true; if (fastSearch === false) { if (term.trim().length === 0) { @@ -146,6 +177,12 @@ function showRecentNotes($el: JQuery) { $el.trigger("focus"); } +function showAllCommands($el: JQuery) { + searchDelay = 0; + $el.setSelectedNotePath(""); + $el.autocomplete("val", ">").autocomplete("open"); +} + function fullTextSearch($el: JQuery, options: Options) { const searchString = $el.autocomplete("val") as unknown as string; if (options.fastSearch === false || searchString?.trim().length === 0) { @@ -270,7 +307,24 @@ function initNoteAutocomplete($el: JQuery, options?: Options) { }, displayKey: "notePathTitle", templates: { - suggestion: (suggestion) => ` ${suggestion.highlightedNotePathTitle}` + suggestion: (suggestion) => { + if (suggestion.action === "command") { + let html = `
`; + html += ``; + html += `
`; + html += `
${suggestion.highlightedNotePathTitle}
`; + if (suggestion.commandDescription) { + html += `
${suggestion.commandDescription}
`; + } + html += `
`; + if (suggestion.commandShortcut) { + html += `${suggestion.commandShortcut}`; + } + html += '
'; + return html; + } + return ` ${suggestion.highlightedNotePathTitle}`; + } }, // we can't cache identical searches because notes can be created / renamed, new recent notes can be added cache: false @@ -280,6 +334,12 @@ function initNoteAutocomplete($el: JQuery, options?: Options) { // TODO: Types fail due to "autocomplete:selected" not being registered in type definitions. ($el as any).on("autocomplete:selected", async (event: Event, suggestion: Suggestion) => { + if (suggestion.action === "command") { + $el.autocomplete("close"); + $el.trigger("autocomplete:commandselected", [suggestion]); + return; + } + if (suggestion.action === "external-link") { $el.setSelectedNotePath(null); $el.setSelectedExternalLink(suggestion.externalLink); @@ -396,6 +456,7 @@ export default { autocompleteSourceForCKEditor, initNoteAutocomplete, showRecentNotes, + showAllCommands, setText, init }; diff --git a/apps/client/src/stylesheets/style.css b/apps/client/src/stylesheets/style.css index 2296166f2..8d56447d4 100644 --- a/apps/client/src/stylesheets/style.css +++ b/apps/client/src/stylesheets/style.css @@ -1780,6 +1780,54 @@ textarea { padding: 1rem; } +/* Command palette styling */ +.jump-to-note-dialog .command-suggestion { + display: flex; + align-items: center; + gap: 0.75rem; + font-size: 0.9em; +} + +.jump-to-note-dialog .aa-suggestion .command-suggestion, +.jump-to-note-dialog .aa-suggestion .command-suggestion div { + padding: 0; +} + +.jump-to-note-dialog .aa-cursor .command-suggestion, +.jump-to-note-dialog .aa-suggestion:hover .command-suggestion { + border-left-color: var(--link-color); + background-color: var(--hover-background-color); +} + +.jump-to-note-dialog .command-icon { + color: var(--muted-text-color); + font-size: 1.125rem; + flex-shrink: 0; + margin-top: 0.125rem; +} + +.jump-to-note-dialog .command-content { + flex-grow: 1; + min-width: 0; +} + +.jump-to-note-dialog .command-name { + font-weight: bold; +} + +.jump-to-note-dialog .command-description { + font-size: 0.8em; + line-height: 1.3; + opacity: 0.75; +} + +.jump-to-note-dialog kbd.command-shortcut { + background-color: transparent; + color: inherit; + opacity: 0.75; + font-family: inherit !important; +} + .empty-table-placeholder { text-align: center; color: var(--muted-text-color); diff --git a/apps/client/src/stylesheets/theme-next/dialogs.css b/apps/client/src/stylesheets/theme-next/dialogs.css index 0351e6f23..9b8fb0306 100644 --- a/apps/client/src/stylesheets/theme-next/dialogs.css +++ b/apps/client/src/stylesheets/theme-next/dialogs.css @@ -128,10 +128,15 @@ div.tn-tool-dialog { .jump-to-note-dialog .modal-header { padding: unset !important; + padding-bottom: 26px !important; } .jump-to-note-dialog .modal-body { - padding: 26px 0 !important; + padding: 0 !important; +} + +.jump-to-note-dialog .modal-footer { + padding-top: 26px; } /* Search box wrapper */ diff --git a/apps/client/src/translations/cn/translation.json b/apps/client/src/translations/cn/translation.json index a57509bed..691ecd6a5 100644 --- a/apps/client/src/translations/cn/translation.json +++ b/apps/client/src/translations/cn/translation.json @@ -210,7 +210,7 @@ "okButton": "确定" }, "jump_to_note": { - "search_placeholder": "按笔记名称搜索", + "search_placeholder": "", "close": "关闭", "search_button": "全文搜索 Ctrl+回车" }, diff --git a/apps/client/src/translations/de/translation.json b/apps/client/src/translations/de/translation.json index 699d78377..76eeddc39 100644 --- a/apps/client/src/translations/de/translation.json +++ b/apps/client/src/translations/de/translation.json @@ -210,7 +210,7 @@ "okButton": "OK" }, "jump_to_note": { - "search_placeholder": "Suche nach einer Notiz anhand ihres Namens", + "search_placeholder": "", "close": "Schließen", "search_button": "Suche im Volltext: Strg+Eingabetaste" }, diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index 7fd0bc700..0d8313aa4 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -211,7 +211,7 @@ "okButton": "OK" }, "jump_to_note": { - "search_placeholder": "search for note by its name", + "search_placeholder": "Search for note by its name or type > for commands...", "close": "Close", "search_button": "Search in full text Ctrl+Enter" }, @@ -1987,5 +1987,20 @@ "delete-column-confirmation": "Are you sure you want to delete this column? The corresponding attribute will be deleted in the notes under this column as well.", "new-item": "New item", "add-column": "Add Column" + }, + "command_palette": { + "tree-action-name": "Tree: {{name}}", + "export_note_title": "Export Note", + "export_note_description": "Export current note", + "show_attachments_title": "Show Attachments", + "show_attachments_description": "View note attachments", + "search_notes_title": "Search Notes", + "search_notes_description": "Open advanced search", + "search_subtree_title": "Search in Subtree", + "search_subtree_description": "Search within current subtree", + "search_history_title": "Show Search History", + "search_history_description": "View previous searches", + "configure_launch_bar_title": "Configure Launch Bar", + "configure_launch_bar_description": "Open the launch bar configuration, to add or remove items." } } diff --git a/apps/client/src/translations/es/translation.json b/apps/client/src/translations/es/translation.json index e1510df88..951228cdf 100644 --- a/apps/client/src/translations/es/translation.json +++ b/apps/client/src/translations/es/translation.json @@ -211,7 +211,7 @@ "okButton": "Aceptar" }, "jump_to_note": { - "search_placeholder": "buscar nota por su nombre", + "search_placeholder": "", "close": "Cerrar", "search_button": "Buscar en texto completo Ctrl+Enter" }, diff --git a/apps/client/src/translations/fr/translation.json b/apps/client/src/translations/fr/translation.json index 56eecd03b..c35d26068 100644 --- a/apps/client/src/translations/fr/translation.json +++ b/apps/client/src/translations/fr/translation.json @@ -210,7 +210,7 @@ "okButton": "OK" }, "jump_to_note": { - "search_placeholder": "rechercher une note par son nom", + "search_placeholder": "", "close": "Fermer", "search_button": "Rechercher dans le texte intégral Ctrl+Entrée" }, diff --git a/apps/client/src/translations/ro/translation.json b/apps/client/src/translations/ro/translation.json index 9c99bc189..6feda41c8 100644 --- a/apps/client/src/translations/ro/translation.json +++ b/apps/client/src/translations/ro/translation.json @@ -755,7 +755,7 @@ }, "jump_to_note": { "search_button": "Caută în întregul conținut Ctrl+Enter", - "search_placeholder": "căutați o notiță după denumirea ei", + "search_placeholder": "", "close": "Închide" }, "left_pane_toggle": { diff --git a/apps/client/src/translations/tw/translation.json b/apps/client/src/translations/tw/translation.json index 394d37efb..64b776771 100644 --- a/apps/client/src/translations/tw/translation.json +++ b/apps/client/src/translations/tw/translation.json @@ -194,7 +194,7 @@ "okButton": "確定" }, "jump_to_note": { - "search_placeholder": "按筆記名稱搜尋", + "search_placeholder": "", "search_button": "全文搜尋 Ctrl+Enter" }, "markdown_import": { diff --git a/apps/client/src/widgets/dialogs/jump_to_note.ts b/apps/client/src/widgets/dialogs/jump_to_note.ts index 98ee6e0e4..6c9b78d84 100644 --- a/apps/client/src/widgets/dialogs/jump_to_note.ts +++ b/apps/client/src/widgets/dialogs/jump_to_note.ts @@ -6,6 +6,7 @@ import BasicWidget from "../basic_widget.js"; import shortcutService from "../../services/shortcuts.js"; import { Modal } from "bootstrap"; import { openDialog } from "../../services/dialog.js"; +import commandRegistry from "../../services/command_registry.js"; const TPL = /*html*/`