diff --git a/src/Explorer/Controls/CommandButton/CommandButtonComponent.tsx b/src/Explorer/Controls/CommandButton/CommandButtonComponent.tsx index 1e5cfc171..df9b1216e 100644 --- a/src/Explorer/Controls/CommandButton/CommandButtonComponent.tsx +++ b/src/Explorer/Controls/CommandButton/CommandButtonComponent.tsx @@ -1,6 +1,8 @@ /** * React component for Command button component. */ +import { KeyboardShortcutAction } from "KeyboardShortcuts"; +import { ExtendedKeyboardEvent } from "mousetrap"; import * as React from "react"; import CollapseChevronDownIcon from "../../../../images/QueryBuilder/CollapseChevronDown_16x.png"; import { KeyCodes } from "../../../Common/Constants"; @@ -30,7 +32,7 @@ export interface CommandButtonComponentProps { /** * Click handler for command button click */ - onCommandClick: (e: React.SyntheticEvent) => void; + onCommandClick: (e: React.SyntheticEvent | ExtendedKeyboardEvent) => void; /** * Label for the button @@ -107,10 +109,13 @@ export interface CommandButtonComponentProps { * Vertical bar to divide buttons */ isDivider?: boolean; + /** * Aria-label for the button */ ariaLabel: string; + + keyboardShortcut?: KeyboardShortcutAction; } export class CommandButtonComponent extends React.Component { diff --git a/src/Explorer/Menus/CommandBar/CommandBarComponentAdapter.tsx b/src/Explorer/Menus/CommandBar/CommandBarComponentAdapter.tsx index 141bda577..f55ae21a7 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 { KeyboardShortcutAction, KeyboardShortcutContributor, KeyboardShortcutHandler, useKeyboardShortcutContributor } 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 = useKeyboardShortcutContributor(KeyboardShortcutContributor.COMMAND_BAR); if (userContext.apiType === "Postgres" || userContext.apiType === "VCoreMongo") { const buttons = @@ -105,6 +107,18 @@ export const CommandBar: React.FC = ({ container }: Props) => { }, }; + const allButtons = staticButtons.concat(contextButtons).concat(controlButtons); + const handlers: Partial> = {}; + allButtons.forEach((button) => { + if(button.keyboardShortcut) { + handlers[button.keyboardShortcut] = (e) => { + button.onCommandClick(e); + return false; + } + } + }); + setKeyboardShortcutHandlers(handlers); + 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, + keyboardShortcut: KeyboardShortcutAction.NEW_QUERY, onCommandClick: () => { const selectedCollection: ViewModels.Collection = selectedNodeState.findSelectedCollection(); selectedCollection && selectedCollection.onNewMongoQueryClick(selectedCollection); diff --git a/src/Explorer/Tabs/QueryTab/QueryTabComponent.tsx b/src/Explorer/Tabs/QueryTab/QueryTabComponent.tsx index fa849c212..b725b4c91 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 { KeyboardShortcutAction } 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, diff --git a/src/KeyboardShortcuts.tsx b/src/KeyboardShortcuts.tsx index 8fb99069b..678d19e1b 100644 --- a/src/KeyboardShortcuts.tsx +++ b/src/KeyboardShortcuts.tsx @@ -1,115 +1,135 @@ -import { useSelectedNode } from "Explorer/useSelectedNode"; -import { userContext } from "UserContext"; import Mousetrap, { ExtendedKeyboardEvent } from "mousetrap"; import * as React from "react"; -import * as ViewModels from "../Contracts/ViewModels"; +import create, { UseStore } from "zustand"; -type KeyboardShortcutRootProps = React.PropsWithChildren; -type KeyboardShortcutHandler = (e: ExtendedKeyboardEvent, combo: string) => boolean | void; +// Provides a system of Keyboard Shortcuts that can be contributed to by different parts of the application. +// +// The goals of this system are: +// * Shortcuts can be contributed from different parts of the application (e.g. the command bar, specified editor tabs, etc.) +// * Contributors may only provide some of their shortcuts, others may be out-of-scope for the current context. +// * Contributors don't have to add/remove handlers individually, they can use a declarative pattern to set all their handlers at once. +// +// So, in order to do that, we store handlers in a two-level hierarchy: +// 1. We store a mapping of contributors to their Contributions. +// 2. Each Contribution is a mapping of actions to their handlers. +// +// Thus, a Contributor sets all its handlers at once, replacing all handlers previously contributed by that Contributor. +// The system then merges all Contributions into a single set of handlers, with duplicate handlers being handled in the order that the Contributors are processed. -export interface KeyboardShortcutBinding { - /** - * The keyboard shortcut to bind to. This can be a single string or an array of strings. - * Any combination supported by Mousetrap (https://craig.is/killing/mice#api.bind) is valid here. - */ - keys: string | string[], - /** - * The handler to run when the keyboard shortcut is pressed. - * @param e The keyboard event that triggered the shortcut. - * @param combo The specific keyboard combination that was matched (in case a single handler is used for multiple shortcuts). - * @returns If the handler returns `false`, the default action for the keyboard shortcut will be prevented AND propagation of the event will be stopped. - */ - handler: KeyboardShortcutHandler, +export type KeyboardShortcutHandler = (e: ExtendedKeyboardEvent, combo: string) => boolean | void; - /** - * The event to bind the keyboard shortcut to (keydown, keyup, etc.). - * The default is 'keydown' - */ - action?: string, +/** + * Lists all the possible contributors to keyboard shortcut handlers. + * + * A "Contributor" is a part of the application that can contribute keyboard shortcut handlers. + * The contributor must set all it's keyboard shortcut handlers at once. + * This allows the contributor to easily replace all it's keyboard shortcuts at once. + * + * For example, the command bar adds/removes keyboard shortcut handlers based on the current context, using the existing logic that determines which buttons are shown. + * Isolating contributors like this allow the command bar to easily replace all it's keyboard shortcuts when the context changes without breaking keyboard shortcuts contributed by other parts of the application. + */ +export enum KeyboardShortcutContributor { + COMMAND_BAR = "COMMAND_BAR", } /** - * Wraps the provided keyboard shortcut handler in one that only runs if a collection is selected. - * @param callback The callback to run if a collection is selected. - * @returns If the handler returns `false`, the default action for the keyboard shortcut will be prevented AND propagation of the event will be stopped. + * The order in which contributors are processed. + * This is important because the last contributor to set a handler for a given action will be the one that is used. */ -function withSelectedCollection(callback: (selectedCollection: ViewModels.Collection, e: ExtendedKeyboardEvent, combo: string) => boolean | void): KeyboardShortcutHandler { - return (e, combo) => { - const state = useSelectedNode.getState(); - if (!state.selectedNode) { - return; - } - - const selectedCollection = state.findSelectedCollection(); - if (selectedCollection) { - return callback(selectedCollection, e, combo); - } - }; +const contributorOrder: KeyboardShortcutContributor[] = [ + KeyboardShortcutContributor.COMMAND_BAR, +]; +/** + * The possible actions that can be triggered by keyboard shortcuts. + */ +export enum KeyboardShortcutAction { + NEW_QUERY = "NEW_QUERY", + EXECUTE_ITEM = "EXECUTE_ITEM", } -const bindings: KeyboardShortcutBinding[] = [ - { - keys: ["ctrl+j"], - handler: withSelectedCollection((selectedCollection) => { - if (userContext.apiType === "SQL" || userContext.apiType === "Gremlin") { - selectedCollection.onNewQueryClick(selectedCollection); - return false; - } else if (userContext.apiType === "Mongo") { - selectedCollection.onNewMongoQueryClick(selectedCollection); - return false; - } - return true; - }), - }, - { - keys: ["shift+enter"], - handler: () => { - alert("TODO: Execute Item"); - return false; - }, - }, - { - keys: ["esc"], - handler: () => { - alert("TODO: Cancel Query"); - return false; - }, - }, - { - keys: ["mod+s"], - handler: () => { - alert("TODO: Save Query"); - return false; - }, - }, - { - keys: ["mod+o"], - handler: () => { - alert("TODO: Open Query"); - return false; - }, - }, - { - keys: ["mod+shift+o"], - handler: () => { - alert("TODO: Open Query from Disk"); - return false; - }, - }, - { - keys: ["mod+s"], - handler: () => { - alert("TODO: Save"); - return false; - }, - }, -] +/** + * The default 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 though, it will not be triggered unless a handler is set for it. + */ +const bindings: Record = { + [KeyboardShortcutAction.NEW_QUERY]: ["ctrl+j"], + [KeyboardShortcutAction.EXECUTE_ITEM]: ["shift+enter"], +}; -export function KeyboardShortcutRoot({ children }: KeyboardShortcutRootProps) { +/** + * Represents all the handlers provided by a contributor. + */ +export type KeyboardShortcutContribution = Partial>; + +interface KeyboardShortcutState { + /** + * Collects all the contributions from different contributors. + */ + contributions: Partial>; + + /** + * A merged set of all the handlers from all contributors. + */ + allHandlers: KeyboardShortcutContribution; + + /** + * Sets the keyboard shortcut handlers for a given contributor. + */ + setHandlers: (contributor: KeyboardShortcutContributor, handlers: Partial>) => void; +} + +/** + * Gets the setHandlers function for a given contributor. + * @param contributor The contributor to get the setHandlers function for. + * @returns A function that sets the keyboard shortcut handlers for the given contributor. + */ +export const useKeyboardShortcutContributor = (contributor: KeyboardShortcutContributor) => { + const setHandlers = useKeyboardShortcutHandlers.getState().setHandlers; + return (handlers: Partial>) => { + setHandlers(contributor, handlers); + }; +} + +const useKeyboardShortcutHandlers: UseStore = create((set, get) => ({ + contributions: {}, + allHandlers: {}, + setHandlers: (contributor: KeyboardShortcutContributor, handlers: Partial>) => { + const current = get(); + + // Update the list of contributions. + const newContributions = { ...current.contributions, [contributor]: handlers }; + + // Merge all the contributions into a single set of handlers. + const allHandlers: KeyboardShortcutContribution = {}; + contributorOrder.forEach((contributor) => { + const contribution = newContributions[contributor]; + if (contribution) { + (Object.keys(contribution) as KeyboardShortcutAction[]).forEach((action) => { + allHandlers[action] = contribution[action]; + }); + } + }); + set({ contributions: newContributions, allHandlers }) + } +})); + +function createHandler(action: KeyboardShortcutAction): KeyboardShortcutHandler { + return (e, combo) => { + const handlers = useKeyboardShortcutHandlers.getState().allHandlers; + const handler = handlers[action]; + if (handler) { + return handler(e, combo); + } + }; +} + +export function KeyboardShortcutRoot(props: React.HTMLProps) { + const ref = React.useRef(null); React.useEffect(() => { - const m = new Mousetrap(document.body); + const m = new Mousetrap(ref.current); const existingStopCallback = m.stopCallback; m.stopCallback = (e, element, combo) => { // Don't block mousetrap callback in the Monaco editor. @@ -120,12 +140,11 @@ export function KeyboardShortcutRoot({ children }: KeyboardShortcutRootProps) { return existingStopCallback(e, element, combo); }; - bindings.forEach(b => { - m.bind(b.keys, b.handler, b.action); + (Object.keys(bindings) as KeyboardShortcutAction[]).forEach((action) => { + m.bind(bindings[action], createHandler(action)); }); - }, []); // Using an empty dependency array means React will only run this _once_ when the component is mounted. + }, [ref]); // We only need to re-render the component when the ref changes. - return <> - {children} - ; + return
+
; } \ No newline at end of file diff --git a/src/Main.tsx b/src/Main.tsx index f79ee29f1..955776236 100644 --- a/src/Main.tsx +++ b/src/Main.tsx @@ -92,53 +92,51 @@ 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 */} + - - - {} - {} - {} - {}
+ + + {} + {} + {} + {} ); };