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';