[Task 3061766] Global Keyboard Shortcuts, implemented through the Command Bar (#1789)
* keyboard shortcuts using tinykeys * refmt and fix lints * retarget keyboard shortcuts to the body instead of the root element of the React component tree * refmt * Update src/Explorer/Menus/CommandBar/CommandBarUtil.tsx Co-authored-by: Laurent Nguyen <laurent.nguyen@microsoft.com> * add Save binding to New Item command bar --------- Co-authored-by: Laurent Nguyen <laurent.nguyen@microsoft.com>
This commit is contained in:
parent
e0cb3da6aa
commit
a44ed1f45c
|
@ -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",
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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<CommandButtonComponentProps> {
|
||||
|
|
|
@ -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<Props> = ({ 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<Props> = ({ container }: Props) => {
|
|||
},
|
||||
};
|
||||
|
||||
const allButtons = staticButtons.concat(contextButtons).concat(controlButtons);
|
||||
const keyboardHandlers = CommandBarUtil.createKeyboardHandlers(allButtons);
|
||||
setKeyboardShortcutHandlers(keyboardHandlers);
|
||||
|
||||
return (
|
||||
<div className="commandBarContainer" style={{ display: isHidden ? "none" : "initial" }}>
|
||||
<FluentCommandBar
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { KeyboardAction } from "KeyboardShortcuts";
|
||||
import { ReactTabKind, useTabs } from "hooks/useTabs";
|
||||
import * as React from "react";
|
||||
import AddCollectionIcon from "../../../../images/AddCollection.svg";
|
||||
|
@ -297,6 +298,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.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", <BrowseQueriesPane explorer={container} />),
|
||||
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", <LoadQueryPane />),
|
||||
commandButtonLabel: label,
|
||||
ariaLabel: label,
|
||||
|
|
|
@ -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: () => <ConnectionStatus container={container} poolId={poolId} />,
|
||||
};
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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<IQueryTabComponen
|
|||
buttons.push({
|
||||
iconSrc: ExecuteQueryIcon,
|
||||
iconAlt: label,
|
||||
keyboardAction: KeyboardAction.EXECUTE_ITEM,
|
||||
onCommandClick: this.props.isSampleCopilotActive
|
||||
? () => OnExecuteQueryClick(this.props.copilotStore)
|
||||
: this.onExecuteQueryClick,
|
||||
|
@ -408,6 +410,7 @@ export default class QueryTabComponent extends React.Component<IQueryTabComponen
|
|||
buttons.push({
|
||||
iconSrc: SaveQueryIcon,
|
||||
iconAlt: label,
|
||||
keyboardAction: KeyboardAction.SAVE_ITEM,
|
||||
onCommandClick: this.onSaveQueryClick,
|
||||
commandButtonLabel: label,
|
||||
ariaLabel: label,
|
||||
|
@ -468,6 +471,7 @@ export default class QueryTabComponent extends React.Component<IQueryTabComponen
|
|||
buttons.push({
|
||||
iconSrc: CancelQueryIcon,
|
||||
iconAlt: label,
|
||||
keyboardAction: KeyboardAction.CANCEL_QUERY,
|
||||
onCommandClick: () => this.queryAbortController.abort(),
|
||||
commandButtonLabel: label,
|
||||
ariaLabel: label,
|
||||
|
|
|
@ -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);
|
||||
},
|
||||
|
|
|
@ -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<TriggerTab, ITriggerTabContentS
|
|||
...this,
|
||||
iconSrc: SaveIcon,
|
||||
iconAlt: label,
|
||||
keyboardAction: KeyboardAction.SAVE_ITEM,
|
||||
onCommandClick: this.onSaveClick,
|
||||
commandButtonLabel: label,
|
||||
ariaLabel: label,
|
||||
|
@ -241,6 +243,7 @@ export class TriggerTabContent extends Component<TriggerTab, ITriggerTabContentS
|
|||
...this,
|
||||
iconSrc: SaveIcon,
|
||||
iconAlt: label,
|
||||
keyboardAction: KeyboardAction.SAVE_ITEM,
|
||||
onCommandClick: this.onUpdateClick,
|
||||
commandButtonLabel: label,
|
||||
ariaLabel: label,
|
||||
|
|
|
@ -1,12 +1,13 @@
|
|||
import { UserDefinedFunctionDefinition } from "@azure/cosmos";
|
||||
import { 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";
|
||||
import * as Constants from "../../Common/Constants";
|
||||
import { getErrorMessage, getErrorStack } from "../../Common/ErrorHandlingUtils";
|
||||
import { createUserDefinedFunction } from "../../Common/dataAccess/createUserDefinedFunction";
|
||||
import { updateUserDefinedFunction } from "../../Common/dataAccess/updateUserDefinedFunction";
|
||||
import { getErrorMessage, getErrorStack } from "../../Common/ErrorHandlingUtils";
|
||||
import * as ViewModels from "../../Contracts/ViewModels";
|
||||
import { Action } from "../../Shared/Telemetry/TelemetryConstants";
|
||||
import * as TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
|
||||
|
@ -80,6 +81,7 @@ export default class UserDefinedFunctionTabContent extends Component<
|
|||
setState: this.setState,
|
||||
iconSrc: SaveIcon,
|
||||
iconAlt: label,
|
||||
keyboardAction: KeyboardAction.SAVE_ITEM,
|
||||
onCommandClick: this.onSaveClick,
|
||||
commandButtonLabel: label,
|
||||
ariaLabel: label,
|
||||
|
@ -94,6 +96,7 @@ export default class UserDefinedFunctionTabContent extends Component<
|
|||
...this,
|
||||
iconSrc: SaveIcon,
|
||||
iconAlt: label,
|
||||
keyboardAction: KeyboardAction.SAVE_ITEM,
|
||||
onCommandClick: this.onUpdateClick,
|
||||
commandButtonLabel: label,
|
||||
ariaLabel: label,
|
||||
|
|
|
@ -0,0 +1,89 @@
|
|||
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<Record<KeyboardAction, KeyboardActionHandler>>;
|
||||
|
||||
/**
|
||||
* 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<KeyboardAction, string[]> = {
|
||||
// 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<KeyboardShortcutState> = create((set) => ({
|
||||
allHandlers: {},
|
||||
setHandlers: (handlers: Partial<Record<KeyboardAction, KeyboardActionHandler>>) => {
|
||||
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<unknown>) {
|
||||
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}</>;
|
||||
}
|
|
@ -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,6 +92,7 @@ const App: React.FunctionComponent = () => {
|
|||
}
|
||||
|
||||
return (
|
||||
<KeyboardShortcutRoot>
|
||||
<div className="flexContainer" aria-hidden="false">
|
||||
<div id="divExplorer" className="flexContainer hideOverflows">
|
||||
<div id="freeTierTeachingBubble"> </div>
|
||||
|
@ -137,6 +139,7 @@ const App: React.FunctionComponent = () => {
|
|||
{<MongoQuickstartTutorial />}
|
||||
{<QueryCopilotCarousel isOpen={isCopilotCarouselOpen} explorer={explorer} />}
|
||||
</div>
|
||||
</KeyboardShortcutRoot>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
Loading…
Reference in New Issue