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 } ) }
+ />
+
+
+
+ { dateI18n( formats[ format ], date ) }
+
+ >;
+}
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 '' . get_the_date( $formats[ $attributes[ 'format' ] ] ) . ' ';
+}
+
+/**
+ * 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 (
+
+ );
+}