From fba10690986a76873667f7878abd4162d2860485 Mon Sep 17 00:00:00 2001 From: Riad Benguella Date: Mon, 23 Dec 2019 09:26:07 +0100 Subject: [PATCH] Add a new keyboard shortcut package (#19100) --- bin/api-docs/packages.js | 1 + .../developers/data/data-core-block-editor.md | 24 ++ .../data/data-core-keyboard-shortcuts.md | 79 +++++++ docs/manifest-devhub.json | 6 + docs/tool/packages.js | 1 + package-lock.json | 12 + package.json | 1 + packages/block-editor/package.json | 1 + .../src/components/block-actions/index.js | 46 +--- .../block-editor-keyboard-shortcuts/index.js | 156 ------------- .../components/block-settings-menu/index.js | 30 ++- packages/block-editor/src/components/index.js | 2 +- .../components/keyboard-shortcuts/index.js | 163 +++++++++++++ packages/block-editor/src/index.js | 1 + packages/block-editor/src/store/actions.js | 92 +++++++- .../block-editor/src/store/test/actions.js | 6 + .../editor/various/shortcut-help.test.js | 8 +- .../keyboard-shortcut-help-modal/config.js | 215 +++++++----------- .../dynamic-shortcut.js | 38 ++++ .../keyboard-shortcut-help-modal/index.js | 86 +++---- .../keyboard-shortcut-help-modal/shortcut.js | 43 ++++ .../keyboard-shortcut-help-modal/style.scss | 8 +- .../test/__snapshots__/index.js.snap | 68 +----- .../edit-post/src/components/layout/index.js | 2 + packages/keyboard-shortcuts/.npmrc | 0 packages/keyboard-shortcuts/CHANGELOG.md | 3 + packages/keyboard-shortcuts/README.md | 32 +++ packages/keyboard-shortcuts/package.json | 34 +++ .../src/hooks/use-shortcut.js | 88 +++++++ packages/keyboard-shortcuts/src/index.js | 6 + .../keyboard-shortcuts/src/store/actions.js | 52 +++++ .../keyboard-shortcuts/src/store/index.js | 17 ++ .../keyboard-shortcuts/src/store/reducer.js | 33 +++ .../keyboard-shortcuts/src/store/selectors.js | 47 ++++ 34 files changed, 956 insertions(+), 445 deletions(-) create mode 100644 docs/designers-developers/developers/data/data-core-keyboard-shortcuts.md delete mode 100644 packages/block-editor/src/components/block-editor-keyboard-shortcuts/index.js create mode 100644 packages/block-editor/src/components/keyboard-shortcuts/index.js create mode 100644 packages/edit-post/src/components/keyboard-shortcut-help-modal/dynamic-shortcut.js create mode 100644 packages/edit-post/src/components/keyboard-shortcut-help-modal/shortcut.js create mode 100644 packages/keyboard-shortcuts/.npmrc create mode 100644 packages/keyboard-shortcuts/CHANGELOG.md create mode 100644 packages/keyboard-shortcuts/README.md create mode 100644 packages/keyboard-shortcuts/package.json create mode 100644 packages/keyboard-shortcuts/src/hooks/use-shortcut.js create mode 100644 packages/keyboard-shortcuts/src/index.js create mode 100644 packages/keyboard-shortcuts/src/store/actions.js create mode 100644 packages/keyboard-shortcuts/src/store/index.js create mode 100644 packages/keyboard-shortcuts/src/store/reducer.js create mode 100644 packages/keyboard-shortcuts/src/store/selectors.js diff --git a/bin/api-docs/packages.js b/bin/api-docs/packages.js index 0fcc19400bf115..ff96517799bf9f 100644 --- a/bin/api-docs/packages.js +++ b/bin/api-docs/packages.js @@ -23,6 +23,7 @@ const packages = [ 'escape-html', 'html-entities', 'i18n', + 'keyboard-shortcuts', 'keycodes', 'plugins', 'priority-queue', diff --git a/docs/designers-developers/developers/data/data-core-block-editor.md b/docs/designers-developers/developers/data/data-core-block-editor.md index b88bccc641bf74..10d031d436d50c 100644 --- a/docs/designers-developers/developers/data/data-core-block-editor.md +++ b/docs/designers-developers/developers/data/data-core-block-editor.md @@ -892,6 +892,14 @@ _Returns_ - `Object`: Action object. +# **duplicateBlocks** + +Generator that triggers an action used to duplicate a list of blocks. + +_Parameters_ + +- _clientIds_ `Array`: + # **enterFormattedText** Returns an action object used in signalling that the caret has entered formatted text. @@ -916,6 +924,22 @@ _Returns_ - `Object`: Action object. +# **insertAfterBlock** + +Generator used to insert an empty block before a given block. + +_Parameters_ + +- _clientId_ `string`: + +# **insertBeforeBlock** + +Generator used to insert an empty block after a given block. + +_Parameters_ + +- _clientId_ `string`: + # **insertBlock** Returns an action object used in signalling that a single block should be diff --git a/docs/designers-developers/developers/data/data-core-keyboard-shortcuts.md b/docs/designers-developers/developers/data/data-core-keyboard-shortcuts.md new file mode 100644 index 00000000000000..76d694cc5a4159 --- /dev/null +++ b/docs/designers-developers/developers/data/data-core-keyboard-shortcuts.md @@ -0,0 +1,79 @@ +# The Keyboard Shortcuts Data + +Namespace: `core/keyboard-shortcuts`. + +## Selectors + + + +# **getShortcutAliases** + +Returns the aliases for a given shortcut name. + +_Parameters_ + +- _state_ `Object`: Global state. +- _name_ `string`: Shortcut name. + +_Returns_ + +- `Array`: Key combinations. + +# **getShortcutDescription** + +Returns the shortcut description given its name. + +_Parameters_ + +- _state_ `Object`: Global state. +- _name_ `string`: Shortcut name. + +_Returns_ + +- `?string`: Shortcut description. + +# **getShortcutKeyCombination** + +Returns the main key combination for a given shortcut name. + +_Parameters_ + +- _state_ `Object`: Global state. +- _name_ `string`: Shortcut name. + +_Returns_ + +- `?WPShortcutKeyCombination`: Key combination. + + + + +## Actions + + + +# **registerShortcut** + +Returns an action object used to register a new keyboard shortcut. + +_Parameters_ + +- _config_ `WPShortcutConfig`: Shortcut config. + +_Returns_ + +- `Object`: action. + +# **unregisterShortcut** + +Returns an action object used to unregister a keyboard shortcut. + +_Parameters_ + +- _name_ `string`: Shortcut name. + +_Returns_ + +- `Object`: action. + + diff --git a/docs/manifest-devhub.json b/docs/manifest-devhub.json index a39279c1ae8736..4aa9e183b6d36d 100644 --- a/docs/manifest-devhub.json +++ b/docs/manifest-devhub.json @@ -1355,6 +1355,12 @@ "markdown_source": "../packages/jest-puppeteer-axe/README.md", "parent": "packages" }, + { + "title": "@wordpress/keyboard-shortcuts", + "slug": "packages-keyboard-shortcuts", + "markdown_source": "../packages/keyboard-shortcuts/README.md", + "parent": "packages" + }, { "title": "@wordpress/keycodes", "slug": "packages-keycodes", diff --git a/docs/tool/packages.js b/docs/tool/packages.js index 5caf4d258e1567..57d3d42b104b16 100644 --- a/docs/tool/packages.js +++ b/docs/tool/packages.js @@ -8,6 +8,7 @@ const packages = [ 'core/block-editor', 'core/editor', 'core/edit-post', + 'core/keyboard-shortcuts', 'core/notices', 'core/nux', 'core/viewport', diff --git a/package-lock.json b/package-lock.json index 095e45821a4598..0c7e5790f2bb60 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7601,6 +7601,7 @@ "@wordpress/html-entities": "file:packages/html-entities", "@wordpress/i18n": "file:packages/i18n", "@wordpress/is-shallow-equal": "file:packages/is-shallow-equal", + "@wordpress/keyboard-shortcuts": "file:packages/keyboard-shortcuts", "@wordpress/keycodes": "file:packages/keycodes", "@wordpress/rich-text": "file:packages/rich-text", "@wordpress/token-list": "file:packages/token-list", @@ -8789,6 +8790,17 @@ "axe-puppeteer": "^1.0.0" } }, + "@wordpress/keyboard-shortcuts": { + "version": "file:packages/keyboard-shortcuts", + "requires": { + "@babel/runtime": "^7.4.4", + "@wordpress/data": "file:packages/data", + "@wordpress/element": "file:packages/element", + "@wordpress/keycodes": "file:packages/keycodes", + "lodash": "^4.17.15", + "mousetrap": "^1.6.2" + } + }, "@wordpress/keycodes": { "version": "file:packages/keycodes", "requires": { diff --git a/package.json b/package.json index 4f9fb52d34481e..2b5fce99c856b1 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "@wordpress/html-entities": "file:packages/html-entities", "@wordpress/i18n": "file:packages/i18n", "@wordpress/is-shallow-equal": "file:packages/is-shallow-equal", + "@wordpress/keyboard-shortcuts": "file:packages/keyboard-shortcuts", "@wordpress/keycodes": "file:packages/keycodes", "@wordpress/list-reusable-blocks": "file:packages/list-reusable-blocks", "@wordpress/media-utils": "file:packages/media-utils", diff --git a/packages/block-editor/package.json b/packages/block-editor/package.json index 02186cbf66cd76..2bd84a84dc6aae 100644 --- a/packages/block-editor/package.json +++ b/packages/block-editor/package.json @@ -36,6 +36,7 @@ "@wordpress/html-entities": "file:../html-entities", "@wordpress/i18n": "file:../i18n", "@wordpress/is-shallow-equal": "file:../is-shallow-equal", + "@wordpress/keyboard-shortcuts": "file:../keyboard-shortcuts", "@wordpress/keycodes": "file:../keycodes", "@wordpress/rich-text": "file:../rich-text", "@wordpress/token-list": "file:../token-list", diff --git a/packages/block-editor/src/components/block-actions/index.js b/packages/block-editor/src/components/block-actions/index.js index 82f12e6e256048..afc69d7e5273ac 100644 --- a/packages/block-editor/src/components/block-actions/index.js +++ b/packages/block-editor/src/components/block-actions/index.js @@ -8,7 +8,7 @@ import { castArray, first, last, every } from 'lodash'; */ import { compose } from '@wordpress/compose'; import { withSelect, withDispatch } from '@wordpress/data'; -import { cloneBlock, hasBlockSupport, switchToBlockType } from '@wordpress/blocks'; +import { hasBlockSupport, switchToBlockType } from '@wordpress/blocks'; function BlockActions( { canDuplicate, @@ -72,59 +72,29 @@ export default compose( [ withDispatch( ( dispatch, props, { select } ) => { const { clientIds, - rootClientId, blocks, - isLocked, - canDuplicate, } = props; const { - insertBlocks, - multiSelect, removeBlocks, - insertDefaultBlock, replaceBlocks, + duplicateBlocks, + insertAfterBlock, + insertBeforeBlock, } = dispatch( 'core/block-editor' ); return { onDuplicate() { - if ( ! canDuplicate ) { - return; - } - - const { getBlockIndex } = select( 'core/block-editor' ); - const lastSelectedIndex = getBlockIndex( last( castArray( clientIds ) ), rootClientId ); - const clonedBlocks = blocks.map( ( block ) => cloneBlock( block ) ); - insertBlocks( - clonedBlocks, - lastSelectedIndex + 1, - rootClientId - ); - if ( clonedBlocks.length > 1 ) { - multiSelect( - first( clonedBlocks ).clientId, - last( clonedBlocks ).clientId - ); - } + return duplicateBlocks( clientIds ); }, onRemove() { - if ( ! isLocked ) { - removeBlocks( clientIds ); - } + removeBlocks( clientIds ); }, onInsertBefore() { - if ( ! isLocked ) { - const { getBlockIndex } = select( 'core/block-editor' ); - const firstSelectedIndex = getBlockIndex( first( castArray( clientIds ) ), rootClientId ); - insertDefaultBlock( {}, rootClientId, firstSelectedIndex ); - } + insertBeforeBlock( first( castArray( clientIds ) ) ); }, onInsertAfter() { - if ( ! isLocked ) { - const { getBlockIndex } = select( 'core/block-editor' ); - const lastSelectedIndex = getBlockIndex( last( castArray( clientIds ) ), rootClientId ); - insertDefaultBlock( {}, rootClientId, lastSelectedIndex + 1 ); - } + insertAfterBlock( last( castArray( clientIds ) ) ); }, onGroup() { if ( ! blocks.length ) { diff --git a/packages/block-editor/src/components/block-editor-keyboard-shortcuts/index.js b/packages/block-editor/src/components/block-editor-keyboard-shortcuts/index.js deleted file mode 100644 index 7e9a433182eba6..00000000000000 --- a/packages/block-editor/src/components/block-editor-keyboard-shortcuts/index.js +++ /dev/null @@ -1,156 +0,0 @@ -/** - * External dependencies - */ -import { first, last, some, flow } from 'lodash'; - -/** - * WordPress dependencies - */ -import { Component } from '@wordpress/element'; -import { KeyboardShortcuts } from '@wordpress/components'; -import { withSelect, withDispatch } from '@wordpress/data'; -import { rawShortcut, displayShortcut } from '@wordpress/keycodes'; -import { compose } from '@wordpress/compose'; - -/** - * Internal dependencies - */ -import BlockActions from '../block-actions'; - -const preventDefault = ( event ) => { - event.preventDefault(); - return event; -}; - -export const shortcuts = { - duplicate: { - raw: rawShortcut.primaryShift( 'd' ), - display: displayShortcut.primaryShift( 'd' ), - }, - removeBlock: { - raw: rawShortcut.access( 'z' ), - display: displayShortcut.access( 'z' ), - }, - insertBefore: { - raw: rawShortcut.primaryAlt( 't' ), - display: displayShortcut.primaryAlt( 't' ), - }, - insertAfter: { - raw: rawShortcut.primaryAlt( 'y' ), - display: displayShortcut.primaryAlt( 'y' ), - }, -}; - -class BlockEditorKeyboardShortcuts extends Component { - constructor() { - super( ...arguments ); - - this.selectAll = this.selectAll.bind( this ); - this.deleteSelectedBlocks = this.deleteSelectedBlocks.bind( this ); - this.clearMultiSelection = this.clearMultiSelection.bind( this ); - } - - selectAll( event ) { - const { rootBlocksClientIds, onMultiSelect } = this.props; - event.preventDefault(); - onMultiSelect( first( rootBlocksClientIds ), last( rootBlocksClientIds ) ); - } - - deleteSelectedBlocks( event ) { - const { selectedBlockClientIds, hasMultiSelection, onRemove, isLocked } = this.props; - if ( hasMultiSelection ) { - event.preventDefault(); - if ( ! isLocked ) { - onRemove( selectedBlockClientIds ); - } - } - } - - /** - * Clears current multi-selection, if one exists. - */ - clearMultiSelection() { - const { hasMultiSelection, clearSelectedBlock } = this.props; - if ( hasMultiSelection ) { - clearSelectedBlock(); - window.getSelection().removeAllRanges(); - } - } - - render() { - const { selectedBlockClientIds } = this.props; - return ( - <> - - { selectedBlockClientIds.length > 0 && ( - - { ( { onDuplicate, onRemove, onInsertAfter, onInsertBefore } ) => ( - - ) } - - ) } - - ); - } -} - -export default compose( [ - withSelect( ( select ) => { - const { - getBlockOrder, - getSelectedBlockClientIds, - hasMultiSelection, - getBlockRootClientId, - getTemplateLock, - } = select( 'core/block-editor' ); - const selectedBlockClientIds = getSelectedBlockClientIds(); - - return { - rootBlocksClientIds: getBlockOrder(), - hasMultiSelection: hasMultiSelection(), - isLocked: some( - selectedBlockClientIds, - ( clientId ) => !! getTemplateLock( getBlockRootClientId( clientId ) ) - ), - selectedBlockClientIds, - }; - } ), - withDispatch( ( dispatch ) => { - const { - clearSelectedBlock, - multiSelect, - removeBlocks, - } = dispatch( 'core/block-editor' ); - - return { - clearSelectedBlock, - onMultiSelect: multiSelect, - onRemove: removeBlocks, - }; - } ), -] )( BlockEditorKeyboardShortcuts ); diff --git a/packages/block-editor/src/components/block-settings-menu/index.js b/packages/block-editor/src/components/block-settings-menu/index.js index 2d89d5f4e39e63..1f15f301510f2b 100644 --- a/packages/block-editor/src/components/block-settings-menu/index.js +++ b/packages/block-editor/src/components/block-settings-menu/index.js @@ -13,11 +13,12 @@ import { MenuGroup, MenuItem, } from '@wordpress/components'; +import { useSelect } from '@wordpress/data'; +import { displayShortcut } from '@wordpress/keycodes'; /** * Internal dependencies */ -import { shortcuts } from '../block-editor-keyboard-shortcuts'; import BlockActions from '../block-actions'; import BlockModeToggle from './block-mode-toggle'; import BlockHTMLConvertButton from './block-html-convert-button'; @@ -35,6 +36,25 @@ export function BlockSettingsMenu( { clientIds } ) { const count = blockClientIds.length; const firstBlockClientId = blockClientIds[ 0 ]; + const shortcuts = useSelect( ( select ) => { + const { getShortcutKeyCombination } = select( 'core/keyboard-shortcuts' ); + return { + duplicate: getShortcutKeyCombination( 'core/block-editor/duplicate' ), + remove: getShortcutKeyCombination( 'core/block-editor/remove' ), + insertAfter: getShortcutKeyCombination( 'core/block-editor/insert-after' ), + insertBefore: getShortcutKeyCombination( 'core/block-editor/insert-before' ), + }; + }, [] ); + + const getShortcutDisplay = ( shortcut ) => { + if ( ! shortcut ) { + return null; + } + return shortcut.modifier ? + displayShortcut[ shortcut.modifier ]( shortcut.character ) : + shortcut.character; + }; + return ( { ( { @@ -74,7 +94,7 @@ export function BlockSettingsMenu( { clientIds } ) { className="block-editor-block-settings-menu__control" onClick={ flow( onClose, onDuplicate ) } icon="admin-page" - shortcut={ shortcuts.duplicate.display } + shortcut={ getShortcutDisplay( shortcuts.duplicate ) } > { __( 'Duplicate' ) } @@ -85,7 +105,7 @@ export function BlockSettingsMenu( { clientIds } ) { className="block-editor-block-settings-menu__control" onClick={ flow( onClose, onInsertBefore ) } icon="insert-before" - shortcut={ shortcuts.insertBefore.display } + shortcut={ getShortcutDisplay( shortcuts.insertBefore ) } > { __( 'Insert Before' ) } @@ -93,7 +113,7 @@ export function BlockSettingsMenu( { clientIds } ) { className="block-editor-block-settings-menu__control" onClick={ flow( onClose, onInsertAfter ) } icon="insert-after" - shortcut={ shortcuts.insertAfter.display } + shortcut={ getShortcutDisplay( shortcuts.insertAfter ) } > { __( 'Insert After' ) } @@ -115,7 +135,7 @@ export function BlockSettingsMenu( { clientIds } ) { className="block-editor-block-settings-menu__control" onClick={ flow( onClose, onRemove ) } icon="trash" - shortcut={ shortcuts.removeBlock.display } + shortcut={ getShortcutDisplay( shortcuts.remove ) } > { _n( 'Remove Block', 'Remove Blocks', count ) } diff --git a/packages/block-editor/src/components/index.js b/packages/block-editor/src/components/index.js index ceb76d13bab678..3a5bc7692f6408 100644 --- a/packages/block-editor/src/components/index.js +++ b/packages/block-editor/src/components/index.js @@ -63,7 +63,6 @@ export { __experimentalWithPageTemplatePickerVisible, __experimentalUsePageTemplatePickerVisible, } from './page-template-picker'; -export { default as BlockEditorKeyboardShortcuts } from './block-editor-keyboard-shortcuts'; export { default as BlockInspector } from './block-inspector'; export { default as BlockList } from './block-list'; export { default as BlockMover } from './block-mover'; @@ -76,6 +75,7 @@ export { default as CopyHandler } from './copy-handler'; export { default as DefaultBlockAppender } from './default-block-appender'; export { default as Inserter } from './inserter'; export { default as MultiBlocksSwitcher } from './block-switcher/multi-blocks-switcher'; +export { default as BlockEditorKeyboardShortcuts } from './keyboard-shortcuts'; export { default as MultiSelectScrollIntoView } from './multi-select-scroll-into-view'; export { default as NavigableToolbar } from './navigable-toolbar'; export { default as ObserveTyping } from './observe-typing'; diff --git a/packages/block-editor/src/components/keyboard-shortcuts/index.js b/packages/block-editor/src/components/keyboard-shortcuts/index.js new file mode 100644 index 00000000000000..12b4f3e1a79c92 --- /dev/null +++ b/packages/block-editor/src/components/keyboard-shortcuts/index.js @@ -0,0 +1,163 @@ +/** + * External dependencies + */ +import { first, last } from 'lodash'; +/** + * WordPress dependencies + */ +import { useEffect, useCallback } from '@wordpress/element'; +import { useDispatch, useSelect } from '@wordpress/data'; +import { useShortcut } from '@wordpress/keyboard-shortcuts'; +import { __ } from '@wordpress/i18n'; + +function KeyboardShortcuts() { + // Shortcuts Logic + const { clientIds, rootBlocksClientIds } = useSelect( ( select ) => { + const { getSelectedBlockClientIds, getBlockOrder } = select( 'core/block-editor' ); + return { + clientIds: getSelectedBlockClientIds(), + rootBlocksClientIds: getBlockOrder(), + }; + }, [] ); + + const { + duplicateBlocks, + removeBlocks, + insertAfterBlock, + insertBeforeBlock, + multiSelect, + clearSelectedBlock, + } = useDispatch( 'core/block-editor' ); + + // Prevents bookmark all Tabs shortcut in Chrome when devtools are closed. + // Prevents reposition Chrome devtools pane shortcut when devtools are open. + useShortcut( 'core/block-editor/duplicate', useCallback( ( event ) => { + event.preventDefault(); + duplicateBlocks( clientIds ); + }, [ clientIds, duplicateBlocks ] ), { bindGlobal: true } ); + + // Does not clash with any known browser/native shortcuts, but preventDefault + // is used to prevent any obscure unknown shortcuts from triggering. + useShortcut( 'core/block-editor/remove', useCallback( ( event ) => { + event.preventDefault(); + removeBlocks( clientIds ); + }, [ clientIds, removeBlocks ] ), { bindGlobal: true } ); + + // Does not clash with any known browser/native shortcuts, but preventDefault + // is used to prevent any obscure unknown shortcuts from triggering. + useShortcut( 'core/block-editor/insert-after', useCallback( ( event ) => { + event.preventDefault(); + insertAfterBlock( last( clientIds ) ); + }, [ clientIds, insertAfterBlock ] ), { bindGlobal: true } ); + + // Prevent 'view recently closed tabs' in Opera using preventDefault. + useShortcut( 'core/block-editor/insert-before', useCallback( ( event ) => { + event.preventDefault(); + insertBeforeBlock( first( clientIds ) ); + }, [ clientIds, insertBeforeBlock ] ), { bindGlobal: true } ); + + useShortcut( 'core/block-editor/delete-multi-selection', useCallback( ( event ) => { + if ( clientIds.length > 0 ) { + event.preventDefault(); + removeBlocks( clientIds ); + } + }, [ clientIds, removeBlocks ] ) ); + + useShortcut( 'core/block-editor/select-all', useCallback( ( event ) => { + event.preventDefault(); + multiSelect( first( rootBlocksClientIds ), last( rootBlocksClientIds ) ); + }, [ rootBlocksClientIds, multiSelect ] ) ); + + useShortcut( 'core/block-editor/unselect', useCallback( ( event ) => { + if ( clientIds.length > 1 ) { + event.preventDefault(); + clearSelectedBlock(); + window.getSelection().removeAllRanges(); + } + }, [ clientIds, clearSelectedBlock ] ) ); + + return null; +} + +function KeyboardShortcutsRegister() { + // Registering the shortcuts + const { registerShortcut } = useDispatch( 'core/keyboard-shortcuts' ); + useEffect( () => { + registerShortcut( { + name: 'core/block-editor/duplicate', + category: 'block', + description: __( 'Duplicate the selected block(s).' ), + keyCombination: { + modifier: 'primaryShift', + character: 'd', + }, + } ); + + registerShortcut( { + name: 'core/block-editor/remove', + category: 'block', + description: __( 'Remove the selected block(s).' ), + keyCombination: { + modifier: 'access', + character: 'z', + }, + } ); + + registerShortcut( { + name: 'core/block-editor/insert-before', + category: 'block', + description: __( 'Insert a new block before the selected block(s).' ), + keyCombination: { + modifier: 'primaryAlt', + character: 't', + }, + } ); + + registerShortcut( { + name: 'core/block-editor/insert-after', + category: 'block', + description: __( 'Insert a new block after the selected block(s).' ), + keyCombination: { + modifier: 'primaryAlt', + character: 'y', + }, + } ); + + registerShortcut( { + name: 'core/block-editor/delete-multi-selection', + category: 'block', + description: __( 'Remove multiple selected blocks.' ), + keyCombination: { + character: 'del', + }, + aliases: [ { + character: 'backspace', + } ], + } ); + + registerShortcut( { + name: 'core/block-editor/select-all', + category: 'selection', + description: __( 'Select all text when typing. Press again to select all blocks.' ), + keyCombination: { + modifier: 'primary', + character: 'a', + }, + } ); + + registerShortcut( { + name: 'core/block-editor/unselect', + category: 'selections', + description: __( 'Clear selection.' ), + keyCombination: { + character: 'escape', + }, + } ); + }, [ registerShortcut ] ); + + return null; +} + +KeyboardShortcuts.Register = KeyboardShortcutsRegister; + +export default KeyboardShortcuts; diff --git a/packages/block-editor/src/index.js b/packages/block-editor/src/index.js index 85b2af41e2e064..85bf3895156723 100644 --- a/packages/block-editor/src/index.js +++ b/packages/block-editor/src/index.js @@ -4,6 +4,7 @@ import '@wordpress/blocks'; import '@wordpress/rich-text'; import '@wordpress/viewport'; +import '@wordpress/keyboard-shortcuts'; /** * Internal dependencies diff --git a/packages/block-editor/src/store/actions.js b/packages/block-editor/src/store/actions.js index 933a5e80064b33..4ab68705d2845e 100644 --- a/packages/block-editor/src/store/actions.js +++ b/packages/block-editor/src/store/actions.js @@ -1,12 +1,12 @@ /** * External dependencies */ -import { castArray, first, get, includes } from 'lodash'; +import { castArray, first, get, includes, last, some } from 'lodash'; /** * WordPress dependencies */ -import { getDefaultBlockName, createBlock } from '@wordpress/blocks'; +import { getDefaultBlockName, createBlock, hasBlockSupport, cloneBlock } from '@wordpress/blocks'; import { speak } from '@wordpress/a11y'; import { __ } from '@wordpress/i18n'; @@ -570,7 +570,16 @@ export function mergeBlocks( firstBlockClientId, secondBlockClientId ) { * selected when a block is removed. */ export function* removeBlocks( clientIds, selectPrevious = true ) { + if ( ! clientIds || ! clientIds.length ) { + return; + } + clientIds = castArray( clientIds ); + const rootClientId = yield select( 'core/block-editor', 'getBlockRootClientId', clientIds[ 0 ] ); + const isLocked = yield select( 'core/block-editor', 'getTemplateLock', rootClientId ); + if ( isLocked ) { + return; + } if ( selectPrevious ) { yield selectPreviousBlock( clientIds[ 0 ] ); @@ -833,3 +842,82 @@ export function * setNavigationMode( isNavigationMode = true ) { speak( __( 'You are currently in edit mode. To return to the navigation mode, press Escape.' ) ); } } + +/** + * Generator that triggers an action used to duplicate a list of blocks. + * + * @param {string[]} clientIds + */ +export function * duplicateBlocks( clientIds ) { + if ( ! clientIds && ! clientIds.length ) { + return; + } + const blocks = yield select( 'core/block-editor', 'getBlocksByClientId', clientIds ); + const rootClientId = yield select( 'core/block-editor', 'getBlockRootClientId', clientIds[ 0 ] ); + // Return early if blocks don't exist. + if ( some( blocks, ( block ) => ! block ) ) { + return; + } + const blockNames = blocks.map( ( block ) => block.name ); + // Return early if blocks don't support multipe usage. + if ( some( blockNames, ( blockName ) => ! hasBlockSupport( blockName, 'multiple', true ) ) ) { + return; + } + + const lastSelectedIndex = yield select( + 'core/block-editor', + 'getBlockIndex', + last( castArray( clientIds ) ), + rootClientId + ); + const clonedBlocks = blocks.map( ( block ) => cloneBlock( block ) ); + yield insertBlocks( + clonedBlocks, + lastSelectedIndex + 1, + rootClientId + ); + if ( clonedBlocks.length > 1 ) { + yield multiSelect( + first( clonedBlocks ).clientId, + last( clonedBlocks ).clientId + ); + } +} + +/** + * Generator used to insert an empty block after a given block. + * + * @param {string} clientId + */ +export function * insertBeforeBlock( clientId ) { + if ( ! clientId ) { + return; + } + const rootClientId = yield select( 'core/block-editor', 'getBlockRootClientId', clientId ); + const isLocked = yield select( 'core/block-editor', 'getTemplateLock', rootClientId ); + if ( isLocked ) { + return; + } + + const firstSelectedIndex = yield select( 'core/block-editor', 'getBlockIndex', clientId, rootClientId ); + yield insertDefaultBlock( {}, rootClientId, firstSelectedIndex ); +} + +/** + * Generator used to insert an empty block before a given block. + * + * @param {string} clientId + */ +export function * insertAfterBlock( clientId ) { + if ( ! clientId ) { + return; + } + const rootClientId = yield select( 'core/block-editor', 'getBlockRootClientId', clientId ); + const isLocked = yield select( 'core/block-editor', 'getTemplateLock', rootClientId ); + if ( isLocked ) { + return; + } + + const firstSelectedIndex = yield select( 'core/block-editor', 'getBlockIndex', clientId, rootClientId ); + yield insertDefaultBlock( {}, rootClientId, firstSelectedIndex + 1 ); +} diff --git a/packages/block-editor/src/store/test/actions.js b/packages/block-editor/src/store/test/actions.js index a2f6d798e9f7d5..d6e99912904dfd 100644 --- a/packages/block-editor/src/store/test/actions.js +++ b/packages/block-editor/src/store/test/actions.js @@ -621,6 +621,8 @@ describe( 'actions', () => { const actions = Array.from( removeBlocks( clientIds ) ); expect( actions ).toEqual( [ + select( 'core/block-editor', 'getBlockRootClientId', clientId ), + select( 'core/block-editor', 'getTemplateLock', undefined ), selectPreviousBlock( clientId ), { type: 'REMOVE_BLOCKS', @@ -819,6 +821,8 @@ describe( 'actions', () => { const actions = Array.from( removeBlock( clientId ) ); expect( actions ).toEqual( [ + select( 'core/block-editor', 'getBlockRootClientId', clientId ), + select( 'core/block-editor', 'getTemplateLock', undefined ), selectPreviousBlock( clientId ), { type: 'REMOVE_BLOCKS', @@ -837,6 +841,8 @@ describe( 'actions', () => { const actions = Array.from( removeBlock( clientId, false ) ); expect( actions ).toEqual( [ + select( 'core/block-editor', 'getBlockRootClientId', clientId ), + select( 'core/block-editor', 'getTemplateLock', undefined ), { type: 'REMOVE_BLOCKS', clientIds: [ clientId ], diff --git a/packages/e2e-tests/specs/editor/various/shortcut-help.test.js b/packages/e2e-tests/specs/editor/various/shortcut-help.test.js index 488c2428e5f0c3..23d51ef2c2d708 100644 --- a/packages/e2e-tests/specs/editor/various/shortcut-help.test.js +++ b/packages/e2e-tests/specs/editor/various/shortcut-help.test.js @@ -15,25 +15,25 @@ describe( 'keyboard shortcut help modal', () => { 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' ); + const shortcutHelpModalElements = await page.$$( '.edit-post-keyboard-shortcut-help-modal' ); 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' ); + const shortcutHelpModalElements = await page.$$( '.edit-post-keyboard-shortcut-help-modal' ); expect( shortcutHelpModalElements ).toHaveLength( 0 ); } ); it( 'displays the shortcut help modal when opened using the shortcut key (access+h)', async () => { await pressKeyWithModifier( 'access', 'h' ); - const shortcutHelpModalElements = await page.$$( '.edit-post-keyboard-shortcut-help' ); + const shortcutHelpModalElements = await page.$$( '.edit-post-keyboard-shortcut-help-modal' ); expect( shortcutHelpModalElements ).toHaveLength( 1 ); } ); it( 'closes the shortcut help modal when the shortcut key (access+h) is pressed again', async () => { await pressKeyWithModifier( 'access', 'h' ); - const shortcutHelpModalElements = await page.$$( '.edit-post-keyboard-shortcut-help' ); + const shortcutHelpModalElements = await page.$$( '.edit-post-keyboard-shortcut-help-modal' ); expect( shortcutHelpModalElements ).toHaveLength( 0 ); } ); } ); diff --git a/packages/edit-post/src/components/keyboard-shortcut-help-modal/config.js b/packages/edit-post/src/components/keyboard-shortcut-help-modal/config.js index cd032135e40b3a..a1336b8ccd6352 100644 --- a/packages/edit-post/src/components/keyboard-shortcut-help-modal/config.js +++ b/packages/edit-post/src/components/keyboard-shortcut-help-modal/config.js @@ -9,8 +9,6 @@ const { primary, // Shift+Cmd+ on a mac, Ctrl+Shift+ elsewhere. primaryShift, - // Option+Cmd+ on a mac, Ctrl+Alt+ elsewhere. - primaryAlt, // Shift+Alt+Cmd+ on a mac, Ctrl+Shift+Akt+ elsewhere. secondary, // Ctrl+Alt+ on a mac, Shift+Alt+ elsewhere. @@ -20,143 +18,82 @@ const { ctrlShift, } = displayShortcutList; -const mainShortcut = { - className: 'edit-post-keyboard-shortcut-help__main-shortcuts', - shortcuts: [ - { - keyCombination: access( 'h' ), - description: __( 'Display these keyboard shortcuts.' ), - }, - ], -}; - -const globalShortcuts = { - title: __( 'Global shortcuts' ), - shortcuts: [ - { - 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.' ), - ariaLabel: shortcutAriaLabel.primaryShift( ',' ), - }, - { - keyCombination: access( 'o' ), - description: __( 'Open the block navigation menu.' ), - }, - { - keyCombination: ctrl( '`' ), - description: __( 'Navigate to the next part of the editor.' ), - ariaLabel: shortcutAriaLabel.ctrl( '`' ), - }, - { - keyCombination: ctrlShift( '`' ), - description: __( 'Navigate to the previous part of the editor.' ), - ariaLabel: shortcutAriaLabel.ctrlShift( '`' ), - }, - { - keyCombination: access( 'n' ), - description: __( 'Navigate to the next part of the editor (alternative).' ), - }, - { - keyCombination: access( 'p' ), - description: __( 'Navigate to the previous part of the editor (alternative).' ), - }, - { - keyCombination: alt( 'F10' ), - description: __( 'Navigate to the nearest toolbar.' ), - }, - { - 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.' ), - /* translators: The 'escape' key on a keyboard. */ - ariaLabel: __( 'Escape' ), - }, - ], -}; - -const blockShortcuts = { - title: __( 'Block shortcuts' ), - shortcuts: [ - { - keyCombination: primaryShift( 'd' ), - description: __( 'Duplicate the selected block(s).' ), - }, - { - keyCombination: access( 'z' ), - description: __( 'Remove the selected block(s).' ), - }, - { - keyCombination: primaryAlt( 't' ), - description: __( 'Insert a new block before the selected block(s).' ), - }, - { - keyCombination: primaryAlt( 'y' ), - description: __( 'Insert a new block after the selected block(s).' ), - }, - { - keyCombination: '/', - description: __( 'Change the block type after adding a new paragraph.' ), - /* translators: The forward-slash character. e.g. '/'. */ - ariaLabel: __( 'Forward-slash' ), - }, - ], -}; +export const mainShortcuts = [ + { + keyCombination: access( 'h' ), + description: __( 'Display these keyboard shortcuts.' ), + }, +]; -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( 'k' ), - description: __( 'Convert the selected text into a link.' ), - }, - { - keyCombination: primaryShift( 'k' ), - description: __( 'Remove a link.' ), - }, - { - keyCombination: primary( 'u' ), - description: __( 'Underline the selected text.' ), - }, - ], -}; +export const globalShortcuts = [ + { + 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.' ), + ariaLabel: shortcutAriaLabel.primaryShift( ',' ), + }, + { + keyCombination: access( 'o' ), + description: __( 'Open the block navigation menu.' ), + }, + { + keyCombination: ctrl( '`' ), + description: __( 'Navigate to the next part of the editor.' ), + ariaLabel: shortcutAriaLabel.ctrl( '`' ), + }, + { + keyCombination: ctrlShift( '`' ), + description: __( 'Navigate to the previous part of the editor.' ), + ariaLabel: shortcutAriaLabel.ctrlShift( '`' ), + }, + { + keyCombination: access( 'n' ), + description: __( 'Navigate to the next part of the editor (alternative).' ), + }, + { + keyCombination: access( 'p' ), + description: __( 'Navigate to the previous part of the editor (alternative).' ), + }, + { + keyCombination: alt( 'F10' ), + description: __( 'Navigate to the nearest toolbar.' ), + }, + { + keyCombination: secondary( 'm' ), + description: __( 'Switch between Visual editor and Code editor.' ), + }, +]; -export default [ - mainShortcut, - globalShortcuts, - selectionShortcuts, - blockShortcuts, - textFormattingShortcuts, +export const textFormattingShortcuts = [ + { + keyCombination: primary( 'b' ), + description: __( 'Make the selected text bold.' ), + }, + { + keyCombination: primary( 'i' ), + description: __( 'Make the selected text italic.' ), + }, + { + keyCombination: primary( 'k' ), + description: __( 'Convert the selected text into a link.' ), + }, + { + keyCombination: primaryShift( 'k' ), + description: __( 'Remove a link.' ), + }, + { + keyCombination: primary( 'u' ), + description: __( 'Underline the selected text.' ), + }, ]; diff --git a/packages/edit-post/src/components/keyboard-shortcut-help-modal/dynamic-shortcut.js b/packages/edit-post/src/components/keyboard-shortcut-help-modal/dynamic-shortcut.js new file mode 100644 index 00000000000000..7ef1d3055bce5a --- /dev/null +++ b/packages/edit-post/src/components/keyboard-shortcut-help-modal/dynamic-shortcut.js @@ -0,0 +1,38 @@ +/** + * WordPress dependencies + */ +import { useSelect } from '@wordpress/data'; +import { displayShortcutList } from '@wordpress/keycodes'; + +/** + * Internal dependencies + */ +import Shortcut from './shortcut'; + +function DynamicShortcut( { name } ) { + const { keyCombination, description } = useSelect( ( select ) => { + const { getShortcutKeyCombination, getShortcutDescription } = select( 'core/keyboard-shortcuts' ); + + return { + keyCombination: getShortcutKeyCombination( name ), + description: getShortcutDescription( name ), + }; + } ); + + if ( ! keyCombination ) { + return null; + } + + const combination = keyCombination.modifier ? + displayShortcutList[ keyCombination.modifier ]( keyCombination.character ) : + keyCombination.character; + + return ( + + ); +} + +export default DynamicShortcut; diff --git a/packages/edit-post/src/components/keyboard-shortcut-help-modal/index.js b/packages/edit-post/src/components/keyboard-shortcut-help-modal/index.js index 1bbc09b5688ed9..e4d273fc8134a8 100644 --- a/packages/edit-post/src/components/keyboard-shortcut-help-modal/index.js +++ b/packages/edit-post/src/components/keyboard-shortcut-help-modal/index.js @@ -1,8 +1,8 @@ /** * External dependencies */ -import { castArray } from 'lodash'; import classnames from 'classnames'; +import { isString } from 'lodash'; /** * WordPress dependencies @@ -17,49 +17,28 @@ import { compose } from '@wordpress/compose'; /** * Internal dependencies */ -import shortcutConfig from './config'; +import { globalShortcuts, mainShortcuts, textFormattingShortcuts } from './config'; +import Shortcut from './shortcut'; +import DynamicShortcut from './dynamic-shortcut'; const MODAL_NAME = 'edit-post/keyboard-shortcut-help'; -const mapKeyCombination = ( keyCombination ) => keyCombination.map( ( character, index ) => { - if ( character === '+' ) { - return ( - - { character } - - ); - } - - return ( - - { character } - - ); -} ); - const ShortcutList = ( { shortcuts } ) => ( /* * Disable reason: The `list` ARIA role is redundant but * Safari+VoiceOver won't announce the list otherwise. */ /* eslint-disable jsx-a11y/no-redundant-roles */ -
    - { shortcuts.map( ( { keyCombination, description, ariaLabel }, index ) => ( +
      + { shortcuts.map( ( shortcut, index ) => (
    • -
      - { description } -
      -
      - - { mapKeyCombination( castArray( keyCombination ) ) } - -
      + { isString( shortcut ) ? + : + + }
    • ) ) }
    @@ -67,9 +46,9 @@ const ShortcutList = ( { shortcuts } ) => ( ); const ShortcutSection = ( { title, shortcuts, className } ) => ( -
    +
    { !! title && ( -

    +

    { title }

    ) } @@ -88,14 +67,45 @@ export function KeyboardShortcutHelpModal( { isModalActive, toggleModal } ) { /> { isModalActive && ( - { shortcutConfig.map( ( config, index ) => ( - - ) ) } + + + + + ) } diff --git a/packages/edit-post/src/components/keyboard-shortcut-help-modal/shortcut.js b/packages/edit-post/src/components/keyboard-shortcut-help-modal/shortcut.js new file mode 100644 index 00000000000000..04b53b29e3024e --- /dev/null +++ b/packages/edit-post/src/components/keyboard-shortcut-help-modal/shortcut.js @@ -0,0 +1,43 @@ +/** + * External dependencies + */ +import { castArray } from 'lodash'; + +/** + * WordPress dependencies + */ +import { Fragment } from '@wordpress/element'; + +function Shortcut( { description, keyCombination, ariaLabel } ) { + return ( + <> +
    + { description } +
    +
    + + { castArray( keyCombination ).map( ( character, index ) => { + if ( character === '+' ) { + return ( + + { character } + + ); + } + + return ( + + { character } + + ); + } ) } + +
    + + ); +} + +export default Shortcut; diff --git a/packages/edit-post/src/components/keyboard-shortcut-help-modal/style.scss b/packages/edit-post/src/components/keyboard-shortcut-help-modal/style.scss index 627b8c56037654..d05e78702062f7 100644 --- a/packages/edit-post/src/components/keyboard-shortcut-help-modal/style.scss +++ b/packages/edit-post/src/components/keyboard-shortcut-help-modal/style.scss @@ -1,9 +1,9 @@ -.edit-post-keyboard-shortcut-help { +.edit-post-keyboard-shortcut-help-modal { &__section { margin: 0 0 2rem 0; } - &__main-shortcuts .edit-post-keyboard-shortcut-help__shortcut-list { + &__main-shortcuts .edit-post-keyboard-shortcut-help-modal__shortcut-list { // Push the shortcut to be flush with top modal header. margin-top: -$grid-size-xlarge -$border-width; } @@ -23,6 +23,10 @@ &:last-child { border-bottom: 1px solid $light-gray-500; } + + &:empty { + display: none; + } } &__shortcut-term { diff --git a/packages/edit-post/src/components/keyboard-shortcut-help-modal/test/__snapshots__/index.js.snap b/packages/edit-post/src/components/keyboard-shortcut-help-modal/test/__snapshots__/index.js.snap index c53199d28f399a..7eae78e951ebf6 100644 --- a/packages/edit-post/src/components/keyboard-shortcut-help-modal/test/__snapshots__/index.js.snap +++ b/packages/edit-post/src/components/keyboard-shortcut-help-modal/test/__snapshots__/index.js.snap @@ -11,14 +11,13 @@ exports[`KeyboardShortcutHelpModal should match snapshot when the modal is activ } /> + + +# **useShortcut** + +Attach a keyboard shortcut handler. + +_Parameters_ + +- _name_ `string`: Shortcut name. +- _callback_ `Function`: Shortcut callback. +- _options_ `Object`: Shortcut options. + + + + +

    Code is Poetry.

    diff --git a/packages/keyboard-shortcuts/package.json b/packages/keyboard-shortcuts/package.json new file mode 100644 index 00000000000000..67fff6509a1460 --- /dev/null +++ b/packages/keyboard-shortcuts/package.json @@ -0,0 +1,34 @@ +{ + "name": "@wordpress/keyboard-shortcuts", + "version": "0.1.0", + "description": "Handling keyboard shortcuts.", + "author": "The WordPress Contributors", + "license": "GPL-2.0-or-later", + "keywords": [ + "wordpress", + "keycodes" + ], + "homepage": "https://github.com/WordPress/gutenberg/tree/master/packages/keyboard-shortcuts/README.md", + "repository": { + "type": "git", + "url": "https://github.com/WordPress/gutenberg.git", + "directory": "packages/keycodes" + }, + "bugs": { + "url": "https://github.com/WordPress/gutenberg/issues" + }, + "main": "build/index.js", + "module": "build-module/index.js", + "react-native": "src/index", + "dependencies": { + "@babel/runtime": "^7.4.4", + "@wordpress/data": "file:../data", + "@wordpress/element": "file:../element", + "@wordpress/keycodes": "file:../keycodes", + "lodash": "^4.17.15", + "mousetrap": "^1.6.2" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/keyboard-shortcuts/src/hooks/use-shortcut.js b/packages/keyboard-shortcuts/src/hooks/use-shortcut.js new file mode 100644 index 00000000000000..b747bbf533e198 --- /dev/null +++ b/packages/keyboard-shortcuts/src/hooks/use-shortcut.js @@ -0,0 +1,88 @@ +/** + * External dependencies + */ +import Mousetrap from 'mousetrap'; +import 'mousetrap/plugins/global-bind/mousetrap-global-bind'; +import { includes, compact } from 'lodash'; + +/** + * WordPress dependencies + */ +import { useSelect } from '@wordpress/data'; +import { rawShortcut } from '@wordpress/keycodes'; +import { useEffect } from '@wordpress/element'; + +/** + * Return true if platform is MacOS. + * + * @param {Object} _window window object by default; used for DI testing. + * + * @return {boolean} True if MacOS; false otherwise. + */ +function isAppleOS( _window = window ) { + const { platform } = _window.navigator; + + return platform.indexOf( 'Mac' ) !== -1 || + includes( [ 'iPad', 'iPhone' ], platform ); +} + +/** + * Attach a keyboard shortcut handler. + * + * @param {string} name Shortcut name. + * @param {Function} callback Shortcut callback. + * @param {Object} options Shortcut options. + */ +function useShortcut( name, callback, { + bindGlobal = false, + eventName = 'keydown', + target, +} = {} ) { + const { combination, aliases } = useSelect( ( select ) => { + return { + combination: select( 'core/keyboard-shortcuts' ).getShortcutKeyCombination( name ), + aliases: select( 'core/keyboard-shortcuts' ).getShortcutAliases( name ), + }; + }, [ name ] ); + + useEffect( () => { + const shortcuts = compact( [ combination, ...aliases ] ); + const mousetrap = new Mousetrap( target ? target.current : document ); + const shortcutKeys = shortcuts.map( ( shortcut ) => { + return shortcut.modifier ? + rawShortcut[ shortcut.modifier ]( shortcut.character ) : + shortcut.character; + } ); + + shortcutKeys.forEach( ( shortcut ) => { + const keys = shortcut.split( '+' ); + // Determines whether a key is a modifier by the length of the string. + // E.g. if I add a pass a shortcut Shift+Cmd+M, it'll determine that + // the modifiers are Shift and Cmd because they're not a single character. + const modifiers = new Set( keys.filter( ( value ) => value.length > 1 ) ); + const hasAlt = modifiers.has( 'alt' ); + const hasShift = modifiers.has( 'shift' ); + + // This should be better moved to the shortcut registration instead. + if ( + isAppleOS() && ( + ( modifiers.size === 1 && hasAlt ) || + ( modifiers.size === 2 && hasAlt && hasShift ) + ) + ) { + throw new Error( `Cannot bind ${ shortcut }. Alt and Shift+Alt modifiers are reserved for character input.` ); + } + + const bindFn = bindGlobal ? 'bindGlobal' : 'bind'; + mousetrap[ bindFn ]( shortcut, callback, eventName ); + } ); + + return () => { + shortcutKeys.forEach( ( shortcut ) => { + mousetrap.unbind( shortcut, eventName ); + } ); + }; + }, [ combination, aliases, bindGlobal, eventName, callback, target ] ); +} + +export default useShortcut; diff --git a/packages/keyboard-shortcuts/src/index.js b/packages/keyboard-shortcuts/src/index.js new file mode 100644 index 00000000000000..9a738b710b2dab --- /dev/null +++ b/packages/keyboard-shortcuts/src/index.js @@ -0,0 +1,6 @@ +/** + * Internal dependencies + */ +import './store'; + +export { default as useShortcut } from './hooks/use-shortcut'; diff --git a/packages/keyboard-shortcuts/src/store/actions.js b/packages/keyboard-shortcuts/src/store/actions.js new file mode 100644 index 00000000000000..a38c6c159c7420 --- /dev/null +++ b/packages/keyboard-shortcuts/src/store/actions.js @@ -0,0 +1,52 @@ +/** + * Keyboard key combination. + * + * @typedef {Object} WPShortcutKeyCombination + * + * @property {string} character Character. + * @property {string} [modifier] Modifier. + */ + +/** + * Configuration of a registered keyboard shortcut. + * + * @typedef {Object} WPShortcutConfig + * + * @property {string} name Shortcut name. + * @property {string} category Shortcut category. + * @property {string} description Shortcut description. + * @property {WPShortcutKeyCombination} keyCombination Shortcut key combination. + * @property {WPShortcutKeyCombination[]} [aliases] Shortcut aliases. + */ + +/** + * Returns an action object used to register a new keyboard shortcut. + * + * @param {WPShortcutConfig} config Shortcut config. + * + * @return {Object} action. + */ +export function registerShortcut( { name, category, description, keyCombination, aliases } ) { + return { + type: 'REGISTER_SHORTCUT', + name, + category, + keyCombination, + aliases, + description, + }; +} + +/** + * Returns an action object used to unregister a keyboard shortcut. + * + * @param {string} name Shortcut name. + * + * @return {Object} action. + */ +export function unregisterShortcut( name ) { + return { + type: 'UNREGISTER_SHORTCUT', + name, + }; +} diff --git a/packages/keyboard-shortcuts/src/store/index.js b/packages/keyboard-shortcuts/src/store/index.js new file mode 100644 index 00000000000000..aadabb955d7526 --- /dev/null +++ b/packages/keyboard-shortcuts/src/store/index.js @@ -0,0 +1,17 @@ +/** + * WordPress dependencies + */ +import { registerStore } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import reducer from './reducer'; +import * as actions from './actions'; +import * as selectors from './selectors'; + +export default registerStore( 'core/keyboard-shortcuts', { + reducer, + actions, + selectors, +} ); diff --git a/packages/keyboard-shortcuts/src/store/reducer.js b/packages/keyboard-shortcuts/src/store/reducer.js new file mode 100644 index 00000000000000..7489d529bf26ad --- /dev/null +++ b/packages/keyboard-shortcuts/src/store/reducer.js @@ -0,0 +1,33 @@ +/** + * External dependencies + */ +import { omit } from 'lodash'; + +/** + * Reducer returning the registered shortcuts + * + * @param {Object} state Current state. + * @param {Object} action Dispatched action. + * + * @return {Object} Updated state. + */ +function reducer( state = {}, action ) { + switch ( action.type ) { + case 'REGISTER_SHORTCUT': + return { + ...state, + [ action.name ]: { + category: action.category, + keyCombination: action.keyCombination, + aliases: action.aliases, + description: action.description, + }, + }; + case 'UNREGISTER_SHORTCUT': + return omit( state, action.name ); + } + + return state; +} + +export default reducer; diff --git a/packages/keyboard-shortcuts/src/store/selectors.js b/packages/keyboard-shortcuts/src/store/selectors.js new file mode 100644 index 00000000000000..97228e1ad6e9d4 --- /dev/null +++ b/packages/keyboard-shortcuts/src/store/selectors.js @@ -0,0 +1,47 @@ +/** @typedef {import('./actions').WPShortcutKeyCombination} WPShortcutKeyCombination */ + +/** + * Shared reference to an empty array for cases where it is important to avoid + * returning a new array reference on every invocation. + * + * @type {Array} + */ +const EMPTY_ARRAY = []; + +/** + * Returns the main key combination for a given shortcut name. + * + * @param {Object} state Global state. + * @param {string} name Shortcut name. + * + * @return {WPShortcutKeyCombination?} Key combination. + */ +export function getShortcutKeyCombination( state, name ) { + return state[ name ] ? state[ name ].keyCombination : null; +} + +/** + * Returns the shortcut description given its name. + * + * @param {Object} state Global state. + * @param {string} name Shortcut name. + * + * @return {string?} Shortcut description. + */ +export function getShortcutDescription( state, name ) { + return state[ name ] ? state[ name ].description : null; +} + +/** + * Returns the aliases for a given shortcut name. + * + * @param {Object} state Global state. + * @param {string} name Shortcut name. + * + * @return {WPShortcutKeyCombination[]} Key combinations. + */ +export function getShortcutAliases( state, name ) { + return state[ name ] && state[ name ].aliases ? + state[ name ].aliases : + EMPTY_ARRAY; +}