diff --git a/frontend/text-editor/src/editor/controllers/SelectionController.js b/frontend/text-editor/src/editor/controllers/SelectionController.js index 8c98662396..a27fab4c2d 100644 --- a/frontend/text-editor/src/editor/controllers/SelectionController.js +++ b/frontend/text-editor/src/editor/controllers/SelectionController.js @@ -1238,7 +1238,11 @@ export class SelectionController extends EventTarget { textSpan.childNodes.length === 0 ) { textSpan.remove(); - return this.collapse(nextTextNode, 0); + // nextTextNode can be null when deleting the last text node in the last + // span of the paragraph; fall back to the last text node of the + // preceding sibling span so the cursor stays within the paragraph. + const forwardTarget = nextTextNode ?? paragraph.lastChild?.lastChild; + return this.collapse(forwardTarget, 0); } return this.collapse(this.focusNode, this.focusOffset); } @@ -1284,9 +1288,14 @@ export class SelectionController extends EventTarget { textSpan.childNodes.length === 0 ) { textSpan.remove(); + // previousTextNode can be null when deleting the first text node in + // the paragraph (no preceding sibling text node exists). Fall back + // to the first text node of the now-first remaining span so the + // cursor stays within the paragraph. + const backwardTarget = previousTextNode ?? paragraph.firstChild?.firstChild; return this.collapse( - previousTextNode, - getTextNodeLength(previousTextNode), + backwardTarget, + getTextNodeLength(backwardTarget), ); } @@ -1358,9 +1367,13 @@ export class SelectionController extends EventTarget { textSpan.childNodes.length === 0 ) { textSpan.remove(); + // previousTextNode can be null when the deleted node was the first + // in the paragraph. Fall back to the first text node of the + // now-first remaining span. + const backwardTarget = previousTextNode ?? paragraph.firstChild?.firstChild; return this.collapse( - previousTextNode, - getTextNodeLength(previousTextNode), + backwardTarget, + getTextNodeLength(backwardTarget), ); } } diff --git a/frontend/text-editor/src/editor/controllers/SelectionController.test.js b/frontend/text-editor/src/editor/controllers/SelectionController.test.js index 5d5b4a7c8c..e549646039 100644 --- a/frontend/text-editor/src/editor/controllers/SelectionController.test.js +++ b/frontend/text-editor/src/editor/controllers/SelectionController.test.js @@ -581,6 +581,27 @@ describe("SelectionController", () => { expect(textEditorMock.root.textContent).toBe(""); }); + test("`removeBackwardText` should not throw when deleting the first character of the first span in a multi-span paragraph", () => { + // Regression test: previousNode() returns null when the cursor is at the + // very first text node; passing null to getTextNodeLength used to throw + // "TypeError: Invalid text node". + const textEditorMock = TextEditorMock.createTextEditorMockWith([ + ["A", "B"], + ]); + const root = textEditorMock.root; + const selection = document.getSelection(); + const selectionController = new SelectionController( + textEditorMock, + selection, + ); + // Focus at offset 1 of the first span's text node ("A"), then delete backward + const firstTextNode = root.firstChild.firstChild.firstChild; + focus(selection, textEditorMock, firstTextNode, 1); + expect(() => selectionController.removeBackwardText()).not.toThrow(); + // "A" is removed; the paragraph should keep the second span with "B" + expect(textEditorMock.root.textContent).toBe("B"); + }); + test("`insertParagraph` should insert a new paragraph in an empty editor", () => { const textEditorMock = TextEditorMock.createTextEditorMockEmpty(); const root = textEditorMock.root; @@ -875,6 +896,26 @@ describe("SelectionController", () => { ); }); + test("`removeForwardText` should not throw when deleting the last character of the last span in a multi-span paragraph", () => { + // Regression test: nextNode() returns null when the cursor is at the + // very last text node; passing null to collapse used to crash. + const textEditorMock = TextEditorMock.createTextEditorMockWith([ + ["A", "B"], + ]); + const root = textEditorMock.root; + const selection = document.getSelection(); + const selectionController = new SelectionController( + textEditorMock, + selection, + ); + // Focus at offset 0 of the second span's text node ("B"), then delete forward + const secondTextNode = root.firstChild.lastChild.firstChild; + focus(selection, textEditorMock, secondTextNode, 0); + expect(() => selectionController.removeForwardText()).not.toThrow(); + // "B" is removed; the paragraph should keep the first span with "A" + expect(textEditorMock.root.textContent).toBe("A"); + }); + test("`replaceText` should replace the selected text", () => { const textEditorMock = TextEditorMock.createTextEditorMockWithText("Hello, World!");