diff --git a/backport-changelog/7.0/11277.md b/backport-changelog/7.0/11277.md new file mode 100644 index 00000000000000..9ecc1be43a3381 --- /dev/null +++ b/backport-changelog/7.0/11277.md @@ -0,0 +1,3 @@ +https://github.com/WordPress/wordpress-develop/pull/11277 + +* https://github.com/WordPress/gutenberg/pull/76549 diff --git a/lib/compat/wordpress-7.0/preload.php b/lib/compat/wordpress-7.0/preload.php index 0c650114cb2a1a..5b1d0fb14e813c 100644 --- a/lib/compat/wordpress-7.0/preload.php +++ b/lib/compat/wordpress-7.0/preload.php @@ -44,7 +44,7 @@ function gutenberg_block_editor_preload_paths_root_fields( $paths ) { // Complete list of fields expected by packages/core-data/src/entities.js. // This must match exactly for preloading to work (same fields, same order). // @see packages/core-data/src/entities.js rootEntitiesConfig.__unstableBase - $root_fields = 'description,gmt_offset,home,image_sizes,image_size_threshold,image_output_formats,jpeg_interlaced,png_interlaced,gif_interlaced,name,site_icon,site_icon_url,site_logo,timezone_string,url,page_for_posts,page_on_front,show_on_front'; + $root_fields = 'description,gmt_offset,home,image_sizes,image_size_threshold,image_output_formats,jpeg_interlaced,png_interlaced,gif_interlaced,client_side_supported_mime_types,name,site_icon,site_icon_url,site_logo,timezone_string,url,page_for_posts,page_on_front,show_on_front'; foreach ( $paths as $key => $path ) { if ( is_string( $path ) && str_starts_with( $path, '/?_fields=' ) ) { diff --git a/lib/media/class-gutenberg-rest-attachments-controller.php b/lib/media/class-gutenberg-rest-attachments-controller.php index d2032ca271fa46..fb45b4ac7b73d5 100644 --- a/lib/media/class-gutenberg-rest-attachments-controller.php +++ b/lib/media/class-gutenberg-rest-attachments-controller.php @@ -41,16 +41,21 @@ public function register_routes(): void { 'callback' => array( $this, 'sideload_item' ), 'permission_callback' => array( $this, 'sideload_item_permissions_check' ), 'args' => array( - 'id' => array( + 'id' => array( 'description' => __( 'Unique identifier for the attachment.', 'gutenberg' ), 'type' => 'integer', ), - 'image_size' => array( + 'image_size' => array( 'description' => __( 'Image size.', 'gutenberg' ), 'type' => 'string', 'enum' => $valid_image_sizes, 'required' => true, ), + 'replace_file' => array( + 'description' => __( 'Whether to replace the main attached file and delete the old one.', 'gutenberg' ), + 'type' => 'boolean', + 'default' => false, + ), ), ), 'allow_batch' => $this->allow_batch, @@ -408,7 +413,32 @@ public function sideload_item( WP_REST_Request $request ) { $metadata = array(); } - if ( 'original' === $image_size ) { + if ( ! empty( $request['replace_file'] ) ) { + // Replace the main attached file with the sideloaded one. + $old_path = get_attached_file( $attachment_id ); + + update_attached_file( $attachment_id, $path ); + + $metadata['file'] = _wp_relative_upload_path( $path ); + + // Store the new file as original_image if subsizes exist. + if ( ! empty( $metadata['sizes'] ) ) { + $metadata['original_image'] = wp_basename( $path ); + } + + // Update the post MIME type to match the new file. + wp_update_post( + array( + 'ID' => $attachment_id, + 'post_mime_type' => $type, + ) + ); + + // Delete the old main file if it differs from the new one. + if ( $old_path && $old_path !== $path && file_exists( $old_path ) ) { + wp_delete_file( $old_path ); + } + } elseif ( 'original' === $image_size ) { $metadata['original_image'] = wp_basename( $path ); } elseif ( 'scaled' === $image_size ) { // The current attached file is the original; record it as original_image. diff --git a/lib/media/load.php b/lib/media/load.php index 15e47a1b007151..cfe9dc82d4c8bc 100644 --- a/lib/media/load.php +++ b/lib/media/load.php @@ -86,13 +86,29 @@ function gutenberg_media_processing_filter_rest_index( WP_REST_Response $respons /** This filter is documented in wp-includes/class-wp-image-editor-imagick.php */ $gif_interlaced = (bool) apply_filters( 'image_save_progressive', false, 'image/gif' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound + /** + * Filters the MIME types supported by client-side media processing. + * + * Allows plugins to add or remove MIME types from the list of formats + * that can be processed client-side using WebAssembly-based vips. + * + * @since 21.8.0 + * + * @param string[] $mime_types Array of MIME type strings. + */ + $client_side_supported_mime_types = apply_filters( + 'client_side_supported_mime_types', + array( 'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif' ) + ); + if ( current_user_can( 'upload_files' ) ) { - $response->data['image_sizes'] = gutenberg_get_all_image_sizes(); - $response->data['image_size_threshold'] = $image_size_threshold; - $response->data['image_output_formats'] = (object) $default_image_output_formats; - $response->data['jpeg_interlaced'] = $jpeg_interlaced; - $response->data['png_interlaced'] = $png_interlaced; - $response->data['gif_interlaced'] = $gif_interlaced; + $response->data['image_sizes'] = gutenberg_get_all_image_sizes(); + $response->data['image_size_threshold'] = $image_size_threshold; + $response->data['image_output_formats'] = (object) $default_image_output_formats; + $response->data['jpeg_interlaced'] = $jpeg_interlaced; + $response->data['png_interlaced'] = $png_interlaced; + $response->data['gif_interlaced'] = $gif_interlaced; + $response->data['client_side_supported_mime_types'] = $client_side_supported_mime_types; } return $response; diff --git a/packages/block-editor/src/components/provider/use-media-upload-settings.js b/packages/block-editor/src/components/provider/use-media-upload-settings.js index 7c00c145d27a72..0c3bbdad18aa99 100644 --- a/packages/block-editor/src/components/provider/use-media-upload-settings.js +++ b/packages/block-editor/src/components/provider/use-media-upload-settings.js @@ -19,6 +19,7 @@ function useMediaUploadSettings( settings = {} ) { allowedMimeTypes: settings.allowedMimeTypes, allImageSizes: settings.allImageSizes, bigImageSizeThreshold: settings.bigImageSizeThreshold, + clientSideSupportedMimeTypes: settings.clientSideSupportedMimeTypes, } ), [ settings ] ); diff --git a/packages/core-data/src/entities.js b/packages/core-data/src/entities.js index 75cc7fc34faa40..b9104891a92665 100644 --- a/packages/core-data/src/entities.js +++ b/packages/core-data/src/entities.js @@ -54,6 +54,7 @@ export const rootEntitiesConfig = [ 'jpeg_interlaced', 'png_interlaced', 'gif_interlaced', + 'client_side_supported_mime_types', 'name', 'site_icon', 'site_icon_url', diff --git a/packages/editor/src/components/provider/use-block-editor-settings.js b/packages/editor/src/components/provider/use-block-editor-settings.js index 997469ba81f54a..58b46f93fa7f71 100644 --- a/packages/editor/src/components/provider/use-block-editor-settings.js +++ b/packages/editor/src/components/provider/use-block-editor-settings.js @@ -183,6 +183,8 @@ function useBlockEditorSettings( settings, postType, postId, renderingMode ) { return { allImageSizes: baseData?.image_sizes, bigImageSizeThreshold: baseData?.image_size_threshold, + clientSideSupportedMimeTypes: + baseData?.client_side_supported_mime_types, allowRightClickOverrides: get( 'core', 'allowRightClickOverrides' diff --git a/packages/upload-media/src/store/private-actions.ts b/packages/upload-media/src/store/private-actions.ts index fa22d3f61e3df4..3bdfc28e06c229 100644 --- a/packages/upload-media/src/store/private-actions.ts +++ b/packages/upload-media/src/store/private-actions.ts @@ -711,9 +711,10 @@ export function prepareItem( id: QueueItemId ) { const settings = select.getSettings(); const isImage = file.type.startsWith( 'image/' ); - const isVipsSupported = CLIENT_SIDE_SUPPORTED_MIME_TYPES.includes( - file.type - ); + const supportedMimeTypes = + settings.clientSideSupportedMimeTypes ?? + CLIENT_SIDE_SUPPORTED_MIME_TYPES; + const isVipsSupported = supportedMimeTypes.includes( file.type ); // For images that can be processed by vips, check if we need to scale down based on threshold. if ( isImage && isVipsSupported ) { diff --git a/packages/upload-media/src/store/test/actions.ts b/packages/upload-media/src/store/test/actions.ts index d9f3072a1648a6..7fcc4115f2313f 100644 --- a/packages/upload-media/src/store/test/actions.ts +++ b/packages/upload-media/src/store/test/actions.ts @@ -324,6 +324,113 @@ describe( 'actions', () => { true ); } ); + + it( 'should use custom clientSideSupportedMimeTypes when set', async () => { + // Exclude AVIF from supported types. + unlock( registry.dispatch( uploadStore ) ).updateSettings( { + clientSideSupportedMimeTypes: [ 'image/jpeg', 'image/png' ], + } ); + + const avifFile = new File( [ 'avif' ], 'photo.avif', { + lastModified: 1234567891, + type: 'image/avif', + } ); + + unlock( registry.dispatch( uploadStore ) ).addItem( { + file: avifFile, + } ); + + const item = unlock( + registry.select( uploadStore ) + ).getAllItems()[ 0 ]; + + await unlock( registry.dispatch( uploadStore ) ).prepareItem( + item.id + ); + + const updatedItem = unlock( + registry.select( uploadStore ) + ).getAllItems()[ 0 ]; + + // AVIF is excluded from custom list, so no client-side processing. + expect( updatedItem.operations ).toEqual( + expect.arrayContaining( [ OperationType.Upload ] ) + ); + expect( updatedItem.operations ).not.toEqual( + expect.arrayContaining( [ OperationType.ThumbnailGeneration ] ) + ); + expect( updatedItem.additionalData.generate_sub_sizes ).toBe( + true + ); + } ); + + it( 'should support normally-unsupported types via clientSideSupportedMimeTypes', async () => { + unlock( registry.dispatch( uploadStore ) ).updateSettings( { + clientSideSupportedMimeTypes: [ 'image/jpeg', 'image/bmp' ], + } ); + + const bmpFile = new File( [ 'bmp' ], 'photo.bmp', { + lastModified: 1234567891, + type: 'image/bmp', + } ); + + unlock( registry.dispatch( uploadStore ) ).addItem( { + file: bmpFile, + } ); + + const item = unlock( + registry.select( uploadStore ) + ).getAllItems()[ 0 ]; + + await unlock( registry.dispatch( uploadStore ) ).prepareItem( + item.id + ); + + const updatedItem = unlock( + registry.select( uploadStore ) + ).getAllItems()[ 0 ]; + + // BMP is in custom list, so client-side processing applies. + expect( updatedItem.operations ).toEqual( + expect.arrayContaining( [ + OperationType.Upload, + OperationType.ThumbnailGeneration, + ] ) + ); + expect( updatedItem.additionalData.generate_sub_sizes ).toBe( + false + ); + } ); + + it( 'should use default constant when clientSideSupportedMimeTypes is not set', async () => { + // No custom setting — defaults should apply. + unlock( registry.dispatch( uploadStore ) ).addItem( { + file: jpegFile, + } ); + + const item = unlock( + registry.select( uploadStore ) + ).getAllItems()[ 0 ]; + + await unlock( registry.dispatch( uploadStore ) ).prepareItem( + item.id + ); + + const updatedItem = unlock( + registry.select( uploadStore ) + ).getAllItems()[ 0 ]; + + // JPEG is in default list, so client-side processing applies. + expect( updatedItem.operations ).toEqual( + expect.arrayContaining( [ + OperationType.Upload, + OperationType.ThumbnailGeneration, + ] ) + ); + expect( updatedItem.additionalData.generate_sub_sizes ).toBe( + false + ); + } ); } ); describe( 'cancelItem', () => { diff --git a/packages/upload-media/src/store/types.ts b/packages/upload-media/src/store/types.ts index 579ad27d5c7e9e..10b5df6c379b2b 100644 --- a/packages/upload-media/src/store/types.ts +++ b/packages/upload-media/src/store/types.ts @@ -172,6 +172,9 @@ export interface Settings { bigImageSizeThreshold?: number; // Map of source MIME types to output MIME types for transcoding. imageOutputFormats?: Record< string, string >; + // MIME types supported by client-side media processing. + // Overrides the default CLIENT_SIDE_SUPPORTED_MIME_TYPES constant. + clientSideSupportedMimeTypes?: readonly string[]; // Whether to use progressive/interlaced encoding for JPEG. jpegInterlaced?: boolean; // Whether to use interlaced encoding for PNG. diff --git a/phpunit/media/media-processing-test.php b/phpunit/media/media-processing-test.php index a697d27897dbb9..1ffb47b7682d76 100644 --- a/phpunit/media/media-processing-test.php +++ b/phpunit/media/media-processing-test.php @@ -121,6 +121,7 @@ public function test_get_rest_index_should_return_additional_settings() { $this->assertArrayNotHasKey( 'png_interlaced', $data ); $this->assertArrayNotHasKey( 'gif_interlaced', $data ); $this->assertArrayNotHasKey( 'image_sizes', $data ); + $this->assertArrayNotHasKey( 'client_side_supported_mime_types', $data ); } /** @@ -141,6 +142,36 @@ public function test_get_rest_index_should_return_additional_settings_can_upload $this->assertArrayHasKey( 'png_interlaced', $data ); $this->assertArrayHasKey( 'gif_interlaced', $data ); $this->assertArrayHasKey( 'image_sizes', $data ); + $this->assertArrayHasKey( 'client_side_supported_mime_types', $data ); + $this->assertSame( + array( 'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif' ), + $data['client_side_supported_mime_types'] + ); + } + + /** + * @covers ::gutenberg_media_processing_filter_rest_index + */ + public function test_client_side_supported_mime_types_filter() { + wp_set_current_user( self::$admin_id ); + + $filter_callback = static function () { + return array( 'image/jpeg', 'image/png' ); + }; + add_filter( 'client_side_supported_mime_types', $filter_callback ); + + $server = new WP_REST_Server(); + + $request = new WP_REST_Request( 'GET', '/' ); + $index = $server->dispatch( $request ); + $data = $index->get_data(); + + remove_filter( 'client_side_supported_mime_types', $filter_callback ); + + $this->assertSame( + array( 'image/jpeg', 'image/png' ), + $data['client_side_supported_mime_types'] + ); } /**