@wordpress/block-editor
Version:
719 lines (688 loc) • 17.6 kB
JavaScript
/**
* WordPress dependencies
*/
import { getBlockSupport } from '@wordpress/blocks';
import { memo, useMemo, useEffect, useId, useState } from '@wordpress/element';
import { useDispatch, useRegistry } from '@wordpress/data';
import { createHigherOrderComponent } from '@wordpress/compose';
import { addFilter } from '@wordpress/hooks';
/**
* Internal dependencies
*/
import {
useBlockEditContext,
mayDisplayControlsKey,
mayDisplayParentControlsKey,
} from '../components/block-edit/context';
import { useSettings } from '../components';
import { useSettingsForBlockElement } from '../components/global-styles/hooks';
import { getValueFromObjectPath, setImmutably } from '../utils/object';
import { store as blockEditorStore } from '../store';
import { unlock } from '../lock-unlock';
/**
* External dependencies
*/
import clsx from 'clsx';
/**
* Removed falsy values from nested object.
*
* @param {*} object
* @return {*} Object cleaned from falsy values
*/
export const cleanEmptyObject = ( object ) => {
if (
object === null ||
typeof object !== 'object' ||
Array.isArray( object )
) {
return object;
}
const cleanedNestedObjects = Object.entries( object )
.map( ( [ key, value ] ) => [ key, cleanEmptyObject( value ) ] )
.filter( ( [ , value ] ) => value !== undefined );
return ! cleanedNestedObjects.length
? undefined
: Object.fromEntries( cleanedNestedObjects );
};
export function transformStyles(
activeSupports,
migrationPaths,
result,
source,
index,
results
) {
// If there are no active supports return early.
if (
Object.values( activeSupports ?? {} ).every(
( isActive ) => ! isActive
)
) {
return result;
}
// If the condition verifies we are probably in the presence of a wrapping transform
// e.g: nesting paragraphs in a group or columns and in that case the styles should not be transformed.
if ( results.length === 1 && result.innerBlocks.length === source.length ) {
return result;
}
// For cases where we have a transform from one block to multiple blocks
// or multiple blocks to one block we apply the styles of the first source block
// to the result(s).
let referenceBlockAttributes = source[ 0 ]?.attributes;
// If we are in presence of transform between more than one block in the source
// that has more than one block in the result
// we apply the styles on source N to the result N,
// if source N does not exists we do nothing.
if ( results.length > 1 && source.length > 1 ) {
if ( source[ index ] ) {
referenceBlockAttributes = source[ index ]?.attributes;
} else {
return result;
}
}
let returnBlock = result;
Object.entries( activeSupports ).forEach( ( [ support, isActive ] ) => {
if ( isActive ) {
migrationPaths[ support ].forEach( ( path ) => {
const styleValue = getValueFromObjectPath(
referenceBlockAttributes,
path
);
if ( styleValue ) {
returnBlock = {
...returnBlock,
attributes: setImmutably(
returnBlock.attributes,
path,
styleValue
),
};
}
} );
}
} );
return returnBlock;
}
/**
* Check whether serialization of specific block support feature or set should
* be skipped.
*
* @param {string|Object} blockNameOrType Block name or block type object.
* @param {string} featureSet Name of block support feature set.
* @param {string} feature Name of the individual feature to check.
*
* @return {boolean} Whether serialization should occur.
*/
export function shouldSkipSerialization(
blockNameOrType,
featureSet,
feature
) {
const support = getBlockSupport( blockNameOrType, featureSet );
const skipSerialization = support?.__experimentalSkipSerialization;
if ( Array.isArray( skipSerialization ) ) {
return skipSerialization.includes( feature );
}
return skipSerialization;
}
const pendingStyleOverrides = new WeakMap();
/**
* Override a block editor settings style. Leave the ID blank to create a new
* style.
*
* @param {Object} override Override object.
* @param {?string} override.id Id of the style override, leave blank to create
* a new style.
* @param {string} override.css CSS to apply.
*/
export function useStyleOverride( { id, css } ) {
return usePrivateStyleOverride( { id, css } );
}
export function usePrivateStyleOverride( {
id,
css,
assets,
__unstableType,
variation,
clientId,
} = {} ) {
const { setStyleOverride, deleteStyleOverride } = unlock(
useDispatch( blockEditorStore )
);
const registry = useRegistry();
const fallbackId = useId();
useEffect( () => {
// Unmount if there is CSS and assets are empty.
if ( ! css && ! assets ) {
return;
}
const _id = id || fallbackId;
const override = {
id,
css,
assets,
__unstableType,
variation,
clientId,
};
// Batch updates to style overrides to avoid triggering cascading renders
// for each style override block included in a tree and optimize initial render.
if ( ! pendingStyleOverrides.get( registry ) ) {
pendingStyleOverrides.set( registry, [] );
}
pendingStyleOverrides.get( registry ).push( [ _id, override ] );
window.queueMicrotask( () => {
if ( pendingStyleOverrides.get( registry )?.length ) {
registry.batch( () => {
pendingStyleOverrides.get( registry ).forEach( ( args ) => {
setStyleOverride( ...args );
} );
pendingStyleOverrides.set( registry, [] );
} );
}
} );
return () => {
const isPending = pendingStyleOverrides
.get( registry )
?.find( ( [ currentId ] ) => currentId === _id );
if ( isPending ) {
pendingStyleOverrides.set(
registry,
pendingStyleOverrides
.get( registry )
.filter( ( [ currentId ] ) => currentId !== _id )
);
} else {
deleteStyleOverride( _id );
}
};
}, [
id,
css,
clientId,
assets,
__unstableType,
fallbackId,
setStyleOverride,
deleteStyleOverride,
registry,
] );
}
/**
* Based on the block and its context, returns an object of all the block settings.
* This object can be passed as a prop to all the Styles UI components
* (TypographyPanel, DimensionsPanel...).
*
* @param {string} name Block name.
* @param {*} parentLayout Parent layout.
*
* @return {Object} Settings object.
*/
export function useBlockSettings( name, parentLayout ) {
const [
backgroundImage,
backgroundSize,
customFontFamilies,
defaultFontFamilies,
themeFontFamilies,
defaultFontSizesEnabled,
customFontSizes,
defaultFontSizes,
themeFontSizes,
customFontSize,
fontStyle,
fontWeight,
lineHeight,
textAlign,
textColumns,
textDecoration,
writingMode,
textTransform,
letterSpacing,
padding,
margin,
blockGap,
defaultSpacingSizesEnabled,
customSpacingSize,
userSpacingSizes,
defaultSpacingSizes,
themeSpacingSizes,
units,
aspectRatio,
minHeight,
layout,
borderColor,
borderRadius,
borderStyle,
borderWidth,
customColorsEnabled,
customColors,
customDuotone,
themeColors,
defaultColors,
defaultPalette,
defaultDuotone,
userDuotonePalette,
themeDuotonePalette,
defaultDuotonePalette,
userGradientPalette,
themeGradientPalette,
defaultGradientPalette,
defaultGradients,
areCustomGradientsEnabled,
isBackgroundEnabled,
isLinkEnabled,
isTextEnabled,
isHeadingEnabled,
isButtonEnabled,
shadow,
] = useSettings(
'background.backgroundImage',
'background.backgroundSize',
'typography.fontFamilies.custom',
'typography.fontFamilies.default',
'typography.fontFamilies.theme',
'typography.defaultFontSizes',
'typography.fontSizes.custom',
'typography.fontSizes.default',
'typography.fontSizes.theme',
'typography.customFontSize',
'typography.fontStyle',
'typography.fontWeight',
'typography.lineHeight',
'typography.textAlign',
'typography.textColumns',
'typography.textDecoration',
'typography.writingMode',
'typography.textTransform',
'typography.letterSpacing',
'spacing.padding',
'spacing.margin',
'spacing.blockGap',
'spacing.defaultSpacingSizes',
'spacing.customSpacingSize',
'spacing.spacingSizes.custom',
'spacing.spacingSizes.default',
'spacing.spacingSizes.theme',
'spacing.units',
'dimensions.aspectRatio',
'dimensions.minHeight',
'layout',
'border.color',
'border.radius',
'border.style',
'border.width',
'color.custom',
'color.palette.custom',
'color.customDuotone',
'color.palette.theme',
'color.palette.default',
'color.defaultPalette',
'color.defaultDuotone',
'color.duotone.custom',
'color.duotone.theme',
'color.duotone.default',
'color.gradients.custom',
'color.gradients.theme',
'color.gradients.default',
'color.defaultGradients',
'color.customGradient',
'color.background',
'color.link',
'color.text',
'color.heading',
'color.button',
'shadow'
);
const rawSettings = useMemo( () => {
return {
background: {
backgroundImage,
backgroundSize,
},
color: {
palette: {
custom: customColors,
theme: themeColors,
default: defaultColors,
},
gradients: {
custom: userGradientPalette,
theme: themeGradientPalette,
default: defaultGradientPalette,
},
duotone: {
custom: userDuotonePalette,
theme: themeDuotonePalette,
default: defaultDuotonePalette,
},
defaultGradients,
defaultPalette,
defaultDuotone,
custom: customColorsEnabled,
customGradient: areCustomGradientsEnabled,
customDuotone,
background: isBackgroundEnabled,
link: isLinkEnabled,
heading: isHeadingEnabled,
button: isButtonEnabled,
text: isTextEnabled,
},
typography: {
fontFamilies: {
custom: customFontFamilies,
default: defaultFontFamilies,
theme: themeFontFamilies,
},
fontSizes: {
custom: customFontSizes,
default: defaultFontSizes,
theme: themeFontSizes,
},
customFontSize,
defaultFontSizes: defaultFontSizesEnabled,
fontStyle,
fontWeight,
lineHeight,
textAlign,
textColumns,
textDecoration,
textTransform,
letterSpacing,
writingMode,
},
spacing: {
spacingSizes: {
custom: userSpacingSizes,
default: defaultSpacingSizes,
theme: themeSpacingSizes,
},
customSpacingSize,
defaultSpacingSizes: defaultSpacingSizesEnabled,
padding,
margin,
blockGap,
units,
},
border: {
color: borderColor,
radius: borderRadius,
style: borderStyle,
width: borderWidth,
},
dimensions: {
aspectRatio,
minHeight,
},
layout,
parentLayout,
shadow,
};
}, [
backgroundImage,
backgroundSize,
customFontFamilies,
defaultFontFamilies,
themeFontFamilies,
defaultFontSizesEnabled,
customFontSizes,
defaultFontSizes,
themeFontSizes,
customFontSize,
fontStyle,
fontWeight,
lineHeight,
textAlign,
textColumns,
textDecoration,
textTransform,
letterSpacing,
writingMode,
padding,
margin,
blockGap,
defaultSpacingSizesEnabled,
customSpacingSize,
userSpacingSizes,
defaultSpacingSizes,
themeSpacingSizes,
units,
aspectRatio,
minHeight,
layout,
parentLayout,
borderColor,
borderRadius,
borderStyle,
borderWidth,
customColorsEnabled,
customColors,
customDuotone,
themeColors,
defaultColors,
defaultPalette,
defaultDuotone,
userDuotonePalette,
themeDuotonePalette,
defaultDuotonePalette,
userGradientPalette,
themeGradientPalette,
defaultGradientPalette,
defaultGradients,
areCustomGradientsEnabled,
isBackgroundEnabled,
isLinkEnabled,
isTextEnabled,
isHeadingEnabled,
isButtonEnabled,
shadow,
] );
return useSettingsForBlockElement( rawSettings, name );
}
export function createBlockEditFilter( features ) {
// We don't want block controls to re-render when typing inside a block.
// `memo` will prevent re-renders unless props change, so only pass the
// needed props and not the whole attributes object.
features = features.map( ( settings ) => {
return { ...settings, Edit: memo( settings.edit ) };
} );
const withBlockEditHooks = createHigherOrderComponent(
( OriginalBlockEdit ) => ( props ) => {
const context = useBlockEditContext();
// CAUTION: code added before this line will be executed for all
// blocks, not just those that support the feature! Code added
// above this line should be carefully evaluated for its impact on
// performance.
return [
...features.map( ( feature, i ) => {
const {
Edit,
hasSupport,
attributeKeys = [],
shareWithChildBlocks,
} = feature;
const shouldDisplayControls =
context[ mayDisplayControlsKey ] ||
( context[ mayDisplayParentControlsKey ] &&
shareWithChildBlocks );
if (
! shouldDisplayControls ||
! hasSupport( props.name )
) {
return null;
}
const neededProps = {};
for ( const key of attributeKeys ) {
if ( props.attributes[ key ] ) {
neededProps[ key ] = props.attributes[ key ];
}
}
return (
<Edit
// We can use the index because the array length
// is fixed per page load right now.
key={ i }
name={ props.name }
isSelected={ props.isSelected }
clientId={ props.clientId }
setAttributes={ props.setAttributes }
__unstableParentLayout={
props.__unstableParentLayout
}
// This component is pure, so only pass needed
// props!!!
{ ...neededProps }
/>
);
} ),
<OriginalBlockEdit key="edit" { ...props } />,
];
},
'withBlockEditHooks'
);
addFilter( 'editor.BlockEdit', 'core/editor/hooks', withBlockEditHooks );
}
function BlockProps( {
index,
useBlockProps: hook,
setAllWrapperProps,
...props
} ) {
const wrapperProps = hook( props );
const setWrapperProps = ( next ) =>
setAllWrapperProps( ( prev ) => {
const nextAll = [ ...prev ];
nextAll[ index ] = next;
return nextAll;
} );
// Setting state after every render is fine because this component is
// pure and will only re-render when needed props change.
useEffect( () => {
// We could shallow compare the props, but since this component only
// changes when needed attributes change, the benefit is probably small.
setWrapperProps( wrapperProps );
return () => {
setWrapperProps( undefined );
};
} );
return null;
}
const BlockPropsPure = memo( BlockProps );
export function createBlockListBlockFilter( features ) {
const withBlockListBlockHooks = createHigherOrderComponent(
( BlockListBlock ) => ( props ) => {
const [ allWrapperProps, setAllWrapperProps ] = useState(
Array( features.length ).fill( undefined )
);
return [
...features.map( ( feature, i ) => {
const {
hasSupport,
attributeKeys = [],
useBlockProps,
isMatch,
} = feature;
const neededProps = {};
for ( const key of attributeKeys ) {
if ( props.attributes[ key ] ) {
neededProps[ key ] = props.attributes[ key ];
}
}
if (
// Skip rendering if none of the needed attributes are
// set.
! Object.keys( neededProps ).length ||
! hasSupport( props.name ) ||
( isMatch && ! isMatch( neededProps ) )
) {
return null;
}
return (
<BlockPropsPure
// We can use the index because the array length
// is fixed per page load right now.
key={ i }
index={ i }
useBlockProps={ useBlockProps }
// This component is pure, so we must pass a stable
// function reference.
setAllWrapperProps={ setAllWrapperProps }
name={ props.name }
clientId={ props.clientId }
// This component is pure, so only pass needed
// props!!!
{ ...neededProps }
/>
);
} ),
<BlockListBlock
key="edit"
{ ...props }
wrapperProps={ allWrapperProps
.filter( Boolean )
.reduce( ( acc, wrapperProps ) => {
return {
...acc,
...wrapperProps,
className: clsx(
acc.className,
wrapperProps.className
),
style: {
...acc.style,
...wrapperProps.style,
},
};
}, props.wrapperProps || {} ) }
/>,
];
},
'withBlockListBlockHooks'
);
addFilter(
'editor.BlockListBlock',
'core/editor/hooks',
withBlockListBlockHooks
);
}
export function createBlockSaveFilter( features ) {
function extraPropsFromHooks( props, name, attributes ) {
return features.reduce( ( accu, feature ) => {
const { hasSupport, attributeKeys = [], addSaveProps } = feature;
const neededAttributes = {};
for ( const key of attributeKeys ) {
if ( attributes[ key ] ) {
neededAttributes[ key ] = attributes[ key ];
}
}
if (
// Skip rendering if none of the needed attributes are
// set.
! Object.keys( neededAttributes ).length ||
! hasSupport( name )
) {
return accu;
}
return addSaveProps( accu, name, neededAttributes );
}, props );
}
addFilter(
'blocks.getSaveContent.extraProps',
'core/editor/hooks',
extraPropsFromHooks,
0
);
addFilter(
'blocks.getSaveContent.extraProps',
'core/editor/hooks',
( props ) => {
// Previously we had a filter deleting the className if it was an empty
// string. That filter is no longer running, so now we need to delete it
// here.
if ( props.hasOwnProperty( 'className' ) && ! props.className ) {
delete props.className;
}
return props;
}
);
}