diff --git a/docs/designers-developers/developers/block-api/block-templates.md b/docs/designers-developers/developers/block-api/block-templates.md index b2b384a96e211f..a2610c053c897d 100644 --- a/docs/designers-developers/developers/block-api/block-templates.md +++ b/docs/designers-developers/developers/block-api/block-templates.md @@ -107,7 +107,8 @@ add_action( 'init', 'myplugin_register_template' ); *Options:* -- `all` — prevents all operations. It is not possible to insert new blocks, move existing blocks, or delete blocks. +- `readonly` — prevents all operations. It is not possible to edit any blocks, insert new blocks, move or delete existing blocks. +- `all` — prevents all block operations. It is not possible to insert new blocks, move or delete existing blocks. - `insert` — prevents inserting or removing blocks, but allows moving existing blocks. ## Nested Templates diff --git a/docs/designers-developers/developers/data/data-core.md b/docs/designers-developers/developers/data/data-core.md index 3f823475fcca1c..3fbc2484d49011 100644 --- a/docs/designers-developers/developers/data/data-core.md +++ b/docs/designers-developers/developers/data/data-core.md @@ -153,6 +153,22 @@ _Returns_ - `Array`: Records. +# **getSiteOptions** + +Return site options as they exist locally. + +_Related_ + +- isSiteOptionsDirty + +_Parameters_ + +- _state_ `Object`: Data state. + +_Returns_ + +- `Object`: Site options. + # **getThemeSupports** Return theme supports data in the index. @@ -242,6 +258,19 @@ _Returns_ - `boolean`: Whether a request is in progress for an embed preview. +# **isSiteOptionsDirty** + +Return whether the client has local changes to site options which haven't +yet been saved to the server. + +_Parameters_ + +- _state_ `Object`: Data state. + +_Returns_ + +- `boolean`: Whether or not the local site options state is dirty. + @@ -317,6 +346,18 @@ _Returns_ - `Object`: Action object. +# **receiveSiteOptions** + +Returns an action object used in signalling that site options have been received. + +_Parameters_ + +- _siteOptions_ `Object`: Site options. + +_Returns_ + +- `Object`: Action object. + # **receiveThemeSupports** Returns an action object used in signalling that the index has been received. @@ -382,4 +423,28 @@ _Returns_ - `Object`: Updated record. +# **saveSiteOptions** + +Action triggered to save site options. + +_Parameters_ + +- _siteOptions_ `Object`: Site options. + +_Returns_ + +- `Object`: Updated site options. + +# **updateSiteOptions** + +Returns an action object used in signalling that site options have been locally updated. + +_Parameters_ + +- _siteOptions_ `Object`: Site options. + +_Returns_ + +- `Object`: Action object. + diff --git a/lib/blocks.php b/lib/blocks.php index fc3adb8a09efa3..418195cb37ac66 100644 --- a/lib/blocks.php +++ b/lib/blocks.php @@ -27,7 +27,12 @@ function gutenberg_reregister_core_block_types() { 'rss.php' => 'core/rss', 'shortcode.php' => 'core/shortcode', 'search.php' => 'core/search', + 'site-title.php' => 'core/site-title', 'tag-cloud.php' => 'core/tag-cloud', + 'post-title.php' => 'core/post-title', + 'post-content.php' => 'core/post-content', + 'post-date.php' => 'core/post-date', + 'template-part.php' => 'core/template-part', ); $registry = WP_Block_Type_Registry::get_instance(); @@ -45,3 +50,24 @@ function gutenberg_reregister_core_block_types() { } } add_action( 'init', 'gutenberg_reregister_core_block_types' ); + +/** + * Adds new block categories needed by the Gutenberg plugin. + * + * @param array $categories Array of block categories. + * + * @return array Array of block categories plus the new categories added. + */ +function gutenberg_block_categories( $categories ) { + return array_merge( + $categories, + array( + array( + 'slug' => 'theme', + 'title' => __( 'Theme Blocks' ), + 'icon' => null, + ), + ) + ); +} +add_filter( 'block_categories', 'gutenberg_block_categories' ); diff --git a/lib/load.php b/lib/load.php index 7a3c97fb65553b..eb273e165ca948 100644 --- a/lib/load.php +++ b/lib/load.php @@ -30,6 +30,7 @@ require dirname( __FILE__ ) . '/compat.php'; require dirname( __FILE__ ) . '/blocks.php'; +require dirname( __FILE__ ) . '/templates.php'; require dirname( __FILE__ ) . '/client-assets.php'; require dirname( __FILE__ ) . '/demo.php'; require dirname( __FILE__ ) . '/widgets.php'; diff --git a/lib/templates.php b/lib/templates.php new file mode 100644 index 00000000000000..58926dcf39d9f8 --- /dev/null +++ b/lib/templates.php @@ -0,0 +1,191 @@ + array( + 'name' => __( 'Templates', 'gutenberg' ), + ), + 'show_in_rest' => true, + 'show_ui' => true, + ) + ); + + $template_query = new WP_Query( + array( + 'post_type' => 'wp_template', + 'name' => 'single-post', + ) + ); + + $template; + if ( ! $template_query->have_posts() ) { + $footer_template_part_id = wp_insert_post( + array( + 'post_type' => 'wp_template', + 'post_title' => 'Footer', + 'post_name' => 'footer-template-part', + 'post_content' => "\n

Template part

\n\n\n", + ) + ); + + $template_id = wp_insert_post( + array( + 'post_type' => 'wp_template', + 'post_name' => 'single-post', + 'post_content' => ' + + + + +
+
+ + + +
+ + + +
+
+

This is a Sidebar.

+ + + +

With some block widgets.

+ + + + + +

You are now reading post:

+ + + + + + +
+
+
+ + ', + ) + ); + $template = get_post( $template_id ); + } else { + $template = $template_query->get_posts(); + $template = $template[0]; + } + + $post_type_object = get_post_type_object( 'post' ); + $post_type_object->template_post = $template; +} +add_action( 'init', 'gutenberg_register_templates' ); + +/** + * Filters the block editor settings object to add the post's template post. + * + * @param array $editor_settings The block editor settings object. + * @param \WP_Post $post The post object. + * + * @return array The maybe modified block editor settings object. + */ +function gutenberg_filter_block_editor_settings( $editor_settings, $post ) { + $post_type_object = get_post_type_object( get_post_type( $post ) ); + if ( ! empty( $post_type_object->template_post ) ) { + $editor_settings['templatePost'] = $post_type_object->template_post; + } + return $editor_settings; +} +add_filter( 'block_editor_settings', 'gutenberg_filter_block_editor_settings', 10, 2 ); + +/** + * Returns an array of paths to template parts that should be preloaded, based on what template part blocks exist in the blocks passed. + * + * @param array $blocks Array of block objects. + * + * @return array Array of paths to template parts posts. + */ +function get_template_part_preload_paths( $blocks ) { + if ( empty( $blocks ) ) { + return array(); + } + $parts = array(); + foreach ( $blocks as $block ) { + if ( 'core/template-part' === $block['blockName'] && + isset( $block['attrs']['id'] ) + ) { + $parts[] = sprintf( '/wp/v2/wp_template/%s?context=edit', $block['attrs']['id'] ); + } + $parts = array_merge( + $parts, + get_template_part_preload_paths( $block['innerBlocks'] ) + ); + } + return $parts; +} + +/** + * Filter the preload_paths to include paths to template part posts referenced in the current post template. + * + * @param array $preload_paths Array of paths to preload. + * + * @return array Array of paths to preload with template part paths added. + */ +function gutenberg_preload_template_parts( $preload_paths ) { + global $post; + $post_type_object = get_post_type_object( get_post_type( $post ) ); + if ( ! empty( $post_type_object->template_post ) ) { + $blocks = parse_blocks( $post_type_object->template_post->post_content ); + return array_merge( + $preload_paths, + get_template_part_preload_paths( $blocks ) + ); + } + return $preload_paths; +} + +add_filter( 'block_editor_preload_paths', 'gutenberg_preload_template_parts' ); + +/** + * Filters template inclusion in pages to hijack the `single.php` template + * and load the Gutenberg editable counterpart instead. + * + * @param string $template The included template file name. + * + * @return string The passed in file name unless the process is hijacked. + */ +function gutenberg_filter_template_include( $template ) { + if ( ! preg_match( '/single\.php$/', $template ) ) { + return $template; + } + + $template_query = new WP_Query( + array( + 'post_type' => 'wp_template', + 'name' => 'single-post', + ) + ); + $template = $template_query->get_posts(); + $template = $template[0]; + echo wp_head() . apply_filters( 'the_content', $template->post_content ) . wp_footer(); + exit; +} +add_filter( 'template_include', 'gutenberg_filter_template_include' ); diff --git a/packages/block-editor/src/components/block-drop-zone/index.js b/packages/block-editor/src/components/block-drop-zone/index.js index e0a0d1060ce4fe..9178bcbf343af2 100644 --- a/packages/block-editor/src/components/block-drop-zone/index.js +++ b/packages/block-editor/src/components/block-drop-zone/index.js @@ -111,10 +111,7 @@ class BlockDropZone extends Component { } render() { - const { isLockedAll, index } = this.props; - if ( isLockedAll ) { - return null; - } + const { index } = this.props; const isAppender = index === undefined; return ( @@ -155,10 +152,10 @@ export default compose( }, }; } ), - withSelect( ( select, { rootClientId } ) => { - const { getClientIdsOfDescendants, getTemplateLock, getBlockIndex } = select( 'core/block-editor' ); + withSelect( ( select ) => { + const { getClientIdsOfDescendants, getBlockIndex } = select( 'core/block-editor' ); + return { - isLockedAll: getTemplateLock( rootClientId ) === 'all', getClientIdsOfDescendants, getBlockIndex, }; diff --git a/packages/block-editor/src/components/block-edit/context.js b/packages/block-editor/src/components/block-edit/context.js index 863cdc3e5d6fa5..d30bbdfc6e3413 100644 --- a/packages/block-editor/src/components/block-edit/context.js +++ b/packages/block-editor/src/components/block-edit/context.js @@ -15,6 +15,7 @@ const { Consumer, Provider } = createContext( { focusedElement: null, setFocusedElement: noop, clientId: null, + isReadOnly: null, } ); export { Provider as BlockEditContextProvider }; diff --git a/packages/block-editor/src/components/block-edit/index.js b/packages/block-editor/src/components/block-edit/index.js index 63c475a50692ff..b60f2f1cd46890 100644 --- a/packages/block-editor/src/components/block-edit/index.js +++ b/packages/block-editor/src/components/block-edit/index.js @@ -27,13 +27,13 @@ class BlockEdit extends Component { ); } - propsToContext( name, isSelected, clientId, onFocus, onCaretVerticalPositionChange ) { - return { name, isSelected, clientId, onFocus, onCaretVerticalPositionChange }; + propsToContext( name, isSelected, clientId, onFocus, onCaretVerticalPositionChange, isReadOnly ) { + return { name, isSelected, clientId, onFocus, onCaretVerticalPositionChange, isReadOnly }; } render() { - const { name, isSelected, clientId, onFocus, onCaretVerticalPositionChange } = this.props; - const value = this.propsToContext( name, isSelected, clientId, onFocus, onCaretVerticalPositionChange ); + const { name, isSelected, clientId, onFocus, onCaretVerticalPositionChange, isReadOnly } = this.props; + const value = this.propsToContext( name, isSelected, clientId, onFocus, onCaretVerticalPositionChange, isReadOnly ); return ( diff --git a/packages/block-editor/src/components/block-inspector/index.js b/packages/block-editor/src/components/block-inspector/index.js index e04dbcbd932be1..df4a2bef80d365 100644 --- a/packages/block-editor/src/components/block-inspector/index.js +++ b/packages/block-editor/src/components/block-inspector/index.js @@ -10,6 +10,7 @@ import { __ } from '@wordpress/i18n'; import { getBlockType, getUnregisteredTypeHandlerName } from '@wordpress/blocks'; import { PanelBody } from '@wordpress/components'; import { withSelect } from '@wordpress/data'; +import { compose } from '@wordpress/compose'; /** * Internal dependencies @@ -20,6 +21,7 @@ import InspectorControls from '../inspector-controls'; import InspectorAdvancedControls from '../inspector-advanced-controls'; import BlockStyles from '../block-styles'; import MultiSelectionInspector from '../multi-selection-inspector'; + const BlockInspector = ( { blockType, count, @@ -89,9 +91,13 @@ const BlockInspector = ( { ); }; -export default withSelect( - ( select ) => { - const { getSelectedBlockClientId, getSelectedBlockCount, getBlockName } = select( 'core/block-editor' ); +export default compose( [ + withSelect( ( select ) => { + const { + getSelectedBlockClientId, + getSelectedBlockCount, + getBlockName, + } = select( 'core/block-editor' ); const { getBlockStyles } = select( 'core/blocks' ); const selectedBlockClientId = getSelectedBlockClientId(); const selectedBlockName = selectedBlockClientId && getBlockName( selectedBlockClientId ); @@ -104,5 +110,5 @@ export default withSelect( selectedBlockClientId, blockType, }; - } -)( BlockInspector ); + } ), +] )( BlockInspector ); diff --git a/packages/block-editor/src/components/block-list-appender/index.js b/packages/block-editor/src/components/block-list-appender/index.js index 35bf09bb89e669..559c9a54b3f0f3 100644 --- a/packages/block-editor/src/components/block-list-appender/index.js +++ b/packages/block-editor/src/components/block-list-appender/index.js @@ -14,7 +14,6 @@ import { getDefaultBlockName } from '@wordpress/blocks'; */ import IgnoreNestedEvents from '../ignore-nested-events'; import DefaultBlockAppender from '../default-block-appender'; -import ButtonBlockAppender from '../button-block-appender'; function BlockListAppender( { blockClientIds, @@ -53,14 +52,7 @@ function BlockListAppender( { // Fallback in the case no renderAppender has been provided and the // default block can't be inserted. - return ( -
- -
- ); + return null; } export default withSelect( ( select, { rootClientId } ) => { @@ -73,6 +65,6 @@ export default withSelect( ( select, { rootClientId } ) => { return { isLocked: !! getTemplateLock( rootClientId ), blockClientIds: getBlockOrder( rootClientId ), - canInsertDefaultBlock: canInsertBlockType( getDefaultBlockName(), rootClientId ), + canInsertDefaultBlock: ! rootClientId && canInsertBlockType( getDefaultBlockName() ), }; } )( BlockListAppender ); diff --git a/packages/block-editor/src/components/block-list/block.js b/packages/block-editor/src/components/block-list/block.js index 320f63555946c8..e234193d76b968 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 { first, last } from 'lodash'; +import { first, last, some } from 'lodash'; import { animated } from 'react-spring/web.cjs'; /** @@ -53,6 +53,8 @@ import useHoveredArea from './hover-area'; import { isInsideRootBlock } from '../../utils/dom'; import useMovingAnimation from './moving-animation'; +const postBlockTypes = [ 'core/post-content', 'core/post-date', 'core/post-title' ]; + /** * Prevents default dragging behavior within a block to allow for multi- * selection to take effect unhampered. @@ -69,6 +71,7 @@ function BlockListBlock( { isFocusMode, hasFixedToolbar, isLocked, + isReadOnly, clientId, rootClientId, isSelected, @@ -101,6 +104,7 @@ function BlockListBlock( { onSelectionStart, animateOnChange, enableAnimation, + isAncestorOfPostBlock, } ) { // Random state used to rerender the component if needed, ideally we don't need this const [ , updateRerenderState ] = useState( {} ); @@ -403,6 +407,8 @@ function BlockListBlock( { 'is-typing': isTypingWithinBlock, 'is-focused': isFocusMode && ( isSelected || isParentOfSelectedBlock ), 'is-focus-mode': isFocusMode, + 'is-ancestor-of-post-block': isAncestorOfPostBlock, + 'is-post-block': postBlockTypes.includes( name ), }, className ); @@ -430,6 +436,7 @@ function BlockListBlock( { insertBlocksAfter={ isLocked ? undefined : onInsertBlocksAfter } onReplace={ isLocked ? undefined : onReplace } mergeBlocks={ isLocked ? undefined : onMerge } + isReadOnly={ isReadOnly } clientId={ clientId } isSelectionEnabled={ isSelectionEnabled } toggleSelection={ toggleSelection } @@ -475,23 +482,20 @@ function BlockListBlock( { rootClientId={ rootClientId } /> ) } - + /> } { isFirstMultiSelected && ( ) }
- { shouldRenderMovers && ( + { isMovable && shouldRenderMovers && ( @@ -504,7 +508,7 @@ function BlockListBlock( { } /> ) } - { ( shouldShowContextualToolbar || isForcingContextualToolbar.current ) && ( + { ( shouldShowContextualToolbar || isForcingContextualToolbar.current ) && ! isReadOnly && ( { + const { + getTemplateLock, + } = select( 'core/block-editor' ); + const templateLock = getTemplateLock( rootClientId ); + + return { + isMovable: isReadOnly === undefined ? templateLock !== 'all' && templateLock !== 'readonly' : ! isReadOnly, + isLocked: isReadOnly === undefined ? !! templateLock : isReadOnly, + isReadOnly: isReadOnly === undefined ? templateLock === 'readonly' : isReadOnly, + }; + } +); + const applyWithSelect = withSelect( ( select, { clientId, rootClientId, isLargeViewport } ) => { const { @@ -590,15 +609,14 @@ const applyWithSelect = withSelect( getSelectedBlocksInitialCaretPosition, getSettings, hasSelectedInnerBlock, - getTemplateLock, getBlockIndex, getBlockOrder, __unstableGetBlockWithoutInnerBlocks, + isAncestorOfBlockTypeName, } = select( 'core/block-editor' ); const block = __unstableGetBlockWithoutInnerBlocks( clientId ); const isSelected = isBlockSelected( clientId ); const { hasFixedToolbar, focusMode, isRTL } = getSettings(); - const templateLock = getTemplateLock( rootClientId ); const isParentOfSelectedBlock = hasSelectedInnerBlock( clientId, true ); const index = getBlockIndex( clientId, rootClientId ); const blockOrder = getBlockOrder( rootClientId ); @@ -622,8 +640,6 @@ const applyWithSelect = withSelect( initialPosition: isSelected ? getSelectedBlocksInitialCaretPosition() : null, isEmptyDefaultBlock: name && isUnmodifiedDefaultBlock( { name, attributes } ), - isMovable: 'all' !== templateLock, - isLocked: !! templateLock, isFocusMode: focusMode && isLargeViewport, hasFixedToolbar: hasFixedToolbar && isLargeViewport, isLast: index === blockOrder.length - 1, @@ -639,6 +655,7 @@ const applyWithSelect = withSelect( isValid, isSelected, isParentOfSelectedBlock, + isAncestorOfPostBlock: some( postBlockTypes, ( blockTypeName ) => isAncestorOfBlockTypeName( clientId, blockTypeName ) ), }; } ); @@ -658,7 +675,12 @@ const applyWithDispatch = withDispatch( ( dispatch, ownProps, { select } ) => { return { setAttributes( newAttributes ) { - const { clientId } = ownProps; + const { clientId, isReadOnly } = ownProps; + + if ( isReadOnly ) { + return; + } + updateBlockAttributes( clientId, newAttributes ); }, onSelect( clientId = ownProps.clientId, initialPosition ) { @@ -735,5 +757,6 @@ export default compose( withViewportMatch( { isLargeViewport: 'medium' } ), applyWithSelect, applyWithDispatch, - withFilters( 'editor.BlockListBlock' ) + withFilters( 'editor.BlockListBlock' ), + applyWithSelectAfterFilters, )( BlockListBlock ); diff --git a/packages/block-editor/src/components/block-list/index.js b/packages/block-editor/src/components/block-list/index.js index 3e65a52fd3b2ec..f949901047acec 100644 --- a/packages/block-editor/src/components/block-list/index.js +++ b/packages/block-editor/src/components/block-list/index.js @@ -203,6 +203,7 @@ class BlockList extends Component { selectedBlockClientId, multiSelectedBlockClientIds, hasMultiSelection, + showWireframes, renderAppender, enableAnimation, } = this.props; @@ -220,19 +221,22 @@ class BlockList extends Component { clientId={ clientId } isBlockInSelection={ isBlockInSelection } > - + { showWireframes &&
} + { ! showWireframes && ( + + ) } ); } ) } diff --git a/packages/block-editor/src/components/block-list/moving-animation.js b/packages/block-editor/src/components/block-list/moving-animation.js index 393bfa33e2a7ca..99a25024ed465f 100644 --- a/packages/block-editor/src/components/block-list/moving-animation.js +++ b/packages/block-editor/src/components/block-list/moving-animation.js @@ -6,7 +6,7 @@ import { useSpring, interpolate } from 'react-spring/web.cjs'; /** * WordPress dependencies */ -import { useState, useLayoutEffect } from '@wordpress/element'; +import { useState, useLayoutEffect, useReducer } from '@wordpress/element'; import { useReducedMotion } from '@wordpress/compose'; /** @@ -29,10 +29,16 @@ import { useReducedMotion } from '@wordpress/compose'; */ function useMovingAnimation( ref, isSelected, enableAnimation, triggerAnimationOnChange ) { const prefersReducedMotion = useReducedMotion() || ! enableAnimation; - const [ resetAnimation, setResetAnimation ] = useState( false ); + const [ triggeredAnimation, triggerAnimation ] = useReducer( ( state = 0 ) => state + 1 ); + const [ finishedAnimation, endAnimation ] = useReducer( ( state = 0 ) => state + 1 ); const [ transform, setTransform ] = useState( { x: 0, y: 0 } ); - const previous = ref.current ? ref.current.getBoundingClientRect() : null; + + useLayoutEffect( () => { + if ( triggeredAnimation ) { + endAnimation(); + } + }, [ triggeredAnimation ] ); useLayoutEffect( () => { if ( prefersReducedMotion ) { return; @@ -46,21 +52,16 @@ function useMovingAnimation( ref, isSelected, enableAnimation, triggerAnimationO ref.current.style.transform = newTransform.x === 0 && newTransform.y === 0 ? undefined : `translate3d(${ newTransform.x }px,${ newTransform.y }px,0)`; - setResetAnimation( true ); + triggerAnimation(); setTransform( newTransform ); }, [ triggerAnimationOnChange ] ); - useLayoutEffect( () => { - if ( resetAnimation ) { - setResetAnimation( false ); - } - }, [ resetAnimation ] ); const animationProps = useSpring( { from: transform, to: { x: 0, y: 0, }, - reset: resetAnimation, + reset: triggeredAnimation !== finishedAnimation, config: { mass: 5, tension: 2000, friction: 200 }, immediate: prefersReducedMotion, } ); diff --git a/packages/block-editor/src/components/block-list/style.scss b/packages/block-editor/src/components/block-list/style.scss index 8ee3558ddf6398..d25c7c578d8776 100644 --- a/packages/block-editor/src/components/block-list/style.scss +++ b/packages/block-editor/src/components/block-list/style.scss @@ -1094,3 +1094,10 @@ padding: 10px $block-padding; } } + +.editor-block-list__wireframe { + background: #eee; + margin: 10px; + height: 100px; + width: 100%; +} diff --git a/packages/block-editor/src/components/block-mover/index.js b/packages/block-editor/src/components/block-mover/index.js index dd09dc1c86b7c5..2251fe34b8d732 100644 --- a/packages/block-editor/src/components/block-mover/index.js +++ b/packages/block-editor/src/components/block-mover/index.js @@ -44,10 +44,11 @@ export class BlockMover extends Component { } render() { - const { onMoveUp, onMoveDown, isFirst, isLast, isDraggable, onDragStart, onDragEnd, clientIds, blockElementId, blockType, firstIndex, isLocked, instanceId, isHidden, rootClientId } = this.props; + const { onMoveUp, onMoveDown, isFirst, isLast, isDraggable, onDragStart, onDragEnd, clientIds, blockElementId, blockType, firstIndex, instanceId, isHidden, rootClientId } = this.props; const { isFocused } = this.state; const blocksCount = castArray( clientIds ).length; - if ( isLocked || ( isFirst && isLast && ! rootClientId ) ) { + + if ( isFirst && isLast && ! rootClientId ) { return null; } @@ -117,7 +118,7 @@ export class BlockMover extends Component { export default compose( withSelect( ( select, { clientIds } ) => { - const { getBlock, getBlockIndex, getTemplateLock, getBlockRootClientId, getBlockOrder } = select( 'core/block-editor' ); + const { getBlock, getBlockIndex, getBlockRootClientId, getBlockOrder } = select( 'core/block-editor' ); const normalizedClientIds = castArray( clientIds ); const firstClientId = first( normalizedClientIds ); const block = getBlock( firstClientId ); @@ -128,7 +129,6 @@ export default compose( return { blockType: block ? getBlockType( block.name ) : null, - isLocked: getTemplateLock( rootClientId ) === 'all', rootClientId, firstIndex, isFirst: firstIndex === 0, diff --git a/packages/block-editor/src/components/block-navigation/index.js b/packages/block-editor/src/components/block-navigation/index.js index b0542caa3d6f39..3b009f26587ccb 100644 --- a/packages/block-editor/src/components/block-navigation/index.js +++ b/packages/block-editor/src/components/block-navigation/index.js @@ -35,6 +35,11 @@ function BlockNavigationList( { const blockType = getBlockType( block.name ); const isSelected = block.clientId === selectedBlockClientId; + let blockDisplayName = blockType.title; + if ( block.name === 'core/template-part' && block.attributes.name ) { + blockDisplayName = `Template: ${ block.attributes.name }`; + } + return (
  • @@ -45,7 +50,7 @@ function BlockNavigationList( { onClick={ () => selectBlock( block.clientId ) } > - { blockType.title } + { blockDisplayName } { isSelected && { __( '(selected block)' ) } }
    diff --git a/packages/block-editor/src/components/inner-blocks/README.md b/packages/block-editor/src/components/inner-blocks/README.md index 303e24730f7fe3..8dd9e8725e5aea 100644 --- a/packages/block-editor/src/components/inner-blocks/README.md +++ b/packages/block-editor/src/components/inner-blocks/README.md @@ -98,7 +98,7 @@ To present the user with a set of template choices for the inner blocks, you may A template option is an object consisting of the following properties: -- `title` (`string`): A human-readable label which describes the template. +- `title` (`string`): A human-readable label which describes the template. - `icon` (`WPElement|string`): An element or [Dashicon](https://developer.wordpress.org/resource/dashicons/) slug to show as a visual approximation of the template. - `template` (`Array`): The template to apply when the option has been selected. See [`template` documentation](#template) for more information. @@ -168,7 +168,8 @@ Template locking of `InnerBlocks` is similar to [Custom Post Type templates lock Template locking allows locking the `InnerBlocks` area for the current template. *Options:* -- `'all'` — prevents all operations. It is not possible to insert new blocks. Move existing blocks or delete them. +- `'readonly'` — prevents all operations. It is not possible to edit any blocks, insert new blocks, or move or delete existing blocks. +- `'all'` — prevents all block operations. It is not possible to insert new blocks. Move existing blocks or delete them. - `'insert'` — prevents inserting or removing blocks, but allows moving existing ones. - `false` — prevents locking from being applied to an `InnerBlocks` area even if a parent block contains locking. ( Boolean ) @@ -184,7 +185,7 @@ A 'render prop' function that can be used to customize the block's appender. #### Notes * For convenience two predefined appender components are exposed on `InnerBlocks` which can be consumed within the render function: - - `` - display a `+` (plus) icon button that, when clicked, displays the block picker menu. No default Block is inserted. + - `` - display a `+` (plus) icon button that, when clicked, displays the block picker menu. No default Block is inserted. - `` - display the default block appender as set by `wp.blocks.setDefaultBlockName`. Typically this is the `paragraph` block. * Consumers are also free to pass any valid render function. This provides the full flexibility to define a bespoke block appender. @@ -205,7 +206,3 @@ A 'render prop' function that can be used to customize the block's appender. ) } /> ``` - - - - diff --git a/packages/block-editor/src/components/inner-blocks/index.js b/packages/block-editor/src/components/inner-blocks/index.js index 0d1feef14ff39d..6c851d1a58c0a4 100644 --- a/packages/block-editor/src/components/inner-blocks/index.js +++ b/packages/block-editor/src/components/inner-blocks/index.js @@ -39,7 +39,13 @@ class InnerBlocks extends Component { const { templateLock, parentLock, + isReadOnly, } = this.props; + + if ( isReadOnly !== null ) { + return isReadOnly ? 'readonly' : false; + } + return templateLock === undefined ? parentLock : templateLock; } @@ -110,6 +116,7 @@ class InnerBlocks extends Component { hasOverlay, renderAppender, template, + showWireframes, __experimentalTemplateOptions: templateOptions, __experimentalOnSelectTemplateOption: onSelectTemplateOption, __experimentalAllowTemplateOptionSkip: allowTemplateOptionSkip, @@ -119,7 +126,8 @@ class InnerBlocks extends Component { const isPlaceholder = template === null && !! templateOptions; const classes = classnames( 'editor-inner-blocks block-editor-inner-blocks', { - 'has-overlay': hasOverlay && ! isPlaceholder, + // Disable overlay temporarily. + 'has-overlay': false && hasOverlay && ! isPlaceholder, } ); return ( @@ -134,6 +142,7 @@ class InnerBlocks extends Component { ) }
  • @@ -142,7 +151,7 @@ class InnerBlocks extends Component { } InnerBlocks = compose( [ - withBlockEditContext( ( context ) => pick( context, [ 'clientId' ] ) ), + withBlockEditContext( ( context ) => pick( context, [ 'clientId', 'isReadOnly' ] ) ), withSelect( ( select, ownProps ) => { const { isBlockSelected, diff --git a/packages/block-editor/src/components/inspector-controls/index.js b/packages/block-editor/src/components/inspector-controls/index.js index b97df2f8d245ee..b7822226b6cbdf 100644 --- a/packages/block-editor/src/components/inspector-controls/index.js +++ b/packages/block-editor/src/components/inspector-controls/index.js @@ -6,11 +6,17 @@ import { createSlotFill } from '@wordpress/components'; /** * Internal dependencies */ -import { ifBlockEditSelected } from '../block-edit/context'; +import { ifBlockEditSelected, withBlockEditContext } from '../block-edit/context'; const { Fill, Slot } = createSlotFill( 'InspectorControls' ); -const InspectorControls = ifBlockEditSelected( Fill ); +const InspectorControls = withBlockEditContext( ( { isReadOnly } ) => ( { isReadOnly } ) )( ifBlockEditSelected( ( { isReadOnly, ...props } ) => { + if ( isReadOnly ) { + return null; + } + + return ; +} ) ); InspectorControls.Slot = Slot; diff --git a/packages/block-editor/src/components/media-placeholder/index.js b/packages/block-editor/src/components/media-placeholder/index.js index efad860f586e7a..8bc77352aa34c8 100644 --- a/packages/block-editor/src/components/media-placeholder/index.js +++ b/packages/block-editor/src/components/media-placeholder/index.js @@ -16,7 +16,6 @@ import classnames from 'classnames'; import { Button, FormFileUpload, - Placeholder, DropZone, IconButton, withFilters, @@ -32,6 +31,7 @@ import { withSelect } from '@wordpress/data'; import MediaUpload from '../media-upload'; import MediaUploadCheck from '../media-upload/check'; import URLPopover from '../url-popover'; +import Placeholder from '../placeholder'; const InsertFromURLPopover = ( { src, onChange, onSubmit, onClose } ) => ( diff --git a/packages/block-editor/src/components/placeholder/index.js b/packages/block-editor/src/components/placeholder/index.js new file mode 100644 index 00000000000000..808500eb7ec08d --- /dev/null +++ b/packages/block-editor/src/components/placeholder/index.js @@ -0,0 +1,37 @@ +/** + * WordPress dependencies + */ +import { Placeholder } from '@wordpress/components'; +import { withSelect } from '@wordpress/data'; +import { compose } from '@wordpress/compose'; + +/** + * Internal dependencies + */ +import { withBlockEditContext } from '../block-edit/context'; + +function PlaceholderInBlockContext( { isReadOnly, ...props } ) { + if ( isReadOnly ) { + return null; + } + + delete props.clientId; + + return ; +} + +export default compose( [ + withBlockEditContext( ( { clientId, isReadOnly } ) => ( { clientId, isReadOnly } ) ), + withSelect( ( select, { clientId, isReadOnly } ) => { + const { + getBlockRootClientId, + getTemplateLock, + } = select( 'core/block-editor' ); + const rootClientId = getBlockRootClientId( clientId ); + const templateLock = getTemplateLock( rootClientId ); + + return { + isReadOnly: isReadOnly === undefined ? templateLock === 'readonly' : isReadOnly, + }; + } ), +] )( PlaceholderInBlockContext ); diff --git a/packages/block-editor/src/components/plain-text/index.js b/packages/block-editor/src/components/plain-text/index.js index b05e48ed60b61c..c8abba7db825da 100644 --- a/packages/block-editor/src/components/plain-text/index.js +++ b/packages/block-editor/src/components/plain-text/index.js @@ -1,8 +1,9 @@ - /** * WordPress dependencies */ import { forwardRef } from '@wordpress/element'; +import { withSelect } from '@wordpress/data'; +import { compose } from '@wordpress/compose'; /** * External dependencies @@ -11,17 +12,37 @@ import TextareaAutosize from 'react-autosize-textarea'; import classnames from 'classnames'; /** - * @see https://github.com/WordPress/gutenberg/blob/master/packages/block-editor/src/components/plain-text/README.md + * Internal dependencies */ -const PlainText = forwardRef( ( { onChange, className, ...props }, ref ) => { +import { withBlockEditContext } from '../block-edit/context'; + +const PlainText = forwardRef( ( { onChange, className, isReadOnly, ...props }, ref ) => { + delete props.clientId; return ( onChange( event.target.value ) } + disabled={ isReadOnly } { ...props } /> ); } ); -export default PlainText; +/** + * @see https://github.com/WordPress/gutenberg/blob/master/packages/block-editor/src/components/plain-text/README.md + */ +export default compose( [ + withBlockEditContext( ( { clientId, isReadOnly } ) => ( { clientId, isReadOnly } ) ), + withSelect( ( select, { clientId, isReadOnly } ) => { + const { + getBlockRootClientId, + getTemplateLock, + } = select( 'core/block-editor' ); + const rootClientId = getBlockRootClientId( clientId ); + const templateLock = getTemplateLock( rootClientId ); + return { + isReadOnly: isReadOnly === undefined ? templateLock === 'readonly' : isReadOnly, + }; + } ), +] )( PlainText ); diff --git a/packages/block-editor/src/components/rich-text/index.js b/packages/block-editor/src/components/rich-text/index.js index 48c2cf817f74ac..0e4c6f19e33f60 100644 --- a/packages/block-editor/src/components/rich-text/index.js +++ b/packages/block-editor/src/components/rich-text/index.js @@ -255,6 +255,7 @@ class RichTextWraper extends Component { render() { const { + isReadOnly, tagName, value: originalValue, onChange: originalOnChange, @@ -307,6 +308,7 @@ class RichTextWraper extends Component { return ( ( { clientId } ) ), + withBlockEditContext( ( { clientId, isReadOnly } ) => ( { clientId, isReadOnly } ) ), withSelect( ( select, { clientId, instanceId, diff --git a/packages/block-editor/src/store/selectors.js b/packages/block-editor/src/store/selectors.js index 5e8771ff94935b..5472ff16639fe1 100644 --- a/packages/block-editor/src/store/selectors.js +++ b/packages/block-editor/src/store/selectors.js @@ -219,6 +219,47 @@ export const getClientIdsWithDescendants = createSelector( ] ); +/** + * Checks whether a block is an ancestor of a given block type based on its name. + * + * @param {Object} state Global application state. + * @param {string} clientId Block client ID. + * @param {string} blockTypeName The name of the block type, e.g.' core/paragraph'. + * + * @return {boolean} True when a block an ancestor of a given block type. + */ +export const isAncestorOfBlockTypeName = createSelector( + ( state, clientId, blockTypeName ) => { + return some( getBlockOrder( state, clientId ), ( innerClientId ) => { + return getBlockName( state, innerClientId ) === blockTypeName || + isAncestorOfBlockTypeName( state, innerClientId, blockTypeName ); + } ); + }, + ( state ) => [ + state.blocks.order, + ] +); + +/** + * Checks whether a block is an descendant of a given block type based on its name. + * + * @param {Object} state Global application state. + * @param {string} clientId Block client ID. + * @param {string} blockTypeName The name of the block type, e.g.' core/paragraph'. + * + * @return {boolean} True when a block an ancestor of a given block type. + */ +export function isDescendantOfBlockTypeName( state, clientId, blockTypeName ) { + let current = clientId; + do { + current = state.blocks.parents[ current ]; + if ( getBlockName( state, current ) === blockTypeName ) { + return true; + } + } while ( current ); + return false; +} + /** * Returns the total number of blocks, or the total number of blocks with a specific name in a post. * The number returned includes nested blocks. diff --git a/packages/block-library/src/classic/edit.js b/packages/block-library/src/classic/edit.js index 5b30ecb7f90692..978fa841d0a6e8 100644 --- a/packages/block-library/src/classic/edit.js +++ b/packages/block-library/src/classic/edit.js @@ -62,11 +62,12 @@ export default class ClassicEdit extends Component { } initialize() { - const { clientId } = this.props; + const { clientId, isReadOnly } = this.props; const { settings } = window.wpEditorL10n.tinymce; wp.oldEditor.initialize( `editor-${ clientId }`, { tinymce: { ...settings, + readonly: isReadOnly, inline: true, content_css: false, fixed_toolbar_container: `#toolbar-${ clientId }`, @@ -176,7 +177,7 @@ export default class ClassicEdit extends Component { } render() { - const { clientId } = this.props; + const { clientId, isReadOnly } = this.props; // Disable reasons: // @@ -190,7 +191,7 @@ export default class ClassicEdit extends Component { /* eslint-disable jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */ return [ -
    this.ref = ref } diff --git a/packages/block-library/src/cover/edit.js b/packages/block-library/src/cover/edit.js index 7de37e53f3fc39..6ba81ed2d96886 100644 --- a/packages/block-library/src/cover/edit.js +++ b/packages/block-library/src/cover/edit.js @@ -54,7 +54,6 @@ const INNER_BLOCKS_TEMPLATE = [ placeholder: __( 'Write title…' ), } ], ]; -const INNER_BLOCKS_ALLOWED_BLOCKS = [ 'core/button', 'core/heading', 'core/paragraph' ]; function retrieveFastAverageColor() { if ( ! retrieveFastAverageColor.fastAverageColor ) { @@ -299,7 +298,6 @@ class CoverEdit extends Component {
    diff --git a/packages/block-library/src/index.js b/packages/block-library/src/index.js index a9a9b78f34ca92..51c589178aec7f 100644 --- a/packages/block-library/src/index.js +++ b/packages/block-library/src/index.js @@ -50,6 +50,7 @@ import * as rss from './rss'; import * as search from './search'; import * as group from './group'; import * as separator from './separator'; +import * as siteTitle from './site-title'; import * as shortcode from './shortcode'; import * as spacer from './spacer'; import * as subhead from './subhead'; @@ -62,6 +63,13 @@ import * as tagCloud from './tag-cloud'; import * as classic from './classic'; +// Top-level template blocks. +import * as post from './post'; +import * as templatePart from './template-part'; +import * as postTitle from './post-title'; +import * as postContent from './post-content'; +import * as postDate from './post-date'; + /** * Function to register core blocks provided by the block editor. * @@ -115,6 +123,7 @@ export const registerCoreBlocks = () => { rss, search, separator, + siteTitle, reusableBlock, spacer, subhead, @@ -124,6 +133,13 @@ export const registerCoreBlocks = () => { textColumns, verse, video, + + // Register top-level template blocks. + post, + postTitle, + postContent, + postDate, + templatePart, ].forEach( ( block ) => { if ( ! block ) { return; diff --git a/packages/block-library/src/navigation-menu/block.json b/packages/block-library/src/navigation-menu/block.json index ee204cfeb890d4..45732afddd8ed2 100644 --- a/packages/block-library/src/navigation-menu/block.json +++ b/packages/block-library/src/navigation-menu/block.json @@ -1,6 +1,6 @@ { "name": "core/navigation-menu", - "category": "layout", + "category": "theme", "attributes": { "automaticallyAdd": { "type": "boolean", diff --git a/packages/block-library/src/navigation-menu/index.js b/packages/block-library/src/navigation-menu/index.js index 8ca8e0c3fe560d..d59e5eee2b7cb5 100644 --- a/packages/block-library/src/navigation-menu/index.js +++ b/packages/block-library/src/navigation-menu/index.js @@ -26,7 +26,6 @@ export const settings = { align: [ 'wide', 'full' ], anchor: true, html: false, - inserter: false, }, edit, diff --git a/packages/block-library/src/post-content/block.json b/packages/block-library/src/post-content/block.json new file mode 100644 index 00000000000000..dfa216457176e9 --- /dev/null +++ b/packages/block-library/src/post-content/block.json @@ -0,0 +1,7 @@ +{ + "name": "core/post-content", + "category": "theme", + "supports": { + "multiple": false + } +} diff --git a/packages/block-library/src/post-content/edit.js b/packages/block-library/src/post-content/edit.js new file mode 100644 index 00000000000000..ad0ab117664e59 --- /dev/null +++ b/packages/block-library/src/post-content/edit.js @@ -0,0 +1,29 @@ +/** + * WordPress dependencies + */ +import { InnerBlocks } from '@wordpress/block-editor'; +import { useSelect } from '@wordpress/data'; + +export default function PostContentEdit( { clientId } ) { + const allowedBlocks = useSelect( ( select ) => { + return select( 'core/blocks' ).getBlockTypes().filter( + ( { category } ) => category !== 'theme' + ).map( ( { name } ) => name ); + } ); + + const { hasInnerBlocks } = useSelect( ( select ) => { + const { getBlock } = select( 'core/block-editor' ); + const block = getBlock( clientId ); + + return { + hasInnerBlocks: !! ( block && block.innerBlocks.length ), + }; + }, [ clientId ] ); + + return ( + + ); +} diff --git a/packages/block-library/src/post-content/icon.js b/packages/block-library/src/post-content/icon.js new file mode 100644 index 00000000000000..ed7991dc791f85 --- /dev/null +++ b/packages/block-library/src/post-content/icon.js @@ -0,0 +1,8 @@ +/** + * WordPress dependencies + */ +import { Path, SVG } from '@wordpress/components'; + +export default ( + +); diff --git a/packages/block-library/src/post-content/index.js b/packages/block-library/src/post-content/index.js new file mode 100644 index 00000000000000..c6be63ad654552 --- /dev/null +++ b/packages/block-library/src/post-content/index.js @@ -0,0 +1,21 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import metadata from './block.json'; +import edit from './edit'; +import icon from './icon'; + +const { name } = metadata; + +export { metadata, name }; + +export const settings = { + title: __( 'Post Content' ), + edit, + icon, +}; diff --git a/packages/block-library/src/post-content/index.php b/packages/block-library/src/post-content/index.php new file mode 100644 index 00000000000000..d9410a7b4e45e2 --- /dev/null +++ b/packages/block-library/src/post-content/index.php @@ -0,0 +1,30 @@ +', ']]>', get_the_content() ) ); +} + +/** + * Registers the `core/post-content` block on the server. + */ +function register_block_core_post_content() { + register_block_type( + 'core/post-content', + array( + 'render_callback' => 'render_block_core_post_content', + ) + ); +} +add_action( 'init', 'register_block_core_post_content' ); diff --git a/packages/block-library/src/post-date/block.json b/packages/block-library/src/post-date/block.json new file mode 100644 index 00000000000000..e5cc72a25b83f4 --- /dev/null +++ b/packages/block-library/src/post-date/block.json @@ -0,0 +1,15 @@ +{ + "name": "core/post-date", + "category": "common", + "attributes": { + "date": { + "type": "string", + "source": "post", + "attribute": "date" + }, + "format": { + "type": "string", + "default": "date" + } + } +} diff --git a/packages/block-library/src/post-date/edit.js b/packages/block-library/src/post-date/edit.js new file mode 100644 index 00000000000000..7cbd515bce6378 --- /dev/null +++ b/packages/block-library/src/post-date/edit.js @@ -0,0 +1,39 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { dateI18n, format as formatDate } from '@wordpress/date'; +import { InspectorControls } from '@wordpress/block-editor'; +import { PanelBody, SelectControl } from '@wordpress/components'; + +const formats = { + date: 'd/m/Y', + datetime: 'd/m/Y g:i', +}; + +export default function PostDateEdit( { + attributes: { date, format }, + setAttributes, +} ) { + return <> + + + + setAttributes( { format: value } ) } + /> + + + + ; +} diff --git a/packages/block-library/src/post-date/icon.js b/packages/block-library/src/post-date/icon.js new file mode 100644 index 00000000000000..023a1902171d04 --- /dev/null +++ b/packages/block-library/src/post-date/icon.js @@ -0,0 +1,8 @@ +/** + * WordPress dependencies + */ +import { G, Path, SVG } from '@wordpress/components'; + +export default ( + +); diff --git a/packages/block-library/src/post-date/index.js b/packages/block-library/src/post-date/index.js new file mode 100644 index 00000000000000..3942a304486f19 --- /dev/null +++ b/packages/block-library/src/post-date/index.js @@ -0,0 +1,21 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import metadata from './block.json'; +import edit from './edit'; +import icon from './icon'; + +const { name } = metadata; + +export { metadata, name }; + +export const settings = { + title: __( 'Post Date' ), + edit, + icon, +}; diff --git a/packages/block-library/src/post-date/index.php b/packages/block-library/src/post-date/index.php new file mode 100644 index 00000000000000..31fd03c393dd32 --- /dev/null +++ b/packages/block-library/src/post-date/index.php @@ -0,0 +1,40 @@ + 'd/m/Y', + 'datetime' => 'd/m/Y g:i', + ); + rewind_posts(); + the_post(); + return ''; +} + +/** + * Registers the `core/post-date` block on the server. + */ +function register_block_core_post_date() { + register_block_type( + 'core/post-date', + array( + 'attributes' => array( + 'format' => array( + 'type' => 'string', + 'default' => 'date', + ), + ), + 'render_callback' => 'render_block_core_post_date', + ) + ); +} +add_action( 'init', 'register_block_core_post_date' ); diff --git a/packages/block-library/src/post-title/block.json b/packages/block-library/src/post-title/block.json new file mode 100644 index 00000000000000..c21df91929f729 --- /dev/null +++ b/packages/block-library/src/post-title/block.json @@ -0,0 +1,16 @@ +{ + "name": "core/post-title", + "category": "theme", + "attributes": { + "title": { + "type": "string", + "source": "post", + "attribute": "title" + }, + "slug": { + "type": "string", + "source": "post", + "attribute": "slug" + } + } +} diff --git a/packages/block-library/src/post-title/edit.js b/packages/block-library/src/post-title/edit.js new file mode 100644 index 00000000000000..bc7d5da6236003 --- /dev/null +++ b/packages/block-library/src/post-title/edit.js @@ -0,0 +1,21 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { cleanForSlug } from '@wordpress/editor'; +import { RichText } from '@wordpress/block-editor'; + +export default function PostTitleEdit( { + attributes: { title }, + setAttributes, +} ) { + return ( + setAttributes( { title: value, slug: cleanForSlug( value ) } ) } + tagName="h1" + placeholder={ __( 'Title' ) } + formattingControls={ [] } + /> + ); +} diff --git a/packages/block-library/src/post-title/icon.js b/packages/block-library/src/post-title/icon.js new file mode 100644 index 00000000000000..7350b484e42eb5 --- /dev/null +++ b/packages/block-library/src/post-title/icon.js @@ -0,0 +1,8 @@ +/** + * WordPress dependencies + */ +import { Path, SVG } from '@wordpress/components'; + +export default ( + +); diff --git a/packages/block-library/src/post-title/index.js b/packages/block-library/src/post-title/index.js new file mode 100644 index 00000000000000..082c84681145ff --- /dev/null +++ b/packages/block-library/src/post-title/index.js @@ -0,0 +1,21 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import metadata from './block.json'; +import edit from './edit'; +import icon from './icon'; + +const { name } = metadata; + +export { metadata, name }; + +export const settings = { + title: __( 'Post Title' ), + edit, + icon, +}; diff --git a/packages/block-library/src/post-title/index.php b/packages/block-library/src/post-title/index.php new file mode 100644 index 00000000000000..498d79408e2963 --- /dev/null +++ b/packages/block-library/src/post-title/index.php @@ -0,0 +1,30 @@ +', '', false ); +} + +/** + * Registers the `core/post-title` block on the server. + */ +function register_block_core_post_title() { + register_block_type( + 'core/post-title', + array( + 'render_callback' => 'render_block_core_post_title', + ) + ); +} +add_action( 'init', 'register_block_core_post_title' ); diff --git a/packages/block-library/src/post/block.json b/packages/block-library/src/post/block.json new file mode 100644 index 00000000000000..f7c7b52cba1855 --- /dev/null +++ b/packages/block-library/src/post/block.json @@ -0,0 +1,5 @@ +{ + "name": "core/post", + "category": "theme", + "attributes": {} +} diff --git a/packages/block-library/src/post/edit.js b/packages/block-library/src/post/edit.js new file mode 100644 index 00000000000000..f0b28bc228822e --- /dev/null +++ b/packages/block-library/src/post/edit.js @@ -0,0 +1,31 @@ +/** + * WordPress dependencies + */ +import { useSelect } from '@wordpress/data'; +import { InnerBlocks } from '@wordpress/block-editor'; + +function PostEdit( { + clientId, +} ) { + const { hasInnerBlocks, viewEditingMode } = useSelect( ( select ) => { + const { getBlock } = select( 'core/block-editor' ); + const { getViewEditingMode } = select( 'core/editor' ); + const block = getBlock( clientId ); + + return { + hasInnerBlocks: !! ( block && block.innerBlocks.length ), + viewEditingMode: getViewEditingMode(), + }; + }, [ clientId ] ); + + return ( + <> + + + ); +} + +export default PostEdit; diff --git a/packages/block-library/src/post/index.js b/packages/block-library/src/post/index.js new file mode 100644 index 00000000000000..7010e913dcb55f --- /dev/null +++ b/packages/block-library/src/post/index.js @@ -0,0 +1,25 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import edit from './edit'; +import metadata from './block.json'; +import save from './save'; + +const { name } = metadata; + +export { metadata, name }; + +export const settings = { + title: __( 'Post' ), + description: __( 'A Post Container block.' ), + supports: { + html: false, + }, + edit, + save, +}; diff --git a/packages/block-library/src/post/save.js b/packages/block-library/src/post/save.js new file mode 100644 index 00000000000000..17571d8f30d2de --- /dev/null +++ b/packages/block-library/src/post/save.js @@ -0,0 +1,8 @@ +/** + * WordPress dependencies + */ +import { InnerBlocks } from '@wordpress/block-editor'; + +export default function save() { + return ; +} diff --git a/packages/block-library/src/site-title/block.json b/packages/block-library/src/site-title/block.json new file mode 100644 index 00000000000000..910f9ac78634bb --- /dev/null +++ b/packages/block-library/src/site-title/block.json @@ -0,0 +1,17 @@ +{ + "name": "core/site-title", + "category": "theme", + "attributes": { + "title": { + "type": "string", + "source": "option", + "option": "title" + }, + "textColor": { + "type": "string" + }, + "customTextColor": { + "type": "string" + } + } +} diff --git a/packages/block-library/src/site-title/edit.js b/packages/block-library/src/site-title/edit.js new file mode 100644 index 00000000000000..c90bc60bd82ee9 --- /dev/null +++ b/packages/block-library/src/site-title/edit.js @@ -0,0 +1,77 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { compose } from '@wordpress/compose'; +import { + InspectorControls, + withColors, + PanelColorSettings, + RichText, +} from '@wordpress/block-editor'; +import { memo } from '@wordpress/element'; + +const SiteTitleColorUI = memo( + function( { + textColorValue, + setTextColor, + } ) { + return ( + + ); + } +); + +function SiteTitleEdit( { + attributes, + setAttributes, + textColor, + setTextColor, +} ) { + const { title } = attributes; + + return ( + <> + + + + setAttributes( { title: newTitle } ) } + placeholder={ __( 'Site Title' ) } + aria-label={ __( 'Site Title' ) } + wrapperClassName={ classnames( 'wp-block-site-title', { + 'has-text-color': textColor.color, + [ textColor.class ]: textColor.class, + } ) } + style={ { + color: textColor.color, + fontSize: '28px', + } } + /> + + ); +} + +export default compose( [ + withColors( 'backgroundColor', { textColor: 'color' } ), +] )( SiteTitleEdit ); diff --git a/packages/block-library/src/site-title/icon.js b/packages/block-library/src/site-title/icon.js new file mode 100644 index 00000000000000..33d3d6bc809839 --- /dev/null +++ b/packages/block-library/src/site-title/icon.js @@ -0,0 +1,8 @@ +/** + * WordPress dependencies + */ +import { Circle, Path, SVG } from '@wordpress/components'; + +export default ( + +); diff --git a/packages/block-library/src/site-title/index.js b/packages/block-library/src/site-title/index.js new file mode 100644 index 00000000000000..570da787ad4d64 --- /dev/null +++ b/packages/block-library/src/site-title/index.js @@ -0,0 +1,22 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import edit from './edit'; +import metadata from './block.json'; +import icon from './icon'; + +const { name } = metadata; + +export { metadata, name }; + +export const settings = { + title: __( 'Site Title' ), + description: __( 'The name of the site visitors will see.' ), + edit, + icon, +}; diff --git a/packages/block-library/src/site-title/index.php b/packages/block-library/src/site-title/index.php new file mode 100644 index 00000000000000..9e3de58637e69c --- /dev/null +++ b/packages/block-library/src/site-title/index.php @@ -0,0 +1,49 @@ +%s', $classes, $style_fragment, get_bloginfo( 'name' ) ); +} + +/** + * Registers the `core/site-title` block on server. + */ +function register_block_core_site_title() { + register_block_type( + 'core/site-title', + array( + 'attributes' => array( + 'textColor' => array( + 'type' => 'string', + ), + 'customTextColor' => array( + 'type' => 'string', + ), + ), + 'render_callback' => 'render_block_core_site_title', + ) + ); +} +add_action( 'init', 'register_block_core_site_title' ); diff --git a/packages/block-library/src/template-part/block.json b/packages/block-library/src/template-part/block.json new file mode 100644 index 00000000000000..449ac670ebfbab --- /dev/null +++ b/packages/block-library/src/template-part/block.json @@ -0,0 +1,15 @@ +{ + "name": "core/template-part", + "category": "theme", + "attributes": { + "id": { + "type": "number" + }, + "name": { + "type": "string" + } + }, + "supports": { + "align": ["full"] + } +} diff --git a/packages/block-library/src/template-part/edit.js b/packages/block-library/src/template-part/edit.js new file mode 100644 index 00000000000000..1786dad097dd4b --- /dev/null +++ b/packages/block-library/src/template-part/edit.js @@ -0,0 +1,71 @@ +/** + * External dependencies + */ +import { get } from 'lodash'; + +/** + * WordPress dependencies + */ +import { InnerBlocks, InspectorControls } from '@wordpress/block-editor'; +import { useSelect, useDispatch } from '@wordpress/data'; +import { useEffect, useMemo } from '@wordpress/element'; +import { parse, serialize } from '@wordpress/blocks'; +import { TextControl } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; + +export default function TemplatePartEdit( { attributes, clientId, setAttributes } ) { + const { id } = attributes; + + const { + rawTemplatePartContent, + newBlocks, + templatePartTitle, + hasInnerBlocks, + } = useSelect( ( select ) => { + const template = id && select( 'core' ).getEntityRecord( 'postType', 'wp_template', id ); + const blocks = select( 'core/block-editor' ).getBlocks( clientId ); + return { + rawTemplatePartContent: get( template, [ 'content', 'raw' ] ), + newBlocks: blocks, + templatePartTitle: get( template, [ 'title', 'raw' ] ), + hasInnerBlocks: blocks && blocks.length > 0, + }; + }, [ id, clientId ] ); + const { replaceInnerBlocks } = useDispatch( 'core/block-editor' ); + useEffect( + () => { + if ( ! rawTemplatePartContent || hasInnerBlocks ) { + return; + } + replaceInnerBlocks( clientId, parse( rawTemplatePartContent ), false ); + }, + [ rawTemplatePartContent, replaceInnerBlocks ] + ); + useEffect( + () => { + setAttributes( { name: templatePartTitle } ); + }, + [ templatePartTitle ] + ); + const newRawTemplatePartContent = useMemo( () => ( serialize( newBlocks ) ), [ newBlocks ] ); + const DEBUG = false; + const innerBlocks = ( + + ); + return ( +
    + + ( setAttributes( { name: value } ) ) } + /> + + { DEBUG && (
    { newRawTemplatePartContent === rawTemplatePartContent ? 'IS NOT DIRTY' : 'IS DIRTY' }
    ) } + { innerBlocks } +
    + ); +} diff --git a/packages/block-library/src/template-part/index.js b/packages/block-library/src/template-part/index.js new file mode 100644 index 00000000000000..31ef579d543e29 --- /dev/null +++ b/packages/block-library/src/template-part/index.js @@ -0,0 +1,19 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import metadata from './block.json'; +import edit from './edit'; + +const { name } = metadata; + +export { metadata, name }; + +export const settings = { + title: __( 'Template Part' ), + edit, +}; diff --git a/packages/block-library/src/template-part/index.php b/packages/block-library/src/template-part/index.php new file mode 100644 index 00000000000000..3eac645e130572 --- /dev/null +++ b/packages/block-library/src/template-part/index.php @@ -0,0 +1,36 @@ +post_content; + } + } + + return apply_filters( 'the_content', $content_string ); +} + +/** + * Registers the `core/template-parts` block on the server. + */ +function register_block_core_template_part() { + register_block_type( + 'core/template-part', + array( + 'render_callback' => 'render_block_core_template_part', + ) + ); +} +add_action( 'init', 'register_block_core_template_part' ); diff --git a/packages/blocks/src/store/reducer.js b/packages/blocks/src/store/reducer.js index 4e3131ca7b62e0..1f489f70a4e513 100644 --- a/packages/blocks/src/store/reducer.js +++ b/packages/blocks/src/store/reducer.js @@ -28,6 +28,7 @@ export const DEFAULT_CATEGORIES = [ { slug: 'layout', title: __( 'Layout Elements' ) }, { slug: 'widgets', title: __( 'Widgets' ) }, { slug: 'embed', title: __( 'Embeds' ) }, + { slug: 'theme', title: __( 'Theme Blocks' ) }, { slug: 'reusable', title: __( 'Reusable Blocks' ) }, ]; diff --git a/packages/core-data/README.md b/packages/core-data/README.md index a2fb271017fcd7..eaa3c2c3846cb8 100644 --- a/packages/core-data/README.md +++ b/packages/core-data/README.md @@ -110,6 +110,18 @@ _Returns_ - `Object`: Action object. +# **receiveSiteOptions** + +Returns an action object used in signalling that site options have been received. + +_Parameters_ + +- _siteOptions_ `Object`: Site options. + +_Returns_ + +- `Object`: Action object. + # **receiveThemeSupports** Returns an action object used in signalling that the index has been received. @@ -175,6 +187,30 @@ _Returns_ - `Object`: Updated record. +# **saveSiteOptions** + +Action triggered to save site options. + +_Parameters_ + +- _siteOptions_ `Object`: Site options. + +_Returns_ + +- `Object`: Updated site options. + +# **updateSiteOptions** + +Returns an action object used in signalling that site options have been locally updated. + +_Parameters_ + +- _siteOptions_ `Object`: Site options. + +_Returns_ + +- `Object`: Action object. + ## Selectors @@ -330,6 +366,22 @@ _Returns_ - `Array`: Records. +# **getSiteOptions** + +Return site options as they exist locally. + +_Related_ + +- isSiteOptionsDirty + +_Parameters_ + +- _state_ `Object`: Data state. + +_Returns_ + +- `Object`: Site options. + # **getThemeSupports** Return theme supports data in the index. @@ -419,6 +471,19 @@ _Returns_ - `boolean`: Whether a request is in progress for an embed preview. +# **isSiteOptionsDirty** + +Return whether the client has local changes to site options which haven't +yet been saved to the server. + +_Parameters_ + +- _state_ `Object`: Data state. + +_Returns_ + +- `boolean`: Whether or not the local site options state is dirty. + diff --git a/packages/core-data/src/actions.js b/packages/core-data/src/actions.js index f9c7940d09db53..da10158fc523a1 100644 --- a/packages/core-data/src/actions.js +++ b/packages/core-data/src/actions.js @@ -98,6 +98,51 @@ export function receiveThemeSupports( themeSupports ) { }; } +/** + * Returns an action object used in signalling that site options have been received. + * + * @param {Object} siteOptions Site options. + * + * @return {Object} Action object. + */ +export function receiveSiteOptions( siteOptions ) { + return { + type: 'RECEIVE_SITE_OPTIONS', + siteOptions, + }; +} + +/** + * Returns an action object used in signalling that site options have been locally updated. + * + * @param {Object} siteOptions Site options. + * + * @return {Object} Action object. + */ +export function updateSiteOptions( siteOptions ) { + return { + type: 'UPDATE_SITE_OPTIONS', + siteOptions, + }; +} + +/** + * Action triggered to save site options. + * + * @param {Object} siteOptions Site options. + * + * @return {Object} Updated site options. + */ +export function* saveSiteOptions( siteOptions ) { + const updatedOptions = yield apiFetch( { + path: '/wp/v2/settings', + method: 'POST', + data: siteOptions, + } ); + yield receiveSiteOptions( updatedOptions ); + return updatedOptions; +} + /** * Returns an action object used in signalling that the preview data for * a given URl has been received. diff --git a/packages/core-data/src/reducer.js b/packages/core-data/src/reducer.js index 272ab20e2693ec..73b6d09b98e0fc 100644 --- a/packages/core-data/src/reducer.js +++ b/packages/core-data/src/reducer.js @@ -118,6 +118,42 @@ export function themeSupports( state = {}, action ) { return state; } +export const siteOptions = combineReducers( { + remote( state = {}, action ) { + switch ( action.type ) { + case 'RECEIVE_SITE_OPTIONS': + return { + ...state, + ...action.siteOptions, + }; + } + + return state; + }, + local( state = {}, action ) { + switch ( action.type ) { + case 'RECEIVE_SITE_OPTIONS': + case 'UPDATE_SITE_OPTIONS': + return { + ...state, + ...action.siteOptions, + }; + } + + return state; + }, + isDirty( state = false, action ) { + switch ( action.type ) { + case 'RECEIVE_SITE_OPTIONS': + return false; + case 'UPDATE_SITE_OPTIONS': + return true; + } + + return state; + }, +} ); + /** * Higher Order Reducer for a given entity config. It supports: * @@ -283,6 +319,7 @@ export default combineReducers( { currentUser, taxonomies, themeSupports, + siteOptions, entities, embedPreviews, userPermissions, diff --git a/packages/core-data/src/resolvers.js b/packages/core-data/src/resolvers.js index 8edfdbf895cded..e8cbe6e7be4b94 100644 --- a/packages/core-data/src/resolvers.js +++ b/packages/core-data/src/resolvers.js @@ -17,6 +17,7 @@ import { receiveCurrentUser, receiveEntityRecords, receiveThemeSupports, + receiveSiteOptions, receiveEmbedPreview, receiveUserPermission, receiveAutosaves, @@ -95,6 +96,11 @@ export function* getThemeSupports() { yield receiveThemeSupports( activeThemes[ 0 ].theme_supports ); } +export function *getSiteOptions() { + const siteOptions = yield apiFetch( { path: '/wp/v2/settings' } ); + yield receiveSiteOptions( siteOptions ); +} + /** * Requests a preview from the from the Embed API. * diff --git a/packages/core-data/src/selectors.js b/packages/core-data/src/selectors.js index 42e4a1f23d982d..de2d3022f921d8 100644 --- a/packages/core-data/src/selectors.js +++ b/packages/core-data/src/selectors.js @@ -136,6 +136,31 @@ export function getThemeSupports( state ) { return state.themeSupports; } +/** + * Return site options as they exist locally. + * + * @see isSiteOptionsDirty + * + * @param {Object} state Data state. + * + * @return {Object} Site options. + */ +export function getSiteOptions( state ) { + return state.siteOptions.local; +} + +/** + * Return whether the client has local changes to site options which haven't + * yet been saved to the server. + * + * @param {Object} state Data state. + * + * @return {boolean} Whether or not the local site options state is dirty. + */ +export function isSiteOptionsDirty( state ) { + return state.siteOptions.isDirty; +} + /** * Returns the embed preview for the given URL. * diff --git a/packages/core-data/src/test/reducer.js b/packages/core-data/src/test/reducer.js index ec8349d0a65d36..6154c6bc6ab623 100644 --- a/packages/core-data/src/test/reducer.js +++ b/packages/core-data/src/test/reducer.js @@ -7,7 +7,7 @@ import { filter } from 'lodash'; /** * Internal dependencies */ -import { terms, entities, embedPreviews, userPermissions, autosaves, currentUser } from '../reducer'; +import { terms, entities, embedPreviews, userPermissions, autosaves, currentUser, siteOptions } from '../reducer'; describe( 'terms()', () => { it( 'returns an empty object by default', () => { @@ -254,3 +254,54 @@ describe( 'currentUser', () => { expect( state ).toEqual( currentUserData ); } ); } ); + +describe( 'siteOptions', () => { + it( 'receives server data into both `remote` and `local`', () => { + const data = { soup: 'veggie' }; + const state = siteOptions( {}, { + type: 'RECEIVE_SITE_OPTIONS', + siteOptions: data, + } ); + + expect( state ).toEqual( { + remote: data, + local: data, + isDirty: false, + } ); + } ); + + it( 'receives client changes only into `local` and sets the dirty bit', () => { + const data = { soup: 'veggie', dessert: 'mousse' }; + const cleanState = { + remote: data, + local: data, + isDirty: false, + }; + + expect( siteOptions( cleanState, { + type: 'UPDATE_SITE_OPTIONS', + siteOptions: { soup: 'fish' }, + } ) ).toEqual( { + remote: data, + local: { soup: 'fish', dessert: 'mousse' }, + isDirty: true, + } ); + } ); + + it( 'clears dirty state upon receiving server data', () => { + const dirtyState = { + remote: { soup: 'veggie' }, + local: { soup: 'fish' }, + isDirty: true, + }; + + expect( siteOptions( dirtyState, { + type: 'RECEIVE_SITE_OPTIONS', + siteOptions: { soup: 'tomato' }, + } ) ).toEqual( { + remote: { soup: 'tomato' }, + local: { soup: 'tomato' }, + isDirty: false, + } ); + } ); +} ); diff --git a/packages/e2e-tests/plugins/cpt-locking.php b/packages/e2e-tests/plugins/cpt-locking.php index ba8a8d611c428c..3ebec40da541b4 100644 --- a/packages/e2e-tests/plugins/cpt-locking.php +++ b/packages/e2e-tests/plugins/cpt-locking.php @@ -21,6 +21,16 @@ function gutenberg_test_cpt_locking() { ), array( 'core/quote' ), ); + register_post_type( + 'locked-readonly-post', + array( + 'public' => true, + 'label' => 'Locked Readonly Post', + 'show_in_rest' => true, + 'template' => $template, + 'template_lock' => 'readonly', + ) + ); register_post_type( 'locked-all-post', array( diff --git a/packages/e2e-tests/specs/plugins/__snapshots__/cpt-locking.test.js.snap b/packages/e2e-tests/specs/plugins/__snapshots__/cpt-locking.test.js.snap index 24166c89f310b3..943253ab4e1b4d 100644 --- a/packages/e2e-tests/specs/plugins/__snapshots__/cpt-locking.test.js.snap +++ b/packages/e2e-tests/specs/plugins/__snapshots__/cpt-locking.test.js.snap @@ -69,3 +69,31 @@ exports[`cpt locking template_lock insert should allow blocks to be moved 1`] =

    " `; + +exports[`cpt locking template_lock readonly should only allow block selection 1`] = ` +" +
    \\"\\"/
    + + + +

    + + + +

    +" +`; + +exports[`cpt locking template_lock readonly should only allow block selection 2`] = ` +" +
    \\"\\"/
    + + + +

    + + + +

    +" +`; diff --git a/packages/e2e-tests/specs/plugins/cpt-locking.test.js b/packages/e2e-tests/specs/plugins/cpt-locking.test.js index 74f8f81bf67e3f..f97b23ad9b55a9 100644 --- a/packages/e2e-tests/specs/plugins/cpt-locking.test.js +++ b/packages/e2e-tests/specs/plugins/cpt-locking.test.js @@ -45,6 +45,38 @@ describe( 'cpt locking', () => { expect( await getEditedPostContent() ).toMatchSnapshot(); }; + describe( 'template_lock readonly', () => { + beforeEach( async () => { + await createNewPost( { postType: 'locked-readonly-post' } ); + } ); + + it( 'should remove the inserter', shouldRemoveTheInserter ); + + it( 'should only allow block selection', async () => { + // Expect the title to be selected. + expect( await page.evaluate( () => { + return document.activeElement.matches( 'textarea' ); + } ) ).toBe( true ); + + await page.keyboard.press( 'Enter' ); + + // Expect no content to be created. + expect( await getEditedPostContent() ).toMatchSnapshot(); + + await page.keyboard.press( 'Tab' ); + await page.keyboard.press( 'Tab' ); + + // Expect the second block to be selected. + expect( await page.evaluate( () => { + return document.activeElement.matches( '[data-type="core/paragraph"]' ); + } ) ).toBe( true ); + + await page.keyboard.press( 'Backspace' ); + + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + } ); + describe( 'template_lock all', () => { beforeEach( async () => { await createNewPost( { postType: 'locked-all-post' } ); diff --git a/packages/edit-post/src/components/header/index.js b/packages/edit-post/src/components/header/index.js index dff5622593603a..40b0ab21a40706 100644 --- a/packages/edit-post/src/components/header/index.js +++ b/packages/edit-post/src/components/header/index.js @@ -4,8 +4,7 @@ import { __ } from '@wordpress/i18n'; import { IconButton } from '@wordpress/components'; import { - PostPreviewButton, - PostSavedState, + ViewEditingModePicker, } from '@wordpress/editor'; import { withDispatch, withSelect } from '@wordpress/data'; import { compose } from '@wordpress/compose'; @@ -18,15 +17,12 @@ import FullscreenModeClose from './fullscreen-mode-close'; import HeaderToolbar from './header-toolbar'; import MoreMenu from './more-menu'; import PinnedPlugins from './pinned-plugins'; -import PostPublishButtonOrToggle from './post-publish-button-or-toggle'; +import PublishControls from './publish-controls'; import shortcuts from '../../keyboard-shortcuts'; function Header( { closeGeneralSidebar, - hasActiveMetaboxes, isEditorSidebarOpened, - isPublishSidebarOpened, - isSaving, openGeneralSidebar, } ) { const toggleGeneralSidebar = isEditorSidebarOpened ? closeGeneralSidebar : openGeneralSidebar; @@ -43,26 +39,9 @@ function Header( { +
    - { ! isPublishSidebarOpened && ( - // This button isn't completely hidden by the publish sidebar. - // We can't hide the whole toolbar when the publish sidebar is open because - // we want to prevent mounting/unmounting the PostPublishButtonOrToggle DOM node. - // We track that DOM node to return focus to the PostPublishButtonOrToggle - // when the publish sidebar has been closed. - - ) } - - +
    ( { - hasActiveMetaboxes: select( 'core/edit-post' ).hasMetaBoxes(), isEditorSidebarOpened: select( 'core/edit-post' ).isEditorSidebarOpened(), - isPublishSidebarOpened: select( 'core/edit-post' ).isPublishSidebarOpened(), - isSaving: select( 'core/edit-post' ).isSavingMetaBoxes(), } ) ), withDispatch( ( dispatch, ownProps, { select } ) => { const { getBlockSelectionStart } = select( 'core/block-editor' ); diff --git a/packages/edit-post/src/components/header/publish-controls/index.js b/packages/edit-post/src/components/header/publish-controls/index.js new file mode 100644 index 00000000000000..6b7ec3d361e163 --- /dev/null +++ b/packages/edit-post/src/components/header/publish-controls/index.js @@ -0,0 +1,48 @@ +/** + * WordPress dependencies + */ +import { + withSelect, +} from '@wordpress/data'; +import { + PostPreviewButton, + PostSavedState, +} from '@wordpress/editor'; + +/** + * Internal dependencies + */ +import PostPublishButtonOrToggle from './post-publish-button-or-toggle'; + +const PublishControls = ( { hasActiveMetaboxes, isPublishSidebarOpened, isSaving } ) => { + return ( + <> + { ! isPublishSidebarOpened && ( + // This button isn't completely hidden by the publish sidebar. + // We can't hide the whole toolbar when the publish sidebar is open because + // we want to prevent mounting/unmounting the PostPublishButtonOrToggle DOM node. + // We track that DOM node to return focus to the PostPublishButtonOrToggle + // when the publish sidebar has been closed. + + ) } + + + + ); +}; + +export default withSelect( ( select ) => ( { + hasActiveMetaboxes: select( 'core/edit-post' ).hasMetaBoxes(), + isPublishSidebarOpened: select( 'core/edit-post' ).isPublishSidebarOpened(), + isSaving: select( 'core/edit-post' ).isSavingMetaBoxes(), +} ) )( PublishControls ); + diff --git a/packages/edit-post/src/components/header/post-publish-button-or-toggle.js b/packages/edit-post/src/components/header/publish-controls/post-publish-button-or-toggle.js similarity index 100% rename from packages/edit-post/src/components/header/post-publish-button-or-toggle.js rename to packages/edit-post/src/components/header/publish-controls/post-publish-button-or-toggle.js diff --git a/packages/edit-post/src/components/header/test/__snapshots__/index.js.snap b/packages/edit-post/src/components/header/publish-controls/test/__snapshots__/post-publish-button-or-toggle.js.snap similarity index 100% rename from packages/edit-post/src/components/header/test/__snapshots__/index.js.snap rename to packages/edit-post/src/components/header/publish-controls/test/__snapshots__/post-publish-button-or-toggle.js.snap diff --git a/packages/edit-post/src/components/header/test/index.js b/packages/edit-post/src/components/header/publish-controls/test/post-publish-button-or-toggle.js similarity index 100% rename from packages/edit-post/src/components/header/test/index.js rename to packages/edit-post/src/components/header/publish-controls/test/post-publish-button-or-toggle.js diff --git a/packages/edit-post/src/components/layout/index.js b/packages/edit-post/src/components/layout/index.js index ee2b55bbed5d24..3fcbdeda759005 100644 --- a/packages/edit-post/src/components/layout/index.js +++ b/packages/edit-post/src/components/layout/index.js @@ -45,7 +45,7 @@ import PluginPrePublishPanel from '../sidebar/plugin-pre-publish-panel'; import FullscreenMode from '../fullscreen-mode'; function Layout( { - mode, + contentEditingMode, editorSidebarOpened, pluginSidebarOpened, publishSidebarOpened, @@ -56,10 +56,11 @@ function Layout( { isSaving, isMobileViewport, isRichEditingEnabled, + viewEditingMode, } ) { const sidebarIsOpened = editorSidebarOpened || pluginSidebarOpened || publishSidebarOpened; - const className = classnames( 'edit-post-layout', { + const className = classnames( 'edit-post-layout', `is-mode-${ viewEditingMode }`, { 'is-sidebar-opened': sidebarIsOpened, 'has-fixed-toolbar': hasFixedToolbar, } ); @@ -90,8 +91,10 @@ function Layout( { - { ( mode === 'text' || ! isRichEditingEnabled ) && } - { isRichEditingEnabled && mode === 'visual' && } + { ( contentEditingMode === 'text' || ! isRichEditingEnabled ) && } + { isRichEditingEnabled && contentEditingMode === 'visual' && ( + + ) }
    @@ -135,16 +138,21 @@ function Layout( { } export default compose( - withSelect( ( select ) => ( { - mode: select( 'core/edit-post' ).getEditorMode(), - editorSidebarOpened: select( 'core/edit-post' ).isEditorSidebarOpened(), - pluginSidebarOpened: select( 'core/edit-post' ).isPluginSidebarOpened(), - publishSidebarOpened: select( 'core/edit-post' ).isPublishSidebarOpened(), - hasFixedToolbar: select( 'core/edit-post' ).isFeatureActive( 'fixedToolbar' ), - hasActiveMetaboxes: select( 'core/edit-post' ).hasMetaBoxes(), - isSaving: select( 'core/edit-post' ).isSavingMetaBoxes(), - isRichEditingEnabled: select( 'core/editor' ).getEditorSettings().richEditingEnabled, - } ) ), + withSelect( ( select ) => { + const { getEditorSettings, getViewEditingMode } = select( 'core/editor' ); + const editorSettings = getEditorSettings(); + return { + contentEditingMode: select( 'core/edit-post' ).getEditorMode(), + editorSidebarOpened: select( 'core/edit-post' ).isEditorSidebarOpened(), + pluginSidebarOpened: select( 'core/edit-post' ).isPluginSidebarOpened(), + publishSidebarOpened: select( 'core/edit-post' ).isPublishSidebarOpened(), + hasFixedToolbar: select( 'core/edit-post' ).isFeatureActive( 'fixedToolbar' ), + hasActiveMetaboxes: select( 'core/edit-post' ).hasMetaBoxes(), + isSaving: select( 'core/edit-post' ).isSavingMetaBoxes(), + isRichEditingEnabled: editorSettings.richEditingEnabled, + viewEditingMode: getViewEditingMode(), + }; + } ), withDispatch( ( dispatch ) => { const { closePublishSidebar, togglePublishSidebar } = dispatch( 'core/edit-post' ); return { diff --git a/packages/edit-post/src/components/layout/style.scss b/packages/edit-post/src/components/layout/style.scss index 66307b74c0101c..0545feb906d202 100644 --- a/packages/edit-post/src/components/layout/style.scss +++ b/packages/edit-post/src/components/layout/style.scss @@ -232,3 +232,70 @@ } } } + +/* Settings */ +:root { + --color: hsla(204, 80%, 72%, 0.15); + /* The following is hard-coded for `gutenberg-theme` */ + --background-columns: linear-gradient(to right, #fff, #fff 15px, var(--color) 16px, var(--color) 200px, #fff 200px, #fff 215px, var(--color) 215px, var(--color) 985px, #fff 985px, #fff 1000px, var(--color) 1000px, var(--color) 1184px, #fff 1184px, #fff 1200px, var(--color) 1200px); +} + +.edit-post-layout.is-mode-design { + background-color: var(--color); + background-image: var(--background-columns); + background-size: 1200px 100%; + background-repeat: no-repeat; + background-position-x: center; + z-index: 1000; + + .block-editor-block-list__block { + background-color: rgba(200, 200, 200, 0.2); + } + + .editor-rich-text { + background: #ccc !important; + } +} + +.is-mode-template-part { + background: $light-gray-300; + + & > div > div:first-child { + background: $white; + border: 1px solid $light-gray-500; + margin: 0 200px; + } +} + +.is-mode-sidebar { + .is-mode-template-part > div > div:first-child { + width: 500px; + margin: 0 auto 100px; + } +} + +.is-mode-footer { + .is-mode-template-part > div > div:first-child { + width: 1300px; + margin: 0 auto 100px; + } +} + + +.is-mode-focus .block-editor-block-list__block { + opacity: 0.2; +} + +.is-mode-focus .is-ancestor-of-post-block, +.is-mode-focus .is-post-block, +.is-mode-focus .is-post-block .block-editor-block-list__block { + opacity: 1; +} + +.is-mode-template .is-post-block { + background-color: $light-gray-100; + + .block-editor-block-list__block { + opacity: 0.5; + } +} diff --git a/packages/edit-post/src/components/visual-editor/index.js b/packages/edit-post/src/components/visual-editor/index.js index 8287f5c27396d5..bfa183488ba9f4 100644 --- a/packages/edit-post/src/components/visual-editor/index.js +++ b/packages/edit-post/src/components/visual-editor/index.js @@ -1,9 +1,18 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + /** * WordPress dependencies */ +import { + withSelect, +} from '@wordpress/data'; import { PostTitle, VisualEditorGlobalKeyboardShortcuts, + getModeConfig, } from '@wordpress/editor'; import { WritingFlow, @@ -22,15 +31,19 @@ import { import BlockInspectorButton from './block-inspector-button'; import PluginBlockSettingsMenuGroup from '../block-settings-menu/plugin-block-settings-menu-group'; -function VisualEditor() { +function VisualEditor( { viewEditingMode } ) { return ( - + - + { ! viewEditingMode.showTemplate && } @@ -45,4 +58,11 @@ function VisualEditor() { ); } -export default VisualEditor; +export default withSelect( ( select ) => { + const { getViewEditingMode, getEditorSettings } = select( 'core/editor' ); + const viewEditingMode = getViewEditingMode(); + const templateParts = getEditorSettings().templateParts; + return { + viewEditingMode: getModeConfig( viewEditingMode, templateParts ), + }; +} )( VisualEditor ); diff --git a/packages/editor/src/components/index.js b/packages/editor/src/components/index.js index f8999c7868042f..eace551f397c68 100644 --- a/packages/editor/src/components/index.js +++ b/packages/editor/src/components/index.js @@ -57,6 +57,7 @@ export { default as PostVisibilityCheck } from './post-visibility/check'; export { default as TableOfContents } from './table-of-contents'; export { default as UnsavedChangesWarning } from './unsaved-changes-warning'; export { default as WordCount } from './word-count'; +export { default as ViewEditingModePicker } from './view-editing-mode-picker'; // State Related Components export { default as EditorProvider } from './provider'; diff --git a/packages/editor/src/components/post-saved-state/index.js b/packages/editor/src/components/post-saved-state/index.js index 26716757bc5886..bb93f8217cb249 100644 --- a/packages/editor/src/components/post-saved-state/index.js +++ b/packages/editor/src/components/post-saved-state/index.js @@ -20,6 +20,11 @@ import { withViewportMatch } from '@wordpress/viewport'; */ import PostSwitchToDraftButton from '../post-switch-to-draft-button'; +/** + * Internal dependencies + */ +import { getModeConfig } from '../../editor-modes'; + /** * Component showing whether the post is saved or not and displaying save links. * @@ -55,6 +60,8 @@ export class PostSavedState extends Component { isAutosaving, isPending, isLargeViewport, + viewEditingMode, + templateParts, } = this.props; const { forceSavedMessage } = this.state; if ( isSaving ) { @@ -97,7 +104,13 @@ export class PostSavedState extends Component { return null; } - const label = isPending ? __( 'Save as Pending' ) : __( 'Save Draft' ); + let label = isPending ? __( 'Save Draft as Pending' ) : __( 'Save Draft' ); + const viewEditingModeObject = getModeConfig( viewEditingMode, templateParts ); + if ( viewEditingModeObject.showTemplate ) { + label = viewEditingModeObject.id ? + `Save ${ viewEditingModeObject.label }` : + `${ label } & Template`; + } if ( ! isLargeViewport ) { return ( ( { diff --git a/packages/editor/src/components/provider/index.js b/packages/editor/src/components/provider/index.js index cfa67ddbb615d7..2a567090e6c89a 100644 --- a/packages/editor/src/components/provider/index.js +++ b/packages/editor/src/components/provider/index.js @@ -23,6 +23,7 @@ import withRegistryProvider from './with-registry-provider'; import { mediaUpload } from '../../utils'; import ReusableBlocksButtons from '../reusable-blocks-buttons'; import ConvertToGroupButtons from '../convert-to-group-buttons'; +import { getModeConfig } from '../../editor-modes'; const fetchLinkSuggestions = async ( search ) => { const posts = await apiFetch( { @@ -53,8 +54,15 @@ class EditorProvider extends Component { return; } + const viewEditingMode = getModeConfig( props.viewEditingMode ); + props.updateViewEditingMode( viewEditingMode.value ); props.updatePostLock( props.settings.postLock ); - props.setupEditor( props.post, props.initialEdits, props.settings.template ); + props.setupEditor( + props.post, + props.initialEdits, + props.settings.template, + viewEditingMode.showTemplate && props.settings.templatePost + ); if ( props.settings.autosave ) { props.createWarningNotice( @@ -183,6 +191,7 @@ export default compose( [ __unstableIsEditorReady: isEditorReady, getEditorBlocks, __experimentalGetReusableBlocks, + getViewEditingMode, } = select( 'core/editor' ); const { canUser } = select( 'core' ); @@ -192,10 +201,12 @@ export default compose( [ blocks: getEditorBlocks(), reusableBlocks: __experimentalGetReusableBlocks(), hasUploadPermissions: defaultTo( canUser( 'create', 'media' ), true ), + viewEditingMode: getViewEditingMode(), }; } ), withDispatch( ( dispatch ) => { const { + updateViewEditingMode, setupEditor, updatePostLock, resetEditorBlocks, @@ -205,6 +216,7 @@ export default compose( [ const { createWarningNotice } = dispatch( 'core/notices' ); return { + updateViewEditingMode, setupEditor, updatePostLock, createWarningNotice, diff --git a/packages/editor/src/components/view-editing-mode-picker/index.js b/packages/editor/src/components/view-editing-mode-picker/index.js new file mode 100644 index 00000000000000..903ea74ae2c8fc --- /dev/null +++ b/packages/editor/src/components/view-editing-mode-picker/index.js @@ -0,0 +1,54 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { useSelect, useDispatch } from '@wordpress/data'; +import { useCallback, useMemo } from '@wordpress/element'; +import { SelectControl } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import { getModeConfig, modes } from '../../editor-modes'; + +export default function ViewEditingModePicker() { + const { post, blocks, settings, viewEditingMode } = useSelect( ( select ) => { + const { + getCurrentPost, + getBlocksForSerialization, + getEditorSettings, + getViewEditingMode, + } = select( 'core/editor' ); + return { + post: getCurrentPost(), + blocks: getBlocksForSerialization(), + settings: getEditorSettings(), + viewEditingMode: getViewEditingMode(), + }; + }, [] ); + const { setupEditor, updateViewEditingMode } = useDispatch( 'core/editor' ); + + const updateViewEditingModeCallback = useCallback( ( newViewEditingMode ) => { + const newModeConfig = getModeConfig( newViewEditingMode, settings.templateParts ); + updateViewEditingMode( newViewEditingMode ); + + setupEditor( + post, + null, + settings.template, + newModeConfig.showTemplate && settings.templatePost, + newModeConfig.id + ); + }, [ post, blocks, settings.template, settings.templatePost, viewEditingMode ] ); + + return ( + [ ...modes, ...( settings.templateParts || [] ) ], [ settings.templateParts ] ) } + value={ viewEditingMode } + onChange={ updateViewEditingModeCallback } + /> + ); +} diff --git a/packages/editor/src/components/view-editing-mode-picker/style.scss b/packages/editor/src/components/view-editing-mode-picker/style.scss new file mode 100644 index 00000000000000..645a256074d8d9 --- /dev/null +++ b/packages/editor/src/components/view-editing-mode-picker/style.scss @@ -0,0 +1,3 @@ +.editor-view-editing-mode-picker .components-base-control__field { + margin-bottom: 0; +} diff --git a/packages/editor/src/editor-modes.js b/packages/editor/src/editor-modes.js new file mode 100644 index 00000000000000..db902a10da7001 --- /dev/null +++ b/packages/editor/src/editor-modes.js @@ -0,0 +1,23 @@ +/** + * External dependencies + */ +import { find } from 'lodash'; + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +export const modes = [ + { value: 'post-content', label: __( '📝 Writing' ), showTemplate: false }, + { value: 'full-site', label: __( '🦋 Full Site Editing' ), showTemplate: true }, + { value: 'preview', label: __( '🍿 Preview' ), showTemplate: true }, + { value: 'focus', label: __( '👁 Focus' ), showTemplate: true }, + { value: 'design', label: __( '🎨 Design' ), showTemplate: true }, + { value: 'template', label: __( '📦 Template' ), showTemplate: true }, + { value: '---', label: __( '---' ), showTemplate: true }, +]; + +export const getModeConfig = ( modeId, dynamicModes = [] ) => { + return find( [ ...modes, ...dynamicModes ], ( mode ) => mode.value === modeId ) || modes[ 0 ]; +}; diff --git a/packages/editor/src/hooks/index.js b/packages/editor/src/hooks/index.js index 2c8a61d9802521..4f6665e003e1c8 100644 --- a/packages/editor/src/hooks/index.js +++ b/packages/editor/src/hooks/index.js @@ -2,3 +2,4 @@ * Internal dependencies */ import './default-autocompleters'; +import './view-edit-mode'; diff --git a/packages/editor/src/hooks/view-edit-mode.js b/packages/editor/src/hooks/view-edit-mode.js new file mode 100644 index 00000000000000..c03a0b57a6b832 --- /dev/null +++ b/packages/editor/src/hooks/view-edit-mode.js @@ -0,0 +1,87 @@ +/** + * External dependencies + */ +import { some } from 'lodash'; + +/** + * WordPress dependencies + */ +import { createHigherOrderComponent } from '@wordpress/compose'; +import { useSelect } from '@wordpress/data'; +import { addFilter } from '@wordpress/hooks'; + +const postBlockTypes = [ 'core/post-content', 'core/post-date', 'core/post-title' ]; + +export const withViewEditingModeForBlockEdit = createHigherOrderComponent( ( BlockEdit ) => { + return ( props ) => { + const { clientId, name } = props; + + const { + viewEditingMode, + } = useSelect( ( select ) => { + const { getViewEditingMode } = select( 'core/editor' ); + + return { + viewEditingMode: getViewEditingMode(), + }; + }, [ clientId ] ); + + if ( viewEditingMode === 'template' && postBlockTypes.includes( name ) ) { + return ; + } + + return ( + + ); + }; +}, 'withViewEditingMode' ); + +export const withViewEditingModeForBlockListBlock = createHigherOrderComponent( ( BlockListBlock ) => { + return ( props ) => { + const { clientId, name } = props; + + const { + isAncestorOfPostBlock, + isDescendantOfPostBlock, + viewEditingMode, + } = useSelect( ( select ) => { + const { isAncestorOfBlockTypeName, isDescendantOfBlockTypeName } = select( 'core/block-editor' ); + const { getViewEditingMode } = select( 'core/editor' ); + + return { + isAncestorOfPostBlock: some( postBlockTypes, ( postBlockType ) => isAncestorOfBlockTypeName( clientId, postBlockType ) ), + isDescendantOfPostBlock: some( postBlockTypes, ( postBlockType ) => isDescendantOfBlockTypeName( clientId, postBlockType ) ), + viewEditingMode: getViewEditingMode(), + }; + }, [ clientId ] ); + + if ( viewEditingMode === 'focus' ) { + if ( isAncestorOfPostBlock ) { + return ; + } + + if ( postBlockTypes.includes( name ) ) { + return ; + } + + if ( isDescendantOfPostBlock ) { + return ; + } + + return ; + } + + if ( viewEditingMode === 'preview' ) { + return ; + } + + if ( viewEditingMode === 'template' && isDescendantOfPostBlock ) { + return ; + } + + return ; + }; +}, 'withViewEditingMode' ); + +addFilter( 'editor.BlockEdit', 'core/editor/block-edit/with-view-edit-mode', withViewEditingModeForBlockEdit ); +addFilter( 'editor.BlockListBlock', 'core/editor/block-list-block/with-view-edit-mode', withViewEditingModeForBlockListBlock ); diff --git a/packages/editor/src/index.js b/packages/editor/src/index.js index 67de1923a013d9..e26cfd3658f7d4 100644 --- a/packages/editor/src/index.js +++ b/packages/editor/src/index.js @@ -17,6 +17,7 @@ import './hooks'; export * from './components'; export * from './utils'; +export * from './editor-modes'; export { storeConfig } from './store'; /* diff --git a/packages/editor/src/store/actions.js b/packages/editor/src/store/actions.js index 8ea2c9a408de84..a950a57169e334 100644 --- a/packages/editor/src/store/actions.js +++ b/packages/editor/src/store/actions.js @@ -12,6 +12,7 @@ import { dispatch, select, apiFetch } from '@wordpress/data-controls'; import { parse, synchronizeBlocksWithTemplate, + serialize, } from '@wordpress/blocks'; import isShallowEqual from '@wordpress/is-shallow-equal'; @@ -35,6 +36,8 @@ import { } from './utils/notice-builder'; import { awaitNextStateChange, getRegistry } from './controls'; import * as sources from './block-sources'; +import { getModeConfig } from '../editor-modes'; +import { findDeepBlock, findDeepBlocks } from '../utils'; /** * Map of Registry instance to WeakMap of dependencies by custom source. @@ -43,6 +46,8 @@ import * as sources from './block-sources'; */ const lastBlockSourceDependenciesByRegistry = new WeakMap; +const templatePartsBlocksCache = {}; + /** * Given a blocks array, returns a blocks array with sourced attribute values * applied. The reference will remain consistent with the original argument if @@ -152,11 +157,13 @@ function* resetLastBlockSourceDependencies( sourcesToUpdate = Object.values( sou * Returns an action generator used in signalling that editor has initialized with * the specified post object and editor settings. * - * @param {Object} post Post object. - * @param {Object} edits Initial edited attributes object. - * @param {Array?} template Block Template. + * @param {Object} post Post object. + * @param {Object} edits Initial edited attributes object. + * @param {Array?} template Block Template. + * @param {Object?} templatePost Post's template post object. + * @param {string?} templatePartId */ -export function* setupEditor( post, edits, template ) { +export function* setupEditor( post, edits, template, templatePost, templatePartId ) { // 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. @@ -164,14 +171,36 @@ export function* setupEditor( post, edits, template ) { if ( has( edits, [ 'content' ] ) ) { content = edits.content; } else { - content = post.content.raw; + content = post.content.raw || post.content; } - let blocks = parse( content ); + let blocks = templatePartsBlocksCache[ 'post-content' ] || parse( content ); + const isNewPost = 'auto-draft' === post.status; + + if ( templatePost ) { // Apply template post. + const postContentInnerBlocks = blocks; + blocks = templatePartsBlocksCache.template || parse( templatePost.post_content ); + + const templatePartBlocks = findDeepBlocks( blocks, 'core/template-part' ); + templatePartBlocks.forEach( ( block ) => { + if ( templatePartsBlocksCache[ block.attributes.id ] ) { + block.innerBlocks = templatePartsBlocksCache[ block.attributes.id ]; + } + } ); - // Apply a template for new posts only, if exists. - const isNewPost = post.status === 'auto-draft'; - if ( isNewPost && template ) { + if ( templatePartId ) { + blocks = templatePartBlocks.find( ( block ) => block.attributes.id === templatePartId ).innerBlocks; + } + + // Post content is nested inside a post content block. + const postContentBlock = findDeepBlock( blocks, 'core/post-content' ); + if ( postContentBlock ) { + postContentBlock.innerBlocks = isNewPost ? // Apply block (post content) template. + synchronizeBlocksWithTemplate( postContentInnerBlocks, template ) : + postContentInnerBlocks; + } + } else if ( isNewPost ) { // Apply block (post content) template. + // Post content is at the top level. blocks = synchronizeBlocksWithTemplate( blocks, template ); } @@ -469,16 +498,87 @@ export function* savePost( options = {} ) { edits = { status: 'draft', ...edits }; } + const allBlocks = yield select( + 'core/block-editor', + 'getBlocksByClientId', + yield select( 'core/block-editor', 'getClientIdsWithDescendants' ) + ); + const post = yield select( STORE_KEY, 'getCurrentPost' ); - const editedPostContent = yield select( + let editedPostContent = yield select( STORE_KEY, 'getEditedPostContent' ); + const viewEditingMode = yield select( STORE_KEY, 'getViewEditingMode' ); + const { templateParts, templatePost } = yield select( STORE_KEY, 'getEditorSettings' ); + const viewEditingModeObject = getModeConfig( viewEditingMode, templateParts ); + if ( viewEditingModeObject.showTemplate ) { + for ( const block of allBlocks ) { + if ( block.name === 'core/template-part' ) { + const { innerBlocks, attributes } = block; + const templatePartContent = serialize( innerBlocks ); + const savedPost = yield dispatch( + 'core', + 'saveEntityRecord', + 'postType', + 'wp_template', { + content: templatePartContent, + id: attributes.id, + title: attributes.name, + } + ); + if ( ! attributes.id ) { + yield dispatch( + 'core/block-editor', + 'updateBlockAttributes', block.clientId, { + id: savedPost.id, + } + ); + } + } + } + + if ( viewEditingModeObject.id ) { + const templatePartContent = editedPostContent; + yield dispatch( + 'core', + 'saveEntityRecord', + 'postType', + 'wp_template', { + content: templatePartContent, + id: viewEditingModeObject.id, + title: viewEditingModeObject.label, + } + ); + } + } + + if ( viewEditingModeObject.showTemplate && ! viewEditingModeObject.id && templatePost ) { + yield apiFetch( { + path: `/wp/v2/${ templatePost.post_type }/${ templatePost.ID }`, + method: 'PUT', + data: { + content: editedPostContent, + id: templatePost.ID, + }, + } ); + const postContentBlock = allBlocks.find( ( block ) => block.name === 'core/post-content' ); + editedPostContent = postContentBlock ? serialize( postContentBlock.innerBlocks ) : ''; + } else if ( viewEditingModeObject.id ) { + editedPostContent = post.content; + } + + const shouldSaveSiteOptions = yield select( 'core', 'isSiteOptionsDirty' ); + if ( shouldSaveSiteOptions ) { + const siteOptions = yield select( 'core', 'getSiteOptions' ); + yield dispatch( 'core', 'saveSiteOptions', siteOptions ); + } + let toSend = { ...edits, content: editedPostContent, @@ -870,6 +970,21 @@ export function disablePublishSidebar() { }; } +/** + * Returns an action object used in signalling that the user has changed + * the view editing mode. + * + * @param {string} viewEditingMode The name of the view editing mode. + * + * @return {Object} Action object. + */ +export function updateViewEditingMode( viewEditingMode ) { + return { + type: 'UPDATE_VIEW_EDITING_MODE', + viewEditingMode, + }; +} + /** * Returns an action object used to signal that post saving is locked. * @@ -943,9 +1058,32 @@ export function* resetEditorBlocks( blocks, options = {} ) { yield* resetLastBlockSourceDependencies( Array.from( updatedSources ) ); } + const newBlocks = yield* getBlocksWithSourcedAttributes( blocks ); + + const viewEditingMode = yield select( STORE_KEY, 'getViewEditingMode' ); + const viewEditingModeObject = getModeConfig( + viewEditingMode, + ( yield select( STORE_KEY, 'getEditorSettings' ) ).templateParts + ); + const cacheKey = + viewEditingModeObject.id || + ( viewEditingModeObject.showTemplate && 'template' ) || + viewEditingMode; + templatePartsBlocksCache[ cacheKey ] = newBlocks; + + if ( cacheKey === 'template' ) { + const postContentBlock = findDeepBlock( newBlocks, 'core/post-content' ); + if ( postContentBlock ) { + templatePartsBlocksCache[ 'post-content' ] = postContentBlock.innerBlocks; + } + + const templatePartBlocks = findDeepBlocks( newBlocks, 'core/template-part' ); + templatePartBlocks.forEach( ( block ) => templatePartsBlocksCache[ block.attributes.id ] = block.innerBlocks ); + } + return { type: 'RESET_EDITOR_BLOCKS', - blocks: yield* getBlocksWithSourcedAttributes( blocks ), + blocks: newBlocks, shouldCreateUndoLevel: options.__unstableShouldCreateUndoLevel !== false, }; } @@ -958,6 +1096,21 @@ export function* resetEditorBlocks( blocks, options = {} ) { * @return {Object} Action object */ export function updateEditorSettings( settings ) { + if ( settings.templatePost ) { + const templatePartBlocks = findDeepBlocks( + parse( settings.templatePost.post_content ), + 'core/template-part' + ); + settings = { + ...settings, + templateParts: templatePartBlocks.map( ( block ) => ( { + value: block.attributes.name.toLowerCase(), + label: block.attributes.name, + showTemplate: true, + id: block.attributes.id, + } ) ), + }; + } return { type: 'UPDATE_EDITOR_SETTINGS', settings, diff --git a/packages/editor/src/store/block-sources/index.js b/packages/editor/src/store/block-sources/index.js index 542d774c313ce9..8964e45f3feba0 100644 --- a/packages/editor/src/store/block-sources/index.js +++ b/packages/editor/src/store/block-sources/index.js @@ -1,6 +1,8 @@ /** * Internal dependencies */ +import * as post from './post'; import * as meta from './meta'; +import * as option from './option'; -export { meta }; +export { post, meta, option }; diff --git a/packages/editor/src/store/block-sources/option.js b/packages/editor/src/store/block-sources/option.js new file mode 100644 index 00000000000000..7e85fe31538a61 --- /dev/null +++ b/packages/editor/src/store/block-sources/option.js @@ -0,0 +1,47 @@ +/** + * WordPress dependencies + */ +import { select, dispatch } from '@wordpress/data-controls'; + +/** + * 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 { + options: yield select( 'core', 'getSiteOptions', ), + }; +} + +/** + * 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.options Site options. + * + * @return {Object} Block attribute value. + */ +export function apply( schema, { options } ) { + return options[ schema.option ]; +} + +/** + * Store control invoked upon a block attributes update, responsible for + * reflecting an update in a site option 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 ) { + const siteOptions = { [ schema.option ]: value }; + yield dispatch( 'core', 'updateSiteOptions', siteOptions ); +} diff --git a/packages/editor/src/store/block-sources/post.js b/packages/editor/src/store/block-sources/post.js new file mode 100644 index 00000000000000..8e0e933cd88bf2 --- /dev/null +++ b/packages/editor/src/store/block-sources/post.js @@ -0,0 +1,46 @@ +/** + * External dependencies + */ +import { get, set } from 'lodash'; + +/** + * WordPress dependencies + */ +import { select } from '@wordpress/data-controls'; + +/** + * Internal dependencies + */ +import { editPost } from '../actions'; +import { EDIT_MERGE_PROPERTIES } from '../constants'; + +export function* getDependencies() { + return { + post: yield select( 'core/editor', 'getCurrentPost' ), + content: yield select( 'core/editor', 'getEditedPostContent' ), + edits: yield select( 'core/editor', 'getPostEdits' ), + }; +} + +export function apply( { attribute }, { post, content, edits } ) { + if ( 'content' === attribute ) { + return content; + } + + if ( undefined === get( edits, attribute ) ) { + return get( post, attribute ); + } + + if ( EDIT_MERGE_PROPERTIES.has( attribute ) ) { + return { + ...get( post, attribute ), + ...get( edits, attribute ), + }; + } + + return get( edits, attribute ); +} + +export function* update( { attribute }, value ) { + yield editPost( set( {}, attribute, value ) ); +} diff --git a/packages/editor/src/store/defaults.js b/packages/editor/src/store/defaults.js index 07c92803bd0a13..22dd5df57f0050 100644 --- a/packages/editor/src/store/defaults.js +++ b/packages/editor/src/store/defaults.js @@ -6,6 +6,7 @@ import { SETTINGS_DEFAULTS } from '@wordpress/block-editor'; export const PREFERENCES_DEFAULTS = { insertUsage: {}, // Should be kept for backward compatibility, see: https://github.com/WordPress/gutenberg/issues/14580. isPublishSidebarEnabled: true, + viewEditingMode: 'post-content', // Name of view editing mode (post-content, preview, template) }; /** diff --git a/packages/editor/src/store/reducer.js b/packages/editor/src/store/reducer.js index 6676b74dcbc395..351755eb2fd24b 100644 --- a/packages/editor/src/store/reducer.js +++ b/packages/editor/src/store/reducer.js @@ -308,6 +308,12 @@ export function template( state = { isValid: true }, action ) { */ export function preferences( state = PREFERENCES_DEFAULTS, action ) { switch ( action.type ) { + case 'UPDATE_VIEW_EDITING_MODE': + return { + ...state, + viewEditingMode: action.viewEditingMode, + }; + case 'ENABLE_PUBLISH_SIDEBAR': return { ...state, diff --git a/packages/editor/src/store/selectors.js b/packages/editor/src/store/selectors.js index 7570e8b7848de6..90e4372ec74ba3 100644 --- a/packages/editor/src/store/selectors.js +++ b/packages/editor/src/store/selectors.js @@ -1108,6 +1108,20 @@ export function canUserUseUnfilteredHTML( state ) { return has( getCurrentPost( state ), [ '_links', 'wp:action-unfiltered-html' ] ); } +/** + * Returns the currently selected view editing mode. + * + * @param {Object} state Global application state. + * + * @return {boolean} The currently selected view editing mode. + */ +export function getViewEditingMode( state ) { + if ( state.preferences.hasOwnProperty( 'viewEditingMode' ) ) { + return state.preferences.viewEditingMode; + } + return PREFERENCES_DEFAULTS.viewEditingMode; +} + /** * Returns whether the pre-publish panel should be shown * or skipped when the user clicks the "publish" button. diff --git a/packages/editor/src/style.scss b/packages/editor/src/style.scss index 949f751bd72af5..92f023161a0e1a 100644 --- a/packages/editor/src/style.scss +++ b/packages/editor/src/style.scss @@ -18,3 +18,4 @@ @import "./components/post-trash/style.scss"; @import "./components/table-of-contents/style.scss"; @import "./components/template-validation-notice/style.scss"; +@import "./components/view-editing-mode-picker/style.scss"; diff --git a/packages/editor/src/utils/blocks.js b/packages/editor/src/utils/blocks.js new file mode 100644 index 00000000000000..c2fd51715dec73 --- /dev/null +++ b/packages/editor/src/utils/blocks.js @@ -0,0 +1,27 @@ +export const findDeepBlock = ( blocksToSearch, blockName ) => { + let foundBlock = blocksToSearch.find( ( block ) => block.name === blockName ); + + if ( foundBlock ) { + return foundBlock; + } + + for ( const block of blocksToSearch ) { + foundBlock = + block.innerBlocks && findDeepBlock( block.innerBlocks, blockName ); + if ( foundBlock ) { + return foundBlock; + } + } +}; + +export const findDeepBlocks = ( blocksToSearch, blockName, foundBlocks = [] ) => { + foundBlocks.push( ...blocksToSearch.filter( ( block ) => block.name === blockName ) ); + + for ( const block of blocksToSearch ) { + if ( block.innerBlocks ) { + foundBlocks.push( ...findDeepBlocks( block.innerBlocks, blockName ) ); + } + } + + return foundBlocks; +}; diff --git a/packages/editor/src/utils/index.js b/packages/editor/src/utils/index.js index 0f246c16e4aec8..734929be6cdfad 100644 --- a/packages/editor/src/utils/index.js +++ b/packages/editor/src/utils/index.js @@ -5,3 +5,4 @@ import mediaUpload from './media-upload'; export { mediaUpload }; export { cleanForSlug } from './url.js'; +export { findDeepBlock, findDeepBlocks } from './blocks'; diff --git a/packages/rich-text/src/component/index.js b/packages/rich-text/src/component/index.js index c2f76c4a22c296..9b5d87367c2c3a 100644 --- a/packages/rich-text/src/component/index.js +++ b/packages/rich-text/src/component/index.js @@ -23,6 +23,7 @@ import isShallowEqual from '@wordpress/is-shallow-equal'; */ import FormatEdit from './format-edit'; import Editable from './editable'; +import ReadOnly from './read-only'; import { pickAriaProps } from './aria'; import { isEmpty } from '../is-empty'; import { create } from '../create'; @@ -852,6 +853,7 @@ class RichText extends Component { render() { const { + isReadOnly, tagName: Tagname = 'div', style, wrapperClassName, @@ -871,10 +873,41 @@ class RichText extends Component { // prevent Editable component updates. const key = Tagname; const MultilineTag = this.multilineTag; - const ariaProps = pickAriaProps( this.props ); const record = this.getRecord(); - const isPlaceholderVisible = placeholder && ( ! isSelected || keepPlaceholderOnFocus ) && isEmpty( record ); + const isPlaceholderVisible = ( + placeholder && + ( ! isSelected || keepPlaceholderOnFocus ) && + isEmpty( record ) + ); + const classes = classnames( 'rich-text', className ); + + const Placeholder = isPlaceholderVisible && ( + + { MultilineTag ? + { placeholder } : + placeholder + } + + ); + + if ( isReadOnly ) { + return ( + <> + + { Placeholder } + + ); + } + const ariaProps = pickAriaProps( this.props ); const autoCompleteContent = ( { listBoxId, activeId } ) => ( <> - { isPlaceholderVisible && - - { MultilineTag ? { placeholder } : placeholder } - - } + { Placeholder } { isSelected && } ); diff --git a/packages/rich-text/src/component/read-only.js b/packages/rich-text/src/component/read-only.js new file mode 100644 index 00000000000000..18bacc491d325a --- /dev/null +++ b/packages/rich-text/src/component/read-only.js @@ -0,0 +1,15 @@ +export default function readOnly( { + tagName: Tag = 'div', + record, + valueToEditableHTML, + isPlaceholderVisible, + ...props +} ) { + return ( + + ); +}