@wordpress/editor
Version:
Enhanced block editor for WordPress posts.
579 lines (524 loc) • 17.4 kB
JavaScript
import { addFilter } from '@wordpress/hooks';
import { createHigherOrderComponent } from '@wordpress/compose';
import {
InspectorAdvancedControls,
store as blockEditorStore,
privateApis as blockEditorPrivateApis,
useBlockEditingMode,
} from '@wordpress/block-editor';
import { BaseControl, Button } from '@wordpress/components';
import { __, sprintf } from '@wordpress/i18n';
import {
__EXPERIMENTAL_STYLE_PROPERTY,
getBlockType,
hasBlockSupport,
store as blocksStore,
} from '@wordpress/blocks';
import { useMemo, useCallback, useState } from '@wordpress/element';
import { useDispatch, useSelect } from '@wordpress/data';
import { store as noticesStore } from '@wordpress/notices';
import { store as coreStore } from '@wordpress/core-data';
import { unlock } from '../../lock-unlock';
import setNestedValue from '../../utils/set-nested-value';
import { useGlobalStyles } from '../../components/global-styles/hooks';
import ApplyGloballyModal from './apply-globally-modal';
const { cleanEmptyObject } = unlock( blockEditorPrivateApis );
// Block Gap is a special case and isn't defined within the blocks
// style properties config. We'll add it here to allow it to be pushed
// to global styles as well.
const STYLE_PROPERTY = {
...__EXPERIMENTAL_STYLE_PROPERTY,
blockGap: { value: [ 'spacing', 'blockGap' ] },
};
// TODO: Temporary duplication of constant in @wordpress/block-editor. Can be
// removed by moving PushChangesToGlobalStylesControl to
// @wordpress/block-editor.
const STYLE_PATH_TO_CSS_VAR_INFIX = {
'border.color': 'color',
'color.background': 'color',
'color.text': 'color',
'elements.link.color.text': 'color',
'elements.link.:hover.color.text': 'color',
'elements.link.typography.fontFamily': 'font-family',
'elements.link.typography.fontSize': 'font-size',
'elements.button.color.text': 'color',
'elements.button.color.background': 'color',
'elements.button.typography.fontFamily': 'font-family',
'elements.button.typography.fontSize': 'font-size',
'elements.caption.color.text': 'color',
'elements.heading.color': 'color',
'elements.heading.color.background': 'color',
'elements.heading.typography.fontFamily': 'font-family',
'elements.heading.gradient': 'gradient',
'elements.heading.color.gradient': 'gradient',
'elements.h1.color': 'color',
'elements.h1.color.background': 'color',
'elements.h1.typography.fontFamily': 'font-family',
'elements.h1.color.gradient': 'gradient',
'elements.h2.color': 'color',
'elements.h2.color.background': 'color',
'elements.h2.typography.fontFamily': 'font-family',
'elements.h2.color.gradient': 'gradient',
'elements.h3.color': 'color',
'elements.h3.color.background': 'color',
'elements.h3.typography.fontFamily': 'font-family',
'elements.h3.color.gradient': 'gradient',
'elements.h4.color': 'color',
'elements.h4.color.background': 'color',
'elements.h4.typography.fontFamily': 'font-family',
'elements.h4.color.gradient': 'gradient',
'elements.h5.color': 'color',
'elements.h5.color.background': 'color',
'elements.h5.typography.fontFamily': 'font-family',
'elements.h5.color.gradient': 'gradient',
'elements.h6.color': 'color',
'elements.h6.color.background': 'color',
'elements.h6.typography.fontFamily': 'font-family',
'elements.h6.color.gradient': 'gradient',
'color.gradient': 'gradient',
blockGap: 'spacing',
'typography.fontSize': 'font-size',
'typography.fontFamily': 'font-family',
};
// TODO: Temporary duplication of constant in @wordpress/block-editor. Can be
// removed by moving PushChangesToGlobalStylesControl to
// @wordpress/block-editor.
const STYLE_PATH_TO_PRESET_BLOCK_ATTRIBUTE = {
'border.color': 'borderColor',
'color.background': 'backgroundColor',
'color.text': 'textColor',
'color.gradient': 'gradient',
'typography.fontSize': 'fontSize',
'typography.fontFamily': 'fontFamily',
};
const SUPPORTED_STYLES = [ 'border', 'color', 'spacing', 'typography' ];
const getValueFromObjectPath = ( object, path ) => {
let value = object;
path.forEach( ( fieldName ) => {
value = value?.[ fieldName ];
} );
return value;
};
const sides = [ 'top', 'right', 'bottom', 'left' ];
/**
* One style change shown as a single row. It holds every `{ path, value }` pair
* the change covers so they all get pushed together.
*
* @typedef {Object} ChangeRow
* @property {string} id Unique row id.
* @property {string[]} primaryPath Path used to look up the current value.
* @property {Array<{path: string[], value: *}>} paths The path/value pairs to push.
* @property {string[]} presetAttributes Preset block attributes to clear when pushed.
* @property {*} newValue Value shown in the "New" column.
* @property {string} [format] How to display the value (`border`, `borderRadius`, `spacing`).
*/
// Builds the border rows, grouped so each one reads as a single CSS `border`
// value (all sides, one side, and radius) instead of a row per property.
function getBorderRows( supports, attributes, blockUserConfig ) {
const rows = [];
const border = attributes.style?.border;
const userBorder = blockUserConfig?.border;
const colorSupported = supports.includes( 'borderColor' );
const widthSupported = supports.includes( 'borderWidth' );
const styleSupported = supports.includes( 'borderStyle' );
// The all-sides border. A preset border color lives in a block attribute
// and is pushed as a preset value.
const presetBorderColor = attributes.borderColor;
const flatColor = presetBorderColor
? `var:preset|color|${ presetBorderColor }`
: border?.color;
const flatRow = buildBorderScopeRow( {
id: 'border',
primaryPath: [ 'border' ],
side: null,
color: colorSupported ? flatColor : undefined,
width: widthSupported ? border?.width : undefined,
style: styleSupported ? border?.style : undefined,
userBorder,
presetAttributes: presetBorderColor ? [ 'borderColor' ] : [],
} );
if ( flatRow ) {
rows.push( flatRow );
}
sides.forEach( ( side ) => {
const sideBorder = border?.[ side ];
const sideRow = buildBorderScopeRow( {
id: `border.${ side }`,
primaryPath: [ 'border', side ],
side,
color: colorSupported ? sideBorder?.color : undefined,
width: widthSupported ? sideBorder?.width : undefined,
style: styleSupported ? sideBorder?.style : undefined,
userBorder,
presetAttributes: [],
} );
if ( sideRow ) {
rows.push( sideRow );
}
} );
// Border radius, pushed as-is (a string or an object per corner) since
// Global Styles takes either.
if (
supports.includes( 'borderRadius' ) &&
border?.radius !== undefined &&
border?.radius !== ''
) {
rows.push( {
id: 'border.radius',
primaryPath: [ 'border', 'radius' ],
paths: [ { path: [ 'border', 'radius' ], value: border.radius } ],
presetAttributes: [],
newValue: border.radius,
format: 'borderRadius',
} );
}
return rows;
}
// Builds one border row (all sides or a single side) that pushes its color,
// width and style together and shows them as one CSS value. Returns `null`
// when there's nothing set.
function buildBorderScopeRow( {
id,
primaryPath,
side,
color,
width,
style,
userBorder,
presetAttributes,
} ) {
if ( ! color && ! width && ! style ) {
return null;
}
const paths = [];
// The all-sides value is also written to each side so it can override any
// per-side values from theme.json.
const targetSides = side ? [ side ] : sides;
const addChange = ( property, value ) => {
if ( value === undefined ) {
return;
}
if ( ! side ) {
paths.push( { path: [ 'border', property ], value } );
}
targetSides.forEach( ( targetSide ) => {
paths.push( { path: [ 'border', targetSide, property ], value } );
} );
};
addChange( 'color', color );
addChange( 'width', width );
addChange( 'style', style );
// A border only shows with a style, so use `solid` when a color or width
// is set without one (unless Global Styles already sets a style for that
// side, which is kept).
let effectiveStyle = style;
if ( ! style && ( color || width ) ) {
const sideStyles = targetSides.map(
( targetSide ) => userBorder?.[ targetSide ]?.style
);
targetSides.forEach( ( targetSide, index ) => {
if ( ! sideStyles[ index ] ) {
paths.push( {
path: [ 'border', targetSide, 'style' ],
value: 'solid',
} );
}
} );
// Show the style the border will actually have after Apply: the shared
// Global Styles style when every side agrees on one, else `solid`.
const sharedStyle = sideStyles.every(
( sideStyle ) => sideStyle === sideStyles[ 0 ]
)
? sideStyles[ 0 ]
: undefined;
effectiveStyle = sharedStyle || 'solid';
}
return {
id,
primaryPath,
paths,
presetAttributes,
newValue: { color, width, style: effectiveStyle },
format: 'border',
};
}
/**
* Works out which of the block's style changes can be pushed to Global Styles,
* grouped into rows (see `ChangeRow`).
*
* @param {Array} supports Supported style keys for the block.
* @param {Object} attributes Block attributes.
* @param {Object} blockUserConfig The block's user Global Styles config.
*
* @return {ChangeRow[]} The changes, grouped into rows.
*/
export function getChangesToPush( supports, attributes, blockUserConfig ) {
const rows = [];
supports.forEach( ( key ) => {
if ( ! STYLE_PROPERTY[ key ] ) {
return;
}
// Border styles are grouped and handled separately below.
if ( key.startsWith( 'border' ) ) {
return;
}
// Root-only properties (e.g. `--wp--style--root--padding`) repeat their
// normal version (`padding`) and only apply to the root, so skip them.
if ( STYLE_PROPERTY[ key ].rootOnly ) {
return;
}
const { value: path } = STYLE_PROPERTY[ key ];
const presetAttributeKey = path.join( '.' );
const presetAttributeName =
STYLE_PATH_TO_PRESET_BLOCK_ATTRIBUTE[ presetAttributeKey ];
const presetAttributeValue = presetAttributeName
? attributes[ presetAttributeName ]
: undefined;
const value = presetAttributeValue
? `var:preset|${ STYLE_PATH_TO_CSS_VAR_INFIX[ presetAttributeKey ] }|${ presetAttributeValue }`
: getValueFromObjectPath( attributes.style, path );
// A preset attribute is only removed from the block when its row is
// pushed (see `getStylesUpdate`).
const presetAttributes = presetAttributeValue
? [ presetAttributeName ]
: [];
// Links have a single support but two styles: color and hover color.
// Add the hover color to the changes when it's set.
if ( key === 'linkColor' ) {
const paths = value ? [ { path, value } ] : [];
const hoverPath = [ 'elements', 'link', ':hover', 'color', 'text' ];
const hoverValue = getValueFromObjectPath(
attributes.style,
hoverPath
);
if ( hoverValue ) {
paths.push( { path: hoverPath, value: hoverValue } );
}
if ( paths.length === 0 ) {
return;
}
rows.push( {
id: presetAttributeKey,
primaryPath: path,
paths,
presetAttributes,
newValue: value ?? hoverValue,
} );
return;
}
if ( value ) {
// Padding and margin can be axial or per-side objects. The format
// hint lets them show as one CSS value instead of a row per side
// or a raw object. Block gap can be an axial `{ top, left }` object.
let format;
if ( key === 'padding' || key === 'margin' ) {
format = 'spacing';
} else if ( key === 'blockGap' ) {
format = 'blockGap';
}
rows.push( {
id: presetAttributeKey,
primaryPath: path,
paths: [ { path, value } ],
presetAttributes,
newValue: value,
format,
} );
}
} );
rows.push( ...getBorderRows( supports, attributes, blockUserConfig ) );
return rows;
}
function useChangesToPush( name, attributes, userConfig ) {
const supports = useSelect(
( select ) => {
return unlock( select( blocksStore ) ).getSupportedStyles( name );
},
[ name ]
);
const blockUserConfig = userConfig?.styles?.blocks?.[ name ];
return useMemo(
() => getChangesToPush( supports, attributes, blockUserConfig ),
[ supports, attributes, blockUserConfig ]
);
}
/**
* Works out the block attribute and user Global Styles updates for the chosen
* rows, without applying them. Returns `null` when there's nothing to push.
*
* @param {Object} options Options.
* @param {Array} options.rowsToPush The rows to push.
* @param {Object} options.attributes Current block attributes.
* @param {Object} options.userConfig Current user Global Styles config.
* @param {string} options.name Block name.
*
* @return {?{newBlockAttributes: Object, newUserConfig: Object}} The updates,
* or `null` when no rows were chosen.
*/
export function getStylesUpdate( {
rowsToPush,
attributes,
userConfig,
name,
} ) {
if ( ! rowsToPush || rowsToPush.length === 0 ) {
return null;
}
const selectedChanges = rowsToPush.flatMap( ( row ) => row.paths );
if ( selectedChanges.length === 0 ) {
return null;
}
const { style: blockStyles } = attributes;
const newBlockStyles = structuredClone( blockStyles );
const newUserConfig = structuredClone( userConfig );
for ( const { path, value } of selectedChanges ) {
setNestedValue( newBlockStyles, path, undefined );
setNestedValue(
newUserConfig,
[ 'styles', 'blocks', name, ...path ],
value
);
}
// Only clear the preset attributes from the rows being pushed. Clearing
// them all would wipe unselected preset styles from the block without
// pushing them to Global Styles.
const newBlockAttributes = {
style: cleanEmptyObject( newBlockStyles ),
};
for ( const presetAttribute of rowsToPush.flatMap(
( row ) => row.presetAttributes
) ) {
newBlockAttributes[ presetAttribute ] = undefined;
}
return { newBlockAttributes, newUserConfig };
}
function PushChangesToGlobalStylesControl( {
name,
attributes,
setAttributes,
} ) {
const { user: userConfig, setUser: setUserConfig } = useGlobalStyles();
const rows = useChangesToPush( name, attributes, userConfig );
const [ isModalOpen, setIsModalOpen ] = useState( false );
const { __unstableMarkNextChangeAsNotPersistent } =
useDispatch( blockEditorStore );
const { createSuccessNotice } = useDispatch( noticesStore );
const pushChanges = useCallback(
( rowsToPush ) => {
const update = getStylesUpdate( {
rowsToPush,
attributes,
userConfig,
name,
} );
if ( ! update ) {
return;
}
const { newBlockAttributes, newUserConfig } = update;
// @wordpress/core-data doesn't support editing multiple entity types in
// a single undo level. So for now, we disable @wordpress/core-data undo
// tracking and implement our own Undo button in the snackbar
// notification.
__unstableMarkNextChangeAsNotPersistent();
setAttributes( newBlockAttributes );
setUserConfig( newUserConfig, { undoIgnore: true } );
createSuccessNotice(
sprintf(
// translators: %s: Title of the block e.g. 'Heading'.
__( '%s styles applied.' ),
getBlockType( name ).title
),
{
type: 'snackbar',
actions: [
{
label: __( 'Undo' ),
onClick() {
__unstableMarkNextChangeAsNotPersistent();
setAttributes( attributes );
setUserConfig( userConfig, {
undoIgnore: true,
} );
},
},
],
}
);
},
[
__unstableMarkNextChangeAsNotPersistent,
attributes,
createSuccessNotice,
name,
setAttributes,
setUserConfig,
userConfig,
]
);
return (
<BaseControl
className="editor-push-changes-to-global-styles-control"
help={ sprintf(
// translators: %s: Title of the block e.g. 'Heading'.
__(
'Review and apply this block’s typography, spacing, dimensions, and color styles to all %s blocks.'
),
getBlockType( name ).title
) }
>
<BaseControl.VisualLabel>
{ __( 'Styles' ) }
</BaseControl.VisualLabel>
<Button
__next40pxDefaultSize
variant="secondary"
accessibleWhenDisabled
disabled={ rows.length === 0 }
onClick={ () => setIsModalOpen( true ) }
>
{ __( 'Apply globally' ) }
</Button>
{ isModalOpen && (
<ApplyGloballyModal
name={ name }
rows={ rows }
onApply={ pushChanges }
onRequestClose={ () => setIsModalOpen( false ) }
/>
) }
</BaseControl>
);
}
function PushChangesToGlobalStyles( props ) {
const blockEditingMode = useBlockEditingMode();
const isBlockBasedTheme = useSelect(
( select ) => select( coreStore ).getCurrentTheme()?.is_block_theme,
[]
);
const supportsStyles = SUPPORTED_STYLES.some( ( feature ) =>
hasBlockSupport( props.name, feature )
);
const isDisplayed =
blockEditingMode === 'default' && supportsStyles && isBlockBasedTheme;
if ( ! isDisplayed ) {
return null;
}
return (
<InspectorAdvancedControls>
<PushChangesToGlobalStylesControl { ...props } />
</InspectorAdvancedControls>
);
}
const withPushChangesToGlobalStyles = createHigherOrderComponent(
( BlockEdit ) => ( props ) => (
<>
<BlockEdit key="edit" { ...props } />
{ props.isSelected && <PushChangesToGlobalStyles { ...props } /> }
</>
)
);
addFilter(
'editor.BlockEdit',
'core/editor/push-changes-to-global-styles',
withPushChangesToGlobalStyles
);