feat(dialogs): port jump to note partially

This commit is contained in:
Elian Doran 2025-08-04 18:54:17 +03:00
parent a9c25b4edd
commit d5e42318dd
No known key found for this signature in database
13 changed files with 176 additions and 232 deletions

View File

@ -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;
}

View File

@ -211,7 +211,7 @@
},
"jump_to_note": {
"close": "关闭",
"search_button": "全文搜索 <kbd>Ctrl+回车</kbd>"
"search_button": "全文搜索"
},
"markdown_import": {
"dialog_title": "Markdown 导入",

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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..."
},

View File

@ -194,7 +194,7 @@
"okButton": "確定"
},
"jump_to_note": {
"search_button": "全文搜尋 <kbd>Ctrl+Enter</kbd>"
"search_button": "全文搜尋"
},
"markdown_import": {
"dialog_title": "Markdown 匯入",

View File

@ -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>

View File

@ -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();
}
}

View 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);
}
}

View File

@ -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">
<h5 className="modal-title">{title}</h5>
{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>
)}

View File

@ -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));
}
}, [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
<input
ref={ref}
className="note-autocomplete form-control"
placeholder={t("add_link.search_note")} />
placeholder={placeholder ?? t("add_link.search_note")} />
</div>
);
}