@wordpress/block-editor
Version:
300 lines (274 loc) • 11.1 kB
JavaScript
/**
* WordPress dependencies
*/
import { useEffect, useRef } from '@wordpress/element';
import { useRegistry, useSelect } from '@wordpress/data';
import { cloneBlock } from '@wordpress/blocks';
/**
* Internal dependencies
*/
import { store as blockEditorStore } from '../../store';
const noop = () => {};
/**
* A function to call when the block value has been updated in the block-editor
* store.
*
* @callback onBlockUpdate
* @param {Object[]} blocks The updated blocks.
* @param {Object} options The updated block options, such as selectionStart
* and selectionEnd.
*/
/**
* useBlockSync is a side effect which handles bidirectional sync between the
* block-editor store and a controlling data source which provides blocks. This
* is most commonly used by the BlockEditorProvider to synchronize the contents
* of the block-editor store with the root entity, like a post.
*
* Another example would be the template part block, which provides blocks from
* a separate entity data source than a root entity. This hook syncs edits to
* the template part in the block editor back to the entity and vice-versa.
*
* Here are some of its basic functions:
* - Initializes the block-editor store for the given clientID to the blocks
* given via props.
* - Adds incoming changes (like undo) to the block-editor store.
* - Adds outgoing changes (like editing content) to the controlling entity,
* determining if a change should be considered persistent or not.
* - Handles edge cases and race conditions which occur in those operations.
* - Ignores changes which happen to other entities (like nested inner block
* controllers.
* - Passes selection state from the block-editor store to the controlling entity.
*
* @param {Object} props Props for the block sync hook
* @param {string} props.clientId The client ID of the inner block controller.
* If none is passed, then it is assumed to be a
* root controller rather than an inner block
* controller.
* @param {Object[]} props.value The control value for the blocks. This value
* is used to initialize the block-editor store
* and for resetting the blocks to incoming
* changes like undo.
* @param {Object} props.selection The selection state responsible to restore the selection on undo/redo.
* @param {onBlockUpdate} props.onChange Function to call when a persistent
* change has been made in the block-editor blocks
* for the given clientId. For example, after
* this function is called, an entity is marked
* dirty because it has changes to save.
* @param {onBlockUpdate} props.onInput Function to call when a non-persistent
* change has been made in the block-editor blocks
* for the given clientId. When this is called,
* controlling sources do not become dirty.
*/
export default function useBlockSync( {
clientId = null,
value: controlledBlocks,
selection: controlledSelection,
onChange = noop,
onInput = noop,
} ) {
const registry = useRegistry();
const {
resetBlocks,
resetSelection,
replaceInnerBlocks,
setHasControlledInnerBlocks,
__unstableMarkNextChangeAsNotPersistent,
} = registry.dispatch( blockEditorStore );
const { getBlockName, getBlocks, getSelectionStart, getSelectionEnd } =
registry.select( blockEditorStore );
const isControlled = useSelect(
( select ) => {
return (
! clientId ||
select( blockEditorStore ).areInnerBlocksControlled( clientId )
);
},
[ clientId ]
);
const pendingChangesRef = useRef( { incoming: null, outgoing: [] } );
const subscribedRef = useRef( false );
const setControlledBlocks = () => {
if ( ! controlledBlocks ) {
return;
}
// We don't need to persist this change because we only replace
// controlled inner blocks when the change was caused by an entity,
// and so it would already be persisted.
__unstableMarkNextChangeAsNotPersistent();
if ( clientId ) {
// It is important to batch here because otherwise,
// as soon as `setHasControlledInnerBlocks` is called
// the effect to restore might be triggered
// before the actual blocks get set properly in state.
registry.batch( () => {
setHasControlledInnerBlocks( clientId, true );
const storeBlocks = controlledBlocks.map( ( block ) =>
cloneBlock( block )
);
if ( subscribedRef.current ) {
pendingChangesRef.current.incoming = storeBlocks;
}
__unstableMarkNextChangeAsNotPersistent();
replaceInnerBlocks( clientId, storeBlocks );
} );
} else {
if ( subscribedRef.current ) {
pendingChangesRef.current.incoming = controlledBlocks;
}
resetBlocks( controlledBlocks );
}
};
// Clean up the changes made by setControlledBlocks() when the component
// containing useBlockSync() unmounts.
const unsetControlledBlocks = () => {
__unstableMarkNextChangeAsNotPersistent();
if ( clientId ) {
setHasControlledInnerBlocks( clientId, false );
__unstableMarkNextChangeAsNotPersistent();
replaceInnerBlocks( clientId, [] );
} else {
resetBlocks( [] );
}
};
// Add a subscription to the block-editor registry to detect when changes
// have been made. This lets us inform the data source of changes. This
// is an effect so that the subscriber can run synchronously without
// waiting for React renders for changes.
const onInputRef = useRef( onInput );
const onChangeRef = useRef( onChange );
useEffect( () => {
onInputRef.current = onInput;
onChangeRef.current = onChange;
}, [ onInput, onChange ] );
// Determine if blocks need to be reset when they change.
useEffect( () => {
if ( pendingChangesRef.current.outgoing.includes( controlledBlocks ) ) {
// 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.
if (
pendingChangesRef.current.outgoing[
pendingChangesRef.current.outgoing.length - 1
] === controlledBlocks
) {
pendingChangesRef.current.outgoing = [];
}
} else if ( getBlocks( clientId ) !== controlledBlocks ) {
// 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.
pendingChangesRef.current.outgoing = [];
setControlledBlocks();
if ( controlledSelection ) {
resetSelection(
controlledSelection.selectionStart,
controlledSelection.selectionEnd,
controlledSelection.initialPosition
);
}
}
}, [ controlledBlocks, clientId ] );
const isMountedRef = useRef( false );
useEffect( () => {
// On mount, controlled blocks are already set in the effect above.
if ( ! isMountedRef.current ) {
isMountedRef.current = true;
return;
}
// When the block becomes uncontrolled, it means its inner state has been reset
// we need to take the blocks again from the external value property.
if ( ! isControlled ) {
pendingChangesRef.current.outgoing = [];
setControlledBlocks();
}
}, [ isControlled ] );
useEffect( () => {
const {
getSelectedBlocksInitialCaretPosition,
isLastBlockChangePersistent,
__unstableIsLastBlockChangeIgnored,
areInnerBlocksControlled,
} = registry.select( blockEditorStore );
let blocks = getBlocks( clientId );
let isPersistent = isLastBlockChangePersistent();
let previousAreBlocksDifferent = false;
subscribedRef.current = true;
const unsubscribe = registry.subscribe( () => {
// Sometimes, when changing block lists, lingering subscriptions
// might trigger before they are cleaned up. If the block for which
// the subscription runs is no longer in the store, this would clear
// its parent entity's block list. To avoid this, we bail out if
// the subscription is triggering for a block (`clientId !== null`)
// and its block name can't be found because it's not on the list.
// (`getBlockName( clientId ) === null`).
if ( clientId !== null && getBlockName( clientId ) === null ) {
return;
}
// When RESET_BLOCKS on parent blocks get called, the controlled blocks
// can reset to uncontrolled, in these situations, it means we need to populate
// the blocks again from the external blocks (the value property here)
// and we should stop triggering onChange
const isStillControlled =
! clientId || areInnerBlocksControlled( clientId );
if ( ! isStillControlled ) {
return;
}
const newIsPersistent = isLastBlockChangePersistent();
const newBlocks = getBlocks( clientId );
const areBlocksDifferent = newBlocks !== blocks;
blocks = newBlocks;
if (
areBlocksDifferent &&
( pendingChangesRef.current.incoming ||
__unstableIsLastBlockChangeIgnored() )
) {
pendingChangesRef.current.incoming = null;
isPersistent = newIsPersistent;
return;
}
// Since we often dispatch an action to mark the previous action as
// persistent, we need to make sure that the blocks changed on the
// previous action before committing the change.
const didPersistenceChange =
previousAreBlocksDifferent &&
! areBlocksDifferent &&
newIsPersistent &&
! isPersistent;
if ( areBlocksDifferent || didPersistenceChange ) {
isPersistent = newIsPersistent;
// We know that onChange/onInput will update controlledBlocks.
// We need to be aware that it was caused by an outgoing change
// so that we do not treat it as an incoming change later on,
// which would cause a block reset.
pendingChangesRef.current.outgoing.push( blocks );
// Inform the controlling entity that changes have been made to
// the block-editor store they should be aware about.
const updateParent = isPersistent
? onChangeRef.current
: onInputRef.current;
updateParent( blocks, {
selection: {
selectionStart: getSelectionStart(),
selectionEnd: getSelectionEnd(),
initialPosition:
getSelectedBlocksInitialCaretPosition(),
},
} );
}
previousAreBlocksDifferent = areBlocksDifferent;
}, blockEditorStore );
return () => {
subscribedRef.current = false;
unsubscribe();
};
}, [ registry, clientId ] );
useEffect( () => {
return () => {
unsetControlledBlocks();
};
}, [] );
}