From f8c2ada021f22d35b0d9c3dec2bcd7ee214ba7e8 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 6 Sep 2026 19:19:44 +0100 Subject: [PATCH] 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. --- .../lexical/core/__tests__/utils/index.ts | 10 +++- .../core/nodes/LexicalLineBreakNode.ts | 23 ++++++++- .../unit/LexicalLineBreakNode.test.ts | 51 ++++++++++++++++++- 3 files changed, 79 insertions(+), 5 deletions(-) diff --git a/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts b/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts index 4af2e17af..482f6d65f 100644 --- a/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts +++ b/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts @@ -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 = { diff --git a/resources/js/wysiwyg/lexical/core/nodes/LexicalLineBreakNode.ts b/resources/js/wysiwyg/lexical/core/nodes/LexicalLineBreakNode.ts index b1746e7f8..cef982e5f 100644 --- a/resources/js/wysiwyg/lexical/core/nodes/LexicalLineBreakNode.ts +++ b/resources/js/wysiwyg/lexical/core/nodes/LexicalLineBreakNode.ts @@ -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; } diff --git a/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalLineBreakNode.test.ts b/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalLineBreakNode.test.ts index 110086ac8..8e5b04ef1 100644 --- a/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalLineBreakNode.test.ts +++ b/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalLineBreakNode.test.ts @@ -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, `

hello

`); + + dispatchKeydownEventForSelectedNode(editor, 'Enter', {shiftKey: true}); + dispatchKeydownEventForSelectedNode(editor, 'Enter', {shiftKey: true}); + + expectEditorHtmlToBeEqual(editor, `

hello



`); + }); + }); });