diff --git a/docs/getting-started/fundamentals/javascript-in-the-block-editor.md b/docs/getting-started/fundamentals/javascript-in-the-block-editor.md index 73c6a6c56e632..615f7f74ce151 100644 --- a/docs/getting-started/fundamentals/javascript-in-the-block-editor.md +++ b/docs/getting-started/fundamentals/javascript-in-the-block-editor.md @@ -44,7 +44,7 @@ Use [`enqueue_block_editor_assets`](https://developer.wordpress.org/reference/ho - [Get started with wp-scripts](https://developer.wordpress.org/block-editor/getting-started/devenv/get-started-with-wp-scripts/) - [Enqueueing assets in the Editor](https://developer.wordpress.org/block-editor/how-to-guides/enqueueing-assets-in-the-editor/) -- [Wordpress Packages handles](https://developer.wordpress.org/block-editor/contributors/code/scripts/) +- [WordPress Packages handles](https://developer.wordpress.org/block-editor/contributors/code/scripts/) - [Javascript Reference](https://developer.mozilla.org/en-US/docs/Web/JavaScript) | MDN Web Docs - [block-development-examples](https://github.com/WordPress/block-development-examples) | GitHub repository - [block-theme-examples](https://github.com/wptrainingteam/block-theme-examples) | GitHub repository diff --git a/docs/reference-guides/block-api/block-metadata.md b/docs/reference-guides/block-api/block-metadata.md index edc61d138128e..d023742092df1 100644 --- a/docs/reference-guides/block-api/block-metadata.md +++ b/docs/reference-guides/block-api/block-metadata.md @@ -197,7 +197,7 @@ The `ancestor` property makes a block available inside the specified block types { "icon": "smile" } ``` -An icon property should be specified to make it easier to identify a block. These can be any of WordPress' Dashicons (slug serving also as a fallback in non-js contexts). +An icon property should be specified to make it easier to identify a block. These can be any of [WordPress' Dashicons](https://developer.wordpress.org/resource/dashicons/) (slug serving also as a fallback in non-js contexts). **Note:** It's also possible to override this property on the client-side with the source of the SVG element. In addition, this property can be defined with JavaScript as an object containing background and foreground colors. This colors will appear with the icon when they are applicable e.g.: in the inserter. Custom SVG icons are automatically wrapped in the [wp.primitives.SVG](/packages/primitives/README.md) component to add accessibility attributes (aria-hidden, role, and focusable). diff --git a/docs/reference-guides/data/data-core-editor.md b/docs/reference-guides/data/data-core-editor.md index fae5b8a78e2cf..f6086090f9b54 100644 --- a/docs/reference-guides/data/data-core-editor.md +++ b/docs/reference-guides/data/data-core-editor.md @@ -268,6 +268,18 @@ _Returns_ - `string?`: Template ID. +### getDeviceType + +Returns the current editing canvas device type. + +_Parameters_ + +- _state_ `Object`: Global application state. + +_Returns_ + +- `string`: Device type. + ### getEditedPostAttribute Returns a single attribute of the post being edited, preferring the unsaved edit if one exists, but falling back to the attribute for the last known saved state of the post. @@ -1261,6 +1273,31 @@ _Related_ - selectBlock in core/block-editor store. +### setDeviceType + +Action that changes the width of the editing canvas. + +_Parameters_ + +- _deviceType_ `string`: + +_Returns_ + +- `Object`: Action object. + +### setEditedPost + +Returns an action that sets the current post Type and post ID. + +_Parameters_ + +- _postType_ `string`: Post Type. +- _postId_ `string`: Post ID. + +_Returns_ + +- `Object`: Action object. + ### setRenderingMode Returns an action used to set the rendering mode of the post editor. We support multiple rendering modes: @@ -1292,16 +1329,14 @@ _Parameters_ ### setupEditorState -Returns an action object used to setup the editor state when first opening an editor. +> **Deprecated** + +Setup the editor state. _Parameters_ - _post_ `Object`: Post object. -_Returns_ - -- `Object`: Action object. - ### showInsertionPoint _Related_ diff --git a/lib/compat/wordpress-6.5/html-api/class-gutenberg-html-attribute-token-6-5.php b/lib/compat/wordpress-6.5/html-api/class-gutenberg-html-attribute-token-6-5.php new file mode 100644 index 0000000000000..70359ea339d66 --- /dev/null +++ b/lib/compat/wordpress-6.5/html-api/class-gutenberg-html-attribute-token-6-5.php @@ -0,0 +1,116 @@ + + * ------------ length is 12, including quotes + * + * + * ------- length is 6 + * + * + * ------------ length is 11 + * + * @since 6.5.0 Replaced `end` with `length` to more closely match `substr()`. + * + * @var int + */ + public $length; + + /** + * Whether the attribute is a boolean attribute with value `true`. + * + * @since 6.2.0 + * + * @var bool + */ + public $is_true; + + /** + * Constructor. + * + * @since 6.2.0 + * @since 6.5.0 Replaced `end` with `length` to more closely match `substr()`. + * + * @param string $name Attribute name. + * @param int $value_start Attribute value. + * @param int $value_length Number of bytes attribute value spans. + * @param int $start The string offset where the attribute name starts. + * @param int $length Byte length of the entire attribute name or name and value pair expression. + * @param bool $is_true Whether the attribute is a boolean attribute with true value. + */ + public function __construct( $name, $value_start, $value_length, $start, $length, $is_true ) { + $this->name = $name; + $this->value_starts_at = $value_start; + $this->value_length = $value_length; + $this->start = $start; + $this->length = $length; + $this->is_true = $is_true; + } +} diff --git a/lib/compat/wordpress-6.5/html-api/class-gutenberg-html-span-6-5.php b/lib/compat/wordpress-6.5/html-api/class-gutenberg-html-span-6-5.php new file mode 100644 index 0000000000000..ed596f1266ab5 --- /dev/null +++ b/lib/compat/wordpress-6.5/html-api/class-gutenberg-html-span-6-5.php @@ -0,0 +1,56 @@ +start = $start; + $this->length = $length; + } +} 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 new file mode 100644 index 0000000000000..5594110f0d1c8 --- /dev/null +++ b/lib/compat/wordpress-6.5/html-api/class-gutenberg-html-tag-processor-6-5.php @@ -0,0 +1,2483 @@ + "c" not " c". + * This would increase the size of the changes for some operations but leave more + * natural-looking output HTML. + * - Decode HTML character references within class names when matching. E.g. match having + * class `1<"2` needs to recognize `class="1<"2"`. Currently the Tag Processor + * will fail to find the right tag if the class name is encoded as such. + * - Properly decode HTML character references in `get_attribute()`. PHP's + * `html_entity_decode()` is wrong in a couple ways: it doesn't account for the + * no-ambiguous-ampersand rule, and it improperly handles the way semicolons may + * or may not terminate a character reference. + * + * @package WordPress + * @subpackage HTML-API + * @since 6.2.0 + */ + +/** + * Core class used to modify attributes in an HTML document for tags matching a query. + * + * ## Usage + * + * Use of this class requires three steps: + * + * 1. Create a new class instance with your input HTML document. + * 2. Find the tag(s) you are looking for. + * 3. Request changes to the attributes in those tag(s). + * + * Example: + * + * $tags = new WP_HTML_Tag_Processor( $html ); + * if ( $tags->next_tag( 'option' ) ) { + * $tags->set_attribute( 'selected', true ); + * } + * + * ### Finding tags + * + * The `next_tag()` function moves the internal cursor through + * your input HTML document until it finds a tag meeting any of + * the supplied restrictions in the optional query argument. If + * no argument is provided then it will find the next HTML tag, + * regardless of what kind it is. + * + * If you want to _find whatever the next tag is_: + * + * $tags->next_tag(); + * + * | Goal | Query | + * |-----------------------------------------------------------|---------------------------------------------------------------------------------| + * | Find any tag. | `$tags->next_tag();` | + * | Find next image tag. | `$tags->next_tag( array( 'tag_name' => 'img' ) );` | + * | Find next image tag (without passing the array). | `$tags->next_tag( 'img' );` | + * | Find next tag containing the `fullwidth` CSS class. | `$tags->next_tag( array( 'class_name' => 'fullwidth' ) );` | + * | Find next image tag containing the `fullwidth` CSS class. | `$tags->next_tag( array( 'tag_name' => 'img', 'class_name' => 'fullwidth' ) );` | + * + * If a tag was found meeting your criteria then `next_tag()` + * will return `true` and you can proceed to modify it. If it + * returns `false`, however, it failed to find the tag and + * moved the cursor to the end of the file. + * + * Once the cursor reaches the end of the file the processor + * is done and if you want to reach an earlier tag you will + * need to recreate the processor and start over, as it's + * unable to back up or move in reverse. + * + * See the section on bookmarks for an exception to this + * no-backing-up rule. + * + * #### Custom queries + * + * Sometimes it's necessary to further inspect an HTML tag than + * the query syntax here permits. In these cases one may further + * inspect the search results using the read-only functions + * provided by the processor or external state or variables. + * + * Example: + * + * // Paint up to the first five DIV or SPAN tags marked with the "jazzy" style. + * $remaining_count = 5; + * while ( $remaining_count > 0 && $tags->next_tag() ) { + * if ( + * ( 'DIV' === $tags->get_tag() || 'SPAN' === $tags->get_tag() ) && + * 'jazzy' === $tags->get_attribute( 'data-style' ) + * ) { + * $tags->add_class( 'theme-style-everest-jazz' ); + * $remaining_count--; + * } + * } + * + * `get_attribute()` will return `null` if the attribute wasn't present + * on the tag when it was called. It may return `""` (the empty string) + * in cases where the attribute was present but its value was empty. + * For boolean attributes, those whose name is present but no value is + * given, it will return `true` (the only way to set `false` for an + * attribute is to remove it). + * + * ### Modifying HTML attributes for a found tag + * + * Once you've found the start of an opening tag you can modify + * any number of the attributes on that tag. You can set a new + * value for an attribute, remove the entire attribute, or do + * nothing and move on to the next opening tag. + * + * Example: + * + * if ( $tags->next_tag( array( 'class_name' => 'wp-group-block' ) ) ) { + * $tags->set_attribute( 'title', 'This groups the contained content.' ); + * $tags->remove_attribute( 'data-test-id' ); + * } + * + * If `set_attribute()` is called for an existing attribute it will + * overwrite the existing value. Similarly, calling `remove_attribute()` + * for a non-existing attribute has no effect on the document. Both + * of these methods are safe to call without knowing if a given attribute + * exists beforehand. + * + * ### Modifying CSS classes for a found tag + * + * The tag processor treats the `class` attribute as a special case. + * Because it's a common operation to add or remove CSS classes, this + * interface adds helper methods to make that easier. + * + * As with attribute values, adding or removing CSS classes is a safe + * operation that doesn't require checking if the attribute or class + * exists before making changes. If removing the only class then the + * entire `class` attribute will be removed. + * + * Example: + * + * // from `Yippee!` + * // to `Yippee!` + * $tags->add_class( 'is-active' ); + * + * // from `Yippee!` + * // to `Yippee!` + * $tags->add_class( 'is-active' ); + * + * // from `Yippee!` + * // to `Yippee!` + * $tags->add_class( 'is-active' ); + * + * // from `` + * // to ` + * $tags->remove_class( 'rugby' ); + * + * // from `` + * // to ` + * $tags->remove_class( 'rugby' ); + * + * // from `` + * // to ` + * $tags->remove_class( 'rugby' ); + * + * When class changes are enqueued but a direct change to `class` is made via + * `set_attribute` then the changes to `set_attribute` (or `remove_attribute`) + * will take precedence over those made through `add_class` and `remove_class`. + * + * ### Bookmarks + * + * While scanning through the input HTMl document it's possible to set + * a named bookmark when a particular tag is found. Later on, after + * continuing to scan other tags, it's possible to `seek` to one of + * the set bookmarks and then proceed again from that point forward. + * + * Because bookmarks create processing overhead one should avoid + * creating too many of them. As a rule, create only bookmarks + * of known string literal names; avoid creating "mark_{$index}" + * and so on. It's fine from a performance standpoint to create a + * bookmark and update it frequently, such as within a loop. + * + * $total_todos = 0; + * while ( $p->next_tag( array( 'tag_name' => 'UL', 'class_name' => 'todo' ) ) ) { + * $p->set_bookmark( 'list-start' ); + * while ( $p->next_tag( array( 'tag_closers' => 'visit' ) ) ) { + * if ( 'UL' === $p->get_tag() && $p->is_tag_closer() ) { + * $p->set_bookmark( 'list-end' ); + * $p->seek( 'list-start' ); + * $p->set_attribute( 'data-contained-todos', (string) $total_todos ); + * $total_todos = 0; + * $p->seek( 'list-end' ); + * break; + * } + * + * if ( 'LI' === $p->get_tag() && ! $p->is_tag_closer() ) { + * $total_todos++; + * } + * } + * } + * + * ## Design and limitations + * + * The Tag Processor is designed to linearly scan HTML documents and tokenize + * HTML tags and their attributes. It's designed to do this as efficiently as + * possible without compromising parsing integrity. Therefore it will be + * slower than some methods of modifying HTML, such as those incorporating + * over-simplified PCRE patterns, but will not introduce the defects and + * failures that those methods bring in, which lead to broken page renders + * and often to security vulnerabilities. On the other hand, it will be faster + * than full-blown HTML parsers such as DOMDocument and use considerably + * less memory. It requires a negligible memory overhead, enough to consider + * it a zero-overhead system. + * + * The performance characteristics are maintained by avoiding tree construction + * and semantic cleanups which are specified in HTML5. Because of this, for + * example, it's not possible for the Tag Processor to associate any given + * opening tag with its corresponding closing tag, or to return the inner markup + * inside an element. Systems may be built on top of the Tag Processor to do + * this, but the Tag Processor is and should be constrained so it can remain an + * efficient, low-level, and reliable HTML scanner. + * + * The Tag Processor's design incorporates a "garbage-in-garbage-out" philosophy. + * HTML5 specifies that certain invalid content be transformed into different forms + * for display, such as removing null bytes from an input document and replacing + * invalid characters with the Unicode replacement character `U+FFFD` (visually "�"). + * Where errors or transformations exist within the HTML5 specification, the Tag Processor + * leaves those invalid inputs untouched, passing them through to the final browser + * to handle. While this implies that certain operations will be non-spec-compliant, + * such as reading the value of an attribute with invalid content, it also preserves a + * simplicity and efficiency for handling those error cases. + * + * Most operations within the Tag Processor are designed to minimize the difference + * between an input and output document for any given change. For example, the + * `add_class` and `remove_class` methods preserve whitespace and the class ordering + * within the `class` attribute; and when encountering tags with duplicated attributes, + * the Tag Processor will leave those invalid duplicate attributes where they are but + * update the proper attribute which the browser will read for parsing its value. An + * exception to this rule is that all attribute updates store their values as + * double-quoted strings, meaning that attributes on input with single-quoted or + * unquoted values will appear in the output with double-quotes. + * + * @since 6.2.0 + * @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. + */ +class Gutenberg_HTML_Tag_Processor_6_5 { + /** + * The maximum number of bookmarks allowed to exist at + * any given time. + * + * @since 6.2.0 + * @var int + * + * @see WP_HTML_Tag_Processor::set_bookmark() + */ + const MAX_BOOKMARKS = 10; + + /** + * Maximum number of times seek() can be called. + * Prevents accidental infinite loops. + * + * @since 6.2.0 + * @var int + * + * @see WP_HTML_Tag_Processor::seek() + */ + const MAX_SEEK_OPS = 1000; + + /** + * The HTML document to parse. + * + * @since 6.2.0 + * @var string + */ + protected $html; + + /** + * The last query passed to next_tag(). + * + * @since 6.2.0 + * @var array|null + */ + private $last_query; + + /** + * The tag name this processor currently scans for. + * + * @since 6.2.0 + * @var string|null + */ + private $sought_tag_name; + + /** + * The CSS class name this processor currently scans for. + * + * @since 6.2.0 + * @var string|null + */ + private $sought_class_name; + + /** + * The match offset this processor currently scans for. + * + * @since 6.2.0 + * @var int|null + */ + private $sought_match_offset; + + /** + * Whether to visit tag closers, e.g. , when walking an input document. + * + * @since 6.2.0 + * @var bool + */ + private $stop_on_tag_closers; + + /** + * How many bytes from the original HTML document have been read and parsed. + * + * This value points to the latest byte offset in the input document which + * has been already parsed. It is the internal cursor for the Tag Processor + * and updates while scanning through the HTML tokens. + * + * @since 6.2.0 + * @var int + */ + private $bytes_already_parsed = 0; + + /** + * Byte offset in input document where current token starts. + * + * Example: + * + *
... + * 01234 + * - token starts at 0 + * + * @since 6.5.0 + * + * @var int|null + */ + private $token_starts_at; + + /** + * Byte length of current token. + * + * Example: + * + *
... + * 012345678901234 + * - token length is 14 - 0 = 14 + * + * a is a token. + * 0123456789 123456789 123456789 + * - token length is 17 - 2 = 15 + * + * @since 6.5.0 + * + * @var int|null + */ + private $token_length; + + /** + * Byte offset in input document where current tag name starts. + * + * Example: + * + *
... + * 01234 + * - tag name starts at 1 + * + * @since 6.2.0 + * + * @var int|null + */ + private $tag_name_starts_at; + + /** + * Byte length of current tag name. + * + * Example: + * + *
... + * 01234 + * --- tag name length is 3 + * + * @since 6.2.0 + * + * @var int|null + */ + private $tag_name_length; + + /** + * Whether the current tag is an opening tag, e.g.
, or a closing tag, e.g.
. + * + * @var bool + */ + private $is_closing_tag; + + /** + * Lazily-built index of attributes found within an HTML tag, keyed by the attribute name. + * + * Example: + * + * // Supposing the parser is working through this content + * // and stops after recognizing the `id` attribute. + * //
+ * // ^ parsing will continue from this point. + * $this->attributes = array( + * 'id' => new WP_HTML_Attribute_Token( 'id', 9, 6, 5, 11, false ) + * ); + * + * // When picking up parsing again, or when asking to find the + * // `class` attribute we will continue and add to this array. + * $this->attributes = array( + * 'id' => new WP_HTML_Attribute_Token( 'id', 9, 6, 5, 11, false ), + * 'class' => new WP_HTML_Attribute_Token( 'class', 23, 7, 17, 13, false ) + * ); + * + * // Note that only the `class` attribute value is stored in the index. + * // That's because it is the only value used by this class at the moment. + * + * @since 6.2.0 + * @var WP_HTML_Attribute_Token[] + */ + private $attributes = array(); + + /** + * Tracks spans of duplicate attributes on a given tag, used for removing + * all copies of an attribute when calling `remove_attribute()`. + * + * @since 6.3.2 + * + * @var (WP_HTML_Span[])[]|null + */ + private $duplicate_attributes = null; + + /** + * Which class names to add or remove from a tag. + * + * These are tracked separately from attribute updates because they are + * semantically distinct, whereas this interface exists for the common + * case of adding and removing class names while other attributes are + * generally modified as with DOM `setAttribute` calls. + * + * When modifying an HTML document these will eventually be collapsed + * into a single `set_attribute( 'class', $changes )` call. + * + * Example: + * + * // Add the `wp-block-group` class, remove the `wp-group` class. + * $classname_updates = array( + * // Indexed by a comparable class name. + * 'wp-block-group' => WP_HTML_Tag_Processor::ADD_CLASS, + * 'wp-group' => WP_HTML_Tag_Processor::REMOVE_CLASS + * ); + * + * @since 6.2.0 + * @var bool[] + */ + private $classname_updates = array(); + + /** + * Tracks a semantic location in the original HTML which + * shifts with updates as they are applied to the document. + * + * @since 6.2.0 + * @var WP_HTML_Span[] + */ + protected $bookmarks = array(); + + const ADD_CLASS = true; + const REMOVE_CLASS = false; + const SKIP_CLASS = null; + + /** + * Lexical replacements to apply to input HTML document. + * + * "Lexical" in this class refers to the part of this class which + * operates on pure text _as text_ and not as HTML. There's a line + * between the public interface, with HTML-semantic methods like + * `set_attribute` and `add_class`, and an internal state that tracks + * text offsets in the input document. + * + * When higher-level HTML methods are called, those have to transform their + * operations (such as setting an attribute's value) into text diffing + * operations (such as replacing the sub-string from indices A to B with + * some given new string). These text-diffing operations are the lexical + * updates. + * + * As new higher-level methods are added they need to collapse their + * operations into these lower-level lexical updates since that's the + * Tag Processor's internal language of change. Any code which creates + * these lexical updates must ensure that they do not cross HTML syntax + * boundaries, however, so these should never be exposed outside of this + * class or any classes which intentionally expand its functionality. + * + * These are enqueued while editing the document instead of being immediately + * applied to avoid processing overhead, string allocations, and string + * copies when applying many updates to a single document. + * + * Example: + * + * // Replace an attribute stored with a new value, indices + * // sourced from the lazily-parsed HTML recognizer. + * $start = $attributes['src']->start; + * $length = $attributes['src']->length; + * $modifications[] = new WP_HTML_Text_Replacement( $start, $length, $new_value ); + * + * // Correspondingly, something like this will appear in this array. + * $lexical_updates = array( + * WP_HTML_Text_Replacement( 14, 28, 'https://my-site.my-domain/wp-content/uploads/2014/08/kittens.jpg' ) + * ); + * + * @since 6.2.0 + * @var WP_HTML_Text_Replacement[] + */ + protected $lexical_updates = array(); + + /** + * Tracks and limits `seek()` calls to prevent accidental infinite loops. + * + * @since 6.2.0 + * @var int + * + * @see WP_HTML_Tag_Processor::seek() + */ + protected $seek_count = 0; + + /** + * Constructor. + * + * @since 6.2.0 + * + * @param string $html HTML to process. + */ + public function __construct( $html ) { + $this->html = $html; + } + + /** + * Finds the next tag matching the $query. + * + * @since 6.2.0 + * + * @param array|string|null $query { + * Optional. Which tag name to find, having which class, etc. Default is to find any tag. + * + * @type string|null $tag_name Which tag to find, or `null` for "any tag." + * @type int|null $match_offset Find the Nth tag matching all search criteria. + * 1 for "first" tag, 3 for "third," etc. + * Defaults to first tag. + * @type string|null $class_name Tag must contain this whole class name to match. + * @type string|null $tag_closers "visit" or "skip": whether to stop on tag closers, e.g.
. + * } + * @return bool Whether a tag was matched. + */ + public function next_tag( $query = null ) { + $this->parse_query( $query ); + $already_found = 0; + + do { + if ( $this->bytes_already_parsed >= strlen( $this->html ) ) { + return false; + } + + // Find the next tag if it exists. + if ( false === $this->parse_next_tag() ) { + $this->bytes_already_parsed = strlen( $this->html ); + + return false; + } + + // Parse all of its attributes. + while ( $this->parse_next_attribute() ) { + continue; + } + + // Ensure that the tag closes before the end of the document. + if ( $this->bytes_already_parsed >= strlen( $this->html ) ) { + return false; + } + + $tag_ends_at = strpos( $this->html, '>', $this->bytes_already_parsed ); + if ( false === $tag_ends_at ) { + return false; + } + $this->token_length = $tag_ends_at - $this->token_starts_at; + $this->bytes_already_parsed = $tag_ends_at; + + // Finally, check if the parsed tag and its attributes match the search query. + if ( $this->matches() ) { + ++$already_found; + } + + /* + * For non-DATA sections which might contain text that looks like HTML tags but + * isn't, scan with the appropriate alternative mode. Looking at the first letter + * of the tag name as a pre-check avoids a string allocation when it's not needed. + */ + $t = $this->html[ $this->tag_name_starts_at ]; + if ( + ! $this->is_closing_tag && + ( + 'i' === $t || 'I' === $t || + 'n' === $t || 'N' === $t || + 's' === $t || 'S' === $t || + 't' === $t || 'T' === $t + ) ) { + $tag_name = $this->get_tag(); + + if ( 'SCRIPT' === $tag_name && ! $this->skip_script_data() ) { + $this->bytes_already_parsed = strlen( $this->html ); + return false; + } elseif ( + ( 'TEXTAREA' === $tag_name || 'TITLE' === $tag_name ) && + ! $this->skip_rcdata( $tag_name ) + ) { + $this->bytes_already_parsed = strlen( $this->html ); + return false; + } elseif ( + ( + 'IFRAME' === $tag_name || + 'NOEMBED' === $tag_name || + 'NOFRAMES' === $tag_name || + 'NOSCRIPT' === $tag_name || + 'STYLE' === $tag_name + ) && + ! $this->skip_rawtext( $tag_name ) + ) { + /* + * "XMP" should be here too but its rules are more complicated and require the + * complexity of the HTML Processor (it needs to close out any open P element, + * meaning it can't be skipped here or else the HTML Processor will lose its + * place). For now, it can be ignored as it's a rare HTML tag in practice and + * any normative HTML should be using PRE instead. + */ + $this->bytes_already_parsed = strlen( $this->html ); + return false; + } + } + } while ( $already_found < $this->sought_match_offset ); + + return true; + } + + + /** + * Generator for a foreach loop to step through each class name for the matched tag. + * + * This generator function is designed to be used inside a "foreach" loop. + * + * Example: + * + * $p = new WP_HTML_Tag_Processor( "
" ); + * $p->next_tag(); + * foreach ( $p->class_list() as $class_name ) { + * echo "{$class_name} "; + * } + * // Outputs: "free lang-en " + * + * @since 6.4.0 + */ + public function class_list() { + /** @var string $class contains the string value of the class attribute, with character references decoded. */ + $class = $this->get_attribute( 'class' ); + + if ( ! is_string( $class ) ) { + return; + } + + $seen = array(); + + $at = 0; + while ( $at < strlen( $class ) ) { + // Skip past any initial boundary characters. + $at += strspn( $class, " \t\f\r\n", $at ); + if ( $at >= strlen( $class ) ) { + return; + } + + // Find the byte length until the next boundary. + $length = strcspn( $class, " \t\f\r\n", $at ); + if ( 0 === $length ) { + return; + } + + /* + * CSS class names are case-insensitive in the ASCII range. + * + * @see https://www.w3.org/TR/CSS2/syndata.html#x1 + */ + $name = strtolower( substr( $class, $at, $length ) ); + $at += $length; + + /* + * It's expected that the number of class names for a given tag is relatively small. + * Given this, it is probably faster overall to scan an array for a value rather + * than to use the class name as a key and check if it's a key of $seen. + */ + if ( in_array( $name, $seen, true ) ) { + continue; + } + + $seen[] = $name; + yield $name; + } + } + + + /** + * Returns if a matched tag contains the given ASCII case-insensitive class name. + * + * @since 6.4.0 + * + * @param string $wanted_class Look for this CSS class name, ASCII case-insensitive. + * @return bool|null Whether the matched tag contains the given class name, or null if not matched. + */ + public function has_class( $wanted_class ) { + if ( ! $this->tag_name_starts_at ) { + return null; + } + + $wanted_class = strtolower( $wanted_class ); + + foreach ( $this->class_list() as $class_name ) { + if ( $class_name === $wanted_class ) { + return true; + } + } + + return false; + } + + + /** + * Sets a bookmark in the HTML document. + * + * Bookmarks represent specific places or tokens in the HTML + * document, such as a tag opener or closer. When applying + * edits to a document, such as setting an attribute, the + * text offsets of that token may shift; the bookmark is + * kept updated with those shifts and remains stable unless + * the entire span of text in which the token sits is removed. + * + * Release bookmarks when they are no longer needed. + * + * Example: + * + *

Surprising fact you may not know!

+ * ^ ^ + * \-|-- this `H2` opener bookmark tracks the token + * + *

Surprising fact you may no… + * ^ ^ + * \-|-- it shifts with edits + * + * Bookmarks provide the ability to seek to a previously-scanned + * place in the HTML document. This avoids the need to re-scan + * the entire document. + * + * Example: + * + *
  • One
  • Two
  • Three
+ * ^^^^ + * want to note this last item + * + * $p = new WP_HTML_Tag_Processor( $html ); + * $in_list = false; + * while ( $p->next_tag( array( 'tag_closers' => $in_list ? 'visit' : 'skip' ) ) ) { + * if ( 'UL' === $p->get_tag() ) { + * if ( $p->is_tag_closer() ) { + * $in_list = false; + * $p->set_bookmark( 'resume' ); + * if ( $p->seek( 'last-li' ) ) { + * $p->add_class( 'last-li' ); + * } + * $p->seek( 'resume' ); + * $p->release_bookmark( 'last-li' ); + * $p->release_bookmark( 'resume' ); + * } else { + * $in_list = true; + * } + * } + * + * if ( 'LI' === $p->get_tag() ) { + * $p->set_bookmark( 'last-li' ); + * } + * } + * + * Bookmarks intentionally hide the internal string offsets + * to which they refer. They are maintained internally as + * updates are applied to the HTML document and therefore + * retain their "position" - the location to which they + * originally pointed. The inability to use bookmarks with + * functions like `substr` is therefore intentional to guard + * against accidentally breaking the HTML. + * + * Because bookmarks allocate memory and require processing + * for every applied update, they are limited and require + * a name. They should not be created with programmatically-made + * names, such as "li_{$index}" with some loop. As a general + * rule they should only be created with string-literal names + * like "start-of-section" or "last-paragraph". + * + * Bookmarks are a powerful tool to enable complicated behavior. + * Consider double-checking that you need this tool if you are + * reaching for it, as inappropriate use could lead to broken + * HTML structure or unwanted processing overhead. + * + * @since 6.2.0 + * + * @param string $name Identifies this particular bookmark. + * @return bool Whether the bookmark was successfully created. + */ + public function set_bookmark( $name ) { + if ( null === $this->tag_name_starts_at ) { + return false; + } + + if ( ! array_key_exists( $name, $this->bookmarks ) && count( $this->bookmarks ) >= static::MAX_BOOKMARKS ) { + _doing_it_wrong( + __METHOD__, + __( 'Too many bookmarks: cannot create any more.' ), + '6.2.0' + ); + return false; + } + + $this->bookmarks[ $name ] = new Gutenberg_HTML_Span_6_5( $this->token_starts_at, $this->token_length ); + + return true; + } + + + /** + * Removes a bookmark that is no longer needed. + * + * Releasing a bookmark frees up the small + * performance overhead it requires. + * + * @param string $name Name of the bookmark to remove. + * @return bool Whether the bookmark already existed before removal. + */ + public function release_bookmark( $name ) { + if ( ! array_key_exists( $name, $this->bookmarks ) ) { + return false; + } + + unset( $this->bookmarks[ $name ] ); + + return true; + } + + /** + * Skips contents of generic rawtext elements. + * + * @since 6.3.2 + * + * @see https://html.spec.whatwg.org/#generic-raw-text-element-parsing-algorithm + * + * @param string $tag_name The uppercase tag name which will close the RAWTEXT region. + * @return bool Whether an end to the RAWTEXT region was found before the end of the document. + */ + private function skip_rawtext( $tag_name ) { + /* + * These two functions distinguish themselves on whether character references are + * decoded, and since functionality to read the inner markup isn't supported, it's + * not necessary to implement these two functions separately. + */ + return $this->skip_rcdata( $tag_name ); + } + + /** + * Skips contents of RCDATA elements, namely title and textarea tags. + * + * @since 6.2.0 + * + * @see https://html.spec.whatwg.org/multipage/parsing.html#rcdata-state + * + * @param string $tag_name The uppercase tag name which will close the RCDATA region. + * @return bool Whether an end to the RCDATA region was found before the end of the document. + */ + private function skip_rcdata( $tag_name ) { + $html = $this->html; + $doc_length = strlen( $html ); + $tag_length = strlen( $tag_name ); + + $at = $this->bytes_already_parsed; + + while ( false !== $at && $at < $doc_length ) { + $at = strpos( $this->html, '= $doc_length ) { + $this->bytes_already_parsed = $doc_length; + return false; + } + + $closer_potentially_starts_at = $at; + $at += 2; + + /* + * Find a case-insensitive match to the tag name. + * + * Because tag names are limited to US-ASCII there is no + * need to perform any kind of Unicode normalization when + * comparing; any character which could be impacted by such + * normalization could not be part of a tag name. + */ + for ( $i = 0; $i < $tag_length; $i++ ) { + $tag_char = $tag_name[ $i ]; + $html_char = $html[ $at + $i ]; + + if ( $html_char !== $tag_char && strtoupper( $html_char ) !== $tag_char ) { + $at += $i; + continue 2; + } + } + + $at += $tag_length; + $this->bytes_already_parsed = $at; + + /* + * Ensure that the tag name terminates to avoid matching on + * substrings of a longer tag name. For example, the sequence + * "' !== $c ) { + continue; + } + + 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; + return true; + } + } + + return false; + } + + /** + * Skips contents of script tags. + * + * @since 6.2.0 + * + * @return bool Whether the script tag was closed before the end of the document. + */ + private function skip_script_data() { + $state = 'unescaped'; + $html = $this->html; + $doc_length = strlen( $html ); + $at = $this->bytes_already_parsed; + + while ( false !== $at && $at < $doc_length ) { + $at += strcspn( $html, '-<', $at ); + + /* + * For all script states a "-->" transitions + * back into the normal unescaped script mode, + * even if that's the current state. + */ + if ( + $at + 2 < $doc_length && + '-' === $html[ $at ] && + '-' === $html[ $at + 1 ] && + '>' === $html[ $at + 2 ] + ) { + $at += 3; + $state = 'unescaped'; + continue; + } + + // Everything of interest past here starts with "<". + if ( $at + 1 >= $doc_length || '<' !== $html[ $at++ ] ) { + continue; + } + + /* + * Unlike with "-->", the " + * https://html.spec.whatwg.org/multipage/parsing.html#tag-open-state + */ + if ( + strlen( $html ) > $at + 3 && + '-' === $html[ $at + 2 ] && + '-' === $html[ $at + 3 ] + ) { + $closer_at = $at + 4; + // If it's not possible to close the comment then there is nothing more to scan. + if ( strlen( $html ) <= $closer_at ) { + return false; + } + + // Abruptly-closed empty comments are a sequence of dashes followed by `>`. + $span_of_dashes = strspn( $html, '-', $closer_at ); + if ( '>' === $html[ $closer_at + $span_of_dashes ] ) { + $at = $closer_at + $span_of_dashes + 1; + continue; + } + + /* + * Comments may be closed by either a --> or an invalid --!>. + * The first occurrence closes the comment. + * + * See https://html.spec.whatwg.org/#parse-error-incorrectly-closed-comment + */ + --$closer_at; // Pre-increment inside condition below reduces risk of accidental infinite looping. + while ( ++$closer_at < strlen( $html ) ) { + $closer_at = strpos( $html, '--', $closer_at ); + if ( false === $closer_at ) { + return false; + } + + if ( $closer_at + 2 < strlen( $html ) && '>' === $html[ $closer_at + 2 ] ) { + $at = $closer_at + 3; + continue 2; + } + + if ( $closer_at + 3 < strlen( $html ) && '!' === $html[ $closer_at + 2 ] && '>' === $html[ $closer_at + 3 ] ) { + $at = $closer_at + 4; + continue 2; + } + } + } + + /* + * + * The CDATA is case-sensitive. + * https://html.spec.whatwg.org/multipage/parsing.html#tag-open-state + */ + if ( + strlen( $html ) > $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 ) { + return false; + } + + $at = $closer_at + 3; + continue; + } + + /* + * + * These are ASCII-case-insensitive. + * https://html.spec.whatwg.org/multipage/parsing.html#tag-open-state + */ + if ( + strlen( $html ) > $at + 8 && + ( 'D' === $html[ $at + 2 ] || 'd' === $html[ $at + 2 ] ) && + ( 'O' === $html[ $at + 3 ] || 'o' === $html[ $at + 3 ] ) && + ( 'C' === $html[ $at + 4 ] || 'c' === $html[ $at + 4 ] ) && + ( 'T' === $html[ $at + 5 ] || 't' === $html[ $at + 5 ] ) && + ( 'Y' === $html[ $at + 6 ] || 'y' === $html[ $at + 6 ] ) && + ( 'P' === $html[ $at + 7 ] || 'p' === $html[ $at + 7 ] ) && + ( 'E' === $html[ $at + 8 ] || 'e' === $html[ $at + 8 ] ) + ) { + $closer_at = strpos( $html, '>', $at + 9 ); + if ( false === $closer_at ) { + return false; + } + + $at = $closer_at + 1; + continue; + } + + /* + * Anything else here is an incorrectly-opened comment and transitions + * to the bogus comment state - skip to the nearest >. + */ + $at = strpos( $html, '>', $at + 1 ); + continue; + } + + /* + * is a missing end tag name, which is ignored. + * + * See https://html.spec.whatwg.org/#parse-error-missing-end-tag-name + */ + if ( '>' === $html[ $at + 1 ] ) { + ++$at; + continue; + } + + /* + * + * 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 ) { + return false; + } + + $at = $closer_at + 1; + continue; + } + + /* + * If a non-alpha starts the tag name in a tag closer it's a comment. + * Find the first `>`, which closes the comment. + * + * See https://html.spec.whatwg.org/#parse-error-invalid-first-character-of-tag-name + */ + if ( $this->is_closing_tag ) { + $closer_at = strpos( $html, '>', $at + 3 ); + if ( false === $closer_at ) { + return false; + } + + $at = $closer_at + 1; + continue; + } + + ++$at; + } + + return false; + } + + /** + * Parses the next attribute. + * + * @since 6.2.0 + * + * @return bool Whether an attribute was found before the end of the document. + */ + 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 ) ) { + return false; + } + + /* + * Treat the equal sign as a part of the attribute + * name if it is the first encountered byte. + * + * @see https://html.spec.whatwg.org/multipage/parsing.html#before-attribute-name-state + */ + $name_length = '=' === $this->html[ $this->bytes_already_parsed ] + ? 1 + strcspn( $this->html, "=/> \t\f\r\n", $this->bytes_already_parsed + 1 ) + : strcspn( $this->html, "=/> \t\f\r\n", $this->bytes_already_parsed ); + + // No attribute, just tag closer. + if ( 0 === $name_length || $this->bytes_already_parsed + $name_length >= strlen( $this->html ) ) { + return false; + } + + $attribute_start = $this->bytes_already_parsed; + $attribute_name = substr( $this->html, $attribute_start, $name_length ); + $this->bytes_already_parsed += $name_length; + if ( $this->bytes_already_parsed >= strlen( $this->html ) ) { + return false; + } + + $this->skip_whitespace(); + if ( $this->bytes_already_parsed >= strlen( $this->html ) ) { + return false; + } + + $has_value = '=' === $this->html[ $this->bytes_already_parsed ]; + if ( $has_value ) { + ++$this->bytes_already_parsed; + $this->skip_whitespace(); + if ( $this->bytes_already_parsed >= strlen( $this->html ) ) { + return false; + } + + switch ( $this->html[ $this->bytes_already_parsed ] ) { + case "'": + case '"': + $quote = $this->html[ $this->bytes_already_parsed ]; + $value_start = $this->bytes_already_parsed + 1; + $value_length = strcspn( $this->html, $quote, $value_start ); + $attribute_end = $value_start + $value_length + 1; + $this->bytes_already_parsed = $attribute_end; + break; + + default: + $value_start = $this->bytes_already_parsed; + $value_length = strcspn( $this->html, "> \t\f\r\n", $value_start ); + $attribute_end = $value_start + $value_length; + $this->bytes_already_parsed = $attribute_end; + } + } else { + $value_start = $this->bytes_already_parsed; + $value_length = 0; + $attribute_end = $attribute_start + $name_length; + } + + if ( $attribute_end >= strlen( $this->html ) ) { + return false; + } + + if ( $this->is_closing_tag ) { + return true; + } + + /* + * > There must never be two or more attributes on + * > the same start tag whose names are an ASCII + * > case-insensitive match for each other. + * - HTML 5 spec + * + * @see https://html.spec.whatwg.org/multipage/syntax.html#attributes-2:ascii-case-insensitive + */ + $comparable_name = strtolower( $attribute_name ); + + // If an attribute is listed many times, only use the first declaration and ignore the rest. + if ( ! array_key_exists( $comparable_name, $this->attributes ) ) { + $this->attributes[ $comparable_name ] = new Gutenberg_HTML_Attribute_Token_6_5( + $attribute_name, + $value_start, + $value_length, + $attribute_start, + $attribute_end - $attribute_start, + ! $has_value + ); + + return true; + } + + /* + * Track the duplicate attributes so if we remove it, all disappear together. + * + * While `$this->duplicated_attributes` could always be stored as an `array()`, + * which would simplify the logic here, storing a `null` and only allocating + * an array when encountering duplicates avoids needless allocations in the + * normative case of parsing tags with no duplicate attributes. + */ + $duplicate_span = new Gutenberg_HTML_Span_6_5( $attribute_start, $attribute_end - $attribute_start ); + if ( null === $this->duplicate_attributes ) { + $this->duplicate_attributes = array( $comparable_name => array( $duplicate_span ) ); + } elseif ( ! array_key_exists( $comparable_name, $this->duplicate_attributes ) ) { + $this->duplicate_attributes[ $comparable_name ] = array( $duplicate_span ); + } else { + $this->duplicate_attributes[ $comparable_name ][] = $duplicate_span; + } + + return true; + } + + /** + * Move the internal cursor past any immediate successive whitespace. + * + * @since 6.2.0 + */ + private function skip_whitespace() { + $this->bytes_already_parsed += strspn( $this->html, " \t\f\r\n", $this->bytes_already_parsed ); + } + + /** + * Applies attribute updates and cleans up once a tag is fully parsed. + * + * @since 6.2.0 + */ + private function after_tag() { + $this->get_updated_html(); + $this->token_starts_at = null; + $this->token_length = null; + $this->tag_name_starts_at = null; + $this->tag_name_length = null; + $this->is_closing_tag = null; + $this->attributes = array(); + $this->duplicate_attributes = null; + } + + /** + * Converts class name updates into tag attributes updates + * (they are accumulated in different data formats for performance). + * + * @since 6.2.0 + * + * @see WP_HTML_Tag_Processor::$lexical_updates + * @see WP_HTML_Tag_Processor::$classname_updates + */ + private function class_name_updates_to_attributes_updates() { + if ( count( $this->classname_updates ) === 0 ) { + return; + } + + $existing_class = $this->get_enqueued_attribute_value( 'class' ); + if ( null === $existing_class || true === $existing_class ) { + $existing_class = ''; + } + + if ( false === $existing_class && isset( $this->attributes['class'] ) ) { + $existing_class = substr( + $this->html, + $this->attributes['class']->value_starts_at, + $this->attributes['class']->value_length + ); + } + + if ( false === $existing_class ) { + $existing_class = ''; + } + + /** + * Updated "class" attribute value. + * + * This is incrementally built while scanning through the existing class + * attribute, skipping removed classes on the way, and then appending + * added classes at the end. Only when finished processing will the + * value contain the final new value. + + * @var string $class + */ + $class = ''; + + /** + * Tracks the cursor position in the existing + * class attribute value while parsing. + * + * @var int $at + */ + $at = 0; + + /** + * Indicates if there's any need to modify the existing class attribute. + * + * If a call to `add_class()` and `remove_class()` wouldn't impact + * the `class` attribute value then there's no need to rebuild it. + * For example, when adding a class that's already present or + * removing one that isn't. + * + * This flag enables a performance optimization when none of the enqueued + * class updates would impact the `class` attribute; namely, that the + * processor can continue without modifying the input document, as if + * none of the `add_class()` or `remove_class()` calls had been made. + * + * This flag is set upon the first change that requires a string update. + * + * @var bool $modified + */ + $modified = false; + + // Remove unwanted classes by only copying the new ones. + $existing_class_length = strlen( $existing_class ); + while ( $at < $existing_class_length ) { + // Skip to the first non-whitespace character. + $ws_at = $at; + $ws_length = strspn( $existing_class, " \t\f\r\n", $ws_at ); + $at += $ws_length; + + // Capture the class name – it's everything until the next whitespace. + $name_length = strcspn( $existing_class, " \t\f\r\n", $at ); + if ( 0 === $name_length ) { + // If no more class names are found then that's the end. + break; + } + + $name = substr( $existing_class, $at, $name_length ); + $at += $name_length; + + // If this class is marked for removal, start processing the next one. + $remove_class = ( + isset( $this->classname_updates[ $name ] ) && + self::REMOVE_CLASS === $this->classname_updates[ $name ] + ); + + // If a class has already been seen then skip it; it should not be added twice. + if ( ! $remove_class ) { + $this->classname_updates[ $name ] = self::SKIP_CLASS; + } + + if ( $remove_class ) { + $modified = true; + continue; + } + + /* + * Otherwise, append it to the new "class" attribute value. + * + * There are options for handling whitespace between tags. + * Preserving the existing whitespace produces fewer changes + * to the HTML content and should clarify the before/after + * content when debugging the modified output. + * + * This approach contrasts normalizing the inter-class + * whitespace to a single space, which might appear cleaner + * in the output HTML but produce a noisier change. + */ + $class .= substr( $existing_class, $ws_at, $ws_length ); + $class .= $name; + } + + // Add new classes by appending those which haven't already been seen. + foreach ( $this->classname_updates as $name => $operation ) { + if ( self::ADD_CLASS === $operation ) { + $modified = true; + + $class .= strlen( $class ) > 0 ? ' ' : ''; + $class .= $name; + } + } + + $this->classname_updates = array(); + if ( ! $modified ) { + return; + } + + if ( strlen( $class ) > 0 ) { + $this->set_attribute( 'class', $class ); + } else { + $this->remove_attribute( 'class' ); + } + } + + /** + * Applies attribute updates to HTML document. + * + * @since 6.2.0 + * @since 6.2.1 Accumulates shift for internal cursor and passed pointer. + * @since 6.3.0 Invalidate any bookmarks whose targets are overwritten. + * + * @param int $shift_this_point Accumulate and return shift for this position. + * @return int How many bytes the given pointer moved in response to the updates. + */ + private function apply_attributes_updates( $shift_this_point = 0 ) { + if ( ! count( $this->lexical_updates ) ) { + return 0; + } + + $accumulated_shift_for_given_point = 0; + + /* + * Attribute updates can be enqueued in any order but updates + * to the document must occur in lexical order; that is, each + * replacement must be made before all others which follow it + * at later string indices in the input document. + * + * Sorting avoid making out-of-order replacements which + * can lead to mangled output, partially-duplicated + * attributes, and overwritten attributes. + */ + usort( $this->lexical_updates, array( self::class, 'sort_start_ascending' ) ); + + $bytes_already_copied = 0; + $output_buffer = ''; + foreach ( $this->lexical_updates as $diff ) { + $shift = strlen( $diff->text ) - $diff->length; + + // Adjust the cursor position by however much an update affects it. + if ( $diff->start <= $this->bytes_already_parsed ) { + $this->bytes_already_parsed += $shift; + } + + // Accumulate shift of the given pointer within this function call. + if ( $diff->start <= $shift_this_point ) { + $accumulated_shift_for_given_point += $shift; + } + + $output_buffer .= substr( $this->html, $bytes_already_copied, $diff->start - $bytes_already_copied ); + $output_buffer .= $diff->text; + $bytes_already_copied = $diff->start + $diff->length; + } + + $this->html = $output_buffer . substr( $this->html, $bytes_already_copied ); + + /* + * Adjust bookmark locations to account for how the text + * replacements adjust offsets in the input document. + */ + foreach ( $this->bookmarks as $bookmark_name => $bookmark ) { + $bookmark_end = $bookmark->start + $bookmark->length; + + /* + * Each lexical update which appears before the bookmark's endpoints + * might shift the offsets for those endpoints. Loop through each change + * and accumulate the total shift for each bookmark, then apply that + * shift after tallying the full delta. + */ + $head_delta = 0; + $tail_delta = 0; + + foreach ( $this->lexical_updates as $diff ) { + $diff_end = $diff->start + $diff->length; + + if ( $bookmark->start < $diff->start && $bookmark_end < $diff->start ) { + break; + } + + if ( $bookmark->start >= $diff->start && $bookmark_end < $diff_end ) { + $this->release_bookmark( $bookmark_name ); + continue 2; + } + + $delta = strlen( $diff->text ) - $diff->length; + + if ( $bookmark->start >= $diff->start ) { + $head_delta += $delta; + } + + if ( $bookmark_end >= $diff_end ) { + $tail_delta += $delta; + } + } + + $bookmark->start += $head_delta; + $bookmark->length += $tail_delta - $head_delta; + } + + $this->lexical_updates = array(); + + return $accumulated_shift_for_given_point; + } + + /** + * Checks whether a bookmark with the given name exists. + * + * @since 6.3.0 + * + * @param string $bookmark_name Name to identify a bookmark that potentially exists. + * @return bool Whether that bookmark exists. + */ + public function has_bookmark( $bookmark_name ) { + return array_key_exists( $bookmark_name, $this->bookmarks ); + } + + /** + * Move the internal cursor in the Tag Processor to a given bookmark's location. + * + * In order to prevent accidental infinite loops, there's a + * maximum limit on the number of times seek() can be called. + * + * @since 6.2.0 + * + * @param string $bookmark_name Jump to the place in the document identified by this bookmark name. + * @return bool Whether the internal cursor was successfully moved to the bookmark's location. + */ + public function seek( $bookmark_name ) { + if ( ! array_key_exists( $bookmark_name, $this->bookmarks ) ) { + _doing_it_wrong( + __METHOD__, + __( 'Unknown bookmark name.' ), + '6.2.0' + ); + return false; + } + + if ( ++$this->seek_count > static::MAX_SEEK_OPS ) { + _doing_it_wrong( + __METHOD__, + __( 'Too many calls to seek() - this can lead to performance issues.' ), + '6.2.0' + ); + return false; + } + + // Flush out any pending updates to the document. + $this->get_updated_html(); + + // 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' ) ); + } + + /** + * Compare two WP_HTML_Text_Replacement objects. + * + * @since 6.2.0 + * + * @param WP_HTML_Text_Replacement $a First attribute update. + * @param WP_HTML_Text_Replacement $b Second attribute update. + * @return int Comparison value for string order. + */ + private static function sort_start_ascending( $a, $b ) { + $by_start = $a->start - $b->start; + if ( 0 !== $by_start ) { + return $by_start; + } + + $by_text = isset( $a->text, $b->text ) ? strcmp( $a->text, $b->text ) : 0; + if ( 0 !== $by_text ) { + return $by_text; + } + + /* + * This code should be unreachable, because it implies the two replacements + * start at the same location and contain the same text. + */ + return $a->length - $b->length; + } + + /** + * Return the enqueued value for a given attribute, if one exists. + * + * Enqueued updates can take different data types: + * - If an update is enqueued and is boolean, the return will be `true` + * - If an update is otherwise enqueued, the return will be the string value of that update. + * - If an attribute is enqueued to be removed, the return will be `null` to indicate that. + * - If no updates are enqueued, the return will be `false` to differentiate from "removed." + * + * @since 6.2.0 + * + * @param string $comparable_name The attribute name in its comparable form. + * @return string|boolean|null Value of enqueued update if present, otherwise false. + */ + private function get_enqueued_attribute_value( $comparable_name ) { + if ( ! isset( $this->lexical_updates[ $comparable_name ] ) ) { + return false; + } + + $enqueued_text = $this->lexical_updates[ $comparable_name ]->text; + + // Removed attributes erase the entire span. + if ( '' === $enqueued_text ) { + return null; + } + + /* + * Boolean attribute updates are just the attribute name without a corresponding value. + * + * This value might differ from the given comparable name in that there could be leading + * or trailing whitespace, and that the casing follows the name given in `set_attribute`. + * + * Example: + * + * $p->set_attribute( 'data-TEST-id', 'update' ); + * 'update' === $p->get_enqueued_attribute_value( 'data-test-id' ); + * + * Detect this difference based on the absence of the `=`, which _must_ exist in any + * attribute containing a value, e.g. ``. + * ¹ ² + * 1. Attribute with a string value. + * 2. Boolean attribute whose value is `true`. + */ + $equals_at = strpos( $enqueued_text, '=' ); + if ( false === $equals_at ) { + return true; + } + + /* + * Finally, a normal update's value will appear after the `=` and + * be double-quoted, as performed incidentally by `set_attribute`. + * + * e.g. `type="text"` + * ¹² ³ + * 1. Equals is here. + * 2. Double-quoting starts one after the equals sign. + * 3. Double-quoting ends at the last character in the update. + */ + $enqueued_value = substr( $enqueued_text, $equals_at + 2, -1 ); + return html_entity_decode( $enqueued_value ); + } + + /** + * Returns the value of a requested attribute from a matched tag opener if that attribute exists. + * + * Example: + * + * $p = new WP_HTML_Tag_Processor( '
Test
' ); + * $p->next_tag( array( 'class_name' => 'test' ) ) === true; + * $p->get_attribute( 'data-test-id' ) === '14'; + * $p->get_attribute( 'enabled' ) === true; + * $p->get_attribute( 'aria-label' ) === null; + * + * $p->next_tag() === false; + * $p->get_attribute( 'class' ) === null; + * + * @since 6.2.0 + * + * @param string $name Name of attribute whose value is requested. + * @return string|true|null Value of attribute or `null` if not available. Boolean attributes return `true`. + */ + public function get_attribute( $name ) { + if ( null === $this->tag_name_starts_at ) { + return null; + } + + $comparable = strtolower( $name ); + + /* + * For every attribute other than `class` it's possible to perform a quick check if + * there's an enqueued lexical update whose value takes priority over what's found in + * the input document. + * + * The `class` attribute is special though because of the exposed helpers `add_class` + * and `remove_class`. These form a builder for the `class` attribute, so an additional + * check for enqueued class changes is required in addition to the check for any enqueued + * attribute values. If any exist, those enqueued class changes must first be flushed out + * into an attribute value update. + */ + if ( 'class' === $name ) { + $this->class_name_updates_to_attributes_updates(); + } + + // Return any enqueued attribute value updates if they exist. + $enqueued_value = $this->get_enqueued_attribute_value( $comparable ); + if ( false !== $enqueued_value ) { + return $enqueued_value; + } + + if ( ! isset( $this->attributes[ $comparable ] ) ) { + return null; + } + + $attribute = $this->attributes[ $comparable ]; + + /* + * This flag distinguishes an attribute with no value + * from an attribute with an empty string value. For + * unquoted attributes this could look very similar. + * It refers to whether an `=` follows the name. + * + * e.g.
+ * ¹ ² + * 1. Attribute `boolean-attribute` is `true`. + * 2. Attribute `empty-attribute` is `""`. + */ + if ( true === $attribute->is_true ) { + return true; + } + + $raw_value = substr( $this->html, $attribute->value_starts_at, $attribute->value_length ); + + return html_entity_decode( $raw_value ); + } + + /** + * Gets lowercase names of all attributes matching a given prefix in the current tag. + * + * Note that matching is case-insensitive. This is in accordance with the spec: + * + * > There must never be two or more attributes on + * > the same start tag whose names are an ASCII + * > case-insensitive match for each other. + * - HTML 5 spec + * + * Example: + * + * $p = new WP_HTML_Tag_Processor( '
Test
' ); + * $p->next_tag( array( 'class_name' => 'test' ) ) === true; + * $p->get_attribute_names_with_prefix( 'data-' ) === array( 'data-enabled', 'data-test-id' ); + * + * $p->next_tag() === false; + * $p->get_attribute_names_with_prefix( 'data-' ) === null; + * + * @since 6.2.0 + * + * @see https://html.spec.whatwg.org/multipage/syntax.html#attributes-2:ascii-case-insensitive + * + * @param string $prefix Prefix of requested attribute names. + * @return array|null List of attribute names, or `null` when no tag opener is matched. + */ + public function get_attribute_names_with_prefix( $prefix ) { + if ( $this->is_closing_tag || null === $this->tag_name_starts_at ) { + return null; + } + + $comparable = strtolower( $prefix ); + + $matches = array(); + foreach ( array_keys( $this->attributes ) as $attr_name ) { + if ( str_starts_with( $attr_name, $comparable ) ) { + $matches[] = $attr_name; + } + } + return $matches; + } + + /** + * Returns the uppercase name of the matched tag. + * + * Example: + * + * $p = new WP_HTML_Tag_Processor( '
Test
' ); + * $p->next_tag() === true; + * $p->get_tag() === 'DIV'; + * + * $p->next_tag() === false; + * $p->get_tag() === null; + * + * @since 6.2.0 + * + * @return string|null Name of currently matched tag in input HTML, or `null` if none found. + */ + public function get_tag() { + 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 ); + } + + /** + * Indicates if the currently matched tag contains the self-closing flag. + * + * No HTML elements ought to have the self-closing flag and for those, the self-closing + * flag will be ignored. For void elements this is benign because they "self close" + * automatically. For non-void HTML elements though problems will appear if someone + * intends to use a self-closing element in place of that element with an empty body. + * For HTML foreign elements and custom elements the self-closing flag determines if + * they self-close or not. + * + * This function does not determine if a tag is self-closing, + * but only if the self-closing flag is present in the syntax. + * + * @since 6.3.0 + * + * @return bool Whether the currently matched tag contains the self-closing flag. + */ + public function has_self_closing_flag() { + if ( ! $this->tag_name_starts_at ) { + return false; + } + + /* + * The self-closing flag is the solidus at the _end_ of the tag, not the beginning. + * + * Example: + * + *
+ * ^ this appears one character before the end of the closing ">". + */ + return '/' === $this->html[ $this->token_starts_at + $this->token_length - 1 ]; + } + + /** + * Indicates if the current tag token is a tag closer. + * + * Example: + * + * $p = new WP_HTML_Tag_Processor( '
' ); + * $p->next_tag( array( 'tag_name' => 'div', 'tag_closers' => 'visit' ) ); + * $p->is_tag_closer() === false; + * + * $p->next_tag( array( 'tag_name' => 'div', 'tag_closers' => 'visit' ) ); + * $p->is_tag_closer() === true; + * + * @since 6.2.0 + * + * @return bool Whether the current tag is a tag closer. + */ + public function is_tag_closer() { + return $this->is_closing_tag; + } + + /** + * Updates or creates a new attribute on the currently matched tag with the passed value. + * + * For boolean attributes special handling is provided: + * - When `true` is passed as the value, then only the attribute name is added to the tag. + * - When `false` is passed, the attribute gets removed if it existed before. + * + * For string attributes, the value is escaped using the `esc_attr` function. + * + * @since 6.2.0 + * @since 6.2.1 Fix: Only create a single update for multiple calls with case-variant attribute names. + * + * @param string $name The attribute name to target. + * @param string|bool $value The new attribute value. + * @return bool Whether an attribute value was set. + */ + public function set_attribute( $name, $value ) { + if ( $this->is_closing_tag || null === $this->tag_name_starts_at ) { + return false; + } + + /* + * WordPress rejects more characters than are strictly forbidden + * in HTML5. This is to prevent additional security risks deeper + * in the WordPress and plugin stack. Specifically the + * less-than (<) greater-than (>) and ampersand (&) aren't allowed. + * + * The use of a PCRE match enables looking for specific Unicode + * code points without writing a UTF-8 decoder. Whereas scanning + * for one-byte characters is trivial (with `strcspn`), scanning + * for the longer byte sequences would be more complicated. Given + * that this shouldn't be in the hot path for execution, it's a + * reasonable compromise in efficiency without introducing a + * noticeable impact on the overall system. + * + * @see https://html.spec.whatwg.org/#attributes-2 + * + * @todo As the only regex pattern maybe we should take it out? + * Are Unicode patterns available broadly in Core? + */ + if ( preg_match( + '~[' . + // Syntax-like characters. + '"\'>& The values "true" and "false" are not allowed on boolean attributes. + * > To represent a false value, the attribute has to be omitted altogether. + * - HTML5 spec, https://html.spec.whatwg.org/#boolean-attributes + */ + if ( false === $value ) { + return $this->remove_attribute( $name ); + } + + if ( true === $value ) { + $updated_attribute = $name; + } else { + $escaped_new_value = esc_attr( $value ); + $updated_attribute = "{$name}=\"{$escaped_new_value}\""; + } + + /* + * > There must never be two or more attributes on + * > the same start tag whose names are an ASCII + * > case-insensitive match for each other. + * - HTML 5 spec + * + * @see https://html.spec.whatwg.org/multipage/syntax.html#attributes-2:ascii-case-insensitive + */ + $comparable_name = strtolower( $name ); + + if ( isset( $this->attributes[ $comparable_name ] ) ) { + /* + * Update an existing attribute. + * + * Example – set attribute id to "new" in
: + * + *
+ * ^-------------^ + * start end + * replacement: `id="new"` + * + * Result:
+ */ + $existing_attribute = $this->attributes[ $comparable_name ]; + $this->lexical_updates[ $comparable_name ] = new Gutenberg_HTML_Text_Replacement_6_5( + $existing_attribute->start, + $existing_attribute->length, + $updated_attribute + ); + } else { + /* + * Create a new attribute at the tag's name end. + * + * Example – add attribute id="new" to
: + * + *
+ * ^ + * start and end + * replacement: ` id="new"` + * + * Result:
+ */ + $this->lexical_updates[ $comparable_name ] = new Gutenberg_HTML_Text_Replacement_6_5( + $this->tag_name_starts_at + $this->tag_name_length, + 0, + ' ' . $updated_attribute + ); + } + + /* + * Any calls to update the `class` attribute directly should wipe out any + * enqueued class changes from `add_class` and `remove_class`. + */ + if ( 'class' === $comparable_name && ! empty( $this->classname_updates ) ) { + $this->classname_updates = array(); + } + + return true; + } + + /** + * Remove an attribute from the currently-matched tag. + * + * @since 6.2.0 + * + * @param string $name The attribute name to remove. + * @return bool Whether an attribute was removed. + */ + public function remove_attribute( $name ) { + if ( $this->is_closing_tag ) { + return false; + } + + /* + * > There must never be two or more attributes on + * > the same start tag whose names are an ASCII + * > case-insensitive match for each other. + * - HTML 5 spec + * + * @see https://html.spec.whatwg.org/multipage/syntax.html#attributes-2:ascii-case-insensitive + */ + $name = strtolower( $name ); + + /* + * Any calls to update the `class` attribute directly should wipe out any + * enqueued class changes from `add_class` and `remove_class`. + */ + if ( 'class' === $name && count( $this->classname_updates ) !== 0 ) { + $this->classname_updates = array(); + } + + /* + * If updating an attribute that didn't exist in the input + * document, then remove the enqueued update and move on. + * + * For example, this might occur when calling `remove_attribute()` + * after calling `set_attribute()` for the same attribute + * and when that attribute wasn't originally present. + */ + if ( ! isset( $this->attributes[ $name ] ) ) { + if ( isset( $this->lexical_updates[ $name ] ) ) { + unset( $this->lexical_updates[ $name ] ); + } + return false; + } + + /* + * Removes an existing tag attribute. + * + * Example – remove the attribute id from
: + *
+ * ^-------------^ + * start end + * replacement: `` + * + * Result:
+ */ + $this->lexical_updates[ $name ] = new Gutenberg_HTML_Text_Replacement_6_5( + $this->attributes[ $name ]->start, + $this->attributes[ $name ]->length, + '' + ); + + // Removes any duplicated attributes if they were also present. + if ( null !== $this->duplicate_attributes && array_key_exists( $name, $this->duplicate_attributes ) ) { + foreach ( $this->duplicate_attributes[ $name ] as $attribute_token ) { + $this->lexical_updates[] = new Gutenberg_HTML_Text_Replacement_6_5( + $attribute_token->start, + $attribute_token->length, + '' + ); + } + } + + return true; + } + + /** + * Adds a new class name to the currently matched tag. + * + * @since 6.2.0 + * + * @param string $class_name The class name to add. + * @return bool Whether the class was set to be added. + */ + public function add_class( $class_name ) { + if ( $this->is_closing_tag ) { + return false; + } + + if ( null !== $this->tag_name_starts_at ) { + $this->classname_updates[ $class_name ] = self::ADD_CLASS; + } + + return true; + } + + /** + * Removes a class name from the currently matched tag. + * + * @since 6.2.0 + * + * @param string $class_name The class name to remove. + * @return bool Whether the class was set to be removed. + */ + public function remove_class( $class_name ) { + if ( $this->is_closing_tag ) { + return false; + } + + if ( null !== $this->tag_name_starts_at ) { + $this->classname_updates[ $class_name ] = self::REMOVE_CLASS; + } + + return true; + } + + /** + * Returns the string representation of the HTML Tag Processor. + * + * @since 6.2.0 + * + * @see WP_HTML_Tag_Processor::get_updated_html() + * + * @return string The processed HTML. + */ + public function __toString() { + return $this->get_updated_html(); + } + + /** + * Returns the string representation of the HTML Tag Processor. + * + * @since 6.2.0 + * @since 6.2.1 Shifts the internal cursor corresponding to the applied updates. + * @since 6.4.0 No longer calls subclass method `next_tag()` after updating HTML. + * + * @return string The processed HTML. + */ + public function get_updated_html() { + $requires_no_updating = 0 === count( $this->classname_updates ) && 0 === count( $this->lexical_updates ); + + /* + * When there is nothing more to update and nothing has already been + * updated, return the original document and avoid a string copy. + */ + if ( $requires_no_updating ) { + return $this->html; + } + + /* + * Keep track of the position right before the current tag. This will + * be necessary for reparsing the current tag after updating the HTML. + */ + $before_current_tag = $this->token_starts_at; + + /* + * 1. Apply the enqueued edits and update all the pointers to reflect those changes. + */ + $this->class_name_updates_to_attributes_updates(); + $before_current_tag += $this->apply_attributes_updates( $before_current_tag ); + + /* + * 2. Rewind to before the current tag and reparse to get updated attributes. + * + * At this point the internal cursor points to the end of the tag name. + * Rewind before the tag name starts so that it's as if the cursor didn't + * move; a call to `next_tag()` will reparse the recently-updated attributes + * and additional calls to modify the attributes will apply at this same + * location, but in order to avoid issues with subclasses that might add + * behaviors to `next_tag()`, the internal methods should be called here + * instead. + * + * It's important to note that in this specific place there will be no change + * because the processor was already at a tag when this was called and it's + * rewinding only to the beginning of this very tag before reprocessing it + * and its attributes. + * + *

Previous HTMLMore HTML

+ * ↑ │ back up by the length of the tag name plus the opening < + * └←─┘ back up by strlen("em") + 1 ==> 3 + */ + $this->bytes_already_parsed = $before_current_tag; + $this->parse_next_tag(); + // Reparse the attributes. + while ( $this->parse_next_attribute() ) { + continue; + } + + $tag_ends_at = strpos( $this->html, '>', $this->bytes_already_parsed ); + $this->token_length = $tag_ends_at - $this->token_starts_at; + $this->bytes_already_parsed = $tag_ends_at; + + return $this->html; + } + + /** + * Parses tag query input into internal search criteria. + * + * @since 6.2.0 + * + * @param array|string|null $query { + * Optional. Which tag name to find, having which class, etc. Default is to find any tag. + * + * @type string|null $tag_name Which tag to find, or `null` for "any tag." + * @type int|null $match_offset Find the Nth tag matching all search criteria. + * 1 for "first" tag, 3 for "third," etc. + * Defaults to first tag. + * @type string|null $class_name Tag must contain this class name to match. + * @type string $tag_closers "visit" or "skip": whether to stop on tag closers, e.g.
. + * } + */ + private function parse_query( $query ) { + if ( null !== $query && $query === $this->last_query ) { + return; + } + + $this->last_query = $query; + $this->sought_tag_name = null; + $this->sought_class_name = null; + $this->sought_match_offset = 1; + $this->stop_on_tag_closers = false; + + // A single string value means "find the tag of this name". + if ( is_string( $query ) ) { + $this->sought_tag_name = $query; + return; + } + + // An empty query parameter applies no restrictions on the search. + if ( null === $query ) { + return; + } + + // If not using the string interface, an associative array is required. + if ( ! is_array( $query ) ) { + _doing_it_wrong( + __METHOD__, + __( 'The query argument must be an array or a tag name.' ), + '6.2.0' + ); + return; + } + + if ( isset( $query['tag_name'] ) && is_string( $query['tag_name'] ) ) { + $this->sought_tag_name = $query['tag_name']; + } + + if ( isset( $query['class_name'] ) && is_string( $query['class_name'] ) ) { + $this->sought_class_name = $query['class_name']; + } + + if ( isset( $query['match_offset'] ) && is_int( $query['match_offset'] ) && 0 < $query['match_offset'] ) { + $this->sought_match_offset = $query['match_offset']; + } + + if ( isset( $query['tag_closers'] ) ) { + $this->stop_on_tag_closers = 'visit' === $query['tag_closers']; + } + } + + + /** + * Checks whether a given tag and its attributes match the search criteria. + * + * @since 6.2.0 + * + * @return bool Whether the given tag and its attribute match the search criteria. + */ + private function matches() { + if ( $this->is_closing_tag && ! $this->stop_on_tag_closers ) { + return false; + } + + // Does the tag name match the requested tag name in a case-insensitive manner? + if ( null !== $this->sought_tag_name ) { + /* + * String (byte) length lookup is fast. If they aren't the + * same length then they can't be the same string values. + */ + if ( strlen( $this->sought_tag_name ) !== $this->tag_name_length ) { + return false; + } + + /* + * Check each character to determine if they are the same. + * Defer calls to `strtoupper()` to avoid them when possible. + * Calling `strcasecmp()` here tested slowed than comparing each + * character, so unless benchmarks show otherwise, it should + * not be used. + * + * It's expected that most of the time that this runs, a + * lower-case tag name will be supplied and the input will + * contain lower-case tag names, thus normally bypassing + * the case comparison code. + */ + for ( $i = 0; $i < $this->tag_name_length; $i++ ) { + $html_char = $this->html[ $this->tag_name_starts_at + $i ]; + $tag_char = $this->sought_tag_name[ $i ]; + + if ( $html_char !== $tag_char && strtoupper( $html_char ) !== $tag_char ) { + return false; + } + } + } + + if ( null !== $this->sought_class_name && ! $this->has_class( $this->sought_class_name ) ) { + return false; + } + + return true; + } +} diff --git a/lib/compat/wordpress-6.5/html-api/class-gutenberg-html-text-replacement-6-5.php b/lib/compat/wordpress-6.5/html-api/class-gutenberg-html-text-replacement-6-5.php new file mode 100644 index 0000000000000..6409255833c81 --- /dev/null +++ b/lib/compat/wordpress-6.5/html-api/class-gutenberg-html-text-replacement-6-5.php @@ -0,0 +1,64 @@ +start = $start; + $this->length = $length; + $this->text = $text; + } +} diff --git a/lib/experimental/interactivity-api/class-wp-directive-processor.php b/lib/experimental/interactivity-api/class-wp-directive-processor.php index e717b2e553943..cf55a048bb9fa 100644 --- a/lib/experimental/interactivity-api/class-wp-directive-processor.php +++ b/lib/experimental/interactivity-api/class-wp-directive-processor.php @@ -20,7 +20,7 @@ * available. Please restrain from investing unnecessary time and effort trying * to improve this code. */ -class WP_Directive_Processor extends Gutenberg_HTML_Tag_Processor_6_4 { +class WP_Directive_Processor extends Gutenberg_HTML_Tag_Processor_6_5 { /** * An array of root blocks. @@ -195,7 +195,7 @@ public function get_inner_html() { } list( $start_name, $end_name ) = $bookmarks; - $start = $this->bookmarks[ $start_name ]->end + 1; + $start = $this->bookmarks[ $start_name ]->start + $this->bookmarks[ $start_name ]->length + 1; $end = $this->bookmarks[ $end_name ]->start; $this->seek( $start_name ); // Return to original position. @@ -225,14 +225,14 @@ public function set_inner_html( $new_html ) { } list( $start_name, $end_name ) = $bookmarks; - $start = $this->bookmarks[ $start_name ]->end + 1; + $start = $this->bookmarks[ $start_name ]->start + $this->bookmarks[ $start_name ]->length + 1; $end = $this->bookmarks[ $end_name ]->start; $this->seek( $start_name ); // Return to original position. $this->release_bookmark( $start_name ); $this->release_bookmark( $end_name ); - $this->lexical_updates[] = new WP_HTML_Text_Replacement( $start, $end, $new_html ); + $this->lexical_updates[] = new Gutenberg_HTML_Text_Replacement_6_5( $start, $end - $start, $new_html ); return true; } diff --git a/lib/load.php b/lib/load.php index 9c7618dbfc678..59fb75541ac41 100644 --- a/lib/load.php +++ b/lib/load.php @@ -76,6 +76,10 @@ function gutenberg_is_experiment_enabled( $name ) { * always be loaded so that Gutenberg code can run the newest version of the Tag Processor. */ require __DIR__ . '/compat/wordpress-6.4/html-api/class-gutenberg-html-tag-processor-6-4.php'; +require __DIR__ . '/compat/wordpress-6.5/html-api/class-gutenberg-html-attribute-token-6-5.php'; +require __DIR__ . '/compat/wordpress-6.5/html-api/class-gutenberg-html-span-6-5.php'; +require __DIR__ . '/compat/wordpress-6.5/html-api/class-gutenberg-html-text-replacement-6-5.php'; +require __DIR__ . '/compat/wordpress-6.5/html-api/class-gutenberg-html-tag-processor-6-5.php'; /* * The HTML Processor appeared after WordPress 6.3. If Gutenberg is running on a version of diff --git a/package-lock.json b/package-lock.json index 4b27980b6b40f..ded852521693f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15265,37 +15265,6 @@ "resolved": "https://registry.npmjs.org/@tannin/postfix/-/postfix-1.1.0.tgz", "integrity": "sha512-oocsqY7g0cR+Gur5jRQLSrX2OtpMLMse1I10JQBm8CdGMrDkh1Mg2gjsiquMHRtBs4Qwu5wgEp5GgIYHk4SNPw==" }, - "node_modules/@tanstack/react-table": { - "version": "8.10.3", - "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.10.3.tgz", - "integrity": "sha512-Qya1cJ+91arAlW7IRDWksRDnYw28O446jJ/ljkRSc663EaftJoBCAU10M+VV1K6MpCBLrXq1BD5IQc1zj/ZEjA==", - "dependencies": { - "@tanstack/table-core": "8.10.3" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - }, - "peerDependencies": { - "react": ">=16", - "react-dom": ">=16" - } - }, - "node_modules/@tanstack/table-core": { - "version": "8.10.3", - "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.10.3.tgz", - "integrity": "sha512-hJ55YfJlWbfzRROfcyA/kC1aZr/shsLA8XNAwN8jXylhYWGLnPmiJJISrUfj4dMMWRiFi0xBlnlC7MLH+zSrcw==", - "engines": { - "node": ">=12" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } - }, "node_modules/@testing-library/dom": { "version": "9.3.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.1.tgz", @@ -54440,7 +54409,7 @@ }, "packages/block-directory": { "name": "@wordpress/block-directory", - "version": "4.24.0", + "version": "4.24.1", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "^7.16.0", @@ -54572,7 +54541,7 @@ }, "packages/block-library": { "name": "@wordpress/block-library", - "version": "8.24.0", + "version": "8.24.1", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "^7.16.0", @@ -54674,6 +54643,7 @@ "@wordpress/i18n": "file:../i18n", "@wordpress/is-shallow-equal": "file:../is-shallow-equal", "@wordpress/private-apis": "file:../private-apis", + "@wordpress/rich-text": "file:../rich-text", "@wordpress/shortcode": "file:../shortcode", "change-case": "^4.1.2", "colord": "^2.7.0", @@ -54973,7 +54943,7 @@ }, "packages/customize-widgets": { "name": "@wordpress/customize-widgets", - "version": "4.24.0", + "version": "4.24.1", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "^7.16.0", @@ -55059,7 +55029,6 @@ "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "^7.16.0", - "@tanstack/react-table": "^8.10.3", "@wordpress/a11y": "file:../a11y", "@wordpress/components": "file:../components", "@wordpress/compose": "file:../compose", @@ -55208,7 +55177,7 @@ }, "packages/e2e-tests": { "name": "@wordpress/e2e-tests", - "version": "7.18.0", + "version": "7.18.1", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { @@ -55247,7 +55216,7 @@ }, "packages/edit-post": { "name": "@wordpress/edit-post", - "version": "7.24.0", + "version": "7.24.1", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "^7.16.0", @@ -55295,7 +55264,7 @@ }, "packages/edit-site": { "name": "@wordpress/edit-site", - "version": "5.24.0", + "version": "5.24.1", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "^7.16.0", @@ -55360,7 +55329,7 @@ }, "packages/edit-widgets": { "name": "@wordpress/edit-widgets", - "version": "5.24.0", + "version": "5.24.1", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "^7.16.0", @@ -55402,7 +55371,7 @@ }, "packages/editor": { "name": "@wordpress/editor", - "version": "13.24.0", + "version": "13.24.1", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "^7.16.0", @@ -55739,7 +55708,7 @@ }, "packages/interactivity": { "name": "@wordpress/interactivity", - "version": "3.0.0", + "version": "3.0.1", "license": "GPL-2.0-or-later", "dependencies": { "@preact/signals": "^1.1.3", @@ -55867,8 +55836,7 @@ "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "^7.16.0", - "@wordpress/i18n": "file:../i18n", - "change-case": "^4.1.2" + "@wordpress/i18n": "file:../i18n" }, "engines": { "node": ">=12" @@ -67561,19 +67529,6 @@ "resolved": "https://registry.npmjs.org/@tannin/postfix/-/postfix-1.1.0.tgz", "integrity": "sha512-oocsqY7g0cR+Gur5jRQLSrX2OtpMLMse1I10JQBm8CdGMrDkh1Mg2gjsiquMHRtBs4Qwu5wgEp5GgIYHk4SNPw==" }, - "@tanstack/react-table": { - "version": "8.10.3", - "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.10.3.tgz", - "integrity": "sha512-Qya1cJ+91arAlW7IRDWksRDnYw28O446jJ/ljkRSc663EaftJoBCAU10M+VV1K6MpCBLrXq1BD5IQc1zj/ZEjA==", - "requires": { - "@tanstack/table-core": "8.10.3" - } - }, - "@tanstack/table-core": { - "version": "8.10.3", - "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.10.3.tgz", - "integrity": "sha512-hJ55YfJlWbfzRROfcyA/kC1aZr/shsLA8XNAwN8jXylhYWGLnPmiJJISrUfj4dMMWRiFi0xBlnlC7MLH+zSrcw==" - }, "@testing-library/dom": { "version": "9.3.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.1.tgz", @@ -70141,6 +70096,7 @@ "@wordpress/i18n": "file:../i18n", "@wordpress/is-shallow-equal": "file:../is-shallow-equal", "@wordpress/private-apis": "file:../private-apis", + "@wordpress/rich-text": "file:../rich-text", "@wordpress/shortcode": "file:../shortcode", "change-case": "^4.1.2", "colord": "^2.7.0", @@ -70418,7 +70374,6 @@ "version": "file:packages/dataviews", "requires": { "@babel/runtime": "^7.16.0", - "@tanstack/react-table": "^8.10.3", "@wordpress/a11y": "file:../a11y", "@wordpress/components": "file:../components", "@wordpress/compose": "file:../compose", @@ -70961,8 +70916,7 @@ "version": "file:packages/keycodes", "requires": { "@babel/runtime": "^7.16.0", - "@wordpress/i18n": "file:../i18n", - "change-case": "^4.1.2" + "@wordpress/i18n": "file:../i18n" } }, "@wordpress/lazy-import": { diff --git a/packages/block-directory/package.json b/packages/block-directory/package.json index 147f58dd719e1..04bcd1c50d8b3 100644 --- a/packages/block-directory/package.json +++ b/packages/block-directory/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/block-directory", - "version": "4.24.0", + "version": "4.24.1", "description": "Extend editor with block directory features to search, download and install blocks.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/block-editor/README.md b/packages/block-editor/README.md index 2d6a5627a52a4..56ab5f1bd94d9 100644 --- a/packages/block-editor/README.md +++ b/packages/block-editor/README.md @@ -280,10 +280,18 @@ _Returns_ ### BlockToolbar +Renders the block toolbar. + _Related_ - +_Parameters_ + +- _props_ `Object`: Components props. +- _props.hideDragHandle_ `boolean`: Show or hide the Drag Handle for drag and drop functionality. +- _props.variant_ `string`: Style variant of the toolbar, also passed to the Dropdowns rendered from Block Toolbar Buttons. + ### BlockTools Renders block tools (the block toolbar, select/navigation mode toolbar, the insertion point and a slot for the inline rich text toolbar). Must be wrapped around the block content and editor styles wrapper or iframe. diff --git a/packages/block-editor/src/components/block-controls/hook.js b/packages/block-editor/src/components/block-controls/hook.js index 18a38e245e58a..e3f69c8bec3b2 100644 --- a/packages/block-editor/src/components/block-controls/hook.js +++ b/packages/block-editor/src/components/block-controls/hook.js @@ -1,45 +1,19 @@ /** * WordPress dependencies */ -import { store as blocksStore } from '@wordpress/blocks'; -import { useSelect } from '@wordpress/data'; /** * Internal dependencies */ import groups from './groups'; -import { store as blockEditorStore } from '../../store'; -import { useBlockEditContext } from '../block-edit/context'; import useDisplayBlockControls from '../use-display-block-controls'; export default function useBlockControlsFill( group, shareWithChildBlocks ) { - const isDisplayed = useDisplayBlockControls(); - const { clientId } = useBlockEditContext(); - const isParentDisplayed = useSelect( - ( select ) => { - if ( ! shareWithChildBlocks ) { - return false; - } - - const { getBlockName, hasSelectedInnerBlock } = - select( blockEditorStore ); - const { hasBlockSupport } = select( blocksStore ); - - return ( - hasBlockSupport( - getBlockName( clientId ), - '__experimentalExposeControlsToChildren', - false - ) && hasSelectedInnerBlock( clientId ) - ); - }, - [ shareWithChildBlocks, clientId ] - ); - + const { isDisplayed, isParentDisplayed } = useDisplayBlockControls(); if ( isDisplayed ) { return groups[ group ]?.Fill; } - if ( isParentDisplayed ) { + if ( isParentDisplayed && shareWithChildBlocks ) { return groups.parent.Fill; } return null; diff --git a/packages/block-editor/src/components/block-info-slot-fill/index.js b/packages/block-editor/src/components/block-info-slot-fill/index.js index db7919b6ef5ea..8e16757f3ebbc 100644 --- a/packages/block-editor/src/components/block-info-slot-fill/index.js +++ b/packages/block-editor/src/components/block-info-slot-fill/index.js @@ -13,7 +13,7 @@ const { createPrivateSlotFill } = unlock( componentsPrivateApis ); const { Fill, Slot } = createPrivateSlotFill( 'BlockInformation' ); const BlockInfo = ( props ) => { - const isDisplayed = useDisplayBlockControls(); + const { isDisplayed } = useDisplayBlockControls(); if ( ! isDisplayed ) { return null; } diff --git a/packages/block-editor/src/components/block-list/index.native.js b/packages/block-editor/src/components/block-list/index.native.js index 810e23e4c1442..4c4d7914b1806 100644 --- a/packages/block-editor/src/components/block-list/index.native.js +++ b/packages/block-editor/src/components/block-list/index.native.js @@ -30,6 +30,7 @@ import { import { BlockDraggableWrapper } from '../block-draggable'; import { useEditorWrapperStyles } from '../../hooks/use-editor-wrapper-styles'; import { store as blockEditorStore } from '../../store'; +import OfflineStatus from '../offline-status'; const identity = ( x ) => x; @@ -235,6 +236,10 @@ export default function BlockList( { onLayout={ onLayout } testID="block-list-wrapper" > + { + // eslint-disable-next-line no-undef + __DEV__ && + } { isRootList ? ( - { blockNamesForPrompt.length === 1 ? ( -

{ rules[ blockNamesForPrompt[ 0 ] ] }

- ) : ( -
    - { blockNamesForPrompt.map( ( name ) => ( -
  • { rules[ name ] }
  • - ) ) } -
- ) }

- { blockNamesForPrompt.length > 1 - ? __( 'Removing these blocks is not advised.' ) - : __( 'Removing this block is not advised.' ) } + { _n( + 'Post or page content will not be displayed if you delete this block.', + 'Post or page content will not be displayed if you delete these blocks.', + blockNamesForPrompt.length + ) }

diff --git a/packages/block-library/src/paragraph/block.json b/packages/block-library/src/paragraph/block.json index 85f56f4a838f5..3fe4fbb34e102 100644 --- a/packages/block-library/src/paragraph/block.json +++ b/packages/block-library/src/paragraph/block.json @@ -13,10 +13,9 @@ "type": "string" }, "content": { - "type": "string", - "source": "html", + "type": "rich-text", + "source": "rich-text", "selector": "p", - "default": "", "__experimentalRole": "content" }, "dropCap": { diff --git a/packages/block-library/src/preformatted/block.json b/packages/block-library/src/preformatted/block.json index ec6ea839385eb..def870e7ad2fb 100644 --- a/packages/block-library/src/preformatted/block.json +++ b/packages/block-library/src/preformatted/block.json @@ -8,10 +8,9 @@ "textdomain": "default", "attributes": { "content": { - "type": "string", - "source": "html", + "type": "rich-text", + "source": "rich-text", "selector": "pre", - "default": "", "__unstablePreserveWhiteSpace": true, "__experimentalRole": "content" } diff --git a/packages/block-library/src/pullquote/block.json b/packages/block-library/src/pullquote/block.json index 1d6c74dbc4ae0..7fc81d5683bd1 100644 --- a/packages/block-library/src/pullquote/block.json +++ b/packages/block-library/src/pullquote/block.json @@ -8,16 +8,15 @@ "textdomain": "default", "attributes": { "value": { - "type": "string", - "source": "html", + "type": "rich-text", + "source": "rich-text", "selector": "p", "__experimentalRole": "content" }, "citation": { - "type": "string", - "source": "html", + "type": "rich-text", + "source": "rich-text", "selector": "cite", - "default": "", "__experimentalRole": "content" }, "textAlign": { diff --git a/packages/block-library/src/quote/block.json b/packages/block-library/src/quote/block.json index 7ed406c0d2096..9deca000efe06 100644 --- a/packages/block-library/src/quote/block.json +++ b/packages/block-library/src/quote/block.json @@ -17,10 +17,9 @@ "__experimentalRole": "content" }, "citation": { - "type": "string", - "source": "html", + "type": "rich-text", + "source": "rich-text", "selector": "cite", - "default": "", "__experimentalRole": "content" }, "align": { diff --git a/packages/block-library/src/table/block.json b/packages/block-library/src/table/block.json index d1139d6c55add..470886a1247fe 100644 --- a/packages/block-library/src/table/block.json +++ b/packages/block-library/src/table/block.json @@ -12,10 +12,9 @@ "default": false }, "caption": { - "type": "string", - "source": "html", - "selector": "figcaption", - "default": "" + "type": "rich-text", + "source": "rich-text", + "selector": "figcaption" }, "head": { "type": "array", @@ -30,8 +29,8 @@ "selector": "td,th", "query": { "content": { - "type": "string", - "source": "html" + "type": "rich-text", + "source": "rich-text" }, "tag": { "type": "string", @@ -75,8 +74,8 @@ "selector": "td,th", "query": { "content": { - "type": "string", - "source": "html" + "type": "rich-text", + "source": "rich-text" }, "tag": { "type": "string", @@ -120,8 +119,8 @@ "selector": "td,th", "query": { "content": { - "type": "string", - "source": "html" + "type": "rich-text", + "source": "rich-text" }, "tag": { "type": "string", diff --git a/packages/block-library/src/utils/remove-anchor-tag.js b/packages/block-library/src/utils/remove-anchor-tag.js index 31d1877082f50..82e7b03423648 100644 --- a/packages/block-library/src/utils/remove-anchor-tag.js +++ b/packages/block-library/src/utils/remove-anchor-tag.js @@ -6,5 +6,6 @@ * @return {string} The value with anchor tags removed. */ export default function removeAnchorTag( value ) { - return value.replace( /<\/?a[^>]*>/g, '' ); + // To do: Refactor this to use rich text's removeFormat instead. + return value.toString().replace( /<\/?a[^>]*>/g, '' ); } diff --git a/packages/block-library/src/verse/block.json b/packages/block-library/src/verse/block.json index fa0bc30798212..846a1dc99caaf 100644 --- a/packages/block-library/src/verse/block.json +++ b/packages/block-library/src/verse/block.json @@ -9,10 +9,9 @@ "textdomain": "default", "attributes": { "content": { - "type": "string", - "source": "html", + "type": "rich-text", + "source": "rich-text", "selector": "pre", - "default": "", "__unstablePreserveWhiteSpace": true, "__experimentalRole": "content" }, diff --git a/packages/block-library/src/video/block.json b/packages/block-library/src/video/block.json index debe6f20fe53f..5d4680f39e79a 100644 --- a/packages/block-library/src/video/block.json +++ b/packages/block-library/src/video/block.json @@ -15,8 +15,8 @@ "attribute": "autoplay" }, "caption": { - "type": "string", - "source": "html", + "type": "rich-text", + "source": "rich-text", "selector": "figcaption", "__experimentalRole": "content" }, diff --git a/packages/blocks/package.json b/packages/blocks/package.json index 961cb338d7337..928d9d94740b4 100644 --- a/packages/blocks/package.json +++ b/packages/blocks/package.json @@ -42,6 +42,7 @@ "@wordpress/i18n": "file:../i18n", "@wordpress/is-shallow-equal": "file:../is-shallow-equal", "@wordpress/private-apis": "file:../private-apis", + "@wordpress/rich-text": "file:../rich-text", "@wordpress/shortcode": "file:../shortcode", "change-case": "^4.1.2", "colord": "^2.7.0", diff --git a/packages/blocks/src/api/matchers.js b/packages/blocks/src/api/matchers.js index 7a6ac84891658..950f1539440a0 100644 --- a/packages/blocks/src/api/matchers.js +++ b/packages/blocks/src/api/matchers.js @@ -3,6 +3,11 @@ */ export { attr, prop, text, query } from 'hpq'; +/** + * WordPress dependencies + */ +import { RichTextData } from '@wordpress/rich-text'; + /** * Internal dependencies */ @@ -41,3 +46,10 @@ export function html( selector, multilineTag ) { return match.innerHTML; }; } + +export const richText = ( selector, preserveWhiteSpace ) => ( el ) => { + const target = selector ? el.querySelector( selector ) : el; + return target + ? RichTextData.fromHTMLElement( target, { preserveWhiteSpace } ) + : RichTextData.empty(); +}; diff --git a/packages/blocks/src/api/parser/get-block-attributes.js b/packages/blocks/src/api/parser/get-block-attributes.js index cc81c10800552..24faae7370463 100644 --- a/packages/blocks/src/api/parser/get-block-attributes.js +++ b/packages/blocks/src/api/parser/get-block-attributes.js @@ -9,12 +9,22 @@ import memoize from 'memize'; */ import { pipe } from '@wordpress/compose'; import { applyFilters } from '@wordpress/hooks'; +import { RichTextData } from '@wordpress/rich-text'; /** * Internal dependencies */ -import { attr, html, text, query, node, children, prop } from '../matchers'; -import { normalizeBlockType } from '../utils'; +import { + attr, + html, + text, + query, + node, + children, + prop, + richText, +} from '../matchers'; +import { normalizeBlockType, getDefault } from '../utils'; /** * Higher-order hpq matcher which enhances an attribute matcher to return true @@ -58,6 +68,9 @@ export const toBooleanAttributeMatcher = ( matcher ) => */ export function isOfType( value, type ) { switch ( type ) { + case 'rich-text': + return value instanceof RichTextData; + case 'string': return typeof value === 'string'; @@ -134,6 +147,7 @@ export function getBlockAttribute( case 'property': case 'html': case 'text': + case 'rich-text': case 'children': case 'node': case 'query': @@ -152,7 +166,7 @@ export function getBlockAttribute( } if ( value === undefined ) { - value = attributeSchema.default; + value = getDefault( attributeSchema ); } return value; @@ -211,6 +225,11 @@ export const matcherFromSource = memoize( ( sourceConfig ) => { return html( sourceConfig.selector, sourceConfig.multiline ); case 'text': return text( sourceConfig.selector ); + case 'rich-text': + return richText( + sourceConfig.selector, + sourceConfig.__unstablePreserveWhiteSpace + ); case 'children': return children( sourceConfig.selector ); case 'node': diff --git a/packages/blocks/src/api/raw-handling/test/paste-handler.js b/packages/blocks/src/api/raw-handling/test/paste-handler.js index 6938ad0d9c408..9b3dad39a0a5b 100644 --- a/packages/blocks/src/api/raw-handling/test/paste-handler.js +++ b/packages/blocks/src/api/raw-handling/test/paste-handler.js @@ -73,9 +73,9 @@ describe( 'pasteHandler', () => { expect( console ).toHaveLogged(); + delete result.attributes.caption; expect( result.attributes ).toEqual( { hasFixedLayout: false, - caption: '', head: [ { cells: [ @@ -113,9 +113,9 @@ describe( 'pasteHandler', () => { expect( console ).toHaveLogged(); + delete result.attributes.caption; expect( result.attributes ).toEqual( { hasFixedLayout: false, - caption: '', head: [ { cells: [ diff --git a/packages/blocks/src/api/utils.js b/packages/blocks/src/api/utils.js index c43445c627226..60a94117b36e2 100644 --- a/packages/blocks/src/api/utils.js +++ b/packages/blocks/src/api/utils.js @@ -11,6 +11,7 @@ import a11yPlugin from 'colord/plugins/a11y'; import { Component, isValidElement } from '@wordpress/element'; import { __, sprintf } from '@wordpress/i18n'; import { __unstableStripHTML as stripHTML } from '@wordpress/dom'; +import { RichTextData } from '@wordpress/rich-text'; /** * Internal dependencies @@ -47,8 +48,12 @@ export function isUnmodifiedBlock( block ) { const newBlock = isUnmodifiedBlock[ block.name ]; const blockType = getBlockType( block.name ); - return Object.keys( blockType?.attributes ?? {} ).every( - ( key ) => newBlock.attributes[ key ] === block.attributes[ key ] + function isEqual( a, b ) { + return ( a?.valueOf() ?? a ) === ( b?.valueOf() ?? b ); + } + + return Object.keys( blockType?.attributes ?? {} ).every( ( key ) => + isEqual( newBlock.attributes[ key ], block.attributes[ key ] ) ); } @@ -243,6 +248,16 @@ export function getAccessibleBlockLabel( ); } +export function getDefault( attributeSchema ) { + if ( attributeSchema.default !== undefined ) { + return attributeSchema.default; + } + + if ( attributeSchema.type === 'rich-text' ) { + return new RichTextData(); + } +} + /** * Ensure attributes contains only values defined by block type, and merge * default values for missing attributes. @@ -264,9 +279,26 @@ export function __experimentalSanitizeBlockAttributes( name, attributes ) { const value = attributes[ key ]; if ( undefined !== value ) { - accumulator[ key ] = value; - } else if ( schema.hasOwnProperty( 'default' ) ) { - accumulator[ key ] = schema.default; + if ( schema.type === 'rich-text' ) { + if ( value instanceof RichTextData ) { + accumulator[ key ] = value; + } else if ( typeof value === 'string' ) { + accumulator[ key ] = + RichTextData.fromHTMLString( value ); + } + } else if ( + schema.type === 'string' && + value instanceof RichTextData + ) { + accumulator[ key ] = value.toHTMLString(); + } else { + accumulator[ key ] = value; + } + } else { + const _default = getDefault( schema ); + if ( undefined !== _default ) { + accumulator[ key ] = _default; + } } if ( [ 'node', 'children' ].indexOf( schema.source ) !== -1 ) { diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md index c4153503fd798..33e4e58d29e7c 100644 --- a/packages/components/CHANGELOG.md +++ b/packages/components/CHANGELOG.md @@ -10,11 +10,25 @@ - `PaletteEdit`: Gradient pickers to use same width as color pickers ([#56801](https://github.com/WordPress/gutenberg/pull/56801)). - `FocalPointPicker`: Add opt-in prop for 40px default size ([#56021](https://github.com/WordPress/gutenberg/pull/56021)). - `DimensionControl`: Add opt-in prop for 40px default size ([#56805](https://github.com/WordPress/gutenberg/pull/56805)). +- `FontSizePicker`: Add opt-in prop for 40px default size ([#56804](https://github.com/WordPress/gutenberg/pull/56804)). ### Bug Fix - `ToggleGroupControl`: react correctly to external controlled updates ([#56678](https://github.com/WordPress/gutenberg/pull/56678)). - `ToolsPanel`: fix a performance issue ([#56770](https://github.com/WordPress/gutenberg/pull/56770)). +- `BorderControl`: adjust `BorderControlDropdown` Button size to fix misaligned border ([#56730](https://github.com/WordPress/gutenberg/pull/56730)). + +### Internal + +- `DropdownMenuV2Ariakit`: prevent prefix collapsing if all radios or checkboxes are unselected ([#56720](https://github.com/WordPress/gutenberg/pull/56720)). + +### Experimental + +- `Tabs`: implement new `tabId` prop ([#56883](https://github.com/WordPress/gutenberg/pull/56883)). + +### Experimental + +- `Tabs`: improve focus handling in controlled mode ([#56658](https://github.com/WordPress/gutenberg/pull/56658)). ## 25.13.0 (2023-11-29) diff --git a/packages/components/src/border-control/border-control-dropdown/component.tsx b/packages/components/src/border-control/border-control-dropdown/component.tsx index 4f43a6ed0ce55..3ee01bcda8f3b 100644 --- a/packages/components/src/border-control/border-control-dropdown/component.tsx +++ b/packages/components/src/border-control/border-control-dropdown/component.tsx @@ -149,6 +149,7 @@ const BorderControlDropdown = ( popoverControlsClassName, resetButtonClassName, showDropdownHeader, + size, __unstablePopoverProps, ...otherProps } = useBorderControlDropdown( props ); @@ -178,6 +179,7 @@ const BorderControlDropdown = ( tooltipPosition={ dropdownPosition } label={ __( 'Border color and style picker' ) } showTooltip={ true } + __next40pxDefaultSize={ size === '__unstable-large' ? true : false } > { __( 'Border color' ) }
- +

Selected tab: Tab 1

- +

Selected tab: Tab 2

- +

Selected tab: Tab 3

@@ -251,19 +251,19 @@ const ControlledModeTemplate: StoryFn< typeof Tabs > = ( props ) => { } } > - Tab 1 + Tab 1 - Tab 2 + Tab 2 - Tab 3 + Tab 3 - +

Selected tab: Tab 1

- +

Selected tab: Tab 2

- +

Selected tab: Tab 3

@@ -314,19 +314,19 @@ const TabBecomesDisabledTemplate: StoryFn< typeof Tabs > = ( props ) => { - Tab 1 - + Tab 1 + Tab 2 - Tab 3 + Tab 3 - +

Selected tab: Tab 1

- +

Selected tab: Tab 2

- +

Selected tab: Tab 3

@@ -348,17 +348,17 @@ const TabGetsRemovedTemplate: StoryFn< typeof Tabs > = ( props ) => { - { ! removeTab1 && Tab 1 } - Tab 2 - Tab 3 + { ! removeTab1 && Tab 1 } + Tab 2 + Tab 3 - +

Selected tab: Tab 1

- +

Selected tab: Tab 2

- +

Selected tab: Tab 3

diff --git a/packages/components/src/tabs/tab.tsx b/packages/components/src/tabs/tab.tsx index 4bfc99e8ef43b..e1aa85c636cdd 100644 --- a/packages/components/src/tabs/tab.tsx +++ b/packages/components/src/tabs/tab.tsx @@ -15,15 +15,15 @@ import type { WordPressComponentProps } from '../context'; export const Tab = forwardRef< HTMLButtonElement, - WordPressComponentProps< TabProps, 'button', false > ->( function Tab( { children, id, disabled, render, ...otherProps }, ref ) { + Omit< WordPressComponentProps< TabProps, 'button', false >, 'id' > +>( function Tab( { children, tabId, disabled, render, ...otherProps }, ref ) { const context = useTabsContext(); if ( ! context ) { warning( '`Tabs.Tab` must be wrapped in a `Tabs` component.' ); return null; } const { store, instanceId } = context; - const instancedTabId = `${ instanceId }-${ id }`; + const instancedTabId = `${ instanceId }-${ tabId }`; return ( ->( function TabPanel( { children, id, focusable = true, ...otherProps }, ref ) { + Omit< WordPressComponentProps< TabPanelProps, 'div', false >, 'id' > +>( function TabPanel( + { children, tabId, focusable = true, ...otherProps }, + ref +) { const context = useTabsContext(); if ( ! context ) { warning( '`Tabs.TabPanel` must be wrapped in a `Tabs` component.' ); return null; } const { store, instanceId } = context; + const instancedTabId = `${ instanceId }-${ tabId }`; return ( diff --git a/packages/components/src/tabs/test/index.tsx b/packages/components/src/tabs/test/index.tsx index fac8127c4cc0d..70ad3c1c18ae5 100644 --- a/packages/components/src/tabs/test/index.tsx +++ b/packages/components/src/tabs/test/index.tsx @@ -2,12 +2,12 @@ * External dependencies */ import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { press, click } from '@ariakit/test'; /** * WordPress dependencies */ -import { useState } from '@wordpress/element'; +import { useEffect, useState } from '@wordpress/element'; /** * Internal dependencies @@ -16,7 +16,7 @@ import Tabs from '..'; import type { TabsProps } from '../types'; type Tab = { - id: string; + tabId: string; title: string; content: React.ReactNode; tab: { @@ -30,19 +30,19 @@ type Tab = { const TABS: Tab[] = [ { - id: 'alpha', + tabId: 'alpha', title: 'Alpha', content: 'Selected tab: Alpha', tab: { className: 'alpha-class' }, }, { - id: 'beta', + tabId: 'beta', title: 'Beta', content: 'Selected tab: Beta', tab: { className: 'beta-class' }, }, { - id: 'gamma', + tabId: 'gamma', title: 'Gamma', content: 'Selected tab: Gamma', tab: { className: 'gamma-class' }, @@ -52,7 +52,7 @@ const TABS: Tab[] = [ const TABS_WITH_DELTA: Tab[] = [ ...TABS, { - id: 'delta', + tabId: 'delta', title: 'Delta', content: 'Selected tab: Delta', tab: { className: 'delta-class' }, @@ -70,8 +70,8 @@ const UncontrolledTabs = ( { { tabs.map( ( tabObj ) => ( @@ -81,8 +81,8 @@ const UncontrolledTabs = ( { { tabs.map( ( tabObj ) => ( { tabObj.content } @@ -102,6 +102,10 @@ const ControlledTabs = ( { string | undefined | null >( props.selectedTabId ); + useEffect( () => { + setSelectedTabId( props.selectedTabId ); + }, [ props.selectedTabId ] ); + return ( { tabs.map( ( tabObj ) => ( @@ -124,7 +128,7 @@ const ControlledTabs = ( { ) ) } { tabs.map( ( tabObj ) => ( - + { tabObj.content } ) ) } @@ -184,28 +188,24 @@ describe( 'Tabs', () => { } ); describe( 'Focus Behavior', () => { it( 'should focus on the related TabPanel when pressing the Tab key', async () => { - const user = userEvent.setup(); - render( ); const selectedTabPanel = await screen.findByRole( 'tabpanel' ); // Tab should initially focus the first tab in the tablist, which // is Alpha. - await user.keyboard( '[Tab]' ); + await press.Tab(); expect( await screen.findByRole( 'tab', { name: 'Alpha' } ) ).toHaveFocus(); // By default the tabpanel should receive focus - await user.keyboard( '[Tab]' ); + await press.Tab(); expect( selectedTabPanel ).toHaveFocus(); } ); it( 'should not focus on the related TabPanel when pressing the Tab key if `focusable: false` is set', async () => { - const user = userEvent.setup(); - const TABS_WITH_ALPHA_FOCUSABLE_FALSE = TABS.map( ( tabObj ) => - tabObj.id === 'alpha' + tabObj.tabId === 'alpha' ? { ...tabObj, content: ( @@ -229,13 +229,13 @@ describe( 'Tabs', () => { // Tab should initially focus the first tab in the tablist, which // is Alpha. - await user.keyboard( '[Tab]' ); + await press.Tab(); expect( await screen.findByRole( 'tab', { name: 'Alpha' } ) ).toHaveFocus(); // Because the alpha tabpanel is set to `focusable: false`, pressing // the Tab key should focus the button, not the tabpanel - await user.keyboard( '[Tab]' ); + await press.Tab(); expect( alphaButton ).toHaveFocus(); } ); } ); @@ -258,7 +258,6 @@ describe( 'Tabs', () => { describe( 'Tab Activation', () => { it( 'defaults to automatic tab activation (pointer clicks)', async () => { - const user = userEvent.setup(); const mockOnSelect = jest.fn(); render( @@ -273,7 +272,7 @@ describe( 'Tabs', () => { expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); // Click on Beta, make sure beta is the selected tab - await user.click( screen.getByRole( 'tab', { name: 'Beta' } ) ); + await click( screen.getByRole( 'tab', { name: 'Beta' } ) ); expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); expect( @@ -282,7 +281,7 @@ describe( 'Tabs', () => { expect( mockOnSelect ).toHaveBeenLastCalledWith( 'beta' ); // Click on Alpha, make sure beta is the selected tab - await user.click( screen.getByRole( 'tab', { name: 'Alpha' } ) ); + await click( screen.getByRole( 'tab', { name: 'Alpha' } ) ); expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); expect( @@ -292,7 +291,6 @@ describe( 'Tabs', () => { } ); it( 'defaults to automatic tab activation (arrow keys)', async () => { - const user = userEvent.setup(); const mockOnSelect = jest.fn(); render( @@ -307,12 +305,12 @@ describe( 'Tabs', () => { // Tab to focus the tablist. Make sure alpha is focused. expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); expect( await getSelectedTab() ).not.toHaveFocus(); - await user.keyboard( '[Tab]' ); + await press.Tab(); expect( await getSelectedTab() ).toHaveFocus(); // Navigate forward with arrow keys and make sure the Beta tab is // selected automatically. - await user.keyboard( '[ArrowRight]' ); + await press.ArrowRight(); expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); expect( await getSelectedTab() ).toHaveFocus(); expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); @@ -320,7 +318,7 @@ describe( 'Tabs', () => { // Navigate backwards with arrow keys. Make sure alpha is // selected automatically. - await user.keyboard( '[ArrowLeft]' ); + await press.ArrowLeft(); expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); expect( await getSelectedTab() ).toHaveFocus(); expect( mockOnSelect ).toHaveBeenCalledTimes( 3 ); @@ -328,7 +326,6 @@ describe( 'Tabs', () => { } ); it( 'wraps around the last/first tab when using arrow keys', async () => { - const user = userEvent.setup(); const mockOnSelect = jest.fn(); render( @@ -341,12 +338,12 @@ describe( 'Tabs', () => { // Tab to focus the tablist. Make sure Alpha is focused. expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); expect( await getSelectedTab() ).not.toHaveFocus(); - await user.keyboard( '[Tab]' ); + await press.Tab(); expect( await getSelectedTab() ).toHaveFocus(); // Navigate backwards with arrow keys and make sure that the Gamma tab // (the last tab) is selected automatically. - await user.keyboard( '[ArrowLeft]' ); + await press.ArrowLeft(); expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); expect( await getSelectedTab() ).toHaveFocus(); expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); @@ -354,7 +351,7 @@ describe( 'Tabs', () => { // Navigate forward with arrow keys. Make sure alpha (the first tab) is // selected automatically. - await user.keyboard( '[ArrowRight]' ); + await press.ArrowRight(); expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); expect( await getSelectedTab() ).toHaveFocus(); expect( mockOnSelect ).toHaveBeenCalledTimes( 3 ); @@ -362,7 +359,6 @@ describe( 'Tabs', () => { } ); it( 'should not move tab selection when pressing the up/down arrow keys, unless the orientation is changed to `vertical`', async () => { - const user = userEvent.setup(); const mockOnSelect = jest.fn(); const { rerender } = render( @@ -377,18 +373,18 @@ describe( 'Tabs', () => { // Tab to focus the tablist. Make sure alpha is focused. expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); expect( await getSelectedTab() ).not.toHaveFocus(); - await user.keyboard( '[Tab]' ); + await press.Tab(); expect( await getSelectedTab() ).toHaveFocus(); // Press the arrow up key, nothing happens. - await user.keyboard( '[ArrowUp]' ); + await press.ArrowUp(); expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); expect( await getSelectedTab() ).toHaveFocus(); expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); // Press the arrow down key, nothing happens - await user.keyboard( '[ArrowDown]' ); + await press.ArrowDown(); expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); expect( await getSelectedTab() ).toHaveFocus(); expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); @@ -415,7 +411,7 @@ describe( 'Tabs', () => { // Navigate forward with arrow keys and make sure the Beta tab is // selected automatically. - await user.keyboard( '[ArrowDown]' ); + await press.ArrowDown(); expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); expect( await getSelectedTab() ).toHaveFocus(); expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); @@ -423,7 +419,7 @@ describe( 'Tabs', () => { // Navigate backwards with arrow keys. Make sure alpha is // selected automatically. - await user.keyboard( '[ArrowUp]' ); + await press.ArrowUp(); expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); expect( await getSelectedTab() ).toHaveFocus(); expect( mockOnSelect ).toHaveBeenCalledTimes( 3 ); @@ -431,7 +427,7 @@ describe( 'Tabs', () => { // Navigate backwards with arrow keys. Make sure alpha is // selected automatically. - await user.keyboard( '[ArrowUp]' ); + await press.ArrowUp(); expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); expect( await getSelectedTab() ).toHaveFocus(); expect( mockOnSelect ).toHaveBeenCalledTimes( 4 ); @@ -439,7 +435,7 @@ describe( 'Tabs', () => { // Navigate backwards with arrow keys. Make sure alpha is // selected automatically. - await user.keyboard( '[ArrowDown]' ); + await press.ArrowDown(); expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); expect( await getSelectedTab() ).toHaveFocus(); expect( mockOnSelect ).toHaveBeenCalledTimes( 5 ); @@ -447,11 +443,10 @@ describe( 'Tabs', () => { } ); it( 'should move focus on a tab even if disabled with arrow key, but not with pointer clicks', async () => { - const user = userEvent.setup(); const mockOnSelect = jest.fn(); const TABS_WITH_DELTA_DISABLED = TABS_WITH_DELTA.map( ( tabObj ) => - tabObj.id === 'delta' + tabObj.tabId === 'delta' ? { ...tabObj, tab: { @@ -477,7 +472,7 @@ describe( 'Tabs', () => { // Tab to focus the tablist. Make sure Alpha is focused. expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); expect( await getSelectedTab() ).not.toHaveFocus(); - await user.keyboard( '[Tab]' ); + await press.Tab(); expect( await getSelectedTab() ).toHaveFocus(); // Confirm onSelect has not been re-called expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); @@ -487,7 +482,9 @@ describe( 'Tabs', () => { // it was the tab that was last selected before delta. Therefore, the // `mockOnSelect` function gets called only twice (and not three times) // - it will receive focus, when using arrow keys - await user.keyboard( '[ArrowRight][ArrowRight][ArrowRight]' ); + await press.ArrowRight(); + await press.ArrowRight(); + await press.ArrowRight(); expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); expect( screen.getByRole( 'tab', { name: 'Delta' } ) @@ -498,7 +495,7 @@ describe( 'Tabs', () => { // Navigate backwards with arrow keys. The gamma tab receives focus. // The `mockOnSelect` callback doesn't fire, since the gamma tab was // already selected. - await user.keyboard( '[ArrowLeft]' ); + await press.ArrowLeft(); expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); expect( await getSelectedTab() ).toHaveFocus(); expect( mockOnSelect ).toHaveBeenCalledTimes( 3 ); @@ -506,37 +503,26 @@ describe( 'Tabs', () => { // Click on the disabled tab. Compared to using arrow keys to move the // focus, disabled tabs ignore pointer clicks — and therefore, they don't // receive focus, nor they cause the `mockOnSelect` function to fire. - await user.click( screen.getByRole( 'tab', { name: 'Delta' } ) ); + await click( screen.getByRole( 'tab', { name: 'Delta' } ) ); expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); expect( await getSelectedTab() ).toHaveFocus(); expect( mockOnSelect ).toHaveBeenCalledTimes( 3 ); } ); it( 'should not focus the next tab when the Tab key is pressed', async () => { - const user = userEvent.setup(); - render( ); // Tab should initially focus the first tab in the tablist, which // is Alpha. - await user.keyboard( '[Tab]' ); + await press.Tab(); expect( await screen.findByRole( 'tab', { name: 'Alpha' } ) ).toHaveFocus(); - // This assertion ensures the component has had time to fully - // render, preventing flakiness. - // see https://github.com/WordPress/gutenberg/pull/55950 - await waitFor( () => - expect( - screen.getByRole( 'tab', { name: 'Beta' } ) - ).toHaveAttribute( 'tabindex', '-1' ) - ); - // Because all other tabs should have `tabindex=-1`, pressing Tab // should NOT move the focus to the next tab, which is Beta. // Instead, focus should go to the currently selected tabpanel (alpha). - await user.keyboard( '[Tab]' ); + await press.Tab(); expect( await screen.findByRole( 'tabpanel', { name: 'Alpha', @@ -545,7 +531,6 @@ describe( 'Tabs', () => { } ); it( 'switches to manual tab activation when the `selectOnMove` prop is set to `false`', async () => { - const user = userEvent.setup(); const mockOnSelect = jest.fn(); render( @@ -563,7 +548,7 @@ describe( 'Tabs', () => { // Click on Alpha and make sure it is selected. // onSelect shouldn't fire since the selected tab didn't change. - await user.click( screen.getByRole( 'tab', { name: 'Alpha' } ) ); + await click( screen.getByRole( 'tab', { name: 'Alpha' } ) ); expect( await screen.findByRole( 'tab', { name: 'Alpha' } ) ).toHaveFocus(); @@ -574,13 +559,13 @@ describe( 'Tabs', () => { // that the tab selection happens only when pressing the spacebar // or enter key. onSelect shouldn't fire since the selected tab // didn't change. - await user.keyboard( '[ArrowRight]' ); + await press.ArrowRight(); expect( await screen.findByRole( 'tab', { name: 'Beta' } ) ).toHaveFocus(); expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); - await user.keyboard( '[Enter]' ); + await press.Enter(); expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'beta' ); @@ -588,7 +573,7 @@ describe( 'Tabs', () => { // focused, but that tab selection happens only when pressing the // spacebar or enter key. onSelect shouldn't fire since the selected // tab didn't change. - await user.keyboard( '[ArrowRight]' ); + await press.ArrowRight(); expect( await screen.findByRole( 'tab', { name: 'Gamma' } ) ).toHaveFocus(); @@ -597,7 +582,7 @@ describe( 'Tabs', () => { screen.getByRole( 'tab', { name: 'Gamma' } ) ).toHaveFocus(); - await user.keyboard( '[Space]' ); + await press.Space(); expect( mockOnSelect ).toHaveBeenCalledTimes( 3 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'gamma' ); } ); @@ -623,7 +608,7 @@ describe( 'Tabs', () => { } ); it( 'should not load any tab if the active tab is removed and there are no enabled tabs', async () => { const TABS_WITH_BETA_GAMMA_DISABLED = TABS.map( ( tabObj ) => - tabObj.id !== 'alpha' + tabObj.tabId !== 'alpha' ? { ...tabObj, tab: { @@ -700,7 +685,6 @@ describe( 'Tabs', () => { } ); it( 'should fall back to the tab associated to `initialTabId` if the currently active tab is removed', async () => { - const user = userEvent.setup(); const mockOnSelect = jest.fn(); const { rerender } = render( @@ -713,9 +697,7 @@ describe( 'Tabs', () => { expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); - await user.click( - screen.getByRole( 'tab', { name: 'Alpha' } ) - ); + await click( screen.getByRole( 'tab', { name: 'Alpha' } ) ); expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); @@ -731,7 +713,6 @@ describe( 'Tabs', () => { } ); it( 'should fall back to the tab associated to `initialTabId` if the currently active tab becomes disabled', async () => { - const user = userEvent.setup(); const mockOnSelect = jest.fn(); const { rerender } = render( @@ -744,14 +725,12 @@ describe( 'Tabs', () => { expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); - await user.click( - screen.getByRole( 'tab', { name: 'Alpha' } ) - ); + await click( screen.getByRole( 'tab', { name: 'Alpha' } ) ); expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); const TABS_WITH_ALPHA_DISABLED = TABS.map( ( tabObj ) => - tabObj.id === 'alpha' + tabObj.tabId === 'alpha' ? { ...tabObj, tab: { @@ -822,12 +801,11 @@ describe( 'Tabs', () => { describe( 'Disabled tab', () => { it( 'should disable the tab when `disabled` is `true`', async () => { - const user = userEvent.setup(); const mockOnSelect = jest.fn(); const TABS_WITH_DELTA_DISABLED = TABS_WITH_DELTA.map( ( tabObj ) => - tabObj.id === 'delta' + tabObj.tabId === 'delta' ? { ...tabObj, tab: { @@ -853,20 +831,15 @@ describe( 'Tabs', () => { expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); + // Move focus to the tablist, make sure alpha is focused. + await press.Tab(); + expect( + screen.getByRole( 'tab', { name: 'Alpha' } ) + ).toHaveFocus(); + // onSelect should not be called since the disabled tab is // highlighted, but not selected. - await user.keyboard( '[Tab]' ); - - // This assertion ensures focus has time to move to the first - // tab before the test proceeds, preventing flakiness. - // see https://github.com/WordPress/gutenberg/pull/55950 - await waitFor( () => - expect( - screen.getByRole( 'tab', { name: 'Alpha' } ) - ).toHaveFocus() - ); - - await user.keyboard( '[ArrowLeft]' ); + await press.ArrowLeft(); expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); // Delta (which is disabled) has focus @@ -880,7 +853,7 @@ describe( 'Tabs', () => { it( 'should select first enabled tab when the initial tab is disabled', async () => { const TABS_WITH_ALPHA_DISABLED = TABS.map( ( tabObj ) => - tabObj.id === 'alpha' + tabObj.tabId === 'alpha' ? { ...tabObj, tab: { @@ -909,7 +882,7 @@ describe( 'Tabs', () => { it( 'should select first enabled tab when the tab associated to `initialTabId` is disabled', async () => { const TABS_ONLY_GAMMA_ENABLED = TABS.map( ( tabObj ) => - tabObj.id !== 'gamma' + tabObj.tabId !== 'gamma' ? { ...tabObj, tab: { @@ -951,7 +924,7 @@ describe( 'Tabs', () => { expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); const TABS_WITH_ALPHA_DISABLED = TABS.map( ( tabObj ) => - tabObj.id === 'alpha' + tabObj.tabId === 'alpha' ? { ...tabObj, tab: { @@ -998,7 +971,7 @@ describe( 'Tabs', () => { expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); const TABS_WITH_GAMMA_DISABLED = TABS.map( ( tabObj ) => - tabObj.id === 'gamma' + tabObj.tabId === 'gamma' ? { ...tabObj, tab: { @@ -1067,14 +1040,10 @@ describe( 'Tabs', () => { /> ); - // No tab should be selected i.e. it doesn't fall back to first tab. - // `waitFor` is needed here to prevent testing library from - // throwing a 'not wrapped in `act()`' error. - await waitFor( () => - expect( - screen.queryByRole( 'tab', { selected: true } ) - ).not.toBeInTheDocument() - ); + expect( + screen.queryByRole( 'tab', { selected: true } ) + ).not.toBeInTheDocument(); + // No tabpanel should be rendered either expect( screen.queryByRole( 'tabpanel' ) ).not.toBeInTheDocument(); } ); @@ -1086,7 +1055,7 @@ describe( 'Tabs', () => { // Remove beta rerender( tab.id !== 'beta' ) } + tabs={ TABS.filter( ( tab ) => tab.tabId !== 'beta' ) } selectedTabId="beta" /> ); @@ -1120,7 +1089,7 @@ describe( 'Tabs', () => { it( 'should not render any tab if `selectedTabId` refers to a disabled tab', async () => { const TABS_WITH_DELTA_WITH_BETA_DISABLED = TABS_WITH_DELTA.map( ( tabObj ) => - tabObj.id === 'beta' + tabObj.tabId === 'beta' ? { ...tabObj, tab: { @@ -1157,7 +1126,7 @@ describe( 'Tabs', () => { expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); const TABS_WITH_BETA_DISABLED = TABS.map( ( tabObj ) => - tabObj.id === 'beta' + tabObj.tabId === 'beta' ? { ...tabObj, tab: { @@ -1203,5 +1172,110 @@ describe( 'Tabs', () => { ).not.toBeInTheDocument(); } ); } ); + + describe( 'When `selectOnMove` is `true`', () => { + it( 'should automatically select a newly focused tab', async () => { + render( ); + + await press.Tab(); + + // Tab key should focus the currently selected tab, which is Beta. + expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); + expect( await getSelectedTab() ).toHaveFocus(); + + // Arrow keys should select and move focus to the next tab. + await press.ArrowRight(); + expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); + expect( await getSelectedTab() ).toHaveFocus(); + } ); + it( 'should automatically update focus when the selected tab is changed by the controlling component', async () => { + const { rerender } = render( + + ); + + // Tab key should focus the currently selected tab, which is Beta. + await press.Tab(); + expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); + expect( await getSelectedTab() ).toHaveFocus(); + + rerender( + + ); + + // When the selected tab is changed, it should automatically receive focus. + expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); + expect( await getSelectedTab() ).toHaveFocus(); + } ); + } ); + describe( 'When `selectOnMove` is `false`', () => { + it( 'should apply focus without automatically changing the selected tab', async () => { + render( + + ); + + expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); + + // Tab key should focus the currently selected tab, which is Beta. + await press.Tab(); + expect( + await screen.findByRole( 'tab', { name: 'Beta' } ) + ).toHaveFocus(); + + // Arrow key should move focus but not automatically change the selected tab. + await press.ArrowRight(); + expect( + screen.getByRole( 'tab', { name: 'Gamma' } ) + ).toHaveFocus(); + expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); + + // Pressing the spacebar should select the focused tab. + await press.Space(); + expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); + + // Arrow key should move focus but not automatically change the selected tab. + await press.ArrowRight(); + expect( + screen.getByRole( 'tab', { name: 'Alpha' } ) + ).toHaveFocus(); + expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); + + // Pressing the enter/return should select the focused tab. + await press.Enter(); + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + } ); + it( 'should not automatically update focus when the selected tab is changed by the controlling component', async () => { + const { rerender } = render( + + ); + + expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); + + // Tab key should focus the currently selected tab, which is Beta. + await press.Tab(); + expect( await getSelectedTab() ).toHaveFocus(); + + rerender( + + ); + + // When the selected tab is changed, it should not automatically receive focus. + expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); + expect( + screen.getByRole( 'tab', { name: 'Beta' } ) + ).toHaveFocus(); + } ); + } ); } ); } ); diff --git a/packages/components/src/tabs/types.ts b/packages/components/src/tabs/types.ts index 8b07193741091..389665b13357f 100644 --- a/packages/components/src/tabs/types.ts +++ b/packages/components/src/tabs/types.ts @@ -78,8 +78,10 @@ export type TabListProps = { export type TabProps = { /** * The id of the tab, which is prepended with the `Tabs` instanceId. + * The value of this prop should match with the value of the `tabId` prop on + * the corresponding `Tabs.TabPanel` component. */ - id: string; + tabId: string; /** * The children elements, generally the text to display on the tab. */ @@ -103,9 +105,12 @@ export type TabPanelProps = { */ children?: React.ReactNode; /** - * A unique identifier for the tabpanel, which is used to generate a unique `id` for the underlying element. + * A unique identifier for the tabpanel, which is used to generate an + * instanced id for the underlying element. + * The value of this prop should match with the value of the `tabId` prop on + * the corresponding `Tabs.Tab` component. */ - id: string; + tabId: string; /** * Determines whether or not the tabpanel element should be focusable. * If `false`, pressing the tab key will skip over the tabpanel, and instead diff --git a/packages/core-data/src/footnotes/get-footnotes-order.js b/packages/core-data/src/footnotes/get-footnotes-order.js index 42adeed7621e8..fcaeae660ec1a 100644 --- a/packages/core-data/src/footnotes/get-footnotes-order.js +++ b/packages/core-data/src/footnotes/get-footnotes-order.js @@ -1,8 +1,3 @@ -/** - * WordPress dependencies - */ -import { create } from '@wordpress/rich-text'; - /** * Internal dependencies */ @@ -14,18 +9,16 @@ function getBlockFootnotesOrder( block ) { if ( ! cache.has( block ) ) { const order = []; for ( const value of getRichTextValuesCached( block ) ) { - if ( ! value || ! value.includes( 'data-fn' ) ) { + if ( ! value ) { continue; } // replacements is a sparse array, use forEach to skip empty slots. - create( { html: value } ).replacements.forEach( - ( { type, attributes } ) => { - if ( type === 'core/footnote' ) { - order.push( attributes[ 'data-fn' ] ); - } + value.replacements.forEach( ( { type, attributes } ) => { + if ( type === 'core/footnote' ) { + order.push( attributes[ 'data-fn' ] ); } - ); + } ); } cache.set( block, order ); } diff --git a/packages/core-data/src/footnotes/index.js b/packages/core-data/src/footnotes/index.js index fa1c5fad5c7e7..9458290f9cb40 100644 --- a/packages/core-data/src/footnotes/index.js +++ b/packages/core-data/src/footnotes/index.js @@ -1,7 +1,7 @@ /** * WordPress dependencies */ -import { create, toHTMLString } from '@wordpress/rich-text'; +import { RichTextData, create, toHTMLString } from '@wordpress/rich-text'; /** * Internal dependencies @@ -53,15 +53,18 @@ export function updateFootnotesFromMeta( blocks, meta ) { continue; } - if ( typeof value !== 'string' ) { + // To do, remove support for string values? + if ( + typeof value !== 'string' && + ! ( value instanceof RichTextData ) + ) { continue; } - if ( value.indexOf( 'data-fn' ) === -1 ) { - continue; - } - - const richTextValue = create( { html: value } ); + const richTextValue = + typeof value === 'string' + ? RichTextData.fromHTMLString( value ) + : value; richTextValue.replacements.forEach( ( replacement ) => { if ( replacement.type === 'core/footnote' ) { @@ -78,7 +81,10 @@ export function updateFootnotesFromMeta( blocks, meta ) { } } ); - attributes[ key ] = toHTMLString( { value: richTextValue } ); + attributes[ key ] = + typeof value === 'string' + ? richTextValue.toHTMLString() + : richTextValue; } return attributes; diff --git a/packages/create-block-interactive-template/CHANGELOG.md b/packages/create-block-interactive-template/CHANGELOG.md index fbe3a8c8c857c..72ed6677e0b4e 100644 --- a/packages/create-block-interactive-template/CHANGELOG.md +++ b/packages/create-block-interactive-template/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 1.10.1 (2023-12-07) + - Update template to use modules instead of scripts. [#56694](https://github.com/WordPress/gutenberg/pull/56694) ## 1.10.0 (2023-11-29) diff --git a/packages/create-block-interactive-template/package.json b/packages/create-block-interactive-template/package.json index 48723b3b756d1..3bc6b1f646c26 100644 --- a/packages/create-block-interactive-template/package.json +++ b/packages/create-block-interactive-template/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/create-block-interactive-template", - "version": "1.10.0", + "version": "1.10.1", "description": "Template for @wordpress/create-block to create interactive blocks with the Interactivity API.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/customize-widgets/package.json b/packages/customize-widgets/package.json index 65a64d7826a7f..14aff02afb016 100644 --- a/packages/customize-widgets/package.json +++ b/packages/customize-widgets/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/customize-widgets", - "version": "4.24.0", + "version": "4.24.1", "description": "Widgets blocks in Customizer Module for WordPress.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/customize-widgets/src/components/header/index.js b/packages/customize-widgets/src/components/header/index.js index 34e4573c719dd..5bd0b2c2f4d47 100644 --- a/packages/customize-widgets/src/components/header/index.js +++ b/packages/customize-widgets/src/components/header/index.js @@ -6,13 +6,9 @@ import classnames from 'classnames'; /** * WordPress dependencies */ -import { Popover, ToolbarButton } from '@wordpress/components'; -import { useViewportMatch } from '@wordpress/compose'; -import { - NavigableToolbar, - privateApis as blockEditorPrivateApis, -} from '@wordpress/block-editor'; -import { createPortal, useEffect, useRef, useState } from '@wordpress/element'; +import { ToolbarButton } from '@wordpress/components'; +import { NavigableToolbar } from '@wordpress/block-editor'; +import { createPortal, useEffect, useState } from '@wordpress/element'; import { displayShortcut, isAppleOS } from '@wordpress/keycodes'; import { __, _x, isRTL } from '@wordpress/i18n'; import { plus, undo as undoIcon, redo as redoIcon } from '@wordpress/icons'; @@ -22,9 +18,6 @@ import { plus, undo as undoIcon, redo as redoIcon } from '@wordpress/icons'; */ import Inserter from '../inserter'; import MoreMenu from '../more-menu'; -import { unlock } from '../../lock-unlock'; - -const { BlockContextualToolbar } = unlock( blockEditorPrivateApis ); function Header( { sidebar, @@ -33,8 +26,6 @@ function Header( { setIsInserterOpened, isFixedToolbarActive, } ) { - const isLargeViewport = useViewportMatch( 'medium' ); - const blockToolbarRef = useRef(); const [ [ hasUndo, hasRedo ], setUndoRedo ] = useState( [ sidebar.hasUndo(), sidebar.hasRedo(), @@ -107,18 +98,6 @@ function Header( { , inserter.contentContainer[ 0 ] ) } - - { isFixedToolbarActive && isLargeViewport && ( - <> -
- -
- - - ) } ); } diff --git a/packages/customize-widgets/src/components/header/style.scss b/packages/customize-widgets/src/components/header/style.scss index d9d4a487e647c..27460a82e0ad1 100644 --- a/packages/customize-widgets/src/components/header/style.scss +++ b/packages/customize-widgets/src/components/header/style.scss @@ -1,5 +1,5 @@ .customize-widgets-header { - @include break-medium() { + @include break-small() { // Make space for the floating toolbar. margin-bottom: $grid-unit-20 + $default-block-margin; } diff --git a/packages/customize-widgets/src/components/sidebar-block-editor/index.js b/packages/customize-widgets/src/components/sidebar-block-editor/index.js index ccb6fca871429..c2e10bca16ec0 100644 --- a/packages/customize-widgets/src/components/sidebar-block-editor/index.js +++ b/packages/customize-widgets/src/components/sidebar-block-editor/index.js @@ -1,11 +1,13 @@ /** * WordPress dependencies */ +import { useViewportMatch } from '@wordpress/compose'; import { store as coreStore } from '@wordpress/core-data'; import { useSelect } from '@wordpress/data'; import { useMemo, createPortal } from '@wordpress/element'; import { BlockList, + BlockToolbar, BlockTools, BlockInspector, privateApis as blockEditorPrivateApis, @@ -37,6 +39,7 @@ export default function SidebarBlockEditor( { inspector, } ) { const [ isInserterOpened, setIsInserterOpened ] = useInserter( inserter ); + const isMediumViewport = useViewportMatch( 'small' ); const { hasUploadPermissions, isFixedToolbarActive, @@ -77,7 +80,7 @@ export default function SidebarBlockEditor( { ...blockEditorSettings, __experimentalSetIsInserterOpened: setIsInserterOpened, mediaUpload: mediaUploadBlockEditor, - hasFixedToolbar: isFixedToolbarActive, + hasFixedToolbar: isFixedToolbarActive || ! isMediumViewport, keepCaretInsideBlock, __unstableHasCustomAppender: true, }; @@ -85,6 +88,7 @@ export default function SidebarBlockEditor( { hasUploadPermissions, blockEditorSettings, isFixedToolbarActive, + isMediumViewport, keepCaretInsideBlock, setIsInserterOpened, ] ); @@ -109,9 +113,13 @@ export default function SidebarBlockEditor( { inserter={ inserter } isInserterOpened={ isInserterOpened } setIsInserterOpened={ setIsInserterOpened } - isFixedToolbarActive={ isFixedToolbarActive } + isFixedToolbarActive={ + isFixedToolbarActive || ! isMediumViewport + } /> - + { ( isFixedToolbarActive || ! isMediumViewport ) && ( + + ) } .block-editor-block-toolbar__group-collapse-fixed-toolbar { - display: none; - } - - // Scroll sideways. - overflow-y: auto; - z-index: z-index(".customize-widgets__block-toolbar"); -} - .customize-control-sidebar_block_editor .block-editor-block-list__block-popover { // FloatingUI library used in Popover component forces us to have an "absolute" inline style. // We need to override this in the customizer. diff --git a/packages/dataviews/package.json b/packages/dataviews/package.json index 3d15ea435ab4f..1872480d759c3 100644 --- a/packages/dataviews/package.json +++ b/packages/dataviews/package.json @@ -28,7 +28,6 @@ "sideEffects": false, "dependencies": { "@babel/runtime": "^7.16.0", - "@tanstack/react-table": "^8.10.3", "@wordpress/a11y": "file:../a11y", "@wordpress/components": "file:../components", "@wordpress/compose": "file:../compose", diff --git a/packages/dataviews/src/dataviews.js b/packages/dataviews/src/dataviews.js index 13397242953b7..9e7b45d04ef87 100644 --- a/packages/dataviews/src/dataviews.js +++ b/packages/dataviews/src/dataviews.js @@ -49,8 +49,11 @@ export default function DataViews( { }, [ fields ] ); return (
- - + + { search && ( div { @@ -10,13 +9,18 @@ } } +.dataviews__filters-view-actions { + padding: $grid-unit-15 $grid-unit-40; +} + .dataviews-pagination { margin-top: auto; position: sticky; bottom: 0; background-color: $white; - padding: $grid-unit-20 0; - border-top: $border-width solid $gray-200; + padding: $grid-unit-15 $grid-unit-40; + border-top: $border-width solid $gray-100; + color: $gray-700; } .dataviews-filters-options { @@ -29,9 +33,12 @@ border-color: inherit; border-collapse: collapse; position: relative; + color: $gray-700; a { text-decoration: none; + color: $gray-900; + font-weight: 500; } th { text-align: left; @@ -42,6 +49,7 @@ td, th { padding: $grid-unit-15; + min-width: 160px; &[data-field-id="actions"] { text-align: right; } @@ -49,6 +57,16 @@ tr { border-bottom: 1px solid $gray-100; + td:first-child, + th:first-child { + padding-left: $grid-unit-40; + } + + td:last-child, + th:last-child { + padding-right: $grid-unit-40; + } + &:last-child { border-bottom: 0; } @@ -59,9 +77,12 @@ } th { position: sticky; - top: - #{$grid-unit-40}; // Offset the container padding - background-color: $white; - box-shadow: inset 0 -#{$border-width} 0 $gray-200; + top: -1px; + background-color: lighten($gray-100, 4%); + box-shadow: inset 0 -#{$border-width} 0 $gray-100; + border-top: 1px solid $gray-100; + padding-top: $grid-unit-05; + padding-bottom: $grid-unit-05; } } } @@ -69,6 +90,7 @@ .dataviews-grid-view { margin-bottom: $grid-unit-30; grid-template-columns: repeat(2, minmax(0, 1fr)) !important; + padding: 0 $grid-unit-40; @include break-xlarge() { grid-template-columns: repeat(3, minmax(0, 1fr)) !important; // Todo: eliminate !important dependency @@ -128,9 +150,14 @@ } .dataviews-list-view { + margin: 0; + li { border-bottom: $border-width solid $gray-100; margin: 0; + &:first-child { + border-top: $border-width solid $gray-100; + } &:last-child { border-bottom: 0; } @@ -210,3 +237,8 @@ .dataviews-action-modal { z-index: z-index(".dataviews-action-modal"); } + +.dataviews-no-results, +.dataviews-loading { + padding: 0 $grid-unit-40; +} diff --git a/packages/dataviews/src/view-table.js b/packages/dataviews/src/view-table.js index dece8a55107c0..e34d99008657b 100644 --- a/packages/dataviews/src/view-table.js +++ b/packages/dataviews/src/view-table.js @@ -1,15 +1,3 @@ -/** - * External dependencies - */ -import { - getCoreRowModel, - getFilteredRowModel, - getSortedRowModel, - getPaginationRowModel, - useReactTable, - flexRender, -} from '@tanstack/react-table'; - /** * WordPress dependencies */ @@ -30,7 +18,7 @@ import { Icon, privateApis as componentsPrivateApis, } from '@wordpress/components'; -import { useMemo, Children, Fragment } from '@wordpress/element'; +import { Children, Fragment } from '@wordpress/element'; /** * Internal dependencies @@ -48,32 +36,23 @@ const { DropdownSubMenuTriggerV2: DropdownSubMenuTrigger, } = unlock( componentsPrivateApis ); -const EMPTY_OBJECT = {}; const sortingItemsInfo = { asc: { icon: arrowUp, label: __( 'Sort ascending' ) }, desc: { icon: arrowDown, label: __( 'Sort descending' ) }, }; const sortIcons = { asc: chevronUp, desc: chevronDown }; -function HeaderMenu( { dataView, header } ) { - if ( header.isPlaceholder ) { - return null; - } - const text = flexRender( - header.column.columnDef.header, - header.getContext() - ); - const isSortable = !! header.column.getCanSort(); - const isHidable = !! header.column.getCanHide(); +function HeaderMenu( { field, view, onChangeView } ) { + const isSortable = field.enableSorting !== false; + const isHidable = field.enableHiding !== false; if ( ! isSortable && ! isHidable ) { - return text; + return field.header; } - const sortedDirection = header.column.getIsSorted(); - + const isSorted = view.sort?.field === field.id; let filter, filterInView; const otherFilters = []; - if ( header.column.columnDef.type === ENUMERATION_TYPE ) { - let columnOperators = header.column.columnDef.filterBy?.operators; + if ( field.type === ENUMERATION_TYPE ) { + let columnOperators = field.filterBy?.operators; if ( ! columnOperators || ! Array.isArray( columnOperators ) ) { columnOperators = [ OPERATOR_IN, OPERATOR_NOT_IN ]; } @@ -82,9 +61,9 @@ function HeaderMenu( { dataView, header } ) { ); if ( operators.length >= 0 ) { filter = { - field: header.column.columnDef.id, + field: field.id, operators, - elements: header.column.columnDef.elements || [], + elements: field.elements || [], }; filterInView = { field: filter.field, @@ -96,32 +75,27 @@ function HeaderMenu( { dataView, header } ) { const isFilterable = !! filter; if ( isFilterable ) { - const columnFilters = dataView.getState().columnFilters; + const columnFilters = view.filters; columnFilters.forEach( ( columnFilter ) => { - const [ field, operator ] = - Object.keys( columnFilter )[ 0 ].split( ':' ); - const value = Object.values( columnFilter )[ 0 ]; - if ( field === filter.field ) { + if ( columnFilter.field === filter.field ) { filterInView = { - field, - operator, - value, + ...columnFilter, }; } else { otherFilters.push( columnFilter ); } } ); } - return ( } > @@ -129,47 +103,61 @@ function HeaderMenu( { dataView, header } ) { { isSortable && ( { Object.entries( sortingItemsInfo ).map( - ( [ direction, info ] ) => ( - } - suffix={ - sortedDirection === direction && ( - - ) - } - onSelect={ ( event ) => { - event.preventDefault(); - if ( sortedDirection === direction ) { - dataView.resetSorting(); - } else { - dataView.setSorting( [ - { - id: header.column.id, - desc: direction === 'desc', - }, - ] ); + ( [ direction, info ] ) => { + const isActive = + isSorted && + view.sort.direction === direction; + return ( + } + suffix={ + isActive && } - } } - > - { info.label } - - ) + onSelect={ ( event ) => { + event.preventDefault(); + if ( + isSorted && + view.sort.direction === + direction + ) { + onChangeView( { + ...view, + sort: undefined, + } ); + } else { + onChangeView( { + ...view, + sort: { + field: field.id, + direction, + }, + } ); + } + } } + > + { info.label } + + ); + } ) } ) } { isHidable && ( } onSelect={ ( event ) => { event.preventDefault(); - header.column.getToggleVisibilityHandler()( event ); + onChangeView( { + ...view, + hiddenFields: view.hiddenFields.concat( + field.id + ), + } ); } } > { __( 'Hide' ) } @@ -215,17 +203,20 @@ function HeaderMenu( { dataView, header } ) { ) } onSelect={ () => { - dataView.setColumnFilters( [ - ...otherFilters, - { - [ filter.field + - ':' + - filterInView?.operator ]: - isActive + onChangeView( { + ...view, + filters: [ + ...otherFilters, + { + field: filter.field, + operator: + filterInView?.operator, + value: isActive ? undefined : element.value, - }, - ] ); + }, + ], + } ); } } > { element.label } @@ -269,15 +260,18 @@ function HeaderMenu( { dataView, header } ) { ) } onSelect={ () => - dataView.setColumnFilters( [ - ...otherFilters, - { - [ filter.field + - ':' + - OPERATOR_IN ]: - filterInView?.value, - }, - ] ) + onChangeView( { + ...view, + filters: [ + ...otherFilters, + { + field: filter.field, + operator: + OPERATOR_IN, + value: filterInView?.value, + }, + ], + } ) } > { __( 'Is' ) } @@ -296,15 +290,18 @@ function HeaderMenu( { dataView, header } ) { ) } onSelect={ () => - dataView.setColumnFilters( [ - ...otherFilters, - { - [ filter.field + - ':' + - OPERATOR_NOT_IN ]: - filterInView?.value, - }, - ] ) + onChangeView( { + ...view, + filters: [ + ...otherFilters, + { + field: filter.field, + operator: + OPERATOR_NOT_IN, + value: filterInView?.value, + }, + ], + } ) } > { __( 'Is not' ) } @@ -339,275 +336,99 @@ function ViewTable( { data, getItemId, isLoading = false, - paginationInfo, deferredRendering, } ) { - const columns = useMemo( () => { - const _columns = fields.map( ( field ) => { - const { render, getValue, ...column } = field; - column.cell = ( props ) => render( { item: props.row.original } ); - if ( getValue ) { - column.accessorFn = ( item ) => getValue( { item } ); - } - return column; - } ); - if ( actions?.length ) { - _columns.push( { - header: __( 'Actions' ), - id: 'actions', - cell: ( props ) => { - return ( - - ); - }, - enableHiding: false, - } ); - } - - return _columns; - }, [ fields, actions ] ); - - const columnVisibility = useMemo( () => { - if ( ! view.hiddenFields?.length ) { - return; - } - return view.hiddenFields.reduce( - ( accumulator, fieldId ) => ( { - ...accumulator, - [ fieldId ]: false, - } ), - {} - ); - }, [ view.hiddenFields ] ); - - /** - * Transform the filters from the view format into the tanstack columns filter format. - * - * Input: - * - * view.filters = [ - * { field: 'date', operator: 'before', value: '2020-01-01' }, - * { field: 'date', operator: 'after', value: '2020-01-01' }, - * ] - * - * Output: - * - * columnFilters = [ - * { "date:before": '2020-01-01' }, - * { "date:after": '2020-01-01' } - * ] - * - * @param {Array} filters The view filters to transform. - * @return {Array} The transformed TanStack column filters. - */ - const toTanStackColumnFilters = ( filters ) => - filters?.map( ( filter ) => ( { - [ filter.field + ':' + filter.operator ]: filter.value, - } ) ); - - /** - * Transform the filters from the view format into the tanstack columns filter format. - * - * Input: - * - * columnFilters = [ - * { "date:before": '2020-01-01'}, - * { "date:after": '2020-01-01' } - * ] - * - * Output: - * - * view.filters = [ - * { field: 'date', operator: 'before', value: '2020-01-01' }, - * { field: 'date', operator: 'after', value: '2020-01-01' }, - * ] - * - * @param {Array} filters The TanStack column filters to transform. - * @return {Array} The transformed view filters. - */ - const fromTanStackColumnFilters = ( filters ) => - filters.map( ( filter ) => { - const [ key, value ] = Object.entries( filter )[ 0 ]; - const [ field, operator ] = key.split( ':' ); - return { field, operator, value }; - } ); - + const visibleFields = fields.filter( + ( field ) => + ! view.hiddenFields.includes( field.id ) && + ! [ view.layout.mediaField, view.layout.primaryField ].includes( + field.id + ) + ); const shownData = useAsyncList( data ); const usedData = deferredRendering ? shownData : data; - const dataView = useReactTable( { - data: usedData, - columns, - manualSorting: true, - manualFiltering: true, - manualPagination: true, - enableRowSelection: true, - state: { - sorting: view.sort - ? [ - { - id: view.sort.field, - desc: view.sort.direction === 'desc', - }, - ] - : [], - globalFilter: view.search, - columnFilters: toTanStackColumnFilters( view.filters ), - pagination: { - pageIndex: view.page, - pageSize: view.perPage, - }, - columnVisibility: columnVisibility ?? EMPTY_OBJECT, - }, - getRowId: getItemId, - onSortingChange: ( sortingUpdater ) => { - onChangeView( ( currentView ) => { - const sort = - typeof sortingUpdater === 'function' - ? sortingUpdater( - currentView.sort - ? [ - { - id: currentView.sort.field, - desc: - currentView.sort - .direction === 'desc', - }, - ] - : [] - ) - : sortingUpdater; - if ( ! sort.length ) { - return { - ...currentView, - sort: {}, - }; - } - const [ { id, desc } ] = sort; - return { - ...currentView, - sort: { field: id, direction: desc ? 'desc' : 'asc' }, - }; - } ); - }, - onColumnVisibilityChange: ( columnVisibilityUpdater ) => { - onChangeView( ( currentView ) => { - const hiddenFields = Object.entries( - columnVisibilityUpdater() - ).reduce( - ( accumulator, [ fieldId, value ] ) => { - if ( value ) { - return accumulator.filter( - ( id ) => id !== fieldId - ); - } - return [ ...accumulator, fieldId ]; - }, - [ ...( currentView.hiddenFields || [] ) ] - ); - return { - ...currentView, - hiddenFields, - }; - } ); - }, - onGlobalFilterChange: ( value ) => { - onChangeView( { ...view, search: value, page: 1 } ); - }, - onColumnFiltersChange: ( columnFiltersUpdater ) => { - onChangeView( { - ...view, - filters: fromTanStackColumnFilters( columnFiltersUpdater() ), - page: 1, - } ); - }, - onPaginationChange: ( paginationUpdater ) => { - onChangeView( ( currentView ) => { - const { pageIndex, pageSize } = paginationUpdater( { - pageIndex: currentView.page, - pageSize: currentView.perPage, - } ); - return { ...view, page: pageIndex, perPage: pageSize }; - } ); - }, - getCoreRowModel: getCoreRowModel(), - getFilteredRowModel: getFilteredRowModel(), - getSortedRowModel: getSortedRowModel(), - getPaginationRowModel: getPaginationRowModel(), - pageCount: paginationInfo.totalPages, - } ); - - const { rows } = dataView.getRowModel(); - const hasRows = !! rows?.length; + const hasData = !! usedData?.length; if ( isLoading ) { // TODO:Add spinner or progress bar.. - return

{ __( 'Loading' ) }

; + return ( +
+

{ __( 'Loading' ) }

+
+ ); } + const sortValues = { asc: 'ascending', desc: 'descending' }; return (
- { hasRows && ( + { hasData && ( - { dataView.getHeaderGroups().map( ( headerGroup ) => ( - - { headerGroup.headers.map( ( header ) => ( - - ) ) } - - ) ) } + + { visibleFields.map( ( field ) => ( + + ) ) } + { !! actions?.length && ( + + ) } + - { rows.map( ( row ) => ( - - { row.getVisibleCells().map( ( cell ) => ( + { usedData.map( ( item, index ) => ( + + { visibleFields.map( ( field ) => ( ) ) } + { !! actions?.length && ( + + ) } ) ) }
- -
+ + + { __( 'Actions' ) } +
- { flexRender( - cell.column.columnDef.cell, - cell.getContext() - ) } + { field.render( { + item, + } ) } + +
) } - { ! hasRows &&

{ __( 'no results' ) }

} + { ! hasData && ( +
+

{ __( 'No results' ) }

+
+ ) }
); } diff --git a/packages/e2e-tests/package.json b/packages/e2e-tests/package.json index 7327336b19b9b..103daf0498b53 100644 --- a/packages/e2e-tests/package.json +++ b/packages/e2e-tests/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/e2e-tests", - "version": "7.18.0", + "version": "7.18.1", "description": "End-To-End (E2E) tests for WordPress.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/e2e-tests/plugins/interactive-blocks/router-navigate/render.php b/packages/e2e-tests/plugins/interactive-blocks/router-navigate/render.php index 6dceffa32da8f..3fbddf623db60 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/router-navigate/render.php +++ b/packages/e2e-tests/plugins/interactive-blocks/router-navigate/render.php @@ -3,6 +3,7 @@ * HTML for testing the router navigate function. * * @package gutenberg-test-interactive-blocks + * * @phpcs:disable VariableAnalysis.CodeAnalysis.VariableAnalysis.UndefinedVariable */ diff --git a/packages/e2e-tests/plugins/interactive-blocks/router-regions/render.php b/packages/e2e-tests/plugins/interactive-blocks/router-regions/render.php index 0bcc14ccb266f..33c319e130fe2 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/router-regions/render.php +++ b/packages/e2e-tests/plugins/interactive-blocks/router-regions/render.php @@ -3,6 +3,7 @@ * HTML for testing the hydration of router regions. * * @package gutenberg-test-interactive-blocks + * * @phpcs:disable VariableAnalysis.CodeAnalysis.VariableAnalysis.UndefinedVariable */ diff --git a/packages/e2e-tests/plugins/interactive-blocks/store-tag/render.php b/packages/e2e-tests/plugins/interactive-blocks/store-tag/render.php index 57200f295c33b..06deea9e1169d 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/store-tag/render.php +++ b/packages/e2e-tests/plugins/interactive-blocks/store-tag/render.php @@ -3,6 +3,7 @@ * HTML for testing the hydration of the serialized store. * * @package gutenberg-test-interactive-blocks + * * @phpcs:disable VariableAnalysis.CodeAnalysis.VariableAnalysis.UndefinedVariable */ diff --git a/packages/edit-post/package.json b/packages/edit-post/package.json index 0bc4376cedec9..eea3306a2665f 100644 --- a/packages/edit-post/package.json +++ b/packages/edit-post/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/edit-post", - "version": "7.24.0", + "version": "7.24.1", "description": "Edit Post module for WordPress.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/edit-post/src/components/device-preview/index.js b/packages/edit-post/src/components/device-preview/index.js index a10688d185023..9fc95b943609d 100644 --- a/packages/edit-post/src/components/device-preview/index.js +++ b/packages/edit-post/src/components/device-preview/index.js @@ -30,21 +30,19 @@ export default function DevicePreview() { hasActiveMetaboxes: select( editPostStore ).hasMetaBoxes(), isPostSaveable: select( editorStore ).isEditedPostSaveable(), isViewable: postType?.viewable ?? false, - deviceType: - select( editPostStore ).__experimentalGetPreviewDeviceType(), + deviceType: select( editorStore ).getDeviceType(), showIconLabels: select( editPostStore ).isFeatureActive( 'showIconLabels' ), }; }, [] ); - const { __experimentalSetPreviewDeviceType: setPreviewDeviceType } = - useDispatch( editPostStore ); + const { setDeviceType } = useDispatch( editorStore ); return ( diff --git a/packages/edit-post/src/components/header/index.js b/packages/edit-post/src/components/header/index.js index 8bfeb2d253ce1..b92c6c44fe49f 100644 --- a/packages/edit-post/src/components/header/index.js +++ b/packages/edit-post/src/components/header/index.js @@ -7,7 +7,7 @@ import classnames from 'classnames'; * WordPress dependencies */ import { - privateApis as blockEditorPrivateApis, + BlockToolbar, store as blockEditorStore, } from '@wordpress/block-editor'; import { @@ -40,9 +40,6 @@ import { default as DevicePreview } from '../device-preview'; import ViewLink from '../view-link'; import MainDashboardButton from './main-dashboard-button'; import { store as editPostStore } from '../../store'; -import { unlock } from '../../lock-unlock'; - -const { BlockContextualToolbar } = unlock( blockEditorPrivateApis ); const slideY = { hidden: { y: '-50px' }, @@ -130,7 +127,7 @@ function Header( { } ) } > - +
=' ); - const isLargeViewport = useViewportMatch( 'large' ); + const isWideViewport = useViewportMatch( 'large' ); + const isLargeViewport = useViewportMatch( 'medium' ); + const { openGeneralSidebar, closeGeneralSidebar, setIsInserterOpened } = useDispatch( editPostStore ); const { createErrorNotice } = useDispatch( noticesStore ); @@ -148,7 +151,6 @@ function Layout() { isRichEditingEnabled, sidebarIsOpened, hasActiveMetaboxes, - hasFixedToolbar, previousShortcut, nextShortcut, hasBlockSelected, @@ -167,8 +169,6 @@ function Layout() { return { showMetaBoxes: select( editorStore ).getRenderingMode() === 'post-only', - hasFixedToolbar: - select( editPostStore ).isFeatureActive( 'fixedToolbar' ), sidebarIsOpened: !! ( select( interfaceStore ).getActiveComplementaryArea( editPostStore.name @@ -219,12 +219,12 @@ function Layout() { if ( sidebarIsOpened && ! isHugeViewport ) { setIsInserterOpened( false ); } - }, [ sidebarIsOpened, isHugeViewport ] ); + }, [ isHugeViewport, setIsInserterOpened, sidebarIsOpened ] ); useEffect( () => { if ( isInserterOpened && ! isHugeViewport ) { closeGeneralSidebar(); } - }, [ isInserterOpened, isHugeViewport ] ); + }, [ closeGeneralSidebar, isInserterOpened, isHugeViewport ] ); // Local state for save panel. // Note 'truthy' callback implies an open panel. @@ -253,9 +253,8 @@ function Layout() { const className = classnames( 'edit-post-layout', 'is-mode-' + mode, { 'is-sidebar-opened': sidebarIsOpened, - 'has-fixed-toolbar': hasFixedToolbar, 'has-metaboxes': hasActiveMetaboxes, - 'is-distraction-free': isDistractionFree && isLargeViewport, + 'is-distraction-free': isDistractionFree && isWideViewport, 'is-entity-save-view-open': !! entitiesSavedStatesCallback, } ); @@ -302,7 +301,7 @@ function Layout() { ) } + { ! isLargeViewport && } { isRichEditingEnabled && mode === 'visual' && ( ) } diff --git a/packages/edit-post/src/components/visual-editor/index.js b/packages/edit-post/src/components/visual-editor/index.js index 9b975414510c5..b929e03bc453a 100644 --- a/packages/edit-post/src/components/visual-editor/index.js +++ b/packages/edit-post/src/components/visual-editor/index.js @@ -10,12 +10,8 @@ import { store as editorStore, privateApis as editorPrivateApis, } from '@wordpress/editor'; -import { - BlockTools, - __experimentalUseResizeCanvas as useResizeCanvas, -} from '@wordpress/block-editor'; +import { BlockTools } from '@wordpress/block-editor'; import { useRef, useMemo } from '@wordpress/element'; -import { __unstableMotion as motion } from '@wordpress/components'; import { useSelect } from '@wordpress/data'; import { store as blocksStore } from '@wordpress/blocks'; @@ -31,20 +27,17 @@ const isGutenbergPlugin = process.env.IS_GUTENBERG_PLUGIN ? true : false; export default function VisualEditor( { styles } ) { const { - deviceType, isWelcomeGuideVisible, renderingMode, isBlockBasedTheme, hasV3BlocksOnly, } = useSelect( ( select ) => { - const { isFeatureActive, __experimentalGetPreviewDeviceType } = - select( editPostStore ); + const { isFeatureActive } = select( editPostStore ); const { getEditorSettings, getRenderingMode } = select( editorStore ); const { getBlockTypes } = select( blocksStore ); const editorSettings = getEditorSettings(); return { - deviceType: __experimentalGetPreviewDeviceType(), isWelcomeGuideVisible: isFeatureActive( 'welcomeGuide' ), renderingMode: getRenderingMode(), isBlockBasedTheme: editorSettings.__unstableIsBlockBasedTheme, @@ -57,43 +50,12 @@ export default function VisualEditor( { styles } ) { ( select ) => select( editPostStore ).hasMetaBoxes(), [] ); - const desktopCanvasStyles = { - height: '100%', - width: '100%', - marginLeft: 'auto', - marginRight: 'auto', - display: 'flex', - flexFlow: 'column', - // Default background color so that grey - // .edit-post-editor-regions__content color doesn't show through. - background: 'white', - }; - const templateModeStyles = { - ...desktopCanvasStyles, - borderRadius: '2px 2px 0 0', - border: '1px solid #ddd', - borderBottom: 0, - }; - const resizedCanvasStyles = useResizeCanvas( deviceType ); - const previewMode = 'is-' + deviceType.toLowerCase() + '-preview'; - - let animatedStyles = - renderingMode === 'template-only' - ? templateModeStyles - : desktopCanvasStyles; - if ( resizedCanvasStyles ) { - animatedStyles = resizedCanvasStyles; - } let paddingBottom; // Add a constant padding for the typewritter effect. When typing at the // bottom, there needs to be room to scroll up. - if ( - ! hasMetaBoxes && - ! resizedCanvasStyles && - renderingMode === 'post-only' - ) { + if ( ! hasMetaBoxes && renderingMode === 'post-only' ) { paddingBottom = '40vh'; } @@ -115,9 +77,7 @@ export default function VisualEditor( { styles } ) { const isToBeIframed = ( ( hasV3BlocksOnly || ( isGutenbergPlugin && isBlockBasedTheme ) ) && ! hasMetaBoxes ) || - renderingMode === 'template-only' || - deviceType === 'Tablet' || - deviceType === 'Mobile'; + renderingMode === 'template-only'; return ( - - - - - + ); } diff --git a/packages/edit-post/src/editor.js b/packages/edit-post/src/editor.js index cff867c3f7a2c..0abf3328635a8 100644 --- a/packages/edit-post/src/editor.js +++ b/packages/edit-post/src/editor.js @@ -14,6 +14,7 @@ import { SlotFillProvider } from '@wordpress/components'; import { store as coreStore } from '@wordpress/core-data'; import { store as preferencesStore } from '@wordpress/preferences'; import { CommandMenu } from '@wordpress/commands'; +import { useViewportMatch } from '@wordpress/compose'; /** * Internal dependencies @@ -26,6 +27,8 @@ import { unlock } from './lock-unlock'; const { ExperimentalEditorProvider } = unlock( editorPrivateApis ); function Editor( { postId, postType, settings, initialEdits, ...props } ) { + const isLargeViewport = useViewportMatch( 'medium' ); + const { hasFixedToolbar, focusMode, @@ -66,9 +69,9 @@ function Editor( { postId, postType, settings, initialEdits, ...props } ) { getEditorSettings().supportsTemplateMode; const isViewable = getPostType( postType )?.viewable ?? false; const canEditTemplate = canUser( 'create', 'templates' ); - return { - hasFixedToolbar: isFeatureActive( 'fixedToolbar' ), + hasFixedToolbar: + isFeatureActive( 'fixedToolbar' ) || ! isLargeViewport, focusMode: isFeatureActive( 'focusMode' ), isDistractionFree: isFeatureActive( 'distractionFree' ), hasInlineToolbar: isFeatureActive( 'inlineToolbar' ), @@ -86,7 +89,7 @@ function Editor( { postId, postType, settings, initialEdits, ...props } ) { post: postObject, }; }, - [ postType, postId ] + [ postType, postId, isLargeViewport ] ); const { updatePreferredStyleVariations, setIsInserterOpened } = diff --git a/packages/edit-post/src/editor.native.js b/packages/edit-post/src/editor.native.js index b031601186c72..17e2ad1780f1d 100644 --- a/packages/edit-post/src/editor.native.js +++ b/packages/edit-post/src/editor.native.js @@ -192,18 +192,12 @@ class Editor extends Component { export default compose( [ withSelect( ( select ) => { - const { - isFeatureActive, - getEditorMode, - __experimentalGetPreviewDeviceType, - getHiddenBlockTypes, - } = select( editPostStore ); + const { isFeatureActive, getEditorMode, getHiddenBlockTypes } = + select( editPostStore ); const { getBlockTypes } = select( blocksStore ); return { - hasFixedToolbar: - isFeatureActive( 'fixedToolbar' ) || - __experimentalGetPreviewDeviceType() !== 'Desktop', + hasFixedToolbar: isFeatureActive( 'fixedToolbar' ), focusMode: isFeatureActive( 'focusMode' ), mode: getEditorMode(), hiddenBlockTypes: getHiddenBlockTypes(), diff --git a/packages/edit-post/src/store/actions.js b/packages/edit-post/src/store/actions.js index 2659b7ad33398..eae1030fad024 100644 --- a/packages/edit-post/src/store/actions.js +++ b/packages/edit-post/src/store/actions.js @@ -456,18 +456,27 @@ export function metaBoxUpdatesFailure() { } /** - * Returns an action object used to toggle the width of the editing canvas. + * Action that changes the width of the editing canvas. + * + * @deprecated * * @param {string} deviceType * * @return {Object} Action object. */ -export function __experimentalSetPreviewDeviceType( deviceType ) { - return { - type: 'SET_PREVIEW_DEVICE_TYPE', - deviceType, +export const __experimentalSetPreviewDeviceType = + ( deviceType ) => + ( { registry } ) => { + deprecated( + "dispatch( 'core/edit-post' ).__experimentalSetPreviewDeviceType", + { + since: '6.5', + version: '6.7', + hint: 'registry.dispatch( editorStore ).setDeviceType', + } + ); + registry.dispatch( editorStore ).setDeviceType( deviceType ); }; -} /** * Returns an action object used to open/close the inserter. diff --git a/packages/edit-post/src/store/reducer.js b/packages/edit-post/src/store/reducer.js index 4748c4fff4972..1072919d388db 100644 --- a/packages/edit-post/src/store/reducer.js +++ b/packages/edit-post/src/store/reducer.js @@ -98,23 +98,6 @@ export function metaBoxLocations( state = {}, action ) { return state; } -/** - * Reducer returning the editing canvas device type. - * - * @param {Object} state Current state. - * @param {Object} action Dispatched action. - * - * @return {Object} Updated state. - */ -export function deviceType( state = 'Desktop', action ) { - switch ( action.type ) { - case 'SET_PREVIEW_DEVICE_TYPE': - return action.deviceType; - } - - return state; -} - /** * Reducer to set the block inserter panel open or closed. * @@ -179,7 +162,6 @@ export default combineReducers( { metaBoxes, publishSidebarActive, removedPanels, - deviceType, blockInserterPanel, listViewPanel, } ); diff --git a/packages/edit-post/src/store/selectors.js b/packages/edit-post/src/store/selectors.js index f7b8f91d380dc..115dcd9bcd78e 100644 --- a/packages/edit-post/src/store/selectors.js +++ b/packages/edit-post/src/store/selectors.js @@ -450,13 +450,25 @@ export function isSavingMetaBoxes( state ) { /** * Returns the current editing canvas device type. * + * @deprecated + * * @param {Object} state Global application state. * * @return {string} Device type. */ -export function __experimentalGetPreviewDeviceType( state ) { - return state.deviceType; -} +export const __experimentalGetPreviewDeviceType = createRegistrySelector( + ( select ) => () => { + deprecated( + `select( 'core/edit-site' ).__experimentalGetPreviewDeviceType`, + { + since: '6.5', + version: '6.7', + alternative: `select( 'core/editor' ).getDeviceType`, + } + ); + return select( editorStore ).getDeviceType(); + } +); /** * Returns true if the inserter is opened. diff --git a/packages/edit-site/package.json b/packages/edit-site/package.json index 92e9fd8bebe59..eba0a06012da7 100644 --- a/packages/edit-site/package.json +++ b/packages/edit-site/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/edit-site", - "version": "5.24.0", + "version": "5.24.1", "description": "Edit Site Page module for WordPress.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/edit-site/src/components/block-editor/editor-canvas.js b/packages/edit-site/src/components/block-editor/editor-canvas.js index fa20a4abae1ad..d7dbf6fb07a7a 100644 --- a/packages/edit-site/src/components/block-editor/editor-canvas.js +++ b/packages/edit-site/src/components/block-editor/editor-canvas.js @@ -6,10 +6,7 @@ import classnames from 'classnames'; /** * WordPress dependencies */ -import { - __experimentalUseResizeCanvas as useResizeCanvas, - store as blockEditorStore, -} from '@wordpress/block-editor'; +import { store as blockEditorStore } from '@wordpress/block-editor'; import { useSelect, useDispatch } from '@wordpress/data'; import { ENTER, SPACE } from '@wordpress/keycodes'; import { useState, useEffect, useMemo } from '@wordpress/element'; @@ -35,34 +32,24 @@ function EditorCanvas( { contentRef, ...props } ) { - const { - hasBlocks, - isFocusMode, - templateType, - canvasMode, - deviceType, - isZoomOutMode, - } = useSelect( ( select ) => { - const { getBlockCount, __unstableGetEditorMode } = - select( blockEditorStore ); - const { - getEditedPostType, - __experimentalGetPreviewDeviceType, - getCanvasMode, - } = unlock( select( editSiteStore ) ); - const _templateType = getEditedPostType(); + const { hasBlocks, isFocusMode, templateType, canvasMode, isZoomOutMode } = + useSelect( ( select ) => { + const { getBlockCount, __unstableGetEditorMode } = + select( blockEditorStore ); + const { getEditedPostType, getCanvasMode } = unlock( + select( editSiteStore ) + ); + const _templateType = getEditedPostType(); - return { - templateType: _templateType, - isFocusMode: FOCUSABLE_ENTITIES.includes( _templateType ), - deviceType: __experimentalGetPreviewDeviceType(), - isZoomOutMode: __unstableGetEditorMode() === 'zoom-out', - canvasMode: getCanvasMode(), - hasBlocks: !! getBlockCount(), - }; - }, [] ); + return { + templateType: _templateType, + isFocusMode: FOCUSABLE_ENTITIES.includes( _templateType ), + isZoomOutMode: __unstableGetEditorMode() === 'zoom-out', + canvasMode: getCanvasMode(), + hasBlocks: !! getBlockCount(), + }; + }, [] ); const { setCanvasMode } = unlock( useDispatch( editSiteStore ) ); - const deviceStyles = useResizeCanvas( deviceType ); const [ isFocused, setIsFocused ] = useState( false ); useEffect( () => { @@ -130,7 +117,6 @@ function EditorCanvas( { expand: isZoomOutMode, scale: isZoomOutMode ? 0.45 : undefined, frameSize: isZoomOutMode ? 100 : undefined, - style: enableResizing ? {} : deviceStyles, className: classnames( 'edit-site-visual-editor__editor-canvas', { diff --git a/packages/edit-site/src/components/block-editor/site-editor-canvas.js b/packages/edit-site/src/components/block-editor/site-editor-canvas.js index bfbb2d3eac43f..944dbab3f96f0 100644 --- a/packages/edit-site/src/components/block-editor/site-editor-canvas.js +++ b/packages/edit-site/src/components/block-editor/site-editor-canvas.js @@ -24,7 +24,6 @@ import { NAVIGATION_POST_TYPE, } from '../../utils/constants'; import { unlock } from '../../lock-unlock'; -import PageContentFocusNotifications from '../page-content-focus-notifications'; export default function SiteEditorCanvas() { const { clearSelectedBlock } = useDispatch( blockEditorStore ); @@ -60,50 +59,46 @@ export default function SiteEditorCanvas() { const forceFullHeight = isNavigationFocusMode; return ( - <> - - { ( [ editorCanvasView ] ) => - editorCanvasView ? ( -
- { editorCanvasView } -
- ) : ( - { - // Clear selected block when clicking on the gray background. - if ( event.target === event.currentTarget ) { - clearSelectedBlock(); - } - } } + + { ( [ editorCanvasView ] ) => + editorCanvasView ? ( +
+ { editorCanvasView } +
+ ) : ( + { + // Clear selected block when clicking on the gray background. + if ( event.target === event.currentTarget ) { + clearSelectedBlock(); + } + } } + > + + - - - - { resizeObserver } - - - - ) - } -
- - + { resizeObserver } + + +
+ ) + } +
); } diff --git a/packages/edit-site/src/components/block-editor/style.scss b/packages/edit-site/src/components/block-editor/style.scss index e02240eb88099..1da43730d9575 100644 --- a/packages/edit-site/src/components/block-editor/style.scss +++ b/packages/edit-site/src/components/block-editor/style.scss @@ -69,17 +69,6 @@ &.is-view-mode { box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.8), 0 8px 10px -6px rgba(0, 0, 0, 0.8); - - /* - Temporary to hide the contextual toolbar in view mode. - See: https://github.com/WordPress/gutenberg/pull/46298 - This rule can possibly be removed once the - contextual toolbar has been redesigned. - See: https://github.com/WordPress/gutenberg/issues/40450 - */ - .block-editor-block-contextual-toolbar.is-fixed { - display: none; - } } } 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 962cfe09afb72..2deb2d4cb5fa6 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 @@ -1,6 +1,7 @@ /** * WordPress dependencies */ +import { useViewportMatch } from '@wordpress/compose'; import { useDispatch, useSelect } from '@wordpress/data'; import { useMemo } from '@wordpress/element'; import { store as coreStore } from '@wordpress/core-data'; @@ -89,6 +90,7 @@ function useArchiveLabel( templateSlug ) { export function useSpecificEditorSettings() { const { setIsInserterOpened } = useDispatch( editSiteStore ); + const isLargeViewport = useViewportMatch( 'medium' ); const { templateSlug, focusMode, @@ -98,44 +100,46 @@ export function useSpecificEditorSettings() { canvasMode, settings, postWithTemplate, - } = useSelect( ( select ) => { - const { - getEditedPostType, - getEditedPostId, - getEditedPostContext, - getCanvasMode, - getSettings, - } = unlock( select( editSiteStore ) ); - const { get: getPreference } = select( preferencesStore ); - const { getEditedEntityRecord } = select( coreStore ); - const usedPostType = getEditedPostType(); - const usedPostId = getEditedPostId(); - const _record = getEditedEntityRecord( - 'postType', - usedPostType, - usedPostId - ); - const _context = getEditedPostContext(); - return { - templateSlug: _record.slug, - focusMode: !! getPreference( 'core/edit-site', 'focusMode' ), - isDistractionFree: !! getPreference( - 'core/edit-site', - 'distractionFree' - ), - hasFixedToolbar: !! getPreference( - 'core/edit-site', - 'fixedToolbar' - ), - keepCaretInsideBlock: !! getPreference( - 'core/edit-site', - 'keepCaretInsideBlock' - ), - canvasMode: getCanvasMode(), - settings: getSettings(), - postWithTemplate: _context?.postId, - }; - }, [] ); + } = useSelect( + ( select ) => { + const { + getEditedPostType, + getEditedPostId, + getEditedPostContext, + getCanvasMode, + getSettings, + } = unlock( select( editSiteStore ) ); + const { get: getPreference } = select( preferencesStore ); + const { getEditedEntityRecord } = select( coreStore ); + const usedPostType = getEditedPostType(); + const usedPostId = getEditedPostId(); + const _record = getEditedEntityRecord( + 'postType', + usedPostType, + usedPostId + ); + const _context = getEditedPostContext(); + return { + templateSlug: _record.slug, + focusMode: !! getPreference( 'core/edit-site', 'focusMode' ), + isDistractionFree: !! getPreference( + 'core/edit-site', + 'distractionFree' + ), + hasFixedToolbar: + !! getPreference( 'core/edit-site', 'fixedToolbar' ) || + ! isLargeViewport, + keepCaretInsideBlock: !! getPreference( + 'core/edit-site', + 'keepCaretInsideBlock' + ), + canvasMode: getCanvasMode(), + settings: getSettings(), + postWithTemplate: _context?.postId, + }; + }, + [ isLargeViewport ] + ); const archiveLabels = useArchiveLabel( templateSlug ); const defaultRenderingMode = postWithTemplate ? 'template-locked' : 'all'; const defaultEditorSettings = useMemo( () => { diff --git a/packages/edit-site/src/components/editor/index.js b/packages/edit-site/src/components/editor/index.js index 5a2f1e2ec4d1a..295f4ec3cf5c6 100644 --- a/packages/edit-site/src/components/editor/index.js +++ b/packages/edit-site/src/components/editor/index.js @@ -8,10 +8,11 @@ import classnames from 'classnames'; */ import { useSelect } from '@wordpress/data'; import { Notice } from '@wordpress/components'; -import { useInstanceId } from '@wordpress/compose'; +import { useInstanceId, useViewportMatch } from '@wordpress/compose'; import { store as preferencesStore } from '@wordpress/preferences'; import { BlockBreadcrumb, + BlockToolbar, store as blockEditorStore, privateApis as blockEditorPrivateApis, BlockInspector, @@ -92,6 +93,8 @@ export default function Editor( { listViewToggleElement, isLoading } ) { const { type: editedPostType } = editedPost; + const isLargeViewport = useViewportMatch( 'medium' ); + const { context, contextPost, @@ -232,6 +235,9 @@ export default function Editor( { listViewToggleElement, isLoading } ) { + { ! isLargeViewport && ( + + ) } { - const { - __experimentalGetPreviewDeviceType, - isInserterOpened, - isListViewOpened, - getEditorMode, - } = select( editSiteStore ); + const { isInserterOpened, isListViewOpened, getEditorMode } = + select( editSiteStore ); const { getShortcutRepresentation } = select( keyboardShortcutsStore ); return { - deviceType: __experimentalGetPreviewDeviceType(), isInserterOpen: isInserterOpened(), isListViewOpen: isListViewOpened(), listViewShortcut: getShortcutRepresentation( @@ -60,12 +56,10 @@ export default function DocumentTools( { }; }, [] ); - const { - __experimentalSetPreviewDeviceType: setPreviewDeviceType, - setIsInserterOpened, - setIsListViewOpened, - } = useDispatch( editSiteStore ); + const { setIsInserterOpened, setIsListViewOpened } = + useDispatch( editSiteStore ); const { __unstableSetEditorMode } = useDispatch( blockEditorStore ); + const { setDeviceType } = useDispatch( editorStore ); const isLargeViewport = useViewportMatch( 'medium' ); @@ -189,7 +183,7 @@ export default function DocumentTools( { /* translators: button label text should, if possible, be under 16 characters. */ label={ __( 'Zoom-out View' ) } onClick={ () => { - setPreviewDeviceType( 'Desktop' ); + setDeviceType( 'Desktop' ); __unstableSetEditorMode( isZoomedOutView ? 'edit' diff --git a/packages/edit-site/src/components/header-edit-mode/index.js b/packages/edit-site/src/components/header-edit-mode/index.js index f8a9d9d4e892b..2d751be691a74 100644 --- a/packages/edit-site/src/components/header-edit-mode/index.js +++ b/packages/edit-site/src/components/header-edit-mode/index.js @@ -9,8 +9,8 @@ import classnames from 'classnames'; import { useViewportMatch, useReducedMotion } from '@wordpress/compose'; import { store as coreStore } from '@wordpress/core-data'; import { + BlockToolbar, __experimentalPreviewOptions as PreviewOptions, - privateApis as blockEditorPrivateApis, store as blockEditorStore, } from '@wordpress/block-editor'; import { useSelect, useDispatch } from '@wordpress/data'; @@ -27,7 +27,7 @@ import { VisuallyHidden, } from '@wordpress/components'; import { store as preferencesStore } from '@wordpress/preferences'; -import { DocumentBar } from '@wordpress/editor'; +import { DocumentBar, store as editorStore } from '@wordpress/editor'; /** * Internal dependencies @@ -43,8 +43,6 @@ import { import { unlock } from '../../lock-unlock'; import { FOCUSABLE_ENTITIES } from '../../utils/constants'; -const { BlockContextualToolbar } = unlock( blockEditorPrivateApis ); - export default function HeaderEditMode( { setListViewToggleElement } ) { const { deviceType, @@ -58,22 +56,18 @@ export default function HeaderEditMode( { setListViewToggleElement } ) { hasFixedToolbar, isZoomOutMode, } = useSelect( ( select ) => { - const { __experimentalGetPreviewDeviceType, getEditedPostType } = - select( editSiteStore ); + const { getEditedPostType } = select( editSiteStore ); const { getBlockSelectionStart, __unstableGetEditorMode } = select( blockEditorStore ); - - const postType = getEditedPostType(); - const { getUnstableBase, // Site index. } = select( coreStore ); - const { get: getPreference } = select( preferencesStore ); + const { getDeviceType } = select( editorStore ); return { - deviceType: __experimentalGetPreviewDeviceType(), - templateType: postType, + deviceType: getDeviceType(), + templateType: getEditedPostType(), blockEditorMode: __unstableGetEditorMode(), blockSelectionStart: getBlockSelectionStart(), homeUrl: getUnstableBase()?.home, @@ -99,9 +93,7 @@ export default function HeaderEditMode( { setListViewToggleElement } ) { const isLargeViewport = useViewportMatch( 'medium' ); const isTopToolbar = ! isZoomOutMode && hasFixedToolbar && isLargeViewport; const blockToolbarRef = useRef(); - - const { __experimentalSetPreviewDeviceType: setPreviewDeviceType } = - useDispatch( editSiteStore ); + const { setDeviceType } = useDispatch( editorStore ); const disableMotion = useReducedMotion(); const hasDefaultEditorCanvasView = ! useHasEditorCanvasContainer(); @@ -163,7 +155,7 @@ export default function HeaderEditMode( { setListViewToggleElement } ) { } ) } > - +
* + * { margin-left: $grid-unit-10; } + + .block-editor-block-mover { + border-left: none; + + &::before { + content: ""; + width: $border-width; + margin-top: $grid-unit + $grid-unit-05; + margin-bottom: $grid-unit + $grid-unit-05; + background-color: $gray-300; + margin-left: $grid-unit; + } + } } .has-fixed-toolbar { .selected-block-tools-wrapper { overflow-x: scroll; + .block-editor-block-contextual-toolbar { + border-bottom: 0; + } + + // Modified group borders + .components-toolbar-group, + .components-toolbar { + border-right: none; + + &::after { + content: ""; + width: $border-width; + margin-top: $grid-unit + $grid-unit-05; + margin-bottom: $grid-unit + $grid-unit-05; + background-color: $gray-300; + margin-left: $grid-unit; + } + + & .components-toolbar-group.components-toolbar-group { + &::after { + display: none; + } + } + } + &.is-collapsed { display: none; } diff --git a/packages/edit-site/src/components/page-content-focus-notifications/index.js b/packages/edit-site/src/components/page-content-focus-notifications/index.js deleted file mode 100644 index b4d8f62436ba2..0000000000000 --- a/packages/edit-site/src/components/page-content-focus-notifications/index.js +++ /dev/null @@ -1,8 +0,0 @@ -/** - * Internal dependencies - */ -import EditTemplateNotification from './edit-template-notification'; - -export default function PageContentFocusNotifications( { contentRef } ) { - return ; -} diff --git a/packages/edit-site/src/components/page-pages/style.scss b/packages/edit-site/src/components/page-pages/style.scss index fde960ca1a72c..933fdadb8d070 100644 --- a/packages/edit-site/src/components/page-pages/style.scss +++ b/packages/edit-site/src/components/page-pages/style.scss @@ -1,3 +1,5 @@ .edit-site-page-pages__featured-image { - border-radius: $radius-block-ui; + border-radius: $grid-unit-05; + width: $grid-unit-40; + height: $grid-unit-40; } diff --git a/packages/edit-site/src/components/page/header.js b/packages/edit-site/src/components/page/header.js index 06de80c25685b..274fd395a16f1 100644 --- a/packages/edit-site/src/components/page/header.js +++ b/packages/edit-site/src/components/page/header.js @@ -19,7 +19,8 @@ export default function Header( { title, subTitle, actions } ) { { title } diff --git a/packages/edit-site/src/components/page/style.scss b/packages/edit-site/src/components/page/style.scss index 8da7df8e0385b..72ecbb4e2b7d7 100644 --- a/packages/edit-site/src/components/page/style.scss +++ b/packages/edit-site/src/components/page/style.scss @@ -12,8 +12,8 @@ } .edit-site-page-header { - padding: 0 $grid-unit-40; - min-height: $header-height; + padding: $grid-unit-20 $grid-unit-40; + min-height: $grid-unit * 9; border-bottom: 1px solid $gray-100; background: $white; position: sticky; diff --git a/packages/edit-site/src/components/site-hub/index.js b/packages/edit-site/src/components/site-hub/index.js index 9d63001c185c3..7af0d53090c57 100644 --- a/packages/edit-site/src/components/site-hub/index.js +++ b/packages/edit-site/src/components/site-hub/index.js @@ -17,6 +17,7 @@ import { useReducedMotion } from '@wordpress/compose'; import { __ } from '@wordpress/i18n'; import { store as blockEditorStore } from '@wordpress/block-editor'; import { store as coreStore } from '@wordpress/core-data'; +import { store as editorStore } from '@wordpress/editor'; import { decodeEntities } from '@wordpress/html-entities'; import { memo } from '@wordpress/element'; import { search, external } from '@wordpress/icons'; @@ -57,11 +58,9 @@ const SiteHub = memo( ( { isTransparent, className } ) => { const { open: openCommandCenter } = useDispatch( commandsStore ); const disableMotion = useReducedMotion(); - const { - setCanvasMode, - __experimentalSetPreviewDeviceType: setPreviewDeviceType, - } = unlock( useDispatch( editSiteStore ) ); + const { setCanvasMode } = unlock( useDispatch( editSiteStore ) ); const { clearSelectedBlock } = useDispatch( blockEditorStore ); + const { setDeviceType } = useDispatch( editorStore ); const isBackToDashboardButton = canvasMode === 'view'; const siteIconButtonProps = isBackToDashboardButton ? { @@ -76,7 +75,7 @@ const SiteHub = memo( ( { isTransparent, className } ) => { event.preventDefault(); if ( canvasMode === 'edit' ) { clearSelectedBlock(); - setPreviewDeviceType( 'Desktop' ); + setDeviceType( 'Desktop' ); setCanvasMode( 'view' ); } }, diff --git a/packages/edit-site/src/store/actions.js b/packages/edit-site/src/store/actions.js index 2dd7aacd38401..6397a31af120b 100644 --- a/packages/edit-site/src/store/actions.js +++ b/packages/edit-site/src/store/actions.js @@ -10,6 +10,7 @@ import { store as noticesStore } from '@wordpress/notices'; import { store as coreStore } from '@wordpress/core-data'; import { store as interfaceStore } from '@wordpress/interface'; import { store as blockEditorStore } from '@wordpress/block-editor'; +import { store as editorStore } from '@wordpress/editor'; import { speak } from '@wordpress/a11y'; import { store as preferencesStore } from '@wordpress/preferences'; import { decodeEntities } from '@wordpress/html-entities'; @@ -49,16 +50,25 @@ export function toggleFeature( featureName ) { /** * Action that changes the width of the editing canvas. * + * @deprecated + * * @param {string} deviceType * * @return {Object} Action object. */ -export function __experimentalSetPreviewDeviceType( deviceType ) { - return { - type: 'SET_PREVIEW_DEVICE_TYPE', - deviceType, +export const __experimentalSetPreviewDeviceType = + ( deviceType ) => + ( { registry } ) => { + deprecated( + "dispatch( 'core/edit-site' ).__experimentalSetPreviewDeviceType", + { + since: '6.5', + version: '6.7', + hint: 'registry.dispatch( editorStore ).setDeviceType', + } + ); + registry.dispatch( editorStore ).setDeviceType( deviceType ); }; -} /** * Action that sets a template, optionally fetching it from REST API. diff --git a/packages/edit-site/src/store/reducer.js b/packages/edit-site/src/store/reducer.js index a46d215f90507..b55acbffd626e 100644 --- a/packages/edit-site/src/store/reducer.js +++ b/packages/edit-site/src/store/reducer.js @@ -3,23 +3,6 @@ */ import { combineReducers } from '@wordpress/data'; -/** - * Reducer returning the editing canvas device type. - * - * @param {Object} state Current state. - * @param {Object} action Dispatched action. - * - * @return {Object} Updated state. - */ -export function deviceType( state = 'Desktop', action ) { - switch ( action.type ) { - case 'SET_PREVIEW_DEVICE_TYPE': - return action.deviceType; - } - - return state; -} - /** * Reducer returning the settings. * @@ -158,7 +141,6 @@ function editorCanvasContainerView( state = undefined, action ) { } export default combineReducers( { - deviceType, settings, editedPost, blockInserterPanel, diff --git a/packages/edit-site/src/store/selectors.js b/packages/edit-site/src/store/selectors.js index 9d00e141270c4..ebaee12dfdc5e 100644 --- a/packages/edit-site/src/store/selectors.js +++ b/packages/edit-site/src/store/selectors.js @@ -44,13 +44,25 @@ export const isFeatureActive = createRegistrySelector( /** * Returns the current editing canvas device type. * + * @deprecated + * * @param {Object} state Global application state. * * @return {string} Device type. */ -export function __experimentalGetPreviewDeviceType( state ) { - return state.deviceType; -} +export const __experimentalGetPreviewDeviceType = createRegistrySelector( + ( select ) => () => { + deprecated( + `select( 'core/edit-site' ).__experimentalGetPreviewDeviceType`, + { + since: '6.5', + version: '6.7', + alternative: `select( 'core/editor' ).getDeviceType`, + } + ); + return select( editorStore ).getDeviceType(); + } +); /** * Returns whether the current user can create media or not. diff --git a/packages/edit-widgets/package.json b/packages/edit-widgets/package.json index eb318c962c1c3..a983c1893ed12 100644 --- a/packages/edit-widgets/package.json +++ b/packages/edit-widgets/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/edit-widgets", - "version": "5.24.0", + "version": "5.24.1", "description": "Widgets Page module for WordPress..", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/edit-widgets/src/components/header/index.js b/packages/edit-widgets/src/components/header/index.js index 9251f528ca5ee..9d4cb4cb60103 100644 --- a/packages/edit-widgets/src/components/header/index.js +++ b/packages/edit-widgets/src/components/header/index.js @@ -1,7 +1,7 @@ /** * WordPress dependencies */ -import { privateApis as blockEditorPrivateApis } from '@wordpress/block-editor'; +import { BlockToolbar } from '@wordpress/block-editor'; import { useSelect } from '@wordpress/data'; import { useRef } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; @@ -16,9 +16,6 @@ import { store as preferencesStore } from '@wordpress/preferences'; import DocumentTools from './document-tools'; import SaveButton from '../save-button'; import MoreMenu from '../more-menu'; -import { unlock } from '../../lock-unlock'; - -const { BlockContextualToolbar } = unlock( blockEditorPrivateApis ); function Header( { setListViewToggleElement } ) { const isLargeViewport = useViewportMatch( 'medium' ); @@ -56,7 +53,7 @@ function Header( { setListViewToggleElement } ) { { hasFixedToolbar && isLargeViewport && ( <>
- +
{ return hasThemeStyles ? blockEditorSettings.styles : []; @@ -37,6 +40,7 @@ export default function WidgetAreasBlockEditorContent( { return (
+ { ! isLargeViewport && } { const { __experimentalGetTemplateInfo: getTemplateInfo } = select( editorStore ); - const templateInfo = getTemplateInfo( document ); + const templateInfo = getTemplateInfo( doc ); return { templateIcon: templateInfo.icon, templateTitle: templateInfo.title, }; } ); - const isNotFound = ! document && ! isResolving; + const isNotFound = ! doc && ! isResolving; const icon = icons[ postType ] ?? pageIcon; const [ isAnimated, setIsAnimated ] = useState( false ); const isMounting = useRef( true ); @@ -123,7 +123,7 @@ function BaseDocumentActions( { postType, postId, onBack } ) { isMounting.current = false; }, [ postType, postId ] ); - const title = isTemplate ? templateTitle : document.title; + const title = isTemplate ? templateTitle : doc.title; return (
} props.contentRef Ref to the block * editor iframe canvas. */ -export default function EditTemplateNotification( { contentRef } ) { +export default function EditTemplateBlocksNotification( { contentRef } ) { const renderingMode = useSelect( ( select ) => select( editorStore ).getRenderingMode(), [] diff --git a/packages/editor/src/components/editor-canvas/index.js b/packages/editor/src/components/editor-canvas/index.js index 86df470087862..921f3ce23c0ee 100644 --- a/packages/editor/src/components/editor-canvas/index.js +++ b/packages/editor/src/components/editor-canvas/index.js @@ -14,6 +14,7 @@ import { useSettings, __experimentalRecursionProvider as RecursionProvider, privateApis as blockEditorPrivateApis, + __experimentalUseResizeCanvas as useResizeCanvas, } from '@wordpress/block-editor'; import { useEffect, useRef, useMemo, forwardRef } from '@wordpress/element'; import { useSelect } from '@wordpress/data'; @@ -27,6 +28,7 @@ import { useMergeRefs } from '@wordpress/compose'; import PostTitle from '../post-title'; import { store as editorStore } from '../../store'; import { unlock } from '../../lock-unlock'; +import EditTemplateBlocksNotification from './edit-template-blocks-notification'; const { LayoutStyle, @@ -89,6 +91,7 @@ function EditorCanvas( editedPostTemplate = {}, wrapperBlockName, wrapperUniqueId, + deviceType, } = useSelect( ( select ) => { const { getCurrentPostId, @@ -96,7 +99,10 @@ function EditorCanvas( getCurrentTemplateId, getEditorSettings, getRenderingMode, + getDeviceType, } = select( editorStore ); + const { getPostType, canUser, getEditedEntityRecord } = + select( coreStore ); const postTypeSlug = getCurrentPostType(); const _renderingMode = getRenderingMode(); let _wrapperBlockName; @@ -109,14 +115,11 @@ function EditorCanvas( const editorSettings = getEditorSettings(); const supportsTemplateMode = editorSettings.supportsTemplateMode; - const postType = select( coreStore ).getPostType( postTypeSlug ); - const canEditTemplate = select( coreStore ).canUser( - 'create', - 'templates' - ); + const postType = getPostType( postTypeSlug ); + const canEditTemplate = canUser( 'create', 'templates' ); const currentTemplateId = getCurrentTemplateId(); const template = currentTemplateId - ? select( coreStore ).getEditedEntityRecord( + ? getEditedEntityRecord( 'postType', 'wp_template', currentTemplateId @@ -134,6 +137,7 @@ function EditorCanvas( : undefined, wrapperBlockName: _wrapperBlockName, wrapperUniqueId: getCurrentPostId(), + deviceType: getDeviceType(), }; }, [] ); const { isCleanNewPost } = useSelect( editorStore ); @@ -151,6 +155,7 @@ function EditorCanvas( }; }, [] ); + const deviceStyles = useResizeCanvas( deviceType ); const [ globalLayoutSettings ] = useSettings( 'layout' ); // fallbackLayout is used if there is no Post Content, @@ -291,11 +296,16 @@ function EditorCanvas( return ( { themeSupportsLayout && ! themeHasDisabledLayoutStyles && @@ -347,6 +357,7 @@ function EditorCanvas( + { children } diff --git a/packages/editor/src/components/post-title/index.native.js b/packages/editor/src/components/post-title/index.native.js index 1ec0dd3ade3bf..6d905e743581e 100644 --- a/packages/editor/src/components/post-title/index.native.js +++ b/packages/editor/src/components/post-title/index.native.js @@ -155,7 +155,6 @@ class PostTitle extends Component { tagsToEliminate={ [ 'strong' ] } unstableOnFocus={ this.props.onSelect } onBlur={ this.props.onBlur } // Always assign onBlur as a props. - multiline={ false } style={ titleStyles } styles={ styles } fontSize={ 24 } diff --git a/packages/editor/src/components/provider/index.js b/packages/editor/src/components/provider/index.js index 714e05e5385bc..fc49339c2c13f 100644 --- a/packages/editor/src/components/provider/index.js +++ b/packages/editor/src/components/provider/index.js @@ -164,13 +164,12 @@ export const ExperimentalEditorProvider = withRegistryProvider( updatePostLock, setupEditor, updateEditorSettings, - __experimentalTearDownEditor, setCurrentTemplateId, + setEditedPost, setRenderingMode, } = unlock( useDispatch( editorStore ) ); const { createWarningNotice } = useDispatch( noticesStore ); - // Initialize and tear down the editor. // Ideally this should be synced on each change and not just something you do once. useLayoutEffect( () => { // Assume that we don't need to initialize in the case of an error recovery. @@ -196,17 +195,19 @@ export const ExperimentalEditorProvider = withRegistryProvider( } ); } - - return () => { - __experimentalTearDownEditor(); - }; }, [] ); + // Synchronizes the active post with the state + useEffect( () => { + setEditedPost( post.type, post.id ); + }, [ post.type, post.id ] ); + // Synchronize the editor settings as they change. useEffect( () => { updateEditorSettings( settings ); }, [ settings, updateEditorSettings ] ); + // Synchronizes the active template with the state. useEffect( () => { setCurrentTemplateId( template?.id ); }, [ template?.id, setCurrentTemplateId ] ); diff --git a/packages/editor/src/store/actions.js b/packages/editor/src/store/actions.js index 4c1170b064202..8fe0822e6a016 100644 --- a/packages/editor/src/store/actions.js +++ b/packages/editor/src/store/actions.js @@ -36,7 +36,7 @@ import { export const setupEditor = ( post, edits, template ) => ( { dispatch } ) => { - dispatch.setupEditorState( post ); + dispatch.setEditedPost( post.type, post.id ); // Apply a template for new posts only, if exists. const isNewPost = post.status === 'auto-draft'; if ( isNewPost && template ) { @@ -70,10 +70,18 @@ export const setupEditor = * Returns an action object signalling that the editor is being destroyed and * that any necessary state or side-effect cleanup should occur. * + * @deprecated + * * @return {Object} Action object. */ export function __experimentalTearDownEditor() { - return { type: 'TEAR_DOWN_EDITOR' }; + deprecated( + "wp.data.dispatch( 'core/editor' ).__experimentalTearDownEditor", + { + since: '6.5', + } + ); + return { type: 'DO_NOTHING' }; } /** @@ -109,17 +117,33 @@ export function updatePost() { } /** - * Returns an action object used to setup the editor state when first opening - * an editor. + * Setup the editor state. + * + * @deprecated * * @param {Object} post Post object. + */ +export function setupEditorState( post ) { + deprecated( "wp.data.dispatch( 'core/editor' ).setupEditorState", { + since: '6.5', + alternative: "wp.data.dispatch( 'core/editor' ).setEditedPost", + } ); + return setEditedPost( post.type, post.id ); +} + +/** + * Returns an action that sets the current post Type and post ID. + * + * @param {string} postType Post Type. + * @param {string} postId Post ID. * * @return {Object} Action object. */ -export function setupEditorState( post ) { +export function setEditedPost( postType, postId ) { return { - type: 'SETUP_EDITOR_STATE', - post, + type: 'SET_EDITED_POST', + postType, + postId, }; } @@ -573,6 +597,20 @@ export const setRenderingMode = } ); }; +/** + * Action that changes the width of the editing canvas. + * + * @param {string} deviceType + * + * @return {Object} Action object. + */ +export function setDeviceType( deviceType ) { + return { + type: 'SET_DEVICE_TYPE', + deviceType, + }; +} + /** * Backward compatibility */ diff --git a/packages/editor/src/store/reducer.js b/packages/editor/src/store/reducer.js index 7821baf5cdc06..a4323b5967956 100644 --- a/packages/editor/src/store/reducer.js +++ b/packages/editor/src/store/reducer.js @@ -83,8 +83,8 @@ export function shouldOverwriteState( action, previousAction ) { export function postId( state = null, action ) { switch ( action.type ) { - case 'SETUP_EDITOR_STATE': - return action.post.id; + case 'SET_EDITED_POST': + return action.postId; } return state; @@ -101,8 +101,8 @@ export function templateId( state = null, action ) { export function postType( state = null, action ) { switch ( action.type ) { - case 'SETUP_EDITOR_STATE': - return action.post.type; + case 'SET_EDITED_POST': + return action.postType; } return state; @@ -246,28 +246,6 @@ export function postAutosavingLock( state = {}, action ) { return state; } -/** - * Reducer returning whether the editor is ready to be rendered. - * The editor is considered ready to be rendered once - * the post object is loaded properly and the initial blocks parsed. - * - * @param {boolean} state - * @param {Object} action - * - * @return {boolean} Updated state. - */ -export function isReady( state = false, action ) { - switch ( action.type ) { - case 'SETUP_EDITOR_STATE': - return true; - - case 'TEAR_DOWN_EDITOR': - return false; - } - - return state; -} - /** * Reducer returning the post editor setting. * @@ -297,6 +275,23 @@ export function renderingMode( state = 'all', action ) { return state; } +/** + * Reducer returning the editing canvas device type. + * + * @param {Object} state Current state. + * @param {Object} action Dispatched action. + * + * @return {Object} Updated state. + */ +export function deviceType( state = 'Desktop', action ) { + switch ( action.type ) { + case 'SET_DEVICE_TYPE': + return action.deviceType; + } + + return state; +} + export default combineReducers( { postId, postType, @@ -306,8 +301,8 @@ export default combineReducers( { postLock, template, postSavingLock, - isReady, editorSettings, postAutosavingLock, renderingMode, + deviceType, } ); diff --git a/packages/editor/src/store/reducer.native.js b/packages/editor/src/store/reducer.native.js index 991addd88620b..7566dfc5dfd03 100644 --- a/packages/editor/src/store/reducer.native.js +++ b/packages/editor/src/store/reducer.native.js @@ -13,7 +13,6 @@ import { postLock, postSavingLock, template, - isReady, editorSettings, } from './reducer.js'; @@ -87,7 +86,6 @@ export default combineReducers( { postLock, postSavingLock, template, - isReady, editorSettings, clipboard, notices, diff --git a/packages/editor/src/store/selectors.js b/packages/editor/src/store/selectors.js index c47a80e96735f..3b3f315812430 100644 --- a/packages/editor/src/store/selectors.js +++ b/packages/editor/src/store/selectors.js @@ -1185,7 +1185,7 @@ export function getEditorSelection( state ) { * @return {boolean} is Ready. */ export function __unstableIsEditorReady( state ) { - return state.isReady; + return !! state.postId; } /** @@ -1210,6 +1210,17 @@ export function getRenderingMode( state ) { return state.renderingMode; } +/** + * Returns the current editing canvas device type. + * + * @param {Object} state Global application state. + * + * @return {string} Device type. + */ +export function getDeviceType( state ) { + return state.deviceType; +} + /* * Backward compatibility */ diff --git a/packages/icons/src/index.js b/packages/icons/src/index.js index 36b2971423442..d743299d35a24 100644 --- a/packages/icons/src/index.js +++ b/packages/icons/src/index.js @@ -190,6 +190,7 @@ export { default as postList } from './library/post-list'; export { default as postTerms } from './library/post-terms'; export { default as previous } from './library/previous'; export { default as next } from './library/next'; +export { default as offline } from './library/offline'; export { default as preformatted } from './library/preformatted'; export { default as pullLeft } from './library/pull-left'; export { default as pullRight } from './library/pull-right'; diff --git a/packages/icons/src/library/offline.js b/packages/icons/src/library/offline.js new file mode 100644 index 0000000000000..f0daa1aaeb79e --- /dev/null +++ b/packages/icons/src/library/offline.js @@ -0,0 +1,22 @@ +/** + * WordPress dependencies + */ +import { SVG, Path } from '@wordpress/primitives'; + +const offline = ( + + + +); + +export default offline; diff --git a/packages/interactivity/package.json b/packages/interactivity/package.json index da118762e3c02..bf8576fd67ae7 100644 --- a/packages/interactivity/package.json +++ b/packages/interactivity/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/interactivity", - "version": "3.0.0", + "version": "3.0.1", "description": "Package that provides a standard and simple way to handle the frontend interactivity of Gutenberg blocks.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/keycodes/package.json b/packages/keycodes/package.json index c98ccc24cb5d8..9531b4980c1e2 100644 --- a/packages/keycodes/package.json +++ b/packages/keycodes/package.json @@ -28,8 +28,7 @@ "sideEffects": false, "dependencies": { "@babel/runtime": "^7.16.0", - "@wordpress/i18n": "file:../i18n", - "change-case": "^4.1.2" + "@wordpress/i18n": "file:../i18n" }, "publishConfig": { "access": "public" diff --git a/packages/keycodes/src/index.js b/packages/keycodes/src/index.js index a9efb210496c3..6b01109e2a40d 100644 --- a/packages/keycodes/src/index.js +++ b/packages/keycodes/src/index.js @@ -9,11 +9,6 @@ * shortcut combos directly to keyboardShortcut(). */ -/** - * External dependencies - */ -import { capitalCase } from 'change-case'; - /** * WordPress dependencies */ @@ -148,6 +143,17 @@ export const ZERO = 48; export { isAppleOS }; +/** + * Capitalise the first character of a string. + * @param {string} string String to capitalise. + * @return {string} Capitalised string. + */ +function capitaliseFirstCharacter( string ) { + return string.length < 2 + ? string.toUpperCase() + : string.charAt( 0 ).toUpperCase() + string.slice( 1 ); +} + /** * Map the values of an object with a specified callback and return the result object. * @@ -260,14 +266,7 @@ export const displayShortcutList = mapValues( /** @type {string[]} */ ( [] ) ); - // Symbols (~`,.) are removed by the default regular expression, - // so override the rule to allow symbols used for shortcuts. - // see: https://github.com/blakeembrey/change-case#options - const capitalizedCharacter = capitalCase( character, { - stripRegexp: /[^A-Z0-9~`,\.\\\-]/gi, - } ); - - return [ ...modifierKeys, capitalizedCharacter ]; + return [ ...modifierKeys, capitaliseFirstCharacter( character ) ]; }; } ); @@ -335,7 +334,7 @@ export const shortcutAriaLabel = mapValues( return [ ...modifier( _isApple ), character ] .map( ( key ) => - capitalCase( replacementKeyMap[ key ] ?? key ) + capitaliseFirstCharacter( replacementKeyMap[ key ] ?? key ) ) .join( isApple ? ' ' : ' + ' ); }; diff --git a/packages/react-native-editor/CHANGELOG.md b/packages/react-native-editor/CHANGELOG.md index 992d61ea9ce37..791b39fee2c7f 100644 --- a/packages/react-native-editor/CHANGELOG.md +++ b/packages/react-native-editor/CHANGELOG.md @@ -12,6 +12,7 @@ For each user feature we should also add a importance categorization label to i ## Unreleased - [*] [internal] Move InserterButton from components package to block-editor package [#56494] - [*] [internal] Move ImageLinkDestinationsScreen from components package to block-editor package [#56775] +- [*] Guard against an Image block styles crash due to null block values [#56903] ## 1.109.2 - [**] Fix issue related to text color format and receiving in rare cases an undefined ref from `RichText` component [#56686] diff --git a/packages/rich-text/README.md b/packages/rich-text/README.md index 90726ff238c1b..90fd15e1c905c 100644 --- a/packages/rich-text/README.md +++ b/packages/rich-text/README.md @@ -355,6 +355,19 @@ _Returns_ - `RichTextValue`: A new value with replacements applied. +### RichTextData + +The RichTextData class is used to instantiate a wrapper around rich text values, with methods that can be used to transform or manipulate the data. + +- Create an emtpy instance: `new RichTextData()`. +- Create one from an html string: `RichTextData.fromHTMLString( +'hello' )`. +- Create one from a wrapper HTMLElement: `RichTextData.fromHTMLElement( +document.querySelector( 'p' ) )`. +- Create one from plain text: `RichTextData.fromPlainText( '1\n2' )`. +- Create one from a rich text value: `new RichTextData( { text: '...', +formats: [ ... ] } )`. + ### RichTextValue An object which represents a formatted string. See main `@wordpress/rich-text` documentation for more information. diff --git a/packages/rich-text/src/component/index.js b/packages/rich-text/src/component/index.js index 0e9291b7a5e03..a2b5734d5c204 100644 --- a/packages/rich-text/src/component/index.js +++ b/packages/rich-text/src/component/index.js @@ -8,7 +8,7 @@ import { useRegistry } from '@wordpress/data'; /** * Internal dependencies */ -import { collapseWhiteSpace, create } from '../create'; +import { create, RichTextData } from '../create'; import { apply } from '../to-dom'; import { toHTMLString } from '../to-html-string'; import { useDefaultStyle } from './use-default-style'; @@ -70,11 +70,18 @@ export function useRichText( { function setRecordFromProps() { _value.current = value; - record.current = create( { - html: preserveWhiteSpace - ? value - : collapseWhiteSpace( typeof value === 'string' ? value : '' ), - } ); + record.current = value; + if ( ! ( value instanceof RichTextData ) ) { + record.current = value + ? RichTextData.fromHTMLString( value, { preserveWhiteSpace } ) + : RichTextData.empty(); + } + // To do: make rich text internally work with RichTextData. + record.current = { + text: record.current.text, + formats: record.current.formats, + replacements: record.current.replacements, + }; if ( disableFormats ) { record.current.formats = Array( value.length ); record.current.replacements = Array( value.length ); @@ -117,17 +124,18 @@ export function useRichText( { if ( disableFormats ) { _value.current = newRecord.text; } else { - _value.current = toHTMLString( { - value: __unstableBeforeSerialize - ? { - ...newRecord, - formats: __unstableBeforeSerialize( newRecord ), - } - : newRecord, - } ); + const newFormats = __unstableBeforeSerialize + ? __unstableBeforeSerialize( newRecord ) + : newRecord.formats; + newRecord = { ...newRecord, formats: newFormats }; + if ( typeof value === 'string' ) { + _value.current = toHTMLString( { value: newRecord } ); + } else { + _value.current = new RichTextData( newRecord ); + } } - const { start, end, formats, text } = newRecord; + const { start, end, formats, text } = record.current; // Selection must be updated first, so it is recorded in history when // the content change happens. diff --git a/packages/rich-text/src/create.js b/packages/rich-text/src/create.js index a23baf70078bc..a35fabbd4e2fa 100644 --- a/packages/rich-text/src/create.js +++ b/packages/rich-text/src/create.js @@ -10,6 +10,8 @@ import { store as richTextStore } from './store'; import { createElement } from './create-element'; import { mergePair } from './concat'; import { OBJECT_REPLACEMENT_CHARACTER, ZWNBSP } from './special-characters'; +import { toHTMLString } from './to-html-string'; +import { getTextContent } from './get-text-content'; /** @typedef {import('./types').RichTextValue} RichTextValue */ @@ -96,6 +98,86 @@ function toFormat( { tagName, attributes } ) { }; } +// Ideally we use a private property. +const RichTextInternalData = Symbol( 'RichTextInternalData' ); + +/** + * The RichTextData class is used to instantiate a wrapper around rich text + * values, with methods that can be used to transform or manipulate the data. + * + * - Create an emtpy instance: `new RichTextData()`. + * - Create one from an html string: `RichTextData.fromHTMLString( + * 'hello' )`. + * - Create one from a wrapper HTMLElement: `RichTextData.fromHTMLElement( + * document.querySelector( 'p' ) )`. + * - Create one from plain text: `RichTextData.fromPlainText( '1\n2' )`. + * - Create one from a rich text value: `new RichTextData( { text: '...', + * formats: [ ... ] } )`. + * + * @todo Add methods to manipulate the data, such as applyFormat, slice etc. + */ +export class RichTextData { + static empty() { + return new RichTextData(); + } + static fromPlainText( text ) { + return new RichTextData( create( { text } ) ); + } + static fromHTMLString( html ) { + return new RichTextData( create( { html } ) ); + } + static fromHTMLElement( htmlElement, options = {} ) { + const { preserveWhiteSpace = false } = options; + const element = preserveWhiteSpace + ? htmlElement + : collapseWhiteSpace( htmlElement ); + const richTextData = new RichTextData( create( { element } ) ); + Object.defineProperty( richTextData, 'originalHTML', { + value: htmlElement.innerHTML, + } ); + return richTextData; + } + constructor( init = createEmptyValue() ) { + // Setting text, formats, and replacements as enumerable properties + // unfortunately visualises these in the e2e tests. As long as the class + // instance doesn't have any enumerable properties, it will be + // visualised as a string. + Object.defineProperty( this, RichTextInternalData, { value: init } ); + } + toPlainText() { + return getTextContent( this[ RichTextInternalData ] ); + } + // We could expose `toHTMLElement` at some point as well, but we'd only use + // it internally. + toHTMLString() { + return ( + this.originalHTML || + toHTMLString( { value: this[ RichTextInternalData ] } ) + ); + } + valueOf() { + return this.toHTMLString(); + } + toString() { + return this.toHTMLString(); + } + toJSON() { + return this.toHTMLString(); + } + get length() { + return this.text.length; + } + get formats() { + return this[ RichTextInternalData ].formats; + } + get replacements() { + return this[ RichTextInternalData ].replacements; + } + get text() { + return this[ RichTextInternalData ].text; + } +} + /** * Create a RichText value from an `Element` tree (DOM), an HTML string or a * plain text string, with optionally a `Range` object to set the selection. If @@ -128,7 +210,6 @@ function toFormat( { tagName, attributes } ) { * @param {string} [$1.html] HTML to create value from. * @param {Range} [$1.range] Range to create value from. * @param {boolean} [$1.__unstableIsEditableTree] - * * @return {RichTextValue} A rich text value. */ export function create( { @@ -138,6 +219,14 @@ export function create( { range, __unstableIsEditableTree: isEditableTree, } = {} ) { + if ( html instanceof RichTextData ) { + return { + text: html.text, + formats: html.formats, + replacements: html.replacements, + }; + } + if ( typeof text === 'string' && text.length > 0 ) { return { formats: Array( text.length ), @@ -268,10 +357,42 @@ function filterRange( node, range, filter ) { * @see * https://developer.mozilla.org/en-US/docs/Web/CSS/white-space-collapse#collapsing_of_white_space * - * @param {string} string + * @param {HTMLElement} element + * @param {boolean} isRoot + * + * @return {HTMLElement} New element with collapsed whitespace. */ -export function collapseWhiteSpace( string ) { - return string.replace( /[\n\r\t]+/g, ' ' ); +function collapseWhiteSpace( element, isRoot = true ) { + const clone = element.cloneNode( true ); + clone.normalize(); + Array.from( clone.childNodes ).forEach( ( node, i, nodes ) => { + if ( node.nodeType === node.TEXT_NODE ) { + let newNodeValue = node.nodeValue; + + if ( /[\n\t\r\f]/.test( newNodeValue ) ) { + newNodeValue = newNodeValue.replace( /[\n\t\r\f]+/g, ' ' ); + } + + if ( newNodeValue.indexOf( ' ' ) !== -1 ) { + newNodeValue = newNodeValue.replace( / {2,}/g, ' ' ); + } + + if ( i === 0 && newNodeValue.startsWith( ' ' ) ) { + newNodeValue = newNodeValue.slice( 1 ); + } else if ( + isRoot && + i === nodes.length - 1 && + newNodeValue.endsWith( ' ' ) + ) { + newNodeValue = newNodeValue.slice( 0, -1 ); + } + + node.nodeValue = newNodeValue; + } else if ( node.nodeType === node.ELEMENT_NODE ) { + collapseWhiteSpace( node, false ); + } + } ); + return clone; } /** diff --git a/packages/rich-text/src/index.ts b/packages/rich-text/src/index.ts index 14d26cab8f7fb..f82317d81573d 100644 --- a/packages/rich-text/src/index.ts +++ b/packages/rich-text/src/index.ts @@ -1,7 +1,7 @@ export { store } from './store'; export { applyFormat } from './apply-format'; export { concat } from './concat'; -export { create } from './create'; +export { RichTextData, create } from './create'; export { getActiveFormat } from './get-active-format'; export { getActiveFormats } from './get-active-formats'; export { getActiveObject } from './get-active-object'; diff --git a/schemas/json/block.json b/schemas/json/block.json index d5b4a04452eaa..7e0c8715a4abd 100644 --- a/schemas/json/block.json +++ b/schemas/json/block.json @@ -114,6 +114,7 @@ "object", "array", "string", + "rich-text", "integer", "number" ] @@ -159,6 +160,7 @@ "enum": [ "attribute", "text", + "rich-text", "html", "raw", "query", diff --git a/storybook/stories/playground/box/index.js b/storybook/stories/playground/box/index.js index 444b7810e5e89..4cb7047b73ec2 100644 --- a/storybook/stories/playground/box/index.js +++ b/storybook/stories/playground/box/index.js @@ -6,6 +6,7 @@ import { registerCoreBlocks } from '@wordpress/block-library'; import { BlockEditorProvider, BlockCanvas, + BlockToolbar, BlockTools, } from '@wordpress/block-editor'; @@ -36,6 +37,7 @@ export default function EditorBox() { hasFixedToolbar: true, } } > + diff --git a/storybook/stories/playground/with-undo-redo/index.js b/storybook/stories/playground/with-undo-redo/index.js index a51d8624282a6..537ea16aade99 100644 --- a/storybook/stories/playground/with-undo-redo/index.js +++ b/storybook/stories/playground/with-undo-redo/index.js @@ -7,6 +7,7 @@ import { registerCoreBlocks } from '@wordpress/block-library'; import { BlockEditorProvider, BlockCanvas, + BlockToolbar, BlockTools, } from '@wordpress/block-editor'; import { Button } from '@wordpress/components'; @@ -58,6 +59,7 @@ export default function EditorWithUndoRedo() { icon={ redoIcon } label="Redo" /> +
diff --git a/storybook/stories/playground/with-undo-redo/style.css b/storybook/stories/playground/with-undo-redo/style.css index a3f0bd5d23deb..6ed082a1de719 100644 --- a/storybook/stories/playground/with-undo-redo/style.css +++ b/storybook/stories/playground/with-undo-redo/style.css @@ -6,5 +6,9 @@ display: flex; align-items: center; border-bottom: 1px solid #ddd; - height: 48px; + height: 46px; } + +.editor-with-undo-redo__toolbar .components-accessible-toolbar.block-editor-block-contextual-toolbar { + border-bottom: none; +} \ No newline at end of file diff --git a/test/e2e/specs/editor/various/is-typing.spec.js b/test/e2e/specs/editor/various/is-typing.spec.js index 0cd5e0d6f6495..8063f688409c4 100644 --- a/test/e2e/specs/editor/various/is-typing.spec.js +++ b/test/e2e/specs/editor/various/is-typing.spec.js @@ -14,24 +14,27 @@ test.describe( 'isTyping', () => { // Insert paragraph await page.keyboard.type( 'Type' ); - const blockToolbar = page.locator( - 'role=toolbar[name="Block tools"i]' + const blockToolbarPopover = page.locator( + '[data-wp-component="Popover"]', + { + has: page.locator( 'role=toolbar[name="Block tools"i]' ), + } ); - // Toolbar should not be showing - await expect( blockToolbar ).toBeHidden(); + // Toolbar Popover should not be showing + await expect( blockToolbarPopover ).toBeHidden(); // Moving the mouse shows the toolbar. await editor.showBlockToolbar(); - // Toolbar is visible. - await expect( blockToolbar ).toBeVisible(); + // Toolbar Popover is visible. + await expect( blockToolbarPopover ).toBeVisible(); // Typing again hides the toolbar await page.keyboard.type( ' and continue' ); - // Toolbar is hidden again - await expect( blockToolbar ).toBeHidden(); + // Toolbar Popover is hidden again + await expect( blockToolbarPopover ).toBeHidden(); } ); test( 'should not close the dropdown when typing in it', async ( { @@ -41,17 +44,22 @@ test.describe( 'isTyping', () => { // Add a block with a dropdown in the toolbar that contains an input. await editor.insertBlock( { name: 'core/query' } ); - // Tab to Start Blank Button - await page.keyboard.press( 'Tab' ); - // Select the Start Blank Button - await page.keyboard.press( 'Enter' ); - // Select the First variation - await page.keyboard.press( 'Enter' ); + await editor.canvas + .getByRole( 'document', { name: 'Block: Query Loop' } ) + .getByRole( 'button', { name: 'Start blank' } ) + .click(); + + await editor.canvas + .getByRole( 'button', { name: 'Title & Date' } ) + .click(); + // Moving the mouse shows the toolbar. await editor.showBlockToolbar(); // Open the dropdown. - await page.getByRole( 'button', { name: 'Display settings' } ).click(); - + const displaySettings = page.getByRole( 'button', { + name: 'Display settings', + } ); + await displaySettings.click(); const itemsPerPageInput = page.getByLabel( 'Items per Page' ); // Make sure we're where we think we are await expect( itemsPerPageInput ).toBeFocused(); diff --git a/test/e2e/specs/editor/various/shortcut-focus-toolbar.spec.js b/test/e2e/specs/editor/various/shortcut-focus-toolbar.spec.js index 0223821613f55..a8e49f7a6b84d 100644 --- a/test/e2e/specs/editor/various/shortcut-focus-toolbar.spec.js +++ b/test/e2e/specs/editor/various/shortcut-focus-toolbar.spec.js @@ -98,6 +98,10 @@ test.describe( 'Focus toolbar shortcut (alt + F10)', () => { // Test: Focus the block toolbar from empty block await editor.insertBlock( { name: 'core/paragraph' } ); + // This fails if we don't wait for the block toolbar to show. + await expect( + toolbarUtils.blockToolbarParagraphButton + ).toBeVisible(); await toolbarUtils.moveToToolbarShortcut(); await expect( toolbarUtils.blockToolbarParagraphButton diff --git a/test/e2e/specs/site-editor/block-removal.spec.js b/test/e2e/specs/site-editor/block-removal.spec.js index 5c46ac769efd3..27d3762364b44 100644 --- a/test/e2e/specs/site-editor/block-removal.spec.js +++ b/test/e2e/specs/site-editor/block-removal.spec.js @@ -34,7 +34,9 @@ test.describe( 'Site editor block removal prompt', () => { // Expect the block removal prompt to have appeared await expect( - page.getByText( 'Query Loop displays a list of posts or pages.' ) + page.getByText( + 'Post or page content will not be displayed if you delete these blocks.' + ) ).toBeVisible(); } ); @@ -57,7 +59,7 @@ test.describe( 'Site editor block removal prompt', () => { // Expect the block removal prompt to have appeared await expect( page.getByText( - 'Post Template displays each post or page in a Query Loop.' + 'Post or page content will not be displayed if you delete this block.' ) ).toBeVisible(); } ); diff --git a/test/integration/fixtures/blocks/core__gallery__deprecated-1.json b/test/integration/fixtures/blocks/core__gallery__deprecated-1.json index 9e15ee7f1c714..bd6108a97230a 100644 --- a/test/integration/fixtures/blocks/core__gallery__deprecated-1.json +++ b/test/integration/fixtures/blocks/core__gallery__deprecated-1.json @@ -16,6 +16,7 @@ "attributes": { "url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACklEQVR4nGMAAQAABQABDQottAAAAABJRU5ErkJggg==", "alt": "title", + "caption": "", "linkDestination": "none" }, "innerBlocks": [] @@ -26,6 +27,7 @@ "attributes": { "url": "data:image/jpeg;base64,/9j/2wBDAAMCAgICAgMCAgIDAwMDBAYEBAQEBAgGBgUGCQgKCgkICQkKDA8MCgsOCwkJDRENDg8QEBEQCgwSExIQEw8QEBD/yQALCAABAAEBAREA/8wABgAQEAX/2gAIAQEAAD8A0s8g/9k=", "alt": "title", + "caption": "", "linkDestination": "none" }, "innerBlocks": [] diff --git a/test/integration/fixtures/documents/ms-word-online-out.html b/test/integration/fixtures/documents/ms-word-online-out.html index 398281520f254..8187b598f9a91 100644 --- a/test/integration/fixtures/documents/ms-word-online-out.html +++ b/test/integration/fixtures/documents/ms-word-online-out.html @@ -8,33 +8,33 @@
    -
  • +
  • -
  • Bulleted 
  • +
  • Bulleted 
  • -
  • Indented 
  • +
  • Indented 
  • -
  • List 
  • +
  • List 
    -
  1. One 
  2. +
  3. One 
  4. -
  5. Two 
  6. +
  7. Two 
  8. -
  9. Three 
  10. +
  11. Three 
diff --git a/test/integration/non-matched-tags-handling.test.js b/test/integration/non-matched-tags-handling.test.js index 67438192f1368..451a628c32977 100644 --- a/test/integration/non-matched-tags-handling.test.js +++ b/test/integration/non-matched-tags-handling.test.js @@ -19,9 +19,9 @@ describe( 'Handling of non matched tags in block transforms', () => { expect( simplePreformattedResult ).toHaveLength( 1 ); expect( simplePreformattedResult[ 0 ].name ).toBe( 'core/paragraph' ); - expect( simplePreformattedResult[ 0 ].attributes.content ).toBe( - 'Pre' - ); + expect( + simplePreformattedResult[ 0 ].attributes.content.valueOf() + ).toBe( 'Pre' ); const codeResult = pasteHandler( { HTML: '
code
', @@ -30,7 +30,7 @@ describe( 'Handling of non matched tags in block transforms', () => { expect( codeResult ).toHaveLength( 1 ); expect( codeResult[ 0 ].name ).toBe( 'core/code' ); - expect( codeResult[ 0 ].attributes.content ).toBe( 'code' ); + expect( codeResult[ 0 ].attributes.content.valueOf() ).toBe( 'code' ); expect( console ).toHaveLogged(); } ); } );