From d5e42318ddd0ca6917ee127c8c56a0eb73e77492 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Mon, 4 Aug 2025 18:54:17 +0300 Subject: [PATCH] feat(dialogs): port jump to note partially --- apps/client/src/services/note_autocomplete.ts | 10 +- .../src/translations/cn/translation.json | 2 +- .../src/translations/de/translation.json | 2 +- .../src/translations/en/translation.json | 2 +- .../src/translations/es/translation.json | 2 +- .../src/translations/fr/translation.json | 2 +- .../src/translations/ro/translation.json | 2 +- .../src/translations/tw/translation.json | 2 +- apps/client/src/widgets/dialogs/add_link.tsx | 6 +- .../src/widgets/dialogs/jump_to_note.ts | 205 ------------------ .../src/widgets/dialogs/jump_to_note.tsx | 130 +++++++++++ apps/client/src/widgets/react/Modal.tsx | 8 +- .../src/widgets/react/NoteAutocomplete.tsx | 35 ++- 13 files changed, 176 insertions(+), 232 deletions(-) delete mode 100644 apps/client/src/widgets/dialogs/jump_to_note.ts create mode 100644 apps/client/src/widgets/dialogs/jump_to_note.tsx diff --git a/apps/client/src/services/note_autocomplete.ts b/apps/client/src/services/note_autocomplete.ts index f6ada1967..ea7e8cd8d 100644 --- a/apps/client/src/services/note_autocomplete.ts +++ b/apps/client/src/services/note_autocomplete.ts @@ -38,7 +38,7 @@ export interface Suggestion { commandShortcut?: string; } -interface Options { +export interface Options { container?: HTMLElement; fastSearch?: boolean; allowCreatingNotes?: boolean; @@ -82,12 +82,12 @@ async function autocompleteSource(term: string, cb: (rows: Suggestion[]) => void // 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 + const commands = commandQuery.length === 0 ? commandRegistry.getAllCommands() : commandRegistry.searchCommands(commandQuery); - + // Convert commands to suggestions const commandSuggestions: Suggestion[] = commands.map(cmd => ({ action: "command", @@ -99,7 +99,7 @@ async function autocompleteSource(term: string, cb: (rows: Suggestion[]) => void commandShortcut: cmd.shortcut, icon: cmd.icon })); - + cb(commandSuggestions); return; } diff --git a/apps/client/src/translations/cn/translation.json b/apps/client/src/translations/cn/translation.json index 99d0cea83..40cecaf6e 100644 --- a/apps/client/src/translations/cn/translation.json +++ b/apps/client/src/translations/cn/translation.json @@ -211,7 +211,7 @@ }, "jump_to_note": { "close": "关闭", - "search_button": "全文搜索 Ctrl+回车" + "search_button": "全文搜索" }, "markdown_import": { "dialog_title": "Markdown 导入", diff --git a/apps/client/src/translations/de/translation.json b/apps/client/src/translations/de/translation.json index 77024d63c..6d3136844 100644 --- a/apps/client/src/translations/de/translation.json +++ b/apps/client/src/translations/de/translation.json @@ -211,7 +211,7 @@ }, "jump_to_note": { "close": "Schließen", - "search_button": "Suche im Volltext: Strg+Eingabetaste" + "search_button": "Suche im Volltext" }, "markdown_import": { "dialog_title": "Markdown-Import", diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index 2410c56b6..1d104739a 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -213,7 +213,7 @@ "jump_to_note": { "search_placeholder": "Search for note by its name or type > for commands...", "close": "Close", - "search_button": "Search in full text Ctrl+Enter" + "search_button": "Search in full text" }, "markdown_import": { "dialog_title": "Markdown import", diff --git a/apps/client/src/translations/es/translation.json b/apps/client/src/translations/es/translation.json index 9df16d534..7aeae1655 100644 --- a/apps/client/src/translations/es/translation.json +++ b/apps/client/src/translations/es/translation.json @@ -212,7 +212,7 @@ }, "jump_to_note": { "close": "Cerrar", - "search_button": "Buscar en texto completo Ctrl+Enter" + "search_button": "Buscar en texto completo" }, "markdown_import": { "dialog_title": "Importación de Markdown", diff --git a/apps/client/src/translations/fr/translation.json b/apps/client/src/translations/fr/translation.json index f30ce130f..85e1bb0fc 100644 --- a/apps/client/src/translations/fr/translation.json +++ b/apps/client/src/translations/fr/translation.json @@ -211,7 +211,7 @@ }, "jump_to_note": { "close": "Fermer", - "search_button": "Rechercher dans le texte intégral Ctrl+Entrée" + "search_button": "Rechercher dans le texte intégral" }, "markdown_import": { "dialog_title": "Importation Markdown", diff --git a/apps/client/src/translations/ro/translation.json b/apps/client/src/translations/ro/translation.json index ee6369d85..101b9e779 100644 --- a/apps/client/src/translations/ro/translation.json +++ b/apps/client/src/translations/ro/translation.json @@ -767,7 +767,7 @@ "title": "Atribute moștenite" }, "jump_to_note": { - "search_button": "Caută în întregul conținut Ctrl+Enter", + "search_button": "Caută în întregul conținut", "close": "Închide", "search_placeholder": "Căutați notițe după nume sau tastați > pentru comenzi..." }, diff --git a/apps/client/src/translations/tw/translation.json b/apps/client/src/translations/tw/translation.json index f9cd5595f..5e10258d7 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_button": "全文搜尋 Ctrl+Enter" + "search_button": "全文搜尋" }, "markdown_import": { "dialog_title": "Markdown 匯入", diff --git a/apps/client/src/widgets/dialogs/add_link.tsx b/apps/client/src/widgets/dialogs/add_link.tsx index c0d344cd2..bca86460a 100644 --- a/apps/client/src/widgets/dialogs/add_link.tsx +++ b/apps/client/src/widgets/dialogs/add_link.tsx @@ -106,9 +106,11 @@ function AddLinkDialogComponent({ text: _text, textTypeWidget }: AddLinkDialogPr diff --git a/apps/client/src/widgets/dialogs/jump_to_note.ts b/apps/client/src/widgets/dialogs/jump_to_note.ts deleted file mode 100644 index 70676f696..000000000 --- a/apps/client/src/widgets/dialogs/jump_to_note.ts +++ /dev/null @@ -1,205 +0,0 @@ -import { t } from "../../services/i18n.js"; -import noteAutocompleteService from "../../services/note_autocomplete.js"; -import utils from "../../services/utils.js"; -import appContext from "../../components/app_context.js"; -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*/``; - -const KEEP_LAST_SEARCH_FOR_X_SECONDS = 120; - -export default class JumpToNoteDialog extends BasicWidget { - - private lastOpenedTs: number; - private modal!: bootstrap.Modal; - private $autoComplete!: JQuery; - private $results!: JQuery; - private $modalFooter!: JQuery; - private isCommandMode: boolean = false; - - constructor() { - super(); - - this.lastOpenedTs = 0; - } - - doRender() { - this.$widget = $(TPL); - this.modal = Modal.getOrCreateInstance(this.$widget[0]); - - this.$autoComplete = this.$widget.find(".jump-to-note-autocomplete"); - this.$results = this.$widget.find(".jump-to-note-results"); - this.$modalFooter = this.$widget.find(".modal-footer"); - this.$modalFooter.find(".show-in-full-text-button").on("click", (e) => this.showInFullText(e)); - - shortcutService.bindElShortcut(this.$widget, "ctrl+return", (e) => this.showInFullText(e)); - - // Monitor input changes to detect command mode switches - this.$autoComplete.on("input", () => { - this.updateCommandModeState(); - }); - } - - private updateCommandModeState() { - const currentValue = String(this.$autoComplete.val() || ""); - const newCommandMode = currentValue.startsWith(">"); - - if (newCommandMode !== this.isCommandMode) { - this.isCommandMode = newCommandMode; - this.updateButtonVisibility(); - } - } - - private updateButtonVisibility() { - if (this.isCommandMode) { - this.$modalFooter.hide(); - } else { - this.$modalFooter.show(); - } - } - - async jumpToNoteEvent() { - await this.openDialog(); - } - - async commandPaletteEvent() { - await this.openDialog(true); - } - - private async openDialog(commandMode = false) { - const dialogPromise = openDialog(this.$widget); - if (utils.isMobile()) { - dialogPromise.then(($dialog) => { - const el = $dialog.find(">.modal-dialog")[0]; - - function reposition() { - const offset = 100; - const modalHeight = (window.visualViewport?.height ?? 0) - offset; - const safeAreaInsetBottom = (window.visualViewport?.height ?? 0) - window.innerHeight; - el.style.height = `${modalHeight}px`; - el.style.bottom = `${(window.visualViewport?.height ?? 0) - modalHeight - safeAreaInsetBottom - offset}px`; - } - - this.$autoComplete.on("focus", () => { - reposition(); - }); - - window.visualViewport?.addEventListener("resize", () => { - reposition(); - }); - - reposition(); - }); - } - - // first open dialog, then refresh since refresh is doing focus which should be visible - this.refresh(commandMode); - - this.lastOpenedTs = Date.now(); - } - - async refresh(commandMode = false) { - noteAutocompleteService - .initNoteAutocomplete(this.$autoComplete, { - allowCreatingNotes: true, - hideGoToSelectedNoteButton: true, - allowJumpToSearchNotes: true, - container: this.$results[0], - isCommandPalette: true - }) - // clear any event listener added in previous invocation of this function - .off("autocomplete:noteselected") - .off("autocomplete:commandselected") - .on("autocomplete:noteselected", function (event, suggestion, dataset) { - if (!suggestion.notePath) { - return false; - } - - appContext.tabManager.getActiveContext()?.setNote(suggestion.notePath); - }) - .on("autocomplete:commandselected", async (event, suggestion, dataset) => { - if (!suggestion.commandId) { - return false; - } - - this.modal.hide(); - await commandRegistry.executeCommand(suggestion.commandId); - }); - - if (commandMode) { - // Start in command mode - manually trigger command search - this.$autoComplete.autocomplete("val", ">"); - this.isCommandMode = true; - this.updateButtonVisibility(); - - // Manually populate with all commands immediately - noteAutocompleteService.showAllCommands(this.$autoComplete); - - this.$autoComplete.trigger("focus"); - } else { - // if you open the Jump To dialog soon after using it previously, it can often mean that you - // actually want to search for the same thing (e.g., you opened the wrong note at first try) - // so we'll keep the content. - // if it's outside of this time limit, then we assume it's a completely new search and show recent notes instead. - if (Date.now() - this.lastOpenedTs > KEEP_LAST_SEARCH_FOR_X_SECONDS * 1000) { - this.isCommandMode = false; - this.updateButtonVisibility(); - noteAutocompleteService.showRecentNotes(this.$autoComplete); - } else { - this.$autoComplete - // hack, the actual search value is stored in
 element next to the search input
-                    // this is important because the search input value is replaced with the suggestion note's title
-                    .autocomplete("val", this.$autoComplete.next().text())
-                    .trigger("focus")
-                    .trigger("select");
-
-                // Update command mode state based on the restored value
-                this.updateCommandModeState();
-
-                // If we restored a command mode value, manually trigger command display
-                if (this.isCommandMode) {
-                    // Clear the value first, then set it to ">" to trigger a proper change
-                    this.$autoComplete.autocomplete("val", "");
-                    noteAutocompleteService.showAllCommands(this.$autoComplete);
-                }
-            }
-        }
-    }
-
-    showInFullText(e: JQuery.TriggeredEvent | KeyboardEvent) {
-        // stop from propagating upwards (dangerous, especially with ctrl+enter executable javascript notes)
-        e.preventDefault();
-        e.stopPropagation();
-
-        // Don't perform full text search in command mode
-        if (this.isCommandMode) {
-            return;
-        }
-
-        const searchString = String(this.$autoComplete.val());
-
-        this.triggerCommand("searchNotes", { searchString });
-        this.modal.hide();
-    }
-}
diff --git a/apps/client/src/widgets/dialogs/jump_to_note.tsx b/apps/client/src/widgets/dialogs/jump_to_note.tsx
new file mode 100644
index 000000000..5ab28b8c5
--- /dev/null
+++ b/apps/client/src/widgets/dialogs/jump_to_note.tsx
@@ -0,0 +1,130 @@
+import { closeActiveDialog, openDialog } from "../../services/dialog";
+import ReactBasicWidget from "../react/ReactBasicWidget";
+import Modal from "../react/Modal";
+import Button from "../react/Button";
+import NoteAutocomplete from "../react/NoteAutocomplete";
+import { t } from "../../services/i18n";
+import { useEffect, useRef, useState } from "preact/hooks";
+import note_autocomplete, { Suggestion } from "../../services/note_autocomplete";
+import appContext from "../../components/app_context";
+import commandRegistry from "../../services/command_registry";
+
+const KEEP_LAST_SEARCH_FOR_X_SECONDS = 120;
+
+type Mode = "last-search" | "recent-notes" | "commands";
+
+interface JumpToNoteDialogProps {
+    mode: Mode;
+}
+
+function JumpToNoteDialogComponent({ mode }: JumpToNoteDialogProps) {
+    const containerRef = useRef(null);
+    const autocompleteRef = useRef(null);
+    const [ isCommandMode, setIsCommandMode ] = useState(mode === "commands");
+    const [ text, setText ] = useState(isCommandMode ? "> " : "");
+
+    console.log(`Got text '${text}'`);
+
+    console.log("Rendering with mode:", mode, "isCommandMode:", isCommandMode); 
+
+    useEffect(() => {
+        setIsCommandMode(text.startsWith(">"));
+    }, [ text ]);
+
+    async function onItemSelected(suggestion: Suggestion) {
+        if (suggestion.notePath) {
+            appContext.tabManager.getActiveContext()?.setNote(suggestion.notePath);
+        } else if (suggestion.commandId) {
+            closeActiveDialog();
+            await commandRegistry.executeCommand(suggestion.commandId);
+        }
+    }
+
+    function onShown() {
+        const $autoComplete = $(autocompleteRef.current);
+        switch (mode) {
+            case "last-search":
+                break;
+            case "recent-notes":
+                note_autocomplete.showRecentNotes($autoComplete);
+                break;
+            case "commands":
+                note_autocomplete.showAllCommands($autoComplete);
+                break;
+        }
+
+        $autoComplete
+            .trigger("focus")
+            .trigger("select");
+    }
+
+    return (
+        }
+            onShown={onShown}
+            footer={!isCommandMode && 
                         )}
diff --git a/apps/client/src/widgets/react/NoteAutocomplete.tsx b/apps/client/src/widgets/react/NoteAutocomplete.tsx
index 23f2ccd25..f8e907e88 100644
--- a/apps/client/src/widgets/react/NoteAutocomplete.tsx
+++ b/apps/client/src/widgets/react/NoteAutocomplete.tsx
@@ -1,33 +1,46 @@
 import { useRef } from "preact/hooks";
 import { t } from "../../services/i18n";
 import { useEffect } from "react";
-import note_autocomplete, { type Suggestion } from "../../services/note_autocomplete";
+import note_autocomplete, { Options, type Suggestion } from "../../services/note_autocomplete";
 import type { RefObject } from "preact";
 
 interface NoteAutocompleteProps {    
     inputRef?: RefObject;
     text?: string;
-    allowExternalLinks?: boolean;
-    allowCreatingNotes?: boolean;
+    placeholder?: string;
+    container?: RefObject;
+    opts?: Omit;
     onChange?: (suggestion: Suggestion) => void;
+    onTextChange?: (text: string) => void;
 }
 
-export default function NoteAutocomplete({ inputRef: _ref, text, allowCreatingNotes, allowExternalLinks, onChange }: NoteAutocompleteProps) {
+export default function NoteAutocomplete({ inputRef: _ref, text, placeholder, onChange, onTextChange, container, opts }: NoteAutocompleteProps) {
     const ref = _ref ?? useRef(null);
     
     useEffect(() => {
         if (!ref.current) return;
         const $autoComplete = $(ref.current);
 
+        // clear any event listener added in previous invocation of this function
+        $autoComplete
+            .off("autocomplete:noteselected")
+            .off("autocomplete:commandselected")
+
         note_autocomplete.initNoteAutocomplete($autoComplete, {
-            allowExternalLinks,
-            allowCreatingNotes
+            ...opts,
+            container: container?.current
         });
         if (onChange) {
-            $autoComplete.on("autocomplete:noteselected", (_e, suggestion) => onChange(suggestion));
-            $autoComplete.on("autocomplete:externallinkselected", (_e, suggestion) => onChange(suggestion));
-        }        
-    }, [allowExternalLinks, allowCreatingNotes]);
+            const listener = (_e, suggestion) => onChange(suggestion);
+            $autoComplete
+                .on("autocomplete:noteselected", listener)
+                .on("autocomplete:externallinkselected", listener)
+                .on("autocomplete:commandselected", listener);
+        }
+        if (onTextChange) {
+            $autoComplete.on("input", () => onTextChange($autoComplete[0].value));
+        }
+    }, [opts, container?.current]);
 
     useEffect(() => {
         if (!ref.current) return;
@@ -44,7 +57,7 @@ export default function NoteAutocomplete({ inputRef: _ref, text, allowCreatingNo
             
+                placeholder={placeholder ?? t("add_link.search_note")} />
         
     );
 }
\ No newline at end of file