Skip to content

Rich text: make the child inert under the editing host to fix iOS double tap selection - #81042

Draft
ellatrix wants to merge 12 commits into
trunkfrom
fix/editable-root-ios-double-tap
Draft

Rich text: make the child inert under the editing host to fix iOS double tap selection#81042
ellatrix wants to merge 12 commits into
trunkfrom
fix/editable-root-ios-double-tap

Conversation

@ellatrix

@ellatrix ellatrix commented Jul 31, 2026

Copy link
Copy Markdown
Member

What?

Fixes native iOS selection gestures (double tap to select a word) in paragraphs while the editableRoot editing host (#79105) is active, without losing native cross-block selection.

Why?

With the editing host active, double tap to select a word rarely worked on iOS. The selected paragraph was still a focusable editing area of its own (nested contenteditable="true" plus a forced tabIndex), so iOS focused it on every tap while the host code immediately moved focus back to the wrapper (a nested editable cannot retain focus once an ancestor hosts editing). That focus flip in the middle of the gesture made iOS cancel the word selection before it formed.

How?

  1. Make the child inert under the host, on touch devices: while the wrapper hosts editing on a coarse pointer device, the selected block's rich text renders contenteditable="inherit" and no tabIndex, so it is editable through the host but never a focus target. Focus stays on the host for the whole gesture and iOS forms the word selection there. Fine pointer devices keep the focusable child: the desktop keyboard and selection flows are built around it (tabbable searches, focus restoration, the autocompleter, multi block selection), and unifying them behind the inert child is follow-up work.
  2. "Editable" no longer means the attribute is "true": ownsSelection, the rich text selection sync, getSelectionEditableElement, and the arrow navigation tab candidate filter treat only an explicit contenteditable="false" as non-editable. Without this, all owned editing listeners went silent for the inert child and writing flow prevented every keystroke. The checks compare against the known editable values because JSDOM does not implement the contentEditable getter.
  3. Keyboard flows reach the inert child: the focus-first-element search falls back to the [contenteditable="inherit"] marker when the tabbable search finds nothing, and placeCaretAtEdge places the range within an inherited editable first and then focuses its editing host, which adopts the selection (range before focus, so Safari does not asynchronously reveal a caret and scroll).

Testing Instructions

  1. On an iPhone or the iOS simulator, open a post with two paragraphs and select one.
  2. Double tap a word: it is selected reliably, with the selection painted on the word.
  3. Tap to place the caret, type, press Enter to split, Backspace to merge.
  4. Long press and drag the selection handles across both paragraphs: native cross-block selection still works.
  5. On desktop, everything behaves as trunk: the gating leaves fine pointer devices on the focusable child code path.

Use of AI Tools

Written with the help of Claude Code.

🤖 Generated with Claude Code

https://claude.ai/code/session_01XrG35JQKJBX1iNof5wtfjN

ellatrix and others added 4 commits July 31, 2026 15:00
While the writing flow wrapper is the editing host, render the selected
block's rich text as contentEditable="inherit" and without a tabIndex, so
it is editable through the host but not a focusable editing area of its
own. iOS focuses a focusable child on every tap, which fights the host
for focus mid-gesture and cancels native word selection (double tap).
With the child inert, focus stays on the host and native selection
gestures work.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XrG35JQKJBX1iNof5wtfjN
Temporary instrumentation for the iOS double tap investigation. Beacons
selection state of the canvas and top documents to localhost:8899 on
mousedown, selectionchange, and top document selection changes. Drop
this commit before merging.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XrG35JQKJBX1iNof5wtfjN
While the writing flow wrapper hosts editing, the selected block's rich
text is editable by inheritance (contenteditable="inherit") and no
longer sets contenteditable="true" on itself. Update the places that
equated being editable with that attribute value so they only treat an
explicit opt-out as non-editable: the ownsSelection ownership check and
the rich text selection sync (which gate all owned editing listeners,
so typing went dead without this), the getSelectionEditableElement
helper (whose miss made writing flow preventDefault every keystroke),
and the arrow navigation tab candidate filter.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XrG35JQKJBX1iNof5wtfjN
@github-actions github-actions Bot added [Package] Rich text /packages/rich-text [Package] Block editor /packages/block-editor labels Jul 31, 2026
@github-actions

github-actions Bot commented Jul 31, 2026

Copy link
Copy Markdown

Size Change: +630 B (+0.01%)

Total Size: 7.78 MB

📦 View Changed
Filename Size Change
build/scripts/block-editor/index.min.js 429 kB +511 B (+0.12%)
build/scripts/dom/index.min.js 5.17 kB +90 B (+1.77%)
build/scripts/rich-text/index.min.js 14.8 kB +29 B (+0.2%)

compressed-size-action

ellatrix and others added 2 commits July 31, 2026 15:32
JSDOM does not implement the contentEditable getter, so it returns
undefined. Comparing against an explicit opt-out let the selection sync
run for the first time under JSDOM, where the selection APIs produce a
degenerate record that rewrites the caret to the start. Compare against
the known editable values (true and inherit) instead, which keeps the
handler off in JSDOM and on for elements editable by inheritance from
the editing host.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XrG35JQKJBX1iNof5wtfjN
Keyboard flows that move focus to a block's text field (e.g. after Enter
splits a paragraph) resolve their target through the tabbable search and
placeCaretAtEdge, both of which assumed the target is focusable. Under
the editing host the block's editable is editable by inheritance
(contenteditable="inherit") and not a focusable area: the tabbable
search missed it, focus fell back to the block wrapper, and
placeCaretAtEdge bailed without placing a caret, so everything typed
after a split went nowhere.

Find inherited editables by their editable marker when the tabbable
search finds nothing, and in placeCaretAtEdge place the range within the
inherited editable first and then focus its editing host, which adopts
the selection. The order matters: focusing an editing host without a
selection makes Safari asynchronously reveal a caret, scrolling the
viewport.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XrG35JQKJBX1iNof5wtfjN
@github-actions github-actions Bot added the [Package] DOM /packages/dom label Jul 31, 2026
ellatrix and others added 6 commits July 31, 2026 16:09
closest() returns Element, which has no focus(); the type check in CI
rejected it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XrG35JQKJBX1iNof5wtfjN
The inert child invalidates focusability assumptions across the desktop
keyboard and selection flows (tabbable searches, focus restoration, the
autocompleter, multi block selection), which CI surfaced suite by suite.
Native selection gestures only need the stable focus on touch devices,
so gate the inert child to coarse pointer devices: fine pointer devices
keep the focusable child and the exact trunk behavior, while the
editable by inheritance handling stays in place for the touch path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XrG35JQKJBX1iNof5wtfjN
Selecting a block turns its editable into an inert part of the editing
host, and three mechanisms did not survive that transition.

Focus: when the editable held focus and turned inert, the browser
dropped focus, leaving the wrapper as the default activeElement without
actually focusing it. Reclaim focus for the host when the document still
has focus and the wrapper is the default activeElement without :focus
(the document focus check keeps focus with the top document, e.g. the
block toolbar).

Caret: when a mousedown placed the caret and selected the block in one
motion, the flip dropped the caret mid-click, leaving typing and Enter
without a text selection. Remember the pointer position on mousedown and
restore the caret from it when the host engages without a selection in
the selected block.

Scroll: placing the caret in an inert editable used a point-based lookup
that hit-tests to a neighbour for empty paragraphs, making
scrollIfNoRange scroll the viewport; build the range directly for the
horizontal edge instead, and focus the host with preventScroll. The
typewriter's editable resolution now accepts editable-by-inheritance
elements, so its initial-fill guard works again.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XrG35JQKJBX1iNof5wtfjN
The selection application effect used the had-update flag as its
dependency, and it resets the flag after running, which happens after
the render snapshot React compares dependencies against: two
consecutive selection updates render with an identical flag and the
second is never applied to the DOM. A triple click produces exactly
that pair (the caret placement, then the full paragraph selection),
leaving the DOM selection overshooting into the next block, which makes
writing flow ignore every keystroke. Depend on the selection props
themselves instead, and read the flag inside the effect.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XrG35JQKJBX1iNof5wtfjN
Three separate races between a click gesture and the re-renders it
triggers, all surfaced by the editable child turning inert under the
editing host.

The focus rescues in useEditableRoot and the selection observer now
require that an element of the selected block recently lost focus with
nowhere to go, the signature of the flip to inert dropping focus. The
wrapper being the default activeElement without :focus also describes a
canvas that was never focused (e.g. a click on its padding), and taking
focus there made the next mousedown of the gesture blur and deselect.

preventFocusCapture only restores the contenteditable value it stored
when the attribute is still the one it set: a render in between (e.g.
the block was deselected by the click) owns the attribute now, and
restoring the stored value left a deselected editable marked inert.

The block selection clearer ignores the second and third mousedown of a
double or triple click: they are part of a text selection gesture, not
a click away from the blocks, and the deselect re-render mutated the
selected block's editable under the pointer, making the browser abandon
the native selection expansion.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XrG35JQKJBX1iNof5wtfjN
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

[Package] Block editor /packages/block-editor [Package] DOM /packages/dom [Package] Rich text /packages/rich-text

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant