Repository navigation
Add a rotation block support - #84505
draganescu wants to merge 20 commits into
Conversation
🤖 PR meta 🤖🎉 PropsIf you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message. To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook. Props Bot commandsYou can trigger Props Bot actions by commenting on this PR:
Updated as activity occurs, without notifying anyone named here. Add the 🏷️ LabelsThis pull request needs exactly one label indicating its type, and has 0.
Read more about Type labels in Gutenberg. If you cannot add labels yourself, a reviewer can do it for you. 📦 Bundle sizeSize Change: +1.08 kB (+0.01%) Total Size: 8.58 MB 📦 View Changed
⚡ PerformanceShow the resultsClient side metrics exclude the server response time. front-end-block-theme
front-end-classic-theme
media-processing
media-upload
post-editor
site-editor
🏁 Flaky testsSome tests passed with failed attempts. The failures may not be related to this commit but are still reported for visibility. See the documentation for more information. Navigates the items list via UP/DOWN arrow keys in
|
7f94fc3 to
41b6a94
Compare
|
I've pushed updates to address my previous feedback, but haven't human reviewed yet and need to do more testing before marking ready. |
0b53950 to
711af92
Compare
|
I think we should just land this as it copies basically other supports and we can think of how to solve overlapping and even move the control in the inspector where we want to if we find solo position item to be weird. |
|
We don't land things without human review of the code. That was why I marked it as draft. Have you reviewed it? I don't think it's a good idea to spend time on this until the MVP functionality I outlined in #81796 is all merged and stable. This is a cool feature, but it's not essential to canvas. I'm fine with trying to get it into 7.3, but we need the essentials in place first or we risk not shipping anything. |
draganescu
left a comment
There was a problem hiding this comment.
Gave the code a read and undid some pointless refactoring. Otherwise LGTM @tellthemachines
Every block that doesn't opt out with `supports.rotate: false` can now be rotated, behind the new "Block rotation" experiment (`gutenberg-block-rotation`, which sets `window.__experimentalEnableBlockRotation`). The Dimensions panel gets an optional Rotation item with an angle picker. The rotation is a number of degrees in (-180, 180] stored in `style.rotate`. It follows the selected viewport style state: overrides live in `style['@tablet'].rotate` and `style['@mobile'].rotate`, where 0 is stored and undoes the default rotation on that viewport. In the default state, 0 is not stored. "Reset all" only clears the selected state's rotation. Blocks are rotated with the CSS `rotate` property rather than `transform`, which the editor writes to animate block moves. In the editor, the rules are added under a generated class, with viewport overrides in the responsive media queries. On the front end, `lib/block-supports/rotate.php` adds the same rules to the block supports stylesheet under a class generated from them, treating the stored value as untrusted. Saved markup doesn't change, and stored rotation is shown whether or not the experiment is on. `getRotateForState()` and `getUpdatedRotateStyle()` read and write the rotation of a style state, for tools such as a canvas rotate handle. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Classic, Custom HTML, Missing, More, Page Break, Pattern, Shortcode and synced patterns opt out with `supports.rotate: false`, like they opt out of custom CSS and visibility: they have no wrapper of their own to rotate, or lose it on save or on the front end. A block whose own `style` attribute is not an object doesn't support rotation, and style values that are not objects are read as no rotation. JS reads stored values like PHP: only decimal numeric strings, and halves rounded away from zero after removing floating point noise, so -12.345 is -12.35 on both sides. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PHPCompatibility flags the '0x10' test value, which checks that hexadecimal strings are rejected. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PHP 7 rejects numeric strings with trailing whitespace, while PHP 8 accepts them, so the PHP 7.4 test run failed on ' 15 '. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Trunk types useSettings as returning unknown values, so the viewport setting is cast to the record getRotateCSS expects, as other typed callers of useSettings do. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The rotate hook registered its own filter adding the style attribute. When the hook was imported early, such as by the grid's mobile stacking, the filter ran before the other block supports and moved the style attribute ahead of theirs, which changed serialized fixtures, and it added the attribute in tests that only load another block support. The style hook's filter now also adds it for rotation, so it stays in the same place. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rename the rotate block support and rotation utility tests to TypeScript so they pass the `gutenberg/use-typescript` lint rule for new files. The tests check the same things: block types are built with a typed helper, the block rotation experiment flag is read through a typed window, and the cases that pass a style attribute that is not an object use `@ts-expect-error`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The rotate render_block filter and the editor's useBlockProps ran with the experiment off, so blocks storing style.rotate were rotated anyway. The front-end filter is now removed in lib/load.php while the experiment is off, which keeps rotate.php as it would be backported, and the editor support's hasSupport is isRotateEnabled, which gates both the control and the rotation. The style attribute is still registered for rotation on both sides, so a stored rotation is kept rather than dropped on save. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Remove core's wp_render_rotate_support filter when it exists, as the custom CSS and block visibility supports do, so a WordPress version that ships rotation doesn't output the rules twice. The angle picker now rounds to two decimals, the same as stored rotations, through a shared roundAngle helper. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`getRotateValue`, `getRotateForState` and `getUpdatedRotateStyle` read and write `style.rotate` without any React or UI code, but lived in the rotate hook module, which imports the inspector controls. Grid code that only needs the data helpers had to import that module. They now live in `utils/rotation.ts` with the other angle helpers. The rotation utils test runs in jsdom, since the block style state helpers need it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The block style state helpers only needed `hooks/utils` for `cleanEmptyObject`, and that module imports the components and the store. Through them, `utils/rotation.ts` couldn't load outside a browser environment. `cleanEmptyObject` is a plain object helper, so it now lives in `utils/object.ts`, and `hooks/utils` re-exports it for its existing importers. The rotation utils test runs in node again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ribute Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Moving it into utils/object only kept the rotation utils test running in Node. Run that test in jsdom instead, like the other rotation tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Core is past the 7.2 feature freeze, so the safe CSS entry moves to the 7.3 compat file and the backport changelog entry to 7.3. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
14f5f58 to
216d8bf
Compare
| ```js | ||
| supports: { | ||
| // Enable the Rotation control. | ||
| rotation: true |
There was a problem hiding this comment.
Minor - the support key is rotation, but the value is stored as style.rotate. Should they match?
aaronrobertshaw
left a comment
There was a problem hiding this comment.
Thanks for all the work on this, I'd really like to see rotation land 👍
I've taken it for a spin. The editor side works well but this isn't ready to merge as it stands.
Once blocks save rotation, most of the issues below get locked in. They need fixing before it lands.
Blockers
- This should be a "Transform" block support with a rotate feature, like "Dimensions" has width and height. That gives
style.transform.rotate, leaves room for scale or skew, and settles therotationvsrotatenaming. - It isn't a registered style property, and has no style engine or theme.json support. Themes can't disable it, set it in styles or use it in a style variation.
- No skip serialization, which every other block support offers.
- No default vs optional control display. The support only accepts
true. - The value is a bare number with
deghard-coded."45deg"is silently ignored. We should have proper CSS unit support from the start as this has added complexity to other supports when inevitably updating them later e.g. border radius. - A new public
position-toolsinspector group. Image gets a Position panel holding only Rotation. It is weird to duplicate "Position" panels effectively. If this must be in a Position panel, why not the actual existing one? - Image and Video rotate the whole
figure, caption included. Without skip serialization or selectors, they can't target theimg. - A rotated Image with lightbox opens rotated and clipped in the overlay.
Some further details are inline.
A pattern to follow
#76949 added min width in one PR: style engine, theme.json, both schemas, Global Styles and tests.
| ) | ||
| ); | ||
|
|
||
| $processor->add_class( $class_name ); |
There was a problem hiding this comment.
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:
- Add an Image, turn on "Enlarge on click" and rotate it.
- Publish and click the image on the front end.
The overlay needs to stay unrotated, with an e2e test covering it.
| * @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 ) { |
There was a problem hiding this comment.
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.*.rotateand 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.
|
|
||
| if ( $rotate ) { | ||
| $rules[] = array( | ||
| 'declarations' => array( 'rotate' => $rotate . 'deg' ), |
There was a problem hiding this comment.
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.
| layout: InspectorControlsLayout, | ||
| list: InspectorControlsListView, | ||
| position: InspectorControlsPosition, | ||
| 'position-tools': InspectorControlsPositionTools, |
There was a problem hiding this comment.
This adds a public position-tools group next to position. Any plugin can fill it, so it's API we'd have to keep.
It also reads oddly on screen. On a Group, the panel menu lists "Position" inside "Position".
The open question in the description about this hasn't been settled. Let's decide where Rotation belongs first. To me, this 100% should be part of a "Transform" block support and therefore a "Rotate" control within a "Transform" panel.
| } | ||
| }, | ||
| "supports": { | ||
| "rotation": true, |
There was a problem hiding this comment.
On Image this rotates the whole figure, caption included. Video does the same.
Other visual supports on Image target the img through selectors and skip serialization.
The support only accepts true, so a block can't pick the element or show the control by default. Is rotating the caption intended?
| $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; | ||
| } |
There was a problem hiding this comment.
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.
| ! hasStyleSupport( settings ) && | ||
| ! hasBlockSupport( settings, 'customCSS', true ) | ||
| ! hasBlockSupport( settings, 'customCSS', true ) && | ||
| ! hasBlockSupport( settings, 'rotation', false ) |
There was a problem hiding this comment.
Rotation is added here as a special case, not as a style property. It isn't in __EXPERIMENTAL_STYLE_PROPERTY or the style support keys.
That list is what the rest of the styles system reads. Global Styles output and block style variations both go through it, so neither knows about rotation.
Registering it there, like minWidth, is what brings that support with it. It also needs the matching style engine and theme.json tests, as #76949 had.
|
@draganescu and @tellthemachines I've put together #86240 as an alternative way forward. It covers most of the feedback from my review.
It opts in the same four blocks. If we go that way, we could move these changes into this PR. It's probably easier to close this one in favour of #86240 though, with credit for the work here. Happy to go either way. |
|
That's the way to go. It's just faster to code than review 😂 particularly if you're the center of expertise. Closing this in favour of #86240 - thank you @aaronrobertshaw I think rotate is not a nice to have, it's fundamental to a delightful canvas experience and it's one of the few "oh look at that" things we can say about this canvas. |
|
Sounds like we have a plan. I can refine and push #86240 along over the next day or two. |


previous (AI) description
What?
Adds a rotate block support, behind a new "Block rotation" experiment. Blocks get an optional Rotation control in their Dimensions panel unless they opt out with
supports.rotate: false. The rotation follows the selected viewport (Desktop, Tablet, Mobile). Split out of #84340, where rotation started as a grid-only value; the canvas rotate handle for blocks in a manual placement grid is #84512, at the top of the grid stack, which builds on this.Why?
Blocks can't be rotated today. This is the general support that the manual grid's canvas rotate handle builds on: the handle reads and writes the same data through the helpers added here.
How?
style.rotate. Viewport overrides live instyle['@tablet'].rotateandstyle['@mobile'].rotate, where 0 is stored and undoes the default rotation on that viewport. In the default state, 0 is not stored.AnglePickerControlin an optionalToolsPanelItemin the Dimensions panel. It edits the selected viewport, and its reset and the panel's "Reset all" only clear that viewport's rotation. It's hidden for pseudo states such as:hover.rotateproperty, nottransform, which the editor writes to animate block moves. Rules are added under a generated class, without!important, with the default rotation first and viewport overrides in the responsive media queries. This happens in the editor through a style override and on the front end through the block supports stylesheet (lib/block-supports/rotate.php). Saved markup doesn't change.0x10), rounded to two decimals with halves away from zero, and wrapped into (-180, 180].rotateis added to the safe CSS properties, because the style engine filters every declaration throughsafecss_filter_attr().styleattribute is not an object is left alone.supports.rotate: false, mirroring the blocks that opt out ofcustomCSSandvisibility: Classic, Custom HTML, Missing, More, Page Break, Pattern, Shortcode and synced patterns (core/block). Classic would lose the rotation on save, Custom HTML and synced patterns would rotate an editor wrapper that doesn't exist on the front end, and More and Page Break have no tag.states.php), following block visibility and custom CSS. The style engine route would need changes to the public style engine package; it would serialise an inline style into the saved markup of blocks that have other style supports; its editor output only covers blocks with style supports; andstates.phpemits viewport rules with!important, which would stop a canvas handle from previewing an angle with an inline style.styleattribute stays registered for rotation in JS and PHP so saving doesn't drop them, but blocks are shown unrotated. In the editor the support'shasSupportisisRotateEnabled(), which gates the control and the rotation together. On the front end,lib/load.phpremoves therender_blockfilter while the experiment is off, sorotate.phpstays as it would be backported. Without this, third-party blocks that store their ownstyle.rotatewould be rotated by Gutenberg with the experiment off.getRotateValue(),getRotateForState()andgetUpdatedRotateStyle()read and write a style state's rotation for other editor tools. They live inutils/rotation.tswith the angle helpers, not in the hook module, so tools that only need the data don't import the hook and its controls. To keep that module free of the hook utilities (which import the components and the store),cleanEmptyObjectmoved fromhooks/utils.tsxtoutils/object.ts;hooks/utils.tsxre-exports it, so its importers and the private API are unchanged.Known limitations:
transformtranslates along the rotated axes, so the block briefly travels at an angle. It settles in the right place.Still to do before this can leave draft:
lib/block-supports/rotate.php(aswp_render_rotate_support()andwp_register_rotate_support()) and therotateentry insafe_style_css: Editor: Add the rotation block support wordpress-develop#14707 (draft), withbackport-changelog/7.2/14707.md. Thesupports.rotate: falseopt-outs in Core's block.json files arrive with the package update.lib/block-supports/rotate.phpremove Core'srender_blockfilter before adding its own, asblock-visibility.phpandcustom-css.phpdo.supports.rotateinschemas/json/block.jsonand the block supports reference while it is experimental.Testing Instructions
style.rotateis removed from the block's attributes (Code editor).Testing Instructions for Keyboard
Tab to the Dimensions panel's options menu, turn on Rotation, then type an angle into the Rotation number field. Check that Tab moves through the panel and that "Reset all" works from the menu.
What
Adds a Rotation block support and enables it on the following blocks:
Although rotation is its own support, in the block inspector it sits under the "Position" section, so now that section will be visible for any block that has rotation even if it doesn't have position. Is this weird? 😅
One thing I'm noticing is when rotating a grid block, in the editor the visualizer stays unrotated.
Rotated blocks partially overlap other blocks. This isn't necessarily a problem, because margins can be used to adjust it.
Rotated blocks can cause horizontal scroll on smaller breakpoints. Again, this is something that's already possible with widths and margins, so might not be a real issue.
Testing Instructions
style.rotateis removed from the block's attributes (Code editor).supports.rotation: true.Testing Instructions for Keyboard
Tab to the Position panel's options menu, turn on Rotation, then type an angle into the Rotation number field. Check that Tab moves through the panel and that "Reset all" works from the menu.
Screenshots or screencast
Use of AI Tools
Written with Claude Code (Claude Opus 5.5), split out of #84340. The design decisions (a general support stored in
style.rotate, a separate experiment) were made in conversation with the PR author.🤖 Generated with Claude Code
Iterations written with copilot/ gpt 6.1 terra and claude sonnet 5.5