diff --git a/lib/block-supports/pattern.php b/lib/block-supports/pattern.php new file mode 100644 index 0000000000000..a783135c793e3 --- /dev/null +++ b/lib/block-supports/pattern.php @@ -0,0 +1,36 @@ +supports, array( '__experimentalConnections' ), false ) : false; + + if ( $pattern_support ) { + if ( ! $block_type->uses_context ) { + $block_type->uses_context = array(); + } + + if ( ! in_array( 'pattern/overrides', $block_type->uses_context, true ) ) { + $block_type->uses_context[] = 'pattern/overrides'; + } + } + } + + // Register the block support. + WP_Block_Supports::get_instance()->register( + 'pattern', + array( + 'register_attribute' => 'gutenberg_register_pattern_support', + ) + ); +} diff --git a/lib/experimental/blocks.php b/lib/experimental/blocks.php index f9f2412ae5120..88e46b478389d 100644 --- a/lib/experimental/blocks.php +++ b/lib/experimental/blocks.php @@ -82,7 +82,10 @@ function wp_enqueue_block_view_script( $block_name, $args ) { $gutenberg_experiments = get_option( 'gutenberg-experiments' ); -if ( $gutenberg_experiments && array_key_exists( 'gutenberg-connections', $gutenberg_experiments ) ) { +if ( $gutenberg_experiments && ( + array_key_exists( 'gutenberg-connections', $gutenberg_experiments ) || + array_key_exists( 'gutenberg-pattern-partial-syncing', $gutenberg_experiments ) +) ) { /** * Renders the block meta attributes. * @@ -132,9 +135,8 @@ function gutenberg_render_block_connections( $block_content, $block, $block_inst continue; } - // If the source value is not "meta_fields", skip it because the only supported - // connection source is meta (custom fields) for now. - if ( 'meta_fields' !== $attribute_value['source'] ) { + // Skip if the source value is not "meta_fields" or "pattern_attributes". + if ( 'meta_fields' !== $attribute_value['source'] && 'pattern_attributes' !== $attribute_value['source'] ) { continue; } @@ -143,16 +145,28 @@ function gutenberg_render_block_connections( $block_content, $block, $block_inst continue; } - // If the attribute does not specify the name of the custom field, skip it. - if ( ! isset( $attribute_value['value'] ) ) { - continue; + if ( 'pattern_attributes' === $attribute_value['source'] ) { + if ( ! _wp_array_get( $block_instance->attributes, array( 'metadata', 'id' ), false ) ) { + continue; + } + + $custom_value = $connection_sources[ $attribute_value['source'] ]( $block_instance ); + } else { + // If the attribute does not specify the name of the custom field, skip it. + if ( ! isset( $attribute_value['value'] ) ) { + continue; + } + + // Get the content from the connection source. + $custom_value = $connection_sources[ $attribute_value['source'] ]( + $block_instance, + $attribute_value['value'] + ); } - // Get the content from the connection source. - $custom_value = $connection_sources[ $attribute_value['source'] ]( - $block_instance, - $attribute_value['value'] - ); + if ( false === $custom_value ) { + continue; + } $tags = new WP_HTML_Tag_Processor( $block_content ); $found = $tags->next_tag( @@ -181,5 +195,6 @@ function gutenberg_render_block_connections( $block_content, $block, $block_inst return $block_content; } + add_filter( 'render_block', 'gutenberg_render_block_connections', 10, 3 ); } diff --git a/lib/experimental/connection-sources/index.php b/lib/experimental/connection-sources/index.php index b63abcad96f62..bf89ba177b6e9 100644 --- a/lib/experimental/connection-sources/index.php +++ b/lib/experimental/connection-sources/index.php @@ -6,10 +6,14 @@ */ return array( - 'name' => 'meta', - 'meta_fields' => function ( $block_instance, $meta_field ) { + 'name' => 'meta', + 'meta_fields' => function ( $block_instance, $meta_field ) { // We should probably also check if the meta field exists but for now it's okay because // if it doesn't, `get_post_meta()` will just return an empty string. return get_post_meta( $block_instance->context['postId'], $meta_field, true ); }, + 'pattern_attributes' => function ( $block_instance ) { + $block_id = $block_instance->attributes['metadata']['id']; + return _wp_array_get( $block_instance->context, array( 'pattern/overrides', $block_id ), false ); + }, ); diff --git a/lib/experimental/editor-settings.php b/lib/experimental/editor-settings.php index 2c7d6310005bf..5f61684e8b134 100644 --- a/lib/experimental/editor-settings.php +++ b/lib/experimental/editor-settings.php @@ -33,6 +33,10 @@ function gutenberg_enable_experiments() { if ( gutenberg_is_experiment_enabled( 'gutenberg-no-tinymce' ) ) { wp_add_inline_script( 'wp-block-library', 'window.__experimentalDisableTinymce = true', 'before' ); } + + if ( $gutenberg_experiments && array_key_exists( 'gutenberg-pattern-partial-syncing', $gutenberg_experiments ) ) { + wp_add_inline_script( 'wp-block-editor', 'window.__experimentalPatternPartialSyncing = true', 'before' ); + } } add_action( 'admin_init', 'gutenberg_enable_experiments' ); diff --git a/lib/experiments-page.php b/lib/experiments-page.php index 0bcd28b2aa2c4..b77a69b692ff1 100644 --- a/lib/experiments-page.php +++ b/lib/experiments-page.php @@ -138,6 +138,18 @@ function gutenberg_initialize_experiments_settings() { ) ); + add_settings_field( + 'gutenberg-pattern-partial-syncing', + __( 'Synced patterns partial syncing', 'gutenberg' ), + 'gutenberg_display_experiment_field', + 'gutenberg-experiments', + 'gutenberg_experiments_section', + array( + 'label' => __( 'Test partial syncing of patterns', 'gutenberg' ), + 'id' => 'gutenberg-pattern-partial-syncing', + ) + ); + register_setting( 'gutenberg-experiments', 'gutenberg-experiments' diff --git a/lib/load.php b/lib/load.php index 38111d9ed5d3d..e940ab94e055f 100644 --- a/lib/load.php +++ b/lib/load.php @@ -270,6 +270,7 @@ function () { require __DIR__ . '/block-supports/shadow.php'; require __DIR__ . '/block-supports/background.php'; require __DIR__ . '/block-supports/behaviors.php'; +require __DIR__ . '/block-supports/pattern.php'; // Data views. require_once __DIR__ . '/experimental/data-views.php'; diff --git a/package-lock.json b/package-lock.json index b2deb50ef559f..7c3539dd39fbe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -56046,7 +56046,8 @@ "@wordpress/icons": "file:../icons", "@wordpress/notices": "file:../notices", "@wordpress/private-apis": "file:../private-apis", - "@wordpress/url": "file:../url" + "@wordpress/url": "file:../url", + "nanoid": "^3.3.4" }, "engines": { "node": ">=16.0.0" @@ -70916,7 +70917,8 @@ "@wordpress/icons": "file:../icons", "@wordpress/notices": "file:../notices", "@wordpress/private-apis": "file:../private-apis", - "@wordpress/url": "file:../url" + "@wordpress/url": "file:../url", + "nanoid": "^3.3.4" } }, "@wordpress/plugins": { diff --git a/packages/block-editor/src/hooks/custom-fields.js b/packages/block-editor/src/hooks/custom-fields.js index adb9df15824a7..8ab816abc7352 100644 --- a/packages/block-editor/src/hooks/custom-fields.js +++ b/packages/block-editor/src/hooks/custom-fields.js @@ -128,12 +128,17 @@ const withCustomFieldsControls = createHigherOrderComponent( ( BlockEdit ) => { }; }, 'withCustomFieldsControls' ); -if ( window.__experimentalConnections ) { +if ( + window.__experimentalConnections || + window.__experimentalPatternPartialSyncing +) { addFilter( 'blocks.registerBlockType', 'core/editor/connections/attribute', addAttribute ); +} +if ( window.__experimentalConnections ) { addFilter( 'editor.BlockEdit', 'core/editor/connections/with-inspector-controls', diff --git a/packages/block-editor/src/store/private-actions.js b/packages/block-editor/src/store/private-actions.js index dc62614b57681..1c29948d81416 100644 --- a/packages/block-editor/src/store/private-actions.js +++ b/packages/block-editor/src/store/private-actions.js @@ -290,3 +290,11 @@ export function deleteStyleOverride( id ) { id, }; } + +export function syncDerivedBlockAttributes( clientId, attributes ) { + return { + type: 'SYNC_DERIVED_BLOCK_ATTRIBUTES', + clientIds: [ clientId ], + attributes, + }; +} diff --git a/packages/block-editor/src/store/reducer.js b/packages/block-editor/src/store/reducer.js index d5ff85e9e4257..5319a3b255365 100644 --- a/packages/block-editor/src/store/reducer.js +++ b/packages/block-editor/src/store/reducer.js @@ -283,6 +283,7 @@ const withBlockTree = false ); break; + case 'SYNC_DERIVED_BLOCK_ATTRIBUTES': case 'UPDATE_BLOCK_ATTRIBUTES': { newState.tree = new Map( newState.tree ); action.clientIds.forEach( ( clientId ) => { @@ -456,6 +457,12 @@ function withPersistentBlockChange( reducer ) { return ( state, action ) => { let nextState = reducer( state, action ); + if ( action.type === 'SYNC_DERIVED_BLOCK_ATTRIBUTES' ) { + return nextState.isPersistentChange + ? { ...nextState, isPersistentChange: false } + : nextState; + } + const isExplicitPersistentChange = action.type === 'MARK_LAST_CHANGE_AS_PERSISTENT' || markNextChangeAsNotPersistent; @@ -860,6 +867,7 @@ export const blocks = pipe( return newState; } + case 'SYNC_DERIVED_BLOCK_ATTRIBUTES': case 'UPDATE_BLOCK_ATTRIBUTES': { // Avoid a state change if none of the block IDs are known. if ( action.clientIds.every( ( id ) => ! state.get( id ) ) ) { diff --git a/packages/block-library/src/block/edit.js b/packages/block-library/src/block/edit.js index 979ae04c62282..e86ed9b59c62b 100644 --- a/packages/block-library/src/block/edit.js +++ b/packages/block-library/src/block/edit.js @@ -6,11 +6,9 @@ import classnames from 'classnames'; /** * WordPress dependencies */ -import { - useEntityBlockEditor, - useEntityProp, - useEntityRecord, -} from '@wordpress/core-data'; +import { useRegistry, useSelect, useDispatch } from '@wordpress/data'; +import { useRef, useMemo, useEffect } from '@wordpress/element'; +import { useEntityProp, useEntityRecord } from '@wordpress/core-data'; import { Placeholder, Spinner, @@ -27,8 +25,9 @@ import { useBlockProps, Warning, privateApis as blockEditorPrivateApis, + store as blockEditorStore, } from '@wordpress/block-editor'; -import { useRef, useMemo } from '@wordpress/element'; +import { getBlockSupport, parse } from '@wordpress/blocks'; /** * Internal dependencies @@ -36,6 +35,24 @@ import { useRef, useMemo } from '@wordpress/element'; import { unlock } from '../lock-unlock'; const { useLayoutClasses } = unlock( blockEditorPrivateApis ); + +function isPartiallySynced( block ) { + return ( + !! getBlockSupport( block.name, '__experimentalConnections', false ) && + !! block.attributes.connections?.attributes && + Object.values( block.attributes.connections.attributes ).some( + ( connection ) => connection.source === 'pattern_attributes' + ) + ); +} +function getPartiallySyncedAttributes( block ) { + return Object.entries( block.attributes.connections.attributes ) + .filter( + ( [ , connection ] ) => connection.source === 'pattern_attributes' + ) + .map( ( [ attributeKey ] ) => attributeKey ); +} + const fullAlignments = [ 'full', 'wide', 'left', 'right' ]; const useInferredLayout = ( blocks, parentLayout ) => { @@ -67,11 +84,61 @@ const useInferredLayout = ( blocks, parentLayout ) => { }, [ blocks, parentLayout ] ); }; +function applyInitialOverrides( blocks, overrides = {}, defaultValues ) { + return blocks.map( ( block ) => { + const innerBlocks = applyInitialOverrides( + block.innerBlocks, + overrides, + defaultValues + ); + const blockId = block.attributes.metadata?.id; + if ( ! isPartiallySynced( block ) || ! blockId ) + return { ...block, innerBlocks }; + const attributes = getPartiallySyncedAttributes( block ); + const newAttributes = { ...block.attributes }; + for ( const attributeKey of attributes ) { + defaultValues[ blockId ] = block.attributes[ attributeKey ]; + if ( overrides[ blockId ] ) { + newAttributes[ attributeKey ] = overrides[ blockId ]; + } + } + return { + ...block, + attributes: newAttributes, + innerBlocks, + }; + } ); +} + +function getOverridesFromBlocks( blocks, defaultValues ) { + /** @type {Record} */ + const overrides = {}; + for ( const block of blocks ) { + Object.assign( + overrides, + getOverridesFromBlocks( block.innerBlocks, defaultValues ) + ); + const blockId = block.attributes.metadata?.id; + if ( ! isPartiallySynced( block ) || ! blockId ) continue; + const attributes = getPartiallySyncedAttributes( block ); + for ( const attributeKey of attributes ) { + if ( + block.attributes[ attributeKey ] !== defaultValues[ blockId ] + ) { + overrides[ blockId ] = block.attributes[ attributeKey ]; + } + } + } + return Object.keys( overrides ).length > 0 ? overrides : undefined; +} + export default function ReusableBlockEdit( { name, - attributes: { ref }, + attributes: { ref, overrides }, __unstableParentLayout: parentLayout, + clientId: patternClientId, } ) { + const registry = useRegistry(); const hasAlreadyRendered = useHasRecursion( ref ); const { record, hasResolved } = useEntityRecord( 'postType', @@ -79,11 +146,46 @@ export default function ReusableBlockEdit( { ref ); const isMissing = hasResolved && ! record; + const initialOverrides = useRef( overrides ); + const defaultValuesRef = useRef( {} ); + const { + replaceInnerBlocks, + __unstableMarkNextChangeAsNotPersistent, + setBlockEditingMode, + } = useDispatch( blockEditorStore ); + const { getBlockEditingMode } = useSelect( blockEditorStore ); - const [ blocks, onInput, onChange ] = useEntityBlockEditor( - 'postType', - 'wp_block', - { id: ref } + useEffect( () => { + if ( ! record?.content?.raw ) return; + const initialBlocks = parse( record.content.raw ); + + const editingMode = getBlockEditingMode( patternClientId ); + registry.batch( () => { + setBlockEditingMode( patternClientId, 'default' ); + __unstableMarkNextChangeAsNotPersistent(); + replaceInnerBlocks( + patternClientId, + applyInitialOverrides( + initialBlocks, + initialOverrides.current, + defaultValuesRef.current + ) + ); + setBlockEditingMode( patternClientId, editingMode ); + } ); + }, [ + __unstableMarkNextChangeAsNotPersistent, + patternClientId, + record, + replaceInnerBlocks, + registry, + getBlockEditingMode, + setBlockEditingMode, + ] ); + + const innerBlocks = useSelect( + ( select ) => select( blockEditorStore ).getBlocks( patternClientId ), + [ patternClientId ] ); const [ title, setTitle ] = useEntityProp( @@ -93,7 +195,10 @@ export default function ReusableBlockEdit( { ref ); - const { alignment, layout } = useInferredLayout( blocks, parentLayout ); + const { alignment, layout } = useInferredLayout( + innerBlocks, + parentLayout + ); const layoutClasses = useLayoutClasses( { layout }, name ); const blockProps = useBlockProps( { @@ -105,16 +210,38 @@ export default function ReusableBlockEdit( { } ); const innerBlocksProps = useInnerBlocksProps( blockProps, { - value: blocks, layout, - onInput, - onChange, - renderAppender: blocks?.length + renderAppender: innerBlocks?.length ? undefined : InnerBlocks.ButtonBlockAppender, } ); + // Sync the `overrides` attribute from the updated blocks. + // `syncDerivedBlockAttributes` is an action that just like `updateBlockAttributes` + // but won't create an undo level. + // This can be abstracted into a `useSyncDerivedAttributes` hook if needed. + useEffect( () => { + const { getBlocks } = registry.select( blockEditorStore ); + const { syncDerivedBlockAttributes } = unlock( + registry.dispatch( blockEditorStore ) + ); + let prevBlocks = getBlocks( patternClientId ); + return registry.subscribe( () => { + const blocks = getBlocks( patternClientId ); + if ( blocks !== prevBlocks ) { + prevBlocks = blocks; + syncDerivedBlockAttributes( patternClientId, { + overrides: getOverridesFromBlocks( + blocks, + defaultValuesRef.current + ), + } ); + } + }, blockEditorStore ); + }, [ patternClientId, registry ] ); + let children = null; + if ( hasAlreadyRendered ) { children = ( diff --git a/packages/block-library/src/block/index.js b/packages/block-library/src/block/index.js index 95e090f0afd6a..0d117e6f3938a 100644 --- a/packages/block-library/src/block/index.js +++ b/packages/block-library/src/block/index.js @@ -8,14 +8,15 @@ import { symbol as icon } from '@wordpress/icons'; */ import initBlock from '../utils/init-block'; import metadata from './block.json'; -import edit from './edit'; +import editV1 from './v1/edit'; +import editV2 from './edit'; const { name } = metadata; export { metadata, name }; export const settings = { - edit, + edit: window.__experimentalPatternPartialSyncing ? editV2 : editV1, icon, }; diff --git a/packages/block-library/src/block/index.php b/packages/block-library/src/block/index.php index d51b35d68b23d..54b54fad139ff 100644 --- a/packages/block-library/src/block/index.php +++ b/packages/block-library/src/block/index.php @@ -46,8 +46,31 @@ function render_block_core_block( $attributes ) { $content = $wp_embed->run_shortcode( $reusable_block->post_content ); $content = $wp_embed->autoembed( $content ); + $gutenberg_experiments = get_option( 'gutenberg-experiments' ); + $has_partial_synced_overrides = $gutenberg_experiments + && array_key_exists( 'gutenberg-pattern-partial-syncing', $gutenberg_experiments ) + && isset( $attributes['overrides'] ); + + /** + * We set the `pattern/overrides` context through the `render_block_context` + * filter so that it is available when a pattern's inner blocks are + * rendering via do_blocks given it only receives the inner content. + */ + if ( $has_partial_synced_overrides ) { + $filter_block_context = static function ( $context ) use ( $attributes ) { + $context['pattern/overrides'] = $attributes['overrides']; + return $context; + }; + add_filter( 'render_block_context', $filter_block_context, 1 ); + } + $content = do_blocks( $content ); unset( $seen_refs[ $attributes['ref'] ] ); + + if ( $has_partial_synced_overrides ) { + remove_filter( 'render_block_context', $filter_block_context, 1 ); + } + return $content; } @@ -63,3 +86,28 @@ function register_block_core_block() { ); } add_action( 'init', 'register_block_core_block' ); + +$gutenberg_experiments = get_option( 'gutenberg-experiments' ); +if ( $gutenberg_experiments && array_key_exists( 'gutenberg-pattern-partial-syncing', $gutenberg_experiments ) ) { + /** + * Registers the overrides attribute for core/block. + * + * @param array $args Array of arguments for registering a block type. + * @param string $block_name Block name including namespace. + * @return array $args + */ + function register_block_core_block_args( $args, $block_name ) { + if ( 'core/block' === $block_name ) { + $args['attributes'] = array_merge( + $args['attributes'], + array( + 'overrides' => array( + 'type' => 'object', + ), + ) + ); + } + return $args; + } + add_filter( 'register_block_type_args', 'register_block_core_block_args', 10, 2 ); +} diff --git a/packages/block-library/src/block/v1/edit.js b/packages/block-library/src/block/v1/edit.js new file mode 100644 index 0000000000000..5975711376c65 --- /dev/null +++ b/packages/block-library/src/block/v1/edit.js @@ -0,0 +1,163 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { + useEntityBlockEditor, + useEntityProp, + useEntityRecord, +} from '@wordpress/core-data'; +import { + Placeholder, + Spinner, + TextControl, + PanelBody, +} from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { + useInnerBlocksProps, + __experimentalRecursionProvider as RecursionProvider, + __experimentalUseHasRecursion as useHasRecursion, + InnerBlocks, + InspectorControls, + useBlockProps, + Warning, + privateApis as blockEditorPrivateApis, +} from '@wordpress/block-editor'; +import { useRef, useMemo } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { unlock } from '../../lock-unlock'; + +const { useLayoutClasses } = unlock( blockEditorPrivateApis ); +const fullAlignments = [ 'full', 'wide', 'left', 'right' ]; + +const useInferredLayout = ( blocks, parentLayout ) => { + const initialInferredAlignmentRef = useRef(); + + return useMemo( () => { + // Exit early if the pattern's blocks haven't loaded yet. + if ( ! blocks?.length ) { + return {}; + } + + let alignment = initialInferredAlignmentRef.current; + + // Only track the initial alignment so that temporarily removed + // alignments can be reapplied. + if ( alignment === undefined ) { + const isConstrained = parentLayout?.type === 'constrained'; + const hasFullAlignment = blocks.some( ( block ) => + fullAlignments.includes( block.attributes.align ) + ); + + alignment = isConstrained && hasFullAlignment ? 'full' : null; + initialInferredAlignmentRef.current = alignment; + } + + const layout = alignment ? parentLayout : undefined; + + return { alignment, layout }; + }, [ blocks, parentLayout ] ); +}; + +export default function ReusableBlockEdit( { + name, + attributes: { ref }, + __unstableParentLayout: parentLayout, +} ) { + const hasAlreadyRendered = useHasRecursion( ref ); + const { record, hasResolved } = useEntityRecord( + 'postType', + 'wp_block', + ref + ); + const isMissing = hasResolved && ! record; + + const [ blocks, onInput, onChange ] = useEntityBlockEditor( + 'postType', + 'wp_block', + { id: ref } + ); + + const [ title, setTitle ] = useEntityProp( + 'postType', + 'wp_block', + 'title', + ref + ); + + const { alignment, layout } = useInferredLayout( blocks, parentLayout ); + const layoutClasses = useLayoutClasses( { layout }, name ); + + const blockProps = useBlockProps( { + className: classnames( + 'block-library-block__reusable-block-container', + layout && layoutClasses, + { [ `align${ alignment }` ]: alignment } + ), + } ); + + const innerBlocksProps = useInnerBlocksProps( blockProps, { + value: blocks, + layout, + onInput, + onChange, + renderAppender: blocks?.length + ? undefined + : InnerBlocks.ButtonBlockAppender, + } ); + + let children = null; + + if ( hasAlreadyRendered ) { + children = ( + + { __( 'Block cannot be rendered inside itself.' ) } + + ); + } + + if ( isMissing ) { + children = ( + + { __( 'Block has been deleted or is unavailable.' ) } + + ); + } + + if ( ! hasResolved ) { + children = ( + + + + ); + } + + return ( + + + + + + + { children === null ? ( +
+ ) : ( +
{ children }
+ ) } + + ); +} diff --git a/packages/block-library/src/block/edit.native.js b/packages/block-library/src/block/v1/edit.native.js similarity index 98% rename from packages/block-library/src/block/edit.native.js rename to packages/block-library/src/block/v1/edit.native.js index 9ab6ccf86a1e1..3a649921b3dda 100644 --- a/packages/block-library/src/block/edit.native.js +++ b/packages/block-library/src/block/v1/edit.native.js @@ -42,8 +42,8 @@ import { store as noticesStore } from '@wordpress/notices'; /** * Internal dependencies */ -import styles from './editor.scss'; -import EditTitle from './edit-title'; +import styles from '../editor.scss'; +import EditTitle from '../edit-title'; export default function ReusableBlockEdit( { attributes: { ref }, diff --git a/packages/editor/src/hooks/index.js b/packages/editor/src/hooks/index.js index 6e0934d63c0cf..5a48ec1bf4956 100644 --- a/packages/editor/src/hooks/index.js +++ b/packages/editor/src/hooks/index.js @@ -3,3 +3,4 @@ */ import './custom-sources-backwards-compatibility'; import './default-autocompleters'; +import './pattern-partial-syncing'; diff --git a/packages/editor/src/hooks/pattern-partial-syncing.js b/packages/editor/src/hooks/pattern-partial-syncing.js new file mode 100644 index 0000000000000..40bd1e16dfc00 --- /dev/null +++ b/packages/editor/src/hooks/pattern-partial-syncing.js @@ -0,0 +1,73 @@ +/** + * WordPress dependencies + */ +import { addFilter } from '@wordpress/hooks'; +import { privateApis as patternsPrivateApis } from '@wordpress/patterns'; +import { createHigherOrderComponent } from '@wordpress/compose'; +import { useBlockEditingMode } from '@wordpress/block-editor'; +import { hasBlockSupport } from '@wordpress/blocks'; +import { useSelect } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { store as editorStore } from '../store'; +import { unlock } from '../lock-unlock'; + +const { + PartialSyncingControls, + PATTERN_TYPES, + PARTIAL_SYNCING_SUPPORTED_BLOCKS, +} = unlock( patternsPrivateApis ); + +/** + * Override the default edit UI to include a new block inspector control for + * assigning a partial syncing controls to supported blocks in the pattern editor. + * Currently, only the `core/paragraph` block is supported. + * + * @param {Component} BlockEdit Original component. + * + * @return {Component} Wrapped component. + */ +const withPartialSyncingControls = createHigherOrderComponent( + ( BlockEdit ) => ( props ) => { + const blockEditingMode = useBlockEditingMode(); + const hasCustomFieldsSupport = hasBlockSupport( + props.name, + '__experimentalConnections', + false + ); + const isEditingPattern = useSelect( + ( select ) => + select( editorStore ).getCurrentPostType() === + PATTERN_TYPES.user, + [] + ); + + const shouldShowPartialSyncingControls = + hasCustomFieldsSupport && + props.isSelected && + isEditingPattern && + blockEditingMode === 'default' && + Object.keys( PARTIAL_SYNCING_SUPPORTED_BLOCKS ).includes( + props.name + ); + + return ( + <> + + { shouldShowPartialSyncingControls && ( + + ) } + + ); + } +); + +if ( window.__experimentalPatternPartialSyncing ) { + addFilter( + 'editor.BlockEdit', + 'core/editor/with-partial-syncing-controls', + withPartialSyncingControls + ); +} diff --git a/packages/patterns/package.json b/packages/patterns/package.json index bab11059bf92c..783193ad2a686 100644 --- a/packages/patterns/package.json +++ b/packages/patterns/package.json @@ -44,7 +44,8 @@ "@wordpress/icons": "file:../icons", "@wordpress/notices": "file:../notices", "@wordpress/private-apis": "file:../private-apis", - "@wordpress/url": "file:../url" + "@wordpress/url": "file:../url", + "nanoid": "^3.3.4" }, "peerDependencies": { "react": "^18.0.0", diff --git a/packages/patterns/src/components/partial-syncing-controls.js b/packages/patterns/src/components/partial-syncing-controls.js new file mode 100644 index 0000000000000..42c39ce69e87b --- /dev/null +++ b/packages/patterns/src/components/partial-syncing-controls.js @@ -0,0 +1,98 @@ +/** + * External dependencies + */ +import { nanoid } from 'nanoid'; + +/** + * WordPress dependencies + */ +import { InspectorControls } from '@wordpress/block-editor'; +import { BaseControl, CheckboxControl } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { PARTIAL_SYNCING_SUPPORTED_BLOCKS } from '../constants'; + +function PartialSyncingControls( { name, attributes, setAttributes } ) { + const syncedAttributes = PARTIAL_SYNCING_SUPPORTED_BLOCKS[ name ]; + + function updateConnections( attributeName, isChecked ) { + if ( ! isChecked ) { + let updatedConnections = { + ...attributes.connections, + attributes: { + ...attributes.connections?.attributes, + [ attributeName ]: undefined, + }, + }; + if ( Object.keys( updatedConnections.attributes ).length === 1 ) { + updatedConnections.attributes = undefined; + } + if ( + Object.keys( updatedConnections ).length === 1 && + updateConnections.attributes === undefined + ) { + updatedConnections = undefined; + } + setAttributes( { + connections: updatedConnections, + } ); + return; + } + + const updatedConnections = { + ...attributes.connections, + attributes: { + ...attributes.connections?.attributes, + [ attributeName ]: { + source: 'pattern_attributes', + }, + }, + }; + + if ( typeof attributes.metadata?.id === 'string' ) { + setAttributes( { connections: updatedConnections } ); + return; + } + + const id = nanoid( 6 ); + setAttributes( { + connections: updatedConnections, + metadata: { + ...attributes.metadata, + id, + }, + } ); + } + + return ( + + + + { __( 'Synced attributes' ) } + + { Object.entries( syncedAttributes ).map( + ( [ attributeName, label ] ) => ( + { + updateConnections( attributeName, isChecked ); + } } + /> + ) + ) } + + + ); +} + +export default PartialSyncingControls; diff --git a/packages/patterns/src/constants.js b/packages/patterns/src/constants.js index 465970b17b7aa..3e533d834fd75 100644 --- a/packages/patterns/src/constants.js +++ b/packages/patterns/src/constants.js @@ -1,3 +1,8 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + export const PATTERN_TYPES = { theme: 'pattern', user: 'wp_block', @@ -14,3 +19,8 @@ export const PATTERN_SYNC_TYPES = { full: 'fully', unsynced: 'unsynced', }; + +// TODO: This should not be hardcoded. Maybe there should be a config and/or an UI. +export const PARTIAL_SYNCING_SUPPORTED_BLOCKS = { + 'core/paragraph': { content: __( 'Content' ) }, +}; diff --git a/packages/patterns/src/private-apis.js b/packages/patterns/src/private-apis.js index 770a78fd4fa9d..b357efb1bc107 100644 --- a/packages/patterns/src/private-apis.js +++ b/packages/patterns/src/private-apis.js @@ -7,12 +7,14 @@ import DuplicatePatternModal from './components/duplicate-pattern-modal'; import RenamePatternModal from './components/rename-pattern-modal'; import PatternsMenuItems from './components'; import RenamePatternCategoryModal from './components/rename-pattern-category-modal'; +import PartialSyncingControls from './components/partial-syncing-controls'; import { PATTERN_TYPES, PATTERN_DEFAULT_CATEGORY, PATTERN_USER_CATEGORY, EXCLUDED_PATTERN_SOURCES, PATTERN_SYNC_TYPES, + PARTIAL_SYNCING_SUPPORTED_BLOCKS, } from './constants'; export const privateApis = {}; @@ -22,9 +24,11 @@ lock( privateApis, { RenamePatternModal, PatternsMenuItems, RenamePatternCategoryModal, + PartialSyncingControls, PATTERN_TYPES, PATTERN_DEFAULT_CATEGORY, PATTERN_USER_CATEGORY, EXCLUDED_PATTERN_SOURCES, PATTERN_SYNC_TYPES, + PARTIAL_SYNCING_SUPPORTED_BLOCKS, } ); diff --git a/test/unit/scripts/resolver.js b/test/unit/scripts/resolver.js index 2c359145f0b3e..7672bb723e124 100644 --- a/test/unit/scripts/resolver.js +++ b/test/unit/scripts/resolver.js @@ -24,7 +24,8 @@ module.exports = ( path, options ) => { pkg.name === 'uuid' || pkg.name === 'react-colorful' || pkg.name === '@eslint/eslintrc' || - pkg.name === 'expect' + pkg.name === 'expect' || + pkg.name === 'nanoid' ) { delete pkg.exports; delete pkg.module;