diff --git a/package-lock.json b/package-lock.json index 7082f93ae92e6..df93443f62616 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10093,12 +10093,21 @@ "lodash": "^4.17.15", "memize": "^1.0.5", "react-autosize-textarea": "^3.0.2", + "react-grid-layout": "^0.17.1", "react-spring": "^8.0.19", "redux-multi": "^0.1.12", "refx": "^3.0.0", "rememo": "^3.0.0", "tinycolor2": "^1.4.1", - "traverse": "^0.6.6" + "traverse": "^0.6.6", + "uuid": "^3.3.3" + }, + "dependencies": { + "uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==" + } } }, "@wordpress/block-library": { @@ -19703,7 +19712,7 @@ }, "node-pre-gyp": { "version": "0.12.0", - "resolved": "https://registry.npmjs.org/node-pre-gyp/-/node-pre-gyp-0.12.0.tgz", + "resolved": false, "integrity": "sha512-4KghwV8vH5k+g2ylT+sLTjy5wmUOb9vPhnM8NHvRf9dHmnW/CndrFXy2aRPaPST6dugXSdHXfeaHQm77PIz/1A==", "dev": true, "optional": true, @@ -19722,7 +19731,7 @@ }, "nopt": { "version": "4.0.1", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-4.0.1.tgz", + "resolved": false, "integrity": "sha1-0NRoWv1UFRk8jHUFYC0NF81kR00=", "dev": true, "optional": true, @@ -26565,8 +26574,7 @@ "lodash.isequal": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", - "integrity": "sha1-QVxEePK8wwEgwizhDtMib30+GOA=", - "dev": true + "integrity": "sha1-QVxEePK8wwEgwizhDtMib30+GOA=" }, "lodash.ismatch": { "version": "4.4.0", @@ -33209,7 +33217,6 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.2.0.tgz", "integrity": "sha512-5wFq//gEoeTYprnd4ze8GrFc+Rbnx+9RkOMR3vk4EbWxj02U6L6T3yrlKeiw4X5CtjD2ma2+b3WujghcXNRzkw==", - "dev": true, "requires": { "classnames": "^2.2.5", "prop-types": "^15.6.0" @@ -33268,6 +33275,18 @@ "use-sidecar": "^1.0.1" } }, + "react-grid-layout": { + "version": "0.17.1", + "resolved": "https://registry.npmjs.org/react-grid-layout/-/react-grid-layout-0.17.1.tgz", + "integrity": "sha512-L+wHFevK+klKvoAHuHn4Q5qHtrW+4zCj0F3QnpR7wZbkZPmrdaWC7cztFLXwINq6WnOWGE22BCTCDCHJi7dVDw==", + "requires": { + "classnames": "2.x", + "lodash.isequal": "^4.0.0", + "prop-types": "^15.0.0", + "react-draggable": "^4.0.0", + "react-resizable": "^1.9.0" + } + }, "react-helmet-async": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/react-helmet-async/-/react-helmet-async-1.0.4.tgz", @@ -33873,6 +33892,15 @@ "integrity": "sha512-ITw8t/HOFNose2yf1y9pPFSSeB9ISOq2JdHpuZvj/Qb+iSsLml8GkkHdDlURzieO7B3dFDtMrrneZLl3N5z/hg==", "dev": true }, + "react-resizable": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/react-resizable/-/react-resizable-1.10.1.tgz", + "integrity": "sha512-Jd/bKOKx6+19NwC4/aMLRu/J9/krfxlDnElP41Oc+oLiUWs/zwV1S9yBfBZRnqAwQb6vQ/HRSk3bsSWGSgVbpw==", + "requires": { + "prop-types": "15.x", + "react-draggable": "^4.0.3" + } + }, "react-resize-aware": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/react-resize-aware/-/react-resize-aware-3.0.0.tgz", diff --git a/packages/block-editor/package.json b/packages/block-editor/package.json index 9f8f952fbbb15..400d401dd18f4 100644 --- a/packages/block-editor/package.json +++ b/packages/block-editor/package.json @@ -52,12 +52,14 @@ "lodash": "^4.17.15", "memize": "^1.0.5", "react-autosize-textarea": "^3.0.2", + "react-grid-layout": "^0.17.1", "react-spring": "^8.0.19", "redux-multi": "^0.1.12", "refx": "^3.0.0", "rememo": "^3.0.0", "tinycolor2": "^1.4.1", - "traverse": "^0.6.6" + "traverse": "^0.6.6", + "uuid": "^3.3.3" }, "publishConfig": { "access": "public" diff --git a/packages/block-editor/src/components/block-list/block.js b/packages/block-editor/src/components/block-list/block.js index 3fb84e824f108..3055671c2f002 100644 --- a/packages/block-editor/src/components/block-list/block.js +++ b/packages/block-editor/src/components/block-list/block.js @@ -337,7 +337,7 @@ function BlockListBlock( { } const applyWithSelect = withSelect( - ( select, { clientId, rootClientId, isLargeViewport } ) => { + ( select, { clientId, rootClientId, isLargeViewport, isLocked } ) => { const { isBlockSelected, isAncestorMultiSelected, @@ -393,7 +393,7 @@ const applyWithSelect = withSelect( : null, isEmptyDefaultBlock: name && isUnmodifiedDefaultBlock( { name, attributes } ), - isLocked: !! templateLock, + isLocked: isLocked || !! templateLock, isFocusMode: focusMode && isLargeViewport, isNavigationMode: isNavigationMode(), isRTL, diff --git a/packages/block-editor/src/components/block-list/grid-utils.js b/packages/block-editor/src/components/block-list/grid-utils.js new file mode 100644 index 0000000000000..c7417f6675f33 --- /dev/null +++ b/packages/block-editor/src/components/block-list/grid-utils.js @@ -0,0 +1,167 @@ +/** + * External dependencies + */ +import uuid from 'uuid/v4'; + +export function createInitialLayouts( grid, blockClientIds ) { + // Hydrate grid layout, if any, with new block client IDs. + return grid ? + grid.map( ( item, i ) => ( { + ...item, + i: `block-${ blockClientIds[ i ] }`, + } ) ) : + []; +} + +export function appendNewBlocks( + nextLayout, + lastClickedBlockAppenderId, + prevBlockClientIds, + blockClientIds +) { + if ( + blockClientIds.length && + ! prevBlockClientIds.includes( blockClientIds[ blockClientIds.length - 1 ] ) + ) { + // If a block client ID has been added, make its block's position and dimensions + // that of the last clicked block appender, since it must be the one that added it. + const appenderItem = nextLayout.find( + ( item ) => item.i === lastClickedBlockAppenderId + ); + nextLayout = nextLayout + .map( ( item ) => { + switch ( item.i ) { + case lastClickedBlockAppenderId: + return { + ...appenderItem, + i: `block-${ blockClientIds[ blockClientIds.length - 1 ] }`, + }; + case blockClientIds[ blockClientIds.length - 1 ]: + return null; + default: + return item; + } + } ) + .filter( Boolean ); + } + + return nextLayout; +} + +export function resizeOverflowingBlocks( nextLayout, nodes ) { + const cellChanges = {}; + const itemsMap = nextLayout.reduce( ( acc, item ) => { + acc[ item.i ] = item; + return acc; + }, {} ); + + for ( const node of Object.values( nodes ) ) { + if ( ! itemsMap[ node.id ] ) { + continue; + } + const { clientWidth, clientHeight } = node.parentNode; + const minCols = Math.ceil( + node.offsetWidth / ( clientWidth / itemsMap[ node.id ].w ) + ); + const minRows = Math.ceil( + ( node.offsetHeight - 20 ) / ( clientHeight / itemsMap[ node.id ].h ) + ); + if ( itemsMap[ node.id ].w < minCols || itemsMap[ node.id ].h < minRows ) { + cellChanges[ node.id ] = { + w: Math.max( itemsMap[ node.id ].w, minCols ), + h: Math.max( itemsMap[ node.id ].h, minRows ), + }; + } + } + if ( Object.keys( cellChanges ).length ) { + nextLayout = nextLayout.map( ( item ) => + cellChanges[ item.i ] ? { ...item, ...cellChanges[ item.i ] } : item + ); + } + + return nextLayout; +} + +export function cropAndFillEmptyCells( nextLayout, cols, rows ) { + const maxRow = + Math.max( + rows, + ...nextLayout + .filter( ( item ) => ! item.i.startsWith( 'block-appender' ) ) + .map( ( item ) => item.y + item.h ) + ) - 1; + if ( nextLayout.some( ( item ) => item.y > maxRow ) ) { + // Crop extra rows. + nextLayout = nextLayout.filter( ( item ) => item.y <= maxRow ); + } + + const emptyCells = {}; + for ( + let col = 0; + col <= Math.max( cols, ...nextLayout.map( ( item ) => item.x + item.w ) ) - 1; + col++ + ) { + for ( let row = 0; row <= maxRow; row++ ) { + emptyCells[ `${ col } | ${ row }` ] = true; + } + } + for ( const item of nextLayout ) { + for ( let col = item.x; col < item.x + item.w; col++ ) { + for ( let row = item.y; row < item.y + item.h; row++ ) { + delete emptyCells[ `${ col } | ${ row }` ]; + } + } + } + if ( Object.keys( emptyCells ).length ) { + // Fill empty cells with block appenders. + nextLayout = [ + ...nextLayout, + ...Object.keys( emptyCells ).map( ( emptyCell ) => { + const [ col, row ] = emptyCell.split( ' | ' ); + return { + i: `block-appender-${ uuid() }`, + x: Number( col ), + y: Number( row ), + w: 1, + h: 1, + }; + } ), + ]; + } + + return nextLayout; +} + +export function hashGrid( grid ) { + return Object.values( JSON.stringify( grid ) ).reduce( ( acc, char ) => { + /* eslint-disable no-bitwise */ + acc = ( acc << 5 ) - acc + char.charCodeAt( 0 ); + return acc & acc; + /* eslint-enable no-bitwise */ + } ); +} + +function createGridItemsStyleRules( gridId, items ) { + return items + .map( + ( item, i ) => `#${ gridId } > #editor-block-list__grid-content-item-${ i } { + grid-area: ${ item.y + 1 } / ${ item.x + 1 } / ${ item.y + 1 + item.h } / ${ item.x + + 1 + + item.w } + }` + ) + .join( '\n\n ' ); +} +export function createGridStyleRules( gridId, grid, breakpoint, cols, rows ) { + const maxCol = Math.max( cols, ...grid.map( ( item ) => item.x + item.w ) ) - 1; + const maxRow = Math.max( rows, ...grid.map( ( item ) => item.y + item.h ) ) - 1; + return `@media (min-width: ${ breakpoint }px) { + #${ gridId } { + grid-template-columns: repeat(${ maxCol + 1 }, 1fr); + grid-template-rows: repeat(${ maxRow + 1 }, 1fr); + + } + + ${ createGridItemsStyleRules( gridId, grid ) } + }`; +} diff --git a/packages/block-editor/src/components/block-list/grid.js b/packages/block-editor/src/components/block-list/grid.js new file mode 100644 index 0000000000000..60ebbd4a02c66 --- /dev/null +++ b/packages/block-editor/src/components/block-list/grid.js @@ -0,0 +1,231 @@ +/** + * External dependencies + */ +import _ReactGridLayout, { WidthProvider } from 'react-grid-layout'; +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { useSelect, useDispatch, AsyncModeProvider } from '@wordpress/data'; +import { + useState, + useRef, + useEffect, + RawHTML, + useContext, +} from '@wordpress/element'; +import { serialize } from '@wordpress/blocks'; +import { BREAKPOINTS } from '@wordpress/viewport'; + +/** + * Internal dependencies + */ +import BlockListAppender from '../block-list-appender'; +import ButtonBlockAppender from '../inner-blocks/button-block-appender'; +import BlockListBlock from './block'; +import { + createInitialLayouts, + appendNewBlocks, + resizeOverflowingBlocks, + cropAndFillEmptyCells, + hashGrid, + createGridStyleRules, +} from './grid-utils'; +import { BlockNodes } from './root-container'; + +const ReactGridLayout = WidthProvider( _ReactGridLayout ); +function BlockGrid( { + rootClientId, + blockClientIds, + className, + hasMultiSelection, + multiSelectedBlockClientIds, + selectedBlockClientId, + isDraggable, + isMultiSelecting, + enableAnimation, + __experimentalUIParts, + targetClientId, +} ) { + const [ nodes ] = useContext( BlockNodes ); + const { grid, cols = 2, rows = 2 } = useSelect( + ( select ) => + select( 'core/block-editor' ).getBlockAttributes( rootClientId ), + [ rootClientId ] + ); + const { updateBlockAttributes } = useDispatch( 'core/block-editor' ); + + const [ layout, setLayout ] = useState( + createInitialLayouts( grid, blockClientIds ) + ); + + const lastClickedBlockAppenderIdRef = useRef(); + const blockClientIdsRef = useRef( blockClientIds ); + + useEffect( + () => { + let nextLayout = layout; + + nextLayout = appendNewBlocks( + nextLayout, + lastClickedBlockAppenderIdRef.current, + blockClientIdsRef.current, + blockClientIds + ); + nextLayout = resizeOverflowingBlocks( nextLayout, nodes ); + nextLayout = cropAndFillEmptyCells( nextLayout, cols, rows ); + + if ( layout !== nextLayout ) { + setLayout( nextLayout ); + } + + blockClientIdsRef.current = blockClientIds; + }, + // We reference `grid` here instead of `layouts` to avoid + // potential chained updates when block appenders are being displaced + // and overflows happen. `grid` will only change with + // persistent user changes and not when block appenders + // are displaced. + [ grid, blockClientIds, nodes, cols, rows ] + ); + + return ( +