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
Draft
Rich text: make the child inert under the editing host to fix iOS double tap selection#81042ellatrix wants to merge 12 commits into
ellatrix wants to merge 12 commits into
Conversation
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
… server" This reverts commit f0c7f3c.
|
Size Change: +630 B (+0.01%) Total Size: 7.78 MB 📦 View Changed
|
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
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
…n touch" This reverts commit 3f37f50.
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
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.
What?
Fixes native iOS selection gestures (double tap to select a word) in paragraphs while the
editableRootediting 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 forcedtabIndex), 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?
contenteditable="inherit"and notabIndex, 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."true":ownsSelection, the rich text selection sync,getSelectionEditableElement, and the arrow navigation tab candidate filter treat only an explicitcontenteditable="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 thecontentEditablegetter.[contenteditable="inherit"]marker when the tabbable search finds nothing, andplaceCaretAtEdgeplaces 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
Use of AI Tools
Written with the help of Claude Code.
🤖 Generated with Claude Code
https://claude.ai/code/session_01XrG35JQKJBX1iNof5wtfjN