Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -58,10 +58,22 @@ export function useFocusFirstElement( { clientId, initialPosition } ) {
}

// Find all tabbables within node.
const textInputs = focus.tabbable
let textInputs = focus.tabbable
.find( ref.current )
.filter( ( node ) => isTextField( node ) );

// While the writing flow wrapper hosts editing, the block's editable
// elements are editable by inheritance (contenteditable="inherit")
// and not focusable areas of their own, so the tabbable search cannot
// find them. Find them by their editable marker instead; the caret
// placement below places the selection within the element and
// focuses the editing host.
if ( ! textInputs.length ) {
textInputs = Array.from(
ref.current.querySelectorAll( '[contenteditable="inherit"]' )
).filter( ( node ) => node.isContentEditable );
}

// If reversed (e.g. merge via backspace), use the last in the set of
// tabbables.
const isReverse = -1 === initialPosition;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,17 @@ export function useBlockSelectionClearer() {
return;
}

// The second and third mousedown of a double or triple click
// are part of a text selection gesture (e.g. a triple click
// on the canvas padding selects the paragraph next to it),
// not a click away from the blocks. Clearing the selection
// mid-gesture also re-renders the selected block's editable,
// and the mutation makes the browser abandon the native
// selection expansion.
if ( event.detail > 1 ) {
return;
}

clearSelectedBlock();
}

Expand Down
34 changes: 31 additions & 3 deletions packages/block-editor/src/components/rich-text/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
useMemo,
forwardRef,
useContext,
useSyncExternalStore,
} from '@wordpress/element';
import { useDispatch, useRegistry, useSelect } from '@wordpress/data';
import { useMergeRefs, useInstanceId } from '@wordpress/compose';
Expand Down Expand Up @@ -44,6 +45,10 @@ import { Content, valueToHTMLString } from './content';
import { withDeprecations } from './with-deprecations';
import BlockContext from '../block-context';
import { unlock } from '../../lock-unlock';
import {
subscribePointerGesture,
isPointerGestureActive,
} from './pointer-gesture';

// `RichTextShortcut` and `RichTextInputEvent` now live in
// `@wordpress/rich-text` so they share the shortcut and input-event contexts
Expand Down Expand Up @@ -443,9 +448,27 @@ export function RichTextWrapper(
// supports `editableRoot`), nested editable elements are no longer
// focusable areas on their own, so an explicit tabIndex restores their
// focusability.
// While a pointer gesture is active, keep the editable as it is: turning
// it into an inert part of the editing host mutates the DOM under the
// pointer and browsers abandon native selection gestures (a selection
// drag, the expansion of a double or triple click). The flip happens on
// pointer up; keyboard driven selection changes flip immediately.
const pointerGestureActive = useSyncExternalStore(
subscribePointerGesture,
isPointerGestureActive
);
const isInertEditingHostChild = isEditingHost && ! pointerGestureActive;

let tabIndex = props.tabIndex;
if ( isEditingHost ) {
tabIndex = props.tabIndex ?? 0;
if ( isInertEditingHostChild ) {
// Do NOT make the child a focusable editing area 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).
// Focus must stay on the host, which owns editing for the whole canvas;
// the child is editable by inheritance (contentEditable="inherit"
// below), not on its own. Block props pass tabIndex 0, so it must be
// explicitly removed here.
tabIndex = null;
} else if ( ! shouldDisableEditing && props.tabIndex === 0 ) {
tabIndex = null;
}
Expand Down Expand Up @@ -526,7 +549,12 @@ export function RichTextWrapper(
anchorRef,
setAnchorElement,
] ) }
contentEditable={ ! shouldDisableEditing }
contentEditable={
// Under the editing host the child is editable by
// inheritance, not a nested editing host of its own, so iOS
// keeps focus on the host and native word selection works.
isInertEditingHostChild ? 'inherit' : ! shouldDisableEditing
}
suppressContentEditableWarning
className={ clsx(
'block-editor-rich-text__editable',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/**
* Tracks whether a pointer gesture is in progress within an editor canvas.
*
* Changing an editable element's `contenteditable` while a pointer gesture is
* active mutates the DOM under the pointer, and browsers abandon native
* selection gestures (a drag extending a selection, the expansion of a double
* or triple click) when that happens. The rich text renders the selected
* block's editable as an inert part of the editing host only once the pointer
* is up; keyboard driven selection changes flip immediately.
*/
const listeners = new Set();
let pointerIsDown = false;

export function subscribePointerGesture( callback ) {
listeners.add( callback );
return () => listeners.delete( callback );
}

export function isPointerGestureActive() {
return pointerIsDown;
}

export function setPointerGestureActive( value ) {
if ( pointerIsDown === value ) {
return;
}
pointerIsDown = value;
listeners.forEach( ( callback ) => callback() );
}
12 changes: 10 additions & 2 deletions packages/block-editor/src/components/typewriter/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,15 @@ export function useTypewriter() {
anchorNode.nodeType === anchorNode.ELEMENT_NODE
? anchorNode
: anchorNode.parentElement;
return element?.closest( '[contenteditable="true"]' ) ?? null;
// The editable element within the block may be editable by
// inheritance from the editing host (contenteditable="inherit"),
// so accept any explicit editable marker except an opt-out. The
// host itself (outside `node`) must not be the result.
return (
element?.closest(
'[contenteditable]:not([contenteditable="false"])'
) ?? null
);
}

/**
Expand All @@ -225,7 +233,7 @@ export function useTypewriter() {

function isLastEditableNode() {
const editableNodes = node.querySelectorAll(
'[contenteditable="true"]'
'[contenteditable]:not([contenteditable="false"])'
);
const lastEditableNode = editableNodes[ editableNodes.length - 1 ];
return lastEditableNode === getActiveEditableElement();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,14 @@ export function getClosestTabbable(
if (
node.isContentEditable &&
node.contentEditable !== 'true' &&
getBlockClientId( node.closest( '[contenteditable="true"]' ) )
// The editable element within the block may be editable by
// inheritance under an editing host (`contenteditable="inherit"`),
// so accept any explicit editable marker except an opt-out.
getBlockClientId(
node.closest(
'[contenteditable]:not([contenteditable="false"])'
)
)
) {
return false;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { useRefEffect } from '@wordpress/compose';
* Internal dependencies
*/
import { store as blockEditorStore } from '../../store';
import { setContentEditableWrapper } from './utils';
import { setContentEditableWrapper, setLastClickPoint } from './utils';
import { getBlockClientId } from '../../utils/dom';

export default function useClickSelection() {
Expand All @@ -22,6 +22,12 @@ export default function useClickSelection() {
return useRefEffect(
( node ) => {
function onMouseDown( event ) {
// Remember the pointer position: when selecting the block
// turns its editable into an inert part of the editing host
// mid-click, the browser drops the caret this mousedown
// placed, and `useEditableRoot` restores it from this point.
setLastClickPoint( node, event );

// The main button.
// https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/button
if ( ! isSelectionEnabled() || event.button !== 0 ) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,12 @@ import { useRefEffect } from '@wordpress/compose';
* Internal dependencies
*/
import { store as blockEditorStore } from '../../store';
import { setContentEditableWrapper } from './utils';
import {
setContentEditableWrapper,
getRecentClickPoint,
getRecentFocusLoss,
caretRangeFromPoint,
} from './utils';
import { getBlockClientId, getSelectionEditableElement } from '../../utils/dom';
import { unlock } from '../../lock-unlock';

Expand Down Expand Up @@ -70,6 +75,62 @@ export default function useEditableRoot() {
activeElement.contains( selection.anchorNode )
) {
node.focus();
} else if (
activeElement === node &&
node.ownerDocument.hasFocus() &&
! node.matches( ':focus' ) &&
// Only when an element of the selected block recently lost
// focus with nowhere to go (dropped by the flip to inert):
// the same activeElement state also describes a canvas that
// was never focused, e.g. after a click on its padding.
getBlockClientId( getRecentFocusLoss( node ) ) ===
getSelectedBlockClientId()
) {
// Focus must genuinely be within the document: the wrapper is
// also the default activeElement while the user works in the
// top document (e.g. the block toolbar), and stealing focus
// from there is never right.
// Becoming the editing host turns the selected block's
// editable into an inert part of the host
// (contenteditable="inherit"): if it held focus, the browser
// dropped focus, leaving the wrapper as the default
// activeElement without actually focusing it.
const selectedClientId = getSelectedBlockClientId();

if (
selection.anchorNode &&
node.contains( selection.anchorNode ) &&
getBlockClientId( selection.anchorNode ) ===
selectedClientId
) {
// The caret survived: reclaim focus for the host so it
// keeps an editing context.
node.focus( { preventScroll: true } );
} else {
// A mousedown placed the caret in the block's editable
// and selected the block, and the flip to inert dropped
// the caret mid-click. Restore it from the pointer
// position, then focus the host, which adopts it.
const point = getRecentClickPoint( node );
const range =
point &&
caretRangeFromPoint(
node.ownerDocument,
point.x,
point.y
);

if (
range &&
node.contains( range.startContainer ) &&
getBlockClientId( range.startContainer ) ===
selectedClientId
) {
selection.removeAllRanges();
selection.addRange( range );
node.focus( { preventScroll: true } );
}
}
}

return () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,12 @@ import { isSelectionForward } from '@wordpress/dom';
*/
import { store as blockEditorStore } from '../../store';
import { getBlockClientId } from '../../utils/dom';
import { setContentEditableWrapper } from './utils';
import { setPointerGestureActive } from '../rich-text/pointer-gesture';
import {
setContentEditableWrapper,
setLastFocusLoss,
getRecentFocusLoss,
} from './utils';
import { unlock } from '../../lock-unlock';

const { ownsSelection } = unlock( richTextPrivateApis );
Expand Down Expand Up @@ -136,6 +141,18 @@ export default function useSelectionObserver() {
}
}

function onPointerDown() {
setPointerGestureActive( true );
}

function onPointerUp() {
setPointerGestureActive( false );
}

function onFocusOut( event ) {
setLastFocusLoss( node, event );
}

function onKeyDown() {
isTripleClick = false;
}
Expand Down Expand Up @@ -204,6 +221,21 @@ export default function useSelectionObserver() {
collapsedClientId
) {
node.focus();
} else if (
// The selected block's editable is an inert part
// of the host (contenteditable="inherit"): when it
// held focus and turned inert, the browser dropped
// focus, leaving the wrapper as the default
// activeElement without actually focusing it. The
// collapsed selection is in the selected block
// here, so reclaim focus for the host.
activeElement === node &&
ownerDocument.hasFocus() &&
! node.matches( ':focus' ) &&
getBlockClientId( getRecentFocusLoss( node ) ) ===
collapsedClientId
) {
node.focus( { preventScroll: true } );
}
return;
}
Expand Down Expand Up @@ -467,6 +499,10 @@ export default function useSelectionObserver() {
defaultView.addEventListener( 'mouseup', onMouseUp );
node.addEventListener( 'mousedown', onMouseDown );
node.addEventListener( 'keydown', onKeyDown );
node.addEventListener( 'focusout', onFocusOut );
node.addEventListener( 'pointerdown', onPointerDown );
defaultView.addEventListener( 'pointerup', onPointerUp );
defaultView.addEventListener( 'pointercancel', onPointerUp );
ownerDocument.addEventListener(
'copy',
ensureMultiBlockSelectionSync,
Expand All @@ -490,6 +526,10 @@ export default function useSelectionObserver() {
defaultView.removeEventListener( 'mouseup', onMouseUp );
node.removeEventListener( 'mousedown', onMouseDown );
node.removeEventListener( 'keydown', onKeyDown );
node.removeEventListener( 'focusout', onFocusOut );
node.removeEventListener( 'pointerdown', onPointerDown );
defaultView.removeEventListener( 'pointerup', onPointerUp );
defaultView.removeEventListener( 'pointercancel', onPointerUp );
ownerDocument.removeEventListener(
'copy',
ensureMultiBlockSelectionSync,
Expand Down
Loading
Loading