@wordpress/block-editor
Version:
740 lines (688 loc) • 19.3 kB
JavaScript
/**
* External dependencies
*/
import clsx from 'clsx';
/**
* WordPress dependencies
*/
import {
ToggleControl,
__experimentalToggleGroupControl as ToggleGroupControl,
__experimentalToggleGroupControlOption as ToggleGroupControlOption,
__experimentalUnitControl as UnitControl,
__experimentalVStack as VStack,
DropZone,
FlexItem,
FocalPointPicker,
MenuItem,
VisuallyHidden,
__experimentalItemGroup as ItemGroup,
__experimentalHStack as HStack,
__experimentalTruncate as Truncate,
Dropdown,
Placeholder,
Spinner,
__experimentalDropdownContentWrapper as DropdownContentWrapper,
Button,
} from '@wordpress/components';
import { __, _x, sprintf } from '@wordpress/i18n';
import { store as noticesStore } from '@wordpress/notices';
import { getFilename } from '@wordpress/url';
import { useRef, useState, useEffect, useMemo } from '@wordpress/element';
import { useDispatch, useSelect } from '@wordpress/data';
import { focus } from '@wordpress/dom';
import { isBlobURL } from '@wordpress/blob';
/**
* Internal dependencies
*/
import { getResolvedValue } from '../global-styles/utils';
import { hasBackgroundImageValue } from '../global-styles/background-panel';
import { setImmutably } from '../../utils/object';
import MediaReplaceFlow from '../media-replace-flow';
import { store as blockEditorStore } from '../../store';
import {
globalStylesDataKey,
globalStylesLinksDataKey,
} from '../../store/private-keys';
const IMAGE_BACKGROUND_TYPE = 'image';
const BACKGROUND_POPOVER_PROPS = {
placement: 'left-start',
offset: 36,
shift: true,
className: 'block-editor-global-styles-background-panel__popover',
};
const noop = () => {};
/**
* Get the help text for the background size control.
*
* @param {string} value backgroundSize value.
* @return {string} Translated help text.
*/
function backgroundSizeHelpText( value ) {
if ( value === 'cover' || value === undefined ) {
return __( 'Image covers the space evenly.' );
}
if ( value === 'contain' ) {
return __( 'Image is contained without distortion.' );
}
return __( 'Image has a fixed width.' );
}
/**
* Converts decimal x and y coords from FocalPointPicker to percentage-based values
* to use as backgroundPosition value.
*
* @param {{x?:number, y?:number}} value FocalPointPicker coords.
* @return {string} backgroundPosition value.
*/
export const coordsToBackgroundPosition = ( value ) => {
if ( ! value || ( isNaN( value.x ) && isNaN( value.y ) ) ) {
return undefined;
}
const x = isNaN( value.x ) ? 0.5 : value.x;
const y = isNaN( value.y ) ? 0.5 : value.y;
return `${ x * 100 }% ${ y * 100 }%`;
};
/**
* Converts backgroundPosition value to x and y coords for FocalPointPicker.
*
* @param {string} value backgroundPosition value.
* @return {{x?:number, y?:number}} FocalPointPicker coords.
*/
export const backgroundPositionToCoords = ( value ) => {
if ( ! value ) {
return { x: undefined, y: undefined };
}
let [ x, y ] = value.split( ' ' ).map( ( v ) => parseFloat( v ) / 100 );
x = isNaN( x ) ? undefined : x;
y = isNaN( y ) ? x : y;
return { x, y };
};
function InspectorImagePreviewItem( {
as = 'span',
imgUrl,
toggleProps = {},
filename,
label,
className,
onToggleCallback = noop,
} ) {
useEffect( () => {
if ( typeof toggleProps?.isOpen !== 'undefined' ) {
onToggleCallback( toggleProps?.isOpen );
}
}, [ toggleProps?.isOpen, onToggleCallback ] );
return (
<ItemGroup as={ as } className={ className } { ...toggleProps }>
<HStack
justify="flex-start"
as="span"
className="block-editor-global-styles-background-panel__inspector-preview-inner"
>
{ imgUrl && (
<span
className="block-editor-global-styles-background-panel__inspector-image-indicator-wrapper"
aria-hidden
>
<span
className="block-editor-global-styles-background-panel__inspector-image-indicator"
style={ {
backgroundImage: `url(${ imgUrl })`,
} }
/>
</span>
) }
<FlexItem as="span" style={ imgUrl ? {} : { flexGrow: 1 } }>
<Truncate
numberOfLines={ 1 }
className="block-editor-global-styles-background-panel__inspector-media-replace-title"
>
{ label }
</Truncate>
<VisuallyHidden as="span">
{ imgUrl
? sprintf(
/* translators: %s: file name */
__( 'Background image: %s' ),
filename || label
)
: __( 'No background image selected' ) }
</VisuallyHidden>
</FlexItem>
</HStack>
</ItemGroup>
);
}
function BackgroundControlsPanel( {
label,
filename,
url: imgUrl,
children,
onToggle: onToggleCallback = noop,
hasImageValue,
} ) {
if ( ! hasImageValue ) {
return;
}
const imgLabel =
label || getFilename( imgUrl ) || __( 'Add background image' );
return (
<Dropdown
popoverProps={ BACKGROUND_POPOVER_PROPS }
renderToggle={ ( { onToggle, isOpen } ) => {
const toggleProps = {
onClick: onToggle,
className:
'block-editor-global-styles-background-panel__dropdown-toggle',
'aria-expanded': isOpen,
'aria-label': __(
'Background size, position and repeat options.'
),
isOpen,
};
return (
<InspectorImagePreviewItem
imgUrl={ imgUrl }
filename={ filename }
label={ imgLabel }
toggleProps={ toggleProps }
as="button"
onToggleCallback={ onToggleCallback }
/>
);
} }
renderContent={ () => (
<DropdownContentWrapper
className="block-editor-global-styles-background-panel__dropdown-content-wrapper"
paddingSize="medium"
>
{ children }
</DropdownContentWrapper>
) }
/>
);
}
function LoadingSpinner() {
return (
<Placeholder className="block-editor-global-styles-background-panel__loading">
<Spinner />
</Placeholder>
);
}
function BackgroundImageControls( {
onChange,
style,
inheritedValue,
onRemoveImage = noop,
onResetImage = noop,
displayInPanel,
defaultValues,
} ) {
const [ isUploading, setIsUploading ] = useState( false );
const { getSettings } = useSelect( blockEditorStore );
const { id, title, url } = style?.background?.backgroundImage || {
...inheritedValue?.background?.backgroundImage,
};
const replaceContainerRef = useRef();
const { createErrorNotice } = useDispatch( noticesStore );
const onUploadError = ( message ) => {
createErrorNotice( message, { type: 'snackbar' } );
setIsUploading( false );
};
const resetBackgroundImage = () =>
onChange(
setImmutably(
style,
[ 'background', 'backgroundImage' ],
undefined
)
);
const onSelectMedia = ( media ) => {
if ( ! media || ! media.url ) {
resetBackgroundImage();
setIsUploading( false );
return;
}
if ( isBlobURL( media.url ) ) {
setIsUploading( true );
return;
}
// For media selections originated from a file upload.
if (
( media.media_type &&
media.media_type !== IMAGE_BACKGROUND_TYPE ) ||
( ! media.media_type &&
media.type &&
media.type !== IMAGE_BACKGROUND_TYPE )
) {
onUploadError(
__( 'Only images can be used as a background image.' )
);
return;
}
const sizeValue =
style?.background?.backgroundSize || defaultValues?.backgroundSize;
const positionValue = style?.background?.backgroundPosition;
onChange(
setImmutably( style, [ 'background' ], {
...style?.background,
backgroundImage: {
url: media.url,
id: media.id,
source: 'file',
title: media.title || undefined,
},
backgroundPosition:
/*
* A background image uploaded and set in the editor receives a default background position of '50% 0',
* when the background image size is the equivalent of "Tile".
* This is to increase the chance that the image's focus point is visible.
* This is in-editor only to assist with the user experience.
*/
! positionValue && ( 'auto' === sizeValue || ! sizeValue )
? '50% 0'
: positionValue,
backgroundSize: sizeValue,
} )
);
setIsUploading( false );
};
// Drag and drop callback, restricting image to one.
const onFilesDrop = ( filesList ) => {
getSettings().mediaUpload( {
allowedTypes: [ IMAGE_BACKGROUND_TYPE ],
filesList,
onFileChange( [ image ] ) {
onSelectMedia( image );
},
onError: onUploadError,
multiple: false,
} );
};
const hasValue = hasBackgroundImageValue( style );
const closeAndFocus = () => {
const [ toggleButton ] = focus.tabbable.find(
replaceContainerRef.current
);
// Focus the toggle button and close the dropdown menu.
// This ensures similar behaviour as to selecting an image, where the dropdown is
// closed and focus is redirected to the dropdown toggle button.
toggleButton?.focus();
toggleButton?.click();
};
const onRemove = () =>
onChange(
setImmutably( style, [ 'background' ], {
backgroundImage: 'none',
} )
);
const canRemove = ! hasValue && hasBackgroundImageValue( inheritedValue );
const imgLabel =
title || getFilename( url ) || __( 'Add background image' );
return (
<div
ref={ replaceContainerRef }
className="block-editor-global-styles-background-panel__image-tools-panel-item"
>
{ isUploading && <LoadingSpinner /> }
<MediaReplaceFlow
mediaId={ id }
mediaURL={ url }
allowedTypes={ [ IMAGE_BACKGROUND_TYPE ] }
accept="image/*"
onSelect={ onSelectMedia }
popoverProps={ {
className: clsx( {
'block-editor-global-styles-background-panel__media-replace-popover':
displayInPanel,
} ),
} }
name={
<InspectorImagePreviewItem
className="block-editor-global-styles-background-panel__image-preview"
imgUrl={ url }
filename={ title }
label={ imgLabel }
/>
}
renderToggle={ ( props ) => (
<Button { ...props } __next40pxDefaultSize />
) }
onError={ onUploadError }
onReset={ () => {
closeAndFocus();
onResetImage();
} }
>
{ canRemove && (
<MenuItem
onClick={ () => {
closeAndFocus();
onRemove();
onRemoveImage();
} }
>
{ __( 'Remove' ) }
</MenuItem>
) }
</MediaReplaceFlow>
<DropZone
onFilesDrop={ onFilesDrop }
label={ __( 'Drop to upload' ) }
/>
</div>
);
}
function BackgroundSizeControls( {
onChange,
style,
inheritedValue,
defaultValues,
} ) {
const sizeValue =
style?.background?.backgroundSize ||
inheritedValue?.background?.backgroundSize;
const repeatValue =
style?.background?.backgroundRepeat ||
inheritedValue?.background?.backgroundRepeat;
const imageValue =
style?.background?.backgroundImage?.url ||
inheritedValue?.background?.backgroundImage?.url;
const isUploadedImage = style?.background?.backgroundImage?.id;
const positionValue =
style?.background?.backgroundPosition ||
inheritedValue?.background?.backgroundPosition;
const attachmentValue =
style?.background?.backgroundAttachment ||
inheritedValue?.background?.backgroundAttachment;
/*
* Set default values for uploaded images.
* The default values are passed by the consumer.
* Block-level controls may have different defaults to root-level controls.
* A falsy value is treated by default as `auto` (Tile).
*/
let currentValueForToggle =
! sizeValue && isUploadedImage
? defaultValues?.backgroundSize
: sizeValue || 'auto';
/*
* The incoming value could be a value + unit, e.g. '20px'.
* In this case set the value to 'tile'.
*/
currentValueForToggle = ! [ 'cover', 'contain', 'auto' ].includes(
currentValueForToggle
)
? 'auto'
: currentValueForToggle;
/*
* If the current value is `cover` and the repeat value is `undefined`, then
* the toggle should be unchecked as the default state. Otherwise, the toggle
* should reflect the current repeat value.
*/
const repeatCheckedValue = ! (
repeatValue === 'no-repeat' ||
( currentValueForToggle === 'cover' && repeatValue === undefined )
);
const updateBackgroundSize = ( next ) => {
// When switching to 'contain' toggle the repeat off.
let nextRepeat = repeatValue;
let nextPosition = positionValue;
if ( next === 'contain' ) {
nextRepeat = 'no-repeat';
nextPosition = undefined;
}
if ( next === 'cover' ) {
nextRepeat = undefined;
nextPosition = undefined;
}
if (
( currentValueForToggle === 'cover' ||
currentValueForToggle === 'contain' ) &&
next === 'auto'
) {
nextRepeat = undefined;
/*
* A background image uploaded and set in the editor (an image with a record id),
* receives a default background position of '50% 0',
* when the toggle switches to "Tile". This is to increase the chance that
* the image's focus point is visible.
* This is in-editor only to assist with the user experience.
*/
if ( !! style?.background?.backgroundImage?.id ) {
nextPosition = '50% 0';
}
}
/*
* Next will be null when the input is cleared,
* in which case the value should be 'auto'.
*/
if ( ! next && currentValueForToggle === 'auto' ) {
next = 'auto';
}
onChange(
setImmutably( style, [ 'background' ], {
...style?.background,
backgroundPosition: nextPosition,
backgroundRepeat: nextRepeat,
backgroundSize: next,
} )
);
};
const updateBackgroundPosition = ( next ) => {
onChange(
setImmutably(
style,
[ 'background', 'backgroundPosition' ],
coordsToBackgroundPosition( next )
)
);
};
const toggleIsRepeated = () =>
onChange(
setImmutably(
style,
[ 'background', 'backgroundRepeat' ],
repeatCheckedValue === true ? 'no-repeat' : 'repeat'
)
);
const toggleScrollWithPage = () =>
onChange(
setImmutably(
style,
[ 'background', 'backgroundAttachment' ],
attachmentValue === 'fixed' ? 'scroll' : 'fixed'
)
);
// Set a default background position for non-site-wide, uploaded images with a size of 'contain'.
const backgroundPositionValue =
! positionValue && isUploadedImage && 'contain' === sizeValue
? defaultValues?.backgroundPosition
: positionValue;
return (
<VStack spacing={ 3 } className="single-column">
<FocalPointPicker
__nextHasNoMarginBottom
label={ __( 'Focal point' ) }
url={ imageValue }
value={ backgroundPositionToCoords( backgroundPositionValue ) }
onChange={ updateBackgroundPosition }
/>
<ToggleControl
__nextHasNoMarginBottom
label={ __( 'Fixed background' ) }
checked={ attachmentValue === 'fixed' }
onChange={ toggleScrollWithPage }
/>
<ToggleGroupControl
__nextHasNoMarginBottom
size="__unstable-large"
label={ __( 'Size' ) }
value={ currentValueForToggle }
onChange={ updateBackgroundSize }
isBlock
help={ backgroundSizeHelpText(
sizeValue || defaultValues?.backgroundSize
) }
>
<ToggleGroupControlOption
key="cover"
value="cover"
label={ _x(
'Cover',
'Size option for background image control'
) }
/>
<ToggleGroupControlOption
key="contain"
value="contain"
label={ _x(
'Contain',
'Size option for background image control'
) }
/>
<ToggleGroupControlOption
key="tile"
value="auto"
label={ _x(
'Tile',
'Size option for background image control'
) }
/>
</ToggleGroupControl>
<HStack justify="flex-start" spacing={ 2 } as="span">
<UnitControl
aria-label={ __( 'Background image width' ) }
onChange={ updateBackgroundSize }
value={ sizeValue }
size="__unstable-large"
__unstableInputWidth="100px"
min={ 0 }
placeholder={ __( 'Auto' ) }
disabled={
currentValueForToggle !== 'auto' ||
currentValueForToggle === undefined
}
/>
<ToggleControl
__nextHasNoMarginBottom
label={ __( 'Repeat' ) }
checked={ repeatCheckedValue }
onChange={ toggleIsRepeated }
disabled={ currentValueForToggle === 'cover' }
/>
</HStack>
</VStack>
);
}
export default function BackgroundImagePanel( {
value,
onChange,
inheritedValue = value,
settings,
defaultValues = {},
} ) {
/*
* Resolve any inherited "ref" pointers.
* Should the block editor need resolved, inherited values
* across all controls, this could be abstracted into a hook,
* e.g., useResolveGlobalStyle
*/
const { globalStyles, _links } = useSelect( ( select ) => {
const { getSettings } = select( blockEditorStore );
const _settings = getSettings();
return {
globalStyles: _settings[ globalStylesDataKey ],
_links: _settings[ globalStylesLinksDataKey ],
};
}, [] );
const resolvedInheritedValue = useMemo( () => {
const resolvedValues = {
background: {},
};
if ( ! inheritedValue?.background ) {
return inheritedValue;
}
Object.entries( inheritedValue?.background ).forEach(
( [ key, backgroundValue ] ) => {
resolvedValues.background[ key ] = getResolvedValue(
backgroundValue,
{
styles: globalStyles,
_links,
}
);
}
);
return resolvedValues;
}, [ globalStyles, _links, inheritedValue ] );
const resetBackground = () =>
onChange( setImmutably( value, [ 'background' ], {} ) );
const { title, url } = value?.background?.backgroundImage || {
...resolvedInheritedValue?.background?.backgroundImage,
};
const hasImageValue =
hasBackgroundImageValue( value ) ||
hasBackgroundImageValue( resolvedInheritedValue );
const imageValue =
value?.background?.backgroundImage ||
inheritedValue?.background?.backgroundImage;
const shouldShowBackgroundImageControls =
hasImageValue &&
'none' !== imageValue &&
( settings?.background?.backgroundSize ||
settings?.background?.backgroundPosition ||
settings?.background?.backgroundRepeat );
const [ isDropDownOpen, setIsDropDownOpen ] = useState( false );
return (
<div
className={ clsx(
'block-editor-global-styles-background-panel__inspector-media-replace-container',
{
'is-open': isDropDownOpen,
}
) }
>
{ shouldShowBackgroundImageControls ? (
<BackgroundControlsPanel
label={ title }
filename={ title }
url={ url }
onToggle={ setIsDropDownOpen }
hasImageValue={ hasImageValue }
>
<VStack spacing={ 3 } className="single-column">
<BackgroundImageControls
onChange={ onChange }
style={ value }
inheritedValue={ resolvedInheritedValue }
displayInPanel
onResetImage={ () => {
setIsDropDownOpen( false );
resetBackground();
} }
onRemoveImage={ () => setIsDropDownOpen( false ) }
defaultValues={ defaultValues }
/>
<BackgroundSizeControls
onChange={ onChange }
style={ value }
defaultValues={ defaultValues }
inheritedValue={ resolvedInheritedValue }
/>
</VStack>
</BackgroundControlsPanel>
) : (
<BackgroundImageControls
onChange={ onChange }
style={ value }
inheritedValue={ resolvedInheritedValue }
defaultValues={ defaultValues }
onResetImage={ () => {
setIsDropDownOpen( false );
resetBackground();
} }
onRemoveImage={ () => setIsDropDownOpen( false ) }
/>
) }
</div>
);
}