diff --git a/packages/lexical-html/src/import/coreImportRules.ts b/packages/lexical-html/src/import/coreImportRules.ts
index 87c4adc6c65..3d03aedcca5 100644
--- a/packages/lexical-html/src/import/coreImportRules.ts
+++ b/packages/lexical-html/src/import/coreImportRules.ts
@@ -30,6 +30,7 @@ import {
isBlockDomNode,
isDOMTextNode,
isLastChildInBlockNode,
+ isManagedLineBreak,
isOnlyChildInBlockNode,
type LexicalNode,
setNodeIndentFromDOM,
@@ -446,14 +447,17 @@ const IgnoreScriptStyleRule = /* @__PURE__ */ defineImportRule({
});
const LineBreakRule = /* @__PURE__ */ defineImportRule({
- // Mirror the legacy LineBreakNode.importDOM filter: stray `
` that
- // are the sole or trailing child of a block parent (e.g. Apple's
- // `
` clipboard sentinel, or the
- // trailing `
` browsers insert after the last text in a `
`)
- // would otherwise survive as a LineBreakNode and tack an extra blank
- // line onto the imported content.
+ // Mirror the LineBreakNode.importDOM filter: drop `
` that are only a
+ // rendering artifact rather than authored content. The sole child of a
+ // block is dropped (empty `
`/`` round-trips as an empty element),
+ // and a trailing `
` is dropped only when it is a managed line break —
+ // Lexical's own `data-lexical-managed-linebreak` terminator or Safari's
+ // `
` clipboard sentinel. An ordinary
+ // authored trailing `
` is preserved so round-trip serialization to
+ // HTML stays lossless.
$import: (_ctx, el) =>
- isOnlyChildInBlockNode(el) || isLastChildInBlockNode(el)
+ isOnlyChildInBlockNode(el) ||
+ (isLastChildInBlockNode(el) && isManagedLineBreak(el))
? []
: [$createLineBreakNode()],
match: sel.tag('br'),
diff --git a/packages/lexical-utils/src/__tests__/unit/LexicalEventHelpers.test.tsx b/packages/lexical-utils/src/__tests__/unit/LexicalEventHelpers.test.tsx
index 4ba20a998a0..aaf9439182b 100644
--- a/packages/lexical-utils/src/__tests__/unit/LexicalEventHelpers.test.tsx
+++ b/packages/lexical-utils/src/__tests__/unit/LexicalEventHelpers.test.tsx
@@ -737,9 +737,25 @@ describe('LexicalEventHelpers', () => {
},
{
expectedHTML:
- '
1
2
3
',
+ '1
2
3
',
inputs: [pasteHTML('12
3')],
- name: 'last br in a block node is ignored',
+ name: 'authored trailing br in a block node is preserved',
+ },
+ {
+ expectedHTML:
+ '1
2
3
',
+ inputs: [
+ pasteHTML('12
3'),
+ ],
+ name: 'managed trailing br in a block node is ignored',
+ },
+ {
+ expectedHTML:
+ '1
2
3
',
+ inputs: [
+ pasteHTML('12
3'),
+ ],
+ name: 'apple-interchange trailing br in a block node is ignored',
},
];
diff --git a/packages/lexical/src/LexicalMutations.ts b/packages/lexical/src/LexicalMutations.ts
index 48545d5d41d..3a399bba39c 100644
--- a/packages/lexical/src/LexicalMutations.ts
+++ b/packages/lexical/src/LexicalMutations.ts
@@ -55,7 +55,13 @@ function initTextEntryListener(editor: LexicalEditor): void {
}
}
-function isManagedLineBreak(
+// True when `dom` is a line break the editor is actively managing in the
+// live DOM — identified by the `__lexicalLineBreak` slot reference or by
+// having a node key. This is distinct from `isManagedLineBreak` in
+// LexicalLineBreakNode, which inspects serialized markup (the
+// `data-lexical-managed-linebreak` attribute / Apple-interchange class)
+// to decide whether a parsed `
` should be dropped on import.
+function isEditorManagedLineBreak(
dom: Node,
target: Node & LexicalPrivateDOM,
editor: LexicalEditor,
@@ -202,7 +208,7 @@ function flushMutations(
parentDOM != null &&
addedDOM !== blockCursorElement &&
node === null &&
- !isManagedLineBreak(addedDOM, parentDOM, editor) &&
+ !isEditorManagedLineBreak(addedDOM, parentDOM, editor) &&
// @experimental named-slots. Slot containers are keyless
// reconciler scaffolding: a flush that observes one being
// parked in its host or relocated by an explicit mount must
@@ -243,7 +249,7 @@ function flushMutations(
const removedDOM = removedDOMs[s];
if (
- isManagedLineBreak(removedDOM, targetDOM, editor) ||
+ isEditorManagedLineBreak(removedDOM, targetDOM, editor) ||
blockCursorElement === removedDOM
) {
targetDOM.appendChild(removedDOM);
@@ -289,7 +295,7 @@ function flushMutations(
if (
parentDOM != null &&
addedDOM.nodeName === 'BR' &&
- !isManagedLineBreak(addedDOM, target, editor)
+ !isEditorManagedLineBreak(addedDOM, target, editor)
) {
parentDOM.removeChild(addedDOM);
}
diff --git a/packages/lexical/src/index.ts b/packages/lexical/src/index.ts
index bc1434f60f3..ca8c1af9313 100644
--- a/packages/lexical/src/index.ts
+++ b/packages/lexical/src/index.ts
@@ -391,6 +391,7 @@ export {
$createLineBreakNode,
$isLineBreakNode,
isLastChildInBlockNode,
+ isManagedLineBreak,
isOnlyChildInBlockNode,
LineBreakNode,
} from './nodes/LexicalLineBreakNode';
diff --git a/packages/lexical/src/nodes/LexicalLineBreakNode.ts b/packages/lexical/src/nodes/LexicalLineBreakNode.ts
index 376ae273ad7..34a233d1988 100644
--- a/packages/lexical/src/nodes/LexicalLineBreakNode.ts
+++ b/packages/lexical/src/nodes/LexicalLineBreakNode.ts
@@ -19,6 +19,7 @@ import {
$applyNodeReplacement,
isBlockDomNode,
isDOMTextNode,
+ isHTMLElement,
} from '../LexicalUtils';
export type SerializedLineBreakNode = SerializedLexicalNode;
@@ -58,7 +59,10 @@ export class LineBreakNode extends LexicalNode {
static importDOM(): DOMConversionMap | null {
return {
br: (node: Node) => {
- if (isOnlyChildInBlockNode(node) || isLastChildInBlockNode(node)) {
+ if (
+ isOnlyChildInBlockNode(node) ||
+ (isLastChildInBlockNode(node) && isManagedLineBreak(node))
+ ) {
return null;
}
return {
@@ -154,3 +158,33 @@ export function isLastChildInBlockNode(node: Node): boolean {
function isWhitespaceDomTextNode(node: Node): boolean {
return isDOMTextNode(node) && /^( |\t|\r?\n)+$/.test(node.textContent || '');
}
+
+/**
+ * True when `node` is a `
` that exists only as a rendering artifact
+ * rather than authored content, and therefore should be dropped on import
+ * to avoid a phantom trailing line break:
+ *
+ * - `data-lexical-managed-linebreak="true"`: the terminating `
` that
+ * Lexical's reconciler injects so a trailing line break is not collapsed
+ * by the parent block element (see `LexicalDOMSlot.setManagedLineBreak`).
+ * Re-importing Lexical's own exported HTML must not turn this into a real
+ * `LineBreakNode`.
+ * - `class="Apple-interchange-newline"`: the trailing `
` WebKit/Safari
+ * appends to clipboard HTML as a transport artifact.
+ *
+ * An ordinary authored trailing `
` carries neither marker and is
+ * preserved so that round-trip serialization to HTML stays lossless.
+ *
+ * Note: this inspects *serialized markup* and is distinct from
+ * `isEditorManagedLineBreak` in `LexicalMutations`, which identifies a
+ * line break the editor is actively managing in the live DOM.
+ */
+export function isManagedLineBreak(node: Node): boolean {
+ if (node.nodeName !== 'BR' || !isHTMLElement(node)) {
+ return false;
+ }
+ return (
+ node.getAttribute('data-lexical-managed-linebreak') === 'true' ||
+ node.getAttribute('class') === 'Apple-interchange-newline'
+ );
+}