diff --git a/packages/block-editor/src/components/provider/index.js b/packages/block-editor/src/components/provider/index.js
index 33602a709665de..5b61f940b5b484 100644
--- a/packages/block-editor/src/components/provider/index.js
+++ b/packages/block-editor/src/components/provider/index.js
@@ -3,13 +3,125 @@
*/
import { Component } from '@wordpress/element';
import { DropZoneProvider, SlotFillProvider } from '@wordpress/components';
-import { withDispatch, withSelect } from '@wordpress/data';
-import { compose } from '@wordpress/compose';
+import { withDispatch, withSelect, RegistryConsumer } from '@wordpress/data';
+import { compose, createHigherOrderComponent } from '@wordpress/compose';
+import isShallowEqual from '@wordpress/is-shallow-equal';
+
+/**
+ * Higher-order component which renders the original component with the current
+ * registry context passed as its `registry` prop.
+ *
+ * @param {WPComponent} OriginalComponent Original component.
+ *
+ * @return {WPComponent} Enhanced component.
+ */
+const withRegistry = createHigherOrderComponent(
+ ( OriginalComponent ) => ( props ) => (
+
+ { ( registry ) => (
+
+ ) }
+
+ ),
+ 'withRegistry'
+);
+
+/**
+ * Returns true if the two object arguments have the same keys, or false
+ * otherwise.
+ *
+ * @param {Object} a First object.
+ * @param {Object} b Second object.
+ *
+ * @return {boolean} Whether the two objects have the same keys.
+ */
+function hasSameKeys( a, b ) {
+ return isShallowEqual( Object.keys( a ), Object.keys( b ) );
+}
+
+/**
+ * Returns true if, given the currently dispatching action and the previously
+ * dispatched action, the two actions are updating the same block attribute, or
+ * false otherwise.
+ *
+ * @param {Object} action Currently dispatching action.
+ * @param {Object} lastAction Previously dispatched action.
+ *
+ * @return {boolean} Whether actions are updating the same block attribute.
+ */
+function isUpdatingSameBlockAttribute( action, lastAction ) {
+ return (
+ action.type === 'UPDATE_BLOCK_ATTRIBUTES' &&
+ action.clientId === lastAction.clientId &&
+ hasSameKeys( action.attributes, lastAction.attributes )
+ );
+}
+
+/**
+ * Given a data namespace store and a callback, returns a substitute dispatch
+ * function which preserves the original dispatch behavior and invokes the
+ * callback when a blocks state change should be committed.
+ *
+ * @param {WPDataNamespaceStore} store Store for which to create replacement
+ * dispatch function.
+ * @param {Function} callback Function to call when blocks state
+ * should be committed.
+ *
+ * @return {Function} Enhanced store dispatch function.
+ */
+function createChangeObserver( store, callback ) {
+ let lastAction, lastState, isPendingCommit;
+
+ function dispatch( action ) {
+ const result = dispatch._originalDispatch( action );
+ const state = store.getState();
+
+ if ( action.type === 'RESET_BLOCKS' ) {
+ // Consider block reset as superseding any pending change commits,
+ // even if destructive to pending user commits. It should rarely be
+ // the case that blocks are suddenly reset while user interacts.
+ isPendingCommit = false;
+ } else if ( lastAction && lastState && state !== lastState ) {
+ if (
+ state.editor.blocks !== lastState.editor.blocks &&
+ isUpdatingSameBlockAttribute( action, lastAction )
+ ) {
+ // So long as block updates occur as operating on the same
+ // attributes in the previous action, delay callback.
+ isPendingCommit = true;
+ } else if ( isPendingCommit ) {
+ // Once any other action occurs while pending commit, release
+ // the deferred callback as completed.
+ callback();
+ isPendingCommit = false;
+ }
+ }
+
+ lastAction = action;
+ lastState = state;
+
+ return result;
+ }
+
+ dispatch._originalDispatch = store.dispatch;
+
+ return dispatch;
+}
class BlockEditorProvider extends Component {
+ constructor() {
+ super( ...arguments );
+
+ this.onChange = this.onChange.bind( this );
+ }
+
componentDidMount() {
this.props.updateEditorSettings( this.props.settings );
this.props.resetBlocks( this.props.value );
+ this.attachChangeObserver( this.props.registry );
this.isSyncingBlockValue = true;
}
@@ -21,17 +133,22 @@ class BlockEditorProvider extends Component {
value,
resetBlocks,
blocks,
- onChange,
+ onInput,
+ registry,
} = this.props;
if ( settings !== prevProps.settings ) {
updateEditorSettings( settings );
}
+ if ( registry !== prevProps.registry ) {
+ this.attachChangeObserver( registry, prevProps.registry );
+ }
+
if ( this.isSyncingBlockValue ) {
this.isSyncingBlockValue = false;
} else if ( blocks !== prevProps.blocks ) {
- onChange( blocks );
+ onInput( blocks );
this.isSyncingBlockValue = true;
} else if ( value !== prevProps.value ) {
resetBlocks( value );
@@ -39,6 +156,38 @@ class BlockEditorProvider extends Component {
}
}
+ /**
+ * Calls the mounted instance's `onChange` prop callback with the current
+ * blocks prop value.
+ */
+ onChange() {
+ this.props.onChange( this.props.blocks );
+ }
+
+ /**
+ * Given a registry object, overrides the default dispatch behavior for the
+ * `core/block-editor` store to interpret a state change which should be
+ * considered as calling the mounted instance's `onChange` callback. Unlike
+ * `onInput` which is called for any change in block state, `onChange` is
+ * only called for meaningful commit interactions. If a second registry
+ * argument is passed, it is treated as the previous registry to which the
+ * dispatch behavior was overridden, and the original dispatch is restored.
+ *
+ * @param {WPDataRegistry} registry Registry from which block editor
+ * dispatch is to be overriden.
+ * @param {WPDataRegistry} prevRegistry Previous registry whose dispatch
+ * behavior should be restored.
+ */
+ attachChangeObserver( registry, prevRegistry ) {
+ const { store } = registry.namespaces[ 'core/block-editor' ];
+ store.dispatch = createChangeObserver( store, this.onChange );
+
+ if ( prevRegistry ) {
+ const { store: prevStore } = prevRegistry.namespaces[ 'core/block-editor' ];
+ prevStore.dispatch = prevStore.dispatch._originalDispatch;
+ }
+ }
+
render() {
const { children } = this.props;
@@ -71,4 +220,5 @@ export default compose( [
resetBlocks,
};
} ),
+ withRegistry,
] )( BlockEditorProvider );
diff --git a/packages/block-editor/src/components/provider/test/index.js b/packages/block-editor/src/components/provider/test/index.js
new file mode 100644
index 00000000000000..8dbc266fdceeba
--- /dev/null
+++ b/packages/block-editor/src/components/provider/test/index.js
@@ -0,0 +1,95 @@
+/**
+ * Internal dependencies
+ */
+import {
+ hasSameKeys,
+ isUpdatingSameBlockAttribute,
+} from '../';
+
+describe( 'hasSameKeys()', () => {
+ it( 'returns false if two objects do not have the same keys', () => {
+ const a = { foo: 10 };
+ const b = { bar: 10 };
+
+ expect( hasSameKeys( a, b ) ).toBe( false );
+ } );
+
+ it( 'returns false if two objects have the same keys', () => {
+ const a = { foo: 10 };
+ const b = { foo: 20 };
+
+ expect( hasSameKeys( a, b ) ).toBe( true );
+ } );
+} );
+
+describe( 'isUpdatingSameBlockAttribute()', () => {
+ it( 'should return false if not updating block attributes', () => {
+ const action = {
+ type: 'START_TYPING',
+ edits: {},
+ };
+ const previousAction = {
+ type: 'START_TYPING',
+ edits: {},
+ };
+
+ expect( isUpdatingSameBlockAttribute( action, previousAction ) ).toBe( false );
+ } );
+
+ it( 'should return false if not updating the same block', () => {
+ const action = {
+ type: 'UPDATE_BLOCK_ATTRIBUTES',
+ clientId: '9db792c6-a25a-495d-adbd-97d56a4c4189',
+ attributes: {
+ foo: 10,
+ },
+ };
+ const previousAction = {
+ type: 'UPDATE_BLOCK_ATTRIBUTES',
+ clientId: 'afd1cb17-2c08-4e7a-91be-007ba7ddc3a1',
+ attributes: {
+ foo: 20,
+ },
+ };
+
+ expect( isUpdatingSameBlockAttribute( action, previousAction ) ).toBe( false );
+ } );
+
+ it( 'should return false if not updating the same block attributes', () => {
+ const action = {
+ type: 'UPDATE_BLOCK_ATTRIBUTES',
+ clientId: '9db792c6-a25a-495d-adbd-97d56a4c4189',
+ attributes: {
+ foo: 10,
+ },
+ };
+ const previousAction = {
+ type: 'UPDATE_BLOCK_ATTRIBUTES',
+ clientId: '9db792c6-a25a-495d-adbd-97d56a4c4189',
+ attributes: {
+ bar: 20,
+ },
+ };
+
+ expect( isUpdatingSameBlockAttribute( action, previousAction ) ).toBe( false );
+ } );
+
+ it( 'should return true if updating the same block attributes', () => {
+ const action = {
+ type: 'UPDATE_BLOCK_ATTRIBUTES',
+ clientId: '9db792c6-a25a-495d-adbd-97d56a4c4189',
+ attributes: {
+ foo: 10,
+ },
+ };
+ const previousAction = {
+ type: 'UPDATE_BLOCK_ATTRIBUTES',
+ clientId: '9db792c6-a25a-495d-adbd-97d56a4c4189',
+ attributes: {
+ foo: 20,
+ },
+ };
+
+ expect( isUpdatingSameBlockAttribute( action, previousAction ) ).toBe( true );
+ } );
+} );
diff --git a/packages/editor/src/components/provider/index.js b/packages/editor/src/components/provider/index.js
index 400500b4b69ce8..2a52eed00a0f60 100644
--- a/packages/editor/src/components/provider/index.js
+++ b/packages/editor/src/components/provider/index.js
@@ -86,6 +86,7 @@ class EditorProvider extends Component {
meta,
onMetaChange,
reusableBlocks,
+ createUndoLevel,
} = this.props;
if ( ! isReady ) {
@@ -99,7 +100,8 @@ class EditorProvider extends Component {
return (
{ children }
@@ -129,6 +131,7 @@ export default compose( [
updatePostLock,
resetEditorBlocks,
editPost,
+ createUndoLevel,
} = dispatch( 'core/editor' );
const { createWarningNotice } = dispatch( 'core/notices' );
@@ -137,6 +140,7 @@ export default compose( [
updatePostLock,
createWarningNotice,
resetEditorBlocks,
+ createUndoLevel,
onMetaChange( meta ) {
editPost( { meta } );
},
diff --git a/packages/editor/src/store/reducer.js b/packages/editor/src/store/reducer.js
index ba5ad80d6f51d4..06d0901830f858 100644
--- a/packages/editor/src/store/reducer.js
+++ b/packages/editor/src/store/reducer.js
@@ -129,7 +129,15 @@ export const editor = flow( [
withHistory( {
resetTypes: [ 'SETUP_EDITOR_STATE' ],
- ignoreTypes: [ 'RECEIVE_BLOCKS', 'RESET_POST', 'UPDATE_POST' ],
+ ignoreTypes: [
+ 'RECEIVE_BLOCKS',
+ 'RESET_POST',
+ 'UPDATE_POST',
+
+ // Blocks history is managed by explicit createUndoLevel actions
+ // occurring via the rendered BlockEditor's onChange callback.
+ 'RESET_BLOCKS',
+ ],
shouldOverwriteState,
} ),
] )( {