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; +}