diff --git a/apps/client/src/services/note_create.spec.ts b/apps/client/src/services/note_create.spec.ts
index 49cb9006f6..4cbaf07a7a 100644
--- a/apps/client/src/services/note_create.spec.ts
+++ b/apps/client/src/services/note_create.spec.ts
@@ -205,22 +205,57 @@ describe("createNote", () => {
);
});
- it("disables saveSelection when the active context note type is not text", async () => {
+ it("saves the selection of the editor it was handed, whatever the active context shows", async () => {
+ // The editor handing us the selection belongs to a split / the quick editor / an embedded
+ // pane, so the active context is a different note of a different type entirely (#9890).
setActiveContext(true);
- tabManager.activeNoteType = "code";
+ tabManager.activeNoteType = "book";
const removeSelection = vi.fn();
const textEditor = {
- getSelectedHtml: vi.fn(() => "
x
"),
+ getSelectedHtml: vi.fn(() => "Heading
body
"),
removeSelection
} as any;
await noteCreateService.createNote("root", { saveSelection: true, textEditor });
- // selection parsing was skipped, so getSelectedHtml/removeSelection untouched
- expect(textEditor.getSelectedHtml).not.toHaveBeenCalled();
+ expect(server.post).toHaveBeenCalledWith(
+ expect.any(String),
+ expect.objectContaining({ title: "Heading", content: "body
" }),
+ undefined
+ );
+ expect(removeSelection).toHaveBeenCalled();
+ });
+
+ it("creates a plain empty note without touching the source when there is nothing selected", async () => {
+ setActiveContext(true);
+ const removeSelection = vi.fn();
+ const textEditor = {
+ getSelectedHtml: vi.fn(() => ""),
+ removeSelection
+ } as any;
+
+ await noteCreateService.createNote("root", { saveSelection: true, textEditor });
+
+ expect(server.post).toHaveBeenCalledWith(
+ expect.any(String),
+ expect.objectContaining({ title: undefined, content: "" }),
+ undefined
+ );
expect(removeSelection).not.toHaveBeenCalled();
});
+ it("does not attempt to save a selection when no text editor was passed", async () => {
+ setActiveContext(true);
+
+ await noteCreateService.createNote("root", { saveSelection: true, title: "Plain" });
+
+ expect(server.post).toHaveBeenCalledWith(
+ expect.any(String),
+ expect.objectContaining({ title: "Plain", content: "" }),
+ undefined
+ );
+ });
+
it("honors an explicit target and targetBranchId in the URL", async () => {
setActiveContext(true);
await noteCreateService.createNote("root", { target: "after", targetBranchId: "tb-9" });
diff --git a/apps/client/src/services/note_create.ts b/apps/client/src/services/note_create.ts
index f1ae13f32a..fef0361fc5 100644
--- a/apps/client/src/services/note_create.ts
+++ b/apps/client/src/services/note_create.ts
@@ -65,14 +65,17 @@ async function createNote(parentNotePath: string | undefined, options: CreateNot
options.isProtected = false;
}
- if (appContext.tabManager.getActiveContextNoteType() !== "text") {
+ // Whether there is a selection to save is the editor's answer, not the tab manager's: the editor
+ // handing us the selection is not necessarily the active tab's (a split, the quick editor, an
+ // embedded pane). An empty selection means there is nothing to cut, so the note is created as an
+ // ordinary empty child and the source note is left untouched.
+ const selectedHtml = options.saveSelection ? options.textEditor?.getSelectedHtml() : null;
+ if (selectedHtml) {
+ [options.title, options.content] = parseSelectedHtml(selectedHtml);
+ } else {
options.saveSelection = false;
}
- if (options.saveSelection && options.textEditor) {
- [options.title, options.content] = parseSelectedHtml(options.textEditor.getSelectedHtml());
- }
-
const parentNoteId = treeService.getNoteIdFromUrl(parentNotePath);
const { note, branch } = await server.post(`notes/${parentNoteId}/children?target=${options.target}&targetBranchId=${options.targetBranchId || ""}`, {
diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json
index af38588b9d..b931d18b6a 100644
--- a/apps/client/src/translations/en/translation.json
+++ b/apps/client/src/translations/en/translation.json
@@ -1557,7 +1557,8 @@
"editor_crashed_details_intro": "If you experience this error several times, consider reporting it on GitHub by pasting the information below.",
"editor_crashed_details_title": "Technical information",
"auto-detect-language": "Auto-detected",
- "keeps-crashing": "Editing component keeps crashing. Please try restarting Trilium. If problem persists, consider creating a bug report."
+ "keeps-crashing": "Editing component keeps crashing. Please try restarting Trilium. If problem persists, consider creating a bug report.",
+ "nothing_selected_to_cut": "Select some text first to cut it into a sub-note."
},
"empty": {
"open_note_instruction": "Open a note by typing the note's title into the input below or choose a note in the tree.",
diff --git a/apps/client/src/widgets/type_widgets/text/EditableText.tsx b/apps/client/src/widgets/type_widgets/text/EditableText.tsx
index 1604862c3f..e45a25c513 100644
--- a/apps/client/src/widgets/type_widgets/text/EditableText.tsx
+++ b/apps/client/src/widgets/type_widgets/text/EditableText.tsx
@@ -196,18 +196,30 @@ export default function EditableText({ note, parentComponent, ntxId, noteContext
}
},
async cutIntoNoteCommand() {
- const note = appContext.tabManager.getActiveContextNote();
- if (!note) return;
+ // The note this editor is showing, not whichever one the tab manager considers active: the
+ // editor also runs in a split, in the quick editor and in the embedded panes of the map and
+ // calendar views, where the active context is a different note entirely. Asking the tab
+ // manager there put the sub-note under an unrelated parent, and — since the selection was
+ // only saved when the *active* note was a text note — usually created it empty (#9890).
+ const sourceNote = noteContext?.note;
+ const parentNotePath = noteContext?.notePath;
+ // This component's own editor, rather than noteContext.getTextEditor(): that one races the
+ // round trip through the event bus against a 200 ms timeout and resolves to null when it
+ // loses, which again meant an empty sub-note and a selection left where it was.
+ const textEditor = await waitForEditor() as CKTextEditor | undefined;
+ if (!sourceNote || !parentNotePath || !textEditor) return;
+
+ if (!textEditor.getSelectedHtml()) {
+ toast.showMessage(t("editable_text.nothing_selected_to_cut"));
+ return;
+ }
// without await as this otherwise causes deadlock through component mutex
- const parentNotePath = appContext.tabManager.getActiveContextNotePath();
- if (noteContext && parentNotePath) {
- note_create.createNote(parentNotePath, {
- isProtected: note.isProtected,
- saveSelection: true,
- textEditor: await noteContext?.getTextEditor()
- });
- }
+ note_create.createNote(parentNotePath, {
+ isProtected: sourceNote.isProtected,
+ saveSelection: true,
+ textEditor
+ });
},
async saveNoteDetailNowCommand() {
// used by cutToNote in CKEditor build
diff --git a/packages/ckeditor5/src/plugins/cuttonote.spec.ts b/packages/ckeditor5/src/plugins/cuttonote.spec.ts
index b330077bb5..782cd11519 100644
--- a/packages/ckeditor5/src/plugins/cuttonote.spec.ts
+++ b/packages/ckeditor5/src/plugins/cuttonote.spec.ts
@@ -67,6 +67,13 @@ describe("CutToNotePlugin", () => {
expect(html).not.toContain("data-list-item-id");
});
+ it("returns an empty string from getSelectedHtml when nothing is selected", () => {
+ // What the host takes as "there is nothing to cut here" before it creates a sub-note (#9890).
+ setModelData(editor.model, "foo[]bar");
+
+ expect(editor.getSelectedHtml()).toBe("");
+ });
+
it("removeSelection deletes the selection, inserts a paragraph and saves the note", async () => {
setModelData(editor.model, "foo[bar]baz");