feat(search): add create into inbox to search

This commit is contained in:
Jakob Schlanstedt 2025-10-12 06:31:48 +02:00 committed by Jakob Schlanstedt
parent a577fd45e2
commit d32bfcc160
26 changed files with 621 additions and 294 deletions

View file

@ -11,6 +11,7 @@ import froca from "../services/froca.js";
import linkService from "../services/link.js"; import linkService from "../services/link.js";
import { t } from "../services/i18n.js"; import { t } from "../services/i18n.js";
import { CreateChildrenResponse, SqlExecuteResponse } from "@triliumnext/commons"; import { CreateChildrenResponse, SqlExecuteResponse } from "@triliumnext/commons";
import noteCreateService from "../services/note_create";
export default class Entrypoints extends Component { export default class Entrypoints extends Component {
constructor() { constructor() {
@ -24,23 +25,7 @@ export default class Entrypoints extends Component {
} }
async createNoteIntoInboxCommand() { async createNoteIntoInboxCommand() {
const inboxNote = await dateNoteService.getInboxNote(); await noteCreateService.createNoteIntoInbox();
if (!inboxNote) {
console.warn("Missing inbox note.");
return;
}
const { note } = await server.post<CreateChildrenResponse>(`notes/${inboxNote.noteId}/children?target=into`, {
content: "",
type: "text",
isProtected: inboxNote.isProtected && protectedSessionHolder.isProtectedSessionAvailable()
});
await ws.waitForMaxKnownEntityChangeId();
await appContext.tabManager.openTabWithNoteWithHoisting(note.noteId, { activate: true });
appContext.triggerEvent("focusAndSelectTitle", { isNewNote: true });
} }
async toggleNoteHoistingCommand({ noteId = appContext.tabManager.getActiveContextNoteId() }) { async toggleNoteHoistingCommand({ noteId = appContext.tabManager.getActiveContextNoteId() }) {

View file

@ -48,7 +48,7 @@ export default class MainTreeExecutors extends Component {
return; return;
} }
await noteCreateService.createNote(activeNoteContext.notePath, { await noteCreateService.createNoteIntoPath(activeNoteContext.notePath, {
isProtected: activeNoteContext.note.isProtected, isProtected: activeNoteContext.note.isProtected,
saveSelection: false saveSelection: false
}); });
@ -72,7 +72,7 @@ export default class MainTreeExecutors extends Component {
return; return;
} }
await noteCreateService.createNote(parentNotePath, { await noteCreateService.createNoteIntoPath(parentNotePath, {
target: "after", target: "after",
targetBranchId: node.data.branchId, targetBranchId: node.data.branchId,
isProtected: isProtected, isProtected: isProtected,

View file

@ -233,7 +233,7 @@ export default class RootCommandExecutor extends Component {
// Create a new AI Chat note at the root level // Create a new AI Chat note at the root level
const rootNoteId = "root"; const rootNoteId = "root";
const result = await noteCreateService.createNote(rootNoteId, { const result = await noteCreateService.createNoteIntoPath(rootNoteId, {
title: "New AI Chat", title: "New AI Chat",
type: "aiChat", type: "aiChat",
content: JSON.stringify({ content: JSON.stringify({

View file

@ -273,7 +273,7 @@ export default class TreeContextMenu implements SelectMenuItemEventListener<Tree
const parentNotePath = treeService.getNotePath(this.node.getParent()); const parentNotePath = treeService.getNotePath(this.node.getParent());
const isProtected = treeService.getParentProtectedStatus(this.node); const isProtected = treeService.getParentProtectedStatus(this.node);
noteCreateService.createNote(parentNotePath, { noteCreateService.createNoteIntoPath(parentNotePath, {
target: "after", target: "after",
targetBranchId: this.node.data.branchId, targetBranchId: this.node.data.branchId,
type: type, type: type,
@ -283,7 +283,7 @@ export default class TreeContextMenu implements SelectMenuItemEventListener<Tree
} else if (command === "insertChildNote") { } else if (command === "insertChildNote") {
const parentNotePath = treeService.getNotePath(this.node); const parentNotePath = treeService.getNotePath(this.node);
noteCreateService.createNote(parentNotePath, { noteCreateService.createNoteIntoPath(parentNotePath, {
type: type, type: type,
isProtected: this.node.data.isProtected, isProtected: this.node.data.isProtected,
templateNoteId: templateNoteId templateNoteId: templateNoteId

View file

@ -5,6 +5,23 @@ import froca from "./froca.js";
import { t } from "./i18n.js"; import { t } from "./i18n.js";
import commandRegistry from "./command_registry.js"; import commandRegistry from "./command_registry.js";
import type { MentionFeedObjectItem } from "@triliumnext/ckeditor5"; import type { MentionFeedObjectItem } from "@triliumnext/ckeditor5";
import { MentionAction } from "@triliumnext/ckeditor5/src/augmentation.js";
/**
* Extends CKEditor's MentionFeedObjectItem with extra fields used by Trilium.
* These additional props (like action, notePath, name, etc.) carry note
* metadata and legacy compatibility info needed for custom autocomplete
* and link insertion behavior beyond CKEditors base mention support.
*/
type ExtendedMentionFeedObjectItem = MentionFeedObjectItem & {
action?: string;
noteTitle?: string;
name?: string;
link?: string;
notePath?: string;
parentNoteId?: string;
highlightedNotePathTitle?: string;
};
// this key needs to have this value, so it's hit by the tooltip // this key needs to have this value, so it's hit by the tooltip
const SELECTED_NOTE_PATH_KEY = "data-note-path"; const SELECTED_NOTE_PATH_KEY = "data-note-path";
@ -23,14 +40,39 @@ function getSearchDelay(notesCount: number): number {
} }
let searchDelay = getSearchDelay(notesCount); let searchDelay = getSearchDelay(notesCount);
// TODO: Deduplicate with server. // String values ensure stable, human-readable identifiers across serialization (JSON, CKEditor, logs).
export enum SuggestionAction {
// These values intentionally mirror MentionAction string values 1:1.
// This overlap ensures that when a suggestion triggers a note creation callback,
// the receiving features (e.g. note creation handlers, CKEditor mentions) can interpret
// the action type consistently
CreateNoteIntoInbox = MentionAction.CreateNoteIntoInbox,
CreateNoteIntoPath = MentionAction.CreateNoteIntoPath,
CreateAndLinkNoteIntoInbox = MentionAction.CreateAndLinkNoteIntoInbox,
CreateAndLinkNoteIntoPath = MentionAction.CreateAndLinkNoteIntoPath,
SearchNotes = "search-notes",
ExternalLink = "external-link",
Command = "command",
}
export enum CreateMode {
None = "none",
CreateOnly = "create-only",
CreateAndLink = "create-and-link"
}
// NOTE: Previously marked for deduplication with a server-side type,
// but review on 2025-10-12 (using `rg Suggestion`) found no corresponding
// server implementation.
// This interface appears to be client-only.
export interface Suggestion { export interface Suggestion {
noteTitle?: string; noteTitle?: string;
externalLink?: string; externalLink?: string;
notePathTitle?: string; notePathTitle?: string;
notePath?: string; notePath?: string;
highlightedNotePathTitle?: string; highlightedNotePathTitle?: string;
action?: string | "create-note" | "search-notes" | "external-link" | "command"; action?: SuggestionAction;
parentNoteId?: string; parentNoteId?: string;
icon?: string; icon?: string;
commandId?: string; commandId?: string;
@ -43,7 +85,7 @@ export interface Suggestion {
export interface Options { export interface Options {
container?: HTMLElement | null; container?: HTMLElement | null;
fastSearch?: boolean; fastSearch?: boolean;
allowCreatingNotes?: boolean; createMode?: CreateMode;
allowJumpToSearchNotes?: boolean; allowJumpToSearchNotes?: boolean;
allowExternalLinks?: boolean; allowExternalLinks?: boolean;
/** If set, hides the right-side button corresponding to go to selected note. */ /** If set, hides the right-side button corresponding to go to selected note. */
@ -54,110 +96,160 @@ export interface Options {
isCommandPalette?: boolean; isCommandPalette?: boolean;
} }
async function autocompleteSourceForCKEditor(queryText: string) { async function autocompleteSourceForCKEditor(
return await new Promise<MentionFeedObjectItem[]>((res, rej) => { queryText: string,
createMode: CreateMode
): Promise<MentionFeedObjectItem[]> {
// Wrap the callback-based autocompleteSource in a Promise for async/await
const rows = await new Promise<Suggestion[]>((resolve) => {
autocompleteSource( autocompleteSource(
queryText, queryText,
(rows) => { (suggestions) => resolve(suggestions),
res(
rows.map((row) => {
return {
action: row.action,
noteTitle: row.noteTitle,
id: `@${row.notePathTitle}`,
name: row.notePathTitle || "",
link: `#${row.notePath}`,
notePath: row.notePath,
highlightedNotePathTitle: row.highlightedNotePathTitle
};
})
);
},
{ {
allowCreatingNotes: true createMode,
} }
); );
}); });
// Map internal suggestions to CKEditor mention feed items
return rows.map((row): ExtendedMentionFeedObjectItem => ({
action: row.action?.toString(),
noteTitle: row.noteTitle,
id: `@${row.notePathTitle}`,
name: row.notePathTitle || "",
link: `#${row.notePath}`,
notePath: row.notePath,
parentNoteId: row.parentNoteId,
highlightedNotePathTitle: row.highlightedNotePathTitle
}));
} }
async function autocompleteSource(term: string, cb: (rows: Suggestion[]) => void, options: Options = {}) { async function autocompleteSource(
term: string,
callback: (rows: Suggestion[]) => void,
options: Options = {}
) {
// Check if we're in command mode // Check if we're in command mode
if (options.isCommandPalette && term.startsWith(">")) { if (options.isCommandPalette && term.startsWith(">")) {
const commandQuery = term.substring(1).trim(); const commandQuery = term.substring(1).trim();
// Get commands (all if no query, filtered if query provided) // Get commands (all if no query, filtered if query provided)
const commands = commandQuery.length === 0 const commands =
? commandRegistry.getAllCommands() commandQuery.length === 0
: commandRegistry.searchCommands(commandQuery); ? commandRegistry.getAllCommands()
: commandRegistry.searchCommands(commandQuery);
// Convert commands to suggestions // Convert commands to suggestions
const commandSuggestions: Suggestion[] = commands.map(cmd => ({ const commandSuggestions: Suggestion[] = commands.map((cmd) => ({
action: "command", action: SuggestionAction.Command,
commandId: cmd.id, commandId: cmd.id,
noteTitle: cmd.name, noteTitle: cmd.name,
notePathTitle: `>${cmd.name}`, notePathTitle: `>${cmd.name}`,
highlightedNotePathTitle: cmd.name, highlightedNotePathTitle: cmd.name,
commandDescription: cmd.description, commandDescription: cmd.description,
commandShortcut: cmd.shortcut, commandShortcut: cmd.shortcut,
icon: cmd.icon icon: cmd.icon,
})); }));
cb(commandSuggestions); callback(commandSuggestions);
return; return;
} }
const fastSearch = options.fastSearch === false ? false : true; const fastSearch = options.fastSearch !== false;
if (fastSearch === false) { const trimmedTerm = term.trim();
if (term.trim().length === 0) { const activeNoteId = appContext.tabManager.getActiveContextNoteId();
return;
} if (!fastSearch && trimmedTerm.length === 0) return;
cb([
if (!fastSearch) {
callback([
{ {
noteTitle: term, noteTitle: trimmedTerm,
highlightedNotePathTitle: t("quick-search.searching") highlightedNotePathTitle: t("quick-search.searching"),
} },
]); ]);
} }
const activeNoteId = appContext.tabManager.getActiveContextNoteId(); let results = await server.get<Suggestion[]>(
const length = term.trim().length; `autocomplete?query=${encodeURIComponent(trimmedTerm)}&activeNoteId=${activeNoteId}&fastSearch=${fastSearch}`
);
let results = await server.get<Suggestion[]>(`autocomplete?query=${encodeURIComponent(term)}&activeNoteId=${activeNoteId}&fastSearch=${fastSearch}`);
options.fastSearch = true; options.fastSearch = true;
if (length >= 1 && options.allowCreatingNotes) { // --- Create Note suggestions ---
results = [ if (trimmedTerm.length >= 1) {
{ switch (options.createMode) {
action: "create-note", case CreateMode.CreateOnly: {
noteTitle: term, results = [
parentNoteId: activeNoteId || "root", {
highlightedNotePathTitle: t("note_autocomplete.create-note", { term }) action: SuggestionAction.CreateNoteIntoInbox,
} as Suggestion noteTitle: trimmedTerm,
].concat(results); parentNoteId: "inbox",
} highlightedNotePathTitle: t("note_autocomplete.create-note-into-inbox", { term: trimmedTerm }),
},
if (length >= 1 && options.allowJumpToSearchNotes) { {
results = results.concat([ action: SuggestionAction.CreateNoteIntoPath,
{ noteTitle: trimmedTerm,
action: "search-notes", parentNoteId: activeNoteId || "root",
noteTitle: term, highlightedNotePathTitle: t("note_autocomplete.create-note-into-path", { term: trimmedTerm }),
highlightedNotePathTitle: `${t("note_autocomplete.search-for", { term })} <kbd style='color: var(--muted-text-color); background-color: transparent; float: right;'>Ctrl+Enter</kbd>` },
...results,
];
break;
} }
]);
case CreateMode.CreateAndLink: {
results = [
{
action: SuggestionAction.CreateAndLinkNoteIntoInbox,
noteTitle: trimmedTerm,
parentNoteId: "inbox",
highlightedNotePathTitle: t("note_autocomplete.create-and-link-note-into-inbox", { term: trimmedTerm }),
},
{
action: SuggestionAction.CreateAndLinkNoteIntoPath,
noteTitle: trimmedTerm,
parentNoteId: activeNoteId || "root",
highlightedNotePathTitle: t("note_autocomplete.create-and-link-note-into-path", { term: trimmedTerm }),
},
...results,
];
break;
}
default:
// CreateMode.None or undefined → no creation suggestions
break;
}
} }
if (term.match(/^[a-z]+:\/\/.+/i) && options.allowExternalLinks) { // --- Jump to Search Notes ---
if (trimmedTerm.length >= 1 && options.allowJumpToSearchNotes) {
results = [
...results,
{
action: SuggestionAction.SearchNotes,
noteTitle: trimmedTerm,
highlightedNotePathTitle: `${t("note_autocomplete.search-for", {
term: trimmedTerm,
})} <kbd style='color: var(--muted-text-color); background-color: transparent; float: right;'>Ctrl+Enter</kbd>`,
},
];
}
// --- External Link suggestion ---
if (/^[a-z]+:\/\/.+/i.test(trimmedTerm) && options.allowExternalLinks) {
results = [ results = [
{ {
action: "external-link", action: SuggestionAction.ExternalLink,
externalLink: term, externalLink: trimmedTerm,
highlightedNotePathTitle: t("note_autocomplete.insert-external-link", { term }) highlightedNotePathTitle: t("note_autocomplete.insert-external-link", { term: trimmedTerm }),
} as Suggestion },
].concat(results); ...results,
];
} }
cb(results); callback(results);
} }
function clearText($el: JQuery<HTMLElement>) { function clearText($el: JQuery<HTMLElement>) {
@ -198,6 +290,64 @@ function fullTextSearch($el: JQuery<HTMLElement>, options: Options) {
$el.autocomplete("val", searchString); $el.autocomplete("val", searchString);
} }
function renderCommandSuggestion(s: Suggestion): string {
const icon = s.icon || "bx bx-terminal";
const shortcut = s.commandShortcut
? `<kbd class="command-shortcut">${s.commandShortcut}</kbd>`
: "";
return `
<div class="command-suggestion">
<span class="command-icon ${icon}"></span>
<div class="command-content">
<div class="command-name">${s.highlightedNotePathTitle}</div>
${s.commandDescription ? `<div class="command-description">${s.commandDescription}</div>` : ""}
</div>
${shortcut}
</div>
`;
}
function renderNoteSuggestion(s: Suggestion): string {
const actionClass =
s.action === SuggestionAction.SearchNotes ? "search-notes-action" : "";
const iconClass = (() => {
switch (s.action) {
case SuggestionAction.SearchNotes:
return "bx bx-search";
case SuggestionAction.CreateAndLinkNoteIntoInbox:
case SuggestionAction.CreateNoteIntoInbox:
return "bx bx-plus";
case SuggestionAction.CreateAndLinkNoteIntoPath:
case SuggestionAction.CreateNoteIntoPath:
return "bx bx-plus";
case SuggestionAction.ExternalLink:
return "bx bx-link-external";
default:
return s.icon ?? "bx bx-note";
}
})();
return `
<div class="note-suggestion ${actionClass}" style="display:inline-flex; align-items:center;">
<span class="icon ${iconClass}" style="display:inline-block; vertical-align:middle; line-height:1; margin-right:0.4em;"></span>
<span class="text" style="display:inline-block; vertical-align:middle;">
<span class="search-result-title">${s.highlightedNotePathTitle}</span>
${s.highlightedAttributeSnippet
? `<span class="search-result-attributes">${s.highlightedAttributeSnippet}</span>`
: ""}
</span>
</div>
`;
}
function renderSuggestion(suggestion: Suggestion): string {
return suggestion.action === SuggestionAction.Command
? renderCommandSuggestion(suggestion)
: renderNoteSuggestion(suggestion);
}
function initNoteAutocomplete($el: JQuery<HTMLElement>, options?: Options) { function initNoteAutocomplete($el: JQuery<HTMLElement>, options?: Options) {
if ($el.hasClass("note-autocomplete-input")) { if ($el.hasClass("note-autocomplete-input")) {
// clear any event listener added in previous invocation of this function // clear any event listener added in previous invocation of this function
@ -283,24 +433,21 @@ function initNoteAutocomplete($el: JQuery<HTMLElement>, options?: Options) {
$el.autocomplete( $el.autocomplete(
{ {
...autocompleteOptions, ...autocompleteOptions,
appendTo: document.querySelector("body"), appendTo: document.body,
hint: false, hint: false,
autoselect: true, autoselect: true,
// openOnFocus has to be false, otherwise re-focus (after return from note type chooser dialog) forces
// re-querying of the autocomplete source which then changes the currently selected suggestion
openOnFocus: false, openOnFocus: false,
minLength: 0, minLength: 0,
tabAutocomplete: false tabAutocomplete: false,
}, },
[ [
{ {
source: (term, cb) => { source: (term, callback) => {
clearTimeout(debounceTimeoutId); clearTimeout(debounceTimeoutId);
debounceTimeoutId = setTimeout(() => { debounceTimeoutId = setTimeout(() => {
if (isComposingInput) { if (!isComposingInput) {
return; autocompleteSource(term, callback, options);
} }
autocompleteSource(term, cb, options);
}, searchDelay); }, searchDelay);
if (searchDelay === 0) { if (searchDelay === 0) {
@ -308,109 +455,124 @@ function initNoteAutocomplete($el: JQuery<HTMLElement>, options?: Options) {
} }
}, },
displayKey: "notePathTitle", displayKey: "notePathTitle",
templates: { templates: { suggestion: renderSuggestion },
suggestion: (suggestion) => { cache: false,
if (suggestion.action === "command") { },
let html = `<div class="command-suggestion">`;
html += `<span class="command-icon ${suggestion.icon || "bx bx-terminal"}"></span>`;
html += `<div class="command-content">`;
html += `<div class="command-name">${suggestion.highlightedNotePathTitle}</div>`;
if (suggestion.commandDescription) {
html += `<div class="command-description">${suggestion.commandDescription}</div>`;
}
html += `</div>`;
if (suggestion.commandShortcut) {
html += `<kbd class="command-shortcut">${suggestion.commandShortcut}</kbd>`;
}
html += '</div>';
return html;
}
// Add special class for search-notes action
const actionClass = suggestion.action === "search-notes" ? "search-notes-action" : "";
// Choose appropriate icon based on action
let iconClass = suggestion.icon ?? "bx bx-note";
if (suggestion.action === "search-notes") {
iconClass = "bx bx-search";
} else if (suggestion.action === "create-note") {
iconClass = "bx bx-plus";
} else if (suggestion.action === "external-link") {
iconClass = "bx bx-link-external";
}
// Simplified HTML structure without nested divs
let html = `<div class="note-suggestion ${actionClass}">`;
html += `<span class="icon ${iconClass}"></span>`;
html += `<span class="text">`;
html += `<span class="search-result-title">${suggestion.highlightedNotePathTitle}</span>`;
// Add attribute snippet inline if available
if (suggestion.highlightedAttributeSnippet) {
html += `<span class="search-result-attributes">${suggestion.highlightedAttributeSnippet}</span>`;
}
html += `</span>`;
html += `</div>`;
return html;
}
},
// we can't cache identical searches because notes can be created / renamed, new recent notes can be added
cache: false
}
] ]
); );
// TODO: Types fail due to "autocomplete:selected" not being registered in type definitions. // TODO: Types fail due to "autocomplete:selected" not being registered in type definitions.
($el as any).on("autocomplete:selected", async (event: Event, suggestion: Suggestion) => { ($el as any).on("autocomplete:selected", async (event: Event, suggestion: Suggestion) => {
if (suggestion.action === "command") { switch (suggestion.action) {
$el.autocomplete("close"); case SuggestionAction.Command: {
$el.trigger("autocomplete:commandselected", [suggestion]); $el.autocomplete("close");
return; $el.trigger("autocomplete:commandselected", [suggestion]);
} break;
if (suggestion.action === "external-link") {
$el.setSelectedNotePath(null);
$el.setSelectedExternalLink(suggestion.externalLink);
$el.autocomplete("val", suggestion.externalLink);
$el.autocomplete("close");
$el.trigger("autocomplete:externallinkselected", [suggestion]);
return;
}
if (suggestion.action === "create-note") {
const { success, noteType, templateNoteId, notePath } = await noteCreateService.chooseNoteType();
if (!success) {
return;
} }
const { note } = await noteCreateService.createNote( notePath || suggestion.parentNoteId, {
title: suggestion.noteTitle,
activate: false,
type: noteType,
templateNoteId: templateNoteId
});
const hoistedNoteId = appContext.tabManager.getActiveContext()?.hoistedNoteId; case SuggestionAction.ExternalLink: {
suggestion.notePath = note?.getBestNotePathString(hoistedNoteId); $el.setSelectedNotePath(null);
$el.setSelectedExternalLink(suggestion.externalLink);
$el.autocomplete("val", suggestion.externalLink);
$el.autocomplete("close");
$el.trigger("autocomplete:externallinkselected", [suggestion]);
break;
}
// --- CREATE NOTE INTO INBOX ---
case SuggestionAction.CreateNoteIntoInbox: {
const { success, noteType, templateNoteId } = await noteCreateService.chooseNoteType();
if (!success) return;
const { note } = await noteCreateService.createNoteIntoInbox({
title: suggestion.noteTitle,
activate: true,
type: noteType,
templateNoteId,
});
const hoistedNoteId = appContext.tabManager.getActiveContext()?.hoistedNoteId;
suggestion.notePath = note?.getBestNotePathString(hoistedNoteId);
$el.trigger("autocomplete:noteselected", [suggestion]);
$el.autocomplete("close");
break;
}
// --- CREATE AND LINK NOTE INTO INBOX ---
case SuggestionAction.CreateAndLinkNoteIntoInbox: {
const { success, noteType, templateNoteId } = await noteCreateService.chooseNoteType();
if (!success) return;
const { note } = await noteCreateService.createNoteIntoInbox({
title: suggestion.noteTitle,
activate: false,
type: noteType,
templateNoteId,
});
const hoistedNoteId = appContext.tabManager.getActiveContext()?.hoistedNoteId;
suggestion.notePath = note?.getBestNotePathString(hoistedNoteId);
$el.trigger("autocomplete:noteselected", [suggestion]);
$el.autocomplete("close");
break;
}
// --- CREATE NOTE INTO PATH ---
case SuggestionAction.CreateNoteIntoPath: {
const { success, noteType, templateNoteId, notePath } = await noteCreateService.chooseNoteType();
if (!success) return;
const { note } = await noteCreateService.createNoteIntoPath(notePath || suggestion.parentNoteId, {
title: suggestion.noteTitle,
activate: true,
type: noteType,
templateNoteId,
});
const hoistedNoteId = appContext.tabManager.getActiveContext()?.hoistedNoteId;
suggestion.notePath = note?.getBestNotePathString(hoistedNoteId);
$el.trigger("autocomplete:noteselected", [suggestion]);
$el.autocomplete("close");
break;
}
// --- CREATE AND LINK NOTE INTO PATH ---
case SuggestionAction.CreateAndLinkNoteIntoPath: {
const { success, noteType, templateNoteId, notePath } = await noteCreateService.chooseNoteType();
if (!success) return;
const { note } = await noteCreateService.createNoteIntoPath(notePath || suggestion.parentNoteId, {
title: suggestion.noteTitle,
activate: false,
type: noteType,
templateNoteId,
});
const hoistedNoteId = appContext.tabManager.getActiveContext()?.hoistedNoteId;
suggestion.notePath = note?.getBestNotePathString(hoistedNoteId);
$el.trigger("autocomplete:noteselected", [suggestion]);
$el.autocomplete("close");
break;
}
case SuggestionAction.SearchNotes: {
const searchString = suggestion.noteTitle;
appContext.triggerCommand("searchNotes", { searchString });
break;
}
default: {
$el.setSelectedNotePath(suggestion.notePath);
$el.setSelectedExternalLink(null);
$el.autocomplete("val", suggestion.noteTitle);
$el.autocomplete("close");
$el.trigger("autocomplete:noteselected", [suggestion]);
}
} }
if (suggestion.action === "search-notes") {
const searchString = suggestion.noteTitle;
appContext.triggerCommand("searchNotes", { searchString });
return;
}
$el.setSelectedNotePath(suggestion.notePath);
$el.setSelectedExternalLink(null);
$el.autocomplete("val", suggestion.noteTitle);
$el.autocomplete("close");
$el.trigger("autocomplete:noteselected", [suggestion]);
}); });
$el.on("autocomplete:closed", () => { $el.on("autocomplete:closed", () => {

View file

@ -10,6 +10,8 @@ import type FNote from "../entities/fnote.js";
import type FBranch from "../entities/fbranch.js"; import type FBranch from "../entities/fbranch.js";
import type { ChooseNoteTypeResponse } from "../widgets/dialogs/note_type_chooser.js"; import type { ChooseNoteTypeResponse } from "../widgets/dialogs/note_type_chooser.js";
import type { CKTextEditor } from "@triliumnext/ckeditor5"; import type { CKTextEditor } from "@triliumnext/ckeditor5";
import dateNoteService from "../services/date_notes.js";
import { CreateChildrenResponse } from "@triliumnext/commons";
export interface CreateNoteOpts { export interface CreateNoteOpts {
isProtected?: boolean; isProtected?: boolean;
@ -37,7 +39,47 @@ interface DuplicateResponse {
note: FNote; note: FNote;
} }
async function createNote(parentNotePath: string | undefined, options: CreateNoteOpts = {}) { /**
* Creates a new note inside the user's Inbox.
*
* @param {CreateNoteOpts} [options] - Optional settings such as title, type, template, or content.
* @returns {Promise<{ note: FNote | null; branch: FBranch | undefined }>}
* Resolves with the created note and its branch, or `{ note: null, branch: undefined }` if the inbox is missing.
*/
async function createNoteIntoInbox(
options: CreateNoteOpts = {}
): Promise<{ note: FNote | null; branch: FBranch | undefined }> {
const inboxNote = await dateNoteService.getInboxNote();
if (!inboxNote) {
console.warn("Missing inbox note.");
// always return a defined object
return { note: null, branch: undefined };
}
if (options.isProtected === undefined) {
options.isProtected =
inboxNote.isProtected && protectedSessionHolder.isProtectedSessionAvailable();
}
const result = await createNoteIntoPath(inboxNote.noteId, {
...options,
target: "into",
});
return result;
}
/**
* Core function that creates a new note under the specified parent note path.
*
* @param {string | undefined} parentNotePath - The parent note path where the new note will be created.
* @param {CreateNoteOpts} [options] - Options controlling note creation (title, content, type, template, focus, etc.).
* @returns {Promise<{ note: FNote | null; branch: FBranch | undefined }>}
* Resolves with the created note and branch entities.
*/
async function createNoteIntoPath(
parentNotePath: string | undefined,
options: CreateNoteOpts = {}
): Promise<{ note: FNote | null; branch: FBranch | undefined }> {
options = Object.assign( options = Object.assign(
{ {
activate: true, activate: true,
@ -113,7 +155,7 @@ async function chooseNoteType() {
}); });
} }
async function createNoteWithTypePrompt(parentNotePath: string, options: CreateNoteOpts = {}) { async function createNoteIntoPathWithTypePrompt(parentNotePath: string, options: CreateNoteOpts = {}) {
const { success, noteType, templateNoteId, notePath } = await chooseNoteType(); const { success, noteType, templateNoteId, notePath } = await chooseNoteType();
if (!success) { if (!success) {
@ -123,7 +165,7 @@ async function createNoteWithTypePrompt(parentNotePath: string, options: CreateN
options.type = noteType; options.type = noteType;
options.templateNoteId = templateNoteId; options.templateNoteId = templateNoteId;
return await createNote(notePath || parentNotePath, options); return await createNoteIntoPath(notePath || parentNotePath, options);
} }
/* If the first element is heading, parse it out and use it as a new heading. */ /* If the first element is heading, parse it out and use it as a new heading. */
@ -158,8 +200,9 @@ async function duplicateSubtree(noteId: string, parentNotePath: string) {
} }
export default { export default {
createNote, createNoteIntoInbox,
createNoteWithTypePrompt, createNoteIntoPath,
createNoteIntoPathWithTypePrompt,
duplicateSubtree, duplicateSubtree,
chooseNoteType chooseNoteType
}; };

View file

@ -1883,7 +1883,10 @@
}, },
"note_autocomplete": { "note_autocomplete": {
"search-for": "Search for \"{{term}}\"", "search-for": "Search for \"{{term}}\"",
"create-note": "Create and link child note \"{{term}}\"", "create-note-into-path": "Create child note \"{{term}}\"",
"create-note-into-inbox": "Create in Inbox note \"{{term}}\"",
"create-and-link-note-into-path": "Create and link child note \"{{term}}\"",
"create-and-link-note-into-inbox": "Create in Inbox and link note \"{{term}}\"",
"insert-external-link": "Insert external link to \"{{term}}\"", "insert-external-link": "Insert external link to \"{{term}}\"",
"clear-text-field": "Clear text field", "clear-text-field": "Clear text field",
"show-recent-notes": "Show recent notes", "show-recent-notes": "Show recent notes",

View file

@ -3,7 +3,7 @@ import server from "../../services/server.js";
import froca from "../../services/froca.js"; import froca from "../../services/froca.js";
import linkService from "../../services/link.js"; import linkService from "../../services/link.js";
import attributeAutocompleteService from "../../services/attribute_autocomplete.js"; import attributeAutocompleteService from "../../services/attribute_autocomplete.js";
import noteAutocompleteService from "../../services/note_autocomplete.js"; import noteAutocompleteService, { CreateMode } from "../../services/note_autocomplete.js";
import promotedAttributeDefinitionParser from "../../services/promoted_attribute_definition_parser.js"; import promotedAttributeDefinitionParser from "../../services/promoted_attribute_definition_parser.js";
import NoteContextAwareWidget from "../note_context_aware_widget.js"; import NoteContextAwareWidget from "../note_context_aware_widget.js";
import SpacedUpdate from "../../services/spaced_update.js"; import SpacedUpdate from "../../services/spaced_update.js";
@ -429,7 +429,7 @@ export default class AttributeDetailWidget extends NoteContextAwareWidget {
this.$rowTargetNote = this.$widget.find(".attr-row-target-note"); this.$rowTargetNote = this.$widget.find(".attr-row-target-note");
this.$inputTargetNote = this.$widget.find(".attr-input-target-note"); this.$inputTargetNote = this.$widget.find(".attr-input-target-note");
noteAutocompleteService.initNoteAutocomplete(this.$inputTargetNote, { allowCreatingNotes: true }).on("autocomplete:noteselected", (event, suggestion, dataset) => { noteAutocompleteService.initNoteAutocomplete(this.$inputTargetNote, { createMode: CreateMode.CreateAndLink }).on("autocomplete:noteselected", (event, suggestion, dataset) => {
if (!suggestion.notePath) { if (!suggestion.notePath) {
return false; return false;
} }

View file

@ -28,7 +28,7 @@ export default class BoardApi {
const parentNotePath = this.parentNote.noteId; const parentNotePath = this.parentNote.noteId;
// Create a new note as a child of the parent note // Create a new note as a child of the parent note
const { note: newNote, branch: newBranch } = await note_create.createNote(parentNotePath, { const { note: newNote, branch: newBranch } = await note_create.createNoteIntoPath(parentNotePath, {
activate: false, activate: false,
title title
}); });
@ -130,7 +130,7 @@ export default class BoardApi {
column: string, column: string,
relativeToBranchId: string, relativeToBranchId: string,
direction: "before" | "after") { direction: "before" | "after") {
const { note, branch } = await note_create.createNote(this.parentNote.noteId, { const { note, branch } = await note_create.createNoteIntoPath(this.parentNote.noteId, {
activate: false, activate: false,
targetBranchId: relativeToBranchId, targetBranchId: relativeToBranchId,
target: direction, target: direction,

View file

@ -6,6 +6,7 @@ import Icon from "../../react/Icon.jsx";
import { useEffect, useRef, useState } from "preact/hooks"; import { useEffect, useRef, useState } from "preact/hooks";
import froca from "../../../services/froca.js"; import froca from "../../../services/froca.js";
import NoteAutocomplete from "../../react/NoteAutocomplete.jsx"; import NoteAutocomplete from "../../react/NoteAutocomplete.jsx";
import { CreateMode } from "../../../services/note_autocomplete.js";
type ColumnType = LabelType | "relation"; type ColumnType = LabelType | "relation";
@ -227,7 +228,7 @@ function RelationEditor({ cell, success }: EditorOpts) {
inputRef={inputRef} inputRef={inputRef}
noteId={cell.getValue()} noteId={cell.getValue()}
opts={{ opts={{
allowCreatingNotes: true, createMode: CreateMode.CreateAndLink,
hideAllButtons: true hideAllButtons: true
}} }}
noteIdChanged={success} noteIdChanged={success}

View file

@ -19,7 +19,7 @@ export default function useRowTableEditing(api: RefObject<Tabulator>, attributeD
activate: false, activate: false,
...customOpts ...customOpts
} }
note_create.createNote(notePath, opts).then(({ branch }) => { note_create.createNoteIntoPath(notePath, opts).then(({ branch }) => {
if (branch) { if (branch) {
setTimeout(() => { setTimeout(() => {
if (!api.current) return; if (!api.current) return;

View file

@ -5,7 +5,7 @@ import FormRadioGroup from "../react/FormRadioGroup";
import NoteAutocomplete from "../react/NoteAutocomplete"; import NoteAutocomplete from "../react/NoteAutocomplete";
import { useRef, useState, useEffect } from "preact/hooks"; import { useRef, useState, useEffect } from "preact/hooks";
import tree from "../../services/tree"; import tree from "../../services/tree";
import note_autocomplete, { Suggestion } from "../../services/note_autocomplete"; import note_autocomplete, { CreateMode, Suggestion } from "../../services/note_autocomplete";
import { default as TextTypeWidget } from "../type_widgets/editable_text.js"; import { default as TextTypeWidget } from "../type_widgets/editable_text.js";
import { logError } from "../../services/ws"; import { logError } from "../../services/ws";
import FormGroup from "../react/FormGroup.js"; import FormGroup from "../react/FormGroup.js";
@ -131,7 +131,7 @@ export default function AddLinkDialog() {
onChange={setSuggestion} onChange={setSuggestion}
opts={{ opts={{
allowExternalLinks: true, allowExternalLinks: true,
allowCreatingNotes: true createMode: CreateMode.CreateAndLink,
}} }}
/> />
</FormGroup> </FormGroup>

View file

@ -5,7 +5,7 @@ import FormRadioGroup from "../react/FormRadioGroup";
import Modal from "../react/Modal"; import Modal from "../react/Modal";
import NoteAutocomplete from "../react/NoteAutocomplete"; import NoteAutocomplete from "../react/NoteAutocomplete";
import Button from "../react/Button"; import Button from "../react/Button";
import { Suggestion, triggerRecentNotes } from "../../services/note_autocomplete"; import { CreateMode, Suggestion, triggerRecentNotes } from "../../services/note_autocomplete";
import tree from "../../services/tree"; import tree from "../../services/tree";
import froca from "../../services/froca"; import froca from "../../services/froca";
import EditableTextTypeWidget, { type BoxSize } from "../type_widgets/editable_text"; import EditableTextTypeWidget, { type BoxSize } from "../type_widgets/editable_text";
@ -49,7 +49,7 @@ export default function IncludeNoteDialog() {
inputRef={autoCompleteRef} inputRef={autoCompleteRef}
opts={{ opts={{
hideGoToSelectedNoteButton: true, hideGoToSelectedNoteButton: true,
allowCreatingNotes: true createMode: CreateMode.CreateOnly,
}} }}
/> />
</FormGroup> </FormGroup>

View file

@ -3,7 +3,7 @@ import Button from "../react/Button";
import NoteAutocomplete from "../react/NoteAutocomplete"; import NoteAutocomplete from "../react/NoteAutocomplete";
import { t } from "../../services/i18n"; import { t } from "../../services/i18n";
import { useRef, useState } from "preact/hooks"; import { useRef, useState } from "preact/hooks";
import note_autocomplete, { Suggestion } from "../../services/note_autocomplete"; import note_autocomplete, { CreateMode, Suggestion } from "../../services/note_autocomplete";
import appContext from "../../components/app_context"; import appContext from "../../components/app_context";
import commandRegistry from "../../services/command_registry"; import commandRegistry from "../../services/command_registry";
import { refToJQuerySelector } from "../react/react_utils"; import { refToJQuerySelector } from "../react/react_utils";
@ -12,34 +12,57 @@ import shortcutService from "../../services/shortcuts";
const KEEP_LAST_SEARCH_FOR_X_SECONDS = 120; const KEEP_LAST_SEARCH_FOR_X_SECONDS = 120;
type Mode = "last-search" | "recent-notes" | "commands"; enum Mode {
LastSearch,
RecentNotes,
Commands,
}
export default function JumpToNoteDialogComponent() { export default function JumpToNoteDialogComponent() {
const [ mode, setMode ] = useState<Mode>(); const [ mode, setMode ] = useState<Mode>();
const [ lastOpenedTs, setLastOpenedTs ] = useState<number>(0); const [ lastOpenedTs, setLastOpenedTs ] = useState<number>(0);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const autocompleteRef = useRef<HTMLInputElement>(null); const autocompleteRef = useRef<HTMLInputElement>(null);
const [ isCommandMode, setIsCommandMode ] = useState(mode === "commands"); const [ isCommandMode, setIsCommandMode ] = useState(mode === Mode.Commands);
const [ initialText, setInitialText ] = useState(isCommandMode ? "> " : ""); const [ initialText, setInitialText ] = useState(isCommandMode ? "> " : "");
const actualText = useRef<string>(initialText); const actualText = useRef<string>(initialText);
const [ shown, setShown ] = useState(false); const [ shown, setShown ] = useState(false);
async function openDialog(commandMode: boolean) { async function openDialog(requestedMode: Mode) {
let newMode: Mode; let newMode: Mode;
let initialText = ""; let initialText = "";
if (commandMode) { switch (requestedMode) {
newMode = "commands"; case Mode.Commands:
initialText = ">"; newMode = Mode.Commands;
} else if (Date.now() - lastOpenedTs <= KEEP_LAST_SEARCH_FOR_X_SECONDS * 1000 && actualText.current) { initialText = ">";
// if you open the Jump To dialog soon after using it previously, it can often mean that you break;
// actually want to search for the same thing (e.g., you opened the wrong note at first try)
// so we'll keep the content. case Mode.LastSearch:
// if it's outside of this time limit, then we assume it's a completely new search and show recent notes instead. // if you open the Jump To dialog soon after using it previously, it can often mean that you
newMode = "last-search"; // actually want to search for the same thing (e.g., you opened the wrong note at first try)
initialText = actualText.current; // so we'll keep the content.
} else { // 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"; if (Date.now() - lastOpenedTs <= KEEP_LAST_SEARCH_FOR_X_SECONDS * 1000 && actualText.current) {
newMode = Mode.LastSearch;
initialText = actualText.current;
} else {
newMode = Mode.RecentNotes;
}
break;
// Mode.RecentNotes intentionally falls through to default:
// both represent the "normal open" behavior, where we decide between
// showing recent notes or restoring the last search depending on timing.
case Mode.RecentNotes:
default:
if (Date.now() - lastOpenedTs <= KEEP_LAST_SEARCH_FOR_X_SECONDS * 1000 && actualText.current) {
newMode = Mode.LastSearch;
initialText = actualText.current;
} else {
newMode = Mode.RecentNotes;
}
break;
} }
if (mode !== newMode) { if (mode !== newMode) {
@ -51,8 +74,8 @@ export default function JumpToNoteDialogComponent() {
setLastOpenedTs(Date.now()); setLastOpenedTs(Date.now());
} }
useTriliumEvent("jumpToNote", () => openDialog(false)); useTriliumEvent("jumpToNote", () => openDialog(Mode.RecentNotes));
useTriliumEvent("commandPalette", () => openDialog(true)); useTriliumEvent("commandPalette", () => openDialog(Mode.Commands));
async function onItemSelected(suggestion?: Suggestion | null) { async function onItemSelected(suggestion?: Suggestion | null) {
if (!suggestion) { if (!suggestion) {
@ -116,7 +139,7 @@ export default function JumpToNoteDialogComponent() {
container={containerRef} container={containerRef}
text={initialText} text={initialText}
opts={{ opts={{
allowCreatingNotes: true, createMode: CreateMode.CreateOnly,
hideGoToSelectedNoteButton: true, hideGoToSelectedNoteButton: true,
allowJumpToSearchNotes: true, allowJumpToSearchNotes: true,
isCommandPalette: true isCommandPalette: true

View file

@ -7,7 +7,7 @@ import { useEffect, useState } from "preact/hooks";
import note_types from "../../services/note_types"; import note_types from "../../services/note_types";
import { MenuCommandItem, MenuItem } from "../../menus/context_menu"; import { MenuCommandItem, MenuItem } from "../../menus/context_menu";
import { TreeCommandNames } from "../../menus/tree_context_menu"; import { TreeCommandNames } from "../../menus/tree_context_menu";
import { Suggestion } from "../../services/note_autocomplete"; import { CreateMode, Suggestion } from "../../services/note_autocomplete";
import Badge from "../react/Badge"; import Badge from "../react/Badge";
import { useTriliumEvent } from "../react/hooks"; import { useTriliumEvent } from "../react/hooks";
@ -85,7 +85,7 @@ export default function NoteTypeChooserDialogComponent() {
onChange={setParentNote} onChange={setParentNote}
placeholder={t("note_type_chooser.search_placeholder")} placeholder={t("note_type_chooser.search_placeholder")}
opts={{ opts={{
allowCreatingNotes: false, createMode: CreateMode.None,
hideGoToSelectedNoteButton: true, hideGoToSelectedNoteButton: true,
allowJumpToSearchNotes: false, allowJumpToSearchNotes: false,
}} }}

View file

@ -5,7 +5,7 @@ import BasicWidget from "../basic_widget.js";
import toastService from "../../services/toast.js"; import toastService from "../../services/toast.js";
import appContext from "../../components/app_context.js"; import appContext from "../../components/app_context.js";
import server from "../../services/server.js"; import server from "../../services/server.js";
import noteAutocompleteService from "../../services/note_autocomplete.js"; import noteAutocompleteService, { CreateMode } from "../../services/note_autocomplete.js";
import { TPL, addMessageToChat, showSources, hideSources, showLoadingIndicator, hideLoadingIndicator } from "./ui.js"; import { TPL, addMessageToChat, showSources, hideSources, showLoadingIndicator, hideLoadingIndicator } from "./ui.js";
import { formatMarkdown } from "./utils.js"; import { formatMarkdown } from "./utils.js";
@ -163,7 +163,7 @@ export default class LlmChatPanel extends BasicWidget {
const mentionSetup: MentionFeed[] = [ const mentionSetup: MentionFeed[] = [
{ {
marker: "@", marker: "@",
feed: (queryText: string) => noteAutocompleteService.autocompleteSourceForCKEditor(queryText), feed: (queryText: string) => noteAutocompleteService.autocompleteSourceForCKEditor(queryText, CreateMode.CreateAndLink),
itemRenderer: (item) => { itemRenderer: (item) => {
const suggestion = item as Suggestion; const suggestion = item as Suggestion;
const itemElement = document.createElement("button"); const itemElement = document.createElement("button");

View file

@ -29,7 +29,7 @@ export default function MobileDetailMenu() {
], ],
selectMenuItemHandler: async ({ command }) => { selectMenuItemHandler: async ({ command }) => {
if (command === "insertChildNote") { if (command === "insertChildNote") {
note_create.createNote(appContext.tabManager.getActiveContextNotePath() ?? undefined); note_create.createNoteIntoPath(appContext.tabManager.getActiveContextNotePath() ?? undefined);
} else if (command === "delete") { } else if (command === "delete") {
const notePath = appContext.tabManager.getActiveContextNotePath(); const notePath = appContext.tabManager.getActiveContextNotePath();
if (!notePath) { if (!notePath) {

View file

@ -429,7 +429,7 @@ export default class NoteDetailWidget extends NoteContextAwareWidget {
// without await as this otherwise causes deadlock through component mutex // without await as this otherwise causes deadlock through component mutex
const parentNotePath = appContext.tabManager.getActiveContextNotePath(); const parentNotePath = appContext.tabManager.getActiveContextNotePath();
if (this.noteContext && parentNotePath) { if (this.noteContext && parentNotePath) {
noteCreateService.createNote(parentNotePath, { noteCreateService.createNoteIntoPath(parentNotePath, {
isProtected: note.isProtected, isProtected: note.isProtected,
saveSelection: true, saveSelection: true,
textEditor: await this.noteContext.getTextEditor() textEditor: await this.noteContext.getTextEditor()

View file

@ -232,7 +232,7 @@ export default class NoteTreeWidget extends NoteContextAwareWidget {
} else if (target.classList.contains("add-note-button")) { } else if (target.classList.contains("add-note-button")) {
const node = $.ui.fancytree.getNode(e as unknown as Event); const node = $.ui.fancytree.getNode(e as unknown as Event);
const parentNotePath = treeService.getNotePath(node); const parentNotePath = treeService.getNotePath(node);
noteCreateService.createNote(parentNotePath, { isProtected: node.data.isProtected }); noteCreateService.createNoteIntoPath(parentNotePath, { isProtected: node.data.isProtected });
} else if (target.classList.contains("enter-workspace-button")) { } else if (target.classList.contains("enter-workspace-button")) {
const node = $.ui.fancytree.getNode(e as unknown as Event); const node = $.ui.fancytree.getNode(e as unknown as Event);
this.triggerCommand("hoistNote", { noteId: node.data.noteId }); this.triggerCommand("hoistNote", { noteId: node.data.noteId });
@ -1843,7 +1843,7 @@ export default class NoteTreeWidget extends NoteContextAwareWidget {
const node = this.getActiveNode(); const node = this.getActiveNode();
if (!node) return; if (!node) return;
const notePath = treeService.getNotePath(node); const notePath = treeService.getNotePath(node);
noteCreateService.createNote(notePath, { noteCreateService.createNoteIntoPath(notePath, {
isProtected: node.data.isProtected isProtected: node.data.isProtected
}); });
} }

View file

@ -2,7 +2,7 @@ import { t } from "../services/i18n.js";
import server from "../services/server.js"; import server from "../services/server.js";
import ws from "../services/ws.js"; import ws from "../services/ws.js";
import treeService from "../services/tree.js"; import treeService from "../services/tree.js";
import noteAutocompleteService from "../services/note_autocomplete.js"; import noteAutocompleteService, { CreateMode } from "../services/note_autocomplete.js";
import NoteContextAwareWidget from "./note_context_aware_widget.js"; import NoteContextAwareWidget from "./note_context_aware_widget.js";
import attributeService from "../services/attributes.js"; import attributeService from "../services/attributes.js";
import options from "../services/options.js"; import options from "../services/options.js";
@ -341,7 +341,7 @@ export default class PromotedAttributesWidget extends NoteContextAwareWidget {
if (utils.isDesktop()) { if (utils.isDesktop()) {
// no need to wait for this // no need to wait for this
noteAutocompleteService.initNoteAutocomplete($input, { allowCreatingNotes: true }); noteAutocompleteService.initNoteAutocomplete($input, { createMode: CreateMode.CreateOnly});
$input.on("autocomplete:noteselected", (event, suggestion, dataset) => { $input.on("autocomplete:noteselected", (event, suggestion, dataset) => {
this.promotedAttributeChanged(event); this.promotedAttributeChanged(event);

View file

@ -2,7 +2,7 @@ import { MutableRef, useEffect, useImperativeHandle, useMemo, useRef, useState }
import { AttributeEditor as CKEditorAttributeEditor, MentionFeed, ModelElement, ModelNode, ModelPosition } from "@triliumnext/ckeditor5"; import { AttributeEditor as CKEditorAttributeEditor, MentionFeed, ModelElement, ModelNode, ModelPosition } from "@triliumnext/ckeditor5";
import { t } from "../../../services/i18n"; import { t } from "../../../services/i18n";
import server from "../../../services/server"; import server from "../../../services/server";
import note_autocomplete, { Suggestion } from "../../../services/note_autocomplete"; import note_autocomplete, { CreateMode, Suggestion } from "../../../services/note_autocomplete";
import CKEditor, { CKEditorApi } from "../../react/CKEditor"; import CKEditor, { CKEditorApi } from "../../react/CKEditor";
import { useLegacyImperativeHandlers, useLegacyWidget, useTooltip, useTriliumEvent, useTriliumOption } from "../../react/hooks"; import { useLegacyImperativeHandlers, useLegacyWidget, useTooltip, useTriliumEvent, useTriliumOption } from "../../react/hooks";
import FAttribute from "../../../entities/fattribute"; import FAttribute from "../../../entities/fattribute";
@ -20,6 +20,7 @@ import type { CommandData, FilteredCommandNames } from "../../../components/app_
import { AttributeType } from "@triliumnext/commons"; import { AttributeType } from "@triliumnext/commons";
import attributes from "../../../services/attributes"; import attributes from "../../../services/attributes";
import note_create from "../../../services/note_create"; import note_create from "../../../services/note_create";
import { MentionAction } from "@triliumnext/ckeditor5/src/augmentation.js";
type AttributeCommandNames = FilteredCommandNames<CommandData>; type AttributeCommandNames = FilteredCommandNames<CommandData>;
@ -33,7 +34,7 @@ const HELP_TEXT = `
const mentionSetup: MentionFeed[] = [ const mentionSetup: MentionFeed[] = [
{ {
marker: "@", marker: "@",
feed: (queryText) => note_autocomplete.autocompleteSourceForCKEditor(queryText), feed: (queryText) => note_autocomplete.autocompleteSourceForCKEditor(queryText, CreateMode.CreateAndLink),
itemRenderer: (_item) => { itemRenderer: (_item) => {
const item = _item as Suggestion; const item = _item as Suggestion;
const itemElement = document.createElement("button"); const itemElement = document.createElement("button");
@ -252,17 +253,40 @@ export default function AttributeEditor({ api, note, componentId, notePath, ntxI
$el.text(title); $el.text(title);
}, },
createNoteForReferenceLink: async (title: string) => { createNoteFromCkEditor: async (
let result; title: string,
if (notePath) { parentNotePath: string | undefined,
result = await note_create.createNoteWithTypePrompt(notePath, { action: MentionAction
activate: false, ): Promise<string> => {
title: title if (!parentNotePath) {
}); console.warn("Missing parentNotePath in createNoteFromCkEditor()");
return "";
} }
return result?.note?.getBestNotePathString(); switch (action) {
case MentionAction.CreateNoteIntoInbox:
case MentionAction.CreateAndLinkNoteIntoInbox: {
const { note } = await note_create.createNoteIntoInbox({
title,
activate: false
});
return note?.getBestNotePathString() ?? "";
}
case MentionAction.CreateNoteIntoPath:
case MentionAction.CreateAndLinkNoteIntoPath: {
const resp = await note_create.createNoteIntoPathWithTypePrompt(parentNotePath, {
title,
activate: false
});
return resp?.note?.getBestNotePathString() ?? "";
}
default:
console.warn("Unknown MentionAction:", action);
return "";
} }
}
}), [ notePath ])); }), [ notePath ]));
// Keyboard shortcuts // Keyboard shortcuts

View file

@ -8,7 +8,7 @@ import { copyTextWithToast } from "../../../services/clipboard_ext.js";
import getTemplates from "./snippets.js"; import getTemplates from "./snippets.js";
import { t } from "../../../services/i18n.js"; import { t } from "../../../services/i18n.js";
import { getMermaidConfig } from "../../../services/mermaid.js"; import { getMermaidConfig } from "../../../services/mermaid.js";
import noteAutocompleteService, { type Suggestion } from "../../../services/note_autocomplete.js"; import noteAutocompleteService, { CreateMode, type Suggestion } from "../../../services/note_autocomplete.js";
import mimeTypesService from "../../../services/mime_types.js"; import mimeTypesService from "../../../services/mime_types.js";
import { normalizeMimeTypeForCKEditor } from "@triliumnext/commons"; import { normalizeMimeTypeForCKEditor } from "@triliumnext/commons";
import { buildToolbarConfig } from "./toolbar.js"; import { buildToolbarConfig } from "./toolbar.js";
@ -181,7 +181,7 @@ export async function buildConfig(opts: BuildEditorOptions): Promise<EditorConfi
feeds: [ feeds: [
{ {
marker: "@", marker: "@",
feed: (queryText: string) => noteAutocompleteService.autocompleteSourceForCKEditor(queryText), feed: (queryText: string) => noteAutocompleteService.autocompleteSourceForCKEditor(queryText, CreateMode.CreateAndLink),
itemRenderer: (item) => { itemRenderer: (item) => {
const itemElement = document.createElement("button"); const itemElement = document.createElement("button");

View file

@ -13,6 +13,8 @@ import { buildConfig, BuildEditorOptions, OPEN_SOURCE_LICENSE_KEY } from "./cked
import type FNote from "../../entities/fnote.js"; import type FNote from "../../entities/fnote.js";
import { PopupEditor, ClassicEditor, EditorWatchdog, type CKTextEditor, type MentionFeed, type WatchdogConfig, EditorConfig } from "@triliumnext/ckeditor5"; import { PopupEditor, ClassicEditor, EditorWatchdog, type CKTextEditor, type MentionFeed, type WatchdogConfig, EditorConfig } from "@triliumnext/ckeditor5";
import { updateTemplateCache } from "./ckeditor/snippets.js"; import { updateTemplateCache } from "./ckeditor/snippets.js";
import { MentionAction } from "@triliumnext/ckeditor5/src/augmentation.js";
import note_create from "../../services/note_create.js";
export type BoxSize = "small" | "medium" | "full"; export type BoxSize = "small" | "medium" | "full";
@ -491,21 +493,81 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget {
}); });
} }
async createNoteForReferenceLink(title: string) { async createNoteFromCkEditor (
if (!this.notePath) { title: string,
return; parentNotePath: string | undefined,
action: MentionAction
): Promise<string> {
try {
switch (action) {
// --- Create note INTO inbox ---
case MentionAction.CreateNoteIntoInbox: {
const { success, noteType, templateNoteId } = await note_create.chooseNoteType();
if (!success) return "";
const { note } = await note_create.createNoteIntoInbox({
title,
activate: true,
type: noteType,
templateNoteId,
});
return note?.getBestNotePathString() ?? "";
}
// --- Create note INTO current path ---
case MentionAction.CreateNoteIntoPath: {
const { success, noteType, templateNoteId, notePath } = await note_create.chooseNoteType();
if (!success) return "";
const { note } = await note_create.createNoteIntoPath(notePath || parentNotePath, {
title,
activate: true,
type: noteType,
templateNoteId,
});
return note?.getBestNotePathString() ?? "";
}
// --- Create & link note INTO inbox ---
case MentionAction.CreateAndLinkNoteIntoInbox: {
const { success, noteType, templateNoteId } = await note_create.chooseNoteType();
if (!success) return "";
const { note } = await note_create.createNoteIntoInbox({
title,
activate: false,
type: noteType,
templateNoteId,
});
return note?.getBestNotePathString() ?? "";
}
// --- Create & link note INTO current path ---
case MentionAction.CreateAndLinkNoteIntoPath: {
const { success, noteType, templateNoteId, notePath } = await note_create.chooseNoteType();
if (!success) return "";
const { note } = await note_create.createNoteIntoPath(notePath || parentNotePath, {
title,
activate: false,
type: noteType,
templateNoteId,
});
return note?.getBestNotePathString() ?? "";
}
default:
console.warn("Unknown MentionAction:", action);
return "";
}
} catch (err) {
console.error("Error while creating note from CKEditor:", err);
return "";
} }
const resp = await noteCreateService.createNoteWithTypePrompt(this.notePath, {
activate: false,
title: title
});
if (!resp || !resp.note) {
return;
}
return resp.note.getBestNotePathString();
} }
async refreshIncludedNoteEvent({ noteId }: EventData<"refreshIncludedNote">) { async refreshIncludedNoteEvent({ noteId }: EventData<"refreshIncludedNote">) {

View file

@ -1,4 +1,4 @@
import noteAutocompleteService from "../../services/note_autocomplete.js"; import noteAutocompleteService, { CreateMode } from "../../services/note_autocomplete.js";
import TypeWidget from "./type_widget.js"; import TypeWidget from "./type_widget.js";
import appContext from "../../components/app_context.js"; import appContext from "../../components/app_context.js";
import searchService from "../../services/search.js"; import searchService from "../../services/search.js";
@ -77,7 +77,7 @@ export default class EmptyTypeWidget extends TypeWidget {
noteAutocompleteService noteAutocompleteService
.initNoteAutocomplete(this.$autoComplete, { .initNoteAutocomplete(this.$autoComplete, {
hideGoToSelectedNoteButton: true, hideGoToSelectedNoteButton: true,
allowCreatingNotes: true, createMode: CreateMode.CreateOnly,
allowJumpToSearchNotes: true, allowJumpToSearchNotes: true,
container: this.$results[0] container: this.$results[0]
}) })

View file

@ -1,4 +1,12 @@
import "ckeditor5"; import "ckeditor5";
import { CKTextEditor } from "src";
export enum MentionAction {
CreateNoteIntoInbox = "create-note-into-inbox",
CreateNoteIntoPath = "create-note-into-path",
CreateAndLinkNoteIntoInbox = "create-and-link-note-into-inbox",
CreateAndLinkNoteIntoPath = "create-and-link-note-into-path"
}
declare global { declare global {
interface Component { interface Component {
@ -7,7 +15,8 @@ declare global {
interface EditorComponent extends Component { interface EditorComponent extends Component {
loadReferenceLinkTitle($el: JQuery<HTMLElement>, href: string): Promise<void>; loadReferenceLinkTitle($el: JQuery<HTMLElement>, href: string): Promise<void>;
createNoteForReferenceLink(title: string): Promise<string>; // Must Return Note Path
createNoteFromCkEditor(title: string, parentNotePath: string | undefined, action: MentionAction): Promise<string>;
loadIncludedNote(noteId: string, $el: JQuery<HTMLElement>): void; loadIncludedNote(noteId: string, $el: JQuery<HTMLElement>): void;
} }

View file

@ -1,4 +1,5 @@
import { Command, Mention, Plugin, ModelRange, type ModelSelectable } from "ckeditor5"; import { Command, Mention, Plugin, ModelRange, type ModelSelectable } from "ckeditor5";
import { MentionAction } from "../augmentation";
/** /**
* Overrides the actions taken by the Mentions plugin (triggered by `@` in the text editor, or `~` & `#` in the attribute editor): * Overrides the actions taken by the Mentions plugin (triggered by `@` in the text editor, or `~` & `#` in the attribute editor):
@ -9,11 +10,11 @@ import { Command, Mention, Plugin, ModelRange, type ModelSelectable } from "cked
*/ */
export default class MentionCustomization extends Plugin { export default class MentionCustomization extends Plugin {
static get requires() { static get requires() {
return [ Mention ]; return [ Mention ];
} }
public static get pluginName() { public static get pluginName() {
return "MentionCustomization" as const; return "MentionCustomization" as const;
} }
@ -25,20 +26,21 @@ export default class MentionCustomization extends Plugin {
} }
interface MentionOpts { interface MentionOpts {
mention: string | { mention: string | {
id: string; id: string;
[key: string]: unknown; [key: string]: unknown;
}; };
marker: string; marker: string;
text?: string; text?: string;
range?: ModelRange; range?: ModelRange;
} }
interface MentionAttribute { interface MentionAttribute {
id: string; id: string;
action?: "create-note"; action?: MentionAction;
noteTitle: string; noteTitle: string;
notePath: string; notePath: string;
parentNoteId?: string;
} }
class CustomMentionCommand extends Command { class CustomMentionCommand extends Command {
@ -56,14 +58,27 @@ class CustomMentionCommand extends Command {
model.insertContent( writer.createText( mention.id, {} ), range ); model.insertContent( writer.createText( mention.id, {} ), range );
}); });
} }
else if (mention.action === 'create-note') { else if (
const editorEl = this.editor.editing.view.getDomRoot(); mention.action === MentionAction.CreateNoteIntoInbox ||
const component = glob.getComponentByEl<EditorComponent>(editorEl); mention.action === MentionAction.CreateNoteIntoPath ||
mention.action === MentionAction.CreateAndLinkNoteIntoInbox ||
mention.action === MentionAction.CreateAndLinkNoteIntoPath
) {
const editorEl = this.editor.editing.view.getDomRoot();
const component = glob.getComponentByEl<EditorComponent>(editorEl);
component.createNoteForReferenceLink(mention.noteTitle).then(notePath => { // use parentNoteId as fallback when notePath is missing
this.insertReference(range, notePath); const parentNotePath = mention.notePath || mention.parentNoteId;
});
} component
.createNoteFromCkEditor(mention.noteTitle, parentNotePath, mention.action)
.then(notePath => {
if (notePath) {
this.insertReference(range, notePath);
}
})
.catch(err => console.error("Error creating note from CKEditor mention:", err));
}
else { else {
this.insertReference(range, mention.notePath); this.insertReference(range, mention.notePath);
} }