rebase from live enviornment

This commit is contained in:
Rachit Bhargava
2024-01-09 22:14:20 -05:00
parent ff0b49a046
commit 3a22fcaa4a
15968 changed files with 2344674 additions and 45234 deletions

View File

@@ -0,0 +1,171 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import {
AlignmentToolbar,
BlockControls as BlockControlsWrapper,
MediaReplaceFlow,
} from '@wordpress/block-editor';
import { ToolbarButton, ToolbarGroup } from '@wordpress/components';
import { crop } from '@wordpress/icons';
import { WP_REST_API_Category } from 'wp-types';
import { ProductResponseItem } from '@woocommerce/types';
import type { ComponentType, Dispatch, SetStateAction } from 'react';
import type { BlockAlignment } from '@wordpress/blocks';
/**
* Internal dependencies
*/
import { useBackgroundImage } from './use-background-image';
import { EditorBlock, GenericBlockUIConfig } from './types';
type Media = { id: number; url: string };
interface WithBlockControlsRequiredProps< T > {
attributes: BlockControlRequiredAttributes &
EditorBlock< T >[ 'attributes' ];
setAttributes: ( attrs: Partial< BlockControlRequiredAttributes > ) => void;
useEditingImage: [ boolean, Dispatch< SetStateAction< boolean > > ];
}
interface WithBlockControlsCategoryProps< T >
extends WithBlockControlsRequiredProps< T > {
category: WP_REST_API_Category;
product: never;
}
interface WithBlockControlsProductProps< T >
extends WithBlockControlsRequiredProps< T > {
category: never;
product: ProductResponseItem;
}
type WithBlockControlsProps< T extends EditorBlock< T > > =
| ( T & WithBlockControlsCategoryProps< T > )
| ( T & WithBlockControlsProductProps< T > );
type BlockControlRequiredAttributes = {
contentAlign: BlockAlignment;
editMode: boolean;
mediaId: number;
mediaSrc: string;
};
interface BlockControlsProps {
backgroundImageId: number;
backgroundImageSrc: string;
contentAlign: BlockAlignment;
cropLabel: string;
editLabel: string;
editMode: boolean;
isEditingImage: boolean;
mediaSrc: string;
setAttributes: ( attrs: Partial< BlockControlRequiredAttributes > ) => void;
setIsEditingImage: ( value: boolean ) => void;
}
interface BlockControlsConfiguration extends GenericBlockUIConfig {
cropLabel: string;
editLabel: string;
}
export const BlockControls = ( {
backgroundImageId,
backgroundImageSrc,
contentAlign,
cropLabel,
editLabel,
editMode,
isEditingImage,
mediaSrc,
setAttributes,
setIsEditingImage,
}: BlockControlsProps ) => {
return (
<BlockControlsWrapper>
<AlignmentToolbar
value={ contentAlign }
onChange={ ( nextAlign: BlockAlignment ) => {
setAttributes( { contentAlign: nextAlign } );
} }
/>
<ToolbarGroup>
{ backgroundImageSrc && ! isEditingImage && (
<ToolbarButton
onClick={ () => setIsEditingImage( true ) }
icon={ crop }
label={ cropLabel }
/>
) }
<MediaReplaceFlow
mediaId={ backgroundImageId }
mediaURL={ mediaSrc }
accept="image/*"
onSelect={ ( media: Media ) => {
setAttributes( {
mediaId: media.id,
mediaSrc: media.url,
} );
} }
allowedTypes={ [ 'image' ] }
/>
{ backgroundImageId && mediaSrc ? (
<ToolbarButton
onClick={ () =>
setAttributes( { mediaId: 0, mediaSrc: '' } )
}
>
{ __( 'Reset', 'woo-gutenberg-products-block' ) }
</ToolbarButton>
) : null }
</ToolbarGroup>
<ToolbarGroup
controls={ [
{
icon: 'edit',
title: editLabel,
onClick: () =>
setAttributes( { editMode: ! editMode } ),
isActive: editMode,
},
] }
/>
</BlockControlsWrapper>
);
};
export const withBlockControls =
( { cropLabel, editLabel }: BlockControlsConfiguration ) =>
< T extends EditorBlock< T > >( Component: ComponentType< T > ) =>
( props: WithBlockControlsProps< T > ) => {
const [ isEditingImage, setIsEditingImage ] = props.useEditingImage;
const { attributes, category, name, product, setAttributes } = props;
const { contentAlign, editMode, mediaId, mediaSrc } = attributes;
const item = category || product;
const { backgroundImageId, backgroundImageSrc } = useBackgroundImage( {
item,
mediaId,
mediaSrc,
blockName: name,
} );
return (
<>
<BlockControls
backgroundImageId={ backgroundImageId }
backgroundImageSrc={ backgroundImageSrc }
contentAlign={ contentAlign }
cropLabel={ cropLabel }
editLabel={ editLabel }
editMode={ editMode }
isEditingImage={ isEditingImage }
mediaSrc={ mediaSrc }
setAttributes={ setAttributes }
setIsEditingImage={ setIsEditingImage }
/>
<Component { ...props } />
</>
);
};

View File

@@ -0,0 +1,67 @@
/**
* External dependencies
*/
import classnames from 'classnames';
import { RichText, InnerBlocks } from '@wordpress/block-editor';
import { __ } from '@wordpress/i18n';
interface CallToActionProps {
itemId: number | 'preview';
linkText: string;
permalink: string;
}
export const CallToAction = ( {
itemId,
linkText,
permalink,
}: CallToActionProps ) => {
const buttonClasses = classnames(
'wp-block-button__link',
'is-style-fill'
);
const buttonStyle = {
backgroundColor: 'vivid-green-cyan',
borderRadius: '5px',
};
const wrapperStyle = {
width: '100%',
};
return itemId === 'preview' ? (
<div className="wp-block-button aligncenter" style={ wrapperStyle }>
<RichText.Content
tagName="a"
className={ buttonClasses }
href={ permalink }
title={ linkText }
style={ buttonStyle }
value={ linkText }
target={ permalink }
/>
</div>
) : (
<InnerBlocks
template={ [
[
'core/buttons',
{
layout: { type: 'flex', justifyContent: 'center' },
},
[
[
'core/button',
{
text: __(
'Shop now',
'woo-gutenberg-products-block'
),
url: permalink,
},
],
],
],
] }
templateLock="all"
/>
);
};

View File

@@ -0,0 +1,9 @@
export const DEFAULT_EDITOR_SIZE = {
height: 500,
width: 500,
} as const;
export const BLOCK_NAMES = {
featuredCategory: 'woocommerce/featured-category',
featuredProduct: 'woocommerce/featured-product',
} as const;

View File

@@ -0,0 +1,42 @@
/**
* External dependencies
*/
import classnames from 'classnames';
import { useState } from '@wordpress/element';
import { ResizableBox } from '@wordpress/components';
import { useThrottledCallback } from 'use-debounce';
type ResizeCallback = Exclude< ResizableBox.Props[ 'onResize' ], undefined >;
export const ConstrainedResizable = ( {
className = '',
onResize,
...props
}: ResizableBox.Props ): JSX.Element => {
const [ isResizing, setIsResizing ] = useState( false );
const classNames = classnames( className, {
'is-resizing': isResizing,
} );
const throttledResize = useThrottledCallback< ResizeCallback >(
( event, direction, elt, _delta ) => {
if ( ! isResizing ) setIsResizing( true );
onResize?.( event, direction, elt, _delta );
},
50,
{ leading: true }
);
return (
<ResizableBox
className={ classNames }
enable={ { bottom: true } }
onResize={ throttledResize }
onResizeStop={ ( ...args ) => {
onResize?.( ...args );
setIsResizing( false );
} }
{ ...props }
/>
);
};

View File

@@ -0,0 +1,21 @@
/**
* External dependencies
*/
import { useBlockProps } from '@wordpress/block-editor';
import type { FunctionComponent } from 'react';
export function Edit< T >( Block: FunctionComponent< T > ) {
return function WithBlock( props: T ): JSX.Element {
const blockProps = useBlockProps();
// The useBlockProps function returns the style with the `color`.
// We need to remove it to avoid the block to be styled with the color.
const { color, ...styles } = blockProps.style;
return (
<div { ...blockProps } style={ styles }>
<Block { ...props } />
</div>
);
};
}

View File

@@ -0,0 +1,108 @@
{
"name": "woocommerce/featured-category",
"version": "1.0.0",
"title": "Featured Category",
"category": "woocommerce",
"keywords": [
"WooCommerce"
],
"description": "Visually highlight a product category and encourage prompt action.",
"supports": {
"align": [
"wide",
"full"
],
"html": false,
"color": {
"background": true,
"text": true
},
"spacing": {
"padding": true,
"__experimentalDefaultControls": {
"padding": true
},
"__experimentalSkipSerialization": true
},
"__experimentalBorder": {
"color": true,
"radius": true,
"width": true,
"__experimentalSkipSerialization": true
}
},
"attributes": {
"alt": {
"type": "string",
"default": ""
},
"contentAlign": {
"type": "string",
"default": "center"
},
"dimRatio": {
"type": "number",
"default": 50
},
"editMode": {
"type": "boolean",
"default": true
},
"focalPoint": {
"type": "object",
"default": {
"x": 0.5,
"y": 0.5
}
},
"imageFit": {
"type": "string",
"default": "none"
},
"hasParallax": {
"type": "boolean",
"default": false
},
"isRepeated": {
"type": "boolean",
"default": false
},
"mediaId": {
"type": "number",
"default": 0
},
"mediaSrc": {
"type": "string",
"default": ""
},
"minHeight": {
"type": "number",
"default": 500
},
"linkText": {
"default": "Shop now",
"type": "string"
},
"categoryId": {
"type": "number"
},
"overlayColor": {
"type": "string",
"default": "#000000"
},
"overlayGradient": {
"type": "string"
},
"previewCategory": {
"type": "object",
"default": null
},
"showDesc": {
"type": "boolean",
"default": true
}
},
"textdomain": "woocommerce",
"apiVersion": 2,
"$schema": "https://schemas.wp.org/trunk/block.json"
}

View File

@@ -0,0 +1,68 @@
/**
* External dependencies
*/
import { withCategory } from '@woocommerce/block-hocs';
import { withSpokenMessages } from '@wordpress/components';
import { compose } from '@wordpress/compose';
import { __ } from '@wordpress/i18n';
import { folderStarred } from '@woocommerce/icons';
/**
* Internal dependencies
*/
import { withBlockControls } from '../block-controls';
import { withImageEditor } from '../image-editor';
import { withInspectorControls } from '../inspector-controls';
import { withApiError } from '../with-api-error';
import { withEditMode } from '../with-edit-mode';
import { withEditingImage } from '../with-editing-image';
import { withFeaturedItem } from '../with-featured-item';
import { withUpdateButtonAttributes } from '../with-update-button-attributes';
const GENERIC_CONFIG = {
icon: folderStarred,
label: __( 'Featured Category', 'woo-gutenberg-products-block' ),
};
const BLOCK_CONTROL_CONFIG = {
...GENERIC_CONFIG,
cropLabel: __( 'Edit category image', 'woo-gutenberg-products-block' ),
editLabel: __( 'Edit selected category', 'woo-gutenberg-products-block' ),
};
const CONTENT_CONFIG = {
...GENERIC_CONFIG,
emptyMessage: __(
'No product category is selected.',
'woo-gutenberg-products-block'
),
noSelectionButtonLabel: __(
'Select a category',
'woo-gutenberg-products-block'
),
};
const EDIT_MODE_CONFIG = {
...GENERIC_CONFIG,
description: __(
'Visually highlight a product category and encourage prompt action.',
'woo-gutenberg-products-block'
),
editLabel: __(
'Showing Featured Product block preview.',
'woo-gutenberg-products-block'
),
};
export default compose( [
withCategory,
withSpokenMessages,
withUpdateButtonAttributes,
withEditingImage,
withEditMode( EDIT_MODE_CONFIG ),
withFeaturedItem( CONTENT_CONFIG ),
withApiError,
withImageEditor,
withInspectorControls,
withBlockControls( BLOCK_CONTROL_CONFIG ),
] )( () => <></> );

View File

@@ -0,0 +1,6 @@
@import "../style";
.wp-block-woocommerce-featured-category {
@extend %with-media-controls;
@extend %with-resizable-box;
}

View File

@@ -0,0 +1,21 @@
/**
* External dependencies
*/
import { previewCategories } from '@woocommerce/resource-previews';
import type { Block } from '@wordpress/blocks';
type ExampleBlock = Block[ 'example' ] & {
attributes: {
categoryId: 'preview' | number;
previewCategory: typeof previewCategories[ number ];
editMode: false;
};
};
export const example: ExampleBlock = {
attributes: {
categoryId: 'preview',
previewCategory: previewCategories[ 0 ],
editMode: false,
},
} as const;

View File

@@ -0,0 +1,26 @@
/**
* External dependencies
*/
import { folderStarred } from '@woocommerce/icons';
import { Icon } from '@wordpress/icons';
/**
* Internal dependencies
*/
import './style.scss';
import './editor.scss';
import Block from './block';
import metadata from './block.json';
import { register } from '../register';
import { example } from './example';
register( Block, example, metadata, {
icon: {
src: (
<Icon
icon={ folderStarred }
className="wc-block-editor-components-block-icon"
/>
),
},
} );

View File

@@ -0,0 +1,9 @@
@import "../style";
.wp-block-woocommerce-featured-category {
@extend %wp-block-featured-item;
}
.wc-block-featured-category {
@include wc-block-featured-item();
}

View File

@@ -0,0 +1,29 @@
/**
* External dependencies
*/
import { WP_REST_API_Category } from 'wp-types';
/**
* Internal dependencies
*/
import { isImageObject } from '../types';
/**
* Get the src from a category object, unless null (no image).
*/
export function getCategoryImageSrc( category: WP_REST_API_Category ) {
if ( category && isImageObject( category.image ) ) {
return category.image.src;
}
return '';
}
/**
* Get the attachment ID from a category object, unless null (no image).
*/
export function getCategoryImageId( category: WP_REST_API_Category ) {
if ( category && isImageObject( category.image ) ) {
return category.image.id;
}
return 0;
}

View File

@@ -0,0 +1,108 @@
{
"name": "woocommerce/featured-product",
"version": "1.0.0",
"title": "Featured Product",
"description": "Highlight a product or variation.",
"category": "woocommerce",
"keywords": [ "WooCommerce" ],
"supports": {
"align": [ "wide", "full" ],
"html": false,
"color": {
"background": true,
"text": true
},
"spacing": {
"padding": true,
"__experimentalDefaultControls": {
"padding": true
},
"__experimentalSkipSerialization": true
},
"__experimentalBorder": {
"color": true,
"radius": true,
"width": true,
"__experimentalSkipSerialization": true
},
"multiple": true
},
"attributes": {
"alt": {
"type": "string",
"default": ""
},
"contentAlign": {
"type": "string",
"default": "center"
},
"dimRatio": {
"type": "number",
"default": 50
},
"editMode": {
"type": "boolean",
"default": true
},
"focalPoint": {
"type": "object",
"default": {
"x": 0.5,
"y": 0.5
}
},
"imageFit": {
"type": "string",
"default": "none"
},
"hasParallax": {
"type": "boolean",
"default": false
},
"isRepeated": {
"type": "boolean",
"default": false
},
"mediaId": {
"type": "number",
"default": 0
},
"mediaSrc": {
"type": "string",
"default": ""
},
"minHeight": {
"type": "number",
"default": 500
},
"linkText": {
"type": "string",
"default": "Shop now"
},
"overlayColor": {
"type": "string",
"default": "#000000"
},
"overlayGradient": {
"type": "string"
},
"productId": {
"type": "number"
},
"previewProduct": {
"type": "object",
"default": null
},
"showDesc": {
"type": "boolean",
"default": true
},
"showPrice": {
"type": "boolean",
"default": true
}
},
"textdomain": "woocommerce",
"apiVersion": 2,
"$schema": "https://schemas.wp.org/trunk/block.json"
}

View File

@@ -0,0 +1,68 @@
/**
* External dependencies
*/
import { withProduct } from '@woocommerce/block-hocs';
import { withSpokenMessages } from '@wordpress/components';
import { compose } from '@wordpress/compose';
import { __ } from '@wordpress/i18n';
import { starEmpty } from '@wordpress/icons';
/**
* Internal dependencies
*/
import { withBlockControls } from '../block-controls';
import { withImageEditor } from '../image-editor';
import { withInspectorControls } from '../inspector-controls';
import { withApiError } from '../with-api-error';
import { withEditMode } from '../with-edit-mode';
import { withEditingImage } from '../with-editing-image';
import { withFeaturedItem } from '../with-featured-item';
import { withUpdateButtonAttributes } from '../with-update-button-attributes';
const GENERIC_CONFIG = {
icon: starEmpty,
label: __( 'Featured Product', 'woo-gutenberg-products-block' ),
};
const BLOCK_CONTROL_CONFIG = {
...GENERIC_CONFIG,
cropLabel: __( 'Edit product image', 'woo-gutenberg-products-block' ),
editLabel: __( 'Edit selected product', 'woo-gutenberg-products-block' ),
};
const CONTENT_CONFIG = {
...GENERIC_CONFIG,
emptyMessage: __(
'No product is selected.',
'woo-gutenberg-products-block'
),
noSelectionButtonLabel: __(
'Select a product',
'woo-gutenberg-products-block'
),
};
const EDIT_MODE_CONFIG = {
...GENERIC_CONFIG,
description: __(
'Highlight a product or variation.',
'woo-gutenberg-products-block'
),
editLabel: __(
'Showing Featured Product block preview.',
'woo-gutenberg-products-block'
),
};
export default compose( [
withProduct,
withSpokenMessages,
withUpdateButtonAttributes,
withEditingImage,
withEditMode( EDIT_MODE_CONFIG ),
withFeaturedItem( CONTENT_CONFIG ),
withApiError,
withImageEditor,
withInspectorControls,
withBlockControls( BLOCK_CONTROL_CONFIG ),
] )( () => <></> );

View File

@@ -0,0 +1,10 @@
@import "../style";
.wp-block-woocommerce-featured-product {
@extend %with-media-controls;
@extend %with-resizable-box;
&__message {
margin-bottom: 16px;
}
}

View File

@@ -0,0 +1,21 @@
/**
* External dependencies
*/
import { previewProducts } from '@woocommerce/resource-previews';
import type { Block } from '@wordpress/blocks';
type ExampleBlock = Block[ 'example' ] & {
attributes: {
productId: 'preview' | number;
previewProduct: typeof previewProducts[ number ];
editMode: false;
};
};
export const example: ExampleBlock = {
attributes: {
productId: 'preview',
previewProduct: previewProducts[ 0 ],
editMode: false,
},
} as const;

View File

@@ -0,0 +1,25 @@
/**
* External dependencies
*/
import { Icon, starEmpty } from '@wordpress/icons';
/**
* Internal dependencies
*/
import './style.scss';
import './editor.scss';
import Block from './block';
import { register } from '../register';
import { example } from './example';
import metadata from './block.json';
register( Block, example, metadata, {
icon: {
src: (
<Icon
icon={ starEmpty }
className="wc-block-editor-components-block-icon"
/>
),
},
} );

View File

@@ -0,0 +1,32 @@
@import "../style";
.wp-block-woocommerce-featured-product {
@extend %wp-block-featured-item;
background-color: transparent;
}
.wc-block-featured-product {
@include wc-block-featured-item();
.wc-block-featured-product__title,
.wc-block-featured-product__variation {
margin-top: 0;
border: 0;
&::before {
display: none;
}
}
.wc-block-featured-product__variation {
font-style: italic;
padding-top: 0;
}
.wc-block-featured-product__description {
p {
margin: 0;
line-height: 1.5;
}
}
}

View File

@@ -0,0 +1,197 @@
/* eslint-disable @wordpress/no-unsafe-wp-apis */
/**
* External dependencies
*/
import { useCallback, useEffect, useRef, useState } from '@wordpress/element';
import { WP_REST_API_Category } from 'wp-types';
import { ProductResponseItem } from '@woocommerce/types';
import {
__experimentalImageEditingProvider as ImageEditingProvider,
__experimentalImageEditor as GutenbergImageEditor,
} from '@wordpress/block-editor';
import type { ComponentType, Dispatch, SetStateAction } from 'react';
/**
* Internal dependencies
*/
import { BLOCK_NAMES, DEFAULT_EDITOR_SIZE } from './constants';
import { EditorBlock } from './types';
import { useBackgroundImage } from './use-background-image';
type MediaAttributes = { align: string; mediaId: number; mediaSrc: string };
type MediaSize = { height: number; width: number };
interface WithImageEditorRequiredProps< T > {
attributes: MediaAttributes & EditorBlock< T >[ 'attributes' ];
backgroundImageSize: MediaSize;
setAttributes: ( attrs: Partial< MediaAttributes > ) => void;
useEditingImage: [ boolean, Dispatch< SetStateAction< boolean > > ];
}
interface WithImageEditorCategoryProps< T >
extends WithImageEditorRequiredProps< T > {
category: WP_REST_API_Category;
product: never;
}
interface WithImageEditorProductProps< T >
extends WithImageEditorRequiredProps< T > {
category: never;
product: ProductResponseItem;
}
type WithImageEditorProps< T extends EditorBlock< T > > =
| ( T & WithImageEditorCategoryProps< T > )
| ( T & WithImageEditorProductProps< T > );
interface ImageEditorProps {
align: string;
backgroundImageId: number;
backgroundImageSize: MediaSize;
backgroundImageSrc: string;
containerRef: React.RefObject< HTMLDivElement >;
isEditingImage: boolean;
setAttributes: ( attrs: MediaAttributes ) => void;
setIsEditingImage: ( value: boolean ) => void;
}
// Adapted from:
// https://github.com/WordPress/gutenberg/blob/v15.6.1/packages/block-library/src/image/use-client-width.js
function useClientWidth(
ref: React.RefObject< HTMLDivElement >,
dependencies: string[]
) {
const [ clientWidth, setClientWidth ]: [
number | undefined,
Dispatch< SetStateAction< number | undefined > >
] = useState();
const calculateClientWidth = useCallback( () => {
setClientWidth( ref.current?.clientWidth );
}, [ ref ] );
useEffect( calculateClientWidth, [
calculateClientWidth,
...dependencies,
] );
useEffect( () => {
if ( ! ref.current ) {
return;
}
const { defaultView } = ref.current.ownerDocument;
if ( ! defaultView ) {
return;
}
defaultView.addEventListener( 'resize', calculateClientWidth );
return () => {
defaultView.removeEventListener( 'resize', calculateClientWidth );
};
}, [ ref, calculateClientWidth ] );
return clientWidth;
}
export const ImageEditor = ( {
align,
backgroundImageId,
backgroundImageSize,
backgroundImageSrc,
containerRef,
isEditingImage,
setAttributes,
setIsEditingImage,
}: ImageEditorProps ) => {
const clientWidth = useClientWidth( containerRef, [ align ] );
// Fallback for WP 6.1 or lower. In WP 6.2. ImageEditingProvider was merged
// with ImageEditor, see: https://github.com/WordPress/gutenberg/pull/47171
if ( typeof ImageEditingProvider === 'function' ) {
return (
<ImageEditingProvider
id={ backgroundImageId }
url={ backgroundImageSrc }
naturalHeight={
backgroundImageSize.height || DEFAULT_EDITOR_SIZE.height
}
naturalWidth={
backgroundImageSize.width || DEFAULT_EDITOR_SIZE.width
}
onSaveImage={ ( { id, url }: { id: number; url: string } ) => {
setAttributes( { mediaId: id, mediaSrc: url } );
} }
isEditing={ isEditingImage }
onFinishEditing={ () => setIsEditingImage( false ) }
>
<GutenbergImageEditor
url={ backgroundImageSrc }
height={
backgroundImageSize.height || DEFAULT_EDITOR_SIZE.height
}
width={
backgroundImageSize.width || DEFAULT_EDITOR_SIZE.width
}
/>
</ImageEditingProvider>
);
}
return (
<GutenbergImageEditor
id={ backgroundImageId }
url={ backgroundImageSrc }
height={ backgroundImageSize.height || DEFAULT_EDITOR_SIZE.height }
width={ backgroundImageSize.width || DEFAULT_EDITOR_SIZE.width }
naturalHeight={ backgroundImageSize.height }
naturalWidth={ backgroundImageSize.width }
onSaveImage={ ( { id, url }: { id: number; url: string } ) => {
setAttributes( { mediaId: id, mediaSrc: url } );
} }
onFinishEditing={ () => setIsEditingImage( false ) }
clientWidth={ clientWidth }
/>
);
};
export const withImageEditor =
< T extends EditorBlock< T > >( Component: ComponentType< T > ) =>
( props: WithImageEditorProps< T > ) => {
const [ isEditingImage, setIsEditingImage ] = props.useEditingImage;
const ref = useRef< HTMLDivElement >( null );
const { attributes, backgroundImageSize, name, setAttributes } = props;
const { mediaId, mediaSrc } = attributes;
const item =
name === BLOCK_NAMES.featuredProduct
? props.product
: props.category;
const { backgroundImageId, backgroundImageSrc } = useBackgroundImage( {
item,
mediaId,
mediaSrc,
blockName: name,
} );
if ( isEditingImage ) {
return (
<div ref={ ref }>
<ImageEditor
align={ attributes.align }
backgroundImageId={ backgroundImageId }
backgroundImageSize={ backgroundImageSize }
backgroundImageSrc={ backgroundImageSrc }
containerRef={ ref }
isEditingImage={ isEditingImage }
setAttributes={ setAttributes }
setIsEditingImage={ setIsEditingImage }
/>
</div>
);
}
return <Component { ...props } />;
};

View File

@@ -0,0 +1,358 @@
/* eslint-disable @wordpress/no-unsafe-wp-apis */
/**
* External dependencies
*/
import { WP_REST_API_Category } from 'wp-types';
import { __ } from '@wordpress/i18n';
import {
InspectorControls as GutenbergInspectorControls,
__experimentalPanelColorGradientSettings as PanelColorGradientSettings,
__experimentalUseGradient as useGradient,
} from '@wordpress/block-editor';
import {
FocalPointPicker,
PanelBody,
RangeControl,
ToggleControl,
__experimentalToggleGroupControl as ToggleGroupControl,
__experimentalToggleGroupControlOption as ToggleGroupControlOption,
TextareaControl,
ExternalLink,
} from '@wordpress/components';
import { LooselyMustHave, ProductResponseItem } from '@woocommerce/types';
import type { ComponentType } from 'react';
/**
* Internal dependencies
*/
import { useBackgroundImage } from './use-background-image';
import { BLOCK_NAMES } from './constants';
import { FeaturedItemRequiredAttributes } from './with-featured-item';
import { EditorBlock, ImageFit } from './types';
type InspectorControlRequiredKeys =
| 'dimRatio'
| 'focalPoint'
| 'hasParallax'
| 'imageFit'
| 'isRepeated'
| 'overlayColor'
| 'overlayGradient'
| 'showDesc';
interface InspectorControlsRequiredAttributes
extends LooselyMustHave<
FeaturedItemRequiredAttributes,
InspectorControlRequiredKeys
> {
alt: string;
backgroundImageSrc: string;
contentPanel: JSX.Element | undefined;
}
interface InspectorControlsProps extends InspectorControlsRequiredAttributes {
setAttributes: (
attrs: Partial< InspectorControlsRequiredAttributes >
) => void;
// Gutenberg doesn't provide some types, so we have to hard-code them here
setGradient: ( newGradientValue: string ) => void;
}
interface WithInspectorControlsRequiredProps< T > {
attributes: InspectorControlsRequiredAttributes &
EditorBlock< T >[ 'attributes' ];
setAttributes: InspectorControlsProps[ 'setAttributes' ];
}
interface WithInspectorControlsCategoryProps< T >
extends WithInspectorControlsRequiredProps< T > {
category: WP_REST_API_Category;
product: never;
}
interface WithInspectorControlsProductProps< T >
extends WithInspectorControlsRequiredProps< T > {
category: never;
product: ProductResponseItem;
showPrice: boolean;
}
type WithInspectorControlsProps< T extends EditorBlock< T > > =
| ( T & WithInspectorControlsCategoryProps< T > )
| ( T & WithInspectorControlsProductProps< T > );
export const InspectorControls = ( {
alt,
backgroundImageSrc,
contentPanel,
dimRatio,
focalPoint,
hasParallax,
imageFit,
isRepeated,
overlayColor,
overlayGradient,
setAttributes,
setGradient,
showDesc,
}: InspectorControlsProps ) => {
// FocalPointPicker was introduced in Gutenberg 5.0 (WordPress 5.2),
// so we need to check if it exists before using it.
const focalPointPickerExists = typeof FocalPointPicker === 'function';
const isImgElement = ! isRepeated && ! hasParallax;
return (
<GutenbergInspectorControls key="inspector">
<PanelBody
title={ __( 'Content', 'woo-gutenberg-products-block' ) }
>
<ToggleControl
label={ __(
'Show description',
'woo-gutenberg-products-block'
) }
checked={ showDesc }
onChange={ () => setAttributes( { showDesc: ! showDesc } ) }
/>
{ contentPanel }
</PanelBody>
{ !! backgroundImageSrc && (
<>
{ focalPointPickerExists && (
<PanelBody
title={ __(
'Media settings',
'woo-gutenberg-products-block'
) }
>
<ToggleControl
label={ __(
'Fixed background',
'woo-gutenberg-products-block'
) }
checked={ hasParallax }
onChange={ () => {
setAttributes( {
hasParallax: ! hasParallax,
} );
} }
/>
<ToggleControl
label={ __(
'Repeated background',
'woo-gutenberg-products-block'
) }
checked={ isRepeated }
onChange={ () => {
setAttributes( {
isRepeated: ! isRepeated,
} );
} }
/>
{ ! isRepeated && (
<ToggleGroupControl
help={
<>
<span
style={ {
display: 'block',
marginBottom: '1em',
} }
>
{ __(
'Select “Cover” to have the image automatically fit its container.',
'woo-gutenberg-products-block'
) }
</span>
<span>
{ __(
'This may affect your ability to freely move the focal point of the image.',
'woo-gutenberg-products-block'
) }
</span>
</>
}
label={ __(
'Image fit',
'woo-gutenberg-products-block'
) }
value={ imageFit }
onChange={ ( value: ImageFit ) =>
setAttributes( {
imageFit: value,
} )
}
>
<ToggleGroupControlOption
label={ __(
'None',
'woo-gutenberg-products-block'
) }
value="none"
/>
<ToggleGroupControlOption
/* translators: "Cover" is a verb that indicates an image covering the entire container. */
label={ __(
'Cover',
'woo-gutenberg-products-block'
) }
value="cover"
/>
</ToggleGroupControl>
) }
<FocalPointPicker
label={ __(
'Focal Point Picker',
'woo-gutenberg-products-block'
) }
url={ backgroundImageSrc }
value={ focalPoint }
onChange={ ( value ) =>
setAttributes( {
focalPoint: value,
} )
}
/>
{ isImgElement && (
<TextareaControl
label={ __(
'Alt text (alternative text)',
'woo-gutenberg-products-block'
) }
value={ alt }
onChange={ ( value: string ) => {
setAttributes( { alt: value } );
} }
help={
<>
<ExternalLink href="https://www.w3.org/WAI/tutorials/images/decision-tree">
{ __(
'Describe the purpose of the image',
'woo-gutenberg-products-block'
) }
</ExternalLink>
</>
}
/>
) }
</PanelBody>
) }
<PanelColorGradientSettings
__experimentalHasMultipleOrigins
__experimentalIsRenderedInSidebar
title={ __(
'Overlay',
'woo-gutenberg-products-block'
) }
initialOpen={ true }
settings={ [
{
colorValue: overlayColor,
gradientValue: overlayGradient,
onColorChange: ( value: string ) =>
setAttributes( { overlayColor: value } ),
onGradientChange: ( value: string ) => {
setGradient( value );
setAttributes( {
overlayGradient: value,
} );
},
label: __(
'Color',
'woo-gutenberg-products-block'
),
},
] }
>
<RangeControl
label={ __(
'Opacity',
'woo-gutenberg-products-block'
) }
value={ dimRatio }
onChange={ ( value ) =>
setAttributes( { dimRatio: value as number } )
}
min={ 0 }
max={ 100 }
step={ 10 }
required
/>
</PanelColorGradientSettings>
</>
) }
</GutenbergInspectorControls>
);
};
export const withInspectorControls =
< T extends EditorBlock< T > >( Component: ComponentType< T > ) =>
( props: WithInspectorControlsProps< T > ) => {
const { attributes, name, setAttributes } = props;
const {
alt,
dimRatio,
focalPoint,
hasParallax,
isRepeated,
imageFit,
mediaId,
mediaSrc,
overlayColor,
overlayGradient,
showDesc,
showPrice,
} = attributes;
const item =
name === BLOCK_NAMES.featuredProduct
? props.product
: props.category;
const { setGradient } = useGradient( {
gradientAttribute: 'overlayGradient',
customGradientAttribute: 'overlayGradient',
} );
const { backgroundImageSrc } = useBackgroundImage( {
item,
mediaId,
mediaSrc,
blockName: name,
} );
const contentPanel =
name === BLOCK_NAMES.featuredProduct ? (
<ToggleControl
label={ __( 'Show price', 'woo-gutenberg-products-block' ) }
checked={ showPrice }
onChange={ () =>
setAttributes( {
showPrice: ! showPrice,
} )
}
/>
) : undefined;
return (
<>
<InspectorControls
alt={ alt }
backgroundImageSrc={ backgroundImageSrc }
contentPanel={ contentPanel }
dimRatio={ dimRatio }
focalPoint={ focalPoint }
hasParallax={ hasParallax }
isRepeated={ isRepeated }
imageFit={ imageFit }
overlayColor={ overlayColor }
overlayGradient={ overlayGradient }
setAttributes={ setAttributes }
setGradient={ setGradient }
showDesc={ showDesc }
/>
<Component { ...props } />
</>
);
};

View File

@@ -0,0 +1,128 @@
// Disabling because of `__experimental` property names.
/* eslint-disable @typescript-eslint/naming-convention */
/**
* External dependencies
*/
import { InnerBlocks } from '@wordpress/block-editor';
import { registerBlockType } from '@wordpress/blocks';
import { getSetting } from '@woocommerce/settings';
import { isFeaturePluginBuild } from '@woocommerce/block-settings';
import type { FunctionComponent } from 'react';
import type { BlockConfiguration } from '@wordpress/blocks';
/**
* Internal dependencies
*/
import { Edit } from './edit';
type CSSDirections = 'top' | 'right' | 'bottom' | 'left';
interface ExtendedBlockSupports {
supports: {
color?: {
background: string;
gradients: boolean;
link: boolean;
text: string;
};
spacing?: {
margin: boolean | CSSDirections[];
padding: boolean | CSSDirections[];
__experimentalDefaultControls?: {
margin?: boolean;
padding?: boolean;
};
__experimentalSkipSerialization?: boolean;
};
__experimentalBorder?: {
color: boolean;
radius: boolean;
width: boolean;
__experimentalSkipSerialization?: boolean;
};
};
}
export function register(
Block: FunctionComponent,
example: { attributes: Record< string, unknown > },
metadata: BlockConfiguration & ExtendedBlockSupports,
settings: Partial< BlockConfiguration >
): void {
const DEFAULT_SETTINGS = {
attributes: {
...metadata.attributes,
/**
* A minimum height for the block.
*
* Note: if padding is increased, this way the inner content will never
* overflow, but instead will resize the container.
*
* It was decided to change this to make this block more in line with
* the “Cover” block.
*/
minHeight: {
type: 'number',
default: getSetting( 'defaultHeight', 500 ),
},
},
supports: {
...metadata.supports,
color: {
background: metadata.supports?.color?.background,
text: metadata.supports?.color?.text,
},
spacing: {
padding: metadata.supports?.spacing?.padding,
...( isFeaturePluginBuild() && {
__experimentalDefaultControls: {
padding:
metadata.supports?.spacing
?.__experimentalDefaultControls,
},
__experimentalSkipSerialization:
metadata.supports?.spacing
?.__experimentalSkipSerialization,
} ),
},
...( isFeaturePluginBuild() && {
__experimentalBorder: metadata?.supports?.__experimentalBorder,
} ),
},
};
const DEFAULT_EXAMPLE = {
attributes: {
alt: '',
contentAlign: 'center',
dimRatio: 50,
editMode: false,
hasParallax: false,
isRepeated: false,
height: getSetting( 'defaultHeight', 500 ),
mediaSrc: '',
overlayColor: '#000000',
showDesc: true,
},
};
registerBlockType( metadata, {
...DEFAULT_SETTINGS,
example: {
...DEFAULT_EXAMPLE,
...example,
},
/**
* Renders and manages the block.
*
* @param {Object} props Props to pass to block.
*/
edit: Edit( Block ),
/**
* Block content is rendered in PHP, not via save function.
*/
save: () => <InnerBlocks.Content />,
...settings,
} );
}

View File

@@ -0,0 +1,183 @@
@mixin with-content-selection {
background-color: inherit;
&__selection {
width: 100%;
}
}
%with-media-controls {
// Applying image edits
.is-applying {
.components-spinner {
position: absolute;
top: 50%;
left: 50%;
margin-top: -9px;
margin-left: -9px;
}
img {
opacity: 0.3;
}
}
}
%with-resizable-box {
.components-resizable-box__container {
position: absolute !important;
top: 0;
left: 0;
right: 0;
bottom: 0;
min-height: 50px;
&:not(.is-resizing) {
height: auto !important;
}
}
.components-resizable-box__handle {
z-index: 10;
}
}
%wp-block-featured-item {
background-color: transparent;
border-color: transparent;
color: #fff;
box-sizing: border-box;
}
@mixin wc-block-featured-item {
$block: &;
@include with-background-dim();
@include with-content-selection();
align-content: center;
align-items: center;
background-position: center center;
background-size: cover;
display: flex;
flex-wrap: wrap;
justify-content: center;
margin: 0;
overflow: hidden;
position: relative;
width: 100%;
&.has-left-content {
justify-content: flex-start;
#{$block}__description,
#{$block}__price,
#{$block}__title,
#{$block}__variation {
margin-left: 0;
text-align: left;
}
}
&.has-right-content {
justify-content: flex-end;
#{$block}__description,
#{$block}__price,
#{$block}__title,
#{$block}__variation {
margin-right: 0;
text-align: right;
}
}
&.is-repeated {
background-repeat: repeat;
background-size: auto;
}
&__description,
&__price,
&__title,
&__variation {
line-height: 1.25;
margin-bottom: 0;
text-align: center;
a,
a:hover,
a:focus,
a:active {
color: $white;
}
}
&__description,
&__link,
&__price,
&__title,
&__variation {
color: inherit;
width: 100%;
padding: 0 48px 16px 48px;
z-index: 1;
}
& &__background-image {
@include absolute-stretch();
object-fit: none;
&.has-parallax {
background-attachment: fixed;
// Mobile Safari does not support fixed background attachment properly.
// See also https://stackoverflow.com/questions/24154666/background-size-cover-not-working-on-ios
// Chrome on Android does not appear to support the attachment at all: https://issuetracker.google.com/issues/36908439
@supports (-webkit-overflow-scrolling: touch) {
background-attachment: scroll;
}
// Remove the appearance of scrolling based on OS-level animation preferences.
@media (prefers-reduced-motion: reduce) {
background-attachment: scroll;
}
}
}
&__description {
color: inherit;
p {
margin: 0;
}
}
& &__title {
color: inherit;
margin-top: 0;
div {
color: inherit;
}
&::before {
display: none;
}
}
&__wrapper {
align-content: center;
align-items: center;
box-sizing: border-box;
display: flex;
flex-wrap: wrap;
justify-content: center;
overflow: hidden;
width: 100%;
height: 100%;
}
.wp-block-button.aligncenter {
text-align: center;
}
}

View File

@@ -0,0 +1,33 @@
/**
* External dependencies
*/
import type { Block, BlockEditProps } from '@wordpress/blocks';
import { isNumber } from '@woocommerce/types';
export type EditorBlock< T > = Block< T > & BlockEditProps< T >;
export interface Coordinates {
x: number;
y: number;
}
export interface GenericBlockUIConfig {
icon: JSX.Element;
label: string;
}
export type ImageFit = 'cover' | 'none';
export interface ImageObject {
id: number;
src: string;
}
export function isImageObject( obj: unknown ): obj is ImageObject {
if ( ! obj ) return false;
return (
isNumber( ( obj as ImageObject ).id ) &&
typeof ( obj as ImageObject ).src === 'string'
);
}

View File

@@ -0,0 +1,67 @@
/**
* External dependencies
*/
import { WP_REST_API_Category } from 'wp-types';
import { ProductResponseItem } from '@woocommerce/types';
import {
getImageSrcFromProduct,
getImageIdFromProduct,
} from '@woocommerce/utils';
import { useEffect, useState } from '@wordpress/element';
/**
* Internal dependencies
*/
import { BLOCK_NAMES } from './constants';
import {
getCategoryImageSrc,
getCategoryImageId,
} from './featured-category/utils';
interface BackgroundProps {
blockName: string;
item: ProductResponseItem | WP_REST_API_Category;
mediaId: number | undefined;
mediaSrc: string | undefined;
}
interface BackgroundImage {
backgroundImageId: number;
backgroundImageSrc: string;
}
export function useBackgroundImage( {
blockName,
item,
mediaId,
mediaSrc,
}: BackgroundProps ): BackgroundImage {
const [ backgroundImageId, setBackgroundImageId ] = useState( 0 );
const [ backgroundImageSrc, setBackgroundImageSrc ] = useState( '' );
useEffect( () => {
if ( mediaId ) {
setBackgroundImageId( mediaId );
} else {
setBackgroundImageId(
blockName === BLOCK_NAMES.featuredProduct
? getImageIdFromProduct( item as ProductResponseItem )
: getCategoryImageId( item as WP_REST_API_Category )
);
}
}, [ blockName, item, mediaId ] );
useEffect( () => {
if ( mediaSrc ) {
setBackgroundImageSrc( mediaSrc );
} else {
setBackgroundImageSrc(
blockName === BLOCK_NAMES.featuredProduct
? getImageSrcFromProduct( item as ProductResponseItem )
: getCategoryImageSrc( item as WP_REST_API_Category )
);
}
}, [ blockName, item, mediaSrc ] );
return { backgroundImageId, backgroundImageSrc };
}

View File

@@ -0,0 +1,93 @@
/**
* Internal dependencies
*/
import { Coordinates, ImageFit } from './types';
/**
* Given x and y coordinates between 0 and 1 returns a rounded percentage string.
*
* Useful for converting to a CSS-compatible position string.
*/
export function calculatePercentPositionFromCoordinates( coords: Coordinates ) {
if ( ! coords ) return '';
const x = Math.round( coords.x * 100 );
const y = Math.round( coords.y * 100 );
return `${ x }% ${ y }%`;
}
/**
* Given x and y coordinates between 0 and 1 returns a CSS `objectPosition`.
*/
export function calculateBackgroundImagePosition( coords: Coordinates ) {
if ( ! coords ) return {};
return {
objectPosition: calculatePercentPositionFromCoordinates( coords ),
};
}
/**
* Generate the style object of the background image of the block.
*
* It outputs styles for either an `img` element or a `div` with a background,
* depending on what is needed.
*/
export function getBackgroundImageStyles( {
focalPoint,
imageFit,
isImgElement,
isRepeated,
url,
}: {
focalPoint: Coordinates;
imageFit: ImageFit;
isImgElement: boolean;
isRepeated: boolean;
url: string;
} ) {
let styles = {};
if ( isImgElement ) {
styles = {
...styles,
...calculateBackgroundImagePosition( focalPoint ),
objectFit: imageFit,
};
} else {
styles = {
...styles,
...( url && {
backgroundImage: `url(${ url })`,
} ),
backgroundPosition:
calculatePercentPositionFromCoordinates( focalPoint ),
...( ! isRepeated && {
backgroundRepeat: 'no-repeat',
backgroundSize: imageFit === 'cover' ? imageFit : 'auto',
} ),
};
}
return styles;
}
/**
* Generates the CSS class prefix for scoping elements to a block.
*/
export function getClassPrefixFromName( blockName: string ) {
return `wc-block-${ blockName.split( '/' )[ 1 ] }`;
}
/**
* Convert the selected ratio to the correct background class.
*
* @param ratio Selected opacity from 0 to 100.
* @return The class name, if applicable (not used for ratio 0 or 50).
*/
export function dimRatioToClass( ratio: number ) {
return ratio === 0 || ratio === 50
? null
: `has-background-dim-${ 10 * Math.round( ratio / 10 ) }`;
}

View File

@@ -0,0 +1,59 @@
/**
* External dependencies
*/
import ErrorPlaceholder, {
ErrorObject,
} from '@woocommerce/editor-components/error-placeholder';
import type { Block } from '@wordpress/blocks';
import type { ComponentType } from 'react';
/**
* Internal dependencies
*/
import { BLOCK_NAMES } from './constants';
import { getClassPrefixFromName } from './utils';
interface APIErrorRequiredProps {
error: ErrorObject;
isLoading: boolean;
name: string;
}
interface APIErrorProductProps extends APIErrorRequiredProps {
getCategory: never;
getProduct(): void;
}
interface APIErrorCategoryProps extends APIErrorRequiredProps {
getCategory(): void;
getProduct: never;
}
type APIErrorProps< T extends Block > =
| ( T & APIErrorProductProps )
| ( T & APIErrorCategoryProps );
export const withApiError =
< T extends Block >( Component: ComponentType< T > ) =>
( props: APIErrorProps< T > ) => {
const { error, isLoading, name } = props;
const className = getClassPrefixFromName( name );
const onRetry =
name === BLOCK_NAMES.featuredCategory
? props.getCategory
: props.getProduct;
if ( error ) {
return (
<ErrorPlaceholder
className={ `${ className }-error` }
error={ error }
isLoading={ isLoading }
onRetry={ onRetry }
/>
);
}
return <Component { ...props } />;
};

View File

@@ -0,0 +1,129 @@
/**
* External dependencies
*/
import { WP_REST_API_Category } from 'wp-types';
import { ProductResponseItem } from '@woocommerce/types';
import { Placeholder, Icon, Button } from '@wordpress/components';
import { __ } from '@wordpress/i18n';
import ProductCategoryControl from '@woocommerce/editor-components/product-category-control';
import ProductControl from '@woocommerce/editor-components/product-control';
import type { ComponentType } from 'react';
/**
* Internal dependencies
*/
import { BLOCK_NAMES } from './constants';
import { EditorBlock, GenericBlockUIConfig } from './types';
import { getClassPrefixFromName } from './utils';
interface EditModeConfiguration extends GenericBlockUIConfig {
description: string;
editLabel: string;
}
type EditModeRequiredAttributes = {
categoryId?: number;
editMode: boolean;
mediaId: number;
mediaSrc: string;
productId?: number;
};
interface EditModeRequiredProps< T > {
attributes: EditModeRequiredAttributes & EditorBlock< T >[ 'attributes' ];
debouncedSpeak: ( label: string ) => void;
setAttributes: ( attrs: Partial< EditModeRequiredAttributes > ) => void;
triggerUrlUpdate: () => void;
}
type EditModeProps< T extends EditorBlock< T > > = T &
EditModeRequiredProps< T >;
export const withEditMode =
( { description, editLabel, icon, label }: EditModeConfiguration ) =>
< T extends EditorBlock< T > >( Component: ComponentType< T > ) =>
( props: EditModeProps< T > ) => {
const {
attributes,
debouncedSpeak,
name,
setAttributes,
triggerUrlUpdate = () => void null,
} = props;
const className = getClassPrefixFromName( name );
const onDone = () => {
setAttributes( { editMode: false } );
debouncedSpeak( editLabel );
};
if ( attributes.editMode ) {
return (
<Placeholder
icon={ <Icon icon={ icon } /> }
label={ label }
className={ className }
>
{ description }
<div className={ `${ className }__selection` }>
{ name === BLOCK_NAMES.featuredCategory && (
// Ignoring this TS error for now as it seems that `ProductCategoryControl`
// types might be too strict.
// @todo Convert `ProductCategoryControl` to TypeScript
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
<ProductCategoryControl
selected={ [ attributes.categoryId ] }
onChange={ (
value: WP_REST_API_Category[] = []
) => {
const id = value[ 0 ] ? value[ 0 ].id : 0;
setAttributes( {
categoryId: id,
mediaId: 0,
mediaSrc: '',
} );
triggerUrlUpdate();
} }
isSingle
/>
) }
{ name === BLOCK_NAMES.featuredProduct && (
<ProductControl
selected={
attributes.productId
? [ attributes.productId ]
: []
}
// `ProductControl` is not yet a TypeScript file and the types
// are incorrectly generated for the wrapped HOC, so `showVariation`
// doesn't appear in the allowed props
// @todo Convert `ProductControl` to TypeScript
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
showVariations
onChange={ (
value: ProductResponseItem[] = []
) => {
const id = value[ 0 ] ? value[ 0 ].id : 0;
setAttributes( {
productId: id,
mediaId: 0,
mediaSrc: '',
} );
triggerUrlUpdate();
} }
/>
) }
<Button isPrimary onClick={ onDone }>
{ __( 'Done', 'woo-gutenberg-products-block' ) }
</Button>
</div>
</Placeholder>
);
}
return <Component { ...props } />;
};

View File

@@ -0,0 +1,35 @@
/**
* External dependencies
*/
import { useEffect, useState } from '@wordpress/element';
import type { ComponentType } from 'react';
/**
* Internal dependencies
*/
import { EditorBlock } from './types';
interface EditingImageRequiredProps {
isSelected: boolean;
}
type EditingImageProps< T extends EditorBlock< T > > = T &
EditingImageRequiredProps;
export const withEditingImage =
< T extends EditorBlock< T > >( Component: ComponentType< T > ) =>
( props: EditingImageProps< T > ) => {
const [ isEditingImage, setIsEditingImage ] = useState( false );
const { isSelected } = props;
useEffect( () => {
setIsEditingImage( false );
}, [ isSelected ] );
return (
<Component
{ ...props }
useEditingImage={ [ isEditingImage, setIsEditingImage ] }
/>
);
};

View File

@@ -0,0 +1,337 @@
/* eslint-disable @wordpress/no-unsafe-wp-apis */
/**
* External dependencies
*/
import type { BlockAlignment } from '@wordpress/blocks';
import { ProductResponseItem, isEmpty } from '@woocommerce/types';
import { Icon, Placeholder, Spinner } from '@wordpress/components';
import classnames from 'classnames';
import { useCallback, useState } from '@wordpress/element';
import { WP_REST_API_Category } from 'wp-types';
import { useStyleProps } from '@woocommerce/base-hooks';
import type { ComponentType, Dispatch, SetStateAction } from 'react';
/**
* Internal dependencies
*/
import { CallToAction } from './call-to-action';
import { ConstrainedResizable } from './constrained-resizable';
import { EditorBlock, GenericBlockUIConfig } from './types';
import { useBackgroundImage } from './use-background-image';
import {
dimRatioToClass,
getBackgroundImageStyles,
getClassPrefixFromName,
} from './utils';
interface WithFeaturedItemConfig extends GenericBlockUIConfig {
emptyMessage: string;
noSelectionButtonLabel: string;
}
export interface FeaturedItemRequiredAttributes {
contentAlign: BlockAlignment;
dimRatio: number;
focalPoint: { x: number; y: number };
hasParallax: boolean;
imageFit: 'cover' | 'none';
isRepeated: boolean;
linkText: string;
mediaId: number;
mediaSrc: string;
minHeight: number;
overlayColor: string;
overlayGradient: string;
showDesc: boolean;
showPrice: boolean;
editMode: boolean;
}
interface FeaturedCategoryRequiredAttributes
extends FeaturedItemRequiredAttributes {
categoryId: number | 'preview';
productId: never;
}
interface FeaturedProductRequiredAttributes
extends FeaturedItemRequiredAttributes {
categoryId: never;
productId: number | 'preview';
}
interface FeaturedItemRequiredProps< T > {
attributes: (
| FeaturedCategoryRequiredAttributes
| FeaturedProductRequiredAttributes
) &
EditorBlock< T >[ 'attributes' ] & {
// This is hardcoded because border and color are not yet included
// in Gutenberg's official types.
style: {
border?: { radius?: number };
color?: { text?: string };
};
textColor?: string;
};
isLoading: boolean;
setAttributes: ( attrs: Partial< FeaturedItemRequiredAttributes > ) => void;
useEditingImage: [ boolean, Dispatch< SetStateAction< boolean > > ];
}
interface FeaturedCategoryProps< T > extends FeaturedItemRequiredProps< T > {
category: WP_REST_API_Category;
product: never;
}
interface FeaturedProductProps< T > extends FeaturedItemRequiredProps< T > {
category: never;
product: ProductResponseItem;
}
type FeaturedItemProps< T extends EditorBlock< T > > =
| ( T & FeaturedCategoryProps< T > )
| ( T & FeaturedProductProps< T > );
export const withFeaturedItem =
( {
emptyMessage,
icon,
label,
noSelectionButtonLabel,
}: WithFeaturedItemConfig ) =>
< T extends EditorBlock< T > >( Component: ComponentType< T > ) =>
( props: FeaturedItemProps< T > ) => {
const [ isEditingImage ] = props.useEditingImage;
const {
attributes,
category,
isLoading,
isSelected,
name,
product,
setAttributes,
} = props;
const { mediaId, mediaSrc } = attributes;
const item = category || product;
const [ backgroundImageSize, setBackgroundImageSize ] = useState( {} );
const { backgroundImageSrc } = useBackgroundImage( {
item,
mediaId,
mediaSrc,
blockName: name,
} );
const className = getClassPrefixFromName( name );
const onResize = useCallback(
( _event, _direction, elt ) => {
setAttributes( {
minHeight: parseInt( elt.style.height, 10 ),
} );
},
[ setAttributes ]
);
const renderButton = () => {
const { categoryId, linkText, productId } = attributes;
return (
<CallToAction
itemId={ categoryId || productId }
linkText={ linkText }
permalink={ ( category || product ).permalink as string }
/>
);
};
const renderNoItemButton = () => {
return (
<>
<p>{ emptyMessage }</p>
<div style={ { flexBasis: '100%', height: '0' } }></div>
<button
type="button"
className="components-button is-secondary"
onClick={ () => setAttributes( { editMode: true } ) }
>
{ noSelectionButtonLabel }
</button>
</>
);
};
const renderNoItem = () => (
<Placeholder
className={ className }
icon={ <Icon icon={ icon } /> }
label={ label }
>
{ isLoading ? <Spinner /> : renderNoItemButton() }
</Placeholder>
);
const styleProps = useStyleProps( attributes );
const renderItem = () => {
const {
contentAlign,
dimRatio,
focalPoint,
hasParallax,
isRepeated,
imageFit,
minHeight,
overlayColor,
overlayGradient,
showDesc,
showPrice,
style,
textColor,
} = attributes;
const containerClass = classnames(
className,
{
'is-selected':
isSelected &&
attributes.categoryId !== 'preview' &&
attributes.productId !== 'preview',
'is-loading': ! item && isLoading,
'is-not-found': ! item && ! isLoading,
'has-background-dim': dimRatio !== 0,
'is-repeated': isRepeated,
},
dimRatioToClass( dimRatio ),
contentAlign !== 'center' && `has-${ contentAlign }-content`,
styleProps.className
);
const containerStyle = {
borderRadius: style?.border?.radius,
color: textColor
? `var(--wp--preset--color--${ textColor })`
: style?.color?.text,
boxSizing: 'border-box',
minHeight,
...styleProps.style,
};
const isImgElement = ! isRepeated && ! hasParallax;
const backgroundImageStyle = getBackgroundImageStyles( {
focalPoint,
imageFit,
isImgElement,
isRepeated,
url: backgroundImageSrc,
} );
const overlayStyle = {
background: overlayGradient,
backgroundColor: overlayColor,
};
return (
<>
<ConstrainedResizable
enable={ { bottom: true } }
onResize={ onResize }
showHandle={ isSelected }
style={ { minHeight } }
/>
<div className={ containerClass } style={ containerStyle }>
<div className={ `${ className }__wrapper` }>
<div
className="background-dim__overlay"
style={ overlayStyle }
/>
{ backgroundImageSrc &&
( isImgElement ? (
<img
alt={ item.name }
className={ `${ className }__background-image` }
src={ backgroundImageSrc }
style={ backgroundImageStyle }
onLoad={ ( e ) => {
setBackgroundImageSize( {
height: e.currentTarget
?.naturalHeight,
width: e.currentTarget
?.naturalWidth,
} );
} }
/>
) : (
<div
className={ classnames(
`${ className }__background-image`,
{
'has-parallax': hasParallax,
}
) }
style={ backgroundImageStyle }
/>
) ) }
<h2
className={ `${ className }__title` }
dangerouslySetInnerHTML={ {
__html: item.name,
} }
/>
{ ! isEmpty( product?.variation ) && (
<h3
className={ `${ className }__variation` }
dangerouslySetInnerHTML={ {
__html: product.variation,
} }
/>
) }
{ showDesc && (
<div
className={ `${ className }__description` }
dangerouslySetInnerHTML={ {
__html:
category?.description ||
product?.short_description,
} }
/>
) }
{ showPrice && (
<div
className={ `${ className }__price` }
dangerouslySetInnerHTML={ {
__html: product.price_html,
} }
/>
) }
<div className={ `${ className }__link` }>
{ renderButton() }
</div>
</div>
</div>
</>
);
};
if ( isEditingImage ) {
return (
<Component
{ ...props }
backgroundImageSize={ backgroundImageSize }
/>
);
}
return (
<>
<Component
{ ...props }
backgroundImageSize={ backgroundImageSize }
/>
{ item ? renderItem() : renderNoItem() }
</>
);
};

View File

@@ -0,0 +1,91 @@
/**
* External dependencies
*/
import { useEffect, useMemo, useState } from '@wordpress/element';
import { WP_REST_API_Category } from 'wp-types';
import { ProductResponseItem } from '@woocommerce/types';
import { useDispatch, useSelect } from '@wordpress/data';
import type { ComponentType } from 'react';
/**
* Internal dependencies
*/
import { EditorBlock } from './types';
interface WithUpdateButtonRequiredAttributes {
editMode: boolean;
}
interface WithUpdateButtonAttributes< T > {
attributes: WithUpdateButtonRequiredAttributes &
EditorBlock< T >[ 'attributes' ];
}
interface WithUpdateButtonCategoryProps< T >
extends WithUpdateButtonAttributes< T > {
category: WP_REST_API_Category;
product: never;
}
interface WithUpdateButtonProductProps< T >
extends WithUpdateButtonAttributes< T > {
category: never;
product: ProductResponseItem;
}
type WithUpdateButtonProps< T extends EditorBlock< T > > =
| ( T & WithUpdateButtonCategoryProps< T > )
| ( T & WithUpdateButtonProductProps< T > );
export const withUpdateButtonAttributes =
< T extends EditorBlock< T > >( Component: ComponentType< T > ) =>
( props: WithUpdateButtonProps< T > ) => {
const [ doUrlUpdate, setDoUrlUpdate ] = useState( false );
const { attributes, category, clientId, product } = props;
const item = category || product;
const { editMode } = attributes;
const permalink =
( item as WP_REST_API_Category )?.link ||
( item as ProductResponseItem )?.permalink;
const block = useSelect( ( select ) => {
return select( 'core/block-editor' ).getBlock( clientId );
} );
const innerBlock = block?.innerBlocks[ 0 ]?.innerBlocks[ 0 ];
const buttonBlockId = innerBlock?.clientId || '';
const currentButtonAttributes = useMemo(
() => innerBlock?.attributes || {},
[ innerBlock ]
);
const { url } = currentButtonAttributes;
const { updateBlockAttributes } = useDispatch( 'core/block-editor' );
useEffect( () => {
if (
doUrlUpdate &&
buttonBlockId &&
! editMode &&
permalink &&
url &&
permalink !== url
) {
updateBlockAttributes( buttonBlockId, {
url: permalink,
} );
setDoUrlUpdate( false );
}
}, [
buttonBlockId,
doUrlUpdate,
editMode,
permalink,
updateBlockAttributes,
url,
] );
const triggerUrlUpdate = () => setDoUrlUpdate( true );
return <Component { ...props } triggerUrlUpdate={ triggerUrlUpdate } />;
};