Skip to content

Commit 03ae5a7

Browse files
committed
Fix message editor double newline bug
Prevent wrapping logic from consuming trailing empty paragraphs or scanning backwards by ensuring collectLogicalParagraph only gathers forward non-empty sibling paragraphs. This avoids accidental removal of empty paragraphs at document end and preserves multiple newlines; tests were added to validate preserving trailing empty paragraphs and typing behavior.
1 parent 0d048e6 commit 03ae5a7

File tree

2 files changed

+180
-1
lines changed

2 files changed

+180
-1
lines changed

packages/ui/src/lib/richText/linewrap.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,12 +123,18 @@ function isLogicalParagraphBoundary(para: ParagraphNode, previousIndent: string)
123123

124124
/**
125125
* Collects all paragraphs that belong to the same logical paragraph.
126+
* NOTE: This function only collects paragraphs FORWARD (nextSibling), never backward.
127+
* Empty paragraphs are never collected as they are considered logical boundaries.
126128
*/
127129
function collectLogicalParagraph(paragraph: ParagraphNode, indent: string): ParagraphNode[] {
128130
const paragraphs: ParagraphNode[] = [paragraph];
129131
let nextSibling = paragraph.getNextSibling();
130132

131133
while (nextSibling && $isParagraphNode(nextSibling)) {
134+
// Extra defensive check: never collect empty paragraphs
135+
const siblingText = nextSibling.getTextContent();
136+
if (siblingText.trim() === '') break;
137+
132138
if (isLogicalParagraphBoundary(nextSibling, indent)) break;
133139
paragraphs.push(nextSibling);
134140
nextSibling = nextSibling.getNextSibling();

packages/ui/src/lib/richText/plugins/HardWrapPlugin.test.ts

Lines changed: 174 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { wrapIfNecessary } from '$lib/richText/linewrap';
2-
import { createEditor, type LexicalEditor } from 'lexical';
2+
import { createEditor, ParagraphNode, type LexicalEditor } from 'lexical';
33
import {
44
$createParagraphNode as createParagraphNode,
55
$createTextNode as createTextNode,
@@ -643,4 +643,177 @@ describe('HardWrapPlugin with multi-paragraph structure', () => {
643643
});
644644
});
645645
});
646+
647+
describe('multiple newlines at end', () => {
648+
it('should preserve multiple newlines at the end of text', () => {
649+
editor.update(() => {
650+
const root = getRoot();
651+
652+
// Create a paragraph with text
653+
const para1 = createParagraphNode();
654+
para1.append(createTextNode('Some text'));
655+
root.append(para1);
656+
657+
// Add empty paragraphs at the end
658+
const emptyPara1 = createParagraphNode();
659+
emptyPara1.append(createTextNode(''));
660+
root.append(emptyPara1);
661+
662+
const emptyPara2 = createParagraphNode();
663+
emptyPara2.append(createTextNode(''));
664+
root.append(emptyPara2);
665+
666+
// Trigger wrap on the first paragraph (should not affect empty paragraphs)
667+
const textNode = para1.getFirstChild() as TextNode;
668+
wrapIfNecessary({ node: textNode, maxLength: 72 });
669+
});
670+
671+
editor.read(() => {
672+
const root = getRoot();
673+
const children = root.getChildren();
674+
675+
// Should have 3 paragraphs total
676+
expect(children.length).toBe(3);
677+
678+
// First paragraph should have text
679+
expect(children[0].getTextContent()).toBe('Some text');
680+
681+
// Second and third paragraphs should be empty
682+
expect(children[1].getTextContent()).toBe('');
683+
expect(children[2].getTextContent()).toBe('');
684+
});
685+
});
686+
687+
it('should allow adding multiple newlines at the end by typing', () => {
688+
editor.update(() => {
689+
const root = getRoot();
690+
const paragraph = createParagraphNode();
691+
const textNode = createTextNode('Some text');
692+
paragraph.append(textNode);
693+
root.append(paragraph);
694+
// Set cursor at the end
695+
textNode.select(9, 9);
696+
});
697+
698+
// Add an empty paragraph at the end
699+
editor.update(() => {
700+
const root = getRoot();
701+
const emptyPara = createParagraphNode();
702+
emptyPara.append(createTextNode(''));
703+
root.append(emptyPara);
704+
});
705+
706+
// Add another empty paragraph at the end
707+
editor.update(() => {
708+
const root = getRoot();
709+
const emptyPara = createParagraphNode();
710+
emptyPara.append(createTextNode(''));
711+
root.append(emptyPara);
712+
});
713+
714+
// Now trigger wrapping on an empty paragraph (simulating typing in the empty area)
715+
editor.update(() => {
716+
const root = getRoot();
717+
const children = root.getChildren();
718+
const lastPara = children[children.length - 1] as ParagraphNode;
719+
const textNode = lastPara.getFirstChild() as TextNode;
720+
if (textNode) {
721+
wrapIfNecessary({ node: textNode, maxLength: 72 });
722+
}
723+
});
724+
725+
editor.read(() => {
726+
const root = getRoot();
727+
const children = root.getChildren();
728+
729+
// Should still have all paragraphs
730+
expect(children.length).toBe(3);
731+
732+
// First paragraph should have text
733+
expect(children[0].getTextContent()).toBe('Some text');
734+
735+
// Last two paragraphs should be empty
736+
expect(children[1].getTextContent()).toBe('');
737+
expect(children[2].getTextContent()).toBe('');
738+
});
739+
});
740+
741+
it('should not remove trailing empty paragraphs when typing in the last one', () => {
742+
editor.update(() => {
743+
const root = getRoot();
744+
745+
// Create initial structure with text followed by empty paragraphs
746+
const para1 = createParagraphNode();
747+
para1.append(createTextNode('Some text'));
748+
root.append(para1);
749+
750+
const emptyPara1 = createParagraphNode();
751+
emptyPara1.append(createTextNode(''));
752+
root.append(emptyPara1);
753+
754+
const emptyPara2 = createParagraphNode();
755+
emptyPara2.append(createTextNode(''));
756+
root.append(emptyPara2);
757+
758+
// Now type something in the last empty paragraph
759+
const lastTextNode = emptyPara2.getFirstChild() as TextNode;
760+
lastTextNode.setTextContent('x');
761+
762+
// Trigger wrapping on the last paragraph (this simulates what happens when typing)
763+
wrapIfNecessary({ node: lastTextNode, maxLength: 72 });
764+
});
765+
766+
editor.read(() => {
767+
const root = getRoot();
768+
const children = root.getChildren();
769+
770+
// Should preserve all paragraphs
771+
expect(children.length).toBe(3);
772+
773+
// Check contents
774+
expect(children[0].getTextContent()).toBe('Some text');
775+
expect(children[1].getTextContent()).toBe('');
776+
expect(children[2].getTextContent()).toBe('x');
777+
});
778+
});
779+
780+
it('should not remove trailing empty paragraphs when typing in the first paragraph', () => {
781+
editor.update(() => {
782+
const root = getRoot();
783+
784+
// Create initial structure with text followed by empty paragraphs
785+
const para1 = createParagraphNode();
786+
para1.append(createTextNode('Some text'));
787+
root.append(para1);
788+
789+
const emptyPara1 = createParagraphNode();
790+
emptyPara1.append(createTextNode(''));
791+
root.append(emptyPara1);
792+
793+
const emptyPara2 = createParagraphNode();
794+
emptyPara2.append(createTextNode(''));
795+
root.append(emptyPara2);
796+
797+
// Now add more text to the first paragraph
798+
const firstTextNode = para1.getFirstChild() as TextNode;
799+
firstTextNode.setTextContent('Some text with more content added');
800+
801+
// Trigger wrapping on the first paragraph (this simulates what happens when typing)
802+
wrapIfNecessary({ node: firstTextNode, maxLength: 72 });
803+
});
804+
805+
editor.read(() => {
806+
const root = getRoot();
807+
const children = root.getChildren();
808+
809+
// Should preserve all paragraphs - the empty ones should NOT be collected!
810+
expect(children.length).toBe(3);
811+
812+
// Check contents
813+
expect(children[0].getTextContent()).toBe('Some text with more content added');
814+
expect(children[1].getTextContent()).toBe('');
815+
expect(children[2].getTextContent()).toBe('');
816+
});
817+
});
818+
});
646819
});

0 commit comments

Comments
 (0)