diff --git a/packages/block-editor/src/components/global-styles/shadow-panel-components.js b/packages/block-editor/src/components/global-styles/shadow-panel-components.js index b88a09aee1449a..a30e209027a2bf 100644 --- a/packages/block-editor/src/components/global-styles/shadow-panel-components.js +++ b/packages/block-editor/src/components/global-styles/shadow-panel-components.js @@ -172,8 +172,11 @@ export function useShadowPresets( settings ) { } const defaultPresetsEnabled = settings?.shadow?.defaultPresets; - const { default: defaultShadows, theme: themeShadows } = - settings?.shadow?.presets ?? {}; + const { + default: defaultShadows, + theme: themeShadows, + custom: customShadows, + } = settings?.shadow?.presets ?? {}; const unsetShadow = { name: __( 'Unset' ), slug: 'unset', @@ -183,6 +186,7 @@ export function useShadowPresets( settings ) { const shadowPresets = [ ...( ( defaultPresetsEnabled && defaultShadows ) || EMPTY_ARRAY ), ...( themeShadows || EMPTY_ARRAY ), + ...( customShadows || EMPTY_ARRAY ), ]; if ( shadowPresets.length ) { shadowPresets.unshift( unsetShadow ); 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 c747efee635d88..4959fc33c49733 100644 --- a/packages/edit-site/src/components/global-styles/root-menu.js +++ b/packages/edit-site/src/components/global-styles/root-menu.js @@ -2,7 +2,13 @@ * WordPress dependencies */ import { __experimentalItemGroup as ItemGroup } from '@wordpress/components'; -import { typography, color, layout, image } from '@wordpress/icons'; +import { + typography, + color, + layout, + image, + shadow as shadowIcon, +} from '@wordpress/icons'; import { __ } from '@wordpress/i18n'; import { privateApis as blockEditorPrivateApis } from '@wordpress/block-editor'; @@ -26,6 +32,7 @@ function RootMenu() { const settings = useSettingsForBlockElement( rawSettings ); const hasTypographyPanel = useHasTypographyPanel( settings ); const hasColorPanel = useHasColorPanel( settings ); + const hasShadowPanel = true; // useHasShadowPanel( settings ); const hasDimensionsPanel = useHasDimensionsPanel( settings ); const hasLayoutPanel = hasDimensionsPanel; const hasBackgroundPanel = useHasBackgroundPanel( settings ); @@ -51,6 +58,15 @@ function RootMenu() { { __( 'Colors' ) } ) } + { hasShadowPanel && ( + + { __( 'Shadows' ) } + + ) } { hasLayoutPanel && ( ; +} + +export function ScreenShadowsEdit() { + return ; +} diff --git a/packages/edit-site/src/components/global-styles/shadow-utils.js b/packages/edit-site/src/components/global-styles/shadow-utils.js new file mode 100644 index 00000000000000..a98f6306bb3f35 --- /dev/null +++ b/packages/edit-site/src/components/global-styles/shadow-utils.js @@ -0,0 +1,158 @@ +export const CUSTOM_VALUE_SETTINGS = { + px: { max: 20, step: 1 }, + '%': { max: 100, step: 1 }, + vw: { max: 100, step: 1 }, + vh: { max: 100, step: 1 }, + em: { max: 10, step: 0.1 }, + rm: { max: 10, step: 0.1 }, + svw: { max: 100, step: 1 }, + lvw: { max: 100, step: 1 }, + dvw: { max: 100, step: 1 }, + svh: { max: 100, step: 1 }, + lvh: { max: 100, step: 1 }, + dvh: { max: 100, step: 1 }, + vi: { max: 100, step: 1 }, + svi: { max: 100, step: 1 }, + lvi: { max: 100, step: 1 }, + dvi: { max: 100, step: 1 }, + vb: { max: 100, step: 1 }, + svb: { max: 100, step: 1 }, + lvb: { max: 100, step: 1 }, + dvb: { max: 100, step: 1 }, + vmin: { max: 100, step: 1 }, + svmin: { max: 100, step: 1 }, + lvmin: { max: 100, step: 1 }, + dvmin: { max: 100, step: 1 }, + vmax: { max: 100, step: 1 }, + svmax: { max: 100, step: 1 }, + lvmax: { max: 100, step: 1 }, + dvmax: { max: 100, step: 1 }, +}; + +export function getShadowParts( shadow ) { + const shadowValues = shadow.match( /(?:[^,(]|\([^)]*\))+/g ) || []; + return shadowValues.map( ( value ) => value.trim() ); +} + +export function shadowStringToObject( shadowValue ) { + /* + * Shadow spec: https://developer.mozilla.org/en-US/docs/Web/CSS/box-shadow + * Shadow string format: [inset] + * + * A shadow to be valid it must satisfy the following. + * + * 1. Should not contain "none" keyword. + * 2. Values x, y, blur, spread should be in the order. Color and inset can be anywhere in the string except in between x, y, blur, spread values. + * 3. Should not contain more than one set of x, y, blur, spread values. + * 4. Should contain at least x and y values. Others are optional. + * 5. Should not contain more than one "inset" (case insensitive) keyword. + * 6. Should not contain more than one color value. + */ + + const defaultShadow = { + x: '0', + y: '0', + blur: '0', + spread: '0', + color: '#000', + inset: false, + }; + + if ( ! shadowValue ) { + return defaultShadow; + } + + // Rule 1: Should not contain "none" keyword. + // if the shadow has "none" keyword, it is not a valid shadow string + if ( shadowValue.includes( 'none' ) ) { + return defaultShadow; + } + + // Rule 2: Values x, y, blur, spread should be in the order. + // Color and inset can be anywhere in the string except in between x, y, blur, spread values. + // Extract length values (x, y, blur, spread) from shadow string + // Regex match groups of 1 to 4 length values. + const lengthsRegex = + /((?:^|\s+)(-?\d*\.?\d+(?:px|%|in|cm|mm|em|rem|ex|pt|pc|vh|vw|vmin|vmax|ch|lh)?)(?=\s|$)(?![^(]*\))){1,4}/g; + const matches = shadowValue.match( lengthsRegex ) || []; + + // Rule 3: Should not contain more than one set of x, y, blur, spread values. + // if the string doesn't contain exactly 1 set of x, y, blur, spread values, + // it is not a valid shadow string + if ( matches.length !== 1 ) { + return defaultShadow; + } + + // Extract length values (x, y, blur, spread) from shadow string + const lengths = matches[ 0 ] + .split( ' ' ) + .map( ( value ) => value.trim() ) + .filter( ( value ) => value ); + + // Rule 4: Should contain at least x and y values. Others are optional. + if ( lengths.length < 2 ) { + return defaultShadow; + } + + // Rule 5: Should not contain more than one "inset" (case insensitive) keyword. + // check if the shadow string contains "inset" keyword + const insets = shadowValue.match( /inset/gi ) || []; + if ( insets.length > 1 ) { + return defaultShadow; + } + + // Strip lengths and inset from shadow string, leaving just color. + const hasInset = insets.length === 1; + let colorString = shadowValue.replace( lengthsRegex, '' ).trim(); + if ( hasInset ) { + colorString = colorString + .replace( 'inset', '' ) + .replace( 'INSET', '' ) + .trim(); + } + + // Rule 6: Should not contain more than one color value. + // validate color string with regular expression + // check if color has matching hex, rgb or hsl values + const colorRegex = + /^#([0-9a-f]{3}){1,2}$|^#([0-9a-f]{4}){1,2}$|^(?:rgb|hsl)a?\(?[\d*\.?\d+%?,?\/?\s]*\)$/gi; + let colorMatches = ( colorString.match( colorRegex ) || [] ) + .map( ( value ) => value?.trim() ) + .filter( ( value ) => value ); + + // If color string has more than one color values, it is not a valid + if ( colorMatches.length > 1 ) { + return defaultShadow; + } else if ( colorMatches.length === 0 ) { + // check if color string has multiple named color values separated by space + colorMatches = colorString + .trim() + .split( ' ' ) + .filter( ( value ) => value ); + // If color string has more than one color values, it is not a valid + if ( colorMatches.length > 1 ) { + return defaultShadow; + } + } + + // Return parsed shadow object. + const [ x, y, blur, spread ] = lengths; + return { + x, + y, + blur: blur || defaultShadow.blur, + spread: spread || defaultShadow.spread, + inset: hasInset, + color: colorString || defaultShadow.color, + }; +} + +export function shadowObjectToString( shadowObj ) { + const shadowString = `${ shadowObj.x || '0px' } ${ shadowObj.y || '0px' } ${ + shadowObj.blur || '0px' + } ${ shadowObj.spread || '0px' }`; + + return `${ shadowObj.inset ? 'inset' : '' } ${ shadowString } ${ + shadowObj.color || '' + }`.trim(); +} diff --git a/packages/edit-site/src/components/global-styles/shadows-edit-panel.js b/packages/edit-site/src/components/global-styles/shadows-edit-panel.js new file mode 100644 index 00000000000000..65503f2376969d --- /dev/null +++ b/packages/edit-site/src/components/global-styles/shadows-edit-panel.js @@ -0,0 +1,558 @@ +/** + * External dependencies + */ +import clsx from 'clsx'; + +/** + * WordPress dependencies + */ +import { + __experimentalHStack as HStack, + __experimentalVStack as VStack, + __experimentalSpacer as Spacer, + __experimentalItemGroup as ItemGroup, + __experimentalHeading as Heading, + __experimentalInputControl as InputControl, + __experimentalUnitControl as UnitControl, + __experimentalParseQuantityAndUnitFromRawValue as parseQuantityAndUnitFromRawValue, + __experimentalGrid as Grid, + __experimentalDropdownContentWrapper as DropdownContentWrapper, + __experimentalUseNavigator as useNavigator, + __experimentalToggleGroupControl as ToggleGroupControl, + __experimentalToggleGroupControlOption as ToggleGroupControlOption, + __experimentalConfirmDialog as ConfirmDialog, + Dropdown, + RangeControl, + Button, + Flex, + FlexItem, + ColorPalette, + Modal, + privateApis as componentsPrivateApis, +} from '@wordpress/components'; +import { __, sprintf } from '@wordpress/i18n'; +import { privateApis as blockEditorPrivateApis } from '@wordpress/block-editor'; +import { + plus, + shadow as shadowIcon, + reset, + settings, + moreVertical, +} from '@wordpress/icons'; +import { useState, useMemo } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { unlock } from '../../lock-unlock'; +import Subtitle from './subtitle'; +import ScreenHeader from './header'; +import { defaultShadow } from './shadows-panel'; +import { + getShadowParts, + shadowStringToObject, + shadowObjectToString, + CUSTOM_VALUE_SETTINGS, +} from './shadow-utils'; + +const { useGlobalSetting } = unlock( blockEditorPrivateApis ); +const { + DropdownMenuV2: DropdownMenu, + DropdownMenuItemV2: DropdownMenuItem, + DropdownMenuItemLabelV2: DropdownMenuItemLabel, +} = unlock( componentsPrivateApis ); + +const customShadowMenuItems = [ + { + label: __( 'Rename' ), + action: 'rename', + }, + { + label: __( 'Delete' ), + action: 'delete', + }, +]; + +const presetShadowMenuItems = [ + { + label: __( 'Reset' ), + action: 'reset', + }, +]; + +export default function ShadowsEditPanel() { + const { + params: { category, slug }, + goTo, + } = useNavigator(); + const [ shadows, setShadows ] = useGlobalSetting( + `shadow.presets.${ category }` + ); + const [ baseShadows ] = useGlobalSetting( + `shadow.presets.${ category }`, + undefined, + 'base' + ); + const [ selectedShadow, setSelectedShadow ] = useState( () => + ( shadows || [] ).find( ( shadow ) => shadow.slug === slug ) + ); + const baseSelectedShadow = useMemo( + () => ( baseShadows || [] ).find( ( b ) => b.slug === slug ), + [ baseShadows, slug ] + ); + const [ isConfirmDialogVisible, setIsConfirmDialogVisible ] = + useState( false ); + const [ isRenameModalVisible, setIsRenameModalVisible ] = useState( false ); + const [ shadowName, setShadowName ] = useState( selectedShadow.name ); + + const onShadowChange = ( shadow ) => { + setSelectedShadow( { ...selectedShadow, shadow } ); + const updatedShadows = shadows.map( ( s ) => + s.slug === slug ? { ...selectedShadow, shadow } : s + ); + setShadows( updatedShadows ); + }; + + const onMenuClick = ( action ) => { + if ( action === 'reset' ) { + const updatedShadows = shadows.map( ( s ) => + s.slug === slug ? baseSelectedShadow : s + ); + setSelectedShadow( baseSelectedShadow ); + setShadows( updatedShadows ); + } else if ( action === 'delete' ) { + setIsConfirmDialogVisible( true ); + } else if ( action === 'rename' ) { + setIsRenameModalVisible( true ); + } + }; + + const handleShadowDelete = () => { + const updatedShadows = shadows.filter( ( s ) => s.slug !== slug ); + setShadows( updatedShadows ); + goTo( `/shadows` ); + }; + + const handleShadowRename = ( newName ) => { + if ( ! newName ) { + return; + } + const updatedShadows = shadows.map( ( s ) => + s.slug === slug ? { ...selectedShadow, name: newName } : s + ); + setSelectedShadow( { ...selectedShadow, name: newName } ); + setShadows( updatedShadows ); + }; + + return ! selectedShadow ? ( + + ) : ( + <> + + + + + + } + > + { ( category === 'custom' + ? customShadowMenuItems + : presetShadowMenuItems + ).map( ( item ) => ( + onMenuClick( item.action ) } + disabled={ + item.action === 'reset' && + selectedShadow.shadow === + baseSelectedShadow.shadow + } + > + + { item.label } + + + ) ) } + + + + +
+ + +
+ { isConfirmDialogVisible && ( + { + handleShadowDelete(); + setIsConfirmDialogVisible( false ); + } } + onCancel={ () => { + setIsConfirmDialogVisible( false ); + } } + confirmButtonText={ __( 'Delete' ) } + > + { sprintf( + // translators: %s: name of the shadow + 'Are you sure you want to delete "%s"?', + selectedShadow.name + ) } + + ) } + { isRenameModalVisible && ( + setIsRenameModalVisible( false ) } + size="small" + > +
{ + event.preventDefault(); + handleShadowRename( shadowName ); + setIsRenameModalVisible( false ); + } } + > + setShadowName( value ) } + /> + + + + + + + + + + +
+ ) } + + ); +} + +function ShadowsPreview( { shadow } ) { + const shadowStyle = { + boxShadow: shadow, + }; + + return ( + + +
+ + + ); +} + +function ShadowEditor( { shadow, onChange } ) { + const shadowParts = useMemo( () => getShadowParts( shadow ), [ shadow ] ); + + const onChangeShadowPart = ( index, part ) => { + shadowParts[ index ] = part; + onChange( shadowParts.join( ', ' ) ); + }; + + const onAddShadowPart = () => { + shadowParts.push( defaultShadow ); + onChange( shadowParts.join( ', ' ) ); + }; + + const onRemoveShadowPart = ( index ) => { + shadowParts.splice( index, 1 ); + onChange( shadowParts.join( ', ' ) ); + }; + + return ( + <> + + + + { 'Shadows' } + + + + + { canRemove && ( + +