From a73afb4c68e19529a97687af0e4f1d9869fbad19 Mon Sep 17 00:00:00 2001 From: vijayojha89 Date: Mon, 5 Oct 2026 21:23:01 +0530 Subject: [PATCH 01/18] Decorator nodes are not selectable with mouse --- .../__tests__/utils/index.mjs | 5 +- packages/lexical/src/LexicalDOMSlot.ts | 23 +- packages/lexical/src/LexicalReconciler.ts | 21 +- packages/lexical/src/LexicalSelection.ts | 61 ++- .../browser/WebkitLinebreakImg.test.ts | 28 +- .../src/__tests__/unit/Issue7158Repro.test.ts | 389 ++++++++++++++++++ .../src/__tests__/unit/Issue8922Repro.test.ts | 18 +- .../src/__tests__/unit/LexicalEditor.test.tsx | 2 + 8 files changed, 522 insertions(+), 25 deletions(-) create mode 100644 packages/lexical/src/__tests__/unit/Issue7158Repro.test.ts diff --git a/packages/lexical-playground/__tests__/utils/index.mjs b/packages/lexical-playground/__tests__/utils/index.mjs index 340b24b3a90..a7d946271c0 100644 --- a/packages/lexical-playground/__tests__/utils/index.mjs +++ b/packages/lexical-playground/__tests__/utils/index.mjs @@ -373,8 +373,11 @@ export async function clickSelectors(page, selectors) { } } +// WebKit, and Chromium since #7158, render the managed line break after a +// trailing inline decorator as an img+br pair; keep the img out of the HTML +// the specs assert on. function removeSafariLinebreakImgHack(actualHtml) { - return E2E_BROWSER === 'webkit' + return E2E_BROWSER === 'webkit' || E2E_BROWSER === 'chromium' ? actualHtml.replaceAll( /]+ )?data-lexical-managed-linebreak="true"(?: [^>]+)?>/g, '', diff --git a/packages/lexical/src/LexicalDOMSlot.ts b/packages/lexical/src/LexicalDOMSlot.ts index 88047c06ef6..1566142d32a 100644 --- a/packages/lexical/src/LexicalDOMSlot.ts +++ b/packages/lexical/src/LexicalDOMSlot.ts @@ -11,7 +11,13 @@ import type {ElementNode} from './nodes/LexicalElementNode'; import invariant from '@lexical/internal/invariant'; -import {IS_APPLE_WEBKIT, IS_IOS, IS_SAFARI} from './environment'; +import { + IS_ANDROID, + IS_APPLE_WEBKIT, + IS_CHROME, + IS_IOS, + IS_SAFARI, +} from './environment'; import {$getDocument, $getEditor} from './LexicalUtils'; /** @@ -57,6 +63,11 @@ const IS_WEBKIT_BROWSER = IS_APPLE_WEBKIT || IS_IOS || IS_SAFARI; * it when both endpoints do. Interior element points next to the same decorator * paint fine everywhere; only the first / last child matters. * + * The leading anchor is also used before an *inline* first-child decorator: + * without an editable position there, a click at the start of the line puts + * the caret inside the decorator's non-editable DOM, and a mouse drag that + * starts there cannot extend past that decorator (#7158). + * * Parking a zero-size, out-of-flow `` on the outside of such a boundary * decorator gives the browser an editable inline box to canonicalize the * boundary position against, which restores the highlight — including over the @@ -447,7 +458,15 @@ export class ElementDOMSlot< if (nextLineBreakType === null) { this.removeManagedLineBreak(); } else { - const webkitHack = nextLineBreakType === 'decorator' && IS_WEBKIT_BROWSER; + // Desktop Chromium needs the same editable inline box after a trailing + // inline decorator: without it, a click to the right of the decorator + // puts the DOM caret inside its contentEditable=false DOM, and a mouse + // drag that starts there can never extend beyond that decorator + // (#7158). Android is left alone: it has no mouse drag, and its IME is + // sensitive to the DOM around the caret. + const webkitHack = + nextLineBreakType === 'decorator' && + (IS_WEBKIT_BROWSER || (IS_CHROME && !IS_ANDROID)); this.insertManagedLineBreak(webkitHack); } } diff --git a/packages/lexical/src/LexicalReconciler.ts b/packages/lexical/src/LexicalReconciler.ts index a297447e8d6..69eb8d963f2 100644 --- a/packages/lexical/src/LexicalReconciler.ts +++ b/packages/lexical/src/LexicalReconciler.ts @@ -954,6 +954,22 @@ function $isBlockDecoratorChild( return $isDecoratorNode(node) && !node.isInline(); } +/** + * Whether the element's first child needs the leading boundary anchor: any + * DecoratorNode, block or inline. A line that starts with an inline decorator + * has no editable caret position before it either, so a browser canonicalizes + * a caret at the start of the line into the decorator's non-editable DOM and + * will not extend a mouse drag out of it (#7158). (The trailing edge of an + * inline decorator already has an editable position: the managed line break + * that {@link $reconcileElementTerminatingLineBreak} appends after it.) + */ +function $isLeadingDecoratorChild( + key: null | NodeKey, + nodeMap: NodeMap, +): boolean { + return !!key && $isDecoratorNode(nodeMap.get(key)); +} + /** * Browsers drop the selection highlight for the whole document when a range * endpoint lands on an element boundary that is immediately adjacent to a @@ -962,6 +978,9 @@ function $isBlockDecoratorChild( * out-of-flow anchor parked outside each such boundary child so the browser has * an editable inline box to resolve the boundary position against. Interior * decorators are unaffected, so only the first / last child is considered. + * The leading anchor is also parked before an inline first-child decorator, + * so a mouse drag can start at the start of such a line + * (see {@link $isLeadingDecoratorChild}, #7158). */ function $reconcileDecoratorBoundaryAnchors( nextElement: ElementNode, @@ -970,7 +989,7 @@ function $reconcileDecoratorBoundaryAnchors( const slot = $getDOMSlot(nextElement, dom, activeEditor); slot.setDecoratorBoundaryAnchor( 'leading', - $isBlockDecoratorChild(nextElement.__first, activeNextNodeMap), + $isLeadingDecoratorChild(nextElement.__first, activeNextNodeMap), ); slot.setDecoratorBoundaryAnchor( 'trailing', diff --git a/packages/lexical/src/LexicalSelection.ts b/packages/lexical/src/LexicalSelection.ts index 6176e38a0c1..bdab55bc296 100644 --- a/packages/lexical/src/LexicalSelection.ts +++ b/packages/lexical/src/LexicalSelection.ts @@ -3332,6 +3332,23 @@ export function $clampRangeSelectionToSlotFrame( return clamped; } +/** + * Whether a DOM selection endpoint is on an inline DecoratorNode's own + * element: the DOM the decorator's `createDOM` returned, between its children, + * rather than somewhere inside the decorator's content. + */ +function $isOnInlineDecoratorElement( + node: LexicalNode | null, + dom: Node, + editor: LexicalEditor, +): boolean { + return ( + $isDecoratorNode(node) && + node.isInline() && + editor.getElementByKey(node.getKey()) === dom + ); +} + function $internalResolveSelectionPoints( anchorDOM: null | Node, anchorOffset: number, @@ -3371,18 +3388,56 @@ function $internalResolveSelectionPoints( $validatePoint('anchor', resolvedAnchorPoint); $validatePoint('focus', resolvedFocusPoint); } + // Set when a DOM endpoint sits on an inline decorator's own element and the + // resolved point is outside it, in the decorator's parent (#7158). + let decoratorDirty = false; if ( resolvedAnchorPoint.type === 'element' && resolvedFocusPoint.type === 'element' ) { const anchorNode = $getNodeFromDOM(anchorDOM); const focusNode = $getNodeFromDOM(focusDOM); + const anchorOnDecorator = $isOnInlineDecoratorElement( + anchorNode, + anchorDOM, + editor, + ); + const focusOnDecorator = $isOnInlineDecoratorElement( + focusNode, + focusDOM, + editor, + ); // Ensure if we're selecting the content of a decorator that we // return null for this point, as it's not in the controlled scope - // of Lexical. - if ($isDecoratorNode(anchorNode) && $isDecoratorNode(focusNode)) { + // of Lexical. Two shapes with both endpoints in inline decorators are a + // selection Lexical owns instead (#7158); each point has already resolved + // to just before or after its decorator in the parent: + // - endpoints in two *different* inline decorators: a mouse drag across + // a run of inline decorators with no text around them; + // - endpoints on an inline decorator's own element (between its + // children, not inside its content): where a browser puts the caret + // for a click beside the decorator at the start or end of such a + // line. Resolving that to null would make the commit remove every DOM + // range and abort the drag it started. + // Block decorators and a selection inside a decorator's content keep + // resolving to null. + if ( + $isDecoratorNode(anchorNode) && + $isDecoratorNode(focusNode) && + !( + anchorNode.isInline() && + focusNode.isInline() && + (!anchorNode.is(focusNode) || (anchorOnDecorator && focusOnDecorator)) + ) + ) { return null; } + // The decorator's DOM is contentEditable=false. A browser that keeps the + // DOM caret there will not extend a mouse drag beyond that decorator, so + // only the decorator the drag started on would ever be selected. Mark the + // selection dirty so the reconciler moves the DOM selection to the + // resolved positions in the editable parent. + decoratorDirty = anchorOnDecorator || focusOnDecorator; } // @experimental named-slots. Clamp a slot-straddling drag into the @@ -3412,7 +3467,7 @@ function $internalResolveSelectionPoints( return [ resolvedAnchorPoint, resolvedFocusPoint, - anchorDirty || focusDirty || slotClamped, + anchorDirty || focusDirty || slotClamped || decoratorDirty, ]; } diff --git a/packages/lexical/src/__tests__/browser/WebkitLinebreakImg.test.ts b/packages/lexical/src/__tests__/browser/WebkitLinebreakImg.test.ts index 41c64aeb2d2..5c8185b2040 100644 --- a/packages/lexical/src/__tests__/browser/WebkitLinebreakImg.test.ts +++ b/packages/lexical/src/__tests__/browser/WebkitLinebreakImg.test.ts @@ -7,13 +7,15 @@ */ /** - * Characterization tests for the WebKit managed-linebreak img hack. + * Characterization tests for the managed-linebreak img hack. * - * On WebKit, when a block's last child is an *inline* DecoratorNode the + * On WebKit and desktop Chromium (since #7158), when a block's last child is + * an *inline* DecoratorNode the * managed line break is rendered as an in-flow `` followed by the usual * `
` (see `ElementDOMSlot.insertManagedLineBreak`'s `webkitHack`), giving - * Safari an editable inline box between the `contenteditable=false` decorator - * and the break. The user-visible symptom it fixes involves native Safari + * the browser an editable inline box between the `contenteditable=false` + * decorator and the break. In Chromium that box is what lets a mouse drag + * start to the right of a line's last inline decorator (#7158). The user-visible symptom it fixes involves native Safari * caret behavior that headless Linux WebKit does not reproduce, so these * tests pin the *mechanism* — the DOM contract of * `setManagedLineBreak('decorator')` on each real engine — such that removing @@ -21,8 +23,8 @@ * would (no other test exercises it). * * Running in browser mode means the real environment detection is live: the - * webkit instance asserts the img+br shape and chromium / firefox assert the - * plain-br shape, all from the same file. + * webkit and chromium instances assert the img+br shape and firefox asserts + * the plain-br shape, all from the same file. */ import {buildEditorFromExtensions, defineExtension} from '@lexical/extension'; @@ -34,7 +36,9 @@ import { $getRoot, $isParagraphNode, DecoratorNode, + IS_ANDROID, IS_APPLE_WEBKIT, + IS_CHROME, IS_IOS, IS_SAFARI, } from 'lexical'; @@ -43,7 +47,8 @@ import {describe, expect, onTestFinished, test} from 'vitest'; import {$assertNodeType} from '../utils/assertNodeType'; // Matches the `webkitHack` gate in ElementDOMSlot.setManagedLineBreak. -const EXPECTS_IMG_HACK = IS_SAFARI || IS_IOS || IS_APPLE_WEBKIT; +const EXPECTS_IMG_HACK = + IS_SAFARI || IS_IOS || IS_APPLE_WEBKIT || (IS_CHROME && !IS_ANDROID); const DECORATOR_LINEBREAK = EXPECTS_IMG_HACK ? ['img', 'br'] : ['br']; class TestInlineDecoratorNode extends DecoratorNode { @@ -98,7 +103,7 @@ function linebreakScaffold(contentEditable: HTMLElement): string[] { ); } -describe('WebKit managed-linebreak img hack (inline decorator last child)', () => { +describe('managed-linebreak img hack (inline decorator last child)', () => { test('a block ending with an inline decorator gets the engine-appropriate scaffold', () => { const {contentEditable, editor} = mountEditor(); editor.update( @@ -115,9 +120,10 @@ describe('WebKit managed-linebreak img hack (inline decorator last child)', () = {discrete: true}, ); - // WebKit: an in-flow img gives Safari an editable inline box between the - // contenteditable=false decorator and the break, and must precede the br - // (the e2e selection utils rely on that order). Everywhere else: plain br. + // WebKit and desktop Chromium: an in-flow img gives the browser an + // editable inline box between the contenteditable=false decorator and the + // break, and must precede the br (the e2e selection utils rely on that + // order). Everywhere else: plain br. expect(linebreakScaffold(contentEditable)).toEqual(DECORATOR_LINEBREAK); const img = contentEditable.querySelector( 'p img[data-lexical-managed-linebreak="true"]', diff --git a/packages/lexical/src/__tests__/unit/Issue7158Repro.test.ts b/packages/lexical/src/__tests__/unit/Issue7158Repro.test.ts new file mode 100644 index 00000000000..71e9452a62d --- /dev/null +++ b/packages/lexical/src/__tests__/unit/Issue7158Repro.test.ts @@ -0,0 +1,389 @@ +/** + * 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. + * + */ + +/** + * Regression tests for #7158 — "Decorator nodes are not selectable with + * mouse". + * + * In a paragraph made only of inline decorators, a mouse drag that starts at + * the edge of the line can leave both DOM selection endpoints inside two + * different decorators' DOM. `$internalResolveSelectionPoints` rejected any + * selection whose endpoints were both inside decorators, which is only meant + * to keep Lexical out of a selection *within one* decorator's own content. The + * update then committed a null selection and removed the DOM ranges, so the + * highlight blinked and was lost. + * + * A click beside such a decorator at the start or end of the line also put + * the caret on the decorator's own (non-editable) element; that caret now + * resolves next to the decorator and is written back to the editable parent, + * so a drag can start from it. Block decorators and selections inside a + * decorator's content still resolve to null, as before. + * + * jsdom has no hit testing, so the DOM anchors that let a real browser start a + * drag at either edge of the line are covered by Issue8922Repro.test.ts. + */ + +import { + buildEditorFromExtensions, + type LexicalEditorWithDispose, +} from '@lexical/extension'; +import { + $create, + $createParagraphNode, + $getRoot, + $getSelection, + $isDecoratorNode, + $isElementNode, + $isRangeSelection, + $setSelection, + DecoratorNode, + defineExtension, + getDOMSelection, + type LexicalEditor, +} from 'lexical'; +import { + afterEach, + assert, + describe, + expect, + onTestFinished, + test, + vi, +} from 'vitest'; + +// Shaped like the node in the issue: an inline, isolated decorator whose DOM +// holds an image (React would portal the into the span). +class ImageLikeDecoratorNode extends DecoratorNode { + $config() { + return this.config('test_7158_image', {extends: DecoratorNode}); + } + createDOM(): HTMLElement { + const span = document.createElement('span'); + span.appendChild(document.createElement('img')); + return span; + } + updateDOM(): false { + return false; + } + isKeyboardSelectable(): boolean { + return false; + } + isIsolated(): boolean { + return true; + } + decorate(): null { + return null; + } +} + +class BlockDecoratorNode extends DecoratorNode { + $config() { + return this.config('test_7158_block', {extends: DecoratorNode}); + } + createDOM(): HTMLElement { + const div = document.createElement('div'); + div.appendChild(document.createElement('img')); + return div; + } + updateDOM(): false { + return false; + } + isInline(): false { + return false; + } + decorate(): null { + return null; + } +} + +const DECORATOR_COUNT = 6; + +describe('Issue #7158: mouse selection across inline decorators', () => { + const mounted: HTMLElement[] = []; + afterEach(() => { + for (const root of mounted.splice(0)) { + root.remove(); + } + }); + + function setUp(): { + editor: LexicalEditorWithDispose; + decoratorDOMs: HTMLElement[]; + decoratorKeys: string[]; + paragraphDOM: HTMLElement; + } { + const editor = buildEditorFromExtensions( + defineExtension({ + $initialEditorState: () => { + $getRoot() + .clear() + .append( + $createParagraphNode().append( + ...Array.from({length: DECORATOR_COUNT}, () => + $create(ImageLikeDecoratorNode), + ), + ), + ); + }, + name: '[7158]', + nodes: [ImageLikeDecoratorNode, BlockDecoratorNode], + }), + ); + onTestFinished(() => editor.dispose()); + const root = document.createElement('div'); + document.body.appendChild(root); + mounted.push(root); + editor.setRootElement(root); + const decoratorKeys = editor.read(() => { + const paragraph = $getRoot().getFirstChild(); + assert($isElementNode(paragraph)); + return paragraph.getChildren().map(node => node.getKey()); + }); + const decoratorDOMs = decoratorKeys.map(key => { + const dom = editor.getElementByKey(key); + assert(dom !== null); + return dom; + }); + const paragraphDOM = decoratorDOMs[0].parentElement; + assert(paragraphDOM !== null); + return {decoratorDOMs, decoratorKeys, editor, paragraphDOM}; + } + + function dragTo( + editor: LexicalEditor, + anchorNode: Node, + anchorOffset: number, + focusNode: Node, + focusOffset: number, + ): void { + const domSelection = getDOMSelection(window); + assert(domSelection !== null); + domSelection.setBaseAndExtent( + anchorNode, + anchorOffset, + focusNode, + focusOffset, + ); + document.dispatchEvent(new Event('selectionchange')); + // Each selectionchange is its own task in a browser, so the update it + // started commits before the next one; flush it the same way here. + editor.read(() => {}); + } + + function selectParagraphStart(editor: LexicalEditor): void { + editor.update( + () => { + $getRoot().selectStart(); + }, + {discrete: true}, + ); + } + + function selectedDecoratorKeys(editor: LexicalEditor): string[] | null { + return editor.read(() => { + const selection = $getSelection(); + if (!$isRangeSelection(selection) || selection.isCollapsed()) { + return null; + } + return selection + .getNodes() + .filter($isDecoratorNode) + .map(node => node.getKey()); + }); + } + + test('dragging backward from inside the last decorator to inside an earlier one selects the decorators in between', () => { + const {editor, decoratorDOMs, decoratorKeys} = setUp(); + // Drag starts after the last image (the end of the line) and ends before + // the third image. + dragTo(editor, decoratorDOMs[5], 1, decoratorDOMs[2], 0); + expect(selectedDecoratorKeys(editor)).toEqual(decoratorKeys.slice(2)); + editor.read(() => { + const selection = $getSelection(); + assert($isRangeSelection(selection)); + expect(selection.isBackward()).toBe(true); + }); + }); + + test('dragging forward from inside the first decorator to inside the last one selects every decorator', () => { + const {editor, decoratorDOMs, decoratorKeys} = setUp(); + dragTo(editor, decoratorDOMs[0], 0, decoratorDOMs[5], 1); + expect(selectedDecoratorKeys(editor)).toEqual(decoratorKeys); + editor.read(() => { + const selection = $getSelection(); + assert($isRangeSelection(selection)); + expect(selection.isBackward()).toBe(false); + }); + }); + + test('endpoints on the images inside two decorators also resolve', () => { + const {editor, decoratorDOMs, decoratorKeys} = setUp(); + const img = (i: number) => decoratorDOMs[i].firstChild as Node; + dragTo(editor, img(1), 0, img(4), 0); + // An has no inner positions, so a point on one resolves to just + // before its decorator: this drag covers decorators 2 through 4. + expect(selectedDecoratorKeys(editor)).toEqual(decoratorKeys.slice(1, 4)); + }); + + test('a caret placed inside the last decorator (click at the end of the line) keeps the DOM selection', () => { + const {editor, decoratorDOMs, decoratorKeys, paragraphDOM} = setUp(); + selectParagraphStart(editor); + const removeAllRanges = vi.spyOn(Selection.prototype, 'removeAllRanges'); + onTestFinished(() => removeAllRanges.mockRestore()); + dragTo(editor, decoratorDOMs[5], 1, decoratorDOMs[5], 1); + // Lexical used to resolve this caret to null, and the commit then removed + // every DOM range, so a drag that started here had nothing to extend. + expect(removeAllRanges).not.toHaveBeenCalled(); + // ...and the DOM caret is moved out of the non-editable decorator. + expect(getDOMSelection(window)?.anchorNode).toBe(paragraphDOM); + // (+1: the leading boundary anchor parked before the first decorator.) + expect(getDOMSelection(window)?.anchorOffset).toBe( + decoratorKeys.length + 1, + ); + editor.read(() => { + const selection = $getSelection(); + assert($isRangeSelection(selection)); + expect(selection.isCollapsed()).toBe(true); + expect(selection.anchor.type).toBe('element'); + expect(selection.anchor.offset).toBe(decoratorKeys.length); + }); + }); + + // Models a real mouse drag: mousedown puts a caret somewhere, then each + // mousemove extends the selection from the browser's current anchor. If + // the anchor were left inside a contentEditable=false decorator, a browser + // would not extend past that decorator, so this asserts the anchor has been + // moved out to the paragraph. + function mouseDrag( + editor: LexicalEditor, + paragraphDOM: HTMLElement, + mouseDown: [Node, number], + moves: [Node, number][], + ): void { + dragTo(editor, mouseDown[0], mouseDown[1], mouseDown[0], mouseDown[1]); + const domSelection = getDOMSelection(window); + assert(domSelection !== null); + expect(domSelection.anchorNode).toBe(paragraphDOM); + for (const [focusNode, focusOffset] of moves) { + domSelection.extend(focusNode, focusOffset); + document.dispatchEvent(new Event('selectionchange')); + editor.read(() => {}); + expect(domSelection.anchorNode).toBe(paragraphDOM); + expect(domSelection.focusNode).toBe(paragraphDOM); + } + } + + test('a mouse drag that starts at the end of the line selects every decorator it crosses', () => { + const {editor, decoratorDOMs, decoratorKeys, paragraphDOM} = setUp(); + selectParagraphStart(editor); + const removeAllRanges = vi.spyOn(Selection.prototype, 'removeAllRanges'); + onTestFinished(() => removeAllRanges.mockRestore()); + mouseDrag( + editor, + paragraphDOM, + [decoratorDOMs[5], 1], // mousedown: caret after the last image + [ + [decoratorDOMs[5], 0], // still over the last image + [decoratorDOMs[4], 0], + [decoratorDOMs[2], 0], // mouseup before the third image + ], + ); + expect(removeAllRanges).not.toHaveBeenCalled(); + expect(selectedDecoratorKeys(editor)).toEqual(decoratorKeys.slice(2)); + }); + + test('a mouse drag that starts at the start of the line selects every decorator it crosses', () => { + const {editor, decoratorDOMs, decoratorKeys, paragraphDOM} = setUp(); + mouseDrag( + editor, + paragraphDOM, + [decoratorDOMs[0], 0], // mousedown: caret before the first image + [ + [decoratorDOMs[0], 1], + [decoratorDOMs[3], 1], + [decoratorDOMs[5], 1], // mouseup after the last image + ], + ); + expect(selectedDecoratorKeys(editor)).toEqual(decoratorKeys); + }); + + test('a selection of text inside a single decorator is still left to the decorator', () => { + const {editor, decoratorDOMs} = setUp(); + const label = document.createTextNode('label'); + decoratorDOMs[3].appendChild(label); + dragTo(editor, label, 1, label, 4); + editor.read(() => { + expect($isRangeSelection($getSelection())).toBe(false); + }); + }); + + test('a drag over a single decorator resolves to the positions around it', () => { + const {editor, decoratorDOMs, decoratorKeys} = setUp(); + dragTo(editor, decoratorDOMs[3], 0, decoratorDOMs[3], 1); + expect(selectedDecoratorKeys(editor)).toEqual([decoratorKeys[3]]); + }); + + // Unchanged behavior: everything below resolved to null before #7158 and + // still does. + + test('a caret on an element inside a single decorator content is still left to the decorator', () => { + const {editor, decoratorDOMs} = setUp(); + const inner = document.createElement('b'); + inner.appendChild(document.createElement('i')); + decoratorDOMs[3].appendChild(inner); + dragTo(editor, inner, 0, inner, 1); + editor.read(() => { + expect($isRangeSelection($getSelection())).toBe(false); + }); + }); + + test('a selection inside a block decorator is still left to the decorator', () => { + const {editor} = setUp(); + let blockKey = ''; + editor.update( + () => { + const block = $create(BlockDecoratorNode); + $getRoot().append(block); + blockKey = block.getKey(); + $setSelection(null); + }, + {discrete: true}, + ); + const blockDOM = editor.getElementByKey(blockKey); + assert(blockDOM !== null); + dragTo(editor, blockDOM, 0, blockDOM, 1); + editor.read(() => { + expect($isRangeSelection($getSelection())).toBe(false); + }); + }); + + test('a drag between two block decorators is still left alone', () => { + const {editor} = setUp(); + const keys: string[] = []; + editor.update( + () => { + for (let i = 0; i < 2; i++) { + const block = $create(BlockDecoratorNode); + $getRoot().append(block); + keys.push(block.getKey()); + } + $setSelection(null); + }, + {discrete: true}, + ); + const [first, second] = keys.map(key => { + const dom = editor.getElementByKey(key); + assert(dom !== null); + return dom; + }); + dragTo(editor, first, 0, second, 1); + editor.read(() => { + expect($isRangeSelection($getSelection())).toBe(false); + }); + }); +}); diff --git a/packages/lexical/src/__tests__/unit/Issue8922Repro.test.ts b/packages/lexical/src/__tests__/unit/Issue8922Repro.test.ts index f9a0ca89fe4..f1317b6ebf3 100644 --- a/packages/lexical/src/__tests__/unit/Issue8922Repro.test.ts +++ b/packages/lexical/src/__tests__/unit/Issue8922Repro.test.ts @@ -40,7 +40,7 @@ import { type RangeSelection, } from 'lexical'; import {createTestEditor} from 'lexical/src/__tests__/utils'; -import {afterEach, beforeEach, describe, expect, test} from 'vitest'; +import {afterEach, assert, beforeEach, describe, expect, test} from 'vitest'; const BOUNDARY_SELECTOR = 'img[data-lexical-decorator-boundary="true"]'; @@ -213,7 +213,7 @@ describe('Issue #8922: select-all with a boundary block decorator', () => { expect(rootChildNames(container)).toEqual(['p']); }); - test('an inline boundary decorator keeps its own linebreak hack instead', () => { + test('an inline boundary decorator gets a leading anchor and keeps its own trailing linebreak hack', () => { editor.update( () => { $getRoot() @@ -226,11 +226,15 @@ describe('Issue #8922: select-all with a boundary block decorator', () => { {discrete: true}, ); - // Inline decorators paint fine at a boundary, so no anchor — the existing - // managed line break is what they need. Outside Safari that line break is - // a plain
; the Safari img+br shape is pinned by - // LexicalWebkitLinebreakImg.test.ts. - expect(container.querySelectorAll(BOUNDARY_SELECTOR)).toHaveLength(0); + // A line that starts with an inline decorator has no editable caret + // position before it, so it gets the leading anchor (#7158). The trailing + // edge needs none: the existing managed line break is what it needs. + // In jsdom that line break is a plain
; WebKit and Chromium render it + // as an img+br pair. + const paragraph = container.firstElementChild; + assert(paragraph !== null); + expect(paragraph.querySelectorAll(BOUNDARY_SELECTOR)).toHaveLength(1); + expect(paragraph.firstElementChild?.matches(BOUNDARY_SELECTOR)).toBe(true); const managedLinebreaks = Array.from( container.querySelectorAll('[data-lexical-managed-linebreak="true"]'), node => node.nodeName.toLowerCase(), diff --git a/packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx b/packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx index fc7f06f67e4..3975d59dce2 100644 --- a/packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx +++ b/packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx @@ -1820,6 +1820,8 @@ describe('LexicalEditor tests', () => { expect(listener).toHaveBeenCalledTimes(1); expect(container.innerHTML).toBe( '

' + + // The leading boundary anchor (#7158), as raw innerHTML serializes it. + '' + 'Hello world

', ); }); From ff3d3f21daf947bd0227e0e6b2c1b9bb45152c6e Mon Sep 17 00:00:00 2001 From: vijayojha89 Date: Tue, 6 Oct 2026 09:18:33 +0530 Subject: [PATCH 02/18] Ignore the leading boundary anchor in SelectBlock browser test offsets --- .../src/__tests__/browser/SelectBlock.test.ts | 27 +++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/packages/lexical-extension/src/__tests__/browser/SelectBlock.test.ts b/packages/lexical-extension/src/__tests__/browser/SelectBlock.test.ts index 4ebe99536d9..5330be5d84c 100644 --- a/packages/lexical-extension/src/__tests__/browser/SelectBlock.test.ts +++ b/packages/lexical-extension/src/__tests__/browser/SelectBlock.test.ts @@ -101,6 +101,21 @@ function setup($children: () => LexicalNode[]) { return {editor, root}; } +// The reconciler parks a zero-size boundary anchor `` before a block's +// first child when that child is a decorator (#8922, #7158). It occupies a DOM +// child slot but is not a Lexical child, so leave it out of DOM paths and +// offsets computed from Lexical child indices. +function isBoundaryAnchor(node: Node): boolean { + return ( + node instanceof HTMLElement && + node.hasAttribute('data-lexical-decorator-boundary') + ); +} + +function managedChildNodes(node: Node): Node[] { + return Array.from(node.childNodes).filter(child => !isBoundaryAnchor(child)); +} + function domSelection() { const selection = window.getSelection()!; return [ @@ -192,13 +207,21 @@ describe('native select-all block expansion', () => { return last.getChildrenSize(); }); const endNode = endPath.reduce( - (node, index) => node.childNodes[index], + (node, index) => managedChildNodes(node)[index], paragraph, ); + const leadingAnchors = Array.from(paragraph.childNodes).findIndex( + child => !isBoundaryAnchor(child), + ); await selectAll(); await expect .poll(domSelection) - .toEqual([paragraph, 0, paragraph, childCount]); + .toEqual([ + paragraph, + leadingAnchors, + paragraph, + childCount + leadingAnchors, + ]); editor.read('latest', () => { const selection = $getSelection(); assert($isRangeSelection(selection)); From 41e3ae4a6fce68a2549f04bff6a8f510475504b1 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Wed, 7 Oct 2026 18:14:41 -0700 Subject: [PATCH 03/18] ensure width/height/etc. are explicitly set to avoid issues with cascade --- packages/lexical/src/LexicalDOMSlot.ts | 49 +++++++++++++++----------- 1 file changed, 29 insertions(+), 20 deletions(-) diff --git a/packages/lexical/src/LexicalDOMSlot.ts b/packages/lexical/src/LexicalDOMSlot.ts index 1566142d32a..5d5bfc61eeb 100644 --- a/packages/lexical/src/LexicalDOMSlot.ts +++ b/packages/lexical/src/LexicalDOMSlot.ts @@ -19,6 +19,7 @@ import { IS_SAFARI, } from './environment'; import {$getDocument, $getEditor} from './LexicalUtils'; +import {setDOMStyleObject} from './utils/setDOMStyle'; /** * The editor has at most one block cursor element @@ -51,6 +52,9 @@ function isSlotContainerDOM( const IS_WEBKIT_BROWSER = IS_APPLE_WEBKIT || IS_IOS || IS_SAFARI; +const DATA_LEXICAL_MANAGED_LINEBREAK = 'data-lexical-managed-linebreak'; +const DATA_LEXICAL_DECORATOR_BOUNDARY = 'data-lexical-decorator-boundary'; + /** * Browsers drop the selection highlight for a range whose endpoint is an * element-boundary DOM position (`(element, 0)` or @@ -78,19 +82,9 @@ const IS_WEBKIT_BROWSER = IS_APPLE_WEBKIT || IS_IOS || IS_SAFARI; * would add a stray blank line here). */ function $createDecoratorBoundaryAnchor(): HTMLImageElement { - const img = $getDocument().createElement('img'); - img.setAttribute('data-lexical-decorator-boundary', 'true'); + const img = $createZeroImg(DATA_LEXICAL_DECORATOR_BOUNDARY); + img.style.setProperty('position', 'absolute', 'important'); img.alt = ''; - for (const [property, value] of [ - ['position', 'absolute'], - ['width', '0px'], - ['height', '0px'], - ['border', '0px'], - ['margin', '0px'], - ['padding', '0px'], - ]) { - img.style.setProperty(property, value, 'important'); - } return img; } @@ -109,7 +103,7 @@ export function isDecoratorBoundaryAnchorDOM( return ( node !== null && node.nodeType === 1 && - (node as Element).hasAttribute('data-lexical-decorator-boundary') + (node as Element).hasAttribute(DATA_LEXICAL_DECORATOR_BOUNDARY) ); } @@ -299,6 +293,26 @@ function $topLevelChildOf(parent: HTMLElement, descendant: Node): Node | null { return node; } +const ZERO_IMG_STYLE = { + border: '0px !important', + display: 'inline !important', + height: '0px !important', + margin: '0px !important', + 'min-height': '0px !important', + 'min-width': '0px !important', + padding: '0px !important', + width: '0px !important', +}; +function $createZeroImg( + attr: 'data-lexical-managed-linebreak' | 'data-lexical-decorator-boundary', +): HTMLImageElement { + const img = $getDocument().createElement('img'); + setDOMStyleObject(img.style, ZERO_IMG_STYLE); + img.setAttribute(attr, 'true'); + img.alt = ''; + return img; +} + /** * A utility class for managing the DOM children of an ElementNode. * @@ -498,15 +512,10 @@ export class ElementDOMSlot< // end of the managed range. const before = this.before || this.getDecoratorBoundaryAnchor('trailing'); const br = $getDocument().createElement('br'); - br.setAttribute('data-lexical-managed-linebreak', 'true'); + br.setAttribute(DATA_LEXICAL_MANAGED_LINEBREAK, 'true'); element.insertBefore(br, before); if (webkitHack) { - const img = $getDocument().createElement('img'); - img.setAttribute('data-lexical-managed-linebreak', 'true'); - img.style.setProperty('display', 'inline', 'important'); - img.style.setProperty('border', '0px', 'important'); - img.style.setProperty('margin', '0px', 'important'); - img.alt = ''; + const img = $createZeroImg(DATA_LEXICAL_MANAGED_LINEBREAK); element.insertBefore(img, br); element.__lexicalLineBreak = img; } else { From 2031ea7cf45f2c9541abdcbadf145903daf7ba3a Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Wed, 7 Oct 2026 18:21:41 -0700 Subject: [PATCH 04/18] reorder dom attr --- packages/lexical/src/LexicalDOMSlot.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/lexical/src/LexicalDOMSlot.ts b/packages/lexical/src/LexicalDOMSlot.ts index 5d5bfc61eeb..39c14c7453c 100644 --- a/packages/lexical/src/LexicalDOMSlot.ts +++ b/packages/lexical/src/LexicalDOMSlot.ts @@ -84,7 +84,6 @@ const DATA_LEXICAL_DECORATOR_BOUNDARY = 'data-lexical-decorator-boundary'; function $createDecoratorBoundaryAnchor(): HTMLImageElement { const img = $createZeroImg(DATA_LEXICAL_DECORATOR_BOUNDARY); img.style.setProperty('position', 'absolute', 'important'); - img.alt = ''; return img; } @@ -307,9 +306,9 @@ function $createZeroImg( attr: 'data-lexical-managed-linebreak' | 'data-lexical-decorator-boundary', ): HTMLImageElement { const img = $getDocument().createElement('img'); - setDOMStyleObject(img.style, ZERO_IMG_STYLE); img.setAttribute(attr, 'true'); img.alt = ''; + setDOMStyleObject(img.style, ZERO_IMG_STYLE); return img; } From b9061f96c356cce28f7228b42f1f5cb98b260ac9 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Wed, 7 Oct 2026 18:23:14 -0700 Subject: [PATCH 05/18] fix packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx expectation --- packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx b/packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx index 3975d59dce2..98baf1de2ae 100644 --- a/packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx +++ b/packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx @@ -1821,7 +1821,7 @@ describe('LexicalEditor tests', () => { expect(container.innerHTML).toBe( '

' + // The leading boundary anchor (#7158), as raw innerHTML serializes it. - '' + + '' + 'Hello world

', ); }); From 4102fb0fc3a7424a0e175f22322bb23c470a9b31 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 01:33:13 +0000 Subject: [PATCH 06/18] [lexical] Chore: Update the decorator boundary anchor HTML in test utils ## Description The decorator boundary anchor `` now gets the shared zero-size style (border, display, height, margin, min-height, min-width, padding, width) followed by `position: absolute`, but `DECORATOR_BOUNDARY_ANCHOR_HTML` in the lexical test utils still described the old style list, so every unit test that interpolates it into expected HTML failed. This updates the constant to the style the reconciler writes now. ## Test plan ### Before ``` $ ./node_modules/.bin/vitest run --project unit FAIL |unit| packages/lexical-history/src/__tests__/unit/LexicalHistory.test.tsx > SharedHistoryExtension > can create a parent editor FAIL |unit| packages/lexical-react/src/__tests__/unit/LexicalExtensionEditorComposer.test.tsx > LexicalExtensionEditorComposer > can render FAIL |unit| packages/lexical-react/src/__tests__/unit/LexicalNestedComposer.test.tsx > LexicalNestedComposer > (6 tests) Test Files 3 failed | 358 passed (361) Tests 8 failed | 5978 passed | 1 skipped (5987) ``` ### After ``` $ ./node_modules/.bin/vitest run --project unit Test Files 361 passed (361) Tests 5986 passed | 1 skipped (5987) ``` Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/lexical/src/__tests__/utils/index.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/lexical/src/__tests__/utils/index.tsx b/packages/lexical/src/__tests__/utils/index.tsx index 4306e0d1a3a..8cc86aa46a7 100644 --- a/packages/lexical/src/__tests__/utils/index.tsx +++ b/packages/lexical/src/__tests__/utils/index.tsx @@ -603,9 +603,11 @@ export function polyfillContentEditable() { * first / last edge. */ export const DECORATOR_BOUNDARY_ANCHOR_HTML = - ''; + ''; /** * Assert that a node's generated exporters write exactly what the schema-driven From 3ad3a3afd5fbdc324d1c0daf858d6a33e86c9489 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Wed, 7 Oct 2026 22:09:24 -0700 Subject: [PATCH 07/18] unify test constants and make tests less sensitive to attribute ordering --- packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx b/packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx index 98baf1de2ae..00223daf7c9 100644 --- a/packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx +++ b/packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx @@ -103,6 +103,7 @@ import { $createTestInlineElementNode, createTestEditor, createTestHeadlessEditor, + DECORATOR_BOUNDARY_ANCHOR_HTML, expectHtmlToBeEqual, html, TestComposer, @@ -1818,10 +1819,10 @@ describe('LexicalEditor tests', () => { }); expect(listener).toHaveBeenCalledTimes(1); - expect(container.innerHTML).toBe( + expectHtmlToBeEqual( + container.innerHTML, '

' + - // The leading boundary anchor (#7158), as raw innerHTML serializes it. - '' + + DECORATOR_BOUNDARY_ANCHOR_HTML + 'Hello world

', ); }); From f40509e3741429a94c1880e54a9277271e509a07 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 05:19:04 +0000 Subject: [PATCH 08/18] [lexical] Bug Fix: Gate the #7158 edge anchors per engine and restore removed scaffolding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Addresses /code-review findings on #9329. - The leading boundary anchor before an inline first-child decorator was added on every engine, while the trailing img before the managed line break was limited to WebKit and desktop Chromium. Both now use one predicate, `NEEDS_INLINE_DECORATOR_EDGE_BOX`, so Android (IME), Firefox and jsdom keep their previous DOM. Block decorators keep their ungated anchors from #8922. - The `$internalResolveSelectionPoints` change is dropped. Driven with a real mouse in Chromium, the DOM anchors alone fix the drags in #7158. The selection change alone does not, because Chromium keeps the anchor it took at mousedown even after Lexical rewrites the DOM selection. It also took over selections inside two different inline decorators' content (and inside one decorator's own element), could rewrite the DOM selection on every selectionchange mid-drag, and skipped mixed text/element endpoints. Its jsdom tests, which modeled drags with `setBaseAndExtent`/`extend` and passed without the working fix, are replaced by `Issue7158MouseDrag.test.ts`, which drives Playwright's mouse through a new `mouseDrag` browser command. - When something outside the reconciler removes scaffolding, the mutation observer now rebuilds it in place. A removed boundary anchor used to stay missing until the element was next reconciled. A removed managed `` was re-appended after the `
`, and a removed managed `
` that followed the img was not recognized at all. `ElementDOMSlot.restoreManagedLineBreak()` rebuilds the img+br pair inside the trailing boundary, and `$reconcileDecoratorBoundaryAnchors` is reused for the anchors. - `$createZeroImg` takes the attribute name from the `DATA_LEXICAL_*` constants, and `insertManagedLineBreak`'s parameter is renamed from `webkitHack` to `withEdgeImg` now that it isn't WebKit-only. ## Test plan ### Before ``` $ ./node_modules/.bin/vitest run --project browser packages/lexical/src/__tests__/browser/WebkitLinebreakImg.test.ts packages/lexical/src/__tests__/browser/Issue7158MouseDrag.test.ts × the leading boundary anchor 184ms × the managed line break img 104ms × the managed line break br 107ms AssertionError: expected [ 'decorator', 'text', …(3) ] to deeply equal [ 'anchor', 'decorator', 'text', …(3) ] AssertionError: expected [ 'anchor', 'decorator', 'text', …(3) ] to deeply equal [ 'anchor', 'decorator', 'text', …(3) ] AssertionError: expected [ 'anchor', 'decorator', 'text', …(2) ] to deeply equal [ 'anchor', 'decorator', 'text', …(3) ] Tests 3 failed | 9 passed (12) ``` On upstream main, `Issue7158MouseDrag.test.ts` fails 3 of its 4 drags (`expected 1 to be 4`, `expected 'null' to be 6`, `expected 'null' to be 4`). ### After ``` $ ./node_modules/.bin/vitest run --project browser packages/lexical/src/__tests__/browser/WebkitLinebreakImg.test.ts packages/lexical/src/__tests__/browser/Issue7158MouseDrag.test.ts Tests 12 passed (12) $ ./node_modules/.bin/vitest run --project unit Test Files 360 passed (360) Tests 5975 passed | 1 skipped (5976) $ ./node_modules/.bin/vitest run --project browser Test Files 61 passed (61) Tests 668 passed | 2 skipped (670) ``` `tsc` and `pnpm run flow` are clean. Browser tests ran in Chromium only; Firefox and WebKit were not run here. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/lexical/flow/Lexical.js.flow | 2 +- packages/lexical/src/LexicalDOMSlot.ts | 73 +++- packages/lexical/src/LexicalMutations.ts | 47 ++- packages/lexical/src/LexicalReconciler.ts | 35 +- packages/lexical/src/LexicalSelection.ts | 61 +-- .../browser/Issue7158MouseDrag.test.ts | 143 +++++++ .../browser/WebkitLinebreakImg.test.ts | 73 +++- .../src/__tests__/unit/Issue7158Repro.test.ts | 389 ------------------ .../src/__tests__/unit/Issue8922Repro.test.ts | 18 +- .../src/__tests__/unit/LexicalEditor.test.tsx | 2 - vitest.config.mts | 21 + 11 files changed, 369 insertions(+), 495 deletions(-) create mode 100644 packages/lexical/src/__tests__/browser/Issue7158MouseDrag.test.ts delete mode 100644 packages/lexical/src/__tests__/unit/Issue7158Repro.test.ts diff --git a/packages/lexical/flow/Lexical.js.flow b/packages/lexical/flow/Lexical.js.flow index 238a3b0fe8f..696fedef910 100644 --- a/packages/lexical/flow/Lexical.js.flow +++ b/packages/lexical/flow/Lexical.js.flow @@ -1123,7 +1123,7 @@ declare export class ElementDOMSlot extends DOMSlot` on the outside of such a boundary * decorator gives the browser an editable inline box to canonicalize the @@ -303,7 +317,9 @@ const ZERO_IMG_STYLE = { width: '0px !important', }; function $createZeroImg( - attr: 'data-lexical-managed-linebreak' | 'data-lexical-decorator-boundary', + attr: + | typeof DATA_LEXICAL_MANAGED_LINEBREAK + | typeof DATA_LEXICAL_DECORATOR_BOUNDARY, ): HTMLImageElement { const img = $getDocument().createElement('img'); img.setAttribute(attr, 'true'); @@ -471,16 +487,37 @@ export class ElementDOMSlot< if (nextLineBreakType === null) { this.removeManagedLineBreak(); } else { - // Desktop Chromium needs the same editable inline box after a trailing - // inline decorator: without it, a click to the right of the decorator - // puts the DOM caret inside its contentEditable=false DOM, and a mouse - // drag that starts there can never extend beyond that decorator - // (#7158). Android is left alone: it has no mouse drag, and its IME is - // sensitive to the DOM around the caret. - const webkitHack = - nextLineBreakType === 'decorator' && - (IS_WEBKIT_BROWSER || (IS_CHROME && !IS_ANDROID)); - this.insertManagedLineBreak(webkitHack); + this.insertManagedLineBreak( + nextLineBreakType === 'decorator' && NEEDS_INLINE_DECORATOR_EDGE_BOX, + ); + } + } + + /** + * @internal + * + * Rebuild the managed line break after something outside the reconciler + * (native editing, an IME, an extension) removed part of it. The `` of + * the img+br pair is the tracked node, so a lone `
` or `` left + * behind is dropped and the pair is inserted again in its usual place, + * inside the trailing boundary. + */ + restoreManagedLineBreak(): void { + const element: HTMLElement & LexicalPrivateDOM = this.element; + for (const child of Array.from(element.childNodes)) { + if ( + child.nodeType === 1 && + (child as Element).hasAttribute(DATA_LEXICAL_MANAGED_LINEBREAK) + ) { + element.removeChild(child); + } + } + element.__lexicalLineBreak = undefined; + const kind = element.__lexicalLastChildKind; + if (kind != null) { + this.insertManagedLineBreak( + kind === 'decorator' && NEEDS_INLINE_DECORATOR_EDGE_BOX, + ); } } @@ -498,10 +535,10 @@ export class ElementDOMSlot< } } /** @internal */ - insertManagedLineBreak(webkitHack: boolean): void { + insertManagedLineBreak(withEdgeImg: boolean): void { const prevBreak = this.getManagedLineBreak(); if (prevBreak) { - if (webkitHack === (prevBreak.nodeName === 'IMG')) { + if (withEdgeImg === (prevBreak.nodeName === 'IMG')) { return; } this.removeManagedLineBreak(); @@ -513,7 +550,7 @@ export class ElementDOMSlot< const br = $getDocument().createElement('br'); br.setAttribute(DATA_LEXICAL_MANAGED_LINEBREAK, 'true'); element.insertBefore(br, before); - if (webkitHack) { + if (withEdgeImg) { const img = $createZeroImg(DATA_LEXICAL_MANAGED_LINEBREAK); element.insertBefore(img, br); element.__lexicalLineBreak = img; diff --git a/packages/lexical/src/LexicalMutations.ts b/packages/lexical/src/LexicalMutations.ts index c668f84bc14..a0af5cfe158 100644 --- a/packages/lexical/src/LexicalMutations.ts +++ b/packages/lexical/src/LexicalMutations.ts @@ -14,6 +14,7 @@ import type {BaseSelection} from './LexicalSelection'; import { $getSelection, $isDecoratorNode, + $isElementNode, $isRangeSelection, $isTextNode, $setSelection, @@ -22,8 +23,10 @@ import { } from '.'; import {IS_FIREFOX} from './environment'; import {isDecoratorBoundaryAnchorDOM} from './LexicalDOMSlot'; +import {$reconcileDecoratorBoundaryAnchors} from './LexicalReconciler'; import {updateEditorSync} from './LexicalUpdates'; import { + $getDOMSlot, $getNodeByKey, $getNodeFromDOMNode, $updateTextNodeFromDOMContent, @@ -75,6 +78,15 @@ function isEditorManagedLineBreak( ); } +// A piece of an element's managed line break: the `
`, or the `` that +// precedes it after an inline decorator. Recognized by its attribute, which +// a removed node keeps after it loses its siblings. +function isManagedLineBreakDOM(dom: Node): boolean { + return ( + isHTMLElement(dom) && dom.hasAttribute('data-lexical-managed-linebreak') + ); +} + function getLastSelection(editor: LexicalEditor): null | BaseSelection { return editor.read('latest', () => { const selection = $getSelection(); @@ -248,23 +260,52 @@ function flushMutations( if (removedDOMsLength > 0) { let unremovedBRs = 0; + // The element's own scaffolding has a fixed place in its DOM (the + // managed line break inside the trailing boundary, with its img + // first; a boundary anchor on the edge it belongs to), so it is + // rebuilt in place below rather than re-appended at the end. + const elementSlot = $isElementNode(targetNode) + ? $getDOMSlot(targetNode, nodeDOM, editor) + : null; + const slot = + elementSlot !== null && elementSlot.element === targetDOM + ? elementSlot + : null; + let restoreLineBreak = false; + let restoreAnchors = false; for (let s = 0; s < removedDOMsLength; s++) { const removedDOM = removedDOMs[s]; if ( + slot !== null && + (removedDOM === slot.getManagedLineBreak() || + isManagedLineBreakDOM(removedDOM)) + ) { + restoreLineBreak = true; + unremovedBRs++; + } else if ( isEditorManagedLineBreak(removedDOM, targetDOM, editor) || blockCursorElement === removedDOM ) { targetDOM.appendChild(removedDOM); unremovedBRs++; } else if (isDecoratorBoundaryAnchorDOM(removedDOM)) { - // Position matters for these (leading vs trailing), so don't - // blindly re-append — the next reconcile of this element puts - // a fresh anchor on the right edge. + restoreAnchors = true; unremovedBRs++; } } + if (slot !== null && restoreLineBreak) { + slot.restoreManagedLineBreak(); + } + if (restoreAnchors && $isElementNode(targetNode)) { + $reconcileDecoratorBoundaryAnchors( + targetNode, + nodeDOM, + editor, + currentEditorState._nodeMap, + ); + } if (removedDOMsLength !== unremovedBRs) { badDOMTargets.set(nodeDOM, targetNode); diff --git a/packages/lexical/src/LexicalReconciler.ts b/packages/lexical/src/LexicalReconciler.ts index 69eb8d963f2..5dc5d8ae05f 100644 --- a/packages/lexical/src/LexicalReconciler.ts +++ b/packages/lexical/src/LexicalReconciler.ts @@ -44,6 +44,7 @@ import { IS_ALIGN_RIGHT, IS_ALIGN_START, } from './LexicalConstants'; +import {NEEDS_INLINE_DECORATOR_EDGE_BOX} from './LexicalDOMSlot'; import {cloneMap} from './LexicalGenMap'; import {$isSlotChild, $isSlotHost, EMPTY_SLOTS} from './LexicalSlot'; import { @@ -955,8 +956,9 @@ function $isBlockDecoratorChild( } /** - * Whether the element's first child needs the leading boundary anchor: any - * DecoratorNode, block or inline. A line that starts with an inline decorator + * Whether the element's first child needs the leading boundary anchor: a + * block DecoratorNode, or on the engines in + * {@link NEEDS_INLINE_DECORATOR_EDGE_BOX} an inline one too. A line that starts with an inline decorator * has no editable caret position before it either, so a browser canonicalizes * a caret at the start of the line into the decorator's non-editable DOM and * will not extend a mouse drag out of it (#7158). (The trailing edge of an @@ -967,7 +969,14 @@ function $isLeadingDecoratorChild( key: null | NodeKey, nodeMap: NodeMap, ): boolean { - return !!key && $isDecoratorNode(nodeMap.get(key)); + if (!key) { + return false; + } + const node = nodeMap.get(key); + return ( + $isDecoratorNode(node) && + (!node.isInline() || NEEDS_INLINE_DECORATOR_EDGE_BOX) + ); } /** @@ -978,22 +987,28 @@ function $isLeadingDecoratorChild( * out-of-flow anchor parked outside each such boundary child so the browser has * an editable inline box to resolve the boundary position against. Interior * decorators are unaffected, so only the first / last child is considered. - * The leading anchor is also parked before an inline first-child decorator, - * so a mouse drag can start at the start of such a line - * (see {@link $isLeadingDecoratorChild}, #7158). + * The leading anchor is also parked before an inline first-child decorator + * where the engine needs it, so a mouse drag can start at the start of such a + * line (see {@link $isLeadingDecoratorChild}, #7158). + * + * Also called outside a reconcile, with the editor and node map passed + * explicitly, to put back an anchor that something else removed from the DOM + * (see `flushMutations`). */ -function $reconcileDecoratorBoundaryAnchors( +export function $reconcileDecoratorBoundaryAnchors( nextElement: ElementNode, dom: HTMLElement & LexicalPrivateDOM, + editor: LexicalEditor = activeEditor, + nodeMap: NodeMap = activeNextNodeMap, ): void { - const slot = $getDOMSlot(nextElement, dom, activeEditor); + const slot = $getDOMSlot(nextElement, dom, editor); slot.setDecoratorBoundaryAnchor( 'leading', - $isLeadingDecoratorChild(nextElement.__first, activeNextNodeMap), + $isLeadingDecoratorChild(nextElement.__first, nodeMap), ); slot.setDecoratorBoundaryAnchor( 'trailing', - $isBlockDecoratorChild(nextElement.__last, activeNextNodeMap), + $isBlockDecoratorChild(nextElement.__last, nodeMap), ); } diff --git a/packages/lexical/src/LexicalSelection.ts b/packages/lexical/src/LexicalSelection.ts index bdab55bc296..6176e38a0c1 100644 --- a/packages/lexical/src/LexicalSelection.ts +++ b/packages/lexical/src/LexicalSelection.ts @@ -3332,23 +3332,6 @@ export function $clampRangeSelectionToSlotFrame( return clamped; } -/** - * Whether a DOM selection endpoint is on an inline DecoratorNode's own - * element: the DOM the decorator's `createDOM` returned, between its children, - * rather than somewhere inside the decorator's content. - */ -function $isOnInlineDecoratorElement( - node: LexicalNode | null, - dom: Node, - editor: LexicalEditor, -): boolean { - return ( - $isDecoratorNode(node) && - node.isInline() && - editor.getElementByKey(node.getKey()) === dom - ); -} - function $internalResolveSelectionPoints( anchorDOM: null | Node, anchorOffset: number, @@ -3388,56 +3371,18 @@ function $internalResolveSelectionPoints( $validatePoint('anchor', resolvedAnchorPoint); $validatePoint('focus', resolvedFocusPoint); } - // Set when a DOM endpoint sits on an inline decorator's own element and the - // resolved point is outside it, in the decorator's parent (#7158). - let decoratorDirty = false; if ( resolvedAnchorPoint.type === 'element' && resolvedFocusPoint.type === 'element' ) { const anchorNode = $getNodeFromDOM(anchorDOM); const focusNode = $getNodeFromDOM(focusDOM); - const anchorOnDecorator = $isOnInlineDecoratorElement( - anchorNode, - anchorDOM, - editor, - ); - const focusOnDecorator = $isOnInlineDecoratorElement( - focusNode, - focusDOM, - editor, - ); // Ensure if we're selecting the content of a decorator that we // return null for this point, as it's not in the controlled scope - // of Lexical. Two shapes with both endpoints in inline decorators are a - // selection Lexical owns instead (#7158); each point has already resolved - // to just before or after its decorator in the parent: - // - endpoints in two *different* inline decorators: a mouse drag across - // a run of inline decorators with no text around them; - // - endpoints on an inline decorator's own element (between its - // children, not inside its content): where a browser puts the caret - // for a click beside the decorator at the start or end of such a - // line. Resolving that to null would make the commit remove every DOM - // range and abort the drag it started. - // Block decorators and a selection inside a decorator's content keep - // resolving to null. - if ( - $isDecoratorNode(anchorNode) && - $isDecoratorNode(focusNode) && - !( - anchorNode.isInline() && - focusNode.isInline() && - (!anchorNode.is(focusNode) || (anchorOnDecorator && focusOnDecorator)) - ) - ) { + // of Lexical. + if ($isDecoratorNode(anchorNode) && $isDecoratorNode(focusNode)) { return null; } - // The decorator's DOM is contentEditable=false. A browser that keeps the - // DOM caret there will not extend a mouse drag beyond that decorator, so - // only the decorator the drag started on would ever be selected. Mark the - // selection dirty so the reconciler moves the DOM selection to the - // resolved positions in the editable parent. - decoratorDirty = anchorOnDecorator || focusOnDecorator; } // @experimental named-slots. Clamp a slot-straddling drag into the @@ -3467,7 +3412,7 @@ function $internalResolveSelectionPoints( return [ resolvedAnchorPoint, resolvedFocusPoint, - anchorDirty || focusDirty || slotClamped || decoratorDirty, + anchorDirty || focusDirty || slotClamped, ]; } diff --git a/packages/lexical/src/__tests__/browser/Issue7158MouseDrag.test.ts b/packages/lexical/src/__tests__/browser/Issue7158MouseDrag.test.ts new file mode 100644 index 00000000000..5b48709fad4 --- /dev/null +++ b/packages/lexical/src/__tests__/browser/Issue7158MouseDrag.test.ts @@ -0,0 +1,143 @@ +/** + * 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. + * + */ + +/** + * #7158: a real mouse drag that starts beside the first or last inline + * decorator of a line must extend across the decorators it crosses. jsdom + * cannot model this (it has no hit testing, and a browser's mouse drag keeps + * its own anchor regardless of later Selection API calls), so the drag is + * driven through Playwright's mouse. + */ + +import {buildEditorFromExtensions, defineExtension} from '@lexical/extension'; +import {RichTextExtension} from '@lexical/rich-text'; +import { + $create, + $createParagraphNode, + $createTextNode, + $getRoot, + $getSelection, + $isDecoratorNode, + $isRangeSelection, + DecoratorNode, + type LexicalEditor, +} from 'lexical'; +import {describe, expect, onTestFinished, test} from 'vitest'; +import {commands} from 'vitest/browser'; + +declare module 'vitest/browser' { + interface BrowserCommands { + mouseDrag: ( + selector: string, + points: [x: number, y: number][], + ) => Promise; + } +} + +const COUNT = 6; +const SIZE = 40; + +class ImageLikeDecoratorNode extends DecoratorNode { + $config() { + return this.config('test_7158_mouse_image', {extends: DecoratorNode}); + } + createDOM(): HTMLElement { + const span = document.createElement('span'); + const img = document.createElement('img'); + img.alt = ''; + img.style.cssText = `display:inline-block;width:${SIZE}px;height:${SIZE}px;background:#888`; + span.appendChild(img); + return span; + } + updateDOM(): false { + return false; + } + isInline(): true { + return true; + } + decorate(): null { + return null; + } +} + +function mount(textBetween: boolean) { + const root = document.createElement('div'); + root.id = 'issue-7158-root'; + root.contentEditable = 'true'; + root.style.cssText = 'width:600px;padding:10px;font-size:16px'; + document.body.appendChild(root); + const editor = buildEditorFromExtensions( + defineExtension({ + $initialEditorState: () => { + const paragraph = $createParagraphNode(); + for (let i = 0; i < COUNT; i++) { + if (textBetween) { + paragraph.append($createTextNode(' ')); + } + paragraph.append($create(ImageLikeDecoratorNode)); + } + $getRoot().clear().append(paragraph); + }, + dependencies: [RichTextExtension], + name: '[7158-mouse]', + nodes: [ImageLikeDecoratorNode], + }), + ); + editor.setRootElement(root); + onTestFinished(() => { + editor.dispose(); + root.remove(); + }); + const images = Array.from(root.querySelectorAll('span > img')); + const rootRect = root.getBoundingClientRect(); + // Points relative to the root's top-left, on the images' center line. + const y = images[0].getBoundingClientRect().top + SIZE / 2 - rootRect.top; + const left = (i: number) => + images[i].getBoundingClientRect().left - rootRect.left; + const right = (i: number) => + images[i].getBoundingClientRect().right - rootRect.left; + return {editor, left, right, y}; +} + +async function selectedDecoratorCount( + editor: LexicalEditor, +): Promise { + // Let the last selectionchange be handled and committed. + await new Promise(resolve => setTimeout(resolve, 50)); + return editor.read(() => { + const selection = $getSelection(); + return $isRangeSelection(selection) + ? selection.getNodes().filter($isDecoratorNode).length + : String(selection); + }); +} + +describe.each([ + ['only inline decorators', false], + ['text between inline decorators', true], +])('Issue #7158: mouse drag over %s', (_name, textBetween) => { + test('a drag from right of the last decorator selects back to the third', async () => { + const {editor, left, right, y} = mount(textBetween); + await commands.mouseDrag('#issue-7158-root', [ + [right(COUNT - 1) + 30, y], + [left(COUNT - 2) + SIZE / 2, y], + [left(2) - 2, y], + ]); + expect(await selectedDecoratorCount(editor)).toBe(COUNT - 2); + }); + + test('a drag from left of the first decorator selects to the end of the line', async () => { + const {editor, left, right, y} = mount(textBetween); + await commands.mouseDrag('#issue-7158-root', [ + [left(0) - 5, y], + [left(1) + SIZE / 2, y], + [right(COUNT - 1) + 30, y], + ]); + expect(await selectedDecoratorCount(editor)).toBe(COUNT); + }); +}); diff --git a/packages/lexical/src/__tests__/browser/WebkitLinebreakImg.test.ts b/packages/lexical/src/__tests__/browser/WebkitLinebreakImg.test.ts index 5c8185b2040..77a71faa351 100644 --- a/packages/lexical/src/__tests__/browser/WebkitLinebreakImg.test.ts +++ b/packages/lexical/src/__tests__/browser/WebkitLinebreakImg.test.ts @@ -12,7 +12,7 @@ * On WebKit and desktop Chromium (since #7158), when a block's last child is * an *inline* DecoratorNode the * managed line break is rendered as an in-flow `` followed by the usual - * `
` (see `ElementDOMSlot.insertManagedLineBreak`'s `webkitHack`), giving + * `
` (see `ElementDOMSlot.insertManagedLineBreak`'s `withEdgeImg`), giving * the browser an editable inline box between the `contenteditable=false` * decorator and the break. In Chromium that box is what lets a mouse drag * start to the right of a line's last inline decorator (#7158). The user-visible symptom it fixes involves native Safari @@ -42,11 +42,11 @@ import { IS_IOS, IS_SAFARI, } from 'lexical'; -import {describe, expect, onTestFinished, test} from 'vitest'; +import {assert, describe, expect, onTestFinished, test} from 'vitest'; import {$assertNodeType} from '../utils/assertNodeType'; -// Matches the `webkitHack` gate in ElementDOMSlot.setManagedLineBreak. +// Restates the internal NEEDS_INLINE_DECORATOR_EDGE_BOX in LexicalDOMSlot.ts. const EXPECTS_IMG_HACK = IS_SAFARI || IS_IOS || IS_APPLE_WEBKIT || (IS_CHROME && !IS_ANDROID); const DECORATOR_LINEBREAK = EXPECTS_IMG_HACK ? ['img', 'br'] : ['br']; @@ -233,3 +233,70 @@ describe('managed-linebreak img hack (inline decorator last child)', () => { expect(linebreakScaffold(contentEditable)).toEqual(['br']); }); }); + +/** The paragraph's DOM children, with the reconciler's scaffolding named. */ +function paragraphShape(contentEditable: HTMLElement): string[] { + const paragraph = contentEditable.querySelector('p'); + assert(paragraph !== null); + return Array.from(paragraph.childNodes, node => { + assert(node instanceof Element); + if (node.hasAttribute('data-lexical-decorator-boundary')) { + return 'anchor'; + } + if (node.hasAttribute('data-lexical-managed-linebreak')) { + return `managed-${node.nodeName.toLowerCase()}`; + } + return node.hasAttribute('data-lexical-decorator') ? 'decorator' : 'text'; + }); +} + +// Lets the MutationObserver deliver its records and the editor flush them. +function flushMutations(): Promise { + return new Promise(resolve => setTimeout(resolve, 0)); +} + +describe('scaffolding removed from outside the reconciler is restored', () => { + const EXPECTED_SHAPE = [ + ...(EXPECTS_IMG_HACK ? ['anchor'] : []), + 'decorator', + 'text', + 'decorator', + ...DECORATOR_LINEBREAK.map(tag => `managed-${tag}`), + ]; + + function mountDecoratorLine() { + const mounted = mountEditor(); + mounted.editor.update( + () => { + $getRoot() + .clear() + .append( + $createParagraphNode().append( + new TestInlineDecoratorNode(), + $createTextNode('text'), + new TestInlineDecoratorNode(), + ), + ); + }, + {discrete: true}, + ); + expect(paragraphShape(mounted.contentEditable)).toEqual(EXPECTED_SHAPE); + return mounted; + } + + test.each([ + ['the leading boundary anchor', '[data-lexical-decorator-boundary]'], + ['the managed line break img', 'img[data-lexical-managed-linebreak]'], + ['the managed line break br', 'br[data-lexical-managed-linebreak]'], + ])('%s', async (_name, selector) => { + const {contentEditable} = mountDecoratorLine(); + const removed = contentEditable.querySelector(`p > ${selector}`); + if (removed === null) { + // Not part of this engine's scaffold. + return; + } + removed.remove(); + await flushMutations(); + expect(paragraphShape(contentEditable)).toEqual(EXPECTED_SHAPE); + }); +}); diff --git a/packages/lexical/src/__tests__/unit/Issue7158Repro.test.ts b/packages/lexical/src/__tests__/unit/Issue7158Repro.test.ts deleted file mode 100644 index 71e9452a62d..00000000000 --- a/packages/lexical/src/__tests__/unit/Issue7158Repro.test.ts +++ /dev/null @@ -1,389 +0,0 @@ -/** - * 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. - * - */ - -/** - * Regression tests for #7158 — "Decorator nodes are not selectable with - * mouse". - * - * In a paragraph made only of inline decorators, a mouse drag that starts at - * the edge of the line can leave both DOM selection endpoints inside two - * different decorators' DOM. `$internalResolveSelectionPoints` rejected any - * selection whose endpoints were both inside decorators, which is only meant - * to keep Lexical out of a selection *within one* decorator's own content. The - * update then committed a null selection and removed the DOM ranges, so the - * highlight blinked and was lost. - * - * A click beside such a decorator at the start or end of the line also put - * the caret on the decorator's own (non-editable) element; that caret now - * resolves next to the decorator and is written back to the editable parent, - * so a drag can start from it. Block decorators and selections inside a - * decorator's content still resolve to null, as before. - * - * jsdom has no hit testing, so the DOM anchors that let a real browser start a - * drag at either edge of the line are covered by Issue8922Repro.test.ts. - */ - -import { - buildEditorFromExtensions, - type LexicalEditorWithDispose, -} from '@lexical/extension'; -import { - $create, - $createParagraphNode, - $getRoot, - $getSelection, - $isDecoratorNode, - $isElementNode, - $isRangeSelection, - $setSelection, - DecoratorNode, - defineExtension, - getDOMSelection, - type LexicalEditor, -} from 'lexical'; -import { - afterEach, - assert, - describe, - expect, - onTestFinished, - test, - vi, -} from 'vitest'; - -// Shaped like the node in the issue: an inline, isolated decorator whose DOM -// holds an image (React would portal the into the span). -class ImageLikeDecoratorNode extends DecoratorNode { - $config() { - return this.config('test_7158_image', {extends: DecoratorNode}); - } - createDOM(): HTMLElement { - const span = document.createElement('span'); - span.appendChild(document.createElement('img')); - return span; - } - updateDOM(): false { - return false; - } - isKeyboardSelectable(): boolean { - return false; - } - isIsolated(): boolean { - return true; - } - decorate(): null { - return null; - } -} - -class BlockDecoratorNode extends DecoratorNode { - $config() { - return this.config('test_7158_block', {extends: DecoratorNode}); - } - createDOM(): HTMLElement { - const div = document.createElement('div'); - div.appendChild(document.createElement('img')); - return div; - } - updateDOM(): false { - return false; - } - isInline(): false { - return false; - } - decorate(): null { - return null; - } -} - -const DECORATOR_COUNT = 6; - -describe('Issue #7158: mouse selection across inline decorators', () => { - const mounted: HTMLElement[] = []; - afterEach(() => { - for (const root of mounted.splice(0)) { - root.remove(); - } - }); - - function setUp(): { - editor: LexicalEditorWithDispose; - decoratorDOMs: HTMLElement[]; - decoratorKeys: string[]; - paragraphDOM: HTMLElement; - } { - const editor = buildEditorFromExtensions( - defineExtension({ - $initialEditorState: () => { - $getRoot() - .clear() - .append( - $createParagraphNode().append( - ...Array.from({length: DECORATOR_COUNT}, () => - $create(ImageLikeDecoratorNode), - ), - ), - ); - }, - name: '[7158]', - nodes: [ImageLikeDecoratorNode, BlockDecoratorNode], - }), - ); - onTestFinished(() => editor.dispose()); - const root = document.createElement('div'); - document.body.appendChild(root); - mounted.push(root); - editor.setRootElement(root); - const decoratorKeys = editor.read(() => { - const paragraph = $getRoot().getFirstChild(); - assert($isElementNode(paragraph)); - return paragraph.getChildren().map(node => node.getKey()); - }); - const decoratorDOMs = decoratorKeys.map(key => { - const dom = editor.getElementByKey(key); - assert(dom !== null); - return dom; - }); - const paragraphDOM = decoratorDOMs[0].parentElement; - assert(paragraphDOM !== null); - return {decoratorDOMs, decoratorKeys, editor, paragraphDOM}; - } - - function dragTo( - editor: LexicalEditor, - anchorNode: Node, - anchorOffset: number, - focusNode: Node, - focusOffset: number, - ): void { - const domSelection = getDOMSelection(window); - assert(domSelection !== null); - domSelection.setBaseAndExtent( - anchorNode, - anchorOffset, - focusNode, - focusOffset, - ); - document.dispatchEvent(new Event('selectionchange')); - // Each selectionchange is its own task in a browser, so the update it - // started commits before the next one; flush it the same way here. - editor.read(() => {}); - } - - function selectParagraphStart(editor: LexicalEditor): void { - editor.update( - () => { - $getRoot().selectStart(); - }, - {discrete: true}, - ); - } - - function selectedDecoratorKeys(editor: LexicalEditor): string[] | null { - return editor.read(() => { - const selection = $getSelection(); - if (!$isRangeSelection(selection) || selection.isCollapsed()) { - return null; - } - return selection - .getNodes() - .filter($isDecoratorNode) - .map(node => node.getKey()); - }); - } - - test('dragging backward from inside the last decorator to inside an earlier one selects the decorators in between', () => { - const {editor, decoratorDOMs, decoratorKeys} = setUp(); - // Drag starts after the last image (the end of the line) and ends before - // the third image. - dragTo(editor, decoratorDOMs[5], 1, decoratorDOMs[2], 0); - expect(selectedDecoratorKeys(editor)).toEqual(decoratorKeys.slice(2)); - editor.read(() => { - const selection = $getSelection(); - assert($isRangeSelection(selection)); - expect(selection.isBackward()).toBe(true); - }); - }); - - test('dragging forward from inside the first decorator to inside the last one selects every decorator', () => { - const {editor, decoratorDOMs, decoratorKeys} = setUp(); - dragTo(editor, decoratorDOMs[0], 0, decoratorDOMs[5], 1); - expect(selectedDecoratorKeys(editor)).toEqual(decoratorKeys); - editor.read(() => { - const selection = $getSelection(); - assert($isRangeSelection(selection)); - expect(selection.isBackward()).toBe(false); - }); - }); - - test('endpoints on the images inside two decorators also resolve', () => { - const {editor, decoratorDOMs, decoratorKeys} = setUp(); - const img = (i: number) => decoratorDOMs[i].firstChild as Node; - dragTo(editor, img(1), 0, img(4), 0); - // An has no inner positions, so a point on one resolves to just - // before its decorator: this drag covers decorators 2 through 4. - expect(selectedDecoratorKeys(editor)).toEqual(decoratorKeys.slice(1, 4)); - }); - - test('a caret placed inside the last decorator (click at the end of the line) keeps the DOM selection', () => { - const {editor, decoratorDOMs, decoratorKeys, paragraphDOM} = setUp(); - selectParagraphStart(editor); - const removeAllRanges = vi.spyOn(Selection.prototype, 'removeAllRanges'); - onTestFinished(() => removeAllRanges.mockRestore()); - dragTo(editor, decoratorDOMs[5], 1, decoratorDOMs[5], 1); - // Lexical used to resolve this caret to null, and the commit then removed - // every DOM range, so a drag that started here had nothing to extend. - expect(removeAllRanges).not.toHaveBeenCalled(); - // ...and the DOM caret is moved out of the non-editable decorator. - expect(getDOMSelection(window)?.anchorNode).toBe(paragraphDOM); - // (+1: the leading boundary anchor parked before the first decorator.) - expect(getDOMSelection(window)?.anchorOffset).toBe( - decoratorKeys.length + 1, - ); - editor.read(() => { - const selection = $getSelection(); - assert($isRangeSelection(selection)); - expect(selection.isCollapsed()).toBe(true); - expect(selection.anchor.type).toBe('element'); - expect(selection.anchor.offset).toBe(decoratorKeys.length); - }); - }); - - // Models a real mouse drag: mousedown puts a caret somewhere, then each - // mousemove extends the selection from the browser's current anchor. If - // the anchor were left inside a contentEditable=false decorator, a browser - // would not extend past that decorator, so this asserts the anchor has been - // moved out to the paragraph. - function mouseDrag( - editor: LexicalEditor, - paragraphDOM: HTMLElement, - mouseDown: [Node, number], - moves: [Node, number][], - ): void { - dragTo(editor, mouseDown[0], mouseDown[1], mouseDown[0], mouseDown[1]); - const domSelection = getDOMSelection(window); - assert(domSelection !== null); - expect(domSelection.anchorNode).toBe(paragraphDOM); - for (const [focusNode, focusOffset] of moves) { - domSelection.extend(focusNode, focusOffset); - document.dispatchEvent(new Event('selectionchange')); - editor.read(() => {}); - expect(domSelection.anchorNode).toBe(paragraphDOM); - expect(domSelection.focusNode).toBe(paragraphDOM); - } - } - - test('a mouse drag that starts at the end of the line selects every decorator it crosses', () => { - const {editor, decoratorDOMs, decoratorKeys, paragraphDOM} = setUp(); - selectParagraphStart(editor); - const removeAllRanges = vi.spyOn(Selection.prototype, 'removeAllRanges'); - onTestFinished(() => removeAllRanges.mockRestore()); - mouseDrag( - editor, - paragraphDOM, - [decoratorDOMs[5], 1], // mousedown: caret after the last image - [ - [decoratorDOMs[5], 0], // still over the last image - [decoratorDOMs[4], 0], - [decoratorDOMs[2], 0], // mouseup before the third image - ], - ); - expect(removeAllRanges).not.toHaveBeenCalled(); - expect(selectedDecoratorKeys(editor)).toEqual(decoratorKeys.slice(2)); - }); - - test('a mouse drag that starts at the start of the line selects every decorator it crosses', () => { - const {editor, decoratorDOMs, decoratorKeys, paragraphDOM} = setUp(); - mouseDrag( - editor, - paragraphDOM, - [decoratorDOMs[0], 0], // mousedown: caret before the first image - [ - [decoratorDOMs[0], 1], - [decoratorDOMs[3], 1], - [decoratorDOMs[5], 1], // mouseup after the last image - ], - ); - expect(selectedDecoratorKeys(editor)).toEqual(decoratorKeys); - }); - - test('a selection of text inside a single decorator is still left to the decorator', () => { - const {editor, decoratorDOMs} = setUp(); - const label = document.createTextNode('label'); - decoratorDOMs[3].appendChild(label); - dragTo(editor, label, 1, label, 4); - editor.read(() => { - expect($isRangeSelection($getSelection())).toBe(false); - }); - }); - - test('a drag over a single decorator resolves to the positions around it', () => { - const {editor, decoratorDOMs, decoratorKeys} = setUp(); - dragTo(editor, decoratorDOMs[3], 0, decoratorDOMs[3], 1); - expect(selectedDecoratorKeys(editor)).toEqual([decoratorKeys[3]]); - }); - - // Unchanged behavior: everything below resolved to null before #7158 and - // still does. - - test('a caret on an element inside a single decorator content is still left to the decorator', () => { - const {editor, decoratorDOMs} = setUp(); - const inner = document.createElement('b'); - inner.appendChild(document.createElement('i')); - decoratorDOMs[3].appendChild(inner); - dragTo(editor, inner, 0, inner, 1); - editor.read(() => { - expect($isRangeSelection($getSelection())).toBe(false); - }); - }); - - test('a selection inside a block decorator is still left to the decorator', () => { - const {editor} = setUp(); - let blockKey = ''; - editor.update( - () => { - const block = $create(BlockDecoratorNode); - $getRoot().append(block); - blockKey = block.getKey(); - $setSelection(null); - }, - {discrete: true}, - ); - const blockDOM = editor.getElementByKey(blockKey); - assert(blockDOM !== null); - dragTo(editor, blockDOM, 0, blockDOM, 1); - editor.read(() => { - expect($isRangeSelection($getSelection())).toBe(false); - }); - }); - - test('a drag between two block decorators is still left alone', () => { - const {editor} = setUp(); - const keys: string[] = []; - editor.update( - () => { - for (let i = 0; i < 2; i++) { - const block = $create(BlockDecoratorNode); - $getRoot().append(block); - keys.push(block.getKey()); - } - $setSelection(null); - }, - {discrete: true}, - ); - const [first, second] = keys.map(key => { - const dom = editor.getElementByKey(key); - assert(dom !== null); - return dom; - }); - dragTo(editor, first, 0, second, 1); - editor.read(() => { - expect($isRangeSelection($getSelection())).toBe(false); - }); - }); -}); diff --git a/packages/lexical/src/__tests__/unit/Issue8922Repro.test.ts b/packages/lexical/src/__tests__/unit/Issue8922Repro.test.ts index f1317b6ebf3..f9a0ca89fe4 100644 --- a/packages/lexical/src/__tests__/unit/Issue8922Repro.test.ts +++ b/packages/lexical/src/__tests__/unit/Issue8922Repro.test.ts @@ -40,7 +40,7 @@ import { type RangeSelection, } from 'lexical'; import {createTestEditor} from 'lexical/src/__tests__/utils'; -import {afterEach, assert, beforeEach, describe, expect, test} from 'vitest'; +import {afterEach, beforeEach, describe, expect, test} from 'vitest'; const BOUNDARY_SELECTOR = 'img[data-lexical-decorator-boundary="true"]'; @@ -213,7 +213,7 @@ describe('Issue #8922: select-all with a boundary block decorator', () => { expect(rootChildNames(container)).toEqual(['p']); }); - test('an inline boundary decorator gets a leading anchor and keeps its own trailing linebreak hack', () => { + test('an inline boundary decorator keeps its own linebreak hack instead', () => { editor.update( () => { $getRoot() @@ -226,15 +226,11 @@ describe('Issue #8922: select-all with a boundary block decorator', () => { {discrete: true}, ); - // A line that starts with an inline decorator has no editable caret - // position before it, so it gets the leading anchor (#7158). The trailing - // edge needs none: the existing managed line break is what it needs. - // In jsdom that line break is a plain
; WebKit and Chromium render it - // as an img+br pair. - const paragraph = container.firstElementChild; - assert(paragraph !== null); - expect(paragraph.querySelectorAll(BOUNDARY_SELECTOR)).toHaveLength(1); - expect(paragraph.firstElementChild?.matches(BOUNDARY_SELECTOR)).toBe(true); + // Inline decorators paint fine at a boundary, so no anchor — the existing + // managed line break is what they need. Outside Safari that line break is + // a plain
; the Safari img+br shape is pinned by + // LexicalWebkitLinebreakImg.test.ts. + expect(container.querySelectorAll(BOUNDARY_SELECTOR)).toHaveLength(0); const managedLinebreaks = Array.from( container.querySelectorAll('[data-lexical-managed-linebreak="true"]'), node => node.nodeName.toLowerCase(), diff --git a/packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx b/packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx index 98baf1de2ae..fc7f06f67e4 100644 --- a/packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx +++ b/packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx @@ -1820,8 +1820,6 @@ describe('LexicalEditor tests', () => { expect(listener).toHaveBeenCalledTimes(1); expect(container.innerHTML).toBe( '

' + - // The leading boundary anchor (#7158), as raw innerHTML serializes it. - '' + 'Hello world

', ); }); diff --git a/vitest.config.mts b/vitest.config.mts index 6ca6b287c4f..82a78949449 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -131,6 +131,27 @@ export default defineConfig({ api: {port: 8315}, browser: { commands: { + // Drive a real mouse drag through `points` (relative to the + // top-left of `selector`), so the browser's own hit testing + // and drag-extension logic decide the selection. Synthetic + // Selection.setBaseAndExtent/extend calls do not model that. + mouseDrag: async ( + {frame, page}, + selector: string, + points: [x: number, y: number][], + ) => { + const target = (await frame()).locator(selector); + const [first, ...rest] = points; + await target.hover({ + force: true, + position: {x: first[0], y: first[1]}, + }); + await page.mouse.down(); + for (const [x, y] of rest) { + await target.hover({force: true, position: {x, y}}); + } + await page.mouse.up(); + }, // Vitest's keyboard descriptor parser splits non-BMP text into // UTF-16 code units. Native Playwright typing preserves code // points, matching the E2E driver's input behavior. Bound each From c95793d681d73937c68fbaf6ba0d08e28e844945 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 05:25:45 +0000 Subject: [PATCH 09/18] [lexical] Chore: Compare rendered HTML in LexicalEditor tests with expectHtmlToBeEqual MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description `LexicalEditor.test.tsx` compared `innerHTML` with a raw `toBe` in 20 places. That makes them depend on attribute insertion order and on void-element serialization, which tests don't care about: reordering the attributes on the decorator boundary anchor broke one of them. 17 of them now use `expectHtmlToBeEqual`, which formats both sides with Prettier and its organize-attributes plugin. The 3 "moves node to different tree branches" tests stay raw, because they render a `
` inside a `

`, which Prettier's HTML parser rejects ("Unexpected closing tag "p""). Each now has a comment saying so. ## Test plan ### Before Converting all 20 sites failed the three tests that render a div inside a p: ``` $ ./node_modules/.bin/vitest run --project unit packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx × moves node to different tree branches 25ms × moves node to different tree branches (inverse) 18ms × moves node to different tree branches (node appended twice in two different branches) 15ms Tests 3 failed | 91 passed (94) ``` ### After ``` $ ./node_modules/.bin/vitest run --project unit packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx Tests 94 passed (94) ``` Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/__tests__/unit/LexicalEditor.test.tsx | 56 +++++++++++++------ 1 file changed, 39 insertions(+), 17 deletions(-) diff --git a/packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx b/packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx index 5a629b71a0d..52d1afc9397 100644 --- a/packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx +++ b/packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx @@ -521,14 +521,16 @@ describe('LexicalEditor tests', () => { // Wait for update to complete await Promise.resolve().then(); - expect(container.innerHTML).toBe( + expectHtmlToBeEqual( + container.innerHTML, '

This works!

', ); const initialEditorState = initialEditor.getEditorState(); initialEditor.setRootElement(null); - expect(container.innerHTML).toBe( + expectHtmlToBeEqual( + container.innerHTML, '
', ); @@ -539,7 +541,8 @@ describe('LexicalEditor tests', () => { editor.setRootElement(rootElement); expect(editor.getEditorState()).toEqual(initialEditorState); - expect(container.innerHTML).toBe( + expectHtmlToBeEqual( + container.innerHTML, '

This works!

', ); }); @@ -788,7 +791,8 @@ describe('LexicalEditor tests', () => { boldListener(); underlineListener(); - expect(container.innerHTML).toBe( + expectHtmlToBeEqual( + container.innerHTML, '

foo

', ); }); @@ -854,7 +858,8 @@ describe('LexicalEditor tests', () => { boldListener(); italicsListener(); - expect(container.innerHTML).toBe( + expectHtmlToBeEqual( + container.innerHTML, '

foo

', ); }); @@ -925,7 +930,8 @@ describe('LexicalEditor tests', () => { megaFooListener(); boldFooListener(); - expect(container.innerHTML).toBe( + expectHtmlToBeEqual( + container.innerHTML, '

Foo!!

', ); }); @@ -1562,13 +1568,17 @@ describe('LexicalEditor tests', () => { paragraph.append(text); }); - expect(container.innerHTML).toBe('
'); + expectHtmlToBeEqual( + container.innerHTML, + '
', + ); act(() => { reactRoot.render(); }); - expect(container.innerHTML).toBe( + expectHtmlToBeEqual( + container.innerHTML, '

This works!

', ); }); @@ -1590,7 +1600,8 @@ describe('LexicalEditor tests', () => { // Wait for update to complete await Promise.resolve().then(); - expect(container.innerHTML).toBe( + expectHtmlToBeEqual( + container.innerHTML, '

This works!

', ); expect(errorListener).toHaveBeenCalledTimes(0); @@ -1607,7 +1618,8 @@ describe('LexicalEditor tests', () => { }); expect(errorListener).toHaveBeenCalledTimes(1); - expect(container.innerHTML).toBe( + expectHtmlToBeEqual( + container.innerHTML, '

This works!

', ); }); @@ -1663,7 +1675,8 @@ describe('LexicalEditor tests', () => { reactRoot.render(); }); - expect(container.innerHTML).toBe( + expectHtmlToBeEqual( + container.innerHTML, '

Not changed

', ); @@ -1673,7 +1686,8 @@ describe('LexicalEditor tests', () => { expect(rootListener).toHaveBeenCalledTimes(3); expect(updateListener).toHaveBeenCalledTimes(4); - expect(container.innerHTML).toBe( + expectHtmlToBeEqual( + container.innerHTML, '

Change successful

', ); }); @@ -1862,7 +1876,8 @@ describe('LexicalEditor tests', () => { }); expect(listener).toHaveBeenCalledTimes(1); - expect(container.innerHTML).toBe( + expectHtmlToBeEqual( + container.innerHTML, '


', ); @@ -1873,7 +1888,8 @@ describe('LexicalEditor tests', () => { }); expect(listener).toHaveBeenCalledTimes(5); - expect(container.innerHTML).toBe( + expectHtmlToBeEqual( + container.innerHTML, '


', ); @@ -2246,6 +2262,7 @@ describe('LexicalEditor tests', () => { expect(editor._editorState._nodeMap.size).toBe(keys.length + 1); // + root expect(editor._keyToDOMMap.size).toBe(keys.length + 1); // + root + // A
inside a

is not valid HTML, so this is compared raw. expect(container.innerHTML).toBe( '

A
B

', ); @@ -2290,6 +2307,7 @@ describe('LexicalEditor tests', () => { elementNode2.append(elementNode1); }); + // A
inside a

is not valid HTML, so this is compared raw. expect(container.innerHTML).toBe( '

B
A

', ); @@ -2343,6 +2361,7 @@ describe('LexicalEditor tests', () => { elementNode1.append(elementNode3); }); + // A
inside a

is not valid HTML, so this is compared raw. expect(container.innerHTML).toBe( '

A
C
B

', ); @@ -3771,7 +3790,8 @@ describe('LexicalEditor tests', () => { await editor.update(() => { $getRoot().append($createParagraphNode().append($createTextNode('foo'))); editor.read(() => {}); - expect(editor.getRootElement()?.innerHTML).toBe( + expectHtmlToBeEqual( + editor.getRootElement()?.innerHTML ?? '', '

foo

', ); editor.update(() => { @@ -3780,7 +3800,8 @@ describe('LexicalEditor tests', () => { ); }); }); - expect(editor.getRootElement()?.innerHTML).toBe( + expectHtmlToBeEqual( + editor.getRootElement()?.innerHTML ?? '', '

foo

bar

', ); }); @@ -3797,7 +3818,8 @@ describe('LexicalEditor tests', () => { ); }); - expect(container.firstElementChild?.innerHTML).toBe( + expectHtmlToBeEqual( + container.firstElementChild?.innerHTML ?? '', '

Hello

', ); }); From c9e3b1927c6deaa06cdb9ad44754580448980ae4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 06:56:55 +0000 Subject: [PATCH 10/18] [lexical] Chore: Wait for the selection to settle in the #7158 mouse drag test ## Description `Issue7158MouseDrag.test.ts` read the editor's selection 50 ms after releasing the mouse. When the last `selectionchange` hadn't been handled yet, it saw an earlier selection. On one Chromium run outside this environment, a drag over text between inline decorators read 0 selected decorators where 4 were expected. The test now waits up to 2 s for the expected count with `vi.waitFor`. A failure reports the Lexical and DOM selections it reached, so a drag that never extended can be told apart from one that extended too far. ## Test plan ### Before Reported from another machine's full browser run: ``` FAIL browser (chromium) packages/lexical/src/__tests__/browser/Issue7158MouseDrag.test.ts > Issue #7158: mouse drag over text between inline decorators > a drag from right of the last decorator selects back to the third AssertionError: expected +0 to be 4 // Object.is equality ``` It did not reproduce here in full Chromium, the headless shell, at a 40x CPU slowdown, or with the Mac platform faked. ### After ``` $ ./node_modules/.bin/vitest run --project browser packages/lexical/src/__tests__/browser/Issue7158MouseDrag.test.ts Tests 4 passed (4) ``` Against upstream main's DOM code the test still fails, now with the selection it reached: ``` AssertionError: expected 4 selected decorators, got 1 (lexical 1:2->1:2; dom p:2->p:2) AssertionError: expected 4 selected decorators, got -1 (lexical null; dom span:1->span:0) ``` Co-Authored-By: Claude Opus 5.5 (1M context) --- .../browser/Issue7158MouseDrag.test.ts | 59 +++++++++++++++---- 1 file changed, 46 insertions(+), 13 deletions(-) diff --git a/packages/lexical/src/__tests__/browser/Issue7158MouseDrag.test.ts b/packages/lexical/src/__tests__/browser/Issue7158MouseDrag.test.ts index 5b48709fad4..263c2789a17 100644 --- a/packages/lexical/src/__tests__/browser/Issue7158MouseDrag.test.ts +++ b/packages/lexical/src/__tests__/browser/Issue7158MouseDrag.test.ts @@ -25,9 +25,10 @@ import { $isDecoratorNode, $isRangeSelection, DecoratorNode, + getDOMSelection, type LexicalEditor, } from 'lexical'; -import {describe, expect, onTestFinished, test} from 'vitest'; +import {assert, describe, onTestFinished, test, vi} from 'vitest'; import {commands} from 'vitest/browser'; declare module 'vitest/browser' { @@ -104,17 +105,49 @@ function mount(textBetween: boolean) { return {editor, left, right, y}; } -async function selectedDecoratorCount( +function describeDOMPoint(node: Node | null, offset: number): string { + if (node === null) { + return 'null'; + } + const name = + node.nodeType === Node.TEXT_NODE + ? `#text(${JSON.stringify(node.textContent)})` + : node.nodeName.toLowerCase(); + return `${name}:${offset}`; +} + +// The last selectionchange is handled asynchronously, and on a busy runner it +// can land well after the mouse is released, so wait for the editor's +// selection to settle rather than for a fixed time. A failure reports the +// selection that was reached, to tell a selection that never extended apart +// from one that extended too far. +async function expectSelectedDecoratorCount( editor: LexicalEditor, -): Promise { - // Let the last selectionchange be handled and committed. - await new Promise(resolve => setTimeout(resolve, 50)); - return editor.read(() => { - const selection = $getSelection(); - return $isRangeSelection(selection) - ? selection.getNodes().filter($isDecoratorNode).length - : String(selection); - }); + expected: number, +): Promise { + await vi.waitFor( + () => { + const [count, lexical] = editor.read(() => { + const selection = $getSelection(); + return $isRangeSelection(selection) + ? [ + selection.getNodes().filter($isDecoratorNode).length, + `${selection.anchor.key}:${selection.anchor.offset}->` + + `${selection.focus.key}:${selection.focus.offset}`, + ] + : [-1, String(selection)]; + }); + const dom = getDOMSelection(window); + assert( + count === expected, + `expected ${expected} selected decorators, got ${count} ` + + `(lexical ${lexical}; dom ` + + `${describeDOMPoint(dom?.anchorNode ?? null, dom?.anchorOffset ?? 0)}->` + + `${describeDOMPoint(dom?.focusNode ?? null, dom?.focusOffset ?? 0)})`, + ); + }, + {interval: 50, timeout: 2000}, + ); } describe.each([ @@ -128,7 +161,7 @@ describe.each([ [left(COUNT - 2) + SIZE / 2, y], [left(2) - 2, y], ]); - expect(await selectedDecoratorCount(editor)).toBe(COUNT - 2); + await expectSelectedDecoratorCount(editor, COUNT - 2); }); test('a drag from left of the first decorator selects to the end of the line', async () => { @@ -138,6 +171,6 @@ describe.each([ [left(1) + SIZE / 2, y], [right(COUNT - 1) + 30, y], ]); - expect(await selectedDecoratorCount(editor)).toBe(COUNT); + await expectSelectedDecoratorCount(editor, COUNT); }); }); From 09f86ad1c511ee401e2e6edbf3899a8d7181de84 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 17:17:53 +0000 Subject: [PATCH 11/18] [lexical] Chore: Pace the mouseDrag browser command one frame per step ## Description The `mouseDrag` command sent mousedown and every mouse move back to back. On a busy machine (for example, with the Chromium and Firefox browser suites running at the same time) the whole drag could arrive before the editor handled the mousedown's selectionchange. The test then ended with a caret where the drag stopped instead of a range. The command now waits for a frame and a task after mousedown and after each move. That matches how a person's mouse events arrive, and it gives the editor's selectionchange handling a turn between steps. ## Test plan ### Before Reported from a run where the Chromium and Firefox suites ran at the same time: ``` FAIL browser (chromium) packages/lexical/src/__tests__/browser/Issue7158MouseDrag.test.ts > Issue #7158: mouse drag over only inline decorators > a drag from right of the last decorator selects back to the third AssertionError: expected 4 selected decorators, got 1 (lexical 1:2->1:2; dom p:3->p:3) ``` It did not reproduce here, even with every core busy. ### After ``` $ ./node_modules/.bin/vitest run --project browser packages/lexical/src/__tests__/browser/Issue7158MouseDrag.test.ts Tests 4 passed (4) $ ./node_modules/.bin/vitest run --project browser Test Files 61 passed (61) Tests 668 passed | 2 skipped (670) ``` The test still catches #7158. Against upstream main's DOM code, both right-to-left drags fail (`got 1 (lexical 1:2->1:2; dom p:2->p:2)` and `got 0`). With the leading inline anchor turned off, the left-to-right drag over only decorators fails (`expected 6 selected decorators, got 1`). Co-Authored-By: Claude Opus 5.5 (1M context) --- vitest.config.mts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/vitest.config.mts b/vitest.config.mts index 82a78949449..a0ee8863f6a 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -140,15 +140,31 @@ export default defineConfig({ selector: string, points: [x: number, y: number][], ) => { - const target = (await frame()).locator(selector); + const testFrame = await frame(); + const target = testFrame.locator(selector); + // A person's mouse events arrive a frame or more apart, so the + // editor has handled the selectionchange from one before the + // next. Without this pause a busy machine can deliver the + // whole drag before the editor reacts to the mousedown, and + // the editor then writes back a selection the drag has + // already moved past. + const settle = () => + testFrame.evaluate( + () => + new Promise(resolve => + requestAnimationFrame(() => setTimeout(resolve, 0)), + ), + ); const [first, ...rest] = points; await target.hover({ force: true, position: {x: first[0], y: first[1]}, }); await page.mouse.down(); + await settle(); for (const [x, y] of rest) { await target.hover({force: true, position: {x, y}}); + await settle(); } await page.mouse.up(); }, From d79c6f9e372ed269caae59a6ede90d8e62d8cc88 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 18:33:32 +0000 Subject: [PATCH 12/18] [lexical] Bug Fix: Extend a mouse drag across unselectable inline decorators ## Description In Chromium, a mouse drag only extends across an inline DecoratorNode when its hit test finds a position inside the decorator's DOM. When the decorator is `user-select: none` or wraps its content in a block box, as the playground's ImageNode does, it never does, so a drag that starts right of a line of images stays collapsed at the end of the line. On macOS Chrome this also happens intermittently with plain inline decorators. On the engines that get the inline decorator edge boxes (desktop Chromium and WebKit), Lexical now tracks a primary-button mouse press in the root element. Once the pointer has moved past the drag threshold, a pointermove over an inline decorator extends the DOM selection to the side of that decorator nearest the pointer; the browser keeps the anchor it took on mousedown. Because the extended selection then contains the mousedown point, Chromium would turn the press into a text drag and drop, so a dragstart after such an extension is cancelled. A drag that starts left of an unselectable first decorator still misses that decorator: Chromium anchors the press after it. ## Test plan ### Before `./node_modules/.bin/vitest run --project browser packages/lexical/src/__tests__/browser/Issue7158MouseDrag.test.ts` (Chromium, Linux) with the new unselectable decorator cases: ``` AssertionError: expected 4 selected decorators, got 1 (lexical 41:6->41:6; dom p:7->p:7) Tests 1 failed | 6 passed (7) ``` ### After ``` Test Files 1 passed (1) Tests 7 passed (7) ``` `./node_modules/.bin/vitest run --project browser`: `Tests 671 passed | 2 skipped (673)`. `pnpm run test-unit`: `Tests 9281 passed | 1 skipped (9282)`. In the playground, a right-to-left drag over six images now selects four (`10:6->10:2`), where it stayed at `10:6->10:6`, and a left-to-right drag over images separated by spaces still selects all six without turning into a drag and drop. Not run: Firefox and WebKit (only Chromium is installed), and macOS. The 7 `Images.spec.mjs` e2e failures here are the same with and without this change. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/lexical/src/LexicalEditor.ts | 15 ++ packages/lexical/src/LexicalEvents.ts | 133 ++++++++++++++++++ .../browser/Issue7158MouseDrag.test.ts | 95 +++++++++++-- 3 files changed, 229 insertions(+), 14 deletions(-) diff --git a/packages/lexical/src/LexicalEditor.ts b/packages/lexical/src/LexicalEditor.ts index 4e5f0b47b22..5caee7f2018 100644 --- a/packages/lexical/src/LexicalEditor.ts +++ b/packages/lexical/src/LexicalEditor.ts @@ -303,6 +303,20 @@ export interface InputState { focusOffset: number; }; isSelectionChangeFromMouseDown: boolean; + /** + * Where a primary-button mouse pointerdown that may start a text selection + * drag in the root element happened, from that pointerdown until the next + * pointerup there, whether the pointer has since moved past the browser's + * drag threshold, and whether Lexical has extended the drag's selection over + * an inline DecoratorNode. Only tracked on the engines in + * NEEDS_INLINE_DECORATOR_EDGE_BOX; null otherwise. + */ + mouseSelectionDrag: null | { + clientX: number; + clientY: number; + hasExtended: boolean; + isPastDragThreshold: boolean; + }; /** * The pointerType of the latest pointerdown in the root element ('touch', * 'pen' or 'mouse'), so the compatibility mousedown and click that follow a @@ -348,6 +362,7 @@ export function createInputState(): InputState { lastKeyCode: null, lastKeyDownTimeStamp: 0, lastPointerType: '', + mouseSelectionDrag: null, postDeleteSelectionToRestore: null, savedInputMode: undefined, selectionChangeFromDOMUpdatePoints: null, diff --git a/packages/lexical/src/LexicalEvents.ts b/packages/lexical/src/LexicalEvents.ts index 1fd37c5a01e..7b660d4c9fd 100644 --- a/packages/lexical/src/LexicalEvents.ts +++ b/packages/lexical/src/LexicalEvents.ts @@ -92,6 +92,7 @@ import { DOUBLE_LINE_BREAK, IS_ALL_FORMATTING, } from './LexicalConstants'; +import {NEEDS_INLINE_DECORATOR_EDGE_BOX} from './LexicalDOMSlot'; import { compileKeyboardShortcuts, CONTROL_OR_ALT, @@ -114,6 +115,7 @@ import { $getAdjacentNode, $getDOMTextNode, $getNodeByKey, + $getNodeFromDOMNode, $isTokenOrSegmented, $isTokenOrTab, $setSelection, @@ -157,6 +159,8 @@ type RootElementEvents = [ ][]; const PASS_THROUGH_COMMAND = /* @__PURE__ */ Object.freeze({}); const ANDROID_COMPOSITION_LATENCY = 30; +// Chromium's mouse drag threshold is 4px and WebKit's is 3px. +const DRAG_THRESHOLD_PX = 4; let rootElementEvents: RootElementEvents | undefined; function getRootElementEvents(): RootElementEvents { @@ -186,6 +190,15 @@ function getRootElementEvents(): RootElementEvents { (event, editor) => onBeforeInput(event as InputEvent, editor), ]); } + if (NEEDS_INLINE_DECORATOR_EDGE_BOX) { + events.push( + [ + 'pointermove', + (event, editor) => onPointerMove(event as PointerEvent, editor), + ], + ['pointerup', onPointerUp], + ); + } if (IS_IOS) { events.push( ['keyup', (event, editor) => onKeyUp(event as KeyboardEvent, editor)], @@ -829,6 +842,123 @@ function onPointerDown(event: PointerEvent, editor: LexicalEditor) { } }); } + if (NEEDS_INLINE_DECORATOR_EDGE_BOX) { + editor._inputState.mouseSelectionDrag = + isDOMNode(target) && + pointerType === 'mouse' && + event.button === 0 && + !isDOMCapturingSelection(target, editor) + ? { + clientX: event.clientX, + clientY: event.clientY, + hasExtended: false, + isPastDragThreshold: false, + } + : null; + } +} + +function onPointerUp(_event: Event, editor: LexicalEditor) { + editor._inputState.mouseSelectionDrag = null; +} + +/** + * Extends a mouse drag selection across an inline DecoratorNode (#7158). + * + * Chromium and WebKit move the focus of a drag selection by hit-testing the + * pointer, and over an inline decorator's contentEditable=false DOM the hit + * test often finds no position the selection can extend to: always when the + * decorator's content is `user-select: none` or holds a block box, and + * intermittently otherwise. The drag then stays where it was until the pointer + * reaches text or the end of the line. While the pointer is over an inline + * decorator, put the DOM focus on the side of the decorator nearest the + * pointer; the drag keeps the anchor it took on mousedown. + * + * Nothing changes until a pointermove after the one that crossed the drag + * threshold, so a press on an existing selection still drags it. Once the + * selection has been extended, it contains the mousedown point, and Chromium + * keeps checking for a selection drag and drop until its own hit test has + * moved the selection; {@link onSelectionDragStart} cancels that drag. + */ +function onPointerMove(event: PointerEvent, editor: LexicalEditor) { + const drag = editor._inputState.mouseSelectionDrag; + if ( + drag === null || + event.pointerType !== 'mouse' || + (event.buttons & 1) === 0 + ) { + return; + } + if (!drag.isPastDragThreshold) { + drag.isPastDragThreshold = + Math.abs(event.clientX - drag.clientX) > DRAG_THRESHOLD_PX || + Math.abs(event.clientY - drag.clientY) > DRAG_THRESHOLD_PX; + return; + } + const target = getComposedEventTarget(event); + const rootElement = editor.getRootElement(); + if (!isDOMNode(target) || rootElement === null) { + return; + } + const domSelection = getDOMSelectionFromTarget(target); + if (domSelection === null || domSelection.rangeCount === 0) { + return; + } + const {anchorNode} = getDOMSelectionPoints(domSelection, rootElement); + if ( + anchorNode === null || + getNearestEditorFromDOMNode(anchorNode) !== editor + ) { + return; + } + const decoratorDOM = editor.read('latest', () => { + let dom: Node | null = target; + while (dom !== null && dom !== rootElement) { + const node = $getNodeFromDOMNode(dom); + if (node !== null) { + return $isDecoratorNode(node) && node.isInline() ? dom : null; + } + dom = getParentElement(dom); + } + return null; + }); + const parentDOM = decoratorDOM && decoratorDOM.parentNode; + if ( + decoratorDOM === null || + parentDOM === null || + !isHTMLElement(decoratorDOM) + ) { + return; + } + const rect = decoratorDOM.getBoundingClientRect(); + const index = Array.prototype.indexOf.call( + parentDOM.childNodes, + decoratorDOM, + ); + const isRTL = + isHTMLElement(parentDOM) && + getWindow(editor).getComputedStyle(parentDOM).direction === 'rtl'; + const isAfter = event.clientX > rect.left + rect.width / 2 !== isRTL; + const offset = isAfter ? index + 1 : index; + const {focusNode, focusOffset} = domSelection; + if (focusNode !== parentDOM || focusOffset !== offset) { + drag.hasExtended = true; + domSelection.extend(parentDOM, offset); + } +} + +/** + * Cancels the drag and drop that a press would otherwise start once + * {@link onPointerMove} has extended its selection over the mousedown point, + * so the press keeps selecting. Returns true when it did. + */ +function onSelectionDragStart(event: DragEvent, editor: LexicalEditor) { + const drag = editor._inputState.mouseSelectionDrag; + if (drag === null || !drag.hasExtended) { + return false; + } + event.preventDefault(); + return true; } function getTargetRange(event: InputEvent): null | StaticRange { @@ -2300,6 +2430,9 @@ export function addRootElementEvents( ); case 'dragstart': + if (onSelectionDragStart(event as DragEvent, editor)) { + return true; + } return ( isEditable && dispatchCommand(editor, DRAGSTART_COMMAND, event as DragEvent) diff --git a/packages/lexical/src/__tests__/browser/Issue7158MouseDrag.test.ts b/packages/lexical/src/__tests__/browser/Issue7158MouseDrag.test.ts index 263c2789a17..e3bce24f549 100644 --- a/packages/lexical/src/__tests__/browser/Issue7158MouseDrag.test.ts +++ b/packages/lexical/src/__tests__/browser/Issue7158MouseDrag.test.ts @@ -66,7 +66,36 @@ class ImageLikeDecoratorNode extends DecoratorNode { } } -function mount(textBetween: boolean) { +/** + * Styled like the playground's ImageNode: an unselectable inline-block whose + * image sits in a block wrapper. Chromium's hit test finds no position inside + * it that a drag selection can extend to. + */ +class BoxedImageDecoratorNode extends ImageLikeDecoratorNode { + $config() { + return this.config('test_7158_mouse_boxed_image', { + extends: ImageLikeDecoratorNode, + }); + } + createDOM(): HTMLElement { + const span = document.createElement('span'); + span.style.cssText = + 'display:inline-block;position:relative;user-select:none;overflow:hidden'; + const div = document.createElement('div'); + const img = document.createElement('img'); + img.alt = ''; + img.style.cssText = `display:block;width:${SIZE}px;height:${SIZE}px;background:#888`; + div.appendChild(img); + span.appendChild(div); + return span; + } +} + +type DecoratorClass = + | typeof ImageLikeDecoratorNode + | typeof BoxedImageDecoratorNode; + +function mount(textBetween: boolean, klass: DecoratorClass) { const root = document.createElement('div'); root.id = 'issue-7158-root'; root.contentEditable = 'true'; @@ -80,13 +109,13 @@ function mount(textBetween: boolean) { if (textBetween) { paragraph.append($createTextNode(' ')); } - paragraph.append($create(ImageLikeDecoratorNode)); + paragraph.append($create(klass)); } $getRoot().clear().append(paragraph); }, dependencies: [RichTextExtension], name: '[7158-mouse]', - nodes: [ImageLikeDecoratorNode], + nodes: [ImageLikeDecoratorNode, BoxedImageDecoratorNode], }), ); editor.setRootElement(root); @@ -94,7 +123,11 @@ function mount(textBetween: boolean) { editor.dispose(); root.remove(); }); - const images = Array.from(root.querySelectorAll('span > img')); + const images = Array.from( + root.querySelectorAll( + 'img:not([data-lexical-decorator-boundary]):not([data-lexical-managed-linebreak])', + ), + ); const rootRect = root.getBoundingClientRect(); // Points relative to the root's top-left, on the images' center line. const y = images[0].getBoundingClientRect().top + SIZE / 2 - rootRect.top; @@ -150,27 +183,61 @@ async function expectSelectedDecoratorCount( ); } -describe.each([ - ['only inline decorators', false], - ['text between inline decorators', true], -])('Issue #7158: mouse drag over %s', (_name, textBetween) => { - test('a drag from right of the last decorator selects back to the third', async () => { - const {editor, left, right, y} = mount(textBetween); +function dragFromLineEnd(klass: DecoratorClass, textBetween: boolean) { + return async () => { + const {editor, left, right, y} = mount(textBetween, klass); await commands.mouseDrag('#issue-7158-root', [ [right(COUNT - 1) + 30, y], [left(COUNT - 2) + SIZE / 2, y], + [left(3) + SIZE / 2, y], [left(2) - 2, y], ]); await expectSelectedDecoratorCount(editor, COUNT - 2); - }); + }; +} - test('a drag from left of the first decorator selects to the end of the line', async () => { - const {editor, left, right, y} = mount(textBetween); +function dragFromLineStart(klass: DecoratorClass, textBetween: boolean) { + return async () => { + const {editor, left, right, y} = mount(textBetween, klass); await commands.mouseDrag('#issue-7158-root', [ [left(0) - 5, y], [left(1) + SIZE / 2, y], + [left(COUNT - 2) + SIZE / 2, y], [right(COUNT - 1) + 30, y], ]); await expectSelectedDecoratorCount(editor, COUNT); - }); + }; +} + +describe.each([ + ['only inline decorators', false], + ['text between inline decorators', true], +])('Issue #7158: mouse drag over %s', (_name, textBetween) => { + test( + 'a drag from right of the last decorator selects back to the third', + dragFromLineEnd(ImageLikeDecoratorNode, textBetween), + ); + test( + 'a drag from left of the first decorator selects to the end of the line', + dragFromLineStart(ImageLikeDecoratorNode, textBetween), + ); +}); + +// Chromium never extends a drag across these decorators by itself, so these +// exercise the pointermove fallback. A drag that starts left of an +// unselectable first decorator is not covered: Chromium anchors it after that +// decorator, and a drag keeps the anchor it took on mousedown. +describe('Issue #7158: mouse drag over unselectable inline decorators', () => { + test( + 'a drag from right of the last decorator selects back to the third', + dragFromLineEnd(BoxedImageDecoratorNode, false), + ); + test( + 'with text between, a drag from right of the last decorator selects back to the third', + dragFromLineEnd(BoxedImageDecoratorNode, true), + ); + test( + 'with text between, a drag from left of the first text selects to the end of the line', + dragFromLineStart(BoxedImageDecoratorNode, true), + ); }); From da9d7618d4d603f02300920e3c060a161fde80c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 21:08:10 +0000 Subject: [PATCH 13/18] [lexical] Chore: Log pointer events in the #7158 mouse drag test failures ## Description When a drag in `Issue7158MouseDrag.test.ts` selects the wrong decorators, the failure message only shows where the selection ended up, so it can't tell a press that landed somewhere other than the intended point apart from a drag that didn't extend. The message now also lists the decorators' measured positions, the device pixel ratio, and each pointerdown, pointermove and pointerup: where it landed relative to the root, the buttons held, and the DOM selection after the browser handled it. ## Test plan ### Before Failure message: `expected 4 selected decorators, got 2 (lexical 1:4->1:2; dom p:5->p:3)` ### After `./node_modules/.bin/vitest run --project browser packages/lexical/src/__tests__/browser/Issue7158MouseDrag.test.ts`: `Tests 7 passed (7)`. With an expectation deliberately broken, the failure message is: ``` AssertionError: expected 3 selected decorators, got 4 (lexical 1:6->1:2; dom p:7->p:3; pointer left(0)=10 right(5)=250 y=46 dpr=1 move(280,46 b0)=null->null down(280,46 b1)=p:7->p:7 move(190,46 b1)=p:7->p:5 move(150,46 b1)=p:7->p:4 move(88,46 b1)=p:7->p:3 up(88,46 b0)=p:7->p:3) ``` Co-Authored-By: Claude Opus 5.5 (1M context) --- .../browser/Issue7158MouseDrag.test.ts | 31 ++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/packages/lexical/src/__tests__/browser/Issue7158MouseDrag.test.ts b/packages/lexical/src/__tests__/browser/Issue7158MouseDrag.test.ts index e3bce24f549..879bff3a136 100644 --- a/packages/lexical/src/__tests__/browser/Issue7158MouseDrag.test.ts +++ b/packages/lexical/src/__tests__/browser/Issue7158MouseDrag.test.ts @@ -95,6 +95,8 @@ type DecoratorClass = | typeof ImageLikeDecoratorNode | typeof BoxedImageDecoratorNode; +const pointerLog: string[] = []; + function mount(textBetween: boolean, klass: DecoratorClass) { const root = document.createElement('div'); root.id = 'issue-7158-root'; @@ -119,7 +121,30 @@ function mount(textBetween: boolean, klass: DecoratorClass) { }), ); editor.setRootElement(root); + // Where each pointer event landed (relative to the root) and the DOM + // selection once the browser has handled it, for the failure message. + pointerLog.length = 0; + const logPointer = (event: PointerEvent) => { + const rect = root.getBoundingClientRect(); + const x = Math.round(event.clientX - rect.left); + const y = Math.round(event.clientY - rect.top); + setTimeout(() => { + const dom = getDOMSelection(window); + pointerLog.push( + `${event.type.slice(7)}(${x},${y} b${event.buttons})=` + + `${describeDOMPoint(dom?.anchorNode ?? null, dom?.anchorOffset ?? 0)}->` + + `${describeDOMPoint(dom?.focusNode ?? null, dom?.focusOffset ?? 0)}`, + ); + }); + }; + const pointerTypes = ['pointerdown', 'pointermove', 'pointerup'] as const; + for (const type of pointerTypes) { + document.addEventListener(type, logPointer, true); + } onTestFinished(() => { + for (const type of pointerTypes) { + document.removeEventListener(type, logPointer, true); + } editor.dispose(); root.remove(); }); @@ -135,6 +160,9 @@ function mount(textBetween: boolean, klass: DecoratorClass) { images[i].getBoundingClientRect().left - rootRect.left; const right = (i: number) => images[i].getBoundingClientRect().right - rootRect.left; + pointerLog.push( + `left(0)=${Math.round(left(0))} right(${COUNT - 1})=${Math.round(right(COUNT - 1))} y=${Math.round(y)} dpr=${window.devicePixelRatio}`, + ); return {editor, left, right, y}; } @@ -176,7 +204,8 @@ async function expectSelectedDecoratorCount( `expected ${expected} selected decorators, got ${count} ` + `(lexical ${lexical}; dom ` + `${describeDOMPoint(dom?.anchorNode ?? null, dom?.anchorOffset ?? 0)}->` + - `${describeDOMPoint(dom?.focusNode ?? null, dom?.focusOffset ?? 0)})`, + `${describeDOMPoint(dom?.focusNode ?? null, dom?.focusOffset ?? 0)}; ` + + `pointer ${pointerLog.join(' ')})`, ); }, {interval: 50, timeout: 2000}, From b673a19ce8555b86221f465476851399f68a44d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 21:23:15 +0000 Subject: [PATCH 14/18] Chore: Move the mouseDrag browser command in small steps ## Description `mouseDrag` jumped straight from the press to each listed point. Chromium collapses the selection at the pointer on the first drag move after a press that placed a caret (`SelectionController::UpdateSelectionForMouseDrag` builds `Collapse(adjusted_position)` unless the selection state is already `kExtendedSelection`), and extends from there. So a drag whose first move lands 90px from the press is anchored 90px away. In Chromium on macOS, three of the `Issue7158MouseDrag` cases failed that way: the press placed the caret where the test meant, and the first move then reset the anchor to the pointer, e.g. `down(280,46 b1)=p:7->p:7 move(190,46 b1)=p:5->p:5`. The command now walks each segment in steps of at most 5px, as a hand would, so the drag is anchored where it was pressed. ## Test plan ### Before On macOS (Chromium), `pnpm run test-browser packages/lexical/src/__tests__/browser/Issue7158MouseDrag.test.ts`: ``` AssertionError: expected 4 selected decorators, got 2 (lexical 1:4->1:2; dom p:5->p:3; pointer left(0)=10 right(5)=250 y=46 dpr=1 move(280,46 b0)=null->null down(280,46 b1)=p:7->p:7 move(190,46 b1)=p:5->p:5 move(150,46 b1)=p:5->p:4 move(88,46 b1)=p:5->p:3 up(88,46 b0)=p:5->p:3) Tests 3 failed | 4 passed (7) ``` ### After On Linux (Chromium), `./node_modules/.bin/vitest run --project browser packages/lexical/src/__tests__/browser/Issue7158MouseDrag.test.ts`: `Tests 7 passed (7)`. Without the pointermove fallback from d79c6f9, the unselectable decorator case still fails (`expected 4 selected decorators, got 1`), so the test still covers it. Not run on macOS here. Co-Authored-By: Claude Opus 5.5 (1M context) --- vitest.config.mts | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/vitest.config.mts b/vitest.config.mts index a0ee8863f6a..5553d022fd1 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -162,9 +162,29 @@ export default defineConfig({ }); await page.mouse.down(); await settle(); + // Chromium collapses the selection at the pointer on the + // first drag move after a press that placed a caret, and + // extends it from there, so a drag's anchor is where it first + // moved. A hand never jumps tens of pixels in one move, so + // move in steps of at most STEP pixels as it would. + const STEP = 5; + let [lastX, lastY] = first; for (const [x, y] of rest) { - await target.hover({force: true, position: {x, y}}); - await settle(); + const steps = Math.max( + 1, + Math.ceil(Math.hypot(x - lastX, y - lastY) / STEP), + ); + for (let i = 1; i <= steps; i++) { + await target.hover({ + force: true, + position: { + x: lastX + ((x - lastX) * i) / steps, + y: lastY + ((y - lastY) * i) / steps, + }, + }); + await settle(); + } + [lastX, lastY] = [x, y]; } await page.mouse.up(); }, From 7d0cce3f24fae6005366e14f4eec80202f01eeed Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 21:37:07 +0000 Subject: [PATCH 15/18] [lexical] Bug Fix: Extend the drag selection after the browser's own mousemove handling ## Description The pointermove fallback for #7158 extended the DOM selection over the hovered inline decorator from inside the pointermove listener. The browser updates the selection for the same pointer position in the default action of the following mousemove, which runs after that listener. Where the browser's hit test finds a position on the line, it overwrote the extension. On macOS Chrome that position seems to be the start or the end of the line (inferred from Bob's report of a right-to-left drag in the playground selecting either all of the images or none of them). The extension is now applied in a task after the event, so it has the last word for each pointer position. ## Test plan ### Before Reported on macOS Chrome in the playground: a right-to-left drag over a line of images selects all of them or none. Linux headless Chromium finds no position over these decorators, so it doesn't reproduce here. ### After `./node_modules/.bin/vitest run --project browser packages/lexical/src/__tests__/browser/Issue7158MouseDrag.test.ts`: `Tests 7 passed (7)`. `./node_modules/.bin/vitest run --project browser`: `Tests 671 passed | 2 skipped (673)`. In the playground (Linux Chromium), a right-to-left drag over six images, moved in 5px steps, now selects one more image for each image the pointer passes the middle of, from `10:6->10:6` to `10:6->10:0`. Not run on macOS here. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/lexical/src/LexicalEvents.ts | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/packages/lexical/src/LexicalEvents.ts b/packages/lexical/src/LexicalEvents.ts index 7b660d4c9fd..7a97bd3964e 100644 --- a/packages/lexical/src/LexicalEvents.ts +++ b/packages/lexical/src/LexicalEvents.ts @@ -940,11 +940,23 @@ function onPointerMove(event: PointerEvent, editor: LexicalEditor) { getWindow(editor).getComputedStyle(parentDOM).direction === 'rtl'; const isAfter = event.clientX > rect.left + rect.width / 2 !== isRTL; const offset = isAfter ? index + 1 : index; - const {focusNode, focusOffset} = domSelection; - if (focusNode !== parentDOM || focusOffset !== offset) { - drag.hasExtended = true; - domSelection.extend(parentDOM, offset); - } + // The browser moves the selection for this pointer position in the + // default action of the mousemove that follows, which may put the focus + // somewhere else on the line (macOS Chrome picks the start or the end of + // it), so extend once that has run. + setTimeout(() => { + if ( + editor._inputState.mouseSelectionDrag !== drag || + domSelection.rangeCount === 0 + ) { + return; + } + const {focusNode, focusOffset} = domSelection; + if (focusNode !== parentDOM || focusOffset !== offset) { + drag.hasExtended = true; + domSelection.extend(parentDOM, offset); + } + }, 0); } /** From 2fa1f3a807123af15f34f7df9e950267a94961cd Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 21:37:11 +0000 Subject: [PATCH 16/18] [lexical-playground] Bug Fix: Let mouse drags select across images ## Description `.editor-shell span.editor-image` has had `user-select: none` since "Enable copy+paste on NodeSelection" (#2327, 2022), which added it alongside Lexical's own copy and cut handling for NodeSelection. With it, Chromium's hit test finds no position over an image, so a mouse drag that starts left of the first image of a line is anchored after it, and a drag across images depends entirely on Lexical's pointermove fallback. Removing it lets the browser place drag positions over images. Clicking an image still makes a NodeSelection that puts no DOM selection in the image, and Backspace still deletes it, the same as with the rule. ## Test plan ### Before Playground, a line of six images, Linux Chromium, a mouse drag from 5px left of the first image to past the end of the line in 5px steps: `10:1->10:6` (5 images). ### After The same drag: `10:0->10:6` (all 6). A right-to-left drag selects `10:6->10:0`. On a line of images separated by spaces, both directions select all 6. Clicking an image: `NodeSelection 13`, DOM selection type `None`, with and without the change; Backspace then removes the image. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/lexical-playground/src/index.css | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/lexical-playground/src/index.css b/packages/lexical-playground/src/index.css index 263d534928a..8a727bdd3ae 100644 --- a/packages/lexical-playground/src/index.css +++ b/packages/lexical-playground/src/index.css @@ -1226,7 +1226,6 @@ i.page-setup, cursor: default; display: inline-block; position: relative; - user-select: none; overflow: hidden; } From c97dfac85d018834074b76b162c54eb9fe3188b4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 22:02:08 +0000 Subject: [PATCH 17/18] [lexical] Bug Fix: Keep a mouse drag's anchor when its selection is reset mid-drag ## Description On macOS Chrome, a drag over the playground's images could lose its anchor partway through. It restarted from wherever the pointer was, or the editor selection went null. When a drag point lands inside a decorator, Lexical resolves it to no selection, and the reconciler then removes the DOM selection. Chrome carries a drag on from the selection's current anchor, so it starts over from the next pointer position. While a single-click mouse drag is in progress (on the engines in `NEEDS_INLINE_DECORATOR_EDGE_BOX`): - The reconciler leaves the DOM selection alone when the editor selection is null. - The drag records its DOM anchor once it crosses the drag threshold, and after each pointermove restores that anchor if something replaced it. - A focus that the browser put inside an inline decorator is moved to the side of the decorator nearest the pointer, as it already was while the pointer is over one. - The press is now released by a pointerup anywhere in the document, not only in the root element. ## Test plan ### Before `./node_modules/.bin/vitest run --project browser packages/lexical/src/__tests__/browser/Issue7158MouseDrag.test.ts -t restarts`, with the new test (it collapses the selection inside a decorator mid-drag, as macOS Chrome does): ``` AssertionError: expected 4 selected decorators, got 2 (lexical 1:4->1:2; dom div:0->p:3; ...) Tests 1 failed | 10 skipped (11) ``` ### After ``` Tests 1 passed | 10 skipped (11) ``` All 11 drag tests pass. `./node_modules/.bin/vitest run --project browser`: `Tests 675 passed | 2 skipped (677)`. `pnpm run test-unit`: `Tests 9281 passed | 1 skipped (9282)`. In the playground (Linux Chromium), drags in both directions over images, and drags that wander past the line end and back, keep their anchor. Not run on macOS here. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/lexical/src/LexicalEditor.ts | 15 +- packages/lexical/src/LexicalEvents.ts | 187 ++++++++++++------ packages/lexical/src/LexicalSelection.ts | 5 + .../browser/Issue7158MouseDrag.test.ts | 84 +++++++- 4 files changed, 221 insertions(+), 70 deletions(-) diff --git a/packages/lexical/src/LexicalEditor.ts b/packages/lexical/src/LexicalEditor.ts index 5caee7f2018..7b1f89d3463 100644 --- a/packages/lexical/src/LexicalEditor.ts +++ b/packages/lexical/src/LexicalEditor.ts @@ -304,18 +304,19 @@ export interface InputState { }; isSelectionChangeFromMouseDown: boolean; /** - * Where a primary-button mouse pointerdown that may start a text selection - * drag in the root element happened, from that pointerdown until the next - * pointerup there, whether the pointer has since moved past the browser's - * drag threshold, and whether Lexical has extended the drag's selection over - * an inline DecoratorNode. Only tracked on the engines in - * NEEDS_INLINE_DECORATOR_EDGE_BOX; null otherwise. + * A single primary-button mouse press in the root element that may start a + * text selection drag, from its pointerdown until the next pointerup + * anywhere in its document: where it was pressed, the DOM anchor of its + * selection once the pointer has crossed the drag threshold (false when + * that is outside this editor), and whether Lexical has changed the drag's + * selection. Only tracked on the engines in NEEDS_INLINE_DECORATOR_EDGE_BOX; + * null otherwise. */ mouseSelectionDrag: null | { + anchor: null | false | [node: Node, offset: number]; clientX: number; clientY: number; hasExtended: boolean; - isPastDragThreshold: boolean; }; /** * The pointerType of the latest pointerdown in the root element ('touch', diff --git a/packages/lexical/src/LexicalEvents.ts b/packages/lexical/src/LexicalEvents.ts index 7a97bd3964e..28dd12af7ae 100644 --- a/packages/lexical/src/LexicalEvents.ts +++ b/packages/lexical/src/LexicalEvents.ts @@ -191,13 +191,10 @@ function getRootElementEvents(): RootElementEvents { ]); } if (NEEDS_INLINE_DECORATOR_EDGE_BOX) { - events.push( - [ - 'pointermove', - (event, editor) => onPointerMove(event as PointerEvent, editor), - ], - ['pointerup', onPointerUp], - ); + events.push([ + 'pointermove', + (event, editor) => onPointerMove(event as PointerEvent, editor), + ]); } if (IS_IOS) { events.push( @@ -843,25 +840,38 @@ function onPointerDown(event: PointerEvent, editor: LexicalEditor) { }); } if (NEEDS_INLINE_DECORATOR_EDGE_BOX) { - editor._inputState.mouseSelectionDrag = + const drag = isDOMNode(target) && pointerType === 'mouse' && event.button === 0 && + event.detail <= 1 && !isDOMCapturingSelection(target, editor) ? { + anchor: null, clientX: event.clientX, clientY: event.clientY, hasExtended: false, - isPastDragThreshold: false, } : null; + editor._inputState.mouseSelectionDrag = drag; + if (drag !== null && isDOMNode(target)) { + // The button may be released outside the root element. + const doc = target.ownerDocument; + if (doc !== null) { + doc.addEventListener( + 'pointerup', + () => { + if (editor._inputState.mouseSelectionDrag === drag) { + editor._inputState.mouseSelectionDrag = null; + } + }, + {capture: true, once: true}, + ); + } + } } } -function onPointerUp(_event: Event, editor: LexicalEditor) { - editor._inputState.mouseSelectionDrag = null; -} - /** * Extends a mouse drag selection across an inline DecoratorNode (#7158). * @@ -871,8 +881,13 @@ function onPointerUp(_event: Event, editor: LexicalEditor) { * decorator's content is `user-select: none` or holds a block box, and * intermittently otherwise. The drag then stays where it was until the pointer * reaches text or the end of the line. While the pointer is over an inline - * decorator, put the DOM focus on the side of the decorator nearest the - * pointer; the drag keeps the anchor it took on mousedown. + * decorator, or the browser has put the focus inside one, put the DOM focus + * on the side of the decorator nearest the pointer. + * + * A drag also keeps extending from the selection's current anchor, so when + * something replaces the selection mid-drag (on macOS Chrome the drag can + * restart from the pointer), restore the anchor the drag had once it crossed + * the drag threshold. * * Nothing changes until a pointermove after the one that crossed the drag * threshold, so a press on an existing selection still drags it. Once the @@ -889,12 +904,6 @@ function onPointerMove(event: PointerEvent, editor: LexicalEditor) { ) { return; } - if (!drag.isPastDragThreshold) { - drag.isPastDragThreshold = - Math.abs(event.clientX - drag.clientX) > DRAG_THRESHOLD_PX || - Math.abs(event.clientY - drag.clientY) > DRAG_THRESHOLD_PX; - return; - } const target = getComposedEventTarget(event); const rootElement = editor.getRootElement(); if (!isDOMNode(target) || rootElement === null) { @@ -904,61 +913,117 @@ function onPointerMove(event: PointerEvent, editor: LexicalEditor) { if (domSelection === null || domSelection.rangeCount === 0) { return; } - const {anchorNode} = getDOMSelectionPoints(domSelection, rootElement); - if ( - anchorNode === null || - getNearestEditorFromDOMNode(anchorNode) !== editor - ) { + if (drag.anchor === null) { + if ( + Math.abs(event.clientX - drag.clientX) > DRAG_THRESHOLD_PX || + Math.abs(event.clientY - drag.clientY) > DRAG_THRESHOLD_PX + ) { + // The browser has placed the drag's anchor, and Lexical has handled + // it, by the time the pointer crosses the drag threshold. + const {anchorNode, anchorOffset} = getDOMSelectionPoints( + domSelection, + rootElement, + ); + drag.anchor = + anchorNode !== null && + getNearestEditorFromDOMNode(anchorNode) === editor + ? [anchorNode, anchorOffset] + : false; + } return; } - const decoratorDOM = editor.read('latest', () => { - let dom: Node | null = target; - while (dom !== null && dom !== rootElement) { - const node = $getNodeFromDOMNode(dom); - if (node !== null) { - return $isDecoratorNode(node) && node.isInline() ? dom : null; - } - dom = getParentElement(dom); - } - return null; - }); - const parentDOM = decoratorDOM && decoratorDOM.parentNode; - if ( - decoratorDOM === null || - parentDOM === null || - !isHTMLElement(decoratorDOM) - ) { + const dragAnchor = drag.anchor; + if (dragAnchor === false) { return; } - const rect = decoratorDOM.getBoundingClientRect(); - const index = Array.prototype.indexOf.call( - parentDOM.childNodes, - decoratorDOM, - ); - const isRTL = - isHTMLElement(parentDOM) && - getWindow(editor).getComputedStyle(parentDOM).direction === 'rtl'; - const isAfter = event.clientX > rect.left + rect.width / 2 !== isRTL; - const offset = isAfter ? index + 1 : index; // The browser moves the selection for this pointer position in the // default action of the mousemove that follows, which may put the focus // somewhere else on the line (macOS Chrome picks the start or the end of - // it), so extend once that has run. + // it) or inside the decorator, so adjust it once that has run. setTimeout(() => { - if ( - editor._inputState.mouseSelectionDrag !== drag || - domSelection.rangeCount === 0 - ) { + if (editor._inputState.mouseSelectionDrag !== drag) { + return; + } + const [anchorNode, anchorOffset] = dragAnchor; + if (!anchorNode.isConnected) { + return; + } + const points = + domSelection.rangeCount > 0 + ? getDOMSelectionPoints(domSelection, rootElement) + : null; + let focusNode = points ? points.focusNode : null; + let focusOffset = points ? points.focusOffset : 0; + const decoratorDOM = + getInlineDecoratorDOM(editor, target, rootElement) || + (focusNode && getInlineDecoratorDOM(editor, focusNode, rootElement)); + const parentDOM = decoratorDOM && decoratorDOM.parentNode; + if (decoratorDOM && parentDOM) { + const rect = decoratorDOM.getBoundingClientRect(); + const index = Array.prototype.indexOf.call( + parentDOM.childNodes, + decoratorDOM, + ); + const isRTL = + isHTMLElement(parentDOM) && + getWindow(editor).getComputedStyle(parentDOM).direction === 'rtl'; + const isAfter = event.clientX > rect.left + rect.width / 2 !== isRTL; + focusNode = parentDOM; + focusOffset = isAfter ? index + 1 : index; + } + if (focusNode === null) { return; } - const {focusNode, focusOffset} = domSelection; - if (focusNode !== parentDOM || focusOffset !== offset) { + if ( + points === null || + points.anchorNode !== anchorNode || + points.anchorOffset !== anchorOffset + ) { + // Something reset the selection during the drag, and the browser + // would carry on from wherever it now is. + drag.hasExtended = true; + domSelection.setBaseAndExtent( + anchorNode, + anchorOffset, + focusNode, + focusOffset, + ); + } else if ( + points.focusNode !== focusNode || + points.focusOffset !== focusOffset + ) { drag.hasExtended = true; - domSelection.extend(parentDOM, offset); + domSelection.extend(focusNode, focusOffset); } }, 0); } +/** + * The DOM of the inline DecoratorNode of `editor` that contains `dom`, or + * null when `dom` is not inside one. + */ +function getInlineDecoratorDOM( + editor: LexicalEditor, + dom: Node, + rootElement: HTMLElement, +): HTMLElement | null { + return editor.read('latest', () => { + let node: Node | null = dom; + while (node !== null && node !== rootElement) { + const lexicalNode = $getNodeFromDOMNode(node); + if (lexicalNode !== null) { + return $isDecoratorNode(lexicalNode) && + lexicalNode.isInline() && + isHTMLElement(node) + ? node + : null; + } + node = getParentElement(node); + } + return null; + }); +} + /** * Cancels the drag and drop that a press would otherwise start once * {@link onPointerMove} has extended its selection over the mousedown point, diff --git a/packages/lexical/src/LexicalSelection.ts b/packages/lexical/src/LexicalSelection.ts index 6176e38a0c1..051a447a584 100644 --- a/packages/lexical/src/LexicalSelection.ts +++ b/packages/lexical/src/LexicalSelection.ts @@ -3942,8 +3942,13 @@ export function $updateDOMSelection( // of editor.setRootElement(). If this occurs on init when the // editor is already focused, then this can cause the editor to // lose focus. + // While a mouse button is held for a selection drag, the browser's + // selection is the drag in progress, and removing it would restart the + // drag from wherever the pointer moves next. It is null here when a drag + // point lands inside a decorator, which the next pointermove moves out. if ( prevSelection !== null && + editor._inputState.mouseSelectionDrag === null && isSelectionWithinEditor( editor, currentPoints.anchorNode, diff --git a/packages/lexical/src/__tests__/browser/Issue7158MouseDrag.test.ts b/packages/lexical/src/__tests__/browser/Issue7158MouseDrag.test.ts index 879bff3a136..2b16ac5bfaf 100644 --- a/packages/lexical/src/__tests__/browser/Issue7158MouseDrag.test.ts +++ b/packages/lexical/src/__tests__/browser/Issue7158MouseDrag.test.ts @@ -91,9 +91,27 @@ class BoxedImageDecoratorNode extends ImageLikeDecoratorNode { } } +/** + * The same block-wrapped image, but selectable: Chromium's hit test can put + * a drag point inside it, where the point resolves to no Lexical selection. + */ +class SelectableBoxedImageDecoratorNode extends ImageLikeDecoratorNode { + $config() { + return this.config('test_7158_mouse_selectable_boxed_image', { + extends: ImageLikeDecoratorNode, + }); + } + createDOM(): HTMLElement { + const span = new BoxedImageDecoratorNode().createDOM(); + span.style.userSelect = ''; + return span; + } +} + type DecoratorClass = | typeof ImageLikeDecoratorNode - | typeof BoxedImageDecoratorNode; + | typeof BoxedImageDecoratorNode + | typeof SelectableBoxedImageDecoratorNode; const pointerLog: string[] = []; @@ -117,7 +135,11 @@ function mount(textBetween: boolean, klass: DecoratorClass) { }, dependencies: [RichTextExtension], name: '[7158-mouse]', - nodes: [ImageLikeDecoratorNode, BoxedImageDecoratorNode], + nodes: [ + ImageLikeDecoratorNode, + BoxedImageDecoratorNode, + SelectableBoxedImageDecoratorNode, + ], }), ); editor.setRootElement(root); @@ -270,3 +292,61 @@ describe('Issue #7158: mouse drag over unselectable inline decorators', () => { dragFromLineStart(BoxedImageDecoratorNode, true), ); }); + +describe('Issue #7158: mouse drag over selectable boxed inline decorators', () => { + test( + 'a drag from right of the last decorator selects back to the third', + dragFromLineEnd(SelectableBoxedImageDecoratorNode, false), + ); + test( + 'a drag from left of the first decorator selects to the end of the line', + dragFromLineStart(SelectableBoxedImageDecoratorNode, false), + ); + test('a drag that returns to its start and leaves again keeps its anchor', async () => { + const {editor, left, right, y} = mount( + false, + SelectableBoxedImageDecoratorNode, + ); + await commands.mouseDrag('#issue-7158-root', [ + [right(COUNT - 1) + 30, y], + [left(2) + SIZE / 2, y], + [right(COUNT - 1) + 10, y], + [left(COUNT - 1) + 5, y], + [left(2) - 2, y], + ]); + await expectSelectedDecoratorCount(editor, COUNT - 2); + }); + + // On macOS Chrome a drag can restart from the pointer, collapsing the + // selection inside a decorator, where it resolves to no Lexical selection. + // Linux Chromium does not do this, so do it the way it would: after the + // browser and Lexical have handled a pointermove. + test('a drag that restarts inside a decorator keeps its anchor', async () => { + const {editor, left, right, y} = mount( + false, + SelectableBoxedImageDecoratorNode, + ); + const root = document.getElementById('issue-7158-root')!; + const decorators = root.querySelectorAll('span > div'); + let injected = false; + const inject = (event: PointerEvent) => { + const x = event.clientX - root.getBoundingClientRect().left; + if (!injected && event.buttons === 1 && x < left(COUNT - 2)) { + injected = true; + requestAnimationFrame(() => + getDOMSelection(window)?.collapse(decorators[COUNT - 2], 0), + ); + } + }; + document.addEventListener('pointermove', inject, true); + onTestFinished(() => + document.removeEventListener('pointermove', inject, true), + ); + await commands.mouseDrag('#issue-7158-root', [ + [right(COUNT - 1) + 30, y], + [left(2) - 2, y], + ]); + assert(injected, 'the drag never reached the injection point'); + await expectSelectedDecoratorCount(editor, COUNT - 2); + }); +}); From 5f1135886c5bf9f541280425f8416e052a8e398d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 00:01:45 +0000 Subject: [PATCH 18/18] [lexical] Bug Fix: Stop rewriting the selection on every move of a drag past the line end ## Description When a mouse drag goes below or past the end of a line that ends in an inline decorator, Chromium puts the focus after the managed line break's ``, which Lexical maps to the point before it. `$updateDOMSelection` wrote that point back on every pointer move, and the browser moved it again on the next one. Each move then caused two `selectionchange` events and one `SELECTION_CHANGE_COMMAND` while the selection never changed. While a mouse drag is in progress, `$updateDOMSelection` now treats a DOM point that differs from the one it would write only by managed line break children as the same point, and leaves it alone. ## Test plan ### Before `pnpm run test-browser packages/lexical/src/__tests__/browser/Issue7158MouseDrag.test.ts` ``` AssertionError: expected at most 3 selection changes, got 10 Tests 1 failed | 11 passed (12) ``` In the playground, pressing right of a line of six images and dragging 40 steps below the line gave 42 `SELECTION_CHANGE_COMMAND`s, 82 `selectionchange` events and 41 selection writes (`setBaseAndExtent P,7,P,7` from `P7->P8`). ### After ``` Tests 12 passed (12) ``` `pnpm run test-browser`: `Tests 676 passed | 2 skipped (678)`. `pnpm run test-unit packages/lexical`: `Tests 6155 passed | 1 skipped (6156)`. The same playground drag gives 3 `SELECTION_CHANGE_COMMAND`s, 3 `selectionchange` events and 1 selection write. Only Chromium on Linux was exercised; macOS Chrome, Firefox and WebKit were not. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/lexical/src/LexicalSelection.ts | 36 ++++++++++++++++ .../browser/Issue7158MouseDrag.test.ts | 41 +++++++++++++++++++ 2 files changed, 77 insertions(+) diff --git a/packages/lexical/src/LexicalSelection.ts b/packages/lexical/src/LexicalSelection.ts index 051a447a584..c58870020bf 100644 --- a/packages/lexical/src/LexicalSelection.ts +++ b/packages/lexical/src/LexicalSelection.ts @@ -3888,6 +3888,25 @@ function $getElementPointScrollTarget( : child; } +/** + * The offset in `node` before any managed line break children that precede + * `offset`, which is the same position as far as Lexical is concerned. + */ +function skipManagedLineBreak(node: Node, offset: number): number { + const children = node.childNodes; + while (offset > 0) { + const child = children[offset - 1]; + if ( + !isHTMLElement(child) || + !child.hasAttribute('data-lexical-managed-linebreak') + ) { + break; + } + offset--; + } + return offset; +} + /** @internal */ export function $updateDOMSelection( prevSelection: BaseSelection | null, @@ -4066,6 +4085,23 @@ export function $updateDOMSelection( } } + // During a mouse drag past the end of a line that ends in an inline + // decorator, the browser puts the focus after the managed line break's + // , which is the same Lexical point as before it. Writing the + // selection back would only make the browser move it again on the next + // pointer move, turning every move into two selection changes. + if ( + editor._inputState.mouseSelectionDrag !== null && + currentPoints.anchorNode === nextAnchorNode && + currentPoints.focusNode === nextFocusNode && + skipManagedLineBreak(nextAnchorNode, currentPoints.anchorOffset) === + nextAnchorOffset && + skipManagedLineBreak(nextFocusNode, currentPoints.focusOffset) === + nextFocusOffset + ) { + return; + } + // Apply the updated selection to the DOM. Note: this will trigger // a "selectionchange" event, although it will be asynchronous. setDOMSelectionBaseAndExtent( diff --git a/packages/lexical/src/__tests__/browser/Issue7158MouseDrag.test.ts b/packages/lexical/src/__tests__/browser/Issue7158MouseDrag.test.ts index 2b16ac5bfaf..8a04f4a33e6 100644 --- a/packages/lexical/src/__tests__/browser/Issue7158MouseDrag.test.ts +++ b/packages/lexical/src/__tests__/browser/Issue7158MouseDrag.test.ts @@ -24,9 +24,11 @@ import { $getSelection, $isDecoratorNode, $isRangeSelection, + COMMAND_PRIORITY_CRITICAL, DecoratorNode, getDOMSelection, type LexicalEditor, + SELECTION_CHANGE_COMMAND, } from 'lexical'; import {assert, describe, onTestFinished, test, vi} from 'vitest'; import {commands} from 'vitest/browser'; @@ -274,6 +276,45 @@ describe.each([ ); }); +// The browser puts a drag's focus below or past the end of the line after the +// managed line break's , which Lexical maps to the point before it. +// Writing that point back only has the browser move it again on the next +// pointer move, so each move would be two selection changes. +test('Issue #7158: a drag past the end of the line does not keep changing the selection', async () => { + const {editor, right, y} = mount(false, ImageLikeDecoratorNode); + let selectionChanges = 0; + onTestFinished( + editor.registerCommand( + SELECTION_CHANGE_COMMAND, + () => { + selectionChanges++; + return false; + }, + COMMAND_PRIORITY_CRITICAL, + ), + ); + const x = right(COUNT - 1) + 30; + await commands.mouseDrag('#issue-7158-root', [ + [x, y], + [x + 100, y + SIZE], + ]); + await vi.waitFor(() => + assert( + editor.read(() => { + const selection = $getSelection(); + return $isRangeSelection(selection) && selection.isCollapsed(); + }), + 'expected a collapsed selection', + ), + ); + // The press and the first move can each change the selection once; the + // other twenty or so moves must not. + assert( + selectionChanges <= 3, + `expected at most 3 selection changes, got ${selectionChanges}`, + ); +}); + // Chromium never extends a drag across these decorators by itself, so these // exercise the pointermove fallback. A drag that starts left of an // unselectable first decorator is not covered: Chromium anchors it after that