diff --git a/blocks/rich-text/index.js b/blocks/rich-text/index.js
index a256fc21aa53f4..5440a379e176f6 100644
--- a/blocks/rich-text/index.js
+++ b/blocks/rich-text/index.js
@@ -93,7 +93,7 @@ export class RichText extends Component {
this.getSettings = this.getSettings.bind( this );
this.onSetup = this.onSetup.bind( this );
this.onChange = this.onChange.bind( this );
- this.throttledOnChange = throttle( this.onChange.bind( this ), 500 );
+ this.throttledOnChange = throttle( this.onChange.bind( this, false ), 500, { leading: true } );
this.onNewBlock = this.onNewBlock.bind( this );
this.onNodeChange = this.onNodeChange.bind( this );
this.onKeyDown = this.onKeyDown.bind( this );
@@ -371,12 +371,15 @@ export class RichText extends Component {
/**
* Handles any case where the content of the tinyMCE instance has changed.
+ *
+ * @param {boolean} checkIfDirty Check whether the editor is dirty before calling onChange.
*/
- onChange() {
- if ( ! this.editor.isDirty() ) {
+ onChange( checkIfDirty = true ) {
+ if ( checkIfDirty && ! this.editor.isDirty() ) {
return;
}
- this.savedContent = this.state.empty ? [] : this.getContent();
+ const isEmpty = tinymce.DOM.isEmpty( this.editor.getBody() );
+ this.savedContent = isEmpty ? [] : this.getContent();
this.props.onChange( this.savedContent );
this.editor.save();
}
@@ -506,6 +509,8 @@ export class RichText extends Component {
return;
}
+ this.onChange( false );
+
const forward = event.keyCode === DELETE;
if ( this.props.onMerge ) {
@@ -718,6 +723,7 @@ export class RichText extends Component {
this.updateContent();
}
}
+
componentWillReceiveProps( nextProps ) {
if ( 'development' === process.env.NODE_ENV ) {
if ( ! isEqual( this.props.formatters, nextProps.formatters ) ) {
diff --git a/editor/components/block-list/block.js b/editor/components/block-list/block.js
index 5f38660aad538d..9413793c431fb0 100644
--- a/editor/components/block-list/block.js
+++ b/editor/components/block-list/block.js
@@ -17,6 +17,7 @@ import {
getBlockType,
getSaveElement,
isReusableBlock,
+ isUnmodifiedDefaultBlock,
} from '@wordpress/blocks';
import { withFilters, withContext, withAPIData } from '@wordpress/components';
import { __, sprintf } from '@wordpress/i18n';
@@ -25,7 +26,6 @@ import { __, sprintf } from '@wordpress/i18n';
* Internal dependencies
*/
import BlockMover from '../block-mover';
-import VisualEditorInserter from '../inserter';
import BlockDropZone from '../block-drop-zone';
import BlockSettingsMenu from '../block-settings-menu';
import InvalidBlockWarning from './invalid-block-warning';
@@ -37,6 +37,7 @@ import BlockMultiControls from './multi-controls';
import BlockMobileToolbar from './block-mobile-toolbar';
import BlockInsertionPoint from './insertion-point';
import IgnoreNestedEvents from './ignore-nested-events';
+import InserterWithShortcuts from '../inserter-with-shortcuts';
import { createInnerBlockList } from './utils';
import {
clearSelectedBlock,
@@ -254,9 +255,9 @@ export class BlockListBlock extends Component {
* @see https://developer.mozilla.org/en-US/docs/Web/Events/mouseenter
*/
maybeHover() {
- const { isHovered, isSelected, isMultiSelected, onHover } = this.props;
+ const { isHovered, isMultiSelected, onHover } = this.props;
- if ( isHovered || isSelected || isMultiSelected || this.hadTouchStart ) {
+ if ( isHovered || isMultiSelected || this.hadTouchStart ) {
return;
}
@@ -458,6 +459,10 @@ export class BlockListBlock extends Component {
rootUID,
layout,
renderBlockMenu,
+ isHovered,
+ isSelected,
+ isMultiSelected,
+ isFirstMultiSelected,
} = this.props;
const { name: blockName, isValid } = block;
const blockType = getBlockType( blockName );
@@ -466,16 +471,21 @@ export class BlockListBlock extends Component {
// The block as rendered in the editor is composed of general block UI
// (mover, toolbar, wrapper) and the display of the block content.
- // Generate the wrapper class names handling the different states of the block.
- const { isHovered, isSelected, isMultiSelected, isFirstMultiSelected } = this.props;
-
- // If the block is selected and we're typing we hide the sidebar
- // unless the selection is not collapsed.
- const showUI = isSelected && ( ! this.props.isTyping || ! this.state.isSelectionCollapsed );
+ // If the block is selected and we're typing the block should not appear as selected unless the selection is not collapsed.
+ // Empty paragraph blocks should always show up as unselected.
+ const isEmptyDefaultBlock = isUnmodifiedDefaultBlock( block );
+ const showSideInserter = ( isSelected || isHovered ) && isEmptyDefaultBlock;
+ const shouldAppearSelected = ! showSideInserter && isSelected && ( ! this.props.isTyping || ! this.state.isSelectionCollapsed );
+ const shouldShowMovers = shouldAppearSelected || isHovered;
+ const shouldShowSettingsMenu = shouldShowMovers;
+ const shouldShowContextualToolbar = shouldAppearSelected && isValid && showContextualToolbar;
+ const shouldShowMobileToolbar = shouldAppearSelected;
const { error } = this.state;
+
+ // Generate the wrapper class names handling the different states of the block.
const wrapperClassName = classnames( 'editor-block-list__block', {
'has-warning': ! isValid || !! error,
- 'is-selected': showUI,
+ 'is-selected': shouldAppearSelected,
'is-multi-selected': isMultiSelected,
'is-hovered': isHovered,
'is-reusable': isReusableBlock( blockType ),
@@ -521,14 +531,7 @@ export class BlockListBlock extends Component {
rootUID={ rootUID }
layout={ layout }
/>
- { ( showUI || isHovered ) && (
-
- ) }
- { ( showUI || isHovered ) && (
+ { shouldShowMovers && (
) }
- { ( showUI || isHovered ) && (
+ { shouldShowSettingsMenu && (
) }
- { showUI && isValid && showContextualToolbar && }
+ { shouldShowContextualToolbar && }
{ isFirstMultiSelected && }
,
] }
- { showUI && }
+ { shouldShowMobileToolbar && }
{ !! error && }
-
+ { ! showSideInserter && (
+
+ ) }
+ { showSideInserter && (
+
+
+
+ ) }
);
/* eslint-enable jsx-a11y/no-static-element-interactions, jsx-a11y/onclick-has-role, jsx-a11y/click-events-have-key-events */
diff --git a/editor/components/block-list/style.scss b/editor/components/block-list/style.scss
index 087a00243f2536..68b409229c8049 100644
--- a/editor/components/block-list/style.scss
+++ b/editor/components/block-list/style.scss
@@ -275,41 +275,6 @@
}
}
- // Left side inserter
- > .editor-inserter {
- position: absolute;
- left: 2px;
- top: 16px;
-
- // Mobile
- display: none;
- @include break-small {
- display: block;
- }
-
- .editor-inserter__toggle {
- width: $icon-button-size-small;
- padding: 2px;
-
- // Adjust inserter design
- box-shadow: inset 0 0 0 1px $light-gray-500;
- border-radius: 50%;
-
- // Hide the outer ring on the inserter, to visually lighten it
- &:before {
- content: '';
- position: absolute;
- top: 2px;
- right: 2px;
- bottom: 2px;
- left: 2px;
- display: block;
- border: 4px solid $white;
- border-radius: 50%;
- }
- }
- }
-
// Left and right side UI
> .editor-block-settings-menu,
> .editor-block-mover {
@@ -509,3 +474,13 @@ $sticky-bottom-offset: 20px;
margin-top: - $sticky-bottom-offset - 1px;
padding-top: 2px;
}
+
+.editor-block-list__side-inserter {
+ position: absolute;
+ top: 10px;
+ right: 10px;
+
+ @include break-small {
+ right: $block-mover-padding-visible + 10px;
+ }
+}
diff --git a/editor/components/inserter-with-shortcuts/index.js b/editor/components/inserter-with-shortcuts/index.js
new file mode 100644
index 00000000000000..385e91b4190f33
--- /dev/null
+++ b/editor/components/inserter-with-shortcuts/index.js
@@ -0,0 +1,76 @@
+/**
+ * External dependencies
+ */
+import { connect } from 'react-redux';
+import { filter, isEmpty } from 'lodash';
+
+/**
+ * WordPress dependencies
+ */
+import { BlockIcon, createBlock, getDefaultBlockName } from '@wordpress/blocks';
+import { compose } from '@wordpress/element';
+import { IconButton, withContext } from '@wordpress/components';
+import { __, sprintf } from '@wordpress/i18n';
+
+/**
+ * Internal dependencies
+ */
+import './style.scss';
+import Inserter from '../inserter';
+import { getFrequentInserterItems } from '../../store/selectors';
+import { replaceBlocks } from '../../store/actions';
+
+function InserterWithShortcuts( { items, isLocked, onToggle, onInsert } ) {
+ if ( isLocked ) {
+ return null;
+ }
+
+ const itemsWithoutDefaultBlock = filter( items, ( item ) =>
+ item.name !== getDefaultBlockName() || ! isEmpty( item.initialAttributes )
+ ).slice( 0, 2 );
+
+ return (
+
+
+
+ { itemsWithoutDefaultBlock.map( ( item ) => (
+ onInsert( item ) }
+ label={ sprintf( __( 'Add %s' ), item.title ) }
+ icon={ (
+
+
+
+ ) }
+ />
+ ) ) }
+
+ );
+}
+
+export default compose(
+ withContext( 'editor' )( ( settings ) => {
+ const { templateLock, blockTypes } = settings;
+
+ return {
+ isLocked: !! templateLock,
+ enabledBlockTypes: blockTypes,
+ };
+ } ),
+ connect(
+ ( state, { enabledBlockTypes } ) => ( {
+ items: getFrequentInserterItems( state, enabledBlockTypes, 3 ),
+ } ),
+ ( dispatch, { uid, layout } ) => ( {
+ onInsert( { name, initialAttributes } ) {
+ const block = createBlock( name, { ...initialAttributes, layout } );
+ return dispatch( replaceBlocks( uid, block ) );
+ },
+ } )
+ ),
+)( InserterWithShortcuts );
diff --git a/editor/components/inserter-with-shortcuts/style.scss b/editor/components/inserter-with-shortcuts/style.scss
new file mode 100644
index 00000000000000..ca174cdf311157
--- /dev/null
+++ b/editor/components/inserter-with-shortcuts/style.scss
@@ -0,0 +1,16 @@
+.editor-inserter-with-shortcuts {
+ display: flex;
+ align-items: center;
+ flex-direction: row-reverse;
+
+ .components-icon-button {
+ border-radius: $button-style__radius-roundrect;
+ }
+}
+
+.editor-inserter-with-shortcuts__block {
+ margin-right: 5px;
+ width: 36px;
+ height: 36px;
+ padding-top: 8px;
+}
diff --git a/editor/store/defaults.js b/editor/store/defaults.js
index a99f2733600419..27a94b95814677 100644
--- a/editor/store/defaults.js
+++ b/editor/store/defaults.js
@@ -1,3 +1,4 @@
export const PREFERENCES_DEFAULTS = {
recentInserts: [],
+ insertUsage: {},
};
diff --git a/editor/store/reducer.js b/editor/store/reducer.js
index e0dd31667c2745..641879e9ce4e0d 100644
--- a/editor/store/reducer.js
+++ b/editor/store/reducer.js
@@ -14,6 +14,7 @@ import {
mapValues,
findIndex,
reject,
+ omitBy,
} from 'lodash';
/**
@@ -614,9 +615,11 @@ export function preferences( state = PREFERENCES_DEFAULTS, action ) {
switch ( action.type ) {
case 'INSERT_BLOCKS':
return action.blocks.reduce( ( prevState, block ) => {
+ let id = block.name;
const insert = { name: block.name };
if ( isReusableBlock( block ) ) {
insert.ref = block.attributes.ref;
+ id += '/' + block.attributes.ref;
}
const isSameAsInsert = ( { name, ref } ) => name === insert.name && ref === insert.ref;
@@ -627,12 +630,20 @@ export function preferences( state = PREFERENCES_DEFAULTS, action ) {
insert,
...reject( prevState.recentInserts, isSameAsInsert ),
],
+ insertUsage: {
+ ...prevState.insertUsage,
+ [ id ]: {
+ count: prevState.insertUsage[ id ] ? prevState.insertUsage[ id ].count + 1 : 1,
+ insert,
+ },
+ },
};
}, state );
case 'REMOVE_REUSABLE_BLOCK':
return {
...state,
+ insertUsage: omitBy( state.insertUsage, ( { insert } ) => insert.ref === action.id ),
recentInserts: reject( state.recentInserts, insert => insert.ref === action.id ),
};
}
diff --git a/editor/store/selectors.js b/editor/store/selectors.js
index 4b563f8bd6fcff..c09879ad37cde5 100644
--- a/editor/store/selectors.js
+++ b/editor/store/selectors.js
@@ -14,6 +14,7 @@ import {
some,
unionWith,
includes,
+ values,
} from 'lodash';
import createSelector from 'rememo';
@@ -1177,36 +1178,25 @@ export function getInserterItems( state, enabledBlockTypes = true ) {
return compact( items );
}
-const getRecentInserts = createSelector(
- state => {
- // Filter out any inserts that are associated with a block type that isn't registered
- const inserts = state.preferences.recentInserts.filter( insert => getBlockType( insert.name ) );
+function fillWithCommonBlocks( inserts ) {
+ // Filter out any inserts that are associated with a block type that isn't registered
+ const items = inserts.filter( insert => getBlockType( insert.name ) );
- // Common blocks that we'll use to pad out our list
- const commonInserts = getBlockTypes()
- .filter( blockType => blockType.category === 'common' )
- .map( blockType => ( { name: blockType.name } ) );
+ // Common blocks that we'll use to pad out our list
+ const commonInserts = getBlockTypes()
+ .filter( blockType => blockType.category === 'common' )
+ .map( blockType => ( { name: blockType.name } ) );
- const areInsertsEqual = ( a, b ) => a.name === b.name && a.ref === b.ref;
- return unionWith( inserts, commonInserts, areInsertsEqual );
- },
- state => state.preferences.recentInserts
-);
+ const areInsertsEqual = ( a, b ) => a.name === b.name && a.ref === b.ref;
+ return unionWith( items, commonInserts, areInsertsEqual );
+}
-/**
- * Determines the items that appear in the 'Recent' tab of the inserter.
- *
- * @param {Object} state Global application state.
- * @param {string[]|boolean} enabledBlockTypes Enabled block types, or true/false to enable/disable all types.
- *
- * @return {Editor.InserterItem[]} Items that appear in the 'Recent' tab.
- */
-export function getRecentInserterItems( state, enabledBlockTypes = true ) {
+function getItemsFromInserts( state, inserts, enabledBlockTypes = true, maximum = MAX_RECENT_BLOCKS ) {
if ( ! enabledBlockTypes ) {
return [];
}
- const items = getRecentInserts( state ).map( insert => {
+ const items = fillWithCommonBlocks( inserts ).map( insert => {
if ( insert.ref ) {
const reusableBlock = getReusableBlock( state, insert.ref );
return buildInserterItemFromReusableBlock( enabledBlockTypes, reusableBlock );
@@ -1216,7 +1206,36 @@ export function getRecentInserterItems( state, enabledBlockTypes = true ) {
return buildInserterItemFromBlockType( state, enabledBlockTypes, blockType );
} );
- return compact( items ).slice( 0, MAX_RECENT_BLOCKS );
+ return compact( items ).slice( 0, maximum );
+}
+
+/**
+ * Determines the items that appear in the 'Recent' tab of the inserter.
+ *
+ * @param {Object} state Global application state.
+ * @param {string[]|boolean} enabledBlockTypes Enabled block types, or true/false to enable/disable all types.
+ * @param {number} maximum Number of items to return.
+ *
+ * @return {Editor.InserterItem[]} Items that appear in the 'Recent' tab.
+ */
+export function getRecentInserterItems( state, enabledBlockTypes = true, maximum = MAX_RECENT_BLOCKS ) {
+ return getItemsFromInserts( state, state.preferences.recentInserts, enabledBlockTypes, maximum );
+}
+
+/**
+ * Determines the items that appear in the inserter with shortcuts based on the block usage
+ *
+ * @param {Object} state Global application state.
+ * @param {string[]|boolean} enabledBlockTypes Enabled block types, or true/false to enable/disable all types.
+ * @param {number} maximum Number of items to return.
+ *
+ * @return {Editor.InserterItem[]} Items that appear in the 'Recent' tab.
+ */
+export function getFrequentInserterItems( state, enabledBlockTypes = true, maximum = MAX_RECENT_BLOCKS ) {
+ const sortedInserts = values( state.preferences.insertUsage )
+ .sort( ( a, b ) => b.count - a.count )
+ .map( ( { insert } ) => insert );
+ return getItemsFromInserts( state, sortedInserts, enabledBlockTypes, maximum );
}
/**
diff --git a/editor/store/test/reducer.js b/editor/store/test/reducer.js
index 1d0041de79059c..b1370556aca9c7 100644
--- a/editor/store/test/reducer.js
+++ b/editor/store/test/reducer.js
@@ -1163,11 +1163,12 @@ describe( 'state', () => {
expect( state ).toEqual( {
recentInserts: [],
+ insertUsage: {},
} );
} );
it( 'should record recently used blocks', () => {
- const state = preferences( deepFreeze( { recentInserts: [] } ), {
+ const state = preferences( deepFreeze( { recentInserts: [], insertUsage: {} } ), {
type: 'INSERT_BLOCKS',
blocks: [ {
uid: 'bacon',
@@ -1179,9 +1180,23 @@ describe( 'state', () => {
recentInserts: [
{ name: 'core-embed/twitter' },
],
+ insertUsage: {
+ 'core-embed/twitter': {
+ count: 1,
+ insert: { name: 'core-embed/twitter' },
+ },
+ },
} );
- const twoRecentBlocks = preferences( deepFreeze( { recentInserts: [] } ), {
+ const twoRecentBlocks = preferences( deepFreeze( {
+ recentInserts: [],
+ insertUsage: {
+ 'core-embed/twitter': {
+ count: 1,
+ insert: { name: 'core-embed/twitter' },
+ },
+ },
+ } ), {
type: 'INSERT_BLOCKS',
blocks: [ {
uid: 'eggs',
@@ -1198,6 +1213,16 @@ describe( 'state', () => {
{ name: 'core/block', ref: 123 },
{ name: 'core-embed/twitter' },
],
+ insertUsage: {
+ 'core-embed/twitter': {
+ count: 2,
+ insert: { name: 'core-embed/twitter' },
+ },
+ 'core/block/123': {
+ count: 1,
+ insert: { name: 'core/block', ref: 123 },
+ },
+ },
} );
} );
@@ -1208,6 +1233,12 @@ describe( 'state', () => {
{ name: 'core/block', ref: 123 },
{ name: 'core/block', ref: 456 },
],
+ insertUsage: {
+ 'core/block/123': {
+ count: 1,
+ insert: { name: 'core/block', ref: 123 },
+ },
+ },
};
const state = preferences( deepFreeze( initialState ), {
@@ -1220,6 +1251,7 @@ describe( 'state', () => {
{ name: 'core-embed/twitter' },
{ name: 'core/block', ref: 456 },
],
+ insertUsage: {},
} );
} );
} );
diff --git a/editor/store/test/selectors.js b/editor/store/test/selectors.js
index c3aeed433d29a1..c818c498ac26c8 100644
--- a/editor/store/test/selectors.js
+++ b/editor/store/test/selectors.js
@@ -75,6 +75,7 @@ const {
isPublishingPost,
getInserterItems,
getRecentInserterItems,
+ getFrequentInserterItems,
POST_UPDATE_TRANSACTION_ID,
} = selectors;
@@ -2199,6 +2200,62 @@ describe( 'selectors', () => {
} );
} );
+ describe( 'getFrequentInserterItems', () => {
+ beforeAll( () => {
+ registerCoreBlocks();
+ } );
+
+ it( 'should return the 8 most recently used blocks', () => {
+ const state = {
+ preferences: {
+ insertUsage: {
+ 'core/deleted-block': { count: 10, insert: { name: 'core/deleted-block' } }, // Deleted blocks should be filtered out
+ 'core/block/456': { count: 4, insert: { name: 'core/block', ref: 456 } }, // Deleted reusable blocks should be filtered out
+ 'core/image': { count: 3, insert: { name: 'core/image' } },
+ 'core/block/123': { count: 5, insert: { name: 'core/block', ref: 123 } },
+ 'core/paragraph': { count: 2, insert: { name: 'core/paragraph' } },
+ },
+ },
+ editor: {
+ present: {
+ blockOrder: [],
+ },
+ },
+ reusableBlocks: {
+ data: {
+ 123: { id: 123, type: 'core/test-block' },
+ },
+ },
+ };
+
+ expect( getFrequentInserterItems( state, true, 3 ) ).toMatchObject( [
+ { name: 'core/block', initialAttributes: { ref: 123 } },
+ { name: 'core/image', initialAttributes: {} },
+ { name: 'core/paragraph', initialAttributes: {} },
+ ] );
+ } );
+
+ it( 'should pad list out with blocks from the common category', () => {
+ const state = {
+ preferences: {
+ insertUsage: {
+ 'core/image': { count: 2, insert: { name: 'core/paragraph' } },
+ },
+ },
+ editor: {
+ present: {
+ blockOrder: [],
+ },
+ },
+ };
+
+ // We should get back 4 items with no duplicates
+ const items = getFrequentInserterItems( state, true, 4 );
+ const blockNames = items.map( item => item.name );
+ expect( union( blockNames ) ).toHaveLength( 4 );
+ } );
+ } );
+
describe( 'getReusableBlock', () => {
it( 'should return a reusable block', () => {
const id = '358b59ee-bab3-4d6f-8445-e8c6971a5605';
diff --git a/test/e2e/integration/004-managing-links.js b/test/e2e/integration/004-managing-links.js
index 30ae2247fc332e..6fd13d3a242640 100644
--- a/test/e2e/integration/004-managing-links.js
+++ b/test/e2e/integration/004-managing-links.js
@@ -45,11 +45,16 @@ describe( 'Managing links', () => {
const lastBlockSelector = '.editor-block-list__block-edit:last [contenteditable="true"]:first';
cy.get( lastBlockSelector ).click();
+ cy.focused().type( 'test' );
+
+ // we need to trigger isTyping = false
+ cy.get( lastBlockSelector ).trigger( 'mousemove', { clientX: 200, clientY: 300 } );
+ cy.get( lastBlockSelector ).trigger( 'mousemove', { clientX: 250, clientY: 350 } );
cy.get( 'button[aria-label="Link"]' ).click();
// Typing "left" should not close the dialog
- cy.focused().type( '{leftarrow}' );
+ cy.get( '.blocks-url-input input' ).type( '{leftarrow}' );
cy.get( '.blocks-format-toolbar__link-modal' ).should( 'be.visible' );
// Escape should close the dialog still.