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 { t } from "../services/i18n.js";
import { CreateChildrenResponse, SqlExecuteResponse } from "@triliumnext/commons";
import noteCreateService from "../services/note_create";
export default class Entrypoints extends Component {
constructor() {
@ -24,23 +25,7 @@ export default class Entrypoints extends Component {
}
async createNoteIntoInboxCommand() {
const inboxNote = await dateNoteService.getInboxNote();
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 });
await noteCreateService.createNoteIntoInbox();
}
async toggleNoteHoistingCommand({ noteId = appContext.tabManager.getActiveContextNoteId() }) {

View file

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

View file

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

View file

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

View file

@ -5,6 +5,23 @@ import froca from "./froca.js";
import { t } from "./i18n.js";
import commandRegistry from "./command_registry.js";
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
const SELECTED_NOTE_PATH_KEY = "data-note-path";
@ -23,14 +40,39 @@ function getSearchDelay(notesCount: number): number {
}
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 {
noteTitle?: string;
externalLink?: string;
notePathTitle?: string;
notePath?: string;
highlightedNotePathTitle?: string;
action?: string | "create-note" | "search-notes" | "external-link" | "command";
action?: SuggestionAction;
parentNoteId?: string;
icon?: string;
commandId?: string;
@ -43,7 +85,7 @@ export interface Suggestion {
export interface Options {
container?: HTMLElement | null;
fastSearch?: boolean;
allowCreatingNotes?: boolean;
createMode?: CreateMode;
allowJumpToSearchNotes?: boolean;
allowExternalLinks?: boolean;
/** If set, hides the right-side button corresponding to go to selected note. */
@ -54,110 +96,160 @@ export interface Options {
isCommandPalette?: boolean;
}
async function autocompleteSourceForCKEditor(queryText: string) {
return await new Promise<MentionFeedObjectItem[]>((res, rej) => {
async function autocompleteSourceForCKEditor(
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(
queryText,
(rows) => {
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
};
})
);
},
(suggestions) => resolve(suggestions),
{
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
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
? commandRegistry.getAllCommands()
: commandRegistry.searchCommands(commandQuery);
const commands =
commandQuery.length === 0
? commandRegistry.getAllCommands()
: commandRegistry.searchCommands(commandQuery);
// Convert commands to suggestions
const commandSuggestions: Suggestion[] = commands.map(cmd => ({
action: "command",
const commandSuggestions: Suggestion[] = commands.map((cmd) => ({
action: SuggestionAction.Command,
commandId: cmd.id,
noteTitle: cmd.name,
notePathTitle: `>${cmd.name}`,
highlightedNotePathTitle: cmd.name,
commandDescription: cmd.description,
commandShortcut: cmd.shortcut,
icon: cmd.icon
icon: cmd.icon,
}));
cb(commandSuggestions);
callback(commandSuggestions);
return;
}
const fastSearch = options.fastSearch === false ? false : true;
if (fastSearch === false) {
if (term.trim().length === 0) {
return;
}
cb([
const fastSearch = options.fastSearch !== false;
const trimmedTerm = term.trim();
const activeNoteId = appContext.tabManager.getActiveContextNoteId();
if (!fastSearch && trimmedTerm.length === 0) return;
if (!fastSearch) {
callback([
{
noteTitle: term,
highlightedNotePathTitle: t("quick-search.searching")
}
noteTitle: trimmedTerm,
highlightedNotePathTitle: t("quick-search.searching"),
},
]);
}
const activeNoteId = appContext.tabManager.getActiveContextNoteId();
const length = term.trim().length;
let results = await server.get<Suggestion[]>(`autocomplete?query=${encodeURIComponent(term)}&activeNoteId=${activeNoteId}&fastSearch=${fastSearch}`);
let results = await server.get<Suggestion[]>(
`autocomplete?query=${encodeURIComponent(trimmedTerm)}&activeNoteId=${activeNoteId}&fastSearch=${fastSearch}`
);
options.fastSearch = true;
if (length >= 1 && options.allowCreatingNotes) {
results = [
{
action: "create-note",
noteTitle: term,
parentNoteId: activeNoteId || "root",
highlightedNotePathTitle: t("note_autocomplete.create-note", { term })
} as Suggestion
].concat(results);
}
if (length >= 1 && options.allowJumpToSearchNotes) {
results = results.concat([
{
action: "search-notes",
noteTitle: term,
highlightedNotePathTitle: `${t("note_autocomplete.search-for", { term })} <kbd style='color: var(--muted-text-color); background-color: transparent; float: right;'>Ctrl+Enter</kbd>`
// --- Create Note suggestions ---
if (trimmedTerm.length >= 1) {
switch (options.createMode) {
case CreateMode.CreateOnly: {
results = [
{
action: SuggestionAction.CreateNoteIntoInbox,
noteTitle: trimmedTerm,
parentNoteId: "inbox",
highlightedNotePathTitle: t("note_autocomplete.create-note-into-inbox", { term: trimmedTerm }),
},
{
action: SuggestionAction.CreateNoteIntoPath,
noteTitle: trimmedTerm,
parentNoteId: activeNoteId || "root",
highlightedNotePathTitle: t("note_autocomplete.create-note-into-path", { term: trimmedTerm }),
},
...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 = [
{
action: "external-link",
externalLink: term,
highlightedNotePathTitle: t("note_autocomplete.insert-external-link", { term })
} as Suggestion
].concat(results);
action: SuggestionAction.ExternalLink,
externalLink: trimmedTerm,
highlightedNotePathTitle: t("note_autocomplete.insert-external-link", { term: trimmedTerm }),
},
...results,
];
}
cb(results);
callback(results);
}
function clearText($el: JQuery<HTMLElement>) {
@ -198,6 +290,64 @@ function fullTextSearch($el: JQuery<HTMLElement>, options: Options) {
$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) {
if ($el.hasClass("note-autocomplete-input")) {
// clear any event listener added in previous invocation of this function
@ -283,24 +433,21 @@ function initNoteAutocomplete($el: JQuery<HTMLElement>, options?: Options) {
$el.autocomplete(
{
...autocompleteOptions,
appendTo: document.querySelector("body"),
appendTo: document.body,
hint: false,
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,
minLength: 0,
tabAutocomplete: false
tabAutocomplete: false,
},
[
{
source: (term, cb) => {
source: (term, callback) => {
clearTimeout(debounceTimeoutId);
debounceTimeoutId = setTimeout(() => {
if (isComposingInput) {
return;
if (!isComposingInput) {
autocompleteSource(term, callback, options);
}
autocompleteSource(term, cb, options);
}, searchDelay);
if (searchDelay === 0) {
@ -308,109 +455,124 @@ function initNoteAutocomplete($el: JQuery<HTMLElement>, options?: Options) {
}
},
displayKey: "notePathTitle",
templates: {
suggestion: (suggestion) => {
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
}
templates: { suggestion: renderSuggestion },
cache: false,
},
]
);
// TODO: Types fail due to "autocomplete:selected" not being registered in type definitions.
($el as any).on("autocomplete:selected", async (event: Event, suggestion: Suggestion) => {
if (suggestion.action === "command") {
$el.autocomplete("close");
$el.trigger("autocomplete:commandselected", [suggestion]);
return;
}
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;
switch (suggestion.action) {
case SuggestionAction.Command: {
$el.autocomplete("close");
$el.trigger("autocomplete:commandselected", [suggestion]);
break;
}
const { note } = await noteCreateService.createNote( notePath || suggestion.parentNoteId, {
title: suggestion.noteTitle,
activate: false,
type: noteType,
templateNoteId: templateNoteId
});
const hoistedNoteId = appContext.tabManager.getActiveContext()?.hoistedNoteId;
suggestion.notePath = note?.getBestNotePathString(hoistedNoteId);
case SuggestionAction.ExternalLink: {
$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", () => {

View file

@ -10,6 +10,8 @@ import type FNote from "../entities/fnote.js";
import type FBranch from "../entities/fbranch.js";
import type { ChooseNoteTypeResponse } from "../widgets/dialogs/note_type_chooser.js";
import type { CKTextEditor } from "@triliumnext/ckeditor5";
import dateNoteService from "../services/date_notes.js";
import { CreateChildrenResponse } from "@triliumnext/commons";
export interface CreateNoteOpts {
isProtected?: boolean;
@ -37,7 +39,47 @@ interface DuplicateResponse {
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(
{
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();
if (!success) {
@ -123,7 +165,7 @@ async function createNoteWithTypePrompt(parentNotePath: string, options: CreateN
options.type = noteType;
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. */
@ -158,8 +200,9 @@ async function duplicateSubtree(noteId: string, parentNotePath: string) {
}
export default {
createNote,
createNoteWithTypePrompt,
createNoteIntoInbox,
createNoteIntoPath,
createNoteIntoPathWithTypePrompt,
duplicateSubtree,
chooseNoteType
};

View file

@ -1883,7 +1883,10 @@
},
"note_autocomplete": {
"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}}\"",
"clear-text-field": "Clear text field",
"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 linkService from "../../services/link.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 NoteContextAwareWidget from "../note_context_aware_widget.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.$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) {
return false;
}

View file

@ -28,7 +28,7 @@ export default class BoardApi {
const parentNotePath = this.parentNote.noteId;
// 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,
title
});
@ -130,7 +130,7 @@ export default class BoardApi {
column: string,
relativeToBranchId: string,
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,
targetBranchId: relativeToBranchId,
target: direction,

View file

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

View file

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

View file

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

View file

@ -5,7 +5,7 @@ import FormRadioGroup from "../react/FormRadioGroup";
import Modal from "../react/Modal";
import NoteAutocomplete from "../react/NoteAutocomplete";
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 froca from "../../services/froca";
import EditableTextTypeWidget, { type BoxSize } from "../type_widgets/editable_text";
@ -49,7 +49,7 @@ export default function IncludeNoteDialog() {
inputRef={autoCompleteRef}
opts={{
hideGoToSelectedNoteButton: true,
allowCreatingNotes: true
createMode: CreateMode.CreateOnly,
}}
/>
</FormGroup>
@ -83,4 +83,4 @@ async function includeNote(notePath: string, textTypeWidget: EditableTextTypeWid
} else {
textTypeWidget.addIncludeNote(noteId, boxSize);
}
}
}

View file

@ -3,7 +3,7 @@ import Button from "../react/Button";
import NoteAutocomplete from "../react/NoteAutocomplete";
import { t } from "../../services/i18n";
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 commandRegistry from "../../services/command_registry";
import { refToJQuerySelector } from "../react/react_utils";
@ -12,34 +12,57 @@ import shortcutService from "../../services/shortcuts";
const KEEP_LAST_SEARCH_FOR_X_SECONDS = 120;
type Mode = "last-search" | "recent-notes" | "commands";
enum Mode {
LastSearch,
RecentNotes,
Commands,
}
export default function JumpToNoteDialogComponent() {
const [ mode, setMode ] = useState<Mode>();
const [ lastOpenedTs, setLastOpenedTs ] = useState<number>(0);
const containerRef = useRef<HTMLDivElement>(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 actualText = useRef<string>(initialText);
const [ shown, setShown ] = useState(false);
async function openDialog(commandMode: boolean) {
async function openDialog(requestedMode: Mode) {
let newMode: Mode;
let initialText = "";
if (commandMode) {
newMode = "commands";
initialText = ">";
} else if (Date.now() - lastOpenedTs <= KEEP_LAST_SEARCH_FOR_X_SECONDS * 1000 && actualText.current) {
// 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 = "last-search";
initialText = actualText.current;
} else {
newMode = "recent-notes";
switch (requestedMode) {
case Mode.Commands:
newMode = Mode.Commands;
initialText = ">";
break;
case Mode.LastSearch:
// 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() - 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) {
@ -51,14 +74,14 @@ export default function JumpToNoteDialogComponent() {
setLastOpenedTs(Date.now());
}
useTriliumEvent("jumpToNote", () => openDialog(false));
useTriliumEvent("commandPalette", () => openDialog(true));
useTriliumEvent("jumpToNote", () => openDialog(Mode.RecentNotes));
useTriliumEvent("commandPalette", () => openDialog(Mode.Commands));
async function onItemSelected(suggestion?: Suggestion | null) {
if (!suggestion) {
return;
}
setShown(false);
if (suggestion.notePath) {
appContext.tabManager.getActiveContext()?.setNote(suggestion.notePath);
@ -83,7 +106,7 @@ export default function JumpToNoteDialogComponent() {
$autoComplete
.trigger("focus")
.trigger("select");
// Add keyboard shortcut for full search
shortcutService.bindElShortcut($autoComplete, "ctrl+return", () => {
if (!isCommandMode) {
@ -91,7 +114,7 @@ export default function JumpToNoteDialogComponent() {
}
});
}
async function showInFullSearch() {
try {
setShown(false);
@ -116,7 +139,7 @@ export default function JumpToNoteDialogComponent() {
container={containerRef}
text={initialText}
opts={{
allowCreatingNotes: true,
createMode: CreateMode.CreateOnly,
hideGoToSelectedNoteButton: true,
allowJumpToSearchNotes: true,
isCommandPalette: true
@ -129,9 +152,9 @@ export default function JumpToNoteDialogComponent() {
/>}
onShown={onShown}
onHidden={() => setShown(false)}
footer={!isCommandMode && <Button
className="show-in-full-text-button"
text={t("jump_to_note.search_button")}
footer={!isCommandMode && <Button
className="show-in-full-text-button"
text={t("jump_to_note.search_button")}
keyboardShortcut="Ctrl+Enter"
onClick={showInFullSearch}
/>}

View file

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

View file

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

View file

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

View file

@ -429,7 +429,7 @@ export default class NoteDetailWidget extends NoteContextAwareWidget {
// without await as this otherwise causes deadlock through component mutex
const parentNotePath = appContext.tabManager.getActiveContextNotePath();
if (this.noteContext && parentNotePath) {
noteCreateService.createNote(parentNotePath, {
noteCreateService.createNoteIntoPath(parentNotePath, {
isProtected: note.isProtected,
saveSelection: true,
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")) {
const node = $.ui.fancytree.getNode(e as unknown as Event);
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")) {
const node = $.ui.fancytree.getNode(e as unknown as Event);
this.triggerCommand("hoistNote", { noteId: node.data.noteId });
@ -1843,7 +1843,7 @@ export default class NoteTreeWidget extends NoteContextAwareWidget {
const node = this.getActiveNode();
if (!node) return;
const notePath = treeService.getNotePath(node);
noteCreateService.createNote(notePath, {
noteCreateService.createNoteIntoPath(notePath, {
isProtected: node.data.isProtected
});
}

View file

@ -2,7 +2,7 @@ import { t } from "../services/i18n.js";
import server from "../services/server.js";
import ws from "../services/ws.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 attributeService from "../services/attributes.js";
import options from "../services/options.js";
@ -341,7 +341,7 @@ export default class PromotedAttributesWidget extends NoteContextAwareWidget {
if (utils.isDesktop()) {
// no need to wait for this
noteAutocompleteService.initNoteAutocomplete($input, { allowCreatingNotes: true });
noteAutocompleteService.initNoteAutocomplete($input, { createMode: CreateMode.CreateOnly});
$input.on("autocomplete:noteselected", (event, suggestion, dataset) => {
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 { t } from "../../../services/i18n";
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 { useLegacyImperativeHandlers, useLegacyWidget, useTooltip, useTriliumEvent, useTriliumOption } from "../../react/hooks";
import FAttribute from "../../../entities/fattribute";
@ -20,6 +20,7 @@ import type { CommandData, FilteredCommandNames } from "../../../components/app_
import { AttributeType } from "@triliumnext/commons";
import attributes from "../../../services/attributes";
import note_create from "../../../services/note_create";
import { MentionAction } from "@triliumnext/ckeditor5/src/augmentation.js";
type AttributeCommandNames = FilteredCommandNames<CommandData>;
@ -33,7 +34,7 @@ const HELP_TEXT = `
const mentionSetup: MentionFeed[] = [
{
marker: "@",
feed: (queryText) => note_autocomplete.autocompleteSourceForCKEditor(queryText),
feed: (queryText) => note_autocomplete.autocompleteSourceForCKEditor(queryText, CreateMode.CreateAndLink),
itemRenderer: (_item) => {
const item = _item as Suggestion;
const itemElement = document.createElement("button");
@ -252,17 +253,40 @@ export default function AttributeEditor({ api, note, componentId, notePath, ntxI
$el.text(title);
},
createNoteForReferenceLink: async (title: string) => {
let result;
if (notePath) {
result = await note_create.createNoteWithTypePrompt(notePath, {
activate: false,
title: title
});
createNoteFromCkEditor: async (
title: string,
parentNotePath: string | undefined,
action: MentionAction
): Promise<string> => {
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 ]));
// Keyboard shortcuts

View file

@ -8,7 +8,7 @@ import { copyTextWithToast } from "../../../services/clipboard_ext.js";
import getTemplates from "./snippets.js";
import { t } from "../../../services/i18n.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 { normalizeMimeTypeForCKEditor } from "@triliumnext/commons";
import { buildToolbarConfig } from "./toolbar.js";
@ -181,7 +181,7 @@ export async function buildConfig(opts: BuildEditorOptions): Promise<EditorConfi
feeds: [
{
marker: "@",
feed: (queryText: string) => noteAutocompleteService.autocompleteSourceForCKEditor(queryText),
feed: (queryText: string) => noteAutocompleteService.autocompleteSourceForCKEditor(queryText, CreateMode.CreateAndLink),
itemRenderer: (item) => {
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 { PopupEditor, ClassicEditor, EditorWatchdog, type CKTextEditor, type MentionFeed, type WatchdogConfig, EditorConfig } from "@triliumnext/ckeditor5";
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";
@ -491,21 +493,81 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget {
});
}
async createNoteForReferenceLink(title: string) {
if (!this.notePath) {
return;
async createNoteFromCkEditor (
title: string,
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">) {

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

View file

@ -1,4 +1,12 @@
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 {
interface Component {
@ -7,7 +15,8 @@ declare global {
interface EditorComponent extends Component {
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;
}

View file

@ -1,4 +1,5 @@
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):
@ -9,11 +10,11 @@ import { Command, Mention, Plugin, ModelRange, type ModelSelectable } from "cked
*/
export default class MentionCustomization extends Plugin {
static get requires() {
static get requires() {
return [ Mention ];
}
public static get pluginName() {
public static get pluginName() {
return "MentionCustomization" as const;
}
@ -25,20 +26,21 @@ export default class MentionCustomization extends Plugin {
}
interface MentionOpts {
mention: string | {
id: string;
[key: string]: unknown;
};
marker: string;
text?: string;
range?: ModelRange;
mention: string | {
id: string;
[key: string]: unknown;
};
marker: string;
text?: string;
range?: ModelRange;
}
interface MentionAttribute {
id: string;
action?: "create-note";
noteTitle: string;
notePath: string;
id: string;
action?: MentionAction;
noteTitle: string;
notePath: string;
parentNoteId?: string;
}
class CustomMentionCommand extends Command {
@ -56,14 +58,27 @@ class CustomMentionCommand extends Command {
model.insertContent( writer.createText( mention.id, {} ), range );
});
}
else if (mention.action === 'create-note') {
const editorEl = this.editor.editing.view.getDomRoot();
const component = glob.getComponentByEl<EditorComponent>(editorEl);
else if (
mention.action === MentionAction.CreateNoteIntoInbox ||
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 => {
this.insertReference(range, notePath);
});
}
// use parentNoteId as fallback when notePath is missing
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 {
this.insertReference(range, mention.notePath);
}