Skip to content

Commit

Permalink
Image block: unwrap img element in editor (#68666)
Browse files Browse the repository at this point in the history
* Remove wrapper around Image block’s img

* Render only used dimension control and monopolize its conditional

* Account for potential top offset of img in figure
  • Loading branch information
stokesman authored Feb 28, 2025
1 parent c0f518b commit d2e36e3
Show file tree
Hide file tree
Showing 3 changed files with 116 additions and 135 deletions.
1 change: 1 addition & 0 deletions packages/block-library/src/image/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ export const LINK_DESTINATION_CUSTOM = 'custom';
export const NEW_TAB_REL = [ 'noreferrer', 'noopener' ];
export const ALLOWED_MEDIA_TYPES = [ 'image' ];
export const MEDIA_ID_NO_FEATURED_IMAGE_SET = 0;
export const SIZED_LAYOUTS = [ 'flex', 'grid' ];
13 changes: 0 additions & 13 deletions packages/block-library/src/image/editor.scss
Original file line number Diff line number Diff line change
Expand Up @@ -48,19 +48,6 @@ figure.wp-block-image:not(.wp-block) {
}
}

// This is necessary for the editor resize handles to accurately work on a non-floated, non-resized, small image.
.wp-block-image .components-resizable-box__container {
// Using "display: table" because:
// - it visually hides empty white space in between elements
// - it allows the element to be as wide as its contents (instead of 100% width, as it would be with `display: block`)
display: table;
img {
display: block;
width: inherit;
height: inherit;
}
}

.block-editor-block-list__block[data-type="core/image"] .block-editor-block-toolbar .block-editor-url-input__button-modal {
position: absolute;
left: 0;
Expand Down
237 changes: 115 additions & 122 deletions packages/block-library/src/image/image.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,11 @@ import {
DropdownMenu,
Popover,
} from '@wordpress/components';
import { useViewportMatch } from '@wordpress/compose';
import {
useMergeRefs,
useResizeObserver,
useViewportMatch,
} from '@wordpress/compose';
import { useSelect, useDispatch } from '@wordpress/data';
import {
BlockControls,
Expand All @@ -34,7 +38,7 @@ import {
privateApis as blockEditorPrivateApis,
BlockSettingsMenuControls,
} from '@wordpress/block-editor';
import { useEffect, useMemo, useState, useRef } from '@wordpress/element';
import { useCallback, useEffect, useMemo, useState } from '@wordpress/element';
import { __, _x, sprintf, isRTL } from '@wordpress/i18n';
import { getFilename } from '@wordpress/url';
import { getBlockBindingsSource, switchToBlockType } from '@wordpress/blocks';
Expand All @@ -54,7 +58,7 @@ import { Caption } from '../utils/caption';
* Module constants
*/
import { useToolsPanelDropdownMenuProps } from '../utils/hooks';
import { MIN_SIZE, ALLOWED_MEDIA_TYPES } from './constants';
import { MIN_SIZE, ALLOWED_MEDIA_TYPES, SIZED_LAYOUTS } from './constants';
import { evalAspectRatio } from './utils';

const { DimensionsTool, ResolutionTool } = unlock( blockEditorPrivateApis );
Expand Down Expand Up @@ -280,12 +284,22 @@ export default function Image( {
lightbox,
metadata,
} = attributes;

// The only supported unit is px, so we can parseInt to strip the px here.
const numericWidth = width ? parseInt( width, 10 ) : undefined;
const numericHeight = height ? parseInt( height, 10 ) : undefined;

const imageRef = useRef();
const [ imageElement, setImageElement ] = useState();
const [ resizeDelta, setResizeDelta ] = useState( null );
const [ pixelSize, setPixelSize ] = useState( {} );
const [ offsetTop, setOffsetTop ] = useState( 0 );
const setResizeObserved = useResizeObserver( ( [ entry ] ) => {
if ( ! resizeDelta ) {
const [ box ] = entry.borderBoxSize;
setPixelSize( { width: box.inlineSize, height: box.blockSize } );
}
// This is usually 0 unless the image height is less than the line-height.
setOffsetTop( entry.target.offsetTop );
} );
const effectResizeableBoxPlacement = useCallback( () => {
setOffsetTop( imageElement?.offsetTop ?? 0 );
}, [ imageElement ] );
const setRefs = useMergeRefs( [ setImageElement, setResizeObserved ] );
const { allowResize = true } = context;
const { getBlock, getSettings } = useSelect( blockEditorStore );

Expand Down Expand Up @@ -369,34 +383,18 @@ export default function Image( {
.catch( () => {} );
}, [ id, url, isSingleSelected, externalBlob ] );

// Get naturalWidth and naturalHeight from image ref, and fall back to loaded natural
// Get naturalWidth and naturalHeight from image, and fall back to loaded natural
// width and height. This resolves an issue in Safari where the loaded natural
// width and height is otherwise lost when switching between alignments.
// See: https://github.com/WordPress/gutenberg/pull/37210.
const { naturalWidth, naturalHeight } = useMemo( () => {
return {
naturalWidth:
imageRef.current?.naturalWidth ||
loadedNaturalWidth ||
undefined,
imageElement?.naturalWidth || loadedNaturalWidth || undefined,
naturalHeight:
imageRef.current?.naturalHeight ||
loadedNaturalHeight ||
undefined,
imageElement?.naturalHeight || loadedNaturalHeight || undefined,
};
}, [
loadedNaturalWidth,
loadedNaturalHeight,
imageRef.current?.complete,
] );

function onResizeStart() {
toggleSelection( false );
}

function onResizeStop() {
toggleSelection( true );
}
}, [ loadedNaturalWidth, loadedNaturalHeight, imageElement?.complete ] );

function onImageError() {
setHasImageErrored( true );
Expand Down Expand Up @@ -541,49 +539,49 @@ export default function Image( {

const dropdownMenuProps = useToolsPanelDropdownMenuProps();

const dimensionsControl = (
<DimensionsTool
value={ { width, height, scale, aspectRatio } }
onChange={ ( {
width: newWidth,
height: newHeight,
scale: newScale,
aspectRatio: newAspectRatio,
} ) => {
// Rebuilding the object forces setting `undefined`
// for values that are removed since setAttributes
// doesn't do anything with keys that aren't set.
setAttributes( {
// CSS includes `height: auto`, but we need
// `width: auto` to fix the aspect ratio when
// only height is set due to the width and
// height attributes set via the server.
width: ! newWidth && newHeight ? 'auto' : newWidth,
const dimensionsControl =
isResizable &&
( SIZED_LAYOUTS.includes( parentLayoutType ) ? (
<DimensionsTool
value={ { aspectRatio } }
onChange={ ( { aspectRatio: newAspectRatio } ) => {
setAttributes( {
aspectRatio: newAspectRatio,
scale: 'cover',
} );
} }
defaultAspectRatio="auto"
tools={ [ 'aspectRatio' ] }
/>
) : (
<DimensionsTool
value={ { width, height, scale, aspectRatio } }
onChange={ ( {
width: newWidth,
height: newHeight,
scale: newScale,
aspectRatio: newAspectRatio,
} );
} }
defaultScale="cover"
defaultAspectRatio="auto"
scaleOptions={ scaleOptions }
unitsOptions={ dimensionsUnitsOptions }
/>
);

const aspectRatioControl = (
<DimensionsTool
value={ { aspectRatio } }
onChange={ ( { aspectRatio: newAspectRatio } ) => {
setAttributes( {
aspectRatio: newAspectRatio,
scale: 'cover',
} );
} }
defaultAspectRatio="auto"
tools={ [ 'aspectRatio' ] }
/>
);
} ) => {
// Rebuilding the object forces setting `undefined`
// for values that are removed since setAttributes
// doesn't do anything with keys that aren't set.
setAttributes( {
// CSS includes `height: auto`, but we need
// `width: auto` to fix the aspect ratio when
// only height is set due to the width and
// height attributes set via the server.
width: ! newWidth && newHeight ? 'auto' : newWidth,
height: newHeight,
scale: newScale,
aspectRatio: newAspectRatio,
} );
} }
defaultScale="cover"
defaultAspectRatio="auto"
scaleOptions={ scaleOptions }
unitsOptions={ dimensionsUnitsOptions }
/>
) );

const resetAll = () => {
setAttributes( {
Expand All @@ -603,10 +601,7 @@ export default function Image( {
resetAll={ resetAll }
dropdownMenuProps={ dropdownMenuProps }
>
{ isResizable &&
( parentLayoutType === 'grid'
? aspectRatioControl
: dimensionsControl ) }
{ dimensionsControl }
</ToolsPanel>
</InspectorControls>
);
Expand Down Expand Up @@ -835,10 +830,7 @@ export default function Image( {
/>
</ToolsPanelItem>
) }
{ isResizable &&
( parentLayoutType === 'grid'
? aspectRatioControl
: dimensionsControl ) }
{ dimensionsControl }
{ !! imageSizeOptions.length && (
<ResolutionTool
value={ sizeSlug }
Expand Down Expand Up @@ -926,17 +918,19 @@ export default function Image( {
alt={ defaultedAlt }
onError={ onImageError }
onLoad={ onImageLoad }
ref={ imageRef }
ref={ setRefs }
className={ borderProps.className }
width={ naturalWidth }
height={ naturalHeight }
style={ {
width:
( width && height ) || aspectRatio
? '100%'
: undefined,
height:
( width && height ) || aspectRatio
? '100%'
: undefined,
aspectRatio,
...( resizeDelta
? {
width: pixelSize.width + resizeDelta.width,
height:
pixelSize.height + resizeDelta.height,
}
: { width, height } ),
objectFit: scale,
...borderProps.style,
...shadowProps.style,
Expand All @@ -953,8 +947,7 @@ export default function Image( {
<ImageEditor
id={ id }
url={ url }
width={ numericWidth }
height={ numericHeight }
{ ...pixelSize }
naturalHeight={ naturalHeight }
naturalWidth={ naturalWidth }
onSaveImage={ ( imageAttributes ) =>
Expand All @@ -967,26 +960,21 @@ export default function Image( {
/>
</ImageWrapper>
);
} else if ( ! isResizable || parentLayoutType === 'grid' ) {
img = (
<div style={ { width, height, aspectRatio } }>
<ImageWrapper href={ href }>{ img }</ImageWrapper>
</div>
);
} else {
img = <ImageWrapper href={ href }>{ img }</ImageWrapper>;
}

let resizableBox;
if (
isResizable &&
isSingleSelected &&
! isEditingImage &&
! SIZED_LAYOUTS.includes( parentLayoutType )
) {
const numericRatio = aspectRatio && evalAspectRatio( aspectRatio );
const customRatio = numericWidth / numericHeight;
const customRatio = pixelSize.width / pixelSize.height;
const naturalRatio = naturalWidth / naturalHeight;
const ratio = numericRatio || customRatio || naturalRatio || 1;
const currentWidth =
! numericWidth && numericHeight
? numericHeight * ratio
: numericWidth;
const currentHeight =
! numericHeight && numericWidth
? numericWidth / ratio
: numericHeight;

const minWidth =
naturalWidth < naturalHeight ? MIN_SIZE : MIN_SIZE * ratio;
const minHeight =
Expand Down Expand Up @@ -1032,21 +1020,17 @@ export default function Image( {
}
}
/* eslint-enable no-lonely-if */
img = (
resizableBox = (
<ResizableBox
ref={ effectResizeableBoxPlacement }
style={ {
display: 'block',
objectFit: scale,
aspectRatio:
! width && ! height && aspectRatio
? aspectRatio
: undefined,
} }
size={ {
width: currentWidth ?? 'auto',
height: currentHeight ?? 'auto',
position: 'absolute',
// To match the vertical-align: bottom of the img (from style.scss)
// syncs the top with the img. This matters when the img height is
// less than the line-height.
inset: `${ offsetTop }px 0 0 0`,
} }
showHandle={ isSingleSelected }
size={ pixelSize }
minWidth={ minWidth }
maxWidth={ maxResizeWidth }
minHeight={ minHeight }
Expand All @@ -1058,9 +1042,19 @@ export default function Image( {
bottom: true,
left: showLeftHandle,
} }
onResizeStart={ onResizeStart }
onResizeStop={ ( event, direction, elt ) => {
onResizeStop();
onResizeStart={ () => {
toggleSelection( false );
} }
onResize={ ( event, direction, elt, delta ) => {
setResizeDelta( delta );
} }
onResizeStop={ ( event, direction, elt, delta ) => {
toggleSelection( true );
setResizeDelta( null );
setPixelSize( ( current ) => ( {
width: current.width + delta.width,
height: current.height + delta.height,
} ) );

// Clear hardcoded width if the resized width is close to the max-content width.
if (
Expand Down Expand Up @@ -1091,9 +1085,7 @@ export default function Image( {
} );
} }
resizeRatio={ align === 'center' ? 2 : 1 }
>
<ImageWrapper href={ href }>{ img }</ImageWrapper>
</ResizableBox>
/>
);
}

Expand Down Expand Up @@ -1139,6 +1131,7 @@ export default function Image( {
{ controls }
{ featuredImageControl }
{ img }
{ resizableBox }

<Caption
attributes={ attributes }
Expand Down

0 comments on commit d2e36e3

Please sign in to comment.