diff --git a/docs/reference-guides/core-blocks.md b/docs/reference-guides/core-blocks.md index c3099d0b898465..b84d8edb8e9d98 100644 --- a/docs/reference-guides/core-blocks.md +++ b/docs/reference-guides/core-blocks.md @@ -332,7 +332,7 @@ Insert an image to make a visual statement. ([Source](https://github.com/WordPre - **Name:** core/image - **Category:** media - **Supports:** anchor, color (~~background~~, ~~text~~), filter (duotone) -- **Attributes:** align, alt, caption, height, href, id, linkClass, linkDestination, linkTarget, rel, sizeSlug, title, url, width +- **Attributes:** align, alt, behaviors, caption, height, href, id, linkClass, linkDestination, linkTarget, rel, sizeSlug, title, url, width ## Latest Comments diff --git a/docs/reference-guides/data/data-core-block-editor.md b/docs/reference-guides/data/data-core-block-editor.md index a3d6fa25e97c89..8b0f24e60fb7bf 100644 --- a/docs/reference-guides/data/data-core-block-editor.md +++ b/docs/reference-guides/data/data-core-block-editor.md @@ -168,6 +168,29 @@ _Returns_ - `Array?`: The list of allowed block types. +### getBehaviors + +Returns the behaviors registered with the editor. + +Behaviors are named, reusable pieces of functionality that can be attached to blocks. They are registered with the editor using the `theme.json` file. + +_Usage_ + +```js +const behaviors = select( blockEditorStore ).getBehaviors(); +if ( behaviors?.lightbox ) { + // Do something with the lightbox. +} +``` + +_Parameters_ + +- _state_ `Object`: Editor state. + +_Returns_ + +- `Object`: The editor behaviors object. + ### getBlock Returns a block given its client ID. This is a parsed copy of the block, containing its `blockName`, `clientId`, and current `attributes` state. This is not the block's registration settings, which must be retrieved from the blocks module registration store. diff --git a/lib/class-wp-theme-json-gutenberg.php b/lib/class-wp-theme-json-gutenberg.php index 9e7a7316ffdd29..c27572e735ee1e 100644 --- a/lib/class-wp-theme-json-gutenberg.php +++ b/lib/class-wp-theme-json-gutenberg.php @@ -323,6 +323,7 @@ class WP_Theme_JSON_Gutenberg { 'templateParts', 'title', 'version', + 'behaviors', ); /** @@ -404,6 +405,7 @@ class WP_Theme_JSON_Gutenberg { 'textDecoration' => null, 'textTransform' => null, ), + 'behaviors' => null, ); /** diff --git a/lib/compat/wordpress-6.3/behaviors.php b/lib/compat/wordpress-6.3/behaviors.php new file mode 100644 index 00000000000000..62e7be7a252d49 --- /dev/null +++ b/lib/compat/wordpress-6.3/behaviors.php @@ -0,0 +1,20 @@ +get_data(); + if ( array_key_exists( 'behaviors', $theme_data ) ) { + $settings['behaviors'] = $theme_data['behaviors']; + } + return $settings; + }, + PHP_INT_MAX +); diff --git a/lib/load.php b/lib/load.php index a97a7cdc881f5e..f66fe7f474b961 100644 --- a/lib/load.php +++ b/lib/load.php @@ -51,6 +51,7 @@ function gutenberg_is_experiment_enabled( $name ) { require_once __DIR__ . '/compat/wordpress-6.3/theme-previews.php'; require_once __DIR__ . '/compat/wordpress-6.3/navigation-block-preloading.php'; require_once __DIR__ . '/compat/wordpress-6.3/link-template.php'; + require_once __DIR__ . '/compat/wordpress-6.3/behaviors.php'; // Experimental. if ( ! class_exists( 'WP_Rest_Customizer_Nonces' ) ) { @@ -165,4 +166,3 @@ function gutenberg_is_experiment_enabled( $name ) { require __DIR__ . '/block-supports/duotone.php'; require __DIR__ . '/block-supports/anchor.php'; require __DIR__ . '/block-supports/shadow.php'; - diff --git a/lib/theme.json b/lib/theme.json index 88befe6dff2ed0..6955d8f2f3016e 100644 --- a/lib/theme.json +++ b/lib/theme.json @@ -1,5 +1,12 @@ { "version": 2, + "behaviors": { + "blocks": { + "core/image": { + "lightbox": false + } + } + }, "settings": { "appearanceTools": false, "useRootPaddingAwareAlignments": false, @@ -450,6 +457,11 @@ "style": true, "width": true } + }, + "core/image": { + "behaviors": { + "lightbox": true + } } } }, diff --git a/package-lock.json b/package-lock.json index 5833c451c403be..2f674821f32951 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17195,6 +17195,7 @@ "change-case": "^4.1.2", "classnames": "^2.3.1", "colord": "^2.7.0", + "deepmerge": "^4.3.0", "diff": "^4.0.2", "dom-scroll-into-view": "^1.2.1", "fast-deep-equal": "^3.1.3", @@ -17205,6 +17206,13 @@ "rememo": "^4.0.2", "remove-accents": "^0.4.2", "traverse": "^0.6.6" + }, + "dependencies": { + "deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==" + } } }, "@wordpress/block-library": { @@ -17298,6 +17306,13 @@ "showdown": "^1.9.1", "simple-html-tokenizer": "^0.5.7", "uuid": "^8.3.0" + }, + "dependencies": { + "deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==" + } } }, "@wordpress/browserslist-config": { @@ -29227,7 +29242,7 @@ "co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", - "integrity": "sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ=", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", "dev": true }, "code-point-at": { @@ -30804,7 +30819,7 @@ "css.escape": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", - "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "integrity": "sha1-QuJ9T6BK4y+TGktNQZH6nN3ul8s=", "dev": true }, "cssesc": { @@ -41365,7 +41380,7 @@ "lz-string": { "version": "1.4.4", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.4.4.tgz", - "integrity": "sha512-0ckx7ZHRPqb0oUm8zNr+90mtf9DQB60H1wMCjBtfi62Kl3a7JbHob6gA2bC+xRvZoOL+1hzUK8jeuEIQE8svEQ==", + "integrity": "sha1-wNjq82BZ9wV5bh40SBHPTEmNOiY=", "dev": true }, "macos-release": { diff --git a/packages/block-editor/package.json b/packages/block-editor/package.json index efd980cba06c66..9e934a68662c2e 100644 --- a/packages/block-editor/package.json +++ b/packages/block-editor/package.json @@ -65,6 +65,7 @@ "change-case": "^4.1.2", "classnames": "^2.3.1", "colord": "^2.7.0", + "deepmerge": "^4.3.0", "diff": "^4.0.2", "dom-scroll-into-view": "^1.2.1", "fast-deep-equal": "^3.1.3", diff --git a/packages/block-editor/src/hooks/behaviors.js b/packages/block-editor/src/hooks/behaviors.js new file mode 100644 index 00000000000000..e47e47b7b610ae --- /dev/null +++ b/packages/block-editor/src/hooks/behaviors.js @@ -0,0 +1,104 @@ +/** + * WordPress dependencies + */ +import { addFilter } from '@wordpress/hooks'; +import { SelectControl } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { createHigherOrderComponent } from '@wordpress/compose'; +import { select } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { InspectorControls } from '../components'; +import { store as blockEditorStore } from '../store'; + +/** + * External dependencies + */ +import merge from 'deepmerge'; + +/** + * Override the default edit UI to include a new block inspector control for + * assigning behaviors to blocks if behaviors are enabled in the theme.json. + * + * Currently, only the `core/image` block is supported. + * + * @param {WPComponent} BlockEdit Original component. + * + * @return {WPComponent} Wrapped component. + */ +export const withBehaviors = createHigherOrderComponent( ( BlockEdit ) => { + return ( props ) => { + // Only add behaviors to the core/image block. + if ( props.name !== 'core/image' ) { + return ; + } + + const settings = + select( blockEditorStore ).getSettings()?.__experimentalFeatures + ?.blocks?.[ props.name ]?.behaviors; + + if ( + ! settings || + // If every behavior is disabled, do not show the behaviors inspector control. + Object.entries( settings ).every( ( [ , value ] ) => ! value ) + ) { + return ; + } + + const { behaviors: blockBehaviors } = props.attributes; + + // Get the theme behaviors for the block from the theme.json. + const themeBehaviors = + select( blockEditorStore ).getBehaviors()?.blocks?.[ props.name ]; + + // Block behaviors take precedence over theme behaviors. + const behaviors = merge( themeBehaviors, blockBehaviors || {} ); + + return ( + <> + + + behaviorValue ) // Filter out behaviors that are disabled. + .map( ( [ behaviorName ] ) => ( { + value: behaviorName, + label: + // Capitalize the first letter of the behavior name. + behaviorName[ 0 ].toUpperCase() + + behaviorName.slice( 1 ).toLowerCase(), + } ) ) + .concat( { + value: '', + label: __( 'No behaviors' ), + } ) } + onChange={ ( nextValue ) => { + // If the user selects something, it means that they want to + // change the default value (true) so we save it in the attributes. + props.setAttributes( { + behaviors: { + lightbox: nextValue === 'lightbox', + }, + } ); + } } + hideCancelButton={ true } + help={ __( 'Add behaviors' ) } + size="__unstable-large" + /> + + + ); + }; +}, 'withBehaviors' ); + +addFilter( + 'editor.BlockEdit', + 'core/behaviors/with-inspector-control', + withBehaviors +); diff --git a/packages/block-editor/src/hooks/index.js b/packages/block-editor/src/hooks/index.js index 2077c143952f59..a66aa0a73ed411 100644 --- a/packages/block-editor/src/hooks/index.js +++ b/packages/block-editor/src/hooks/index.js @@ -20,6 +20,7 @@ import './layout'; import './content-lock-ui'; import './metadata'; import './metadata-name'; +import './behaviors'; export { useCustomSides } from './dimensions'; export { useLayoutClasses, useLayoutStyles } from './layout'; diff --git a/packages/block-editor/src/store/selectors.js b/packages/block-editor/src/store/selectors.js index 59c36dca2b8237..487d13db811d84 100644 --- a/packages/block-editor/src/store/selectors.js +++ b/packages/block-editor/src/store/selectors.js @@ -2490,6 +2490,30 @@ export function getSettings( state ) { return state.settings; } +/** + * Returns the behaviors registered with the editor. + * + * Behaviors are named, reusable pieces of functionality that can be + * attached to blocks. They are registered with the editor using the + * `theme.json` file. + * + * @example + * + * ```js + * const behaviors = select( blockEditorStore ).getBehaviors(); + * if ( behaviors?.lightbox ) { + * // Do something with the lightbox. + * } + *``` + * + * @param {Object} state Editor state. + * + * @return {Object} The editor behaviors object. + */ +export function getBehaviors( state ) { + return state.settings.behaviors; +} + /** * Returns true if the most recent block change is be considered persistent, or * false otherwise. A persistent change is one committed by BlockEditorProvider diff --git a/packages/block-library/src/image/block.json b/packages/block-library/src/image/block.json index 92931455c1144c..791e09f73c8009 100644 --- a/packages/block-library/src/image/block.json +++ b/packages/block-library/src/image/block.json @@ -80,6 +80,9 @@ "source": "attribute", "selector": "figure > a", "attribute": "target" + }, + "behaviors": { + "type": "object" } }, "supports": { diff --git a/packages/editor/src/components/provider/use-block-editor-settings.js b/packages/editor/src/components/provider/use-block-editor-settings.js index d8035f4f54fca1..8a71fb5deca65f 100644 --- a/packages/editor/src/components/provider/use-block-editor-settings.js +++ b/packages/editor/src/components/provider/use-block-editor-settings.js @@ -75,6 +75,7 @@ const BLOCK_EDITOR_SETTINGS = [ '__unstableIsPreviewMode', '__unstableResolvedAssets', '__unstableIsBlockBasedTheme', + 'behaviors', ]; /** diff --git a/test/e2e/specs/editor/various/behaviors.spec.js b/test/e2e/specs/editor/various/behaviors.spec.js new file mode 100644 index 00000000000000..b219ebfb809c13 --- /dev/null +++ b/test/e2e/specs/editor/various/behaviors.spec.js @@ -0,0 +1,161 @@ +/** + * External dependencies + */ +const path = require( 'path' ); + +/** + * WordPress dependencies + */ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +test.describe( 'Testing behaviors functionality', () => { + const filename = '1024x768_e2e_test_image_size.jpeg'; + const filepath = path.join( './test/e2e/assets', filename ); + + const createMedia = async ( { admin, requestUtils } ) => { + await admin.createNewPost(); + const media = await requestUtils.uploadMedia( filepath ); + return media; + }; + + test.afterAll( async ( { requestUtils } ) => { + await requestUtils.activateTheme( 'twentytwentyone' ); + await requestUtils.deleteAllPosts(); + } ); + + test.afterEach( async ( { requestUtils } ) => { + await requestUtils.deleteAllMedia(); + } ); + + test( '`No Behaviors` should be the default as defined in the core theme.json', async ( { + admin, + editor, + requestUtils, + page, + } ) => { + await requestUtils.activateTheme( 'twentytwentyone' ); + const media = await createMedia( { admin, requestUtils } ); + await editor.insertBlock( { + name: 'core/image', + attributes: { + alt: filename, + id: media.id, + url: media.source_url, + }, + } ); + + await page.getByRole( 'button', { name: 'Advanced' } ).click(); + const select = page.getByLabel( 'Behavior' ); + + // By default, no behaviors should be selected. + await expect( select ).toHaveCount( 1 ); + await expect( select ).toHaveValue( '' ); + + // By default, you should be able to select the Lightbox behavior. + const options = select.locator( 'option' ); + await expect( options ).toHaveCount( 2 ); + } ); + + test( 'Behaviors UI can be disabled in the `theme.json`', async ( { + admin, + editor, + requestUtils, + page, + } ) => { + // { "lightbox": true } is the default behavior setting, so we activate the + // `behaviors-ui-disabled` theme where it is disabled by default. Change if we change + // the default value in the core theme.json file. + await requestUtils.activateTheme( 'behaviors-ui-disabled' ); + const media = await createMedia( { admin, requestUtils } ); + + await editor.insertBlock( { + name: 'core/image', + attributes: { + alt: filename, + id: media.id, + url: media.source_url, + }, + } ); + + await page.getByRole( 'button', { name: 'Advanced' } ).click(); + + // No behaviors dropdown should be present. + await expect( page.getByLabel( 'Behavior' ) ).toHaveCount( 0 ); + } ); + + test( "Block's value for behaviors takes precedence over the theme's value", async ( { + admin, + editor, + requestUtils, + page, + } ) => { + await requestUtils.activateTheme( 'twentytwentyone' ); + const media = await createMedia( { admin, requestUtils } ); + + await editor.insertBlock( { + name: 'core/image', + attributes: { + alt: filename, + id: media.id, + url: media.source_url, + // Explicitly set the value for behaviors to true. + behaviors: { lightbox: true }, + }, + } ); + + await page.getByRole( 'button', { name: 'Advanced' } ).click(); + const select = page.getByLabel( 'Behavior' ); + + // The lightbox should be selected because the value from the block's + // attributes takes precedence over the theme's value. + await expect( select ).toHaveCount( 1 ); + await expect( select ).toHaveValue( 'lightbox' ); + + // There should be 2 options available: `No behaviors` and `Lightbox`. + const options = select.locator( 'option' ); + await expect( options ).toHaveCount( 2 ); + + // We can change the value of the behaviors dropdown to `No behaviors`. + await select.selectOption( { label: 'No behaviors' } ); + await expect( select ).toHaveValue( '' ); + + // Here we should also check that the block renders on the frontend with the + // lightbox even though the theme.json has it set to false. + } ); + + test( 'You can set the default value for the behaviors in the theme.json', async ( { + admin, + editor, + requestUtils, + page, + } ) => { + // In this theme, the default value for settings.behaviors.blocks.core/image.lightbox is `true`. + await requestUtils.activateTheme( 'behaviors-enabled' ); + const media = await createMedia( { admin, requestUtils } ); + + await editor.insertBlock( { + name: 'core/image', + attributes: { + alt: filename, + id: media.id, + url: media.source_url, + }, + } ); + + await page.getByRole( 'button', { name: 'Advanced' } ).click(); + const select = page.getByLabel( 'Behavior' ); + + // The behaviors dropdown should be present and the value should be set to + // `lightbox`. + await expect( select ).toHaveCount( 1 ); + await expect( select ).toHaveValue( 'lightbox' ); + + // There should be 2 options available: `No behaviors` and `Lightbox`. + const options = select.locator( 'option' ); + await expect( options ).toHaveCount( 2 ); + + // We can change the value of the behaviors dropdown to `No behaviors`. + await select.selectOption( { label: 'No behaviors' } ); + await expect( select ).toHaveValue( '' ); + } ); +} ); diff --git a/test/gutenberg-test-themes/behaviors-enabled/index.php b/test/gutenberg-test-themes/behaviors-enabled/index.php new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/test/gutenberg-test-themes/behaviors-enabled/style.css b/test/gutenberg-test-themes/behaviors-enabled/style.css new file mode 100644 index 00000000000000..c8ae349f915bef --- /dev/null +++ b/test/gutenberg-test-themes/behaviors-enabled/style.css @@ -0,0 +1,16 @@ +/* +Theme Name: Behaviors Enabled + +Theme URI: https://github.com/wordpress/theme-experiments/ +Author: the WordPress team +Description: Behaviors test theme. +Requires at least: 5.3 +Tested up to: 6.2 +Requires PHP: 5.6 +Version: 1.0 +License: GNU General Public License v2 or later +License URI: http://www.gnu.org/licenses/gpl-2.0.html +Text Domain: behaviors +Behaviors WordPress Theme, (C) 2023 WordPress.org +Behaviors is distributed under the terms of the GNU GPL. +*/ diff --git a/test/gutenberg-test-themes/behaviors-enabled/theme.json b/test/gutenberg-test-themes/behaviors-enabled/theme.json new file mode 100644 index 00000000000000..f49129622d9f6d --- /dev/null +++ b/test/gutenberg-test-themes/behaviors-enabled/theme.json @@ -0,0 +1,10 @@ +{ + "version": 2, + "behaviors": { + "blocks": { + "core/image": { + "lightbox": true + } + } + } +} diff --git a/test/gutenberg-test-themes/behaviors-ui-disabled/index.php b/test/gutenberg-test-themes/behaviors-ui-disabled/index.php new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/test/gutenberg-test-themes/behaviors-ui-disabled/style.css b/test/gutenberg-test-themes/behaviors-ui-disabled/style.css new file mode 100644 index 00000000000000..2cf6387b490e91 --- /dev/null +++ b/test/gutenberg-test-themes/behaviors-ui-disabled/style.css @@ -0,0 +1,15 @@ +/* +Theme Name: Behaviors UI Disabled +Theme URI: https://github.com/wordpress/theme-experiments/ +Author: the WordPress team +Description: Behaviors test theme. +Requires at least: 5.3 +Tested up to: 6.2 +Requires PHP: 5.6 +Version: 1.0 +License: GNU General Public License v2 or later +License URI: http://www.gnu.org/licenses/gpl-2.0.html +Text Domain: behaviors +Behaviors WordPress Theme, (C) 2023 WordPress.org +Behaviors is distributed under the terms of the GNU GPL. +*/ diff --git a/test/gutenberg-test-themes/behaviors-ui-disabled/theme.json b/test/gutenberg-test-themes/behaviors-ui-disabled/theme.json new file mode 100644 index 00000000000000..a9f920f6dd0abc --- /dev/null +++ b/test/gutenberg-test-themes/behaviors-ui-disabled/theme.json @@ -0,0 +1,12 @@ +{ + "version": 2, + "settings": { + "blocks": { + "core/image": { + "behaviors": { + "lightbox": false + } + } + } + } +}