diff --git a/changelog.txt b/changelog.txt index 81da24718c6b29..7edbc51ad7c215 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,5 +1,21 @@ == Changelog == += 17.6.3 = + +## Changelog + +### Bug Fixes + +#### Block Editor +- Navigation: Update the fallback block list to avoid a PHP Warning ([58588](https://github.com/WordPress/gutenberg/pull/58588)) + +## Contributors + +The following contributors merged PRs in this release: + +@dd32 + + = 17.6.2 = diff --git a/docs/getting-started/fundamentals/markup-representation-block.md b/docs/getting-started/fundamentals/markup-representation-block.md index b048160907a259..b9cbb528b49db8 100644 --- a/docs/getting-started/fundamentals/markup-representation-block.md +++ b/docs/getting-started/fundamentals/markup-representation-block.md @@ -1,46 +1,51 @@ # Markup representation of a block -When stored in the database or in templates as HTML files, blocks are represented using a [specific HTML grammar](https://developer.wordpress.org/block-editor/explanations/architecture/key-concepts/#data-and-attributes), which is technically valid HTML based on HTML comments that act as explicit block delimiters +Blocks are stored in the database or within HTML templates using a unique [HTML-based syntax](https://developer.wordpress.org/block-editor/explanations/architecture/key-concepts/#data-and-attributes), distinguished by HTML comments that serve as clear block delimiters. This ensures that block markup is technically valid HTML. -These are some of the rules for the markup used to represent a block: +Here are a few guidelines for the markup that defines a block: -- All core block comments start with a prefix and the block name: `wp:blockname` -- For custom blocks, `blockname` is `namespace/blockname` +- Core blocks begin with the `wp:` prefix, followed by the block name (e.g., `wp:image`). Notably, the `core` namespace is omitted. +- Custom blocks begin with the `wp:` prefix, followed by the block namespace and name (e.g., `wp:namespace/name`). - The comment can be a single line, self-closing, or wrapper for HTML content. -- Custom block settings and attributes are stored as a JSON object inside the block comment. +- Block settings and attributes are stored as a JSON object inside the block comment. -_Example: Markup representation of an `image` core block_ +The following is the simplified markup representation of an Image block: -``` - -
+```html + +
+ +
``` -The [markup representation of a block is parsed for the Block Editor](https://developer.wordpress.org/block-editor/explanations/architecture/data-flow/) and the block's output for the front end: - -- In the editor, WordPress parses this block markup, captures its data and loads its `edit` version -- In the front end, WordPress parses this block markup, captures its data and generates its final HTML markup - -Whenever a block is saved, the `save` function, defined when the [block is registered in the client](https://developer.wordpress.org/block-editor/getting-started/fundamentals/registration-of-a-block/#registration-of-the-block-with-javascript-client-side), is called to return the markup that will be saved into the database within the block delimiter's comment. If `save` is `null` (common case for blocks with dynamic rendering), only a single line block delimiter's comment is stored, along with any attributes +The markup for a block is crucial both in the Block Editor and for displaying the block on the front end: -The Post Editor checks that the markup created by the `save` function is identical to the block's markup saved to the database: +- WordPress analyzes the block's markup within the Editor to extract its data and present the editable version to the user. +- On the front end, WordPress again parses the markup to extract data and render the final HTML output. -- If there are any differences, the Post Editor triggers a [block validation error](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-edit-save/#validation). -- Block validation errors usually happen when a block’s `save` function is updated to change the markup produced by the block. -- A block developer can mitigate these issues by adding a [**block deprecation**](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-deprecation/) to register the change in the block. +
+ Refer to the Data Flow article for a more in-depth look at how block data is parsed in WordPress. +
-The markup of a **block with dynamic rendering** is expected to change so the markup of these blocks is not saved to the database. What is saved in the database as representation of the block, for blocks with dynamic rendering, is a single line of HTML consisting on just the block delimiter's comment (including block attributes values). That HTML is not subject to the Post Editor’s validation. +When a block is saved, the `save` function—defined when the [block is registered in the client](https://developer.wordpress.org/block-editor/getting-started/fundamentals/registration-of-a-block/#registration-of-the-block-with-javascript-client-side)—is executed to generate the markup stored in the database, wrapped in block delimiter comments. For dynamically rendered blocks, which typically set `save` to `null`, only a placeholder comment with block attributes is saved. -_Example: Markup representation of a block with dynamic rendering (`save` = `null`) and attributes_ +Here is the markup representation of a dynamically rendered block (`save` = `null`). Notice there is no HTML markup besides the comment. ```html ``` -## Additional Resources +When a block has a `save` function, the Block Editor checks that the markup created by the `save` function is identical to the block's markup saved to the database: + +- Discrepancies will trigger a [validation error](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-edit-save/#validation), often due to changes in the `save` function's output. +- Developers can address potential validation issues by implementing [block deprecations](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-deprecation/) to account for changes. + +As the example above shows, the stored markup is minimal for dynamically rendered blocks. Generally, this is just a delimiter comment containing block attributes, which is not subject to the Block Editor's validation. This approach reflects the dynamic nature of these blocks, where the actual HTML is generated server-side and is not stored in the database. + +## Additional resources - [Data Flow and Data Format](https://developer.wordpress.org/block-editor/explanations/architecture/data-flow/) -- [Static vs. dynamic blocks: What’s the difference?](https://developer.wordpress.org/news/2023/02/27/static-vs-dynamic-blocks-whats-the-difference/) -- [Block deprecation – a tutorial](https://developer.wordpress.org/news/2023/03/10/block-deprecation-a-tutorial/) +- [Static vs. dynamic blocks: What’s the difference?](https://developer.wordpress.org/news/2023/02/27/static-vs-dynamic-blocks-whats-the-difference/) | Developer Blog +- [Block deprecation – a tutorial](https://developer.wordpress.org/news/2023/03/10/block-deprecation-a-tutorial/) | Developer Blog - [Introduction to Templates > Block markup](https://developer.wordpress.org/themes/templates/introduction-to-templates/#block-markup) | Theme Handbook \ No newline at end of file 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 0921d29c7b1c43..141dff730a15fc 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 @@ -117,14 +117,13 @@ public function get_font_collections() { * @since 6.5.0 * * @param string $slug Font collection slug. - * @return WP_Font_Collection|WP_Error Font collection object, - * or WP_Error object if the font collection doesn't exist. + * @return WP_Font_Collection|null Font collection object, or null if the font collection doesn't exist. */ public function get_font_collection( $slug ) { if ( $this->is_collection_registered( $slug ) ) { return $this->collections[ $slug ]; } - return new WP_Error( 'font_collection_not_found', __( 'Font collection not found.', 'gutenberg' ) ); + return null; } /** diff --git a/lib/compat/wordpress-6.5/fonts/class-wp-rest-font-collections-controller.php b/lib/compat/wordpress-6.5/fonts/class-wp-rest-font-collections-controller.php index 90ee80649bb489..97aba89a8b24e9 100644 --- a/lib/compat/wordpress-6.5/fonts/class-wp-rest-font-collections-controller.php +++ b/lib/compat/wordpress-6.5/fonts/class-wp-rest-font-collections-controller.php @@ -144,10 +144,9 @@ public function get_item( $request ) { $slug = $request->get_param( 'slug' ); $collection = WP_Font_Library::get_instance()->get_font_collection( $slug ); - // If the collection doesn't exist returns a 404. - if ( is_wp_error( $collection ) ) { - $collection->add_data( array( 'status' => 404 ) ); - return $collection; + // @TODO: remove `is_wp_error` check once WP trunk is updated to return null when a collection is not found. + if ( ! $collection || is_wp_error( $collection ) ) { + return new WP_Error( 'rest_font_collection_not_found', __( 'Font collection not found.' ), array( 'status' => 404 ) ); } return $this->prepare_item_for_response( $collection, $request ); @@ -158,22 +157,22 @@ public function get_item( $request ) { * * @since 6.5.0 * - * @param WP_Font_Collection $collection Collection object. - * @param WP_REST_Request $request Request object. - * @return WP_REST_Response Response object. + * @param WP_Font_Collection $item Font collection object. + * @param WP_REST_Request $request Request object. + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. */ - public function prepare_item_for_response( $collection, $request ) { + public function prepare_item_for_response( $item, $request ) { $fields = $this->get_fields_for_response( $request ); - $item = array(); + $data = array(); if ( rest_is_field_included( 'slug', $fields ) ) { - $item['slug'] = $collection->slug; + $data['slug'] = $item->slug; } // If any data fields are requested, get the collection data. $data_fields = array( 'name', 'description', 'font_families', 'categories' ); if ( ! empty( array_intersect( $fields, $data_fields ) ) ) { - $collection_data = $collection->get_data(); + $collection_data = $item->get_data(); if ( is_wp_error( $collection_data ) ) { $collection_data->add_data( array( 'status' => 500 ) ); return $collection_data; @@ -181,15 +180,15 @@ public function prepare_item_for_response( $collection, $request ) { foreach ( $data_fields as $field ) { if ( rest_is_field_included( $field, $fields ) ) { - $item[ $field ] = $collection_data[ $field ]; + $data[ $field ] = $collection_data[ $field ]; } } } - $response = rest_ensure_response( $item ); + $response = rest_ensure_response( $data ); if ( rest_is_field_included( '_links', $fields ) ) { - $links = $this->prepare_links( $collection ); + $links = $this->prepare_links( $item ); $response->add_links( $links ); } @@ -198,17 +197,15 @@ public function prepare_item_for_response( $collection, $request ) { $response->data = $this->filter_response_by_context( $response->data, $context ); /** - * Filters a font collection returned from the REST API. - * - * Allows modification of the font collection right before it is returned. + * Filters the font collection data for a REST API response. * * @since 6.5.0 * - * @param WP_REST_Response $response The response object. - * @param WP_Font_Collection $collection The Font Collection object. - * @param WP_REST_Request $request Request used to generate the response. + * @param WP_REST_Response $response The response object. + * @param WP_Font_Collection $item The font collection object. + * @param WP_REST_Request $request Request used to generate the response. */ - return apply_filters( 'rest_prepare_font_collection', $response, $collection, $request ); + return apply_filters( 'rest_prepare_font_collection', $response, $item, $request ); } /** 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 0ecd069fc230c4..8a4040e3397e0c 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 @@ -13,6 +13,15 @@ * Class to access font faces through the REST API. */ class WP_REST_Font_Faces_Controller extends WP_REST_Posts_Controller { + + /** + * The latest version of theme.json schema supported by the controller. + * + * @since 6.5.0 + * @var int + */ + const LATEST_THEME_JSON_VERSION_SUPPORTED = 2; + /** * Whether the controller supports batching. * @@ -233,9 +242,8 @@ public function validate_create_font_face_settings( $value, $request ) { * * @since 6.5.0 * - * @param string $value Encoded JSON string of font face settings. - * @param WP_REST_Request $request Request object. - * @return array Decoded array of font face settings. + * @param string $value Encoded JSON string of font face settings. + * @return array Decoded and sanitized array of font face settings. */ public function sanitize_font_face_settings( $value ) { // Settings arrive as stringified JSON, since this is a multipart/form-data request. @@ -328,7 +336,7 @@ public function create_item( $request ) { 'update_post_term_cache' => false, ) ); - if ( ! empty( $query->get_posts() ) ) { + if ( ! empty( $query->posts ) ) { return new WP_Error( 'rest_duplicate_font_face', __( 'A font face matching those settings already exists.', 'gutenberg' ), @@ -418,7 +426,7 @@ public function delete_item( $request ) { return new WP_Error( 'rest_trash_not_supported', /* translators: %s: force=true */ - sprintf( __( "Font faces do not support trashing. Set '%s' to delete.", 'gutenberg' ), 'force=true' ), + sprintf( __( 'Font faces do not support trashing. Set "%s" to delete.', 'gutenberg' ), 'force=true' ), array( 'status' => 501 ) ); } @@ -443,7 +451,7 @@ public function prepare_item_for_response( $item, $request ) { $data['id'] = $item->ID; } if ( rest_is_field_included( 'theme_json_version', $fields ) ) { - $data['theme_json_version'] = 2; + $data['theme_json_version'] = static::LATEST_THEME_JSON_VERSION_SUPPORTED; } if ( rest_is_field_included( 'parent', $fields ) ) { @@ -504,9 +512,9 @@ public function get_item_schema() { 'theme_json_version' => array( 'description' => __( 'Version of the theme.json schema used for the typography settings.', 'gutenberg' ), 'type' => 'integer', - 'default' => 2, + 'default' => static::LATEST_THEME_JSON_VERSION_SUPPORTED, 'minimum' => 2, - 'maximum' => 2, + 'maximum' => static::LATEST_THEME_JSON_VERSION_SUPPORTED, 'context' => array( 'view', 'edit', 'embed' ), ), 'parent' => array( @@ -699,14 +707,16 @@ public function get_collection_params() { $query_params = parent::get_collection_params(); // Remove unneeded params. - unset( $query_params['after'] ); - unset( $query_params['modified_after'] ); - unset( $query_params['before'] ); - unset( $query_params['modified_before'] ); - unset( $query_params['search'] ); - unset( $query_params['search_columns'] ); - unset( $query_params['slug'] ); - unset( $query_params['status'] ); + unset( + $query_params['after'], + $query_params['modified_after'], + $query_params['before'], + $query_params['modified_before'], + $query_params['search'], + $query_params['search_columns'], + $query_params['slug'], + $query_params['status'] + ); $query_params['orderby']['default'] = 'id'; $query_params['orderby']['enum'] = array( 'id', 'include' ); @@ -803,7 +813,7 @@ protected function prepare_links( $post ) { * @since 6.5.0 * * @param WP_REST_Request $request Request object. - * @return stdClass|WP_Error Post object or WP_Error. + * @return stdClass Post object. */ protected function prepare_item_for_database( $request ) { $prepared_post = new stdClass(); @@ -831,7 +841,6 @@ protected function prepare_item_for_database( $request ) { * @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 ) { @@ -845,7 +854,7 @@ protected function sanitize_src( $value ) { * @since 6.5.0 * * @param array $file Single file item from $_FILES. - * @return array Array containing uploaded file attributes on success, or error on failure. + * @return array|WP_Error Array containing uploaded file attributes on success, or WP_Error object on failure. */ protected function handle_font_file_upload( $file ) { add_filter( 'upload_mimes', array( 'WP_Font_Utils', 'get_allowed_font_mime_types' ) ); 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 dbd59abd6085a1..4cde4d636cf9ff 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 @@ -15,6 +15,15 @@ * @since 6.5.0 */ class WP_REST_Font_Families_Controller extends WP_REST_Posts_Controller { + + /** + * The latest version of theme.json schema supported by the controller. + * + * @since 6.5.0 + * @var int + */ + const LATEST_THEME_JSON_VERSION_SUPPORTED = 2; + /** * Whether the controller supports batching. * @@ -138,9 +147,8 @@ public function validate_font_family_settings( $value, $request ) { * * @since 6.5.0 * - * @param string $value Encoded JSON string of font family settings. - * @param WP_REST_Request $request Request object. - * @return array Decoded array font family settings. + * @param string $value Encoded JSON string of font family settings. + * @return array Decoded array of font family settings. */ public function sanitize_font_family_settings( $value ) { // Settings arrive as stringified JSON, since this is a multipart/form-data request. @@ -177,7 +185,7 @@ public function create_item( $request ) { 'update_post_term_cache' => false, ) ); - if ( ! empty( $query->get_posts() ) ) { + if ( ! empty( $query->posts ) ) { return new WP_Error( 'rest_duplicate_font_family', /* translators: %s: Font family slug. */ @@ -205,7 +213,7 @@ public function delete_item( $request ) { return new WP_Error( 'rest_trash_not_supported', /* translators: %s: force=true */ - sprintf( __( "Font faces do not support trashing. Set '%s' to delete.", 'gutenberg' ), 'force=true' ), + sprintf( __( 'Font faces do not support trashing. Set "%s" to delete.', 'gutenberg' ), 'force=true' ), array( 'status' => 501 ) ); } @@ -231,7 +239,7 @@ public function prepare_item_for_response( $item, $request ) { } if ( rest_is_field_included( 'theme_json_version', $fields ) ) { - $data['theme_json_version'] = 2; + $data['theme_json_version'] = static::LATEST_THEME_JSON_VERSION_SUPPORTED; } if ( rest_is_field_included( 'font_faces', $fields ) ) { @@ -292,9 +300,9 @@ public function get_item_schema() { 'theme_json_version' => array( 'description' => __( 'Version of the theme.json schema used for the typography settings.', 'gutenberg' ), 'type' => 'integer', - 'default' => 2, + 'default' => static::LATEST_THEME_JSON_VERSION_SUPPORTED, 'minimum' => 2, - 'maximum' => 2, + 'maximum' => static::LATEST_THEME_JSON_VERSION_SUPPORTED, 'context' => array( 'view', 'edit', 'embed' ), ), 'font_faces' => array( @@ -385,13 +393,15 @@ public function get_collection_params() { $query_params = parent::get_collection_params(); // Remove unneeded params. - unset( $query_params['after'] ); - unset( $query_params['modified_after'] ); - unset( $query_params['before'] ); - unset( $query_params['modified_before'] ); - unset( $query_params['search'] ); - unset( $query_params['search_columns'] ); - unset( $query_params['status'] ); + unset( + $query_params['after'], + $query_params['modified_after'], + $query_params['before'], + $query_params['modified_before'], + $query_params['search'], + $query_params['search_columns'], + $query_params['status'] + ); $query_params['orderby']['default'] = 'id'; $query_params['orderby']['enum'] = array( 'id', 'include' ); @@ -455,7 +465,7 @@ protected function get_font_face_ids( $font_family_id ) { ) ); - return $query->get_posts(); + return $query->posts; } /** @@ -489,7 +499,7 @@ protected function prepare_font_face_links( $font_family_id ) { foreach ( $font_face_ids as $font_face_id ) { $links[] = array( 'embeddable' => true, - 'href' => rest_url( $this->namespace . '/' . $this->rest_base . '/' . $font_family_id . '/font-faces/' . $font_face_id ), + 'href' => rest_url( sprintf( '%s/%s/%s/font-faces/%s', $this->namespace, $this->rest_base, $font_family_id, $font_face_id ) ), ); } return $links; diff --git a/lib/compat/wordpress-6.5/fonts/fonts.php b/lib/compat/wordpress-6.5/fonts/fonts.php index 96bd089488e8fd..e0341533057188 100644 --- a/lib/compat/wordpress-6.5/fonts/fonts.php +++ b/lib/compat/wordpress-6.5/fonts/fonts.php @@ -150,8 +150,7 @@ function wp_unregister_font_collection( $slug ) { } function gutenberg_register_font_collections() { - // TODO: update to production font collection URL. - wp_register_font_collection( 'google-fonts', 'https://raw.githubusercontent.com/WordPress/google-fonts-to-wordpress-collection/01aa57731575bd13f9db8d86ab80a2d74e28a1ac/releases/gutenberg-17.6/collections/google-fonts-with-preview.json' ); + wp_register_font_collection( 'google-fonts', 'https://s.w.org/images/fonts/17.7/collections/google-fonts-with-preview.json' ); } add_action( 'init', 'gutenberg_register_font_collections' ); diff --git a/lib/compat/wordpress-6.5/html-api/class-gutenberg-html-processor-6-5.php b/lib/compat/wordpress-6.5/html-api/class-gutenberg-html-processor-6-5.php index eae2b1eda815ca..30d7ea52d4b544 100644 --- a/lib/compat/wordpress-6.5/html-api/class-gutenberg-html-processor-6-5.php +++ b/lib/compat/wordpress-6.5/html-api/class-gutenberg-html-processor-6-5.php @@ -101,18 +101,18 @@ * * - Containers: ADDRESS, BLOCKQUOTE, DETAILS, DIALOG, DIV, FOOTER, HEADER, MAIN, MENU, SPAN, SUMMARY. * - Custom elements: All custom elements are supported. :) - * - Form elements: BUTTON, DATALIST, FIELDSET, LABEL, LEGEND, METER, PROGRESS, SEARCH. - * - Formatting elements: B, BIG, CODE, EM, FONT, I, SMALL, STRIKE, STRONG, TT, U. + * - Form elements: BUTTON, DATALIST, FIELDSET, INPUT, LABEL, LEGEND, METER, PROGRESS, SEARCH. + * - Formatting elements: B, BIG, CODE, EM, FONT, I, PRE, SMALL, STRIKE, STRONG, TT, U, WBR. * - Heading elements: H1, H2, H3, H4, H5, H6, HGROUP. * - Links: A. - * - Lists: DD, DL, DT, LI, OL, LI. - * - Media elements: AUDIO, CANVAS, FIGCAPTION, FIGURE, IMG, MAP, PICTURE, VIDEO. - * - Paragraph: P. - * - Phrasing elements: ABBR, BDI, BDO, CITE, DATA, DEL, DFN, INS, MARK, OUTPUT, Q, SAMP, SUB, SUP, TIME, VAR. - * - Sectioning elements: ARTICLE, ASIDE, NAV, SECTION. + * - Lists: DD, DL, DT, LI, OL, UL. + * - Media elements: AUDIO, CANVAS, EMBED, FIGCAPTION, FIGURE, IMG, MAP, PICTURE, SOURCE, TRACK, VIDEO. + * - Paragraph: BR, P. + * - Phrasing elements: ABBR, AREA, BDI, BDO, CITE, DATA, DEL, DFN, INS, MARK, OUTPUT, Q, SAMP, SUB, SUP, TIME, VAR. + * - Sectioning elements: ARTICLE, ASIDE, HR, NAV, SECTION. * - Templating elements: SLOT. * - Text decoration: RUBY. - * - Deprecated elements: ACRONYM, BLINK, CENTER, DIR, ISINDEX, MULTICOL, NEXTID, SPACER. + * - Deprecated elements: ACRONYM, BLINK, CENTER, DIR, ISINDEX, KEYGEN, LISTING, MULTICOL, NEXTID, PARAM, SPACER. * * ### Supported markup * @@ -149,17 +149,6 @@ class Gutenberg_HTML_Processor_6_5 extends Gutenberg_HTML_Tag_Processor_6_5 { */ const MAX_BOOKMARKS = 100; - /** - * Static query for instructing the Tag Processor to visit every token. - * - * @access private - * - * @since 6.4.0 - * - * @var array - */ - const VISIT_EVERYTHING = array( 'tag_closers' => 'visit' ); - /** * Holds the working state of the parser, including the stack of * open elements and the stack of active formatting elements. @@ -424,6 +413,30 @@ public function next_tag( $query = null ) { return false; } + /** + * Ensures internal accounting is maintained for HTML semantic rules while + * the underlying Tag Processor class is seeking to a bookmark. + * + * This doesn't currently have a way to represent non-tags and doesn't process + * semantic rules for text nodes. For access to the raw tokens consider using + * WP_HTML_Tag_Processor instead. + * + * @since 6.5.0 Added for internal support; do not use. + * + * @access private + * + * @return bool + */ + public function next_token() { + $found_a_token = parent::next_token(); + + if ( '#tag' === $this->get_token_type() ) { + $this->step( self::PROCESS_CURRENT_NODE ); + } + + return $found_a_token; + } + /** * Indicates if the currently-matched tag matches the given breadcrumbs. * @@ -500,7 +513,7 @@ public function step( $node_to_process = self::PROCESS_NEXT_NODE ) { return false; } - if ( self::PROCESS_NEXT_NODE === $node_to_process ) { + if ( self::REPROCESS_CURRENT_NODE !== $node_to_process ) { /* * Void elements still hop onto the stack of open elements even though * there's no corresponding closing tag. This is important for managing @@ -519,8 +532,12 @@ public function step( $node_to_process = self::PROCESS_NEXT_NODE ) { if ( $top_node && self::is_void( $top_node->node_name ) ) { $this->state->stack_of_open_elements->pop(); } + } - parent::next_tag( self::VISIT_EVERYTHING ); + if ( self::PROCESS_NEXT_NODE === $node_to_process ) { + while ( parent::next_token() && '#tag' !== $this->get_token_type() ) { + continue; + } } // Finish stepping when there are no more tokens in the document. @@ -531,7 +548,7 @@ public function step( $node_to_process = self::PROCESS_NEXT_NODE ) { $this->state->current_token = new WP_HTML_Token( $this->bookmark_tag(), $this->get_tag(), - $this->is_tag_closer(), + $this->has_self_closing_flag(), $this->release_internal_bookmark_on_destruct ); @@ -684,10 +701,12 @@ private function step_in_body() { case '-FOOTER': case '-HEADER': case '-HGROUP': + case '-LISTING': case '-MAIN': case '-MENU': case '-NAV': case '-OL': + case '-PRE': case '-SEARCH': case '-SECTION': case '-SUMMARY': @@ -732,6 +751,18 @@ private function step_in_body() { $this->insert_html_element( $this->state->current_token ); return true; + /* + * > A start tag whose tag name is one of: "pre", "listing" + */ + case '+PRE': + case '+LISTING': + if ( $this->state->stack_of_open_elements->has_p_in_button_scope() ) { + $this->close_a_p_element(); + } + $this->insert_html_element( $this->state->current_token ); + $this->state->frameset_ok = false; + return true; + /* * > An end tag whose tag name is one of: "h1", "h2", "h3", "h4", "h5", "h6" */ @@ -934,11 +965,64 @@ private function step_in_body() { $this->run_adoption_agency_algorithm(); return true; + /* + * > An end tag whose tag name is "br" + * > Parse error. Drop the attributes from the token, and act as described in the next + * > entry; i.e. act as if this was a "br" start tag token with no attributes, rather + * > than the end tag token that it actually is. + */ + case '-BR': + $this->last_error = self::ERROR_UNSUPPORTED; + throw new WP_HTML_Unsupported_Exception( 'Closing BR tags require unimplemented special handling.' ); + /* * > A start tag whose tag name is one of: "area", "br", "embed", "img", "keygen", "wbr" */ + case '+AREA': + case '+BR': + case '+EMBED': case '+IMG': + case '+KEYGEN': + case '+WBR': $this->reconstruct_active_formatting_elements(); + $this->insert_html_element( $this->state->current_token ); + $this->state->frameset_ok = false; + return true; + + /* + * > A start tag whose tag name is "input" + */ + case '+INPUT': + $this->reconstruct_active_formatting_elements(); + $this->insert_html_element( $this->state->current_token ); + $type_attribute = $this->get_attribute( 'type' ); + /* + * > If the token does not have an attribute with the name "type", or if it does, + * > but that attribute's value is not an ASCII case-insensitive match for the + * > string "hidden", then: set the frameset-ok flag to "not ok". + */ + if ( ! is_string( $type_attribute ) || 'hidden' !== strtolower( $type_attribute ) ) { + $this->state->frameset_ok = false; + } + return true; + + /* + * > A start tag whose tag name is "hr" + */ + case '+HR': + if ( $this->state->stack_of_open_elements->has_p_in_button_scope() ) { + $this->close_a_p_element(); + } + $this->insert_html_element( $this->state->current_token ); + $this->state->frameset_ok = false; + return true; + + /* + * > A start tag whose tag name is one of: "param", "source", "track" + */ + case '+PARAM': + case '+SOURCE': + case '+TRACK': $this->insert_html_element( $this->state->current_token ); return true; } @@ -961,30 +1045,20 @@ private function step_in_body() { */ switch ( $tag_name ) { case 'APPLET': - case 'AREA': case 'BASE': case 'BASEFONT': case 'BGSOUND': case 'BODY': - case 'BR': case 'CAPTION': case 'COL': case 'COLGROUP': - case 'DD': - case 'DT': - case 'EMBED': case 'FORM': case 'FRAME': case 'FRAMESET': case 'HEAD': - case 'HR': case 'HTML': case 'IFRAME': - case 'INPUT': - case 'KEYGEN': - case 'LI': case 'LINK': - case 'LISTING': case 'MARQUEE': case 'MATH': case 'META': @@ -993,12 +1067,9 @@ private function step_in_body() { case 'NOFRAMES': case 'NOSCRIPT': case 'OBJECT': - case 'OL': case 'OPTGROUP': case 'OPTION': - case 'PARAM': case 'PLAINTEXT': - case 'PRE': case 'RB': case 'RP': case 'RT': @@ -1006,7 +1077,6 @@ private function step_in_body() { case 'SARCASM': case 'SCRIPT': case 'SELECT': - case 'SOURCE': case 'STYLE': case 'SVG': case 'TABLE': @@ -1019,9 +1089,6 @@ private function step_in_body() { case 'THEAD': case 'TITLE': case 'TR': - case 'TRACK': - case 'UL': - case 'WBR': case 'XMP': $this->last_error = self::ERROR_UNSUPPORTED; throw new WP_HTML_Unsupported_Exception( "Cannot process {$tag_name} element." ); @@ -1675,14 +1742,19 @@ public static function is_void( $tag_name ) { return ( 'AREA' === $tag_name || 'BASE' === $tag_name || + 'BASEFONT' === $tag_name || // Obsolete but still treated as void. + 'BGSOUND' === $tag_name || // Obsolete but still treated as void. 'BR' === $tag_name || 'COL' === $tag_name || 'EMBED' === $tag_name || + 'FRAME' === $tag_name || 'HR' === $tag_name || 'IMG' === $tag_name || 'INPUT' === $tag_name || + 'KEYGEN' === $tag_name || // Obsolete but still treated as void. 'LINK' === $tag_name || 'META' === $tag_name || + 'PARAM' === $tag_name || // Obsolete but still treated as void. 'SOURCE' === $tag_name || 'TRACK' === $tag_name || 'WBR' === $tag_name @@ -1711,6 +1783,15 @@ public static function is_void( $tag_name ) { */ const REPROCESS_CURRENT_NODE = 'reprocess-current-node'; + /** + * Indicates that the current HTML token should be processed without advancing the parser. + * + * @since 6.5.0 + * + * @var string + */ + const PROCESS_CURRENT_NODE = 'process-current-node'; + /** * Indicates that the parser encountered unsupported markup and has bailed. * diff --git a/lib/compat/wordpress-6.5/html-api/class-gutenberg-html-tag-processor-6-5.php b/lib/compat/wordpress-6.5/html-api/class-gutenberg-html-tag-processor-6-5.php index de3823d2b2703b..5c371f4bf6569d 100644 --- a/lib/compat/wordpress-6.5/html-api/class-gutenberg-html-tag-processor-6-5.php +++ b/lib/compat/wordpress-6.5/html-api/class-gutenberg-html-tag-processor-6-5.php @@ -247,6 +247,95 @@ * } * } * + * ## Tokens and finer-grained processing. + * + * It's possible to scan through every lexical token in the + * HTML document using the `next_token()` function. This + * alternative form takes no argument and provides no built-in + * query syntax. + * + * Example: + * + * $title = '(untitled)'; + * $text = ''; + * while ( $processor->next_token() ) { + * switch ( $processor->get_token_name() ) { + * case '#text': + * $text .= $processor->get_modifiable_text(); + * break; + * + * case 'BR': + * $text .= "\n"; + * break; + * + * case 'TITLE': + * $title = $processor->get_modifiable_text(); + * break; + * } + * } + * return trim( "# {$title}\n\n{$text}" ); + * + * ### Tokens and _modifiable text_. + * + * #### Special "atomic" HTML elements. + * + * Not all HTML elements are able to contain other elements inside of them. + * For instance, the contents inside a TITLE element are plaintext (except + * that character references like & will be decoded). This means that + * if the string `` appears inside a TITLE element, then it's not an + * image tag, but rather it's text describing an image tag. Likewise, the + * contents of a SCRIPT or STYLE element are handled entirely separately in + * a browser than the contents of other elements because they represent a + * different language than HTML. + * + * For these elements the Tag Processor treats the entire sequence as one, + * from the opening tag, including its contents, through its closing tag. + * This means that the it's not possible to match the closing tag for a + * SCRIPT element unless it's unexpected; the Tag Processor already matched + * it when it found the opening tag. + * + * The inner contents of these elements are that element's _modifiable text_. + * + * The special elements are: + * - `SCRIPT` whose contents are treated as raw plaintext but supports a legacy + * style of including Javascript inside of HTML comments to avoid accidentally + * closing the SCRIPT from inside a Javascript string. E.g. `console.log( '' )`. + * - `TITLE` and `TEXTAREA` whose contents are treated as plaintext and then any + * character references are decoded. E.g. `1 < 2 < 3` becomes `1 < 2 < 3`. + * - `IFRAME`, `NOSCRIPT`, `NOEMBED`, `NOFRAME`, `STYLE` whose contents are treated as + * raw plaintext and left as-is. E.g. `1 < 2 < 3` remains `1 < 2 < 3`. + * + * #### Other tokens with modifiable text. + * + * There are also non-elements which are void/self-closing in nature and contain + * modifiable text that is part of that individual syntax token itself. + * + * - `#text` nodes, whose entire token _is_ the modifiable text. + * - HTML comments and tokens that become comments due to some syntax error. The + * text for these tokens is the portion of the comment inside of the syntax. + * E.g. for `` the text is `" comment "` (note the spaces are included). + * - `CDATA` sections, whose text is the content inside of the section itself. E.g. for + * `` the text is `"some content"` (with restrictions [1]). + * - "Funky comments," which are a special case of invalid closing tags whose name is + * invalid. The text for these nodes is the text that a browser would transform into + * an HTML comment when parsing. E.g. for `` the text is `%post_author`. + * - `DOCTYPE` declarations like `` which have no closing tag. + * - XML Processing instruction nodes like `` (with restrictions [2]). + * - The empty end tag `` which is ignored in the browser and DOM. + * + * [1]: There are no CDATA sections in HTML. When encountering `` becomes a bogus HTML comment, meaning there can be no CDATA + * section in an HTML document containing `>`. The Tag Processor will first find + * all valid and bogus HTML comments, and then if the comment _would_ have been a + * CDATA section _were they to exist_, it will indicate this as the type of comment. + * + * [2]: XML allows a broader range of characters in a processing instruction's target name + * and disallows "xml" as a name, since it's special. The Tag Processor only recognizes + * target names with an ASCII-representable subset of characters. It also exhibits the + * same constraint as with CDATA sections, in that `>` cannot exist within the token + * since Processing Instructions do no exist within HTML and their syntax transforms + * into a bogus comment in the DOM. + * * ## Design and limitations * * The Tag Processor is designed to linearly scan HTML documents and tokenize @@ -320,7 +409,8 @@ * @since 6.2.1 Fix: Support for various invalid comments; attribute updates are case-insensitive. * @since 6.3.2 Fix: Skip HTML-like content inside rawtext elements such as STYLE. * @since 6.5.0 Pauses processor when input ends in an incomplete syntax token. - * Introduces "special" elements which act like void elements, e.g. STYLE. + * Introduces "special" elements which act like void elements, e.g. TITLE, STYLE. + * Allows scanning through all tokens and processing modifiable text, where applicable. */ class Gutenberg_HTML_Tag_Processor_6_5 { /** @@ -396,23 +486,47 @@ class Gutenberg_HTML_Tag_Processor_6_5 { /** * Specifies mode of operation of the parser at any given time. * - * | State | Meaning | - * | --------------|----------------------------------------------------------------------| - * | *Ready* | The parser is ready to run. | - * | *Complete* | There is nothing left to parse. | - * | *Incomplete* | The HTML ended in the middle of a token; nothing more can be parsed. | - * | *Matched tag* | Found an HTML tag; it's possible to modify its attributes. | + * | State | Meaning | + * | ----------------|----------------------------------------------------------------------| + * | *Ready* | The parser is ready to run. | + * | *Complete* | There is nothing left to parse. | + * | *Incomplete* | The HTML ended in the middle of a token; nothing more can be parsed. | + * | *Matched tag* | Found an HTML tag; it's possible to modify its attributes. | + * | *Text node* | Found a #text node; this is plaintext and modifiable. | + * | *CDATA node* | Found a CDATA section; this is modifiable. | + * | *Comment* | Found a comment or bogus comment; this is modifiable. | + * | *Presumptuous* | Found an empty tag closer: ``. | + * | *Funky comment* | Found a tag closer with an invalid tag name; this is modifiable. | * * @since 6.5.0 * * @see WP_HTML_Tag_Processor::STATE_READY * @see WP_HTML_Tag_Processor::STATE_COMPLETE - * @see WP_HTML_Tag_Processor::STATE_INCOMPLETE + * @see WP_HTML_Tag_Processor::STATE_INCOMPLETE_INPUT * @see WP_HTML_Tag_Processor::STATE_MATCHED_TAG + * @see WP_HTML_Tag_Processor::STATE_TEXT_NODE + * @see WP_HTML_Tag_Processor::STATE_CDATA_NODE + * @see WP_HTML_Tag_Processor::STATE_COMMENT + * @see WP_HTML_Tag_Processor::STATE_DOCTYPE + * @see WP_HTML_Tag_Processor::STATE_PRESUMPTUOUS_TAG + * @see WP_HTML_Tag_Processor::STATE_FUNKY_COMMENT * * @var string */ - private $parser_state = self::STATE_READY; + protected $parser_state = self::STATE_READY; + + /** + * What kind of syntax token became an HTML comment. + * + * Since there are many ways in which HTML syntax can create an HTML comment, + * this indicates which of those caused it. This allows the Tag Processor to + * represent more from the original input document than would appear in the DOM. + * + * @since 6.5.0 + * + * @var string|null + */ + protected $comment_type = null; /** * How many bytes from the original HTML document have been read and parsed. @@ -490,6 +604,24 @@ class Gutenberg_HTML_Tag_Processor_6_5 { */ private $tag_name_length; + /** + * Byte offset into input document where current modifiable text starts. + * + * @since 6.5.0 + * + * @var int + */ + private $text_starts_at; + + /** + * Byte length of modifiable text. + * + * @since 6.5.0 + * + * @var string + */ + private $text_length; + /** * Whether the current tag is an opening tag, e.g.
, or a closing tag, e.g.
. * @@ -705,13 +837,13 @@ public function next_tag( $query = null ) { * @return bool Whether a token was parsed. */ public function next_token() { - $this->get_updated_html(); $was_at = $this->bytes_already_parsed; + $this->get_updated_html(); // Don't proceed if there's nothing more to scan. if ( self::STATE_COMPLETE === $this->parser_state || - self::STATE_INCOMPLETE === $this->parser_state + self::STATE_INCOMPLETE_INPUT === $this->parser_state ) { return false; } @@ -729,13 +861,27 @@ public function next_token() { // Find the next tag if it exists. if ( false === $this->parse_next_tag() ) { - if ( self::STATE_INCOMPLETE === $this->parser_state ) { + if ( self::STATE_INCOMPLETE_INPUT === $this->parser_state ) { $this->bytes_already_parsed = $was_at; } return false; } + /* + * For legacy reasons the rest of this function handles tags and their + * attributes. If the processor has reached the end of the document + * or if it matched any other token then it should return here to avoid + * attempting to process tag-specific syntax. + */ + if ( + self::STATE_INCOMPLETE_INPUT !== $this->parser_state && + self::STATE_COMPLETE !== $this->parser_state && + self::STATE_MATCHED_TAG !== $this->parser_state + ) { + return true; + } + // Parse all of its attributes. while ( $this->parse_next_attribute() ) { continue; @@ -743,11 +889,11 @@ public function next_token() { // Ensure that the tag closes before the end of the document. if ( - self::STATE_INCOMPLETE === $this->parser_state || + self::STATE_INCOMPLETE_INPUT === $this->parser_state || $this->bytes_already_parsed >= strlen( $this->html ) ) { // Does this appropriately clear state (parsed attributes)? - $this->parser_state = self::STATE_INCOMPLETE; + $this->parser_state = self::STATE_INCOMPLETE_INPUT; $this->bytes_already_parsed = $was_at; return false; @@ -755,14 +901,14 @@ public function next_token() { $tag_ends_at = strpos( $this->html, '>', $this->bytes_already_parsed ); if ( false === $tag_ends_at ) { - $this->parser_state = self::STATE_INCOMPLETE; + $this->parser_state = self::STATE_INCOMPLETE_INPUT; $this->bytes_already_parsed = $was_at; return false; } $this->parser_state = self::STATE_MATCHED_TAG; $this->token_length = $tag_ends_at - $this->token_starts_at; - $this->bytes_already_parsed = $tag_ends_at; + $this->bytes_already_parsed = $tag_ends_at + 1; /* * For non-DATA sections which might contain text that looks like HTML tags but @@ -771,8 +917,8 @@ public function next_token() { */ $t = $this->html[ $this->tag_name_starts_at ]; if ( - ! $this->is_closing_tag && - ( + $this->is_closing_tag || + ! ( 'i' === $t || 'I' === $t || 'n' === $t || 'N' === $t || 's' === $t || 'S' === $t || @@ -780,38 +926,81 @@ public function next_token() { 'x' === $t || 'X' === $t ) ) { - $tag_name = $this->get_tag(); + return true; + } - if ( 'SCRIPT' === $tag_name && ! $this->skip_script_data() ) { - $this->parser_state = self::STATE_INCOMPLETE; - $this->bytes_already_parsed = $was_at; + $tag_name = $this->get_tag(); - return false; - } elseif ( - ( 'TEXTAREA' === $tag_name || 'TITLE' === $tag_name ) && - ! $this->skip_rcdata( $tag_name ) - ) { - $this->parser_state = self::STATE_INCOMPLETE; - $this->bytes_already_parsed = $was_at; + /* + * Preserve the opening tag pointers, as these will be overwritten + * when finding the closing tag. They will be reset after finding + * the closing to tag to point to the opening of the special atomic + * tag sequence. + */ + $tag_name_starts_at = $this->tag_name_starts_at; + $tag_name_length = $this->tag_name_length; + $tag_ends_at = $this->token_starts_at + $this->token_length; + $attributes = $this->attributes; + $duplicate_attributes = $this->duplicate_attributes; + + // Find the closing tag if necessary. + $found_closer = false; + switch ( $tag_name ) { + case 'SCRIPT': + $found_closer = $this->skip_script_data(); + break; - return false; - } elseif ( - ( - 'IFRAME' === $tag_name || - 'NOEMBED' === $tag_name || - 'NOFRAMES' === $tag_name || - 'STYLE' === $tag_name || - 'XMP' === $tag_name - ) && - ! $this->skip_rawtext( $tag_name ) - ) { - $this->parser_state = self::STATE_INCOMPLETE; - $this->bytes_already_parsed = $was_at; + case 'TEXTAREA': + case 'TITLE': + $found_closer = $this->skip_rcdata( $tag_name ); + break; - return false; - } + /* + * In the browser this list would include the NOSCRIPT element, + * but the Tag Processor is an environment with the scripting + * flag disabled, meaning that it needs to descend into the + * NOSCRIPT element to be able to properly process what will be + * sent to a browser. + * + * Note that this rule makes HTML5 syntax incompatible with XML, + * because the parsing of this token depends on client application. + * The NOSCRIPT element cannot be represented in the XHTML syntax. + */ + case 'IFRAME': + case 'NOEMBED': + case 'NOFRAMES': + case 'STYLE': + case 'XMP': + $found_closer = $this->skip_rawtext( $tag_name ); + break; + + // No other tags should be treated in their entirety here. + default: + return true; + } + + if ( ! $found_closer ) { + $this->parser_state = self::STATE_INCOMPLETE_INPUT; + $this->bytes_already_parsed = $was_at; + return false; } + /* + * The values here look like they reference the opening tag but they reference + * the closing tag instead. This is why the opening tag values were stored + * above in a variable. It reads confusingly here, but that's because the + * functions that skip the contents have moved all the internal cursors past + * the inner content of the tag. + */ + $this->token_starts_at = $was_at; + $this->token_length = $this->bytes_already_parsed - $this->token_starts_at; + $this->text_starts_at = $tag_ends_at + 1; + $this->text_length = $this->tag_name_starts_at - $this->text_starts_at; + $this->tag_name_starts_at = $tag_name_starts_at; + $this->tag_name_length = $tag_name_length; + $this->attributes = $attributes; + $this->duplicate_attributes = $duplicate_attributes; + return true; } @@ -830,7 +1019,7 @@ public function next_token() { * @return bool Whether the parse paused at the start of an incomplete token. */ public function paused_at_incomplete_token() { - return self::STATE_INCOMPLETE === $this->parser_state; + return self::STATE_INCOMPLETE_INPUT === $this->parser_state; } /** @@ -1007,7 +1196,10 @@ public function has_class( $wanted_class ) { */ public function set_bookmark( $name ) { // It only makes sense to set a bookmark if the parser has paused on a concrete token. - if ( self::STATE_MATCHED_TAG !== $this->parser_state ) { + if ( + self::STATE_COMPLETE === $this->parser_state || + self::STATE_INCOMPLETE_INPUT === $this->parser_state + ) { return false; } @@ -1082,15 +1274,15 @@ private function skip_rcdata( $tag_name ) { $at = $this->bytes_already_parsed; while ( false !== $at && $at < $doc_length ) { - $at = strpos( $this->html, 'html, 'tag_name_starts_at = $at; // Fail if there is no possible tag closer. if ( false === $at || ( $at + $tag_length ) >= $doc_length ) { return false; } - $closer_potentially_starts_at = $at; - $at += 2; + $at += 2; /* * Find a case-insensitive match to the tag name. @@ -1131,13 +1323,23 @@ private function skip_rcdata( $tag_name ) { while ( $this->parse_next_attribute() ) { continue; } + $at = $this->bytes_already_parsed; if ( $at >= strlen( $this->html ) ) { return false; } - if ( '>' === $html[ $at ] || '/' === $html[ $at ] ) { - $this->bytes_already_parsed = $closer_potentially_starts_at; + if ( '>' === $html[ $at ] ) { + $this->bytes_already_parsed = $at + 1; + return true; + } + + if ( $at + 1 >= strlen( $this->html ) ) { + return false; + } + + if ( '/' === $html[ $at ] && '>' === $html[ $at + 1 ] ) { + $this->bytes_already_parsed = $at + 2; return true; } } @@ -1259,6 +1461,7 @@ private function skip_script_data() { if ( $is_closing ) { $this->bytes_already_parsed = $closer_potentially_starts_at; + $this->tag_name_starts_at = $closer_potentially_starts_at; if ( $this->bytes_already_parsed >= $doc_length ) { return false; } @@ -1268,13 +1471,13 @@ private function skip_script_data() { } if ( $this->bytes_already_parsed >= $doc_length ) { - $this->parser_state = self::STATE_INCOMPLETE; + $this->parser_state = self::STATE_INCOMPLETE_INPUT; return false; } if ( '>' === $html[ $this->bytes_already_parsed ] ) { - $this->bytes_already_parsed = $closer_potentially_starts_at; + ++$this->bytes_already_parsed; return true; } } @@ -1303,7 +1506,8 @@ private function parse_next_tag() { $html = $this->html; $doc_length = strlen( $html ); - $at = $this->bytes_already_parsed; + $was_at = $this->bytes_already_parsed; + $at = $was_at; while ( false !== $at && $at < $doc_length ) { $at = strpos( $html, '<', $at ); @@ -1313,7 +1517,50 @@ private function parse_next_tag() { * can be nothing left in the document other than a #text node. */ if ( false === $at ) { - return false; + $this->parser_state = self::STATE_TEXT_NODE; + $this->token_starts_at = $was_at; + $this->token_length = strlen( $html ) - $was_at; + $this->text_starts_at = $was_at; + $this->text_length = $this->token_length; + $this->bytes_already_parsed = strlen( $html ); + return true; + } + + if ( $at > $was_at ) { + /* + * A "<" normally starts a new HTML tag or syntax token, but in cases where the + * following character can't produce a valid token, the "<" is instead treated + * as plaintext and the parser should skip over it. This avoids a problem when + * following earlier practices of typing emoji with text, e.g. "<3". This + * should be a heart, not a tag. It's supposed to be rendered, not hidden. + * + * At this point the parser checks if this is one of those cases and if it is + * will continue searching for the next "<" in search of a token boundary. + * + * @see https://html.spec.whatwg.org/#tag-open-state + */ + if ( strlen( $html ) > $at + 1 ) { + $next_character = $html[ $at + 1 ]; + $at_another_node = ( + '!' === $next_character || + '/' === $next_character || + '?' === $next_character || + ( 'A' <= $next_character && $next_character <= 'Z' ) || + ( 'a' <= $next_character && $next_character <= 'z' ) + ); + if ( ! $at_another_node ) { + ++$at; + continue; + } + } + + $this->parser_state = self::STATE_TEXT_NODE; + $this->token_starts_at = $was_at; + $this->token_length = $at - $was_at; + $this->text_starts_at = $was_at; + $this->text_length = $this->token_length; + $this->bytes_already_parsed = $at; + return true; } $this->token_starts_at = $at; @@ -1342,8 +1589,9 @@ private function parse_next_tag() { $tag_name_prefix_length = strspn( $html, 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ', $at + 1 ); if ( $tag_name_prefix_length > 0 ) { ++$at; - $this->tag_name_length = $tag_name_prefix_length + strcspn( $html, " \t\f\r\n/>", $at + $tag_name_prefix_length ); + $this->parser_state = self::STATE_MATCHED_TAG; $this->tag_name_starts_at = $at; + $this->tag_name_length = $tag_name_prefix_length + strcspn( $html, " \t\f\r\n/>", $at + $tag_name_prefix_length ); $this->bytes_already_parsed = $at + $this->tag_name_length; return true; } @@ -1353,18 +1601,18 @@ private function parse_next_tag() { * the document. There is nothing left to parse. */ if ( $at + 1 >= $doc_length ) { - $this->parser_state = self::STATE_INCOMPLETE; + $this->parser_state = self::STATE_INCOMPLETE_INPUT; return false; } /* - * + * ``. Unlike other comment + * and bogus comment syntax, these leave no clear insertion point for text and + * they need to be modified specially in order to contain text. E.g. to store + * `?` as the modifiable text, the `` needs to become ``, which + * involves inserting an additional `-` into the token after the modifiable text. + */ + $this->parser_state = self::STATE_COMMENT; + $this->comment_type = self::COMMENT_AS_ABRUPTLY_CLOSED_COMMENT; + $this->token_length = $closer_at + $span_of_dashes + 1 - $this->token_starts_at; + + // Only provide modifiable text if the token is long enough to contain it. + if ( $span_of_dashes >= 2 ) { + $this->comment_type = self::COMMENT_AS_HTML_COMMENT; + $this->text_starts_at = $this->token_starts_at + 4; + $this->text_length = $span_of_dashes - 2; + } + + $this->bytes_already_parsed = $closer_at + $span_of_dashes + 1; + return true; } /* @@ -1397,51 +1664,39 @@ private function parse_next_tag() { while ( ++$closer_at < $doc_length ) { $closer_at = strpos( $html, '--', $closer_at ); if ( false === $closer_at ) { - $this->parser_state = self::STATE_INCOMPLETE; + $this->parser_state = self::STATE_INCOMPLETE_INPUT; return false; } if ( $closer_at + 2 < $doc_length && '>' === $html[ $closer_at + 2 ] ) { - $at = $closer_at + 3; - continue 2; + $this->parser_state = self::STATE_COMMENT; + $this->comment_type = self::COMMENT_AS_HTML_COMMENT; + $this->token_length = $closer_at + 3 - $this->token_starts_at; + $this->text_starts_at = $this->token_starts_at + 4; + $this->text_length = $closer_at - $this->text_starts_at; + $this->bytes_already_parsed = $closer_at + 3; + return true; } - if ( $closer_at + 3 < $doc_length && '!' === $html[ $closer_at + 2 ] && '>' === $html[ $closer_at + 3 ] ) { - $at = $closer_at + 4; - continue 2; + if ( + $closer_at + 3 < $doc_length && + '!' === $html[ $closer_at + 2 ] && + '>' === $html[ $closer_at + 3 ] + ) { + $this->parser_state = self::STATE_COMMENT; + $this->comment_type = self::COMMENT_AS_HTML_COMMENT; + $this->token_length = $closer_at + 4 - $this->token_starts_at; + $this->text_starts_at = $this->token_starts_at + 4; + $this->text_length = $closer_at - $this->text_starts_at; + $this->bytes_already_parsed = $closer_at + 4; + return true; } } } /* - * - * The CDATA is case-sensitive. - * https://html.spec.whatwg.org/multipage/parsing.html#tag-open-state - */ - if ( - $doc_length > $at + 8 && - '[' === $html[ $at + 2 ] && - 'C' === $html[ $at + 3 ] && - 'D' === $html[ $at + 4 ] && - 'A' === $html[ $at + 5 ] && - 'T' === $html[ $at + 6 ] && - 'A' === $html[ $at + 7 ] && - '[' === $html[ $at + 8 ] - ) { - $closer_at = strpos( $html, ']]>', $at + 9 ); - if ( false === $closer_at ) { - $this->parser_state = self::STATE_INCOMPLETE; - - return false; - } - - $at = $closer_at + 3; - continue; - } - - /* - * + * ` * These are ASCII-case-insensitive. * https://html.spec.whatwg.org/multipage/parsing.html#tag-open-state */ @@ -1457,13 +1712,17 @@ private function parse_next_tag() { ) { $closer_at = strpos( $html, '>', $at + 9 ); if ( false === $closer_at ) { - $this->parser_state = self::STATE_INCOMPLETE; + $this->parser_state = self::STATE_INCOMPLETE_INPUT; return false; } - $at = $closer_at + 1; - continue; + $this->parser_state = self::STATE_DOCTYPE; + $this->token_length = $closer_at + 1 - $this->token_starts_at; + $this->text_starts_at = $this->token_starts_at + 9; + $this->text_length = $closer_at - $this->text_starts_at; + $this->bytes_already_parsed = $closer_at + 1; + return true; } /* @@ -1471,14 +1730,54 @@ private function parse_next_tag() { * to the bogus comment state - skip to the nearest >. If no closer is * found then the HTML was truncated inside the markup declaration. */ - $at = strpos( $html, '>', $at + 1 ); - if ( false === $at ) { - $this->parser_state = self::STATE_INCOMPLETE; + $closer_at = strpos( $html, '>', $at + 1 ); + if ( false === $closer_at ) { + $this->parser_state = self::STATE_INCOMPLETE_INPUT; return false; } - continue; + $this->parser_state = self::STATE_COMMENT; + $this->comment_type = self::COMMENT_AS_INVALID_HTML; + $this->token_length = $closer_at + 1 - $this->token_starts_at; + $this->text_starts_at = $this->token_starts_at + 2; + $this->text_length = $closer_at - $this->text_starts_at; + $this->bytes_already_parsed = $closer_at + 1; + + /* + * Identify nodes that would be CDATA if HTML had CDATA sections. + * + * This section must occur after identifying the bogus comment end + * because in an HTML parser it will span to the nearest `>`, even + * if there's no `]]>` as would be required in an XML document. It + * is therefore not possible to parse a CDATA section containing + * a `>` in the HTML syntax. + * + * Inside foreign elements there is a discrepancy between browsers + * and the specification on this. + * + * @todo Track whether the Tag Processor is inside a foreign element + * and require the proper closing `]]>` in those cases. + */ + if ( + $this->token_length >= 10 && + '[' === $html[ $this->token_starts_at + 2 ] && + 'C' === $html[ $this->token_starts_at + 3 ] && + 'D' === $html[ $this->token_starts_at + 4 ] && + 'A' === $html[ $this->token_starts_at + 5 ] && + 'T' === $html[ $this->token_starts_at + 6 ] && + 'A' === $html[ $this->token_starts_at + 7 ] && + '[' === $html[ $this->token_starts_at + 8 ] && + ']' === $html[ $closer_at - 1 ] && + ']' === $html[ $closer_at - 2 ] + ) { + $this->parser_state = self::STATE_COMMENT; + $this->comment_type = self::COMMENT_AS_CDATA_LOOKALIKE; + $this->text_starts_at += 7; + $this->text_length -= 9; + } + + return true; } /* @@ -1491,30 +1790,80 @@ private function parse_next_tag() { * See https://html.spec.whatwg.org/#parse-error-missing-end-tag-name */ if ( '>' === $html[ $at + 1 ] ) { - ++$at; - continue; + $this->parser_state = self::STATE_PRESUMPTUOUS_TAG; + $this->token_length = $at + 2 - $this->token_starts_at; + $this->bytes_already_parsed = $at + 2; + return true; } /* - * + * ` * See https://html.spec.whatwg.org/multipage/parsing.html#tag-open-state */ if ( '?' === $html[ $at + 1 ] ) { $closer_at = strpos( $html, '>', $at + 2 ); if ( false === $closer_at ) { - $this->parser_state = self::STATE_INCOMPLETE; + $this->parser_state = self::STATE_INCOMPLETE_INPUT; return false; } - $at = $closer_at + 1; - continue; + $this->parser_state = self::STATE_COMMENT; + $this->comment_type = self::COMMENT_AS_INVALID_HTML; + $this->token_length = $closer_at + 1 - $this->token_starts_at; + $this->text_starts_at = $this->token_starts_at + 2; + $this->text_length = $closer_at - $this->text_starts_at; + $this->bytes_already_parsed = $closer_at + 1; + + /* + * Identify a Processing Instruction node were HTML to have them. + * + * This section must occur after identifying the bogus comment end + * because in an HTML parser it will span to the nearest `>`, even + * if there's no `?>` as would be required in an XML document. It + * is therefore not possible to parse a Processing Instruction node + * containing a `>` in the HTML syntax. + * + * XML allows for more target names, but this code only identifies + * those with ASCII-representable target names. This means that it + * may identify some Processing Instruction nodes as bogus comments, + * but it will not misinterpret the HTML structure. By limiting the + * identification to these target names the Tag Processor can avoid + * the need to start parsing UTF-8 sequences. + * + * > NameStartChar ::= ":" | [A-Z] | "_" | [a-z] | [#xC0-#xD6] | [#xD8-#xF6] | [#xF8-#x2FF] | + * [#x370-#x37D] | [#x37F-#x1FFF] | [#x200C-#x200D] | [#x2070-#x218F] | + * [#x2C00-#x2FEF] | [#x3001-#xD7FF] | [#xF900-#xFDCF] | [#xFDF0-#xFFFD] | + * [#x10000-#xEFFFF] + * > NameChar ::= NameStartChar | "-" | "." | [0-9] | #xB7 | [#x0300-#x036F] | [#x203F-#x2040] + * + * @see https://www.w3.org/TR/2006/REC-xml11-20060816/#NT-PITarget + */ + if ( $this->token_length >= 5 && '?' === $html[ $closer_at - 1 ] ) { + $comment_text = substr( $html, $this->token_starts_at + 2, $this->token_length - 4 ); + $pi_target_length = strspn( $comment_text, 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ:_' ); + + if ( 0 < $pi_target_length ) { + $pi_target_length += strspn( $comment_text, 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789:_-.', $pi_target_length ); + + $this->comment_type = self::COMMENT_AS_PI_NODE_LOOKALIKE; + $this->tag_name_starts_at = $this->token_starts_at + 2; + $this->tag_name_length = $pi_target_length; + $this->text_starts_at += $pi_target_length; + $this->text_length -= $pi_target_length + 1; + } + } + + return true; } /* * If a non-alpha starts the tag name in a tag closer it's a comment. * Find the first `>`, which closes the comment. * + * This parser classifies these particular comments as special "funky comments" + * which are made available for further processing. + * * See https://html.spec.whatwg.org/#parse-error-invalid-first-character-of-tag-name */ if ( $this->is_closing_tag ) { @@ -1525,13 +1874,17 @@ private function parse_next_tag() { $closer_at = strpos( $html, '>', $at + 3 ); if ( false === $closer_at ) { - $this->parser_state = self::STATE_INCOMPLETE; + $this->parser_state = self::STATE_INCOMPLETE_INPUT; return false; } - $at = $closer_at + 1; - continue; + $this->parser_state = self::STATE_FUNKY_COMMENT; + $this->token_length = $closer_at + 1 - $this->token_starts_at; + $this->text_starts_at = $this->token_starts_at + 2; + $this->text_length = $closer_at - $this->text_starts_at; + $this->bytes_already_parsed = $closer_at + 1; + return true; } ++$at; @@ -1551,7 +1904,7 @@ private function parse_next_attribute() { // Skip whitespace and slashes. $this->bytes_already_parsed += strspn( $this->html, " \t\f\r\n/", $this->bytes_already_parsed ); if ( $this->bytes_already_parsed >= strlen( $this->html ) ) { - $this->parser_state = self::STATE_INCOMPLETE; + $this->parser_state = self::STATE_INCOMPLETE_INPUT; return false; } @@ -1575,14 +1928,14 @@ private function parse_next_attribute() { $attribute_name = substr( $this->html, $attribute_start, $name_length ); $this->bytes_already_parsed += $name_length; if ( $this->bytes_already_parsed >= strlen( $this->html ) ) { - $this->parser_state = self::STATE_INCOMPLETE; + $this->parser_state = self::STATE_INCOMPLETE_INPUT; return false; } $this->skip_whitespace(); if ( $this->bytes_already_parsed >= strlen( $this->html ) ) { - $this->parser_state = self::STATE_INCOMPLETE; + $this->parser_state = self::STATE_INCOMPLETE_INPUT; return false; } @@ -1592,7 +1945,7 @@ private function parse_next_attribute() { ++$this->bytes_already_parsed; $this->skip_whitespace(); if ( $this->bytes_already_parsed >= strlen( $this->html ) ) { - $this->parser_state = self::STATE_INCOMPLETE; + $this->parser_state = self::STATE_INCOMPLETE_INPUT; return false; } @@ -1620,7 +1973,7 @@ private function parse_next_attribute() { } if ( $attribute_end >= strlen( $this->html ) ) { - $this->parser_state = self::STATE_INCOMPLETE; + $this->parser_state = self::STATE_INCOMPLETE_INPUT; return false; } @@ -1692,8 +2045,11 @@ private function after_tag() { $this->token_length = null; $this->tag_name_starts_at = null; $this->tag_name_length = null; + $this->text_starts_at = 0; + $this->text_length = 0; $this->is_closing_tag = null; $this->attributes = array(); + $this->comment_type = null; $this->duplicate_attributes = null; } @@ -1985,7 +2341,8 @@ public function seek( $bookmark_name ) { // Point this tag processor before the sought tag opener and consume it. $this->bytes_already_parsed = $this->bookmarks[ $bookmark_name ]->start; - return $this->next_tag( array( 'tag_closers' => 'visit' ) ); + $this->parser_state = self::STATE_READY; + return $this->next_token(); } /** @@ -2216,13 +2573,24 @@ public function get_attribute_names_with_prefix( $prefix ) { * @return string|null Name of currently matched tag in input HTML, or `null` if none found. */ public function get_tag() { - if ( self::STATE_MATCHED_TAG !== $this->parser_state ) { + if ( null === $this->tag_name_starts_at ) { return null; } $tag_name = substr( $this->html, $this->tag_name_starts_at, $this->tag_name_length ); - return strtoupper( $tag_name ); + if ( self::STATE_MATCHED_TAG === $this->parser_state ) { + return strtoupper( $tag_name ); + } + + if ( + self::STATE_COMMENT === $this->parser_state && + self::COMMENT_AS_PI_NODE_LOOKALIKE === $this->get_comment_type() + ) { + return $tag_name; + } + + return null; } /** @@ -2281,6 +2649,191 @@ public function is_tag_closer() { ); } + /** + * Indicates the kind of matched token, if any. + * + * This differs from `get_token_name()` in that it always + * returns a static string indicating the type, whereas + * `get_token_name()` may return values derived from the + * token itself, such as a tag name or processing + * instruction tag. + * + * Possible values: + * - `#tag` when matched on a tag. + * - `#text` when matched on a text node. + * - `#cdata-section` when matched on a CDATA node. + * - `#comment` when matched on a comment. + * - `#doctype` when matched on a DOCTYPE declaration. + * - `#presumptuous-tag` when matched on an empty tag closer. + * - `#funky-comment` when matched on a funky comment. + * + * @since 6.5.0 + * + * @return string|null What kind of token is matched, or null. + */ + public function get_token_type() { + switch ( $this->parser_state ) { + case self::STATE_MATCHED_TAG: + return '#tag'; + + case self::STATE_DOCTYPE: + return '#doctype'; + + default: + return $this->get_token_name(); + } + } + + /** + * Returns the node name represented by the token. + * + * This matches the DOM API value `nodeName`. Some values + * are static, such as `#text` for a text node, while others + * are dynamically generated from the token itself. + * + * Dynamic names: + * - Uppercase tag name for tag matches. + * - `html` for DOCTYPE declarations. + * + * Note that if the Tag Processor is not matched on a token + * then this function will return `null`, either because it + * hasn't yet found a token or because it reached the end + * of the document without matching a token. + * + * @since 6.5.0 + * + * @return string|null Name of the matched token. + */ + public function get_token_name() { + switch ( $this->parser_state ) { + case self::STATE_MATCHED_TAG: + return $this->get_tag(); + + case self::STATE_TEXT_NODE: + return '#text'; + + case self::STATE_CDATA_NODE: + return '#cdata-section'; + + case self::STATE_COMMENT: + return '#comment'; + + case self::STATE_DOCTYPE: + return 'html'; + + case self::STATE_PRESUMPTUOUS_TAG: + return '#presumptuous-tag'; + + case self::STATE_FUNKY_COMMENT: + return '#funky-comment'; + } + } + + /** + * Indicates what kind of comment produced the comment node. + * + * Because there are different kinds of HTML syntax which produce + * comments, the Tag Processor tracks and exposes this as a type + * for the comment. Nominally only regular HTML comments exist as + * they are commonly known, but a number of unrelated syntax errors + * also produce comments. + * + * @see self::COMMENT_AS_ABRUPTLY_CLOSED_COMMENT + * @see self::COMMENT_AS_CDATA_LOOKALIKE + * @see self::COMMENT_AS_INVALID_HTML + * @see self::COMMENT_AS_HTML_COMMENT + * @see self::COMMENT_AS_PI_NODE_LOOKALIKE + * + * @since 6.5.0 + * + * @return string|null + */ + public function get_comment_type() { + if ( self::STATE_COMMENT !== $this->parser_state ) { + return null; + } + + return $this->comment_type; + } + + /** + * Returns the modifiable text for a matched token, or an empty string. + * + * Modifiable text is text content that may be read and changed without + * changing the HTML structure of the document around it. This includes + * the contents of `#text` nodes in the HTML as well as the inner + * contents of HTML comments, Processing Instructions, and others, even + * though these nodes aren't part of a parsed DOM tree. They also contain + * the contents of SCRIPT and STYLE tags, of TEXTAREA tags, and of any + * other section in an HTML document which cannot contain HTML markup (DATA). + * + * If a token has no modifiable text then an empty string is returned to + * avoid needless crashing or type errors. An empty string does not mean + * that a token has modifiable text, and a token with modifiable text may + * have an empty string (e.g. a comment with no contents). + * + * @since 6.5.0 + * + * @return string + */ + public function get_modifiable_text() { + if ( null === $this->text_starts_at ) { + return ''; + } + + $text = substr( $this->html, $this->text_starts_at, $this->text_length ); + + // Comment data is not decoded. + if ( + self::STATE_CDATA_NODE === $this->parser_state || + self::STATE_COMMENT === $this->parser_state || + self::STATE_DOCTYPE === $this->parser_state || + self::STATE_FUNKY_COMMENT === $this->parser_state + ) { + return $text; + } + + $tag_name = $this->get_tag(); + if ( + // Script data is not decoded. + 'SCRIPT' === $tag_name || + + // RAWTEXT data is not decoded. + 'IFRAME' === $tag_name || + 'NOEMBED' === $tag_name || + 'NOFRAMES' === $tag_name || + 'STYLE' === $tag_name || + 'XMP' === $tag_name + ) { + return $text; + } + + $decoded = html_entity_decode( $text, ENT_QUOTES | ENT_HTML5 | ENT_SUBSTITUTE ); + + if ( empty( $decoded ) ) { + return ''; + } + + /* + * TEXTAREA skips a leading newline, but this newline may appear not only as the + * literal character `\n`, but also as a character reference, such as in the + * following markup: ``. + * + * For these cases it's important to first decode the text content before checking + * for a leading newline and removing it. + */ + if ( + self::STATE_MATCHED_TAG === $this->parser_state && + 'TEXTAREA' === $tag_name && + strlen( $decoded ) > 0 && + "\n" === $decoded[0] + ) { + return substr( $decoded, 1 ); + } + + return $decoded; + } + /** * Updates or creates a new attribute on the currently matched tag with the passed value. * @@ -2746,7 +3299,7 @@ private function matches() { } /** - * Parser Ready State + * Parser Ready State. * * Indicates that the parser is ready to run and waiting for a state transition. * It may not have started yet, or it may have just finished parsing a token and @@ -2759,7 +3312,7 @@ private function matches() { const STATE_READY = 'STATE_READY'; /** - * Parser Complete State + * Parser Complete State. * * Indicates that the parser has reached the end of the document and there is * nothing left to scan. It finished parsing the last token completely. @@ -2771,7 +3324,7 @@ private function matches() { const STATE_COMPLETE = 'STATE_COMPLETE'; /** - * Parser Incomplete State + * Parser Incomplete Input State. * * Indicates that the parser has reached the end of the document before finishing * a token. It started parsing a token but there is a possibility that the input @@ -2784,10 +3337,10 @@ private function matches() { * * @access private */ - const STATE_INCOMPLETE = 'STATE_INCOMPLETE'; + const STATE_INCOMPLETE_INPUT = 'STATE_INCOMPLETE_INPUT'; /** - * Parser Matched Tag State + * Parser Matched Tag State. * * Indicates that the parser has found an HTML tag and it's possible to get * the tag name and read or modify its attributes (if it's not a closing tag). @@ -2797,4 +3350,153 @@ private function matches() { * @access private */ const STATE_MATCHED_TAG = 'STATE_MATCHED_TAG'; + + /** + * Parser Text Node State. + * + * Indicates that the parser has found a text node and it's possible + * to read and modify that text. + * + * @since 6.5.0 + * + * @access private + */ + const STATE_TEXT_NODE = 'STATE_TEXT_NODE'; + + /** + * Parser CDATA Node State. + * + * Indicates that the parser has found a CDATA node and it's possible + * to read and modify its modifiable text. Note that in HTML there are + * no CDATA nodes outside of foreign content (SVG and MathML). Outside + * of foreign content, they are treated as HTML comments. + * + * @since 6.5.0 + * + * @access private + */ + const STATE_CDATA_NODE = 'STATE_CDATA_NODE'; + + /** + * Indicates that the parser has found an HTML comment and it's + * possible to read and modify its modifiable text. + * + * @since 6.5.0 + * + * @access private + */ + const STATE_COMMENT = 'STATE_COMMENT'; + + /** + * Indicates that the parser has found a DOCTYPE node and it's + * possible to read and modify its modifiable text. + * + * @since 6.5.0 + * + * @access private + */ + const STATE_DOCTYPE = 'STATE_DOCTYPE'; + + /** + * Indicates that the parser has found an empty tag closer ``. + * + * Note that in HTML there are no empty tag closers, and they + * are ignored. Nonetheless, the Tag Processor still + * recognizes them as they appear in the HTML stream. + * + * These were historically discussed as a "presumptuous tag + * closer," which would close the nearest open tag, but were + * dismissed in favor of explicitly-closing tags. + * + * @since 6.5.0 + * + * @access private + */ + const STATE_PRESUMPTUOUS_TAG = 'STATE_PRESUMPTUOUS_TAG'; + + /** + * Indicates that the parser has found a "funky comment" + * and it's possible to read and modify its modifiable text. + * + * Example: + * + * + * + * + * + * Funky comments are tag closers with invalid tag names. Note + * that in HTML these are turn into bogus comments. Nonetheless, + * the Tag Processor recognizes them in a stream of HTML and + * exposes them for inspection and modification. + * + * @since 6.5.0 + * + * @access private + */ + const STATE_FUNKY_COMMENT = 'STATE_WP_FUNKY'; + + /** + * Indicates that a comment was created when encountering abruptly-closed HTML comment. + * + * Example: + * + * + * + * + * @since 6.5.0 + */ + const COMMENT_AS_ABRUPTLY_CLOSED_COMMENT = 'COMMENT_AS_ABRUPTLY_CLOSED_COMMENT'; + + /** + * Indicates that a comment would be parsed as a CDATA node, + * were HTML to allow CDATA nodes outside of foreign content. + * + * Example: + * + * + * + * This is an HTML comment, but it looks like a CDATA node. + * + * @since 6.5.0 + */ + const COMMENT_AS_CDATA_LOOKALIKE = 'COMMENT_AS_CDATA_LOOKALIKE'; + + /** + * Indicates that a comment was created when encountering + * normative HTML comment syntax. + * + * Example: + * + * + * + * @since 6.5.0 + */ + const COMMENT_AS_HTML_COMMENT = 'COMMENT_AS_HTML_COMMENT'; + + /** + * Indicates that a comment would be parsed as a Processing + * Instruction node, were they to exist within HTML. + * + * Example: + * + * + * + * This is an HTML comment, but it looks like a CDATA node. + * + * @since 6.5.0 + */ + const COMMENT_AS_PI_NODE_LOOKALIKE = 'COMMENT_AS_PI_NODE_LOOKALIKE'; + + /** + * Indicates that a comment was created when encountering invalid + * HTML input, a so-called "bogus comment." + * + * Example: + * + * + * + * + * @since 6.5.0 + */ + const COMMENT_AS_INVALID_HTML = 'COMMENT_AS_INVALID_HTML'; } diff --git a/package-lock.json b/package-lock.json index 856b34dc274827..4324dc79ffa4f9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "gutenberg", - "version": "17.6.2", + "version": "17.6.3", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "gutenberg", - "version": "17.6.2", + "version": "17.6.3", "hasInstallScript": true, "license": "GPL-2.0-or-later", "dependencies": { diff --git a/package.json b/package.json index e0b500f47105b4..353aec64385273 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gutenberg", - "version": "17.6.2", + "version": "17.6.3", "private": true, "description": "A new WordPress editor experience.", "author": "The WordPress Contributors", diff --git a/packages/block-editor/CHANGELOG.md b/packages/block-editor/CHANGELOG.md index ecacd192751ed4..666859c602b81e 100644 --- a/packages/block-editor/CHANGELOG.md +++ b/packages/block-editor/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- `FontSizePicker`: Remove deprecated `__nextHasNoMarginBottom` prop and promote to default behavior ([#58702](https://github.com/WordPress/gutenberg/pull/58702)). + ## 12.18.0 (2024-01-24) - Deprecated `__experimentalRecursionProvider` and `__experimentalUseHasRecursion` in favor of their new stable counterparts `RecursionProvider` and `useHasRecursion`. diff --git a/packages/block-editor/src/components/block-inspector/index.js b/packages/block-editor/src/components/block-inspector/index.js index 7b8e5296c4502a..e8df38232306cf 100644 --- a/packages/block-editor/src/components/block-inspector/index.js +++ b/packages/block-editor/src/components/block-inspector/index.js @@ -30,6 +30,7 @@ import PositionControls from '../inspector-controls-tabs/position-controls-panel import useBlockInspectorAnimationSettings from './useBlockInspectorAnimationSettings'; import BlockInfo from '../block-info-slot-fill'; import BlockQuickNavigation from '../block-quick-navigation'; +import { getBorderPanelLabel } from '../../hooks/border'; function BlockInspectorLockedBlocks( { topLevelLockedBlock } ) { const contentClientIds = useSelect( @@ -139,7 +140,9 @@ const BlockInspector = ( { showNoBlockSelectedMessage = true } ) => { /> @@ -248,6 +251,7 @@ const BlockInspectorSingleBlock = ( { clientId, blockName } ) => { [ blockName ] ); const blockInformation = useBlockDisplayInformation( clientId ); + const borderPanelLabel = getBorderPanelLabel( { blockName } ); return (
@@ -300,7 +304,7 @@ const BlockInspectorSingleBlock = ( { clientId, blockName } ) => { /> { label={ __( 'Background' ) } /> -
diff --git a/packages/block-editor/src/components/colors-gradients/control.js b/packages/block-editor/src/components/colors-gradients/control.js index cf82510f78c896..5a1cda78f86683 100644 --- a/packages/block-editor/src/components/colors-gradients/control.js +++ b/packages/block-editor/src/components/colors-gradients/control.js @@ -81,7 +81,6 @@ function ColorGradientControlInner( { ), [ TAB_IDS.gradient ]: ( { return ( { @@ -80,11 +79,3 @@ If `true`, the UI will contain a slider, instead of a numeric text input field. - Type: `Boolean` - Required: no - Default: `false` - -### __nextHasNoMarginBottom - -Start opting into the new margin-free styles that will become the default in a future version, currently scheduled to be WordPress 6.4. (The prop can be safely removed once this happens.) - -- Type: `Boolean` -- Required: no -- Default: `false` diff --git a/packages/block-editor/src/components/global-styles/border-panel.js b/packages/block-editor/src/components/global-styles/border-panel.js index a1cbcfd9a01c65..0dee6040c87052 100644 --- a/packages/block-editor/src/components/global-styles/border-panel.js +++ b/packages/block-editor/src/components/global-styles/border-panel.js @@ -7,6 +7,8 @@ import { __experimentalIsDefinedBorder as isDefinedBorder, __experimentalToolsPanel as ToolsPanel, __experimentalToolsPanelItem as ToolsPanelItem, + __experimentalItemGroup as ItemGroup, + BaseControl, } from '@wordpress/components'; import { useCallback, useMemo } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; @@ -17,6 +19,14 @@ import { __ } from '@wordpress/i18n'; import BorderRadiusControl from '../border-radius-control'; import { useColorsPerOrigin } from './hooks'; import { getValueFromVariable, TOOLSPANEL_DROPDOWNMENU_PROPS } from './utils'; +import { mergeOrigins } from '../../store/get-block-settings'; +import { setImmutably } from '../../utils/object'; +import { getBorderPanelLabel } from '../../hooks/border'; +import { ShadowPopover } from './shadow-panel-components'; + +function useHasShadowControl( settings ) { + return !! settings?.shadow; +} export function useHasBorderPanel( settings ) { const controls = [ @@ -24,6 +34,7 @@ export function useHasBorderPanel( settings ) { useHasBorderRadiusControl( settings ), useHasBorderStyleControl( settings ), useHasBorderWidthControl( settings ), + useHasShadowControl( settings ), ]; return controls.some( Boolean ); @@ -51,6 +62,7 @@ function BorderToolsPanel( { value, panelId, children, + label, } ) { const resetAll = () => { const updatedValue = resetAllFilter( value ); @@ -59,7 +71,7 @@ function BorderToolsPanel( { return ( { + const slug = mergedShadowPresets?.find( + ( { shadow: shadowName } ) => shadowName === newValue + )?.slug; + + onChange( + setImmutably( + value, + [ 'shadow' ], + slug ? `var:preset|shadow|${ slug }` : newValue || undefined + ) + ); + }; + const hasShadow = () => !! value?.shadow; + const resetShadow = () => setShadow( undefined ); const resetBorder = () => { if ( hasBorderRadius() ) { @@ -173,18 +210,30 @@ export default function BorderPanel( { return { ...previousValue, border: undefined, + shadow: undefined, }; }, [] ); const showBorderByDefault = defaultControls?.color || defaultControls?.width; + const label = getBorderPanelLabel( { + blockName: name, + hasShadowControl, + hasBorderControl: + showBorderColor || + showBorderStyle || + showBorderWidth || + showBorderRadius, + } ); + return ( { ( showBorderWidth || showBorderColor ) && ( ) } @@ -223,6 +274,26 @@ export default function BorderPanel( { /> ) } + { hasShadowControl && ( + + + { __( 'Shadow' ) } + + + + + + ) } ); } diff --git a/packages/block-editor/src/components/global-styles/effects-panel.js b/packages/block-editor/src/components/global-styles/effects-panel.js deleted file mode 100644 index 4fefed8e3497e3..00000000000000 --- a/packages/block-editor/src/components/global-styles/effects-panel.js +++ /dev/null @@ -1,244 +0,0 @@ -/** - * External dependencies - */ -import classnames from 'classnames'; - -/** - * WordPress dependencies - */ -import { - __experimentalToolsPanel as ToolsPanel, - __experimentalToolsPanelItem as ToolsPanelItem, - __experimentalItemGroup as ItemGroup, - __experimentalHStack as HStack, - __experimentalVStack as VStack, - __experimentalGrid as Grid, - __experimentalHeading as Heading, - FlexItem, - Dropdown, - __experimentalDropdownContentWrapper as DropdownContentWrapper, - Button, -} from '@wordpress/components'; -import { __ } from '@wordpress/i18n'; -import { useCallback } from '@wordpress/element'; -import { shadow as shadowIcon, Icon, check } from '@wordpress/icons'; - -/** - * Internal dependencies - */ -import { mergeOrigins } from '../../store/get-block-settings'; -import { getValueFromVariable, TOOLSPANEL_DROPDOWNMENU_PROPS } from './utils'; -import { setImmutably } from '../../utils/object'; - -export function useHasEffectsPanel( settings ) { - const hasShadowControl = useHasShadowControl( settings ); - return hasShadowControl; -} - -function useHasShadowControl( settings ) { - return !! settings?.shadow; -} - -function EffectsToolsPanel( { - resetAllFilter, - onChange, - value, - panelId, - children, -} ) { - const resetAll = () => { - const updatedValue = resetAllFilter( value ); - onChange( updatedValue ); - }; - - return ( - - { children } - - ); -} - -const DEFAULT_CONTROLS = { - shadow: true, -}; - -export default function EffectsPanel( { - as: Wrapper = EffectsToolsPanel, - value, - onChange, - inheritedValue = value, - settings, - panelId, - defaultControls = DEFAULT_CONTROLS, -} ) { - const decodeValue = ( rawValue ) => - getValueFromVariable( { settings }, '', rawValue ); - - // Shadow - const hasShadowEnabled = useHasShadowControl( settings ); - const shadow = decodeValue( inheritedValue?.shadow ); - const shadowPresets = settings?.shadow?.presets; - const mergedShadowPresets = shadowPresets - ? mergeOrigins( shadowPresets ) - : []; - const setShadow = ( newValue ) => { - const slug = mergedShadowPresets?.find( - ( { shadow: shadowName } ) => shadowName === newValue - )?.slug; - - onChange( - setImmutably( - value, - [ 'shadow' ], - slug ? `var:preset|shadow|${ slug }` : newValue || undefined - ) - ); - }; - const hasShadow = () => !! value?.shadow; - const resetShadow = () => setShadow( undefined ); - - const resetAllFilter = useCallback( ( previousValue ) => { - return { - ...previousValue, - shadow: undefined, - }; - }, [] ); - - return ( - - { hasShadowEnabled && ( - - - - - - ) } - - ); -} - -const ShadowPopover = ( { shadow, onShadowChange, settings } ) => { - const popoverProps = { - placement: 'left-start', - offset: 36, - shift: true, - }; - - return ( - ( - - - - ) } - /> - ); -}; - -function renderShadowToggle() { - return ( { onToggle, isOpen } ) => { - const toggleProps = { - onClick: onToggle, - className: classnames( { 'is-open': isOpen } ), - 'aria-expanded': isOpen, - }; - - return ( - - ); - }; -} - -function ShadowPopoverContainer( { shadow, onShadowChange, settings } ) { - const defaultShadows = settings?.shadow?.presets?.default; - const themeShadows = settings?.shadow?.presets?.theme; - const defaultPresetsEnabled = settings?.shadow?.defaultPresets; - - const shadows = [ - ...( defaultPresetsEnabled ? defaultShadows : [] ), - ...( themeShadows || [] ), - ]; - - return ( -
- - { __( 'Shadow' ) } - - -
- ); -} - -function ShadowPresets( { presets, activeShadow, onSelect } ) { - return ! presets ? null : ( - - { presets.map( ( { name, slug, shadow } ) => ( - - onSelect( shadow === activeShadow ? undefined : shadow ) - } - shadow={ shadow } - /> - ) ) } - - ); -} - -function ShadowIndicator( { label, isActive, onSelect, shadow } ) { - return ( -
- -
- ); -} diff --git a/packages/block-editor/src/components/global-styles/index.js b/packages/block-editor/src/components/global-styles/index.js index 65392a7636c442..6df4ed512d025c 100644 --- a/packages/block-editor/src/components/global-styles/index.js +++ b/packages/block-editor/src/components/global-styles/index.js @@ -21,7 +21,6 @@ export { } from './dimensions-panel'; export { default as BorderPanel, useHasBorderPanel } from './border-panel'; export { default as ColorPanel, useHasColorPanel } from './color-panel'; -export { default as EffectsPanel, useHasEffectsPanel } from './effects-panel'; export { default as FiltersPanel, useHasFiltersPanel } from './filters-panel'; export { default as ImageSettingsPanel, diff --git a/packages/block-editor/src/components/global-styles/shadow-panel-components.js b/packages/block-editor/src/components/global-styles/shadow-panel-components.js new file mode 100644 index 00000000000000..6e4e3a15b184d8 --- /dev/null +++ b/packages/block-editor/src/components/global-styles/shadow-panel-components.js @@ -0,0 +1,125 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { + __experimentalVStack as VStack, + __experimentalHeading as Heading, + __experimentalGrid as Grid, + __experimentalHStack as HStack, + __experimentalDropdownContentWrapper as DropdownContentWrapper, + Button, + FlexItem, + Dropdown, +} from '@wordpress/components'; +import { shadow as shadowIcon, Icon, check } from '@wordpress/icons'; +/** + * External dependencies + */ +import classNames from 'classnames'; + +export function ShadowPopoverContainer( { shadow, onShadowChange, settings } ) { + const defaultShadows = settings?.shadow?.presets?.default; + const themeShadows = settings?.shadow?.presets?.theme; + const defaultPresetsEnabled = settings?.shadow?.defaultPresets; + + const shadows = [ + ...( defaultPresetsEnabled ? defaultShadows : [] ), + ...( themeShadows || [] ), + ]; + + return ( +
+ + { __( 'Drop shadow' ) } + + +
+ ); +} + +export function ShadowPresets( { presets, activeShadow, onSelect } ) { + return ! presets ? null : ( + + { presets.map( ( { name, slug, shadow } ) => ( + + onSelect( shadow === activeShadow ? undefined : shadow ) + } + shadow={ shadow } + /> + ) ) } + + ); +} + +export function ShadowIndicator( { label, isActive, onSelect, shadow } ) { + return ( +
+ +
+ ); +} + +export function ShadowPopover( { shadow, onShadowChange, settings } ) { + const popoverProps = { + placement: 'left-start', + offset: 36, + shift: true, + }; + + return ( + ( + + + + ) } + /> + ); +} + +function renderShadowToggle() { + return ( { onToggle, isOpen } ) => { + const toggleProps = { + onClick: onToggle, + className: classNames( { 'is-open': isOpen } ), + 'aria-expanded': isOpen, + }; + + return ( + + ); + }; +} diff --git a/packages/block-editor/src/components/global-styles/style.scss b/packages/block-editor/src/components/global-styles/style.scss index 693f1cee762bed..010c5faaefff44 100644 --- a/packages/block-editor/src/components/global-styles/style.scss +++ b/packages/block-editor/src/components/global-styles/style.scss @@ -1,13 +1,13 @@ -.block-editor-global-styles-effects-panel__toggle-icon { +.block-editor-global-styles__toggle-icon { fill: currentColor; } -.block-editor-global-styles-effects-panel__shadow-popover-container { +.block-editor-global-styles__shadow-popover-container { width: 230px; } .block-editor-global-styles-filters-panel__dropdown, -.block-editor-global-styles-effects-panel__shadow-dropdown { +.block-editor-global-styles__shadow-dropdown { display: block; padding: 0; @@ -22,7 +22,7 @@ } // wrapper to clip the shadow beyond 6px -.block-editor-global-styles-effects-panel__shadow-indicator-wrapper { +.block-editor-global-styles__shadow-indicator-wrapper { padding: $grid-unit-15 * 0.5; display: flex; align-items: center; @@ -30,7 +30,7 @@ } // These styles are similar to the color palette. -.block-editor-global-styles-effects-panel__shadow-indicator { +.block-editor-global-styles__shadow-indicator { color: $gray-800; border: $gray-200 $border-width solid; border-radius: $radius-block-ui; diff --git a/packages/block-editor/src/components/global-styles/typography-panel.js b/packages/block-editor/src/components/global-styles/typography-panel.js index 5347ddab922651..e689d84c83c981 100644 --- a/packages/block-editor/src/components/global-styles/typography-panel.js +++ b/packages/block-editor/src/components/global-styles/typography-panel.js @@ -373,7 +373,6 @@ export default function TypographyPanel( { withReset={ false } withSlider size="__unstable-large" - __nextHasNoMarginBottom /> ) } diff --git a/packages/block-editor/src/components/inspector-controls-tabs/styles-tab.js b/packages/block-editor/src/components/inspector-controls-tabs/styles-tab.js index 6f24051ea2cfcb..6c2556f2378ff1 100644 --- a/packages/block-editor/src/components/inspector-controls-tabs/styles-tab.js +++ b/packages/block-editor/src/components/inspector-controls-tabs/styles-tab.js @@ -11,8 +11,11 @@ import { __ } from '@wordpress/i18n'; import BlockStyles from '../block-styles'; import DefaultStylePicker from '../default-style-picker'; import InspectorControls from '../inspector-controls'; +import { getBorderPanelLabel } from '../../hooks/border'; const StylesTab = ( { blockName, clientId, hasBlockStyles } ) => { + const borderPanelLabel = getBorderPanelLabel( { blockName } ); + return ( <> { hasBlockStyles && ( @@ -45,8 +48,7 @@ const StylesTab = ( { blockName, clientId, hasBlockStyles } ) => { group="dimensions" label={ __( 'Dimensions' ) } /> - - + ); diff --git a/packages/block-editor/src/hooks/background.js b/packages/block-editor/src/hooks/background.js index d093d3da55c8d6..9524564b487ecb 100644 --- a/packages/block-editor/src/hooks/background.js +++ b/packages/block-editor/src/hooks/background.js @@ -18,6 +18,7 @@ import { __experimentalVStack as VStack, DropZone, FlexItem, + FocalPointPicker, MenuItem, VisuallyHidden, __experimentalItemGroup as ItemGroup, @@ -59,13 +60,17 @@ export function hasBackgroundImageValue( style ) { /** * Checks if there is a current value in the background size block support - * attributes. + * attributes. Background size values include background size as well + * as background position. * * @param {Object} style Style attribute. * @return {boolean} Whether or not the block has a background size value set. */ export function hasBackgroundSizeValue( style ) { - return style?.background?.backgroundSize !== undefined; + return ( + style?.background?.backgroundPosition !== undefined || + style?.background?.backgroundSize !== undefined + ); } /** @@ -130,6 +135,7 @@ function resetBackgroundSize( style = {}, setAttributes ) { ...style, background: { ...style?.background, + backgroundPosition: undefined, backgroundRepeat: undefined, backgroundSize: undefined, }, @@ -367,6 +373,26 @@ function backgroundSizeHelpText( value ) { return __( 'Set a fixed width.' ); } +export const coordsToBackgroundPosition = ( value ) => { + if ( ! value || isNaN( value.x ) || isNaN( value.y ) ) { + return undefined; + } + + return `${ value.x * 100 }% ${ value.y * 100 }%`; +}; + +export const backgroundPositionToCoords = ( value ) => { + if ( ! value ) { + return { x: undefined, y: undefined }; + } + + let [ x, y ] = value.split( ' ' ).map( ( v ) => parseFloat( v ) / 100 ); + x = isNaN( x ) ? undefined : x; + y = isNaN( y ) ? x : y; + + return { x, y }; +}; + function BackgroundSizePanelItem( { clientId, isShownByDefault, @@ -446,6 +472,18 @@ function BackgroundSizePanelItem( { } ); }; + const updateBackgroundPosition = ( next ) => { + setAttributes( { + style: cleanEmptyObject( { + ...style, + background: { + ...style?.background, + backgroundPosition: coordsToBackgroundPosition( next ), + }, + } ), + } ); + }; + const toggleIsRepeated = () => { setAttributes( { style: cleanEmptyObject( { @@ -471,6 +509,16 @@ function BackgroundSizePanelItem( { resetAllFilter={ resetAllFilter } panelId={ clientId } > + { let matchedColor; @@ -109,7 +111,7 @@ function attributesToStyle( attributes ) { }; } -function BordersInspectorControl( { children, resetAllFilter } ) { +function BordersInspectorControl( { label, children, resetAllFilter } ) { const attributesResetAllFilter = useCallback( ( attributes ) => { const existingStyle = attributesToStyle( attributes ); @@ -126,6 +128,7 @@ function BordersInspectorControl( { children, resetAllFilter } ) { { children } @@ -152,10 +155,16 @@ export function BorderPanel( { clientId, name, setAttributes, settings } ) { return null; } - const defaultControls = getBlockSupport( name, [ - BORDER_SUPPORT_KEY, - '__experimentalDefaultControls', - ] ); + const defaultControls = { + ...getBlockSupport( name, [ + BORDER_SUPPORT_KEY, + '__experimentalDefaultControls', + ] ), + ...getBlockSupport( name, [ + SHADOW_SUPPORT_KEY, + '__experimentalDefaultControls', + ] ), + }; return ( - hasBlockSupport( blockName, key ) - ); -} - -function EffectsInspectorControl( { children, resetAllFilter } ) { - return ( - - { children } - - ); -} -export function EffectsPanel( { clientId, setAttributes, settings } ) { - const isEnabled = useHasEffectsPanel( settings ); - const value = useSelect( - ( select ) => - select( blockEditorStore ).getBlockAttributes( clientId )?.style, - [ clientId ] - ); - - const onChange = ( newStyle ) => { - setAttributes( { style: cleanEmptyObject( newStyle ) } ); - }; - - if ( ! isEnabled ) { - return null; - } - - return ( - - ); -} diff --git a/packages/block-editor/src/hooks/font-size.js b/packages/block-editor/src/hooks/font-size.js index 12d9412181d705..e9a7a81aafbdf0 100644 --- a/packages/block-editor/src/hooks/font-size.js +++ b/packages/block-editor/src/hooks/font-size.js @@ -131,7 +131,6 @@ export function FontSizeEdit( props ) { withReset={ false } withSlider size="__unstable-large" - __nextHasNoMarginBottom /> ); } diff --git a/packages/block-editor/src/hooks/style.js b/packages/block-editor/src/hooks/style.js index 42fe431a40b242..dad62bc0594a75 100644 --- a/packages/block-editor/src/hooks/style.js +++ b/packages/block-editor/src/hooks/style.js @@ -15,7 +15,7 @@ import { getCSSRules, compileCSS } from '@wordpress/style-engine'; * Internal dependencies */ import { BACKGROUND_SUPPORT_KEY, BackgroundImagePanel } from './background'; -import { BORDER_SUPPORT_KEY, BorderPanel } from './border'; +import { BORDER_SUPPORT_KEY, BorderPanel, SHADOW_SUPPORT_KEY } from './border'; import { COLOR_SUPPORT_KEY, ColorEdit } from './color'; import { TypographyPanel, @@ -27,11 +27,6 @@ import { SPACING_SUPPORT_KEY, DimensionsPanel, } from './dimensions'; -import { - EFFECTS_SUPPORT_KEYS, - SHADOW_SUPPORT_KEY, - EffectsPanel, -} from './effects'; import { shouldSkipSerialization, useStyleOverride, @@ -42,12 +37,12 @@ import { useBlockEditingMode } from '../components/block-editing-mode'; const styleSupportKeys = [ ...TYPOGRAPHY_SUPPORT_KEYS, - ...EFFECTS_SUPPORT_KEYS, BORDER_SUPPORT_KEY, COLOR_SUPPORT_KEY, DIMENSIONS_SUPPORT_KEY, BACKGROUND_SUPPORT_KEY, SPACING_SUPPORT_KEY, + SHADOW_SUPPORT_KEY, ]; const hasStyleSupport = ( nameOrType ) => @@ -349,7 +344,6 @@ function BlockStyleControls( { - ); } diff --git a/packages/block-editor/src/hooks/test/background.js b/packages/block-editor/src/hooks/test/background.js new file mode 100644 index 00000000000000..cbc9033c2256f6 --- /dev/null +++ b/packages/block-editor/src/hooks/test/background.js @@ -0,0 +1,50 @@ +/** + * Internal dependencies + */ + +import { + backgroundPositionToCoords, + coordsToBackgroundPosition, +} from '../background'; + +describe( 'backgroundPositionToCoords', () => { + it( 'should return the correct coordinates for a percentage value using 2-value syntax', () => { + expect( backgroundPositionToCoords( '25% 75%' ) ).toEqual( { + x: 0.25, + y: 0.75, + } ); + } ); + + it( 'should return the correct coordinates for a percentage using 1-value syntax', () => { + expect( backgroundPositionToCoords( '50%' ) ).toEqual( { + x: 0.5, + y: 0.5, + } ); + } ); + + it( 'should return undefined coords in given an empty value', () => { + expect( backgroundPositionToCoords( '' ) ).toEqual( { + x: undefined, + y: undefined, + } ); + } ); + + it( 'should return undefined coords in given a string that cannot be converted', () => { + expect( backgroundPositionToCoords( 'apples' ) ).toEqual( { + x: undefined, + y: undefined, + } ); + } ); +} ); + +describe( 'coordsToBackgroundPosition', () => { + it( 'should return the correct background position for a set of coordinates', () => { + expect( coordsToBackgroundPosition( { x: 0.25, y: 0.75 } ) ).toBe( + '25% 75%' + ); + } ); + + it( 'should return undefined if no coordinates are provided', () => { + expect( coordsToBackgroundPosition( {} ) ).toBeUndefined(); + } ); +} ); diff --git a/packages/block-editor/src/hooks/test/effects.js b/packages/block-editor/src/hooks/test/effects.js deleted file mode 100644 index b4fe61745744b1..00000000000000 --- a/packages/block-editor/src/hooks/test/effects.js +++ /dev/null @@ -1,39 +0,0 @@ -/** - * Internal dependencies - */ -import { hasEffectsSupport } from '../effects'; - -describe( 'effects', () => { - describe( 'hasEffectsSupport', () => { - it( 'should return false if the block does not support effects', () => { - const settings = { - supports: { - shadow: false, - }, - }; - - expect( hasEffectsSupport( settings ) ).toBe( false ); - } ); - - it( 'should return true if the block supports effects', () => { - const settings = { - supports: { - shadow: true, - }, - }; - - expect( hasEffectsSupport( settings ) ).toBe( true ); - } ); - - it( 'should return true if the block supports effects and other features', () => { - const settings = { - supports: { - shadow: true, - align: true, - }, - }; - - expect( hasEffectsSupport( settings ) ).toBe( true ); - } ); - } ); -} ); diff --git a/packages/block-library/src/block/edit.js b/packages/block-library/src/block/edit.js index f8359c9889312c..fb6f89ce506ca8 100644 --- a/packages/block-library/src/block/edit.js +++ b/packages/block-library/src/block/edit.js @@ -29,6 +29,7 @@ import { } from '@wordpress/block-editor'; import { privateApis as patternsPrivateApis } from '@wordpress/patterns'; import { parse, cloneBlock } from '@wordpress/blocks'; +import { RichTextData } from '@wordpress/rich-text'; /** * Internal dependencies @@ -131,6 +132,16 @@ function applyInitialContentValuesToInnerBlocks( } ); } +function isAttributeEqual( attribute1, attribute2 ) { + if ( + attribute1 instanceof RichTextData && + attribute2 instanceof RichTextData + ) { + return attribute1.toString() === attribute2.toString(); + } + return attribute1 === attribute2; +} + function getContentValuesFromInnerBlocks( blocks, defaultValues ) { /** @type {Record}>} */ const content = {}; @@ -145,10 +156,14 @@ function getContentValuesFromInnerBlocks( blocks, defaultValues ) { const attributes = getOverridableAttributes( block ); for ( const attributeKey of attributes ) { if ( - block.attributes[ attributeKey ] !== - defaultValues[ blockId ][ attributeKey ] + ! isAttributeEqual( + block.attributes[ attributeKey ], + defaultValues[ blockId ][ attributeKey ] + ) ) { - content[ blockId ] ??= { values: {} }; + content[ blockId ] ??= { values: {}, blockName: block.name }; + // TODO: We need a way to represent `undefined` in the serialized overrides. + // Also see: https://github.com/WordPress/gutenberg/pull/57249#discussion_r1452987871 content[ blockId ].values[ attributeKey ] = block.attributes[ attributeKey ] === undefined ? // TODO: We use an empty string to represent undefined for now until diff --git a/packages/block-library/src/file/view.js b/packages/block-library/src/file/view.js index 79340223f007cb..62c31b7b365ff2 100644 --- a/packages/block-library/src/file/view.js +++ b/packages/block-library/src/file/view.js @@ -7,10 +7,14 @@ import { store } from '@wordpress/interactivity'; */ import { browserSupportsPdfs } from './utils'; -store( 'core/file', { - state: { - get hasPdfPreview() { - return browserSupportsPdfs(); +store( + 'core/file', + { + state: { + get hasPdfPreview() { + return browserSupportsPdfs(); + }, }, }, -} ); + { lock: true } +); diff --git a/packages/block-library/src/image/view.js b/packages/block-library/src/image/view.js index 2d5268e4836cb7..8ae0149726570c 100644 --- a/packages/block-library/src/image/view.js +++ b/packages/block-library/src/image/view.js @@ -77,269 +77,278 @@ function handleScroll( ctx ) { } } -const { state, actions, callbacks } = store( 'core/image', { - state: { - windowWidth: window.innerWidth, - windowHeight: window.innerHeight, - get roleAttribute() { - const ctx = getContext(); - return ctx.lightboxEnabled ? 'dialog' : null; - }, - get ariaModal() { - const ctx = getContext(); - return ctx.lightboxEnabled ? 'true' : null; - }, - get dialogLabel() { - const ctx = getContext(); - return ctx.lightboxEnabled ? ctx.dialogLabel : null; - }, - get lightboxObjectFit() { - const ctx = getContext(); - if ( ctx.initialized ) { - return 'cover'; - } - }, - get enlargedImgSrc() { - const ctx = getContext(); - return ctx.initialized - ? ctx.imageUploadedSrc - : 'data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs='; - }, - }, - actions: { - showLightbox( event ) { - const ctx = getContext(); - // We can't initialize the lightbox until the reference - // image is loaded, otherwise the UX is broken. - if ( ! ctx.imageLoaded ) { - return; - } - ctx.initialized = true; - ctx.lastFocusedElement = window.document.activeElement; - ctx.scrollDelta = 0; - ctx.pointerType = event.pointerType; - - ctx.lightboxEnabled = true; - setStyles( ctx, ctx.imageRef ); - - ctx.scrollTopReset = - window.pageYOffset || document.documentElement.scrollTop; - - // In most cases, this value will be 0, but this is included - // in case a user has created a page with horizontal scrolling. - ctx.scrollLeftReset = - window.pageXOffset || document.documentElement.scrollLeft; - - // We define and bind the scroll callback here so - // that we can pass the context and as an argument. - // We may be able to change this in the future if we - // define the scroll callback in the store instead, but - // this approach seems to tbe clearest for now. - scrollCallback = handleScroll.bind( null, ctx ); - - // We need to add a scroll event listener to the window - // here because we are unable to otherwise access it via - // the Interactivity API directives. If we add a native way - // to access the window, we can remove this. - window.addEventListener( 'scroll', scrollCallback, false ); - }, - hideLightbox() { - const ctx = getContext(); - ctx.hideAnimationEnabled = true; - if ( ctx.lightboxEnabled ) { - // We want to wait until the close animation is completed - // before allowing a user to scroll again. The duration of this - // animation is defined in the styles.scss and depends on if the - // animation is 'zoom' or 'fade', but in any case we should wait - // a few milliseconds longer than the duration, otherwise a user - // may scroll too soon and cause the animation to look sloppy. - setTimeout( function () { - window.removeEventListener( 'scroll', scrollCallback ); - // If we don't delay before changing the focus, - // the focus ring will appear on Firefox before - // the image has finished animating, which looks broken. - ctx.lightboxTriggerRef.focus( { - preventScroll: true, - } ); - }, 450 ); - - ctx.lightboxEnabled = false; - } +const { state, actions, callbacks } = store( + 'core/image', + { + state: { + windowWidth: window.innerWidth, + windowHeight: window.innerHeight, + get roleAttribute() { + const ctx = getContext(); + return ctx.lightboxEnabled ? 'dialog' : null; + }, + get ariaModal() { + const ctx = getContext(); + return ctx.lightboxEnabled ? 'true' : null; + }, + get dialogLabel() { + const ctx = getContext(); + return ctx.lightboxEnabled ? ctx.dialogLabel : null; + }, + get lightboxObjectFit() { + const ctx = getContext(); + if ( ctx.initialized ) { + return 'cover'; + } + }, + get enlargedImgSrc() { + const ctx = getContext(); + return ctx.initialized + ? ctx.imageUploadedSrc + : 'data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs='; + }, }, - handleKeydown( event ) { - const ctx = getContext(); - if ( ctx.lightboxEnabled ) { - if ( event.key === 'Tab' || event.keyCode === 9 ) { - // If shift + tab it change the direction - if ( - event.shiftKey && - window.document.activeElement === - ctx.firstFocusableElement - ) { - event.preventDefault(); - ctx.lastFocusableElement.focus(); - } else if ( - ! event.shiftKey && - window.document.activeElement === - ctx.lastFocusableElement - ) { - event.preventDefault(); - ctx.firstFocusableElement.focus(); - } + actions: { + showLightbox( event ) { + const ctx = getContext(); + // We can't initialize the lightbox until the reference + // image is loaded, otherwise the UX is broken. + if ( ! ctx.imageLoaded ) { + return; } + ctx.initialized = true; + ctx.lastFocusedElement = window.document.activeElement; + ctx.scrollDelta = 0; + ctx.pointerType = event.pointerType; + + ctx.lightboxEnabled = true; + setStyles( ctx, ctx.imageRef ); + + ctx.scrollTopReset = + window.pageYOffset || document.documentElement.scrollTop; + + // In most cases, this value will be 0, but this is included + // in case a user has created a page with horizontal scrolling. + ctx.scrollLeftReset = + window.pageXOffset || document.documentElement.scrollLeft; + + // We define and bind the scroll callback here so + // that we can pass the context and as an argument. + // We may be able to change this in the future if we + // define the scroll callback in the store instead, but + // this approach seems to tbe clearest for now. + scrollCallback = handleScroll.bind( null, ctx ); + + // We need to add a scroll event listener to the window + // here because we are unable to otherwise access it via + // the Interactivity API directives. If we add a native way + // to access the window, we can remove this. + window.addEventListener( 'scroll', scrollCallback, false ); + }, + hideLightbox() { + const ctx = getContext(); + ctx.hideAnimationEnabled = true; + if ( ctx.lightboxEnabled ) { + // We want to wait until the close animation is completed + // before allowing a user to scroll again. The duration of this + // animation is defined in the styles.scss and depends on if the + // animation is 'zoom' or 'fade', but in any case we should wait + // a few milliseconds longer than the duration, otherwise a user + // may scroll too soon and cause the animation to look sloppy. + setTimeout( function () { + window.removeEventListener( 'scroll', scrollCallback ); + // If we don't delay before changing the focus, + // the focus ring will appear on Firefox before + // the image has finished animating, which looks broken. + ctx.lightboxTriggerRef.focus( { + preventScroll: true, + } ); + }, 450 ); + + ctx.lightboxEnabled = false; + } + }, + handleKeydown( event ) { + const ctx = getContext(); + if ( ctx.lightboxEnabled ) { + if ( event.key === 'Tab' || event.keyCode === 9 ) { + // If shift + tab it change the direction + if ( + event.shiftKey && + window.document.activeElement === + ctx.firstFocusableElement + ) { + event.preventDefault(); + ctx.lastFocusableElement.focus(); + } else if ( + ! event.shiftKey && + window.document.activeElement === + ctx.lastFocusableElement + ) { + event.preventDefault(); + ctx.firstFocusableElement.focus(); + } + } - if ( event.key === 'Escape' || event.keyCode === 27 ) { - actions.hideLightbox( event ); + if ( event.key === 'Escape' || event.keyCode === 27 ) { + actions.hideLightbox( event ); + } } - } - }, - // This is fired just by lazily loaded - // images on the page, not all images. - handleLoad() { - const ctx = getContext(); - const { ref } = getElement(); - ctx.imageLoaded = true; - ctx.imageCurrentSrc = ref.currentSrc; - callbacks.setButtonStyles(); - }, - handleTouchStart() { - isTouching = true; - }, - handleTouchMove( event ) { - const ctx = getContext(); - // On mobile devices, we want to prevent triggering the - // scroll event because otherwise the page jumps around as - // we reset the scroll position. This also means that closing - // the lightbox requires that a user perform a simple tap. This - // may be changed in the future if we find a better alternative - // to override or reset the scroll position during swipe actions. - if ( ctx.lightboxEnabled ) { - event.preventDefault(); - } - }, - handleTouchEnd() { - // We need to wait a few milliseconds before resetting - // to ensure that pinch to zoom works consistently - // on mobile devices when the lightbox is open. - lastTouchTime = Date.now(); - isTouching = false; - }, - }, - callbacks: { - initOriginImage() { - const ctx = getContext(); - const { ref } = getElement(); - ctx.imageRef = ref; - if ( ref.complete ) { + }, + // This is fired just by lazily loaded + // images on the page, not all images. + handleLoad() { + const ctx = getContext(); + const { ref } = getElement(); ctx.imageLoaded = true; ctx.imageCurrentSrc = ref.currentSrc; - } - }, - initTriggerButton() { - const ctx = getContext(); - const { ref } = getElement(); - ctx.lightboxTriggerRef = ref; - }, - initLightbox() { - const ctx = getContext(); - const { ref } = getElement(); - if ( ctx.lightboxEnabled ) { - const focusableElements = - ref.querySelectorAll( focusableSelectors ); - ctx.firstFocusableElement = focusableElements[ 0 ]; - ctx.lastFocusableElement = - focusableElements[ focusableElements.length - 1 ]; - - // Move focus to the dialog when opening it. - ref.focus(); - } + callbacks.setButtonStyles(); + }, + handleTouchStart() { + isTouching = true; + }, + handleTouchMove( event ) { + const ctx = getContext(); + // On mobile devices, we want to prevent triggering the + // scroll event because otherwise the page jumps around as + // we reset the scroll position. This also means that closing + // the lightbox requires that a user perform a simple tap. This + // may be changed in the future if we find a better alternative + // to override or reset the scroll position during swipe actions. + if ( ctx.lightboxEnabled ) { + event.preventDefault(); + } + }, + handleTouchEnd() { + // We need to wait a few milliseconds before resetting + // to ensure that pinch to zoom works consistently + // on mobile devices when the lightbox is open. + lastTouchTime = Date.now(); + isTouching = false; + }, }, - setButtonStyles() { - const { ref } = getElement(); - const { naturalWidth, naturalHeight, offsetWidth, offsetHeight } = - ref; - - // If the image isn't loaded yet, we can't - // calculate where the button should be. - if ( naturalWidth === 0 || naturalHeight === 0 ) { - return; - } + callbacks: { + initOriginImage() { + const ctx = getContext(); + const { ref } = getElement(); + ctx.imageRef = ref; + if ( ref.complete ) { + ctx.imageLoaded = true; + ctx.imageCurrentSrc = ref.currentSrc; + } + }, + initTriggerButton() { + const ctx = getContext(); + const { ref } = getElement(); + ctx.lightboxTriggerRef = ref; + }, + initLightbox() { + const ctx = getContext(); + const { ref } = getElement(); + if ( ctx.lightboxEnabled ) { + const focusableElements = + ref.querySelectorAll( focusableSelectors ); + ctx.firstFocusableElement = focusableElements[ 0 ]; + ctx.lastFocusableElement = + focusableElements[ focusableElements.length - 1 ]; + + // Move focus to the dialog when opening it. + ref.focus(); + } + }, + setButtonStyles() { + const { ref } = getElement(); + const { + naturalWidth, + naturalHeight, + offsetWidth, + offsetHeight, + } = ref; + + // If the image isn't loaded yet, we can't + // calculate where the button should be. + if ( naturalWidth === 0 || naturalHeight === 0 ) { + return; + } - const figure = ref.parentElement; - const figureWidth = ref.parentElement.clientWidth; - - // We need special handling for the height because - // a caption will cause the figure to be taller than - // the image, which means we need to account for that - // when calculating the placement of the button in the - // top right corner of the image. - let figureHeight = ref.parentElement.clientHeight; - const caption = figure.querySelector( 'figcaption' ); - if ( caption ) { - const captionComputedStyle = window.getComputedStyle( caption ); - if ( - ! [ 'absolute', 'fixed' ].includes( - captionComputedStyle.position - ) - ) { - figureHeight = - figureHeight - - caption.offsetHeight - - parseFloat( captionComputedStyle.marginTop ) - - parseFloat( captionComputedStyle.marginBottom ); + const figure = ref.parentElement; + const figureWidth = ref.parentElement.clientWidth; + + // We need special handling for the height because + // a caption will cause the figure to be taller than + // the image, which means we need to account for that + // when calculating the placement of the button in the + // top right corner of the image. + let figureHeight = ref.parentElement.clientHeight; + const caption = figure.querySelector( 'figcaption' ); + if ( caption ) { + const captionComputedStyle = + window.getComputedStyle( caption ); + if ( + ! [ 'absolute', 'fixed' ].includes( + captionComputedStyle.position + ) + ) { + figureHeight = + figureHeight - + caption.offsetHeight - + parseFloat( captionComputedStyle.marginTop ) - + parseFloat( captionComputedStyle.marginBottom ); + } } - } - const buttonOffsetTop = figureHeight - offsetHeight; - const buttonOffsetRight = figureWidth - offsetWidth; - - const ctx = getContext(); - - // In the case of an image with object-fit: contain, the - // size of the element can be larger than the image itself, - // so we need to calculate where to place the button. - if ( ctx.scaleAttr === 'contain' ) { - // Natural ratio of the image. - const naturalRatio = naturalWidth / naturalHeight; - // Offset ratio of the image. - const offsetRatio = offsetWidth / offsetHeight; - - if ( naturalRatio >= offsetRatio ) { - // If it reaches the width first, keep - // the width and compute the height. - const referenceHeight = offsetWidth / naturalRatio; - ctx.imageButtonTop = - ( offsetHeight - referenceHeight ) / 2 + - buttonOffsetTop + - 16; - ctx.imageButtonRight = buttonOffsetRight + 16; + const buttonOffsetTop = figureHeight - offsetHeight; + const buttonOffsetRight = figureWidth - offsetWidth; + + const ctx = getContext(); + + // In the case of an image with object-fit: contain, the + // size of the element can be larger than the image itself, + // so we need to calculate where to place the button. + if ( ctx.scaleAttr === 'contain' ) { + // Natural ratio of the image. + const naturalRatio = naturalWidth / naturalHeight; + // Offset ratio of the image. + const offsetRatio = offsetWidth / offsetHeight; + + if ( naturalRatio >= offsetRatio ) { + // If it reaches the width first, keep + // the width and compute the height. + const referenceHeight = offsetWidth / naturalRatio; + ctx.imageButtonTop = + ( offsetHeight - referenceHeight ) / 2 + + buttonOffsetTop + + 16; + ctx.imageButtonRight = buttonOffsetRight + 16; + } else { + // If it reaches the height first, keep + // the height and compute the width. + const referenceWidth = offsetHeight * naturalRatio; + ctx.imageButtonTop = buttonOffsetTop + 16; + ctx.imageButtonRight = + ( offsetWidth - referenceWidth ) / 2 + + buttonOffsetRight + + 16; + } } else { - // If it reaches the height first, keep - // the height and compute the width. - const referenceWidth = offsetHeight * naturalRatio; ctx.imageButtonTop = buttonOffsetTop + 16; - ctx.imageButtonRight = - ( offsetWidth - referenceWidth ) / 2 + - buttonOffsetRight + - 16; + ctx.imageButtonRight = buttonOffsetRight + 16; } - } else { - ctx.imageButtonTop = buttonOffsetTop + 16; - ctx.imageButtonRight = buttonOffsetRight + 16; - } - }, - setStylesOnResize() { - const ctx = getContext(); - const { ref } = getElement(); - if ( - ctx.lightboxEnabled && - ( state.windowWidth || state.windowHeight ) - ) { - setStyles( ctx, ref ); - } + }, + setStylesOnResize() { + const ctx = getContext(); + const { ref } = getElement(); + if ( + ctx.lightboxEnabled && + ( state.windowWidth || state.windowHeight ) + ) { + setStyles( ctx, ref ); + } + }, }, }, -} ); + { lock: true } +); window.addEventListener( 'resize', diff --git a/packages/block-library/src/navigation/view.js b/packages/block-library/src/navigation/view.js index d42832a1f8d02e..eb553eee9ca181 100644 --- a/packages/block-library/src/navigation/view.js +++ b/packages/block-library/src/navigation/view.js @@ -23,193 +23,206 @@ const focusableSelectors = [ // capture the clicks, instead of relying on the focusout event. document.addEventListener( 'click', () => {} ); -const { state, actions } = store( 'core/navigation', { - state: { - get roleAttribute() { - const ctx = getContext(); - return ctx.type === 'overlay' && state.isMenuOpen ? 'dialog' : null; - }, - get ariaModal() { - const ctx = getContext(); - return ctx.type === 'overlay' && state.isMenuOpen ? 'true' : null; - }, - get ariaLabel() { - const ctx = getContext(); - return ctx.type === 'overlay' && state.isMenuOpen - ? ctx.ariaLabel - : null; - }, - get isMenuOpen() { - // The menu is opened if either `click`, `hover` or `focus` is true. - return ( - Object.values( state.menuOpenedBy ).filter( Boolean ).length > 0 - ); - }, - get menuOpenedBy() { - const ctx = getContext(); - return ctx.type === 'overlay' - ? ctx.overlayOpenedBy - : ctx.submenuOpenedBy; - }, - }, - actions: { - openMenuOnHover() { - const { type, overlayOpenedBy } = getContext(); - if ( - type === 'submenu' && - // Only open on hover if the overlay is closed. - Object.values( overlayOpenedBy || {} ).filter( Boolean ) - .length === 0 - ) - actions.openMenu( 'hover' ); - }, - closeMenuOnHover() { - actions.closeMenu( 'hover' ); - }, - openMenuOnClick() { - const ctx = getContext(); - const { ref } = getElement(); - ctx.previousFocus = ref; - actions.openMenu( 'click' ); - }, - closeMenuOnClick() { - actions.closeMenu( 'click' ); - actions.closeMenu( 'focus' ); - }, - openMenuOnFocus() { - actions.openMenu( 'focus' ); - }, - toggleMenuOnClick() { - const ctx = getContext(); - const { ref } = getElement(); - // Safari won't send focus to the clicked element, so we need to manually place it: https://bugs.webkit.org/show_bug.cgi?id=22261 - if ( window.document.activeElement !== ref ) ref.focus(); - const { menuOpenedBy } = state; - if ( menuOpenedBy.click || menuOpenedBy.focus ) { - actions.closeMenu( 'click' ); - actions.closeMenu( 'focus' ); - } else { +const { state, actions } = store( + 'core/navigation', + { + state: { + get roleAttribute() { + const ctx = getContext(); + return ctx.type === 'overlay' && state.isMenuOpen + ? 'dialog' + : null; + }, + get ariaModal() { + const ctx = getContext(); + return ctx.type === 'overlay' && state.isMenuOpen + ? 'true' + : null; + }, + get ariaLabel() { + const ctx = getContext(); + return ctx.type === 'overlay' && state.isMenuOpen + ? ctx.ariaLabel + : null; + }, + get isMenuOpen() { + // The menu is opened if either `click`, `hover` or `focus` is true. + return ( + Object.values( state.menuOpenedBy ).filter( Boolean ) + .length > 0 + ); + }, + get menuOpenedBy() { + const ctx = getContext(); + return ctx.type === 'overlay' + ? ctx.overlayOpenedBy + : ctx.submenuOpenedBy; + }, + }, + actions: { + openMenuOnHover() { + const { type, overlayOpenedBy } = getContext(); + if ( + type === 'submenu' && + // Only open on hover if the overlay is closed. + Object.values( overlayOpenedBy || {} ).filter( Boolean ) + .length === 0 + ) + actions.openMenu( 'hover' ); + }, + closeMenuOnHover() { + actions.closeMenu( 'hover' ); + }, + openMenuOnClick() { + const ctx = getContext(); + const { ref } = getElement(); ctx.previousFocus = ref; actions.openMenu( 'click' ); - } - }, - handleMenuKeydown( event ) { - const { type, firstFocusableElement, lastFocusableElement } = - getContext(); - if ( state.menuOpenedBy.click ) { - // If Escape close the menu. - if ( event?.key === 'Escape' ) { + }, + closeMenuOnClick() { + actions.closeMenu( 'click' ); + actions.closeMenu( 'focus' ); + }, + openMenuOnFocus() { + actions.openMenu( 'focus' ); + }, + toggleMenuOnClick() { + const ctx = getContext(); + const { ref } = getElement(); + // Safari won't send focus to the clicked element, so we need to manually place it: https://bugs.webkit.org/show_bug.cgi?id=22261 + if ( window.document.activeElement !== ref ) ref.focus(); + const { menuOpenedBy } = state; + if ( menuOpenedBy.click || menuOpenedBy.focus ) { actions.closeMenu( 'click' ); actions.closeMenu( 'focus' ); - return; + } else { + ctx.previousFocus = ref; + actions.openMenu( 'click' ); } + }, + handleMenuKeydown( event ) { + const { type, firstFocusableElement, lastFocusableElement } = + getContext(); + if ( state.menuOpenedBy.click ) { + // If Escape close the menu. + if ( event?.key === 'Escape' ) { + actions.closeMenu( 'click' ); + actions.closeMenu( 'focus' ); + return; + } - // Trap focus if it is an overlay (main menu). - if ( type === 'overlay' && event.key === 'Tab' ) { - // If shift + tab it change the direction. - if ( - event.shiftKey && - window.document.activeElement === firstFocusableElement - ) { - event.preventDefault(); - lastFocusableElement.focus(); - } else if ( - ! event.shiftKey && - window.document.activeElement === lastFocusableElement - ) { - event.preventDefault(); - firstFocusableElement.focus(); + // Trap focus if it is an overlay (main menu). + if ( type === 'overlay' && event.key === 'Tab' ) { + // If shift + tab it change the direction. + if ( + event.shiftKey && + window.document.activeElement === + firstFocusableElement + ) { + event.preventDefault(); + lastFocusableElement.focus(); + } else if ( + ! event.shiftKey && + window.document.activeElement === + lastFocusableElement + ) { + event.preventDefault(); + firstFocusableElement.focus(); + } } } - } - }, - handleMenuFocusout( event ) { - const { modal } = getContext(); - // If focus is outside modal, and in the document, close menu - // event.target === The element losing focus - // event.relatedTarget === The element receiving focus (if any) - // When focusout is outsite the document, - // `window.document.activeElement` doesn't change. + }, + handleMenuFocusout( event ) { + const { modal } = getContext(); + // If focus is outside modal, and in the document, close menu + // event.target === The element losing focus + // event.relatedTarget === The element receiving focus (if any) + // When focusout is outsite the document, + // `window.document.activeElement` doesn't change. - // The event.relatedTarget is null when something outside the navigation menu is clicked. This is only necessary for Safari. - if ( - event.relatedTarget === null || - ( ! modal?.contains( event.relatedTarget ) && - event.target !== window.document.activeElement ) - ) { - actions.closeMenu( 'click' ); - actions.closeMenu( 'focus' ); - } - }, + // The event.relatedTarget is null when something outside the navigation menu is clicked. This is only necessary for Safari. + if ( + event.relatedTarget === null || + ( ! modal?.contains( event.relatedTarget ) && + event.target !== window.document.activeElement ) + ) { + actions.closeMenu( 'click' ); + actions.closeMenu( 'focus' ); + } + }, - openMenu( menuOpenedOn = 'click' ) { - const { type } = getContext(); - state.menuOpenedBy[ menuOpenedOn ] = true; - if ( type === 'overlay' ) { - // Add a `has-modal-open` class to the root. - document.documentElement.classList.add( 'has-modal-open' ); - } - }, + openMenu( menuOpenedOn = 'click' ) { + const { type } = getContext(); + state.menuOpenedBy[ menuOpenedOn ] = true; + if ( type === 'overlay' ) { + // Add a `has-modal-open` class to the root. + document.documentElement.classList.add( 'has-modal-open' ); + } + }, - closeMenu( menuClosedOn = 'click' ) { - const ctx = getContext(); - state.menuOpenedBy[ menuClosedOn ] = false; - // Check if the menu is still open or not. - if ( ! state.isMenuOpen ) { - if ( ctx.modal?.contains( window.document.activeElement ) ) { - ctx.previousFocus?.focus(); + closeMenu( menuClosedOn = 'click' ) { + const ctx = getContext(); + state.menuOpenedBy[ menuClosedOn ] = false; + // Check if the menu is still open or not. + if ( ! state.isMenuOpen ) { + if ( + ctx.modal?.contains( window.document.activeElement ) + ) { + ctx.previousFocus?.focus(); + } + ctx.modal = null; + ctx.previousFocus = null; + if ( ctx.type === 'overlay' ) { + document.documentElement.classList.remove( + 'has-modal-open' + ); + } } - ctx.modal = null; - ctx.previousFocus = null; - if ( ctx.type === 'overlay' ) { - document.documentElement.classList.remove( - 'has-modal-open' - ); + }, + }, + callbacks: { + initMenu() { + const ctx = getContext(); + const { ref } = getElement(); + if ( state.isMenuOpen ) { + const focusableElements = + ref.querySelectorAll( focusableSelectors ); + ctx.modal = ref; + ctx.firstFocusableElement = focusableElements[ 0 ]; + ctx.lastFocusableElement = + focusableElements[ focusableElements.length - 1 ]; } - } - }, - }, - callbacks: { - initMenu() { - const ctx = getContext(); - const { ref } = getElement(); - if ( state.isMenuOpen ) { - const focusableElements = - ref.querySelectorAll( focusableSelectors ); - ctx.modal = ref; - ctx.firstFocusableElement = focusableElements[ 0 ]; - ctx.lastFocusableElement = - focusableElements[ focusableElements.length - 1 ]; - } - }, - focusFirstElement() { - const { ref } = getElement(); - if ( state.isMenuOpen ) { - const focusableElements = - ref.querySelectorAll( focusableSelectors ); - focusableElements?.[ 0 ]?.focus(); - } - }, - initNav() { - const context = getContext(); - const mediaQuery = window.matchMedia( - `(max-width: ${ NAVIGATION_MOBILE_COLLAPSE })` - ); + }, + focusFirstElement() { + const { ref } = getElement(); + if ( state.isMenuOpen ) { + const focusableElements = + ref.querySelectorAll( focusableSelectors ); + focusableElements?.[ 0 ]?.focus(); + } + }, + initNav() { + const context = getContext(); + const mediaQuery = window.matchMedia( + `(max-width: ${ NAVIGATION_MOBILE_COLLAPSE })` + ); - // Run once to set the initial state. - context.isCollapsed = mediaQuery.matches; + // Run once to set the initial state. + context.isCollapsed = mediaQuery.matches; - function handleCollapse( event ) { - context.isCollapsed = event.matches; - } + function handleCollapse( event ) { + context.isCollapsed = event.matches; + } - // Run on resize to update the state. - mediaQuery.addEventListener( 'change', handleCollapse ); + // Run on resize to update the state. + mediaQuery.addEventListener( 'change', handleCollapse ); - // Remove the listener when the component is unmounted. - return () => { - mediaQuery.removeEventListener( 'change', handleCollapse ); - }; + // Remove the listener when the component is unmounted. + return () => { + mediaQuery.removeEventListener( 'change', handleCollapse ); + }; + }, }, }, -} ); + { lock: true } +); diff --git a/packages/block-library/src/query/view.js b/packages/block-library/src/query/view.js index ee811b4b8e90f1..396bf4de643698 100644 --- a/packages/block-library/src/query/view.js +++ b/packages/block-library/src/query/view.js @@ -18,54 +18,62 @@ const isValidEvent = ( event ) => ! event.shiftKey && ! event.defaultPrevented; -store( 'core/query', { - actions: { - *navigate( event ) { - const ctx = getContext(); - const { ref } = getElement(); - const queryRef = ref.closest( - '.wp-block-query[data-wp-router-region]' - ); - const isDisabled = queryRef?.dataset.wpNavigationDisabled; +store( + 'core/query', + { + actions: { + *navigate( event ) { + const ctx = getContext(); + const { ref } = getElement(); + const queryRef = ref.closest( + '.wp-block-query[data-wp-router-region]' + ); + const isDisabled = queryRef?.dataset.wpNavigationDisabled; - if ( isValidLink( ref ) && isValidEvent( event ) && ! isDisabled ) { - event.preventDefault(); + if ( + isValidLink( ref ) && + isValidEvent( event ) && + ! isDisabled + ) { + event.preventDefault(); - const { actions } = yield import( - '@wordpress/interactivity-router' - ); - yield actions.navigate( ref.href ); - ctx.url = ref.href; + const { actions } = yield import( + '@wordpress/interactivity-router' + ); + yield actions.navigate( ref.href ); + ctx.url = ref.href; - // Focus the first anchor of the Query block. - const firstAnchor = `.wp-block-post-template a[href]`; - queryRef.querySelector( firstAnchor )?.focus(); - } - }, - *prefetch() { - 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( - '@wordpress/interactivity-router' + // Focus the first anchor of the Query block. + const firstAnchor = `.wp-block-post-template a[href]`; + queryRef.querySelector( firstAnchor )?.focus(); + } + }, + *prefetch() { + const { ref } = getElement(); + const queryRef = ref.closest( + '.wp-block-query[data-wp-router-region]' ); - yield actions.prefetch( ref.href ); - } + const isDisabled = queryRef?.dataset.wpNavigationDisabled; + if ( isValidLink( ref ) && ! isDisabled ) { + const { actions } = yield import( + '@wordpress/interactivity-router' + ); + yield actions.prefetch( ref.href ); + } + }, }, - }, - callbacks: { - *prefetch() { - const { url } = getContext(); - const { ref } = getElement(); - if ( url && isValidLink( ref ) ) { - const { actions } = yield import( - '@wordpress/interactivity-router' - ); - yield actions.prefetch( ref.href ); - } + callbacks: { + *prefetch() { + const { url } = getContext(); + const { ref } = getElement(); + if ( url && isValidLink( ref ) ) { + const { actions } = yield import( + '@wordpress/interactivity-router' + ); + yield actions.prefetch( ref.href ); + } + }, }, }, -} ); + { lock: true } +); diff --git a/packages/block-library/src/search/view.js b/packages/block-library/src/search/view.js index b633bf971f363a..0e4c462a2e3213 100644 --- a/packages/block-library/src/search/view.js +++ b/packages/block-library/src/search/view.js @@ -3,66 +3,70 @@ */ import { store, getContext, getElement } from '@wordpress/interactivity'; -const { actions } = store( 'core/search', { - state: { - get ariaLabel() { - const { - isSearchInputVisible, - ariaLabelCollapsed, - ariaLabelExpanded, - } = getContext(); - return isSearchInputVisible - ? ariaLabelExpanded - : ariaLabelCollapsed; +const { actions } = store( + 'core/search', + { + state: { + get ariaLabel() { + const { + isSearchInputVisible, + ariaLabelCollapsed, + ariaLabelExpanded, + } = getContext(); + return isSearchInputVisible + ? ariaLabelExpanded + : ariaLabelCollapsed; + }, + get ariaControls() { + const { isSearchInputVisible, inputId } = getContext(); + return isSearchInputVisible ? null : inputId; + }, + get type() { + const { isSearchInputVisible } = getContext(); + return isSearchInputVisible ? 'submit' : 'button'; + }, + get tabindex() { + const { isSearchInputVisible } = getContext(); + return isSearchInputVisible ? '0' : '-1'; + }, }, - get ariaControls() { - const { isSearchInputVisible, inputId } = getContext(); - return isSearchInputVisible ? null : inputId; - }, - get type() { - const { isSearchInputVisible } = getContext(); - return isSearchInputVisible ? 'submit' : 'button'; - }, - get tabindex() { - const { isSearchInputVisible } = getContext(); - return isSearchInputVisible ? '0' : '-1'; - }, - }, - actions: { - openSearchInput( event ) { - const ctx = getContext(); - const { ref } = getElement(); - if ( ! ctx.isSearchInputVisible ) { - event.preventDefault(); - ctx.isSearchInputVisible = true; - ref.parentElement.querySelector( 'input' ).focus(); - } - }, - closeSearchInput() { - const ctx = getContext(); - ctx.isSearchInputVisible = false; - }, - handleSearchKeydown( event ) { - const { ref } = getElement(); - // If Escape close the menu. - if ( event?.key === 'Escape' ) { - actions.closeSearchInput(); - ref.querySelector( 'button' ).focus(); - } - }, - handleSearchFocusout( event ) { - const { ref } = getElement(); - // If focus is outside search form, and in the document, close menu - // event.target === The element losing focus - // event.relatedTarget === The element receiving focus (if any) - // When focusout is outside the document, - // `window.document.activeElement` doesn't change. - if ( - ! ref.contains( event.relatedTarget ) && - event.target !== window.document.activeElement - ) { - actions.closeSearchInput(); - } + actions: { + openSearchInput( event ) { + const ctx = getContext(); + const { ref } = getElement(); + if ( ! ctx.isSearchInputVisible ) { + event.preventDefault(); + ctx.isSearchInputVisible = true; + ref.parentElement.querySelector( 'input' ).focus(); + } + }, + closeSearchInput() { + const ctx = getContext(); + ctx.isSearchInputVisible = false; + }, + handleSearchKeydown( event ) { + const { ref } = getElement(); + // If Escape close the menu. + if ( event?.key === 'Escape' ) { + actions.closeSearchInput(); + ref.querySelector( 'button' ).focus(); + } + }, + handleSearchFocusout( event ) { + const { ref } = getElement(); + // If focus is outside search form, and in the document, close menu + // event.target === The element losing focus + // event.relatedTarget === The element receiving focus (if any) + // When focusout is outside the document, + // `window.document.activeElement` doesn't change. + if ( + ! ref.contains( event.relatedTarget ) && + event.target !== window.document.activeElement + ) { + actions.closeSearchInput(); + } + }, }, }, -} ); + { lock: true } +); diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md index 5b9aaeea25a2c1..49adb06a0a6db3 100644 --- a/packages/components/CHANGELOG.md +++ b/packages/components/CHANGELOG.md @@ -2,6 +2,12 @@ ## Unreleased +### Breaking Changes + +- `FontSizePicker`: Remove deprecated `__nextHasNoMarginBottom` prop and promote to default behavior ([#58702](https://github.com/WordPress/gutenberg/pull/58702)). +- `GradientPicker`: Remove deprecated `__nextHasNoMargin` prop and promote to default behavior ([#58701](https://github.com/WordPress/gutenberg/pull/58701)). +- `CustomGradientPicker`: Remove deprecated `__nextHasNoMargin` prop and promote to default behavior ([#58699](https://github.com/WordPress/gutenberg/pull/58699)). +- `AnglePickerControl`: Remove deprecated `__nextHasNoMarginBottom` prop and promote to default behavior ([#58700](https://github.com/WordPress/gutenberg/pull/58700)). ### Enhancements @@ -9,9 +15,11 @@ - `ConfirmDialog`: Add `__next40pxDefaultSize` to buttons ([#58421](https://github.com/WordPress/gutenberg/pull/58421)). - `Snackbar`: Update the warning message ([#58591](https://github.com/WordPress/gutenberg/pull/58591)). - `Composite`: Implementing `useCompositeState` with Ariakit ([#57304](https://github.com/WordPress/gutenberg/pull/57304)) +- `CheckboxControl`: Remove ability for label prop to be false ([#58339](https://github.com/WordPress/gutenberg/pull/58339)). ### Bug Fix +- `FocalPointPicker`: Allow `PointerCircle` to render in a default centered position when x and y coordinates are undefined ([#58592](https://github.com/WordPress/gutenberg/pull/58592)). - `DateTime`: Add a timezone offset value for display purposes. ([#56682](https://github.com/WordPress/gutenberg/pull/56682)). - `Placeholder`: Fix Placeholder component padding when body text font size is changed ([#58323](https://github.com/WordPress/gutenberg/pull/58323)). - `Placeholder`: Fix Global Styles typography settings bleeding into placeholder component ([#58303](https://github.com/WordPress/gutenberg/pull/58303)). @@ -27,10 +35,6 @@ - Expand theming support in the `COLORS` variable object ([#58097](https://github.com/WordPress/gutenberg/pull/58097)). - `CustomSelect`: disable `virtualFocus` to fix issue for screenreaders ([#58585](https://github.com/WordPress/gutenberg/pull/58585)). -### Enhancements - -- `CheckboxControl`: Remove ability for label prop to be false ([#58339](https://github.com/WordPress/gutenberg/pull/58339)). - ### Internal - `Composite`: Removing Reakit `Composite` implementation ([#58620](https://github.com/WordPress/gutenberg/pull/58620)). diff --git a/packages/components/src/angle-picker-control/README.md b/packages/components/src/angle-picker-control/README.md index 3cbc1f6c8d9e1a..deed41089fdc1d 100644 --- a/packages/components/src/angle-picker-control/README.md +++ b/packages/components/src/angle-picker-control/README.md @@ -15,7 +15,6 @@ function Example() { ); }; @@ -43,10 +42,3 @@ The current value of the input. The value represents an angle in degrees and sho A function that receives the new value of the input. - Required: Yes - -### `__nextHasNoMarginBottom`: `boolean` - -Start opting into the new margin-free styles that will become the default in a future version, currently scheduled to be WordPress 6.4. (The prop can be safely removed once this happens.) - -- Required: No -- Default: `false` diff --git a/packages/components/src/angle-picker-control/index.tsx b/packages/components/src/angle-picker-control/index.tsx index 06178e0b401015..dcd0fe6f94a2e0 100644 --- a/packages/components/src/angle-picker-control/index.tsx +++ b/packages/components/src/angle-picker-control/index.tsx @@ -7,18 +7,17 @@ import classnames from 'classnames'; /** * WordPress dependencies */ -import deprecated from '@wordpress/deprecated'; import { forwardRef } from '@wordpress/element'; import { isRTL, __ } from '@wordpress/i18n'; /** * Internal dependencies */ -import { FlexBlock } from '../flex'; +import { Flex, FlexBlock } from '../flex'; import { Spacer } from '../spacer'; import NumberControl from '../number-control'; import AngleCircle from './angle-circle'; -import { Root, UnitText } from './styles/angle-picker-control-styles'; +import { UnitText } from './styles/angle-picker-control-styles'; import type { WordPressComponentProps } from '../context'; import type { AnglePickerControlProps } from './types'; @@ -28,7 +27,6 @@ function UnforwardedAnglePickerControl( ref: ForwardedRef< any > ) { const { - __nextHasNoMarginBottom = false, className, label = __( 'Angle' ), onChange, @@ -36,16 +34,6 @@ function UnforwardedAnglePickerControl( ...restProps } = props; - if ( ! __nextHasNoMarginBottom ) { - deprecated( - 'Bottom margin styles for wp.components.AnglePickerControl', - { - since: '6.1', - hint: 'Set the `__nextHasNoMarginBottom` prop to true to start opting into the new styles, which will become the default in a future version.', - } - ); - } - const handleOnNumberChange = ( unprocessedValue: string | undefined ) => { if ( onChange === undefined ) { return; @@ -66,13 +54,7 @@ function UnforwardedAnglePickerControl( : [ null, unitText ]; return ( - + - + ); } @@ -115,7 +97,6 @@ function UnforwardedAnglePickerControl( * * ); * } diff --git a/packages/components/src/angle-picker-control/stories/index.story.tsx b/packages/components/src/angle-picker-control/stories/index.story.tsx index d10403a436bfc2..ebbf3425d802f1 100644 --- a/packages/components/src/angle-picker-control/stories/index.story.tsx +++ b/packages/components/src/angle-picker-control/stories/index.story.tsx @@ -52,6 +52,3 @@ const AnglePickerWithState: StoryFn< typeof AnglePickerControl > = ( { }; export const Default = AnglePickerWithState.bind( {} ); -Default.args = { - __nextHasNoMarginBottom: true, -}; diff --git a/packages/components/src/angle-picker-control/styles/angle-picker-control-styles.tsx b/packages/components/src/angle-picker-control/styles/angle-picker-control-styles.tsx index 08a907b21e0c99..0141bd860d7df9 100644 --- a/packages/components/src/angle-picker-control/styles/angle-picker-control-styles.tsx +++ b/packages/components/src/angle-picker-control/styles/angle-picker-control-styles.tsx @@ -1,37 +1,19 @@ /** * External dependencies */ -import { css } from '@emotion/react'; import styled from '@emotion/styled'; /** * Internal dependencies */ -import { Flex } from '../../flex'; import { COLORS } from '../../utils'; import { space } from '../../utils/space'; import { Text } from '../../text'; import CONFIG from '../../utils/config-values'; -import type { AnglePickerControlProps } from '../types'; - const CIRCLE_SIZE = 32; const INNER_CIRCLE_SIZE = 6; -const deprecatedBottomMargin = ( { - __nextHasNoMarginBottom, -}: Pick< AnglePickerControlProps, '__nextHasNoMarginBottom' > ) => { - return ! __nextHasNoMarginBottom - ? css` - margin-bottom: ${ space( 2 ) }; - ` - : ''; -}; - -export const Root = styled( Flex )` - ${ deprecatedBottomMargin } -`; - export const CircleRoot = styled.div` border-radius: 50%; border: ${ CONFIG.borderWidth } solid ${ COLORS.ui.border }; diff --git a/packages/components/src/angle-picker-control/types.ts b/packages/components/src/angle-picker-control/types.ts index 1ddd26f679db6d..d453be1d6023bd 100644 --- a/packages/components/src/angle-picker-control/types.ts +++ b/packages/components/src/angle-picker-control/types.ts @@ -4,6 +4,8 @@ export type AnglePickerControlProps = { * in a future version. * * @default false + * @deprecated Default behavior since WP 6.5. Prop can be safely removed. + * @ignore */ __nextHasNoMarginBottom?: boolean; /** diff --git a/packages/components/src/custom-gradient-picker/index.tsx b/packages/components/src/custom-gradient-picker/index.tsx index 19cfd8f740df7d..66add261ce5e67 100644 --- a/packages/components/src/custom-gradient-picker/index.tsx +++ b/packages/components/src/custom-gradient-picker/index.tsx @@ -1,13 +1,11 @@ /** * External dependencies */ -import classnames from 'classnames'; import type gradientParser from 'gradient-parser'; /** * WordPress dependencies */ -import deprecated from '@wordpress/deprecated'; import { __ } from '@wordpress/i18n'; /** @@ -60,7 +58,6 @@ const GradientAnglePicker = ( { }; return ( @@ -141,8 +138,6 @@ const GradientTypePicker = ( { * ``` */ export function CustomGradientPicker( { - /** Start opting into the new margin-free styles that will become the default in a future version. */ - __nextHasNoMargin = false, value, onChange, __experimentalIsRenderedInSidebar = false, @@ -166,24 +161,8 @@ export function CustomGradientPicker( { }; } ); - if ( ! __nextHasNoMargin ) { - deprecated( - 'Outer margin styles for wp.components.CustomGradientPicker', - { - since: '6.1', - version: '6.4', - hint: 'Set the `__nextHasNoMargin` prop to true to start opting into the new styles, which will become the default in a future version', - } - ); - } - return ( - + { return ( { - const { - fontSizes, - value, - __nextHasNoMarginBottom, - __next40pxDefaultSize, - size, - onChange, - } = props; + const { fontSizes, value, __next40pxDefaultSize, size, onChange } = props; return ( ) => { const { - /** Start opting into the new margin-free styles that will become the default in a future version. */ - __nextHasNoMarginBottom = false, __next40pxDefaultSize = false, fallbackFontSize, fontSizes = [], @@ -57,14 +53,6 @@ const UnforwardedFontSizePicker = ( withReset = true, } = props; - if ( ! __nextHasNoMarginBottom ) { - deprecated( 'Bottom margin styles for wp.components.FontSizePicker', { - since: '6.1', - version: '6.4', - hint: 'Set the `__nextHasNoMarginBottom` prop to true to start opting into the new styles, which will become the default in a future version.', - } ); - } - const units = useCustomUnits( { availableUnits: unitsProp || [ 'px', 'em', 'rem' ], } ); @@ -159,10 +147,7 @@ const UnforwardedFontSizePicker = ( ) } - +
{ !! fontSizes.length && shouldUseSelectControl && ! showCustomValueControl && ( @@ -196,7 +181,6 @@ const UnforwardedFontSizePicker = ( { @@ -243,9 +227,7 @@ const UnforwardedFontSizePicker = ( ) } - +
); }; diff --git a/packages/components/src/font-size-picker/stories/index.story.tsx b/packages/components/src/font-size-picker/stories/index.story.tsx index 69043278389c75..6ea47742ba783f 100644 --- a/packages/components/src/font-size-picker/stories/index.story.tsx +++ b/packages/components/src/font-size-picker/stories/index.story.tsx @@ -66,7 +66,6 @@ const TwoFontSizePickersWithState: StoryFn< typeof FontSizePicker > = ( { export const Default: StoryFn< typeof FontSizePicker > = FontSizePickerWithState.bind( {} ); Default.args = { - __nextHasNoMarginBottom: true, disableCustomFontSizes: false, fontSizes: [ { diff --git a/packages/components/src/font-size-picker/styles.ts b/packages/components/src/font-size-picker/styles.ts index ef8ec8ebb30cb2..f47ca41b51eb71 100644 --- a/packages/components/src/font-size-picker/styles.ts +++ b/packages/components/src/font-size-picker/styles.ts @@ -36,10 +36,3 @@ export const HeaderLabel = styled( BaseControl.VisualLabel )` export const HeaderHint = styled.span` color: ${ COLORS.gray[ 700 ] }; `; - -export const Controls = styled.div< { - __nextHasNoMarginBottom: boolean; -} >` - ${ ( props ) => - ! props.__nextHasNoMarginBottom && `margin-bottom: ${ space( 6 ) };` } -`; diff --git a/packages/components/src/font-size-picker/test/index.tsx b/packages/components/src/font-size-picker/test/index.tsx index 0d7816399b3652..9bb3b2d8677b69 100644 --- a/packages/components/src/font-size-picker/test/index.tsx +++ b/packages/components/src/font-size-picker/test/index.tsx @@ -21,13 +21,7 @@ describe( 'FontSizePicker', () => { async ( { value, expectedValue } ) => { const user = userEvent.setup(); const onChange = jest.fn(); - render( - - ); + render( ); const input = screen.getByLabelText( 'Custom' ); await user.clear( input ); await user.type( input, '80' ); @@ -48,7 +42,6 @@ describe( 'FontSizePicker', () => { const onChange = jest.fn(); render( @@ -99,12 +92,7 @@ describe( 'FontSizePicker', () => { it( 'displays a select control', async () => { const user = userEvent.setup(); - render( - - ); + render( ); await user.click( screen.getByRole( 'button', { name: 'Font size' } ) ); @@ -128,11 +116,7 @@ describe( 'FontSizePicker', () => { 'displays $expectedLabel as label when value is $value', ( { value, expectedLabel } ) => { render( - + ); expect( screen.getByLabelText( expectedLabel ) @@ -158,7 +142,6 @@ describe( 'FontSizePicker', () => { const onChange = jest.fn(); render( { it( 'displays a select control', async () => { const user = userEvent.setup(); - render( - - ); + render( ); await user.click( screen.getByRole( 'button', { name: 'Font size' } ) ); @@ -244,11 +222,7 @@ describe( 'FontSizePicker', () => { 'defaults to $option when value is $value', ( { value, option } ) => { render( - + ); expect( screen.getByRole( 'button', { name: 'Font size' } ) @@ -267,11 +241,7 @@ describe( 'FontSizePicker', () => { 'displays $expectedLabel as label when value is $value', ( { value, expectedLabel } ) => { render( - + ); expect( screen.getByLabelText( expectedLabel ) @@ -315,7 +285,6 @@ describe( 'FontSizePicker', () => { const onChange = jest.fn(); render( { ]; it( 'displays a toggle group control with t-shirt sizes', () => { - render( - - ); + render( ); const options = screen.getAllByRole( 'radio' ); expect( options ).toHaveLength( 5 ); expect( options[ 0 ] ).toHaveTextContent( 'S' ); @@ -394,11 +358,7 @@ describe( 'FontSizePicker', () => { 'displays $expectedLabel as label when value is $value', ( { value, expectedLabel } ) => { render( - + ); expect( screen.getByLabelText( expectedLabel ) @@ -410,11 +370,7 @@ describe( 'FontSizePicker', () => { const user = userEvent.setup(); const onChange = jest.fn(); render( - + ); await user.click( screen.getByRole( 'radio', { name: 'Medium' } ) ); expect( onChange ).toHaveBeenCalledTimes( 1 ); @@ -450,12 +406,7 @@ describe( 'FontSizePicker', () => { ]; it( 'displays a toggle group control with t-shirt sizes', () => { - render( - - ); + render( ); const options = screen.getAllByRole( 'radio' ); expect( options ).toHaveLength( 4 ); expect( options[ 0 ] ).toHaveTextContent( 'S' ); @@ -481,11 +432,7 @@ describe( 'FontSizePicker', () => { 'displays $expectedLabel as label when value is $value', ( { value, expectedLabel } ) => { render( - + ); expect( screen.getByLabelText( expectedLabel ) @@ -511,7 +458,6 @@ describe( 'FontSizePicker', () => { const onChange = jest.fn(); render( @@ -532,7 +478,6 @@ describe( 'FontSizePicker', () => { it( 'defaults to M when value is 16px', () => { render( @@ -546,11 +491,7 @@ describe( 'FontSizePicker', () => { 'has no selection when value is %p', ( value ) => { render( - + ); expect( screen.getByRole( 'radiogroup' ) ).toBeInTheDocument(); expect( @@ -565,11 +506,7 @@ describe( 'FontSizePicker', () => { const user = userEvent.setup(); const onChange = jest.fn(); render( - + ); await user.click( screen.getByRole( 'button', { name: 'Font size' } ) @@ -584,13 +521,7 @@ describe( 'FontSizePicker', () => { function commonTests( fontSizes: FontSize[] ) { it( 'shows custom input when value is unknown', () => { - render( - - ); + render( ); expect( screen.getByLabelText( 'Custom' ) ).toBeInTheDocument(); } ); @@ -598,11 +529,7 @@ describe( 'FontSizePicker', () => { const user = userEvent.setup(); const onChange = jest.fn(); render( - + ); await user.click( screen.getByRole( 'button', { name: 'Set custom size' } ) @@ -615,7 +542,6 @@ describe( 'FontSizePicker', () => { it( 'does not allow custom values when disableCustomFontSizes is set', () => { render( @@ -627,12 +553,7 @@ describe( 'FontSizePicker', () => { it( 'does not display a slider by default', async () => { const user = userEvent.setup(); - render( - - ); + render( ); await user.click( screen.getByRole( 'button', { name: 'Set custom size' } ) ); @@ -646,7 +567,6 @@ describe( 'FontSizePicker', () => { const onChange = jest.fn(); render( { const onChange = jest.fn(); render( { const user = userEvent.setup(); render( { render( & { fontSizes: NonNullable< FontSizePickerProps[ 'fontSizes' ] >; onChange: NonNullable< FontSizePickerProps[ 'onChange' ] >; diff --git a/packages/components/src/gradient-picker/README.md b/packages/components/src/gradient-picker/README.md index c092c8b5673cc9..815b3d8eb5dd75 100644 --- a/packages/components/src/gradient-picker/README.md +++ b/packages/components/src/gradient-picker/README.md @@ -17,7 +17,6 @@ const myGradientPicker = () => { return ( setGradient( currentGradient ) } gradients={ [ @@ -89,13 +88,6 @@ If true, the gradient picker will not be displayed and only defined gradients fr - Required: No - Default: false -### `__nextHasNoMargin`: `boolean` - -Start opting into the new margin-free styles that will become the default in a future version, currently scheduled to be WordPress 6.4. (The prop can be safely removed once this happens.) - -- Required: No -- Default: `false` - ### `headingLevel`: `1 | 2 | 3 | 4 | 5 | 6 | '1' | '2' | '3' | '4' | '5' | '6'` The heading level. Only applies in cases where gradients are provided from multiple origins (ie. when the array passed as the `gradients` prop contains two or more items). diff --git a/packages/components/src/gradient-picker/index.tsx b/packages/components/src/gradient-picker/index.tsx index 52e7e716642da9..8368279b8afd70 100644 --- a/packages/components/src/gradient-picker/index.tsx +++ b/packages/components/src/gradient-picker/index.tsx @@ -4,7 +4,6 @@ import { __, sprintf } from '@wordpress/i18n'; import { useInstanceId } from '@wordpress/compose'; import { useCallback, useMemo } from '@wordpress/element'; -import deprecated from '@wordpress/deprecated'; /** * Internal dependencies @@ -13,7 +12,6 @@ import CircularOptionPicker from '../circular-option-picker'; import CustomGradientPicker from '../custom-gradient-picker'; import { VStack } from '../v-stack'; import { ColorHeading } from '../color-palette/styles'; -import { Spacer } from '../spacer'; import type { GradientPickerComponentProps, PickerProps, @@ -181,7 +179,6 @@ function Component( props: PickerProps< any > ) { * * return ( * setGradient( currentGradient ) } * gradients={ [ @@ -211,8 +208,6 @@ function Component( props: PickerProps< any > ) { * */ export function GradientPicker( { - /** Start opting into the new margin-free styles that will become the default in a future version. */ - __nextHasNoMargin = false, className, gradients = [], onChange, @@ -228,58 +223,39 @@ export function GradientPicker( { [ onChange ] ); - if ( ! __nextHasNoMargin ) { - deprecated( 'Outer margin styles for wp.components.GradientPicker', { - since: '6.1', - version: '6.4', - hint: 'Set the `__nextHasNoMargin` prop to true to start opting into the new styles, which will become the default in a future version', - } ); - } - - const deprecatedMarginSpacerProps = ! __nextHasNoMargin - ? { - marginTop: ! gradients.length ? 3 : undefined, - marginBottom: ! clearable ? 6 : 0, - } - : {}; - return ( - // Outmost Spacer wrapper can be removed when deprecation period is over - - - { ! disableCustomGradients && ( - - ) } - { ( gradients.length > 0 || clearable ) && ( - - { __( 'Clear' ) } - - ) - } - headingLevel={ headingLevel } - /> - ) } - - + + { ! disableCustomGradients && ( + + ) } + { ( gradients.length > 0 || clearable ) && ( + + { __( 'Clear' ) } + + ) + } + headingLevel={ headingLevel } + /> + ) } + ); } diff --git a/packages/components/src/gradient-picker/stories/index.story.tsx b/packages/components/src/gradient-picker/stories/index.story.tsx index 039a2f0da1729c..b5f3a9dbca15a5 100644 --- a/packages/components/src/gradient-picker/stories/index.story.tsx +++ b/packages/components/src/gradient-picker/stories/index.story.tsx @@ -85,7 +85,6 @@ const Template: StoryFn< typeof GradientPicker > = ( { export const Default = Template.bind( {} ); Default.args = { - __nextHasNoMargin: true, gradients: GRADIENTS, }; diff --git a/packages/components/src/gradient-picker/types.ts b/packages/components/src/gradient-picker/types.ts index 8deb9513cc0dbe..b563653e33e4c4 100644 --- a/packages/components/src/gradient-picker/types.ts +++ b/packages/components/src/gradient-picker/types.ts @@ -93,6 +93,8 @@ export type GradientPickerComponentProps = GradientPickerBaseProps & { * can be safely removed once this happens.) * * @default false + * @deprecated Default behavior since WP 6.5. Prop can be safely removed. + * @ignore */ __nextHasNoMargin?: boolean; /** diff --git a/packages/components/src/palette-edit/index.tsx b/packages/components/src/palette-edit/index.tsx index 694b7ad9511908..a5008077525177 100644 --- a/packages/components/src/palette-edit/index.tsx +++ b/packages/components/src/palette-edit/index.tsx @@ -152,7 +152,6 @@ function ColorPickerPopover< T extends Color | Gradient >( { { isGradient && (
{ @@ -590,7 +589,6 @@ export function PaletteEdit( { { ! isEditing && ( isGradient ? ( ) { + const [ value, setValue ] = useState( '' ); + + return ( + { + setValue( ...args ); + onChange( ...args ); + } } + /> + ); +} + +describe( 'SearchControl', () => { + describe.each( [ + // TODO: Uncontrolled mode is not supported yet. + // [ 'Uncontrolled', SearchControl ], + [ 'Controlled mode', ControlledSearchControl ], + ] )( '%s', ( ...modeAndComponent ) => { + const [ , Component ] = modeAndComponent; + + it( 'should call onChange with input value when value is changed', async () => { + const onChangeSpy = jest.fn(); + render( ); + + const searchInput = screen.getByRole( 'searchbox' ); + await type( 'test', searchInput ); + expect( searchInput ).toHaveValue( 'test' ); + expect( onChangeSpy ).toHaveBeenLastCalledWith( 'test' ); + } ); + + it( 'should render a Reset search button if no onClose function is provided', async () => { + const onChangeSpy = jest.fn(); + render( ); + + const searchInput = screen.getByRole( 'searchbox' ); + + expect( + screen.queryByRole( 'button', { name: 'Reset search' } ) + ).not.toBeInTheDocument(); + + await type( 'test', searchInput ); + const resetButton = screen.getByRole( 'button', { + name: 'Reset search', + } ); + expect( resetButton ).toBeVisible(); + + await click( resetButton ); + expect( searchInput ).toHaveValue( '' ); + expect( onChangeSpy ).toHaveBeenLastCalledWith( '' ); + } ); + + it( 'should should render a Close button (instead of Reset) when onClose function is provided', async () => { + const onChangeSpy = jest.fn(); + const onCloseSpy = jest.fn(); + render( + + ); + + expect( + screen.queryByRole( 'button', { name: 'Close search' } ) + ).toBeVisible(); + expect( + screen.queryByRole( 'button', { name: 'Reset search' } ) + ).not.toBeInTheDocument(); + + const searchInput = screen.getByRole( 'searchbox' ); + await type( 'test', searchInput ); + + expect( + screen.queryByRole( 'button', { name: 'Close search' } ) + ).toBeVisible(); + expect( + screen.queryByRole( 'button', { name: 'Reset search' } ) + ).not.toBeInTheDocument(); + expect( onChangeSpy ).toHaveBeenCalledTimes( 'test'.length ); + + await click( + screen.getByRole( 'button', { name: 'Close search' } ) + ); + expect( onCloseSpy ).toHaveBeenCalledTimes( 1 ); + expect( searchInput ).toHaveValue( 'test' ); // no change + expect( onChangeSpy ).toHaveBeenCalledTimes( 'test'.length ); // no change + } ); + } ); +} ); diff --git a/packages/dataviews/src/dataviews.js b/packages/dataviews/src/dataviews.js index 272676e6491550..cff15ec304c23c 100644 --- a/packages/dataviews/src/dataviews.js +++ b/packages/dataviews/src/dataviews.js @@ -42,25 +42,27 @@ export default function DataViews( { if ( selection.length > 0 && selection.some( - ( id ) => ! data.some( ( item ) => item.id === id ) + ( id ) => ! data.some( ( item ) => getItemId( item ) === id ) ) ) { const newSelection = selection.filter( ( id ) => - data.some( ( item ) => item.id === id ) + data.some( ( item ) => getItemId( item ) === id ) ); setSelection( newSelection ); onSelectionChange( - data.filter( ( item ) => newSelection.includes( item.id ) ) + data.filter( ( item ) => + newSelection.includes( getItemId( item ) ) + ) ); } - }, [ selection, data, onSelectionChange ] ); + }, [ selection, data, getItemId, onSelectionChange ] ); const onSetSelection = useCallback( ( items ) => { - setSelection( items.map( ( item ) => item.id ) ); + setSelection( items.map( ( item ) => getItemId( item ) ) ); onSelectionChange( items ); }, - [ setSelection, onSelectionChange ] + [ setSelection, getItemId, onSelectionChange ] ); const ViewComponent = VIEW_LAYOUTS.find( diff --git a/packages/e2e-tests/specs/editor/various/__snapshots__/inserting-blocks.test.js.snap b/packages/e2e-tests/specs/editor/various/__snapshots__/inserting-blocks.test.js.snap deleted file mode 100644 index fa3400670a602d..00000000000000 --- a/packages/e2e-tests/specs/editor/various/__snapshots__/inserting-blocks.test.js.snap +++ /dev/null @@ -1,118 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Inserting blocks Should insert content using the placeholder and the regular inserter 1`] = ` -" -

Paragraph block

- - - -

Quote block

- - - -
\\"\\"/
- - - -

-" -`; - -exports[`Inserting blocks Should insert content using the placeholder and the regular inserter 2`] = ` -" -

Paragraph block

- - - -

Quote block

-" -`; - -exports[`Inserting blocks Should insert content using the placeholder and the regular inserter 3`] = ` -" -

Paragraph block

- - - -

Second paragraph

- - - -

Quote block

- - - -
Pre text
-
-Foo
- - - -[myshortcode]With multiple -lines preserved[/myshortcode] -" -`; - -exports[`Inserting blocks inserts a block in proper place after having clicked \`Browse All\` from block appender 1`] = ` -" -
-

Paragraph inside group

-
- - - -

Paragraph after group

-" -`; - -exports[`Inserting blocks inserts a block in proper place after having clicked \`Browse All\` from inline inserter 1`] = ` -" -

First paragraph

- - - -

Heading

- - - -

Second paragraph

- - - -

Third paragraph

-" -`; - -exports[`Inserting blocks inserts a block in proper place after having clicked \`Browse All\` from inline inserter 2`] = ` -" -

First paragraph

- - - -
- - - -

Heading

- - - -

Second paragraph

- - - -

Third paragraph

-" -`; - -exports[`Inserting blocks inserts blocks at root level when using the root appender while selection is in an inner block 1`] = ` -" -
- -
- - - -

2

-" -`; diff --git a/packages/e2e-tests/specs/editor/various/inserting-blocks.test.js b/packages/e2e-tests/specs/editor/various/inserting-blocks.test.js deleted file mode 100644 index 13e19a154e0f2b..00000000000000 --- a/packages/e2e-tests/specs/editor/various/inserting-blocks.test.js +++ /dev/null @@ -1,388 +0,0 @@ -/** - * WordPress dependencies - */ -import { - closeGlobalBlockInserter, - createNewPost, - getEditedPostContent, - insertBlock, - openGlobalBlockInserter, - pressKeyTimes, - searchForBlock, - setBrowserViewport, - pressKeyWithModifier, - canvas, -} from '@wordpress/e2e-test-utils'; - -/** @typedef {import('puppeteer-core').ElementHandle} ElementHandle */ - -/** - * Waits for all patterns in the inserter to have a height, which should - * indicate they've been parsed and are visible. - * - * This allows a test to wait for the layout in the inserter menu to stabilize - * before attempting to interact with the menu contents. - */ -async function waitForInserterPatternLoad() { - await page.waitForFunction( () => { - const previewElements = document.querySelectorAll( - '.block-editor-block-preview__container' - ); - - if ( ! previewElements.length ) { - return true; - } - - return Array.from( previewElements ).every( - ( previewElement ) => previewElement.offsetHeight > 0 - ); - } ); -} - -describe( 'Inserting blocks', () => { - beforeEach( async () => { - await createNewPost(); - } ); - - /** - * Given a Puppeteer ElementHandle, clicks below its bounding box. - * - * @param {ElementHandle} elementHandle Element handle. - * - * @return {Promise} Promise resolving when click occurs. - */ - async function clickAtBottom( elementHandle ) { - const box = await elementHandle.boundingBox(); - const x = box.x + box.width / 2; - const y = box.y + box.height - 50; - return page.mouse.click( x, y ); - } - - it.skip( 'Should insert content using the placeholder and the regular inserter', async () => { - // This ensures the editor is loaded in navigation mode. - await page.reload(); - await page.waitForSelector( '.edit-post-layout' ); - - // Set a tall viewport. The typewriter's intrinsic height can be enough - // to scroll the page on a shorter viewport, thus obscuring the presence - // of any potential buggy behavior with the "stretched" click redirect. - await setBrowserViewport( { width: 960, height: 1400 } ); - - // Click below editor to focus last field (block appender) - await clickAtBottom( - await page.$( '.interface-interface-skeleton__content' ) - ); - expect( - await page.waitForSelector( '[data-type="core/paragraph"]' ) - ).not.toBeNull(); - await page.keyboard.type( 'Paragraph block' ); - - // Using the slash command. - await page.keyboard.press( 'Enter' ); - await page.keyboard.type( '/quote' ); - await page.waitForXPath( - `//*[contains(@class, "components-autocomplete__result") and contains(@class, "is-selected") and contains(text(), 'Quote')]` - ); - await page.keyboard.press( 'Enter' ); - await page.keyboard.type( 'Quote block' ); - - // Arrow down into default appender. - await page.keyboard.press( 'ArrowDown' ); - await page.keyboard.press( 'ArrowDown' ); - - // Focus should be moved to block focus boundary on a block which does - // not have its own inputs (e.g. image). Proceeding to press enter will - // append the default block. Pressing backspace on the focused block - // will remove it. - await page.keyboard.type( '/image' ); - await page.waitForXPath( - `//*[contains(@class, "components-autocomplete__result") and contains(@class, "is-selected") and contains(text(), 'Image')]` - ); - await page.keyboard.press( 'Enter' ); - await page.keyboard.press( 'Enter' ); - expect( await getEditedPostContent() ).toMatchSnapshot(); - await page.keyboard.press( 'Backspace' ); - await page.keyboard.press( 'Backspace' ); - expect( await getEditedPostContent() ).toMatchSnapshot(); - - // Using the regular inserter. - await insertBlock( 'Preformatted' ); - await page.keyboard.type( 'Pre block' ); - await page.keyboard.press( 'Enter' ); - await page.keyboard.press( 'Enter' ); - - // Verify vertical traversal at offset. This has been buggy in the past - // where verticality on a blank newline would skip into previous block. - await page.keyboard.type( 'Foo' ); - await page.keyboard.press( 'ArrowUp' ); - await page.keyboard.press( 'ArrowUp' ); - await pressKeyTimes( 'Delete', 6 ); - await page.keyboard.type( ' text' ); - - // Ensure newline preservation in shortcode block. - // See: https://github.com/WordPress/gutenberg/issues/4456 - await insertBlock( 'Shortcode' ); - await page.keyboard.type( '[myshortcode]With multiple' ); - await page.keyboard.press( 'Enter' ); - await page.keyboard.type( 'lines preserved[/myshortcode]' ); - - // Unselect blocks to avoid conflicts with the inbetween inserter - await page.click( '.editor-post-title__input' ); - await closeGlobalBlockInserter(); - - // Using the between inserter. - const insertionPoint = await page.$( '[data-type="core/quote"]' ); - const rect = await insertionPoint.boundingBox(); - await page.mouse.move( rect.x + rect.width / 2, rect.y - 10, { - steps: 10, - } ); - const lineInserter = await page.waitForSelector( - '.block-editor-block-list__insertion-point .block-editor-inserter__toggle' - ); - await lineInserter.click(); - // [TODO]: Search input should be focused immediately. It shouldn't be - // necessary to have `waitForFunction`. - await page.waitForFunction( - () => - document.activeElement && - document.activeElement.classList.contains( - 'components-search-control__input' - ) - ); - await page.keyboard.type( 'para' ); - await pressKeyTimes( 'Tab', 2 ); - await page.keyboard.press( 'Enter' ); - await page.keyboard.type( 'Second paragraph' ); - - expect( await getEditedPostContent() ).toMatchSnapshot(); - } ); - - it( 'should insert block with the slash inserter when using multiple words', async () => { - await page.keyboard.press( 'Enter' ); - await page.keyboard.type( '/tag cloud' ); - await page.waitForXPath( - `//*[contains(@class, "components-autocomplete__result") and contains(@class, "is-selected") and contains(text(), 'Tag Cloud')]` - ); - await page.keyboard.press( 'Enter' ); - - expect( - await canvas().waitForSelector( '[data-type="core/tag-cloud"]' ) - ).not.toBeNull(); - } ); - - // Check for regression of https://github.com/WordPress/gutenberg/issues/23263 - it( 'inserts blocks at root level when using the root appender while selection is in an inner block', async () => { - await insertBlock( 'Buttons' ); - await page.keyboard.type( '1.1' ); - - // After inserting the Buttons block the inner button block should be selected. - const selectedButtonBlocks = await canvas().$$( - '.wp-block-button.is-selected' - ); - expect( selectedButtonBlocks.length ).toBe( 1 ); - - // The block appender is only visible when there's no selection. - await page.evaluate( () => - window.wp.data.dispatch( 'core/block-editor' ).clearSelectedBlock() - ); - // Specifically click the root container appender. - await canvas().click( - '.block-editor-block-list__layout.is-root-container > .block-list-appender .block-editor-inserter__toggle' - ); - - // Insert a paragraph block. - await page.waitForSelector( '.block-editor-inserter__search input' ); - - // Search for the paragraph block if it's not in the list of blocks shown. - if ( ! page.$( '.editor-block-list-item-paragraph' ) ) { - await page.keyboard.type( 'Paragraph' ); - await page.waitForSelector( '.editor-block-list-item-paragraph' ); - await waitForInserterPatternLoad(); - } - - // Add the block. - await page.click( '.editor-block-list-item-paragraph' ); - await page.keyboard.type( '2' ); - - // The snapshot should show a buttons block followed by a paragraph. - // The buttons block should contain a single button. - expect( await getEditedPostContent() ).toMatchSnapshot(); - } ); - - // Check for regression of https://github.com/WordPress/gutenberg/issues/24262 - it( 'inserts a block in proper place after having clicked `Browse All` from inline inserter', async () => { - await insertBlock( 'Paragraph' ); - await page.keyboard.type( 'First paragraph' ); - await insertBlock( 'Heading' ); - await page.keyboard.type( 'Heading' ); - await page.keyboard.press( 'Enter' ); - await insertBlock( 'Paragraph' ); - await page.keyboard.type( 'Second paragraph' ); - await page.keyboard.press( 'Enter' ); - await page.keyboard.type( 'Third paragraph' ); - expect( await getEditedPostContent() ).toMatchSnapshot(); - - // Using the between inserter. - const insertionPoint = await canvas().$( '[data-type="core/heading"]' ); - const rect = await insertionPoint.boundingBox(); - await page.mouse.move( rect.x + rect.width / 2, rect.y - 10, { - steps: 10, - } ); - const insertionLine = await page.waitForSelector( - '.block-editor-block-list__insertion-point .block-editor-inserter__toggle' - ); - await insertionLine.click(); - const browseAll = await page.waitForSelector( - 'button.block-editor-inserter__quick-inserter-expand' - ); - await browseAll.click(); - // `insertBlock` uses the currently open panel. - await insertBlock( 'Cover' ); - - expect( await getEditedPostContent() ).toMatchSnapshot(); - } ); - - // Check for regression of https://github.com/WordPress/gutenberg/issues/25785 - it( 'inserts a block should show a blue line indicator', async () => { - // First insert a random Paragraph. - await insertBlock( 'Paragraph' ); - await page.keyboard.type( 'First paragraph' ); - await insertBlock( 'Image' ); - const paragraphBlock = await canvas().$( - 'p[aria-label="Block: Paragraph"]' - ); - paragraphBlock.click(); - await page.evaluate( () => new Promise( window.requestIdleCallback ) ); - - // Open the global inserter and search for the Heading block. - await searchForBlock( 'Heading' ); - - const headingButton = ( - await page.$x( `//button//span[contains(text(), 'Heading')]` ) - )[ 0 ]; - // Hover over the block should show the blue line indicator. - await headingButton.hover(); - - // Should show the blue line indicator somewhere. - const indicator = await page.waitForSelector( - '.block-editor-block-list__insertion-point-indicator' - ); - const indicatorRect = await indicator.boundingBox(); - const paragraphRect = await paragraphBlock.boundingBox(); - - // The blue line indicator should be below the last block. - expect( indicatorRect.x ).toBe( paragraphRect.x ); - expect( indicatorRect.y > paragraphRect.y ).toBe( true ); - } ); - - // Check for regression of https://github.com/WordPress/gutenberg/issues/24403 - it( 'inserts a block in proper place after having clicked `Browse All` from block appender', async () => { - await insertBlock( 'Group' ); - // Select the default, selected Group layout from the variation picker. - await canvas().click( - 'button[aria-label="Group: Gather blocks in a container."]' - ); - await insertBlock( 'Paragraph' ); - await page.keyboard.type( 'Paragraph after group' ); - // Click the Group first to make the appender inside it clickable. - await canvas().click( '[data-type="core/group"]' ); - await canvas().click( - '[data-type="core/group"] [aria-label="Add block"]' - ); - const browseAll = await page.waitForXPath( - '//button[text()="Browse all"]' - ); - await browseAll.click(); - await insertBlock( 'Paragraph' ); - await page.keyboard.type( 'Paragraph inside group' ); - expect( await getEditedPostContent() ).toMatchSnapshot(); - } ); - - it( 'passes the search value in the main inserter when clicking `Browse all`', async () => { - const INSERTER_SEARCH_SELECTOR = - '.block-editor-inserter__search input,.block-editor-inserter__search-input,input.block-editor-inserter__search'; - await insertBlock( 'Group' ); - // Select the default, selected Group layout from the variation picker. - await canvas().click( - 'button[aria-label="Group: Gather blocks in a container."]' - ); - await insertBlock( 'Paragraph' ); - await page.keyboard.type( 'Text' ); - // Click the Group first to make the appender inside it clickable. - await canvas().click( '[data-type="core/group"]' ); - await canvas().click( - '[data-type="core/group"] [aria-label="Add block"]' - ); - await page.waitForSelector( INSERTER_SEARCH_SELECTOR ); - await page.focus( INSERTER_SEARCH_SELECTOR ); - await pressKeyWithModifier( 'primary', 'a' ); - const searchTerm = 'Verse'; - await page.keyboard.type( searchTerm ); - const browseAll = await page.waitForXPath( - '//button[text()="Browse all"]' - ); - await browseAll.click(); - const availableBlocks = await page.$$( - '.editor-inserter-sidebar .block-editor-block-types-list__list-item' - ); - expect( availableBlocks ).toHaveLength( 1 ); - } ); - - // Check for regression of https://github.com/WordPress/gutenberg/issues/27586 - it( 'closes the main inserter after inserting a single-use block, like the More block', async () => { - await insertBlock( 'More' ); - await page.waitForSelector( - '.editor-document-tools__inserter-toggle:not(.is-pressed)' - ); - - // The inserter panel should've closed. - const inserterPanels = await page.$$( '.editor-inserter-sidebar' ); - expect( inserterPanels.length ).toBe( 0 ); - - // The editable 'Read More' text should be focused. - const isFocusInBlock = await canvas().evaluate( () => - document - .querySelector( '[data-type="core/more"]' ) - .contains( document.activeElement ) - ); - expect( isFocusInBlock ).toBe( true ); - } ); - - it( 'shows block preview when hovering over block in inserter', async () => { - await openGlobalBlockInserter(); - const paragraphButton = ( - await page.$x( `//button//span[contains(text(), 'Paragraph')]` ) - )[ 0 ]; - await paragraphButton.hover(); - const preview = await page.waitForSelector( - '.block-editor-inserter__preview', - { - visible: true, - } - ); - const isPreviewVisible = await preview.isIntersectingViewport(); - expect( isPreviewVisible ).toBe( true ); - } ); - - it.each( [ 'large', 'small' ] )( - 'last-inserted block should be given and keep the focus (%s viewport)', - async ( viewport ) => { - await setBrowserViewport( viewport ); - - await canvas().type( - '.block-editor-default-block-appender__content', - 'Testing inserted block focus' - ); - - await insertBlock( 'Image' ); - - await canvas().waitForSelector( 'figure[data-type="core/image"]' ); - - const selectedBlock = await page.evaluate( () => { - return wp.data.select( 'core/block-editor' ).getSelectedBlock(); - } ); - - expect( selectedBlock.name ).toBe( 'core/image' ); - } - ); -} ); diff --git a/packages/edit-site/src/components/block-editor/use-site-editor-settings.js b/packages/edit-site/src/components/block-editor/use-site-editor-settings.js index 77e9e69f02a984..a115094bc1e510 100644 --- a/packages/edit-site/src/components/block-editor/use-site-editor-settings.js +++ b/packages/edit-site/src/components/block-editor/use-site-editor-settings.js @@ -6,6 +6,7 @@ import { useMemo } from '@wordpress/element'; import { store as coreStore } from '@wordpress/core-data'; import { privateApis as editorPrivateApis } from '@wordpress/editor'; import { privateApis as routerPrivateApis } from '@wordpress/router'; +import { usePrevious } from '@wordpress/compose'; /** * Internal dependencies @@ -92,13 +93,24 @@ function useArchiveLabel( templateSlug ) { function useGoBack() { const location = useLocation(); + const previousLocation = usePrevious( location ); const history = useHistory(); const goBack = useMemo( () => { const isFocusMode = location.params.focusMode || - FOCUSABLE_ENTITIES.includes( location.params.postType ); - return isFocusMode ? () => history.back() : undefined; - }, [ location.params.focusMode, location.params.postType, history ] ); + ( location.params.postId && + FOCUSABLE_ENTITIES.includes( location.params.postType ) ); + const didComeFromEditorCanvas = + previousLocation?.params.postId && + previousLocation?.params.postType && + previousLocation?.params.canvas === 'edit'; + const showBackButton = isFocusMode && didComeFromEditorCanvas; + return showBackButton ? () => history.back() : undefined; + // Disable reason: previousLocation changes when the component updates for any reason, not + // just when location changes. Until this is fixed we can't add it to deps. See + // https://github.com/WordPress/gutenberg/pull/58710#discussion_r1479219465. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ location, history ] ); return goBack; } diff --git a/packages/edit-site/src/components/global-styles/screen-block.js b/packages/edit-site/src/components/global-styles/screen-block.js index aaeb1a4a833fa7..570b465b10eea1 100644 --- a/packages/edit-site/src/components/global-styles/screen-block.js +++ b/packages/edit-site/src/components/global-styles/screen-block.js @@ -64,7 +64,6 @@ const { useGlobalSetting, useSettingsForBlockElement, useHasColorPanel, - useHasEffectsPanel, useHasFiltersPanel, useHasImageSettingsPanel, useGlobalStyle, @@ -72,7 +71,6 @@ const { ColorPanel: StylesColorPanel, TypographyPanel: StylesTypographyPanel, DimensionsPanel: StylesDimensionsPanel, - EffectsPanel: StylesEffectsPanel, FiltersPanel: StylesFiltersPanel, ImageSettingsPanel, AdvancedPanel: StylesAdvancedPanel, @@ -124,7 +122,6 @@ function ScreenBlock( { name, variation } ) { const hasColorPanel = useHasColorPanel( settings ); const hasBorderPanel = useHasBorderPanel( settings ); const hasDimensionsPanel = useHasDimensionsPanel( settings ); - const hasEffectsPanel = useHasEffectsPanel( settings ); const hasFiltersPanel = useHasFiltersPanel( settings ); const hasImageSettingsPanel = useHasImageSettingsPanel( name, @@ -279,15 +276,6 @@ function ScreenBlock( { name, variation } ) { settings={ settings } /> ) } - { hasEffectsPanel && ( - - ) } { hasFiltersPanel && ( ); diff --git a/packages/edit-site/src/components/site-icon/style.scss b/packages/edit-site/src/components/site-icon/style.scss index fc680166bf2691..d8b5e3f9b51dee 100644 --- a/packages/edit-site/src/components/site-icon/style.scss +++ b/packages/edit-site/src/components/site-icon/style.scss @@ -1,10 +1,18 @@ .edit-site-site-icon__icon { fill: currentColor; + // Matches SiteIcon motion, smoothing out the transition. + transition: padding 0.3s ease-out; + @include reduce-motion("transition"); + + .edit-site-layout.is-full-canvas & { + // Make the WordPress icon not so big in full canvas. + padding: $grid-unit-15 * 0.5; // 6px. + } } .edit-site-site-icon__image { width: 100%; - height: auto; + height: 100%; border-radius: $radius-block-ui * 2; object-fit: cover; background: #333; diff --git a/packages/editor/src/components/post-author/combobox.js b/packages/editor/src/components/post-author/combobox.js index a2f4c312dfd8a1..aee01ee5178728 100644 --- a/packages/editor/src/components/post-author/combobox.js +++ b/packages/editor/src/components/post-author/combobox.js @@ -18,9 +18,9 @@ import { AUTHORS_QUERY } from './constants'; function PostAuthorCombobox() { const [ fieldValue, setFieldValue ] = useState(); - const { authorId, isLoading, authors, postAuthor } = useSelect( + const { authorId, authors, postAuthor } = useSelect( ( select ) => { - const { getUser, getUsers, isResolving } = select( coreStore ); + const { getUser, getUsers } = select( coreStore ); const { getEditedPostAttribute } = select( editorStore ); const author = getUser( getEditedPostAttribute( 'author' ), { context: 'view', @@ -35,7 +35,6 @@ function PostAuthorCombobox() { authorId: getEditedPostAttribute( 'author' ), postAuthor: author, authors: getUsers( query ), - isLoading: isResolving( 'core', 'getUsers', [ query ] ), }; }, [ fieldValue ] @@ -89,10 +88,6 @@ function PostAuthorCombobox() { setFieldValue( inputValue ); }; - if ( ! postAuthor ) { - return null; - } - return ( ); diff --git a/packages/editor/src/components/post-author/select.js b/packages/editor/src/components/post-author/select.js index c23e88125173f5..24958862b50f81 100644 --- a/packages/editor/src/components/post-author/select.js +++ b/packages/editor/src/components/post-author/select.js @@ -14,24 +14,47 @@ import { store as coreStore } from '@wordpress/core-data'; import { store as editorStore } from '../../store'; import { AUTHORS_QUERY } from './constants'; -function PostAuthorSelect() { +export default function PostAuthorSelect() { const { editPost } = useDispatch( editorStore ); - const { postAuthor, authors } = useSelect( ( select ) => { + const { authorId, postAuthor, authors } = useSelect( ( select ) => { + const { getUser, getUsers } = select( coreStore ); + const { getEditedPostAttribute } = select( editorStore ); + const _authorId = getEditedPostAttribute( 'author' ); + return { - postAuthor: - select( editorStore ).getEditedPostAttribute( 'author' ), - authors: select( coreStore ).getUsers( AUTHORS_QUERY ), + authorId: _authorId, + authors: getUsers( AUTHORS_QUERY ), + postAuthor: getUser( _authorId, { + context: 'view', + } ), }; }, [] ); const authorOptions = useMemo( () => { - return ( authors ?? [] ).map( ( author ) => { + const fetchedAuthors = ( authors ?? [] ).map( ( author ) => { return { value: author.id, label: decodeEntities( author.name ), }; } ); - }, [ authors ] ); + + // Ensure the current author is included in the dropdown list. + const foundAuthor = fetchedAuthors.findIndex( + ( { value } ) => postAuthor?.id === value + ); + + if ( foundAuthor < 0 && postAuthor ) { + return [ + { + value: postAuthor.id, + label: decodeEntities( postAuthor.name ), + }, + ...fetchedAuthors, + ]; + } + + return fetchedAuthors; + }, [ authors, postAuthor ] ); const setAuthorId = ( value ) => { const author = Number( value ); @@ -46,9 +69,7 @@ function PostAuthorSelect() { label={ __( 'Author' ) } options={ authorOptions } onChange={ setAuthorId } - value={ postAuthor } + value={ authorId } /> ); } - -export default PostAuthorSelect; diff --git a/packages/editor/src/hooks/pattern-partial-syncing.js b/packages/editor/src/hooks/pattern-partial-syncing.js index 0ddfea8d9d8e36..f86268cb495463 100644 --- a/packages/editor/src/hooks/pattern-partial-syncing.js +++ b/packages/editor/src/hooks/pattern-partial-syncing.js @@ -15,6 +15,7 @@ import { unlock } from '../lock-unlock'; const { PartialSyncingControls, + ResetOverridesControl, PATTERN_TYPES, PARTIAL_SYNCING_SUPPORTED_BLOCKS, } = unlock( patternsPrivateApis ); @@ -54,12 +55,30 @@ function ControlsWithStoreSubscription( props ) { select( editorStore ).getCurrentPostType() === PATTERN_TYPES.user, [] ); + const bindings = props.attributes.metadata?.bindings; + const hasPatternBindings = + !! bindings && + Object.values( bindings ).some( + ( binding ) => binding.source === 'core/pattern-overrides' + ); + + const shouldShowPartialSyncingControls = + isEditingPattern && blockEditingMode === 'default'; + const shouldShowResetOverridesControl = + ! isEditingPattern && + !! props.attributes.metadata?.id && + blockEditingMode !== 'disabled' && + hasPatternBindings; return ( - isEditingPattern && - blockEditingMode === 'default' && ( - - ) + <> + { shouldShowPartialSyncingControls && ( + + ) } + { shouldShowResetOverridesControl && ( + + ) } + ); } diff --git a/packages/patterns/src/components/reset-overrides-control.js b/packages/patterns/src/components/reset-overrides-control.js new file mode 100644 index 00000000000000..03d520d2e9b813 --- /dev/null +++ b/packages/patterns/src/components/reset-overrides-control.js @@ -0,0 +1,78 @@ +/** + * WordPress dependencies + */ +import { + store as blockEditorStore, + BlockControls, +} from '@wordpress/block-editor'; +import { ToolbarButton, ToolbarGroup } from '@wordpress/components'; +import { useSelect, useRegistry } from '@wordpress/data'; +import { store as coreStore } from '@wordpress/core-data'; +import { parse } from '@wordpress/blocks'; +import { __ } from '@wordpress/i18n'; + +function recursivelyFindBlockWithId( blocks, id ) { + for ( const block of blocks ) { + if ( block.attributes.metadata?.id === id ) { + return block; + } + + const found = recursivelyFindBlockWithId( block.innerBlocks, id ); + if ( found ) { + return found; + } + } +} + +export default function ResetOverridesControl( props ) { + const registry = useRegistry(); + const id = props.attributes.metadata?.id; + const patternWithOverrides = useSelect( + ( select ) => { + if ( ! id ) { + return undefined; + } + + const { getBlockParentsByBlockName, getBlocksByClientId } = + select( blockEditorStore ); + const patternBlock = getBlocksByClientId( + getBlockParentsByBlockName( props.clientId, 'core/block' ) + )[ 0 ]; + + if ( ! patternBlock?.attributes.content?.[ id ] ) { + return undefined; + } + + return patternBlock; + }, + [ props.clientId, id ] + ); + + const resetOverrides = async () => { + const editedRecord = await registry + .resolveSelect( coreStore ) + .getEditedEntityRecord( + 'postType', + 'wp_block', + patternWithOverrides.attributes.ref + ); + const blocks = editedRecord.blocks ?? parse( editedRecord.content ); + const block = recursivelyFindBlockWithId( blocks, id ); + + props.setAttributes( block.attributes ); + }; + + return ( + + + + { __( 'Reset' ) } + + + + ); +} diff --git a/packages/patterns/src/private-apis.js b/packages/patterns/src/private-apis.js index 046e20dd300039..099e4ae8ffed4c 100644 --- a/packages/patterns/src/private-apis.js +++ b/packages/patterns/src/private-apis.js @@ -14,6 +14,7 @@ import RenamePatternModal from './components/rename-pattern-modal'; import PatternsMenuItems from './components'; import RenamePatternCategoryModal from './components/rename-pattern-category-modal'; import PartialSyncingControls from './components/partial-syncing-controls'; +import ResetOverridesControl from './components/reset-overrides-control'; import { PATTERN_TYPES, PATTERN_DEFAULT_CATEGORY, @@ -33,6 +34,7 @@ lock( privateApis, { PatternsMenuItems, RenamePatternCategoryModal, PartialSyncingControls, + ResetOverridesControl, PATTERN_TYPES, PATTERN_DEFAULT_CATEGORY, PATTERN_USER_CATEGORY, diff --git a/packages/style-engine/src/styles/background/index.ts b/packages/style-engine/src/styles/background/index.ts index b9879ad2032a17..8ce8c7d577fb28 100644 --- a/packages/style-engine/src/styles/background/index.ts +++ b/packages/style-engine/src/styles/background/index.ts @@ -40,6 +40,18 @@ const backgroundImage = { }, }; +const backgroundPosition = { + name: 'backgroundRepeat', + generate: ( style: Style, options: StyleOptions ) => { + return generateRule( + style, + options, + [ 'background', 'backgroundPosition' ], + 'backgroundPosition' + ); + }, +}; + const backgroundRepeat = { name: 'backgroundRepeat', generate: ( style: Style, options: StyleOptions ) => { @@ -89,4 +101,9 @@ const backgroundSize = { }, }; -export default [ backgroundImage, backgroundRepeat, backgroundSize ]; +export default [ + backgroundImage, + backgroundPosition, + backgroundRepeat, + backgroundSize, +]; diff --git a/packages/style-engine/src/test/index.js b/packages/style-engine/src/test/index.js index 1727ed535897bc..b679775d3f37f4 100644 --- a/packages/style-engine/src/test/index.js +++ b/packages/style-engine/src/test/index.js @@ -229,6 +229,7 @@ describe( 'getCSSRules', () => { source: 'file', url: 'https://example.com/image.jpg', }, + backgroundPosition: '50% 50%', backgroundRepeat: 'no-repeat', backgroundSize: '300px', }, @@ -384,6 +385,11 @@ describe( 'getCSSRules', () => { key: 'backgroundImage', value: "url( 'https://example.com/image.jpg' )", }, + { + selector: '.some-selector', + key: 'backgroundPosition', + value: '50% 50%', + }, { selector: '.some-selector', key: 'backgroundRepeat', diff --git a/phpunit/tests/fonts/font-library/fontLibraryHooks.php b/phpunit/tests/fonts/font-library/fontLibraryHooks.php deleted file mode 100644 index 85d631ecaa45f2..00000000000000 --- a/phpunit/tests/fonts/font-library/fontLibraryHooks.php +++ /dev/null @@ -1,88 +0,0 @@ -post->create( - array( - 'post_type' => 'wp_font_family', - ) - ); - $font_face_id = self::factory()->post->create( - array( - 'post_type' => 'wp_font_face', - 'post_parent' => $font_family_id, - ) - ); - $other_font_family_id = self::factory()->post->create( - array( - 'post_type' => 'wp_font_family', - ) - ); - $other_font_face_id = self::factory()->post->create( - array( - 'post_type' => 'wp_font_face', - 'post_parent' => $other_font_family_id, - ) - ); - - wp_delete_post( $font_family_id, true ); - - $this->assertNull( get_post( $font_face_id ), 'Font face post should also have been deleted.' ); - $this->assertNotNull( get_post( $other_font_face_id ), 'The other post should exist.' ); - } - - public function test_deleting_font_faces_deletes_associated_font_files() { - list( $font_face_id, $font_path ) = $this->create_font_face_with_file( 'OpenSans-Regular.woff2' ); - list( , $other_font_path ) = $this->create_font_face_with_file( 'OpenSans-Regular.ttf' ); - - wp_delete_post( $font_face_id, true ); - - $this->assertFalse( file_exists( $font_path ), 'The font file should have been deleted when the post was deleted.' ); - $this->assertTrue( file_exists( $other_font_path ), 'The other font file should exist.' ); - } - - protected function create_font_face_with_file( $filename ) { - $font_face_id = self::factory()->post->create( - array( - 'post_type' => 'wp_font_face', - ) - ); - - $font_file = $this->upload_font_file( $filename ); - - // Make sure the font file uploaded successfully. - $this->assertFalse( $font_file['error'] ); - - $font_path = $font_file['file']; - $font_filename = basename( $font_path ); - add_post_meta( $font_face_id, '_wp_font_face_file', $font_filename ); - - return array( $font_face_id, $font_path ); - } - - protected function upload_font_file( $font_filename ) { - // @core-merge Use `DIR_TESTDATA` instead of `GUTENBERG_DIR_TESTDATA`. - $font_file_path = GUTENBERG_DIR_TESTDATA . 'fonts/' . $font_filename; - - add_filter( 'upload_mimes', array( 'WP_Font_Utils', 'get_allowed_font_mime_types' ) ); - add_filter( 'upload_dir', 'wp_get_font_dir' ); - $font_file = wp_upload_bits( - $font_filename, - null, - file_get_contents( $font_file_path ) - ); - remove_filter( 'upload_dir', 'wp_get_font_dir' ); - remove_filter( 'upload_mimes', array( 'WP_Font_Utils', 'get_allowed_font_mime_types' ) ); - - return $font_file; - } -} diff --git a/phpunit/tests/fonts/font-library/wpFontCollection/__construct.php b/phpunit/tests/fonts/font-library/wpFontCollection/__construct.php deleted file mode 100644 index a0693ce3414565..00000000000000 --- a/phpunit/tests/fonts/font-library/wpFontCollection/__construct.php +++ /dev/null @@ -1,26 +0,0 @@ -setExpectedIncorrectUsage( 'WP_Font_Collection::__construct' ); - $mock_collection_data = array( - 'name' => 'Test Collection', - 'font_families' => array( 'mock ' ), - ); - - $collection = new WP_Font_Collection( 'slug with spaces', $mock_collection_data ); - - $this->assertSame( 'slug-with-spaces', $collection->slug, 'Slug is not sanitized.' ); - } -} diff --git a/phpunit/tests/fonts/font-library/wpFontCollection/getData.php b/phpunit/tests/fonts/font-library/wpFontCollection/getData.php deleted file mode 100644 index 1cb16e271db61a..00000000000000 --- a/phpunit/tests/fonts/font-library/wpFontCollection/getData.php +++ /dev/null @@ -1,358 +0,0 @@ -get_data(); - - $this->assertSame( $slug, $collection->slug, 'The slug should match.' ); - $this->assertSame( $expected_data, $data, 'The collection data should match.' ); - } - - /** - * @dataProvider data_create_font_collection - * - * @param string $slug Font collection slug. - * @param array $config Font collection config. - * @param array $expected_data Expected collection data. - */ - public function test_should_get_data_from_json_file( $slug, $config, $expected_data ) { - $mock_file = wp_tempnam( 'my-collection-data-' ); - file_put_contents( $mock_file, wp_json_encode( $config ) ); - - $collection = new WP_Font_Collection( $slug, $mock_file ); - $data = $collection->get_data(); - - $this->assertSame( $slug, $collection->slug, 'The slug should match.' ); - $this->assertSame( $expected_data, $data, 'The collection data should match.' ); - } - - /** - * @dataProvider data_create_font_collection - * - * @param string $slug Font collection slug. - * @param array $config Font collection config. - * @param array $expected_data Expected collection data. - */ - public function test_should_get_data_from_json_url( $slug, $config, $expected_data ) { - add_filter( 'pre_http_request', array( $this, 'mock_request' ), 10, 3 ); - - self::$mock_collection_data = $config; - $collection = new WP_Font_Collection( $slug, 'https://localhost/fonts/mock-font-collection.json' ); - $data = $collection->get_data(); - - remove_filter( 'pre_http_request', array( $this, 'mock_request' ) ); - - $this->assertSame( $slug, $collection->slug, 'The slug should match.' ); - $this->assertSame( $expected_data, $data, 'The collection data should match.' ); - } - - /** - * Data provider. - * - * @return array - */ - public function data_create_font_collection() { - return array( - - 'font collection with required data' => array( - 'slug' => 'my-collection', - 'config' => array( - 'name' => 'My Collection', - 'font_families' => array( array() ), - ), - 'expected_data' => array( - 'description' => '', - 'categories' => array(), - 'name' => 'My Collection', - 'font_families' => array( array() ), - ), - ), - - 'font collection with all data' => array( - 'slug' => 'my-collection', - 'config' => array( - 'name' => 'My Collection', - 'description' => 'My collection description', - 'font_families' => array( array() ), - 'categories' => array(), - ), - 'expected_data' => array( - 'description' => 'My collection description', - 'categories' => array(), - 'name' => 'My Collection', - 'font_families' => array( array() ), - ), - ), - - 'font collection with risky data' => array( - 'slug' => 'my-collection', - 'config' => array( - 'name' => 'My Collection', - 'description' => 'My collection description', - 'font_families' => array( - array( - 'font_family_settings' => array( - 'fontFamily' => 'Open Sans, sans-serif', - 'slug' => 'open-sans', - 'name' => 'Open Sans', - 'fontFace' => array( - array( - 'fontFamily' => 'Open Sans', - 'fontStyle' => 'normal', - 'fontWeight' => '400', - 'src' => 'https://example.com/src-as-string.ttf?a=', - ), - array( - 'fontFamily' => 'Open Sans', - 'fontStyle' => 'normal', - 'fontWeight' => '400', - 'src' => array( - 'https://example.com/src-as-array.woff2?a=', - 'https://example.com/src-as-array.ttf', - ), - ), - ), - 'unwanted_property' => 'potentially evil value', - ), - 'categories' => array( 'sans-serif' ), - ), - ), - 'categories' => array( - array( - 'name' => 'Mock col', - 'slug' => 'mock-col', - 'unwanted_property' => 'potentially evil value', - ), - ), - 'unwanted_property' => 'potentially evil value', - ), - 'expected_data' => array( - 'description' => 'My collection description', - 'categories' => array( - array( - 'name' => 'Mock col', - 'slug' => 'mock-colalertxss', - ), - ), - 'name' => 'My Collection', - 'font_families' => array( - array( - 'font_family_settings' => array( - 'fontFamily' => 'Open Sans, sans-serif', - 'slug' => 'open-sans', - 'name' => 'Open Sans', - 'fontFace' => array( - array( - 'fontFamily' => 'Open Sans', - 'fontStyle' => 'normal', - 'fontWeight' => '400', - 'src' => 'https://example.com/src-as-string.ttf?a=', - ), - array( - 'fontFamily' => 'Open Sans', - 'fontStyle' => 'normal', - 'fontWeight' => '400', - 'src' => array( - 'https://example.com/src-as-array.woff2?a=', - 'https://example.com/src-as-array.ttf', - ), - ), - ), - ), - 'categories' => array( 'sans-serifalertxss' ), - ), - ), - ), - ), - - ); - } - - /** - * @dataProvider data_should_error_when_missing_properties - * - * @param array $config Font collection config. - */ - public function test_should_error_when_missing_properties( $config ) { - $this->setExpectedIncorrectUsage( 'WP_Font_Collection::sanitize_and_validate_data' ); - - $collection = new WP_Font_Collection( 'my-collection', $config ); - $data = $collection->get_data(); - - $this->assertWPError( $data, 'Error is not returned when property is missing or invalid.' ); - $this->assertSame( - $data->get_error_code(), - 'font_collection_missing_property', - 'Incorrect error code when property is missing or invalid.' - ); - } - - /** - * Data provider. - * - * @return array - */ - public function data_should_error_when_missing_properties() { - return array( - 'missing name' => array( - 'config' => array( - 'font_families' => array( 'mock' ), - ), - ), - 'empty name' => array( - 'config' => array( - 'name' => '', - 'font_families' => array( 'mock' ), - ), - ), - 'missing font_families' => array( - 'config' => array( - 'name' => 'My Collection', - ), - ), - 'empty font_families' => array( - 'config' => array( - 'name' => 'My Collection', - 'font_families' => array(), - ), - ), - ); - } - - public function test_should_error_with_invalid_json_file_path() { - $this->setExpectedIncorrectUsage( 'WP_Font_Collection::load_from_json' ); - - $collection = new WP_Font_Collection( 'my-collection', 'non-existing.json' ); - $data = $collection->get_data(); - - $this->assertWPError( $data, 'Error is not returned when invalid file path is provided.' ); - $this->assertSame( - $data->get_error_code(), - 'font_collection_json_missing', - 'Incorrect error code when invalid file path is provided.' - ); - } - - public function test_should_error_with_invalid_json_from_file() { - $mock_file = wp_tempnam( 'my-collection-data-' ); - file_put_contents( $mock_file, 'invalid-json' ); - - $collection = new WP_Font_Collection( 'my-collection', $mock_file ); - - // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Testing error response returned by `load_from_json`, not the underlying error from `wp_json_file_decode`. - $data = @$collection->get_data(); - - $this->assertWPError( $data, 'Error is not returned with invalid json file contents.' ); - $this->assertSame( - $data->get_error_code(), - 'font_collection_decode_error', - 'Incorrect error code with invalid json file contents.' - ); - } - - public function test_should_error_with_invalid_url() { - $this->setExpectedIncorrectUsage( 'WP_Font_Collection::load_from_json' ); - - $collection = new WP_Font_Collection( 'my-collection', 'not-a-url' ); - $data = $collection->get_data(); - - $this->assertWPError( $data, 'Error is not returned when invalid url is provided.' ); - $this->assertSame( - $data->get_error_code(), - 'font_collection_json_missing', - 'Incorrect error code when invalid url is provided.' - ); - } - - public function test_should_error_with_unsuccessful_response_status() { - add_filter( 'pre_http_request', array( $this, 'mock_request_unsuccessful_response' ), 10, 3 ); - - $collection = new WP_Font_Collection( 'my-collection', 'https://localhost/fonts/missing-collection.json' ); - $data = $collection->get_data(); - - remove_filter( 'pre_http_request', array( $this, 'mock_request_unsuccessful_response' ) ); - - $this->assertWPError( $data, 'Error is not returned when response is unsuccessful.' ); - $this->assertSame( - $data->get_error_code(), - 'font_collection_request_error', - 'Incorrect error code when response is unsuccussful.' - ); - } - - public function test_should_error_with_invalid_json_from_url() { - add_filter( 'pre_http_request', array( $this, 'mock_request_invalid_json' ), 10, 3 ); - - $collection = new WP_Font_Collection( 'my-collection', 'https://localhost/fonts/invalid-collection.json' ); - $data = $collection->get_data(); - - remove_filter( 'pre_http_request', array( $this, 'mock_request_invalid_json' ) ); - - $this->assertWPError( $data, 'Error is not returned when response is invalid json.' ); - $this->assertSame( - $data->get_error_code(), - 'font_collection_decode_error', - 'Incorrect error code when response is invalid json.' - ); - } - - public function mock_request( $preempt, $args, $url ) { - if ( 'https://localhost/fonts/mock-font-collection.json' !== $url ) { - return false; - } - - return array( - 'body' => wp_json_encode( self::$mock_collection_data ), - 'response' => array( - 'code' => 200, - ), - ); - } - - public function mock_request_unsuccessful_response( $preempt, $args, $url ) { - if ( 'https://localhost/fonts/missing-collection.json' !== $url ) { - return false; - } - - return array( - 'body' => '', - 'response' => array( - 'code' => 404, - ), - ); - } - - public function mock_request_invalid_json( $preempt, $args, $url ) { - if ( 'https://localhost/fonts/invalid-collection.json' !== $url ) { - return false; - } - - return array( - 'body' => 'invalid', - 'response' => array( - 'code' => 200, - ), - ); - } -} diff --git a/phpunit/tests/fonts/font-library/wpFontLibrary/base.php b/phpunit/tests/fonts/font-library/wpFontLibrary/base.php deleted file mode 100644 index 135329e5add73a..00000000000000 --- a/phpunit/tests/fonts/font-library/wpFontLibrary/base.php +++ /dev/null @@ -1,25 +0,0 @@ -get_font_collections(); - foreach ( $collections as $slug => $collection ) { - WP_Font_Library::get_instance()->unregister_font_collection( $slug ); - } - } - - public function set_up() { - parent::set_up(); - $this->reset_font_collections(); - } - - public function tear_down() { - parent::tear_down(); - $this->reset_font_collections(); - } -} diff --git a/phpunit/tests/fonts/font-library/wpFontLibrary/getFontCollection.php b/phpunit/tests/fonts/font-library/wpFontLibrary/getFontCollection.php deleted file mode 100644 index 675efe81aec59b..00000000000000 --- a/phpunit/tests/fonts/font-library/wpFontLibrary/getFontCollection.php +++ /dev/null @@ -1,30 +0,0 @@ - 'Test Collection', - 'font_families' => array( 'mock' ), - ); - - wp_register_font_collection( 'my-font-collection', $mock_collection_data ); - $font_collection = WP_Font_Library::get_instance()->get_font_collection( 'my-font-collection' ); - $this->assertInstanceOf( 'WP_Font_Collection', $font_collection ); - } - - public function test_should_get_no_font_collection_if_the_slug_is_not_registered() { - $font_collection = WP_Font_Library::get_instance()->get_font_collection( 'not-registered-font-collection' ); - $this->assertWPError( $font_collection ); - } -} diff --git a/phpunit/tests/fonts/font-library/wpFontLibrary/getFontCollections.php b/phpunit/tests/fonts/font-library/wpFontLibrary/getFontCollections.php deleted file mode 100644 index f5ca6389b8ff51..00000000000000 --- a/phpunit/tests/fonts/font-library/wpFontLibrary/getFontCollections.php +++ /dev/null @@ -1,34 +0,0 @@ -get_font_collections(); - $this->assertEmpty( $font_collections, 'Should return an empty array.' ); - } - - public function test_should_get_mock_font_collection() { - $my_font_collection_config = array( - 'name' => 'My Font Collection', - 'description' => 'Demo about how to a font collection to your WordPress Font Library.', - 'font_families' => array( 'mock' ), - ); - - WP_Font_Library::get_instance()->register_font_collection( 'my-font-collection', $my_font_collection_config ); - - $font_collections = WP_Font_Library::get_instance()->get_font_collections(); - $this->assertNotEmpty( $font_collections, 'Should return an array of font collections.' ); - $this->assertCount( 1, $font_collections, 'Should return an array with one font collection.' ); - $this->assertArrayHasKey( 'my-font-collection', $font_collections, 'The array should have the key of the registered font collection id.' ); - $this->assertInstanceOf( 'WP_Font_Collection', $font_collections['my-font-collection'], 'The value of the array $font_collections[id] should be an instance of WP_Font_Collection class.' ); - } -} diff --git a/phpunit/tests/fonts/font-library/wpFontLibrary/registerFontCollection.php b/phpunit/tests/fonts/font-library/wpFontLibrary/registerFontCollection.php deleted file mode 100644 index d3b0f126e2e7e1..00000000000000 --- a/phpunit/tests/fonts/font-library/wpFontLibrary/registerFontCollection.php +++ /dev/null @@ -1,40 +0,0 @@ - 'My Collection', - 'font_families' => array( 'mock' ), - ); - - $collection = WP_Font_Library::get_instance()->register_font_collection( 'my-collection', $config ); - $this->assertInstanceOf( 'WP_Font_Collection', $collection ); - } - - public function test_should_return_error_if_slug_is_repeated() { - $mock_collection_data = array( - 'name' => 'Test Collection', - 'font_families' => array( 'mock' ), - ); - - // Register first collection. - $collection1 = WP_Font_Library::get_instance()->register_font_collection( 'my-collection-1', $mock_collection_data ); - $this->assertInstanceOf( 'WP_Font_Collection', $collection1, 'A collection should be registered.' ); - - // Expects a _doing_it_wrong notice. - $this->setExpectedIncorrectUsage( 'WP_Font_Library::register_font_collection' ); - - // Try to register a second collection with same slug. - WP_Font_Library::get_instance()->register_font_collection( 'my-collection-1', $mock_collection_data ); - } -} diff --git a/phpunit/tests/fonts/font-library/wpFontLibrary/unregisterFontCollection.php b/phpunit/tests/fonts/font-library/wpFontLibrary/unregisterFontCollection.php deleted file mode 100644 index ddb0fa91c1d609..00000000000000 --- a/phpunit/tests/fonts/font-library/wpFontLibrary/unregisterFontCollection.php +++ /dev/null @@ -1,46 +0,0 @@ - 'Test Collection', - 'font_families' => array( 'mock' ), - ); - - // Registers two mock font collections. - WP_Font_Library::get_instance()->register_font_collection( 'mock-font-collection-1', $mock_collection_data ); - WP_Font_Library::get_instance()->register_font_collection( 'mock-font-collection-2', $mock_collection_data ); - - // Unregister mock font collection. - WP_Font_Library::get_instance()->unregister_font_collection( 'mock-font-collection-1' ); - $collections = WP_Font_Library::get_instance()->get_font_collections(); - $this->assertArrayNotHasKey( 'mock-font-collection-1', $collections, 'Font collection was not unregistered.' ); - $this->assertArrayHasKey( 'mock-font-collection-2', $collections, 'Font collection was unregistered by mistake.' ); - - // Unregisters remaining mock font collection. - WP_Font_Library::get_instance()->unregister_font_collection( 'mock-font-collection-2' ); - $collections = WP_Font_Library::get_instance()->get_font_collections(); - $this->assertArrayNotHasKey( 'mock-font-collection-2', $collections, 'Mock font collection was not unregistered.' ); - - // Checks that all font collections were unregistered. - $this->assertEmpty( $collections, 'Font collections were not unregistered.' ); - } - - public function unregister_non_existing_collection() { - // Unregisters non-existing font collection. - WP_Font_Library::get_instance()->unregister_font_collection( 'non-existing-collection' ); - $collections = WP_Font_Library::get_instance()->get_font_collections(); - $this->assertEmpty( $collections, 'No collections should be registered.' ); - } -} diff --git a/phpunit/tests/fonts/font-library/wpFontUtils/getFontFaceSlug.php b/phpunit/tests/fonts/font-library/wpFontUtils/getFontFaceSlug.php deleted file mode 100644 index de0b02e63185ed..00000000000000 --- a/phpunit/tests/fonts/font-library/wpFontUtils/getFontFaceSlug.php +++ /dev/null @@ -1,92 +0,0 @@ -assertSame( $expected_slug, $slug ); - } - - /** - * Data provider. - * - * @return array - */ - public function data_get_font_face_slug_normalizes_values() { - return array( - 'Sets defaults' => array( - 'settings' => array( - 'fontFamily' => 'Open Sans', - ), - 'expected_slug' => 'open sans;normal;400;100%;U+0-10FFFF', - ), - 'Converts normal weight to 400' => array( - 'settings' => array( - 'fontFamily' => 'Open Sans', - 'fontWeight' => 'normal', - ), - 'expected_slug' => 'open sans;normal;400;100%;U+0-10FFFF', - ), - 'Converts bold weight to 700' => array( - 'settings' => array( - 'fontFamily' => 'Open Sans', - 'fontWeight' => 'bold', - ), - 'expected_slug' => 'open sans;normal;700;100%;U+0-10FFFF', - ), - 'Converts normal font-stretch to 100%' => array( - 'settings' => array( - 'fontFamily' => 'Open Sans', - 'fontStretch' => 'normal', - ), - 'expected_slug' => 'open sans;normal;400;100%;U+0-10FFFF', - ), - 'Removes double quotes from fontFamilies' => array( - 'settings' => array( - 'fontFamily' => '"Open Sans"', - ), - 'expected_slug' => 'open sans;normal;400;100%;U+0-10FFFF', - ), - 'Removes single quotes from fontFamilies' => array( - 'settings' => array( - 'fontFamily' => "'Open Sans'", - ), - 'expected_slug' => 'open sans;normal;400;100%;U+0-10FFFF', - ), - 'Removes spaces between comma separated font families' => array( - 'settings' => array( - 'fontFamily' => 'Open Sans, serif', - ), - 'expected_slug' => 'open sans,serif;normal;400;100%;U+0-10FFFF', - ), - 'Removes tabs between comma separated font families' => array( - 'settings' => array( - 'fontFamily' => "Open Sans,\tserif", - ), - 'expected_slug' => 'open sans,serif;normal;400;100%;U+0-10FFFF', - ), - 'Removes new lines between comma separated font families' => array( - 'settings' => array( - 'fontFamily' => "Open Sans,\nserif", - ), - 'expected_slug' => 'open sans,serif;normal;400;100%;U+0-10FFFF', - ), - ); - } -} diff --git a/phpunit/tests/fonts/font-library/wpFontUtils/sanitizeFontFamily.php b/phpunit/tests/fonts/font-library/wpFontUtils/sanitizeFontFamily.php deleted file mode 100644 index 71511331c65dcb..00000000000000 --- a/phpunit/tests/fonts/font-library/wpFontUtils/sanitizeFontFamily.php +++ /dev/null @@ -1,63 +0,0 @@ -assertSame( - $expected, - WP_Font_Utils::sanitize_font_family( - $font_family - ) - ); - } - - /** - * Data provider. - * - * @return array - */ - public function data_should_sanitize_font_family() { - return array( - 'data_families_with_spaces_and_numbers' => array( - 'font_family' => 'Rock 3D , Open Sans,serif', - 'expected' => '"Rock 3D", "Open Sans", serif', - ), - 'data_single_font_family' => array( - 'font_family' => 'Rock 3D', - 'expected' => '"Rock 3D"', - ), - 'data_no_spaces' => array( - 'font_family' => 'Rock3D', - 'expected' => 'Rock3D', - ), - 'data_many_spaces_and_existing_quotes' => array( - 'font_family' => 'Rock 3D serif, serif,sans-serif, "Open Sans"', - 'expected' => '"Rock 3D serif", serif, sans-serif, "Open Sans"', - ), - 'data_empty_family' => array( - 'font_family' => ' ', - 'expected' => '', - ), - 'data_font_family_with_whitespace_tags_new_lines' => array( - 'font_family' => " Rock 3D\n ", - 'expected' => '"Rock 3D"', - ), - ); - } -} diff --git a/phpunit/tests/fonts/font-library/wpFontUtils/sanitizeFromSchema.php b/phpunit/tests/fonts/font-library/wpFontUtils/sanitizeFromSchema.php deleted file mode 100644 index 88983fe15a14ec..00000000000000 --- a/phpunit/tests/fonts/font-library/wpFontUtils/sanitizeFromSchema.php +++ /dev/null @@ -1,310 +0,0 @@ -assertSame( $result, $expected ); - } - - public function data_sanitize_from_schema() { - return array( - 'One level associative array' => array( - 'data' => array( - 'slug' => 'open - sans', - 'fontFamily' => 'Open Sans, sans-serif', - 'src' => 'https://wordpress.org/example.json', - ), - 'schema' => array( - 'slug' => 'sanitize_title', - 'fontFamily' => 'sanitize_text_field', - 'src' => 'sanitize_url', - ), - 'expected' => array( - 'slug' => 'open-sansalertxss', - 'fontFamily' => 'Open Sans, sans-serif', - 'src' => 'https://wordpress.org/example.json/stylescriptalert(xss)/script', - ), - ), - - 'Nested associative arrays' => array( - 'data' => array( - 'slug' => 'open - sans', - 'fontFamily' => 'Open Sans, sans-serif', - 'src' => 'https://wordpress.org/example.json', - 'nested' => array( - 'key1' => 'value1', - 'key2' => 'value2', - 'nested2' => array( - 'key3' => 'value3', - 'key4' => 'value4', - ), - ), - ), - 'schema' => array( - 'slug' => 'sanitize_title', - 'fontFamily' => 'sanitize_text_field', - 'src' => 'sanitize_url', - 'nested' => array( - 'key1' => 'sanitize_text_field', - 'key2' => 'sanitize_text_field', - 'nested2' => array( - 'key3' => 'sanitize_text_field', - 'key4' => 'sanitize_text_field', - ), - ), - ), - 'expected' => array( - 'slug' => 'open-sansalertxss', - 'fontFamily' => 'Open Sans, sans-serif', - 'src' => 'https://wordpress.org/example.json/stylescriptalert(xss)/script', - 'nested' => array( - 'key1' => 'value1', - 'key2' => 'value2', - 'nested2' => array( - 'key3' => 'value3', - 'key4' => 'value4', - ), - ), - ), - ), - - 'Indexed arrays' => array( - 'data' => array( - 'slug' => 'oPeN SaNs', - 'enum' => array( - 'value1', - 'value2', - 'value3', - ), - ), - 'schema' => array( - 'slug' => 'sanitize_title', - 'enum' => array( 'sanitize_text_field' ), - ), - 'expected' => array( - 'slug' => 'open-sans', - 'enum' => array( 'value1', 'value2', 'value3' ), - ), - ), - - 'Nested indexed arrays' => array( - 'data' => array( - 'slug' => 'OPEN-SANS', - 'name' => 'Open Sans', - 'fontFace' => array( - array( - 'fontFamily' => 'Open Sans, sans-serif', - 'src' => 'https://wordpress.org/example.json/stylescriptalert(xss)/script', - ), - array( - 'fontFamily' => 'Open Sans, sans-serif', - 'src' => 'https://wordpress.org/example.json/stylescriptalert(xss)/script', - ), - ), - ), - 'schema' => array( - 'slug' => 'sanitize_title', - 'name' => 'sanitize_text_field', - 'fontFace' => array( - array( - 'fontFamily' => 'sanitize_text_field', - 'src' => 'sanitize_url', - ), - ), - ), - 'expected' => array( - 'slug' => 'open-sans', - 'name' => 'Open Sans', - 'fontFace' => array( - array( - 'fontFamily' => 'Open Sans, sans-serif', - 'src' => 'https://wordpress.org/example.json/stylescriptalert(xss)/script', - ), - array( - 'fontFamily' => 'Open Sans, sans-serif', - 'src' => 'https://wordpress.org/example.json/stylescriptalert(xss)/script', - ), - ), - ), - ), - - 'Custom sanitization function' => array( - 'data' => array( - 'key1' => 'abc123edf456ghi789', - 'key2' => 'value2', - ), - 'schema' => array( - 'key1' => function ( $value ) { - // Remove the six first character. - return substr( $value, 6 ); - }, - 'key2' => function ( $value ) { - // Capitalize the value. - return strtoupper( $value ); - }, - ), - 'expected' => array( - 'key1' => 'edf456ghi789', - 'key2' => 'VALUE2', - ), - ), - - 'Null as schema value' => array( - 'data' => array( - 'key1' => 'value1', - 'key2' => 'value2', - 'nested' => array( - 'key3' => 'value3', - 'key4' => 'value4', - ), - ), - 'schema' => array( - 'key1' => null, - 'key2' => 'sanitize_text_field', - 'nested' => null, - ), - 'expected' => array( - 'key1' => 'value1', - 'key2' => 'value2', - 'nested' => array( - 'key3' => 'value3', - 'key4' => 'value4', - ), - ), - ), - - 'Keys to remove' => array( - 'data' => array( - 'key1' => 'value1', - 'key2' => 'value2', - 'unwanted1' => 'value', - 'unwanted2' => 'value', - 'nestedAssociative' => array( - 'key5' => 'value5', - 'unwanted3' => 'value', - ), - 'nestedIndexed' => array( - array( - 'key6' => 'value7', - 'unwanted4' => 'value', - ), - array( - 'key6' => 'value7', - 'unwanted5' => 'value', - ), - ), - - ), - 'schema' => array( - 'key1' => 'sanitize_text_field', - 'key2' => 'sanitize_text_field', - 'nestedAssociative' => array( - 'key5' => 'sanitize_text_field', - ), - 'nestedIndexed' => array( - array( - 'key6' => 'sanitize_text_field', - ), - ), - ), - 'expected' => array( - 'key1' => 'value1', - 'key2' => 'value2', - 'nestedAssociative' => array( - 'key5' => 'value5', - ), - 'nestedIndexed' => array( - array( - 'key6' => 'value7', - ), - array( - 'key6' => 'value7', - ), - ), - ), - ), - - 'With empty structure' => array( - 'data' => array( - 'slug' => 'open-sans', - 'nested' => array( - 'key1' => 'value', - 'nested2' => array( - 'key2' => 'value', - 'nested3' => array( - 'nested4' => array(), - ), - ), - ), - ), - 'schema' => array( - 'slug' => 'sanitize_title', - 'nested' => array( - 'key1' => 'sanitize_text_field', - 'nested2' => array( - 'key2' => 'sanitize_text_field', - 'nested3' => array( - 'key3' => 'sanitize_text_field', - 'nested4' => array( - 'key4' => 'sanitize_text_field', - ), - ), - ), - ), - ), - 'expected' => array( - 'slug' => 'open-sans', - 'nested' => array( - 'key1' => 'value', - 'nested2' => array( - 'key2' => 'value', - ), - ), - ), - ), - ); - } - - public function test_sanitize_from_schema_with_invalid_data() { - $data = 'invalid data'; - $schema = array( - 'key1' => 'sanitize_text_field', - 'key2' => 'sanitize_text_field', - ); - - $result = WP_Font_Utils::sanitize_from_schema( $data, $schema ); - - $this->assertSame( $result, array() ); - } - - - public function test_sanitize_from_schema_with_invalid_schema() { - $data = array( - 'key1' => 'value1', - 'key2' => 'value2', - ); - $schema = 'invalid schema'; - - $result = WP_Font_Utils::sanitize_from_schema( $data, $schema ); - - $this->assertSame( $result, array() ); - } -} diff --git a/phpunit/tests/fonts/font-library/wpFontsDir.php b/phpunit/tests/fonts/font-library/wpFontsDir.php deleted file mode 100644 index a8f79888315bdf..00000000000000 --- a/phpunit/tests/fonts/font-library/wpFontsDir.php +++ /dev/null @@ -1,72 +0,0 @@ - path_join( WP_CONTENT_DIR, 'fonts' ), - 'url' => content_url( 'fonts' ), - 'subdir' => '', - 'basedir' => path_join( WP_CONTENT_DIR, 'fonts' ), - 'baseurl' => content_url( 'fonts' ), - 'error' => false, - ); - } - - public function test_fonts_dir() { - $font_dir = wp_get_font_dir(); - - $this->assertSame( $font_dir, static::$dir_defaults ); - } - - public function test_fonts_dir_with_filter() { - // Define a callback function to pass to the filter. - function set_new_values( $defaults ) { - $defaults['path'] = '/custom-path/fonts/my-custom-subdir'; - $defaults['url'] = 'http://example.com/custom-path/fonts/my-custom-subdir'; - $defaults['subdir'] = 'my-custom-subdir'; - $defaults['basedir'] = '/custom-path/fonts'; - $defaults['baseurl'] = 'http://example.com/custom-path/fonts'; - $defaults['error'] = false; - return $defaults; - } - - // Add the filter. - add_filter( 'font_dir', 'set_new_values' ); - - // Gets the fonts dir. - $font_dir = wp_get_font_dir(); - - $expected = array( - 'path' => '/custom-path/fonts/my-custom-subdir', - 'url' => 'http://example.com/custom-path/fonts/my-custom-subdir', - 'subdir' => 'my-custom-subdir', - 'basedir' => '/custom-path/fonts', - 'baseurl' => 'http://example.com/custom-path/fonts', - 'error' => false, - ); - - // Remove the filter. - remove_filter( 'font_dir', 'set_new_values' ); - - $this->assertSame( $expected, $font_dir, 'The wp_get_font_dir() method should return the expected values.' ); - - // Gets the fonts dir. - $font_dir = wp_get_font_dir(); - - $this->assertSame( static::$dir_defaults, $font_dir, 'The wp_get_font_dir() method should return the default values.' ); - } -} diff --git a/phpunit/tests/fonts/font-library/wpRestFontCollectionsController.php b/phpunit/tests/fonts/font-library/wpRestFontCollectionsController.php index 60f50e503fdbe4..0f7fe8f9c662bb 100644 --- a/phpunit/tests/fonts/font-library/wpRestFontCollectionsController.php +++ b/phpunit/tests/fonts/font-library/wpRestFontCollectionsController.php @@ -19,6 +19,12 @@ class Tests_REST_WpRestFontCollectionsController extends WP_Test_REST_Controller public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) { + // Clear the font collections. + $collections = WP_Font_Library::get_instance()->get_font_collections(); + foreach ( $collections as $slug => $collection ) { + WP_Font_Library::get_instance()->unregister_font_collection( $slug ); + } + self::$admin_id = $factory->user->create( array( 'role' => 'administrator', @@ -115,7 +121,7 @@ public function test_get_item_invalid_slug() { wp_set_current_user( self::$admin_id ); $request = new WP_REST_Request( 'GET', '/wp/v2/font-collections/non-existing-collection' ); $response = rest_get_server()->dispatch( $request ); - $this->assertErrorResponse( 'font_collection_not_found', $response, 404 ); + $this->assertErrorResponse( 'rest_font_collection_not_found', $response, 404 ); } /** diff --git a/phpunit/tests/fonts/font-library/wpRestFontFacesController.php b/phpunit/tests/fonts/font-library/wpRestFontFacesController.php index 042928c73da3fe..2312ca6dabc6fd 100644 --- a/phpunit/tests/fonts/font-library/wpRestFontFacesController.php +++ b/phpunit/tests/fonts/font-library/wpRestFontFacesController.php @@ -362,7 +362,7 @@ public function test_create_item() { $files = $this->setup_font_file_upload( array( 'woff2' ) ); $request = new WP_REST_Request( 'POST', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces' ); - $request->set_param( 'theme_json_version', 2 ); + $request->set_param( 'theme_json_version', WP_REST_Font_Faces_Controller::LATEST_THEME_JSON_VERSION_SUPPORTED ); $request->set_param( 'font_face_settings', wp_json_encode( @@ -406,7 +406,7 @@ public function test_create_item_with_multiple_font_files() { $files = $this->setup_font_file_upload( array( 'ttf', 'otf', 'woff', 'woff2' ) ); $request = new WP_REST_Request( 'POST', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces' ); - $request->set_param( 'theme_json_version', 2 ); + $request->set_param( 'theme_json_version', WP_REST_Font_Faces_Controller::LATEST_THEME_JSON_VERSION_SUPPORTED ); $request->set_param( 'font_face_settings', wp_json_encode( @@ -452,7 +452,7 @@ public function test_create_item_invalid_file_type() { wp_set_current_user( self::$admin_id ); $request = new WP_REST_Request( 'POST', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces' ); - $request->set_param( 'theme_json_version', 2 ); + $request->set_param( 'theme_json_version', WP_REST_Font_Faces_Controller::LATEST_THEME_JSON_VERSION_SUPPORTED ); $request->set_param( 'font_face_settings', wp_json_encode( @@ -478,7 +478,7 @@ public function test_create_item_invalid_file_type() { public function test_create_item_with_url_src() { wp_set_current_user( self::$admin_id ); $request = new WP_REST_Request( 'POST', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces' ); - $request->set_param( 'theme_json_version', 2 ); + $request->set_param( 'theme_json_version', WP_REST_Font_Faces_Controller::LATEST_THEME_JSON_VERSION_SUPPORTED ); $request->set_param( 'font_face_settings', wp_json_encode( @@ -523,7 +523,7 @@ public function test_create_item_with_all_properties() { ); $request = new WP_REST_Request( 'POST', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces' ); - $request->set_param( 'theme_json_version', 2 ); + $request->set_param( 'theme_json_version', WP_REST_Font_Faces_Controller::LATEST_THEME_JSON_VERSION_SUPPORTED ); $request->set_param( 'font_face_settings', wp_json_encode( $properties ) ); $response = rest_get_server()->dispatch( $request ); @@ -597,7 +597,7 @@ public function test_create_item_default_theme_json_version() { $this->assertSame( 201, $response->get_status(), 'The response status should be 201.' ); $this->assertArrayHasKey( 'theme_json_version', $data, 'The theme_json_version property should exist in the response data.' ); - $this->assertSame( 2, $data['theme_json_version'], 'The default theme.json version should be 2.' ); + $this->assertSame( WP_REST_Font_Faces_Controller::LATEST_THEME_JSON_VERSION_SUPPORTED, $data['theme_json_version'], 'The default theme.json version should match the latest version supported by the controller.' ); } /** @@ -639,7 +639,7 @@ public function data_create_item_invalid_theme_json_version() { public function test_create_item_invalid_settings( $settings ) { wp_set_current_user( self::$admin_id ); $request = new WP_REST_Request( 'POST', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces' ); - $request->set_param( 'theme_json_version', 2 ); + $request->set_param( 'theme_json_version', WP_REST_Font_Faces_Controller::LATEST_THEME_JSON_VERSION_SUPPORTED ); $request->set_param( 'font_face_settings', wp_json_encode( $settings ) ); $response = rest_get_server()->dispatch( $request ); @@ -693,7 +693,7 @@ public function data_create_item_invalid_settings() { public function test_create_item_invalid_settings_json() { wp_set_current_user( self::$admin_id ); $request = new WP_REST_Request( 'POST', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces' ); - $request->set_param( 'theme_json_version', 2 ); + $request->set_param( 'theme_json_version', WP_REST_Font_Faces_Controller::LATEST_THEME_JSON_VERSION_SUPPORTED ); $request->set_param( 'font_face_settings', 'invalid' ); $response = rest_get_server()->dispatch( $request ); @@ -713,7 +713,7 @@ public function test_create_item_invalid_file_src() { wp_set_current_user( self::$admin_id ); $src = 'invalid'; $request = new WP_REST_Request( 'POST', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces' ); - $request->set_param( 'theme_json_version', 2 ); + $request->set_param( 'theme_json_version', WP_REST_Font_Faces_Controller::LATEST_THEME_JSON_VERSION_SUPPORTED ); $request->set_param( 'font_face_settings', wp_json_encode( @@ -738,7 +738,7 @@ public function test_create_item_missing_file_src() { wp_set_current_user( self::$admin_id ); $request = new WP_REST_Request( 'POST', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces' ); - $request->set_param( 'theme_json_version', 2 ); + $request->set_param( 'theme_json_version', WP_REST_Font_Faces_Controller::LATEST_THEME_JSON_VERSION_SUPPORTED ); $request->set_param( 'font_face_settings', wp_json_encode( @@ -974,9 +974,9 @@ public function test_get_item_schema() { $properties = $data['schema']['properties']; $this->assertCount( 4, $properties, 'There should be 4 properties in the schema::properties data.' ); $this->assertArrayHasKey( 'id', $properties, 'The id property should exist in the schema::properties data.' ); - $this->assertArrayHasKey( 'theme_json_version', $properties, 'The id property should exist in the schema::properties data.' ); - $this->assertArrayHasKey( 'parent', $properties, 'The id property should exist in the schema::properties data.' ); - $this->assertArrayHasKey( 'font_face_settings', $properties, 'The id property should exist in the schema::properties data.' ); + $this->assertArrayHasKey( 'theme_json_version', $properties, 'The theme_json_version property should exist in the schema::properties data.' ); + $this->assertArrayHasKey( 'parent', $properties, 'The parent property should exist in the schema::properties data.' ); + $this->assertArrayHasKey( 'font_face_settings', $properties, 'The font_face_settings property should exist in the schema::properties data.' ); } /** @@ -1013,6 +1013,16 @@ public function test_get_public_item_schema_should_not_have_arg_options() { } } + + /** + * If WP_Theme_JSON::LATEST_SCHEMA is changed, the controller should be updated to handle any differences + * in `fontFace` structure to ensure support for the latest theme.json schema, and backwards compatibility + * for existing wp_font_face posts. + */ + public function test_controller_supports_latest_theme_json_version() { + $this->assertSame( WP_Theme_JSON::LATEST_SCHEMA, WP_REST_Font_Faces_Controller::LATEST_THEME_JSON_VERSION_SUPPORTED ); + } + protected function check_font_face_data( $data, $post_id, $links ) { self::$post_ids_for_cleanup[] = $post_id; $post = get_post( $post_id ); @@ -1024,7 +1034,7 @@ protected function check_font_face_data( $data, $post_id, $links ) { $this->assertSame( $post->post_parent, $data['parent'], 'The "parent" from the response data should match the post parent.' ); $this->assertArrayHasKey( 'theme_json_version', $data, 'The theme_json_version property should exist in response data.' ); - $this->assertSame( WP_Theme_JSON::LATEST_SCHEMA, $data['theme_json_version'], 'The "theme_json_version" from the response data should match WP_Theme_JSON::LATEST_SCHEMA.' ); + $this->assertSame( WP_REST_Font_Faces_Controller::LATEST_THEME_JSON_VERSION_SUPPORTED, $data['theme_json_version'], 'The "theme_json_version" from the response data should match the latest version supported by the controller.' ); $this->assertArrayHasKey( 'font_face_settings', $data, 'The font_face_settings property should exist in response data.' ); $this->assertSame( $post->post_content, wp_json_encode( $data['font_face_settings'] ), 'The encoded "font_face_settings" from the response data should match the post content.' ); diff --git a/phpunit/tests/fonts/font-library/wpRestFontFamiliesController.php b/phpunit/tests/fonts/font-library/wpRestFontFamiliesController.php index 94ad5eccd7e570..300be122f14c1f 100644 --- a/phpunit/tests/fonts/font-library/wpRestFontFamiliesController.php +++ b/phpunit/tests/fonts/font-library/wpRestFontFamiliesController.php @@ -376,7 +376,7 @@ public function test_create_item() { wp_set_current_user( self::$admin_id ); $request = new WP_REST_Request( 'POST', '/wp/v2/font-families' ); - $request->set_param( 'theme_json_version', 2 ); + $request->set_param( 'theme_json_version', WP_REST_Font_Families_Controller::LATEST_THEME_JSON_VERSION_SUPPORTED ); $request->set_param( 'font_family_settings', wp_json_encode( $settings ) ); $response = rest_get_server()->dispatch( $request ); $data = $response->get_data(); @@ -405,7 +405,7 @@ public function test_create_item_default_theme_json_version() { $this->assertSame( 201, $response->get_status(), 'The response status should be 201.' ); $this->assertArrayHasKey( 'theme_json_version', $data, 'The theme_json_version property should exist in the response data.' ); - $this->assertSame( 2, $data['theme_json_version'], 'The default theme.json version should be 2.' ); + $this->assertSame( WP_REST_Font_Families_Controller::LATEST_THEME_JSON_VERSION_SUPPORTED, $data['theme_json_version'], 'The default theme.json version should match the latest version supported by the controller.' ); } /** @@ -447,7 +447,7 @@ public function data_create_item_invalid_theme_json_version() { public function test_create_item_with_default_preview( $settings ) { wp_set_current_user( self::$admin_id ); $request = new WP_REST_Request( 'POST', '/wp/v2/font-families' ); - $request->set_param( 'theme_json_version', 2 ); + $request->set_param( 'theme_json_version', WP_REST_Font_Families_Controller::LATEST_THEME_JSON_VERSION_SUPPORTED ); $request->set_param( 'font_family_settings', wp_json_encode( $settings ) ); $response = rest_get_server()->dispatch( $request ); $data = $response->get_data(); @@ -569,7 +569,7 @@ public function data_sanitize_font_family_settings() { public function test_create_item_invalid_settings( $settings ) { wp_set_current_user( self::$admin_id ); $request = new WP_REST_Request( 'POST', '/wp/v2/font-families' ); - $request->set_param( 'theme_json_version', 2 ); + $request->set_param( 'theme_json_version', WP_REST_Font_Families_Controller::LATEST_THEME_JSON_VERSION_SUPPORTED ); $request->set_param( 'font_family_settings', wp_json_encode( $settings ) ); $response = rest_get_server()->dispatch( $request ); @@ -619,7 +619,7 @@ public function data_create_item_invalid_settings() { public function test_create_item_invalid_settings_json() { wp_set_current_user( self::$admin_id ); $request = new WP_REST_Request( 'POST', '/wp/v2/font-families' ); - $request->set_param( 'theme_json_version', 2 ); + $request->set_param( 'theme_json_version', WP_REST_Font_Families_Controller::LATEST_THEME_JSON_VERSION_SUPPORTED ); $request->set_param( 'font_family_settings', 'invalid' ); $response = rest_get_server()->dispatch( $request ); @@ -636,7 +636,7 @@ public function test_create_item_invalid_settings_json() { public function test_create_item_with_duplicate_slug() { wp_set_current_user( self::$admin_id ); $request = new WP_REST_Request( 'POST', '/wp/v2/font-families' ); - $request->set_param( 'theme_json_version', 2 ); + $request->set_param( 'theme_json_version', WP_REST_Font_Families_Controller::LATEST_THEME_JSON_VERSION_SUPPORTED ); $request->set_param( 'font_family_settings', wp_json_encode( array_merge( self::$default_settings, array( 'slug' => 'helvetica' ) ) ) ); $response = rest_get_server()->dispatch( $request ); @@ -990,6 +990,15 @@ public function test_get_public_item_schema_should_not_have_arg_options() { } } + /** + * If WP_Theme_JSON::LATEST_SCHEMA is changed, the controller should be updated to handle any differences + * in `fontFamilies` structure to ensure support for the latest theme.json schema, and backwards compatibility + * for existing wp_font_family posts. + */ + public function test_controller_supports_latest_theme_json_version() { + $this->assertSame( WP_Theme_JSON::LATEST_SCHEMA, WP_REST_Font_Families_Controller::LATEST_THEME_JSON_VERSION_SUPPORTED ); + } + protected function check_font_family_data( $data, $post_id, $links ) { static::$post_ids_to_cleanup[] = $post_id; $post = get_post( $post_id ); @@ -998,7 +1007,7 @@ protected function check_font_family_data( $data, $post_id, $links ) { $this->assertSame( $post->ID, $data['id'], 'The "id" from the response data should match the post ID.' ); $this->assertArrayHasKey( 'theme_json_version', $data, 'The theme_json_version property should exist in response data.' ); - $this->assertSame( WP_Theme_JSON::LATEST_SCHEMA, $data['theme_json_version'], 'The "theme_json_version" from the response data should match WP_Theme_JSON::LATEST_SCHEMA.' ); + $this->assertSame( WP_REST_Font_Families_Controller::LATEST_THEME_JSON_VERSION_SUPPORTED, $data['theme_json_version'], 'The "theme_json_version" from the response data should match the latest version supported by the controller.' ); $font_face_ids = get_children( array( diff --git a/test/e2e/specs/editor/various/inserting-blocks.spec.js b/test/e2e/specs/editor/various/inserting-blocks.spec.js index a000f02eaca8c2..a247f482020882 100644 --- a/test/e2e/specs/editor/various/inserting-blocks.spec.js +++ b/test/e2e/specs/editor/various/inserting-blocks.spec.js @@ -386,6 +386,284 @@ test.describe( 'Inserting blocks (@firefox, @webkit)', () => { await expect( blockLibrary ).toBeHidden(); } ); + + test( 'should insert block with the slash inserter when using multiple words', async ( { + admin, + editor, + page, + } ) => { + await admin.createNewPost(); + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( '/tag cloud' ); + + await expect( + page.getByRole( 'option', { name: 'Tag Cloud', selected: true } ) + ).toBeVisible(); + await page.keyboard.press( 'Enter' ); + + await expect( + editor.canvas.getByRole( 'document', { name: 'Block: Tag Cloud' } ) + ).toBeVisible(); + } ); + + // Check for regression of https://github.com/WordPress/gutenberg/issues/24262. + test( 'inserts a block in proper place after having clicked `Browse All` from inline inserter', async ( { + admin, + editor, + page, + } ) => { + await admin.createNewPost(); + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( 'First paragraph' ); + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( '## Heading' ); + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( 'Second paragraph' ); + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( 'Third paragraph' ); + + const boundingBox = await editor.canvas + .getByRole( 'document', { name: 'Block: Heading' } ) + .boundingBox(); + + // Using the between inserter. + await page.mouse.move( + boundingBox.x + boundingBox.width / 2, + boundingBox.y - 10, + // An arbitrary number of `steps` imitates cursor movement in the test environment, + // activating the in-between inserter. + { steps: 10 } + ); + + await page + .getByRole( 'button', { + name: 'Add block', + } ) + .click(); + await page.getByRole( 'button', { name: 'Browse All' } ).click(); + await page + .getByRole( 'listbox', { name: 'Media' } ) + .getByRole( 'option', { name: 'Image' } ) + .click(); + + await expect + .poll( editor.getBlocks ) + .toMatchObject( [ + { name: 'core/paragraph' }, + { name: 'core/image' }, + { name: 'core/heading' }, + { name: 'core/paragraph' }, + { name: 'core/paragraph' }, + ] ); + } ); + + // Check for regression of https://github.com/WordPress/gutenberg/issues/25785. + test( 'inserts a block should show a blue line indicator', async ( { + admin, + editor, + page, + insertingBlocksUtils, + } ) => { + await admin.createNewPost(); + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( 'First paragraph' ); + await editor.insertBlock( { name: 'core/image' } ); + + const paragraphBlock = editor.canvas.getByRole( 'document', { + name: 'Block: Paragraph', + } ); + + await editor.selectBlocks( paragraphBlock ); + await page + .getByRole( 'toolbar', { name: 'Document tools' } ) + .getByRole( 'button', { name: 'Toggle block inserter' } ) + .click(); + await page + .getByRole( 'listbox', { name: 'Text' } ) + .getByRole( 'option', { name: 'Heading' } ) + .hover(); + + await expect( insertingBlocksUtils.indicator ).toBeVisible(); + + const paragraphBoundingBox = await paragraphBlock.boundingBox(); + const indicatorBoundingBox = + await insertingBlocksUtils.indicator.boundingBox(); + + // Expect the indicator to be below the selected block. + expect( indicatorBoundingBox.y ).toBeGreaterThan( + paragraphBoundingBox.y + ); + } ); + + // Check for regression of https://github.com/WordPress/gutenberg/issues/24403. + test( 'inserts a block in proper place after having clicked `Browse All` from block appender', async ( { + admin, + editor, + page, + } ) => { + await admin.createNewPost(); + await editor.insertBlock( { name: 'core/group' } ); + await editor.insertBlock( { + name: 'core/paragraph', + attributes: { content: 'Paragraph after group' }, + } ); + + await editor.canvas + .getByRole( 'button', { + name: 'Group: Gather blocks in a container.', + } ) + .click(); + await editor.canvas + .getByRole( 'button', { + name: 'Add block', + } ) + .click(); + await page.getByRole( 'button', { name: 'Browse All' } ).click(); + await page + .getByRole( 'listbox', { name: 'Text' } ) + .getByRole( 'option', { name: 'Paragraph' } ) + .click(); + await editor.canvas + .getByRole( 'document', { name: 'Empty block' } ) + .fill( 'Paragraph inside group' ); + + await expect.poll( editor.getBlocks ).toMatchObject( [ + { + name: 'core/group', + innerBlocks: [ + { + name: 'core/paragraph', + attributes: { content: 'Paragraph inside group' }, + }, + ], + }, + { + name: 'core/paragraph', + attributes: { content: 'Paragraph after group' }, + }, + ] ); + } ); + + test( 'passes the search value in the main inserter when clicking `Browse all`', async ( { + admin, + editor, + page, + } ) => { + await admin.createNewPost(); + await editor.insertBlock( { name: 'core/group' } ); + await editor.insertBlock( { + name: 'core/paragraph', + attributes: { content: 'Paragraph after group' }, + } ); + + await editor.canvas + .getByRole( 'button', { + name: 'Group: Gather blocks in a container.', + } ) + .click(); + await editor.canvas + .getByRole( 'button', { + name: 'Add block', + } ) + .click(); + + const searchBox = page.getByRole( 'searchbox', { + name: 'Search for blocks and patterns', + } ); + + await searchBox.fill( 'Verse' ); + await page.getByRole( 'button', { name: 'Browse All' } ).click(); + + await expect( searchBox ).toHaveValue( 'Verse' ); + await expect( + page.getByRole( 'listbox', { name: 'Blocks' } ) + ).toHaveCount( 1 ); + } ); + + // Check for regression of https://github.com/WordPress/gutenberg/issues/27586. + test( 'can close the main inserter after inserting a single-use block, like the More block', async ( { + admin, + editor, + page, + } ) => { + await admin.createNewPost(); + await page + .getByRole( 'toolbar', { name: 'Document tools' } ) + .getByRole( 'button', { name: 'Toggle block inserter' } ) + .click(); + await page.getByRole( 'option', { name: 'More', exact: true } ).click(); + + // Moving focus to the More block should close the inserter. + await editor.canvas + .getByRole( 'textbox', { name: 'Read more' } ) + .fill( 'More' ); + await expect( + page.getByRole( 'region', { + name: 'Block Library', + } ) + ).toBeHidden(); + } ); + + test( 'shows block preview when hovering over block in inserter', async ( { + admin, + page, + } ) => { + await admin.createNewPost(); + await page + .getByRole( 'toolbar', { name: 'Document tools' } ) + .getByRole( 'button', { name: 'Toggle block inserter' } ) + .click(); + await page + .getByRole( 'listbox', { name: 'Text' } ) + .getByRole( 'option', { name: 'Paragraph' } ) + .hover(); + + await expect( + page.locator( '.block-editor-inserter__preview' ) + ).toBeInViewport(); + } ); + + [ 'large', 'small' ].forEach( ( viewport ) => { + test( `last-inserted block should be given and keep the selection (${ viewport } viewport)`, async ( { + admin, + editor, + page, + pageUtils, + } ) => { + await pageUtils.setBrowserViewport( viewport ); + await admin.createNewPost(); + await editor.insertBlock( { + name: 'core/paragraph', + attributes: { content: 'Testing inserted block selection' }, + } ); + + await page + .getByRole( 'toolbar', { name: 'Document tools' } ) + .getByRole( 'button', { name: 'Toggle block inserter' } ) + .click(); + await page + .getByRole( 'listbox', { name: 'Media' } ) + .getByRole( 'option', { name: 'Image' } ) + .click(); + + await expect( + editor.canvas.getByRole( 'document', { name: 'Block: Image' } ) + ).toBeVisible(); + await expect + .poll( () => + page.evaluate( + () => + window.wp.data + .select( 'core/block-editor' ) + .getSelectedBlock()?.name + ) + ) + .toBe( 'core/image' ); + + // Restore the viewport. + await pageUtils.setBrowserViewport( 'large' ); + } ); + } ); } ); test.describe( 'insert media from inserter', () => { diff --git a/test/e2e/specs/editor/various/keyboard-navigable-blocks.spec.js b/test/e2e/specs/editor/various/keyboard-navigable-blocks.spec.js index 84536c88227ce9..51b08359d12698 100644 --- a/test/e2e/specs/editor/various/keyboard-navigable-blocks.spec.js +++ b/test/e2e/specs/editor/various/keyboard-navigable-blocks.spec.js @@ -12,8 +12,9 @@ test.use( { } ); test.describe( 'Order of block keyboard navigation', () => { - test.beforeEach( async ( { admin } ) => { + test.beforeEach( async ( { admin, editor } ) => { await admin.createNewPost(); + await editor.openDocumentSettingsSidebar(); } ); test( 'permits tabbing through paragraph blocks in the expected order', async ( { diff --git a/test/e2e/specs/editor/various/navigable-toolbar.spec.js b/test/e2e/specs/editor/various/navigable-toolbar.spec.js index 6f9ed2a37a6e41..4cfc3f9c210086 100644 --- a/test/e2e/specs/editor/various/navigable-toolbar.spec.js +++ b/test/e2e/specs/editor/various/navigable-toolbar.spec.js @@ -273,20 +273,14 @@ test.describe( 'Block Toolbar', () => { } ); // Make sure it's in an acvite state for now - await expect( blockToolbarMoveUpButton ).not.toHaveAttribute( - 'aria-disabled', - 'true' - ); + await expect( blockToolbarMoveUpButton ).toBeEnabled(); await expect( blockToolbarMoveUpButton ).toBeFocused(); await pageUtils.pressKeys( 'Enter' ); await expect( blockToolbarMoveUpButton ).toBeFocused(); await pageUtils.pressKeys( 'Enter' ); await expect( blockToolbarMoveUpButton ).toBeFocused(); - await expect( blockToolbarMoveUpButton ).toHaveAttribute( - 'aria-disabled', - 'true' - ); + await expect( blockToolbarMoveUpButton ).toBeDisabled(); // Check to make sure focus returns to the Move Up button roving index after all of this await pageUtils.pressKeys( 'Tab' ); diff --git a/test/e2e/specs/editor/various/pattern-overrides.spec.js b/test/e2e/specs/editor/various/pattern-overrides.spec.js index c5cb9d2599170a..ba619b66fc474a 100644 --- a/test/e2e/specs/editor/various/pattern-overrides.spec.js +++ b/test/e2e/specs/editor/various/pattern-overrides.spec.js @@ -493,4 +493,95 @@ test.describe( 'Pattern Overrides', () => { page.getByText( 'Inner paragraph (edited)' ) ).toBeVisible(); } ); + + test( 'resets overrides after clicking the reset button', async ( { + page, + admin, + requestUtils, + editor, + } ) => { + const headingId = 'heading-id'; + const paragraphId = 'paragraph-id'; + const { id } = await requestUtils.createBlock( { + title: 'Pattern', + content: ` +

Heading

+ + +

Paragraph

+`, + status: 'publish', + } ); + + await admin.createNewPost(); + + await editor.insertBlock( { + name: 'core/block', + attributes: { ref: id }, + } ); + + // Make an edit to the heading. + await editor.canvas + .getByRole( 'document', { name: 'Block: Heading' } ) + .fill( 'Heading (edited)' ); + + const patternBlock = editor.canvas.getByRole( 'document', { + name: 'Block: Pattern', + } ); + const headingBlock = patternBlock.getByRole( 'document', { + name: 'Block: Heading', + } ); + const paragraphBlock = patternBlock.getByRole( 'document', { + name: 'Block: Paragraph', + } ); + const resetButton = page + .getByRole( 'toolbar', { name: 'Block tools' } ) + .getByRole( 'button', { name: 'Reset' } ); + + // Assert the pattern block. + await editor.selectBlocks( patternBlock ); + await editor.showBlockToolbar(); + await expect( + resetButton, + 'The pattern block should have the reset button enabled' + ).toBeEnabled(); + + // Assert the modified heading block with overrides. + await editor.selectBlocks( headingBlock ); + await editor.showBlockToolbar(); + await expect( + resetButton, + 'The heading block should have the reset button enabled' + ).toBeEnabled(); + + // Assert the unmodified paragraph block (no overrides). + await editor.selectBlocks( paragraphBlock ); + await editor.showBlockToolbar(); + await expect( + resetButton, + 'The paragraph block should not have the reset button enabled' + ).toBeDisabled(); + + // Reset the whole pattern. + await editor.selectBlocks( patternBlock ); + await editor.showBlockToolbar(); + await resetButton.click(); + await expect( headingBlock ).toHaveText( 'Heading' ); + + // Undo should work + await page + .getByRole( 'toolbar', { name: 'Document tools' } ) + .getByRole( 'button', { name: 'Undo' } ) + .click(); + await expect( headingBlock ).toHaveText( 'Heading (edited)' ); + + // Reset the individual heading block. + await editor.selectBlocks( headingBlock ); + await editor.showBlockToolbar(); + await resetButton.click(); + await expect( headingBlock ).toHaveText( 'Heading' ); + await editor.selectBlocks( patternBlock ); + await editor.showBlockToolbar(); + await expect( resetButton ).toBeDisabled(); + } ); } ); diff --git a/test/integration/fixtures/blocks/core__avatar.json b/test/integration/fixtures/blocks/core__avatar.json index 6d21f8bebfc7b0..9c1aeef62009c3 100644 --- a/test/integration/fixtures/blocks/core__avatar.json +++ b/test/integration/fixtures/blocks/core__avatar.json @@ -7,8 +7,8 @@ "size": 85, "isLink": true, "linkTarget": "_self", - "align": "right", "borderColor": "vivid-red", + "align": "right", "style": { "spacing": { "margin": { diff --git a/test/integration/fixtures/blocks/core__avatar.serialized.html b/test/integration/fixtures/blocks/core__avatar.serialized.html index 6965019bf96dbe..260cacb274b57e 100644 --- a/test/integration/fixtures/blocks/core__avatar.serialized.html +++ b/test/integration/fixtures/blocks/core__avatar.serialized.html @@ -1 +1 @@ - +