UNPKG

@wordpress/block-editor

Version:
563 lines (553 loc) 20 kB
/** * 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