Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
62 commits
Select commit Hold shift + click to select a range
3abf9e6
Rich text: keep the editable child inert while the wrapper hosts editing
ellatrix Jul 31, 2026
eb37021
Rich text: stay inert while part of a multi-selection
ellatrix Aug 3, 2026
365771a
Rich text: restrict editable checks to known editable values
ellatrix Jul 31, 2026
95af3fe
Writing flow: treat elements editable through the editing host as edi…
ellatrix Jul 31, 2026
9d84774
Survive the flip to an inert editable child: focus, caret, and scroll
ellatrix Aug 1, 2026
a591f52
Flip the editable to inert in the DOM before the browser acts on the …
ellatrix Aug 3, 2026
f81a8c1
Ignore repeat mousedowns of a multi-click in the selection clearer
ellatrix Aug 3, 2026
c9d9701
Render the inert child without a contenteditable attribute
ellatrix Aug 3, 2026
f018e2f
Scope inherited-editable caret placement to rich text elements
ellatrix Aug 3, 2026
15d521d
Writing flow: navigate from a non-focusable editable by document order
ellatrix Aug 3, 2026
26dbf67
Sync the click's caret to the store when the editing host holds focus
ellatrix Aug 3, 2026
a4ff011
Block focus handler: the caret owns block selection, not the wrapper
ellatrix Aug 3, 2026
b14db0f
Scope the caret-ownership focus rule to engaged editing hosts
ellatrix Aug 3, 2026
389a92e
Collab sidebar: place the caret in the related block instead of focus…
ellatrix Aug 3, 2026
004cdcc
Widgets: interact with inert editables instead of focusing them
ellatrix Aug 3, 2026
7aee01f
Rely on the block node for caret placement under the editing host
ellatrix Aug 3, 2026
1ac51ed
Writing flow: build the paragraph selection for a triple click WebKit…
ellatrix Aug 3, 2026
bc0506b
Reclaim dropped focus onto the page body of an inline editor too
ellatrix Aug 3, 2026
5928b98
Keep focus out of deselected blocks when the editing host disengages
ellatrix Aug 3, 2026
9180092
Hand focus to the engaged host from any element containing the caret
ellatrix Aug 3, 2026
1b33710
Typewriter: decide eligibility from the caret
ellatrix Aug 4, 2026
3c0e298
Arrow nav: identify block text content by the rich text class alone
ellatrix Aug 4, 2026
ffd0770
Arrow nav: skip candidates that are not really tabbable
ellatrix Aug 4, 2026
2523ce0
Decide shift+arrow extension from block adjacency in document order
ellatrix Aug 4, 2026
fe10fae
Take focus for the engaged host through one condition
ellatrix Aug 4, 2026
9ade469
Complete the document order of the shift+arrow adjacency walk
ellatrix Aug 4, 2026
a069f38
Start the drag gesture from an editable inherited from the host
ellatrix Aug 4, 2026
814b09d
Rich text: stay inert within any multi-selection
ellatrix Aug 4, 2026
8890a1d
Expect focus to move once on Enter, straight to the editing host
ellatrix Sep 17, 2026
9a7892f
Drop the WebKit paragraph selection for a triple click on the host pa…
ellatrix Sep 17, 2026
6fde379
Let the selection clearer handle every mousedown again
ellatrix Sep 17, 2026
c3cc9eb
Remove a comment-only change
ellatrix Sep 17, 2026
25a7571
Let the RichText focus effect refocus the host after the field turns …
ellatrix Sep 17, 2026
d896c7f
Keep focus() working on the field under the editing host
ellatrix Sep 17, 2026
ac3ab1c
Revert "Let the RichText focus effect refocus the host after the fiel…
ellatrix Sep 17, 2026
28b266b
Move focus to the editing host when Firefox focuses an ancestor block
ellatrix Sep 17, 2026
5098cbf
Move the Firefox focus correction into a block props compatibility hook
ellatrix Sep 17, 2026
2270d01
Decide the Firefox focus correction from the caret, not the press
ellatrix Sep 17, 2026
3c0f6e1
Revert the Firefox focus compatibility hook
ellatrix Sep 17, 2026
5a96253
Typewriter: ask the store whether the caret is in the last block
ellatrix Sep 17, 2026
3d7020b
Find the field containing the selection through a set of RichText ele…
ellatrix Sep 17, 2026
54c24bb
Keep the selector constants together in the dom utilities
ellatrix Sep 17, 2026
28684f5
Resolve the editable element under the host as the block element
ellatrix Sep 17, 2026
8931613
Resolve the focus owner under the host as the block element in the e2…
ellatrix Sep 17, 2026
236979f
Simplify arrow navigation under the host and restore the caret at the…
ellatrix Sep 17, 2026
d8d7a7f
Leave the block selection alone only for a caret in the selected block
ellatrix Sep 17, 2026
544397b
Take the editable root hook back to trunk
ellatrix Sep 18, 2026
5092945
Assert block focus ownership directly instead of through a label
ellatrix Sep 24, 2026
60e1ebb
Remove the shift+arrow container edge test
ellatrix Sep 24, 2026
fa0c71a
Take the focus handover condition back to trunk
ellatrix Sep 24, 2026
414cd8c
Typewriter: find the last field in the DOM again
ellatrix Sep 24, 2026
1ff9786
Start the drag gesture only from a field
ellatrix Sep 24, 2026
c36a927
Drop the redundant wrapper check from the click flip
ellatrix Sep 24, 2026
3333ad8
Check the caret before the editable state in the focus handler
ellatrix Sep 24, 2026
3f6cc44
Collapse the selection at the field edge directly
ellatrix Sep 24, 2026
3c99738
Trim the rich text editing host comments
ellatrix Sep 24, 2026
fdca2da
Let the rich text hook sync the click's caret under the host
ellatrix Sep 26, 2026
f328000
Skip the focus capture flip for a field under the editing host
ellatrix Sep 28, 2026
4fad568
Describe the inherit reading as the property's, not an attribute value
ellatrix Sep 28, 2026
2bf24cc
Drop the disengage focus guard and tighten the observer comments
ellatrix Sep 28, 2026
d023d5a
Typewriter: drop the last editable rule
ellatrix Sep 28, 2026
ee7786f
Identify the hosted field by the selected block element
ellatrix Sep 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -85,26 +85,47 @@ export function useFocusFirstElement( { clientId, initialPosition } ) {
}
}
// Do not place a caret when the target already contains one:
// while a focused editing host contains the target (the block
// supports `editableRoot`), the caret can be inside it without the
// target holding focus. Only a caret the rich text synchronized to
// the store (offsets present) is deliberate; a leftover one yields
// to an explicitly requested edge position (initialPosition -1).
const { activeElement } = ownerDocument;
// while an editing host contains the target (the block supports
// `editableRoot`), the caret can be inside it without the target
// holding focus. Only a caret the rich text synchronized to the
// store (offsets present) is deliberate; a leftover one yields to
// an explicitly requested edge position (initialPosition -1).
const selection = ownerDocument.defaultView.getSelection();
const { clientId: selectionClientId, offset } = getSelectionStart();
const hasCaret =
activeElement?.isContentEditable &&
activeElement.contains( target ) &&
!! selection.anchorNode &&
target.contains( selection.anchorNode );
!! selection.anchorNode && target.contains( selection.anchorNode );
const isDeliberate =
initialPosition === 0 ||
( offset !== undefined && selectionClientId === clientId );

if ( ! ( hasCaret && isDeliberate ) ) {
placeCaretAtHorizontalEdge( target, isReverse );
if ( hasCaret && isDeliberate ) {
// The field applies the store selection when focused, so only
// move focus, unless an editing host containing the target has
// it.
const { activeElement } = ownerDocument;
const isHosted =
activeElement?.isContentEditable &&
activeElement.contains( target );
if ( ! isHosted ) {
target.focus();
}
return;
}

// Under the editing host the target is editable through the host
// and not a focus target of its own (no contenteditable attribute):
// place the caret at its edge directly. Focusing it then focuses
// the host (see RichText), which adopts the caret.
if ( target.isContentEditable && target.contentEditable !== 'true' ) {
selection.collapse(
target,
isReverse ? target.childNodes.length : 0
);
target.focus();
return;
}

placeCaretAtHorizontalEdge( target, isReverse );
}, [ initialPosition, clientId ] );

return ref;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {
useRefEffect,
privateApis as composePrivateApis,
} from '@wordpress/compose';
import { isInsideRootBlock } from '../../../utils/dom';
import { isInsideRootBlock, getBlockClientId } from '../../../utils/dom';
import { store as blockEditorStore } from '../../../store';
import { unlock } from '../../../lock-unlock';

Expand Down Expand Up @@ -74,6 +74,21 @@ export function useFocusHandler( clientId ) {
// builds the multi-selection from the anchor it recorded at
// mousedown, so this dispatch overwriting the store anchor
// is harmless.
// A click on the selected block's inert field can focus this
// wrapper instead (Firefox focuses the nearest focusable
// ancestor). Under an engaged editing host, a caret in the
// selected block within this one keeps that selection.
const { anchorNode } =
node.ownerDocument.defaultView.getSelection();
if (
anchorNode &&
node.contains( anchorNode ) &&
isBlockSelected( getBlockClientId( anchorNode ) ) &&
node.isContentEditable
) {
return;
}

if ( event.target.isContentEditable ) {
selectBlock( clientId, null );
return;
Expand Down
106 changes: 89 additions & 17 deletions packages/block-editor/src/components/rich-text/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
useContext,
} from '@wordpress/element';
import { useDispatch, useRegistry, useSelect } from '@wordpress/data';
import { useMergeRefs, useInstanceId } from '@wordpress/compose';
import { useMergeRefs, useInstanceId, useRefEffect } from '@wordpress/compose';
import { privateApis as richTextPrivateApis } from '@wordpress/rich-text';
import { Popover } from '@wordpress/components';
import { getBlockBindingsSource } from '@wordpress/blocks';
Expand Down Expand Up @@ -239,25 +239,33 @@ function RichTextWrapper(
const shouldDisableEditing =
readOnly || disableBoundBlock || shouldDisableForPattern;

// Whether the wrapper is the editing host, which depends on the selected
// block, not necessarily this one. Only the selected, default-mode block
// can be it, so others skip the subscription entirely.
// Whether the wrapper hosts editing for this block: the selected block
// can host it, and any block in a multi-selection is inside the host's
// range and must not be an editing area of its own there.
const isEditingHost = useSelect(
( select ) => {
if (
shouldDisableEditing ||
! hasDefaultEditingMode ||
! isBlockSelected
) {
if ( shouldDisableEditing || ! hasDefaultEditingMode ) {
return false;
}

const { getSelectedBlockClientId, canHostEditableRoot } = unlock(
select( blockEditorStore )
);
return canHostEditableRoot( getSelectedBlockClientId() );
const {
getSelectedBlockClientId,
canHostEditableRoot,
isBlockMultiSelected,
} = unlock( select( blockEditorStore ) );

if ( isBlockSelected ) {
return canHostEditableRoot( getSelectedBlockClientId() );
}

return isBlockMultiSelected( clientId );
},
[ shouldDisableEditing, hasDefaultEditingMode, isBlockSelected ]
[
shouldDisableEditing,
hasDefaultEditingMode,
isBlockSelected,
clientId,
]
);

const { getSelectionStart, getSelectionEnd, getBlockRootClientId } =
Expand Down Expand Up @@ -366,7 +374,7 @@ function RichTextWrapper(
// A pointer press outside the field makes it non editable until the
// release (see rich text's preventFocusCapture). Focusing it then
// makes the block focus handler drop the text selection.
if ( ! isSelected || element?.contentEditable !== 'true' ) {
if ( ! isSelected || element?.contentEditable === 'false' ) {
return;
}

Expand Down Expand Up @@ -462,6 +470,59 @@ function RichTextWrapper(
anchorRef.current?.focus();
}

// Under the editing host the element is not a focus target (no tabindex
// and no contenteditable attribute, see below), so `focus()` on it would
// do nothing. Keep it working: place the caret in the element and focus
// the host instead.
const focusUnderHostRef = useRefEffect(
( element ) => {
if ( ! isEditingHost ) {
return;
}

const { ownerDocument } = element;
const { focus: nativeFocus } = element;

element.focus = ( options ) => {
const host = element.parentElement?.closest(
'[contenteditable="true"]'
);

// The host disengaged in this commit, ahead of this
// override's removal: the element is a focus target again.
if ( ! host ) {
nativeFocus.call( element, options );
return;
}

const selection = ownerDocument.defaultView.getSelection();

if ( ! element.contains( selection.anchorNode ) ) {
selection.collapse( element, 0 );
}

if (
ownerDocument.activeElement !== host ||
! ownerDocument.hasFocus()
) {
const range = selection.getRangeAt( 0 ).cloneRange();
host.focus( { preventScroll: true, ...options } );
// Gecko moves the selection when an editing host takes
// focus instead of adopting the one within it.
if ( ! element.contains( selection.anchorNode ) ) {
selection.removeAllRanges();
selection.addRange( range );
}
}
};

return () => {
delete element.focus;
};
},
[ isEditingHost ]
);

// Setting tabIndex to 0 is unnecessary, the element is already focusable
// because it's contentEditable. This also fixes a Safari bug where it's
// not possible to Shift+Click multi select blocks when Shift Clicking
Expand All @@ -473,7 +534,11 @@ function RichTextWrapper(
// focusability.
let tabIndex = props.tabIndex;
if ( isEditingHost ) {
tabIndex = props.tabIndex ?? 0;
// The field must not be a focus target under the host: iOS focuses a
// focusable child on tap, thrashing focus with the host and canceling
// native selection gestures (double tap to select a word). Block
// props pass tabIndex 0, so remove it explicitly.
tabIndex = null;
} else if ( ! shouldDisableEditing && props.tabIndex === 0 ) {
tabIndex = null;
}
Expand Down Expand Up @@ -554,8 +619,15 @@ function RichTextWrapper(
} ),
anchorRef,
setAnchorElement,
focusUnderHostRef,
] ) }
contentEditable={ ! shouldDisableEditing }
contentEditable={
// Under the editing host the field is editable through the
// host, not an editing host of its own. The attribute must
// be absent, not "inherit": Gecko treats the invalid value
// as non-editable.
isEditingHost ? undefined : ! shouldDisableEditing
}
suppressContentEditableWarning
className={ clsx(
'block-editor-rich-text__editable',
Expand Down
64 changes: 13 additions & 51 deletions packages/block-editor/src/components/typewriter/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,16 +100,14 @@ export function useTypewriter() {
: ( caretRect.top - scrollContainerY ) /
( defaultView.innerHeight - scrollContainerY );

// If the scroll position is at the start, the active editable element
// is the last one, and the caret is positioned within the initial
// trigger percentage of the page, do not scroll the page.
// The typewriter effect should not kick in until an empty page has been
// filled with the initial trigger percentage or the user scrolls
// intentionally down.
// If the scroll position is at the start and the caret is
// positioned within the initial trigger percentage of the page, do
// not scroll the page. The typewriter effect should not kick in
// until an empty page has been filled with the initial trigger
// percentage or the user scrolls intentionally down.
if (
scrollY === 0 &&
relativeScrollPosition < initialTriggerPercentage &&
isLastEditableNode()
relativeScrollPosition < initialTriggerPercentage
) {
// Reset the caret position to maintain.
caretRect = currentCaretRect;
Expand Down Expand Up @@ -174,58 +172,22 @@ export function useTypewriter() {
}

/**
* Returns the editable element owning the selection: the active
* element, or, when a focused editing host contains the node
* (a selected block supports `editableRoot`), the editable
* element containing the selection.
* Checks if the current situation is eligible for scroll:
* - The component must contain the selection.
* - The selection must be within editable content.
*/
function getActiveEditableElement() {
const { activeElement } = ownerDocument;

if ( ! activeElement ) {
return null;
}

if (
! activeElement.isContentEditable ||
! activeElement.contains( node )
) {
return activeElement;
}

function isSelectionEligibleForScroll() {
const { anchorNode } = defaultView.getSelection();

if ( ! anchorNode ) {
return null;
if ( ! anchorNode || ! node.contains( anchorNode ) ) {
return false;
}

const element =
anchorNode.nodeType === anchorNode.ELEMENT_NODE
? anchorNode
: anchorNode.parentElement;
return element?.closest( '[contenteditable="true"]' ) ?? null;
}

/**
* Checks if the current situation is eligible for scroll:
* - The component must contain the selection.
* - The active element must be contenteditable.
*/
function isSelectionEligibleForScroll() {
const activeEditableElement = getActiveEditableElement();
return (
!! activeEditableElement &&
node.contains( activeEditableElement ) &&
activeEditableElement.isContentEditable
);
}

function isLastEditableNode() {
const editableNodes = node.querySelectorAll(
'[contenteditable="true"]'
);
const lastEditableNode = editableNodes[ editableNodes.length - 1 ];
return lastEditableNode === getActiveEditableElement();
return !! element?.isContentEditable;
}

// When the user scrolls or resizes, the scroll position should be
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,12 +100,24 @@ export function getClosestTabbable(
focusableNodes.reverse();
}

// Consider as candidates those focusables after the current target. It's
// assumed this can only be reached if the target is focusable (on its
// keydown event), so no need to verify it exists in the set.
focusableNodes = focusableNodes.slice(
focusableNodes.indexOf( target ) + 1
);
// Consider as candidates those focusables after the current target.
const targetIndex = focusableNodes.indexOf( target );

if ( targetIndex !== -1 ) {
focusableNodes = focusableNodes.slice( targetIndex + 1 );
} else {
// The target is not focusable (the selected block's editable under
// the editing host has no tabindex): take the focusables on the
// navigation side of it in document order, not its descendants.
focusableNodes = focusableNodes.filter( ( focusableNode ) => {
const position = target.compareDocumentPosition( focusableNode );
const mask = isReverse
? target.DOCUMENT_POSITION_PRECEDING
: target.DOCUMENT_POSITION_FOLLOWING;
// eslint-disable-next-line no-bitwise
return !! ( position & mask ) && ! target.contains( focusableNode );
} );
}

let targetRect;

Expand Down
Loading
Loading