diff --git a/docs/manifest.json b/docs/manifest.json index 84c7da42aa8b2..638c5d2b16c83 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -1679,6 +1679,12 @@ "markdown_source": "../packages/interactivity/README.md", "parent": "packages" }, + { + "title": "API Reference", + "slug": "packages-interactivity-api-reference", + "markdown_source": "../packages/interactivity/docs/api-reference.md", + "parent": "packages-interactivity" + }, { "title": "@wordpress/interface", "slug": "packages-interface", diff --git a/lib/compat/wordpress-6.5/fonts/class-wp-font-collection.php b/lib/compat/wordpress-6.5/fonts/class-wp-font-collection.php index cd402a8e54500..3ccbed6ed275e 100644 --- a/lib/compat/wordpress-6.5/fonts/class-wp-font-collection.php +++ b/lib/compat/wordpress-6.5/fonts/class-wp-font-collection.php @@ -48,12 +48,11 @@ final class WP_Font_Collection { * * @since 6.5.0 * - * @param string $slug Font collection slug. - * @param array|string $data_or_file Font collection data array or a path/URL to a JSON file - * containing the font collection. - * See {@see wp_register_font_collection()} for the supported fields. + * @param string $slug Font collection slug. May only contain alphanumeric characters, dashes, + * and underscores. See sanitize_title(). + * @param array $args Font collection data. See wp_register_font_collection() for information on accepted arguments. */ - public function __construct( $slug, $data_or_file ) { + public function __construct( string $slug, array $args ) { $this->slug = sanitize_title( $slug ); if ( $this->slug !== $slug ) { _doing_it_wrong( @@ -64,12 +63,17 @@ public function __construct( $slug, $data_or_file ) { ); } - if ( is_array( $data_or_file ) ) { - $this->data = $this->sanitize_and_validate_data( $data_or_file ); - } else { + $required_properties = array( 'name', 'font_families' ); + + if ( isset( $args['font_families'] ) && is_string( $args['font_families'] ) ) { // JSON data is lazy loaded by ::get_data(). - $this->src = $data_or_file; + $this->src = $args['font_families']; + unset( $args['font_families'] ); + + $required_properties = array( 'name' ); } + + $this->data = $this->sanitize_and_validate_data( $args, $required_properties ); } /** @@ -80,8 +84,12 @@ public function __construct( $slug, $data_or_file ) { * @return array|WP_Error An array containing the font collection data, or a WP_Error on failure. */ public function get_data() { + if ( is_wp_error( $this->data ) ) { + return $this->data; + } + // If the collection uses JSON data, load it and cache the data/error. - if ( $this->src && empty( $this->data ) ) { + if ( isset( $this->src ) ) { $this->data = $this->load_from_json( $this->src ); } @@ -118,7 +126,26 @@ private function load_from_json( $file_or_url ) { return new WP_Error( 'font_collection_json_missing', $message ); } - return $url ? $this->load_from_url( $url ) : $this->load_from_file( $file ); + $data = $url ? $this->load_from_url( $url ) : $this->load_from_file( $file ); + + if ( is_wp_error( $data ) ) { + return $data; + } + + $data = array( + 'name' => $this->data['name'], + 'font_families' => $data['font_families'], + ); + + if ( isset( $this->data['description'] ) ) { + $data['description'] = $this->data['description']; + } + + if ( isset( $this->data['categories'] ) ) { + $data['categories'] = $this->data['categories']; + } + + return $data; } /** @@ -136,7 +163,7 @@ private function load_from_file( $file ) { return new WP_Error( 'font_collection_decode_error', __( 'Error decoding the font collection JSON file contents.', 'gutenberg' ) ); } - return $this->sanitize_and_validate_data( $data ); + return $this->sanitize_and_validate_data( $data, array( 'font_families' ) ); } /** @@ -156,8 +183,14 @@ private function load_from_url( $url ) { if ( false === $data ) { $response = wp_safe_remote_get( $url ); if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) { - // translators: %s: Font collection URL. - return new WP_Error( 'font_collection_request_error', sprintf( __( 'Error fetching the font collection data from "%s".', 'gutenberg' ), $url ) ); + return new WP_Error( + 'font_collection_request_error', + sprintf( + // translators: %s: Font collection URL. + __( 'Error fetching the font collection data from "%s".', 'gutenberg' ), + $url + ) + ); } $data = json_decode( wp_remote_retrieve_body( $response ), true ); @@ -166,7 +199,7 @@ private function load_from_url( $url ) { } // Make sure the data is valid before storing it in a transient. - $data = $this->sanitize_and_validate_data( $data ); + $data = $this->sanitize_and_validate_data( $data, array( 'font_families' ) ); if ( is_wp_error( $data ) ) { return $data; } @@ -182,18 +215,18 @@ private function load_from_url( $url ) { * * @since 6.5.0 * - * @param array $data Font collection data to sanitize and validate. + * @param array $data Font collection data to sanitize and validate. + * @param array $required_properties Required properties that must exist in the passed data. * @return array|WP_Error Sanitized data if valid, otherwise a WP_Error instance. */ - private function sanitize_and_validate_data( $data ) { + private function sanitize_and_validate_data( $data, $required_properties = array() ) { $schema = self::get_sanitization_schema(); $data = WP_Font_Utils::sanitize_from_schema( $data, $schema ); - $required_properties = array( 'name', 'font_families' ); foreach ( $required_properties as $property ) { if ( empty( $data[ $property ] ) ) { $message = sprintf( - // translators: 1: Font collection slug, 2: Missing property name, e.g. "font_families". + // translators: 1: Font collection slug, 2: Missing property name, e.g. "font_families". __( 'Font collection "%1$s" has missing or empty property: "%2$s".', 'gutenberg' ), $this->slug, $property diff --git a/lib/compat/wordpress-6.5/fonts/class-wp-font-library.php b/lib/compat/wordpress-6.5/fonts/class-wp-font-library.php index 141dff730a15f..eb4a269b3a806 100644 --- a/lib/compat/wordpress-6.5/fonts/class-wp-font-library.php +++ b/lib/compat/wordpress-6.5/fonts/class-wp-font-library.php @@ -39,20 +39,19 @@ class WP_Font_Library { * * @since 6.5.0 * - * @param string $slug Font collection slug. - * @param array $data_or_file Font collection data array or a path/URL to a JSON file - * containing the font collection. - * See {@see wp_register_font_collection()} for the supported fields. + * @param string $slug Font collection slug. May only contain alphanumeric characters, dashes, + * and underscores. See sanitize_title(). + * @param array $args Font collection data. See wp_register_font_collection() for information on accepted arguments. * @return WP_Font_Collection|WP_Error A font collection if it was registered successfully, * or WP_Error object on failure. */ - public function register_font_collection( $slug, $data_or_file ) { - $new_collection = new WP_Font_Collection( $slug, $data_or_file ); + public function register_font_collection( string $slug, array $args ) { + $new_collection = new WP_Font_Collection( $slug, $args ); if ( $this->is_collection_registered( $new_collection->slug ) ) { $error_message = sprintf( /* translators: %s: Font collection slug. */ - __( 'Font collection with slug "%s" is already registered.', 'gutenberg' ), + __( 'Font collection with slug: "%s" is already registered.', 'gutenberg' ), $new_collection->slug ); _doing_it_wrong( @@ -74,7 +73,7 @@ public function register_font_collection( $slug, $data_or_file ) { * @param string $slug Font collection slug. * @return bool True if the font collection was unregistered successfully and false otherwise. */ - public function unregister_font_collection( $slug ) { + public function unregister_font_collection( string $slug ) { if ( ! $this->is_collection_registered( $slug ) ) { _doing_it_wrong( __METHOD__, @@ -96,7 +95,7 @@ public function unregister_font_collection( $slug ) { * @param string $slug Font collection slug. * @return bool True if the font collection is registered and false otherwise. */ - private function is_collection_registered( $slug ) { + private function is_collection_registered( string $slug ) { return array_key_exists( $slug, $this->collections ); } @@ -119,7 +118,7 @@ public function get_font_collections() { * @param string $slug Font collection slug. * @return WP_Font_Collection|null Font collection object, or null if the font collection doesn't exist. */ - public function get_font_collection( $slug ) { + public function get_font_collection( string $slug ) { if ( $this->is_collection_registered( $slug ) ) { return $this->collections[ $slug ]; } diff --git a/lib/compat/wordpress-6.5/fonts/class-wp-rest-font-collections-controller.php b/lib/compat/wordpress-6.5/fonts/class-wp-rest-font-collections-controller.php index 97aba89a8b24e..2433a8d4ff955 100644 --- a/lib/compat/wordpress-6.5/fonts/class-wp-rest-font-collections-controller.php +++ b/lib/compat/wordpress-6.5/fonts/class-wp-rest-font-collections-controller.php @@ -106,7 +106,7 @@ public function get_items( $request ) { $response = rest_ensure_response( $items ); $response->header( 'X-WP-Total', (int) $total_items ); - $response->header( 'X-WP-TotalPages', (int) $max_pages ); + $response->header( 'X-WP-TotalPages', $max_pages ); $request_params = $request->get_query_params(); $collection_url = rest_url( $this->namespace . '/' . $this->rest_base ); diff --git a/lib/compat/wordpress-6.5/fonts/fonts.php b/lib/compat/wordpress-6.5/fonts/fonts.php index 1f29645a9c2b9..8307d5217ad42 100644 --- a/lib/compat/wordpress-6.5/fonts/fonts.php +++ b/lib/compat/wordpress-6.5/fonts/fonts.php @@ -114,28 +114,30 @@ function gutenberg_init_font_library() { if ( ! function_exists( 'wp_register_font_collection' ) ) { /** - * Registers a new Font Collection in the Font Library. + * Registers a new font collection in the font library. + * + * See {@link https://schemas.wp.org/trunk/font-collection.json} for the schema + * the font collection data must adhere to. * * @since 6.5.0 * - * @param string $slug Font collection slug. May only contain alphanumeric characters, dashes, + * @param string $slug Font collection slug. May only contain alphanumeric characters, dashes, * and underscores. See sanitize_title(). - * @param array|string $data_or_file { - * Font collection data array or a path/URL to a JSON file containing the font collection. - * - * @link https://schemas.wp.org/trunk/font-collection.json + * @param array $args { + * Font collection data. * - * @type string $name Required. Name of the font collection shown in the Font Library. - * @type string $description Optional. A short descriptive summary of the font collection. Default empty. - * @type array $font_families Required. Array of font family definitions that are in the collection. - * @type array $categories Optional. Array of categories, each with a name and slug, that are used by the - * fonts in the collection. Default empty. + * @type string $name Required. Name of the font collection shown in the Font Library. + * @type string $description Optional. A short descriptive summary of the font collection. Default empty. + * @type array|string $font_families Required. Array of font family definitions that are in the collection, + * or a string containing the path or URL to a JSON file containing the font collection. + * @type array $categories Optional. Array of categories, each with a name and slug, that are used by the + * fonts in the collection. Default empty. * } * @return WP_Font_Collection|WP_Error A font collection if it was registered * successfully, or WP_Error object on failure. */ - function wp_register_font_collection( $slug, $data_or_file ) { - return WP_Font_Library::get_instance()->register_font_collection( $slug, $data_or_file ); + function wp_register_font_collection( string $slug, array $args ) { + return WP_Font_Library::get_instance()->register_font_collection( $slug, $args ); } } @@ -148,7 +150,7 @@ function wp_register_font_collection( $slug, $data_or_file ) { * @param string $slug Font collection slug. * @return bool True if the font collection was unregistered successfully, else false. */ - function wp_unregister_font_collection( $slug ) { + function wp_unregister_font_collection( string $slug ) { return WP_Font_Library::get_instance()->unregister_font_collection( $slug ); } } @@ -157,7 +159,36 @@ function gutenberg_register_font_collections() { if ( null !== WP_Font_Library::get_instance()->get_font_collection( 'google-fonts' ) ) { return; } - wp_register_font_collection( 'google-fonts', 'https://s.w.org/images/fonts/17.7/collections/google-fonts-with-preview.json' ); + wp_register_font_collection( + 'google-fonts', + array( + 'name' => _x( 'Google Fonts', 'font collection name', 'gutenberg' ), + 'description' => __( 'Install from Google Fonts. Fonts are copied to and served from your site.', 'gutenberg' ), + 'font_families' => 'https://s.w.org/images/fonts/17.7/collections/google-fonts-with-preview.json', + 'categories' => array( + array( + 'name' => _x( 'Sans Serif', 'font category', 'gutenberg' ), + 'slug' => 'sans-serif', + ), + array( + 'name' => _x( 'Display', 'font category', 'gutenberg' ), + 'slug' => 'display', + ), + array( + 'name' => _x( 'Serif', 'font category', 'gutenberg' ), + 'slug' => 'serif', + ), + array( + 'name' => _x( 'Handwriting', 'font category', 'gutenberg' ), + 'slug' => 'handwriting', + ), + array( + 'name' => _x( 'Monospace', 'font category', 'gutenberg' ), + 'slug' => 'monospace', + ), + ), + ) + ); } add_action( 'init', 'gutenberg_register_font_collections', 11 ); diff --git a/packages/block-editor/src/components/index.native.js b/packages/block-editor/src/components/index.native.js index ac4b45af3609c..debf5a074a07a 100644 --- a/packages/block-editor/src/components/index.native.js +++ b/packages/block-editor/src/components/index.native.js @@ -49,6 +49,7 @@ export { default as MediaUploadProgress } from './media-upload-progress'; export { MEDIA_UPLOAD_STATE_UPLOADING, MEDIA_UPLOAD_STATE_SUCCEEDED, + MEDIA_UPLOAD_STATE_PAUSED, MEDIA_UPLOAD_STATE_FAILED, MEDIA_UPLOAD_STATE_RESET, } from './media-upload-progress/constants'; diff --git a/packages/block-editor/src/components/link-control/link-preview.js b/packages/block-editor/src/components/link-control/link-preview.js index 88a19cc4c7af4..d922187ec3aa0 100644 --- a/packages/block-editor/src/components/link-control/link-preview.js +++ b/packages/block-editor/src/components/link-control/link-preview.js @@ -16,8 +16,9 @@ import { useCopyToClipboard } from '@wordpress/compose'; import { filterURLForDisplay, safeDecodeURI } from '@wordpress/url'; import { Icon, globe, info, linkOff, edit, copySmall } from '@wordpress/icons'; import { __unstableStripHTML as stripHTML } from '@wordpress/dom'; -import { useDispatch } from '@wordpress/data'; +import { useDispatch, useSelect } from '@wordpress/data'; import { store as noticesStore } from '@wordpress/notices'; +import { store as preferencesStore } from '@wordpress/preferences'; /** * Internal dependencies @@ -33,6 +34,12 @@ export default function LinkPreview( { hasUnlinkControl = false, onRemove, } ) { + const showIconLabels = useSelect( + ( select ) => + select( preferencesStore ).get( 'core', 'showIconLabels' ), + [] + ); + // Avoid fetching if rich previews are not desired. const showRichPreviews = hasRichPreviews ? value?.url : null; @@ -139,7 +146,7 @@ export default function LinkPreview( { label={ sprintf( // Translators: %s is a placeholder for the link URL and an optional colon, (if a Link URL is present). __( 'Copy link%s' ), // Ends up looking like "Copy link: https://example.com". - isEmptyURL ? '' : ': ' + value.url + isEmptyURL || showIconLabels ? '' : ': ' + value.url ) } ref={ ref } disabled={ isEmptyURL } diff --git a/packages/block-editor/src/components/link-control/style.scss b/packages/block-editor/src/components/link-control/style.scss index 425df96ab31fa..9531ad6f0c7a0 100644 --- a/packages/block-editor/src/components/link-control/style.scss +++ b/packages/block-editor/src/components/link-control/style.scss @@ -34,6 +34,15 @@ $block-editor-link-control-number-of-actions: 1; content: attr(aria-label); } } + + .block-editor-link-control__search-item-top { + gap: $grid-unit-10; + + .components-button.has-icon { + min-width: inherit; + width: min-content; + } + } } } diff --git a/packages/block-editor/src/components/url-popover/style.scss b/packages/block-editor/src/components/url-popover/style.scss index df4e10fe13d53..324d82d4183aa 100644 --- a/packages/block-editor/src/components/url-popover/style.scss +++ b/packages/block-editor/src/components/url-popover/style.scss @@ -58,6 +58,7 @@ text-overflow: ellipsis; white-space: nowrap; margin-right: $grid-unit-10; + min-width: 150px; // Avoids the popover from growing too wide when the URL is long. // See https://github.com/WordPress/gutenberg/issues/58599 max-width: $modal-min-width; diff --git a/packages/block-library/src/button/edit.js b/packages/block-library/src/button/edit.js index ff90cdd1bf64c..5e5c978007b17 100644 --- a/packages/block-library/src/button/edit.js +++ b/packages/block-library/src/button/edit.js @@ -123,7 +123,7 @@ function WidthPanel( { selectedWidth, setAttributes } ) { } return ( - + { [ 25, 50, 75, 100 ].map( ( widthValue ) => { return ( diff --git a/packages/block-library/src/buttons/transforms.js b/packages/block-library/src/buttons/transforms.js index 3e89b4973e372..9848299f3a99f 100644 --- a/packages/block-library/src/buttons/transforms.js +++ b/packages/block-library/src/buttons/transforms.js @@ -4,6 +4,11 @@ import { createBlock } from '@wordpress/blocks'; import { __unstableCreateElement as createElement } from '@wordpress/rich-text'; +/** + * Internal dependencies + */ +import { getTransformedMetadata } from '../utils/get-transformed-metadata'; + const transforms = { from: [ { @@ -33,10 +38,8 @@ const transforms = { {}, // Loop the selected buttons. buttons.map( ( attributes ) => { - const element = createElement( - document, - attributes.content - ); + const { content, metadata } = attributes; + const element = createElement( document, content ); // Remove any HTML tags. const text = element.innerText || ''; // Get first url. @@ -46,6 +49,13 @@ const transforms = { return createBlock( 'core/button', { text, url, + metadata: getTransformedMetadata( + metadata, + 'core/button', + ( { content: contentBinding } ) => ( { + text: contentBinding, + } ) + ), } ); } ) ), diff --git a/packages/block-library/src/code/transforms.js b/packages/block-library/src/code/transforms.js index af6d4686af812..e537db342b8d5 100644 --- a/packages/block-library/src/code/transforms.js +++ b/packages/block-library/src/code/transforms.js @@ -4,6 +4,11 @@ import { createBlock } from '@wordpress/blocks'; import { create, toHTMLString } from '@wordpress/rich-text'; +/** + * Internal dependencies + */ +import { getTransformedMetadata } from '../utils/get-transformed-metadata'; + const transforms = { from: [ { @@ -14,17 +19,21 @@ const transforms = { { type: 'block', blocks: [ 'core/paragraph' ], - transform: ( { content } ) => - createBlock( 'core/code', { content } ), + transform: ( { content, metadata } ) => + createBlock( 'core/code', { + content, + metadata: getTransformedMetadata( metadata, 'core/code' ), + } ), }, { type: 'block', blocks: [ 'core/html' ], - transform: ( { content: text } ) => { + transform: ( { content: text, metadata } ) => { return createBlock( 'core/code', { // The HTML is plain text (with plain line breaks), so // convert it to rich text. content: toHTMLString( { value: create( { text } ) } ), + metadata: getTransformedMetadata( metadata, 'core/code' ), } ); }, }, @@ -51,8 +60,14 @@ const transforms = { { type: 'block', blocks: [ 'core/paragraph' ], - transform: ( { content } ) => - createBlock( 'core/paragraph', { content } ), + transform: ( { content, metadata } ) => + createBlock( 'core/paragraph', { + content, + metadata: getTransformedMetadata( + metadata, + 'core/paragraph' + ), + } ), }, ], }; diff --git a/packages/block-library/src/comment-author-avatar/edit.js b/packages/block-library/src/comment-author-avatar/edit.js index a8c831db620c8..3b25bf6916c8b 100644 --- a/packages/block-library/src/comment-author-avatar/edit.js +++ b/packages/block-library/src/comment-author-avatar/edit.js @@ -48,7 +48,7 @@ export default function Edit( { const inspectorControls = ( - + { 'hidden' !== type && ( - + { 'checkbox' !== type && ( - attributes.map( ( { content, anchor, align: textAlign } ) => - createBlock( 'core/heading', { - content, - anchor, - textAlign, - } ) + attributes.map( + ( { content, anchor, align: textAlign, metadata } ) => + createBlock( 'core/heading', { + content, + anchor, + textAlign, + metadata: getTransformedMetadata( + metadata, + 'core/heading', + ( { content: contentBinding } ) => ( { + content: contentBinding, + } ) + ), + } ) ), }, { @@ -82,8 +91,18 @@ const transforms = { isMultiBlock: true, blocks: [ 'core/paragraph' ], transform: ( attributes ) => - attributes.map( ( { content, textAlign: align } ) => - createBlock( 'core/paragraph', { content, align } ) + attributes.map( ( { content, textAlign: align, metadata } ) => + createBlock( 'core/paragraph', { + content, + align, + metadata: getTransformedMetadata( + metadata, + 'core/paragraph', + ( { content: contentBinding } ) => ( { + content: contentBinding, + } ) + ), + } ) ), }, ], diff --git a/packages/block-library/src/list/ordered-list-settings.js b/packages/block-library/src/list/ordered-list-settings.js index 5d5d9936c73a0..8c433a465638f 100644 --- a/packages/block-library/src/list/ordered-list-settings.js +++ b/packages/block-library/src/list/ordered-list-settings.js @@ -12,7 +12,7 @@ import { const OrderedListSettings = ( { setAttributes, reversed, start, type } ) => ( - + { + uploadCallBack = callback; +} ); +sendMediaUpload.mockImplementation( ( payload ) => { + uploadCallBack( payload ); +} ); + +setupCoreBlocks( [ 'core/media-text' ] ); + +describe( 'Media & Text block edit', () => { + it( 'should display an error message for failed video uploads', async () => { + requestMediaPicker.mockImplementation( + ( source, filter, multiple, callback ) => { + callback( { + id: 1, + url: 'file://video.mp4', + type: 'video', + } ); + } + ); + await initializeEditor(); + await addBlock( screen, 'Media & Text' ); + fireEvent.press( screen.getByText( 'Add image or video' ) ); + fireEvent.press( screen.getByText( 'Choose from device' ) ); + + sendMediaUpload( { + mediaId: 1, + state: MEDIA_UPLOAD_STATE_PAUSED, + progress: 0, + } ); + + expect( + screen.getByText( 'Failed to insert media.\nTap for more info.' ) + ).toBeVisible(); + } ); +} ); diff --git a/packages/block-library/src/navigation/index.php b/packages/block-library/src/navigation/index.php index 5dcada62f6feb..3a3a654aee612 100644 --- a/packages/block-library/src/navigation/index.php +++ b/packages/block-library/src/navigation/index.php @@ -554,14 +554,18 @@ private static function get_nav_element_directives( $is_interactive ) { // When adding to this array be mindful of security concerns. $nav_element_context = data_wp_context( array( - 'overlayOpenedBy' => array(), + 'overlayOpenedBy' => array( + 'click' => false, + 'hover' => false, + 'focus' => false, + ), 'type' => 'overlay', 'roleAttribute' => '', 'ariaLabel' => __( 'Menu' ), ) ); $nav_element_directives = ' - data-wp-interactive="core/navigation"' + data-wp-interactive="core/navigation" ' . $nav_element_context; return $nav_element_directives; @@ -764,7 +768,7 @@ function block_core_navigation_add_directives_to_submenu( $tags, $block_attribut ) ) { // Add directives to the parent `
  • `. $tags->set_attribute( 'data-wp-interactive', 'core/navigation' ); - $tags->set_attribute( 'data-wp-context', '{ "submenuOpenedBy": {}, "type": "submenu" }' ); + $tags->set_attribute( 'data-wp-context', '{ "submenuOpenedBy": { "click": false, "hover": false, "focus": false }, "type": "submenu" }' ); $tags->set_attribute( 'data-wp-watch', 'callbacks.initMenu' ); $tags->set_attribute( 'data-wp-on--focusout', 'actions.handleMenuFocusout' ); $tags->set_attribute( 'data-wp-on--keydown', 'actions.handleMenuKeydown' ); diff --git a/packages/block-library/src/post-featured-image/edit.js b/packages/block-library/src/post-featured-image/edit.js index 26f3439964f90..a3b3b1ec6398a 100644 --- a/packages/block-library/src/post-featured-image/edit.js +++ b/packages/block-library/src/post-featured-image/edit.js @@ -24,6 +24,7 @@ import { useBlockProps, store as blockEditorStore, __experimentalUseBorderProps as useBorderProps, + useBlockEditingMode, } from '@wordpress/block-editor'; import { useMemo } from '@wordpress/element'; import { __, sprintf } from '@wordpress/i18n'; @@ -143,6 +144,7 @@ export default function PostFeaturedImageEdit( { style: { width, height, aspectRatio }, } ); const borderProps = useBorderProps( attributes ); + const blockEditingMode = useBlockEditingMode(); const placeholder = ( content ) => { return ( @@ -174,8 +176,13 @@ export default function PostFeaturedImageEdit( { createErrorNotice( message, { type: 'snackbar' } ); }; - const controls = ( + const controls = blockEditingMode === 'default' && ( <> + ); + let image; /** @@ -251,11 +259,6 @@ export default function PostFeaturedImageEdit( { ) : ( placeholder() ) } - ); @@ -360,11 +363,6 @@ export default function PostFeaturedImageEdit( { ) : ( image ) } - ); diff --git a/packages/block-library/src/post-title/edit.js b/packages/block-library/src/post-title/edit.js index 28d9b8cdcfd8f..1cb89bc3e5e67 100644 --- a/packages/block-library/src/post-title/edit.js +++ b/packages/block-library/src/post-title/edit.js @@ -115,53 +115,59 @@ export default function PostTitleEdit( { return ( <> { blockEditingMode === 'default' && ( - - - setAttributes( { level: newLevel } ) - } - /> - { - setAttributes( { textAlign: nextAlign } ); - } } - /> - - ) } - - - setAttributes( { isLink: ! isLink } ) } - checked={ isLink } - /> - { isLink && ( - <> + <> + + + setAttributes( { level: newLevel } ) + } + /> + { + setAttributes( { textAlign: nextAlign } ); + } } + /> + + + - setAttributes( { - linkTarget: value ? '_blank' : '_self', - } ) + label={ __( 'Make title a link' ) } + onChange={ () => + setAttributes( { isLink: ! isLink } ) } - checked={ linkTarget === '_blank' } + checked={ isLink } /> - - setAttributes( { rel: newRel } ) - } - /> - - ) } - - + { isLink && ( + <> + + setAttributes( { + linkTarget: value + ? '_blank' + : '_self', + } ) + } + checked={ linkTarget === '_blank' } + /> + + setAttributes( { rel: newRel } ) + } + /> + + ) } + + + + ) } { titleElement } ); diff --git a/packages/block-library/src/search/edit.js b/packages/block-library/src/search/edit.js index 0123bdfd56569..2f60b1f650ef8 100644 --- a/packages/block-library/src/search/edit.js +++ b/packages/block-library/src/search/edit.js @@ -400,7 +400,7 @@ export default function SearchEdit( { - + + ); +} + export default function TemplatePartEdit( { attributes, setAttributes, clientId, } ) { + const { createSuccessNotice } = useDispatch( noticesStore ); const currentTheme = useSelect( ( select ) => select( coreStore ).getCurrentTheme()?.stylesheet, [] @@ -117,12 +139,28 @@ export default function TemplatePartEdit( { [ templatePartId, attributes.area, clientId ] ); + const { templateParts } = useAlternativeTemplateParts( + area, + templatePartId + ); + const blockPatterns = useAlternativeBlockPatterns( area, clientId ); + const hasReplacements = !! templateParts.length || !! blockPatterns.length; const areaObject = useTemplatePartArea( area ); const blockProps = useBlockProps(); const isPlaceholder = ! slug; const isEntityAvailable = ! isPlaceholder && ! isMissing && isResolved; const TagName = tagName || areaObject.tagName; + const canReplace = + isEntityAvailable && + hasReplacements && + ( area === 'header' || area === 'footer' ); + + const createFromBlocks = useCreateTemplatePartFromBlocks( + area, + setAttributes + ); + // We don't want to render a missing state if we have any inner blocks. // A new template part is automatically created if we have any inner blocks but no entity. if ( @@ -154,6 +192,28 @@ export default function TemplatePartEdit( { ); } + const partsAsPatterns = templateParts.map( ( templatePart ) => + mapTemplatePartToBlockPattern( templatePart ) + ); + + const onTemplatePartSelect = ( templatePart ) => { + setAttributes( { + slug: templatePart.slug, + theme: templatePart.theme, + area: undefined, + } ); + createSuccessNotice( + sprintf( + /* translators: %s: template part title. */ + __( 'Template Part "%s" replaceed.' ), + templatePart.title?.rendered || templatePart.slug + ), + { + type: 'snackbar', + } + ); + }; + return ( <> @@ -207,6 +267,33 @@ export default function TemplatePartEdit( { ); } } + + { canReplace && + ( partsAsPatterns.length > 0 || + blockPatterns.length > 0 ) && ( + + + { + onTemplatePartSelect( + pattern.templatePart + ); + } } + /> + { + createFromBlocks( + blocks, + pattern.title + ); + } } + /> + + + ) } + { isEntityAvailable && ( { - const partsAsPatterns = templateParts.map( ( templatePart ) => ( { - name: createTemplatePartId( templatePart.theme, templatePart.slug ), - title: templatePart.title.rendered, - blocks: parse( templatePart.content.raw ), - templatePart, - } ) ); + const partsAsPatterns = templateParts.map( ( templatePart ) => + mapTemplatePartToBlockPattern( templatePart ) + ); return searchPatterns( partsAsPatterns, searchValue ); }, [ templateParts, searchValue ] ); diff --git a/packages/block-library/src/template-part/edit/utils/map-template-part-to-block-pattern.js b/packages/block-library/src/template-part/edit/utils/map-template-part-to-block-pattern.js new file mode 100644 index 0000000000000..5a053350d750f --- /dev/null +++ b/packages/block-library/src/template-part/edit/utils/map-template-part-to-block-pattern.js @@ -0,0 +1,23 @@ +/** + * WordPress dependencies + */ +import { parse } from '@wordpress/blocks'; + +/** + * Internal dependencies + */ +import { createTemplatePartId } from './create-template-part-id'; + +/** + * This maps the properties of a template part to those of a block pattern. + * @param {Object} templatePart + * @return {Object} The template part in the shape of block pattern. + */ +export function mapTemplatePartToBlockPattern( templatePart ) { + return { + name: createTemplatePartId( templatePart.theme, templatePart.slug ), + title: templatePart.title.rendered, + blocks: parse( templatePart.content.raw ), + templatePart, + }; +} diff --git a/packages/block-library/src/utils/get-transformed-metadata.js b/packages/block-library/src/utils/get-transformed-metadata.js new file mode 100644 index 0000000000000..53d79d3c1e42a --- /dev/null +++ b/packages/block-library/src/utils/get-transformed-metadata.js @@ -0,0 +1,65 @@ +/** + * WordPress dependencies + */ +import { getBlockType } from '@wordpress/blocks'; + +/** + * Transform the metadata attribute with only the values and bindings specified by each transform. + * Returns `undefined` if the input metadata is falsy. + * + * @param {Object} metadata Original metadata attribute from the block that is being transformed. + * @param {Object} newBlockName Name of the final block after the transformation. + * @param {Function} bindingsCallback Optional callback to transform the `bindings` property object. + * @return {Object|undefined} New metadata object only with the relevant properties. + */ +export function getTransformedMetadata( + metadata, + newBlockName, + bindingsCallback +) { + if ( ! metadata ) { + return; + } + const { supports } = getBlockType( newBlockName ); + // Fixed until an opt-in mechanism is implemented. + const BLOCK_BINDINGS_SUPPORTED_BLOCKS = [ + 'core/paragraph', + 'core/heading', + 'core/image', + 'core/button', + ]; + // The metadata properties that should be preserved after the transform. + const transformSupportedProps = []; + // If it support bindings, and there is a transform bindings callback, add the `id` and `bindings` properties. + if ( + BLOCK_BINDINGS_SUPPORTED_BLOCKS.includes( newBlockName ) && + bindingsCallback + ) { + transformSupportedProps.push( 'id', 'bindings' ); + } + // If it support block naming (true by default), add the `name` property. + if ( supports.renaming !== false ) { + transformSupportedProps.push( 'name' ); + } + + // Return early if no supported properties. + if ( ! transformSupportedProps.length ) { + return; + } + + const newMetadata = Object.entries( metadata ).reduce( + ( obj, [ prop, value ] ) => { + // If prop is not supported, don't add it to the new metadata object. + if ( ! transformSupportedProps.includes( prop ) ) { + return obj; + } + obj[ prop ] = + prop === 'bindings' ? bindingsCallback( value ) : value; + return obj; + }, + {} + ); + + // Return undefined if object is empty. + return Object.keys( newMetadata ).length ? newMetadata : undefined; +} diff --git a/packages/components/src/mobile/image/index.native.js b/packages/components/src/mobile/image/index.native.js index 0b9588eb3ac8a..aec8f94d1b508 100644 --- a/packages/components/src/mobile/image/index.native.js +++ b/packages/components/src/mobile/image/index.native.js @@ -1,7 +1,7 @@ /** * External dependencies */ -import { Animated, Image as RNImage, Text, View } from 'react-native'; +import { Image as RNImage, Text, View } from 'react-native'; import FastImage from 'react-native-fast-image'; /** @@ -11,7 +11,7 @@ import { __ } from '@wordpress/i18n'; import { Icon } from '@wordpress/components'; import { image, offline } from '@wordpress/icons'; import { usePreferredColorSchemeStyle } from '@wordpress/compose'; -import { useEffect, useState, useRef, Platform } from '@wordpress/element'; +import { useEffect, useState, Platform } from '@wordpress/element'; /** * Internal dependencies @@ -218,19 +218,8 @@ const ImageComponent = ( { focalPoint && styles.focalPointContainer, ]; - const opacityValue = useRef( new Animated.Value( 1 ) ).current; - - useEffect( () => { - Animated.timing( opacityValue, { - toValue: isUploadInProgress ? 0.3 : 1, - duration: 100, - useNativeDriver: true, - } ).start(); - }, [ isUploadInProgress, opacityValue ] ); - const imageStyles = [ { - opacity: opacityValue, height: containerSize?.height, }, ! resizeMode && { @@ -319,7 +308,7 @@ const ImageComponent = ( { { Platform.isAndroid && ( <> { networkImageLoaded && networkURL && ( - ) } { ! networkImageLoaded && ! networkURL && ( - - - + { createInterpolateElement( sprintf( // translators: %s: Total number of pages. diff --git a/packages/dataviews/src/style.scss b/packages/dataviews/src/style.scss index cd293b46d4a47..81aaa97e670a4 100644 --- a/packages/dataviews/src/style.scss +++ b/packages/dataviews/src/style.scss @@ -44,6 +44,13 @@ color: $gray-700; } +.dataviews-pagination__page-selection { + font-size: 11px; + text-transform: uppercase; + font-weight: 500; + color: $gray-900; +} + .dataviews-filters-options { margin: $grid-unit-40 0 $grid-unit-20; } @@ -648,7 +655,8 @@ } &:hover, - &:focus-visible { + &:focus-visible, + &[aria-expanded="true"] { background: $gray-200; color: $gray-900; } @@ -657,8 +665,9 @@ color: var(--wp-admin-theme-color); background: rgba(var(--wp-admin-theme-color--rgb), 0.04); - &:hover { - background: rgba(var(--wp-admin-theme-color--rgb), 0.08); + &:hover, + &[aria-expanded="true"] { + background: rgba(var(--wp-admin-theme-color--rgb), 0.12); } } diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-context/render.php b/packages/e2e-tests/plugins/interactive-blocks/directive-context/render.php index 428d47ec39795..6b20b2dba8376 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/directive-context/render.php +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-context/render.php @@ -18,6 +18,7 @@ > + +
    @@ -59,6 +68,7 @@ > + +
    @@ -143,3 +158,41 @@ + +
    + + +
    +
    + +
    + +
    + + +
    +
    diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-context/view.js b/packages/e2e-tests/plugins/interactive-blocks/directive-context/view.js index aed4a3fefed07..fd061100fca64 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/directive-context/view.js +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-context/view.js @@ -9,6 +9,10 @@ store( 'directive-context', { const ctx = getContext(); return JSON.stringify( ctx, undefined, 2 ); }, + get selected() { + const { list, selected } = getContext(); + return list.find( ( obj ) => obj === selected )?.text; + } }, actions: { updateContext( event ) { @@ -22,6 +26,15 @@ store( 'directive-context', { const ctx = getContext(); ctx.text = ctx.text === 'Text 1' ? 'Text 2' : 'Text 1'; }, + selectItem( event ) { + const ctx = getContext(); + const value = parseInt( event.target.value ); + ctx.selected = ctx.list.find( ( { id } ) => id === value ); + }, + replaceObj() { + const ctx = getContext(); + ctx.obj = { overwritten: true }; + } }, } ); @@ -29,12 +42,17 @@ const html = `
    +
    +
    +
    +
    - + +
    `; @@ -49,13 +67,17 @@ const { actions } = store( 'directive-context-navigate', { const ctx = getContext(); ctx.newText = 'some new text'; }, + addText2() { + const ctx = getContext(); + ctx.text2 = 'some new text'; + }, navigate() { return import( '@wordpress/interactivity-router' ).then( - ( { actions: routerActions } ) => - routerActions.navigate( - window.location, - { force: true, html }, - ) + ( { actions: routerActions } ) => { + const url = new URL( window.location.href ); + url.searchParams.set( 'next_page', 'true' ); + return routerActions.navigate( url, { force: true, html } ); + } ); }, @@ -66,3 +88,21 @@ const { actions } = store( 'directive-context-navigate', { }, }, } ); + +store( 'directive-context-watch', { + actions: { + increment: () => { + const ctx = getContext(); + ctx.counter = ctx.counter + 1; + }, + }, + callbacks: { + countChanges: () => { + const ctx = getContext(); + // Subscribe to changes in counter. + // eslint-disable-next-line no-unused-expressions + ctx.counter; + ctx.changes = ctx.changes + 1; + }, + }, +}); diff --git a/packages/edit-post/src/components/header/index.js b/packages/edit-post/src/components/header/index.js index b7e42d0217eef..62a6a462d8dd1 100644 --- a/packages/edit-post/src/components/header/index.js +++ b/packages/edit-post/src/components/header/index.js @@ -61,7 +61,7 @@ function Header( { setEntitiesSavedStatesCallback, initialPost } ) { const blockToolbarRef = useRef(); const { isTextEditor, - hasBlockSelection, + blockSelectionStart, hasActiveMetaboxes, hasFixedToolbar, isPublishSidebarOpened, @@ -73,8 +73,8 @@ function Header( { setEntitiesSavedStatesCallback, initialPost } ) { return { isTextEditor: getEditorMode() === 'text', - hasBlockSelection: - !! select( blockEditorStore ).getBlockSelectionStart(), + blockSelectionStart: + select( blockEditorStore ).getBlockSelectionStart(), hasActiveMetaboxes: select( editPostStore ).hasMetaBoxes(), hasHistory: !! select( editorStore ).getEditorSettings() @@ -88,13 +88,14 @@ function Header( { setEntitiesSavedStatesCallback, initialPost } ) { const [ isBlockToolsCollapsed, setIsBlockToolsCollapsed ] = useState( true ); + const hasBlockSelection = !! blockSelectionStart; useEffect( () => { // If we have a new block selection, show the block tools - if ( hasBlockSelection ) { + if ( blockSelectionStart ) { setIsBlockToolsCollapsed( false ); } - }, [ hasBlockSelection ] ); + }, [ blockSelectionStart ] ); return (
    @@ -121,7 +122,9 @@ function Header( { setEntitiesSavedStatesCallback, initialPost } ) { className={ classnames( 'selected-block-tools-wrapper', { - 'is-collapsed': isBlockToolsCollapsed, + 'is-collapsed': + isBlockToolsCollapsed || + ! hasBlockSelection, } ) } > diff --git a/packages/edit-site/src/components/global-styles/font-library-modal/font-collection.js b/packages/edit-site/src/components/global-styles/font-library-modal/font-collection.js index 8615159e3ce35..74daf15640ffa 100644 --- a/packages/edit-site/src/components/global-styles/font-library-modal/font-collection.js +++ b/packages/edit-site/src/components/global-styles/font-library-modal/font-collection.js @@ -44,6 +44,7 @@ const DEFAULT_CATEGORY = { }; const LOCAL_STORAGE_ITEM = 'wp-font-library-google-fonts-permission'; +const MIN_WINDOW_HEIGHT = 500; function FontCollection( { slug } ) { const requiresPermission = slug === 'google-fonts'; @@ -123,7 +124,8 @@ function FontCollection( { slug } ) { // NOTE: The height of the font library modal unavailable to use for rendering font family items is roughly 417px // The height of each font family item is 61px. - const pageSize = Math.floor( ( window.innerHeight - 417 ) / 61 ); + const windowHeight = Math.max( window.innerHeight, MIN_WINDOW_HEIGHT ); + const pageSize = Math.floor( ( windowHeight - 417 ) / 61 ); const totalPages = Math.ceil( fonts.length / pageSize ); const itemsStart = ( page - 1 ) * pageSize; const itemsLimit = page * pageSize; diff --git a/packages/edit-site/src/components/global-styles/screen-revisions/index.js b/packages/edit-site/src/components/global-styles/screen-revisions/index.js index b1a5cdb3ce93e..84c7dae8976b0 100644 --- a/packages/edit-site/src/components/global-styles/screen-revisions/index.js +++ b/packages/edit-site/src/components/global-styles/screen-revisions/index.js @@ -73,7 +73,11 @@ function ScreenRevisions() { const onCloseRevisions = () => { goTo( '/' ); // Return to global styles main panel. - setEditorCanvasContainerView( undefined ); + const canvasContainerView = + editorCanvasContainerView === 'global-styles-revisions:style-book' + ? 'style-book' + : undefined; + setEditorCanvasContainerView( canvasContainerView ); }; const restoreRevision = ( revision ) => { @@ -99,7 +103,6 @@ function ScreenRevisions() { ! editorCanvasContainerView.startsWith( 'global-styles-revisions' ) ) { goTo( '/' ); // Return to global styles main panel. - setEditorCanvasContainerView( editorCanvasContainerView ); } }, [ editorCanvasContainerView ] ); diff --git a/packages/edit-site/src/components/global-styles/ui.js b/packages/edit-site/src/components/global-styles/ui.js index 3abd1c811f11e..cdaadb1d1acb3 100644 --- a/packages/edit-site/src/components/global-styles/ui.js +++ b/packages/edit-site/src/components/global-styles/ui.js @@ -230,12 +230,14 @@ function GlobalStylesBlockLink() { } function GlobalStylesEditorCanvasContainerLink() { - const { goTo } = useNavigator(); + const { goTo, location } = useNavigator(); const editorCanvasContainerView = useSelect( ( select ) => unlock( select( editSiteStore ) ).getEditorCanvasContainerView(), [] ); + const path = location?.path; + const isRevisionsOpen = path === '/revisions'; // If the user switches the editor canvas container view, redirect // to the appropriate screen. This effectively allows deep linking to the @@ -249,11 +251,33 @@ function GlobalStylesEditorCanvasContainerLink() { case 'global-styles-css': goTo( '/css' ); break; + case 'style-book': + /* + * The stand-alone style book is open + * and the revisions panel is open, + * close the revisions panel. + * Otherwise keep the style book open while + * browsing global styles panel. + */ + if ( isRevisionsOpen ) { + goTo( '/' ); + } + break; default: + /* + * Example: the user has navigated to "Browse styles" or elsewhere + * and changes the editorCanvasContainerView, e.g., closes the style book. + * The panel should not be affected. + * Exclude revisions panel from this behavior, + * as it should close when the editorCanvasContainerView doesn't correspond. + */ + if ( path !== '/' && ! isRevisionsOpen ) { + return; + } goTo( '/' ); break; } - }, [ editorCanvasContainerView, goTo ] ); + }, [ editorCanvasContainerView, isRevisionsOpen, goTo ] ); } function GlobalStylesUI() { 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 5b8b44f63efe7..daacf7b4df21e 100644 --- a/packages/edit-site/src/components/header-edit-mode/index.js +++ b/packages/edit-site/src/components/header-edit-mode/index.js @@ -134,7 +134,9 @@ export default function HeaderEditMode() { className={ classnames( 'selected-block-tools-wrapper', { - 'is-collapsed': isBlockToolsCollapsed, + 'is-collapsed': + isBlockToolsCollapsed || + ! hasBlockSelected, } ) } > diff --git a/packages/interactivity/CHANGELOG.md b/packages/interactivity/CHANGELOG.md index 88b59a0424dc6..8e48ead8429d3 100644 --- a/packages/interactivity/CHANGELOG.md +++ b/packages/interactivity/CHANGELOG.md @@ -7,6 +7,7 @@ ### Bug Fixes - Only add proxies to plain objects inside the store. ([#59039](https://github.com/WordPress/gutenberg/pull/59039)) +- Improve context merges using proxies. ([59187](https://github.com/WordPress/gutenberg/pull/59187)) ## 5.0.0 (2024-02-09) diff --git a/packages/interactivity/README.md b/packages/interactivity/README.md index 0ea49bdea0782..6c1e98d011556 100644 --- a/packages/interactivity/README.md +++ b/packages/interactivity/README.md @@ -1,62 +1,141 @@ # Interactivity API -> **Warning** -> **This package is only available in Gutenberg** at the moment and not in WordPress Core as it is still very experimental, and very likely to change. +> **Note** +> This package enables the API shared at [Proposal: The Interactivity API – A better developer experience in building interactive blocks](https://make.wordpress.org/core/2023/03/30/proposal-the-interactivity-api-a-better-developer-experience-in-building-interactive-blocks/). As part of an [Open Source project](https://developer.wordpress.org/block-editor/getting-started/faq/#the-gutenberg-project), participation is encouraged in testing this API providing feedback at the [discussions in GitHub](https://github.com/WordPress/gutenberg/discussions/categories/interactivity-api). + +The Interactivity API is available at WordPress Core from version 6.5: [Merge announcement](https://make.wordpress.org/core/2024/02/19/merge-announcement-interactivity-api/) + +These Core blocks are already powered by the API: +- Search +- Query +- Navigation +- File + +## Installation > **Note** -> This package enables the API shared at [Proposal: The Interactivity API – A better developer experience in building interactive blocks](https://make.wordpress.org/core/2023/03/30/proposal-the-interactivity-api-a-better-developer-experience-in-building-interactive-blocks/). As part of an [Open Source project](https://developer.wordpress.org/block-editor/getting-started/faq/#the-gutenberg-project) we encourage participation in helping shape this API and the [discussions in GitHub](https://github.com/WordPress/gutenberg/discussions/categories/interactivity-api) is the best place to engage. +> This step is only required if you are using this API outside of WordPress. +> +> Within WordPress, the package is already bundled in Core, so all you need to do to ensure it is loaded, by adding `wp-interactivity` to the dependency array of the module script. +> +>This happens automatically when you use the dependency extraction Webpack plugin that is used in tools like wp-scripts. + +Install the module: + +```bash +npm install @wordpress/interactivity --save +``` + +_This package assumes that your code will run in an **ES2015+** environment. If you're using an environment that has limited or no support for such language features and APIs, you should include [the polyfill shipped in `@wordpress/babel-preset-default`](https://github.com/WordPress/gutenberg/tree/HEAD/packages/babel-preset-default#polyfill) in your code._ + +## Quick Start Guide -This package can be tested, but it's still very experimental. -The Interactivity API is [being used in some core blocks](https://github.com/search?q=repo%3AWordPress%2Fgutenberg%20%40wordpress%2Finteractivity&type=code) but its use is still very limited. +### Table of Contents +- [Quick Start Guide](#quick-start-guide) + - [1. Scaffold an interactive block](#1-scaffold-an-interactive-block) + - [2. Generate the build](#2-generate-the-build) + - [3. Use it in your WordPress installation ](#3-use-it-in-your-wordpress-installation) +- [Requirements of the Interactivity API](#requirements-of-the-interactivity-aPI) + - [A local WordPress installation](#a-local-wordpress-installation) + - [Latest vesion of Gutenberg](#latest-vesion-of-gutenberg) + - [Node.js](#nodejs) + - [Code requirements](#code-requirements) + - [Add `interactivity` support to `block.json`](#add-interactivity-support-to-blockjson) + - [Add `wp-interactive` directive to a DOM element](#add-wp-interactive-directive-to-a-dom-element) -## Frequently Asked Questions +#### 1. Scaffold an interactive block -At this point, some of the questions you have about the Interactivity API may be: +A WordPress plugin that registers an interactive block (using the Interactivity API) by using a [template](https://www.npmjs.com/package/@wordpress/create-block-interactive-template) can be scaffolded with the `@wordpress/create-block` command. -### What is this? +``` +npx @wordpress/create-block@latest my-first-interactive-block --template @wordpress/create-block-interactive-template +``` -This is the base of a new standard to create interactive blocks. Read [the proposal](https://make.wordpress.org/core/2023/03/30/proposal-the-interactivity-api-a-better-developer-experience-in-building-interactive-blocks/) to learn more about this. +#### 2. Generate the build -### Can I use it? +When the plugin folder is generated, the build process needs to be launched to get a working version of the interactive block that can be used in WordPress. -You can test it, but it's still very experimental. +``` +cd my-first-interactive-block && npm start +``` -### How do I get started? +#### 3. Use it in your WordPress installation -The best place to start with the Interactivity API is this [**Getting started guide**](https://github.com/WordPress/gutenberg/blob/trunk/packages/interactivity/docs/1-getting-started.md). There you'll will find a very quick start guide and the current requirements of the Interactivity API. +If you have a local WordPress installation already running, you can launch the commands above inside the `plugins` folder of that installation. If not, you can use [`wp-now`](https://github.com/WordPress/playground-tools/tree/trunk/packages/wp-now) to launch a WordPress site with the plugin installed by executing from the generated folder (and from a different terminal window or tab) the following command -### Where can I ask questions? +``` +npx @wp-now/wp-now start +``` -The [“Interactivity API” category](https://github.com/WordPress/gutenberg/discussions/categories/interactivity-api) in Gutenberg repo discussions is the best place to ask questions about the Interactivity API. +At this point you should be able to insert the "My First Interactive Block" block into any post, and see how it behaves in the frontend when published. -### Where can I share my feedback about the API? +### Requirements of the Interactivity API -The [“Interactivity API” category](https://github.com/WordPress/gutenberg/discussions/categories/interactivity-api) in Gutenberg repo discussions is also the best place to share your feedback about the Interactivity API. +To start working with the Interactivity API you'll need to have a [proper WordPress development environment for blocks](https://developer.wordpress.org/block-editor/getting-started/devenv/) and some specific code in your block, which should include: -## Installation +#### A local 6.5 WordPress installation -Install the module: +You can use [the tools to set your local WordPress environment](https://developer.wordpress.org/block-editor/getting-started/devenv/#wordpress-development-site) you feel more comfortable with. -```bash -npm install @wordpress/interactivity --save +To get quickly started, [`wp-now`](https://www.npmjs.com/package/@wp-now/wp-now) is the easiest way to get a WordPress site up and running locally. + +Interactivity API is included in Core in WordPress 6.5, for versions below, you'll need to have Gutenberg 17.5 or higher version installed and activated in your WordPress installation. + +#### Node.js + +Block development requires [Node](https://nodejs.org/en), so you'll need to have Node installed and running on your machine. Any version modern should work, but please check the minimum version requirements if you run into any issues with any of the Node.js tools used in WordPress development. + +#### Code requirements + +##### Add `interactivity` support to `block.json` + +To indicate that the block [supports](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/) the Interactivity API features, add `"interactivity": true` to the `supports` attribute of the block's `block.json` + +``` +"supports": { + "interactivity": true +}, ``` -_This package assumes that your code will run in an **ES2015+** environment. If you're using an environment that has limited or no support for such language features and APIs, you should include [the polyfill shipped in `@wordpress/babel-preset-default`](https://github.com/WordPress/gutenberg/tree/HEAD/packages/babel-preset-default#polyfill) in your code._ +##### Add `wp-interactive` directive to a DOM element + +To "activate" the Interactivity API in a DOM element (and its children), add the [`wp-interactive` directive](./docs/api-reference.md#wp-interactive) to it from `render.php` or `save.js` -## Docs & Examples -**[Interactivity API Documentation](https://github.com/WordPress/gutenberg/tree/trunk/packages/interactivity/docs)** is the best place to learn about this proposal. Although it's still in progress, some key pages are already available: +```html +
    + +
    +``` -- **[Getting Started Guide](https://github.com/WordPress/gutenberg/blob/trunk/packages/interactivity/docs/1-getting-started.md)**: Follow this Getting Started guide to learn how to scaffold a new project and create your first interactive blocks. -- **[API Reference](https://github.com/WordPress/gutenberg/blob/trunk/packages/interactivity/docs/2-api-reference.md)**: Check this page for technical detailed explanations and examples of the directives and the store. +## API Reference + +To take a deep dive in how the API works internally, the list of Directives, and how Store works, click [here](https://developer.wordpress.org/block-editor/reference-guides/packages/packages-interactivity/packages-interactivity-api-reference/). + +## Docs & Examples Here you have some more resources to learn/read more about the Interactivity API: - **[Interactivity API Discussions](https://github.com/WordPress/gutenberg/discussions/52882)** +- [Merge announcement](https://make.wordpress.org/core/2024/02/19/merge-announcement-interactivity-api/) - [Proposal: The Interactivity API – A better developer experience in building interactive blocks](https://make.wordpress.org/core/2023/03/30/proposal-the-interactivity-api-a-better-developer-experience-in-building-interactive-blocks/) - Developer Hours sessions ([Americas](https://www.youtube.com/watch?v=RXNoyP2ZiS8&t=664s) & [APAC/EMEA](https://www.youtube.com/watch?v=6ghbrhyAcvA)) - [wpmovies.dev](http://wpmovies.dev/) demo and its [wp-movies-demo](https://github.com/WordPress/wp-movies-demo) repo -

    Code is Poetry.

    +There's a Tracking Issue opened to ease the coordination of the work related to the Interactivity API Docs: **[Documentation for the Interactivity API - Tracking Issue #53296](https://github.com/WordPress/gutenberg/issues/53296)** + + +## Get Involved + +As part of an [Open Source project](https://developer.wordpress.org/block-editor/getting-started/faq/#the-gutenberg-project) participation is encouraged in helping shape this API and its Docs. The [discussions](https://github.com/WordPress/gutenberg/discussions/categories/interactivity-api) and [issues](https://github.com/WordPress/gutenberg/labels/%5BFeature%5D%20Interactivity%20API) in GitHub are the best place to engage. + +If you are willing to help with the documentation, please add a comment to [#51928](https://github.com/WordPress/gutenberg/discussions/51928) to coordinate everyone's efforts. + + +## License + +Interactivity API proposal, as part of Gutenberg and the WordPress project is free software, and is released under the terms of the GNU General Public License version 2 or (at your option) any later version. See [LICENSE.md](https://github.com/WordPress/gutenberg/blob/trunk/LICENSE.md) for complete license. + +

    Code is Poetry.

    diff --git a/packages/interactivity/docs/1-getting-started.md b/packages/interactivity/docs/1-getting-started.md deleted file mode 100644 index 4e8c1086da695..0000000000000 --- a/packages/interactivity/docs/1-getting-started.md +++ /dev/null @@ -1,87 +0,0 @@ -# Getting started with the Interactivity API - -To get started with the Interactivity API, you can follow this [**Quick Start Guide**](#quick-start-guide) by taking into account the [current requirements of the Interactivity API](#requirements-of-the-interactivity-api) (especially the need for Gutenberg 16.2 or later). - -## Table of Contents - -- [Quick Start Guide](#quick-start-guide) - - [1. Scaffold an interactive block](#1-scaffold-an-interactive-block) - - [2. Generate the build](#2-generate-the-build) - - [3. Use it in your WordPress installation ](#3-use-it-in-your-wordpress-installation) -- [Requirements of the Interactivity API](#requirements-of-the-interactivity-aPI) - - [A local WordPress installation](#a-local-wordpress-installation) - - [Latest vesion of Gutenberg](#latest-vesion-of-gutenberg) - - [Node.js](#nodejs) - - [Code requirements](#code-requirements) - - [Add `interactivity` support to `block.json`](#add-interactivity-support-to-blockjson) - - [Add `wp-interactive` directive to a DOM element](#add-wp-interactive-directive-to-a-dom-element) - -## Quick Start Guide - -#### 1. Scaffold an interactive block - -We can scaffold a WordPress plugin that registers an interactive block (using the Interactivity API) by using a [template](https://www.npmjs.com/package/@wordpress/create-block-interactive-template) with the `@wordpress/create-block` command. - -``` -npx @wordpress/create-block@latest my-first-interactive-block --template @wordpress/create-block-interactive-template -``` - -#### 2. Generate the build - -When the plugin folder is generated, we should launch the build process to get the final version of the interactive block that can be used from WordPress. - -``` -cd my-first-interactive-block && npm start -``` - -#### 3. Use it in your WordPress installation - -If you have a local WordPress installation already running, you can launch the commands above inside the `plugins` folder of that installation. If not, you can use [`wp-now`](https://github.com/WordPress/playground-tools/tree/trunk/packages/wp-now) to launch a WordPress site with the plugin installed by executing from the generated folder (and from a different terminal window or tab) the following command - -``` -npx @wp-now/wp-now start -``` - -At this point you should be able to insert the "My First Interactive Block" block into any post, and see how it behaves in the frontend when published. - -> **Note** -> We recommend you to also check the [API Reference](./2-api-reference.md) docs for your first exploration of the Interactivity API - -## Requirements of the Interactivity API - -To start working with the Interactivity API you'll need to have a [proper WordPress development environment for blocks](https://developer.wordpress.org/block-editor/getting-started/devenv/) and some specific code in your block, which should include: - -#### A local 6.5 WordPress installation - -You can use [the tools to set your local WordPress environment](https://developer.wordpress.org/block-editor/getting-started/devenv/#wordpress-development-site) you feel more comfortable with. - -To get quickly started, [`wp-now`](https://www.npmjs.com/package/@wp-now/wp-now) is the easiest way to get a WordPress site up and running locally. - -Interactivity API is included in Core in WordPress 6.5, for versions below, you'll need to have Gutenberg 17.5 or higher version installed and activated in your WordPress installation. - -#### Node.js - -Block development requires [Node](https://nodejs.org/en), so you'll need to have Node installed and running on your machine. Any version modern should work, but please check the minimum version requirements if you run into any issues with any of the Node.js tools used in WordPress development. - -#### Code requirements - -##### Add `interactivity` support to `block.json` - -To indicate that our block [supports](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/) the Interactivity API features, we do so by adding `"interactivity": true` to the `supports` attribute of our block's `block.json` - -``` -"supports": { - "interactivity": true -}, -``` - -##### Add `wp-interactive` directive to a DOM element - -To "activate" the Interactivity API in a DOM element (and its children) we add the [`wp-interactive` directive](./2-api-reference.md#wp-interactive) to it from our `render.php` or `save.js` - - -```html -
    - -
    -``` diff --git a/packages/interactivity/docs/README.md b/packages/interactivity/docs/README.md deleted file mode 100644 index e63ed6b82e420..0000000000000 --- a/packages/interactivity/docs/README.md +++ /dev/null @@ -1,33 +0,0 @@ -# Interactivity API Docs - -👋 Hi! Welcome to the Interactivity API documentation. - - -> Interactivity API is a current [proposal](https://make.wordpress.org/core/2023/03/30/proposal-the-interactivity-api-a-better-developer-experience-in-building-interactive-blocks/) that **is only available in Gutenberg** at the moment and not in WordPress Core as it is still very experimental, and very likely to change. - -> As part of an [Open Source project](https://developer.wordpress.org/block-editor/getting-started/faq/#the-gutenberg-project) we encourage participation in helping shape this API and its Docs. The [discussions](https://github.com/WordPress/gutenberg/discussions/categories/interactivity-api) and [issues](https://github.com/WordPress/gutenberg/labels/%5BFeature%5D%20Interactivity%20API) in GitHub are the best place to engage. - - -## Quick start - -The best place to start with the Interactivity API is this [**Getting started guide**](1-getting-started.md). There you'll will find a very quick start guide and the current requirements of the Interactivity API. - -## Take a deep dive - -At the [**API Reference**](2-api-reference.md) page you'll find detailed technical descriptions for the *Directives* and *Store* which are the main elements of the Interactivity API. - -You can also check [Getting Started - and other learning resources](https://github.com/WordPress/gutenberg/discussions/52894) among other [discussions in GitHub](https://github.com/WordPress/gutenberg/discussions/categories/interactivity-api) to learn more on this proposal. - -## Get Involved - -Feel free to open pull requests to improve any part of these docs, or to add other sections or files to the docs. - -If you are willing to help with the documentation, please add a comment to [#51928](https://github.com/WordPress/gutenberg/discussions/51928), and we'll coordinate everyone's efforts. - -There's a Tracking Issue opened to ease the coordination of the work related to the Interactivity API Docs: **[Documentation for the Interactivity API - Tracking Issue #53296](https://github.com/WordPress/gutenberg/issues/53296)** - -## License - -Interactivity API proposal, as part of Gutenberg and the WordPress project is free software, and is released under the terms of the GNU General Public License version 2 or (at your option) any later version. See [LICENSE.md](https://github.com/WordPress/gutenberg/blob/trunk/LICENSE.md) for complete license. - -

    Code is Poetry.

    diff --git a/packages/interactivity/docs/2-api-reference.md b/packages/interactivity/docs/api-reference.md similarity index 96% rename from packages/interactivity/docs/2-api-reference.md rename to packages/interactivity/docs/api-reference.md index fa06bae4fedf8..b755b12464667 100644 --- a/packages/interactivity/docs/2-api-reference.md +++ b/packages/interactivity/docs/api-reference.md @@ -1,5 +1,8 @@ # API Reference +> **Note** +> Interactivity API is only available for WordPress 6.5 and above. + To add interactivity to blocks using the Interactivity API, developers can use: - **Directives** - added to the markup to add specific behavior to the DOM elements of the block. @@ -7,7 +10,7 @@ To add interactivity to blocks using the Interactivity API, developers can use: DOM elements are connected to data stored in the state and context through directives. If data in the state or context change directives will react to those changes, updating the DOM accordingly (see [diagram](https://excalidraw.com/#json=T4meh6lltJh6TCX51NTIu,DmIhxYSGFTL_ywZFbsmuSw)). -![State & Directives](assets/state-directives.png) +![State & Directives](https://make.wordpress.org/core/files/2024/02/interactivity-state-directives.png) ## Table of Contents @@ -69,7 +72,7 @@ Directives can also be injected dynamically using the [HTML Tag Processor](https ### List of Directives -With directives, we can directly manage interactions related to things such as side effects, state, event handlers, attributes or content. +With directives, you can directly manage interactions related to things such as side effects, state, event handlers, attributes or content. #### `wp-interactive` @@ -659,7 +662,7 @@ It would generate the following output: ``` -The prop that holds the item in the context can be changed by passing a suffix to the directive name. In the following example, we change the default prop `item` to `greeting`. +The prop that holds the item in the context can be changed by passing a suffix to the directive name. In the following example, the default prop changes from `item` to `greeting`. ```html
      @@ -708,7 +711,7 @@ For server-side rendered lists, another directive called `data-wp-each-child` en The value assigned to a directive is a string pointing to a specific state, action, or side effect. -In the following example, we use a getter to define the `state.isPlaying` derived value. +In the following example, a getter is used to define the `state.isPlaying` derived value. ```js const { state } = store( "myPlugin", { @@ -721,7 +724,7 @@ const { state } = store( "myPlugin", { } ); ``` -And then, we use the string value `"state.isPlaying"` to assign the result of this selector to `data-bind--hidden`. +And then, the string value `"state.isPlaying"` is used to assign the result of this selector to `data-bind--hidden`. ```html
      @@ -731,9 +734,9 @@ And then, we use the string value `"state.isPlaying"` to assign the result of th These values assigned to directives are **references** to a particular property in the store. They are wired to the directives automatically so that each directive “knows” what store element refers to, without any additional configuration. -Note that, by default, references point to properties in the current namespace, which is the one specified by the closest ancestor with a `data-wp-interactive` attribute. If you need to access a property from a different namespace, you can explicitly set the namespace where the property we want to access is defined. The syntax is `namespace::reference`, replacing `namespace` with the appropriate value. +Note that, by default, references point to properties in the current namespace, which is the one specified by the closest ancestor with a `data-wp-interactive` attribute. If you need to access a property from a different namespace, you can explicitly set the namespace where the property accessed is defined. The syntax is `namespace::reference`, replacing `namespace` with the appropriate value. -In the example below, we get `state.isPlaying` from `otherPlugin` instead of `myPlugin`: +The example below is getting `state.isPlaying` from `otherPlugin` instead of `myPlugin`: ```html
      @@ -890,7 +893,7 @@ This approach enables some functionalities that make directives flexible and pow #### On the client side -*In the `view.js` file of each block* we can define both the state and the elements of the store referencing functions like actions, side effects or derived state. +*In the `view.js` file of each block* the developer can define both the state and the elements of the store referencing functions like actions, side effects or derived state. The `store` method used to set the store in javascript can be imported from `@wordpress/interactivity`. diff --git a/packages/interactivity/docs/assets/state-directives.png b/packages/interactivity/docs/assets/state-directives.png deleted file mode 100644 index a2422d1a2a049..0000000000000 Binary files a/packages/interactivity/docs/assets/state-directives.png and /dev/null differ diff --git a/packages/interactivity/docs/assets/store-server-client.png b/packages/interactivity/docs/assets/store-server-client.png deleted file mode 100644 index 37818e37faa3d..0000000000000 Binary files a/packages/interactivity/docs/assets/store-server-client.png and /dev/null differ diff --git a/packages/interactivity/docs/toc.json b/packages/interactivity/docs/toc.json new file mode 100644 index 0000000000000..43af0d2181203 --- /dev/null +++ b/packages/interactivity/docs/toc.json @@ -0,0 +1,8 @@ +[ + { + "title": "API Reference", + "slug": "packages-interactivity-api-reference", + "markdown_source": "../packages/interactivity/docs/api-reference.md", + "parent": "packages-interactivity" + } +] diff --git a/packages/interactivity/src/directives.js b/packages/interactivity/src/directives.js index 9184fb1d6d803..d04c1a1f74d3e 100644 --- a/packages/interactivity/src/directives.js +++ b/packages/interactivity/src/directives.js @@ -14,23 +14,121 @@ import { useWatch, useInit } from './utils'; import { directive, getScope, getEvaluate } from './hooks'; import { kebabToCamelCase } from './utils/kebab-to-camelcase'; -const isObject = ( item ) => - item && typeof item === 'object' && ! Array.isArray( item ); +// Assigned objects should be ignore during proxification. +const contextAssignedObjects = new WeakMap(); -const mergeDeepSignals = ( target, source, overwrite ) => { +const isPlainObject = ( item ) => + item && typeof item === 'object' && item.constructor === Object; + +const descriptor = Reflect.getOwnPropertyDescriptor; + +/** + * Wrap a context object with a proxy to reproduce the context stack. The proxy + * uses the passed `inherited` context as a fallback to look up for properties + * that don't exist in the given context. Also, updated properties are modified + * where they are defined, or added to the main context when they don't exist. + * + * By default, all plain objects inside the context are wrapped, unless it is + * listed in the `ignore` option. + * + * @param {Object} current Current context. + * @param {Object} inherited Inherited context, used as fallback. + * + * @return {Object} The wrapped context object. + */ +const proxifyContext = ( current, inherited = {} ) => + new Proxy( current, { + get: ( target, k ) => { + // Always subscribe to prop changes in the current context. + const currentProp = target[ k ]; + + // Return the inherited prop when missing in target. + if ( ! ( k in target ) && k in inherited ) { + return inherited[ k ]; + } + + // Proxify plain objects that are not listed in `ignore`. + if ( + k in target && + ! contextAssignedObjects.get( target )?.has( k ) && + isPlainObject( peek( target, k ) ) + ) { + return proxifyContext( currentProp, inherited[ k ] ); + } + + /* + * For other cases, return the value from target, also subscribing + * to changes in the parent context when the current prop is + * not defined. + */ + return k in target ? currentProp : inherited[ k ]; + }, + set: ( target, k, value ) => { + const obj = + k in target || ! ( k in inherited ) ? target : inherited; + + // Values that are objects should not be proxified so they point to + // the original object and don't inherit unexpected properties. + if ( value && typeof value === 'object' ) { + if ( ! contextAssignedObjects.has( obj ) ) { + contextAssignedObjects.set( obj, new Set() ); + } + contextAssignedObjects.get( obj ).add( k ); + } + + obj[ k ] = value; + return true; + }, + ownKeys: ( target ) => [ + ...new Set( [ + ...Object.keys( inherited ), + ...Object.keys( target ), + ] ), + ], + getOwnPropertyDescriptor: ( target, k ) => + descriptor( target, k ) || descriptor( inherited, k ), + } ); + +/** + * Recursively update values within a deepSignal object. + * + * @param {Object} target A deepSignal instance. + * @param {Object} source Object with properties to update in `target` + */ +const updateSignals = ( target, source ) => { for ( const k in source ) { - if ( isObject( peek( target, k ) ) && isObject( peek( source, k ) ) ) { - mergeDeepSignals( - target[ `$${ k }` ].peek(), - source[ `$${ k }` ].peek(), - overwrite - ); - } else if ( overwrite || typeof peek( target, k ) === 'undefined' ) { - target[ `$${ k }` ] = source[ `$${ k }` ]; + if ( + isPlainObject( peek( target, k ) ) && + isPlainObject( peek( source, k ) ) + ) { + updateSignals( target[ `$${ k }` ].peek(), source[ k ] ); + } else { + target[ k ] = source[ k ]; } } }; +/** + * Recursively clone the passed object. + * + * @param {Object} source Source object. + * @return {Object} Cloned object. + */ +const deepClone = ( source ) => { + if ( isPlainObject( source ) ) { + return Object.fromEntries( + Object.entries( source ).map( ( [ key, value ] ) => [ + key, + deepClone( value ), + ] ) + ); + } + if ( Array.isArray( source ) ) { + return source.map( ( i ) => deepClone( i ) ); + } + return source; +}; + const newRule = /(?:([\u0080-\uFFFF\w-%@]+) *:? *([^{;]+?);|([^;}{]*?) *{)|(}\s*)/g; const ruleClean = /\/\*[^]*?\*\/| +/g; @@ -105,22 +203,18 @@ export default () => { ( { suffix } ) => suffix === 'default' ); - currentValue.current = useMemo( () => { - if ( ! defaultEntry ) return null; - const { namespace, value } = defaultEntry; - const newValue = deepSignal( { [ namespace ]: value } ); - mergeDeepSignals( newValue, inheritedValue ); - mergeDeepSignals( currentValue.current, newValue, true ); - return currentValue.current; - }, [ inheritedValue, defaultEntry ] ); + // No change should be made if `defaultEntry` does not exist. + const contextStack = useMemo( () => { + if ( defaultEntry ) { + const { namespace, value } = defaultEntry; + updateSignals( currentValue.current, { + [ namespace ]: deepClone( value ), + } ); + } + return proxifyContext( currentValue.current, inheritedValue ); + }, [ defaultEntry, inheritedValue ] ); - if ( currentValue.current ) { - return ( - - { children } - - ); - } + return { children }; }, { priority: 5 } ); @@ -358,15 +452,16 @@ export default () => { const list = evaluate( entry ); return list.map( ( item ) => { - const mergedContext = deepSignal( {} ); - const itemProp = suffix === 'default' ? 'item' : kebabToCamelCase( suffix ); - const newValue = deepSignal( { - [ namespace ]: { [ itemProp ]: item }, - } ); - mergeDeepSignals( newValue, inheritedValue ); - mergeDeepSignals( mergedContext, newValue, true ); + const itemContext = deepSignal( { [ namespace ]: {} } ); + const mergedContext = proxifyContext( + itemContext, + inheritedValue + ); + + // Set the item after proxifying the context. + mergedContext[ namespace ][ itemProp ] = item; const scope = { ...getScope(), context: mergedContext }; const key = eachKey diff --git a/packages/patterns/src/components/partial-syncing-controls.js b/packages/patterns/src/components/partial-syncing-controls.js index f55ee7bdc26d3..7b3e5cb312e82 100644 --- a/packages/patterns/src/components/partial-syncing-controls.js +++ b/packages/patterns/src/components/partial-syncing-controls.js @@ -17,7 +17,7 @@ import { PARTIAL_SYNCING_SUPPORTED_BLOCKS } from '../constants'; function PartialSyncingControls( { name, attributes, setAttributes } ) { const syncedAttributes = PARTIAL_SYNCING_SUPPORTED_BLOCKS[ name ]; - const attributeSources = Object.keys( syncedAttributes ).map( + const attributeSources = syncedAttributes.map( ( attributeName ) => attributes.metadata?.bindings?.[ attributeName ]?.source ); @@ -36,7 +36,7 @@ function PartialSyncingControls( { name, attributes, setAttributes } ) { }; if ( ! isChecked ) { - for ( const attributeName of Object.keys( syncedAttributes ) ) { + for ( const attributeName of syncedAttributes ) { if ( updatedBindings[ attributeName ]?.source === 'core/pattern-overrides' @@ -56,7 +56,7 @@ function PartialSyncingControls( { name, attributes, setAttributes } ) { return; } - for ( const attributeName of Object.keys( syncedAttributes ) ) { + for ( const attributeName of syncedAttributes ) { if ( ! updatedBindings[ attributeName ] ) { updatedBindings[ attributeName ] = { source: 'core/pattern-overrides', diff --git a/packages/patterns/src/constants.js b/packages/patterns/src/constants.js index 24401e24fdd3b..99d6a0fa975a8 100644 --- a/packages/patterns/src/constants.js +++ b/packages/patterns/src/constants.js @@ -1,8 +1,3 @@ -/** - * WordPress dependencies - */ -import { __ } from '@wordpress/i18n'; - export const PATTERN_TYPES = { theme: 'pattern', user: 'wp_block', @@ -22,18 +17,8 @@ export const PATTERN_SYNC_TYPES = { // TODO: This should not be hardcoded. Maybe there should be a config and/or an UI. export const PARTIAL_SYNCING_SUPPORTED_BLOCKS = { - 'core/paragraph': { content: __( 'Content' ) }, - 'core/heading': { content: __( 'Content' ) }, - 'core/button': { - text: __( 'Text' ), - url: __( 'URL' ), - linkTarget: __( 'Link Target' ), - rel: __( 'Link Relationship' ), - }, - 'core/image': { - id: __( 'Image ID' ), - url: __( 'URL' ), - title: __( 'Title' ), - alt: __( 'Alt Text' ), - }, + 'core/paragraph': [ 'content' ], + 'core/heading': [ 'content' ], + 'core/button': [ 'text', 'url', 'linkTarget', 'rel' ], + 'core/image': [ 'id', 'url', 'title', 'alt' ], }; diff --git a/packages/react-native-editor/CHANGELOG.md b/packages/react-native-editor/CHANGELOG.md index ecd7457c3f1ed..d2cd33f567265 100644 --- a/packages/react-native-editor/CHANGELOG.md +++ b/packages/react-native-editor/CHANGELOG.md @@ -11,6 +11,8 @@ For each user feature we should also add a importance categorization label to i ## Unreleased - [*] Prevent crash when autoscrolling to blocks [#59110] +- [*] Remove opacity change when images are being uploaded [#59264] +- [*] Media & Text blocks correctly show an error message when the attached video upload fails [#59288] ## 1.112.0 - [*] [internal] Upgrade React Native to version 0.71.15 [#57667] diff --git a/test/e2e/specs/editor/blocks/buttons.spec.js b/test/e2e/specs/editor/blocks/buttons.spec.js index dcddfca2b5b28..f62732470d974 100644 --- a/test/e2e/specs/editor/blocks/buttons.spec.js +++ b/test/e2e/specs/editor/blocks/buttons.spec.js @@ -405,4 +405,80 @@ test.describe( 'Buttons', () => { ` ); } ); + + test.describe( 'Block transforms', () => { + test.describe( 'FROM paragraph', () => { + test( 'should preserve the content', async ( { editor } ) => { + await editor.insertBlock( { + name: 'core/paragraph', + attributes: { + content: 'initial content', + }, + } ); + await editor.transformBlockTo( 'core/buttons' ); + const buttonBlock = ( await editor.getBlocks() )[ 0 ] + .innerBlocks[ 0 ]; + expect( buttonBlock.name ).toBe( 'core/button' ); + expect( buttonBlock.attributes.text ).toBe( 'initial content' ); + } ); + + test( 'should preserve the metadata attribute', async ( { + editor, + } ) => { + await editor.insertBlock( { + name: 'core/paragraph', + attributes: { + content: 'initial content', + metadata: { + name: 'Custom name', + }, + }, + } ); + + await editor.transformBlockTo( 'core/buttons' ); + const buttonBlock = ( await editor.getBlocks() )[ 0 ] + .innerBlocks[ 0 ]; + expect( buttonBlock.name ).toBe( 'core/button' ); + expect( buttonBlock.attributes.metadata ).toMatchObject( { + name: 'Custom name', + } ); + } ); + + test( 'should preserve the block bindings', async ( { + editor, + } ) => { + await editor.insertBlock( { + name: 'core/paragraph', + attributes: { + content: 'initial content', + metadata: { + bindings: { + content: { + source: 'core/post-meta', + args: { + key: 'custom_field', + }, + }, + }, + }, + }, + } ); + + await editor.transformBlockTo( 'core/buttons' ); + const buttonBlock = ( await editor.getBlocks() )[ 0 ] + .innerBlocks[ 0 ]; + expect( buttonBlock.name ).toBe( 'core/button' ); + expect( + buttonBlock.attributes.metadata.bindings + ).toMatchObject( { + text: { + source: 'core/post-meta', + args: { + key: 'custom_field', + }, + }, + } ); + } ); + } ); + } ); } ); diff --git a/test/e2e/specs/editor/blocks/code.spec.js b/test/e2e/specs/editor/blocks/code.spec.js index 6abfb15d10b83..ba5af46f69cfd 100644 --- a/test/e2e/specs/editor/blocks/code.spec.js +++ b/test/e2e/specs/editor/blocks/code.spec.js @@ -46,4 +46,120 @@ test.describe( 'Code', () => { expect( await editor.getEditedPostContent() ).toMatchSnapshot(); } ); + + test.describe( 'Block transforms', () => { + test.describe( 'FROM paragraph', () => { + test( 'should preserve the content', async ( { editor } ) => { + await editor.insertBlock( { + name: 'core/paragraph', + attributes: { + content: 'initial content', + }, + } ); + await editor.transformBlockTo( 'core/code' ); + const codeBlock = ( await editor.getBlocks() )[ 0 ]; + expect( codeBlock.name ).toBe( 'core/code' ); + expect( codeBlock.attributes.content ).toBe( + 'initial content' + ); + } ); + + test( 'should preserve the metadata name attribute', async ( { + editor, + } ) => { + await editor.insertBlock( { + name: 'core/paragraph', + attributes: { + content: 'initial content', + metadata: { + name: 'Custom name', + }, + }, + } ); + + await editor.transformBlockTo( 'core/code' ); + const codeBlock = ( await editor.getBlocks() )[ 0 ]; + expect( codeBlock.name ).toBe( 'core/code' ); + expect( codeBlock.attributes.metadata ).toMatchObject( { + name: 'Custom name', + } ); + } ); + } ); + + test.describe( 'FROM HTML', () => { + test( 'should preserve the content', async ( { editor } ) => { + await editor.insertBlock( { + name: 'core/html', + attributes: { + content: 'initial content', + }, + } ); + await editor.transformBlockTo( 'core/code' ); + const codeBlock = ( await editor.getBlocks() )[ 0 ]; + expect( codeBlock.name ).toBe( 'core/code' ); + expect( codeBlock.attributes.content ).toBe( + 'initial content' + ); + } ); + + test( 'should preserve the metadata name attribute', async ( { + editor, + } ) => { + await editor.insertBlock( { + name: 'core/html', + attributes: { + content: 'initial content', + metadata: { + name: 'Custom name', + }, + }, + } ); + + await editor.transformBlockTo( 'core/code' ); + const codeBlock = ( await editor.getBlocks() )[ 0 ]; + expect( codeBlock.name ).toBe( 'core/code' ); + expect( codeBlock.attributes.metadata ).toMatchObject( { + name: 'Custom name', + } ); + } ); + } ); + + test.describe( 'TO paragraph', () => { + test( 'should preserve the content', async ( { editor } ) => { + await editor.insertBlock( { + name: 'core/code', + attributes: { + content: 'initial content', + }, + } ); + await editor.transformBlockTo( 'core/paragraph' ); + const codeBlock = ( await editor.getBlocks() )[ 0 ]; + expect( codeBlock.name ).toBe( 'core/paragraph' ); + expect( codeBlock.attributes.content ).toBe( + 'initial content' + ); + } ); + + test( 'should preserve the metadata name attribute', async ( { + editor, + } ) => { + await editor.insertBlock( { + name: 'core/code', + attributes: { + content: 'initial content', + metadata: { + name: 'Custom name', + }, + }, + } ); + + await editor.transformBlockTo( 'core/paragraph' ); + const codeBlock = ( await editor.getBlocks() )[ 0 ]; + expect( codeBlock.name ).toBe( 'core/paragraph' ); + expect( codeBlock.attributes.metadata ).toMatchObject( { + name: 'Custom name', + } ); + } ); + } ); + } ); } ); diff --git a/test/e2e/specs/editor/blocks/heading.spec.js b/test/e2e/specs/editor/blocks/heading.spec.js index 705bce2c3f2c9..f0271a8f6e897 100644 --- a/test/e2e/specs/editor/blocks/heading.spec.js +++ b/test/e2e/specs/editor/blocks/heading.spec.js @@ -291,4 +291,184 @@ test.describe( 'Heading', () => { }, ] ); } ); + + test.describe( 'Block transforms', () => { + test.describe( 'FROM paragraph', () => { + test( 'should preserve the content', async ( { editor } ) => { + await editor.insertBlock( { + name: 'core/paragraph', + attributes: { + content: 'initial content', + }, + } ); + await editor.transformBlockTo( 'core/heading' ); + const headingBlock = ( await editor.getBlocks() )[ 0 ]; + expect( headingBlock.name ).toBe( 'core/heading' ); + expect( headingBlock.attributes.content ).toBe( + 'initial content' + ); + } ); + + test( 'should preserve the text align attribute', async ( { + editor, + } ) => { + await editor.insertBlock( { + name: 'core/paragraph', + attributes: { + align: 'right', + content: 'initial content', + }, + } ); + await editor.transformBlockTo( 'core/heading' ); + const headingBlock = ( await editor.getBlocks() )[ 0 ]; + expect( headingBlock.name ).toBe( 'core/heading' ); + expect( headingBlock.attributes.textAlign ).toBe( 'right' ); + } ); + + test( 'should preserve the metadata attribute', async ( { + editor, + } ) => { + await editor.insertBlock( { + name: 'core/paragraph', + attributes: { + content: 'initial content', + metadata: { + name: 'Custom name', + }, + }, + } ); + + await editor.transformBlockTo( 'core/heading' ); + const headingBlock = ( await editor.getBlocks() )[ 0 ]; + expect( headingBlock.name ).toBe( 'core/heading' ); + expect( headingBlock.attributes.metadata ).toMatchObject( { + name: 'Custom name', + } ); + } ); + + test( 'should preserve the block bindings', async ( { + editor, + } ) => { + await editor.insertBlock( { + name: 'core/paragraph', + attributes: { + content: 'initial content', + metadata: { + bindings: { + content: { + source: 'core/post-meta', + args: { + key: 'custom_field', + }, + }, + }, + }, + }, + } ); + + await editor.transformBlockTo( 'core/heading' ); + const headingBlock = ( await editor.getBlocks() )[ 0 ]; + expect( headingBlock.name ).toBe( 'core/heading' ); + expect( + headingBlock.attributes.metadata.bindings + ).toMatchObject( { + content: { + source: 'core/post-meta', + args: { + key: 'custom_field', + }, + }, + } ); + } ); + } ); + + test.describe( 'TO paragraph', () => { + test( 'should preserve the content', async ( { editor } ) => { + await editor.insertBlock( { + name: 'core/heading', + attributes: { + content: 'initial content', + }, + } ); + await editor.transformBlockTo( 'core/paragraph' ); + const paragraphBlock = ( await editor.getBlocks() )[ 0 ]; + expect( paragraphBlock.name ).toBe( 'core/paragraph' ); + expect( paragraphBlock.attributes.content ).toBe( + 'initial content' + ); + } ); + + test( 'should preserve the text align attribute', async ( { + editor, + } ) => { + await editor.insertBlock( { + name: 'core/heading', + attributes: { + textAlign: 'right', + content: 'initial content', + }, + } ); + await editor.transformBlockTo( 'core/paragraph' ); + const paragraphBlock = ( await editor.getBlocks() )[ 0 ]; + expect( paragraphBlock.name ).toBe( 'core/paragraph' ); + expect( paragraphBlock.attributes.align ).toBe( 'right' ); + } ); + + test( 'should preserve the metadata attribute', async ( { + editor, + } ) => { + await editor.insertBlock( { + name: 'core/heading', + attributes: { + content: 'initial content', + metadata: { + name: 'Custom name', + }, + }, + } ); + + await editor.transformBlockTo( 'core/paragraph' ); + const paragraphBlock = ( await editor.getBlocks() )[ 0 ]; + expect( paragraphBlock.name ).toBe( 'core/paragraph' ); + expect( paragraphBlock.attributes.metadata ).toMatchObject( { + name: 'Custom name', + } ); + } ); + + test( 'should preserve the block bindings', async ( { + editor, + } ) => { + await editor.insertBlock( { + name: 'core/heading', + attributes: { + content: 'initial content', + metadata: { + bindings: { + content: { + source: 'core/post-meta', + args: { + key: 'custom_field', + }, + }, + }, + }, + }, + } ); + + await editor.transformBlockTo( 'core/paragraph' ); + const paragraphBlock = ( await editor.getBlocks() )[ 0 ]; + expect( paragraphBlock.name ).toBe( 'core/paragraph' ); + expect( + paragraphBlock.attributes.metadata.bindings + ).toMatchObject( { + content: { + source: 'core/post-meta', + args: { + key: 'custom_field', + }, + }, + } ); + } ); + } ); + } ); } ); diff --git a/test/e2e/specs/interactivity/directive-context.spec.ts b/test/e2e/specs/interactivity/directive-context.spec.ts index 95300dc53bf86..85341774c2af4 100644 --- a/test/e2e/specs/interactivity/directive-context.spec.ts +++ b/test/e2e/specs/interactivity/directive-context.spec.ts @@ -136,6 +136,27 @@ test.describe( 'data-wp-context', () => { expect( parentContext.obj.prop5 ).toBe( 'modifiedFromParent' ); } ); + test( 'new inherited properties update child contexts', async ( { + page, + } ) => { + const childContextBefore = await parseContent( + page.getByTestId( 'child context' ) + ); + expect( childContextBefore.new ).toBeUndefined(); + + await page.getByTestId( 'parent new' ).click(); + + const childContextAfter = await parseContent( + page.getByTestId( 'child context' ) + ); + expect( childContextAfter.new ).toBe( 'modifiedFromParent' ); + + const parentContext = await parseContent( + page.getByTestId( 'parent context' ) + ); + expect( parentContext.new ).toBe( 'modifiedFromParent' ); + } ); + test( 'Array properties are shadowed', async ( { page } ) => { const parentContext = await parseContent( page.getByTestId( 'parent context' ) @@ -149,6 +170,52 @@ test.describe( 'data-wp-context', () => { expect( childContext.array ).toMatchObject( [ 4, 5, 6 ] ); } ); + test( 'overwritten objects updates inherited values', async ( { + page, + } ) => { + await page.getByTestId( 'parent replace' ).click(); + + const childContext = await parseContent( + page.getByTestId( 'child context' ) + ); + + expect( childContext.obj.prop4 ).toBeUndefined(); + expect( childContext.obj.prop5 ).toBe( 'child' ); + expect( childContext.obj.prop6 ).toBe( 'child' ); + expect( childContext.obj.overwritten ).toBe( true ); + + const parentContext = await parseContent( + page.getByTestId( 'parent context' ) + ); + + expect( parentContext.obj.prop4 ).toBeUndefined(); + expect( parentContext.obj.prop5 ).toBeUndefined(); + expect( parentContext.obj.prop6 ).toBeUndefined(); + expect( parentContext.obj.overwritten ).toBe( true ); + } ); + + test( 'overwritten objects do not inherit values', async ( { page } ) => { + await page.getByTestId( 'child replace' ).click(); + + const childContext = await parseContent( + page.getByTestId( 'child context' ) + ); + + expect( childContext.obj.prop4 ).toBeUndefined(); + expect( childContext.obj.prop5 ).toBeUndefined(); + expect( childContext.obj.prop6 ).toBeUndefined(); + expect( childContext.obj.overwritten ).toBe( true ); + + const parentContext = await parseContent( + page.getByTestId( 'parent context' ) + ); + + expect( parentContext.obj.prop4 ).toBe( 'parent' ); + expect( parentContext.obj.prop5 ).toBe( 'parent' ); + expect( parentContext.obj.prop6 ).toBeUndefined(); + expect( parentContext.obj.overwritten ).toBeUndefined(); + } ); + test( 'can be accessed in other directives on the same element', async ( { page, } ) => { @@ -181,6 +248,39 @@ test.describe( 'data-wp-context', () => { await expect( element ).toHaveText( 'some new text' ); } ); + test( 'should update values when navigating back or forward', async ( { + page, + } ) => { + const element = page.getByTestId( 'navigation text' ); + await page.getByTestId( 'navigate' ).click(); + await expect( element ).toHaveText( 'second page' ); + await page.goBack(); + await expect( element ).toHaveText( 'first page' ); + await page.goForward(); + await expect( element ).toHaveText( 'second page' ); + } ); + + test( 'should inherit values on navigation', async ( { page } ) => { + const text = page.getByTestId( 'navigation inherited text' ); + const text2 = page.getByTestId( 'navigation inherited text2' ); + await expect( text ).toHaveText( 'first page' ); + await expect( text2 ).toBeEmpty(); + await page.getByTestId( 'toggle text' ).click(); + await expect( text ).toHaveText( 'changed dynamically' ); + await page.getByTestId( 'add text2' ).click(); + await expect( text2 ).toHaveText( 'some new text' ); + await page.getByTestId( 'navigate' ).click(); + await expect( text ).toHaveText( 'second page' ); + await expect( text2 ).toHaveText( 'second page' ); + await page.goBack(); + await expect( text ).toHaveText( 'first page' ); + // text2 maintains its value as it is not defined in the first page. + await expect( text2 ).toHaveText( 'second page' ); + await page.goForward(); + await expect( text ).toHaveText( 'second page' ); + await expect( text2 ).toHaveText( 'second page' ); + } ); + test( 'should maintain the same context reference on async actions', async ( { page, } ) => { @@ -199,4 +299,41 @@ test.describe( 'data-wp-context', () => { const element = page.getByTestId( 'non-default suffix context' ); await expect( element ).toHaveText( '' ); } ); + + test( 'references to objects are kept', async ( { page } ) => { + const selected = page.getByTestId( 'selected' ); + const select1 = page.getByTestId( 'select 1' ); + const select2 = page.getByTestId( 'select 2' ); + + await expect( selected ).toBeEmpty(); + + await select1.click(); + await expect( selected ).toHaveText( 'Text 1' ); + + await select2.click(); + await expect( selected ).toHaveText( 'Text 2' ); + } ); + + test( 'should not subscribe to parent context props if those also exist in child', async ( { + page, + } ) => { + const counterParent = page.getByTestId( 'counter parent' ); + const counterChild = page.getByTestId( 'counter child' ); + const changes = page.getByTestId( 'counter changes' ); + + await expect( counterParent ).toHaveText( '0' ); + await expect( counterChild ).toHaveText( '0' ); + // The first render counts, so the changes counter starts at 1. + await expect( changes ).toHaveText( '1' ); + + await counterParent.click(); + await expect( counterParent ).toHaveText( '1' ); + await expect( counterChild ).toHaveText( '0' ); + await expect( changes ).toHaveText( '1' ); + + await counterChild.click(); + await expect( counterParent ).toHaveText( '1' ); + await expect( counterChild ).toHaveText( '1' ); + await expect( changes ).toHaveText( '2' ); + } ); } ); diff --git a/test/e2e/specs/site-editor/style-book.spec.js b/test/e2e/specs/site-editor/style-book.spec.js index 08f5e6463ebc7..a832e06f6a639 100644 --- a/test/e2e/specs/site-editor/style-book.spec.js +++ b/test/e2e/specs/site-editor/style-book.spec.js @@ -155,6 +155,35 @@ test.describe( 'Style Book', () => { 'should close when Escape key is pressed' ).toBeHidden(); } ); + + test( 'should persist when navigating the global styles sidebar', async ( { + page, + } ) => { + await page + .getByRole( 'region', { name: 'Editor settings' } ) + .getByRole( 'button', { name: 'Browse styles' } ) + .click(); + + const styleBookRegion = page.getByRole( 'region', { + name: 'Style Book', + } ); + await expect( + styleBookRegion, + 'style book should be visible' + ).toBeVisible(); + + await page.click( 'role=button[name="Navigate to the previous view"]' ); + + await page + .getByRole( 'region', { name: 'Editor settings' } ) + .getByRole( 'button', { name: 'Typography' } ) + .click(); + + await expect( + styleBookRegion, + 'style book should be visible' + ).toBeVisible(); + } ); } ); class StyleBook { diff --git a/test/e2e/specs/site-editor/user-global-styles-revisions.spec.js b/test/e2e/specs/site-editor/user-global-styles-revisions.spec.js index 9f5c9c8e36b22..b4848fe9401c4 100644 --- a/test/e2e/specs/site-editor/user-global-styles-revisions.spec.js +++ b/test/e2e/specs/site-editor/user-global-styles-revisions.spec.js @@ -192,6 +192,38 @@ test.describe( 'Style Revisions', () => { ).toBeHidden(); } ); + test( 'should close revisions panel and leave style book open if activated', async ( { + page, + editor, + userGlobalStylesRevisions, + } ) => { + await editor.canvas.locator( 'body' ).click(); + await userGlobalStylesRevisions.openStylesPanel(); + const revisionsButton = page.getByRole( 'button', { + name: 'Revisions', + } ); + const styleBookButton = page.getByRole( 'button', { + name: 'Style Book', + } ); + await revisionsButton.click(); + await styleBookButton.click(); + + await expect( + page.getByLabel( 'Global styles revisions list' ) + ).toBeVisible(); + + await page.click( 'role=button[name="Navigate to the previous view"]' ); + + await expect( + page.getByLabel( 'Global styles revisions list' ) + ).toBeHidden(); + + // The site editor canvas has been restored. + await expect( + page.locator( 'iframe[name="style-book-canvas"]' ) + ).toBeVisible(); + } ); + test( 'should paginate', async ( { page, editor, diff --git a/test/performance/specs/site-editor.spec.js b/test/performance/specs/site-editor.spec.js index 0766d1f0acab3..89e8d9d347776 100644 --- a/test/performance/specs/site-editor.spec.js +++ b/test/performance/specs/site-editor.spec.js @@ -229,15 +229,47 @@ test.describe( 'Site Editor Performance', () => { } ); await editor.openDocumentSettingsSidebar(); + /* + * https://github.com/WordPress/gutenberg/pull/55091 updated the HTML by + * removing the replace template button in sidebar-edit-mode/template-panel/replace-template-button.js + * with a "transform into" list. https://github.com/WordPress/gutenberg/pull/59259 made these tests + * compatible with the new UI, however, the performance tests compare previous versions of the UI. + * + * The following code is a workaround to test the performance of the new UI. + * `actionsButtonElement` is used to check if the old UI is present. + * If there is a Replace template button (old UI), click it, otherwise, click the "transform into" button. + * Once the performance tests are updated to compare compatible versions this code can be removed. + */ + // eslint-disable-next-line no-restricted-syntax + const isActionsButtonVisible = await page + .locator( + '.edit-site-template-card__actions button[aria-label="Actions"]' + ) + .isVisible(); + + if ( isActionsButtonVisible ) { + await page + .getByRole( 'button', { + name: 'Actions', + } ) + .click(); + } + // Wait for the browser to be idle before starting the monitoring. // eslint-disable-next-line no-restricted-syntax await page.waitForTimeout( BROWSER_IDLE_WAIT ); const startTime = performance.now(); - await page - .getByRole( 'button', { name: 'Transform into:' } ) - .click(); + if ( isActionsButtonVisible ) { + await page + .getByRole( 'menuitem', { name: 'Replace template' } ) + .click(); + } else { + await page + .getByRole( 'button', { name: 'Transform into:' } ) + .click(); + } const patterns = [ 'Blogging home template',