From f9e725b9626ae8af3f0f45c9df35e8682a20a479 Mon Sep 17 00:00:00 2001 From: George Mamadashvili Date: Wed, 1 May 2024 15:27:41 +0400 Subject: [PATCH 1/9] Templates API: Fix template files query by post-type (#61244) Co-authored-by: Mamaduka Co-authored-by: creativecoder Co-authored-by: mike-henderson Co-authored-by: youknowriad --- .../wordpress-6.6/block-template-utils.php | 249 ++++++++++++++++++ ...utenberg-rest-templates-controller-6-6.php | 29 ++ phpunit/blocks/get-block-templates-test.php | 109 ++++++++ .../templates/custom-hero-template.html | 3 + .../custom-single-post-template.html | 3 + .../block-theme/templates/index.html | 3 + .../block-theme/templates/page-home.html | 3 + 7 files changed, 399 insertions(+) create mode 100644 phpunit/blocks/get-block-templates-test.php create mode 100644 phpunit/data/themedir1/block-theme/templates/custom-hero-template.html create mode 100644 phpunit/data/themedir1/block-theme/templates/custom-single-post-template.html create mode 100644 phpunit/data/themedir1/block-theme/templates/index.html create mode 100644 phpunit/data/themedir1/block-theme/templates/page-home.html diff --git a/lib/compat/wordpress-6.6/block-template-utils.php b/lib/compat/wordpress-6.6/block-template-utils.php index 8dd35903af079..953f6bf20c077 100644 --- a/lib/compat/wordpress-6.6/block-template-utils.php +++ b/lib/compat/wordpress-6.6/block-template-utils.php @@ -111,3 +111,252 @@ function gutenberg_get_template_hierarchy( $slug, $is_custom = false, $template_ } return $template_hierarchy; } + +/** + * Retrieves the template files from the theme. + * + * @since 5.9.0 + * @since 6.3.0 Added the `$query` parameter. + * @access private + * + * @param string $template_type Template type. Either 'wp_template' or 'wp_template_part'. + * @param array $query { + * Arguments to retrieve templates. Optional, empty by default. + * + * @type string[] $slug__in List of slugs to include. + * @type string[] $slug__not_in List of slugs to skip. + * @type string $area A 'wp_template_part_area' taxonomy value to filter by (for 'wp_template_part' template type only). + * @type string $post_type Post type to get the templates for. + * } + * + * @return array Template + */ +function _gutenberg_get_block_templates_files( $template_type, $query = array() ) { + if ( 'wp_template' !== $template_type && 'wp_template_part' !== $template_type ) { + return null; + } + + // @core-merge: This code will go into Core's '_get_block_templates_files' function. + $default_template_types = array(); + if ( 'wp_template' === $template_type ) { + $default_template_types = get_default_block_template_types(); + } + // @core-merge: End of the code that will go into Core. + + // Prepare metadata from $query. + $slugs_to_include = isset( $query['slug__in'] ) ? $query['slug__in'] : array(); + $slugs_to_skip = isset( $query['slug__not_in'] ) ? $query['slug__not_in'] : array(); + $area = isset( $query['area'] ) ? $query['area'] : null; + $post_type = isset( $query['post_type'] ) ? $query['post_type'] : ''; + + $stylesheet = get_stylesheet(); + $template = get_template(); + $themes = array( + $stylesheet => get_stylesheet_directory(), + ); + // Add the parent theme if it's not the same as the current theme. + if ( $stylesheet !== $template ) { + $themes[ $template ] = get_template_directory(); + } + $template_files = array(); + foreach ( $themes as $theme_slug => $theme_dir ) { + $template_base_paths = get_block_theme_folders( $theme_slug ); + $theme_template_files = _get_block_templates_paths( $theme_dir . '/' . $template_base_paths[ $template_type ] ); + foreach ( $theme_template_files as $template_file ) { + $template_base_path = $template_base_paths[ $template_type ]; + $template_slug = substr( + $template_file, + // Starting position of slug. + strpos( $template_file, $template_base_path . DIRECTORY_SEPARATOR ) + 1 + strlen( $template_base_path ), + // Subtract ending '.html'. + -5 + ); + + // Skip this item if its slug doesn't match any of the slugs to include. + if ( ! empty( $slugs_to_include ) && ! in_array( $template_slug, $slugs_to_include, true ) ) { + continue; + } + + // Skip this item if its slug matches any of the slugs to skip. + if ( ! empty( $slugs_to_skip ) && in_array( $template_slug, $slugs_to_skip, true ) ) { + continue; + } + + /* + * The child theme items (stylesheet) are processed before the parent theme's (template). + * If a child theme defines a template, prevent the parent template from being added to the list as well. + */ + if ( isset( $template_files[ $template_slug ] ) ) { + continue; + } + + $new_template_item = array( + 'slug' => $template_slug, + 'path' => $template_file, + 'theme' => $theme_slug, + 'type' => $template_type, + ); + + if ( 'wp_template_part' === $template_type ) { + $candidate = _add_block_template_part_area_info( $new_template_item ); + if ( ! isset( $area ) || ( isset( $area ) && $area === $candidate['area'] ) ) { + $template_files[ $template_slug ] = $candidate; + } + } + + if ( 'wp_template' === $template_type ) { + $candidate = _add_block_template_info( $new_template_item ); + $is_custom = ! isset( $default_template_types[ $candidate['slug'] ] ); + + if ( + ! $post_type || + ( $post_type && isset( $candidate['postTypes'] ) && in_array( $post_type, $candidate['postTypes'], true ) ) + ) { + $template_files[ $template_slug ] = $candidate; + } + + // @core-merge: This code will go into Core's '_get_block_templates_files' function. + // The custom templates with no associated post-types are available for all post-types. + if ( $post_type && ! isset( $candidate['postTypes'] ) && $is_custom ) { + $template_files[ $template_slug ] = $candidate; + } + // @core-merge: End of the code that will go into Core. + } + } + } + + return array_values( $template_files ); +} + +/** + * Retrieves a list of unified template objects based on a query. + * + * @since 5.8.0 + * + * @param array $query { + * Optional. Arguments to retrieve templates. + * + * @type string[] $slug__in List of slugs to include. + * @type int $wp_id Post ID of customized template. + * @type string $area A 'wp_template_part_area' taxonomy value to filter by (for 'wp_template_part' template type only). + * @type string $post_type Post type to get the templates for. + * } + * @param string $template_type Template type. Either 'wp_template' or 'wp_template_part'. + * @return WP_Block_Template[] Array of block templates. + */ +function gutenberg_get_block_templates( $query = array(), $template_type = 'wp_template' ) { + /** + * Filters the block templates array before the query takes place. + * + * Return a non-null value to bypass the WordPress queries. + * + * @since 5.9.0 + * + * @param WP_Block_Template[]|null $block_templates Return an array of block templates to short-circuit the default query, + * or null to allow WP to run its normal queries. + * @param array $query { + * Arguments to retrieve templates. All arguments are optional. + * + * @type string[] $slug__in List of slugs to include. + * @type int $wp_id Post ID of customized template. + * @type string $area A 'wp_template_part_area' taxonomy value to filter by (for 'wp_template_part' template type only). + * @type string $post_type Post type to get the templates for. + * } + * @param string $template_type Template type. Either 'wp_template' or 'wp_template_part'. + */ + $templates = apply_filters( 'pre_get_block_templates', null, $query, $template_type ); + if ( ! is_null( $templates ) ) { + return $templates; + } + + $post_type = isset( $query['post_type'] ) ? $query['post_type'] : ''; + $wp_query_args = array( + 'post_status' => array( 'auto-draft', 'draft', 'publish' ), + 'post_type' => $template_type, + 'posts_per_page' => -1, + 'no_found_rows' => true, + 'lazy_load_term_meta' => false, + 'tax_query' => array( + array( + 'taxonomy' => 'wp_theme', + 'field' => 'name', + 'terms' => get_stylesheet(), + ), + ), + ); + + if ( 'wp_template_part' === $template_type && isset( $query['area'] ) ) { + $wp_query_args['tax_query'][] = array( + 'taxonomy' => 'wp_template_part_area', + 'field' => 'name', + 'terms' => $query['area'], + ); + $wp_query_args['tax_query']['relation'] = 'AND'; + } + + if ( ! empty( $query['slug__in'] ) ) { + $wp_query_args['post_name__in'] = $query['slug__in']; + $wp_query_args['posts_per_page'] = count( array_unique( $query['slug__in'] ) ); + } + + // This is only needed for the regular templates/template parts post type listing and editor. + if ( isset( $query['wp_id'] ) ) { + $wp_query_args['p'] = $query['wp_id']; + } else { + $wp_query_args['post_status'] = 'publish'; + } + + $template_query = new WP_Query( $wp_query_args ); + $query_result = array(); + foreach ( $template_query->posts as $post ) { + $template = _build_block_template_result_from_post( $post ); + + if ( is_wp_error( $template ) ) { + continue; + } + + if ( $post_type && ! $template->is_custom ) { + continue; + } + + if ( + $post_type && + isset( $template->post_types ) && + ! in_array( $post_type, $template->post_types, true ) + ) { + continue; + } + + $query_result[] = $template; + } + + if ( ! isset( $query['wp_id'] ) ) { + /* + * If the query has found some use templates, those have priority + * over the theme-provided ones, so we skip querying and building them. + */ + $query['slug__not_in'] = wp_list_pluck( $query_result, 'slug' ); + $template_files = _gutenberg_get_block_templates_files( $template_type, $query ); + foreach ( $template_files as $template_file ) { + $query_result[] = _build_block_template_result_from_file( $template_file, $template_type ); + } + } + + /** + * Filters the array of queried block templates array after they've been fetched. + * + * @since 5.9.0 + * + * @param WP_Block_Template[] $query_result Array of found block templates. + * @param array $query { + * Arguments to retrieve templates. All arguments are optional. + * + * @type string[] $slug__in List of slugs to include. + * @type int $wp_id Post ID of customized template. + * @type string $area A 'wp_template_part_area' taxonomy value to filter by (for 'wp_template_part' template type only). + * @type string $post_type Post type to get the templates for. + * } + * @param string $template_type wp_template or wp_template_part. + */ + return apply_filters( 'get_block_templates', $query_result, $query, $template_type ); +} diff --git a/lib/compat/wordpress-6.6/class-gutenberg-rest-templates-controller-6-6.php b/lib/compat/wordpress-6.6/class-gutenberg-rest-templates-controller-6-6.php index 6db06a940e15f..656e38ffe933f 100644 --- a/lib/compat/wordpress-6.6/class-gutenberg-rest-templates-controller-6-6.php +++ b/lib/compat/wordpress-6.6/class-gutenberg-rest-templates-controller-6-6.php @@ -41,6 +41,35 @@ public function get_items_permissions_check( $request ) { // phpcs:ignore Variab ); } + /** + * Returns a list of templates. + * + * @since 5.8.0 + * + * @param WP_REST_Request $request The request instance. + * @return WP_REST_Response + */ + public function get_items( $request ) { + $query = array(); + if ( isset( $request['wp_id'] ) ) { + $query['wp_id'] = $request['wp_id']; + } + if ( isset( $request['area'] ) ) { + $query['area'] = $request['area']; + } + if ( isset( $request['post_type'] ) ) { + $query['post_type'] = $request['post_type']; + } + + $templates = array(); + foreach ( gutenberg_get_block_templates( $query, $this->post_type ) as $template ) { + $data = $this->prepare_item_for_response( $template, $request ); + $templates[] = $this->prepare_response_for_collection( $data ); + } + + return rest_ensure_response( $templates ); + } + /** * Checks if a given request has access to read templates. * diff --git a/phpunit/blocks/get-block-templates-test.php b/phpunit/blocks/get-block-templates-test.php new file mode 100644 index 0000000000000..7c8045a1f25f7 --- /dev/null +++ b/phpunit/blocks/get-block-templates-test.php @@ -0,0 +1,109 @@ +theme_root = realpath( __DIR__ . '/../data/themedir1' ); + $this->orig_theme_dir = $GLOBALS['wp_theme_directories']; + + // /themes is necessary as theme.php functions assume /themes is the root if there is only one root. + $GLOBALS['wp_theme_directories'] = array( WP_CONTENT_DIR . '/themes', $this->theme_root ); + + add_filter( 'theme_root', array( $this, 'filter_set_theme_root' ) ); + add_filter( 'stylesheet_root', array( $this, 'filter_set_theme_root' ) ); + add_filter( 'template_root', array( $this, 'filter_set_theme_root' ) ); + // Clear caches. + wp_clean_themes_cache(); + unset( $GLOBALS['wp_themes'] ); + switch_theme( self::TEST_THEME ); + } + + public function tear_down() { + $GLOBALS['wp_theme_directories'] = $this->orig_theme_dir; + wp_clean_themes_cache(); + unset( $GLOBALS['wp_themes'] ); + parent::tear_down(); + } + + public function filter_set_theme_root() { + return $this->theme_root; + } + + /** + * Gets the template IDs from the given array. + * + * @param object[] $templates Array of template objects to parse. + * @return string[] The template IDs. + */ + private function get_template_ids( $templates ) { + return array_map( + static function ( $template ) { + return $template->id; + }, + $templates + ); + } + + /** + * @dataProvider data_get_block_templates_should_respect_posttypes_property + * @ticket 55881 + * + * @param string $post_type Post type for query. + * @param array $expected Expected template IDs. + */ + public function test_get_block_templates_should_respect_posttypes_property( $post_type, $expected ) { + $templates = gutenberg_get_block_templates( array( 'post_type' => $post_type ) ); + + $this->assertSameSets( + $expected, + $this->get_template_ids( $templates ) + ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_get_block_templates_should_respect_posttypes_property() { + // @code-merge: This code will go into Core's tests/phpunit/tests/blocks/getBlockTemplates.php. + return array( + 'post' => array( + 'post_type' => 'post', + 'expected' => array( + 'block-theme//custom-hero-template', + 'block-theme//custom-single-post-template', + ), + ), + 'page' => array( + 'post_type' => 'page', + 'expected' => array( + 'block-theme//custom-hero-template', + 'block-theme//page-home', + ), + ), + ); + } +} diff --git a/phpunit/data/themedir1/block-theme/templates/custom-hero-template.html b/phpunit/data/themedir1/block-theme/templates/custom-hero-template.html new file mode 100644 index 0000000000000..0a62cda60641a --- /dev/null +++ b/phpunit/data/themedir1/block-theme/templates/custom-hero-template.html @@ -0,0 +1,3 @@ + +

Custom Hero template

+ diff --git a/phpunit/data/themedir1/block-theme/templates/custom-single-post-template.html b/phpunit/data/themedir1/block-theme/templates/custom-single-post-template.html new file mode 100644 index 0000000000000..fc0a5f8a4b154 --- /dev/null +++ b/phpunit/data/themedir1/block-theme/templates/custom-single-post-template.html @@ -0,0 +1,3 @@ + +

Custom Single Post template

+ diff --git a/phpunit/data/themedir1/block-theme/templates/index.html b/phpunit/data/themedir1/block-theme/templates/index.html new file mode 100644 index 0000000000000..451c2c1a5c159 --- /dev/null +++ b/phpunit/data/themedir1/block-theme/templates/index.html @@ -0,0 +1,3 @@ + +

Index Template

+ diff --git a/phpunit/data/themedir1/block-theme/templates/page-home.html b/phpunit/data/themedir1/block-theme/templates/page-home.html new file mode 100644 index 0000000000000..fd46fc0018fff --- /dev/null +++ b/phpunit/data/themedir1/block-theme/templates/page-home.html @@ -0,0 +1,3 @@ + +

Page (Home) Template

+ From 33e5b7f5a70b899ca57f29969b3526e7a844e833 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dami=C3=A1n=20Su=C3=A1rez?= Date: Wed, 1 May 2024 12:31:33 +0100 Subject: [PATCH 2/9] __experimentalPluginPostExcerpt return plugin cmp (#61238) Co-authored-by: retrofox Co-authored-by: youknowriad Co-authored-by: pdewouters Co-authored-by: peterwilsoncc --- packages/edit-post/src/deprecated.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/edit-post/src/deprecated.js b/packages/edit-post/src/deprecated.js index 3360d67b82b7b..8d24a51aeb3d8 100644 --- a/packages/edit-post/src/deprecated.js +++ b/packages/edit-post/src/deprecated.js @@ -2,6 +2,7 @@ * WordPress dependencies */ import { + privateApis as editorPrivateApis, PluginBlockSettingsMenuItem as EditorPluginBlockSettingsMenuItem, PluginDocumentSettingPanel as EditorPluginDocumentSettingPanel, PluginMoreMenuItem as EditorPluginMoreMenuItem, @@ -13,6 +14,12 @@ import { } from '@wordpress/editor'; import deprecated from '@wordpress/deprecated'; +/** + * Internal dependencies + */ +import { unlock } from './lock-unlock'; +const { PluginPostExcerpt } = unlock( editorPrivateApis ); + const deprecateSlot = ( name ) => { deprecated( `wp.editPost.${ name }`, { since: '6.6', @@ -94,7 +101,7 @@ export function __experimentalPluginPostExcerpt() { hint: 'Core and custom panels can be access programmatically using their panel name.', link: 'https://developer.wordpress.org/block-editor/reference-guides/slotfills/plugin-document-setting-panel/#accessing-a-panel-programmatically', } ); - return null; + return PluginPostExcerpt; } /* eslint-enable jsdoc/require-param */ From 952dba4034ce75337fe5e4f4d379850e29373da3 Mon Sep 17 00:00:00 2001 From: JuanMa Date: Wed, 1 May 2024 16:06:03 +0200 Subject: [PATCH 3/9] Rephrasing for accuracy and link to Core Trac ticket (#61284) --- docs/reference-guides/block-api/block-metadata.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reference-guides/block-api/block-metadata.md b/docs/reference-guides/block-api/block-metadata.md index 95b1107babee6..1b0925513b3a8 100644 --- a/docs/reference-guides/block-api/block-metadata.md +++ b/docs/reference-guides/block-api/block-metadata.md @@ -543,7 +543,7 @@ Block type frontend script module definition. They will be enqueued only when vi It's possible to pass a script module ID registered with the [`wp_register_script_module`](https://developer.wordpress.org/reference/functions/wp_register_script_module/) function, a path to a JavaScript module relative to the `block.json` file, or a list with a mix of both ([learn more](#wpdefinedasset)). -WordPress scripts and WordPress script modules are not compatible at the moment. If frontend view assets depend on WordPress scripts, `viewScript` should be used. If they depend on WordPress script modules —the Interactivity API at this time— `viewScriptModule` should be used. In the future, it will be possible to depend on scripts from script modules. +WordPress scripts and WordPress script modules are not compatible at the moment. If frontend view assets depend on WordPress scripts, `viewScript` should be used. If they depend on WordPress script modules —the Interactivity API at this time— `viewScriptModule` should be used. [More functionality](https://core.trac.wordpress.org/ticket/60647) will gradually become available to Script Modules. _Note: Available since WordPress `6.5.0`._ From ed47bc83eb4e0fc3506044e75e45554049da81f3 Mon Sep 17 00:00:00 2001 From: Ella <4710635+ellatrix@users.noreply.github.com> Date: Wed, 1 May 2024 17:38:58 +0200 Subject: [PATCH 4/9] Rich text: combine all ref effects (#60936) --- .../event-listeners/before-input-rules.js | 92 ++++++ .../rich-text/event-listeners/delete.js | 53 ++++ .../rich-text/event-listeners/enter.js | 104 +++++++ .../event-listeners/firefox-compat.js | 31 ++ .../rich-text/event-listeners/index.js | 56 ++++ .../rich-text/event-listeners/input-events.js | 13 + .../rich-text/event-listeners/input-rules.js | 146 ++++++++++ .../insert-replacement-text.js | 28 ++ .../event-listeners/paste-handler.js | 169 +++++++++++ .../remove-browser-shortcuts.js | 24 ++ .../rich-text/event-listeners/shortcuts.js | 13 + .../event-listeners/undo-automatic-change.js | 45 +++ .../src/components/rich-text/index.js | 41 +-- .../rich-text/use-before-input-rules.js | 99 ------- .../src/components/rich-text/use-delete.js | 59 ---- .../src/components/rich-text/use-enter.js | 110 ------- .../rich-text/use-firefox-compat.js | 39 --- .../components/rich-text/use-input-events.js | 19 -- .../components/rich-text/use-input-rules.js | 150 ---------- .../rich-text/use-insert-replacement-text.js | 31 -- .../components/rich-text/use-paste-handler.js | 180 ------------ .../rich-text/use-remove-browser-shortcuts.js | 29 -- .../src/components/rich-text/use-shortcuts.js | 19 -- .../rich-text/use-undo-automatic-change.js | 46 --- .../component/event-listeners/copy-handler.js | 39 +++ .../src/component/event-listeners/delete.js | 38 +++ .../event-listeners/format-boundaries.js | 103 +++++++ .../src/component/event-listeners/index.js | 43 +++ .../event-listeners/input-and-selection.js | 254 ++++++++++++++++ .../event-listeners/select-object.js | 54 ++++ .../selection-change-compat.js | 53 ++++ packages/rich-text/src/component/index.js | 20 +- .../src/component/use-copy-handler.js | 49 ---- .../rich-text/src/component/use-delete.js | 44 --- .../src/component/use-format-boundaries.js | 110 ------- .../src/component/use-input-and-selection.js | 270 ------------------ .../src/component/use-select-object.js | 61 ---- .../component/use-selection-change-compat.js | 66 ----- 38 files changed, 1369 insertions(+), 1431 deletions(-) create mode 100644 packages/block-editor/src/components/rich-text/event-listeners/before-input-rules.js create mode 100644 packages/block-editor/src/components/rich-text/event-listeners/delete.js create mode 100644 packages/block-editor/src/components/rich-text/event-listeners/enter.js create mode 100644 packages/block-editor/src/components/rich-text/event-listeners/firefox-compat.js create mode 100644 packages/block-editor/src/components/rich-text/event-listeners/index.js create mode 100644 packages/block-editor/src/components/rich-text/event-listeners/input-events.js create mode 100644 packages/block-editor/src/components/rich-text/event-listeners/input-rules.js create mode 100644 packages/block-editor/src/components/rich-text/event-listeners/insert-replacement-text.js create mode 100644 packages/block-editor/src/components/rich-text/event-listeners/paste-handler.js create mode 100644 packages/block-editor/src/components/rich-text/event-listeners/remove-browser-shortcuts.js create mode 100644 packages/block-editor/src/components/rich-text/event-listeners/shortcuts.js create mode 100644 packages/block-editor/src/components/rich-text/event-listeners/undo-automatic-change.js delete mode 100644 packages/block-editor/src/components/rich-text/use-before-input-rules.js delete mode 100644 packages/block-editor/src/components/rich-text/use-delete.js delete mode 100644 packages/block-editor/src/components/rich-text/use-enter.js delete mode 100644 packages/block-editor/src/components/rich-text/use-firefox-compat.js delete mode 100644 packages/block-editor/src/components/rich-text/use-input-events.js delete mode 100644 packages/block-editor/src/components/rich-text/use-input-rules.js delete mode 100644 packages/block-editor/src/components/rich-text/use-insert-replacement-text.js delete mode 100644 packages/block-editor/src/components/rich-text/use-paste-handler.js delete mode 100644 packages/block-editor/src/components/rich-text/use-remove-browser-shortcuts.js delete mode 100644 packages/block-editor/src/components/rich-text/use-shortcuts.js delete mode 100644 packages/block-editor/src/components/rich-text/use-undo-automatic-change.js create mode 100644 packages/rich-text/src/component/event-listeners/copy-handler.js create mode 100644 packages/rich-text/src/component/event-listeners/delete.js create mode 100644 packages/rich-text/src/component/event-listeners/format-boundaries.js create mode 100644 packages/rich-text/src/component/event-listeners/index.js create mode 100644 packages/rich-text/src/component/event-listeners/input-and-selection.js create mode 100644 packages/rich-text/src/component/event-listeners/select-object.js create mode 100644 packages/rich-text/src/component/event-listeners/selection-change-compat.js delete mode 100644 packages/rich-text/src/component/use-copy-handler.js delete mode 100644 packages/rich-text/src/component/use-delete.js delete mode 100644 packages/rich-text/src/component/use-format-boundaries.js delete mode 100644 packages/rich-text/src/component/use-input-and-selection.js delete mode 100644 packages/rich-text/src/component/use-select-object.js delete mode 100644 packages/rich-text/src/component/use-selection-change-compat.js diff --git a/packages/block-editor/src/components/rich-text/event-listeners/before-input-rules.js b/packages/block-editor/src/components/rich-text/event-listeners/before-input-rules.js new file mode 100644 index 0000000000000..e02b059550bd1 --- /dev/null +++ b/packages/block-editor/src/components/rich-text/event-listeners/before-input-rules.js @@ -0,0 +1,92 @@ +/** + * WordPress dependencies + */ +import { insert, isCollapsed } from '@wordpress/rich-text'; +import { applyFilters } from '@wordpress/hooks'; + +/** + * Internal dependencies + */ +import { store as blockEditorStore } from '../../../store'; + +/** + * When typing over a selection, the selection will we wrapped by a matching + * character pair. The second character is optional, it defaults to the first + * character. + * + * @type {string[]} Array of character pairs. + */ +const wrapSelectionSettings = [ '`', '"', "'", '“”', '‘’' ]; + +export default ( props ) => ( element ) => { + function onInput( event ) { + const { inputType, data } = event; + const { value, onChange, registry } = props.current; + + // Only run the rules when inserting text. + if ( inputType !== 'insertText' ) { + return; + } + + if ( isCollapsed( value ) ) { + return; + } + + const pair = applyFilters( + 'blockEditor.wrapSelectionSettings', + wrapSelectionSettings + ).find( + ( [ startChar, endChar ] ) => startChar === data || endChar === data + ); + + if ( ! pair ) { + return; + } + + const [ startChar, endChar = startChar ] = pair; + const start = value.start; + const end = value.end + startChar.length; + + let newValue = insert( value, startChar, start, start ); + newValue = insert( newValue, endChar, end, end ); + + const { + __unstableMarkLastChangeAsPersistent, + __unstableMarkAutomaticChange, + } = registry.dispatch( blockEditorStore ); + + __unstableMarkLastChangeAsPersistent(); + onChange( newValue ); + __unstableMarkAutomaticChange(); + + const init = {}; + + for ( const key in event ) { + init[ key ] = event[ key ]; + } + + init.data = endChar; + + const { ownerDocument } = element; + const { defaultView } = ownerDocument; + const newEvent = new defaultView.InputEvent( 'input', init ); + + // Dispatch an `input` event with the new data. This will trigger the + // input rules. + // Postpone the `input` to the next event loop tick so that the dispatch + // doesn't happen synchronously in the middle of `beforeinput` dispatch. + // This is closer to how native `input` event would be timed, and also + // makes sure that the `input` event is dispatched only after the `onChange` + // call few lines above has fully updated the data store state and rerendered + // all affected components. + window.queueMicrotask( () => { + event.target.dispatchEvent( newEvent ); + } ); + event.preventDefault(); + } + + element.addEventListener( 'beforeinput', onInput ); + return () => { + element.removeEventListener( 'beforeinput', onInput ); + }; +}; diff --git a/packages/block-editor/src/components/rich-text/event-listeners/delete.js b/packages/block-editor/src/components/rich-text/event-listeners/delete.js new file mode 100644 index 0000000000000..ae3fd733bb94e --- /dev/null +++ b/packages/block-editor/src/components/rich-text/event-listeners/delete.js @@ -0,0 +1,53 @@ +/** + * WordPress dependencies + */ +import { DELETE, BACKSPACE } from '@wordpress/keycodes'; +import { isCollapsed, isEmpty } from '@wordpress/rich-text'; + +export default ( props ) => ( element ) => { + function onKeyDown( event ) { + const { keyCode } = event; + + if ( event.defaultPrevented ) { + return; + } + + const { value, onMerge, onRemove } = props.current; + + if ( keyCode === DELETE || keyCode === BACKSPACE ) { + const { start, end, text } = value; + const isReverse = keyCode === BACKSPACE; + const hasActiveFormats = + value.activeFormats && !! value.activeFormats.length; + + // Only process delete if the key press occurs at an uncollapsed edge. + if ( + ! isCollapsed( value ) || + hasActiveFormats || + ( isReverse && start !== 0 ) || + ( ! isReverse && end !== text.length ) + ) { + return; + } + + if ( onMerge ) { + onMerge( ! isReverse ); + } + + // Only handle remove on Backspace. This serves dual-purpose of being + // an intentional user interaction distinguishing between Backspace and + // Delete to remove the empty field, but also to avoid merge & remove + // causing destruction of two fields (merge, then removed merged). + else if ( onRemove && isEmpty( value ) && isReverse ) { + onRemove( ! isReverse ); + } + + event.preventDefault(); + } + } + + element.addEventListener( 'keydown', onKeyDown ); + return () => { + element.removeEventListener( 'keydown', onKeyDown ); + }; +}; diff --git a/packages/block-editor/src/components/rich-text/event-listeners/enter.js b/packages/block-editor/src/components/rich-text/event-listeners/enter.js new file mode 100644 index 0000000000000..8bff9b8f170cc --- /dev/null +++ b/packages/block-editor/src/components/rich-text/event-listeners/enter.js @@ -0,0 +1,104 @@ +/** + * WordPress dependencies + */ +import { ENTER } from '@wordpress/keycodes'; +import { insert, remove } from '@wordpress/rich-text'; +import { getBlockTransforms, findTransform } from '@wordpress/blocks'; + +/** + * Internal dependencies + */ +import { store as blockEditorStore } from '../../../store'; +import { splitValue } from '../split-value'; + +export default ( props ) => ( element ) => { + function onKeyDown( event ) { + if ( event.target.contentEditable !== 'true' ) { + return; + } + + if ( event.defaultPrevented ) { + return; + } + + if ( event.keyCode !== ENTER ) { + return; + } + + const { + removeEditorOnlyFormats, + value, + onReplace, + onSplit, + onChange, + disableLineBreaks, + onSplitAtEnd, + onSplitAtDoubleLineEnd, + registry, + } = props.current; + + event.preventDefault(); + + const _value = { ...value }; + _value.formats = removeEditorOnlyFormats( value ); + const canSplit = onReplace && onSplit; + + if ( onReplace ) { + const transforms = getBlockTransforms( 'from' ).filter( + ( { type } ) => type === 'enter' + ); + const transformation = findTransform( transforms, ( item ) => { + return item.regExp.test( _value.text ); + } ); + + if ( transformation ) { + onReplace( [ + transformation.transform( { + content: _value.text, + } ), + ] ); + registry + .dispatch( blockEditorStore ) + .__unstableMarkAutomaticChange(); + return; + } + } + + const { text, start, end } = _value; + + if ( event.shiftKey ) { + if ( ! disableLineBreaks ) { + onChange( insert( _value, '\n' ) ); + } + } else if ( canSplit ) { + splitValue( { + value: _value, + onReplace, + onSplit, + } ); + } else if ( onSplitAtEnd && start === end && end === text.length ) { + onSplitAtEnd(); + } else if ( + // For some blocks it's desirable to split at the end of the + // block when there are two line breaks at the end of the + // block, so triple Enter exits the block. + onSplitAtDoubleLineEnd && + start === end && + end === text.length && + text.slice( -2 ) === '\n\n' + ) { + registry.batch( () => { + _value.start = _value.end - 2; + onChange( remove( _value ) ); + onSplitAtDoubleLineEnd(); + } ); + } else if ( ! disableLineBreaks ) { + onChange( insert( _value, '\n' ) ); + } + } + + element.addEventListener( 'keydown', onKeyDown ); + return () => { + element.removeEventListener( 'keydown', onKeyDown ); + }; +}; diff --git a/packages/block-editor/src/components/rich-text/event-listeners/firefox-compat.js b/packages/block-editor/src/components/rich-text/event-listeners/firefox-compat.js new file mode 100644 index 0000000000000..b6b859d346fd3 --- /dev/null +++ b/packages/block-editor/src/components/rich-text/event-listeners/firefox-compat.js @@ -0,0 +1,31 @@ +/** + * Internal dependencies + */ +import { store as blockEditorStore } from '../../../store'; + +export default ( props ) => ( element ) => { + function onFocus() { + const { registry } = props.current; + if ( ! registry.select( blockEditorStore ).isMultiSelecting() ) { + return; + } + + // This is a little hack to work around focus issues with nested + // editable elements in Firefox. For some reason the editable child + // element sometimes regains focus, while it should not be focusable + // and focus should remain on the editable parent element. + // To do: try to find the cause of the shifting focus. + const parentEditable = element.parentElement.closest( + '[contenteditable="true"]' + ); + + if ( parentEditable ) { + parentEditable.focus(); + } + } + + element.addEventListener( 'focus', onFocus ); + return () => { + element.removeEventListener( 'focus', onFocus ); + }; +}; diff --git a/packages/block-editor/src/components/rich-text/event-listeners/index.js b/packages/block-editor/src/components/rich-text/event-listeners/index.js new file mode 100644 index 0000000000000..46e721dfe5cf7 --- /dev/null +++ b/packages/block-editor/src/components/rich-text/event-listeners/index.js @@ -0,0 +1,56 @@ +/** + * WordPress dependencies + */ +import { useMemo, useRef } from '@wordpress/element'; +import { useRefEffect } from '@wordpress/compose'; + +/** + * Internal dependencies + */ +import beforeInputRules from './before-input-rules'; +import inputRules from './input-rules'; +import insertReplacementText from './insert-replacement-text'; +import removeBrowserShortcuts from './remove-browser-shortcuts'; +import shortcuts from './shortcuts'; +import inputEvents from './input-events'; +import undoAutomaticChange from './undo-automatic-change'; +import pasteHandler from './paste-handler'; +import _delete from './delete'; +import enter from './enter'; +import firefoxCompat from './firefox-compat'; + +const allEventListeners = [ + beforeInputRules, + inputRules, + insertReplacementText, + removeBrowserShortcuts, + shortcuts, + inputEvents, + undoAutomaticChange, + pasteHandler, + _delete, + enter, + firefoxCompat, +]; + +export function useEventListeners( props ) { + const propsRef = useRef( props ); + propsRef.current = props; + const refEffects = useMemo( + () => allEventListeners.map( ( refEffect ) => refEffect( propsRef ) ), + [ propsRef ] + ); + + return useRefEffect( + ( element ) => { + if ( ! props.isSelected ) { + return; + } + const cleanups = refEffects.map( ( effect ) => effect( element ) ); + return () => { + cleanups.forEach( ( cleanup ) => cleanup() ); + }; + }, + [ refEffects, props.isSelected ] + ); +} diff --git a/packages/block-editor/src/components/rich-text/event-listeners/input-events.js b/packages/block-editor/src/components/rich-text/event-listeners/input-events.js new file mode 100644 index 0000000000000..e16dc8f1dd0f5 --- /dev/null +++ b/packages/block-editor/src/components/rich-text/event-listeners/input-events.js @@ -0,0 +1,13 @@ +export default ( props ) => ( element ) => { + const { inputEvents } = props.current; + function onInput( event ) { + for ( const keyboardShortcut of inputEvents.current ) { + keyboardShortcut( event ); + } + } + + element.addEventListener( 'input', onInput ); + return () => { + element.removeEventListener( 'input', onInput ); + }; +}; diff --git a/packages/block-editor/src/components/rich-text/event-listeners/input-rules.js b/packages/block-editor/src/components/rich-text/event-listeners/input-rules.js new file mode 100644 index 0000000000000..f316348994c85 --- /dev/null +++ b/packages/block-editor/src/components/rich-text/event-listeners/input-rules.js @@ -0,0 +1,146 @@ +/** + * WordPress dependencies + */ +import { insert, toHTMLString } from '@wordpress/rich-text'; +import { getBlockTransforms, findTransform } from '@wordpress/blocks'; + +/** + * Internal dependencies + */ +import { store as blockEditorStore } from '../../../store'; +import { preventEventDiscovery } from '../prevent-event-discovery'; +import { + retrieveSelectedAttribute, + START_OF_SELECTED_AREA, +} from '../../../utils/selection'; + +function findSelection( blocks ) { + let i = blocks.length; + + while ( i-- ) { + const attributeKey = retrieveSelectedAttribute( + blocks[ i ].attributes + ); + + if ( attributeKey ) { + blocks[ i ].attributes[ attributeKey ] = blocks[ i ].attributes[ + attributeKey + ] + // To do: refactor this to use rich text's selection instead, so + // we no longer have to use on this hack inserting a special + // character. + .toString() + .replace( START_OF_SELECTED_AREA, '' ); + return [ blocks[ i ].clientId, attributeKey, 0, 0 ]; + } + + const nestedSelection = findSelection( blocks[ i ].innerBlocks ); + + if ( nestedSelection ) { + return nestedSelection; + } + } + + return []; +} + +export default ( props ) => ( element ) => { + function inputRule() { + const { getValue, onReplace, selectionChange, registry } = + props.current; + + if ( ! onReplace ) { + return; + } + + // We must use getValue() here because value may be update + // asynchronously. + const value = getValue(); + const { start, text } = value; + const characterBefore = text.slice( start - 1, start ); + + // The character right before the caret must be a plain space. + if ( characterBefore !== ' ' ) { + return; + } + + const trimmedTextBefore = text.slice( 0, start ).trim(); + const prefixTransforms = getBlockTransforms( 'from' ).filter( + ( { type } ) => type === 'prefix' + ); + const transformation = findTransform( + prefixTransforms, + ( { prefix } ) => { + return trimmedTextBefore === prefix; + } + ); + + if ( ! transformation ) { + return; + } + + const content = toHTMLString( { + value: insert( value, START_OF_SELECTED_AREA, 0, start ), + } ); + const block = transformation.transform( content ); + + selectionChange( ...findSelection( [ block ] ) ); + onReplace( [ block ] ); + registry.dispatch( blockEditorStore ).__unstableMarkAutomaticChange(); + + return true; + } + + function onInput( event ) { + const { inputType, type } = event; + const { + getValue, + onChange, + __unstableAllowPrefixTransformations, + formatTypes, + registry, + } = props.current; + + // Only run input rules when inserting text. + if ( inputType !== 'insertText' && type !== 'compositionend' ) { + return; + } + + if ( __unstableAllowPrefixTransformations && inputRule() ) { + return; + } + + const value = getValue(); + const transformed = formatTypes.reduce( + ( accumlator, { __unstableInputRule } ) => { + if ( __unstableInputRule ) { + accumlator = __unstableInputRule( accumlator ); + } + + return accumlator; + }, + preventEventDiscovery( value ) + ); + + const { + __unstableMarkLastChangeAsPersistent, + __unstableMarkAutomaticChange, + } = registry.dispatch( blockEditorStore ); + + if ( transformed !== value ) { + __unstableMarkLastChangeAsPersistent(); + onChange( { + ...transformed, + activeFormats: value.activeFormats, + } ); + __unstableMarkAutomaticChange(); + } + } + + element.addEventListener( 'input', onInput ); + element.addEventListener( 'compositionend', onInput ); + return () => { + element.removeEventListener( 'input', onInput ); + element.removeEventListener( 'compositionend', onInput ); + }; +}; diff --git a/packages/block-editor/src/components/rich-text/event-listeners/insert-replacement-text.js b/packages/block-editor/src/components/rich-text/event-listeners/insert-replacement-text.js new file mode 100644 index 0000000000000..78b862eb4ac9e --- /dev/null +++ b/packages/block-editor/src/components/rich-text/event-listeners/insert-replacement-text.js @@ -0,0 +1,28 @@ +/** + * Internal dependencies + */ +import { store as blockEditorStore } from '../../../store'; + +/** + * When the browser is about to auto correct, add an undo level so the user can + * revert the change. + * + * @param {Object} props + */ +export default ( props ) => ( element ) => { + function onInput( event ) { + if ( event.inputType !== 'insertReplacementText' ) { + return; + } + + const { registry } = props.current; + registry + .dispatch( blockEditorStore ) + .__unstableMarkLastChangeAsPersistent(); + } + + element.addEventListener( 'beforeinput', onInput ); + return () => { + element.removeEventListener( 'beforeinput', onInput ); + }; +}; diff --git a/packages/block-editor/src/components/rich-text/event-listeners/paste-handler.js b/packages/block-editor/src/components/rich-text/event-listeners/paste-handler.js new file mode 100644 index 0000000000000..04b7309441f1b --- /dev/null +++ b/packages/block-editor/src/components/rich-text/event-listeners/paste-handler.js @@ -0,0 +1,169 @@ +/** + * WordPress dependencies + */ +import { + pasteHandler, + findTransform, + getBlockTransforms, +} from '@wordpress/blocks'; +import { isEmpty, insert, create } from '@wordpress/rich-text'; +import { isURL } from '@wordpress/url'; + +/** + * Internal dependencies + */ +import { addActiveFormats } from '../utils'; +import { splitValue } from '../split-value'; +import { getPasteEventData } from '../../../utils/pasting'; + +/** @typedef {import('@wordpress/rich-text').RichTextValue} RichTextValue */ + +export default ( props ) => ( element ) => { + function _onPaste( event ) { + const { + disableFormats, + onChange, + value, + formatTypes, + tagName, + onReplace, + onSplit, + __unstableEmbedURLOnPaste, + pastePlainText, + } = props.current; + + const { plainText, html, files } = getPasteEventData( event ); + + event.preventDefault(); + + // Allows us to ask for this information when we get a report. + window.console.log( 'Received HTML:\n\n', html ); + window.console.log( 'Received plain text:\n\n', plainText ); + + if ( disableFormats ) { + onChange( insert( value, plainText ) ); + return; + } + + const isInternal = + event.clipboardData.getData( 'rich-text' ) === 'true'; + + function pasteInline( content ) { + const transformed = formatTypes.reduce( + ( accumulator, { __unstablePasteRule } ) => { + // Only allow one transform. + if ( __unstablePasteRule && accumulator === value ) { + accumulator = __unstablePasteRule( value, { + html, + plainText, + } ); + } + + return accumulator; + }, + value + ); + if ( transformed !== value ) { + onChange( transformed ); + } else { + const valueToInsert = create( { html: content } ); + addActiveFormats( valueToInsert, value.activeFormats ); + onChange( insert( value, valueToInsert ) ); + } + } + + // If the data comes from a rich text instance, we can directly use it + // without filtering the data. The filters are only meant for externally + // pasted content and remove inline styles. + if ( isInternal ) { + pasteInline( html ); + return; + } + + if ( pastePlainText ) { + onChange( insert( value, create( { text: plainText } ) ) ); + return; + } + + if ( files?.length ) { + // Allows us to ask for this information when we get a report. + // eslint-disable-next-line no-console + window.console.log( 'Received items:\n\n', files ); + + const fromTransforms = getBlockTransforms( 'from' ); + const blocks = files + .reduce( ( accumulator, file ) => { + const transformation = findTransform( + fromTransforms, + ( transform ) => + transform.type === 'files' && + transform.isMatch( [ file ] ) + ); + if ( transformation ) { + accumulator.push( + transformation.transform( [ file ] ) + ); + } + return accumulator; + }, [] ) + .flat(); + if ( ! blocks.length ) { + return; + } + + if ( onReplace && isEmpty( value ) ) { + onReplace( blocks ); + } else { + splitValue( { + value, + pastedBlocks: blocks, + onReplace, + onSplit, + } ); + } + + return; + } + + let mode = onReplace && onSplit ? 'AUTO' : 'INLINE'; + + const trimmedPlainText = plainText.trim(); + + if ( + __unstableEmbedURLOnPaste && + isEmpty( value ) && + isURL( trimmedPlainText ) && + // For the link pasting feature, allow only http(s) protocols. + /^https?:/.test( trimmedPlainText ) + ) { + mode = 'BLOCKS'; + } + + const content = pasteHandler( { + HTML: html, + plainText, + mode, + tagName, + } ); + + if ( typeof content === 'string' ) { + pasteInline( content ); + } else if ( content.length > 0 ) { + if ( onReplace && isEmpty( value ) ) { + onReplace( content, content.length - 1, -1 ); + } else { + splitValue( { + value, + pastedBlocks: content, + onReplace, + onSplit, + } ); + } + } + } + + element.addEventListener( 'paste', _onPaste ); + return () => { + element.removeEventListener( 'paste', _onPaste ); + }; +}; diff --git a/packages/block-editor/src/components/rich-text/event-listeners/remove-browser-shortcuts.js b/packages/block-editor/src/components/rich-text/event-listeners/remove-browser-shortcuts.js new file mode 100644 index 0000000000000..7a057022dd2a8 --- /dev/null +++ b/packages/block-editor/src/components/rich-text/event-listeners/remove-browser-shortcuts.js @@ -0,0 +1,24 @@ +/** + * WordPress dependencies + */ +import { isKeyboardEvent } from '@wordpress/keycodes'; + +/** + * Hook to prevent default behaviors for key combinations otherwise handled + * internally by RichText. + */ +export default () => ( node ) => { + function onKeydown( event ) { + if ( + isKeyboardEvent.primary( event, 'z' ) || + isKeyboardEvent.primary( event, 'y' ) || + isKeyboardEvent.primaryShift( event, 'z' ) + ) { + event.preventDefault(); + } + } + node.addEventListener( 'keydown', onKeydown ); + return () => { + node.removeEventListener( 'keydown', onKeydown ); + }; +}; diff --git a/packages/block-editor/src/components/rich-text/event-listeners/shortcuts.js b/packages/block-editor/src/components/rich-text/event-listeners/shortcuts.js new file mode 100644 index 0000000000000..bb51a72958815 --- /dev/null +++ b/packages/block-editor/src/components/rich-text/event-listeners/shortcuts.js @@ -0,0 +1,13 @@ +export default ( props ) => ( element ) => { + const { keyboardShortcuts } = props.current; + function onKeyDown( event ) { + for ( const keyboardShortcut of keyboardShortcuts.current ) { + keyboardShortcut( event ); + } + } + + element.addEventListener( 'keydown', onKeyDown ); + return () => { + element.removeEventListener( 'keydown', onKeyDown ); + }; +}; diff --git a/packages/block-editor/src/components/rich-text/event-listeners/undo-automatic-change.js b/packages/block-editor/src/components/rich-text/event-listeners/undo-automatic-change.js new file mode 100644 index 0000000000000..6bcfd0822f7fe --- /dev/null +++ b/packages/block-editor/src/components/rich-text/event-listeners/undo-automatic-change.js @@ -0,0 +1,45 @@ +/** + * WordPress dependencies + */ +import { BACKSPACE, ESCAPE } from '@wordpress/keycodes'; + +/** + * Internal dependencies + */ +import { store as blockEditorStore } from '../../../store'; + +export default ( props ) => ( element ) => { + function onKeyDown( event ) { + const { keyCode } = event; + + if ( event.defaultPrevented ) { + return; + } + + if ( keyCode !== BACKSPACE && keyCode !== ESCAPE ) { + return; + } + + const { registry } = props.current; + const { didAutomaticChange, getSettings } = + registry.select( blockEditorStore ); + + const { __experimentalUndo } = getSettings(); + + if ( ! __experimentalUndo ) { + return; + } + + if ( ! didAutomaticChange() ) { + return; + } + + event.preventDefault(); + __experimentalUndo(); + } + + element.addEventListener( 'keydown', onKeyDown ); + return () => { + element.removeEventListener( 'keydown', onKeyDown ); + }; +}; diff --git a/packages/block-editor/src/components/rich-text/index.js b/packages/block-editor/src/components/rich-text/index.js index fac1a594b1c95..8871f5eeafef8 100644 --- a/packages/block-editor/src/components/rich-text/index.js +++ b/packages/block-editor/src/components/rich-text/index.js @@ -12,7 +12,7 @@ import { forwardRef, createContext, } from '@wordpress/element'; -import { useDispatch, useSelect } from '@wordpress/data'; +import { useDispatch, useRegistry, useSelect } from '@wordpress/data'; import { useMergeRefs, useInstanceId } from '@wordpress/compose'; import { __unstableUseRichText as useRichText, @@ -29,19 +29,9 @@ import { useBlockEditContext } from '../block-edit'; import { blockBindingsKey, isPreviewModeKey } from '../block-edit/context'; import FormatToolbarContainer from './format-toolbar-container'; import { store as blockEditorStore } from '../../store'; -import { useUndoAutomaticChange } from './use-undo-automatic-change'; import { useMarkPersistent } from './use-mark-persistent'; -import { usePasteHandler } from './use-paste-handler'; -import { useBeforeInputRules } from './use-before-input-rules'; -import { useInputRules } from './use-input-rules'; -import { useDelete } from './use-delete'; -import { useEnter } from './use-enter'; import { useFormatTypes } from './use-format-types'; -import { useRemoveBrowserShortcuts } from './use-remove-browser-shortcuts'; -import { useShortcuts } from './use-shortcuts'; -import { useInputEvents } from './use-input-events'; -import { useInsertReplacementText } from './use-insert-replacement-text'; -import { useFirefoxCompat } from './use-firefox-compat'; +import { useEventListeners } from './event-listeners'; import FormatEdit from './format-edit'; import { getAllowedFormats } from './utils'; import { Content, valueToHTMLString } from './content'; @@ -357,6 +347,7 @@ export function RichTextWrapper( anchorRef.current?.focus(); } + const registry = useRegistry(); const TagName = tagName; return ( <> @@ -400,48 +391,30 @@ export function RichTextWrapper( forwardedRef, autocompleteProps.ref, props.ref, - useBeforeInputRules( { value, onChange } ), - useInputRules( { + useEventListeners( { + registry, getValue, onChange, __unstableAllowPrefixTransformations, formatTypes, onReplace, selectionChange, - } ), - useInsertReplacementText(), - useRemoveBrowserShortcuts(), - useShortcuts( keyboardShortcuts ), - useInputEvents( inputEvents ), - useUndoAutomaticChange(), - usePasteHandler( { isSelected, disableFormats, - onChange, value, - formatTypes, tagName, - onReplace, onSplit, __unstableEmbedURLOnPaste, pastePlainText, - } ), - useDelete( { - value, onMerge, onRemove, - } ), - useEnter( { removeEditorOnlyFormats, - value, - onReplace, - onSplit, - onChange, disableLineBreaks, onSplitAtEnd, onSplitAtDoubleLineEnd, + keyboardShortcuts, + inputEvents, } ), - useFirefoxCompat(), anchorRef, ] ) } contentEditable={ ! shouldDisableEditing } diff --git a/packages/block-editor/src/components/rich-text/use-before-input-rules.js b/packages/block-editor/src/components/rich-text/use-before-input-rules.js deleted file mode 100644 index f6f036f039b50..0000000000000 --- a/packages/block-editor/src/components/rich-text/use-before-input-rules.js +++ /dev/null @@ -1,99 +0,0 @@ -/** - * WordPress dependencies - */ -import { useRef } from '@wordpress/element'; -import { useRefEffect } from '@wordpress/compose'; -import { insert, isCollapsed } from '@wordpress/rich-text'; -import { useDispatch } from '@wordpress/data'; -import { applyFilters } from '@wordpress/hooks'; - -/** - * Internal dependencies - */ -import { store as blockEditorStore } from '../../store'; - -/** - * When typing over a selection, the selection will we wrapped by a matching - * character pair. The second character is optional, it defaults to the first - * character. - * - * @type {string[]} Array of character pairs. - */ -const wrapSelectionSettings = [ '`', '"', "'", '“”', '‘’' ]; - -export function useBeforeInputRules( props ) { - const { - __unstableMarkLastChangeAsPersistent, - __unstableMarkAutomaticChange, - } = useDispatch( blockEditorStore ); - const propsRef = useRef( props ); - propsRef.current = props; - return useRefEffect( ( element ) => { - function onInput( event ) { - const { inputType, data } = event; - const { value, onChange } = propsRef.current; - - // Only run the rules when inserting text. - if ( inputType !== 'insertText' ) { - return; - } - - if ( isCollapsed( value ) ) { - return; - } - - const pair = applyFilters( - 'blockEditor.wrapSelectionSettings', - wrapSelectionSettings - ).find( - ( [ startChar, endChar ] ) => - startChar === data || endChar === data - ); - - if ( ! pair ) { - return; - } - - const [ startChar, endChar = startChar ] = pair; - const start = value.start; - const end = value.end + startChar.length; - - let newValue = insert( value, startChar, start, start ); - newValue = insert( newValue, endChar, end, end ); - - __unstableMarkLastChangeAsPersistent(); - onChange( newValue ); - __unstableMarkAutomaticChange(); - - const init = {}; - - for ( const key in event ) { - init[ key ] = event[ key ]; - } - - init.data = endChar; - - const { ownerDocument } = element; - const { defaultView } = ownerDocument; - const newEvent = new defaultView.InputEvent( 'input', init ); - - // Dispatch an `input` event with the new data. This will trigger the - // input rules. - // Postpone the `input` to the next event loop tick so that the dispatch - // doesn't happen synchronously in the middle of `beforeinput` dispatch. - // This is closer to how native `input` event would be timed, and also - // makes sure that the `input` event is dispatched only after the `onChange` - // call few lines above has fully updated the data store state and rerendered - // all affected components. - window.queueMicrotask( () => { - event.target.dispatchEvent( newEvent ); - } ); - event.preventDefault(); - } - - element.addEventListener( 'beforeinput', onInput ); - return () => { - element.removeEventListener( 'beforeinput', onInput ); - }; - }, [] ); -} diff --git a/packages/block-editor/src/components/rich-text/use-delete.js b/packages/block-editor/src/components/rich-text/use-delete.js deleted file mode 100644 index fbf025a5d4ea4..0000000000000 --- a/packages/block-editor/src/components/rich-text/use-delete.js +++ /dev/null @@ -1,59 +0,0 @@ -/** - * WordPress dependencies - */ -import { useRef } from '@wordpress/element'; -import { useRefEffect } from '@wordpress/compose'; -import { DELETE, BACKSPACE } from '@wordpress/keycodes'; -import { isCollapsed, isEmpty } from '@wordpress/rich-text'; - -export function useDelete( props ) { - const propsRef = useRef( props ); - propsRef.current = props; - return useRefEffect( ( element ) => { - function onKeyDown( event ) { - const { keyCode } = event; - - if ( event.defaultPrevented ) { - return; - } - - const { value, onMerge, onRemove } = propsRef.current; - - if ( keyCode === DELETE || keyCode === BACKSPACE ) { - const { start, end, text } = value; - const isReverse = keyCode === BACKSPACE; - const hasActiveFormats = - value.activeFormats && !! value.activeFormats.length; - - // Only process delete if the key press occurs at an uncollapsed edge. - if ( - ! isCollapsed( value ) || - hasActiveFormats || - ( isReverse && start !== 0 ) || - ( ! isReverse && end !== text.length ) - ) { - return; - } - - if ( onMerge ) { - onMerge( ! isReverse ); - } - - // Only handle remove on Backspace. This serves dual-purpose of being - // an intentional user interaction distinguishing between Backspace and - // Delete to remove the empty field, but also to avoid merge & remove - // causing destruction of two fields (merge, then removed merged). - else if ( onRemove && isEmpty( value ) && isReverse ) { - onRemove( ! isReverse ); - } - - event.preventDefault(); - } - } - - element.addEventListener( 'keydown', onKeyDown ); - return () => { - element.removeEventListener( 'keydown', onKeyDown ); - }; - }, [] ); -} diff --git a/packages/block-editor/src/components/rich-text/use-enter.js b/packages/block-editor/src/components/rich-text/use-enter.js deleted file mode 100644 index 6b40a82d72d4b..0000000000000 --- a/packages/block-editor/src/components/rich-text/use-enter.js +++ /dev/null @@ -1,110 +0,0 @@ -/** - * WordPress dependencies - */ -import { useRef } from '@wordpress/element'; -import { useRefEffect } from '@wordpress/compose'; -import { ENTER } from '@wordpress/keycodes'; -import { insert, remove } from '@wordpress/rich-text'; -import { getBlockTransforms, findTransform } from '@wordpress/blocks'; -import { useDispatch, useRegistry } from '@wordpress/data'; - -/** - * Internal dependencies - */ -import { store as blockEditorStore } from '../../store'; -import { splitValue } from './split-value'; - -export function useEnter( props ) { - const registry = useRegistry(); - const { __unstableMarkAutomaticChange } = useDispatch( blockEditorStore ); - const propsRef = useRef( props ); - propsRef.current = props; - return useRefEffect( ( element ) => { - function onKeyDown( event ) { - if ( event.target.contentEditable !== 'true' ) { - return; - } - - if ( event.defaultPrevented ) { - return; - } - - if ( event.keyCode !== ENTER ) { - return; - } - - const { - removeEditorOnlyFormats, - value, - onReplace, - onSplit, - onChange, - disableLineBreaks, - onSplitAtEnd, - onSplitAtDoubleLineEnd, - } = propsRef.current; - - event.preventDefault(); - - const _value = { ...value }; - _value.formats = removeEditorOnlyFormats( value ); - const canSplit = onReplace && onSplit; - - if ( onReplace ) { - const transforms = getBlockTransforms( 'from' ).filter( - ( { type } ) => type === 'enter' - ); - const transformation = findTransform( transforms, ( item ) => { - return item.regExp.test( _value.text ); - } ); - - if ( transformation ) { - onReplace( [ - transformation.transform( { - content: _value.text, - } ), - ] ); - __unstableMarkAutomaticChange(); - return; - } - } - - const { text, start, end } = _value; - - if ( event.shiftKey ) { - if ( ! disableLineBreaks ) { - onChange( insert( _value, '\n' ) ); - } - } else if ( canSplit ) { - splitValue( { - value: _value, - onReplace, - onSplit, - } ); - } else if ( onSplitAtEnd && start === end && end === text.length ) { - onSplitAtEnd(); - } else if ( - // For some blocks it's desirable to split at the end of the - // block when there are two line breaks at the end of the - // block, so triple Enter exits the block. - onSplitAtDoubleLineEnd && - start === end && - end === text.length && - text.slice( -2 ) === '\n\n' - ) { - registry.batch( () => { - _value.start = _value.end - 2; - onChange( remove( _value ) ); - onSplitAtDoubleLineEnd(); - } ); - } else if ( ! disableLineBreaks ) { - onChange( insert( _value, '\n' ) ); - } - } - - element.addEventListener( 'keydown', onKeyDown ); - return () => { - element.removeEventListener( 'keydown', onKeyDown ); - }; - }, [] ); -} diff --git a/packages/block-editor/src/components/rich-text/use-firefox-compat.js b/packages/block-editor/src/components/rich-text/use-firefox-compat.js deleted file mode 100644 index 59a5718508163..0000000000000 --- a/packages/block-editor/src/components/rich-text/use-firefox-compat.js +++ /dev/null @@ -1,39 +0,0 @@ -/** - * WordPress dependencies - */ -import { useRefEffect } from '@wordpress/compose'; -import { useSelect } from '@wordpress/data'; - -/** - * Internal dependencies - */ -import { store as blockEditorStore } from '../../store'; - -export function useFirefoxCompat() { - const { isMultiSelecting } = useSelect( blockEditorStore ); - return useRefEffect( ( element ) => { - function onFocus() { - if ( ! isMultiSelecting() ) { - return; - } - - // This is a little hack to work around focus issues with nested - // editable elements in Firefox. For some reason the editable child - // element sometimes regains focus, while it should not be focusable - // and focus should remain on the editable parent element. - // To do: try to find the cause of the shifting focus. - const parentEditable = element.parentElement.closest( - '[contenteditable="true"]' - ); - - if ( parentEditable ) { - parentEditable.focus(); - } - } - - element.addEventListener( 'focus', onFocus ); - return () => { - element.removeEventListener( 'focus', onFocus ); - }; - }, [] ); -} diff --git a/packages/block-editor/src/components/rich-text/use-input-events.js b/packages/block-editor/src/components/rich-text/use-input-events.js deleted file mode 100644 index 4305e126fda7e..0000000000000 --- a/packages/block-editor/src/components/rich-text/use-input-events.js +++ /dev/null @@ -1,19 +0,0 @@ -/** - * WordPress dependencies - */ -import { useRefEffect } from '@wordpress/compose'; - -export function useInputEvents( inputEvents ) { - return useRefEffect( ( element ) => { - function onInput( event ) { - for ( const keyboardShortcut of inputEvents.current ) { - keyboardShortcut( event ); - } - } - - element.addEventListener( 'input', onInput ); - return () => { - element.removeEventListener( 'input', onInput ); - }; - }, [] ); -} diff --git a/packages/block-editor/src/components/rich-text/use-input-rules.js b/packages/block-editor/src/components/rich-text/use-input-rules.js deleted file mode 100644 index ff871eb936323..0000000000000 --- a/packages/block-editor/src/components/rich-text/use-input-rules.js +++ /dev/null @@ -1,150 +0,0 @@ -/** - * WordPress dependencies - */ -import { useRef } from '@wordpress/element'; -import { useRefEffect } from '@wordpress/compose'; -import { insert, toHTMLString } from '@wordpress/rich-text'; -import { getBlockTransforms, findTransform } from '@wordpress/blocks'; -import { useDispatch } from '@wordpress/data'; - -/** - * Internal dependencies - */ -import { store as blockEditorStore } from '../../store'; -import { preventEventDiscovery } from './prevent-event-discovery'; -import { - retrieveSelectedAttribute, - START_OF_SELECTED_AREA, -} from '../../utils/selection'; - -function findSelection( blocks ) { - let i = blocks.length; - - while ( i-- ) { - const attributeKey = retrieveSelectedAttribute( - blocks[ i ].attributes - ); - - if ( attributeKey ) { - blocks[ i ].attributes[ attributeKey ] = blocks[ i ].attributes[ - attributeKey - ] - // To do: refactor this to use rich text's selection instead, so - // we no longer have to use on this hack inserting a special - // character. - .toString() - .replace( START_OF_SELECTED_AREA, '' ); - return [ blocks[ i ].clientId, attributeKey, 0, 0 ]; - } - - const nestedSelection = findSelection( blocks[ i ].innerBlocks ); - - if ( nestedSelection ) { - return nestedSelection; - } - } - - return []; -} - -export function useInputRules( props ) { - const { - __unstableMarkLastChangeAsPersistent, - __unstableMarkAutomaticChange, - } = useDispatch( blockEditorStore ); - const propsRef = useRef( props ); - propsRef.current = props; - return useRefEffect( ( element ) => { - function inputRule() { - const { getValue, onReplace, selectionChange } = propsRef.current; - - if ( ! onReplace ) { - return; - } - - // We must use getValue() here because value may be update - // asynchronously. - const value = getValue(); - const { start, text } = value; - const characterBefore = text.slice( start - 1, start ); - - // The character right before the caret must be a plain space. - if ( characterBefore !== ' ' ) { - return; - } - - const trimmedTextBefore = text.slice( 0, start ).trim(); - const prefixTransforms = getBlockTransforms( 'from' ).filter( - ( { type } ) => type === 'prefix' - ); - const transformation = findTransform( - prefixTransforms, - ( { prefix } ) => { - return trimmedTextBefore === prefix; - } - ); - - if ( ! transformation ) { - return; - } - - const content = toHTMLString( { - value: insert( value, START_OF_SELECTED_AREA, 0, start ), - } ); - const block = transformation.transform( content ); - - selectionChange( ...findSelection( [ block ] ) ); - onReplace( [ block ] ); - __unstableMarkAutomaticChange(); - - return true; - } - - function onInput( event ) { - const { inputType, type } = event; - const { - getValue, - onChange, - __unstableAllowPrefixTransformations, - formatTypes, - } = propsRef.current; - - // Only run input rules when inserting text. - if ( inputType !== 'insertText' && type !== 'compositionend' ) { - return; - } - - if ( __unstableAllowPrefixTransformations && inputRule() ) { - return; - } - - const value = getValue(); - const transformed = formatTypes.reduce( - ( accumlator, { __unstableInputRule } ) => { - if ( __unstableInputRule ) { - accumlator = __unstableInputRule( accumlator ); - } - - return accumlator; - }, - preventEventDiscovery( value ) - ); - - if ( transformed !== value ) { - __unstableMarkLastChangeAsPersistent(); - onChange( { - ...transformed, - activeFormats: value.activeFormats, - } ); - __unstableMarkAutomaticChange(); - } - } - - element.addEventListener( 'input', onInput ); - element.addEventListener( 'compositionend', onInput ); - return () => { - element.removeEventListener( 'input', onInput ); - element.removeEventListener( 'compositionend', onInput ); - }; - }, [] ); -} diff --git a/packages/block-editor/src/components/rich-text/use-insert-replacement-text.js b/packages/block-editor/src/components/rich-text/use-insert-replacement-text.js deleted file mode 100644 index e5ec7c23e906b..0000000000000 --- a/packages/block-editor/src/components/rich-text/use-insert-replacement-text.js +++ /dev/null @@ -1,31 +0,0 @@ -/** - * WordPress dependencies - */ -import { useRefEffect } from '@wordpress/compose'; -import { useDispatch } from '@wordpress/data'; - -/** - * Internal dependencies - */ -import { store as blockEditorStore } from '../../store'; - -/** - * When the browser is about to auto correct, add an undo level so the user can - * revert the change. - */ -export function useInsertReplacementText() { - const { __unstableMarkLastChangeAsPersistent } = - useDispatch( blockEditorStore ); - return useRefEffect( ( element ) => { - function onInput( event ) { - if ( event.inputType === 'insertReplacementText' ) { - __unstableMarkLastChangeAsPersistent(); - } - } - - element.addEventListener( 'beforeinput', onInput ); - return () => { - element.removeEventListener( 'beforeinput', onInput ); - }; - }, [] ); -} diff --git a/packages/block-editor/src/components/rich-text/use-paste-handler.js b/packages/block-editor/src/components/rich-text/use-paste-handler.js deleted file mode 100644 index fc75caa43615f..0000000000000 --- a/packages/block-editor/src/components/rich-text/use-paste-handler.js +++ /dev/null @@ -1,180 +0,0 @@ -/** - * WordPress dependencies - */ -import { useRef } from '@wordpress/element'; -import { useRefEffect } from '@wordpress/compose'; -import { - pasteHandler, - findTransform, - getBlockTransforms, -} from '@wordpress/blocks'; -import { isEmpty, insert, create } from '@wordpress/rich-text'; -import { isURL } from '@wordpress/url'; - -/** - * Internal dependencies - */ -import { addActiveFormats } from './utils'; -import { splitValue } from './split-value'; -import { getPasteEventData } from '../../utils/pasting'; - -/** @typedef {import('@wordpress/rich-text').RichTextValue} RichTextValue */ - -export function usePasteHandler( props ) { - const propsRef = useRef( props ); - propsRef.current = props; - return useRefEffect( ( element ) => { - function _onPaste( event ) { - const { - isSelected, - disableFormats, - onChange, - value, - formatTypes, - tagName, - onReplace, - onSplit, - __unstableEmbedURLOnPaste, - pastePlainText, - } = propsRef.current; - - if ( ! isSelected ) { - return; - } - - const { plainText, html, files } = getPasteEventData( event ); - - event.preventDefault(); - - // Allows us to ask for this information when we get a report. - window.console.log( 'Received HTML:\n\n', html ); - window.console.log( 'Received plain text:\n\n', plainText ); - - if ( disableFormats ) { - onChange( insert( value, plainText ) ); - return; - } - - const isInternal = - event.clipboardData.getData( 'rich-text' ) === 'true'; - - function pasteInline( content ) { - const transformed = formatTypes.reduce( - ( accumulator, { __unstablePasteRule } ) => { - // Only allow one transform. - if ( __unstablePasteRule && accumulator === value ) { - accumulator = __unstablePasteRule( value, { - html, - plainText, - } ); - } - - return accumulator; - }, - value - ); - if ( transformed !== value ) { - onChange( transformed ); - } else { - const valueToInsert = create( { html: content } ); - addActiveFormats( valueToInsert, value.activeFormats ); - onChange( insert( value, valueToInsert ) ); - } - } - - // If the data comes from a rich text instance, we can directly use it - // without filtering the data. The filters are only meant for externally - // pasted content and remove inline styles. - if ( isInternal ) { - pasteInline( html ); - return; - } - - if ( pastePlainText ) { - onChange( insert( value, create( { text: plainText } ) ) ); - return; - } - - if ( files?.length ) { - // Allows us to ask for this information when we get a report. - // eslint-disable-next-line no-console - window.console.log( 'Received items:\n\n', files ); - - const fromTransforms = getBlockTransforms( 'from' ); - const blocks = files - .reduce( ( accumulator, file ) => { - const transformation = findTransform( - fromTransforms, - ( transform ) => - transform.type === 'files' && - transform.isMatch( [ file ] ) - ); - if ( transformation ) { - accumulator.push( - transformation.transform( [ file ] ) - ); - } - return accumulator; - }, [] ) - .flat(); - if ( ! blocks.length ) { - return; - } - - if ( onReplace && isEmpty( value ) ) { - onReplace( blocks ); - } else { - splitValue( { - value, - pastedBlocks: blocks, - onReplace, - onSplit, - } ); - } - - return; - } - - let mode = onReplace && onSplit ? 'AUTO' : 'INLINE'; - - const trimmedPlainText = plainText.trim(); - - if ( - __unstableEmbedURLOnPaste && - isEmpty( value ) && - isURL( trimmedPlainText ) && - // For the link pasting feature, allow only http(s) protocols. - /^https?:/.test( trimmedPlainText ) - ) { - mode = 'BLOCKS'; - } - - const content = pasteHandler( { - HTML: html, - plainText, - mode, - tagName, - } ); - - if ( typeof content === 'string' ) { - pasteInline( content ); - } else if ( content.length > 0 ) { - if ( onReplace && isEmpty( value ) ) { - onReplace( content, content.length - 1, -1 ); - } else { - splitValue( { - value, - pastedBlocks: content, - onReplace, - onSplit, - } ); - } - } - } - - element.addEventListener( 'paste', _onPaste ); - return () => { - element.removeEventListener( 'paste', _onPaste ); - }; - }, [] ); -} diff --git a/packages/block-editor/src/components/rich-text/use-remove-browser-shortcuts.js b/packages/block-editor/src/components/rich-text/use-remove-browser-shortcuts.js deleted file mode 100644 index a7e1b6ec48097..0000000000000 --- a/packages/block-editor/src/components/rich-text/use-remove-browser-shortcuts.js +++ /dev/null @@ -1,29 +0,0 @@ -/** - * WordPress dependencies - */ -import { useRefEffect } from '@wordpress/compose'; -import { isKeyboardEvent } from '@wordpress/keycodes'; - -/** - * Hook to prevent default behaviors for key combinations otherwise handled - * internally by RichText. - * - * @return {import('react').RefObject} The component to be rendered. - */ -export function useRemoveBrowserShortcuts() { - return useRefEffect( ( node ) => { - function onKeydown( event ) { - if ( - isKeyboardEvent.primary( event, 'z' ) || - isKeyboardEvent.primary( event, 'y' ) || - isKeyboardEvent.primaryShift( event, 'z' ) - ) { - event.preventDefault(); - } - } - node.addEventListener( 'keydown', onKeydown ); - return () => { - node.removeEventListener( 'keydown', onKeydown ); - }; - }, [] ); -} diff --git a/packages/block-editor/src/components/rich-text/use-shortcuts.js b/packages/block-editor/src/components/rich-text/use-shortcuts.js deleted file mode 100644 index 61b2e3f6ce31b..0000000000000 --- a/packages/block-editor/src/components/rich-text/use-shortcuts.js +++ /dev/null @@ -1,19 +0,0 @@ -/** - * WordPress dependencies - */ -import { useRefEffect } from '@wordpress/compose'; - -export function useShortcuts( keyboardShortcuts ) { - return useRefEffect( ( element ) => { - function onKeyDown( event ) { - for ( const keyboardShortcut of keyboardShortcuts.current ) { - keyboardShortcut( event ); - } - } - - element.addEventListener( 'keydown', onKeyDown ); - return () => { - element.removeEventListener( 'keydown', onKeyDown ); - }; - }, [] ); -} diff --git a/packages/block-editor/src/components/rich-text/use-undo-automatic-change.js b/packages/block-editor/src/components/rich-text/use-undo-automatic-change.js deleted file mode 100644 index 9094633186ebd..0000000000000 --- a/packages/block-editor/src/components/rich-text/use-undo-automatic-change.js +++ /dev/null @@ -1,46 +0,0 @@ -/** - * WordPress dependencies - */ -import { useSelect } from '@wordpress/data'; -import { useRefEffect } from '@wordpress/compose'; -import { BACKSPACE, ESCAPE } from '@wordpress/keycodes'; - -/** - * Internal dependencies - */ -import { store as blockEditorStore } from '../../store'; - -export function useUndoAutomaticChange() { - const { didAutomaticChange, getSettings } = useSelect( blockEditorStore ); - return useRefEffect( ( element ) => { - function onKeyDown( event ) { - const { keyCode } = event; - - if ( event.defaultPrevented ) { - return; - } - - if ( keyCode !== BACKSPACE && keyCode !== ESCAPE ) { - return; - } - - const { __experimentalUndo } = getSettings(); - - if ( ! __experimentalUndo ) { - return; - } - - if ( ! didAutomaticChange() ) { - return; - } - - event.preventDefault(); - __experimentalUndo(); - } - - element.addEventListener( 'keydown', onKeyDown ); - return () => { - element.removeEventListener( 'keydown', onKeyDown ); - }; - }, [] ); -} diff --git a/packages/rich-text/src/component/event-listeners/copy-handler.js b/packages/rich-text/src/component/event-listeners/copy-handler.js new file mode 100644 index 0000000000000..d756095a94026 --- /dev/null +++ b/packages/rich-text/src/component/event-listeners/copy-handler.js @@ -0,0 +1,39 @@ +/** + * Internal dependencies + */ +import { toHTMLString } from '../../to-html-string'; +import { isCollapsed } from '../../is-collapsed'; +import { slice } from '../../slice'; +import { getTextContent } from '../../get-text-content'; + +export default ( props ) => ( element ) => { + function onCopy( event ) { + const { record } = props.current; + const { ownerDocument } = element; + if ( + isCollapsed( record.current ) || + ! element.contains( ownerDocument.activeElement ) + ) { + return; + } + + const selectedRecord = slice( record.current ); + const plainText = getTextContent( selectedRecord ); + const html = toHTMLString( { value: selectedRecord } ); + event.clipboardData.setData( 'text/plain', plainText ); + event.clipboardData.setData( 'text/html', html ); + event.clipboardData.setData( 'rich-text', 'true' ); + event.preventDefault(); + + if ( event.type === 'cut' ) { + ownerDocument.execCommand( 'delete' ); + } + } + + element.addEventListener( 'copy', onCopy ); + element.addEventListener( 'cut', onCopy ); + return () => { + element.removeEventListener( 'copy', onCopy ); + element.removeEventListener( 'cut', onCopy ); + }; +}; diff --git a/packages/rich-text/src/component/event-listeners/delete.js b/packages/rich-text/src/component/event-listeners/delete.js new file mode 100644 index 0000000000000..62dd06a0f2cdb --- /dev/null +++ b/packages/rich-text/src/component/event-listeners/delete.js @@ -0,0 +1,38 @@ +/** + * WordPress dependencies + */ +import { BACKSPACE, DELETE } from '@wordpress/keycodes'; + +/** + * Internal dependencies + */ +import { remove } from '../../remove'; + +export default ( props ) => ( element ) => { + function onKeyDown( event ) { + const { keyCode } = event; + const { createRecord, handleChange } = props.current; + + if ( event.defaultPrevented ) { + return; + } + + if ( keyCode !== DELETE && keyCode !== BACKSPACE ) { + return; + } + + const currentValue = createRecord(); + const { start, end, text } = currentValue; + + // Always handle full content deletion ourselves. + if ( start === 0 && end !== 0 && end === text.length ) { + handleChange( remove( currentValue ) ); + event.preventDefault(); + } + } + + element.addEventListener( 'keydown', onKeyDown ); + return () => { + element.removeEventListener( 'keydown', onKeyDown ); + }; +}; diff --git a/packages/rich-text/src/component/event-listeners/format-boundaries.js b/packages/rich-text/src/component/event-listeners/format-boundaries.js new file mode 100644 index 0000000000000..e9fdfd10ebfd5 --- /dev/null +++ b/packages/rich-text/src/component/event-listeners/format-boundaries.js @@ -0,0 +1,103 @@ +/** + * WordPress dependencies + */ +import { LEFT, RIGHT } from '@wordpress/keycodes'; + +/** + * Internal dependencies + */ +import { isCollapsed } from '../../is-collapsed'; + +const EMPTY_ACTIVE_FORMATS = []; + +export default ( props ) => ( element ) => { + function onKeyDown( event ) { + const { keyCode, shiftKey, altKey, metaKey, ctrlKey } = event; + + if ( + // Only override left and right keys without modifiers pressed. + shiftKey || + altKey || + metaKey || + ctrlKey || + ( keyCode !== LEFT && keyCode !== RIGHT ) + ) { + return; + } + + const { record, applyRecord, forceRender } = props.current; + const { + text, + formats, + start, + end, + activeFormats: currentActiveFormats = [], + } = record.current; + const collapsed = isCollapsed( record.current ); + const { ownerDocument } = element; + const { defaultView } = ownerDocument; + // To do: ideally, we should look at visual position instead. + const { direction } = defaultView.getComputedStyle( element ); + const reverseKey = direction === 'rtl' ? RIGHT : LEFT; + const isReverse = event.keyCode === reverseKey; + + // If the selection is collapsed and at the very start, do nothing if + // navigating backward. + // If the selection is collapsed and at the very end, do nothing if + // navigating forward. + if ( collapsed && currentActiveFormats.length === 0 ) { + if ( start === 0 && isReverse ) { + return; + } + + if ( end === text.length && ! isReverse ) { + return; + } + } + + // If the selection is not collapsed, let the browser handle collapsing + // the selection for now. Later we could expand this logic to set + // boundary positions if needed. + if ( ! collapsed ) { + return; + } + + const formatsBefore = formats[ start - 1 ] || EMPTY_ACTIVE_FORMATS; + const formatsAfter = formats[ start ] || EMPTY_ACTIVE_FORMATS; + const destination = isReverse ? formatsBefore : formatsAfter; + const isIncreasing = currentActiveFormats.every( + ( format, index ) => format === destination[ index ] + ); + + let newActiveFormatsLength = currentActiveFormats.length; + + if ( ! isIncreasing ) { + newActiveFormatsLength--; + } else if ( newActiveFormatsLength < destination.length ) { + newActiveFormatsLength++; + } + + if ( newActiveFormatsLength === currentActiveFormats.length ) { + record.current._newActiveFormats = destination; + return; + } + + event.preventDefault(); + + const origin = isReverse ? formatsAfter : formatsBefore; + const source = isIncreasing ? destination : origin; + const newActiveFormats = source.slice( 0, newActiveFormatsLength ); + const newValue = { + ...record.current, + activeFormats: newActiveFormats, + }; + record.current = newValue; + applyRecord( newValue ); + forceRender(); + } + + element.addEventListener( 'keydown', onKeyDown ); + return () => { + element.removeEventListener( 'keydown', onKeyDown ); + }; +}; diff --git a/packages/rich-text/src/component/event-listeners/index.js b/packages/rich-text/src/component/event-listeners/index.js new file mode 100644 index 0000000000000..a6327fe6637c3 --- /dev/null +++ b/packages/rich-text/src/component/event-listeners/index.js @@ -0,0 +1,43 @@ +/** + * WordPress dependencies + */ +import { useMemo, useRef } from '@wordpress/element'; +import { useRefEffect } from '@wordpress/compose'; + +/** + * Internal dependencies + */ +import copyHandler from './copy-handler'; +import selectObject from './select-object'; +import formatBoundaries from './format-boundaries'; +import deleteHandler from './delete'; +import inputAndSelection from './input-and-selection'; +import selectionChangeCompat from './selection-change-compat'; + +const allEventListeners = [ + copyHandler, + selectObject, + formatBoundaries, + deleteHandler, + inputAndSelection, + selectionChangeCompat, +]; + +export function useEventListeners( props ) { + const propsRef = useRef( props ); + propsRef.current = props; + const refEffects = useMemo( + () => allEventListeners.map( ( refEffect ) => refEffect( propsRef ) ), + [ propsRef ] + ); + + return useRefEffect( + ( element ) => { + const cleanups = refEffects.map( ( effect ) => effect( element ) ); + return () => { + cleanups.forEach( ( cleanup ) => cleanup() ); + }; + }, + [ refEffects ] + ); +} diff --git a/packages/rich-text/src/component/event-listeners/input-and-selection.js b/packages/rich-text/src/component/event-listeners/input-and-selection.js new file mode 100644 index 0000000000000..41dac7fa7ff98 --- /dev/null +++ b/packages/rich-text/src/component/event-listeners/input-and-selection.js @@ -0,0 +1,254 @@ +/** + * Internal dependencies + */ +import { getActiveFormats } from '../../get-active-formats'; +import { updateFormats } from '../../update-formats'; + +/** + * All inserting input types that would insert HTML into the DOM. + * + * @see https://www.w3.org/TR/input-events-2/#interface-InputEvent-Attributes + * + * @type {Set} + */ +const INSERTION_INPUT_TYPES_TO_IGNORE = new Set( [ + 'insertParagraph', + 'insertOrderedList', + 'insertUnorderedList', + 'insertHorizontalRule', + 'insertLink', +] ); + +const EMPTY_ACTIVE_FORMATS = []; + +const PLACEHOLDER_ATTR_NAME = 'data-rich-text-placeholder'; + +/** + * If the selection is set on the placeholder element, collapse the selection to + * the start (before the placeholder). + * + * @param {Window} defaultView + */ +function fixPlaceholderSelection( defaultView ) { + const selection = defaultView.getSelection(); + const { anchorNode, anchorOffset } = selection; + + if ( anchorNode.nodeType !== anchorNode.ELEMENT_NODE ) { + return; + } + + const targetNode = anchorNode.childNodes[ anchorOffset ]; + + if ( + ! targetNode || + targetNode.nodeType !== targetNode.ELEMENT_NODE || + ! targetNode.hasAttribute( PLACEHOLDER_ATTR_NAME ) + ) { + return; + } + + selection.collapseToStart(); +} + +export default ( props ) => ( element ) => { + const { ownerDocument } = element; + const { defaultView } = ownerDocument; + + let isComposing = false; + + function onInput( event ) { + // Do not trigger a change if characters are being composed. Browsers + // will usually emit a final `input` event when the characters are + // composed. As of December 2019, Safari doesn't support + // nativeEvent.isComposing. + if ( isComposing ) { + return; + } + + let inputType; + + if ( event ) { + inputType = event.inputType; + } + + const { record, applyRecord, createRecord, handleChange } = + props.current; + + // The browser formatted something or tried to insert HTML. Overwrite + // it. It will be handled later by the format library if needed. + if ( + inputType && + ( inputType.indexOf( 'format' ) === 0 || + INSERTION_INPUT_TYPES_TO_IGNORE.has( inputType ) ) + ) { + applyRecord( record.current ); + return; + } + + const currentValue = createRecord(); + const { start, activeFormats: oldActiveFormats = [] } = record.current; + + // Update the formats between the last and new caret position. + const change = updateFormats( { + value: currentValue, + start, + end: currentValue.start, + formats: oldActiveFormats, + } ); + + handleChange( change ); + } + + /** + * Syncs the selection to local state. A callback for the `selectionchange` + * event. + */ + function handleSelectionChange() { + const { record, applyRecord, createRecord, onSelectionChange } = + props.current; + + // Check if the implementor disabled editing. `contentEditable` does + // disable input, but not text selection, so we must ignore selection + // changes. + if ( element.contentEditable !== 'true' ) { + return; + } + + // Ensure the active element is the rich text element. + if ( ownerDocument.activeElement !== element ) { + // If it is not, we can stop listening for selection changes. We + // resume listening when the element is focused. + ownerDocument.removeEventListener( + 'selectionchange', + handleSelectionChange + ); + return; + } + + // In case of a keyboard event, ignore selection changes during + // composition. + if ( isComposing ) { + return; + } + + const { start, end, text } = createRecord(); + const oldRecord = record.current; + + // Fallback mechanism for IE11, which doesn't support the input event. + // Any input results in a selection change. + if ( text !== oldRecord.text ) { + onInput(); + return; + } + + if ( start === oldRecord.start && end === oldRecord.end ) { + // Sometimes the browser may set the selection on the placeholder + // element, in which case the caret is not visible. We need to set + // the caret before the placeholder if that's the case. + if ( oldRecord.text.length === 0 && start === 0 ) { + fixPlaceholderSelection( defaultView ); + } + + return; + } + + const newValue = { + ...oldRecord, + start, + end, + // _newActiveFormats may be set on arrow key navigation to control + // the right boundary position. If undefined, getActiveFormats will + // give the active formats according to the browser. + activeFormats: oldRecord._newActiveFormats, + _newActiveFormats: undefined, + }; + + const newActiveFormats = getActiveFormats( + newValue, + EMPTY_ACTIVE_FORMATS + ); + + // Update the value with the new active formats. + newValue.activeFormats = newActiveFormats; + + // It is important that the internal value is updated first, + // otherwise the value will be wrong on render! + record.current = newValue; + applyRecord( newValue, { domOnly: true } ); + onSelectionChange( start, end ); + } + + function onCompositionStart() { + isComposing = true; + // Do not update the selection when characters are being composed as + // this rerenders the component and might destroy internal browser + // editing state. + ownerDocument.removeEventListener( + 'selectionchange', + handleSelectionChange + ); + // Remove the placeholder. Since the rich text value doesn't update + // during composition, the placeholder doesn't get removed. There's no + // need to re-add it, when the value is updated on compositionend it + // will be re-added when the value is empty. + element.querySelector( `[${ PLACEHOLDER_ATTR_NAME }]` )?.remove(); + } + + function onCompositionEnd() { + isComposing = false; + // Ensure the value is up-to-date for browsers that don't emit a final + // input event after composition. + onInput( { inputType: 'insertText' } ); + // Tracking selection changes can be resumed. + ownerDocument.addEventListener( + 'selectionchange', + handleSelectionChange + ); + } + + function onFocus() { + const { record, isSelected, onSelectionChange, applyRecord } = + props.current; + + // When the whole editor is editable, let writing flow handle + // selection. + if ( element.parentElement.closest( '[contenteditable="true"]' ) ) { + return; + } + + if ( ! isSelected ) { + // We know for certain that on focus, the old selection is invalid. + // It will be recalculated on the next mouseup, keyup, or touchend + // event. + const index = undefined; + + record.current = { + ...record.current, + start: index, + end: index, + activeFormats: EMPTY_ACTIVE_FORMATS, + }; + } else { + applyRecord( record.current, { domOnly: true } ); + } + + onSelectionChange( record.current.start, record.current.end ); + + ownerDocument.addEventListener( + 'selectionchange', + handleSelectionChange + ); + } + + element.addEventListener( 'input', onInput ); + element.addEventListener( 'compositionstart', onCompositionStart ); + element.addEventListener( 'compositionend', onCompositionEnd ); + element.addEventListener( 'focus', onFocus ); + + return () => { + element.removeEventListener( 'input', onInput ); + element.removeEventListener( 'compositionstart', onCompositionStart ); + element.removeEventListener( 'compositionend', onCompositionEnd ); + element.removeEventListener( 'focus', onFocus ); + }; +}; diff --git a/packages/rich-text/src/component/event-listeners/select-object.js b/packages/rich-text/src/component/event-listeners/select-object.js new file mode 100644 index 0000000000000..2598b149d2418 --- /dev/null +++ b/packages/rich-text/src/component/event-listeners/select-object.js @@ -0,0 +1,54 @@ +export default () => ( element ) => { + function onClick( event ) { + const { target } = event; + + // If the child element has no text content, it must be an object. + if ( + target === element || + ( target.textContent && target.isContentEditable ) + ) { + return; + } + + const { ownerDocument } = target; + const { defaultView } = ownerDocument; + const selection = defaultView.getSelection(); + + // If it's already selected, do nothing and let default behavior happen. + // This means it's "click-through". + if ( selection.containsNode( target ) ) { + return; + } + + const range = ownerDocument.createRange(); + // If the target is within a non editable element, select the non + // editable element. + const nodeToSelect = target.isContentEditable + ? target + : target.closest( '[contenteditable]' ); + + range.selectNode( nodeToSelect ); + selection.removeAllRanges(); + selection.addRange( range ); + + event.preventDefault(); + } + + function onFocusIn( event ) { + // When there is incoming focus from a link, select the object. + if ( + event.relatedTarget && + ! element.contains( event.relatedTarget ) && + event.relatedTarget.tagName === 'A' + ) { + onClick( event ); + } + } + + element.addEventListener( 'click', onClick ); + element.addEventListener( 'focusin', onFocusIn ); + return () => { + element.removeEventListener( 'click', onClick ); + element.removeEventListener( 'focusin', onFocusIn ); + }; +}; diff --git a/packages/rich-text/src/component/event-listeners/selection-change-compat.js b/packages/rich-text/src/component/event-listeners/selection-change-compat.js new file mode 100644 index 0000000000000..f64f22658c329 --- /dev/null +++ b/packages/rich-text/src/component/event-listeners/selection-change-compat.js @@ -0,0 +1,53 @@ +/** + * Internal dependencies + */ +import { isRangeEqual } from '../../is-range-equal'; + +/** + * Sometimes some browsers are not firing a `selectionchange` event when + * changing the selection by mouse or keyboard. This hook makes sure that, if we + * detect no `selectionchange` or `input` event between the up and down events, + * we fire a `selectionchange` event. + */ +export default () => ( element ) => { + const { ownerDocument } = element; + const { defaultView } = ownerDocument; + const selection = defaultView?.getSelection(); + + let range; + + function getRange() { + return selection.rangeCount ? selection.getRangeAt( 0 ) : null; + } + + function onDown( event ) { + const type = event.type === 'keydown' ? 'keyup' : 'pointerup'; + + function onCancel() { + ownerDocument.removeEventListener( type, onUp ); + ownerDocument.removeEventListener( 'selectionchange', onCancel ); + ownerDocument.removeEventListener( 'input', onCancel ); + } + + function onUp() { + onCancel(); + if ( isRangeEqual( range, getRange() ) ) { + return; + } + ownerDocument.dispatchEvent( new Event( 'selectionchange' ) ); + } + + ownerDocument.addEventListener( type, onUp ); + ownerDocument.addEventListener( 'selectionchange', onCancel ); + ownerDocument.addEventListener( 'input', onCancel ); + + range = getRange(); + } + + element.addEventListener( 'pointerdown', onDown ); + element.addEventListener( 'keydown', onDown ); + return () => { + element.removeEventListener( 'pointerdown', onDown ); + element.removeEventListener( 'keydown', onDown ); + }; +}; diff --git a/packages/rich-text/src/component/index.js b/packages/rich-text/src/component/index.js index 6767498548a64..2606ae9f902fa 100644 --- a/packages/rich-text/src/component/index.js +++ b/packages/rich-text/src/component/index.js @@ -13,12 +13,7 @@ import { apply } from '../to-dom'; import { toHTMLString } from '../to-html-string'; import { useDefaultStyle } from './use-default-style'; import { useBoundaryStyle } from './use-boundary-style'; -import { useCopyHandler } from './use-copy-handler'; -import { useFormatBoundaries } from './use-format-boundaries'; -import { useSelectObject } from './use-select-object'; -import { useInputAndSelection } from './use-input-and-selection'; -import { useSelectionChangeCompat } from './use-selection-change-compat'; -import { useDelete } from './use-delete'; +import { useEventListeners } from './event-listeners'; export function useRichText( { value = '', @@ -186,22 +181,15 @@ export function useRichText( { ref, useDefaultStyle(), useBoundaryStyle( { record } ), - useCopyHandler( { record } ), - useSelectObject(), - useFormatBoundaries( { record, applyRecord } ), - useDelete( { - createRecord, - handleChange, - } ), - useInputAndSelection( { + useEventListeners( { record, + handleChange, applyRecord, createRecord, - handleChange, isSelected, onSelectionChange, + forceRender, } ), - useSelectionChangeCompat(), useRefEffect( () => { applyFromProps(); didMount.current = true; diff --git a/packages/rich-text/src/component/use-copy-handler.js b/packages/rich-text/src/component/use-copy-handler.js deleted file mode 100644 index f0ba652528e87..0000000000000 --- a/packages/rich-text/src/component/use-copy-handler.js +++ /dev/null @@ -1,49 +0,0 @@ -/** - * WordPress dependencies - */ -import { useRef } from '@wordpress/element'; -import { useRefEffect } from '@wordpress/compose'; - -/** - * Internal dependencies - */ -import { toHTMLString } from '../to-html-string'; -import { isCollapsed } from '../is-collapsed'; -import { slice } from '../slice'; -import { getTextContent } from '../get-text-content'; - -export function useCopyHandler( props ) { - const propsRef = useRef( props ); - propsRef.current = props; - return useRefEffect( ( element ) => { - function onCopy( event ) { - const { record } = propsRef.current; - const { ownerDocument } = element; - if ( - isCollapsed( record.current ) || - ! element.contains( ownerDocument.activeElement ) - ) { - return; - } - - const selectedRecord = slice( record.current ); - const plainText = getTextContent( selectedRecord ); - const html = toHTMLString( { value: selectedRecord } ); - event.clipboardData.setData( 'text/plain', plainText ); - event.clipboardData.setData( 'text/html', html ); - event.clipboardData.setData( 'rich-text', 'true' ); - event.preventDefault(); - - if ( event.type === 'cut' ) { - ownerDocument.execCommand( 'delete' ); - } - } - - element.addEventListener( 'copy', onCopy ); - element.addEventListener( 'cut', onCopy ); - return () => { - element.removeEventListener( 'copy', onCopy ); - element.removeEventListener( 'cut', onCopy ); - }; - }, [] ); -} diff --git a/packages/rich-text/src/component/use-delete.js b/packages/rich-text/src/component/use-delete.js deleted file mode 100644 index 69ccd0ee0dc0b..0000000000000 --- a/packages/rich-text/src/component/use-delete.js +++ /dev/null @@ -1,44 +0,0 @@ -/** - * WordPress dependencies - */ -import { useRef } from '@wordpress/element'; -import { useRefEffect } from '@wordpress/compose'; -import { BACKSPACE, DELETE } from '@wordpress/keycodes'; - -/** - * Internal dependencies - */ -import { remove } from '../remove'; - -export function useDelete( props ) { - const propsRef = useRef( props ); - propsRef.current = props; - return useRefEffect( ( element ) => { - function onKeyDown( event ) { - const { keyCode } = event; - const { createRecord, handleChange } = propsRef.current; - - if ( event.defaultPrevented ) { - return; - } - - if ( keyCode !== DELETE && keyCode !== BACKSPACE ) { - return; - } - - const currentValue = createRecord(); - const { start, end, text } = currentValue; - - // Always handle full content deletion ourselves. - if ( start === 0 && end !== 0 && end === text.length ) { - handleChange( remove( currentValue ) ); - event.preventDefault(); - } - } - - element.addEventListener( 'keydown', onKeyDown ); - return () => { - element.removeEventListener( 'keydown', onKeyDown ); - }; - }, [] ); -} diff --git a/packages/rich-text/src/component/use-format-boundaries.js b/packages/rich-text/src/component/use-format-boundaries.js deleted file mode 100644 index eec097672a146..0000000000000 --- a/packages/rich-text/src/component/use-format-boundaries.js +++ /dev/null @@ -1,110 +0,0 @@ -/** - * WordPress dependencies - */ -import { useRef, useReducer } from '@wordpress/element'; -import { useRefEffect } from '@wordpress/compose'; -import { LEFT, RIGHT } from '@wordpress/keycodes'; - -/** - * Internal dependencies - */ -import { isCollapsed } from '../is-collapsed'; - -const EMPTY_ACTIVE_FORMATS = []; - -export function useFormatBoundaries( props ) { - const [ , forceRender ] = useReducer( () => ( {} ) ); - const propsRef = useRef( props ); - propsRef.current = props; - return useRefEffect( ( element ) => { - function onKeyDown( event ) { - const { keyCode, shiftKey, altKey, metaKey, ctrlKey } = event; - - if ( - // Only override left and right keys without modifiers pressed. - shiftKey || - altKey || - metaKey || - ctrlKey || - ( keyCode !== LEFT && keyCode !== RIGHT ) - ) { - return; - } - - const { record, applyRecord } = propsRef.current; - const { - text, - formats, - start, - end, - activeFormats: currentActiveFormats = [], - } = record.current; - const collapsed = isCollapsed( record.current ); - const { ownerDocument } = element; - const { defaultView } = ownerDocument; - // To do: ideally, we should look at visual position instead. - const { direction } = defaultView.getComputedStyle( element ); - const reverseKey = direction === 'rtl' ? RIGHT : LEFT; - const isReverse = event.keyCode === reverseKey; - - // If the selection is collapsed and at the very start, do nothing if - // navigating backward. - // If the selection is collapsed and at the very end, do nothing if - // navigating forward. - if ( collapsed && currentActiveFormats.length === 0 ) { - if ( start === 0 && isReverse ) { - return; - } - - if ( end === text.length && ! isReverse ) { - return; - } - } - - // If the selection is not collapsed, let the browser handle collapsing - // the selection for now. Later we could expand this logic to set - // boundary positions if needed. - if ( ! collapsed ) { - return; - } - - const formatsBefore = formats[ start - 1 ] || EMPTY_ACTIVE_FORMATS; - const formatsAfter = formats[ start ] || EMPTY_ACTIVE_FORMATS; - const destination = isReverse ? formatsBefore : formatsAfter; - const isIncreasing = currentActiveFormats.every( - ( format, index ) => format === destination[ index ] - ); - - let newActiveFormatsLength = currentActiveFormats.length; - - if ( ! isIncreasing ) { - newActiveFormatsLength--; - } else if ( newActiveFormatsLength < destination.length ) { - newActiveFormatsLength++; - } - - if ( newActiveFormatsLength === currentActiveFormats.length ) { - record.current._newActiveFormats = destination; - return; - } - - event.preventDefault(); - - const origin = isReverse ? formatsAfter : formatsBefore; - const source = isIncreasing ? destination : origin; - const newActiveFormats = source.slice( 0, newActiveFormatsLength ); - const newValue = { - ...record.current, - activeFormats: newActiveFormats, - }; - record.current = newValue; - applyRecord( newValue ); - forceRender(); - } - - element.addEventListener( 'keydown', onKeyDown ); - return () => { - element.removeEventListener( 'keydown', onKeyDown ); - }; - }, [] ); -} diff --git a/packages/rich-text/src/component/use-input-and-selection.js b/packages/rich-text/src/component/use-input-and-selection.js deleted file mode 100644 index 1b0cd03829469..0000000000000 --- a/packages/rich-text/src/component/use-input-and-selection.js +++ /dev/null @@ -1,270 +0,0 @@ -/** - * WordPress dependencies - */ -import { useRef } from '@wordpress/element'; -import { useRefEffect } from '@wordpress/compose'; - -/** - * Internal dependencies - */ -import { getActiveFormats } from '../get-active-formats'; -import { updateFormats } from '../update-formats'; - -/** - * All inserting input types that would insert HTML into the DOM. - * - * @see https://www.w3.org/TR/input-events-2/#interface-InputEvent-Attributes - * - * @type {Set} - */ -const INSERTION_INPUT_TYPES_TO_IGNORE = new Set( [ - 'insertParagraph', - 'insertOrderedList', - 'insertUnorderedList', - 'insertHorizontalRule', - 'insertLink', -] ); - -const EMPTY_ACTIVE_FORMATS = []; - -const PLACEHOLDER_ATTR_NAME = 'data-rich-text-placeholder'; - -/** - * If the selection is set on the placeholder element, collapse the selection to - * the start (before the placeholder). - * - * @param {Window} defaultView - */ -function fixPlaceholderSelection( defaultView ) { - const selection = defaultView.getSelection(); - const { anchorNode, anchorOffset } = selection; - - if ( anchorNode.nodeType !== anchorNode.ELEMENT_NODE ) { - return; - } - - const targetNode = anchorNode.childNodes[ anchorOffset ]; - - if ( - ! targetNode || - targetNode.nodeType !== targetNode.ELEMENT_NODE || - ! targetNode.hasAttribute( PLACEHOLDER_ATTR_NAME ) - ) { - return; - } - - selection.collapseToStart(); -} - -export function useInputAndSelection( props ) { - const propsRef = useRef( props ); - propsRef.current = props; - return useRefEffect( ( element ) => { - const { ownerDocument } = element; - const { defaultView } = ownerDocument; - - let isComposing = false; - - function onInput( event ) { - // Do not trigger a change if characters are being composed. - // Browsers will usually emit a final `input` event when the - // characters are composed. - // As of December 2019, Safari doesn't support - // nativeEvent.isComposing. - if ( isComposing ) { - return; - } - - let inputType; - - if ( event ) { - inputType = event.inputType; - } - - const { record, applyRecord, createRecord, handleChange } = - propsRef.current; - - // The browser formatted something or tried to insert HTML. - // Overwrite it. It will be handled later by the format library if - // needed. - if ( - inputType && - ( inputType.indexOf( 'format' ) === 0 || - INSERTION_INPUT_TYPES_TO_IGNORE.has( inputType ) ) - ) { - applyRecord( record.current ); - return; - } - - const currentValue = createRecord(); - const { start, activeFormats: oldActiveFormats = [] } = - record.current; - - // Update the formats between the last and new caret position. - const change = updateFormats( { - value: currentValue, - start, - end: currentValue.start, - formats: oldActiveFormats, - } ); - - handleChange( change ); - } - - /** - * Syncs the selection to local state. A callback for the - * `selectionchange` event. - */ - function handleSelectionChange() { - const { record, applyRecord, createRecord, onSelectionChange } = - propsRef.current; - - // Check if the implementor disabled editing. `contentEditable` - // does disable input, but not text selection, so we must ignore - // selection changes. - if ( element.contentEditable !== 'true' ) { - return; - } - - // Ensure the active element is the rich text element. - if ( ownerDocument.activeElement !== element ) { - // If it is not, we can stop listening for selection changes. - // We resume listening when the element is focused. - ownerDocument.removeEventListener( - 'selectionchange', - handleSelectionChange - ); - return; - } - - // In case of a keyboard event, ignore selection changes during - // composition. - if ( isComposing ) { - return; - } - - const { start, end, text } = createRecord(); - const oldRecord = record.current; - - // Fallback mechanism for IE11, which doesn't support the input event. - // Any input results in a selection change. - if ( text !== oldRecord.text ) { - onInput(); - return; - } - - if ( start === oldRecord.start && end === oldRecord.end ) { - // Sometimes the browser may set the selection on the placeholder - // element, in which case the caret is not visible. We need to set - // the caret before the placeholder if that's the case. - if ( oldRecord.text.length === 0 && start === 0 ) { - fixPlaceholderSelection( defaultView ); - } - - return; - } - - const newValue = { - ...oldRecord, - start, - end, - // _newActiveFormats may be set on arrow key navigation to control - // the right boundary position. If undefined, getActiveFormats will - // give the active formats according to the browser. - activeFormats: oldRecord._newActiveFormats, - _newActiveFormats: undefined, - }; - - const newActiveFormats = getActiveFormats( - newValue, - EMPTY_ACTIVE_FORMATS - ); - - // Update the value with the new active formats. - newValue.activeFormats = newActiveFormats; - - // It is important that the internal value is updated first, - // otherwise the value will be wrong on render! - record.current = newValue; - applyRecord( newValue, { domOnly: true } ); - onSelectionChange( start, end ); - } - - function onCompositionStart() { - isComposing = true; - // Do not update the selection when characters are being composed as - // this rerenders the component and might destroy internal browser - // editing state. - ownerDocument.removeEventListener( - 'selectionchange', - handleSelectionChange - ); - // Remove the placeholder. Since the rich text value doesn't update - // during composition, the placeholder doesn't get removed. There's - // no need to re-add it, when the value is updated on compositionend - // it will be re-added when the value is empty. - element.querySelector( `[${ PLACEHOLDER_ATTR_NAME }]` )?.remove(); - } - - function onCompositionEnd() { - isComposing = false; - // Ensure the value is up-to-date for browsers that don't emit a final - // input event after composition. - onInput( { inputType: 'insertText' } ); - // Tracking selection changes can be resumed. - ownerDocument.addEventListener( - 'selectionchange', - handleSelectionChange - ); - } - - function onFocus() { - const { record, isSelected, onSelectionChange, applyRecord } = - propsRef.current; - - // When the whole editor is editable, let writing flow handle - // selection. - if ( element.parentElement.closest( '[contenteditable="true"]' ) ) { - return; - } - - if ( ! isSelected ) { - // We know for certain that on focus, the old selection is invalid. - // It will be recalculated on the next mouseup, keyup, or touchend - // event. - const index = undefined; - - record.current = { - ...record.current, - start: index, - end: index, - activeFormats: EMPTY_ACTIVE_FORMATS, - }; - } else { - applyRecord( record.current, { domOnly: true } ); - } - - onSelectionChange( record.current.start, record.current.end ); - - ownerDocument.addEventListener( - 'selectionchange', - handleSelectionChange - ); - } - - element.addEventListener( 'input', onInput ); - element.addEventListener( 'compositionstart', onCompositionStart ); - element.addEventListener( 'compositionend', onCompositionEnd ); - element.addEventListener( 'focus', onFocus ); - - return () => { - element.removeEventListener( 'input', onInput ); - element.removeEventListener( - 'compositionstart', - onCompositionStart - ); - element.removeEventListener( 'compositionend', onCompositionEnd ); - element.removeEventListener( 'focus', onFocus ); - }; - }, [] ); -} diff --git a/packages/rich-text/src/component/use-select-object.js b/packages/rich-text/src/component/use-select-object.js deleted file mode 100644 index fe5512f18d26d..0000000000000 --- a/packages/rich-text/src/component/use-select-object.js +++ /dev/null @@ -1,61 +0,0 @@ -/** - * WordPress dependencies - */ -import { useRefEffect } from '@wordpress/compose'; - -export function useSelectObject() { - return useRefEffect( ( element ) => { - function onClick( event ) { - const { target } = event; - - // If the child element has no text content, it must be an object. - if ( - target === element || - ( target.textContent && target.isContentEditable ) - ) { - return; - } - - const { ownerDocument } = target; - const { defaultView } = ownerDocument; - const selection = defaultView.getSelection(); - - // If it's already selected, do nothing and let default behavior - // happen. This means it's "click-through". - if ( selection.containsNode( target ) ) { - return; - } - - const range = ownerDocument.createRange(); - // If the target is within a non editable element, select the non - // editable element. - const nodeToSelect = target.isContentEditable - ? target - : target.closest( '[contenteditable]' ); - - range.selectNode( nodeToSelect ); - selection.removeAllRanges(); - selection.addRange( range ); - - event.preventDefault(); - } - - function onFocusIn( event ) { - // When there is incoming focus from a link, select the object. - if ( - event.relatedTarget && - ! element.contains( event.relatedTarget ) && - event.relatedTarget.tagName === 'A' - ) { - onClick( event ); - } - } - - element.addEventListener( 'click', onClick ); - element.addEventListener( 'focusin', onFocusIn ); - return () => { - element.removeEventListener( 'click', onClick ); - element.removeEventListener( 'focusin', onFocusIn ); - }; - }, [] ); -} diff --git a/packages/rich-text/src/component/use-selection-change-compat.js b/packages/rich-text/src/component/use-selection-change-compat.js deleted file mode 100644 index 74dddc10722c5..0000000000000 --- a/packages/rich-text/src/component/use-selection-change-compat.js +++ /dev/null @@ -1,66 +0,0 @@ -/** - * WordPress dependencies - */ -import { useRefEffect } from '@wordpress/compose'; - -/** - * Internal dependencies - */ -import { isRangeEqual } from '../is-range-equal'; - -/** - * Sometimes some browsers are not firing a `selectionchange` event when - * changing the selection by mouse or keyboard. This hook makes sure that, if we - * detect no `selectionchange` or `input` event between the up and down events, - * we fire a `selectionchange` event. - * - * @return {import('@wordpress/compose').RefEffect} A ref effect attaching the - * listeners. - */ -export function useSelectionChangeCompat() { - return useRefEffect( ( element ) => { - const { ownerDocument } = element; - const { defaultView } = ownerDocument; - const selection = defaultView?.getSelection(); - - let range; - - function getRange() { - return selection.rangeCount ? selection.getRangeAt( 0 ) : null; - } - - function onDown( event ) { - const type = event.type === 'keydown' ? 'keyup' : 'pointerup'; - - function onCancel() { - ownerDocument.removeEventListener( type, onUp ); - ownerDocument.removeEventListener( - 'selectionchange', - onCancel - ); - ownerDocument.removeEventListener( 'input', onCancel ); - } - - function onUp() { - onCancel(); - if ( isRangeEqual( range, getRange() ) ) { - return; - } - ownerDocument.dispatchEvent( new Event( 'selectionchange' ) ); - } - - ownerDocument.addEventListener( type, onUp ); - ownerDocument.addEventListener( 'selectionchange', onCancel ); - ownerDocument.addEventListener( 'input', onCancel ); - - range = getRange(); - } - - element.addEventListener( 'pointerdown', onDown ); - element.addEventListener( 'keydown', onDown ); - return () => { - element.removeEventListener( 'pointerdown', onDown ); - element.removeEventListener( 'keydown', onDown ); - }; - }, [] ); -} From 907c8b118dac0fbfd4ddb22b0f3511f9cbcd7947 Mon Sep 17 00:00:00 2001 From: Huub <50170696+huubl@users.noreply.github.com> Date: Wed, 1 May 2024 17:39:31 +0200 Subject: [PATCH 5/9] Output post classes in the editor (#60642) Co-authored-by: huubl Co-authored-by: ntsekouras Co-authored-by: ellatrix Co-authored-by: TimothyBJacobs --- lib/compat/wordpress-6.6/rest-api.php | 47 +++++++++++++++++++ .../block-library/src/post-template/edit.js | 13 +++-- 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/lib/compat/wordpress-6.6/rest-api.php b/lib/compat/wordpress-6.6/rest-api.php index bf462cd11ca4b..8526093dc99dd 100644 --- a/lib/compat/wordpress-6.6/rest-api.php +++ b/lib/compat/wordpress-6.6/rest-api.php @@ -29,3 +29,50 @@ function wp_api_template_access_controller( $args, $post_type ) { } } add_filter( 'register_post_type_args', 'wp_api_template_access_controller', 10, 2 ); + +/** + * Adds the post classes to the REST API response. + * + * @param array $post The response object data. + * + * @return array + */ +function gutenberg_add_class_list_to_api_response( $post ) { + + if ( ! isset( $post['id'] ) ) { + return array(); + } + + return get_post_class( array(), $post['id'] ); +} + +/** + * Adds the post classes to public post types in the REST API. + */ +function gutenberg_add_class_list_to_public_post_types() { + $post_types = get_post_types( + array( + 'public' => true, + 'show_in_rest' => true, + ), + 'names' + ); + + if ( ! empty( $post_types ) ) { + register_rest_field( + $post_types, + 'class_list', + array( + 'get_callback' => 'gutenberg_add_class_list_to_api_response', + 'schema' => array( + 'description' => __( 'An array of the class names for the post container element.', 'gutenberg' ), + 'type' => 'array', + 'items' => array( + 'type' => 'string', + ), + ), + ) + ); + } +} +add_action( 'rest_api_init', 'gutenberg_add_class_list_to_public_post_types' ); diff --git a/packages/block-library/src/post-template/edit.js b/packages/block-library/src/post-template/edit.js index 3db9bf465fa05..4302c44309423 100644 --- a/packages/block-library/src/post-template/edit.js +++ b/packages/block-library/src/post-template/edit.js @@ -27,9 +27,9 @@ const TEMPLATE = [ [ 'core/post-excerpt' ], ]; -function PostTemplateInnerBlocks() { +function PostTemplateInnerBlocks( { classList } ) { const innerBlocksProps = useInnerBlocksProps( - { className: 'wp-block-post' }, + { className: classnames( 'wp-block-post', classList ) }, { template: TEMPLATE, __unstableDisableLayoutClassNames: true } ); return
  • ; @@ -38,13 +38,14 @@ function PostTemplateInnerBlocks() { function PostTemplateBlockPreview( { blocks, blockContextId, + classList, isHidden, setActiveBlockContextId, } ) { const blockPreviewProps = useBlockPreview( { blocks, props: { - className: 'wp-block-post', + className: classnames( 'wp-block-post', classList ), }, } ); @@ -213,6 +214,7 @@ export default function PostTemplateEdit( { posts?.map( ( post ) => ( { postType: post.type, postId: post.id, + classList: post.class_list ?? '', } ) ), [ posts ] ); @@ -280,11 +282,14 @@ export default function PostTemplateEdit( { { blockContext.postId === ( activeBlockContextId || blockContexts[ 0 ]?.postId ) ? ( - + ) : null } Date: Wed, 1 May 2024 11:33:22 -0500 Subject: [PATCH 6/9] Move search into inserter tabs (#61108) * Move search into inserter tabs * Make inserterSearch component * focus the search field using a useEffect * use request animation frame * add comment * remove unused code * change selector for search * use useRefEffect instead of the requestAnimationFrame * Only inserter on mount * keep the imperative ref and focus once the tabs are done * revert changes to the inserter tabs and leave the tabs contents memoized * reset order * remove unneeded change * reset order * show message if no blocks and no patterns available, do not auto focus tabs or search * fix test * fix firefox bug * auto focus the inserter when open so that we can tab into it * Remove unused searchRef * Pin hint to bottom of block tab panel so it doesn't flash * Remove unnecessary tab filtering * restore no results message * Focus first active tab * hide the inserter tabs on the widget editor --------- Co-authored-by: Ben Dwyer Co-authored-by: Andrei Draganescu Co-authored-by: jeryj Co-authored-by: scruffian Co-authored-by: draganescu Co-authored-by: getdave --- .../inserter/block-patterns-tab/index.js | 5 + .../components/inserter/block-types-tab.js | 5 + .../inserter/media-tab/media-tab.js | 5 + .../src/components/inserter/menu.js | 229 +++++++++--------- .../src/components/inserter/style.scss | 5 + .../src/components/inserter/tabs.js | 18 +- .../src/components/layout/style.scss | 4 + .../secondary-sidebar/inserter-sidebar.js | 7 +- .../src/components/inserter-sidebar/index.js | 7 +- .../editor/various/adding-patterns.spec.js | 5 +- .../editor/various/inserting-blocks.spec.js | 4 +- test/performance/specs/post-editor.spec.js | 4 +- 12 files changed, 161 insertions(+), 137 deletions(-) diff --git a/packages/block-editor/src/components/inserter/block-patterns-tab/index.js b/packages/block-editor/src/components/inserter/block-patterns-tab/index.js index d2f302683ed2d..ebf816a9e2dec 100644 --- a/packages/block-editor/src/components/inserter/block-patterns-tab/index.js +++ b/packages/block-editor/src/components/inserter/block-patterns-tab/index.js @@ -14,6 +14,7 @@ import MobileTabNavigation from '../mobile-tab-navigation'; import { PatternCategoryPreviews } from './pattern-category-previews'; import { usePatternCategories } from './use-pattern-categories'; import CategoryTabs from '../category-tabs'; +import InserterNoResults from '../no-results'; function BlockPatternsTab( { onSelectCategory, @@ -28,6 +29,10 @@ function BlockPatternsTab( { const isMobile = useViewportMatch( 'medium', '<' ); + if ( ! categories.length ) { + return ; + } + return ( <> { ! isMobile && ( diff --git a/packages/block-editor/src/components/inserter/block-types-tab.js b/packages/block-editor/src/components/inserter/block-types-tab.js index 60e1ff4beb6d5..fc742c3bf6489 100644 --- a/packages/block-editor/src/components/inserter/block-types-tab.js +++ b/packages/block-editor/src/components/inserter/block-types-tab.js @@ -13,6 +13,7 @@ import InserterPanel from './panel'; import useBlockTypesState from './hooks/use-block-types-state'; import InserterListbox from '../inserter-listbox'; import { orderBy } from '../../utils/sorting'; +import InserterNoResults from './no-results'; const getBlockNamespace = ( item ) => item.name.split( '/' )[ 0 ]; @@ -102,6 +103,10 @@ export function BlockTypesTab( { didRenderAllCategories ? collectionEntries : EMPTY_ARRAY ); + if ( ! items.length ) { + return ; + } + return (
    diff --git a/packages/block-editor/src/components/inserter/media-tab/media-tab.js b/packages/block-editor/src/components/inserter/media-tab/media-tab.js index c5f462210fa4a..2f3985aef569c 100644 --- a/packages/block-editor/src/components/inserter/media-tab/media-tab.js +++ b/packages/block-editor/src/components/inserter/media-tab/media-tab.js @@ -16,6 +16,7 @@ import { useMediaCategories } from './hooks'; import { getBlockAndPreviewFromMedia } from './utils'; import MobileTabNavigation from '../mobile-tab-navigation'; import CategoryTabs from '../category-tabs'; +import InserterNoResults from '../no-results'; const ALLOWED_MEDIA_TYPES = [ 'image', 'video', 'audio' ]; @@ -48,6 +49,10 @@ function MediaTab( { [ mediaCategories ] ); + if ( ! categories.length ) { + return ; + } + return ( <> { ! isMobile && ( diff --git a/packages/block-editor/src/components/inserter/menu.js b/packages/block-editor/src/components/inserter/menu.js index 33737c16bcb50..672f625465117 100644 --- a/packages/block-editor/src/components/inserter/menu.js +++ b/packages/block-editor/src/components/inserter/menu.js @@ -11,28 +11,25 @@ import { useState, useCallback, useMemo, - useImperativeHandle, useRef, + useLayoutEffect, } from '@wordpress/element'; import { VisuallyHidden, SearchControl, Popover } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; -import { useSelect } from '@wordpress/data'; import { useDebouncedInput } from '@wordpress/compose'; /** * Internal dependencies */ -import { unlock } from '../../lock-unlock'; import Tips from './tips'; import InserterPreviewPanel from './preview-panel'; import BlockTypesTab from './block-types-tab'; import BlockPatternsTab from './block-patterns-tab'; import { PatternCategoryPreviewPanel } from './block-patterns-tab/pattern-category-preview-panel'; -import { MediaTab, MediaCategoryPanel, useMediaCategories } from './media-tab'; +import { MediaTab, MediaCategoryPanel } from './media-tab'; import InserterSearchResults from './search-results'; import useInsertionPoint from './hooks/use-insertion-point'; import InserterTabs from './tabs'; -import { store as blockEditorStore } from '../../store'; import { useZoomOut } from '../../hooks/use-zoom-out'; const NOOP = () => {}; @@ -59,7 +56,7 @@ function InserterMenu( const [ patternFilter, setPatternFilter ] = useState( 'all' ); const [ selectedMediaCategory, setSelectedMediaCategory ] = useState( null ); - const [ selectedTab, setSelectedTab ] = useState( null ); + const [ selectedTab, setSelectedTab ] = useState( 'blocks' ); const [ destinationRootClientId, onInsertBlocks, onToggleInsertionPoint ] = useInsertionPoint( { @@ -69,18 +66,6 @@ function InserterMenu( insertionIndex: __experimentalInsertionIndex, shouldFocusBlock, } ); - const { showPatterns } = useSelect( - ( select ) => { - const { hasAllowedPatterns } = unlock( select( blockEditorStore ) ); - return { - showPatterns: hasAllowedPatterns( destinationRootClientId ), - }; - }, - [ destinationRootClientId ] - ); - - const mediaCategories = useMediaCategories( destinationRootClientId ); - const showMedia = mediaCategories.length > 0; const onInsert = useCallback( ( blocks, meta, shouldForceFocusBlock ) => { @@ -135,29 +120,86 @@ function InserterMenu( ! delayedFilterValue && selectedPatternCategory; - const showMediaPanel = - selectedTab === 'media' && - ! delayedFilterValue && - selectedMediaCategory; + const showMediaPanel = selectedTab === 'media' && selectedMediaCategory; - const blocksTab = useMemo( - () => ( + const inserterSearch = useMemo( () => { + if ( selectedTab === 'media' ) { + return null; + } + return ( <> -
    - { + if ( hoveredItem ) { + setHoveredItem( null ); + } + setFilterValue( value ); + } } + value={ filterValue } + label={ __( 'Search for blocks and patterns' ) } + placeholder={ __( 'Search' ) } + /> + { !! delayedFilterValue && ( + -
    - { showInserterHelpPanel && ( -
    - - { __( 'A tip for using the block editor' ) } - - -
    + ) } + + ); + }, [ + selectedTab, + hoveredItem, + setHoveredItem, + setFilterValue, + filterValue, + delayedFilterValue, + onSelect, + onHover, + onHoverPattern, + shouldFocusBlock, + clientId, + rootClientId, + __experimentalInsertionIndex, + isAppender, + ] ); + + const blocksTab = useMemo( + () => ( + <> + { inserterSearch } + { ! delayedFilterValue && ( + <> +
    + +
    + { showInserterHelpPanel && ( +
    + + { __( 'A tip for using the block editor' ) } + + +
    + ) } + ) } ), @@ -167,28 +209,35 @@ function InserterMenu( onHover, showMostUsedBlocks, showInserterHelpPanel, + inserterSearch, + delayedFilterValue, ] ); const patternsTab = useMemo( () => ( - - { showPatternPanel && ( - + { inserterSearch } + { ! delayedFilterValue && ( + + onSelectCategory={ onClickPatternCategory } + selectedCategory={ selectedPatternCategory } + > + { showPatternPanel && ( + + ) } + ) } - + ), [ destinationRootClientId, @@ -198,6 +247,8 @@ function InserterMenu( patternFilter, selectedPatternCategory, showPatternPanel, + inserterSearch, + delayedFilterValue, ] ); @@ -236,15 +287,6 @@ function InserterMenu( [ blocksTab, mediaTab, patternsTab ] ); - const searchRef = useRef(); - useImperativeHandle( ref, () => ( { - focusSearch: () => { - searchRef.current.focus(); - }, - } ) ); - - const showAsTabs = ! delayedFilterValue && ( showPatterns || showMedia ); - // When the pattern panel is showing, we want to use zoom out mode useZoomOut( showPatternPanel ); @@ -256,62 +298,31 @@ function InserterMenu( setSelectedTab( value ); }; + // Focus first active tab, if any + const tabsRef = useRef(); + useLayoutEffect( () => { + if ( tabsRef.current ) { + window.requestAnimationFrame( () => { + tabsRef.current + .querySelector( '[role="tab"][aria-selected="true"]' ) + ?.focus(); + } ); + } + }, [] ); + return (
    -
    - { - if ( hoveredItem ) { - setHoveredItem( null ); - } - setFilterValue( value ); - } } - value={ filterValue } - label={ __( 'Search for blocks and patterns' ) } - placeholder={ __( 'Search' ) } - ref={ searchRef } +
    + - { !! delayedFilterValue && ( -
    - -
    - ) } - { showAsTabs && ( - - ) } - { ! delayedFilterValue && ! showAsTabs && ( -
    - { blocksTab } -
    - ) }
    { showInserterHelpPanel && hoveredItem && ( +
    { tabs.map( ( tab ) => ( @@ -69,4 +61,4 @@ function InserterTabs( { ); } -export default InserterTabs; +export default forwardRef( InserterTabs ); diff --git a/packages/edit-widgets/src/components/layout/style.scss b/packages/edit-widgets/src/components/layout/style.scss index 1aed3d3eefc86..a10665f7cafe7 100644 --- a/packages/edit-widgets/src/components/layout/style.scss +++ b/packages/edit-widgets/src/components/layout/style.scss @@ -18,6 +18,10 @@ // Leave space for the close button height: calc(100% - #{$button-size} - #{$grid-unit-10}); + .block-editor-inserter__tab { + display: none; + } + @include break-medium() { height: 100%; } diff --git a/packages/edit-widgets/src/components/secondary-sidebar/inserter-sidebar.js b/packages/edit-widgets/src/components/secondary-sidebar/inserter-sidebar.js index b01481748ee88..2374b35ad2d69 100644 --- a/packages/edit-widgets/src/components/secondary-sidebar/inserter-sidebar.js +++ b/packages/edit-widgets/src/components/secondary-sidebar/inserter-sidebar.js @@ -8,7 +8,7 @@ import { useViewportMatch, __experimentalUseDialog as useDialog, } from '@wordpress/compose'; -import { useCallback, useEffect, useRef } from '@wordpress/element'; +import { useCallback, useRef } from '@wordpress/element'; import { useDispatch } from '@wordpress/data'; import { __ } from '@wordpress/i18n'; @@ -31,13 +31,10 @@ export default function InserterSidebar() { const TagName = ! isMobileViewport ? VisuallyHidden : 'div'; const [ inserterDialogRef, inserterDialogProps ] = useDialog( { onClose: closeInserter, - focusOnMount: null, + focusOnMount: true, } ); const libraryRef = useRef(); - useEffect( () => { - libraryRef.current.focusSearch(); - }, [] ); return (
    setIsInserterOpened( false ), - focusOnMount: null, + focusOnMount: true, } ); const libraryRef = useRef(); - useEffect( () => { - libraryRef.current.focusSearch(); - }, [] ); return (
    { } ); test( 'should insert a block pattern', async ( { page, editor } ) => { - await page.click( - 'role=region[name="Editor top bar"i] >> role=button[name="Toggle block inserter"i]' - ); + await page.getByLabel( 'Toggle block inserter' ).click(); + await page.getByRole( 'tab', { name: 'Patterns' } ).click(); await page.fill( 'role=region[name="Block Library"i] >> role=searchbox[name="Search for blocks and patterns"i]', 'Social links with a shared background color' diff --git a/test/e2e/specs/editor/various/inserting-blocks.spec.js b/test/e2e/specs/editor/various/inserting-blocks.spec.js index 6e19fd31a71b0..c277b056a323c 100644 --- a/test/e2e/specs/editor/various/inserting-blocks.spec.js +++ b/test/e2e/specs/editor/various/inserting-blocks.spec.js @@ -598,6 +598,7 @@ test.describe( 'Inserting blocks (@firefox, @webkit)', () => { .getByRole( 'searchbox', { name: 'Search for blocks and patterns', } ) + .first() .fill( 'Verse' ); await page.getByRole( 'button', { name: 'Browse All' } ).click(); @@ -607,9 +608,10 @@ test.describe( 'Inserting blocks (@firefox, @webkit)', () => { .getByRole( 'searchbox', { name: 'Search for blocks and patterns', } ) + .first() ).toHaveValue( 'Verse' ); await expect( - page.getByRole( 'listbox', { name: 'Blocks' } ) + page.getByRole( 'listbox', { name: 'Blocks' } ).first() ).toHaveCount( 1 ); } ); diff --git a/test/performance/specs/post-editor.spec.js b/test/performance/specs/post-editor.spec.js index c8010c79b1550..b82d3a31d4cb6 100644 --- a/test/performance/specs/post-editor.spec.js +++ b/test/performance/specs/post-editor.spec.js @@ -418,9 +418,11 @@ test.describe( 'Post Editor Performance', () => { const globalInserterToggle = page.getByRole( 'button', { name: 'Toggle block inserter', } ); - // Open Inserter. await globalInserterToggle.click(); + + await page.getByRole( 'searchbox' ).click(); + await perfUtils.expectExpandedState( globalInserterToggle, 'true' ); const samples = 10; From edb7e4ea109d0155de9d6dce327b2ba72dead56b Mon Sep 17 00:00:00 2001 From: Ramon Date: Thu, 2 May 2024 12:01:41 +1000 Subject: [PATCH 7/9] Backporting annotations from Core. (#61301) Co-authored-by: ramonjd --- lib/class-wp-theme-json-gutenberg.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/class-wp-theme-json-gutenberg.php b/lib/class-wp-theme-json-gutenberg.php index d73e7aa8566c8..72c9057aa2af0 100644 --- a/lib/class-wp-theme-json-gutenberg.php +++ b/lib/class-wp-theme-json-gutenberg.php @@ -2145,8 +2145,8 @@ protected static function flatten_tree( $tree, $prefix = '', $token = '--' ) { * @since 5.8.0 * @since 5.9.0 Added the `$settings` and `$properties` parameters. * @since 6.1.0 Added `$theme_json`, `$selector`, and `$use_root_padding` parameters. - * @since 6.5.0 Passing current theme JSON settings to wp_get_typography_font_size_value(). - * @since 6.6.0 Using style engine to correctly fetch background CSS values. + * @since 6.5.0 Output a `min-height: unset` rule when `aspect-ratio` is set. + * @since 6.6.0 Passing current theme JSON settings to wp_get_typography_font_size_value(). Using style engine to correctly fetch background CSS values. * * @param array $styles Styles to process. * @param array $settings Theme settings. From 1351c672d76dc9fcba4ce28916be9632dc542c04 Mon Sep 17 00:00:00 2001 From: Daniel Richards Date: Thu, 2 May 2024 12:49:38 +0800 Subject: [PATCH 8/9] Use contentOnly locking for pattern block, remove hard-coded block check in block inspector (#61227) Co-authored-by: talldan Co-authored-by: aaronrobertshaw --- packages/block-editor/src/components/block-inspector/index.js | 3 +-- packages/block-library/src/block/edit.js | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/block-editor/src/components/block-inspector/index.js b/packages/block-editor/src/components/block-inspector/index.js index 6da035a883ffb..1fa6c3ea507d9 100644 --- a/packages/block-editor/src/components/block-inspector/index.js +++ b/packages/block-editor/src/components/block-inspector/index.js @@ -95,8 +95,7 @@ const BlockInspector = ( { showNoBlockSelectedMessage = true } ) => { blockType: _blockType, topLevelLockedBlock: getContentLockingParent( _selectedBlockClientId ) || - ( getTemplateLock( _selectedBlockClientId ) === 'contentOnly' || - _selectedBlockName === 'core/block' + ( getTemplateLock( _selectedBlockClientId ) === 'contentOnly' ? _selectedBlockClientId : undefined ), }; diff --git a/packages/block-library/src/block/edit.js b/packages/block-library/src/block/edit.js index 8a2c292f395b5..7b555c1c7186e 100644 --- a/packages/block-library/src/block/edit.js +++ b/packages/block-library/src/block/edit.js @@ -402,7 +402,7 @@ function ReusableBlockEdit( { } ); const innerBlocksProps = useInnerBlocksProps( blockProps, { - templateLock: 'all', + templateLock: 'contentOnly', layout, renderAppender: innerBlocks?.length ? undefined From 809d6228914e96675b7e34d2b0b393ad7299fb5a Mon Sep 17 00:00:00 2001 From: Ramon Date: Thu, 2 May 2024 15:25:58 +1000 Subject: [PATCH 9/9] Only tell the toolspanel that it has a value when there are styles that are not inherited from theme.json (#61304) Co-authored-by: ramonjd Co-authored-by: tellthemachines --- .../src/components/global-styles/background-panel.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/block-editor/src/components/global-styles/background-panel.js b/packages/block-editor/src/components/global-styles/background-panel.js index ef1f9673d601f..145787bc59436 100644 --- a/packages/block-editor/src/components/global-styles/background-panel.js +++ b/packages/block-editor/src/components/global-styles/background-panel.js @@ -275,9 +275,7 @@ function BackgroundImageToolsPanelItem( { }; }, [] ); - const hasValue = - hasBackgroundImageValue( style ) || - hasBackgroundImageValue( inheritedValue ); + const hasValue = hasBackgroundImageValue( style ); return (