Lexical: Fixed difference in line break in-editor & on export

Aligned the linebreak exportdom behaviour to align with how the
reconciler displays line breaks in the editor, by adding an extra line
break to each series of line breaks, so they're now visually the same
across editor and export.
This commit is contained in:
Dan Brown
2026-09-06 19:19:44 +01:00
parent df40d8c08a
commit f8c2ada021
3 changed files with 79 additions and 5 deletions
@@ -41,6 +41,7 @@ import {ImageNode} from "@lexical/rich-text/LexicalImageNode";
import {MediaNode} from "@lexical/rich-text/LexicalMediaNode";
import {DiagramNode} from "@lexical/rich-text/LexicalDiagramNode";
import {DiagramDecorator} from "../../../../ui/decorators/DiagramDecorator";
import {$generateHtmlFromNodes} from "@lexical/html";
type TestEnv = {
readonly container: HTMLDivElement;
@@ -768,7 +769,14 @@ export function html(
}
export function expectHtmlToBeEqual(expected: string, actual: string): void {
expect(formatHtml(expected)).toBe(formatHtml(actual));
expect(formatHtml(actual)).toBe(formatHtml(expected));
}
export function expectEditorHtmlToBeEqual(editor: LexicalEditor, expected: string): void {
const html = editor.read(() => {
return $generateHtmlFromNodes(editor, null);
});
expect(formatHtml(html)).toBe(formatHtml(expected));
}
type nodeTextShape = {
@@ -6,10 +6,10 @@
*
*/
import type {KlassConstructor} from '../LexicalEditor';
import {KlassConstructor, LexicalEditor} from '../LexicalEditor';
import type {
DOMConversionMap,
DOMConversionOutput,
DOMConversionOutput, DOMExportOutput,
NodeKey,
SerializedLexicalNode,
} from '../LexicalNode';
@@ -43,6 +43,25 @@ export class LineBreakNode extends LexicalNode {
return document.createElement('br');
}
exportDOM(editor: LexicalEditor): DOMExportOutput {
const output = super.exportDOM(editor);
const parent = this.getParent();
// We insert an extra line break when the line break is the last child of a block node
// so that a line break is properly represented as an empty line, which mirrors the in-editor
// behaviour, where it's handled at the reconciliation phase
if (parent && !parent.isInline() && parent.getLastChild()?.getKey() === this.getKey()) {
output.after = (el) => {
if (el instanceof HTMLElement) {
el.before(document.createElement('br'), el);
}
return null;
};
}
return output;
}
updateDOM(): false {
return false;
}
@@ -6,9 +6,22 @@
*
*/
import {$createLineBreakNode, $isLineBreakNode} from 'lexical';
import {
$createLineBreakNode,
$createParagraphNode,
$createTextNode,
$getRoot,
$isLineBreakNode,
ParagraphNode
} from 'lexical';
import {initializeUnitTest} from '../../../__tests__/utils';
import {
createTestContext,
dispatchKeydownEventForSelectedNode, expectEditorHtmlToBeEqual,
expectNodeShapeToMatch,
initializeUnitTest
} from '../../../__tests__/utils';
import {registerRichText} from "@lexical/rich-text";
describe('LexicalLineBreakNode tests', () => {
initializeUnitTest((testEnv) => {
@@ -71,4 +84,38 @@ describe('LexicalLineBreakNode tests', () => {
});
});
});
describe('LineBreakNode.exportDOM()', () => {
test('first linebreaks are represented as a double break on export', () => {
const {editor} = createTestContext();
registerRichText(editor);
let textNode;
let paragraph!: ParagraphNode;
editor.updateAndCommit(() => {
paragraph = $createParagraphNode();
textNode = $createTextNode('hello');
paragraph.append(textNode);
$getRoot().append(paragraph);
textNode.selectEnd();
});
dispatchKeydownEventForSelectedNode(editor, 'Enter', {shiftKey: true});
expectNodeShapeToMatch(editor, [
{type: 'paragraph', children: [
{text: 'hello'},
{type: 'linebreak'},
]}
]);
expectEditorHtmlToBeEqual(editor, `<p>hello<br><br></p>`);
dispatchKeydownEventForSelectedNode(editor, 'Enter', {shiftKey: true});
dispatchKeydownEventForSelectedNode(editor, 'Enter', {shiftKey: true});
expectEditorHtmlToBeEqual(editor, `<p>hello<br><br><br><br></p>`);
});
});
});