fix(text): cut selection into sub-note loses the selection (closes #9890)

The command took the note and parent path from the tab manager, and
createNote() dropped saveSelection whenever the *active* note was not a
text note. But the editor firing the command is not always the active
tab's: it also runs in a split, the quick editor, the tree popup and the
embedded panes of the map and calendar views, whose note contexts live
outside the tab manager. There the selection was discarded without a
word — an empty sub-note under an unrelated parent, the source text left
where it was. Which is the "randomly" of the report: it turns on what the
active tab happens to hold, not on what was selected.

Both are now answered by the editor that fired the command: its own note
context for the note and parent, and its own editor rather than
noteContext.getTextEditor(), which races the round trip through the event
bus against a 200 ms timeout and resolves to null when it loses — the
same empty sub-note by another road. Cutting with nothing selected says
so instead of creating an empty note.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Elian Doran 2026-08-12 23:35:11 +03:00
parent 563d355f6b
commit a072f1a4d3
No known key found for this signature in database
5 changed files with 79 additions and 21 deletions

View File

@ -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(() => "<h1>x</h1>"),
getSelectedHtml: vi.fn(() => "<h1>Heading</h1><p>body</p>"),
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: "<p>body</p>" }),
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" });

View File

@ -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<Response>(`notes/${parentNoteId}/children?target=${options.target}&targetBranchId=${options.targetBranchId || ""}`, {

View File

@ -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.",

View File

@ -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

View File

@ -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, "<paragraph>foo[]bar</paragraph>");
expect(editor.getSelectedHtml()).toBe("");
});
it("removeSelection deletes the selection, inserts a paragraph and saves the note", async () => {
setModelData(editor.model, "<paragraph>foo[bar]baz</paragraph>");