Compare commits

...

70 Commits

Author SHA1 Message Date
Jakob Schlanstedt
b531257fd2
Merge 8ee59e9daa9629c7e568847b811c8e22aa3bdfad into 32c16021c420ce10f630d98b00f8735f0bf2fdd7 2025-12-01 01:01:11 +00:00
Adorian Doran
32c16021c4 style/calendar collection: refactor
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
2025-12-01 03:00:38 +02:00
Adorian Doran
7713c1173a style/calendar collection: tweak the color of the today column / cell 2025-12-01 02:40:07 +02:00
Adorian Doran
8018f400c3 style/calendar collection: correct a hover color 2025-12-01 02:26:16 +02:00
Adorian Doran
79c8293881 style/calendar collection: handle dot events as normal events 2025-12-01 02:21:19 +02:00
Adorian Doran
db5652623b style/calendar collection: fix broken background color for events without a hue 2025-12-01 02:04:26 +02:00
Adorian Doran
0f7a48b323 style/calendar collection: add basic support for the legacy theme 2025-12-01 01:55:03 +02:00
Adorian Doran
415d2826c6 style/calendar collection: tweak dark theme colors 2025-11-30 23:30:30 +02:00
Adorian Doran
7787e7085e style/calendar collection: tweak dark theme colors 2025-11-30 23:22:41 +02:00
Elian Doran
4ab8417168
feat(forge): add safeguard for ARM64 better-sqlite3 binary 2025-11-30 22:57:14 +02: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
43 changed files with 943 additions and 383 deletions

View File

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

View File

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

View File

@ -48,10 +48,15 @@ export default class MainTreeExecutors extends Component {
return; return;
} }
await noteCreateService.createNote(activeNoteContext.notePath, { await noteCreateService.createNote(
isProtected: activeNoteContext.note.isProtected, {
saveSelection: false target: "into",
}); parentNoteLink: activeNoteContext.notePath,
isProtected: activeNoteContext.note.isProtected,
saveSelection: false,
promptForType: false,
}
);
} }
async createNoteAfterCommand() { async createNoteAfterCommand() {
@ -72,11 +77,14 @@ export default class MainTreeExecutors extends Component {
return; return;
} }
await noteCreateService.createNote(parentNotePath, { await noteCreateService.createNote(
target: "after", {
targetBranchId: node.data.branchId, target: "after",
isProtected: isProtected, parentNoteLink: parentNotePath,
saveSelection: false 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">) { async searchInSubtreeCommand({ notePath }: CommandListenerData<"searchInSubtree">) {
const noteId = treeService.getNoteIdFromUrl(notePath); const noteId = treeService.getNoteIdFromLink(notePath);
this.searchNotesCommand({ ancestorNoteId: noteId }); this.searchNotesCommand({ ancestorNoteId: noteId });
} }
@ -240,14 +240,18 @@ export default class RootCommandExecutor extends Component {
// Create a new AI Chat note at the root level // Create a new AI Chat note at the root level
const rootNoteId = "root"; const rootNoteId = "root";
const result = await noteCreateService.createNote(rootNoteId, { const result = await noteCreateService.createNote(
title: "New AI Chat", {
type: "aiChat", parentNoteLink: rootNoteId,
content: JSON.stringify({ target: "into",
messages: [], title: "New AI Chat",
title: "New AI Chat" type: "aiChat",
}) content: JSON.stringify({
}); messages: [],
title: "New AI Chat"
}),
}
);
if (!result.note) { if (!result.note) {
toastService.showError("Failed to create AI Chat 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 // preload all notes at once
await froca.getNotes([...noteContextsToOpen.flatMap((tab: NoteContextState) => 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 filteredNoteContexts = noteContextsToOpen.filter((openTab: NoteContextState) => {
const noteId = treeService.getNoteIdFromUrl(openTab.notePath); const noteId = treeService.getNoteIdFromLink(openTab.notePath);
if (!noteId || !(noteId in froca.notes)) { if (!noteId || !(noteId in froca.notes)) {
// note doesn't exist so don't try to open tab for it // note doesn't exist so don't try to open tab for it
return false; return false;

View File

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

View File

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

View File

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

View File

@ -5,6 +5,24 @@ import froca from "./froca.js";
import { t } from "./i18n.js"; import { t } from "./i18n.js";
import commandRegistry from "./command_registry.js"; import commandRegistry from "./command_registry.js";
import type { MentionFeedObjectItem } from "@triliumnext/ckeditor5"; import type { MentionFeedObjectItem } from "@triliumnext/ckeditor5";
import { 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 // this key needs to have this value, so it's hit by the tooltip
const SELECTED_NOTE_PATH_KEY = "data-note-path"; const SELECTED_NOTE_PATH_KEY = "data-note-path";
@ -23,14 +41,39 @@ function getSearchDelay(notesCount: number): number {
} }
let searchDelay = getSearchDelay(notesCount); let searchDelay = getSearchDelay(notesCount);
// TODO: Deduplicate with server. // String values ensure stable, human-readable identifiers across serialization (JSON, CKEditor, logs).
export enum SuggestionAction {
// These values intentionally mirror 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 { export interface Suggestion {
noteTitle?: string; noteTitle?: string;
externalLink?: string; externalLink?: string;
notePathTitle?: string; notePathTitle?: string;
notePath?: string; notePath?: string;
highlightedNotePathTitle?: string; highlightedNotePathTitle?: string;
action?: string | "create-note" | "search-notes" | "external-link" | "command"; action?: SuggestionAction;
parentNoteId?: string; parentNoteId?: string;
icon?: string; icon?: string;
commandId?: string; commandId?: string;
@ -43,7 +86,7 @@ export interface Suggestion {
export interface Options { export interface Options {
container?: HTMLElement | null; container?: HTMLElement | null;
fastSearch?: boolean; fastSearch?: boolean;
allowCreatingNotes?: boolean; suggestionMode?: SuggestionMode;
allowJumpToSearchNotes?: boolean; allowJumpToSearchNotes?: boolean;
allowExternalLinks?: boolean; allowExternalLinks?: boolean;
/** If set, hides the right-side button corresponding to go to selected note. */ /** If set, hides the right-side button corresponding to go to selected note. */
@ -54,110 +97,160 @@ export interface Options {
isCommandPalette?: boolean; isCommandPalette?: boolean;
} }
async function autocompleteSourceForCKEditor(queryText: string) { async function autocompleteSourceForCKEditor(
return await new Promise<MentionFeedObjectItem[]>((res, rej) => { queryText: string,
suggestionMode: SuggestionMode
): Promise<MentionFeedObjectItem[]> {
// Wrap the callback-based autocompleteSource in a Promise for async/await
const rows = await new Promise<Suggestion[]>((resolve) => {
autocompleteSource( autocompleteSource(
queryText, queryText,
(rows) => { (suggestions) => resolve(suggestions),
res(
rows.map((row) => {
return {
action: row.action,
noteTitle: row.noteTitle,
id: `@${row.notePathTitle}`,
name: row.notePathTitle || "",
link: `#${row.notePath}`,
notePath: row.notePath,
highlightedNotePathTitle: row.highlightedNotePathTitle
};
})
);
},
{ {
allowCreatingNotes: true suggestionMode,
} }
); );
}); });
// Map internal suggestions to CKEditor mention feed items
return rows.map((row): ExtendedMentionFeedObjectItem => ({
action: row.action?.toString(),
noteTitle: row.noteTitle,
id: `@${row.notePathTitle}`,
name: row.notePathTitle || "",
link: `#${row.notePath}`,
notePath: row.notePath,
parentNoteId: row.parentNoteId,
highlightedNotePathTitle: row.highlightedNotePathTitle
}));
} }
async function autocompleteSource(term: string, cb: (rows: Suggestion[]) => void, options: Options = {}) { async function autocompleteSource(
term: string,
callback: (rows: Suggestion[]) => void,
options: Options = {}
) {
// Check if we're in command mode // Check if we're in command mode
if (options.isCommandPalette && term.startsWith(">")) { if (options.isCommandPalette && term.startsWith(">")) {
const commandQuery = term.substring(1).trim(); const commandQuery = term.substring(1).trim();
// Get commands (all if no query, filtered if query provided) // Get commands (all if no query, filtered if query provided)
const commands = commandQuery.length === 0 const commands =
? commandRegistry.getAllCommands() commandQuery.length === 0
: commandRegistry.searchCommands(commandQuery); ? commandRegistry.getAllCommands()
: commandRegistry.searchCommands(commandQuery);
// Convert commands to suggestions // Convert commands to suggestions
const commandSuggestions: Suggestion[] = commands.map(cmd => ({ const commandSuggestions: Suggestion[] = commands.map((cmd) => ({
action: "command", action: SuggestionAction.Command,
commandId: cmd.id, commandId: cmd.id,
noteTitle: cmd.name, noteTitle: cmd.name,
notePathTitle: `>${cmd.name}`, notePathTitle: `>${cmd.name}`,
highlightedNotePathTitle: cmd.name, highlightedNotePathTitle: cmd.name,
commandDescription: cmd.description, commandDescription: cmd.description,
commandShortcut: cmd.shortcut, commandShortcut: cmd.shortcut,
icon: cmd.icon icon: cmd.icon,
})); }));
cb(commandSuggestions); callback(commandSuggestions);
return; return;
} }
const fastSearch = options.fastSearch === false ? false : true; const fastSearch = options.fastSearch !== false;
if (fastSearch === false) { const trimmedTerm = term.trim();
if (term.trim().length === 0) { const activeNoteId = appContext.tabManager.getActiveContextNoteId();
return;
} if (!fastSearch && trimmedTerm.length === 0) return;
cb([
if (!fastSearch) {
callback([
{ {
noteTitle: term, noteTitle: trimmedTerm,
highlightedNotePathTitle: t("quick-search.searching") highlightedNotePathTitle: t("quick-search.searching"),
} },
]); ]);
} }
const activeNoteId = appContext.tabManager.getActiveContextNoteId(); let results = await server.get<Suggestion[]>(
const length = term.trim().length; `autocomplete?query=${encodeURIComponent(trimmedTerm)}&activeNoteId=${activeNoteId}&fastSearch=${fastSearch}`
);
let results = await server.get<Suggestion[]>(`autocomplete?query=${encodeURIComponent(term)}&activeNoteId=${activeNoteId}&fastSearch=${fastSearch}`);
options.fastSearch = true; options.fastSearch = true;
if (length >= 1 && options.allowCreatingNotes) { // --- Create Note suggestions ---
results = [ if (trimmedTerm.length >= 1) {
{ switch (options.suggestionMode) {
action: "create-note", case SuggestionMode.SuggestCreateOnly: {
noteTitle: term, results = [
parentNoteId: activeNoteId || "root", {
highlightedNotePathTitle: t("note_autocomplete.create-note", { term }) action: SuggestionAction.CreateNote,
} as Suggestion noteTitle: trimmedTerm,
].concat(results); parentNoteId: "inbox",
} highlightedNotePathTitle: t("note_autocomplete.create-note", { term: trimmedTerm }),
},
if (length >= 1 && options.allowJumpToSearchNotes) { {
results = results.concat([ action: SuggestionAction.CreateChildNote,
{ noteTitle: trimmedTerm,
action: "search-notes", parentNoteId: activeNoteId || "root",
noteTitle: term, highlightedNotePathTitle: t("note_autocomplete.create-child-note", { term: trimmedTerm }),
highlightedNotePathTitle: `${t("note_autocomplete.search-for", { term })} <kbd style='color: var(--muted-text-color); background-color: transparent; float: right;'>Ctrl+Enter</kbd>` },
...results,
];
break;
} }
]);
case SuggestionMode.SuggestCreateAndLink: {
results = [
{
action: SuggestionAction.CreateAndLinkNote,
noteTitle: trimmedTerm,
parentNoteId: "inbox",
highlightedNotePathTitle: t("note_autocomplete.create-and-link-note", { term: trimmedTerm }),
},
{
action: SuggestionAction.CreateAndLinkChildNote,
noteTitle: trimmedTerm,
parentNoteId: activeNoteId || "root",
highlightedNotePathTitle: t("note_autocomplete.create-and-link-child-note", { term: trimmedTerm }),
},
...results,
];
break;
}
default:
// CreateMode.None or undefined → no creation suggestions
break;
}
} }
if (term.match(/^[a-z]+:\/\/.+/i) && options.allowExternalLinks) { // --- Jump to Search Notes ---
if (trimmedTerm.length >= 1 && options.allowJumpToSearchNotes) {
results = [
...results,
{
action: SuggestionAction.SearchNotes,
noteTitle: trimmedTerm,
highlightedNotePathTitle: `${t("note_autocomplete.search-for", {
term: trimmedTerm,
})} <kbd style='color: var(--muted-text-color); background-color: transparent; float: right;'>Ctrl+Enter</kbd>`,
},
];
}
// --- External Link suggestion ---
if (/^[a-z]+:\/\/.+/i.test(trimmedTerm) && options.allowExternalLinks) {
results = [ results = [
{ {
action: "external-link", action: SuggestionAction.ExternalLink,
externalLink: term, externalLink: trimmedTerm,
highlightedNotePathTitle: t("note_autocomplete.insert-external-link", { term }) highlightedNotePathTitle: t("note_autocomplete.insert-external-link", { term: trimmedTerm }),
} as Suggestion },
].concat(results); ...results,
];
} }
cb(results); callback(results);
} }
function clearText($el: JQuery<HTMLElement>) { function clearText($el: JQuery<HTMLElement>) {
@ -198,6 +291,85 @@ function fullTextSearch($el: JQuery<HTMLElement>, options: Options) {
$el.autocomplete("val", searchString); $el.autocomplete("val", searchString);
} }
function renderCommandSuggestion(s: Suggestion): string {
const icon = s.icon || "bx bx-terminal";
const shortcut = s.commandShortcut
? `<kbd class="command-shortcut">${s.commandShortcut}</kbd>`
: "";
return `
<div class="command-suggestion">
<span class="command-icon ${icon}"></span>
<div class="command-content">
<div class="command-name">${s.highlightedNotePathTitle}</div>
${s.commandDescription ? `<div class="command-description">${s.commandDescription}</div>` : ""}
</div>
${shortcut}
</div>
`;
}
function renderNoteSuggestion(s: Suggestion): string {
const actionClass =
s.action === SuggestionAction.SearchNotes ? "search-notes-action" : "";
const iconClass = (() => {
switch (s.action) {
case SuggestionAction.SearchNotes:
return "bx bx-search";
case SuggestionAction.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) { function initNoteAutocomplete($el: JQuery<HTMLElement>, options?: Options) {
if ($el.hasClass("note-autocomplete-input")) { if ($el.hasClass("note-autocomplete-input")) {
// clear any event listener added in previous invocation of this function // clear any event listener added in previous invocation of this function
@ -283,24 +455,21 @@ function initNoteAutocomplete($el: JQuery<HTMLElement>, options?: Options) {
$el.autocomplete( $el.autocomplete(
{ {
...autocompleteOptions, ...autocompleteOptions,
appendTo: document.querySelector("body"), appendTo: document.body,
hint: false, hint: false,
autoselect: true, autoselect: true,
// openOnFocus has to be false, otherwise re-focus (after return from note type chooser dialog) forces
// re-querying of the autocomplete source which then changes the currently selected suggestion
openOnFocus: false, openOnFocus: false,
minLength: 0, minLength: 0,
tabAutocomplete: false tabAutocomplete: false,
}, },
[ [
{ {
source: (term, cb) => { source: (term, callback) => {
clearTimeout(debounceTimeoutId); clearTimeout(debounceTimeoutId);
debounceTimeoutId = setTimeout(() => { debounceTimeoutId = setTimeout(() => {
if (isComposingInput) { if (!isComposingInput) {
return; autocompleteSource(term, callback, options);
} }
autocompleteSource(term, cb, options);
}, searchDelay); }, searchDelay);
if (searchDelay === 0) { if (searchDelay === 0) {
@ -308,109 +477,85 @@ function initNoteAutocomplete($el: JQuery<HTMLElement>, options?: Options) {
} }
}, },
displayKey: "notePathTitle", displayKey: "notePathTitle",
templates: { templates: { suggestion: renderSuggestion },
suggestion: (suggestion) => { cache: false,
if (suggestion.action === "command") { },
let html = `<div class="command-suggestion">`;
html += `<span class="command-icon ${suggestion.icon || "bx bx-terminal"}"></span>`;
html += `<div class="command-content">`;
html += `<div class="command-name">${suggestion.highlightedNotePathTitle}</div>`;
if (suggestion.commandDescription) {
html += `<div class="command-description">${suggestion.commandDescription}</div>`;
}
html += `</div>`;
if (suggestion.commandShortcut) {
html += `<kbd class="command-shortcut">${suggestion.commandShortcut}</kbd>`;
}
html += '</div>';
return html;
}
// Add special class for search-notes action
const actionClass = suggestion.action === "search-notes" ? "search-notes-action" : "";
// Choose appropriate icon based on action
let iconClass = suggestion.icon ?? "bx bx-note";
if (suggestion.action === "search-notes") {
iconClass = "bx bx-search";
} else if (suggestion.action === "create-note") {
iconClass = "bx bx-plus";
} else if (suggestion.action === "external-link") {
iconClass = "bx bx-link-external";
}
// Simplified HTML structure without nested divs
let html = `<div class="note-suggestion ${actionClass}">`;
html += `<span class="icon ${iconClass}"></span>`;
html += `<span class="text">`;
html += `<span class="search-result-title">${suggestion.highlightedNotePathTitle}</span>`;
// Add attribute snippet inline if available
if (suggestion.highlightedAttributeSnippet) {
html += `<span class="search-result-attributes">${suggestion.highlightedAttributeSnippet}</span>`;
}
html += `</span>`;
html += `</div>`;
return html;
}
},
// we can't cache identical searches because notes can be created / renamed, new recent notes can be added
cache: false
}
] ]
); );
// TODO: Types fail due to "autocomplete:selected" not being registered in type definitions. // TODO: Types fail due to "autocomplete:selected" not being registered in type definitions.
($el as any).on("autocomplete:selected", async (event: Event, suggestion: Suggestion) => { ($el as any).on("autocomplete:selected", async (event: Event, suggestion: Suggestion) => {
if (suggestion.action === "command") { async function doCommand() {
$el.autocomplete("close"); $el.autocomplete("close");
$el.trigger("autocomplete:commandselected", [suggestion]); $el.trigger("autocomplete:commandselected", [suggestion]);
return;
} }
if (suggestion.action === "external-link") { async function doExternalLink() {
$el.setSelectedNotePath(null); $el.setSelectedNotePath(null);
$el.setSelectedExternalLink(suggestion.externalLink); $el.setSelectedExternalLink(suggestion.externalLink);
$el.autocomplete("val", suggestion.externalLink); $el.autocomplete("val", suggestion.externalLink);
$el.autocomplete("close");
$el.trigger("autocomplete:externallinkselected", [suggestion]);
}
async function resolveSuggestionNotePathUnderCurrentHoist(note: FNote) {
const hoisted = appContext.tabManager.getActiveContext()?.hoistedNoteId;
suggestion.notePath = note.getBestNotePathString(hoisted);
}
async function doSearchNotes() {
const searchString = suggestion.noteTitle;
appContext.triggerCommand("searchNotes", { searchString });
}
async function selectNoteFromAutocomplete(suggestion: Suggestion) {
$el.setSelectedNotePath(suggestion.notePath);
$el.setSelectedExternalLink(null);
$el.autocomplete("val", suggestion.noteTitle);
$el.autocomplete("close"); $el.autocomplete("close");
$el.trigger("autocomplete:externallinkselected", [suggestion]); $el.trigger("autocomplete:noteselected", [suggestion]);
return;
} }
if (suggestion.action === "create-note") { switch (suggestion.action) {
const { success, noteType, templateNoteId, notePath } = await noteCreateService.chooseNoteType(); case SuggestionAction.Command:
if (!success) { await doCommand();
return; return;
case SuggestionAction.ExternalLink:
await doExternalLink();
break;
case SuggestionAction.CreateNote:
case SuggestionAction.CreateAndLinkNote:
case SuggestionAction.CreateChildNote:
case SuggestionAction.CreateAndLinkChildNote: {
const createNoteAction = mapSuggestionToCreateNoteAction(
suggestion.action
)!;
const { note } = await noteCreateService.createNoteFromAction(
createNoteAction,
true,
suggestion.noteTitle,
suggestion.parentNoteId,
);
if (!note) break;
await resolveSuggestionNotePathUnderCurrentHoist(note);
await selectNoteFromAutocomplete(suggestion);
break;
} }
const { note } = await noteCreateService.createNote( notePath || suggestion.parentNoteId, {
title: suggestion.noteTitle,
activate: false,
type: noteType,
templateNoteId: templateNoteId
});
const hoistedNoteId = appContext.tabManager.getActiveContext()?.hoistedNoteId; case SuggestionAction.SearchNotes:
suggestion.notePath = note?.getBestNotePathString(hoistedNoteId); await doSearchNotes();
break;
default:
await selectNoteFromAutocomplete(suggestion);
} }
if (suggestion.action === "search-notes") {
const searchString = suggestion.noteTitle;
appContext.triggerCommand("searchNotes", { searchString });
return;
}
$el.setSelectedNotePath(suggestion.notePath);
$el.setSelectedExternalLink(null);
$el.autocomplete("val", suggestion.noteTitle);
$el.autocomplete("close");
$el.trigger("autocomplete:noteselected", [suggestion]);
}); });
$el.on("autocomplete:closed", () => { $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 FBranch from "../entities/fbranch.js";
import type { ChooseNoteTypeResponse } from "../widgets/dialogs/note_type_chooser.js"; import type { ChooseNoteTypeResponse } from "../widgets/dialogs/note_type_chooser.js";
import type { CKTextEditor } from "@triliumnext/ckeditor5"; import type { CKTextEditor } from "@triliumnext/ckeditor5";
import dateNoteService from "../services/date_notes.js";
import { 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; isProtected?: boolean;
saveSelection?: boolean; saveSelection?: boolean;
title?: string | null; title?: string | null;
@ -21,10 +76,34 @@ export interface CreateNoteOpts {
templateNoteId?: string; templateNoteId?: string;
activate?: boolean; activate?: boolean;
focus?: "title" | "content"; focus?: "title" | "content";
target?: string;
targetBranchId?: string;
textEditor?: CKTextEditor; 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 { interface Response {
// TODO: Deduplicate with server once we have client/server architecture. // TODO: Deduplicate with server once we have client/server architecture.
@ -37,7 +116,141 @@ interface DuplicateResponse {
note: FNote; 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( options = Object.assign(
{ {
activate: true, activate: true,
@ -61,7 +274,8 @@ async function createNote(parentNotePath: string | undefined, options: CreateNot
[options.title, options.content] = parseSelectedHtml(options.textEditor.getSelectedHtml()); [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) { if (options.type === "mermaid" && !options.content && !options.templateNoteId) {
options.content = `graph TD; options.content = `graph TD;
@ -71,7 +285,12 @@ async function createNote(parentNotePath: string | undefined, options: CreateNot
C-->D;`; 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, title: options.title,
content: options.content || "", content: options.content || "",
isProtected: options.isProtected, isProtected: options.isProtected,
@ -89,7 +308,7 @@ async function createNote(parentNotePath: string | undefined, options: CreateNot
const activeNoteContext = appContext.tabManager.getActiveContext(); const activeNoteContext = appContext.tabManager.getActiveContext();
if (activeNoteContext && options.activate) { if (activeNoteContext && options.activate) {
await activeNoteContext.setNote(`${parentNotePath}/${note.noteId}`); await activeNoteContext.setNote(`${parentNoteId}/${note.noteId}`);
if (options.focus === "title") { if (options.focus === "title") {
appContext.triggerEvent("focusAndSelectTitle", { isNewNote: true }); 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() { async function chooseNoteType() {
return new Promise<ChooseNoteTypeResponse>((res) => { return new Promise<ChooseNoteTypeResponse>((res) => {
appContext.triggerCommand("chooseNoteType", { callback: 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. */ /* If the first element is heading, parse it out and use it as a new heading. */
function parseSelectedHtml(selectedHtml: string) { function parseSelectedHtml(selectedHtml: string) {
const dom = $.parseHTML(selectedHtml); const dom = $.parseHTML(selectedHtml);
@ -146,7 +386,7 @@ function parseSelectedHtml(selectedHtml: string) {
} }
async function duplicateSubtree(noteId: string, parentNotePath: 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}`); const { note } = await server.post<DuplicateResponse>(`notes/${noteId}/duplicate/${parentNoteId}`);
await ws.waitForMaxKnownEntityChangeId(); await ws.waitForMaxKnownEntityChangeId();
@ -159,7 +399,6 @@ async function duplicateSubtree(noteId: string, parentNotePath: string) {
export default { export default {
createNote, createNote,
createNoteWithTypePrompt, createNoteFromAction,
duplicateSubtree, duplicateSubtree,
chooseNoteType
}; };

View File

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

View File

@ -27,6 +27,9 @@
--bs-body-bg: var(--main-background-color) !important; --bs-body-bg: var(--main-background-color) !important;
--ck-mention-list-max-height: 500px; --ck-mention-list-max-height: 500px;
--tn-modal-max-height: 90vh; --tn-modal-max-height: 90vh;
--tree-item-light-theme-max-color-lightness: 50;
--tree-item-dark-theme-min-color-lightness: 75;
} }
body#trilium-app.motion-disabled *, body#trilium-app.motion-disabled *,
@ -2579,4 +2582,12 @@ iframe.print-iframe {
position: relative; position: relative;
flex-grow: 1; flex-grow: 1;
width: 100%; width: 100%;
}
/* Calendar collection */
.calendar-view a.fc-timegrid-event,
.calendar-view a.fc-daygrid-event {
/* Workaround: set font weight only if the theme-next is not active */
font-weight: var(--root-background, 800);
} }

View File

@ -76,6 +76,9 @@
--mermaid-theme: dark; --mermaid-theme: dark;
--native-titlebar-background: #00000000; --native-titlebar-background: #00000000;
--calendar-coll-event-background-saturation: 30%;
--calendar-coll-event-background-lightness: 30%;
} }
body ::-webkit-calendar-picker-indicator { body ::-webkit-calendar-picker-indicator {

View File

@ -80,6 +80,9 @@ html {
--mermaid-theme: default; --mermaid-theme: default;
--native-titlebar-background: #ffffff00; --native-titlebar-background: #ffffff00;
--calendar-coll-event-background-lightness: 95%;
--calendar-coll-event-background-saturation: 80%;
} }
#left-pane .fancytree-node.tinted { #left-pane .fancytree-node.tinted {

View File

@ -271,11 +271,12 @@
--ck-editor-toolbar-button-on-shadow: 1px 1px 2px rgba(0, 0, 0, .75); --ck-editor-toolbar-button-on-shadow: 1px 1px 2px rgba(0, 0, 0, .75);
--ck-editor-toolbar-dropdown-button-open-background: #ffffff14; --ck-editor-toolbar-dropdown-button-open-background: #ffffff14;
--calendar-coll-event-background-saturation: 12%; --calendar-coll-event-background-saturation: 25%;
--calendar-coll-event-background-lightness: 21%; --calendar-coll-event-background-lightness: 20%;
--calendar-coll-event-background-color: #3c3c3c; --calendar-coll-event-background-color: #3c3c3c;
--calendar-coll-event-text-color: white; --calendar-coll-event-text-color: white;
--calendar-cell-event-hover-filter: brightness(1.25); --calendar-cell-event-hover-filter: brightness(1.25);
--calendar-coll-today-background-color: #ffffff08;
} }
/* /*

View File

@ -274,6 +274,7 @@
--calendar-coll-event-background-color: #eaeaea; --calendar-coll-event-background-color: #eaeaea;
--calendar-coll-event-text-color: black; --calendar-coll-event-text-color: black;
--calendar-cell-event-hover-filter: brightness(.95) saturate(1.25); --calendar-cell-event-hover-filter: brightness(.95) saturate(1.25);
--calendar-coll-today-background-color: #00000006;
} }
#left-pane .fancytree-node.tinted { #left-pane .fancytree-node.tinted {

View File

@ -1897,7 +1897,10 @@
}, },
"note_autocomplete": { "note_autocomplete": {
"search-for": "Search for \"{{term}}\"", "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}}\"", "insert-external-link": "Insert external link to \"{{term}}\"",
"clear-text-field": "Clear text field", "clear-text-field": "Clear text field",
"show-recent-notes": "Show recent notes", "show-recent-notes": "Show recent notes",

View File

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

View File

@ -7,7 +7,7 @@ import branches from "../../../services/branches";
import { executeBulkActions } from "../../../services/bulk_action"; import { executeBulkActions } from "../../../services/bulk_action";
import froca from "../../../services/froca"; import froca from "../../../services/froca";
import { t } from "../../../services/i18n"; 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 server from "../../../services/server";
import { ColumnMap } from "./data"; import { ColumnMap } from "./data";
@ -39,9 +39,11 @@ export default class BoardApi {
const parentNotePath = this.parentNote.noteId; const parentNotePath = this.parentNote.noteId;
// Create a new note as a child of the parent note // Create a new note as a child of the parent note
const { note: newNote, branch: newBranch } = await note_create.createNote(parentNotePath, { const { note: newNote, branch: newBranch } = await note_create.createNote({
target: "into",
parentNoteLink: parentNotePath,
activate: false, activate: false,
title title,
}); });
if (newNote && newBranch) { if (newNote && newBranch) {
@ -139,13 +141,17 @@ export default class BoardApi {
async insertRowAtPosition( async insertRowAtPosition(
column: string, column: string,
relativeToBranchId: string, relativeToBranchId: string,
direction: "before" | "after") { direction: "before" | "after"
const { note, branch } = await note_create.createNote(this.parentNote.noteId, { ) {
activate: false, const { note, branch } = await note_create.createNote(
targetBranchId: relativeToBranchId, {
target: direction, target: direction,
title: t("board_view.new-item") parentNoteLink: this.parentNote.noteId,
}); activate: false,
targetBranchId: relativeToBranchId,
title: t("board_view.new-item"),
}
);
if (!note || !branch) { if (!note || !branch) {
throw new Error("Failed to create note"); 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"), title: t("board_view.insert-above"),
uiIcon: "bx bx-list-plus", uiIcon: "bx bx-list-plus",
handler: () => api.insertRowAtPosition(column, branchId, "before") handler: () => api.insertRowAtPosition(
column,
branchId,
"before")
}, },
{ {
title: t("board_view.insert-below"), title: t("board_view.insert-below"),
uiIcon: "bx bx-empty", uiIcon: "bx bx-empty",
handler: () => api.insertRowAtPosition(column, branchId, "after") handler: () => api.insertRowAtPosition(
column,
branchId,
"after")
}, },
{ kind: "separator" }, { kind: "separator" },
{ {

View File

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

View File

@ -81,7 +81,6 @@ export async function buildEventsForCalendar(note: FNote, e: EventSourceFuncArg)
export async function buildEvent(note: FNote, { startDate, endDate, startTime, endTime, isArchived }: Event) { export async function buildEvent(note: FNote, { startDate, endDate, startTime, endTime, isArchived }: Event) {
const customTitleAttributeName = note.getLabelValue("calendar:title"); const customTitleAttributeName = note.getLabelValue("calendar:title");
const titles = await parseCustomTitle(customTitleAttributeName, note); const titles = await parseCustomTitle(customTitleAttributeName, note);
const color = note.getLabelValue("calendar:color") ?? note.getLabelValue("color");
const colorClass = note.getColorClass(); const colorClass = note.getColorClass();
const events: EventInput[] = []; const events: EventInput[] = [];
@ -110,7 +109,6 @@ export async function buildEvent(note: FNote, { startDate, endDate, startTime, e
start: startDate, start: startDate,
url: `#${note.noteId}?popup`, url: `#${note.noteId}?popup`,
noteId: note.noteId, noteId: note.noteId,
color: color ?? undefined,
iconClass: note.getLabelValue("iconClass"), iconClass: note.getLabelValue("iconClass"),
promotedAttributes: displayedAttributesData, promotedAttributes: displayedAttributesData,
className: clsx({archived: isArchived}, colorClass) className: clsx({archived: isArchived}, colorClass)

View File

@ -1,8 +1,19 @@
:root {
/* Default values to be overridden by themes */
--calendar-coll-event-background-lightness: 95%;
--calendar-coll-event-background-saturation: 80%;
--calendar-coll-event-background-color: var(--accented-background-color);
--calendar-coll-event-text-color: var(--primary-button-text-color);
--calendar-cell-event-hover-filter: none;
--calendar-coll-today-background-color: var(--more-accented-background-color);
}
.calendar-view { .calendar-view {
--fc-event-border-color: var(--calendar-coll-event-text-color); --fc-event-border-color: var(--calendar-coll-event-text-color);
--fc-event-bg-color: var(--calendar-coll-event-background-color); --fc-event-bg-color: var(--calendar-coll-event-background-color);
--fc-event-text-color: var(--calendar-coll-event-text-color); --fc-event-text-color: var(--calendar-coll-event-text-color);
--fc-event-selected-overlay-color: transparent; --fc-event-selected-overlay-color: transparent;
--fc-today-bg-color: var(--calendar-coll-today-background-color);
overflow: hidden; overflow: hidden;
position: relative; position: relative;
@ -12,8 +23,9 @@
padding: 10px; padding: 10px;
} }
.calendar-view a { .calendar-view a,
color: unset; :root .calendar-view a.fc-daygrid-event:hover {
color: var(--fc-event-text-color);
} }
.search-result-widget-content .calendar-view { .search-result-widget-content .calendar-view {
@ -85,17 +97,25 @@ body.desktop:not(.zen) .calendar-view .calendar-header {
/* #region Events */ /* #region Events */
.calendar-view a.fc-timegrid-event, .calendar-view a.fc-timegrid-event,
.calendar-view a.fc-daygrid-event { .calendar-view a.fc-daygrid-event,
.fc-daygrid-dot-event .fc-event-title {
font-weight: 500; font-weight: 500;
} }
.calendar-view a.fc-timegrid-event, .calendar-view a.fc-timegrid-event:focus-visible,
.calendar-view a.fc-daygrid-event:not(.fc-daygrid-dot-event) { .calendar-view a.fc-daygrid-event:focus-visible {
--border-color: transparent; outline: none;
}
border-width: 2px 2px 2px 4px; .calendar-view a.fc-timegrid-event,
.calendar-view a.fc-daygrid-event {
--border-color: transparent;
border: 2px solid;
border-left-width: 4px;
border-color: var(--border-color) var(--border-color) var(--border-color) border-color: var(--border-color) var(--border-color) var(--border-color)
var(--fc-event-text-color) !important; var(--fc-event-text-color) !important;
background: var(--fc-event-bg-color) !important;
padding-left: 8px; padding-left: 8px;
} }
@ -115,8 +135,8 @@ body.desktop:not(.zen) .calendar-view .calendar-header {
color: currentColor; color: currentColor;
} }
.fc-timegrid-event.with-hue, .calendar-view .fc-timegrid-event.with-hue,
.fc-daygrid-event:not(.fc-daygrid-dot-event).with-hue { .calendar-view .fc-daygrid-event.with-hue {
--fc-event-text-color: var(--custom-color); --fc-event-text-color: var(--custom-color);
background: hsl(var(--custom-color-hue), background: hsl(var(--custom-color-hue),
@ -124,8 +144,12 @@ body.desktop:not(.zen) .calendar-view .calendar-header {
var(--calendar-coll-event-background-lightness)) !important; var(--calendar-coll-event-background-lightness)) !important;
} }
.fc-event-time { .calendar-view .fc-event-time {
opacity: .75; opacity: .75;
} }
.fc-daygrid-event-dot {
display: none;
}
/* #endregion */ /* #endregion */

View File

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

View File

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

View File

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

View File

@ -5,7 +5,7 @@ import FormRadioGroup from "../react/FormRadioGroup";
import NoteAutocomplete from "../react/NoteAutocomplete"; import NoteAutocomplete from "../react/NoteAutocomplete";
import { useRef, useState, useEffect } from "preact/hooks"; import { useRef, useState, useEffect } from "preact/hooks";
import tree from "../../services/tree"; import tree from "../../services/tree";
import note_autocomplete, { Suggestion } from "../../services/note_autocomplete"; import note_autocomplete, { SuggestionMode, Suggestion } from "../../services/note_autocomplete";
import { logError } from "../../services/ws"; import { logError } from "../../services/ws";
import FormGroup from "../react/FormGroup.js"; import FormGroup from "../react/FormGroup.js";
import { refToJQuerySelector } from "../react/react_utils"; import { refToJQuerySelector } from "../react/react_utils";
@ -58,7 +58,7 @@ export default function AddLinkDialog() {
} }
if (suggestion.notePath) { if (suggestion.notePath) {
const noteId = tree.getNoteIdFromUrl(suggestion.notePath); const noteId = tree.getNoteIdFromLink(suggestion.notePath);
if (noteId) { if (noteId) {
setDefaultLinkTitle(noteId); setDefaultLinkTitle(noteId);
} }
@ -133,7 +133,7 @@ export default function AddLinkDialog() {
onChange={setSuggestion} onChange={setSuggestion}
opts={{ opts={{
allowExternalLinks: true, allowExternalLinks: true,
allowCreatingNotes: true suggestionMode: SuggestionMode.SuggestCreateAndLink,
}} }}
/> />
</FormGroup> </FormGroup>

View File

@ -5,7 +5,7 @@ import FormRadioGroup from "../react/FormRadioGroup";
import Modal from "../react/Modal"; import Modal from "../react/Modal";
import NoteAutocomplete from "../react/NoteAutocomplete"; import NoteAutocomplete from "../react/NoteAutocomplete";
import Button from "../react/Button"; import Button from "../react/Button";
import { Suggestion, triggerRecentNotes } from "../../services/note_autocomplete"; import { SuggestionMode, Suggestion, triggerRecentNotes } from "../../services/note_autocomplete.js";
import tree from "../../services/tree"; import tree from "../../services/tree";
import froca from "../../services/froca"; import froca from "../../services/froca";
import { useTriliumEvent } from "../react/hooks"; import { useTriliumEvent } from "../react/hooks";
@ -50,7 +50,7 @@ export default function IncludeNoteDialog() {
inputRef={autoCompleteRef} inputRef={autoCompleteRef}
opts={{ opts={{
hideGoToSelectedNoteButton: true, hideGoToSelectedNoteButton: true,
allowCreatingNotes: true suggestionMode: SuggestionMode.SuggestCreateOnly,
}} }}
/> />
</FormGroup> </FormGroup>
@ -71,7 +71,7 @@ export default function IncludeNoteDialog() {
} }
async function includeNote(notePath: string, editorApi: CKEditorApi, boxSize: BoxSize) { async function includeNote(notePath: string, editorApi: CKEditorApi, boxSize: BoxSize) {
const noteId = tree.getNoteIdFromUrl(notePath); const noteId = tree.getNoteIdFromLink(notePath);
if (!noteId) { if (!noteId) {
return; return;
} }

View File

@ -3,7 +3,7 @@ import Button from "../react/Button";
import NoteAutocomplete from "../react/NoteAutocomplete"; import NoteAutocomplete from "../react/NoteAutocomplete";
import { t } from "../../services/i18n"; import { t } from "../../services/i18n";
import { useRef, useState } from "preact/hooks"; import { useRef, useState } from "preact/hooks";
import note_autocomplete, { Suggestion } from "../../services/note_autocomplete"; import note_autocomplete, { SuggestionMode, Suggestion } from "../../services/note_autocomplete.js";
import appContext from "../../components/app_context"; import appContext from "../../components/app_context";
import commandRegistry from "../../services/command_registry"; import commandRegistry from "../../services/command_registry";
import { refToJQuerySelector } from "../react/react_utils"; import { refToJQuerySelector } from "../react/react_utils";
@ -12,34 +12,53 @@ import shortcutService from "../../services/shortcuts";
const KEEP_LAST_SEARCH_FOR_X_SECONDS = 120; const KEEP_LAST_SEARCH_FOR_X_SECONDS = 120;
type Mode = "last-search" | "recent-notes" | "commands"; enum Mode {
LastSearch,
RecentNotes,
Commands,
}
export default function JumpToNoteDialogComponent() { export default function JumpToNoteDialogComponent() {
const [ mode, setMode ] = useState<Mode>(); const [ mode, setMode ] = useState<Mode>();
const [ lastOpenedTs, setLastOpenedTs ] = useState<number>(0); const [ lastOpenedTs, setLastOpenedTs ] = useState<number>(0);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const autocompleteRef = useRef<HTMLInputElement>(null); const autocompleteRef = useRef<HTMLInputElement>(null);
const [ isCommandMode, setIsCommandMode ] = useState(mode === "commands"); const [ isCommandMode, setIsCommandMode ] = useState(mode === Mode.Commands);
const [ initialText, setInitialText ] = useState(isCommandMode ? "> " : ""); const [ initialText, setInitialText ] = useState(isCommandMode ? "> " : "");
const actualText = useRef<string>(initialText); const actualText = useRef<string>(initialText);
const [ shown, setShown ] = useState(false); const [ shown, setShown ] = useState(false);
async function openDialog(commandMode: boolean) { async function openDialog(requestedMode: Mode) {
let newMode: Mode; let newMode: Mode;
let initialText = ""; let initialText = "";
if (commandMode) { switch (requestedMode) {
newMode = "commands"; case Mode.Commands:
initialText = ">"; newMode = Mode.Commands;
} else if (Date.now() - lastOpenedTs <= KEEP_LAST_SEARCH_FOR_X_SECONDS * 1000 && actualText.current) { initialText = ">";
// if you open the Jump To dialog soon after using it previously, it can often mean that you break;
// actually want to search for the same thing (e.g., you opened the wrong note at first try)
// so we'll keep the content. case Mode.LastSearch:
// if it's outside of this time limit, then we assume it's a completely new search and show recent notes instead. // if you open the Jump To dialog soon after using it previously, it can often mean that you
newMode = "last-search"; // actually want to search for the same thing (e.g., you opened the wrong note at first try)
initialText = actualText.current; // so we'll keep the content.
} else { // if it's outside of this time limit, then we assume it's a completely new search and show recent notes instead.
newMode = "recent-notes"; if (Date.now() - lastOpenedTs <= KEEP_LAST_SEARCH_FOR_X_SECONDS * 1000 && actualText.current) {
newMode = Mode.LastSearch;
initialText = actualText.current;
} else {
newMode = Mode.RecentNotes;
}
break;
default:
if (Date.now() - lastOpenedTs <= KEEP_LAST_SEARCH_FOR_X_SECONDS * 1000 && actualText.current) {
newMode = Mode.LastSearch;
initialText = actualText.current;
} else {
newMode = Mode.RecentNotes;
}
break;
} }
if (mode !== newMode) { if (mode !== newMode) {
@ -51,14 +70,14 @@ export default function JumpToNoteDialogComponent() {
setLastOpenedTs(Date.now()); setLastOpenedTs(Date.now());
} }
useTriliumEvent("jumpToNote", () => openDialog(false)); useTriliumEvent("jumpToNote", () => openDialog(Mode.RecentNotes));
useTriliumEvent("commandPalette", () => openDialog(true)); useTriliumEvent("commandPalette", () => openDialog(Mode.Commands));
async function onItemSelected(suggestion?: Suggestion | null) { async function onItemSelected(suggestion?: Suggestion | null) {
if (!suggestion) { if (!suggestion) {
return; return;
} }
setShown(false); setShown(false);
if (suggestion.notePath) { if (suggestion.notePath) {
appContext.tabManager.getActiveContext()?.setNote(suggestion.notePath); appContext.tabManager.getActiveContext()?.setNote(suggestion.notePath);
@ -70,12 +89,12 @@ export default function JumpToNoteDialogComponent() {
function onShown() { function onShown() {
const $autoComplete = refToJQuerySelector(autocompleteRef); const $autoComplete = refToJQuerySelector(autocompleteRef);
switch (mode) { switch (mode) {
case "last-search": case Mode.LastSearch:
break; break;
case "recent-notes": case Mode.RecentNotes:
note_autocomplete.showRecentNotes($autoComplete); note_autocomplete.showRecentNotes($autoComplete);
break; break;
case "commands": case Mode.Commands:
note_autocomplete.showAllCommands($autoComplete); note_autocomplete.showAllCommands($autoComplete);
break; break;
} }
@ -83,7 +102,7 @@ export default function JumpToNoteDialogComponent() {
$autoComplete $autoComplete
.trigger("focus") .trigger("focus")
.trigger("select"); .trigger("select");
// Add keyboard shortcut for full search // Add keyboard shortcut for full search
shortcutService.bindElShortcut($autoComplete, "ctrl+return", () => { shortcutService.bindElShortcut($autoComplete, "ctrl+return", () => {
if (!isCommandMode) { if (!isCommandMode) {
@ -91,7 +110,7 @@ export default function JumpToNoteDialogComponent() {
} }
}); });
} }
async function showInFullSearch() { async function showInFullSearch() {
try { try {
setShown(false); setShown(false);
@ -116,7 +135,7 @@ export default function JumpToNoteDialogComponent() {
container={containerRef} container={containerRef}
text={initialText} text={initialText}
opts={{ opts={{
allowCreatingNotes: true, suggestionMode: SuggestionMode.SuggestCreateOnly,
hideGoToSelectedNoteButton: true, hideGoToSelectedNoteButton: true,
allowJumpToSearchNotes: true, allowJumpToSearchNotes: true,
isCommandPalette: true isCommandPalette: true
@ -129,9 +148,9 @@ export default function JumpToNoteDialogComponent() {
/>} />}
onShown={onShown} onShown={onShown}
onHidden={() => setShown(false)} onHidden={() => setShown(false)}
footer={!isCommandMode && <Button footer={!isCommandMode && <Button
className="show-in-full-text-button" className="show-in-full-text-button"
text={t("jump_to_note.search_button")} text={t("jump_to_note.search_button")}
keyboardShortcut="Ctrl+Enter" keyboardShortcut="Ctrl+Enter"
onClick={showInFullSearch} onClick={showInFullSearch}
/>} />}

View File

@ -7,7 +7,7 @@ import { useEffect, useState } from "preact/hooks";
import note_types from "../../services/note_types"; import note_types from "../../services/note_types";
import { MenuCommandItem, MenuItem } from "../../menus/context_menu"; import { MenuCommandItem, MenuItem } from "../../menus/context_menu";
import { TreeCommandNames } from "../../menus/tree_context_menu"; import { TreeCommandNames } from "../../menus/tree_context_menu";
import { Suggestion } from "../../services/note_autocomplete"; import { SuggestionMode, Suggestion } from "../../services/note_autocomplete.js";
import Badge from "../react/Badge"; import Badge from "../react/Badge";
import { useTriliumEvent } from "../react/hooks"; import { useTriliumEvent } from "../react/hooks";
@ -76,6 +76,7 @@ export default function NoteTypeChooserDialogComponent() {
onHidden={() => { onHidden={() => {
callback?.({ success: false }); callback?.({ success: false });
setShown(false); setShown(false);
setParentNote(null);
}} }}
show={shown} show={shown}
stackable stackable
@ -85,7 +86,7 @@ export default function NoteTypeChooserDialogComponent() {
onChange={setParentNote} onChange={setParentNote}
placeholder={t("note_type_chooser.search_placeholder")} placeholder={t("note_type_chooser.search_placeholder")}
opts={{ opts={{
allowCreatingNotes: false, suggestionMode: SuggestionMode.SuggestNothing,
hideGoToSelectedNoteButton: true, hideGoToSelectedNoteButton: true,
allowJumpToSearchNotes: false, allowJumpToSearchNotes: false,
}} }}

View File

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

View File

@ -29,7 +29,14 @@ export default function MobileDetailMenu() {
], ],
selectMenuItemHandler: async ({ command }) => { selectMenuItemHandler: async ({ command }) => {
if (command === "insertChildNote") { 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") { } else if (command === "delete") {
const notePath = appContext.tabManager.getActiveContextNotePath(); const notePath = appContext.tabManager.getActiveContextNotePath();
if (!notePath) { if (!notePath) {

View File

@ -224,7 +224,13 @@ export default class NoteTreeWidget extends NoteContextAwareWidget {
} else if (target.classList.contains("add-note-button")) { } else if (target.classList.contains("add-note-button")) {
const node = $.ui.fancytree.getNode(e as unknown as Event); const node = $.ui.fancytree.getNode(e as unknown as Event);
const parentNotePath = treeService.getNotePath(node); const parentNotePath = treeService.getNotePath(node);
noteCreateService.createNote(parentNotePath, { isProtected: node.data.isProtected }); noteCreateService.createNote(
{
target: "into",
parentNoteLink: parentNotePath,
isProtected: node.data.isProtected
},
);
} else if (target.classList.contains("enter-workspace-button")) { } else if (target.classList.contains("enter-workspace-button")) {
const node = $.ui.fancytree.getNode(e as unknown as Event); const node = $.ui.fancytree.getNode(e as unknown as Event);
this.triggerCommand("hoistNote", { noteId: node.data.noteId }); this.triggerCommand("hoistNote", { noteId: node.data.noteId });
@ -1403,10 +1409,10 @@ export default class NoteTreeWidget extends NoteContextAwareWidget {
let node: Fancytree.FancytreeNode | null | undefined = await this.expandToNote(activeNotePath, false); 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, // 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 // 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) { if (noteId) {
const notesById = this.getNodesByNoteId(noteId); const notesById = this.getNodesByNoteId(noteId);
@ -1836,9 +1842,13 @@ export default class NoteTreeWidget extends NoteContextAwareWidget {
const node = this.getActiveNode(); const node = this.getActiveNode();
if (!node) return; if (!node) return;
const notePath = treeService.getNotePath(node); const notePath = treeService.getNotePath(node);
noteCreateService.createNote(notePath, { noteCreateService.createNote(
isProtected: node.data.isProtected {
}); target: "into",
parentNoteLink: notePath,
isProtected: node.data.isProtected
}
)
} }
}), }),
new TouchBar.TouchBarButton({ new TouchBar.TouchBarButton({

View File

@ -2,7 +2,7 @@ import { MutableRef, useEffect, useImperativeHandle, useMemo, useRef, useState }
import { AttributeEditor as CKEditorAttributeEditor, MentionFeed, ModelElement, ModelNode, ModelPosition } from "@triliumnext/ckeditor5"; import { AttributeEditor as CKEditorAttributeEditor, MentionFeed, ModelElement, ModelNode, ModelPosition } from "@triliumnext/ckeditor5";
import { t } from "../../../services/i18n"; import { t } from "../../../services/i18n";
import server from "../../../services/server"; import server from "../../../services/server";
import note_autocomplete, { Suggestion } from "../../../services/note_autocomplete"; import note_autocomplete, { SuggestionMode, Suggestion } from "../../../services/note_autocomplete.js";
import CKEditor, { CKEditorApi } from "../../react/CKEditor"; import CKEditor, { CKEditorApi } from "../../react/CKEditor";
import { useLegacyImperativeHandlers, useLegacyWidget, useTooltip, useTriliumEvent, useTriliumOption } from "../../react/hooks"; import { useLegacyImperativeHandlers, useLegacyWidget, useTooltip, useTriliumEvent, useTriliumOption } from "../../react/hooks";
import FAttribute from "../../../entities/fattribute"; import FAttribute from "../../../entities/fattribute";
@ -20,6 +20,7 @@ import type { CommandData, FilteredCommandNames } from "../../../components/app_
import { AttributeType } from "@triliumnext/commons"; import { AttributeType } from "@triliumnext/commons";
import attributes from "../../../services/attributes"; import attributes from "../../../services/attributes";
import note_create from "../../../services/note_create"; import note_create from "../../../services/note_create";
import { CreateNoteAction } from "@triliumnext/commons";
type AttributeCommandNames = FilteredCommandNames<CommandData>; type AttributeCommandNames = FilteredCommandNames<CommandData>;
@ -33,7 +34,7 @@ const HELP_TEXT = `
const mentionSetup: MentionFeed[] = [ const mentionSetup: MentionFeed[] = [
{ {
marker: "@", marker: "@",
feed: (queryText) => note_autocomplete.autocompleteSourceForCKEditor(queryText), feed: (queryText) => note_autocomplete.autocompleteSourceForCKEditor(queryText, SuggestionMode.SuggestCreateAndLink),
itemRenderer: (_item) => { itemRenderer: (_item) => {
const item = _item as Suggestion; const item = _item as Suggestion;
const itemElement = document.createElement("button"); const itemElement = document.createElement("button");
@ -247,16 +248,18 @@ export default function AttributeEditor({ api, note, componentId, notePath, ntxI
$el.text(title); $el.text(title);
}, },
createNoteForReferenceLink: async (title: string) => { createNoteFromCkEditor: async (
let result; title: string,
if (notePath) { parentNotePath: string | undefined,
result = await note_create.createNoteWithTypePrompt(notePath, { action: CreateNoteAction
activate: false, ): Promise<string> => {
title: title const { note } = await note_create.createNoteFromAction(
}); action,
} true,
title,
return result?.note?.getBestNotePathString(); parentNotePath,
);
return note?.getBestNotePathString() ?? "";
} }
}), [ notePath ])); }), [ notePath ]));

View File

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

View File

@ -16,7 +16,7 @@ import note_create from "../../../services/note_create";
import TouchBar, { TouchBarButton, TouchBarGroup, TouchBarSegmentedControl } from "../../react/TouchBar"; import TouchBar, { TouchBarButton, TouchBarGroup, TouchBarSegmentedControl } from "../../react/TouchBar";
import { RefObject } from "preact"; import { RefObject } from "preact";
import { buildSelectedBackgroundColor } from "../../../components/touch_bar"; import { buildSelectedBackgroundColor } from "../../../components/touch_bar";
import { deferred } from "@triliumnext/commons"; import { CreateNoteAction, deferred } from "@triliumnext/commons";
import { t } from "../../../services/i18n"; import { t } from "../../../services/i18n";
/** /**
@ -115,17 +115,18 @@ export default function EditableText({ note, parentComponent, ntxId, noteContext
}, },
loadIncludedNote, loadIncludedNote,
// Creating notes in @-completion // Creating notes in @-completion
async createNoteForReferenceLink(title: string) { async createNoteFromCkEditor (
const notePath = noteContext?.notePath; title: string,
if (!notePath) return; parentNotePath: string | undefined,
action: CreateNoteAction
const resp = await note_create.createNoteWithTypePrompt(notePath, { ): Promise<string> {
activate: false, const { note }= await note_create.createNoteFromAction(
title: title action,
}); true,
title,
if (!resp || !resp.note) return; parentNotePath,
return resp.note.getBestNotePathString(); )
return note?.getBestNotePathString() ?? "";
}, },
// Keyboard shortcut // Keyboard shortcut
async followLinkUnderCursorCommand() { async followLinkUnderCursorCommand() {
@ -162,7 +163,9 @@ export default function EditableText({ note, parentComponent, ntxId, noteContext
// without await as this otherwise causes deadlock through component mutex // without await as this otherwise causes deadlock through component mutex
const parentNotePath = appContext.tabManager.getActiveContextNotePath(); const parentNotePath = appContext.tabManager.getActiveContextNotePath();
if (noteContext && parentNotePath) { if (noteContext && parentNotePath) {
note_create.createNote(parentNotePath, { note_create.createNote({
parentNoteLink: parentNotePath,
target: "into",
isProtected: note.isProtected, isProtected: note.isProtected,
saveSelection: true, saveSelection: true,
textEditor: await noteContext?.getTextEditor() 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 { copyTextWithToast } from "../../../services/clipboard_ext.js";
import { t } from "../../../services/i18n.js"; import { t } from "../../../services/i18n.js";
import { getMermaidConfig } from "../../../services/mermaid.js"; import { getMermaidConfig } from "../../../services/mermaid.js";
import noteAutocompleteService, { type Suggestion } from "../../../services/note_autocomplete.js"; import noteAutocompleteService, { SuggestionMode, type Suggestion } from "../../../services/note_autocomplete.js";
import mimeTypesService from "../../../services/mime_types.js"; import mimeTypesService from "../../../services/mime_types.js";
import { normalizeMimeTypeForCKEditor } from "@triliumnext/commons"; import { normalizeMimeTypeForCKEditor } from "@triliumnext/commons";
import { buildToolbarConfig } from "./toolbar.js"; import { buildToolbarConfig } from "./toolbar.js";
@ -181,7 +181,7 @@ export async function buildConfig(opts: BuildEditorOptions): Promise<EditorConfi
feeds: [ feeds: [
{ {
marker: "@", marker: "@",
feed: (queryText: string) => noteAutocompleteService.autocompleteSourceForCKEditor(queryText), feed: (queryText: string) => noteAutocompleteService.autocompleteSourceForCKEditor(queryText, SuggestionMode.SuggestCreateAndLink),
itemRenderer: (item) => { itemRenderer: (item) => {
const itemElement = document.createElement("button"); const itemElement = document.createElement("button");

View File

@ -1,8 +1,9 @@
import path from "path"; import path, { join } from "path";
import fs from "fs-extra"; import fs from "fs-extra";
import { LOCALES } from "@triliumnext/commons"; import { LOCALES } from "@triliumnext/commons";
import { PRODUCT_NAME } from "../src/app-info.js"; import { PRODUCT_NAME } from "../src/app-info.js";
import type { ForgeConfig } from "@electron-forge/shared-types"; import type { ForgeConfig } from "@electron-forge/shared-types";
import { existsSync } from "fs";
const ELECTRON_FORGE_DIR = __dirname; const ELECTRON_FORGE_DIR = __dirname;
@ -228,8 +229,22 @@ const config: ForgeConfig = {
// Ensure all locales that should be kept are actually present. // Ensure all locales that should be kept are actually present.
for (const locale of localesToKeep) { for (const locale of localesToKeep) {
if (!keptLocales.has(locale)) { if (!keptLocales.has(locale)) {
console.error(`Locale ${locale} was not found in the packaged app.`); throw new Error(`Locale ${locale} was not found in the packaged app.`);
process.exit(1); }
}
// Check that the bettersqlite3 binary has the right architecture.
if (packageResult.platform === "linux" && packageResult.arch === "arm64") {
for (const outputPath of packageResult.outputPaths) {
const binaryPath = join(outputPath, "resources/app.asar.unpacked/node_modules/better-sqlite3/build/Release/better_sqlite3.node");
if (!existsSync(binaryPath)) {
throw new Error(`[better-sqlite3] Unable to find .node file at ${binaryPath}`);
}
const actualArch = getELFArch(binaryPath);
if (actualArch !== "ARM64") {
throw new Error(`[better-sqlite3] Expected ARM64 architecture but got ${actualArch} at: ${binaryPath}`);
}
} }
} }
}, },
@ -284,4 +299,20 @@ function getExtraResourcesForPlatform() {
return resources; return resources;
} }
function getELFArch(file: string) {
const buf = fs.readFileSync(file);
if (buf[0] !== 0x7f || buf[1] !== 0x45 || buf[2] !== 0x4c || buf[3] !== 0x46) {
throw new Error("Not an ELF file");
}
const eiClass = buf[4]; // 1=32-bit, 2=64-bit
const eiMachine = buf[18]; // architecture code
if (eiMachine === 0x3E) return 'x86-64';
if (eiMachine === 0xB7) return 'ARM64';
return 'other';
}
export default config; export default config;

View File

@ -8,6 +8,7 @@ interface GotoOpts {
} }
const BASE_URL = "http://127.0.0.1:8082"; const BASE_URL = "http://127.0.0.1:8082";
const NUM_OF_CREATE_NOTE_OPTIONS = 2;
interface DropdownLocator extends Locator { interface DropdownLocator extends Locator {
selectOptionByText: (text: string) => Promise<void>; selectOptionByText: (text: string) => Promise<void>;
@ -73,7 +74,8 @@ export default class App {
const resultsSelector = this.currentNoteSplit.locator(".note-detail-empty-results"); const resultsSelector = this.currentNoteSplit.locator(".note-detail-empty-results");
await expect(resultsSelector).toContainText(noteTitle); await expect(resultsSelector).toContainText(noteTitle);
await resultsSelector.locator(".aa-suggestion", { hasText: 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(); .click();
} }

View File

@ -1,4 +1,5 @@
import "ckeditor5"; import "ckeditor5";
import { type CreateNoteAction } from "@triliumnext/commons"
declare global { declare global {
interface Component { interface Component {
@ -7,7 +8,8 @@ declare global {
interface EditorComponent extends Component { interface EditorComponent extends Component {
loadReferenceLinkTitle($el: JQuery<HTMLElement>, href: string): Promise<void>; loadReferenceLinkTitle($el: JQuery<HTMLElement>, href: string): Promise<void>;
createNoteForReferenceLink(title: string): Promise<string>; // Must Return Note Path
createNoteFromCkEditor(title: string, parentNotePath: string | undefined, action: CreateNoteAction): Promise<string>;
loadIncludedNote(noteId: string, $el: JQuery<HTMLElement>): void; loadIncludedNote(noteId: string, $el: JQuery<HTMLElement>): void;
} }

View File

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

View File

@ -10,4 +10,5 @@ export * from "./lib/server_api.js";
export * from "./lib/shared_constants.js"; export * from "./lib/shared_constants.js";
export * from "./lib/ws_api.js"; export * from "./lib/ws_api.js";
export * from "./lib/attribute_names.js"; export * from "./lib/attribute_names.js";
export * from "./lib/create_note_actions.js";
export * from "./lib/utils.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"
}