diff --git a/packages/block-editor/src/components/block-mover/index.js b/packages/block-editor/src/components/block-mover/index.js index a143ab391a43f..6d4e461e6416b 100644 --- a/packages/block-editor/src/components/block-mover/index.js +++ b/packages/block-editor/src/components/block-mover/index.js @@ -25,7 +25,14 @@ function BlockMover( { isBlockMoverUpButtonDisabled, isBlockMoverDownButtonDisabled, } ) { - const { canMove, rootClientId, isFirst, isLast, orientation } = useSelect( + const { + canMove, + rootClientId, + isFirst, + isLast, + orientation, + isManualGrid, + } = useSelect( ( select ) => { const { getBlockIndex, @@ -33,6 +40,7 @@ function BlockMover( { canMoveBlocks, getBlockOrder, getBlockRootClientId, + getBlockAttributes, } = select( blockEditorStore ); const normalizedClientIds = Array.isArray( clientIds ) ? clientIds @@ -44,6 +52,7 @@ function BlockMover( { normalizedClientIds[ normalizedClientIds.length - 1 ] ); const blockOrder = getBlockOrder( _rootClientId ); + const { layout = {} } = getBlockAttributes( _rootClientId ) ?? {}; return { canMove: canMoveBlocks( clientIds, _rootClientId ), @@ -51,6 +60,10 @@ function BlockMover( { isFirst: firstIndex === 0, isLast: lastIndex === blockOrder.length - 1, orientation: getBlockListSettings( _rootClientId )?.orientation, + isManualGrid: + layout.type === 'grid' && + !! layout.columnCount && + window.__experimentalEnableGridInteractivity, }; }, [ clientIds ] @@ -60,8 +73,6 @@ function BlockMover( { return null; } - const dragHandleLabel = __( 'Drag' ); - return ( ); } 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 dfc4ee69437f6..b27d6cacd0697 100644 --- a/packages/block-editor/src/components/child-layout-control/index.js +++ b/packages/block-editor/src/components/child-layout-control/index.js @@ -14,6 +14,13 @@ import { } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; import { useEffect } from '@wordpress/element'; +import { useSelect, useDispatch } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { useGetNumberOfBlocksBeforeCell } from '../grid-visualizer/use-get-number-of-blocks-before-cell'; +import { store as blockEditorStore } from '../../store'; function helpText( selfStretch, parentLayout ) { const { orientation = 'horizontal' } = parentLayout; @@ -48,21 +55,46 @@ export default function ChildLayoutControl( { isShownByDefault, panelId, } ) { - const { - selfStretch, - flexSize, - columnStart, - rowStart, - columnSpan, - rowSpan, - } = childLayout; const { type: parentType, default: { type: defaultParentType = 'default' } = {}, - orientation = 'horizontal', } = parentLayout ?? {}; const parentLayoutType = parentType || defaultParentType; + if ( parentLayoutType === 'flex' ) { + return ( + + ); + } else if ( parentLayoutType === 'grid' ) { + return ( + + ); + } + + return null; +} + +function FlexControls( { + childLayout, + onChange, + parentLayout, + isShownByDefault, + panelId, +} ) { + const { selfStretch, flexSize } = childLayout; + const { orientation = 'horizontal' } = parentLayout ?? {}; const hasFlexValue = () => !! selfStretch; const flexResetLabel = orientation === 'horizontal' ? __( 'Width' ) : __( 'Height' ); @@ -73,6 +105,96 @@ export default function ChildLayoutControl( { } ); }; + useEffect( () => { + if ( selfStretch === 'fixed' && ! flexSize ) { + onChange( { + ...childLayout, + selfStretch: 'fit', + } ); + } + }, [] ); + + return ( + + { + const newFlexSize = value !== 'fixed' ? null : flexSize; + onChange( { + selfStretch: value, + flexSize: newFlexSize, + } ); + } } + isBlock + > + + + + + { selfStretch === 'fixed' && ( + { + onChange( { + selfStretch, + flexSize: value, + } ); + } } + value={ flexSize } + /> + ) } + + ); +} + +export function childLayoutOrientation( parentLayout ) { + const { orientation = 'horizontal' } = parentLayout; + return orientation === 'horizontal' ? __( 'Width' ) : __( 'Height' ); +} + +function GridControls( { + childLayout, + onChange, + parentLayout, + isShownByDefault, + panelId, +} ) { + const { columnStart, rowStart, columnSpan, rowSpan } = childLayout; + const { columnCount } = parentLayout ?? {}; + const gridColumnNumber = parseInt( columnCount, 10 ) || 3; + const rootClientId = useSelect( ( select ) => + select( blockEditorStore ).getBlockRootClientId( panelId ) + ); + const { moveBlocksToPosition, __unstableMarkNextChangeAsNotPersistent } = + useDispatch( blockEditorStore ); + const getNumberOfBlocksBeforeCell = useGetNumberOfBlocksBeforeCell( + rootClientId, + gridColumnNumber + ); const hasStartValue = () => !! columnStart || !! rowStart; const hasSpanValue = () => !! columnSpan || !! rowSpan; const resetGridStarts = () => { @@ -88,184 +210,127 @@ export default function ChildLayoutControl( { } ); }; - useEffect( () => { - if ( selfStretch === 'fixed' && ! flexSize ) { - onChange( { - ...childLayout, - selfStretch: 'fit', - } ); - } - }, [] ); - return ( <> - { parentLayoutType === 'flex' && ( - + { + onChange( { + columnStart, + rowStart, + rowSpan, + columnSpan: value, + } ); + } } + value={ columnSpan } + min={ 1 } + /> + { + onChange( { + columnStart, + rowStart, + columnSpan, + rowSpan: value, + } ); + } } + value={ rowSpan } + min={ 1 } + /> + + { window.__experimentalEnableGridInteractivity && columnCount && ( + // Use Flex with an explicit width on the FlexItem instead of HStack to + // work around an issue in webkit where inputs with a max attribute are + // sized incorrectly. + - { - const newFlexSize = - value !== 'fixed' ? null : flexSize; - onChange( { - selfStretch: value, - flexSize: newFlexSize, - } ); - } } - isBlock - > - - - - - { selfStretch === 'fixed' && ( - { - onChange( { - selfStretch, - flexSize: value, - } ); - } } - value={ flexSize } - /> - ) } - - ) } - { parentLayoutType === 'grid' && ( - <> - + { onChange( { - columnStart, + columnStart: value, rowStart, + columnSpan, rowSpan, - columnSpan: value, } ); + __unstableMarkNextChangeAsNotPersistent(); + moveBlocksToPosition( + [ panelId ], + rootClientId, + rootClientId, + getNumberOfBlocksBeforeCell( + value, + rowStart + ) + ); } } - value={ columnSpan } + value={ columnStart } min={ 1 } + max={ + gridColumnNumber + ? gridColumnNumber - ( columnSpan ?? 1 ) + 1 + : undefined + } /> + + { onChange( { columnStart, - rowStart, + rowStart: value, columnSpan, - rowSpan: value, + rowSpan, } ); + __unstableMarkNextChangeAsNotPersistent(); + moveBlocksToPosition( + [ panelId ], + rootClientId, + rootClientId, + getNumberOfBlocksBeforeCell( + columnStart, + value + ) + ); } } - value={ rowSpan } + value={ rowStart } min={ 1 } + max={ + parentLayout?.rowCount + ? parentLayout.rowCount - + ( rowSpan ?? 1 ) + + 1 + : undefined + } /> - - { window.__experimentalEnableGridInteractivity && ( - // Use Flex with an explicit width on the FlexItem instead of HStack to - // work around an issue in webkit where inputs with a max attribute are - // sized incorrectly. - - - { - onChange( { - columnStart: value, - rowStart, - columnSpan, - rowSpan, - } ); - } } - value={ columnStart } - min={ 1 } - max={ - parentLayout?.columnCount - ? parentLayout.columnCount - - ( columnSpan ?? 1 ) + - 1 - : undefined - } - /> - - - { - onChange( { - columnStart, - rowStart: value, - columnSpan, - rowSpan, - } ); - } } - value={ rowStart } - min={ 1 } - max={ - parentLayout?.rowCount - ? parentLayout.rowCount - - ( rowSpan ?? 1 ) + - 1 - : undefined - } - /> - - - ) } - + + ) } ); } - -export function childLayoutOrientation( parentLayout ) { - const { orientation = 'horizontal' } = parentLayout; - - return orientation === 'horizontal' ? __( 'Width' ) : __( 'Height' ); -} diff --git a/packages/block-editor/src/components/grid-visualizer/grid-item-movers.js b/packages/block-editor/src/components/grid-visualizer/grid-item-movers.js new file mode 100644 index 0000000000000..4f1d3853568fd --- /dev/null +++ b/packages/block-editor/src/components/grid-visualizer/grid-item-movers.js @@ -0,0 +1,128 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { ToolbarButton } from '@wordpress/components'; +import { arrowLeft, arrowUp, arrowDown, arrowRight } from '@wordpress/icons'; +import { useDispatch } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import BlockControls from '../block-controls'; +import { useGetNumberOfBlocksBeforeCell } from './use-get-number-of-blocks-before-cell'; +import { store as blockEditorStore } from '../../store'; + +export function GridItemMovers( { + layout, + parentLayout, + onChange, + gridClientId, + blockClientId, +} ) { + const { moveBlocksToPosition, __unstableMarkNextChangeAsNotPersistent } = + useDispatch( blockEditorStore ); + + const columnStart = layout?.columnStart ?? 1; + const rowStart = layout?.rowStart ?? 1; + const columnSpan = layout?.columnSpan ?? 1; + const rowSpan = layout?.rowSpan ?? 1; + const columnEnd = columnStart + columnSpan - 1; + const rowEnd = rowStart + rowSpan - 1; + const columnCount = parentLayout?.columnCount; + const rowCount = parentLayout?.rowCount; + + const columnCountNumber = parseInt( columnCount, 10 ); + const rowStartNumber = parseInt( rowStart, 10 ); + const columnStartNumber = parseInt( columnStart, 10 ); + + const getNumberOfBlocksBeforeCell = useGetNumberOfBlocksBeforeCell( + gridClientId, + columnCountNumber + ); + + return ( + + { + onChange( { + rowStart: rowStart - 1, + } ); + __unstableMarkNextChangeAsNotPersistent(); + moveBlocksToPosition( + [ blockClientId ], + gridClientId, + gridClientId, + getNumberOfBlocksBeforeCell( + columnStartNumber, + rowStartNumber - 1 + ) + ); + } } + /> + = rowCount } + onClick={ () => { + onChange( { + rowStart: rowStart + 1, + } ); + __unstableMarkNextChangeAsNotPersistent(); + moveBlocksToPosition( + [ blockClientId ], + gridClientId, + gridClientId, + getNumberOfBlocksBeforeCell( + columnStartNumber, + rowStartNumber + 1 + ) + ); + } } + /> + { + onChange( { + columnStart: columnStartNumber - 1, + } ); + __unstableMarkNextChangeAsNotPersistent(); + moveBlocksToPosition( + [ blockClientId ], + gridClientId, + gridClientId, + getNumberOfBlocksBeforeCell( + columnStartNumber - 1, + rowStartNumber + ) + ); + } } + /> + = columnCount } + onClick={ () => { + onChange( { + columnStart: columnStartNumber + 1, + } ); + __unstableMarkNextChangeAsNotPersistent(); + moveBlocksToPosition( + [ blockClientId ], + gridClientId, + gridClientId, + getNumberOfBlocksBeforeCell( + columnStartNumber + 1, + rowStartNumber + ) + ); + } } + /> + + ); +} 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 index 21e9bfccee754..a5847d852e0a9 100644 --- a/packages/block-editor/src/components/grid-visualizer/grid-item-resizer.js +++ b/packages/block-editor/src/components/grid-visualizer/grid-item-resizer.js @@ -9,11 +9,17 @@ import { useState, useEffect } from '@wordpress/element'; */ import { __unstableUseBlockElement as useBlockElement } from '../block-list/use-block-props/use-block-refs'; import BlockPopoverCover from '../block-popover/cover'; -import { getComputedCSS } from './utils'; +import { getComputedCSS, getGridTracks, getClosestTrack } from './utils'; -export function GridItemResizer( { clientId, bounds, onChange } ) { +export function GridItemResizer( { + clientId, + bounds, + onChange, + parentLayout, +} ) { const blockElement = useBlockElement( clientId ); const rootBlockElement = blockElement?.parentElement; + const { columnCount } = parentLayout; if ( ! blockElement || ! rootBlockElement ) { return null; @@ -26,6 +32,9 @@ export function GridItemResizer( { clientId, bounds, onChange } ) { blockElement={ blockElement } rootBlockElement={ rootBlockElement } onChange={ onChange } + isManualGrid={ + !! columnCount && window.__experimentalEnableGridInteractivity + } /> ); } @@ -36,6 +45,7 @@ function GridItemResizerInner( { blockElement, rootBlockElement, onChange, + isManualGrid, } ) { const [ resizeDirection, setResizeDirection ] = useState( null ); const [ enableSide, setEnableSide ] = useState( { @@ -171,59 +181,11 @@ function GridItemResizerInner( { onChange( { columnSpan: columnEnd - columnStart + 1, rowSpan: rowEnd - rowStart + 1, + columnStart: isManualGrid ? columnStart : undefined, + rowStart: isManualGrid ? rowStart : undefined, } ); } } /> ); } - -/** - * Given a grid-template-columns or grid-template-rows CSS property value, gets the start and end - * position in pixels of each grid track. - * - * https://css-tricks.com/snippets/css/complete-guide-grid/#aa-grid-track - * - * @param {string} template The grid-template-columns or grid-template-rows CSS property value. - * Only supports fixed sizes in pixels. - * @param {number} gap The gap between grid tracks in pixels. - * - * @return {Array<{start: number, end: number}>} An array of objects with the start and end - * position in pixels of each grid track. - */ -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 ); - tracks.push( { start, end } ); - } - return tracks; -} - -/** - * Given an array of grid tracks and a position in pixels, gets the index of the closest track to - * that position. - * - * https://css-tricks.com/snippets/css/complete-guide-grid/#aa-grid-track - * - * @param {Array<{start: number, end: number}>} tracks An array of objects with the start and end - * position in pixels of each grid track. - * @param {number} position The position in pixels. - * @param {string} edge The edge of the track to compare the - * position to. Either 'start' or 'end'. - * - * @return {number} The index of the closest track to the position. 0-based, unlike CSS grid which - * is 1-based. - */ -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 - ); -} diff --git a/packages/block-editor/src/components/grid-visualizer/grid-visualizer.js b/packages/block-editor/src/components/grid-visualizer/grid-visualizer.js index cff5efc5218e1..5e639615a062c 100644 --- a/packages/block-editor/src/components/grid-visualizer/grid-visualizer.js +++ b/packages/block-editor/src/components/grid-visualizer/grid-visualizer.js @@ -1,101 +1,267 @@ +/** + * External dependencies + */ +import clsx from 'clsx'; + /** * WordPress dependencies */ import { useState, useEffect, forwardRef } from '@wordpress/element'; -import { useSelect } from '@wordpress/data'; +import { useSelect, useDispatch } from '@wordpress/data'; +import { __experimentalUseDropZone as useDropZone } from '@wordpress/compose'; /** * Internal dependencies */ import { __unstableUseBlockElement as useBlockElement } from '../block-list/use-block-props/use-block-refs'; import BlockPopoverCover from '../block-popover/cover'; +import { range, GridRect, getGridInfo } from './utils'; import { store as blockEditorStore } from '../../store'; -import { getComputedCSS } from './utils'; +import { useGetNumberOfBlocksBeforeCell } from './use-get-number-of-blocks-before-cell'; -export function GridVisualizer( { clientId, contentRef } ) { +export function GridVisualizer( { clientId, contentRef, parentLayout } ) { const isDistractionFree = useSelect( ( select ) => select( blockEditorStore ).getSettings().isDistractionFree, [] ); - const blockElement = useBlockElement( clientId ); + const gridElement = useBlockElement( clientId ); - if ( isDistractionFree || ! blockElement ) { + if ( isDistractionFree || ! gridElement ) { return null; } + const isManualGrid = + parentLayout?.columnCount && + window.__experimentalEnableGridInteractivity; return ( - - - + gridElement={ gridElement } + isManualGrid={ isManualGrid } + ref={ contentRef } + /> ); } -const GridVisualizerGrid = forwardRef( ( { blockElement }, ref ) => { - 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(); +const GridVisualizerGrid = forwardRef( + ( { clientId, gridElement, isManualGrid }, ref ) => { + const [ gridInfo, setGridInfo ] = useState( () => + getGridInfo( gridElement ) + ); + const [ isDroppingAllowed, setIsDroppingAllowed ] = useState( false ); + const [ highlightedRect, setHighlightedRect ] = useState( null ); + + useEffect( () => { + const observers = []; + for ( const element of [ gridElement, ...gridElement.children ] ) { + const observer = new window.ResizeObserver( () => { + setGridInfo( getGridInfo( gridElement ) ); + } ); + observer.observe( element ); + observers.push( observer ); + } + return () => { + for ( const observer of observers ) { + observer.disconnect(); + } + }; + }, [ gridElement ] ); + + useEffect( () => { + function onGlobalDrag() { + setIsDroppingAllowed( true ); } - }; - }, [ blockElement ] ); + function onGlobalDragEnd() { + setIsDroppingAllowed( false ); + } + document.addEventListener( 'drag', onGlobalDrag ); + document.addEventListener( 'dragend', onGlobalDragEnd ); + return () => { + document.removeEventListener( 'drag', onGlobalDrag ); + document.removeEventListener( 'dragend', onGlobalDragEnd ); + }; + }, [] ); + + return ( + +
+ { isManualGrid + ? range( 1, gridInfo.numRows ).map( ( row ) => + range( 1, gridInfo.numColumns ).map( + ( column ) => ( + + + + ) + ) + ) + : Array.from( + { length: gridInfo.numItems }, + ( _, i ) => ( + + ) + ) } +
+
+ ); + } +); + +function GridVisualizerCell( { color, children } ) { return (
- { Array.from( { length: gridInfo.numItems }, ( _, i ) => ( -
- ) ) } + { children }
); -} ); +} + +function GridVisualizerDropZone( { + column, + row, + gridClientId, + gridInfo, + highlightedRect, + setHighlightedRect, +} ) { + const { getBlockAttributes } = useSelect( blockEditorStore ); + const { + updateBlockAttributes, + moveBlocksToPosition, + __unstableMarkNextChangeAsNotPersistent, + } = useDispatch( blockEditorStore ); -function getGridInfo( blockElement ) { - const gridTemplateColumns = getComputedCSS( - blockElement, - 'grid-template-columns' + const getNumberOfBlocksBeforeCell = useGetNumberOfBlocksBeforeCell( + gridClientId, + gridInfo.numColumns ); - const gridTemplateRows = getComputedCSS( - blockElement, - 'grid-template-rows' + + const ref = useDropZoneWithValidation( { + validateDrag( srcClientId ) { + 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 ); + return isInBounds; + }, + 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 ) { + setHighlightedRect( null ); + const attributes = getBlockAttributes( srcClientId ); + updateBlockAttributes( srcClientId, { + style: { + ...attributes.style, + layout: { + ...attributes.style?.layout, + columnStart: column, + rowStart: row, + }, + }, + } ); + __unstableMarkNextChangeAsNotPersistent(); + moveBlocksToPosition( + [ srcClientId ], + gridClientId, + gridClientId, + getNumberOfBlocksBeforeCell( column, row ) + ); + }, + } ); + + const isHighlighted = highlightedRect?.contains( column, row ) ?? false; + + return ( +
); - const numColumns = gridTemplateColumns.split( ' ' ).length; - const numRows = gridTemplateRows.split( ' ' ).length; - const numItems = numColumns * numRows; - return { - numItems, - currentColor: getComputedCSS( blockElement, 'color' ), - style: { - gridTemplateColumns, - gridTemplateRows, - gap: getComputedCSS( blockElement, 'gap' ), - padding: getComputedCSS( blockElement, 'padding' ), +} + +function useDropZoneWithValidation( { + validateDrag, + onDragEnter, + onDragLeave, + onDrop, +} ) { + const { getDraggedBlockClientIds } = useSelect( blockEditorStore ); + return useDropZone( { + onDragEnter() { + const [ srcClientId ] = getDraggedBlockClientIds(); + if ( srcClientId && validateDrag( srcClientId ) ) { + onDragEnter( srcClientId ); + } + }, + onDragLeave() { + onDragLeave(); + }, + onDrop() { + const [ srcClientId ] = getDraggedBlockClientIds(); + if ( srcClientId && validateDrag( srcClientId ) ) { + onDrop( srcClientId ); + } }, - }; + } ); } diff --git a/packages/block-editor/src/components/grid-visualizer/index.js b/packages/block-editor/src/components/grid-visualizer/index.js index add845d702203..a1552610102a0 100644 --- a/packages/block-editor/src/components/grid-visualizer/index.js +++ b/packages/block-editor/src/components/grid-visualizer/index.js @@ -1,2 +1,4 @@ export { GridVisualizer } from './grid-visualizer'; export { GridItemResizer } from './grid-item-resizer'; +export { GridItemMovers } from './grid-item-movers'; +export { useGridLayoutSync } from './use-grid-layout-sync'; diff --git a/packages/block-editor/src/components/grid-visualizer/style.scss b/packages/block-editor/src/components/grid-visualizer/style.scss index 2adaf18f52470..dfb3e57f84ae0 100644 --- a/packages/block-editor/src/components/grid-visualizer/style.scss +++ b/packages/block-editor/src/components/grid-visualizer/style.scss @@ -1,34 +1,63 @@ -// 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; + // 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__item { - outline: 1px solid transparent; - border-radius: $radius-block-ui; +.block-editor-grid-visualizer__cell { + align-items: center; + display: flex; + justify-content: center; } -.block-editor-grid-item-resizer { - z-index: z-index(".block-editor-grid-visualizer") !important; +.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 .components-popover__content * { - pointer-events: none !important; +.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 { - pointer-events: all !important; + // 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-visualizer/use-get-number-of-blocks-before-cell.js b/packages/block-editor/src/components/grid-visualizer/use-get-number-of-blocks-before-cell.js new file mode 100644 index 0000000000000..11e991c432080 --- /dev/null +++ b/packages/block-editor/src/components/grid-visualizer/use-get-number-of-blocks-before-cell.js @@ -0,0 +1,30 @@ +/** + * WordPress dependencies + */ +import { useSelect } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { store as blockEditorStore } from '../../store'; + +export function useGetNumberOfBlocksBeforeCell( gridClientId, numColumns ) { + const { getBlockOrder, getBlockAttributes } = useSelect( blockEditorStore ); + + const getNumberOfBlocksBeforeCell = ( column, row ) => { + const targetIndex = ( row - 1 ) * numColumns + column - 1; + + let count = 0; + for ( const clientId of getBlockOrder( gridClientId ) ) { + const { columnStart, rowStart } = + getBlockAttributes( clientId ).style?.layout ?? {}; + const cellIndex = ( rowStart - 1 ) * numColumns + columnStart - 1; + if ( cellIndex < targetIndex ) { + count++; + } + } + return count; + }; + + return getNumberOfBlocksBeforeCell; +} diff --git a/packages/block-editor/src/components/grid-visualizer/use-grid-layout-sync.js b/packages/block-editor/src/components/grid-visualizer/use-grid-layout-sync.js new file mode 100644 index 0000000000000..6a3a05e52fcb9 --- /dev/null +++ b/packages/block-editor/src/components/grid-visualizer/use-grid-layout-sync.js @@ -0,0 +1,167 @@ +/** + * WordPress dependencies + */ +import { useDispatch, useSelect } from '@wordpress/data'; +import { useEffect } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { store as blockEditorStore } from '../../store'; +import { GridRect } from './utils'; + +export function useGridLayoutSync( { clientId: gridClientId } ) { + const { gridLayout, blockOrder } = useSelect( + ( select ) => { + const { getBlockAttributes, getBlockOrder } = + select( blockEditorStore ); + return { + gridLayout: getBlockAttributes( gridClientId ).layout ?? {}, + blockOrder: getBlockOrder( gridClientId ), + }; + }, + [ gridClientId ] + ); + + const { getBlockAttributes } = useSelect( blockEditorStore ); + const { updateBlockAttributes, __unstableMarkNextChangeAsNotPersistent } = + useDispatch( blockEditorStore ); + + useEffect( () => { + const updates = {}; + + const { columnCount, rowCount = 2 } = gridLayout; + const isManualGrid = !! columnCount; + + if ( isManualGrid ) { + const rects = []; + let cellsTaken = 0; + + // Respect the position of blocks that already have a columnStart and rowStart value. + for ( const clientId of blockOrder ) { + const attributes = getBlockAttributes( clientId ); + const { + columnStart, + rowStart, + columnSpan = 1, + rowSpan = 1, + } = attributes.style?.layout || {}; + cellsTaken += columnSpan * rowSpan; + if ( ! columnStart || ! rowStart ) { + continue; + } + rects.push( + new GridRect( { + columnStart, + rowStart, + columnSpan, + rowSpan, + } ) + ); + } + + // Ensure there's enough rows to fit all blocks. + const minimumNeededRows = Math.ceil( cellsTaken / columnCount ); + if ( rowCount < minimumNeededRows ) { + updates[ gridClientId ] = { + layout: { + ...gridLayout, + rowCount: minimumNeededRows, + }, + }; + } + + // When in manual mode, ensure that every block has a columnStart and rowStart value. + for ( const clientId of blockOrder ) { + const attributes = getBlockAttributes( clientId ); + const { columnStart, rowStart, columnSpan, rowSpan } = + attributes.style?.layout || {}; + if ( columnStart && rowStart ) { + continue; + } + const [ newColumnStart, newRowStart ] = getFirstEmptyCell( + rects, + columnCount, + minimumNeededRows, + columnSpan, + rowSpan + ); + rects.push( + new GridRect( { + columnStart: newColumnStart, + rowStart: newRowStart, + columnSpan, + rowSpan, + } ) + ); + updates[ clientId ] = { + style: { + ...attributes.style, + layout: { + ...attributes.style?.layout, + columnStart: newColumnStart, + rowStart: newRowStart, + }, + }, + }; + } + } else { + // When in auto mode, remove all of the columnStart and rowStart values. + for ( const clientId of blockOrder ) { + const attributes = getBlockAttributes( clientId ); + const { columnStart, rowStart, ...layout } = + attributes.style?.layout || {}; + // Only update attributes if columnStart or rowStart are set. + if ( columnStart || rowStart ) { + updates[ clientId ] = { + style: { + ...attributes.style, + layout, + }, + }; + } + } + } + + if ( Object.keys( updates ).length ) { + __unstableMarkNextChangeAsNotPersistent(); + updateBlockAttributes( + Object.keys( updates ), + updates, + /* uniqueByBlock: */ true + ); + } + }, [ + // Actual deps to sync: + gridClientId, + gridLayout, + blockOrder, + // Needed for linter: + __unstableMarkNextChangeAsNotPersistent, + getBlockAttributes, + updateBlockAttributes, + ] ); +} + +function getFirstEmptyCell( + rects, + columnCount, + rowCount, + columnSpan = 1, + rowSpan = 1 +) { + for ( let row = 1; row <= rowCount; row++ ) { + for ( let column = 1; column <= columnCount; column++ ) { + const rect = new GridRect( { + columnStart: column, + rowStart: row, + columnSpan, + rowSpan, + } ); + if ( ! rects.some( ( r ) => r.intersectsRect( rect ) ) ) { + return [ column, row ]; + } + } + } + return [ 1, 1 ]; +} diff --git a/packages/block-editor/src/components/grid-visualizer/utils.js b/packages/block-editor/src/components/grid-visualizer/utils.js index a100e596a4e24..fc012c645f091 100644 --- a/packages/block-editor/src/components/grid-visualizer/utils.js +++ b/packages/block-editor/src/components/grid-visualizer/utils.js @@ -1,5 +1,178 @@ +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 ); } + +/** + * Given a grid-template-columns or grid-template-rows CSS property value, gets the start and end + * position in pixels of each grid track. + * + * https://css-tricks.com/snippets/css/complete-guide-grid/#aa-grid-track + * + * @param {string} template The grid-template-columns or grid-template-rows CSS property value. + * Only supports fixed sizes in pixels. + * @param {number} gap The gap between grid tracks in pixels. + * + * @return {Array<{start: number, end: number}>} An array of objects with the start and end + * position in pixels of each grid track. + */ +export 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 ); + tracks.push( { start, end } ); + } + return tracks; +} + +/** + * Given an array of grid tracks and a position in pixels, gets the index of the closest track to + * that position. + * + * https://css-tricks.com/snippets/css/complete-guide-grid/#aa-grid-track + * + * @param {Array<{start: number, end: number}>} tracks An array of objects with the start and end + * position in pixels of each grid track. + * @param {number} position The position in pixels. + * @param {string} edge The edge of the track to compare the + * position to. Either 'start' or 'end'. + * + * @return {number} The index of the closest track to the position. 0-based, unlike CSS grid which + * is 1-based. + */ +export 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 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 + ) + ); +} + +export 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, + currentColor: getComputedCSS( gridElement, 'color' ), + style: { + gridTemplateColumns, + gridTemplateRows, + gap: getComputedCSS( gridElement, 'gap' ), + padding: getComputedCSS( gridElement, 'padding' ), + }, + }; +} diff --git a/packages/block-editor/src/components/inner-blocks/index.js b/packages/block-editor/src/components/inner-blocks/index.js index 49ab36499692b..d46f28d1e19f2 100644 --- a/packages/block-editor/src/components/inner-blocks/index.js +++ b/packages/block-editor/src/components/inner-blocks/index.js @@ -267,7 +267,9 @@ export function useInnerBlocksProps( props = {}, options = {} ) { const ref = useMergeRefs( [ props.ref, - __unstableDisableDropZone || isDropZoneDisabled + __unstableDisableDropZone || + isDropZoneDisabled || + ( layout?.columnCount && window.__experimentalEnableGridInteractivity ) ? null : blockDropZoneRef, ] ); diff --git a/packages/block-editor/src/hooks/layout-child.js b/packages/block-editor/src/hooks/layout-child.js index 860b4aaf04179..d92d1069c2e33 100644 --- a/packages/block-editor/src/hooks/layout-child.js +++ b/packages/block-editor/src/hooks/layout-child.js @@ -11,7 +11,11 @@ import { useState } from '@wordpress/element'; 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, + GridItemMovers, +} from '../components/grid-visualizer'; function useBlockPropsChildLayoutStyles( { style } ) { const shouldRenderChildLayoutStyles = useSelect( ( select ) => { @@ -135,10 +139,12 @@ function useBlockPropsChildLayoutStyles( { style } ) { } function ChildLayoutControlsPure( { clientId, style, setAttributes } ) { + const parentLayout = useLayout() || {}; const { type: parentLayoutType = 'default', allowSizingOnChildren = false, - } = useLayout() || {}; + columnCount, + } = parentLayout; const rootClientId = useSelect( ( select ) => { @@ -154,29 +160,43 @@ function ChildLayoutControlsPure( { clientId, style, setAttributes } ) { return null; } + const isManualGrid = !! columnCount; + + function updateLayout( layout ) { + setAttributes( { + style: { + ...style, + layout: { + ...style?.layout, + ...layout, + }, + }, + } ); + } + return ( <> { allowSizingOnChildren && ( { - setAttributes( { - style: { - ...style, - layout: { - ...style?.layout, - columnSpan, - rowSpan, - }, - }, - } ); - } } + onChange={ updateLayout } + parentLayout={ parentLayout } + /> + ) } + { isManualGrid && window.__experimentalEnableGridInteractivity && ( + ) } diff --git a/packages/block-editor/src/layouts/grid.js b/packages/block-editor/src/layouts/grid.js index 4528de117c45b..6a42d6898697f 100644 --- a/packages/block-editor/src/layouts/grid.js +++ b/packages/block-editor/src/layouts/grid.js @@ -23,7 +23,10 @@ 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, + useGridLayoutSync, +} from '../components/grid-visualizer'; const RANGE_CONTROL_MAX_VALUES = { px: 600, @@ -93,7 +96,14 @@ export default { ); }, toolBarControls: function GridLayoutToolbarControls( { clientId } ) { - return ; + return ( + <> + { window.__experimentalEnableGridInteractivity && ( + + ) } + + + ); }, getLayoutStyle: function getLayoutStyle( { selector, @@ -245,9 +255,6 @@ function GridLayoutColumnsAndRowsControl( { return ( <>
- - { __( 'Columns' ) } - + - - onChange( { - ...layout, - columnCount: value, - } ) - } - min={ 1 } - max={ 16 } - withInputField={ false } - label={ __( 'Columns' ) } - hideLabelFromVision - /> + { window.__experimentalEnableGridInteractivity && + allowSizingOnChildren ? ( + { + onChange( { + ...layout, + rowCount: value, + } ); + } } + value={ rowCount } + min={ 1 } + label={ __( 'Rows' ) } + /> + ) : ( + + onChange( { + ...layout, + columnCount: value, + } ) + } + min={ 1 } + max={ 16 } + withInputField={ false } + label={ __( 'Columns' ) } + hideLabelFromVision + /> + ) }
- { allowSizingOnChildren && - window.__experimentalEnableGridInteractivity && ( -
- - { __( 'Rows' ) } - - - - { - onChange( { - ...layout, - rowCount: value, - } ); - } } - value={ rowCount } - min={ 1 } - label={ __( 'Rows' ) } - hideLabelFromVision - /> - - - - onChange( { - ...layout, - rowCount: value, - } ) - } - min={ 1 } - max={ 16 } - withInputField={ false } - label={ __( 'Rows' ) } - hideLabelFromVision - /> - - -
- ) } ); } @@ -366,10 +348,19 @@ function GridLayoutTypeControl( { layout, onChange } ) { return ( ); } + +function GridLayoutSync( props ) { + useGridLayoutSync( props ); +}