diff --git a/packages/block-editor/src/components/block-list/block.js b/packages/block-editor/src/components/block-list/block.js index 3556b829ba188..320f63555946c 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'; import { animated } from 'react-spring/web.cjs'; /** @@ -658,42 +658,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/README.md b/packages/block-editor/src/components/provider/README.md new file mode 100644 index 0000000000000..647c5854d0e21 --- /dev/null +++ b/packages/block-editor/src/components/provider/README.md @@ -0,0 +1,40 @@ +BlockEditorProvider +=================== + +BlockEditorProvider is a component which establishes a new block editing context, and serves as the entry point for a new block editor. It is implemented as a [controlled input](https://reactjs.org/docs/forms.html#controlled-components), expected to receive a value of a blocks array, calling `onChange` and/or `onInput` when the user interacts to change blocks in the editor. It is intended to be used as a wrapper component, where its children comprise the user interface through which a user modifies the blocks value, notably via other components made available from this `block-editor` module. + +## Props + +### `value` + +* **Type:** `Array` +* **Required** `no` + +The current array of blocks. + +### `onChange` + +* **Type:** `Function` +* **Required** `no` + +A callback invoked when the blocks have been modified in a persistent manner. Contrasted with `onInput`, a "persistent" change is one which is not an extension of a composed input. Any update to a distinct block or block attribute is treated as persistent. + +The distinction between these two callbacks is akin to the [differences between `input` and `change` events](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/input_event) in the DOM API: + +>The input event is fired every time the value of the element changes. **This is unlike the change event, which only fires when the value is committed**, such as by pressing the enter key, selecting a value from a list of options, and the like. + +In the context of an editor, an example usage of this distinction is for managing a history of blocks values (an "Undo"/"Redo" mechanism). While value updates should always be reflected immediately (`onInput`), you may only want history entries to reflect change milestones (`onChange`). + +### `onInput` + +* **Type:** `Function` +* **Required** `no` + +A callback invoked when the blocks have been modified in a non-persistent manner. Contrasted with `onChange`, a "non-persistent" change is one which is part of a composed input. Any sequence of updates to the same block attribute are treated as non-persistent, except for the first. + +### `children` + +* **Type:** `WPElement` +* **Required** `no` + +Children elements for which the BlockEditorProvider context should apply. diff --git a/packages/block-editor/src/components/provider/index.js b/packages/block-editor/src/components/provider/index.js index 6d06246ca9170..33eff22a08892 100644 --- a/packages/block-editor/src/components/provider/index.js +++ b/packages/block-editor/src/components/provider/index.js @@ -34,10 +34,21 @@ class BlockEditorProvider extends Component { this.attachChangeObserver( registry ); } - if ( this.isSyncingOutcomingValue ) { - this.isSyncingOutcomingValue = false; + if ( this.isSyncingOutcomingValue !== null && this.isSyncingOutcomingValue === value ) { + // Skip block reset if the value matches expected outbound sync + // triggered by this component by a preceding change detection. + // Only skip if the value matches expectation, since a reset should + // still occur if the value is modified (not equal by reference), + // to allow that the consumer may apply modifications to reflect + // back on the editor. + this.isSyncingOutcomingValue = null; } else if ( value !== prevProps.value ) { - this.isSyncingIncomingValue = true; + // Reset changing value in all other cases than the sync described + // above. Since this can be reached in an update following an out- + // bound sync, unset the outbound value to avoid considering it in + // subsequent renders. + this.isSyncingOutcomingValue = null; + this.isSyncingIncomingValue = value; resetBlocks( value ); } } @@ -79,15 +90,17 @@ class BlockEditorProvider extends Component { onChange, onInput, } = this.props; + const newBlocks = getBlocks(); const newIsPersistent = isLastBlockChangePersistent(); + if ( newBlocks !== blocks && ( this.isSyncingIncomingValue || __unstableIsLastBlockChangeIgnored() ) ) { - this.isSyncingIncomingValue = false; + this.isSyncingIncomingValue = null; blocks = newBlocks; isPersistent = newIsPersistent; return; @@ -101,7 +114,7 @@ class BlockEditorProvider extends Component { // When knowing the blocks value is changing, assign instance // value to skip reset in subsequent `componentDidUpdate`. if ( newBlocks !== blocks ) { - this.isSyncingOutcomingValue = true; + this.isSyncingOutcomingValue = newBlocks; } blocks = newBlocks; diff --git a/packages/block-editor/src/store/reducer.js b/packages/block-editor/src/store/reducer.js index b4cd52a08886e..8df16d7bda9eb 100644 --- a/packages/block-editor/src/store/reducer.js +++ b/packages/block-editor/src/store/reducer.js @@ -195,29 +195,6 @@ export function isUpdatingSameBlockAttribute( action, lastAction ) { ); } -/** - * Higher-order reducer intended to reset the cache key of all blocks - * whenever the post meta values change. - * - * @param {Function} reducer Original reducer function. - * - * @return {Function} Enhanced reducer function. - */ -const withPostMetaUpdateCacheReset = ( reducer ) => ( state, action ) => { - const newState = reducer( state, action ); - const previousMetaValues = get( state, [ 'settings', '__experimentalMetaSource', 'value' ] ); - const nextMetaValues = get( newState.settings.__experimentalMetaSource, [ 'value' ] ); - // If post meta values change, reset the cache key for all blocks - if ( previousMetaValues !== nextMetaValues ) { - newState.blocks = { - ...newState.blocks, - cache: mapValues( newState.blocks.cache, () => ( {} ) ), - }; - } - - return newState; -}; - /** * Utility returning an object with an empty object value for each key. * @@ -1228,17 +1205,44 @@ export const blockListSettings = ( state = {}, action ) => { return state; }; -export default withPostMetaUpdateCacheReset( - combineReducers( { - blocks, - isTyping, - isCaretWithinFormattedText, - blockSelection, - blocksMode, - blockListSettings, - insertionPoint, - template, - settings, - preferences, - } ) -); +/** + * Reducer return an updated state representing the most recent block attribute + * update. The state is structured as an object where the keys represent the + * client IDs of blocks, the values a subset of attributes from the most recent + * block update. The state is always reset to null if the last action is + * anything other than an attributes update. + * + * @param {Object} state Current state. + * @param {Object} action Action object. + * + * @return {[string,Object]} Updated state. + */ +export function lastBlockAttributesChange( state, action ) { + switch ( action.type ) { + case 'UPDATE_BLOCK': + if ( ! action.updates.attributes ) { + break; + } + + return { [ action.clientId ]: action.updates.attributes }; + + case 'UPDATE_BLOCK_ATTRIBUTES': + return { [ action.clientId ]: action.attributes }; + } + + return null; +} + +export default combineReducers( { + blocks, + isTyping, + isCaretWithinFormattedText, + blockSelection, + blocksMode, + blockListSettings, + insertionPoint, + template, + settings, + preferences, + lastBlockAttributesChange, +} ); diff --git a/packages/block-editor/src/store/selectors.js b/packages/block-editor/src/store/selectors.js index 85a7442cd8613..5e8771ff94935 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 block's name given its client ID, or null if no block exists with * the client ID. @@ -108,42 +100,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, @@ -193,7 +157,7 @@ export const __unstableGetBlockWithoutInnerBlocks = createSelector( }, ( state, clientId ) => [ state.blocks.byClientId[ clientId ], - ...getBlockAttributes.getDependants( state, clientId ), + state.blocks.attributes[ clientId ], ] ); @@ -296,7 +260,6 @@ export const getBlocksByClientId = createSelector( ( clientId ) => getBlock( state, clientId ) ), ( state ) => [ - getPostMeta( state ), state.blocks.byClientId, state.blocks.order, state.blocks.attributes, @@ -656,7 +619,6 @@ export const getMultiSelectedBlocks = createSelector( state.blocks.byClientId, state.blocks.order, state.blocks.attributes, - getPostMeta( state ), ] ); @@ -1421,19 +1383,16 @@ export function __unstableIsLastBlockChangeIgnored( state ) { } /** - * Returns the value of a post meta from the editor settings. + * Returns the block attributes changed as a result of the last dispatched + * action. * - * @param {Object} state Global application state. - * @param {string} key Meta Key to retrieve + * @param {Object} state Block editor state. * - * @return {*} Meta value + * @return {Object} Subsets of block attributes changed, keyed + * by block client ID. */ -function getPostMeta( state, key ) { - if ( key === undefined ) { - return get( state, [ 'settings', '__experimentalMetaSource', 'value' ], EMPTY_OBJECT ); - } - - return get( state, [ 'settings', '__experimentalMetaSource', 'value', key ] ); +export function __experimentalGetLastBlockAttributeChanges( state ) { + return state.lastBlockAttributesChange; } /** diff --git a/packages/block-editor/src/store/test/reducer.js b/packages/block-editor/src/store/test/reducer.js index 99f0ecb16282a..0626f57fb4129 100644 --- a/packages/block-editor/src/store/test/reducer.js +++ b/packages/block-editor/src/store/test/reducer.js @@ -28,6 +28,7 @@ import { insertionPoint, template, blockListSettings, + lastBlockAttributesChange, } from '../reducer'; describe( 'state', () => { @@ -2390,4 +2391,70 @@ describe( 'state', () => { expect( state ).toEqual( {} ); } ); } ); + + describe( 'lastBlockAttributesChange', () => { + it( 'defaults to null', () => { + const state = lastBlockAttributesChange( undefined, {} ); + + expect( state ).toBe( null ); + } ); + + it( 'does not return updated value when block update does not include attributes', () => { + const original = null; + + const state = lastBlockAttributesChange( original, { + type: 'UPDATE_BLOCK', + clientId: 'afd1cb17-2c08-4e7a-91be-007ba7ddc3a1', + updates: {}, + } ); + + expect( state ).toBe( original ); + } ); + + it( 'returns updated value when block update includes attributes', () => { + const original = null; + + const state = lastBlockAttributesChange( original, { + type: 'UPDATE_BLOCK', + clientId: 'afd1cb17-2c08-4e7a-91be-007ba7ddc3a1', + updates: { + attributes: { + food: 'banana', + }, + }, + } ); + + expect( state ).toEqual( { + 'afd1cb17-2c08-4e7a-91be-007ba7ddc3a1': { food: 'banana' }, + } ); + } ); + + it( 'returns updated value when explicit block attributes update', () => { + const original = null; + + const state = lastBlockAttributesChange( original, { + type: 'UPDATE_BLOCK_ATTRIBUTES', + clientId: 'afd1cb17-2c08-4e7a-91be-007ba7ddc3a1', + attributes: { + food: 'banana', + }, + } ); + + expect( state ).toEqual( { + 'afd1cb17-2c08-4e7a-91be-007ba7ddc3a1': { food: 'banana' }, + } ); + } ); + + it( 'returns null on anything other than block attributes update', () => { + const original = deepFreeze( { + 'afd1cb17-2c08-4e7a-91be-007ba7ddc3a1': { food: 'banana' }, + } ); + + const state = lastBlockAttributesChange( original, { + type: '__INERT__', + } ); + + expect( state ).toBe( null ); + } ); + } ); } ); diff --git a/packages/block-editor/src/store/test/selectors.js b/packages/block-editor/src/store/test/selectors.js index 7bc0390317ece..4e1289e44aefc 100644 --- a/packages/block-editor/src/store/test/selectors.js +++ b/packages/block-editor/src/store/test/selectors.js @@ -58,6 +58,7 @@ const { getTemplate, getTemplateLock, getBlockListSettings, + __experimentalGetLastBlockAttributeChanges, INSERTER_UTILITY_HIGH, INSERTER_UTILITY_MEDIUM, INSERTER_UTILITY_LOW, @@ -263,60 +264,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: [], - }, - parents: { - 123: '', - }, - cache: { - 123: {}, - }, - }, - }; - - expect( getBlock( state, 123 ) ).toEqual( { - clientId: 123, - name: 'core/meta-block', - attributes: { - foo: 'bar', - }, - innerBlocks: [], - } ); - - unregisterBlockType( 'core/meta-block' ); - } ); } ); describe( 'getBlocks', () => { @@ -2492,4 +2439,20 @@ describe( 'selectors', () => { expect( getBlockListSettings( state, 'chicken' ) ).toBe( undefined ); } ); } ); + + describe( '__experimentalGetLastBlockAttributeChanges', () => { + it( 'returns the last block attributes change', () => { + const state = { + lastBlockAttributesChange: { + block1: { fruit: 'bananas' }, + }, + }; + + const result = __experimentalGetLastBlockAttributeChanges( state ); + + expect( result ).toEqual( { + block1: { fruit: 'bananas' }, + } ); + } ); + } ); } ); diff --git a/packages/blocks/CHANGELOG.md b/packages/blocks/CHANGELOG.md index d38762e51694a..fdbc957dd802e 100644 --- a/packages/blocks/CHANGELOG.md +++ b/packages/blocks/CHANGELOG.md @@ -1,5 +1,9 @@ ## Master +### Improvements + +- Omitting `attributes` or `keywords` settings will now stub default values (an empty object or empty array, respectively). + ### Bug Fixes - The `'blocks.registerBlockType'` filter is now applied to each of a block's deprecated settings as well as the block's main settings. Ensures `supports` settings like `anchor` work for deprecations. diff --git a/packages/blocks/README.md b/packages/blocks/README.md index e456eb546ffbb..331be71c3a15f 100644 --- a/packages/blocks/README.md +++ b/packages/blocks/README.md @@ -548,11 +548,11 @@ in the codebase. _Parameters_ -- _icon_ `(Object|string|WPElement)`: Slug of the Dashicon to be shown as the icon for the block in the inserter, or element or an object describing the icon. +- _icon_ `WPBlockTypeIconRender`: Render behavior of a block type icon; one of a Dashicon slug, an element, or a component. _Returns_ -- `Object`: Object describing the icon. +- `WPBlockTypeIconDescriptor`: Object describing the icon. # **parse** diff --git a/packages/blocks/src/api/registration.js b/packages/blocks/src/api/registration.js index 83dcc057c94e6..498f83627e6ff 100644 --- a/packages/blocks/src/api/registration.js +++ b/packages/blocks/src/api/registration.js @@ -21,30 +21,72 @@ import { select, dispatch } from '@wordpress/data'; */ import { isValidIcon, normalizeIconObject } from './utils'; +/** + * Render behavior of a block type icon; one of a Dashicon slug, an element, + * or a component. + * + * @typedef {(string|WPElement|WPComponent)} WPBlockTypeIconRender + * + * @see https://developer.wordpress.org/resource/dashicons/ + */ + +/** + * An object describing a normalized block type icon. + * + * @typedef {WPBlockTypeIconDescriptor} + * + * @property {WPBlockTypeIconRender} src Render behavior of the icon, + * one of a Dashicon slug, an + * element, or a component. + * @property {string} background Optimal background hex string + * color when displaying icon. + * @property {string} foreground Optimal foreground hex string + * color when displaying icon. + * @property {string} shadowColor Optimal shadow hex string + * color when displaying icon. + */ + +/** + * Value to use to render the icon for a block type in an editor interface, + * either a Dashicon slug, an element, a component, or an object describing + * the icon. + * + * @typedef {(WPBlockTypeIconDescriptor|WPBlockTypeIconRender)} WPBlockTypeIcon + */ + /** * Defined behavior of a block type. * * @typedef {WPBlockType} * - * @property {string} name Block's namespaced name. - * @property {string} title Human-readable label for a block. - * Shown in the block inserter. - * @property {string} category Category classification of block, - * impacting where block is shown in - * inserter results. - * @property {(Object|string|WPElement)} icon Slug of the Dashicon to be shown - * as the icon for the block in the - * inserter, or element or an object describing the icon. - * @property {?string[]} keywords Additional keywords to produce - * block as inserter search result. - * @property {?Object} attributes Block attributes. - * @property {?Function} save Serialize behavior of a block, - * returning an element describing - * structure of the block's post - * content markup. - * @property {WPComponent} edit Component rendering element to be - * interacted with in an editor. + * @property {string} name Block type's namespaced name. + * @property {string} title Human-readable block type label. + * @property {string} category Block type category classification, + * used in search interfaces to arrange + * block types by category. + * @property {?WPBlockTypeIcon} icon Block type icon. + * @property {?string[]} keywords Additional keywords to produce block + * type as result in search interfaces. + * @property {?Object} attributes Block type attributes. + * @property {?WPComponent} save Optional component describing + * serialized markup structure of a + * block type. + * @property {WPComponent} edit Component rendering an element to + * manipulate the attributes of a block + * in the context of an editor. + */ + +/** + * Default values to assign for omitted optional block type settings. + * + * @type {Object} */ +const DEFAULT_BLOCK_TYPE_SETTINGS = { + icon: 'block-default', + attributes: {}, + keywords: [], + save: () => null, +}; let serverSideBlockDefinitions = {}; @@ -74,7 +116,7 @@ export function unstable__bootstrapServerSideBlockDefinitions( definitions ) { / export function registerBlockType( name, settings ) { settings = { name, - save: () => null, + ...DEFAULT_BLOCK_TYPE_SETTINGS, ...get( serverSideBlockDefinitions, name ), ...settings, }; diff --git a/packages/blocks/src/api/test/registration.js b/packages/blocks/src/api/test/registration.js index cd8c9692c21a5..30df36c9359d4 100644 --- a/packages/blocks/src/api/test/registration.js +++ b/packages/blocks/src/api/test/registration.js @@ -95,6 +95,8 @@ describe( 'blocks', () => { icon: { src: 'block-default', }, + attributes: {}, + keywords: [], save: noop, category: 'common', title: 'block title', @@ -111,6 +113,8 @@ describe( 'blocks', () => { it( 'should reject blocks with invalid save function', () => { const block = registerBlockType( 'my-plugin/fancy-block-5', { ...defaultBlockSettings, + attributes: {}, + keywords: [], save: 'invalid', } ); expect( console ).toHaveErroredWith( 'The "save" property must be a valid function.' ); @@ -159,6 +163,25 @@ describe( 'blocks', () => { expect( block ).toBeUndefined(); } ); + it( 'should assign default settings', () => { + registerBlockType( 'core/test-block-with-defaults', { + title: 'block title', + category: 'common', + } ); + + expect( getBlockType( 'core/test-block-with-defaults' ) ).toEqual( { + name: 'core/test-block-with-defaults', + title: 'block title', + category: 'common', + icon: { + src: 'block-default', + }, + attributes: {}, + keywords: [], + save: expect.any( Function ), + } ); + } ); + it( 'should default to browser-initialized global attributes', () => { const attributes = { ok: { type: 'boolean' } }; unstable__bootstrapServerSideBlockDefinitions( { @@ -181,6 +204,7 @@ describe( 'blocks', () => { type: 'boolean', }, }, + keywords: [], } ); } ); @@ -218,6 +242,8 @@ describe( 'blocks', () => { fill="red" stroke="blue" strokeWidth="10" /> ), }, + attributes: {}, + keywords: [], } ); } ); @@ -237,6 +263,8 @@ describe( 'blocks', () => { icon: { src: 'foo', }, + attributes: {}, + keywords: [], } ); } ); @@ -262,6 +290,8 @@ describe( 'blocks', () => { icon: { src: MyTestIcon, }, + attributes: {}, + keywords: [], } ); } ); @@ -293,6 +323,8 @@ describe( 'blocks', () => { fill="red" stroke="blue" strokeWidth="10" /> ), }, + attributes: {}, + keywords: [], } ); } ); @@ -309,6 +341,8 @@ describe( 'blocks', () => { icon: { src: 'block-default', }, + attributes: {}, + keywords: [], } ); } ); @@ -394,6 +428,8 @@ describe( 'blocks', () => { icon: { src: 'block-default', }, + attributes: {}, + keywords: [], }, ] ); const oldBlock = unregisterBlockType( 'core/test-block' ); @@ -406,6 +442,8 @@ describe( 'blocks', () => { icon: { src: 'block-default', }, + attributes: {}, + keywords: [], } ); expect( getBlockTypes() ).toEqual( [] ); } ); @@ -478,6 +516,8 @@ describe( 'blocks', () => { icon: { src: 'block-default', }, + attributes: {}, + keywords: [], } ); } ); @@ -493,6 +533,8 @@ describe( 'blocks', () => { icon: { src: 'block-default', }, + attributes: {}, + keywords: [], } ); } ); } ); @@ -515,6 +557,8 @@ describe( 'blocks', () => { icon: { src: 'block-default', }, + attributes: {}, + keywords: [], }, { name: 'core/test-block-with-settings', @@ -525,6 +569,8 @@ describe( 'blocks', () => { icon: { src: 'block-default', }, + attributes: {}, + keywords: [], }, ] ); } ); diff --git a/packages/blocks/src/api/utils.js b/packages/blocks/src/api/utils.js index 6dcabbcbcea01..99dc98b5c3204 100644 --- a/packages/blocks/src/api/utils.js +++ b/packages/blocks/src/api/utils.js @@ -77,17 +77,13 @@ export function isValidIcon( icon ) { * and returns a new icon object that is normalized so we can rely on just on possible icon structure * in the codebase. * - * @param {(Object|string|WPElement)} icon Slug of the Dashicon to be shown - * as the icon for the block in the - * inserter, or element or an object describing the icon. + * @param {WPBlockTypeIconRender} icon Render behavior of a block type icon; + * one of a Dashicon slug, an element, or a + * component. * - * @return {Object} Object describing the icon. + * @return {WPBlockTypeIconDescriptor} Object describing the icon. */ export function normalizeIconObject( icon ) { - if ( ! icon ) { - icon = 'block-default'; - } - if ( isValidIcon( icon ) ) { return { src: icon }; } diff --git a/packages/editor/src/components/provider/index.js b/packages/editor/src/components/provider/index.js index d241aedac6fd5..cfa67ddbb615d 100644 --- a/packages/editor/src/components/provider/index.js +++ b/packages/editor/src/components/provider/index.js @@ -74,8 +74,6 @@ class EditorProvider extends Component { getBlockEditorSettings( settings, - meta, - onMetaChange, reusableBlocks, hasUploadPermissions, canUserUseUnfilteredHTML @@ -102,10 +100,6 @@ class EditorProvider extends Component { 'templateLock', 'titlePlaceholder', ] ), - __experimentalMetaSource: { - value: meta, - onChange: onMetaChange, - }, __experimentalReusableBlocks: reusableBlocks, __experimentalMediaUpload: hasUploadPermissions ? mediaUpload : undefined, __experimentalFetchLinkSuggestions: fetchLinkSuggestions, @@ -137,6 +131,10 @@ class EditorProvider extends Component { } } + componentWillUnmount() { + this.props.tearDownEditor(); + } + render() { const { canUserUseUnfilteredHTML, @@ -145,8 +143,6 @@ class EditorProvider extends Component { resetEditorBlocks, isReady, settings, - meta, - onMetaChange, reusableBlocks, resetEditorBlocksWithoutUndoLevel, hasUploadPermissions, @@ -158,8 +154,6 @@ class EditorProvider extends Component { const editorSettings = this.getBlockEditorSettings( settings, - meta, - onMetaChange, reusableBlocks, hasUploadPermissions, canUserUseUnfilteredHTML @@ -188,7 +182,6 @@ export default compose( [ canUserUseUnfilteredHTML, __unstableIsEditorReady: isEditorReady, getEditorBlocks, - getEditedPostAttribute, __experimentalGetReusableBlocks, } = select( 'core/editor' ); const { canUser } = select( 'core' ); @@ -197,7 +190,6 @@ export default compose( [ canUserUseUnfilteredHTML: canUserUseUnfilteredHTML(), isReady: isEditorReady(), blocks: getEditorBlocks(), - meta: getEditedPostAttribute( 'meta' ), reusableBlocks: __experimentalGetReusableBlocks(), hasUploadPermissions: defaultTo( canUser( 'create', 'media' ), true ), }; @@ -207,8 +199,8 @@ export default compose( [ setupEditor, updatePostLock, resetEditorBlocks, - editPost, updateEditorSettings, + __experimentalTearDownEditor, } = dispatch( 'core/editor' ); const { createWarningNotice } = dispatch( 'core/notices' ); @@ -223,9 +215,7 @@ export default compose( [ __unstableShouldCreateUndoLevel: false, } ); }, - onMetaChange( meta ) { - editPost( { meta } ); - }, + tearDownEditor: __experimentalTearDownEditor, }; } ), ] )( EditorProvider ); diff --git a/packages/editor/src/store/actions.js b/packages/editor/src/store/actions.js index 073887663bd1b..8ea2c9a408de8 100644 --- a/packages/editor/src/store/actions.js +++ b/packages/editor/src/store/actions.js @@ -13,6 +13,7 @@ import { parse, synchronizeBlocksWithTemplate, } from '@wordpress/blocks'; +import isShallowEqual from '@wordpress/is-shallow-equal'; /** * Internal dependencies @@ -32,6 +33,120 @@ import { getNotificationArgumentsForSaveFail, getNotificationArgumentsForTrashFail, } from './utils/notice-builder'; +import { awaitNextStateChange, getRegistry } from './controls'; +import * as sources from './block-sources'; + +/** + * Map of Registry instance to WeakMap of dependencies by custom source. + * + * @type WeakMap> + */ +const lastBlockSourceDependenciesByRegistry = new WeakMap; + +/** + * Given a blocks array, returns a blocks array with sourced attribute values + * applied. The reference will remain consistent with the original argument if + * no attribute values must be overridden. If sourced values are applied, the + * return value will be a modified copy of the original array. + * + * @param {WPBlock[]} blocks Original blocks array. + * + * @return {WPBlock[]} Blocks array with sourced values applied. + */ +function* getBlocksWithSourcedAttributes( blocks ) { + const registry = yield getRegistry(); + + let workingBlocks = blocks; + for ( let i = 0; i < blocks.length; i++ ) { + let block = blocks[ i ]; + const blockType = yield select( 'core/blocks', 'getBlockType', block.name ); + + for ( const [ attributeName, schema ] of Object.entries( blockType.attributes ) ) { + if ( ! sources[ schema.source ] || ! sources[ schema.source ].apply ) { + continue; + } + + if ( ! lastBlockSourceDependenciesByRegistry.has( registry ) ) { + continue; + } + + const blockSourceDependencies = lastBlockSourceDependenciesByRegistry.get( registry ); + if ( ! blockSourceDependencies.has( sources[ schema.source ] ) ) { + continue; + } + + const dependencies = blockSourceDependencies.get( sources[ schema.source ] ); + const sourcedAttributeValue = sources[ schema.source ].apply( schema, dependencies ); + + // It's only necessary to apply the value if it differs from the + // block's locally-assigned value, to avoid needlessly resetting + // the block editor. + if ( sourcedAttributeValue === block.attributes[ attributeName ] ) { + continue; + } + + // Create a shallow clone to mutate, leaving the original intact. + if ( workingBlocks === blocks ) { + workingBlocks = [ ...workingBlocks ]; + } + + block = { + ...block, + attributes: { + ...block.attributes, + [ attributeName ]: sourcedAttributeValue, + }, + }; + + workingBlocks.splice( i, 1, block ); + } + + // Recurse to apply source attributes to inner blocks. + if ( block.innerBlocks.length ) { + const appliedInnerBlocks = yield* getBlocksWithSourcedAttributes( block.innerBlocks ); + if ( appliedInnerBlocks !== block.innerBlocks ) { + if ( workingBlocks === blocks ) { + workingBlocks = [ ...workingBlocks ]; + } + + block = { + ...block, + innerBlocks: appliedInnerBlocks, + }; + + workingBlocks.splice( i, 1, block ); + } + } + } + + return workingBlocks; +} + +/** + * Refreshes the last block source dependencies, optionally for a given subset + * of sources (defaults to the full set of sources). + * + * @param {?Array} sourcesToUpdate Optional subset of sources to reset. + * + * @yield {Object} Yielded actions or control descriptors. + */ +function* resetLastBlockSourceDependencies( sourcesToUpdate = Object.values( sources ) ) { + if ( ! sourcesToUpdate.length ) { + return; + } + + const registry = yield getRegistry(); + + for ( const source of sourcesToUpdate ) { + if ( ! lastBlockSourceDependenciesByRegistry.has( registry ) ) { + lastBlockSourceDependenciesByRegistry.set( registry, new WeakMap ); + } + + const lastBlockSourceDependencies = lastBlockSourceDependenciesByRegistry.get( registry ); + const dependencies = yield* source.getDependencies(); + lastBlockSourceDependencies.set( source, dependencies ); + } +} /** * Returns an action generator used in signalling that editor has initialized with @@ -42,13 +157,6 @@ import { * @param {Array?} template Block Template. */ export function* setupEditor( post, edits, template ) { - yield { - type: 'SETUP_EDITOR', - post, - edits, - template, - }; - // In order to ensure maximum of a single parse during setup, edits are // included as part of editor setup action. Assume edited content as // canonical if provided, falling back to post. @@ -67,8 +175,76 @@ export function* setupEditor( post, edits, template ) { blocks = synchronizeBlocksWithTemplate( blocks, template ); } + yield resetPost( post ); + yield* resetLastBlockSourceDependencies(); + yield { + type: 'SETUP_EDITOR', + post, + edits, + template, + }; yield resetEditorBlocks( blocks ); yield setupEditorState( post ); + yield* __experimentalSubscribeSources(); +} + +/** + * Returns an action object signalling that the editor is being destroyed and + * that any necessary state or side-effect cleanup should occur. + * + * @return {Object} Action object. + */ +export function __experimentalTearDownEditor() { + return { type: 'TEAR_DOWN_EDITOR' }; +} + +/** + * Returns an action generator which loops to await the next state change, + * calling to reset blocks when a block source dependencies change. + * + * @yield {Object} Action object. + */ +export function* __experimentalSubscribeSources() { + while ( true ) { + yield awaitNextStateChange(); + + // The bailout case: If the editor becomes unmounted, it will flag + // itself as non-ready. Effectively unsubscribes from the registry. + const isStillReady = yield select( 'core/editor', '__unstableIsEditorReady' ); + if ( ! isStillReady ) { + break; + } + + const registry = yield getRegistry(); + + let reset = false; + for ( const source of Object.values( sources ) ) { + if ( ! source.getDependencies ) { + continue; + } + + const dependencies = yield* source.getDependencies(); + + if ( ! lastBlockSourceDependenciesByRegistry.has( registry ) ) { + lastBlockSourceDependenciesByRegistry.set( registry, new WeakMap ); + } + + const lastBlockSourceDependencies = lastBlockSourceDependenciesByRegistry.get( registry ); + const lastDependencies = lastBlockSourceDependencies.get( source ); + + if ( ! isShallowEqual( dependencies, lastDependencies ) ) { + lastBlockSourceDependencies.set( source, dependencies ); + + // Allow the loop to continue in order to assign latest + // dependencies values, but mark for reset. + reset = true; + } + } + + if ( reset ) { + yield resetEditorBlocks( yield select( 'core/editor', 'getEditorBlocks' ) ); + } + } } /** @@ -730,10 +906,46 @@ export function unlockPostSaving( lockName ) { * * @return {Object} Action object */ -export function resetEditorBlocks( blocks, options = {} ) { +export function* resetEditorBlocks( blocks, options = {} ) { + const lastBlockAttributesChange = yield select( 'core/block-editor', '__experimentalGetLastBlockAttributeChanges' ); + + // Sync to sources from block attributes updates. + if ( lastBlockAttributesChange ) { + const updatedSources = new Set; + const updatedBlockTypes = new Set; + for ( const [ clientId, attributes ] of Object.entries( lastBlockAttributesChange ) ) { + const blockName = yield select( 'core/block-editor', 'getBlockName', clientId ); + if ( updatedBlockTypes.has( blockName ) ) { + continue; + } + + updatedBlockTypes.add( blockName ); + const blockType = yield select( 'core/blocks', 'getBlockType', blockName ); + + for ( const [ attributeName, newAttributeValue ] of Object.entries( attributes ) ) { + if ( ! blockType.attributes.hasOwnProperty( attributeName ) ) { + continue; + } + + const schema = blockType.attributes[ attributeName ]; + const source = sources[ schema.source ]; + + if ( source && source.update ) { + yield* source.update( schema, newAttributeValue ); + updatedSources.add( source ); + } + } + } + + // Dependencies are reset so that source dependencies subscription + // skips a reset which would otherwise occur by dependencies change. + // This assures that at most one reset occurs per block change. + yield* resetLastBlockSourceDependencies( Array.from( updatedSources ) ); + } + return { type: 'RESET_EDITOR_BLOCKS', - blocks, + blocks: yield* getBlocksWithSourcedAttributes( blocks ), shouldCreateUndoLevel: options.__unstableShouldCreateUndoLevel !== false, }; } diff --git a/packages/editor/src/store/block-sources/README.md b/packages/editor/src/store/block-sources/README.md new file mode 100644 index 0000000000000..0c16d12b3159d --- /dev/null +++ b/packages/editor/src/store/block-sources/README.md @@ -0,0 +1,22 @@ +Block Sources +============= + +By default, the blocks module supports only attributes serialized into a block's comment demarcations, or those sourced from a [standard set of sources](https://developer.wordpress.org/block-editor/developers/block-api/block-attributes/). Since the blocks module is intended to be used in a number of contexts outside the post editor, the implementation of additional context-specific sources must be implemented as an external process. + +The post editor supports such additional sources for attributes (e.g. `meta` source). + +These sources are implemented here using a uniform interface for applying and responding to block updates to sourced attributes. In the future, this interface may be generalized to allow third-party extensions to either extend the post editor sources or implement their own in custom renderings of a block editor. + +## Source API + +### `getDependencies` + +Store control called on every store change, expected to return an object whose values represent the data blocks assigned this source depend on. When these values change, all blocks assigned this source are automatically updated. The value returned from this function is passed as the second argument of the source's `apply` function, where it is expected to be used as shared data relevant for sourcing the attribute value. + +### `apply` + +Function called to retrieve an attribute value for a block. Given the attribute schema and the dependencies defined by the source's `getDependencies`, the function should return the expected attribute value. + +### `update` + +Store control called when a single block's attributes have been updated, before the new block value has taken effect (i.e. before `apply` and `applyAll` are once again called). Given the attribute schema and updated value, the control should reflect the update on the source. diff --git a/packages/editor/src/store/block-sources/__mocks__/index.js b/packages/editor/src/store/block-sources/__mocks__/index.js new file mode 100644 index 0000000000000..cb0ff5c3b541f --- /dev/null +++ b/packages/editor/src/store/block-sources/__mocks__/index.js @@ -0,0 +1 @@ +export {}; diff --git a/packages/editor/src/store/block-sources/index.js b/packages/editor/src/store/block-sources/index.js new file mode 100644 index 0000000000000..542d774c313ce --- /dev/null +++ b/packages/editor/src/store/block-sources/index.js @@ -0,0 +1,6 @@ +/** + * Internal dependencies + */ +import * as meta from './meta'; + +export { meta }; diff --git a/packages/editor/src/store/block-sources/meta.js b/packages/editor/src/store/block-sources/meta.js new file mode 100644 index 0000000000000..3910395c4a740 --- /dev/null +++ b/packages/editor/src/store/block-sources/meta.js @@ -0,0 +1,55 @@ +/** + * WordPress dependencies + */ +import { select } from '@wordpress/data-controls'; + +/** + * Internal dependencies + */ +import { editPost } from '../actions'; + +/** + * Store control invoked upon a state change, responsible for returning an + * object of dependencies. When a change in dependencies occurs (by shallow + * equality of the returned object), blocks are reset to apply the new sourced + * value. + * + * @yield {Object} Optional yielded controls. + * + * @return {Object} Dependencies as object. + */ +export function* getDependencies() { + return { + meta: yield select( 'core/editor', 'getEditedPostAttribute', 'meta' ), + }; +} + +/** + * Given an attribute schema and dependencies data, returns a source value. + * + * @param {Object} schema Block type attribute schema. + * @param {Object} dependencies Source dependencies. + * @param {Object} dependencies.meta Post meta. + * + * @return {Object} Block attribute value. + */ +export function apply( schema, { meta } ) { + return meta[ schema.meta ]; +} + +/** + * Store control invoked upon a block attributes update, responsible for + * reflecting an update in a meta value. + * + * @param {Object} schema Block type attribute schema. + * @param {*} value Updated block attribute value. + * + * @yield {Object} Yielded action objects or store controls. + */ +export function* update( schema, value ) { + yield editPost( { + meta: { + [ schema.meta ]: value, + }, + } ); +} diff --git a/packages/editor/src/store/controls.js b/packages/editor/src/store/controls.js new file mode 100644 index 0000000000000..3b0288d00b0a5 --- /dev/null +++ b/packages/editor/src/store/controls.js @@ -0,0 +1,38 @@ +/** + * WordPress dependencies + */ +import { createRegistryControl } from '@wordpress/data'; + +/** + * Returns a control descriptor signalling to subscribe to the registry and + * resolve the control promise only when the next state change occurs. + * + * @return {Object} Control descriptor. + */ +export function awaitNextStateChange() { + return { type: 'AWAIT_NEXT_STATE_CHANGE' }; +} + +/** + * Returns a control descriptor signalling to resolve with the current data + * registry. + * + * @return {Object} Control descriptor. + */ +export function getRegistry() { + return { type: 'GET_REGISTRY' }; +} + +const controls = { + AWAIT_NEXT_STATE_CHANGE: createRegistryControl( + ( registry ) => () => new Promise( ( resolve ) => { + const unsubscribe = registry.subscribe( () => { + unsubscribe(); + resolve(); + } ); + } ) + ), + GET_REGISTRY: createRegistryControl( ( registry ) => () => registry ), +}; + +export default controls; diff --git a/packages/editor/src/store/index.js b/packages/editor/src/store/index.js index 33c5686396097..8f377151e08d4 100644 --- a/packages/editor/src/store/index.js +++ b/packages/editor/src/store/index.js @@ -2,7 +2,7 @@ * WordPress dependencies */ import { registerStore } from '@wordpress/data'; -import { controls } from '@wordpress/data-controls'; +import { controls as dataControls } from '@wordpress/data-controls'; /** * Internal dependencies @@ -11,6 +11,7 @@ import reducer from './reducer'; import applyMiddlewares from './middlewares'; import * as selectors from './selectors'; import * as actions from './actions'; +import controls from './controls'; import { STORE_KEY } from './constants'; /** @@ -24,7 +25,10 @@ export const storeConfig = { reducer, selectors, actions, - controls, + controls: { + ...dataControls, + ...controls, + }, }; const store = registerStore( STORE_KEY, { diff --git a/packages/editor/src/store/reducer.js b/packages/editor/src/store/reducer.js index a6f2bd4a4565a..6676b74dcbc39 100644 --- a/packages/editor/src/store/reducer.js +++ b/packages/editor/src/store/reducer.js @@ -558,6 +558,9 @@ export function isReady( state = false, action ) { switch ( action.type ) { case 'SETUP_EDITOR_STATE': return true; + + case 'TEAR_DOWN_EDITOR': + return false; } return state; diff --git a/packages/editor/src/store/test/actions.js b/packages/editor/src/store/test/actions.js index dd78100a4385d..ff10dcf0a9044 100644 --- a/packages/editor/src/store/test/actions.js +++ b/packages/editor/src/store/test/actions.js @@ -20,6 +20,7 @@ import { } from '../constants'; jest.mock( '@wordpress/data-controls' ); +jest.mock( '../block-sources' ); select.mockImplementation( ( ...args ) => { const { select: actualSelect } = jest @@ -640,15 +641,24 @@ describe( 'Post generator actions', () => { describe( 'Editor actions', () => { describe( 'setupEditor()', () => { + const post = { content: { raw: '' }, status: 'publish' }; + let fulfillment; - const reset = ( post, edits, template ) => fulfillment = actions + const reset = ( edits, template ) => fulfillment = actions .setupEditor( post, edits, template, ); + beforeAll( () => { + reset(); + } ); + + it( 'should yield action object for resetPost', () => { + const { value } = fulfillment.next(); + expect( value ).toEqual( actions.resetPost( post ) ); + } ); it( 'should yield the SETUP_EDITOR action', () => { - reset( { content: { raw: '' }, status: 'publish' } ); const { value } = fulfillment.next(); expect( value ).toEqual( { type: 'SETUP_EDITOR',