Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
0fe519d
Block Editor: Add a rotate block support
draganescu Oct 10, 2026
7b038e4
Block Editor: Opt wrapperless blocks out of rotation and harden values
draganescu Oct 10, 2026
6e46f16
Block Editor: Allow the hexadecimal string in the rotate support tests
draganescu Oct 10, 2026
68a6cf5
Block Editor: Trim rotation strings before checking they are numeric
draganescu Oct 10, 2026
0de41db
Rotate: Type the viewport setting now that useSettings is typed
draganescu Oct 16, 2026
1c9b30d
Block Supports: Add the rotate style attribute in the style hook
draganescu Oct 21, 2026
f40c744
Block Supports: Convert the rotate tests to TypeScript
draganescu Oct 21, 2026
5105ad2
Block Supports: Only apply rotation with the block rotation experiment
draganescu Oct 21, 2026
273447a
Add the backport changelog entry for the rotate block support
draganescu Oct 22, 2026
a6447e1
Rotate: Avoid duplicate core rules and keep picker decimals
draganescu Oct 23, 2026
3ed18c4
Block Editor: Move rotation style helpers into utils/rotation
draganescu Oct 23, 2026
8664cf6
Block Editor: Move cleanEmptyObject into utils/object
draganescu Oct 23, 2026
fda2306
add import and fix type errors
tellthemachines Oct 27, 2026
62055ed
make rotation opt-in, non experimental and under position
tellthemachines Oct 27, 2026
9e1a35a
add support for group
tellthemachines Oct 27, 2026
34fada7
Document the rotation block support
draganescu Oct 30, 2026
ad7149e
Block Library: Link the rotation support in the block READMEs
draganescu Nov 2, 2026
503c67d
Block Editor: Check rotation support inline when adding the style att…
draganescu Nov 2, 2026
9fb81bf
Block Editor: Keep cleanEmptyObject in hooks/utils
draganescu Nov 2, 2026
216d8bf
Rotation: Target WordPress 7.3 and simplify the docblocks
draganescu Nov 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions backport-changelog/7.3/14707.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
https://github.com/WordPress/wordpress-develop/pull/14707

* https://github.com/WordPress/gutenberg/pull/84505
29 changes: 29 additions & 0 deletions docs/reference-guides/block-api/block-supports.md
Original file line number Diff line number Diff line change
Expand Up @@ -1005,6 +1005,35 @@ supports: {
}
```

## rotation

- Type: `boolean`
- Default value: `false`

This value signals that a block can be rotated. When it does, the block editor shows a Rotation control in the Position panel of the block settings. The rotation can be set separately for each viewport (desktop, tablet and mobile), and it is applied with the CSS `rotate` property, so it combines with any `transform` a theme or block applies.

```js
supports: {
// Enable the Rotation control.
rotation: true

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor - the support key is rotation, but the value is stored as style.rotate. Should they match?

}
```

When the block declares support for `rotation`, its attributes definition is extended to include the `style` attribute.

- `style`: an attribute of `object` type with no default assigned. This is added when `rotation` support is declared. It stores the angle in degrees, in the range -180 to 180, in `rotate`. Tablet and mobile overrides are stored under `@tablet` and `@mobile`, where 0 removes the default rotation on that viewport. For example:

```js
attributes: {
style: {
rotate: 15,
'@mobile': {
rotate: 0
}
}
}
```

## shadow

_**Note:** Since WordPress 6.5._
Expand Down
8 changes: 4 additions & 4 deletions docs/reference-guides/core-blocks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -366,7 +366,7 @@ Gather blocks in a layout container.

- **Name:** [core/group](https://developer.wordpress.org/block-editor/reference-guides/core-blocks/core-blocks-design/core-block-group/)
- **Category:** [design](https://developer.wordpress.org/block-editor/reference-guides/core-blocks/core-blocks-design/)
- **Supports:** align (full, wide), allowedBlocks, anchor, ariaLabel, background (backgroundImage, backgroundSize, gradient), color (background, button, gradients, heading, link, text), dimensions (minHeight, minWidth), interactivity (clientNavigation), layout (allowSizingOnChildren), position (sticky), shadow, spacing (blockGap, margin, padding), typography (fontSize, lineHeight, textColumns), ~~html~~
- **Supports:** align (full, wide), allowedBlocks, anchor, ariaLabel, background (backgroundImage, backgroundSize, gradient), color (background, button, gradients, heading, link, text), dimensions (minHeight, minWidth), interactivity (clientNavigation), layout (allowSizingOnChildren), position (sticky), rotation, shadow, spacing (blockGap, margin, padding), typography (fontSize, lineHeight, textColumns), ~~html~~
- **Attributes:** tagName, templateLock

## Heading
Expand All @@ -375,7 +375,7 @@ Introduce new sections and organize content to help visitors (and search engines

- **Name:** [core/heading](https://developer.wordpress.org/block-editor/reference-guides/core-blocks/core-blocks-text/core-block-heading/)
- **Category:** [text](https://developer.wordpress.org/block-editor/reference-guides/core-blocks/core-blocks-text/)
- **Supports:** __unstablePasteTextInline, align (full, wide), anchor, background (backgroundClip, backgroundImage, backgroundSize, gradient), className, color (background, gradients, link, text), dimensions (minHeight, minWidth), interactivity (clientNavigation), shadow, spacing (margin, padding), splitting, typography (fitText, fontSize, lineHeight, textAlign, textIndent, textShadow)
- **Supports:** __unstablePasteTextInline, align (full, wide), anchor, background (backgroundClip, backgroundImage, backgroundSize, gradient), className, color (background, gradients, link, text), dimensions (minHeight, minWidth), interactivity (clientNavigation), rotation, shadow, spacing (margin, padding), splitting, typography (fitText, fontSize, lineHeight, textAlign, textIndent, textShadow)
- **Attributes:** content, level, levelOptions, placeholder

## Home Link
Expand Down Expand Up @@ -412,7 +412,7 @@ Insert an image to make a visual statement.

- **Name:** [core/image](https://developer.wordpress.org/block-editor/reference-guides/core-blocks/core-blocks-media/core-block-image/)
- **Category:** [media](https://developer.wordpress.org/block-editor/reference-guides/core-blocks/core-blocks-media/)
- **Supports:** align (center, full, left, right, wide), anchor, color (~~background~~, ~~text~~), filter (duotone), interactivity, shadow, spacing (margin)
- **Supports:** align (center, full, left, right, wide), anchor, color (~~background~~, ~~text~~), filter (duotone), interactivity, rotation, shadow, spacing (margin)
- **Attributes:** alt, aspectRatio, blob, caption, focalPoint, height, href, id, isDecorative, lightbox, linkClass, linkDestination, linkTarget, rel, scale, sizeSlug, title, url, width

## Latest Comments
Expand Down Expand Up @@ -1099,7 +1099,7 @@ Embed a video from your media library or upload a new one.

- **Name:** [core/video](https://developer.wordpress.org/block-editor/reference-guides/core-blocks/core-blocks-media/core-block-video/)
- **Category:** [media](https://developer.wordpress.org/block-editor/reference-guides/core-blocks/core-blocks-media/)
- **Supports:** align, anchor, dimensions (minHeight, minWidth), filter (duotone), interactivity (clientNavigation, interactive), shadow, spacing (margin, padding)
- **Supports:** align, anchor, dimensions (minHeight, minWidth), filter (duotone), interactivity (clientNavigation, interactive), rotation, shadow, spacing (margin, padding)
- **Attributes:** autoplay, blob, caption, controls, height, id, loop, muted, playsInline, poster, preload, src, tracks, width

<!-- END TOKEN Autogenerated - DO NOT EDIT -->
190 changes: 190 additions & 0 deletions lib/block-supports/rotation.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
<?php
/**
* Rotation block support flag.
*
* Blocks support rotation when they opt in with `supports.rotation: true`.
* The rotation is a number of degrees stored in `style.rotate`, with viewport
* overrides in `style['@tablet'].rotate` and `style['@mobile'].rotate`.
*
* @package gutenberg
*/

/**
* Registers the style block attribute for block types that support rotation.
*
* @param WP_Block_Type $block_type Block Type.
*/
function gutenberg_register_rotate_support( $block_type ) {
if ( ! block_has_support( $block_type, 'rotation', false ) ) {
return;
}

if ( ! $block_type->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.
*
* @param mixed $value Stored rotation.
* @return float|null The rotation in degrees, or null when the value is not a rotation.
*/
function gutenberg_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`.
*
* @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 gutenberg_get_rotate_style_rules( $style, $responsive_media_queries ) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rotation is built by hand here and again in getRotateCSS(). It isn't in the style engine or theme.json.

That leaves gaps other supports don't have:

  • Themes can't turn the control off, like settings.position.sticky.
  • styles.blocks.*.rotate and block style variations are dropped.
  • Global Styles can't show or set it.

New block supports should go through the style engine with a theme.json setting and style. That should be in place before blocks opt in.

$rules = array();
$rotate = gutenberg_get_rotate_value( $style['rotate'] ?? null );

if ( $rotate ) {
$rules[] = array(
'declarations' => array( 'rotate' => $rotate . 'deg' ),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we should store rotation as a bare number with deg added on output.

Other style values are CSS strings, like minHeight: "50vh" or a border width of "1px". Here a hand-written "45deg" or "0.25turn" is silently ignored, in the editor and on the front end. Themes will set whatever string they like in theme.json.

Once blocks save this shape, changing it needs a deprecation per block type or forking logic within the block support which is complexity we don't need to carry or maintain.

Let's store a CSS angle string and have the UI write deg or support selected units.

);
}

foreach ( $responsive_media_queries as $breakpoint => $media_query ) {
if ( ! isset( $style[ $breakpoint ] ) || ! is_array( $style[ $breakpoint ] ) ) {
continue;
}

$viewport_rotate = gutenberg_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.
*
* @param string $block_content Rendered block content.
* @param array $block Block object.
* @return string Filtered block content.
*/
function gutenberg_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;
}
Comment on lines +135 to +138

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This always adds the rotate class to the block wrapper. There's no way for a block to skip serialization.

Every other style support checks __experimentalSkipSerialization, for example dimensions. The editor hook needs the same check.

Blocks rely on that to put a style on an inner element or conditionally apply it. Image puts border and shadow on the inner img. Without it, Image can't rotate the img instead of the figure, and the lightbox issue has no clean fix.

Let's support skip serialization in PHP and JS, with tests, the same as other supports.


// 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_Gutenberg::get_viewport_media_queries( gutenberg_get_global_settings( array( 'viewport' ) ) )
: array();
$rules = gutenberg_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";
}

gutenberg_style_engine_get_stylesheet_from_css_rules(
$rules,
array(
'context' => 'block-supports',
'prettify' => false,
)
);

$processor->add_class( $class_name );

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This breaks the lightbox on a rotated Image.

The rotate class lands on the figure, and the lightbox copies the figure's classes onto the overlay. So the zoomed image opens rotated and clipped.

To reproduce:

  1. Add an Image, turn on "Enlarge on click" and rotate it.
  2. Publish and click the image on the front end.

The overlay needs to stay unrotated, with an e2e test covering it.


return $processor->get_updated_html();
}

// Register the block support.
WP_Block_Supports::get_instance()->register(
'rotation',
array(
'register_attribute' => 'gutenberg_register_rotate_support',
)
);

// Remove the core filter to avoid rendering duplicate rotation styles.
if ( function_exists( 'wp_render_rotate_support' ) ) {
remove_filter( 'render_block', 'wp_render_rotate_support' );
}
add_filter( 'render_block', 'gutenberg_render_rotate_support', 10, 2 );
19 changes: 19 additions & 0 deletions lib/compat/wordpress-7.3/kses.php
Original file line number Diff line number Diff line change
Expand Up @@ -77,3 +77,22 @@ function gutenberg_add_box_sizing_to_safe_style_css( array $attr ): array {
return $attr;
}
add_filter( 'safe_style_css', 'gutenberg_add_box_sizing_to_safe_style_css' );

/**
* Adds 'rotate' to the list of safe CSS properties.
*
* The rotation block support uses the `rotate` property. The style engine filters
* every declaration through {@see safecss_filter_attr()}, so without this entry the
* rules the support adds to the block supports stylesheet are silently dropped.
*
* @param string[] $attr Array of allowed CSS attributes.
* @return string[] Modified array of allowed CSS attributes.
*/
function gutenberg_add_rotate_to_safe_style_css( array $attr ): array {
if ( ! in_array( 'rotate', $attr, true ) ) {
$attr[] = 'rotate';
}

return $attr;
}
add_filter( 'safe_style_css', 'gutenberg_add_rotate_to_safe_style_css' );
1 change: 1 addition & 0 deletions lib/load.php
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,7 @@ function gutenberg_get_experiment_default( $name ) {
require __DIR__ . '/block-supports/anchor.php';
require __DIR__ . '/block-supports/block-visibility.php';
require __DIR__ . '/block-supports/custom-css.php';
require __DIR__ . '/block-supports/rotation.php';
require __DIR__ . '/block-supports/states.php';

// Client-side media processing.
Expand Down
Loading
Loading