Merge 8ee59e9daa9629c7e568847b811c8e22aa3bdfad into 64662d5215ea01f6634fd72b5ff7a77dbc7e9e7d

This commit is contained in:
Jakob Schlanstedt 2025-12-01 15:33:54 +01:00 committed by GitHub
commit f57c89cd5b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
35 changed files with 854 additions and 366 deletions

View File

@ -25,7 +25,7 @@ import TouchBarComponent from "./touch_bar.js";
import type { CKTextEditor } from "@triliumnext/ckeditor5";
import type CodeMirror from "@triliumnext/codemirror";
import { StartupChecks } from "./startup_checks.js";
import type { CreateNoteOpts } from "../services/note_create.js";
import type { CreateNoteOpts, CreateNoteWithLinkOpts } from "../services/note_create.js";
import { ColumnComponent } from "tabulator-tables";
import { ChooseNoteTypeCallback } from "../widgets/dialogs/note_type_chooser.jsx";
import type RootContainer from "../widgets/containers/root_container.js";
@ -359,8 +359,7 @@ export type CommandMappings = {
// Table view
addNewRow: CommandData & {
customOpts: CreateNoteOpts;
parentNotePath?: string;
customOpts?: CreateNoteWithLinkOpts;
};
addNewTableColumn: CommandData & {
columnToEdit?: ColumnComponent;

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.js";
export default class Entrypoints extends Component {
constructor() {
@ -24,23 +25,9 @@ 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.createNote(
{ target: "default" }
);
}
async toggleNoteHoistingCommand({ noteId = appContext.tabManager.getActiveContextNoteId() }) {

View File

@ -48,10 +48,15 @@ export default class MainTreeExecutors extends Component {
return;
}
await noteCreateService.createNote(activeNoteContext.notePath, {
isProtected: activeNoteContext.note.isProtected,
saveSelection: false
});
await noteCreateService.createNote(
{
target: "into",
parentNoteLink: activeNoteContext.notePath,
isProtected: activeNoteContext.note.isProtected,
saveSelection: false,
promptForType: false,
}
);
}
async createNoteAfterCommand() {
@ -72,11 +77,14 @@ export default class MainTreeExecutors extends Component {
return;
}
await noteCreateService.createNote(parentNotePath, {
target: "after",
targetBranchId: node.data.branchId,
isProtected: isProtected,
saveSelection: false
});
await noteCreateService.createNote(
{
target: "after",
parentNoteLink: parentNotePath,
targetBranchId: node.data.branchId,
isProtected: isProtected,
saveSelection: false
}
);
}
}

View File

@ -45,7 +45,7 @@ export default class RootCommandExecutor extends Component {
}
async searchInSubtreeCommand({ notePath }: CommandListenerData<"searchInSubtree">) {
const noteId = treeService.getNoteIdFromUrl(notePath);
const noteId = treeService.getNoteIdFromLink(notePath);
this.searchNotesCommand({ ancestorNoteId: noteId });
}
@ -240,14 +240,18 @@ 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, {
title: "New AI Chat",
type: "aiChat",
content: JSON.stringify({
messages: [],
title: "New AI Chat"
})
});
const result = await noteCreateService.createNote(
{
parentNoteLink: rootNoteId,
target: "into",
title: "New AI Chat",
type: "aiChat",
content: JSON.stringify({
messages: [],
title: "New AI Chat"
}),
}
);
if (!result.note) {
toastService.showError("Failed to create AI Chat note");

View File

@ -74,10 +74,10 @@ export default class TabManager extends Component {
// preload all notes at once
await froca.getNotes([...noteContextsToOpen.flatMap((tab: NoteContextState) =>
[treeService.getNoteIdFromUrl(tab.notePath), tab.hoistedNoteId])], true);
[treeService.getNoteIdFromLink(tab.notePath), tab.hoistedNoteId])], true);
const filteredNoteContexts = noteContextsToOpen.filter((openTab: NoteContextState) => {
const noteId = treeService.getNoteIdFromUrl(openTab.notePath);
const noteId = treeService.getNoteIdFromLink(openTab.notePath);
if (!noteId || !(noteId in froca.notes)) {
// note doesn't exist so don't try to open tab for it
return false;

View File

@ -283,21 +283,31 @@ export default class TreeContextMenu implements SelectMenuItemEventListener<Tree
const parentNotePath = treeService.getNotePath(this.node.getParent());
const isProtected = treeService.getParentProtectedStatus(this.node);
noteCreateService.createNote(parentNotePath, {
target: "after",
targetBranchId: this.node.data.branchId,
type: type,
isProtected: isProtected,
templateNoteId: templateNoteId
});
noteCreateService.createNote(
{
target: "after",
parentNoteLink: parentNotePath,
targetBranchId: this.node.data.branchId,
type: type,
isProtected: isProtected,
templateNoteId: templateNoteId,
promptForType: false,
}
);
} else if (command === "insertChildNote") {
const parentNotePath = treeService.getNotePath(this.node);
noteCreateService.createNote(parentNotePath, {
type: type,
isProtected: this.node.data.isProtected,
templateNoteId: templateNoteId
});
noteCreateService.createNote(
{
target: "into",
parentNoteLink: parentNotePath,
type: type,
isProtected: this.node.data.isProtected,
templateNoteId: templateNoteId,
promptForType: false,
}
);
} else if (command === "openNoteInSplit") {
const subContexts = appContext.tabManager.getActiveContext()?.getSubContexts();
const { ntxId } = subContexts?.[subContexts.length - 1] ?? {};

View File

@ -50,7 +50,7 @@ async function checkNoteAccess(notePath: string, noteContext: NoteContext) {
const hoistedNoteId = noteContext.hoistedNoteId;
if (!resolvedNotePath.includes(hoistedNoteId) && (!resolvedNotePath.includes("_hidden") || resolvedNotePath.includes("_lbBookmarks"))) {
const noteId = treeService.getNoteIdFromUrl(resolvedNotePath);
const noteId = treeService.getNoteIdFromLink(resolvedNotePath);
if (!noteId) {
return false;
}

View File

@ -261,7 +261,7 @@ export function parseNavigationStateFromUrl(url: string | undefined) {
return {
notePath,
noteId: treeService.getNoteIdFromUrl(notePath),
noteId: treeService.getNoteIdFromLink(notePath),
ntxId,
hoistedNoteId,
viewScope,

View File

@ -5,6 +5,24 @@ import froca from "./froca.js";
import { t } from "./i18n.js";
import commandRegistry from "./command_registry.js";
import type { MentionFeedObjectItem } from "@triliumnext/ckeditor5";
import { CreateNoteAction } from "@triliumnext/commons"
import FNote from "../entities/fnote.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 +41,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 CreateNoteAction 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
CreateNote = CreateNoteAction.CreateNote,
CreateChildNote = CreateNoteAction.CreateChildNote,
CreateAndLinkNote = CreateNoteAction.CreateAndLinkNote,
CreateAndLinkChildNote = CreateNoteAction.CreateAndLinkChildNote,
SearchNotes = "search-notes",
ExternalLink = "external-link",
Command = "command",
}
export enum SuggestionMode {
SuggestNothing = "nothing",
SuggestCreateOnly = "create-only",
SuggestCreateAndLink = "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 +86,7 @@ export interface Suggestion {
export interface Options {
container?: HTMLElement | null;
fastSearch?: boolean;
allowCreatingNotes?: boolean;
suggestionMode?: SuggestionMode;
allowJumpToSearchNotes?: boolean;
allowExternalLinks?: boolean;
/** If set, hides the right-side button corresponding to go to selected note. */
@ -54,110 +97,160 @@ export interface Options {
isCommandPalette?: boolean;
}
async function autocompleteSourceForCKEditor(queryText: string) {
return await new Promise<MentionFeedObjectItem[]>((res, rej) => {
async function autocompleteSourceForCKEditor(
queryText: string,
suggestionMode: SuggestionMode
): 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
suggestionMode,
}
);
});
// 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.suggestionMode) {
case SuggestionMode.SuggestCreateOnly: {
results = [
{
action: SuggestionAction.CreateNote,
noteTitle: trimmedTerm,
parentNoteId: "inbox",
highlightedNotePathTitle: t("note_autocomplete.create-note", { term: trimmedTerm }),
},
{
action: SuggestionAction.CreateChildNote,
noteTitle: trimmedTerm,
parentNoteId: activeNoteId || "root",
highlightedNotePathTitle: t("note_autocomplete.create-child-note", { term: trimmedTerm }),
},
...results,
];
break;
}
]);
case SuggestionMode.SuggestCreateAndLink: {
results = [
{
action: SuggestionAction.CreateAndLinkNote,
noteTitle: trimmedTerm,
parentNoteId: "inbox",
highlightedNotePathTitle: t("note_autocomplete.create-and-link-note", { term: trimmedTerm }),
},
{
action: SuggestionAction.CreateAndLinkChildNote,
noteTitle: trimmedTerm,
parentNoteId: activeNoteId || "root",
highlightedNotePathTitle: t("note_autocomplete.create-and-link-child-note", { 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 +291,85 @@ 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.CreateAndLinkNote:
case SuggestionAction.CreateNote:
return "bx bx-plus";
case SuggestionAction.CreateAndLinkChildNote:
case SuggestionAction.CreateChildNote:
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 mapSuggestionToCreateNoteAction(
action: SuggestionAction
): CreateNoteAction | null {
switch (action) {
case SuggestionAction.CreateNote:
return CreateNoteAction.CreateNote;
case SuggestionAction.CreateAndLinkNote:
return CreateNoteAction.CreateAndLinkNote;
case SuggestionAction.CreateChildNote:
return CreateNoteAction.CreateChildNote;
case SuggestionAction.CreateAndLinkChildNote:
return CreateNoteAction.CreateAndLinkChildNote;
default:
return null;
}
}
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 +455,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 +477,85 @@ 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") {
async function doCommand() {
$el.autocomplete("close");
$el.trigger("autocomplete:commandselected", [suggestion]);
return;
}
if (suggestion.action === "external-link") {
async function doExternalLink() {
$el.setSelectedNotePath(null);
$el.setSelectedExternalLink(suggestion.externalLink);
$el.autocomplete("val", suggestion.externalLink);
$el.autocomplete("close");
$el.trigger("autocomplete:externallinkselected", [suggestion]);
}
async function resolveSuggestionNotePathUnderCurrentHoist(note: FNote) {
const hoisted = appContext.tabManager.getActiveContext()?.hoistedNoteId;
suggestion.notePath = note.getBestNotePathString(hoisted);
}
async function doSearchNotes() {
const searchString = suggestion.noteTitle;
appContext.triggerCommand("searchNotes", { searchString });
}
async function selectNoteFromAutocomplete(suggestion: Suggestion) {
$el.setSelectedNotePath(suggestion.notePath);
$el.setSelectedExternalLink(null);
$el.autocomplete("val", suggestion.noteTitle);
$el.autocomplete("close");
$el.trigger("autocomplete:externallinkselected", [suggestion]);
return;
$el.trigger("autocomplete:noteselected", [suggestion]);
}
if (suggestion.action === "create-note") {
const { success, noteType, templateNoteId, notePath } = await noteCreateService.chooseNoteType();
if (!success) {
switch (suggestion.action) {
case SuggestionAction.Command:
await doCommand();
return;
case SuggestionAction.ExternalLink:
await doExternalLink();
break;
case SuggestionAction.CreateNote:
case SuggestionAction.CreateAndLinkNote:
case SuggestionAction.CreateChildNote:
case SuggestionAction.CreateAndLinkChildNote: {
const createNoteAction = mapSuggestionToCreateNoteAction(
suggestion.action
)!;
const { note } = await noteCreateService.createNoteFromAction(
createNoteAction,
true,
suggestion.noteTitle,
suggestion.parentNoteId,
);
if (!note) break;
await resolveSuggestionNotePathUnderCurrentHoist(note);
await selectNoteFromAutocomplete(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.SearchNotes:
await doSearchNotes();
break;
default:
await selectNoteFromAutocomplete(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,8 +10,63 @@ 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 { CreateNoteAction } from "@triliumnext/commons";
export interface CreateNoteOpts {
/**
* Defines the type hierarchy and rules for valid argument combinations
* accepted by `note_create`.
*
* ## Overview
* Each variant extends `CreateNoteOpts` and enforces specific constraints to
* ensure only valid note creation options are allowed at compile time.
*
* ## Type Safety
* The `PromptingRule` ensures that `promptForType` and `type` stay mutually
* exclusive (if prompting, `type` is undefined).
*
* The type system prevents invalid argument mixes by design successful type
* checks guarantee a valid state, following CurryHoward correspondence
* principles (types as proofs).
*
* ## Maintenance
* If adding or modifying `Opts`, ensure:
* - All valid combinations are represented (avoid *false negatives*).
* - No invalid ones slip through (avoid *false positives*).
*
* Hierarchy (general specific):
* - CreateNoteOpts
* - CreateNoteWithUrlOpts
* - CreateNoteIntoDefaultOpts
*/
/** enforces a truth rule:
* - If `promptForType` is true `type` must be undefined.
* - If `promptForType` is false `type` must be defined.
*/
type PromptingRule = {
promptForType: true;
type?: never;
} | {
promptForType?: false;
/**
* The note type (e.g. "text", "code", "image", "mermaid", etc.).
*
* If omitted, the server will automatically default to `"text"`.
* TypeScript still enforces explicit typing unless `promptForType` is true,
* to encourage clarity at the call site.
*/
type?: string;
};
/**
* Base type for all note creation options (domain hypernym).
* All specific note option types extend from this.
*
* Combine with `&` to ensure valid logical combinations.
*/
type CreateNoteBase = {
isProtected?: boolean;
saveSelection?: boolean;
title?: string | null;
@ -21,10 +76,34 @@ export interface CreateNoteOpts {
templateNoteId?: string;
activate?: boolean;
focus?: "title" | "content";
target?: string;
targetBranchId?: string;
textEditor?: CKTextEditor;
}
} & PromptingRule;
/*
* Defines options for creating a note at a specific path.
* Serves as a base for "into", "before", and "after" variants,
* sharing common URL-related fields.
*/
export type CreateNoteWithLinkOpts =
| (CreateNoteBase & {
target: "into";
parentNoteLink?: string;
// No branch ID needed for "into"
})
| (CreateNoteBase & {
target: "before" | "after";
// Either an Url or a Path
parentNoteLink?: string;
// Required for "before"/"after"
targetBranchId: string;
});
export type CreateNoteIntoDefaultOpts = CreateNoteBase & {
target: "default";
parentNoteLink?: never;
};
export type CreateNoteOpts = CreateNoteWithLinkOpts | CreateNoteIntoDefaultOpts;
interface Response {
// TODO: Deduplicate with server once we have client/server architecture.
@ -37,7 +116,141 @@ interface DuplicateResponse {
note: FNote;
}
async function createNote(parentNotePath: string | undefined, options: CreateNoteOpts = {}) {
// The low level note creation
async function createNote(
options: CreateNoteOpts
): Promise<{ note: FNote | null; branch: FBranch | undefined }> {
let resolvedOptions = { ...options };
// handle prompts centrally to write once fix for all
if (options.promptForType) {
const maybeResolvedOptions = await promptForType(options);
if (!maybeResolvedOptions) {
return { note: null, branch: undefined };
}
resolvedOptions = maybeResolvedOptions;
}
switch(resolvedOptions.target) {
case "default":
return createNoteIntoDefaultLocation(resolvedOptions);
case "into":
case "before":
case "after":
return createNoteWithLink(resolvedOptions);
}
}
// A wrapper to standardize note creation
async function createNoteFromAction(
action: CreateNoteAction,
promptForType: boolean,
title: string | undefined,
parentNoteLink: string | undefined,
): Promise<{ note: FNote | null; branch: FBranch | undefined }> {
switch (action) {
case CreateNoteAction.CreateNote: {
const resp = await createNote(
{
target: "default",
title: title,
activate: true,
promptForType,
}
);
return resp;
}
case CreateNoteAction.CreateAndLinkNote: {
const resp = await createNote(
{
target: "default",
title,
activate: false,
promptForType,
}
);
return resp;
}
case CreateNoteAction.CreateChildNote: {
if (!parentNoteLink) {
console.warn("createNoteFromAction: Missing parentNoteLink");
return { note: null, branch: undefined };
}
const resp = await createNote(
{
target: "into",
parentNoteLink,
title,
activate: true,
promptForType,
},
);
return resp
}
case CreateNoteAction.CreateAndLinkChildNote: {
if (!parentNoteLink) {
console.warn("createNoteFromAction: Missing parentNoteLink");
return { note: null, branch: undefined };
}
const resp = await createNote(
{
target: "into",
parentNoteLink: parentNoteLink,
title,
activate: false,
promptForType,
},
)
return resp;
}
default:
console.warn("Unknown CreateNoteAction:", action);
return { note: null, branch: undefined };
}
}
async function promptForType(
options: CreateNoteOpts
) : Promise<CreateNoteOpts | null> {
const { success, noteType, templateNoteId, notePath } = await chooseNoteType();
if (!success) {
return null;
}
let resolvedOptions: CreateNoteOpts = {
...options,
promptForType: false,
type: noteType,
templateNoteId,
};
if (notePath) {
resolvedOptions = {
...resolvedOptions,
target: "into",
parentNoteLink: notePath,
};
}
return resolvedOptions;
}
/**
* Creates a new note under a specified parent note path.
*
* @param target - Mirrors the `createNote` API in apps/server/src/routes/api/notes.ts.
* @param options - Note creation options
* @returns A promise resolving with the created note and its branch.
*/
async function createNoteWithLink(
options: CreateNoteWithLinkOpts
): Promise<{ note: FNote | null; branch: FBranch | undefined }> {
options = Object.assign(
{
activate: true,
@ -61,7 +274,8 @@ async function createNote(parentNotePath: string | undefined, options: CreateNot
[options.title, options.content] = parseSelectedHtml(options.textEditor.getSelectedHtml());
}
const parentNoteId = treeService.getNoteIdFromUrl(parentNotePath);
const parentNoteLink = options.parentNoteLink;
const parentNoteId = treeService.getNoteIdFromLink(parentNoteLink);
if (options.type === "mermaid" && !options.content && !options.templateNoteId) {
options.content = `graph TD;
@ -71,7 +285,12 @@ async function createNote(parentNotePath: string | undefined, options: CreateNot
C-->D;`;
}
const { note, branch } = await server.post<Response>(`notes/${parentNoteId}/children?target=${options.target}&targetBranchId=${options.targetBranchId || ""}`, {
const query =
options.target === "into"
? `target=${options.target}`
: `target=${options.target}&targetBranchId=${options.targetBranchId}`;
const { note, branch } = await server.post<Response>(`notes/${parentNoteId}/children?${query}`, {
title: options.title,
content: options.content || "",
isProtected: options.isProtected,
@ -89,7 +308,7 @@ async function createNote(parentNotePath: string | undefined, options: CreateNot
const activeNoteContext = appContext.tabManager.getActiveContext();
if (activeNoteContext && options.activate) {
await activeNoteContext.setNote(`${parentNotePath}/${note.noteId}`);
await activeNoteContext.setNote(`${parentNoteId}/${note.noteId}`);
if (options.focus === "title") {
appContext.triggerEvent("focusAndSelectTitle", { isNewNote: true });
@ -107,25 +326,46 @@ async function createNote(parentNotePath: string | undefined, options: CreateNot
};
}
/**
* Creates a new note inside the user's Inbox.
*
* @param {CreateNoteIntoDefaultOpts} [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 createNoteIntoDefaultLocation(
options: CreateNoteIntoDefaultOpts
): 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 createNoteWithLink(
{
...options,
target: "into",
parentNoteLink: inboxNote.getBestNotePathString(),
}
);
return result;
}
async function chooseNoteType() {
return new Promise<ChooseNoteTypeResponse>((res) => {
appContext.triggerCommand("chooseNoteType", { callback: res });
});
}
async function createNoteWithTypePrompt(parentNotePath: string, options: CreateNoteOpts = {}) {
const { success, noteType, templateNoteId, notePath } = await chooseNoteType();
if (!success) {
return;
}
options.type = noteType;
options.templateNoteId = templateNoteId;
return await createNote(notePath || parentNotePath, options);
}
/* If the first element is heading, parse it out and use it as a new heading. */
function parseSelectedHtml(selectedHtml: string) {
const dom = $.parseHTML(selectedHtml);
@ -146,7 +386,7 @@ function parseSelectedHtml(selectedHtml: string) {
}
async function duplicateSubtree(noteId: string, parentNotePath: string) {
const parentNoteId = treeService.getNoteIdFromUrl(parentNotePath);
const parentNoteId = treeService.getNoteIdFromLink(parentNotePath);
const { note } = await server.post<DuplicateResponse>(`notes/${noteId}/duplicate/${parentNoteId}`);
await ws.waitForMaxKnownEntityChangeId();
@ -159,7 +399,6 @@ async function duplicateSubtree(noteId: string, parentNotePath: string) {
export default {
createNote,
createNoteWithTypePrompt,
createNoteFromAction,
duplicateSubtree,
chooseNoteType
};

View File

@ -92,7 +92,7 @@ async function resolveNotePathToSegments(notePath: string, hoistedNoteId = "root
if (effectivePathSegments.includes(hoistedNoteId) && effectivePathSegments.includes('root')) {
return effectivePathSegments;
} else {
const noteId = getNoteIdFromUrl(notePath);
const noteId = getNoteIdFromLink(notePath);
if (!noteId) {
throw new Error(`Unable to find note with ID: ${noteId}.`);
}
@ -129,7 +129,7 @@ function getParentProtectedStatus(node: Fancytree.FancytreeNode) {
return hoistedNoteService.isHoistedNode(node) ? false : node.getParent().data.isProtected;
}
function getNoteIdFromUrl(urlOrNotePath: string | null | undefined) {
function getNoteIdFromLink(urlOrNotePath: string | null | undefined) {
if (!urlOrNotePath) {
return null;
}
@ -306,7 +306,7 @@ export default {
getParentProtectedStatus,
getNotePath,
getNotePathTitleComponents,
getNoteIdFromUrl,
getNoteIdFromLink,
getNoteIdAndParentIdFromUrl,
getBranchIdFromUrl,
getNoteTitle,

View File

@ -1897,7 +1897,10 @@
},
"note_autocomplete": {
"search-for": "Search for \"{{term}}\"",
"create-note": "Create and link child note \"{{term}}\"",
"create-child-note": "Create child note \"{{term}}\"",
"create-note": "Create note \"{{term}}\"",
"create-and-link-child-note": "Create and link child note \"{{term}}\"",
"create-and-link-note": "Create 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, { SuggestionMode } 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, { suggestionMode: SuggestionMode.SuggestCreateAndLink }).on("autocomplete:noteselected", (event, suggestion, dataset) => {
if (!suggestion.notePath) {
return false;
}

View File

@ -7,7 +7,7 @@ import branches from "../../../services/branches";
import { executeBulkActions } from "../../../services/bulk_action";
import froca from "../../../services/froca";
import { t } from "../../../services/i18n";
import note_create from "../../../services/note_create";
import note_create from "../../../services/note_create.js";
import server from "../../../services/server";
import { ColumnMap } from "./data";
@ -39,9 +39,11 @@ 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.createNote({
target: "into",
parentNoteLink: parentNotePath,
activate: false,
title
title,
});
if (newNote && newBranch) {
@ -139,13 +141,17 @@ export default class BoardApi {
async insertRowAtPosition(
column: string,
relativeToBranchId: string,
direction: "before" | "after") {
const { note, branch } = await note_create.createNote(this.parentNote.noteId, {
activate: false,
targetBranchId: relativeToBranchId,
target: direction,
title: t("board_view.new-item")
});
direction: "before" | "after"
) {
const { note, branch } = await note_create.createNote(
{
target: direction,
parentNoteLink: this.parentNote.noteId,
activate: false,
targetBranchId: relativeToBranchId,
title: t("board_view.new-item"),
}
);
if (!note || !branch) {
throw new Error("Failed to create note");

View File

@ -46,12 +46,18 @@ export function openNoteContextMenu(api: Api, event: ContextMenuEvent, note: FNo
{
title: t("board_view.insert-above"),
uiIcon: "bx bx-list-plus",
handler: () => api.insertRowAtPosition(column, branchId, "before")
handler: () => api.insertRowAtPosition(
column,
branchId,
"before")
},
{
title: t("board_view.insert-below"),
uiIcon: "bx bx-empty",
handler: () => api.insertRowAtPosition(column, branchId, "after")
handler: () => api.insertRowAtPosition(
column,
branchId,
"after")
},
{ kind: "separator" },
{

View File

@ -15,6 +15,7 @@ import FormTextArea from "../../react/FormTextArea";
import FNote from "../../../entities/fnote";
import NoteAutocomplete from "../../react/NoteAutocomplete";
import toast from "../../../services/toast";
import { SuggestionMode } from "../../../services/note_autocomplete";
export interface BoardViewData {
columns?: BoardColumnData[];
@ -309,7 +310,7 @@ export function TitleEditor({ currentValue, placeholder, save, dismiss, mode, is
noteId={currentValue ?? ""}
opts={{
hideAllButtons: true,
allowCreatingNotes: true
suggestionMode: SuggestionMode.SuggestCreateAndLink
}}
onKeyDown={(e) => {
if (e.key === "Escape") {

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 { SuggestionMode } 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,
suggestionMode: SuggestionMode.SuggestCreateAndLink,
hideAllButtons: true
}}
noteIdChanged={success}

View File

@ -181,8 +181,8 @@ export function showRowContextMenu(parentComponent: Component, e: MouseEvent, ro
uiIcon: "bx bx-horizontal-left bx-rotate-90",
enabled: !sorters.length,
handler: () => parentComponent?.triggerCommand("addNewRow", {
parentNotePath: parentNoteId,
customOpts: {
parentNoteLink: parentNoteId,
target: "before",
targetBranchId: rowData.branchId,
}
@ -194,9 +194,12 @@ export function showRowContextMenu(parentComponent: Component, e: MouseEvent, ro
handler: async () => {
const branchId = row.getData().branchId;
const note = await froca.getBranch(branchId)?.getNote();
if (!note) {
return;
}
parentComponent?.triggerCommand("addNewRow", {
parentNotePath: note?.noteId,
customOpts: {
parentNoteLink: note.noteId,
target: "after",
targetBranchId: branchId,
}
@ -208,8 +211,8 @@ export function showRowContextMenu(parentComponent: Component, e: MouseEvent, ro
uiIcon: "bx bx-horizontal-left bx-rotate-270",
enabled: !sorters.length,
handler: () => parentComponent?.triggerCommand("addNewRow", {
parentNotePath: parentNoteId,
customOpts: {
parentNoteLink: parentNoteId,
target: "after",
targetBranchId: rowData.branchId,
}

View File

@ -1,6 +1,6 @@
import { EventCallBackMethods, RowComponent, Tabulator } from "tabulator-tables";
import { CommandListenerData } from "../../../components/app_context";
import note_create, { CreateNoteOpts } from "../../../services/note_create";
import note_create from "../../../services/note_create";
import { useLegacyImperativeHandlers } from "../../react/hooks";
import { RefObject } from "preact";
import { setAttribute, setLabel } from "../../../services/attributes";
@ -9,17 +9,23 @@ import server from "../../../services/server";
import branches from "../../../services/branches";
import AttributeDetailWidget from "../../attribute_widgets/attribute_detail";
/**
* Hook for handling row table editing, including adding new rows.
*/
export default function useRowTableEditing(api: RefObject<Tabulator>, attributeDetailWidget: AttributeDetailWidget, parentNotePath: string): Partial<EventCallBackMethods> {
// Adding new rows
useLegacyImperativeHandlers({
addNewRowCommand({ customOpts, parentNotePath: customNotePath }: CommandListenerData<"addNewRow">) {
const notePath = customNotePath ?? parentNotePath;
if (notePath) {
const opts: CreateNoteOpts = {
activate: false,
...customOpts
}
note_create.createNote(notePath, opts).then(({ branch }) => {
addNewRowCommand({ customOpts }: CommandListenerData<"addNewRow">) {
if (!customOpts) {
customOpts = {
target: "into",
};
}
const noteUrl = customOpts.parentNoteLink ?? parentNotePath;
if (noteUrl) {
customOpts.parentNoteLink = noteUrl;
customOpts.activate = false;
note_create.createNote(customOpts).then(({ branch }) => {
if (branch) {
setTimeout(() => {
if (!api.current) return;
@ -27,6 +33,7 @@ export default function useRowTableEditing(api: RefObject<Tabulator>, attributeD
}, 100);
}
})
}
}
});

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, { SuggestionMode, Suggestion } from "../../services/note_autocomplete";
import { logError } from "../../services/ws";
import FormGroup from "../react/FormGroup.js";
import { refToJQuerySelector } from "../react/react_utils";
@ -58,7 +58,7 @@ export default function AddLinkDialog() {
}
if (suggestion.notePath) {
const noteId = tree.getNoteIdFromUrl(suggestion.notePath);
const noteId = tree.getNoteIdFromLink(suggestion.notePath);
if (noteId) {
setDefaultLinkTitle(noteId);
}
@ -133,7 +133,7 @@ export default function AddLinkDialog() {
onChange={setSuggestion}
opts={{
allowExternalLinks: true,
allowCreatingNotes: true
suggestionMode: SuggestionMode.SuggestCreateAndLink,
}}
/>
</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 { SuggestionMode, Suggestion, triggerRecentNotes } from "../../services/note_autocomplete.js";
import tree from "../../services/tree";
import froca from "../../services/froca";
import { useTriliumEvent } from "../react/hooks";
@ -50,7 +50,7 @@ export default function IncludeNoteDialog() {
inputRef={autoCompleteRef}
opts={{
hideGoToSelectedNoteButton: true,
allowCreatingNotes: true
suggestionMode: SuggestionMode.SuggestCreateOnly,
}}
/>
</FormGroup>
@ -71,7 +71,7 @@ export default function IncludeNoteDialog() {
}
async function includeNote(notePath: string, editorApi: CKEditorApi, boxSize: BoxSize) {
const noteId = tree.getNoteIdFromUrl(notePath);
const noteId = tree.getNoteIdFromLink(notePath);
if (!noteId) {
return;
}

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, { SuggestionMode, Suggestion } from "../../services/note_autocomplete.js";
import appContext from "../../components/app_context";
import commandRegistry from "../../services/command_registry";
import { refToJQuerySelector } from "../react/react_utils";
@ -12,34 +12,53 @@ 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;
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,8 +70,8 @@ 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) {
@ -70,12 +89,12 @@ export default function JumpToNoteDialogComponent() {
function onShown() {
const $autoComplete = refToJQuerySelector(autocompleteRef);
switch (mode) {
case "last-search":
case Mode.LastSearch:
break;
case "recent-notes":
case Mode.RecentNotes:
note_autocomplete.showRecentNotes($autoComplete);
break;
case "commands":
case Mode.Commands:
note_autocomplete.showAllCommands($autoComplete);
break;
}
@ -116,7 +135,7 @@ export default function JumpToNoteDialogComponent() {
container={containerRef}
text={initialText}
opts={{
allowCreatingNotes: true,
suggestionMode: SuggestionMode.SuggestCreateOnly,
hideGoToSelectedNoteButton: true,
allowJumpToSearchNotes: true,
isCommandPalette: true

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 { SuggestionMode, Suggestion } from "../../services/note_autocomplete.js";
import Badge from "../react/Badge";
import { useTriliumEvent } from "../react/hooks";
@ -76,6 +76,7 @@ export default function NoteTypeChooserDialogComponent() {
onHidden={() => {
callback?.({ success: false });
setShown(false);
setParentNote(null);
}}
show={shown}
stackable
@ -85,7 +86,7 @@ export default function NoteTypeChooserDialogComponent() {
onChange={setParentNote}
placeholder={t("note_type_chooser.search_placeholder")}
opts={{
allowCreatingNotes: false,
suggestionMode: SuggestionMode.SuggestNothing,
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, { SuggestionMode } 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, SuggestionMode.SuggestCreateAndLink),
itemRenderer: (item) => {
const suggestion = item as Suggestion;
const itemElement = document.createElement("button");

View File

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

View File

@ -224,7 +224,13 @@ 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.createNote(
{
target: "into",
parentNoteLink: 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 });
@ -1403,10 +1409,10 @@ export default class NoteTreeWidget extends NoteContextAwareWidget {
let node: Fancytree.FancytreeNode | null | undefined = await this.expandToNote(activeNotePath, false);
if (node && node.data.noteId !== treeService.getNoteIdFromUrl(activeNotePath)) {
if (node && node.data.noteId !== treeService.getNoteIdFromLink(activeNotePath)) {
// if the active note has been moved elsewhere then it won't be found by the path,
// so we switch to the alternative of trying to find it by noteId
const noteId = treeService.getNoteIdFromUrl(activeNotePath);
const noteId = treeService.getNoteIdFromLink(activeNotePath);
if (noteId) {
const notesById = this.getNodesByNoteId(noteId);
@ -1836,9 +1842,13 @@ export default class NoteTreeWidget extends NoteContextAwareWidget {
const node = this.getActiveNode();
if (!node) return;
const notePath = treeService.getNotePath(node);
noteCreateService.createNote(notePath, {
isProtected: node.data.isProtected
});
noteCreateService.createNote(
{
target: "into",
parentNoteLink: notePath,
isProtected: node.data.isProtected
}
)
}
}),
new TouchBar.TouchBarButton({

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, { SuggestionMode, Suggestion } from "../../../services/note_autocomplete.js";
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 { CreateNoteAction } from "@triliumnext/commons";
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, SuggestionMode.SuggestCreateAndLink),
itemRenderer: (_item) => {
const item = _item as Suggestion;
const itemElement = document.createElement("button");
@ -247,16 +248,18 @@ 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
});
}
return result?.note?.getBestNotePathString();
createNoteFromCkEditor: async (
title: string,
parentNotePath: string | undefined,
action: CreateNoteAction
): Promise<string> => {
const { note } = await note_create.createNoteFromAction(
action,
true,
title,
parentNotePath,
);
return note?.getBestNotePathString() ?? "";
}
}), [ notePath ]));

View File

@ -4,7 +4,7 @@ import FormGroup from "../react/FormGroup";
import NoteAutocomplete from "../react/NoteAutocomplete";
import "./Empty.css";
import { ParentComponent, refToJQuerySelector } from "../react/react_utils";
import note_autocomplete from "../../services/note_autocomplete";
import note_autocomplete, { SuggestionMode } from "../../services/note_autocomplete";
import appContext from "../../components/app_context";
import FNote from "../../entities/fnote";
import search from "../../services/search";
@ -38,7 +38,7 @@ function NoteSearch({ ntxId }: { ntxId: string | null }) {
inputRef={autocompleteRef}
opts={{
hideGoToSelectedNoteButton: true,
allowCreatingNotes: true,
suggestionMode: SuggestionMode.SuggestCreateOnly,
allowJumpToSearchNotes: true,
}}
onChange={suggestion => {

View File

@ -16,7 +16,7 @@ import note_create from "../../../services/note_create";
import TouchBar, { TouchBarButton, TouchBarGroup, TouchBarSegmentedControl } from "../../react/TouchBar";
import { RefObject } from "preact";
import { buildSelectedBackgroundColor } from "../../../components/touch_bar";
import { deferred } from "@triliumnext/commons";
import { CreateNoteAction, deferred } from "@triliumnext/commons";
import { t } from "../../../services/i18n";
/**
@ -115,17 +115,18 @@ export default function EditableText({ note, parentComponent, ntxId, noteContext
},
loadIncludedNote,
// Creating notes in @-completion
async createNoteForReferenceLink(title: string) {
const notePath = noteContext?.notePath;
if (!notePath) return;
const resp = await note_create.createNoteWithTypePrompt(notePath, {
activate: false,
title: title
});
if (!resp || !resp.note) return;
return resp.note.getBestNotePathString();
async createNoteFromCkEditor (
title: string,
parentNotePath: string | undefined,
action: CreateNoteAction
): Promise<string> {
const { note }= await note_create.createNoteFromAction(
action,
true,
title,
parentNotePath,
)
return note?.getBestNotePathString() ?? "";
},
// Keyboard shortcut
async followLinkUnderCursorCommand() {
@ -162,7 +163,9 @@ export default function EditableText({ note, parentComponent, ntxId, noteContext
// without await as this otherwise causes deadlock through component mutex
const parentNotePath = appContext.tabManager.getActiveContextNotePath();
if (noteContext && parentNotePath) {
note_create.createNote(parentNotePath, {
note_create.createNote({
parentNoteLink: parentNotePath,
target: "into",
isProtected: note.isProtected,
saveSelection: true,
textEditor: await noteContext?.getTextEditor()

View File

@ -7,7 +7,7 @@ import emojiDefinitionsUrl from "@triliumnext/ckeditor5/src/emoji_definitions/en
import { copyTextWithToast } from "../../../services/clipboard_ext.js";
import { t } from "../../../services/i18n.js";
import { getMermaidConfig } from "../../../services/mermaid.js";
import noteAutocompleteService, { type Suggestion } from "../../../services/note_autocomplete.js";
import noteAutocompleteService, { SuggestionMode, 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, SuggestionMode.SuggestCreateAndLink),
itemRenderer: (item) => {
const itemElement = document.createElement("button");

View File

@ -8,6 +8,7 @@ interface GotoOpts {
}
const BASE_URL = "http://127.0.0.1:8082";
const NUM_OF_CREATE_NOTE_OPTIONS = 2;
interface DropdownLocator extends Locator {
selectOptionByText: (text: string) => Promise<void>;
@ -73,7 +74,8 @@ export default class App {
const resultsSelector = this.currentNoteSplit.locator(".note-detail-empty-results");
await expect(resultsSelector).toContainText(noteTitle);
await resultsSelector.locator(".aa-suggestion", { hasText: noteTitle })
.nth(1) // Select the second one, as the first one is "Create a new note"
// Select the n+1 one, as the first one is "Create a new note"
.nth(NUM_OF_CREATE_NOTE_OPTIONS)
.click();
}

View File

@ -1,4 +1,5 @@
import "ckeditor5";
import { type CreateNoteAction } from "@triliumnext/commons"
declare global {
interface Component {
@ -7,7 +8,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: CreateNoteAction): 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 { CreateNoteAction } from "@triliumnext/commons"
/**
* 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?: CreateNoteAction;
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 === CreateNoteAction.CreateNote ||
mention.action === CreateNoteAction.CreateChildNote ||
mention.action === CreateNoteAction.CreateAndLinkNote ||
mention.action === CreateNoteAction.CreateAndLinkChildNote
) {
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);
}

View File

@ -10,4 +10,5 @@ export * from "./lib/server_api.js";
export * from "./lib/shared_constants.js";
export * from "./lib/ws_api.js";
export * from "./lib/attribute_names.js";
export * from "./lib/create_note_actions.js";
export * from "./lib/utils.js";

View File

@ -0,0 +1,6 @@
export enum CreateNoteAction {
CreateNote = "create-note",
CreateChildNote = "create-child-note",
CreateAndLinkNote = "create-and-link-note",
CreateAndLinkChildNote = "create-and-link-child-note"
}