Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
68 commits
Select commit Hold shift + click to select a range
d89fa18
Add editableRoot block support for paragraph
ellatrix Jun 10, 2026
4a5c9b3
Replace event redirect with selection-owned listeners
ellatrix Jun 11, 2026
1e13b9b
Always listen to selectionchange in rich text
ellatrix Jun 11, 2026
5726296
Fix writing flow interactions while the wrapper holds focus
ellatrix Jun 11, 2026
df34944
Assert selection ownership in block deletion tests
ellatrix Jun 11, 2026
a2a06dc
Mirror autocomplete ARIA state onto the editing host
ellatrix Jun 11, 2026
7f35372
Maintain typewriter scrolling while an editing host holds focus
ellatrix Jun 11, 2026
422f748
Place the caret at the requested edge after removing a block
ellatrix Jun 11, 2026
197fa7d
Fix type error and add components changelog entry
ellatrix Jun 11, 2026
d575896
Do not make the wrapper an editing host without contentEditable support
ellatrix Jun 11, 2026
6ec0720
Only convert focus to the wrapper for the selected block's editable
ellatrix Jun 11, 2026
81b1170
Assert selection ownership on bottom padding click insertion
ellatrix Jun 11, 2026
a9254e5
Restrict the editable root to canvases in their own document
ellatrix Jun 11, 2026
9313003
Revert "Restrict the editable root to canvases in their own document"
ellatrix Jun 11, 2026
b483428
Limit host mode tabIndex to blocks with default editing mode
ellatrix Jun 11, 2026
0882651
Assert selection ownership in widgets customizer tests
ellatrix Jun 11, 2026
a650875
Navigate to blocks inside an editable root with arrow keys
ellatrix Jun 11, 2026
46bb728
Resolve the focus owner in roving tabindex assertions
ellatrix Jun 11, 2026
dbd6118
Resolve the focus owner in keyboard navigation assertions
ellatrix Jun 11, 2026
91e09ea
Handle block Enter and Space hooks through the selection owner
ellatrix Jun 11, 2026
8a4b9e0
Assert selection ownership in the details list view test
ellatrix Jun 12, 2026
073e8fa
Assert selection ownership when editing text under the link popover
ellatrix Jun 12, 2026
52dae97
Sync cross-block selection to the store before clipboard handlers rea…
ellatrix Jun 12, 2026
c7e9508
Preserve the browser's caret placement when clicking an unselected block
ellatrix Jun 12, 2026
49a10e3
Adapt region navigation and keyboard navigation specs to selection ow…
ellatrix Jun 12, 2026
1f51487
Adapt block toolbar navigation spec to selection ownership
ellatrix Jun 12, 2026
f9a0db8
Sync drag selections over a temporarily non-editable rich text element
ellatrix Jun 12, 2026
e17670f
Render a caller-passed tabIndex on rich text unless the editable root…
ellatrix Jun 12, 2026
addf42a
Place a caret when a rich text element nested in an editing host is f…
ellatrix Jun 12, 2026
36ffe61
Keep shift+arrow selection within the block when there is no block to…
ellatrix Jun 12, 2026
1954171
Add an ownsSelection editor util for selection ownership assertions
ellatrix Jun 12, 2026
024714e
Restore the record selection when a nested rich text element is focused
ellatrix Jun 12, 2026
771c3cf
Don't re-enable the editable wrapper for a stale native selection
ellatrix Jun 15, 2026
f28e7ab
Assert selection ownership when a deleted note returns focus to its b…
ellatrix Jun 15, 2026
5938f8a
Select the heading by clicking before dragging it onto an empty parag…
ellatrix Jun 15, 2026
e80539c
Select the first paragraph by clicking before dragging it to the list…
ellatrix Jun 15, 2026
3ac670c
Select the overridable paragraph by clicking before renaming it
ellatrix Jun 15, 2026
d9e2bbf
Don't make the wrapper an editing host while a block is edited as HTML
ellatrix Jun 15, 2026
3d77a1c
Only move focus to the editable wrapper once it is an editing host
ellatrix Jun 15, 2026
8097640
Wait one animation frame before measuring the caret in typewriter tests
ellatrix Jun 15, 2026
9d066cd
Poll the maintained caret position in typewriter tests
ellatrix Jun 15, 2026
e3189d8
Keep the typewriter active regardless of the hasSelectedBlock subscri…
ellatrix Jun 16, 2026
9762cd5
Expose the editable root host as a labelled textbox
ellatrix Jun 30, 2026
7f0ca87
Type the getSelectionEditableElement parameters
ellatrix Jun 30, 2026
ad37dbe
Only become an editing host when the block has sibling blocks
ellatrix Jun 30, 2026
2a50382
Don't sync the selection from a rich text edited inside another host
ellatrix Jun 30, 2026
3c01529
Revert "Don't sync the selection from a rich text edited inside anoth…
ellatrix Jun 30, 2026
aaa05c1
Assert selection ownership in list view spotlight mode tests
ellatrix Jul 10, 2026
fb96c25
Move the components changelog entry back to the unreleased section
ellatrix Jul 11, 2026
5ab2ce3
Adopt the trunk shape of selection synchronization on consumed events
ellatrix Jul 13, 2026
b45b531
Keep the trunk shape of the singular selection dispatch
ellatrix Jul 13, 2026
6b3849c
Remove the beforeinput value pin left from the derived getValue
ellatrix Jul 13, 2026
7ae0e7c
Remove the consumption sync duplicated by the trunk merge
ellatrix Jul 13, 2026
f02bd24
Share the focus owner label resolution as an editor test util
ellatrix Jul 13, 2026
53fa266
Inline the focus owner label poll
ellatrix Jul 13, 2026
b9cd672
Keep typewriter key listeners on the writing flow node
ellatrix Jul 13, 2026
5825e50
Flatten the editable root effect
ellatrix Jul 13, 2026
69d7d6b
Make the editable root effect a plain setup and teardown
ellatrix Jul 13, 2026
81fb1a1
Read editable root selectors through the registry
ellatrix Jul 13, 2026
ffd943f
Consume the shift gesture flag when the focus handler bails
ellatrix Jul 13, 2026
27d4d79
Select editable focus targets without initial caret placement
ellatrix Jul 13, 2026
12b22c3
Name the initial caret placement guard conditions
ellatrix Jul 13, 2026
10e5554
Condition the caret placement instead of returning early
ellatrix Jul 13, 2026
912ffa0
Don't let ancestor or multi-selected focus steal block selection
ellatrix Jul 13, 2026
03dc034
Only host editable roots among fully editable siblings
ellatrix Jul 13, 2026
4d16e39
Keep the editing host's textbox semantics while it hosts
ellatrix Jul 14, 2026
38cf484
Assert parent selection when clicking a Details summary
ellatrix Jul 14, 2026
c0d2f9a
Remove the redundant shift+click selection anchor
ellatrix Jul 14, 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
13 changes: 13 additions & 0 deletions docs/reference-guides/block-api/block-supports.md
Original file line number Diff line number Diff line change
Expand Up @@ -682,6 +682,19 @@ attributes: {
}
```

## editableRoot

- Type: `boolean`
- Default value: `false`

When set to `true`, the block declares that it keeps working when an ancestor
element (the editor canvas) is the `contenteditable` editing host, with focus
and the native selection residing there instead of on the block's own editable
elements. The editor uses this to keep the canvas editable while such a block
is selected, enabling native partial selection across blocks. Blocks that rely
on `event.target` or DOM focus pointing at their own elements should not
declare this support.

## filter

- Type: `Object`
Expand Down
2 changes: 1 addition & 1 deletion docs/reference-guides/core-blocks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -616,7 +616,7 @@ Start with the basic building block of all narrative.

- **Name:** [core/paragraph](https://developer.wordpress.org/block-editor/reference-guides/core-blocks/core-blocks-text/core-block-paragraph/)
- **Category:** [text](https://developer.wordpress.org/block-editor/reference-guides/core-blocks/core-blocks-text/)
- **Supports:** __unstablePasteTextInline, align (full, wide), anchor, color (background, gradients, link, text), interactivity (clientNavigation), spacing (margin, padding), splitting, typography (fitText, fontSize, lineHeight, textAlign, textColumns, textIndent), ~~className~~
- **Supports:** __unstablePasteTextInline, align (full, wide), anchor, color (background, gradients, link, text), editableRoot, interactivity (clientNavigation), spacing (margin, padding), splitting, typography (fitText, fontSize, lineHeight, textAlign, textColumns, textIndent), ~~className~~
- **Attributes:** content, direction, dropCap, placeholder

## Pattern Placeholder
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,8 @@ import { unlock } from '../../../lock-unlock';
*/
export function useFocusFirstElement( { clientId, initialPosition } ) {
const ref = useRef();
const { isBlockSelected, isMultiSelecting, isZoomOut } = unlock(
useSelect( blockEditorStore )
);
const { isBlockSelected, isMultiSelecting, isZoomOut, getSelectionStart } =
unlock( useSelect( blockEditorStore ) );

useEffect( () => {
// Check if the block is still selected at the time this effect runs.
Expand Down Expand Up @@ -87,7 +86,27 @@ export function useFocusFirstElement( { clientId, initialPosition } ) {
return;
}
}
placeCaretAtHorizontalEdge( target, isReverse );
// 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;
const selection = ownerDocument.defaultView.getSelection();
const { clientId: selectionClientId, offset } = getSelectionStart();
const hasCaret =
activeElement?.isContentEditable &&
activeElement.contains( target ) &&
!! selection.anchorNode &&
target.contains( selection.anchorNode );
const isDeliberate =
initialPosition === 0 ||
( offset !== undefined && selectionClientId === clientId );

if ( ! ( hasCaret && isDeliberate ) ) {
placeCaretAtHorizontalEdge( target, isReverse );
}
}, [ initialPosition, clientId ] );

return ref;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
*/
import { isInsideRootBlock } from '../../../utils/dom';
import { store as blockEditorStore } from '../../../store';
import { isShiftClickInProgress } from '../../writing-flow/utils';
import { unlock } from '../../../lock-unlock';

const { subscribeDelegatedListener } = unlock( composePrivateApis );
Expand All @@ -22,7 +23,8 @@ const { subscribeDelegatedListener } = unlock( composePrivateApis );
* @param {string} clientId Block client ID.
*/
export function useFocusHandler( clientId ) {
const { isBlockSelected } = useSelect( blockEditorStore );
const { isBlockSelected, isBlockMultiSelected } =
useSelect( blockEditorStore );
const { selectBlock, selectionChange } = useDispatch( blockEditorStore );

return useRefEffect(
Expand All @@ -36,14 +38,6 @@ export function useFocusHandler( clientId ) {
* @param {FocusEvent} event Focus event.
*/
function onFocus( event ) {
// When the whole editor is editable, let writing flow handle
// selection.
if (
node.parentElement.closest( '[contenteditable="true"]' )
) {
return;
}

// Check synchronously because a non-selected block might be
// getting data through `useSelect` asynchronously.
if ( isBlockSelected( clientId ) ) {
Expand All @@ -54,12 +48,43 @@ export function useFocusHandler( clientId ) {
return;
}

// Never select on the focus fired by a shift+click: the
// browser can focus the common editable ancestor of the
// range (e.g. a group block), and any selection re-render
// mid gesture destroys the native selection being made. The
// selection observer builds the multi-selection on mouseup.
if ( isShiftClickInProgress() ) {
return;
}

// A block that is part of the current multi-selection must
// not collapse it. Focus can land on the clicked block after
// the multi-selection was built: the focus event of a
// shift+click is not ordered consistently against its
// mouseup across browsers.
if ( isBlockMultiSelected( clientId ) ) {
return;
}

// If an inner block is focussed, that block is responsible for
// setting the selected block.
if ( ! isInsideRootBlock( node, event.target ) ) {
return;
}

// For editable targets, select without initial caret
// placement: the caret is inside the target. Placement would
// move it, and would collapse a native selection in the
// making, e.g. a shift+click extending the selection across
// blocks while the wrapper is the editing host. The observer
// builds the multi-selection from the anchor it recorded at
// mousedown, so this dispatch overwriting the store anchor
// is harmless.
if ( event.target.isContentEditable ) {
selectBlock( clientId, null );
return;
}

selectBlock( clientId );
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
/**
* WordPress dependencies
*/
import { insert, isCollapsed } from '@wordpress/rich-text';
import {
insert,
isCollapsed,
privateApis as richTextPrivateApis,
} from '@wordpress/rich-text';
import { applyFilters } from '@wordpress/hooks';
import { privateApis as composePrivateApis } from '@wordpress/compose';

/**
* Internal dependencies
*/
import { store as blockEditorStore } from '../../../store';
import { unlock } from '../../../lock-unlock';

const { subscribeDelegatedListener } = unlock( composePrivateApis );
const { subscribeOwnedListener } = unlock( richTextPrivateApis );

/**
* When typing over a selection, the selection will we wrapped by a matching
Expand Down Expand Up @@ -84,10 +87,10 @@ export default ( props ) => ( element ) => {
// call few lines above has fully updated the data store state and rerendered
// all affected components.
window.queueMicrotask( () => {
event.target.dispatchEvent( newEvent );
element.dispatchEvent( newEvent );
} );
event.preventDefault();
}

return subscribeDelegatedListener( element, 'beforeinput', onInput, true );
return subscribeOwnedListener( element, 'beforeinput', onInput, true );
};
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,18 @@
* WordPress dependencies
*/
import { DELETE, BACKSPACE } from '@wordpress/keycodes';
import { isCollapsed, isEmpty } from '@wordpress/rich-text';
import { privateApis as composePrivateApis } from '@wordpress/compose';
import {
isCollapsed,
isEmpty,
privateApis as richTextPrivateApis,
} from '@wordpress/rich-text';

/**
* Internal dependencies
*/
import { unlock } from '../../../lock-unlock';

const { subscribeDelegatedListener } = unlock( composePrivateApis );
const { subscribeOwnedListener } = unlock( richTextPrivateApis );

export default ( props ) => ( element ) => {
function onKeyDown( event ) {
Expand Down Expand Up @@ -54,5 +57,5 @@ export default ( props ) => ( element ) => {
}
}

return subscribeDelegatedListener( element, 'keydown', onKeyDown );
return subscribeOwnedListener( element, 'keydown', onKeyDown );
};
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,19 @@
* WordPress dependencies
*/
import { ENTER } from '@wordpress/keycodes';
import { insert, remove } from '@wordpress/rich-text';
import {
insert,
remove,
privateApis as richTextPrivateApis,
} from '@wordpress/rich-text';
import { privateApis as composePrivateApis } from '@wordpress/compose';

/**
* Internal dependencies
*/
import { unlock } from '../../../lock-unlock';

const { subscribeOwnedListener, ownsSelection } = unlock( richTextPrivateApis );
const { subscribeDelegatedListener } = unlock( composePrivateApis );

export default ( props ) => ( element ) => {
Expand All @@ -31,8 +36,9 @@ export default ( props ) => ( element ) => {
}

// The event listener is attached to the window, so we need to check if
// the target is the element.
if ( event.target !== element ) {
// the target is the element, or whether the element owns the
// selection through a focused editing host.
if ( event.target !== element && ! ownsSelection( element ) ) {
return;
}

Expand Down Expand Up @@ -90,7 +96,7 @@ export default ( props ) => ( element ) => {
);
// Capture phase so this runs before ancestor (writing flow) bubble
// handlers, matching the timing of the previous raw element listener.
const unsubscribeKeyDownDeprecated = subscribeDelegatedListener(
const unsubscribeKeyDownDeprecated = subscribeOwnedListener(
element,
'keydown',
onKeyDownDeprecated,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
/**
* WordPress dependencies
*/
import { privateApis as composePrivateApis } from '@wordpress/compose';

import { privateApis as richTextPrivateApis } from '@wordpress/rich-text';

/**
* Internal dependencies
*/
import { unlock } from '../../../lock-unlock';

const { subscribeDelegatedListener } = unlock( composePrivateApis );
const { subscribeOwnedListener } = unlock( richTextPrivateApis );

export default ( props ) => ( element ) => {
const { inputEvents } = props.current;
Expand All @@ -18,5 +19,5 @@ export default ( props ) => ( element ) => {
}
}

return subscribeDelegatedListener( element, 'input', onInput );
return subscribeOwnedListener( element, 'input', onInput );
};
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
/**
* WordPress dependencies
*/
import { insert, toHTMLString } from '@wordpress/rich-text';
import {
insert,
toHTMLString,
privateApis as richTextPrivateApis,
} from '@wordpress/rich-text';
import { getBlockTransforms, findTransform } from '@wordpress/blocks';
import { privateApis as composePrivateApis } from '@wordpress/compose';

/**
* Internal dependencies
Expand All @@ -16,7 +19,7 @@ import {
} from '../../../utils/selection';
import { unlock } from '../../../lock-unlock';

const { subscribeDelegatedListener } = unlock( composePrivateApis );
const { subscribeOwnedListener } = unlock( richTextPrivateApis );

export function findSelection( blocks ) {
let i = blocks.length;
Expand Down Expand Up @@ -160,13 +163,13 @@ export default ( props ) => ( element ) => {

// Capture phase so these run before ancestor (writing flow) bubble
// handlers, matching the timing of the previous raw element listeners.
const unsubscribeInput = subscribeDelegatedListener(
const unsubscribeInput = subscribeOwnedListener(
element,
'input',
onInput,
true
);
const unsubscribeCompositionEnd = subscribeDelegatedListener(
const unsubscribeCompositionEnd = subscribeOwnedListener(
element,
'compositionend',
onInput,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
/**
* WordPress dependencies
*/
import { privateApis as composePrivateApis } from '@wordpress/compose';

import { privateApis as richTextPrivateApis } from '@wordpress/rich-text';

/**
* Internal dependencies
*/
import { store as blockEditorStore } from '../../../store';
import { unlock } from '../../../lock-unlock';

const { subscribeDelegatedListener } = unlock( composePrivateApis );
const { subscribeOwnedListener } = unlock( richTextPrivateApis );

/**
* When the browser is about to auto correct, add an undo level so the user can
Expand All @@ -29,5 +30,5 @@ export default ( props ) => ( element ) => {
.__unstableMarkLastChangeAsPersistent();
}

return subscribeDelegatedListener( element, 'beforeinput', onInput );
return subscribeOwnedListener( element, 'beforeinput', onInput );
};
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@
* WordPress dependencies
*/
import { pasteHandler } from '@wordpress/blocks';
import { isEmpty, insert, create } from '@wordpress/rich-text';
import {
isEmpty,
insert,
create,
privateApis as richTextPrivateApis,
} from '@wordpress/rich-text';
import { isURL } from '@wordpress/url';
import { privateApis as composePrivateApis } from '@wordpress/compose';

Expand All @@ -15,6 +20,7 @@ import { getPasteEventData } from '../../../utils/pasting';
import { unlock } from '../../../lock-unlock';

const { subscribeDelegatedListener } = unlock( composePrivateApis );
const { ownsSelection } = unlock( richTextPrivateApis );

/** @typedef {import('@wordpress/rich-text').RichTextValue} RichTextValue */

Expand All @@ -34,8 +40,14 @@ export default ( props ) => ( element ) => {
} = props.current;

// The event listener is attached to the window, so we need to check if
// the target is the element or inside the element.
if ( ! element.contains( event.target ) ) {
// the target is the element or inside the element. When the editable
// wrapper holds focus (the selected block supports `editableRoot`),
// the event targets the wrapper instead; the element then owns the
// paste when it contains the selection.
if (
! element.contains( event.target ) &&
! ownsSelection( element )
) {
return;
}

Expand Down
Loading
Loading