From 880d4f0dc9bf16328898ece7ec0aea2716c1581b Mon Sep 17 00:00:00 2001 From: Gerard Rovira Date: Fri, 30 Oct 2026 22:44:19 -0700 Subject: [PATCH 1/2] [lexical-selection] Bug Fix: Wrap a block leaf in an element that is not a block container on its own MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Closes #9638. `$wrapNodes` groups the selected leaves by the block that holds them and moves each block's content into a new element. A block leaf (a block decorator, such as a horizontal rule) in an element that is not a block container (a box that declares no structure, without `NodeStructureExtension`) took that element for its block: all the box's children moved into one new element, those before the range included, and the new elements were then inserted next to one of them (the paragraph before the range), inside that new element. The tree had a cycle and the update never returned. Such a leaf is now wrapped on its own, as it already is in a block container, and where the range starts at it the new elements go where it is. Block containers and inline content are unchanged. ## Test plan `WrapNodesBlockLeaf.test.ts` (a guard turns a parent cycle into an error, so a hang fails the test instead of the run): - the issue's shape: a range from inside "bb" to the end of "cc" across a rule in a box keeps "aa", "bb", the rule and "cc" in order, wraps "bb", the rule and "cc", and the rule stays in the box; - a range that starts at the rule; - controls: the same range without the rule; a rule at the root; - a property over 300 random documents of paragraphs, rules and boxes (nested up to twice) and random ranges between text and element points: `$wrapNodes` returns and keeps every text, in order. ### Before main (c0c7e426): the issue's repro never returns. With the guard: ``` Tests 3 failed | 2 passed (5) × a range across a horizontal rule in a box keeps every paragraph × a range that starts at the rule × property: random box, rule and paragraph mixes ``` ### After ``` $ vitest --run --project unit packages/lexical-selection/src/__tests__/unit/WrapNodesBlockLeaf.test.ts Tests 5 passed (5) ``` All unit tests: 24,722 passed, 421 expected fail, 0 failed. `pnpm run ci-check` passes. --- .changeset/selection-wrap-block-leaf.md | 7 + .../__tests__/unit/WrapNodesBlockLeaf.test.ts | 301 ++++++++++++++++++ .../lexical-selection/src/range-selection.ts | 24 ++ 3 files changed, 332 insertions(+) create mode 100644 .changeset/selection-wrap-block-leaf.md create mode 100644 packages/lexical-selection/src/__tests__/unit/WrapNodesBlockLeaf.test.ts diff --git a/.changeset/selection-wrap-block-leaf.md b/.changeset/selection-wrap-block-leaf.md new file mode 100644 index 00000000000..597c65ddff9 --- /dev/null +++ b/.changeset/selection-wrap-block-leaf.md @@ -0,0 +1,7 @@ +--- +'@lexical/selection': patch +--- + +[fix] `$wrapNodes` over a range across a block decorator (a horizontal rule) in an element that is not a block container returns, and keeps every paragraph + +Verify: in a box element that declares no structure, holding "aa", "bb", a horizontal rule and "cc", select from inside "bb" to the end of "cc" and wrap in quotes: the call returns, "bb", the rule and "cc" are in quotes, and "aa" is untouched. diff --git a/packages/lexical-selection/src/__tests__/unit/WrapNodesBlockLeaf.test.ts b/packages/lexical-selection/src/__tests__/unit/WrapNodesBlockLeaf.test.ts new file mode 100644 index 00000000000..3e3a39de1e6 --- /dev/null +++ b/packages/lexical-selection/src/__tests__/unit/WrapNodesBlockLeaf.test.ts @@ -0,0 +1,301 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +// @vitest-environment node + +import { + $createHorizontalRuleNode, + $isHorizontalRuleNode, + buildEditorFromExtensions, + HorizontalRuleExtension, +} from '@lexical/extension'; +import {$createQuoteNode, QuoteNode} from '@lexical/rich-text'; +import {$wrapNodes} from '@lexical/selection'; +import { + $create, + $createParagraphNode, + $createRangeSelection, + $createTextNode, + $getRoot, + $isElementNode, + $isTextNode, + $setSelection, + defineExtension, + ElementNode, + type LexicalEditor, + type LexicalNode, + type PointType, +} from 'lexical'; +import {afterEach, beforeEach, describe, expect, test} from 'vitest'; + +// #9638: a block leaf (a horizontal rule) in an element that is not a block +// container (a box that declares no structure): $wrapNodes took the box for +// the rule's block, moved all the box's children into one new element, those +// before the range included, and put the new elements next to one of them, +// inside that new element. The update never returned. A block leaf there is +// now wrapped on its own, as in a block container. + +/** A plain block element that holds blocks, with no declared structure. */ +class BoxNode extends ElementNode { + $config() { + return this.config('test_wrap_box', {extends: ElementNode}); + } +} + +// A cycle in the tree makes the update walk parents forever: a guard turns +// that into an error, so a hang fails the test instead of the run. +const LIMIT = 1_000_000; +let parentCalls = 0; +const originalGetParent = ElementNode.prototype.getParent; +beforeEach(() => { + parentCalls = 0; + ElementNode.prototype.getParent = function (this: ElementNode) { + if (++parentCalls > LIMIT) { + throw new Error('getParent called more than a million times: a cycle'); + } + return originalGetParent.call(this); + } as typeof ElementNode.prototype.getParent; +}); +afterEach(() => { + ElementNode.prototype.getParent = originalGetParent; +}); + +function build() { + return buildEditorFromExtensions( + defineExtension({ + dependencies: [HorizontalRuleExtension], + name: 'wrap-block-leaf', + nodes: [BoxNode, QuoteNode], + }), + ); +} + +/** The texts and rules in document order, and the quotes' texts. */ +function outline(editor: LexicalEditor): { + all: string[]; + quoted: string[]; +} { + return editor.read(() => { + const all: string[] = []; + const quoted: string[] = []; + const walk = (node: LexicalNode, inQuote: boolean) => { + if ($isTextNode(node)) { + all.push(node.getTextContent()); + if (inQuote) { + quoted.push(node.getTextContent()); + } + } else if ($isHorizontalRuleNode(node)) { + all.push('
'); + if (inQuote) { + quoted.push('
'); + } + } else if ($isElementNode(node)) { + const quote = inQuote || node.getType() === 'quote'; + node.getChildren().forEach(child => walk(child, quote)); + } + }; + walk($getRoot(), false); + return {all, quoted}; + }); +} + +describe('$wrapNodes with a block leaf in an element that is not a block container (#9638)', () => { + test('a range across a horizontal rule in a box keeps every paragraph', () => { + using editor = build(); + editor.update( + () => { + const bb = $createTextNode('bb'); + const cc = $createTextNode('cc'); + $getRoot() + .clear() + .append( + $create(BoxNode).append( + $createParagraphNode().append($createTextNode('aa')), + $createParagraphNode().append(bb), + $createHorizontalRuleNode(), + $createParagraphNode().append(cc), + ), + ); + const selection = $createRangeSelection(); + selection.anchor.set(bb.getKey(), 1, 'text'); + selection.focus.set(cc.getKey(), 2, 'text'); + $setSelection(selection); + $wrapNodes(selection, () => $createQuoteNode()); + }, + {discrete: true}, + ); + expect(outline(editor)).toEqual({ + all: ['aa', 'bb', '
', 'cc'], + quoted: ['bb', '
', 'cc'], + }); + // The rule stays in the box, next to the paragraphs. + expect( + editor.read(() => + $getRoot() + .getChildren() + .map(n => n.getType()), + ), + ).toEqual(['test_wrap_box']); + }); + + test('a range that starts at the rule', () => { + using editor = build(); + editor.update( + () => { + const cc = $createTextNode('cc'); + const box = $create(BoxNode).append( + $createParagraphNode().append($createTextNode('aa')), + $createHorizontalRuleNode(), + $createParagraphNode().append(cc), + ); + $getRoot().clear().append(box); + const selection = $createRangeSelection(); + selection.anchor.set(box.getKey(), 1, 'element'); + selection.focus.set(cc.getKey(), 2, 'text'); + $setSelection(selection); + $wrapNodes(selection, () => $createQuoteNode()); + }, + {discrete: true}, + ); + expect(outline(editor)).toEqual({ + all: ['aa', '
', 'cc'], + quoted: ['
', 'cc'], + }); + }); + + test('control: the same range without the rule', () => { + using editor = build(); + editor.update( + () => { + const bb = $createTextNode('bb'); + const cc = $createTextNode('cc'); + $getRoot() + .clear() + .append( + $create(BoxNode).append( + $createParagraphNode().append($createTextNode('aa')), + $createParagraphNode().append(bb), + $createParagraphNode().append(cc), + ), + ); + const selection = $createRangeSelection(); + selection.anchor.set(bb.getKey(), 1, 'text'); + selection.focus.set(cc.getKey(), 2, 'text'); + $setSelection(selection); + $wrapNodes(selection, () => $createQuoteNode()); + }, + {discrete: true}, + ); + expect(outline(editor)).toEqual({ + all: ['aa', 'bb', 'cc'], + quoted: ['bb', 'cc'], + }); + }); + + test('control: a rule at the root', () => { + using editor = build(); + editor.update( + () => { + const bb = $createTextNode('bb'); + const cc = $createTextNode('cc'); + $getRoot() + .clear() + .append( + $createParagraphNode().append($createTextNode('aa')), + $createParagraphNode().append(bb), + $createHorizontalRuleNode(), + $createParagraphNode().append(cc), + ); + const selection = $createRangeSelection(); + selection.anchor.set(bb.getKey(), 1, 'text'); + selection.focus.set(cc.getKey(), 2, 'text'); + $setSelection(selection); + $wrapNodes(selection, () => $createQuoteNode()); + }, + {discrete: true}, + ); + expect(outline(editor)).toEqual({ + all: ['aa', 'bb', '
', 'cc'], + quoted: ['bb', '
', 'cc'], + }); + }); + + // Random documents of paragraphs, rules and boxes (nested up to twice), + // and random ranges between text and element points: $wrapNodes returns, + // and keeps every text and rule, in order. + test('property: random box, rule and paragraph mixes', () => { + let seed = 9638; + const random = () => { + seed = (seed * 1103515245 + 12345) % 2147483648; + return seed / 2147483648; + }; + const failures: string[] = []; + for (let run = 0; run < 300; run++) { + using editor = build(); + let n = 0; + const $children = (depth: number): LexicalNode[] => { + const out: LexicalNode[] = []; + const count = 1 + Math.floor(random() * 4); + for (let i = 0; i < count; i++) { + const r = random(); + if (r < 0.25) { + out.push($createHorizontalRuleNode()); + } else if (r < 0.4 && depth < 2) { + out.push($create(BoxNode).append(...$children(depth + 1))); + } else { + out.push($createParagraphNode().append($createTextNode(`t${n++}`))); + } + } + return out; + }; + let before: string[] = []; + let error: unknown = null; + try { + editor.update( + () => { + $getRoot() + .clear() + .append(...$children(0)); + before = $getRoot() + .getAllTextNodes() + .map(t => t.getTextContent()); + const points: [string, number, 'text' | 'element'][] = []; + const collect = (node: LexicalNode) => { + if ($isTextNode(node)) { + for (let o = 0; o <= node.getTextContentSize(); o++) { + points.push([node.getKey(), o, 'text']); + } + } else if ($isElementNode(node)) { + for (let o = 0; o <= node.getChildrenSize(); o++) { + points.push([node.getKey(), o, 'element']); + } + node.getChildren().forEach(collect); + } + }; + collect($getRoot()); + const a = points[Math.floor(random() * points.length)]; + const b = points[Math.floor(random() * points.length)]; + const selection = $createRangeSelection(); + (selection.anchor as PointType).set(...a); + (selection.focus as PointType).set(...b); + $setSelection(selection); + $wrapNodes(selection, () => $createQuoteNode()); + }, + {discrete: true}, + ); + } catch (e) { + error = e; + } + const after = outline(editor).all.filter(t => t !== '
'); + if (error !== null || after.join() !== before.join()) { + failures.push(`run ${run}: ${String(error ?? after.join())}`); + } + } + expect(failures).toEqual([]); + }); +}); diff --git a/packages/lexical-selection/src/range-selection.ts b/packages/lexical-selection/src/range-selection.ts index a0f2e1b8d72..66038bf69da 100644 --- a/packages/lexical-selection/src/range-selection.ts +++ b/packages/lexical-selection/src/range-selection.ts @@ -285,6 +285,24 @@ function $wrappedBlock( return found; } +/** + * A leaf that is a block of its own (a block decorator, such as a horizontal + * rule) in an element that is not a block container: it is wrapped on its + * own, as it is in a block container. Why: taking its parent for its block + * moved all the parent's children into one new element, those before the + * range included, and the new elements then went next to one of them, inside + * that new element: the update never returned (#9638). + */ +function $isBlockLeafInElement(node: LexicalNode): boolean { + const parent = node.getParent(); + return ( + parent !== null && + $isLeafNode(node) && + !INTERNAL_isInlineContent(node) && + !$isBlockContainer(parent) + ); +} + /** An empty element that is not inline content. */ function $isEmptyBlock(node: LexicalNode): node is ElementNode { return ( @@ -508,6 +526,8 @@ export function $wrapNodesImpl( if ($isBlockContainer(firstNodeBlock)) { const child = $childIn(firstNodeBlock, firstNode, childOf); target = INTERNAL_isInlineContent(child) ? $inlineRun(child)[0] : child; + } else if ($isBlockLeafInElement(firstNode)) { + target = firstNode; } let targetIsPrevSibling = false; @@ -556,6 +576,10 @@ export function $wrapNodesImpl( const runOf = new Map(); for (let i = 0; i < nodesLength; i++) { const nodeParent = nodes[i].getParent(); + if (nodeParent !== null && $isBlockLeafInElement(nodes[i])) { + units.push({block: nodeParent, key: nodes[i].getKey(), run: [nodes[i]]}); + continue; + } const block = nodeParent === null ? null : $wrappedBlock(nodeParent, blockOf); if (block === null || !$isBlockContainer(block)) { From 0a498829f150539c6c64c09282d7345501539f32 Mon Sep 17 00:00:00 2001 From: Gerard Rovira Date: Fri, 30 Oct 2026 22:58:14 -0700 Subject: [PATCH 2/2] [lexical-selection] Bug Fix: Wrap a block leaf on its own only where its parent holds blocks only ## Description Review of the first commit found a new hang: a box holding text directly, two rules and more text, with the range from the second rule to the end. The second rule became its own unit and the anchor for the new elements was the first rule, but the text's unit (the box, the block of its direct text) then moved all the box's children, the first rule included, into a new element: the elements were inserted next to a node inside that element. A block leaf is now its own unit only where its parent holds no inline content directly (a box of blocks, the shape of #9638). Where the parent holds text directly, the parent is the block of that text and of the leaf, as before. ## Test plan `WrapNodesBlockLeaf.test.ts` adds the review's case (forward and backward; both hang on the previous head, pass here), and the property generates adjacent rules. With text directly in a box, `$wrapNodes` reorders text or never returns in some documents on main too: that property is pinned as `test.fails` and filed as #9665 (main fails 8 of its 500 documents, this change 7, all among main's). All unit tests pass (`DisposeMemory.test.ts`'s heap bound failed once under 16 workers and passes alone). `pnpm run ci-check` passes. --- .../__tests__/unit/WrapNodesBlockLeaf.test.ts | 192 +++++++++++------- .../lexical-selection/src/range-selection.ts | 49 +++-- 2 files changed, 156 insertions(+), 85 deletions(-) diff --git a/packages/lexical-selection/src/__tests__/unit/WrapNodesBlockLeaf.test.ts b/packages/lexical-selection/src/__tests__/unit/WrapNodesBlockLeaf.test.ts index 3e3a39de1e6..9c830dce1eb 100644 --- a/packages/lexical-selection/src/__tests__/unit/WrapNodesBlockLeaf.test.ts +++ b/packages/lexical-selection/src/__tests__/unit/WrapNodesBlockLeaf.test.ts @@ -104,6 +104,87 @@ function outline(editor: LexicalEditor): { }); } +// Random documents of paragraphs, rules (adjacent ones too), boxes (nested +// up to twice) and, with `directText`, text directly in a box, and random +// ranges between text and element points: the documents where $wrapNodes +// throws (a parent cycle: it would never return) or does not keep every text +// in order. +function wrapFailures(directText: boolean): string[] { + let seed = 9638; + const random = () => { + seed = (seed * 1103515245 + 12345) % 2147483648; + return seed / 2147483648; + }; + const failures: string[] = []; + for (let run = 0; run < 500; run++) { + parentCalls = 0; + using editor = build(); + let n = 0; + const $children = (depth: number): LexicalNode[] => { + const out: LexicalNode[] = []; + const count = 1 + Math.floor(random() * 4); + for (let i = 0; i < count; i++) { + const r = random(); + if (r < 0.25) { + out.push($createHorizontalRuleNode()); + } else if (r < 0.4 && depth < 2) { + out.push($create(BoxNode).append(...$children(depth + 1))); + } else if (directText && r < 0.55 && depth > 0) { + // Text directly in a box, next to its blocks. + out.push($createTextNode(`t${n++}`)); + } else { + out.push($createParagraphNode().append($createTextNode(`t${n++}`))); + } + } + return out; + }; + let before: string[] = []; + let error: unknown = null; + try { + editor.update( + () => { + $getRoot() + .clear() + .append(...$children(0)); + before = $getRoot() + .getAllTextNodes() + .map(t => t.getTextContent()); + const points: [string, number, 'text' | 'element'][] = []; + const collect = (node: LexicalNode) => { + if ($isTextNode(node)) { + for (let o = 0; o <= node.getTextContentSize(); o++) { + points.push([node.getKey(), o, 'text']); + } + } else if ($isElementNode(node)) { + for (let o = 0; o <= node.getChildrenSize(); o++) { + points.push([node.getKey(), o, 'element']); + } + node.getChildren().forEach(collect); + } + }; + collect($getRoot()); + const a = points[Math.floor(random() * points.length)]; + const b = points[Math.floor(random() * points.length)]; + const selection = $createRangeSelection(); + (selection.anchor as PointType).set(...a); + (selection.focus as PointType).set(...b); + $setSelection(selection); + $wrapNodes(selection, () => $createQuoteNode()); + }, + {discrete: true}, + ); + } catch (e) { + error = e; + } + const after = outline(editor).all.filter(t => t !== '
'); + // Adjacent texts may merge: compare the text in order. + if (error !== null || after.join('') !== before.join('')) { + failures.push(`run ${run}: ${String(error ?? after.join())}`); + } + } + return failures; +} + describe('$wrapNodes with a block leaf in an element that is not a block container (#9638)', () => { test('a range across a horizontal rule in a box keeps every paragraph', () => { using editor = build(); @@ -168,6 +249,36 @@ describe('$wrapNodes with a block leaf in an element that is not a block contain }); }); + // A box that holds text directly is the block of that text and of the + // rules next to it: its children move together, as before. + for (const backward of [false, true]) { + test(`a box with text and two rules in it, from the second rule (${backward ? 'backward' : 'forward'})`, () => { + using editor = build(); + editor.update( + () => { + const cc = $createTextNode('cc'); + const box = $create(BoxNode).append( + $createTextNode('aa'), + $createHorizontalRuleNode(), + $createHorizontalRuleNode(), + cc, + ); + $getRoot().clear().append(box); + const selection = $createRangeSelection(); + const [from, to] = backward + ? [selection.focus, selection.anchor] + : [selection.anchor, selection.focus]; + from.set(box.getKey(), 2, 'element'); + to.set(cc.getKey(), 2, 'text'); + $setSelection(selection); + $wrapNodes(selection, () => $createQuoteNode()); + }, + {discrete: true}, + ); + expect(outline(editor).all).toEqual(['aa', '
', '
', 'cc']); + }); + } + test('control: the same range without the rule', () => { using editor = build(); editor.update( @@ -225,77 +336,16 @@ describe('$wrapNodes with a block leaf in an element that is not a block contain }); }); - // Random documents of paragraphs, rules and boxes (nested up to twice), - // and random ranges between text and element points: $wrapNodes returns, - // and keeps every text and rule, in order. test('property: random box, rule and paragraph mixes', () => { - let seed = 9638; - const random = () => { - seed = (seed * 1103515245 + 12345) % 2147483648; - return seed / 2147483648; - }; - const failures: string[] = []; - for (let run = 0; run < 300; run++) { - using editor = build(); - let n = 0; - const $children = (depth: number): LexicalNode[] => { - const out: LexicalNode[] = []; - const count = 1 + Math.floor(random() * 4); - for (let i = 0; i < count; i++) { - const r = random(); - if (r < 0.25) { - out.push($createHorizontalRuleNode()); - } else if (r < 0.4 && depth < 2) { - out.push($create(BoxNode).append(...$children(depth + 1))); - } else { - out.push($createParagraphNode().append($createTextNode(`t${n++}`))); - } - } - return out; - }; - let before: string[] = []; - let error: unknown = null; - try { - editor.update( - () => { - $getRoot() - .clear() - .append(...$children(0)); - before = $getRoot() - .getAllTextNodes() - .map(t => t.getTextContent()); - const points: [string, number, 'text' | 'element'][] = []; - const collect = (node: LexicalNode) => { - if ($isTextNode(node)) { - for (let o = 0; o <= node.getTextContentSize(); o++) { - points.push([node.getKey(), o, 'text']); - } - } else if ($isElementNode(node)) { - for (let o = 0; o <= node.getChildrenSize(); o++) { - points.push([node.getKey(), o, 'element']); - } - node.getChildren().forEach(collect); - } - }; - collect($getRoot()); - const a = points[Math.floor(random() * points.length)]; - const b = points[Math.floor(random() * points.length)]; - const selection = $createRangeSelection(); - (selection.anchor as PointType).set(...a); - (selection.focus as PointType).set(...b); - $setSelection(selection); - $wrapNodes(selection, () => $createQuoteNode()); - }, - {discrete: true}, - ); - } catch (e) { - error = e; - } - const after = outline(editor).all.filter(t => t !== '
'); - if (error !== null || after.join() !== before.join()) { - failures.push(`run ${run}: ${String(error ?? after.join())}`); - } - } - expect(failures).toEqual([]); + expect(wrapFailures(false)).toEqual([]); + }); + + // With text directly in a box next to its blocks, $wrapNodes reorders text + // or never returns in some documents on main too (#9665): pinned until + // that is fixed. This change adds no failure there (main fails 8 of these + // 500 documents, 4 of them by never returning; this change fails 7, 3 by + // never returning, all of them among main's). + test.fails('property: with text directly in a box (#9665)', () => { + expect(wrapFailures(true)).toEqual([]); }); }); diff --git a/packages/lexical-selection/src/range-selection.ts b/packages/lexical-selection/src/range-selection.ts index 66038bf69da..b809a20e95a 100644 --- a/packages/lexical-selection/src/range-selection.ts +++ b/packages/lexical-selection/src/range-selection.ts @@ -287,20 +287,37 @@ function $wrappedBlock( /** * A leaf that is a block of its own (a block decorator, such as a horizontal - * rule) in an element that is not a block container: it is wrapped on its - * own, as it is in a block container. Why: taking its parent for its block - * moved all the parent's children into one new element, those before the - * range included, and the new elements then went next to one of them, inside - * that new element: the update never returned (#9638). + * rule) in an element that is not a block container but holds blocks only + * (a box that declares no structure): it is wrapped on its own, as it is in a + * block container. Why: taking its parent for its block moved all the + * parent's children into one new element, those before the range included, + * and the new elements then went next to one of them, inside that new + * element: the update never returned (#9638). Where the parent also holds + * inline content directly, the parent is the block of that content, and of + * the leaf, as before: its children move together, and the new elements go + * where it is. `cache` holds the answer for each parent already looked at. */ -function $isBlockLeafInElement(node: LexicalNode): boolean { +function $isBlockLeafInElement( + node: LexicalNode, + cache: Map = new Map(), +): boolean { const parent = node.getParent(); - return ( - parent !== null && - $isLeafNode(node) && - !INTERNAL_isInlineContent(node) && - !$isBlockContainer(parent) - ); + if ( + parent === null || + !$isLeafNode(node) || + INTERNAL_isInlineContent(node) || + $isBlockContainer(parent) + ) { + return false; + } + let blocksOnly = cache.get(parent.getKey()); + if (blocksOnly === undefined) { + blocksOnly = parent + .getChildren() + .every(child => !INTERNAL_isInlineContent(child)); + cache.set(parent.getKey(), blocksOnly); + } + return blocksOnly; } /** An empty element that is not inline content. */ @@ -516,6 +533,7 @@ export function $wrapNodesImpl( // structures. const blockOf = new Map(); const childOf = new Map(); + const blockLeafParents = new Map(); const firstNodeBlock = $wrappedBlock(firstNode, blockOf); if (firstNodeBlock === null) { return; @@ -526,7 +544,7 @@ export function $wrapNodesImpl( if ($isBlockContainer(firstNodeBlock)) { const child = $childIn(firstNodeBlock, firstNode, childOf); target = INTERNAL_isInlineContent(child) ? $inlineRun(child)[0] : child; - } else if ($isBlockLeafInElement(firstNode)) { + } else if ($isBlockLeafInElement(firstNode, blockLeafParents)) { target = firstNode; } @@ -576,7 +594,10 @@ export function $wrapNodesImpl( const runOf = new Map(); for (let i = 0; i < nodesLength; i++) { const nodeParent = nodes[i].getParent(); - if (nodeParent !== null && $isBlockLeafInElement(nodes[i])) { + if ( + nodeParent !== null && + $isBlockLeafInElement(nodes[i], blockLeafParents) + ) { units.push({block: nodeParent, key: nodes[i].getKey(), run: [nodes[i]]}); continue; }