Commit a4f7cd77e2d for woocommerce
commit a4f7cd77e2dfc0d5b31d5b9b30bcc003f8b4c2fd
Author: Albert Juhé Lluveras <contact@albertjuhe.com>
Date: Mon Jul 6 19:40:05 2026 +0200
Update Product Image block to use larger thumbnails (#66191)
* Add changelog
* Update Product Image block to use larger thumbnails
* Replace imageSizing attribute with aspectRatio
* Fix Single Product previews not having the correct aspect ratio
* Types fixes
* Fix unit test
* Move away from cropped sizing
* Rename test
* Update resolveAspectRatio() signature
* Make sure PHP tests reset options
* Fix imageSizing value in tests
* Use render_product_image_block() util when possible
* Minor cleanups
* Remove unnecessary test
* Make sure All Products images keep the correct aspect ratio
* Update Product Collection 3 Columns pattern to not use imageSizing
diff --git a/plugins/woocommerce/changelog/update-product-image-use-larger-thumbnails b/plugins/woocommerce/changelog/update-product-image-use-larger-thumbnails
new file mode 100644
index 00000000000..ebfbf3e83d8
--- /dev/null
+++ b/plugins/woocommerce/changelog/update-product-image-use-larger-thumbnails
@@ -0,0 +1,4 @@
+Significance: patch
+Type: enhancement
+
+Updated Product Image block to use larger thumbnails
diff --git a/plugins/woocommerce/client/blocks/assets/js/atomic/blocks/product-elements/image/block.tsx b/plugins/woocommerce/client/blocks/assets/js/atomic/blocks/product-elements/image/block.tsx
index 29297785852..8c64f57f678 100644
--- a/plugins/woocommerce/client/blocks/assets/js/atomic/blocks/product-elements/image/block.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/atomic/blocks/product-elements/image/block.tsx
@@ -14,12 +14,7 @@ import { withProductDataContext } from '@woocommerce/shared-hocs';
import { useStoreEvents } from '@woocommerce/base-context/hooks';
import type { HTMLAttributes } from 'react';
import { decodeEntities } from '@wordpress/html-entities';
-import {
- isString,
- objectHasProp,
- isEmpty,
- ProductResponseItem,
-} from '@woocommerce/types';
+import { isEmpty, ProductResponseItem } from '@woocommerce/types';
import { ProductEntityResponse } from '@woocommerce/entities';
/**
@@ -27,8 +22,8 @@ import { ProductEntityResponse } from '@woocommerce/entities';
*/
import ProductSaleBadge from '../sale-badge/block';
import './style.scss';
-import { BlockAttributes, ImageSizing, ProductImageContext } from './types';
-import { isTryingToDisplayLegacySaleBadge } from './utils';
+import { BlockAttributes, ProductImageContext } from './types';
+import { isTryingToDisplayLegacySaleBadge, resolveAspectRatio } from './utils';
const buildStyles = ( props: Partial< ImageProps > ) => {
const { aspectRatio, height, width, scale } = props;
@@ -58,16 +53,12 @@ const chooseImage = ( product: ProductResponseItem, imageId?: number ) => {
const ImagePlaceholder = ( props: {
style?: Record< string, unknown >;
- showFullSize: boolean;
} ): JSX.Element => {
- const { showFullSize, ...restProps } = props;
- const src = showFullSize
- ? getSetting( 'placeholderImgSrcFullSize', PLACEHOLDER_IMG_SRC )
- : PLACEHOLDER_IMG_SRC;
+ const src = getSetting( 'placeholderImgSrcFullSize', PLACEHOLDER_IMG_SRC );
return (
<img
- { ...restProps }
+ { ...props }
src={ src }
// Decorative image with no value, so alt should be empty.
alt=""
@@ -88,7 +79,6 @@ interface ImageProps {
thumbnail?: string | undefined;
};
loaded: boolean;
- showFullSize: boolean;
fallbackAlt: string;
scale: 'cover' | 'contain' | 'fill';
width?: string | undefined;
@@ -99,19 +89,19 @@ interface ImageProps {
const Image = ( {
image,
loaded,
- showFullSize,
fallbackAlt,
width,
scale,
height,
aspectRatio,
}: ImageProps ): JSX.Element => {
- const { thumbnail, src, srcset, sizes, alt } = image || {};
+ const { src, srcset, sizes, alt } = image || {};
const imageProps = {
alt: alt || fallbackAlt,
hidden: ! loaded,
- src: showFullSize ? src : thumbnail,
- ...( showFullSize && { srcSet: srcset, sizes } ),
+ src,
+ srcSet: srcset,
+ sizes,
};
const imageStyles = buildStyles( {
@@ -122,12 +112,7 @@ const Image = ( {
} );
if ( ! image ) {
- return (
- <ImagePlaceholder
- showFullSize={ showFullSize }
- style={ imageStyles }
- />
- );
+ return <ImagePlaceholder style={ imageStyles } />;
}
return (
@@ -142,7 +127,7 @@ const Image = ( {
type Props = BlockAttributes &
Pick< ProductImageContext, 'imageId' > &
- HTMLAttributes< HTMLDivElement > & {
+ Omit< HTMLAttributes< HTMLDivElement >, 'style' > & {
isAdmin?: boolean;
product?: ProductResponseItem | ProductEntityResponse;
isResolving?: boolean;
@@ -173,7 +158,7 @@ export const Block = ( props: Props ): JSX.Element | null => {
className,
height,
imageId,
- imageSizing = ImageSizing.SINGLE,
+ imageSizing,
scale,
showProductLink = true,
style,
@@ -193,13 +178,16 @@ export const Block = ( props: Props ): JSX.Element | null => {
} );
const { dispatchStoreEvent } = useStoreEvents();
- const showFullSize = imageSizing !== ImageSizing.THUMBNAIL;
- const finalAspectRatio =
- objectHasProp( style, 'dimensions' ) &&
- objectHasProp( style.dimensions, 'aspectRatio' ) &&
- isString( style.dimensions.aspectRatio )
- ? style.dimensions.aspectRatio
- : aspectRatio;
+ const storeAspectRatio = getSetting< string | null >(
+ 'thumbnailAspectRatio',
+ null
+ );
+ const finalAspectRatio = resolveAspectRatio(
+ style,
+ aspectRatio,
+ storeAspectRatio,
+ imageSizing
+ );
const aspectRatioClass = `wc-block-components-product-image--aspect-ratio-${
finalAspectRatio ? finalAspectRatio.replace( '/', '-' ) : 'auto'
}`;
@@ -227,10 +215,7 @@ export const Block = ( props: Props ): JSX.Element | null => {
) }
style={ styleProps.style }
>
- <ImagePlaceholder
- showFullSize={ showFullSize }
- style={ imageStyles }
- />
+ <ImagePlaceholder style={ imageStyles } />
</div>
{ children }
</>
@@ -289,7 +274,6 @@ export const Block = ( props: Props ): JSX.Element | null => {
fallbackAlt={ decodeEntities( product.name ) }
image={ image }
loaded={ ! isLoading }
- showFullSize={ showFullSize }
width={ width }
height={ height }
scale={ scale }
diff --git a/plugins/woocommerce/client/blocks/assets/js/atomic/blocks/product-elements/image/edit.tsx b/plugins/woocommerce/client/blocks/assets/js/atomic/blocks/product-elements/image/edit.tsx
index b734ff41465..52450e466c9 100644
--- a/plugins/woocommerce/client/blocks/assets/js/atomic/blocks/product-elements/image/edit.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/atomic/blocks/product-elements/image/edit.tsx
@@ -9,26 +9,12 @@ import {
useInnerBlocksProps,
store as blockEditorStore,
} from '@wordpress/block-editor';
-import {
- createInterpolateElement,
- useEffect,
- useRef,
-} from '@wordpress/element';
-import { getAdminLink, getSettingWithCoercion } from '@woocommerce/settings';
+import { useEffect, useRef } from '@wordpress/element';
import { useProduct } from '@woocommerce/entities';
-import { isBoolean } from '@woocommerce/types';
import type { BlockEditProps } from '@wordpress/blocks';
import { ProductQueryContext as Context } from '@woocommerce/blocks/product-query/types';
import {
ToggleControl,
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
- // @ts-ignore - Ignoring because `__experimentalToggleGroupControl` is not yet in the type definitions.
- // eslint-disable-next-line @wordpress/no-unsafe-wp-apis
- __experimentalToggleGroupControl as ToggleGroupControl,
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
- // @ts-ignore - Ignoring because `__experimentalToggleGroupControl` is not yet in the type definitions.
- // eslint-disable-next-line @wordpress/no-unsafe-wp-apis
- __experimentalToggleGroupControlOption as ToggleGroupControlOption,
// eslint-disable-next-line @wordpress/no-unsafe-wp-apis
__experimentalToolsPanel as ToolsPanel,
// eslint-disable-next-line @wordpress/no-unsafe-wp-apis
@@ -40,7 +26,7 @@ import {
*/
import Block from './block';
import { useIsDescendentOfSingleProductBlock } from '../shared/use-is-descendent-of-single-product-block';
-import { BlockAttributes, ImageSizing } from './types';
+import { BlockAttributes } from './types';
import { ImageSizeSettings } from './image-size-settings';
const TEMPLATE = [
@@ -54,7 +40,6 @@ const TEMPLATE = [
const DEFAULT_ATTRIBUTES = {
showProductLink: true,
- imageSizing: ImageSizing.SINGLE,
};
const Edit = ( {
@@ -63,7 +48,7 @@ const Edit = ( {
context,
clientId,
}: BlockEditProps< BlockAttributes > & { context: Context } ): JSX.Element => {
- const { showProductLink, imageSizing, width, height, scale } = attributes;
+ const { showProductLink, width, height, scale } = attributes;
const ref = useRef< HTMLDivElement >( null );
@@ -123,12 +108,6 @@ const Edit = ( {
}
);
- const isBlockTheme = getSettingWithCoercion(
- 'isBlockTheme',
- false,
- isBoolean
- );
-
const { product, isResolving } = useProduct( context.postId );
return (
@@ -149,7 +128,6 @@ const Edit = ( {
setAttributes( {
showProductLink:
DEFAULT_ATTRIBUTES.showProductLink,
- imageSizing: DEFAULT_ATTRIBUTES.imageSizing,
} )
}
>
@@ -188,60 +166,6 @@ const Edit = ( {
}
/>
</ToolsPanelItem>
- <ToolsPanelItem
- label={ __( 'Resolution', 'woocommerce' ) }
- hasValue={ () =>
- imageSizing !== DEFAULT_ATTRIBUTES.imageSizing
- }
- onDeselect={ () =>
- setAttributes( {
- imageSizing: DEFAULT_ATTRIBUTES.imageSizing,
- } )
- }
- isShownByDefault
- >
- <ToggleGroupControl
- __next40pxDefaultSize
- __nextHasNoMarginBottom
- label={ __( 'Resolution', 'woocommerce' ) }
- isBlock
- help={
- ! isBlockTheme
- ? createInterpolateElement(
- __(
- 'Product image cropping can be modified in the <a>Customizer</a>.',
- 'woocommerce'
- ),
- {
- a: (
- // eslint-disable-next-line jsx-a11y/anchor-has-content
- <a
- href={ `${ getAdminLink(
- 'customize.php'
- ) }?autofocus[panel]=woocommerce&autofocus[section]=woocommerce_product_images` }
- target="_blank"
- rel="noopener noreferrer"
- />
- ),
- }
- )
- : null
- }
- value={ imageSizing }
- onChange={ ( value: ImageSizing ) =>
- setAttributes( { imageSizing: value } )
- }
- >
- <ToggleGroupControlOption
- value={ ImageSizing.SINGLE }
- label={ __( 'Full Size', 'woocommerce' ) }
- />
- <ToggleGroupControlOption
- value={ ImageSizing.THUMBNAIL }
- label={ __( 'Thumbnail', 'woocommerce' ) }
- />
- </ToggleGroupControl>
- </ToolsPanelItem>
</ToolsPanel>
</InspectorControls>
) }
diff --git a/plugins/woocommerce/client/blocks/assets/js/atomic/blocks/product-elements/image/test/block.test.tsx b/plugins/woocommerce/client/blocks/assets/js/atomic/blocks/product-elements/image/test/block.test.tsx
index 9711bef9d7a..404df4ed43b 100644
--- a/plugins/woocommerce/client/blocks/assets/js/atomic/blocks/product-elements/image/test/block.test.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/atomic/blocks/product-elements/image/test/block.test.tsx
@@ -3,6 +3,7 @@
*/
import { render, fireEvent } from '@testing-library/react';
import { ProductDataContextProvider } from '@woocommerce/shared-context';
+import { getSetting } from '@woocommerce/settings';
import { ProductResponseItem } from '@woocommerce/types';
/**
@@ -27,6 +28,9 @@ jest.mock( '@woocommerce/settings', () => {
if ( key === 'placeholderImgSrcFullSize' ) {
return 'placeholder-full-size.jpg';
}
+ if ( key === 'thumbnailAspectRatio' ) {
+ return '1/1';
+ }
// Use the original getSetting for other keys
return originalModule.getSetting( key, defaultValue );
} ),
@@ -300,4 +304,120 @@ describe( 'Product Image Block', () => {
expect( placeholderImage.getAttribute( 'height' ) ).toBe( null );
} );
} );
+
+ describe( 'aspect ratio', () => {
+ test( 'uses full-size image src even when imageSizing is thumbnail', () => {
+ const component = render(
+ <ProductDataContextProvider
+ product={ productWithImages }
+ isLoading={ false }
+ >
+ <Block
+ showProductLink={ true }
+ productId={ productWithImages.id }
+ showSaleBadge={ false }
+ saleBadgeAlign={ 'left' }
+ imageSizing={ ImageSizing.THUMBNAIL }
+ isDescendentOfQueryLoop={ false }
+ />
+ </ProductDataContextProvider>
+ );
+
+ const image = component.getByTestId( 'product-image' );
+ fireEvent.load( image );
+
+ expect( image.getAttribute( 'src' ) ).toBe(
+ productWithImages.images[ 0 ].src
+ );
+ expect( image.getAttribute( 'src' ) ).not.toBe(
+ productWithImages.images[ 0 ].thumbnail
+ );
+ } );
+
+ test( 'applies store thumbnail aspect ratio when imageSizing is thumbnail', () => {
+ const component = render(
+ <ProductDataContextProvider
+ product={ productWithImages }
+ isLoading={ false }
+ >
+ <Block
+ showProductLink={ true }
+ productId={ productWithImages.id }
+ showSaleBadge={ false }
+ saleBadgeAlign={ 'left' }
+ imageSizing={ ImageSizing.THUMBNAIL }
+ isDescendentOfQueryLoop={ false }
+ />
+ </ProductDataContextProvider>
+ );
+
+ const image = component.getByTestId( 'product-image' );
+ expect( image.style.aspectRatio ).toBe( '1/1' );
+ expect(
+ component.container.querySelector(
+ '.wc-block-components-product-image--aspect-ratio-1-1'
+ )
+ ).not.toBeNull();
+ } );
+
+ test( 'block aspect ratio overrides store thumbnail aspect ratio', () => {
+ const component = render(
+ <ProductDataContextProvider
+ product={ productWithImages }
+ isLoading={ false }
+ >
+ <Block
+ showProductLink={ true }
+ productId={ productWithImages.id }
+ showSaleBadge={ false }
+ saleBadgeAlign={ 'left' }
+ imageSizing={ ImageSizing.THUMBNAIL }
+ isDescendentOfQueryLoop={ false }
+ aspectRatio="3/5"
+ />
+ </ProductDataContextProvider>
+ );
+
+ const image = component.getByTestId( 'product-image' );
+ expect( image.style.aspectRatio ).toBe( '3/5' );
+ } );
+
+ test( 'uses auto aspect ratio class when store cropping is uncropped', () => {
+ ( getSetting as jest.Mock ).mockImplementation(
+ ( key, defaultValue ) => {
+ if ( key === 'placeholderImgSrcFullSize' ) {
+ return 'placeholder-full-size.jpg';
+ }
+ if ( key === 'thumbnailAspectRatio' ) {
+ return null;
+ }
+ return defaultValue;
+ }
+ );
+
+ const component = render(
+ <ProductDataContextProvider
+ product={ productWithImages }
+ isLoading={ false }
+ >
+ <Block
+ showProductLink={ true }
+ productId={ productWithImages.id }
+ showSaleBadge={ false }
+ saleBadgeAlign={ 'left' }
+ imageSizing={ ImageSizing.THUMBNAIL }
+ isDescendentOfQueryLoop={ false }
+ />
+ </ProductDataContextProvider>
+ );
+
+ const image = component.getByTestId( 'product-image' );
+ expect( image.style.aspectRatio ).toBe( '' );
+ expect(
+ component.container.querySelector(
+ '.wc-block-components-product-image--aspect-ratio-auto'
+ )
+ ).not.toBeNull();
+ } );
+ } );
} );
diff --git a/plugins/woocommerce/client/blocks/assets/js/atomic/blocks/product-elements/image/test/utils.test.ts b/plugins/woocommerce/client/blocks/assets/js/atomic/blocks/product-elements/image/test/utils.test.ts
new file mode 100644
index 00000000000..a401c14e5c7
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/atomic/blocks/product-elements/image/test/utils.test.ts
@@ -0,0 +1,57 @@
+/**
+ * Internal dependencies
+ */
+import { resolveAspectRatio } from '../utils';
+import { ImageSizing } from '../types';
+
+describe( 'resolveAspectRatio', () => {
+ it( 'uses style.dimensions.aspectRatio when set', () => {
+ expect(
+ resolveAspectRatio(
+ { dimensions: { aspectRatio: '16/9' } },
+ '1/1',
+ '4/3',
+ ImageSizing.THUMBNAIL
+ )
+ ).toBe( '16/9' );
+ } );
+
+ it( 'uses aspectRatio attribute when dimensions are not set', () => {
+ expect(
+ resolveAspectRatio( undefined, '3/5', '1/1', ImageSizing.THUMBNAIL )
+ ).toBe( '3/5' );
+ } );
+
+ it( 'falls back to store aspect ratio when no block override is set', () => {
+ expect(
+ resolveAspectRatio(
+ undefined,
+ undefined,
+ '4/3',
+ ImageSizing.THUMBNAIL
+ )
+ ).toBe( '4/3' );
+ } );
+
+ it( 'returns undefined when store aspect ratio is null (uncropped)', () => {
+ expect(
+ resolveAspectRatio(
+ undefined,
+ undefined,
+ null,
+ ImageSizing.THUMBNAIL
+ )
+ ).toBeUndefined();
+ } );
+
+ it( 'returns undefined when imageSizing is not thumbnail', () => {
+ expect(
+ resolveAspectRatio(
+ undefined,
+ undefined,
+ '4/3',
+ ImageSizing.SINGLE
+ )
+ ).toBeUndefined();
+ } );
+} );
diff --git a/plugins/woocommerce/client/blocks/assets/js/atomic/blocks/product-elements/image/types.ts b/plugins/woocommerce/client/blocks/assets/js/atomic/blocks/product-elements/image/types.ts
index 22d5d99d3a5..2e318fde042 100644
--- a/plugins/woocommerce/client/blocks/assets/js/atomic/blocks/product-elements/image/types.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/atomic/blocks/product-elements/image/types.ts
@@ -8,6 +8,12 @@ export enum ImageSizing {
THUMBNAIL = 'thumbnail',
}
+export type AspectRatioStyle = {
+ dimensions?: {
+ aspectRatio?: string;
+ };
+};
+
export interface BlockAttributes {
// The product ID.
productId: number;
@@ -33,6 +39,8 @@ export interface BlockAttributes {
scale: 'cover' | 'contain' | 'fill';
// Aspect ratio of the image.
aspectRatio: string;
+ // Block style from dimensions support (not React CSSProperties).
+ style?: AspectRatioStyle;
}
export interface ProductImageContext extends ProductQueryContext {
diff --git a/plugins/woocommerce/client/blocks/assets/js/atomic/blocks/product-elements/image/utils.ts b/plugins/woocommerce/client/blocks/assets/js/atomic/blocks/product-elements/image/utils.ts
index eca4523de44..2fa825d4c5f 100644
--- a/plugins/woocommerce/client/blocks/assets/js/atomic/blocks/product-elements/image/utils.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/atomic/blocks/product-elements/image/utils.ts
@@ -1,3 +1,8 @@
+/**
+ * Internal dependencies
+ */
+import { AspectRatioStyle, ImageSizing } from './types';
+
export const isTryingToDisplayLegacySaleBadge = ( showSaleBadge?: boolean ) => {
// If the block is pristine, it doesn't have a showSaleBadge attribute
// but it is supposed to be `true` by default.
@@ -9,3 +14,34 @@ export const isTryingToDisplayLegacySaleBadge = ( showSaleBadge?: boolean ) => {
// that we should respect.
return showSaleBadge;
};
+
+/**
+ * Resolve the aspect ratio for a product image.
+ *
+ * Block-level overrides take priority over store thumbnail cropping settings.
+ */
+export const resolveAspectRatio = (
+ style: AspectRatioStyle | undefined,
+ aspectRatio: string | undefined,
+ storeAspectRatio: string | null | undefined,
+ imageSizing: ImageSizing | undefined
+): string | undefined => {
+ if (
+ style &&
+ style.dimensions &&
+ style.dimensions.aspectRatio &&
+ typeof style.dimensions.aspectRatio === 'string'
+ ) {
+ return style.dimensions.aspectRatio;
+ }
+
+ if ( aspectRatio && typeof aspectRatio === 'string' ) {
+ return aspectRatio;
+ }
+
+ if ( imageSizing && imageSizing === ImageSizing.THUMBNAIL ) {
+ return storeAspectRatio ?? undefined;
+ }
+
+ return undefined;
+};
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/constants.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/constants.ts
index 88a71288cff..6826b519786 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/constants.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/constants.ts
@@ -23,7 +23,6 @@ import {
LayoutOptions,
WidthOptions,
} from './types';
-import { ImageSizing } from '../../atomic/blocks/product-elements/image/types';
export const PRODUCT_COLLECTION_BLOCK_NAME = blockJson.name;
const PRODUCT_TITLE_NAME = `${ PRODUCT_COLLECTION_BLOCK_NAME }/product-title`;
@@ -131,8 +130,12 @@ export const INNER_BLOCKS_PRODUCT_TEMPLATE: InnerBlockTemplate = [
[
'woocommerce/product-image',
{
- imageSizing: ImageSizing.THUMBNAIL,
showSaleBadge: false,
+ style: {
+ dimensions: {
+ aspectRatio: '1/1',
+ },
+ },
},
[
[
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-query/constants.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-query/constants.ts
index a5d0fc5c8d4..c579c76fedc 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-query/constants.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-query/constants.ts
@@ -11,7 +11,6 @@ import { isBoolean } from '@woocommerce/types';
import { QueryBlockAttributes } from './types';
import { VARIATION_NAME as PRODUCT_TITLE_ID } from './variations/elements/product-title';
import { VARIATION_NAME as PRODUCT_TEMPLATE_ID } from './variations/elements/product-template';
-import { ImageSizing } from '../../atomic/blocks/product-elements/image/types';
export const PRODUCT_QUERY_VARIATION_NAME = 'woocommerce/product-query';
@@ -95,7 +94,11 @@ export const INNER_BLOCKS_TEMPLATE: InnerBlockTemplate[] = [
[
'woocommerce/product-image',
{
- imageSizing: ImageSizing.THUMBNAIL,
+ style: {
+ dimensions: {
+ aspectRatio: '1/1',
+ },
+ },
},
],
[
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-query/variations/related-products.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/product-query/variations/related-products.tsx
index ed9114f16c3..2a117ac18e0 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-query/variations/related-products.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-query/variations/related-products.tsx
@@ -73,7 +73,11 @@ export const INNER_BLOCKS_TEMPLATE: InnerBlockTemplate[] = [
'woocommerce/product-image',
{
productId: 0,
- imageSizing: 'cropped',
+ style: {
+ dimensions: {
+ aspectRatio: '1/1',
+ },
+ },
},
],
[
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/products/base-utils.js b/plugins/woocommerce/client/blocks/assets/js/blocks/products/base-utils.js
index 137e70e4f26..84b2bf23fff 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/products/base-utils.js
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/products/base-utils.js
@@ -7,13 +7,21 @@ import clsx from 'clsx';
* Internal dependencies
*/
import addToCartButtonMetadata from '../../atomic/blocks/product-elements/button/block.json';
-import { ImageSizing } from '../../atomic/blocks/product-elements/image/types';
/**
* The default layout built from the default template.
*/
export const DEFAULT_PRODUCT_LIST_LAYOUT = [
- [ 'woocommerce/product-image', { imageSizing: ImageSizing.THUMBNAIL } ],
+ [
+ 'woocommerce/product-image',
+ {
+ style: {
+ dimensions: {
+ aspectRatio: '1/1',
+ },
+ },
+ },
+ ],
[ 'woocommerce/product-title' ],
[ 'woocommerce/product-price' ],
[ 'woocommerce/product-rating' ],
diff --git a/plugins/woocommerce/patterns/product-collection-3-columns.php b/plugins/woocommerce/patterns/product-collection-3-columns.php
index ccc20d5a3b2..10069655a85 100644
--- a/plugins/woocommerce/patterns/product-collection-3-columns.php
+++ b/plugins/woocommerce/patterns/product-collection-3-columns.php
@@ -11,7 +11,7 @@
<!-- wp:woocommerce/product-collection {"query":{"perPage":3,"pages":0,"offset":0,"postType":"product","order":"asc","orderBy":"title","search":"","exclude":[],"inherit":false,"taxQuery":{},"isProductCollectionBlock":true,"woocommerceOnSale":false,"woocommerceStockStatus":["instock","outofstock","onbackorder"],"woocommerceAttributes":[],"woocommerceHandPickedProducts":[]},"tagName":"div","dimensions":{"widthType":"fill","fixedWidth":""},"displayLayout":{"type":"flex","columns":3},"align":"wide"} -->
<div class="wp-block-woocommerce-product-collection alignwide">
<!-- wp:woocommerce/product-template -->
- <!-- wp:woocommerce/product-image {"showSaleBadge":false,"aspectRatio":"3/5","imageSizing":"single","isDescendentOfQueryLoop":true} -->
+ <!-- wp:woocommerce/product-image {"showSaleBadge":false,"aspectRatio":"3/5","isDescendentOfQueryLoop":true} -->
<!-- wp:woocommerce/product-sale-badge {"isDescendentOfQueryLoop":true,"align":"right"} /-->
<!-- /wp:woocommerce/product-image -->
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/AllProducts.php b/plugins/woocommerce/src/Blocks/BlockTypes/AllProducts.php
index f33142b14d0..a86664180bb 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/AllProducts.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/AllProducts.php
@@ -29,6 +29,7 @@ class AllProducts extends AbstractBlock {
$this->asset_data_registry->add( 'minRows', wc_get_theme_support( 'product_blocks::min_rows', 1 ) );
$this->asset_data_registry->add( 'maxRows', wc_get_theme_support( 'product_blocks::max_rows', 6 ) );
$this->asset_data_registry->add( 'defaultRows', wc_get_theme_support( 'product_blocks::default_rows', 3 ) );
+ $this->asset_data_registry->add( 'thumbnailAspectRatio', $this->get_store_thumbnail_aspect_ratio() );
// Hydrate the All Product block with data from the API. This is for the add to cart buttons which show current quantity in cart, and events.
if ( ! is_admin() && ! WC()->is_rest_api_request() ) {
@@ -43,4 +44,27 @@ class AllProducts extends AbstractBlock {
parent::register_block_type_assets();
$this->register_chunk_translations( [ $this->block_name ] );
}
+
+ /**
+ * Get the store thumbnail aspect ratio from WooCommerce Customizer settings.
+ * This method is a copy from the one in ProductImage.php.
+ *
+ * @return string|null CSS aspect ratio value (e.g. "1/1", "4/3"), or null when uncropped.
+ */
+ private function get_store_thumbnail_aspect_ratio() {
+ $cropping = get_option( 'woocommerce_thumbnail_cropping', '1:1' );
+
+ if ( 'uncropped' === $cropping ) {
+ return null;
+ }
+
+ if ( 'custom' === $cropping ) {
+ $width = max( 1, (float) get_option( 'woocommerce_thumbnail_cropping_custom_width', '4' ) );
+ $height = max( 1, (float) get_option( 'woocommerce_thumbnail_cropping_custom_height', '3' ) );
+
+ return $width . '/' . $height;
+ }
+
+ return str_replace( ':', '/', $cropping );
+ }
}
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductImage.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductImage.php
index 0263d38edca..4c75a87b1d6 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductImage.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductImage.php
@@ -123,6 +123,54 @@ class ProductImage extends AbstractBlock {
);
}
+ /**
+ * Get the store thumbnail aspect ratio from WooCommerce Customizer settings.
+ *
+ * @return string|null CSS aspect ratio value (e.g. "1/1", "4/3"), or null when uncropped.
+ */
+ private function get_store_thumbnail_aspect_ratio() {
+ $cropping = get_option( 'woocommerce_thumbnail_cropping', '1:1' );
+
+ if ( 'uncropped' === $cropping ) {
+ return null;
+ }
+
+ if ( 'custom' === $cropping ) {
+ $width = max( 1, (float) get_option( 'woocommerce_thumbnail_cropping_custom_width', '4' ) );
+ $height = max( 1, (float) get_option( 'woocommerce_thumbnail_cropping_custom_height', '3' ) );
+
+ return $width . '/' . $height;
+ }
+
+ return str_replace( ':', '/', $cropping );
+ }
+
+ /**
+ * Resolve the aspect ratio for a product image.
+ *
+ * Block-level overrides take priority over store thumbnail cropping settings.
+ *
+ * @param array $attributes Parsed block attributes.
+ * @return string|null CSS aspect ratio value, or null when no ratio should be applied.
+ */
+ private function resolve_aspect_ratio( $attributes ) {
+ if ( ! empty( $attributes['style']['dimensions']['aspectRatio'] ) ) {
+ return $attributes['style']['dimensions']['aspectRatio'];
+ }
+
+ if ( ! empty( $attributes['aspectRatio'] ) ) {
+ return $attributes['aspectRatio'];
+ }
+
+ // For backwards compatibility, we interpret "thumbnail" as following
+ // the store thumbnail cropping settings.
+ if ( 'thumbnail' === $attributes['imageSizing'] ) {
+ return $this->get_store_thumbnail_aspect_ratio();
+ }
+
+ return null;
+ }
+
/**
* Render Image.
*
@@ -132,8 +180,7 @@ class ProductImage extends AbstractBlock {
* @return string
*/
private function render_image( $product, $attributes, $image_id = null ) {
- $image_size = 'single' === $attributes['imageSizing'] ? 'woocommerce_single' : 'woocommerce_thumbnail';
-
+ $image_size = 'large';
$image_style = '';
if ( ! empty( $attributes['height'] ) ) {
@@ -146,13 +193,9 @@ class ProductImage extends AbstractBlock {
$image_style .= sprintf( 'object-fit:%s;', $attributes['scale'] );
}
- // Keep this aspect ratio for backward compatibility.
- if ( ! empty( $attributes['aspectRatio'] ) ) {
- $image_style .= sprintf( 'aspect-ratio:%s;', $attributes['aspectRatio'] );
- }
-
- if ( ! empty( $attributes['style']['dimensions']['aspectRatio'] ) ) {
- $image_style .= sprintf( 'aspect-ratio:%s;', $attributes['style']['dimensions']['aspectRatio'] );
+ $aspect_ratio = $this->resolve_aspect_ratio( $attributes );
+ if ( $aspect_ratio ) {
+ $image_style .= sprintf( 'aspect-ratio:%s;', $aspect_ratio );
}
if ( ! empty( $attributes['style']['dimensions']['minHeight'] ) ) {
@@ -227,8 +270,8 @@ class ProductImage extends AbstractBlock {
* not in the post content on editor load.
*/
protected function enqueue_data( array $attributes = [] ) {
- $this->asset_data_registry->add( 'isBlockTheme', wp_is_block_theme() );
$this->asset_data_registry->add( 'placeholderImgSrcFullSize', wc_placeholder_img_src( 'woocommerce_single' ) );
+ $this->asset_data_registry->add( 'thumbnailAspectRatio', $this->get_store_thumbnail_aspect_ratio() );
}
/**
@@ -240,13 +283,13 @@ class ProductImage extends AbstractBlock {
* @return string Rendered block type output.
*/
protected function render( $attributes, $content, $block ) {
- $parsed_attributes = $this->parse_attributes( $attributes );
- $classes_and_styles = StyleAttributesUtils::get_classes_and_styles_by_attributes( $attributes, array(), array( 'extra_classes' ) );
- $post_id = isset( $block->context['postId'] ) ? $block->context['postId'] : '';
- $image_id = isset( $block->context['imageId'] ) ? (int) $block->context['imageId'] : null;
- $product = wc_get_product( $post_id );
- $aspect_ratio = $parsed_attributes['aspectRatio'] ?? $parsed_attributes['style']['dimensions']['aspectRatio'] ?? 'auto';
- $aspect_ratio_class = 'wc-block-components-product-image--aspect-ratio-' . str_replace( '/', '-', $aspect_ratio );
+ $parsed_attributes = $this->parse_attributes( $attributes );
+ $classes_and_styles = StyleAttributesUtils::get_classes_and_styles_by_attributes( $attributes, array(), array( 'extra_classes' ) );
+ $post_id = isset( $block->context['postId'] ) ? $block->context['postId'] : '';
+ $image_id = isset( $block->context['imageId'] ) ? (int) $block->context['imageId'] : null;
+ $product = wc_get_product( $post_id );
+ $resolved_aspect_ratio = $this->resolve_aspect_ratio( $parsed_attributes );
+ $aspect_ratio_class = 'wc-block-components-product-image--aspect-ratio-' . ( $resolved_aspect_ratio ? str_replace( '/', '-', $resolved_aspect_ratio ) : 'auto' );
$classes = implode(
' ',
diff --git a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductImage.php b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductImage.php
index 0c145a9c452..568449b6f0d 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductImage.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductImage.php
@@ -11,6 +11,45 @@ use WC_Helper_Product;
*/
class ProductImage extends \WP_UnitTestCase {
+ /**
+ * Previous thumbnail cropping option values to restore after tests.
+ *
+ * @var array<string, mixed>
+ */
+ private $prev_thumbnail_cropping_options = array();
+
+ /**
+ * Set up.
+ */
+ public function setUp(): void {
+ parent::setUp();
+
+ $option_names = array(
+ 'woocommerce_thumbnail_cropping',
+ 'woocommerce_thumbnail_cropping_custom_width',
+ 'woocommerce_thumbnail_cropping_custom_height',
+ );
+
+ foreach ( $option_names as $option_name ) {
+ $this->prev_thumbnail_cropping_options[ $option_name ] = get_option( $option_name, null );
+ }
+ }
+
+ /**
+ * Tear down.
+ */
+ public function tearDown(): void {
+ foreach ( $this->prev_thumbnail_cropping_options as $option_name => $value ) {
+ if ( null === $value ) {
+ delete_option( $option_name );
+ } else {
+ update_option( $option_name, $value );
+ }
+ }
+
+ parent::tearDown();
+ }
+
/**
* Helper method to create a simple product with an image.
*
@@ -99,13 +138,30 @@ class ProductImage extends \WP_UnitTestCase {
);
}
+ /**
+ * Helper to render a product image block for a product.
+ *
+ * @param \WC_Product $product Product object.
+ * @param string $block_attributes Optional JSON attributes for the block.
+ * @return string Rendered block markup.
+ */
+ private function render_product_image_block( $product, $block_attributes = '' ) {
+ $attrs = '' !== $block_attributes ? ' ' . $block_attributes : '';
+
+ return do_blocks(
+ '<!-- wp:woocommerce/single-product {"productId":' . $product->get_id() . '} -->' .
+ '<!-- wp:woocommerce/product-image' . $attrs . ' /-->' .
+ '<!-- /wp:woocommerce/single-product -->'
+ );
+ }
+
/**
* Test that the ProductImage block renders correctly for a simple product.
*/
public function test_product_image_render_simple_product() {
$data = $this->create_product_with_image();
- $markup = do_blocks( '<!-- wp:woocommerce/single-product {"productId":' . $data['product']->get_id() . '} --><!-- wp:woocommerce/product-image /--><!-- /wp:woocommerce/single-product -->' );
+ $markup = $this->render_product_image_block( $data['product'] );
$this->assertStringContainsString( 'wc-block-components-product-image', $markup );
$this->assertStringContainsString( 'data-testid="product-image"', $markup );
@@ -122,7 +178,7 @@ class ProductImage extends \WP_UnitTestCase {
public function test_product_image_render_includes_product_link_by_default() {
$data = $this->create_product_with_image();
- $markup = do_blocks( '<!-- wp:woocommerce/single-product {"productId":' . $data['product']->get_id() . '} --><!-- wp:woocommerce/product-image /--><!-- /wp:woocommerce/single-product -->' );
+ $markup = $this->render_product_image_block( $data['product'] );
$this->assertStringContainsString( '<a href="' . $data['product']->get_permalink() . '"', $markup );
$this->assertStringContainsString( 'data-wp-on--click="woocommerce/product-collection::actions.viewProduct"', $markup );
@@ -138,7 +194,7 @@ class ProductImage extends \WP_UnitTestCase {
public function test_product_image_render_omits_anchor_when_product_link_is_hidden() {
$data = $this->create_product_with_image();
- $markup = do_blocks( '<!-- wp:woocommerce/single-product {"productId":' . $data['product']->get_id() . '} --><!-- wp:woocommerce/product-image {"showProductLink":false} /--><!-- /wp:woocommerce/single-product -->' );
+ $markup = $this->render_product_image_block( $data['product'], '{"showProductLink":false}' );
$this->assertStringContainsString( 'data-testid="product-image"', $markup );
$this->assertStringNotContainsString( '<a ', $markup );
@@ -162,19 +218,19 @@ class ProductImage extends \WP_UnitTestCase {
$variation_image_id = $data['variation_image_ids'][0];
// Test that the ProductImage block recognizes the variation image when provided via context.
- $markup = do_blocks( '<!-- wp:woocommerce/single-product {"productId":' . $data['product']->get_id() . '} --><!-- wp:woocommerce/product-image {"imageId":' . $variation_image_id . '} /--><!-- /wp:woocommerce/single-product -->' );
+ $markup = $this->render_product_image_block( $data['product'], '{"imageId":' . $variation_image_id . '}' );
// The block should recognize the variation image as valid and use it.
$this->assertStringContainsString( 'data-image-id="' . $variation_image_id . '"', $markup );
$this->assertStringContainsString( 'wc-block-components-product-image', $markup );
// Test that the block falls back to the main product image when no imageId is provided.
- $markup_no_image_id = do_blocks( '<!-- wp:woocommerce/single-product {"productId":' . $data['product']->get_id() . '} --><!-- wp:woocommerce/product-image /--><!-- /wp:woocommerce/single-product -->' );
+ $markup_no_image_id = $this->render_product_image_block( $data['product'] );
$this->assertStringContainsString( 'data-image-id="' . $data['main_image_id'] . '"', $markup_no_image_id );
// Test that the block rejects invalid image IDs.
$invalid_image_id = 99999;
- $markup_invalid = do_blocks( '<!-- wp:woocommerce/single-product {"productId":' . $data['product']->get_id() . '} --><!-- wp:woocommerce/product-image {"imageId":' . $invalid_image_id . '} /--><!-- /wp:woocommerce/single-product -->' );
+ $markup_invalid = $this->render_product_image_block( $data['product'], '{"imageId":' . $invalid_image_id . '}' );
// Should fall back to main product image when invalid image ID is provided.
$this->assertStringContainsString( 'data-image-id="' . $data['main_image_id'] . '"', $markup_invalid );
@@ -188,18 +244,51 @@ class ProductImage extends \WP_UnitTestCase {
}
/**
- * Test that the ProductImage block renders correctly with different image sizing options.
+ * Test that the ProductImage block uses store thumbnail cropping aspect ratio.
+ *
+ * @testdox Should honor the store thumbnail cropping aspect ratio when imageSizing is thumbnail.
*/
- public function test_product_image_render_with_different_sizing() {
+ public function test_product_image_render_with_store_aspect_ratio() {
$data = $this->create_product_with_image();
- // Test with 'single' image sizing.
- $markup_single = do_blocks( '<!-- wp:woocommerce/single-product {"productId":' . $data['product']->get_id() . '} --><!-- wp:woocommerce/product-image {"imageSizing":"single"} /--><!-- /wp:woocommerce/single-product -->' );
- $this->assertStringContainsString( 'wc-block-components-product-image', $markup_single );
+ update_option( 'woocommerce_thumbnail_cropping', '1:1' );
+ $markup_single = $this->render_product_image_block( $data['product'], '{"imageSizing":"single"}' );
+ $markup_thumbnail = $this->render_product_image_block( $data['product'], '{"imageSizing":"thumbnail"}' );
+ $this->assertStringNotContainsString( 'aspect-ratio:1/1', $markup_single );
+ $this->assertStringNotContainsString( 'wc-block-components-product-image--aspect-ratio-1-1', $markup_single );
+ $this->assertStringContainsString( 'aspect-ratio:1/1', $markup_thumbnail );
+ $this->assertStringContainsString( 'wc-block-components-product-image--aspect-ratio-1-1', $markup_thumbnail );
+
+ update_option( 'woocommerce_thumbnail_cropping', 'custom' );
+ update_option( 'woocommerce_thumbnail_cropping_custom_width', '4' );
+ update_option( 'woocommerce_thumbnail_cropping_custom_height', '3' );
+ $markup = $this->render_product_image_block( $data['product'], '{"imageSizing":"thumbnail"}' );
+ $this->assertStringContainsString( 'aspect-ratio:4/3', $markup );
+ $this->assertStringContainsString( 'wc-block-components-product-image--aspect-ratio-4-3', $markup );
+
+ update_option( 'woocommerce_thumbnail_cropping', 'uncropped' );
+ $markup = $this->render_product_image_block( $data['product'], '{"imageSizing":"thumbnail"}' );
+ $this->assertStringNotContainsString( 'aspect-ratio:', $markup );
+ $this->assertStringContainsString( 'wc-block-components-product-image--aspect-ratio-auto', $markup );
+
+ // Clean up.
+ $data['product']->delete( true );
+ wp_delete_attachment( $data['image_id'], true );
+ }
+
+ /**
+ * Test that block aspect ratio overrides store thumbnail cropping.
+ *
+ * @testdox Should prioritize the block aspect ratio over the store thumbnail cropping aspect ratio.
+ */
+ public function test_product_image_render_with_block_aspect_ratio_override() {
+ $data = $this->create_product_with_image();
- // Test with 'thumbnail' image sizing.
- $markup_thumbnail = do_blocks( '<!-- wp:woocommerce/single-product {"productId":' . $data['product']->get_id() . '} --><!-- wp:woocommerce/product-image {"imageSizing":"thumbnail"} /--><!-- /wp:woocommerce/single-product -->' );
- $this->assertStringContainsString( 'wc-block-components-product-image', $markup_thumbnail );
+ update_option( 'woocommerce_thumbnail_cropping', '1:1' );
+ $markup = $this->render_product_image_block( $data['product'], '{"aspectRatio":"3/5","imageSizing":"thumbnail"}' );
+ $this->assertStringContainsString( 'aspect-ratio:3/5', $markup );
+ $this->assertStringContainsString( 'wc-block-components-product-image--aspect-ratio-3-5', $markup );
+ $this->assertStringNotContainsString( 'aspect-ratio:1/1', $markup );
// Clean up.
$data['product']->delete( true );
@@ -215,7 +304,7 @@ class ProductImage extends \WP_UnitTestCase {
$data['product']->set_sale_price( 5 );
$data['product']->save();
- $markup = do_blocks( '<!-- wp:woocommerce/single-product {"productId":' . $data['product']->get_id() . '} --><!-- wp:woocommerce/product-image {"showSaleBadge":true} /--><!-- /wp:woocommerce/single-product -->' );
+ $markup = $this->render_product_image_block( $data['product'], '{"showSaleBadge":true}' );
$this->assertStringContainsString( 'wc-block-components-product-image', $markup );
$this->assertStringContainsString( 'wp-block-woocommerce-product-sale-badge', $markup );
@@ -250,7 +339,7 @@ class ProductImage extends \WP_UnitTestCase {
$product = WC_Helper_Product::create_simple_product();
$product->save();
- $markup = do_blocks( '<!-- wp:woocommerce/single-product {"productId":' . $product->get_id() . '} --><!-- wp:woocommerce/product-image /--><!-- /wp:woocommerce/single-product -->' );
+ $markup = $this->render_product_image_block( $product );
$this->assertStringContainsString( 'wc-block-components-product-image', $markup );
// Should contain placeholder image.