@wordpress/editor
Version:
Enhanced block editor for WordPress posts.
720 lines (672 loc) • 18.8 kB
JavaScript
/**
* WordPress dependencies
*/
import { store as coreStore } from '@wordpress/core-data';
import { __, _x, sprintf } from '@wordpress/i18n';
import { store as noticesStore } from '@wordpress/notices';
import { store as blockEditorStore } from '@wordpress/block-editor';
import { store as preferencesStore } from '@wordpress/preferences';
import { addQueryArgs } from '@wordpress/url';
import apiFetch from '@wordpress/api-fetch';
import { parse, __unstableSerializeAndClean } from '@wordpress/blocks';
import { decodeEntities } from '@wordpress/html-entities';
import { dateI18n, getSettings as getDateSettings } from '@wordpress/date';
/**
* Internal dependencies
*/
import isTemplateRevertable from './utils/is-template-revertable';
export * from '../dataviews/store/private-actions';
/**
* Returns an action object used to set which template is currently being used/edited.
*
* @param {string} id Template Id.
*
* @return {Object} Action object.
*/
export function setCurrentTemplateId( id ) {
return {
type: 'SET_CURRENT_TEMPLATE_ID',
id,
};
}
/**
* Create a block based template.
*
* @param {?Object} template Template to create and assign.
*/
export const createTemplate =
( template ) =>
async ( { select, dispatch, registry } ) => {
const savedTemplate = await registry
.dispatch( coreStore )
.saveEntityRecord( 'postType', 'wp_template', template );
registry
.dispatch( coreStore )
.editEntityRecord(
'postType',
select.getCurrentPostType(),
select.getCurrentPostId(),
{
template: savedTemplate.slug,
}
);
registry
.dispatch( noticesStore )
.createSuccessNotice(
__( "Custom template created. You're in template mode now." ),
{
type: 'snackbar',
actions: [
{
label: __( 'Go back' ),
onClick: () =>
dispatch.setRenderingMode(
select.getEditorSettings()
.defaultRenderingMode
),
},
],
}
);
return savedTemplate;
};
/**
* Update the provided block types to be visible.
*
* @param {string[]} blockNames Names of block types to show.
*/
export const showBlockTypes =
( blockNames ) =>
( { registry } ) => {
const existingBlockNames =
registry
.select( preferencesStore )
.get( 'core', 'hiddenBlockTypes' ) ?? [];
const newBlockNames = existingBlockNames.filter(
( type ) =>
! (
Array.isArray( blockNames ) ? blockNames : [ blockNames ]
).includes( type )
);
registry
.dispatch( preferencesStore )
.set( 'core', 'hiddenBlockTypes', newBlockNames );
};
/**
* Update the provided block types to be hidden.
*
* @param {string[]} blockNames Names of block types to hide.
*/
export const hideBlockTypes =
( blockNames ) =>
( { registry } ) => {
const existingBlockNames =
registry
.select( preferencesStore )
.get( 'core', 'hiddenBlockTypes' ) ?? [];
const mergedBlockNames = new Set( [
...existingBlockNames,
...( Array.isArray( blockNames ) ? blockNames : [ blockNames ] ),
] );
registry
.dispatch( preferencesStore )
.set( 'core', 'hiddenBlockTypes', [ ...mergedBlockNames ] );
};
/**
* Save entity records marked as dirty.
*
* @param {Object} options Options for the action.
* @param {Function} [options.onSave] Callback when saving happens.
* @param {object[]} [options.dirtyEntityRecords] Array of dirty entities.
* @param {object[]} [options.entitiesToSkip] Array of entities to skip saving.
* @param {Function} [options.close] Callback when the actions is called. It should be consolidated with `onSave`.
* @param {string} [options.successNoticeContent] Optional custom success notice content. Defaults to 'Site updated.'.
*/
export const saveDirtyEntities =
( {
onSave,
dirtyEntityRecords = [],
entitiesToSkip = [],
close,
successNoticeContent,
} = {} ) =>
( { registry } ) => {
const PUBLISH_ON_SAVE_ENTITIES = [
{ kind: 'postType', name: 'wp_navigation' },
];
const saveNoticeId = 'site-editor-save-success';
const homeUrl = registry
.select( coreStore )
.getEntityRecord( 'root', '__unstableBase' )?.home;
registry.dispatch( noticesStore ).removeNotice( saveNoticeId );
const entitiesToSave = dirtyEntityRecords.filter(
( { kind, name, key, property } ) => {
return ! entitiesToSkip.some(
( elt ) =>
elt.kind === kind &&
elt.name === name &&
elt.key === key &&
elt.property === property
);
}
);
close?.( entitiesToSave );
const siteItemsToSave = [];
const pendingSavedRecords = [];
entitiesToSave.forEach( ( { kind, name, key, property } ) => {
if ( 'root' === kind && 'site' === name ) {
siteItemsToSave.push( property );
} else {
if (
PUBLISH_ON_SAVE_ENTITIES.some(
( typeToPublish ) =>
typeToPublish.kind === kind &&
typeToPublish.name === name
)
) {
registry
.dispatch( coreStore )
.editEntityRecord( kind, name, key, {
status: 'publish',
} );
}
pendingSavedRecords.push(
registry
.dispatch( coreStore )
.saveEditedEntityRecord( kind, name, key )
);
}
} );
if ( siteItemsToSave.length ) {
pendingSavedRecords.push(
registry
.dispatch( coreStore )
.__experimentalSaveSpecifiedEntityEdits(
'root',
'site',
undefined,
siteItemsToSave
)
);
}
registry
.dispatch( blockEditorStore )
.__unstableMarkLastChangeAsPersistent();
Promise.all( pendingSavedRecords )
.then( ( values ) => {
return onSave ? onSave( values ) : values;
} )
.then( ( values ) => {
if (
values.some( ( value ) => typeof value === 'undefined' )
) {
registry
.dispatch( noticesStore )
.createErrorNotice( __( 'Saving failed.' ) );
} else {
registry
.dispatch( noticesStore )
.createSuccessNotice(
successNoticeContent || __( 'Site updated.' ),
{
type: 'snackbar',
id: saveNoticeId,
actions: [
{
label: __( 'View site' ),
url: homeUrl,
openInNewTab: true,
},
],
}
);
}
} )
.catch( ( error ) =>
registry
.dispatch( noticesStore )
.createErrorNotice(
`${ __( 'Saving failed.' ) } ${ error }`
)
);
};
/**
* Reverts a template to its original theme-provided file.
*
* @param {Object} template The template to revert.
* @param {Object} [options]
* @param {boolean} [options.allowUndo] Whether to allow the user to undo
* reverting the template. Default true.
*/
export const revertTemplate =
( template, { allowUndo = true } = {} ) =>
async ( { registry } ) => {
const noticeId = 'edit-site-template-reverted';
registry.dispatch( noticesStore ).removeNotice( noticeId );
if ( ! isTemplateRevertable( template ) ) {
registry
.dispatch( noticesStore )
.createErrorNotice( __( 'This template is not revertable.' ), {
type: 'snackbar',
} );
return;
}
try {
const templateEntityConfig = registry
.select( coreStore )
.getEntityConfig( 'postType', template.type );
if ( ! templateEntityConfig ) {
registry
.dispatch( noticesStore )
.createErrorNotice(
__(
'The editor has encountered an unexpected error. Please reload.'
),
{ type: 'snackbar' }
);
return;
}
const fileTemplatePath = addQueryArgs(
`${ templateEntityConfig.baseURL }/${ template.id }`,
{ context: 'edit', source: template.origin }
);
const fileTemplate = await apiFetch( { path: fileTemplatePath } );
if ( ! fileTemplate ) {
registry
.dispatch( noticesStore )
.createErrorNotice(
__(
'The editor has encountered an unexpected error. Please reload.'
),
{ type: 'snackbar' }
);
return;
}
const serializeBlocks = ( {
blocks: blocksForSerialization = [],
} ) => __unstableSerializeAndClean( blocksForSerialization );
const edited = registry
.select( coreStore )
.getEditedEntityRecord(
'postType',
template.type,
template.id
);
// We are fixing up the undo level here to make sure we can undo
// the revert in the header toolbar correctly.
registry.dispatch( coreStore ).editEntityRecord(
'postType',
template.type,
template.id,
{
content: serializeBlocks, // Required to make the `undo` behave correctly.
blocks: edited.blocks, // Required to revert the blocks in the editor.
source: 'custom', // required to avoid turning the editor into a dirty state
},
{
undoIgnore: true, // Required to merge this edit with the last undo level.
}
);
const blocks = parse( fileTemplate?.content?.raw );
registry
.dispatch( coreStore )
.editEntityRecord( 'postType', template.type, fileTemplate.id, {
content: serializeBlocks,
blocks,
source: 'theme',
} );
if ( allowUndo ) {
const undoRevert = () => {
registry
.dispatch( coreStore )
.editEntityRecord(
'postType',
template.type,
edited.id,
{
content: serializeBlocks,
blocks: edited.blocks,
source: 'custom',
}
);
};
registry
.dispatch( noticesStore )
.createSuccessNotice( __( 'Template reset.' ), {
type: 'snackbar',
id: noticeId,
actions: [
{
label: __( 'Undo' ),
onClick: undoRevert,
},
],
} );
}
} catch ( error ) {
const errorMessage =
error.message && error.code !== 'unknown_error'
? error.message
: __( 'Template revert failed. Please reload.' );
registry
.dispatch( noticesStore )
.createErrorNotice( errorMessage, { type: 'snackbar' } );
}
};
/**
* Action that removes an array of templates, template parts or patterns.
*
* @param {Array} items An array of template,template part or pattern objects to remove.
*/
export const removeTemplates =
( items ) =>
async ( { registry } ) => {
const isResetting = items.every( ( item ) => item?.has_theme_file );
const promiseResult = await Promise.allSettled(
items.map( ( item ) => {
return registry
.dispatch( coreStore )
.deleteEntityRecord(
'postType',
item.type,
item.id,
{ force: true },
{ throwOnError: true }
);
} )
);
// If all the promises were fulfilled with success.
if ( promiseResult.every( ( { status } ) => status === 'fulfilled' ) ) {
let successMessage;
if ( items.length === 1 ) {
// Depending on how the entity was retrieved its title might be
// an object or simple string.
let title;
if ( typeof items[ 0 ].title === 'string' ) {
title = items[ 0 ].title;
} else if ( typeof items[ 0 ].title?.rendered === 'string' ) {
title = items[ 0 ].title?.rendered;
} else if ( typeof items[ 0 ].title?.raw === 'string' ) {
title = items[ 0 ].title?.raw;
}
successMessage = isResetting
? sprintf(
/* translators: %s: The template/part's name. */
__( '"%s" reset.' ),
decodeEntities( title )
)
: sprintf(
/* translators: %s: The template/part's name. */
_x( '"%s" deleted.', 'template part' ),
decodeEntities( title )
);
} else {
successMessage = isResetting
? __( 'Items reset.' )
: __( 'Items deleted.' );
}
registry
.dispatch( noticesStore )
.createSuccessNotice( successMessage, {
type: 'snackbar',
id: 'editor-template-deleted-success',
} );
} else {
// If there was at lease one failure.
let errorMessage;
// If we were trying to delete a single template.
if ( promiseResult.length === 1 ) {
if ( promiseResult[ 0 ].reason?.message ) {
errorMessage = promiseResult[ 0 ].reason.message;
} else {
errorMessage = isResetting
? __( 'An error occurred while reverting the item.' )
: __( 'An error occurred while deleting the item.' );
}
// If we were trying to delete a multiple templates
} else {
const errorMessages = new Set();
const failedPromises = promiseResult.filter(
( { status } ) => status === 'rejected'
);
for ( const failedPromise of failedPromises ) {
if ( failedPromise.reason?.message ) {
errorMessages.add( failedPromise.reason.message );
}
}
if ( errorMessages.size === 0 ) {
errorMessage = __(
'An error occurred while deleting the items.'
);
} else if ( errorMessages.size === 1 ) {
errorMessage = isResetting
? sprintf(
/* translators: %s: an error message */
__(
'An error occurred while reverting the items: %s'
),
[ ...errorMessages ][ 0 ]
)
: sprintf(
/* translators: %s: an error message */
__(
'An error occurred while deleting the items: %s'
),
[ ...errorMessages ][ 0 ]
);
} else {
errorMessage = isResetting
? sprintf(
/* translators: %s: a list of comma separated error messages */
__(
'Some errors occurred while reverting the items: %s'
),
[ ...errorMessages ].join( ',' )
)
: sprintf(
/* translators: %s: a list of comma separated error messages */
__(
'Some errors occurred while deleting the items: %s'
),
[ ...errorMessages ].join( ',' )
);
}
}
registry
.dispatch( noticesStore )
.createErrorNotice( errorMessage, { type: 'snackbar' } );
}
};
/**
* Set the default rendering mode preference for the current post type.
*
* @param {string} mode The rendering mode to set as default.
*/
export const setDefaultRenderingMode =
( mode ) =>
( { select, registry } ) => {
const postType = select.getCurrentPostType();
const theme = registry
.select( coreStore )
.getCurrentTheme()?.stylesheet;
const renderingModes =
registry
.select( preferencesStore )
.get( 'core', 'renderingModes' )?.[ theme ] ?? {};
if ( renderingModes[ postType ] === mode ) {
return;
}
const newModes = {
[ theme ]: {
...renderingModes,
[ postType ]: mode,
},
};
registry
.dispatch( preferencesStore )
.set( 'core', 'renderingModes', newModes );
};
/**
* Set the current global styles navigation path.
*
* @param {string} path The navigation path.
* @return {Object} Action object.
*/
export function setStylesPath( path ) {
return {
type: 'SET_STYLES_PATH',
path,
};
}
/**
* Set whether the stylebook is visible.
*
* @param {boolean} show Whether to show the stylebook.
* @return {Object} Action object.
*/
export function setShowStylebook( show ) {
return {
type: 'SET_SHOW_STYLEBOOK',
show,
};
}
/**
* Reset the global styles navigation to initial state.
*
* @return {Object} Action object.
*/
export function resetStylesNavigation() {
return {
type: 'RESET_STYLES_NAVIGATION',
};
}
/**
* Set the minimum height of the canvas.
*
* @param {number} minHeight
* @return {Object} Action object.
*/
export function setCanvasMinHeight( minHeight ) {
return {
type: 'SET_CANVAS_MIN_HEIGHT',
minHeight,
};
}
/**
* Set the current revision ID for revisions preview mode.
* Pass a revision ID to enter revisions mode, or null to exit.
*
* @param {number|null} revisionId The revision ID, or null to exit revisions mode.
* @return {Object} Action object.
*/
export function setCurrentRevisionId( revisionId ) {
return {
type: 'SET_CURRENT_REVISION_ID',
revisionId,
};
}
/**
* Set whether the revision diff highlighting is shown.
*
* @param {boolean} showDiff Whether to show diff highlighting.
* @return {Object} Action object.
*/
export function setShowRevisionDiff( showDiff ) {
return {
type: 'SET_SHOW_REVISION_DIFF',
showDiff,
};
}
/**
* Restore a revision by replacing the current content with the revision's content
* and auto-saving.
*
* @param {number} revisionId The revision ID to restore.
*/
export const restoreRevision =
( revisionId ) =>
async ( { select, dispatch, registry } ) => {
const postType = select.getCurrentPostType();
const postId = select.getCurrentPostId();
const entityConfig = registry
.select( coreStore )
.getEntityConfig( 'postType', postType );
const revisionKey = entityConfig?.revisionKey || 'id';
// Use resolveSelect to ensure the revision is fetched if not yet
// in the store. The _fields parameter matches the query used by
// getRevisions so the result is served from cache without an
// extra API call.
const revision = await registry
.resolveSelect( coreStore )
.getRevision( 'postType', postType, postId, revisionId, {
context: 'edit',
_fields: [
...new Set( [
'id',
'date',
'modified',
'author',
'meta',
'title.raw',
'excerpt.raw',
'content.raw',
revisionKey,
] ),
].join(),
} );
if ( ! revision ) {
return;
}
// Build the edits object with all restorable fields from the revision.
const edits = {
blocks: undefined,
content: revision.content.raw,
};
if ( revision.title?.raw !== undefined ) {
edits.title = revision.title.raw;
}
if ( revision.excerpt?.raw !== undefined ) {
edits.excerpt = revision.excerpt.raw;
}
if ( revision.meta !== undefined ) {
edits.meta = revision.meta;
}
// Apply edits and save.
dispatch.editPost( edits );
// Exit revisions mode.
dispatch.setCurrentRevisionId( null );
// Save the post to persist the restored revision.
await dispatch.savePost();
// Show success notice.
registry.dispatch( noticesStore ).createSuccessNotice(
sprintf(
/* translators: %s: Date and time of the revision. */
__( 'Restored to revision from %s.' ),
dateI18n(
getDateSettings().formats.datetime,
// Template revisions use the template REST API format, which
// exposes 'modified' instead of 'date'.
revisionKey === 'wp_id' ? revision.modified : revision.date
)
),
{
type: 'snackbar',
id: 'editor-revision-restored',
}
);
};
/**
* Select a note by its ID, or clear the selection.
*
* @param {undefined|number|'new'} noteId The note ID to select, 'new' to open the new note form, or undefined to clear.
* @param {Object} [options] Optional options for the selection.
* @param {boolean} [options.focus] Whether to focus the selected note. Default false.
* @return {Object} Action object.
*/
export function selectNote( noteId, options = { focus: false } ) {
return {
type: 'SELECT_NOTE',
noteId,
options,
};
}