@wordpress/editor
Version:
Enhanced block editor for WordPress posts.
795 lines (737 loc) • 20.5 kB
JavaScript
/**
* External dependencies
*/
import clsx from 'clsx';
/**
* WordPress dependencies
*/
import {
Disabled,
Composite,
privateApis as componentsPrivateApis,
} from '@wordpress/components';
import { __, _x, sprintf } from '@wordpress/i18n';
import {
BlockList,
privateApis as blockEditorPrivateApis,
store as blockEditorStore,
useSettings,
BlockEditorProvider,
__unstableEditorStyles as EditorStyles,
__unstableIframe as Iframe,
__experimentalUseMultipleOriginColorsAndGradients as useMultipleOriginColorsAndGradients,
} from '@wordpress/block-editor';
import { useSelect, dispatch } from '@wordpress/data';
import { mergeGlobalStyles } from '@wordpress/global-styles-engine';
import {
useMemo,
useState,
memo,
useRef,
useLayoutEffect,
useEffect,
forwardRef,
} from '@wordpress/element';
import { ENTER, SPACE } from '@wordpress/keycodes';
import { uploadMedia } from '@wordpress/media-utils';
import { store as coreStore } from '@wordpress/core-data';
/**
* Internal dependencies
*/
import { unlock } from '../../lock-unlock';
import { STYLE_BOOK_IFRAME_STYLES } from './constants';
import {
getExamplesByCategory,
getTopLevelStyleBookCategories,
} from './categories';
import { getExamples } from './examples';
import { GlobalStylesRenderer } from '../global-styles-renderer';
import {
STYLE_BOOK_COLOR_GROUPS,
STYLE_BOOK_PREVIEW_CATEGORIES,
} from '../style-book/constants';
import { useGlobalStylesOutputWithConfig } from '../../hooks/use-global-styles-output';
import { useStyle, useGlobalStyles } from '../global-styles';
import { store as editorStore } from '../../store';
const { ExperimentalBlockEditorProvider } = unlock( blockEditorPrivateApis );
const { Tabs } = unlock( componentsPrivateApis );
function isObjectEmpty( object ) {
return ! object || Object.keys( object ).length === 0;
}
/**
* Scrolls to a section within an iframe.
*
* @param {string} anchorId The id of the element to scroll to.
* @param {HTMLIFrameElement} iframe The target iframe.
*/
const scrollToSection = ( anchorId, iframe ) => {
if ( ! anchorId || ! iframe || ! iframe?.contentDocument ) {
return;
}
const element =
anchorId === 'top'
? iframe.contentDocument.body
: iframe.contentDocument.getElementById( anchorId );
if ( element ) {
element.scrollIntoView( {
behavior: 'smooth',
} );
}
};
/**
* Parses a Block Editor navigation path to build a style book navigation path.
* The object can be extended to include a category, representing a style book tab/section.
*
* @param {string} path An internal Block Editor navigation path.
* @return {null|{block: string}} An object containing the example to navigate to.
*/
const getStyleBookNavigationFromPath = ( path ) => {
if ( path && typeof path === 'string' ) {
if (
path === '/' ||
path.startsWith( '/typography' ) ||
path.startsWith( '/colors' ) ||
path.startsWith( '/blocks' )
) {
return {
top: true,
};
}
}
return null;
};
/**
* Retrieves colors, gradients, and duotone filters from Global Styles.
* The inclusion of default (Core) palettes is controlled by the relevant
* theme.json property e.g. defaultPalette, defaultGradients, defaultDuotone.
*
* @return {Object} Object containing properties for each type of palette.
*/
function useMultiOriginPalettes() {
const { colors, gradients } = useMultipleOriginColorsAndGradients();
// Add duotone filters to the palettes data.
const [
shouldDisplayDefaultDuotones,
customDuotones,
themeDuotones,
defaultDuotones,
] = useSettings(
'color.defaultDuotone',
'color.duotone.custom',
'color.duotone.theme',
'color.duotone.default'
);
const palettes = useMemo( () => {
const result = { colors, gradients, duotones: [] };
if ( themeDuotones && themeDuotones.length ) {
result.duotones.push( {
name: _x(
'Theme',
'Indicates these duotone filters come from the theme.'
),
slug: 'theme',
duotones: themeDuotones,
} );
}
if (
shouldDisplayDefaultDuotones &&
defaultDuotones &&
defaultDuotones.length
) {
result.duotones.push( {
name: _x(
'Default',
'Indicates these duotone filters come from WordPress.'
),
slug: 'default',
duotones: defaultDuotones,
} );
}
if ( customDuotones && customDuotones.length ) {
result.duotones.push( {
name: _x(
'Custom',
'Indicates these doutone filters are created by the user.'
),
slug: 'custom',
duotones: customDuotones,
} );
}
return result;
}, [
colors,
gradients,
customDuotones,
themeDuotones,
defaultDuotones,
shouldDisplayDefaultDuotones,
] );
return palettes;
}
/**
* Get deduped examples for single page stylebook.
* @param {Array} examples Array of examples.
* @return {Array} Deduped examples.
*/
export function getExamplesForSinglePageUse( examples ) {
const examplesForSinglePageUse = [];
const overviewCategoryExamples = getExamplesByCategory(
{ slug: 'overview' },
examples
);
examplesForSinglePageUse.push( ...overviewCategoryExamples.examples );
const otherExamples = examples.filter( ( example ) => {
return (
example.category !== 'overview' &&
! overviewCategoryExamples.examples.find(
( overviewExample ) => overviewExample.name === example.name
)
);
} );
examplesForSinglePageUse.push( ...otherExamples );
return examplesForSinglePageUse;
}
/**
* Applies a block variation to each example by updating its attributes.
*
* @param {Array} examples Array of examples
* @param {string} variation Block variation name.
* @return {Array} Updated examples with variation applied.
*/
function applyBlockVariationsToExamples( examples, variation ) {
if ( ! variation ) {
return examples;
}
return examples.map( ( example ) => {
return {
...example,
variation,
blocks: Array.isArray( example.blocks )
? example.blocks.map( ( block ) => ( {
...block,
attributes: {
...block.attributes,
style: undefined,
className: `is-style-${ variation }`,
},
} ) )
: {
...example.blocks,
attributes: {
...example.blocks.attributes,
style: undefined,
className: `is-style-${ variation }`,
},
},
};
} );
}
function StyleBook(
{
isSelected,
onClick,
onSelect,
showTabs = true,
userConfig = {},
path = '',
},
ref
) {
const textColor = useStyle( 'color.text' );
const backgroundColor = useStyle( 'color.background' );
const colors = useMultiOriginPalettes();
const examples = useMemo( () => getExamples( colors ), [ colors ] );
const tabs = useMemo(
() =>
getTopLevelStyleBookCategories().filter( ( category ) =>
examples.some(
( example ) => example.category === category.slug
)
),
[ examples ]
);
const examplesForSinglePageUse = getExamplesForSinglePageUse( examples );
const { base: baseConfig } = useGlobalStyles();
const goTo = getStyleBookNavigationFromPath( path );
const mergedConfig = useMemo( () => {
if ( ! isObjectEmpty( userConfig ) && ! isObjectEmpty( baseConfig ) ) {
return mergeGlobalStyles( baseConfig, userConfig );
}
return {};
}, [ baseConfig, userConfig ] );
const originalSettings = useSelect(
( select ) => select( blockEditorStore ).getSettings(),
[]
);
const [ globalStyles ] = useGlobalStylesOutputWithConfig( mergedConfig );
const settings = useMemo(
() => ( {
...originalSettings,
styles:
! isObjectEmpty( globalStyles ) && ! isObjectEmpty( userConfig )
? globalStyles
: originalSettings.styles,
isPreviewMode: true,
} ),
[ globalStyles, originalSettings, userConfig ]
);
return (
<div
ref={ ref }
className={ clsx( 'editor-style-book', {
'is-button': !! onClick,
} ) }
style={ {
color: textColor,
background: backgroundColor,
} }
>
{ showTabs ? (
<Tabs>
<div className="editor-style-book__tablist-container">
<Tabs.TabList>
{ tabs.map( ( tab ) => (
<Tabs.Tab tabId={ tab.slug } key={ tab.slug }>
{ tab.title }
</Tabs.Tab>
) ) }
</Tabs.TabList>
</div>
{ tabs.map( ( tab ) => {
const categoryDefinition = tab.slug
? getTopLevelStyleBookCategories().find(
( _category ) => _category.slug === tab.slug
)
: null;
const filteredExamples = categoryDefinition
? getExamplesByCategory(
categoryDefinition,
examples
)
: { examples };
return (
<Tabs.TabPanel
key={ tab.slug }
tabId={ tab.slug }
focusable={ false }
className="editor-style-book__tabpanel"
>
<StyleBookBody
category={ tab.slug }
examples={ filteredExamples }
isSelected={ isSelected }
onSelect={ onSelect }
settings={ settings }
title={ tab.title }
goTo={ goTo }
/>
</Tabs.TabPanel>
);
} ) }
</Tabs>
) : (
<StyleBookBody
examples={ { examples: examplesForSinglePageUse } }
isSelected={ isSelected }
onClick={ onClick }
onSelect={ onSelect }
settings={ settings }
goTo={ goTo }
/>
) }
</div>
);
}
/**
* Style Book Preview component renders the stylebook without the Editor dependency.
*
* @param {Object} props Component props.
* @param {string} props.path Current path in global styles.
* @param {Function} props.onPathChange Callback when the path changes.
* @param {Object} props.userConfig User configuration.
* @param {boolean} props.isStatic Whether the stylebook is static or clickable.
* @param {Object} props.settings Optional editor settings to use instead of the editor store settings.
* @return {Object} Style Book Preview component.
*/
export const StyleBookPreview = ( {
userConfig = {},
isStatic = false,
path,
onPathChange,
settings: settingsProp,
} ) => {
const editorSettings = useSelect(
( select ) => settingsProp ?? select( editorStore ).getEditorSettings(),
[ settingsProp ]
);
const canUserUploadMedia = useSelect(
( select ) =>
select( coreStore ).canUser( 'create', {
kind: 'postType',
name: 'attachment',
} ),
[]
);
// Update block editor settings because useMultipleOriginColorsAndGradients fetch colours from there.
useEffect( () => {
dispatch( blockEditorStore ).updateSettings( {
...editorSettings,
mediaUpload: canUserUploadMedia ? uploadMedia : undefined,
} );
}, [ editorSettings, canUserUploadMedia ] );
const [ internalPath, setInternalPath ] = useState( '/' );
const section = path ?? internalPath;
const onChangeSection = onPathChange ?? setInternalPath;
const isSelected = ( blockName ) => {
// Match '/blocks/core%2Fbutton' and
// '/blocks/core%2Fbutton/typography', but not
// '/blocks/core%2Fbuttons'.
return (
section === `/blocks/${ encodeURIComponent( blockName ) }` ||
section.startsWith(
`/blocks/${ encodeURIComponent( blockName ) }/`
)
);
};
const onSelect = ( blockName, isBlockVariation = false ) => {
if (
STYLE_BOOK_COLOR_GROUPS.find(
( group ) => group.slug === blockName
)
) {
// Go to color palettes Global Styles.
onChangeSection( '/colors/palette' );
return;
}
if ( blockName === 'typography' ) {
// Go to typography Global Styles.
onChangeSection( '/typography' );
return;
}
if ( isBlockVariation ) {
return;
}
// Now go to the selected block.
onChangeSection( `/blocks/${ encodeURIComponent( blockName ) }` );
};
const colors = useMultiOriginPalettes();
const examples = getExamples( colors );
const examplesForSinglePageUse = getExamplesForSinglePageUse( examples );
let previewCategory = null;
let blockVariation = null;
if ( section.includes( '/colors' ) ) {
previewCategory = 'colors';
} else if ( section.includes( '/typography' ) ) {
previewCategory = 'text';
} else if ( section.includes( '/blocks' ) ) {
previewCategory = 'blocks';
let blockName = decodeURIComponent( section ).split( '/blocks/' )[ 1 ];
// The blockName can contain variations, if so, extract the variation.
if ( blockName?.includes( '/variations' ) ) {
[ blockName, blockVariation ] = blockName.split( '/variations/' );
}
if (
blockName &&
examples.find( ( example ) => example.name === blockName )
) {
previewCategory = blockName;
}
} else if ( ! isStatic ) {
previewCategory = 'overview';
}
const categoryDefinition = STYLE_BOOK_PREVIEW_CATEGORIES.find(
( category ) => category.slug === previewCategory
);
const filteredExamples = useMemo( () => {
// If there's no category definition there may be a single block.
if ( ! categoryDefinition ) {
return {
examples: [
examples.find(
( example ) => example.name === previewCategory
),
],
};
}
return getExamplesByCategory( categoryDefinition, examples );
}, [ categoryDefinition, examples, previewCategory ] );
const displayedExamples = useMemo( () => {
// If there's no preview category, show all examples.
if ( ! previewCategory ) {
return { examples: examplesForSinglePageUse };
}
if ( blockVariation ) {
return {
examples: applyBlockVariationsToExamples(
filteredExamples.examples,
blockVariation
),
};
}
return filteredExamples;
}, [
previewCategory,
examplesForSinglePageUse,
blockVariation,
filteredExamples,
] );
const { base: baseConfig } = useGlobalStyles();
const goTo = getStyleBookNavigationFromPath( section );
const mergedConfig = useMemo( () => {
if ( ! isObjectEmpty( userConfig ) && ! isObjectEmpty( baseConfig ) ) {
return mergeGlobalStyles( baseConfig, userConfig );
}
return {};
}, [ baseConfig, userConfig ] );
const [ globalStyles ] = useGlobalStylesOutputWithConfig( mergedConfig );
const settings = useMemo(
() => ( {
...editorSettings,
styles:
! isObjectEmpty( globalStyles ) && ! isObjectEmpty( userConfig )
? globalStyles
: editorSettings.styles,
isPreviewMode: true,
} ),
[ globalStyles, editorSettings, userConfig ]
);
return (
<div className="editor-style-book">
<BlockEditorProvider settings={ settings }>
<GlobalStylesRenderer disableRootPadding />
<StyleBookBody
examples={ displayedExamples }
settings={ settings }
goTo={ goTo }
isSelected={ ! isStatic ? isSelected : null }
onSelect={ ! isStatic ? onSelect : null }
/>
</BlockEditorProvider>
</div>
);
};
export const StyleBookBody = ( {
examples,
isSelected,
onClick,
onSelect,
settings,
title,
goTo,
} ) => {
const [ isFocused, setIsFocused ] = useState( false );
const [ hasIframeLoaded, setHasIframeLoaded ] = useState( false );
const iframeRef = useRef( null );
// The presence of an `onClick` prop indicates that the Style Book is being used as a button.
// In this case, add additional props to the iframe to make it behave like a button.
const buttonModeProps = {
role: 'button',
onFocus: () => setIsFocused( true ),
onBlur: () => setIsFocused( false ),
onKeyDown: ( event ) => {
if ( event.defaultPrevented ) {
return;
}
const { keyCode } = event;
if ( onClick && ( keyCode === ENTER || keyCode === SPACE ) ) {
event.preventDefault();
onClick( event );
}
},
onClick: ( event ) => {
if ( event.defaultPrevented ) {
return;
}
if ( onClick ) {
event.preventDefault();
onClick( event );
}
},
readonly: true,
};
const handleLoad = () => setHasIframeLoaded( true );
useLayoutEffect( () => {
if ( hasIframeLoaded && iframeRef.current && goTo?.top ) {
scrollToSection( 'top', iframeRef.current );
}
}, [ goTo?.top, hasIframeLoaded ] );
return (
<Iframe
onLoad={ handleLoad }
ref={ iframeRef }
className={ clsx( 'editor-style-book__iframe', {
'is-focused': isFocused && !! onClick,
'is-button': !! onClick,
} ) }
name="style-book-canvas"
tabIndex={ 0 }
{ ...( onClick ? buttonModeProps : {} ) }
>
<EditorStyles styles={ settings.styles } />
<style>
{ STYLE_BOOK_IFRAME_STYLES }
{ !! onClick &&
'body { cursor: pointer; } body * { pointer-events: none; }' }
</style>
<Examples
className="editor-style-book__examples"
filteredExamples={ examples }
label={
title
? sprintf(
// translators: %s: Category of blocks, e.g. Text.
__( 'Examples of blocks in the %s category' ),
title
)
: __( 'Examples of blocks' )
}
isSelected={ isSelected }
onSelect={ onSelect }
key={ title }
/>
</Iframe>
);
};
const Examples = memo(
( { className, filteredExamples, label, isSelected, onSelect } ) => {
return (
<Composite
orientation="vertical"
className={ className }
aria-label={ label }
role="grid"
>
{ !! filteredExamples?.examples?.length &&
filteredExamples.examples.map( ( example ) => (
<Example
key={ example.name }
id={ `example-${ example.name }` }
title={ example.title }
content={ example.content }
blocks={ example.blocks }
isSelected={ isSelected?.( example.name ) }
onClick={
!! onSelect
? () =>
onSelect(
example.name,
!! example.variation
)
: null
}
/>
) ) }
{ !! filteredExamples?.subcategories?.length &&
filteredExamples.subcategories.map( ( subcategory ) => (
<Composite.Group
className="editor-style-book__subcategory"
key={ `subcategory-${ subcategory.slug }` }
>
<Composite.GroupLabel>
<h2 className="editor-style-book__subcategory-title">
{ subcategory.title }
</h2>
</Composite.GroupLabel>
<Subcategory
examples={ subcategory.examples }
isSelected={ isSelected }
onSelect={ onSelect }
/>
</Composite.Group>
) ) }
</Composite>
);
}
);
const Subcategory = ( { examples, isSelected, onSelect } ) => {
return (
!! examples?.length &&
examples.map( ( example ) => (
<Example
key={ example.name }
id={ `example-${ example.name }` }
title={ example.title }
content={ example.content }
blocks={ example.blocks }
isSelected={ isSelected?.( example.name ) }
onClick={ !! onSelect ? () => onSelect( example.name ) : null }
/>
) )
);
};
const disabledExamples = [ 'example-duotones' ];
const Example = ( { id, title, blocks, isSelected, onClick, content } ) => {
const originalSettings = useSelect(
( select ) => select( blockEditorStore ).getSettings(),
[]
);
const settings = useMemo(
() => ( {
...originalSettings,
focusMode: false, // Disable "Spotlight mode".
isPreviewMode: true,
} ),
[ originalSettings ]
);
// Cache the list of blocks to avoid additional processing when the component is re-rendered.
const renderedBlocks = useMemo(
() => ( Array.isArray( blocks ) ? blocks : [ blocks ] ),
[ blocks ]
);
const disabledProps =
disabledExamples.includes( id ) || ! onClick
? {
disabled: true,
accessibleWhenDisabled: !! onClick,
}
: {};
return (
<div role="row">
<div role="gridcell">
<Composite.Item
className={ clsx( 'editor-style-book__example', {
'is-selected': isSelected,
'is-disabled-example': !! disabledProps?.disabled,
} ) }
id={ id }
aria-label={
!! onClick
? sprintf(
// translators: %s: Title of a block, e.g. Heading.
__( 'Open %s styles in Styles panel' ),
title
)
: undefined
}
render={ <div /> }
role={ !! onClick ? 'button' : null }
onClick={ onClick }
{ ...disabledProps }
>
<span className="editor-style-book__example-title">
{ title }
</span>
<div
className="editor-style-book__example-preview"
aria-hidden
>
<Disabled className="editor-style-book__example-preview__content">
{ content ? (
content
) : (
<ExperimentalBlockEditorProvider
value={ renderedBlocks }
settings={ settings }
>
<EditorStyles />
<BlockList renderAppender={ false } />
</ExperimentalBlockEditorProvider>
) }
</Disabled>
</div>
</Composite.Item>
</div>
</div>
);
};
export default forwardRef( StyleBook );