diff --git a/docs/reference-guides/data/data-core-block-editor.md b/docs/reference-guides/data/data-core-block-editor.md index eaffc0a875ca3..9e9c1ef140c76 100644 --- a/docs/reference-guides/data/data-core-block-editor.md +++ b/docs/reference-guides/data/data-core-block-editor.md @@ -1263,7 +1263,7 @@ Action that hides the insertion point. ### insertAfterBlock -Action that inserts an empty block after a given block. +Action that inserts a default block after a given block. _Parameters_ @@ -1271,7 +1271,7 @@ _Parameters_ ### insertBeforeBlock -Action that inserts an empty block before a given block. +Action that inserts a default block before a given block. _Parameters_ diff --git a/lib/compat/wordpress-6.5/block-bindings/pattern-overrides.php b/lib/compat/wordpress-6.5/block-bindings/pattern-overrides.php index 76c3d49ca8085..e5f9891f04c47 100644 --- a/lib/compat/wordpress-6.5/block-bindings/pattern-overrides.php +++ b/lib/compat/wordpress-6.5/block-bindings/pattern-overrides.php @@ -15,11 +15,30 @@ * @return mixed The value computed for the source. */ function gutenberg_block_bindings_pattern_overrides_callback( $source_attrs, $block_instance, $attribute_name ) { - if ( empty( $block_instance->attributes['metadata']['id'] ) ) { + if ( ! isset( $block_instance->context['pattern/overrides'] ) ) { return null; } - $block_id = $block_instance->attributes['metadata']['id']; - return _wp_array_get( $block_instance->context, array( 'pattern/overrides', $block_id, 'values', $attribute_name ), null ); + + $override_content = $block_instance->context['pattern/overrides']; + + // Back compat. Pattern overrides previously used a metadata `id` instead of `name`. + // We check first for the name, and if it exists, use that value. + if ( isset( $block_instance->attributes['metadata']['name'] ) ) { + $metadata_name = $block_instance->attributes['metadata']['name']; + if ( array_key_exists( $metadata_name, $override_content ) ) { + return _wp_array_get( $override_content, array( $metadata_name, $attribute_name ), null ); + } + } + + // Next check for the `id`. + if ( isset( $block_instance->attributes['metadata']['id'] ) ) { + $metadata_id = $block_instance->attributes['metadata']['id']; + if ( array_key_exists( $metadata_id, $override_content ) ) { + return _wp_array_get( $override_content, array( $metadata_id, $attribute_name ), null ); + } + } + + return null; } /** diff --git a/lib/compat/wordpress-6.5/block-bindings/post-meta.php b/lib/compat/wordpress-6.5/block-bindings/post-meta.php index 5ce8eb7ac56ee..56298a7c4d4c5 100644 --- a/lib/compat/wordpress-6.5/block-bindings/post-meta.php +++ b/lib/compat/wordpress-6.5/block-bindings/post-meta.php @@ -28,6 +28,19 @@ function gutenberg_block_bindings_post_meta_callback( $source_attrs, $block_inst return null; } + // Check if the meta field is protected. + if ( is_protected_meta( $source_attrs['key'], 'post' ) ) { + return null; + } + + // Check if the meta field is registered to be shown in REST. + $meta_keys = get_registered_meta_keys( 'post', $block_instance->context['postType'] ); + // Add fields registered for all subtypes. + $meta_keys = array_merge( $meta_keys, get_registered_meta_keys( 'post', '' ) ); + if ( empty( $meta_keys[ $source_attrs['key'] ]['show_in_rest'] ) ) { + return null; + } + return get_post_meta( $post_id, $source_attrs['key'], true ); } diff --git a/lib/compat/wordpress-6.5/compat.php b/lib/compat/wordpress-6.5/compat.php index 7844792712589..39edaef83e5cc 100644 --- a/lib/compat/wordpress-6.5/compat.php +++ b/lib/compat/wordpress-6.5/compat.php @@ -36,3 +36,18 @@ function array_is_list( $arr ) { return true; } } + +/** + * Sets a global JS variable used to flag whether to direct the Site Logo block's admin urls + * to the Customizer. This allows Gutenberg running on versions of WordPress < 6.5.0 to + * support the previous location for the Site Icon settings. This function should not be + * backported to core, and should be removed when the required WP core version for Gutenberg + * is >= 6.5.0. + */ +function gutenberg_add_use_customizer_site_logo_url_flag() { + if ( ! is_wp_version_compatible( '6.5' ) ) { + wp_add_inline_script( 'wp-block-editor', 'window.__experimentalUseCustomizerSiteLogoUrl = true', 'before' ); + } +} + +add_action( 'admin_init', 'gutenberg_add_use_customizer_site_logo_url_flag' ); diff --git a/lib/compat/wordpress-6.5/fonts/class-wp-rest-font-faces-controller.php b/lib/compat/wordpress-6.5/fonts/class-wp-rest-font-faces-controller.php index 8a4040e3397e0..1d65e0f63aab9 100644 --- a/lib/compat/wordpress-6.5/fonts/class-wp-rest-font-faces-controller.php +++ b/lib/compat/wordpress-6.5/fonts/class-wp-rest-font-faces-controller.php @@ -858,7 +858,21 @@ protected function sanitize_src( $value ) { */ protected function handle_font_file_upload( $file ) { add_filter( 'upload_mimes', array( 'WP_Font_Utils', 'get_allowed_font_mime_types' ) ); - add_filter( 'upload_dir', 'wp_get_font_dir' ); + + /* + * Set the upload directory to the fonts directory. + * + * wp_get_font_dir() contains the 'font_dir' hook, whose callbacks are + * likely to call wp_get_upload_dir(). + * + * To avoid an infinite loop, don't hook wp_get_font_dir() to 'upload_dir'. + * Instead, just pass its return value to the 'upload_dir' callback. + */ + $font_dir = wp_get_font_dir(); + $set_upload_dir = function () use ( $font_dir ) { + return $font_dir; + }; + add_filter( 'upload_dir', $set_upload_dir ); $overrides = array( 'upload_error_handler' => array( $this, 'handle_font_file_upload_error' ), @@ -875,8 +889,7 @@ protected function handle_font_file_upload( $file ) { ); $uploaded_file = wp_handle_upload( $file, $overrides ); - - remove_filter( 'upload_dir', 'wp_get_font_dir' ); + remove_filter( 'upload_dir', $set_upload_dir ); remove_filter( 'upload_mimes', array( 'WP_Font_Utils', 'get_allowed_font_mime_types' ) ); return $uploaded_file; diff --git a/lib/compat/wordpress-6.5/fonts/fonts.php b/lib/compat/wordpress-6.5/fonts/fonts.php index 1f29645a9c2b9..7a040ab9d9b27 100644 --- a/lib/compat/wordpress-6.5/fonts/fonts.php +++ b/lib/compat/wordpress-6.5/fonts/fonts.php @@ -168,16 +168,6 @@ function gutenberg_register_font_collections() { * * @since 6.5.0 * - * @param array $defaults { - * Array of information about the upload directory. - * - * @type string $path Base directory and subdirectory or full path to the fonts upload directory. - * @type string $url Base URL and subdirectory or absolute URL to the fonts upload directory. - * @type string $subdir Subdirectory - * @type string $basedir Path without subdir. - * @type string $baseurl URL path without subdir. - * @type string|false $error False or error message. - * } * @return array $defaults { * Array of information about the upload directory. * @@ -189,19 +179,20 @@ function gutenberg_register_font_collections() { * @type string|false $error False or error message. * } */ - function wp_get_font_dir( $defaults = array() ) { + function wp_get_font_dir() { $site_path = ''; if ( is_multisite() && ! ( is_main_network() && is_main_site() ) ) { $site_path = '/sites/' . get_current_blog_id(); } - // Sets the defaults. - $defaults['path'] = path_join( WP_CONTENT_DIR, 'fonts' ) . $site_path; - $defaults['url'] = untrailingslashit( content_url( 'fonts' ) ) . $site_path; - $defaults['subdir'] = ''; - $defaults['basedir'] = path_join( WP_CONTENT_DIR, 'fonts' ) . $site_path; - $defaults['baseurl'] = untrailingslashit( content_url( 'fonts' ) ) . $site_path; - $defaults['error'] = false; + $defaults = array( + 'path' => path_join( WP_CONTENT_DIR, 'fonts' ) . $site_path, + 'url' => untrailingslashit( content_url( 'fonts' ) ) . $site_path, + 'subdir' => '', + 'basedir' => path_join( WP_CONTENT_DIR, 'fonts' ) . $site_path, + 'baseurl' => untrailingslashit( content_url( 'fonts' ) ) . $site_path, + 'error' => false, + ); /** * Filters the fonts directory data. diff --git a/lib/compat/wordpress-6.5/interactivity-api/interactivity-api.php b/lib/compat/wordpress-6.5/interactivity-api/interactivity-api.php index 395aece766cb6..adbfb0e1800a0 100644 --- a/lib/compat/wordpress-6.5/interactivity-api/interactivity-api.php +++ b/lib/compat/wordpress-6.5/interactivity-api/interactivity-api.php @@ -151,7 +151,7 @@ function wp_interactivity_config( string $store_namespace, array $config = array } } -if ( ! function_exists( 'data_wp_context' ) ) { +if ( ! function_exists( 'wp_interactivity_data_wp_context' ) ) { /** * Generates a `data-wp-context` directive attribute by encoding a context * array. @@ -162,7 +162,7 @@ function wp_interactivity_config( string $store_namespace, array $config = array * * Example: * - *
true, 'count' => 0 ) ); ?>> + *
true, 'count' => 0 ) ); ?>> * * @since 6.5.0 * @@ -171,7 +171,7 @@ function wp_interactivity_config( string $store_namespace, array $config = array * @return string A complete `data-wp-context` directive with a JSON encoded value representing the context array and * the store namespace if specified. */ - function data_wp_context( array $context, string $store_namespace = '' ): string { + function wp_interactivity_data_wp_context( array $context, string $store_namespace = '' ): string { return 'data-wp-context=\'' . ( $store_namespace ? $store_namespace . '::' : '' ) . ( empty( $context ) ? '{}' : wp_json_encode( $context, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP ) ) . diff --git a/package-lock.json b/package-lock.json index d73e001f35e77..9051e7300bfb2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -55333,8 +55333,7 @@ "@wordpress/icons": "file:../icons", "@wordpress/notices": "file:../notices", "@wordpress/private-apis": "file:../private-apis", - "@wordpress/url": "file:../url", - "nanoid": "^3.3.4" + "@wordpress/url": "file:../url" }, "engines": { "node": ">=16.0.0" @@ -70023,8 +70022,7 @@ "@wordpress/icons": "file:../icons", "@wordpress/notices": "file:../notices", "@wordpress/private-apis": "file:../private-apis", - "@wordpress/url": "file:../url", - "nanoid": "^3.3.4" + "@wordpress/url": "file:../url" } }, "@wordpress/plugins": { diff --git a/packages/base-styles/_default-custom-properties.scss b/packages/base-styles/_default-custom-properties.scss index 52dfeb3899d77..5760753c48ce8 100644 --- a/packages/base-styles/_default-custom-properties.scss +++ b/packages/base-styles/_default-custom-properties.scss @@ -1,4 +1,3 @@ - // It is important to include these styles in all built stylesheets. // This allows to CSS variables post CSS plugin to generate fallbacks. // It also provides default CSS variables for npm package consumers. @@ -6,4 +5,5 @@ @include admin-scheme(#007cba); --wp-block-synced-color: #7a00df; --wp-block-synced-color--rgb: #{hex-to-rgb(#7a00df)}; + --wp-bound-block-color: #9747ff; } diff --git a/packages/base-styles/_mixins.scss b/packages/base-styles/_mixins.scss index 41ef8c0e13cbc..e8a8f5959b662 100644 --- a/packages/base-styles/_mixins.scss +++ b/packages/base-styles/_mixins.scss @@ -367,6 +367,14 @@ } } +@mixin link-reset { + &:focus { + color: var(--wp-admin-theme-color--rgb); + box-shadow: 0 0 0 var(--wp-admin-border-width-focus) var(--wp-admin-theme-color, #007cba); + border-radius: $radius-block-ui; + } +} + // The editor input reset with increased specificity to avoid theme styles bleeding in. @mixin editor-input-reset() { font-family: $editor-html-font !important; diff --git a/packages/block-editor/src/components/block-actions/index.js b/packages/block-editor/src/components/block-actions/index.js index 5d942d2b25c70..760f0f4daabde 100644 --- a/packages/block-editor/src/components/block-actions/index.js +++ b/packages/block-editor/src/components/block-actions/index.js @@ -20,42 +20,55 @@ export default function BlockActions( { children, __experimentalUpdateSelection: updateSelection, } ) { - const { - canInsertBlockType, - getBlockRootClientId, - getBlocksByClientId, - canMoveBlocks, - canRemoveBlocks, - } = useSelect( blockEditorStore ); const { getDefaultBlockName, getGroupingBlockName } = useSelect( blocksStore ); - - const blocks = getBlocksByClientId( clientIds ); - const rootClientId = getBlockRootClientId( clientIds[ 0 ] ); - - const canCopyStyles = blocks.every( ( block ) => { - return ( - !! block && - ( hasBlockSupport( block.name, 'color' ) || - hasBlockSupport( block.name, 'typography' ) ) - ); - } ); - - const canDuplicate = blocks.every( ( block ) => { - return ( - !! block && - hasBlockSupport( block.name, 'multiple', true ) && - canInsertBlockType( block.name, rootClientId ) - ); - } ); - - const canInsertDefaultBlock = canInsertBlockType( - getDefaultBlockName(), - rootClientId + const selected = useSelect( + ( select ) => { + const { + canInsertBlockType, + getBlockRootClientId, + getBlocksByClientId, + getDirectInsertBlock, + canMoveBlocks, + canRemoveBlocks, + } = select( blockEditorStore ); + + const blocks = getBlocksByClientId( clientIds ); + const rootClientId = getBlockRootClientId( clientIds[ 0 ] ); + const canInsertDefaultBlock = canInsertBlockType( + getDefaultBlockName(), + rootClientId + ); + const directInsertBlock = rootClientId + ? getDirectInsertBlock( rootClientId ) + : null; + + return { + canMove: canMoveBlocks( clientIds, rootClientId ), + canRemove: canRemoveBlocks( clientIds, rootClientId ), + canInsertBlock: canInsertDefaultBlock || !! directInsertBlock, + canCopyStyles: blocks.every( ( block ) => { + return ( + !! block && + ( hasBlockSupport( block.name, 'color' ) || + hasBlockSupport( block.name, 'typography' ) ) + ); + } ), + canDuplicate: blocks.every( ( block ) => { + return ( + !! block && + hasBlockSupport( block.name, 'multiple', true ) && + canInsertBlockType( block.name, rootClientId ) + ); + } ), + }; + }, + [ clientIds, getDefaultBlockName ] ); + const { getBlocksByClientId, getBlocks } = useSelect( blockEditorStore ); - const canMove = canMoveBlocks( clientIds, rootClientId ); - const canRemove = canRemoveBlocks( clientIds, rootClientId ); + const { canMove, canRemove, canInsertBlock, canCopyStyles, canDuplicate } = + selected; const { removeBlocks, @@ -75,11 +88,9 @@ export default function BlockActions( { return children( { canCopyStyles, canDuplicate, - canInsertDefaultBlock, + canInsertBlock, canMove, canRemove, - rootClientId, - blocks, onDuplicate() { return duplicateBlocks( clientIds, updateSelection ); }, @@ -104,14 +115,17 @@ export default function BlockActions( { setBlockMovingClientId( clientIds[ 0 ] ); }, onGroup() { - if ( ! blocks.length ) { + if ( ! clientIds.length ) { return; } const groupingBlockName = getGroupingBlockName(); // Activate the `transform` on `core/group` which does the conversion. - const newBlocks = switchToBlockType( blocks, groupingBlockName ); + const newBlocks = switchToBlockType( + getBlocksByClientId( clientIds ), + groupingBlockName + ); if ( ! newBlocks ) { return; @@ -119,12 +133,11 @@ export default function BlockActions( { replaceBlocks( clientIds, newBlocks ); }, onUngroup() { - if ( ! blocks.length ) { + if ( ! clientIds.length ) { return; } - const innerBlocks = blocks[ 0 ].innerBlocks; - + const innerBlocks = getBlocks( clientIds[ 0 ] ); if ( ! innerBlocks.length ) { return; } @@ -132,16 +145,13 @@ export default function BlockActions( { replaceBlocks( clientIds, innerBlocks ); }, onCopy() { - const selectedBlockClientIds = blocks.map( - ( { clientId } ) => clientId - ); - if ( blocks.length === 1 ) { - flashBlock( selectedBlockClientIds[ 0 ] ); + if ( clientIds.length === 1 ) { + flashBlock( clientIds[ 0 ] ); } - notifyCopy( 'copy', selectedBlockClientIds ); + notifyCopy( 'copy', clientIds ); }, async onPasteStyles() { - await pasteStyles( blocks ); + await pasteStyles( getBlocksByClientId( clientIds ) ); }, } ); } diff --git a/packages/block-editor/src/components/block-bindings-toolbar-indicator/index.js b/packages/block-editor/src/components/block-bindings-toolbar-indicator/index.js new file mode 100644 index 0000000000000..4b2d3df725a66 --- /dev/null +++ b/packages/block-editor/src/components/block-bindings-toolbar-indicator/index.js @@ -0,0 +1,20 @@ +/** + * WordPress dependencies + */ +import { ToolbarItem, ToolbarGroup, Icon } from '@wordpress/components'; +import { connection } from '@wordpress/icons'; +import { _x } from '@wordpress/i18n'; + +export default function BlockBindingsToolbarIndicator() { + return ( + + + + + + ); +} diff --git a/packages/block-editor/src/components/block-bindings-toolbar-indicator/style.scss b/packages/block-editor/src/components/block-bindings-toolbar-indicator/style.scss new file mode 100644 index 0000000000000..4aeabdf8acf6e --- /dev/null +++ b/packages/block-editor/src/components/block-bindings-toolbar-indicator/style.scss @@ -0,0 +1,14 @@ +.block-editor-block-bindings-toolbar-indicator { + display: inline-flex; + align-items: center; + height: 48px; + padding: 6px; + + svg g { + stroke: var(--wp-bound-block-color); + fill: transparent; + stroke-width: 1.5; + stroke-linecap: round; + stroke-linejoin: round; + } +} diff --git a/packages/block-editor/src/components/block-list/use-block-props/index.js b/packages/block-editor/src/components/block-list/use-block-props/index.js index 08b43fa46257e..c929c1014dc03 100644 --- a/packages/block-editor/src/components/block-list/use-block-props/index.js +++ b/packages/block-editor/src/components/block-list/use-block-props/index.js @@ -19,13 +19,17 @@ import useMovingAnimation from '../../use-moving-animation'; import { PrivateBlockContext } from '../private-block-context'; import { useFocusFirstElement } from './use-focus-first-element'; import { useIsHovered } from './use-is-hovered'; -import { useBlockEditContext } from '../../block-edit/context'; +import { + blockBindingsKey, + useBlockEditContext, +} from '../../block-edit/context'; import { useFocusHandler } from './use-focus-handler'; import { useEventHandlers } from './use-selected-block-event-handlers'; import { useNavModeExit } from './use-nav-mode-exit'; import { useBlockRefProvider } from './use-block-refs'; import { useIntersectionObserver } from './use-intersection-observer'; import { useFlashEditableBlocks } from '../../use-flash-editable-blocks'; +import { canBindBlock } from '../../../hooks/use-bindings-attributes'; /** * This hook is used to lightly mark an element as a block element. The element @@ -123,6 +127,12 @@ export function useBlockProps( props = {}, { __unstableIsHtml } = {} ) { ] ); const blockEditContext = useBlockEditContext(); + const hasBlockBindings = !! blockEditContext[ blockBindingsKey ]; + const bindingsStyle = + hasBlockBindings && canBindBlock( name ) + ? { '--wp-admin-theme-color': 'var(--wp-bound-block-color)' } + : {}; + // Ensures it warns only inside the `edit` implementation for the block. if ( blockApiVersion < 2 && clientId === blockEditContext.clientId ) { warning( @@ -168,7 +178,7 @@ export function useBlockProps( props = {}, { __unstableIsHtml } = {} ) { wrapperProps.className, defaultClassName ), - style: { ...wrapperProps.style, ...props.style }, + style: { ...wrapperProps.style, ...props.style, ...bindingsStyle }, }; } diff --git a/packages/block-editor/src/components/block-list/use-block-props/use-selected-block-event-handlers.js b/packages/block-editor/src/components/block-list/use-block-props/use-selected-block-event-handlers.js index bf4fc55879448..01cc462e507ec 100644 --- a/packages/block-editor/src/components/block-list/use-block-props/use-selected-block-event-handlers.js +++ b/packages/block-editor/src/components/block-list/use-block-props/use-selected-block-event-handlers.js @@ -22,7 +22,7 @@ import { store as blockEditorStore } from '../../../store'; export function useEventHandlers( { clientId, isSelected } ) { const { getBlockRootClientId, getBlockIndex } = useSelect( blockEditorStore ); - const { insertDefaultBlock, removeBlock } = useDispatch( blockEditorStore ); + const { insertAfterBlock, removeBlock } = useDispatch( blockEditorStore ); return useRefEffect( ( node ) => { @@ -57,11 +57,7 @@ export function useEventHandlers( { clientId, isSelected } ) { event.preventDefault(); if ( keyCode === ENTER ) { - insertDefaultBlock( - {}, - getBlockRootClientId( clientId ), - getBlockIndex( clientId ) + 1 - ); + insertAfterBlock( clientId ); } else { removeBlock( clientId ); } @@ -90,7 +86,7 @@ export function useEventHandlers( { clientId, isSelected } ) { isSelected, getBlockRootClientId, getBlockIndex, - insertDefaultBlock, + insertAfterBlock, removeBlock, ] ); diff --git a/packages/block-editor/src/components/block-settings-menu/block-settings-dropdown.js b/packages/block-editor/src/components/block-settings-menu/block-settings-dropdown.js index 657d685b966e0..aad2ca7bc6d47 100644 --- a/packages/block-editor/src/components/block-settings-menu/block-settings-dropdown.js +++ b/packages/block-editor/src/components/block-settings-menu/block-settings-dropdown.js @@ -39,8 +39,12 @@ const POPOVER_PROPS = { placement: 'bottom-start', }; -function CopyMenuItem( { blocks, onCopy, label } ) { - const ref = useCopyToClipboard( () => serialize( blocks ), onCopy ); +function CopyMenuItem( { clientIds, onCopy, label } ) { + const { getBlocksByClientId } = useSelect( blockEditorStore ); + const ref = useCopyToClipboard( + () => serialize( getBlocksByClientId( clientIds ) ), + onCopy + ); const copyMenuItemLabel = label ? label : __( 'Copy' ); return { copyMenuItemLabel }; } @@ -239,7 +243,7 @@ export function BlockSettingsDropdown( { { ( { canCopyStyles, canDuplicate, - canInsertDefaultBlock, + canInsertBlock, canMove, canRemove, onDuplicate, @@ -249,7 +253,6 @@ export function BlockSettingsDropdown( { onCopy, onPasteStyles, onMoveTo, - blocks, } ) => ( ) } { canDuplicate && ( @@ -342,7 +345,7 @@ export function BlockSettingsDropdown( { { __( 'Duplicate' ) } ) } - { canInsertDefaultBlock && ( + { canInsertBlock && ( <> diff --git a/packages/block-editor/src/components/block-toolbar/index.js b/packages/block-editor/src/components/block-toolbar/index.js index 0d9b61314c4ed..a64a09d7f871f 100644 --- a/packages/block-editor/src/components/block-toolbar/index.js +++ b/packages/block-editor/src/components/block-toolbar/index.js @@ -35,6 +35,8 @@ import { store as blockEditorStore } from '../../store'; import __unstableBlockNameContext from './block-name-context'; import NavigableToolbar from '../navigable-toolbar'; import { useHasAnyBlockControls } from '../block-controls/use-has-block-controls'; +import BlockBindingsIndicator from '../block-bindings-toolbar-indicator'; +import { canBindBlock } from '../../hooks/use-bindings-attributes'; /** * Renders the block toolbar. @@ -60,8 +62,10 @@ export function PrivateBlockToolbar( { blockClientIds, isDefaultEditingMode, blockType, + blockName, shouldShowVisualToolbar, showParentSelector, + isUsingBindings, } = useSelect( ( select ) => { const { getBlockName, @@ -71,6 +75,7 @@ export function PrivateBlockToolbar( { isBlockValid, getBlockRootClientId, getBlockEditingMode, + getBlockAttributes, } = select( blockEditorStore ); const selectedBlockClientIds = getSelectedBlockClientIds(); const selectedBlockClientId = selectedBlockClientIds[ 0 ]; @@ -81,20 +86,21 @@ export function PrivateBlockToolbar( { const parentBlockType = getBlockType( parentBlockName ); const _isDefaultEditingMode = getBlockEditingMode( selectedBlockClientId ) === 'default'; + const _blockName = getBlockName( selectedBlockClientId ); const isValid = selectedBlockClientIds.every( ( id ) => isBlockValid( id ) ); const isVisual = selectedBlockClientIds.every( ( id ) => getBlockMode( id ) === 'visual' ); + const _isUsingBindings = !! getBlockAttributes( selectedBlockClientId ) + ?.metadata?.bindings; return { blockClientId: selectedBlockClientId, blockClientIds: selectedBlockClientIds, isDefaultEditingMode: _isDefaultEditingMode, - blockType: - selectedBlockClientId && - getBlockType( getBlockName( selectedBlockClientId ) ), - + blockName: _blockName, + blockType: selectedBlockClientId && getBlockType( _blockName ), shouldShowVisualToolbar: isValid && isVisual, rootClientId: blockRootClientId, showParentSelector: @@ -107,6 +113,7 @@ export function PrivateBlockToolbar( { ) && selectedBlockClientIds.length === 1 && _isDefaultEditingMode, + isUsingBindings: _isUsingBindings, }; }, [] ); @@ -165,6 +172,9 @@ export function PrivateBlockToolbar( { { ! isMultiToolbar && isLargeViewport && isDefaultEditingMode && } + { isUsingBindings && canBindBlock( blockName ) && ( + + ) } { ( shouldShowVisualToolbar || isMultiToolbar ) && isDefaultEditingMode && (
) } + { isConnected && canBindBlock( blockName ) && ( + + + + ) } { positionLabel && isSticky && ( diff --git a/packages/block-editor/src/components/list-view/style.scss b/packages/block-editor/src/components/list-view/style.scss index 11cf1fafa0e14..1245bfbabcb7a 100644 --- a/packages/block-editor/src/components/list-view/style.scss +++ b/packages/block-editor/src/components/list-view/style.scss @@ -557,3 +557,11 @@ $block-navigation-max-indent: 8; .list-view-appender__description { display: none; } + +.block-editor-list-view-block-select-button__bindings svg g { + stroke: var(--wp-bound-block-color); + fill: transparent; + stroke-width: 1.5; + stroke-linecap: round; + stroke-linejoin: round; +} diff --git a/packages/block-editor/src/components/list-view/use-clipboard-handler.js b/packages/block-editor/src/components/list-view/use-clipboard-handler.js index cd25c71e9bf7c..dd3ac65ac79d2 100644 --- a/packages/block-editor/src/components/list-view/use-clipboard-handler.js +++ b/packages/block-editor/src/components/list-view/use-clipboard-handler.js @@ -1,7 +1,7 @@ /** * WordPress dependencies */ -import { useDispatch, useSelect } from '@wordpress/data'; +import { useDispatch, useRegistry, useSelect } from '@wordpress/data'; import { useRefEffect } from '@wordpress/compose'; /** @@ -15,6 +15,7 @@ import { getPasteBlocks, setClipboardBlocks } from '../writing-flow/utils'; // This hook borrows from useClipboardHandler in ../writing-flow/use-clipboard-handler.js // and adds behaviour for the list view, while skipping partial selection. export default function useClipboardHandler( { selectBlock } ) { + const registry = useRegistry(); const { getBlockOrder, getBlockRootClientId, @@ -106,7 +107,7 @@ export default function useClipboardHandler( { selectBlock } ) { notifyCopy( event.type, selectedBlockClientIds ); const blocks = getBlocksByClientId( selectedBlockClientIds ); - setClipboardBlocks( event, blocks ); + setClipboardBlocks( event, blocks, registry ); } if ( event.type === 'cut' ) { diff --git a/packages/block-editor/src/components/rich-text/index.js b/packages/block-editor/src/components/rich-text/index.js index aa870dee07397..3f896702d4d17 100644 --- a/packages/block-editor/src/components/rich-text/index.js +++ b/packages/block-editor/src/components/rich-text/index.js @@ -47,7 +47,7 @@ import { getAllowedFormats } from './utils'; import { Content } from './content'; import { withDeprecations } from './with-deprecations'; import { unlock } from '../../lock-unlock'; -import { BLOCK_BINDINGS_ALLOWED_BLOCKS } from '../../hooks/use-bindings-attributes'; +import { canBindBlock } from '../../hooks/use-bindings-attributes'; export const keyboardShortcutContext = createContext(); export const inputEventContext = createContext(); @@ -161,7 +161,7 @@ export function RichTextWrapper( ( select ) => { // Disable Rich Text editing if block bindings specify that. let _disableBoundBlocks = false; - if ( blockBindings && blockName in BLOCK_BINDINGS_ALLOWED_BLOCKS ) { + if ( blockBindings && canBindBlock( blockName ) ) { const blockTypeAttributes = getBlockType( blockName ).attributes; const { getBlockBindingsSource } = unlock( diff --git a/packages/block-editor/src/components/url-popover/index.js b/packages/block-editor/src/components/url-popover/index.js index b5bbe8f50958b..d060a464cc306 100644 --- a/packages/block-editor/src/components/url-popover/index.js +++ b/packages/block-editor/src/components/url-popover/index.js @@ -92,12 +92,12 @@ const URLPopover = forwardRef( /> ) }
- { showSettings && ( -
- { renderSettings() } -
- ) }
+ { showSettings && ( +
+ { renderSettings() } +
+ ) } { additionalControls && ! showSettings && (
{ additionalControls } diff --git a/packages/block-editor/src/components/writing-flow/use-clipboard-handler.js b/packages/block-editor/src/components/writing-flow/use-clipboard-handler.js index 8528655c1dcc9..43e887888dbd1 100644 --- a/packages/block-editor/src/components/writing-flow/use-clipboard-handler.js +++ b/packages/block-editor/src/components/writing-flow/use-clipboard-handler.js @@ -5,7 +5,7 @@ import { documentHasSelection, documentHasUncollapsedSelection, } from '@wordpress/dom'; -import { useDispatch, useSelect } from '@wordpress/data'; +import { useDispatch, useRegistry, useSelect } from '@wordpress/data'; import { useRefEffect } from '@wordpress/compose'; /** @@ -16,6 +16,7 @@ import { useNotifyCopy } from '../../utils/use-notify-copy'; import { getPasteBlocks, setClipboardBlocks } from './utils'; export default function useClipboardHandler() { + const registry = useRegistry(); const { getBlocksByClientId, getSelectedBlockClientIds, @@ -104,7 +105,7 @@ export default function useClipboardHandler() { blocks = [ head, ...inBetweenBlocks, tail ]; } - setClipboardBlocks( event, blocks ); + setClipboardBlocks( event, blocks, registry ); } } diff --git a/packages/block-editor/src/components/writing-flow/utils.js b/packages/block-editor/src/components/writing-flow/utils.js index ef1827077ccbf..2a2010854ed20 100644 --- a/packages/block-editor/src/components/writing-flow/utils.js +++ b/packages/block-editor/src/components/writing-flow/utils.js @@ -8,36 +8,51 @@ import { pasteHandler, findTransform, getBlockTransforms, + store as blocksStore, } from '@wordpress/blocks'; /** * Internal dependencies */ import { getPasteEventData } from '../../utils/pasting'; +import { store as blockEditorStore } from '../../store'; + +export const requiresWrapperOnCopy = Symbol( 'requiresWrapperOnCopy' ); /** * Sets the clipboard data for the provided blocks, with both HTML and plain * text representations. * - * @param {ClipboardEvent} event Clipboard event. - * @param {WPBlock[]} blocks Blocks to set as clipboard data. + * @param {ClipboardEvent} event Clipboard event. + * @param {WPBlock[]} blocks Blocks to set as clipboard data. + * @param {Object} registry The registry to select from. */ -export function setClipboardBlocks( event, blocks ) { +export function setClipboardBlocks( event, blocks, registry ) { let _blocks = blocks; - const wrapperBlockName = event.clipboardData.getData( - '__unstableWrapperBlockName' - ); - if ( wrapperBlockName ) { - _blocks = createBlock( - wrapperBlockName, - JSON.parse( - event.clipboardData.getData( - '__unstableWrapperBlockAttributes' - ) - ), - _blocks - ); + const [ firstBlock ] = blocks; + + if ( firstBlock ) { + const firstBlockType = registry + .select( blocksStore ) + .getBlockType( firstBlock.name ); + + if ( firstBlockType[ requiresWrapperOnCopy ] ) { + const { getBlockRootClientId, getBlockName, getBlockAttributes } = + registry.select( blockEditorStore ); + const wrapperBlockClientId = getBlockRootClientId( + firstBlock.clientId + ); + const wrapperBlockName = getBlockName( wrapperBlockClientId ); + + if ( wrapperBlockName ) { + _blocks = createBlock( + wrapperBlockName, + getBlockAttributes( wrapperBlockClientId ), + _blocks + ); + } + } } const serialized = serialize( _blocks ); diff --git a/packages/block-editor/src/hooks/block-hooks.js b/packages/block-editor/src/hooks/block-hooks.js index eb84352ab62f0..93bf87f42124b 100644 --- a/packages/block-editor/src/hooks/block-hooks.js +++ b/packages/block-editor/src/hooks/block-hooks.js @@ -19,18 +19,28 @@ import { store as blockEditorStore } from '../store'; const EMPTY_OBJECT = {}; -function BlockHooksControlPure( { name, clientId } ) { +function BlockHooksControlPure( { + name, + clientId, + metadata: { ignoredHookedBlocks = [] } = {}, +} ) { const blockTypes = useSelect( ( select ) => select( blocksStore ).getBlockTypes(), [] ); + // A hooked block added via a filter will not be exposed through a block + // type's `blockHooks` property; however, if the containing layout has been + // modified, it will be present in the anchor block's `ignoredHookedBlocks` + // metadata. const hookedBlocksForCurrentBlock = useMemo( () => blockTypes?.filter( - ( { blockHooks } ) => blockHooks && name in blockHooks + ( { name: blockName, blockHooks } ) => + ( blockHooks && name in blockHooks ) || + ignoredHookedBlocks.includes( blockName ) ), - [ blockTypes, name ] + [ blockTypes, name, ignoredHookedBlocks ] ); const { blockIndex, rootClientId, innerBlocksLength } = useSelect( @@ -79,6 +89,16 @@ function BlockHooksControlPure( { name, clientId } ) { // inserted and then moved around a bit by the user. candidates = getBlocks( clientId ); break; + + case undefined: + // If we haven't found a blockHooks field with a relative position for the hooked + // block, it means that it was added by a filter. In this case, we look for the block + // both among the current block's siblings and its children. + candidates = [ + ...getBlocks( rootClientId ), + ...getBlocks( clientId ), + ]; + break; } const hookedBlock = candidates?.find( @@ -151,6 +171,18 @@ function BlockHooksControlPure( { name, clientId } ) { false ); break; + + case undefined: + // If we do not know the relative position, it is because the block was + // added via a filter. In this case, we default to inserting it after the + // current block. + insertBlock( + block, + blockIndex + 1, + rootClientId, // Insert as a child of the current block's parent + false + ); + break; } }; @@ -219,6 +251,7 @@ function BlockHooksControlPure( { name, clientId } ) { export default { edit: BlockHooksControlPure, + attributeKeys: [ 'metadata' ], hasSupport() { return true; }, diff --git a/packages/block-editor/src/hooks/use-bindings-attributes.js b/packages/block-editor/src/hooks/use-bindings-attributes.js index 0e5b6614f07cb..5cd8cb46b3b7e 100644 --- a/packages/block-editor/src/hooks/use-bindings-attributes.js +++ b/packages/block-editor/src/hooks/use-bindings-attributes.js @@ -4,12 +4,13 @@ import { getBlockType, store as blocksStore } from '@wordpress/blocks'; import { createHigherOrderComponent } from '@wordpress/compose'; import { useSelect } from '@wordpress/data'; +import { useLayoutEffect, useCallback, useState } from '@wordpress/element'; import { addFilter } from '@wordpress/hooks'; +import { RichTextData } from '@wordpress/rich-text'; + /** * Internal dependencies */ -import { store as blockEditorStore } from '../store'; -import { useBlockEditContext } from '../components/block-edit/context'; import { unlock } from '../lock-unlock'; /** @typedef {import('@wordpress/compose').WPHigherOrderComponent} WPHigherOrderComponent */ @@ -22,87 +23,238 @@ import { unlock } from '../lock-unlock'; * @return {WPHigherOrderComponent} Higher-order component. */ -export const BLOCK_BINDINGS_ALLOWED_BLOCKS = { +const BLOCK_BINDINGS_ALLOWED_BLOCKS = { 'core/paragraph': [ 'content' ], 'core/heading': [ 'content' ], 'core/image': [ 'url', 'title', 'alt' ], 'core/button': [ 'url', 'text', 'linkTarget' ], }; -const createEditFunctionWithBindingsAttribute = () => - createHigherOrderComponent( - ( BlockEdit ) => ( props ) => { - const { clientId, name: blockName } = useBlockEditContext(); - const blockBindingsSources = unlock( - useSelect( blocksStore ) - ).getAllBlockBindingsSources(); - const { getBlockAttributes } = useSelect( blockEditorStore ); - - const updatedAttributes = getBlockAttributes( clientId ); - if ( updatedAttributes?.metadata?.bindings ) { - Object.entries( updatedAttributes.metadata.bindings ).forEach( - ( [ attributeName, settings ] ) => { - const source = blockBindingsSources[ settings.source ]; - - if ( source && source.useSource ) { - // Second argument (`updateMetaValue`) will be used to update the value in the future. - const { - placeholder, - useValue: [ metaValue = null ] = [], - } = source.useSource( props, settings.args ); - - if ( placeholder && ! metaValue ) { - // If the attribute is `src` or `href`, a placeholder can't be used because it is not a valid url. - // Adding this workaround until attributes and metadata fields types are improved and include `url`. - const htmlAttribute = - getBlockType( blockName ).attributes[ - attributeName - ].attribute; - if ( - htmlAttribute === 'src' || - htmlAttribute === 'href' - ) { - updatedAttributes[ attributeName ] = null; - } else { - updatedAttributes[ attributeName ] = - placeholder; - } - } - - if ( metaValue ) { - updatedAttributes[ attributeName ] = metaValue; - } - } - } - ); +/** + * Based on the given block name, + * check if it is possible to bind the block. + * + * @param {string} blockName - The block name. + * @return {boolean} Whether it is possible to bind the block to sources. + */ +export function canBindBlock( blockName ) { + return blockName in BLOCK_BINDINGS_ALLOWED_BLOCKS; +} + +/** + * Based on the given block name and attribute name, + * check if it is possible to bind the block attribute. + * + * @param {string} blockName - The block name. + * @param {string} attributeName - The attribute name. + * @return {boolean} Whether it is possible to bind the block attribute. + */ +export function canBindAttribute( blockName, attributeName ) { + return ( + canBindBlock( blockName ) && + BLOCK_BINDINGS_ALLOWED_BLOCKS[ blockName ].includes( attributeName ) + ); +} + +/** + * This component is responsible for detecting and + * propagating data changes from the source to the block. + * + * @param {Object} props - The component props. + * @param {string} props.attrName - The attribute name. + * @param {Object} props.blockProps - The block props with bound attribute. + * @param {Object} props.source - Source handler. + * @param {Object} props.args - The arguments to pass to the source. + * @param {Function} props.onPropValueChange - The function to call when the attribute value changes. + * @return {null} Data-handling component. Render nothing. + */ +const BindingConnector = ( { + args, + attrName, + blockProps, + source, + onPropValueChange, +} ) => { + const { placeholder, value: propValue } = source.useSource( + blockProps, + args + ); + + const { name: blockName } = blockProps; + const attrValue = blockProps.attributes[ attrName ]; + + const updateBoundAttibute = useCallback( + ( newAttrValue, prevAttrValue ) => { + /* + * If the attribute is a RichTextData instance, + * (core/paragraph, core/heading, core/button, etc.) + * compare its HTML representation with the new value. + * + * To do: it looks like a workaround. + * Consider improving the attribute and metadata fields types. + */ + if ( prevAttrValue instanceof RichTextData ) { + // Bail early if the Rich Text value is the same. + if ( prevAttrValue.toHTMLString() === newAttrValue ) { + return; + } + + /* + * To preserve the value type, + * convert the new value to a RichTextData instance. + */ + newAttrValue = RichTextData.fromHTMLString( newAttrValue ); + } + + if ( prevAttrValue === newAttrValue ) { + return; } - return ( + onPropValueChange( { [ attrName ]: newAttrValue } ); + }, + [ attrName, onPropValueChange ] + ); + + useLayoutEffect( () => { + if ( typeof propValue !== 'undefined' ) { + updateBoundAttibute( propValue, attrValue ); + } else if ( placeholder ) { + /* + * Placeholder fallback. + * If the attribute is `src` or `href`, + * a placeholder can't be used because it is not a valid url. + * Adding this workaround until + * attributes and metadata fields types are improved and include `url`. + */ + const htmlAttribute = + getBlockType( blockName ).attributes[ attrName ].attribute; + + if ( htmlAttribute === 'src' || htmlAttribute === 'href' ) { + updateBoundAttibute( null ); + return; + } + + updateBoundAttibute( placeholder ); + } + }, [ + updateBoundAttibute, + propValue, + attrValue, + placeholder, + blockName, + attrName, + ] ); + + return null; +}; + +/** + * BlockBindingBridge acts like a component wrapper + * that connects the bound attributes of a block + * to the source handlers. + * For this, it creates a BindingConnector for each bound attribute. + * + * @param {Object} props - The component props. + * @param {Object} props.blockProps - The BlockEdit props object. + * @param {Object} props.bindings - The block bindings settings. + * @param {Function} props.onPropValueChange - The function to call when the attribute value changes. + * @return {null} Data-handling component. Render nothing. + */ +function BlockBindingBridge( { blockProps, bindings, onPropValueChange } ) { + const blockBindingsSources = unlock( + useSelect( blocksStore ) + ).getAllBlockBindingsSources(); + + return ( + <> + { Object.entries( bindings ).map( + ( [ attrName, boundAttribute ] ) => { + // Bail early if the block doesn't have a valid source handler. + const source = + blockBindingsSources[ boundAttribute.source ]; + if ( ! source?.useSource ) { + return null; + } + + return ( + + ); + } + ) } + + ); +} + +const withBlockBindingSupport = createHigherOrderComponent( + ( BlockEdit ) => ( props ) => { + /* + * Collect and update the bound attributes + * in a separate state. + */ + const [ boundAttributes, setBoundAttributes ] = useState( {} ); + const updateBoundAttributes = useCallback( + ( newAttributes ) => + setBoundAttributes( ( prev ) => ( { + ...prev, + ...newAttributes, + } ) ), + [] + ); + + /* + * Create binding object filtering + * only the attributes that can be bound. + */ + const bindings = Object.fromEntries( + Object.entries( props.attributes.metadata?.bindings || {} ).filter( + ( [ attrName ] ) => canBindAttribute( props.name, attrName ) + ) + ); + + return ( + <> + { Object.keys( bindings ).length > 0 && ( + + ) } + - ); - }, - 'useBoundAttributes' - ); + + ); + }, + 'withBlockBindingSupport' +); /** * Filters a registered block's settings to enhance a block's `edit` component * to upgrade bound attributes. * - * @param {WPBlockSettings} settings Registered block settings. - * + * @param {WPBlockSettings} settings - Registered block settings. + * @param {string} name - Block name. * @return {WPBlockSettings} Filtered block settings. */ -function shimAttributeSource( settings ) { - if ( ! ( settings.name in BLOCK_BINDINGS_ALLOWED_BLOCKS ) ) { +function shimAttributeSource( settings, name ) { + if ( ! canBindBlock( name ) ) { return settings; } - settings.edit = createEditFunctionWithBindingsAttribute()( settings.edit ); - return settings; + return { + ...settings, + edit: withBlockBindingSupport( settings.edit ), + }; } addFilter( diff --git a/packages/block-editor/src/private-apis.js b/packages/block-editor/src/private-apis.js index ec6843ead2489..6862d2a542457 100644 --- a/packages/block-editor/src/private-apis.js +++ b/packages/block-editor/src/private-apis.js @@ -27,6 +27,7 @@ import { ExperimentalBlockCanvas } from './components/block-canvas'; import { getDuotoneFilter } from './components/duotone/utils'; import { useFlashEditableBlocks } from './components/use-flash-editable-blocks'; import { selectBlockPatternsKey } from './store/private-keys'; +import { requiresWrapperOnCopy } from './components/writing-flow/utils'; import { PrivateRichText } from './components/rich-text/'; /** @@ -59,5 +60,6 @@ lock( privateApis, { usesContextKey, useFlashEditableBlocks, selectBlockPatternsKey, + requiresWrapperOnCopy, PrivateRichText, } ); diff --git a/packages/block-editor/src/store/actions.js b/packages/block-editor/src/store/actions.js index 6adbafe28341c..4edf075e712ac 100644 --- a/packages/block-editor/src/store/actions.js +++ b/packages/block-editor/src/store/actions.js @@ -1575,7 +1575,7 @@ export const duplicateBlocks = }; /** - * Action that inserts an empty block before a given block. + * Action that inserts a default block before a given block. * * @param {string} clientId */ @@ -1591,16 +1591,34 @@ export const insertBeforeBlock = return; } - const firstSelectedIndex = select.getBlockIndex( clientId ); - return dispatch.insertDefaultBlock( - {}, - rootClientId, - firstSelectedIndex - ); + const blockIndex = select.getBlockIndex( clientId ); + const directInsertBlock = rootClientId + ? select.getDirectInsertBlock( rootClientId ) + : null; + + if ( ! directInsertBlock ) { + return dispatch.insertDefaultBlock( {}, rootClientId, blockIndex ); + } + + const copiedAttributes = {}; + if ( directInsertBlock.attributesToCopy ) { + const attributes = select.getBlockAttributes( clientId ); + directInsertBlock.attributesToCopy.forEach( ( key ) => { + if ( attributes[ key ] ) { + copiedAttributes[ key ] = attributes[ key ]; + } + } ); + } + + const block = createBlock( directInsertBlock.name, { + ...directInsertBlock.attributes, + ...copiedAttributes, + } ); + return dispatch.insertBlock( block, blockIndex, rootClientId ); }; /** - * Action that inserts an empty block after a given block. + * Action that inserts a default block after a given block. * * @param {string} clientId */ @@ -1616,12 +1634,34 @@ export const insertAfterBlock = return; } - const firstSelectedIndex = select.getBlockIndex( clientId ); - return dispatch.insertDefaultBlock( - {}, - rootClientId, - firstSelectedIndex + 1 - ); + const blockIndex = select.getBlockIndex( clientId ); + const directInsertBlock = rootClientId + ? select.getDirectInsertBlock( rootClientId ) + : null; + + if ( ! directInsertBlock ) { + return dispatch.insertDefaultBlock( + {}, + rootClientId, + blockIndex + 1 + ); + } + + const copiedAttributes = {}; + if ( directInsertBlock.attributesToCopy ) { + const attributes = select.getBlockAttributes( clientId ); + directInsertBlock.attributesToCopy.forEach( ( key ) => { + if ( attributes[ key ] ) { + copiedAttributes[ key ] = attributes[ key ]; + } + } ); + } + + const block = createBlock( directInsertBlock.name, { + ...directInsertBlock.attributes, + ...copiedAttributes, + } ); + return dispatch.insertBlock( block, blockIndex + 1, rootClientId ); }; /** diff --git a/packages/block-editor/src/style.scss b/packages/block-editor/src/style.scss index 43fb047710b8d..bb10ace5ba5b4 100644 --- a/packages/block-editor/src/style.scss +++ b/packages/block-editor/src/style.scss @@ -1,5 +1,6 @@ @import "./autocompleters/style.scss"; @import "./components/block-alignment-control/style.scss"; +@import "./components/block-bindings-toolbar-indicator/style.scss"; @import "./components/block-canvas/style.scss"; @import "./components/block-icon/style.scss"; @import "./components/block-inspector/style.scss"; diff --git a/packages/block-library/src/block/deprecated.js b/packages/block-library/src/block/deprecated.js index 7bc243bbf4ce9..f820867fff627 100644 --- a/packages/block-library/src/block/deprecated.js +++ b/packages/block-library/src/block/deprecated.js @@ -1,4 +1,75 @@ -// v1: Migrate and rename the `overrides` attribute to the `content` attribute. +const isObject = ( obj ) => + typeof obj === 'object' && ! Array.isArray( obj ) && obj !== null; + +// v2: Migrate to a more condensed version of the 'content' attribute attribute. +const v2 = { + attributes: { + ref: { + type: 'number', + }, + content: { + type: 'object', + }, + }, + supports: { + customClassName: false, + html: false, + inserter: false, + renaming: false, + }, + // Force this deprecation to run whenever there's a values sub-property that's an object. + // + // This could fail in the future if a block ever has binding to a `values` attribute. + // Some extra protection is added to ensure `values` is an object, but this only reduces + // the likelihood, it doesn't solve it completely. + isEligible( { content } ) { + return ( + !! content && + Object.keys( content ).every( + ( contentKey ) => + content[ contentKey ].values && + isObject( content[ contentKey ].values ) + ) + ); + }, + /* + * Old attribute format: + * content: { + * "V98q_x": { + * // The attribute values are now stored as a 'values' sub-property. + * values: { content: 'My content value' }, + * // ... additional metadata, like the block name can be stored here. + * } + * } + * + * New attribute format: + * content: { + * "V98q_x": { + * content: 'My content value', + * } + * } + */ + migrate( attributes ) { + const { content, ...retainedAttributes } = attributes; + + if ( content && Object.keys( content ).length ) { + const updatedContent = { ...content }; + + for ( const contentKey in content ) { + updatedContent[ contentKey ] = content[ contentKey ].values; + } + + return { + ...retainedAttributes, + content: updatedContent, + }; + } + + return attributes; + }, +}; + +// v1: Rename the `overrides` attribute to the `content` attribute. const v1 = { attributes: { ref: { @@ -23,16 +94,12 @@ const v1 = { * overrides: { * // An key is an id that represents a block. * // The values are the attribute values of the block. - * "V98q_x": { content: 'dwefwefwefwe' } + * "V98q_x": { content: 'My content value' } * } * * New attribute format: * content: { - * "V98q_x": { - * // The attribute values are now stored as a 'values' sub-property. - * values: { content: 'dwefwefwefwe' }, - * // ... additional metadata, like the block name can be stored here. - * } + * "V98q_x": { content: 'My content value' } * } * */ @@ -42,9 +109,7 @@ const v1 = { const content = {}; Object.keys( overrides ).forEach( ( id ) => { - content[ id ] = { - values: overrides[ id ], - }; + content[ id ] = overrides[ id ]; } ); return { @@ -54,4 +119,4 @@ const v1 = { }, }; -export default [ v1 ]; +export default [ v2, v1 ]; diff --git a/packages/block-library/src/block/edit.js b/packages/block-library/src/block/edit.js index 5efe245c935fc..ddacc47dbd039 100644 --- a/packages/block-library/src/block/edit.js +++ b/packages/block-library/src/block/edit.js @@ -42,6 +42,25 @@ const { PARTIAL_SYNCING_SUPPORTED_BLOCKS } = unlock( patternsPrivateApis ); const fullAlignments = [ 'full', 'wide', 'left', 'right' ]; +function getLegacyIdMap( blocks, content, nameCount = {} ) { + let idToClientIdMap = {}; + for ( const block of blocks ) { + if ( block?.innerBlocks?.length ) { + idToClientIdMap = { + ...idToClientIdMap, + ...getLegacyIdMap( block.innerBlocks, content, nameCount ), + }; + } + + const id = block.attributes.metadata?.id; + const clientId = block.clientId; + if ( id && content?.[ id ] ) { + idToClientIdMap[ clientId ] = id; + } + } + return idToClientIdMap; +} + const useInferredLayout = ( blocks, parentLayout ) => { const initialInferredAlignmentRef = useRef(); @@ -101,25 +120,31 @@ function getOverridableAttributes( block ) { function applyInitialContentValuesToInnerBlocks( blocks, content = {}, - defaultValues + defaultValues, + legacyIdMap ) { return blocks.map( ( block ) => { const innerBlocks = applyInitialContentValuesToInnerBlocks( block.innerBlocks, content, - defaultValues + defaultValues, + legacyIdMap ); - const blockId = block.attributes.metadata?.id; - if ( ! hasOverridableAttributes( block ) || ! blockId ) + const metadataName = + legacyIdMap?.[ block.clientId ] ?? block.attributes.metadata?.name; + + if ( ! metadataName || ! hasOverridableAttributes( block ) ) { return { ...block, innerBlocks }; + } + const attributes = getOverridableAttributes( block ); const newAttributes = { ...block.attributes }; for ( const attributeKey of attributes ) { - defaultValues[ blockId ] ??= {}; - defaultValues[ blockId ][ attributeKey ] = + defaultValues[ metadataName ] ??= {}; + defaultValues[ metadataName ][ attributeKey ] = block.attributes[ attributeKey ]; - const contentValues = content[ blockId ]?.values; + const contentValues = content[ metadataName ]; if ( contentValues?.[ attributeKey ] !== undefined ) { newAttributes[ attributeKey ] = contentValues[ attributeKey ]; } @@ -142,29 +167,40 @@ function isAttributeEqual( attribute1, attribute2 ) { return attribute1 === attribute2; } -function getContentValuesFromInnerBlocks( blocks, defaultValues ) { +function getContentValuesFromInnerBlocks( blocks, defaultValues, legacyIdMap ) { /** @type {Record}>} */ const content = {}; for ( const block of blocks ) { if ( block.name === patternBlockName ) continue; - Object.assign( - content, - getContentValuesFromInnerBlocks( block.innerBlocks, defaultValues ) - ); - const blockId = block.attributes.metadata?.id; - if ( ! hasOverridableAttributes( block ) || ! blockId ) continue; + if ( block.innerBlocks.length ) { + Object.assign( + content, + getContentValuesFromInnerBlocks( + block.innerBlocks, + defaultValues, + legacyIdMap + ) + ); + } + const metadataName = + legacyIdMap?.[ block.clientId ] ?? block.attributes.metadata?.name; + if ( ! metadataName || ! hasOverridableAttributes( block ) ) { + continue; + } + const attributes = getOverridableAttributes( block ); + for ( const attributeKey of attributes ) { if ( ! isAttributeEqual( block.attributes[ attributeKey ], - defaultValues[ blockId ][ attributeKey ] + defaultValues?.[ metadataName ]?.[ attributeKey ] ) ) { - content[ blockId ] ??= { values: {}, blockName: block.name }; + content[ metadataName ] ??= {}; // TODO: We need a way to represent `undefined` in the serialized overrides. // Also see: https://github.com/WordPress/gutenberg/pull/57249#discussion_r1452987871 - content[ blockId ].values[ attributeKey ] = + content[ metadataName ][ attributeKey ] = block.attributes[ attributeKey ] === undefined ? // TODO: We use an empty string to represent undefined for now until // we support a richer format for overrides and the block binding API. @@ -278,8 +314,15 @@ export default function ReusableBlockEdit( { [ editedRecord.blocks, editedRecord.content ] ); + const legacyIdMap = useRef( {} ); + // Apply the initial overrides from the pattern block to the inner blocks. useEffect( () => { + // Build a map of clientIds to the old nano id system to provide back compat. + legacyIdMap.current = getLegacyIdMap( + initialBlocks, + initialContent.current + ); defaultContent.current = {}; const originalEditingMode = getBlockEditingMode( patternClientId ); // Replace the contents of the blocks with the overrides. @@ -291,7 +334,8 @@ export default function ReusableBlockEdit( { applyInitialContentValuesToInnerBlocks( initialBlocks, initialContent.current, - defaultContent.current + defaultContent.current, + legacyIdMap.current ) ); } ); @@ -343,7 +387,8 @@ export default function ReusableBlockEdit( { setAttributes( { content: getContentValuesFromInnerBlocks( blocks, - defaultContent.current + defaultContent.current, + legacyIdMap.current ), } ); } ); diff --git a/packages/block-library/src/block/index.php b/packages/block-library/src/block/index.php index 8e24317501d9f..4886373b5885a 100644 --- a/packages/block-library/src/block/index.php +++ b/packages/block-library/src/block/index.php @@ -46,26 +46,35 @@ function render_block_core_block( $attributes ) { $content = $wp_embed->run_shortcode( $reusable_block->post_content ); $content = $wp_embed->autoembed( $content ); - // Back compat, the content attribute was previously named overrides and - // had a slightly different format. For blocks that have not been migrated, - // also convert the format here so that the provided `pattern/overrides` - // context is correct. - if ( isset( $attributes['overrides'] ) && ! isset( $attributes['content'] ) ) { - $migrated_content = array(); - foreach ( $attributes['overrides'] as $id => $values ) { - $migrated_content[ $id ] = array( - 'values' => $values, - ); + // Back compat. + // For blocks that have not been migrated in the editor, add some back compat + // so that front-end rendering continues to work. + + // This matches the `v2` deprecation. Removes the inner `values` property + // from every item. + if ( isset( $attributes['content'] ) ) { + foreach ( $attributes['content'] as &$content_data ) { + if ( isset( $content_data['values'] ) ) { + $is_assoc_array = is_array( $content_data['values'] ) && ! wp_is_numeric_array( $content_data['values'] ); + + if ( $is_assoc_array ) { + $content_data = $content_data['values']; + } + } } - $attributes['content'] = $migrated_content; } - $has_pattern_overrides = isset( $attributes['content'] ); + + // This matches the `v1` deprecation. Rename `overrides` to `content`. + if ( isset( $attributes['overrides'] ) && ! isset( $attributes['content'] ) ) { + $attributes['content'] = $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. */ + $has_pattern_overrides = isset( $attributes['content'] ); if ( $has_pattern_overrides ) { $filter_block_context = static function ( $context ) use ( $attributes ) { $context['pattern/overrides'] = $attributes['content']; diff --git a/packages/block-library/src/image/edit.js b/packages/block-library/src/image/edit.js index e1e221bc9575a..489343e1dfef7 100644 --- a/packages/block-library/src/image/edit.js +++ b/packages/block-library/src/image/edit.js @@ -20,7 +20,7 @@ import { useBlockEditingMode, } from '@wordpress/block-editor'; import { useEffect, useRef, useState } from '@wordpress/element'; -import { __ } from '@wordpress/i18n'; +import { __, sprintf } from '@wordpress/i18n'; import { image as icon, plugins as pluginsIcon } from '@wordpress/icons'; import { store as noticesStore } from '@wordpress/notices'; @@ -336,7 +336,7 @@ export function ImageEdit( { } ); // Much of this description is duplicated from MediaPlaceholder. - const { lockUrlControls = false } = useSelect( + const { lockUrlControls = false, lockUrlControlsMessage } = useSelect( ( select ) => { if ( ! isSingleSelected ) { return {}; @@ -351,6 +351,13 @@ export function ImageEdit( { !! metadata?.bindings?.url && ( ! blockBindingsSource || blockBindingsSource?.lockAttributesEditing ), + lockUrlControlsMessage: blockBindingsSource?.label + ? sprintf( + /* translators: %s: Label of the bindings source. */ + __( 'Connected to %s' ), + blockBindingsSource.label + ) + : __( 'Connected to dynamic data' ), }; }, [ isSingleSelected ] @@ -387,7 +394,7 @@ export function ImageEdit( { - { __( 'Connected to a custom field' ) } + { lockUrlControlsMessage } ) : ( content diff --git a/packages/block-library/src/image/image.js b/packages/block-library/src/image/image.js index ea0f82a2e1986..e55f6bfa03d73 100644 --- a/packages/block-library/src/image/image.js +++ b/packages/block-library/src/image/image.js @@ -410,7 +410,9 @@ export default function Image( { lockUrlControls = false, lockHrefControls = false, lockAltControls = false, + lockAltControlsMessage, lockTitleControls = false, + lockTitleControlsMessage, lockCaption = false, } = useSelect( ( select ) => { @@ -454,10 +456,24 @@ export default function Image( { !! altBinding && ( ! altBindingSource || altBindingSource?.lockAttributesEditing ), + lockAltControlsMessage: altBindingSource?.label + ? sprintf( + /* translators: %s: Label of the bindings source. */ + __( 'Connected to %s' ), + altBindingSource.label + ) + : __( 'Connected to dynamic data' ), lockTitleControls: !! titleBinding && ( ! titleBindingSource || titleBindingSource?.lockAttributesEditing ), + lockTitleControlsMessage: titleBindingSource?.label + ? sprintf( + /* translators: %s: Label of the bindings source. */ + __( 'Connected to %s' ), + titleBindingSource.label + ) + : __( 'Connected to dynamic data' ), }; }, [ clientId, isSingleSelected, metadata?.bindings ] @@ -557,11 +573,7 @@ export default function Image( { disabled={ lockAltControls } help={ lockAltControls ? ( - <> - { __( - 'Connected to a custom field' - ) } - + <>{ lockAltControlsMessage } ) : ( <> @@ -607,11 +619,7 @@ export default function Image( { disabled={ lockTitleControls } help={ lockTitleControls ? ( - <> - { __( - 'Connected to a custom field' - ) } - + <>{ lockTitleControlsMessage } ) : ( <> { __( @@ -652,11 +660,7 @@ export default function Image( { readOnly={ lockAltControls } help={ lockAltControls ? ( - <> - { __( - 'Connected to a custom field' - ) } - + <>{ lockAltControlsMessage } ) : ( <> @@ -694,7 +698,7 @@ export default function Image( { readOnly={ lockTitleControls } help={ lockTitleControls ? ( - <>{ __( 'Connected to a custom field' ) } + <>{ lockTitleControlsMessage } ) : ( <> { __( diff --git a/packages/block-library/src/list-item/edit.js b/packages/block-library/src/list-item/edit.js index 46cbd3a94831d..467154f76992e 100644 --- a/packages/block-library/src/list-item/edit.js +++ b/packages/block-library/src/list-item/edit.js @@ -29,7 +29,6 @@ import { useOutdentListItem, useSplit, useMerge, - useCopy, } from './hooks'; import { convertToListItems } from './utils'; @@ -79,7 +78,7 @@ export default function ListItemEdit( { mergeBlocks, } ) { const { placeholder, content } = attributes; - const blockProps = useBlockProps( { ref: useCopy( clientId ) } ); + const blockProps = useBlockProps(); const innerBlocksProps = useInnerBlocksProps( blockProps, { renderAppender: false, __unstableDisableDropZone: true, diff --git a/packages/block-library/src/list-item/hooks/index.js b/packages/block-library/src/list-item/hooks/index.js index 3bbc3167abed3..1687adbe740d0 100644 --- a/packages/block-library/src/list-item/hooks/index.js +++ b/packages/block-library/src/list-item/hooks/index.js @@ -4,4 +4,3 @@ export { default as useEnter } from './use-enter'; export { default as useSpace } from './use-space'; export { default as useSplit } from './use-split'; export { default as useMerge } from './use-merge'; -export { default as useCopy } from './use-copy'; diff --git a/packages/block-library/src/list-item/hooks/use-copy.js b/packages/block-library/src/list-item/hooks/use-copy.js deleted file mode 100644 index 7a76019ad11a4..0000000000000 --- a/packages/block-library/src/list-item/hooks/use-copy.js +++ /dev/null @@ -1,38 +0,0 @@ -/** - * WordPress dependencies - */ -import { useRefEffect } from '@wordpress/compose'; -import { store as blockEditorStore } from '@wordpress/block-editor'; -import { useSelect } from '@wordpress/data'; - -export default function useCopy( clientId ) { - const { getBlockRootClientId, getBlockName, getBlockAttributes } = - useSelect( blockEditorStore ); - - return useRefEffect( ( node ) => { - function onCopy( event ) { - // The event propagates through all nested lists, so don't override - // when copying nested list items. - if ( event.clipboardData.getData( '__unstableWrapperBlockName' ) ) { - return; - } - - const rootClientId = getBlockRootClientId( clientId ); - event.clipboardData.setData( - '__unstableWrapperBlockName', - getBlockName( rootClientId ) - ); - event.clipboardData.setData( - '__unstableWrapperBlockAttributes', - JSON.stringify( getBlockAttributes( rootClientId ) ) - ); - } - - node.addEventListener( 'copy', onCopy ); - node.addEventListener( 'cut', onCopy ); - return () => { - node.removeEventListener( 'copy', onCopy ); - node.removeEventListener( 'cut', onCopy ); - }; - }, [] ); -} diff --git a/packages/block-library/src/list-item/index.js b/packages/block-library/src/list-item/index.js index 00adc1c2c4026..07c5bb7fda901 100644 --- a/packages/block-library/src/list-item/index.js +++ b/packages/block-library/src/list-item/index.js @@ -2,6 +2,7 @@ * WordPress dependencies */ import { listItem as icon } from '@wordpress/icons'; +import { privateApis } from '@wordpress/block-editor'; /** * Internal dependencies @@ -11,6 +12,7 @@ import metadata from './block.json'; import edit from './edit'; import save from './save'; import transforms from './transforms'; +import { unlock } from '../lock-unlock'; const { name } = metadata; @@ -27,6 +29,7 @@ export const settings = { }; }, transforms, + [ unlock( privateApis ).requiresWrapperOnCopy ]: true, }; export const init = () => initBlock( { name, metadata, settings } ); diff --git a/packages/block-library/src/navigation/index.php b/packages/block-library/src/navigation/index.php index 3a3a654aee612..1d73d09bbd1fb 100644 --- a/packages/block-library/src/navigation/index.php +++ b/packages/block-library/src/navigation/index.php @@ -552,7 +552,7 @@ private static function get_nav_element_directives( $is_interactive ) { return ''; } // When adding to this array be mindful of security concerns. - $nav_element_context = data_wp_context( + $nav_element_context = wp_interactivity_data_wp_context( array( 'overlayOpenedBy' => array( 'click' => false, diff --git a/packages/block-library/src/search/index.php b/packages/block-library/src/search/index.php index c368c2ab03dbf..ca8c70edfa907 100644 --- a/packages/block-library/src/search/index.php +++ b/packages/block-library/src/search/index.php @@ -179,7 +179,7 @@ function render_block_core_search( $attributes ) { if ( $is_expandable_searchfield ) { $aria_label_expanded = __( 'Submit Search' ); $aria_label_collapsed = __( 'Expand search field' ); - $form_context = data_wp_context( + $form_context = wp_interactivity_data_wp_context( array( 'isSearchInputVisible' => $open_by_default, 'inputId' => $input_id, diff --git a/packages/block-library/src/site-logo/edit.js b/packages/block-library/src/site-logo/edit.js index 0c146400cd74f..079811f0aae95 100644 --- a/packages/block-library/src/site-logo/edit.js +++ b/packages/block-library/src/site-logo/edit.js @@ -268,6 +268,14 @@ const SiteLogo = ( { ); + // Support the previous location for the Site Icon settings. To be removed + // when the required WP core version for Gutenberg is >= 6.5.0. + const shouldUseNewUrl = ! window?.__experimentalUseCustomizerSiteLogoUrl; + + const siteIconSettingsUrl = shouldUseNewUrl + ? siteUrl + '/wp-admin/options-general.php' + : siteUrl + '/wp-admin/customize.php?autofocus[section]=title_tagline'; + const syncSiteIconHelpText = createInterpolateElement( __( 'Site Icons are what you see in browser tabs, bookmark bars, and within the WordPress mobile apps. To use a custom icon that is different from your site logo, use the Site Icon settings.' @@ -276,10 +284,7 @@ const SiteLogo = ( { a: ( // eslint-disable-next-line jsx-a11y/anchor-has-content @@ -331,7 +336,7 @@ const SiteLogo = ( { <> { setAttributes( { shouldSyncIcon: value } ); setIcon( value ? logoId : undefined ); diff --git a/packages/dataviews/src/single-selection-checkbox.js b/packages/dataviews/src/single-selection-checkbox.js index ce21419f969ce..f55fc58b908d8 100644 --- a/packages/dataviews/src/single-selection-checkbox.js +++ b/packages/dataviews/src/single-selection-checkbox.js @@ -32,10 +32,14 @@ export default function SingleSelectionCheckbox( { { + if ( disabled ) { + return; + } + if ( ! isSelected ) { onSelectionChange( data.filter( ( _item ) => { diff --git a/packages/dataviews/src/style.scss b/packages/dataviews/src/style.scss index 10e3b511cc58a..95e685f3cb905 100644 --- a/packages/dataviews/src/style.scss +++ b/packages/dataviews/src/style.scss @@ -219,7 +219,6 @@ color: $gray-900; text-overflow: ellipsis; white-space: nowrap; - overflow: hidden; display: block; width: 100%; @@ -235,6 +234,7 @@ &:hover { color: $gray-900; } + @include link-reset(); } button.components-button.is-link { diff --git a/packages/e2e-test-utils-playwright/src/page-utils/press-keys.ts b/packages/e2e-test-utils-playwright/src/page-utils/press-keys.ts index 3b187625fd47c..11bc11c43f603 100644 --- a/packages/e2e-test-utils-playwright/src/page-utils/press-keys.ts +++ b/packages/e2e-test-utils-playwright/src/page-utils/press-keys.ts @@ -55,18 +55,26 @@ async function emulateClipboard( page: Page, type: 'copy' | 'cut' | 'paste' ) { const canvasDoc = // @ts-ignore document.activeElement?.contentDocument ?? document; - const clipboardDataTransfer = new DataTransfer(); + const event = new ClipboardEvent( _type, { + bubbles: true, + cancelable: true, + clipboardData: new DataTransfer(), + } ); + + if ( ! event.clipboardData ) { + throw new Error( 'ClipboardEvent.clipboardData is null' ); + } if ( _type === 'paste' ) { - clipboardDataTransfer.setData( + event.clipboardData.setData( 'text/plain', _clipboardData[ 'text/plain' ] ); - clipboardDataTransfer.setData( + event.clipboardData.setData( 'text/html', _clipboardData[ 'text/html' ] ); - clipboardDataTransfer.setData( + event.clipboardData.setData( 'rich-text', _clipboardData[ 'rich-text' ] ); @@ -85,22 +93,16 @@ async function emulateClipboard( page: Page, type: 'copy' | 'cut' | 'paste' ) { ) .join( '' ); } - clipboardDataTransfer.setData( 'text/plain', plainText ); - clipboardDataTransfer.setData( 'text/html', html ); + event.clipboardData.setData( 'text/plain', plainText ); + event.clipboardData.setData( 'text/html', html ); } - canvasDoc.activeElement?.dispatchEvent( - new ClipboardEvent( _type, { - bubbles: true, - cancelable: true, - clipboardData: clipboardDataTransfer, - } ) - ); + canvasDoc.activeElement.dispatchEvent( event ); return { - 'text/plain': clipboardDataTransfer.getData( 'text/plain' ), - 'text/html': clipboardDataTransfer.getData( 'text/html' ), - 'rich-text': clipboardDataTransfer.getData( 'rich-text' ), + 'text/plain': event.clipboardData.getData( 'text/plain' ), + 'text/html': event.clipboardData.getData( 'text/html' ), + 'rich-text': event.clipboardData.getData( 'rich-text' ), }; }, [ type, clipboardDataHolder ] as const diff --git a/packages/e2e-tests/plugins/block-bindings.php b/packages/e2e-tests/plugins/block-bindings.php index c686b40006a06..74aec2adb500f 100644 --- a/packages/e2e-tests/plugins/block-bindings.php +++ b/packages/e2e-tests/plugins/block-bindings.php @@ -21,7 +21,6 @@ function gutenberg_test_block_bindings_register_custom_fields() { 'default' => 'Value of the text_custom_field', ) ); - // TODO: Change url. register_meta( 'post', 'url_custom_field', @@ -32,5 +31,24 @@ function gutenberg_test_block_bindings_register_custom_fields() { 'default' => '#url-custom-field', ) ); + register_meta( + 'post', + '_protected_field', + array( + 'type' => 'string', + 'single' => true, + 'default' => 'protected field value', + ) + ); + register_meta( + 'post', + 'show_in_rest_false_field', + array( + 'show_in_rest' => false, + 'type' => 'string', + 'single' => true, + 'default' => 'show_in_rest false field value', + ) + ); } add_action( 'init', 'gutenberg_test_block_bindings_register_custom_fields' ); diff --git a/packages/edit-site/src/components/global-styles/font-library-modal/context.js b/packages/edit-site/src/components/global-styles/font-library-modal/context.js index 35276b0ad8b2b..2f46e0cdec7e0 100644 --- a/packages/edit-site/src/components/global-styles/font-library-modal/context.js +++ b/packages/edit-site/src/components/global-styles/font-library-modal/context.js @@ -162,16 +162,6 @@ function FontLibraryProvider( { children } ) { // Demo const [ loadedFontUrls ] = useState( new Set() ); - // Theme data - const { site, currentTheme } = useSelect( ( select ) => { - return { - site: select( coreStore ).getSite(), - currentTheme: select( coreStore ).getCurrentTheme(), - }; - } ); - const themeUrl = - site?.url + '/wp-content/themes/' + currentTheme?.stylesheet; - const getAvailableFontsOutline = ( availableFontFamilies ) => { const outline = availableFontFamilies.reduce( ( acc, font ) => { const availableFontFaces = @@ -210,113 +200,115 @@ function FontLibraryProvider( { children } ) { return getActivatedFontsOutline( source )[ slug ] || []; }; - async function installFont( fontFamilyToInstall ) { + async function installFonts( fontFamiliesToInstall ) { setIsInstalling( true ); try { - // Get the font family if it already exists. - let installedFontFamily = await fetchGetFontFamilyBySlug( - fontFamilyToInstall.slug - ); + const fontFamiliesToActivate = []; + let installationErrors = []; + + for ( const fontFamilyToInstall of fontFamiliesToInstall ) { + let isANewFontFamily = false; - // Otherwise create it. - if ( ! installedFontFamily ) { - // Prepare font family form data to install. - installedFontFamily = await fetchInstallFontFamily( - makeFontFamilyFormData( fontFamilyToInstall ) + // Get the font family if it already exists. + let installedFontFamily = await fetchGetFontFamilyBySlug( + fontFamilyToInstall.slug ); - } - // Collect font faces that have already been installed (to be activated later) - const alreadyInstalledFontFaces = - installedFontFamily.fontFace && fontFamilyToInstall.fontFace - ? installedFontFamily.fontFace.filter( + // Otherwise create it. + if ( ! installedFontFamily ) { + isANewFontFamily = true; + // Prepare font family form data to install. + installedFontFamily = await fetchInstallFontFamily( + makeFontFamilyFormData( fontFamilyToInstall ) + ); + } + + // Collect font faces that have already been installed (to be activated later) + const alreadyInstalledFontFaces = + installedFontFamily.fontFace && fontFamilyToInstall.fontFace + ? installedFontFamily.fontFace.filter( + ( fontFaceToInstall ) => + checkFontFaceInstalled( + fontFaceToInstall, + fontFamilyToInstall.fontFace + ) + ) + : []; + + // Filter out Font Faces that have already been installed (so that they are not re-installed) + if ( + installedFontFamily.fontFace && + fontFamilyToInstall.fontFace + ) { + fontFamilyToInstall.fontFace = + fontFamilyToInstall.fontFace.filter( ( fontFaceToInstall ) => - checkFontFaceInstalled( + ! checkFontFaceInstalled( fontFaceToInstall, - fontFamilyToInstall.fontFace + installedFontFamily.fontFace ) - ) - : []; - - // Filter out Font Faces that have already been installed (so that they are not re-installed) - if ( - installedFontFamily.fontFace && - fontFamilyToInstall.fontFace - ) { - fontFamilyToInstall.fontFace = - fontFamilyToInstall.fontFace.filter( - ( fontFaceToInstall ) => - ! checkFontFaceInstalled( - fontFaceToInstall, - installedFontFamily.fontFace - ) + ); + } + + // Install the fonts (upload the font files to the server and create the post in the database). + let sucessfullyInstalledFontFaces = []; + let unsucessfullyInstalledFontFaces = []; + if ( fontFamilyToInstall?.fontFace?.length > 0 ) { + const response = await batchInstallFontFaces( + installedFontFamily.id, + makeFontFacesFormData( fontFamilyToInstall ) ); - } - - // Install the fonts (upload the font files to the server and create the post in the database). - let sucessfullyInstalledFontFaces = []; - let unsucessfullyInstalledFontFaces = []; - if ( fontFamilyToInstall?.fontFace?.length > 0 ) { - const response = await batchInstallFontFaces( - installedFontFamily.id, - makeFontFacesFormData( fontFamilyToInstall ) + sucessfullyInstalledFontFaces = response?.successes; + unsucessfullyInstalledFontFaces = response?.errors; + } + + // Use the sucessfully installed font faces + // As well as any font faces that were already installed (those will be activated) + if ( + sucessfullyInstalledFontFaces?.length > 0 || + alreadyInstalledFontFaces?.length > 0 + ) { + fontFamilyToInstall.fontFace = [ + ...sucessfullyInstalledFontFaces, + ...alreadyInstalledFontFaces, + ]; + fontFamiliesToActivate.push( fontFamilyToInstall ); + } else if ( isANewFontFamily ) { + // If the font family is new, delete it to avoid having font families without font faces. + await fetchUninstallFontFamily( installedFontFamily.id ); + } + + installationErrors = installationErrors.concat( + unsucessfullyInstalledFontFaces ); - sucessfullyInstalledFontFaces = response?.successes; - unsucessfullyInstalledFontFaces = response?.errors; } - const detailedErrorMessage = unsucessfullyInstalledFontFaces.reduce( - ( errorMessageCollection, error ) => { - return `${ errorMessageCollection } ${ error.message }`; - }, - '' - ); + if ( fontFamiliesToActivate.length > 0 ) { + // Activate the font family (add the font family to the global styles). + activateCustomFontFamilies( fontFamiliesToActivate ); - // If there were no successes and nothing already installed then we don't need to activate anything and can bounce now. - if ( - fontFamilyToInstall?.fontFace?.length > 0 && - sucessfullyInstalledFontFaces.length === 0 && - alreadyInstalledFontFaces.length === 0 - ) { - throw new Error( - sprintf( - /* translators: %s: Specific error message returned from server. */ - __( 'No font faces were installed. %s' ), - detailedErrorMessage - ) + // Save the global styles to the database. + await saveSpecifiedEntityEdits( + 'root', + 'globalStyles', + globalStylesId, + [ 'settings.typography.fontFamilies' ] ); - } - // Use the sucessfully installed font faces - // As well as any font faces that were already installed (those will be activated) - if ( - sucessfullyInstalledFontFaces?.length > 0 || - alreadyInstalledFontFaces?.length > 0 - ) { - fontFamilyToInstall.fontFace = [ - ...sucessfullyInstalledFontFaces, - ...alreadyInstalledFontFaces, - ]; + refreshLibrary(); } - // Activate the font family (add the font family to the global styles). - activateCustomFontFamilies( [ fontFamilyToInstall ] ); - - // Save the global styles to the database. - saveSpecifiedEntityEdits( 'root', 'globalStyles', globalStylesId, [ - 'settings.typography.fontFamilies', - ] ); - - refreshLibrary(); - - if ( unsucessfullyInstalledFontFaces.length > 0 ) { + if ( installationErrors.length > 0 ) { throw new Error( sprintf( /* translators: %s: Specific error message returned from server. */ - __( - 'Some font faces were installed. There were some errors. %s' - ), - detailedErrorMessage + __( 'There were some errors installing fonts. %s' ), + installationErrors.reduce( + ( errorMessageCollection, error ) => { + return `${ errorMessageCollection } ${ error.message }`; + }, + '' + ) ) ); } @@ -375,14 +367,10 @@ function FontLibraryProvider( { children } ) { const activateCustomFontFamilies = ( fontsToAdd ) => { // Merge the existing custom fonts with the new fonts. - const newCustomFonts = mergeFontFamilies( - fontFamilies?.custom, - fontsToAdd - ); // Activate the fonts by set the new custom fonts array. setFontFamilies( { ...fontFamilies, - custom: newCustomFonts, + custom: mergeFontFamilies( fontFamilies?.custom, fontsToAdd ), } ); // Add custom fonts to the browser. fontsToAdd.forEach( ( font ) => { @@ -416,7 +404,7 @@ function FontLibraryProvider( { children } ) { // If the font doesn't have a src, don't load it. if ( ! fontFace.src ) return; // Get the src of the font. - const src = getDisplaySrcFromFontFace( fontFace.src, themeUrl ); + const src = getDisplaySrcFromFontFace( fontFace.src ); // If the font is already loaded, don't load it again. if ( ! src || loadedFontUrls.has( src ) ) return; // Load the font in the browser. @@ -467,7 +455,7 @@ function FontLibraryProvider( { children } ) { isFontActivated, getFontFacesActivated, loadFontFaceAsset, - installFont, + installFonts, uninstallFontFamily, toggleActivateFont, getAvailableFontsOutline, diff --git a/packages/edit-site/src/components/global-styles/font-library-modal/font-collection.js b/packages/edit-site/src/components/global-styles/font-library-modal/font-collection.js index 01f7a90357c8b..78c042f3fa405 100644 --- a/packages/edit-site/src/components/global-styles/font-library-modal/font-collection.js +++ b/packages/edit-site/src/components/global-styles/font-library-modal/font-collection.js @@ -77,7 +77,7 @@ function FontCollection( { slug } ) { const { collections, getFontCollection, - installFont, + installFonts, isInstalling, notice, setNotice, @@ -214,7 +214,7 @@ function FontCollection( { slug } ) { } try { - await installFont( fontFamily ); + await installFonts( [ fontFamily ] ); setNotice( { type: 'success', message: __( 'Fonts were installed successfully.' ), @@ -277,12 +277,14 @@ function FontCollection( { slug } ) { > - - { selectedCollection.name } - + + + { selectedCollection.name } + + { selectedCollection.description } + - { selectedCollection.description } diff --git a/packages/edit-site/src/components/global-styles/font-library-modal/index.js b/packages/edit-site/src/components/global-styles/font-library-modal/index.js index 71966449eb616..979dbece9fad2 100644 --- a/packages/edit-site/src/components/global-styles/font-library-modal/index.js +++ b/packages/edit-site/src/components/global-styles/font-library-modal/index.js @@ -6,6 +6,8 @@ import { Modal, privateApis as componentsPrivateApis, } from '@wordpress/components'; +import { store as coreStore } from '@wordpress/core-data'; +import { useSelect } from '@wordpress/data'; import { useContext } from '@wordpress/element'; /** @@ -19,16 +21,15 @@ import { unlock } from '../../../lock-unlock'; const { Tabs } = unlock( componentsPrivateApis ); -const DEFAULT_TABS = [ - { - id: 'installed-fonts', - title: __( 'Library' ), - }, - { - id: 'upload-fonts', - title: __( 'Upload' ), - }, -]; +const DEFAULT_TAB = { + id: 'installed-fonts', + title: __( 'Library' ), +}; + +const UPLOAD_TAB = { + id: 'upload-fonts', + title: __( 'Upload' ), +}; const tabsFromCollections = ( collections ) => collections.map( ( { slug, name } ) => ( { @@ -44,11 +45,17 @@ function FontLibraryModal( { initialTabId = 'installed-fonts', } ) { const { collections, setNotice } = useContext( FontLibraryContext ); + const canUserCreate = useSelect( ( select ) => { + const { canUser } = select( coreStore ); + return canUser( 'create', 'font-families' ); + }, [] ); + + const tabs = [ DEFAULT_TAB ]; - const tabs = [ - ...DEFAULT_TABS, - ...tabsFromCollections( collections || [] ), - ]; + if ( canUserCreate ) { + tabs.push( UPLOAD_TAB ); + tabs.push( ...tabsFromCollections( collections || [] ) ); + } // Reset notice when new tab is selected. const onSelect = () => { diff --git a/packages/edit-site/src/components/global-styles/font-library-modal/installed-fonts.js b/packages/edit-site/src/components/global-styles/font-library-modal/installed-fonts.js index 9425cb9c2d27b..226e9cf5a3eef 100644 --- a/packages/edit-site/src/components/global-styles/font-library-modal/installed-fonts.js +++ b/packages/edit-site/src/components/global-styles/font-library-modal/installed-fonts.js @@ -18,6 +18,8 @@ import { Spinner, privateApis as componentsPrivateApis, } from '@wordpress/components'; +import { store as coreStore } from '@wordpress/core-data'; +import { useSelect } from '@wordpress/data'; import { useContext, useEffect, useState } from '@wordpress/element'; import { __, sprintf } from '@wordpress/i18n'; import { chevronLeft } from '@wordpress/icons'; @@ -49,9 +51,24 @@ function InstalledFonts() { setNotice, } = useContext( FontLibraryContext ); const [ isConfirmDeleteOpen, setIsConfirmDeleteOpen ] = useState( false ); + const customFontFamilyId = + libraryFontSelected?.source === 'custom' && libraryFontSelected?.id; + + const canUserDelete = useSelect( + ( select ) => { + const { canUser } = select( coreStore ); + return ( + customFontFamilyId && + canUser( 'delete', 'font-families', customFontFamilyId ) + ); + }, + [ customFontFamilyId ] + ); const shouldDisplayDeleteButton = - !! libraryFontSelected && libraryFontSelected?.source !== 'theme'; + !! libraryFontSelected && + libraryFontSelected?.source !== 'theme' && + canUserDelete; const handleUninstallClick = () => { setIsConfirmDeleteOpen( true ); diff --git a/packages/edit-site/src/components/global-styles/font-library-modal/upload-fonts.js b/packages/edit-site/src/components/global-styles/font-library-modal/upload-fonts.js index 8b53ddcdbb38a..e7010355eed5c 100644 --- a/packages/edit-site/src/components/global-styles/font-library-modal/upload-fonts.js +++ b/packages/edit-site/src/components/global-styles/font-library-modal/upload-fonts.js @@ -28,7 +28,8 @@ import { unlock } from '../../../lock-unlock'; const { ProgressBar } = unlock( componentsPrivateApis ); function UploadFonts() { - const { installFont, notice, setNotice } = useContext( FontLibraryContext ); + const { installFonts, notice, setNotice } = + useContext( FontLibraryContext ); const [ isUploading, setIsUploading ] = useState( false ); const handleDropZone = ( files ) => { @@ -143,19 +144,8 @@ function UploadFonts() { const handleInstall = async ( fontFaces ) => { const fontFamilies = makeFamiliesFromFaces( fontFaces ); - if ( fontFamilies.length > 1 ) { - setNotice( { - type: 'error', - message: __( - 'Variants from only one font family can be uploaded at a time.' - ), - } ); - setIsUploading( false ); - return; - } - try { - await installFont( fontFamilies[ 0 ] ); + await installFonts( fontFamilies ); setNotice( { type: 'success', message: __( 'Fonts were installed successfully.' ), diff --git a/packages/edit-site/src/components/global-styles/font-library-modal/utils/index.js b/packages/edit-site/src/components/global-styles/font-library-modal/utils/index.js index 011f09b12a841..1458b47cd010a 100644 --- a/packages/edit-site/src/components/global-styles/font-library-modal/utils/index.js +++ b/packages/edit-site/src/components/global-styles/font-library-modal/utils/index.js @@ -121,7 +121,13 @@ export async function loadFontFaceInBrowser( fontFace, source, addTo = 'all' ) { } } -export function getDisplaySrcFromFontFace( input, urlPrefix ) { +/** + * Retrieves the display source from a font face src. + * + * @param {string|string[]} input - The font face src. + * @return {string|undefined} The display source or undefined if the input is invalid. + */ +export function getDisplaySrcFromFontFace( input ) { if ( ! input ) { return; } @@ -132,9 +138,9 @@ export function getDisplaySrcFromFontFace( input, urlPrefix ) { } else { src = input; } - // If it is a theme font, we need to make the url absolute - if ( src.startsWith( 'file:.' ) && urlPrefix ) { - src = src.replace( 'file:.', urlPrefix ); + // It's expected theme fonts will already be loaded in the browser. + if ( src.startsWith( 'file:.' ) ) { + return; } if ( ! isUrlEncoded( src ) ) { src = encodeURI( src ); diff --git a/packages/edit-site/src/components/global-styles/font-library-modal/utils/test/getDisplaySrcFromFontFace.spec.js b/packages/edit-site/src/components/global-styles/font-library-modal/utils/test/getDisplaySrcFromFontFace.spec.js index 9c6235443a099..3cbdc0283f1a9 100644 --- a/packages/edit-site/src/components/global-styles/font-library-modal/utils/test/getDisplaySrcFromFontFace.spec.js +++ b/packages/edit-site/src/components/global-styles/font-library-modal/utils/test/getDisplaySrcFromFontFace.spec.js @@ -21,33 +21,22 @@ describe( 'getDisplaySrcFromFontFace', () => { ); } ); - it( 'makes URL absolute when it starts with file:. and urlPrefix is given', () => { - const input = 'file:./font1'; - const urlPrefix = 'http://example.com'; - expect( getDisplaySrcFromFontFace( input, urlPrefix ) ).toBe( - 'http://example.com/font1' - ); - } ); - - it( 'does not modify URL if it does not start with file:.', () => { - const input = [ 'http://some-other-place.com/font1' ]; - const urlPrefix = 'http://example.com'; - expect( getDisplaySrcFromFontFace( input, urlPrefix ) ).toBe( - 'http://some-other-place.com/font1' - ); + it( 'return undefined when the url starts with file:', () => { + const input = 'file:./theme/assets/font1.ttf'; + expect( getDisplaySrcFromFontFace( input ) ).toBe( undefined ); } ); it( 'encodes the URL if it is not encoded', () => { - const input = 'file:./assets/font one with spaces.ttf'; + const input = 'https://example.org/font one with spaces.ttf'; expect( getDisplaySrcFromFontFace( input ) ).toBe( - 'file:./assets/font%20one%20with%20spaces.ttf' + 'https://example.org/font%20one%20with%20spaces.ttf' ); } ); it( 'does not encode the URL if it is already encoded', () => { - const input = 'file:./font%20one'; + const input = 'https://example.org/fonts/font%20one.ttf'; expect( getDisplaySrcFromFontFace( input ) ).toBe( - 'file:./font%20one' + 'https://example.org/fonts/font%20one.ttf' ); } ); } ); diff --git a/packages/edit-site/src/components/resizable-frame/index.js b/packages/edit-site/src/components/resizable-frame/index.js index ee5c98da09033..e8d0588c420b2 100644 --- a/packages/edit-site/src/components/resizable-frame/index.js +++ b/packages/edit-site/src/components/resizable-frame/index.js @@ -300,6 +300,7 @@ function ResizableFrame( { className={ classnames( 'edit-site-resizable-frame__inner', { 'is-resizing': isResizing, } ) } + showHandle={ false } // Do not show the default handle, as we're using a custom one. > { setMeta( { ...meta, [ metaKey ]: newValue } ); }; + return { placeholder: metaKey, - useValue: [ metaValue, updateMetaValue ], + value: metaValue, + updateValue: updateMetaValue, }; }, }; diff --git a/packages/editor/src/hooks/index.js b/packages/editor/src/hooks/index.js index 5a48ec1bf4956..75bb34abf6cfa 100644 --- a/packages/editor/src/hooks/index.js +++ b/packages/editor/src/hooks/index.js @@ -3,4 +3,4 @@ */ import './custom-sources-backwards-compatibility'; import './default-autocompleters'; -import './pattern-partial-syncing'; +import './pattern-overrides'; diff --git a/packages/editor/src/hooks/pattern-partial-syncing.js b/packages/editor/src/hooks/pattern-overrides.js similarity index 81% rename from packages/editor/src/hooks/pattern-partial-syncing.js rename to packages/editor/src/hooks/pattern-overrides.js index f86268cb49546..442ce70a2bf71 100644 --- a/packages/editor/src/hooks/pattern-partial-syncing.js +++ b/packages/editor/src/hooks/pattern-overrides.js @@ -14,7 +14,7 @@ import { store as editorStore } from '../store'; import { unlock } from '../lock-unlock'; const { - PartialSyncingControls, + useSetPatternBindings, ResetOverridesControl, PATTERN_TYPES, PARTIAL_SYNCING_SUPPORTED_BLOCKS, @@ -29,7 +29,7 @@ const { * * @return {Component} Wrapped component. */ -const withPartialSyncingControls = createHigherOrderComponent( +const withPatternOverrideControls = createHigherOrderComponent( ( BlockEdit ) => ( props ) => { const isSupportedBlock = Object.keys( PARTIAL_SYNCING_SUPPORTED_BLOCKS @@ -38,6 +38,7 @@ const withPartialSyncingControls = createHigherOrderComponent( return ( <> + { isSupportedBlock && } { props.isSelected && isSupportedBlock && ( ) } @@ -46,6 +47,15 @@ const withPartialSyncingControls = createHigherOrderComponent( } ); +function BindingUpdater( props ) { + const postType = useSelect( + ( select ) => select( editorStore ).getCurrentPostType(), + [] + ); + useSetPatternBindings( props, postType ); + return null; +} + // Split into a separate component to avoid a store subscription // on every block. function ControlsWithStoreSubscription( props ) { @@ -55,6 +65,7 @@ function ControlsWithStoreSubscription( props ) { select( editorStore ).getCurrentPostType() === PATTERN_TYPES.user, [] ); + const bindings = props.attributes.metadata?.bindings; const hasPatternBindings = !! bindings && @@ -62,19 +73,14 @@ function ControlsWithStoreSubscription( props ) { ( binding ) => binding.source === 'core/pattern-overrides' ); - const shouldShowPartialSyncingControls = - isEditingPattern && blockEditingMode === 'default'; const shouldShowResetOverridesControl = ! isEditingPattern && - !! props.attributes.metadata?.id && + !! props.attributes.metadata?.name && blockEditingMode !== 'disabled' && hasPatternBindings; return ( <> - { shouldShowPartialSyncingControls && ( - - ) } { shouldShowResetOverridesControl && ( ) } @@ -84,6 +90,6 @@ function ControlsWithStoreSubscription( props ) { addFilter( 'editor.BlockEdit', - 'core/editor/with-partial-syncing-controls', - withPartialSyncingControls + 'core/editor/with-pattern-override-controls', + withPatternOverrideControls ); diff --git a/packages/icons/src/index.js b/packages/icons/src/index.js index 1d3c8c24c5cfd..788fc0152ba1d 100644 --- a/packages/icons/src/index.js +++ b/packages/icons/src/index.js @@ -59,6 +59,7 @@ export { default as commentAuthorName } from './library/comment-author-name'; export { default as commentContent } from './library/comment-content'; export { default as commentReplyLink } from './library/comment-reply-link'; export { default as commentEditLink } from './library/comment-edit-link'; +export { default as connection } from './library/connection'; export { default as cover } from './library/cover'; export { default as create } from './library/create'; export { default as crop } from './library/crop'; diff --git a/packages/icons/src/library/connection.js b/packages/icons/src/library/connection.js new file mode 100644 index 0000000000000..47cee6c66a3ef --- /dev/null +++ b/packages/icons/src/library/connection.js @@ -0,0 +1,25 @@ +/** + * WordPress dependencies + */ +import { SVG, Path, G } from '@wordpress/primitives'; + +const connection = ( + + + + + + + + + + +); + +export default connection; diff --git a/packages/interactivity/docs/2-api-reference.md b/packages/interactivity/docs/2-api-reference.md index bae15e9a7fcf2..64033107a8a55 100644 --- a/packages/interactivity/docs/2-api-reference.md +++ b/packages/interactivity/docs/2-api-reference.md @@ -969,3 +969,150 @@ const { state } = store( // The following call works as expected. store( "myPlugin/private", { /* store part */ }, { lock: PRIVATE_LOCK } ); ``` + +### Store client methods + +Apart from the store function, there are also some methods that allows the developer to access data on their store functions. + + - getContext() + - getElement() + +#### getContext() + +Retrieves the context inherited by the element evaluating a function from the store. The returned value depends on the element and the namespace where the function calling `getContext()` exists. + +```php +// render.php +
+ +
+``` + +```js +// store +import { store, getContext } from '@wordpress/interactivity'; + +store( "myPlugin", { + actions: { + log: () => { + const context = getContext(); + // Logs "false" + console.log('context => ', context.isOpen) + }, + }, +}); +``` + +#### getElement() + +Retrieves a representation of the element that the action is bound to or called from. Such representation is read-only, and contains a reference to the DOM element, its props and a local reactive state. +It returns an object with two keys: + +##### ref + +`ref` is the reference to the DOM element as an (HTMLElement)[https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement] + +##### attributes + +`attributes` contains a (Proxy)[https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy], which adds a getter that allows to reference other store namespaces. Feel free to check the getter in the code. [Link](https://github.com/WordPress/gutenberg/blob/8cb23964d58f3ce5cf6ae1b6f967a4b8d4939a8e/packages/interactivity/src/store.ts#L70) + +Those attributes will contain the directives of that element. In the button example: + +```js +// store +import { store, getContext } from '@wordpress/interactivity'; + +store( "myPlugin", { + actions: { + log: () => { + const element = getElement(); + // Logs "false" + console.log('element attributes => ', element.attributes) + }, + }, +}); +``` + +The code will log: + +```json +{ + "data-wp-on--click": 'actions.increaseCounter', + "children": ['Log'], + "onclick": event => { evaluate(entry, event); } +} +``` + +## Server functions + +The Interactivity API comes with handy functions on the PHP part. Apart from [setting the store via server](#on-the-server-side), there is also a function to get and set Interactivity related config variables. + +### wp_interactivity_config + +`wp_interactivity_config` allows to set or get a configuration array, referenced to a store namespace. +The configuration is also available on the client, but it is static information. + +Consider it a global setting for interactions of a site, that won't be updated on user interactions. + +An example of setting: + +```php + wp_interactivity_config( 'myPlugin', array( 'showLikeButton' => is_user_logged_in() ) ); +``` + +An example of getting: + +```php + wp_interactivity_config( 'myPlugin' ); +``` + +This config can be retrieved on the client: + +```js +// view.js + +const { showLikeButton } = getConfig(); +``` + +### wp_interactivity_process_directives + +`wp_interactivity_process_directives` returns the updated HTML after the directives have been processed. + +It is the Core function of the Interactivity API server side rendering part, and is public so any HTML can be processed, whether is a block or not. + +This code + +```php +wp_interactivity_state( 'myPlugin', array( 'greeting' => 'Hello, World!' ) ); +$html = '
'; +$processed_html = wp_interactivity_process_directives( $html_content ); +echo $processed_html; +``` + +will output: +```html +
Hello, World!
+``` + +### wp_interactivity_data_wp_context + +`wp_interactivity_data_wp_context` returns a stringified JSON of a context directive. +This function is the recommended way to print the `data-wp-context` attribute in the server side rendedered markup. + +```php + +$my_context = array( + 'counter' => 0, + 'isOpen' => true, +); +
+
+``` + +will output: + +```html +
+``` diff --git a/packages/patterns/package.json b/packages/patterns/package.json index e90e9b891b192..385c389fa14de 100644 --- a/packages/patterns/package.json +++ b/packages/patterns/package.json @@ -44,8 +44,7 @@ "@wordpress/icons": "file:../icons", "@wordpress/notices": "file:../notices", "@wordpress/private-apis": "file:../private-apis", - "@wordpress/url": "file:../url", - "nanoid": "^3.3.4" + "@wordpress/url": "file:../url" }, "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 deleted file mode 100644 index 7b3e5cb312e82..0000000000000 --- a/packages/patterns/src/components/partial-syncing-controls.js +++ /dev/null @@ -1,108 +0,0 @@ -/** - * 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 ]; - const attributeSources = syncedAttributes.map( - ( attributeName ) => - attributes.metadata?.bindings?.[ attributeName ]?.source - ); - const isConnectedToOtherSources = attributeSources.every( - ( source ) => source && source !== 'core/pattern-overrides' - ); - - // Render nothing if all supported attributes are connected to other sources. - if ( isConnectedToOtherSources ) { - return null; - } - - function updateBindings( isChecked ) { - let updatedBindings = { - ...attributes?.metadata?.bindings, - }; - - if ( ! isChecked ) { - for ( const attributeName of syncedAttributes ) { - if ( - updatedBindings[ attributeName ]?.source === - 'core/pattern-overrides' - ) { - delete updatedBindings[ attributeName ]; - } - } - if ( ! Object.keys( updatedBindings ).length ) { - updatedBindings = undefined; - } - setAttributes( { - metadata: { - ...attributes.metadata, - bindings: updatedBindings, - }, - } ); - return; - } - - for ( const attributeName of syncedAttributes ) { - if ( ! updatedBindings[ attributeName ] ) { - updatedBindings[ attributeName ] = { - source: 'core/pattern-overrides', - }; - } - } - - if ( typeof attributes.metadata?.id === 'string' ) { - setAttributes( { - metadata: { - ...attributes.metadata, - bindings: updatedBindings, - }, - } ); - return; - } - - const id = nanoid( 6 ); - setAttributes( { - metadata: { - ...attributes.metadata, - id, - bindings: updatedBindings, - }, - } ); - } - - return ( - - - - { __( 'Pattern overrides' ) } - - source === 'core/pattern-overrides' - ) } - onChange={ ( isChecked ) => { - updateBindings( isChecked ); - } } - /> - - - ); -} - -export default PartialSyncingControls; diff --git a/packages/patterns/src/components/reset-overrides-control.js b/packages/patterns/src/components/reset-overrides-control.js index 586f460835234..1d3ae013addd3 100644 --- a/packages/patterns/src/components/reset-overrides-control.js +++ b/packages/patterns/src/components/reset-overrides-control.js @@ -11,13 +11,13 @@ import { store as coreStore } from '@wordpress/core-data'; import { parse } from '@wordpress/blocks'; import { __ } from '@wordpress/i18n'; -function recursivelyFindBlockWithId( blocks, id ) { +function recursivelyFindBlockWithName( blocks, name ) { for ( const block of blocks ) { - if ( block.attributes.metadata?.id === id ) { + if ( block.attributes.metadata?.name === name ) { return block; } - const found = recursivelyFindBlockWithId( block.innerBlocks, id ); + const found = recursivelyFindBlockWithName( block.innerBlocks, name ); if ( found ) { return found; } @@ -26,10 +26,10 @@ function recursivelyFindBlockWithId( blocks, id ) { export default function ResetOverridesControl( props ) { const registry = useRegistry(); - const id = props.attributes.metadata?.id; + const name = props.attributes.metadata?.name; const patternWithOverrides = useSelect( ( select ) => { - if ( ! id ) { + if ( ! name ) { return undefined; } @@ -39,13 +39,13 @@ export default function ResetOverridesControl( props ) { getBlockParentsByBlockName( props.clientId, 'core/block' ) )[ 0 ]; - if ( ! patternBlock?.attributes.content?.[ id ] ) { + if ( ! patternBlock?.attributes.content?.[ name ] ) { return undefined; } return patternBlock; }, - [ props.clientId, id ] + [ props.clientId, name ] ); const resetOverrides = async () => { @@ -57,7 +57,7 @@ export default function ResetOverridesControl( props ) { patternWithOverrides.attributes.ref ); const blocks = editedRecord.blocks ?? parse( editedRecord.content ); - const block = recursivelyFindBlockWithId( blocks, id ); + const block = recursivelyFindBlockWithName( blocks, name ); const newAttributes = Object.assign( // Reset every existing attribute to undefined. diff --git a/packages/patterns/src/components/use-set-pattern-bindings.js b/packages/patterns/src/components/use-set-pattern-bindings.js new file mode 100644 index 0000000000000..df16d2b2b0591 --- /dev/null +++ b/packages/patterns/src/components/use-set-pattern-bindings.js @@ -0,0 +1,106 @@ +/** + * WordPress dependencies + */ +import { usePrevious } from '@wordpress/compose'; +import { useEffect } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { PARTIAL_SYNCING_SUPPORTED_BLOCKS } from '../constants'; + +function removeBindings( bindings, syncedAttributes ) { + let updatedBindings = {}; + for ( const attributeName of syncedAttributes ) { + // Omit any pattern override bindings from the `updatedBindings` object. + if ( + bindings?.[ attributeName ]?.source !== 'core/pattern-overrides' && + bindings?.[ attributeName ]?.source !== undefined + ) { + updatedBindings[ attributeName ] = bindings[ attributeName ]; + } + } + if ( ! Object.keys( updatedBindings ).length ) { + updatedBindings = undefined; + } + return updatedBindings; +} + +function addBindings( bindings, syncedAttributes ) { + const updatedBindings = { ...bindings }; + for ( const attributeName of syncedAttributes ) { + if ( ! bindings?.[ attributeName ] ) { + updatedBindings[ attributeName ] = { + source: 'core/pattern-overrides', + }; + } + } + return updatedBindings; +} + +export default function useSetPatternBindings( + { name, attributes, setAttributes }, + currentPostType +) { + const metadataName = attributes?.metadata?.name ?? ''; + const prevMetadataName = usePrevious( metadataName ) ?? ''; + const bindings = attributes?.metadata?.bindings; + + useEffect( () => { + // Bindings should only be created when editing a wp_block post type, + // and also when there's a change to the user-given name for the block. + if ( + currentPostType !== 'wp_block' || + metadataName === prevMetadataName + ) { + return; + } + + const syncedAttributes = PARTIAL_SYNCING_SUPPORTED_BLOCKS[ name ]; + const attributeSources = syncedAttributes.map( + ( attributeName ) => + attributes.metadata?.bindings?.[ attributeName ]?.source + ); + const isConnectedToOtherSources = attributeSources.every( + ( source ) => source && source !== 'core/pattern-overrides' + ); + + // Avoid overwriting other (e.g. meta) bindings. + if ( isConnectedToOtherSources ) { + return; + } + + // The user-given name for the block was deleted, remove the bindings. + if ( ! metadataName?.length && prevMetadataName?.length ) { + const updatedBindings = removeBindings( + bindings, + syncedAttributes + ); + setAttributes( { + metadata: { + ...attributes.metadata, + bindings: updatedBindings, + }, + } ); + } + + // The user-given name for the block was set, set the bindings. + if ( ! prevMetadataName?.length && metadataName.length ) { + const updatedBindings = addBindings( bindings, syncedAttributes ); + setAttributes( { + metadata: { + ...attributes.metadata, + bindings: updatedBindings, + }, + } ); + } + }, [ + bindings, + prevMetadataName, + metadataName, + currentPostType, + name, + attributes.metadata, + setAttributes, + ] ); +} diff --git a/packages/patterns/src/private-apis.js b/packages/patterns/src/private-apis.js index a5fbddb62fd62..54ad5a4aa47d1 100644 --- a/packages/patterns/src/private-apis.js +++ b/packages/patterns/src/private-apis.js @@ -13,7 +13,7 @@ import { 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 useSetPatternBindings from './components/use-set-pattern-bindings'; import ResetOverridesControl from './components/reset-overrides-control'; import { useAddPatternCategory } from './private-hooks'; import { @@ -34,7 +34,7 @@ lock( privateApis, { RenamePatternModal, PatternsMenuItems, RenamePatternCategoryModal, - PartialSyncingControls, + useSetPatternBindings, ResetOverridesControl, useAddPatternCategory, PATTERN_TYPES, diff --git a/packages/rich-text/src/create.js b/packages/rich-text/src/create.js index fce6d5a55b202..3629ff8c60a74 100644 --- a/packages/rich-text/src/create.js +++ b/packages/rich-text/src/create.js @@ -472,11 +472,9 @@ function createFromElement( { element, range, isEditableTree } ) { if ( isEditableTree && - // Ignore any placeholders. - ( node.getAttribute( 'data-rich-text-placeholder' ) || - // Ignore any line breaks that are not inserted by us. - ( tagName === 'br' && - ! node.getAttribute( 'data-rich-text-line-break' ) ) ) + // Ignore any line breaks that are not inserted by us. + tagName === 'br' && + ! node.getAttribute( 'data-rich-text-line-break' ) ) { accumulateSelection( accumulator, node, range, createEmptyValue() ); continue; @@ -541,7 +539,9 @@ function createFromElement( { element, range, isEditableTree } ) { accumulateSelection( accumulator, node, range, value ); - if ( ! format ) { + // Ignore any placeholders, but keep their content since the browser + // might insert text inside them when the editable element is flex. + if ( ! format || node.getAttribute( 'data-rich-text-placeholder' ) ) { mergePair( accumulator, value ); } else if ( value.text.length === 0 ) { if ( format.attributes ) { diff --git a/test/e2e/specs/editor/blocks/list.spec.js b/test/e2e/specs/editor/blocks/list.spec.js index 48d50a862a50b..10f25d6b3609f 100644 --- a/test/e2e/specs/editor/blocks/list.spec.js +++ b/test/e2e/specs/editor/blocks/list.spec.js @@ -8,6 +8,36 @@ test.describe( 'List (@firefox)', () => { await admin.createNewPost(); } ); + test( 'can be copied from multi selection', async ( { + editor, + page, + pageUtils, + } ) => { + await editor.insertBlock( { name: 'core/list' } ); + await page.keyboard.type( 'one' ); + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( 'two' ); + await pageUtils.pressKeys( 'primary+a' ); + await pageUtils.pressKeys( 'primary+a' ); + await pageUtils.pressKeys( 'primary+c' ); + await editor.insertBlock( { name: 'core/paragraph' } ); + await pageUtils.pressKeys( 'primary+v' ); + + const copied = ` +
    +
  • one
  • + + + +
  • two
  • +
+`; + + expect( await editor.getEditedPostContent() ).toBe( + copied + '\n\n' + copied + ); + } ); + test( 'can be created by using an asterisk at the start of a paragraph block', async ( { editor, page, diff --git a/test/e2e/specs/editor/various/block-bindings.spec.js b/test/e2e/specs/editor/various/block-bindings.spec.js index fc315e522b81a..8cd3423290e3f 100644 --- a/test/e2e/specs/editor/various/block-bindings.spec.js +++ b/test/e2e/specs/editor/various/block-bindings.spec.js @@ -47,7 +47,7 @@ test.describe( 'Block bindings', () => { } ); test.describe( 'Paragraph', () => { - test( 'Should show the value of the custom field', async ( { + test( 'should show the value of the custom field', async ( { editor, } ) => { await editor.insertBlock( { @@ -72,7 +72,7 @@ test.describe( 'Block bindings', () => { ); } ); - test( 'Should lock the appropriate controls with a registered source', async ( { + test( 'should lock the appropriate controls with a registered source', async ( { editor, page, } ) => { @@ -118,7 +118,7 @@ test.describe( 'Block bindings', () => { ); } ); - test( 'Should lock the appropriate controls when source is not defined', async ( { + test( 'should lock the appropriate controls when source is not defined', async ( { editor, page, } ) => { @@ -166,7 +166,7 @@ test.describe( 'Block bindings', () => { } ); test.describe( 'Heading', () => { - test( 'Should show the key of the custom field', async ( { + test( 'should show the key of the custom field', async ( { editor, } ) => { await editor.insertBlock( { @@ -189,7 +189,7 @@ test.describe( 'Block bindings', () => { await expect( headingBlock ).toHaveText( 'text_custom_field' ); } ); - test( 'Should lock the appropriate controls with a registered source', async ( { + test( 'should lock the appropriate controls with a registered source', async ( { editor, page, } ) => { @@ -235,7 +235,7 @@ test.describe( 'Block bindings', () => { ); } ); - test( 'Should lock the appropriate controls when source is not defined', async ( { + test( 'should lock the appropriate controls when source is not defined', async ( { editor, page, } ) => { @@ -283,7 +283,7 @@ test.describe( 'Block bindings', () => { } ); test.describe( 'Button', () => { - test( 'Should show the key of the custom field when text is bound', async ( { + test( 'should show the key of the custom field when text is bound', async ( { editor, } ) => { await editor.insertBlock( { @@ -313,7 +313,7 @@ test.describe( 'Block bindings', () => { await expect( buttonBlock ).toHaveText( 'text_custom_field' ); } ); - test( 'Should lock text controls when text is bound to a registered source', async ( { + test( 'should lock text controls when text is bound to a registered source', async ( { editor, page, } ) => { @@ -375,7 +375,7 @@ test.describe( 'Block bindings', () => { ).toBeVisible(); } ); - test( 'Should lock text controls when text is bound to an undefined source', async ( { + test( 'should lock text controls when text is bound to an undefined source', async ( { editor, page, } ) => { @@ -437,7 +437,7 @@ test.describe( 'Block bindings', () => { ).toBeVisible(); } ); - test( 'Should lock url controls when url is bound to a registered source', async ( { + test( 'should lock url controls when url is bound to a registered source', async ( { editor, page, } ) => { @@ -497,7 +497,7 @@ test.describe( 'Block bindings', () => { ).toBeHidden(); } ); - test( 'Should lock url controls when url is bound to an undefined source', async ( { + test( 'should lock url controls when url is bound to an undefined source', async ( { editor, page, } ) => { @@ -557,7 +557,7 @@ test.describe( 'Block bindings', () => { ).toBeHidden(); } ); - test( 'Should lock url and text controls when both are bound', async ( { + test( 'should lock url and text controls when both are bound', async ( { editor, page, } ) => { @@ -630,7 +630,7 @@ test.describe( 'Block bindings', () => { } ); test.describe( 'Image', () => { - test( 'Should show the upload form when url is not bound', async ( { + test( 'should show the upload form when url is not bound', async ( { editor, } ) => { await editor.insertBlock( { name: 'core/image' } ); @@ -643,7 +643,7 @@ test.describe( 'Block bindings', () => { ).toBeVisible(); } ); - test( 'Should NOT show the upload form when url is bound to a registered source', async ( { + test( 'should NOT show the upload form when url is bound to a registered source', async ( { editor, } ) => { await editor.insertBlock( { @@ -671,7 +671,7 @@ test.describe( 'Block bindings', () => { ).toBeHidden(); } ); - test( 'Should NOT show the upload form when url is bound to an undefined source', async ( { + test( 'should NOT show the upload form when url is bound to an undefined source', async ( { editor, } ) => { await editor.insertBlock( { @@ -699,7 +699,7 @@ test.describe( 'Block bindings', () => { ).toBeHidden(); } ); - test( 'Should lock url controls when url is bound to a registered source', async ( { + test( 'should lock url controls when url is bound to a registered source', async ( { editor, page, } ) => { @@ -768,7 +768,7 @@ test.describe( 'Block bindings', () => { expect( titleValue ).toBe( 'default title value' ); } ); - test( 'Should lock url controls when url is bound to an undefined source', async ( { + test( 'should lock url controls when url is bound to an undefined source', async ( { editor, page, } ) => { @@ -837,7 +837,7 @@ test.describe( 'Block bindings', () => { expect( titleValue ).toBe( 'default title value' ); } ); - test( 'Should disable alt textarea when alt is bound to a registered source', async ( { + test( 'should disable alt textarea when alt is bound to a registered source', async ( { editor, page, } ) => { @@ -900,7 +900,7 @@ test.describe( 'Block bindings', () => { expect( titleValue ).toBe( 'default title value' ); } ); - test( 'Should disable alt textarea when alt is bound to an undefined source', async ( { + test( 'should disable alt textarea when alt is bound to an undefined source', async ( { editor, page, } ) => { @@ -963,7 +963,7 @@ test.describe( 'Block bindings', () => { expect( titleValue ).toBe( 'default title value' ); } ); - test( 'Should disable title input when title is bound to a registered source', async ( { + test( 'should disable title input when title is bound to a registered source', async ( { editor, page, } ) => { @@ -1026,7 +1026,7 @@ test.describe( 'Block bindings', () => { expect( titleValue ).toBe( 'text_custom_field' ); } ); - test( 'Should disable title input when title is bound to an undefined source', async ( { + test( 'should disable title input when title is bound to an undefined source', async ( { editor, page, } ) => { @@ -1168,7 +1168,7 @@ test.describe( 'Block bindings', () => { await admin.createNewPost( { title: 'Test bindings' } ); } ); test.describe( 'Paragraph', () => { - test( 'Should show the value of the custom field when exists', async ( { + test( 'should show the value of the custom field when exists', async ( { editor, page, } ) => { @@ -1210,7 +1210,7 @@ test.describe( 'Block bindings', () => { ); } ); - test( "Should show the value of the key when custom field doesn't exists", async ( { + test( "should show the value of the key when custom field doesn't exist", async ( { editor, page, } ) => { @@ -1218,7 +1218,7 @@ test.describe( 'Block bindings', () => { name: 'core/paragraph', attributes: { anchor: 'paragraph-binding', - content: 'p', + content: 'fallback value', metadata: { bindings: { content: { @@ -1244,54 +1244,192 @@ test.describe( 'Block bindings', () => { // Check the frontend doesn't show the content. const postId = await editor.publishPost(); await page.goto( `/?p=${ postId }` ); - await expect( - page.locator( '#paragraph-binding' ) - ).toBeHidden(); + await expect( page.locator( '#paragraph-binding' ) ).toHaveText( + 'fallback value' + ); + } ); + + test( 'should not show the value of a protected meta field', async ( { + editor, + page, + } ) => { + await editor.insertBlock( { + name: 'core/paragraph', + attributes: { + anchor: 'paragraph-binding', + content: 'fallback value', + metadata: { + bindings: { + content: { + source: 'core/post-meta', + args: { key: '_protected_field' }, + }, + }, + }, + }, + } ); + const paragraphBlock = editor.canvas.getByRole( 'document', { + name: 'Block: Paragraph', + } ); + await expect( paragraphBlock ).toHaveText( '_protected_field' ); + // Check the frontend doesn't show the content. + const postId = await editor.publishPost(); + await page.goto( `/?p=${ postId }` ); + await expect( page.locator( '#paragraph-binding' ) ).toHaveText( + 'fallback value' + ); + } ); + + test( 'should not show the value of a meta field with `show_in_rest` false', async ( { + editor, + page, + } ) => { + await editor.insertBlock( { + name: 'core/paragraph', + attributes: { + anchor: 'paragraph-binding', + content: 'fallback value', + metadata: { + bindings: { + content: { + source: 'core/post-meta', + args: { key: 'show_in_rest_false_field' }, + }, + }, + }, + }, + } ); + const paragraphBlock = editor.canvas.getByRole( 'document', { + name: 'Block: Paragraph', + } ); + await expect( paragraphBlock ).toHaveText( + 'show_in_rest_false_field' + ); + // Check the frontend doesn't show the content. + const postId = await editor.publishPost(); + await page.goto( `/?p=${ postId }` ); + await expect( page.locator( '#paragraph-binding' ) ).toHaveText( + 'fallback value' + ); + } ); + + test( 'should add empty paragraph block when pressing enter', async ( { + editor, + page, + } ) => { + await editor.insertBlock( { + name: 'core/paragraph', + attributes: { + content: 'paragraph default content', + metadata: { + bindings: { + content: { + source: 'core/post-meta', + args: { key: 'text_custom_field' }, + }, + }, + }, + }, + } ); + await page.keyboard.press( 'Enter' ); + const [ initialParagraph, newEmptyParagraph ] = + await editor.canvas + .locator( '[data-type="core/paragraph"]' ) + .all(); + await expect( initialParagraph ).toHaveText( + 'Value of the text_custom_field' + ); + await expect( newEmptyParagraph ).toHaveText( '' ); + await expect( newEmptyParagraph ).toBeEditable(); } ); } ); - test( 'Heading - should show the value of the custom field', async ( { - editor, - page, - } ) => { - await editor.insertBlock( { - name: 'core/heading', - attributes: { - anchor: 'heading-binding', - content: 'heading default content', - metadata: { - bindings: { - content: { - source: 'core/post-meta', - args: { key: 'text_custom_field' }, + test.describe( 'Heading', () => { + test( 'should show the value of the custom field', async ( { + editor, + page, + } ) => { + await editor.insertBlock( { + name: 'core/heading', + attributes: { + anchor: 'heading-binding', + content: 'heading default content', + metadata: { + bindings: { + content: { + source: 'core/post-meta', + args: { key: 'text_custom_field' }, + }, }, }, }, - }, + } ); + const headingBlock = editor.canvas.getByRole( 'document', { + name: 'Block: Heading', + } ); + await expect( headingBlock ).toHaveText( + 'Value of the text_custom_field' + ); + // Heading is not editable. + await expect( headingBlock ).toHaveAttribute( + 'contenteditable', + 'false' + ); + + // Check the frontend shows the value of the custom field. + const postId = await editor.publishPost(); + await page.goto( `/?p=${ postId }` ); + await expect( + page.locator( '#heading-binding' ) + ).toBeVisible(); + await expect( page.locator( '#heading-binding' ) ).toHaveText( + 'Value of the text_custom_field' + ); } ); - const headingBlock = editor.canvas.getByRole( 'document', { - name: 'Block: Heading', + + test( 'should add empty paragraph block when pressing enter', async ( { + editor, + page, + } ) => { + await editor.insertBlock( { + name: 'core/heading', + attributes: { + anchor: 'heading-binding', + content: 'heading default content', + metadata: { + bindings: { + content: { + source: 'core/post-meta', + args: { key: 'text_custom_field' }, + }, + }, + }, + }, + } ); + await page.keyboard.press( 'Enter' ); + // Can't use `editor.getBlocks` because it doesn't return the meta value shown in the editor. + const [ initialHeading, newEmptyParagraph ] = + await editor.canvas.locator( '[data-block]' ).all(); + // First block should be the original block. + await expect( initialHeading ).toHaveAttribute( + 'data-type', + 'core/heading' + ); + await expect( initialHeading ).toHaveText( + 'Value of the text_custom_field' + ); + // Second block should be an empty paragraph block. + await expect( newEmptyParagraph ).toHaveAttribute( + 'data-type', + 'core/paragraph' + ); + await expect( newEmptyParagraph ).toHaveText( '' ); + await expect( newEmptyParagraph ).toBeEditable(); } ); - await expect( headingBlock ).toHaveText( - 'Value of the text_custom_field' - ); - // Heading is not editable. - await expect( headingBlock ).toHaveAttribute( - 'contenteditable', - 'false' - ); - - // Check the frontend shows the value of the custom field. - const postId = await editor.publishPost(); - await page.goto( `/?p=${ postId }` ); - await expect( page.locator( '#heading-binding' ) ).toBeVisible(); - await expect( page.locator( '#heading-binding' ) ).toHaveText( - 'Value of the text_custom_field' - ); } ); test.describe( 'Button', () => { - test( 'Should show the value of the custom field when text is bound', async ( { + test( 'should show the value of the custom field when text is bound', async ( { editor, page, } ) => { @@ -1347,7 +1485,7 @@ test.describe( 'Block bindings', () => { ); } ); - test( 'Should use the value of the custom field when url is bound', async ( { + test( 'should use the value of the custom field when url is bound', async ( { editor, page, } ) => { @@ -1385,7 +1523,7 @@ test.describe( 'Block bindings', () => { ); } ); - test( 'Should use the values of the custom fields when text and url are bound', async ( { + test( 'should use the values of the custom fields when text and url are bound', async ( { editor, page, } ) => { @@ -1428,6 +1566,51 @@ test.describe( 'Block bindings', () => { '#url-custom-field' ); } ); + + test( 'should add empty button block when pressing enter', async ( { + editor, + page, + } ) => { + await editor.insertBlock( { + name: 'core/buttons', + innerBlocks: [ + { + name: 'core/button', + attributes: { + anchor: 'button-text-binding', + text: 'button default text', + url: '#default-url', + metadata: { + bindings: { + text: { + source: 'core/post-meta', + args: { key: 'text_custom_field' }, + }, + }, + }, + }, + }, + ], + } ); + await editor.canvas + .getByRole( 'document', { + name: 'Block: Button', + exact: true, + } ) + .getByRole( 'textbox' ) + .click(); + await page.keyboard.press( 'Enter' ); + const [ initialButton, newEmptyButton ] = await editor.canvas + .locator( '[data-type="core/button"]' ) + .all(); + // First block should be the original block. + await expect( initialButton ).toHaveText( + 'Value of the text_custom_field' + ); + // Second block should be an empty paragraph block. + await expect( newEmptyButton ).toHaveText( '' ); + await expect( newEmptyButton ).toBeEditable(); + } ); } ); test.describe( 'Image', () => { @@ -1454,7 +1637,7 @@ test.describe( 'Block bindings', () => { } ); await page.reload(); } ); - test( 'Should show the value of the custom field when url is bound', async ( { + test( 'should show the value of the custom field when url is bound', async ( { editor, page, BlockBindingsUtils, @@ -1505,7 +1688,7 @@ test.describe( 'Block bindings', () => { ); } ); - test( 'Should show value of the custom field in the alt textarea when alt is bound', async ( { + test( 'should show value of the custom field in the alt textarea when alt is bound', async ( { editor, page, BlockBindingsUtils, @@ -1571,7 +1754,7 @@ test.describe( 'Block bindings', () => { ); } ); - test( 'Should show value of the custom field in the title input when title is bound', async ( { + test( 'should show value of the custom field in the title input when title is bound', async ( { editor, page, BlockBindingsUtils, diff --git a/test/e2e/specs/editor/various/pattern-overrides.spec.js b/test/e2e/specs/editor/various/pattern-overrides.spec.js index 86d7b9117bef5..4542e8c789ad1 100644 --- a/test/e2e/specs/editor/various/pattern-overrides.spec.js +++ b/test/e2e/specs/editor/various/pattern-overrides.spec.js @@ -32,7 +32,7 @@ test.describe( 'Pattern Overrides', () => { editor, } ) => { let patternId; - let editableParagraphId; + const editableParagraphName = 'Editable Paragraph'; await test.step( 'Create a synced pattern and assign blocks to allow overrides', async () => { await admin.visitSiteEditor( { path: '/patterns' } ); @@ -85,8 +85,8 @@ test.describe( 'Pattern Overrides', () => { await advancedPanel.click(); } await editorSettings - .getByRole( 'checkbox', { name: 'Allow instance overrides' } ) - .setChecked( true ); + .getByRole( 'textbox', { name: 'Block Name' } ) + .fill( editableParagraphName ); await expect.poll( editor.getBlocks ).toMatchObject( [ { @@ -94,7 +94,7 @@ test.describe( 'Pattern Overrides', () => { attributes: { content: 'This paragraph can be edited', metadata: { - id: expect.any( String ), + name: editableParagraphName, bindings: { content: { source: 'core/pattern-overrides', @@ -123,8 +123,6 @@ test.describe( 'Pattern Overrides', () => { ).toBeVisible(); patternId = new URL( page.url() ).searchParams.get( 'postId' ); - const blocks = await editor.getBlocks(); - editableParagraphId = blocks[ 0 ].attributes.metadata.id; } ); await test.step( 'Create a post and insert the pattern with overrides', async () => { @@ -176,10 +174,8 @@ test.describe( 'Pattern Overrides', () => { attributes: { ref: patternId, content: { - [ editableParagraphId ]: { - values: { - content: 'I would word it this way', - }, + [ editableParagraphName ]: { + content: 'I would word it this way', }, }, }, @@ -189,10 +185,8 @@ test.describe( 'Pattern Overrides', () => { attributes: { ref: patternId, content: { - [ editableParagraphId ]: { - values: { - content: 'This one is different', - }, + [ editableParagraphName ]: { + content: 'This one is different', }, }, }, @@ -276,11 +270,11 @@ test.describe( 'Pattern Overrides', () => { editor, context, } ) => { - const buttonId = 'button-id'; + const buttonName = 'Editable button'; const { id } = await requestUtils.createBlock( { title: 'Button with target', content: ` -
+ `, @@ -386,21 +380,21 @@ test.describe( 'Pattern Overrides', () => { requestUtils, editor, } ) => { - const paragraphId = 'paragraph-id'; - const headingId = 'heading-id'; + const paragraphName = 'Editable paragraph'; + const headingName = 'Editable heading'; const innerPattern = await requestUtils.createBlock( { title: 'Inner Pattern', - content: ` + content: `

Inner paragraph

`, status: 'publish', } ); const outerPattern = await requestUtils.createBlock( { title: 'Outer Pattern', - content: ` + content: `

Outer heading

-`, +`, status: 'publish', } ); @@ -425,8 +419,8 @@ test.describe( 'Pattern Overrides', () => { attributes: { ref: outerPattern.id, content: { - [ headingId ]: { - values: { content: 'Outer heading (edited)' }, + [ headingName ]: { + content: 'Outer heading (edited)', }, }, }, @@ -440,10 +434,8 @@ test.describe( 'Pattern Overrides', () => { attributes: { ref: innerPattern.id, content: { - [ paragraphId ]: { - values: { - content: 'Inner paragraph (edited)', - }, + [ paragraphName ]: { + content: 'Inner paragraph (edited)', }, }, }, @@ -505,14 +497,14 @@ test.describe( 'Pattern Overrides', () => { requestUtils, editor, } ) => { - const headingId = 'heading-id'; - const paragraphId = 'paragraph-id'; + const headingName = 'Editable heading'; + const paragraphName = 'Editable paragraph'; const { id } = await requestUtils.createBlock( { title: 'Pattern', - content: ` + content: `

Heading

- +

Paragraph

`, status: 'publish', @@ -597,14 +589,14 @@ test.describe( 'Pattern Overrides', () => { requestUtils, editor, } ) => { - const imageId = 'image-id'; + const imageName = 'Editable image'; const TEST_IMAGE_FILE_PATH = path.resolve( __dirname, '../../../assets/10x10_e2e_test_image_z9T8jK.png' ); const { id } = await requestUtils.createBlock( { title: 'Pattern', - content: ` + content: `
`, status: 'publish', diff --git a/test/integration/fixtures/blocks/core__block__overrides__deprecated-1.html b/test/integration/fixtures/blocks/core__block__overrides__deprecated-1.html new file mode 100644 index 0000000000000..5b7b2cdf95cfb --- /dev/null +++ b/test/integration/fixtures/blocks/core__block__overrides__deprecated-1.html @@ -0,0 +1 @@ + diff --git a/test/integration/fixtures/blocks/core__block__overrides__deprecated-1.json b/test/integration/fixtures/blocks/core__block__overrides__deprecated-1.json new file mode 100644 index 0000000000000..3f0292c83ac35 --- /dev/null +++ b/test/integration/fixtures/blocks/core__block__overrides__deprecated-1.json @@ -0,0 +1,15 @@ +[ + { + "name": "core/block", + "isValid": true, + "attributes": { + "ref": 123, + "content": { + "V98q_x": { + "content": "Some value" + } + } + }, + "innerBlocks": [] + } +] diff --git a/test/integration/fixtures/blocks/core__block__overrides__deprecated-1.parsed.json b/test/integration/fixtures/blocks/core__block__overrides__deprecated-1.parsed.json new file mode 100644 index 0000000000000..2cf2ac3797445 --- /dev/null +++ b/test/integration/fixtures/blocks/core__block__overrides__deprecated-1.parsed.json @@ -0,0 +1,16 @@ +[ + { + "blockName": "core/block", + "attrs": { + "ref": 123, + "overrides": { + "V98q_x": { + "content": "Some value" + } + } + }, + "innerBlocks": [], + "innerHTML": "", + "innerContent": [] + } +] diff --git a/test/integration/fixtures/blocks/core__block__overrides__deprecated-1.serialized.html b/test/integration/fixtures/blocks/core__block__overrides__deprecated-1.serialized.html new file mode 100644 index 0000000000000..3d91f47859d01 --- /dev/null +++ b/test/integration/fixtures/blocks/core__block__overrides__deprecated-1.serialized.html @@ -0,0 +1 @@ + diff --git a/test/integration/fixtures/blocks/core__block__overrides__deprecated-2.html b/test/integration/fixtures/blocks/core__block__overrides__deprecated-2.html new file mode 100644 index 0000000000000..d75c773c630ef --- /dev/null +++ b/test/integration/fixtures/blocks/core__block__overrides__deprecated-2.html @@ -0,0 +1 @@ + diff --git a/test/integration/fixtures/blocks/core__block__overrides__deprecated-2.json b/test/integration/fixtures/blocks/core__block__overrides__deprecated-2.json new file mode 100644 index 0000000000000..3f0292c83ac35 --- /dev/null +++ b/test/integration/fixtures/blocks/core__block__overrides__deprecated-2.json @@ -0,0 +1,15 @@ +[ + { + "name": "core/block", + "isValid": true, + "attributes": { + "ref": 123, + "content": { + "V98q_x": { + "content": "Some value" + } + } + }, + "innerBlocks": [] + } +] diff --git a/test/integration/fixtures/blocks/core__block__overrides__deprecated-2.parsed.json b/test/integration/fixtures/blocks/core__block__overrides__deprecated-2.parsed.json new file mode 100644 index 0000000000000..41cd1dc9afd44 --- /dev/null +++ b/test/integration/fixtures/blocks/core__block__overrides__deprecated-2.parsed.json @@ -0,0 +1,18 @@ +[ + { + "blockName": "core/block", + "attrs": { + "ref": 123, + "content": { + "V98q_x": { + "values": { + "content": "Some value" + } + } + } + }, + "innerBlocks": [], + "innerHTML": "", + "innerContent": [] + } +] diff --git a/test/integration/fixtures/blocks/core__block__overrides__deprecated-2.serialized.html b/test/integration/fixtures/blocks/core__block__overrides__deprecated-2.serialized.html new file mode 100644 index 0000000000000..3d91f47859d01 --- /dev/null +++ b/test/integration/fixtures/blocks/core__block__overrides__deprecated-2.serialized.html @@ -0,0 +1 @@ + diff --git a/test/performance/specs/site-editor.spec.js b/test/performance/specs/site-editor.spec.js index 458d41cbeb9a0..89e8d9d347776 100644 --- a/test/performance/specs/site-editor.spec.js +++ b/test/performance/specs/site-editor.spec.js @@ -228,11 +228,32 @@ test.describe( 'Site Editor Performance', () => { canvas: 'edit', } ); await editor.openDocumentSettingsSidebar(); - await page - .getByRole( 'button', { - name: 'Actions', - } ) - .click(); + + /* + * https://github.com/WordPress/gutenberg/pull/55091 updated the HTML by + * removing the replace template button in sidebar-edit-mode/template-panel/replace-template-button.js + * with a "transform into" list. https://github.com/WordPress/gutenberg/pull/59259 made these tests + * compatible with the new UI, however, the performance tests compare previous versions of the UI. + * + * The following code is a workaround to test the performance of the new UI. + * `actionsButtonElement` is used to check if the old UI is present. + * If there is a Replace template button (old UI), click it, otherwise, click the "transform into" button. + * Once the performance tests are updated to compare compatible versions this code can be removed. + */ + // eslint-disable-next-line no-restricted-syntax + const isActionsButtonVisible = await page + .locator( + '.edit-site-template-card__actions button[aria-label="Actions"]' + ) + .isVisible(); + + if ( isActionsButtonVisible ) { + await page + .getByRole( 'button', { + name: 'Actions', + } ) + .click(); + } // Wait for the browser to be idle before starting the monitoring. // eslint-disable-next-line no-restricted-syntax @@ -240,9 +261,15 @@ test.describe( 'Site Editor Performance', () => { const startTime = performance.now(); - await page - .getByRole( 'menuitem', { name: 'Replace template' } ) - .click(); + if ( isActionsButtonVisible ) { + await page + .getByRole( 'menuitem', { name: 'Replace template' } ) + .click(); + } else { + await page + .getByRole( 'button', { name: 'Transform into:' } ) + .click(); + } const patterns = [ 'Blogging home template',