diff --git a/apps/client/src/components/app_context.ts b/apps/client/src/components/app_context.ts index c73fe5a425..4d3114ac8d 100644 --- a/apps/client/src/components/app_context.ts +++ b/apps/client/src/components/app_context.ts @@ -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; diff --git a/apps/client/src/components/entrypoints.ts b/apps/client/src/components/entrypoints.ts index 8a902666f9..8dd7261afb 100644 --- a/apps/client/src/components/entrypoints.ts +++ b/apps/client/src/components/entrypoints.ts @@ -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(`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() }) { diff --git a/apps/client/src/components/main_tree_executors.ts b/apps/client/src/components/main_tree_executors.ts index 9bc037ccf3..067c6491b6 100644 --- a/apps/client/src/components/main_tree_executors.ts +++ b/apps/client/src/components/main_tree_executors.ts @@ -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 + } + ); } } diff --git a/apps/client/src/components/root_command_executor.ts b/apps/client/src/components/root_command_executor.ts index 4a1c987f76..50eb15c019 100644 --- a/apps/client/src/components/root_command_executor.ts +++ b/apps/client/src/components/root_command_executor.ts @@ -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"); diff --git a/apps/client/src/components/tab_manager.ts b/apps/client/src/components/tab_manager.ts index 127ec30b7d..9bfcef22bb 100644 --- a/apps/client/src/components/tab_manager.ts +++ b/apps/client/src/components/tab_manager.ts @@ -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; diff --git a/apps/client/src/menus/tree_context_menu.ts b/apps/client/src/menus/tree_context_menu.ts index 19e6f4e169..67ddf5eb87 100644 --- a/apps/client/src/menus/tree_context_menu.ts +++ b/apps/client/src/menus/tree_context_menu.ts @@ -287,21 +287,31 @@ export default class TreeContextMenu implements SelectMenuItemEventListener treeService.getNoteIdFromUrl($(el).attr("href") || ""); + const getNoteIdFromLink = (el: HTMLElement) => treeService.getNoteIdFromLink($(el).attr("href") || ""); const referenceLinks = $renderedContent.find("a.reference-link"); const noteIdsToPrefetch = referenceLinks.map((i, el) => getNoteIdFromLink(el)); await froca.getNotes(noteIdsToPrefetch); diff --git a/apps/client/src/services/hoisted_note.ts b/apps/client/src/services/hoisted_note.ts index f41f08e066..f466feb660 100644 --- a/apps/client/src/services/hoisted_note.ts +++ b/apps/client/src/services/hoisted_note.ts @@ -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; } diff --git a/apps/client/src/services/link.ts b/apps/client/src/services/link.ts index a596e71366..9814a30368 100644 --- a/apps/client/src/services/link.ts +++ b/apps/client/src/services/link.ts @@ -261,7 +261,7 @@ export function parseNavigationStateFromUrl(url: string | undefined) { return { notePath, - noteId: treeService.getNoteIdFromUrl(notePath), + noteId: treeService.getNoteIdFromLink(notePath), ntxId, hoistedNoteId, viewScope, diff --git a/apps/client/src/services/note_autocomplete.ts b/apps/client/src/services/note_autocomplete.ts index 9ca4fa86fb..557b4b9cc8 100644 --- a/apps/client/src/services/note_autocomplete.ts +++ b/apps/client/src/services/note_autocomplete.ts @@ -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 CKEditor’s 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 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 +86,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 +97,160 @@ export interface Options { isCommandPalette?: boolean; } -async function autocompleteSourceForCKEditor(queryText: string) { - return await new Promise((res, rej) => { +async function autocompleteSourceForCKEditor( + queryText: string, + createMode: CreateMode +): Promise { + // Wrap the callback-based autocompleteSource in a Promise for async/await + const rows = await new Promise((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(`autocomplete?query=${encodeURIComponent(term)}&activeNoteId=${activeNoteId}&fastSearch=${fastSearch}`); + let results = await server.get( + `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); + // --- Create Note suggestions --- + if (trimmedTerm.length >= 1) { + switch (options.createMode) { + case CreateMode.CreateOnly: { + 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 CreateMode.CreateAndLink: { + 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 (length >= 1 && options.allowJumpToSearchNotes) { - results = results.concat([ + // --- Jump to Search Notes --- + if (trimmedTerm.length >= 1 && options.allowJumpToSearchNotes) { + results = [ + ...results, { - action: "search-notes", - noteTitle: term, - highlightedNotePathTitle: `${t("note_autocomplete.search-for", { term })} Ctrl+Enter` - } - ]); + action: SuggestionAction.SearchNotes, + noteTitle: trimmedTerm, + highlightedNotePathTitle: `${t("note_autocomplete.search-for", { + term: trimmedTerm, + })} Ctrl+Enter`, + }, + ]; } - if (term.match(/^[a-z]+:\/\/.+/i) && options.allowExternalLinks) { + // --- 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) { @@ -198,6 +291,85 @@ function fullTextSearch($el: JQuery, options: Options) { $el.autocomplete("val", searchString); } +function renderCommandSuggestion(s: Suggestion): string { + const icon = s.icon || "bx bx-terminal"; + const shortcut = s.commandShortcut + ? `${s.commandShortcut}` + : ""; + + return ` +
+ +
+
${s.highlightedNotePathTitle}
+ ${s.commandDescription ? `
${s.commandDescription}
` : ""} +
+ ${shortcut} +
+ `; +} + +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 ` +
+ + + ${s.highlightedNotePathTitle} + ${s.highlightedAttributeSnippet + ? `${s.highlightedAttributeSnippet}` + : ""} + +
+ `; +} + +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, 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, 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, options?: Options) { } }, displayKey: "notePathTitle", - templates: { - suggestion: (suggestion) => { - if (suggestion.action === "command") { - let html = `
`; - html += ``; - html += `
`; - html += `
${suggestion.highlightedNotePathTitle}
`; - if (suggestion.commandDescription) { - html += `
${suggestion.commandDescription}
`; - } - html += `
`; - if (suggestion.commandShortcut) { - html += `${suggestion.commandShortcut}`; - } - html += '
'; - 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 = `
`; - html += ``; - html += ``; - html += `${suggestion.highlightedNotePathTitle}`; - - // Add attribute snippet inline if available - if (suggestion.highlightedAttributeSnippet) { - html += `${suggestion.highlightedAttributeSnippet}`; - } - - html += ``; - html += `
`; - 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]); - - return; } - if (suggestion.action === "create-note") { - const { success, noteType, templateNoteId, notePath } = await noteCreateService.chooseNoteType(); - if (!success) { - return; - } - const { note } = await noteCreateService.createNote( notePath || suggestion.parentNoteId, { - title: suggestion.noteTitle, - activate: false, - type: noteType, - templateNoteId: templateNoteId - }); - - const hoistedNoteId = appContext.tabManager.getActiveContext()?.hoistedNoteId; - suggestion.notePath = note?.getBestNotePathString(hoistedNoteId); + async function resolveSuggestionNotePathUnderCurrentHoist(note: FNote) { + const hoisted = appContext.tabManager.getActiveContext()?.hoistedNoteId; + suggestion.notePath = note.getBestNotePathString(hoisted); } - if (suggestion.action === "search-notes") { + async function doSearchNotes() { const searchString = suggestion.noteTitle; appContext.triggerCommand("searchNotes", { searchString }); - return; } - $el.setSelectedNotePath(suggestion.notePath); - $el.setSelectedExternalLink(null); + async function selectNoteFromAutocomplete(suggestion: Suggestion) { + $el.setSelectedNotePath(suggestion.notePath); + $el.setSelectedExternalLink(null); - $el.autocomplete("val", suggestion.noteTitle); + $el.autocomplete("val", suggestion.noteTitle); - $el.autocomplete("close"); + $el.autocomplete("close"); + + $el.trigger("autocomplete:noteselected", [suggestion]); + } - $el.trigger("autocomplete:noteselected", [suggestion]); + 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; + } + + case SuggestionAction.SearchNotes: + await doSearchNotes(); + break; + + default: + await selectNoteFromAutocomplete(suggestion); + } }); $el.on("autocomplete:closed", () => { diff --git a/apps/client/src/services/note_create.ts b/apps/client/src/services/note_create.ts index 00ae717d21..5608520652 100644 --- a/apps/client/src/services/note_create.ts +++ b/apps/client/src/services/note_create.ts @@ -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 Curry–Howard 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("Missing parentNoteLink in createNoteFromCkEditor()"); + 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("Missing parentNoteLink in createNoteFromCkEditor()"); + 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 { + 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(`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(`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,23 +326,44 @@ async function createNote(parentNotePath: string | undefined, options: CreateNot }; } -async function chooseNoteType() { - return new Promise((res) => { - appContext.triggerCommand("chooseNoteType", { callback: res }); - }); -} -async function createNoteWithTypePrompt(parentNotePath: string, options: CreateNoteOpts = {}) { - const { success, noteType, templateNoteId, notePath } = await chooseNoteType(); +/** + * 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 (!success) { - return; + if (options.isProtected === undefined) { + options.isProtected = + inboxNote.isProtected && protectedSessionHolder.isProtectedSessionAvailable(); } - options.type = noteType; - options.templateNoteId = templateNoteId; + const result = await createNoteWithLink( + { + ...options, + target: "into", + parentNoteLink: inboxNote.noteId, + } + ); + + return result; +} - return await createNote(notePath || parentNotePath, options); +async function chooseNoteType() { + return new Promise((res) => { + appContext.triggerCommand("chooseNoteType", { callback: res }); + }); } /* If the first element is heading, parse it out and use it as a new heading. */ @@ -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(`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 }; diff --git a/apps/client/src/services/tree.ts b/apps/client/src/services/tree.ts index cfa210600b..6c0700b81a 100644 --- a/apps/client/src/services/tree.ts +++ b/apps/client/src/services/tree.ts @@ -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, diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index aa8c5926c9..ccb4e44da8 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -1888,7 +1888,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", diff --git a/apps/client/src/widgets/attribute_widgets/attribute_detail.ts b/apps/client/src/widgets/attribute_widgets/attribute_detail.ts index 2a7a55aef1..8f144f63d8 100644 --- a/apps/client/src/widgets/attribute_widgets/attribute_detail.ts +++ b/apps/client/src/widgets/attribute_widgets/attribute_detail.ts @@ -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; } diff --git a/apps/client/src/widgets/collections/board/api.ts b/apps/client/src/widgets/collections/board/api.ts index af88f935e5..28d74a0e44 100644 --- a/apps/client/src/widgets/collections/board/api.ts +++ b/apps/client/src/widgets/collections/board/api.ts @@ -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"); diff --git a/apps/client/src/widgets/collections/board/context_menu.ts b/apps/client/src/widgets/collections/board/context_menu.ts index c834b4c8d4..4a8f49e636 100644 --- a/apps/client/src/widgets/collections/board/context_menu.ts +++ b/apps/client/src/widgets/collections/board/context_menu.ts @@ -57,12 +57,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" }, { diff --git a/apps/client/src/widgets/collections/board/index.tsx b/apps/client/src/widgets/collections/board/index.tsx index 7b939224d5..9d0a72df58 100644 --- a/apps/client/src/widgets/collections/board/index.tsx +++ b/apps/client/src/widgets/collections/board/index.tsx @@ -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 { CreateMode } from "../../../services/note_autocomplete"; export interface BoardViewData { columns?: BoardColumnData[]; @@ -298,7 +299,7 @@ export function TitleEditor({ currentValue, placeholder, save, dismiss, mode, is noteId={currentValue ?? ""} opts={{ hideAllButtons: true, - allowCreatingNotes: true + createMode: CreateMode.CreateAndLink }} onKeyDown={(e) => { if (e.key === "Escape") { diff --git a/apps/client/src/widgets/collections/table/columns.tsx b/apps/client/src/widgets/collections/table/columns.tsx index 74db6ddb71..60985cfe9c 100644 --- a/apps/client/src/widgets/collections/table/columns.tsx +++ b/apps/client/src/widgets/collections/table/columns.tsx @@ -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} diff --git a/apps/client/src/widgets/collections/table/context_menu.ts b/apps/client/src/widgets/collections/table/context_menu.ts index 80b1c93c2f..9319c58d7a 100644 --- a/apps/client/src/widgets/collections/table/context_menu.ts +++ b/apps/client/src/widgets/collections/table/context_menu.ts @@ -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, } diff --git a/apps/client/src/widgets/collections/table/row_editing.ts b/apps/client/src/widgets/collections/table/row_editing.ts index 22ef0e7e48..9d64561677 100644 --- a/apps/client/src/widgets/collections/table/row_editing.ts +++ b/apps/client/src/widgets/collections/table/row_editing.ts @@ -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, attributeDetailWidget: AttributeDetailWidget, parentNotePath: string): Partial { - // 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, attributeD }, 100); } }) + } } }); diff --git a/apps/client/src/widgets/dialogs/add_link.tsx b/apps/client/src/widgets/dialogs/add_link.tsx index 4bb1d1711c..0cc3361df8 100644 --- a/apps/client/src/widgets/dialogs/add_link.tsx +++ b/apps/client/src/widgets/dialogs/add_link.tsx @@ -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 { 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 + createMode: CreateMode.CreateAndLink, }} /> diff --git a/apps/client/src/widgets/dialogs/include_note.tsx b/apps/client/src/widgets/dialogs/include_note.tsx index aabd64bab7..c01d0c856c 100644 --- a/apps/client/src/widgets/dialogs/include_note.tsx +++ b/apps/client/src/widgets/dialogs/include_note.tsx @@ -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.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 + createMode: CreateMode.CreateOnly, }} /> @@ -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; } diff --git a/apps/client/src/widgets/dialogs/jump_to_note.tsx b/apps/client/src/widgets/dialogs/jump_to_note.tsx index 89c4388039..94f3f37aa0 100644 --- a/apps/client/src/widgets/dialogs/jump_to_note.tsx +++ b/apps/client/src/widgets/dialogs/jump_to_note.tsx @@ -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.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(); const [ lastOpenedTs, setLastOpenedTs ] = useState(0); const containerRef = useRef(null); const autocompleteRef = useRef(null); - const [ isCommandMode, setIsCommandMode ] = useState(mode === "commands"); + const [ isCommandMode, setIsCommandMode ] = useState(mode === Mode.Commands); const [ initialText, setInitialText ] = useState(isCommandMode ? "> " : ""); const actualText = useRef(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,14 +70,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); @@ -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; } @@ -83,7 +102,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 +110,7 @@ export default function JumpToNoteDialogComponent() { } }); } - + async function showInFullSearch() { try { setShown(false); @@ -116,7 +135,7 @@ export default function JumpToNoteDialogComponent() { container={containerRef} text={initialText} opts={{ - allowCreatingNotes: true, + createMode: CreateMode.CreateOnly, hideGoToSelectedNoteButton: true, allowJumpToSearchNotes: true, isCommandPalette: true @@ -129,9 +148,9 @@ export default function JumpToNoteDialogComponent() { />} onShown={onShown} onHidden={() => setShown(false)} - footer={!isCommandMode &&