diff --git a/package-lock.json b/package-lock.json index 7d9582d1c9006..8fe00600ca576 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7262,12 +7262,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.3.3", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.3.3.tgz", + "integrity": "sha512-pW0No1RGHgzlpHJO1nsVrHKpOEIxkGg1xB+v0ZmdNH5OAeAwzAVrCnI2/6Mtx+Uys6iaylxa+D3g4j63IKKjSQ==" + } } }, "@wordpress/block-library": { @@ -21331,8 +21340,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", @@ -27637,7 +27645,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.0.3.tgz", "integrity": "sha512-4vD6zms+9QGeZ2RQXzlUBw8PBYUXy+dzYX5r22idjp9YwQKIIvD/EojL0rbjS1GK4C3P0rAJnmKa8gDQYWUDyA==", - "dev": true, "requires": { "classnames": "^2.2.5", "prop-types": "^15.6.0" @@ -27667,6 +27674,18 @@ "react-clientside-effect": "^1.2.0" } }, + "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.3", "resolved": "https://registry.npmjs.org/react-helmet-async/-/react-helmet-async-1.0.3.tgz", @@ -28340,6 +28359,15 @@ "integrity": "sha512-ITw8t/HOFNose2yf1y9pPFSSeB9ISOq2JdHpuZvj/Qb+iSsLml8GkkHdDlURzieO7B3dFDtMrrneZLl3N5z/hg==", "dev": true }, + "react-resizable": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/react-resizable/-/react-resizable-1.9.0.tgz", + "integrity": "sha512-YAls7H4+34MMDg2cPnhVfLtes//XWvrLNuxbVwawwJFIGlPvhGi8BLCcIsDdLJ2CCCxtymVotk2Hq4RtPJ7t5g==", + "requires": { + "prop-types": "15.x", + "react-draggable": "^4.0.3" + } + }, "react-select": { "version": "3.0.8", "resolved": "https://registry.npmjs.org/react-select/-/react-select-3.0.8.tgz", diff --git a/packages/block-editor/package.json b/packages/block-editor/package.json index 9d9f00f675fb2..17c84d2864476 100644 --- a/packages/block-editor/package.json +++ b/packages/block-editor/package.json @@ -49,12 +49,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 48b5506e452f2..5007d97f5857f 100644 --- a/packages/block-editor/src/components/block-list/block.js +++ b/packages/block-editor/src/components/block-list/block.js @@ -618,7 +618,7 @@ function BlockListBlock( { } const applyWithSelect = withSelect( - ( select, { clientId, rootClientId, isLargeViewport } ) => { + ( select, { clientId, rootClientId, isLargeViewport, isLocked } ) => { const { isBlockSelected, isAncestorMultiSelected, @@ -661,11 +661,13 @@ const applyWithSelect = withSelect( isCaretWithinFormattedText: isCaretWithinFormattedText(), mode: getBlockMode( clientId ), isSelectionEnabled: isSelectionEnabled(), - initialPosition: isSelected ? getSelectedBlocksInitialCaretPosition() : null, + initialPosition: isSelected ? + getSelectedBlocksInitialCaretPosition() : + null, isEmptyDefaultBlock: name && isUnmodifiedDefaultBlock( { name, attributes } ), isMovable: 'all' !== templateLock, - isLocked: !! templateLock, + isLocked: isLocked || !! templateLock, isFocusMode: focusMode && isLargeViewport, hasFixedToolbar: hasFixedToolbar && isLargeViewport, isLast: index === blockOrder.length - 1, 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..f3f4a3932ffe6 --- /dev/null +++ b/packages/block-editor/src/components/block-list/grid-utils.js @@ -0,0 +1,203 @@ +/** + * External dependencies + */ +import uuid from 'uuid/v4'; + +export const BREAKPOINTS = { lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }; +export const COLS = { lg: 12, md: 10, sm: 6, xs: 4, xxs: 2 }; +export const MIN_ROWS = 2; + +export function createInitialLayouts( grid, blockClientIds ) { + return Object.keys( BREAKPOINTS ).reduce( ( acc, breakpoint ) => { + // Hydrate grid layouts, if any, with new block client IDs. + acc[ breakpoint ] = + grid && grid[ breakpoint ] ? + grid[ breakpoint ].map( ( item, i ) => ( { + ...item, + i: `block-${ blockClientIds[ i ] }`, + } ) ) : + []; + return acc; + }, {} ); +} + +export function appendNewBlocks( + nextLayouts, + breakpoint, + 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 = nextLayouts[ breakpoint ].find( + ( item ) => item.i === lastClickedBlockAppenderId + ); + nextLayouts = Object.keys( nextLayouts ).reduce( ( acc, _breakpoint ) => { + acc[ _breakpoint ] = nextLayouts[ _breakpoint ] + .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 acc; + }, {} ); + } + + return nextLayouts; +} + +export function resizeOverflowingBlocks( nextLayouts, breakpoint, nodes ) { + const cellChanges = {}; + const itemsMap = nextLayouts[ breakpoint ].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 ) { + nextLayouts = { + ...nextLayouts, + [ breakpoint ]: nextLayouts[ breakpoint ].map( ( item ) => + cellChanges[ item.i ] ? { ...item, ...cellChanges[ item.i ] } : item + ), + }; + } + + return nextLayouts; +} + +export function cropAndFillEmptyCells( nextLayouts, breakpoint ) { + const maxRow = + Math.max( + MIN_ROWS, + ...nextLayouts[ breakpoint ] + .filter( ( item ) => ! item.i.startsWith( 'block-appender' ) ) + .map( ( item ) => item.y + item.h ) + ) - 1; + if ( nextLayouts[ breakpoint ].some( ( item ) => item.y > maxRow ) ) { + // Crop extra rows. + nextLayouts = { + ...nextLayouts, + [ breakpoint ]: nextLayouts[ breakpoint ].filter( ( item ) => item.y <= maxRow ), + }; + } + + const emptyCells = {}; + for ( + let col = 0; + col <= + Math.max( + COLS[ breakpoint ], + ...nextLayouts[ breakpoint ].map( ( item ) => item.x + item.w ) + ) - + 1; + col++ + ) { + for ( let row = 0; row <= maxRow; row++ ) { + emptyCells[ `${ col } | ${ row }` ] = true; + } + } + for ( const item of nextLayouts[ breakpoint ] ) { + 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. + nextLayouts = { + ...nextLayouts, + [ breakpoint ]: [ + ...nextLayouts[ breakpoint ], + ...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 nextLayouts; +} + +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 ) { + return Object.keys( grid ) + .sort( ( a, b ) => BREAKPOINTS[ a ] - BREAKPOINTS[ b ] ) + .map( ( breakpoint ) => { + const maxCol = + Math.max( + COLS[ breakpoint ], + ...grid[ breakpoint ].map( ( item ) => item.x + item.w ) + ) - 1; + const maxRow = + Math.max( MIN_ROWS, ...grid[ breakpoint ].map( ( item ) => item.y + item.h ) ) - 1; + return `@media (min-width: ${ BREAKPOINTS[ breakpoint ] }px) { + #${ gridId } { + grid-template-columns: repeat(${ maxCol + 1 }, 1fr); + grid-template-rows: repeat(${ maxRow + 1 }, 1fr); + + } + + ${ createGridItemsStyleRules( gridId, grid[ breakpoint ] ) } +}`; + } ) + .join( '\n\n' ); +} 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..6bc4a02755abf --- /dev/null +++ b/packages/block-editor/src/components/block-list/grid.js @@ -0,0 +1,224 @@ +/** + * External dependencies + */ +import _ReactGridLayout, { WidthProvider } from 'react-grid-layout'; +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { useSelect, useDispatch } from '@wordpress/data'; +import { useState, useRef, useEffect, RawHTML } from '@wordpress/element'; +import { Toolbar } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { serialize } from '@wordpress/blocks'; + +/** + * Internal dependencies + */ +import BlockListAppender from '../block-list-appender'; +import ButtonBlockAppender from '../inner-blocks/button-block-appender'; +import BlockAsyncModeProvider from './block-async-mode-provider'; +import BlockListBlock from './block'; +import BlockControls from '../block-controls'; +import { + createInitialLayouts, + appendNewBlocks, + resizeOverflowingBlocks, + cropAndFillEmptyCells, + COLS, + BREAKPOINTS, + hashGrid, + createGridStyleRules, +} from './grid-utils'; + +const ReactGridLayout = WidthProvider( _ReactGridLayout ); +function BlockGrid( { + rootClientId, + blockClientIds, + nodes, + className, + hasMultiSelection, + multiSelectedBlockClientIds, + selectedBlockClientId, + setBlockRef, + onSelectionStart, +} ) { + const { grid } = useSelect( + ( select ) => select( 'core/block-editor' ).getBlockAttributes( rootClientId ), + [ rootClientId ] + ); + const { updateBlockAttributes } = useDispatch( 'core/block-editor' ); + + const [ breakpoint, setBreakpoint ] = useState( 'xxs' ); + const [ layouts, setLayouts ] = useState( + createInitialLayouts( grid, blockClientIds ) + ); + + const lastClickedBlockAppenderIdRef = useRef(); + const blockClientIdsRef = useRef( blockClientIds ); + + useEffect( + () => { + let nextLayouts = layouts; + + nextLayouts = appendNewBlocks( + nextLayouts, + breakpoint, + lastClickedBlockAppenderIdRef.current, + blockClientIdsRef.current, + blockClientIds + ); + nextLayouts = resizeOverflowingBlocks( nextLayouts, breakpoint, nodes ); + nextLayouts = cropAndFillEmptyCells( nextLayouts, breakpoint ); + + if ( layouts !== nextLayouts ) { + setLayouts( nextLayouts ); + } + + 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, breakpoint ] + ); + + return ( +
+ { + setLayouts( { ...layouts, [ breakpoint ]: nextLayout } ); + updateBlockAttributes( rootClientId, { + grid: { + ...grid, + [ breakpoint ]: nextLayout.filter( + ( item ) => ! item.i.startsWith( 'block-appender' ) + ), + }, + } ); + } } + rowHeight={ 200 } + style={ { + minWidth: BREAKPOINTS[ breakpoint ], + } } + verticalCompact={ false } + > + { [ + ...layouts[ breakpoint ] + .filter( ( item ) => item.i.startsWith( 'block-appender' ) ) + .map( ( item ) => ( +
+ ( lastClickedBlockAppenderIdRef.current = id ) + } + onKeyPress={ ( { currentTarget: { id } } ) => + ( lastClickedBlockAppenderIdRef.current = id ) + } + role="button" + tabIndex="0" + > + +
+ ) ), + ...blockClientIds.map( ( blockClientId ) => { + const isBlockInSelection = hasMultiSelection ? + multiSelectedBlockClientIds.includes( blockClientId ) : + selectedBlockClientId === blockClientId; + return ( +
+ + + +
+ ); + } ), + ] } +
+ + ( { + title: `${ _breakpoint } (>= ${ BREAKPOINTS[ _breakpoint ] }px)`, + isActive: breakpoint === _breakpoint, + onClick: setBreakpoint.bind( null, _breakpoint ), + } ) ) } + isCollapsed + /> + +
+ ); +} + +BlockGrid.Content = ( { attributes: { grid, align }, innerBlocks } ) => { + const gridId = `editor-block-list__grid-content-${ hashGrid( grid ) }`; + return ( +
+ + { `` } + + { /** + * We loop over the grid and not inner blocks directly, + * because inner blocks are not provided during validation, + * so this block would never pass validation if its + * own markup depended on its inner blocks. + */ } + { Object.keys( grid ) + .reduce( + ( acc, breakpoint ) => + // We use the breakpoint with the most blocks as its set of + // blocks will be a superset of the others' and therefore + // allow us to render all blocks once. + grid[ breakpoint ].length > acc.length ? grid[ breakpoint ] : acc, + grid.xxs + ) + .map( ( _item, i ) => ( +
+ { /* Guard here, because inner blocks are not available during validation. */ } + { innerBlocks[ i ] && ( + + { serialize( innerBlocks[ i ], { isInnerBlocks: true } ) } + + ) } +
+ ) ) } +
+ ); +}; + +export default BlockGrid; diff --git a/packages/block-editor/src/components/block-list/index.js b/packages/block-editor/src/components/block-list/index.js index 54511ac941ecc..d4202eb23f0dd 100644 --- a/packages/block-editor/src/components/block-list/index.js +++ b/packages/block-editor/src/components/block-list/index.js @@ -28,6 +28,7 @@ import BlockAsyncModeProvider from './block-async-mode-provider'; import BlockListBlock from './block'; import BlockListAppender from '../block-list-appender'; import { getBlockDOMNode } from '../../utils/dom'; +import BlockGrid from './grid'; /** * If the block count exceeds the threshold, we disable the reordering animation @@ -202,8 +203,20 @@ class BlockList extends Component { hasMultiSelection, renderAppender, enableAnimation, + __experimentalGridMode, } = this.props; + if ( __experimentalGridMode ) { + return ( + + ); + } + return (
*, + & > * > * { + height: 100%; + } + } +} + +.block-editor-block-list__grid-item { + padding: 0 20px; +} diff --git a/packages/block-editor/src/components/inner-blocks/index.js b/packages/block-editor/src/components/inner-blocks/index.js index ed147bfe736ad..56a67cdecda05 100644 --- a/packages/block-editor/src/components/inner-blocks/index.js +++ b/packages/block-editor/src/components/inner-blocks/index.js @@ -111,6 +111,7 @@ class InnerBlocks extends Component { __experimentalTemplateOptions: templateOptions, __experimentalOnSelectTemplateOption: onSelectTemplateOption, __experimentalAllowTemplateOptionSkip: allowTemplateOptionSkip, + __experimentalGridMode, } = this.props; const { templateInProcess } = this.state; @@ -133,6 +134,7 @@ class InnerBlocks extends Component { rootClientId={ clientId } renderAppender={ renderAppender } __experimentalMoverDirection={ moverDirection } + __experimentalGridMode={ __experimentalGridMode } /> ) }
@@ -186,7 +188,12 @@ InnerBlocks.DefaultBlockAppender = DefaultBlockAppender; InnerBlocks.ButtonBlockAppender = ButtonBlockAppender; InnerBlocks.Content = withBlockContentContext( - ( { BlockContent } ) => + ( { BlockContent, __experimentalGridMode } ) => + __experimentalGridMode ? ( + + ) : ( + + ) ); /** diff --git a/packages/block-library/src/grid/block.json b/packages/block-library/src/grid/block.json new file mode 100644 index 0000000000000..2d40f56252499 --- /dev/null +++ b/packages/block-library/src/grid/block.json @@ -0,0 +1,10 @@ +{ + "name": "core/grid", + "category": "layout", + "supports": { "align": true }, + "attributes": { + "grid": { + "type": "object" + } + } +} diff --git a/packages/block-library/src/grid/edit.js b/packages/block-library/src/grid/edit.js new file mode 100644 index 0000000000000..da29a3694ca32 --- /dev/null +++ b/packages/block-library/src/grid/edit.js @@ -0,0 +1,8 @@ +/** + * WordPress dependencies + */ +import { InnerBlocks } from '@wordpress/block-editor'; + +export default function GridEdit() { + return ; +} diff --git a/packages/block-library/src/grid/index.js b/packages/block-library/src/grid/index.js new file mode 100644 index 0000000000000..fc02aee4c3377 --- /dev/null +++ b/packages/block-library/src/grid/index.js @@ -0,0 +1,20 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import metadata from './block.json'; +import edit from './edit'; +import save from './save'; + +const { name } = metadata; +export { metadata, name }; + +export const settings = { + title: __( 'Grid' ), + edit, + save, +}; diff --git a/packages/block-library/src/grid/save.js b/packages/block-library/src/grid/save.js new file mode 100644 index 0000000000000..7e266f0ea0cd3 --- /dev/null +++ b/packages/block-library/src/grid/save.js @@ -0,0 +1,8 @@ +/** + * WordPress dependencies + */ +import { InnerBlocks } from '@wordpress/block-editor'; + +export default function GridSave() { + return ; +} diff --git a/packages/block-library/src/index.js b/packages/block-library/src/index.js index 3147ebcc0e9cd..5824c699778b0 100644 --- a/packages/block-library/src/index.js +++ b/packages/block-library/src/index.js @@ -48,6 +48,7 @@ import * as pullquote from './pullquote'; import * as reusableBlock from './block'; import * as rss from './rss'; import * as search from './search'; +import * as grid from './grid'; import * as group from './group'; import * as separator from './separator'; import * as shortcode from './shortcode'; @@ -118,6 +119,7 @@ export const registerCoreBlocks = () => { ...embed.common, ...embed.others, file, + grid, group, window.wp && window.wp.oldEditor ? classic : null, // Only add the classic block in WP Context html, diff --git a/packages/blocks/src/api/serializer.js b/packages/blocks/src/api/serializer.js index d2dfbcab2d672..1140c2beb64d5 100644 --- a/packages/blocks/src/api/serializer.js +++ b/packages/blocks/src/api/serializer.js @@ -111,7 +111,7 @@ export function getSaveElement( blockTypeOrName, attributes, innerBlocks = [] ) element = applyFilters( 'blocks.getSaveElement', element, blockType, attributes ); return ( - + { element } ); diff --git a/packages/blocks/src/block-content-provider/index.js b/packages/blocks/src/block-content-provider/index.js index 96b9a4d31bcd1..8cff379cde9e4 100644 --- a/packages/blocks/src/block-content-provider/index.js +++ b/packages/blocks/src/block-content-provider/index.js @@ -28,7 +28,7 @@ const { Consumer, Provider } = createContext( () => {} ); * * @return {WPComponent} Element with BlockContent injected via context. */ -const BlockContentProvider = ( { children, innerBlocks } ) => { +const BlockContentProvider = ( { children, attributes, innerBlocks } ) => { const BlockContent = () => { // Value is an array of blocks, so defer to block serializer const html = serialize( innerBlocks, { isInnerBlocks: true } ); @@ -36,6 +36,8 @@ const BlockContentProvider = ( { children, innerBlocks } ) => { // Use special-cased raw HTML tag to avoid default escaping return { html }; }; + BlockContent.attributes = attributes; + BlockContent.innerBlocks = innerBlocks; return (