diff --git a/docs/reference-guides/data/data-core-block-editor.md b/docs/reference-guides/data/data-core-block-editor.md index 7b0bd386daaf48..b03e905d166bc0 100644 --- a/docs/reference-guides/data/data-core-block-editor.md +++ b/docs/reference-guides/data/data-core-block-editor.md @@ -409,6 +409,19 @@ _Returns_ - `WPBlock[]`: Block objects. +### getBlocksByName + +Returns all blocks that match a blockName. Results include nested blocks. + +_Parameters_ + +- _state_ `Object`: Global application state. +- _blockName_ `?string`: Optional block name, if not specified, returns an empty array. + +_Returns_ + +- `Array`: Array of clientIds of blocks with name equal to blockName. + ### getBlockSelectionEnd Returns the current block selection end. This value may be null, and it may represent either a singular block selection or multi-selection end. A selection is singular if its start and end match. diff --git a/docs/reference-guides/data/data-core-notices.md b/docs/reference-guides/data/data-core-notices.md index e11e6f226169f9..d36098429811dd 100644 --- a/docs/reference-guides/data/data-core-notices.md +++ b/docs/reference-guides/data/data-core-notices.md @@ -277,7 +277,7 @@ export const ExampleComponent = () => { const notices = useSelect( ( select ) => select( noticesStore ).getNotices() ); - const { removeNotices } = useDispatch( noticesStore ); + const { removeAllNotices } = useDispatch( noticesStore ); return ( <> '; - } - - // Add directives to the submenu if needed. - if ( $has_submenus && $should_load_view_script ) { - $tags = new WP_HTML_Tag_Processor( $inner_blocks_html ); - $inner_blocks_html = gutenberg_block_core_navigation_add_directives_to_submenu( $tags, $attributes ); - } - - return $inner_blocks_html; - } - - /** - * Gets the inner blocks for the navigation block from the navigation post. - * - * @param array $attributes The block attributes. - * @return WP_Block_List Returns the inner blocks for the navigation block. - */ - private static function get_inner_blocks_from_navigation_post( $attributes ) { - $navigation_post = get_post( $attributes['ref'] ); - if ( ! isset( $navigation_post ) ) { - return new WP_Block_List( array(), $attributes ); - } - - // Only published posts are valid. If this is changed then a corresponding change - // must also be implemented in `use-navigation-menu.js`. - if ( 'publish' === $navigation_post->post_status ) { - $parsed_blocks = parse_blocks( $navigation_post->post_content ); - - // 'parse_blocks' includes a null block with '\n\n' as the content when - // it encounters whitespace. This code strips it. - $compacted_blocks = gutenberg_block_core_navigation_filter_out_empty_blocks( $parsed_blocks ); - - // TODO - this uses the full navigation block attributes for the - // context which could be refined. - return new WP_Block_List( $compacted_blocks, $attributes ); - } - } - - /** - * Gets the inner blocks for the navigation block from the fallback. - * - * @param array $attributes The block attributes. - * @return WP_Block_List Returns the inner blocks for the navigation block. - */ - private static function get_inner_blocks_from_fallback( $attributes ) { - $fallback_blocks = gutenberg_block_core_navigation_get_fallback_blocks(); - - // Fallback my have been filtered so do basic test for validity. - if ( empty( $fallback_blocks ) || ! is_array( $fallback_blocks ) ) { - return new WP_Block_List( array(), $attributes ); - } - - return new WP_Block_List( $fallback_blocks, $attributes ); - } - - /** - * Gets the inner blocks for the navigation block. - * - * @param array $attributes The block attributes. - * @param WP_Block $block The parsed block. - * @return WP_Block_List Returns the inner blocks for the navigation block. - */ - private static function get_inner_blocks( $attributes, $block ) { - $inner_blocks = $block->inner_blocks; - - // Ensure that blocks saved with the legacy ref attribute name (navigationMenuId) continue to render. - if ( array_key_exists( 'navigationMenuId', $attributes ) ) { - $attributes['ref'] = $attributes['navigationMenuId']; - } - - // If: - // - the gutenberg plugin is active - // - `__unstableLocation` is defined - // - we have menu items at the defined location - // - we don't have a relationship to a `wp_navigation` Post (via `ref`). - // ...then create inner blocks from the classic menu assigned to that location. - if ( - defined( 'IS_GUTENBERG_PLUGIN' ) && IS_GUTENBERG_PLUGIN && - array_key_exists( '__unstableLocation', $attributes ) && - ! array_key_exists( 'ref', $attributes ) && - ! empty( gutenberg_block_core_navigation_get_menu_items_at_location( $attributes['__unstableLocation'] ) ) - ) { - $inner_blocks = gutenberg_block_core_navigation_get_inner_blocks_from_unstable_location( $attributes ); - } - - // Load inner blocks from the navigation post. - if ( array_key_exists( 'ref', $attributes ) ) { - $inner_blocks = static::get_inner_blocks_from_navigation_post( $attributes ); - } - - // If there are no inner blocks then fallback to rendering an appropriate fallback. - if ( empty( $inner_blocks ) ) { - $inner_blocks = static::get_inner_blocks_from_fallback( $attributes ); - } - - /** - * Filter navigation block $inner_blocks. - * Allows modification of a navigation block menu items. - * - * @since 6.1.0 - * - * @param \WP_Block_List $inner_blocks - */ - $inner_blocks = apply_filters( 'block_core_navigation_render_inner_blocks', $inner_blocks ); - - $post_ids = gutenberg_block_core_navigation_get_post_ids( $inner_blocks ); - if ( $post_ids ) { - _prime_post_caches( $post_ids, false, false ); - } - - return $inner_blocks; - } - - /** - * Gets the name of the current navigation, if it has one. - * - * @param array $attributes The block attributes. - * @return string Returns the name of the navigation. - */ - private static function get_navigation_name( $attributes ) { - - $navigation_name = $attributes['ariaLabel'] ?? ''; - - // Load the navigation post. - if ( array_key_exists( 'ref', $attributes ) ) { - $navigation_post = get_post( $attributes['ref'] ); - if ( ! isset( $navigation_post ) ) { - return $navigation_name; - } - - // Only published posts are valid. If this is changed then a corresponding change - // must also be implemented in `use-navigation-menu.js`. - if ( 'publish' === $navigation_post->post_status ) { - $navigation_name = $navigation_post->post_title; - - // This is used to count the number of times a navigation name has been seen, - // so that we can ensure every navigation has a unique id. - if ( isset( static::$seen_menu_names[ $navigation_name ] ) ) { - ++static::$seen_menu_names[ $navigation_name ]; - } else { - static::$seen_menu_names[ $navigation_name ] = 1; - } - } - } - - return $navigation_name; - } - - /** - * Returns the layout class for the navigation block. - * - * @param array $attributes The block attributes. - * @return string Returns the layout class for the navigation block. - */ - private static function get_layout_class( $attributes ) { - $layout_justification = array( - 'left' => 'items-justified-left', - 'right' => 'items-justified-right', - 'center' => 'items-justified-center', - 'space-between' => 'items-justified-space-between', - ); - - $layout_class = ''; - if ( - isset( $attributes['layout']['justifyContent'] ) && - isset( $layout_justification[ $attributes['layout']['justifyContent'] ] ) - ) { - $layout_class .= $layout_justification[ $attributes['layout']['justifyContent'] ]; - } - if ( isset( $attributes['layout']['orientation'] ) && 'vertical' === $attributes['layout']['orientation'] ) { - $layout_class .= ' is-vertical'; - } - - if ( isset( $attributes['layout']['flexWrap'] ) && 'nowrap' === $attributes['layout']['flexWrap'] ) { - $layout_class .= ' no-wrap'; - } - return $layout_class; - } - - /** - * Return classes for the navigation block. - * - * @param array $attributes The block attributes. - * @return string Returns the classes for the navigation block. - */ - private static function get_classes( $attributes ) { - // Restore legacy classnames for submenu positioning. - $layout_class = static::get_layout_class( $attributes ); - $colors = gutenberg_block_core_navigation_build_css_colors( $attributes ); - $font_sizes = gutenberg_block_core_navigation_build_css_font_sizes( $attributes ); - $is_responsive_menu = static::is_responsive( $attributes ); - - // Manually add block support text decoration as CSS class. - $text_decoration = $attributes['style']['typography']['textDecoration'] ?? null; - $text_decoration_class = sprintf( 'has-text-decoration-%s', $text_decoration ); - - // Sets the is-collapsed class when the navigation is set to always use the overlay. - // This saves us from needing to do this check in the view.js file (see the collapseNav function). - $is_collapsed_class = static::is_always_overlay( $attributes ) ? array( 'is-collapsed' ) : array(); - - $classes = array_merge( - $colors['css_classes'], - $font_sizes['css_classes'], - $is_responsive_menu ? array( 'is-responsive' ) : array(), - $layout_class ? array( $layout_class ) : array(), - $text_decoration ? array( $text_decoration_class ) : array(), - $is_collapsed_class - ); - return implode( ' ', $classes ); - } - - private static function is_always_overlay( $attributes ) { - return isset( $attributes['overlayMenu'] ) && 'always' === $attributes['overlayMenu']; - } - - /** - * Get styles for the navigation block. - * - * @param array $attributes The block attributes. - * @return string Returns the styles for the navigation block. - */ - private static function get_styles( $attributes ) { - $colors = gutenberg_block_core_navigation_build_css_colors( $attributes ); - $font_sizes = gutenberg_block_core_navigation_build_css_font_sizes( $attributes ); - $block_styles = isset( $attributes['styles'] ) ? $attributes['styles'] : ''; - return $block_styles . $colors['inline_styles'] . $font_sizes['inline_styles']; - } - - /** - * Get the responsive container markup - * - * @param array $attributes The block attributes. - * @param WP_Block_List $inner_blocks The list of inner blocks. - * @param string $inner_blocks_html The markup for the inner blocks. - * @return string Returns the container markup. - */ - private static function get_responsive_container_markup( $attributes, $inner_blocks, $inner_blocks_html ) { - $should_load_view_script = static::should_load_view_script( $attributes, $inner_blocks ); - $colors = gutenberg_block_core_navigation_build_css_colors( $attributes ); - $modal_unique_id = wp_unique_id( 'modal-' ); - - $responsive_container_classes = array( - 'wp-block-navigation__responsive-container', - implode( ' ', $colors['overlay_css_classes'] ), - ); - $open_button_classes = array( - 'wp-block-navigation__responsive-container-open', - ); - - $should_display_icon_label = isset( $attributes['hasIcon'] ) && true === $attributes['hasIcon']; - $toggle_button_icon = ''; - if ( isset( $attributes['icon'] ) ) { - if ( 'menu' === $attributes['icon'] ) { - $toggle_button_icon = ''; - } - } - $toggle_button_content = $should_display_icon_label ? $toggle_button_icon : __( 'Menu' ); - $toggle_close_button_icon = ''; - $toggle_close_button_content = $should_display_icon_label ? $toggle_close_button_icon : __( 'Close' ); - $toggle_aria_label_open = $should_display_icon_label ? 'aria-label="' . __( 'Open menu' ) . '"' : ''; // Open button label. - $toggle_aria_label_close = $should_display_icon_label ? 'aria-label="' . __( 'Close menu' ) . '"' : ''; // Close button label. - - // Add Interactivity API directives to the markup if needed. - $open_button_directives = ''; - $responsive_container_directives = ''; - $responsive_dialog_directives = ''; - $close_button_directives = ''; - if ( $should_load_view_script ) { - $open_button_directives = ' - data-wp-on--click="actions.openMenuOnClick" - data-wp-on--keydown="actions.handleMenuKeydown" - '; - $responsive_container_directives = ' - data-wp-class--has-modal-open="state.isMenuOpen" - data-wp-class--is-menu-open="state.isMenuOpen" - data-wp-watch="callbacks.initMenu" - data-wp-on--keydown="actions.handleMenuKeydown" - data-wp-on--focusout="actions.handleMenuFocusout" - tabindex="-1" - '; - $responsive_dialog_directives = ' - data-wp-bind--aria-modal="state.ariaModal" - data-wp-bind--aria-label="state.ariaLabel" - data-wp-bind--role="state.roleAttribute" - '; - $close_button_directives = ' - data-wp-on--click="actions.closeMenuOnClick" - '; - $responsive_container_content_directives = ' - data-wp-watch="callbacks.focusFirstElement" - '; - } - - return sprintf( - ' -
-
-
- -
- %2$s -
-
-
-
', - esc_attr( $modal_unique_id ), - $inner_blocks_html, - $toggle_aria_label_open, - $toggle_aria_label_close, - esc_attr( implode( ' ', $responsive_container_classes ) ), - esc_attr( implode( ' ', $open_button_classes ) ), - esc_attr( safecss_filter_attr( $colors['overlay_inline_styles'] ) ), - $toggle_button_content, - $toggle_close_button_content, - $open_button_directives, - $responsive_container_directives, - $responsive_dialog_directives, - $close_button_directives, - $responsive_container_content_directives - ); - } - - /** - * Get the wrapper attributes - * - * @param array $attributes The block attributes. - * @param WP_Block_List $inner_blocks A list of inner blocks. - * @return string Returns the navigation block markup. - */ - private static function get_nav_wrapper_attributes( $attributes, $inner_blocks ) { - $nav_menu_name = static::get_unique_navigation_name( $attributes ); - $should_load_view_script = static::should_load_view_script( $attributes, $inner_blocks ); - $is_responsive_menu = static::is_responsive( $attributes ); - $style = static::get_styles( $attributes ); - $class = static::get_classes( $attributes ); - $wrapper_attributes = get_block_wrapper_attributes( - array( - 'class' => $class, - 'style' => $style, - 'aria-label' => $nav_menu_name, - ) - ); - - if ( $is_responsive_menu ) { - $nav_element_directives = static::get_nav_element_directives( $should_load_view_script, $attributes ); - $wrapper_attributes .= ' ' . $nav_element_directives; - } - - return $wrapper_attributes; - } - - /** - * Get the nav element directives - * - * @param bool $should_load_view_script Whether or not the view script should be loaded. - * @return string the directives for the navigation element. - */ - private static function get_nav_element_directives( $should_load_view_script, $attributes ) { - if ( ! $should_load_view_script ) { - return ''; - } - // When adding to this array be mindful of security concerns. - $nav_element_context = wp_json_encode( - array( - 'overlayOpenedBy' => array(), - 'type' => 'overlay', - 'roleAttribute' => '', - 'ariaLabel' => __( 'Menu' ), - ), - JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP - ); - $nav_element_directives = ' - data-wp-interactive=\'{"namespace":"core/navigation"}\' - data-wp-context=\'' . $nav_element_context . '\' - '; - - // When the navigation overlayMenu attribute is set to "always" - // we don't need to use JavaScript to collapse the menu as we set the class manually. - if ( ! static::is_always_overlay( $attributes ) ) { - $nav_element_directives .= 'data-wp-init="callbacks.initNav"'; - $nav_element_directives .= ' '; // space separator - $nav_element_directives .= 'data-wp-class--is-collapsed="context.isCollapsed"'; - } - - return $nav_element_directives; - } - - /** - * Handle view script loading. - * - * @param array $attributes The block attributes. - * @param WP_Block $block The parsed block. - * @param WP_Block_List $inner_blocks The list of inner blocks. - */ - private static function handle_view_script_loading( $attributes, $block, $inner_blocks ) { - $should_load_view_script = static::should_load_view_script( $attributes, $inner_blocks ); - $is_gutenberg_plugin = defined( 'IS_GUTENBERG_PLUGIN' ) && IS_GUTENBERG_PLUGIN; - $view_js_file = 'wp-block-navigation-view'; - $script_handles = $block->block_type->view_script_handles; - - if ( $is_gutenberg_plugin ) { - if ( $should_load_view_script ) { - gutenberg_enqueue_module( '@wordpress/block-library/navigation-block' ); - } - // Remove the view script because we are using the module. - $block->block_type->view_script_handles = array_diff( $script_handles, array( $view_js_file ) ); - } else { - // If the script already exists, there is no point in removing it from viewScript. - if ( ! wp_script_is( $view_js_file ) ) { - - // If the script is not needed, and it is still in the `view_script_handles`, remove it. - if ( ! $should_load_view_script && in_array( $view_js_file, $script_handles, true ) ) { - $block->block_type->view_script_handles = array_diff( $script_handles, array( $view_js_file ) ); - } - // If the script is needed, but it was previously removed, add it again. - if ( $should_load_view_script && ! in_array( $view_js_file, $script_handles, true ) ) { - $block->block_type->view_script_handles = array_merge( $script_handles, array( $view_js_file ) ); - } - } - } - } - - /** - * Returns the markup for the navigation block. - * - * @param array $attributes The block attributes. - * @param WP_Block_List $inner_blocks The list of inner blocks. - * @return string Returns the navigation wrapper markup. - */ - private static function get_wrapper_markup( $attributes, $inner_blocks ) { - $inner_blocks_html = static::get_inner_blocks_html( $attributes, $inner_blocks ); - if ( static::is_responsive( $attributes ) ) { - return static::get_responsive_container_markup( $attributes, $inner_blocks, $inner_blocks_html ); - } - return $inner_blocks_html; - } - - /** - * Returns a unique name for the navigation. - * - * @param array $attributes The block attributes. - * @return string Returns a unique name for the navigation. - */ - private static function get_unique_navigation_name( $attributes ) { - $nav_menu_name = static::get_navigation_name( $attributes ); - - // If the menu name has been used previously then append an ID - // to the name to ensure uniqueness across a given post. - if ( isset( static::$seen_menu_names[ $nav_menu_name ] ) && static::$seen_menu_names[ $nav_menu_name ] > 1 ) { - $count = static::$seen_menu_names[ $nav_menu_name ]; - $nav_menu_name = $nav_menu_name . ' ' . ( $count ); - } - - return $nav_menu_name; - } - - /** - * Renders the navigation block. - * - * @param array $attributes The block attributes. - * @param string $content The saved content. - * @param WP_Block $block The parsed block. - * @return string Returns the navigation block markup. - */ - public static function render( $attributes, $content, $block ) { - /** - * Deprecated: - * The rgbTextColor and rgbBackgroundColor attributes - * have been deprecated in favor of - * customTextColor and customBackgroundColor ones. - * Move the values from old attrs to the new ones. - */ - if ( isset( $attributes['rgbTextColor'] ) && empty( $attributes['textColor'] ) ) { - $attributes['customTextColor'] = $attributes['rgbTextColor']; - } - - if ( isset( $attributes['rgbBackgroundColor'] ) && empty( $attributes['backgroundColor'] ) ) { - $attributes['customBackgroundColor'] = $attributes['rgbBackgroundColor']; - } - - unset( $attributes['rgbTextColor'], $attributes['rgbBackgroundColor'] ); - - $inner_blocks = static::get_inner_blocks( $attributes, $block ); - // Prevent navigation blocks referencing themselves from rendering. - if ( gutenberg_block_core_navigation_block_contains_core_navigation( $inner_blocks ) ) { - return ''; - } - - static::handle_view_script_loading( $attributes, $block, $inner_blocks ); - - return sprintf( - '', - static::get_nav_wrapper_attributes( $attributes, $inner_blocks ), - static::get_wrapper_markup( $attributes, $inner_blocks ) - ); - } -} diff --git a/lib/compat/wordpress-6.5/class-wp-script-modules.php b/lib/compat/wordpress-6.5/class-wp-script-modules.php new file mode 100644 index 00000000000000..f6a2a348f92ef3 --- /dev/null +++ b/lib/compat/wordpress-6.5/class-wp-script-modules.php @@ -0,0 +1,323 @@ + + */ + private $enqueued_before_registered = array(); + + /** + * Registers the script module if no script module with that script module + * identifier has already been registered. + * + * @since 6.5.0 + * + * @param string $id The identifier of the script module. Should be unique. It will be used in the + * final import map. + * @param string $src Optional. Full URL of the script module, or path of the script module relative + * to the WordPress root directory. If it is provided and the script module has + * not been registered yet, it will be registered. + * @param array $deps { + * Optional. List of dependencies. + * + * @type string|array $0... { + * An array of script module identifiers of the dependencies of this script + * module. The dependencies can be strings or arrays. If they are arrays, + * they need an `id` key with the script module identifier, and can contain + * an `import` key with either `static` or `dynamic`. By default, + * dependencies that don't contain an `import` key are considered static. + * + * @type string $id The script module identifier. + * @type string $import Optional. Import type. May be either `static` or + * `dynamic`. Defaults to `static`. + * } + * } + * @param string|false|null $version Optional. String specifying the script module version number. Defaults to false. + * It is added to the URL as a query string for cache busting purposes. If $version + * is set to false, the version number is the currently installed WordPress version. + * If $version is set to null, no version is added. + */ + public function register( string $id, string $src, array $deps = array(), $version = false ) { + if ( ! isset( $this->registered[ $id ] ) ) { + $dependencies = array(); + foreach ( $deps as $dependency ) { + if ( is_array( $dependency ) ) { + if ( ! isset( $dependency['id'] ) ) { + _doing_it_wrong( __METHOD__, __( 'Missing required id key in entry among dependencies array.' ), '6.5.0' ); + continue; + } + $dependencies[] = array( + 'id' => $dependency['id'], + 'import' => isset( $dependency['import'] ) && 'dynamic' === $dependency['import'] ? 'dynamic' : 'static', + ); + } elseif ( is_string( $dependency ) ) { + $dependencies[] = array( + 'id' => $dependency, + 'import' => 'static', + ); + } else { + _doing_it_wrong( __METHOD__, __( 'Entries in dependencies array must be either strings or arrays with an id key.' ), '6.5.0' ); + } + } + + $this->registered[ $id ] = array( + 'src' => $src, + 'version' => $version, + 'enqueue' => isset( $this->enqueued_before_registered[ $id ] ), + 'dependencies' => $dependencies, + ); + } + } + + /** + * Marks the script module to be enqueued in the page. + * + * If a src is provided and the script module has not been registered yet, it + * will be registered. + * + * @since 6.5.0 + * + * @param string $id The identifier of the script module. Should be unique. It will be used in the + * final import map. + * @param string $src Optional. Full URL of the script module, or path of the script module relative + * to the WordPress root directory. If it is provided and the script module has + * not been registered yet, it will be registered. + * @param array $deps { + * Optional. List of dependencies. + * + * @type string|array $0... { + * An array of script module identifiers of the dependencies of this script + * module. The dependencies can be strings or arrays. If they are arrays, + * they need an `id` key with the script module identifier, and can contain + * an `import` key with either `static` or `dynamic`. By default, + * dependencies that don't contain an `import` key are considered static. + * + * @type string $id The script module identifier. + * @type string $import Optional. Import type. May be either `static` or + * `dynamic`. Defaults to `static`. + * } + * } + * @param string|false|null $version Optional. String specifying the script module version number. Defaults to false. + * It is added to the URL as a query string for cache busting purposes. If $version + * is set to false, the version number is the currently installed WordPress version. + * If $version is set to null, no version is added. + */ + public function enqueue( string $id, string $src = '', array $deps = array(), $version = false ) { + if ( isset( $this->registered[ $id ] ) ) { + $this->registered[ $id ]['enqueue'] = true; + } elseif ( $src ) { + $this->register( $id, $src, $deps, $version ); + $this->registered[ $id ]['enqueue'] = true; + } else { + $this->enqueued_before_registered[ $id ] = true; + } + } + + /** + * Unmarks the script module so it will no longer be enqueued in the page. + * + * @since 6.5.0 + * + * @param string $id The identifier of the script module. + */ + public function dequeue( string $id ) { + if ( isset( $this->registered[ $id ] ) ) { + $this->registered[ $id ]['enqueue'] = false; + } + unset( $this->enqueued_before_registered[ $id ] ); + } + + /** + * Adds the hooks to print the import map, enqueued script modules and script + * module preloads. + * + * In classic themes, the script modules used by the blocks are not yet known + * when the `wp_head` actions is fired, so it needs to print everything in the + * footer. + * + * @since 6.5.0 + */ + public function add_hooks() { + $position = wp_is_block_theme() ? 'wp_head' : 'wp_footer'; + add_action( $position, array( $this, 'print_import_map' ) ); + add_action( $position, array( $this, 'print_enqueued_script_modules' ) ); + add_action( $position, array( $this, 'print_script_module_preloads' ) ); + } + + /** + * Prints the enqueued script modules using script tags with type="module" + * attributes. + * + * @since 6.5.0 + */ + public function print_enqueued_script_modules() { + foreach ( $this->get_marked_for_enqueue() as $id => $script_module ) { + wp_print_script_tag( + array( + 'type' => 'module', + 'src' => $this->get_versioned_src( $script_module ), + 'id' => $id . '-js-module', + ) + ); + } + } + + /** + * Prints the the static dependencies of the enqueued script modules using + * link tags with rel="modulepreload" attributes. + * + * If a script module is marked for enqueue, it will not be preloaded. + * + * @since 6.5.0 + */ + public function print_script_module_preloads() { + foreach ( $this->get_dependencies( array_keys( $this->get_marked_for_enqueue() ), array( 'static' ) ) as $id => $script_module ) { + // Don't preload if it's marked for enqueue. + if ( true !== $script_module['enqueue'] ) { + echo sprintf( + '', + esc_url( $this->get_versioned_src( $script_module ) ), + esc_attr( $id . '-js-modulepreload' ) + ); + } + } + } + + /** + * Prints the import map using a script tag with a type="importmap" attribute. + * + * @since 6.5.0 + */ + public function print_import_map() { + $import_map = $this->get_import_map(); + if ( ! empty( $import_map['imports'] ) ) { + wp_print_inline_script_tag( + wp_json_encode( $import_map, JSON_HEX_TAG | JSON_HEX_AMP ), + array( + 'type' => 'importmap', + 'id' => 'wp-importmap', + ) + ); + } + } + + /** + * Returns the import map array. + * + * @since 6.5.0 + * + * @return array Array with an `imports` key mapping to an array of script module identifiers and their respective + * URLs, including the version query. + */ + private function get_import_map(): array { + $imports = array(); + foreach ( $this->get_dependencies( array_keys( $this->get_marked_for_enqueue() ) ) as $id => $script_module ) { + $imports[ $id ] = $this->get_versioned_src( $script_module ); + } + return array( 'imports' => $imports ); + } + + /** + * Retrieves the list of script modules marked for enqueue. + * + * @since 6.5.0 + * + * @return array Script modules marked for enqueue, keyed by script module identifier. + */ + private function get_marked_for_enqueue(): array { + $enqueued = array(); + foreach ( $this->registered as $id => $script_module ) { + if ( true === $script_module['enqueue'] ) { + $enqueued[ $id ] = $script_module; + } + } + return $enqueued; + } + + /** + * Retrieves all the dependencies for the given script module identifiers, + * filtered by import types. + * + * It will consolidate an array containing a set of unique dependencies based + * on the requested import types: 'static', 'dynamic', or both. This method is + * recursive and also retrieves dependencies of the dependencies. + * + * @since 6.5.0 + * + + * @param string[] $ids The identifiers of the script modules for which to gather dependencies. + * @param array $import_types Optional. Import types of dependencies to retrieve: 'static', 'dynamic', or both. + * Default is both. + * @return array List of dependencies, keyed by script module identifier. + */ + private function get_dependencies( array $ids, array $import_types = array( 'static', 'dynamic' ) ) { + return array_reduce( + $ids, + function ( $dependency_script_modules, $id ) use ( $import_types ) { + $dependencies = array(); + foreach ( $this->registered[ $id ]['dependencies'] as $dependency ) { + if ( + in_array( $dependency['import'], $import_types, true ) && + isset( $this->registered[ $dependency['id'] ] ) && + ! isset( $dependency_script_modules[ $dependency['id'] ] ) + ) { + $dependencies[ $dependency['id'] ] = $this->registered[ $dependency['id'] ]; + } + } + return array_merge( $dependency_script_modules, $dependencies, $this->get_dependencies( array_keys( $dependencies ), $import_types ) ); + }, + array() + ); + } + + /** + * Gets the versioned URL for a script module src. + * + * If $version is set to false, the version number is the currently installed + * WordPress version. If $version is set to null, no version is added. + * Otherwise, the string passed in $version is used. + * + * @since 6.5.0 + * + * @param array $script_module The script module. + * @return string The script module src with a version if relevant. + */ + private function get_versioned_src( array $script_module ): string { + $args = array(); + if ( false === $script_module['version'] ) { + $args['ver'] = get_bloginfo( 'version' ); + } elseif ( null !== $script_module['version'] ) { + $args['ver'] = $script_module['version']; + } + if ( $args ) { + return add_query_arg( $args, $script_module['src'] ); + } + return $script_module['src']; + } + } +} diff --git a/lib/compat/wordpress-6.5/interactivity-api/class-wp-interactivity-api-directives-processor.php b/lib/compat/wordpress-6.5/interactivity-api/class-wp-interactivity-api-directives-processor.php new file mode 100644 index 00000000000000..b437bcefa67568 --- /dev/null +++ b/lib/compat/wordpress-6.5/interactivity-api/class-wp-interactivity-api-directives-processor.php @@ -0,0 +1,143 @@ +get_balanced_tag_bookmarks(); + if ( ! $bookmarks ) { + return null; + } + list( $start_name, $end_name ) = $bookmarks; + + $start = $this->bookmarks[ $start_name ]->start + $this->bookmarks[ $start_name ]->length + 1; + $end = $this->bookmarks[ $end_name ]->start; + + $this->seek( $start_name ); + $this->release_bookmark( $start_name ); + $this->release_bookmark( $end_name ); + + return substr( $this->html, $start, $end - $start ); + } + + /** + * Sets the content between two balanced tags. + * + * @access private + * + * @param string $new_content The string to replace the content between the matching tags. + * @return bool Whether the content was successfully replaced. + */ + public function set_content_between_balanced_tags( string $new_content ): bool { + $this->get_updated_html(); + + $bookmarks = $this->get_balanced_tag_bookmarks(); + if ( ! $bookmarks ) { + return false; + } + list( $start_name, $end_name ) = $bookmarks; + + $start = $this->bookmarks[ $start_name ]->start + $this->bookmarks[ $start_name ]->length + 1; + $end = $this->bookmarks[ $end_name ]->start; + + $this->seek( $start_name ); + $this->release_bookmark( $start_name ); + $this->release_bookmark( $end_name ); + + $this->lexical_updates[] = new Gutenberg_HTML_Text_Replacement_6_5( $start, $end - $start, esc_html( $new_content ) ); + return true; + } + + /** + * Returns a pair of bookmarks for the current opening tag and the matching + * closing tag. + * + * @return array|null A pair of bookmarks, or null if there's no matching closing tag. + */ + private function get_balanced_tag_bookmarks() { + static $i = 0; + $start_name = 'start_of_balanced_tag_' . ++$i; + + $this->set_bookmark( $start_name ); + if ( ! $this->next_balanced_closer() ) { + $this->release_bookmark( $start_name ); + return null; + } + + $end_name = 'end_of_balanced_tag_' . ++$i; + $this->set_bookmark( $end_name ); + + return array( $start_name, $end_name ); + } + + /** + * Finds the matching closing tag for an opening tag. + * + * When called while the processor is on an open tag, it traverses the HTML + * until it finds the matching closing tag, respecting any in-between content, + * including nested tags of the same name. Returns false when called on a + * closing or void tag, or if no matching closing tag was found. + * + * @return bool Whether a matching closing tag was found. + */ + private function next_balanced_closer(): bool { + $depth = 0; + $tag_name = $this->get_tag(); + + if ( $this->is_void() ) { + return false; + } + + while ( $this->next_tag( + array( + 'tag_name' => $tag_name, + 'tag_closers' => 'visit', + ) + ) ) { + if ( ! $this->is_tag_closer() ) { + ++$depth; + continue; + } + + if ( 0 === $depth ) { + return true; + } + + --$depth; + } + + return false; + } + + /** + * Checks whether the current tag is void. + * + * @access private + * + * @return bool Whether the current tag is void or not. + */ + public function is_void(): bool { + $tag_name = $this->get_tag(); + return Gutenberg_HTML_Processor_6_5::is_void( null !== $tag_name ? $tag_name : '' ); + } + } +} diff --git a/lib/compat/wordpress-6.5/interactivity-api/class-wp-interactivity-api.php b/lib/compat/wordpress-6.5/interactivity-api/class-wp-interactivity-api.php new file mode 100644 index 00000000000000..de1d8b2a9e7890 --- /dev/null +++ b/lib/compat/wordpress-6.5/interactivity-api/class-wp-interactivity-api.php @@ -0,0 +1,671 @@ + 'data_wp_interactive_processor', + 'data-wp-context' => 'data_wp_context_processor', + 'data-wp-bind' => 'data_wp_bind_processor', + 'data-wp-class' => 'data_wp_class_processor', + 'data-wp-style' => 'data_wp_style_processor', + 'data-wp-text' => 'data_wp_text_processor', + ); + + /** + * Holds the initial state of the different Interactivity API stores. + * + * This state is used during the server directive processing. Then, it is + * serialized and sent to the client as part of the interactivity data to be + * recovered during the hydration of the client interactivity stores. + * + * @since 6.5.0 + * @var array + */ + private $state_data = array(); + + /** + * Holds the configuration required by the different Interactivity API stores. + * + * This configuration is serialized and sent to the client as part of the + * interactivity data and can be accessed by the client interactivity stores. + * + * @since 6.5.0 + * @var array + */ + private $config_data = array(); + + /** + * Gets and/or sets the initial state of an Interactivity API store for a + * given namespace. + * + * If state for that store namespace already exists, it merges the new + * provided state with the existing one. + * + * @since 6.5.0 + * + * @param string $store_namespace The unique store namespace identifier. + * @param array $state Optional. The array that will be merged with the existing state for the specified + * store namespace. + * @return array The current state for the specified store namespace. + */ + public function state( string $store_namespace, array $state = null ): array { + if ( ! isset( $this->state_data[ $store_namespace ] ) ) { + $this->state_data[ $store_namespace ] = array(); + } + if ( is_array( $state ) ) { + $this->state_data[ $store_namespace ] = array_replace_recursive( + $this->state_data[ $store_namespace ], + $state + ); + } + return $this->state_data[ $store_namespace ]; + } + + /** + * Gets and/or sets the configuration of the Interactivity API for a given + * store namespace. + * + * If configuration for that store namespace exists, it merges the new + * provided configuration with the existing one. + * + * @since 6.5.0 + * + * @param string $store_namespace The unique store namespace identifier. + * @param array $config Optional. The array that will be merged with the existing configuration for the + * specified store namespace. + * @return array The current configuration for the specified store namespace. + */ + public function config( string $store_namespace, array $config = null ): array { + if ( ! isset( $this->config_data[ $store_namespace ] ) ) { + $this->config_data[ $store_namespace ] = array(); + } + if ( is_array( $config ) ) { + $this->config_data[ $store_namespace ] = array_replace_recursive( + $this->config_data[ $store_namespace ], + $config + ); + } + return $this->config_data[ $store_namespace ]; + } + + /** + * Prints the serialized client-side interactivity data. + * + * Encodes the config and initial state into JSON and prints them inside a + * script tag of type "application/json". Once in the browser, the state will + * be parsed and used to hydrate the client-side interactivity stores and the + * configuration will be available using a `getConfig` utility. + * + * @since 6.5.0 + */ + public function print_client_interactivity_data() { + $store = array(); + $has_state = ! empty( $this->state_data ); + $has_config = ! empty( $this->config_data ); + + if ( $has_state || $has_config ) { + if ( $has_config ) { + $store['config'] = $this->config_data; + } + if ( $has_state ) { + $store['state'] = $this->state_data; + } + wp_print_inline_script_tag( + wp_json_encode( + $store, + JSON_HEX_TAG | JSON_HEX_AMP + ), + array( + 'type' => 'application/json', + 'id' => 'wp-interactivity-data', + ) + ); + } + } + + /** + * Registers the `@wordpress/interactivity` script modules. + * + * @since 6.5.0 + */ + public function register_script_modules() { + wp_register_script_module( + '@wordpress/interactivity', + gutenberg_url( '/build/interactivity/index.min.js' ), + array(), + defined( 'GUTENBERG_VERSION' ) ? GUTENBERG_VERSION : get_bloginfo( 'version' ) + ); + } + + /** + * Adds the necessary hooks for the Interactivity API. + * + * @since 6.5.0 + */ + public function add_hooks() { + add_action( 'wp_footer', array( $this, 'print_client_interactivity_data' ) ); + } + + /** + * Processes the interactivity directives contained within the HTML content + * and updates the markup accordingly. + * + * @since 6.5.0 + * + * @param string $html The HTML content to process. + * @return string The processed HTML content. It returns the original content when the HTML contains unbalanced tags. + */ + public function process_directives( string $html ): string { + $p = new WP_Interactivity_API_Directives_Processor( $html ); + $tag_stack = array(); + $namespace_stack = array(); + $context_stack = array(); + $unbalanced = false; + + $directive_processor_prefixes = array_keys( self::$directive_processors ); + $directive_processor_prefixes_reversed = array_reverse( $directive_processor_prefixes ); + + while ( $p->next_tag( array( 'tag_closers' => 'visit' ) ) && false === $unbalanced ) { + $tag_name = $p->get_tag(); + + if ( $p->is_tag_closer() ) { + list( $opening_tag_name, $directives_prefixes ) = end( $tag_stack ); + + if ( 0 === count( $tag_stack ) || $opening_tag_name !== $tag_name ) { + + /* + * If the tag stack is empty or the matching opening tag is not the + * same than the closing tag, it means the HTML is unbalanced and it + * stops processing it. + */ + $unbalanced = true; + continue; + } else { + + /* + * It removes the last tag from the stack. + */ + array_pop( $tag_stack ); + + /* + * If the matching opening tag didn't have any directives, it can skip + * the processing. + */ + if ( 0 === count( $directives_prefixes ) ) { + continue; + } + } + } else { + $directives_prefixes = array(); + + foreach ( $p->get_attribute_names_with_prefix( 'data-wp-' ) as $attribute_name ) { + + /* + * Extracts the directive prefix to see if there is a server directive + * processor registered for that directive. + */ + list( $directive_prefix ) = $this->extract_prefix_and_suffix( $attribute_name ); + if ( array_key_exists( $directive_prefix, self::$directive_processors ) ) { + $directives_prefixes[] = $directive_prefix; + } + } + + /* + * If this is not a void element, it adds it to the tag stack so it can + * process its closing tag and check for unbalanced tags. + */ + if ( ! $p->is_void() ) { + $tag_stack[] = array( $tag_name, $directives_prefixes ); + } + } + + /* + * Sorts the attributes by the order of the `directives_processor` array + * and checks what directives are present in this element. The processing + * order is reversed for tag closers. + */ + $directives_prefixes = array_intersect( + $p->is_tag_closer() + ? $directive_processor_prefixes_reversed + : $directive_processor_prefixes, + $directives_prefixes + ); + + // Executes the directive processors present in this element. + foreach ( $directives_prefixes as $directive_prefix ) { + $func = is_array( self::$directive_processors[ $directive_prefix ] ) + ? self::$directive_processors[ $directive_prefix ] + : array( $this, self::$directive_processors[ $directive_prefix ] ); + call_user_func_array( + $func, + array( $p, &$context_stack, &$namespace_stack ) + ); + } + } + + /* + * It returns the original content if the HTML is unbalanced because + * unbalanced HTML is not safe to process. In that case, the Interactivity + * API runtime will update the HTML on the client side during the hydration. + */ + return $unbalanced || 0 < count( $tag_stack ) ? $html : $p->get_updated_html(); + } + + /** + * Evaluates the reference path passed to a directive based on the current + * store namespace, state and context. + * + * @since 6.5.0 + * + * @param string|true $directive_value The directive attribute value string or `true` when it's a boolean attribute. + * @param string $default_namespace The default namespace to use if none is explicitly defined in the directive + * value. + * @param array|false $context The current context for evaluating the directive or false if there is no + * context. + * @return mixed|null The result of the evaluation. Null if the reference path doesn't exist. + */ + private function evaluate( $directive_value, string $default_namespace, $context = false ) { + list( $ns, $path ) = $this->extract_directive_value( $directive_value, $default_namespace ); + if ( empty( $path ) ) { + return null; + } + + $store = array( + 'state' => isset( $this->state_data[ $ns ] ) ? $this->state_data[ $ns ] : array(), + 'context' => isset( $context[ $ns ] ) ? $context[ $ns ] : array(), + ); + + // Checks if the reference path is preceded by a negator operator (!). + $should_negate_value = '!' === $path[0]; + $path = $should_negate_value ? substr( $path, 1 ) : $path; + + // Extracts the value from the store using the reference path. + $path_segments = explode( '.', $path ); + $current = $store; + foreach ( $path_segments as $path_segment ) { + if ( isset( $current[ $path_segment ] ) ) { + $current = $current[ $path_segment ]; + } else { + return null; + } + } + + // Returns the opposite if it contains a negator operator (!). + return $should_negate_value ? ! $current : $current; + } + + /** + * Extracts the directive attribute name to separate and return the directive + * prefix and an optional suffix. + * + * The suffix is the string after the first double hyphen and the prefix is + * everything that comes before the suffix. + * + * Example: + * + * extract_prefix_and_suffix( 'data-wp-interactive' ) => array( 'data-wp-interactive', null ) + * extract_prefix_and_suffix( 'data-wp-bind--src' ) => array( 'data-wp-bind', 'src' ) + * extract_prefix_and_suffix( 'data-wp-foo--and--bar' ) => array( 'data-wp-foo', 'and--bar' ) + * + * @since 6.5.0 + * + * @param string $directive_name The directive attribute name. + * @return array An array containing the directive prefix and optional suffix. + */ + private function extract_prefix_and_suffix( string $directive_name ): array { + return explode( '--', $directive_name, 2 ); + } + + /** + * Parses and extracts the namespace and reference path from the given + * directive attribute value. + * + * If the value doesn't contain an explicit namespace, it returns the + * default one. If the value contains a JSON object instead of a reference + * path, the function tries to parse it and return the resulting array. If + * the value contains strings that reprenset booleans ("true" and "false"), + * numbers ("1" and "1.2") or "null", the function also transform them to + * regular booleans, numbers and `null`. + * + * Example: + * + * extract_directive_value( 'actions.foo', 'myPlugin' ) => array( 'myPlugin', 'actions.foo' ) + * extract_directive_value( 'otherPlugin::actions.foo', 'myPlugin' ) => array( 'otherPlugin', 'actions.foo' ) + * extract_directive_value( '{ "isOpen": false }', 'myPlugin' ) => array( 'myPlugin', array( 'isOpen' => false ) ) + * extract_directive_value( 'otherPlugin::{ "isOpen": false }', 'myPlugin' ) => array( 'otherPlugin', array( 'isOpen' => false ) ) + * + * @since 6.5.0 + * + * @param string|true $directive_value The directive attribute value. It can be `true` when it's a boolean + * attribute. + * @param string|null $default_namespace Optional. The default namespace if none is explicitly defined. + * @return array An array containing the namespace in the first item and the JSON, the reference path, or null on the + * second item. + */ + private function extract_directive_value( $directive_value, $default_namespace = null ): array { + if ( empty( $directive_value ) || is_bool( $directive_value ) ) { + return array( $default_namespace, null ); + } + + // Replaces the value and namespace if there is a namespace in the value. + if ( 1 === preg_match( '/^([\w\-_\/]+)::./', $directive_value ) ) { + list($default_namespace, $directive_value) = explode( '::', $directive_value, 2 ); + } + + /* + * Tries to decode the value as a JSON object. If it fails and the value + * isn't `null`, it returns the value as it is. Otherwise, it returns the + * decoded JSON or null for the string `null`. + */ + $decoded_json = json_decode( $directive_value, true ); + if ( null !== $decoded_json || 'null' === $directive_value ) { + $directive_value = $decoded_json; + } + + return array( $default_namespace, $directive_value ); + } + + + /** + * Processes the `data-wp-interactive` directive. + * + * It adds the default store namespace defined in the directive value to the + * stack so it's available for the nested interactivity elements. + * + * @since 6.5.0 + * + * @param WP_Interactivity_API_Directives_Processor $p The directives processor instance. + * @param array $context_stack The reference to the context stack. + * @param array $namespace_stack The reference to the store namespace stack. + */ + private function data_wp_interactive_processor( WP_Interactivity_API_Directives_Processor $p, array &$context_stack, array &$namespace_stack ) { + // In closing tags, it removes the last namespace from the stack. + if ( $p->is_tag_closer() ) { + return array_pop( $namespace_stack ); + } + + // Tries to decode the `data-wp-interactive` attribute value. + $attribute_value = $p->get_attribute( 'data-wp-interactive' ); + $decoded_json = is_string( $attribute_value ) && ! empty( $attribute_value ) + ? json_decode( $attribute_value, true ) + : null; + + /* + * Pushes the newly defined namespace or the current one if the + * `data-wp-interactive` definition was invalid or does not contain a + * namespace. It does so because the function pops out the current namespace + * from the stack whenever it finds a `data-wp-interactive`'s closing tag, + * independently of whether the previous `data-wp-interactive` definition + * contained a valid namespace. + */ + $namespace_stack[] = isset( $decoded_json['namespace'] ) + ? $decoded_json['namespace'] + : end( $namespace_stack ); + } + + /** + * Processes the `data-wp-context` directive. + * + * It adds the context defined in the directive value to the stack so it's + * available for the nested interactivity elements. + * + * @since 6.5.0 + * + * @param WP_Interactivity_API_Directives_Processor $p The directives processor instance. + * @param array $context_stack The reference to the context stack. + * @param array $namespace_stack The reference to the store namespace stack. + */ + private function data_wp_context_processor( WP_Interactivity_API_Directives_Processor $p, array &$context_stack, array &$namespace_stack ) { + // In closing tags, it removes the last context from the stack. + if ( $p->is_tag_closer() ) { + return array_pop( $context_stack ); + } + + $attribute_value = $p->get_attribute( 'data-wp-context' ); + $namespace_value = end( $namespace_stack ); + + // Separates the namespace from the context JSON object. + list( $namespace_value, $decoded_json ) = is_string( $attribute_value ) && ! empty( $attribute_value ) + ? $this->extract_directive_value( $attribute_value, $namespace_value ) + : array( $namespace_value, null ); + + /* + * If there is a namespace, it adds a new context to the stack merging the + * previous context with the new one. + */ + if ( is_string( $namespace_value ) ) { + array_push( + $context_stack, + array_replace_recursive( + end( $context_stack ) !== false ? end( $context_stack ) : array(), + array( $namespace_value => is_array( $decoded_json ) ? $decoded_json : array() ) + ) + ); + } else { + /* + * If there is no namespace, it pushes the current context to the stack. + * It needs to do so because the function pops out the current context + * from the stack whenever it finds a `data-wp-context`'s closing tag. + */ + array_push( $context_stack, end( $context_stack ) ); + } + } + + /** + * Processes the `data-wp-bind` directive. + * + * It updates or removes the bound attributes based on the evaluation of its + * associated reference. + * + * @since 6.5.0 + * + * @param WP_Interactivity_API_Directives_Processor $p The directives processor instance. + * @param array $context_stack The reference to the context stack. + * @param array $namespace_stack The reference to the store namespace stack. + */ + private function data_wp_bind_processor( WP_Interactivity_API_Directives_Processor $p, array &$context_stack, array &$namespace_stack ) { + if ( ! $p->is_tag_closer() ) { + $all_bind_directives = $p->get_attribute_names_with_prefix( 'data-wp-bind--' ); + + foreach ( $all_bind_directives as $attribute_name ) { + list( , $bound_attribute ) = $this->extract_prefix_and_suffix( $attribute_name ); + if ( empty( $bound_attribute ) ) { + return; + } + + $attribute_value = $p->get_attribute( $attribute_name ); + $result = $this->evaluate( $attribute_value, end( $namespace_stack ), end( $context_stack ) ); + + if ( null !== $result && ( false !== $result || '-' === $bound_attribute[4] ) ) { + /* + * If the result of the evaluation is a boolean and the attribute is + * `aria-` or `data-, convert it to a string "true" or "false". It + * follows the exact same logic as Preact because it needs to + * replicate what Preact will later do in the client: + * https://github.com/preactjs/preact/blob/ea49f7a0f9d1ff2c98c0bdd66aa0cbc583055246/src/diff/props.js#L131C24-L136 + */ + if ( is_bool( $result ) && '-' === $bound_attribute[4] ) { + $result = $result ? 'true' : 'false'; + } + $p->set_attribute( $bound_attribute, $result ); + } else { + $p->remove_attribute( $bound_attribute ); + } + } + } + } + + + /** + * Processes the `data-wp-class` directive. + * + * It adds or removes CSS classes in the current HTML element based on the + * evaluation of its associated references. + * + * @since 6.5.0 + * + * @param WP_Interactivity_API_Directives_Processor $p The directives processor instance. + * @param array $context_stack The reference to the context stack. + * @param array $namespace_stack The reference to the store namespace stack. + */ + private function data_wp_class_processor( WP_Interactivity_API_Directives_Processor $p, array &$context_stack, array &$namespace_stack ) { + if ( ! $p->is_tag_closer() ) { + $all_class_directives = $p->get_attribute_names_with_prefix( 'data-wp-class--' ); + + foreach ( $all_class_directives as $attribute_name ) { + list( , $class_name ) = $this->extract_prefix_and_suffix( $attribute_name ); + if ( empty( $class_name ) ) { + return; + } + + $attribute_value = $p->get_attribute( $attribute_name ); + $result = $this->evaluate( $attribute_value, end( $namespace_stack ), end( $context_stack ) ); + + if ( $result ) { + $p->add_class( $class_name ); + } else { + $p->remove_class( $class_name ); + } + } + } + } + + /** + * Processes the `data-wp-style` directive. + * + * It updates the style attribute value of the current HTML element based on + * the evaluation of its associated references. + * + * @since 6.5.0 + * + * @param WP_Interactivity_API_Directives_Processor $p The directives processor instance. + * @param array $context_stack The reference to the context stack. + * @param array $namespace_stack The reference to the store namespace stack. + */ + private function data_wp_style_processor( WP_Interactivity_API_Directives_Processor $p, array &$context_stack, array &$namespace_stack ) { + if ( ! $p->is_tag_closer() ) { + $all_style_attributes = $p->get_attribute_names_with_prefix( 'data-wp-style--' ); + + foreach ( $all_style_attributes as $attribute_name ) { + list( , $style_property ) = $this->extract_prefix_and_suffix( $attribute_name ); + if ( empty( $style_property ) ) { + continue; + } + + $directive_attribute_value = $p->get_attribute( $attribute_name ); + $style_property_value = $this->evaluate( $directive_attribute_value, end( $namespace_stack ), end( $context_stack ) ); + $style_attribute_value = $p->get_attribute( 'style' ); + $style_attribute_value = ( $style_attribute_value && ! is_bool( $style_attribute_value ) ) ? $style_attribute_value : ''; + + /* + * Checks first if the style property is not falsy and the style + * attribute value is not empty because if it is, it doesn't need to + * update the attribute value. + */ + if ( $style_property_value || ( ! $style_property_value && $style_attribute_value ) ) { + $style_attribute_value = $this->set_style_property( $style_attribute_value, $style_property, $style_property_value ); + /* + * If the style attribute value is not empty, it sets it. Otherwise, + * it removes it. + */ + if ( ! empty( $style_attribute_value ) ) { + $p->set_attribute( 'style', $style_attribute_value ); + } else { + $p->remove_attribute( 'style' ); + } + } + } + } + } + + /** + * Sets an individual style property in the `style` attribute of an HTML + * element, updating or removing the property when necessary. + * + * If a property is modified, it is added at the end of the list to make sure + * that it overrides the previous ones. + * + * @since 6.5.0 + * + * Example: + * + * set_style_property( 'color:green;', 'color', 'red' ) => 'color:red;' + * set_style_property( 'background:green;', 'color', 'red' ) => 'background:green;color:red;' + * set_style_property( 'color:green;', 'color', null ) => '' + * + * @param string $style_attribute_value The current style attribute value. + * @param string $style_property_name The style property name to set. + * @param string|false|null $style_property_value The value to set for the style property. With false, null or an + * empty string, it removes the style property. + * @return string The new style attribute value after the specified property has been added, updated or removed. + */ + private function set_style_property( string $style_attribute_value, string $style_property_name, $style_property_value ): string { + $style_assignments = explode( ';', $style_attribute_value ); + $result = array(); + $style_property_value = ! empty( $style_property_value ) ? rtrim( trim( $style_property_value ), ';' ) : null; + $new_style_property = $style_property_value ? $style_property_name . ':' . $style_property_value . ';' : ''; + + // Generate an array with all the properties but the modified one. + foreach ( $style_assignments as $style_assignment ) { + if ( empty( trim( $style_assignment ) ) ) { + continue; + } + list( $name, $value ) = explode( ':', $style_assignment ); + if ( trim( $name ) !== $style_property_name ) { + $result[] = trim( $name ) . ':' . trim( $value ) . ';'; + } + } + + // Add the new/modified property at the end of the list. + array_push( $result, $new_style_property ); + + return implode( '', $result ); + } + + /** + * Processes the `data-wp-text` directive. + * + * It updates the inner content of the current HTML element based on the + * evaluation of its associated reference. + * + * @since 6.5.0 + * + * @param WP_Interactivity_API_Directives_Processor $p The directives processor instance. + * @param array $context_stack The reference to the context stack. + * @param array $namespace_stack The reference to the store namespace stack. + */ + private function data_wp_text_processor( WP_Interactivity_API_Directives_Processor $p, array &$context_stack, array &$namespace_stack ) { + if ( ! $p->is_tag_closer() ) { + $attribute_value = $p->get_attribute( 'data-wp-text' ); + $result = $this->evaluate( $attribute_value, end( $namespace_stack ), end( $context_stack ) ); + + /* + * Follows the same logic as Preact in the client and only changes the + * content if the value is a string or a number. Otherwise, it removes the + * content. + */ + if ( is_string( $result ) || is_numeric( $result ) ) { + $p->set_content_between_balanced_tags( esc_html( $result ) ); + } else { + $p->set_content_between_balanced_tags( '' ); + } + } + } + } + +} diff --git a/lib/compat/wordpress-6.5/interactivity-api/interactivity-api.php b/lib/compat/wordpress-6.5/interactivity-api/interactivity-api.php new file mode 100644 index 00000000000000..cd7ca7fb902870 --- /dev/null +++ b/lib/compat/wordpress-6.5/interactivity-api/interactivity-api.php @@ -0,0 +1,144 @@ +get_registered( $block_name ); + + if ( isset( $block_name ) && isset( $block_type->supports['interactivity'] ) && $block_type->supports['interactivity'] ) { + // Annotates the root interactive block for processing. + $root_interactive_block = array( $block_name, md5( serialize( $parsed_block ) ) ); + + /* + * Adds a filter to process the root interactive block once it has + * finished rendering. + */ + $process_interactive_blocks = static function ( $content, $parsed_block ) use ( &$root_interactive_block, &$process_interactive_blocks ) { + // Checks whether the current block is the root interactive block. + list($root_block_name, $root_block_md5) = $root_interactive_block; + if ( $root_block_name === $parsed_block['blockName'] && md5( serialize( $parsed_block ) ) === $root_block_md5 ) { + // The root interactive blocks has finished rendering, process it. + $content = wp_interactivity_process_directives( $content ); + // Removes the filter and reset the root interactive block. + remove_filter( 'render_block', $process_interactive_blocks ); + $root_interactive_block = null; + } + return $content; + }; + + /* + * Uses a priority of 20 to ensure that other filters can add additional + * directives before the processing starts. + */ + add_filter( 'render_block', $process_interactive_blocks, 20, 2 ); + } + } + + return $parsed_block; + } + add_filter( 'render_block_data', 'wp_interactivity_process_directives_of_interactive_blocks', 10, 1 ); +} + +if ( ! function_exists( 'wp_interactivity' ) ) { + /** + * Retrieves the main WP_Interactivity_API instance. + * + * It provides access to the WP_Interactivity_API instance, creating one if it + * doesn't exist yet. It also registers the hooks and necessary script + * modules. + * + * @since 6.5.0 + * + * @return WP_Interactivity_API The main WP_Interactivity_API instance. + */ + function wp_interactivity() { + static $instance = null; + if ( is_null( $instance ) ) { + $instance = new WP_Interactivity_API(); + $instance->add_hooks(); + $instance->register_script_modules(); + } + return $instance; + } +} + +if ( ! function_exists( 'wp_interactivity_process_directives' ) ) { + /** + * Processes the interactivity directives contained within the HTML content + * and updates the markup accordingly. + * + * @since 6.5.0 + * + * @param string $html The HTML content to process. + * @return string The processed HTML content. It returns the original content when the HTML contains unbalanced tags. + */ + function wp_interactivity_process_directives( $html ) { + return wp_interactivity()->process_directives( $html ); + } +} + +if ( ! function_exists( 'wp_interactivity_state' ) ) { + /** + * Gets and/or sets the initial state of an Interactivity API store for a + * given namespace. + * + * If state for that store namespace already exists, it merges the new + * provided state with the existing one. + * + * @since 6.5.0 + * + * @param string $store_namespace The unique store namespace identifier. + * @param array $state Optional. The array that will be merged with the existing state for the specified + * store namespace. + * @return array The current state for the specified store namespace. + */ + function wp_interactivity_state( $store_namespace, $state = null ) { + return wp_interactivity()->state( $store_namespace, $state ); + } +} + +if ( ! function_exists( 'wp_interactivity_config' ) ) { + /** + * Gets and/or sets the configuration of the Interactivity API for a given + * store namespace. + * + * If configuration for that store namespace exists, it merges the new + * provided configuration with the existing one. + * + * @since 6.5.0 + * + * @param string $store_namespace The unique store namespace identifier. + * @param array $config Optional. The array that will be merged with the existing configuration for the + * specified store namespace. + * @return array The current configuration for the specified store namespace. + */ + function wp_interactivity_config( $store_namespace, $initial_state = null ) { + return wp_interactivity()->config( $store_namespace, $initial_state ); + } +} diff --git a/lib/compat/wordpress-6.5/scripts-modules.php b/lib/compat/wordpress-6.5/scripts-modules.php new file mode 100644 index 00000000000000..ba329b255b1965 --- /dev/null +++ b/lib/compat/wordpress-6.5/scripts-modules.php @@ -0,0 +1,119 @@ +add_hooks(); + } + return $instance; + } +} + +if ( ! function_exists( 'wp_register_script_module' ) ) { + /** + * Registers the script module if no script module with that script module + * identifier has already been registered. + * + * @since 6.5.0 + * + * @param string $id The identifier of the script module. Should be unique. It will be used in the + * final import map. + * @param string $src Optional. Full URL of the script module, or path of the script module relative + * to the WordPress root directory. If it is provided and the script module has + * not been registered yet, it will be registered. + * @param array $deps { + * Optional. List of dependencies. + * + * @type string|array $0... { + * An array of script module identifiers of the dependencies of this script + * module. The dependencies can be strings or arrays. If they are arrays, + * they need an `id` key with the script module identifier, and can contain + * an `import` key with either `static` or `dynamic`. By default, + * dependencies that don't contain an `import` key are considered static. + * + * @type string $id The script module identifier. + * @type string $import Optional. Import type. May be either `static` or + * `dynamic`. Defaults to `static`. + * } + * } + * @param string|false|null $version Optional. String specifying the script module version number. Defaults to false. + * It is added to the URL as a query string for cache busting purposes. If $version + * is set to false, the version number is the currently installed WordPress version. + * If $version is set to null, no version is added. + */ + function wp_register_script_module( string $id, string $src, array $deps = array(), $version = false ) { + wp_script_modules()->register( $id, $src, $deps, $version ); + } +} + +if ( ! function_exists( 'wp_enqueue_script_module' ) ) { + /** + * Marks the script module to be enqueued in the page. + * + * If a src is provided and the script module has not been registered yet, it + * will be registered. + * + * @since 6.5.0 + * + * @param string $id The identifier of the script module. Should be unique. It will be used in the + * final import map. + * @param string $src Optional. Full URL of the script module, or path of the script module relative + * to the WordPress root directory. If it is provided and the script module has + * not been registered yet, it will be registered. + * @param array $deps { + * Optional. List of dependencies. + * + * @type string|array $0... { + * An array of script module identifiers of the dependencies of this script + * module. The dependencies can be strings or arrays. If they are arrays, + * they need an `id` key with the script module identifier, and can contain + * an `import` key with either `static` or `dynamic`. By default, + * dependencies that don't contain an `import` key are considered static. + * + * @type string $id The script module identifier. + * @type string $import Optional. Import type. May be either `static` or + * `dynamic`. Defaults to `static`. + * } + * } + * @param string|false|null $version Optional. String specifying the script module version number. Defaults to false. + * It is added to the URL as a query string for cache busting purposes. If $version + * is set to false, the version number is the currently installed WordPress version. + * If $version is set to null, no version is added. + */ + function wp_enqueue_script_module( string $id, string $src = '', array $deps = array(), $version = false ) { + wp_script_modules()->enqueue( $id, $src, $deps, $version ); + } +} + +if ( ! function_exists( 'wp_dequeue_script_module' ) ) { + /** + * Unmarks the script module so it is no longer enqueued in the page. + * + * @since 6.5.0 + * + * @param string $id The identifier of the script module. + */ + function wp_dequeue_script_module( string $id ) { + wp_script_modules()->dequeue( $id ); + } +} diff --git a/lib/experimental/block-bindings/index.php b/lib/experimental/block-bindings/index.php deleted file mode 100644 index dc9a6c9b96957b..00000000000000 --- a/lib/experimental/block-bindings/index.php +++ /dev/null @@ -1,19 +0,0 @@ -attributes, array( 'metadata', 'id' ), false ) ) { - return null; - } - $block_id = $block_instance->attributes['metadata']['id']; - return _wp_array_get( $block_instance->context, array( 'pattern/overrides', $block_id, $attribute_name ), null ); - }; - wp_block_bindings_register_source( - 'pattern_attributes', - __( 'Pattern Attributes', 'gutenberg' ), - $pattern_source_callback - ); -} diff --git a/lib/experimental/blocks.php b/lib/experimental/blocks.php index 93b65f95fc61ae..fc67f2c9d43770 100644 --- a/lib/experimental/blocks.php +++ b/lib/experimental/blocks.php @@ -77,92 +77,3 @@ function wp_enqueue_block_view_script( $block_name, $args ) { add_filter( 'render_block', $callback, 10, 2 ); } } - - - - -$gutenberg_experiments = get_option( 'gutenberg-experiments' ); -if ( $gutenberg_experiments && ( - array_key_exists( 'gutenberg-block-bindings', $gutenberg_experiments ) || - array_key_exists( 'gutenberg-pattern-partial-syncing', $gutenberg_experiments ) -) ) { - - require_once __DIR__ . '/block-bindings/index.php'; - - if ( ! function_exists( 'gutenberg_process_block_bindings' ) ) { - /** - * Process the block bindings attribute. - * - * @param string $block_content Block Content. - * @param array $block Block attributes. - * @param WP_Block $block_instance The block instance. - */ - function gutenberg_process_block_bindings( $block_content, $block, $block_instance ) { - - // Allowed blocks that support block bindings. - // TODO: Look for a mechanism to opt-in for this. Maybe adding a property to block attributes? - $allowed_blocks = array( - 'core/paragraph' => array( 'content' ), - 'core/heading' => array( 'content' ), - 'core/image' => array( 'url', 'title', 'alt' ), - 'core/button' => array( 'url', 'text' ), - ); - - // If the block doesn't have the bindings property or isn't one of the allowed block types, return. - if ( ! isset( $block['attrs']['metadata']['bindings'] ) || ! isset( $allowed_blocks[ $block_instance->name ] ) ) { - return $block_content; - } - - // Assuming the following format for the bindings property of the "metadata" attribute: - // - // "bindings": { - // "title": { - // "source": { - // "name": "post_meta", - // "attributes": { "value": "text_custom_field" } - // } - // }, - // "url": { - // "source": { - // "name": "post_meta", - // "attributes": { "value": "text_custom_field" } - // } - // } - // } - // - - $block_bindings_sources = wp_block_bindings_get_sources(); - $modified_block_content = $block_content; - foreach ( $block['attrs']['metadata']['bindings'] as $binding_attribute => $binding_source ) { - - // If the attribute is not in the list, process next attribute. - if ( ! in_array( $binding_attribute, $allowed_blocks[ $block_instance->name ], true ) ) { - continue; - } - // If no source is provided, or that source is not registered, process next attribute. - if ( ! isset( $binding_source['source'] ) || ! isset( $binding_source['source']['name'] ) || ! isset( $block_bindings_sources[ $binding_source['source']['name'] ] ) ) { - continue; - } - - $source_callback = $block_bindings_sources[ $binding_source['source']['name'] ]['apply']; - // Get the value based on the source. - if ( ! isset( $binding_source['source']['attributes'] ) ) { - $source_args = array(); - } else { - $source_args = $binding_source['source']['attributes']; - } - $source_value = $source_callback( $source_args, $block_instance, $binding_attribute ); - // If the value is null, process next attribute. - if ( is_null( $source_value ) ) { - continue; - } - - // Process the HTML based on the block and the attribute. - $modified_block_content = wp_block_bindings_replace_html( $modified_block_content, $block_instance->name, $binding_attribute, $source_value ); - } - return $modified_block_content; - } - } - - add_filter( 'render_block', 'gutenberg_process_block_bindings', 20, 3 ); -} diff --git a/lib/experimental/editor-settings.php b/lib/experimental/editor-settings.php index 729376cf030dd9..37774e07b27691 100644 --- a/lib/experimental/editor-settings.php +++ b/lib/experimental/editor-settings.php @@ -25,18 +25,9 @@ function gutenberg_enable_experiments() { if ( $gutenberg_experiments && array_key_exists( 'gutenberg-group-grid-variation', $gutenberg_experiments ) ) { wp_add_inline_script( 'wp-block-editor', 'window.__experimentalEnableGroupGridVariation = true', 'before' ); } - - if ( $gutenberg_experiments && array_key_exists( 'gutenberg-block-bindings', $gutenberg_experiments ) ) { - wp_add_inline_script( 'wp-block-editor', 'window.__experimentalBlockBindings = true', 'before' ); - } - if ( gutenberg_is_experiment_enabled( 'gutenberg-no-tinymce' ) ) { wp_add_inline_script( 'wp-block-library', 'window.__experimentalDisableTinymce = true', 'before' ); } - - if ( $gutenberg_experiments && array_key_exists( 'gutenberg-pattern-partial-syncing', $gutenberg_experiments ) ) { - wp_add_inline_script( 'wp-block-editor', 'window.__experimentalPatternPartialSyncing = true', 'before' ); - } } add_action( 'admin_init', 'gutenberg_enable_experiments' ); diff --git a/lib/experimental/fonts/font-library/class-wp-font-collection.php b/lib/experimental/fonts/font-library/class-wp-font-collection.php index 6189da5fa984b1..1ff96b1343b453 100644 --- a/lib/experimental/fonts/font-library/class-wp-font-collection.php +++ b/lib/experimental/fonts/font-library/class-wp-font-collection.php @@ -21,41 +21,128 @@ class WP_Font_Collection { /** - * Font collection configuration. + * The unique slug for the font collection. + * + * @since 6.5.0 + * + * @var string + */ + private $slug; + + /** + * The name of the font collection. + * + * @since 6.5.0 + * + * @var string + */ + private $name; + + /** + * Description of the font collection. + * + * @since 6.5.0 + * + * @var string + */ + private $description; + + /** + * Source of the font collection. + * + * @since 6.5.0 + * + * @var string + */ + private $src; + + /** + * Array of font families in the collection. * * @since 6.5.0 * * @var array */ - private $config; + private $font_families; + + /** + * Categories associated with the font collection. + * + * @since 6.5.0 + * + * @var array + */ + private $categories; + /** * WP_Font_Collection constructor. * * @since 6.5.0 * - * @param array $config Font collection config options. - * See {@see wp_register_font_collection()} for the supported fields. - * @throws Exception If the required parameters are missing. + * @param array $config Font collection config options. { + * @type string $slug The font collection's unique slug. + * @type string $name The font collection's name. + * @type string $description The font collection's description. + * @type string $src The font collection's source. + * @type array $font_families An array of font families in the font collection. + * @type array $categories The font collection's categories. + * } */ public function __construct( $config ) { - if ( empty( $config ) || ! is_array( $config ) ) { - throw new Exception( 'Font Collection config options is required as a non-empty array.' ); - } + $this->is_config_valid( $config ); + + $this->slug = isset( $config['slug'] ) ? $config['slug'] : ''; + $this->name = isset( $config['name'] ) ? $config['name'] : ''; + $this->description = isset( $config['description'] ) ? $config['description'] : ''; + $this->src = isset( $config['src'] ) ? $config['src'] : ''; + $this->font_families = isset( $config['font_families'] ) ? $config['font_families'] : array(); + $this->categories = isset( $config['categories'] ) ? $config['categories'] : array(); + } - if ( empty( $config['slug'] ) || ! is_string( $config['slug'] ) ) { - throw new Exception( 'Font Collection config slug is required as a non-empty string.' ); + /** + * Checks if the font collection config is valid. + * + * @since 6.5.0 + * + * @param array $config Font collection config options. { + * @type string $slug The font collection's unique slug. + * @type string $name The font collection's name. + * @type string $description The font collection's description. + * @type string $src The font collection's source. + * @type array $font_families An array of font families in the font collection. + * @type array $categories The font collection's categories. + * } + * @return bool True if the font collection config is valid and false otherwise. + */ + public static function is_config_valid( $config ) { + if ( empty( $config ) || ! is_array( $config ) ) { + _doing_it_wrong( __METHOD__, __( 'Font Collection config options are required as a non-empty array.', 'gutenberg' ), '6.5.0' ); + return false; } - if ( empty( $config['name'] ) || ! is_string( $config['name'] ) ) { - throw new Exception( 'Font Collection config name is required as a non-empty string.' ); + $required_keys = array( 'slug', 'name' ); + foreach ( $required_keys as $key ) { + if ( empty( $config[ $key ] ) ) { + _doing_it_wrong( + __METHOD__, + // translators: %s: Font collection config key. + sprintf( __( 'Font Collection config %s is required as a non-empty string.', 'gutenberg' ), $key ), + '6.5.0' + ); + return false; + } } - if ( ( empty( $config['src'] ) || ! is_string( $config['src'] ) ) && ( empty( $config['data'] ) ) ) { - throw new Exception( 'Font Collection config "src" option OR "data" option is required.' ); + if ( + ( empty( $config['src'] ) && empty( $config['font_families'] ) ) || + ( ! empty( $config['src'] ) && ! empty( $config['font_families'] ) ) + ) { + _doing_it_wrong( __METHOD__, __( 'Font Collection config "src" option OR "font_families" option are required.', 'gutenberg' ), '6.5.0' ); + return false; } - $this->config = $config; + return true; } /** @@ -73,56 +160,59 @@ public function __construct( $config ) { */ public function get_config() { return array( - 'slug' => $this->config['slug'], - 'name' => $this->config['name'], - 'description' => $this->config['description'] ?? '', + 'slug' => $this->slug, + 'name' => $this->name, + 'description' => $this->description, ); } /** - * Gets the font collection config and data. + * Gets the font collection content. * - * This function returns an array containing the font collection's unique ID, - * name, and its data as a PHP array. + * Load the font collection data from the src if it is not already loaded. * * @since 6.5.0 * - * @return array { - * An array of font collection config and data. + * @return array|WP_Error { + * An array of font collection contents. * - * @type string $slug The font collection's unique ID. - * @type string $name The font collection's name. - * @type string $description The font collection's description. - * @type array $data The font collection's data as a PHP array. + * @type array $font_families The font collection's font families. + * @type string $categories The font collection's categories. * } + * + * A WP_Error object if there was an error loading the font collection data. */ - public function get_config_and_data() { - $config_and_data = $this->get_config(); - $config_and_data['data'] = $this->load_data(); - return $config_and_data; + public function get_content() { + // If the font families are not loaded, and the src is not empty, load the data from the src. + if ( empty( $this->font_families ) && ! empty( $this->src ) ) { + $data = $this->load_contents_from_src(); + if ( is_wp_error( $data ) ) { + return $data; + } + } + + return array( + 'font_families' => $this->font_families, + 'categories' => $this->categories, + ); } /** - * Loads the font collection data. + * Loads the font collection data from the src. * * @since 6.5.0 * * @return array|WP_Error An array containing the list of font families in font-collection.json format on success, * else an instance of WP_Error on failure. */ - public function load_data() { - - if ( ! empty( $this->config['data'] ) ) { - return $this->config['data']; - } - + private function load_contents_from_src() { // If the src is a URL, fetch the data from the URL. - if ( str_contains( $this->config['src'], 'http' ) && str_contains( $this->config['src'], '://' ) ) { - if ( ! wp_http_validate_url( $this->config['src'] ) ) { + if ( preg_match( '#^https?://#', $this->src ) ) { + if ( ! wp_http_validate_url( $this->src ) ) { return new WP_Error( 'font_collection_read_error', __( 'Invalid URL for Font Collection data.', 'gutenberg' ) ); } - $response = wp_remote_get( $this->config['src'] ); + $response = wp_remote_get( $this->src ); if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) { return new WP_Error( 'font_collection_read_error', __( 'Error fetching the Font Collection data from a URL.', 'gutenberg' ) ); } @@ -133,15 +223,22 @@ public function load_data() { } // If the src is a file path, read the data from the file. } else { - if ( ! file_exists( $this->config['src'] ) ) { + if ( ! file_exists( $this->src ) ) { return new WP_Error( 'font_collection_read_error', __( 'Font Collection data JSON file does not exist.', 'gutenberg' ) ); } - $data = wp_json_file_decode( $this->config['src'], array( 'associative' => true ) ); + $data = wp_json_file_decode( $this->src, array( 'associative' => true ) ); if ( empty( $data ) ) { return new WP_Error( 'font_collection_read_error', __( 'Error reading the Font Collection data JSON file contents.', 'gutenberg' ) ); } } + if ( empty( $data['font_families'] ) ) { + return new WP_Error( 'font_collection_contents_error', __( 'Font Collection data JSON file does not contain font families.', 'gutenberg' ) ); + } + + $this->font_families = $data['font_families']; + $this->categories = $data['categories'] ?? array(); + return $data; } } diff --git a/lib/experimental/fonts/font-library/class-wp-font-family-utils.php b/lib/experimental/fonts/font-library/class-wp-font-family-utils.php index 35e6856e50aad8..b291a8f1ee348d 100644 --- a/lib/experimental/fonts/font-library/class-wp-font-family-utils.php +++ b/lib/experimental/fonts/font-library/class-wp-font-family-utils.php @@ -91,7 +91,7 @@ public static function format_font_family( $font_family ) { function ( $family ) { $trimmed = trim( $family ); if ( ! empty( $trimmed ) && strpos( $trimmed, ' ' ) !== false && strpos( $trimmed, "'" ) === false && strpos( $trimmed, '"' ) === false ) { - return "'" . $trimmed . "'"; + return '"' . $trimmed . '"'; } return $trimmed; }, @@ -107,4 +107,84 @@ function ( $family ) { return $font_family; } + + /** + * Generates a slug from font face properties, e.g. `open sans;normal;400;100%;U+0-10FFFF` + * + * Used for comparison with other font faces in the same family, to prevent duplicates + * that would both match according the CSS font matching spec. Uses only simple case-insensitive + * matching for fontFamily and unicodeRange, so does not handle overlapping font-family lists or + * unicode ranges. + * + * @since 6.5.0 + * + * @link https://drafts.csswg.org/css-fonts/#font-style-matching + * + * @param array $settings { + * Font face settings. + * + * @type string $fontFamily Font family name. + * @type string $fontStyle Optional font style, defaults to 'normal'. + * @type string $fontWeight Optional font weight, defaults to 400. + * @type string $fontStretch Optional font stretch, defaults to '100%'. + * @type string $unicodeRange Optional unicode range, defaults to 'U+0-10FFFF'. + * } + * @return string Font face slug. + */ + public static function get_font_face_slug( $settings ) { + $settings = wp_parse_args( + $settings, + array( + 'fontFamily' => '', + 'fontStyle' => 'normal', + 'fontWeight' => '400', + 'fontStretch' => '100%', + 'unicodeRange' => 'U+0-10FFFF', + ) + ); + + // Convert all values to lowercase for comparison. + // Font family names may use multibyte characters. + $font_family = mb_strtolower( $settings['fontFamily'] ); + $font_style = strtolower( $settings['fontStyle'] ); + $font_weight = strtolower( $settings['fontWeight'] ); + $font_stretch = strtolower( $settings['fontStretch'] ); + $unicode_range = strtoupper( $settings['unicodeRange'] ); + + // Convert weight keywords to numeric strings. + $font_weight = str_replace( 'normal', '400', $font_weight ); + $font_weight = str_replace( 'bold', '700', $font_weight ); + + // Convert stretch keywords to numeric strings. + $font_stretch_map = array( + 'ultra-condensed' => '50%', + 'extra-condensed' => '62.5%', + 'condensed' => '75%', + 'semi-condensed' => '87.5%', + 'normal' => '100%', + 'semi-expanded' => '112.5%', + 'expanded' => '125%', + 'extra-expanded' => '150%', + 'ultra-expanded' => '200%', + ); + $font_stretch = str_replace( array_keys( $font_stretch_map ), array_values( $font_stretch_map ), $font_stretch ); + + $slug_elements = array( $font_family, $font_style, $font_weight, $font_stretch, $unicode_range ); + + $slug_elements = array_map( + function ( $elem ) { + // Remove quotes to normalize font-family names, and ';' to use as a separator. + $elem = trim( str_replace( array( '"', "'", ';' ), '', $elem ) ); + + // Normalize comma separated lists by removing whitespace in between items, + // but keep whitespace within items (e.g. "Open Sans" and "OpenSans" are different fonts). + // CSS spec for whitespace includes: U+000A LINE FEED, U+0009 CHARACTER TABULATION, or U+0020 SPACE, + // which by default are all matched by \s in PHP. + return preg_replace( '/,\s+/', ',', $elem ); + }, + $slug_elements + ); + + return join( ';', $slug_elements ); + } } diff --git a/lib/experimental/fonts/font-library/class-wp-font-family.php b/lib/experimental/fonts/font-library/class-wp-font-family.php deleted file mode 100644 index f64aebc0c8efa7..00000000000000 --- a/lib/experimental/fonts/font-library/class-wp-font-family.php +++ /dev/null @@ -1,621 +0,0 @@ -data = $font_data; - } - - /** - * Gets the font family data. - * - * @since 6.5.0 - * - * @return array An array in fontFamily theme.json format. - */ - public function get_data() { - return $this->data; - } - - /** - * Gets the font family data. - * - * @since 6.5.0 - * - * @return string fontFamily in theme.json format as stringified JSON. - */ - public function get_data_as_json() { - return wp_json_encode( $this->get_data() ); - } - - /** - * Checks whether the font family has font faces defined. - * - * @since 6.5.0 - * - * @return bool True if the font family has font faces defined, false otherwise. - */ - public function has_font_faces() { - return ! empty( $this->data['fontFace'] ) && is_array( $this->data['fontFace'] ); - } - - /** - * Removes font family assets. - * - * @since 6.5.0 - * - * @return bool True if assets were removed, false otherwise. - */ - private function remove_font_family_assets() { - if ( $this->has_font_faces() ) { - foreach ( $this->data['fontFace'] as $font_face ) { - $were_assets_removed = $this->delete_font_face_assets( $font_face ); - if ( false === $were_assets_removed ) { - return false; - } - } - } - return true; - } - - /** - * Removes a font family from the database and deletes its assets. - * - * @since 6.5.0 - * - * @return bool|WP_Error True if the font family was uninstalled, WP_Error otherwise. - */ - public function uninstall() { - $post = $this->get_data_from_post(); - if ( null === $post ) { - return new WP_Error( - 'font_family_not_found', - __( 'The font family could not be found.', 'gutenberg' ) - ); - } - - if ( - ! $this->remove_font_family_assets() || - ! wp_delete_post( $post->ID, true ) - ) { - return new WP_Error( - 'font_family_not_deleted', - __( 'The font family could not be deleted.', 'gutenberg' ) - ); - } - - return true; - } - - /** - * Deletes a specified font asset file from the fonts directory. - * - * @since 6.5.0 - * - * @param string $src The path of the font asset file to delete. - * @return bool Whether the file was deleted. - */ - private static function delete_asset( $src ) { - $filename = basename( $src ); - $file_path = path_join( wp_get_font_dir()['path'], $filename ); - - wp_delete_file( $file_path ); - - return ! file_exists( $file_path ); - } - - /** - * Deletes all font face asset files associated with a given font face. - * - * @since 6.5.0 - * - * @param array $font_face The font face array containing the 'src' attribute - * with the file path(s) to be deleted. - * @return bool True if delete was successful, otherwise false. - */ - private static function delete_font_face_assets( $font_face ) { - $sources = (array) $font_face['src']; - foreach ( $sources as $src ) { - $was_asset_removed = self::delete_asset( $src ); - if ( ! $was_asset_removed ) { - // Bail if any of the assets could not be removed. - return false; - } - } - return true; - } - - /** - * Gets the overrides for the 'wp_handle_upload' function. - * - * @since 6.5.0 - * - * @param string $filename The filename to be used for the uploaded file. - * @return array The overrides for the 'wp_handle_upload' function. - */ - private function get_upload_overrides( $filename ) { - return array( - // Arbitrary string to avoid the is_uploaded_file() check applied - // when using 'wp_handle_upload'. - 'action' => 'wp_handle_font_upload', - // Not testing a form submission. - 'test_form' => false, - // Seems mime type for files that are not images cannot be tested. - // See wp_check_filetype_and_ext(). - 'test_type' => true, - 'mimes' => WP_Font_Library::get_expected_font_mime_types_per_php_version(), - 'unique_filename_callback' => static function () use ( $filename ) { - // Keep the original filename. - return $filename; - }, - ); - } - - /** - * Downloads a font asset from a specified source URL and saves it to - * the font directory. - * - * @since 6.5.0 - * - * @param string $url The source URL of the font asset to be downloaded. - * @param string $filename The filename to save the downloaded font asset as. - * @return string|bool The relative path to the downloaded font asset. - * False if the download failed. - */ - private function download_asset( $url, $filename ) { - // Include file with download_url() if function doesn't exist. - if ( ! function_exists( 'download_url' ) ) { - require_once ABSPATH . 'wp-admin/includes/file.php'; - } - - // Downloads the font asset or returns false. - $temp_file = download_url( $url ); - if ( is_wp_error( $temp_file ) ) { - return false; - } - - $overrides = $this->get_upload_overrides( $filename ); - - $file = array( - 'tmp_name' => $temp_file, - 'name' => $filename, - ); - - $handled_file = wp_handle_upload( $file, $overrides ); - - // Cleans the temp file. - @unlink( $temp_file ); - - if ( ! isset( $handled_file['url'] ) ) { - return false; - } - - // Returns the relative path to the downloaded font asset to be used as - // font face src. - return $handled_file['url']; - } - - /** - * Moves an uploaded font face asset from temp folder to the fonts directory. - * - * This is used when uploading local fonts. - * - * @since 6.5.0 - * - * @param array $font_face Font face to download. - * @param array $file Uploaded file. - * @return array New font face with all assets downloaded and referenced in - * the font face definition. - */ - private function move_font_face_asset( $font_face, $file ) { - $new_font_face = $font_face; - $filename = WP_Font_Family_Utils::get_filename_from_font_face( - $this->data['slug'], - $font_face, - $file['name'] - ); - - // Remove the uploaded font asset reference from the font face definition - // because it is no longer needed. - unset( $new_font_face['uploadedFile'] ); - - // Move the uploaded font asset from the temp folder to the fonts directory. - if ( ! function_exists( 'wp_handle_upload' ) ) { - require_once ABSPATH . 'wp-admin/includes/file.php'; - } - - $overrides = $this->get_upload_overrides( $filename ); - - $handled_file = wp_handle_upload( $file, $overrides ); - - if ( isset( $handled_file['url'] ) ) { - // If the file was successfully moved, update the font face definition - // to reference the new file location. - $new_font_face['src'] = $handled_file['url']; - } - - return $new_font_face; - } - - /** - * Sanitizes the font family data using WP_Theme_JSON. - * - * @since 6.5.0 - * - * @return array A sanitized font family definition. - */ - private function sanitize() { - // Creates the structure of theme.json array with the new fonts. - $fonts_json = array( - 'version' => '2', - 'settings' => array( - 'typography' => array( - 'fontFamilies' => array( - 'custom' => array( - $this->data, - ), - ), - ), - ), - ); - - // Creates a new WP_Theme_JSON object with the new fonts to - // leverage sanitization and validation. - $fonts_json = WP_Theme_JSON_Gutenberg::remove_insecure_properties( $fonts_json ); - $theme_json = new WP_Theme_JSON_Gutenberg( $fonts_json ); - $theme_data = $theme_json->get_data(); - $sanitized_font = ! empty( $theme_data['settings']['typography']['fontFamilies'] ) - ? $theme_data['settings']['typography']['fontFamilies'][0] - : array(); - - $sanitized_font['slug'] = _wp_to_kebab_case( $sanitized_font['slug'] ); - $sanitized_font['fontFamily'] = WP_Font_Family_Utils::format_font_family( $sanitized_font['fontFamily'] ); - $this->data = $sanitized_font; - return $this->data; - } - - /** - * Downloads font face assets. - * - * Downloads the font face asset(s) associated with a font face. It works with - * both single source URLs and arrays of multiple source URLs. - * - * @since 6.5.0 - * - * @param array $font_face The font face array containing the 'src' attribute - * with the source URL(s) of the assets. - * @return array The modified font face array with the new source URL(s) to - * the downloaded assets. - */ - private function download_font_face_assets( $font_face ) { - $new_font_face = $font_face; - $sources = (array) $font_face['downloadFromUrl']; - $new_font_face['src'] = array(); - $index = 0; - - foreach ( $sources as $src ) { - $suffix = $index++ > 0 ? $index : ''; - $filename = WP_Font_Family_Utils::get_filename_from_font_face( - $this->data['slug'], - $font_face, - $src, - $suffix - ); - $new_src = $this->download_asset( $src, $filename ); - if ( $new_src ) { - $new_font_face['src'][] = $new_src; - } - } - - if ( count( $new_font_face['src'] ) === 1 ) { - $new_font_face['src'] = $new_font_face['src'][0]; - } - - // Remove the download url reference from the font face definition - // because it is no longer needed. - unset( $new_font_face['downloadFromUrl'] ); - - return $new_font_face; - } - - - /** - * Downloads font face assets if the font family is a Google font, - * or moves them if it is a local font. - * - * @since 6.5.0 - * - * @param array $files An array of files to be installed. - * @return bool True if the font faces were downloaded or moved successfully, false otherwise. - */ - private function download_or_move_font_faces( $files ) { - if ( ! $this->has_font_faces() ) { - return true; - } - - $new_font_faces = array(); - foreach ( $this->data['fontFace'] as $font_face ) { - // If the fonts are not meant to be downloaded or uploaded - // (for example to install fonts that use a remote url). - $new_font_face = $font_face; - - $font_face_is_repeated = false; - - // If the font face has the same fontStyle and fontWeight as an existing, continue. - foreach ( $new_font_faces as $font_to_compare ) { - if ( $new_font_face['fontStyle'] === $font_to_compare['fontStyle'] && - $new_font_face['fontWeight'] === $font_to_compare['fontWeight'] ) { - $font_face_is_repeated = true; - } - } - - if ( $font_face_is_repeated ) { - continue; - } - - // If the font face requires the use of the filesystem, create the fonts dir if it doesn't exist. - if ( ! empty( $font_face['downloadFromUrl'] ) && ! empty( $font_face['uploadedFile'] ) ) { - wp_mkdir_p( wp_get_font_dir()['path'] ); - } - - // If installing google fonts, download the font face assets. - if ( ! empty( $font_face['downloadFromUrl'] ) ) { - $new_font_face = $this->download_font_face_assets( $new_font_face ); - } - - // If installing local fonts, move the font face assets from - // the temp folder to the wp fonts directory. - if ( ! empty( $font_face['uploadedFile'] ) && ! empty( $files ) ) { - $new_font_face = $this->move_font_face_asset( - $new_font_face, - $files[ $new_font_face['uploadedFile'] ] - ); - } - - /* - * If the font face assets were successfully downloaded, add the font face - * to the new font. Font faces with failed downloads are not added to the - * new font. - */ - if ( ! empty( $new_font_face['src'] ) ) { - $new_font_faces[] = $new_font_face; - } - } - - if ( ! empty( $new_font_faces ) ) { - $this->data['fontFace'] = $new_font_faces; - return true; - } - - return false; - } - - /** - * Gets the post for a font family. - * - * @since 6.5.0 - * - * @return WP_Post|null The post for this font family object or - * null if the post does not exist. - */ - public function get_font_post() { - $args = array( - 'post_type' => 'wp_font_family', - 'post_name' => $this->data['slug'], - 'name' => $this->data['slug'], - 'posts_per_page' => 1, - ); - - $posts_query = new WP_Query( $args ); - - if ( $posts_query->have_posts() ) { - return $posts_query->posts[0]; - } - - return null; - } - - /** - * Gets the data for this object from the database and - * sets it to the data property. - * - * @since 6.5.0 - * - * @return WP_Post|null The post for this font family object or - * null if the post does not exist. - */ - private function get_data_from_post() { - $post = $this->get_font_post(); - if ( $post ) { - $this->data = json_decode( $post->post_content, true ); - return $post; - } - - return null; - } - - /** - * Creates a post for a font family. - * - * @since 6.5.0 - * - * @return int|WP_Error Post ID if the post was created, WP_Error otherwise. - */ - private function create_font_post() { - $post = array( - 'post_title' => $this->data['name'], - 'post_name' => $this->data['slug'], - 'post_type' => 'wp_font_family', - 'post_content' => $this->get_data_as_json(), - 'post_status' => 'publish', - ); - - $post_id = wp_insert_post( $post ); - if ( 0 === $post_id || is_wp_error( $post_id ) ) { - return new WP_Error( - 'font_post_creation_failed', - __( 'Font post creation failed', 'gutenberg' ) - ); - } - - return $post_id; - } - - /** - * Gets the font faces that are in both the existing and incoming font families. - * - * @since 6.5.0 - * - * @param array $existing The existing font faces. - * @param array $incoming The incoming font faces. - * @return array The font faces that are in both the existing and incoming font families. - */ - private function get_intersecting_font_faces( $existing, $incoming ) { - $intersecting = array(); - foreach ( $existing as $existing_face ) { - foreach ( $incoming as $incoming_face ) { - if ( $incoming_face['fontStyle'] === $existing_face['fontStyle'] && - $incoming_face['fontWeight'] === $existing_face['fontWeight'] && - $incoming_face['src'] !== $existing_face['src'] ) { - $intersecting[] = $existing_face; - } - } - } - return $intersecting; - } - - /** - * Updates a post for a font family. - * - * @since 6.5.0 - * - * @param WP_Post $post The post to update. - * @return int|WP_Error Post ID if the update was successful, WP_Error otherwise. - */ - private function update_font_post( $post ) { - $post_font_data = json_decode( $post->post_content, true ); - $new_data = WP_Font_Family_Utils::merge_fonts_data( $post_font_data, $this->data ); - if ( isset( $post_font_data['fontFace'] ) && ! empty( $post_font_data['fontFace'] ) ) { - $intersecting = $this->get_intersecting_font_faces( $post_font_data['fontFace'], $new_data['fontFace'] ); - } - - if ( isset( $intersecting ) && ! empty( $intersecting ) ) { - $serialized_font_faces = array_map( 'serialize', $new_data['fontFace'] ); - $serialized_intersecting = array_map( 'serialize', $intersecting ); - - $diff = array_diff( $serialized_font_faces, $serialized_intersecting ); - - $new_data['fontFace'] = array_values( array_map( 'unserialize', $diff ) ); - - foreach ( $intersecting as $intersect ) { - $this->delete_font_face_assets( $intersect ); - } - } - $this->data = $new_data; - - $post = array( - 'ID' => $post->ID, - 'post_content' => $this->get_data_as_json(), - ); - - $post_id = wp_update_post( $post ); - - if ( 0 === $post_id || is_wp_error( $post_id ) ) { - return new WP_Error( - 'font_post_update_failed', - __( 'Font post update failed', 'gutenberg' ) - ); - } - - return $post_id; - } - - /** - * Creates a post for a font in the Font Library if it doesn't exist, - * or updates it if it does. - * - * @since 6.5.0 - * - * @return int|WP_Error Post id if the post was created or updated successfully, - * WP_Error otherwise. - */ - private function create_or_update_font_post() { - $this->sanitize(); - - $post = $this->get_font_post(); - if ( $post ) { - return $this->update_font_post( $post ); - } - - return $this->create_font_post(); - } - - /** - * Installs the font family into the library. - * - * @since 6.5.0 - * - * @param array $files Optional. An array of files to be installed. Default null. - * @return array|WP_Error An array of font family data on success, WP_Error otherwise. - */ - public function install( $files = null ) { - add_filter( 'upload_mimes', array( 'WP_Font_Library', 'set_allowed_mime_types' ) ); - add_filter( 'upload_dir', 'wp_get_font_dir' ); - $were_assets_written = $this->download_or_move_font_faces( $files ); - remove_filter( 'upload_dir', 'wp_get_font_dir' ); - remove_filter( 'upload_mimes', array( 'WP_Font_Library', 'set_allowed_mime_types' ) ); - - if ( ! $were_assets_written ) { - return new WP_Error( - 'font_face_download_failed', - __( 'The font face assets could not be written.', 'gutenberg' ) - ); - } - - $post_id = $this->create_or_update_font_post(); - - if ( is_wp_error( $post_id ) ) { - return $post_id; - } - - return $this->get_data(); - } -} diff --git a/lib/experimental/fonts/font-library/class-wp-font-library.php b/lib/experimental/fonts/font-library/class-wp-font-library.php index fd36f6ba073c4f..51a84b957ea117 100644 --- a/lib/experimental/fonts/font-library/class-wp-font-library.php +++ b/lib/experimental/fonts/font-library/class-wp-font-library.php @@ -62,11 +62,17 @@ public static function get_expected_font_mime_types_per_php_version( $php_versio * @return WP_Font_Collection|WP_Error A font collection is it was registered successfully and a WP_Error otherwise. */ public static function register_font_collection( $config ) { + if ( ! WP_Font_Collection::is_config_valid( $config ) ) { + $error_message = __( 'Font collection config is invalid.', 'gutenberg' ); + return new WP_Error( 'font_collection_registration_error', $error_message ); + } + $new_collection = new WP_Font_Collection( $config ); - if ( self::is_collection_registered( $config['slug'] ) ) { + + if ( self::is_collection_registered( $new_collection->get_config()['slug'] ) ) { $error_message = sprintf( /* translators: %s: Font collection slug. */ - __( 'Font collection with slug: "%s" is already registered.', 'default' ), + __( 'Font collection with slug: "%s" is already registered.', 'gutenberg' ), $config['slug'] ); _doing_it_wrong( @@ -76,7 +82,7 @@ public static function register_font_collection( $config ) { ); return new WP_Error( 'font_collection_registration_error', $error_message ); } - self::$collections[ $config['slug'] ] = $new_collection; + self::$collections[ $new_collection->get_config()['slug'] ] = $new_collection; return $new_collection; } diff --git a/lib/experimental/fonts/font-library/class-wp-rest-autosave-font-families-controller.php b/lib/experimental/fonts/font-library/class-wp-rest-autosave-font-families-controller.php deleted file mode 100644 index 0e31bd4004b40f..00000000000000 --- a/lib/experimental/fonts/font-library/class-wp-rest-autosave-font-families-controller.php +++ /dev/null @@ -1,25 +0,0 @@ - WP_REST_Server::READABLE, - 'callback' => array( $this, 'get_font_collections' ), - 'permission_callback' => array( $this, 'update_font_library_permissions_check' ), + 'callback' => array( $this, 'get_items' ), + 'permission_callback' => array( $this, 'get_items_permissions_check' ), ), ) ); @@ -54,13 +54,29 @@ public function register_routes() { array( array( 'methods' => WP_REST_Server::READABLE, - 'callback' => array( $this, 'get_font_collection' ), - 'permission_callback' => array( $this, 'update_font_library_permissions_check' ), + 'callback' => array( $this, 'get_item' ), + 'permission_callback' => array( $this, 'get_items_permissions_check' ), ), ) ); } + /** + * Gets the font collections available. + * + * @since 6.5.0 + * + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. + */ + public function get_items( $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable + $collections = array(); + foreach ( WP_Font_Library::get_font_collections() as $collection ) { + $collections[] = $collection->get_config(); + } + + return rest_ensure_response( $collections, 200 ); + } + /** * Gets a font collection. * @@ -69,54 +85,42 @@ public function register_routes() { * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. */ - public function get_font_collection( $request ) { + public function get_item( $request ) { $slug = $request->get_param( 'slug' ); $collection = WP_Font_Library::get_font_collection( $slug ); + // If the collection doesn't exist returns a 404. if ( is_wp_error( $collection ) ) { $collection->add_data( array( 'status' => 404 ) ); return $collection; } - $config_and_data = $collection->get_config_and_data(); - $collection_data = $config_and_data['data']; - // If there was an error getting the collection data, return the error. - if ( is_wp_error( $collection_data ) ) { - $collection_data->add_data( array( 'status' => 500 ) ); - return $collection_data; - } - - return new WP_REST_Response( $config_and_data ); - } + $config = $collection->get_config(); + $contents = $collection->get_content(); - /** - * Gets the font collections available. - * - * @since 6.5.0 - * - * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. - */ - public function get_font_collections() { - $collections = array(); - foreach ( WP_Font_Library::get_font_collections() as $collection ) { - $collections[] = $collection->get_config_and_data(); + // If there was an error getting the collection data, return the error. + if ( is_wp_error( $contents ) ) { + $contents->add_data( array( 'status' => 500 ) ); + return $contents; } - return new WP_REST_Response( $collections, 200 ); + $collection_data = array_merge( $config, $contents ); + return rest_ensure_response( $collection_data ); } /** - * Checks whether the user has permissions to update the Font Library. + * Checks whether the user has permissions to use the Fonts Collections. * * @since 6.5.0 * * @return true|WP_Error True if the request has write access for the item, WP_Error object otherwise. */ - public function update_font_library_permissions_check() { + public function get_items_permissions_check( $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable + if ( ! current_user_can( 'edit_theme_options' ) ) { return new WP_Error( - 'rest_cannot_update_font_library', - __( 'Sorry, you are not allowed to update the Font Library on this site.', 'gutenberg' ), + 'rest_cannot_read', + __( 'Sorry, you are not allowed to use the Font Library on this site.', 'gutenberg' ), array( 'status' => rest_authorization_required_code(), ) diff --git a/lib/experimental/fonts/font-library/class-wp-rest-font-faces-controller.php b/lib/experimental/fonts/font-library/class-wp-rest-font-faces-controller.php new file mode 100644 index 00000000000000..fac32362325f49 --- /dev/null +++ b/lib/experimental/fonts/font-library/class-wp-rest-font-faces-controller.php @@ -0,0 +1,836 @@ +namespace, + '/' . $this->rest_base, + array( + 'args' => array( + 'font_family_id' => array( + 'description' => __( 'The ID for the parent font family of the font face.', 'gutenberg' ), + 'type' => 'integer', + 'required' => true, + ), + ), + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_items' ), + 'permission_callback' => array( $this, 'get_items_permissions_check' ), + 'args' => $this->get_collection_params(), + ), + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'create_item' ), + 'permission_callback' => array( $this, 'create_item_permissions_check' ), + 'args' => $this->get_create_params(), + ), + 'schema' => array( $this, 'get_public_item_schema' ), + ) + ); + + register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/(?P[\d]+)', + array( + 'args' => array( + 'font_family_id' => array( + 'description' => __( 'The ID for the parent font family of the font face.', 'gutenberg' ), + 'type' => 'integer', + 'required' => true, + ), + 'id' => array( + 'description' => __( 'Unique identifier for the font face.', 'gutenberg' ), + 'type' => 'integer', + 'required' => true, + ), + ), + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_item' ), + 'permission_callback' => array( $this, 'get_item_permissions_check' ), + 'args' => array( + 'context' => $this->get_context_param( array( 'default' => 'edit' ) ), + ), + ), + array( + 'methods' => WP_REST_Server::DELETABLE, + 'callback' => array( $this, 'delete_item' ), + 'permission_callback' => array( $this, 'delete_item_permissions_check' ), + 'args' => array( + 'force' => array( + 'type' => 'boolean', + 'default' => false, + 'description' => __( 'Whether to bypass Trash and force deletion.', 'default' ), + ), + ), + ), + 'schema' => array( $this, 'get_public_item_schema' ), + ) + ); + } + + /** + * Checks if a given request has access to font faces. + * + * @since 6.5.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return true|WP_Error True if the request has read access, WP_Error object otherwise. + */ + public function get_items_permissions_check( $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- required by parent class + $post_type = get_post_type_object( $this->post_type ); + + if ( ! current_user_can( $post_type->cap->read ) ) { + return new WP_Error( + 'rest_cannot_read', + __( 'Sorry, you are not allowed to access font faces.', 'gutenberg' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + return true; + } + + /** + * Checks if a given request has access to a font face. + * + * @since 6.5.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return true|WP_Error True if the request has read access, WP_Error object otherwise. + */ + public function get_item_permissions_check( $request ) { + return $this->get_items_permissions_check( $request ); + } + + /** + * Validates settings when creating a font face. + * + * @since 6.5.0 + * + * @param string $value Encoded JSON string of font face settings. + * @param WP_REST_Request $request Request object. + * @return false|WP_Error True if the settings are valid, otherwise a WP_Error object. + */ + public function validate_create_font_face_settings( $value, $request ) { + $settings = json_decode( $value, true ); + + // Check settings string is valid JSON. + if ( null === $settings ) { + return new WP_Error( + 'rest_invalid_param', + __( 'font_face_settings parameter must be a valid JSON string.', 'gutenberg' ), + array( 'status' => 400 ) + ); + } + + // Check that the font face settings match the theme.json schema. + $schema = $this->get_item_schema()['properties']['font_face_settings']; + $has_valid_settings = rest_validate_value_from_schema( $settings, $schema, 'font_face_settings' ); + + if ( is_wp_error( $has_valid_settings ) ) { + $has_valid_settings->add_data( array( 'status' => 400 ) ); + return $has_valid_settings; + } + + // Check that none of the required settings are empty values. + $required = $schema['required']; + foreach ( $required as $key ) { + if ( isset( $settings[ $key ] ) && ! $settings[ $key ] ) { + return new WP_Error( + 'rest_invalid_param', + /* translators: %s: Font family setting key. */ + sprintf( __( 'font_face_setting[%s] cannot be empty.', 'gutenberg' ), $key ), + array( 'status' => 400 ) + ); + } + } + + $srcs = is_array( $settings['src'] ) ? $settings['src'] : array( $settings['src'] ); + + // Check that srcs are non-empty strings. + $filtered_src = array_filter( array_filter( $srcs, 'is_string' ) ); + if ( empty( $filtered_src ) ) { + return new WP_Error( + 'rest_invalid_param', + __( 'font_face_settings[src] values must be non-empty strings.', 'gutenberg' ), + array( 'status' => 400 ) + ); + } + + // Check that each file in the request references a src in the settings. + $files = $request->get_file_params(); + foreach ( array_keys( $files ) as $file ) { + if ( ! in_array( $file, $srcs, true ) ) { + return new WP_Error( + 'rest_invalid_param', + // translators: %s: File key (e.g. `file-0`) in the request data. + sprintf( __( 'File %1$s must be used in font_face_settings[src].', 'gutenberg' ), $file ), + array( 'status' => 400 ) + ); + } + } + + return true; + } + + /** + * Sanitizes the font face settings when creating a font face. + * + * @since 6.5.0 + * + * @param string $value Encoded JSON string of font face settings. + * @param WP_REST_Request $request Request object. + * @return array Decoded array of font face settings. + */ + public function sanitize_font_face_settings( $value ) { + // Settings arrive as stringified JSON, since this is a multipart/form-data request. + $settings = json_decode( $value, true ); + + if ( isset( $settings['fontFamily'] ) ) { + $settings['fontFamily'] = WP_Font_Family_Utils::format_font_family( $settings['fontFamily'] ); + } + + return $settings; + } + + /** + * Retrieves a collection of font faces within the parent font family. + * + * @since 6.5.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. + */ + public function get_items( $request ) { + $font_family = $this->get_parent_font_family_post( $request['font_family_id'] ); + if ( is_wp_error( $font_family ) ) { + return $font_family; + } + + return parent::get_items( $request ); + } + + /** + * Retrieves a single font face within the parent font family. + * + * @since 6.5.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. + */ + public function get_item( $request ) { + $post = $this->get_post( $request['id'] ); + if ( is_wp_error( $post ) ) { + return $post; + } + + // Check that the font face has a valid parent font family. + $font_family = $this->get_parent_font_family_post( $request['font_family_id'] ); + if ( is_wp_error( $font_family ) ) { + return $font_family; + } + + if ( (int) $font_family->ID !== (int) $post->post_parent ) { + return new WP_Error( + 'rest_font_face_parent_id_mismatch', + /* translators: %d: A post id. */ + sprintf( __( 'The font face does not belong to the specified font family with id of "%d"', 'gutenberg' ), $font_family->ID ), + array( 'status' => 404 ) + ); + } + + return parent::get_item( $request ); + } + + /** + * Creates a font face for the parent font family. + * + * @since 6.5.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. + */ + public function create_item( $request ) { + $font_family = $this->get_parent_font_family_post( $request['font_family_id'] ); + if ( is_wp_error( $font_family ) ) { + return $font_family; + } + + // Settings have already been decoded by ::sanitize_font_face_settings(). + $settings = $request->get_param( 'font_face_settings' ); + $file_params = $request->get_file_params(); + + // Check that the necessary font face properties are unique. + $query = new WP_Query( + array( + 'post_type' => $this->post_type, + 'posts_per_page' => 1, + 'title' => WP_Font_Family_Utils::get_font_face_slug( $settings ), + 'update_post_meta_cache' => false, + 'update_post_term_cache' => false, + ) + ); + if ( ! empty( $query->get_posts() ) ) { + return new WP_Error( + 'rest_duplicate_font_face', + __( 'A font face matching those settings already exists.', 'gutenberg' ), + array( 'status' => 400 ) + ); + } + + // Move the uploaded font asset from the temp folder to the fonts directory. + if ( ! function_exists( 'wp_handle_upload' ) ) { + require_once ABSPATH . 'wp-admin/includes/file.php'; + } + + $srcs = is_string( $settings['src'] ) ? array( $settings['src'] ) : $settings['src']; + $processed_srcs = array(); + $font_file_meta = array(); + + foreach ( $srcs as $src ) { + // If src not a file reference, use it as is. + if ( ! isset( $file_params[ $src ] ) ) { + $processed_srcs[] = $src; + continue; + } + + $file = $file_params[ $src ]; + $font_file = $this->handle_font_file_upload( $file ); + if ( is_wp_error( $font_file ) ) { + return $font_file; + } + + $processed_srcs[] = $font_file['url']; + $font_file_meta[] = $this->relative_fonts_path( $font_file['file'] ); + } + + // Store the updated settings for prepare_item_for_database to use. + $settings['src'] = count( $processed_srcs ) === 1 ? $processed_srcs[0] : $processed_srcs; + $request->set_param( 'font_face_settings', $settings ); + + // Ensure that $settings data is slashed, so values with quotes are escaped. + // WP_REST_Posts_Controller::create_item uses wp_slash() on the post_content. + $font_face_post = parent::create_item( $request ); + + if ( is_wp_error( $font_face_post ) ) { + return $font_face_post; + } + + $font_face_id = $font_face_post->data['id']; + + foreach ( $font_file_meta as $font_file_path ) { + add_post_meta( $font_face_id, '_wp_font_face_file', $font_file_path ); + } + + return $font_face_post; + } + + /** + * Deletes a single font face. + * + * @since 6.5.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. + */ + public function delete_item( $request ) { + $post = $this->get_post( $request['id'] ); + if ( is_wp_error( $post ) ) { + return $post; + } + + $font_family = $this->get_parent_font_family_post( $request['font_family_id'] ); + if ( is_wp_error( $font_family ) ) { + return $font_family; + } + + if ( (int) $font_family->ID !== (int) $post->post_parent ) { + return new WP_Error( + 'rest_font_face_parent_id_mismatch', + /* translators: %d: A post id. */ + sprintf( __( 'The font face does not belong to the specified font family with id of "%d"', 'gutenberg' ), $font_family->ID ), + array( 'status' => 404 ) + ); + } + + $force = isset( $request['force'] ) ? (bool) $request['force'] : false; + + // We don't support trashing for font faces. + if ( ! $force ) { + return new WP_Error( + 'rest_trash_not_supported', + /* translators: %s: force=true */ + sprintf( __( "Font faces do not support trashing. Set '%s' to delete.", 'gutenberg' ), 'force=true' ), + array( 'status' => 501 ) + ); + } + + return parent::delete_item( $request ); + } + + /** + * Prepares a single font face output for response. + * + * @since 6.5.0 + * + * @param WP_Post $item Post object. + * @param WP_REST_Request $request Request object. + * @return WP_REST_Response Response object. + */ + public function prepare_item_for_response( $item, $request ) { + $fields = $this->get_fields_for_response( $request ); + $data = array(); + + if ( rest_is_field_included( 'id', $fields ) ) { + $data['id'] = $item->ID; + } + if ( rest_is_field_included( 'theme_json_version', $fields ) ) { + $data['theme_json_version'] = 2; + } + + if ( rest_is_field_included( 'parent', $fields ) ) { + $data['parent'] = $item->post_parent; + } + + if ( rest_is_field_included( 'font_face_settings', $fields ) ) { + $data['font_face_settings'] = $this->get_settings_from_post( $item ); + } + + $context = ! empty( $request['context'] ) ? $request['context'] : 'edit'; + $data = $this->add_additional_fields_to_object( $data, $request ); + $data = $this->filter_response_by_context( $data, $context ); + + $response = rest_ensure_response( $data ); + + if ( rest_is_field_included( '_links', $fields ) || rest_is_field_included( '_embedded', $fields ) ) { + $links = $this->prepare_links( $item ); + $response->add_links( $links ); + } + + /** + * Filters the font face data for a REST API response. + * + * @since 6.5.0 + * + * @param WP_REST_Response $response The response object. + * @param WP_Post $post Font face post object. + * @param WP_REST_Request $request Request object. + */ + return apply_filters( 'rest_prepare_wp_font_face', $response, $item, $request ); + } + + /** + * Retrieves the post's schema, conforming to JSON Schema. + * + * @since 6.5.0 + * + * @return array Item schema data. + */ + public function get_item_schema() { + if ( $this->schema ) { + return $this->add_additional_fields_schema( $this->schema ); + } + + $schema = array( + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'title' => $this->post_type, + 'type' => 'object', + // Base properties for every Post. + 'properties' => array( + 'id' => array( + 'description' => __( 'Unique identifier for the post.', 'default' ), + 'type' => 'integer', + 'context' => array( 'edit', 'embed' ), + 'readonly' => true, + ), + 'theme_json_version' => array( + 'description' => __( 'Version of the theme.json schema used for the typography settings.', 'gutenberg' ), + 'type' => 'integer', + 'default' => 2, + 'minimum' => 2, + 'maximum' => 2, + 'context' => array( 'edit', 'embed' ), + ), + 'parent' => array( + 'description' => __( 'The ID for the parent font family of the font face.', 'gutenberg' ), + 'type' => 'integer', + 'context' => array( 'edit', 'embed' ), + ), + // Font face settings come directly from theme.json schema + // See https://schemas.wp.org/trunk/theme.json + 'font_face_settings' => array( + 'description' => __( 'font-face declaration in theme.json format.', 'gutenberg' ), + 'type' => 'object', + 'context' => array( 'edit', 'embed' ), + 'properties' => array( + 'fontFamily' => array( + 'description' => __( 'CSS font-family value.', 'gutenberg' ), + 'type' => 'string', + 'default' => '', + ), + 'fontStyle' => array( + 'description' => __( 'CSS font-style value.', 'gutenberg' ), + 'type' => 'string', + 'default' => 'normal', + ), + 'fontWeight' => array( + 'description' => __( 'List of available font weights, separated by a space.', 'gutenberg' ), + 'default' => '400', + // Changed from `oneOf` to avoid errors from loose type checking. + // e.g. a fontWeight of "400" validates as both a string and an integer due to is_numeric check. + 'type' => array( 'string', 'integer' ), + ), + 'fontDisplay' => array( + 'description' => __( 'CSS font-display value.', 'gutenberg' ), + 'type' => 'string', + 'default' => 'fallback', + 'enum' => array( + 'auto', + 'block', + 'fallback', + 'swap', + 'optional', + ), + ), + 'src' => array( + 'description' => __( 'Paths or URLs to the font files.', 'gutenberg' ), + // Changed from `oneOf` to `anyOf` due to rest_sanitize_array converting a string into an array. + 'anyOf' => array( + array( + 'type' => 'string', + ), + array( + 'type' => 'array', + 'items' => array( + 'type' => 'string', + ), + ), + ), + 'default' => array(), + ), + 'fontStretch' => array( + 'description' => __( 'CSS font-stretch value.', 'gutenberg' ), + 'type' => 'string', + ), + 'ascentOverride' => array( + 'description' => __( 'CSS ascent-override value.', 'gutenberg' ), + 'type' => 'string', + ), + 'descentOverride' => array( + 'description' => __( 'CSS descent-override value.', 'gutenberg' ), + 'type' => 'string', + ), + 'fontVariant' => array( + 'description' => __( 'CSS font-variant value.', 'gutenberg' ), + 'type' => 'string', + ), + 'fontFeatureSettings' => array( + 'description' => __( 'CSS font-feature-settings value.', 'gutenberg' ), + 'type' => 'string', + ), + 'fontVariationSettings' => array( + 'description' => __( 'CSS font-variation-settings value.', 'gutenberg' ), + 'type' => 'string', + ), + 'lineGapOverride' => array( + 'description' => __( 'CSS line-gap-override value.', 'gutenberg' ), + 'type' => 'string', + ), + 'sizeAdjust' => array( + 'description' => __( 'CSS size-adjust value.', 'gutenberg' ), + 'type' => 'string', + ), + 'unicodeRange' => array( + 'description' => __( 'CSS unicode-range value.', 'gutenberg' ), + 'type' => 'string', + ), + 'preview' => array( + 'description' => __( 'URL to a preview image of the font face.', 'gutenberg' ), + 'type' => 'string', + ), + ), + 'required' => array( 'fontFamily', 'src' ), + 'additionalProperties' => false, + ), + ), + ); + + $this->schema = $schema; + + return $this->add_additional_fields_schema( $this->schema ); + } + + /** + * Retrieves the query params for the font face collection. + * + * @since 6.5.0 + * + * @return array Collection parameters. + */ + public function get_collection_params() { + $query_params = parent::get_collection_params(); + + $query_params['context']['default'] = 'edit'; + + // Remove unneeded params. + unset( $query_params['after'] ); + unset( $query_params['modified_after'] ); + unset( $query_params['before'] ); + unset( $query_params['modified_before'] ); + unset( $query_params['search'] ); + unset( $query_params['search_columns'] ); + unset( $query_params['slug'] ); + unset( $query_params['status'] ); + + $query_params['orderby']['default'] = 'id'; + $query_params['orderby']['enum'] = array( 'id', 'include' ); + + /** + * Filters collection parameters for the font face controller. + * + * @since 6.5.0 + * + * @param array $query_params JSON Schema-formatted collection parameters. + */ + return apply_filters( 'rest_wp_font_face_collection_params', $query_params ); + } + + /** + * Get the params used when creating a new font face. + * + * @since 6.5.0 + * + * @return array Font face create arguments. + */ + public function get_create_params() { + $properties = $this->get_item_schema()['properties']; + return array( + 'theme_json_version' => $properties['theme_json_version'], + // When creating, font_face_settings is stringified JSON, to work with multipart/form-data used + // when uploading font files. + 'font_face_settings' => array( + 'description' => __( 'font-face declaration in theme.json format, encoded as a string.', 'gutenberg' ), + 'type' => 'string', + 'required' => true, + 'validate_callback' => array( $this, 'validate_create_font_face_settings' ), + 'sanitize_callback' => array( $this, 'sanitize_font_face_settings' ), + ), + ); + } + + /** + * Get the parent font family, if the ID is valid. + * + * @since 6.5.0 + * + * @param int $font_family_id Supplied ID. + * @return WP_Post|WP_Error Post object if ID is valid, WP_Error otherwise. + */ + protected function get_parent_font_family_post( $font_family_id ) { + $error = new WP_Error( + 'rest_post_invalid_parent', + __( 'Invalid post parent ID.', 'default' ), + array( 'status' => 404 ) + ); + + if ( (int) $font_family_id <= 0 ) { + return $error; + } + + $font_family_post = get_post( (int) $font_family_id ); + + if ( empty( $font_family_post ) || empty( $font_family_post->ID ) + || 'wp_font_family' !== $font_family_post->post_type + ) { + return $error; + } + + return $font_family_post; + } + + /** + * Prepares links for the request. + * + * @since 6.5.0 + * + * @param WP_Post $post Post object. + * @return array Links for the given post. + */ + protected function prepare_links( $post ) { + // Entity meta. + $links = array( + 'self' => array( + 'href' => rest_url( $this->namespace . '/font-families/' . $post->post_parent . '/font-faces/' . $post->ID ), + ), + 'collection' => array( + 'href' => rest_url( $this->namespace . '/font-families/' . $post->post_parent . '/font-faces' ), + ), + 'parent' => array( + 'href' => rest_url( $this->namespace . '/font-families/' . $post->post_parent ), + ), + ); + + return $links; + } + + /** + * Prepares a single font face post for creation. + * + * @since 6.5.0 + * + * @param WP_REST_Request $request Request object. + * @return stdClass|WP_Error Post object or WP_Error. + */ + protected function prepare_item_for_database( $request ) { + $prepared_post = new stdClass(); + + // Settings have already been decoded by ::sanitize_font_face_settings(). + $settings = $request->get_param( 'font_face_settings' ); + + // Store this "slug" as the post_title rather than post_name, since it uses the fontFamily setting, + // which may contain multibyte characters. + $title = WP_Font_Family_Utils::get_font_face_slug( $settings ); + + $prepared_post->post_type = $this->post_type; + $prepared_post->post_parent = $request['font_family_id']; + $prepared_post->post_status = 'publish'; + $prepared_post->post_title = $title; + $prepared_post->post_name = sanitize_title( $title ); + $prepared_post->post_content = wp_json_encode( $settings ); + + return $prepared_post; + } + + /** + * Handles the upload of a font file using wp_handle_upload(). + * + * @since 6.5.0 + * + * @param array $file Single file item from $_FILES. + * @return array Array containing uploaded file attributes on success, or error on failure. + */ + protected function handle_font_file_upload( $file ) { + add_filter( 'upload_mimes', array( 'WP_Font_Library', 'set_allowed_mime_types' ) ); + add_filter( 'upload_dir', 'wp_get_font_dir' ); + + $overrides = array( + 'upload_error_handler' => array( $this, 'handle_font_file_upload_error' ), + // Arbitrary string to avoid the is_uploaded_file() check applied + // when using 'wp_handle_upload'. + 'action' => 'wp_handle_font_upload', + // Not testing a form submission. + 'test_form' => false, + // Seems mime type for files that are not images cannot be tested. + // See wp_check_filetype_and_ext(). + 'test_type' => true, + // Only allow uploading font files for this request. + 'mimes' => WP_Font_Library::get_expected_font_mime_types_per_php_version(), + ); + + $uploaded_file = wp_handle_upload( $file, $overrides ); + + remove_filter( 'upload_dir', 'wp_get_font_dir' ); + remove_filter( 'upload_mimes', array( 'WP_Font_Library', 'set_allowed_mime_types' ) ); + + return $uploaded_file; + } + + /** + * Handles file upload error. + * + * @since 6.5.0 + * + * @param array $file File upload data. + * @param string $message Error message from wp_handle_upload(). + * @return WP_Error WP_Error object. + */ + public function handle_font_file_upload_error( $file, $message ) { + $status = 500; + $code = 'rest_font_upload_unknown_error'; + + if ( __( 'Sorry, you are not allowed to upload this file type.', 'default' ) === $message ) { + $status = 400; + $code = 'rest_font_upload_invalid_file_type'; + } + + return new WP_Error( $code, $message, array( 'status' => $status ) ); + } + + /** + * Returns relative path to an uploaded font file. + * + * The path is relative to the current fonts dir. + * + * @since 6.5.0 + * @access private + * + * @param string $path Full path to the file. + * @return string Relative path on success, unchanged path on failure. + */ + protected function relative_fonts_path( $path ) { + $new_path = $path; + + $fonts_dir = wp_get_font_dir(); + if ( str_starts_with( $new_path, $fonts_dir['path'] ) ) { + $new_path = str_replace( $fonts_dir, '', $new_path ); + $new_path = ltrim( $new_path, '/' ); + } + + return $new_path; + } + + /** + * Gets the font face's settings from the post. + * + * @since 6.5.0 + * + * @param WP_Post $post Font face post object. + * @return array Font face settings array. + */ + protected function get_settings_from_post( $post ) { + $settings = json_decode( $post->post_content, true ); + $properties = $this->get_item_schema()['properties']['font_face_settings']['properties']; + + // Provide required, empty settings if needed. + if ( null === $settings ) { + $settings = array( + 'fontFamily' => '', + 'src' => array(), + ); + } + + // Only return the properties defined in the schema. + return array_intersect_key( $settings, $properties ); + } +} diff --git a/lib/experimental/fonts/font-library/class-wp-rest-font-families-controller.php b/lib/experimental/fonts/font-library/class-wp-rest-font-families-controller.php index ede8762c88c6dc..887a8a5250cc32 100644 --- a/lib/experimental/fonts/font-library/class-wp-rest-font-families-controller.php +++ b/lib/experimental/fonts/font-library/class-wp-rest-font-families-controller.php @@ -1,12 +1,10 @@ rest_base = 'font-families'; - $this->namespace = 'wp/v2'; - $this->post_type = 'wp_font_family'; - } + protected $allow_batch = false; /** - * Registers the routes for the objects of the controller. + * Checks if a given request has access to font families. * * @since 6.5.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return true|WP_Error True if the request has read access, WP_Error object otherwise. */ - public function register_routes() { - register_rest_route( - $this->namespace, - '/' . $this->rest_base, - array( - array( - 'methods' => WP_REST_Server::READABLE, - 'callback' => array( $this, 'get_items' ), - 'permission_callback' => array( $this, 'update_font_library_permissions_check' ), - ), - ) - ); + public function get_items_permissions_check( $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- required by parent class + $post_type = get_post_type_object( $this->post_type ); - register_rest_route( - $this->namespace, - '/' . $this->rest_base, - array( - array( - 'methods' => WP_REST_Server::EDITABLE, - 'callback' => array( $this, 'install_fonts' ), - 'permission_callback' => array( $this, 'update_font_library_permissions_check' ), - 'args' => array( - 'font_family_settings' => array( - 'required' => true, - 'type' => 'string', - 'validate_callback' => array( $this, 'validate_install_font_families' ), - ), - ), - ), - ) - ); + if ( ! current_user_can( $post_type->cap->read ) ) { + return new WP_Error( + 'rest_cannot_read', + __( 'Sorry, you are not allowed to access font families.', 'gutenberg' ), + array( 'status' => rest_authorization_required_code() ) + ); + } - register_rest_route( - $this->namespace, - '/' . $this->rest_base, - array( - array( - 'methods' => WP_REST_Server::DELETABLE, - 'callback' => array( $this, 'uninstall_fonts' ), - 'permission_callback' => array( $this, 'update_font_library_permissions_check' ), - 'args' => $this->uninstall_schema(), - ), - ) - ); + return true; } /** - * Returns validation errors in font families data for installation. + * Checks if a given request has access to a font family. * * @since 6.5.0 * - * @param array[] $font_families Font families to install. - * @param array $files Files to install. - * @return array $error_messages Array of error messages. + * @param WP_REST_Request $request Full details about the request. + * @return true|WP_Error True if the request has read access, WP_Error object otherwise. */ - private function get_validation_errors( $font_family_settings, $files ) { - $error_messages = array(); + public function get_item_permissions_check( $request ) { + return $this->get_items_permissions_check( $request ); + } - if ( ! is_array( $font_family_settings ) ) { - $error_messages[] = __( 'font_family_settings should be a font family definition.', 'gutenberg' ); - return $error_messages; + /** + * Validates settings when creating or updating a font family. + * + * @since 6.5.0 + * + * @param string $value Encoded JSON string of font family settings. + * @param WP_REST_Request $request Request object. + * @return false|WP_Error True if the settings are valid, otherwise a WP_Error object. + */ + public function validate_font_family_settings( $value, $request ) { + $settings = json_decode( $value, true ); + + // Check settings string is valid JSON. + if ( null === $settings ) { + return new WP_Error( + 'rest_invalid_param', + __( 'font_family_settings parameter must be a valid JSON string.', 'gutenberg' ), + array( 'status' => 400 ) + ); } - if ( - ! isset( $font_family_settings['slug'] ) || - ! isset( $font_family_settings['name'] ) || - ! isset( $font_family_settings['fontFamily'] ) - ) { - $error_messages[] = __( 'Font family should have slug, name and fontFamily properties defined.', 'gutenberg' ); + $schema = $this->get_item_schema()['properties']['font_family_settings']; + $required = $schema['required']; - return $error_messages; - } + if ( isset( $request['id'] ) ) { + // Allow sending individual properties if we are updating an existing font family. + unset( $schema['required'] ); - if ( isset( $font_family_settings['fontFace'] ) ) { - if ( ! is_array( $font_family_settings['fontFace'] ) ) { - $error_messages[] = __( 'Font family should have fontFace property defined as an array.', 'gutenberg' ); + // But don't allow updating the slug, since it is used as a unique identifier. + if ( isset( $settings['slug'] ) ) { + return new WP_Error( + 'rest_invalid_param', + __( 'font_family_settings[slug] cannot be updated.', 'gutenberg' ), + array( 'status' => 400 ) + ); } + } - if ( count( $font_family_settings['fontFace'] ) < 1 ) { - $error_messages[] = __( 'Font family should have at least one font face definition.', 'gutenberg' ); - } + // Check that the font face settings match the theme.json schema. + $has_valid_settings = rest_validate_value_from_schema( $settings, $schema, 'font_family_settings' ); - if ( ! empty( $font_family_settings['fontFace'] ) ) { - for ( $face_index = 0; $face_index < count( $font_family_settings['fontFace'] ); $face_index++ ) { - - $font_face = $font_family_settings['fontFace'][ $face_index ]; - if ( ! isset( $font_face['fontWeight'] ) || ! isset( $font_face['fontStyle'] ) ) { - $error_messages[] = sprintf( - // translators: font face index. - __( 'Font family Font face [%1$s] should have fontWeight and fontStyle properties defined.', 'gutenberg' ), - $face_index - ); - } - - if ( isset( $font_face['downloadFromUrl'] ) && isset( $font_face['uploadedFile'] ) ) { - $error_messages[] = sprintf( - // translators: font face index. - __( 'Font family Font face [%1$s] should have only one of the downloadFromUrl or uploadedFile properties defined and not both.', 'gutenberg' ), - $face_index - ); - } - - if ( isset( $font_face['uploadedFile'] ) ) { - if ( ! isset( $files[ $font_face['uploadedFile'] ] ) ) { - $error_messages[] = sprintf( - // translators: font face index. - __( 'Font family Font face [%1$s] file is not defined in the request files.', 'gutenberg' ), - $face_index - ); - } - } - } + if ( is_wp_error( $has_valid_settings ) ) { + $has_valid_settings->add_data( array( 'status' => 400 ) ); + return $has_valid_settings; + } + + // Check that none of the required settings are empty values. + foreach ( $required as $key ) { + if ( isset( $settings[ $key ] ) && ! $settings[ $key ] ) { + return new WP_Error( + 'rest_invalid_param', + /* translators: %s: Font family setting key. */ + sprintf( __( 'font_family_settings[%s] cannot be empty.', 'gutenberg' ), $key ), + array( 'status' => 400 ) + ); } } - return $error_messages; + return true; } /** - * Validate input for the install endpoint. + * Sanitizes the font family settings when creating or updating a font family. * - * @since 6.5.0 + * @since 6.5.0 * - * @param string $param The font families to install. - * @param WP_REST_Request $request The request object. - * @return true|WP_Error True if the parameter is valid, WP_Error otherwise. + * @param string $value Encoded JSON string of font family settings. + * @param WP_REST_Request $request Request object. + * @return array Decoded array font family settings. */ - public function validate_install_font_families( $param, $request ) { - $font_family_settings = json_decode( $param, true ); - $files = $request->get_file_params(); - $error_messages = $this->get_validation_errors( $font_family_settings, $files ); + public function sanitize_font_family_settings( $value ) { + $settings = json_decode( $value, true ); - if ( empty( $error_messages ) ) { - return true; + if ( isset( $settings['fontFamily'] ) ) { + $settings['fontFamily'] = WP_Font_Family_Utils::format_font_family( $settings['fontFamily'] ); } - return new WP_Error( 'rest_invalid_param', implode( ', ', $error_messages ), array( 'status' => 400 ) ); + // Provide default for preview, if not provided. + if ( ! isset( $settings['preview'] ) ) { + $settings['preview'] = ''; + } + + return $settings; } /** - * Gets the schema for the uninstall endpoint. + * Creates a single font family. * * @since 6.5.0 * - * @return array Schema array. + * @param WP_REST_Request $request Full details about the request. + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. */ - public function uninstall_schema() { - return array( - 'font_families' => array( - 'type' => 'array', - 'description' => __( 'The font families to install.', 'gutenberg' ), - 'required' => true, - 'minItems' => 1, - 'items' => array( - 'required' => true, - 'type' => 'object', - 'properties' => array( - 'slug' => array( - 'type' => 'string', - 'description' => __( 'The font family slug.', 'gutenberg' ), - 'required' => true, - ), - ), - ), - ), + public function create_item( $request ) { + $settings = $request->get_param( 'font_family_settings' ); + + // Check that the font family slug is unique. + $query = new WP_Query( + array( + 'post_type' => $this->post_type, + 'posts_per_page' => 1, + 'name' => $settings['slug'], + 'update_post_meta_cache' => false, + 'update_post_term_cache' => false, + ) ); + if ( ! empty( $query->get_posts() ) ) { + return new WP_Error( + 'rest_duplicate_font_family', + /* translators: %s: Font family slug. */ + sprintf( __( 'A font family with slug "%s" already exists.', 'gutenberg' ), $settings['slug'] ), + array( 'status' => 400 ) + ); + } + + return parent::create_item( $request ); } /** - * Removes font families from the Font Library and all their assets. + * Deletes a single font family. * * @since 6.5.0 * * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. */ - public function uninstall_fonts( $request ) { - $fonts_to_uninstall = $request->get_param( 'font_families' ); + public function delete_item( $request ) { + $force = isset( $request['force'] ) ? (bool) $request['force'] : false; - $errors = array(); - $successes = array(); - - if ( empty( $fonts_to_uninstall ) ) { - $errors[] = new WP_Error( - 'no_fonts_to_install', - __( 'No fonts to uninstall', 'gutenberg' ) - ); - $data = array( - 'successes' => $successes, - 'errors' => $errors, + // We don't support trashing for font families. + if ( ! $force ) { + return new WP_Error( + 'rest_trash_not_supported', + /* translators: %s: force=true */ + sprintf( __( "Font faces do not support trashing. Set '%s' to delete.", 'gutenberg' ), 'force=true' ), + array( 'status' => 501 ) ); - $response = rest_ensure_response( $data ); - $response->set_status( 400 ); - return $response; } - foreach ( $fonts_to_uninstall as $font_data ) { - $font = new WP_Font_Family( $font_data ); - $result = $font->uninstall(); - if ( is_wp_error( $result ) ) { - $errors[] = $result; - } else { - $successes[] = $result; - } - } - $data = array( - 'successes' => $successes, - 'errors' => $errors, - ); - return rest_ensure_response( $data ); + return parent::delete_item( $request ); } /** - * Checks whether the user has permissions to update the Font Library. + * Prepares a single font family output for response. * * @since 6.5.0 * - * @return true|WP_Error True if the request has write access for the item, WP_Error object otherwise. + * @param WP_Post $item Post object. + * @param WP_REST_Request $request Request object. + * @return WP_REST_Response Response object. */ - public function update_font_library_permissions_check() { - if ( ! current_user_can( 'edit_theme_options' ) ) { - return new WP_Error( - 'rest_cannot_update_font_library', - __( 'Sorry, you are not allowed to update the Font Library on this site.', 'gutenberg' ), - array( - 'status' => rest_authorization_required_code(), - ) - ); + public function prepare_item_for_response( $item, $request ) { + $fields = $this->get_fields_for_response( $request ); + $data = array(); + + if ( rest_is_field_included( 'id', $fields ) ) { + $data['id'] = $item->ID; } - return true; + + if ( rest_is_field_included( 'theme_json_version', $fields ) ) { + $data['theme_json_version'] = 2; + } + + if ( rest_is_field_included( 'font_faces', $fields ) ) { + $data['font_faces'] = $this->get_font_face_ids( $item->ID ); + } + + if ( rest_is_field_included( 'font_family_settings', $fields ) ) { + $data['font_family_settings'] = $this->get_settings_from_post( $item ); + } + + $context = ! empty( $request['context'] ) ? $request['context'] : 'edit'; + $data = $this->add_additional_fields_to_object( $data, $request ); + $data = $this->filter_response_by_context( $data, $context ); + + $response = rest_ensure_response( $data ); + + if ( rest_is_field_included( '_links', $fields ) ) { + $links = $this->prepare_links( $item ); + $response->add_links( $links ); + } + + /** + * Filters the font family data for a REST API response. + * + * @since 6.5.0 + * + * @param WP_REST_Response $response The response object. + * @param WP_Post $post Font family post object. + * @param WP_REST_Request $request Request object. + */ + return apply_filters( 'rest_prepare_wp_font_family', $response, $item, $request ); + } + + /** + * Retrieves the post's schema, conforming to JSON Schema. + * + * @since 6.5.0 + * + * @return array Item schema data. + */ + public function get_item_schema() { + if ( $this->schema ) { + return $this->add_additional_fields_schema( $this->schema ); + } + + $schema = array( + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'title' => $this->post_type, + 'type' => 'object', + // Base properties for every Post. + 'properties' => array( + 'id' => array( + 'description' => __( 'Unique identifier for the post.', 'default' ), + 'type' => 'integer', + 'context' => array( 'edit' ), + 'readonly' => true, + ), + 'theme_json_version' => array( + 'description' => __( 'Version of the theme.json schema used for the typography settings.', 'gutenberg' ), + 'type' => 'integer', + 'default' => 2, + 'minimum' => 2, + 'maximum' => 2, + 'context' => array( 'edit' ), + ), + 'font_faces' => array( + 'description' => __( 'The IDs of the child font faces in the font family.', 'gutenberg' ), + 'type' => 'array', + 'context' => array( 'edit' ), + 'items' => array( + 'type' => 'integer', + ), + ), + // Font family settings come directly from theme.json schema + // See https://schemas.wp.org/trunk/theme.json + 'font_family_settings' => array( + 'description' => __( 'font-face declaration in theme.json format.', 'gutenberg' ), + 'type' => 'object', + 'context' => array( 'edit' ), + 'properties' => array( + 'name' => array( + 'description' => 'Name of the font family preset, translatable.', + 'type' => 'string', + ), + 'slug' => array( + 'description' => 'Kebab-case unique identifier for the font family preset.', + 'type' => 'string', + ), + 'fontFamily' => array( + 'description' => 'CSS font-family value.', + 'type' => 'string', + ), + 'preview' => array( + 'description' => 'URL to a preview image of the font family.', + 'type' => 'string', + ), + ), + 'required' => array( 'name', 'slug', 'fontFamily' ), + 'additionalProperties' => false, + ), + ), + ); + + $this->schema = $schema; + + return $this->add_additional_fields_schema( $this->schema ); } /** - * Checks whether the font directory exists or not. + * Retrieves the query params for the font family collection. * * @since 6.5.0 * - * @return bool Whether the font directory exists. + * @return array Collection parameters. */ - private function has_upload_directory() { - $upload_dir = wp_get_font_dir()['path']; - return is_dir( $upload_dir ); + public function get_collection_params() { + $query_params = parent::get_collection_params(); + + $query_params['context']['default'] = 'edit'; + + // Remove unneeded params. + unset( $query_params['after'] ); + unset( $query_params['modified_after'] ); + unset( $query_params['before'] ); + unset( $query_params['modified_before'] ); + unset( $query_params['search'] ); + unset( $query_params['search_columns'] ); + unset( $query_params['status'] ); + + $query_params['orderby']['default'] = 'id'; + $query_params['orderby']['enum'] = array( 'id', 'include' ); + + /** + * Filters collection parameters for the font family controller. + * + * @since 6.5.0 + * + * @param array $query_params JSON Schema-formatted collection parameters. + */ + return apply_filters( 'rest_wp_font_family_collection_params', $query_params ); } /** - * Checks whether the user has write permissions to the temp and fonts directories. + * Retrieves the query params for the font family collection, defaulting to the 'edit' context. * * @since 6.5.0 * - * @return true|WP_Error True if the user has write permissions, WP_Error object otherwise. + * @param array $args Optional. Additional arguments for context parameter. Default empty array. + * @return array Context parameter details. */ - private function has_write_permission() { - // The update endpoints requires write access to the temp and the fonts directories. - $temp_dir = get_temp_dir(); - $upload_dir = wp_get_font_dir()['path']; - if ( ! is_writable( $temp_dir ) || ! wp_is_writable( $upload_dir ) ) { - return false; + public function get_context_param( $args = array() ) { + if ( isset( $args['default'] ) ) { + $args['default'] = 'edit'; } - return true; + return parent::get_context_param( $args ); } /** - * Checks whether the request needs write permissions. + * Get the arguments used when creating or updating a font family. * * @since 6.5.0 * - * @param array[] $font_family_settings Font family definition. - * @return bool Whether the request needs write permissions. + * @return array Font family create/edit arguments. */ - private function needs_write_permission( $font_family_settings ) { - if ( isset( $font_family_settings['fontFace'] ) ) { - foreach ( $font_family_settings['fontFace'] as $face ) { - // If the font is being downloaded from a URL or uploaded, it needs write permissions. - if ( isset( $face['downloadFromUrl'] ) || isset( $face['uploadedFile'] ) ) { - return true; - } - } + public function get_endpoint_args_for_item_schema( $method = WP_REST_Server::CREATABLE ) { + if ( WP_REST_Server::CREATABLE === $method || WP_REST_Server::EDITABLE === $method ) { + $properties = $this->get_item_schema()['properties']; + return array( + 'theme_json_version' => $properties['theme_json_version'], + // When creating or updating, font_family_settings is stringified JSON, to work with multipart/form-data. + // Font families don't currently support file uploads, but may accept preview files in the future. + 'font_family_settings' => array( + 'description' => __( 'font-family declaration in theme.json format, encoded as a string.', 'gutenberg' ), + 'type' => 'string', + 'required' => true, + 'validate_callback' => array( $this, 'validate_font_family_settings' ), + 'sanitize_callback' => array( $this, 'sanitize_font_family_settings' ), + ), + ); } - return false; + + return parent::get_endpoint_args_for_item_schema( $method ); } /** - * Installs new fonts. + * Get the child font face post IDs. * - * Takes a request containing new fonts to install, downloads their assets, and adds them - * to the Font Library. + * @since 6.5.0 + * + * @param int $font_family_id Font family post ID. + * @return int[] Array of child font face post IDs. + * . + */ + protected function get_font_face_ids( $font_family_id ) { + $query = new WP_Query( + array( + 'fields' => 'ids', + 'post_parent' => $font_family_id, + 'post_type' => 'wp_font_face', + 'posts_per_page' => 99, + 'order' => 'ASC', + 'orderby' => 'id', + 'update_post_meta_cache' => false, + 'update_post_term_cache' => false, + ) + ); + + return $query->get_posts(); + } + + /** + * Prepares font family links for the request. * * @since 6.5.0 * - * @param WP_REST_Request $request The request object containing the new fonts to install - * in the request parameters. - * @return WP_REST_Response|WP_Error The updated Font Library post content. + * @param WP_Post $post Post object. + * @return array Links for the given post. */ - public function install_fonts( $request ) { - // Get new fonts to install. - $font_family_settings = $request->get_param( 'font_family_settings' ); - - /* - * As this is receiving form data, the font families are encoded as a string. - * The form data is used because local fonts need to use that format to - * attach the files in the request. - */ - $font_family_settings = json_decode( $font_family_settings, true ); + protected function prepare_links( $post ) { + // Entity meta. + $links = parent::prepare_links( $post ); - $successes = array(); - $errors = array(); - $response_status = 200; + return array( + 'self' => $links['self'], + 'collection' => $links['collection'], + 'font_faces' => $this->prepare_font_face_links( $post->ID ), + ); + } - if ( empty( $font_family_settings ) ) { - $errors[] = new WP_Error( - 'no_fonts_to_install', - __( 'No fonts to install', 'gutenberg' ) + /** + * Prepares child font face links for the request. + * + * @param int $font_family_id Font family post ID. + * @return array Links for the child font face posts. + */ + protected function prepare_font_face_links( $font_family_id ) { + $font_face_ids = $this->get_font_face_ids( $font_family_id ); + $links = array(); + foreach ( $font_face_ids as $font_face_id ) { + $links[] = array( + 'embeddable' => true, + 'href' => rest_url( $this->namespace . '/' . $this->rest_base . '/' . $font_family_id . '/font-faces/' . $font_face_id ), ); - $response_status = 400; } + return $links; + } - if ( $this->needs_write_permission( $font_family_settings ) ) { - $upload_dir = wp_get_font_dir()['path']; - if ( ! $this->has_upload_directory() ) { - if ( ! wp_mkdir_p( $upload_dir ) ) { - $errors[] = new WP_Error( - 'cannot_create_fonts_folder', - sprintf( - /* translators: %s: Directory path. */ - __( 'Error: Unable to create directory %s.', 'gutenberg' ), - esc_html( $upload_dir ) - ) - ); - $response_status = 500; - } + /** + * Prepares a single font family post for create or update. + * + * @since 6.5.0 + * + * @param WP_REST_Request $request Request object. + * @return stdClass|WP_Error Post object or WP_Error. + */ + protected function prepare_item_for_database( $request ) { + $prepared_post = new stdClass(); + // Settings have already been decoded by ::sanitize_font_family_settings(). + $settings = $request->get_param( 'font_family_settings' ); + + // This is an update and we merge with the existing font family. + if ( isset( $request['id'] ) ) { + $existing_post = $this->get_post( $request['id'] ); + if ( is_wp_error( $existing_post ) ) { + return $existing_post; } - if ( $this->has_upload_directory() && ! $this->has_write_permission() ) { - $errors[] = new WP_Error( - 'cannot_write_fonts_folder', - __( 'Error: WordPress does not have permission to write the fonts folder on your server.', 'gutenberg' ) - ); - $response_status = 500; - } + $prepared_post->ID = $existing_post->ID; + $existing_settings = $this->get_settings_from_post( $existing_post ); + $settings = array_merge( $existing_settings, $settings ); } - if ( ! empty( $errors ) ) { - $data = array( - 'successes' => $successes, - 'errors' => $errors, - ); - $response = rest_ensure_response( $data ); - $response->set_status( $response_status ); - return $response; - } + $prepared_post->post_type = $this->post_type; + $prepared_post->post_status = 'publish'; + $prepared_post->post_title = $settings['name']; + $prepared_post->post_name = sanitize_title( $settings['slug'] ); - // Get uploaded files (used when installing local fonts). - $files = $request->get_file_params(); - $font = new WP_Font_Family( $font_family_settings ); - $result = $font->install( $files ); - if ( is_wp_error( $result ) ) { - $errors[] = $result; - } else { - $successes[] = $result; - } + // Remove duplicate information from settings. + unset( $settings['name'] ); + unset( $settings['slug'] ); + + $prepared_post->post_content = wp_json_encode( $settings ); - $data = array( - 'successes' => $successes, - 'errors' => $errors, + return $prepared_post; + } + + /** + * Gets the font family's settings from the post. + * + * @since 6.5.0 + * + * @param WP_Post $post Font family post object. + * @return array Font family settings array. + */ + protected function get_settings_from_post( $post ) { + $settings_json = json_decode( $post->post_content, true ); + + // Default to empty strings if the settings are missing. + return array( + 'name' => isset( $post->post_title ) && $post->post_title ? $post->post_title : '', + 'slug' => isset( $post->post_name ) && $post->post_name ? $post->post_name : '', + 'fontFamily' => isset( $settings_json['fontFamily'] ) && $settings_json['fontFamily'] ? $settings_json['fontFamily'] : '', + 'preview' => isset( $settings_json['preview'] ) && $settings_json['preview'] ? $settings_json['preview'] : '', ); - return rest_ensure_response( $data ); } } diff --git a/lib/experimental/fonts/font-library/font-library.php b/lib/experimental/fonts/font-library/font-library.php index d1ad8e1447ad9c..e9744da5958f4c 100644 --- a/lib/experimental/fonts/font-library/font-library.php +++ b/lib/experimental/fonts/font-library/font-library.php @@ -22,16 +22,73 @@ function gutenberg_init_font_library_routes() { // @core-merge: This code will go into Core's `create_initial_post_types()`. $args = array( + 'labels' => array( + 'name' => __( 'Font Families', 'gutenberg' ), + 'singular_name' => __( 'Font Family', 'gutenberg' ), + ), 'public' => false, - '_builtin' => true, /* internal use only. don't use this when registering your own post type. */ - 'label' => 'Font Family', + '_builtin' => true, /* internal use only. don't use this when registering your own post type. */ + 'hierarchical' => false, + 'capabilities' => array( + 'read' => 'edit_theme_options', + 'read_post' => 'edit_theme_options', + 'read_private_posts' => 'edit_theme_options', + 'create_posts' => 'edit_theme_options', + 'publish_posts' => 'edit_theme_options', + 'edit_post' => 'edit_theme_options', + 'edit_posts' => 'edit_theme_options', + 'edit_others_posts' => 'edit_theme_options', + 'edit_published_posts' => 'edit_theme_options', + 'delete_post' => 'edit_theme_options', + 'delete_posts' => 'edit_theme_options', + 'delete_others_posts' => 'edit_theme_options', + 'delete_published_posts' => 'edit_theme_options', + ), + 'map_meta_cap' => false, + 'query_var' => false, 'show_in_rest' => true, 'rest_base' => 'font-families', 'rest_controller_class' => 'WP_REST_Font_Families_Controller', - 'autosave_rest_controller_class' => 'WP_REST_Autosave_Font_Families_Controller', + // Disable autosave endpoints for font families. + 'autosave_rest_controller_class' => 'stdClass', ); register_post_type( 'wp_font_family', $args ); + register_post_type( + 'wp_font_face', + array( + 'labels' => array( + 'name' => __( 'Font Faces', 'gutenberg' ), + 'singular_name' => __( 'Font Face', 'gutenberg' ), + ), + 'public' => false, + '_builtin' => true, /* internal use only. don't use this when registering your own post type. */ + 'hierarchical' => false, + 'capabilities' => array( + 'read' => 'edit_theme_options', + 'read_post' => 'edit_theme_options', + 'read_private_posts' => 'edit_theme_options', + 'create_posts' => 'edit_theme_options', + 'publish_posts' => 'edit_theme_options', + 'edit_post' => 'edit_theme_options', + 'edit_posts' => 'edit_theme_options', + 'edit_others_posts' => 'edit_theme_options', + 'edit_published_posts' => 'edit_theme_options', + 'delete_post' => 'edit_theme_options', + 'delete_posts' => 'edit_theme_options', + 'delete_others_posts' => 'edit_theme_options', + 'delete_published_posts' => 'edit_theme_options', + ), + 'map_meta_cap' => false, + 'query_var' => false, + 'show_in_rest' => true, + 'rest_base' => 'font-families/(?P[\d]+)/font-faces', + 'rest_controller_class' => 'WP_REST_Font_Faces_Controller', + // Disable autosave endpoints for font faces. + 'autosave_rest_controller_class' => 'stdClass', + ) + ); + // @core-merge: This code will go into Core's `create_initial_rest_routes()`. $font_collections_controller = new WP_REST_Font_Collections_Controller(); $font_collections_controller->register_routes(); @@ -79,7 +136,7 @@ function wp_unregister_font_collection( $collection_id ) { 'slug' => 'default-font-collection', 'name' => 'Google Fonts', 'description' => __( 'Add from Google Fonts. Fonts are copied to and served from your site.', 'gutenberg' ), - 'src' => 'https://s.w.org/images/fonts/16.7/collections/google-fonts-with-preview.json', + 'src' => 'https://s.w.org/images/fonts/17.6/collections/google-fonts-with-preview.json', ); wp_register_font_collection( $default_font_collection ); @@ -132,3 +189,141 @@ function wp_get_font_dir( $defaults = array() ) { return apply_filters( 'font_dir', $defaults ); } } + +// @core-merge: Filters should go in `src/wp-includes/default-filters.php`, +// functions in a general file for font library. +if ( ! function_exists( '_wp_after_delete_font_family' ) ) { + /** + * Deletes child font faces when a font family is deleted. + * + * @access private + * @since 6.5.0 + * + * @param int $post_id Post ID. + * @param WP_Post $post Post object. + * @return void + */ + function _wp_after_delete_font_family( $post_id, $post ) { + if ( 'wp_font_family' !== $post->post_type ) { + return; + } + + $font_faces = get_children( + array( + 'post_parent' => $post_id, + 'post_type' => 'wp_font_face', + ) + ); + + foreach ( $font_faces as $font_face ) { + wp_delete_post( $font_face->ID, true ); + } + } + add_action( 'deleted_post', '_wp_after_delete_font_family', 10, 2 ); +} + +if ( ! function_exists( '_wp_before_delete_font_face' ) ) { + /** + * Deletes associated font files when a font face is deleted. + * + * @access private + * @since 6.5.0 + * + * @param int $post_id Post ID. + * @param WP_Post $post Post object. + * @return void + */ + function _wp_before_delete_font_face( $post_id, $post ) { + if ( 'wp_font_face' !== $post->post_type ) { + return; + } + + $font_files = get_post_meta( $post_id, '_wp_font_face_file', false ); + + foreach ( $font_files as $font_file ) { + wp_delete_file( wp_get_font_dir()['path'] . '/' . $font_file ); + } + } + add_action( 'before_delete_post', '_wp_before_delete_font_face', 10, 2 ); +} + +// @core-merge: Do not merge this back compat function, it is for supporting a legacy font family format only in Gutenberg. +/** + * Convert legacy font family posts to the new format. + * + * @return void + */ +function gutenberg_convert_legacy_font_family_format() { + if ( get_option( 'gutenberg_font_family_format_converted' ) ) { + return; + } + + $font_families = new WP_Query( + array( + 'post_type' => 'wp_font_family', + // Set a maximum, but in reality there will be far less than this. + 'posts_per_page' => 999, + 'update_post_meta_cache' => false, + 'update_post_term_cache' => false, + ) + ); + + foreach ( $font_families->get_posts() as $font_family ) { + $already_converted = get_post_meta( $font_family->ID, '_gutenberg_legacy_font_family', true ); + if ( $already_converted ) { + continue; + } + + // Stash the old font family content in a meta field just in case we need it. + update_post_meta( $font_family->ID, '_gutenberg_legacy_font_family', $font_family->post_content ); + + $font_family_json = json_decode( $font_family->post_content, true ); + if ( ! $font_family_json ) { + continue; + } + + $font_faces = $font_family_json['fontFace'] ?? array(); + unset( $font_family_json['fontFace'] ); + + // Save wp_font_face posts within the family. + foreach ( $font_faces as $font_face ) { + $args = array(); + $args['post_type'] = 'wp_font_face'; + $args['post_title'] = WP_Font_Family_Utils::get_font_face_slug( $font_face ); + $args['post_name'] = sanitize_title( $args['post_title'] ); + $args['post_status'] = 'publish'; + $args['post_parent'] = $font_family->ID; + $args['post_content'] = wp_json_encode( $font_face ); + + $font_face_id = wp_insert_post( wp_slash( $args ) ); + + $file_urls = (array) $font_face['src'] ?? array(); + + foreach ( $file_urls as $file_url ) { + // continue if the file is not local. + if ( false === strpos( $file_url, site_url() ) ) { + continue; + } + + $relative_path = basename( $file_url ); + update_post_meta( $font_face_id, '_wp_font_face_file', $relative_path ); + } + } + + // Update the font family post to remove the font face data. + $args = array(); + $args['ID'] = $font_family->ID; + $args['post_title'] = $font_family_json['name'] ?? ''; + $args['post_name'] = sanitize_title( $font_family_json['slug'] ); + + unset( $font_family_json['name'] ); + unset( $font_family_json['slug'] ); + + $args['post_content'] = wp_json_encode( $font_family_json ); + + wp_update_post( wp_slash( $args ) ); + } + + update_option( 'gutenberg_font_family_format_converted', true ); +} +add_action( 'init', 'gutenberg_convert_legacy_font_family_format' ); diff --git a/lib/experimental/interactivity-api/class-wp-directive-context.php b/lib/experimental/interactivity-api/class-wp-directive-context.php deleted file mode 100644 index 4276eddca20acb..00000000000000 --- a/lib/experimental/interactivity-api/class-wp-directive-context.php +++ /dev/null @@ -1,82 +0,0 @@ - - * - *
- * - *
- * - * - */ -class WP_Directive_Context { - /** - * The stack used to store contexts internally. - * - * @var array An array of contexts. - */ - protected $stack = array( array() ); - - /** - * Constructor. - * - * Accepts a context as an argument to initialize this with. - * - * @param array $context A context. - */ - public function __construct( $context = array() ) { - $this->set_context( $context ); - } - - /** - * Return the current context. - * - * @return array The current context. - */ - public function get_context() { - return end( $this->stack ); - } - - /** - * Set the current context. - * - * @param array $context The context to be set. - * - * @return void - */ - public function set_context( $context ) { - array_push( - $this->stack, - array_replace_recursive( $this->get_context(), $context ) - ); - } - - /** - * Reset the context to its previous state. - * - * @return void - */ - public function rewind_context() { - array_pop( $this->stack ); - } -} diff --git a/lib/experimental/interactivity-api/class-wp-directive-processor.php b/lib/experimental/interactivity-api/class-wp-directive-processor.php deleted file mode 100644 index 723b36026ce2af..00000000000000 --- a/lib/experimental/interactivity-api/class-wp-directive-processor.php +++ /dev/null @@ -1,283 +0,0 @@ -get_tag(); - - if ( self::is_html_void_element( $tag_name ) ) { - return false; - } - - while ( $this->next_tag( - array( - 'tag_name' => $tag_name, - 'tag_closers' => 'visit', - ) - ) ) { - if ( ! $this->is_tag_closer() ) { - ++$depth; - continue; - } - - if ( 0 === $depth ) { - return true; - } - - --$depth; - } - - return false; - } - - /** - * Returns the content between two balanced tags. - * - * When called on an opening tag, return the HTML content found between that - * opening tag and its matching closing tag. - * - * @return string The content between the current opening and its matching - * closing tag. - */ - public function get_inner_html() { - $bookmarks = $this->get_balanced_tag_bookmarks(); - if ( ! $bookmarks ) { - return false; - } - list( $start_name, $end_name ) = $bookmarks; - - $start = $this->bookmarks[ $start_name ]->start + $this->bookmarks[ $start_name ]->length + 1; - $end = $this->bookmarks[ $end_name ]->start; - - $this->seek( $start_name ); // Return to original position. - $this->release_bookmark( $start_name ); - $this->release_bookmark( $end_name ); - - return substr( $this->html, $start, $end - $start ); - } - - /** - * Sets the content between two balanced tags. - * - * When called on an opening tag, set the HTML content found between that - * opening tag and its matching closing tag. - * - * @param string $new_html The string to replace the content between the - * matching tags with. - * @return bool Whether the content was successfully replaced. - */ - public function set_inner_html( $new_html ) { - $this->get_updated_html(); // Apply potential previous updates. - - $bookmarks = $this->get_balanced_tag_bookmarks(); - if ( ! $bookmarks ) { - return false; - } - list( $start_name, $end_name ) = $bookmarks; - - $start = $this->bookmarks[ $start_name ]->start + $this->bookmarks[ $start_name ]->length + 1; - $end = $this->bookmarks[ $end_name ]->start; - - $this->seek( $start_name ); // Return to original position. - $this->release_bookmark( $start_name ); - $this->release_bookmark( $end_name ); - - $this->lexical_updates[] = new Gutenberg_HTML_Text_Replacement_6_5( $start, $end - $start, $new_html ); - return true; - } - - /** - * Returns a pair of bookmarks for the current opening tag and the matching - * closing tag. - * - * @return array|false A pair of bookmarks, or false if there's no matching - * closing tag. - */ - public function get_balanced_tag_bookmarks() { - $i = 0; - while ( array_key_exists( 'start' . $i, $this->bookmarks ) ) { - ++$i; - } - $start_name = 'start' . $i; - - $this->set_bookmark( $start_name ); - if ( ! $this->next_balanced_closer() ) { - $this->release_bookmark( $start_name ); - return false; - } - - $i = 0; - while ( array_key_exists( 'end' . $i, $this->bookmarks ) ) { - ++$i; - } - $end_name = 'end' . $i; - $this->set_bookmark( $end_name ); - - return array( $start_name, $end_name ); - } - - /** - * Checks whether a given HTML element is void (e.g.
). - * - * @see https://html.spec.whatwg.org/#elements-2 - * - * @param string $tag_name The element in question. - * @return bool True if the element is void. - */ - public static function is_html_void_element( $tag_name ) { - switch ( $tag_name ) { - case 'AREA': - case 'BASE': - case 'BR': - case 'COL': - case 'EMBED': - case 'HR': - case 'IMG': - case 'INPUT': - case 'LINK': - case 'META': - case 'SOURCE': - case 'TRACK': - case 'WBR': - return true; - - default: - return false; - } - } - - /** - * Extracts and return the directive type and the the part after the double - * hyphen from an attribute name (if present), in an array format. - * - * Examples: - * - * 'wp-island' => array( 'wp-island', null ) - * 'wp-bind--src' => array( 'wp-bind', 'src' ) - * 'wp-thing--and--thang' => array( 'wp-thing', 'and--thang' ) - * - * @param string $name The attribute name. - * @return array The resulting array. - */ - public static function parse_attribute_name( $name ) { - return explode( '--', $name, 2 ); - } - - /** - * Parse and extract the namespace and path from the given value. - * - * If the value contains a JSON instead of a path, the function parses it - * and returns the resulting array. - * - * @param string $value Passed value. - * @param string $ns Namespace fallback. - * @return array The resulting array - */ - public static function parse_attribute_value( $value, $ns = null ) { - $matches = array(); - $has_ns = preg_match( '/^([\w\-_\/]+)::(.+)$/', $value, $matches ); - - /* - * Overwrite both `$ns` and `$value` variables if `$value` explicitly - * contains a namespace. - */ - if ( $has_ns ) { - list( , $ns, $value ) = $matches; - } - - /* - * Try to decode `$value` as a JSON object. If it works, `$value` is - * replaced with the resulting array. The original string is preserved - * otherwise. - * - * Note that `json_decode` returns `null` both for an invalid JSON or - * the `'null'` string (a valid JSON). In the latter case, `$value` is - * replaced with `null`. - */ - $data = json_decode( $value, true ); - if ( null !== $data || 'null' === trim( $value ) ) { - $value = $data; - } - - return array( $ns, $value ); - } -} diff --git a/lib/experimental/interactivity-api/class-wp-interactivity-initial-state.php b/lib/experimental/interactivity-api/class-wp-interactivity-initial-state.php deleted file mode 100644 index 15e57edfa4a6a2..00000000000000 --- a/lib/experimental/interactivity-api/class-wp-interactivity-initial-state.php +++ /dev/null @@ -1,82 +0,0 @@ -%s', - wp_json_encode( self::$initial_state, JSON_HEX_TAG | JSON_HEX_AMP ) - ); - } -} diff --git a/lib/experimental/interactivity-api/directive-processing.php b/lib/experimental/interactivity-api/directive-processing.php deleted file mode 100644 index 5a97166d6d22bf..00000000000000 --- a/lib/experimental/interactivity-api/directive-processing.php +++ /dev/null @@ -1,214 +0,0 @@ -get_registered( $parsed_block['blockName'] ); - $is_interactive = isset( $block_type->supports['interactivity'] ) && $block_type->supports['interactivity']; - if ( $is_interactive ) { - WP_Directive_Processor::mark_interactive_root_block( $parsed_block ); - } - } - - return $parsed_block; -} -add_filter( 'render_block_data', 'gutenberg_interactivity_mark_root_interactive_blocks', 10, 1 ); - -/** - * Processes the directives in the root blocks. - * - * @param string $block_content The block content. - * @param array $block The full block. - * - * @return string Filtered block content. - */ -function gutenberg_process_directives_in_root_blocks( $block_content, $block ) { - if ( WP_Directive_Processor::is_marked_as_interactive_root_block( $block ) ) { - WP_Directive_Processor::unmark_interactive_root_block(); - $context = new WP_Directive_Context(); - $namespace_stack = array(); - return gutenberg_process_interactive_html( $block_content, $context, $namespace_stack ); - } - - return $block_content; -} -add_filter( 'render_block', 'gutenberg_process_directives_in_root_blocks', 10, 2 ); - -/** - * Processes interactive HTML by applying directives to the HTML tags. - * - * It uses the WP_Directive_Processor class to parse the HTML and apply the - * directives. If a tag contains a 'WP-INNER-BLOCKS' string and there are inner - * blocks to process, the function processes these inner blocks and replaces the - * 'WP-INNER-BLOCKS' tag in the HTML with those blocks. - * - * @param string $html The HTML to process. - * @param mixed $context The context to use when processing. - * @param array $inner_blocks The inner blocks to process. - * @param array $namespace_stack Stack of namespackes passed by reference. - * - * @return string The processed HTML. - */ -function gutenberg_process_interactive_html( $html, $context, &$namespace_stack = array() ) { - static $directives = array( - 'data-wp-interactive' => 'gutenberg_interactivity_process_wp_interactive', - 'data-wp-context' => 'gutenberg_interactivity_process_wp_context', - 'data-wp-bind' => 'gutenberg_interactivity_process_wp_bind', - 'data-wp-class' => 'gutenberg_interactivity_process_wp_class', - 'data-wp-style' => 'gutenberg_interactivity_process_wp_style', - 'data-wp-text' => 'gutenberg_interactivity_process_wp_text', - ); - - $tags = new WP_Directive_Processor( $html ); - $prefix = 'data-wp-'; - $tag_stack = array(); - while ( $tags->next_tag( array( 'tag_closers' => 'visit' ) ) ) { - $tag_name = $tags->get_tag(); - - if ( $tags->is_tag_closer() ) { - if ( 0 === count( $tag_stack ) ) { - continue; - } - list( $latest_opening_tag_name, $attributes ) = end( $tag_stack ); - if ( $latest_opening_tag_name === $tag_name ) { - array_pop( $tag_stack ); - // If the matching opening tag didn't have any directives, we move on. - if ( 0 === count( $attributes ) ) { - continue; - } - } - } else { - $attributes = array(); - foreach ( $tags->get_attribute_names_with_prefix( $prefix ) as $name ) { - /* - * Removes the part after the double hyphen before looking for - * the directive processor inside `$directives`, e.g., "wp-bind" - * from "wp-bind--src" and "wp-context" from "wp-context" etc... - */ - list( $type ) = $tags::parse_attribute_name( $name ); - if ( array_key_exists( $type, $directives ) ) { - $attributes[] = $type; - } - } - - /* - * If this is an open tag, and if it either has directives, or if - * we're inside a tag that does, take note of this tag and its - * directives so we can call its directive processor once we - * encounter the matching closing tag. - */ - if ( - ! $tags::is_html_void_element( $tag_name ) && - ( 0 !== count( $attributes ) || 0 !== count( $tag_stack ) ) - ) { - $tag_stack[] = array( $tag_name, $attributes ); - } - } - - // Extract all directive names. They'll be used later on. - $directive_names = array_keys( $directives ); - $directive_names_rev = array_reverse( $directive_names ); - - /* - * Sort attributes by the order they appear in the `$directives` - * argument, considering it as the priority order in which - * directives should be processed. Note that the order is reversed - * for tag closers. - */ - $sorted_attrs = array_intersect( - $tags->is_tag_closer() - ? $directive_names_rev - : $directive_names, - $attributes - ); - - foreach ( $sorted_attrs as $attribute ) { - call_user_func_array( - $directives[ $attribute ], - array( - $tags, - $context, - end( $namespace_stack ), - &$namespace_stack, - ) - ); - } - } - - return $tags->get_updated_html(); -} - -/** - * Resolves the passed reference from the store and the context under the given - * namespace. - * - * A reference could be either a single path or a namespace followed by a path, - * separated by two colons, i.e, `namespace::path.to.prop`. If the reference - * contains a namespace, that namespace overrides the one passed as argument. - * - * @param string $reference Reference value. - * @param string $ns Inherited namespace. - * @param array $context Context data. - * @return mixed Resolved value. - */ -function gutenberg_interactivity_evaluate_reference( $reference, $ns, array $context = array() ) { - // Extract the namespace from the reference (if present). - list( $ns, $path ) = WP_Directive_Processor::parse_attribute_value( $reference, $ns ); - - $store = array( - 'state' => WP_Interactivity_Initial_State::get_state( $ns ), - 'context' => $context[ $ns ] ?? array(), - ); - - /* - * Checks first if the directive path is preceded by a negator operator (!), - * indicating that the value obtained from the Interactivity Store (or the - * passed context) using the subsequent path should be negated. - */ - $should_negate_value = '!' === $path[0]; - $path = $should_negate_value ? substr( $path, 1 ) : $path; - $path_segments = explode( '.', $path ); - $current = $store; - foreach ( $path_segments as $p ) { - if ( isset( $current[ $p ] ) ) { - $current = $current[ $p ]; - } else { - return null; - } - } - - /* - * Checks if $current is an anonymous function or an arrow function, and if - * so, call it passing the store. Other types of callables are ignored on - * purpose, as arbitrary strings or arrays could be wrongly evaluated as - * "callables". - * - * E.g., "file" is an string and a "callable" (the "file" function exists). - */ - if ( $current instanceof Closure ) { - /* - * TODO: Figure out a way to implement derived state without having to - * pass the store as argument: - * - * $current = call_user_func( $current ); - */ - } - - // Returns the opposite if it has a negator operator (!). - return $should_negate_value ? ! $current : $current; -} diff --git a/lib/experimental/interactivity-api/directives/wp-bind.php b/lib/experimental/interactivity-api/directives/wp-bind.php deleted file mode 100644 index 57d2e5deb23ab4..00000000000000 --- a/lib/experimental/interactivity-api/directives/wp-bind.php +++ /dev/null @@ -1,33 +0,0 @@ -is_tag_closer() ) { - return; - } - - $prefixed_attributes = $tags->get_attribute_names_with_prefix( 'data-wp-bind--' ); - - foreach ( $prefixed_attributes as $attr ) { - list( , $bound_attr ) = WP_Directive_Processor::parse_attribute_name( $attr ); - if ( empty( $bound_attr ) ) { - continue; - } - - $reference = $tags->get_attribute( $attr ); - $value = gutenberg_interactivity_evaluate_reference( $reference, $ns, $context->get_context() ); - $tags->set_attribute( $bound_attr, $value ); - } -} diff --git a/lib/experimental/interactivity-api/directives/wp-class.php b/lib/experimental/interactivity-api/directives/wp-class.php deleted file mode 100644 index ef91835be86fc1..00000000000000 --- a/lib/experimental/interactivity-api/directives/wp-class.php +++ /dev/null @@ -1,37 +0,0 @@ -is_tag_closer() ) { - return; - } - - $prefixed_attributes = $tags->get_attribute_names_with_prefix( 'data-wp-class--' ); - - foreach ( $prefixed_attributes as $attr ) { - list( , $class_name ) = WP_Directive_Processor::parse_attribute_name( $attr ); - if ( empty( $class_name ) ) { - continue; - } - - $reference = $tags->get_attribute( $attr ); - $add_class = gutenberg_interactivity_evaluate_reference( $reference, $ns, $context->get_context() ); - if ( $add_class ) { - $tags->add_class( $class_name ); - } else { - $tags->remove_class( $class_name ); - } - } -} diff --git a/lib/experimental/interactivity-api/directives/wp-context.php b/lib/experimental/interactivity-api/directives/wp-context.php deleted file mode 100644 index b41b47c86c78c3..00000000000000 --- a/lib/experimental/interactivity-api/directives/wp-context.php +++ /dev/null @@ -1,30 +0,0 @@ -is_tag_closer() ) { - $context->rewind_context(); - return; - } - - $attr_value = $tags->get_attribute( 'data-wp-context' ); - - //Separate namespace and value from the context directive attribute. - list( $ns, $data ) = is_string( $attr_value ) && ! empty( $attr_value ) - ? WP_Directive_Processor::parse_attribute_value( $attr_value, $ns ) - : array( $ns, null ); - - // Add parsed data to the context under the corresponding namespace. - $context->set_context( array( $ns => is_array( $data ) ? $data : array() ) ); -} diff --git a/lib/experimental/interactivity-api/directives/wp-interactive.php b/lib/experimental/interactivity-api/directives/wp-interactive.php deleted file mode 100644 index 9f3471a8b4e6a9..00000000000000 --- a/lib/experimental/interactivity-api/directives/wp-interactive.php +++ /dev/null @@ -1,44 +0,0 @@ -is_tag_closer() ) { - array_pop( $ns_stack ); - return; - } - - /* - * Decode the data-wp-interactive attribute. In the case it is not a valid - * JSON string, NULL is stored in `$island_data`. - */ - $island = $tags->get_attribute( 'data-wp-interactive' ); - $island_data = is_string( $island ) && ! empty( $island ) - ? json_decode( $island, true ) - : null; - - /* - * Push the newly defined namespace, or the current one if the island - * definition was invalid or does not contain a namespace. - * - * This is done because the function pops out the current namespace from the - * stack whenever it finds an island's closing tag, independently of whether - * the island definition was correct or it contained a valid namespace. - */ - $ns_stack[] = isset( $island_data ) && $island_data['namespace'] - ? $island_data['namespace'] - : $ns; -} diff --git a/lib/experimental/interactivity-api/directives/wp-style.php b/lib/experimental/interactivity-api/directives/wp-style.php deleted file mode 100644 index 16432e57282606..00000000000000 --- a/lib/experimental/interactivity-api/directives/wp-style.php +++ /dev/null @@ -1,73 +0,0 @@ -is_tag_closer() ) { - return; - } - - $prefixed_attributes = $tags->get_attribute_names_with_prefix( 'data-wp-style--' ); - - foreach ( $prefixed_attributes as $attr ) { - list( , $style_name ) = WP_Directive_Processor::parse_attribute_name( $attr ); - if ( empty( $style_name ) ) { - continue; - } - - $reference = $tags->get_attribute( $attr ); - $style_value = gutenberg_interactivity_evaluate_reference( $reference, $ns, $context->get_context() ); - if ( $style_value ) { - $style_attr = $tags->get_attribute( 'style' ) ?? ''; - $style_attr = gutenberg_interactivity_set_style( $style_attr, $style_name, $style_value ); - $tags->set_attribute( 'style', $style_attr ); - } else { - // TODO: Do we want to unset styles if they're null? - } - } -} - -/** - * Set style. - * - * @param string $style Existing style to amend. - * @param string $name Style property name. - * @param string $value Style property value. - * @return string Amended styles. - */ -function gutenberg_interactivity_set_style( $style, $name, $value ) { - $style_assignments = explode( ';', $style ); - $modified = false; - foreach ( $style_assignments as $style_assignment ) { - list( $style_name ) = explode( ':', $style_assignment ); - if ( trim( $style_name ) === $name ) { - // TODO: Retain surrounding whitespace from $style_value, if any. - $style_assignment = $style_name . ': ' . $value; - $modified = true; - break; - } - } - - if ( ! $modified ) { - $new_style_assignment = $name . ': ' . $value; - // If the last element is empty or whitespace-only, we insert - // the new "key: value" pair before it. - if ( empty( trim( end( $style_assignments ) ) ) ) { - array_splice( $style_assignments, - 1, 0, $new_style_assignment ); - } else { - array_push( $style_assignments, $new_style_assignment ); - } - } - return implode( ';', $style_assignments ); -} diff --git a/lib/experimental/interactivity-api/directives/wp-text.php b/lib/experimental/interactivity-api/directives/wp-text.php deleted file mode 100644 index c4c5bb27a31e10..00000000000000 --- a/lib/experimental/interactivity-api/directives/wp-text.php +++ /dev/null @@ -1,28 +0,0 @@ -is_tag_closer() ) { - return; - } - - $value = $tags->get_attribute( 'data-wp-text' ); - if ( null === $value ) { - return; - } - - $text = gutenberg_interactivity_evaluate_reference( $value, $ns, $context->get_context() ); - $tags->set_inner_html( esc_html( $text ) ); -} diff --git a/lib/experimental/interactivity-api/initial-state.php b/lib/experimental/interactivity-api/initial-state.php deleted file mode 100644 index a38d0da631f3c4..00000000000000 --- a/lib/experimental/interactivity-api/initial-state.php +++ /dev/null @@ -1,29 +0,0 @@ - $dependency['id'], - 'type' => isset( $dependency['type'] ) && 'dynamic' === $dependency['type'] ? 'dynamic' : 'static', - ); - } elseif ( is_string( $dependency ) ) { - $deps[] = array( - 'id' => $dependency, - 'type' => 'static', - ); - } - } - - self::$registered[ $module_identifier ] = array( - 'src' => $src, - 'version' => $version, - 'enqueued' => in_array( $module_identifier, self::$enqueued_modules_before_register, true ), - 'dependencies' => $deps, - ); - } - } - - /** - * Marks the module to be enqueued in the page. - * - * @param string $module_identifier The identifier of the module. - */ - public static function enqueue( $module_identifier ) { - if ( isset( self::$registered[ $module_identifier ] ) ) { - self::$registered[ $module_identifier ]['enqueued'] = true; - } elseif ( ! in_array( $module_identifier, self::$enqueued_modules_before_register, true ) ) { - self::$enqueued_modules_before_register[] = $module_identifier; - } - } - - /** - * Unmarks the module so it is no longer enqueued in the page. - * - * @param string $module_identifier The identifier of the module. - */ - public static function dequeue( $module_identifier ) { - if ( isset( self::$registered[ $module_identifier ] ) ) { - self::$registered[ $module_identifier ]['enqueued'] = false; - } - $key = array_search( $module_identifier, self::$enqueued_modules_before_register, true ); - if ( false !== $key ) { - array_splice( self::$enqueued_modules_before_register, $key, 1 ); - } - } - - /** - * Returns the import map array. - * - * @return array Array with an 'imports' key mapping to an array of module identifiers and their respective source URLs, including the version query. - */ - public static function get_import_map() { - $imports = array(); - foreach ( self::get_dependencies( array_keys( self::get_enqueued() ) ) as $module_identifier => $module ) { - $imports[ $module_identifier ] = $module['src'] . self::get_version_query_string( $module['version'] ); - } - return array( 'imports' => $imports ); - } - - /** - * Prints the import map using a script tag with an type="importmap" attribute. - */ - public static function print_import_map() { - $import_map = self::get_import_map(); - if ( ! empty( $import_map['imports'] ) ) { - echo ''; - } - } - - /** - * Prints all the enqueued modules using ' - ); - } - - /** - * Gets the version of a module. - * - * If SCRIPT_DEBUG is true, the version is the current timestamp. If $version - * is set to false, the version number is the currently installed WordPress - * version. If $version is set to null, no version is added. - * - * @param array $version The version of the module. - * @return string A string presenting the version. - */ - private static function get_version_query_string( $version ) { - if ( defined( 'SCRIPT_DEBUG ' ) && SCRIPT_DEBUG ) { - return '?ver=' . time(); - } elseif ( false === $version ) { - return '?ver=' . get_bloginfo( 'version' ); - } elseif ( null !== $version ) { - return '?ver=' . $version; - } - return ''; - } - - /** - * Retrieves an array of enqueued modules. - * - * @return array Array of modules keyed by module identifier. - */ - private static function get_enqueued() { - $enqueued = array(); - foreach ( self::$registered as $module_identifier => $module ) { - if ( true === $module['enqueued'] ) { - $enqueued[ $module_identifier ] = $module; - } - } - return $enqueued; - } - - /** - * Retrieves all the dependencies for given modules depending on type. - * - * This method is recursive to also retrieve dependencies of the dependencies. - * It will consolidate an array containing unique dependencies based on the - * requested types ('static' or 'dynamic'). - * - * @param array $module_identifiers The identifiers of the modules for which to gather dependencies. - * @param array $types Optional. Types of dependencies to retrieve: 'static', 'dynamic', or both. Default is both. - * @return array Array of modules keyed by module identifier. - */ - private static function get_dependencies( $module_identifiers, $types = array( 'static', 'dynamic' ) ) { - return array_reduce( - $module_identifiers, - function ( $dependency_modules, $module_identifier ) use ( $types ) { - $dependencies = array(); - foreach ( self::$registered[ $module_identifier ]['dependencies'] as $dependency ) { - if ( - in_array( $dependency['type'], $types, true ) && - isset( self::$registered[ $dependency['id'] ] ) && - ! isset( $dependency_modules[ $dependency['id'] ] ) - ) { - $dependencies[ $dependency['id'] ] = self::$registered[ $dependency['id'] ]; - } - } - return array_merge( $dependency_modules, $dependencies, self::get_dependencies( array_keys( $dependencies ), $types ) ); - }, - array() - ); - } -} - -/** - * Registers the module if no module with that module identifier has already - * been registered. - * - * @param string $module_identifier The identifier of the module. Should be unique. It will be used in the final import map. - * @param string $src Full URL of the module, or path of the script relative to the WordPress root directory. - * @param array $dependencies Optional. An array of module identifiers of the dependencies of this module. The dependencies can be strings or arrays. If they are arrays, they need an `id` key with the module identifier, and can contain a `type` key with either `static` or `dynamic`. By default, dependencies that don't contain a type are considered static. - * @param string|false|null $version Optional. String specifying module version number. Defaults to false. It is added to the URL as a query string for cache busting purposes. If SCRIPT_DEBUG is true, the version is the current timestamp. If $version is set to false, the version number is the currently installed WordPress version. If $version is set to null, no version is added. - */ -function gutenberg_register_module( $module_identifier, $src, $dependencies = array(), $version = false ) { - Gutenberg_Modules::register( $module_identifier, $src, $dependencies, $version ); -} - -/** - * Marks the module to be enqueued in the page. - * - * @param string $module_identifier The identifier of the module. - */ -function gutenberg_enqueue_module( $module_identifier ) { - Gutenberg_Modules::enqueue( $module_identifier ); -} - -/** - * Unmarks the module so it is not longer enqueued in the page. - * - * @param string $module_identifier The identifier of the module. - */ -function gutenberg_dequeue_module( $module_identifier ) { - Gutenberg_Modules::dequeue( $module_identifier ); -} - -$modules_position = wp_is_block_theme() ? 'wp_head' : 'wp_footer'; -// Prints the import map in the head tag in block themes. Otherwise in the footer. -add_action( $modules_position, array( 'Gutenberg_Modules', 'print_import_map' ) ); - -// Prints the enqueued modules in the head tag in block themes. Otherwise in the footer. -add_action( $modules_position, array( 'Gutenberg_Modules', 'print_enqueued_modules' ) ); - -// Prints the preloaded modules in the head tag in block themes. Otherwise in the footer. -add_action( $modules_position, array( 'Gutenberg_Modules', 'print_module_preloads' ) ); - -// Prints the script that loads the import map polyfill in the footer. -add_action( 'wp_head', array( 'Gutenberg_Modules', 'print_import_map_polyfill' ), 11 ); - -/** - * Add module fields from block metadata to WP_Block_Type settings. - * - * This filter allows us to register modules from block metadata and attach additional fields to - * WP_Block_Type instances. - * - * @param array $settings Array of determined settings for registering a block type. - * @param array $metadata Metadata provided for registering a block type. - */ -function gutenberg_filter_block_type_metadata_settings_register_modules( $settings, $metadata = null ) { - $module_fields = array( - 'viewModule' => 'view_module_ids', - ); - foreach ( $module_fields as $metadata_field_name => $settings_field_name ) { - if ( ! empty( $settings[ $metadata_field_name ] ) ) { - $metadata[ $metadata_field_name ] = $settings[ $metadata_field_name ]; - } - if ( ! empty( $metadata[ $metadata_field_name ] ) ) { - $modules = $metadata[ $metadata_field_name ]; - $processed_modules = array(); - if ( is_array( $modules ) ) { - for ( $index = 0; $index < count( $modules ); $index++ ) { - $processed_modules[] = gutenberg_register_block_module_id( - $metadata, - $metadata_field_name, - $index - ); - } - } else { - $processed_modules[] = gutenberg_register_block_module_id( - $metadata, - $metadata_field_name - ); - } - $settings[ $settings_field_name ] = $processed_modules; - } - } - - return $settings; -} - -add_filter( 'block_type_metadata_settings', 'gutenberg_filter_block_type_metadata_settings_register_modules', 10, 2 ); - -/** - * Enqueue modules associated with the block. - * - * @param string $block_content The block content. - * @param array $block The full block, including name and attributes. - * @param WP_Block $instance The block instance. - */ -function gutenberg_filter_render_block_enqueue_view_modules( $block_content, $parsed_block, $block_instance ) { - $block_type = $block_instance->block_type; - - if ( ! empty( $block_type->view_module_ids ) ) { - foreach ( $block_type->view_module_ids as $module_id ) { - gutenberg_enqueue_module( $module_id ); - } - } - - return $block_content; -} - -add_filter( 'render_block', 'gutenberg_filter_render_block_enqueue_view_modules', 10, 3 ); - -/** - * Finds a module ID for the selected block metadata field. It detects - * when a path to file was provided and finds a corresponding asset file - * with details necessary to register the module under an automatically - * generated module ID. - * - * This is analogous to the `register_block_script_handle` in WordPress Core. - * - * @param array $metadata Block metadata. - * @param string $field_name Field name to pick from metadata. - * @param int $index Optional. Index of the script to register when multiple items passed. - * Default 0. - * @return string Module ID. - */ -function gutenberg_register_block_module_id( $metadata, $field_name, $index = 0 ) { - if ( empty( $metadata[ $field_name ] ) ) { - return false; - } - - $module_id = $metadata[ $field_name ]; - if ( is_array( $module_id ) ) { - if ( empty( $module_id[ $index ] ) ) { - return false; - } - $module_id = $module_id[ $index ]; - } - - $module_path = remove_block_asset_path_prefix( $module_id ); - if ( $module_id === $module_path ) { - return $module_id; - } - - $path = dirname( $metadata['file'] ); - $module_asset_raw_path = $path . '/' . substr_replace( $module_path, '.asset.php', - strlen( '.js' ) ); - $module_id = gutenberg_generate_block_asset_module_id( $metadata['name'], $field_name, $index ); - $module_asset_path = wp_normalize_path( realpath( $module_asset_raw_path ) ); - - if ( empty( $module_asset_path ) ) { - _doing_it_wrong( - __FUNCTION__, - sprintf( - // This string is from WordPress Core. See `register_block_script_handle`. - // Translators: This is a translation from WordPress Core (default). No need to translate. - __( 'The asset file (%1$s) for the "%2$s" defined in "%3$s" block definition is missing.', 'default' ), - $module_asset_raw_path, - $field_name, - $metadata['name'] - ), - '6.5.0' - ); - return false; - } - - $module_path_norm = wp_normalize_path( realpath( $path . '/' . $module_path ) ); - $module_uri = get_block_asset_url( $module_path_norm ); - $module_asset = require $module_asset_path; - $module_dependencies = isset( $module_asset['dependencies'] ) ? $module_asset['dependencies'] : array(); - - gutenberg_register_module( - $module_id, - $module_uri, - $module_dependencies, - isset( $module_asset['version'] ) ? $module_asset['version'] : false - ); - - return $module_id; -} - -/** - * Generates the module ID for an asset based on the name of the block - * and the field name provided. - * - * This is analogous to the `generate_block_asset_handle` in WordPress Core. - * - * @param string $block_name Name of the block. - * @param string $field_name Name of the metadata field. - * @param int $index Optional. Index of the asset when multiple items passed. - * Default 0. - * @return string Generated module ID for the block's field. - */ -function gutenberg_generate_block_asset_module_id( $block_name, $field_name, $index = 0 ) { - if ( str_starts_with( $block_name, 'core/' ) ) { - $asset_handle = str_replace( 'core/', 'wp-block-', $block_name ); - if ( str_starts_with( $field_name, 'editor' ) ) { - $asset_handle .= '-editor'; - } - if ( str_starts_with( $field_name, 'view' ) ) { - $asset_handle .= '-view'; - } - if ( $index > 0 ) { - $asset_handle .= '-' . ( $index + 1 ); - } - return $asset_handle; - } - - $field_mappings = array( - 'viewModule' => 'view-module', - ); - $asset_handle = str_replace( '/', '-', $block_name ) . - '-' . $field_mappings[ $field_name ]; - if ( $index > 0 ) { - $asset_handle .= '-' . ( $index + 1 ); - } - return $asset_handle; -} - -function gutenberg_register_view_module_ids_rest_field() { - register_rest_field( - 'block-type', - 'view_module_ids', - array( - 'get_callback' => function ( $item ) { - $block_type = WP_Block_Type_Registry::get_instance()->get_registered( $item['name'] ); - if ( isset( $block_type->view_module_ids ) ) { - return $block_type->view_module_ids; - } - return array(); - }, - ) - ); -} - -add_action( 'rest_api_init', 'gutenberg_register_view_module_ids_rest_field' ); diff --git a/lib/experimental/script-modules.php b/lib/experimental/script-modules.php new file mode 100644 index 00000000000000..1f481a8098b20c --- /dev/null +++ b/lib/experimental/script-modules.php @@ -0,0 +1,234 @@ + 'view_module_ids', + ); + foreach ( $module_fields as $metadata_field_name => $settings_field_name ) { + if ( ! empty( $settings[ $metadata_field_name ] ) ) { + $metadata[ $metadata_field_name ] = $settings[ $metadata_field_name ]; + } + if ( ! empty( $metadata[ $metadata_field_name ] ) ) { + $modules = $metadata[ $metadata_field_name ]; + $processed_modules = array(); + if ( is_array( $modules ) ) { + for ( $index = 0; $index < count( $modules ); $index++ ) { + $processed_modules[] = gutenberg_register_block_module_id( + $metadata, + $metadata_field_name, + $index + ); + } + } else { + $processed_modules[] = gutenberg_register_block_module_id( + $metadata, + $metadata_field_name + ); + } + $settings[ $settings_field_name ] = $processed_modules; + } + } + + return $settings; +} + +add_filter( 'block_type_metadata_settings', 'gutenberg_filter_block_type_metadata_settings_register_modules', 10, 2 ); + +/** + * Enqueue modules associated with the block. + * + * @param string $block_content The block content. + * @param array $parsed_block The full block, including name and attributes. + * @param WP_Block $block_instance The block instance. + */ +function gutenberg_filter_render_block_enqueue_view_modules( $block_content, $parsed_block, $block_instance ) { + $block_type = $block_instance->block_type; + + if ( ! empty( $block_type->view_module_ids ) ) { + foreach ( $block_type->view_module_ids as $module_id ) { + wp_enqueue_script_module( $module_id ); + } + } + + return $block_content; +} + +add_filter( 'render_block', 'gutenberg_filter_render_block_enqueue_view_modules', 10, 3 ); + +/** + * Finds a module ID for the selected block metadata field. It detects + * when a path to file was provided and finds a corresponding asset file + * with details necessary to register the module under an automatically + * generated module ID. + * + * This is analogous to the `register_block_script_handle` in WordPress Core. + * + * @param array $metadata Block metadata. + * @param string $field_name Field name to pick from metadata. + * @param int $index Optional. Index of the script to register when multiple items passed. + * Default 0. + * @return string Module ID. + */ +function gutenberg_register_block_module_id( $metadata, $field_name, $index = 0 ) { + if ( empty( $metadata[ $field_name ] ) ) { + return false; + } + + $module_id = $metadata[ $field_name ]; + if ( is_array( $module_id ) ) { + if ( empty( $module_id[ $index ] ) ) { + return false; + } + $module_id = $module_id[ $index ]; + } + + $module_path = remove_block_asset_path_prefix( $module_id ); + if ( $module_id === $module_path ) { + return $module_id; + } + + $path = dirname( $metadata['file'] ); + $module_asset_raw_path = $path . '/' . substr_replace( $module_path, '.asset.php', - strlen( '.js' ) ); + $module_id = gutenberg_generate_block_asset_module_id( $metadata['name'], $field_name, $index ); + $module_asset_path = wp_normalize_path( realpath( $module_asset_raw_path ) ); + + if ( empty( $module_asset_path ) ) { + _doing_it_wrong( + __FUNCTION__, + sprintf( + // This string is from WordPress Core. See `register_block_script_handle`. + // Translators: This is a translation from WordPress Core (default). No need to translate. + __( 'The asset file (%1$s) for the "%2$s" defined in "%3$s" block definition is missing.', 'default' ), + $module_asset_raw_path, + $field_name, + $metadata['name'] + ), + '6.5.0' + ); + return false; + } + + $module_path_norm = wp_normalize_path( realpath( $path . '/' . $module_path ) ); + $module_uri = get_block_asset_url( $module_path_norm ); + $module_asset = require $module_asset_path; + $module_dependencies = isset( $module_asset['dependencies'] ) ? $module_asset['dependencies'] : array(); + + wp_register_script_module( + $module_id, + $module_uri, + $module_dependencies, + isset( $module_asset['version'] ) ? $module_asset['version'] : false + ); + + return $module_id; +} + +/** + * Generates the module ID for an asset based on the name of the block + * and the field name provided. + * + * This is analogous to the `generate_block_asset_handle` in WordPress Core. + * + * @param string $block_name Name of the block. + * @param string $field_name Name of the metadata field. + * @param int $index Optional. Index of the asset when multiple items passed. + * Default 0. + * @return string Generated module ID for the block's field. + */ +function gutenberg_generate_block_asset_module_id( $block_name, $field_name, $index = 0 ) { + if ( str_starts_with( $block_name, 'core/' ) ) { + $asset_handle = str_replace( 'core/', 'wp-block-', $block_name ); + if ( str_starts_with( $field_name, 'editor' ) ) { + $asset_handle .= '-editor'; + } + if ( str_starts_with( $field_name, 'view' ) ) { + $asset_handle .= '-view'; + } + if ( $index > 0 ) { + $asset_handle .= '-' . ( $index + 1 ); + } + return $asset_handle; + } + + $field_mappings = array( + 'viewModule' => 'view-module', + ); + $asset_handle = str_replace( '/', '-', $block_name ) . + '-' . $field_mappings[ $field_name ]; + if ( $index > 0 ) { + $asset_handle .= '-' . ( $index + 1 ); + } + return $asset_handle; +} + +/** + * Registers a REST field for block types to provide view module IDs. + * + * Adds the `view_module_ids` field to block type objects in the REST API, which + * lists the script module IDs for any script modules associated with the + * block's viewModule(s) key. + * + * @since 6.5.0 + */ +function gutenberg_register_view_module_ids_rest_field() { + register_rest_field( + 'block-type', + 'view_module_ids', + array( + 'get_callback' => function ( $item ) { + $block_type = WP_Block_Type_Registry::get_instance()->get_registered( $item['name'] ); + if ( isset( $block_type->view_module_ids ) ) { + return $block_type->view_module_ids; + } + return array(); + }, + ) + ); +} + +add_action( 'rest_api_init', 'gutenberg_register_view_module_ids_rest_field' ); + +/** + * Registers the module if no module with that module identifier has already + * been registered. + * + * @param string $module_identifier The identifier of the module. Should be unique. It will be used in the final import map. + * @param string $src Full URL of the module, or path of the script relative to the WordPress root directory. + * @param array $dependencies Optional. An array of module identifiers of the dependencies of this module. The dependencies can be strings or arrays. If they are arrays, they need an `id` key with the module identifier, and can contain an `import` key with either `static` or `dynamic`. By default, dependencies that don't contain an import are considered static. + * @param string|false|null $version Optional. String specifying module version number. Defaults to false. It is added to the URL as a query string for cache busting purposes. If $version is set to false, the version number is the currently installed WordPress version. If $version is set to null, no version is added. + * @deprecated 17.6.0 gutenberg_register_module is deprecated. Please use wp_register_script_module instead. + */ +function gutenberg_register_module( $module_identifier, $src = '', $dependencies = array(), $version = false ) { + _deprecated_function( __FUNCTION__, 'Gutenberg 17.6.0', 'wp_register_script_module' ); + wp_script_modules()->register( $module_identifier, $src, $dependencies, $version ); +} + +/** + * Marks the module to be enqueued in the page. + * + * @param string $module_identifier The identifier of the module. + * @deprecated 17.6.0 gutenberg_enqueue_module is deprecated. Please use wp_enqueue_script_module instead. + */ +function gutenberg_enqueue_module( $module_identifier ) { + _deprecated_function( __FUNCTION__, 'Gutenberg 17.6.0', 'wp_enqueue_script_module' ); + wp_script_modules()->enqueue( $module_identifier ); +} + +/** + * Unmarks the module so it is not longer enqueued in the page. + * + * @param string $module_identifier The identifier of the module. + * @deprecated 17.6.0 gutenberg_dequeue_module is deprecated. Please use wp_dequeue_script_module instead. + */ +function gutenberg_dequeue_module( $module_identifier ) { + _deprecated_function( __FUNCTION__, 'Gutenberg 17.6.0', 'wp_dequeue_script_module' ); + wp_script_modules()->dequeue( $module_identifier ); +} diff --git a/lib/experiments-page.php b/lib/experiments-page.php index 8af1eb82c6bed0..bccbed2195958b 100644 --- a/lib/experiments-page.php +++ b/lib/experiments-page.php @@ -126,30 +126,6 @@ function gutenberg_initialize_experiments_settings() { ) ); - add_settings_field( - 'gutenberg-custom-fields', - __( 'Block Bindings & Custom Fields', 'gutenberg' ), - 'gutenberg_display_experiment_field', - 'gutenberg-experiments', - 'gutenberg_experiments_section', - array( - 'label' => __( 'Test connecting block attributes to different sources like custom fields', 'gutenberg' ), - 'id' => 'gutenberg-block-bindings', - ) - ); - - add_settings_field( - 'gutenberg-pattern-partial-syncing', - __( 'Pattern overrides', 'gutenberg' ), - 'gutenberg_display_experiment_field', - 'gutenberg-experiments', - 'gutenberg_experiments_section', - array( - 'label' => __( 'Test overrides in synced patterns', 'gutenberg' ), - 'id' => 'gutenberg-pattern-partial-syncing', - ) - ); - register_setting( 'gutenberg-experiments', 'gutenberg-experiments' diff --git a/lib/load.php b/lib/load.php index 8c9c8532d573ca..4b2b4d5d8b0db8 100644 --- a/lib/load.php +++ b/lib/load.php @@ -106,8 +106,17 @@ function gutenberg_is_experiment_enabled( $name ) { // WordPress 6.5 compat. require __DIR__ . '/compat/wordpress-6.5/blocks.php'; require __DIR__ . '/compat/wordpress-6.5/block-patterns.php'; -require __DIR__ . '/compat/wordpress-6.5/class-wp-navigation-block-renderer.php'; require __DIR__ . '/compat/wordpress-6.5/kses.php'; +require __DIR__ . '/compat/wordpress-6.5/interactivity-api/class-wp-interactivity-api.php'; +require __DIR__ . '/compat/wordpress-6.5/interactivity-api/class-wp-interactivity-api-directives-processor.php'; +require __DIR__ . '/compat/wordpress-6.5/interactivity-api/interactivity-api.php'; +require __DIR__ . '/compat/wordpress-6.5/class-wp-script-modules.php'; +require __DIR__ . '/compat/wordpress-6.5/scripts-modules.php'; +require __DIR__ . '/compat/wordpress-6.5/block-bindings/class-wp-block-bindings.php'; +require __DIR__ . '/compat/wordpress-6.5/block-bindings/block-bindings.php'; +require __DIR__ . '/compat/wordpress-6.5/block-bindings/sources/post-meta.php'; +require __DIR__ . '/compat/wordpress-6.5/block-bindings/sources/pattern.php'; + // Experimental features. require __DIR__ . '/experimental/block-editor-settings-mobile.php'; @@ -116,26 +125,12 @@ function gutenberg_is_experiment_enabled( $name ) { require __DIR__ . '/experimental/kses.php'; require __DIR__ . '/experimental/l10n.php'; require __DIR__ . '/experimental/synchronization.php'; +require __DIR__ . '/experimental/script-modules.php'; if ( gutenberg_is_experiment_enabled( 'gutenberg-no-tinymce' ) ) { require __DIR__ . '/experimental/disable-tinymce.php'; } -require __DIR__ . '/experimental/interactivity-api/class-wp-interactivity-initial-state.php'; -require __DIR__ . '/experimental/interactivity-api/initial-state.php'; -require __DIR__ . '/experimental/interactivity-api/modules.php'; -require __DIR__ . '/experimental/interactivity-api/class-wp-directive-processor.php'; -require __DIR__ . '/experimental/interactivity-api/class-wp-directive-context.php'; -require __DIR__ . '/experimental/interactivity-api/directive-processing.php'; -require __DIR__ . '/experimental/interactivity-api/directives/wp-bind.php'; -require __DIR__ . '/experimental/interactivity-api/directives/wp-context.php'; -require __DIR__ . '/experimental/interactivity-api/directives/wp-class.php'; -require __DIR__ . '/experimental/interactivity-api/directives/wp-style.php'; -require __DIR__ . '/experimental/interactivity-api/directives/wp-text.php'; -require __DIR__ . '/experimental/interactivity-api/directives/wp-interactive.php'; - -require __DIR__ . '/experimental/modules/class-gutenberg-modules.php'; - // Fonts API / Font Face. remove_action( 'plugins_loaded', '_wp_theme_json_webfonts_handler' ); // Turns off WordPress 6.0's stopgap handler. @@ -143,10 +138,9 @@ function gutenberg_is_experiment_enabled( $name ) { require __DIR__ . '/experimental/fonts/font-library/class-wp-font-collection.php'; require __DIR__ . '/experimental/fonts/font-library/class-wp-font-library.php'; require __DIR__ . '/experimental/fonts/font-library/class-wp-font-family-utils.php'; -require __DIR__ . '/experimental/fonts/font-library/class-wp-font-family.php'; require __DIR__ . '/experimental/fonts/font-library/class-wp-rest-font-families-controller.php'; +require __DIR__ . '/experimental/fonts/font-library/class-wp-rest-font-faces-controller.php'; require __DIR__ . '/experimental/fonts/font-library/class-wp-rest-font-collections-controller.php'; -require __DIR__ . '/experimental/fonts/font-library/class-wp-rest-autosave-font-families-controller.php'; require __DIR__ . '/experimental/fonts/font-library/font-library.php'; // Load the Font Face and Font Face Resolver, if not already loaded by WordPress Core. diff --git a/package-lock.json b/package-lock.json index 059b710d89811c..48abf7ff587d0a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -165,14 +165,14 @@ "babel-plugin-react-native-platform-specific-extensions": "1.1.1", "babel-plugin-transform-remove-console": "6.9.4", "benchmark": "2.1.4", - "browserslist": "4.21.10", + "browserslist": "4.22.2", "caniuse-lite": "1.0.30001579", "chalk": "4.1.1", "change-case": "4.1.2", "commander": "9.2.0", "concurrently": "3.5.0", "copy-webpack-plugin": "10.2.0", - "core-js-builder": "3.31.0", + "core-js-builder": "3.35.1", "cross-env": "3.2.4", "css-loader": "6.2.0", "cssnano": "6.0.1", @@ -19623,28 +19623,22 @@ "dev": true }, "node_modules/assert": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/assert/-/assert-1.5.0.tgz", - "integrity": "sha512-EDsgawzwoun2CZkCgtxJbv392v4nbk9XDD06zI+kQYoBM/3RBWLlEyJARDOmhAAosBjWACEkKL6S+lIZtcAubA==", + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/assert/-/assert-1.5.1.tgz", + "integrity": "sha512-zzw1uCAgLbsKwBfFc8CX78DDg+xZeBksSO3vwVIDDN5i94eOrPsSSyiVhmsSABFDM/OcpE2aagCat9dnWQLG1A==", "dev": true, "dependencies": { - "object-assign": "^4.1.1", - "util": "0.10.3" + "object.assign": "^4.1.4", + "util": "^0.10.4" } }, - "node_modules/assert/node_modules/inherits": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz", - "integrity": "sha512-8nWq2nLTAwd02jTqJExUYFSD/fKq6VH9Y/oG2accc/kdI0V98Bag8d5a4gi3XHz73rDWa2PvTtvcWYquKqSENA==", - "dev": true - }, "node_modules/assert/node_modules/util": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/util/-/util-0.10.3.tgz", - "integrity": "sha512-5KiHfsmkqacuKjkRkdV7SsfDJ2EGiPsK92s2MhNSY0craxjTdKTtqKsJaCWp4LW33ZZ0OPUv1WO/TFvNQRiQxQ==", + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/util/-/util-0.10.4.tgz", + "integrity": "sha512-0Pm9hTQ3se5ll1XihRic3FDIku70C+iHUdT/W926rSgHV5QgXsYbKZN8MSC3tJtSkhuROzvsQjAaFENRXr+19A==", "dev": true, "dependencies": { - "inherits": "2.0.1" + "inherits": "2.0.3" } }, "node_modules/assign-symbols": { @@ -19686,10 +19680,16 @@ "integrity": "sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ==" }, "node_modules/async-each": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/async-each/-/async-each-1.0.3.tgz", - "integrity": "sha512-z/WhQ5FPySLdvREByI2vZiTWwCnF0moMJ1hK9YQwDTHKh6I7/uSckMetoRGb5UBZPC1z0jlw+n/XCgjeH7y1AQ==", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/async-each/-/async-each-1.0.6.tgz", + "integrity": "sha512-c646jH1avxr+aVpndVMeAfYw7wAa6idufrlN3LPA4PmKS0QEGp6PIC9nwz0WQkkvBGAMEki3pFdtxaF39J9vvg==", "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ], "optional": true }, "node_modules/async-limiter": { @@ -20801,20 +20801,23 @@ } }, "node_modules/browserify-sign": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/browserify-sign/-/browserify-sign-4.2.1.tgz", - "integrity": "sha512-/vrA5fguVAKKAVTNJjgSm1tRQDHUU6DbwO9IROu/0WAzC8PKhucDSh18J0RMvVeHAn5puMd+QHC2erPRNf8lmg==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/browserify-sign/-/browserify-sign-4.2.2.tgz", + "integrity": "sha512-1rudGyeYY42Dk6texmv7c4VcQ0EsvVbLwZkA+AQB7SxvXxmcD93jcHie8bzecJ+ChDlmAm2Qyu0+Ccg5uhZXCg==", "dev": true, "dependencies": { - "bn.js": "^5.1.1", - "browserify-rsa": "^4.0.1", + "bn.js": "^5.2.1", + "browserify-rsa": "^4.1.0", "create-hash": "^1.2.0", "create-hmac": "^1.1.7", - "elliptic": "^6.5.3", + "elliptic": "^6.5.4", "inherits": "^2.0.4", - "parse-asn1": "^5.1.5", - "readable-stream": "^3.6.0", - "safe-buffer": "^5.2.0" + "parse-asn1": "^5.1.6", + "readable-stream": "^3.6.2", + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">= 4" } }, "node_modules/browserify-sign/node_modules/inherits": { @@ -20867,9 +20870,9 @@ } }, "node_modules/browserslist": { - "version": "4.21.10", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.21.10.tgz", - "integrity": "sha512-bipEBdZfVH5/pwrvqc+Ub0kUPVfGUhlKxbvfD+z1BDnPEO/X98ruXGA1WP5ASpAFKan7Qr6j736IacbZQuAlKQ==", + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.22.2.tgz", + "integrity": "sha512-0UgcrvQmBDvZHFGdYUehrCNIazki7/lUP3kkoi/r3YB2amZbFM9J43ZRkJTXBUZK4gmx56+Sqk9+Vs9mwZx9+A==", "funding": [ { "type": "opencollective", @@ -20885,10 +20888,10 @@ } ], "dependencies": { - "caniuse-lite": "^1.0.30001517", - "electron-to-chromium": "^1.4.477", - "node-releases": "^2.0.13", - "update-browserslist-db": "^1.0.11" + "caniuse-lite": "^1.0.30001565", + "electron-to-chromium": "^1.4.601", + "node-releases": "^2.0.14", + "update-browserslist-db": "^1.0.13" }, "bin": { "browserslist": "cli.js" @@ -23114,9 +23117,9 @@ } }, "node_modules/core-js": { - "version": "3.31.0", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.31.0.tgz", - "integrity": "sha512-NIp2TQSGfR6ba5aalZD+ZQ1fSxGhDo/s1w0nx3RYzf2pnJxt7YynxFlFScP6eV7+GZsKO95NSjGxyJsU3DZgeQ==", + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.35.1.tgz", + "integrity": "sha512-IgdsbxNyMskrTFxa9lWHyMwAJU5gXOPP+1yO+K59d50VLVAIDAbs7gIv705KzALModfK3ZrSZTPNpC0PQgIZuw==", "hasInstallScript": true, "funding": { "type": "opencollective", @@ -23124,15 +23127,15 @@ } }, "node_modules/core-js-builder": { - "version": "3.31.0", - "resolved": "https://registry.npmjs.org/core-js-builder/-/core-js-builder-3.31.0.tgz", - "integrity": "sha512-qx8vgRM3U4+IjlMqNQl7Vj53ectTm1FpzJ+nJSQuT865StCXvusxCO+HuASWIKlkoc+96AfnFa2MEdqvGep9nA==", + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/core-js-builder/-/core-js-builder-3.35.1.tgz", + "integrity": "sha512-CfIvg+khWyS7ElbRXhuQH9QVUQxRDFl8AUwP08BxubJmYtRZuqwjwvc/DVnwRXQ6sqg8ghfNRgnUDWzwNhU/Rw==", "dev": true, "dependencies": { - "core-js": "3.31.0", - "core-js-compat": "3.31.0", + "core-js": "3.35.1", + "core-js-compat": "3.35.1", "mkdirp": ">=0.5.5 <1", - "webpack": ">=4.46.0 <5" + "webpack": ">=4.47.0 <5" }, "engines": { "node": ">=8.9.0" @@ -23327,9 +23330,9 @@ } }, "node_modules/core-js-builder/node_modules/webpack": { - "version": "4.46.0", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-4.46.0.tgz", - "integrity": "sha512-6jJuJjg8znb/xRItk7bkT0+Q7AHCYjjFnvKIWQPkNIOyRqoCGvkOs0ipeQzrqz4l5FtN5ZI/ukEHroeX/o1/5Q==", + "version": "4.47.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-4.47.0.tgz", + "integrity": "sha512-td7fYwgLSrky3fI1EuU5cneU4+pbH6GgOfuKNS1tNPcfdGinGELAqsb/BP4nnvZyKSG2i/xFGU7+n2PvZA8HJQ==", "dev": true, "dependencies": { "@webassemblyjs/ast": "1.9.0", @@ -23386,11 +23389,11 @@ } }, "node_modules/core-js-compat": { - "version": "3.31.0", - "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.31.0.tgz", - "integrity": "sha512-hM7YCu1cU6Opx7MXNu0NuumM0ezNeAeRKadixyiQELWY3vT3De9S4J5ZBMraWV2vZnrE1Cirl0GtFtDtMUXzPw==", + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.35.1.tgz", + "integrity": "sha512-sftHa5qUJY3rs9Zht1WEnmkvXputCyDBczPnr7QDgL8n3qrF3CMXY4VPSYtOLLiOUJcah2WNXREd48iOl6mQIw==", "dependencies": { - "browserslist": "^4.21.5" + "browserslist": "^4.22.2" }, "funding": { "type": "opencollective", @@ -23983,9 +23986,9 @@ } }, "node_modules/cyclist": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/cyclist/-/cyclist-0.2.2.tgz", - "integrity": "sha1-GzN5LhHpFKL9bW7WRHRkRE5fpkA=", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cyclist/-/cyclist-1.0.2.tgz", + "integrity": "sha512-0sVXIohTfLqVIW3kb/0n6IiWF3Ifj5nm2XaSrLq2DI6fKIGa2fYAZdk917rUneaeLVpYfFcyXE2ft0fe3remsA==", "dev": true }, "node_modules/damerau-levenshtein": { @@ -24891,9 +24894,9 @@ } }, "node_modules/des.js": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/des.js/-/des.js-1.0.1.tgz", - "integrity": "sha512-Q0I4pfFrv2VPd34/vfLrFOoRmlYj3OV50i7fskps1jZWK1kApMWWT9G6RRUeYedLcBDIhnSDaUvJMb3AhUlaEA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/des.js/-/des.js-1.1.0.tgz", + "integrity": "sha512-r17GxjhUCjSRy8aiJpr8/UadFIzMzJGexI3Nmz4ADi9LYSFx4gTBp80+NaX/YsXWWLhpZ7v/v/ubEc/bCNfKwg==", "dev": true, "dependencies": { "inherits": "^2.0.1", @@ -25559,9 +25562,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.4.525", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.525.tgz", - "integrity": "sha512-GIZ620hDK4YmIqAWkscG4W6RwY6gOx1y5J6f4JUQwctiJrqH2oxZYU4mXHi35oV32tr630UcepBzSBGJ/WYcZA==" + "version": "1.4.643", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.643.tgz", + "integrity": "sha512-QHscvvS7gt155PtoRC0dR2ilhL8E9LHhfTQEq1uD5AL0524rBLAwpAREFH06f87/e45B9XkR6Ki5dbhbCsVEIg==" }, "node_modules/elegant-spinner": { "version": "1.0.1", @@ -27691,9 +27694,10 @@ "dev": true }, "node_modules/figgy-pudding": { - "version": "3.5.1", - "resolved": "https://registry.npmjs.org/figgy-pudding/-/figgy-pudding-3.5.1.tgz", - "integrity": "sha512-vNKxJHTEKNThjfrdJwHc7brvM6eVevuO5nTj6ez8ZQ1qbXTvGthucRF7S4vf2cr71QVnT70V34v0S1DyQsti0w==", + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/figgy-pudding/-/figgy-pudding-3.5.2.tgz", + "integrity": "sha512-0btnI/H8f2pavGMN8w40mlSKOfTK2SVJmBfBeVIj3kNw0swwgzyRq0d5TJVOwodFmtvpPeWPN/MCcfuWF0Ezbw==", + "deprecated": "This module is no longer supported.", "dev": true }, "node_modules/figures": { @@ -28116,13 +28120,13 @@ } }, "node_modules/flush-write-stream": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/flush-write-stream/-/flush-write-stream-1.0.3.tgz", - "integrity": "sha512-calZMC10u0FMUqoiunI2AiGIIUtUIvifNwkHhNupZH4cbNnW1Itkoh/Nf5HFYmDrwWPjrUxpkZT0KhuCq0jmGw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/flush-write-stream/-/flush-write-stream-1.1.1.tgz", + "integrity": "sha512-3Z4XhFZ3992uIq0XOqb9AreonueSYphE6oYbpt5+3u06JWklbsPkNv3ZKkP9Bz/r+1MWCaMoSQ28P85+1Yc77w==", "dev": true, "dependencies": { - "inherits": "^2.0.1", - "readable-stream": "^2.0.4" + "inherits": "^2.0.3", + "readable-stream": "^2.3.6" } }, "node_modules/fn.name": { @@ -39413,9 +39417,9 @@ "dev": true }, "node_modules/nan": { - "version": "2.14.2", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.14.2.tgz", - "integrity": "sha512-M2ufzIiINKCuDfBSAUr1vWQ+vuVcA9kqx8JJUsbQi6yf1uGRyb7HfpdfUr5qLXf3B/t8dPvcjhKMmlfnP47EzQ==", + "version": "2.18.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.18.0.tgz", + "integrity": "sha512-W7tfG7vMOGtD30sHoZSSc/JVYiyDPEyQVso/Zz+/uQd0B0L46gtC+pHha5FFMRpil6fm/AoEcRWyOVi4+E/f8w==", "dev": true, "optional": true }, @@ -39736,9 +39740,9 @@ "dev": true }, "node_modules/node-releases": { - "version": "2.0.13", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.13.tgz", - "integrity": "sha512-uYr7J37ae/ORWdZeQ1xxMJe3NtdmqMC/JZK+geofDrkLUApKRHPd18/TxtBOJ4A0/+uUIliorNrfYV6s1b02eQ==" + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz", + "integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==" }, "node_modules/node-stream-zip": { "version": "1.15.0", @@ -42114,12 +42118,12 @@ "dev": true }, "node_modules/parallel-transform": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/parallel-transform/-/parallel-transform-1.1.0.tgz", - "integrity": "sha1-1BDwZbBdojCB/NEPKIVMKb2jOwY=", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/parallel-transform/-/parallel-transform-1.2.0.tgz", + "integrity": "sha512-P2vSmIu38uIlvdcU7fDkyrxj33gTUy/ABO5ZUbGowxNCopBq/OoD42bP4UmMrJoPyk4Uqf0mu3mtWBhHCZD8yg==", "dev": true, "dependencies": { - "cyclist": "~0.2.2", + "cyclist": "^1.0.1", "inherits": "^2.0.3", "readable-stream": "^2.1.5" } @@ -48096,9 +48100,9 @@ } }, "node_modules/source-list-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/source-list-map/-/source-list-map-2.0.0.tgz", - "integrity": "sha512-I2UmuJSRr/T8jisiROLU3A3ltr+swpniSmNPI4Ml3ZCX6tVnDsuZzK7F2hl5jTqbZBWCEKlj5HRQiPExXLgE8A==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/source-list-map/-/source-list-map-2.0.1.tgz", + "integrity": "sha512-qnQ7gVMxGNxsiL4lEuJwe/To8UnK7fAnmbGEEH8RpLouuKbeEm0lhbQVFIrNSuB+G7tVrAlVsZgETT5nljf+Iw==", "dev": true }, "node_modules/source-map": { @@ -48527,9 +48531,9 @@ } }, "node_modules/stream-each": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/stream-each/-/stream-each-1.2.2.tgz", - "integrity": "sha512-mc1dbFhGBxvTM3bIWmAAINbqiuAk9TATcfIQC8P+/+HJefgaiTlMn2dHvkX8qlI12KeYKSQ1Ua9RrIqrn1VPoA==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/stream-each/-/stream-each-1.2.3.tgz", + "integrity": "sha512-vlMC2f8I2u/bZGqkdfLQW/13Zihpej/7PmSiMQsbYddxuTsJp8vRe2x2FvVExZg7FaOds43ROAuFJwPR4MTZLw==", "dev": true, "dependencies": { "end-of-stream": "^1.1.0", @@ -50950,9 +50954,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.11.tgz", - "integrity": "sha512-dCwEFf0/oT85M1fHBg4F0jtLwJrutGoHSQXCh7u4o2t1drG+c0a9Flnqww6XUKSfQMPpJBRjU8d4RXB09qtvaA==", + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz", + "integrity": "sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==", "funding": [ { "type": "opencollective", @@ -55702,7 +55706,7 @@ }, "packages/react-native-aztec": { "name": "@wordpress/react-native-aztec", - "version": "1.111.0", + "version": "1.111.1", "license": "GPL-2.0-or-later", "dependencies": { "@wordpress/element": "file:../element", @@ -55715,7 +55719,7 @@ }, "packages/react-native-bridge": { "name": "@wordpress/react-native-bridge", - "version": "1.111.0", + "version": "1.111.1", "license": "GPL-2.0-or-later", "dependencies": { "@wordpress/react-native-aztec": "file:../react-native-aztec" @@ -55726,7 +55730,7 @@ }, "packages/react-native-editor": { "name": "@wordpress/react-native-editor", - "version": "1.111.0", + "version": "1.111.1", "hasInstallScript": true, "license": "GPL-2.0-or-later", "dependencies": { @@ -71933,28 +71937,22 @@ } }, "assert": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/assert/-/assert-1.5.0.tgz", - "integrity": "sha512-EDsgawzwoun2CZkCgtxJbv392v4nbk9XDD06zI+kQYoBM/3RBWLlEyJARDOmhAAosBjWACEkKL6S+lIZtcAubA==", + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/assert/-/assert-1.5.1.tgz", + "integrity": "sha512-zzw1uCAgLbsKwBfFc8CX78DDg+xZeBksSO3vwVIDDN5i94eOrPsSSyiVhmsSABFDM/OcpE2aagCat9dnWQLG1A==", "dev": true, "requires": { - "object-assign": "^4.1.1", - "util": "0.10.3" + "object.assign": "^4.1.4", + "util": "^0.10.4" }, "dependencies": { - "inherits": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz", - "integrity": "sha512-8nWq2nLTAwd02jTqJExUYFSD/fKq6VH9Y/oG2accc/kdI0V98Bag8d5a4gi3XHz73rDWa2PvTtvcWYquKqSENA==", - "dev": true - }, "util": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/util/-/util-0.10.3.tgz", - "integrity": "sha512-5KiHfsmkqacuKjkRkdV7SsfDJ2EGiPsK92s2MhNSY0craxjTdKTtqKsJaCWp4LW33ZZ0OPUv1WO/TFvNQRiQxQ==", + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/util/-/util-0.10.4.tgz", + "integrity": "sha512-0Pm9hTQ3se5ll1XihRic3FDIku70C+iHUdT/W926rSgHV5QgXsYbKZN8MSC3tJtSkhuROzvsQjAaFENRXr+19A==", "dev": true, "requires": { - "inherits": "2.0.1" + "inherits": "2.0.3" } } } @@ -71989,9 +71987,9 @@ "integrity": "sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ==" }, "async-each": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/async-each/-/async-each-1.0.3.tgz", - "integrity": "sha512-z/WhQ5FPySLdvREByI2vZiTWwCnF0moMJ1hK9YQwDTHKh6I7/uSckMetoRGb5UBZPC1z0jlw+n/XCgjeH7y1AQ==", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/async-each/-/async-each-1.0.6.tgz", + "integrity": "sha512-c646jH1avxr+aVpndVMeAfYw7wAa6idufrlN3LPA4PmKS0QEGp6PIC9nwz0WQkkvBGAMEki3pFdtxaF39J9vvg==", "dev": true, "optional": true }, @@ -72876,20 +72874,20 @@ } }, "browserify-sign": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/browserify-sign/-/browserify-sign-4.2.1.tgz", - "integrity": "sha512-/vrA5fguVAKKAVTNJjgSm1tRQDHUU6DbwO9IROu/0WAzC8PKhucDSh18J0RMvVeHAn5puMd+QHC2erPRNf8lmg==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/browserify-sign/-/browserify-sign-4.2.2.tgz", + "integrity": "sha512-1rudGyeYY42Dk6texmv7c4VcQ0EsvVbLwZkA+AQB7SxvXxmcD93jcHie8bzecJ+ChDlmAm2Qyu0+Ccg5uhZXCg==", "dev": true, "requires": { - "bn.js": "^5.1.1", - "browserify-rsa": "^4.0.1", + "bn.js": "^5.2.1", + "browserify-rsa": "^4.1.0", "create-hash": "^1.2.0", "create-hmac": "^1.1.7", - "elliptic": "^6.5.3", + "elliptic": "^6.5.4", "inherits": "^2.0.4", - "parse-asn1": "^5.1.5", - "readable-stream": "^3.6.0", - "safe-buffer": "^5.2.0" + "parse-asn1": "^5.1.6", + "readable-stream": "^3.6.2", + "safe-buffer": "^5.2.1" }, "dependencies": { "inherits": { @@ -72927,14 +72925,14 @@ } }, "browserslist": { - "version": "4.21.10", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.21.10.tgz", - "integrity": "sha512-bipEBdZfVH5/pwrvqc+Ub0kUPVfGUhlKxbvfD+z1BDnPEO/X98ruXGA1WP5ASpAFKan7Qr6j736IacbZQuAlKQ==", + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.22.2.tgz", + "integrity": "sha512-0UgcrvQmBDvZHFGdYUehrCNIazki7/lUP3kkoi/r3YB2amZbFM9J43ZRkJTXBUZK4gmx56+Sqk9+Vs9mwZx9+A==", "requires": { - "caniuse-lite": "^1.0.30001517", - "electron-to-chromium": "^1.4.477", - "node-releases": "^2.0.13", - "update-browserslist-db": "^1.0.11" + "caniuse-lite": "^1.0.30001565", + "electron-to-chromium": "^1.4.601", + "node-releases": "^2.0.14", + "update-browserslist-db": "^1.0.13" } }, "bser": { @@ -74672,20 +74670,20 @@ } }, "core-js": { - "version": "3.31.0", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.31.0.tgz", - "integrity": "sha512-NIp2TQSGfR6ba5aalZD+ZQ1fSxGhDo/s1w0nx3RYzf2pnJxt7YynxFlFScP6eV7+GZsKO95NSjGxyJsU3DZgeQ==" + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.35.1.tgz", + "integrity": "sha512-IgdsbxNyMskrTFxa9lWHyMwAJU5gXOPP+1yO+K59d50VLVAIDAbs7gIv705KzALModfK3ZrSZTPNpC0PQgIZuw==" }, "core-js-builder": { - "version": "3.31.0", - "resolved": "https://registry.npmjs.org/core-js-builder/-/core-js-builder-3.31.0.tgz", - "integrity": "sha512-qx8vgRM3U4+IjlMqNQl7Vj53ectTm1FpzJ+nJSQuT865StCXvusxCO+HuASWIKlkoc+96AfnFa2MEdqvGep9nA==", + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/core-js-builder/-/core-js-builder-3.35.1.tgz", + "integrity": "sha512-CfIvg+khWyS7ElbRXhuQH9QVUQxRDFl8AUwP08BxubJmYtRZuqwjwvc/DVnwRXQ6sqg8ghfNRgnUDWzwNhU/Rw==", "dev": true, "requires": { - "core-js": "3.31.0", - "core-js-compat": "3.31.0", + "core-js": "3.35.1", + "core-js-compat": "3.35.1", "mkdirp": ">=0.5.5 <1", - "webpack": ">=4.46.0 <5" + "webpack": ">=4.47.0 <5" }, "dependencies": { "ajv": { @@ -74837,9 +74835,9 @@ } }, "webpack": { - "version": "4.46.0", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-4.46.0.tgz", - "integrity": "sha512-6jJuJjg8znb/xRItk7bkT0+Q7AHCYjjFnvKIWQPkNIOyRqoCGvkOs0ipeQzrqz4l5FtN5ZI/ukEHroeX/o1/5Q==", + "version": "4.47.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-4.47.0.tgz", + "integrity": "sha512-td7fYwgLSrky3fI1EuU5cneU4+pbH6GgOfuKNS1tNPcfdGinGELAqsb/BP4nnvZyKSG2i/xFGU7+n2PvZA8HJQ==", "dev": true, "requires": { "@webassemblyjs/ast": "1.9.0", @@ -74880,11 +74878,11 @@ } }, "core-js-compat": { - "version": "3.31.0", - "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.31.0.tgz", - "integrity": "sha512-hM7YCu1cU6Opx7MXNu0NuumM0ezNeAeRKadixyiQELWY3vT3De9S4J5ZBMraWV2vZnrE1Cirl0GtFtDtMUXzPw==", + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.35.1.tgz", + "integrity": "sha512-sftHa5qUJY3rs9Zht1WEnmkvXputCyDBczPnr7QDgL8n3qrF3CMXY4VPSYtOLLiOUJcah2WNXREd48iOl6mQIw==", "requires": { - "browserslist": "^4.21.5" + "browserslist": "^4.22.2" } }, "core-js-pure": { @@ -75341,9 +75339,9 @@ } }, "cyclist": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/cyclist/-/cyclist-0.2.2.tgz", - "integrity": "sha1-GzN5LhHpFKL9bW7WRHRkRE5fpkA=", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cyclist/-/cyclist-1.0.2.tgz", + "integrity": "sha512-0sVXIohTfLqVIW3kb/0n6IiWF3Ifj5nm2XaSrLq2DI6fKIGa2fYAZdk917rUneaeLVpYfFcyXE2ft0fe3remsA==", "dev": true }, "damerau-levenshtein": { @@ -76002,9 +76000,9 @@ "dev": true }, "des.js": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/des.js/-/des.js-1.0.1.tgz", - "integrity": "sha512-Q0I4pfFrv2VPd34/vfLrFOoRmlYj3OV50i7fskps1jZWK1kApMWWT9G6RRUeYedLcBDIhnSDaUvJMb3AhUlaEA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/des.js/-/des.js-1.1.0.tgz", + "integrity": "sha512-r17GxjhUCjSRy8aiJpr8/UadFIzMzJGexI3Nmz4ADi9LYSFx4gTBp80+NaX/YsXWWLhpZ7v/v/ubEc/bCNfKwg==", "dev": true, "requires": { "inherits": "^2.0.1", @@ -76515,9 +76513,9 @@ } }, "electron-to-chromium": { - "version": "1.4.525", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.525.tgz", - "integrity": "sha512-GIZ620hDK4YmIqAWkscG4W6RwY6gOx1y5J6f4JUQwctiJrqH2oxZYU4mXHi35oV32tr630UcepBzSBGJ/WYcZA==" + "version": "1.4.643", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.643.tgz", + "integrity": "sha512-QHscvvS7gt155PtoRC0dR2ilhL8E9LHhfTQEq1uD5AL0524rBLAwpAREFH06f87/e45B9XkR6Ki5dbhbCsVEIg==" }, "elegant-spinner": { "version": "1.0.1", @@ -78157,9 +78155,9 @@ "dev": true }, "figgy-pudding": { - "version": "3.5.1", - "resolved": "https://registry.npmjs.org/figgy-pudding/-/figgy-pudding-3.5.1.tgz", - "integrity": "sha512-vNKxJHTEKNThjfrdJwHc7brvM6eVevuO5nTj6ez8ZQ1qbXTvGthucRF7S4vf2cr71QVnT70V34v0S1DyQsti0w==", + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/figgy-pudding/-/figgy-pudding-3.5.2.tgz", + "integrity": "sha512-0btnI/H8f2pavGMN8w40mlSKOfTK2SVJmBfBeVIj3kNw0swwgzyRq0d5TJVOwodFmtvpPeWPN/MCcfuWF0Ezbw==", "dev": true }, "figures": { @@ -78496,13 +78494,13 @@ "integrity": "sha512-2hJ5ACYeJCzNtiVULov6pljKOLygy0zddoqSI1fFetM+XRPpRshFdGEijtqlamA1XwyZ+7rhryI6FQFzvtLWUQ==" }, "flush-write-stream": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/flush-write-stream/-/flush-write-stream-1.0.3.tgz", - "integrity": "sha512-calZMC10u0FMUqoiunI2AiGIIUtUIvifNwkHhNupZH4cbNnW1Itkoh/Nf5HFYmDrwWPjrUxpkZT0KhuCq0jmGw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/flush-write-stream/-/flush-write-stream-1.1.1.tgz", + "integrity": "sha512-3Z4XhFZ3992uIq0XOqb9AreonueSYphE6oYbpt5+3u06JWklbsPkNv3ZKkP9Bz/r+1MWCaMoSQ28P85+1Yc77w==", "dev": true, "requires": { - "inherits": "^2.0.1", - "readable-stream": "^2.0.4" + "inherits": "^2.0.3", + "readable-stream": "^2.3.6" } }, "fn.name": { @@ -87175,9 +87173,9 @@ "dev": true }, "nan": { - "version": "2.14.2", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.14.2.tgz", - "integrity": "sha512-M2ufzIiINKCuDfBSAUr1vWQ+vuVcA9kqx8JJUsbQi6yf1uGRyb7HfpdfUr5qLXf3B/t8dPvcjhKMmlfnP47EzQ==", + "version": "2.18.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.18.0.tgz", + "integrity": "sha512-W7tfG7vMOGtD30sHoZSSc/JVYiyDPEyQVso/Zz+/uQd0B0L46gtC+pHha5FFMRpil6fm/AoEcRWyOVi4+E/f8w==", "dev": true, "optional": true }, @@ -87424,9 +87422,9 @@ "dev": true }, "node-releases": { - "version": "2.0.13", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.13.tgz", - "integrity": "sha512-uYr7J37ae/ORWdZeQ1xxMJe3NtdmqMC/JZK+geofDrkLUApKRHPd18/TxtBOJ4A0/+uUIliorNrfYV6s1b02eQ==" + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz", + "integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==" }, "node-stream-zip": { "version": "1.15.0", @@ -89220,12 +89218,12 @@ "dev": true }, "parallel-transform": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/parallel-transform/-/parallel-transform-1.1.0.tgz", - "integrity": "sha1-1BDwZbBdojCB/NEPKIVMKb2jOwY=", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/parallel-transform/-/parallel-transform-1.2.0.tgz", + "integrity": "sha512-P2vSmIu38uIlvdcU7fDkyrxj33gTUy/ABO5ZUbGowxNCopBq/OoD42bP4UmMrJoPyk4Uqf0mu3mtWBhHCZD8yg==", "dev": true, "requires": { - "cyclist": "~0.2.2", + "cyclist": "^1.0.1", "inherits": "^2.0.3", "readable-stream": "^2.1.5" } @@ -93765,9 +93763,9 @@ } }, "source-list-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/source-list-map/-/source-list-map-2.0.0.tgz", - "integrity": "sha512-I2UmuJSRr/T8jisiROLU3A3ltr+swpniSmNPI4Ml3ZCX6tVnDsuZzK7F2hl5jTqbZBWCEKlj5HRQiPExXLgE8A==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/source-list-map/-/source-list-map-2.0.1.tgz", + "integrity": "sha512-qnQ7gVMxGNxsiL4lEuJwe/To8UnK7fAnmbGEEH8RpLouuKbeEm0lhbQVFIrNSuB+G7tVrAlVsZgETT5nljf+Iw==", "dev": true }, "source-map": { @@ -94105,9 +94103,9 @@ } }, "stream-each": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/stream-each/-/stream-each-1.2.2.tgz", - "integrity": "sha512-mc1dbFhGBxvTM3bIWmAAINbqiuAk9TATcfIQC8P+/+HJefgaiTlMn2dHvkX8qlI12KeYKSQ1Ua9RrIqrn1VPoA==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/stream-each/-/stream-each-1.2.3.tgz", + "integrity": "sha512-vlMC2f8I2u/bZGqkdfLQW/13Zihpej/7PmSiMQsbYddxuTsJp8vRe2x2FvVExZg7FaOds43ROAuFJwPR4MTZLw==", "dev": true, "requires": { "end-of-stream": "^1.1.0", @@ -95969,9 +95967,9 @@ "optional": true }, "update-browserslist-db": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.11.tgz", - "integrity": "sha512-dCwEFf0/oT85M1fHBg4F0jtLwJrutGoHSQXCh7u4o2t1drG+c0a9Flnqww6XUKSfQMPpJBRjU8d4RXB09qtvaA==", + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz", + "integrity": "sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==", "requires": { "escalade": "^3.1.1", "picocolors": "^1.0.0" diff --git a/package.json b/package.json index 7d44eda369d687..8550c6ff3f9603 100644 --- a/package.json +++ b/package.json @@ -177,14 +177,14 @@ "babel-plugin-react-native-platform-specific-extensions": "1.1.1", "babel-plugin-transform-remove-console": "6.9.4", "benchmark": "2.1.4", - "browserslist": "4.21.10", + "browserslist": "4.22.2", "caniuse-lite": "1.0.30001579", "chalk": "4.1.1", "change-case": "4.1.2", "commander": "9.2.0", "concurrently": "3.5.0", "copy-webpack-plugin": "10.2.0", - "core-js-builder": "3.31.0", + "core-js-builder": "3.35.1", "cross-env": "3.2.4", "css-loader": "6.2.0", "cssnano": "6.0.1", diff --git a/packages/babel-preset-default/bin/index.js b/packages/babel-preset-default/bin/index.js index a2d86233565138..54c35564d43d74 100755 --- a/packages/babel-preset-default/bin/index.js +++ b/packages/babel-preset-default/bin/index.js @@ -8,17 +8,13 @@ const { minify } = require( 'uglify-js' ); const { writeFile } = require( 'fs' ).promises; builder( { - modules: [ 'es', 'web' ], + modules: [ 'es.', 'web.' ], exclude: [ - // core-js is extremely conservative in which polyfills to include. - // Since we don't care about the tiny browser implementation bugs behind its decision - // to polyfill these features, we forcefully prevent them from being included. - // @see https://github.com/WordPress/gutenberg/pull/31279 - 'es.promise', // This is an IE-only feature which we don't use, and don't want to polyfill. // @see https://github.com/WordPress/gutenberg/pull/49234 'web.immediate', ], + summary: { console: { size: true, modules: true } }, targets: require( '@wordpress/browserslist-config' ), filename: './build/polyfill.js', } ) diff --git a/packages/block-editor/CHANGELOG.md b/packages/block-editor/CHANGELOG.md index a66143dd30ac92..ec4793ec970ee7 100644 --- a/packages/block-editor/CHANGELOG.md +++ b/packages/block-editor/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- Deprecated `__experimentalRecursionProvider` and `__experimentalUseHasRecursion` in favor of their new stable counterparts `RecursionProvider` and `useHasRecursion`. + ## 12.17.0 (2024-01-10) ## 12.16.0 (2023-12-13) diff --git a/packages/block-editor/README.md b/packages/block-editor/README.md index 5917ac235505cb..6fc51d84512122 100644 --- a/packages/block-editor/README.md +++ b/packages/block-editor/README.md @@ -707,6 +707,23 @@ _Related_ Private @wordpress/block-editor APIs. +### RecursionProvider + +A React context provider for use with the `useHasRecursion` hook to prevent recursive renders. + +Wrap block content with this provider and provide the same `uniqueId` prop as used with `useHasRecursion`. + +_Parameters_ + +- _props_ `Object`: +- _props.uniqueId_ `*`: Any value that acts as a unique identifier for a block instance. +- _props.blockName_ `string`: Optional block name. +- _props.children_ `JSX.Element`: React children. + +_Returns_ + +- `JSX.Element`: A React element. + ### ReusableBlocksRenameHint Undocumented declaration. @@ -941,6 +958,21 @@ _Returns_ - `any`: value +### useHasRecursion + +A React hook for keeping track of blocks previously rendered up in the block tree. Blocks susceptible to recursion can use this hook in their `Edit` function to prevent said recursion. + +Use this with the `RecursionProvider` component, using the same `uniqueId` value for both the hook and the provider. + +_Parameters_ + +- _uniqueId_ `*`: Any value that acts as a unique identifier for a block instance. +- _blockName_ `string`: Optional block name. + +_Returns_ + +- `boolean`: A boolean describing whether the provided id has already been rendered. + ### useInnerBlocksProps This hook is used to lightly mark an element as an inner blocks wrapper element. Call this hook and pass the returned props to the element to mark as an inner blocks wrapper, automatically rendering inner blocks as children. If you define a ref for the element, it is important to pass the ref to this hook, which the hook in turn will pass to the component through the props it returns. Optionally, you can also pass any other props through this hook, and they will be merged and returned. diff --git a/packages/block-editor/src/components/block-inspector/index.js b/packages/block-editor/src/components/block-inspector/index.js index 9cb3b89a7bc252..a52b8b9a01507f 100644 --- a/packages/block-editor/src/components/block-inspector/index.js +++ b/packages/block-editor/src/components/block-inspector/index.js @@ -93,8 +93,7 @@ const BlockInspector = ( { showNoBlockSelectedMessage = true } ) => { topLevelLockedBlock: __unstableGetContentLockingParent( _selectedBlockClientId ) || ( getTemplateLock( _selectedBlockClientId ) === 'contentOnly' || - ( _selectedBlockName === 'core/block' && - window.__experimentalPatternPartialSyncing ) + _selectedBlockName === 'core/block' ? _selectedBlockClientId : undefined ), }; @@ -307,6 +306,10 @@ const BlockInspectorSingleBlock = ( { clientId, blockName } ) => { label={ __( 'Background' ) } /> +
diff --git a/packages/block-editor/src/components/block-list/content.scss b/packages/block-editor/src/components/block-list/content.scss index 2b778c0892cfa5..a650710bfb119a 100644 --- a/packages/block-editor/src/components/block-list/content.scss +++ b/packages/block-editor/src/components/block-list/content.scss @@ -199,9 +199,6 @@ _::-webkit-full-page-media, _:future, :root .has-multi-selection .block-editor-b width: 100%; .components-notice { - margin-left: 0; - margin-right: 0; - .components-notice__content { font-size: $default-font-size; } diff --git a/packages/block-editor/src/components/block-list/use-in-between-inserter.js b/packages/block-editor/src/components/block-list/use-in-between-inserter.js index bd323ed057d733..044e5b185a2244 100644 --- a/packages/block-editor/src/components/block-list/use-in-between-inserter.js +++ b/packages/block-editor/src/components/block-list/use-in-between-inserter.js @@ -77,8 +77,7 @@ export function useInBetweenInserter() { if ( getTemplateLock( rootClientId ) || getBlockEditingMode( rootClientId ) === 'disabled' || - ( getBlockName( rootClientId ) === 'core/block' && - window.__experimentalPatternPartialSyncing ) + getBlockName( rootClientId ) === 'core/block' ) { return; } diff --git a/packages/block-editor/src/components/block-popover/inbetween.js b/packages/block-editor/src/components/block-popover/inbetween.js index a0175c4d4ae584..afc983b1a4196e 100644 --- a/packages/block-editor/src/components/block-popover/inbetween.js +++ b/packages/block-editor/src/components/block-popover/inbetween.js @@ -33,6 +33,8 @@ function BlockPopoverInbetween( { children, __unstablePopoverSlot, __unstableContentRef, + operation = 'insert', + nearestSide = 'right', ...props } ) { // This is a temporary hack to get the inbetween inserter to recompute properly. @@ -81,7 +83,10 @@ function BlockPopoverInbetween( { return undefined; } - const contextElement = previousElement || nextElement; + const contextElement = + operation === 'group' + ? nextElement || previousElement + : previousElement || nextElement; return { contextElement, @@ -98,7 +103,20 @@ function BlockPopoverInbetween( { let width = 0; let height = 0; - if ( isVertical ) { + if ( operation === 'group' ) { + const targetRect = nextRect || previousRect; + top = targetRect.top; + // No spacing is likely around blocks in this operation. + // So width of the inserter containing rect is set to 0. + width = 0; + height = targetRect.bottom - targetRect.top; + // Popover calculates its distance from mid-block so some + // adjustments are needed to make it appear in the right place. + left = + nearestSide === 'left' + ? targetRect.left - 2 + : targetRect.right - 2; + } else if ( isVertical ) { // vertical top = previousRect ? previousRect.bottom : nextRect.top; width = previousRect ? previousRect.width : nextRect.width; @@ -141,6 +159,8 @@ function BlockPopoverInbetween( { popoverRecomputeCounter, isVertical, isVisible, + operation, + nearestSide, ] ); const popoverScrollRef = usePopoverScroll( __unstableContentRef ); diff --git a/packages/block-editor/src/components/block-removal-warning-modal/index.js b/packages/block-editor/src/components/block-removal-warning-modal/index.js index a6de602bcdda81..846883e48e9cd3 100644 --- a/packages/block-editor/src/components/block-removal-warning-modal/index.js +++ b/packages/block-editor/src/components/block-removal-warning-modal/index.js @@ -50,11 +50,12 @@ export function BlockRemovalWarningModal( { rules } ) {

{ _n( - 'Post or page content will not be displayed if you delete this block.', - 'Post or page content will not be displayed if you delete these blocks.', + 'Deleting this block will stop your post or page content from displaying on this template. It is not recommended.', + 'Deleting these blocks will stop your post or page content from displaying on this template. It is not recommended.', blockNamesForPrompt.length ) }

diff --git a/packages/block-editor/src/components/block-tools/insertion-point.js b/packages/block-editor/src/components/block-tools/insertion-point.js index 1caec5f3aee8e0..19ad39caca336a 100644 --- a/packages/block-editor/src/components/block-tools/insertion-point.js +++ b/packages/block-editor/src/components/block-tools/insertion-point.js @@ -24,6 +24,8 @@ export const InsertionPointOpenRef = createContext(); function InbetweenInsertionPointPopover( { __unstablePopoverSlot, __unstableContentRef, + operation = 'insert', + nearestSide = 'right', } ) { const { selectBlock, hideInsertionPoint } = useDispatch( blockEditorStore ); const openRef = useContext( InsertionPointOpenRef ); @@ -138,9 +140,14 @@ function InbetweenInsertionPointPopover( { return null; } + const orientationClassname = + orientation === 'horizontal' || operation === 'group' + ? 'is-horizontal' + : 'is-vertical'; + const className = classnames( 'block-editor-block-list__insertion-point', - 'is-' + orientation + orientationClassname ); return ( @@ -149,6 +156,8 @@ function InbetweenInsertionPointPopover( { nextClientId={ nextClientId } __unstablePopoverSlot={ __unstablePopoverSlot } __unstableContentRef={ __unstableContentRef } + operation={ operation } + nearestSide={ nearestSide } > ) : ( - + ); } diff --git a/packages/block-editor/src/components/contrast-checker/style.scss b/packages/block-editor/src/components/contrast-checker/style.scss deleted file mode 100644 index b3b08d6230d05d..00000000000000 --- a/packages/block-editor/src/components/contrast-checker/style.scss +++ /dev/null @@ -1,3 +0,0 @@ -.block-editor-contrast-checker > .components-notice { - margin: 0; -} diff --git a/packages/block-editor/src/components/global-styles/effects-panel.js b/packages/block-editor/src/components/global-styles/effects-panel.js index 9a9fd8d1258edd..94c1d119c354c9 100644 --- a/packages/block-editor/src/components/global-styles/effects-panel.js +++ b/packages/block-editor/src/components/global-styles/effects-panel.js @@ -26,6 +26,7 @@ import { shadow as shadowIcon, Icon, check } from '@wordpress/icons'; /** * Internal dependencies */ +import { mergeOrigins } from '../use-settings'; import { getValueFromVariable, TOOLSPANEL_DROPDOWNMENU_PROPS } from './utils'; import { setImmutably } from '../../utils/object'; @@ -81,8 +82,22 @@ export default function EffectsPanel( { // Shadow const hasShadowEnabled = useHasShadowControl( settings ); const shadow = decodeValue( inheritedValue?.shadow ); + const shadowPresets = settings?.shadow?.presets; + const mergedShadowPresets = shadowPresets + ? mergeOrigins( shadowPresets ) + : []; const setShadow = ( newValue ) => { - onChange( setImmutably( value, [ 'shadow' ], newValue ) ); + const slug = mergedShadowPresets?.find( + ( { shadow: shadowName } ) => shadowName === newValue + )?.slug; + + onChange( + setImmutably( + value, + [ 'shadow' ], + slug ? `var:preset|shadow|${ slug }` : newValue || undefined + ) + ); }; const hasShadow = () => !! value?.shadow; const resetShadow = () => setShadow( undefined ); diff --git a/packages/block-editor/src/components/index.js b/packages/block-editor/src/components/index.js index 08247d8cdb014a..5263ca3332b250 100644 --- a/packages/block-editor/src/components/index.js +++ b/packages/block-editor/src/components/index.js @@ -152,8 +152,10 @@ export { default as WritingFlow } from './writing-flow'; export { default as useBlockDisplayInformation } from './use-block-display-information'; export { default as __unstableIframe } from './iframe'; export { - RecursionProvider as __experimentalRecursionProvider, - useHasRecursion as __experimentalUseHasRecursion, + RecursionProvider, + DeprecatedExperimentalRecursionProvider as __experimentalRecursionProvider, + useHasRecursion, + DeprecatedExperimentalUseHasRecursion as __experimentalUseHasRecursion, } from './recursion-provider'; export { default as __experimentalBlockPatternsList } from './block-patterns-list'; export { default as __experimentalPublishDateTimePicker } from './publish-date-time-picker'; diff --git a/packages/block-editor/src/components/index.native.js b/packages/block-editor/src/components/index.native.js index 21a9b1114ce5fa..ac4b45af3609ca 100644 --- a/packages/block-editor/src/components/index.native.js +++ b/packages/block-editor/src/components/index.native.js @@ -61,8 +61,10 @@ export { default as PanelColorSettings } from './panel-color-settings'; export { default as __experimentalPanelColorGradientSettings } from './colors-gradients/panel-color-gradient-settings'; export { useSettings, default as useSetting } from './use-settings'; export { - RecursionProvider as __experimentalRecursionProvider, - useHasRecursion as __experimentalUseHasRecursion, + RecursionProvider, + DeprecatedExperimentalRecursionProvider as __experimentalRecursionProvider, + useHasRecursion, + DeprecatedExperimentalUseHasRecursion as __experimentalUseHasRecursion, } from './recursion-provider'; export { default as Warning } from './warning'; export { default as ContrastChecker } from './contrast-checker'; diff --git a/packages/block-editor/src/components/inspector-controls-tabs/styles-tab.js b/packages/block-editor/src/components/inspector-controls-tabs/styles-tab.js index d42b3bffce4ebc..6f24051ea2cfcb 100644 --- a/packages/block-editor/src/components/inspector-controls-tabs/styles-tab.js +++ b/packages/block-editor/src/components/inspector-controls-tabs/styles-tab.js @@ -46,6 +46,7 @@ const StylesTab = ( { blockName, clientId, hasBlockStyles } ) => { label={ __( 'Dimensions' ) } /> + ); diff --git a/packages/block-editor/src/components/inspector-controls-tabs/use-inspector-controls-tabs.js b/packages/block-editor/src/components/inspector-controls-tabs/use-inspector-controls-tabs.js index 2a47ae5267ca4e..ff68be82a829f1 100644 --- a/packages/block-editor/src/components/inspector-controls-tabs/use-inspector-controls-tabs.js +++ b/packages/block-editor/src/components/inspector-controls-tabs/use-inspector-controls-tabs.js @@ -40,6 +40,7 @@ export default function useInspectorControlsTabs( blockName ) { position: positionGroup, styles: stylesGroup, typography: typographyGroup, + effects: effectsGroup, } = InspectorControlsGroups; // List View Tab: If there are any fills for the list group add that tab. @@ -55,6 +56,7 @@ export default function useInspectorControlsTabs( blockName ) { ...( useSlotFills( dimensionsGroup.Slot.__unstableName ) || [] ), ...( useSlotFills( stylesGroup.Slot.__unstableName ) || [] ), ...( useSlotFills( typographyGroup.Slot.__unstableName ) || [] ), + ...( useSlotFills( effectsGroup.Slot.__unstableName ) || [] ), ]; const hasStyleFills = styleFills.length; diff --git a/packages/block-editor/src/components/inspector-controls/groups.js b/packages/block-editor/src/components/inspector-controls/groups.js index b4eada4b6b4be6..9ca1a72b9918a6 100644 --- a/packages/block-editor/src/components/inspector-controls/groups.js +++ b/packages/block-editor/src/components/inspector-controls/groups.js @@ -20,6 +20,7 @@ const InspectorControlsTypography = createSlotFill( ); const InspectorControlsListView = createSlotFill( 'InspectorControlsListView' ); const InspectorControlsStyles = createSlotFill( 'InspectorControlsStyles' ); +const InspectorControlsEffects = createSlotFill( 'InspectorControlsEffects' ); const groups = { default: InspectorControlsDefault, @@ -28,6 +29,7 @@ const groups = { border: InspectorControlsBorder, color: InspectorControlsColor, dimensions: InspectorControlsDimensions, + effects: InspectorControlsEffects, filter: InspectorControlsFilter, list: InspectorControlsListView, position: InspectorControlsPosition, 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 04a67d44789948..17cc851c503a14 100644 --- a/packages/block-editor/src/components/link-control/link-preview.js +++ b/packages/block-editor/src/components/link-control/link-preview.js @@ -10,12 +10,15 @@ import { __ } from '@wordpress/i18n'; import { Button, ExternalLink, - __experimentalText as Text, + __experimentalTruncate as Truncate, Tooltip, } from '@wordpress/components'; +import { useCopyToClipboard } from '@wordpress/compose'; import { filterURLForDisplay, safeDecodeURI } from '@wordpress/url'; -import { Icon, globe, info, linkOff, edit } from '@wordpress/icons'; +import { Icon, globe, info, linkOff, edit, copy } from '@wordpress/icons'; import { __unstableStripHTML as stripHTML } from '@wordpress/dom'; +import { useDispatch } from '@wordpress/data'; +import { store as noticesStore } from '@wordpress/notices'; /** * Internal dependencies @@ -41,7 +44,7 @@ export default function LinkPreview( { const hasRichData = richData && Object.keys( richData ).length; const displayURL = - ( value && filterURLForDisplay( safeDecodeURI( value.url ), 16 ) ) || + ( value && filterURLForDisplay( safeDecodeURI( value.url ), 24 ) ) || ''; // url can be undefined if the href attribute is unset @@ -61,6 +64,14 @@ export default function LinkPreview( { icon = ; } + const { createNotice } = useDispatch( noticesStore ); + const ref = useCopyToClipboard( value.url, () => { + createNotice( 'info', __( 'Copied URL to clipboard.' ), { + isDismissible: true, + type: 'snackbar', + } ); + } ); + return (
{ ! isEmptyURL ? ( <> - + - { displayTitle } + + { displayTitle } + - { value?.url && displayTitle !== displayURL && ( - { displayURL } + + { displayURL } + ) } @@ -119,7 +130,7 @@ export default function LinkPreview( { label={ __( 'Edit' ) } className="block-editor-link-control__search-item-action" onClick={ onEditClick } - iconSize={ 24 } + size="compact" /> { hasUnlinkControl && (
- - { !! ( - ( hasRichData && - ( richData?.image || richData?.description ) ) || - isFetching - ) && ( -
- { ( richData?.image || isFetching ) && ( -
- { richData?.image && ( - - ) } -
- ) } - - { ( richData?.description || isFetching ) && ( -
- { richData?.description && ( - - { richData.description } - - ) } -
- ) } -
- ) } - { additionalControls && additionalControls() } ); diff --git a/packages/block-editor/src/components/link-control/style.scss b/packages/block-editor/src/components/link-control/style.scss index 1a053eabf265ce..7b6470df435437 100644 --- a/packages/block-editor/src/components/link-control/style.scss +++ b/packages/block-editor/src/components/link-control/style.scss @@ -1,5 +1,4 @@ $block-editor-link-control-number-of-actions: 1; -$preview-image-height: 140px; @keyframes loadingpulse { 0% { @@ -180,6 +179,7 @@ $preview-image-height: 140px; flex-direction: row; align-items: flex-start; margin-right: $grid-unit-10; + gap: $grid-unit-10; // Force text to wrap to improve UX when encountering long lines // of text, particular those with no spaces. @@ -188,6 +188,9 @@ $preview-image-height: 140px; overflow-wrap: break-word; .block-editor-link-control__search-item-info { + color: $gray-700; + line-height: 1.1; + font-size: $helptext-font-size; word-break: break-all; } } @@ -206,17 +209,29 @@ $preview-image-height: 140px; word-break: break-all; } + .block-editor-link-control__search-item-details { + display: flex; + flex-direction: column; + justify-content: space-between; + gap: $grid-unit-05; + } + + .block-editor-link-control__search-item-header .block-editor-link-control__search-item-icon { + background-color: $gray-100; + width: $grid-unit-40; + height: $grid-unit-40; + border-radius: $radius-block-ui; + } + .block-editor-link-control__search-item-icon { position: relative; - margin-right: $grid-unit-10; - max-height: 24px; flex-shrink: 0; - width: 24px; display: flex; justify-content: center; + align-items: center; img { - width: 16px; // favicons often have a source of 32px + width: $grid-unit-20; // favicons often have a source of 32px } } @@ -227,10 +242,13 @@ $preview-image-height: 140px; } .block-editor-link-control__search-item-title { - display: block; - font-weight: 500; - position: relative; - line-height: $grid-unit-30; + border-radius: $radius-block-ui; + line-height: 1.1; + + &:focus-visible { + @include block-toolbar-button-style__focus(); + text-decoration: none; + } mark { font-weight: 600; @@ -246,58 +264,6 @@ $preview-image-height: 140px; display: none; // specifically requested to be removed visually as well. } } - - .block-editor-link-control__search-item-description { - padding-top: 12px; - margin: 0; - - &.is-placeholder { - margin-top: 12px; - padding-top: 0; - height: 28px; - display: flex; - flex-direction: column; - justify-content: space-around; - - &::before, - &::after { - display: block; - content: ""; - height: 0.7em; - width: 100%; - background-color: $gray-100; - border-radius: 3px; - } - } - - .components-text { - font-size: 0.9em; - } - } - - .block-editor-link-control__search-item-image { - display: flex; - width: 100%; - background-color: $gray-100; - justify-content: center; - height: $preview-image-height; // limit height - max-height: $preview-image-height; // limit height - overflow: hidden; - border-radius: 2px; - margin-top: 12px; - - &.is-placeholder { - background-color: $gray-100; - border-radius: 3px; - } - - img { - display: block; // remove unwanted space below image - width: 100%; - height: 100%; - object-fit: contain; - } - } } .block-editor-link-control__search-item-top { @@ -307,24 +273,7 @@ $preview-image-height: 140px; align-items: center; } -.block-editor-link-control__search-item-bottom { - transition: opacity 1.5s; - width: 100%; -} - .block-editor-link-control__search-item.is-fetching { - .block-editor-link-control__search-item-description { - &::before, - &::after { - animation: loadingpulse 1s linear infinite; - animation-delay: 0.5s; // avoid animating for fast network responses - } - } - - .block-editor-link-control__search-item-image { - animation: loadingpulse 1s linear infinite; - animation-delay: 0.5s; // avoid animating for fast network responses - } .block-editor-link-control__search-item-icon { svg, diff --git a/packages/block-editor/src/components/link-control/test/index.js b/packages/block-editor/src/components/link-control/test/index.js index 32db57a55d76ed..054f7a3ea87468 100644 --- a/packages/block-editor/src/components/link-control/test/index.js +++ b/packages/block-editor/src/components/link-control/test/index.js @@ -2244,7 +2244,8 @@ describe( 'Rich link previews', () => { const titlePreview = screen.getByText( selectedLink.title ); - expect( titlePreview ).toHaveClass( + // eslint-disable-next-line testing-library/no-node-access + expect( titlePreview.parentElement ).toHaveClass( 'block-editor-link-control__search-item-title' ); } ); diff --git a/packages/block-editor/src/components/recursion-provider/README.md b/packages/block-editor/src/components/recursion-provider/README.md index 4538fd6a7d3507..37af15d75c1914 100644 --- a/packages/block-editor/src/components/recursion-provider/README.md +++ b/packages/block-editor/src/components/recursion-provider/README.md @@ -11,8 +11,8 @@ To help with detecting infinite loops on the client, the `RecursionProvider` com * WordPress dependencies */ import { - __experimentalRecursionProvider as RecursionProvider, - __experimentalUseHasRecursion as useHasRecursion, + RecursionProvider, + useHasRecursion, useBlockProps, Warning, } from '@wordpress/block-editor'; diff --git a/packages/block-editor/src/components/recursion-provider/index.js b/packages/block-editor/src/components/recursion-provider/index.js index 2c38087a8731d4..4f462cb33ef2a3 100644 --- a/packages/block-editor/src/components/recursion-provider/index.js +++ b/packages/block-editor/src/components/recursion-provider/index.js @@ -2,6 +2,7 @@ * WordPress dependencies */ import { createContext, useContext, useMemo } from '@wordpress/element'; +import deprecated from '@wordpress/deprecated'; /** * Internal dependencies @@ -82,3 +83,19 @@ export function useHasRecursion( uniqueId, blockName = '' ) { blockName = blockName || name; return Boolean( previouslyRenderedBlocks[ blockName ]?.has( uniqueId ) ); } + +export const DeprecatedExperimentalRecursionProvider = ( props ) => { + deprecated( 'wp.blockEditor.__experimentalRecursionProvider', { + since: '6.5', + alternative: 'wp.blockEditor.RecursionProvider', + } ); + return ; +}; + +export const DeprecatedExperimentalUseHasRecursion = ( props ) => { + deprecated( 'wp.blockEditor.__experimentalUseHasRecursion', { + since: '6.5', + alternative: 'wp.blockEditor.useHasRecursion', + } ); + return useHasRecursion( ...props ); +}; diff --git a/packages/block-editor/src/components/use-block-drop-zone/index.js b/packages/block-editor/src/components/use-block-drop-zone/index.js index 20bec8af76dff8..b1573133dcd056 100644 --- a/packages/block-editor/src/components/use-block-drop-zone/index.js +++ b/packages/block-editor/src/components/use-block-drop-zone/index.js @@ -20,6 +20,7 @@ import useOnBlockDrop from '../use-on-block-drop'; import { getDistanceToNearestEdge, isPointContainedByRect, + isPointWithinTopAndBottomBoundariesOfRect, } from '../../utils/math'; import { store as blockEditorStore } from '../../store'; @@ -72,6 +73,8 @@ export function getDropTargetPosition( let nearestIndex = 0; let insertPosition = 'before'; let minDistance = Infinity; + let targetBlockIndex = null; + let nearestSide = 'right'; const { dropZoneElement, @@ -136,7 +139,12 @@ export function getDropTargetPosition( } blocksData.forEach( - ( { isUnmodifiedDefaultBlock, getBoundingClientRect, blockIndex } ) => { + ( { + isUnmodifiedDefaultBlock, + getBoundingClientRect, + blockIndex, + blockOrientation, + } ) => { const rect = getBoundingClientRect(); let [ distance, edge ] = getDistanceToNearestEdge( @@ -144,12 +152,35 @@ export function getDropTargetPosition( rect, allowedEdges ); + // If the the point is close to a side, prioritize that side. + const [ sideDistance, sideEdge ] = getDistanceToNearestEdge( + position, + rect, + [ 'left', 'right' ] + ); + + const isPointInsideRect = isPointContainedByRect( position, rect ); + // Prioritize the element if the point is inside of an unmodified default block. - if ( - isUnmodifiedDefaultBlock && - isPointContainedByRect( position, rect ) - ) { + if ( isUnmodifiedDefaultBlock && isPointInsideRect ) { distance = 0; + } else if ( + orientation === 'vertical' && + blockOrientation !== 'horizontal' && + ( ( isPointInsideRect && sideDistance < THRESHOLD_DISTANCE ) || + ( ! isPointInsideRect && + isPointWithinTopAndBottomBoundariesOfRect( + position, + rect + ) ) ) + ) { + /** + * This condition should only apply when the layout is vertical (otherwise there's + * no need to create a Row) and dropzones should only activate when the block is + * either within and close to the sides of the target block or on its outer sides. + */ + targetBlockIndex = blockIndex; + nearestSide = sideEdge; } if ( distance < minDistance ) { @@ -175,6 +206,10 @@ export function getDropTargetPosition( const isAdjacentBlockUnmodifiedDefaultBlock = !! blocksData[ adjacentIndex ]?.isUnmodifiedDefaultBlock; + // If the target index is set then group with the block at that index. + if ( targetBlockIndex !== null ) { + return [ targetBlockIndex, 'group', nearestSide ]; + } // If both blocks are not unmodified default blocks then just insert between them. if ( ! isNearestBlockUnmodifiedDefaultBlock && @@ -284,6 +319,7 @@ export default function useBlockDropZone( { dropTarget.index, { operation: dropTarget.operation, + nearestSide: dropTarget.nearestSide, } ); const throttled = useThrottle( @@ -333,28 +369,32 @@ export default function useBlockDropZone( { .getElementById( `block-${ clientId }` ) .getBoundingClientRect(), blockIndex: getBlockIndex( clientId ), + blockOrientation: + getBlockListSettings( clientId )?.orientation, }; } ); - const [ targetIndex, operation ] = getDropTargetPosition( - blocksData, - { x: event.clientX, y: event.clientY }, - getBlockListSettings( targetRootClientId )?.orientation, - { - dropZoneElement, - parentBlockClientId, - parentBlockOrientation: parentBlockClientId - ? getBlockListSettings( parentBlockClientId ) - ?.orientation - : undefined, - rootBlockIndex: getBlockIndex( targetRootClientId ), - } - ); + const [ targetIndex, operation, nearestSide ] = + getDropTargetPosition( + blocksData, + { x: event.clientX, y: event.clientY }, + getBlockListSettings( targetRootClientId )?.orientation, + { + dropZoneElement, + parentBlockClientId, + parentBlockOrientation: parentBlockClientId + ? getBlockListSettings( parentBlockClientId ) + ?.orientation + : undefined, + rootBlockIndex: getBlockIndex( targetRootClientId ), + } + ); registry.batch( () => { setDropTarget( { index: targetIndex, operation, + nearestSide, } ); const insertionPointClientId = [ @@ -366,6 +406,7 @@ export default function useBlockDropZone( { showInsertionPoint( insertionPointClientId, targetIndex, { operation, + nearestSide, } ); } ); }, diff --git a/packages/block-editor/src/components/use-block-drop-zone/test/index.js b/packages/block-editor/src/components/use-block-drop-zone/test/index.js index f5560c1cfdf13a..e6614b3fafc133 100644 --- a/packages/block-editor/src/components/use-block-drop-zone/test/index.js +++ b/packages/block-editor/src/components/use-block-drop-zone/test/index.js @@ -22,13 +22,6 @@ const elementData = [ bottom: 900, right: 400, }, - // Fourth block wraps to the next row/column. - { - top: 0, - left: 400, - bottom: 300, - right: 800, - }, ]; const mapElements = @@ -73,7 +66,7 @@ describe( 'getDropTargetPosition', () => { const orientation = 'vertical'; it( 'returns `0` when the position is nearest to the start of the first block', () => { - const position = { x: 0, y: 0 }; + const position = { x: 32, y: 0 }; const result = getDropTargetPosition( verticalBlocksData, @@ -85,7 +78,7 @@ describe( 'getDropTargetPosition', () => { } ); it( 'returns `1` when the position is nearest to the end of the first block', () => { - const position = { x: 0, y: 190 }; + const position = { x: 32, y: 190 }; const result = getDropTargetPosition( verticalBlocksData, @@ -97,7 +90,7 @@ describe( 'getDropTargetPosition', () => { } ); it( 'returns `1` when the position is nearest to the start of the second block', () => { - const position = { x: 0, y: 210 }; + const position = { x: 32, y: 210 }; const result = getDropTargetPosition( verticalBlocksData, @@ -109,7 +102,7 @@ describe( 'getDropTargetPosition', () => { } ); it( 'returns `2` when the position is nearest to the end of the second block', () => { - const position = { x: 0, y: 450 }; + const position = { x: 32, y: 450 }; const result = getDropTargetPosition( verticalBlocksData, @@ -121,7 +114,7 @@ describe( 'getDropTargetPosition', () => { } ); it( 'returns `2` when the position is nearest to the start of the third block', () => { - const position = { x: 0, y: 510 }; + const position = { x: 32, y: 510 }; const result = getDropTargetPosition( verticalBlocksData, @@ -133,7 +126,7 @@ describe( 'getDropTargetPosition', () => { } ); it( 'returns `3` when the position is nearest to the end of the third block', () => { - const position = { x: 0, y: 880 }; + const position = { x: 32, y: 880 }; const result = getDropTargetPosition( verticalBlocksData, @@ -145,7 +138,7 @@ describe( 'getDropTargetPosition', () => { } ); it( 'returns `3` when the position is past the end of the third block', () => { - const position = { x: 0, y: 920 }; + const position = { x: 32, y: 920 }; const result = getDropTargetPosition( verticalBlocksData, @@ -155,9 +148,8 @@ describe( 'getDropTargetPosition', () => { expect( result ).toEqual( [ 3, 'insert' ] ); } ); - - it( 'returns `4` when the position is nearest to the start of the fourth block', () => { - const position = { x: 401, y: 0 }; + it( 'returns group with index 0 when position is close to the right of the first block', () => { + const position = { x: 372, y: 0 }; const result = getDropTargetPosition( verticalBlocksData, @@ -165,11 +157,10 @@ describe( 'getDropTargetPosition', () => { orientation ); - expect( result ).toEqual( [ 3, 'insert' ] ); + expect( result ).toEqual( [ 0, 'group', 'right' ] ); } ); - - it( 'returns `5` when the position is nearest to the end of the fourth block', () => { - const position = { x: 401, y: 300 }; + it( 'returns group with index 1 when position is close to the left of the second block', () => { + const position = { x: 12, y: 212 }; const result = getDropTargetPosition( verticalBlocksData, @@ -177,7 +168,7 @@ describe( 'getDropTargetPosition', () => { orientation ); - expect( result ).toEqual( [ 4, 'insert' ] ); + expect( result ).toEqual( [ 1, 'group', 'left' ] ); } ); } ); @@ -267,30 +258,6 @@ describe( 'getDropTargetPosition', () => { expect( result ).toEqual( [ 3, 'insert' ] ); } ); - - it( 'returns `3` when the position is nearest to the start of the last block', () => { - const position = { x: 0, y: 401 }; - - const result = getDropTargetPosition( - horizontalBlocksData, - position, - orientation - ); - - expect( result ).toEqual( [ 3, 'insert' ] ); - } ); - - it( 'returns `4` when the position is nearest to the end of the last block', () => { - const position = { x: 300, y: 401 }; - - const result = getDropTargetPosition( - horizontalBlocksData, - position, - orientation - ); - - expect( result ).toEqual( [ 4, 'insert' ] ); - } ); } ); describe( 'Unmodified default blocks', () => { @@ -316,14 +283,18 @@ describe( 'getDropTargetPosition', () => { // Dropping above the first block. expect( - getDropTargetPosition( blocksData, { x: 0, y: 0 }, orientation ) + getDropTargetPosition( + blocksData, + { x: 32, y: 0 }, + orientation + ) ).toEqual( [ 0, 'replace' ] ); // Dropping on the top half of the first block. expect( getDropTargetPosition( blocksData, - { x: 0, y: 20 }, + { x: 32, y: 20 }, orientation ) ).toEqual( [ 0, 'replace' ] ); @@ -332,7 +303,7 @@ describe( 'getDropTargetPosition', () => { expect( getDropTargetPosition( blocksData, - { x: 0, y: 200 }, + { x: 32, y: 200 }, orientation ) ).toEqual( [ 0, 'replace' ] ); @@ -341,7 +312,7 @@ describe( 'getDropTargetPosition', () => { expect( getDropTargetPosition( blocksData, - { x: 0, y: 211 }, + { x: 32, y: 211 }, orientation ) ).toEqual( [ 0, 'replace' ] ); @@ -350,7 +321,7 @@ describe( 'getDropTargetPosition', () => { expect( getDropTargetPosition( blocksData, - { x: 0, y: 219 }, + { x: 32, y: 219 }, orientation ) ).toEqual( [ 0, 'replace' ] ); @@ -359,7 +330,7 @@ describe( 'getDropTargetPosition', () => { expect( getDropTargetPosition( blocksData, - { x: 0, y: 230 }, + { x: 32, y: 230 }, orientation ) ).toEqual( [ 0, 'replace' ] ); @@ -368,7 +339,7 @@ describe( 'getDropTargetPosition', () => { expect( getDropTargetPosition( blocksData, - { x: 0, y: 410 }, + { x: 32, y: 410 }, orientation ) ).toEqual( [ 2, 'insert' ] ); @@ -377,7 +348,7 @@ describe( 'getDropTargetPosition', () => { expect( getDropTargetPosition( blocksData, - { x: 0, y: 421 }, + { x: 32, y: 421 }, orientation ) ).toEqual( [ 2, 'insert' ] ); @@ -410,7 +381,7 @@ describe( 'getDropTargetPosition', () => { expect( getDropTargetPosition( blocksData, - { x: 0, y: 20 }, + { x: 32, y: 20 }, orientation ) ).toEqual( [ 0, 'insert' ] ); @@ -419,7 +390,7 @@ describe( 'getDropTargetPosition', () => { expect( getDropTargetPosition( blocksData, - { x: 0, y: 200 }, + { x: 32, y: 200 }, orientation ) ).toEqual( [ 1, 'replace' ] ); @@ -428,7 +399,7 @@ describe( 'getDropTargetPosition', () => { expect( getDropTargetPosition( blocksData, - { x: 0, y: 211 }, + { x: 32, y: 211 }, orientation ) ).toEqual( [ 1, 'replace' ] ); @@ -437,7 +408,7 @@ describe( 'getDropTargetPosition', () => { expect( getDropTargetPosition( blocksData, - { x: 0, y: 219 }, + { x: 32, y: 219 }, orientation ) ).toEqual( [ 1, 'replace' ] ); @@ -446,7 +417,7 @@ describe( 'getDropTargetPosition', () => { expect( getDropTargetPosition( blocksData, - { x: 0, y: 230 }, + { x: 32, y: 230 }, orientation ) ).toEqual( [ 1, 'replace' ] ); @@ -455,7 +426,7 @@ describe( 'getDropTargetPosition', () => { expect( getDropTargetPosition( blocksData, - { x: 0, y: 410 }, + { x: 32, y: 410 }, orientation ) ).toEqual( [ 1, 'replace' ] ); @@ -464,7 +435,7 @@ describe( 'getDropTargetPosition', () => { expect( getDropTargetPosition( blocksData, - { x: 0, y: 421 }, + { x: 32, y: 421 }, orientation ) ).toEqual( [ 1, 'replace' ] ); diff --git a/packages/block-editor/src/components/use-on-block-drop/index.js b/packages/block-editor/src/components/use-on-block-drop/index.js index 701cc9f4f8451f..80e83c01b4b9ae 100644 --- a/packages/block-editor/src/components/use-on-block-drop/index.js +++ b/packages/block-editor/src/components/use-on-block-drop/index.js @@ -4,9 +4,11 @@ import { useCallback } from '@wordpress/element'; import { cloneBlock, + createBlock, findTransform, getBlockTransforms, pasteHandler, + store as blocksStore, } from '@wordpress/blocks'; import { useDispatch, useSelect, useRegistry } from '@wordpress/data'; import { getFilesFromDataTransfer } from '@wordpress/dom'; @@ -61,6 +63,8 @@ export function parseDropEvent( event ) { * @param {Function} moveBlocks A function that moves blocks. * @param {Function} insertOrReplaceBlocks A function that inserts or replaces blocks. * @param {Function} clearSelectedBlock A function that clears block selection. + * @param {string} operation The type of operation to perform on drop. Could be `insert` or `replace` or `group`. + * @param {Function} getBlock A function that returns a block given its client id. * @return {Function} The event handler for a block drop event. */ export function onBlockDrop( @@ -70,7 +74,9 @@ export function onBlockDrop( getClientIdsOfDescendants, moveBlocks, insertOrReplaceBlocks, - clearSelectedBlock + clearSelectedBlock, + operation, + getBlock ) { return ( event ) => { const { @@ -113,6 +119,21 @@ export function onBlockDrop( return; } + // If the user is dropping a block over another block, replace both blocks + // with a group block containing them + if ( operation === 'group' ) { + const blocksToInsert = sourceClientIds.map( ( clientId ) => + getBlock( clientId ) + ); + insertOrReplaceBlocks( + blocksToInsert, + true, + null, + sourceClientIds + ); + return; + } + const isAtSameLevel = sourceRootClientId === targetRootClientId; const draggedBlockCount = sourceClientIds.length; @@ -202,7 +223,7 @@ export default function useOnBlockDrop( targetBlockIndex, options = {} ) { - const { operation = 'insert' } = options; + const { operation = 'insert', nearestSide = 'right' } = options; const { canInsertBlockType, getBlockIndex, @@ -210,7 +231,10 @@ export default function useOnBlockDrop( getBlockOrder, getBlocksByClientId, getSettings, + getBlock, + isGroupable, } = useSelect( blockEditorStore ); + const { getBlockType, getGroupingBlockName } = useSelect( blocksStore ); const { insertBlocks, moveBlocksToPosition, @@ -222,12 +246,63 @@ export default function useOnBlockDrop( const registry = useRegistry(); const insertOrReplaceBlocks = useCallback( - ( blocks, updateSelection = true, initialPosition = 0 ) => { + ( + blocks, + updateSelection = true, + initialPosition = 0, + clientIdsToReplace = [] + ) => { + const clientIds = getBlockOrder( targetRootClientId ); + const clientId = clientIds[ targetBlockIndex ]; + const blocksClientIds = blocks.map( ( block ) => block.clientId ); + const areGroupableBlocks = isGroupable( [ + ...blocksClientIds, + clientId, + ] ); if ( operation === 'replace' ) { - const clientIds = getBlockOrder( targetRootClientId ); - const clientId = clientIds[ targetBlockIndex ]; - replaceBlocks( clientId, blocks, undefined, initialPosition ); + } else if ( operation === 'group' && areGroupableBlocks ) { + const targetBlock = getBlock( clientId ); + if ( nearestSide === 'left' ) { + blocks.push( targetBlock ); + } else { + blocks.unshift( targetBlock ); + } + + const groupInnerBlocks = blocks.map( ( block ) => { + return createBlock( + block.name, + block.attributes, + block.innerBlocks + ); + } ); + + const areAllImages = blocks.every( ( block ) => { + return block.name === 'core/image'; + } ); + + const galleryBlock = !! getBlockType( 'core/gallery' ); + + const wrappedBlocks = createBlock( + areAllImages && galleryBlock + ? 'core/gallery' + : getGroupingBlockName(), + { + layout: { + type: 'flex', + flexWrap: areAllImages ? null : 'nowrap', + }, + }, + groupInnerBlocks + ); + // Need to make sure both the target block and the block being dragged are replaced + // otherwise the dragged block will be duplicated. + replaceBlocks( + [ clientId, ...clientIdsToReplace ], + wrappedBlocks, + undefined, + initialPosition + ); } else { insertBlocks( blocks, @@ -239,12 +314,16 @@ export default function useOnBlockDrop( } }, [ - operation, getBlockOrder, - insertBlocks, - replaceBlocks, - targetBlockIndex, targetRootClientId, + targetBlockIndex, + operation, + replaceBlocks, + getBlock, + nearestSide, + getBlockType, + getGroupingBlockName, + insertBlocks, ] ); @@ -297,7 +376,9 @@ export default function useOnBlockDrop( getClientIdsOfDescendants, moveBlocks, insertOrReplaceBlocks, - clearSelectedBlock + clearSelectedBlock, + operation, + getBlock ); const _onFilesDrop = onFilesDrop( targetRootClientId, diff --git a/packages/block-editor/src/hooks/block-hooks.js b/packages/block-editor/src/hooks/block-hooks.js index f9d7026e48545f..1e0b8e894d2067 100644 --- a/packages/block-editor/src/hooks/block-hooks.js +++ b/packages/block-editor/src/hooks/block-hooks.js @@ -55,8 +55,7 @@ function BlockHooksControlPure( { name, clientId } ) { const _hookedBlockClientIds = hookedBlocksForCurrentBlock.reduce( ( clientIds, block ) => { // If the block doesn't exist anywhere in the block tree, - // we know that we have to display the toggle for it, and set - // it to disabled. + // we know that we have to set the toggle to disabled. if ( getGlobalBlockCount( block.name ) === 0 ) { return clientIds; } @@ -96,13 +95,8 @@ function BlockHooksControlPure( { name, clientId } ) { } // If no hooked block was found in any of its designated locations, - // but it exists elsewhere in the block tree, we consider it manually inserted. - // In this case, we take note and will remove the corresponding toggle from the - // block inspector panel. - return { - ...clientIds, - [ block.name ]: false, - }; + // we set the toggle to disabled. + return clientIds; }, {} ); @@ -118,13 +112,7 @@ function BlockHooksControlPure( { name, clientId } ) { const { insertBlock, removeBlock } = useDispatch( blockEditorStore ); - // Remove toggle if block isn't present in the designated location but elsewhere in the block tree. - const hookedBlocksForCurrentBlockIfNotPresentElsewhere = - hookedBlocksForCurrentBlock?.filter( - ( block ) => hookedBlockClientIds?.[ block.name ] !== false - ); - - if ( ! hookedBlocksForCurrentBlockIfNotPresentElsewhere.length ) { + if ( ! hookedBlocksForCurrentBlock.length ) { return null; } diff --git a/packages/block-editor/src/hooks/effects.js b/packages/block-editor/src/hooks/effects.js new file mode 100644 index 00000000000000..74d2aa46019d7b --- /dev/null +++ b/packages/block-editor/src/hooks/effects.js @@ -0,0 +1,57 @@ +/** + * WordPress dependencies + */ +import { hasBlockSupport } from '@wordpress/blocks'; +import { useSelect } from '@wordpress/data'; +/** + * Internal dependencies + */ +import StylesEffectsPanel, { + useHasEffectsPanel, +} from '../components/global-styles/effects-panel'; +import { InspectorControls } from '../components'; +import { store as blockEditorStore } from '../store'; +import { cleanEmptyObject } from './utils'; + +export const SHADOW_SUPPORT_KEY = 'shadow'; +export const EFFECTS_SUPPORT_KEYS = [ SHADOW_SUPPORT_KEY ]; + +export function hasEffectsSupport( blockName ) { + return EFFECTS_SUPPORT_KEYS.some( ( key ) => + hasBlockSupport( blockName, key ) + ); +} + +function EffectsInspectorControl( { children, resetAllFilter } ) { + return ( + + { children } + + ); +} +export function EffectsPanel( { clientId, setAttributes, settings } ) { + const isEnabled = useHasEffectsPanel( settings ); + const value = useSelect( + ( select ) => + select( blockEditorStore ).getBlockAttributes( clientId )?.style, + [ clientId ] + ); + + const onChange = ( newStyle ) => { + setAttributes( { style: cleanEmptyObject( newStyle ) } ); + }; + + if ( ! isEnabled ) { + return null; + } + + return ( + + ); +} diff --git a/packages/block-editor/src/hooks/index.js b/packages/block-editor/src/hooks/index.js index f17c0a22166e4e..cb0ca4e2ff3e58 100644 --- a/packages/block-editor/src/hooks/index.js +++ b/packages/block-editor/src/hooks/index.js @@ -68,6 +68,7 @@ createBlockSaveFilter( [ export { useCustomSides } from './dimensions'; export { useLayoutClasses, useLayoutStyles } from './layout'; export { getBorderClassesAndStyles, useBorderProps } from './use-border-props'; +export { getShadowClassesAndStyles, useShadowProps } from './use-shadow-props'; export { getColorClassesAndStyles, useColorProps } from './use-color-props'; export { getSpacingClassesAndStyles } from './use-spacing-props'; export { getTypographyClassesAndStyles } from './use-typography-props'; diff --git a/packages/block-editor/src/hooks/index.native.js b/packages/block-editor/src/hooks/index.native.js index 55ae7e19df7037..6e4c1c6a17ba54 100644 --- a/packages/block-editor/src/hooks/index.native.js +++ b/packages/block-editor/src/hooks/index.native.js @@ -28,6 +28,7 @@ createBlockSaveFilter( [ ] ); export { getBorderClassesAndStyles, useBorderProps } from './use-border-props'; +export { getShadowClassesAndStyles, useShadowProps } from './use-shadow-props'; export { getColorClassesAndStyles, useColorProps } from './use-color-props'; export { getSpacingClassesAndStyles } from './use-spacing-props'; export { useCachedTruthy } from './use-cached-truthy'; diff --git a/packages/block-editor/src/hooks/style.js b/packages/block-editor/src/hooks/style.js index 7221de63456cd5..dec046f888a468 100644 --- a/packages/block-editor/src/hooks/style.js +++ b/packages/block-editor/src/hooks/style.js @@ -27,6 +27,11 @@ import { SPACING_SUPPORT_KEY, DimensionsPanel, } from './dimensions'; +import { + EFFECTS_SUPPORT_KEYS, + SHADOW_SUPPORT_KEY, + EffectsPanel, +} from './effects'; import { shouldSkipSerialization, useStyleOverride, @@ -37,6 +42,7 @@ import { useBlockEditingMode } from '../components/block-editing-mode'; const styleSupportKeys = [ ...TYPOGRAPHY_SUPPORT_KEYS, + ...EFFECTS_SUPPORT_KEYS, BORDER_SUPPORT_KEY, COLOR_SUPPORT_KEY, DIMENSIONS_SUPPORT_KEY, @@ -110,6 +116,7 @@ const skipSerializationPathsEdit = { [ `${ SPACING_SUPPORT_KEY }.__experimentalSkipSerialization` ]: [ SPACING_SUPPORT_KEY, ], + [ `${ SHADOW_SUPPORT_KEY }` ]: [ SHADOW_SUPPORT_KEY ], }; /** @@ -336,6 +343,7 @@ function BlockStyleControls( { + ); } diff --git a/packages/block-editor/src/hooks/supports.js b/packages/block-editor/src/hooks/supports.js index 2cf08d46fa8fe2..4e116494029bf1 100644 --- a/packages/block-editor/src/hooks/supports.js +++ b/packages/block-editor/src/hooks/supports.js @@ -59,8 +59,10 @@ const TYPOGRAPHY_SUPPORT_KEYS = [ WRITING_MODE_SUPPORT_KEY, LETTER_SPACING_SUPPORT_KEY, ]; +const EFFECTS_SUPPORT_KEYS = [ 'shadow' ]; const SPACING_SUPPORT_KEY = 'spacing'; const styleSupportKeys = [ + ...EFFECTS_SUPPORT_KEYS, ...TYPOGRAPHY_SUPPORT_KEYS, BORDER_SUPPORT_KEY, COLOR_SUPPORT_KEY, diff --git a/packages/block-editor/src/hooks/test/effects.js b/packages/block-editor/src/hooks/test/effects.js new file mode 100644 index 00000000000000..b4fe61745744b1 --- /dev/null +++ b/packages/block-editor/src/hooks/test/effects.js @@ -0,0 +1,39 @@ +/** + * Internal dependencies + */ +import { hasEffectsSupport } from '../effects'; + +describe( 'effects', () => { + describe( 'hasEffectsSupport', () => { + it( 'should return false if the block does not support effects', () => { + const settings = { + supports: { + shadow: false, + }, + }; + + expect( hasEffectsSupport( settings ) ).toBe( false ); + } ); + + it( 'should return true if the block supports effects', () => { + const settings = { + supports: { + shadow: true, + }, + }; + + expect( hasEffectsSupport( settings ) ).toBe( true ); + } ); + + it( 'should return true if the block supports effects and other features', () => { + const settings = { + supports: { + shadow: true, + align: true, + }, + }; + + expect( hasEffectsSupport( settings ) ).toBe( true ); + } ); + } ); +} ); diff --git a/packages/block-editor/src/hooks/use-shadow-props.js b/packages/block-editor/src/hooks/use-shadow-props.js new file mode 100644 index 00000000000000..fdc601366245c9 --- /dev/null +++ b/packages/block-editor/src/hooks/use-shadow-props.js @@ -0,0 +1,37 @@ +/** + * Internal dependencies + */ +import { getInlineStyles } from './style'; + +// This utility is intended to assist where the serialization of the shadow +// block support is being skipped for a block but the shadow related CSS classes +// & styles still need to be generated so they can be applied to inner elements. + +/** + * Provides the CSS class names and inline styles for a block's shadow support + * attributes. + * + * @param {Object} attributes Block attributes. + * @return {Object} Shadow block support derived CSS classes & styles. + */ +export function getShadowClassesAndStyles( attributes ) { + const shadow = attributes.style?.shadow || ''; + + return { + className: undefined, + style: getInlineStyles( { shadow } ), + }; +} + +/** + * Derives the shadow related props for a block from its shadow block support + * attributes. + * + * @param {Object} attributes Block attributes. + * + * @return {Object} ClassName & style props from shadow block support. + */ +export function useShadowProps( attributes ) { + const shadowProps = getShadowClassesAndStyles( attributes ); + return shadowProps; +} diff --git a/packages/block-editor/src/hooks/utils.js b/packages/block-editor/src/hooks/utils.js index e63029e4e34e81..ea31a516ac6343 100644 --- a/packages/block-editor/src/hooks/utils.js +++ b/packages/block-editor/src/hooks/utils.js @@ -221,6 +221,7 @@ export function useBlockSettings( name, parentLayout ) { isTextEnabled, isHeadingEnabled, isButtonEnabled, + shadow, ] = useSettings( 'background.backgroundImage', 'background.backgroundSize', @@ -268,7 +269,8 @@ export function useBlockSettings( name, parentLayout ) { 'color.link', 'color.text', 'color.heading', - 'color.button' + 'color.button', + 'shadow' ); const rawSettings = useMemo( () => { @@ -345,6 +347,7 @@ export function useBlockSettings( name, parentLayout ) { }, layout, parentLayout, + shadow, }; }, [ backgroundImage, @@ -395,6 +398,7 @@ export function useBlockSettings( name, parentLayout ) { isTextEnabled, isHeadingEnabled, isButtonEnabled, + shadow, ] ); return useSettingsForBlockElement( rawSettings, name ); diff --git a/packages/block-editor/src/index.js b/packages/block-editor/src/index.js index 1dbc4501e92180..83475b9358723e 100644 --- a/packages/block-editor/src/index.js +++ b/packages/block-editor/src/index.js @@ -11,6 +11,8 @@ export { useCustomSides as __experimentalUseCustomSides, getSpacingClassesAndStyles as __experimentalGetSpacingClassesAndStyles, getGapCSSValue as __experimentalGetGapCSSValue, + getShadowClassesAndStyles as __experimentalGetShadowClassesAndStyles, + useShadowProps as __experimentalUseShadowProps, useCachedTruthy, } from './hooks'; export * from './components'; diff --git a/packages/block-editor/src/store/actions.js b/packages/block-editor/src/store/actions.js index 47d530c8319a23..6adbafe28341c6 100644 --- a/packages/block-editor/src/store/actions.js +++ b/packages/block-editor/src/store/actions.js @@ -640,13 +640,15 @@ export function showInsertionPoint( index, __unstableOptions = {} ) { - const { __unstableWithInserter, operation } = __unstableOptions; + const { __unstableWithInserter, operation, nearestSide } = + __unstableOptions; return { type: 'SHOW_INSERTION_POINT', rootClientId, index, __unstableWithInserter, operation, + nearestSide, }; } /** diff --git a/packages/block-editor/src/store/index.js b/packages/block-editor/src/store/index.js index 0bcc00cb5f6ae8..10e16a0779cd63 100644 --- a/packages/block-editor/src/store/index.js +++ b/packages/block-editor/src/store/index.js @@ -10,6 +10,7 @@ import reducer from './reducer'; import * as selectors from './selectors'; import * as privateActions from './private-actions'; import * as privateSelectors from './private-selectors'; +import * as resolvers from './resolvers'; import * as actions from './actions'; import { STORE_NAME } from './constants'; import { unlock } from '../lock-unlock'; @@ -22,6 +23,7 @@ import { unlock } from '../lock-unlock'; export const storeConfig = { reducer, selectors, + resolvers, actions, }; diff --git a/packages/block-editor/src/store/private-selectors.js b/packages/block-editor/src/store/private-selectors.js index e8230eea89daa3..adad08c7b98dc8 100644 --- a/packages/block-editor/src/store/private-selectors.js +++ b/packages/block-editor/src/store/private-selectors.js @@ -3,6 +3,11 @@ */ import createSelector from 'rememo'; +/** + * WordPress dependencies + */ +import { createRegistrySelector } from '@wordpress/data'; + /** * Internal dependencies */ @@ -11,11 +16,12 @@ import { getBlockParents, getBlockEditingMode, getSettings, - __experimentalGetParsedPattern, canInsertBlockType, - __experimentalGetAllowedPatterns, } from './selectors'; -import { getAllPatterns, checkAllowListRecursive } from './utils'; +import { checkAllowListRecursive, getAllPatternsDependants } from './utils'; +import { INSERTER_PATTERN_TYPES } from '../components/inserter/block-patterns-tab/utils'; +import { store } from './'; +import { unlock } from '../lock-unlock'; /** * Returns true if the block interface is hidden, or false otherwise. @@ -242,6 +248,10 @@ export const getInserterMediaCategories = createSelector( ] ); +export function getFetchedPatterns( state ) { + return state.blockPatterns; +} + /** * Returns whether there is at least one allowed pattern for inner blocks children. * This is useful for deferring the parsing of all patterns until needed. @@ -251,29 +261,74 @@ export const getInserterMediaCategories = createSelector( * * @return {boolean} If there is at least one allowed pattern. */ -export const hasAllowedPatterns = createSelector( - ( state, rootClientId = null ) => { - const patterns = getAllPatterns( state ); - const { allowedBlockTypes } = getSettings( state ); - return patterns.some( ( { name, inserter = true } ) => { - if ( ! inserter ) { - return false; - } - const { blocks } = __experimentalGetParsedPattern( state, name ); - return ( - checkAllowListRecursive( blocks, allowedBlockTypes ) && - blocks.every( ( { name: blockName } ) => - canInsertBlockType( state, blockName, rootClientId ) - ) +export const hasAllowedPatterns = createRegistrySelector( ( select ) => + createSelector( + ( state, rootClientId = null ) => { + const { getAllPatterns, __experimentalGetParsedPattern } = unlock( + select( store ) ); - } ); - }, - ( state, rootClientId ) => [ - ...__experimentalGetAllowedPatterns.getDependants( - state, - rootClientId - ), - ] + const patterns = getAllPatterns(); + const { allowedBlockTypes } = getSettings( state ); + return patterns.some( ( { name, inserter = true } ) => { + if ( ! inserter ) { + return false; + } + const { blocks } = __experimentalGetParsedPattern( name ); + return ( + checkAllowListRecursive( blocks, allowedBlockTypes ) && + blocks.every( ( { name: blockName } ) => + canInsertBlockType( state, blockName, rootClientId ) + ) + ); + } ); + }, + ( state, rootClientId ) => [ + getAllPatternsDependants( state ), + state.settings.allowedBlockTypes, + state.settings.templateLock, + state.blockListSettings[ rootClientId ], + state.blocks.byClientId.get( rootClientId ), + ] + ) +); + +export const getAllPatterns = createRegistrySelector( ( select ) => + createSelector( ( state ) => { + // This setting is left for back compat. + const { + __experimentalBlockPatterns = [], + __experimentalUserPatternCategories = [], + __experimentalReusableBlocks = [], + } = state.settings; + const userPatterns = ( __experimentalReusableBlocks ?? [] ).map( + ( userPattern ) => { + return { + name: `core/block/${ userPattern.id }`, + id: userPattern.id, + type: INSERTER_PATTERN_TYPES.user, + title: userPattern.title.raw, + categories: userPattern.wp_pattern_category.map( + ( catId ) => { + const category = ( + __experimentalUserPatternCategories ?? [] + ).find( ( { id } ) => id === catId ); + return category ? category.slug : catId; + } + ), + content: userPattern.content.raw, + syncStatus: userPattern.wp_pattern_sync_status, + }; + } + ); + return [ + ...userPatterns, + ...__experimentalBlockPatterns, + ...unlock( select( store ) ).getFetchedPatterns(), + ].filter( + ( x, index, arr ) => + index === arr.findIndex( ( y ) => x.name === y.name ) + ); + }, getAllPatternsDependants ) ); /** diff --git a/packages/block-editor/src/store/reducer.js b/packages/block-editor/src/store/reducer.js index fa6c8942e66add..70e2dc3488772d 100644 --- a/packages/block-editor/src/store/reducer.js +++ b/packages/block-editor/src/store/reducer.js @@ -1599,13 +1599,19 @@ export function blocksMode( state = {}, action ) { export function insertionPoint( state = null, action ) { switch ( action.type ) { case 'SHOW_INSERTION_POINT': { - const { rootClientId, index, __unstableWithInserter, operation } = - action; + const { + rootClientId, + index, + __unstableWithInserter, + operation, + nearestSide, + } = action; const nextState = { rootClientId, index, __unstableWithInserter, operation, + nearestSide, }; // Bail out updates if the states are the same. @@ -2017,6 +2023,15 @@ export function lastFocus( state = false, action ) { return state; } +function blockPatterns( state = [], action ) { + switch ( action.type ) { + case 'RECEIVE_BLOCK_PATTERNS': + return action.patterns; + } + + return state; +} + const combinedReducers = combineReducers( { blocks, isTyping, @@ -2047,6 +2062,7 @@ const combinedReducers = combineReducers( { blockRemovalRules, openedBlockSettingsMenu, registeredInserterMediaCategories, + blockPatterns, } ); function withAutomaticChangeReset( reducer ) { diff --git a/packages/block-editor/src/store/resolvers.js b/packages/block-editor/src/store/resolvers.js new file mode 100644 index 00000000000000..40c51d241ac676 --- /dev/null +++ b/packages/block-editor/src/store/resolvers.js @@ -0,0 +1,17 @@ +export const getFetchedPatterns = + () => + async ( { dispatch, select } ) => { + const { __experimentalFetchBlockPatterns } = select.getSettings(); + if ( ! __experimentalFetchBlockPatterns ) { + return []; + } + const patterns = await __experimentalFetchBlockPatterns(); + dispatch( { type: 'RECEIVE_BLOCK_PATTERNS', patterns } ); + }; + +getFetchedPatterns.shouldInvalidate = ( action ) => { + return ( + action.type === 'UPDATE_SETTINGS' && + !! action.settings.__experimentalFetchBlockPatterns + ); +}; diff --git a/packages/block-editor/src/store/selectors.js b/packages/block-editor/src/store/selectors.js index 5e47e966ef3345..099c6b30222efc 100644 --- a/packages/block-editor/src/store/selectors.js +++ b/packages/block-editor/src/store/selectors.js @@ -27,11 +27,13 @@ import { createRegistrySelector } from '@wordpress/data'; * Internal dependencies */ import { - getAllPatterns, checkAllowListRecursive, checkAllowList, + getAllPatternsDependants, } from './utils'; import { orderBy } from '../utils/sorting'; +import { STORE_NAME } from './constants'; +import { unlock } from '../lock-unlock'; /** * A block selection object. @@ -318,14 +320,14 @@ export const getGlobalBlockCount = createSelector( ); /** - * Returns all global blocks that match a blockName. Results include nested blocks. + * Returns all blocks that match a blockName. Results include nested blocks. * * @param {Object} state Global application state. * @param {?string} blockName Optional block name, if not specified, returns an empty array. * * @return {Array} Array of clientIds of blocks with name equal to blockName. */ -export const __experimentalGetGlobalBlocksByName = createSelector( +export const getBlocksByName = createSelector( ( state, blockName ) => { if ( ! blockName ) { return EMPTY_ARRAY; @@ -343,6 +345,27 @@ export const __experimentalGetGlobalBlocksByName = createSelector( ( state ) => [ state.blocks.order, state.blocks.byClientId ] ); +/** + * Returns all global blocks that match a blockName. Results include nested blocks. + * + * @deprecated + * + * @param {Object} state Global application state. + * @param {?string} blockName Optional block name, if not specified, returns an empty array. + * + * @return {Array} Array of clientIds of blocks with name equal to blockName. + */ +export function __experimentalGetGlobalBlocksByName( state, blockName ) { + deprecated( + "wp.data.select( 'core/block-editor' ).__experimentalGetGlobalBlocksByName", + { + since: '6.5', + alternative: `wp.data.select( 'core/block-editor' ).getBlocksByName`, + } + ); + return getBlocksByName( state, blockName ); +} + /** * Given an array of block client IDs, returns the corresponding array of block * objects. @@ -2239,41 +2262,36 @@ export const __experimentalGetDirectInsertBlock = createSelector( ] ); -export const __experimentalGetParsedPattern = createSelector( - ( state, patternName ) => { - const patterns = getAllPatterns( state ); - const pattern = patterns.find( ( { name } ) => name === patternName ); - if ( ! pattern ) { - return null; - } - return { - ...pattern, - blocks: parse( pattern.content, { - __unstableSkipMigrationLogs: true, - } ), - }; - }, - ( state ) => [ getAllPatterns( state ) ] -); - -const getAllAllowedPatterns = createSelector( - ( state ) => { - const patterns = getAllPatterns( state ); - const { allowedBlockTypes } = getSettings( state ); - - const parsedPatterns = patterns - .filter( ( { inserter = true } ) => !! inserter ) - .map( ( { name } ) => - __experimentalGetParsedPattern( state, name ) +export const __experimentalGetParsedPattern = createRegistrySelector( + ( select ) => + createSelector( ( state, patternName ) => { + const { getAllPatterns } = unlock( select( STORE_NAME ) ); + const patterns = getAllPatterns(); + const pattern = patterns.find( + ( { name } ) => name === patternName ); - const allowedPatterns = parsedPatterns.filter( ( { blocks } ) => - checkAllowListRecursive( blocks, allowedBlockTypes ) - ); - return allowedPatterns; - }, - ( state ) => [ getAllPatterns( state ), state.settings.allowedBlockTypes ] + if ( ! pattern ) { + return null; + } + return { + ...pattern, + blocks: parse( pattern.content, { + __unstableSkipMigrationLogs: true, + } ), + }; + }, getAllPatternsDependants ) ); +const getAllowedPatternsDependants = ( state, rootClientId ) => { + return [ + ...getAllPatternsDependants( state ), + state.settings.allowedBlockTypes, + state.settings.templateLock, + state.blockListSettings[ rootClientId ], + state.blocks.byClientId.get( rootClientId ), + ]; +}; + /** * Returns the list of allowed patterns for inner blocks children. * @@ -2282,24 +2300,33 @@ const getAllAllowedPatterns = createSelector( * * @return {Array?} The list of allowed patterns. */ -export const __experimentalGetAllowedPatterns = createSelector( - ( state, rootClientId = null ) => { - const availableParsedPatterns = getAllAllowedPatterns( state ); - const patternsAllowed = availableParsedPatterns.filter( - ( { blocks } ) => - blocks.every( ( { name } ) => - canInsertBlockType( state, name, rootClientId ) - ) - ); +export const __experimentalGetAllowedPatterns = createRegistrySelector( + ( select ) => { + return createSelector( ( state, rootClientId = null ) => { + const { + getAllPatterns, + __experimentalGetParsedPattern: getParsedPattern, + } = unlock( select( STORE_NAME ) ); + const patterns = getAllPatterns(); + const { allowedBlockTypes } = getSettings( state ); + + const parsedPatterns = patterns + .filter( ( { inserter = true } ) => !! inserter ) + .map( ( { name } ) => getParsedPattern( name ) ); + const availableParsedPatterns = parsedPatterns.filter( + ( { blocks } ) => + checkAllowListRecursive( blocks, allowedBlockTypes ) + ); + const patternsAllowed = availableParsedPatterns.filter( + ( { blocks } ) => + blocks.every( ( { name } ) => + canInsertBlockType( state, name, rootClientId ) + ) + ); - return patternsAllowed; - }, - ( state, rootClientId ) => [ - getAllAllowedPatterns( state ), - state.settings.templateLock, - state.blockListSettings[ rootClientId ], - state.blocks.byClientId.get( rootClientId ), - ] + return patternsAllowed; + }, getAllowedPatternsDependants ); + } ); /** @@ -2315,36 +2342,34 @@ export const __experimentalGetAllowedPatterns = createSelector( * * @return {Array} The list of matched block patterns based on declared `blockTypes` and block name. */ -export const getPatternsByBlockTypes = createSelector( - ( state, blockNames, rootClientId = null ) => { - if ( ! blockNames ) return EMPTY_ARRAY; - const patterns = __experimentalGetAllowedPatterns( - state, - rootClientId - ); - const normalizedBlockNames = Array.isArray( blockNames ) - ? blockNames - : [ blockNames ]; - const filteredPatterns = patterns.filter( ( pattern ) => - pattern?.blockTypes?.some?.( ( blockName ) => - normalizedBlockNames.includes( blockName ) - ) - ); - if ( filteredPatterns.length === 0 ) { - return EMPTY_ARRAY; - } - return filteredPatterns; - }, - ( state, blockNames, rootClientId ) => [ - ...__experimentalGetAllowedPatterns.getDependants( - state, - rootClientId - ), - ] +export const getPatternsByBlockTypes = createRegistrySelector( ( select ) => + createSelector( + ( state, blockNames, rootClientId = null ) => { + if ( ! blockNames ) return EMPTY_ARRAY; + const patterns = + select( STORE_NAME ).__experimentalGetAllowedPatterns( + rootClientId + ); + const normalizedBlockNames = Array.isArray( blockNames ) + ? blockNames + : [ blockNames ]; + const filteredPatterns = patterns.filter( ( pattern ) => + pattern?.blockTypes?.some?.( ( blockName ) => + normalizedBlockNames.includes( blockName ) + ) + ); + if ( filteredPatterns.length === 0 ) { + return EMPTY_ARRAY; + } + return filteredPatterns; + }, + ( state, blockNames, rootClientId ) => + getAllowedPatternsDependants( state, rootClientId ) + ) ); -export const __experimentalGetPatternsByBlockTypes = createSelector( - ( state, blockNames, rootClientId = null ) => { +export const __experimentalGetPatternsByBlockTypes = createRegistrySelector( + ( select ) => { deprecated( 'wp.data.select( "core/block-editor" ).__experimentalGetPatternsByBlockTypes', { @@ -2354,14 +2379,8 @@ export const __experimentalGetPatternsByBlockTypes = createSelector( version: '6.4', } ); - return getPatternsByBlockTypes( state, blockNames, rootClientId ); - }, - ( state, blockNames, rootClientId ) => [ - ...__experimentalGetAllowedPatterns.getDependants( - state, - rootClientId - ), - ] + return select( STORE_NAME ).getPatternsByBlockTypes; + } ); /** @@ -2381,45 +2400,46 @@ export const __experimentalGetPatternsByBlockTypes = createSelector( * * @return {WPBlockPattern[]} Items that are eligible for a pattern transformation. */ -export const __experimentalGetPatternTransformItems = createSelector( - ( state, blocks, rootClientId = null ) => { - if ( ! blocks ) return EMPTY_ARRAY; - /** - * For now we only handle blocks without InnerBlocks and take into account - * the `__experimentalRole` property of blocks' attributes for the transformation. - * Note that the blocks have been retrieved through `getBlock`, which doesn't - * return the inner blocks of an inner block controller, so we still need - * to check for this case too. - */ - if ( - blocks.some( - ( { clientId, innerBlocks } ) => - innerBlocks.length || - areInnerBlocksControlled( state, clientId ) - ) - ) { - return EMPTY_ARRAY; - } - - // Create a Set of the selected block names that is used in patterns filtering. - const selectedBlockNames = Array.from( - new Set( blocks.map( ( { name } ) => name ) ) - ); - /** - * Here we will return first set of possible eligible block patterns, - * by checking the `blockTypes` property. We still have to recurse through - * block pattern's blocks and try to find matches from the selected blocks. - * Now this happens in the consumer to avoid heavy operations in the selector. - */ - return getPatternsByBlockTypes( - state, - selectedBlockNames, - rootClientId - ); - }, - ( state, blocks, rootClientId ) => [ - ...getPatternsByBlockTypes.getDependants( state, rootClientId ), - ] +export const __experimentalGetPatternTransformItems = createRegistrySelector( + ( select ) => + createSelector( + ( state, blocks, rootClientId = null ) => { + if ( ! blocks ) return EMPTY_ARRAY; + /** + * For now we only handle blocks without InnerBlocks and take into account + * the `__experimentalRole` property of blocks' attributes for the transformation. + * Note that the blocks have been retrieved through `getBlock`, which doesn't + * return the inner blocks of an inner block controller, so we still need + * to check for this case too. + */ + if ( + blocks.some( + ( { clientId, innerBlocks } ) => + innerBlocks.length || + areInnerBlocksControlled( state, clientId ) + ) + ) { + return EMPTY_ARRAY; + } + + // Create a Set of the selected block names that is used in patterns filtering. + const selectedBlockNames = Array.from( + new Set( blocks.map( ( { name } ) => name ) ) + ); + /** + * Here we will return first set of possible eligible block patterns, + * by checking the `blockTypes` property. We still have to recurse through + * block pattern's blocks and try to find matches from the selected blocks. + * Now this happens in the consumer to avoid heavy operations in the selector. + */ + return select( STORE_NAME ).getPatternsByBlockTypes( + selectedBlockNames, + rootClientId + ); + }, + ( state, blocks, rootClientId ) => + getAllowedPatternsDependants( state, rootClientId ) + ) ); /** @@ -2721,8 +2741,7 @@ export const __unstableGetContentLockingParent = createSelector( current = state.blocks.parents.get( current ); if ( ( current && - getBlockName( state, current ) === 'core/block' && - window.__experimentalPatternPartialSyncing ) || + getBlockName( state, current ) === 'core/block' ) || ( current && getTemplateLock( state, current ) === 'contentOnly' ) ) { diff --git a/packages/block-editor/src/store/test/registry-selectors.js b/packages/block-editor/src/store/test/registry-selectors.js new file mode 100644 index 00000000000000..89115c75f05144 --- /dev/null +++ b/packages/block-editor/src/store/test/registry-selectors.js @@ -0,0 +1,431 @@ +/** + * WordPress dependencies + */ +import { registerBlockType, unregisterBlockType } from '@wordpress/blocks'; +import { select, dispatch } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { store } from '../'; + +describe( 'selectors', () => { + beforeEach( () => { + registerBlockType( 'core/test-block-a', { + save: ( props ) => props.attributes.text, + category: 'design', + title: 'Test Block A', + icon: 'test', + keywords: [ 'testing' ], + } ); + + registerBlockType( 'core/test-block-b', { + save: ( props ) => props.attributes.text, + category: 'text', + title: 'Test Block B', + icon: 'test', + keywords: [ 'testing' ], + supports: { + multiple: false, + }, + } ); + } ); + + afterEach( async () => { + unregisterBlockType( 'core/test-block-a' ); + unregisterBlockType( 'core/test-block-b' ); + } ); + + describe( '__experimentalGetAllowedPatterns', () => { + beforeAll( async () => { + await dispatch( store ).resetBlocks( [ + { + clientId: 'block1', + name: 'core/test-block-a', + innerBlocks: [], + }, + { + clientId: 'block2', + name: 'core/test-block-b', + innerBlocks: [], + }, + ] ); + await dispatch( store ).updateSettings( { + __experimentalBlockPatterns: [ + { + name: 'pattern-a', + title: 'pattern with a', + content: ``, + }, + { + name: 'pattern-b', + title: 'pattern with b', + content: + '', + }, + { + name: 'pattern-c', + title: 'pattern hidden from UI', + inserter: false, + content: + '', + }, + ], + } ); + await dispatch( store ).updateBlockListSettings( 'block1', { + allowedBlocks: [ 'core/test-block-b' ], + } ); + } ); + + afterAll( async () => { + await dispatch( store ).resetBlocks( [] ); + await dispatch( store ).updateSettings( { + __experimentalBlockPatterns: [], + } ); + await dispatch( store ).updateBlockListSettings( 'block1', {} ); + } ); + + it( 'should return all patterns for root level', () => { + expect( + select( store ).__experimentalGetAllowedPatterns( null ) + ).toHaveLength( 2 ); + } ); + it( 'should return patterns that consists of blocks allowed for the specified client ID', () => { + expect( + select( store ).__experimentalGetAllowedPatterns( 'block1' ) + ).toHaveLength( 1 ); + expect( + select( store ).__experimentalGetAllowedPatterns( 'block2' ) + ).toHaveLength( 0 ); + } ); + it( 'should return empty array if only patterns hidden from UI exist', () => { + expect( + select( store ).__experimentalGetAllowedPatterns( { + blocks: { byClientId: new Map() }, + blockListSettings: {}, + settings: { + __experimentalBlockPatterns: [ + { + name: 'pattern-c', + title: 'pattern hidden from UI', + inserter: false, + content: + '', + }, + ], + }, + } ) + ).toHaveLength( 0 ); + } ); + } ); + + describe( '__experimentalGetParsedPattern', () => { + beforeAll( async () => { + await dispatch( store ).updateSettings( { + __experimentalBlockPatterns: [ + { + name: 'pattern-a', + title: 'pattern with a', + content: ``, + }, + { + name: 'pattern-hidden-from-ui', + title: 'pattern hidden from UI', + inserter: false, + content: + '', + }, + ], + } ); + } ); + + afterAll( async () => { + await dispatch( store ).updateSettings( { + __experimentalBlockPatterns: [], + } ); + } ); + + it( 'should return proper results when pattern does not exist', () => { + expect( + select( store ).__experimentalGetParsedPattern( 'not there' ) + ).toBeNull(); + } ); + it( 'should return existing pattern properly parsed', () => { + const { name, blocks } = + select( store ).__experimentalGetParsedPattern( 'pattern-a' ); + expect( name ).toEqual( 'pattern-a' ); + expect( blocks ).toHaveLength( 1 ); + expect( blocks[ 0 ] ).toEqual( + expect.objectContaining( { + name: 'core/test-block-a', + } ) + ); + } ); + it( 'should return hidden from UI pattern when requested', () => { + const { name, blocks, inserter } = select( + store + ).__experimentalGetParsedPattern( 'pattern-hidden-from-ui' ); + expect( name ).toEqual( 'pattern-hidden-from-ui' ); + expect( inserter ).toBeFalsy(); + expect( blocks ).toHaveLength( 2 ); + expect( blocks[ 0 ] ).toEqual( + expect.objectContaining( { + name: 'core/test-block-a', + } ) + ); + } ); + } ); + + describe( 'getPatternsByBlockTypes', () => { + beforeAll( async () => { + await dispatch( store ).resetBlocks( [ + { + clientId: 'block1', + name: 'core/test-block-a', + innerBlocks: [], + }, + ] ); + await dispatch( store ).updateSettings( { + __experimentalBlockPatterns: [ + { + name: 'pattern-a', + blockTypes: [ 'test/block-a' ], + title: 'pattern a', + content: + '', + }, + { + name: 'pattern-b', + blockTypes: [ 'test/block-b' ], + title: 'pattern b', + content: + '', + }, + { + title: 'pattern c', + blockTypes: [ 'test/block-a' ], + content: + '', + }, + ], + } ); + await dispatch( store ).updateBlockListSettings( 'block1', { + allowedBlocks: [ 'core/test-block-b' ], + } ); + } ); + + afterAll( async () => { + await dispatch( store ).resetBlocks( [] ); + await dispatch( store ).updateSettings( { + __experimentalBlockPatterns: [], + } ); + await dispatch( store ).updateBlockListSettings( 'block1', {} ); + } ); + + it( 'should return empty array if no block name is provided', () => { + expect( select( store ).getPatternsByBlockTypes() ).toEqual( [] ); + } ); + it( 'should return empty array if no match is found', () => { + const patterns = select( store ).getPatternsByBlockTypes( + 'test/block-not-exists' + ); + expect( patterns ).toEqual( [] ); + } ); + it( 'should return the same empty array in both empty array cases', () => { + const patterns1 = select( store ).getPatternsByBlockTypes(); + const patterns2 = select( store ).getPatternsByBlockTypes( + 'test/block-not-exists' + ); + expect( patterns1 ).toBe( patterns2 ); + } ); + it( 'should return proper results when there are matched block patterns', () => { + const patterns = + select( store ).getPatternsByBlockTypes( 'test/block-a' ); + expect( patterns ).toHaveLength( 2 ); + expect( patterns ).toEqual( + expect.arrayContaining( [ + expect.objectContaining( { title: 'pattern a' } ), + expect.objectContaining( { title: 'pattern c' } ), + ] ) + ); + } ); + it( 'should return proper result with matched patterns and allowed blocks from rootClientId', () => { + const patterns = select( store ).getPatternsByBlockTypes( + 'test/block-a', + 'block1' + ); + expect( patterns ).toHaveLength( 1 ); + expect( patterns[ 0 ] ).toEqual( + expect.objectContaining( { title: 'pattern c' } ) + ); + } ); + } ); + + describe( '__experimentalGetPatternTransformItems', () => { + beforeAll( async () => { + await dispatch( store ).resetBlocks( [ + { + clientId: 'block1', + name: 'core/test-block-a', + innerBlocks: [], + }, + { + clientId: 'block2', + name: 'core/test-block-b', + innerBlocks: [], + }, + ] ); + await dispatch( store ).setHasControlledInnerBlocks( + 'block2-clientId', + true + ); + await dispatch( store ).updateSettings( { + __experimentalBlockPatterns: [ + { + name: 'pattern-a', + blockTypes: [ 'test/block-a' ], + title: 'pattern a', + content: + '', + }, + { + name: 'pattern-b', + blockTypes: [ 'test/block-b' ], + title: 'pattern b', + content: + '', + }, + { + name: 'pattern-c', + title: 'pattern c', + blockTypes: [ 'test/block-a' ], + content: + '', + }, + { + name: 'pattern-mix', + title: 'pattern mix', + blockTypes: [ + 'core/test-block-a', + 'core/test-block-b', + ], + content: + '', + }, + ], + } ); + } ); + + afterAll( async () => { + await dispatch( store ).resetBlocks( [] ); + await dispatch( store ).updateSettings( { + __experimentalBlockPatterns: [], + } ); + await dispatch( store ).updateBlockListSettings( 'block1', {} ); + } ); + + describe( 'should return empty array', () => { + it( 'when no blocks are selected', () => { + expect( + select( store ).__experimentalGetPatternTransformItems() + ).toEqual( [] ); + } ); + it( 'when a selected block has inner blocks', () => { + const blocks = [ + { name: 'core/test-block-a', innerBlocks: [] }, + { + name: 'core/test-block-b', + innerBlocks: [ { name: 'some inner block' } ], + }, + ]; + expect( + select( store ).__experimentalGetPatternTransformItems( + blocks + ) + ).toEqual( [] ); + } ); + it( 'when a selected block has controlled inner blocks', () => { + const blocks = [ + { name: 'core/test-block-a', innerBlocks: [] }, + { + name: 'core/test-block-b', + clientId: 'block2-clientId', + innerBlocks: [], + }, + ]; + expect( + select( store ).__experimentalGetPatternTransformItems( + blocks + ) + ).toEqual( [] ); + } ); + it( 'when no patterns are available based on the selected blocks', () => { + const blocks = [ + { name: 'block-with-no-patterns', innerBlocks: [] }, + ]; + expect( + select( store ).__experimentalGetPatternTransformItems( + blocks + ) + ).toEqual( [] ); + } ); + } ); + describe( 'should return proper results', () => { + it( 'when a single block is selected', () => { + const blocks = [ + { name: 'core/test-block-b', innerBlocks: [] }, + ]; + const patterns = + select( store ).__experimentalGetPatternTransformItems( + blocks + ); + expect( patterns ).toHaveLength( 1 ); + expect( patterns[ 0 ] ).toEqual( + expect.objectContaining( { + name: 'pattern-mix', + } ) + ); + } ); + it( 'when different multiple blocks are selected', () => { + const blocks = [ + { name: 'core/test-block-b', innerBlocks: [] }, + { name: 'test/block-b', innerBlocks: [] }, + { name: 'some other block', innerBlocks: [] }, + ]; + const patterns = + select( store ).__experimentalGetPatternTransformItems( + blocks + ); + expect( patterns ).toHaveLength( 2 ); + expect( patterns ).toEqual( + expect.arrayContaining( [ + expect.objectContaining( { + name: 'pattern-mix', + } ), + expect.objectContaining( { + name: 'pattern-b', + } ), + ] ) + ); + } ); + it( 'when multiple blocks are selected containing multiple times the same block', () => { + const blocks = [ + { name: 'core/test-block-b', innerBlocks: [] }, + { name: 'some other block', innerBlocks: [] }, + { name: 'core/test-block-a', innerBlocks: [] }, + { name: 'core/test-block-b', innerBlocks: [] }, + ]; + const patterns = + select( store ).__experimentalGetPatternTransformItems( + blocks + ); + expect( patterns ).toHaveLength( 1 ); + expect( patterns[ 0 ] ).toEqual( + expect.objectContaining( { + name: 'pattern-mix', + } ) + ); + } ); + } ); + } ); +} ); diff --git a/packages/block-editor/src/store/test/selectors.js b/packages/block-editor/src/store/test/selectors.js index 5046b6e5b83d68..29833611b17f4a 100644 --- a/packages/block-editor/src/store/test/selectors.js +++ b/packages/block-editor/src/store/test/selectors.js @@ -65,14 +65,10 @@ const { __experimentalGetLastBlockAttributeChanges, getLowestCommonAncestorWithSelectedBlock, __experimentalGetActiveBlockIdByBlockNames: getActiveBlockIdByBlockNames, - __experimentalGetAllowedPatterns, - __experimentalGetParsedPattern, - getPatternsByBlockTypes, __unstableGetClientIdWithClientIdsTree, __unstableGetClientIdsTree, - __experimentalGetPatternTransformItems, wasBlockJustInserted, - __experimentalGetGlobalBlocksByName, + getBlocksByName, getBlockEditingMode, } = selectors; @@ -975,7 +971,7 @@ describe( 'selectors', () => { } ); } ); - describe( '__experimentalGetGlobalBlocksByName', () => { + describe( 'getBlocksByName', () => { const state = { blocks: { byClientId: new Map( @@ -1017,31 +1013,25 @@ describe( 'selectors', () => { }; it( 'should return the clientIds of blocks of a given type', () => { - expect( - __experimentalGetGlobalBlocksByName( state, 'core/heading' ) - ).toStrictEqual( [ '123' ] ); + expect( getBlocksByName( state, 'core/heading' ) ).toStrictEqual( [ + '123', + ] ); } ); it( 'should return the clientIds of blocks of a given type even if blocks are nested', () => { - expect( - __experimentalGetGlobalBlocksByName( state, 'core/paragraph' ) - ).toStrictEqual( [ '456', '1415', '1213' ] ); + expect( getBlocksByName( state, 'core/paragraph' ) ).toStrictEqual( + [ '456', '1415', '1213' ] + ); } ); it( 'Should return empty array if no blocks match. The empty array should be the same reference', () => { - const result = __experimentalGetGlobalBlocksByName( - state, - 'test/missing' + const result = getBlocksByName( state, 'test/missing' ); + expect( getBlocksByName( state, 'test/missing' ) ).toStrictEqual( + [] + ); + expect( getBlocksByName( state, 'test/missing2' ) === result ).toBe( + true ); - expect( - __experimentalGetGlobalBlocksByName( state, 'test/missing' ) - ).toStrictEqual( [] ); - expect( - __experimentalGetGlobalBlocksByName( - state, - 'test/missing2' - ) === result - ).toBe( true ); } ); } ); @@ -4205,382 +4195,6 @@ describe( 'selectors', () => { } ); } ); - describe( '__experimentalGetAllowedPatterns', () => { - const state = { - blocks: { - byClientId: new Map( - Object.entries( { - block1: { name: 'core/test-block-a' }, - block2: { name: 'core/test-block-b' }, - } ) - ), - attributes: new Map( - Object.entries( { - block1: {}, - block2: {}, - } ) - ), - parents: new Map( - Object.entries( { - block1: '', - block2: '', - } ) - ), - }, - blockListSettings: { - block1: { - allowedBlocks: [ 'core/test-block-b' ], - }, - block2: { - allowedBlocks: [], - }, - }, - settings: { - __experimentalBlockPatterns: [ - { - name: 'pattern-a', - title: 'pattern with a', - content: ``, - }, - { - name: 'pattern-b', - title: 'pattern with b', - content: - '', - }, - { - name: 'pattern-c', - title: 'pattern hidden from UI', - inserter: false, - content: - '', - }, - ], - }, - blockEditingModes: new Map(), - }; - - it( 'should return all patterns for root level', () => { - expect( - __experimentalGetAllowedPatterns( state, null ) - ).toHaveLength( 2 ); - } ); - - it( 'should return patterns that consists of blocks allowed for the specified client ID', () => { - expect( - __experimentalGetAllowedPatterns( state, 'block1' ) - ).toHaveLength( 1 ); - - expect( - __experimentalGetAllowedPatterns( state, 'block2' ) - ).toHaveLength( 0 ); - } ); - it( 'should return empty array if only patterns hidden from UI exist', () => { - expect( - __experimentalGetAllowedPatterns( { - blocks: { byClientId: new Map() }, - blockListSettings: {}, - settings: { - __experimentalBlockPatterns: [ - { - name: 'pattern-c', - title: 'pattern hidden from UI', - inserter: false, - content: - '', - }, - ], - }, - } ) - ).toHaveLength( 0 ); - } ); - } ); - describe( '__experimentalGetParsedPattern', () => { - const state = { - settings: { - __experimentalBlockPatterns: [ - { - name: 'pattern-a', - title: 'pattern with a', - content: ``, - }, - { - name: 'pattern-hidden-from-ui', - title: 'pattern hidden from UI', - inserter: false, - content: - '', - }, - ], - }, - }; - it( 'should return proper results when pattern does not exist', () => { - expect( - __experimentalGetParsedPattern( state, 'not there' ) - ).toBeNull(); - } ); - it( 'should return existing pattern properly parsed', () => { - const { name, blocks } = __experimentalGetParsedPattern( - state, - 'pattern-a' - ); - expect( name ).toEqual( 'pattern-a' ); - expect( blocks ).toHaveLength( 1 ); - expect( blocks[ 0 ] ).toEqual( - expect.objectContaining( { - name: 'core/test-block-a', - } ) - ); - } ); - it( 'should return hidden from UI pattern when requested', () => { - const { name, blocks, inserter } = __experimentalGetParsedPattern( - state, - 'pattern-hidden-from-ui' - ); - expect( name ).toEqual( 'pattern-hidden-from-ui' ); - expect( inserter ).toBeFalsy(); - expect( blocks ).toHaveLength( 2 ); - expect( blocks[ 0 ] ).toEqual( - expect.objectContaining( { - name: 'core/test-block-a', - } ) - ); - } ); - } ); - describe( 'getPatternsByBlockTypes', () => { - const state = { - blocks: { - byClientId: new Map( - Object.entries( { - block1: { name: 'core/test-block-a' }, - } ) - ), - parents: new Map(), - }, - blockListSettings: { - block1: { - allowedBlocks: [ 'core/test-block-b' ], - }, - }, - settings: { - __experimentalBlockPatterns: [ - { - name: 'pattern-a', - blockTypes: [ 'test/block-a' ], - title: 'pattern a', - content: - '', - }, - { - name: 'pattern-b', - blockTypes: [ 'test/block-b' ], - title: 'pattern b', - content: - '', - }, - { - title: 'pattern c', - blockTypes: [ 'test/block-a' ], - content: - '', - }, - ], - }, - blockEditingModes: new Map(), - }; - it( 'should return empty array if no block name is provided', () => { - expect( getPatternsByBlockTypes( state ) ).toEqual( [] ); - } ); - it( 'should return empty array if no match is found', () => { - const patterns = getPatternsByBlockTypes( - state, - 'test/block-not-exists' - ); - expect( patterns ).toEqual( [] ); - } ); - it( 'should return the same empty array in both empty array cases', () => { - const patterns1 = getPatternsByBlockTypes( state ); - const patterns2 = getPatternsByBlockTypes( - state, - 'test/block-not-exists' - ); - expect( patterns1 ).toBe( patterns2 ); - } ); - it( 'should return proper results when there are matched block patterns', () => { - const patterns = getPatternsByBlockTypes( state, 'test/block-a' ); - expect( patterns ).toHaveLength( 2 ); - expect( patterns ).toEqual( - expect.arrayContaining( [ - expect.objectContaining( { title: 'pattern a' } ), - expect.objectContaining( { title: 'pattern c' } ), - ] ) - ); - } ); - it( 'should return proper result with matched patterns and allowed blocks from rootClientId', () => { - const patterns = getPatternsByBlockTypes( - state, - 'test/block-a', - 'block1' - ); - expect( patterns ).toHaveLength( 1 ); - expect( patterns[ 0 ] ).toEqual( - expect.objectContaining( { title: 'pattern c' } ) - ); - } ); - } ); - describe( '__experimentalGetPatternTransformItems', () => { - const state = { - blocks: { - byClientId: new Map( - Object.entries( { - block1: { name: 'core/test-block-a' }, - block2: { name: 'core/test-block-b' }, - } ) - ), - parents: new Map(), - controlledInnerBlocks: { 'block2-clientId': true }, - }, - blockListSettings: { - block1: { - allowedBlocks: [ 'core/test-block-b' ], - }, - }, - settings: { - __experimentalBlockPatterns: [ - { - name: 'pattern-a', - blockTypes: [ 'test/block-a' ], - title: 'pattern a', - content: - '', - }, - { - name: 'pattern-b', - blockTypes: [ 'test/block-b' ], - title: 'pattern b', - content: - '', - }, - { - name: 'pattern-c', - title: 'pattern c', - blockTypes: [ 'test/block-a' ], - content: - '', - }, - { - name: 'pattern-mix', - title: 'pattern mix', - blockTypes: [ - 'core/test-block-a', - 'core/test-block-b', - ], - content: - '', - }, - ], - }, - blockEditingModes: new Map(), - }; - describe( 'should return empty array', () => { - it( 'when no blocks are selected', () => { - expect( - __experimentalGetPatternTransformItems( state ) - ).toEqual( [] ); - } ); - it( 'when a selected block has inner blocks', () => { - const blocks = [ - { name: 'core/test-block-a', innerBlocks: [] }, - { - name: 'core/test-block-b', - innerBlocks: [ { name: 'some inner block' } ], - }, - ]; - expect( - __experimentalGetPatternTransformItems( state, blocks ) - ).toEqual( [] ); - } ); - it( 'when a selected block has controlled inner blocks', () => { - const blocks = [ - { name: 'core/test-block-a', innerBlocks: [] }, - { - name: 'core/test-block-b', - clientId: 'block2-clientId', - innerBlocks: [], - }, - ]; - expect( - __experimentalGetPatternTransformItems( state, blocks ) - ).toEqual( [] ); - } ); - it( 'when no patterns are available based on the selected blocks', () => { - const blocks = [ - { name: 'block-with-no-patterns', innerBlocks: [] }, - ]; - expect( - __experimentalGetPatternTransformItems( state, blocks ) - ).toEqual( [] ); - } ); - } ); - describe( 'should return proper results', () => { - it( 'when a single block is selected', () => { - const blocks = [ - { name: 'core/test-block-b', innerBlocks: [] }, - ]; - const patterns = __experimentalGetPatternTransformItems( - state, - blocks - ); - expect( patterns ).toHaveLength( 1 ); - expect( patterns[ 0 ] ).toEqual( - expect.objectContaining( { - name: 'pattern-mix', - } ) - ); - } ); - it( 'when different multiple blocks are selected', () => { - const blocks = [ - { name: 'core/test-block-b', innerBlocks: [] }, - { name: 'test/block-b', innerBlocks: [] }, - { name: 'some other block', innerBlocks: [] }, - ]; - const patterns = __experimentalGetPatternTransformItems( - state, - blocks - ); - expect( patterns ).toHaveLength( 2 ); - expect( patterns ).toEqual( - expect.arrayContaining( [ - expect.objectContaining( { - name: 'pattern-mix', - } ), - expect.objectContaining( { - name: 'pattern-b', - } ), - ] ) - ); - } ); - it( 'when multiple blocks are selected containing multiple times the same block', () => { - const blocks = [ - { name: 'core/test-block-b', innerBlocks: [] }, - { name: 'some other block', innerBlocks: [] }, - { name: 'core/test-block-a', innerBlocks: [] }, - { name: 'core/test-block-b', innerBlocks: [] }, - ]; - const patterns = __experimentalGetPatternTransformItems( - state, - blocks - ); - expect( patterns ).toHaveLength( 1 ); - expect( patterns[ 0 ] ).toEqual( - expect.objectContaining( { - name: 'pattern-mix', - } ) - ); - } ); - } ); - } ); - describe( 'wasBlockJustInserted', () => { it( 'should return true if the client id passed to wasBlockJustInserted is found within the state', () => { const expectedClientId = '62bfef6e-d5e9-43ba-b7f9-c77cf354141f'; diff --git a/packages/block-editor/src/store/utils.js b/packages/block-editor/src/store/utils.js index 7587dcdf56fd79..6cde56da1b55a7 100644 --- a/packages/block-editor/src/store/utils.js +++ b/packages/block-editor/src/store/utils.js @@ -1,53 +1,3 @@ -/** - * External dependencies - */ -import createSelector from 'rememo'; - -/** - * Internal dependencies - */ -import { INSERTER_PATTERN_TYPES } from '../components/inserter/block-patterns-tab/utils'; - -export const getUserPatterns = createSelector( - ( state ) => { - const userPatterns = state.settings.__experimentalReusableBlocks ?? []; - const userPatternCategories = - state.settings.__experimentalUserPatternCategories ?? []; - return userPatterns.map( ( userPattern ) => { - return { - name: `core/block/${ userPattern.id }`, - id: userPattern.id, - type: INSERTER_PATTERN_TYPES.user, - title: userPattern.title.raw, - categories: userPattern.wp_pattern_category.map( ( catId ) => { - const category = userPatternCategories.find( - ( { id } ) => id === catId - ); - return category ? category.slug : catId; - } ), - content: userPattern.content.raw, - syncStatus: userPattern.wp_pattern_sync_status, - }; - } ); - }, - ( state ) => [ - state.settings.__experimentalReusableBlocks, - state.settings.__experimentalUserPatternCategories, - ] -); - -export const getAllPatterns = createSelector( - ( state ) => { - const patterns = state.settings.__experimentalBlockPatterns; - const userPatterns = getUserPatterns( state ); - return [ ...userPatterns, ...patterns ]; - }, - ( state ) => [ - state.settings.__experimentalBlockPatterns, - getUserPatterns( state ), - ] -); - export const checkAllowList = ( list, item, defaultResult = null ) => { if ( typeof list === 'boolean' ) { return list; @@ -89,3 +39,13 @@ export const checkAllowListRecursive = ( blocks, allowedBlockTypes ) => { return true; }; + +export const getAllPatternsDependants = ( state ) => { + return [ + state.settings.__experimentalBlockPatterns, + state.settings.__experimentalUserPatternCategories, + state.settings.__experimentalReusableBlocks, + state.settings.__experimentalFetchBlockPatterns, + state.blockPatterns, + ]; +}; diff --git a/packages/block-editor/src/style.scss b/packages/block-editor/src/style.scss index 2d7b1547394452..43fb047710b8d1 100644 --- a/packages/block-editor/src/style.scss +++ b/packages/block-editor/src/style.scss @@ -24,7 +24,6 @@ @import "./components/block-variation-transforms/style.scss"; @import "./components/border-radius-control/style.scss"; @import "./components/colors-gradients/style.scss"; -@import "./components/contrast-checker/style.scss"; @import "./components/date-format-picker/style.scss"; @import "./components/duotone-control/style.scss"; @import "./components/font-appearance-control/style.scss"; diff --git a/packages/block-editor/src/utils/block-variation-transforms.js b/packages/block-editor/src/utils/block-variation-transforms.js deleted file mode 100644 index 15b644bd235ee9..00000000000000 --- a/packages/block-editor/src/utils/block-variation-transforms.js +++ /dev/null @@ -1,38 +0,0 @@ -/** @typedef {import('@wordpress/blocks').WPBlockVariation} WPBlockVariation */ - -function matchesAttributes( blockAttributes, variation ) { - return Object.entries( variation ).every( ( [ key, value ] ) => { - if ( - typeof value === 'object' && - typeof blockAttributes[ key ] === 'object' - ) { - return matchesAttributes( blockAttributes[ key ], value ); - } - return blockAttributes[ key ] === value; - } ); -} - -/** - * Matches the provided block variations with a block's attributes. If no match - * or more than one matches are found it returns `undefined`. If a single match is - * found it returns it. - * - * This is a simple implementation for now as it takes into account only the attributes - * of a block variation and not `InnerBlocks`. - * - * @param {Object} blockAttributes - The block attributes to try to find a match. - * @param {WPBlockVariation[]} variations - A list of block variations to test for a match. - * @return {WPBlockVariation | undefined} - If a match is found returns it. If not or more than one matches are found returns `undefined`. - */ -export const __experimentalGetMatchingVariation = ( - blockAttributes, - variations -) => { - if ( ! variations || ! blockAttributes ) return; - const matches = variations.filter( ( { attributes } ) => { - if ( ! attributes || ! Object.keys( attributes ).length ) return false; - return matchesAttributes( blockAttributes, attributes ); - } ); - if ( matches.length !== 1 ) return; - return matches[ 0 ]; -}; diff --git a/packages/block-editor/src/utils/index.js b/packages/block-editor/src/utils/index.js index ee3b2692b369a8..6f53ba585e5ecb 100644 --- a/packages/block-editor/src/utils/index.js +++ b/packages/block-editor/src/utils/index.js @@ -1,3 +1,2 @@ export { default as transformStyles } from './transform-styles'; -export * from './block-variation-transforms'; export { default as getPxFromCssUnit } from './get-px-from-css-unit'; diff --git a/packages/block-editor/src/utils/math.js b/packages/block-editor/src/utils/math.js index 128972e8a400e1..5d8ed97ffefb29 100644 --- a/packages/block-editor/src/utils/math.js +++ b/packages/block-editor/src/utils/math.js @@ -106,3 +106,15 @@ export function isPointContainedByRect( point, rect ) { rect.bottom >= point.y ); } + +/** + * Is the point within the top and bottom boundaries of the rectangle. + * + * @param {WPPoint} point The point. + * @param {DOMRect} rect The rectangle. + * + * @return {boolean} True if the point is within top and bottom of rectangle, false otherwise. + */ +export function isPointWithinTopAndBottomBoundariesOfRect( point, rect ) { + return rect.top <= point.y && rect.bottom >= point.y; +} diff --git a/packages/block-editor/src/utils/test/block-variation-transforms.js b/packages/block-editor/src/utils/test/block-variation-transforms.js deleted file mode 100644 index 47844ba0309426..00000000000000 --- a/packages/block-editor/src/utils/test/block-variation-transforms.js +++ /dev/null @@ -1,94 +0,0 @@ -/** - * Internal dependencies - */ -import { __experimentalGetMatchingVariation as getMatchingVariation } from '../block-variation-transforms'; - -describe( 'getMatchingVariation', () => { - describe( 'should not find a match', () => { - it( 'when no variations or attributes passed', () => { - expect( - getMatchingVariation( null, { content: 'hi' } ) - ).toBeUndefined(); - expect( getMatchingVariation( {} ) ).toBeUndefined(); - } ); - it( 'when no variation matched', () => { - const variations = [ - { name: 'one', attributes: { level: 1 } }, - { name: 'two', attributes: { level: 2 } }, - ]; - expect( - getMatchingVariation( { level: 4 }, variations ) - ).toBeUndefined(); - } ); - it( 'when more than one match found', () => { - const variations = [ - { name: 'one', attributes: { level: 1 } }, - { name: 'two', attributes: { level: 1, content: 'hi' } }, - ]; - expect( - getMatchingVariation( - { level: 1, content: 'hi', other: 'prop' }, - variations - ) - ).toBeUndefined(); - } ); - it( 'when variation is a superset of attributes', () => { - const variations = [ - { name: 'one', attributes: { level: 1, content: 'hi' } }, - ]; - expect( - getMatchingVariation( { level: 1, other: 'prop' }, variations ) - ).toBeUndefined(); - } ); - it( 'when variation has a nested attribute', () => { - const variations = [ - { name: 'one', attributes: { query: { author: 'somebody' } } }, - { name: 'two', attributes: { query: { author: 'nobody' } } }, - ]; - expect( - getMatchingVariation( - { query: { author: 'foobar' }, other: 'prop' }, - variations - ) - ).toBeUndefined(); - } ); - } ); - describe( 'should find a match', () => { - it( 'when variation has one attribute', () => { - const variations = [ - { name: 'one', attributes: { level: 1 } }, - { name: 'two', attributes: { level: 2 } }, - ]; - expect( - getMatchingVariation( - { level: 2, content: 'hi', other: 'prop' }, - variations - ).name - ).toEqual( 'two' ); - } ); - it( 'when variation has many attributes', () => { - const variations = [ - { name: 'one', attributes: { level: 1, content: 'hi' } }, - { name: 'two', attributes: { level: 2 } }, - ]; - expect( - getMatchingVariation( - { level: 1, content: 'hi', other: 'prop' }, - variations - ).name - ).toEqual( 'one' ); - } ); - it( 'when variation has a nested attribute', () => { - const variations = [ - { name: 'one', attributes: { query: { author: 'somebody' } } }, - { name: 'two', attributes: { query: { author: 'nobody' } } }, - ]; - expect( - getMatchingVariation( - { query: { author: 'somebody' }, other: 'prop' }, - variations - ).name - ).toEqual( 'one' ); - } ); - } ); -} ); diff --git a/packages/block-library/src/avatar/edit.js b/packages/block-library/src/avatar/edit.js index 8b326f4e72d88a..8726d0cf2c0df2 100644 --- a/packages/block-library/src/avatar/edit.js +++ b/packages/block-library/src/avatar/edit.js @@ -192,22 +192,12 @@ const UserEdit = ( { attributes, context, setAttributes, isSelected } ) => { avatar={ avatar } setAttributes={ setAttributes } /> - + + ) : ( + + ) } ); }; diff --git a/packages/block-library/src/block/edit.js b/packages/block-library/src/block/edit.js index c53f52bfb703e9..2d517983c3fe12 100644 --- a/packages/block-library/src/block/edit.js +++ b/packages/block-library/src/block/edit.js @@ -18,8 +18,8 @@ import { import { __ } from '@wordpress/i18n'; import { useInnerBlocksProps, - __experimentalRecursionProvider as RecursionProvider, - __experimentalUseHasRecursion as useHasRecursion, + RecursionProvider, + useHasRecursion, InnerBlocks, useBlockProps, Warning, @@ -88,6 +88,26 @@ const useInferredLayout = ( blocks, parentLayout ) => { }, [ blocks, parentLayout ] ); }; +/** + * Enum for patch operations. + * We use integers here to minimize the size of the serialized data. + * This has to be deserialized accordingly on the server side. + * See block-bindings/sources/pattern.php + */ +const PATCH_OPERATIONS = { + /** @type {0} */ + Remove: 0, + /** @type {1} */ + Replace: 1, + // Other operations are reserved for future use. (e.g. Add) +}; + +/** + * @typedef {[typeof PATCH_OPERATIONS.Remove]} RemovePatch + * @typedef {[typeof PATCH_OPERATIONS.Replace, unknown]} ReplacePatch + * @typedef {RemovePatch | ReplacePatch} OverridePatch + */ + function applyInitialOverrides( blocks, overrides = {}, defaultValues ) { return blocks.map( ( block ) => { const innerBlocks = applyInitialOverrides( @@ -104,9 +124,15 @@ function applyInitialOverrides( blocks, overrides = {}, defaultValues ) { defaultValues[ blockId ] ??= {}; defaultValues[ blockId ][ attributeKey ] = block.attributes[ attributeKey ]; - if ( overrides[ blockId ]?.[ attributeKey ] !== undefined ) { - newAttributes[ attributeKey ] = - overrides[ blockId ][ attributeKey ]; + /** @type {OverridePatch} */ + const overrideAttribute = overrides[ blockId ]?.[ attributeKey ]; + if ( ! overrideAttribute ) { + continue; + } + if ( overrideAttribute[ 0 ] === PATCH_OPERATIONS.Remove ) { + delete newAttributes[ attributeKey ]; + } else if ( overrideAttribute[ 0 ] === PATCH_OPERATIONS.Replace ) { + newAttributes[ attributeKey ] = overrideAttribute[ 1 ]; } } return { @@ -118,13 +144,14 @@ function applyInitialOverrides( blocks, overrides = {}, defaultValues ) { } function getOverridesFromBlocks( blocks, defaultValues ) { - /** @type {Record>} */ + /** @type {Record>} */ const overrides = {}; for ( const block of blocks ) { Object.assign( overrides, getOverridesFromBlocks( block.innerBlocks, defaultValues ) ); + /** @type {string} */ const blockId = block.attributes.metadata?.id; if ( ! isPartiallySynced( block ) || ! blockId ) continue; const attributes = getPartiallySyncedAttributes( block ); @@ -134,10 +161,23 @@ function getOverridesFromBlocks( blocks, defaultValues ) { defaultValues[ blockId ][ attributeKey ] ) { overrides[ blockId ] ??= {}; - // TODO: We need a way to represent `undefined` in the serialized overrides. - // Also see: https://github.com/WordPress/gutenberg/pull/57249#discussion_r1452987871 - overrides[ blockId ][ attributeKey ] = - block.attributes[ attributeKey ]; + /** + * Create a patch operation for the binding attribute. + * We use a tuple here to minimize the size of the serialized data. + * The first item is the operation type, the second item is the value if any. + */ + if ( block.attributes[ attributeKey ] === undefined ) { + /** @type {RemovePatch} */ + overrides[ blockId ][ attributeKey ] = [ + PATCH_OPERATIONS.Remove, + ]; + } else { + /** @type {ReplacePatch} */ + overrides[ blockId ][ attributeKey ] = [ + PATCH_OPERATIONS.Replace, + block.attributes[ attributeKey ], + ]; + } } } } diff --git a/packages/block-library/src/block/v1/edit.native.js b/packages/block-library/src/block/edit.native.js similarity index 96% rename from packages/block-library/src/block/v1/edit.native.js rename to packages/block-library/src/block/edit.native.js index 3a649921b3dda1..ae8c8315aa2e88 100644 --- a/packages/block-library/src/block/v1/edit.native.js +++ b/packages/block-library/src/block/edit.native.js @@ -27,8 +27,8 @@ import { import { useSelect, useDispatch } from '@wordpress/data'; import { __, sprintf } from '@wordpress/i18n'; import { - __experimentalRecursionProvider as RecursionProvider, - __experimentalUseHasRecursion as useHasRecursion, + RecursionProvider, + useHasRecursion, InnerBlocks, Warning, store as blockEditorStore, @@ -42,8 +42,8 @@ import { store as noticesStore } from '@wordpress/notices'; /** * Internal dependencies */ -import styles from '../editor.scss'; -import EditTitle from '../edit-title'; +import styles from './editor.scss'; +import EditTitle from './edit-title'; export default function ReusableBlockEdit( { attributes: { ref }, diff --git a/packages/block-library/src/block/index.js b/packages/block-library/src/block/index.js index 0d117e6f3938ab..95e090f0afd6ad 100644 --- a/packages/block-library/src/block/index.js +++ b/packages/block-library/src/block/index.js @@ -8,15 +8,14 @@ import { symbol as icon } from '@wordpress/icons'; */ import initBlock from '../utils/init-block'; import metadata from './block.json'; -import editV1 from './v1/edit'; -import editV2 from './edit'; +import edit from './edit'; const { name } = metadata; export { metadata, name }; export const settings = { - edit: window.__experimentalPatternPartialSyncing ? editV2 : editV1, + edit, icon, }; diff --git a/packages/block-library/src/block/v1/edit.js b/packages/block-library/src/block/v1/edit.js deleted file mode 100644 index 5975711376c650..00000000000000 --- a/packages/block-library/src/block/v1/edit.js +++ /dev/null @@ -1,163 +0,0 @@ -/** - * External dependencies - */ -import classnames from 'classnames'; - -/** - * WordPress dependencies - */ -import { - useEntityBlockEditor, - useEntityProp, - useEntityRecord, -} from '@wordpress/core-data'; -import { - Placeholder, - Spinner, - TextControl, - PanelBody, -} from '@wordpress/components'; -import { __ } from '@wordpress/i18n'; -import { - useInnerBlocksProps, - __experimentalRecursionProvider as RecursionProvider, - __experimentalUseHasRecursion as useHasRecursion, - InnerBlocks, - InspectorControls, - useBlockProps, - Warning, - privateApis as blockEditorPrivateApis, -} from '@wordpress/block-editor'; -import { useRef, useMemo } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import { unlock } from '../../lock-unlock'; - -const { useLayoutClasses } = unlock( blockEditorPrivateApis ); -const fullAlignments = [ 'full', 'wide', 'left', 'right' ]; - -const useInferredLayout = ( blocks, parentLayout ) => { - const initialInferredAlignmentRef = useRef(); - - return useMemo( () => { - // Exit early if the pattern's blocks haven't loaded yet. - if ( ! blocks?.length ) { - return {}; - } - - let alignment = initialInferredAlignmentRef.current; - - // Only track the initial alignment so that temporarily removed - // alignments can be reapplied. - if ( alignment === undefined ) { - const isConstrained = parentLayout?.type === 'constrained'; - const hasFullAlignment = blocks.some( ( block ) => - fullAlignments.includes( block.attributes.align ) - ); - - alignment = isConstrained && hasFullAlignment ? 'full' : null; - initialInferredAlignmentRef.current = alignment; - } - - const layout = alignment ? parentLayout : undefined; - - return { alignment, layout }; - }, [ blocks, parentLayout ] ); -}; - -export default function ReusableBlockEdit( { - name, - attributes: { ref }, - __unstableParentLayout: parentLayout, -} ) { - const hasAlreadyRendered = useHasRecursion( ref ); - const { record, hasResolved } = useEntityRecord( - 'postType', - 'wp_block', - ref - ); - const isMissing = hasResolved && ! record; - - const [ blocks, onInput, onChange ] = useEntityBlockEditor( - 'postType', - 'wp_block', - { id: ref } - ); - - const [ title, setTitle ] = useEntityProp( - 'postType', - 'wp_block', - 'title', - ref - ); - - const { alignment, layout } = useInferredLayout( blocks, parentLayout ); - const layoutClasses = useLayoutClasses( { layout }, name ); - - const blockProps = useBlockProps( { - className: classnames( - 'block-library-block__reusable-block-container', - layout && layoutClasses, - { [ `align${ alignment }` ]: alignment } - ), - } ); - - const innerBlocksProps = useInnerBlocksProps( blockProps, { - value: blocks, - layout, - onInput, - onChange, - renderAppender: blocks?.length - ? undefined - : InnerBlocks.ButtonBlockAppender, - } ); - - let children = null; - - if ( hasAlreadyRendered ) { - children = ( - - { __( 'Block cannot be rendered inside itself.' ) } - - ); - } - - if ( isMissing ) { - children = ( - - { __( 'Block has been deleted or is unavailable.' ) } - - ); - } - - if ( ! hasResolved ) { - children = ( - - - - ); - } - - return ( - - - - - - - { children === null ? ( -
- ) : ( -
{ children }
- ) } - - ); -} diff --git a/packages/block-library/src/button/edit.js b/packages/block-library/src/button/edit.js index b46e145d760ad5..a0994ce3f84b12 100644 --- a/packages/block-library/src/button/edit.js +++ b/packages/block-library/src/button/edit.js @@ -32,6 +32,7 @@ import { __experimentalUseBorderProps as useBorderProps, __experimentalUseColorProps as useColorProps, __experimentalGetSpacingClassesAndStyles as useSpacingProps, + __experimentalUseShadowProps as useShadowProps, __experimentalLinkControl as LinkControl, __experimentalGetElementClassName, store as blockEditorStore, @@ -184,6 +185,7 @@ function ButtonEdit( props ) { const borderProps = useBorderProps( attributes ); const colorProps = useColorProps( attributes ); const spacingProps = useSpacingProps( attributes ); + const shadowProps = useShadowProps( attributes ); const ref = useRef(); const richTextRef = useRef(); const blockProps = useBlockProps( { @@ -266,6 +268,7 @@ function ButtonEdit( props ) { ...borderProps.style, ...colorProps.style, ...spacingProps.style, + ...shadowProps.style, } } onSplit={ ( value ) => createBlock( 'core/button', { diff --git a/packages/block-library/src/button/save.js b/packages/block-library/src/button/save.js index e12936e8c92457..ba0fbd45f083c9 100644 --- a/packages/block-library/src/button/save.js +++ b/packages/block-library/src/button/save.js @@ -12,6 +12,7 @@ import { __experimentalGetBorderClassesAndStyles as getBorderClassesAndStyles, __experimentalGetColorClassesAndStyles as getColorClassesAndStyles, __experimentalGetSpacingClassesAndStyles as getSpacingClassesAndStyles, + __experimentalGetShadowClassesAndStyles as getShadowClassesAndStyles, __experimentalGetElementClassName, } from '@wordpress/block-editor'; @@ -40,6 +41,7 @@ export default function save( { attributes, className } ) { const borderProps = getBorderClassesAndStyles( attributes ); const colorProps = getColorClassesAndStyles( attributes ); const spacingProps = getSpacingClassesAndStyles( attributes ); + const shadowProps = getShadowClassesAndStyles( attributes ); const buttonClasses = classnames( 'wp-block-button__link', colorProps.className, @@ -56,6 +58,7 @@ export default function save( { attributes, className } ) { ...borderProps.style, ...colorProps.style, ...spacingProps.style, + ...shadowProps.style, }; // The use of a `title` attribute here is soft-deprecated, but still applied diff --git a/packages/block-library/src/file/block.json b/packages/block-library/src/file/block.json index 9dc6677e4adce3..fd5da67d284f49 100644 --- a/packages/block-library/src/file/block.json +++ b/packages/block-library/src/file/block.json @@ -72,7 +72,6 @@ }, "interactivity": true }, - "viewScript": "file:./view.min.js", "editorStyle": "wp-block-file-editor", "style": "wp-block-file" } diff --git a/packages/block-library/src/file/index.php b/packages/block-library/src/file/index.php index 5910a63e6cf182..24eaff8bac622e 100644 --- a/packages/block-library/src/file/index.php +++ b/packages/block-library/src/file/index.php @@ -14,35 +14,8 @@ * * @return string Returns the block content. */ -function render_block_core_file( $attributes, $content, $block ) { - $is_gutenberg_plugin = defined( 'IS_GUTENBERG_PLUGIN' ) && IS_GUTENBERG_PLUGIN; - $should_load_view_script = ! empty( $attributes['displayPreview'] ); - $view_js_file = 'wp-block-file-view'; - $script_handles = $block->block_type->view_script_handles; - - if ( $is_gutenberg_plugin ) { - if ( $should_load_view_script ) { - gutenberg_enqueue_module( '@wordpress/block-library/file-block' ); - } - // Remove the view script because we are using the module. - $block->block_type->view_script_handles = array_diff( $script_handles, array( $view_js_file ) ); - } else { - // If the script already exists, there is no point in removing it from viewScript. - if ( ! wp_script_is( $view_js_file ) ) { - - // If the script is not needed, and it is still in the `view_script_handles`, remove it. - if ( ! $should_load_view_script && in_array( $view_js_file, $script_handles, true ) ) { - $block->block_type->view_script_handles = array_diff( $script_handles, array( $view_js_file ) ); - } - // If the script is needed, but it was previously removed, add it again. - if ( $should_load_view_script && ! in_array( $view_js_file, $script_handles, true ) ) { - $block->block_type->view_script_handles = array_merge( $script_handles, array( $view_js_file ) ); - } - } - } - +function render_block_core_file( $attributes, $content ) { // Update object's aria-label attribute if present in block HTML. - // Match an aria-label attribute from an object tag. $pattern = '@aria-label="(?[^"]+)?")@i'; $content = preg_replace_callback( @@ -63,8 +36,10 @@ static function ( $matches ) { $content ); - // If it uses the Interactivity API, add the directives. - if ( $should_load_view_script ) { + // If it's interactive, enqueue the script module and add the directives. + if ( ! empty( $attributes['displayPreview'] ) ) { + wp_enqueue_script_module( '@wordpress/block-library/file-block' ); + $processor = new WP_HTML_Tag_Processor( $content ); $processor->next_tag(); $processor->set_attribute( 'data-wp-interactive', '{"namespace":"core/file"}' ); @@ -77,25 +52,6 @@ static function ( $matches ) { return $content; } -/** - * Ensure that the view script has the `wp-interactivity` dependency. - * - * @since 6.4.0 - * - * @global WP_Scripts $wp_scripts - */ -function block_core_file_ensure_interactivity_dependency() { - global $wp_scripts; - if ( - isset( $wp_scripts->registered['wp-block-file-view'] ) && - ! in_array( 'wp-interactivity', $wp_scripts->registered['wp-block-file-view']->deps, true ) - ) { - $wp_scripts->registered['wp-block-file-view']->deps[] = 'wp-interactivity'; - } -} - -add_action( 'wp_print_scripts', 'block_core_file_ensure_interactivity_dependency' ); - /** * Registers the `core/file` block on server. */ @@ -107,13 +63,11 @@ function register_block_core_file() { ) ); - if ( defined( 'IS_GUTENBERG_PLUGIN' ) && IS_GUTENBERG_PLUGIN ) { - gutenberg_register_module( - '@wordpress/block-library/file-block', - gutenberg_url( '/build/interactivity/file.min.js' ), - array( '@wordpress/interactivity' ), - defined( 'GUTENBERG_VERSION' ) ? GUTENBERG_VERSION : get_bloginfo( 'version' ) - ); - } + wp_register_script_module( + '@wordpress/block-library/file-block', + gutenberg_url( '/build/interactivity/file.min.js' ), + array( '@wordpress/interactivity' ), + defined( 'GUTENBERG_VERSION' ) ? GUTENBERG_VERSION : get_bloginfo( 'version' ) + ); } add_action( 'init', 'register_block_core_file' ); diff --git a/packages/block-library/src/footnotes/format.js b/packages/block-library/src/footnotes/format.js index eda45ac0d30f39..def721c21eecd4 100644 --- a/packages/block-library/src/footnotes/format.js +++ b/packages/block-library/src/footnotes/format.js @@ -15,7 +15,7 @@ import { privateApis, } from '@wordpress/block-editor'; import { useSelect, useDispatch, useRegistry } from '@wordpress/data'; -import { useEntityProp } from '@wordpress/core-data'; +import { store as coreDataStore } from '@wordpress/core-data'; import { createBlock, store as blocksStore } from '@wordpress/blocks'; /** @@ -54,43 +54,42 @@ export const format = { getBlockName, getBlockParentsByBlockName, } = registry.select( blockEditorStore ); - const hasFootnotesBlockType = useSelect( - ( select ) => - !! select( blocksStore ).getBlockType( 'core/footnotes' ), - [] + const isFootnotesSupported = useSelect( + ( select ) => { + if ( + ! select( blocksStore ).getBlockType( 'core/footnotes' ) + ) { + return false; + } + + const entityRecord = select( coreDataStore ).getEntityRecord( + 'postType', + postType, + postId + ); + + if ( 'string' !== typeof entityRecord?.meta?.footnotes ) { + return false; + } + + // Checks if the selected block lives within a pattern. + const { + getBlockParentsByBlockName: _getBlockParentsByBlockName, + getSelectedBlockClientId: _getSelectedBlockClientId, + } = select( blockEditorStore ); + const parentCoreBlocks = _getBlockParentsByBlockName( + _getSelectedBlockClientId(), + SYNCED_PATTERN_BLOCK_NAME + ); + return ! parentCoreBlocks || parentCoreBlocks.length === 0; + }, + [ postType, postId ] ); - /* - * This useSelect exists because we need to use its return value - * outside the event callback. - */ - const isBlockWithinPattern = useSelect( ( select ) => { - const { - getBlockParentsByBlockName: _getBlockParentsByBlockName, - getSelectedBlockClientId: _getSelectedBlockClientId, - } = select( blockEditorStore ); - const parentCoreBlocks = _getBlockParentsByBlockName( - _getSelectedBlockClientId(), - SYNCED_PATTERN_BLOCK_NAME - ); - return parentCoreBlocks && parentCoreBlocks.length > 0; - }, [] ); - - const [ meta ] = useEntityProp( 'postType', postType, 'meta', postId ); - const footnotesSupported = 'string' === typeof meta?.footnotes; const { selectionChange, insertBlock } = useDispatch( blockEditorStore ); - if ( ! hasFootnotesBlockType ) { - return null; - } - - if ( ! footnotesSupported ) { - return null; - } - - // Checks if the selected block lives within a pattern. - if ( isBlockWithinPattern ) { + if ( ! isFootnotesSupported ) { return null; } diff --git a/packages/block-library/src/image/block.json b/packages/block-library/src/image/block.json index a9357d28815b61..d60bcadf0eec74 100644 --- a/packages/block-library/src/image/block.json +++ b/packages/block-library/src/image/block.json @@ -134,6 +134,5 @@ { "name": "rounded", "label": "Rounded" } ], "editorStyle": "wp-block-image-editor", - "style": "wp-block-image", - "viewScript": "file:./view.min.js" + "style": "wp-block-image" } diff --git a/packages/block-library/src/image/image.js b/packages/block-library/src/image/image.js index d8788fde4844f6..f2a484b6e03c1b 100644 --- a/packages/block-library/src/image/image.js +++ b/packages/block-library/src/image/image.js @@ -355,7 +355,8 @@ export default function Image( { const lightboxChecked = !! lightbox?.enabled || ( ! lightbox && !! lightboxSetting?.enabled ); - const lightboxToggleDisabled = linkDestination !== 'none'; + const lightboxToggleDisabled = + linkDestination && linkDestination !== 'none'; const dimensionsControl = ( parsed_block ); - $is_gutenberg_plugin = defined( 'IS_GUTENBERG_PLUGIN' ) && IS_GUTENBERG_PLUGIN; - $view_js_file_handle = 'wp-block-image-view'; - $script_handles = $block->block_type->view_script_handles; - /* * If the lightbox is enabled and the image is not linked, add the filter * and the JavaScript view file. @@ -51,34 +47,22 @@ function render_block_core_image( $attributes, $content, $block ) { isset( $lightbox_settings['enabled'] ) && true === $lightbox_settings['enabled'] ) { - if ( $is_gutenberg_plugin ) { - gutenberg_enqueue_module( '@wordpress/block-library/image' ); - // Remove the view script because we are using the module. - $block->block_type->view_script_handles = array_diff( $script_handles, array( $view_js_file_handle ) ); - } elseif ( ! in_array( $view_js_file_handle, $script_handles, true ) ) { - $block->block_type->view_script_handles = array_merge( $script_handles, array( $view_js_file_handle ) ); - } + wp_enqueue_script_module( '@wordpress/block-library/image' ); /* - * This render needs to happen in a filter with priority 15 to ensure - * that it runs after the duotone filter and that duotone styles are - * applied to the image in the lightbox. We also need to ensure that the - * lightbox works with any plugins that might use filters as well. We - * can consider removing this in the future if the way the blocks are - * rendered changes, or if a new kind of filter is introduced. + * This render needs to happen in a filter with priority 15 to ensure that + * it runs after the duotone filter and that duotone styles are applied to + * the image in the lightbox. Lightbox has to work with any plugins that + * might use filters as well. Removing this can be considered in the + * future if the way the blocks are rendered changes, or if a + * new kind of filter is introduced. */ add_filter( 'render_block_core/image', 'block_core_image_render_lightbox', 15, 2 ); } else { /* - * Remove the filter and the JavaScript view file if previously added by - * other Image blocks. + * Remove the filter if previously added by other Image blocks. */ remove_filter( 'render_block_core/image', 'block_core_image_render_lightbox', 15 ); - - // If the script is not needed, and it is still in the `view_script_handles`, remove it. - if ( in_array( $view_js_file_handle, $script_handles, true ) ) { - $block->block_type->view_script_handles = array_diff( $script_handles, array( $view_js_file_handle ) ); - } } return $processor->get_updated_html(); @@ -328,25 +312,6 @@ class="lightbox-trigger" return str_replace( '', $lightbox_html . '', $body_content ); } -/** - * Ensures that the view script has the `wp-interactivity` dependency. - * - * @since 6.4.0 - * - * @global WP_Scripts $wp_scripts - */ -function block_core_image_ensure_interactivity_dependency() { - global $wp_scripts; - if ( - isset( $wp_scripts->registered['wp-block-image-view'] ) && - ! in_array( 'wp-interactivity', $wp_scripts->registered['wp-block-image-view']->deps, true ) - ) { - $wp_scripts->registered['wp-block-image-view']->deps[] = 'wp-interactivity'; - } -} - -add_action( 'wp_print_scripts', 'block_core_image_ensure_interactivity_dependency' ); - /** * Registers the `core/image` block on server. */ @@ -358,13 +323,11 @@ function register_block_core_image() { ) ); - if ( defined( 'IS_GUTENBERG_PLUGIN' ) && IS_GUTENBERG_PLUGIN ) { - gutenberg_register_module( - '@wordpress/block-library/image', - gutenberg_url( '/build/interactivity/image.min.js' ), - array( '@wordpress/interactivity' ), - defined( 'GUTENBERG_VERSION' ) ? GUTENBERG_VERSION : get_bloginfo( 'version' ) - ); - } + wp_register_script_module( + '@wordpress/block-library/image', + gutenberg_url( '/build/interactivity/image.min.js' ), + array( '@wordpress/interactivity' ), + defined( 'GUTENBERG_VERSION' ) ? GUTENBERG_VERSION : get_bloginfo( 'version' ) + ); } add_action( 'init', 'register_block_core_image' ); diff --git a/packages/block-library/src/more/index.js b/packages/block-library/src/more/index.js index 4c1fad3cb67f48..b40bb2123bc727 100644 --- a/packages/block-library/src/more/index.js +++ b/packages/block-library/src/more/index.js @@ -20,6 +20,12 @@ export const settings = { icon, example: {}, __experimentalLabel( attributes, { context } ) { + const customName = attributes?.metadata?.name; + + if ( context === 'list-view' && customName ) { + return customName; + } + if ( context === 'accessibility' ) { return attributes.customText; } diff --git a/packages/block-library/src/navigation/block.json b/packages/block-library/src/navigation/block.json index 9ec919ae38d1fa..36817a5e1c35b1 100644 --- a/packages/block-library/src/navigation/block.json +++ b/packages/block-library/src/navigation/block.json @@ -136,7 +136,6 @@ "interactivity": true, "renaming": false }, - "viewScript": "file:./view.min.js", "editorStyle": "wp-block-navigation-editor", "style": "wp-block-navigation" } diff --git a/packages/block-library/src/navigation/edit/index.js b/packages/block-library/src/navigation/edit/index.js index 5589e8ea9e60f0..14ef6fc73d48f0 100644 --- a/packages/block-library/src/navigation/edit/index.js +++ b/packages/block-library/src/navigation/edit/index.js @@ -16,8 +16,8 @@ import { import { InspectorControls, useBlockProps, - __experimentalRecursionProvider as RecursionProvider, - __experimentalUseHasRecursion as useHasRecursion, + RecursionProvider, + useHasRecursion, store as blockEditorStore, withColors, ContrastChecker, diff --git a/packages/block-library/src/navigation/index.php b/packages/block-library/src/navigation/index.php index 3af85afd92522f..8b39a89022f8b2 100644 --- a/packages/block-library/src/navigation/index.php +++ b/packages/block-library/src/navigation/index.php @@ -5,6 +5,640 @@ * @package WordPress */ +/** + * Helper functions used to render the navigation block. + */ +class WP_Navigation_Block_Renderer { + /** + * Used to determine which blocks are wrapped in an
  • . + * + * @var array + */ + private static $nav_blocks_wrapped_in_list_item = array( + 'core/navigation-link', + 'core/home-link', + 'core/site-title', + 'core/site-logo', + 'core/navigation-submenu', + ); + + /** + * Used to determine which blocks need an
  • wrapper. + * + * @var array + */ + private static $needs_list_item_wrapper = array( + 'core/site-title', + 'core/site-logo', + ); + + /** + * Keeps track of all the navigation names that have been seen. + * + * @var array + */ + private static $seen_menu_names = array(); + + /** + * Returns whether or not this is responsive navigation. + * + * @param array $attributes The block attributes. + * @return bool Returns whether or not this is responsive navigation. + */ + private static function is_responsive( $attributes ) { + /** + * This is for backwards compatibility after the `isResponsive` attribute was been removed. + */ + + $has_old_responsive_attribute = ! empty( $attributes['isResponsive'] ) && $attributes['isResponsive']; + return isset( $attributes['overlayMenu'] ) && 'never' !== $attributes['overlayMenu'] || $has_old_responsive_attribute; + } + + /** + * Returns whether or not a navigation has a submenu. + * + * @param WP_Block_List $inner_blocks The list of inner blocks. + * @return bool Returns whether or not a navigation has a submenu. + */ + private static function has_submenus( $inner_blocks ) { + foreach ( $inner_blocks as $inner_block ) { + $inner_block_content = $inner_block->render(); + $p = new WP_HTML_Tag_Processor( $inner_block_content ); + if ( $p->next_tag( + array( + 'name' => 'LI', + 'class_name' => 'has-child', + ) + ) ) { + return true; + } + } + return false; + } + + /** + * Determine whether the navigation blocks is interactive. + * + * @param array $attributes The block attributes. + * @param WP_Block_List $inner_blocks The list of inner blocks. + * @return bool Returns whether or not to load the view script. + */ + private static function is_interactive( $attributes, $inner_blocks ) { + $has_submenus = static::has_submenus( $inner_blocks ); + $is_responsive_menu = static::is_responsive( $attributes ); + return ( $has_submenus && ( $attributes['openSubmenusOnClick'] || $attributes['showSubmenuIcon'] ) ) || $is_responsive_menu; + } + + /** + * Returns whether or not a block needs a list item wrapper. + * + * @param WP_Block $block The block. + * @return bool Returns whether or not a block needs a list item wrapper. + */ + private static function does_block_need_a_list_item_wrapper( $block ) { + return in_array( $block->name, static::$needs_list_item_wrapper, true ); + } + + /** + * Returns the markup for a single inner block. + * + * @param WP_Block $inner_block The inner block. + * @return string Returns the markup for a single inner block. + */ + private static function get_markup_for_inner_block( $inner_block ) { + $inner_block_content = $inner_block->render(); + if ( ! empty( $inner_block_content ) ) { + if ( static::does_block_need_a_list_item_wrapper( $inner_block ) ) { + return '
  • ' . $inner_block_content . '
  • '; + } + + return $inner_block_content; + } + } + + /** + * Returns the html for the inner blocks of the navigation block. + * + * @param array $attributes The block attributes. + * @param WP_Block_List $inner_blocks The list of inner blocks. + * @return string Returns the html for the inner blocks of the navigation block. + */ + private static function get_inner_blocks_html( $attributes, $inner_blocks ) { + $has_submenus = static::has_submenus( $inner_blocks ); + $is_interactive = static::is_interactive( $attributes, $inner_blocks ); + + $style = static::get_styles( $attributes ); + $class = static::get_classes( $attributes ); + $container_attributes = get_block_wrapper_attributes( + array( + 'class' => 'wp-block-navigation__container ' . $class, + 'style' => $style, + ) + ); + + $inner_blocks_html = ''; + $is_list_open = false; + + foreach ( $inner_blocks as $inner_block ) { + $is_list_item = in_array( $inner_block->name, static::$nav_blocks_wrapped_in_list_item, true ); + + if ( $is_list_item && ! $is_list_open ) { + $is_list_open = true; + $inner_blocks_html .= sprintf( + '
      ', + $container_attributes + ); + } + + if ( ! $is_list_item && $is_list_open ) { + $is_list_open = false; + $inner_blocks_html .= '
    '; + } + + $inner_blocks_html .= static::get_markup_for_inner_block( $inner_block ); + } + + if ( $is_list_open ) { + $inner_blocks_html .= ''; + } + + // Add directives to the submenu if needed. + if ( $has_submenus && $is_interactive ) { + $tags = new WP_HTML_Tag_Processor( $inner_blocks_html ); + $inner_blocks_html = block_core_navigation_add_directives_to_submenu( $tags, $attributes ); + } + + return $inner_blocks_html; + } + + /** + * Gets the inner blocks for the navigation block from the navigation post. + * + * @param array $attributes The block attributes. + * @return WP_Block_List Returns the inner blocks for the navigation block. + */ + private static function get_inner_blocks_from_navigation_post( $attributes ) { + $navigation_post = get_post( $attributes['ref'] ); + if ( ! isset( $navigation_post ) ) { + return new WP_Block_List( array(), $attributes ); + } + + // Only published posts are valid. If this is changed then a corresponding change + // must also be implemented in `use-navigation-menu.js`. + if ( 'publish' === $navigation_post->post_status ) { + $parsed_blocks = parse_blocks( $navigation_post->post_content ); + + // 'parse_blocks' includes a null block with '\n\n' as the content when + // it encounters whitespace. This code strips it. + $compacted_blocks = block_core_navigation_filter_out_empty_blocks( $parsed_blocks ); + + // TODO - this uses the full navigation block attributes for the + // context which could be refined. + return new WP_Block_List( $compacted_blocks, $attributes ); + } + } + + /** + * Gets the inner blocks for the navigation block from the fallback. + * + * @param array $attributes The block attributes. + * @return WP_Block_List Returns the inner blocks for the navigation block. + */ + private static function get_inner_blocks_from_fallback( $attributes ) { + $fallback_blocks = block_core_navigation_get_fallback_blocks(); + + // Fallback my have been filtered so do basic test for validity. + if ( empty( $fallback_blocks ) || ! is_array( $fallback_blocks ) ) { + return new WP_Block_List( array(), $attributes ); + } + + return new WP_Block_List( $fallback_blocks, $attributes ); + } + + /** + * Gets the inner blocks for the navigation block. + * + * @param array $attributes The block attributes. + * @param WP_Block $block The parsed block. + * @return WP_Block_List Returns the inner blocks for the navigation block. + */ + private static function get_inner_blocks( $attributes, $block ) { + $inner_blocks = $block->inner_blocks; + + // Ensure that blocks saved with the legacy ref attribute name (navigationMenuId) continue to render. + if ( array_key_exists( 'navigationMenuId', $attributes ) ) { + $attributes['ref'] = $attributes['navigationMenuId']; + } + + // If: + // - the gutenberg plugin is active + // - `__unstableLocation` is defined + // - we have menu items at the defined location + // - we don't have a relationship to a `wp_navigation` Post (via `ref`). + // ...then create inner blocks from the classic menu assigned to that location. + if ( + defined( 'IS_GUTENBERG_PLUGIN' ) && IS_GUTENBERG_PLUGIN && + array_key_exists( '__unstableLocation', $attributes ) && + ! array_key_exists( 'ref', $attributes ) && + ! empty( block_core_navigation_get_menu_items_at_location( $attributes['__unstableLocation'] ) ) + ) { + $inner_blocks = block_core_navigation_get_inner_blocks_from_unstable_location( $attributes ); + } + + // Load inner blocks from the navigation post. + if ( array_key_exists( 'ref', $attributes ) ) { + $inner_blocks = static::get_inner_blocks_from_navigation_post( $attributes ); + } + + // If there are no inner blocks then fallback to rendering an appropriate fallback. + if ( empty( $inner_blocks ) ) { + $inner_blocks = static::get_inner_blocks_from_fallback( $attributes ); + } + + /** + * Filter navigation block $inner_blocks. + * Allows modification of a navigation block menu items. + * + * @since 6.1.0 + * + * @param \WP_Block_List $inner_blocks + */ + $inner_blocks = apply_filters( 'block_core_navigation_render_inner_blocks', $inner_blocks ); + + $post_ids = block_core_navigation_get_post_ids( $inner_blocks ); + if ( $post_ids ) { + _prime_post_caches( $post_ids, false, false ); + } + + return $inner_blocks; + } + + /** + * Gets the name of the current navigation, if it has one. + * + * @param array $attributes The block attributes. + * @return string Returns the name of the navigation. + */ + private static function get_navigation_name( $attributes ) { + + $navigation_name = $attributes['ariaLabel'] ?? ''; + + // Load the navigation post. + if ( array_key_exists( 'ref', $attributes ) ) { + $navigation_post = get_post( $attributes['ref'] ); + if ( ! isset( $navigation_post ) ) { + return $navigation_name; + } + + // Only published posts are valid. If this is changed then a corresponding change + // must also be implemented in `use-navigation-menu.js`. + if ( 'publish' === $navigation_post->post_status ) { + $navigation_name = $navigation_post->post_title; + + // This is used to count the number of times a navigation name has been seen, + // so that we can ensure every navigation has a unique id. + if ( isset( static::$seen_menu_names[ $navigation_name ] ) ) { + ++static::$seen_menu_names[ $navigation_name ]; + } else { + static::$seen_menu_names[ $navigation_name ] = 1; + } + } + } + + return $navigation_name; + } + + /** + * Returns the layout class for the navigation block. + * + * @param array $attributes The block attributes. + * @return string Returns the layout class for the navigation block. + */ + private static function get_layout_class( $attributes ) { + $layout_justification = array( + 'left' => 'items-justified-left', + 'right' => 'items-justified-right', + 'center' => 'items-justified-center', + 'space-between' => 'items-justified-space-between', + ); + + $layout_class = ''; + if ( + isset( $attributes['layout']['justifyContent'] ) && + isset( $layout_justification[ $attributes['layout']['justifyContent'] ] ) + ) { + $layout_class .= $layout_justification[ $attributes['layout']['justifyContent'] ]; + } + if ( isset( $attributes['layout']['orientation'] ) && 'vertical' === $attributes['layout']['orientation'] ) { + $layout_class .= ' is-vertical'; + } + + if ( isset( $attributes['layout']['flexWrap'] ) && 'nowrap' === $attributes['layout']['flexWrap'] ) { + $layout_class .= ' no-wrap'; + } + return $layout_class; + } + + /** + * Return classes for the navigation block. + * + * @param array $attributes The block attributes. + * @return string Returns the classes for the navigation block. + */ + private static function get_classes( $attributes ) { + // Restore legacy classnames for submenu positioning. + $layout_class = static::get_layout_class( $attributes ); + $colors = block_core_navigation_build_css_colors( $attributes ); + $font_sizes = block_core_navigation_build_css_font_sizes( $attributes ); + $is_responsive_menu = static::is_responsive( $attributes ); + + // Manually add block support text decoration as CSS class. + $text_decoration = $attributes['style']['typography']['textDecoration'] ?? null; + $text_decoration_class = sprintf( 'has-text-decoration-%s', $text_decoration ); + + // Sets the is-collapsed class when the navigation is set to always use the overlay. + // This saves us from needing to do this check in the view.js file (see the collapseNav function). + $is_collapsed_class = static::is_always_overlay( $attributes ) ? array( 'is-collapsed' ) : array(); + + $classes = array_merge( + $colors['css_classes'], + $font_sizes['css_classes'], + $is_responsive_menu ? array( 'is-responsive' ) : array(), + $layout_class ? array( $layout_class ) : array(), + $text_decoration ? array( $text_decoration_class ) : array(), + $is_collapsed_class + ); + return implode( ' ', $classes ); + } + + private static function is_always_overlay( $attributes ) { + return isset( $attributes['overlayMenu'] ) && 'always' === $attributes['overlayMenu']; + } + + /** + * Get styles for the navigation block. + * + * @param array $attributes The block attributes. + * @return string Returns the styles for the navigation block. + */ + private static function get_styles( $attributes ) { + $colors = block_core_navigation_build_css_colors( $attributes ); + $font_sizes = block_core_navigation_build_css_font_sizes( $attributes ); + $block_styles = isset( $attributes['styles'] ) ? $attributes['styles'] : ''; + return $block_styles . $colors['inline_styles'] . $font_sizes['inline_styles']; + } + + /** + * Get the responsive container markup + * + * @param array $attributes The block attributes. + * @param WP_Block_List $inner_blocks The list of inner blocks. + * @param string $inner_blocks_html The markup for the inner blocks. + * @return string Returns the container markup. + */ + private static function get_responsive_container_markup( $attributes, $inner_blocks, $inner_blocks_html ) { + $is_interactive = static::is_interactive( $attributes, $inner_blocks ); + $colors = block_core_navigation_build_css_colors( $attributes ); + $modal_unique_id = wp_unique_id( 'modal-' ); + + $responsive_container_classes = array( + 'wp-block-navigation__responsive-container', + implode( ' ', $colors['overlay_css_classes'] ), + ); + $open_button_classes = array( + 'wp-block-navigation__responsive-container-open', + ); + + $should_display_icon_label = isset( $attributes['hasIcon'] ) && true === $attributes['hasIcon']; + $toggle_button_icon = ''; + if ( isset( $attributes['icon'] ) ) { + if ( 'menu' === $attributes['icon'] ) { + $toggle_button_icon = ''; + } + } + $toggle_button_content = $should_display_icon_label ? $toggle_button_icon : __( 'Menu' ); + $toggle_close_button_icon = ''; + $toggle_close_button_content = $should_display_icon_label ? $toggle_close_button_icon : __( 'Close' ); + $toggle_aria_label_open = $should_display_icon_label ? 'aria-label="' . __( 'Open menu' ) . '"' : ''; // Open button label. + $toggle_aria_label_close = $should_display_icon_label ? 'aria-label="' . __( 'Close menu' ) . '"' : ''; // Close button label. + + // Add Interactivity API directives to the markup if needed. + $open_button_directives = ''; + $responsive_container_directives = ''; + $responsive_dialog_directives = ''; + $close_button_directives = ''; + if ( $is_interactive ) { + $open_button_directives = ' + data-wp-on--click="actions.openMenuOnClick" + data-wp-on--keydown="actions.handleMenuKeydown" + '; + $responsive_container_directives = ' + data-wp-class--has-modal-open="state.isMenuOpen" + data-wp-class--is-menu-open="state.isMenuOpen" + data-wp-watch="callbacks.initMenu" + data-wp-on--keydown="actions.handleMenuKeydown" + data-wp-on--focusout="actions.handleMenuFocusout" + tabindex="-1" + '; + $responsive_dialog_directives = ' + data-wp-bind--aria-modal="state.ariaModal" + data-wp-bind--aria-label="state.ariaLabel" + data-wp-bind--role="state.roleAttribute" + '; + $close_button_directives = ' + data-wp-on--click="actions.closeMenuOnClick" + '; + $responsive_container_content_directives = ' + data-wp-watch="callbacks.focusFirstElement" + '; + } + + return sprintf( + ' +
    +
    +
    + +
    + %2$s +
    +
    +
    +
    ', + esc_attr( $modal_unique_id ), + $inner_blocks_html, + $toggle_aria_label_open, + $toggle_aria_label_close, + esc_attr( implode( ' ', $responsive_container_classes ) ), + esc_attr( implode( ' ', $open_button_classes ) ), + esc_attr( safecss_filter_attr( $colors['overlay_inline_styles'] ) ), + $toggle_button_content, + $toggle_close_button_content, + $open_button_directives, + $responsive_container_directives, + $responsive_dialog_directives, + $close_button_directives, + $responsive_container_content_directives + ); + } + + /** + * Get the wrapper attributes + * + * @param array $attributes The block attributes. + * @param WP_Block_List $inner_blocks A list of inner blocks. + * @return string Returns the navigation block markup. + */ + private static function get_nav_wrapper_attributes( $attributes, $inner_blocks ) { + $nav_menu_name = static::get_unique_navigation_name( $attributes ); + $is_interactive = static::is_interactive( $attributes, $inner_blocks ); + $is_responsive_menu = static::is_responsive( $attributes ); + $style = static::get_styles( $attributes ); + $class = static::get_classes( $attributes ); + $wrapper_attributes = get_block_wrapper_attributes( + array( + 'class' => $class, + 'style' => $style, + 'aria-label' => $nav_menu_name, + ) + ); + + if ( $is_responsive_menu ) { + $nav_element_directives = static::get_nav_element_directives( $is_interactive, $attributes ); + $wrapper_attributes .= ' ' . $nav_element_directives; + } + + return $wrapper_attributes; + } + + /** + * Gets the nav element directives. + * + * @param bool $is_interactive Whether the block is interactive. + * @param array $attributes The block attributes. + * @return string the directives for the navigation element. + */ + private static function get_nav_element_directives( $is_interactive, $attributes ) { + if ( ! $is_interactive ) { + return ''; + } + // When adding to this array be mindful of security concerns. + $nav_element_context = wp_json_encode( + array( + 'overlayOpenedBy' => array(), + 'type' => 'overlay', + 'roleAttribute' => '', + 'ariaLabel' => __( 'Menu' ), + ), + JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP + ); + $nav_element_directives = ' + data-wp-interactive=\'{"namespace":"core/navigation"}\' + data-wp-context=\'' . $nav_element_context . '\' + '; + + /* + * When the navigation's 'overlayMenu' attribute is set to 'always', JavaScript + * is not needed for collapsing the menu because the class is set manually. + */ + if ( ! static::is_always_overlay( $attributes ) ) { + $nav_element_directives .= 'data-wp-init="callbacks.initNav"'; + $nav_element_directives .= ' '; // space separator + $nav_element_directives .= 'data-wp-class--is-collapsed="context.isCollapsed"'; + } + + return $nav_element_directives; + } + + /** + * Handle view script module loading. + * + * @param array $attributes The block attributes. + * @param WP_Block $block The parsed block. + * @param WP_Block_List $inner_blocks The list of inner blocks. + */ + private static function handle_view_script_module_loading( $attributes, $block, $inner_blocks ) { + if ( static::is_interactive( $attributes, $inner_blocks ) ) { + wp_enqueue_script_module( '@wordpress/block-library/navigation-block' ); + } + } + + /** + * Returns the markup for the navigation block. + * + * @param array $attributes The block attributes. + * @param WP_Block_List $inner_blocks The list of inner blocks. + * @return string Returns the navigation wrapper markup. + */ + private static function get_wrapper_markup( $attributes, $inner_blocks ) { + $inner_blocks_html = static::get_inner_blocks_html( $attributes, $inner_blocks ); + if ( static::is_responsive( $attributes ) ) { + return static::get_responsive_container_markup( $attributes, $inner_blocks, $inner_blocks_html ); + } + return $inner_blocks_html; + } + + /** + * Returns a unique name for the navigation. + * + * @param array $attributes The block attributes. + * @return string Returns a unique name for the navigation. + */ + private static function get_unique_navigation_name( $attributes ) { + $nav_menu_name = static::get_navigation_name( $attributes ); + + // If the menu name has been used previously then append an ID + // to the name to ensure uniqueness across a given post. + if ( isset( static::$seen_menu_names[ $nav_menu_name ] ) && static::$seen_menu_names[ $nav_menu_name ] > 1 ) { + $count = static::$seen_menu_names[ $nav_menu_name ]; + $nav_menu_name = $nav_menu_name . ' ' . ( $count ); + } + + return $nav_menu_name; + } + + /** + * Renders the navigation block. + * + * @param array $attributes The block attributes. + * @param string $content The saved content. + * @param WP_Block $block The parsed block. + * @return string Returns the navigation block markup. + */ + public static function render( $attributes, $content, $block ) { + /** + * Deprecated: + * The rgbTextColor and rgbBackgroundColor attributes + * have been deprecated in favor of + * customTextColor and customBackgroundColor ones. + * Move the values from old attrs to the new ones. + */ + if ( isset( $attributes['rgbTextColor'] ) && empty( $attributes['textColor'] ) ) { + $attributes['customTextColor'] = $attributes['rgbTextColor']; + } + + if ( isset( $attributes['rgbBackgroundColor'] ) && empty( $attributes['backgroundColor'] ) ) { + $attributes['customBackgroundColor'] = $attributes['rgbBackgroundColor']; + } + + unset( $attributes['rgbTextColor'], $attributes['rgbBackgroundColor'] ); + + $inner_blocks = static::get_inner_blocks( $attributes, $block ); + // Prevent navigation blocks referencing themselves from rendering. + if ( block_core_navigation_block_contains_core_navigation( $inner_blocks ) ) { + return ''; + } + + static::handle_view_script_module_loading( $attributes, $block, $inner_blocks ); + + return sprintf( + '', + static::get_nav_wrapper_attributes( $attributes, $inner_blocks ), + static::get_wrapper_markup( $attributes, $inner_blocks ) + ); + } +} + // These functions are used for the __unstableLocation feature and only active // when the gutenberg plugin is active. if ( defined( 'IS_GUTENBERG_PLUGIN' ) && IS_GUTENBERG_PLUGIN ) { @@ -427,14 +1061,12 @@ function register_block_core_navigation() { ) ); - if ( defined( 'IS_GUTENBERG_PLUGIN' ) && IS_GUTENBERG_PLUGIN ) { - gutenberg_register_module( - '@wordpress/block-library/navigation-block', - gutenberg_url( '/build/interactivity/navigation.min.js' ), - array( '@wordpress/interactivity' ), - defined( 'GUTENBERG_VERSION' ) ? GUTENBERG_VERSION : get_bloginfo( 'version' ) - ); - } + wp_register_script_module( + '@wordpress/block-library/navigation-block', + gutenberg_url( '/build/interactivity/navigation.min.js' ), + array( '@wordpress/interactivity' ), + defined( 'GUTENBERG_VERSION' ) ? GUTENBERG_VERSION : get_bloginfo( 'version' ) + ); } add_action( 'init', 'register_block_core_navigation' ); @@ -473,25 +1105,6 @@ function block_core_navigation_typographic_presets_backcompatibility( $parsed_bl add_filter( 'render_block_data', 'block_core_navigation_typographic_presets_backcompatibility' ); -/** - * Ensure that the view script has the `wp-interactivity` dependency. - * - * @since 6.4.0 - * - * @global WP_Scripts $wp_scripts - */ -function block_core_navigation_ensure_interactivity_dependency() { - global $wp_scripts; - if ( - isset( $wp_scripts->registered['wp-block-navigation-view'] ) && - ! in_array( 'wp-interactivity', $wp_scripts->registered['wp-block-navigation-view']->deps, true ) - ) { - $wp_scripts->registered['wp-block-navigation-view']->deps[] = 'wp-interactivity'; - } -} - -add_action( 'wp_print_scripts', 'block_core_navigation_ensure_interactivity_dependency' ); - /** * Turns menu item data into a nested array of parsed blocks * diff --git a/packages/block-library/src/page-list/editor.scss b/packages/block-library/src/page-list/editor.scss index 18ecf904e3919a..b3f70746acd57f 100644 --- a/packages/block-library/src/page-list/editor.scss +++ b/packages/block-library/src/page-list/editor.scss @@ -58,10 +58,6 @@ } } -.wp-block-page-list .components-notice { - margin-left: 0; -} - // Space spinner to give it breathing // room when block is selected and has focus outline. .wp-block-page-list__loading-indicator-container { diff --git a/packages/block-library/src/post-content/edit.js b/packages/block-library/src/post-content/edit.js index ae3bb938c90eee..041a3b4fa02f61 100644 --- a/packages/block-library/src/post-content/edit.js +++ b/packages/block-library/src/post-content/edit.js @@ -5,8 +5,8 @@ import { __ } from '@wordpress/i18n'; import { useBlockProps, useInnerBlocksProps, - __experimentalRecursionProvider as RecursionProvider, - __experimentalUseHasRecursion as useHasRecursion, + RecursionProvider, + useHasRecursion, Warning, } from '@wordpress/block-editor'; import { diff --git a/packages/block-library/src/query/block.json b/packages/block-library/src/query/block.json index d30eccf3765792..6189f6f0189b79 100644 --- a/packages/block-library/src/query/block.json +++ b/packages/block-library/src/query/block.json @@ -52,6 +52,5 @@ "layout": true }, "editorStyle": "wp-block-query-editor", - "style": "wp-block-query", - "viewScript": "file:./view.min.js" + "style": "wp-block-query" } diff --git a/packages/block-library/src/query/edit/query-placeholder.js b/packages/block-library/src/query/edit/query-placeholder.js index ceb5cb065f75ca..1b81a2893ec4fb 100644 --- a/packages/block-library/src/query/edit/query-placeholder.js +++ b/packages/block-library/src/query/edit/query-placeholder.js @@ -11,7 +11,6 @@ import { useBlockProps, store as blockEditorStore, __experimentalBlockVariationPicker, - __experimentalGetMatchingVariation as getMatchingVariation, } from '@wordpress/block-editor'; import { Button, Placeholder } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; @@ -34,31 +33,32 @@ export default function QueryPlaceholder( { clientId, attributes ); - - const { blockType, allVariations, hasPatterns } = useSelect( + const { blockType, activeBlockVariation, hasPatterns } = useSelect( ( select ) => { - const { getBlockVariations, getBlockType } = select( blocksStore ); + const { getActiveBlockVariation, getBlockType } = + select( blocksStore ); const { getBlockRootClientId, getPatternsByBlockTypes } = select( blockEditorStore ); const rootClientId = getBlockRootClientId( clientId ); return { blockType: getBlockType( name ), - allVariations: getBlockVariations( name ), + activeBlockVariation: getActiveBlockVariation( + name, + attributes + ), hasPatterns: !! getPatternsByBlockTypes( blockNameForPatterns, rootClientId ).length, }; }, - [ name, blockNameForPatterns, clientId ] + [ name, blockNameForPatterns, clientId, attributes ] ); - - const matchingVariation = getMatchingVariation( attributes, allVariations ); const icon = - matchingVariation?.icon?.src || - matchingVariation?.icon || + activeBlockVariation?.icon?.src || + activeBlockVariation?.icon || blockType?.icon?.src; - const label = matchingVariation?.title || blockType?.title; + const label = activeBlockVariation?.title || blockType?.title; if ( isStartingBlank ) { return ( next_tag() ) { // Add the necessary directives. @@ -63,43 +69,17 @@ class="wp-block-query__enhanced-pagination-animation" } } - $is_gutenberg_plugin = defined( 'IS_GUTENBERG_PLUGIN' ) && IS_GUTENBERG_PLUGIN; - $should_load_view_script = $attributes['enhancedPagination'] && isset( $attributes['queryId'] ); - $view_asset = 'wp-block-query-view'; - $script_handles = $block->block_type->view_script_handles; - - if ( $is_gutenberg_plugin ) { - if ( $should_load_view_script ) { - gutenberg_enqueue_module( '@wordpress/block-library/query' ); - } - // Remove the view script because we are using the module. - $block->block_type->view_script_handles = array_diff( $script_handles, array( $view_asset ) ); - } else { - if ( ! wp_script_is( $view_asset ) ) { - // If the script is not needed, and it is still in the `view_script_handles`, remove it. - if ( ! $should_load_view_script && in_array( $view_asset, $script_handles, true ) - ) { - $block->block_type->view_script_handles = array_diff( $script_handles, array( $view_asset ) ); - } - // If the script is needed, but it was previously removed, add it again. - if ( $should_load_view_script && ! in_array( $view_asset, $script_handles, true ) ) { - $block->block_type->view_script_handles = array_merge( $script_handles, array( $view_asset ) ); - } - } - } - + // Add the styles to the block type if the block is interactive and remove + // them if it's not. $style_asset = 'wp-block-query'; if ( ! wp_style_is( $style_asset ) ) { $style_handles = $block->block_type->style_handles; // If the styles are not needed, and they are still in the `style_handles`, remove them. - if ( - ( ! $attributes['enhancedPagination'] || ! isset( $attributes['queryId'] ) ) - && in_array( $style_asset, $style_handles, true ) - ) { + if ( ! $is_interactive && in_array( $style_asset, $style_handles, true ) ) { $block->block_type->style_handles = array_diff( $style_handles, array( $style_asset ) ); } // If the styles are needed, but they were previously removed, add them again. - if ( $attributes['enhancedPagination'] && isset( $attributes['queryId'] ) && ! in_array( $style_asset, $style_handles, true ) ) { + if ( $is_interactive && ! in_array( $style_asset, $style_handles, true ) ) { $block->block_type->style_handles = array_merge( $style_handles, array( $style_asset ) ); } } @@ -107,25 +87,6 @@ class="wp-block-query__enhanced-pagination-animation" return $content; } -/** - * Ensure that the view script has the `wp-interactivity` dependency. - * - * @since 6.4.0 - * - * @global WP_Scripts $wp_scripts - */ -function block_core_query_ensure_interactivity_dependency() { - global $wp_scripts; - if ( - isset( $wp_scripts->registered['wp-block-query-view'] ) && - ! in_array( 'wp-interactivity', $wp_scripts->registered['wp-block-query-view']->deps, true ) - ) { - $wp_scripts->registered['wp-block-query-view']->deps[] = 'wp-interactivity'; - } -} - -add_action( 'wp_print_scripts', 'block_core_query_ensure_interactivity_dependency' ); - /** * Registers the `core/query` block on the server. */ @@ -137,20 +98,18 @@ function register_block_core_query() { ) ); - if ( defined( 'IS_GUTENBERG_PLUGIN' ) && IS_GUTENBERG_PLUGIN ) { - gutenberg_register_module( - '@wordpress/block-library/query', - '/wp-content/plugins/gutenberg/build/interactivity/query.min.js', + wp_register_script_module( + '@wordpress/block-library/query', + '/wp-content/plugins/gutenberg/build/interactivity/query.min.js', + array( + '@wordpress/interactivity', array( - '@wordpress/interactivity', - array( - 'id' => '@wordpress/interactivity/router', - 'type' => 'dynamic', - ), + 'id' => '@wordpress/interactivity/router', + 'type' => 'dynamic', ), - defined( 'GUTENBERG_VERSION' ) ? GUTENBERG_VERSION : get_bloginfo( 'version' ) - ); - } + ), + defined( 'GUTENBERG_VERSION' ) ? GUTENBERG_VERSION : get_bloginfo( 'version' ) + ); } add_action( 'init', 'register_block_core_query' ); @@ -170,14 +129,10 @@ function block_core_query_disable_enhanced_pagination( $parsed_block ) { static $dirty_enhanced_queries = array(); static $render_query_callback = null; - $block_name = $parsed_block['blockName']; + $is_interactive = isset( $parsed_block['attrs']['enhancedPagination'] ) && true === $parsed_block['attrs']['enhancedPagination'] && isset( $parsed_block['attrs']['queryId'] ); + $block_name = $parsed_block['blockName']; - if ( - 'core/query' === $block_name && - isset( $parsed_block['attrs']['enhancedPagination'] ) && - true === $parsed_block['attrs']['enhancedPagination'] && - isset( $parsed_block['attrs']['queryId'] ) - ) { + if ( 'core/query' === $block_name && $is_interactive ) { $enhanced_query_stack[] = $parsed_block['attrs']['queryId']; if ( ! isset( $render_query_callback ) ) { @@ -192,12 +147,9 @@ function block_core_query_disable_enhanced_pagination( $parsed_block ) { * @return string Returns the modified output of the query block. */ $render_query_callback = static function ( $content, $block ) use ( &$enhanced_query_stack, &$dirty_enhanced_queries, &$render_query_callback ) { - $has_enhanced_pagination = - isset( $block['attrs']['enhancedPagination'] ) && - true === $block['attrs']['enhancedPagination'] && - isset( $block['attrs']['queryId'] ); + $is_interactive = isset( $block['attrs']['enhancedPagination'] ) && true === $block['attrs']['enhancedPagination'] && isset( $block['attrs']['queryId'] ); - if ( ! $has_enhanced_pagination ) { + if ( ! $is_interactive ) { return $content; } diff --git a/packages/block-library/src/query/variations.js b/packages/block-library/src/query/variations.js index fb15645760f293..65a73d134981f3 100644 --- a/packages/block-library/src/query/variations.js +++ b/packages/block-library/src/query/variations.js @@ -39,6 +39,7 @@ const variations = [ ), icon: postList, attributes: { + namespace: 'core/posts-list', query: { perPage: 4, pages: 1, @@ -53,6 +54,9 @@ const variations = [ }, }, scope: [ 'inserter' ], + isActive: ( { namespace, query } ) => { + return namespace === 'core/posts-list' && query.postType === 'post'; + }, }, { name: 'title-date', diff --git a/packages/block-library/src/search/block.json b/packages/block-library/src/search/block.json index 15531475adc9ac..8d5e208045068d 100644 --- a/packages/block-library/src/search/block.json +++ b/packages/block-library/src/search/block.json @@ -87,7 +87,6 @@ }, "html": false }, - "viewScript": "file:./view.min.js", "editorStyle": "wp-block-search-editor", "style": "wp-block-search" } diff --git a/packages/block-library/src/search/index.php b/packages/block-library/src/search/index.php index ae6ddb1c4fb372..266eb93ca82a47 100644 --- a/packages/block-library/src/search/index.php +++ b/packages/block-library/src/search/index.php @@ -16,7 +16,7 @@ * * @return string The search block markup. */ -function render_block_core_search( $attributes, $content, $block ) { +function render_block_core_search( $attributes ) { // Older versions of the Search block defaulted the label and buttonText // attributes to `__( 'Search' )` meaning that many posts contain ``. Support these by defaulting an undefined label and @@ -77,39 +77,19 @@ function render_block_core_search( $attributes, $content, $block ) { $input->set_attribute( 'value', get_search_query() ); $input->set_attribute( 'placeholder', $attributes['placeholder'] ); + // If it's interactive, enqueue the script module and add the directives. $is_expandable_searchfield = 'button-only' === $button_position; if ( $is_expandable_searchfield ) { + wp_enqueue_script_module( '@wordpress/block-library/search-block' ); + $input->set_attribute( 'data-wp-bind--aria-hidden', '!context.isSearchInputVisible' ); $input->set_attribute( 'data-wp-bind--tabindex', 'state.tabindex' ); - // Adding these attributes manually is needed until the Interactivity API SSR logic is added to core. + + // Adding these attributes manually is needed until the Interactivity API + // SSR logic is added to core. $input->set_attribute( 'aria-hidden', 'true' ); $input->set_attribute( 'tabindex', '-1' ); } - - $is_gutenberg_plugin = defined( 'IS_GUTENBERG_PLUGIN' ) && IS_GUTENBERG_PLUGIN; - $script_handles = $block->block_type->view_script_handles; - $view_js_file = 'wp-block-search-view'; - - if ( $is_gutenberg_plugin ) { - if ( $is_expandable_searchfield ) { - gutenberg_enqueue_module( '@wordpress/block-library/search-block' ); - } - // Remove the view script because we are using the module. - $block->block_type->view_script_handles = array_diff( $script_handles, array( $view_js_file ) ); - } else { - // If the script already exists, there is no point in removing it from viewScript. - if ( ! wp_script_is( $view_js_file ) ) { - - // If the script is not needed, and it is still in the `view_script_handles`, remove it. - if ( ! $is_expandable_searchfield && in_array( $view_js_file, $script_handles, true ) ) { - $block->block_type->view_script_handles = array_diff( $script_handles, array( $view_js_file ) ); - } - // If the script is needed, but it was previously removed, add it again. - if ( $is_expandable_searchfield && ! in_array( $view_js_file, $script_handles, true ) ) { - $block->block_type->view_script_handles = array_merge( $script_handles, array( $view_js_file ) ); - } - } - } } if ( count( $query_params ) > 0 ) { @@ -159,7 +139,9 @@ function render_block_core_search( $attributes, $content, $block ) { $button->set_attribute( 'data-wp-bind--aria-expanded', 'context.isSearchInputVisible' ); $button->set_attribute( 'data-wp-bind--type', 'state.type' ); $button->set_attribute( 'data-wp-on--click', 'actions.openSearchInput' ); - // Adding these attributes manually is needed until the Interactivity API SSR logic is added to core. + + // Adding these attributes manually is needed until the Interactivity + // API SSR logic is added to core. $button->set_attribute( 'aria-label', __( 'Expand search field' ) ); $button->set_attribute( 'aria-controls', 'wp-block-search__input-' . $input_id ); $button->set_attribute( 'aria-expanded', 'false' ); @@ -181,6 +163,8 @@ function render_block_core_search( $attributes, $content, $block ) { array( 'class' => $classnames ) ); $form_directives = ''; + + // If it's interactive, add the directives. if ( $is_expandable_searchfield ) { $aria_label_expanded = __( 'Submit Search' ); $aria_label_collapsed = __( 'Expand search field' ); @@ -213,14 +197,12 @@ function register_block_core_search() { ) ); - if ( defined( 'IS_GUTENBERG_PLUGIN' ) && IS_GUTENBERG_PLUGIN ) { - gutenberg_register_module( - '@wordpress/block-library/search-block', - gutenberg_url( '/build/interactivity/search.min.js' ), - array( '@wordpress/interactivity' ), - defined( 'GUTENBERG_VERSION' ) ? GUTENBERG_VERSION : get_bloginfo( 'version' ) - ); - } + wp_register_script_module( + '@wordpress/block-library/search-block', + gutenberg_url( '/build/interactivity/search.min.js' ), + array( '@wordpress/interactivity' ), + defined( 'GUTENBERG_VERSION' ) ? GUTENBERG_VERSION : get_bloginfo( 'version' ) + ); } add_action( 'init', 'register_block_core_search' ); diff --git a/packages/block-library/src/table-of-contents/hooks.js b/packages/block-library/src/table-of-contents/hooks.js index af7b66568123f8..ef24d8167381ad 100644 --- a/packages/block-library/src/table-of-contents/hooks.js +++ b/packages/block-library/src/table-of-contents/hooks.js @@ -17,7 +17,7 @@ function getLatestHeadings( select, clientId ) { getBlockAttributes, getBlockName, getClientIdsWithDescendants, - __experimentalGetGlobalBlocksByName: getGlobalBlocksByName, + getBlocksByName, } = select( blockEditorStore ); // FIXME: @wordpress/block-library should not depend on @wordpress/editor. @@ -28,7 +28,7 @@ function getLatestHeadings( select, clientId ) { // eslint-disable-next-line @wordpress/data-no-store-string-literals const permalink = select( 'core/editor' ).getPermalink() ?? null; - const isPaginated = getGlobalBlocksByName( 'core/nextpage' ).length !== 0; + const isPaginated = getBlocksByName( 'core/nextpage' ).length !== 0; const { onlyIncludeCurrentPage } = getBlockAttributes( clientId ) ?? {}; // Get the client ids of all blocks in the editor. diff --git a/packages/block-library/src/template-part/edit/index.js b/packages/block-library/src/template-part/edit/index.js index ec92996978bd6f..72fc06338e7807 100644 --- a/packages/block-library/src/template-part/edit/index.js +++ b/packages/block-library/src/template-part/edit/index.js @@ -7,8 +7,8 @@ import { useBlockProps, Warning, store as blockEditorStore, - __experimentalRecursionProvider as RecursionProvider, - __experimentalUseHasRecursion as useHasRecursion, + RecursionProvider, + useHasRecursion, InspectorControls, } from '@wordpress/block-editor'; import { Spinner, Modal, MenuItem } from '@wordpress/components'; diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md index 92ce15dc425f6d..7ad6becdb8aef6 100644 --- a/packages/components/CHANGELOG.md +++ b/packages/components/CHANGELOG.md @@ -9,16 +9,22 @@ - `PaletteEdit`: improve unit tests ([#57645](https://github.com/WordPress/gutenberg/pull/57645)). - `PaletteEdit` and `CircularOptionPicker`: improve unit tests ([#57809](https://github.com/WordPress/gutenberg/pull/57809)). - `Tooltip`: no-op when nested inside other `Tooltip` components ([#57202](https://github.com/WordPress/gutenberg/pull/57202)). +- `Tooltip` and `Button`: tidy up unit tests ([#57975](https://github.com/WordPress/gutenberg/pull/57975)). +- `BorderControl`, `BorderBoxControl`: Replace style picker with ToggleGroupControl ([#57562](https://github.com/WordPress/gutenberg/pull/57562)). +- `SlotFill`: fix typo in use-slot-fills return docs ([#57654](https://github.com/WordPress/gutenberg/pull/57654)) ### Bug Fix - `ToggleGroupControl`: Improve controlled value detection ([#57770](https://github.com/WordPress/gutenberg/pull/57770)). - `Tooltip`: Improve props forwarding to children of nested `Tooltip` components ([#57878](https://github.com/WordPress/gutenberg/pull/57878)). +- `Tooltip`: revert prop types to only accept component-specific props ([#58125](https://github.com/WordPress/gutenberg/pull/58125)). +- `Button`: prevent the component from trashing and re-creating the HTML element ([#56490](https://github.com/WordPress/gutenberg/pull/56490)). ### Experimental - `BoxControl`: Update design ([#56665](https://github.com/WordPress/gutenberg/pull/56665)). - `CustomSelect`: adjust `renderSelectedValue` to fix sizing ([#57865](https://github.com/WordPress/gutenberg/pull/57865)). +- `Theme`: Set `color` on wrapper div ([#58095](https://github.com/WordPress/gutenberg/pull/58095)). ## 25.15.0 (2024-01-10) diff --git a/packages/components/src/border-control/border-control-dropdown/component.tsx b/packages/components/src/border-control/border-control-dropdown/component.tsx index 3ee01bcda8f3b3..0a56edd946308b 100644 --- a/packages/components/src/border-control/border-control-dropdown/component.tsx +++ b/packages/components/src/border-control/border-control-dropdown/component.tsx @@ -142,6 +142,7 @@ const BorderControlDropdown = ( enableStyle, indicatorClassName, indicatorWrapperClassName, + isStyleSettable, onReset, onColorChange, onStyleChange, @@ -218,7 +219,7 @@ const BorderControlDropdown = ( clearable={ false } enableAlpha={ enableAlpha } /> - { enableStyle && ( + { enableStyle && isStyleSettable && ( { - const { label, hideLabelFromVision } = props; - - if ( ! label ) { - return null; - } - - return hideLabelFromVision ? ( - { label } - ) : ( - { label } - ); -}; - -const BorderControlStylePicker = ( - props: WordPressComponentProps< StylePickerProps, 'div' >, +function UnconnectedBorderControlStylePicker( + { onChange, ...restProps }: StylePickerProps, forwardedRef: React.ForwardedRef< any > -) => { - const { - buttonClassName, - hideLabelFromVision, - label, - onChange, - value, - ...otherProps - } = useBorderControlStylePicker( props ); - +) { return ( - -