import * as React from "react"; import { PropsWithChildren, useEffect } from "react"; import { KeyBindingMap, tinykeys } from "tinykeys"; import create, { UseStore } from "zustand"; /** * Represents a keyboard shortcut handler. * Return `true` to prevent the default action of the keyboard shortcut. * Any other return value will allow the default action to proceed. */ export type KeyboardActionHandler = (e: KeyboardEvent) => boolean | void; export type KeyboardHandlerMap = Partial>; /** * The groups of keyboard actions that can be managed by the application. * Each group can be updated separately, but, when updated, must be completely replaced. */ export enum KeyboardActionGroup { /** Keyboard actions related to tab navigation. */ TABS = "TABS", /** Keyboard actions managed by the global command bar. */ COMMAND_BAR = "COMMAND_BAR", /** * Keyboard actions specific to the active tab. * This group is automatically cleared when the active tab changes. */ ACTIVE_TAB = "ACTIVE_TAB", } /** * The possible actions that can be triggered by keyboard shortcuts. */ export enum KeyboardAction { NEW_QUERY = "NEW_QUERY", EXECUTE_ITEM = "EXECUTE_ITEM", CANCEL_OR_DISCARD = "CANCEL_OR_DISCARD", SAVE_ITEM = "SAVE_ITEM", DOWNLOAD_ITEM = "DOWNLOAD_ITEM", OPEN_QUERY = "OPEN_QUERY", OPEN_QUERY_FROM_DISK = "OPEN_QUERY_FROM_DISK", NEW_SPROC = "NEW_SPROC", NEW_UDF = "NEW_UDF", NEW_TRIGGER = "NEW_TRIGGER", NEW_DATABASE = "NEW_DATABASE", NEW_COLLECTION = "NEW_CONTAINER", NEW_ITEM = "NEW_ITEM", DELETE_ITEM = "DELETE_ITEM", TOGGLE_COPILOT = "TOGGLE_COPILOT", SELECT_LEFT_TAB = "SELECT_LEFT_TAB", SELECT_RIGHT_TAB = "SELECT_RIGHT_TAB", CLOSE_TAB = "CLOSE_TAB", SEARCH = "SEARCH", CLEAR_SEARCH = "CLEAR_SEARCH", } /** * The keyboard shortcuts for the application. * This record maps each action to the keyboard shortcuts that trigger the action. * Even if an action is specified here, it will not be triggered unless a handler is set for it. */ const bindings: Record = { // NOTE: The "$mod" special value is used to represent the "Control" key on Windows/Linux and the "Command" key on macOS. // See https://www.npmjs.com/package/tinykeys#commonly-used-keys-and-codes for more information on the expected values for keyboard shortcuts. [KeyboardAction.NEW_QUERY]: ["$mod+J", "Alt+N Q"], [KeyboardAction.EXECUTE_ITEM]: ["Shift+Enter", "F5"], [KeyboardAction.CANCEL_OR_DISCARD]: ["Escape"], [KeyboardAction.SAVE_ITEM]: ["$mod+S"], [KeyboardAction.DOWNLOAD_ITEM]: ["$mod+Shift+S"], [KeyboardAction.OPEN_QUERY]: ["$mod+O"], [KeyboardAction.OPEN_QUERY_FROM_DISK]: ["$mod+Shift+O"], [KeyboardAction.NEW_SPROC]: ["Alt+N P"], [KeyboardAction.NEW_UDF]: ["Alt+N F"], [KeyboardAction.NEW_TRIGGER]: ["Alt+N T"], [KeyboardAction.NEW_DATABASE]: ["Alt+N D"], [KeyboardAction.NEW_COLLECTION]: ["Alt+N C"], [KeyboardAction.NEW_ITEM]: ["Alt+N I"], [KeyboardAction.DELETE_ITEM]: ["Alt+D"], [KeyboardAction.TOGGLE_COPILOT]: ["$mod+P"], [KeyboardAction.SELECT_LEFT_TAB]: ["$mod+Alt+[", "$mod+Shift+F6"], [KeyboardAction.SELECT_RIGHT_TAB]: ["$mod+Alt+]", "$mod+F6"], [KeyboardAction.CLOSE_TAB]: ["$mod+Alt+W"], [KeyboardAction.SEARCH]: ["$mod+Shift+F"], [KeyboardAction.CLEAR_SEARCH]: ["$mod+Shift+C"], }; interface KeyboardShortcutState { /** * A set of all the keyboard shortcuts handlers. */ allHandlers: KeyboardHandlerMap; /** * A set of all the groups of keyboard shortcuts handlers. */ groups: Partial>; /** * Sets the keyboard shortcut handlers for the given group. */ setHandlers: (group: KeyboardActionGroup, handlers: KeyboardHandlerMap) => void; } export type KeyboardHandlerSetter = (handlers: KeyboardHandlerMap) => void; /** * Defines the calling component as the manager of the keyboard actions for the given group. * @param group The group of keyboard actions to manage. * @returns A function that can be used to set the keyboard action handlers for the given group. */ export const useKeyboardActionGroup: (group: KeyboardActionGroup) => KeyboardHandlerSetter = (group: KeyboardActionGroup) => (handlers: KeyboardHandlerMap) => useKeyboardActionHandlers.getState().setHandlers(group, handlers); /** * Clears the keyboard action handlers for the given group. * @param group The group of keyboard actions to clear. */ export const clearKeyboardActionGroup = (group: KeyboardActionGroup) => { useKeyboardActionHandlers.getState().setHandlers(group, {}); }; const useKeyboardActionHandlers: UseStore = create((set, get) => ({ allHandlers: {}, groups: {}, setHandlers: (group: KeyboardActionGroup, handlers: KeyboardHandlerMap) => { const state = get(); const groups = { ...state.groups, [group]: handlers }; // Combine all the handlers from all the groups in the correct order. const allHandlers: KeyboardHandlerMap = {}; eachKey(groups).forEach((group) => { const groupHandlers = groups[group]; if (groupHandlers) { eachKey(groupHandlers).forEach((action) => { // Check for duplicate handlers in development mode. // We don't want to raise an error here in production, but having duplicate handlers is a mistake. if (process.env.NODE_ENV === "development" && allHandlers[action]) { throw new Error(`Duplicate handler for Keyboard Action "${action}".`); } allHandlers[action] = groupHandlers[action]; }); } }); set({ groups, allHandlers }); }, })); function createHandler(action: KeyboardAction): KeyboardActionHandler { return (e) => { const state = useKeyboardActionHandlers.getState(); const handler = state.allHandlers[action]; if (handler && handler(e)) { e.preventDefault(); e.stopPropagation(); } }; } const allHandlers: KeyBindingMap = {}; eachKey(bindings).forEach((action) => { const shortcuts = bindings[action]; shortcuts.forEach((shortcut) => { allHandlers[shortcut] = createHandler(action); }); }); export function KeyboardShortcutRoot({ children }: PropsWithChildren) { useEffect(() => { // We bind to the body because Fluent UI components sometimes shift focus to the body, which is above the root React component. tinykeys(document.body, allHandlers); }, []); return <>{children}; } /** A _typed_ version of `Object.keys` that preserves the original key type */ function eachKey(record: Partial>): K[] { return Object.keys(record) as K[]; }