mirror of
				https://github.com/zadam/trilium.git
				synced 2025-10-30 18:05:55 +01:00 
			
		
		
		
	feat(dialogs): port jump to note partially
This commit is contained in:
		| @@ -38,7 +38,7 @@ export interface Suggestion { | ||||
|     commandShortcut?: string; | ||||
| } | ||||
|  | ||||
| interface Options { | ||||
| export interface Options { | ||||
|     container?: HTMLElement; | ||||
|     fastSearch?: boolean; | ||||
|     allowCreatingNotes?: boolean; | ||||
|   | ||||
| @@ -211,7 +211,7 @@ | ||||
|   }, | ||||
|   "jump_to_note": { | ||||
|     "close": "关闭", | ||||
|     "search_button": "全文搜索 <kbd>Ctrl+回车</kbd>" | ||||
|     "search_button": "全文搜索" | ||||
|   }, | ||||
|   "markdown_import": { | ||||
|     "dialog_title": "Markdown 导入", | ||||
|   | ||||
| @@ -211,7 +211,7 @@ | ||||
|   }, | ||||
|   "jump_to_note": { | ||||
|     "close": "Schließen", | ||||
|     "search_button": "Suche im Volltext: <kbd>Strg+Eingabetaste</kbd>" | ||||
|     "search_button": "Suche im Volltext" | ||||
|   }, | ||||
|   "markdown_import": { | ||||
|     "dialog_title": "Markdown-Import", | ||||
|   | ||||
| @@ -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 <kbd>Ctrl+Enter</kbd>" | ||||
|     "search_button": "Search in full text" | ||||
|   }, | ||||
|   "markdown_import": { | ||||
|     "dialog_title": "Markdown import", | ||||
|   | ||||
| @@ -212,7 +212,7 @@ | ||||
|   }, | ||||
|   "jump_to_note": { | ||||
|     "close": "Cerrar", | ||||
|     "search_button": "Buscar en texto completo <kbd>Ctrl+Enter</kbd>" | ||||
|     "search_button": "Buscar en texto completo" | ||||
|   }, | ||||
|   "markdown_import": { | ||||
|     "dialog_title": "Importación de Markdown", | ||||
|   | ||||
| @@ -211,7 +211,7 @@ | ||||
|   }, | ||||
|   "jump_to_note": { | ||||
|     "close": "Fermer", | ||||
|     "search_button": "Rechercher dans le texte intégral <kbd>Ctrl+Entrée</kbd>" | ||||
|     "search_button": "Rechercher dans le texte intégral" | ||||
|   }, | ||||
|   "markdown_import": { | ||||
|     "dialog_title": "Importation Markdown", | ||||
|   | ||||
| @@ -767,7 +767,7 @@ | ||||
|     "title": "Atribute moștenite" | ||||
|   }, | ||||
|   "jump_to_note": { | ||||
|     "search_button": "Caută în întregul conținut <kbd>Ctrl+Enter</kbd>", | ||||
|     "search_button": "Caută în întregul conținut", | ||||
|     "close": "Închide", | ||||
|     "search_placeholder": "Căutați notițe după nume sau tastați > pentru comenzi..." | ||||
|   }, | ||||
|   | ||||
| @@ -194,7 +194,7 @@ | ||||
|     "okButton": "確定" | ||||
|   }, | ||||
|   "jump_to_note": { | ||||
|     "search_button": "全文搜尋 <kbd>Ctrl+Enter</kbd>" | ||||
|     "search_button": "全文搜尋" | ||||
|   }, | ||||
|   "markdown_import": { | ||||
|     "dialog_title": "Markdown 匯入", | ||||
|   | ||||
| @@ -106,9 +106,11 @@ function AddLinkDialogComponent({ text: _text, textTypeWidget }: AddLinkDialogPr | ||||
|                 <NoteAutocomplete | ||||
|                     inputRef={autocompleteRef} | ||||
|                     text={text} | ||||
|                     allowExternalLinks | ||||
|                     allowCreatingNotes | ||||
|                     onChange={setSuggestion} | ||||
|                     opts={{ | ||||
|                         allowExternalLinks: true, | ||||
|                         allowCreatingNotes: true | ||||
|                     }} | ||||
|                 /> | ||||
|             </div> | ||||
|  | ||||
|   | ||||
| @@ -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*/`<div class="jump-to-note-dialog modal mx-auto" tabindex="-1" role="dialog"> | ||||
|     <div class="modal-dialog modal-lg" role="document"> | ||||
|         <div class="modal-content"> | ||||
|             <div class="modal-header"> | ||||
|                 <div class="input-group"> | ||||
|                     <input class="jump-to-note-autocomplete form-control" placeholder="${t("jump_to_note.search_placeholder")}"> | ||||
|                 </div> | ||||
|                 <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="${t("jump_to_note.close")}"></button> | ||||
|             </div> | ||||
|             <div class="modal-body"> | ||||
|                 <div class="algolia-autocomplete-container jump-to-note-results"></div> | ||||
|             </div> | ||||
|             <div class="modal-footer"> | ||||
|                 <button class="show-in-full-text-button btn btn-sm">${t("jump_to_note.search_button")}</button> | ||||
|             </div> | ||||
|         </div> | ||||
|     </div> | ||||
| </div>`; | ||||
|  | ||||
| const KEEP_LAST_SEARCH_FOR_X_SECONDS = 120; | ||||
|  | ||||
| export default class JumpToNoteDialog extends BasicWidget { | ||||
|  | ||||
|     private lastOpenedTs: number; | ||||
|     private modal!: bootstrap.Modal; | ||||
|     private $autoComplete!: JQuery<HTMLElement>; | ||||
|     private $results!: JQuery<HTMLElement>; | ||||
|     private $modalFooter!: JQuery<HTMLElement>; | ||||
|     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 <pre> 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(); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										130
									
								
								apps/client/src/widgets/dialogs/jump_to_note.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										130
									
								
								apps/client/src/widgets/dialogs/jump_to_note.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -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<HTMLDivElement>(null); | ||||
|     const autocompleteRef = useRef<HTMLInputElement>(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 ( | ||||
|         <Modal | ||||
|             className="jump-to-note-dialog" | ||||
|             size="lg" | ||||
|             title={<NoteAutocomplete | ||||
|                 placeholder={t("jump_to_note.search_placeholder")} | ||||
|                 inputRef={autocompleteRef} | ||||
|                 container={containerRef} | ||||
|                 text={text} | ||||
|                 opts={{ | ||||
|                     allowCreatingNotes: true, | ||||
|                     hideGoToSelectedNoteButton: true, | ||||
|                     allowJumpToSearchNotes: true, | ||||
|                     isCommandPalette: true | ||||
|                 }} | ||||
|                 onTextChange={setText} | ||||
|                 onChange={onItemSelected} | ||||
|                 />} | ||||
|             onShown={onShown} | ||||
|             footer={!isCommandMode && <Button className="show-in-full-text-button" text={t("jump_to_note.search_button")} keyboardShortcut="Ctrl+Enter" />} | ||||
|         > | ||||
|             <div className="algolia-autocomplete-container jump-to-note-results" ref={containerRef}></div> | ||||
|         </Modal> | ||||
|     ); | ||||
| } | ||||
|  | ||||
| export default class JumpToNoteDialog extends ReactBasicWidget { | ||||
|  | ||||
|     private lastOpenedTs: number; | ||||
|     private props: JumpToNoteDialogProps = { | ||||
|         mode: "last-search" | ||||
|     }; | ||||
|  | ||||
|     get component() { | ||||
|         return <JumpToNoteDialogComponent {...this.props} />; | ||||
|     } | ||||
|  | ||||
|     async openDialog(commandMode = false) {         | ||||
|         this.lastOpenedTs = Date.now(); | ||||
|          | ||||
|         let newMode: Mode; | ||||
|         if (commandMode) { | ||||
|             newMode = "commands";             | ||||
|         } else if (Date.now() - this.lastOpenedTs > KEEP_LAST_SEARCH_FOR_X_SECONDS * 1000) { | ||||
|             // 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. | ||||
|             newMode = "recent-notes"; | ||||
|         } else { | ||||
|             newMode = "last-search"; | ||||
|         } | ||||
|  | ||||
|         if (this.props.mode !== newMode) { | ||||
|             this.props.mode = newMode; | ||||
|             this.doRender(); | ||||
|         } | ||||
|  | ||||
|         openDialog(this.$widget); | ||||
|     } | ||||
|  | ||||
|     async jumpToNoteEvent() { | ||||
|         await this.openDialog(); | ||||
|     } | ||||
|      | ||||
|     async commandPaletteEvent() { | ||||
|         await this.openDialog(true); | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -5,7 +5,7 @@ import type { CSSProperties } from "preact/compat"; | ||||
|  | ||||
| interface ModalProps { | ||||
|     className: string; | ||||
|     title: string; | ||||
|     title: string | ComponentChildren; | ||||
|     size: "lg" | "sm"; | ||||
|     children: ComponentChildren; | ||||
|     footer?: ComponentChildren; | ||||
| @@ -49,7 +49,11 @@ export default function Modal({ children, className, size, title, footer, onShow | ||||
|             <div className={`modal-dialog modal-${size}`} style={style} role="document"> | ||||
|                 <div className="modal-content"> | ||||
|                     <div className="modal-header"> | ||||
|                         {typeof title === "string" ? ( | ||||
|                             <h5 className="modal-title">{title}</h5> | ||||
|                         ) : ( | ||||
|                             title | ||||
|                         )} | ||||
|                         {helpPageId && ( | ||||
|                             <button className="help-button" type="button" data-in-app-help={helpPageId} title={t("modal.help_title")}>?</button> | ||||
|                         )} | ||||
|   | ||||
| @@ -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<HTMLInputElement>; | ||||
|     text?: string; | ||||
|     allowExternalLinks?: boolean; | ||||
|     allowCreatingNotes?: boolean; | ||||
|     placeholder?: string; | ||||
|     container?: RefObject<HTMLDivElement>; | ||||
|     opts?: Omit<Options, "container">; | ||||
|     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<HTMLInputElement>(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)); | ||||
|             const listener = (_e, suggestion) => onChange(suggestion); | ||||
|             $autoComplete | ||||
|                 .on("autocomplete:noteselected", listener) | ||||
|                 .on("autocomplete:externallinkselected", listener) | ||||
|                 .on("autocomplete:commandselected", listener); | ||||
|         } | ||||
|     }, [allowExternalLinks, allowCreatingNotes]); | ||||
|         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 | ||||
|             <input | ||||
|                 ref={ref} | ||||
|                 className="note-autocomplete form-control" | ||||
|                 placeholder={t("add_link.search_note")} /> | ||||
|                 placeholder={placeholder ?? t("add_link.search_note")} /> | ||||
|         </div> | ||||
|     ); | ||||
| } | ||||
		Reference in New Issue
	
	Block a user