From 0fe519d080a0fc9a4c9a89253340bad7246e9471 Mon Sep 17 00:00:00 2001 From: Andrei Draganescu Date: Sat, 10 Oct 2026 11:35:48 -0500 Subject: [PATCH 01/20] Block Editor: Add a rotate block support 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 --- lib/block-supports/rotate.php | 177 ++++++++ lib/compat/wordpress-7.2/kses.php | 19 + lib/experimental/editor-settings.php | 3 + lib/experimental/experiments/load.php | 5 + lib/load.php | 1 + packages/block-editor/src/hooks/index.ts | 3 + packages/block-editor/src/hooks/rotate.tsx | 395 ++++++++++++++++++ .../hooks/test/rotate-control.jsdom.test.tsx | 170 ++++++++ .../src/hooks/test/rotate.jsdom.test.js | 269 ++++++++++++ packages/block-editor/src/utils/rotation.ts | 42 ++ .../block-editor/src/utils/test/rotation.js | 54 +++ phpunit/block-supports/rotate-test.php | 311 ++++++++++++++ 12 files changed, 1449 insertions(+) create mode 100644 lib/block-supports/rotate.php create mode 100644 packages/block-editor/src/hooks/rotate.tsx create mode 100644 packages/block-editor/src/hooks/test/rotate-control.jsdom.test.tsx create mode 100644 packages/block-editor/src/hooks/test/rotate.jsdom.test.js create mode 100644 packages/block-editor/src/utils/rotation.ts create mode 100644 packages/block-editor/src/utils/test/rotation.js create mode 100644 phpunit/block-supports/rotate-test.php diff --git a/lib/block-supports/rotate.php b/lib/block-supports/rotate.php new file mode 100644 index 00000000000000..808975744fa9ef --- /dev/null +++ b/lib/block-supports/rotate.php @@ -0,0 +1,177 @@ +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 ) { + 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 rather than `transform` so that it combines with any + * transforms a theme or block applies. 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 ) { + $rules = array(); + $rotate = gutenberg_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 = 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, the default rotation first, so that viewport overrides win. + * + * @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, 'rotate', true ) ) { + 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 ); + + return $processor->get_updated_html(); +} + +// Register the block support. +WP_Block_Supports::get_instance()->register( + 'rotate', + array( + 'register_attribute' => 'gutenberg_register_rotate_support', + ) +); + +add_filter( 'render_block', 'gutenberg_render_rotate_support', 10, 2 ); diff --git a/lib/compat/wordpress-7.2/kses.php b/lib/compat/wordpress-7.2/kses.php index 947a1d5d916337..4b691c20dbd8b8 100644 --- a/lib/compat/wordpress-7.2/kses.php +++ b/lib/compat/wordpress-7.2/kses.php @@ -49,3 +49,22 @@ function gutenberg_add_background_clip_to_safe_style_css( array $attr ): array { return $attr; } add_filter( 'safe_style_css', 'gutenberg_add_background_clip_to_safe_style_css' ); + +/** + * Adds 'rotate' to the list of safe CSS properties. + * + * The rotate block support uses the `rotate` property, rather than `transform`, so + * that the rotation combines with any transforms a theme or block applies instead + * of replacing them. + * + * @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' ); diff --git a/lib/experimental/editor-settings.php b/lib/experimental/editor-settings.php index d6251e763946f0..3c0b0524df6eea 100644 --- a/lib/experimental/editor-settings.php +++ b/lib/experimental/editor-settings.php @@ -14,6 +14,9 @@ function gutenberg_enable_experiments() { if ( gutenberg_is_experiment_enabled( 'gutenberg-grid-interactivity' ) ) { wp_add_inline_script( 'wp-block-editor', 'window.__experimentalEnableGridInteractivity = true', 'before' ); } + if ( gutenberg_is_experiment_enabled( 'gutenberg-block-rotation' ) ) { + wp_add_inline_script( 'wp-block-editor', 'window.__experimentalEnableBlockRotation = true', 'before' ); + } if ( gutenberg_is_experiment_enabled( 'gutenberg-dataviews-media-modal' ) ) { wp_add_inline_script( 'wp-block-editor', 'window.__experimentalDataViewsMediaModal = true', 'before' ); } diff --git a/lib/experimental/experiments/load.php b/lib/experimental/experiments/load.php index 4ea594fe842241..d2c8a08f226257 100644 --- a/lib/experimental/experiments/load.php +++ b/lib/experimental/experiments/load.php @@ -29,6 +29,11 @@ function gutenberg_initialize_experiments_settings() { 'label' => __( 'Grid interactivity', 'gutenberg' ), 'description' => __( 'Enables enhancements to the Grid block that let you move and resize items in the editor canvas.', 'gutenberg' ), ), + array( + 'id' => 'gutenberg-block-rotation', + 'label' => __( 'Block rotation', 'gutenberg' ), + 'description' => __( 'Adds a Rotation control to the Dimensions panel of every block.', 'gutenberg' ), + ), ), ), array( diff --git a/lib/load.php b/lib/load.php index e3768f96450b76..8265e855f2eded 100644 --- a/lib/load.php +++ b/lib/load.php @@ -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/rotate.php'; require __DIR__ . '/block-supports/states.php'; // Client-side media processing. diff --git a/packages/block-editor/src/hooks/index.ts b/packages/block-editor/src/hooks/index.ts index bdf8b72093fb9b..adfd2d0b3de73c 100644 --- a/packages/block-editor/src/hooks/index.ts +++ b/packages/block-editor/src/hooks/index.ts @@ -24,6 +24,7 @@ import textShadow from './text-shadow'; import fitText from './fit-text'; import border from './border'; import customCSS from './custom-css'; +import rotate from './rotate'; import position from './position'; import blockStyleVariation from './block-style-variation'; import layout from './layout'; @@ -43,6 +44,7 @@ createBlockEditFilter( anchor, customClassName, style, + rotate, customCSS, duotone, fitText, @@ -70,6 +72,7 @@ createBlockListBlockFilter( [ fitText, border, customCSS, + rotate, position, blockStyleVariation, childLayout, diff --git a/packages/block-editor/src/hooks/rotate.tsx b/packages/block-editor/src/hooks/rotate.tsx new file mode 100644 index 00000000000000..907185f61dae30 --- /dev/null +++ b/packages/block-editor/src/hooks/rotate.tsx @@ -0,0 +1,395 @@ +import { hasBlockSupport } from '@wordpress/blocks'; +import type { BlockType } from '@wordpress/blocks'; +import { + AnglePickerControl, + __experimentalToolsPanelItem as ToolsPanelItem, +} from '@wordpress/components'; +import { useInstanceId } from '@wordpress/compose'; +import { useSelect } from '@wordpress/data'; +import { useMemo } from '@wordpress/element'; +import { privateApis as globalStylesEnginePrivateApis } from '@wordpress/global-styles-engine'; +import { addFilter } from '@wordpress/hooks'; +import { __ } from '@wordpress/i18n'; +import InspectorControls from '../components/inspector-controls'; +import { useSettings } from '../components/use-settings'; +import { store as blockEditorStore } from '../store'; +import { unlock } from '../lock-unlock'; +import { + fromPickerAngle, + normalizeAngle, + toPickerAngle, +} from '../utils/rotation'; +import { cleanEmptyObject, usePrivateStyleOverride } from './utils'; +import { + BlockStyleStateProvider, + DEFAULT_BLOCK_STYLE_STATE, + getStyleForState, + hasPseudoBlockStyleState, + hasViewportBlockStyleState, + setStyleForState, +} from './block-style-state'; + +const { getResponsiveMediaQueries } = unlock( globalStylesEnginePrivateApis ); + +export const ROTATE_SUPPORT_KEY = 'rotate'; + +type BlockStyle = Record< string, any >; + +interface BlockStyleState { + viewport?: string; + pseudo?: string; +} + +interface BlockAttributes { + style?: BlockStyle; + [ key: string ]: unknown; +} + +interface RotateEditProps { + clientId: string; + name: string; + setAttributes: ( attributes: BlockAttributes ) => void; + style?: BlockStyle; +} + +// Used for generating the instance ID. +const ROTATE_BLOCK_PROPS_REFERENCE = {}; + +/** + * Returns whether a block supports rotation. Every block does unless it opts + * out with `supports.rotate: false`. + * + * @param nameOrType Block name or block type object. + * + * @return Whether the block supports rotation. + */ +export function hasRotateSupport( nameOrType: string | BlockType ): boolean { + return hasBlockSupport( nameOrType, ROTATE_SUPPORT_KEY, true ); +} + +/** + * Returns whether rotation can be edited for a block: the block rotation + * experiment is on and the block supports rotation. + * + * Rotation that is already stored is shown whether or not this is true. + * + * @param nameOrType Block name or block type object. + * + * @return Whether rotation can be edited. + */ +export function isRotateEnabled( nameOrType: string | BlockType ): boolean { + const { __experimentalEnableBlockRotation: isExperimentEnabled } = + window as Window & { __experimentalEnableBlockRotation?: boolean }; + return !! isExperimentEnabled && hasRotateSupport( nameOrType ); +} + +/** + * Reads a stored rotation, which is a number of degrees. Anything that is not + * a finite number, or a string holding one, is ignored. + * + * @param value Stored rotation. + * + * @return The rotation in (-180, 180], rounded to two decimals, or undefined. + */ +export function getRotateValue( value: unknown ): number | undefined { + const angle = + typeof value === 'string' && value.trim() !== '' + ? Number( value ) + : value; + if ( typeof angle !== 'number' || ! Number.isFinite( angle ) ) { + return undefined; + } + return normalizeAngle( Math.round( angle * 100 ) / 100 ); +} + +/** + * Returns the state that holds rotation for a selected style state. Rotation + * is stored per viewport only, so a pseudo state such as `:hover` is ignored. + * + * @param selectedState Selected block style state. + * + * @return The style state rotation is stored in. + */ +function getRotateState( selectedState: BlockStyleState ): BlockStyleState { + return { + viewport: hasViewportBlockStyleState( selectedState ) + ? selectedState.viewport + : DEFAULT_BLOCK_STYLE_STATE.viewport, + pseudo: DEFAULT_BLOCK_STYLE_STATE.pseudo, + }; +} + +/** + * Returns the styles of a block for the state rotation is stored in. + * + * @param style Block style attribute. + * @param selectedState Selected block style state. + * + * @return The styles of that state, if any. + */ +function getRotateStateStyle( + style: BlockStyle | undefined, + selectedState: BlockStyleState +): BlockStyle | undefined { + return getStyleForState( style ?? {}, getRotateState( selectedState ) ); +} + +/** + * Returns the rotation a block has in a style state: the viewport's own + * rotation when it sets one, otherwise the default rotation. + * + * @param style Block style attribute. + * @param selectedState Selected block style state. + * + * @return The rotation in degrees, in (-180, 180]. 0 when not rotated. + */ +export function getRotateForState( + style: BlockStyle | undefined, + selectedState: BlockStyleState +): number { + const defaultRotate = getRotateValue( style?.rotate ) ?? 0; + if ( ! hasViewportBlockStyleState( selectedState ) ) { + return defaultRotate; + } + return ( + getRotateValue( getRotateStateStyle( style, selectedState )?.rotate ) ?? + defaultRotate + ); +} + +/** + * Returns a block style attribute with the rotation of a style state set. + * + * In the default state, no rotation is stored as no value. In a viewport + * state, 0 is kept, because it undoes the default rotation on that viewport. + * An undefined angle removes the state's rotation. + * + * @param style Block style attribute. + * @param angle Rotation in degrees. + * @param selectedState Selected block style state. + * + * @return The updated block style attribute. + * + * @example + * ```js + * setAttributes( { + * style: getUpdatedRotateStyle( attributes.style, 30, { viewport: '@mobile' } ), + * } ); + * ``` + */ +export function getUpdatedRotateStyle( + style: BlockStyle | undefined, + angle: number | undefined, + selectedState: BlockStyleState +): BlockStyle | undefined { + const rotateState = getRotateState( selectedState ); + const isViewportState = hasViewportBlockStyleState( rotateState ); + let rotate = getRotateValue( angle ); + if ( rotate === 0 && ! isViewportState ) { + rotate = undefined; + } + + if ( ! isViewportState ) { + return cleanEmptyObject( { ...style, rotate } ); + } + + return setStyleForState( style ?? {}, rotateState, { + ...getRotateStateStyle( style, rotateState ), + rotate, + } ); +} + +/** + * Returns whether a block style attribute holds any rotation. + * + * @param style Block style attribute. + * + * @return Whether there is a rotation in the default or a viewport state. + */ +function hasRotateValue( style: BlockStyle | undefined ): boolean { + if ( ! style || typeof style !== 'object' ) { + return false; + } + return Object.entries( style ).some( + ( [ key, value ] ) => + key === ROTATE_SUPPORT_KEY || + ( key.startsWith( '@' ) && + value?.[ ROTATE_SUPPORT_KEY ] !== undefined ) + ); +} + +/** + * Generates the CSS that rotates a block, including viewport overrides. + * + * The `rotate` property is used rather than `transform` so that it doesn't + * clash with the transforms the editor applies to animate block moves. + * + * @param style Block style attribute. + * @param selector Selector of the block. + * @param viewportSettings Viewport breakpoint settings. + * + * @return The generated stylesheet. + */ +export function getRotateCSS( + style: BlockStyle | undefined, + selector: string, + viewportSettings?: Record< string, unknown > +): string { + const rules: string[] = []; + const rotate = getRotateValue( style?.rotate ); + if ( rotate ) { + rules.push( `${ selector }{rotate:${ rotate }deg;}` ); + } + + Object.entries( getResponsiveMediaQueries( viewportSettings ) ).forEach( + ( [ viewport, mediaQuery ] ) => { + const viewportRotate = getRotateValue( + getRotateStateStyle( style, { viewport } )?.rotate + ); + if ( viewportRotate === undefined ) { + return; + } + // In a viewport, 0 undoes the default rotation. + const value = viewportRotate ? `${ viewportRotate }deg` : 'none'; + rules.push( `${ mediaQuery }{${ selector }{rotate:${ value };}}` ); + } + ); + + return rules.join( '' ); +} + +/** + * Filters registered block settings, extending attributes to include the + * `style` attribute that rotation is stored in. + * + * @param settings Original block settings. + * + * @return Filtered block settings. + */ +function addAttribute( settings: BlockType ): BlockType { + if ( ! hasRotateSupport( settings ) || settings.attributes?.style ) { + return settings; + } + + settings.attributes = { + ...settings.attributes, + style: { + type: 'object', + }, + }; + + return settings; +} + +/** + * Removes the rotation from a block's attributes. The inspector controls + * scope it to the selected style state. + * + * @param attributes Block attributes. + * + * @return The block attributes without rotation. + */ +function resetRotate( attributes: BlockAttributes ): BlockAttributes { + return { + ...attributes, + style: cleanEmptyObject( { + ...attributes.style, + rotate: undefined, + } ), + }; +} + +function RotateControl( { clientId, setAttributes, style }: RotateEditProps ) { + const { blockEditingMode, selectedState } = useSelect( + ( select ) => { + const { getBlockEditingMode, getSelectedBlockStyleState } = + select( blockEditorStore ); + return { + blockEditingMode: getBlockEditingMode( clientId ), + selectedState: getSelectedBlockStyleState( clientId ), + }; + }, + [ clientId ] + ); + + if ( + blockEditingMode !== 'default' || + hasPseudoBlockStyleState( selectedState ) + ) { + return null; + } + + const stateStyle = getRotateStateStyle( style, selectedState ); + const updateRotate = ( angle: number | undefined ) => + setAttributes( { + style: getUpdatedRotateStyle( style, angle, selectedState ), + } ); + + return ( + // The provider scopes the panel's "Reset all" to the selected state. + + + stateStyle?.rotate !== undefined } + label={ __( 'Rotation' ) } + onDeselect={ () => updateRotate( undefined ) } + isShownByDefault={ false } + panelId={ clientId } + > + + updateRotate( fromPickerAngle( value ) ) + } + /> + + + + ); +} + +function RotateEdit( props: RotateEditProps ) { + if ( ! isRotateEnabled( props.name ) ) { + return null; + } + return ; +} + +function useBlockProps( { + clientId, + style, +}: Pick< RotateEditProps, 'clientId' | 'style' > ) { + const [ viewportSettings ] = useSettings( 'viewport' ); + const className = useInstanceId( + ROTATE_BLOCK_PROPS_REFERENCE, + 'wp-rotate' + ); + const css = useMemo( + () => getRotateCSS( style, `.${ className }`, viewportSettings ), + [ style, className, viewportSettings ] + ); + + usePrivateStyleOverride( { css, clientId } ); + + return css ? { className } : {}; +} + +export default { + edit: RotateEdit, + useBlockProps, + attributeKeys: [ 'style' ], + hasSupport: hasRotateSupport, + isMatch: ( { style }: { style?: BlockStyle } ) => hasRotateValue( style ), +}; + +addFilter( + 'blocks.registerBlockType', + 'core/rotate/addAttribute', + addAttribute +); diff --git a/packages/block-editor/src/hooks/test/rotate-control.jsdom.test.tsx b/packages/block-editor/src/hooks/test/rotate-control.jsdom.test.tsx new file mode 100644 index 00000000000000..e7c72616a9f565 --- /dev/null +++ b/packages/block-editor/src/hooks/test/rotate-control.jsdom.test.tsx @@ -0,0 +1,170 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { + createBlock, + registerBlockType, + unregisterBlockType, +} from '@wordpress/blocks'; +import { SlotFillProvider } from '@wordpress/components'; +import { dispatch, select, useDispatch, useSelect } from '@wordpress/data'; +import { + BlockEditContextProvider, + mayDisplayControlsKey, +} from '../../components/block-edit/context'; +import InspectorControlsSlot from '../../components/inspector-controls/slot'; +import { store as blockEditorStore } from '../../store'; +import { unlock } from '../../lock-unlock'; +import rotate from '../rotate'; + +const BLOCK_NAME = 'test/rotate-control'; + +type TestWindow = Window & { __experimentalEnableBlockRotation?: boolean }; + +type BlockStyle = Record< string, unknown >; + +type BlockAttributes = { style?: BlockStyle } | null; + +function getStyle( clientId: string ) { + return ( + select( blockEditorStore ).getBlockAttributes( + clientId + ) as BlockAttributes + )?.style; +} + +function RotateEdit( { clientId }: { clientId: string } ) { + const style = useSelect( + ( selectStore ) => + ( + selectStore( blockEditorStore ).getBlockAttributes( + clientId + ) as BlockAttributes + )?.style, + [ clientId ] + ); + const { updateBlockAttributes } = useDispatch( blockEditorStore ); + const Edit = rotate.edit; + return ( + + updateBlockAttributes( clientId, attributes ) + } + /> + ); +} + +function showRotateControl( style?: BlockStyle ) { + const block = createBlock( BLOCK_NAME, { style } ); + // The block is selected, so its controls are displayed. + const blockEditContext = { + name: BLOCK_NAME, + clientId: block.clientId, + isSelected: true, + [ mayDisplayControlsKey ]: true, + }; + act( () => { + dispatch( blockEditorStore ).resetBlocks( [ block ] ); + dispatch( blockEditorStore ).selectBlock( block.clientId ); + } ); + render( + + + + + + + ); + return block.clientId; +} + +describe( 'Rotation control', () => { + beforeEach( () => { + registerBlockType( BLOCK_NAME, { + apiVersion: 3, + title: 'Rotate control test', + category: 'text', + attributes: { style: { type: 'object' } }, + edit: () => null, + save: () => null, + } ); + } ); + + afterEach( () => { + unregisterBlockType( BLOCK_NAME ); + delete ( window as TestWindow ).__experimentalEnableBlockRotation; + act( () => { + unlock( dispatch( blockEditorStore ) ).setStyleStateViewport( + 'default' + ); + } ); + } ); + + it( 'is not shown without the block rotation experiment', () => { + showRotateControl( { rotate: 15 } ); + + expect( + screen.queryByRole( 'spinbutton', { name: 'Rotation' } ) + ).not.toBeInTheDocument(); + } ); + + it( 'shows and edits the default rotation', () => { + ( window as TestWindow ).__experimentalEnableBlockRotation = true; + const clientId = showRotateControl( { rotate: 15 } ); + + const input = screen.getByRole( 'spinbutton', { name: 'Rotation' } ); + expect( input ).toHaveValue( 15 ); + + fireEvent.change( input, { target: { value: '270' } } ); + expect( getStyle( clientId ) ).toEqual( { rotate: -90 } ); + + fireEvent.change( input, { target: { value: '0' } } ); + expect( getStyle( clientId ) ).toBeUndefined(); + } ); + + it( 'edits the rotation of the selected viewport', () => { + ( window as TestWindow ).__experimentalEnableBlockRotation = true; + act( () => { + unlock( dispatch( blockEditorStore ) ).setStyleStateViewport( + '@mobile' + ); + } ); + const clientId = showRotateControl( { + rotate: 15, + '@mobile': { rotate: 30 }, + } ); + + const input = screen.getByRole( 'spinbutton', { name: 'Rotation' } ); + expect( input ).toHaveValue( 30 ); + + fireEvent.change( input, { target: { value: '0' } } ); + expect( getStyle( clientId ) ).toEqual( { + rotate: 15, + '@mobile': { rotate: 0 }, + } ); + } ); + + it( 'resets only the rotation of the selected viewport', () => { + ( window as TestWindow ).__experimentalEnableBlockRotation = true; + act( () => { + unlock( dispatch( blockEditorStore ) ).setStyleStateViewport( + '@mobile' + ); + } ); + const clientId = showRotateControl( { + rotate: 15, + '@mobile': { rotate: 30 }, + } ); + + fireEvent.click( + screen.getByRole( 'button', { name: 'Dimensions options' } ) + ); + fireEvent.click( + screen.getByRole( 'menuitem', { name: 'Reset all' } ) + ); + + expect( getStyle( clientId ) ).toEqual( { rotate: 15 } ); + } ); +} ); diff --git a/packages/block-editor/src/hooks/test/rotate.jsdom.test.js b/packages/block-editor/src/hooks/test/rotate.jsdom.test.js new file mode 100644 index 00000000000000..69eaf9607aaadd --- /dev/null +++ b/packages/block-editor/src/hooks/test/rotate.jsdom.test.js @@ -0,0 +1,269 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import rotate, { + getRotateCSS, + getRotateForState, + getRotateValue, + getUpdatedRotateStyle, + hasRotateSupport, + isRotateEnabled, +} from '../rotate'; + +const DEFAULT_STATE = { viewport: 'default', pseudo: 'default' }; +const MOBILE_STATE = { viewport: '@mobile', pseudo: 'default' }; +const TABLET_STATE = { viewport: '@tablet', pseudo: 'default' }; + +describe( 'rotate block support', () => { + afterEach( () => { + delete window.__experimentalEnableBlockRotation; + } ); + + describe( 'hasRotateSupport()', () => { + it( 'is on for blocks that do not opt out', () => { + expect( hasRotateSupport( { supports: {} } ) ).toBe( true ); + expect( hasRotateSupport( { supports: { rotate: true } } ) ).toBe( + true + ); + } ); + + it( 'is off for blocks that opt out', () => { + expect( hasRotateSupport( { supports: { rotate: false } } ) ).toBe( + false + ); + } ); + } ); + + describe( 'isRotateEnabled()', () => { + it( 'needs the block rotation experiment', () => { + expect( isRotateEnabled( { supports: {} } ) ).toBe( false ); + window.__experimentalEnableBlockRotation = true; + expect( isRotateEnabled( { supports: {} } ) ).toBe( true ); + } ); + + it( 'needs the block to support rotation', () => { + window.__experimentalEnableBlockRotation = true; + expect( isRotateEnabled( { supports: { rotate: false } } ) ).toBe( + false + ); + } ); + } ); + + describe( 'getRotateValue()', () => { + it( 'reads numbers and numeric strings', () => { + expect( getRotateValue( 15 ) ).toBe( 15 ); + expect( getRotateValue( -12.5 ) ).toBe( -12.5 ); + expect( getRotateValue( '30' ) ).toBe( 30 ); + expect( getRotateValue( 0 ) ).toBe( 0 ); + } ); + + it( 'wraps into (-180, 180] and rounds to two decimals', () => { + expect( getRotateValue( 270 ) ).toBe( -90 ); + expect( getRotateValue( -180 ) ).toBe( 180 ); + expect( getRotateValue( 12.3456 ) ).toBe( 12.35 ); + } ); + + it( 'ignores values that are not numbers', () => { + expect( getRotateValue( undefined ) ).toBeUndefined(); + expect( getRotateValue( null ) ).toBeUndefined(); + expect( getRotateValue( '' ) ).toBeUndefined(); + expect( getRotateValue( '15deg' ) ).toBeUndefined(); + expect( getRotateValue( Infinity ) ).toBeUndefined(); + expect( getRotateValue( NaN ) ).toBeUndefined(); + expect( getRotateValue( { angle: 15 } ) ).toBeUndefined(); + } ); + } ); + + describe( 'getRotateForState()', () => { + it( 'returns the default rotation in the default state', () => { + expect( getRotateForState( { rotate: 15 }, DEFAULT_STATE ) ).toBe( + 15 + ); + expect( getRotateForState( undefined, DEFAULT_STATE ) ).toBe( 0 ); + } ); + + it( 'returns the viewport rotation when the viewport sets one', () => { + const style = { rotate: 15, '@mobile': { rotate: -30 } }; + expect( getRotateForState( style, MOBILE_STATE ) ).toBe( -30 ); + } ); + + it( 'returns 0 when a viewport undoes the default rotation', () => { + const style = { rotate: 15, '@mobile': { rotate: 0 } }; + expect( getRotateForState( style, MOBILE_STATE ) ).toBe( 0 ); + } ); + + it( 'falls back to the default rotation in a viewport without its own', () => { + const style = { rotate: 15, '@mobile': { rotate: -30 } }; + expect( getRotateForState( style, TABLET_STATE ) ).toBe( 15 ); + } ); + + it( 'ignores pseudo states', () => { + const style = { rotate: 15, '@mobile': { rotate: -30 } }; + expect( + getRotateForState( style, { + viewport: '@mobile', + pseudo: ':hover', + } ) + ).toBe( -30 ); + expect( + getRotateForState( style, { + viewport: 'default', + pseudo: ':hover', + } ) + ).toBe( 15 ); + } ); + } ); + + describe( 'getUpdatedRotateStyle()', () => { + it( 'sets the default rotation', () => { + expect( + getUpdatedRotateStyle( + { color: { text: 'red' } }, + 30, + DEFAULT_STATE + ) + ).toEqual( { color: { text: 'red' }, rotate: 30 } ); + } ); + + it( 'wraps the angle into the stored range', () => { + expect( + getUpdatedRotateStyle( undefined, 270, DEFAULT_STATE ) + ).toEqual( { rotate: -90 } ); + } ); + + it( 'does not store 0 in the default state', () => { + expect( + getUpdatedRotateStyle( + { rotate: 30, color: { text: 'red' } }, + 0, + DEFAULT_STATE + ) + ).toEqual( { color: { text: 'red' } } ); + expect( + getUpdatedRotateStyle( { rotate: 30 }, 0, DEFAULT_STATE ) + ).toBeUndefined(); + } ); + + it( 'keeps viewport overrides when the default rotation is removed', () => { + expect( + getUpdatedRotateStyle( + { rotate: 30, '@mobile': { rotate: 0 } }, + undefined, + DEFAULT_STATE + ) + ).toEqual( { '@mobile': { rotate: 0 } } ); + } ); + + it( 'stores 0 in a viewport state', () => { + expect( + getUpdatedRotateStyle( { rotate: 30 }, 0, MOBILE_STATE ) + ).toEqual( { rotate: 30, '@mobile': { rotate: 0 } } ); + } ); + + it( 'keeps the other styles of a viewport state', () => { + expect( + getUpdatedRotateStyle( + { + rotate: 30, + '@mobile': { color: { text: 'red' } }, + }, + -45, + MOBILE_STATE + ) + ).toEqual( { + rotate: 30, + '@mobile': { color: { text: 'red' }, rotate: -45 }, + } ); + } ); + + it( 'removes a viewport rotation and cleans up the empty state', () => { + expect( + getUpdatedRotateStyle( + { rotate: 30, '@mobile': { rotate: 0 } }, + undefined, + MOBILE_STATE + ) + ).toEqual( { rotate: 30 } ); + } ); + + it( 'writes to the viewport when a pseudo state is selected', () => { + expect( + getUpdatedRotateStyle( undefined, 10, { + viewport: '@tablet', + pseudo: ':hover', + } ) + ).toEqual( { '@tablet': { rotate: 10 } } ); + } ); + } ); + + describe( 'getRotateCSS()', () => { + it( 'rotates the block with the rotate property', () => { + expect( getRotateCSS( { rotate: 15 }, '.wp-rotate-1' ) ).toBe( + '.wp-rotate-1{rotate:15deg;}' + ); + } ); + + it( 'outputs nothing without a rotation', () => { + expect( getRotateCSS( undefined, '.wp-rotate-1' ) ).toBe( '' ); + expect( + getRotateCSS( { color: { text: 'red' } }, '.wp-rotate-1' ) + ).toBe( '' ); + } ); + + it( 'outputs viewport overrides in media queries after the default rotation', () => { + expect( + getRotateCSS( + { + rotate: 15, + '@mobile': { rotate: 0 }, + '@tablet': { rotate: -20 }, + }, + '.wp-rotate-1' + ) + ).toBe( + '.wp-rotate-1{rotate:15deg;}' + + '@media (width <= 480px){.wp-rotate-1{rotate:none;}}' + + '@media (480px < width <= 782px){.wp-rotate-1{rotate:-20deg;}}' + ); + } ); + + it( 'uses the configured breakpoints', () => { + expect( + getRotateCSS( { '@mobile': { rotate: 45 } }, '.wp-rotate-1', { + mobile: '600px', + tablet: '1024px', + } ) + ).toBe( '@media (width <= 600px){.wp-rotate-1{rotate:45deg;}}' ); + } ); + + it( 'ignores values that are not numbers', () => { + expect( + getRotateCSS( + { + rotate: '15deg; color: red', + '@mobile': { rotate: 'none' }, + }, + '.wp-rotate-1' + ) + ).toBe( '' ); + } ); + } ); + + describe( 'isMatch()', () => { + it( 'matches styles with a default or a viewport rotation', () => { + expect( rotate.isMatch( { style: { rotate: 15 } } ) ).toBe( true ); + expect( + rotate.isMatch( { style: { '@mobile': { rotate: 0 } } } ) + ).toBe( true ); + } ); + + it( 'does not match styles without a rotation', () => { + expect( + rotate.isMatch( { style: { color: { text: 'red' } } } ) + ).toBe( false ); + expect( + rotate.isMatch( { + style: { '@mobile': { color: { text: 'red' } } }, + } ) + ).toBe( false ); + } ); + } ); +} ); diff --git a/packages/block-editor/src/utils/rotation.ts b/packages/block-editor/src/utils/rotation.ts new file mode 100644 index 00000000000000..15df99bfa61ffd --- /dev/null +++ b/packages/block-editor/src/utils/rotation.ts @@ -0,0 +1,42 @@ +/** + * Wraps an angle into the (-180, 180] range used to store rotation. + * + * @param angle Angle in degrees. + * + * @return The equivalent angle in (-180, 180]. + */ +export function normalizeAngle( angle: number ): number { + // Only add or remove a turn when needed, so that angles which are already + // in range come back exactly as they are. + let normalized = angle % 360; + if ( normalized > 180 ) { + normalized -= 360; + } else if ( normalized <= -180 ) { + normalized += 360; + } + // Avoid returning -0. + return normalized || 0; +} + +/** + * Converts a stored rotation to the [0, 360) range used by `AnglePickerControl`. + * + * @param rotate Stored rotation in degrees. + * + * @return The angle in [0, 360). + */ +export function toPickerAngle( rotate: number | undefined ): number { + return ( ( ( rotate ?? 0 ) % 360 ) + 360 ) % 360; +} + +/** + * Converts an `AnglePickerControl` value to a stored rotation. + * + * @param value Angle in degrees, in [0, 360]. + * + * @return The rotation in (-180, 180]. + */ +export function fromPickerAngle( value: number | string | undefined ): number { + const angle = Number( value ); + return Number.isFinite( angle ) ? normalizeAngle( Math.round( angle ) ) : 0; +} diff --git a/packages/block-editor/src/utils/test/rotation.js b/packages/block-editor/src/utils/test/rotation.js new file mode 100644 index 00000000000000..6342e9b482779d --- /dev/null +++ b/packages/block-editor/src/utils/test/rotation.js @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; +import { fromPickerAngle, normalizeAngle, toPickerAngle } from '../rotation'; + +describe( 'normalizeAngle', () => { + it( 'keeps angles that are already in range', () => { + expect( normalizeAngle( 0 ) ).toBe( 0 ); + expect( normalizeAngle( 45 ) ).toBe( 45 ); + expect( normalizeAngle( -45 ) ).toBe( -45 ); + expect( normalizeAngle( 180 ) ).toBe( 180 ); + } ); + + it( 'wraps angles into (-180, 180]', () => { + expect( normalizeAngle( 190 ) ).toBe( -170 ); + expect( normalizeAngle( -190 ) ).toBe( 170 ); + expect( normalizeAngle( -180 ) ).toBe( 180 ); + expect( normalizeAngle( 540 ) ).toBe( 180 ); + expect( normalizeAngle( 405 ) ).toBe( 45 ); + } ); + + it( 'never returns -0', () => { + expect( Object.is( normalizeAngle( -0 ), 0 ) ).toBe( true ); + expect( Object.is( normalizeAngle( -360 ), 0 ) ).toBe( true ); + } ); +} ); + +describe( 'toPickerAngle', () => { + it( 'converts a stored rotation to the picker range', () => { + expect( toPickerAngle( undefined ) ).toBe( 0 ); + expect( toPickerAngle( 0 ) ).toBe( 0 ); + expect( toPickerAngle( 45 ) ).toBe( 45 ); + expect( toPickerAngle( 180 ) ).toBe( 180 ); + expect( toPickerAngle( -90 ) ).toBe( 270 ); + } ); +} ); + +describe( 'fromPickerAngle', () => { + it( 'converts a picker value to a stored rotation', () => { + expect( fromPickerAngle( 0 ) ).toBe( 0 ); + expect( fromPickerAngle( 90 ) ).toBe( 90 ); + expect( fromPickerAngle( 180 ) ).toBe( 180 ); + expect( fromPickerAngle( 270 ) ).toBe( -90 ); + expect( fromPickerAngle( 360 ) ).toBe( 0 ); + } ); + + it( 'accepts strings and rounds to whole degrees', () => { + expect( fromPickerAngle( '45' ) ).toBe( 45 ); + expect( fromPickerAngle( 12.6 ) ).toBe( 13 ); + } ); + + it( 'treats values that are not numbers as no rotation', () => { + expect( fromPickerAngle( 'abc' ) ).toBe( 0 ); + expect( fromPickerAngle( undefined ) ).toBe( 0 ); + } ); +} ); diff --git a/phpunit/block-supports/rotate-test.php b/phpunit/block-supports/rotate-test.php new file mode 100644 index 00000000000000..d3661c77eece8e --- /dev/null +++ b/phpunit/block-supports/rotate-test.php @@ -0,0 +1,311 @@ +test_block_name = null; + WP_Style_Engine_CSS_Rules_Store_Gutenberg::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_Gutenberg::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(), $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 gutenberg_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 gutenberg_style_engine_get_stylesheet_from_context( 'block-supports', array( 'prettify' => false ) ); + } + + /** + * Tests that every block supports rotation unless it opts out. + * + * @covers ::gutenberg_register_rotate_support + */ + public function test_registers_style_attribute_unless_block_opts_out() { + $block_type = $this->register_test_block(); + gutenberg_register_rotate_support( $block_type ); + $this->assertSame( array( 'type' => 'object' ), $block_type->attributes['style'] ?? null, 'A block supports rotation by default.' ); + unregister_block_type( $this->test_block_name ); + + $block_type = $this->register_test_block( array( 'rotate' => false ) ); + gutenberg_register_rotate_support( $block_type ); + $this->assertArrayNotHasKey( 'style', $block_type->attributes, 'A block that opts out of rotation does not get a style attribute from it.' ); + } + + /** + * Tests that stored rotations are read as angles in (-180, 180]. + * + * @covers ::gutenberg_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, gutenberg_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 ), + '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 ::gutenberg_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 ::gutenberg_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 ::gutenberg_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 ::gutenberg_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 ::gutenberg_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 ::gutenberg_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 blocks that opt out of rotation are not rotated. + * + * @covers ::gutenberg_render_rotate_support + */ + public function test_does_not_render_when_block_opts_out() { + $this->register_test_block( array( 'rotate' => false ) ); + $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 ::gutenberg_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 ::gutenberg_add_rotate_to_safe_style_css + */ + 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' ) ); + } +} From 7b038e41de501766b5391bef0b3aa9241e504d75 Mon Sep 17 00:00:00 2001 From: Andrei Draganescu Date: Sat, 10 Oct 2026 11:53:26 -0500 Subject: [PATCH 02/20] Block Editor: Opt wrapperless blocks out of rotation and harden values 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 --- docs/reference-guides/core-blocks/README.md | 16 +-- lib/block-supports/rotate.php | 8 +- lib/compat/wordpress-7.2/kses.php | 4 +- lib/experimental/experiments/load.php | 2 +- packages/block-editor/src/hooks/rotate.tsx | 77 +++++++++--- .../src/hooks/test/rotate.jsdom.test.js | 104 +++++++++++++++ packages/block-library/src/block/README.md | 1 + packages/block-library/src/block/block.json | 1 + packages/block-library/src/freeform/README.md | 1 + .../block-library/src/freeform/block.json | 1 + packages/block-library/src/html/README.md | 1 + packages/block-library/src/html/block.json | 1 + packages/block-library/src/missing/README.md | 1 + packages/block-library/src/missing/block.json | 1 + packages/block-library/src/more/README.md | 1 + packages/block-library/src/more/block.json | 1 + packages/block-library/src/nextpage/README.md | 1 + .../block-library/src/nextpage/block.json | 1 + packages/block-library/src/pattern/README.md | 1 + packages/block-library/src/pattern/block.json | 1 + .../block-library/src/shortcode/README.md | 1 + .../block-library/src/shortcode/block.json | 1 + phpunit/block-supports/rotate-test.php | 119 +++++++++++++++--- 23 files changed, 298 insertions(+), 48 deletions(-) diff --git a/docs/reference-guides/core-blocks/README.md b/docs/reference-guides/core-blocks/README.md index af672d5e26a64c..acf8de65f7fb6c 100644 --- a/docs/reference-guides/core-blocks/README.md +++ b/docs/reference-guides/core-blocks/README.md @@ -92,7 +92,7 @@ Reuse this design across your site. - **Name:** [core/block](https://developer.wordpress.org/block-editor/reference-guides/core-blocks/core-blocks-reusable/core-block-block/) - **Category:** [reusable](https://developer.wordpress.org/block-editor/reference-guides/core-blocks/core-blocks-reusable/) -- **Supports:** interactivity (clientNavigation), ~~customCSS~~, ~~customClassName~~, ~~html~~, ~~inserter~~, ~~renaming~~, ~~visibility~~ +- **Supports:** interactivity (clientNavigation), ~~customCSS~~, ~~customClassName~~, ~~html~~, ~~inserter~~, ~~renaming~~, ~~rotate~~, ~~visibility~~ - **Attributes:** content, ref ## Breadcrumbs @@ -347,7 +347,7 @@ Use the classic WordPress editor. - **Name:** [core/freeform](https://developer.wordpress.org/block-editor/reference-guides/core-blocks/core-blocks-text/core-block-freeform/) - **Category:** [text](https://developer.wordpress.org/block-editor/reference-guides/core-blocks/core-blocks-text/) -- **Supports:** ~~className~~, ~~customCSS~~, ~~customClassName~~, ~~lock~~, ~~renaming~~, ~~reusable~~, ~~visibility~~ +- **Supports:** ~~className~~, ~~customCSS~~, ~~customClassName~~, ~~lock~~, ~~renaming~~, ~~reusable~~, ~~rotate~~, ~~visibility~~ - **Attributes:** content ## Gallery @@ -394,7 +394,7 @@ Add custom HTML code and preview it as you edit. - **Name:** [core/html](https://developer.wordpress.org/block-editor/reference-guides/core-blocks/core-blocks-widgets/core-block-html/) - **Category:** [widgets](https://developer.wordpress.org/block-editor/reference-guides/core-blocks/core-blocks-widgets/) -- **Supports:** interactivity (clientNavigation), listView, ~~className~~, ~~customCSS~~, ~~customClassName~~, ~~html~~, ~~visibility~~ +- **Supports:** interactivity (clientNavigation), listView, ~~className~~, ~~customCSS~~, ~~customClassName~~, ~~html~~, ~~rotate~~, ~~visibility~~ - **Attributes:** content ## Icon @@ -487,7 +487,7 @@ Your site doesn’t include support for this block. - **Name:** [core/missing](https://developer.wordpress.org/block-editor/reference-guides/core-blocks/core-blocks-text/core-block-missing/) - **Category:** [text](https://developer.wordpress.org/block-editor/reference-guides/core-blocks/core-blocks-text/) -- **Supports:** interactivity (clientNavigation), ~~className~~, ~~customCSS~~, ~~customClassName~~, ~~html~~, ~~inserter~~, ~~lock~~, ~~renaming~~, ~~reusable~~, ~~visibility~~ +- **Supports:** interactivity (clientNavigation), ~~className~~, ~~customCSS~~, ~~customClassName~~, ~~html~~, ~~inserter~~, ~~lock~~, ~~renaming~~, ~~reusable~~, ~~rotate~~, ~~visibility~~ - **Attributes:** originalContent, originalName, originalUndelimitedContent ## More @@ -496,7 +496,7 @@ Content before this block is shown in the excerpt on blog and archive pages. Whe - **Name:** [core/more](https://developer.wordpress.org/block-editor/reference-guides/core-blocks/core-blocks-design/core-block-more/) - **Category:** [design](https://developer.wordpress.org/block-editor/reference-guides/core-blocks/core-blocks-design/) -- **Supports:** interactivity (clientNavigation), ~~className~~, ~~customCSS~~, ~~customClassName~~, ~~html~~, ~~multiple~~, ~~visibility~~ +- **Supports:** interactivity (clientNavigation), ~~className~~, ~~customCSS~~, ~~customClassName~~, ~~html~~, ~~multiple~~, ~~rotate~~, ~~visibility~~ - **Attributes:** customText, noTeaser ## Navigation @@ -546,7 +546,7 @@ Separate your content into a multi-page experience. - **Name:** [core/nextpage](https://developer.wordpress.org/block-editor/reference-guides/core-blocks/core-blocks-design/core-block-nextpage/) - **Category:** [design](https://developer.wordpress.org/block-editor/reference-guides/core-blocks/core-blocks-design/) - **Parent:** core/post-content -- **Supports:** interactivity (clientNavigation), ~~className~~, ~~customCSS~~, ~~customClassName~~, ~~html~~, ~~visibility~~ +- **Supports:** interactivity (clientNavigation), ~~className~~, ~~customCSS~~, ~~customClassName~~, ~~html~~, ~~rotate~~, ~~visibility~~ ## Page List @@ -583,7 +583,7 @@ Show a block pattern. - **Name:** [core/pattern](https://developer.wordpress.org/block-editor/reference-guides/core-blocks/core-blocks-theme/core-block-pattern/) - **Category:** [theme](https://developer.wordpress.org/block-editor/reference-guides/core-blocks/core-blocks-theme/) -- **Supports:** interactivity (clientNavigation), ~~html~~, ~~inserter~~, ~~renaming~~, ~~visibility~~ +- **Supports:** interactivity (clientNavigation), ~~html~~, ~~inserter~~, ~~renaming~~, ~~rotate~~, ~~visibility~~ - **Attributes:** slug ## Playlist @@ -895,7 +895,7 @@ Insert additional custom elements with a WordPress shortcode. - **Name:** [core/shortcode](https://developer.wordpress.org/block-editor/reference-guides/core-blocks/core-blocks-widgets/core-block-shortcode/) - **Category:** [widgets](https://developer.wordpress.org/block-editor/reference-guides/core-blocks/core-blocks-widgets/) -- **Supports:** ~~className~~, ~~customCSS~~, ~~customClassName~~, ~~html~~, ~~visibility~~ +- **Supports:** ~~className~~, ~~customCSS~~, ~~customClassName~~, ~~html~~, ~~rotate~~, ~~visibility~~ - **Attributes:** text ## Site Logo diff --git a/lib/block-supports/rotate.php b/lib/block-supports/rotate.php index 808975744fa9ef..5dfc5c665b6403 100644 --- a/lib/block-supports/rotate.php +++ b/lib/block-supports/rotate.php @@ -2,7 +2,7 @@ /** * Rotate block support flag. * - * Every block supports rotation unless it opts out with `supports.rotate: false`. + * Blocks support rotation unless they opt out with `supports.rotate: false`. * The rotation is a number of degrees stored in `style.rotate`, with viewport * overrides in `style['@tablet'].rotate` and `style['@mobile'].rotate`. * @@ -134,6 +134,12 @@ function gutenberg_render_rotate_support( $block_content, $block ) { 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_Gutenberg::get_viewport_media_queries( gutenberg_get_global_settings( array( 'viewport' ) ) ) diff --git a/lib/compat/wordpress-7.2/kses.php b/lib/compat/wordpress-7.2/kses.php index 4b691c20dbd8b8..84bc4eaf03afae 100644 --- a/lib/compat/wordpress-7.2/kses.php +++ b/lib/compat/wordpress-7.2/kses.php @@ -55,7 +55,9 @@ function gutenberg_add_background_clip_to_safe_style_css( array $attr ): array { * * The rotate block support uses the `rotate` property, rather than `transform`, so * that the rotation combines with any transforms a theme or block applies instead - * of replacing them. + * of replacing them. 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. diff --git a/lib/experimental/experiments/load.php b/lib/experimental/experiments/load.php index d2c8a08f226257..5834c20eb03516 100644 --- a/lib/experimental/experiments/load.php +++ b/lib/experimental/experiments/load.php @@ -32,7 +32,7 @@ function gutenberg_initialize_experiments_settings() { array( 'id' => 'gutenberg-block-rotation', 'label' => __( 'Block rotation', 'gutenberg' ), - 'description' => __( 'Adds a Rotation control to the Dimensions panel of every block.', 'gutenberg' ), + 'description' => __( 'Adds a Rotation control to the Dimensions panel of blocks. Blocks that are already rotated stay rotated when this is off.', 'gutenberg' ), ), ), ), diff --git a/packages/block-editor/src/hooks/rotate.tsx b/packages/block-editor/src/hooks/rotate.tsx index 907185f61dae30..b7da464da7cf8c 100644 --- a/packages/block-editor/src/hooks/rotate.tsx +++ b/packages/block-editor/src/hooks/rotate.tsx @@ -1,4 +1,4 @@ -import { hasBlockSupport } from '@wordpress/blocks'; +import { getBlockType, hasBlockSupport } from '@wordpress/blocks'; import type { BlockType } from '@wordpress/blocks'; import { AnglePickerControl, @@ -14,6 +14,7 @@ import InspectorControls from '../components/inspector-controls'; import { useSettings } from '../components/use-settings'; import { store as blockEditorStore } from '../store'; import { unlock } from '../lock-unlock'; +import { isPlainObject } from '../utils/object'; import { fromPickerAngle, normalizeAngle, @@ -55,16 +56,42 @@ interface RotateEditProps { // Used for generating the instance ID. const ROTATE_BLOCK_PROPS_REFERENCE = {}; +// A number in decimal notation, as PHP's `is_numeric()` accepts it. Hex, +// binary and octal strings such as `0x10`, which `Number()` would read, are +// not rotations. +const DECIMAL_NUMBER_PATTERN = /^\s*[+-]?(\d+(\.\d*)?|\.\d+)(e[+-]?\d+)?\s*$/i; + /** - * Returns whether a block supports rotation. Every block does unless it opts - * out with `supports.rotate: false`. + * Returns whether a block supports rotation. Blocks do unless they opt out + * with `supports.rotate: false`, or define a `style` attribute of their own + * that is not an object, which rotation could not be stored in. * * @param nameOrType Block name or block type object. * * @return Whether the block supports rotation. */ export function hasRotateSupport( nameOrType: string | BlockType ): boolean { - return hasBlockSupport( nameOrType, ROTATE_SUPPORT_KEY, true ); + const blockType = + typeof nameOrType === 'string' + ? getBlockType( nameOrType ) + : nameOrType; + const styleType = blockType?.attributes?.style?.type; + return ( + ( styleType === undefined || styleType === 'object' ) && + hasBlockSupport( nameOrType, ROTATE_SUPPORT_KEY, true ) + ); +} + +/** + * Returns a block style attribute when it is an object, which is the only + * kind of style attribute rotation is read from or written to. + * + * @param style Block style attribute. + * + * @return The style attribute, or undefined when it is not an object. + */ +function getStyleObject( style: unknown ): BlockStyle | undefined { + return isPlainObject( style ) ? style : undefined; } /** @@ -85,7 +112,8 @@ export function isRotateEnabled( nameOrType: string | BlockType ): boolean { /** * Reads a stored rotation, which is a number of degrees. Anything that is not - * a finite number, or a string holding one, is ignored. + * a finite number, or a string holding one in decimal notation, is ignored. + * Matches `gutenberg_get_rotate_value()` in PHP. * * @param value Stored rotation. * @@ -93,13 +121,21 @@ export function isRotateEnabled( nameOrType: string | BlockType ): boolean { */ export function getRotateValue( value: unknown ): number | undefined { const angle = - typeof value === 'string' && value.trim() !== '' + typeof value === 'string' && DECIMAL_NUMBER_PATTERN.test( value ) ? Number( value ) : value; if ( typeof angle !== 'number' || ! Number.isFinite( angle ) ) { return undefined; } - return normalizeAngle( Math.round( angle * 100 ) / 100 ); + // Round halves away from zero, after removing floating point noise, the + // way PHP's `round()` does: 1.005 is 1.01 and -12.345 is -12.35. + const rounded = + ( Math.sign( angle ) * + Math.round( + Number( ( Math.abs( angle ) * 100 ).toPrecision( 15 ) ) + ) ) / + 100; + return normalizeAngle( rounded ); } /** @@ -131,7 +167,12 @@ function getRotateStateStyle( style: BlockStyle | undefined, selectedState: BlockStyleState ): BlockStyle | undefined { - return getStyleForState( style ?? {}, getRotateState( selectedState ) ); + return getStyleObject( + getStyleForState( + getStyleObject( style ) ?? {}, + getRotateState( selectedState ) + ) + ); } /** @@ -147,7 +188,8 @@ export function getRotateForState( style: BlockStyle | undefined, selectedState: BlockStyleState ): number { - const defaultRotate = getRotateValue( style?.rotate ) ?? 0; + const defaultRotate = + getRotateValue( getStyleObject( style )?.rotate ) ?? 0; if ( ! hasViewportBlockStyleState( selectedState ) ) { return defaultRotate; } @@ -182,6 +224,7 @@ export function getUpdatedRotateStyle( angle: number | undefined, selectedState: BlockStyleState ): BlockStyle | undefined { + const baseStyle = getStyleObject( style ); const rotateState = getRotateState( selectedState ); const isViewportState = hasViewportBlockStyleState( rotateState ); let rotate = getRotateValue( angle ); @@ -190,11 +233,11 @@ export function getUpdatedRotateStyle( } if ( ! isViewportState ) { - return cleanEmptyObject( { ...style, rotate } ); + return cleanEmptyObject( { ...baseStyle, rotate } ); } - return setStyleForState( style ?? {}, rotateState, { - ...getRotateStateStyle( style, rotateState ), + return setStyleForState( baseStyle ?? {}, rotateState, { + ...getRotateStateStyle( baseStyle, rotateState ), rotate, } ); } @@ -207,14 +250,12 @@ export function getUpdatedRotateStyle( * @return Whether there is a rotation in the default or a viewport state. */ function hasRotateValue( style: BlockStyle | undefined ): boolean { - if ( ! style || typeof style !== 'object' ) { - return false; - } - return Object.entries( style ).some( + return Object.entries( getStyleObject( style ) ?? {} ).some( ( [ key, value ] ) => key === ROTATE_SUPPORT_KEY || ( key.startsWith( '@' ) && - value?.[ ROTATE_SUPPORT_KEY ] !== undefined ) + isPlainObject( value ) && + value[ ROTATE_SUPPORT_KEY ] !== undefined ) ); } @@ -236,7 +277,7 @@ export function getRotateCSS( viewportSettings?: Record< string, unknown > ): string { const rules: string[] = []; - const rotate = getRotateValue( style?.rotate ); + const rotate = getRotateValue( getStyleObject( style )?.rotate ); if ( rotate ) { rules.push( `${ selector }{rotate:${ rotate }deg;}` ); } diff --git a/packages/block-editor/src/hooks/test/rotate.jsdom.test.js b/packages/block-editor/src/hooks/test/rotate.jsdom.test.js index 69eaf9607aaadd..9887b5b9690dbc 100644 --- a/packages/block-editor/src/hooks/test/rotate.jsdom.test.js +++ b/packages/block-editor/src/hooks/test/rotate.jsdom.test.js @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it } from 'vitest'; +import { registerBlockType, unregisterBlockType } from '@wordpress/blocks'; import rotate, { getRotateCSS, getRotateForState, @@ -30,6 +31,21 @@ describe( 'rotate block support', () => { false ); } ); + + it( 'is off for blocks whose own style attribute is not an object', () => { + expect( + hasRotateSupport( { + supports: {}, + attributes: { style: { type: 'string' } }, + } ) + ).toBe( false ); + expect( + hasRotateSupport( { + supports: {}, + attributes: { style: { type: 'object' } }, + } ) + ).toBe( true ); + } ); } ); describe( 'isRotateEnabled()', () => { @@ -55,12 +71,30 @@ describe( 'rotate block support', () => { expect( getRotateValue( 0 ) ).toBe( 0 ); } ); + it( 'reads decimal strings the way PHP does', () => { + expect( getRotateValue( ' 15 ' ) ).toBe( 15 ); + expect( getRotateValue( '.5' ) ).toBe( 0.5 ); + expect( getRotateValue( '1e2' ) ).toBe( 100 ); + expect( getRotateValue( '0x10' ) ).toBeUndefined(); + expect( getRotateValue( '0b11' ) ).toBeUndefined(); + expect( getRotateValue( '0o7' ) ).toBeUndefined(); + expect( getRotateValue( 'Infinity' ) ).toBeUndefined(); + } ); + it( 'wraps into (-180, 180] and rounds to two decimals', () => { expect( getRotateValue( 270 ) ).toBe( -90 ); expect( getRotateValue( -180 ) ).toBe( 180 ); expect( getRotateValue( 12.3456 ) ).toBe( 12.35 ); } ); + it( 'rounds halves away from zero, like PHP', () => { + expect( getRotateValue( -12.345 ) ).toBe( -12.35 ); + expect( getRotateValue( 12.345 ) ).toBe( 12.35 ); + expect( getRotateValue( 1.005 ) ).toBe( 1.01 ); + expect( getRotateValue( 0.145 ) ).toBe( 0.15 ); + expect( getRotateValue( -179.999 ) ).toBe( 180 ); + } ); + it( 'ignores values that are not numbers', () => { expect( getRotateValue( undefined ) ).toBeUndefined(); expect( getRotateValue( null ) ).toBeUndefined(); @@ -247,6 +281,76 @@ describe( 'rotate block support', () => { } ); } ); + describe( 'style attributes that are not objects', () => { + it( 'are read as no rotation', () => { + expect( getRotateForState( 'rotate: 15deg', DEFAULT_STATE ) ).toBe( + 0 + ); + expect( getRotateCSS( 'rotate: 15deg', '.wp-rotate-1' ) ).toBe( + '' + ); + expect( + getRotateCSS( { '@mobile': 'rotate' }, '.wp-rotate-1' ) + ).toBe( '' ); + expect( rotate.isMatch( { style: 'rotate' } ) ).toBe( false ); + } ); + + it( 'are replaced when a rotation is set', () => { + expect( + getUpdatedRotateStyle( 'rotate: 15deg', 15, DEFAULT_STATE ) + ).toEqual( { rotate: 15 } ); + expect( + getUpdatedRotateStyle( + { '@mobile': 'rotate' }, + 15, + MOBILE_STATE + ) + ).toEqual( { '@mobile': { rotate: 15 } } ); + } ); + } ); + + describe( 'style attribute registration', () => { + const BLOCK_NAME = 'test/rotate-attribute'; + + afterEach( () => { + unregisterBlockType( BLOCK_NAME ); + } ); + + function registerTestBlock( settings ) { + return registerBlockType( BLOCK_NAME, { + apiVersion: 3, + title: 'Rotate attribute test', + category: 'text', + edit: () => null, + save: () => null, + ...settings, + } ); + } + + it( 'adds an object style attribute', () => { + expect( registerTestBlock().attributes.style ).toEqual( { + type: 'object', + } ); + } ); + + it( 'keeps an existing style attribute definition', () => { + const style = { + type: 'object', + default: { color: { text: 'red' } }, + }; + expect( + registerTestBlock( { attributes: { style } } ).attributes.style + ).toEqual( style ); + } ); + + it( 'does not add a style attribute to blocks that opt out', () => { + expect( + registerTestBlock( { supports: { rotate: false } } ).attributes + .style + ).toBeUndefined(); + } ); + } ); + describe( 'isMatch()', () => { it( 'matches styles with a default or a viewport rotation', () => { expect( rotate.isMatch( { style: { rotate: 15 } } ) ).toBe( true ); diff --git a/packages/block-library/src/block/README.md b/packages/block-library/src/block/README.md index bf7c3b06338850..02c5a7aa124957 100644 --- a/packages/block-library/src/block/README.md +++ b/packages/block-library/src/block/README.md @@ -29,6 +29,7 @@ _Defined via the [`supports`](https://developer.wordpress.org/block-editor/refer - [`interactivity`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#interactivity): - `clientNavigation`: `true` - `customCSS`: `false` +- `rotate`: `false` - [`visibility`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#visibility): `false` ## Context diff --git a/packages/block-library/src/block/block.json b/packages/block-library/src/block/block.json index ef5c31ae1e957d..39ee89b5cfcd75 100644 --- a/packages/block-library/src/block/block.json +++ b/packages/block-library/src/block/block.json @@ -28,6 +28,7 @@ "clientNavigation": true }, "customCSS": false, + "rotate": false, "visibility": false } } diff --git a/packages/block-library/src/freeform/README.md b/packages/block-library/src/freeform/README.md index 29245b2ab20afa..b319a061c1f1f0 100644 --- a/packages/block-library/src/freeform/README.md +++ b/packages/block-library/src/freeform/README.md @@ -25,6 +25,7 @@ _Defined via the [`supports`](https://developer.wordpress.org/block-editor/refer - [`lock`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#lock): `false` - [`reusable`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#reusable): `false` - [`renaming`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#renaming): `false` +- `rotate`: `false` - [`visibility`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#visibility): `false` - `customCSS`: `false` diff --git a/packages/block-library/src/freeform/block.json b/packages/block-library/src/freeform/block.json index ba06598ffebd34..25dd04aca51bcc 100644 --- a/packages/block-library/src/freeform/block.json +++ b/packages/block-library/src/freeform/block.json @@ -18,6 +18,7 @@ "lock": false, "reusable": false, "renaming": false, + "rotate": false, "visibility": false, "customCSS": false }, diff --git a/packages/block-library/src/html/README.md b/packages/block-library/src/html/README.md index b774d45517af86..d7ecbe814b3c4b 100644 --- a/packages/block-library/src/html/README.md +++ b/packages/block-library/src/html/README.md @@ -28,6 +28,7 @@ _Defined via the [`supports`](https://developer.wordpress.org/block-editor/refer - `clientNavigation`: `true` - [`listView`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#listview): `true` - `customCSS`: `false` +- `rotate`: `false` - [`visibility`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#visibility): `false` ## Block Markup diff --git a/packages/block-library/src/html/block.json b/packages/block-library/src/html/block.json index 9a59152fd57f24..7d731f14eab877 100644 --- a/packages/block-library/src/html/block.json +++ b/packages/block-library/src/html/block.json @@ -22,6 +22,7 @@ }, "listView": true, "customCSS": false, + "rotate": false, "visibility": false }, "editorStyle": "wp-block-html-editor" diff --git a/packages/block-library/src/missing/README.md b/packages/block-library/src/missing/README.md index fd8edc5e332b2d..2f98e413cce316 100644 --- a/packages/block-library/src/missing/README.md +++ b/packages/block-library/src/missing/README.md @@ -29,6 +29,7 @@ _Defined via the [`supports`](https://developer.wordpress.org/block-editor/refer - [`lock`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#lock): `false` - [`reusable`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#reusable): `false` - [`renaming`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#renaming): `false` +- `rotate`: `false` - [`visibility`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#visibility): `false` - [`interactivity`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#interactivity): - `clientNavigation`: `true` diff --git a/packages/block-library/src/missing/block.json b/packages/block-library/src/missing/block.json index 92c9735d1ba157..697b763b23aba8 100644 --- a/packages/block-library/src/missing/block.json +++ b/packages/block-library/src/missing/block.json @@ -26,6 +26,7 @@ "lock": false, "reusable": false, "renaming": false, + "rotate": false, "visibility": false, "interactivity": { "clientNavigation": true diff --git a/packages/block-library/src/more/README.md b/packages/block-library/src/more/README.md index 3665d0a96684c1..47e3b8acd27e41 100644 --- a/packages/block-library/src/more/README.md +++ b/packages/block-library/src/more/README.md @@ -26,6 +26,7 @@ _Defined via the [`supports`](https://developer.wordpress.org/block-editor/refer - [`className`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#classname): `false` - [`html`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#html): `false` - [`multiple`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#multiple): `false` +- `rotate`: `false` - [`visibility`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#visibility): `false` - [`interactivity`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#interactivity): - `clientNavigation`: `true` diff --git a/packages/block-library/src/more/block.json b/packages/block-library/src/more/block.json index 81ad94ba3e455d..f1db8ce1d53c0c 100644 --- a/packages/block-library/src/more/block.json +++ b/packages/block-library/src/more/block.json @@ -23,6 +23,7 @@ "className": false, "html": false, "multiple": false, + "rotate": false, "visibility": false, "interactivity": { "clientNavigation": true diff --git a/packages/block-library/src/nextpage/README.md b/packages/block-library/src/nextpage/README.md index 6d3ea4de578a23..50b3bbb119d735 100644 --- a/packages/block-library/src/nextpage/README.md +++ b/packages/block-library/src/nextpage/README.md @@ -27,6 +27,7 @@ _Defined via the [`supports`](https://developer.wordpress.org/block-editor/refer - [`customClassName`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#customclassname): `false` - [`className`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#classname): `false` - [`html`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#html): `false` +- `rotate`: `false` - [`visibility`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#visibility): `false` - [`interactivity`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#interactivity): - `clientNavigation`: `true` diff --git a/packages/block-library/src/nextpage/block.json b/packages/block-library/src/nextpage/block.json index 9021183adcce70..5a8f00a55a3ca0 100644 --- a/packages/block-library/src/nextpage/block.json +++ b/packages/block-library/src/nextpage/block.json @@ -12,6 +12,7 @@ "customClassName": false, "className": false, "html": false, + "rotate": false, "visibility": false, "interactivity": { "clientNavigation": true diff --git a/packages/block-library/src/pattern/README.md b/packages/block-library/src/pattern/README.md index 0dbf90a509d343..9411aa90c515b3 100644 --- a/packages/block-library/src/pattern/README.md +++ b/packages/block-library/src/pattern/README.md @@ -23,6 +23,7 @@ _Defined via the [`supports`](https://developer.wordpress.org/block-editor/refer - [`html`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#html): `false` - [`inserter`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#inserter): `false` - [`renaming`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#renaming): `false` +- `rotate`: `false` - [`visibility`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#visibility): `false` - [`interactivity`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#interactivity): - `clientNavigation`: `true` diff --git a/packages/block-library/src/pattern/block.json b/packages/block-library/src/pattern/block.json index d95db89e32f5ac..6aba4ba787a957 100644 --- a/packages/block-library/src/pattern/block.json +++ b/packages/block-library/src/pattern/block.json @@ -9,6 +9,7 @@ "html": false, "inserter": false, "renaming": false, + "rotate": false, "visibility": false, "interactivity": { "clientNavigation": true diff --git a/packages/block-library/src/shortcode/README.md b/packages/block-library/src/shortcode/README.md index 5cae1fce9f1bed..e3d8c9e8541d4b 100644 --- a/packages/block-library/src/shortcode/README.md +++ b/packages/block-library/src/shortcode/README.md @@ -24,6 +24,7 @@ _Defined via the [`supports`](https://developer.wordpress.org/block-editor/refer - [`customClassName`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#customclassname): `false` - [`html`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#html): `false` - `customCSS`: `false` +- `rotate`: `false` - [`visibility`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#visibility): `false` ## Block Markup diff --git a/packages/block-library/src/shortcode/block.json b/packages/block-library/src/shortcode/block.json index adbb48553ff130..e62139dc05920e 100644 --- a/packages/block-library/src/shortcode/block.json +++ b/packages/block-library/src/shortcode/block.json @@ -18,6 +18,7 @@ "customClassName": false, "html": false, "customCSS": false, + "rotate": false, "visibility": false }, "editorStyle": "wp-block-shortcode-editor" diff --git a/phpunit/block-supports/rotate-test.php b/phpunit/block-supports/rotate-test.php index d3661c77eece8e..ce356c225ec870 100644 --- a/phpunit/block-supports/rotate-test.php +++ b/phpunit/block-supports/rotate-test.php @@ -99,6 +99,22 @@ public function test_registers_style_attribute_unless_block_opts_out() { $this->assertArrayNotHasKey( 'style', $block_type->attributes, 'A block that opts out of rotation does not get a style attribute from it.' ); } + /** + * Tests that a block's own style attribute definition is kept. + * + * @covers ::gutenberg_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(), array( 'style' => $style_attribute ) ); + gutenberg_register_rotate_support( $block_type ); + + $this->assertSame( $style_attribute, $block_type->attributes['style'] ); + } + /** * Tests that stored rotations are read as angles in (-180, 180]. * @@ -120,25 +136,31 @@ public function test_get_rotate_value( $value, $expected ) { */ 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 ), - '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 ), + '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 ), + '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 ), ); } @@ -274,6 +296,65 @@ public function test_blocks_with_the_same_rotation_share_a_class() { $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 ::gutenberg_render_rotate_support + */ + public function test_does_not_render_when_style_attribute_is_not_an_object() { + $this->register_test_block( array(), 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 ::gutenberg_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_Gutenberg::LATEST_SCHEMA, + 'settings' => array( + 'viewport' => array( + 'mobile' => '640px', + 'tablet' => '960px', + ), + ), + ) + ); + }; + + add_filter( 'wp_theme_json_data_theme', $filter ); + WP_Theme_JSON_Resolver_Gutenberg::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_Gutenberg::clean_cached_data(); + } + } + /** * Tests that blocks that opt out of rotation are not rotated. * From 6e46f16a8cb2012854a08434f6e01aab298936c8 Mon Sep 17 00:00:00 2001 From: Andrei Draganescu Date: Sat, 10 Oct 2026 12:24:42 -0500 Subject: [PATCH 03/20] Block Editor: Allow the hexadecimal string in the rotate support tests PHPCompatibility flags the '0x10' test value, which checks that hexadecimal strings are rejected. Co-Authored-By: Claude Opus 5.5 --- phpunit/block-supports/rotate-test.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/phpunit/block-supports/rotate-test.php b/phpunit/block-supports/rotate-test.php index ce356c225ec870..35ff78e83b9a7e 100644 --- a/phpunit/block-supports/rotate-test.php +++ b/phpunit/block-supports/rotate-test.php @@ -150,7 +150,7 @@ public function data_get_rotate_value() { '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 ), + '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 ), From 68a6cf5d01ce3a5abc788cf85df4fbedcb7fd255 Mon Sep 17 00:00:00 2001 From: Andrei Draganescu Date: Sat, 10 Oct 2026 12:40:32 -0500 Subject: [PATCH 04/20] Block Editor: Trim rotation strings before checking they are numeric 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 --- lib/block-supports/rotate.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/block-supports/rotate.php b/lib/block-supports/rotate.php index 5dfc5c665b6403..a36dfbc0c4854c 100644 --- a/lib/block-supports/rotate.php +++ b/lib/block-supports/rotate.php @@ -41,6 +41,10 @@ function gutenberg_register_rotate_support( $block_type ) { * @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; } From 0de41db45d8a77c4174b5ae4701b47e8acb3a6fa Mon Sep 17 00:00:00 2001 From: Andrei Draganescu Date: Fri, 16 Oct 2026 12:42:18 +0300 Subject: [PATCH 05/20] Rotate: Type the viewport setting now that useSettings is typed 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 --- packages/block-editor/src/hooks/rotate.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/block-editor/src/hooks/rotate.tsx b/packages/block-editor/src/hooks/rotate.tsx index b7da464da7cf8c..74deb601481c7a 100644 --- a/packages/block-editor/src/hooks/rotate.tsx +++ b/packages/block-editor/src/hooks/rotate.tsx @@ -406,7 +406,10 @@ function useBlockProps( { clientId, style, }: Pick< RotateEditProps, 'clientId' | 'style' > ) { - const [ viewportSettings ] = useSettings( 'viewport' ); + // Settings are untyped. + const [ viewportSettings ] = useSettings( 'viewport' ) as [ + Record< string, unknown > | undefined, + ]; const className = useInstanceId( ROTATE_BLOCK_PROPS_REFERENCE, 'wp-rotate' From 1c9b30df919ce94e8dbf2b19bd4240c41641bd4e Mon Sep 17 00:00:00 2001 From: Andrei Draganescu Date: Wed, 21 Oct 2026 14:52:09 +0300 Subject: [PATCH 06/20] Block Supports: Add the rotate style attribute in the style hook 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 --- packages/block-editor/src/hooks/rotate.tsx | 30 ------------- packages/block-editor/src/hooks/style.tsx | 6 ++- .../src/hooks/test/rotate.jsdom.test.js | 43 ------------------- .../src/hooks/test/style.jsdom.test.tsx | 21 +++++++++ 4 files changed, 26 insertions(+), 74 deletions(-) diff --git a/packages/block-editor/src/hooks/rotate.tsx b/packages/block-editor/src/hooks/rotate.tsx index 74deb601481c7a..f247d5266d9c18 100644 --- a/packages/block-editor/src/hooks/rotate.tsx +++ b/packages/block-editor/src/hooks/rotate.tsx @@ -8,7 +8,6 @@ import { useInstanceId } from '@wordpress/compose'; import { useSelect } from '@wordpress/data'; import { useMemo } from '@wordpress/element'; import { privateApis as globalStylesEnginePrivateApis } from '@wordpress/global-styles-engine'; -import { addFilter } from '@wordpress/hooks'; import { __ } from '@wordpress/i18n'; import InspectorControls from '../components/inspector-controls'; import { useSettings } from '../components/use-settings'; @@ -299,29 +298,6 @@ export function getRotateCSS( return rules.join( '' ); } -/** - * Filters registered block settings, extending attributes to include the - * `style` attribute that rotation is stored in. - * - * @param settings Original block settings. - * - * @return Filtered block settings. - */ -function addAttribute( settings: BlockType ): BlockType { - if ( ! hasRotateSupport( settings ) || settings.attributes?.style ) { - return settings; - } - - settings.attributes = { - ...settings.attributes, - style: { - type: 'object', - }, - }; - - return settings; -} - /** * Removes the rotation from a block's attributes. The inspector controls * scope it to the selected style state. @@ -431,9 +407,3 @@ export default { hasSupport: hasRotateSupport, isMatch: ( { style }: { style?: BlockStyle } ) => hasRotateValue( style ), }; - -addFilter( - 'blocks.registerBlockType', - 'core/rotate/addAttribute', - addAttribute -); diff --git a/packages/block-editor/src/hooks/style.tsx b/packages/block-editor/src/hooks/style.tsx index a742cdc5153f1c..9c0cb2577e8d8a 100644 --- a/packages/block-editor/src/hooks/style.tsx +++ b/packages/block-editor/src/hooks/style.tsx @@ -58,6 +58,7 @@ import { useSettings } from '../components/use-settings'; import { store as blockEditorStore } from '../store'; import { globalStylesDataKey } from '../store/private-keys'; import { isPlainObject } from '../utils/object'; +import { hasRotateSupport } from './rotate'; import { unlock } from '../lock-unlock'; import type { PanelBorderSide, @@ -780,6 +781,8 @@ export function getCanvasStateStyleValue( /** * Filters registered block settings, extending attributes to include `style` attribute. + * Rotation is stored in it too, so it is added here rather than in the rotate + * hook, which keeps the attribute in the same place among the others. * * @param settings Original block settings. * @@ -788,7 +791,8 @@ export function getCanvasStateStyleValue( function addAttribute( settings: BlockType ): BlockType { if ( ! hasStyleSupport( settings ) && - ! hasBlockSupport( settings, 'customCSS', true ) + ! hasBlockSupport( settings, 'customCSS', true ) && + ! hasRotateSupport( settings ) ) { return settings; } diff --git a/packages/block-editor/src/hooks/test/rotate.jsdom.test.js b/packages/block-editor/src/hooks/test/rotate.jsdom.test.js index 9887b5b9690dbc..b399d8032d4c09 100644 --- a/packages/block-editor/src/hooks/test/rotate.jsdom.test.js +++ b/packages/block-editor/src/hooks/test/rotate.jsdom.test.js @@ -1,5 +1,4 @@ import { afterEach, describe, expect, it } from 'vitest'; -import { registerBlockType, unregisterBlockType } from '@wordpress/blocks'; import rotate, { getRotateCSS, getRotateForState, @@ -309,48 +308,6 @@ describe( 'rotate block support', () => { } ); } ); - describe( 'style attribute registration', () => { - const BLOCK_NAME = 'test/rotate-attribute'; - - afterEach( () => { - unregisterBlockType( BLOCK_NAME ); - } ); - - function registerTestBlock( settings ) { - return registerBlockType( BLOCK_NAME, { - apiVersion: 3, - title: 'Rotate attribute test', - category: 'text', - edit: () => null, - save: () => null, - ...settings, - } ); - } - - it( 'adds an object style attribute', () => { - expect( registerTestBlock().attributes.style ).toEqual( { - type: 'object', - } ); - } ); - - it( 'keeps an existing style attribute definition', () => { - const style = { - type: 'object', - default: { color: { text: 'red' } }, - }; - expect( - registerTestBlock( { attributes: { style } } ).attributes.style - ).toEqual( style ); - } ); - - it( 'does not add a style attribute to blocks that opt out', () => { - expect( - registerTestBlock( { supports: { rotate: false } } ).attributes - .style - ).toBeUndefined(); - } ); - } ); - describe( 'isMatch()', () => { it( 'matches styles with a default or a viewport rotation', () => { expect( rotate.isMatch( { style: { rotate: 15 } } ) ).toBe( true ); diff --git a/packages/block-editor/src/hooks/test/style.jsdom.test.tsx b/packages/block-editor/src/hooks/test/style.jsdom.test.tsx index b6edacde08044d..4821e0081c7b56 100644 --- a/packages/block-editor/src/hooks/test/style.jsdom.test.tsx +++ b/packages/block-editor/src/hooks/test/style.jsdom.test.tsx @@ -1059,3 +1059,24 @@ describe( 'omitStyle', () => { expect( omitStyle( style, [ [] ] ) ).toEqual( style ); } ); } ); + +describe( 'addAttribute', () => { + const addAttribute = ( supports ) => + applyFilters( + 'blocks.registerBlockType', + { attributes: {}, supports }, + 'core/foo' + ).attributes; + + it( 'adds the style attribute for rotation alone', () => { + expect( addAttribute( { customCSS: false } ) ).toEqual( { + style: { type: 'object' }, + } ); + } ); + + it( "doesn't add it to a block without any style support", () => { + expect( addAttribute( { customCSS: false, rotate: false } ) ).toEqual( + {} + ); + } ); +} ); From f40c744b4e4ba2ddd3ea8f9f2122c5c6f3c08b1c Mon Sep 17 00:00:00 2001 From: Andrei Draganescu Date: Wed, 21 Oct 2026 15:01:38 +0300 Subject: [PATCH 07/20] Block Supports: Convert the rotate tests to TypeScript 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 --- ...ate.jsdom.test.js => rotate.jsdom.test.ts} | 83 ++++++++++++++----- .../utils/test/{rotation.js => rotation.ts} | 0 2 files changed, 60 insertions(+), 23 deletions(-) rename packages/block-editor/src/hooks/test/{rotate.jsdom.test.js => rotate.jsdom.test.ts} (82%) rename packages/block-editor/src/utils/test/{rotation.js => rotation.ts} (100%) diff --git a/packages/block-editor/src/hooks/test/rotate.jsdom.test.js b/packages/block-editor/src/hooks/test/rotate.jsdom.test.ts similarity index 82% rename from packages/block-editor/src/hooks/test/rotate.jsdom.test.js rename to packages/block-editor/src/hooks/test/rotate.jsdom.test.ts index b399d8032d4c09..fc9e2e830d9d90 100644 --- a/packages/block-editor/src/hooks/test/rotate.jsdom.test.js +++ b/packages/block-editor/src/hooks/test/rotate.jsdom.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it } from 'vitest'; +import type { BlockType } from '@wordpress/blocks'; import rotate, { getRotateCSS, getRotateForState, @@ -12,53 +13,85 @@ const DEFAULT_STATE = { viewport: 'default', pseudo: 'default' }; const MOBILE_STATE = { viewport: '@mobile', pseudo: 'default' }; const TABLET_STATE = { viewport: '@tablet', pseudo: 'default' }; +const testWindow = window as Window & { + __experimentalEnableBlockRotation?: boolean; +}; + +/** + * Returns a block type with only the settings a test needs. Rotation support + * only reads `supports` and `attributes`. + * + * @param settings Block type settings. + * + * @return The block type. + */ +function createBlockType( settings: Partial< BlockType > ): BlockType { + return settings as BlockType; +} + describe( 'rotate block support', () => { afterEach( () => { - delete window.__experimentalEnableBlockRotation; + delete testWindow.__experimentalEnableBlockRotation; } ); describe( 'hasRotateSupport()', () => { it( 'is on for blocks that do not opt out', () => { - expect( hasRotateSupport( { supports: {} } ) ).toBe( true ); - expect( hasRotateSupport( { supports: { rotate: true } } ) ).toBe( - true - ); + expect( + hasRotateSupport( createBlockType( { supports: {} } ) ) + ).toBe( true ); + expect( + hasRotateSupport( + createBlockType( { supports: { rotate: true } } ) + ) + ).toBe( true ); } ); it( 'is off for blocks that opt out', () => { - expect( hasRotateSupport( { supports: { rotate: false } } ) ).toBe( - false - ); + expect( + hasRotateSupport( + createBlockType( { supports: { rotate: false } } ) + ) + ).toBe( false ); } ); it( 'is off for blocks whose own style attribute is not an object', () => { expect( - hasRotateSupport( { - supports: {}, - attributes: { style: { type: 'string' } }, - } ) + hasRotateSupport( + createBlockType( { + supports: {}, + attributes: { style: { type: 'string' } }, + } ) + ) ).toBe( false ); expect( - hasRotateSupport( { - supports: {}, - attributes: { style: { type: 'object' } }, - } ) + hasRotateSupport( + createBlockType( { + supports: {}, + attributes: { style: { type: 'object' } }, + } ) + ) ).toBe( true ); } ); } ); describe( 'isRotateEnabled()', () => { it( 'needs the block rotation experiment', () => { - expect( isRotateEnabled( { supports: {} } ) ).toBe( false ); - window.__experimentalEnableBlockRotation = true; - expect( isRotateEnabled( { supports: {} } ) ).toBe( true ); + expect( + isRotateEnabled( createBlockType( { supports: {} } ) ) + ).toBe( false ); + testWindow.__experimentalEnableBlockRotation = true; + expect( + isRotateEnabled( createBlockType( { supports: {} } ) ) + ).toBe( true ); } ); it( 'needs the block to support rotation', () => { - window.__experimentalEnableBlockRotation = true; - expect( isRotateEnabled( { supports: { rotate: false } } ) ).toBe( - false - ); + testWindow.__experimentalEnableBlockRotation = true; + expect( + isRotateEnabled( + createBlockType( { supports: { rotate: false } } ) + ) + ).toBe( false ); } ); } ); @@ -282,20 +315,24 @@ describe( 'rotate block support', () => { describe( 'style attributes that are not objects', () => { it( 'are read as no rotation', () => { + // @ts-expect-error The style attribute is deliberately not an object. expect( getRotateForState( 'rotate: 15deg', DEFAULT_STATE ) ).toBe( 0 ); + // @ts-expect-error The style attribute is deliberately not an object. expect( getRotateCSS( 'rotate: 15deg', '.wp-rotate-1' ) ).toBe( '' ); expect( getRotateCSS( { '@mobile': 'rotate' }, '.wp-rotate-1' ) ).toBe( '' ); + // @ts-expect-error The style attribute is deliberately not an object. expect( rotate.isMatch( { style: 'rotate' } ) ).toBe( false ); } ); it( 'are replaced when a rotation is set', () => { expect( + // @ts-expect-error The style attribute is deliberately not an object. getUpdatedRotateStyle( 'rotate: 15deg', 15, DEFAULT_STATE ) ).toEqual( { rotate: 15 } ); expect( diff --git a/packages/block-editor/src/utils/test/rotation.js b/packages/block-editor/src/utils/test/rotation.ts similarity index 100% rename from packages/block-editor/src/utils/test/rotation.js rename to packages/block-editor/src/utils/test/rotation.ts From 5105ad2984ef1d9ff774b5f0b7fcc69b0388a2dd Mon Sep 17 00:00:00 2001 From: Andrei Draganescu Date: Wed, 21 Oct 2026 23:39:22 +0300 Subject: [PATCH 08/20] Block Supports: Only apply rotation with the block rotation experiment 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 --- lib/experimental/experiments/load.php | 2 +- lib/load.php | 5 +++++ packages/block-editor/src/hooks/rotate.tsx | 20 ++++++++----------- .../hooks/test/rotate-control.jsdom.test.tsx | 14 ------------- .../src/hooks/test/rotate.jsdom.test.ts | 9 +++++++++ phpunit/bootstrap.php | 1 + 6 files changed, 24 insertions(+), 27 deletions(-) diff --git a/lib/experimental/experiments/load.php b/lib/experimental/experiments/load.php index 5834c20eb03516..233b12cc21c373 100644 --- a/lib/experimental/experiments/load.php +++ b/lib/experimental/experiments/load.php @@ -32,7 +32,7 @@ function gutenberg_initialize_experiments_settings() { array( 'id' => 'gutenberg-block-rotation', 'label' => __( 'Block rotation', 'gutenberg' ), - 'description' => __( 'Adds a Rotation control to the Dimensions panel of blocks. Blocks that are already rotated stay rotated when this is off.', 'gutenberg' ), + 'description' => __( 'Adds a Rotation control to the Dimensions panel of blocks. When this is off, blocks that were rotated keep their rotation but are shown unrotated.', 'gutenberg' ), ), ), ), diff --git a/lib/load.php b/lib/load.php index 8265e855f2eded..a7b9d39ae0a5ea 100644 --- a/lib/load.php +++ b/lib/load.php @@ -255,6 +255,11 @@ function gutenberg_get_experiment_default( $name ) { require __DIR__ . '/block-supports/rotate.php'; require __DIR__ . '/block-supports/states.php'; +// Stored rotations are kept but only applied while block rotation is enabled. +if ( ! gutenberg_is_experiment_enabled( 'gutenberg-block-rotation' ) ) { + remove_filter( 'render_block', 'gutenberg_render_rotate_support' ); +} + // Client-side media processing. require_once __DIR__ . '/media/load.php'; diff --git a/packages/block-editor/src/hooks/rotate.tsx b/packages/block-editor/src/hooks/rotate.tsx index f247d5266d9c18..9cf8a2ac15a228 100644 --- a/packages/block-editor/src/hooks/rotate.tsx +++ b/packages/block-editor/src/hooks/rotate.tsx @@ -94,14 +94,15 @@ function getStyleObject( style: unknown ): BlockStyle | undefined { } /** - * Returns whether rotation can be edited for a block: the block rotation + * Returns whether rotation is edited and shown for a block: the block rotation * experiment is on and the block supports rotation. * - * Rotation that is already stored is shown whether or not this is true. + * When this is false, a rotation that is already stored is kept but not shown, + * as on the front end. * * @param nameOrType Block name or block type object. * - * @return Whether rotation can be edited. + * @return Whether rotation is edited and shown. */ export function isRotateEnabled( nameOrType: string | BlockType ): boolean { const { __experimentalEnableBlockRotation: isExperimentEnabled } = @@ -371,13 +372,6 @@ function RotateControl( { clientId, setAttributes, style }: RotateEditProps ) { ); } -function RotateEdit( props: RotateEditProps ) { - if ( ! isRotateEnabled( props.name ) ) { - return null; - } - return ; -} - function useBlockProps( { clientId, style, @@ -401,9 +395,11 @@ function useBlockProps( { } export default { - edit: RotateEdit, + edit: RotateControl, useBlockProps, attributeKeys: [ 'style' ], - hasSupport: hasRotateSupport, + // Blocks without the experiment keep their stored rotation, which the + // style attribute registered for them holds, but it isn't applied. + hasSupport: isRotateEnabled, isMatch: ( { style }: { style?: BlockStyle } ) => hasRotateValue( style ), }; diff --git a/packages/block-editor/src/hooks/test/rotate-control.jsdom.test.tsx b/packages/block-editor/src/hooks/test/rotate-control.jsdom.test.tsx index e7c72616a9f565..4d7f14b74d8318 100644 --- a/packages/block-editor/src/hooks/test/rotate-control.jsdom.test.tsx +++ b/packages/block-editor/src/hooks/test/rotate-control.jsdom.test.tsx @@ -18,8 +18,6 @@ import rotate from '../rotate'; const BLOCK_NAME = 'test/rotate-control'; -type TestWindow = Window & { __experimentalEnableBlockRotation?: boolean }; - type BlockStyle = Record< string, unknown >; type BlockAttributes = { style?: BlockStyle } | null; @@ -94,7 +92,6 @@ describe( 'Rotation control', () => { afterEach( () => { unregisterBlockType( BLOCK_NAME ); - delete ( window as TestWindow ).__experimentalEnableBlockRotation; act( () => { unlock( dispatch( blockEditorStore ) ).setStyleStateViewport( 'default' @@ -102,16 +99,7 @@ describe( 'Rotation control', () => { } ); } ); - it( 'is not shown without the block rotation experiment', () => { - showRotateControl( { rotate: 15 } ); - - expect( - screen.queryByRole( 'spinbutton', { name: 'Rotation' } ) - ).not.toBeInTheDocument(); - } ); - it( 'shows and edits the default rotation', () => { - ( window as TestWindow ).__experimentalEnableBlockRotation = true; const clientId = showRotateControl( { rotate: 15 } ); const input = screen.getByRole( 'spinbutton', { name: 'Rotation' } ); @@ -125,7 +113,6 @@ describe( 'Rotation control', () => { } ); it( 'edits the rotation of the selected viewport', () => { - ( window as TestWindow ).__experimentalEnableBlockRotation = true; act( () => { unlock( dispatch( blockEditorStore ) ).setStyleStateViewport( '@mobile' @@ -147,7 +134,6 @@ describe( 'Rotation control', () => { } ); it( 'resets only the rotation of the selected viewport', () => { - ( window as TestWindow ).__experimentalEnableBlockRotation = true; act( () => { unlock( dispatch( blockEditorStore ) ).setStyleStateViewport( '@mobile' diff --git a/packages/block-editor/src/hooks/test/rotate.jsdom.test.ts b/packages/block-editor/src/hooks/test/rotate.jsdom.test.ts index fc9e2e830d9d90..704d485021326e 100644 --- a/packages/block-editor/src/hooks/test/rotate.jsdom.test.ts +++ b/packages/block-editor/src/hooks/test/rotate.jsdom.test.ts @@ -345,6 +345,15 @@ describe( 'rotate block support', () => { } ); } ); + describe( 'hasSupport()', () => { + it( 'needs the block rotation experiment, so a stored rotation is only applied with it', () => { + const blockType = createBlockType( { supports: {} } ); + expect( rotate.hasSupport( blockType ) ).toBe( false ); + testWindow.__experimentalEnableBlockRotation = true; + expect( rotate.hasSupport( blockType ) ).toBe( true ); + } ); + } ); + describe( 'isMatch()', () => { it( 'matches styles with a default or a viewport rotation', () => { expect( rotate.isMatch( { style: { rotate: 15 } } ) ).toBe( true ); diff --git a/phpunit/bootstrap.php b/phpunit/bootstrap.php index 9f7c1f373954ca..38403e2d714da5 100644 --- a/phpunit/bootstrap.php +++ b/phpunit/bootstrap.php @@ -93,6 +93,7 @@ function fail_if_died( $message ) { 'gutenberg-guidelines' => 1, 'gutenberg-dashboard-widgets' => 1, 'gutenberg-real-time-collaboration' => 1, + 'gutenberg-block-rotation' => 1, ), ); From 273447a496a1e4e04cfc450d3538a9f737060de4 Mon Sep 17 00:00:00 2001 From: Andrei Draganescu Date: Thu, 22 Oct 2026 10:11:29 +0300 Subject: [PATCH 09/20] Add the backport changelog entry for the rotate block support Co-Authored-By: Claude Opus 5.5 --- backport-changelog/7.2/14707.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 backport-changelog/7.2/14707.md diff --git a/backport-changelog/7.2/14707.md b/backport-changelog/7.2/14707.md new file mode 100644 index 00000000000000..6595e40d5c1056 --- /dev/null +++ b/backport-changelog/7.2/14707.md @@ -0,0 +1,3 @@ +https://github.com/WordPress/wordpress-develop/pull/14707 + +* https://github.com/WordPress/gutenberg/pull/84505 From a6447e1f3cca94c00028c61a5f92b75dbd949fdf Mon Sep 17 00:00:00 2001 From: Andrei Draganescu Date: Fri, 23 Oct 2026 18:49:13 +0300 Subject: [PATCH 10/20] Rotate: Avoid duplicate core rules and keep picker decimals 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 --- lib/block-supports/rotate.php | 4 ++++ packages/block-editor/src/hooks/rotate.tsx | 11 ++------- packages/block-editor/src/utils/rotation.ts | 23 +++++++++++++++++-- .../block-editor/src/utils/test/rotation.ts | 23 ++++++++++++++++--- 4 files changed, 47 insertions(+), 14 deletions(-) diff --git a/lib/block-supports/rotate.php b/lib/block-supports/rotate.php index a36dfbc0c4854c..75d9d9aeaec589 100644 --- a/lib/block-supports/rotate.php +++ b/lib/block-supports/rotate.php @@ -184,4 +184,8 @@ function gutenberg_render_rotate_support( $block_content, $block ) { ) ); +// 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 ); diff --git a/packages/block-editor/src/hooks/rotate.tsx b/packages/block-editor/src/hooks/rotate.tsx index 9cf8a2ac15a228..b666c91a2056db 100644 --- a/packages/block-editor/src/hooks/rotate.tsx +++ b/packages/block-editor/src/hooks/rotate.tsx @@ -17,6 +17,7 @@ import { isPlainObject } from '../utils/object'; import { fromPickerAngle, normalizeAngle, + roundAngle, toPickerAngle, } from '../utils/rotation'; import { cleanEmptyObject, usePrivateStyleOverride } from './utils'; @@ -127,15 +128,7 @@ export function getRotateValue( value: unknown ): number | undefined { if ( typeof angle !== 'number' || ! Number.isFinite( angle ) ) { return undefined; } - // Round halves away from zero, after removing floating point noise, the - // way PHP's `round()` does: 1.005 is 1.01 and -12.345 is -12.35. - const rounded = - ( Math.sign( angle ) * - Math.round( - Number( ( Math.abs( angle ) * 100 ).toPrecision( 15 ) ) - ) ) / - 100; - return normalizeAngle( rounded ); + return normalizeAngle( roundAngle( angle ) ); } /** diff --git a/packages/block-editor/src/utils/rotation.ts b/packages/block-editor/src/utils/rotation.ts index 15df99bfa61ffd..4a45dbe89d8038 100644 --- a/packages/block-editor/src/utils/rotation.ts +++ b/packages/block-editor/src/utils/rotation.ts @@ -18,6 +18,25 @@ export function normalizeAngle( angle: number ): number { return normalized || 0; } +/** + * Rounds an angle to two decimals the way PHP's `round()` does: halves are + * rounded away from zero after removing floating point noise, so 1.005 is + * 1.01 and -12.345 is -12.35. + * + * @param angle Angle in degrees. + * + * @return The angle rounded to two decimals. + */ +export function roundAngle( angle: number ): number { + return ( + ( Math.sign( angle ) * + Math.round( + Number( ( Math.abs( angle ) * 100 ).toPrecision( 15 ) ) + ) ) / + 100 + ); +} + /** * Converts a stored rotation to the [0, 360) range used by `AnglePickerControl`. * @@ -34,9 +53,9 @@ export function toPickerAngle( rotate: number | undefined ): number { * * @param value Angle in degrees, in [0, 360]. * - * @return The rotation in (-180, 180]. + * @return The rotation in (-180, 180], rounded to two decimals. */ export function fromPickerAngle( value: number | string | undefined ): number { const angle = Number( value ); - return Number.isFinite( angle ) ? normalizeAngle( Math.round( angle ) ) : 0; + return Number.isFinite( angle ) ? normalizeAngle( roundAngle( angle ) ) : 0; } diff --git a/packages/block-editor/src/utils/test/rotation.ts b/packages/block-editor/src/utils/test/rotation.ts index 6342e9b482779d..e0c87a5e311946 100644 --- a/packages/block-editor/src/utils/test/rotation.ts +++ b/packages/block-editor/src/utils/test/rotation.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest'; -import { fromPickerAngle, normalizeAngle, toPickerAngle } from '../rotation'; +import { + fromPickerAngle, + normalizeAngle, + roundAngle, + toPickerAngle, +} from '../rotation'; describe( 'normalizeAngle', () => { it( 'keeps angles that are already in range', () => { @@ -23,6 +28,15 @@ describe( 'normalizeAngle', () => { } ); } ); +describe( 'roundAngle', () => { + it( 'rounds to two decimals, halves away from zero', () => { + expect( roundAngle( 12.344 ) ).toBe( 12.34 ); + expect( roundAngle( 12.345 ) ).toBe( 12.35 ); + expect( roundAngle( -12.345 ) ).toBe( -12.35 ); + expect( roundAngle( 1.005 ) ).toBe( 1.01 ); + } ); +} ); + describe( 'toPickerAngle', () => { it( 'converts a stored rotation to the picker range', () => { expect( toPickerAngle( undefined ) ).toBe( 0 ); @@ -42,9 +56,12 @@ describe( 'fromPickerAngle', () => { expect( fromPickerAngle( 360 ) ).toBe( 0 ); } ); - it( 'accepts strings and rounds to whole degrees', () => { + it( 'accepts strings and rounds to two decimals', () => { expect( fromPickerAngle( '45' ) ).toBe( 45 ); - expect( fromPickerAngle( 12.6 ) ).toBe( 13 ); + expect( fromPickerAngle( '12.5' ) ).toBe( 12.5 ); + expect( fromPickerAngle( 12.6 ) ).toBe( 12.6 ); + expect( fromPickerAngle( 12.345 ) ).toBe( 12.35 ); + expect( fromPickerAngle( 359.999 ) ).toBe( 0 ); } ); it( 'treats values that are not numbers as no rotation', () => { From 3ed18c4cef5dee7d677f8250c724e3b918636b02 Mon Sep 17 00:00:00 2001 From: Andrei Draganescu Date: Fri, 23 Oct 2026 20:58:31 +0300 Subject: [PATCH 11/20] Block Editor: Move rotation style helpers into utils/rotation `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 --- packages/block-editor/src/hooks/rotate.tsx | 157 +----------------- .../src/hooks/test/rotate.jsdom.test.ts | 8 +- packages/block-editor/src/utils/rotation.ts | 157 ++++++++++++++++++ .../{rotation.ts => rotation.jsdom.test.ts} | 0 4 files changed, 167 insertions(+), 155 deletions(-) rename packages/block-editor/src/utils/test/{rotation.ts => rotation.jsdom.test.ts} (100%) diff --git a/packages/block-editor/src/hooks/rotate.tsx b/packages/block-editor/src/hooks/rotate.tsx index b666c91a2056db..a973ffc9792d2e 100644 --- a/packages/block-editor/src/hooks/rotate.tsx +++ b/packages/block-editor/src/hooks/rotate.tsx @@ -16,18 +16,17 @@ import { unlock } from '../lock-unlock'; import { isPlainObject } from '../utils/object'; import { fromPickerAngle, - normalizeAngle, - roundAngle, + getRotateForState, + getRotateStateStyle, + getRotateValue, + getStyleObject, + getUpdatedRotateStyle, toPickerAngle, } from '../utils/rotation'; import { cleanEmptyObject, usePrivateStyleOverride } from './utils'; import { BlockStyleStateProvider, - DEFAULT_BLOCK_STYLE_STATE, - getStyleForState, hasPseudoBlockStyleState, - hasViewportBlockStyleState, - setStyleForState, } from './block-style-state'; const { getResponsiveMediaQueries } = unlock( globalStylesEnginePrivateApis ); @@ -36,11 +35,6 @@ export const ROTATE_SUPPORT_KEY = 'rotate'; type BlockStyle = Record< string, any >; -interface BlockStyleState { - viewport?: string; - pseudo?: string; -} - interface BlockAttributes { style?: BlockStyle; [ key: string ]: unknown; @@ -56,11 +50,6 @@ interface RotateEditProps { // Used for generating the instance ID. const ROTATE_BLOCK_PROPS_REFERENCE = {}; -// A number in decimal notation, as PHP's `is_numeric()` accepts it. Hex, -// binary and octal strings such as `0x10`, which `Number()` would read, are -// not rotations. -const DECIMAL_NUMBER_PATTERN = /^\s*[+-]?(\d+(\.\d*)?|\.\d+)(e[+-]?\d+)?\s*$/i; - /** * Returns whether a block supports rotation. Blocks do unless they opt out * with `supports.rotate: false`, or define a `style` attribute of their own @@ -82,18 +71,6 @@ export function hasRotateSupport( nameOrType: string | BlockType ): boolean { ); } -/** - * Returns a block style attribute when it is an object, which is the only - * kind of style attribute rotation is read from or written to. - * - * @param style Block style attribute. - * - * @return The style attribute, or undefined when it is not an object. - */ -function getStyleObject( style: unknown ): BlockStyle | undefined { - return isPlainObject( style ) ? style : undefined; -} - /** * Returns whether rotation is edited and shown for a block: the block rotation * experiment is on and the block supports rotation. @@ -111,130 +88,6 @@ export function isRotateEnabled( nameOrType: string | BlockType ): boolean { return !! isExperimentEnabled && hasRotateSupport( nameOrType ); } -/** - * Reads a stored rotation, which is a number of degrees. Anything that is not - * a finite number, or a string holding one in decimal notation, is ignored. - * Matches `gutenberg_get_rotate_value()` in PHP. - * - * @param value Stored rotation. - * - * @return The rotation in (-180, 180], rounded to two decimals, or undefined. - */ -export function getRotateValue( value: unknown ): number | undefined { - const angle = - typeof value === 'string' && DECIMAL_NUMBER_PATTERN.test( value ) - ? Number( value ) - : value; - if ( typeof angle !== 'number' || ! Number.isFinite( angle ) ) { - return undefined; - } - return normalizeAngle( roundAngle( angle ) ); -} - -/** - * Returns the state that holds rotation for a selected style state. Rotation - * is stored per viewport only, so a pseudo state such as `:hover` is ignored. - * - * @param selectedState Selected block style state. - * - * @return The style state rotation is stored in. - */ -function getRotateState( selectedState: BlockStyleState ): BlockStyleState { - return { - viewport: hasViewportBlockStyleState( selectedState ) - ? selectedState.viewport - : DEFAULT_BLOCK_STYLE_STATE.viewport, - pseudo: DEFAULT_BLOCK_STYLE_STATE.pseudo, - }; -} - -/** - * Returns the styles of a block for the state rotation is stored in. - * - * @param style Block style attribute. - * @param selectedState Selected block style state. - * - * @return The styles of that state, if any. - */ -function getRotateStateStyle( - style: BlockStyle | undefined, - selectedState: BlockStyleState -): BlockStyle | undefined { - return getStyleObject( - getStyleForState( - getStyleObject( style ) ?? {}, - getRotateState( selectedState ) - ) - ); -} - -/** - * Returns the rotation a block has in a style state: the viewport's own - * rotation when it sets one, otherwise the default rotation. - * - * @param style Block style attribute. - * @param selectedState Selected block style state. - * - * @return The rotation in degrees, in (-180, 180]. 0 when not rotated. - */ -export function getRotateForState( - style: BlockStyle | undefined, - selectedState: BlockStyleState -): number { - const defaultRotate = - getRotateValue( getStyleObject( style )?.rotate ) ?? 0; - if ( ! hasViewportBlockStyleState( selectedState ) ) { - return defaultRotate; - } - return ( - getRotateValue( getRotateStateStyle( style, selectedState )?.rotate ) ?? - defaultRotate - ); -} - -/** - * Returns a block style attribute with the rotation of a style state set. - * - * In the default state, no rotation is stored as no value. In a viewport - * state, 0 is kept, because it undoes the default rotation on that viewport. - * An undefined angle removes the state's rotation. - * - * @param style Block style attribute. - * @param angle Rotation in degrees. - * @param selectedState Selected block style state. - * - * @return The updated block style attribute. - * - * @example - * ```js - * setAttributes( { - * style: getUpdatedRotateStyle( attributes.style, 30, { viewport: '@mobile' } ), - * } ); - * ``` - */ -export function getUpdatedRotateStyle( - style: BlockStyle | undefined, - angle: number | undefined, - selectedState: BlockStyleState -): BlockStyle | undefined { - const baseStyle = getStyleObject( style ); - const rotateState = getRotateState( selectedState ); - const isViewportState = hasViewportBlockStyleState( rotateState ); - let rotate = getRotateValue( angle ); - if ( rotate === 0 && ! isViewportState ) { - rotate = undefined; - } - - if ( ! isViewportState ) { - return cleanEmptyObject( { ...baseStyle, rotate } ); - } - - return setStyleForState( baseStyle ?? {}, rotateState, { - ...getRotateStateStyle( baseStyle, rotateState ), - rotate, - } ); -} - /** * Returns whether a block style attribute holds any rotation. * diff --git a/packages/block-editor/src/hooks/test/rotate.jsdom.test.ts b/packages/block-editor/src/hooks/test/rotate.jsdom.test.ts index 704d485021326e..ffeb25d0d0ea5e 100644 --- a/packages/block-editor/src/hooks/test/rotate.jsdom.test.ts +++ b/packages/block-editor/src/hooks/test/rotate.jsdom.test.ts @@ -2,12 +2,14 @@ import { afterEach, describe, expect, it } from 'vitest'; import type { BlockType } from '@wordpress/blocks'; import rotate, { getRotateCSS, - getRotateForState, - getRotateValue, - getUpdatedRotateStyle, hasRotateSupport, isRotateEnabled, } from '../rotate'; +import { + getRotateForState, + getRotateValue, + getUpdatedRotateStyle, +} from '../../utils/rotation'; const DEFAULT_STATE = { viewport: 'default', pseudo: 'default' }; const MOBILE_STATE = { viewport: '@mobile', pseudo: 'default' }; diff --git a/packages/block-editor/src/utils/rotation.ts b/packages/block-editor/src/utils/rotation.ts index 4a45dbe89d8038..c91f9a3d241940 100644 --- a/packages/block-editor/src/utils/rotation.ts +++ b/packages/block-editor/src/utils/rotation.ts @@ -1,3 +1,19 @@ +import { isPlainObject } from './object'; +import { cleanEmptyObject } from '../hooks/utils'; +import { + DEFAULT_BLOCK_STYLE_STATE, + getStyleForState, + hasViewportBlockStyleState, + setStyleForState, +} from '../hooks/block-style-state'; + +type BlockStyle = Record< string, any >; + +interface BlockStyleState { + viewport?: string; + pseudo?: string; +} + /** * Wraps an angle into the (-180, 180] range used to store rotation. * @@ -59,3 +75,144 @@ export function fromPickerAngle( value: number | string | undefined ): number { const angle = Number( value ); return Number.isFinite( angle ) ? normalizeAngle( roundAngle( angle ) ) : 0; } + +// A number in decimal notation, as PHP's `is_numeric()` accepts it. Hex, +// binary and octal strings such as `0x10`, which `Number()` would read, are +// not rotations. +const DECIMAL_NUMBER_PATTERN = /^\s*[+-]?(\d+(\.\d*)?|\.\d+)(e[+-]?\d+)?\s*$/i; + +/** + * Returns a block style attribute when it is an object, which is the only + * kind of style attribute rotation is read from or written to. + * + * @param style Block style attribute. + * + * @return The style attribute, or undefined when it is not an object. + */ +export function getStyleObject( style: unknown ): BlockStyle | undefined { + return isPlainObject( style ) ? style : undefined; +} + +/** + * Reads a stored rotation, which is a number of degrees. Anything that is not + * a finite number, or a string holding one in decimal notation, is ignored. + * Matches `gutenberg_get_rotate_value()` in PHP. + * + * @param value Stored rotation. + * + * @return The rotation in (-180, 180], rounded to two decimals, or undefined. + */ +export function getRotateValue( value: unknown ): number | undefined { + const angle = + typeof value === 'string' && DECIMAL_NUMBER_PATTERN.test( value ) + ? Number( value ) + : value; + if ( typeof angle !== 'number' || ! Number.isFinite( angle ) ) { + return undefined; + } + return normalizeAngle( roundAngle( angle ) ); +} + +/** + * Returns the state that holds rotation for a selected style state. Rotation + * is stored per viewport only, so a pseudo state such as `:hover` is ignored. + * + * @param selectedState Selected block style state. + * + * @return The style state rotation is stored in. + */ +function getRotateState( selectedState: BlockStyleState ): BlockStyleState { + return { + viewport: hasViewportBlockStyleState( selectedState ) + ? selectedState.viewport + : DEFAULT_BLOCK_STYLE_STATE.viewport, + pseudo: DEFAULT_BLOCK_STYLE_STATE.pseudo, + }; +} + +/** + * Returns the styles of a block for the state rotation is stored in. + * + * @param style Block style attribute. + * @param selectedState Selected block style state. + * + * @return The styles of that state, if any. + */ +export function getRotateStateStyle( + style: BlockStyle | undefined, + selectedState: BlockStyleState +): BlockStyle | undefined { + return getStyleObject( + getStyleForState( + getStyleObject( style ) ?? {}, + getRotateState( selectedState ) + ) + ); +} + +/** + * Returns the rotation a block has in a style state: the viewport's own + * rotation when it sets one, otherwise the default rotation. + * + * @param style Block style attribute. + * @param selectedState Selected block style state. + * + * @return The rotation in degrees, in (-180, 180]. 0 when not rotated. + */ +export function getRotateForState( + style: BlockStyle | undefined, + selectedState: BlockStyleState +): number { + const defaultRotate = + getRotateValue( getStyleObject( style )?.rotate ) ?? 0; + if ( ! hasViewportBlockStyleState( selectedState ) ) { + return defaultRotate; + } + return ( + getRotateValue( getRotateStateStyle( style, selectedState )?.rotate ) ?? + defaultRotate + ); +} + +/** + * Returns a block style attribute with the rotation of a style state set. + * + * In the default state, no rotation is stored as no value. In a viewport + * state, 0 is kept, because it undoes the default rotation on that viewport. + * An undefined angle removes the state's rotation. + * + * @param style Block style attribute. + * @param angle Rotation in degrees. + * @param selectedState Selected block style state. + * + * @return The updated block style attribute. + * + * @example + * ```js + * setAttributes( { + * style: getUpdatedRotateStyle( attributes.style, 30, { viewport: '@mobile' } ), + * } ); + * ``` + */ +export function getUpdatedRotateStyle( + style: BlockStyle | undefined, + angle: number | undefined, + selectedState: BlockStyleState +): BlockStyle | undefined { + const baseStyle = getStyleObject( style ); + const rotateState = getRotateState( selectedState ); + const isViewportState = hasViewportBlockStyleState( rotateState ); + let rotate = getRotateValue( angle ); + if ( rotate === 0 && ! isViewportState ) { + rotate = undefined; + } + + if ( ! isViewportState ) { + return cleanEmptyObject( { ...baseStyle, rotate } ); + } + + return setStyleForState( baseStyle ?? {}, rotateState, { + ...getRotateStateStyle( baseStyle, rotateState ), + rotate, + } ); +} diff --git a/packages/block-editor/src/utils/test/rotation.ts b/packages/block-editor/src/utils/test/rotation.jsdom.test.ts similarity index 100% rename from packages/block-editor/src/utils/test/rotation.ts rename to packages/block-editor/src/utils/test/rotation.jsdom.test.ts From 8664cf6bb2366e3ce54febb1290c11106bfb2817 Mon Sep 17 00:00:00 2001 From: Andrei Draganescu Date: Fri, 23 Oct 2026 21:04:21 +0300 Subject: [PATCH 12/20] Block Editor: Move cleanEmptyObject into utils/object 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 --- .../src/hooks/block-style-state.ts | 7 +++-- packages/block-editor/src/hooks/utils.tsx | 29 ++----------------- packages/block-editor/src/utils/object.ts | 28 ++++++++++++++++++ packages/block-editor/src/utils/rotation.ts | 3 +- .../{rotation.jsdom.test.ts => rotation.ts} | 0 5 files changed, 36 insertions(+), 31 deletions(-) rename packages/block-editor/src/utils/test/{rotation.jsdom.test.ts => rotation.ts} (100%) diff --git a/packages/block-editor/src/hooks/block-style-state.ts b/packages/block-editor/src/hooks/block-style-state.ts index db99ae89013180..8d6f3e2a46adad 100644 --- a/packages/block-editor/src/hooks/block-style-state.ts +++ b/packages/block-editor/src/hooks/block-style-state.ts @@ -1,6 +1,9 @@ import { createContext, useContext } from '@wordpress/element'; -import { cleanEmptyObject } from './utils'; -import { getValueFromObjectPath, setImmutably } from '../utils/object'; +import { + cleanEmptyObject, + getValueFromObjectPath, + setImmutably, +} from '../utils/object'; import type { BlockStyle, BlockStyleState, ResetAllFilter } from './types'; const DEFAULT_STATE_VALUE = 'default'; diff --git a/packages/block-editor/src/hooks/utils.tsx b/packages/block-editor/src/hooks/utils.tsx index 776fa8cefa0f53..a3bb87ee8aa106 100644 --- a/packages/block-editor/src/hooks/utils.tsx +++ b/packages/block-editor/src/hooks/utils.tsx @@ -16,8 +16,8 @@ import { import { useSettings } from '../components'; import { useSettingsForBlockElement } from '../components/global-styles/hooks'; import { + cleanEmptyObject, getValueFromObjectPath, - isPlainObject, setImmutably, } from '../utils/object'; import { store as blockEditorStore } from '../store'; @@ -32,35 +32,10 @@ import type { BlockListBlockHookProps, BlockSaveFeature, BlockWrapperProps, - CleanedObject, StyleOverrideOptions, } from './types'; -/** - * Removed falsy values from nested object. - * - * @param object - * @return Object cleaned from falsy values - */ -export const cleanEmptyObject = < T, >( - object: T -): CleanedObject< T > | undefined => { - if ( ! isPlainObject( object ) ) { - // Only plain objects are cleaned, so anything else keeps its type. - return object as CleanedObject< T >; - } - - const cleanedNestedObjects: [ string, unknown ][] = Object.entries( object ) - .map( ( [ key, value ] ): [ string, unknown ] => [ - key, - cleanEmptyObject( value ), - ] ) - .filter( ( [ , value ] ) => value !== undefined ); - // The cleaned object keeps the shape, with the empty values left out. - return ! cleanedNestedObjects.length - ? undefined - : ( Object.fromEntries( cleanedNestedObjects ) as CleanedObject< T > ); -}; +export { cleanEmptyObject }; export function transformStyles( activeSupports: Record< string, unknown >, diff --git a/packages/block-editor/src/utils/object.ts b/packages/block-editor/src/utils/object.ts index 98828834739ab6..67d60e38e8ce00 100644 --- a/packages/block-editor/src/utils/object.ts +++ b/packages/block-editor/src/utils/object.ts @@ -1,3 +1,5 @@ +import type { CleanedObject } from '../hooks/types'; + type AnyObject = Record< string, unknown >; type ObjectOrArray = AnyObject | unknown[]; type ObjectPathKey = number | string; @@ -83,3 +85,29 @@ export function isPlainObject( value: unknown ): value is AnyObject { typeof value === 'object' && value !== null && ! Array.isArray( value ) ); } + +/** + * Removed falsy values from nested object. + * + * @param object + * @return Object cleaned from falsy values + */ +export const cleanEmptyObject = < T >( + object: T +): CleanedObject< T > | undefined => { + if ( ! isPlainObject( object ) ) { + // Only plain objects are cleaned, so anything else keeps its type. + return object as CleanedObject< T >; + } + + const cleanedNestedObjects: [ string, unknown ][] = Object.entries( object ) + .map( ( [ key, value ] ): [ string, unknown ] => [ + key, + cleanEmptyObject( value ), + ] ) + .filter( ( [ , value ] ) => value !== undefined ); + // The cleaned object keeps the shape, with the empty values left out. + return ! cleanedNestedObjects.length + ? undefined + : ( Object.fromEntries( cleanedNestedObjects ) as CleanedObject< T > ); +}; diff --git a/packages/block-editor/src/utils/rotation.ts b/packages/block-editor/src/utils/rotation.ts index c91f9a3d241940..2827002932f529 100644 --- a/packages/block-editor/src/utils/rotation.ts +++ b/packages/block-editor/src/utils/rotation.ts @@ -1,5 +1,4 @@ -import { isPlainObject } from './object'; -import { cleanEmptyObject } from '../hooks/utils'; +import { cleanEmptyObject, isPlainObject } from './object'; import { DEFAULT_BLOCK_STYLE_STATE, getStyleForState, diff --git a/packages/block-editor/src/utils/test/rotation.jsdom.test.ts b/packages/block-editor/src/utils/test/rotation.ts similarity index 100% rename from packages/block-editor/src/utils/test/rotation.jsdom.test.ts rename to packages/block-editor/src/utils/test/rotation.ts From fda2306aed275c0dc98ab5358316e3160ad6683d Mon Sep 17 00:00:00 2001 From: tellthemachines Date: Tue, 27 Oct 2026 14:21:26 +1100 Subject: [PATCH 13/20] add import and fix type errors --- .../src/hooks/test/style.jsdom.test.tsx | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/block-editor/src/hooks/test/style.jsdom.test.tsx b/packages/block-editor/src/hooks/test/style.jsdom.test.tsx index 4821e0081c7b56..a8e579547f1a9b 100644 --- a/packages/block-editor/src/hooks/test/style.jsdom.test.tsx +++ b/packages/block-editor/src/hooks/test/style.jsdom.test.tsx @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { registerBlockType, unregisterBlockType } from '@wordpress/blocks'; import type { BlockType } from '@wordpress/blocks'; +import { applyFilters } from '@wordpress/hooks'; import _style, { getCanvasStateStyleValue, getBlockStateStylesCSS, @@ -1061,12 +1062,14 @@ describe( 'omitStyle', () => { } ); describe( 'addAttribute', () => { - const addAttribute = ( supports ) => - applyFilters( - 'blocks.registerBlockType', - { attributes: {}, supports }, - 'core/foo' - ).attributes; + const addAttribute = ( supports: Record< string, boolean > ) => + ( + applyFilters( + 'blocks.registerBlockType', + { attributes: {}, supports }, + 'core/foo' + ) as { attributes: Record< string, unknown > } + ).attributes; it( 'adds the style attribute for rotation alone', () => { expect( addAttribute( { customCSS: false } ) ).toEqual( { From 62055ed0c3dc15dcfa5188b52bf0faedaa1c4819 Mon Sep 17 00:00:00 2001 From: tellthemachines Date: Tue, 27 Oct 2026 17:01:46 +1100 Subject: [PATCH 14/20] make rotation opt-in, non experimental and under position --- docs/reference-guides/core-blocks/README.md | 22 +-- .../{rotate.php => rotation.php} | 10 +- lib/experimental/editor-settings.php | 3 - lib/experimental/experiments/load.php | 5 - lib/load.php | 7 +- .../position-controls-panel.tsx | 72 +++++++-- ...use-inspector-controls-tabs.jsdom.test.tsx | 18 +++ .../use-inspector-controls-tabs.ts | 2 + .../components/inspector-controls/groups.ts | 5 + packages/block-editor/src/hooks/index.ts | 2 +- .../src/hooks/{rotate.tsx => rotation.tsx} | 39 ++--- packages/block-editor/src/hooks/style.tsx | 2 +- ...st.tsx => rotation-control.jsdom.test.tsx} | 145 +++++++++++++++++- ...e.jsdom.test.ts => rotation.jsdom.test.ts} | 69 +++------ .../src/hooks/test/style.jsdom.test.tsx | 10 +- packages/block-library/src/block/README.md | 1 - packages/block-library/src/block/block.json | 1 - packages/block-library/src/freeform/README.md | 1 - .../block-library/src/freeform/block.json | 1 - packages/block-library/src/heading/README.md | 1 + packages/block-library/src/heading/block.json | 1 + packages/block-library/src/html/README.md | 1 - packages/block-library/src/html/block.json | 1 - packages/block-library/src/image/README.md | 1 + packages/block-library/src/image/block.json | 1 + packages/block-library/src/missing/README.md | 1 - packages/block-library/src/missing/block.json | 1 - packages/block-library/src/more/README.md | 1 - packages/block-library/src/more/block.json | 1 - packages/block-library/src/nextpage/README.md | 1 - .../block-library/src/nextpage/block.json | 1 - packages/block-library/src/pattern/README.md | 1 - packages/block-library/src/pattern/block.json | 1 - .../block-library/src/shortcode/README.md | 1 - .../block-library/src/shortcode/block.json | 1 - packages/block-library/src/video/README.md | 1 + packages/block-library/src/video/block.json | 1 + .../{rotate-test.php => rotation-test.php} | 24 +-- phpunit/bootstrap.php | 1 - 39 files changed, 297 insertions(+), 161 deletions(-) rename lib/block-supports/{rotate.php => rotation.php} (95%) rename packages/block-editor/src/hooks/{rotate.tsx => rotation.tsx} (83%) rename packages/block-editor/src/hooks/test/{rotate-control.jsdom.test.tsx => rotation-control.jsdom.test.tsx} (50%) rename packages/block-editor/src/hooks/test/{rotate.jsdom.test.ts => rotation.jsdom.test.ts} (86%) rename phpunit/block-supports/{rotate-test.php => rotation-test.php} (93%) diff --git a/docs/reference-guides/core-blocks/README.md b/docs/reference-guides/core-blocks/README.md index acf8de65f7fb6c..88e6bdd2fe3ea1 100644 --- a/docs/reference-guides/core-blocks/README.md +++ b/docs/reference-guides/core-blocks/README.md @@ -92,7 +92,7 @@ Reuse this design across your site. - **Name:** [core/block](https://developer.wordpress.org/block-editor/reference-guides/core-blocks/core-blocks-reusable/core-block-block/) - **Category:** [reusable](https://developer.wordpress.org/block-editor/reference-guides/core-blocks/core-blocks-reusable/) -- **Supports:** interactivity (clientNavigation), ~~customCSS~~, ~~customClassName~~, ~~html~~, ~~inserter~~, ~~renaming~~, ~~rotate~~, ~~visibility~~ +- **Supports:** interactivity (clientNavigation), ~~customCSS~~, ~~customClassName~~, ~~html~~, ~~inserter~~, ~~renaming~~, ~~visibility~~ - **Attributes:** content, ref ## Breadcrumbs @@ -347,7 +347,7 @@ Use the classic WordPress editor. - **Name:** [core/freeform](https://developer.wordpress.org/block-editor/reference-guides/core-blocks/core-blocks-text/core-block-freeform/) - **Category:** [text](https://developer.wordpress.org/block-editor/reference-guides/core-blocks/core-blocks-text/) -- **Supports:** ~~className~~, ~~customCSS~~, ~~customClassName~~, ~~lock~~, ~~renaming~~, ~~reusable~~, ~~rotate~~, ~~visibility~~ +- **Supports:** ~~className~~, ~~customCSS~~, ~~customClassName~~, ~~lock~~, ~~renaming~~, ~~reusable~~, ~~visibility~~ - **Attributes:** content ## Gallery @@ -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 @@ -394,7 +394,7 @@ Add custom HTML code and preview it as you edit. - **Name:** [core/html](https://developer.wordpress.org/block-editor/reference-guides/core-blocks/core-blocks-widgets/core-block-html/) - **Category:** [widgets](https://developer.wordpress.org/block-editor/reference-guides/core-blocks/core-blocks-widgets/) -- **Supports:** interactivity (clientNavigation), listView, ~~className~~, ~~customCSS~~, ~~customClassName~~, ~~html~~, ~~rotate~~, ~~visibility~~ +- **Supports:** interactivity (clientNavigation), listView, ~~className~~, ~~customCSS~~, ~~customClassName~~, ~~html~~, ~~visibility~~ - **Attributes:** content ## Icon @@ -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 @@ -487,7 +487,7 @@ Your site doesn’t include support for this block. - **Name:** [core/missing](https://developer.wordpress.org/block-editor/reference-guides/core-blocks/core-blocks-text/core-block-missing/) - **Category:** [text](https://developer.wordpress.org/block-editor/reference-guides/core-blocks/core-blocks-text/) -- **Supports:** interactivity (clientNavigation), ~~className~~, ~~customCSS~~, ~~customClassName~~, ~~html~~, ~~inserter~~, ~~lock~~, ~~renaming~~, ~~reusable~~, ~~rotate~~, ~~visibility~~ +- **Supports:** interactivity (clientNavigation), ~~className~~, ~~customCSS~~, ~~customClassName~~, ~~html~~, ~~inserter~~, ~~lock~~, ~~renaming~~, ~~reusable~~, ~~visibility~~ - **Attributes:** originalContent, originalName, originalUndelimitedContent ## More @@ -496,7 +496,7 @@ Content before this block is shown in the excerpt on blog and archive pages. Whe - **Name:** [core/more](https://developer.wordpress.org/block-editor/reference-guides/core-blocks/core-blocks-design/core-block-more/) - **Category:** [design](https://developer.wordpress.org/block-editor/reference-guides/core-blocks/core-blocks-design/) -- **Supports:** interactivity (clientNavigation), ~~className~~, ~~customCSS~~, ~~customClassName~~, ~~html~~, ~~multiple~~, ~~rotate~~, ~~visibility~~ +- **Supports:** interactivity (clientNavigation), ~~className~~, ~~customCSS~~, ~~customClassName~~, ~~html~~, ~~multiple~~, ~~visibility~~ - **Attributes:** customText, noTeaser ## Navigation @@ -546,7 +546,7 @@ Separate your content into a multi-page experience. - **Name:** [core/nextpage](https://developer.wordpress.org/block-editor/reference-guides/core-blocks/core-blocks-design/core-block-nextpage/) - **Category:** [design](https://developer.wordpress.org/block-editor/reference-guides/core-blocks/core-blocks-design/) - **Parent:** core/post-content -- **Supports:** interactivity (clientNavigation), ~~className~~, ~~customCSS~~, ~~customClassName~~, ~~html~~, ~~rotate~~, ~~visibility~~ +- **Supports:** interactivity (clientNavigation), ~~className~~, ~~customCSS~~, ~~customClassName~~, ~~html~~, ~~visibility~~ ## Page List @@ -583,7 +583,7 @@ Show a block pattern. - **Name:** [core/pattern](https://developer.wordpress.org/block-editor/reference-guides/core-blocks/core-blocks-theme/core-block-pattern/) - **Category:** [theme](https://developer.wordpress.org/block-editor/reference-guides/core-blocks/core-blocks-theme/) -- **Supports:** interactivity (clientNavigation), ~~html~~, ~~inserter~~, ~~renaming~~, ~~rotate~~, ~~visibility~~ +- **Supports:** interactivity (clientNavigation), ~~html~~, ~~inserter~~, ~~renaming~~, ~~visibility~~ - **Attributes:** slug ## Playlist @@ -895,7 +895,7 @@ Insert additional custom elements with a WordPress shortcode. - **Name:** [core/shortcode](https://developer.wordpress.org/block-editor/reference-guides/core-blocks/core-blocks-widgets/core-block-shortcode/) - **Category:** [widgets](https://developer.wordpress.org/block-editor/reference-guides/core-blocks/core-blocks-widgets/) -- **Supports:** ~~className~~, ~~customCSS~~, ~~customClassName~~, ~~html~~, ~~rotate~~, ~~visibility~~ +- **Supports:** ~~className~~, ~~customCSS~~, ~~customClassName~~, ~~html~~, ~~visibility~~ - **Attributes:** text ## Site Logo @@ -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 diff --git a/lib/block-supports/rotate.php b/lib/block-supports/rotation.php similarity index 95% rename from lib/block-supports/rotate.php rename to lib/block-supports/rotation.php index 75d9d9aeaec589..d755457a8c2afc 100644 --- a/lib/block-supports/rotate.php +++ b/lib/block-supports/rotation.php @@ -1,8 +1,8 @@ get_registered( $block['blockName'] ); - if ( ! $block_type || ! block_has_support( $block_type, 'rotate', true ) ) { + if ( ! $block_type || ! block_has_support( $block_type, 'rotation', false ) ) { return $block_content; } @@ -178,7 +178,7 @@ function gutenberg_render_rotate_support( $block_content, $block ) { // Register the block support. WP_Block_Supports::get_instance()->register( - 'rotate', + 'rotation', array( 'register_attribute' => 'gutenberg_register_rotate_support', ) diff --git a/lib/experimental/editor-settings.php b/lib/experimental/editor-settings.php index 3c0b0524df6eea..d6251e763946f0 100644 --- a/lib/experimental/editor-settings.php +++ b/lib/experimental/editor-settings.php @@ -14,9 +14,6 @@ function gutenberg_enable_experiments() { if ( gutenberg_is_experiment_enabled( 'gutenberg-grid-interactivity' ) ) { wp_add_inline_script( 'wp-block-editor', 'window.__experimentalEnableGridInteractivity = true', 'before' ); } - if ( gutenberg_is_experiment_enabled( 'gutenberg-block-rotation' ) ) { - wp_add_inline_script( 'wp-block-editor', 'window.__experimentalEnableBlockRotation = true', 'before' ); - } if ( gutenberg_is_experiment_enabled( 'gutenberg-dataviews-media-modal' ) ) { wp_add_inline_script( 'wp-block-editor', 'window.__experimentalDataViewsMediaModal = true', 'before' ); } diff --git a/lib/experimental/experiments/load.php b/lib/experimental/experiments/load.php index 233b12cc21c373..4ea594fe842241 100644 --- a/lib/experimental/experiments/load.php +++ b/lib/experimental/experiments/load.php @@ -29,11 +29,6 @@ function gutenberg_initialize_experiments_settings() { 'label' => __( 'Grid interactivity', 'gutenberg' ), 'description' => __( 'Enables enhancements to the Grid block that let you move and resize items in the editor canvas.', 'gutenberg' ), ), - array( - 'id' => 'gutenberg-block-rotation', - 'label' => __( 'Block rotation', 'gutenberg' ), - 'description' => __( 'Adds a Rotation control to the Dimensions panel of blocks. When this is off, blocks that were rotated keep their rotation but are shown unrotated.', 'gutenberg' ), - ), ), ), array( diff --git a/lib/load.php b/lib/load.php index a7b9d39ae0a5ea..c402407cb16f91 100644 --- a/lib/load.php +++ b/lib/load.php @@ -252,14 +252,9 @@ 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/rotate.php'; +require __DIR__ . '/block-supports/rotation.php'; require __DIR__ . '/block-supports/states.php'; -// Stored rotations are kept but only applied while block rotation is enabled. -if ( ! gutenberg_is_experiment_enabled( 'gutenberg-block-rotation' ) ) { - remove_filter( 'render_block', 'gutenberg_render_rotate_support' ); -} - // Client-side media processing. require_once __DIR__ . '/media/load.php'; diff --git a/packages/block-editor/src/components/inspector-controls-tabs/position-controls-panel.tsx b/packages/block-editor/src/components/inspector-controls-tabs/position-controls-panel.tsx index bee6e6f9133623..024b1acb0f03a7 100644 --- a/packages/block-editor/src/components/inspector-controls-tabs/position-controls-panel.tsx +++ b/packages/block-editor/src/components/inspector-controls-tabs/position-controls-panel.tsx @@ -7,6 +7,7 @@ import { useDispatch, useRegistry, useSelect } from '@wordpress/data'; import { __ } from '@wordpress/i18n'; import InspectorControlsGroups from '../inspector-controls/groups'; import { default as InspectorControls } from '../inspector-controls'; +import BlockSupportSlotContainer from '../inspector-controls/block-support-slot-container'; import { store as blockEditorStore } from '../../store'; import { useToolsPanelDropdownMenuProps } from '../global-styles/utils'; import { cleanEmptyObject } from '../../hooks/utils'; @@ -14,6 +15,7 @@ import { unlock } from '../../lock-unlock'; import type { TreeBlock } from '../../store/types'; import type { BlockStyleState } from '../../hooks/types'; import type { PositionStyleAttribute } from './types'; +import type { BlockSupportResetAllFilter } from '../inspector-controls/types'; const DEFAULT_STATE_VALUE = 'default'; @@ -54,7 +56,11 @@ const hasAnyPositionValue = ( style?: PositionStyleAttribute ): boolean => { ); }; -const PositionControlsPanel = () => { +const PositionControlsPanel = ( { + hasPositionFills, +}: { + hasPositionFills: boolean; +} ) => { const { selectedClientIds, selectedBlocks, hasPositionAttribute } = useSelect( ( select ) => { const { getBlocksByClientId, getSelectedBlockClientIds } = @@ -84,7 +90,7 @@ const PositionControlsPanel = () => { const { updateBlockAttributes } = useDispatch( blockEditorStore ); const dropdownMenuProps = useToolsPanelDropdownMenuProps(); - function resetPosition() { + function resetPosition( resetFilters: BlockSupportResetAllFilter[] = [] ) { if ( ! selectedClientIds?.length || ! selectedBlocks?.length ) { return; } @@ -94,12 +100,31 @@ const PositionControlsPanel = () => { ); const attributesByClientId = Object.fromEntries( selectedBlocks?.map( ( { clientId, attributes } ) => { + let updatedAttributes: Record< string, unknown > = { + style: attributes?.style, + }; + const resetContext = { + attributes, + clientId, + name: registry + .select( blockEditorStore ) + .getBlockName( clientId ), + }; + resetFilters.forEach( ( resetFilter ) => { + updatedAttributes = { + ...updatedAttributes, + ...resetFilter( updatedAttributes, resetContext ), + }; + } ); + if ( ! hasPositionFills ) { + return [ clientId, updatedAttributes ]; + } const viewport = getPositionStateViewport( getSelectedBlockStyleState( clientId ) ); const style: PositionStyleAttribute = { // The style attribute is an object. - ...( attributes?.style as + ...( updatedAttributes.style as PositionStyleAttribute | undefined ), }; @@ -130,7 +155,10 @@ const PositionControlsPanel = () => { }; } - return [ clientId, { style: cleanEmptyObject( style ) } ]; + return [ + clientId, + { ...updatedAttributes, style: cleanEmptyObject( style ) }, + ]; } ) ); @@ -142,29 +170,45 @@ const PositionControlsPanel = () => { className="block-editor-block-inspector__position" label={ __( 'Position' ) } resetAll={ resetPosition } + panelId={ + selectedClientIds.length === 1 ? selectedClientIds[ 0 ] : null + } dropdownMenuProps={ dropdownMenuProps } > - hasPositionAttribute } - onDeselect={ resetPosition } - > - - + { hasPositionFills && ( + hasPositionAttribute } + onDeselect={ () => resetPosition() } + panelId={ + selectedClientIds.length === 1 + ? selectedClientIds[ 0 ] + : undefined + } + > + + + ) } + ); }; const PositionControls = () => { const fills = useSlotFills( InspectorControlsGroups.position.name ); + const toolFills = useSlotFills( + InspectorControlsGroups[ 'position-tools' ].name + ); const hasFills = Boolean( fills && fills.length ); - if ( ! hasFills ) { + if ( ! hasFills && ! toolFills?.length ) { return null; } - return ; + return ; }; export default PositionControls; diff --git a/packages/block-editor/src/components/inspector-controls-tabs/test/use-inspector-controls-tabs.jsdom.test.tsx b/packages/block-editor/src/components/inspector-controls-tabs/test/use-inspector-controls-tabs.jsdom.test.tsx index 2daf9805201ff0..a8e8093f7b43be 100644 --- a/packages/block-editor/src/components/inspector-controls-tabs/test/use-inspector-controls-tabs.jsdom.test.tsx +++ b/packages/block-editor/src/components/inspector-controls-tabs/test/use-inspector-controls-tabs.jsdom.test.tsx @@ -19,6 +19,24 @@ vi.mock( import( '@wordpress/data' ), async ( importOriginal ) => { } ); describe( 'useInspectorControlsTabs', () => { + it( 'includes Styles when Rotation is the only style control', () => { + const PositionToolsFill = groups[ 'position-tools' ].Fill; + const { result } = renderHook( + () => + useInspectorControlsTabs( 'test/rotatable', [], false, false ), + { + wrapper: ( { children }: { children: ReactNode } ) => ( + + Rotation + { children } + + ), + } + ); + + expect( result.current ).toEqual( [ TAB_STYLES ] ); + } ); + it( 'includes Settings when Allowed blocks is the only settings control', () => { const { result } = renderHook( () => diff --git a/packages/block-editor/src/components/inspector-controls-tabs/use-inspector-controls-tabs.ts b/packages/block-editor/src/components/inspector-controls-tabs/use-inspector-controls-tabs.ts index 32cc0f33769efd..63621edcff54e9 100644 --- a/packages/block-editor/src/components/inspector-controls-tabs/use-inspector-controls-tabs.ts +++ b/packages/block-editor/src/components/inspector-controls-tabs/use-inspector-controls-tabs.ts @@ -45,6 +45,7 @@ export default function useInspectorControlsTabs( dimensions: dimensionsGroup, list: listGroup, position: positionGroup, + 'position-tools': positionToolsGroup, styles: stylesGroup, typography: typographyGroup, effects: effectsGroup, @@ -65,6 +66,7 @@ export default function useInspectorControlsTabs( ...( useSlotFills( colorGroup.name ) || [] ), ...( useSlotFills( layoutGroup.name ) || [] ), ...( useSlotFills( positionGroup.name ) || [] ), + ...( useSlotFills( positionToolsGroup.name ) || [] ), ...( useSlotFills( dimensionsGroup.name ) || [] ), ...( useSlotFills( stylesGroup.name ) || [] ), ...( useSlotFills( typographyGroup.name ) || [] ), diff --git a/packages/block-editor/src/components/inspector-controls/groups.ts b/packages/block-editor/src/components/inspector-controls/groups.ts index 7236cf5776439a..bdd17303d8982d 100644 --- a/packages/block-editor/src/components/inspector-controls/groups.ts +++ b/packages/block-editor/src/components/inspector-controls/groups.ts @@ -16,6 +16,10 @@ const InspectorControlsDimensions = createSlotFill( const InspectorControlsLayout = createSlotFill( 'InspectorControlsLayout' ); const InspectorControlsElements = createSlotFill( 'InspectorControlsElements' ); const InspectorControlsPosition = createSlotFill( 'InspectorControlsPosition' ); +// The position group contains the legacy selector; tools register separately. +const InspectorControlsPositionTools = createSlotFill( + 'InspectorControlsPositionTools' +); const InspectorControlsTypography = createSlotFill( 'InspectorControlsTypography' ); @@ -42,6 +46,7 @@ const groups: Record< string, SlotFill > = { layout: InspectorControlsLayout, list: InspectorControlsListView, position: InspectorControlsPosition, + 'position-tools': InspectorControlsPositionTools, settings: InspectorControlsDefault, // Alias for default. styles: InspectorControlsStyles, typography: InspectorControlsTypography, diff --git a/packages/block-editor/src/hooks/index.ts b/packages/block-editor/src/hooks/index.ts index adfd2d0b3de73c..0e817be9620603 100644 --- a/packages/block-editor/src/hooks/index.ts +++ b/packages/block-editor/src/hooks/index.ts @@ -24,7 +24,7 @@ import textShadow from './text-shadow'; import fitText from './fit-text'; import border from './border'; import customCSS from './custom-css'; -import rotate from './rotate'; +import rotate from './rotation'; import position from './position'; import blockStyleVariation from './block-style-variation'; import layout from './layout'; diff --git a/packages/block-editor/src/hooks/rotate.tsx b/packages/block-editor/src/hooks/rotation.tsx similarity index 83% rename from packages/block-editor/src/hooks/rotate.tsx rename to packages/block-editor/src/hooks/rotation.tsx index a973ffc9792d2e..465455f9dfcd2f 100644 --- a/packages/block-editor/src/hooks/rotate.tsx +++ b/packages/block-editor/src/hooks/rotation.tsx @@ -31,7 +31,9 @@ import { const { getResponsiveMediaQueries } = unlock( globalStylesEnginePrivateApis ); -export const ROTATE_SUPPORT_KEY = 'rotate'; +export const ROTATION_SUPPORT_KEY = 'rotation'; + +const ROTATE_STYLE_KEY = 'rotate'; type BlockStyle = Record< string, any >; @@ -51,9 +53,9 @@ interface RotateEditProps { const ROTATE_BLOCK_PROPS_REFERENCE = {}; /** - * Returns whether a block supports rotation. Blocks do unless they opt out - * with `supports.rotate: false`, or define a `style` attribute of their own - * that is not an object, which rotation could not be stored in. + * Returns whether a block supports rotation. Blocks must opt in with + * `supports.rotation: true`, and must not define a `style` attribute of their + * own that is not an object, which rotation could not be stored in. * * @param nameOrType Block name or block type object. * @@ -67,27 +69,10 @@ export function hasRotateSupport( nameOrType: string | BlockType ): boolean { const styleType = blockType?.attributes?.style?.type; return ( ( styleType === undefined || styleType === 'object' ) && - hasBlockSupport( nameOrType, ROTATE_SUPPORT_KEY, true ) + hasBlockSupport( nameOrType, ROTATION_SUPPORT_KEY, false ) ); } -/** - * Returns whether rotation is edited and shown for a block: the block rotation - * experiment is on and the block supports rotation. - * - * When this is false, a rotation that is already stored is kept but not shown, - * as on the front end. - * - * @param nameOrType Block name or block type object. - * - * @return Whether rotation is edited and shown. - */ -export function isRotateEnabled( nameOrType: string | BlockType ): boolean { - const { __experimentalEnableBlockRotation: isExperimentEnabled } = - window as Window & { __experimentalEnableBlockRotation?: boolean }; - return !! isExperimentEnabled && hasRotateSupport( nameOrType ); -} - /** * Returns whether a block style attribute holds any rotation. * @@ -98,10 +83,10 @@ export function isRotateEnabled( nameOrType: string | BlockType ): boolean { function hasRotateValue( style: BlockStyle | undefined ): boolean { return Object.entries( getStyleObject( style ) ?? {} ).some( ( [ key, value ] ) => - key === ROTATE_SUPPORT_KEY || + key === ROTATE_STYLE_KEY || ( key.startsWith( '@' ) && isPlainObject( value ) && - value[ ROTATE_SUPPORT_KEY ] !== undefined ) + value[ ROTATE_STYLE_KEY ] !== undefined ) ); } @@ -193,7 +178,7 @@ function RotateControl( { clientId, setAttributes, style }: RotateEditProps ) { // The provider scopes the panel's "Reset all" to the selected state. hasRotateValue( style ), }; diff --git a/packages/block-editor/src/hooks/style.tsx b/packages/block-editor/src/hooks/style.tsx index 9c0cb2577e8d8a..9d32f891b6873d 100644 --- a/packages/block-editor/src/hooks/style.tsx +++ b/packages/block-editor/src/hooks/style.tsx @@ -58,7 +58,7 @@ import { useSettings } from '../components/use-settings'; import { store as blockEditorStore } from '../store'; import { globalStylesDataKey } from '../store/private-keys'; import { isPlainObject } from '../utils/object'; -import { hasRotateSupport } from './rotate'; +import { hasRotateSupport } from './rotation'; import { unlock } from '../lock-unlock'; import type { PanelBorderSide, diff --git a/packages/block-editor/src/hooks/test/rotate-control.jsdom.test.tsx b/packages/block-editor/src/hooks/test/rotation-control.jsdom.test.tsx similarity index 50% rename from packages/block-editor/src/hooks/test/rotate-control.jsdom.test.tsx rename to packages/block-editor/src/hooks/test/rotation-control.jsdom.test.tsx index 4d7f14b74d8318..911bf96f239082 100644 --- a/packages/block-editor/src/hooks/test/rotate-control.jsdom.test.tsx +++ b/packages/block-editor/src/hooks/test/rotation-control.jsdom.test.tsx @@ -11,10 +11,11 @@ import { BlockEditContextProvider, mayDisplayControlsKey, } from '../../components/block-edit/context'; -import InspectorControlsSlot from '../../components/inspector-controls/slot'; +import InspectorControls from '../../components/inspector-controls'; +import PositionControls from '../../components/inspector-controls-tabs/position-controls-panel'; import { store as blockEditorStore } from '../../store'; import { unlock } from '../../lock-unlock'; -import rotate from '../rotate'; +import rotate from '../rotation'; const BLOCK_NAME = 'test/rotate-control'; @@ -54,7 +55,7 @@ function RotateEdit( { clientId }: { clientId: string } ) { ); } -function showRotateControl( style?: BlockStyle ) { +function showRotateControl( style?: BlockStyle, hasPositionControl = false ) { const block = createBlock( BLOCK_NAME, { style } ); // The block is selected, so its controls are displayed. const blockEditContext = { @@ -71,8 +72,13 @@ function showRotateControl( style?: BlockStyle ) { + { hasPositionControl && ( + +
Position selector
+
+ ) }
- +
); return block.clientId; @@ -85,6 +91,7 @@ describe( 'Rotation control', () => { title: 'Rotate control test', category: 'text', attributes: { style: { type: 'object' } }, + supports: { rotation: true }, edit: () => null, save: () => null, } ); @@ -112,6 +119,78 @@ describe( 'Rotation control', () => { expect( getStyle( clientId ) ).toBeUndefined(); } ); + it( 'offers Rotation without a Position option when the block has no position control', () => { + const clientId = showRotateControl(); + + fireEvent.click( + screen.getByRole( 'button', { name: 'Position options' } ) + ); + expect( + screen.queryByRole( 'menuitem', { name: /Position,/ } ) + ).not.toBeInTheDocument(); + fireEvent.click( + screen.getByRole( 'menuitemcheckbox', { name: 'Rotation, show' } ) + ); + fireEvent.change( + screen.getByRole( 'spinbutton', { name: 'Rotation' } ), + { target: { value: '45' } } + ); + expect( getStyle( clientId ) ).toEqual( { rotate: 45 } ); + } ); + + it( 'keeps the existing Position option alongside Rotation', () => { + showRotateControl( { position: { type: 'sticky' } }, true ); + + expect( screen.getByText( 'Position selector' ) ).toBeInTheDocument(); + fireEvent.click( + screen.getByRole( 'button', { name: 'Position options' } ) + ); + expect( + screen.getByRole( 'menuitem', { + name: 'Position, reset to default', + } ) + ).toBeInTheDocument(); + expect( + screen.getByRole( 'menuitemcheckbox', { name: 'Rotation, show' } ) + ).toBeInTheDocument(); + } ); + + it( 'hides Rotation without changing the existing position', () => { + const clientId = showRotateControl( + { position: { type: 'sticky' }, rotate: 30 }, + true + ); + + fireEvent.click( + screen.getByRole( 'button', { name: 'Position options' } ) + ); + fireEvent.click( + screen.getByRole( 'menuitemcheckbox', { + name: 'Rotation, hide and reset', + } ) + ); + expect( getStyle( clientId ) ).toEqual( { + position: { type: 'sticky' }, + } ); + } ); + + it( 'resets Position without changing the existing rotation', () => { + const clientId = showRotateControl( + { position: { type: 'sticky', top: '0px' }, rotate: 30 }, + true + ); + + fireEvent.click( + screen.getByRole( 'button', { name: 'Position options' } ) + ); + fireEvent.click( + screen.getByRole( 'menuitem', { + name: 'Position, reset to default', + } ) + ); + expect( getStyle( clientId ) ).toEqual( { rotate: 30 } ); + } ); + it( 'edits the rotation of the selected viewport', () => { act( () => { unlock( dispatch( blockEditorStore ) ).setStyleStateViewport( @@ -145,7 +224,7 @@ describe( 'Rotation control', () => { } ); fireEvent.click( - screen.getByRole( 'button', { name: 'Dimensions options' } ) + screen.getByRole( 'button', { name: 'Position options' } ) ); fireEvent.click( screen.getByRole( 'menuitem', { name: 'Reset all' } ) @@ -153,4 +232,60 @@ describe( 'Rotation control', () => { expect( getStyle( clientId ) ).toEqual( { rotate: 15 } ); } ); + + it( 'resets Position and Rotation together without clearing other styles', () => { + const clientId = showRotateControl( + { + position: { type: 'sticky', top: '0px' }, + rotate: 30, + color: { text: 'red' }, + }, + true + ); + + fireEvent.click( + screen.getByRole( 'button', { name: 'Position options' } ) + ); + fireEvent.click( + screen.getByRole( 'menuitem', { name: 'Reset all' } ) + ); + expect( getStyle( clientId ) ).toEqual( { + color: { text: 'red' }, + } ); + } ); + + it( 'resets both controls in the selected viewport without clearing desktop styles', () => { + act( () => { + unlock( dispatch( blockEditorStore ) ).setStyleStateViewport( + '@mobile' + ); + } ); + const clientId = showRotateControl( + { + rotate: 15, + position: { type: 'sticky', top: '0px' }, + '@mobile': { + rotate: 30, + position: { type: 'fixed', top: '0px' }, + color: { text: 'red' }, + }, + }, + true + ); + + fireEvent.click( + screen.getByRole( 'button', { name: 'Position options' } ) + ); + fireEvent.click( + screen.getByRole( 'menuitem', { name: 'Reset all' } ) + ); + expect( getStyle( clientId ) ).toEqual( { + rotate: 15, + position: { type: 'sticky', top: '0px' }, + '@mobile': { + position: { type: '' }, + color: { text: 'red' }, + }, + } ); + } ); } ); diff --git a/packages/block-editor/src/hooks/test/rotate.jsdom.test.ts b/packages/block-editor/src/hooks/test/rotation.jsdom.test.ts similarity index 86% rename from packages/block-editor/src/hooks/test/rotate.jsdom.test.ts rename to packages/block-editor/src/hooks/test/rotation.jsdom.test.ts index ffeb25d0d0ea5e..4b3125182ed5f2 100644 --- a/packages/block-editor/src/hooks/test/rotate.jsdom.test.ts +++ b/packages/block-editor/src/hooks/test/rotation.jsdom.test.ts @@ -1,10 +1,6 @@ -import { afterEach, describe, expect, it } from 'vitest'; +import { describe, expect, it } from 'vitest'; import type { BlockType } from '@wordpress/blocks'; -import rotate, { - getRotateCSS, - hasRotateSupport, - isRotateEnabled, -} from '../rotate'; +import rotate, { getRotateCSS, hasRotateSupport } from '../rotation'; import { getRotateForState, getRotateValue, @@ -15,10 +11,6 @@ const DEFAULT_STATE = { viewport: 'default', pseudo: 'default' }; const MOBILE_STATE = { viewport: '@mobile', pseudo: 'default' }; const TABLET_STATE = { viewport: '@tablet', pseudo: 'default' }; -const testWindow = window as Window & { - __experimentalEnableBlockRotation?: boolean; -}; - /** * Returns a block type with only the settings a test needs. Rotation support * only reads `supports` and `attributes`. @@ -32,26 +24,22 @@ function createBlockType( settings: Partial< BlockType > ): BlockType { } describe( 'rotate block support', () => { - afterEach( () => { - delete testWindow.__experimentalEnableBlockRotation; - } ); - describe( 'hasRotateSupport()', () => { - it( 'is on for blocks that do not opt out', () => { - expect( - hasRotateSupport( createBlockType( { supports: {} } ) ) - ).toBe( true ); + it( 'is on for blocks that opt in', () => { expect( hasRotateSupport( - createBlockType( { supports: { rotate: true } } ) + createBlockType( { supports: { rotation: true } } ) ) ).toBe( true ); } ); - it( 'is off for blocks that opt out', () => { + it( 'is off for blocks that do not opt in', () => { + expect( + hasRotateSupport( createBlockType( { supports: {} } ) ) + ).toBe( false ); expect( hasRotateSupport( - createBlockType( { supports: { rotate: false } } ) + createBlockType( { supports: { rotation: false } } ) ) ).toBe( false ); } ); @@ -60,7 +48,7 @@ describe( 'rotate block support', () => { expect( hasRotateSupport( createBlockType( { - supports: {}, + supports: { rotation: true }, attributes: { style: { type: 'string' } }, } ) ) @@ -68,7 +56,7 @@ describe( 'rotate block support', () => { expect( hasRotateSupport( createBlockType( { - supports: {}, + supports: { rotation: true }, attributes: { style: { type: 'object' } }, } ) ) @@ -76,27 +64,6 @@ describe( 'rotate block support', () => { } ); } ); - describe( 'isRotateEnabled()', () => { - it( 'needs the block rotation experiment', () => { - expect( - isRotateEnabled( createBlockType( { supports: {} } ) ) - ).toBe( false ); - testWindow.__experimentalEnableBlockRotation = true; - expect( - isRotateEnabled( createBlockType( { supports: {} } ) ) - ).toBe( true ); - } ); - - it( 'needs the block to support rotation', () => { - testWindow.__experimentalEnableBlockRotation = true; - expect( - isRotateEnabled( - createBlockType( { supports: { rotate: false } } ) - ) - ).toBe( false ); - } ); - } ); - describe( 'getRotateValue()', () => { it( 'reads numbers and numeric strings', () => { expect( getRotateValue( 15 ) ).toBe( 15 ); @@ -348,11 +315,15 @@ describe( 'rotate block support', () => { } ); describe( 'hasSupport()', () => { - it( 'needs the block rotation experiment, so a stored rotation is only applied with it', () => { - const blockType = createBlockType( { supports: {} } ); - expect( rotate.hasSupport( blockType ) ).toBe( false ); - testWindow.__experimentalEnableBlockRotation = true; - expect( rotate.hasSupport( blockType ) ).toBe( true ); + it( 'needs the block to opt in', () => { + expect( + rotate.hasSupport( createBlockType( { supports: {} } ) ) + ).toBe( false ); + expect( + rotate.hasSupport( + createBlockType( { supports: { rotation: true } } ) + ) + ).toBe( true ); } ); } ); diff --git a/packages/block-editor/src/hooks/test/style.jsdom.test.tsx b/packages/block-editor/src/hooks/test/style.jsdom.test.tsx index a8e579547f1a9b..46ce685cc95e3c 100644 --- a/packages/block-editor/src/hooks/test/style.jsdom.test.tsx +++ b/packages/block-editor/src/hooks/test/style.jsdom.test.tsx @@ -1072,13 +1072,15 @@ describe( 'addAttribute', () => { ).attributes; it( 'adds the style attribute for rotation alone', () => { - expect( addAttribute( { customCSS: false } ) ).toEqual( { - style: { type: 'object' }, - } ); + expect( addAttribute( { customCSS: false, rotation: true } ) ).toEqual( + { + style: { type: 'object' }, + } + ); } ); it( "doesn't add it to a block without any style support", () => { - expect( addAttribute( { customCSS: false, rotate: false } ) ).toEqual( + expect( addAttribute( { customCSS: false, rotation: false } ) ).toEqual( {} ); } ); diff --git a/packages/block-library/src/block/README.md b/packages/block-library/src/block/README.md index 02c5a7aa124957..bf7c3b06338850 100644 --- a/packages/block-library/src/block/README.md +++ b/packages/block-library/src/block/README.md @@ -29,7 +29,6 @@ _Defined via the [`supports`](https://developer.wordpress.org/block-editor/refer - [`interactivity`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#interactivity): - `clientNavigation`: `true` - `customCSS`: `false` -- `rotate`: `false` - [`visibility`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#visibility): `false` ## Context diff --git a/packages/block-library/src/block/block.json b/packages/block-library/src/block/block.json index 39ee89b5cfcd75..ef5c31ae1e957d 100644 --- a/packages/block-library/src/block/block.json +++ b/packages/block-library/src/block/block.json @@ -28,7 +28,6 @@ "clientNavigation": true }, "customCSS": false, - "rotate": false, "visibility": false } } diff --git a/packages/block-library/src/freeform/README.md b/packages/block-library/src/freeform/README.md index b319a061c1f1f0..29245b2ab20afa 100644 --- a/packages/block-library/src/freeform/README.md +++ b/packages/block-library/src/freeform/README.md @@ -25,7 +25,6 @@ _Defined via the [`supports`](https://developer.wordpress.org/block-editor/refer - [`lock`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#lock): `false` - [`reusable`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#reusable): `false` - [`renaming`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#renaming): `false` -- `rotate`: `false` - [`visibility`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#visibility): `false` - `customCSS`: `false` diff --git a/packages/block-library/src/freeform/block.json b/packages/block-library/src/freeform/block.json index 25dd04aca51bcc..ba06598ffebd34 100644 --- a/packages/block-library/src/freeform/block.json +++ b/packages/block-library/src/freeform/block.json @@ -18,7 +18,6 @@ "lock": false, "reusable": false, "renaming": false, - "rotate": false, "visibility": false, "customCSS": false }, diff --git a/packages/block-library/src/heading/README.md b/packages/block-library/src/heading/README.md index e27684687f067d..0eb39abb3b0cdf 100644 --- a/packages/block-library/src/heading/README.md +++ b/packages/block-library/src/heading/README.md @@ -24,6 +24,7 @@ _Defined via the [`attributes`](https://developer.wordpress.org/block-editor/ref _Defined via the [`supports`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/) property in block.json._ +- `rotation`: `true` - [`align`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#align): `"wide"`, `"full"` - [`anchor`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#anchor): `true` - [`className`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#classname): `true` diff --git a/packages/block-library/src/heading/block.json b/packages/block-library/src/heading/block.json index a19a6ecdd33a77..ee44d4f03a2290 100644 --- a/packages/block-library/src/heading/block.json +++ b/packages/block-library/src/heading/block.json @@ -26,6 +26,7 @@ } }, "supports": { + "rotation": true, "align": [ "wide", "full" ], "anchor": true, "className": true, diff --git a/packages/block-library/src/html/README.md b/packages/block-library/src/html/README.md index d7ecbe814b3c4b..b774d45517af86 100644 --- a/packages/block-library/src/html/README.md +++ b/packages/block-library/src/html/README.md @@ -28,7 +28,6 @@ _Defined via the [`supports`](https://developer.wordpress.org/block-editor/refer - `clientNavigation`: `true` - [`listView`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#listview): `true` - `customCSS`: `false` -- `rotate`: `false` - [`visibility`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#visibility): `false` ## Block Markup diff --git a/packages/block-library/src/html/block.json b/packages/block-library/src/html/block.json index 7d731f14eab877..9a59152fd57f24 100644 --- a/packages/block-library/src/html/block.json +++ b/packages/block-library/src/html/block.json @@ -22,7 +22,6 @@ }, "listView": true, "customCSS": false, - "rotate": false, "visibility": false }, "editorStyle": "wp-block-html-editor" diff --git a/packages/block-library/src/image/README.md b/packages/block-library/src/image/README.md index 37b2ef552112b7..60d2c7166d9112 100644 --- a/packages/block-library/src/image/README.md +++ b/packages/block-library/src/image/README.md @@ -39,6 +39,7 @@ _Defined via the [`attributes`](https://developer.wordpress.org/block-editor/ref _Defined via the [`supports`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/) property in block.json._ +- `rotation`: `true` - [`interactivity`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#interactivity): `true` - [`align`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#align): `"left"`, `"center"`, `"right"`, `"wide"`, `"full"` - [`anchor`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#anchor): `true` diff --git a/packages/block-library/src/image/block.json b/packages/block-library/src/image/block.json index cd3cd31b1f392f..1aea5ed86dcce1 100644 --- a/packages/block-library/src/image/block.json +++ b/packages/block-library/src/image/block.json @@ -112,6 +112,7 @@ } }, "supports": { + "rotation": true, "interactivity": true, "align": [ "left", "center", "right", "wide", "full" ], "anchor": true, diff --git a/packages/block-library/src/missing/README.md b/packages/block-library/src/missing/README.md index 2f98e413cce316..fd8edc5e332b2d 100644 --- a/packages/block-library/src/missing/README.md +++ b/packages/block-library/src/missing/README.md @@ -29,7 +29,6 @@ _Defined via the [`supports`](https://developer.wordpress.org/block-editor/refer - [`lock`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#lock): `false` - [`reusable`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#reusable): `false` - [`renaming`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#renaming): `false` -- `rotate`: `false` - [`visibility`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#visibility): `false` - [`interactivity`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#interactivity): - `clientNavigation`: `true` diff --git a/packages/block-library/src/missing/block.json b/packages/block-library/src/missing/block.json index 697b763b23aba8..92c9735d1ba157 100644 --- a/packages/block-library/src/missing/block.json +++ b/packages/block-library/src/missing/block.json @@ -26,7 +26,6 @@ "lock": false, "reusable": false, "renaming": false, - "rotate": false, "visibility": false, "interactivity": { "clientNavigation": true diff --git a/packages/block-library/src/more/README.md b/packages/block-library/src/more/README.md index 47e3b8acd27e41..3665d0a96684c1 100644 --- a/packages/block-library/src/more/README.md +++ b/packages/block-library/src/more/README.md @@ -26,7 +26,6 @@ _Defined via the [`supports`](https://developer.wordpress.org/block-editor/refer - [`className`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#classname): `false` - [`html`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#html): `false` - [`multiple`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#multiple): `false` -- `rotate`: `false` - [`visibility`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#visibility): `false` - [`interactivity`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#interactivity): - `clientNavigation`: `true` diff --git a/packages/block-library/src/more/block.json b/packages/block-library/src/more/block.json index f1db8ce1d53c0c..81ad94ba3e455d 100644 --- a/packages/block-library/src/more/block.json +++ b/packages/block-library/src/more/block.json @@ -23,7 +23,6 @@ "className": false, "html": false, "multiple": false, - "rotate": false, "visibility": false, "interactivity": { "clientNavigation": true diff --git a/packages/block-library/src/nextpage/README.md b/packages/block-library/src/nextpage/README.md index 50b3bbb119d735..6d3ea4de578a23 100644 --- a/packages/block-library/src/nextpage/README.md +++ b/packages/block-library/src/nextpage/README.md @@ -27,7 +27,6 @@ _Defined via the [`supports`](https://developer.wordpress.org/block-editor/refer - [`customClassName`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#customclassname): `false` - [`className`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#classname): `false` - [`html`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#html): `false` -- `rotate`: `false` - [`visibility`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#visibility): `false` - [`interactivity`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#interactivity): - `clientNavigation`: `true` diff --git a/packages/block-library/src/nextpage/block.json b/packages/block-library/src/nextpage/block.json index 5a8f00a55a3ca0..9021183adcce70 100644 --- a/packages/block-library/src/nextpage/block.json +++ b/packages/block-library/src/nextpage/block.json @@ -12,7 +12,6 @@ "customClassName": false, "className": false, "html": false, - "rotate": false, "visibility": false, "interactivity": { "clientNavigation": true diff --git a/packages/block-library/src/pattern/README.md b/packages/block-library/src/pattern/README.md index 9411aa90c515b3..0dbf90a509d343 100644 --- a/packages/block-library/src/pattern/README.md +++ b/packages/block-library/src/pattern/README.md @@ -23,7 +23,6 @@ _Defined via the [`supports`](https://developer.wordpress.org/block-editor/refer - [`html`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#html): `false` - [`inserter`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#inserter): `false` - [`renaming`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#renaming): `false` -- `rotate`: `false` - [`visibility`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#visibility): `false` - [`interactivity`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#interactivity): - `clientNavigation`: `true` diff --git a/packages/block-library/src/pattern/block.json b/packages/block-library/src/pattern/block.json index 6aba4ba787a957..d95db89e32f5ac 100644 --- a/packages/block-library/src/pattern/block.json +++ b/packages/block-library/src/pattern/block.json @@ -9,7 +9,6 @@ "html": false, "inserter": false, "renaming": false, - "rotate": false, "visibility": false, "interactivity": { "clientNavigation": true diff --git a/packages/block-library/src/shortcode/README.md b/packages/block-library/src/shortcode/README.md index e3d8c9e8541d4b..5cae1fce9f1bed 100644 --- a/packages/block-library/src/shortcode/README.md +++ b/packages/block-library/src/shortcode/README.md @@ -24,7 +24,6 @@ _Defined via the [`supports`](https://developer.wordpress.org/block-editor/refer - [`customClassName`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#customclassname): `false` - [`html`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#html): `false` - `customCSS`: `false` -- `rotate`: `false` - [`visibility`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#visibility): `false` ## Block Markup diff --git a/packages/block-library/src/shortcode/block.json b/packages/block-library/src/shortcode/block.json index e62139dc05920e..adbb48553ff130 100644 --- a/packages/block-library/src/shortcode/block.json +++ b/packages/block-library/src/shortcode/block.json @@ -18,7 +18,6 @@ "customClassName": false, "html": false, "customCSS": false, - "rotate": false, "visibility": false }, "editorStyle": "wp-block-shortcode-editor" diff --git a/packages/block-library/src/video/README.md b/packages/block-library/src/video/README.md index 2816e6e1d378e1..0ebe0029636a26 100644 --- a/packages/block-library/src/video/README.md +++ b/packages/block-library/src/video/README.md @@ -34,6 +34,7 @@ _Defined via the [`attributes`](https://developer.wordpress.org/block-editor/ref _Defined via the [`supports`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/) property in block.json._ +- `rotation`: `true` - [`anchor`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#anchor): `true` - [`align`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#align): `true` - [`filter`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#filter): diff --git a/packages/block-library/src/video/block.json b/packages/block-library/src/video/block.json index f030c66090501c..28dff54529a7de 100644 --- a/packages/block-library/src/video/block.json +++ b/packages/block-library/src/video/block.json @@ -89,6 +89,7 @@ } }, "supports": { + "rotation": true, "anchor": true, "align": true, "filter": { diff --git a/phpunit/block-supports/rotate-test.php b/phpunit/block-supports/rotation-test.php similarity index 93% rename from phpunit/block-supports/rotate-test.php rename to phpunit/block-supports/rotation-test.php index 35ff78e83b9a7e..c32fcc8b7bf013 100644 --- a/phpunit/block-supports/rotate-test.php +++ b/phpunit/block-supports/rotation-test.php @@ -1,11 +1,11 @@ true ), $attributes = array() ) { $this->test_block_name = 'test/rotate-block'; register_block_type( $this->test_block_name, @@ -84,19 +84,19 @@ private function get_block_supports_stylesheet() { } /** - * Tests that every block supports rotation unless it opts out. + * Tests that a block supports rotation only when it opts in. * * @covers ::gutenberg_register_rotate_support */ - public function test_registers_style_attribute_unless_block_opts_out() { - $block_type = $this->register_test_block(); + public function test_registers_style_attribute_only_when_block_opts_in() { + $block_type = $this->register_test_block( array( 'rotation' => true ) ); gutenberg_register_rotate_support( $block_type ); - $this->assertSame( array( 'type' => 'object' ), $block_type->attributes['style'] ?? null, 'A block supports rotation by default.' ); + $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( 'rotate' => false ) ); + $block_type = $this->register_test_block( array() ); gutenberg_register_rotate_support( $block_type ); - $this->assertArrayNotHasKey( 'style', $block_type->attributes, 'A block that opts out of rotation does not get a style attribute from it.' ); + $this->assertArrayNotHasKey( 'style', $block_type->attributes, 'A block that does not opt in does not get a style attribute from rotation.' ); } /** @@ -109,7 +109,7 @@ public function test_keeps_existing_style_attribute() { 'type' => 'object', 'default' => array( 'color' => array( 'text' => 'red' ) ), ); - $block_type = $this->register_test_block( array(), array( 'style' => $style_attribute ) ); + $block_type = $this->register_test_block( array( 'rotation' => true ), array( 'style' => $style_attribute ) ); gutenberg_register_rotate_support( $block_type ); $this->assertSame( $style_attribute, $block_type->attributes['style'] ); @@ -302,7 +302,7 @@ public function test_blocks_with_the_same_rotation_share_a_class() { * @covers ::gutenberg_render_rotate_support */ public function test_does_not_render_when_style_attribute_is_not_an_object() { - $this->register_test_block( array(), array( 'style' => array( 'type' => 'string' ) ) ); + $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 ) ); @@ -361,7 +361,7 @@ public function test_renders_viewport_overrides_with_theme_breakpoints() { * @covers ::gutenberg_render_rotate_support */ public function test_does_not_render_when_block_opts_out() { - $this->register_test_block( array( 'rotate' => false ) ); + $this->register_test_block( array() ); $block_content = '
Content
'; $this->assertSame( $block_content, $this->render_test_block( array( 'rotate' => 15 ), $block_content ) ); diff --git a/phpunit/bootstrap.php b/phpunit/bootstrap.php index 38403e2d714da5..9f7c1f373954ca 100644 --- a/phpunit/bootstrap.php +++ b/phpunit/bootstrap.php @@ -93,7 +93,6 @@ function fail_if_died( $message ) { 'gutenberg-guidelines' => 1, 'gutenberg-dashboard-widgets' => 1, 'gutenberg-real-time-collaboration' => 1, - 'gutenberg-block-rotation' => 1, ), ); From 9e1a35adeb66469db07239eef0d4cf211a9fc4ad Mon Sep 17 00:00:00 2001 From: tellthemachines Date: Tue, 27 Oct 2026 17:12:16 +1100 Subject: [PATCH 15/20] add support for group --- docs/reference-guides/core-blocks/README.md | 2 +- packages/block-library/src/group/README.md | 1 + packages/block-library/src/group/block.json | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/reference-guides/core-blocks/README.md b/docs/reference-guides/core-blocks/README.md index 88e6bdd2fe3ea1..d7e7b7dff3d6f5 100644 --- a/docs/reference-guides/core-blocks/README.md +++ b/docs/reference-guides/core-blocks/README.md @@ -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 diff --git a/packages/block-library/src/group/README.md b/packages/block-library/src/group/README.md index 8520f71c524cc0..e63f36032f1e47 100644 --- a/packages/block-library/src/group/README.md +++ b/packages/block-library/src/group/README.md @@ -45,6 +45,7 @@ _Defined via the [`supports`](https://developer.wordpress.org/block-editor/refer - `minWidth`: `true` - [`position`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#position): - `sticky`: `true` +- `rotation`: `true` - [`typography`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#typography): - [`fontSize`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#typography-fontsize): `true` - [`lineHeight`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#typography-lineheight): `true` diff --git a/packages/block-library/src/group/block.json b/packages/block-library/src/group/block.json index 37ff93cab562b2..d718d89d2e02da 100644 --- a/packages/block-library/src/group/block.json +++ b/packages/block-library/src/group/block.json @@ -72,6 +72,7 @@ "position": { "sticky": true }, + "rotation": true, "typography": { "fontSize": true, "lineHeight": true, From 34fada7495abe5f44a4efa423da9292ae4ab5a34 Mon Sep 17 00:00:00 2001 From: Andrei Draganescu Date: Fri, 30 Oct 2026 15:46:45 +0200 Subject: [PATCH 16/20] Document the rotation block support Co-Authored-By: Claude Opus 5.5 --- .../block-api/block-supports.md | 29 +++++++++++++++++++ schemas/json/block.json | 5 ++++ 2 files changed, 34 insertions(+) diff --git a/docs/reference-guides/block-api/block-supports.md b/docs/reference-guides/block-api/block-supports.md index b86f6a8d97dd6e..6529c44e1a9fc5 100644 --- a/docs/reference-guides/block-api/block-supports.md +++ b/docs/reference-guides/block-api/block-supports.md @@ -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 +} +``` + +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._ diff --git a/schemas/json/block.json b/schemas/json/block.json index 40b6b6f6fae2e2..165b07cd10f766 100644 --- a/schemas/json/block.json +++ b/schemas/json/block.json @@ -554,6 +554,11 @@ "type": "boolean", "default": true }, + "rotation": { + "description": "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, and the angle can be set separately for each viewport.\n\nWhen the block declares support for rotation, its attributes definition is extended to include the style attribute.", + "type": "boolean", + "default": false + }, "lock": { "description": "A block may want to disable the ability to toggle the lock state. It can be locked/unlocked by a user from the block 'Options' dropdown by default. To disable this behavior, set lock to false.", "type": "boolean", From ad7149e4db34a8c7111a0c11c451f887786b72bf Mon Sep 17 00:00:00 2001 From: Andrei Draganescu Date: Mon, 2 Nov 2026 17:26:30 +0200 Subject: [PATCH 17/20] Block Library: Link the rotation support in the block READMEs Co-Authored-By: Claude Opus 5.5 --- packages/block-library/src/group/README.md | 2 +- packages/block-library/src/heading/README.md | 2 +- packages/block-library/src/image/README.md | 2 +- packages/block-library/src/video/README.md | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/block-library/src/group/README.md b/packages/block-library/src/group/README.md index e63f36032f1e47..c9880b4d5bb539 100644 --- a/packages/block-library/src/group/README.md +++ b/packages/block-library/src/group/README.md @@ -45,7 +45,7 @@ _Defined via the [`supports`](https://developer.wordpress.org/block-editor/refer - `minWidth`: `true` - [`position`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#position): - `sticky`: `true` -- `rotation`: `true` +- [`rotation`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#rotation): `true` - [`typography`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#typography): - [`fontSize`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#typography-fontsize): `true` - [`lineHeight`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#typography-lineheight): `true` diff --git a/packages/block-library/src/heading/README.md b/packages/block-library/src/heading/README.md index 0eb39abb3b0cdf..eb4ebcf81c6ce5 100644 --- a/packages/block-library/src/heading/README.md +++ b/packages/block-library/src/heading/README.md @@ -24,7 +24,7 @@ _Defined via the [`attributes`](https://developer.wordpress.org/block-editor/ref _Defined via the [`supports`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/) property in block.json._ -- `rotation`: `true` +- [`rotation`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#rotation): `true` - [`align`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#align): `"wide"`, `"full"` - [`anchor`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#anchor): `true` - [`className`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#classname): `true` diff --git a/packages/block-library/src/image/README.md b/packages/block-library/src/image/README.md index 60d2c7166d9112..1ec0388d499be3 100644 --- a/packages/block-library/src/image/README.md +++ b/packages/block-library/src/image/README.md @@ -39,7 +39,7 @@ _Defined via the [`attributes`](https://developer.wordpress.org/block-editor/ref _Defined via the [`supports`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/) property in block.json._ -- `rotation`: `true` +- [`rotation`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#rotation): `true` - [`interactivity`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#interactivity): `true` - [`align`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#align): `"left"`, `"center"`, `"right"`, `"wide"`, `"full"` - [`anchor`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#anchor): `true` diff --git a/packages/block-library/src/video/README.md b/packages/block-library/src/video/README.md index 0ebe0029636a26..4a56c79e215c93 100644 --- a/packages/block-library/src/video/README.md +++ b/packages/block-library/src/video/README.md @@ -34,7 +34,7 @@ _Defined via the [`attributes`](https://developer.wordpress.org/block-editor/ref _Defined via the [`supports`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/) property in block.json._ -- `rotation`: `true` +- [`rotation`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#rotation): `true` - [`anchor`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#anchor): `true` - [`align`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#align): `true` - [`filter`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#filter): From 503c67de5529dfc9f619633e775ffddc50ce0341 Mon Sep 17 00:00:00 2001 From: Andrei Draganescu Date: Mon, 2 Nov 2026 17:26:31 +0200 Subject: [PATCH 18/20] Block Editor: Check rotation support inline when adding the style attribute Co-Authored-By: Claude Opus 5.5 --- packages/block-editor/src/hooks/style.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/block-editor/src/hooks/style.tsx b/packages/block-editor/src/hooks/style.tsx index 9d32f891b6873d..2e88ea91d62604 100644 --- a/packages/block-editor/src/hooks/style.tsx +++ b/packages/block-editor/src/hooks/style.tsx @@ -58,7 +58,6 @@ import { useSettings } from '../components/use-settings'; import { store as blockEditorStore } from '../store'; import { globalStylesDataKey } from '../store/private-keys'; import { isPlainObject } from '../utils/object'; -import { hasRotateSupport } from './rotation'; import { unlock } from '../lock-unlock'; import type { PanelBorderSide, @@ -781,8 +780,6 @@ export function getCanvasStateStyleValue( /** * Filters registered block settings, extending attributes to include `style` attribute. - * Rotation is stored in it too, so it is added here rather than in the rotate - * hook, which keeps the attribute in the same place among the others. * * @param settings Original block settings. * @@ -792,7 +789,7 @@ function addAttribute( settings: BlockType ): BlockType { if ( ! hasStyleSupport( settings ) && ! hasBlockSupport( settings, 'customCSS', true ) && - ! hasRotateSupport( settings ) + ! hasBlockSupport( settings, 'rotation', false ) ) { return settings; } From 9fb81bf136d881a29c2a4f2670f2a167c9adf01a Mon Sep 17 00:00:00 2001 From: Andrei Draganescu Date: Mon, 2 Nov 2026 17:37:05 +0200 Subject: [PATCH 19/20] Block Editor: Keep cleanEmptyObject in hooks/utils 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 --- .../src/hooks/block-style-state.ts | 7 ++--- packages/block-editor/src/hooks/utils.tsx | 29 +++++++++++++++++-- packages/block-editor/src/utils/object.ts | 28 ------------------ packages/block-editor/src/utils/rotation.ts | 3 +- .../{rotation.ts => rotation.jsdom.test.ts} | 0 5 files changed, 31 insertions(+), 36 deletions(-) rename packages/block-editor/src/utils/test/{rotation.ts => rotation.jsdom.test.ts} (100%) diff --git a/packages/block-editor/src/hooks/block-style-state.ts b/packages/block-editor/src/hooks/block-style-state.ts index 8d6f3e2a46adad..db99ae89013180 100644 --- a/packages/block-editor/src/hooks/block-style-state.ts +++ b/packages/block-editor/src/hooks/block-style-state.ts @@ -1,9 +1,6 @@ import { createContext, useContext } from '@wordpress/element'; -import { - cleanEmptyObject, - getValueFromObjectPath, - setImmutably, -} from '../utils/object'; +import { cleanEmptyObject } from './utils'; +import { getValueFromObjectPath, setImmutably } from '../utils/object'; import type { BlockStyle, BlockStyleState, ResetAllFilter } from './types'; const DEFAULT_STATE_VALUE = 'default'; diff --git a/packages/block-editor/src/hooks/utils.tsx b/packages/block-editor/src/hooks/utils.tsx index a3bb87ee8aa106..776fa8cefa0f53 100644 --- a/packages/block-editor/src/hooks/utils.tsx +++ b/packages/block-editor/src/hooks/utils.tsx @@ -16,8 +16,8 @@ import { import { useSettings } from '../components'; import { useSettingsForBlockElement } from '../components/global-styles/hooks'; import { - cleanEmptyObject, getValueFromObjectPath, + isPlainObject, setImmutably, } from '../utils/object'; import { store as blockEditorStore } from '../store'; @@ -32,10 +32,35 @@ import type { BlockListBlockHookProps, BlockSaveFeature, BlockWrapperProps, + CleanedObject, StyleOverrideOptions, } from './types'; -export { cleanEmptyObject }; +/** + * Removed falsy values from nested object. + * + * @param object + * @return Object cleaned from falsy values + */ +export const cleanEmptyObject = < T, >( + object: T +): CleanedObject< T > | undefined => { + if ( ! isPlainObject( object ) ) { + // Only plain objects are cleaned, so anything else keeps its type. + return object as CleanedObject< T >; + } + + const cleanedNestedObjects: [ string, unknown ][] = Object.entries( object ) + .map( ( [ key, value ] ): [ string, unknown ] => [ + key, + cleanEmptyObject( value ), + ] ) + .filter( ( [ , value ] ) => value !== undefined ); + // The cleaned object keeps the shape, with the empty values left out. + return ! cleanedNestedObjects.length + ? undefined + : ( Object.fromEntries( cleanedNestedObjects ) as CleanedObject< T > ); +}; export function transformStyles( activeSupports: Record< string, unknown >, diff --git a/packages/block-editor/src/utils/object.ts b/packages/block-editor/src/utils/object.ts index 67d60e38e8ce00..98828834739ab6 100644 --- a/packages/block-editor/src/utils/object.ts +++ b/packages/block-editor/src/utils/object.ts @@ -1,5 +1,3 @@ -import type { CleanedObject } from '../hooks/types'; - type AnyObject = Record< string, unknown >; type ObjectOrArray = AnyObject | unknown[]; type ObjectPathKey = number | string; @@ -85,29 +83,3 @@ export function isPlainObject( value: unknown ): value is AnyObject { typeof value === 'object' && value !== null && ! Array.isArray( value ) ); } - -/** - * Removed falsy values from nested object. - * - * @param object - * @return Object cleaned from falsy values - */ -export const cleanEmptyObject = < T >( - object: T -): CleanedObject< T > | undefined => { - if ( ! isPlainObject( object ) ) { - // Only plain objects are cleaned, so anything else keeps its type. - return object as CleanedObject< T >; - } - - const cleanedNestedObjects: [ string, unknown ][] = Object.entries( object ) - .map( ( [ key, value ] ): [ string, unknown ] => [ - key, - cleanEmptyObject( value ), - ] ) - .filter( ( [ , value ] ) => value !== undefined ); - // The cleaned object keeps the shape, with the empty values left out. - return ! cleanedNestedObjects.length - ? undefined - : ( Object.fromEntries( cleanedNestedObjects ) as CleanedObject< T > ); -}; diff --git a/packages/block-editor/src/utils/rotation.ts b/packages/block-editor/src/utils/rotation.ts index 2827002932f529..c91f9a3d241940 100644 --- a/packages/block-editor/src/utils/rotation.ts +++ b/packages/block-editor/src/utils/rotation.ts @@ -1,4 +1,5 @@ -import { cleanEmptyObject, isPlainObject } from './object'; +import { isPlainObject } from './object'; +import { cleanEmptyObject } from '../hooks/utils'; import { DEFAULT_BLOCK_STYLE_STATE, getStyleForState, diff --git a/packages/block-editor/src/utils/test/rotation.ts b/packages/block-editor/src/utils/test/rotation.jsdom.test.ts similarity index 100% rename from packages/block-editor/src/utils/test/rotation.ts rename to packages/block-editor/src/utils/test/rotation.jsdom.test.ts From 216d8bf9d9f39f5d5a230604cad54da8b363da0c Mon Sep 17 00:00:00 2001 From: Andrei Draganescu Date: Mon, 2 Nov 2026 17:43:33 +0200 Subject: [PATCH 20/20] Rotation: Target WordPress 7.3 and simplify the docblocks 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 --- backport-changelog/{7.2 => 7.3}/14707.md | 0 lib/block-supports/rotation.php | 7 +++---- lib/compat/wordpress-7.2/kses.php | 21 --------------------- lib/compat/wordpress-7.3/kses.php | 19 +++++++++++++++++++ 4 files changed, 22 insertions(+), 25 deletions(-) rename backport-changelog/{7.2 => 7.3}/14707.md (100%) diff --git a/backport-changelog/7.2/14707.md b/backport-changelog/7.3/14707.md similarity index 100% rename from backport-changelog/7.2/14707.md rename to backport-changelog/7.3/14707.md diff --git a/lib/block-supports/rotation.php b/lib/block-supports/rotation.php index d755457a8c2afc..ccfed0667afd81 100644 --- a/lib/block-supports/rotation.php +++ b/lib/block-supports/rotation.php @@ -68,9 +68,8 @@ function gutenberg_get_rotate_value( $value ) { /** * Builds the CSS rules that rotate a block, without their selector. * - * The `rotate` property is used rather than `transform` so that it combines with any - * transforms a theme or block applies. In a viewport override, 0 undoes the default - * rotation, so it is output as `none`. + * 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`. @@ -109,7 +108,7 @@ function gutenberg_get_rotate_style_rules( $style, $responsive_media_queries ) { * Rotates a block on the front end. * * The rules are added to the block supports stylesheet under a class generated from - * them, the default rotation first, so that viewport overrides win. + * them. * * @param string $block_content Rendered block content. * @param array $block Block object. diff --git a/lib/compat/wordpress-7.2/kses.php b/lib/compat/wordpress-7.2/kses.php index 84bc4eaf03afae..947a1d5d916337 100644 --- a/lib/compat/wordpress-7.2/kses.php +++ b/lib/compat/wordpress-7.2/kses.php @@ -49,24 +49,3 @@ function gutenberg_add_background_clip_to_safe_style_css( array $attr ): array { return $attr; } add_filter( 'safe_style_css', 'gutenberg_add_background_clip_to_safe_style_css' ); - -/** - * Adds 'rotate' to the list of safe CSS properties. - * - * The rotate block support uses the `rotate` property, rather than `transform`, so - * that the rotation combines with any transforms a theme or block applies instead - * of replacing them. 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' ); diff --git a/lib/compat/wordpress-7.3/kses.php b/lib/compat/wordpress-7.3/kses.php index 0d2f0005ee3204..82231ba7977ff3 100644 --- a/lib/compat/wordpress-7.3/kses.php +++ b/lib/compat/wordpress-7.3/kses.php @@ -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' );