Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 141 additions & 5 deletions lib/block-supports/layout.php
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,7 @@ function gutenberg_get_layout_child_values( $layout ) {
return array_intersect_key(
$layout,
array_flip(
array( 'selfStretch', 'flexSize', 'columnStart', 'columnSpan', 'rowStart', 'rowSpan' )
array( 'selfStretch', 'flexSize', 'columnStart', 'columnSpan', 'rowStart', 'rowSpan', 'rotate' )
)
);
}
Expand All @@ -273,7 +273,7 @@ function gutenberg_get_layout_container_values( $layout ) {
return array_diff_key(
$layout,
array_flip(
array( 'selfStretch', 'flexSize', 'columnStart', 'columnSpan', 'rowStart', 'rowSpan' )
array( 'selfStretch', 'flexSize', 'columnStart', 'columnSpan', 'rowStart', 'rowSpan', 'rotate' )
)
);
}
Expand Down Expand Up @@ -415,6 +415,36 @@ function gutenberg_get_child_layout_style_rules( $selector, $child_layout, $pare
}
}

/*
* Manual grids stack their children on mobile with a rule on the grid, which reads each
* child's row span from this custom property so that tall blocks stay tall. It is always
* set, so that a child doesn't inherit the row span of a grid it is nested in.
*/
if ( null === $viewport_overrides && ! empty( $parent_layout['isManualPlacement'] ) ) {
$child_layout_declarations['--wp--grid-item--row-span'] = (string) ( $row_span ? $row_span : 1 );
}

/*
* Rotation is a number of degrees, wrapped into the (-180, 180] range. It uses the
* `rotate` property rather than `transform` so that it combines with any transforms a
* theme or block applies. In a viewport override, 0 undoes the default rotation.
*/
$rotate_attr = $child_layout['rotate'] ?? null;
$rotate = null;
if ( is_numeric( $rotate_attr ) ) {
$rotate = round( fmod( fmod( (float) $rotate_attr, 360 ) + 360, 360 ), 2 );
if ( $rotate > 180 ) {
$rotate -= 360;
}
}
if ( null === $viewport_overrides ) {
if ( $rotate ) {
$child_layout_declarations['rotate'] = $rotate . 'deg';
}
} elseif ( $has_viewport_property_override( 'rotate' ) && null !== $rotate ) {
$child_layout_declarations['rotate'] = $rotate ? $rotate . 'deg' : 'none';
}

if ( ! empty( $child_layout_declarations ) ) {
$child_layout_styles[] = array(
'selector' => $selector,
Expand Down Expand Up @@ -905,7 +935,8 @@ function gutenberg_get_layout_style( $selector, $layout, $has_block_gap_support
$should_output_grid_columns = true;
}

$should_output_grid_rows = ( null === $viewport_overrides || $has_viewport_property_override( 'rowCount' ) ) && ! empty( $column_count ) && ! empty( $row_count );
$is_manual_placement = ! empty( $layout_for_styles['isManualPlacement'] );
$should_output_grid_rows = ( null === $viewport_overrides || $has_viewport_property_override( 'rowCount' ) || ( $is_manual_placement && $has_viewport_property_override( 'columnCount' ) ) ) && ! empty( $column_count ) && ! empty( $row_count );
$grid_declarations = array();

/* When enabled, columns stretch to fill the available space using
Expand Down Expand Up @@ -936,10 +967,54 @@ function gutenberg_get_layout_style( $selector, $layout, $has_block_gap_support
);
}

/*
* Every row gets the same height, the height of the tallest one, so that all the
* grid's cells are the same size. Rows added outside the row count, such as by
* stacking on mobile, match them.
*/
if ( $should_output_grid_rows ) {
$grid_row_declarations = array(
'grid-template-rows' => 'repeat(' . $row_count . ', minmax(1rem, 1fr))',
'grid-auto-rows' => 'minmax(1rem, 1fr)',
);
/*
* Manual grids take their height from their width, so that cells are close to
* square and content never makes them bigger. The gaps keep them from being
* exactly square.
*/
if ( $is_manual_placement ) {
$grid_row_declarations['aspect-ratio'] = $column_count . ' / ' . $row_count;
$grid_row_declarations['min-height'] = '0';
}
$layout_styles[] = array(
'selector' => $selector,
'declarations' => array( 'grid-template-rows' => 'repeat(' . $row_count . ', minmax(1rem, auto))' ),
'declarations' => $grid_row_declarations,
);
}

// Images in a manual grid cover their cells, below any caption.
if ( null === $viewport_overrides && $is_manual_placement ) {
$layout_styles[] = array(
'selector' => "$selector > .wp-block-image",
'declarations' => array(
'display' => 'flex',
'flex-direction' => 'column',
),
);
$layout_styles[] = array(
'selector' => "$selector > .wp-block-image > :is(img, a)",
'declarations' => array(
'flex' => '1 1 0',
'min-height' => '0',
),
);
$layout_styles[] = array(
'selector' => "$selector > .wp-block-image img",
'declarations' => array(
'width' => '100%',
'height' => '100%',
'object-fit' => 'cover',
),
);
}

Expand Down Expand Up @@ -1088,7 +1163,7 @@ function gutenberg_render_layout_support_flag( $block_content, $block ) {
'layout' => $base_child_layout,
'parentLayout' => array_intersect_key(
$parent_layout,
array_flip( array( 'minimumColumnWidth', 'columnCount' ) )
array_flip( array( 'minimumColumnWidth', 'columnCount', 'isManualPlacement' ) )
),
);
foreach ( $viewport_child_layouts as $breakpoint => $viewport_data ) {
Expand Down Expand Up @@ -1376,6 +1451,67 @@ function gutenberg_render_layout_support_flag( $block_content, $block ) {
$class_names[] = $container_class;
}
}

/*
* Manual placement grids stack their children on mobile unless they opt out with
* `stackOnMobile: false`: each child becomes full width, in block order, and unrotated,
* and keeps the row span it publishes as `--wp--grid-item--row-span`.
* The selector is repeated so that the rule beats each child's own placement rule,
* whatever order the stylesheets end up in.
*/
$mobile_media_query = $responsive_media_queries['@mobile'] ?? null;
$mobile_layout = array_replace(
is_array( $used_layout ) ? $used_layout : array(),
gutenberg_get_layout_container_values( $style_attr['@mobile']['layout'] ?? null )
);
if (
$mobile_media_query &&
'grid' === ( $used_layout['type'] ?? null ) &&
! empty( $used_layout['isManualPlacement'] ) &&
false !== ( $mobile_layout['stackOnMobile'] ?? true )
) {
// Stacked blocks are sized by their content again, images included.
$stacking_styles = gutenberg_style_engine_get_stylesheet_from_css_rules(
array(
array(
'rules_group' => $mobile_media_query,
'selector' => ".$container_class.$container_class",
'declarations' => array(
'aspect-ratio' => 'auto',
'grid-template-rows' => 'none',
'grid-auto-rows' => 'auto',
),
),
array(
'rules_group' => $mobile_media_query,
'selector' => ".$container_class.$container_class > *",
'declarations' => array(
'grid-column' => '1 / -1',
'grid-row' => 'span var(--wp--grid-item--row-span, 1)',
'rotate' => 'none',
),
),
array(
'rules_group' => $mobile_media_query,
'selector' => ".$container_class.$container_class > .wp-block-image",
'declarations' => array( 'display' => 'block' ),
),
array(
'rules_group' => $mobile_media_query,
'selector' => ".$container_class.$container_class > .wp-block-image img",
'declarations' => array( 'height' => 'auto' ),
),
),
array(
'context' => 'block-supports',
'prettify' => false,
)
);

if ( ! empty( $stacking_styles ) && ! in_array( $container_class, $class_names, true ) ) {
$class_names[] = $container_class;
}
}
}

// Add combined layout and block classname for global styles to hook onto.
Expand Down
19 changes: 19 additions & 0 deletions lib/compat/wordpress-7.2/kses.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
* Rotated children of a manual placement grid use 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' );
4 changes: 4 additions & 0 deletions packages/block-editor/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

### Enhancements

- Grid: In manual placement grids (behind the `gutenberg-grid-interactivity` experiment), dragging a block shows the cells it will land in, any blocks it will overlap, and alignment guides to the container and nearby blocks. The pointer marks the centre of the dragged block, a rotate handle sits on the selected block, the block settings gain a Rotation control stored as `style.layout.rotate`, the grid stacks its blocks on mobile unless "Stack on mobile" is turned off (`layout.stackOnMobile`), the grid takes its height from its width so all its cells are the same size, close to square, whatever their content, images cover their cells, dragging across empty cells inserts a block that covers them, and dragging the top or bottom edge of the grid adds or removes rows ([#84340](https://github.com/WordPress/gutenberg/pull/84340)).

## 18.1.0 (2026-10-07)

### New Features
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export function useEventHandlers( { clientId, isSelected } ) {
isSectionBlock,
editedContentOnlySection,
getBlock,
getBlockAttributes,
} = unlock( useSelect( blockEditorStore ) );
const {
removeBlock,
Expand Down Expand Up @@ -128,6 +129,52 @@ export function useEventHandlers( { clientId, isSelected } ) {
ownerDocument.body.appendChild( dragElement );
event.dataTransfer.setDragImage( dragElement, 0, 0 );

// In a manual placement grid, the grid shows the cells the
// block will land in, so the block stays in place, dimmed,
// instead of following the pointer.
const rootLayout = getBlockAttributes(
getBlockRootClientId( clientId )
)?.layout;
if (
rootLayout?.type === 'grid' &&
rootLayout.isManualPlacement &&
window.__experimentalEnableGridInteractivity
) {
const originalOpacity = node.style.opacity;
node.style.opacity = '0.5';

function endGridDrag() {
ownerDocument.removeEventListener(
'dragend',
endGridDrag
);
ownerDocument.removeEventListener(
'drop',
endGridDrag
);
node.style.opacity = originalOpacity;
dragElement.remove();
stopDraggingBlocks();
document.body.classList.remove(
'is-dragging-components-draggable'
);
ownerDocument.documentElement.classList.remove(
'is-dragging'
);
}

ownerDocument.addEventListener( 'dragend', endGridDrag );
ownerDocument.addEventListener( 'drop', endGridDrag );
startDraggingBlocks( [ clientId ] );
document.body.classList.add(
'is-dragging-components-draggable'
);
ownerDocument.documentElement.classList.add(
'is-dragging'
);
return;
}

const rect = node.getBoundingClientRect();

// Remove the id and leave it on a shallow clone so that drop
Expand Down
Loading
Loading