diff --git a/docs/designers-developers/developers/data/data-core-block-editor.md b/docs/designers-developers/developers/data/data-core-block-editor.md
index 588b1f27e3249..1bd2c38e601a1 100644
--- a/docs/designers-developers/developers/data/data-core-block-editor.md
+++ b/docs/designers-developers/developers/data/data-core-block-editor.md
@@ -706,6 +706,19 @@ _Returns_
- `boolean`: Whether an ancestor of the block is in multi-selection set.
+# **isBlockHighlighted**
+
+Returns true if the current highlighted block matches the block clientId.
+
+_Parameters_
+
+- _state_ `Object`: Global application state.
+- _clientId_ `string`: The block to check.
+
+_Returns_
+
+- `boolean`: Whether the block is currently highlighted.
+
# **isBlockInsertionPointVisible**
Returns true if we should show the block insertion point.
@@ -1296,6 +1309,15 @@ _Returns_
- `Object`: Action object.
+# **toggleBlockHighlight**
+
+Returns an action object that toggles the highlighted block state.
+
+_Parameters_
+
+- _clientId_ `string`: The block's clientId.
+- _isHighlighted_ `boolean`: The highlight state.
+
# **toggleBlockMode**
Returns an action object used to toggle the block editing mode between
diff --git a/packages/block-editor/src/components/block-list/block.js b/packages/block-editor/src/components/block-list/block.js
index 52b1a915364b0..2ae2354576a6f 100644
--- a/packages/block-editor/src/components/block-list/block.js
+++ b/packages/block-editor/src/components/block-list/block.js
@@ -39,6 +39,7 @@ function BlockListBlock( {
isLocked,
clientId,
rootClientId,
+ isHighlighted,
isSelected,
isMultiSelected,
isPartOfMultiSelection,
@@ -112,6 +113,7 @@ function BlockListBlock( {
'has-selected-ui': hasSelectedUI,
'has-warning': ! isValid || !! hasError || isUnregisteredBlock,
'is-selected': isSelected,
+ 'is-highlighted': isHighlighted,
'is-multi-selected': isMultiSelected,
'is-reusable': isReusableBlock( blockType ),
'is-dragging': isDragging,
@@ -228,6 +230,7 @@ const applyWithSelect = withSelect(
getTemplateLock,
__unstableGetBlockWithoutInnerBlocks,
isNavigationMode,
+ isBlockHighlighted,
} = select( 'core/block-editor' );
const block = __unstableGetBlockWithoutInnerBlocks( clientId );
const isSelected = isBlockSelected( clientId );
@@ -248,6 +251,7 @@ const applyWithSelect = withSelect(
const { name, attributes, isValid } = block || {};
return {
+ isHighlighted: isBlockHighlighted( clientId ),
isMultiSelected: isBlockMultiSelected( clientId ),
isPartOfMultiSelection:
isBlockMultiSelected( clientId ) ||
diff --git a/packages/block-editor/src/components/block-list/style.scss b/packages/block-editor/src/components/block-list/style.scss
index 2d90124cce001..5e121df85b60d 100644
--- a/packages/block-editor/src/components/block-list/style.scss
+++ b/packages/block-editor/src/components/block-list/style.scss
@@ -149,6 +149,7 @@
// The primary indicator of selection in text is the native selection marker.
// When selecting multiple blocks, we provide an additional selection indicator.
.is-navigate-mode & .block-editor-block-list__block.is-selected,
+ .block-editor-block-list__block.is-highlighted,
.block-editor-block-list__block.is-multi-selected {
// Show selection borders around every non-nested block's actual footprint.
@@ -168,6 +169,8 @@
// 2px outside.
box-shadow: 0 0 0 2px $blue-medium-focus;
border-radius: $radius-block-ui;
+ transition: box-shadow 0.2s ease-out;
+ @include reduce-motion("transition");
// Windows High Contrast mode will show this outline.
outline: 2px solid transparent;
@@ -203,6 +206,20 @@
min-height: ( $block-padding + $block-spacing ) * 2;
}
+ &::after {
+ content: "";
+ pointer-events: none;
+ position: absolute;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ border-radius: $radius-block-ui;
+ box-shadow: 0 0 0 2px transparent;
+ transition: box-shadow 0.1s ease-in;
+ @include reduce-motion("transition");
+ }
+
// Warnings
&.has-warning {
// When a block has a warning, you shouldn't be able to manipulate the contents.
diff --git a/packages/block-editor/src/components/block-toolbar/index.js b/packages/block-editor/src/components/block-toolbar/index.js
index 36b20c44e0fcb..04a13cc498e27 100644
--- a/packages/block-editor/src/components/block-toolbar/index.js
+++ b/packages/block-editor/src/components/block-toolbar/index.js
@@ -16,11 +16,12 @@ import BlockSwitcher from '../block-switcher';
import BlockControls from '../block-controls';
import BlockFormatControls from '../block-format-controls';
import BlockSettingsMenu from '../block-settings-menu';
-import { useShowMoversGestures } from './utils';
+import { useShowMoversGestures, useToggleBlockHighlight } from './utils';
export default function BlockToolbar( { hideDragHandle } ) {
const {
blockClientIds,
+ blockClientId,
hasFixedToolbar,
isValid,
mode,
@@ -36,15 +37,15 @@ export default function BlockToolbar( { hideDragHandle } ) {
getSettings,
} = select( 'core/block-editor' );
const selectedBlockClientIds = getSelectedBlockClientIds();
- const blockRootClientId = getBlockRootClientId(
- selectedBlockClientIds[ 0 ]
- );
+ const selectedBlockClientId = selectedBlockClientIds[ 0 ];
+ const blockRootClientId = getBlockRootClientId( selectedBlockClientId );
const { __experimentalMoverDirection, __experimentalUIParts = {} } =
getBlockListSettings( blockRootClientId ) || {};
return {
blockClientIds: selectedBlockClientIds,
+ blockClientId: selectedBlockClientId,
hasFixedToolbar: getSettings().hasFixedToolbar,
rootClientId: blockRootClientId,
isValid:
@@ -60,12 +61,15 @@ export default function BlockToolbar( { hideDragHandle } ) {
};
}, [] );
+ const toggleBlockHighlight = useToggleBlockHighlight( blockClientId );
const nodeRef = useRef();
- const {
- showMovers,
- gestures: showMoversGestures,
- } = useShowMoversGestures( { ref: nodeRef } );
+ const { showMovers, gestures: showMoversGestures } = useShowMoversGestures(
+ {
+ ref: nodeRef,
+ onChange: toggleBlockHighlight,
+ }
+ );
const displayHeaderToolbar =
useViewportMatch( 'medium', '<' ) || hasFixedToolbar;
diff --git a/packages/block-editor/src/components/block-toolbar/utils.js b/packages/block-editor/src/components/block-toolbar/utils.js
index da64ca0bfb348..2ee4a65b0d43f 100644
--- a/packages/block-editor/src/components/block-toolbar/utils.js
+++ b/packages/block-editor/src/components/block-toolbar/utils.js
@@ -1,9 +1,15 @@
+/**
+ * External dependencies
+ */
+import { noop } from 'lodash';
/**
* WordPress dependencies
*/
+import { useDispatch } from '@wordpress/data';
import { useState, useRef, useEffect, useCallback } from '@wordpress/element';
-const { clearTimeout, setTimeout } = window;
+const { clearTimeout, requestAnimationFrame, setTimeout } = window;
+const DEBOUNCE_TIMEOUT = 250;
/**
* Hook that creates a showMover state, as well as debounced show/hide callbacks
@@ -11,11 +17,17 @@ const { clearTimeout, setTimeout } = window;
export function useDebouncedShowMovers( {
ref,
isFocused,
- debounceTimeout = 500,
+ debounceTimeout = DEBOUNCE_TIMEOUT,
+ onChange = noop,
} ) {
const [ showMovers, setShowMovers ] = useState( false );
const timeoutRef = useRef();
+ const handleOnChange = ( nextIsFocused ) => {
+ setShowMovers( nextIsFocused );
+ onChange( nextIsFocused );
+ };
+
const getIsHovered = () => {
return ref?.current && ref.current.matches( ':hover' );
};
@@ -26,40 +38,41 @@ export function useDebouncedShowMovers( {
return ! isFocused && ! isHovered;
};
- const debouncedShowMovers = useCallback(
- ( event ) => {
- if ( event ) {
- event.stopPropagation();
- }
+ const clearTimeoutRef = () => {
+ const timeout = timeoutRef.current;
- const timeout = timeoutRef.current;
+ if ( timeout && clearTimeout ) {
+ clearTimeout( timeout );
+ }
+ };
- if ( timeout && clearTimeout ) {
- clearTimeout( timeout );
- }
- if ( ! showMovers ) {
- setShowMovers( true );
- }
- },
- [ showMovers ]
- );
+ const debouncedShowMovers = ( event ) => {
+ if ( event ) {
+ event.stopPropagation();
+ }
- const debouncedHideMovers = useCallback(
- ( event ) => {
- if ( event ) {
- event.stopPropagation();
- }
+ clearTimeoutRef();
- timeoutRef.current = setTimeout( () => {
- if ( shouldHideMovers() ) {
- setShowMovers( false );
- }
- }, debounceTimeout );
- },
- [ isFocused ]
- );
+ if ( ! showMovers ) {
+ handleOnChange( true );
+ }
+ };
+
+ const debouncedHideMovers = ( event ) => {
+ if ( event ) {
+ event.stopPropagation();
+ }
- useEffect( () => () => clearTimeout( timeoutRef.current ), [] );
+ clearTimeoutRef();
+
+ timeoutRef.current = setTimeout( () => {
+ if ( shouldHideMovers() ) {
+ handleOnChange( false );
+ }
+ }, debounceTimeout );
+ };
+
+ useEffect( () => () => clearTimeoutRef(), [] );
return {
showMovers,
@@ -72,13 +85,17 @@ export function useDebouncedShowMovers( {
* Hook that provides a showMovers state and gesture events for DOM elements
* that interact with the showMovers state.
*/
-export function useShowMoversGestures( { ref, debounceTimeout = 500 } ) {
+export function useShowMoversGestures( {
+ ref,
+ debounceTimeout = DEBOUNCE_TIMEOUT,
+ onChange = noop,
+} ) {
const [ isFocused, setIsFocused ] = useState( false );
const {
showMovers,
debouncedShowMovers,
debouncedHideMovers,
- } = useDebouncedShowMovers( { ref, debounceTimeout, isFocused } );
+ } = useDebouncedShowMovers( { ref, debounceTimeout, isFocused, onChange } );
const registerRef = useRef( false );
@@ -135,3 +152,33 @@ export function useShowMoversGestures( { ref, debounceTimeout = 500 } ) {
},
};
}
+
+/**
+ * Hook that toggles the highlight (outline) state of a block
+ *
+ * @param {string} clientId The block's clientId
+ *
+ * @return {Function} Callback function to toggle highlight state.
+ */
+export function useToggleBlockHighlight( clientId ) {
+ const { toggleBlockHighlight } = useDispatch( 'core/block-editor' );
+
+ const updateBlockHighlight = useCallback(
+ ( isFocused ) => {
+ toggleBlockHighlight( clientId, isFocused );
+ },
+ [ clientId ]
+ );
+
+ useEffect( () => {
+ return () => {
+ // Sequences state change to enable editor updates (e.g. cursor
+ // position) to render correctly.
+ requestAnimationFrame( () => {
+ updateBlockHighlight( false );
+ } );
+ };
+ }, [] );
+
+ return updateBlockHighlight;
+}
diff --git a/packages/block-editor/src/store/actions.js b/packages/block-editor/src/store/actions.js
index ccbf57f90edac..fe401713c3ab9 100644
--- a/packages/block-editor/src/store/actions.js
+++ b/packages/block-editor/src/store/actions.js
@@ -990,3 +990,17 @@ export function* insertAfterBlock( clientId ) {
);
yield insertDefaultBlock( {}, rootClientId, firstSelectedIndex + 1 );
}
+
+/**
+ * Returns an action object that toggles the highlighted block state.
+ *
+ * @param {string} clientId The block's clientId.
+ * @param {boolean} isHighlighted The highlight state.
+ */
+export function toggleBlockHighlight( clientId, isHighlighted ) {
+ return {
+ type: 'TOGGLE_BLOCK_HIGHLIGHT',
+ clientId,
+ isHighlighted,
+ };
+}
diff --git a/packages/block-editor/src/store/reducer.js b/packages/block-editor/src/store/reducer.js
index 1027890f2d8ec..78e405a528f5a 100644
--- a/packages/block-editor/src/store/reducer.js
+++ b/packages/block-editor/src/store/reducer.js
@@ -1448,6 +1448,28 @@ export function automaticChangeStatus( state, action ) {
// Reset the state by default (for any action not handled).
}
+/**
+ * Reducer returning current highlighted block.
+ *
+ * @param {boolean} state Current highlighted block.
+ * @param {Object} action Dispatched action.
+ *
+ * @return {string} Updated state.
+ */
+export function highlightedBlock( state, action ) {
+ const { clientId, isHighlighted } = action;
+
+ if ( action.type === 'TOGGLE_BLOCK_HIGHLIGHT' ) {
+ if ( isHighlighted ) {
+ return clientId;
+ } else if ( state === clientId ) {
+ return null;
+ }
+ }
+
+ return state;
+}
+
export default combineReducers( {
blocks,
isTyping,
@@ -1467,4 +1489,5 @@ export default combineReducers( {
lastBlockAttributesChange,
isNavigationMode,
automaticChangeStatus,
+ highlightedBlock,
} );
diff --git a/packages/block-editor/src/store/selectors.js b/packages/block-editor/src/store/selectors.js
index 52be22ab843fe..fc9dd38979294 100644
--- a/packages/block-editor/src/store/selectors.js
+++ b/packages/block-editor/src/store/selectors.js
@@ -1621,3 +1621,15 @@ export function isNavigationMode( state ) {
export function didAutomaticChange( state ) {
return !! state.automaticChangeStatus;
}
+
+/**
+ * Returns true if the current highlighted block matches the block clientId.
+ *
+ * @param {Object} state Global application state.
+ * @param {string} clientId The block to check.
+ *
+ * @return {boolean} Whether the block is currently highlighted.
+ */
+export function isBlockHighlighted( state, clientId ) {
+ return state.highlightedBlock === clientId;
+}