diff --git a/packages/block-library/src/column/edit.js b/packages/block-library/src/column/edit.js index ab38ff506aed4..b1d17fbddac3c 100644 --- a/packages/block-library/src/column/edit.js +++ b/packages/block-library/src/column/edit.js @@ -2,6 +2,7 @@ * External dependencies */ import classnames from 'classnames'; +import { forEach, find, difference } from 'lodash'; /** * WordPress dependencies @@ -17,6 +18,17 @@ import { withDispatch, withSelect } from '@wordpress/data'; import { compose } from '@wordpress/compose'; import { __ } from '@wordpress/i18n'; +/** + * Internal dependencies + */ +import { + toWidthPrecision, + getTotalColumnsWidth, + getColumnWidths, + getAdjacentBlocks, + getRedistributedColumnWidths, +} from '../columns/utils'; + function ColumnEdit( { attributes, updateAlignment, @@ -85,35 +97,37 @@ export default compose( updateBlockAttributes( rootClientId, { verticalAlignment: null } ); }, updateWidth( width ) { - const { clientId, attributes, setAttributes } = ownProps; + const { clientId } = ownProps; const { updateBlockAttributes } = dispatch( 'core/block-editor' ); - const { - getBlockRootClientId, - getBlockOrder, - getBlockAttributes, - } = registry.select( 'core/block-editor' ); - - // Update own width. - setAttributes( { width } ); + const { getBlockRootClientId, getBlocks } = registry.select( 'core/block-editor' ); // Constrain or expand siblings to account for gain or loss of // total columns area. - const rootClientId = getBlockRootClientId( clientId ); - const columnClientIds = getBlockOrder( rootClientId ); - const { width: previousWidth = 100 / columnClientIds.length } = attributes; - const index = columnClientIds.indexOf( clientId ); - const isLastColumn = index === columnClientIds.length - 1; - const increment = isLastColumn ? -1 : 1; - const endIndex = isLastColumn ? 0 : columnClientIds.length - 1; - const adjustment = ( previousWidth - width ) / Math.abs( index - endIndex ); + const columns = getBlocks( getBlockRootClientId( clientId ) ); + const adjacentColumns = getAdjacentBlocks( columns, clientId ); + + // The occupied width is calculated as the sum of the new width + // and the total width of blocks _not_ in the adjacent set. + const occupiedWidth = width + getTotalColumnsWidth( + difference( columns, [ + find( columns, { clientId } ), + ...adjacentColumns, + ] ) + ); + + // Compute _all_ next column widths, in case the updated column + // is in the middle of a set of columns which don't yet have + // any explicit widths assigned (include updates to those not + // part of the adjacent blocks). + const nextColumnWidths = { + ...getColumnWidths( columns, columns.length ), + [ clientId ]: toWidthPrecision( width ), + ...getRedistributedColumnWidths( adjacentColumns, 100 - occupiedWidth, columns.length ), + }; - for ( let i = index + increment; i - increment !== endIndex; i += increment ) { - const columnClientId = columnClientIds[ i ]; - const { width: columnWidth = 100 / columnClientIds.length } = getBlockAttributes( columnClientId ); - updateBlockAttributes( columnClientId, { - width: columnWidth + adjustment, - } ); - } + forEach( nextColumnWidths, ( nextColumnWidth, columnClientId ) => { + updateBlockAttributes( columnClientId, { width: nextColumnWidth } ); + } ); }, }; } ) diff --git a/packages/block-library/src/columns/edit.js b/packages/block-library/src/columns/edit.js index 21916716b1815..7162562523381 100644 --- a/packages/block-library/src/columns/edit.js +++ b/packages/block-library/src/columns/edit.js @@ -2,7 +2,7 @@ * External dependencies */ import classnames from 'classnames'; -import { last } from 'lodash'; +import { dropRight } from 'lodash'; /** * WordPress dependencies @@ -24,7 +24,13 @@ import { createBlock } from '@wordpress/blocks'; /** * Internal dependencies */ -import { getColumnsTemplate } from './utils'; +import { + getColumnsTemplate, + hasExplicitColumnWidths, + getMappedColumnWidths, + getRedistributedColumnWidths, + toWidthPrecision, +} from './utils'; /** * Allowed blocks constant is passed to InnerBlocks precisely as specified here. @@ -118,12 +124,7 @@ export default withDispatch( ( dispatch, ownProps, registry ) => ( { setAttributes( { columns } ); let innerBlocks = getBlocks( clientId ); - const hasExplicitColumnWidths = innerBlocks.some( ( innerBlock ) => ( - innerBlock.attributes.width !== undefined - ) ); - - let newOrRemovedColumnWidth; - if ( ! hasExplicitColumnWidths ) { + if ( ! hasExplicitColumnWidths( innerBlocks ) ) { return; } @@ -134,32 +135,26 @@ export default withDispatch( ( dispatch, ownProps, registry ) => ( { if ( isAddingColumn ) { // If adding a new column, assign width to the new column equal to // as if it were `1 / columns` of the total available space. - newOrRemovedColumnWidth = ( 100 / columns ); + const newColumnWidth = toWidthPrecision( 100 / columns ); + + // Redistribute in consideration of pending block insertion as + // constraining the available working width. + const widths = getRedistributedColumnWidths( innerBlocks, 100 - newColumnWidth ); + + innerBlocks = [ + ...getMappedColumnWidths( innerBlocks, widths ), + createBlock( 'core/column', { + width: newColumnWidth, + } ), + ]; } else { // The removed column will be the last of the inner blocks. - newOrRemovedColumnWidth = last( innerBlocks ).attributes.width || ( 100 / previousColumns ); - } + innerBlocks = dropRight( innerBlocks ); - const adjustment = newOrRemovedColumnWidth / ( isAddingColumn ? -1 * previousColumns : columns ); - innerBlocks = innerBlocks.map( ( innerBlock ) => { - const { width: columnWidth = ( 100 / previousColumns ) } = innerBlock.attributes; - return { - ...innerBlock, - attributes: { - ...innerBlocks.attributes, - width: parseFloat( ( columnWidth + adjustment ).toFixed( 2 ) ), - }, - }; - } ); - - // Explicitly manage the new column block, since template would not - // account for the explicitly assigned width. - if ( isAddingColumn ) { - const block = createBlock( 'core/column', { - width: parseFloat( newOrRemovedColumnWidth.toFixed( 2 ) ), - } ); + // Redistribute as if block is already removed. + const widths = getRedistributedColumnWidths( innerBlocks, 100 ); - innerBlocks = [ ...innerBlocks, block ]; + innerBlocks = getMappedColumnWidths( innerBlocks, widths ); } replaceInnerBlocks( clientId, innerBlocks, false ); diff --git a/packages/block-library/src/columns/test/utils.js b/packages/block-library/src/columns/test/utils.js new file mode 100644 index 0000000000000..b264829e3987d --- /dev/null +++ b/packages/block-library/src/columns/test/utils.js @@ -0,0 +1,224 @@ +/** + * Internal dependencies + */ +import { + getColumnsTemplate, + toWidthPrecision, + getAdjacentBlocks, + getEffectiveColumnWidth, + getTotalColumnsWidth, + getColumnWidths, + getRedistributedColumnWidths, + hasExplicitColumnWidths, + getMappedColumnWidths, +} from '../utils'; + +describe( 'getColumnsTemplate', () => { + it( 'should return a template corresponding to columns count', () => { + const template = getColumnsTemplate( 4 ); + + expect( template ).toEqual( [ + [ 'core/column' ], + [ 'core/column' ], + [ 'core/column' ], + [ 'core/column' ], + ] ); + } ); +} ); + +describe( 'toWidthPrecision', () => { + it( 'should round value to standard precision', () => { + const value = toWidthPrecision( 50.108 ); + + expect( value ).toBe( 50.11 ); + } ); +} ); + +describe( 'getAdjacentBlocks', () => { + const blockA = { clientId: 'a' }; + const blockB = { clientId: 'b' }; + const blockC = { clientId: 'c' }; + const blocks = [ blockA, blockB, blockC ]; + + it( 'should return blocks after clientId', () => { + const result = getAdjacentBlocks( blocks, 'b' ); + + expect( result ).toEqual( [ blockC ] ); + } ); + + it( 'should return blocks before clientId if clientId is last', () => { + const result = getAdjacentBlocks( blocks, 'c' ); + + expect( result ).toEqual( [ blockA, blockB ] ); + } ); +} ); + +describe( 'getEffectiveColumnWidth', () => { + it( 'should return attribute value if set, rounded to precision', () => { + const block = { attributes: { width: 50.108 } }; + + const width = getEffectiveColumnWidth( block, 3 ); + + expect( width ).toBe( 50.11 ); + } ); + + it( 'should return assumed width if attribute value not set, rounded to precision', () => { + const block = { attributes: {} }; + + const width = getEffectiveColumnWidth( block, 3 ); + + expect( width ).toBe( 33.33 ); + } ); +} ); + +describe( 'getTotalColumnsWidth', () => { + describe( 'explicit width', () => { + const blocks = [ + { clientId: 'a', attributes: { width: 30 } }, + { clientId: 'b', attributes: { width: 40 } }, + ]; + + it( 'returns the sum total of columns width', () => { + const width = getTotalColumnsWidth( blocks ); + + expect( width ).toBe( 70 ); + } ); + } ); + + describe( 'implicit width', () => { + const blocks = [ + { clientId: 'a', attributes: {} }, + { clientId: 'b', attributes: {} }, + ]; + + it( 'returns the sum total of columns width', () => { + const widths = getTotalColumnsWidth( blocks ); + + expect( widths ).toBe( 100 ); + } ); + } ); +} ); + +describe( 'getColumnWidths', () => { + describe( 'explicit width', () => { + const blocks = [ + { clientId: 'a', attributes: { width: 30.459 } }, + { clientId: 'b', attributes: { width: 29.543 } }, + ]; + + it( 'returns the column widths', () => { + const widths = getColumnWidths( blocks ); + + expect( widths ).toEqual( { + a: 30.46, + b: 29.54, + } ); + } ); + } ); + + describe( 'implicit width', () => { + const blocks = [ + { clientId: 'a', attributes: {} }, + { clientId: 'b', attributes: {} }, + ]; + + it( 'returns the column widths', () => { + const widths = getColumnWidths( blocks ); + + expect( widths ).toEqual( { + a: 50, + b: 50, + } ); + } ); + } ); +} ); + +describe( 'getRedistributedColumnWidths', () => { + describe( 'explicit width', () => { + const blocks = [ + { clientId: 'a', attributes: { width: 30 } }, + { clientId: 'b', attributes: { width: 40 } }, + ]; + + it( 'should constrain to fit available width', () => { + const widths = getRedistributedColumnWidths( blocks, 60 ); + + expect( widths ).toEqual( { + a: 25, + b: 35, + } ); + } ); + + it( 'should expand to fit available width', () => { + const widths = getRedistributedColumnWidths( blocks, 80 ); + + expect( widths ).toEqual( { + a: 35, + b: 45, + } ); + } ); + } ); + + describe( 'implicit width', () => { + const blocks = [ + { clientId: 'a', attributes: {} }, + { clientId: 'b', attributes: {} }, + ]; + + it( 'should equally distribute to available width', () => { + const widths = getRedistributedColumnWidths( blocks, 60 ); + + expect( widths ).toEqual( { + a: 30, + b: 30, + } ); + } ); + + it( 'should constrain to fit available width', () => { + const widths = getRedistributedColumnWidths( blocks, 66.66, 3 ); + + expect( widths ).toEqual( { + a: 33.33, + b: 33.33, + } ); + } ); + } ); +} ); + +describe( 'hasExplicitColumnWidths', () => { + it( 'returns false if no blocks have explicit width', () => { + const blocks = [ { attributes: {} } ]; + + const result = hasExplicitColumnWidths( blocks ); + + expect( result ).toBe( false ); + } ); + + it( 'returns true if a block has explicit width', () => { + const blocks = [ { attributes: { width: 10 } } ]; + + const result = hasExplicitColumnWidths( blocks ); + + expect( result ).toBe( true ); + } ); +} ); + +describe( 'getMappedColumnWidths', () => { + it( 'merges to block attributes using provided widths', () => { + const blocks = [ + { clientId: 'a', attributes: { width: 30 } }, + { clientId: 'b', attributes: { width: 40 } }, + ]; + const widths = { + a: 25, + b: 35, + }; + + const result = getMappedColumnWidths( blocks, widths ); + + expect( result ).toEqual( [ + { clientId: 'a', attributes: { width: 25 } }, + { clientId: 'b', attributes: { width: 35 } }, + ] ); + } ); +} ); diff --git a/packages/block-library/src/columns/utils.js b/packages/block-library/src/columns/utils.js index e7e3f90df70fd..5da0b3e282c33 100644 --- a/packages/block-library/src/columns/utils.js +++ b/packages/block-library/src/columns/utils.js @@ -2,7 +2,7 @@ * External dependencies */ import memoize from 'memize'; -import { times } from 'lodash'; +import { times, findIndex, sumBy, merge, mapValues } from 'lodash'; /** * Returns the layouts configuration for a given number of columns. @@ -14,3 +14,125 @@ import { times } from 'lodash'; export const getColumnsTemplate = memoize( ( columns ) => { return times( columns, () => [ 'core/column' ] ); } ); + +/** + * Returns a column width attribute value rounded to standard precision. + * + * @param {number} value Raw value. + * + * @return {number} Value rounded to standard precision. + */ +export const toWidthPrecision = ( value ) => parseFloat( value.toFixed( 2 ) ); + +/** + * Returns the considered adjacent to that of the specified `clientId` for + * resizing consideration. Adjacent blocks are those occurring after, except + * when the given block is the last block in the set. For the last block, the + * behavior is reversed. + * + * @param {WPBlock[]} blocks Block objects. + * @param {string} clientId Client ID to consider for adjacent blocks. + * + * @return {WPBlock[]} Adjacent block objects. + */ +export function getAdjacentBlocks( blocks, clientId ) { + const index = findIndex( blocks, { clientId } ); + const isLastBlock = index === blocks.length - 1; + + return isLastBlock ? blocks.slice( 0, index ) : blocks.slice( index + 1 ); +} + +/** + * Returns an effective width for a given block. An effective width is equal to + * its attribute value if set, or a computed value assuming equal distribution. + * + * @param {WPBlock} block Block object. + * @param {number} totalBlockCount Total number of blocks in Columns. + * + * @return {number} Effective column width. + */ +export function getEffectiveColumnWidth( block, totalBlockCount ) { + const { width = 100 / totalBlockCount } = block.attributes; + return toWidthPrecision( width ); +} + +/** + * Returns the total width occupied by the given set of column blocks. + * + * @param {WPBlock[]} blocks Block objects. + * @param {?number} totalBlockCount Total number of blocks in Columns. + * Defaults to number of blocks passed. + * + * @return {number} Total width occupied by blocks. + */ +export function getTotalColumnsWidth( blocks, totalBlockCount = blocks.length ) { + return sumBy( blocks, ( block ) => getEffectiveColumnWidth( block, totalBlockCount ) ); +} + +/** + * Returns an object of `clientId` → `width` of effective column widths. + * + * @param {WPBlock[]} blocks Block objects. + * @param {?number} totalBlockCount Total number of blocks in Columns. + * Defaults to number of blocks passed. + * + * @return {Object} Column widths. + */ +export function getColumnWidths( blocks, totalBlockCount = blocks.length ) { + return blocks.reduce( ( result, block ) => { + const width = getEffectiveColumnWidth( block, totalBlockCount ); + return Object.assign( result, { [ block.clientId ]: width } ); + }, {} ); +} + +/** + * Returns an object of `clientId` → `width` of column widths as redistributed + * proportional to their current widths, constrained or expanded to fit within + * the given available width. + * + * @param {WPBlock[]} blocks Block objects. + * @param {number} availableWidth Maximum width to fit within. + * @param {?number} totalBlockCount Total number of blocks in Columns. + * Defaults to number of blocks passed. + * + * @return {Object} Redistributed column widths. + */ +export function getRedistributedColumnWidths( blocks, availableWidth, totalBlockCount = blocks.length ) { + const totalWidth = getTotalColumnsWidth( blocks, totalBlockCount ); + const difference = availableWidth - totalWidth; + const adjustment = difference / blocks.length; + + return mapValues( + getColumnWidths( blocks, totalBlockCount ), + ( width ) => toWidthPrecision( width + adjustment ), + ); +} + +/** + * Returns true if column blocks within the provided set are assigned with + * explicit widths, or false otherwise. + * + * @param {WPBlock[]} blocks Block objects. + * + * @return {boolean} Whether columns have explicit widths. + */ +export function hasExplicitColumnWidths( blocks ) { + return blocks.some( ( block ) => Number.isFinite( block.attributes.width ) ); +} + +/** + * Returns a copy of the given set of blocks with new widths assigned from the + * provided object of redistributed column widths. + * + * @param {WPBlock[]} blocks Block objects. + * @param {Object} widths Redistributed column widths. + * + * @return {WPBlock[]} blocks Mapped block objects. + */ +export function getMappedColumnWidths( blocks, widths ) { + return blocks.map( ( block ) => merge( {}, block, { + attributes: { + width: widths[ block.clientId ], + }, + } ) ); +}