diff --git a/packages/editor/src/components/provider/index.js b/packages/editor/src/components/provider/index.js index e525ce28d251ce..44d72e904deeb2 100644 --- a/packages/editor/src/components/provider/index.js +++ b/packages/editor/src/components/provider/index.js @@ -1,24 +1,14 @@ -/** - * External dependencies - */ -import { map, pick, defaultTo, flatten, partialRight } from 'lodash'; -import memize from 'memize'; - /** * WordPress dependencies */ -import { compose } from '@wordpress/compose'; -import { Component } from '@wordpress/element'; -import { withDispatch, withSelect } from '@wordpress/data'; +import { useEffect, useLayoutEffect, useMemo } from '@wordpress/element'; +import { useDispatch, useSelect } from '@wordpress/data'; import { __ } from '@wordpress/i18n'; import { EntityProvider } from '@wordpress/core-data'; import { BlockEditorProvider, BlockContextProvider, } from '@wordpress/block-editor'; -import apiFetch from '@wordpress/api-fetch'; -import { addQueryArgs } from '@wordpress/url'; -import { decodeEntities } from '@wordpress/html-entities'; import { ReusableBlocksMenuItems } from '@wordpress/reusable-blocks'; import { store as noticesStore } from '@wordpress/notices'; @@ -26,121 +16,64 @@ import { store as noticesStore } from '@wordpress/notices'; * Internal dependencies */ import withRegistryProvider from './with-registry-provider'; -import { mediaUpload } from '../../utils'; import ConvertToGroupButtons from '../convert-to-group-buttons'; -import serializeBlocks from '../../store/utils/serialize-blocks'; - -/** - * Fetches link suggestions from the API. This function is an exact copy of a function found at: - * - * packages/edit-navigation/src/index.js - * - * It seems like there is no suitable package to import this from. Ideally it would be either part of core-data. - * Until we refactor it, just copying the code is the simplest solution. - * - * @param {string} search - * @param {Object} [searchArguments] - * @param {number} [searchArguments.isInitialSuggestions] - * @param {number} [searchArguments.type] - * @param {number} [searchArguments.subtype] - * @param {number} [searchArguments.page] - * @param {Object} [editorSettings] - * @param {boolean} [editorSettings.disablePostFormats=false] - * @return {Promise} List of suggestions - */ - -const fetchLinkSuggestions = async ( - search, - { isInitialSuggestions, type, subtype, page, perPage: perPageArg } = {}, - { disablePostFormats = false } = {} -) => { - const perPage = perPageArg || isInitialSuggestions ? 3 : 20; - - const queries = []; - - if ( ! type || type === 'post' ) { - queries.push( - apiFetch( { - path: addQueryArgs( '/wp/v2/search', { - search, - page, - per_page: perPage, - type: 'post', - subtype, - } ), - } ).catch( () => [] ) // fail by returning no results - ); - } - - if ( ! type || type === 'term' ) { - queries.push( - apiFetch( { - path: addQueryArgs( '/wp/v2/search', { - search, - page, - per_page: perPage, - type: 'term', - subtype, - } ), - } ).catch( () => [] ) - ); - } - - if ( ! disablePostFormats && ( ! type || type === 'post-format' ) ) { - queries.push( - apiFetch( { - path: addQueryArgs( '/wp/v2/search', { - search, - page, - per_page: perPage, - type: 'post-format', - subtype, - } ), - } ).catch( () => [] ) - ); - } - - return Promise.all( queries ).then( ( results ) => { - return map( - flatten( results ) - .filter( ( result ) => !! result.id ) - .slice( 0, perPage ), - ( result ) => ( { - id: result.id, - url: result.url, - title: decodeEntities( result.title ) || __( '(no title)' ), - type: result.subtype || result.type, - } ) - ); - } ); -}; - -class EditorProvider extends Component { - constructor( props ) { - super( ...arguments ); - - this.getBlockEditorSettings = memize( this.getBlockEditorSettings, { - maxSize: 1, - } ); - - this.getDefaultBlockContext = memize( this.getDefaultBlockContext, { - maxSize: 1, - } ); - +import usePostContentEditor from './use-post-content-editor'; +import { store as editorStore } from '../../store'; +import useBlockEditorSettings from './use-block-editor-settings'; + +function EditorProvider( { + __unstableTemplate, + post, + settings, + recovery, + initialEdits, + children, +} ) { + const defaultBlockContext = useMemo( () => { + if ( post.type === 'wp_template' ) { + return {}; + } + return { postId: post.id, postType: post.type }; + }, [ post.id, post.type ] ); + const { selectionEnd, selectionStart, isReady } = useSelect( ( select ) => { + const { + getEditorSelectionStart, + getEditorSelectionEnd, + __unstableIsEditorReady, + } = select( editorStore ); + return { + isReady: __unstableIsEditorReady(), + selectionStart: getEditorSelectionStart(), + selectionEnd: getEditorSelectionEnd(), + }; + }, [] ); + const { id, type } = __unstableTemplate ?? post; + const blockEditorProps = usePostContentEditor( type, id ); + const editorSettings = useBlockEditorSettings( + settings, + !! __unstableTemplate + ); + const { + updatePostLock, + setupEditor, + updateEditorSettings, + __experimentalTearDownEditor, + __unstableSetupTemplate, + } = useDispatch( editorStore ); + const { createWarningNotice } = useDispatch( noticesStore ); + + // Iniitialize and tear down the editor. + // Ideally this should be synced on each change and not just something you do once. + useLayoutEffect( () => { // Assume that we don't need to initialize in the case of an error recovery. - if ( props.recovery ) { + if ( recovery ) { return; } - props.updatePostLock( props.settings.postLock ); - props.setupEditor( - props.post, - props.initialEdits, - props.settings.template - ); - - if ( props.settings.autosave ) { - props.createWarningNotice( + updatePostLock( settings.postLock ); + setupEditor( post, initialEdits, settings.template ); + if ( settings.autosave ) { + createWarningNotice( __( 'There is an autosave of this post that is more recent than the version below.' ), @@ -149,263 +82,51 @@ class EditorProvider extends Component { actions: [ { label: __( 'View the autosave' ), - url: props.settings.autosave.editLink, + url: settings.autosave.editLink, }, ], } ); } - } - getBlockEditorSettings( - settings, - reusableBlocks, - hasUploadPermissions, - canUserUseUnfilteredHTML, - undo, - shouldInsertAtTheTop, - hasTemplate - ) { - return { - ...pick( settings, [ - '__experimentalBlockDirectory', - '__experimentalBlockPatterns', - '__experimentalBlockPatternCategories', - '__experimentalFeatures', - '__experimentalGlobalStylesUserEntityId', - '__experimentalGlobalStylesBaseStyles', - '__experimentalPreferredStyleVariations', - '__experimentalSetIsInserterOpened', - 'alignWide', - 'allowedBlockTypes', - 'availableLegacyWidgets', - 'bodyPlaceholder', - 'codeEditingEnabled', - 'colors', - 'disableCustomColors', - 'disableCustomFontSizes', - 'disableCustomGradients', - 'enableCustomUnits', - 'enableCustomLineHeight', - 'focusMode', - 'fontSizes', - 'gradients', - 'hasFixedToolbar', - 'hasReducedUI', - 'imageEditing', - 'imageSizes', - 'imageDimensions', - 'isRTL', - 'keepCaretInsideBlock', - 'maxWidth', - 'onUpdateDefaultBlockStyles', - 'styles', - 'template', - 'templateLock', - 'titlePlaceholder', - ] ), - mediaUpload: hasUploadPermissions ? mediaUpload : undefined, - __experimentalReusableBlocks: reusableBlocks, - __experimentalFetchLinkSuggestions: partialRight( - fetchLinkSuggestions, - settings - ), - __experimentalCanUserUseUnfilteredHTML: canUserUseUnfilteredHTML, - __experimentalUndo: undo, - __experimentalShouldInsertAtTheTop: shouldInsertAtTheTop, - outlineMode: hasTemplate, + return () => { + __experimentalTearDownEditor(); }; - } + }, [] ); - getDefaultBlockContext( postId, postType ) { - // To avoid infinite loops, the template CPT shouldn't provide itself as a post content. - if ( postType === 'wp_template' ) { - return {}; - } - return { postId, postType }; - } + // Synchronize the editor settings as they change + useEffect( () => { + updateEditorSettings( settings ); + }, [ settings ] ); - componentDidMount() { - this.props.updateEditorSettings( this.props.settings ); - } + // Synchronize the template as it changes + useEffect( () => { + __unstableSetupTemplate( __unstableTemplate ); + }, [ __unstableTemplate ] ); - componentDidUpdate( prevProps ) { - if ( this.props.settings !== prevProps.settings ) { - this.props.updateEditorSettings( this.props.settings ); - } - if ( - this.props.__unstableTemplate && - this.props.__unstableTemplate.id !== - prevProps.__unstableTemplate?.id - ) { - this.props.setupTemplate( this.props.__unstableTemplate ); - } - } - - componentWillUnmount() { - this.props.tearDownEditor(); + if ( ! isReady ) { + return null; } - render() { - const { - canUserUseUnfilteredHTML, - children, - post, - blocks, - resetEditorBlocks, - selectionStart, - selectionEnd, - isReady, - settings, - reusableBlocks, - resetEditorBlocksWithoutUndoLevel, - hasUploadPermissions, - isPostTitleSelected, - undo, - hasTemplate, - } = this.props; - - if ( ! isReady ) { - return null; - } - - const editorSettings = this.getBlockEditorSettings( - settings, - reusableBlocks, - hasUploadPermissions, - canUserUseUnfilteredHTML, - undo, - isPostTitleSelected, - hasTemplate - ); - - const defaultBlockContext = this.getDefaultBlockContext( - post.id, - post.type - ); - - return ( - - - - - { children } - - - - - + return ( + + + + + { children } + + + + - ); - } + + ); } -export default compose( [ - withRegistryProvider, - withSelect( ( select, { __unstableTemplate, post } ) => { - const { - canUserUseUnfilteredHTML, - __unstableIsEditorReady: isEditorReady, - getEditorSelectionStart, - getEditorSelectionEnd, - isPostTitleSelected, - } = select( 'core/editor' ); - const { canUser, getEditedEntityRecord } = select( 'core' ); - - const { id, type } = __unstableTemplate ?? post; - return { - hasTemplate: !! __unstableTemplate, - canUserUseUnfilteredHTML: canUserUseUnfilteredHTML(), - isReady: isEditorReady(), - blocks: getEditedEntityRecord( 'postType', type, id ).blocks, - selectionStart: getEditorSelectionStart(), - selectionEnd: getEditorSelectionEnd(), - reusableBlocks: select( 'core' ).getEntityRecords( - 'postType', - 'wp_block', - { per_page: -1 } - ), - hasUploadPermissions: defaultTo( - canUser( 'create', 'media' ), - true - ), - // This selector is only defined on mobile. - isPostTitleSelected: isPostTitleSelected && isPostTitleSelected(), - }; - } ), - withDispatch( ( dispatch, props ) => { - const { - setupEditor, - updatePostLock, - updateEditorSettings, - __experimentalTearDownEditor, - undo, - __unstableSetupTemplate, - } = dispatch( 'core/editor' ); - const { createWarningNotice } = dispatch( noticesStore ); - const { __unstableCreateUndoLevel, editEntityRecord } = dispatch( - 'core' - ); - - // This is not breaking the withDispatch rule. - // eslint-disable-next-line no-restricted-syntax - function updateBlocks( blocks, options ) { - const { - post, - __unstableTemplate: template, - blocks: currentBlocks, - } = props; - const { id, type } = template ?? post; - const { - __unstableShouldCreateUndoLevel, - selectionStart, - selectionEnd, - } = options; - const edits = { blocks, selectionStart, selectionEnd }; - - if ( __unstableShouldCreateUndoLevel !== false ) { - const noChange = currentBlocks === edits.blocks; - if ( noChange ) { - return __unstableCreateUndoLevel( 'postType', type, id ); - } - - // We create a new function here on every persistent edit - // to make sure the edit makes the post dirty and creates - // a new undo level. - edits.content = ( { blocks: blocksForSerialization = [] } ) => - serializeBlocks( blocksForSerialization ); - } - - editEntityRecord( 'postType', type, id, edits ); - } - - return { - setupEditor, - updatePostLock, - createWarningNotice, - resetEditorBlocks: updateBlocks, - updateEditorSettings, - resetEditorBlocksWithoutUndoLevel( blocks, options ) { - updateBlocks( blocks, { - ...options, - __unstableShouldCreateUndoLevel: false, - } ); - }, - tearDownEditor: __experimentalTearDownEditor, - setupTemplate: __unstableSetupTemplate, - undo, - }; - } ), -] )( EditorProvider ); +export default withRegistryProvider( EditorProvider ); diff --git a/packages/editor/src/components/provider/use-block-editor-settings.js b/packages/editor/src/components/provider/use-block-editor-settings.js new file mode 100644 index 00000000000000..e9625513514d45 --- /dev/null +++ b/packages/editor/src/components/provider/use-block-editor-settings.js @@ -0,0 +1,208 @@ +/** + * External dependencies + */ +import { map, pick, defaultTo, flatten, partialRight } from 'lodash'; + +/** + * WordPress dependencies + */ +import { useMemo } from '@wordpress/element'; +import { useDispatch, useSelect } from '@wordpress/data'; +import { __ } from '@wordpress/i18n'; +import { store as coreStore } from '@wordpress/core-data'; +import apiFetch from '@wordpress/api-fetch'; +import { addQueryArgs } from '@wordpress/url'; +import { decodeEntities } from '@wordpress/html-entities'; + +/** + * Internal dependencies + */ +import { mediaUpload } from '../../utils'; +import { store as editorStore } from '../../store'; + +/** + * Fetches link suggestions from the API. This function is an exact copy of a function found at: + * + * packages/edit-navigation/src/index.js + * + * It seems like there is no suitable package to import this from. Ideally it would be either part of core-data. + * Until we refactor it, just copying the code is the simplest solution. + * + * @param {string} search + * @param {Object} [searchArguments] + * @param {number} [searchArguments.isInitialSuggestions] + * @param {number} [searchArguments.type] + * @param {number} [searchArguments.subtype] + * @param {number} [searchArguments.page] + * @param {Object} [editorSettings] + * @param {boolean} [editorSettings.disablePostFormats=false] + * @return {Promise} List of suggestions + */ + +const fetchLinkSuggestions = async ( + search, + { isInitialSuggestions, type, subtype, page, perPage: perPageArg } = {}, + { disablePostFormats = false } = {} +) => { + const perPage = perPageArg || isInitialSuggestions ? 3 : 20; + + const queries = []; + + if ( ! type || type === 'post' ) { + queries.push( + apiFetch( { + path: addQueryArgs( '/wp/v2/search', { + search, + page, + per_page: perPage, + type: 'post', + subtype, + } ), + } ).catch( () => [] ) // fail by returning no results + ); + } + + if ( ! type || type === 'term' ) { + queries.push( + apiFetch( { + path: addQueryArgs( '/wp/v2/search', { + search, + page, + per_page: perPage, + type: 'term', + subtype, + } ), + } ).catch( () => [] ) + ); + } + + if ( ! disablePostFormats && ( ! type || type === 'post-format' ) ) { + queries.push( + apiFetch( { + path: addQueryArgs( '/wp/v2/search', { + search, + page, + per_page: perPage, + type: 'post-format', + subtype, + } ), + } ).catch( () => [] ) + ); + } + + return Promise.all( queries ).then( ( results ) => { + return map( + flatten( results ) + .filter( ( result ) => !! result.id ) + .slice( 0, perPage ), + ( result ) => ( { + id: result.id, + url: result.url, + title: decodeEntities( result.title ) || __( '(no title)' ), + type: result.subtype || result.type, + } ) + ); + } ); +}; + +/** + * React hook used to compute the block editor settings to use for the post editor. + * + * @param {Object} settings EditorProvider settings prop. + * @param {boolean} hasTemplate Whether template mode is enabled. + * + * @return {Object} Block Editor Settings. + */ +function useBlockEditorSettings( settings, hasTemplate ) { + const { + reusableBlocks, + hasUploadPermissions, + canUseUnfilteredHTML, + isTitleSelected, + } = useSelect( ( select ) => { + const { canUserUseUnfilteredHTML, isPostTitleSelected } = select( + editorStore + ); + const { canUser } = select( coreStore ); + + return { + canUseUnfilteredHTML: canUserUseUnfilteredHTML(), + reusableBlocks: select( 'core' ).getEntityRecords( + 'postType', + 'wp_block', + { per_page: -1 } + ), + hasUploadPermissions: defaultTo( + canUser( 'create', 'media' ), + true + ), + // This selector is only defined on mobile. + isTitleSelected: isPostTitleSelected && isPostTitleSelected(), + }; + } ); + + const { undo } = useDispatch( editorStore ); + + return useMemo( + () => ( { + ...pick( settings, [ + '__experimentalBlockDirectory', + '__experimentalBlockPatterns', + '__experimentalBlockPatternCategories', + '__experimentalFeatures', + '__experimentalGlobalStylesUserEntityId', + '__experimentalGlobalStylesBaseStyles', + '__experimentalPreferredStyleVariations', + '__experimentalSetIsInserterOpened', + 'alignWide', + 'allowedBlockTypes', + 'availableLegacyWidgets', + 'bodyPlaceholder', + 'codeEditingEnabled', + 'colors', + 'disableCustomColors', + 'disableCustomFontSizes', + 'disableCustomGradients', + 'enableCustomUnits', + 'enableCustomLineHeight', + 'focusMode', + 'fontSizes', + 'gradients', + 'hasFixedToolbar', + 'hasReducedUI', + 'imageEditing', + 'imageSizes', + 'imageDimensions', + 'isRTL', + 'keepCaretInsideBlock', + 'maxWidth', + 'onUpdateDefaultBlockStyles', + 'styles', + 'template', + 'templateLock', + 'titlePlaceholder', + ] ), + mediaUpload: hasUploadPermissions ? mediaUpload : undefined, + __experimentalReusableBlocks: reusableBlocks, + __experimentalFetchLinkSuggestions: partialRight( + fetchLinkSuggestions, + settings + ), + __experimentalCanUserUseUnfilteredHTML: canUseUnfilteredHTML, + __experimentalUndo: undo, + __experimentalShouldInsertAtTheTop: isTitleSelected, + outlineMode: hasTemplate, + } ), + [ + settings, + hasUploadPermissions, + reusableBlocks, + canUseUnfilteredHTML, + undo, + isTitleSelected, + hasTemplate, + ] + ); +} + +export default useBlockEditorSettings; diff --git a/packages/editor/src/components/provider/use-post-content-editor.js b/packages/editor/src/components/provider/use-post-content-editor.js new file mode 100644 index 00000000000000..1077d3e8fee580 --- /dev/null +++ b/packages/editor/src/components/provider/use-post-content-editor.js @@ -0,0 +1,77 @@ +/** + * WordPress dependencies + */ +import { useSelect, useDispatch } from '@wordpress/data'; +import { store as coreStore } from '@wordpress/core-data'; +import { useCallback } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import serializeBlocks from '../../store/utils/serialize-blocks'; + +/** + * This hook provides the required props to edit the "content" of a given postType and postId. + * + * @param {string} postType Post Type. + * @param {string} postId Post Id. + * + * @return {Object} BlockEditorProvider props. + */ +function usePostContentEditor( postType, postId ) { + const { blocks } = useSelect( + ( select ) => { + const { getEditedEntityRecord } = select( coreStore ); + return { + blocks: getEditedEntityRecord( 'postType', postType, postId ) + .blocks, + }; + }, + [ postType, postId ] + ); + const { __unstableCreateUndoLevel, editEntityRecord } = useDispatch( + coreStore + ); + + const onChange = useCallback( + ( newBlocks, options ) => { + const { + __unstableShouldCreateUndoLevel, + selectionStart, + selectionEnd, + } = options; + const edits = { blocks: newBlocks, selectionStart, selectionEnd }; + + if ( __unstableShouldCreateUndoLevel !== false ) { + const noChange = blocks === edits.blocks; + if ( noChange ) { + return __unstableCreateUndoLevel( + 'postType', + postType, + postId + ); + } + + // We create a new function here on every persistent edit + // to make sure the edit makes the post dirty and creates + // a new undo level. + edits.content = ( { blocks: blocksForSerialization = [] } ) => + serializeBlocks( blocksForSerialization ); + } + + editEntityRecord( 'postType', postType, postId, edits ); + }, + [ blocks, postId, postType ] + ); + + const onInput = useCallback( ( newBlocks, options ) => { + onChange( newBlocks, { + ...options, + __unstableShouldCreateUndoLevel: false, + } ); + }, onChange ); + + return { value: blocks, onChange, onInput }; +} + +export default usePostContentEditor;