diff --git a/lib/experimental/editor-settings.php b/lib/experimental/editor-settings.php
index a700a0f484371d..ba667019c17a8c 100644
--- a/lib/experimental/editor-settings.php
+++ b/lib/experimental/editor-settings.php
@@ -92,6 +92,9 @@ function gutenberg_enable_experiments() {
if ( $gutenberg_experiments && array_key_exists( 'gutenberg-details-blocks', $gutenberg_experiments ) ) {
wp_add_inline_script( 'wp-block-editor', 'window.__experimentalEnableDetailsBlocks = true', 'before' );
}
+ if ( $gutenberg_experiments && array_key_exists( 'gutenberg-image-block-alignment-snapping', $gutenberg_experiments ) ) {
+ wp_add_inline_script( 'wp-block-editor', 'window.__experimentalEnableImageBlockAlignmentSnapping = true', 'before' );
+ }
}
add_action( 'admin_init', 'gutenberg_enable_experiments' );
diff --git a/lib/experiments-page.php b/lib/experiments-page.php
index 5ba1830bb4e003..e8ed0ca1fca1b2 100644
--- a/lib/experiments-page.php
+++ b/lib/experiments-page.php
@@ -101,6 +101,18 @@ function gutenberg_initialize_experiments_settings() {
)
);
+ add_settings_field(
+ 'gutenberg-image-block-alignment-snapping',
+ __( 'Image block snapping ', 'gutenberg' ),
+ 'gutenberg_display_experiment_field',
+ 'gutenberg-experiments',
+ 'gutenberg_experiments_section',
+ array(
+ 'label' => __( 'Test new guides for snapping to alignment sizes when resizing the image block.', 'gutenberg' ),
+ 'id' => 'gutenberg-image-block-alignment-snapping',
+ )
+ );
+
register_setting(
'gutenberg-experiments',
'gutenberg-experiments'
diff --git a/packages/block-editor/src/components/block-alignment-visualizer/guide-context.js b/packages/block-editor/src/components/block-alignment-visualizer/guide-context.js
new file mode 100644
index 00000000000000..39e82d478ee604
--- /dev/null
+++ b/packages/block-editor/src/components/block-alignment-visualizer/guide-context.js
@@ -0,0 +1,122 @@
+/**
+ * WordPress dependencies
+ */
+import { useThrottle } from '@wordpress/compose';
+import { getScreenRect } from '@wordpress/dom';
+import { createContext, useContext, useRef } from '@wordpress/element';
+
+/**
+ * Internal dependencies
+ */
+import { getDistanceFromPointToEdge } from '../../utils/math';
+
+const BlockAlignmentGuideContext = createContext( new Map() );
+export const useBlockAlignmentGuides = () =>
+ useContext( BlockAlignmentGuideContext );
+
+export function BlockAlignmentGuideContextProvider( { children } ) {
+ const guides = useRef( new Map() );
+
+ return (
+
+ { children }
+
+ );
+}
+
+/**
+ * Detect whether the `element` is snapping to one of the alignment guide along its `snapEdge`.
+ *
+ * @param {Node} element The element to check for snapping.
+ * @param {'left'|'right'} snapEdge The edge that will snap.
+ * @param {Map} alignmentGuides A Map of alignment guide nodes.
+ * @param {number} snapGap The pixel threshold for snapping.
+ *
+ * @return {null|'none'|'wide'|'full'} The alignment guide or `null` if no snapping was detected.
+ */
+function detectSnapping( element, snapEdge, alignmentGuides, snapGap ) {
+ const elementRect = getScreenRect( element );
+
+ // Get a point on the resizable rect's edge for `getDistanceFromPointToEdge`.
+ // - Caveat: this assumes horizontal resizing.
+ const pointFromElementRect = {
+ x: elementRect[ snapEdge ],
+ y: elementRect.top,
+ };
+
+ let candidateGuide = null;
+
+ // Loop through alignment guide nodes.
+ alignmentGuides?.forEach( ( guide, name ) => {
+ const guideRect = getScreenRect( guide );
+
+ // Calculate the distance from the resizeable element's edge to the
+ // alignment zone's edge.
+ const distance = getDistanceFromPointToEdge(
+ pointFromElementRect,
+ guideRect,
+ snapEdge
+ );
+
+ // If the distance is within snapping tolerance, we are snapping to this alignment.
+ if ( distance < snapGap ) {
+ candidateGuide = name;
+ }
+ } );
+
+ return candidateGuide;
+}
+
+export function useDetectSnapping( {
+ snapGap = 50,
+ dwellTime = 300,
+ throttle = 100,
+} = {} ) {
+ const alignmentGuides = useBlockAlignmentGuides();
+ const snappedAlignmentInfo = useRef();
+
+ return useThrottle( ( element, snapEdge ) => {
+ const snappedAlignment = detectSnapping(
+ element,
+ snapEdge,
+ alignmentGuides,
+ snapGap
+ );
+
+ // Set snapped alignment info when the user first reaches a snap guide.
+ if (
+ snappedAlignment &&
+ ( ! snappedAlignmentInfo.current ||
+ snappedAlignmentInfo.current.name !== snappedAlignment )
+ ) {
+ snappedAlignmentInfo.current = {
+ timestamp: Date.now(),
+ name: snappedAlignment,
+ };
+ }
+
+ // Unset snapped alignment info when the user moves away from a snap guide.
+ if ( ! snappedAlignment && snappedAlignmentInfo.current ) {
+ snappedAlignmentInfo.current = null;
+ return null;
+ }
+
+ // If the user hasn't dwelt long enough on the alignment, return early.
+ if (
+ snappedAlignmentInfo.current &&
+ Date.now() - snappedAlignmentInfo.current.timestamp < dwellTime
+ ) {
+ return null;
+ }
+
+ const guide = alignmentGuides.get( snappedAlignmentInfo.current?.name );
+ if ( ! guide ) {
+ return null;
+ }
+
+ return {
+ name: snappedAlignment,
+ rect: getScreenRect( guide ),
+ };
+ }, throttle );
+}
diff --git a/packages/block-editor/src/components/block-alignment-visualizer/guides.js b/packages/block-editor/src/components/block-alignment-visualizer/guides.js
new file mode 100644
index 00000000000000..3744e1a3fa6f76
--- /dev/null
+++ b/packages/block-editor/src/components/block-alignment-visualizer/guides.js
@@ -0,0 +1,113 @@
+/**
+ * External dependencies
+ */
+import classnames from 'classnames';
+
+/**
+ * WordPress dependencies
+ */
+import { useRefEffect } from '@wordpress/compose';
+
+/**
+ * Internal dependencies
+ */
+import { useBlockAlignmentGuides } from './guide-context';
+
+/**
+ * Renders hidden guide elements that are used for calculating snapping.
+ * Each guide has the same rect as a block would at the given alignment.
+ *
+ * @param {Object} props
+ * @param {string} props.contentSize The CSS value for content size (e.g. 600px).
+ * @param {string} props.wideSize The CSS value for wide size (e.g. 80%).
+ * @param {'none'|'wide'|'full'[]} props.alignments An array of the alignments to render.
+ * @param {'left'|'right'|'center'} props.justification The justification.
+ */
+export default function Guides( {
+ contentSize,
+ wideSize,
+ alignments,
+ justification,
+} ) {
+ return (
+ <>
+
+
+ { alignments.map( ( { name } ) => (
+
+ ) ) }
+
+ >
+ );
+}
+
+function Guide( { alignment } ) {
+ const guides = useBlockAlignmentGuides();
+ const updateGuideContext = useRefEffect(
+ ( node ) => {
+ guides?.set( alignment, node );
+ return () => {
+ guides?.delete( alignment );
+ };
+ },
+ [ alignment ]
+ );
+
+ return (
+
+ );
+}
diff --git a/packages/block-editor/src/components/block-alignment-visualizer/index.js b/packages/block-editor/src/components/block-alignment-visualizer/index.js
new file mode 100644
index 00000000000000..9aec7962079b1c
--- /dev/null
+++ b/packages/block-editor/src/components/block-alignment-visualizer/index.js
@@ -0,0 +1,76 @@
+/**
+ * WordPress dependencies
+ */
+import { getBlockSupport, hasBlockSupport } from '@wordpress/blocks';
+import { useSelect } from '@wordpress/data';
+
+/**
+ * Internal dependencies
+ */
+import ShadowDOMContainer from './shadow-dom-container';
+import Underlay from './underlay';
+import { useLayout } from '../block-list/layout';
+import useAvailableAlignments from '../block-alignment-control/use-available-alignments';
+import { store as blockEditorStore } from '../../store';
+import { getValidAlignments } from '../../hooks/align';
+import Visualization from './visualization';
+import Guides from './guides';
+
+/**
+ * A component that displays block alignment guidelines.
+ *
+ * @param {Object} props
+ * @param {?string[]} props.allowedAlignments An optional array of alignments names. By default, the alignment support will be derived from the
+ * 'focused' block's block supports, but some blocks (image) have an ad-hoc alignment implementation.
+ * @param {string} props.focusedClientId The client id of the block to show the alignment guides for.
+ * @param {?string} props.highlightedAlignment The alignment name to show the label of.
+ */
+export default function BlockAlignmentVisualizer( {
+ allowedAlignments,
+ focusedClientId,
+ highlightedAlignment,
+} ) {
+ const focusedBlockName = useSelect(
+ ( select ) =>
+ select( blockEditorStore ).getBlockName( focusedClientId ),
+ [ focusedClientId ]
+ );
+
+ // Get the valid alignments of the focused block, or use the supplied `allowedAlignments`,
+ // which allows this to work for blocks like 'image' that don't use block supports.
+ const validAlignments =
+ allowedAlignments ??
+ getValidAlignments(
+ getBlockSupport( focusedBlockName, 'align' ),
+ hasBlockSupport( focusedBlockName, 'alignWide', true )
+ );
+ const availableAlignments = useAvailableAlignments( validAlignments );
+ const layout = useLayout();
+
+ if ( availableAlignments?.length === 0 ) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
+
+ );
+}
diff --git a/packages/block-editor/src/components/block-alignment-visualizer/shadow-dom-container.js b/packages/block-editor/src/components/block-alignment-visualizer/shadow-dom-container.js
new file mode 100644
index 00000000000000..2e37497dbfd4e6
--- /dev/null
+++ b/packages/block-editor/src/components/block-alignment-visualizer/shadow-dom-container.js
@@ -0,0 +1,19 @@
+/**
+ * WordPress dependencies
+ */
+import { useRefEffect } from '@wordpress/compose';
+import { createPortal, useState } from '@wordpress/element';
+
+export default function ShadowDOMContainer( { children } ) {
+ const [ shadowRoot, setShadowRoot ] = useState( null );
+ const ref = useRefEffect( ( node ) => {
+ setShadowRoot( node.attachShadow( { mode: 'open' } ) );
+ return () => setShadowRoot( null );
+ }, [] );
+
+ return (
+
+ { shadowRoot && createPortal( children, shadowRoot ) }
+
+ );
+}
diff --git a/packages/block-editor/src/components/block-alignment-visualizer/underlay.js b/packages/block-editor/src/components/block-alignment-visualizer/underlay.js
new file mode 100644
index 00000000000000..c314736ab25ad7
--- /dev/null
+++ b/packages/block-editor/src/components/block-alignment-visualizer/underlay.js
@@ -0,0 +1,77 @@
+/**
+ * WordPress dependencies
+ */
+import { useContext, useEffect, useState } from '@wordpress/element';
+
+/**
+ * Internal dependencies
+ */
+import { BlockList } from '../';
+import { __unstableUseBlockElement as useBlockElement } from '../block-list/use-block-props/use-block-refs';
+
+/** @typedef {import('react').ReactNode} ReactNode */
+
+/**
+ * Underlay is a bit like a Popover, but is inline so only requires half the code.
+ *
+ * @param {Object} props
+ * @param {string} props.className A classname to apply to the underlay.
+ * @param {string} props.focusedClientId The client id of the block being interacted with.
+ * @param {ReactNode} props.children Child elements.
+ */
+export default function Underlay( { className, focusedClientId, children } ) {
+ const [ underlayStyle, setUnderlayStyle ] = useState( null );
+ const focusedBlockElement = useBlockElement( focusedClientId );
+
+ // useBlockElement is unable to return the document's root block list.
+ // __unstableElementContext seems to provide this.
+ const rootBlockListElement = useContext(
+ BlockList.__unstableElementContext
+ );
+
+ useEffect( () => {
+ if ( ! focusedBlockElement || ! rootBlockListElement ) {
+ return;
+ }
+
+ const { ownerDocument } = focusedBlockElement;
+ const { defaultView } = ownerDocument;
+
+ const update = () => {
+ const layoutRect = rootBlockListElement.getBoundingClientRect();
+ const focusedBlockRect =
+ focusedBlockElement.getBoundingClientRect();
+
+ // The 'underlay' has the width and horizontal positioning of the root block list,
+ // and the height and vertical positioning of the edited block.
+ // Note: using the root block list is a naive implementation here, ideally the parent
+ // block that provides the layout should be used.
+ setUnderlayStyle( {
+ position: 'absolute',
+ left: layoutRect.x - focusedBlockRect.x,
+ top: 0,
+ width: Math.floor( layoutRect.width ),
+ height: Math.floor( focusedBlockRect.height ),
+ zIndex: 0,
+ } );
+ };
+
+ // Observe any resizes of both the layout and focused elements.
+ const resizeObserver = defaultView.ResizeObserver
+ ? new defaultView.ResizeObserver( update )
+ : undefined;
+ resizeObserver?.observe( rootBlockListElement );
+ resizeObserver?.observe( focusedBlockElement );
+ update();
+
+ return () => {
+ resizeObserver?.disconnect();
+ };
+ }, [ focusedBlockElement, rootBlockListElement ] );
+
+ return (
+
+ { children }
+
+ );
+}
diff --git a/packages/block-editor/src/components/block-alignment-visualizer/visualization.js b/packages/block-editor/src/components/block-alignment-visualizer/visualization.js
new file mode 100644
index 00000000000000..cd29d81b256088
--- /dev/null
+++ b/packages/block-editor/src/components/block-alignment-visualizer/visualization.js
@@ -0,0 +1,204 @@
+/**
+ * External dependencies
+ */
+import classnames from 'classnames';
+
+/**
+ * WordPress dependencies
+ */
+import { Fragment } from '@wordpress/element';
+import { __ } from '@wordpress/i18n';
+
+const ALIGNMENT_LABELS = {
+ content: __( 'Content width' ),
+ wide: __( 'Wide width' ),
+ full: __( 'Full width' ),
+};
+
+/**
+ * Renders a visualization of block alignments.
+ *
+ * @param {Object} props
+ * @param {string} props.contentSize The CSS value for content size (e.g. 600px).
+ * @param {string} props.wideSize The CSS value for wide size (e.g. 80%).
+ * @param {'none'|'wide'|'full'[]} props.alignments An array of the alignments to render.
+ * @param {'left'|'right'|'center'} props.justification The justification.
+ * @param {string} props.highlightedAlignment The name of the highlighted alignment.
+ */
+export default function Visualization( {
+ contentSize,
+ wideSize,
+ alignments,
+ justification,
+ highlightedAlignment,
+} ) {
+ return (
+ <>
+
+
+
+ { [ ...alignments ]
+ .reverse()
+ .map(
+ ( { name } ) =>
+ ( name === 'full' || name === 'wide' ) && (
+
+ )
+ ) }
+
+ { alignments.map(
+ ( { name } ) =>
+ ( name === 'full' || name === 'wide' ) && (
+
+ )
+ ) }
+
+
+ >
+ );
+}
+
+function VisualizationSegment( { side, alignment, isHighlighted } ) {
+ const label = ALIGNMENT_LABELS[ alignment ];
+
+ return (
+
+ { !! label && (
+
+ { label }
+
+ ) }
+
+ );
+}
diff --git a/packages/block-editor/src/components/block-list/content.scss b/packages/block-editor/src/components/block-list/content.scss
index 268301e598d5aa..b1315357162e0d 100644
--- a/packages/block-editor/src/components/block-list/content.scss
+++ b/packages/block-editor/src/components/block-list/content.scss
@@ -87,7 +87,7 @@
.block-editor-block-list__block.is-highlighted ~ .is-multi-selected,
&.is-navigate-mode .block-editor-block-list__block.is-selected,
& .is-block-moving-mode.block-editor-block-list__block.has-child-selected,
- .block-editor-block-list__block:not([contenteditable]):focus {
+ .block-editor-block-list__block:not([contenteditable]):not(.hide-block-border):focus {
outline: none;
// We're using a pseudo element to overflow placeholder borders
diff --git a/packages/block-editor/src/components/block-list/use-block-props/use-block-class-names.js b/packages/block-editor/src/components/block-list/use-block-props/use-block-class-names.js
index fce94b85f91190..c960bd21876028 100644
--- a/packages/block-editor/src/components/block-list/use-block-props/use-block-class-names.js
+++ b/packages/block-editor/src/components/block-list/use-block-props/use-block-class-names.js
@@ -13,6 +13,7 @@ import { isReusableBlock, getBlockType } from '@wordpress/blocks';
* Internal dependencies
*/
import { store as blockEditorStore } from '../../../store';
+import { unlock } from '../../../lock-unlock';
/**
* Returns the class names used for the different states of the block.
@@ -26,6 +27,7 @@ export function useBlockClassNames( clientId ) {
( select ) => {
const {
isBlockBeingDragged,
+ isBlockInterfaceHidden,
isBlockHighlighted,
isBlockSelected,
isBlockMultiSelected,
@@ -35,7 +37,7 @@ export function useBlockClassNames( clientId ) {
isTyping,
__unstableIsFullySelected,
__unstableSelectionHasUnmergeableBlock,
- } = select( blockEditorStore );
+ } = unlock( select( blockEditorStore ) );
const { outlineMode } = getSettings();
const isDragging = isBlockBeingDragged( clientId );
const isSelected = isBlockSelected( clientId );
@@ -47,6 +49,7 @@ export function useBlockClassNames( clientId ) {
checkDeep
);
const isMultiSelected = isBlockMultiSelected( clientId );
+
return classnames( {
'is-selected': isSelected,
'is-highlighted': isBlockHighlighted( clientId ),
@@ -59,6 +62,7 @@ export function useBlockClassNames( clientId ) {
'is-dragging': isDragging,
'has-child-selected': isAncestorOfSelectedBlock,
'remove-outline': isSelected && outlineMode && isTyping(),
+ 'hide-block-border': isBlockInterfaceHidden(),
} );
},
[ clientId ]
diff --git a/packages/block-editor/src/components/resizable-alignment-controls/index.js b/packages/block-editor/src/components/resizable-alignment-controls/index.js
new file mode 100644
index 00000000000000..9c4814d267b6b1
--- /dev/null
+++ b/packages/block-editor/src/components/resizable-alignment-controls/index.js
@@ -0,0 +1,237 @@
+/**
+ * WordPress dependencies
+ */
+import {
+ ResizableBox,
+ __unstableAnimatePresence as AnimatePresence,
+ __unstableMotion as motion,
+} from '@wordpress/components';
+import { useDispatch } from '@wordpress/data';
+import { getScreenRect } from '@wordpress/dom';
+import { useMemo, useRef, useState } from '@wordpress/element';
+import { isRTL } from '@wordpress/i18n';
+
+/**
+ * Internal dependencies
+ */
+import BlockAlignmentVisualizer from '../block-alignment-visualizer';
+import {
+ BlockAlignmentGuideContextProvider,
+ useDetectSnapping,
+} from '../block-alignment-visualizer/guide-context';
+import { store as blockEditorStore } from '../../store';
+import { unlock } from '../../lock-unlock';
+
+function getVisibleHandles( alignment ) {
+ if ( alignment === 'center' ) {
+ // When the image is centered, show both handles.
+ return { right: true, left: true, bottom: true, top: false };
+ }
+
+ if ( isRTL() ) {
+ // In RTL mode the image is on the right by default.
+ // Show the right handle and hide the left handle only when it is
+ // aligned left. Otherwise always show the left handle.
+ if ( alignment === 'left' ) {
+ return { right: true, left: false, bottom: true, top: false };
+ }
+ return { left: true, right: false, bottom: true, top: false };
+ }
+
+ // Show the left handle and hide the right handle only when the
+ // image is aligned right. Otherwise always show the right handle.
+ if ( alignment === 'right' ) {
+ return { left: true, right: false, bottom: true, top: false };
+ }
+ return { right: true, left: false, bottom: true, top: false };
+}
+
+/**
+ * A component that composes `ResizableBox` and `BlockAlignmentVisualizer`
+ * and configures snapping to block alignments.
+ *
+ * @param {Object} props
+ * @param {?string[]} props.allowedAlignments An optional array of allowed alignments. If not provided this will be inferred from the block supports.
+ * @param {import('react').ReactElement} props.children Children of the ResizableBox.
+ * @param {string} props.clientId The clientId of the block
+ * @param {?string} props.currentAlignment The current alignment name. Defaults to 'none'.
+ * @param {number} props.minWidth Minimum width of the resizable box.
+ * @param {number} props.maxWidth Maximum width of the resizable box.
+ * @param {number} props.minHeight Minimum height of the resizable box.
+ * @param {number} props.maxHeight Maximum height of the resizable box.
+ * @param {Function} props.onResizeStart An event handler called when resizing starts.
+ * @param {Function} props.onResizeStop An event handler called when resizing stops.
+ * @param {Function} props.onSnap Function called when alignment is set.
+ * @param {boolean} props.showHandle Whether to show the drag handle.
+ * @param {Object} props.size The current dimensions.
+ */
+function ResizableAlignmentControls( {
+ allowedAlignments,
+ children,
+ clientId,
+ currentAlignment = 'none',
+ minWidth,
+ maxWidth,
+ minHeight,
+ maxHeight,
+ onResizeStart,
+ onResizeStop,
+ onSnap,
+ showHandle,
+ size,
+} ) {
+ const resizableRef = useRef();
+ const detectSnapping = useDetectSnapping();
+ const [ isAlignmentVisualizerVisible, setIsAlignmentVisualizerVisible ] =
+ useState( false );
+ const [ snappedAlignment, setSnappedAlignment ] = useState( null );
+ const { hideBlockInterface, showBlockInterface } = unlock(
+ useDispatch( blockEditorStore )
+ );
+
+ const isSnappingExperimentEnabled =
+ window.__experimentalEnableImageBlockAlignmentSnapping;
+ const showAlignmentVisualizer =
+ isSnappingExperimentEnabled && isAlignmentVisualizerVisible;
+
+ // Compute the styles of the content when snapped or unsnapped.
+ const contentStyle = useMemo( () => {
+ if ( ! snappedAlignment ) {
+ // By default the content takes up the full width of the resizable box.
+ return { width: 'inherit' };
+ }
+
+ // Calculate the positioning of the snapped image.
+ const resizableRect = getScreenRect( resizableRef.current );
+ const alignmentRect = snappedAlignment.rect;
+ return {
+ position: 'absolute',
+ left: alignmentRect.left - resizableRect.left,
+ top: alignmentRect.top - resizableRect.top,
+ width: alignmentRect.width,
+ };
+ }, [ snappedAlignment ] );
+
+ // Because the `contentStyle` is absolutely positioned when snapping occurs
+ // the block won't have the correct height. A separate div is used to provide
+ // the correct height, calculated here.
+ const heightStyle = useMemo( () => {
+ if ( ! snappedAlignment ) {
+ // This is a bit hacky, but using `float: left` ensures the element
+ // isn't part of the layout but still gives a height to the
+ // container.
+ return { float: 'left', height: 'inherit' };
+ }
+
+ const alignmentRect = snappedAlignment.rect;
+ const aspect = size.height / size.width;
+
+ return {
+ float: 'left',
+ height: alignmentRect.width * aspect,
+ };
+ }, [ snappedAlignment, size.width, size.height ] );
+
+ return (
+ <>
+
+ { showAlignmentVisualizer && (
+
+
+
+ ) }
+
+ {
+ onResizeStart( ...resizeArgs );
+ const [ , resizeDirection, resizeElement ] = resizeArgs;
+
+ // The 'ref' prop on the `ResizableBox` component is used to expose the re-resizable API.
+ // This seems to be the only way to get a ref to the element.
+ resizableRef.current = resizeElement;
+ hideBlockInterface();
+
+ if (
+ isSnappingExperimentEnabled &&
+ ( resizeDirection === 'right' ||
+ resizeDirection === 'left' )
+ ) {
+ setIsAlignmentVisualizerVisible( true );
+ }
+ } }
+ onResize={ ( event, resizeDirection, resizableElement ) => {
+ if ( showAlignmentVisualizer ) {
+ // Detect if snapping is happening.
+ const newSnappedAlignment = detectSnapping(
+ resizableElement,
+ resizeDirection
+ );
+ if (
+ newSnappedAlignment?.name !== snappedAlignment?.name
+ ) {
+ setSnappedAlignment( newSnappedAlignment );
+ }
+ }
+ } }
+ onResizeStop={ ( ...resizeArgs ) => {
+ if ( onSnap && snappedAlignment ) {
+ onSnap( snappedAlignment?.name );
+ } else {
+ onResizeStop( ...resizeArgs );
+ }
+ if ( isSnappingExperimentEnabled ) {
+ setIsAlignmentVisualizerVisible( false );
+ }
+ showBlockInterface();
+ setSnappedAlignment( null );
+ } }
+ resizeRatio={ currentAlignment === 'center' ? 2 : 1 }
+ >
+ { showAlignmentVisualizer && (
+ <>
+
+
+ { children }
+
+ >
+ ) }
+ { ! showAlignmentVisualizer && children }
+
+ >
+ );
+}
+
+export default function ResizableAlignmentControlsWithZoneContext( {
+ ...props
+} ) {
+ return (
+
+
+
+ );
+}
diff --git a/packages/block-editor/src/hooks/dimensions.js b/packages/block-editor/src/hooks/dimensions.js
index 02c3931ef9850d..1049ea01d5eba9 100644
--- a/packages/block-editor/src/hooks/dimensions.js
+++ b/packages/block-editor/src/hooks/dimensions.js
@@ -2,8 +2,9 @@
* WordPress dependencies
*/
import { useState, useEffect, useCallback } from '@wordpress/element';
-import { useDispatch } from '@wordpress/data';
import { getBlockSupport } from '@wordpress/blocks';
+import { usePrevious } from '@wordpress/compose';
+import { useDispatch } from '@wordpress/data';
import deprecated from '@wordpress/deprecated';
/**
@@ -28,16 +29,18 @@ export const AXIAL_SIDES = [ 'vertical', 'horizontal' ];
function useVisualizer() {
const [ property, setProperty ] = useState( false );
+ const previousProperty = usePrevious( property );
const { hideBlockInterface, showBlockInterface } = unlock(
useDispatch( blockEditorStore )
);
useEffect( () => {
- if ( ! property ) {
+ if ( ! property && previousProperty ) {
showBlockInterface();
- } else {
+ }
+ if ( property && ! previousProperty ) {
hideBlockInterface();
}
- }, [ property, showBlockInterface, hideBlockInterface ] );
+ }, [ property, previousProperty, showBlockInterface, hideBlockInterface ] );
return [ property, setProperty ];
}
diff --git a/packages/block-editor/src/private-apis.js b/packages/block-editor/src/private-apis.js
index ceb6bcd0e31c02..785ecd70487ead 100644
--- a/packages/block-editor/src/private-apis.js
+++ b/packages/block-editor/src/private-apis.js
@@ -9,6 +9,7 @@ import LeafMoreMenu from './components/off-canvas-editor/leaf-more-menu';
import ResizableBoxPopover from './components/resizable-box-popover';
import { ComposedPrivateInserter as PrivateInserter } from './components/inserter';
import { PrivateListView } from './components/list-view';
+import ResizableAlignmentControls from './components/resizable-alignment-controls';
/**
* Private @wordpress/block-editor APIs.
@@ -22,4 +23,5 @@ lock( privateApis, {
PrivateInserter,
PrivateListView,
ResizableBoxPopover,
+ ResizableAlignmentControls,
} );
diff --git a/packages/block-library/src/image/image.js b/packages/block-library/src/image/image.js
index c513ede8b9fe29..47bde4282b73bd 100644
--- a/packages/block-library/src/image/image.js
+++ b/packages/block-library/src/image/image.js
@@ -10,7 +10,6 @@ import { isBlobURL } from '@wordpress/blob';
import {
ExternalLink,
PanelBody,
- ResizableBox,
Spinner,
TextareaControl,
TextControl,
@@ -30,6 +29,7 @@ import {
__experimentalImageEditor as ImageEditor,
__experimentalGetElementClassName,
__experimentalUseBorderProps as useBorderProps,
+ privateApis as blockEditorPrivateApis,
} from '@wordpress/block-editor';
import {
useEffect,
@@ -38,7 +38,7 @@ import {
useRef,
useCallback,
} from '@wordpress/element';
-import { __, sprintf, isRTL } from '@wordpress/i18n';
+import { __, sprintf } from '@wordpress/i18n';
import { getFilename } from '@wordpress/url';
import {
createBlock,
@@ -60,6 +60,7 @@ import { store as coreStore } from '@wordpress/core-data';
import { createUpgradedEmbedBlock } from '../embed/util';
import useClientWidth from './use-client-width';
import { isExternalImage } from './edit';
+import { unlock } from '../private-apis';
/**
* Module constants
@@ -81,6 +82,7 @@ export default function Image( {
clientId,
isContentLocked,
} ) {
+ const { ResizableAlignmentControls } = unlock( blockEditorPrivateApis );
const {
url = '',
alt,
@@ -300,10 +302,11 @@ export default function Image( {
} );
}
- function updateAlignment( nextAlign ) {
- const extraUpdatedAttributes = [ 'wide', 'full' ].includes( nextAlign )
- ? { width: undefined, height: undefined }
- : {};
+ function updateAlignment( nextAlign, { resetDimensions } = {} ) {
+ const extraUpdatedAttributes =
+ resetDimensions || [ 'wide', 'full' ].includes( nextAlign )
+ ? { width: undefined, height: undefined }
+ : {};
setAttributes( {
...extraUpdatedAttributes,
align: nextAlign,
@@ -560,53 +563,15 @@ export default function Image( {
// becomes available.
const maxWidthBuffer = maxWidth * 2.5;
- let showRightHandle = false;
- let showLeftHandle = false;
-
- /* eslint-disable no-lonely-if */
- // See https://github.com/WordPress/gutenberg/issues/7584.
- if ( align === 'center' ) {
- // When the image is centered, show both handles.
- showRightHandle = true;
- showLeftHandle = true;
- } else if ( isRTL() ) {
- // In RTL mode the image is on the right by default.
- // Show the right handle and hide the left handle only when it is
- // aligned left. Otherwise always show the left handle.
- if ( align === 'left' ) {
- showRightHandle = true;
- } else {
- showLeftHandle = true;
- }
- } else {
- // Show the left handle and hide the right handle only when the
- // image is aligned right. Otherwise always show the right handle.
- if ( align === 'right' ) {
- showLeftHandle = true;
- } else {
- showRightHandle = true;
- }
- }
- /* eslint-enable no-lonely-if */
-
img = (
- {
onResizeStop();
@@ -615,10 +580,20 @@ export default function Image( {
height: parseInt( currentHeight + delta.height, 10 ),
} );
} }
- resizeRatio={ align === 'center' ? 2 : 1 }
+ onSnap={ ( newAlignment ) => {
+ // When snapping, reset the image dimensions. This ensures
+ // when the image is set to align 'none', any custom dimensions
+ // are removed, and the image appears as content width.
+ updateAlignment( newAlignment, { resetDimensions: true } );
+ } }
+ showHandle={ isSelected }
+ size={ {
+ width: width ?? 'auto',
+ height: height && ! hasCustomBorder ? height : 'auto',
+ } }
>
{ img }
-
+
);
}
diff --git a/packages/dom/README.md b/packages/dom/README.md
index f87ccbb3ac731e..0aa3af77abb157 100644
--- a/packages/dom/README.md
+++ b/packages/dom/README.md
@@ -124,6 +124,19 @@ _Returns_
- `DOMRect?`: The rectangle.
+### getScreenRect
+
+Gets an element's true screen space rect, offsetting any intervening iFrames in the element's ancestry.
+
+_Parameters_
+
+- _element_ `Element`: The dom element to return the rect.
+- _rect_ `?DOMRect`: The rect to offset. Only use if you already have `element`'s rect, this will save a call to `getBoundingClientRect`.
+
+_Returns_
+
+- `DOMRect|undefined`: The rect offset by any parent iFrames.
+
### getScrollContainer
Given a DOM node, finds the closest scrollable container node or the node itself, if scrollable.
diff --git a/packages/dom/src/dom/get-screen-rect.js b/packages/dom/src/dom/get-screen-rect.js
new file mode 100644
index 00000000000000..98ab3bb1f5701e
--- /dev/null
+++ b/packages/dom/src/dom/get-screen-rect.js
@@ -0,0 +1,32 @@
+/**
+ * Gets an element's true screen space rect, offsetting any intervening iFrames
+ * in the element's ancestry.
+ *
+ * @param {Element} element The dom element to return the rect.
+ * @param {?DOMRect} rect The rect to offset. Only use if you already have `element`'s rect,
+ * this will save a call to `getBoundingClientRect`.
+ *
+ * @return {DOMRect|undefined} The rect offset by any parent iFrames.
+ */
+export default function getScreenRect( element, rect ) {
+ const frame = element?.ownerDocument?.defaultView?.frameElement;
+
+ // Return early when there's no parent iframe.
+ if ( ! frame ) {
+ return rect ?? element.getBoundingClientRect();
+ }
+
+ const frameRect = frame?.getBoundingClientRect();
+ rect = rect ?? element?.getBoundingClientRect();
+
+ const offsetRect = new window.DOMRect(
+ rect.x + ( frameRect?.left ?? 0 ),
+ rect.y + ( frameRect?.top ?? 0 ),
+ rect.width,
+ rect.height
+ );
+
+ // Perform a tail recursion and continue offsetting
+ // by the next parent iframe.
+ return getScreenRect( frame, offsetRect );
+}
diff --git a/packages/dom/src/dom/index.js b/packages/dom/src/dom/index.js
index f21ec1e4e85e66..10cc881fb5a959 100644
--- a/packages/dom/src/dom/index.js
+++ b/packages/dom/src/dom/index.js
@@ -3,6 +3,7 @@ export { default as documentHasTextSelection } from './document-has-text-selecti
export { default as documentHasUncollapsedSelection } from './document-has-uncollapsed-selection';
export { default as documentHasSelection } from './document-has-selection';
export { default as getRectangleFromRange } from './get-rectangle-from-range';
+export { default as getScreenRect } from './get-screen-rect';
export { default as getScrollContainer } from './get-scroll-container';
export { default as getOffsetParent } from './get-offset-parent';
export { default as isEntirelySelected } from './is-entirely-selected';