diff --git a/package-lock.json b/package-lock.json index d3a36e3f3..edb334b7c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -106,6 +106,7 @@ "styled-components": "5.0.1", "swr": "0.4.0", "terser-webpack-plugin": "5.3.9", + "tinykeys": "2.1.0", "underscore": "1.9.1", "utility-types": "3.10.0", "zustand": "3.5.0" @@ -37786,6 +37787,11 @@ "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==" }, + "node_modules/tinykeys": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tinykeys/-/tinykeys-2.1.0.tgz", + "integrity": "sha512-/MESnqBD1xItZJn5oGQ4OsNORQgJfPP96XSGoyu4eLpwpL0ifO0SYR5OD76u0YMhMXsqkb0UqvI9+yXTh4xv8Q==" + }, "node_modules/tinyqueue": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-1.2.3.tgz", diff --git a/package.json b/package.json index a7bfec4b8..cc195cd5f 100644 --- a/package.json +++ b/package.json @@ -101,6 +101,7 @@ "styled-components": "5.0.1", "swr": "0.4.0", "terser-webpack-plugin": "5.3.9", + "tinykeys": "2.1.0", "underscore": "1.9.1", "utility-types": "3.10.0", "zustand": "3.5.0" diff --git a/src/Explorer/Controls/CommandButton/CommandButtonComponent.tsx b/src/Explorer/Controls/CommandButton/CommandButtonComponent.tsx index 1e5cfc171..6337f947e 100644 --- a/src/Explorer/Controls/CommandButton/CommandButtonComponent.tsx +++ b/src/Explorer/Controls/CommandButton/CommandButtonComponent.tsx @@ -1,6 +1,7 @@ /** * React component for Command button component. */ +import { KeyboardAction } from "KeyboardShortcuts"; import * as React from "react"; import CollapseChevronDownIcon from "../../../../images/QueryBuilder/CollapseChevronDown_16x.png"; import { KeyCodes } from "../../../Common/Constants"; @@ -30,7 +31,7 @@ export interface CommandButtonComponentProps { /** * Click handler for command button click */ - onCommandClick: (e: React.SyntheticEvent) => void; + onCommandClick: (e: React.SyntheticEvent | KeyboardEvent) => void; /** * Label for the button @@ -107,10 +108,17 @@ export interface CommandButtonComponentProps { * Vertical bar to divide buttons */ isDivider?: boolean; + /** * Aria-label for the button */ ariaLabel: string; + + /** + * If specified, a keyboard action that should trigger this button's onCommandClick handler when activated. + * If not specified, the button will not be triggerable by keyboard shortcuts. + */ + keyboardAction?: KeyboardAction; } export class CommandButtonComponent extends React.Component { diff --git a/src/Explorer/Menus/CommandBar/CommandBarComponentAdapter.tsx b/src/Explorer/Menus/CommandBar/CommandBarComponentAdapter.tsx index 141bda577..eaa56591d 100644 --- a/src/Explorer/Menus/CommandBar/CommandBarComponentAdapter.tsx +++ b/src/Explorer/Menus/CommandBar/CommandBarComponentAdapter.tsx @@ -5,6 +5,7 @@ */ import { CommandBar as FluentCommandBar, ICommandBarItemProps } from "@fluentui/react"; import { useNotebook } from "Explorer/Notebook/useNotebook"; +import { useKeyboardActionHandlers } from "KeyboardShortcuts"; import { userContext } from "UserContext"; import * as React from "react"; import create, { UseStore } from "zustand"; @@ -40,6 +41,7 @@ export const CommandBar: React.FC = ({ container }: Props) => { const buttons = useCommandBar((state) => state.contextButtons); const isHidden = useCommandBar((state) => state.isHidden); const backgroundColor = StyleConstants.BaseLight; + const setKeyboardShortcutHandlers = useKeyboardActionHandlers((state) => state.setHandlers); if (userContext.apiType === "Postgres" || userContext.apiType === "VCoreMongo") { const buttons = @@ -105,6 +107,10 @@ export const CommandBar: React.FC = ({ container }: Props) => { }, }; + const allButtons = staticButtons.concat(contextButtons).concat(controlButtons); + const keyboardHandlers = CommandBarUtil.createKeyboardHandlers(allButtons); + setKeyboardShortcutHandlers(keyboardHandlers); + return (
{ const selectedCollection: ViewModels.Collection = selectedNodeState.findSelectedCollection(); selectedCollection && selectedCollection.onNewQueryClick(selectedCollection); @@ -312,6 +314,7 @@ function createNewSQLQueryButton(selectedNodeState: SelectedNodeState): CommandB id: "newQueryBtn", iconSrc: AddSqlQueryIcon, iconAlt: label, + keyboardAction: KeyboardAction.NEW_QUERY, onCommandClick: () => { const selectedCollection: ViewModels.Collection = selectedNodeState.findSelectedCollection(); selectedCollection && selectedCollection.onNewMongoQueryClick(selectedCollection); @@ -397,6 +400,7 @@ function createOpenQueryButton(container: Explorer): CommandButtonComponentProps return { iconSrc: BrowseQueriesIcon, iconAlt: label, + keyboardAction: KeyboardAction.OPEN_QUERY, onCommandClick: () => useSidePanel.getState().openSidePanel("Open Saved Queries", ), commandButtonLabel: label, @@ -411,6 +415,7 @@ function createOpenQueryFromDiskButton(): CommandButtonComponentProps { return { iconSrc: OpenQueryFromDiskIcon, iconAlt: label, + keyboardAction: KeyboardAction.OPEN_QUERY_FROM_DISK, onCommandClick: () => useSidePanel.getState().openSidePanel("Load Query", ), commandButtonLabel: label, ariaLabel: label, diff --git a/src/Explorer/Menus/CommandBar/CommandBarUtil.tsx b/src/Explorer/Menus/CommandBar/CommandBarUtil.tsx index 384bd06cd..fc67ad894 100644 --- a/src/Explorer/Menus/CommandBar/CommandBarUtil.tsx +++ b/src/Explorer/Menus/CommandBar/CommandBarUtil.tsx @@ -7,6 +7,7 @@ import { IDropdownStyles, } from "@fluentui/react"; import { useQueryCopilot } from "hooks/useQueryCopilot"; +import { KeyboardHandlerMap } from "KeyboardShortcuts"; import * as React from "react"; import _ from "underscore"; import ChevronDownIcon from "../../../../images/Chevron_down.svg"; @@ -233,3 +234,28 @@ export const createConnectionStatus = (container: Explorer, poolId: PoolIdType, onRender: () => , }; }; + +export function createKeyboardHandlers(allButtons: CommandButtonComponentProps[]): KeyboardHandlerMap { + const handlers: KeyboardHandlerMap = {}; + + function createHandlers(buttons: CommandButtonComponentProps[]) { + buttons.forEach((button) => { + if (!button.disabled && button.keyboardAction) { + handlers[button.keyboardAction] = (e) => { + button.onCommandClick(e); + + // If the handler is bound, it means the button is visible and enabled, so we should prevent the default action + return true; + }; + } + + if (button.children && button.children.length > 0) { + createHandlers(button.children); + } + }); + } + + createHandlers(allButtons); + + return handlers; +} diff --git a/src/Explorer/Tabs/DocumentsTab.ts b/src/Explorer/Tabs/DocumentsTab.ts index 3591378da..263fa77bd 100644 --- a/src/Explorer/Tabs/DocumentsTab.ts +++ b/src/Explorer/Tabs/DocumentsTab.ts @@ -1,6 +1,7 @@ import { ItemDefinition, PartitionKey, PartitionKeyDefinition, QueryIterator, Resource } from "@azure/cosmos"; import { Platform, configContext } from "ConfigContext"; import { querySampleDocuments, readSampleDocument } from "Explorer/QueryCopilot/QueryCopilotUtilities"; +import { KeyboardAction } from "KeyboardShortcuts"; import { QueryConstants } from "Shared/Constants"; import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility"; import * as ko from "knockout"; @@ -907,6 +908,7 @@ export default class DocumentsTab extends TabsBase { buttons.push({ iconSrc: SaveIcon, iconAlt: label, + keyboardAction: KeyboardAction.SAVE_ITEM, onCommandClick: this.onSaveNewDocumentClick, commandButtonLabel: label, ariaLabel: label, @@ -936,6 +938,7 @@ export default class DocumentsTab extends TabsBase { buttons.push({ iconSrc: SaveIcon, iconAlt: label, + keyboardAction: KeyboardAction.SAVE_ITEM, onCommandClick: this.onSaveExistingDocumentClick, commandButtonLabel: label, ariaLabel: label, diff --git a/src/Explorer/Tabs/QueryTab/QueryTabComponent.tsx b/src/Explorer/Tabs/QueryTab/QueryTabComponent.tsx index fa849c212..e3f740a25 100644 --- a/src/Explorer/Tabs/QueryTab/QueryTabComponent.tsx +++ b/src/Explorer/Tabs/QueryTab/QueryTabComponent.tsx @@ -10,6 +10,7 @@ import { OnExecuteQueryClick, QueryDocumentsPerPage } from "Explorer/QueryCopilo import { QueryCopilotSidebar } from "Explorer/QueryCopilot/V2/Sidebar/QueryCopilotSidebar"; import { QueryResultSection } from "Explorer/Tabs/QueryTab/QueryResultSection"; import { useSelectedNode } from "Explorer/useSelectedNode"; +import { KeyboardAction } from "KeyboardShortcuts"; import { QueryConstants } from "Shared/Constants"; import { LocalStorageUtility, StorageKey, getRUThreshold, ruThresholdEnabled } from "Shared/StorageUtility"; import { Action } from "Shared/Telemetry/TelemetryConstants"; @@ -393,6 +394,7 @@ export default class QueryTabComponent extends React.Component OnExecuteQueryClick(this.props.copilotStore) : this.onExecuteQueryClick, @@ -408,6 +410,7 @@ export default class QueryTabComponent extends React.Component this.queryAbortController.abort(), commandButtonLabel: label, ariaLabel: label, diff --git a/src/Explorer/Tabs/StoredProcedureTab/StoredProcedureTabComponent.tsx b/src/Explorer/Tabs/StoredProcedureTab/StoredProcedureTabComponent.tsx index 98e398e1b..dc179b002 100644 --- a/src/Explorer/Tabs/StoredProcedureTab/StoredProcedureTabComponent.tsx +++ b/src/Explorer/Tabs/StoredProcedureTab/StoredProcedureTabComponent.tsx @@ -1,5 +1,6 @@ import { Resource, StoredProcedureDefinition } from "@azure/cosmos"; import { Pivot, PivotItem } from "@fluentui/react"; +import { KeyboardAction } from "KeyboardShortcuts"; import React from "react"; import ExecuteQueryIcon from "../../../../images/ExecuteQuery.svg"; import DiscardIcon from "../../../../images/discard.svg"; @@ -321,6 +322,7 @@ export default class StoredProcedureTabComponent extends React.Component< buttons.push({ iconSrc: SaveIcon, iconAlt: label, + keyboardAction: KeyboardAction.SAVE_ITEM, onCommandClick: this.onSaveClick, commandButtonLabel: label, ariaLabel: label, @@ -334,6 +336,7 @@ export default class StoredProcedureTabComponent extends React.Component< buttons.push({ iconSrc: SaveIcon, iconAlt: label, + keyboardAction: KeyboardAction.SAVE_ITEM, onCommandClick: this.onUpdateClick, commandButtonLabel: label, ariaLabel: label, @@ -360,6 +363,7 @@ export default class StoredProcedureTabComponent extends React.Component< buttons.push({ iconSrc: ExecuteQueryIcon, iconAlt: label, + keyboardAction: KeyboardAction.EXECUTE_ITEM, onCommandClick: () => { this.collection.container.openExecuteSprocParamsPanel(this.node); }, diff --git a/src/Explorer/Tabs/TriggerTabContent.tsx b/src/Explorer/Tabs/TriggerTabContent.tsx index bf756598e..2c7d32ab4 100644 --- a/src/Explorer/Tabs/TriggerTabContent.tsx +++ b/src/Explorer/Tabs/TriggerTabContent.tsx @@ -1,5 +1,6 @@ import { TriggerDefinition } from "@azure/cosmos"; import { Dropdown, IDropdownOption, Label, TextField } from "@fluentui/react"; +import { KeyboardAction } from "KeyboardShortcuts"; import React, { Component } from "react"; import DiscardIcon from "../../../images/discard.svg"; import SaveIcon from "../../../images/save-cosmos.svg"; @@ -227,6 +228,7 @@ export class TriggerTabContent extends Component boolean | void; + +export type KeyboardHandlerMap = Partial>; + +/** + * The possible actions that can be triggered by keyboard shortcuts. + */ +export enum KeyboardAction { + NEW_QUERY = "NEW_QUERY", + EXECUTE_ITEM = "EXECUTE_ITEM", + CANCEL_QUERY = "CANCEL_QUERY", + SAVE_ITEM = "SAVE_ITEM", + OPEN_QUERY = "OPEN_QUERY", + OPEN_QUERY_FROM_DISK = "OPEN_QUERY_FROM_DISK", +} + +/** + * 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"], + [KeyboardAction.EXECUTE_ITEM]: ["Shift+Enter"], + [KeyboardAction.CANCEL_QUERY]: ["Escape"], + [KeyboardAction.SAVE_ITEM]: ["$mod+S"], + [KeyboardAction.OPEN_QUERY]: ["$mod+O"], + [KeyboardAction.OPEN_QUERY_FROM_DISK]: ["$mod+Shift+O"], +}; + +interface KeyboardShortcutState { + /** + * A set of all the keyboard shortcuts handlers. + */ + allHandlers: KeyboardHandlerMap; + + /** + * Sets the keyboard shortcut handlers. + */ + setHandlers: (handlers: KeyboardHandlerMap) => void; +} + +export const useKeyboardActionHandlers: UseStore = create((set) => ({ + allHandlers: {}, + setHandlers: (handlers: Partial>) => { + set({ allHandlers: handlers }); + }, +})); + +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 = {}; +(Object.keys(bindings) as KeyboardAction[]).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}; +} diff --git a/src/Main.tsx b/src/Main.tsx index c6b79b139..f79ee29f1 100644 --- a/src/Main.tsx +++ b/src/Main.tsx @@ -21,6 +21,7 @@ import "../externals/jquery.typeahead.min.js"; // Image Dependencies import { Platform } from "ConfigContext"; import { QueryCopilotCarousel } from "Explorer/QueryCopilot/CopilotCarousel"; +import { KeyboardShortcutRoot } from "KeyboardShortcuts"; import "../images/CosmosDB_rgb_ui_lighttheme.ico"; import hdeConnectImage from "../images/HdeConnectCosmosDB.svg"; import "../images/favicon.ico"; @@ -91,52 +92,54 @@ const App: React.FunctionComponent = () => { } return ( -
-
-
- {/* Main Command Bar - Start */} - - {/* Collections Tree and Tabs - Begin */} -
- {/* Collections Tree - Start */} - {userContext.apiType !== "Postgres" && userContext.apiType !== "VCoreMongo" && ( -
-
- {/* Collections Tree Expanded - Start */} - - {/* Collections Tree Expanded - End */} - {/* Collections Tree Collapsed - Start */} - - {/* Collections Tree Collapsed - End */} + +
+
+
+ {/* Main Command Bar - Start */} + + {/* Collections Tree and Tabs - Begin */} +
+ {/* Collections Tree - Start */} + {userContext.apiType !== "Postgres" && userContext.apiType !== "VCoreMongo" && ( +
+
+ {/* Collections Tree Expanded - Start */} + + {/* Collections Tree Expanded - End */} + {/* Collections Tree Collapsed - Start */} + + {/* Collections Tree Collapsed - End */} +
-
- )} - -
- {/* Collections Tree and Tabs - End */} - + {/* Collections Tree and Tabs - End */} +
+ + + {} + {} + {} + {}
- - - {} - {} - {} - {} -
+ ); };