Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
a73afb4
Decorator nodes are not selectable with mouse
vijayojha89 Oct 5, 2026
2b17736
Merge remote-tracking branch 'origin/main' into fix/decorator-nodes-a…
vijayojha89 Oct 6, 2026
ff3d3f2
Ignore the leading boundary anchor in SelectBlock browser test offsets
vijayojha89 Oct 6, 2026
41e3ae4
ensure width/height/etc. are explicitly set to avoid issues with cascade
etrepum Oct 8, 2026
7508af4
Merge remote-tracking branch 'origin/main' into fix/decorator-nodes-a…
etrepum Oct 8, 2026
2031ea7
reorder dom attr
etrepum Oct 8, 2026
b9061f9
fix packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx expect…
etrepum Oct 8, 2026
4102fb0
[lexical] Chore: Update the decorator boundary anchor HTML in test utils
claude Oct 8, 2026
3ad3a3a
unify test constants and make tests less sensitive to attribute ordering
etrepum Oct 8, 2026
f40509e
[lexical] Bug Fix: Gate the #7158 edge anchors per engine and restore…
claude Oct 8, 2026
67912ec
Merge branch 'pr9329' into claude/project-thread-igtcj2
claude Oct 8, 2026
c95793d
[lexical] Chore: Compare rendered HTML in LexicalEditor tests with ex…
claude Oct 8, 2026
c9e3b19
[lexical] Chore: Wait for the selection to settle in the #7158 mouse …
claude Oct 8, 2026
09f86ad
[lexical] Chore: Pace the mouseDrag browser command one frame per step
claude Oct 9, 2026
d79c6f9
[lexical] Bug Fix: Extend a mouse drag across unselectable inline dec…
claude Oct 9, 2026
da9d761
[lexical] Chore: Log pointer events in the #7158 mouse drag test fail…
claude Oct 9, 2026
b673a19
Chore: Move the mouseDrag browser command in small steps
claude Oct 9, 2026
7d0cce3
[lexical] Bug Fix: Extend the drag selection after the browser's own …
claude Oct 9, 2026
2fa1f3a
[lexical-playground] Bug Fix: Let mouse drags select across images
claude Oct 9, 2026
c97dfac
[lexical] Bug Fix: Keep a mouse drag's anchor when its selection is r…
claude Oct 9, 2026
5f11358
[lexical] Bug Fix: Stop rewriting the selection on every move of a dr…
claude Oct 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,21 @@ function setup($children: () => LexicalNode[]) {
return {editor, root};
}

// The reconciler parks a zero-size boundary anchor `<img>` 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 [
Expand Down Expand Up @@ -192,13 +207,21 @@ describe('native select-all block expansion', () => {
return last.getChildrenSize();
});
const endNode = endPath.reduce<Node>(
(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));
Expand Down
5 changes: 4 additions & 1 deletion packages/lexical-playground/__tests__/utils/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -382,8 +382,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(
/<img (?:[^>]+ )?data-lexical-managed-linebreak="true"(?: [^>]+)?>/g,
'',
Expand Down
1 change: 0 additions & 1 deletion packages/lexical-playground/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -1226,7 +1226,6 @@ i.page-setup,
cursor: default;
display: inline-block;
position: relative;
user-select: none;
overflow: hidden;
}

Expand Down
2 changes: 1 addition & 1 deletion packages/lexical/flow/Lexical.js.flow
Original file line number Diff line number Diff line change
Expand Up @@ -1123,7 +1123,7 @@ declare export class ElementDOMSlot<out T extends HTMLElement> extends DOMSlot<T
//
getManagedLineBreak(): HTMLElement | null;
removeManagedLineBreak(): void;
insertManagedLineBreak(webkitHack: boolean): void;
insertManagedLineBreak(withEdgeImg: boolean): void;
getFirstChildOffset(): number;
resolveChildIndex(element: ElementNode, elementDOM: HTMLElement, initialDOM: Node, initialOffset: number): [node: ElementNode, idx: number];
}
Expand Down
118 changes: 91 additions & 27 deletions packages/lexical/src/LexicalDOMSlot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,15 @@ 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';
import {setDOMStyleObject} from './utils/setDOMStyle';

/**
* The editor has at most one block cursor element
Expand Down Expand Up @@ -45,6 +52,22 @@ function isSlotContainerDOM(

const IS_WEBKIT_BROWSER = IS_APPLE_WEBKIT || IS_IOS || IS_SAFARI;

/**
* @internal
*
* Engines that need an editable inline box beside an inline DecoratorNode at
* either edge of a line: without one, a click beside 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). WebKit has needed the
* trailing one for its caret all along. Android is left alone: its IME is
* sensitive to the DOM around the caret.
*/
export const NEEDS_INLINE_DECORATOR_EDGE_BOX: boolean =
IS_WEBKIT_BROWSER || (IS_CHROME && !IS_ANDROID);

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
Expand All @@ -57,6 +80,12 @@ 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.
*
* On the engines in {@link NEEDS_INLINE_DECORATOR_EDGE_BOX}, 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 `<img>` 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
Expand All @@ -67,19 +96,8 @@ 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');
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');
}
const img = $createZeroImg(DATA_LEXICAL_DECORATOR_BOUNDARY);
img.style.setProperty('position', 'absolute', 'important');
return img;
}

Expand All @@ -98,7 +116,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)
);
}

Expand Down Expand Up @@ -288,6 +306,28 @@ 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:
| typeof DATA_LEXICAL_MANAGED_LINEBREAK
| typeof DATA_LEXICAL_DECORATOR_BOUNDARY,
): HTMLImageElement {
const img = $getDocument().createElement('img');
img.setAttribute(attr, 'true');
img.alt = '';
setDOMStyleObject(img.style, ZERO_IMG_STYLE);
return img;
}

/**
* A utility class for managing the DOM children of an ElementNode.
*
Expand Down Expand Up @@ -447,8 +487,37 @@ export class ElementDOMSlot<
if (nextLineBreakType === null) {
this.removeManagedLineBreak();
} else {
const webkitHack = nextLineBreakType === 'decorator' && IS_WEBKIT_BROWSER;
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 `<img>` of
* the img+br pair is the tracked node, so a lone `<br>` or `<img>` 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,
);
}
}

Expand All @@ -466,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();
Expand All @@ -479,15 +548,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 = '';
if (withEdgeImg) {
const img = $createZeroImg(DATA_LEXICAL_MANAGED_LINEBREAK);
element.insertBefore(img, br);
element.__lexicalLineBreak = img;
} else {
Expand Down
16 changes: 16 additions & 0 deletions packages/lexical/src/LexicalEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,21 @@ export interface InputState {
focusOffset: number;
};
isSelectionChangeFromMouseDown: boolean;
/**
* 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;
};
/**
* The pointerType of the latest pointerdown in the root element ('touch',
* 'pen' or 'mouse'), so the compatibility mousedown and click that follow a
Expand Down Expand Up @@ -348,6 +363,7 @@ export function createInputState(): InputState {
lastKeyCode: null,
lastKeyDownTimeStamp: 0,
lastPointerType: '',
mouseSelectionDrag: null,
postDeleteSelectionToRestore: null,
savedInputMode: undefined,
selectionChangeFromDOMUpdatePoints: null,
Expand Down
Loading
Loading