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.