@wordpress/block-editor
Version:
563 lines (553 loc) • 20 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, __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';
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
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
}) {
const {
isOpen,
...restToggleProps
} = toggleProps;
useEffect(() => {
if (typeof isOpen !== 'undefined') {
onToggleCallback(isOpen);
}
}, [isOpen, onToggleCallback]);
const renderPreviewContent = () => {
return /*#__PURE__*/_jsxs(HStack, {
justify: "flex-start",
as: "span",
className: "block-editor-global-styles-background-panel__inspector-preview-inner",
children: [imgUrl && /*#__PURE__*/_jsx("span", {
className: "block-editor-global-styles-background-panel__inspector-image-indicator-wrapper",
"aria-hidden": true,
children: /*#__PURE__*/_jsx("span", {
className: "block-editor-global-styles-background-panel__inspector-image-indicator",
style: {
backgroundImage: `url(${imgUrl})`
}
})
}), /*#__PURE__*/_jsxs(FlexItem, {
as: "span",
style: imgUrl ? {} : {
flexGrow: 1
},
children: [/*#__PURE__*/_jsx(Truncate, {
numberOfLines: 1,
className: "block-editor-global-styles-background-panel__inspector-media-replace-title",
children: label
}), /*#__PURE__*/_jsx(VisuallyHidden, {
as: "span",
children: imgUrl ? sprintf(/* translators: %s: file name */
__('Background image: %s'), filename || label) : __('No background image selected')
})]
})]
});
};
return as === 'button' ? /*#__PURE__*/_jsx(Button, {
__next40pxDefaultSize: true,
className: className,
...restToggleProps,
"aria-expanded": isOpen,
children: renderPreviewContent()
}) : renderPreviewContent();
}
function BackgroundControlsPanel({
label,
filename,
url: imgUrl,
children,
onToggle: onToggleCallback = noop,
hasImageValue
}) {
if (!hasImageValue) {
return;
}
const imgLabel = label || getFilename(imgUrl) || __('Add background image');
return /*#__PURE__*/_jsx(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 /*#__PURE__*/_jsx(InspectorImagePreviewItem, {
imgUrl: imgUrl,
filename: filename,
label: imgLabel,
toggleProps: toggleProps,
as: "button",
onToggleCallback: onToggleCallback
});
},
renderContent: () => /*#__PURE__*/_jsx(DropdownContentWrapper, {
className: "block-editor-global-styles-background-panel__dropdown-content-wrapper",
paddingSize: "medium",
children: children
})
});
}
function LoadingSpinner() {
return /*#__PURE__*/_jsx(Placeholder, {
className: "block-editor-global-styles-background-panel__loading",
children: /*#__PURE__*/_jsx(Spinner, {})
});
}
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 /*#__PURE__*/_jsxs("div", {
ref: replaceContainerRef,
className: "block-editor-global-styles-background-panel__image-tools-panel-item",
children: [isUploading && /*#__PURE__*/_jsx(LoadingSpinner, {}), /*#__PURE__*/_jsx(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: /*#__PURE__*/_jsx(InspectorImagePreviewItem, {
className: "block-editor-global-styles-background-panel__image-preview",
imgUrl: url,
filename: title,
label: imgLabel
}),
renderToggle: props => /*#__PURE__*/_jsx(Button, {
...props,
__next40pxDefaultSize: true
}),
onError: onUploadError,
onReset: () => {
closeAndFocus();
onResetImage();
},
children: canRemove && /*#__PURE__*/_jsx(MenuItem, {
onClick: () => {
closeAndFocus();
onRemove();
onRemoveImage();
},
children: __('Remove')
})
}), /*#__PURE__*/_jsx(DropZone, {
onFilesDrop: onFilesDrop,
label: __('Drop to upload')
})]
});
}
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 /*#__PURE__*/_jsxs(VStack, {
spacing: 3,
className: "single-column",
children: [/*#__PURE__*/_jsx(FocalPointPicker, {
__nextHasNoMarginBottom: true,
label: __('Focal point'),
url: imageValue,
value: backgroundPositionToCoords(backgroundPositionValue),
onChange: updateBackgroundPosition
}), /*#__PURE__*/_jsx(ToggleControl, {
__nextHasNoMarginBottom: true,
label: __('Fixed background'),
checked: attachmentValue === 'fixed',
onChange: toggleScrollWithPage
}), /*#__PURE__*/_jsxs(ToggleGroupControl, {
__nextHasNoMarginBottom: true,
size: "__unstable-large",
label: __('Size'),
value: currentValueForToggle,
onChange: updateBackgroundSize,
isBlock: true,
help: backgroundSizeHelpText(sizeValue || defaultValues?.backgroundSize),
children: [/*#__PURE__*/_jsx(ToggleGroupControlOption, {
value: "cover",
label: _x('Cover', 'Size option for background image control')
}, "cover"), /*#__PURE__*/_jsx(ToggleGroupControlOption, {
value: "contain",
label: _x('Contain', 'Size option for background image control')
}, "contain"), /*#__PURE__*/_jsx(ToggleGroupControlOption, {
value: "auto",
label: _x('Tile', 'Size option for background image control')
}, "tile")]
}), /*#__PURE__*/_jsxs(HStack, {
justify: "flex-start",
spacing: 2,
as: "span",
children: [/*#__PURE__*/_jsx(UnitControl, {
"aria-label": __('Background image width'),
onChange: updateBackgroundSize,
value: sizeValue,
size: "__unstable-large",
__unstableInputWidth: "100px",
min: 0,
placeholder: __('Auto'),
disabled: currentValueForToggle !== 'auto' || currentValueForToggle === undefined
}), /*#__PURE__*/_jsx(ToggleControl, {
__nextHasNoMarginBottom: true,
label: __('Repeat'),
checked: repeatCheckedValue,
onChange: toggleIsRepeated,
disabled: currentValueForToggle === 'cover'
})]
})]
});
}
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 /*#__PURE__*/_jsx("div", {
className: clsx('block-editor-global-styles-background-panel__inspector-media-replace-container', {
'is-open': isDropDownOpen
}),
children: shouldShowBackgroundImageControls ? /*#__PURE__*/_jsx(BackgroundControlsPanel, {
label: title,
filename: title,
url: url,
onToggle: setIsDropDownOpen,
hasImageValue: hasImageValue,
children: /*#__PURE__*/_jsxs(VStack, {
spacing: 3,
className: "single-column",
children: [/*#__PURE__*/_jsx(BackgroundImageControls, {
onChange: onChange,
style: value,
inheritedValue: resolvedInheritedValue,
displayInPanel: true,
onResetImage: () => {
setIsDropDownOpen(false);
resetBackground();
},
onRemoveImage: () => setIsDropDownOpen(false),
defaultValues: defaultValues
}), /*#__PURE__*/_jsx(BackgroundSizeControls, {
onChange: onChange,
style: value,
defaultValues: defaultValues,
inheritedValue: resolvedInheritedValue
})]
})
}) : /*#__PURE__*/_jsx(BackgroundImageControls, {
onChange: onChange,
style: value,
inheritedValue: resolvedInheritedValue,
defaultValues: defaultValues,
onResetImage: () => {
setIsDropDownOpen(false);
resetBackground();
},
onRemoveImage: () => setIsDropDownOpen(false)
})
});
}
//# sourceMappingURL=index.js.map