Compare commits

...

74 Commits

Author SHA1 Message Date
Jakob Schlanstedt
7f4d8bc8ca
Merge 8ee59e9daa9629c7e568847b811c8e22aa3bdfad into 732494dfc51286926d86d49a73d3915a4aa22ef9 2025-11-30 02:55:04 +02:00
Adorian Doran
732494dfc5 client/keyboard shortcuts cheatsheet: add an edit button
Some checks are pending
Checks / main (push) Waiting to run
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
Dev / Test development (push) Waiting to run
Dev / Build Docker image (push) Blocked by required conditions
Dev / Check Docker build (Dockerfile) (push) Blocked by required conditions
Dev / Check Docker build (Dockerfile.alpine) (push) Blocked by required conditions
/ Check Docker build (Dockerfile) (push) Waiting to run
/ Check Docker build (Dockerfile.alpine) (push) Waiting to run
/ Build Docker images (Dockerfile, ubuntu-24.04-arm, linux/arm64) (push) Blocked by required conditions
/ Build Docker images (Dockerfile.alpine, ubuntu-latest, linux/amd64) (push) Blocked by required conditions
/ Build Docker images (Dockerfile.legacy, ubuntu-24.04-arm, linux/arm/v7) (push) Blocked by required conditions
/ Build Docker images (Dockerfile.legacy, ubuntu-24.04-arm, linux/arm/v8) (push) Blocked by required conditions
/ Merge manifest lists (push) Blocked by required conditions
playwright / E2E tests on linux-arm64 (push) Waiting to run
playwright / E2E tests on linux-x64 (push) Waiting to run
Deploy website / Build & deploy website (push) Waiting to run
2025-11-30 02:54:51 +02:00
Adorian Doran
b8748b856a client/note menu: use proper style for development-only actions section header 2025-11-30 02:34:09 +02:00
Adorian Doran
cc71f15700 client/quick edit: remove fixed toolbar transparency 2025-11-30 02:17:43 +02:00
Adorian Doran
124ef640b1 client/quick edit: tweak layout 2025-11-30 02:04:40 +02:00
Adorian Doran
f5e3df0cd2 client/quick edit: add placeholder for "open in full editor" custom title bar button 2025-11-30 01:54:28 +02:00
Adorian Doran
c8431181c8 client/dialogs/custom title bar buttons: tweak 2025-11-30 01:52:55 +02:00
Adorian Doran
07fb5ab017 client/dialogs: add support for custom title bar buttons 2025-11-30 01:44:20 +02:00
Adorian Doran
6735b257b4 style/promoted color attributes: fix the layout on narrow width 2025-11-30 01:09:07 +02:00
Adorian Doran
cef242a9ce style/button group: fix the appearance of the active button 2025-11-30 00:55:34 +02:00
Elian Doran
2923d917e5
Translations update from Hosted Weblate (#7891) 2025-11-29 22:58:03 +02:00
Hosted Weblate
9a76a9069c
Update translation files
Updated by "Cleanup translation files" add-on in Weblate.

Translation: Trilium Notes/Client
Translate-URL: https://hosted.weblate.org/projects/trilium/client/
2025-11-29 20:01:42 +01:00
pythaac
8e1d796870
Translated using Weblate (Korean)
Currently translated at 44.7% (68 of 152 strings)

Translation: Trilium Notes/Website
Translate-URL: https://hosted.weblate.org/projects/trilium/website/ko/
2025-11-29 20:01:38 +01:00
Andreas H.
8b0d4e5c3b
Translated using Weblate (German)
Currently translated at 100.0% (152 of 152 strings)

Translation: Trilium Notes/Website
Translate-URL: https://hosted.weblate.org/projects/trilium/website/de/
2025-11-29 20:01:37 +01:00
Jakob Schlanstedt
8ee59e9daa refactor(suggestion): improve naming 2025-11-28 22:53:24 +01:00
Jakob Schlanstedt
4e5c26371e fix(note_create): use correct API for intoDefaultLocation 2025-11-28 22:53:24 +01:00
Jakob Schlanstedt
436146d829 fix(note_create): warn message 2025-11-28 22:53:24 +01:00
Jakob Schlanstedt
315fcecf57 fix(include_note): fix wrongly merged code 2025-11-28 22:53:24 +01:00
Jakob Schlanstedt
0a57e6e154 fix(NoteAutocomplete): use new API 2025-11-28 22:53:24 +01:00
Jakob Schlanstedt
bdcb84a394 fix(EditableTest): cutIntoNoteCommand createNote to new API 2025-11-28 22:53:23 +01:00
Jakob Schlanstedt
213c36ba84 fix(EditableTest): Fix EdtableText to use new API 2025-11-28 22:53:22 +01:00
Jakob Schlanstedt
995e765276 fix(translation): missing ',' 2025-11-28 22:52:03 +01:00
Jakob Schlanstedt
8e81c38c14 fix(translation): restore missing translations 2025-11-28 22:52:03 +01:00
Jakob Schlanstedt
7378fa4cbd fix(createNoteFromAction): don't overwrite promptForType but use the variable instead 2025-11-28 22:52:02 +01:00
Jakob Schlanstedt
af8a5ff0c9 refactor(note_create) use correct terminology link not url 2025-11-28 22:52:02 +01:00
Jakob Schlanstedt
eca5a4a072 refactor(url -> link): let link refer to url and path.
before there was polysemy in word url that is now resolved by making
link hypernym to url and path.
2025-11-28 22:52:02 +01:00
Jakob Schlanstedt
71b3ad5027 refactor(note_create): Inbox -> Default naming in functions, parameters and types 2025-11-28 22:52:02 +01:00
Jakob Schlanstedt
7864168adc fix(AttributeEditor): wrong order of Arguments 2025-11-28 22:52:01 +01:00
Jakob Schlanstedt
f8090d9217 feat(search): add create into inbox to search 2025-11-28 22:52:01 +01:00
Jakob Schlanstedt
09aa22c74b refactor(autocomplete-pipline): refactor autocomplete -> create -> select pipeline 2025-11-28 22:52:01 +01:00
Jakob Schlanstedt
8fc8f97879 refactor(create-note-naming): simplify naming 2025-11-28 22:52:00 +01:00
Jakob Schlanstedt
547cdff510 style(jump_to_note): remove dead case to improve readability 2025-11-28 22:52:00 +01:00
Jakob Schlanstedt
cdd08d6971 style(row_editing comments): make inline comment proper doc 2025-11-28 22:52:00 +01:00
Jakob Schlanstedt
b3cf9c8f2d style(unused-imports): Remove unused-imports 2025-11-28 22:52:00 +01:00
Jakob Schlanstedt
feefa389b4 test(server-e2e): fix test for new create note option 2025-11-28 22:52:00 +01:00
Jakob Schlanstedt
f65be4f368 fix(note_autocomplete): fix wrong definition of types, and resulting bugs esp. improving row editing 2025-11-28 22:52:00 +01:00
Jakob Schlanstedt
77a014109e fix(note_autocomplete): fix attributes not linking 2025-11-28 22:52:00 +01:00
Jakob Schlanstedt
d3db48c99b refactor(note-create): remove as typecast 2025-11-28 22:52:00 +01:00
Jakob Schlanstedt
7e4833e893 fix(note-autocomplete): logic error hidden by as typecast 2025-11-28 22:52:00 +01:00
Jakob Schlanstedt
470d7eee31 fix(type-checker): remove as casts hiding type-errors thus bugs. Solve subsequent found bugs 2025-11-28 22:52:00 +01:00
Jakob Schlanstedt
aada631c0f fix(note_autocomplete): fix wrong type of target 2025-11-28 22:52:00 +01:00
Jakob Schlanstedt
bc4186d216 fix(note_create): type casting 2025-11-28 22:52:00 +01:00
Jakob Schlanstedt
c2a27eff2c refactor(note_autocomplete): simplify big switch statement removing duplicate logic 2025-11-28 22:52:00 +01:00
Jakob Schlanstedt
ca24408a13 fix(root-command-executor): fix regression in root_command_executor 2025-11-28 22:52:00 +01:00
Jakob Schlanstedt
b9e19e524a fix(typecheck-proven incorrectness): typecheck caught incorrectness through pnpm typecheck 2025-11-28 22:52:00 +01:00
contributor
09c8a778f5 createNote: better typing without cast and never type 2025-11-28 22:52:00 +01:00
Jakob Schlanstedt
3438f1103d refactor(note-create): remove small redundancy 2025-11-28 22:52:00 +01:00
Jakob Schlanstedt
82a3be06d1 fix(note-create): fix type definition for CreateNoteWithUrlOpts 2025-11-28 22:52:00 +01:00
Jakob Schlanstedt
f0dead5390 refactor(note-create): remove unnecessary deep hierarchy 2025-11-28 22:52:00 +01:00
Jakob Schlanstedt
b0fdb9fef2 refactor(note-create): replace 'at' with 'with' 2025-11-28 22:52:00 +01:00
Jakob Schlanstedt
71009bddc7 refactor(note-create): simplify createNote switch to equivalent small ifs 2025-11-28 22:52:00 +01:00
Jakob Schlanstedt
66e499a2e1 refactor(note-create): replace enum with optional fields 2025-11-28 22:52:00 +01:00
Jakob Schlanstedt
a5ef5eee2f refactor(note_create): simplify type implementation and documentation 2025-11-28 22:52:00 +01:00
Jakob Schlanstedt
bcb29d22f5 docs(note_create): further clarify type system 2025-11-28 22:52:00 +01:00
Jakob Schlanstedt
6ad2b49ab3 docs: remove comments duplicating code 2025-11-28 22:52:00 +01:00
Jakob Schlanstedt
656e7c069d refactor(create_note): rename types to fit ontological concepts better 2025-11-28 22:52:00 +01:00
Jakob Schlanstedt
00aa470bf2 fix(Omit in types): remove Omit from types to make hierarchy logical consistent and resulting errors from corrected logic. 2025-11-28 22:52:00 +01:00
Jakob Schlanstedt
c6ed0b43fc docs(note_create): improve clarification of type system 2025-11-28 22:52:00 +01:00
Jakob Schlanstedt
3d8a4d2553 docs(note_create): improve documentation for type checking system 2025-11-28 22:52:00 +01:00
Jakob Schlanstedt
42ab0eb804 fix(note_create): fix type hierarchy inheriting from wrong type and improve its documentation 2025-11-28 22:52:00 +01:00
Jakob Schlanstedt
d80d06a9b8 refactor(note_create): cleanup by removing unused import 2025-11-28 22:52:00 +01:00
Jakob Schlanstedt
3c39026739 refactor(note_create): reorder function order to simplify diff 2025-11-28 22:52:00 +01:00
Jakob Schlanstedt
72c17b22df refactor(note_create): improve comments for type system 2025-11-28 22:52:00 +01:00
Jakob Schlanstedt
dd1aa23cb6 refactor(note_create): clarify type system 2025-11-28 22:52:00 +01:00
Jakob Schlanstedt
ecdf243e63 refactor: cleanup comments 2025-11-28 22:52:00 +01:00
Jakob Schlanstedt
1e15585a24 refactor(typeerror): resolve typeerrors by refactoring code 2025-11-28 22:52:00 +01:00
Jakob Schlanstedt
9d40c0cb26 fix(note-type-chooser): fix note type chooser, unnecessary stick to old chosen path 2025-11-28 22:52:00 +01:00
Jakob Schlanstedt
e41e24b044 refactor(create-note): centralize and add advanced type checking to create-note with and without prompt 2025-11-28 22:52:00 +01:00
Jakob Schlanstedt
a15d661fd7 fix(jump_to_note): fix enum typescript error in switch statement 2025-11-28 22:52:00 +01:00
contributor
cb5954e8c7 fix typings for creating a note using mentions in ckeditor 2025-11-28 22:52:00 +01:00
contributor
2b55db05e1 rename MentionAction to CreateNoteAction and move to packages/common 2025-11-28 22:52:00 +01:00
Jakob Schlanstedt
74bf93059c refactor(ECMAScript Modules jsimport convention): refactor newly created imports to js convention
https://nodejs.org/api/esm.html#import-specifiers
2025-11-28 22:51:59 +01:00
Jakob Schlanstedt
384d8c9c37 refactor(note-create): change order of noteCreate intoPath and intoInbox to improve code compatibility 2025-11-28 22:51:59 +01:00
Jakob Schlanstedt
1bb6149dbe feat(search): add create into inbox to search 2025-11-28 22:51:59 +01:00
55 changed files with 927 additions and 390 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, {
await noteCreateService.createNote(
{
target: "into",
parentNoteLink: activeNoteContext.notePath,
isProtected: activeNoteContext.note.isProtected,
saveSelection: false
});
saveSelection: false,
promptForType: false,
}
);
}
async createNoteAfterCommand() {
@ -72,11 +77,14 @@ export default class MainTreeExecutors extends Component {
return;
}
await noteCreateService.createNote(parentNotePath, {
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, {
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, {
noteCreateService.createNote(
{
target: "after",
parentNoteLink: parentNotePath,
targetBranchId: this.node.data.branchId,
type: type,
isProtected: isProtected,
templateNoteId: templateNoteId
});
templateNoteId: templateNoteId,
promptForType: false,
}
);
} else if (command === "insertChildNote") {
const parentNotePath = treeService.getNotePath(this.node);
noteCreateService.createNote(parentNotePath, {
noteCreateService.createNote(
{
target: "into",
parentNoteLink: parentNotePath,
type: type,
isProtected: this.node.data.isProtected,
templateNoteId: templateNoteId
});
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,
(suggestions) => resolve(suggestions),
{
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
};
})
);
},
{
allowCreatingNotes: true
}
);
});
}));
}
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
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) {
// --- Create Note suggestions ---
if (trimmedTerm.length >= 1) {
switch (options.suggestionMode) {
case SuggestionMode.SuggestCreateOnly: {
results = [
{
action: "create-note",
noteTitle: term,
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-note", { term })
} as Suggestion
].concat(results);
highlightedNotePathTitle: t("note_autocomplete.create-child-note", { term: trimmedTerm }),
},
...results,
];
break;
}
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>`
}
]);
}
if (term.match(/^[a-z]+:\/\/.+/i) && options.allowExternalLinks) {
case SuggestionMode.SuggestCreateAndLink: {
results = [
{
action: "external-link",
externalLink: term,
highlightedNotePathTitle: t("note_autocomplete.insert-external-link", { term })
} as Suggestion
].concat(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;
}
cb(results);
default:
// CreateMode.None or undefined → no creation suggestions
break;
}
}
// --- 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: SuggestionAction.ExternalLink,
externalLink: trimmedTerm,
highlightedNotePathTitle: t("note_autocomplete.insert-external-link", { term: trimmedTerm }),
},
...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,101 +477,38 @@ 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;
}
templates: { suggestion: renderSuggestion },
cache: false,
},
// we can't cache identical searches because notes can be created / renamed, new recent notes can be added
cache: false
}
]
);
// TODO: Types fail due to "autocomplete:selected" not being registered in type definitions.
($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;
}
async function selectNoteFromAutocomplete(suggestion: Suggestion) {
$el.setSelectedNotePath(suggestion.notePath);
$el.setSelectedExternalLink(null);
@ -411,6 +517,45 @@ function initNoteAutocomplete($el: JQuery<HTMLElement>, options?: Options) {
$el.autocomplete("close");
$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", () => {

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

@ -592,11 +592,6 @@ button.btn-sm {
color: var(--left-pane-text-color);
}
.btn.active:not(.btn-primary) {
background-color: var(--button-disabled-background-color) !important;
opacity: 0.4;
}
.ck.ck-block-toolbar-button {
transform: translateX(7px);
color: var(--muted-text-color);

View File

@ -41,6 +41,9 @@
--cmd-button-keyboard-shortcut-color: white;
--cmd-button-disabled-opacity: 0.5;
--button-group-active-button-background: #ffffff4e;
--button-group-active-button-text-color: white;
--icon-button-color: currentColor;
--icon-button-hover-background: var(--hover-item-background-color);
--icon-button-hover-color: var(--hover-item-text-color);

View File

@ -41,6 +41,9 @@
--cmd-button-keyboard-shortcut-color: black;
--cmd-button-disabled-opacity: 0.5;
--button-group-active-button-background: #00000026;
--button-group-active-button-text-color: black;
--icon-button-color: currentColor;
--icon-button-hover-background: var(--hover-item-background-color);
--icon-button-hover-color: var(--hover-item-text-color);

View File

@ -25,6 +25,7 @@
.modal .modal-header .btn-close,
.modal .modal-header .help-button,
.modal .modal-header .custom-title-bar-button,
#toast-container .toast .toast-header .btn-close {
display: flex;
justify-content: center;
@ -55,15 +56,17 @@
font-family: boxicons;
}
.modal .modal-header .help-button {
.modal .modal-header .help-button,
.modal .modal-header .custom-title-bar-button {
margin-inline-end: 0;
font-size: calc(var(--modal-control-button-size) * .75);
font-size: calc(var(--modal-control-button-size) * .70);
font-family: unset;
font-weight: bold;
}
.modal .modal-header .btn-close:hover,
.modal .modal-header .help-button:hover,
.modal .modal-header .custom-title-bar-button:hover,
#toast-container .toast .toast-header .btn-close:hover {
background: var(--modal-control-button-hover-background);
color: var(--modal-control-button-hover-color);
@ -71,6 +74,7 @@
.modal .modal-header .btn-close:active,
.modal .modal-header .help-button:active,
.modal .modal-header .custom-title-bar-button:active,
#toast-container .toast .toast-header .btn-close:active {
transform: scale(.85);
}

View File

@ -146,6 +146,14 @@ button.btn.btn-success kbd {
outline: 2px solid var(--input-focus-outline-color);
}
/* Button groups */
/* Active button */
:root .btn-group button.btn.active {
background-color: var(--button-group-active-button-background);
color: var(--button-group-active-button-text-color);
}
/*
* Input boxes
*/

View File

@ -1448,6 +1448,14 @@ div.promoted-attribute-cell .multiplicity:has(span) span {
justify-content: center;
}
div.promoted-attribute-cell.promoted-attribute-label-color {
justify-content: space-between;
}
div.promoted-attribute-cell.promoted-attribute-label-color .input-group {
width: auto;
}
/*
* Floating buttons
*/

View File

@ -2098,7 +2098,6 @@
"read-only-info": {
"read-only-note": "当前正在查看一个只读笔记。",
"auto-read-only-note": "这条笔记以只读模式显示便于快速加载。",
"auto-read-only-learn-more": "了解更多",
"edit-note": "编辑笔记"
},
"note-color": {

View File

@ -2097,7 +2097,6 @@
"read-only-info": {
"read-only-note": "Aktuelle Notiz wird im Lese-Modus angezeigt.",
"auto-read-only-note": "Diese Notiz wird im Nur-Lesen-Modus angezeigt, um ein schnelleres Laden zu ermöglichen.",
"auto-read-only-learn-more": "Mehr erfahren",
"edit-note": "Notiz bearbeiten"
},
"calendar_view": {

View File

@ -112,6 +112,7 @@
},
"help": {
"title": "Cheatsheet",
"editShortcuts": "Edit keyboard shortcuts",
"noteNavigation": "Note navigation",
"goUpDown": "go up/down in the list of notes",
"collapseExpand": "collapse/expand node",
@ -1896,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

@ -2096,7 +2096,6 @@
"read-only-info": {
"read-only-note": "Actualmente, está viendo una nota de solo lectura.",
"auto-read-only-note": "Esta nota se muestra en modo de solo lectura para una carga más rápida.",
"auto-read-only-learn-more": "Para saber más",
"edit-note": "Editar nota"
},
"calendar_view": {

View File

@ -2092,7 +2092,6 @@
"read-only-info": {
"read-only-note": "Stai visualizzando una nota di sola lettura.",
"auto-read-only-note": "Questa nota viene visualizzata in modalità di sola lettura per un caricamento più rapido.",
"auto-read-only-learn-more": "Per saperne di più",
"edit-note": "Modifica nota"
},
"calendar_view": {

View File

@ -2098,7 +2098,6 @@
"read-only-info": {
"read-only-note": "現在、読み取り専用のノートを表示しています。",
"auto-read-only-note": "このノートは読み込みを高速化するために読み取り専用モードで表示されています。",
"auto-read-only-learn-more": "さらに詳しく",
"edit-note": "ノートを編集"
},
"note-color": {

View File

@ -2097,7 +2097,6 @@
"read-only-info": {
"read-only-note": "Vizualizați o notiță în modul doar în citire.",
"auto-read-only-note": "Această notiță este afișată în modul doar în citire din motive de performanță.",
"auto-read-only-learn-more": "Mai multe detalii",
"edit-note": "Editează notița"
},
"calendar_view": {

View File

@ -2098,7 +2098,6 @@
"read-only-info": {
"read-only-note": "目前正在檢視唯讀筆記。",
"auto-read-only-note": "此筆記以唯讀模式顯示以加快載入速度。",
"auto-read-only-learn-more": "了解更多",
"edit-note": "編輯筆記"
},
"note-color": {

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, {
direction: "before" | "after"
) {
const { note, branch } = await note_create.createNote(
{
target: direction,
parentNoteLink: this.parentNote.noteId,
activate: false,
targetBranchId: relativeToBranchId,
target: direction,
title: t("board_view.new-item")
});
title: t("board_view.new-item"),
}
);
if (!note || !branch) {
throw new Error("Failed to create note");

View File

@ -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" },
{

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
addNewRowCommand({ customOpts }: CommandListenerData<"addNewRow">) {
if (!customOpts) {
customOpts = {
target: "into",
};
}
note_create.createNote(notePath, opts).then(({ branch }) => {
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

@ -33,8 +33,8 @@ body.mobile .modal.popup-editor-dialog .modal-dialog {
align-items: center;
}
.modal.popup-editor-dialog .modal-header .title-row > * {
margin: 5px;
.modal.popup-editor-dialog .modal-header .note-title-widget {
margin-top: 8px;
}
.modal.popup-editor-dialog .modal-body {
@ -66,12 +66,17 @@ body.mobile .modal.popup-editor-dialog .modal-dialog {
background-color: transparent;
}
.modal.popup-editor-dialog div.promoted-attributes-container {
margin-block: 0;
}
.modal.popup-editor-dialog .classic-toolbar-widget {
position: sticky;
margin-inline: 8px;
top: 0;
inset-inline-start: 0;
inset-inline-end: 0;
background: transparent;
background: var(--modal-background-color);
z-index: 998;
align-items: flex-start;
}

View File

@ -60,6 +60,11 @@ export default function PopupEditor() {
<DialogWrapper>
<Modal
title={<TitleRow />}
customTitleBarButtons={[{
iconClassName: "bx-expand-alt",
title: "Switch to full editor",
onClick: () => {/* TO DO */}
}]}
className="popup-editor-dialog"
size="lg"
show={shown}

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

@ -1,7 +1,7 @@
import Modal from "../react/Modal.jsx";
import { t } from "../../services/i18n.js";
import { ComponentChildren } from "preact";
import { CommandNames } from "../../components/app_context.js";
import appContext, { CommandNames } from "../../components/app_context.js";
import RawHtml from "../react/RawHtml.jsx";
import { useEffect, useState } from "preact/hooks";
import keyboard_actions from "../../services/keyboard_actions.js";
@ -14,6 +14,7 @@ export default function HelpDialog() {
return (
<Modal
title={t("help.title")} className="help-dialog use-tn-links" minWidth="90%" size="lg" scrollable
customTitleBarButtons={[{title: t("help.editShortcuts"), iconClassName: "bxs-pencil", onClick: editShortcuts}]}
onHidden={() => setShown(false)}
show={shown}
>
@ -160,3 +161,7 @@ function Card({ title, children }: { title: string, children: ComponentChildren
</div>
)
}
function editShortcuts() {
appContext.tabManager.openContextWithNote("_optionsShortcuts", { activate: true });
}

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";
switch (requestedMode) {
case Mode.Commands:
newMode = Mode.Commands;
initialText = ">";
} else if (Date.now() - lastOpenedTs <= KEEP_LAST_SEARCH_FOR_X_SECONDS * 1000 && actualText.current) {
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.
newMode = "last-search";
if (Date.now() - lastOpenedTs <= KEEP_LAST_SEARCH_FOR_X_SECONDS * 1000 && actualText.current) {
newMode = Mode.LastSearch;
initialText = actualText.current;
} else {
newMode = "recent-notes";
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, {
noteCreateService.createNote(
{
target: "into",
parentNoteLink: notePath,
isProtected: node.data.isProtected
});
}
)
}
}),
new TouchBar.TouchBarButton({

View File

@ -1,3 +1,4 @@
import clsx from "clsx";
import { useEffect, useRef, useMemo } from "preact/hooks";
import { t } from "../../services/i18n";
import { ComponentChildren } from "preact";
@ -7,9 +8,16 @@ import { Modal as BootstrapModal } from "bootstrap";
import { memo } from "preact/compat";
import { useSyncedRef } from "./hooks";
interface CustomTitleBarButton {
title: string;
iconClassName: string;
onClick: () => void;
}
interface ModalProps {
className: string;
title: string | ComponentChildren;
customTitleBarButtons?: (CustomTitleBarButton | null)[];
size: "xl" | "lg" | "md" | "sm";
children: ComponentChildren;
/**
@ -72,7 +80,7 @@ interface ModalProps {
noFocus?: boolean;
}
export default function Modal({ children, className, size, title, header, footer, footerStyle, footerAlignment, onShown, onSubmit, helpPageId, minWidth, maxWidth, zIndex, scrollable, onHidden: onHidden, modalRef: externalModalRef, formRef, bodyStyle, show, stackable, keepInDom, noFocus }: ModalProps) {
export default function Modal({ children, className, size, title, customTitleBarButtons: titleBarButtons, header, footer, footerStyle, footerAlignment, onShown, onSubmit, helpPageId, minWidth, maxWidth, zIndex, scrollable, onHidden: onHidden, modalRef: externalModalRef, formRef, bodyStyle, show, stackable, keepInDom, noFocus }: ModalProps) {
const modalRef = useSyncedRef<HTMLDivElement>(externalModalRef);
const modalInstanceRef = useRef<BootstrapModal>();
const elementToFocus = useRef<Element | null>();
@ -148,7 +156,17 @@ export default function Modal({ children, className, size, title, header, footer
{helpPageId && (
<button className="help-button" type="button" data-in-app-help={helpPageId} title={t("modal.help_title")}>?</button>
)}
{titleBarButtons?.filter((b) => b !== null).map((titleBarButton) => (
<button type="button"
className={clsx("custom-title-bar-button bx", titleBarButton.iconClassName)}
title={titleBarButton.title}
onClick={titleBarButton.onClick}>
</button>
))}
<button type="button" className="btn-close" data-bs-dismiss="modal" aria-label={t("modal.close")}></button>
</div>
{onSubmit ? (

View File

@ -1,5 +1,5 @@
import { ConvertToAttachmentResponse } from "@triliumnext/commons";
import { FormDropdownDivider, FormListItem } from "../react/FormList";
import { FormDropdownDivider, FormListHeader, FormListItem } from "../react/FormList";
import { isElectron as getIsElectron, isMac as getIsMac } from "../../services/utils";
import { ParentComponent } from "../react/react_utils";
import { t } from "../../services/i18n"
@ -113,8 +113,7 @@ function NoteContextMenu({ note, noteContext }: { note: FNote, noteContext?: Not
function DevelopmentActions({ note }: { note: FNote }) {
return (
<>
<FormDropdownDivider />
<FormListItem disabled>Development-only Actions</FormListItem>
<FormListHeader text="Development-only Actions" />
<FormListItem
icon="bx bx-printer"
onClick={() => window.open(`/?print=#root/${note.noteId}`, "_blank")}

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

@ -21,7 +21,7 @@
"note_structure_description": "Notizen lassen sich hierarchisch anordnen. Ordner sind nicht nötig, da jede Notiz Unternotizen enthalten kann. Eine einzelne Notiz kann an mehreren Stellen in der Hierarchie hinzugefügt werden.",
"hoisting_description": "Trennen Sie Ihre persönlichen und beruflichen Notizen ganz einfach, indem Sie sie in einem Arbeitsbereich gruppieren. Dadurch wird Ihre Notizstruktur so fokussiert, dass nur ein bestimmter Satz von Notizen angezeigt wird.",
"hoisting_title": "Arbeitsbereiche und Fokusansicht",
"attributes_description": "Nutzen Sie Verbindungen zwischen Notizen oder fügen Sie Labels hinzu, um die Kategorisierung zu erleichtern. Mit hervorgehobenen („promoted“) Attributen können Sie strukturierte Informationen erfassen, die sich direkt in Tabellen und Boards verwenden lassen."
"attributes_description": "Verwenden Sie Beziehungen zwischen Notizen oder fügen Sie Beschriftungen hinzu, um die Kategorisierung zu vereinfachen. Verwenden Sie hervorgehobene Attribute, um strukturierte Informationen einzugeben, die in Tabellen und Boards verwendet werden können."
},
"productivity_benefits": {
"revisions_title": "Notizrevisionen",

View File

@ -87,6 +87,8 @@
"description_arm64": "ARM 기반 리눅스 배포판에서 aarch64 아키텍처와 호환됩니다."
},
"note_types": {
"text_title": "텍스트 노트"
"text_title": "텍스트 노트",
"text_description": "노트는 WYSIWYG 편집기를 사용하며 표, 이미지, 수학 표현식, 구문 강조 기능의 코드 블록을 지원합니다. 특수문자를 사용한 마크다운 유사 구문이나 슬래시(/) 명령으로 텍스트 서식을 빠르게 지정할 수 있습니다.",
"code_title": "코드 노트"
}
}

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):
@ -36,9 +37,10 @@ interface MentionOpts {
interface MentionAttribute {
id: string;
action?: "create-note";
action?: CreateNoteAction;
noteTitle: string;
notePath: string;
parentNoteId?: string;
}
class CustomMentionCommand extends Command {
@ -56,13 +58,26 @@ class CustomMentionCommand extends Command {
model.insertContent( writer.createText( mention.id, {} ), range );
});
}
else if (mention.action === 'create-note') {
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 => {
// 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"
}