mirror of
https://github.com/zadam/trilium.git
synced 2025-10-20 15:19:01 +02:00
feat(dialogs): port jump to note partially
This commit is contained in:
parent
a9c25b4edd
commit
d5e42318dd
@ -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;
|
||||
}
|
||||
|
@ -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">
|
||||
<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>
|
||||
)}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user