diff --git a/packages/block-editor/src/components/block-popover/index.js b/packages/block-editor/src/components/block-popover/index.js index f01c43ef26a71d..06409bc6e65f42 100644 --- a/packages/block-editor/src/components/block-popover/index.js +++ b/packages/block-editor/src/components/block-popover/index.js @@ -20,7 +20,7 @@ import { */ import { useBlockElement } from '../block-list/use-block-props/use-block-refs'; import usePopoverScroll from './use-popover-scroll'; -import { rectUnion, getVisibleElementBounds } from '../../utils/dom'; +import { rectUnion, getElementBounds } from '../../utils/dom'; const MAX_POPOVER_RECOMPUTE_COUNTER = Number.MAX_SAFE_INTEGER; @@ -90,10 +90,10 @@ function BlockPopover( getBoundingClientRect() { return lastSelectedElement ? rectUnion( - getVisibleElementBounds( selectedElement ), - getVisibleElementBounds( lastSelectedElement ) + getElementBounds( selectedElement ), + getElementBounds( lastSelectedElement ) ) - : getVisibleElementBounds( selectedElement ); + : getElementBounds( selectedElement ); }, contextElement: selectedElement, }; diff --git a/packages/block-editor/src/components/block-tools/use-block-toolbar-popover-props.js b/packages/block-editor/src/components/block-tools/use-block-toolbar-popover-props.js index 6d64f5a5882cb8..df016e73c29d45 100644 --- a/packages/block-editor/src/components/block-tools/use-block-toolbar-popover-props.js +++ b/packages/block-editor/src/components/block-tools/use-block-toolbar-popover-props.js @@ -17,7 +17,7 @@ import { import { store as blockEditorStore } from '../../store'; import { useBlockElement } from '../block-list/use-block-props/use-block-refs'; import { hasStickyOrFixedPositionValue } from '../../hooks/position'; -import { getVisibleElementBounds } from '../../utils/dom'; +import { getElementBounds } from '../../utils/dom'; const COMMON_PROPS = { placement: 'top-start', @@ -68,7 +68,7 @@ function getProps( // Get how far the content area has been scrolled. const scrollTop = scrollContainer?.scrollTop || 0; - const blockRect = getVisibleElementBounds( selectedBlockElement ); + const blockRect = getElementBounds( selectedBlockElement ); const contentRect = contentElement.getBoundingClientRect(); // Get the vertical position of top of the visible content area. diff --git a/packages/block-editor/src/utils/dom.js b/packages/block-editor/src/utils/dom.js index e30f809e387797..6d55f2468d24b6 100644 --- a/packages/block-editor/src/utils/dom.js +++ b/packages/block-editor/src/utils/dom.js @@ -134,23 +134,21 @@ function isScrollable( element ) { ); } +export const WITH_OVERFLOW_ELEMENT_BLOCKS = [ 'core/navigation' ]; /** - * Returns the rect of the element including all visible nested elements. - * - * Visible nested elements, including elements that overflow the parent, are - * taken into account. - * - * This function is useful for calculating the visible area of a block that - * contains nested elements that overflow the block, e.g. the Navigation block, - * which can contain overflowing Submenu blocks. + * Returns the bounding rectangle of an element, with special handling for blocks + * that have visible overflowing children (defined in WITH_OVERFLOW_ELEMENT_BLOCKS). * + * For blocks like Navigation that can have overflowing elements (e.g. submenus), + * this function calculates the combined bounds of both the parent and its visible + * children. The returned rect may extend beyond the viewport. * The returned rect represents the full extent of the element and its visible * children, which may extend beyond the viewport. * * @param {Element} element Element. * @return {DOMRect} Bounding client rect of the element and its visible children. */ -export function getVisibleElementBounds( element ) { +export function getElementBounds( element ) { const viewport = element.ownerDocument.defaultView; if ( ! viewport ) { @@ -158,17 +156,25 @@ export function getVisibleElementBounds( element ) { } let bounds = element.getBoundingClientRect(); - const stack = [ element ]; - let currentElement; - - while ( ( currentElement = stack.pop() ) ) { - // Children won’t affect bounds unless the element is not scrollable. - if ( ! isScrollable( currentElement ) ) { - for ( const child of currentElement.children ) { - if ( isElementVisible( child ) ) { - const childBounds = child.getBoundingClientRect(); - bounds = rectUnion( bounds, childBounds ); - stack.push( child ); + const dataType = element.getAttribute( 'data-type' ); + + /* + * For blocks with overflowing elements (like Navigation), include the bounds + * of visible children that extend beyond the parent container. + */ + if ( dataType && WITH_OVERFLOW_ELEMENT_BLOCKS.includes( dataType ) ) { + const stack = [ element ]; + let currentElement; + + while ( ( currentElement = stack.pop() ) ) { + // Children won’t affect bounds unless the element is not scrollable. + if ( ! isScrollable( currentElement ) ) { + for ( const child of currentElement.children ) { + if ( isElementVisible( child ) ) { + const childBounds = child.getBoundingClientRect(); + bounds = rectUnion( bounds, childBounds ); + stack.push( child ); + } } } } diff --git a/packages/block-editor/src/utils/test/dom.js b/packages/block-editor/src/utils/test/dom.js new file mode 100644 index 00000000000000..50a16a27cf9617 --- /dev/null +++ b/packages/block-editor/src/utils/test/dom.js @@ -0,0 +1,224 @@ +/** + * Internal dependencies + */ +import { getElementBounds, WITH_OVERFLOW_ELEMENT_BLOCKS } from '../dom'; +describe( 'dom', () => { + describe( 'getElementBounds', () => { + it( 'should return a DOMRectReadOnly object if the viewport is not available', () => { + const element = { + ownerDocument: { + defaultView: null, + }, + }; + expect( getElementBounds( element ) ).toEqual( + new window.DOMRectReadOnly() + ); + } ); + it( 'should return a DOMRectReadOnly object if the viewport is available', () => { + const element = { + ownerDocument: { + defaultView: { + getComputedStyle: () => ( { + display: 'block', + visibility: 'visible', + opacity: '1', + } ), + }, + }, + getBoundingClientRect: () => ( { + left: 0, + top: 0, + right: 100, + bottom: 100, + width: 100, + height: 100, + } ), + getAttribute: ( x ) => x, + }; + expect( getElementBounds( element ) ).toEqual( + new window.DOMRectReadOnly( 0, 0, 100, 100 ) + ); + } ); + it( 'should clip left and right values when an element is larger than the viewport width', () => { + const element = window.document.createElement( 'div' ); + element.getBoundingClientRect = jest.fn().mockReturnValue( { + left: -10, + top: 0, + right: window.innerWidth + 10, + bottom: 100, + width: window.innerWidth, + height: 100, + } ); + expect( getElementBounds( element ).toJSON() ).toEqual( { + left: 0, // Reset to min left bound. + top: 0, + right: window.innerWidth, // Reset to max right bound. + bottom: 100, + width: window.innerWidth, + height: 100, + x: 0, + y: 0, + } ); + } ); + it( 'should return the parent DOMRectReadOnly object if the parent block type is not supported', () => { + const element = window.document.createElement( 'div' ); + element.getBoundingClientRect = jest.fn().mockReturnValue( { + left: 0, + top: 0, + right: 100, + bottom: 100, + width: 100, + height: 100, + } ); + element.setAttribute( 'data-type', 'test' ); + const childElement = window.document.createElement( 'div' ); + childElement.getBoundingClientRect = jest.fn().mockReturnValue( { + left: 0, + top: 0, + right: 333, + bottom: 333, + width: 333, + height: 333, + x: 0, + y: 0, + } ); + element.appendChild( childElement ); + + expect( getElementBounds( element ).toJSON() ).toEqual( { + left: 0, + top: 0, + right: 100, + bottom: 100, + width: 100, + height: 100, + x: 0, + y: 0, + } ); + } ); + describe( 'With known block type', () => { + it( 'should return the child DOMRectReadOnly object if it is visible and a known block type', () => { + const element = window.document.createElement( 'div' ); + element.getBoundingClientRect = jest.fn().mockReturnValue( { + left: 0, + top: 0, + right: 100, + bottom: 100, + width: 100, + height: 100, + } ); + element.setAttribute( + 'data-type', + WITH_OVERFLOW_ELEMENT_BLOCKS[ 0 ] + ); + const childElement = window.document.createElement( 'div' ); + childElement.getBoundingClientRect = jest + .fn() + .mockReturnValue( { + left: 0, + top: 0, + right: 333, + bottom: 333, + width: 333, + height: 333, + x: 0, + y: 0, + } ); + element.appendChild( childElement ); + + expect( getElementBounds( element ).toJSON() ).toEqual( { + left: 0, + top: 0, + right: 333, + bottom: 333, + width: 333, + height: 333, + x: 0, + y: 0, + } ); + } ); + it( 'should return the parent DOMRectReadOnly if the child is scrollable', () => { + const element = window.document.createElement( 'div' ); + element.setAttribute( + 'data-type', + WITH_OVERFLOW_ELEMENT_BLOCKS[ 0 ] + ); + element.style.overflowX = 'auto'; + element.style.overflowY = 'auto'; + element.getBoundingClientRect = jest.fn().mockReturnValue( { + left: 0, + top: 0, + right: 100, + bottom: 100, + width: 100, + height: 100, + } ); + const childElement = window.document.createElement( 'div' ); + childElement.getBoundingClientRect = jest + .fn() + .mockReturnValue( { + left: 0, + top: 0, + right: 333, + bottom: 333, + width: 333, + height: 333, + x: 0, + y: 0, + } ); + element.appendChild( childElement ); + + expect( getElementBounds( element ).toJSON() ).toEqual( { + left: 0, + top: 0, + right: 100, + bottom: 100, + width: 100, + height: 100, + x: 0, + y: 0, + } ); + } ); + it( 'should return the parent DOMRectReadOnly object if the child element is not visible', () => { + const element = window.document.createElement( 'div' ); + element.getBoundingClientRect = jest.fn().mockReturnValue( { + left: 0, + top: 0, + right: 100, + bottom: 100, + width: 100, + height: 100, + } ); + element.setAttribute( + 'data-type', + WITH_OVERFLOW_ELEMENT_BLOCKS[ 0 ] + ); + const childElement = window.document.createElement( 'div' ); + childElement.getBoundingClientRect = jest + .fn() + .mockReturnValue( { + left: 0, + top: 0, + right: 333, + bottom: 333, + width: 333, + height: 333, + x: 0, + y: 0, + } ); + childElement.style.display = 'none'; + element.appendChild( childElement ); + + expect( getElementBounds( element ).toJSON() ).toEqual( { + left: 0, + top: 0, + right: 100, + bottom: 100, + width: 100, + height: 100, + x: 0, + y: 0, + } ); + } ); + } ); + } ); +} );