From 09be7e84b799650eb1f54658c7087a043aa07ede Mon Sep 17 00:00:00 2001 From: Sukhendu Sekhar Guria Date: Tue, 22 Apr 2025 18:42:41 +0530 Subject: [PATCH] Add triggerCommand action and useShortcutCommand hook --- .../data/data-core-commands.md | 25 +++++ packages/commands/README.md | 101 ++++++++++++++++++ .../src/hooks/use-shortcut-command.js | 59 ++++++++++ packages/commands/src/index.js | 1 + packages/commands/src/store/actions.js | 14 +++ packages/commands/src/store/index.js | 2 + packages/commands/src/store/middleware.js | 44 ++++++++ packages/commands/src/store/selectors.js | 12 +++ 8 files changed, 258 insertions(+) create mode 100644 packages/commands/src/hooks/use-shortcut-command.js create mode 100644 packages/commands/src/store/middleware.js diff --git a/docs/reference-guides/data/data-core-commands.md b/docs/reference-guides/data/data-core-commands.md index 9621de9d98c957..bed2b1be1009cf 100644 --- a/docs/reference-guides/data/data-core-commands.md +++ b/docs/reference-guides/data/data-core-commands.md @@ -6,6 +6,19 @@ Namespace: `core/commands`. +### getCommand + +Returns a specific command by name. + +_Parameters_ + +- _state_ `Object`: State tree. +- _name_ `string`: Command name. + +_Returns_ + +- `import('./actions').WPCommandConfig|undefined`: The requested command, if it exists. + ### getCommandLoaders Returns the registered command loaders. @@ -102,6 +115,18 @@ _Returns_ - `Object`: action. +### triggerCommand + +Triggers a command by name. + +_Parameters_ + +- _name_ `string`: Command name. + +_Returns_ + +- `Object`: action. + ### unregisterCommand Returns an action object used to unregister a command. diff --git a/packages/commands/README.md b/packages/commands/README.md index b7382f98a8c848..f25e6339b30474 100644 --- a/packages/commands/README.md +++ b/packages/commands/README.md @@ -43,9 +43,99 @@ The Command Palette also offers a number of [selectors and actions](https://deve - Retrieving the registered commands and command loaders using the following selectors `getCommands` and `getCommandLoader` - Checking if the Command Palette is open using the `isOpen` selector. - Programmatically open or close the Command Palette using the `open` and `close` actions. +- Trigger a registered command by name using the `triggerCommand` action. See the [Commands Data](https://developer.wordpress.org/block-editor/reference-guides/data/data-core-commands/) documentation for more information. +## Integration with Keyboard Shortcuts + +Commands can be integrated with keyboard shortcuts using the `useShortcutCommand` hook. This hook binds a keyboard shortcut to a command, allowing the command to be triggered either through the Command Palette or via the keyboard shortcut. + +### useShortcutCommand + +The `useShortcutCommand` hook connects an existing keyboard shortcut to a command. This allows you to define your action once as a command and trigger it through a keyboard shortcut. + +```js +import { useShortcutCommand } from '@wordpress/commands'; + +function MyComponent() { + // This connects the existing keyboard shortcut to the command. + // When the shortcut is pressed, the command will be executed. + useShortcutCommand( + 'core/edit-post/toggle-fullscreen', // Keyboard shortcut name + 'core/toggle-fullscreen-mode' // Command name + ); + + return
My Component
; +} +``` + +### Triggering Commands Programmatically + +You can also trigger commands directly from your components or other parts of your code: + +```js +import { useDispatch } from '@wordpress/data'; +import { store as commandsStore } from '@wordpress/commands'; + +function MyComponent() { + const { triggerCommand } = useDispatch( commandsStore ); + + const handleClick = () => { + // This will execute the registered command with the given name. + triggerCommand( 'core/toggle-fullscreen-mode' ); + }; + + return ; +} +``` + +### Complete Integration Example + +Here's a complete example showing how to register both a command and a keyboard shortcut, then connect them: + +```js +import { useCommand, useShortcutCommand } from '@wordpress/commands'; +import { __ } from '@wordpress/i18n'; +import { fullscreen } from '@wordpress/icons'; +import { useDispatch } from '@wordpress/data'; +import { store as keyboardShortcutsStore } from '@wordpress/keyboard-shortcuts'; +import { useEffect } from '@wordpress/element'; + +function MyComponent() { + // 1. Register the command. + useCommand( { + name: 'app/toggle-feature', + label: __( 'Toggle Feature' ), + icon: fullscreen, + callback: ( { close } ) => { + // Feature toggle implementation here + console.log( 'Feature toggled!' ); + close(); + }, + } ); + + // 2. Register the keyboard shortcut. + const { registerShortcut } = useDispatch( keyboardShortcutsStore ); + useEffect( () => { + registerShortcut( { + name: 'app/toggle-feature-shortcut', + category: 'global', + description: __( 'Toggle a feature on or off' ), + keyCombination: { + modifier: 'ctrl+alt', + character: 't', + }, + } ); + }, [] ); + + // 3. Connect the shortcut to the command. + useShortcutCommand( 'app/toggle-feature-shortcut', 'app/toggle-feature' ); + + return
Unified Command Example
; +} +``` + ## Installation Install the module @@ -179,6 +269,17 @@ _Parameters_ - _loader_ `import('../store/actions').WPCommandLoaderConfig`: command loader config. +### useShortcutCommand + +Hook that binds a keyboard shortcut to a command. + +_Parameters_ + +- _shortcutName_ `string`: The name of the keyboard shortcut. +- _commandName_ `string`: The name of the command to trigger. +- _options_ `Object`: Shortcut options. +- _options.isDisabled_ `boolean`: Whether to disable the shortcut. + ## Contributing to this package diff --git a/packages/commands/src/hooks/use-shortcut-command.js b/packages/commands/src/hooks/use-shortcut-command.js new file mode 100644 index 00000000000000..e21bd9e2e1c544 --- /dev/null +++ b/packages/commands/src/hooks/use-shortcut-command.js @@ -0,0 +1,59 @@ +/** + * WordPress dependencies + */ +import { useDispatch, useSelect } from '@wordpress/data'; +import { useShortcut } from '@wordpress/keyboard-shortcuts'; + +/** + * Internal dependencies + */ +import { store as commandsStore } from '../store'; + +/** + * Hook that binds a keyboard shortcut to a command. + * + * @param {string} shortcutName The name of the keyboard shortcut. + * @param {string} commandName The name of the command to trigger. + * @param {Object} options Shortcut options. + * @param {boolean} options.isDisabled Whether to disable the shortcut. + */ +export default function useShortcutCommand( + shortcutName, + commandName, + { isDisabled = false } = {} +) { + const command = useSelect( + ( select ) => { + const allCommands = select( commandsStore ).getCommands(); + return allCommands.find( ( cmd ) => cmd.name === commandName ); + }, + [ commandName ] + ); + + const { triggerCommand, close: closeCommandPalette } = + useDispatch( commandsStore ); + + useShortcut( + shortcutName, + ( event ) => { + event.preventDefault(); + + // If we have the command and it has a callback, execute it directly + // This is a fallback in case the triggerCommand doesn't work. + if ( command && typeof command.callback === 'function' ) { + const closeFunction = () => { + if ( typeof closeCommandPalette === 'function' ) { + closeCommandPalette(); + } + }; + + command.callback( { + close: closeFunction, + } ); + } else { + triggerCommand( commandName ); + } + }, + { isDisabled } + ); +} diff --git a/packages/commands/src/index.js b/packages/commands/src/index.js index b62166f6afc44c..46205bf48ccb4a 100644 --- a/packages/commands/src/index.js +++ b/packages/commands/src/index.js @@ -2,4 +2,5 @@ export { CommandMenu } from './components/command-menu'; export { privateApis } from './private-apis'; export { default as useCommand } from './hooks/use-command'; export { default as useCommandLoader } from './hooks/use-command-loader'; +export { default as useShortcutCommand } from './hooks/use-shortcut-command'; export { store } from './store'; diff --git a/packages/commands/src/store/actions.js b/packages/commands/src/store/actions.js index 2926bb84ab6079..a10ff6d9125e60 100644 --- a/packages/commands/src/store/actions.js +++ b/packages/commands/src/store/actions.js @@ -106,3 +106,17 @@ export function close() { type: 'CLOSE', }; } + +/** + * Triggers a command by name. + * + * @param {string} name Command name. + * + * @return {Object} action. + */ +export function triggerCommand( name ) { + return { + type: 'TRIGGER_COMMAND', + name, + }; +} diff --git a/packages/commands/src/store/index.js b/packages/commands/src/store/index.js index f3aa6f85f28b86..b9276f9d8a5daa 100644 --- a/packages/commands/src/store/index.js +++ b/packages/commands/src/store/index.js @@ -10,6 +10,7 @@ import reducer from './reducer'; import * as actions from './actions'; import * as selectors from './selectors'; import * as privateActions from './private-actions'; +import { commandTriggerer } from './middleware'; import { unlock } from '../lock-unlock'; const STORE_NAME = 'core/commands'; @@ -33,6 +34,7 @@ export const store = createReduxStore( STORE_NAME, { reducer, actions, selectors, + middleware: [ commandTriggerer ], } ); register( store ); diff --git a/packages/commands/src/store/middleware.js b/packages/commands/src/store/middleware.js new file mode 100644 index 00000000000000..8562e733a1e3e9 --- /dev/null +++ b/packages/commands/src/store/middleware.js @@ -0,0 +1,44 @@ +/** + * WordPress dependencies + */ +import { select } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { store as commandsStore } from './index'; + +/** + * Middleware for handling command triggering. + * When a TRIGGER_COMMAND action is dispatched, it finds the + * corresponding registered command and executes its callback. + * + * @param {Object} store Redux store instance. + * + * @return {Function} Redux middleware. + */ +export const commandTriggerer = ( store ) => ( next ) => ( action ) => { + const result = next( action ); + + if ( action.type === 'TRIGGER_COMMAND' ) { + const { name } = action; + const allCommands = select( commandsStore ).getCommands(); + let command = select( commandsStore ).getCommand( name ); + + if ( ! command ) { + command = allCommands.find( ( cmd ) => cmd.name === name ); + } + + if ( command && typeof command.callback === 'function' ) { + const close = () => { + store.dispatch( { type: 'CLOSE' } ); + }; + + command.callback( { close } ); + } + } + + return result; +}; + +export default commandTriggerer; diff --git a/packages/commands/src/store/selectors.js b/packages/commands/src/store/selectors.js index 69646c479d5067..6941eac01d5ca6 100644 --- a/packages/commands/src/store/selectors.js +++ b/packages/commands/src/store/selectors.js @@ -60,3 +60,15 @@ export function isOpen( state ) { export function getContext( state ) { return state.context; } + +/** + * Returns a specific command by name. + * + * @param {Object} state State tree. + * @param {string} name Command name. + * + * @return {import('./actions').WPCommandConfig|undefined} The requested command, if it exists. + */ +export function getCommand( state, name ) { + return state.commands[ name ]; +}