Skip to content

Rich text: fix iOS double tap selection, preventing focus capture under editable host - #81042

Merged
ellatrix merged 62 commits into
trunkfrom
fix/editable-root-ios-double-tap
Sep 28, 2026
Merged

ellatrix merged 62 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, on all devices.

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. Remove tabIndex and contentEditable attributes from rich text element when the editable host is active, so rich tech no longer incorrectly captures focus.
  2. A lot of other changes are downstream of this change. Anything that relies on detecting rich text elements with the contenteditable attribute must be adjusted by either checking data-wp-rich-text-attributke-key, or the block selector, since any block opting in to the editable host is also a single rich text element.

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 in Chrome, Firefox, and Safari: double and triple click selection, shift+click and shift+arrow multi-selection, drag selection into nested blocks, arrow navigation across blocks, the slash inserter, and typing after splits all behave as on trunk.

Use of AI Tools

Written with the help of Claude Code.

🤖 Generated with Claude Code

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: +636 B (+0.01%)

Total Size: 7.81 MB

📦 View Changed
Filename Size Change
build/scripts/block-editor/index.min.js 434 kB +435 B (+0.1%)
build/scripts/dom/index.min.js 5.23 kB +147 B (+2.89%)
build/scripts/editor/index.min.js 508 kB +11 B (0%)
build/scripts/rich-text/index.min.js 14.9 kB +43 B (+0.29%)

compressed-size-action

@github-actions github-actions Bot added [Package] DOM /packages/dom [Package] Editor /packages/editor [Package] E2E Tests /packages/e2e-tests labels Jul 31, 2026
@ellatrix
ellatrix force-pushed the fix/editable-root-ios-double-tap branch from 5aab74a to 271d0d7 Compare August 3, 2026 19:23
@github-actions github-actions Bot added [Package] E2E Tests /packages/e2e-tests [Package] Editor /packages/editor and removed [Package] Editor /packages/editor [Package] E2E Tests /packages/e2e-tests labels Aug 3, 2026
@ellatrix
ellatrix force-pushed the fix/editable-root-ios-double-tap branch 3 times, most recently from aa3c28a to 663224c Compare August 4, 2026 16:09
@ellatrix ellatrix closed this Sep 16, 2026
@ellatrix ellatrix reopened this Sep 17, 2026
@github-actions

github-actions Bot commented Sep 17, 2026 •

Copy link
Copy Markdown

🤖 PR meta 🤖

📦 Bundle size

Size Change: +368 B (0%)

Total Size: 8.23 MB

📦 View Changed
Filename Size Change
build/scripts/block-editor/index.min.js 506 kB +343 B (+0.07%)
build/scripts/rich-text/index.min.js 15.1 kB +25 B (+0.17%)

ee7786f Run

⚡ Performance

Show the results

Client side metrics exclude the server response time.

front-end-block-theme

Metric e0173e3 trunk % Change
timeToFirstByte 56.9 ms +9.84% -3.51% 56.3 ms +5.33% -3.82% 1.07%
largestContentfulPaint 96 ms +4.17% -8.33% 92 ms +6.52% -4.35% 4.35%
lcpMinusTtfb 34.55 ms +21.13% -6.51% 34.05 ms +22.91% -5.87% 1.47%
wpBeforeTemplate 28.3 ms +14.2% -2.33% 27.78 ms +9.14% -1.12% 1.87%
wpTemplate 24.54 ms +1.83% -3.91% 24.21 ms +3.1% -5.29% 1.36%
wpTotal 53.45 ms +9.41% -3.91% 52.5 ms +5.3% -3.96% 1.81%
wpMemoryUsage 7.61 MB +0% -0% 7.55 MB +0% -0% 0.75%
wpDbQueries 17 +0% -0% 17 +0% -0% 0%

front-end-classic-theme

Metric e0173e3 trunk % Change
timeToFirstByte 46.65 ms +13.08% -1.39% 46.3 ms +7.78% -0.86% 0.76%
largestContentfulPaint 102 ms +5.88% -1.96% 100 ms +4% -0% 2%
lcpMinusTtfb 54.4 ms +3.03% -1.47% 54.05 ms +2.31% -3.79% 0.65%
wpBeforeTemplate 26.05 ms +7.68% -2.03% 25.71 ms +3.54% -1.24% 1.32%
wpTemplate 17.75 ms +6.08% -1.8% 17.65 ms +4.93% -1.19% 0.57%
wpTotal 43.67 ms +13.19% -1.49% 43.32 ms +7.23% -1.11% 0.81%
wpMemoryUsage 6.22 MB +0% -0% 6.17 MB +0% -0% 0.86%
wpDbQueries 14 +0% -0% 14 +0% -0% 0%

media-processing

Metric e0173e3 trunk % Change
mediaProcessingJpeg 398.92 ms +1.07% -0.67% 397.12 ms +1% -1% 0.45%
mediaProcessingAvif 6068.9 ms +0.05% -0.29% 6068.48 ms +0.24% -0.16% 0.01%
mediaProcessingJpegToAvif 4181.74 ms +0.29% -0.35% 4196.53 ms +0.11% -0.14% -0.35%

media-upload

Metric e0173e3 trunk % Change
jpegUploadProcessing 1443.43 ms +33.56% -2.81% 1412.84 ms +0.41% -0.52% 2.17%
pngUploadProcessing 177.48 ms +16.51% -6.65% 173.39 ms +24.82% -1.54% 2.36%
largeJpegUploadProcessing 1397.31 ms +1.04% -0.62% 1412.23 ms +0.49% -0.67% -1.06%
multipleImageUploadProcessing 1550.58 ms +9.57% -0.35% 1555.73 ms +0.28% -0.98% -0.33%

post-editor

Metric e0173e3 trunk % Change
serverResponse 267.35 ms +5.59% -6.69% 273.55 ms +5.74% -4.31% -2.27%
firstPaint 150.08 ms +11.48% -14.26% 144.45 ms +14.86% -9.3% 3.9%
domContentLoaded 729.3 ms +1.41% -6.8% 703.8 ms +0.92% -2.19% 3.62%
loaded 730.43 ms +1.39% -6.76% 705 ms +0.91% -2.15% 3.61%
firstContentfulPaint 325.32 ms +5.38% -9.9% 311.62 ms +4.42% -6.32% 4.4%
firstBlock 2058.31 ms +1.73% -1.27% 1997.75 ms +2.53% -1.4% 3.03%
type 14.17 ms +5.36% -6.28% 17.26 ms +2.67% -4.35% -17.9%
typeWithoutInspector 15.32 ms +10.51% -6.27% 16.38 ms +3.91% -1.65% -6.47%
typeWithTopToolbar 16.54 ms +5.68% -2.3% 23.48 ms +3.92% -6.09% -29.56%
typeContainer 0.9 ms +20% -30% 7.35 ms +10.07% -3.54% -87.76%
focus 9.68 ms +12.19% -5.17% 61.79 ms +12.48% -13.27% -84.33%
firstFocus 23.6 ms +0% -0% 153.36 ms +0% -0% -84.61%
selectAll 858.76 ms +11.22% -0.58% 343.88 ms +9.39% -5.14% 149.73%
listViewOpen 38.12 ms +16.42% -8.53% 47.75 ms +7.83% -10.09% -20.17%
inserterOpen 17.5 ms +14.97% -4.11% 17.06 ms +10.9% -5.92% 2.58%
inserterHover 1.9 ms +13.68% -9.47% 3.05 ms +6.56% -6.23% -37.7%
inserterSearch 6.33 ms +3.48% -3.63% 6.67 ms +7.5% -3.45% -5.1%
loadPatterns 458.83 ms +3.31% -7.73% 477.42 ms +6.69% -2.87% -3.89%
wpTotal 259.7 ms +5.94% -6.44% 266.85 ms +5.31% -4.47% -2.68%
wpMemoryUsage 13.14 MB +0% -0% 13.09 MB +0% -0% 0.42%
wpDbQueries 54 +0% -0% 54 +0% -0% 0%

site-editor

Metric e0173e3 trunk % Change
serverResponse 245.41 ms +1.75% -5.07% 245.11 ms +2.23% -7.97% 0.12%
firstPaint 166.33 ms +13.63% -8.43% 166.8 ms +70.03% -10.57% -0.28%
domContentLoaded 702.9 ms +1.02% -3.37% 693.57 ms +7.11% -2.4% 1.35%
loaded 703.88 ms +1.01% -3.38% 694.49 ms +7.12% -2.42% 1.35%
firstContentfulPaint 302.11 ms +4.83% -2.96% 297.4 ms +8.48% -4.36% 1.58%
firstBlock 2456.1 ms +0.41% -1.99% 2538.01 ms +1.83% -6.35% -3.23%
type 12.63 ms +3.72% -2.93% 14.38 ms +10.36% -3.41% -12.17%
navigate 75.48 ms +11.61% -5.9% 106.34 ms +4.44% -6.7% -29.02%
loadPatterns 851.32 ms +3.47% -4.73% 919.5 ms +8.03% -4.44% -7.41%
loadPages 1058.13 ms +1.29% -8.44% 973.19 ms +11.98% -1.19% 8.73%
wpTotal 239.02 ms +1.74% -5.34% 238.34 ms +2.4% -8.03% 0.29%
wpMemoryUsage 12.11 MB +0% -0% 12.05 MB +0% -0% 0.45%
wpDbQueries 44 +0% -2.27% 44 +0% -2.27% 0%

ee7786f Run

@ellatrix
ellatrix force-pushed the fix/editable-root-ios-double-tap branch from 522575a to 2c915a1 Compare September 17, 2026 16:18
@github-actions github-actions Bot added [Tool] E2E Test Utils /packages/e2e-test-utils-playwright and removed [Package] E2E Tests /packages/e2e-tests labels Sep 17, 2026
@ellatrix
ellatrix force-pushed the fix/editable-root-ios-double-tap branch from 2c915a1 to cf7f0e4 Compare September 17, 2026 16:42
@github-actions github-actions Bot removed [Package] DOM /packages/dom [Package] Editor /packages/editor labels Sep 17, 2026
ellatrix and others added 4 commits September 22, 2026 15:33
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
The child's inertness was tied to its block being the single selected
block. When a native selection drag grew into a multi-selection, the
block stopped being singly selected and the child reverted to an
editing area of its own, rewriting the element under the pointer mid
gesture, which breaks the in-progress drag, and reintroducing a nested
editable inside the engaged editing host. Keep the child inert while
its block is part of the multi-selection.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XrG35JQKJBX1iNof5wtfjN
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
…table

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 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
@ellatrix
ellatrix force-pushed the fix/editable-root-ios-double-tap branch from ddaaa9e to 544397b Compare September 22, 2026 22:33
While the editing host holds focus, the focus owner helper reported the
label of the block around the caret, and finding that block relied on
the field carrying a contenteditable attribute, which the selected
block's field no longer does. The two specs asserting block labels now
say what they mean: the selected block is the labelled one, and it owns
focus, either focused itself or contained with the selection by the
focused host. The helper goes back to trunk.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XrG35JQKJBX1iNof5wtfjN
@github-actions github-actions Bot removed the [Tool] E2E Test Utils /packages/e2e-test-utils-playwright label Sep 24, 2026
ellatrix and others added 14 commits September 23, 2026 20:54
It pinned a store walk for shift+arrow extension that is no longer in
the branch. The behaviour it covers is trunk's.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XrG35JQKJBX1iNof5wtfjN
Handing focus to the engaged host from any focused element containing
the caret was added for Firefox, which focuses the nearest focusable
ancestor when a click lands on the inert field. The focus handler now
keeps the block selection for that caret, and the sentinel specs pass
on Firefox with the trunk condition, so the widening goes. The reclaim
of focus dropped onto the default target stays: block deletion, paste
and the widgets screen lose focus without it.

The click's synced offset defaults to 0 like the single block sync
below it, so the store never holds a field identity without an offset.
Shorter comments.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XrG35JQKJBX1iNof5wtfjN
Asking the store whether the selected block is inside the last root
block widened the check to every field in a closing container. Keep
trunk's DOM check and let the selected block's field, editable through
the host without a contenteditable attribute, count as a field too.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XrG35JQKJBX1iNof5wtfjN
Any element editable through the host passed the check, so the pointer
leaving a formatted word inside a field started a block selection and
engaged the wrapper mid-drag. A field is one with its own contenteditable
attribute, or the selected block's, editable through the host.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XrG35JQKJBX1iNof5wtfjN
The wrapper never carries a block client ID, so the client ID comparison
already excludes it. Plainer comment.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XrG35JQKJBX1iNof5wtfjN
Reading isContentEditable forces a style recalculation on every block
focus, so it goes last. Shorter comment.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XrG35JQKJBX1iNof5wtfjN
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XrG35JQKJBX1iNof5wtfjN
The tabIndex comment referred to a contentEditable="inherit" attribute
that is no longer rendered.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XrG35JQKJBX1iNof5wtfjN
The observer synced the field identity and caret to the store after a
click in the selected block's inert field, because the field gets no
focus event with the host focused. Trunk's rich text hook now syncs the
selection itself while a focused editing host owns it, on selection
change and on capture of the events that consume it, so the copy goes.

That leaves one case: Firefox focuses the nearest focusable ancestor on
a click in the inert field, so an inner group wrapper holds focus, no
editing host owns the selection, and the hook stays silent. The
nested split spec fails there three of three times on Firefox. Handing
focus to the host from any focused element containing the caret, not
only the selected block's own editable, covers it: the hook then syncs
as on Chromium.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XrG35JQKJBX1iNof5wtfjN
The listener stops a field from capturing focus on a press beside it by
switching it off for the duration of the press. Under the editing host
the selected block's field has no contenteditable attribute and is not
a focus target, so there is nothing to prevent. Trunk read the absent
attribute as nothing to restore and left the field switched off after
the press: rich text "should only mutate text data on input" fails.
Only a field that is an editing host of its own takes the flip, which
puts the rest of the listener back to trunk.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XrG35JQKJBX1iNof5wtfjN
The field under the editing host has no contenteditable attribute; the
contentEditable property reads "inherit" for it. The comments named
an attribute value that is no longer rendered.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XrG35JQKJBX1iNof5wtfjN
Focusing the editable at the collapsed selection when the host
disengages was limited to the selected block's editable, so a stale
caret could not focus a deselected block back after select all promoted
the selection to the parent. Repeated select all inside a column now
promotes and stays promoted on Chromium, Firefox and WebKit with
trunk's plain focus call, so the guard goes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XrG35JQKJBX1iNof5wtfjN
On an unscrolled page the effect stays off while the caret is within
the initial trigger percentage, whichever block the caret is in. The
rule was limited to the last editable element, which under the editing
host has no contenteditable attribute and could only be found through a
field marker. Eligibility reads the selection rather than the active
element, which is the host there.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XrG35JQKJBX1iNof5wtfjN
The drag gesture started from an element carrying the rich text
attribute key. Under the editing host the selected block's element is
the field, so compare the block element's client ID with the selected
one instead.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XrG35JQKJBX1iNof5wtfjN
@ellatrix ellatrix added the [Type] Bug An existing feature does not function as intended label Sep 28, 2026
@ellatrix
ellatrix marked this pull request as ready for review September 28, 2026 05:16
@github-actions

Copy link
Copy Markdown

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the props-bot label.

If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message.

Co-authored-by: ellatrix <ellatrix@git.wordpress.org>

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

@ellatrix
ellatrix merged commit 456a7d9 into trunk Sep 28, 2026
83 checks passed
@ellatrix
ellatrix deleted the fix/editable-root-ios-double-tap branch September 28, 2026 05:24
@ellatrix ellatrix changed the title Rich text: make the child inert under the editing host to fix iOS double tap selection Rich text: fix iOS double tap selection, preventing focus capture under editable host Sep 28, 2026
@github-actions github-actions Bot added this to the Gutenberg 24.2 milestone Sep 28, 2026
@Mamaduka

Copy link
Copy Markdown
Member

Just noticed this. Nice work!

Screenshot

CleanShot 2026-09-29 at 08 07 17

@ellatrix

Copy link
Copy Markdown
Member Author

@Mamaduka Hmm, probably work just moved to the mousedown event callback. We should probably include that in the sum of the metric.

@ellatrix

Copy link
Copy Markdown
Member Author

Btw, I'm reverting this in #82816, so it will go up again.

@t-hamano

Copy link
Copy Markdown
Contributor

I noticed that this PR changes the mouse cursor over selected blocks from text to default, which seems to be unexpected behavior. This bug should be fixed once #82816 is merged, but at the very least, we should fix this cursor bug in the 7.2 release.

rich-text-cursor.mp4

widoz pushed a commit to widoz/gutenberg that referenced this pull request Oct 2, 2026
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] Rich text /packages/rich-text [Type] Bug An existing feature does not function as intended

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants