From 9f7966610ed14a6e00b832a3c97e1d10caf25d7c Mon Sep 17 00:00:00 2001 From: Ramon Date: Mon, 16 Sep 2024 11:33:11 +1000 Subject: [PATCH] Global Styles: refactor site background controls and move site global styles into Background group (#65304) Expose background styles in the top level global styles navigation menu. Background is now underneath color. Refactor background image controls and their styles into a dedicated component. This is in preparation for adding colors and other background controls later. Co-authored-by: amitraj2203 Co-authored-by: mtias Co-authored-by: mirka <0mirka00@git.wordpress.org> Co-authored-by: tyxla Co-authored-by: ramonjd Co-authored-by: ciampo Co-authored-by: jasmussen Co-authored-by: richtabor Co-authored-by: jameskoster --- .../background-image-control/index.js | 741 +++++++++++++++++ .../background-image-control/style.scss | 170 ++++ .../background-image-control/test/index.js | 47 ++ .../global-styles/background-panel.js | 749 +----------------- .../src/components/global-styles/style.scss | 168 ---- .../global-styles/test/background-panel.js | 48 +- packages/block-editor/src/style.scss | 1 + .../src/components/global-styles/root-menu.js | 16 + .../global-styles/screen-background.js | 37 + .../components/global-styles/screen-layout.js | 16 +- .../src/components/global-styles/ui.js | 5 + packages/icons/src/index.js | 1 + packages/icons/src/library/background.js | 16 + 13 files changed, 1057 insertions(+), 958 deletions(-) create mode 100644 packages/block-editor/src/components/background-image-control/index.js create mode 100644 packages/block-editor/src/components/background-image-control/style.scss create mode 100644 packages/block-editor/src/components/background-image-control/test/index.js create mode 100644 packages/edit-site/src/components/global-styles/screen-background.js create mode 100644 packages/icons/src/library/background.js diff --git a/packages/block-editor/src/components/background-image-control/index.js b/packages/block-editor/src/components/background-image-control/index.js new file mode 100644 index 0000000000000..2703aa3988d64 --- /dev/null +++ b/packages/block-editor/src/components/background-image-control/index.js @@ -0,0 +1,741 @@ +/** + * 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, +} 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 ( + + + { imgUrl && ( + + + + ) } + + + { label } + + + { imgUrl + ? sprintf( + /* translators: %s: file name */ + __( 'Background image: %s' ), + filename || label + ) + : __( 'No background image selected' ) } + + + + + ); +} + +function BackgroundControlsPanel( { + label, + filename, + url: imgUrl, + children, + onToggle: onToggleCallback = noop, + hasImageValue, +} ) { + if ( ! hasImageValue ) { + return; + } + + const imgLabel = + label || getFilename( imgUrl ) || __( 'Add background image' ); + + return ( + { + 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 ( + + ); + } } + renderContent={ () => ( + + { children } + + ) } + /> + ); +} + +function LoadingSpinner() { + return ( + + + + ); +} + +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 ) => { + if ( filesList?.length > 1 ) { + onUploadError( + __( 'Only one image can be used as a background image.' ) + ); + return; + } + getSettings().mediaUpload( { + allowedTypes: [ IMAGE_BACKGROUND_TYPE ], + filesList, + onFileChange( [ image ] ) { + onSelectMedia( image ); + }, + onError: onUploadError, + } ); + }; + + 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 ( +
+ { isUploading && } + + } + variant="secondary" + onError={ onUploadError } + onReset={ () => { + closeAndFocus(); + onResetImage(); + } } + > + { canRemove && ( + { + closeAndFocus(); + onRemove(); + onRemoveImage(); + } } + > + { __( 'Remove' ) } + + ) } + + +
+ ); +} + +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 ( + + + + + + + + + + + + + + ); +} + +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 ( +
+ { shouldShowBackgroundImageControls ? ( + + + { + setIsDropDownOpen( false ); + resetBackground(); + } } + onRemoveImage={ () => setIsDropDownOpen( false ) } + defaultValues={ defaultValues } + /> + + + + ) : ( + { + setIsDropDownOpen( false ); + resetBackground(); + } } + onRemoveImage={ () => setIsDropDownOpen( false ) } + /> + ) } +
+ ); +} diff --git a/packages/block-editor/src/components/background-image-control/style.scss b/packages/block-editor/src/components/background-image-control/style.scss new file mode 100644 index 0000000000000..cde8044c24c12 --- /dev/null +++ b/packages/block-editor/src/components/background-image-control/style.scss @@ -0,0 +1,170 @@ +.block-editor-global-styles-background-panel__inspector-media-replace-container { + border: $border-width solid $gray-300; + border-radius: $radius-small; + // Full width. ToolsPanel lays out children in a grid. + grid-column: 1 / -1; + + &.is-open { + background-color: $gray-100; + } + + .block-editor-global-styles-background-panel__image-tools-panel-item { + flex-grow: 1; + border: 0; + + .components-dropdown { + display: block; + } + } + + .block-editor-global-styles-background-panel__inspector-preview-inner { + height: 100%; + } + + .components-dropdown { + display: block; + height: 36px; + } +} + +.block-editor-global-styles-background-panel__image-tools-panel-item { + border: $border-width solid $gray-300; + + // Full width. ToolsPanel lays out children in a grid. + grid-column: 1 / -1; + + // Ensure the dropzone is positioned to the size of the item. + position: relative; + + // Since there is no option to skip rendering the drag'n'drop icon in drop + // zone, we hide it for now. + .components-drop-zone__content-icon { + display: none; + } + + .components-dropdown { + display: block; + height: 36px; + } + + button.components-button { + color: $gray-900; + width: 100%; + display: block; + + &:hover { + color: var(--wp-admin-theme-color); + } + + &:focus { + box-shadow: inset 0 0 0 var(--wp-admin-border-width-focus) var(--wp-admin-theme-color); + } + } + + .block-editor-global-styles-background-panel__loading { + height: 100%; + position: absolute; + z-index: 1; + width: 100%; + padding: 10px 0 0 0; + + svg { + margin: 0; + } + } +} + +.block-editor-global-styles-background-panel__image-preview-content, +.block-editor-global-styles-background-panel__dropdown-toggle { + height: 100%; + width: 100%; + padding-left: $grid-unit-15; +} + +.block-editor-global-styles-background-panel__dropdown-toggle { + cursor: pointer; + background: transparent; + border: none; +} + +.block-editor-global-styles-background-panel__inspector-media-replace-title { + word-break: break-all; + // The Button component is white-space: nowrap, and that won't work with line-clamp. + white-space: normal; + + // Without this, the ellipsis can sometimes be partially hidden by the Button padding. + text-align: start; + text-align-last: center; +} + +.block-editor-global-styles-background-panel__inspector-preview-inner { + .block-editor-global-styles-background-panel__inspector-image-indicator-wrapper { + width: 20px; + height: 20px; + min-width: auto; + } +} + +.block-editor-global-styles-background-panel__inspector-image-indicator { + background-size: cover; + border-radius: $radius-round; + width: 20px; + height: 20px; + display: block; + position: relative; +} + +.block-editor-global-styles-background-panel__inspector-image-indicator::after { + content: ""; + position: absolute; + top: -1px; + left: -1px; + bottom: -1px; + right: -1px; + border-radius: $radius-round; + box-shadow: inset 0 0 0 $border-width rgba(0, 0, 0, 0.2); + // Show a thin outline in Windows high contrast mode, otherwise the button is invisible. + border: 1px solid transparent; + box-sizing: inherit; +} + +.block-editor-global-styles-background-panel__dropdown-content-wrapper { + min-width: 260px; + overflow-x: hidden; + + .components-focal-point-picker-wrapper { + background-color: $gray-100; + width: 100%; + border-radius: $radius-small; + border: $border-width solid $gray-300; + } + + .components-focal-point-picker__media--image { + max-height: 180px; + } + + // Override focal picker to avoid a double border. + .components-focal-point-picker::after { + content: none; + } +} + +// Push control panel into the background when the media modal is open. +.modal-open .block-editor-global-styles-background-panel__popover { + z-index: z-index(".block-editor-global-styles-background-panel__popover"); +} + +.block-editor-global-styles-background-panel__media-replace-popover { + .components-popover__content { + // width of block-editor-global-styles-background-panel__dropdown-content-wrapper minus padding. + width: 226px; + } + + .components-button { + padding: 0 $grid-unit-10; + } + + .components-button .components-menu-items__item-icon.has-icon-right { + margin-left: $grid-unit-30 - $grid-unit-10; + } +} diff --git a/packages/block-editor/src/components/background-image-control/test/index.js b/packages/block-editor/src/components/background-image-control/test/index.js new file mode 100644 index 0000000000000..ebadad97eda02 --- /dev/null +++ b/packages/block-editor/src/components/background-image-control/test/index.js @@ -0,0 +1,47 @@ +/** + * Internal dependencies + */ + +import { backgroundPositionToCoords, coordsToBackgroundPosition } from '../'; + +describe( 'backgroundPositionToCoords', () => { + it( 'should return the correct coordinates for a percentage value using 2-value syntax', () => { + expect( backgroundPositionToCoords( '25% 75%' ) ).toEqual( { + x: 0.25, + y: 0.75, + } ); + } ); + + it( 'should return the correct coordinates for a percentage using 1-value syntax', () => { + expect( backgroundPositionToCoords( '50%' ) ).toEqual( { + x: 0.5, + y: 0.5, + } ); + } ); + + it( 'should return undefined coords in given an empty value', () => { + expect( backgroundPositionToCoords( '' ) ).toEqual( { + x: undefined, + y: undefined, + } ); + } ); + + it( 'should return undefined coords in given a string that cannot be converted', () => { + expect( backgroundPositionToCoords( 'apples' ) ).toEqual( { + x: undefined, + y: undefined, + } ); + } ); +} ); + +describe( 'coordsToBackgroundPosition', () => { + it( 'should return the correct background position for a set of coordinates', () => { + expect( coordsToBackgroundPosition( { x: 0.25, y: 0.75 } ) ).toBe( + '25% 75%' + ); + } ); + + it( 'should return undefined if no coordinates are provided', () => { + expect( coordsToBackgroundPosition( {} ) ).toBeUndefined(); + } ); +} ); diff --git a/packages/block-editor/src/components/global-styles/background-panel.js b/packages/block-editor/src/components/global-styles/background-panel.js index 93fed5780e454..c66ea01bce549 100644 --- a/packages/block-editor/src/components/global-styles/background-panel.js +++ b/packages/block-editor/src/components/global-styles/background-panel.js @@ -1,71 +1,22 @@ -/** - * External dependencies - */ -import clsx from 'clsx'; - /** * WordPress dependencies */ import { __experimentalToolsPanel as ToolsPanel, __experimentalToolsPanelItem as ToolsPanelItem, - 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, } from '@wordpress/components'; -import { __, _x, sprintf } from '@wordpress/i18n'; -import { store as noticesStore } from '@wordpress/notices'; -import { getFilename } from '@wordpress/url'; -import { - useCallback, - Platform, - useRef, - useState, - useEffect, - useMemo, -} from '@wordpress/element'; -import { useDispatch, useSelect } from '@wordpress/data'; -import { focus } from '@wordpress/dom'; -import { isBlobURL } from '@wordpress/blob'; - +import { useCallback, Platform } from '@wordpress/element'; /** * Internal dependencies */ -import { useToolsPanelDropdownMenuProps, getResolvedValue } from './utils'; +import BackgroundImageControl from '../background-image-control'; +import { useToolsPanelDropdownMenuProps } from './utils'; 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 { __ } from '@wordpress/i18n'; -const IMAGE_BACKGROUND_TYPE = 'image'; const DEFAULT_CONTROLS = { backgroundImage: true, }; -const BACKGROUND_POPOVER_PROPS = { - placement: 'left-start', - offset: 36, - shift: true, - className: 'block-editor-global-styles-background-panel__popover', -}; -const noop = () => {}; /** * Checks site settings to see if the background panel may be used. @@ -110,567 +61,6 @@ export function hasBackgroundImageValue( style ) { ); } -/** - * 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 ( - - - { imgUrl && ( - - - - ) } - - - { label } - - - { imgUrl - ? sprintf( - /* translators: %s: file name */ - __( 'Background image: %s' ), - filename || label - ) - : __( 'No background image selected' ) } - - - - - ); -} - -function BackgroundControlsPanel( { - label, - filename, - url: imgUrl, - children, - onToggle: onToggleCallback = noop, - hasImageValue, -} ) { - if ( ! hasImageValue ) { - return; - } - - const imgLabel = - label || getFilename( imgUrl ) || __( 'Add background image' ); - - return ( - { - 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 ( - - ); - } } - renderContent={ () => ( - - { children } - - ) } - /> - ); -} - -function LoadingSpinner() { - return ( - - - - ); -} - -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 ) => { - if ( filesList?.length > 1 ) { - onUploadError( - __( 'Only one image can be used as a background image.' ) - ); - return; - } - getSettings().mediaUpload( { - allowedTypes: [ IMAGE_BACKGROUND_TYPE ], - filesList, - onFileChange( [ image ] ) { - onSelectMedia( image ); - }, - onError: onUploadError, - } ); - }; - - 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 ( -
- { isUploading && } - - } - variant="secondary" - onError={ onUploadError } - onReset={ () => { - closeAndFocus(); - onResetImage(); - } } - > - { canRemove && ( - { - closeAndFocus(); - onRemove(); - onRemoveImage(); - } } - > - { __( 'Remove' ) } - - ) } - - -
- ); -} - -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 ( - - - - - - - - - - - - - - ); -} - function BackgroundToolsPanel( { resetAllFilter, onChange, @@ -697,54 +87,20 @@ function BackgroundToolsPanel( { ); } -export default function BackgroundPanel( { +export default function BackgroundImagePanel( { as: Wrapper = BackgroundToolsPanel, value, onChange, - inheritedValue = value, + inheritedValue, settings, panelId, defaultControls = DEFAULT_CONTROLS, defaultValues = {}, headerLabel = __( 'Background image' ), } ) { - /* - * 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 showBackgroundImageControl = useHasBackgroundPanel( settings ); + const resetBackground = () => + onChange( setImmutably( value, [ 'background' ], {} ) ); const resetAllFilter = useCallback( ( previousValue ) => { return { ...previousValue, @@ -752,29 +108,6 @@ export default function BackgroundPanel( { }; }, [] ); - 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 ( -
+ { showBackgroundImageControl && ( !! value?.background } label={ __( 'Image' ) } @@ -798,53 +124,16 @@ export default function BackgroundPanel( { isShownByDefault={ defaultControls.backgroundImage } panelId={ panelId } > - { shouldShowBackgroundImageControls ? ( - - - { - setIsDropDownOpen( false ); - resetBackground(); - } } - onRemoveImage={ () => - setIsDropDownOpen( false ) - } - defaultValues={ defaultValues } - /> - - - - ) : ( - { - setIsDropDownOpen( false ); - resetBackground(); - } } - onRemoveImage={ () => setIsDropDownOpen( false ) } - /> - ) } + -
+ ) }
); } diff --git a/packages/block-editor/src/components/global-styles/style.scss b/packages/block-editor/src/components/global-styles/style.scss index 1cebbfe7a85d4..3ba4f81d09daf 100644 --- a/packages/block-editor/src/components/global-styles/style.scss +++ b/packages/block-editor/src/components/global-styles/style.scss @@ -70,171 +70,3 @@ /*rtl:ignore*/ direction: ltr; } - - -.block-editor-global-styles-background-panel__inspector-media-replace-container { - border: $border-width solid $gray-300; - border-radius: $radius-small; - // Full width. ToolsPanel lays out children in a grid. - grid-column: 1 / -1; - - &.is-open { - background-color: $gray-100; - } - - .block-editor-global-styles-background-panel__image-tools-panel-item { - flex-grow: 1; - border: 0; - .components-dropdown { - display: block; - } - } - - .block-editor-global-styles-background-panel__inspector-preview-inner { - height: 100%; - } - - .components-dropdown { - display: block; - height: 36px; - } -} - -.block-editor-global-styles-background-panel__image-tools-panel-item { - border: $border-width solid $gray-300; - - // Full width. ToolsPanel lays out children in a grid. - grid-column: 1 / -1; - - // Ensure the dropzone is positioned to the size of the item. - position: relative; - - // Since there is no option to skip rendering the drag'n'drop icon in drop - // zone, we hide it for now. - .components-drop-zone__content-icon { - display: none; - } - - .components-dropdown { - display: block; - height: 36px; - } - - button.components-button { - color: $gray-900; - width: 100%; - display: block; - - &:hover { - color: var(--wp-admin-theme-color); - } - - &:focus { - box-shadow: inset 0 0 0 var(--wp-admin-border-width-focus) var(--wp-admin-theme-color); - } - } - - .block-editor-global-styles-background-panel__loading { - height: 100%; - position: absolute; - z-index: 1; - width: 100%; - padding: 10px 0 0 0; - svg { - margin: 0; - } - } -} - -.block-editor-global-styles-background-panel__image-preview-content, -.block-editor-global-styles-background-panel__dropdown-toggle { - height: 100%; - width: 100%; - padding-left: $grid-unit-15; -} - -.block-editor-global-styles-background-panel__dropdown-toggle { - cursor: pointer; - background: transparent; - border: none; -} - -.block-editor-global-styles-background-panel__inspector-media-replace-title { - word-break: break-all; - // The Button component is white-space: nowrap, and that won't work with line-clamp. - white-space: normal; - - // Without this, the ellipsis can sometimes be partially hidden by the Button padding. - text-align: start; - text-align-last: center; -} - -.block-editor-global-styles-background-panel__inspector-preview-inner { - .block-editor-global-styles-background-panel__inspector-image-indicator-wrapper { - width: 20px; - height: 20px; - min-width: auto; - } -} - -.block-editor-global-styles-background-panel__inspector-image-indicator { - background-size: cover; - border-radius: $radius-round; - width: 20px; - height: 20px; - display: block; - position: relative; -} - -.block-editor-global-styles-background-panel__inspector-image-indicator::after { - content: ""; - position: absolute; - top: -1px; - left: -1px; - bottom: -1px; - right: -1px; - border-radius: $radius-round; - box-shadow: inset 0 0 0 $border-width rgba(0, 0, 0, 0.2); - // Show a thin outline in Windows high contrast mode, otherwise the button is invisible. - border: 1px solid transparent; - box-sizing: inherit; -} - -.block-editor-global-styles-background-panel__dropdown-content-wrapper { - min-width: 260px; - overflow-x: hidden; - - .components-focal-point-picker-wrapper { - background-color: $gray-100; - width: 100%; - border-radius: $radius-small; - border: $border-width solid $gray-300; - } - - .components-focal-point-picker__media--image { - max-height: 180px; - } - - // Override focal picker to avoid a double border. - .components-focal-point-picker::after { - content: none; - } -} - -// Push control panel into the background when the media modal is open. -.modal-open .block-editor-global-styles-background-panel__popover { - z-index: z-index(".block-editor-global-styles-background-panel__popover"); -} - -.block-editor-global-styles-background-panel__media-replace-popover { - .components-popover__content { - // width of block-editor-global-styles-background-panel__dropdown-content-wrapper minus padding. - width: 226px; - } - .components-button { - padding: 0 $grid-unit-10; - } - .components-button .components-menu-items__item-icon.has-icon-right { - margin-left: $grid-unit-30 - $grid-unit-10; - } -} diff --git a/packages/block-editor/src/components/global-styles/test/background-panel.js b/packages/block-editor/src/components/global-styles/test/background-panel.js index d0b3a8ad60170..ad2c55e747f70 100644 --- a/packages/block-editor/src/components/global-styles/test/background-panel.js +++ b/packages/block-editor/src/components/global-styles/test/background-panel.js @@ -2,53 +2,7 @@ * Internal dependencies */ -import { - backgroundPositionToCoords, - coordsToBackgroundPosition, - hasBackgroundImageValue, -} from '../background-panel'; - -describe( 'backgroundPositionToCoords', () => { - it( 'should return the correct coordinates for a percentage value using 2-value syntax', () => { - expect( backgroundPositionToCoords( '25% 75%' ) ).toEqual( { - x: 0.25, - y: 0.75, - } ); - } ); - - it( 'should return the correct coordinates for a percentage using 1-value syntax', () => { - expect( backgroundPositionToCoords( '50%' ) ).toEqual( { - x: 0.5, - y: 0.5, - } ); - } ); - - it( 'should return undefined coords in given an empty value', () => { - expect( backgroundPositionToCoords( '' ) ).toEqual( { - x: undefined, - y: undefined, - } ); - } ); - - it( 'should return undefined coords in given a string that cannot be converted', () => { - expect( backgroundPositionToCoords( 'apples' ) ).toEqual( { - x: undefined, - y: undefined, - } ); - } ); -} ); - -describe( 'coordsToBackgroundPosition', () => { - it( 'should return the correct background position for a set of coordinates', () => { - expect( coordsToBackgroundPosition( { x: 0.25, y: 0.75 } ) ).toBe( - '25% 75%' - ); - } ); - - it( 'should return undefined if no coordinates are provided', () => { - expect( coordsToBackgroundPosition( {} ) ).toBeUndefined(); - } ); -} ); +import { hasBackgroundImageValue } from '../background-panel'; describe( 'hasBackgroundImageValue', () => { it( 'should return `true` when id and url exist', () => { diff --git a/packages/block-editor/src/style.scss b/packages/block-editor/src/style.scss index feaabbbda9442..e6ec77b55a0ec 100644 --- a/packages/block-editor/src/style.scss +++ b/packages/block-editor/src/style.scss @@ -1,4 +1,5 @@ @import "./autocompleters/style.scss"; +@import "./components/background-image-control/style.scss"; @import "./components/block-alignment-control/style.scss"; @import "./components/block-canvas/style.scss"; @import "./components/block-icon/style.scss"; diff --git a/packages/edit-site/src/components/global-styles/root-menu.js b/packages/edit-site/src/components/global-styles/root-menu.js index 6db4621299f1e..183686bb52d82 100644 --- a/packages/edit-site/src/components/global-styles/root-menu.js +++ b/packages/edit-site/src/components/global-styles/root-menu.js @@ -3,6 +3,7 @@ */ import { __experimentalItemGroup as ItemGroup } from '@wordpress/components'; import { + background, typography, color, layout, @@ -23,11 +24,17 @@ const { useHasColorPanel, useGlobalSetting, useSettingsForBlockElement, + useHasBackgroundPanel, } = unlock( blockEditorPrivateApis ); function RootMenu() { const [ rawSettings ] = useGlobalSetting( '' ); const settings = useSettingsForBlockElement( rawSettings ); + /* + * Use the raw settings to determine if the background panel should be displayed, + * as the background panel is not dependent on the block element settings. + */ + const hasBackgroundPanel = useHasBackgroundPanel( rawSettings ); const hasTypographyPanel = useHasTypographyPanel( settings ); const hasColorPanel = useHasColorPanel( settings ); const hasShadowPanel = true; // useHasShadowPanel( settings ); @@ -55,6 +62,15 @@ function RootMenu() { { __( 'Colors' ) } ) } + { hasBackgroundPanel && ( + + { __( 'Background' ) } + + ) } { hasShadowPanel && ( + + { __( 'Set styles for the site’s background.' ) } + + } + /> + { hasBackgroundPanel && } + + ); +} + +export default ScreenBackground; diff --git a/packages/edit-site/src/components/global-styles/screen-layout.js b/packages/edit-site/src/components/global-styles/screen-layout.js index 1e68309fe0186..b6fa9f18f18de 100644 --- a/packages/edit-site/src/components/global-styles/screen-layout.js +++ b/packages/edit-site/src/components/global-styles/screen-layout.js @@ -8,31 +8,21 @@ import { privateApis as blockEditorPrivateApis } from '@wordpress/block-editor'; * Internal dependencies */ import DimensionsPanel from './dimensions-panel'; -import BackgroundPanel from './background-panel'; import ScreenHeader from './header'; import { unlock } from '../../lock-unlock'; -const { - useHasBackgroundPanel, - useHasDimensionsPanel, - useGlobalSetting, - useSettingsForBlockElement, -} = unlock( blockEditorPrivateApis ); +const { useHasDimensionsPanel, useGlobalSetting, useSettingsForBlockElement } = + unlock( blockEditorPrivateApis ); function ScreenLayout() { const [ rawSettings ] = useGlobalSetting( '' ); const settings = useSettingsForBlockElement( rawSettings ); const hasDimensionsPanel = useHasDimensionsPanel( settings ); - /* - * Use the raw settings to determine if the background panel should be displayed, - * as the background panel is not dependent on the block element settings. - */ - const hasBackgroundPanel = useHasBackgroundPanel( rawSettings ); + return ( <> { hasDimensionsPanel && } - { hasBackgroundPanel && } ); } diff --git a/packages/edit-site/src/components/global-styles/ui.js b/packages/edit-site/src/components/global-styles/ui.js index 54bd4f97390a8..60d7e314d7776 100644 --- a/packages/edit-site/src/components/global-styles/ui.js +++ b/packages/edit-site/src/components/global-styles/ui.js @@ -38,6 +38,7 @@ import FontSize from './font-sizes/font-size'; import FontSizes from './font-sizes/font-sizes'; import ScreenColors from './screen-colors'; import ScreenColorPalette from './screen-color-palette'; +import ScreenBackground from './screen-background'; import { ScreenShadows, ScreenShadowsEdit } from './screen-shadows'; import ScreenLayout from './screen-layout'; import ScreenStyleVariations from './screen-style-variations'; @@ -372,6 +373,10 @@ function GlobalStylesUI() { + + + + { blocks.map( ( block ) => ( + + +); + +export default background;