Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Block Editor: Interpret adjacent block selection from insertion point #16818

Closed
wants to merge 7 commits into from
Original file line number Diff line number Diff line change
Expand Up @@ -1031,7 +1031,7 @@ reflects a reverse selection.
_Parameters_

- _clientId_ `string`: Block client ID.
- _initialPosition_ `?number`: Optional initial position. Pass as -1 to reflect reverse selection.
- _initialPosition_ `?WPBlockInitialPosition`: Optional initial position.

_Returns_

Expand Down Expand Up @@ -1061,6 +1061,7 @@ clientId should be selected.
_Parameters_

- _clientId_ `string`: Block client ID.
- _initialPosition_ `?WPBlockInitialPosition`: Optional initial position.

<a name="selectPreviousBlock" href="#selectPreviousBlock">#</a> **selectPreviousBlock**

Expand All @@ -1070,6 +1071,7 @@ clientId should be selected.
_Parameters_

- _clientId_ `string`: Block client ID.
- _initialPosition_ `?WPBlockInitialPosition`: Optional initial position.

<a name="setTemplateValidity" href="#setTemplateValidity">#</a> **setTemplateValidity**

Expand Down
9 changes: 6 additions & 3 deletions packages/block-editor/src/components/block-list/block.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { useRef, useEffect, useState } from '@wordpress/element';
import {
focus,
isTextField,
placeCaretAtHorizontalEdge,
placeCaretAtVerticalEdge,
} from '@wordpress/dom';
import { BACKSPACE, DELETE, ENTER } from '@wordpress/keycodes';
import {
Expand Down Expand Up @@ -224,15 +224,18 @@ function BlockListBlock( {

// If reversed (e.g. merge via backspace), use the last in the set of
// tabbables.
const isReverse = -1 === initialPosition;
const { isReverse, caretRect } = {
isReverse: initialPosition === -1,
...initialPosition,
};
const target = ( isReverse ? last : first )( textInputs );

if ( ! target ) {
wrapper.current.focus();
return;
}

placeCaretAtHorizontalEdge( target, isReverse );
placeCaretAtVerticalEdge( target, isReverse, caretRect );
};

// Focus the selected block's wrapper or inner input on mount and update
Expand Down
151 changes: 80 additions & 71 deletions packages/block-editor/src/components/block-list/insertion-point.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,93 +6,102 @@ import classnames from 'classnames';
/**
* WordPress dependencies
*/
import { Component } from '@wordpress/element';
import { withSelect } from '@wordpress/data';
import { useState, useCallback } from '@wordpress/element';
import { useSelect, useDispatch } from '@wordpress/data';

/**
* Internal dependencies
*/
import Inserter from '../inserter';

class BlockInsertionPoint extends Component {
constructor() {
super( ...arguments );
this.state = {
isInserterFocused: false,
};

this.onBlurInserter = this.onBlurInserter.bind( this );
this.onFocusInserter = this.onFocusInserter.bind( this );
}
function BlockInsertionPoint( { rootClientId, clientId } ) {
const [ isInserterFocused, setIsInserterFocused ] = useState( false );

onFocusInserter( event ) {
const onFocusInserter = useCallback( ( event ) => {
// Stop propagation of the focus event to avoid selecting the current
// block while inserting a new block, as it is not relevant to sibling
// insertion and conflicts with contextual toolbar placement.
event.stopPropagation();

this.setState( {
isInserterFocused: true,
} );
}
setIsInserterFocused( true );
}, [] );

onBlurInserter() {
this.setState( {
isInserterFocused: false,
} );
}
const onBlurInserter = useCallback( () => setIsInserterFocused( false ), [] );

render() {
const { isInserterFocused } = this.state;
const { showInsertionPoint } = useSelect( ( select ) => {
const {
showInsertionPoint,
rootClientId,
clientId,
} = this.props;
getBlockIndex,
getBlockInsertionPoint,
isBlockInsertionPointVisible,
} = select( 'core/block-editor' );
const blockIndex = getBlockIndex( clientId, rootClientId );
const insertionPoint = getBlockInsertionPoint();
return {
showInsertionPoint: (
isBlockInsertionPointVisible() &&
insertionPoint.index === blockIndex &&
insertionPoint.rootClientId === rootClientId
),
};
}, [ clientId, rootClientId ] );

const { selectBlock, selectPreviousBlock } = useDispatch( 'core/block-editor' );

const selectAdjacentBlock = useCallback( ( event ) => {
const { currentTarget } = event;
const targetRect = currentTarget.getBoundingClientRect();
const yPercent = ( event.clientY - targetRect.top ) / targetRect.height;

selectBlock( null );

return (
<div className="editor-block-list__insertion-point block-editor-block-list__insertion-point">
{ showInsertionPoint && (
<div className="editor-block-list__insertion-point-indicator block-editor-block-list__insertion-point-indicator" />
) }
<div
onFocus={ this.onFocusInserter }
onBlur={ this.onBlurInserter }
// While ideally it would be enough to capture the
// bubbling focus event from the Inserter, due to the
// characteristics of click focusing of `button`s in
// Firefox and Safari, it is not reliable.
//
// See: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/button#Clicking_and_focus
tabIndex={ -1 }
className={
classnames( 'editor-block-list__insertion-point-inserter block-editor-block-list__insertion-point-inserter', {
'is-visible': isInserterFocused,
} )
}
>
<Inserter
rootClientId={ rootClientId }
clientId={ clientId }
/>
</div>
const caretRect = new window.DOMRect( event.clientX, targetRect.top, 0, 28 );
if ( yPercent > 0.5 ) {
selectBlock( clientId, { caretRect } );
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not call placeCaretAtVerticalEdge directly and avoid the strange initialPosition state?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not call placeCaretAtVerticalEdge directly and avoid the strange initialPosition state?

It could be done, but the difficulty is in finding the input, which likely would require copying one or the other of WritingFlow or BlockListBlock's respective behaviors for finding a tabbable at the extent of the block [1] [2].

Maybe there's another option where we "wait" for the focus to take place from the block selection, and then call placeCaretAtVerticalEdge from the document.activeElement.

} else {
selectPreviousBlock( clientId, { isReverse: true, caretRect } );
}

// Prevent the `focus` event from firing, which would otherwise trigger
// the block's fallback focus handling selection.
event.preventDefault();
}, [ clientId ] );

// Disable reason: The mouse interaction is not an essential interaction,
// and merely serves as convenience redirect to select the adjacent block.

/* eslint-disable jsx-a11y/no-static-element-interactions */
return (
<div
onMouseDown={ selectAdjacentBlock }
className="editor-block-list__insertion-point block-editor-block-list__insertion-point"
>
{ showInsertionPoint && (
<div className="editor-block-list__insertion-point-indicator block-editor-block-list__insertion-point-indicator" />
) }
<div
onFocus={ onFocusInserter }
onBlur={ onBlurInserter }
// While ideally it would be enough to capture the
// bubbling focus event from the Inserter, due to the
// characteristics of click focusing of `button`s in
// Firefox and Safari, it is not reliable.
//
// See: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/button#Clicking_and_focus
tabIndex={ -1 }
className={
classnames( 'editor-block-list__insertion-point-inserter block-editor-block-list__insertion-point-inserter', {
'is-visible': isInserterFocused,
} )
}
>
<Inserter
rootClientId={ rootClientId }
clientId={ clientId }
/>
</div>
);
}
}
export default withSelect( ( select, { clientId, rootClientId } ) => {
const {
getBlockIndex,
getBlockInsertionPoint,
isBlockInsertionPointVisible,
} = select( 'core/block-editor' );
const blockIndex = getBlockIndex( clientId, rootClientId );
const insertionPoint = getBlockInsertionPoint();
const showInsertionPoint = (
isBlockInsertionPointVisible() &&
insertionPoint.index === blockIndex &&
insertionPoint.rootClientId === rootClientId
</div>
);
/* eslint-enable jsx-a11y/no-static-element-interactions */
}

return { showInsertionPoint };
} )( BlockInsertionPoint );
export default BlockInsertionPoint;
7 changes: 6 additions & 1 deletion packages/block-editor/src/components/block-list/style.scss
Original file line number Diff line number Diff line change
Expand Up @@ -849,11 +849,16 @@
// This is the edge-to-edge hover area that contains the plus.
.block-editor-block-list__block {
> .block-editor-block-list__insertion-point {
display: flex;
align-items: center;

cursor: text;

position: absolute;
top: -$block-padding - $block-spacing / 2;

// Matches the whole empty space between two blocks.
height: $block-padding * 2;
height: $block-padding * 2 + $block-spacing;
bottom: auto;

// Go edge to edge on mobile.
Expand Down
30 changes: 21 additions & 9 deletions packages/block-editor/src/store/actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,17 @@ import { getDefaultBlockName, createBlock } from '@wordpress/blocks';
*/
import { select } from './controls';

/**
* Value describing a selected block's initial position, either a number as the
* offset (-1 for reverse), or an object of properties `isReverse`, `caretRect`.
*
* @typedef {(number|Object)} WPBlockInitialPosition
*
* @property {boolean} isReverse Whether initial position should be placed at
* the end of the selected block.
* @property {DOMRect} caretRect Rect describing offset of initial position.
*/

/**
* Generator which will yield a default block insert action if there
* are no other blocks at the root of the editor. This generator should be used
Expand Down Expand Up @@ -104,9 +115,8 @@ export function updateBlock( clientId, updates ) {
* value reflecting its selection directionality. An initialPosition of -1
* reflects a reverse selection.
*
* @param {string} clientId Block client ID.
* @param {?number} initialPosition Optional initial position. Pass as -1 to
* reflect reverse selection.
* @param {string} clientId Block client ID.
* @param {?WPBlockInitialPosition} initialPosition Optional initial position.
*
* @return {Object} Action object.
*/
Expand All @@ -122,35 +132,37 @@ export function selectBlock( clientId, initialPosition = null ) {
* Yields action objects used in signalling that the block preceding the given
* clientId should be selected.
*
* @param {string} clientId Block client ID.
* @param {string} clientId Block client ID.
* @param {?WPBlockInitialPosition} initialPosition Optional initial position.
*/
export function* selectPreviousBlock( clientId ) {
export function* selectPreviousBlock( clientId, initialPosition = -1 ) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not super excited to see more use of initialPosition... Ideally, we should try to get rid of it and directly set the correct selection either in the selection store or the DOM. initialPosition is already reduced to only be needed for block deletion. I was hoping that soon we'd be able to get rid of it entirely.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not super excited to see more use of initialPosition... Ideally, we should try to get rid of it and directly set the correct selection either in the selection store or the DOM. initialPosition is already reduced to only be needed for block deletion. I was hoping that soon we'd be able to get rid of it entirely.

In this model, how would you foresee doing something like we're doing here (and also doing in WritingFlow) where we want to map a specific DOMRect offset to a selection?

const previousBlockClientId = yield select(
'core/block-editor',
'getPreviousBlockClientId',
clientId
);

if ( previousBlockClientId ) {
yield selectBlock( previousBlockClientId, -1 );
yield selectBlock( previousBlockClientId, initialPosition );
}
}

/**
* Yields action objects used in signalling that the block following the given
* clientId should be selected.
*
* @param {string} clientId Block client ID.
* @param {string} clientId Block client ID.
* @param {?WPBlockInitialPosition} initialPosition Optional initial position.
*/
export function* selectNextBlock( clientId ) {
export function* selectNextBlock( clientId, initialPosition ) {
const nextBlockClientId = yield select(
'core/block-editor',
'getNextBlockClientId',
clientId
);

if ( nextBlockClientId ) {
yield selectBlock( nextBlockClientId );
yield selectBlock( nextBlockClientId, initialPosition );
}
}

Expand Down
6 changes: 5 additions & 1 deletion packages/block-editor/src/store/selectors.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ import {
hasChildBlocksWithInserterSupport,
} from '@wordpress/blocks';

/**
* @typedef {import('./actions').WPBlockInitialPosition} WPBlockInitialPosition
*/

// Module constants

/**
Expand Down Expand Up @@ -523,7 +527,7 @@ export function getNextBlockClientId( state, startClientId ) {
*
* @param {Object} state Global application state.
*
* @return {?Object} Selected block.
* @return {?WPBlockInitialPosition} Initial position.
*/
export function getSelectedBlocksInitialCaretPosition( state ) {
const { start, end } = state.blockSelection;
Expand Down