Skip to content

[lexical][lexical-rich-text][lexical-selection] Bug Fix: Fix navigation across unmergeable TextNode boundaries in inline-grid containers#8797

Open
mayrang wants to merge 7 commits into
facebook:mainfrom
mayrang:fix/7301-inline-grid-deletion
Open

[lexical][lexical-rich-text][lexical-selection] Bug Fix: Fix navigation across unmergeable TextNode boundaries in inline-grid containers#8797
mayrang wants to merge 7 commits into
facebook:mainfrom
mayrang:fix/7301-inline-grid-deletion

Conversation

@mayrang

@mayrang mayrang commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Description

When adjacent unmergeable TextNodes live inside a display: inline-grid (or inline-flex) container, the browser renders each node in its own <span> and treats each grid column as a separate "line." This breaks Selection.modify — forward/backward delete gets stuck at span boundaries, left/right arrow keys can't cross them, and up/down arrows cycle through columns instead of moving to the next paragraph.

Fix

Left/right arrows and delete — Three coordinated changes:

  1. RangeSelection.modify() now pre-normalizes the DOM selection into the adjacent sibling's Text node before calling native Selection.modify, so the browser can cross the <span> boundary. This only fires at character granularity on unmergeable TextNodes.
  2. resolveSelectionPointOnBoundary skips the text-to-text normalization when the current node is unmergeable, preserving the cursor's identity at the boundary instead of collapsing it into the previous sibling.
  3. $shouldOverrideDefaultCharacterSelection returns true at unmergeable TextNode boundaries adjacent to another plain TextNode, triggering Lexical's own modify() path instead of relying on native handling.

Up/down arrows$tryInlineGridLineNavigation in @lexical/rich-text detects when the cursor's parent block contains an inline-grid/inline-flex child. It finds the sibling block, uses caretFromPoint to place the caret at the same X coordinate in that block, and falls back to structural navigation when coordinate lookup fails. At document boundaries (no sibling block), up goes to the start of the line and down goes to the end.

Closes #7301

Test plan

  • pnpm vitest run --project unit — 3638 pass (209 files), including 4 new browser tests in Issue7301InlineGridDeletion.test.ts
  • Manual test page with Before [Alpha|Beta|Gamma] After in an inline-grid container + a second normal paragraph:
    • Forward delete from end of Alpha: Beta deleted, then Gamma deleted
    • Backspace from start of Gamma: Beta deleted
    • Right arrow from end of Alpha: cursor moves to end of Beta (not stuck)
    • Left arrow from end of Beta: cursor moves to start of Beta (preserves identity)
    • Down arrow from Before/Alpha/Beta/Gamma/After: moves to second paragraph
    • Up arrow from second paragraph: moves to first paragraph
    • Down arrow on last paragraph: cursor moves to end of line
    • Up arrow on first paragraph: cursor moves to start of line
    • Second paragraph up/down: no regression
  • Playground bold/italic format-affinity: selecting bold text then pressing right arrow still lands in the bold run (unmergeable guard prevents interference)
  • tsc / eslint / prettier clean

@vercel

vercel Bot commented Jul 6, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
lexical Ready Ready Preview Jul 7, 2026 3:45pm
lexical-playground Ready Ready Preview Jul 7, 2026 3:45pm

Request Review

mayrang added 6 commits July 7, 2026 09:53
…on across unmergeable TextNode boundaries in inline-grid containers (facebook#7301)

Forward/backward delete, left/right arrows, and up/down arrows all failed to cross <span> boundaries between adjacent unmergeable TextNodes inside display: inline-grid containers because the browser's native Selection.modify treats each grid column as a separate line.

- Add pre-normalization in modify() for character-granularity boundary crossing on unmergeable TextNodes
- Guard resolveSelectionPointOnBoundary to preserve cursor identity at unmergeable boundaries instead of normalizing to the previous sibling
- Restrict $shouldOverrideDefaultCharacterSelection to unmergeable nodes to avoid disrupting format-affinity at normal bold/italic boundaries
- Add $tryInlineGridLineNavigation in rich-text for up/down arrow keys that detects inline-grid/flex in the parent block and navigates to the sibling block using coordinate-based caret placement

Fixes facebook#7301
…in inline-grid navigation

- Return false (fall through to native) when siblingDOM or domSelection
  is unavailable, instead of returning true which would freeze the caret
- Constrain caretFromPoint hit check to siblingDOM.contains instead of
  rootElement.contains to prevent cross-paragraph jumps
- Add isUnmergeable() guard to pre-normalization in modify() to restrict
  the blast radius to inline-grid scenarios only
Native Selection.modify('extend') cannot cross inline-grid span
boundaries even after pre-normalizing the DOM selection. For extend
mode (used by deleteCharacter), set the Lexical selection focus
directly to the adjacent sibling's first/last character offset and
return early, bypassing the native modify call entirely.
…fy()

The extend path at unmergeable boundaries sets the Lexical selection
directly without touching the DOM, so it should not be gated on
nextFocusDOM. On CI headless Chromium, $getDOMTextNode could return
null, skipping the entire block and causing deleteCharacter to fail.
…Deletion

PR facebook#8766 replaced modify('extend') with $extendSelectionForDeletion in
deleteCharacter. The inline-grid boundary fix in modify() was no longer
reached by the delete path. Add the same unmergeable sibling resolution
directly in $extendSelectionForDeletion so native 'move' failures at
inline-grid/flex span boundaries are handled for character deletion.
Comment on lines +561 to +568
focusCaret.origin.getType() === 'text' &&
focusCaret.origin.isUnmergeable()
) {
const sibling = $getSiblingCaret(
focusCaret.origin,
focusCaret.direction,
).getNodeAtCaret();
if ($isTextNode(sibling) && sibling.getType() === 'text') {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getType() === 'text' is never correct because node replacement can change what node class and type is used for the editor, whatever this is trying to determine should be inferred from guards and methods. Not entirely clear from context what node types it's trying to exclude here by looking specifically for the text type.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry about that — should have used a guard instead of string comparison. Will keep node replacement in mind going forward. Fixed with !$isTabNode() in 956052b.

…de guard

Node replacement can change the type string for a given node class, so getType() === 'text' misses replaced TextNodes. Use !$isTabNode() to explicitly exclude the only TextNode subclass that should not trigger this path.
Comment on lines +565 to +568
const sibling = $getSiblingCaret(
focusCaret.origin,
focusCaret.direction,
).getNodeAtCaret();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A TextPointCaret behaves exactly like a sibling caret at the same origin

Suggested change
const sibling = $getSiblingCaret(
focusCaret.origin,
focusCaret.direction,
).getNodeAtCaret();
const sibling = focusCaret.getNodeAtCaret();
class SiblingCaretNext<T extends LexicalNode> extends AbstractSiblingCaret<
  T,
  'next'
> {
  readonly direction = 'next';
  getNodeAtCaret(): null | LexicalNode {
    return this.origin.getNextSibling();
  }
  insert(node: LexicalNode): this {
    this.origin.insertAfter(node);
    return this;
  }
}
class TextPointCaretNext<T extends TextNode> extends AbstractTextPointCaret<
  T,
  'next'
> {
  readonly direction = 'next';
  getNodeAtCaret(): null | LexicalNode {
    return this.origin.getNextSibling();
  }
  insert(node: LexicalNode): this {
    this.origin.insertAfter(node);
    return this;
  }
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. extended-tests Run extended e2e tests on a PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature: Proper Handling of Deletions Across Adjacent Unmergeable Text Spans in Grid Containers

2 participants