[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
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
…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.
3896406 to
42fa198
Compare
etrepum
reviewed
Jul 7, 2026
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') { |
Collaborator
There was a problem hiding this comment.
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.
Contributor
Author
There was a problem hiding this comment.
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.
etrepum
reviewed
Jul 9, 2026
Comment on lines
+565
to
+568
| const sibling = $getSiblingCaret( | ||
| focusCaret.origin, | ||
| focusCaret.direction, | ||
| ).getNodeAtCaret(); |
Collaborator
There was a problem hiding this comment.
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;
}
}
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
When adjacent unmergeable TextNodes live inside a
display: inline-grid(orinline-flex) container, the browser renders each node in its own<span>and treats each grid column as a separate "line." This breaksSelection.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:
RangeSelection.modify()now pre-normalizes the DOM selection into the adjacent sibling's Text node before calling nativeSelection.modify, so the browser can cross the<span>boundary. This only fires atcharactergranularity on unmergeable TextNodes.resolveSelectionPointOnBoundaryskips 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.$shouldOverrideDefaultCharacterSelectionreturnstrueat unmergeable TextNode boundaries adjacent to another plain TextNode, triggering Lexical's ownmodify()path instead of relying on native handling.Up/down arrows —
$tryInlineGridLineNavigationin@lexical/rich-textdetects when the cursor's parent block contains aninline-grid/inline-flexchild. It finds the sibling block, usescaretFromPointto 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 inIssue7301InlineGridDeletion.test.tsBefore [Alpha|Beta|Gamma] Afterin an inline-grid container + a second normal paragraph: