diff --git a/src/wp-includes/block-supports/rotation.php b/src/wp-includes/block-supports/rotation.php new file mode 100644 index 0000000000000..06248b1831893 --- /dev/null +++ b/src/wp-includes/block-supports/rotation.php @@ -0,0 +1,199 @@ +attributes ) { + $block_type->attributes = array(); + } + + // Check for existing style attribute definition e.g. from block.json. + if ( ! array_key_exists( 'style', $block_type->attributes ) ) { + $block_type->attributes['style'] = array( + 'type' => 'object', + ); + } +} + +/** + * Reads a stored rotation, which is a number of degrees. + * + * Block attributes are untrusted, so anything that is not numeric is ignored. + * The angle is rounded to two decimals and wrapped into the (-180, 180] range. + * + * @since 7.3.0 + * @access private + * + * @param mixed $value Stored rotation. + * @return float|null The rotation in degrees, or null when the value is not a rotation. + */ +function wp_get_rotate_value( $value ) { + // PHP 7 rejects numeric strings with trailing whitespace, PHP 8 accepts them. + if ( is_string( $value ) ) { + $value = trim( $value ); + } + if ( ! is_numeric( $value ) ) { + return null; + } + + $angle = (float) $value; + if ( ! is_finite( $angle ) ) { + return null; + } + + $angle = fmod( round( $angle, 2 ), 360 ); + if ( $angle <= -180 ) { + $angle += 360; + } elseif ( $angle > 180 ) { + $angle -= 360; + } + + // Avoid returning -0. + return 0.0 === $angle ? 0.0 : $angle; +} + +/** + * Builds the CSS rules that rotate a block, without their selector. + * + * The `rotate` property is used because it maps most directly to the editor feature. + * In a viewport override, 0 undoes the default rotation, so it is output as `none`. + * + * @since 7.3.0 + * @access private + * + * @param array $style Block style attribute. + * @param array $responsive_media_queries Media queries keyed by viewport state, e.g. `@mobile`. + * @return array[] CSS rules with `declarations` and, for viewport overrides, `rules_group`. + */ +function wp_get_rotate_style_rules( $style, $responsive_media_queries ) { + $rules = array(); + $rotate = wp_get_rotate_value( $style['rotate'] ?? null ); + + if ( $rotate ) { + $rules[] = array( + 'declarations' => array( 'rotate' => $rotate . 'deg' ), + ); + } + + foreach ( $responsive_media_queries as $breakpoint => $media_query ) { + if ( ! isset( $style[ $breakpoint ] ) || ! is_array( $style[ $breakpoint ] ) ) { + continue; + } + + $viewport_rotate = wp_get_rotate_value( $style[ $breakpoint ]['rotate'] ?? null ); + if ( null === $viewport_rotate ) { + continue; + } + + $rules[] = array( + 'rules_group' => $media_query, + 'declarations' => array( 'rotate' => $viewport_rotate ? $viewport_rotate . 'deg' : 'none' ), + ); + } + + return $rules; +} + +/** + * Rotates a block on the front end. + * + * The rules are added to the block supports stylesheet under a class generated from + * them. + * + * @since 7.3.0 + * @access private + * + * @param string $block_content Rendered block content. + * @param array $block Block object. + * @return string Filtered block content. + */ +function wp_render_rotate_support( $block_content, $block ) { + $style = $block['attrs']['style'] ?? null; + if ( empty( $block_content ) || empty( $block['blockName'] ) || ! is_array( $style ) ) { + return $block_content; + } + + $has_viewport_rotate = false; + foreach ( $style as $key => $value ) { + if ( is_string( $key ) && str_starts_with( $key, '@' ) && is_array( $value ) && array_key_exists( 'rotate', $value ) ) { + $has_viewport_rotate = true; + break; + } + } + + if ( ! array_key_exists( 'rotate', $style ) && ! $has_viewport_rotate ) { + return $block_content; + } + + $block_type = WP_Block_Type_Registry::get_instance()->get_registered( $block['blockName'] ); + if ( ! $block_type || ! block_has_support( $block_type, 'rotation', false ) ) { + return $block_content; + } + + // A block's own `style` attribute that is not an object doesn't hold a rotation. + $style_attribute_type = $block_type->attributes['style']['type'] ?? 'object'; + if ( 'object' !== $style_attribute_type ) { + return $block_content; + } + + // Only resolve the global settings when there is a viewport override to place. + $responsive_media_queries = $has_viewport_rotate + ? WP_Theme_JSON::get_viewport_media_queries( wp_get_global_settings( array( 'viewport' ) ) ) + : array(); + $rules = wp_get_rotate_style_rules( $style, $responsive_media_queries ); + if ( empty( $rules ) ) { + return $block_content; + } + + $processor = new WP_HTML_Tag_Processor( $block_content ); + if ( ! $processor->next_tag() ) { + return $block_content; + } + + $class_name = wp_unique_id_from_values( $rules, 'wp-rotate-' ); + foreach ( $rules as $index => $rule ) { + $rules[ $index ]['selector'] = ".$class_name"; + } + + wp_style_engine_get_stylesheet_from_css_rules( + $rules, + array( + 'context' => 'block-supports', + 'prettify' => false, + ) + ); + + $processor->add_class( $class_name ); + + return $processor->get_updated_html(); +} + +// Register the block support. +WP_Block_Supports::get_instance()->register( + 'rotation', + array( + 'register_attribute' => 'wp_register_rotate_support', + ) +); + +add_filter( 'render_block', 'wp_render_rotate_support', 10, 2 ); diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php index aee33b474a4d6..9aadf7fc16dac 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -3721,6 +3721,7 @@ function kses_init() { * and URLs in the SVG element reference properties. * @since 7.2.0 Added support for CSS anchor positioning properties and `text-shadow`. * Added support for `background-clip`. + * @since 7.3.0 Added support for `rotate`. * * @param string $css A string of CSS rules, decoded from an HTML `style` attribute. * @param string $deprecated Not used. @@ -3908,6 +3909,7 @@ function safecss_filter_attr( $css, $deprecated = '' ) { 'box-shadow', 'aspect-ratio', 'container-type', + 'rotate', 'fill', 'fill-opacity', diff --git a/src/wp-settings.php b/src/wp-settings.php index 92f9456aa02ca..7c122f2fbb110 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -463,6 +463,7 @@ require ABSPATH . WPINC . '/block-supports/anchor.php'; require ABSPATH . WPINC . '/block-supports/block-visibility.php'; require ABSPATH . WPINC . '/block-supports/custom-css.php'; +require ABSPATH . WPINC . '/block-supports/rotation.php'; require ABSPATH . WPINC . '/block-supports/states.php'; require ABSPATH . WPINC . '/style-engine.php'; require ABSPATH . WPINC . '/style-engine/class-wp-style-engine.php'; diff --git a/tests/phpunit/tests/block-supports/rotation.php b/tests/phpunit/tests/block-supports/rotation.php new file mode 100644 index 0000000000000..65ecd88abe7c1 --- /dev/null +++ b/tests/phpunit/tests/block-supports/rotation.php @@ -0,0 +1,395 @@ +test_block_name = null; + WP_Style_Engine_CSS_Rules_Store::remove_all_stores(); + } + + public function tear_down() { + if ( $this->test_block_name ) { + unregister_block_type( $this->test_block_name ); + } + $this->test_block_name = null; + WP_Style_Engine_CSS_Rules_Store::remove_all_stores(); + parent::tear_down(); + } + + /** + * Registers a block for the tests. + * + * @param array $supports Block supports. + * @param array $attributes Block attributes. + * @return WP_Block_Type The registered block type. + */ + private function register_test_block( $supports = array( 'rotation' => true ), $attributes = array() ) { + $this->test_block_name = 'test/rotate-block'; + register_block_type( + $this->test_block_name, + array( + 'api_version' => 3, + 'attributes' => $attributes, + 'supports' => $supports, + ) + ); + + return WP_Block_Type_Registry::get_instance()->get_registered( $this->test_block_name ); + } + + /** + * Renders the test block with a style attribute. + * + * @param array $style Block style attribute. + * @param string $block_content Rendered block content. + * @return string Filtered block content. + */ + private function render_test_block( $style, $block_content = '
Content
' ) { + return wp_render_rotate_support( + $block_content, + array( + 'blockName' => 'test/rotate-block', + 'attrs' => array( 'style' => $style ), + ) + ); + } + + /** + * Returns the class the rotate support added to rendered block content. + * + * @param string $block_content Rendered block content. + * @return string|null The class, or null when there is none. + */ + private function get_rotate_class( $block_content ) { + return preg_match( '/wp-rotate-[a-f0-9]+/', $block_content, $matches ) ? $matches[0] : null; + } + + /** + * Returns the block supports stylesheet. + * + * @return string The stylesheet. + */ + private function get_block_supports_stylesheet() { + return wp_style_engine_get_stylesheet_from_context( 'block-supports', array( 'prettify' => false ) ); + } + + /** + * Tests that a block supports rotation only when it opts in. + * + * @covers ::wp_register_rotate_support + */ + public function test_registers_style_attribute_only_when_block_opts_in() { + $block_type = $this->register_test_block( array( 'rotation' => true ) ); + wp_register_rotate_support( $block_type ); + $this->assertSame( array( 'type' => 'object' ), $block_type->attributes['style'] ?? null, 'A block that opts in supports rotation.' ); + unregister_block_type( $this->test_block_name ); + + $block_type = $this->register_test_block( array() ); + wp_register_rotate_support( $block_type ); + $this->assertArrayNotHasKey( 'style', $block_type->attributes, 'A block that does not opt in does not get a style attribute from rotation.' ); + } + + /** + * Tests that a block's own style attribute definition is kept. + * + * @covers ::wp_register_rotate_support + */ + public function test_keeps_existing_style_attribute() { + $style_attribute = array( + 'type' => 'object', + 'default' => array( 'color' => array( 'text' => 'red' ) ), + ); + $block_type = $this->register_test_block( array( 'rotation' => true ), array( 'style' => $style_attribute ) ); + wp_register_rotate_support( $block_type ); + + $this->assertSame( $style_attribute, $block_type->attributes['style'] ); + } + + /** + * Tests that stored rotations are read as angles in (-180, 180]. + * + * @covers ::wp_get_rotate_value + * + * @dataProvider data_get_rotate_value + * + * @param mixed $value Stored rotation. + * @param float|null $expected Expected rotation. + */ + public function test_get_rotate_value( $value, $expected ) { + $this->assertSame( $expected, wp_get_rotate_value( $value ) ); + } + + /** + * Data provider for test_get_rotate_value(). + * + * @return array[] + */ + public function data_get_rotate_value() { + return array( + 'integer' => array( 15, 15.0 ), + 'negative float' => array( -12.5, -12.5 ), + 'numeric string' => array( '30', 30.0 ), + 'zero' => array( 0, 0.0 ), + 'half turn' => array( 180, 180.0 ), + 'minus half turn' => array( -180, 180.0 ), + 'more than a half turn' => array( 270, -90.0 ), + 'more than a turn' => array( 405, 45.0 ), + 'whole negative turn' => array( -360, 0.0 ), + 'rounded to two decimals' => array( 12.3456, 12.35 ), + 'negative half rounded away from zero' => array( -12.345, -12.35 ), + 'half rounded away from zero' => array( 1.005, 1.01 ), + 'numeric string with spaces' => array( ' 15 ', 15.0 ), + 'numeric string with exponent' => array( '1e2', 100.0 ), + 'hexadecimal string' => array( '0x10', null ), // phpcs:ignore PHPCompatibility.Miscellaneous.ValidIntegers.HexNumericStringFound -- Checks that hexadecimal strings are rejected. + 'binary string' => array( '0b11', null ), + 'rounded onto a half turn' => array( -179.999, 180.0 ), + 'string with a unit' => array( '15deg', null ), + 'string with a css injection' => array( '15deg; color: red', null ), + 'empty string' => array( '', null ), + 'null' => array( null, null ), + 'boolean' => array( true, null ), + 'array' => array( array( 15 ), null ), + 'infinite' => array( INF, null ), + 'not a number' => array( NAN, null ), + ); + } + + /** + * Tests that a rotated block gets a class with a rotate rule. + * + * @covers ::wp_render_rotate_support + */ + public function test_renders_default_rotation() { + $this->register_test_block(); + + $actual = $this->render_test_block( array( 'rotate' => 15 ) ); + $class_name = $this->get_rotate_class( $actual ); + + $this->assertNotNull( $class_name, 'The block should get a rotate class.' ); + $this->assertSame( + '
Content
', + $actual + ); + $this->assertSame( ".$class_name{rotate:15deg;}", $this->get_block_supports_stylesheet() ); + } + + /** + * Tests that the rotation is wrapped and rounded before it is output. + * + * @covers ::wp_render_rotate_support + */ + public function test_renders_normalized_rotation() { + $this->register_test_block(); + + $class_name = $this->get_rotate_class( $this->render_test_block( array( 'rotate' => '-270.004' ) ) ); + + $this->assertSame( ".$class_name{rotate:90deg;}", $this->get_block_supports_stylesheet() ); + } + + /** + * Tests that blocks without a rotation are left alone. + * + * @covers ::wp_render_rotate_support + * + * @dataProvider data_does_not_render_without_rotation + * + * @param array $style Block style attribute. + */ + public function test_does_not_render_without_rotation( $style ) { + $this->register_test_block(); + $block_content = '
Content
'; + + $this->assertSame( $block_content, $this->render_test_block( $style, $block_content ) ); + $this->assertSame( '', $this->get_block_supports_stylesheet() ); + } + + /** + * Data provider for test_does_not_render_without_rotation(). + * + * @return array[] + */ + public function data_does_not_render_without_rotation() { + return array( + 'no rotation' => array( array( 'color' => array( 'text' => 'red' ) ) ), + 'zero rotation' => array( array( 'rotate' => 0 ) ), + 'rotation that is not a number' => array( array( 'rotate' => '15deg; color: red' ) ), + 'viewport rotation that is not a number' => array( array( '@mobile' => array( 'rotate' => 'none' ) ) ), + 'malformed viewport style' => array( array( '@mobile' => 'rotate' ) ), + ); + } + + /** + * Tests that viewport overrides are output in media queries after the default rotation, + * and that 0 undoes the default rotation. + * + * @covers ::wp_render_rotate_support + */ + public function test_renders_viewport_overrides() { + $this->register_test_block(); + + $class_name = $this->get_rotate_class( + $this->render_test_block( + array( + 'rotate' => 15, + '@mobile' => array( 'rotate' => 0 ), + '@tablet' => array( 'rotate' => -20 ), + ) + ) + ); + + $this->assertNotNull( $class_name, 'The block should get a rotate class.' ); + $this->assertSame( + ".$class_name{rotate:15deg;}" . + "@media (width <= 480px){.$class_name{rotate:none;}}" . + "@media (480px < width <= 782px){.$class_name{rotate:-20deg;}}", + $this->get_block_supports_stylesheet() + ); + } + + /** + * Tests that a block can be rotated on a viewport only. + * + * @covers ::wp_render_rotate_support + */ + public function test_renders_viewport_rotation_without_default_rotation() { + $this->register_test_block(); + + $class_name = $this->get_rotate_class( + $this->render_test_block( + array( + '@mobile' => array( 'rotate' => 45 ), + ) + ) + ); + + $this->assertNotNull( $class_name, 'The block should get a rotate class.' ); + $this->assertSame( + "@media (width <= 480px){.$class_name{rotate:45deg;}}", + $this->get_block_supports_stylesheet() + ); + } + + /** + * Tests that blocks with the same rotation share a class and a rule. + * + * @covers ::wp_render_rotate_support + */ + public function test_blocks_with_the_same_rotation_share_a_class() { + $this->register_test_block(); + + $first = $this->get_rotate_class( $this->render_test_block( array( 'rotate' => 15 ) ) ); + $second = $this->get_rotate_class( $this->render_test_block( array( 'rotate' => 15 ) ) ); + $third = $this->get_rotate_class( $this->render_test_block( array( 'rotate' => 30 ) ) ); + + $this->assertSame( $first, $second, 'Blocks with the same rotation should share a class.' ); + $this->assertNotSame( $first, $third, 'Blocks with different rotations should have different classes.' ); + $this->assertSame( ".$first{rotate:15deg;}.$third{rotate:30deg;}", $this->get_block_supports_stylesheet() ); + } + + /** + * Tests that a block whose own style attribute is not an object is not rotated. + * + * @covers ::wp_render_rotate_support + */ + public function test_does_not_render_when_style_attribute_is_not_an_object() { + $this->register_test_block( array( 'rotation' => true ), array( 'style' => array( 'type' => 'string' ) ) ); + $block_content = '
Content
'; + + $this->assertSame( $block_content, $this->render_test_block( array( 'rotate' => 15 ), $block_content ) ); + $this->assertSame( '', $this->get_block_supports_stylesheet() ); + } + + /** + * Tests that viewport overrides use the theme's viewport breakpoints. + * + * @covers ::wp_render_rotate_support + */ + public function test_renders_viewport_overrides_with_theme_breakpoints() { + $this->register_test_block(); + + $filter = static function ( $theme_json ) { + return $theme_json->update_with( + array( + 'version' => WP_Theme_JSON::LATEST_SCHEMA, + 'settings' => array( + 'viewport' => array( + 'mobile' => '640px', + 'tablet' => '960px', + ), + ), + ) + ); + }; + + add_filter( 'wp_theme_json_data_theme', $filter ); + WP_Theme_JSON_Resolver::clean_cached_data(); + + try { + $class_name = $this->get_rotate_class( + $this->render_test_block( + array( + '@mobile' => array( 'rotate' => 10 ), + '@tablet' => array( 'rotate' => 0 ), + ) + ) + ); + + $this->assertSame( + "@media (width <= 640px){.$class_name{rotate:10deg;}}" . + "@media (640px < width <= 960px){.$class_name{rotate:none;}}", + $this->get_block_supports_stylesheet() + ); + } finally { + remove_filter( 'wp_theme_json_data_theme', $filter ); + WP_Theme_JSON_Resolver::clean_cached_data(); + } + } + + /** + * Tests that blocks that opt out of rotation are not rotated. + * + * @covers ::wp_render_rotate_support + */ + public function test_does_not_render_when_block_opts_out() { + $this->register_test_block( array() ); + $block_content = '
Content
'; + + $this->assertSame( $block_content, $this->render_test_block( array( 'rotate' => 15 ), $block_content ) ); + $this->assertSame( '', $this->get_block_supports_stylesheet() ); + } + + /** + * Tests that block content without a tag to rotate is left alone. + * + * @covers ::wp_render_rotate_support + */ + public function test_does_not_render_without_a_tag() { + $this->register_test_block(); + + $this->assertSame( 'Plain text', $this->render_test_block( array( 'rotate' => 15 ), 'Plain text' ) ); + $this->assertSame( '', $this->get_block_supports_stylesheet() ); + } + + /** + * Tests that the rotate property is allowed in inline styles. + * + * @covers ::safecss_filter_attr + */ + public function test_rotate_is_a_safe_css_property() { + $this->assertSame( 'rotate: 15deg', safecss_filter_attr( 'rotate: 15deg' ) ); + $this->assertSame( 'rotate: none', safecss_filter_attr( 'rotate: none' ) ); + } +}