diff --git a/lib/compat/wordpress-7.1/block-bindings.php b/lib/compat/wordpress-7.1/block-bindings.php index e0f4aad4075962..42e3696ec57ed5 100644 --- a/lib/compat/wordpress-7.1/block-bindings.php +++ b/lib/compat/wordpress-7.1/block-bindings.php @@ -7,7 +7,7 @@ * @subpackage Block Bindings */ -// The following filter can be removed once the minimum required WordPress version is 7.1 or newer. +// The following filters can be removed once the minimum required WordPress version is 7.1 or newer. add_filter( 'block_bindings_supported_attributes', function ( $attributes, $block_type ) { @@ -20,6 +20,16 @@ function ( $attributes, $block_type ) { 2 ); +add_filter( + 'block_bindings_supported_attributes_core/media-text', + function ( $supported_attributes ) { + $supported_attributes[] = 'mediaUrl'; + $supported_attributes[] = 'mediaAlt'; + $supported_attributes[] = 'mediaId'; + return $supported_attributes; + } +); + /* * On WordPress versions before 7.1, `WP_Block::replace_html()` lacks the * inner-blocks fix (wordpress-develop#12113), so binding a List Item's content diff --git a/packages/block-library/src/media-text/index.php b/packages/block-library/src/media-text/index.php index fef37cdd8db565..48e689cb1d58d0 100644 --- a/packages/block-library/src/media-text/index.php +++ b/packages/block-library/src/media-text/index.php @@ -17,7 +17,97 @@ */ function render_block_core_media_text( $attributes, $content ) { if ( false === $attributes['useFeaturedImage'] ) { - return $content; + $media_bindings = $attributes['metadata']['bindings'] ?? array(); + if ( empty( $media_bindings['mediaUrl'] ) ) { + return $content; + } + + /* + * The `mediaUrl` and `mediaAlt` attributes are bound to a block bindings + * source. `WP_Block::replace_html()` cannot match the block's descendant + * selectors (e.g. `figure img`), so update the media element markup here + * with the resolved values. + */ + $media_url = $attributes['mediaUrl'] ?? null; + if ( '' === $media_url ) { + return $content; + } + + $media_type = $attributes['mediaType'] ?? 'image'; + $is_video = 'video' === $media_type; + $media_alt = $attributes['mediaAlt'] ?? ''; + $has_media_on_right = 'right' === ( $attributes['mediaPosition'] ?? null ); + $figure_query = array( + 'tag_name' => 'figure', + 'class_name' => 'wp-block-media-text__media', + ); + + /* + * Locate this block's media figure, accounting for `media-position: right` + * where the media column follows the content column. Returns a processor + * positioned on the figure, or null if no media figure is found. + */ + $find_media_figure = static function ( $block_content ) use ( $has_media_on_right, $figure_query ) { + $processor = new WP_HTML_Tag_Processor( $block_content ); + $found = false; + while ( $processor->next_tag( $figure_query ) ) { + $found = true; + $processor->set_bookmark( 'media_figure' ); + if ( ! $has_media_on_right ) { + break; + } + } + if ( ! $found || ! $processor->seek( 'media_figure' ) ) { + return null; + } + return $processor; + }; + + $media_processor = $find_media_figure( $content ); + if ( null === $media_processor ) { + return $content; + } + + if ( $media_processor->next_tag( array( 'tag_name' => $is_video ? 'video' : 'img' ) ) ) { + // Update an existing media element in the saved markup. + $media_processor->set_attribute( 'src', $media_url ); + if ( ! $is_video && isset( $media_bindings['mediaAlt'] ) ) { + $media_processor->set_attribute( 'alt', $media_alt ); + } + + return $media_processor->get_updated_html(); + } + + // The media figure is empty (e.g. the pattern was saved without an image), + // so build the media element from the bound attributes and insert it. + $media_processor->seek( 'media_figure' ); + $unique_id = 'wp-block-media-text__media-' . wp_unique_id(); + $media_processor->set_attribute( 'id', $unique_id ); + + $media_tag = $is_video + ? '' + : '' . esc_attr( $media_alt ) . ''; + + $content = $media_processor->get_updated_html(); + // Insert the media element right after the figure opening tag, then remove + // the temporary id used to target it. + $content = preg_replace( + '/()/', + '$1' . $media_tag, + $content + ); + + $cleanup_processor = new WP_HTML_Tag_Processor( $content ); + if ( $cleanup_processor->next_tag( + array( + 'tag_name' => 'figure', + 'id' => $unique_id, + ) + ) ) { + $cleanup_processor->remove_attribute( 'id' ); + } + + return $cleanup_processor->get_updated_html(); } if ( in_the_loop() ) { diff --git a/packages/block-library/src/media-text/media-container.js b/packages/block-library/src/media-text/media-container.js index af0d3ec59a2e16..d78e0d820cf015 100644 --- a/packages/block-library/src/media-text/media-container.js +++ b/packages/block-library/src/media-text/media-container.js @@ -134,6 +134,11 @@ function MediaContainer( props, ref ) { const { toggleSelection } = useDispatch( blockEditorStore ); if ( mediaUrl || featuredImageURL || useFeaturedImage ) { + // `mediaType` may be unset when the media comes from a block binding + // (e.g. a pattern override) rather than a media library selection. + // Default to rendering an image so the bound media is visible in the editor. + const mediaTypeToRender = mediaType || 'image'; + const onResizeStart = () => { toggleSelection( false ); }; @@ -207,7 +212,7 @@ function MediaContainer( props, ref ) { toggleUseFeaturedImage={ toggleUseFeaturedImage } useFeaturedImage={ useFeaturedImage } /> - { ( mediaTypeRenderers[ mediaType ] || noop )() } + { ( mediaTypeRenderers[ mediaTypeToRender ] || noop )() } { isTemporaryMedia && } { ! useFeaturedImage && } { ! featuredImageURL && useFeaturedImage && ( diff --git a/phpunit/block-bindings-test.php b/phpunit/block-bindings-test.php index 99f5b901ac8813..2704aadc2c51a1 100644 --- a/phpunit/block-bindings-test.php +++ b/phpunit/block-bindings-test.php @@ -316,6 +316,133 @@ public function test_update_block_with_value_from_source_image_placeholder() { ); } + /** + * Tests if the block content is updated with the value returned by the source + * for the Media & Text block, whose supported attributes are added via the + * `block_bindings_supported_attributes_core/media-text` filter. + * + * @covers ::register_block_bindings_source + */ + public function test_update_block_with_value_from_source_media_text() { + $get_value_callback = function ( $source_args, $block_instance, $attribute_name ) { + if ( 'mediaUrl' === $attribute_name ) { + return 'https://example.com/updated-image.jpg'; + } + if ( 'mediaAlt' === $attribute_name ) { + return 'Updated alt text'; + } + }; + + register_block_bindings_source( + self::SOURCE_NAME, + array( + 'label' => self::SOURCE_LABEL, + 'get_value_callback' => $get_value_callback, + ) + ); + + $this->assertContains( + 'mediaUrl', + get_block_bindings_supported_attributes( 'core/media-text' ), + 'The mediaUrl attribute should be supported for block bindings.' + ); + $this->assertContains( + 'mediaAlt', + get_block_bindings_supported_attributes( 'core/media-text' ), + 'The mediaAlt attribute should be supported for block bindings.' + ); + $this->assertContains( + 'mediaId', + get_block_bindings_supported_attributes( 'core/media-text' ), + 'The mediaId attribute should be supported for block bindings.' + ); + + $block_content = << +
original alt

+ +HTML; + $parsed_blocks = parse_blocks( $block_content ); + $block = new WP_Block( $parsed_blocks[0] ); + $result = $block->render(); + + $this->assertSame( + 'https://example.com/updated-image.jpg', + $block->attributes['mediaUrl'], + "The 'mediaUrl' attribute should be updated with the value returned by the source." + ); + $this->assertSame( + 'Updated alt text', + $block->attributes['mediaAlt'], + "The 'mediaAlt' attribute should be updated with the value returned by the source." + ); + $this->assertStringContainsString( + 'Updated alt text', + $result, + 'The block content should be updated with the value returned by the source.' + ); + } + + public function data_update_block_with_value_from_source_media_text_empty_figure() { + return array( + 'media on the left' => array( + 'left', + '

', + ), + 'media on the right' => array( + 'right', + '

', + ), + ); + } + + /** + * Tests that a Media & Text block saved without media renders the image + * bound via block bindings, for both media positions. + * + * @covers ::register_block_bindings_source + * + * @dataProvider data_update_block_with_value_from_source_media_text_empty_figure + */ + public function test_update_block_with_value_from_source_media_text_empty_figure( $media_position, $block_markup ) { + $get_value_callback = function ( $source_args, $block_instance, $attribute_name ) { + if ( 'mediaUrl' === $attribute_name ) { + return 'https://example.com/updated-image.jpg'; + } + if ( 'mediaAlt' === $attribute_name ) { + return 'Updated alt text'; + } + }; + + register_block_bindings_source( + self::SOURCE_NAME, + array( + 'label' => self::SOURCE_LABEL, + 'get_value_callback' => $get_value_callback, + ) + ); + + $block_content = << +{$block_markup} + +HTML; + $parsed_blocks = parse_blocks( $block_content ); + $block = new WP_Block( $parsed_blocks[0] ); + $result = $block->render(); + + $this->assertStringContainsString( + 'Updated alt text', + $result, + 'The block should render the bound image in an empty media figure.' + ); + $this->assertStringNotContainsString( + 'id="wp-block-media-text__media-', + $result, + 'The temporary figure id used to insert the media should be removed.' + ); + } + /** * Tests if the `__default` attribute is replaced with real attributes for * pattern overrides.