diff --git a/docs/designers-developers/developers/data/data-core-block-editor.md b/docs/designers-developers/developers/data/data-core-block-editor.md index 6e496446d6be2..afc41727d2321 100644 --- a/docs/designers-developers/developers/data/data-core-block-editor.md +++ b/docs/designers-developers/developers/data/data-core-block-editor.md @@ -353,6 +353,18 @@ _Returns_ - `Array`: Items that appear in inserter. +# **getLastBlockAttributesChange** + +Returns an object describing the last block attributes changes + +_Parameters_ + +- _state_ `Object`: Block editor state. + +_Returns_ + +- `Object`: the clientId and the block attributes changes + # **getLastMultiSelectedBlockClientId** Returns the client ID of the last block in the multi-selection set, or null diff --git a/docs/designers-developers/developers/data/data-core-editor.md b/docs/designers-developers/developers/data/data-core-editor.md index e3456d65168db..75da1124748ff 100644 --- a/docs/designers-developers/developers/data/data-core-editor.md +++ b/docs/designers-developers/developers/data/data-core-editor.md @@ -1047,10 +1047,6 @@ _Parameters_ - _edits_ `Object`: Post attributes to edit. -_Returns_ - -- `Object`: Action object. - # **enablePublishSidebar** Returns an action object used in signalling that the user has enabled the @@ -1211,10 +1207,6 @@ _Parameters_ - _blocks_ `Array`: Block Array. - _options_ `?Object`: Optional options. -_Returns_ - -- `Object`: Action object - # **resetPost** Returns an action object used in signalling that the latest version of the diff --git a/packages/block-editor/src/components/block-list/block.js b/packages/block-editor/src/components/block-list/block.js index aac68e2e6faa8..f1b86d615d99f 100644 --- a/packages/block-editor/src/components/block-list/block.js +++ b/packages/block-editor/src/components/block-list/block.js @@ -2,7 +2,7 @@ * External dependencies */ import classnames from 'classnames'; -import { get, reduce, size, first, last } from 'lodash'; +import { first, last } from 'lodash'; /** * WordPress dependencies @@ -649,42 +649,8 @@ const applyWithDispatch = withDispatch( ( dispatch, ownProps, { select } ) => { return { setAttributes( newAttributes ) { - const { name, clientId } = ownProps; - const type = getBlockType( name ); - - function isMetaAttribute( key ) { - return get( type, [ 'attributes', key, 'source' ] ) === 'meta'; - } - - // Partition new attributes to delegate update behavior by source. - // - // TODO: A consolidated approach to external attributes sourcing - // should be devised to avoid specific handling for meta, enable - // additional attributes sources. - // - // See: https://github.com/WordPress/gutenberg/issues/2759 - const { - blockAttributes, - metaAttributes, - } = reduce( newAttributes, ( result, value, key ) => { - if ( isMetaAttribute( key ) ) { - result.metaAttributes[ type.attributes[ key ].meta ] = value; - } else { - result.blockAttributes[ key ] = value; - } - - return result; - }, { blockAttributes: {}, metaAttributes: {} } ); - - if ( size( blockAttributes ) ) { - updateBlockAttributes( clientId, blockAttributes ); - } - - if ( size( metaAttributes ) ) { - const { getSettings } = select( 'core/block-editor' ); - const onChangeMeta = getSettings().__experimentalMetaSource.onChange; - onChangeMeta( metaAttributes ); - } + const { clientId } = ownProps; + updateBlockAttributes( clientId, newAttributes ); }, onSelect( clientId = ownProps.clientId, initialPosition ) { selectBlock( clientId, initialPosition ); diff --git a/packages/block-editor/src/components/provider/index.js b/packages/block-editor/src/components/provider/index.js index 43f336325434d..e237ba14ab47d 100644 --- a/packages/block-editor/src/components/provider/index.js +++ b/packages/block-editor/src/components/provider/index.js @@ -12,6 +12,10 @@ import { compose } from '@wordpress/compose'; import withRegistryProvider from './with-registry-provider'; class BlockEditorProvider extends Component { + constructor() { + super( ...arguments ); + this.lastPersistedBlocks = []; + } componentDidMount() { this.props.updateSettings( this.props.settings ); this.props.resetBlocks( this.props.value ); @@ -35,11 +39,15 @@ class BlockEditorProvider extends Component { this.attachChangeObserver( registry ); } - if ( this.isSyncingOutcomingValue ) { - this.isSyncingOutcomingValue = false; - } else if ( value !== prevProps.value ) { + if ( value !== prevProps.value && this.lastPersistedBlocks.indexOf( value ) === -1 ) { this.isSyncingIncomingValue = true; resetBlocks( value ); + this.lastPersistedBlocks = []; + } + + // Reset the last persisted values once the last change is performed + if ( value === this.lastPersistedBlocks[ 0 ] ) { + this.lastPersistedBlocks = []; } } @@ -69,6 +77,7 @@ class BlockEditorProvider extends Component { const { getBlocks, isLastBlockChangePersistent, + getLastBlockAttributesChange, __unstableIsLastBlockChangeIgnored, } = registry.select( 'core/block-editor' ); @@ -99,19 +108,15 @@ class BlockEditorProvider extends Component { // This happens when a previous input is explicitely marked as persistent. ( newIsPersistent && ! isPersistent ) ) { - // When knowing the blocks value is changing, assign instance - // value to skip reset in subsequent `componentDidUpdate`. - if ( newBlocks !== blocks ) { - this.isSyncingOutcomingValue = true; - } - blocks = newBlocks; isPersistent = newIsPersistent; + const lastChanges = getLastBlockAttributesChange(); + this.lastPersistedBlocks.push( blocks ); if ( isPersistent ) { - onChange( blocks ); + onChange( blocks, lastChanges ); } else { - onInput( blocks ); + onInput( blocks, lastChanges ); } } } ); diff --git a/packages/block-editor/src/store/reducer.js b/packages/block-editor/src/store/reducer.js index d9d0ecbdd28b6..d0c81b06e2b38 100644 --- a/packages/block-editor/src/store/reducer.js +++ b/packages/block-editor/src/store/reducer.js @@ -983,8 +983,35 @@ export const blockListSettings = ( state = {}, action ) => { return state; }; +export function lastBlockAttributesChanges( state, action ) { + switch ( action.type ) { + case 'UPDATE_BLOCK': + if ( ! action.updates.attributes ) { + return null; + } + return { + [ action.clientId ]: action.updates.attributes, + }; + + case 'UPDATE_BLOCK_ATTRIBUTES': + return { + [ action.clientId ]: action.attributes, + }; + + case 'RESET_BLOCKS': + case 'INSERT_BLOCKS': + case 'RECEIVE_BLOCKS': + case 'REPLACE_BLOCKS': + return getFlattenedBlockAttributes( action.blocks ); + } + + return null; +} + export default combineReducers( { blocks, + // This is ideally embedded in blocks, the issues is that it alterns the isPersistent behavior + lastBlockAttributesChanges, isTyping, isCaretWithinFormattedText, blockSelection, diff --git a/packages/block-editor/src/store/selectors.js b/packages/block-editor/src/store/selectors.js index 6633611fbd9b1..beaf48d0d817b 100644 --- a/packages/block-editor/src/store/selectors.js +++ b/packages/block-editor/src/store/selectors.js @@ -64,14 +64,6 @@ const MILLISECONDS_PER_WEEK = 7 * 24 * 3600 * 1000; */ const EMPTY_ARRAY = []; -/** - * Shared reference to an empty object for cases where it is important to avoid - * returning a new object reference on every invocation. - * - * @type {Object} - */ -const EMPTY_OBJECT = {}; - /** * Returns a new reference when the inner blocks of a given block client ID * change. This is used exclusively as a memoized selector dependant, relying @@ -130,42 +122,14 @@ export function isBlockValid( state, clientId ) { * * @return {Object?} Block attributes. */ -export const getBlockAttributes = createSelector( - ( state, clientId ) => { - const block = state.blocks.byClientId[ clientId ]; - if ( ! block ) { - return null; - } - - let attributes = state.blocks.attributes[ clientId ]; - - // Inject custom source attribute values. - // - // TODO: Create generic external sourcing pattern, not explicitly - // targeting meta attributes. - const type = getBlockType( block.name ); - if ( type ) { - attributes = reduce( type.attributes, ( result, value, key ) => { - if ( value.source === 'meta' ) { - if ( result === attributes ) { - result = { ...result }; - } - - result[ key ] = getPostMeta( state, value.meta ); - } - - return result; - }, attributes ); - } +export function getBlockAttributes( state, clientId ) { + const block = state.blocks.byClientId[ clientId ]; + if ( ! block ) { + return null; + } - return attributes; - }, - ( state, clientId ) => [ - state.blocks.byClientId[ clientId ], - state.blocks.attributes[ clientId ], - getPostMeta( state ), - ] -); + return state.blocks.attributes[ clientId ]; +} /** * Returns a block given its client ID. This is a parsed copy of the block, @@ -192,7 +156,8 @@ export const getBlock = createSelector( }; }, ( state, clientId ) => [ - ...getBlockAttributes.getDependants( state, clientId ), + state.blocks.attributes[ clientId ], + state.blocks.byClientId[ clientId ], getBlockDependantsCacheBust( state, clientId ), ] ); @@ -211,7 +176,7 @@ export const __unstableGetBlockWithoutInnerBlocks = createSelector( }, ( state, clientId ) => [ state.blocks.byClientId[ clientId ], - ...getBlockAttributes.getDependants( state, clientId ), + state.blocks.attributes[ clientId ], ] ); @@ -314,7 +279,6 @@ export const getBlocksByClientId = createSelector( ( clientId ) => getBlock( state, clientId ) ), ( state ) => [ - getPostMeta( state ), state.blocks.byClientId, state.blocks.order, state.blocks.attributes, @@ -691,7 +655,6 @@ export const getMultiSelectedBlocks = createSelector( state.blocks.byClientId, state.blocks.order, state.blocks.attributes, - getPostMeta( state ), ] ); @@ -1437,6 +1400,17 @@ export function isLastBlockChangePersistent( state ) { return state.blocks.isPersistentChange; } +/** + * Returns an object describing the last block attributes changes + * + * @param {Object} state Block editor state. + * + * @return {Object} the clientId and the block attributes changes + */ +export function getLastBlockAttributesChange( state ) { + return state.lastBlockAttributesChanges; +} + /** * Returns true if the most recent block change is be considered ignored, or * false otherwise. An ignored change is one not to be committed by @@ -1455,22 +1429,6 @@ export function __unstableIsLastBlockChangeIgnored( state ) { return state.blocks.isIgnoredChange; } -/** - * Returns the value of a post meta from the editor settings. - * - * @param {Object} state Global application state. - * @param {string} key Meta Key to retrieve - * - * @return {*} Meta value - */ -function getPostMeta( state, key ) { - if ( key === undefined ) { - return get( state, [ 'settings', '__experimentalMetaSource', 'value' ], EMPTY_OBJECT ); - } - - return get( state, [ 'settings', '__experimentalMetaSource', 'value', key ] ); -} - /** * Returns the available reusable blocks * diff --git a/packages/block-editor/src/store/test/selectors.js b/packages/block-editor/src/store/test/selectors.js index c038a40621eff..f2e465b00f181 100644 --- a/packages/block-editor/src/store/test/selectors.js +++ b/packages/block-editor/src/store/test/selectors.js @@ -473,54 +473,6 @@ describe( 'selectors', () => { } ], } ); } ); - - it( 'should merge meta attributes for the block', () => { - registerBlockType( 'core/meta-block', { - save: ( props ) => props.attributes.text, - category: 'common', - title: 'test block', - attributes: { - foo: { - type: 'string', - source: 'meta', - meta: 'foo', - }, - }, - } ); - - const state = { - settings: { - __experimentalMetaSource: { - value: { - foo: 'bar', - }, - }, - }, - blocks: { - byClientId: { - 123: { clientId: 123, name: 'core/meta-block' }, - }, - attributes: { - 123: {}, - }, - order: { - '': [ 123 ], - 123: [], - }, - }, - }; - - expect( getBlock( state, 123 ) ).toEqual( { - clientId: 123, - name: 'core/meta-block', - attributes: { - foo: 'bar', - }, - innerBlocks: [], - } ); - - unregisterBlockType( 'core/meta-block' ); - } ); } ); describe( 'getBlocks', () => { diff --git a/packages/e2e-tests/specs/performance.test.js b/packages/e2e-tests/specs/performance.test.js index 1d547f8075139..c63ffb1156087 100644 --- a/packages/e2e-tests/specs/performance.test.js +++ b/packages/e2e-tests/specs/performance.test.js @@ -34,7 +34,7 @@ describe( 'Performance', () => { } } ); - dispatch( 'core/editor' ).resetBlocks( blocks ); + dispatch( 'core/block-editor' ).resetBlocks( blocks ); }, html ); await saveDraft(); diff --git a/packages/editor/src/components/provider/index.js b/packages/editor/src/components/provider/index.js index e897931daf6c4..99a99e30cbd13 100644 --- a/packages/editor/src/components/provider/index.js +++ b/packages/editor/src/components/provider/index.js @@ -71,7 +71,7 @@ class EditorProvider extends Component { } } - getBlockEditorSettings( settings, meta, onMetaChange, reusableBlocks, hasUploadPermissions ) { + getBlockEditorSettings( settings, reusableBlocks, hasUploadPermissions ) { return { ...pick( settings, [ 'alignWide', @@ -94,10 +94,6 @@ class EditorProvider extends Component { 'templateLock', 'titlePlaceholder', ] ), - __experimentalMetaSource: { - value: meta, - onChange: onMetaChange, - }, __experimentalReusableBlocks: reusableBlocks, __experimentalMediaUpload: hasUploadPermissions ? mediaUpload : undefined, __experimentalFetchLinkSuggestions: fetchLinkSuggestions, @@ -132,14 +128,12 @@ class EditorProvider extends Component { const { children, blocks, - resetEditorBlocks, isReady, settings, - meta, - onMetaChange, - reusableBlocks, - resetEditorBlocksWithoutUndoLevel, hasUploadPermissions, + onInput, + onChange, + reusableBlocks, } = this.props; if ( ! isReady ) { @@ -147,14 +141,14 @@ class EditorProvider extends Component { } const editorSettings = this.getBlockEditorSettings( - settings, meta, onMetaChange, reusableBlocks, hasUploadPermissions + settings, reusableBlocks, hasUploadPermissions ); return ( @@ -171,7 +165,6 @@ export default compose( [ const { __unstableIsEditorReady: isEditorReady, getEditorBlocks, - getEditedPostAttribute, __experimentalGetReusableBlocks, } = select( 'core/editor' ); const { canUser } = select( 'core' ); @@ -179,7 +172,6 @@ export default compose( [ return { isReady: isEditorReady(), blocks: getEditorBlocks(), - meta: getEditedPostAttribute( 'meta' ), reusableBlocks: __experimentalGetReusableBlocks(), hasUploadPermissions: defaultTo( canUser( 'create', 'media' ), true ), }; @@ -189,7 +181,6 @@ export default compose( [ setupEditor, updatePostLock, resetEditorBlocks, - editPost, updateEditorSettings, } = dispatch( 'core/editor' ); const { createWarningNotice } = dispatch( 'core/notices' ); @@ -198,16 +189,18 @@ export default compose( [ setupEditor, updatePostLock, createWarningNotice, - resetEditorBlocks, + onChange( blocks, lastChanges ) { + resetEditorBlocks( blocks, { + __unstableLastChanges: lastChanges, + } ); + }, updateEditorSettings, - resetEditorBlocksWithoutUndoLevel( blocks ) { + onInput( blocks, lastChanges ) { resetEditorBlocks( blocks, { __unstableShouldCreateUndoLevel: false, + __unstableLastChanges: lastChanges, } ); }, - onMetaChange( meta ) { - editPost( { meta } ); - }, }; } ), ] )( EditorProvider ); diff --git a/packages/editor/src/sources/meta.js b/packages/editor/src/sources/meta.js new file mode 100644 index 0000000000000..a129f4559dbc4 --- /dev/null +++ b/packages/editor/src/sources/meta.js @@ -0,0 +1,42 @@ +/** + * External dependencies + */ +import { forEach } from 'lodash'; + +/** + * WordPress dependencies + */ +import { getBlockType } from '@wordpress/blocks'; + +const source = { + name: 'meta', + + synchronize( block, metaValues ) { + const currentBlockType = getBlockType( block.name ); + let newAttributes; + forEach( currentBlockType.attributes, ( attributeConfig, attributeName ) => { + if ( attributeConfig.source === 'meta' ) { + forEach( metaValues, ( newValue, key ) => { + if ( attributeConfig.meta === key ) { + newAttributes = newAttributes || {}; + newAttributes[ attributeName ] = newValue; + } + } ); + } + } ); + + if ( ! newAttributes ) { + return block; + } + + return { + ...block, + attributes: { + ...block.attributes, + ...newAttributes, + }, + }; + }, +}; + +export default source; diff --git a/packages/editor/src/store/actions.js b/packages/editor/src/store/actions.js index 073887663bd1b..255e0dfecbbc7 100644 --- a/packages/editor/src/store/actions.js +++ b/packages/editor/src/store/actions.js @@ -1,7 +1,7 @@ /** * External dependencies */ -import { castArray, pick, mapValues, has } from 'lodash'; +import { castArray, pick, mapValues, has, forEach, find } from 'lodash'; import { BEGIN, COMMIT, REVERT } from 'redux-optimist'; /** @@ -12,6 +12,7 @@ import { dispatch, select, apiFetch } from '@wordpress/data-controls'; import { parse, synchronizeBlocksWithTemplate, + getBlockType, } from '@wordpress/blocks'; /** @@ -32,6 +33,65 @@ import { getNotificationArgumentsForSaveFail, getNotificationArgumentsForTrashFail, } from './utils/notice-builder'; +import metaSource from '../sources/meta'; + +/** + * Function used to travers a list of block, calling a mapping function on each block + * The list is only updated if at least one block is updated. + * + * @param {Array} blocks Block Array. + * @param {function} callback Mapping function. + * + * @return {Array} Updated Blocks. + */ +function mapBlocks( blocks, callback ) { + let hasUpdatedBlock = false; + const updatedBlocks = blocks.map( ( block ) => { + const updatedBlock = callback( block ); + const updatedInnerBlocks = mapBlocks( block.innerBlocks, callback ); + + if ( updatedBlock !== block || updatedInnerBlocks !== block.innerBlocks ) { + hasUpdatedBlock = true; + } + + return { + ...updatedBlock, + innerBlocks: updatedInnerBlocks, + }; + } ); + + return hasUpdatedBlock ? updatedBlocks : blocks; +} + +/** + * Returns an action object used to signal that attributes of some post edits should be applied to the state. + * + * @param {Object} edits Post attributes to edit. + * + * @return {Object} Action object. + */ +function getEditPostAction( edits ) { + return { + type: 'EDIT_POST', + edits, + }; +} + +/** + * Returns an action object used to signal that the blocks have been updated. + * + * @param {Array} blocks Block Array. + * @param {?Object} options Optional options. + * + * @return {Object} Action object. + */ +function getResetBlocksAction( blocks, options = {} ) { + return { + type: 'RESET_EDITOR_BLOCKS', + blocks, + shouldCreateUndoLevel: options.__unstableShouldCreateUndoLevel !== false, + }; +} /** * Returns an action generator used in signalling that editor has initialized with @@ -59,8 +119,12 @@ export function* setupEditor( post, edits, template ) { content = post.content.raw; } + // Parse content blocks let blocks = parse( content ); + // Augment with post attributes + blocks = mapBlocks( blocks, ( block ) => metaSource.synchronize( block, post.meta ) ); + // Apply a template for new posts only, if exists. const isNewPost = post.status === 'auto-draft'; if ( isNewPost && template ) { @@ -225,14 +289,15 @@ export function setupEditorState( post ) { * been edited. * * @param {Object} edits Post attributes to edit. - * - * @return {Object} Action object. */ -export function editPost( edits ) { - return { - type: 'EDIT_POST', - edits, - }; +export function* editPost( edits ) { + if ( edits && edits.meta ) { + const blocks = yield select( 'core/editor', 'getEditorBlocks' ); + const updatedBlocks = mapBlocks( blocks, ( block ) => metaSource.synchronize( block, edits.meta ) ); + yield getResetBlocksAction( updatedBlocks ); + } + + yield getEditPostAction( edits ); } /** @@ -727,15 +792,41 @@ export function unlockPostSaving( lockName ) { * * @param {Array} blocks Block Array. * @param {?Object} options Optional options. - * - * @return {Object} Action object */ -export function resetEditorBlocks( blocks, options = {} ) { - return { - type: 'RESET_EDITOR_BLOCKS', - blocks, - shouldCreateUndoLevel: options.__unstableShouldCreateUndoLevel !== false, +export function* resetEditorBlocks( blocks, options = {} ) { + // Handle meta sources + let hasMetaChanges = false; + let updatedBlocks = blocks; + const postEdits = { + meta: {}, }; + if ( options.__unstableLastChanges ) { + const meta = yield select( 'core/editor', 'getEditedPostAttribute', 'meta' ); + forEach( options.__unstableLastChanges, ( newAttributes, clientId ) => { + const changedBlock = find( blocks, ( current ) => current.clientId === clientId ); + if ( changedBlock ) { + const blockType = getBlockType( changedBlock.name ); + forEach( newAttributes, ( newValue, key ) => { + const attributeConfig = blockType.attributes[ key ]; + if ( attributeConfig.source === 'meta' && newValue !== meta[ attributeConfig.meta ] ) { + postEdits.meta[ attributeConfig.meta ] = newValue; + hasMetaChanges = true; + } + } ); + } + } ); + + // Update all the references to this metaValue + if ( hasMetaChanges ) { + updatedBlocks = mapBlocks( blocks, ( block ) => metaSource.synchronize( block, postEdits.meta ) ); + } + } + + yield getResetBlocksAction( updatedBlocks, options ); + + if ( hasMetaChanges ) { + yield getEditPostAction( postEdits ); + } } /* diff --git a/packages/editor/src/store/test/actions.js b/packages/editor/src/store/test/actions.js index ab698ea82a731..43670ec7932b2 100644 --- a/packages/editor/src/store/test/actions.js +++ b/packages/editor/src/store/test/actions.js @@ -744,7 +744,8 @@ describe( 'Editor actions', () => { describe( 'editPost', () => { it( 'should return EDIT_POST action', () => { const edits = { format: 'sample' }; - expect( actions.editPost( edits ) ).toEqual( { + const gen = actions.editPost( edits ); + expect( gen.next( edits ).value ).toEqual( { type: 'EDIT_POST', edits, } );