diff --git a/packages/block-editor/src/components/block-list/block-popover.js b/packages/block-editor/src/components/block-list/block-popover.js index 91f44d2df09f8..84cab43330773 100644 --- a/packages/block-editor/src/components/block-list/block-popover.js +++ b/packages/block-editor/src/components/block-list/block-popover.js @@ -7,13 +7,7 @@ import classnames from 'classnames'; /** * WordPress dependencies */ -import { - useState, - useCallback, - useContext, - useRef, - useEffect, -} from '@wordpress/element'; +import { useState, useCallback, useRef, useEffect } from '@wordpress/element'; import { isUnmodifiedDefaultBlock } from '@wordpress/blocks'; import { Popover } from '@wordpress/components'; import { useDispatch, useSelect } from '@wordpress/data'; @@ -27,9 +21,8 @@ import { getScrollContainer } from '@wordpress/dom'; import BlockSelectionButton from './block-selection-button'; import BlockContextualToolbar from './block-contextual-toolbar'; import Inserter from '../inserter'; -import { BlockNodes } from './'; -import { getBlockDOMNode } from '../../utils/dom'; import { store as blockEditorStore } from '../../store'; +import { __unstableUseBlockElement as useBlockElement } from './use-block-props/use-block-refs'; function selector( select ) { const { @@ -71,7 +64,6 @@ function BlockPopover( { const isLargeViewport = useViewportMatch( 'medium' ); const [ isToolbarForced, setIsToolbarForced ] = useState( false ); const [ isInserterShown, setIsInserterShown ] = useState( false ); - const blockNodes = useContext( BlockNodes ); const { stopTyping } = useDispatch( blockEditorStore ); // Controls when the side inserter on empty lines should @@ -115,11 +107,9 @@ function BlockPopover( { // to it when re-mounting. const initialToolbarItemIndexRef = useRef(); - useEffect( () => { - // Resets the index whenever the active block changes so this is not - // persisted. See https://github.com/WordPress/gutenberg/pull/25760#issuecomment-717906169 - initialToolbarItemIndexRef.current = undefined; - }, [ clientId ] ); + const selectedElement = useBlockElement( clientId ); + const lastSelectedElement = useBlockElement( lastClientId ); + const capturingElement = useBlockElement( capturingClientId ); if ( ! shouldShowBreadcrumb && @@ -130,32 +120,28 @@ function BlockPopover( { return null; } - let node = blockNodes[ clientId ]; + let node = selectedElement; if ( ! node ) { return null; } - const { ownerDocument } = node; - if ( capturingClientId ) { - node = getBlockDOMNode( capturingClientId, ownerDocument ); + node = capturingElement; } let anchorRef = node; if ( hasMultiSelection ) { - const bottomNode = blockNodes[ lastClientId ]; - // Wait to render the popover until the bottom reference is available // as well. - if ( ! bottomNode ) { + if ( ! lastSelectedElement ) { return null; } anchorRef = { top: node, - bottom: bottomNode, + bottom: lastSelectedElement, }; } @@ -174,6 +160,7 @@ function BlockPopover( { const popoverPosition = showEmptyBlockSideInserter ? 'top left right' : 'top right left'; + const { ownerDocument } = node; const stickyBoundaryElement = showEmptyBlockSideInserter ? undefined : // The sticky boundary element should be the boundary at which the @@ -235,6 +222,9 @@ function BlockPopover( { __experimentalOnIndexChange={ ( index ) => { initialToolbarItemIndexRef.current = index; } } + // Resets the index whenever the active block changes so + // this is not persisted. See https://github.com/WordPress/gutenberg/pull/25760#issuecomment-717906169 + key={ clientId } /> ) } { shouldShowBreadcrumb && ( diff --git a/packages/block-editor/src/components/block-list/index.js b/packages/block-editor/src/components/block-list/index.js index 352df1fd8a4f3..f19c46e9d8d60 100644 --- a/packages/block-editor/src/components/block-list/index.js +++ b/packages/block-editor/src/components/block-list/index.js @@ -7,7 +7,7 @@ import classnames from 'classnames'; * WordPress dependencies */ import { AsyncModeProvider, useSelect } from '@wordpress/data'; -import { useRef, createContext, useState } from '@wordpress/element'; +import { useRef } from '@wordpress/element'; import { useViewportMatch, useMergeRefs } from '@wordpress/compose'; /** @@ -22,12 +22,8 @@ import { store as blockEditorStore } from '../../store'; import { usePreParsePatterns } from '../../utils/pre-parse-patterns'; import { LayoutProvider, defaultLayout } from './layout'; -export const BlockNodes = createContext(); -export const SetBlockNodes = createContext(); - export default function BlockList( { className, __experimentalLayout } ) { const ref = useRef(); - const [ blockNodes, setBlockNodes ] = useState( {} ); const insertionPoint = useInsertionPoint( ref ); usePreParsePatterns(); @@ -53,7 +49,7 @@ export default function BlockList( { className, __experimentalLayout } ) { }, [] ); return ( - + <> { insertionPoint }
- - - +
-
+ ); } diff --git a/packages/block-editor/src/components/block-list/insertion-point.js b/packages/block-editor/src/components/block-list/insertion-point.js index c209c48f513a6..6aec1ab40e720 100644 --- a/packages/block-editor/src/components/block-list/insertion-point.js +++ b/packages/block-editor/src/components/block-list/insertion-point.js @@ -21,8 +21,8 @@ import { isRTL } from '@wordpress/i18n'; * Internal dependencies */ import Inserter from '../inserter'; -import { getBlockDOMNode } from '../../utils/dom'; import { store as blockEditorStore } from '../../store'; +import { __unstableUseBlockElement as useBlockElement } from './use-block-props/use-block-refs'; function InsertionPointInserter( { clientId, @@ -53,17 +53,14 @@ function InsertionPointPopover( { isInserterShown, isInserterForced, setIsInserterForced, - containerRef, showInsertionPoint, } ) { const { selectBlock } = useDispatch( blockEditorStore ); const ref = useRef(); - const { - previousElement, - nextElement, orientation, isHidden, + previousClientId, nextClientId, rootClientId, } = useSelect( @@ -77,7 +74,6 @@ function InsertionPointPopover( { hasMultiSelection, getSettings, } = select( blockEditorStore ); - const { ownerDocument } = containerRef.current; const targetRootClientId = clientId ? getBlockRootClientId( clientId ) : selectedRootClientId; @@ -100,8 +96,7 @@ function InsertionPointPopover( { 'vertical'; return { - previousElement: getBlockDOMNode( previous, ownerDocument ), - nextElement: getBlockDOMNode( next, ownerDocument ), + previousClientId: previous, nextClientId: next, isHidden: hasReducedUI || @@ -116,7 +111,8 @@ function InsertionPointPopover( { }, [ clientId, selectedRootClientId ] ); - + const previousElement = useBlockElement( previousClientId ); + const nextElement = useBlockElement( nextClientId ); const style = useMemo( () => { if ( ! previousElement ) { return {}; @@ -411,7 +407,6 @@ export default function useInsertionPoint( ref ) { setIsInserterShown( value ); } } } - containerRef={ ref } showInsertionPoint={ isInserterVisible } /> ) diff --git a/packages/block-editor/src/components/block-list/use-block-props/index.js b/packages/block-editor/src/components/block-list/use-block-props/index.js index 627f848ebf841..4331a9251d563 100644 --- a/packages/block-editor/src/components/block-list/use-block-props/index.js +++ b/packages/block-editor/src/components/block-list/use-block-props/index.js @@ -29,8 +29,8 @@ import { useBlockMovingModeClassNames } from './use-block-moving-mode-class-name import { useFocusHandler } from './use-focus-handler'; import { useEventHandlers } from './use-selected-block-event-handlers'; import { useNavModeExit } from './use-nav-mode-exit'; -import { useBlockNodes } from './use-block-nodes'; import { useScrollIntoView } from './use-scroll-into-view'; +import { useBlockRefProvider } from './use-block-refs'; import { store as blockEditorStore } from '../../../store'; /** @@ -106,7 +106,7 @@ export function useBlockProps( props = {}, { __unstableIsHtml } = {} ) { useFocusFirstElement( clientId ), // Must happen after focus because we check for focus in the block. useScrollIntoView( clientId ), - useBlockNodes( clientId ), + useBlockRefProvider( clientId ), useFocusHandler( clientId ), useEventHandlers( clientId ), useNavModeExit( clientId ), diff --git a/packages/block-editor/src/components/block-list/use-block-props/use-block-nodes.js b/packages/block-editor/src/components/block-list/use-block-props/use-block-nodes.js deleted file mode 100644 index 49542364e2cfe..0000000000000 --- a/packages/block-editor/src/components/block-list/use-block-props/use-block-nodes.js +++ /dev/null @@ -1,61 +0,0 @@ -/** - * External dependencies - */ -import { omit } from 'lodash'; - -/** - * WordPress dependencies - */ -import { useContext } from '@wordpress/element'; -import { useRefEffect } from '@wordpress/compose'; -import { useSelect } from '@wordpress/data'; - -/** - * Internal dependencies - */ -import { SetBlockNodes } from '../'; -import { store as blockEditorStore } from '../../../store'; - -export function useBlockNodes( clientId ) { - const setBlockNodes = useContext( SetBlockNodes ); - // Provide the selected node, or the first and last nodes of a multi- - // selection, so it can be used to position the contextual block toolbar. - // We only provide what is necessary, and remove the nodes again when they - // are no longer selected. - const isNodeNeeded = useSelect( - ( select ) => { - const { - isBlockSelected, - isFirstMultiSelectedBlock, - getLastMultiSelectedBlockClientId, - } = select( blockEditorStore ); - return ( - isBlockSelected( clientId ) || - isFirstMultiSelectedBlock( clientId ) || - getLastMultiSelectedBlockClientId() === clientId - ); - } - // To do: figure out why tests are failing when dependencies are added. - // This data was originally retrieved with `withSelect` in `block.js`. - // For some reason, adding `clientId` as a dependency results in - // `toolbar-roving-tabindex.test.js` e2e test failures. - ); - - return useRefEffect( - ( node ) => { - if ( ! isNodeNeeded ) { - return; - } - - setBlockNodes( ( nodes ) => ( { - ...nodes, - [ clientId ]: node, - } ) ); - - return () => { - setBlockNodes( ( nodes ) => omit( nodes, clientId ) ); - }; - }, - [ isNodeNeeded, clientId, setBlockNodes ] - ); -} diff --git a/packages/block-editor/src/components/block-list/use-block-props/use-block-refs.js b/packages/block-editor/src/components/block-list/use-block-props/use-block-refs.js new file mode 100644 index 0000000000000..50794c611f1f6 --- /dev/null +++ b/packages/block-editor/src/components/block-list/use-block-props/use-block-refs.js @@ -0,0 +1,101 @@ +/** + * WordPress dependencies + */ +import { + useCallback, + useContext, + useLayoutEffect, + useMemo, + useRef, + useState, +} from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { BlockRefs } from '../../provider/block-refs-provider'; + +/** @typedef {import('@wordpress/element').RefCallback} RefCallback */ +/** @typedef {import('@wordpress/element').RefObject} RefObject */ + +/** + * Provides a ref to the BlockRefs context. + * + * @param {string} clientId The client ID of the element ref. + * + * @return {RefCallback} Ref callback. + */ +export function useBlockRefProvider( clientId ) { + const { refs, callbacks } = useContext( BlockRefs ); + const ref = useRef(); + useLayoutEffect( () => { + refs.set( clientId, ref ); + return () => { + refs.delete( clientId ); + }; + }, [] ); + return useCallback( ( element ) => { + // Update the ref in the provider. + ref.current = element; + // Call any update functions. + callbacks.forEach( ( id, setElement ) => { + if ( clientId === id ) { + setElement( element ); + } + } ); + }, [] ); +} + +/** + * Gets a ref pointing to the current block element. Continues to return a + * stable ref even if the block client ID changes. + * + * @param {string} clientId The client ID to get a ref for. + * + * @return {RefObject} A ref containing the element. + */ +function useBlockRef( clientId ) { + const { refs } = useContext( BlockRefs ); + const freshClientId = useRef(); + freshClientId.current = clientId; + // Always return an object, even if no ref exists for a given client ID, so + // that `current` works at a later point. + return useMemo( + () => ( { + get current() { + return refs.get( freshClientId.current )?.current || null; + }, + } ), + [] + ); +} + +/** + * Return the element for a given client ID. Updates whenever the element + * changes, becomes available, or disappears. + * + * @param {string} clientId The client ID to an element for. + * + * @return {Element|null} The block's wrapper element. + */ +function useBlockElement( clientId ) { + const { callbacks } = useContext( BlockRefs ); + const ref = useBlockRef( clientId ); + const [ element, setElement ] = useState( null ); + + useLayoutEffect( () => { + if ( ! clientId ) { + return; + } + + callbacks.set( setElement, clientId ); + return () => { + callbacks.delete( setElement ); + }; + }, [ clientId ] ); + + return ref.current || element; +} + +export { useBlockRef as __unstableUseBlockRef }; +export { useBlockElement as __unstableUseBlockElement }; diff --git a/packages/block-editor/src/components/provider/block-refs-provider.js b/packages/block-editor/src/components/provider/block-refs-provider.js new file mode 100644 index 0000000000000..e98e0f4d25c9d --- /dev/null +++ b/packages/block-editor/src/components/provider/block-refs-provider.js @@ -0,0 +1,16 @@ +/** + * WordPress dependencies + */ +import { createContext, useMemo } from '@wordpress/element'; + +export const BlockRefs = createContext(); + +export function BlockRefsProvider( { children } ) { + const value = useMemo( + () => ( { refs: new Map(), callbacks: new Map() } ), + [] + ); + return ( + { children } + ); +} diff --git a/packages/block-editor/src/components/provider/index.js b/packages/block-editor/src/components/provider/index.js index 157cd72d1f237..8851a2b9b47a1 100644 --- a/packages/block-editor/src/components/provider/index.js +++ b/packages/block-editor/src/components/provider/index.js @@ -10,6 +10,7 @@ import { useEffect } from '@wordpress/element'; import withRegistryProvider from './with-registry-provider'; import useBlockSync from './use-block-sync'; import { store as blockEditorStore } from '../../store'; +import { BlockRefsProvider } from './block-refs-provider'; /** @typedef {import('@wordpress/data').WPDataRegistry} WPDataRegistry */ @@ -24,7 +25,7 @@ function BlockEditorProvider( props ) { // Syncs the entity provider with changes in the block-editor store. useBlockSync( props ); - return children; + return { children }; } export default withRegistryProvider( BlockEditorProvider ); diff --git a/packages/block-editor/src/components/skip-to-selected-block/index.js b/packages/block-editor/src/components/skip-to-selected-block/index.js index e5c54524dc46e..61e7e3f8fd05d 100644 --- a/packages/block-editor/src/components/skip-to-selected-block/index.js +++ b/packages/block-editor/src/components/skip-to-selected-block/index.js @@ -8,16 +8,13 @@ import { Button } from '@wordpress/components'; /** * Internal dependencies */ -import { getBlockDOMNode } from '../../utils/dom'; import { store as blockEditorStore } from '../../store'; +import { __unstableUseBlockRef as useBlockRef } from '../block-list/use-block-props/use-block-refs'; const SkipToSelectedBlock = ( { selectedBlockClientId } ) => { + const ref = useBlockRef( selectedBlockClientId ); const onClick = () => { - const selectedBlockElement = getBlockDOMNode( - selectedBlockClientId, - document - ); - selectedBlockElement.focus(); + ref.current.focus(); }; return selectedBlockClientId ? ( diff --git a/packages/block-editor/src/components/writing-flow/use-multi-selection.js b/packages/block-editor/src/components/writing-flow/use-multi-selection.js index a6aee58da331f..71ded141fce5d 100644 --- a/packages/block-editor/src/components/writing-flow/use-multi-selection.js +++ b/packages/block-editor/src/components/writing-flow/use-multi-selection.js @@ -1,3 +1,8 @@ +/** + * External dependencies + */ +import { first, last } from 'lodash'; + /** * WordPress dependencies */ @@ -7,8 +12,9 @@ import { useSelect, useDispatch } from '@wordpress/data'; /** * Internal dependencies */ -import { getBlockClientId, getBlockDOMNode } from '../../utils/dom'; +import { getBlockClientId } from '../../utils/dom'; import { store as blockEditorStore } from '../../store'; +import { __unstableUseBlockRef as useBlockRef } from '../block-list/use-block-props/use-block-refs'; /** * Returns for the deepest node at the start or end of a container node. Ignores @@ -84,6 +90,10 @@ export default function useMultiSelection( ref ) { const rafId = useRef(); const startClientId = useRef(); const anchorElement = useRef(); + const selectedRef = useBlockRef( selectedBlockClientId ); + // These must be in the right DOM order. + const startRef = useBlockRef( first( multiSelectedBlockClientIds ) ); + const endRef = useBlockRef( last( multiSelectedBlockClientIds ) ); /** * When the component updates, and there is multi selection, we need to @@ -101,10 +111,7 @@ export default function useMultiSelection( ref ) { const selection = defaultView.getSelection(); if ( selection.rangeCount && ! selection.isCollapsed ) { - const blockNode = getBlockDOMNode( - selectedBlockClientId, - ownerDocument - ); + const blockNode = selectedRef.current; const { startContainer, endContainer } = selection.getRangeAt( 0 ); @@ -128,19 +135,14 @@ export default function useMultiSelection( ref ) { } // These must be in the right DOM order. - const start = multiSelectedBlockClientIds[ 0 ]; - const end = multiSelectedBlockClientIds[ length - 1 ]; - - let startNode = getBlockDOMNode( start, ownerDocument ); - let endNode = getBlockDOMNode( end, ownerDocument ); const selection = defaultView.getSelection(); const range = ownerDocument.createRange(); // The most stable way to select the whole block contents is to start // and end at the deepest points. - startNode = getDeepestNode( startNode, 'start' ); - endNode = getDeepestNode( endNode, 'end' ); + const startNode = getDeepestNode( startRef.current, 'start' ); + const endNode = getDeepestNode( endRef.current, 'end' ); range.setStartBefore( startNode ); range.setEndAfter( endNode ); diff --git a/packages/block-editor/src/hooks/color-panel.js b/packages/block-editor/src/hooks/color-panel.js index 9b93203b3d265..eacc2bbbc413b 100644 --- a/packages/block-editor/src/hooks/color-panel.js +++ b/packages/block-editor/src/hooks/color-panel.js @@ -10,7 +10,7 @@ import { useState, useEffect } from '@wordpress/element'; import PanelColorGradientSettings from '../components/colors-gradients/panel-color-gradient-settings'; import ContrastChecker from '../components/contrast-checker'; import InspectorControls from '../components/inspector-controls'; -import { getBlockDOMNode } from '../utils/dom'; +import { __unstableUseBlockRef as useBlockRef } from '../components/block-list/use-block-props/use-block-refs'; function getComputedStyle( node ) { return node.ownerDocument.defaultView.getComputedStyle( node ); @@ -23,19 +23,19 @@ export default function ColorPanel( { } ) { const [ detectedBackgroundColor, setDetectedBackgroundColor ] = useState(); const [ detectedColor, setDetectedColor ] = useState(); + const ref = useBlockRef( clientId ); useEffect( () => { if ( ! enableContrastChecking ) { return; } - const colorsDetectionElement = getBlockDOMNode( clientId, document ); - if ( ! colorsDetectionElement ) { + if ( ! ref.current ) { return; } - setDetectedColor( getComputedStyle( colorsDetectionElement ).color ); + setDetectedColor( getComputedStyle( ref.current ).color ); - let backgroundColorNode = colorsDetectionElement; + let backgroundColorNode = ref.current; let backgroundColor = getComputedStyle( backgroundColorNode ) .backgroundColor; while ( diff --git a/packages/block-editor/src/utils/dom.js b/packages/block-editor/src/utils/dom.js index 248587311d1d2..f99468a7e751c 100644 --- a/packages/block-editor/src/utils/dom.js +++ b/packages/block-editor/src/utils/dom.js @@ -1,36 +1,3 @@ -/** - * Given a block client ID, returns the corresponding DOM node for the block, - * if exists. As much as possible, this helper should be avoided, and used only - * in cases where isolated behaviors need remote access to a block node. - * - * @param {string} clientId Block client ID. - * @param {Document} doc Document to search. - * - * @return {Element?} Block DOM node. - */ -export function getBlockDOMNode( clientId, doc ) { - return doc.getElementById( 'block-' + clientId ); -} - -/** - * Returns the preview container DOM node for a given block client ID, or - * undefined if the container cannot be determined. - * - * @param {string} clientId Block client ID. - * @param {Document} doc Document to search. - * - * @return {Node|undefined} Preview container DOM node. - */ -export function getBlockPreviewContainerDOMNode( clientId, doc ) { - const domNode = getBlockDOMNode( clientId, doc ); - - if ( ! domNode ) { - return; - } - - return domNode.firstChild || domNode; -} - /** * Returns true if two elements are contained within the same block. *