diff --git a/core-blocks/freeform/edit.js b/core-blocks/freeform/edit.js index 3e099308562234..95cb6f24bcc279 100644 --- a/core-blocks/freeform/edit.js +++ b/core-blocks/freeform/edit.js @@ -84,6 +84,13 @@ export default class FreeformEdit extends Component { this.editor = editor; + // Disable TinyMCE's keyboard shortcut help. + editor.on( 'BeforeExecCommand', ( event ) => { + if ( event.command === 'WP_Help' ) { + event.preventDefault(); + } + } ); + if ( content ) { editor.on( 'loadContent', () => editor.setContent( content ) ); } diff --git a/edit-post/components/header/keyboard-shortcuts-help-menu-item/index.js b/edit-post/components/header/keyboard-shortcuts-help-menu-item/index.js new file mode 100644 index 00000000000000..355c7a18b70be7 --- /dev/null +++ b/edit-post/components/header/keyboard-shortcuts-help-menu-item/index.js @@ -0,0 +1,35 @@ +/** + * WordPress Dependencies + */ +import { withDispatch } from '@wordpress/data'; +import { displayShortcut } from '@wordpress/keycodes'; + +/** + * WordPress Dependencies + */ +import { __ } from '@wordpress/i18n'; +import { MenuItem } from '@wordpress/components'; + +export function KeyboardShortcutsHelpMenuItem( { openModal, onSelect } ) { + return ( + { + onSelect(); + openModal( 'edit-post/keyboard-shortcut-help' ); + } } + shortcut={ displayShortcut.access( 'h' ) } + > + { __( 'Keyboard Shortcuts' ) } + + ); +} + +export default withDispatch( ( dispatch, ) => { + const { + openModal, + } = dispatch( 'core/edit-post' ); + + return { + openModal, + }; +} )( KeyboardShortcutsHelpMenuItem ); diff --git a/edit-post/components/header/more-menu/index.js b/edit-post/components/header/more-menu/index.js index 3c08d76b67bbe4..19e6feb2210b32 100644 --- a/edit-post/components/header/more-menu/index.js +++ b/edit-post/components/header/more-menu/index.js @@ -12,6 +12,7 @@ import ModeSwitcher from '../mode-switcher'; import FixedToolbarToggle from '../fixed-toolbar-toggle'; import PluginMoreMenuGroup from '../plugins-more-menu-group'; import TipsToggle from '../tips-toggle'; +import KeyboardShortcutsHelpMenuItem from '../keyboard-shortcuts-help-menu-item'; const MoreMenu = () => ( ( + > + + ) } /> diff --git a/edit-post/components/keyboard-shortcut-help-modal/config.js b/edit-post/components/keyboard-shortcut-help-modal/config.js new file mode 100644 index 00000000000000..7916d9fb895359 --- /dev/null +++ b/edit-post/components/keyboard-shortcut-help-modal/config.js @@ -0,0 +1,134 @@ +/** + * WordPress dependencies + */ +import { displayShortcutList } from '@wordpress/keycodes'; +import { __ } from '@wordpress/i18n'; + +const { + // Cmd+ on a mac, Ctrl+ elsewhere + primary, + // Shift+Cmd+ on a mac, Ctrl+Shift+ elsewhere + primaryShift, + // Shift+Alt+Cmd+ on a mac, Ctrl+Shift+Akt+ elsewhere + secondary, + // Ctrl+Alt+ on a mac, Shift+Alt+ elsewhere + access, + ctrl, + ctrlShift, + shiftAlt, +} = displayShortcutList; + +const globalShortcuts = { + title: __( 'Global shortcuts' ), + shortcuts: [ + { + keyCombination: access( 'h' ), + description: __( 'Display this help.' ), + }, + { + keyCombination: primary( 's' ), + description: __( 'Save your changes.' ), + }, + { + keyCombination: primary( 'z' ), + description: __( 'Undo your last changes.' ), + }, + { + keyCombination: primaryShift( 'z' ), + description: __( 'Redo your last undo.' ), + }, + { + keyCombination: primaryShift( ',' ), + description: __( 'Show or hide the settings sidebar.' ), + }, + { + keyCombination: ctrl( '`' ), + description: __( 'Navigate to a the next part of the editor.' ), + }, + { + keyCombination: ctrlShift( '`' ), + description: __( 'Navigate to the previous part of the editor.' ), + }, + { + keyCombination: shiftAlt( 'n' ), + description: __( 'Navigate to a the next part of the editor (alternative).' ), + }, + { + keyCombination: shiftAlt( 'p' ), + description: __( 'Navigate to the previous part of the editor (alternative).' ), + }, + { + keyCombination: secondary( 'm' ), + description: __( 'Switch between Visual Editor and Code Editor.' ), + }, + ], +}; + +const selectionShortcuts = { + title: __( 'Selection shortcuts' ), + shortcuts: [ + { + keyCombination: primary( 'a' ), + description: __( 'Select all text when typing. Press again to select all blocks.' ), + }, + { + keyCombination: 'Esc', + description: __( 'Clear selection.' ), + }, + ], +}; + +const blockShortcuts = { + title: __( 'Block shortcuts' ), + shortcuts: [ + { + keyCombination: primaryShift( 'd' ), + description: __( 'Duplicate the selected block(s).' ), + }, + { + keyCombination: '/', + description: __( `Change the block type after adding a new paragraph.` ), + }, + ], +}; + +const textFormattingShortcuts = { + title: __( 'Text formatting' ), + shortcuts: [ + { + keyCombination: primary( 'b' ), + description: __( 'Make the selected text bold.' ), + }, + { + keyCombination: primary( 'i' ), + description: __( 'Make the selected text italic.' ), + }, + { + keyCombination: primary( 'u' ), + description: __( 'Underline the selected text.' ), + }, + { + keyCombination: primary( 'k' ), + description: __( 'Convert the selected text into a link.' ), + }, + { + keyCombination: access( 's' ), + description: __( 'Remove a link.' ), + }, + { + keyCombination: access( 'd' ), + description: __( 'Add a strikethrough to the selected text.' ), + }, + { + keyCombination: access( 'x' ), + description: __( 'Display the selected text in a monospaced font.' ), + }, + ], +}; + +export default [ + globalShortcuts, + selectionShortcuts, + blockShortcuts, + textFormattingShortcuts, +]; diff --git a/edit-post/components/keyboard-shortcut-help-modal/index.js b/edit-post/components/keyboard-shortcut-help-modal/index.js new file mode 100644 index 00000000000000..295d3e9ba5fdcc --- /dev/null +++ b/edit-post/components/keyboard-shortcut-help-modal/index.js @@ -0,0 +1,119 @@ +/** + * External dependencies + */ +import { castArray } from 'lodash'; + +/** + * WordPress dependencies + */ +import { Fragment } from '@wordpress/element'; +import { Modal, KeyboardShortcuts } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { rawShortcut } from '@wordpress/keycodes'; +import { withSelect, withDispatch } from '@wordpress/data'; +import { compose } from '@wordpress/compose'; + +/** + * Internal dependencies + */ +import shortcutConfig from './config'; +import './style.scss'; + +const MODAL_NAME = 'edit-post/keyboard-shortcut-help'; + +const mapKeyCombination = ( keyCombination ) => keyCombination.map( ( character, index ) => { + if ( character === '+' ) { + return ( + + { character } + + ); + } + + return ( + + { character } + + ); +} ); + +const ShortcutList = ( { shortcuts } ) => ( +
+ { shortcuts.map( ( { keyCombination, description }, index ) => ( +
+
+ + { mapKeyCombination( castArray( keyCombination ) ) } + +
+
+ { description } +
+
+ ) ) } +
+); + +const ShortcutSection = ( { title, shortcuts } ) => ( +
+

+ { title } +

+ +
+); + +export function KeyboardShortcutHelpModal( { isModalActive, toggleModal } ) { + const title = ( + + { __( 'Keyboard Shortcuts' ) } + + ); + + return ( + + + { isModalActive && ( + + + { shortcutConfig.map( ( config, index ) => ( + + ) ) } + + + ) } + + ); +} + +export default compose( [ + withSelect( ( select ) => ( { + isModalActive: select( 'core/edit-post' ).isModalActive( MODAL_NAME ), + } ) ), + withDispatch( ( dispatch, { isModalActive } ) => { + const { + openModal, + closeModal, + } = dispatch( 'core/edit-post' ); + + return { + toggleModal: () => isModalActive ? closeModal() : openModal( MODAL_NAME ), + }; + } ), +] )( KeyboardShortcutHelpModal ); diff --git a/edit-post/components/keyboard-shortcut-help-modal/style.scss b/edit-post/components/keyboard-shortcut-help-modal/style.scss new file mode 100644 index 00000000000000..8cf40412fbbc18 --- /dev/null +++ b/edit-post/components/keyboard-shortcut-help-modal/style.scss @@ -0,0 +1,56 @@ +.edit-post-keyboard-shortcut-help { + &__title { + font-size: 1rem; + font-weight: bold; + } + + &__section { + margin: 0 0 2rem 0; + } + + + &__section-title { + font-size: 0.9rem; + font-weight: bold; + } + + &__shortcut { + display: flex; + align-items: center; + padding: 0.6rem 0; + border-top: 1px solid $light-gray-500; + + &:last-child { + border-bottom: 1px solid $light-gray-500; + } + } + + &__shortcut-term { + flex: 1; + order: 1; + text-align: right; + font-weight: bold; + margin: 0 0 0 1rem; + } + + &__shortcut-description { + order: 0; + margin: 0; + } + + &__shortcut-key-combination { + background: none; + margin: 0; + padding: 0; + } + + &__shortcut-key { + padding: 0.25rem 0.5rem; + border-radius: 8%; + margin: 0 0.2rem 0 0.2rem; + + &:last-child { + margin: 0 0 0 0.2rem; + } + } +} diff --git a/edit-post/components/keyboard-shortcut-help-modal/test/__snapshots__/index.js.snap b/edit-post/components/keyboard-shortcut-help-modal/test/__snapshots__/index.js.snap new file mode 100644 index 00000000000000..601341308ffeed --- /dev/null +++ b/edit-post/components/keyboard-shortcut-help-modal/test/__snapshots__/index.js.snap @@ -0,0 +1,256 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`KeyboardShortcutHelpModal should match snapshot when the modal is active 1`] = ` + + + + Keyboard Shortcuts + + } + > + + + + + + +`; + +exports[`KeyboardShortcutHelpModal should match snapshot when the modal is not active 1`] = ` + + + +`; diff --git a/edit-post/components/keyboard-shortcut-help-modal/test/index.js b/edit-post/components/keyboard-shortcut-help-modal/test/index.js new file mode 100644 index 00000000000000..c8bc43fe9b4ec8 --- /dev/null +++ b/edit-post/components/keyboard-shortcut-help-modal/test/index.js @@ -0,0 +1,34 @@ +/** + * External dependencies + */ +import { noop } from 'lodash'; +import { shallow } from 'enzyme'; + +/** + * Internal dependencies + */ +import { KeyboardShortcutHelpModal } from '../index'; + +describe( 'KeyboardShortcutHelpModal', () => { + it( 'should match snapshot when the modal is active', () => { + const wrapper = shallow( + + ); + + expect( wrapper ).toMatchSnapshot(); + } ); + + it( 'should match snapshot when the modal is not active', () => { + const wrapper = shallow( + + ); + + expect( wrapper ).toMatchSnapshot(); + } ); +} ); diff --git a/edit-post/components/layout/index.js b/edit-post/components/layout/index.js index 46dc2427a642ad..839ea7e31678e6 100644 --- a/edit-post/components/layout/index.js +++ b/edit-post/components/layout/index.js @@ -34,6 +34,7 @@ import Header from '../header'; import TextEditor from '../text-editor'; import VisualEditor from '../visual-editor'; import EditorModeKeyboardShortcuts from '../keyboard-shortcuts'; +import KeyboardShortcutHelpModal from '../keyboard-shortcut-help-modal'; import MetaBoxes from '../meta-boxes'; import { getMetaBoxContainer } from '../../utils/meta-boxes'; import Sidebar from '../sidebar'; @@ -88,6 +89,7 @@ function Layout( { + { mode === 'text' && } { mode === 'visual' && }
diff --git a/edit-post/store/actions.js b/edit-post/store/actions.js index d48b88de062d75..2a58739addd93f 100644 --- a/edit-post/store/actions.js +++ b/edit-post/store/actions.js @@ -1,8 +1,9 @@ /** * Returns an action object used in signalling that the user opened an editor sidebar. * - * @param {string} name Sidebar name to be opened. - * @return {Object} Action object. + * @param {string} name Sidebar name to be opened. + * + * @return {Object} Action object. */ export function openGeneralSidebar( name ) { return { @@ -22,6 +23,31 @@ export function closeGeneralSidebar() { }; } +/** + * Returns an action object used in signalling that the user opened an editor sidebar. + * + * @param {string} name A string that uniquely identifies the modal. + * + * @return {Object} Action object. + */ +export function openModal( name ) { + return { + type: 'OPEN_MODAL', + name, + }; +} + +/** + * Returns an action object signalling that the user closed the sidebar. + * + * @return {Object} Action object. + */ +export function closeModal() { + return { + type: 'CLOSE_MODAL', + }; +} + /** * Returns an action object used in signalling that the user opened the publish * sidebar. @@ -130,7 +156,7 @@ export function initializeMetaBoxState( metaBoxes ) { /** * Returns an action object used to request meta box update. * - * @return {Object} Action object. + * @return {Object} Action object. */ export function requestMetaBoxUpdates() { return { @@ -154,7 +180,8 @@ export function metaBoxUpdatesSuccess() { * This is used to check if the meta boxes have been touched when leaving the editor. * * @param {Object} dataPerLocation Meta Boxes Data per location. - * @return {Object} Action object. + * + * @return {Object} Action object. */ export function setMetaBoxSavedData( dataPerLocation ) { return { diff --git a/edit-post/store/reducer.js b/edit-post/store/reducer.js index e335843620c134..fd7bdf7c47d6ab 100644 --- a/edit-post/store/reducer.js +++ b/edit-post/store/reducer.js @@ -88,6 +88,25 @@ export function panel( state = 'document', action ) { return state; } +/** + * Reducer for storing the name of the open modal, or null if no modal is open. + * + * @param {Object} state Previous state. + * @param {Object} action Action object containing the `name` of the modal + * + * @return {Object} Updated state + */ +export function activeModal( state = null, action ) { + switch ( action.type ) { + case 'OPEN_MODAL': + return action.name; + case 'CLOSE_MODAL': + return null; + } + + return state; +} + export function publishSidebarActive( state = false, action ) { switch ( action.type ) { case 'OPEN_PUBLISH_SIDEBAR': @@ -121,7 +140,8 @@ const defaultMetaBoxState = locations.reduce( ( result, key ) => { * * @param {boolean} state Previous state. * @param {Object} action Action Object. - * @return {Object} Updated state. + * + * @return {Object} Updated state. */ export function isSavingMetaBoxes( state = false, action ) { switch ( action.type ) { @@ -144,7 +164,8 @@ export function isSavingMetaBoxes( state = false, action ) { * * @param {boolean} state Previous state. * @param {Object} action Action Object. - * @return {Object} Updated state. + * + * @return {Object} Updated state. */ export function metaBoxes( state = defaultMetaBoxState, action ) { switch ( action.type ) { @@ -172,6 +193,7 @@ export function metaBoxes( state = defaultMetaBoxState, action ) { export default combineReducers( { preferences, panel, + activeModal, publishSidebarActive, metaBoxes, isSavingMetaBoxes, diff --git a/edit-post/store/selectors.js b/edit-post/store/selectors.js index c8629d8ab69d4a..46c2210e1aabcb 100644 --- a/edit-post/store/selectors.js +++ b/edit-post/store/selectors.js @@ -19,7 +19,8 @@ export function getEditorMode( state ) { * Returns true if the editor sidebar is opened. * * @param {Object} state Global application state - * @return {boolean} Whether the editor sidebar is opened. + * + * @return {boolean} Whether the editor sidebar is opened. */ export function isEditorSidebarOpened( state ) { const activeGeneralSidebar = getPreference( state, 'activeGeneralSidebar', null ); @@ -78,7 +79,8 @@ export function getPreference( state, preferenceKey, defaultValue ) { * Returns true if the publish sidebar is opened. * * @param {Object} state Global application state - * @return {boolean} Whether the publish sidebar is open. + * + * @return {boolean} Whether the publish sidebar is open. */ export function isPublishSidebarOpened( state ) { return state.publishSidebarActive; @@ -89,13 +91,26 @@ export function isPublishSidebarOpened( state ) { * * @param {Object} state Global application state. * @param {string} panel Sidebar panel name. - * @return {boolean} Whether the sidebar panel is open. + * + * @return {boolean} Whether the sidebar panel is open. */ export function isEditorSidebarPanelOpened( state, panel ) { const panels = getPreference( state, 'panels' ); return panels ? !! panels[ panel ] : false; } +/** + * Returns true if a modal is active, or false otherwise. + * + * @param {Object} state Global application state. + * @param {string} modalName A string that uniquely identifies the modal. + * + * @return {boolean} Whether the modal is active. + */ +export function isModalActive( state, modalName ) { + return state.activeModal === modalName; +} + /** * Returns whether the given feature is enabled or not. * @@ -127,7 +142,8 @@ export function isPluginItemPinned( state, pluginName ) { * Returns the state of legacy meta boxes. * * @param {Object} state Global application state. - * @return {Object} State of meta boxes. + * + * @return {Object} State of meta boxes. */ export function getMetaBoxes( state ) { return state.metaBoxes; @@ -149,7 +165,8 @@ export function getMetaBox( state, location ) { * Returns true if the post is using Meta Boxes * * @param {Object} state Global application state - * @return {boolean} Whether there are metaboxes or not. + * + * @return {boolean} Whether there are metaboxes or not. */ export const hasMetaBoxes = createSelector( ( state ) => { @@ -166,7 +183,8 @@ export const hasMetaBoxes = createSelector( * Returns true if the the Meta Boxes are being saved. * * @param {Object} state Global application state. - * @return {boolean} Whether the metaboxes are being saved. + * + * @return {boolean} Whether the metaboxes are being saved. */ export function isSavingMetaBoxes( state ) { return state.isSavingMetaBoxes; diff --git a/edit-post/store/test/actions.js b/edit-post/store/test/actions.js index c0ae7ea9753aaa..e50b5a5310002e 100644 --- a/edit-post/store/test/actions.js +++ b/edit-post/store/test/actions.js @@ -8,6 +8,8 @@ import { openPublishSidebar, closePublishSidebar, togglePublishSidebar, + openModal, + closeModal, toggleFeature, togglePinnedPluginItem, requestMetaBoxUpdates, @@ -67,6 +69,24 @@ describe( 'actions', () => { } ); } ); + describe( 'openModal', () => { + it( 'should return OPEN_MODAL action', () => { + const name = 'plugin/my-name'; + expect( openModal( name ) ).toEqual( { + type: 'OPEN_MODAL', + name, + } ); + } ); + } ); + + describe( 'closeModal', () => { + it( 'should return CLOSE_MODAL action', () => { + expect( closeModal() ).toEqual( { + type: 'CLOSE_MODAL', + } ); + } ); + } ); + describe( 'toggleFeature', () => { it( 'should return TOGGLE_FEATURE action', () => { const feature = 'name'; diff --git a/edit-post/store/test/reducer.js b/edit-post/store/test/reducer.js index 18bdf6cfbbd6e1..fa9ebf4f8f0f5b 100644 --- a/edit-post/store/test/reducer.js +++ b/edit-post/store/test/reducer.js @@ -8,6 +8,7 @@ import deepFreeze from 'deep-freeze'; */ import { preferences, + activeModal, isSavingMetaBoxes, metaBoxes, } from '../reducer'; @@ -153,6 +154,30 @@ describe( 'state', () => { } ); } ); + describe( 'activeModal', () => { + it( 'should default to null', () => { + const state = activeModal( undefined, {} ); + expect( state ).toBeNull(); + } ); + + it( 'should set the activeModal to the provided name', () => { + const state = activeModal( null, { + type: 'OPEN_MODAL', + name: 'test-modal', + } ); + + expect( state ).toEqual( 'test-modal' ); + } ); + + it( 'should set the activeModal to null', () => { + const state = activeModal( 'test-modal', { + type: 'CLOSE_MODAL', + } ); + + expect( state ).toBeNull(); + } ); + } ); + describe( 'isSavingMetaBoxes', () => { it( 'should return default state', () => { const actual = isSavingMetaBoxes( undefined, {} ); diff --git a/edit-post/store/test/selectors.js b/edit-post/store/test/selectors.js index d706c4b3ecfc65..7fce2ef7716810 100644 --- a/edit-post/store/test/selectors.js +++ b/edit-post/store/test/selectors.js @@ -6,6 +6,7 @@ import { getPreference, isEditorSidebarOpened, isEditorSidebarPanelOpened, + isModalActive, isFeatureActive, isPluginSidebarOpened, isPluginItemPinned, @@ -125,6 +126,32 @@ describe( 'selectors', () => { } ); } ); + describe( 'isModalActive', () => { + it( 'returns true if the provided name matches the value in the preferences activeModal property', () => { + const state = { + activeModal: 'test-modal', + }; + + expect( isModalActive( state, 'test-modal' ) ).toBe( true ); + } ); + + it( 'returns false if the provided name does not match the preferences activeModal property', () => { + const state = { + activeModal: 'something-else', + }; + + expect( isModalActive( state, 'test-modal' ) ).toBe( false ); + } ); + + it( 'returns false if the preferences activeModal property is null', () => { + const state = { + activeModal: null, + }; + + expect( isModalActive( state, 'test-modal' ) ).toBe( false ); + } ); + } ); + describe( 'isEditorSidebarPanelOpened', () => { it( 'should return false if no panels preference', () => { const state = { diff --git a/packages/components/src/menu-item/style.scss b/packages/components/src/menu-item/style.scss index fc2b1db41ef2bf..0f0b4c1356e9b9 100644 --- a/packages/components/src/menu-item/style.scss +++ b/packages/components/src/menu-item/style.scss @@ -47,4 +47,5 @@ opacity: 0.5; margin-right: 0; margin-left: auto; + align-self: center; } diff --git a/packages/keycodes/src/index.js b/packages/keycodes/src/index.js index 98d29362774877..5cc8fcde4118b7 100644 --- a/packages/keycodes/src/index.js +++ b/packages/keycodes/src/index.js @@ -50,6 +50,10 @@ const modifiers = { primaryAlt: ( _isMac ) => _isMac() ? [ ALT, COMMAND ] : [ CTRL, ALT ], secondary: ( _isMac ) => _isMac() ? [ SHIFT, ALT, COMMAND ] : [ CTRL, SHIFT, ALT ], access: ( _isMac ) => _isMac() ? [ CTRL, ALT ] : [ SHIFT, ALT ], + ctrl: () => [ CTRL ], + ctrlShift: () => [ CTRL, SHIFT ], + shift: () => [ SHIFT ], + shiftAlt: () => [ SHIFT, ALT ], }; /** @@ -66,12 +70,12 @@ export const rawShortcut = mapValues( modifiers, ( modifier ) => { } ); /** - * An object that contains functions to display shortcuts. - * E.g. displayShortcut.primary( 'm' ) will return '⌘M' on Mac. + * Return an array of the parts of a keyboard shortcut chord for display + * E.g displayShortcutList.primary( 'm' ) will return [ '⌘', 'M' ] on Mac. * - * @type {Object} Keyed map of functions to display shortcuts. + * @type {Object} keyed map of functions to shortcut sequences */ -export const displayShortcut = mapValues( modifiers, ( modifier ) => { +export const displayShortcutList = mapValues( modifiers, ( modifier ) => { return ( character, _isMac = isMacOS ) => { const isMac = _isMac(); const replacementKeyMap = { @@ -80,18 +84,32 @@ export const displayShortcut = mapValues( modifiers, ( modifier ) => { [ COMMAND ]: '⌘', [ SHIFT ]: 'Shift', }; - const shortcut = [ - ...modifier( _isMac ).map( ( key ) => get( replacementKeyMap, key, key ) ), - capitalize( character ), - ].join( '+' ); - - // Because we use just the clover symbol for MacOS's "command" key, remove - // the key join character ("+") between it and the final character if that - // final character is alphanumeric. ⌘S looks nicer than ⌘+S. - return shortcut.replace( /⌘\+(.+)$/g, '⌘$1' ); + + const modifierKeys = modifier( _isMac ).reduce( ( accumulator, key ) => { + const replacementKey = get( replacementKeyMap, key, key ); + // When the mac's clover symbol is used, do not display a + afterwards + if ( replacementKey === '⌘' ) { + return [ ...accumulator, replacementKey ]; + } + + return [ ...accumulator, replacementKey, '+' ]; + }, [] ); + + const capitalizedCharacter = capitalize( character ); + return [ ...modifierKeys, capitalizedCharacter ]; }; } ); +/** + * An object that contains functions to display shortcuts. + * E.g. displayShortcut.primary( 'm' ) will return '⌘M' on Mac. + * + * @type {Object} Keyed map of functions to display shortcuts. + */ +export const displayShortcut = mapValues( displayShortcutList, ( sequence ) => { + return ( character, _isMac = isMacOS ) => sequence( character, _isMac ).join( '' ); +} ); + /** * An object that contains functions to check if a keyboard event matches a * predefined shortcut combination. diff --git a/packages/keycodes/src/test/index.js b/packages/keycodes/src/test/index.js index 36d8316875fd14..771955a1a1ced9 100644 --- a/packages/keycodes/src/test/index.js +++ b/packages/keycodes/src/test/index.js @@ -3,6 +3,7 @@ */ import { isMacOS, + displayShortcutList, displayShortcut, rawShortcut, } from '../'; @@ -10,6 +11,66 @@ import { const isMacOSFalse = () => false; const isMacOSTrue = () => true; +describe( 'displayShortcutList', () => { + describe( 'primary', () => { + it( 'should output [ Ctrl, +, M ] on Windows', () => { + const shortcut = displayShortcutList.primary( 'm', isMacOSFalse ); + expect( shortcut ).toEqual( [ 'Ctrl', '+', 'M' ] ); + } ); + + it( 'should output [ ⌘, M ] on MacOS', () => { + const shortcut = displayShortcutList.primary( 'm', isMacOSTrue ); + expect( shortcut ).toEqual( [ '⌘', 'M' ] ); + } ); + + it( 'outputs [ ⌘, Del ] on MacOS (works for multiple character keys)', () => { + const shortcut = displayShortcutList.primary( 'del', isMacOSTrue ); + expect( shortcut ).toEqual( [ '⌘', 'Del' ] ); + } ); + } ); + + describe( 'primaryShift', () => { + it( 'should output [ Ctrl, +, Shift, +, M ] on Windows', () => { + const shortcut = displayShortcutList.primaryShift( 'm', isMacOSFalse ); + expect( shortcut ).toEqual( [ 'Ctrl', '+', 'Shift', '+', 'M' ] ); + } ); + + it( 'should output [ Shift, +, ⌘, M ] on MacOS', () => { + const shortcut = displayShortcutList.primaryShift( 'm', isMacOSTrue ); + expect( shortcut ).toEqual( [ 'Shift', '+', '⌘', 'M' ] ); + } ); + + it( 'outputs [ Shift, +, ⌘, Del ] on MacOS (works for multiple character keys)', () => { + const shortcut = displayShortcutList.primaryShift( 'del', isMacOSTrue ); + expect( shortcut ).toEqual( [ 'Shift', '+', '⌘', 'Del' ] ); + } ); + } ); + + describe( 'secondary', () => { + it( 'should output [ Ctrl, +, Shift, +, Alt ] text on Windows', () => { + const shortcut = displayShortcutList.secondary( 'm', isMacOSFalse ); + expect( shortcut ).toEqual( [ 'Ctrl', '+', 'Shift', '+', 'Alt', '+', 'M' ] ); + } ); + + it( 'should output [ Shift, +, Option, +, Command, M ] on MacOS', () => { + const shortcut = displayShortcutList.secondary( 'm', isMacOSTrue ); + expect( shortcut ).toEqual( [ 'Shift', '+', 'Option', '+', '⌘', 'M' ] ); + } ); + } ); + + describe( 'access', () => { + it( 'should output [ Shift, +, Alt, +, M ] on Windows', () => { + const shortcut = displayShortcutList.access( 'm', isMacOSFalse ); + expect( shortcut ).toEqual( [ 'Shift', '+', 'Alt', '+', 'M' ] ); + } ); + + it( 'should output [Ctrl, +, Option, +, M ] on MacOS', () => { + const shortcut = displayShortcutList.access( 'm', isMacOSTrue ); + expect( shortcut ).toEqual( [ 'Ctrl', '+', 'Option', '+', 'M' ] ); + } ); + } ); +} ); + describe( 'displayShortcut', () => { describe( 'primary', () => { it( 'should output Control text on Windows', () => { diff --git a/test/e2e/specs/change-detection.test.js b/test/e2e/specs/change-detection.test.js index 1eb6dcf55d791d..0c1902cd718093 100644 --- a/test/e2e/specs/change-detection.test.js +++ b/test/e2e/specs/change-detection.test.js @@ -7,6 +7,7 @@ import { pressWithModifier, ensureSidebarOpened, publishPost, + META_KEY, } from '../support/utils'; describe( 'Change detection', () => { @@ -68,7 +69,7 @@ describe( 'Change detection', () => { await interceptSave(); // Keyboard shortcut Ctrl+S save. - await pressWithModifier( 'Mod', 'S' ); + await pressWithModifier( META_KEY, 'S' ); expect( hadInterceptedSave ).toBe( false ); } ); @@ -153,7 +154,7 @@ describe( 'Change detection', () => { page.waitForSelector( '.editor-post-saved-state.is-saved' ), // Keyboard shortcut Ctrl+S save. - pressWithModifier( 'Mod', 'S' ), + pressWithModifier( META_KEY, 'S' ), ] ); await assertIsDirty( false ); @@ -167,13 +168,13 @@ describe( 'Change detection', () => { page.waitForSelector( '.editor-post-saved-state.is-saved' ), // Keyboard shortcut Ctrl+S save. - pressWithModifier( 'Mod', 'S' ), + pressWithModifier( META_KEY, 'S' ), ] ); await interceptSave(); // Keyboard shortcut Ctrl+S save. - await pressWithModifier( 'Mod', 'S' ); + await pressWithModifier( META_KEY, 'S' ); expect( hadInterceptedSave ).toBe( false ); } ); @@ -185,7 +186,7 @@ describe( 'Change detection', () => { await Promise.all( [ // Keyboard shortcut Ctrl+S save. - pressWithModifier( 'Mod', 'S' ), + pressWithModifier( META_KEY, 'S' ), // Ensure save update fails and presents button. page.waitForXPath( "//p[contains(text(), 'Updating failed')]" ), @@ -209,7 +210,7 @@ describe( 'Change detection', () => { await interceptSave(); // Keyboard shortcut Ctrl+S save. - await pressWithModifier( 'Mod', 'S' ); + await pressWithModifier( META_KEY, 'S' ); await releaseSaveIntercept(); @@ -225,7 +226,7 @@ describe( 'Change detection', () => { await interceptSave(); // Keyboard shortcut Ctrl+S save. - await pressWithModifier( 'Mod', 'S' ); + await pressWithModifier( META_KEY, 'S' ); await page.type( '.editor-post-title__input', '!' ); @@ -242,7 +243,7 @@ describe( 'Change detection', () => { await interceptSave(); // Keyboard shortcut Ctrl+S save. - await pressWithModifier( 'Mod', 'S' ); + await pressWithModifier( META_KEY, 'S' ); // Dirty post while save is in-flight. await page.type( '.editor-post-title__input', '!' ); @@ -264,7 +265,7 @@ describe( 'Change detection', () => { await interceptSave(); // Keyboard shortcut Ctrl+S save. - await pressWithModifier( 'Mod', 'S' ); + await pressWithModifier( META_KEY, 'S' ); await clickBlockAppender(); diff --git a/test/e2e/specs/formatting-controls.test.js b/test/e2e/specs/formatting-controls.test.js index e38d3315fe57c9..4df566090625e5 100644 --- a/test/e2e/specs/formatting-controls.test.js +++ b/test/e2e/specs/formatting-controls.test.js @@ -6,6 +6,7 @@ import { getEditedPostContent, newPost, pressWithModifier, + META_KEY, } from '../support/utils'; describe( 'Formatting Controls', () => { @@ -26,7 +27,7 @@ describe( 'Formatting Controls', () => { await page.keyboard.up( 'Shift' ); // Applying "bold" - await pressWithModifier( 'Mod', 'b' ); + await pressWithModifier( META_KEY, 'b' ); // Check content const content = await getEditedPostContent(); diff --git a/test/e2e/specs/multi-block-selection.test.js b/test/e2e/specs/multi-block-selection.test.js index 5e8607fd8b2957..d341f099bf3b1e 100644 --- a/test/e2e/specs/multi-block-selection.test.js +++ b/test/e2e/specs/multi-block-selection.test.js @@ -6,6 +6,7 @@ import { insertBlock, newPost, pressWithModifier, + META_KEY, } from '../support/utils'; describe( 'Multi-block selection', () => { @@ -59,7 +60,7 @@ describe( 'Multi-block selection', () => { // Multiselect via keyboard await page.click( 'body' ); - await pressWithModifier( 'Mod', 'a' ); + await pressWithModifier( META_KEY, 'a' ); // Verify selection await expectMultiSelected( blocks, true ); @@ -72,8 +73,8 @@ describe( 'Multi-block selection', () => { // Select all via double shortcut. await page.click( firstBlockSelector ); - await pressWithModifier( 'Mod', 'a' ); - await pressWithModifier( 'Mod', 'a' ); + await pressWithModifier( META_KEY, 'a' ); + await pressWithModifier( META_KEY, 'a' ); await expectMultiSelected( blocks, true ); } ); } ); diff --git a/test/e2e/specs/shortcut-help.test.js b/test/e2e/specs/shortcut-help.test.js new file mode 100644 index 00000000000000..b7f7ea7491fd22 --- /dev/null +++ b/test/e2e/specs/shortcut-help.test.js @@ -0,0 +1,40 @@ +/** + * Internal dependencies + */ +import { + newPost, + clickOnMoreMenuItem, + clickOnCloseModalButton, + pressWithModifier, + ACCESS_MODIFIER_KEYS, +} from '../support/utils'; + +describe( 'keyboard shortcut help modal', () => { + beforeAll( async () => { + await newPost(); + } ); + + it( 'displays the shortcut help modal when opened using the menu item in the more menu', async () => { + await clickOnMoreMenuItem( 'Keyboard Shortcuts' ); + const shortcutHelpModalElements = await page.$$( '.edit-post-keyboard-shortcut-help' ); + expect( shortcutHelpModalElements ).toHaveLength( 1 ); + } ); + + it( 'closes the shortcut help modal when the close icon is clicked', async () => { + await clickOnCloseModalButton(); + const shortcutHelpModalElements = await page.$$( '.edit-post-keyboard-shortcut-help' ); + expect( shortcutHelpModalElements ).toHaveLength( 0 ); + } ); + + it( 'displays the shortcut help modal when opened using the shortcut key (access+h)', async () => { + await pressWithModifier( ACCESS_MODIFIER_KEYS, 'h' ); + const shortcutHelpModalElements = await page.$$( '.edit-post-keyboard-shortcut-help' ); + expect( shortcutHelpModalElements ).toHaveLength( 1 ); + } ); + + it( 'closes the shortcut help modal when the shortcut key (access+h) is pressed again', async () => { + await pressWithModifier( ACCESS_MODIFIER_KEYS, 'h' ); + const shortcutHelpModalElements = await page.$$( '.edit-post-keyboard-shortcut-help' ); + expect( shortcutHelpModalElements ).toHaveLength( 0 ); + } ); +} ); diff --git a/test/e2e/specs/splitting-merging.test.js b/test/e2e/specs/splitting-merging.test.js index d8bdc29de618e1..2a1bf6337e01e5 100644 --- a/test/e2e/specs/splitting-merging.test.js +++ b/test/e2e/specs/splitting-merging.test.js @@ -7,6 +7,7 @@ import { getEditedPostContent, pressTimes, pressWithModifier, + META_KEY, } from '../support/utils'; describe( 'splitting and merging blocks', () => { @@ -43,7 +44,7 @@ describe( 'splitting and merging blocks', () => { await page.keyboard.down( 'Shift' ); await pressTimes( 'ArrowRight', 5 ); await page.keyboard.up( 'Shift' ); - await pressWithModifier( 'mod', 'b' ); + await pressWithModifier( META_KEY, 'b' ); // Collapse selection, still within inline boundary. await page.keyboard.press( 'ArrowRight' ); await page.keyboard.press( 'Enter' ); @@ -56,7 +57,7 @@ describe( 'splitting and merging blocks', () => { // Regression Test: Caret should reset to end of inline boundary when // backspacing to delete second paragraph. await insertBlock( 'Paragraph' ); - await pressWithModifier( 'mod', 'b' ); + await pressWithModifier( META_KEY, 'b' ); await page.keyboard.type( 'Foo' ); await page.keyboard.press( 'Enter' ); await page.keyboard.press( 'Backspace' ); @@ -118,7 +119,7 @@ describe( 'splitting and merging blocks', () => { await page.keyboard.down( 'Shift' ); await pressTimes( 'ArrowLeft', 3 ); await page.keyboard.up( 'Shift' ); - await pressWithModifier( 'mod', 'b' ); + await pressWithModifier( META_KEY, 'b' ); await page.keyboard.press( 'ArrowRight' ); await page.keyboard.press( 'Enter' ); await page.keyboard.press( 'Enter' ); diff --git a/test/e2e/specs/undo.test.js b/test/e2e/specs/undo.test.js index 0c2eb1357695d1..0b91359fadbb83 100644 --- a/test/e2e/specs/undo.test.js +++ b/test/e2e/specs/undo.test.js @@ -6,6 +6,7 @@ import { getEditedPostContent, newPost, pressWithModifier, + META_KEY, } from '../support/utils'; describe( 'undo', () => { @@ -24,12 +25,12 @@ describe( 'undo', () => { expect( await getEditedPostContent() ).toMatchSnapshot(); - await pressWithModifier( 'mod', 'z' ); // Undo 3rd paragraph text. - await pressWithModifier( 'mod', 'z' ); // Undo 3rd block. - await pressWithModifier( 'mod', 'z' ); // Undo 2nd paragraph text. - await pressWithModifier( 'mod', 'z' ); // Undo 2nd block. - await pressWithModifier( 'mod', 'z' ); // Undo 1st paragraph text. - await pressWithModifier( 'mod', 'z' ); // Undo 1st block. + await pressWithModifier( META_KEY, 'z' ); // Undo 3rd paragraph text. + await pressWithModifier( META_KEY, 'z' ); // Undo 3rd block. + await pressWithModifier( META_KEY, 'z' ); // Undo 2nd paragraph text. + await pressWithModifier( META_KEY, 'z' ); // Undo 2nd block. + await pressWithModifier( META_KEY, 'z' ); // Undo 1st paragraph text. + await pressWithModifier( META_KEY, 'z' ); // Undo 1st block. // After undoing every action, there should be no more undo history. await page.waitForSelector( '.editor-history__undo:disabled' ); diff --git a/test/e2e/specs/writing-flow.test.js b/test/e2e/specs/writing-flow.test.js index d28b484be9e079..cd598e1ec93b1b 100644 --- a/test/e2e/specs/writing-flow.test.js +++ b/test/e2e/specs/writing-flow.test.js @@ -7,6 +7,7 @@ import { newPost, pressTimes, pressWithModifier, + META_KEY, } from '../support/utils'; describe( 'adding blocks', () => { @@ -88,7 +89,7 @@ describe( 'adding blocks', () => { await page.keyboard.down( 'Shift' ); await pressTimes( 'ArrowLeft', 6 ); await page.keyboard.up( 'Shift' ); - await pressWithModifier( 'mod', 'b' ); + await pressWithModifier( META_KEY, 'b' ); // Arrow left from selected bold should collapse to before the inline // boundary. Arrow once more to traverse into first paragraph. @@ -145,7 +146,7 @@ describe( 'adding blocks', () => { // Ensure no zero-width space character. Notably, this can occur when // save occurs while at an inline boundary edge. await clickBlockAppender(); - await pressWithModifier( 'mod', 'b' ); + await pressWithModifier( META_KEY, 'b' ); expect( await getEditedPostContent() ).toMatchSnapshot(); // When returning to Visual mode, backspace in selected block should @@ -154,7 +155,7 @@ describe( 'adding blocks', () => { // Ensure no data-mce-selected. Notably, this can occur when content // is saved while typing within an inline boundary. - await pressWithModifier( 'mod', 'b' ); + await pressWithModifier( META_KEY, 'b' ); await page.keyboard.type( 'Inside' ); expect( await getEditedPostContent() ).toMatchSnapshot(); } ); diff --git a/test/e2e/support/utils.js b/test/e2e/support/utils.js index 685253a9c72086..2959e64548d84c 100644 --- a/test/e2e/support/utils.js +++ b/test/e2e/support/utils.js @@ -7,7 +7,7 @@ import { URL } from 'url'; /** * External dependencies */ -import { times } from 'lodash'; +import { times, castArray } from 'lodash'; const { WP_BASE_URL = 'http://localhost:8889', @@ -16,13 +16,22 @@ const { } = process.env; /** - * Platform-specific modifier key. + * Platform-specific meta key. * * @see pressWithModifier * * @type {string} */ -const MOD_KEY = process.platform === 'darwin' ? 'Meta' : 'Control'; +export const META_KEY = process.platform === 'darwin' ? 'Meta' : 'Control'; + +/** + * Platform-specific modifier for the access key chord. + * + * @see pressWithModifier + * + * @type {string} + */ +export const ACCESS_MODIFIER_KEYS = process.platform === 'darwin' ? [ 'Control', 'Alt' ] : [ 'Shift', 'Alt' ]; /** * Regular expression matching zero-width space characters. @@ -235,19 +244,21 @@ export async function insertBlock( searchTerm, panelName = null ) { * Performs a key press with modifier (Shift, Control, Meta, Mod), where "Mod" * is normalized to platform-specific modifier (Meta in MacOS, else Control). * - * @param {string} modifier Modifier key. - * @param {string} key Key to press while modifier held. - * - * @return {Promise} Promise resolving when key combination pressed. + * @param {string|Array} modifiers Modifier key or array of modifier keys. + * @param {string} key Key to press while modifier held. */ -export async function pressWithModifier( modifier, key ) { - if ( modifier.toLowerCase() === 'mod' ) { - modifier = MOD_KEY; - } +export async function pressWithModifier( modifiers, key ) { + const modifierKeys = castArray( modifiers ); + + await Promise.all( + modifierKeys.map( async ( modifier ) => page.keyboard.down( modifier ) ) + ); - await page.keyboard.down( modifier ); await page.keyboard.press( key ); - return page.keyboard.up( modifier ); + + await Promise.all( + modifierKeys.map( async ( modifier ) => page.keyboard.up( modifier ) ) + ); } /** @@ -337,3 +348,22 @@ async function acceptPageDialog( dialog ) { export function enablePageDialogAccept() { page.on( 'dialog', acceptPageDialog ); } + +/** + * Click on the close button of an open modal. + * + * @param {?string} modalClassName Class name for the modal to close + */ +export async function clickOnCloseModalButton( modalClassName ) { + let closeButtonClassName = '.components-modal__header .components-icon-button'; + + if ( modalClassName ) { + closeButtonClassName = `${ modalClassName } ${ closeButtonClassName }`; + } + + const closeButton = await page.$( closeButtonClassName ); + + if ( closeButton ) { + await page.click( closeButtonClassName ); + } +}