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('1

    2

    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('1

    2

    3'), + ], + name: 'managed trailing br in a block node is ignored', + }, + { + expectedHTML: + '

    1

    2

    3

    ', + inputs: [ + pasteHTML('1

    2

    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' + ); +}