diff --git a/docs/reference-guides/core-blocks.md b/docs/reference-guides/core-blocks.md index 35cecf6a27e663..2fec413e834a74 100644 --- a/docs/reference-guides/core-blocks.md +++ b/docs/reference-guides/core-blocks.md @@ -42,7 +42,7 @@ Reuse this design across your site. ([Source](https://github.com/WordPress/guten - **Name:** core/block - **Category:** reusable - **Supports:** interactivity (clientNavigation), ~~customClassName~~, ~~html~~, ~~inserter~~, ~~renaming~~ -- **Attributes:** overrides, ref +- **Attributes:** content, ref ## Button @@ -51,7 +51,7 @@ Prompt visitors to take action with a button-style link. ([Source](https://githu - **Name:** core/button - **Category:** design - **Parent:** core/buttons -- **Supports:** anchor, color (background, gradients, text), interactivity (clientNavigation), shadow, spacing (padding), typography (fontSize, lineHeight), ~~alignWide~~, ~~align~~, ~~reusable~~ +- **Supports:** anchor, color (background, gradients, text), interactivity (clientNavigation), shadow (), spacing (padding), typography (fontSize, lineHeight), ~~alignWide~~, ~~align~~, ~~reusable~~ - **Attributes:** backgroundColor, gradient, linkTarget, placeholder, rel, tagName, text, textAlign, textColor, title, type, url, width ## Buttons diff --git a/lib/compat/wordpress-6.5/block-bindings/sources/pattern.php b/lib/compat/wordpress-6.5/block-bindings/sources/pattern.php index 518348a43b8123..9168cfc785b557 100644 --- a/lib/compat/wordpress-6.5/block-bindings/sources/pattern.php +++ b/lib/compat/wordpress-6.5/block-bindings/sources/pattern.php @@ -9,7 +9,7 @@ function gutenberg_block_bindings_pattern_overrides_callback( $source_attrs, $bl return null; } $block_id = $block_instance->attributes['metadata']['id']; - return _wp_array_get( $block_instance->context, array( 'pattern/overrides', $block_id, $attribute_name ), null ); + return _wp_array_get( $block_instance->context, array( 'pattern/overrides', $block_id, 'values', $attribute_name ), null ); } function gutenberg_register_block_bindings_pattern_overrides_source() { diff --git a/lib/compat/wordpress-6.5/compat.php b/lib/compat/wordpress-6.5/compat.php new file mode 100644 index 00000000000000..78447927125894 --- /dev/null +++ b/lib/compat/wordpress-6.5/compat.php @@ -0,0 +1,38 @@ + $arr The array being evaluated. + * @return bool True if array is a list, false otherwise. + */ + function array_is_list( $arr ) { + if ( ( array() === $arr ) || ( array_values( $arr ) === $arr ) ) { + return true; + } + + $next_key = -1; + + foreach ( $arr as $k => $v ) { + if ( ++$next_key !== $k ) { + return false; + } + } + + return true; + } +} diff --git a/lib/compat/wordpress-6.5/fonts/class-wp-font-collection.php b/lib/compat/wordpress-6.5/fonts/class-wp-font-collection.php index 154621ead50fbe..019cf0f3f0b29d 100644 --- a/lib/compat/wordpress-6.5/fonts/class-wp-font-collection.php +++ b/lib/compat/wordpress-6.5/fonts/class-wp-font-collection.php @@ -15,13 +15,14 @@ * Font Collection class. * * @since 6.5.0 + * + * @see wp_register_font_collection() */ final class WP_Font_Collection { /** * The unique slug for the font collection. * * @since 6.5.0 - * * @var string */ public $slug; @@ -30,8 +31,7 @@ final class WP_Font_Collection { * Font collection data. * * @since 6.5.0 - * - * @var array + * @var array|WP_Error|null */ private $data; @@ -39,8 +39,7 @@ final class WP_Font_Collection { * Font collection JSON file path or URL. * * @since 6.5.0 - * - * @var string + * @var string|null */ private $src; @@ -49,23 +48,18 @@ final class WP_Font_Collection { * * @since 6.5.0 * - * @param string $slug Font collection slug. - * @param array|string $data_or_file { - * Font collection data array or a file path or URL to a JSON file containing the font collection. - * - * @type string $name Name of the font collection. - * @type string $description Description of the font collection. - * @type array $font_families Array of font family definitions included in the collection. - * @type array $categories Array of categories associated with the fonts in the collection. - * } + * @param string $slug Font collection slug. + * @param array|string $data_or_file Font collection data array or a path/URL to a JSON file + * containing the font collection. + * See {@see wp_register_font_collection()} for the supported fields. */ public function __construct( $slug, $data_or_file ) { $this->slug = sanitize_title( $slug ); - // Data or json are lazy loaded and validated in get_data(). if ( is_array( $data_or_file ) ) { - $this->data = $data_or_file; + $this->data = $this->sanitize_and_validate_data( $data_or_file ); } else { + // JSON data is lazy loaded by ::get_data(). $this->src = $data_or_file; } @@ -87,30 +81,21 @@ public function __construct( $slug, $data_or_file ) { * @return array|WP_Error An array containing the font collection data, or a WP_Error on failure. */ public function get_data() { - // If we have a JSON config, load it and cache the data if it's valid. + // If the collection uses JSON data, load it and cache the data/error. if ( $this->src && empty( $this->data ) ) { - $data = $this->load_from_json( $this->src ); - if ( is_wp_error( $data ) ) { - return $data; - } - - $this->data = $data; + $this->data = $this->load_from_json( $this->src ); } - // Validate required properties are not empty. - $data = $this->validate_data( $this->data ); - if ( is_wp_error( $data ) ) { - return $data; + if ( is_wp_error( $this->data ) ) { + return $this->data; } // Set defaults for optional properties. - return wp_parse_args( - $data, - array( - 'description' => '', - 'categories' => array(), - ) + $defaults = array( + 'description' => '', + 'categories' => array(), ); + return wp_parse_args( $this->data, $defaults ); } /** @@ -151,7 +136,7 @@ private function load_from_file( $file ) { return new WP_Error( 'font_collection_decode_error', __( 'Error decoding the font collection JSON file contents.', 'gutenberg' ) ); } - return $data; + return $this->sanitize_and_validate_data( $data ); } /** @@ -180,8 +165,8 @@ private function load_from_url( $url ) { return new WP_Error( 'font_collection_decode_error', __( 'Error decoding the font collection data from the REST response JSON.', 'gutenberg' ) ); } - // Make sure the data is valid before caching it. - $data = $this->validate_data( $data ); + // Make sure the data is valid before storing it in a transient. + $data = $this->sanitize_and_validate_data( $data ); if ( is_wp_error( $data ) ) { return $data; } @@ -193,14 +178,17 @@ private function load_from_url( $url ) { } /** - * Validates the font collection configuration. + * Sanitizes and validates the font collection data. * * @since 6.5.0 * - * @param array $data Font collection configuration to validate. - * @return array|WP_Error Array of data if valid, otherwise a WP_Error instance. + * @param array $data Font collection data to sanitize and validate. + * @return array|WP_Error Sanitized data if valid, otherwise a WP_Error instance. */ - private function validate_data( $data ) { + private function sanitize_and_validate_data( $data ) { + $schema = self::get_sanitization_schema(); + $data = WP_Font_Utils::sanitize_from_schema( $data, $schema ); + $required_properties = array( 'name', 'font_families' ); foreach ( $required_properties as $property ) { if ( empty( $data[ $property ] ) ) { @@ -217,5 +205,59 @@ private function validate_data( $data ) { return $data; } + + /** + * Retrieves the font collection sanitization schema. + * + * @since 6.5.0 + * + * @return array Font collection sanitization schema. + */ + private static function get_sanitization_schema() { + return array( + 'name' => 'sanitize_text_field', + 'description' => 'sanitize_text_field', + 'font_families' => array( + array( + 'font_family_settings' => array( + 'name' => 'sanitize_text_field', + 'slug' => 'sanitize_title', + 'fontFamily' => 'sanitize_text_field', + 'preview' => 'sanitize_url', + 'fontFace' => array( + array( + 'fontFamily' => 'sanitize_text_field', + 'fontStyle' => 'sanitize_text_field', + 'fontWeight' => 'sanitize_text_field', + 'src' => function ( $value ) { + return is_array( $value ) + ? array_map( 'sanitize_text_field', $value ) + : sanitize_text_field( $value ); + }, + 'preview' => 'sanitize_url', + 'fontDisplay' => 'sanitize_text_field', + 'fontStretch' => 'sanitize_text_field', + 'ascentOverride' => 'sanitize_text_field', + 'descentOverride' => 'sanitize_text_field', + 'fontVariant' => 'sanitize_text_field', + 'fontFeatureSettings' => 'sanitize_text_field', + 'fontVariationSettings' => 'sanitize_text_field', + 'lineGapOverride' => 'sanitize_text_field', + 'sizeAdjust' => 'sanitize_text_field', + 'unicodeRange' => 'sanitize_text_field', + ), + ), + ), + 'categories' => array( 'sanitize_title' ), + ), + ), + 'categories' => array( + array( + 'name' => 'sanitize_text_field', + 'slug' => 'sanitize_title', + ), + ), + ); + } } } diff --git a/lib/compat/wordpress-6.5/fonts/class-wp-font-library.php b/lib/compat/wordpress-6.5/fonts/class-wp-font-library.php index d5db42c499814c..b09e2c5a7b0f38 100644 --- a/lib/compat/wordpress-6.5/fonts/class-wp-font-library.php +++ b/lib/compat/wordpress-6.5/fonts/class-wp-font-library.php @@ -56,10 +56,11 @@ public static function get_expected_font_mime_types_per_php_version( $php_versio * @since 6.5.0 * * @param string $slug Font collection slug. - * @param array $data_or_file Font collection data array or a file path or url to a JSON file + * @param array $data_or_file Font collection data array or a path/URL to a JSON file * containing the font collection. * See {@see wp_register_font_collection()} for the supported fields. - * @return WP_Font_Collection|WP_Error A font collection if registration was successful, else WP_Error. + * @return WP_Font_Collection|WP_Error A font collection if it was registered successfully, + * or WP_Error object on failure. */ public static function register_font_collection( $slug, $data_or_file ) { $new_collection = new WP_Font_Collection( $slug, $data_or_file ); diff --git a/lib/compat/wordpress-6.5/fonts/class-wp-font-utils.php b/lib/compat/wordpress-6.5/fonts/class-wp-font-utils.php index 792a5aaa80eef6..2f810640c55f05 100644 --- a/lib/compat/wordpress-6.5/fonts/class-wp-font-utils.php +++ b/lib/compat/wordpress-6.5/fonts/class-wp-font-utils.php @@ -21,35 +21,41 @@ */ class WP_Font_Utils { /** - * Format font family names. + * Sanitizes and formats font family names. * - * Adds surrounding quotes to font family names containing spaces and not already quoted. + * - Applies `sanitize_text_field` + * - Adds surrounding quotes to names that contain spaces and are not already quoted * * @since 6.5.0 * @access private * + * @see sanitize_text_field() + * * @param string $font_family Font family name(s), comma-separated. - * @return string Formatted font family name(s). + * @return string Sanitized and formatted font family name(s). */ - public static function format_font_family( $font_family ) { - if ( $font_family ) { - $font_families = explode( ',', $font_family ); - $wrapped_font_families = array_map( - function ( $family ) { - $trimmed = trim( $family ); - if ( ! empty( $trimmed ) && strpos( $trimmed, ' ' ) !== false && strpos( $trimmed, "'" ) === false && strpos( $trimmed, '"' ) === false ) { - return '"' . $trimmed . '"'; - } - return $trimmed; - }, - $font_families - ); - - if ( count( $wrapped_font_families ) === 1 ) { - $font_family = $wrapped_font_families[0]; - } else { - $font_family = implode( ', ', $wrapped_font_families ); - } + public static function sanitize_font_family( $font_family ) { + if ( ! $font_family ) { + return ''; + } + + $font_family = sanitize_text_field( $font_family ); + $font_families = explode( ',', $font_family ); + $wrapped_font_families = array_map( + function ( $family ) { + $trimmed = trim( $family ); + if ( ! empty( $trimmed ) && false !== strpos( $trimmed, ' ' ) && false === strpos( $trimmed, "'" ) && false === strpos( $trimmed, '"' ) ) { + return '"' . $trimmed . '"'; + } + return $trimmed; + }, + $font_families + ); + + if ( count( $wrapped_font_families ) === 1 ) { + $font_family = $wrapped_font_families[0]; + } else { + $font_family = implode( ', ', $wrapped_font_families ); } return $font_family; @@ -128,21 +134,23 @@ function ( $elem ) { $slug_elements ); - return join( ';', $slug_elements ); + return sanitize_text_field( join( ';', $slug_elements ) ); } /** - * Sanitize a tree of data using a schema that defines the sanitization to apply to each key. + * Sanitizes a tree of data using a schema. * - * It removes the keys not in the schema and applies the sanitizer to the values. + * The schema structure should mirror the data tree. Each value provided in the + * schema should be a callable that will be applied to sanitize the corresponding + * value in the data tree. Keys that are in the data tree, but not present in the + * schema, will be removed in the santized data. Nested arrays are traversed recursively. * * @since 6.5.0 * * @access private * - * @param array $tree The data to sanitize. + * @param array $tree The data to sanitize. * @param array $schema The schema used for sanitization. - * * @return array The sanitized data. */ public static function sanitize_from_schema( $tree, $schema ) { @@ -158,7 +166,7 @@ public static function sanitize_from_schema( $tree, $schema ) { } $is_value_array = is_array( $value ); - $is_schema_array = is_array( $schema[ $key ] ); + $is_schema_array = is_array( $schema[ $key ] ) && ! is_callable( $schema[ $key ] ); if ( $is_value_array && $is_schema_array ) { if ( wp_is_numeric_array( $value ) ) { @@ -169,7 +177,7 @@ public static function sanitize_from_schema( $tree, $schema ) { : self::apply_sanitizer( $item_value, $schema[ $key ][0] ); } } else { - // If it is an associative or indexed array., process as a single object. + // If it is an associative or indexed array, process as a single object. $tree[ $key ] = self::sanitize_from_schema( $value, $schema[ $key ] ); } } elseif ( ! $is_value_array && $is_schema_array ) { @@ -190,12 +198,12 @@ public static function sanitize_from_schema( $tree, $schema ) { } /** - * Apply the sanitizer to the value. + * Applies a sanitizer function to a value. * * @since 6.5.0 - * @param mixed $value The value to sanitize. - * @param mixed $sanitizer The sanitizer to apply. * + * @param mixed $value The value to sanitize. + * @param mixed $sanitizer The sanitizer function to apply. * @return mixed The sanitized value. */ private static function apply_sanitizer( $value, $sanitizer ) { 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 efecd6c6821c32..22a843e7e69ed0 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 @@ -187,20 +187,32 @@ public function validate_create_font_face_settings( $value, $request ) { } } - $srcs = is_array( $settings['src'] ) ? $settings['src'] : array( $settings['src'] ); + $srcs = is_array( $settings['src'] ) ? $settings['src'] : array( $settings['src'] ); + $files = $request->get_file_params(); - // Check that srcs are non-empty strings. - $filtered_src = array_filter( array_filter( $srcs, 'is_string' ) ); - if ( empty( $filtered_src ) ) { - return new WP_Error( - 'rest_invalid_param', - __( 'font_face_settings[src] values must be non-empty strings.', 'gutenberg' ), - array( 'status' => 400 ) - ); + foreach ( $srcs as $src ) { + // Check that each src is a non-empty string. + $src = ltrim( $src ); + if ( empty( $src ) ) { + return new WP_Error( + 'rest_invalid_param', + __( 'font_face_settings[src] values must be non-empty strings.', 'gutenberg' ), + array( 'status' => 400 ) + ); + } + + // Check that srcs are valid URLs or file references. + if ( false === wp_http_validate_url( $src ) && ! isset( $files[ $src ] ) ) { + return new WP_Error( + 'rest_invalid_param', + /* translators: %s: src value in the font face settings. */ + sprintf( __( 'font_face_settings[src] value "%s" must be a valid URL or file reference.', 'gutenberg' ), $src ), + array( 'status' => 400 ) + ); + } } // Check that each file in the request references a src in the settings. - $files = $request->get_file_params(); foreach ( array_keys( $files ) as $file ) { if ( ! in_array( $file, $srcs, true ) ) { return new WP_Error( @@ -227,9 +239,12 @@ public function validate_create_font_face_settings( $value, $request ) { public function sanitize_font_face_settings( $value ) { // Settings arrive as stringified JSON, since this is a multipart/form-data request. $settings = json_decode( $value, true ); + $schema = $this->get_item_schema()['properties']['font_face_settings']['properties']; - if ( isset( $settings['fontFamily'] ) ) { - $settings['fontFamily'] = WP_Font_Utils::format_font_family( $settings['fontFamily'] ); + // Sanitize settings based on callbacks in the schema. + foreach ( $settings as $key => $value ) { + $sanitize_callback = $schema[ $key ]['arg_options']['sanitize_callback']; + $settings[ $key ] = call_user_func( $sanitize_callback, $value ); } return $settings; @@ -509,11 +524,17 @@ public function get_item_schema() { 'description' => __( 'CSS font-family value.', 'gutenberg' ), 'type' => 'string', 'default' => '', + 'arg_options' => array( + 'sanitize_callback' => array( 'WP_Font_Utils', 'sanitize_font_family' ), + ), ), 'fontStyle' => array( 'description' => __( 'CSS font-style value.', 'gutenberg' ), 'type' => 'string', 'default' => 'normal', + 'arg_options' => array( + 'sanitize_callback' => 'sanitize_text_field', + ), ), 'fontWeight' => array( 'description' => __( 'List of available font weights, separated by a space.', 'gutenberg' ), @@ -521,6 +542,9 @@ public function get_item_schema() { // Changed from `oneOf` to avoid errors from loose type checking. // e.g. a fontWeight of "400" validates as both a string and an integer due to is_numeric check. 'type' => array( 'string', 'integer' ), + 'arg_options' => array( + 'sanitize_callback' => 'sanitize_text_field', + ), ), 'fontDisplay' => array( 'description' => __( 'CSS font-display value.', 'gutenberg' ), @@ -533,10 +557,14 @@ public function get_item_schema() { 'swap', 'optional', ), + 'arg_options' => array( + 'sanitize_callback' => 'sanitize_text_field', + ), ), 'src' => array( 'description' => __( 'Paths or URLs to the font files.', 'gutenberg' ), - // Changed from `oneOf` to `anyOf` due to rest_sanitize_array converting a string into an array. + // Changed from `oneOf` to `anyOf` due to rest_sanitize_array converting a string into an array, + // and causing a "matches more than one of the expected formats" error. 'anyOf' => array( array( 'type' => 'string', @@ -549,46 +577,83 @@ public function get_item_schema() { ), ), 'default' => array(), + 'arg_options' => array( + 'sanitize_callback' => function ( $value ) { + return is_array( $value ) ? array_map( array( $this, 'sanitize_src' ), $value ) : $this->sanitize_src( $value ); + }, + ), ), 'fontStretch' => array( 'description' => __( 'CSS font-stretch value.', 'gutenberg' ), 'type' => 'string', + 'arg_options' => array( + 'sanitize_callback' => 'sanitize_text_field', + ), ), 'ascentOverride' => array( 'description' => __( 'CSS ascent-override value.', 'gutenberg' ), 'type' => 'string', + 'arg_options' => array( + 'sanitize_callback' => 'sanitize_text_field', + ), ), 'descentOverride' => array( 'description' => __( 'CSS descent-override value.', 'gutenberg' ), 'type' => 'string', + 'arg_options' => array( + 'sanitize_callback' => 'sanitize_text_field', + ), ), 'fontVariant' => array( 'description' => __( 'CSS font-variant value.', 'gutenberg' ), 'type' => 'string', + 'arg_options' => array( + 'sanitize_callback' => 'sanitize_text_field', + ), ), 'fontFeatureSettings' => array( 'description' => __( 'CSS font-feature-settings value.', 'gutenberg' ), 'type' => 'string', + 'arg_options' => array( + 'sanitize_callback' => 'sanitize_text_field', + ), ), 'fontVariationSettings' => array( 'description' => __( 'CSS font-variation-settings value.', 'gutenberg' ), 'type' => 'string', + 'arg_options' => array( + 'sanitize_callback' => 'sanitize_text_field', + ), ), 'lineGapOverride' => array( 'description' => __( 'CSS line-gap-override value.', 'gutenberg' ), 'type' => 'string', + 'arg_options' => array( + 'sanitize_callback' => 'sanitize_text_field', + ), ), 'sizeAdjust' => array( 'description' => __( 'CSS size-adjust value.', 'gutenberg' ), 'type' => 'string', + 'arg_options' => array( + 'sanitize_callback' => 'sanitize_text_field', + ), ), 'unicodeRange' => array( 'description' => __( 'CSS unicode-range value.', 'gutenberg' ), 'type' => 'string', + 'arg_options' => array( + 'sanitize_callback' => 'sanitize_text_field', + ), ), 'preview' => array( 'description' => __( 'URL to a preview image of the font face.', 'gutenberg' ), 'type' => 'string', + 'format' => 'uri', + 'default' => '', + 'arg_options' => array( + 'sanitize_callback' => 'sanitize_url', + ), ), ), 'required' => array( 'fontFamily', 'src' ), @@ -602,6 +667,26 @@ public function get_item_schema() { return $this->add_additional_fields_schema( $this->schema ); } + /** + * Retrieves the item's schema for display / public consumption purposes. + * + * @since 6.5.0 + * + * @return array Public item schema data. + */ + public function get_public_item_schema() { + + $schema = parent::get_public_item_schema(); + + // Also remove `arg_options' from child font_family_settings properties, since the parent + // controller only handles the top level properties. + foreach ( $schema['properties']['font_face_settings']['properties'] as &$property ) { + unset( $property['arg_options'] ); + } + + return $schema; + } + /** * Retrieves the query params for the font face collection. * @@ -739,6 +824,20 @@ protected function prepare_item_for_database( $request ) { return $prepared_post; } + /** + * Sanitizes a single src value for a font face. + * + * @since 6.5.0 + * + * @param string $value Font face src that is a URL or the key for a $_FILES array item. + * + * @return string Sanitized value. + */ + protected function sanitize_src( $value ) { + $value = ltrim( $value ); + return false === wp_http_validate_url( $value ) ? (string) $value : sanitize_url( $value ); + } + /** * Handles the upload of a font file using wp_handle_upload(). * diff --git a/lib/compat/wordpress-6.5/fonts/class-wp-rest-font-families-controller.php b/lib/compat/wordpress-6.5/fonts/class-wp-rest-font-families-controller.php index 7586fe0209329c..e4a2b2f8e97813 100644 --- a/lib/compat/wordpress-6.5/fonts/class-wp-rest-font-families-controller.php +++ b/lib/compat/wordpress-6.5/fonts/class-wp-rest-font-families-controller.php @@ -141,15 +141,14 @@ public function validate_font_family_settings( $value, $request ) { * @return array Decoded array font family settings. */ public function sanitize_font_family_settings( $value ) { + // Settings arrive as stringified JSON, since this is a multipart/form-data request. $settings = json_decode( $value, true ); + $schema = $this->get_item_schema()['properties']['font_family_settings']['properties']; - if ( isset( $settings['fontFamily'] ) ) { - $settings['fontFamily'] = WP_Font_Utils::format_font_family( $settings['fontFamily'] ); - } - - // Provide default for preview, if not provided. - if ( ! isset( $settings['preview'] ) ) { - $settings['preview'] = ''; + // Sanitize settings based on callbacks in the schema. + foreach ( $settings as $key => $value ) { + $sanitize_callback = $schema[ $key ]['arg_options']['sanitize_callback']; + $settings[ $key ] = call_user_func( $sanitize_callback, $value ); } return $settings; @@ -307,25 +306,39 @@ public function get_item_schema() { // Font family settings come directly from theme.json schema // See https://schemas.wp.org/trunk/theme.json 'font_family_settings' => array( - 'description' => __( 'font-face declaration in theme.json format.', 'gutenberg' ), + 'description' => __( 'font-face definition in theme.json format.', 'gutenberg' ), 'type' => 'object', 'context' => array( 'view', 'edit', 'embed' ), 'properties' => array( 'name' => array( - 'description' => 'Name of the font family preset, translatable.', + 'description' => __( 'Name of the font family preset, translatable.', 'gutenberg' ), 'type' => 'string', + 'arg_options' => array( + 'sanitize_callback' => 'sanitize_text_field', + ), ), 'slug' => array( - 'description' => 'Kebab-case unique identifier for the font family preset.', + 'description' => __( 'Kebab-case unique identifier for the font family preset.', 'gutenberg' ), 'type' => 'string', + 'arg_options' => array( + 'sanitize_callback' => 'sanitize_title', + ), ), 'fontFamily' => array( - 'description' => 'CSS font-family value.', + 'description' => __( 'CSS font-family value.', 'gutenberg' ), 'type' => 'string', + 'arg_options' => array( + 'sanitize_callback' => array( 'WP_Font_Utils', 'sanitize_font_family' ), + ), ), 'preview' => array( - 'description' => 'URL to a preview image of the font family.', + 'description' => __( 'URL to a preview image of the font family.', 'gutenberg' ), 'type' => 'string', + 'format' => 'uri', + 'default' => '', + 'arg_options' => array( + 'sanitize_callback' => 'sanitize_url', + ), ), ), 'required' => array( 'name', 'slug', 'fontFamily' ), @@ -339,6 +352,26 @@ public function get_item_schema() { return $this->add_additional_fields_schema( $this->schema ); } + /** + * Retrieves the item's schema for display / public consumption purposes. + * + * @since 6.5.0 + * + * @return array Public item schema data. + */ + public function get_public_item_schema() { + + $schema = parent::get_public_item_schema(); + + // Also remove `arg_options' from child font_family_settings properties, since the parent + // controller only handles the top level properties. + foreach ( $schema['properties']['font_family_settings']['properties'] as &$property ) { + unset( $property['arg_options'] ); + } + + return $schema; + } + /** * Retrieves the query params for the font family collection. * diff --git a/lib/compat/wordpress-6.5/fonts/fonts.php b/lib/compat/wordpress-6.5/fonts/fonts.php index 28b019b9c1fa61..1258eaacd99c69 100644 --- a/lib/compat/wordpress-6.5/fonts/fonts.php +++ b/lib/compat/wordpress-6.5/fonts/fonts.php @@ -114,17 +114,20 @@ function gutenberg_init_font_library() { * * @since 6.5.0 * - * @param string $slug Font collection slug or path/url to a JSON file defining the font collection. + * @param string $slug Font collection slug. May only contain alphanumeric characters, dashes, + * and underscores. See sanitize_title(). * @param array|string $data_or_file { - * Font collection associative array of data, or a file path or url to a JSON - * file containing the font collection. + * Font collection data array or a path/URL to a JSON file containing the font collection. * - * @type string $name Name of the font collection. - * @type string $description Description of the font collection. - * @type array $font_families Array of font family definitions that are in the collection. - * @type array $categories Array of categories for the fonts that are in the collection. + * @link https://schemas.wp.org/trunk/font-collection.json + * + * @type string $name Required. Name of the font collection shown in the Font Library. + * @type string $description Optional. A short descriptive summary of the font collection. Default empty. + * @type array $font_families Required. Array of font family definitions that are in the collection. + * @type array $categories Optional. Array of categories, each with a name and slug, that are used by the + * fonts in the collection. Default empty. * } - * @return WP_Font_Collection|WP_Error A font collection is it was registered + * @return WP_Font_Collection|WP_Error A font collection if it was registered * successfully, or WP_Error object on failure. */ function wp_register_font_collection( $slug, $data_or_file ) { @@ -138,11 +141,11 @@ function wp_register_font_collection( $slug, $data_or_file ) { * * @since 6.5.0 * - * @param string $collection_id The font collection ID. + * @param string $slug Font collection slug. * @return bool True if the font collection was unregistered successfully, else false. */ - function wp_unregister_font_collection( $collection_id ) { - return WP_Font_Library::unregister_font_collection( $collection_id ); + function wp_unregister_font_collection( $slug ) { + return WP_Font_Library::unregister_font_collection( $slug ); } } diff --git a/lib/compat/wordpress-6.5/interactivity-api/class-wp-interactivity-api-directives-processor.php b/lib/compat/wordpress-6.5/interactivity-api/class-wp-interactivity-api-directives-processor.php index b437bcefa67568..b4cfa5a499872c 100644 --- a/lib/compat/wordpress-6.5/interactivity-api/class-wp-interactivity-api-directives-processor.php +++ b/lib/compat/wordpress-6.5/interactivity-api/class-wp-interactivity-api-directives-processor.php @@ -15,14 +15,24 @@ */ class WP_Interactivity_API_Directives_Processor extends Gutenberg_HTML_Tag_Processor_6_5 { /** - * Returns the content between two balanced tags. + * Returns the content between two balanced template tags. + * + * It positions the cursor in the closer tag of the balanced template tag, + * if it exists. * * @access private * - * @return string|null The content between the current opening and its matching closing tag or null if it doesn't - * find the matching closing tag. + * @return string|null The content between the current opener template tag and its matching closer tag or null if it + * doesn't find the matching closing tag. */ - public function get_content_between_balanced_tags() { + public function get_content_between_balanced_template_tags() { + if ( 'TEMPLATE' !== $this->get_tag() || $this->is_tag_closer() ) { + return null; + } + + // Flushes any changes. + $this->get_updated_html(); + $bookmarks = $this->get_balanced_tag_bookmarks(); if ( ! $bookmarks ) { return null; @@ -32,7 +42,6 @@ public function get_content_between_balanced_tags() { $start = $this->bookmarks[ $start_name ]->start + $this->bookmarks[ $start_name ]->length + 1; $end = $this->bookmarks[ $end_name ]->start; - $this->seek( $start_name ); $this->release_bookmark( $start_name ); $this->release_bookmark( $end_name ); @@ -48,6 +57,7 @@ public function get_content_between_balanced_tags() { * @return bool Whether the content was successfully replaced. */ public function set_content_between_balanced_tags( string $new_content ): bool { + // Flushes any changes. $this->get_updated_html(); $bookmarks = $this->get_balanced_tag_bookmarks(); @@ -67,6 +77,37 @@ public function set_content_between_balanced_tags( string $new_content ): bool { return true; } + /** + * Appends content after the closing tag of a template tag. + * + * This method positions the processor in the last tag of the appended + * content, if it exists. + * + * @access private + * + * @param string $new_content The string to append after the closing template tag. + * @return bool Whether the content was successfully appended. + */ + public function append_content_after_template_tag_closer( string $new_content ): bool { + // Refuses to process if the content is empty or this is not a closer template tag. + if ( empty( $new_content ) || 'TEMPLATE' !== $this->get_tag() || ! $this->is_tag_closer() ) { + return false; + } + + // Flushes any changes. + $this->get_updated_html(); + + $bookmark = 'append_content_after_template_tag_closer'; + $this->set_bookmark( $bookmark ); + $end = $this->bookmarks[ $bookmark ]->start + $this->bookmarks[ $bookmark ]->length + 1; + $this->release_bookmark( $bookmark ); + + // Appends the new content. + $this->lexical_updates[] = new Gutenberg_HTML_Text_Replacement_6_5( $end, 0, $new_content ); + + return true; + } + /** * Returns a pair of bookmarks for the current opening tag and the matching * closing tag. @@ -78,7 +119,7 @@ private function get_balanced_tag_bookmarks() { $start_name = 'start_of_balanced_tag_' . ++$i; $this->set_bookmark( $start_name ); - if ( ! $this->next_balanced_closer() ) { + if ( ! $this->next_balanced_tag_closer_tag() ) { $this->release_bookmark( $start_name ); return null; } @@ -93,13 +134,15 @@ private function get_balanced_tag_bookmarks() { * Finds the matching closing tag for an opening tag. * * When called while the processor is on an open tag, it traverses the HTML - * until it finds the matching closing tag, respecting any in-between content, - * including nested tags of the same name. Returns false when called on a - * closing or void tag, or if no matching closing tag was found. + * until it finds the matching closing tag, respecting any in-between + * content, including nested tags of the same name. Returns false when + * called on a closing or void tag, or if no matching closing tag was found. + * + * @access private * * @return bool Whether a matching closing tag was found. */ - private function next_balanced_closer(): bool { + public function next_balanced_tag_closer_tag(): bool { $depth = 0; $tag_name = $this->get_tag(); diff --git a/lib/compat/wordpress-6.5/interactivity-api/class-wp-interactivity-api.php b/lib/compat/wordpress-6.5/interactivity-api/class-wp-interactivity-api.php index 9cbbfb1d6b6540..be9203198d3f2f 100644 --- a/lib/compat/wordpress-6.5/interactivity-api/class-wp-interactivity-api.php +++ b/lib/compat/wordpress-6.5/interactivity-api/class-wp-interactivity-api.php @@ -25,6 +25,12 @@ class WP_Interactivity_API { 'data-wp-class' => 'data_wp_class_processor', 'data-wp-style' => 'data_wp_style_processor', 'data-wp-text' => 'data_wp_text_processor', + /* + * `data-wp-each` needs to be processed in the last place because it moves + * the cursor to the end of the processed items to prevent them to be + * processed twice. + */ + 'data-wp-each' => 'data_wp_each_processor', ); /** @@ -194,11 +200,30 @@ public function add_hooks() { * @return string The processed HTML content. It returns the original content when the HTML contains unbalanced tags. */ public function process_directives( string $html ): string { - $p = new WP_Interactivity_API_Directives_Processor( $html ); - $tag_stack = array(); - $namespace_stack = array(); $context_stack = array(); - $unbalanced = false; + $namespace_stack = array(); + $result = $this->process_directives_args( $html, $context_stack, $namespace_stack ); + return null === $result ? $html : $result; + } + + /** + * Processes the interactivity directives contained within the HTML content + * and updates the markup accordingly. + * + * It needs the context and namespace stacks to be passed by reference and + * it returns null if the HTML contains unbalanced tags. + * + * @since 6.5.0 + * + * @param string $html The HTML content to process. + * @param array $context_stack The reference to the array used to keep track of contexts during processing. + * @param array $namespace_stack The reference to the array used to manage namespaces during processing. + * @return string|null The processed HTML content. It returns null when the HTML contains unbalanced tags. + */ + private function process_directives_args( string $html, array &$context_stack, array &$namespace_stack ) { + $p = new WP_Interactivity_API_Directives_Processor( $html ); + $tag_stack = array(); + $unbalanced = false; $directive_processor_prefixes = array_keys( self::$directive_processors ); $directive_processor_prefixes_reversed = array_reverse( $directive_processor_prefixes ); @@ -234,26 +259,28 @@ public function process_directives( string $html ): string { } } } else { - $directives_prefixes = array(); - - foreach ( $p->get_attribute_names_with_prefix( 'data-wp-' ) as $attribute_name ) { + if ( 0 === count( $p->get_attribute_names_with_prefix( 'data-wp-each-child' ) ) ) { + $directives_prefixes = array(); + + // Checks if there is is a server directive processor registered for each directive. + foreach ( $p->get_attribute_names_with_prefix( 'data-wp-' ) as $attribute_name ) { + list( $directive_prefix ) = $this->extract_prefix_and_suffix( $attribute_name ); + if ( array_key_exists( $directive_prefix, self::$directive_processors ) ) { + $directives_prefixes[] = $directive_prefix; + } + } /* - * Extracts the directive prefix to see if there is a server directive - * processor registered for that directive. - */ - list( $directive_prefix ) = $this->extract_prefix_and_suffix( $attribute_name ); - if ( array_key_exists( $directive_prefix, self::$directive_processors ) ) { - $directives_prefixes[] = $directive_prefix; + * If this is not a void element, it adds it to the tag stack so it can + * process its closing tag and check for unbalanced tags. + */ + if ( ! $p->is_void() ) { + $tag_stack[] = array( $tag_name, $directives_prefixes ); } - } - - /* - * If this is not a void element, it adds it to the tag stack so it can - * process its closing tag and check for unbalanced tags. - */ - if ( ! $p->is_void() ) { - $tag_stack[] = array( $tag_name, $directives_prefixes ); + } else { + // Jumps to the tag closer if the tag has a `data-wp-each-child` directive. + $p->next_balanced_tag_closer_tag(); + continue; } } @@ -276,17 +303,17 @@ public function process_directives( string $html ): string { : array( $this, self::$directive_processors[ $directive_prefix ] ); call_user_func_array( $func, - array( $p, &$context_stack, &$namespace_stack ) + array( $p, &$context_stack, &$namespace_stack, &$tag_stack ) ); } } /* - * It returns the original content if the HTML is unbalanced because - * unbalanced HTML is not safe to process. In that case, the Interactivity - * API runtime will update the HTML on the client side during the hydration. + * It returns null if the HTML is unbalanced because unbalanced HTML is + * not safe to process. In that case, the Interactivity API runtime will + * update the HTML on the client side during the hydration. */ - return $unbalanced || 0 < count( $tag_stack ) ? $html : $p->get_updated_html(); + return $unbalanced || 0 < count( $tag_stack ) ? null : $p->get_updated_html(); } /** @@ -403,6 +430,23 @@ private function extract_directive_value( $directive_value, $default_namespace = return array( $default_namespace, $directive_value ); } + /** + * Transforms a kebab-case string to camelCase. + * + * @param string $str The kebab-case string to transform to camelCase. + * @return string The transformed camelCase string. + */ + private function kebab_to_camel_case( string $str ): string { + return lcfirst( + preg_replace_callback( + '/(-)([a-z])/', + function ( $matches ) { + return strtoupper( $matches[2] ); + }, + strtolower( preg_replace( '/-+$/', '', $str ) ) + ) + ); + } /** * Processes the `data-wp-interactive` directive. @@ -768,9 +812,109 @@ class="screen-reader-text" > HTML; }; + add_action( 'wp_footer', $callback ); } } - } + /** + * Processes the `data-wp-each` directive. + * + * This directive gets an array passed as reference and iterates over it + * generating new content for each item based on the inner markup of the + * `template` tag. + * + * @since 6.5.0 + * + * @param WP_Interactivity_API_Directives_Processor $p The directives processor instance. + * @param array $context_stack The reference to the context stack. + * @param array $namespace_stack The reference to the store namespace stack. + * @param array $tag_stack The reference to the tag stack. + */ + private function data_wp_each_processor( WP_Interactivity_API_Directives_Processor $p, array &$context_stack, array &$namespace_stack, array &$tag_stack ) { + if ( ! $p->is_tag_closer() && 'TEMPLATE' === $p->get_tag() ) { + $attribute_name = $p->get_attribute_names_with_prefix( 'data-wp-each' )[0]; + $extracted_suffix = $this->extract_prefix_and_suffix( $attribute_name ); + $item_name = isset( $extracted_suffix[1] ) ? $this->kebab_to_camel_case( $extracted_suffix[1] ) : 'item'; + $attribute_value = $p->get_attribute( $attribute_name ); + $result = $this->evaluate( $attribute_value, end( $namespace_stack ), end( $context_stack ) ); + + // Gets the content between the template tags and leaves the cursor in the closer tag. + $inner_content = $p->get_content_between_balanced_template_tags(); + + // Checks if there is a manual server-side directive processing. + $template_end = 'data-wp-each: template end'; + $p->set_bookmark( $template_end ); + $p->next_tag(); + $manual_sdp = $p->get_attribute( 'data-wp-each-child' ); + $p->seek( $template_end ); // Rewinds to the template closer tag. + $p->release_bookmark( $template_end ); + + /* + * It doesn't process in these situations: + * - Manual server-side directive processing. + * - Empty or non-array values. + * - Associative arrays because those are deserialized as objects in JS. + * - Templates that contain top-level texts because those texts can't be + * identified and removed in the client. + */ + if ( + $manual_sdp || + empty( $result ) || + ! is_array( $result ) || + ! array_is_list( $result ) || + ! str_starts_with( trim( $inner_content ), '<' ) || + ! str_ends_with( trim( $inner_content ), '>' ) + ) { + array_pop( $tag_stack ); + return; + } + + // Extracts the namespace from the directive attribute value. + $namespace_value = end( $namespace_stack ); + list( $namespace_value ) = is_string( $attribute_value ) && ! empty( $attribute_value ) + ? $this->extract_directive_value( $attribute_value, $namespace_value ) + : array( $namespace_value, null ); + + // Processes the inner content for each item of the array. + $processed_content = ''; + foreach ( $result as $item ) { + // Creates a new context that includes the current item of the array. + array_push( + $context_stack, + array_replace_recursive( + end( $context_stack ) !== false ? end( $context_stack ) : array(), + array( $namespace_value => array( $item_name => $item ) ) + ) + ); + + // Processes the inner content with the new context. + $processed_item = $this->process_directives_args( $inner_content, $context_stack, $namespace_stack ); + + if ( null === $processed_item ) { + // If the HTML is unbalanced, stop processing it. + array_pop( $context_stack ); + return; + } + + // Adds the `data-wp-each-child` to each top-level tag. + $i = new WP_Interactivity_API_Directives_Processor( $processed_item ); + while ( $i->next_tag() ) { + $i->set_attribute( 'data-wp-each-child', true ); + $i->next_balanced_tag_closer_tag(); + } + $processed_content .= $i->get_updated_html(); + + // Removes the current context from the stack. + array_pop( $context_stack ); + } + + // Appends the processed content after the tag closer of the template. + $p->append_content_after_template_tag_closer( $processed_content ); + + // Pops the last tag because it skipped the closing tag of the template tag. + array_pop( $tag_stack ); + } + } + } } diff --git a/lib/experimental/interactivity-api.php b/lib/experimental/interactivity-api.php new file mode 100644 index 00000000000000..aff57bf0bce807 --- /dev/null +++ b/lib/experimental/interactivity-api.php @@ -0,0 +1,22 @@ + { @@ -102,7 +103,6 @@ describe( 'BlockTitle', () => { getBlockName: () => 'reusable-block', getBlockType: ( name ) => blockTypeMap[ name ], getBlockAttributes: () => ( { ref: 1 } ), - __experimentalGetReusableBlockTitle: () => 'Reuse me!', } ) ) ); diff --git a/packages/block-editor/src/components/block-title/use-block-display-title.js b/packages/block-editor/src/components/block-title/use-block-display-title.js index 1e4578630b4723..a51b336554a2a7 100644 --- a/packages/block-editor/src/components/block-title/use-block-display-title.js +++ b/packages/block-editor/src/components/block-title/use-block-display-title.js @@ -3,7 +3,6 @@ */ import { useSelect } from '@wordpress/data'; import { - isReusableBlock, __experimentalGetBlockLabel as getBlockLabel, store as blocksStore, } from '@wordpress/blocks'; @@ -40,11 +39,8 @@ export default function useBlockDisplayTitle( { return null; } - const { - getBlockName, - getBlockAttributes, - __experimentalGetReusableBlockTitle, - } = select( blockEditorStore ); + const { getBlockName, getBlockAttributes } = + select( blockEditorStore ); const { getBlockType, getActiveBlockVariation } = select( blocksStore ); @@ -55,15 +51,6 @@ export default function useBlockDisplayTitle( { } const attributes = getBlockAttributes( clientId ); - const isReusable = isReusableBlock( blockType ); - const reusableBlockTitle = isReusable - ? __experimentalGetReusableBlockTitle( attributes.ref ) - : null; - - if ( reusableBlockTitle ) { - return reusableBlockTitle; - } - const label = getBlockLabel( blockType, attributes, context ); // If the label is defined we prioritize it over a possible block variation title match. if ( label !== blockType.title ) { diff --git a/packages/block-editor/src/components/inner-blocks/index.js b/packages/block-editor/src/components/inner-blocks/index.js index 76f3d70041464f..deb4328212b105 100644 --- a/packages/block-editor/src/components/inner-blocks/index.js +++ b/packages/block-editor/src/components/inner-blocks/index.js @@ -203,7 +203,7 @@ export function useInnerBlocksProps( props = {}, options = {} ) { getBlockRootClientId, getBlockEditingMode, getBlockSettings, - isDraggingBlocks, + isDragging, } = unlock( select( blockEditorStore ) ); const { hasBlockSupport, getBlockType } = select( blocksStore ); const blockName = getBlockName( clientId ); @@ -223,7 +223,7 @@ export function useInnerBlocksProps( props = {}, options = {} ) { ! isBlockSelected( clientId ) && ! hasSelectedInnerBlock( clientId, true ) && enableClickThrough && - ! isDraggingBlocks(), + ! isDragging(), name: blockName, blockType: getBlockType( blockName ), parentLock: getTemplateLock( parentClientId ), diff --git a/packages/block-editor/src/components/inserter-draggable-blocks/index.js b/packages/block-editor/src/components/inserter-draggable-blocks/index.js index cdaadcb0f36eb5..7d20b5e53650bf 100644 --- a/packages/block-editor/src/components/inserter-draggable-blocks/index.js +++ b/packages/block-editor/src/components/inserter-draggable-blocks/index.js @@ -7,12 +7,15 @@ import { serialize, store as blocksStore, } from '@wordpress/blocks'; -import { useSelect } from '@wordpress/data'; +import { useDispatch, useSelect } from '@wordpress/data'; + /** * Internal dependencies */ import BlockDraggableChip from '../block-draggable/draggable-chip'; import { INSERTER_PATTERN_TYPES } from '../inserter/block-patterns-tab/utils'; +import { store as blockEditorStore } from '../../store'; +import { unlock } from '../../lock-unlock'; const InserterDraggableBlocks = ( { isEnabled, @@ -36,11 +39,16 @@ const InserterDraggableBlocks = ( { [ blocks ] ); + const { startDragging, stopDragging } = unlock( + useDispatch( blockEditorStore ) + ); + return ( { + startDragging(); const parsedBlocks = pattern?.type === INSERTER_PATTERN_TYPES.user && pattern?.syncStatus !== 'unsynced' @@ -51,6 +59,9 @@ const InserterDraggableBlocks = ( { serialize( parsedBlocks ) ); } } + onDragEnd={ () => { + stopDragging(); + } } __experimentalDragComponent={ - { ! settings.__unstableIsPreviewMode && ( + { ! settings?.__unstableIsPreviewMode && ( ) } { children } diff --git a/packages/block-editor/src/components/url-popover/image-url-input-ui.js b/packages/block-editor/src/components/url-popover/image-url-input-ui.js index 0eaea3149197b9..f45770004f045d 100644 --- a/packages/block-editor/src/components/url-popover/image-url-input-ui.js +++ b/packages/block-editor/src/components/url-popover/image-url-input-ui.js @@ -2,7 +2,8 @@ * WordPress dependencies */ import { __ } from '@wordpress/i18n'; -import { useRef, useState } from '@wordpress/element'; +import { useRef, useEffect, useState } from '@wordpress/element'; +import { focus } from '@wordpress/dom'; import { ToolbarButton, Button, @@ -57,6 +58,17 @@ const ImageURLInputUI = ( { const [ urlInput, setUrlInput ] = useState( null ); const autocompleteRef = useRef( null ); + const wrapperRef = useRef(); + + useEffect( () => { + if ( ! wrapperRef.current ) { + return; + } + const nextFocusTarget = + focus.focusable.find( wrapperRef.current )[ 0 ] || + wrapperRef.current; + nextFocusTarget.focus(); + }, [ isEditingLink, url, lightboxEnabled ] ); const startEditLink = () => { if ( @@ -249,6 +261,7 @@ const ImageURLInputUI = ( { /> { isOpen && ( ) } + offset={ 13 } > { ( ! url || isEditingLink ) && ! lightboxEnabled && ( <> diff --git a/packages/block-editor/src/components/url-popover/index.js b/packages/block-editor/src/components/url-popover/index.js index a62d1bb750b153..b5bbe8f50958bb 100644 --- a/packages/block-editor/src/components/url-popover/index.js +++ b/packages/block-editor/src/components/url-popover/index.js @@ -2,7 +2,7 @@ * WordPress dependencies */ import { __ } from '@wordpress/i18n'; -import { useState } from '@wordpress/element'; +import { forwardRef, useState } from '@wordpress/element'; import { Button, Popover, @@ -24,83 +24,89 @@ const { __experimentalPopoverLegacyPositionToPlacement } = unlock( const DEFAULT_PLACEMENT = 'bottom'; -function URLPopover( { - additionalControls, - children, - renderSettings, - // The DEFAULT_PLACEMENT value is assigned inside the function's body - placement, - focusOnMount = 'firstElement', - // Deprecated - position, - // Rest - ...popoverProps -} ) { - if ( position !== undefined ) { - deprecated( '`position` prop in wp.blockEditor.URLPopover', { - since: '6.2', - alternative: '`placement` prop', - } ); - } +const URLPopover = forwardRef( + ( + { + additionalControls, + children, + renderSettings, + // The DEFAULT_PLACEMENT value is assigned inside the function's body + placement, + focusOnMount = 'firstElement', + // Deprecated + position, + // Rest + ...popoverProps + }, + ref + ) => { + if ( position !== undefined ) { + deprecated( '`position` prop in wp.blockEditor.URLPopover', { + since: '6.2', + alternative: '`placement` prop', + } ); + } - // Compute popover's placement: - // - give priority to `placement` prop, if defined - // - otherwise, compute it from the legacy `position` prop (if defined) - // - finally, fallback to the DEFAULT_PLACEMENT. - let computedPlacement; - if ( placement !== undefined ) { - computedPlacement = placement; - } else if ( position !== undefined ) { - computedPlacement = - __experimentalPopoverLegacyPositionToPlacement( position ); - } - computedPlacement = computedPlacement || DEFAULT_PLACEMENT; + // Compute popover's placement: + // - give priority to `placement` prop, if defined + // - otherwise, compute it from the legacy `position` prop (if defined) + // - finally, fallback to the DEFAULT_PLACEMENT. + let computedPlacement; + if ( placement !== undefined ) { + computedPlacement = placement; + } else if ( position !== undefined ) { + computedPlacement = + __experimentalPopoverLegacyPositionToPlacement( position ); + } + computedPlacement = computedPlacement || DEFAULT_PLACEMENT; - const [ isSettingsExpanded, setIsSettingsExpanded ] = useState( false ); + const [ isSettingsExpanded, setIsSettingsExpanded ] = useState( false ); - const showSettings = !! renderSettings && isSettingsExpanded; + const showSettings = !! renderSettings && isSettingsExpanded; - const toggleSettingsVisibility = () => { - setIsSettingsExpanded( ! isSettingsExpanded ); - }; + const toggleSettingsVisibility = () => { + setIsSettingsExpanded( ! isSettingsExpanded ); + }; - return ( - -
-
- { children } - { !! renderSettings && ( -
+ { showSettings && ( +
+ { renderSettings() } +
) }
- { showSettings && ( -
- { renderSettings() } + { additionalControls && ! showSettings && ( +
+ { additionalControls }
) } -
- { additionalControls && ! showSettings && ( -
- { additionalControls } -
- ) } -
- ); -} + + ); + } +); URLPopover.LinkEditor = LinkEditor; diff --git a/packages/block-editor/src/components/use-block-drop-zone/index.js b/packages/block-editor/src/components/use-block-drop-zone/index.js index b1573133dcd056..857a132f1f9fa4 100644 --- a/packages/block-editor/src/components/use-block-drop-zone/index.js +++ b/packages/block-editor/src/components/use-block-drop-zone/index.js @@ -23,6 +23,7 @@ import { isPointWithinTopAndBottomBoundariesOfRect, } from '../../utils/math'; import { store as blockEditorStore } from '../../store'; +import { unlock } from '../../lock-unlock'; const THRESHOLD_DISTANCE = 30; const MINIMUM_HEIGHT_FOR_THRESHOLD = 120; @@ -308,9 +309,14 @@ export default function useBlockDropZone( { getDraggedBlockClientIds, getBlockNamesByClientId, getAllowedBlocks, - } = useSelect( blockEditorStore ); - const { showInsertionPoint, hideInsertionPoint } = - useDispatch( blockEditorStore ); + isDragging, + } = unlock( useSelect( blockEditorStore ) ); + const { + showInsertionPoint, + hideInsertionPoint, + startDragging, + stopDragging, + } = unlock( useDispatch( blockEditorStore ) ); const onBlockDrop = useOnBlockDrop( dropTarget.operation === 'before' || dropTarget.operation === 'after' @@ -325,6 +331,11 @@ export default function useBlockDropZone( { const throttled = useThrottle( useCallback( ( event, ownerDocument ) => { + if ( ! isDragging() ) { + // When dragging from the desktop, no drag start event is fired. + // So, ensure that the drag state is set when the user drags over a drop zone. + startDragging(); + } const allowedBlocks = getAllowedBlocks( targetRootClientId ); const targetBlockName = getBlockNamesByClientId( [ targetRootClientId, @@ -423,6 +434,8 @@ export default function useBlockDropZone( { getBlockIndex, registry, showInsertionPoint, + isDragging, + startDragging, ] ), 200 @@ -444,6 +457,7 @@ export default function useBlockDropZone( { }, onDragEnd() { throttled.cancel(); + stopDragging(); hideInsertionPoint(); }, } ); diff --git a/packages/block-editor/src/hooks/style.js b/packages/block-editor/src/hooks/style.js index 52fd990a3cac44..42fe431a40b242 100644 --- a/packages/block-editor/src/hooks/style.js +++ b/packages/block-editor/src/hooks/style.js @@ -116,7 +116,9 @@ const skipSerializationPathsEdit = { [ `${ SPACING_SUPPORT_KEY }.__experimentalSkipSerialization` ]: [ SPACING_SUPPORT_KEY, ], - [ `${ SHADOW_SUPPORT_KEY }` ]: [ SHADOW_SUPPORT_KEY ], + [ `${ SHADOW_SUPPORT_KEY }.__experimentalSkipSerialization` ]: [ + SHADOW_SUPPORT_KEY, + ], }; /** diff --git a/packages/block-editor/src/store/private-actions.js b/packages/block-editor/src/store/private-actions.js index aea3613884bb69..1d0d41197bc1ee 100644 --- a/packages/block-editor/src/store/private-actions.js +++ b/packages/block-editor/src/store/private-actions.js @@ -370,3 +370,25 @@ export function registerBlockBindingsSource( source ) { lockAttributesEditing: source.lockAttributesEditing, }; } + +/** + * Returns an action object used in signalling that the user has begun to drag. + * + * @return {Object} Action object. + */ +export function startDragging() { + return { + type: 'START_DRAGGING', + }; +} + +/** + * Returns an action object used in signalling that the user has stopped dragging. + * + * @return {Object} Action object. + */ +export function stopDragging() { + return { + type: 'STOP_DRAGGING', + }; +} diff --git a/packages/block-editor/src/store/private-selectors.js b/packages/block-editor/src/store/private-selectors.js index ee8faeab155b8c..4700e50f739f45 100644 --- a/packages/block-editor/src/store/private-selectors.js +++ b/packages/block-editor/src/store/private-selectors.js @@ -351,3 +351,16 @@ export function getAllBlockBindingsSources( state ) { export function getBlockBindingsSource( state, sourceName ) { return state.blockBindingsSources[ sourceName ]; } + +/** + * Returns true if the user is dragging anything, or false otherwise. It is possible for a + * user to be dragging data from outside of the editor, so this selector is separate from + * the `isDraggingBlocks` selector which only returns true if the user is dragging blocks. + * + * @param {Object} state Global application state. + * + * @return {boolean} Whether user is dragging. + */ +export function isDragging( state ) { + return state.isDragging; +} diff --git a/packages/block-editor/src/store/reducer.js b/packages/block-editor/src/store/reducer.js index dc69a4da609a4d..c465e390213036 100644 --- a/packages/block-editor/src/store/reducer.js +++ b/packages/block-editor/src/store/reducer.js @@ -1246,6 +1246,27 @@ export function isTyping( state = false, action ) { return state; } +/** + * Reducer returning dragging state. It is possible for a user to be dragging + * data from outside of the editor, so this state is separate from `draggedBlocks`. + * + * @param {boolean} state Current state. + * @param {Object} action Dispatched action. + * + * @return {boolean} Updated state. + */ +export function isDragging( state = false, action ) { + switch ( action.type ) { + case 'START_DRAGGING': + return true; + + case 'STOP_DRAGGING': + return false; + } + + return state; +} + /** * Reducer returning dragged block client id. * @@ -2048,6 +2069,7 @@ function blockPatterns( state = [], action ) { const combinedReducers = combineReducers( { blocks, + isDragging, isTyping, isBlockInterfaceHidden, draggedBlocks, diff --git a/packages/block-editor/src/store/test/private-actions.js b/packages/block-editor/src/store/test/private-actions.js index 5763cb382937b2..08370f731902d2 100644 --- a/packages/block-editor/src/store/test/private-actions.js +++ b/packages/block-editor/src/store/test/private-actions.js @@ -6,6 +6,8 @@ import { showBlockInterface, __experimentalUpdateSettings, setOpenedBlockSettingsMenu, + startDragging, + stopDragging, } from '../private-actions'; describe( 'private actions', () => { @@ -95,4 +97,20 @@ describe( 'private actions', () => { } ); } ); } ); + + describe( 'startDragging', () => { + it( 'should return the START_DRAGGING action', () => { + expect( startDragging() ).toEqual( { + type: 'START_DRAGGING', + } ); + } ); + } ); + + describe( 'stopDragging', () => { + it( 'should return the STOP_DRAGGING action', () => { + expect( stopDragging() ).toEqual( { + type: 'STOP_DRAGGING', + } ); + } ); + } ); } ); diff --git a/packages/block-editor/src/store/test/private-selectors.js b/packages/block-editor/src/store/test/private-selectors.js index 746a51b6031101..f661271b570b4b 100644 --- a/packages/block-editor/src/store/test/private-selectors.js +++ b/packages/block-editor/src/store/test/private-selectors.js @@ -7,6 +7,7 @@ import { isBlockSubtreeDisabled, getEnabledClientIdsTree, getEnabledBlockParents, + isDragging, } from '../private-selectors'; import { getBlockEditingMode } from '../selectors'; @@ -477,4 +478,22 @@ describe( 'private selectors', () => { ] ); } ); } ); + + describe( 'isDragging', () => { + it( 'should return true if the dragging state is true', () => { + const state = { + isDragging: true, + }; + + expect( isDragging( state ) ).toBe( true ); + } ); + + it( 'should return false if the dragging state is false', () => { + const state = { + isDragging: false, + }; + + expect( isDragging( state ) ).toBe( false ); + } ); + } ); } ); diff --git a/packages/block-editor/src/store/test/reducer.js b/packages/block-editor/src/store/test/reducer.js index 8c82d1c3092b16..c99d914ba21755 100644 --- a/packages/block-editor/src/store/test/reducer.js +++ b/packages/block-editor/src/store/test/reducer.js @@ -21,6 +21,7 @@ import { blocks, isBlockInterfaceHidden, isTyping, + isDragging, draggedBlocks, selection, initialPosition, @@ -2445,6 +2446,24 @@ describe( 'state', () => { } ); } ); + describe( 'isDragging', () => { + it( 'should set the dragging flag to true', () => { + const state = isDragging( false, { + type: 'START_DRAGGING', + } ); + + expect( state ).toBe( true ); + } ); + + it( 'should set the dragging flag to false', () => { + const state = isDragging( true, { + type: 'STOP_DRAGGING', + } ); + + expect( state ).toBe( false ); + } ); + } ); + describe( 'draggedBlocks', () => { it( 'should store the dragged client ids when a user starts dragging blocks', () => { const clientIds = [ 'block-1', 'block-2', 'block-3' ]; diff --git a/packages/block-library/src/block/block.json b/packages/block-library/src/block/block.json index 0e5565233ef3c2..34dcb9a396ac6f 100644 --- a/packages/block-library/src/block/block.json +++ b/packages/block-library/src/block/block.json @@ -11,7 +11,7 @@ "ref": { "type": "number" }, - "overrides": { + "content": { "type": "object" } }, diff --git a/packages/block-library/src/block/deprecated.js b/packages/block-library/src/block/deprecated.js new file mode 100644 index 00000000000000..7bc243bbf4ce98 --- /dev/null +++ b/packages/block-library/src/block/deprecated.js @@ -0,0 +1,57 @@ +// v1: Migrate and rename the `overrides` attribute to the `content` attribute. +const v1 = { + attributes: { + ref: { + type: 'number', + }, + overrides: { + type: 'object', + }, + }, + supports: { + customClassName: false, + html: false, + inserter: false, + renaming: false, + }, + // Force this deprecation to run whenever there's an `overrides` object. + isEligible( { overrides } ) { + return !! overrides; + }, + /* + * Old attribute format: + * overrides: { + * // An key is an id that represents a block. + * // The values are the attribute values of the block. + * "V98q_x": { content: 'dwefwefwefwe' } + * } + * + * 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. + * } + * } + * + */ + migrate( attributes ) { + const { overrides, ...retainedAttributes } = attributes; + + const content = {}; + + Object.keys( overrides ).forEach( ( id ) => { + content[ id ] = { + values: overrides[ id ], + }; + } ); + + return { + ...retainedAttributes, + content, + }; + }, +}; + +export default [ v1 ]; diff --git a/packages/block-library/src/block/edit.js b/packages/block-library/src/block/edit.js index ae1cca181561f3..9c1e81de04e17a 100644 --- a/packages/block-library/src/block/edit.js +++ b/packages/block-library/src/block/edit.js @@ -38,25 +38,6 @@ import { unlock } from '../lock-unlock'; const { useLayoutClasses } = unlock( blockEditorPrivateApis ); const { PARTIAL_SYNCING_SUPPORTED_BLOCKS } = unlock( patternsPrivateApis ); -function isPartiallySynced( block ) { - return ( - Object.keys( PARTIAL_SYNCING_SUPPORTED_BLOCKS ).includes( - block.name - ) && - !! block.attributes.metadata?.bindings && - Object.values( block.attributes.metadata.bindings ).some( - ( binding ) => binding.source === 'core/pattern-overrides' - ) - ); -} -function getPartiallySyncedAttributes( block ) { - return Object.entries( block.attributes.metadata.bindings ) - .filter( - ( [ , binding ] ) => binding.source === 'core/pattern-overrides' - ) - .map( ( [ attributeKey ] ) => attributeKey ); -} - const fullAlignments = [ 'full', 'wide', 'left', 'right' ]; const useInferredLayout = ( blocks, parentLayout ) => { @@ -88,25 +69,57 @@ const useInferredLayout = ( blocks, parentLayout ) => { }, [ blocks, parentLayout ] ); }; -function applyInitialOverrides( blocks, overrides = {}, defaultValues ) { +function hasOverridableAttributes( block ) { + return ( + Object.keys( PARTIAL_SYNCING_SUPPORTED_BLOCKS ).includes( + block.name + ) && + !! block.attributes.metadata?.bindings && + Object.values( block.attributes.metadata.bindings ).some( + ( binding ) => binding.source === 'core/pattern-overrides' + ) + ); +} + +function hasOverridableBlocks( blocks ) { + return blocks.some( ( block ) => { + if ( hasOverridableAttributes( block ) ) return true; + return hasOverridableBlocks( block.innerBlocks ); + } ); +} + +function getOverridableAttributes( block ) { + return Object.entries( block.attributes.metadata.bindings ) + .filter( + ( [ , binding ] ) => binding.source === 'core/pattern-overrides' + ) + .map( ( [ attributeKey ] ) => attributeKey ); +} + +function applyInitialContentValuesToInnerBlocks( + blocks, + content = {}, + defaultValues +) { return blocks.map( ( block ) => { - const innerBlocks = applyInitialOverrides( + const innerBlocks = applyInitialContentValuesToInnerBlocks( block.innerBlocks, - overrides, + content, defaultValues ); const blockId = block.attributes.metadata?.id; - if ( ! isPartiallySynced( block ) || ! blockId ) + if ( ! hasOverridableAttributes( block ) || ! blockId ) return { ...block, innerBlocks }; - const attributes = getPartiallySyncedAttributes( block ); + const attributes = getOverridableAttributes( block ); const newAttributes = { ...block.attributes }; for ( const attributeKey of attributes ) { defaultValues[ blockId ] ??= {}; defaultValues[ blockId ][ attributeKey ] = block.attributes[ attributeKey ]; - if ( overrides[ blockId ]?.[ attributeKey ] !== undefined ) { - newAttributes[ attributeKey ] = - overrides[ blockId ][ attributeKey ]; + + const contentValues = content[ blockId ]?.values; + if ( contentValues?.[ attributeKey ] !== undefined ) { + newAttributes[ attributeKey ] = contentValues[ attributeKey ]; } } return { @@ -117,52 +130,46 @@ function applyInitialOverrides( blocks, overrides = {}, defaultValues ) { } ); } -function getOverridesFromBlocks( blocks, defaultValues ) { - /** @type {Record>} */ - const overrides = {}; +function getContentValuesFromInnerBlocks( blocks, defaultValues ) { + /** @type {Record}>} */ + const content = {}; for ( const block of blocks ) { Object.assign( - overrides, - getOverridesFromBlocks( block.innerBlocks, defaultValues ) + content, + getContentValuesFromInnerBlocks( block.innerBlocks, defaultValues ) ); const blockId = block.attributes.metadata?.id; - if ( ! isPartiallySynced( block ) || ! blockId ) continue; - const attributes = getPartiallySyncedAttributes( block ); + if ( ! hasOverridableAttributes( block ) || ! blockId ) continue; + const attributes = getOverridableAttributes( block ); for ( const attributeKey of attributes ) { if ( block.attributes[ attributeKey ] !== defaultValues[ blockId ][ attributeKey ] ) { - overrides[ blockId ] ??= {}; + content[ blockId ] ??= { values: {} }; // TODO: We need a way to represent `undefined` in the serialized overrides. // Also see: https://github.com/WordPress/gutenberg/pull/57249#discussion_r1452987871 - overrides[ blockId ][ attributeKey ] = + content[ blockId ].values[ attributeKey ] = block.attributes[ attributeKey ]; } } } - return Object.keys( overrides ).length > 0 ? overrides : undefined; + return Object.keys( content ).length > 0 ? content : undefined; } function setBlockEditMode( setEditMode, blocks, mode ) { blocks.forEach( ( block ) => { const editMode = - mode || ( isPartiallySynced( block ) ? 'contentOnly' : 'disabled' ); + mode || + ( hasOverridableAttributes( block ) ? 'contentOnly' : 'disabled' ); setEditMode( block.clientId, editMode ); setBlockEditMode( setEditMode, block.innerBlocks, mode ); } ); } -function getHasOverridableBlocks( blocks ) { - return blocks.some( ( block ) => { - if ( isPartiallySynced( block ) ) return true; - return getHasOverridableBlocks( block.innerBlocks ); - } ); -} - export default function ReusableBlockEdit( { name, - attributes: { ref, overrides }, + attributes: { ref, content }, __unstableParentLayout: parentLayout, clientId: patternClientId, setAttributes, @@ -175,8 +182,13 @@ export default function ReusableBlockEdit( { ref ); const isMissing = hasResolved && ! record; - const initialOverrides = useRef( overrides ); - const defaultValuesRef = useRef( {} ); + + // The initial value of the `content` attribute. + const initialContent = useRef( content ); + + // The default content values from the original pattern for overridable attributes. + // Set by the `applyInitialContentValuesToInnerBlocks` function. + const defaultContent = useRef( {} ); const { replaceInnerBlocks, @@ -220,8 +232,8 @@ export default function ReusableBlockEdit( { [ innerBlocks, setBlockEditingMode ] ); - const hasOverridableBlocks = useMemo( - () => getHasOverridableBlocks( innerBlocks ), + const canOverrideBlocks = useMemo( + () => hasOverridableBlocks( innerBlocks ), [ innerBlocks ] ); @@ -237,18 +249,17 @@ export default function ReusableBlockEdit( { // Apply the initial overrides from the pattern block to the inner blocks. useEffect( () => { - defaultValuesRef.current = {}; + defaultContent.current = {}; const editingMode = getBlockEditingMode( patternClientId ); - // Replace the contents of the blocks with the overrides. registry.batch( () => { setBlockEditingMode( patternClientId, 'default' ); syncDerivedUpdates( () => { replaceInnerBlocks( patternClientId, - applyInitialOverrides( + applyInitialContentValuesToInnerBlocks( initialBlocks, - initialOverrides.current, - defaultValuesRef.current + initialContent.current, + defaultContent.current ) ); } ); @@ -287,7 +298,7 @@ export default function ReusableBlockEdit( { : InnerBlocks.ButtonBlockAppender, } ); - // Sync the `overrides` attribute from the updated blocks to the pattern block. + // Sync the `content` attribute from the updated blocks to the pattern block. // `syncDerivedUpdates` is used here to avoid creating an additional undo level. useEffect( () => { const { getBlocks } = registry.select( blockEditorStore ); @@ -298,9 +309,9 @@ export default function ReusableBlockEdit( { prevBlocks = blocks; syncDerivedUpdates( () => { setAttributes( { - overrides: getOverridesFromBlocks( + content: getContentValuesFromInnerBlocks( blocks, - defaultValuesRef.current + defaultContent.current ), } ); } ); @@ -313,8 +324,8 @@ export default function ReusableBlockEdit( { editOriginalProps.onClick( event ); }; - const resetOverrides = () => { - if ( overrides ) { + const resetContent = () => { + if ( content ) { replaceInnerBlocks( patternClientId, initialBlocks ); } }; @@ -360,12 +371,12 @@ export default function ReusableBlockEdit( { ) } - { hasOverridableBlocks && ( + { canOverrideBlocks && ( { __( 'Reset' ) } diff --git a/packages/block-library/src/block/index.js b/packages/block-library/src/block/index.js index 95e090f0afd6ad..7f1fa7bdab3494 100644 --- a/packages/block-library/src/block/index.js +++ b/packages/block-library/src/block/index.js @@ -2,6 +2,9 @@ * WordPress dependencies */ import { symbol as icon } from '@wordpress/icons'; +import { store as coreStore } from '@wordpress/core-data'; +import { select } from '@wordpress/data'; +import { decodeEntities } from '@wordpress/html-entities'; /** * Internal dependencies @@ -9,14 +12,32 @@ import { symbol as icon } from '@wordpress/icons'; import initBlock from '../utils/init-block'; import metadata from './block.json'; import edit from './edit'; +import deprecated from './deprecated'; const { name } = metadata; export { metadata, name }; export const settings = { + deprecated, edit, icon, + __experimentalLabel: ( { ref } ) => { + if ( ! ref ) { + return; + } + + const entity = select( coreStore ).getEditedEntityRecord( + 'postType', + 'wp_block', + ref + ); + if ( ! entity?.title ) { + return; + } + + return decodeEntities( entity.title ); + }, }; export const init = () => initBlock( { name, metadata, settings } ); diff --git a/packages/block-library/src/block/index.php b/packages/block-library/src/block/index.php index 444001fa498595..8e24317501d9fa 100644 --- a/packages/block-library/src/block/index.php +++ b/packages/block-library/src/block/index.php @@ -46,7 +46,20 @@ function render_block_core_block( $attributes ) { $content = $wp_embed->run_shortcode( $reusable_block->post_content ); $content = $wp_embed->autoembed( $content ); - $has_pattern_overrides = isset( $attributes['overrides'] ); + // 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, + ); + } + $attributes['content'] = $migrated_content; + } + $has_pattern_overrides = isset( $attributes['content'] ); /** * We set the `pattern/overrides` context through the `render_block_context` @@ -55,7 +68,7 @@ function render_block_core_block( $attributes ) { */ if ( $has_pattern_overrides ) { $filter_block_context = static function ( $context ) use ( $attributes ) { - $context['pattern/overrides'] = $attributes['overrides']; + $context['pattern/overrides'] = $attributes['content']; return $context; }; add_filter( 'render_block_context', $filter_block_context, 1 ); diff --git a/packages/block-library/src/button/block.json b/packages/block-library/src/button/block.json index aa5d81c65bad31..c3d51c61a0999a 100644 --- a/packages/block-library/src/button/block.json +++ b/packages/block-library/src/button/block.json @@ -98,7 +98,9 @@ } }, "reusable": false, - "shadow": true, + "shadow": { + "__experimentalSkipSerialization": true + }, "spacing": { "__experimentalSkipSerialization": true, "padding": [ "horizontal", "vertical" ], diff --git a/packages/block-library/src/query/view.js b/packages/block-library/src/query/view.js index dc82d7968dad45..ee811b4b8e90f1 100644 --- a/packages/block-library/src/query/view.js +++ b/packages/block-library/src/query/view.js @@ -23,7 +23,9 @@ store( 'core/query', { *navigate( event ) { const ctx = getContext(); const { ref } = getElement(); - const { queryRef } = ctx; + const queryRef = ref.closest( + '.wp-block-query[data-wp-router-region]' + ); const isDisabled = queryRef?.dataset.wpNavigationDisabled; if ( isValidLink( ref ) && isValidEvent( event ) && ! isDisabled ) { @@ -41,8 +43,10 @@ store( 'core/query', { } }, *prefetch() { - const { queryRef } = getContext(); const { ref } = getElement(); + const queryRef = ref.closest( + '.wp-block-query[data-wp-router-region]' + ); const isDisabled = queryRef?.dataset.wpNavigationDisabled; if ( isValidLink( ref ) && ! isDisabled ) { const { actions } = yield import( @@ -63,10 +67,5 @@ store( 'core/query', { yield actions.prefetch( ref.href ); } }, - setQueryRef() { - const ctx = getContext(); - const { ref } = getElement(); - ctx.queryRef = ref; - }, }, } ); diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-each/render.php b/packages/e2e-tests/plugins/interactive-blocks/directive-each/render.php index 3d018bca46d060..03a2b89cc1233d 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/directive-each/render.php +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-each/render.php @@ -21,6 +21,18 @@
+
+ + +

A

+

B

+

C

+
+ +
+
); diff --git a/packages/edit-site/src/components/global-styles/font-library-modal/google-fonts-confirm-dialog.js b/packages/edit-site/src/components/global-styles/font-library-modal/google-fonts-confirm-dialog.js index 8d7954c54dbcce..67140fbe4d0d93 100644 --- a/packages/edit-site/src/components/global-styles/font-library-modal/google-fonts-confirm-dialog.js +++ b/packages/edit-site/src/components/global-styles/font-library-modal/google-fonts-confirm-dialog.js @@ -34,7 +34,7 @@ function GoogleFontsConfirmDialog() { { __( - 'You can alternatively upload files directly on the Library tab.' + 'You can alternatively upload files directly on the Upload tab.' ) } diff --git a/packages/edit-site/src/components/global-styles/font-library-modal/style.scss b/packages/edit-site/src/components/global-styles/font-library-modal/style.scss index cd55fe99bcd369..51c7bfaaf38bcc 100644 --- a/packages/edit-site/src/components/global-styles/font-library-modal/style.scss +++ b/packages/edit-site/src/components/global-styles/font-library-modal/style.scss @@ -65,6 +65,25 @@ margin-top: -1px; /* To collapse the margin with the previous element */ } +.font-library-modal__font-variant_demo-image { + display: block; + height: $grid-unit-30; + width: auto; +} + +.font-library-modal__font-variant_demo-text { + white-space: nowrap; + flex-shrink: 0; + font-size: 18px; + transition: opacity 0.3s ease-in-out; + @include reduce-motion("transition"); +} + +.font-library-modal__font-variant { + border-bottom: 1px solid $gray-200; + padding-bottom: $grid-unit-20; +} + .font-library-modal__tabs { [role="tablist"] { position: sticky; diff --git a/packages/edit-site/src/components/page-templates-template-parts/index.js b/packages/edit-site/src/components/page-templates-template-parts/index.js index b95489b480f13d..514ff148071955 100644 --- a/packages/edit-site/src/components/page-templates-template-parts/index.js +++ b/packages/edit-site/src/components/page-templates-template-parts/index.js @@ -325,7 +325,6 @@ export default function PageTemplatesTemplateParts( { postType } ) { render: ( { item } ) => { return ; }, - enableHiding: false, type: ENUMERATION_TYPE, elements: authors, width: '1%', diff --git a/packages/interactivity-router/src/index.js b/packages/interactivity-router/src/index.js index a985ed3d74b896..46f184b4ec030e 100644 --- a/packages/interactivity-router/src/index.js +++ b/packages/interactivity-router/src/index.js @@ -1,13 +1,12 @@ /** * WordPress dependencies */ -import { - render, - directivePrefix, - toVdom, - getRegionRootFragment, - store, -} from '@wordpress/interactivity'; +import { render, store, privateApis } from '@wordpress/interactivity'; + +const { directivePrefix, getRegionRootFragment, initialVdom, toVdom } = + privateApis( + 'I acknowledge that using private APIs means my theme or plugin will inevitably break in the next version of WordPress.' + ); // The cache of visited and prefetched pages. const pages = new Map(); @@ -36,12 +35,14 @@ const fetchPage = async ( url, { html } ) => { // Return an object with VDOM trees of those HTML regions marked with a // `router-region` directive. -const regionsToVdom = ( dom ) => { +const regionsToVdom = ( dom, { vdom } = {} ) => { const regions = {}; const attrName = `data-${ directivePrefix }-router-region`; dom.querySelectorAll( `[${ attrName }]` ).forEach( ( region ) => { const id = region.getAttribute( attrName ); - regions[ id ] = toVdom( region ); + regions[ id ] = vdom?.has( region ) + ? vdom.get( region ) + : toVdom( region ); } ); const title = dom.querySelector( 'title' )?.innerText; return { regions, title }; @@ -74,10 +75,10 @@ window.addEventListener( 'popstate', async () => { } } ); -// Cache the current regions. +// Cache the initial page using the intially parsed vDOM. pages.set( getPagePath( window.location ), - Promise.resolve( regionsToVdom( document ) ) + Promise.resolve( regionsToVdom( document, { vdom: initialVdom } ) ) ); // Variable to store the current navigation. diff --git a/packages/interactivity/src/directives.js b/packages/interactivity/src/directives.js index 38849f53d7f953..f0df1dae4b3c93 100644 --- a/packages/interactivity/src/directives.js +++ b/packages/interactivity/src/directives.js @@ -13,6 +13,7 @@ import { deepSignal, peek } from 'deepsignal'; import { createPortal } from './portals'; import { useWatch, useInit } from './utils'; import { directive, getScope, getEvaluate } from './hooks'; +import { kebabToCamelCase } from './utils/kebab-to-camelcase'; const isObject = ( item ) => item && typeof item === 'object' && ! Array.isArray( item ); @@ -356,7 +357,8 @@ export default () => { return list.map( ( item ) => { const mergedContext = deepSignal( {} ); - const itemProp = suffix === 'default' ? 'item' : suffix; + const itemProp = + suffix === 'default' ? 'item' : kebabToCamelCase( suffix ); const newValue = deepSignal( { [ namespace ]: { [ itemProp ]: item }, } ); diff --git a/packages/interactivity/src/index.js b/packages/interactivity/src/index.ts similarity index 53% rename from packages/interactivity/src/index.js rename to packages/interactivity/src/index.ts index 5d9165dc9920ee..477b90db1efc1f 100644 --- a/packages/interactivity/src/index.js +++ b/packages/interactivity/src/index.ts @@ -2,7 +2,9 @@ * Internal dependencies */ import registerDirectives from './directives'; -import { init } from './init'; +import { init, getRegionRootFragment, initialVdom } from './init'; +import { directivePrefix } from './constants'; +import { toVdom } from './vdom'; export { store } from './store'; export { directive, getContext, getElement, getNamespace } from './hooks'; @@ -15,14 +17,27 @@ export { useCallback, useMemo, } from './utils'; -export { directivePrefix } from './constants'; -export { toVdom } from './vdom'; -export { getRegionRootFragment } from './init'; export { h as createElement, cloneElement, render } from 'preact'; export { useContext, useState, useRef } from 'preact/hooks'; export { deepSignal } from 'deepsignal'; +const requiredConsent = + 'I acknowledge that using private APIs means my theme or plugin will inevitably break in the next version of WordPress.'; + +export const privateApis = ( lock ) => { + if ( lock === requiredConsent ) { + return { + directivePrefix, + getRegionRootFragment, + initialVdom, + toVdom, + }; + } + + throw new Error( 'Forbidden access.' ); +}; + document.addEventListener( 'DOMContentLoaded', async () => { registerDirectives(); await init(); diff --git a/packages/interactivity/src/init.js b/packages/interactivity/src/init.js index 839302c4f8c6b2..fb510a9c00fced 100644 --- a/packages/interactivity/src/init.js +++ b/packages/interactivity/src/init.js @@ -28,6 +28,9 @@ function yieldToMain() { } ); } +// Initial vDOM regions associated with its DOM element. +export const initialVdom = new WeakMap(); + // Initialize the router with the initial DOM. export const init = async () => { const nodes = document.querySelectorAll( @@ -39,6 +42,7 @@ export const init = async () => { await yieldToMain(); const fragment = getRegionRootFragment( node ); const vdom = toVdom( node ); + initialVdom.set( node, vdom ); await yieldToMain(); hydrate( vdom, fragment ); } diff --git a/packages/interactivity/src/utils/kebab-to-camelcase.js b/packages/interactivity/src/utils/kebab-to-camelcase.js new file mode 100644 index 00000000000000..a2c0d3403db3c0 --- /dev/null +++ b/packages/interactivity/src/utils/kebab-to-camelcase.js @@ -0,0 +1,14 @@ +/** + * Transforms a kebab-case string to camelCase. + * + * @param {string} str The kebab-case string to transform to camelCase. + * @return {string} The transformed camelCase string. + */ +export function kebabToCamelCase( str ) { + return str + .replace( /^-+|-+$/g, '' ) + .toLowerCase() + .replace( /-([a-z])/g, function ( match, group1 ) { + return group1.toUpperCase(); + } ); +} diff --git a/packages/interactivity/src/utils/test/utils.js b/packages/interactivity/src/utils/test/utils.js new file mode 100644 index 00000000000000..2416b03e342ee9 --- /dev/null +++ b/packages/interactivity/src/utils/test/utils.js @@ -0,0 +1,26 @@ +/** + * Internal dependencies + */ +import { kebabToCamelCase } from '../kebab-to-camelcase'; + +describe( 'kebabToCamelCase', () => { + it( 'should work exactly as the PHP version', async () => { + expect( kebabToCamelCase( '' ) ).toBe( '' ); + expect( kebabToCamelCase( 'item' ) ).toBe( 'item' ); + expect( kebabToCamelCase( 'my-item' ) ).toBe( 'myItem' ); + expect( kebabToCamelCase( 'my_item' ) ).toBe( 'my_item' ); + expect( kebabToCamelCase( 'My-iTem' ) ).toBe( 'myItem' ); + expect( kebabToCamelCase( 'my-item-with-multiple-hyphens' ) ).toBe( + 'myItemWithMultipleHyphens' + ); + expect( kebabToCamelCase( 'my-item-with--double-hyphens' ) ).toBe( + 'myItemWith-DoubleHyphens' + ); + expect( kebabToCamelCase( 'my-item-with_under-score' ) ).toBe( + 'myItemWith_underScore' + ); + expect( kebabToCamelCase( '-my-item' ) ).toBe( 'myItem' ); + expect( kebabToCamelCase( 'my-item-' ) ).toBe( 'myItem' ); + expect( kebabToCamelCase( '-my-item-' ) ).toBe( 'myItem' ); + } ); +} ); diff --git a/packages/interactivity/src/vdom.js b/packages/interactivity/src/vdom.ts similarity index 94% rename from packages/interactivity/src/vdom.js rename to packages/interactivity/src/vdom.ts index 4a7cfff9f9d0df..9928012ada3f46 100644 --- a/packages/interactivity/src/vdom.js +++ b/packages/interactivity/src/vdom.ts @@ -35,7 +35,12 @@ const nsPathRegExp = /^([\w-_\/]+)::(.+)$/; export const hydratedIslands = new WeakSet(); -// Recursive function that transforms a DOM tree into vDOM. +/** + * Recursive function that transforms a DOM tree into vDOM. + * + * @param {Node} root The root element or node to start traversing on. + * @return {import('preact').VNode[]} The resulting vDOM tree. + */ export function toVdom( root ) { const treeWalker = document.createTreeWalker( root, @@ -57,7 +62,7 @@ export function toVdom( root ) { return [ null, next ]; } - const props = {}; + const props: Record< string, any > = {}; const children = []; const directives = []; let ignore = false; diff --git a/phpunit/interactivity-api/class-wp-interactivity-api-directives-processor-test.php b/phpunit/interactivity-api/class-wp-interactivity-api-directives-processor-test.php index aa1ee999fd58f1..6536fb6cb4b365 100644 --- a/phpunit/interactivity-api/class-wp-interactivity-api-directives-processor-test.php +++ b/phpunit/interactivity-api/class-wp-interactivity-api-directives-processor-test.php @@ -11,119 +11,144 @@ */ class Tests_WP_Interactivity_API_Directives_Processor extends WP_UnitTestCase { /** - * Tests the `get_content_between_balanced_tags` method on standard tags. + * Tests the `get_content_between_balanced_template_tags` method on template + * tags. * - * @covers ::get_content_between_balanced_tags + * @covers ::get_content_between_balanced_template_tags */ - public function test_get_content_between_balanced_tags_standard_tags() { - $content = '
Text
'; + public function test_get_content_between_balanced_template_tags_standard_tags() { + $content = ''; $p = new WP_Interactivity_API_Directives_Processor( $content ); $p->next_tag(); - $this->assertEquals( 'Text', $p->get_content_between_balanced_tags() ); + $this->assertEquals( 'Text', $p->get_content_between_balanced_template_tags() ); - $content = '
Text
More text
'; + $content = ''; $p = new WP_Interactivity_API_Directives_Processor( $content ); $p->next_tag(); - $this->assertEquals( 'Text', $p->get_content_between_balanced_tags() ); + $this->assertEquals( 'Text', $p->get_content_between_balanced_template_tags() ); $p->next_tag(); - $this->assertEquals( 'More text', $p->get_content_between_balanced_tags() ); + $this->assertEquals( 'More text', $p->get_content_between_balanced_template_tags() ); } /** - * Tests the `get_content_between_balanced_tags` method on an empty tag. + * Tests the `get_content_between_balanced_template_tags` method on an empty + * tag. * - * @covers ::get_content_between_balanced_tags + * @covers ::get_content_between_balanced_template_tags */ - public function test_get_content_between_balanced_tags_empty_tag() { - $content = '
'; + public function test_get_content_between_balanced_template_tags_empty_tag() { + $content = ''; $p = new WP_Interactivity_API_Directives_Processor( $content ); $p->next_tag(); - $this->assertEquals( '', $p->get_content_between_balanced_tags() ); + $this->assertEquals( '', $p->get_content_between_balanced_template_tags() ); } /** - * Tests the `get_content_between_balanced_tags` method with a self-closing - * tag. + * Tests the `get_content_between_balanced_template_tags` method with + * non-template tags. * - * @covers ::get_content_between_balanced_tags + * @covers ::get_content_between_balanced_template_tags */ - public function test_get_content_between_balanced_tags_self_closing_tag() { + public function test_get_content_between_balanced_template_tags_self_closing_tag() { $content = ''; $p = new WP_Interactivity_API_Directives_Processor( $content ); $p->next_tag(); - $this->assertNull( $p->get_content_between_balanced_tags() ); + $this->assertNull( $p->get_content_between_balanced_template_tags() ); + + $content = '
Text
'; + $p = new WP_Interactivity_API_Directives_Processor( $content ); + $p->next_tag(); + $this->assertNull( $p->get_content_between_balanced_template_tags() ); } /** - * Tests the `get_content_between_balanced_tags` method with nested tags. + * Tests the `get_content_between_balanced_template_tags` method with nested + * template tags. * - * @covers ::get_content_between_balanced_tags + * @covers ::get_content_between_balanced_template_tags */ - public function test_get_content_between_balanced_tags_nested_tags() { - $content = '
ContentMore Content
'; + public function test_get_content_between_balanced_template_tags_nested_tags() { + $content = ''; $p = new WP_Interactivity_API_Directives_Processor( $content ); $p->next_tag(); - $this->assertEquals( 'ContentMore Content', $p->get_content_between_balanced_tags() ); + $this->assertEquals( 'ContentMore Content', $p->get_content_between_balanced_template_tags() ); - $content = '
Content
'; + $content = ''; $p = new WP_Interactivity_API_Directives_Processor( $content ); $p->next_tag(); - $this->assertEquals( '
Content
', $p->get_content_between_balanced_tags() ); + $this->assertEquals( '', $p->get_content_between_balanced_template_tags() ); } /** - * Tests the `get_content_between_balanced_tags` method when no tags are - * present. + * Tests the `get_content_between_balanced_template_tags` method when no tags + * are present. * - * @covers ::get_content_between_balanced_tags + * @covers ::get_content_between_balanced_template_tags */ - public function test_get_content_between_balanced_tags_no_tags() { + public function test_get_content_between_balanced_template_tags_no_tags() { $content = 'Just a string with no tags.'; $p = new WP_Interactivity_API_Directives_Processor( $content ); $p->next_tag(); - $this->assertNull( $p->get_content_between_balanced_tags() ); + $this->assertNull( $p->get_content_between_balanced_template_tags() ); } /** - * Tests the `get_content_between_balanced_tags` method with unbalanced tags. + * Tests the `get_content_between_balanced_template_tags` method with unbalanced tags. * - * @covers ::get_content_between_balanced_tags + * @covers ::get_content_between_balanced_template_tags */ - public function test_get_content_between_balanced_tags_with_unbalanced_tags() { - $content = '
Missing closing div'; + public function test_get_content_between_balanced_template_tags_with_unbalanced_tags() { + $content = '