From 7c5e9ec7c366d83461a691b97490f36e730127c4 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Tue, 19 May 2026 15:05:18 -0400 Subject: [PATCH] [lexical] Bug Fix: handle triple-click overselection in `$setBlocksType` (#8517) Co-authored-by: Bob Ippolito Co-authored-by: Claude --- .../__tests__/unit/LexicalSelection.test.tsx | 206 +++++++++++++++++- .../lexical-selection/src/range-selection.ts | 44 +++- packages/lexical/src/LexicalSelection.ts | 3 +- packages/lexical/src/LexicalUtils.ts | 4 +- 4 files changed, 248 insertions(+), 9 deletions(-) diff --git a/packages/lexical-selection/src/__tests__/unit/LexicalSelection.test.tsx b/packages/lexical-selection/src/__tests__/unit/LexicalSelection.test.tsx index 00fefc7d3cd..d65cf2d3325 100644 --- a/packages/lexical-selection/src/__tests__/unit/LexicalSelection.test.tsx +++ b/packages/lexical-selection/src/__tests__/unit/LexicalSelection.test.tsx @@ -6,7 +6,8 @@ * */ -import {$createLinkNode} from '@lexical/link'; +import {buildEditorFromExtensions} from '@lexical/extension'; +import {$createLinkNode, LinkExtension} from '@lexical/link'; import { $createListItemNode, $createListNode, @@ -22,7 +23,11 @@ import {HistoryPlugin} from '@lexical/react/LexicalHistoryPlugin'; import {ListPlugin} from '@lexical/react/LexicalListPlugin'; import {MarkdownShortcutPlugin} from '@lexical/react/LexicalMarkdownShortcutPlugin'; import {RichTextPlugin} from '@lexical/react/LexicalRichTextPlugin'; -import {$createHeadingNode} from '@lexical/rich-text'; +import { + $createHeadingNode, + $isHeadingNode, + RichTextExtension, +} from '@lexical/rich-text'; import { $getSelectionStyleValueForProperty, $patchStyleText, @@ -38,6 +43,7 @@ import { $getSelection, $isElementNode, $isLineBreakNode, + $isParagraphNode, $isRangeSelection, $isTextNode, $setSelection, @@ -3152,6 +3158,202 @@ describe('LexicalSelection tests', () => { ); }); + test('Triple-click overselection: focus at element offset 0 of non-empty next block is skipped', () => { + using testEditor = buildEditorFromExtensions({ + $initialEditorState: () => { + const text1 = $createTextNode('text 1'); + const paragraph2 = $createParagraphNode(); + $getRoot().append( + $createParagraphNode().append(text1), + paragraph2.append($createLineBreakNode()), + ); + + // Browser triple-click: focus lands on the text node of the + // following block at offset 0, even though visually only + // paragraph1's content is selected. + const selection = text1.selectStart(); + selection.focus.set(paragraph2.getKey(), 0, 'element'); + + $setBlocksType(selection, () => $createHeadingNode('h1')); + }, + dependencies: [RichTextExtension], + name: '@test', + }); + testEditor.read(() => { + const rootChildren = $getRoot().getChildren(); + expect($isHeadingNode(rootChildren[0])).toBe(true); + expect($isParagraphNode(rootChildren[1])).toBe(true); + expect(rootChildren.length).toBe(2); + }); + }); + + test('Triple-click overselection: focus at element offset 0 of empty next block is converted', () => { + using testEditor = buildEditorFromExtensions({ + $initialEditorState: () => { + const text1 = $createTextNode('text 1'); + const paragraph2 = $createParagraphNode(); + $getRoot().append($createParagraphNode().append(text1), paragraph2); + + // Browser triple-click: focus lands on the text node of the + // following block at offset 0, even though visually only + // paragraph1's content is selected. + const selection = text1.selectStart(); + selection.focus.set(paragraph2.getKey(), 0, 'element'); + + $setBlocksType(selection, () => $createHeadingNode('h1')); + }, + dependencies: [RichTextExtension], + name: '@test', + }); + testEditor.read(() => { + const rootChildren = $getRoot().getChildren(); + expect($isHeadingNode(rootChildren[0])).toBe(true); + expect($isHeadingNode(rootChildren[1])).toBe(true); + expect(rootChildren.length).toBe(2); + }); + }); + + test('Triple-click overselection: focus at offset 0 of next block is skipped', () => { + using testEditor = buildEditorFromExtensions({ + $initialEditorState: () => { + const text1 = $createTextNode('text 1'); + const text2 = $createTextNode('text 2'); + $getRoot().append( + $createParagraphNode().append(text1), + $createParagraphNode().append(text2), + ); + + // Browser triple-click: focus lands on the text node of the + // following block at offset 0, even though visually only + // paragraph1's content is selected. + const selection = text1.select().setTextNodeRange(text1, 0, text2, 0); + + $setBlocksType(selection, () => $createHeadingNode('h1')); + }, + dependencies: [RichTextExtension], + name: '@test', + }); + testEditor.read(() => { + const rootChildren = $getRoot().getChildren(); + expect($isHeadingNode(rootChildren[0])).toBe(true); + expect($isParagraphNode(rootChildren[1])).toBe(true); + expect(rootChildren.length).toBe(2); + }); + }); + + test('Triple-click overselection: focus inside nested inline at offset 0 is skipped', () => { + using testEditor = buildEditorFromExtensions({ + $initialEditorState: () => { + const text1 = $createTextNode('text 1'); + const text2 = $createTextNode('text 2'); + $getRoot().append( + $createParagraphNode().append(text1), + $createParagraphNode().append( + $createLinkNode('https://lexical.dev').append(text2), + ), + ); + + // Browser triple-click: focus lands on the text node of the + // following block at offset 0, even though visually only + // paragraph1's content is selected. + const selection = text1.select().setTextNodeRange(text1, 0, text2, 0); + + $setBlocksType(selection, () => $createHeadingNode('h1')); + }, + dependencies: [RichTextExtension, LinkExtension], + name: '@test', + }); + testEditor.read(() => { + const rootChildren = $getRoot().getChildren(); + expect($isHeadingNode(rootChildren[0])).toBe(true); + expect($isParagraphNode(rootChildren[1])).toBe(true); + expect(rootChildren.length).toBe(2); + }); + }); + + test('Non-zero focus offset in next block still converts both blocks', () => { + using testEditor = buildEditorFromExtensions({ + $initialEditorState: () => { + const text1 = $createTextNode('text 1'); + const text2 = $createTextNode('text 2'); + $getRoot().append( + $createParagraphNode().append(text1), + $createParagraphNode().append(text2), + ); + + const selection = text1.select().setTextNodeRange(text1, 0, text2, 1); + + $setBlocksType(selection, () => $createHeadingNode('h1')); + }, + dependencies: [RichTextExtension], + name: '@test', + }); + testEditor.read(() => { + const rootChildren = $getRoot().getChildren(); + expect($isHeadingNode(rootChildren[0])).toBe(true); + expect($isHeadingNode(rootChildren[1])).toBe(true); + expect(rootChildren.length).toBe(2); + }); + }); + + test('Triple-click overselection spanning multiple blocks skips only the focus block', () => { + using testEditor = buildEditorFromExtensions({ + $initialEditorState: () => { + const text1 = $createTextNode('text 1'); + const text2 = $createTextNode('text 2'); + const text3 = $createTextNode('text 3'); + $getRoot().append( + $createParagraphNode().append(text1), + $createParagraphNode().append(text2), + $createParagraphNode().append(text3), + ); + + const selection = text1.select().setTextNodeRange(text1, 0, text3, 0); + + $setBlocksType(selection, () => $createHeadingNode('h1')); + }, + dependencies: [RichTextExtension], + name: '@test', + }); + testEditor.read(() => { + const rootChildren = $getRoot().getChildren(); + expect($isHeadingNode(rootChildren[0])).toBe(true); + expect($isHeadingNode(rootChildren[1])).toBe(true); + expect($isParagraphNode(rootChildren[2])).toBe(true); + expect(rootChildren.length).toBe(3); + }); + }); + + test('Focus at offset 0 in next block whose first descendant has a prior sibling still converts focus block', () => { + using testEditor = buildEditorFromExtensions({ + $initialEditorState: () => { + const text1 = $createTextNode('text 1'); + const text2a = $createTextNode('foo'); + const text2b = $createTextNode('bar').setFormat('bold'); + $getRoot().append( + $createParagraphNode().append(text1), + // text2b is the second child; offset 0 of text2b is NOT at + // the start of paragraph2 (text2a precedes it). + $createParagraphNode().append(text2a, text2b), + ); + + const selection = text1 + .select() + .setTextNodeRange(text1, 0, text2b, 0); + + $setBlocksType(selection, () => $createHeadingNode('h1')); + }, + dependencies: [RichTextExtension], + name: '@test', + }); + testEditor.read(() => { + const rootChildren = $getRoot().getChildren(); + expect($isHeadingNode(rootChildren[0])).toBe(true); + expect($isHeadingNode(rootChildren[1])).toBe(true); + expect(rootChildren.length).toBe(2); + }); + }); + test('Nested list with listItem twice indented from its parent', async () => { const testEditor = createTestEditor(); const element = document.createElement('div'); diff --git a/packages/lexical-selection/src/range-selection.ts b/packages/lexical-selection/src/range-selection.ts index 04cbe9d3f35..43a79c6e4a5 100644 --- a/packages/lexical-selection/src/range-selection.ts +++ b/packages/lexical-selection/src/range-selection.ts @@ -8,6 +8,7 @@ import type { BaseSelection, + DecoratorNode, ElementNode, LexicalNode, NodeKey, @@ -53,6 +54,30 @@ export function $copyBlockFormatIndent( } } +function $isPointAtBlockStart(point: Point, block: ElementNode): boolean { + if (point.offset !== 0) { + return false; + } + let node: LexicalNode = point.getNode(); + // When an ElementNode is empty it's not possible to distinguish if + // the selection's intent is the entire block or the edge so we consider + // it to be the entire block + if ($isElementNode(node) && node.isEmpty()) { + return false; + } + while (!node.is(block)) { + if (node.getPreviousSibling() !== null) { + return false; + } + const parent = node.getParent(); + if (parent === null) { + return false; + } + node = parent; + } + return true; +} + /** * Converts all nodes in the selection that are of one block type to another. * @param selection - The selected blocks to be converted. @@ -67,12 +92,14 @@ export function $setBlocksType( newNodeDest: T, ) => void = $copyBlockFormatIndent, ): void { - if (selection === null) { + if (!selection) { return; } // Selections tend to not include their containing blocks so we effectively // expand it here const anchorAndFocus = selection.getStartEndPoints(); + let skipFocusAtBlockStart = false; + let focusBlock: ElementNode | DecoratorNode | null = null; const blockMap = new Map(); if (anchorAndFocus) { const [anchor, focus] = anchorAndFocus; @@ -80,18 +107,25 @@ export function $setBlocksType( anchor.getNode(), INTERNAL_$isBlock, ); - const focusBlock = $findMatchingParent(focus.getNode(), INTERNAL_$isBlock); + focusBlock = $findMatchingParent(focus.getNode(), INTERNAL_$isBlock); + skipFocusAtBlockStart = + $isElementNode(focusBlock) && + !focusBlock.is(anchorBlock) && + $isPointAtBlockStart(focus, focusBlock); if ($isElementNode(anchorBlock)) { blockMap.set(anchorBlock.getKey(), anchorBlock); } - if ($isElementNode(focusBlock)) { + if ($isElementNode(focusBlock) && !skipFocusAtBlockStart) { blockMap.set(focusBlock.getKey(), focusBlock); } } for (const node of selection.getNodes()) { if ($isElementNode(node) && INTERNAL_$isBlock(node)) { + if (skipFocusAtBlockStart && node.is(focusBlock)) { + continue; + } blockMap.set(node.getKey(), node); - } else if (anchorAndFocus === null) { + } else if (!anchorAndFocus) { const ancestorBlock = $findMatchingParent(node, INTERNAL_$isBlock); if ($isElementNode(ancestorBlock)) { blockMap.set(ancestorBlock.getKey(), ancestorBlock); @@ -101,7 +135,7 @@ export function $setBlocksType( // Selection remapping is delegated to LexicalNode.replace (and the // ListItemNode.replace override): both remap an element-anchored point // on the replaced block to {key: replacement, offset: prevSize + offset}. - for (const [, prevNode] of blockMap) { + for (const prevNode of blockMap.values()) { const element = $createElement(); $afterCreateElement(prevNode, element); prevNode.replace(element, true); diff --git a/packages/lexical/src/LexicalSelection.ts b/packages/lexical/src/LexicalSelection.ts index 2d048b4619d..3949d3ac3ab 100644 --- a/packages/lexical/src/LexicalSelection.ts +++ b/packages/lexical/src/LexicalSelection.ts @@ -580,9 +580,10 @@ export class RangeSelection implements BaseSelection { anchorOffset: number, focusNode: TextNode, focusOffset: number, - ): void { + ): this { this.anchor.set(anchorNode.__key, anchorOffset, 'text'); this.focus.set(focusNode.__key, focusOffset, 'text'); + return this; } /** diff --git a/packages/lexical/src/LexicalUtils.ts b/packages/lexical/src/LexicalUtils.ts index 9845d14f3dd..d11603cbf3b 100644 --- a/packages/lexical/src/LexicalUtils.ts +++ b/packages/lexical/src/LexicalUtils.ts @@ -1942,6 +1942,8 @@ export function isBlockDomNode( : BLOCK_TAG_RE.test(node.nodeName); } +const BlockNodeBrand: unique symbol = Symbol.for('@lexical/BlockNodeBrand'); + /** * @internal * @@ -1957,7 +1959,7 @@ export function isBlockDomNode( */ export function INTERNAL_$isBlock( node: LexicalNode, -): node is ElementNode | DecoratorNode { +): node is (ElementNode | DecoratorNode) & {[BlockNodeBrand]: never} { if ($isDecoratorNode(node) && !node.isInline()) { return true; }