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");