Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 11 additions & 7 deletions packages/lexical-html/src/import/coreImportRules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
isBlockDomNode,
isDOMTextNode,
isLastChildInBlockNode,
isManagedLineBreak,
isOnlyChildInBlockNode,
type LexicalNode,
setNodeIndentFromDOM,
Expand Down Expand Up @@ -446,14 +447,17 @@ const IgnoreScriptStyleRule = /* @__PURE__ */ defineImportRule({
});

const LineBreakRule = /* @__PURE__ */ defineImportRule({
// Mirror the legacy LineBreakNode.importDOM filter: stray `<br>` that
// are the sole or trailing child of a block parent (e.g. Apple's
// `<br class="Apple-interchange-newline">` clipboard sentinel, or the
// trailing `<br>` browsers insert after the last text in a `<div>`)
// would otherwise survive as a LineBreakNode and tack an extra blank
// line onto the imported content.
// Mirror the LineBreakNode.importDOM filter: drop `<br>` that are only a
// rendering artifact rather than authored content. The sole child of a
// block is dropped (empty `<li>`/`<p>` round-trips as an empty element),
// and a trailing `<br>` is dropped only when it is a managed line break —
// Lexical's own `data-lexical-managed-linebreak` terminator or Safari's
// `<br class="Apple-interchange-newline">` clipboard sentinel. An ordinary
// authored trailing `<br>` 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'),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -737,9 +737,25 @@ describe('LexicalEventHelpers', () => {
},
{
expectedHTML:
'<p class="editor-paragraph" dir="auto"><span data-lexical-text="true">1</span></p><p class="editor-paragraph" dir="auto"><span data-lexical-text="true">2</span></p><p class="editor-paragraph" dir="auto"><span data-lexical-text="true">3</span></p>',
'<p class="editor-paragraph" dir="auto"><span data-lexical-text="true">1</span></p><p class="editor-paragraph" dir="auto"><span data-lexical-text="true">2</span><br><br data-lexical-managed-linebreak="true"></p><p class="editor-paragraph" dir="auto"><span data-lexical-text="true">3</span></p>',
inputs: [pasteHTML('1<p>2<br /></p>3')],
name: 'last br in a block node is ignored',
name: 'authored trailing br in a block node is preserved',
},
{
expectedHTML:
'<p class="editor-paragraph" dir="auto"><span data-lexical-text="true">1</span></p><p class="editor-paragraph" dir="auto"><span data-lexical-text="true">2</span></p><p class="editor-paragraph" dir="auto"><span data-lexical-text="true">3</span></p>',
inputs: [
pasteHTML('1<p>2<br data-lexical-managed-linebreak="true" /></p>3'),
],
name: 'managed trailing br in a block node is ignored',
},
{
expectedHTML:
'<p class="editor-paragraph" dir="auto"><span data-lexical-text="true">1</span></p><p class="editor-paragraph" dir="auto"><span data-lexical-text="true">2</span></p><p class="editor-paragraph" dir="auto"><span data-lexical-text="true">3</span></p>',
inputs: [
pasteHTML('1<p>2<br class="Apple-interchange-newline" /></p>3'),
],
name: 'apple-interchange trailing br in a block node is ignored',
},
];

Expand Down
14 changes: 10 additions & 4 deletions packages/lexical/src/LexicalMutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<br>` should be dropped on import.
function isEditorManagedLineBreak(
dom: Node,
target: Node & LexicalPrivateDOM,
editor: LexicalEditor,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -243,7 +249,7 @@ function flushMutations(
const removedDOM = removedDOMs[s];

if (
isManagedLineBreak(removedDOM, targetDOM, editor) ||
isEditorManagedLineBreak(removedDOM, targetDOM, editor) ||
blockCursorElement === removedDOM
) {
targetDOM.appendChild(removedDOM);
Expand Down Expand Up @@ -289,7 +295,7 @@ function flushMutations(
if (
parentDOM != null &&
addedDOM.nodeName === 'BR' &&
!isManagedLineBreak(addedDOM, target, editor)
!isEditorManagedLineBreak(addedDOM, target, editor)
) {
parentDOM.removeChild(addedDOM);
}
Expand Down
1 change: 1 addition & 0 deletions packages/lexical/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,7 @@ export {
$createLineBreakNode,
$isLineBreakNode,
isLastChildInBlockNode,
isManagedLineBreak,
isOnlyChildInBlockNode,
LineBreakNode,
} from './nodes/LexicalLineBreakNode';
Expand Down
36 changes: 35 additions & 1 deletion packages/lexical/src/nodes/LexicalLineBreakNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
$applyNodeReplacement,
isBlockDomNode,
isDOMTextNode,
isHTMLElement,
} from '../LexicalUtils';

export type SerializedLineBreakNode = SerializedLexicalNode;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 `<br>` 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 `<br>` 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 `<br>` WebKit/Safari
* appends to clipboard HTML as a transport artifact.
*
* An ordinary authored trailing `<br>` 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 {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A different name for this probably makes sense, or we should rename the one in LexicalMutations (maybe isEditorManagedLineBreak). They don't work the same way, so it may cause confusion.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call — renamed the existing LexicalMutations helper to isEditorManagedLineBreak (68c830d) to avoid the collision. Added cross-referencing comments on both so the distinction is clear: the mutations one identifies a break the editor is actively managing in the live DOM (via __lexicalLineBreak / node key), while the new one inspects serialized markup (data-lexical-managed-linebreak / Apple-interchange class) to decide whether a parsed <br> should be dropped on import.

if (node.nodeName !== 'BR' || !isHTMLElement(node)) {
return false;
}
return (
node.getAttribute('data-lexical-managed-linebreak') === 'true' ||
node.getAttribute('class') === 'Apple-interchange-newline'
);
}
Loading