From e54325a6ccfca3cecddace0038922b478e97d73d Mon Sep 17 00:00:00 2001 From: Josh Oakes Date: Mon, 19 Feb 2024 17:07:15 -0600 Subject: [PATCH 001/899] Initial nav block --- .../class-kadence-blocks-navigation-block.php | 228 +++++ ...s-kadence-blocks-navigation-link-block.php | 87 ++ includes/init.php | 7 +- includes/navigation/kb-navigation-cpt.php | 102 ++ includes/navigation/kb-navigation-rest.php | 191 ++++ kadence-blocks.php | 6 + src/blocks/navigation-link/block.json | 92 ++ src/blocks/navigation-link/edit.js | 583 +++++++++++ src/blocks/navigation-link/editor.scss | 139 +++ src/blocks/navigation-link/hooks.js | 54 + src/blocks/navigation-link/index.js | 36 + src/blocks/navigation-link/index.php | 493 ++++++++++ src/blocks/navigation-link/link-ui.js | 239 +++++ src/blocks/navigation-link/style.scss | 16 + src/blocks/navigation-link/transforms.js | 133 +++ .../navigation-link/update-attributes.js | 98 ++ src/blocks/navigation-submenu/block.json | 68 ++ src/blocks/navigation-submenu/edit.js | 504 ++++++++++ src/blocks/navigation-submenu/editor.scss | 42 + src/blocks/navigation-submenu/icons.js | 16 + src/blocks/navigation-submenu/index.js | 46 + src/blocks/navigation-submenu/index.php | 252 +++++ src/blocks/navigation-submenu/init.js | 6 + src/blocks/navigation-submenu/save.js | 8 + src/blocks/navigation-submenu/transforms.js | 58 ++ src/blocks/navigation/README.md | 13 + src/blocks/navigation/block.json | 146 +++ src/blocks/navigation/constants.js | 41 + .../navigation/edit/accessible-description.js | 14 + .../edit/accessible-menu-description.js | 20 + .../navigation/edit/are-blocks-dirty.js | 51 + .../edit/deleted-navigation-warning.js | 24 + src/blocks/navigation/edit/index.js | 927 ++++++++++++++++++ src/blocks/navigation/edit/inner-blocks.js | 127 +++ src/blocks/navigation/edit/leaf-more-menu.js | 170 ++++ .../navigation/edit/manage-menus-button.js | 32 + .../edit/menu-inspector-controls.js | 184 ++++ .../edit/navigation-menu-delete-control.js | 79 ++ .../edit/navigation-menu-name-control.js | 23 + .../edit/navigation-menu-selector.js | 199 ++++ .../navigation/edit/overlay-menu-icon.js | 25 + .../navigation/edit/overlay-menu-preview.js | 49 + .../navigation/edit/placeholder/index.js | 90 ++ .../edit/placeholder/placeholder-preview.js | 21 + .../navigation/edit/responsive-wrapper.js | 121 +++ .../navigation/edit/unsaved-inner-blocks.js | 135 +++ .../use-convert-classic-menu-to-block-menu.js | 179 ++++ .../edit/use-create-navigation-menu.js | 106 ++ .../use-generate-default-navigation-title.js | 79 ++ .../navigation/edit/use-inner-blocks.js | 39 + .../navigation/edit/use-navigation-notice.js | 40 + src/blocks/navigation/edit/utils.js | 112 +++ src/blocks/navigation/editor.scss | 647 ++++++++++++ src/blocks/navigation/index.js | 57 ++ src/blocks/navigation/index.php | 712 ++++++++++++++ src/blocks/navigation/init.js | 6 + src/blocks/navigation/menu-items-to-blocks.js | 224 +++++ src/blocks/navigation/style.scss | 756 ++++++++++++++ .../navigation/use-navigation-entities.js | 72 ++ src/blocks/navigation/use-navigation-menu.js | 105 ++ .../use-template-part-area-label.js | 89 ++ src/blocks/navigation/view.js | 189 ++++ webpack.config.js | 2 + 63 files changed, 9408 insertions(+), 1 deletion(-) create mode 100644 includes/blocks/class-kadence-blocks-navigation-block.php create mode 100644 includes/blocks/class-kadence-blocks-navigation-link-block.php create mode 100644 includes/navigation/kb-navigation-cpt.php create mode 100644 includes/navigation/kb-navigation-rest.php create mode 100644 src/blocks/navigation-link/block.json create mode 100644 src/blocks/navigation-link/edit.js create mode 100644 src/blocks/navigation-link/editor.scss create mode 100644 src/blocks/navigation-link/hooks.js create mode 100644 src/blocks/navigation-link/index.js create mode 100644 src/blocks/navigation-link/index.php create mode 100644 src/blocks/navigation-link/link-ui.js create mode 100644 src/blocks/navigation-link/style.scss create mode 100644 src/blocks/navigation-link/transforms.js create mode 100644 src/blocks/navigation-link/update-attributes.js create mode 100644 src/blocks/navigation-submenu/block.json create mode 100644 src/blocks/navigation-submenu/edit.js create mode 100644 src/blocks/navigation-submenu/editor.scss create mode 100644 src/blocks/navigation-submenu/icons.js create mode 100644 src/blocks/navigation-submenu/index.js create mode 100644 src/blocks/navigation-submenu/index.php create mode 100644 src/blocks/navigation-submenu/init.js create mode 100644 src/blocks/navigation-submenu/save.js create mode 100644 src/blocks/navigation-submenu/transforms.js create mode 100644 src/blocks/navigation/README.md create mode 100644 src/blocks/navigation/block.json create mode 100644 src/blocks/navigation/constants.js create mode 100644 src/blocks/navigation/edit/accessible-description.js create mode 100644 src/blocks/navigation/edit/accessible-menu-description.js create mode 100644 src/blocks/navigation/edit/are-blocks-dirty.js create mode 100644 src/blocks/navigation/edit/deleted-navigation-warning.js create mode 100644 src/blocks/navigation/edit/index.js create mode 100644 src/blocks/navigation/edit/inner-blocks.js create mode 100644 src/blocks/navigation/edit/leaf-more-menu.js create mode 100644 src/blocks/navigation/edit/manage-menus-button.js create mode 100644 src/blocks/navigation/edit/menu-inspector-controls.js create mode 100644 src/blocks/navigation/edit/navigation-menu-delete-control.js create mode 100644 src/blocks/navigation/edit/navigation-menu-name-control.js create mode 100644 src/blocks/navigation/edit/navigation-menu-selector.js create mode 100644 src/blocks/navigation/edit/overlay-menu-icon.js create mode 100644 src/blocks/navigation/edit/overlay-menu-preview.js create mode 100644 src/blocks/navigation/edit/placeholder/index.js create mode 100644 src/blocks/navigation/edit/placeholder/placeholder-preview.js create mode 100644 src/blocks/navigation/edit/responsive-wrapper.js create mode 100644 src/blocks/navigation/edit/unsaved-inner-blocks.js create mode 100644 src/blocks/navigation/edit/use-convert-classic-menu-to-block-menu.js create mode 100644 src/blocks/navigation/edit/use-create-navigation-menu.js create mode 100644 src/blocks/navigation/edit/use-generate-default-navigation-title.js create mode 100644 src/blocks/navigation/edit/use-inner-blocks.js create mode 100644 src/blocks/navigation/edit/use-navigation-notice.js create mode 100644 src/blocks/navigation/edit/utils.js create mode 100644 src/blocks/navigation/editor.scss create mode 100644 src/blocks/navigation/index.js create mode 100644 src/blocks/navigation/index.php create mode 100644 src/blocks/navigation/init.js create mode 100644 src/blocks/navigation/menu-items-to-blocks.js create mode 100644 src/blocks/navigation/style.scss create mode 100644 src/blocks/navigation/use-navigation-entities.js create mode 100644 src/blocks/navigation/use-navigation-menu.js create mode 100644 src/blocks/navigation/use-template-part-area-label.js create mode 100644 src/blocks/navigation/view.js diff --git a/includes/blocks/class-kadence-blocks-navigation-block.php b/includes/blocks/class-kadence-blocks-navigation-block.php new file mode 100644 index 000000000..49a72e2e1 --- /dev/null +++ b/includes/blocks/class-kadence-blocks-navigation-block.php @@ -0,0 +1,228 @@ + 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(); + + /** + * Instance Control + */ + public static function get_instance() { + if ( is_null( self::$instance ) ) { + self::$instance = new self(); + } + + return self::$instance; + } + + /** + * Builds CSS for block. + * + * @param array $attributes the blocks attributes. + * @param Kadence_Blocks_CSS $css the css class for blocks. + * @param string $unique_id the blocks attr ID. + * @param string $unique_style_id the blocks alternate ID for queries. + */ + public function build_css( $attributes, $css, $unique_id, $unique_style_id ) { + + $css->set_style_id( 'kb-' . $this->block_name . $unique_style_id ); + + return $css->css_output(); + } + + /** + * Build HTML for dynamic blocks + * + * @param $attributes + * @param $unique_id + * @param $content + * @param WP_Block $block_instance The instance of the WP_Block class that represents the block being rendered. + * + * @return mixed + */ + public function build_html( $attributes, $unique_id, $content, $block_instance ) { + + $nav_block = get_post( $attributes['ref'] ); + + $inner_blocks = static::get_inner_blocks( $attributes, $block_instance ); + // Prevent navigation blocks referencing themselves from rendering. + if ( block_core_navigation_block_contains_core_navigation( $inner_blocks ) ) { + return '-- Failed here --'; + } + + $name = ! empty( $attributes['name'] ) ? $attributes['name'] : ''; + $wrapper_attributes = get_block_wrapper_attributes( + array( + 'class' => 'kb-block-navigation', + 'aria-label' => $name, + ) + ); + + $content = do_blocks( $nav_block->post_content ); + + return sprintf( + '', + $wrapper_attributes, + $content + ); + } + + /** + * 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. + $blocks = block_core_navigation_filter_out_empty_blocks( $parsed_blocks ); + + if ( function_exists( 'get_hooked_block_markup' ) ) { + // Run Block Hooks algorithm to inject hooked blocks. + $markup = block_core_navigation_insert_hooked_blocks( $blocks, $navigation_post ); + $root_nav_block = parse_blocks( $markup )[0]; + + $blocks = isset( $root_nav_block['innerBlocks'] ) ? $root_nav_block['innerBlocks'] : $blocks; + } + + // TODO - this uses the full navigation block attributes for the + // context which could be refined. + return new WP_Block_List( $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; + + // 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 ); + } + + $post_ids = block_core_navigation_get_post_ids( $inner_blocks ); + if ( $post_ids ) { + _prime_post_caches( $post_ids, false, false ); + } + + return $inner_blocks; + } + + + /** + * 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 ); + + return $inner_blocks_html; + } + +} + +Kadence_Blocks_Navigation_Block::get_instance(); diff --git a/includes/blocks/class-kadence-blocks-navigation-link-block.php b/includes/blocks/class-kadence-blocks-navigation-link-block.php new file mode 100644 index 000000000..59dadd5f5 --- /dev/null +++ b/includes/blocks/class-kadence-blocks-navigation-link-block.php @@ -0,0 +1,87 @@ +set_style_id( 'kb-' . $this->block_name . $unique_style_id ); + + return $css->css_output(); + } + /** + * Build HTML for dynamic blocks + * + * @param $attributes + * @param $unique_id + * @param $content + * @param WP_Block $block_instance The instance of the WP_Block class that represents the block being rendered. + * + * @return mixed + */ + public function build_html( $attributes, $unique_id, $content, $block_instance ) { + $label = !empty( $attributes['label'] ) ? $attributes['label'] : ''; + $url = !empty( $attributes['url'] ) ? $attributes['url'] : ''; + + return '' . esc_html( $label ) . ''; + } + +} + +Kadence_Blocks_Navigation_Link_Block::get_instance(); diff --git a/includes/init.php b/includes/init.php index 4d10e5c57..d08100732 100644 --- a/includes/init.php +++ b/includes/init.php @@ -81,6 +81,8 @@ function kadence_gutenberg_editor_assets() { 'image', 'infobox', 'lottie', + 'navigation', + 'navigation-link', 'posts', 'rowlayout', 'progress-bar', @@ -1078,6 +1080,9 @@ function kadence_blocks_register_api_endpoints() { $lottieanimation_controller_upload = new Kadence_LottieAnimation_post_REST_Controller(); $lottieanimation_controller_upload->register_routes(); + $nav = new WP_REST_KB_Navigation_Fallback_Controller(); + $nav->register_routes(); + $design_library_controller_upload = new Kadence_Blocks_Prebuilt_Library_REST_Controller(); $design_library_controller_upload->register_routes(); $image_picker_controller_upload = new Kadence_Blocks_Image_Picker_REST_Controller(); @@ -1135,7 +1140,7 @@ function kadence_blocks_skip_lazy_load( $value, $image, $context ) { /** * Filter to remove block rendering when events builds their custom excerpts. - * + * * @param bool $remove_blocks Whether to remove blocks or not. * @param WP_Post $post The post object. */ diff --git a/includes/navigation/kb-navigation-cpt.php b/includes/navigation/kb-navigation-cpt.php new file mode 100644 index 000000000..c84622f44 --- /dev/null +++ b/includes/navigation/kb-navigation-cpt.php @@ -0,0 +1,102 @@ + '%s', + 'postType' => 'kb_navigation', + 'canvas' => 'edit', + ) + ); + + register_post_type( + 'kb_navigation', + array( + 'labels' => array( + 'name' => _x( 'Kadence Navigation Menus', 'post type general name' ), + 'singular_name' => _x( 'Kadence Navigation Menu', 'post type singular name' ), + 'add_new' => __( 'Add New Kadence Navigation Menu' ), + 'add_new_item' => __( 'Add New Kadence Navigation Menu' ), + 'new_item' => __( 'New Kadence Navigation Menu' ), + 'edit_item' => __( 'Edit Kadence Navigation Menu' ), + 'view_item' => __( 'View Kadence Navigation Menu' ), + 'all_items' => __( 'Kadence Navigation Menus' ), + 'search_items' => __( 'Search Kadence Navigation Menus' ), + 'parent_item_colon' => __( 'Parent Kadence Navigation Menu:' ), + 'not_found' => __( 'No Kadence Navigation Menu found.' ), + 'not_found_in_trash' => __( 'No Kadence Navigation Menu found in Trash.' ), + 'archives' => __( 'Kadence Navigation Menu archives' ), + 'insert_into_item' => __( 'Insert into Kadence Navigation Menu' ), + 'uploaded_to_this_item' => __( 'Uploaded to this Kadence Navigation Menu' ), + 'filter_items_list' => __( 'Filter Kadence Navigation Menu list' ), + 'items_list_navigation' => __( 'Kadence Navigation Menus list navigation' ), + 'items_list' => __( 'Kadence Navigation Menus list' ), + ), + 'description' => __( 'Kadence Navigation menus that can be inserted into your site.' ), + 'public' => false, + '_builtin' => true, /* internal use only. don't use this when registering your own post type. */ + '_edit_link' => $navigation_post_edit_link, /* internal use only. don't use this when registering your own post type. */ + 'has_archive' => false, + 'show_ui' => true, + 'show_in_menu' => false, + 'show_in_admin_bar' => false, + 'show_in_rest' => true, + 'rewrite' => false, + 'map_meta_cap' => true, + 'capabilities' => array( + 'edit_others_posts' => 'edit_theme_options', + 'delete_posts' => 'edit_theme_options', + 'publish_posts' => 'edit_theme_options', + 'create_posts' => 'edit_theme_options', + 'read_private_posts' => 'edit_theme_options', + 'delete_private_posts' => 'edit_theme_options', + 'delete_published_posts' => 'edit_theme_options', + 'delete_others_posts' => 'edit_theme_options', + 'edit_private_posts' => 'edit_theme_options', + 'edit_published_posts' => 'edit_theme_options', + 'edit_posts' => 'edit_theme_options', + ), + 'rest_base' => 'kb_navigation', + 'rest_controller_class' => 'WP_REST_Posts_Controller', + 'supports' => array( + 'title', + 'editor', + 'revisions', + ), + ) + ); + } +} + +Kadence_Blocks_Navigation_CPT_Controller::get_instance(); diff --git a/includes/navigation/kb-navigation-rest.php b/includes/navigation/kb-navigation-rest.php new file mode 100644 index 000000000..0ec80c03c --- /dev/null +++ b/includes/navigation/kb-navigation-rest.php @@ -0,0 +1,191 @@ +namespace = 'wp-block-editor/v1'; + $this->rest_base = 'navigation-fallback'; + $this->post_type = 'kb_navigation'; + } + + /** + * Registers the controllers routes. + * + * @since 6.3.0 + */ + public function register_routes() { + + // Lists a single nav item based on the given id or slug. + register_rest_route( + $this->namespace, + '/' . $this->rest_base, + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_item' ), + 'permission_callback' => array( $this, 'get_item_permissions_check' ), + 'args' => $this->get_endpoint_args_for_item_schema( WP_REST_Server::READABLE ), + ), + 'schema' => array( $this, 'get_item_schema' ), + ) + ); + } + + /** + * Checks if a given request has access to read fallbacks. + * + * @since 6.3.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 ) { + + $post_type = get_post_type_object( $this->post_type ); + + // Getting fallbacks requires creating and reading `wp_navigation` posts. + if ( ! current_user_can( $post_type->cap->create_posts ) || ! current_user_can( 'edit_theme_options' ) || ! current_user_can( 'edit_posts' ) ) { + return new WP_Error( + 'rest_cannot_create', + __( 'Sorry, you are not allowed to create Navigation Menus as this user.' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + if ( 'edit' === $request['context'] && ! current_user_can( $post_type->cap->edit_posts ) ) { + return new WP_Error( + 'rest_forbidden_context', + __( 'Sorry, you are not allowed to edit Navigation Menus as this user.' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + return true; + } + + /** + * Gets the most appropriate fallback Navigation Menu. + * + * @since 6.3.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 = WP_Navigation_Fallback::get_fallback(); + + if ( empty( $post ) ) { + return rest_ensure_response( new WP_Error( 'no_fallback_menu', __( 'No fallback menu found.' ), array( 'status' => 404 ) ) ); + } + + $response = $this->prepare_item_for_response( $post, $request ); + + return $response; + } + + /** + * Retrieves the fallbacks' schema, conforming to JSON Schema. + * + * @since 6.3.0 + * + * @return array Item schema data. + */ + public function get_item_schema() { + if ( $this->schema ) { + return $this->add_additional_fields_schema( $this->schema ); + } + + $this->schema = array( + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'title' => 'navigation-fallback', + 'type' => 'object', + 'properties' => array( + 'id' => array( + 'description' => __( 'The unique identifier for the Navigation Menu.' ), + 'type' => 'integer', + 'context' => array( 'view', 'edit', 'embed' ), + 'readonly' => true, + ), + ), + ); + + return $this->add_additional_fields_schema( $this->schema ); + } + + /** + * Matches the post data to the schema we want. + * + * @since 6.3.0 + * + * @param WP_Post $item The wp_navigation Post object whose response is being prepared. + * @param WP_REST_Request $request Request object. + * @return WP_REST_Response $response The response data. + */ + public function prepare_item_for_response( $item, $request ) { + $data = array(); + + $fields = $this->get_fields_for_response( $request ); + + if ( rest_is_field_included( 'id', $fields ) ) { + $data['id'] = (int) $item->ID; + } + + $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; + $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 ); + } + + return $response; + } + + /** + * Prepares the links for the request. + * + * @since 6.3.0 + * + * @param WP_Post $post the Navigation Menu post object. + * @return array Links for the given request. + */ + private function prepare_links( $post ) { + return array( + 'self' => array( + 'href' => rest_url( rest_get_route_for_post( $post->ID ) ), + 'embeddable' => true, + ), + ); + } +} diff --git a/kadence-blocks.php b/kadence-blocks.php index 2c42d5399..7b5df6648 100644 --- a/kadence-blocks.php +++ b/kadence-blocks.php @@ -88,6 +88,8 @@ function kadence_blocks_init() { require_once KADENCE_BLOCKS_PATH . 'includes/blocks/class-kadence-blocks-tabs-block.php'; require_once KADENCE_BLOCKS_PATH . 'includes/blocks/class-kadence-blocks-testimonials-block.php'; require_once KADENCE_BLOCKS_PATH . 'includes/blocks/class-kadence-blocks-testimonial-block.php'; + require_once KADENCE_BLOCKS_PATH . 'includes/blocks/class-kadence-blocks-navigation-block.php'; + require_once KADENCE_BLOCKS_PATH . 'includes/blocks/class-kadence-blocks-navigation-link-block.php'; require_once KADENCE_BLOCKS_PATH . 'includes/settings/class-kadence-blocks-settings.php'; require_once KADENCE_BLOCKS_PATH . 'includes/class-kadence-blocks-posts-rest-api.php'; @@ -98,6 +100,10 @@ function kadence_blocks_init() { require_once KADENCE_BLOCKS_PATH . 'includes/class-lottieanimation-post-rest-api.php'; // Advanced Form. require_once KADENCE_BLOCKS_PATH . 'includes/advanced-form/advanced-form-init.php'; + // Navigation + require_once KADENCE_BLOCKS_PATH . 'includes/navigation/kb-navigation-cpt.php'; + require_once KADENCE_BLOCKS_PATH . 'includes/navigation/kb-navigation-rest.php'; + // SVG render. require_once KADENCE_BLOCKS_PATH . 'includes/class-kadence-blocks-svg.php'; require_once KADENCE_BLOCKS_PATH . 'includes/class-local-gfonts.php'; diff --git a/src/blocks/navigation-link/block.json b/src/blocks/navigation-link/block.json new file mode 100644 index 000000000..2e1cb7582 --- /dev/null +++ b/src/blocks/navigation-link/block.json @@ -0,0 +1,92 @@ +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "apiVersion": 3, + "name": "kadence/navigation-link", + "title": "KB Custom Link", + "category": "design", + "parent": [ "kadence/navigation", "core/navigation" ], + "allowedBlocks": [ + "kadence/navigation-link", + "core/navigation-link", + "core/navigation-submenu", + "core/page-list" + ], + "description": "Add a page, link, or another item to your navigation.", + "textdomain": "default", + "attributes": { + "uniqueID": { + "type": "string", + "default": "987654321" + }, + "label": { + "type": "string" + }, + "type": { + "type": "string" + }, + "description": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "id": { + "type": "number" + }, + "opensInNewTab": { + "type": "boolean", + "default": false + }, + "url": { + "type": "string" + }, + "title": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "isTopLevelLink": { + "type": "boolean" + } + }, + "usesContext": [ + "textColor", + "customTextColor", + "backgroundColor", + "customBackgroundColor", + "overlayTextColor", + "customOverlayTextColor", + "overlayBackgroundColor", + "customOverlayBackgroundColor", + "fontSize", + "customFontSize", + "showSubmenuIcon", + "maxNestingLevel", + "style" + ], + "supports": { + "reusable": false, + "html": false, + "__experimentalSlashInserter": true, + "typography": { + "fontSize": true, + "lineHeight": true, + "__experimentalFontFamily": true, + "__experimentalFontWeight": true, + "__experimentalFontStyle": true, + "__experimentalTextTransform": true, + "__experimentalTextDecoration": true, + "__experimentalLetterSpacing": true, + "__experimentalDefaultControls": { + "fontSize": true + } + }, + "renaming": false, + "interactivity": { + "clientNavigation": true + } + }, + "editorStyle": "wp-block-navigation-link-editor", + "style": "wp-block-navigation-link" +} diff --git a/src/blocks/navigation-link/edit.js b/src/blocks/navigation-link/edit.js new file mode 100644 index 000000000..0654033d0 --- /dev/null +++ b/src/blocks/navigation-link/edit.js @@ -0,0 +1,583 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { createBlock } from '@wordpress/blocks'; +import { useSelect, useDispatch } from '@wordpress/data'; +import { + PanelBody, + TextControl, + TextareaControl, + ToolbarButton, + Tooltip, + ToolbarGroup, +} from '@wordpress/components'; +import { displayShortcut, isKeyboardEvent, ENTER } from '@wordpress/keycodes'; +import { __ } from '@wordpress/i18n'; +import { + BlockControls, + InspectorControls, + RichText, + useBlockProps, + store as blockEditorStore, + getColorClassName, + useInnerBlocksProps, +} from '@wordpress/block-editor'; +import { isURL, prependHTTP, safeDecodeURI } from '@wordpress/url'; +import { useState, useEffect, useRef } from '@wordpress/element'; +import { + placeCaretAtHorizontalEdge, + __unstableStripHTML as stripHTML, +} from '@wordpress/dom'; +import { decodeEntities } from '@wordpress/html-entities'; +import { link as linkIcon, addSubmenu } from '@wordpress/icons'; +import { store as coreStore } from '@wordpress/core-data'; +import { useMergeRefs } from '@wordpress/compose'; + +/** + * Internal dependencies + */ +import { LinkUI } from './link-ui'; +import { updateAttributes } from './update-attributes'; +import { getColors } from '../navigation/edit/utils'; + +const DEFAULT_BLOCK = { name: 'kadence/navigation-link' }; + +/** + * A React hook to determine if it's dragging within the target element. + * + * @typedef {import('@wordpress/element').RefObject} RefObject + * + * @param {RefObject} elementRef The target elementRef object. + * + * @return {boolean} Is dragging within the target element. + */ +const useIsDraggingWithin = ( elementRef ) => { + const [ isDraggingWithin, setIsDraggingWithin ] = useState( false ); + + useEffect( () => { + const { ownerDocument } = elementRef.current; + + function handleDragStart( event ) { + // Check the first time when the dragging starts. + handleDragEnter( event ); + } + + // Set to false whenever the user cancel the drag event by either releasing the mouse or press Escape. + function handleDragEnd() { + setIsDraggingWithin( false ); + } + + function handleDragEnter( event ) { + // Check if the current target is inside the item element. + if ( elementRef.current.contains( event.target ) ) { + setIsDraggingWithin( true ); + } else { + setIsDraggingWithin( false ); + } + } + + // Bind these events to the document to catch all drag events. + // Ideally, we can also use `event.relatedTarget`, but sadly that + // doesn't work in Safari. + ownerDocument.addEventListener( 'dragstart', handleDragStart ); + ownerDocument.addEventListener( 'dragend', handleDragEnd ); + ownerDocument.addEventListener( 'dragenter', handleDragEnter ); + + return () => { + ownerDocument.removeEventListener( 'dragstart', handleDragStart ); + ownerDocument.removeEventListener( 'dragend', handleDragEnd ); + ownerDocument.removeEventListener( 'dragenter', handleDragEnter ); + }; + }, [] ); + + return isDraggingWithin; +}; + +const useIsInvalidLink = ( kind, type, id ) => { + const isPostType = + kind === 'post-type' || type === 'post' || type === 'page'; + const hasId = Number.isInteger( id ); + const postStatus = useSelect( + ( select ) => { + if ( ! isPostType ) { + return null; + } + const { getEntityRecord } = select( coreStore ); + return getEntityRecord( 'postType', type, id )?.status; + }, + [ isPostType, type, id ] + ); + + // Check Navigation Link validity if: + // 1. Link is 'post-type'. + // 2. It has an id. + // 3. It's neither null, nor undefined, as valid items might be either of those while loading. + // If those conditions are met, check if + // 1. The post status is published. + // 2. The Navigation Link item has no label. + // If either of those is true, invalidate. + const isInvalid = + isPostType && hasId && postStatus && 'trash' === postStatus; + const isDraft = 'draft' === postStatus; + + return [ isInvalid, isDraft ]; +}; + +function getMissingText( type ) { + let missingText = ''; + + switch ( type ) { + case 'post': + /* translators: label for missing post in navigation link block */ + missingText = __( 'Select post' ); + break; + case 'page': + /* translators: label for missing page in navigation link block */ + missingText = __( 'Select page' ); + break; + case 'category': + /* translators: label for missing category in navigation link block */ + missingText = __( 'Select category' ); + break; + case 'tag': + /* translators: label for missing tag in navigation link block */ + missingText = __( 'Select tag' ); + break; + default: + /* translators: label for missing values in navigation link block */ + missingText = __( 'Add link' ); + } + + return missingText; +} + +export default function NavigationLinkEdit( { + attributes, + isSelected, + setAttributes, + insertBlocksAfter, + mergeBlocks, + onReplace, + context, + clientId, +} ) { + const { id, label, type, url, description, rel, title, kind } = attributes; + + const [ isInvalid, isDraft ] = useIsInvalidLink( kind, type, id ); + const { maxNestingLevel } = context; + + const { replaceBlock, __unstableMarkNextChangeAsNotPersistent } = + useDispatch( blockEditorStore ); + const [ isLinkOpen, setIsLinkOpen ] = useState( false ); + // Use internal state instead of a ref to make sure that the component + // re-renders when the popover's anchor updates. + const [ popoverAnchor, setPopoverAnchor ] = useState( null ); + const listItemRef = useRef( null ); + const isDraggingWithin = useIsDraggingWithin( listItemRef ); + const itemLabelPlaceholder = __( 'Add label…' ); + const ref = useRef(); + + // Change the label using inspector causes rich text to change focus on firefox. + // This is a workaround to keep the focus on the label field when label filed is focused we don't render the rich text. + const [ isLabelFieldFocused, setIsLabelFieldFocused ] = useState( false ); + + const { + innerBlocks, + isAtMaxNesting, + isTopLevelLink, + isParentOfSelectedBlock, + hasChildren, + } = useSelect( + ( select ) => { + const { + getBlocks, + getBlockCount, + getBlockName, + getBlockRootClientId, + hasSelectedInnerBlock, + getBlockParentsByBlockName, + } = select( blockEditorStore ); + + return { + innerBlocks: getBlocks( clientId ), + isAtMaxNesting: + getBlockParentsByBlockName( clientId, [ + 'kadence/navigation-link', + 'core/navigation-submenu', + ] ).length >= maxNestingLevel, + isTopLevelLink: + getBlockName( getBlockRootClientId( clientId ) ) === + 'core/navigation', + isParentOfSelectedBlock: hasSelectedInnerBlock( + clientId, + true + ), + hasChildren: !! getBlockCount( clientId ), + }; + }, + [ clientId ] + ); + + /** + * Transform to submenu block. + */ + function transformToSubmenu() { + const newSubmenu = createBlock( + 'core/navigation-submenu', + attributes, + innerBlocks.length > 0 + ? innerBlocks + : [ createBlock( 'kadence/navigation-link' ) ] + ); + replaceBlock( clientId, newSubmenu ); + } + + useEffect( () => { + // Show the LinkControl on mount if the URL is empty + // ( When adding a new menu item) + // This can't be done in the useState call because it conflicts + // with the autofocus behavior of the BlockListBlock component. + if ( ! url ) { + setIsLinkOpen( true ); + } + }, [ url ] ); + + useEffect( () => { + // If block has inner blocks, transform to Submenu. + if ( hasChildren ) { + // This side-effect should not create an undo level as those should + // only be created via user interactions. + __unstableMarkNextChangeAsNotPersistent(); + transformToSubmenu(); + } + }, [ hasChildren ] ); + + /** + * The hook shouldn't be necessary but due to a focus loss happening + * when selecting a suggestion in the link popover, we force close on block unselection. + */ + useEffect( () => { + if ( ! isSelected ) { + setIsLinkOpen( false ); + } + }, [ isSelected ] ); + + // If the LinkControl popover is open and the URL has changed, close the LinkControl and focus the label text. + useEffect( () => { + if ( isLinkOpen && url ) { + // Does this look like a URL and have something TLD-ish? + if ( + isURL( prependHTTP( label ) ) && + /^.+\.[a-z]+/.test( label ) + ) { + // Focus and select the label text. + selectLabelText(); + } else { + // Focus it (but do not select). + placeCaretAtHorizontalEdge( ref.current, true ); + } + } + }, [ url ] ); + + /** + * Focus the Link label text and select it. + */ + function selectLabelText() { + ref.current.focus(); + const { ownerDocument } = ref.current; + const { defaultView } = ownerDocument; + const selection = defaultView.getSelection(); + const range = ownerDocument.createRange(); + // Get the range of the current ref contents so we can add this range to the selection. + range.selectNodeContents( ref.current ); + selection.removeAllRanges(); + selection.addRange( range ); + } + + /** + * Removes the current link if set. + */ + function removeLink() { + // Reset all attributes that comprise the link. + // It is critical that all attributes are reset + // to their default values otherwise this may + // in advertently trigger side effects because + // the values will have "changed". + setAttributes( { + url: undefined, + label: undefined, + id: undefined, + kind: undefined, + type: undefined, + opensInNewTab: false, + } ); + + // Close the link editing UI. + setIsLinkOpen( false ); + } + + const { + textColor, + customTextColor, + backgroundColor, + customBackgroundColor, + } = getColors( context, ! isTopLevelLink ); + + function onKeyDown( event ) { + if ( + isKeyboardEvent.primary( event, 'k' ) || + ( ( ! url || isDraft || isInvalid ) && event.keyCode === ENTER ) + ) { + setIsLinkOpen( true ); + } + } + + const blockProps = useBlockProps( { + ref: useMergeRefs( [ setPopoverAnchor, listItemRef ] ), + className: classnames( 'wp-block-navigation-item', { + 'is-editing': isSelected || isParentOfSelectedBlock, + 'is-dragging-within': isDraggingWithin, + 'has-link': !! url, + 'has-child': hasChildren, + 'has-text-color': !! textColor || !! customTextColor, + [ getColorClassName( 'color', textColor ) ]: !! textColor, + 'has-background': !! backgroundColor || customBackgroundColor, + [ getColorClassName( 'background-color', backgroundColor ) ]: + !! backgroundColor, + } ), + style: { + color: ! textColor && customTextColor, + backgroundColor: ! backgroundColor && customBackgroundColor, + }, + onKeyDown, + } ); + + const innerBlocksProps = useInnerBlocksProps( + { + ...blockProps, + className: 'remove-outline', // Remove the outline from the inner blocks container. + }, + { + defaultBlock: DEFAULT_BLOCK, + directInsert: true, + renderAppender: false, + } + ); + + if ( ! url || isInvalid || isDraft ) { + blockProps.onClick = () => setIsLinkOpen( true ); + } + + const classes = classnames( 'wp-block-navigation-item__content', { + 'wp-block-navigation-link__placeholder': ! url || isInvalid || isDraft, + } ); + + const missingText = getMissingText( type ); + /* translators: Whether the navigation link is Invalid or a Draft. */ + const placeholderText = `(${ + isInvalid ? __( 'Invalid' ) : __( 'Draft' ) + })`; + const tooltipText = + isInvalid || isDraft + ? __( 'This item has been deleted, or is a draft' ) + : __( 'This item is missing a link' ); + + return ( + <> + + + setIsLinkOpen( true ) } + /> + { ! isAtMaxNesting && ( + + ) } + + + { /* Warning, this duplicated in packages/block-library/src/navigation-submenu/edit.js */ } + + + { + setAttributes( { label: labelValue } ); + } } + label={ __( 'Label' ) } + autoComplete="off" + onFocus={ () => setIsLabelFieldFocused( true ) } + onBlur={ () => setIsLabelFieldFocused( false ) } + /> + { + updateAttributes( + { url: urlValue }, + setAttributes, + attributes + ); + } } + label={ __( 'URL' ) } + autoComplete="off" + /> + { + setAttributes( { description: descriptionValue } ); + } } + label={ __( 'Description' ) } + help={ __( + 'The description will be displayed in the menu if the current theme supports it.' + ) } + /> + { + setAttributes( { title: titleValue } ); + } } + label={ __( 'Title attribute' ) } + autoComplete="off" + help={ __( + 'Additional information to help clarify the purpose of the link.' + ) } + /> + { + setAttributes( { rel: relValue } ); + } } + label={ __( 'Rel attribute' ) } + autoComplete="off" + help={ __( + 'The relationship of the linked URL as space-separated link types.' + ) } + /> + + +
+ { /* eslint-disable jsx-a11y/anchor-is-valid */ } + + { /* eslint-enable */ } + { ! url ? ( +
+ + { missingText } + +
+ ) : ( + <> + { ! isInvalid && + ! isDraft && + ! isLabelFieldFocused && ( + <> + + setAttributes( { + label: labelValue, + } ) + } + onMerge={ mergeBlocks } + onReplace={ onReplace } + __unstableOnSplitAtEnd={ () => + insertBlocksAfter( + createBlock( + 'kadence/navigation-link' + ) + ) + } + aria-label={ __( + 'Navigation link text' + ) } + placeholder={ itemLabelPlaceholder } + withoutInteractiveFormatting + allowedFormats={ [ + 'core/bold', + 'core/italic', + 'core/image', + 'core/strikethrough', + ] } + onClick={ () => { + if ( ! url ) { + setIsLinkOpen( true ); + } + } } + /> + { description && ( + + { description } + + ) } + + ) } + { ( isInvalid || + isDraft || + isLabelFieldFocused ) && ( +
+ + + { + // Some attributes are stored in an escaped form. It's a legacy issue. + // Ideally they would be stored in a raw, unescaped form. + // Unescape is used here to "recover" the escaped characters + // so they display without encoding. + // See `updateAttributes` for more details. + `${ decodeEntities( label ) } ${ + isInvalid || isDraft + ? placeholderText + : '' + }`.trim() + } + + +
+ ) } + + ) } + { isLinkOpen && ( + setIsLinkOpen( false ) } + anchor={ popoverAnchor } + onRemove={ removeLink } + onChange={ ( updatedValue ) => { + updateAttributes( + updatedValue, + setAttributes, + attributes + ); + } } + /> + ) } +
+
+
+ + ); +} diff --git a/src/blocks/navigation-link/editor.scss b/src/blocks/navigation-link/editor.scss new file mode 100644 index 000000000..6d1dc32e5 --- /dev/null +++ b/src/blocks/navigation-link/editor.scss @@ -0,0 +1,139 @@ +/** + * Appender + */ + +.wp-block-navigation .block-list-appender { + position: relative; +} + +/** + * Submenus. + */ + +// Show submenus above the sibling inserter. +.wp-block-navigation .has-child { + cursor: pointer; + + .wp-block-navigation__submenu-container { + z-index: z-index(".has-child .wp-block-navigation__submenu-container"); + } + + &:hover { + .wp-block-navigation__submenu-container { + z-index: z-index(".has-child:hover .wp-block-navigation__submenu-container"); + } + } + + // Show on editor selected, even if on frontend it only stays open on focus-within. + &.is-selected, + &.has-child-selected { + > .wp-block-navigation__submenu-container { + // We use important here because if the parent block is selected and submenus are present, they should always be visible. + visibility: visible !important; + opacity: 1 !important; + min-width: 200px !important; + height: auto !important; + width: auto !important; + overflow: visible !important; + } + } +} + + +/** + * Navigation Items. + */ + +.wp-block-navigation-item { + .wp-block-navigation-item__content { + cursor: text; + } + + &.is-editing, + &.is-selected { + min-width: 20px; + } + + .block-list-appender { + margin-top: $grid-unit-20; + // The right margin should be set to auto, so as to not shift layout in flex containers. + margin-right: auto; + margin-bottom: $grid-unit-20; + margin-left: $grid-unit-20; + } +} + +.wp-block-navigation-link__invalid-item { + color: #000; +} + +/** + * Menu item setup state. Is shown when a menu item has no URL configured. + */ + +.wp-block-navigation-link__placeholder { + position: relative; + + // While in a placeholder state, hide any underlines the theme might add. + text-decoration: none !important; + box-shadow: none !important; + background-image: none !important; + + // Draw a wavy underline. + .wp-block-navigation-link__placeholder-text span { + $blur: 10%; + $width: 6%; + $stop1: 30%; + $stop2: 64%; + + --wp-underline-color: var(--wp-admin-theme-color); + .is-dark-theme & { + --wp-underline-color: #{ $dark-theme-focus }; + } + + background-image: + linear-gradient(45deg, transparent ($stop1 - $blur), var(--wp-underline-color) $stop1, var(--wp-underline-color) ($stop1 + $width), transparent ($stop1 + $width + $blur)), + linear-gradient(135deg, transparent ($stop2 - $blur), var(--wp-underline-color) $stop2, var(--wp-underline-color) ($stop2 + $width), transparent ($stop2 + $width + $blur)); + background-position: 0 100%; + background-size: 6px 3px; + background-repeat: repeat-x; + + // Since applied to a span, it doesn't change the footprint of the item, + // but it does vertically shift the underline to better align. + padding-bottom: 0.1em; + } + + // This needs extra specificity. + &.wp-block-navigation-item__content { + cursor: pointer; + } +} + +/** +* Link Control Transforms +*/ + +.link-control-transform { + border-top: $border-width solid $gray-400; + padding: 0 $grid-unit-20 $grid-unit-10 $grid-unit-20; +} + +.link-control-transform__subheading { + font-size: 11px; + text-transform: uppercase; + font-weight: 500; + color: $gray-900; + margin-bottom: 1.5em; +} + +.link-control-transform__items { + display: flex; + justify-content: space-between; +} + +.link-control-transform__item { + flex-basis: 33%; + flex-direction: column; + gap: $grid-unit-10; + height: auto; +} diff --git a/src/blocks/navigation-link/hooks.js b/src/blocks/navigation-link/hooks.js new file mode 100644 index 000000000..427b227f3 --- /dev/null +++ b/src/blocks/navigation-link/hooks.js @@ -0,0 +1,54 @@ +/** + * WordPress dependencies + */ +import { + category, + page, + postList, + tag, + customPostType, +} from '@wordpress/icons'; + +function getIcon( variationName ) { + switch ( variationName ) { + case 'post': + return postList; + case 'page': + return page; + case 'tag': + return tag; + case 'category': + return category; + default: + return customPostType; + } +} + +export function enhanceNavigationLinkVariations( settings, name ) { + if ( name !== 'kadence/navigation-link' ) { + return settings; + } + + // Otherwise decorate server passed variations with an icon and isActive function. + if ( settings.variations ) { + const isActive = ( blockAttributes, variationAttributes ) => { + return blockAttributes.type === variationAttributes.type; + }; + const variations = settings.variations.map( ( variation ) => { + return { + ...variation, + ...( ! variation.icon && { + icon: getIcon( variation.name ), + } ), + ...( ! variation.isActive && { + isActive, + } ), + }; + } ); + return { + ...settings, + variations, + }; + } + return settings; +} diff --git a/src/blocks/navigation-link/index.js b/src/blocks/navigation-link/index.js new file mode 100644 index 000000000..bf3535f85 --- /dev/null +++ b/src/blocks/navigation-link/index.js @@ -0,0 +1,36 @@ +/** + * WordPress dependencies + */ +import { _x } from '@wordpress/i18n'; +import { customLink as linkIcon } from '@wordpress/icons'; +import { addFilter } from '@wordpress/hooks'; +import { registerBlockType } from '@wordpress/blocks'; +import { InnerBlocks } from '@wordpress/block-editor'; + +/** + * Internal dependencies + */ +import metadata from './block.json'; +import edit from './edit'; +import { enhanceNavigationLinkVariations } from './hooks'; +import transforms from './transforms'; + +const { name } = metadata; + +export { metadata, name }; + +registerBlockType( name, { + icon: linkIcon, + __experimentalLabel: ( { label } ) => label, + edit, + save:() => { + return ; + }, + example: { + attributes: { + label: _x( 'Example Link', 'navigation link preview example' ), + url: 'https://example.com', + }, + }, + transforms, +} ); diff --git a/src/blocks/navigation-link/index.php b/src/blocks/navigation-link/index.php new file mode 100644 index 000000000..e22ddf6d4 --- /dev/null +++ b/src/blocks/navigation-link/index.php @@ -0,0 +1,493 @@ + array(), + 'inline_styles' => '', + ); + + // Text color. + $named_text_color = null; + $custom_text_color = null; + + if ( $is_sub_menu && array_key_exists( 'customOverlayTextColor', $context ) ) { + $custom_text_color = $context['customOverlayTextColor']; + } elseif ( $is_sub_menu && array_key_exists( 'overlayTextColor', $context ) ) { + $named_text_color = $context['overlayTextColor']; + } elseif ( array_key_exists( 'customTextColor', $context ) ) { + $custom_text_color = $context['customTextColor']; + } elseif ( array_key_exists( 'textColor', $context ) ) { + $named_text_color = $context['textColor']; + } elseif ( isset( $context['style']['color']['text'] ) ) { + $custom_text_color = $context['style']['color']['text']; + } + + // If has text color. + if ( ! is_null( $named_text_color ) ) { + // Add the color class. + array_push( $colors['css_classes'], 'has-text-color', sprintf( 'has-%s-color', $named_text_color ) ); + } elseif ( ! is_null( $custom_text_color ) ) { + // Add the custom color inline style. + $colors['css_classes'][] = 'has-text-color'; + $colors['inline_styles'] .= sprintf( 'color: %s;', $custom_text_color ); + } + + // Background color. + $named_background_color = null; + $custom_background_color = null; + + if ( $is_sub_menu && array_key_exists( 'customOverlayBackgroundColor', $context ) ) { + $custom_background_color = $context['customOverlayBackgroundColor']; + } elseif ( $is_sub_menu && array_key_exists( 'overlayBackgroundColor', $context ) ) { + $named_background_color = $context['overlayBackgroundColor']; + } elseif ( array_key_exists( 'customBackgroundColor', $context ) ) { + $custom_background_color = $context['customBackgroundColor']; + } elseif ( array_key_exists( 'backgroundColor', $context ) ) { + $named_background_color = $context['backgroundColor']; + } elseif ( isset( $context['style']['color']['background'] ) ) { + $custom_background_color = $context['style']['color']['background']; + } + + // If has background color. + if ( ! is_null( $named_background_color ) ) { + // Add the background-color class. + array_push( $colors['css_classes'], 'has-background', sprintf( 'has-%s-background-color', $named_background_color ) ); + } elseif ( ! is_null( $custom_background_color ) ) { + // Add the custom background-color inline style. + $colors['css_classes'][] = 'has-background'; + $colors['inline_styles'] .= sprintf( 'background-color: %s;', $custom_background_color ); + } + + return $colors; +} + +/** + * Build an array with CSS classes and inline styles defining the font sizes + * which will be applied to the navigation markup in the front-end. + * + * @param array $context Navigation block context. + * @return array Font size CSS classes and inline styles. + */ +function block_core_navigation_link_build_css_font_sizes( $context ) { + // CSS classes. + $font_sizes = array( + 'css_classes' => array(), + 'inline_styles' => '', + ); + + $has_named_font_size = array_key_exists( 'fontSize', $context ); + $has_custom_font_size = isset( $context['style']['typography']['fontSize'] ); + + if ( $has_named_font_size ) { + // Add the font size class. + $font_sizes['css_classes'][] = sprintf( 'has-%s-font-size', $context['fontSize'] ); + } elseif ( $has_custom_font_size ) { + // Add the custom font size inline style. + $font_sizes['inline_styles'] = sprintf( + 'font-size: %s;', + wp_get_typography_font_size_value( + array( + 'size' => $context['style']['typography']['fontSize'], + ) + ) + ); + } + + return $font_sizes; +} + +/** + * Returns the top-level submenu SVG chevron icon. + * + * @return string + */ +function block_core_navigation_link_render_submenu_icon() { + return ''; +} + +/** + * Decodes a url if it's encoded, returning the same url if not. + * + * @param string $url The url to decode. + * + * @return string $url Returns the decoded url. + */ +function block_core_navigation_link_maybe_urldecode( $url ) { + $is_url_encoded = false; + $query = parse_url( $url, PHP_URL_QUERY ); + $query_params = wp_parse_args( $query ); + + foreach ( $query_params as $query_param ) { + $can_query_param_be_encoded = is_string( $query_param ) && ! empty( $query_param ); + if ( ! $can_query_param_be_encoded ) { + continue; + } + if ( rawurldecode( $query_param ) !== $query_param ) { + $is_url_encoded = true; + break; + } + } + + if ( $is_url_encoded ) { + return rawurldecode( $url ); + } + + return $url; +} + + +/** + * Renders the `kadence/navigation-link` block. + * + * @param array $attributes The block attributes. + * @param string $content The saved content. + * @param WP_Block $block The parsed block. + * + * @return string Returns the post content with the legacy widget added. + */ +function render_block_core_navigation_link( $attributes, $content, $block ) { + $navigation_link_has_id = isset( $attributes['id'] ) && is_numeric( $attributes['id'] ); + $is_post_type = isset( $attributes['kind'] ) && 'post-type' === $attributes['kind']; + $is_post_type = $is_post_type || isset( $attributes['type'] ) && ( 'post' === $attributes['type'] || 'page' === $attributes['type'] ); + + // Don't render the block's subtree if it is a draft or if the ID does not exist. + if ( $is_post_type && $navigation_link_has_id ) { + $post = get_post( $attributes['id'] ); + if ( ! $post || 'publish' !== $post->post_status ) { + return ''; + } + } + + // Don't render the block's subtree if it has no label. + if ( empty( $attributes['label'] ) ) { + return ''; + } + + $font_sizes = block_core_navigation_link_build_css_font_sizes( $block->context ); + $classes = array_merge( + $font_sizes['css_classes'] + ); + $style_attribute = $font_sizes['inline_styles']; + + $css_classes = trim( implode( ' ', $classes ) ); + $has_submenu = count( $block->inner_blocks ) > 0; + $kind = empty( $attributes['kind'] ) ? 'post_type' : str_replace( '-', '_', $attributes['kind'] ); + $is_active = ! empty( $attributes['id'] ) && get_queried_object_id() === (int) $attributes['id'] && ! empty( get_queried_object()->$kind ); + + $wrapper_attributes = get_block_wrapper_attributes( + array( + 'class' => $css_classes . ' wp-block-navigation-item' . ( $has_submenu ? ' has-child' : '' ) . + ( $is_active ? ' current-menu-item' : '' ), + 'style' => $style_attribute, + ) + ); + $html = '
  • ' . + ''; + + if ( isset( $attributes['label'] ) ) { + $html .= wp_kses_post( $attributes['label'] ); + } + + $html .= ''; + + // Add description if available. + if ( ! empty( $attributes['description'] ) ) { + $html .= ''; + $html .= wp_kses_post( $attributes['description'] ); + $html .= ''; + } + + $html .= ''; + // End anchor tag content. + + if ( isset( $block->context['showSubmenuIcon'] ) && $block->context['showSubmenuIcon'] && $has_submenu ) { + // The submenu icon can be hidden by a CSS rule on the Navigation Block. + $html .= '' . block_core_navigation_link_render_submenu_icon() . ''; + } + + if ( $has_submenu ) { + $inner_blocks_html = ''; + foreach ( $block->inner_blocks as $inner_block ) { + $inner_blocks_html .= $inner_block->render(); + } + + $html .= sprintf( + '
      %s
    ', + $inner_blocks_html + ); + } + + $html .= '
  • '; + + return $html; +} + +/** + * Returns a navigation link variation + * + * @param WP_Taxonomy|WP_Post_Type $entity post type or taxonomy entity. + * @param string $kind string of value 'taxonomy' or 'post-type'. + * + * @return array + */ +function build_variation_for_navigation_link( $entity, $kind ) { + $title = ''; + $description = ''; + + if ( property_exists( $entity->labels, 'item_link' ) ) { + $title = $entity->labels->item_link; + } + if ( property_exists( $entity->labels, 'item_link_description' ) ) { + $description = $entity->labels->item_link_description; + } + + $variation = array( + 'name' => $entity->name, + 'title' => $title, + 'description' => $description, + 'attributes' => array( + 'type' => $entity->name, + 'kind' => $kind, + ), + ); + + // Tweak some value for the variations. + $variation_overrides = array( + 'post_tag' => array( + 'name' => 'tag', + 'attributes' => array( + 'type' => 'tag', + 'kind' => $kind, + ), + ), + 'post_format' => array( + // The item_link and item_link_description for post formats is the + // same as for tags, so need to be overridden. + 'title' => __( 'Post Format Link' ), + 'description' => __( 'A link to a post format' ), + 'attributes' => array( + 'type' => 'post_format', + 'kind' => $kind, + ), + ), + ); + + if ( array_key_exists( $entity->name, $variation_overrides ) ) { + $variation = array_merge( + $variation, + $variation_overrides[ $entity->name ] + ); + } + + return $variation; +} + +/** + * Register a variation for a post type / taxonomy for the navigation link block. + * + * @param array $variation Variation array from build_variation_for_navigation_link. + * @return void + */ +function block_core_navigation_link_register_variation( $variation ) { + // Directly set the variations on the registered block type + // because there's no server side registration for variations (see #47170). + $navigation_block_type = WP_Block_Type_Registry::get_instance()->get_registered( 'kadence/navigation-link' ); + // If the block is not registered yet, bail early. + // Variation will be registered in register_block_core_navigation_link then. + if ( ! $navigation_block_type ) { + return; + } + + $navigation_block_type->variations = array_merge( + $navigation_block_type->variations, + array( $variation ) + ); +} + +/** + * Unregister a variation for a post type / taxonomy for the navigation link block. + * + * @param string $name Name of the post type / taxonomy (which was used as variation name). + * @return void + */ +function block_core_navigation_link_unregister_variation( $name ) { + // Directly get the variations from the registered block type + // because there's no server side (un)registration for variations (see #47170). + $navigation_block_type = WP_Block_Type_Registry::get_instance()->get_registered( 'kadence/navigation-link' ); + // If the block is not registered (yet), there's no need to remove a variation. + if ( ! $navigation_block_type || empty( $navigation_block_type->variations ) ) { + return; + } + $variations = $navigation_block_type->variations; + // Search for the variation and remove it from the array. + foreach ( $variations as $i => $variation ) { + if ( $variation['name'] === $name ) { + unset( $variations[ $i ] ); + break; + } + } + // Reindex array after removing one variation. + $navigation_block_type->variations = array_values( $variations ); +} + +/** + * Returns an array of variations for the navigation link block. + * + * @return array + */ +function block_core_navigation_link_build_variations() { + // This will only handle post types and taxonomies registered until this point (init on priority 9). + // See action hooks below for other post types and taxonomies. + // See https://github.com/WordPress/gutenberg/issues/53826 for details. + $post_types = get_post_types( array( 'show_in_nav_menus' => true ), 'objects' ); + $taxonomies = get_taxonomies( array( 'show_in_nav_menus' => true ), 'objects' ); + + // Use two separate arrays as a way to order the variations in the UI. + // Known variations (like Post Link and Page Link) are added to the + // `built_ins` array. Variations for custom post types and taxonomies are + // added to the `variations` array and will always appear after `built-ins. + $built_ins = array(); + $variations = array(); + + if ( $post_types ) { + foreach ( $post_types as $post_type ) { + $variation = build_variation_for_navigation_link( $post_type, 'post-type' ); + if ( $post_type->_builtin ) { + $built_ins[] = $variation; + } else { + $variations[] = $variation; + } + } + } + if ( $taxonomies ) { + foreach ( $taxonomies as $taxonomy ) { + $variation = build_variation_for_navigation_link( $taxonomy, 'taxonomy' ); + if ( $taxonomy->_builtin ) { + $built_ins[] = $variation; + } else { + $variations[] = $variation; + } + } + } + + return array_merge( $built_ins, $variations ); +} + +/** + * Register the navigation link block. + * + * @uses render_block_core_navigation() + * @throws WP_Error An WP_Error exception parsing the block definition. + */ +function register_block_core_navigation_link() { + register_block_type_from_metadata( + __DIR__ . '/navigation-link', + array( + 'render_callback' => 'render_block_core_navigation_link', + 'variation_callback' => 'block_core_navigation_link_build_variations', + ) + ); +} +add_action( 'init', 'register_block_core_navigation_link' ); +// Register actions for all post types and taxonomies, to add variations when they are registered. +// All post types/taxonomies registered before register_block_core_navigation_link, will be handled by that function. +add_action( 'registered_post_type', 'block_core_navigation_link_register_post_type_variation', 10, 2 ); +add_action( 'registered_taxonomy', 'block_core_navigation_link_register_taxonomy_variation', 10, 3 ); +// Handle unregistering of post types and taxonomies and remove the variations. +add_action( 'unregistered_post_type', 'block_core_navigation_link_unregister_post_type_variation' ); +add_action( 'unregistered_taxonomy', 'block_core_navigation_link_unregister_taxonomy_variation' ); + +/** + * Register custom post type variations for navigation link on post type registration + * Handles all post types registered after the block is registered in register_navigation_link_post_type_variations + * + * @param string $post_type The post type name passed from registered_post_type action hook. + * @param WP_Post_Type $post_type_object The post type object passed from registered_post_type. + * @return void + */ +function block_core_navigation_link_register_post_type_variation( $post_type, $post_type_object ) { + if ( $post_type_object->show_in_nav_menus ) { + $variation = build_variation_for_navigation_link( $post_type_object, 'post-type' ); + block_core_navigation_link_register_variation( $variation ); + } +} + +/** + * Register a custom taxonomy variation for navigation link on taxonomy registration + * Handles all taxonomies registered after the block is registered in register_navigation_link_post_type_variations + * + * @param string $taxonomy Taxonomy slug. + * @param array|string $object_type Object type or array of object types. + * @param array $args Array of taxonomy registration arguments. + * @return void + */ +function block_core_navigation_link_register_taxonomy_variation( $taxonomy, $object_type, $args ) { + if ( isset( $args['show_in_nav_menus'] ) && $args['show_in_nav_menus'] ) { + $variation = build_variation_for_navigation_link( (object) $args, 'post-type' ); + block_core_navigation_link_register_variation( $variation ); + } +} + +/** + * Unregisters a custom post type variation for navigation link on post type unregistration. + * + * @param string $post_type The post type name passed from unregistered_post_type action hook. + * @return void + */ +function block_core_navigation_link_unregister_post_type_variation( $post_type ) { + block_core_navigation_link_unregister_variation( $post_type ); +} + +/** + * Unregisters a custom taxonomy variation for navigation link on taxonomy unregistration. + * + * @param string $taxonomy The taxonomy name passed from unregistered_taxonomy action hook. + * @return void + */ +function block_core_navigation_link_unregister_taxonomy_variation( $taxonomy ) { + block_core_navigation_link_unregister_variation( $taxonomy ); +} diff --git a/src/blocks/navigation-link/link-ui.js b/src/blocks/navigation-link/link-ui.js new file mode 100644 index 000000000..3c3d91e7b --- /dev/null +++ b/src/blocks/navigation-link/link-ui.js @@ -0,0 +1,239 @@ +/** + * WordPress dependencies + */ +import { __unstableStripHTML as stripHTML } from '@wordpress/dom'; +import { Popover, Button } from '@wordpress/components'; +import { __, sprintf } from '@wordpress/i18n'; +import { + __experimentalLinkControl as LinkControl, + BlockIcon, + store as blockEditorStore, +} from '@wordpress/block-editor'; +import { createInterpolateElement, useMemo } from '@wordpress/element'; +import { + store as coreStore, + useResourcePermissions, +} from '@wordpress/core-data'; +import { decodeEntities } from '@wordpress/html-entities'; +import { switchToBlockType } from '@wordpress/blocks'; +import { useSelect, useDispatch } from '@wordpress/data'; + +/** + * Given the Link block's type attribute, return the query params to give to + * /wp/v2/search. + * + * @param {string} type Link block's type attribute. + * @param {string} kind Link block's entity of kind (post-type|taxonomy) + * @return {{ type?: string, subtype?: string }} Search query params. + */ +export function getSuggestionsQuery( type, kind ) { + switch ( type ) { + case 'post': + case 'page': + return { type: 'post', subtype: type }; + case 'category': + return { type: 'term', subtype: 'category' }; + case 'tag': + return { type: 'term', subtype: 'post_tag' }; + case 'post_format': + return { type: 'post-format' }; + default: + if ( kind === 'taxonomy' ) { + return { type: 'term', subtype: type }; + } + if ( kind === 'post-type' ) { + return { type: 'post', subtype: type }; + } + return { + // for custom link which has no type + // always show pages as initial suggestions + initialSuggestionsSearchOptions: { + type: 'post', + subtype: 'page', + perPage: 20, + }, + }; + } +} + +/** + * Add transforms to Link Control + * + * @param {Object} props Component props. + * @param {string} props.clientId Block client ID. + */ +function LinkControlTransforms( { clientId } ) { + const { getBlock, blockTransforms } = useSelect( + ( select ) => { + const { + getBlock: _getBlock, + getBlockRootClientId, + getBlockTransformItems, + } = select( blockEditorStore ); + + return { + getBlock: _getBlock, + blockTransforms: getBlockTransformItems( + _getBlock( clientId ), + getBlockRootClientId( clientId ) + ), + }; + }, + [ clientId ] + ); + + const { replaceBlock } = useDispatch( blockEditorStore ); + + const featuredBlocks = [ + 'core/page-list', + 'core/site-logo', + 'core/social-links', + 'core/search', + ]; + + const transforms = blockTransforms.filter( ( item ) => { + return featuredBlocks.includes( item.name ); + } ); + + if ( ! transforms?.length ) { + return null; + } + + if ( ! clientId ) { + return null; + } + + return ( +
    +

    + { __( 'Transform' ) } +

    +
    + { transforms.map( ( item, index ) => { + return ( + + ); + } ) } +
    +
    + ); +} + +export function LinkUI( props ) { + const { saveEntityRecord } = useDispatch( coreStore ); + const pagesPermissions = useResourcePermissions( 'pages' ); + const postsPermissions = useResourcePermissions( 'posts' ); + + async function handleCreate( pageTitle ) { + const postType = props.link.type || 'page'; + + const page = await saveEntityRecord( 'postType', postType, { + title: pageTitle, + status: 'draft', + } ); + + return { + id: page.id, + type: postType, + // Make `title` property consistent with that in `fetchLinkSuggestions` where the `rendered` title (containing HTML entities) + // is also being decoded. By being consistent in both locations we avoid having to branch in the rendering output code. + // Ideally in the future we will update both APIs to utilise the "raw" form of the title which is better suited to edit contexts. + // e.g. + // - title.raw = "Yes & No" + // - title.rendered = "Yes & No" + // - decodeEntities( title.rendered ) = "Yes & No" + // See: + // - https://github.com/WordPress/gutenberg/pull/41063 + // - https://github.com/WordPress/gutenberg/blob/a1e1fdc0e6278457e9f4fc0b31ac6d2095f5450b/packages/core-data/src/fetch/__experimental-fetch-link-suggestions.js#L212-L218 + title: decodeEntities( page.title.rendered ), + url: page.link, + kind: 'post-type', + }; + } + + const { label, url, opensInNewTab, type, kind } = props.link; + + let userCanCreate = false; + if ( ! type || type === 'page' ) { + userCanCreate = pagesPermissions.canCreate; + } else if ( type === 'post' ) { + userCanCreate = postsPermissions.canCreate; + } + + // Memoize link value to avoid overriding the LinkControl's internal state. + // This is a temporary fix. See https://github.com/WordPress/gutenberg/issues/50976#issuecomment-1568226407. + const link = useMemo( + () => ( { + url, + opensInNewTab, + title: label && stripHTML( label ), + } ), + [ label, opensInNewTab, url ] + ); + + return ( + + { + let format; + + if ( type === 'post' ) { + /* translators: %s: search term. */ + format = __( 'Create draft post: %s' ); + } else { + /* translators: %s: search term. */ + format = __( 'Create draft page: %s' ); + } + + return createInterpolateElement( + sprintf( format, searchTerm ), + { + mark: , + } + ); + } } + noDirectEntry={ !! type } + noURLSuggestion={ !! type } + suggestionsQuery={ getSuggestionsQuery( type, kind ) } + onChange={ props.onChange } + onRemove={ props.onRemove } + onCancel={ props.onCancel } + renderControlBottom={ + ! url + ? () => ( + + ) + : null + } + /> + + ); +} diff --git a/src/blocks/navigation-link/style.scss b/src/blocks/navigation-link/style.scss new file mode 100644 index 000000000..2a45c3c54 --- /dev/null +++ b/src/blocks/navigation-link/style.scss @@ -0,0 +1,16 @@ +// Navigation item styles. +// Contains styles only that are unique to the manually inserted menu item. +// Styles in the Navigation block itself target both custom menu items, and +// those from the Page List block. +.wp-block-navigation { + // This wraps just the innermost text for custom menu items. + .wp-block-navigation-item__label { + overflow-wrap: break-word; + } + + // Hide the description by default. + // If a theme opts-in to show descriptions, they will need to provide styles for them. + .wp-block-navigation-item__description { + display: none; + } +} diff --git a/src/blocks/navigation-link/transforms.js b/src/blocks/navigation-link/transforms.js new file mode 100644 index 000000000..6f616a7c0 --- /dev/null +++ b/src/blocks/navigation-link/transforms.js @@ -0,0 +1,133 @@ +/** + * WordPress dependencies + */ +import { createBlock } from '@wordpress/blocks'; + +const transforms = { + from: [ + { + type: 'block', + blocks: [ 'core/site-logo' ], + transform: () => { + return createBlock( 'kadence/navigation-link' ); + }, + }, + { + type: 'block', + blocks: [ 'core/spacer' ], + transform: () => { + return createBlock( 'kadence/navigation-link' ); + }, + }, + { + type: 'block', + blocks: [ 'core/home-link' ], + transform: () => { + return createBlock( 'kadence/navigation-link' ); + }, + }, + { + type: 'block', + blocks: [ 'core/social-links' ], + transform: () => { + return createBlock( 'kadence/navigation-link' ); + }, + }, + { + type: 'block', + blocks: [ 'core/search' ], + transform: () => { + return createBlock( 'kadence/navigation-link' ); + }, + }, + { + type: 'block', + blocks: [ 'core/page-list' ], + transform: () => { + return createBlock( 'kadence/navigation-link' ); + }, + }, + { + type: 'block', + blocks: [ 'core/buttons' ], + transform: () => { + return createBlock( 'kadence/navigation-link' ); + }, + }, + ], + to: [ + { + type: 'block', + blocks: [ 'core/navigation-submenu' ], + transform: ( attributes, innerBlocks ) => + createBlock( + 'core/navigation-submenu', + attributes, + innerBlocks + ), + }, + { + type: 'block', + blocks: [ 'core/spacer' ], + transform: () => { + return createBlock( 'core/spacer' ); + }, + }, + { + type: 'block', + blocks: [ 'core/site-logo' ], + transform: () => { + return createBlock( 'core/site-logo' ); + }, + }, + { + type: 'block', + blocks: [ 'core/home-link' ], + transform: () => { + return createBlock( 'core/home-link' ); + }, + }, + { + type: 'block', + blocks: [ 'core/social-links' ], + transform: () => { + return createBlock( 'core/social-links' ); + }, + }, + { + type: 'block', + blocks: [ 'core/search' ], + transform: () => { + return createBlock( 'core/search', { + showLabel: false, + buttonUseIcon: true, + buttonPosition: 'button-inside', + } ); + }, + }, + { + type: 'block', + blocks: [ 'core/page-list' ], + transform: () => { + return createBlock( 'core/page-list' ); + }, + }, + { + type: 'block', + blocks: [ 'core/buttons' ], + transform: ( { label, url, rel, title, opensInNewTab } ) => { + return createBlock( 'core/buttons', {}, [ + createBlock( 'core/button', { + text: label, + url, + rel, + title, + linkTarget: opensInNewTab ? '_blank' : undefined, + } ), + ] ); + }, + }, + ], +}; + +export default transforms; diff --git a/src/blocks/navigation-link/update-attributes.js b/src/blocks/navigation-link/update-attributes.js new file mode 100644 index 000000000..6aa28d181 --- /dev/null +++ b/src/blocks/navigation-link/update-attributes.js @@ -0,0 +1,98 @@ +/** + * WordPress dependencies + */ +import { escapeHTML } from '@wordpress/escape-html'; +import { safeDecodeURI } from '@wordpress/url'; + +/** + * @typedef {'post-type'|'custom'|'taxonomy'|'post-type-archive'} WPNavigationLinkKind + */ +/** + * Navigation Link Block Attributes + * + * @typedef {Object} WPNavigationLinkBlockAttributes + * + * @property {string} [label] Link text. + * @property {WPNavigationLinkKind} [kind] Kind is used to differentiate between term and post ids to check post draft status. + * @property {string} [type] The type such as post, page, tag, category and other custom types. + * @property {string} [rel] The relationship of the linked URL. + * @property {number} [id] A post or term id. + * @property {boolean} [opensInNewTab] Sets link target to _blank when true. + * @property {string} [url] Link href. + * @property {string} [title] Link title attribute. + */ +/** + * Link Control onChange handler that updates block attributes when a setting is changed. + * + * @param {Object} updatedValue New block attributes to update. + * @param {Function} setAttributes Block attribute update function. + * @param {WPNavigationLinkBlockAttributes} blockAttributes Current block attributes. + */ + +export const updateAttributes = ( + updatedValue = {}, + setAttributes, + blockAttributes = {} +) => { + const { + label: originalLabel = '', + kind: originalKind = '', + type: originalType = '', + } = blockAttributes; + + const { + title: newLabel = '', // the title of any provided Post. + url: newUrl = '', + opensInNewTab, + id, + kind: newKind = originalKind, + type: newType = originalType, + } = updatedValue; + + const newLabelWithoutHttp = newLabel.replace( /http(s?):\/\//gi, '' ); + const newUrlWithoutHttp = newUrl.replace( /http(s?):\/\//gi, '' ); + + const useNewLabel = + newLabel && + newLabel !== originalLabel && + // LinkControl without the title field relies + // on the check below. Specifically, it assumes that + // the URL is the same as a title. + // This logic a) looks suspicious and b) should really + // live in the LinkControl and not here. It's a great + // candidate for future refactoring. + newLabelWithoutHttp !== newUrlWithoutHttp; + + // Unfortunately this causes the escaping model to be inverted. + // The escaped content is stored in the block attributes (and ultimately in the database), + // and then the raw data is "recovered" when outputting into the DOM. + // It would be preferable to store the **raw** data in the block attributes and escape it in JS. + // Why? Because there isn't one way to escape data. Depending on the context, you need to do + // different transforms. It doesn't make sense to me to choose one of them for the purposes of storage. + // See also: + // - https://github.com/WordPress/gutenberg/pull/41063 + // - https://github.com/WordPress/gutenberg/pull/18617. + const label = useNewLabel + ? escapeHTML( newLabel ) + : originalLabel || escapeHTML( newUrlWithoutHttp ); + + // In https://github.com/WordPress/gutenberg/pull/24670 we decided to use "tag" in favor of "post_tag" + const type = newType === 'post_tag' ? 'tag' : newType.replace( '-', '_' ); + + const isBuiltInType = + [ 'post', 'page', 'tag', 'category' ].indexOf( type ) > -1; + + const isCustomLink = + ( ! newKind && ! isBuiltInType ) || newKind === 'custom'; + const kind = isCustomLink ? 'custom' : newKind; + + setAttributes( { + // Passed `url` may already be encoded. To prevent double encoding, decodeURI is executed to revert to the original string. + ...( newUrl && { url: encodeURI( safeDecodeURI( newUrl ) ) } ), + ...( label && { label } ), + ...( undefined !== opensInNewTab && { opensInNewTab } ), + ...( id && Number.isInteger( id ) && { id } ), + ...( kind && { kind } ), + ...( type && type !== 'URL' && { type } ), + } ); +}; diff --git a/src/blocks/navigation-submenu/block.json b/src/blocks/navigation-submenu/block.json new file mode 100644 index 000000000..81e026aa5 --- /dev/null +++ b/src/blocks/navigation-submenu/block.json @@ -0,0 +1,68 @@ +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "apiVersion": 3, + "name": "core/navigation-submenu", + "title": "Submenu", + "category": "design", + "parent": [ "kadence/navigation" ], + "description": "Add a submenu to your navigation.", + "textdomain": "default", + "attributes": { + "label": { + "type": "string" + }, + "type": { + "type": "string" + }, + "description": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "id": { + "type": "number" + }, + "opensInNewTab": { + "type": "boolean", + "default": false + }, + "url": { + "type": "string" + }, + "title": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "isTopLevelItem": { + "type": "boolean" + } + }, + "usesContext": [ + "textColor", + "customTextColor", + "backgroundColor", + "customBackgroundColor", + "overlayTextColor", + "customOverlayTextColor", + "overlayBackgroundColor", + "customOverlayBackgroundColor", + "fontSize", + "customFontSize", + "showSubmenuIcon", + "maxNestingLevel", + "openSubmenusOnClick", + "style" + ], + "supports": { + "reusable": false, + "html": false, + "interactivity": { + "clientNavigation": true + } + }, + "editorStyle": "wp-block-navigation-submenu-editor", + "style": "wp-block-navigation-submenu" +} diff --git a/src/blocks/navigation-submenu/edit.js b/src/blocks/navigation-submenu/edit.js new file mode 100644 index 000000000..507ea6494 --- /dev/null +++ b/src/blocks/navigation-submenu/edit.js @@ -0,0 +1,504 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { useSelect, useDispatch } from '@wordpress/data'; +import { + PanelBody, + TextControl, + TextareaControl, + ToolbarButton, + ToolbarGroup, +} from '@wordpress/components'; +import { displayShortcut, isKeyboardEvent } from '@wordpress/keycodes'; +import { __ } from '@wordpress/i18n'; +import { + BlockControls, + InnerBlocks, + useInnerBlocksProps, + InspectorControls, + RichText, + useBlockProps, + store as blockEditorStore, + getColorClassName, +} from '@wordpress/block-editor'; +import { isURL, prependHTTP } from '@wordpress/url'; +import { useState, useEffect, useRef } from '@wordpress/element'; +import { placeCaretAtHorizontalEdge } from '@wordpress/dom'; +import { link as linkIcon, removeSubmenu } from '@wordpress/icons'; +import { useResourcePermissions } from '@wordpress/core-data'; +import { speak } from '@wordpress/a11y'; +import { createBlock } from '@wordpress/blocks'; +import { useMergeRefs, usePrevious } from '@wordpress/compose'; + +/** + * Internal dependencies + */ +import { ItemSubmenuIcon } from './icons'; +import { LinkUI } from '../navigation-link/link-ui'; +import { updateAttributes } from '../navigation-link/update-attributes'; +import { + getColors, + getNavigationChildBlockProps, +} from '../navigation/edit/utils'; + +const ALLOWED_BLOCKS = [ + 'core/navigation-link', + 'core/navigation-submenu', + 'core/page-list', +]; + +const DEFAULT_BLOCK = { + name: 'core/navigation-link', +}; + +/** + * A React hook to determine if it's dragging within the target element. + * + * @typedef {import('@wordpress/element').RefObject} RefObject + * + * @param {RefObject} elementRef The target elementRef object. + * + * @return {boolean} Is dragging within the target element. + */ +const useIsDraggingWithin = ( elementRef ) => { + const [ isDraggingWithin, setIsDraggingWithin ] = useState( false ); + + useEffect( () => { + const { ownerDocument } = elementRef.current; + + function handleDragStart( event ) { + // Check the first time when the dragging starts. + handleDragEnter( event ); + } + + // Set to false whenever the user cancel the drag event by either releasing the mouse or press Escape. + function handleDragEnd() { + setIsDraggingWithin( false ); + } + + function handleDragEnter( event ) { + // Check if the current target is inside the item element. + if ( elementRef.current.contains( event.target ) ) { + setIsDraggingWithin( true ); + } else { + setIsDraggingWithin( false ); + } + } + + // Bind these events to the document to catch all drag events. + // Ideally, we can also use `event.relatedTarget`, but sadly that + // doesn't work in Safari. + ownerDocument.addEventListener( 'dragstart', handleDragStart ); + ownerDocument.addEventListener( 'dragend', handleDragEnd ); + ownerDocument.addEventListener( 'dragenter', handleDragEnter ); + + return () => { + ownerDocument.removeEventListener( 'dragstart', handleDragStart ); + ownerDocument.removeEventListener( 'dragend', handleDragEnd ); + ownerDocument.removeEventListener( 'dragenter', handleDragEnter ); + }; + }, [] ); + + return isDraggingWithin; +}; + +/** + * @typedef {'post-type'|'custom'|'taxonomy'|'post-type-archive'} WPNavigationLinkKind + */ + +/** + * Navigation Link Block Attributes + * + * @typedef {Object} WPNavigationLinkBlockAttributes + * + * @property {string} [label] Link text. + * @property {WPNavigationLinkKind} [kind] Kind is used to differentiate between term and post ids to check post draft status. + * @property {string} [type] The type such as post, page, tag, category and other custom types. + * @property {string} [rel] The relationship of the linked URL. + * @property {number} [id] A post or term id. + * @property {boolean} [opensInNewTab] Sets link target to _blank when true. + * @property {string} [url] Link href. + * @property {string} [title] Link title attribute. + */ + +export default function NavigationSubmenuEdit( { + attributes, + isSelected, + setAttributes, + mergeBlocks, + onReplace, + context, + clientId, +} ) { + const { label, type, url, description, rel, title } = attributes; + + const { showSubmenuIcon, maxNestingLevel, openSubmenusOnClick } = context; + + const { __unstableMarkNextChangeAsNotPersistent, replaceBlock } = + useDispatch( blockEditorStore ); + const [ isLinkOpen, setIsLinkOpen ] = useState( false ); + // Use internal state instead of a ref to make sure that the component + // re-renders when the popover's anchor updates. + const [ popoverAnchor, setPopoverAnchor ] = useState( null ); + const listItemRef = useRef( null ); + const isDraggingWithin = useIsDraggingWithin( listItemRef ); + const itemLabelPlaceholder = __( 'Add text…' ); + const ref = useRef(); + + const pagesPermissions = useResourcePermissions( 'pages' ); + const postsPermissions = useResourcePermissions( 'posts' ); + + const { + parentCount, + isParentOfSelectedBlock, + isImmediateParentOfSelectedBlock, + hasChildren, + selectedBlockHasChildren, + onlyDescendantIsEmptyLink, + } = useSelect( + ( select ) => { + const { + hasSelectedInnerBlock, + getSelectedBlockClientId, + getBlockParentsByBlockName, + getBlock, + getBlockCount, + getBlockOrder, + } = select( blockEditorStore ); + + let _onlyDescendantIsEmptyLink; + + const selectedBlockId = getSelectedBlockClientId(); + + const selectedBlockChildren = getBlockOrder( selectedBlockId ); + + // Check for a single descendant in the submenu. If that block + // is a link block in a "placeholder" state with no label then + // we can consider as an "empty" link. + if ( selectedBlockChildren?.length === 1 ) { + const singleBlock = getBlock( selectedBlockChildren[ 0 ] ); + + _onlyDescendantIsEmptyLink = + singleBlock?.name === 'core/navigation-link' && + ! singleBlock?.attributes?.label; + } + + return { + parentCount: getBlockParentsByBlockName( + clientId, + 'core/navigation-submenu' + ).length, + isParentOfSelectedBlock: hasSelectedInnerBlock( + clientId, + true + ), + isImmediateParentOfSelectedBlock: hasSelectedInnerBlock( + clientId, + false + ), + hasChildren: !! getBlockCount( clientId ), + selectedBlockHasChildren: !! selectedBlockChildren?.length, + onlyDescendantIsEmptyLink: _onlyDescendantIsEmptyLink, + }; + }, + [ clientId ] + ); + + const prevHasChildren = usePrevious( hasChildren ); + + // Show the LinkControl on mount if the URL is empty + // ( When adding a new menu item) + // This can't be done in the useState call because it conflicts + // with the autofocus behavior of the BlockListBlock component. + useEffect( () => { + if ( ! openSubmenusOnClick && ! url ) { + setIsLinkOpen( true ); + } + }, [] ); + + /** + * The hook shouldn't be necessary but due to a focus loss happening + * when selecting a suggestion in the link popover, we force close on block unselection. + */ + useEffect( () => { + if ( ! isSelected ) { + setIsLinkOpen( false ); + } + }, [ isSelected ] ); + + // If the LinkControl popover is open and the URL has changed, close the LinkControl and focus the label text. + useEffect( () => { + if ( isLinkOpen && url ) { + // Does this look like a URL and have something TLD-ish? + if ( + isURL( prependHTTP( label ) ) && + /^.+\.[a-z]+/.test( label ) + ) { + // Focus and select the label text. + selectLabelText(); + } else { + // Focus it (but do not select). + placeCaretAtHorizontalEdge( ref.current, true ); + } + } + }, [ url ] ); + + /** + * Focus the Link label text and select it. + */ + function selectLabelText() { + ref.current.focus(); + const { ownerDocument } = ref.current; + const { defaultView } = ownerDocument; + const selection = defaultView.getSelection(); + const range = ownerDocument.createRange(); + // Get the range of the current ref contents so we can add this range to the selection. + range.selectNodeContents( ref.current ); + selection.removeAllRanges(); + selection.addRange( range ); + } + + let userCanCreate = false; + if ( ! type || type === 'page' ) { + userCanCreate = pagesPermissions.canCreate; + } else if ( type === 'post' ) { + userCanCreate = postsPermissions.canCreate; + } + + const { + textColor, + customTextColor, + backgroundColor, + customBackgroundColor, + } = getColors( context, parentCount > 0 ); + + function onKeyDown( event ) { + if ( isKeyboardEvent.primary( event, 'k' ) ) { + setIsLinkOpen( true ); + } + } + + const blockProps = useBlockProps( { + ref: useMergeRefs( [ setPopoverAnchor, listItemRef ] ), + className: classnames( 'wp-block-navigation-item', { + 'is-editing': isSelected || isParentOfSelectedBlock, + 'is-dragging-within': isDraggingWithin, + 'has-link': !! url, + 'has-child': hasChildren, + 'has-text-color': !! textColor || !! customTextColor, + [ getColorClassName( 'color', textColor ) ]: !! textColor, + 'has-background': !! backgroundColor || customBackgroundColor, + [ getColorClassName( 'background-color', backgroundColor ) ]: + !! backgroundColor, + 'open-on-click': openSubmenusOnClick, + } ), + style: { + color: ! textColor && customTextColor, + backgroundColor: ! backgroundColor && customBackgroundColor, + }, + onKeyDown, + } ); + + // Always use overlay colors for submenus. + const innerBlocksColors = getColors( context, true ); + + const allowedBlocks = + parentCount >= maxNestingLevel + ? ALLOWED_BLOCKS.filter( + ( blockName ) => blockName !== 'core/navigation-submenu' + ) + : ALLOWED_BLOCKS; + + const navigationChildBlockProps = + getNavigationChildBlockProps( innerBlocksColors ); + const innerBlocksProps = useInnerBlocksProps( navigationChildBlockProps, { + allowedBlocks, + defaultBlock: DEFAULT_BLOCK, + directInsert: true, + + // Ensure block toolbar is not too far removed from item + // being edited. + // see: https://github.com/WordPress/gutenberg/pull/34615. + __experimentalCaptureToolbars: true, + + renderAppender: + isSelected || + ( isImmediateParentOfSelectedBlock && + ! selectedBlockHasChildren ) || + // Show the appender while dragging to allow inserting element between item and the appender. + hasChildren + ? InnerBlocks.ButtonBlockAppender + : false, + } ); + + const ParentElement = openSubmenusOnClick ? 'button' : 'a'; + + function transformToLink() { + const newLinkBlock = createBlock( 'core/navigation-link', attributes ); + replaceBlock( clientId, newLinkBlock ); + } + + useEffect( () => { + // If block becomes empty, transform to Navigation Link. + if ( ! hasChildren && prevHasChildren ) { + // This side-effect should not create an undo level as those should + // only be created via user interactions. + __unstableMarkNextChangeAsNotPersistent(); + transformToLink(); + } + }, [ hasChildren, prevHasChildren ] ); + + const canConvertToLink = + ! selectedBlockHasChildren || onlyDescendantIsEmptyLink; + + return ( + <> + + + { ! openSubmenusOnClick && ( + setIsLinkOpen( true ) } + /> + ) } + + + + + { /* Warning, this duplicated in packages/block-library/src/navigation-link/edit.js */ } + + + { + setAttributes( { label: labelValue } ); + } } + label={ __( 'Label' ) } + autoComplete="off" + /> + { + setAttributes( { url: urlValue } ); + } } + label={ __( 'URL' ) } + autoComplete="off" + /> + { + setAttributes( { + description: descriptionValue, + } ); + } } + label={ __( 'Description' ) } + help={ __( + 'The description will be displayed in the menu if the current theme supports it.' + ) } + /> + { + setAttributes( { title: titleValue } ); + } } + label={ __( 'Title attribute' ) } + autoComplete="off" + help={ __( + 'Additional information to help clarify the purpose of the link.' + ) } + /> + { + setAttributes( { rel: relValue } ); + } } + label={ __( 'Rel attribute' ) } + autoComplete="off" + help={ __( + 'The relationship of the linked URL as space-separated link types.' + ) } + /> + + +
    + { /* eslint-disable jsx-a11y/anchor-is-valid */ } + + { /* eslint-enable */ } + { + + setAttributes( { label: labelValue } ) + } + onMerge={ mergeBlocks } + onReplace={ onReplace } + aria-label={ __( 'Navigation link text' ) } + placeholder={ itemLabelPlaceholder } + withoutInteractiveFormatting + allowedFormats={ [ + 'core/bold', + 'core/italic', + 'core/image', + 'core/strikethrough', + ] } + onClick={ () => { + if ( ! openSubmenusOnClick && ! url ) { + setIsLinkOpen( true ); + } + } } + /> + } + { ! openSubmenusOnClick && isLinkOpen && ( + setIsLinkOpen( false ) } + anchor={ popoverAnchor } + hasCreateSuggestion={ userCanCreate } + onRemove={ () => { + setAttributes( { url: '' } ); + speak( __( 'Link removed.' ), 'assertive' ); + } } + onChange={ ( updatedValue ) => { + updateAttributes( + updatedValue, + setAttributes, + attributes + ); + } } + /> + ) } + + { ( showSubmenuIcon || openSubmenusOnClick ) && ( + + + + ) } +
    +
    + + ); +} diff --git a/src/blocks/navigation-submenu/editor.scss b/src/blocks/navigation-submenu/editor.scss new file mode 100644 index 000000000..627978a00 --- /dev/null +++ b/src/blocks/navigation-submenu/editor.scss @@ -0,0 +1,42 @@ +.wp-block-navigation-submenu { + display: block; + + .wp-block-navigation__submenu-container { + z-index: 28; + } + + // Show on editor selected, even if on frontend it only stays open on focus-within. + &.is-selected, + &.has-child-selected { + > .wp-block-navigation__submenu-container { + // We use important here because if the parent block is selected and submenus are present, they should always be visible. + visibility: visible !important; + opacity: 1 !important; + min-width: 200px !important; + height: auto !important; + width: auto !important; + // These styles are needed to display the dropdown properly when it is empty. + position: absolute; + left: -1px; // Border width. + top: 100%; + + @include break-medium { + .wp-block-navigation__submenu-container { + left: 100%; + top: -1px; // Border width. + + // Prevent the menu from disappearing when the mouse is over the gap + &::before { + content: ""; + position: absolute; + right: 100%; + height: 100%; + display: block; + width: 0.5em; + background: transparent; + } + } + } + } + } +} diff --git a/src/blocks/navigation-submenu/icons.js b/src/blocks/navigation-submenu/icons.js new file mode 100644 index 000000000..3a44a250b --- /dev/null +++ b/src/blocks/navigation-submenu/icons.js @@ -0,0 +1,16 @@ +/** + * WordPress dependencies + */ +import { Path, SVG } from '@wordpress/components'; + +export const ItemSubmenuIcon = () => ( + + + +); diff --git a/src/blocks/navigation-submenu/index.js b/src/blocks/navigation-submenu/index.js new file mode 100644 index 000000000..0ddac86c1 --- /dev/null +++ b/src/blocks/navigation-submenu/index.js @@ -0,0 +1,46 @@ +/** + * WordPress dependencies + */ +import { page, addSubmenu } from '@wordpress/icons'; +import { customLink as linkIcon } from '@wordpress/icons'; + +/** + * Internal dependencies + */ +import initBlock from '../utils/init-block'; +import metadata from './block.json'; +import edit from './edit'; +import save from './save'; +import transforms from './transforms'; + +const { name } = metadata; + +export { metadata, name }; + +export const settings = { + // icon: ( { context } ) => { + // if ( context === 'list-view' ) { + // return page; + // } + // return addSubmenu; + // }, + icon: linkIcon, + __experimentalLabel( attributes, { context } ) { + const { label } = attributes; + + const customName = attributes?.metadata?.name; + + // In the list view, use the block's menu label as the label. + // If the menu label is empty, fall back to the default label. + if ( context === 'list-view' && ( customName || label ) ) { + return attributes?.metadata?.name || label; + } + + return label; + }, + edit, + save, + transforms, +}; + +export const init = () => initBlock( { name, metadata, settings } ); diff --git a/src/blocks/navigation-submenu/index.php b/src/blocks/navigation-submenu/index.php new file mode 100644 index 000000000..2ae23a92b --- /dev/null +++ b/src/blocks/navigation-submenu/index.php @@ -0,0 +1,252 @@ + array(), + 'inline_styles' => '', + ); + + $has_named_font_size = array_key_exists( 'fontSize', $context ); + $has_custom_font_size = isset( $context['style']['typography']['fontSize'] ); + + if ( $has_named_font_size ) { + // Add the font size class. + $font_sizes['css_classes'][] = sprintf( 'has-%s-font-size', $context['fontSize'] ); + } elseif ( $has_custom_font_size ) { + // Add the custom font size inline style. + $font_sizes['inline_styles'] = sprintf( + 'font-size: %s;', + wp_get_typography_font_size_value( + array( + 'size' => $context['style']['typography']['fontSize'], + ) + ) + ); + } + + return $font_sizes; +} + +/** + * Returns the top-level submenu SVG chevron icon. + * + * @return string + */ +function block_core_navigation_submenu_render_submenu_icon() { + return ''; +} + +/** + * Renders the `core/navigation-submenu` block. + * + * @param array $attributes The block attributes. + * @param string $content The saved content. + * @param WP_Block $block The parsed block. + * + * @return string Returns the post content with the legacy widget added. + */ +function render_block_core_navigation_submenu( $attributes, $content, $block ) { + $navigation_link_has_id = isset( $attributes['id'] ) && is_numeric( $attributes['id'] ); + $is_post_type = isset( $attributes['kind'] ) && 'post-type' === $attributes['kind']; + $is_post_type = $is_post_type || isset( $attributes['type'] ) && ( 'post' === $attributes['type'] || 'page' === $attributes['type'] ); + + // Don't render the block's subtree if it is a draft. + if ( $is_post_type && $navigation_link_has_id && 'publish' !== get_post_status( $attributes['id'] ) ) { + return ''; + } + + // Don't render the block's subtree if it has no label. + if ( empty( $attributes['label'] ) ) { + return ''; + } + + $font_sizes = block_core_navigation_submenu_build_css_font_sizes( $block->context ); + $style_attribute = $font_sizes['inline_styles']; + + $css_classes = trim( implode( ' ', $font_sizes['css_classes'] ) ); + $has_submenu = count( $block->inner_blocks ) > 0; + $kind = empty( $attributes['kind'] ) ? 'post_type' : str_replace( '-', '_', $attributes['kind'] ); + $is_active = ! empty( $attributes['id'] ) && get_queried_object_id() === (int) $attributes['id'] && ! empty( get_queried_object()->$kind ); + + $show_submenu_indicators = isset( $block->context['showSubmenuIcon'] ) && $block->context['showSubmenuIcon']; + $open_on_click = isset( $block->context['openSubmenusOnClick'] ) && $block->context['openSubmenusOnClick']; + $open_on_hover_and_click = isset( $block->context['openSubmenusOnClick'] ) && ! $block->context['openSubmenusOnClick'] && + $show_submenu_indicators; + + $wrapper_attributes = get_block_wrapper_attributes( + array( + 'class' => $css_classes . ' wp-block-navigation-item' . ( $has_submenu ? ' has-child' : '' ) . + ( $open_on_click ? ' open-on-click' : '' ) . ( $open_on_hover_and_click ? ' open-on-hover-click' : '' ) . + ( $is_active ? ' current-menu-item' : '' ), + 'style' => $style_attribute, + ) + ); + + $label = ''; + + if ( isset( $attributes['label'] ) ) { + $label .= wp_kses_post( $attributes['label'] ); + } + + $aria_label = sprintf( + /* translators: Accessibility text. %s: Parent page title. */ + __( '%s submenu' ), + wp_strip_all_tags( $label ) + ); + + $html = '
  • '; + + // If Submenus open on hover, we render an anchor tag with attributes. + // If submenu icons are set to show, we also render a submenu button, so the submenu can be opened on click. + if ( ! $open_on_click ) { + $item_url = isset( $attributes['url'] ) ? $attributes['url'] : ''; + // Start appending HTML attributes to anchor tag. + $html .= '