diff --git a/packages/block-editor/src/components/child-layout-control/index.js b/packages/block-editor/src/components/child-layout-control/index.js
index e0af72c238ad5..eb2a02e5095d7 100644
--- a/packages/block-editor/src/components/child-layout-control/index.js
+++ b/packages/block-editor/src/components/child-layout-control/index.js
@@ -223,7 +223,13 @@ export default function ChildLayoutControl( {
} }
value={ columnStart }
min={ 1 }
- max={ parentLayout?.columnCount }
+ max={
+ parentLayout?.columnCount
+ ? parentLayout.columnCount -
+ ( columnSpan ?? 1 ) +
+ 1
+ : undefined
+ }
/>
@@ -241,7 +247,13 @@ export default function ChildLayoutControl( {
} }
value={ rowStart }
min={ 1 }
- max={ parentLayout?.columnCount }
+ max={
+ parentLayout?.rowCount
+ ? parentLayout.rowCount -
+ ( rowSpan ?? 1 ) +
+ 1
+ : undefined
+ }
/>
diff --git a/packages/block-editor/src/components/grid-interactivity/grid-item-pin-toolbar-item.js b/packages/block-editor/src/components/grid-interactivity/grid-item-pin-toolbar-item.js
new file mode 100644
index 0000000000000..0f786738b06ff
--- /dev/null
+++ b/packages/block-editor/src/components/grid-interactivity/grid-item-pin-toolbar-item.js
@@ -0,0 +1,50 @@
+/**
+ * WordPress dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { ToolbarButton } from '@wordpress/components';
+import { pin as pinIcon } from '@wordpress/icons';
+
+/**
+ * Internal dependencies
+ */
+import BlockControls from '../block-controls';
+import { __unstableUseBlockElement as useBlockElement } from '../block-list/use-block-props/use-block-refs';
+import { getGridItemRect } from './utils';
+
+export function GridItemPinToolbarItem( { clientId, layout, onChange } ) {
+ const blockElement = useBlockElement( clientId );
+ if ( ! blockElement ) {
+ return null;
+ }
+
+ const isPinned = !! layout?.columnStart || !! layout?.rowStart;
+
+ function unpinBlock() {
+ onChange( {
+ columnStart: undefined,
+ rowStart: undefined,
+ } );
+ }
+
+ function pinBlock() {
+ const rect = getGridItemRect( blockElement );
+ onChange( {
+ columnStart: rect.columnStart,
+ rowStart: rect.rowStart,
+ } );
+ }
+
+ return (
+
+
+
+ );
+}
diff --git a/packages/block-editor/src/components/grid-interactivity/grid-item-resizer.js b/packages/block-editor/src/components/grid-interactivity/grid-item-resizer.js
new file mode 100644
index 0000000000000..f628f081286f7
--- /dev/null
+++ b/packages/block-editor/src/components/grid-interactivity/grid-item-resizer.js
@@ -0,0 +1,58 @@
+/**
+ * WordPress dependencies
+ */
+import { ResizableBox } from '@wordpress/components';
+
+/**
+ * Internal dependencies
+ */
+import { __unstableUseBlockElement as useBlockElement } from '../block-list/use-block-props/use-block-refs';
+import BlockPopoverCover from '../block-popover/cover';
+import { getGridRect } from './utils';
+
+export function GridItemResizer( { clientId, onChange } ) {
+ const blockElement = useBlockElement( clientId );
+ if ( ! blockElement ) {
+ return null;
+ }
+ return (
+
+ {
+ const rect = getGridRect(
+ blockElement.parentElement,
+ new window.DOMRect(
+ blockElement.offsetLeft,
+ blockElement.offsetTop,
+ boxElement.offsetWidth,
+ boxElement.offsetHeight
+ )
+ );
+ onChange( {
+ columnSpan: rect.columnSpan,
+ rowSpan: rect.rowSpan,
+ } );
+ } }
+ />
+
+ );
+}
diff --git a/packages/block-editor/src/components/grid-interactivity/grid-visualizer.js b/packages/block-editor/src/components/grid-interactivity/grid-visualizer.js
new file mode 100644
index 0000000000000..3ab9cc0c84515
--- /dev/null
+++ b/packages/block-editor/src/components/grid-interactivity/grid-visualizer.js
@@ -0,0 +1,528 @@
+/**
+ * External dependencies
+ */
+import classnames from 'classnames';
+
+/**
+ * WordPress dependencies
+ */
+import { useState, useEffect } from '@wordpress/element';
+import { useSelect, useDispatch } from '@wordpress/data';
+import { __experimentalUseDropZone as useDropZone } from '@wordpress/compose';
+import { Popover } from '@wordpress/components';
+
+/**
+ * Internal dependencies
+ */
+import { __unstableUseBlockElement as useBlockElement } from '../block-list/use-block-props/use-block-refs';
+import BlockPopoverCover from '../block-popover/cover';
+import {
+ getComputedCSS,
+ range,
+ GridRect,
+ getGridItemRect,
+ getGridCell,
+ getClosestGridCell,
+} from './utils';
+import { store as blockEditorStore } from '../../store';
+import QuickInserter from '../inserter/quick-inserter';
+
+export function GridVisualizer( { clientId } ) {
+ const gridElement = useBlockElement( clientId );
+ if ( ! gridElement ) {
+ return null;
+ }
+ return (
+
+ );
+}
+
+function getGridInfo( gridElement ) {
+ const gridTemplateColumns = getComputedCSS(
+ gridElement,
+ 'grid-template-columns'
+ );
+ const gridTemplateRows = getComputedCSS(
+ gridElement,
+ 'grid-template-rows'
+ );
+ const numColumns = gridTemplateColumns.split( ' ' ).length;
+ const numRows = gridTemplateRows.split( ' ' ).length;
+ const numItems = numColumns * numRows;
+ return {
+ numColumns,
+ numRows,
+ numItems,
+ style: {
+ gridTemplateColumns,
+ gridTemplateRows,
+ gap: getComputedCSS( gridElement, 'gap' ),
+ padding: getComputedCSS( gridElement, 'padding' ),
+ },
+ };
+}
+
+// TODO: clean up these two funcs
+// TODO: these two funcs don't properly handle the case where an item only has a row or a column set
+// what we're supposed to do in that case is quite complicated: https://www.w3.org/TR/css-grid-1/#auto-placement-algo
+
+function getNaturalPosition( {
+ rects,
+ numColumns,
+ numRows,
+ columnStart, // if specified, constrain to this column
+ rowStart, // if specified, constrain to this row
+ columnSpan = 1,
+ rowSpan = 1,
+} ) {
+ for ( let row = 1; row <= numRows; row++ ) {
+ for ( let column = 1; column <= numColumns; column++ ) {
+ const rect = new GridRect( {
+ columnStart: columnStart ?? column,
+ rowStart: rowStart ?? row,
+ columnSpan,
+ rowSpan,
+ } );
+ if (
+ ! rects.some( ( otherRect ) =>
+ rect.intersectsRect( otherRect )
+ )
+ ) {
+ return { column, row };
+ }
+ }
+ }
+ return null;
+}
+
+function getRects( { innerBlocks, numColumns, numRows } ) {
+ const rects = [];
+
+ for ( const block of innerBlocks ) {
+ const layout = block.attributes.style?.layout;
+ if ( layout.columnStart && layout.rowStart ) {
+ const rect = new GridRect( {
+ columnStart: layout.columnStart,
+ rowStart: layout.rowStart,
+ columnSpan: layout.columnSpan ?? 1,
+ rowSpan: layout.rowSpan ?? 1,
+ } );
+ rects.push( rect );
+ }
+ }
+
+ for ( const block of innerBlocks ) {
+ const layout = block.attributes.style?.layout;
+ if ( ! layout.columnStart || ! layout.rowStart ) {
+ const naturalPosition = getNaturalPosition( {
+ rects,
+ numColumns,
+ numRows,
+ columnStart: layout.columnStart,
+ rowStart: layout.rowStart,
+ columnSpan: layout.columnSpan ?? 1,
+ rowSpan: layout.rowSpan ?? 1,
+ } );
+ const rect = new GridRect( {
+ columnStart: naturalPosition.column,
+ rowStart: naturalPosition.row,
+ columnSpan: layout.columnSpan ?? 1,
+ rowSpan: layout.rowSpan ?? 1,
+ } );
+ rects.push( rect );
+ }
+ }
+
+ return rects;
+}
+
+function GridVisualizerGrid( { gridClientId, gridElement } ) {
+ const [ gridInfo, setGridInfo ] = useState( () =>
+ getGridInfo( gridElement )
+ );
+ const [ isDroppingAllowed, setIsDroppingAllowed ] = useState( false );
+ const [ highlightedRect, setHighlightedRect ] = useState( null );
+ const [ insertion, setInsertion ] = useState( false );
+
+ const { getBlockAttributes, getBlocks } = useSelect( blockEditorStore );
+ const { updateBlockAttributes, moveBlockToPosition } =
+ useDispatch( blockEditorStore );
+
+ useEffect( () => {
+ const observers = [];
+ for ( const element of [ gridElement, ...gridElement.children ] ) {
+ const observer = new window.ResizeObserver( () => {
+ setGridInfo( getGridInfo( gridElement ) );
+ } );
+ observer.observe( element );
+ observers.push( observer );
+ }
+
+ let dragStartCell = null;
+
+ function onMouseDown( event ) {
+ if ( event.target !== gridElement ) {
+ return;
+ }
+ const { column, row } = getGridCell(
+ gridElement,
+ event.offsetX,
+ event.offsetY
+ );
+ if ( column && row ) {
+ dragStartCell = {
+ column,
+ row,
+ };
+ }
+ }
+ function onMouseUp( event ) {
+ if ( event.target !== gridElement ) {
+ return;
+ }
+ const { column, row } = getGridCell(
+ gridElement,
+ event.offsetX,
+ event.offsetY
+ );
+ if ( dragStartCell && column && row ) {
+ const rect = new GridRect( {
+ columnStart: Math.min( dragStartCell.column, column ),
+ rowStart: Math.min( dragStartCell.row, row ),
+ columnSpan: Math.abs( dragStartCell.column - column ) + 1,
+ rowSpan: Math.abs( dragStartCell.row - row ) + 1,
+ } );
+ const anchor = {
+ getBoundingClientRect() {
+ return new window.DOMRect(
+ event.clientX,
+ event.clientY,
+ 0,
+ 0
+ );
+ },
+ ownerDocument: event.target.ownerDocument,
+ };
+ setInsertion( { rect, anchor } );
+ }
+ dragStartCell = null;
+ }
+ function onMouseMove( event ) {
+ if ( event.target !== gridElement ) {
+ return;
+ }
+ if ( dragStartCell ) {
+ const { column, row } = getClosestGridCell(
+ gridElement,
+ event.offsetX,
+ event.offsetY
+ );
+ setHighlightedRect(
+ column && row
+ ? new GridRect( {
+ columnStart: Math.min(
+ dragStartCell.column,
+ column
+ ),
+ rowStart: Math.min( dragStartCell.row, row ),
+ columnSpan:
+ Math.abs( dragStartCell.column - column ) +
+ 1,
+ rowSpan:
+ Math.abs( dragStartCell.row - row ) + 1,
+ } )
+ : new GridRect( {
+ columnStart: dragStartCell.column,
+ rowStart: dragStartCell.row,
+ } )
+ );
+ } else {
+ const { column, row } = getGridCell(
+ gridElement,
+ event.offsetX,
+ event.offsetY
+ );
+ setHighlightedRect(
+ column && row
+ ? new GridRect( {
+ columnStart: column,
+ rowStart: row,
+ } )
+ : null
+ );
+ }
+ }
+ gridElement.addEventListener( 'mousedown', onMouseDown );
+ gridElement.addEventListener( 'mouseup', onMouseUp );
+ gridElement.addEventListener( 'mousemove', onMouseMove );
+
+ return () => {
+ for ( const observer of observers ) {
+ observer.disconnect();
+ }
+
+ gridElement.removeEventListener( 'mousedown', onMouseDown );
+ gridElement.removeEventListener( 'mouseup', onMouseUp );
+ gridElement.removeEventListener( 'mousemove', onMouseMove );
+ };
+ }, [ gridElement ] );
+
+ useEffect( () => {
+ function onGlobalDrag() {
+ setIsDroppingAllowed( true );
+ }
+ function onGlobalDragEnd() {
+ setIsDroppingAllowed( false );
+ }
+ document.addEventListener( 'drag', onGlobalDrag );
+ document.addEventListener( 'dragend', onGlobalDragEnd );
+ return () => {
+ document.removeEventListener( 'drag', onGlobalDrag );
+ document.removeEventListener( 'dragend', onGlobalDragEnd );
+ };
+ }, [] );
+
+ return (
+ <>
+ { insertion && (
+
+ {
+ updateBlockAttributes( block.clientId, {
+ style: {
+ ...block.attributes.style,
+ layout: {
+ ...block.attributes.style?.layout,
+ columnStart: insertion.rect.columnStart,
+ rowStart: insertion.rect.rowStart,
+ columnSpan: insertion.rect.columnSpan,
+ rowSpan: insertion.rect.rowSpan,
+ },
+ },
+ } );
+ setInsertion( null );
+ } }
+ />
+
+ ) }
+
+
+ { range( 1, gridInfo.numRows ).map( ( row ) =>
+ range( 1, gridInfo.numColumns ).map( ( column ) => (
+ {
+ const attributes =
+ getBlockAttributes( srcClientId );
+ const rect = new GridRect( {
+ columnStart: column,
+ rowStart: row,
+ columnSpan:
+ attributes.style?.layout
+ ?.columnSpan,
+ rowSpan:
+ attributes.style?.layout?.rowSpan,
+ } );
+
+ const isInBounds = new GridRect( {
+ columnSpan: gridInfo.numColumns,
+ rowSpan: gridInfo.numRows,
+ } ).containsRect( rect );
+ if ( ! isInBounds ) {
+ return false;
+ }
+
+ const isOverlapping = Array.from(
+ gridElement.children
+ ).some(
+ ( child ) =>
+ child.dataset.block !==
+ srcClientId &&
+ rect.intersectsRect(
+ getGridItemRect( child )
+ )
+ );
+ if ( isOverlapping ) {
+ return false;
+ }
+
+ return true;
+ } }
+ onDragEnter={ ( srcClientId ) => {
+ const attributes =
+ getBlockAttributes( srcClientId );
+ setHighlightedRect(
+ new GridRect( {
+ columnStart: column,
+ rowStart: row,
+ columnSpan:
+ attributes.style?.layout
+ ?.columnSpan,
+ rowSpan:
+ attributes.style?.layout
+ ?.rowSpan,
+ } )
+ );
+ } }
+ onDragLeave={ () => {
+ // onDragEnter can be called before onDragLeave if the user moves
+ // their mouse quickly, so only clear the highlight if it was set
+ // by this cell.
+ setHighlightedRect(
+ ( prevHighlightedRect ) =>
+ prevHighlightedRect?.columnStart ===
+ column &&
+ prevHighlightedRect?.rowStart ===
+ row
+ ? null
+ : prevHighlightedRect
+ );
+ } }
+ onDrop={ ( srcClientId ) => {
+ // TODO: this is messy
+
+ const attributes =
+ getBlockAttributes( srcClientId );
+
+ const blocks = getBlocks(
+ gridClientId
+ ).filter(
+ ( { clientId } ) =>
+ clientId !== srcClientId
+ );
+ const rects = getRects( {
+ innerBlocks: blocks,
+ numColumns: gridInfo.numColumns,
+ numRows: gridInfo.numRows,
+ } );
+ const naturalPosition = getNaturalPosition(
+ {
+ rects,
+ numColumns: gridInfo.numColumns,
+ numRows: gridInfo.numRows,
+ columnSpan:
+ attributes.style?.layout
+ ?.columnSpan,
+ rowSpan:
+ attributes.style?.layout
+ ?.rowSpan,
+ }
+ );
+
+ if (
+ column === naturalPosition?.column &&
+ row === naturalPosition?.row
+ ) {
+ moveBlockToPosition(
+ srcClientId,
+ gridClientId,
+ gridClientId,
+ blocks.length
+ );
+ if (
+ attributes.style?.layout
+ ?.columnStart ||
+ attributes.style?.layout?.rowStart
+ ) {
+ const {
+ columnStart,
+ rowStart,
+ ...layout
+ } = attributes.style.layout;
+ updateBlockAttributes(
+ srcClientId,
+ {
+ style: {
+ ...attributes.style,
+ layout,
+ },
+ }
+ );
+ }
+ } else {
+ updateBlockAttributes( srcClientId, {
+ style: {
+ ...attributes.style,
+ layout: {
+ ...attributes.style?.layout,
+ columnStart: column,
+ rowStart: row,
+ },
+ },
+ } );
+ }
+
+ setHighlightedRect( null );
+ } }
+ />
+ ) )
+ ) }
+
+
+ >
+ );
+}
+
+function GridVisualizerCell( {
+ isHighlighted,
+ validateDrag,
+ onDragEnter,
+ onDragLeave,
+ onDrop,
+} ) {
+ const { getDraggedBlockClientIds } = useSelect( blockEditorStore );
+
+ const ref = useDropZone( {
+ onDragEnter() {
+ const [ srcClientId ] = getDraggedBlockClientIds();
+ if ( srcClientId && validateDrag( srcClientId ) ) {
+ onDragEnter( srcClientId );
+ }
+ },
+ onDragLeave() {
+ onDragLeave();
+ },
+ onDrop() {
+ const [ srcClientId ] = getDraggedBlockClientIds();
+ if ( srcClientId && validateDrag( srcClientId ) ) {
+ onDrop( srcClientId );
+ }
+ },
+ } );
+
+ return (
+
+ );
+}
diff --git a/packages/block-editor/src/components/grid-visualizer/index.js b/packages/block-editor/src/components/grid-interactivity/index.js
similarity index 60%
rename from packages/block-editor/src/components/grid-visualizer/index.js
rename to packages/block-editor/src/components/grid-interactivity/index.js
index add845d702203..663f89aac426c 100644
--- a/packages/block-editor/src/components/grid-visualizer/index.js
+++ b/packages/block-editor/src/components/grid-interactivity/index.js
@@ -1,2 +1,3 @@
export { GridVisualizer } from './grid-visualizer';
export { GridItemResizer } from './grid-item-resizer';
+export { GridItemPinToolbarItem } from './grid-item-pin-toolbar-item';
diff --git a/packages/block-editor/src/components/grid-interactivity/style.scss b/packages/block-editor/src/components/grid-interactivity/style.scss
new file mode 100644
index 0000000000000..dfb3e57f84ae0
--- /dev/null
+++ b/packages/block-editor/src/components/grid-interactivity/style.scss
@@ -0,0 +1,63 @@
+.block-editor-grid-visualizer {
+ // Specificity to override the z-index and pointer-events set by .components-popover.
+ &.block-editor-grid-visualizer.block-editor-grid-visualizer {
+ z-index: z-index(".block-editor-grid-visualizer");
+
+ .components-popover__content * {
+ pointer-events: none;
+ }
+
+ &.is-dropping-allowed {
+ .block-editor-grid-visualizer__drop-zone {
+ pointer-events: all;
+ }
+ }
+ }
+}
+
+.block-editor-grid-visualizer__grid {
+ display: grid;
+}
+
+.block-editor-grid-visualizer__cell {
+ align-items: center;
+ display: flex;
+ justify-content: center;
+}
+
+.block-editor-grid-visualizer__drop-zone {
+ background: rgba($gray-400, 0.1);
+ border: $border-width dotted $gray-300;
+ width: 100%;
+ height: 100%;
+
+ // Make drop zone 8x8 at minimum so that it's easier to drag into. This will overflow the parent.
+ min-width: $grid-unit-10;
+ min-height: $grid-unit-10;
+
+ &.is-highlighted {
+ background: var(--wp-admin-theme-color);
+ }
+}
+
+.block-editor-grid-item-resizer {
+ // Specificity to override the z-index and pointer-events set by .components-popover.
+ &.block-editor-grid-item-resizer.block-editor-grid-item-resizer {
+ z-index: z-index(".block-editor-grid-visualizer");
+
+ .components-popover__content * {
+ pointer-events: none;
+ }
+ }
+}
+
+.block-editor-grid-item-resizer__box {
+ border: $border-width solid var(--wp-admin-theme-color);
+
+ .components-resizable-box__handle {
+ // Specificity to override the pointer-events set by .components-popover.
+ &.components-resizable-box__handle.components-resizable-box__handle {
+ pointer-events: all;
+ }
+ }
+}
diff --git a/packages/block-editor/src/components/grid-interactivity/utils.js b/packages/block-editor/src/components/grid-interactivity/utils.js
new file mode 100644
index 0000000000000..09d6cbe3fb45a
--- /dev/null
+++ b/packages/block-editor/src/components/grid-interactivity/utils.js
@@ -0,0 +1,165 @@
+export function range( start, length ) {
+ return Array.from( { length }, ( _, i ) => start + i );
+}
+
+export class GridRect {
+ constructor( {
+ columnStart,
+ rowStart,
+ columnEnd,
+ rowEnd,
+ columnSpan,
+ rowSpan,
+ } = {} ) {
+ this.columnStart = columnStart ?? 1;
+ this.rowStart = rowStart ?? 1;
+ if ( columnSpan !== undefined ) {
+ this.columnEnd = this.columnStart + columnSpan - 1;
+ } else {
+ this.columnEnd = columnEnd ?? this.columnStart;
+ }
+ if ( rowSpan !== undefined ) {
+ this.rowEnd = this.rowStart + rowSpan - 1;
+ } else {
+ this.rowEnd = rowEnd ?? this.rowStart;
+ }
+ }
+
+ get columnSpan() {
+ return this.columnEnd - this.columnStart + 1;
+ }
+
+ get rowSpan() {
+ return this.rowEnd - this.rowStart + 1;
+ }
+
+ contains( column, row ) {
+ return (
+ column >= this.columnStart &&
+ column <= this.columnEnd &&
+ row >= this.rowStart &&
+ row <= this.rowEnd
+ );
+ }
+
+ containsRect( rect ) {
+ return (
+ this.contains( rect.columnStart, rect.rowStart ) &&
+ this.contains( rect.columnEnd, rect.rowEnd )
+ );
+ }
+
+ intersectsRect( rect ) {
+ return (
+ this.columnStart <= rect.columnEnd &&
+ this.columnEnd >= rect.columnStart &&
+ this.rowStart <= rect.rowEnd &&
+ this.rowEnd >= rect.rowStart
+ );
+ }
+}
+
+export function getComputedCSS( element, property ) {
+ return element.ownerDocument.defaultView
+ .getComputedStyle( element )
+ .getPropertyValue( property );
+}
+
+function getGridTracks( template, gap ) {
+ const tracks = [];
+ for ( const size of template.split( ' ' ) ) {
+ const previousTrack = tracks[ tracks.length - 1 ];
+ const start = previousTrack ? previousTrack.end + gap : 0;
+ const end = start + parseFloat( size );
+ const center = ( start + end ) / 2;
+ tracks.push( { start, end, center } );
+ }
+ return tracks;
+}
+
+function getClosestTrack( tracks, position, edge = 'start' ) {
+ return tracks.reduce(
+ ( closest, track, index ) =>
+ Math.abs( track[ edge ] - position ) <
+ Math.abs( tracks[ closest ][ edge ] - position )
+ ? index
+ : closest,
+ 0
+ );
+}
+
+export function getGridCell( gridElement, x, y ) {
+ const columnGap = parseFloat( getComputedCSS( gridElement, 'column-gap' ) );
+ const rowGap = parseFloat( getComputedCSS( gridElement, 'row-gap' ) );
+ const gridColumnTracks = getGridTracks(
+ getComputedCSS( gridElement, 'grid-template-columns' ),
+ columnGap
+ );
+ const gridRowTracks = getGridTracks(
+ getComputedCSS( gridElement, 'grid-template-rows' ),
+ rowGap
+ );
+ const columnIndex = gridColumnTracks.findIndex(
+ ( track ) => x >= track.start && x < track.end
+ );
+ const rowIndex = gridRowTracks.findIndex(
+ ( track ) => y >= track.start && y < track.end
+ );
+ const column = columnIndex === -1 ? null : columnIndex + 1;
+ const row = rowIndex === -1 ? null : rowIndex + 1;
+ return { column, row };
+}
+
+export function getClosestGridCell( gridElement, x, y ) {
+ const columnGap = parseFloat( getComputedCSS( gridElement, 'column-gap' ) );
+ const rowGap = parseFloat( getComputedCSS( gridElement, 'row-gap' ) );
+ const gridColumnTracks = getGridTracks(
+ getComputedCSS( gridElement, 'grid-template-columns' ),
+ columnGap
+ );
+ const gridRowTracks = getGridTracks(
+ getComputedCSS( gridElement, 'grid-template-rows' ),
+ rowGap
+ );
+ const columnIndex = getClosestTrack( gridColumnTracks, x, 'center' );
+ const rowIndex = getClosestTrack( gridRowTracks, y, 'center' );
+ const column = columnIndex === -1 ? null : columnIndex + 1;
+ const row = rowIndex === -1 ? null : rowIndex + 1;
+ return { column, row };
+}
+
+export function getGridRect( gridElement, rect ) {
+ const columnGap = parseFloat( getComputedCSS( gridElement, 'column-gap' ) );
+ const rowGap = parseFloat( getComputedCSS( gridElement, 'row-gap' ) );
+ const gridColumnTracks = getGridTracks(
+ getComputedCSS( gridElement, 'grid-template-columns' ),
+ columnGap
+ );
+ const gridRowTracks = getGridTracks(
+ getComputedCSS( gridElement, 'grid-template-rows' ),
+ rowGap
+ );
+ const columnStart = getClosestTrack( gridColumnTracks, rect.left ) + 1;
+ const rowStart = getClosestTrack( gridRowTracks, rect.top ) + 1;
+ const columnEnd =
+ getClosestTrack( gridColumnTracks, rect.right, 'end' ) + 1;
+ const rowEnd = getClosestTrack( gridRowTracks, rect.bottom, 'end' ) + 1;
+ return new GridRect( {
+ columnStart,
+ columnEnd,
+ rowStart,
+ rowEnd,
+ } );
+}
+
+export function getGridItemRect( gridItemElement ) {
+ return getGridRect(
+ gridItemElement.parentElement,
+ new window.DOMRect(
+ gridItemElement.offsetLeft,
+ gridItemElement.offsetTop,
+ gridItemElement.offsetWidth,
+ gridItemElement.offsetHeight
+ )
+ );
+}
diff --git a/packages/block-editor/src/components/grid-visualizer/grid-item-resizer.js b/packages/block-editor/src/components/grid-visualizer/grid-item-resizer.js
deleted file mode 100644
index 54683e48beeea..0000000000000
--- a/packages/block-editor/src/components/grid-visualizer/grid-item-resizer.js
+++ /dev/null
@@ -1,100 +0,0 @@
-/**
- * WordPress dependencies
- */
-import { ResizableBox } from '@wordpress/components';
-
-/**
- * Internal dependencies
- */
-import { __unstableUseBlockElement as useBlockElement } from '../block-list/use-block-props/use-block-refs';
-import BlockPopoverCover from '../block-popover/cover';
-import { getComputedCSS } from './utils';
-
-export function GridItemResizer( { clientId, onChange } ) {
- const blockElement = useBlockElement( clientId );
- if ( ! blockElement ) {
- return null;
- }
- return (
-
- {
- const gridElement = blockElement.parentElement;
- const columnGap = parseFloat(
- getComputedCSS( gridElement, 'column-gap' )
- );
- const rowGap = parseFloat(
- getComputedCSS( gridElement, 'row-gap' )
- );
- const gridColumnLines = getGridLines(
- getComputedCSS( gridElement, 'grid-template-columns' ),
- columnGap
- );
- const gridRowLines = getGridLines(
- getComputedCSS( gridElement, 'grid-template-rows' ),
- rowGap
- );
- const columnStart = getClosestLine(
- gridColumnLines,
- blockElement.offsetLeft
- );
- const rowStart = getClosestLine(
- gridRowLines,
- blockElement.offsetTop
- );
- const columnEnd = getClosestLine(
- gridColumnLines,
- blockElement.offsetLeft + boxElement.offsetWidth
- );
- const rowEnd = getClosestLine(
- gridRowLines,
- blockElement.offsetTop + boxElement.offsetHeight
- );
- onChange( {
- columnSpan: Math.max( columnEnd - columnStart, 1 ),
- rowSpan: Math.max( rowEnd - rowStart, 1 ),
- } );
- } }
- />
-
- );
-}
-
-function getGridLines( template, gap ) {
- const lines = [ 0 ];
- for ( const size of template.split( ' ' ) ) {
- const line = parseFloat( size );
- lines.push( lines[ lines.length - 1 ] + line + gap );
- }
- return lines;
-}
-
-function getClosestLine( lines, position ) {
- return lines.reduce(
- ( closest, line, index ) =>
- Math.abs( line - position ) <
- Math.abs( lines[ closest ] - position )
- ? index
- : closest,
- 0
- );
-}
diff --git a/packages/block-editor/src/components/grid-visualizer/grid-visualizer.js b/packages/block-editor/src/components/grid-visualizer/grid-visualizer.js
deleted file mode 100644
index 2ca65eb6722e4..0000000000000
--- a/packages/block-editor/src/components/grid-visualizer/grid-visualizer.js
+++ /dev/null
@@ -1,81 +0,0 @@
-/**
- * WordPress dependencies
- */
-import { useState, useEffect } from '@wordpress/element';
-
-/**
- * Internal dependencies
- */
-import { __unstableUseBlockElement as useBlockElement } from '../block-list/use-block-props/use-block-refs';
-import BlockPopoverCover from '../block-popover/cover';
-import { getComputedCSS } from './utils';
-
-export function GridVisualizer( { clientId } ) {
- const blockElement = useBlockElement( clientId );
- if ( ! blockElement ) {
- return null;
- }
- return (
-
-
-
- );
-}
-
-function GridVisualizerGrid( { blockElement } ) {
- const [ gridInfo, setGridInfo ] = useState( () =>
- getGridInfo( blockElement )
- );
- useEffect( () => {
- const observers = [];
- for ( const element of [ blockElement, ...blockElement.children ] ) {
- const observer = new window.ResizeObserver( () => {
- setGridInfo( getGridInfo( blockElement ) );
- } );
- observer.observe( element );
- observers.push( observer );
- }
- return () => {
- for ( const observer of observers ) {
- observer.disconnect();
- }
- };
- }, [ blockElement ] );
- return (
-
- { Array.from( { length: gridInfo.numItems }, ( _, i ) => (
-
- ) ) }
-
- );
-}
-
-function getGridInfo( blockElement ) {
- const gridTemplateColumns = getComputedCSS(
- blockElement,
- 'grid-template-columns'
- );
- const gridTemplateRows = getComputedCSS(
- blockElement,
- 'grid-template-rows'
- );
- const numColumns = gridTemplateColumns.split( ' ' ).length;
- const numRows = gridTemplateRows.split( ' ' ).length;
- const numItems = numColumns * numRows;
- return {
- numItems,
- style: {
- gridTemplateColumns,
- gridTemplateRows,
- gap: getComputedCSS( blockElement, 'gap' ),
- padding: getComputedCSS( blockElement, 'padding' ),
- },
- };
-}
diff --git a/packages/block-editor/src/components/grid-visualizer/style.scss b/packages/block-editor/src/components/grid-visualizer/style.scss
deleted file mode 100644
index 45140e59c7af9..0000000000000
--- a/packages/block-editor/src/components/grid-visualizer/style.scss
+++ /dev/null
@@ -1,33 +0,0 @@
-// TODO: Specificity hacks to get rid of all these darn !importants.
-
-.block-editor-grid-visualizer {
- z-index: z-index(".block-editor-grid-visualizer") !important;
-}
-
-.block-editor-grid-visualizer .components-popover__content * {
- pointer-events: none !important;
-}
-
-.block-editor-grid-visualizer__grid {
- display: grid;
-}
-
-.block-editor-grid-visualizer__item {
- border: $border-width dashed $gray-300;
-}
-
-.block-editor-grid-item-resizer {
- z-index: z-index(".block-editor-grid-visualizer") !important;
-}
-
-.block-editor-grid-item-resizer .components-popover__content * {
- pointer-events: none !important;
-}
-
-.block-editor-grid-item-resizer__box {
- border: $border-width solid var(--wp-admin-theme-color);
-
- .components-resizable-box__handle {
- pointer-events: all !important;
- }
-}
diff --git a/packages/block-editor/src/components/grid-visualizer/utils.js b/packages/block-editor/src/components/grid-visualizer/utils.js
deleted file mode 100644
index a100e596a4e24..0000000000000
--- a/packages/block-editor/src/components/grid-visualizer/utils.js
+++ /dev/null
@@ -1,5 +0,0 @@
-export function getComputedCSS( element, property ) {
- return element.ownerDocument.defaultView
- .getComputedStyle( element )
- .getPropertyValue( property );
-}
diff --git a/packages/block-editor/src/components/use-on-block-drop/index.js b/packages/block-editor/src/components/use-on-block-drop/index.js
index 58ce615436d8e..0e9ad67204199 100644
--- a/packages/block-editor/src/components/use-on-block-drop/index.js
+++ b/packages/block-editor/src/components/use-on-block-drop/index.js
@@ -336,14 +336,40 @@ export default function useOnBlockDrop(
const moveBlocks = useCallback(
( sourceClientIds, sourceRootClientId, insertIndex ) => {
+ const sourceBlocks = getBlocksByClientId( sourceClientIds );
+
+ // TODO: Figure out a better place for this. useOnBlockDrop shouldn't care about layout styles.
+ const unpinBlock = () => {
+ updateBlockAttributes(
+ sourceClientIds,
+ sourceBlocks.reduce( ( accumulator, sourceBlock ) => {
+ if (
+ sourceBlock.attributes.style?.layout?.columnStart ||
+ sourceBlock.attributes.style?.layout?.rowStart
+ ) {
+ const { columnStart, rowStart, ...layout } =
+ sourceBlock.attributes.style.layout;
+ accumulator[ sourceBlock.clientId ] = {
+ style: {
+ ...sourceBlock.attributes.style,
+ layout,
+ },
+ };
+ }
+ return accumulator;
+ }, {} ),
+ /* uniqueByBlock: */ true
+ );
+ };
+
if ( operation === 'replace' ) {
- const sourceBlocks = getBlocksByClientId( sourceClientIds );
const targetBlockClientIds =
getBlockOrder( targetRootClientId );
const targetBlockClientId =
targetBlockClientIds[ targetBlockIndex ];
registry.batch( () => {
+ unpinBlock();
// Remove the source blocks.
removeBlocks( sourceClientIds, false );
// Replace the target block with the source blocks.
@@ -355,12 +381,15 @@ export default function useOnBlockDrop(
);
} );
} else {
- moveBlocksToPosition(
- sourceClientIds,
- sourceRootClientId,
- targetRootClientId,
- insertIndex
- );
+ registry.batch( () => {
+ unpinBlock();
+ moveBlocksToPosition(
+ sourceClientIds,
+ sourceRootClientId,
+ targetRootClientId,
+ insertIndex
+ );
+ } );
}
},
[
diff --git a/packages/block-editor/src/hooks/layout-child.js b/packages/block-editor/src/hooks/layout-child.js
index 4fa641bff203a..a5e9add7b3a46 100644
--- a/packages/block-editor/src/hooks/layout-child.js
+++ b/packages/block-editor/src/hooks/layout-child.js
@@ -10,7 +10,11 @@ import { useSelect } from '@wordpress/data';
import { store as blockEditorStore } from '../store';
import { useStyleOverride } from './utils';
import { useLayout } from '../components/block-list/layout';
-import { GridVisualizer, GridItemResizer } from '../components/grid-visualizer';
+import {
+ GridVisualizer,
+ GridItemResizer,
+ GridItemPinToolbarItem,
+} from '../components/grid-interactivity';
function useBlockPropsChildLayoutStyles( { style } ) {
const shouldRenderChildLayoutStyles = useSelect( ( select ) => {
@@ -141,29 +145,34 @@ function ChildLayoutControlsPure( { clientId, style, setAttributes } ) {
},
[ clientId ]
);
+
if ( parentLayout.type !== 'grid' ) {
return null;
}
if ( ! window.__experimentalEnableGridInteractivity ) {
return null;
}
+
+ function updateLayout( layout ) {
+ setAttributes( {
+ style: {
+ ...style,
+ layout: {
+ ...style?.layout,
+ ...layout,
+ },
+ },
+ } );
+ }
+
return (
<>
-
+ {
- setAttributes( {
- style: {
- ...style,
- layout: {
- ...style?.layout,
- columnSpan,
- rowSpan,
- },
- },
- } );
- } }
+ layout={ style?.layout }
+ onChange={ updateLayout }
/>
>
);
diff --git a/packages/block-editor/src/layouts/grid.js b/packages/block-editor/src/layouts/grid.js
index e71e1739447aa..0282c173a24cc 100644
--- a/packages/block-editor/src/layouts/grid.js
+++ b/packages/block-editor/src/layouts/grid.js
@@ -23,7 +23,7 @@ import { appendSelectors, getBlockGapCSS } from './utils';
import { getGapCSSValue } from '../hooks/gap';
import { shouldSkipSerialization } from '../hooks/utils';
import { LAYOUT_DEFINITIONS } from './definitions';
-import { GridVisualizer } from '../components/grid-visualizer';
+import { GridVisualizer } from '../components/grid-interactivity';
const RANGE_CONTROL_MAX_VALUES = {
px: 600,
@@ -68,7 +68,6 @@ export default {
inspectorControls: function GridLayoutInspectorControls( {
layout = {},
onChange,
- clientId,
layoutBlockSupport = {},
} ) {
const { allowSizingOnChildren = false } = layoutBlockSupport;
@@ -90,14 +89,14 @@ export default {
onChange={ onChange }
/>
) }
- { window.__experimentalEnableGridInteractivity && (
-
- ) }
>
);
},
- toolBarControls: function GridLayoutToolbarControls() {
- return null;
+ toolBarControls: function GridLayoutToolbarControls( { clientId } ) {
+ if ( ! window.__experimentalEnableGridInteractivity ) {
+ return null;
+ }
+ return ;
},
getLayoutStyle: function getLayoutStyle( {
selector,
@@ -136,7 +135,7 @@ export default {
} else if ( minimumColumnWidth ) {
rules.push(
`grid-template-columns: repeat(auto-fill, minmax(min(${ minimumColumnWidth }, 100%), 1fr))`,
- `container-type: inline-size`
+ 'container-type: inline-size'
);
}
diff --git a/packages/block-editor/src/store/reducer.js b/packages/block-editor/src/store/reducer.js
index 13024d4d2e8fa..5766c096faf53 100644
--- a/packages/block-editor/src/store/reducer.js
+++ b/packages/block-editor/src/store/reducer.js
@@ -884,7 +884,7 @@ export const blocks = pipe(
for ( const clientId of action.clientIds ) {
const updatedAttributeEntries = Object.entries(
action.uniqueByBlock
- ? action.attributes[ clientId ]
+ ? action.attributes[ clientId ] ?? {}
: action.attributes ?? {}
);
if ( updatedAttributeEntries.length === 0 ) {
diff --git a/packages/block-editor/src/style.scss b/packages/block-editor/src/style.scss
index 535d1005d274d..d2ed7b96dcefe 100644
--- a/packages/block-editor/src/style.scss
+++ b/packages/block-editor/src/style.scss
@@ -28,7 +28,7 @@
@import "./components/duotone-control/style.scss";
@import "./components/font-appearance-control/style.scss";
@import "./components/global-styles/style.scss";
-@import "./components/grid-visualizer/style.scss";
+@import "./components/grid-interactivity/style.scss";
@import "./components/height-control/style.scss";
@import "./components/image-size-control/style.scss";
@import "./components/inserter-list-item/style.scss";
diff --git a/packages/block-library/src/group/edit.js b/packages/block-library/src/group/edit.js
index a763bc95e60d7..2b4db947da068 100644
--- a/packages/block-library/src/group/edit.js
+++ b/packages/block-library/src/group/edit.js
@@ -109,7 +109,7 @@ function GroupEdit( { attributes, name, setAttributes, clientId } ) {
// Default to the regular appender being rendered.
let renderAppender;
- if ( showPlaceholder ) {
+ if ( showPlaceholder || type === 'grid' ) {
// In the placeholder state, ensure the appender is not rendered.
// This is needed because `...innerBlocksProps` is used in the placeholder
// state so that blocks can dragged onto the placeholder area