2024-04-17 19:19:09 +01:00
|
|
|
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>>;
|
|
|
|
|
2024-04-19 21:44:30 +01:00
|
|
|
/**
|
|
|
|
* 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 {
|
2024-04-30 18:03:27 +01:00
|
|
|
/** Keyboard actions related to tab navigation. */
|
2024-04-19 21:44:30 +01:00
|
|
|
TABS = "TABS",
|
2024-04-30 18:03:27 +01:00
|
|
|
|
2024-08-01 18:02:36 +01:00
|
|
|
/** Keyboard actions managed by the command bar. */
|
2024-04-19 21:44:30 +01:00
|
|
|
COMMAND_BAR = "COMMAND_BAR",
|
2024-04-30 18:03:27 +01:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Keyboard actions specific to the active tab.
|
|
|
|
* This group is automatically cleared when the active tab changes.
|
|
|
|
*/
|
|
|
|
ACTIVE_TAB = "ACTIVE_TAB",
|
2024-08-01 18:02:36 +01:00
|
|
|
|
|
|
|
/** Keyboard actions managed by the global commands section, in the top-left corner. */
|
|
|
|
GLOBAL_COMMANDS = "GLOBAL_COMMANDS",
|
2024-04-19 21:44:30 +01:00
|
|
|
}
|
|
|
|
|
2024-04-17 19:19:09 +01:00
|
|
|
/**
|
|
|
|
* The possible actions that can be triggered by keyboard shortcuts.
|
|
|
|
*/
|
|
|
|
export enum KeyboardAction {
|
|
|
|
NEW_QUERY = "NEW_QUERY",
|
|
|
|
EXECUTE_ITEM = "EXECUTE_ITEM",
|
2024-04-19 17:43:27 +01:00
|
|
|
CANCEL_OR_DISCARD = "CANCEL_OR_DISCARD",
|
2024-04-17 19:19:09 +01:00
|
|
|
SAVE_ITEM = "SAVE_ITEM",
|
2024-04-24 23:11:51 +01:00
|
|
|
DOWNLOAD_ITEM = "DOWNLOAD_ITEM",
|
2024-04-17 19:19:09 +01:00
|
|
|
OPEN_QUERY = "OPEN_QUERY",
|
|
|
|
OPEN_QUERY_FROM_DISK = "OPEN_QUERY_FROM_DISK",
|
2024-04-19 17:43:27 +01:00
|
|
|
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",
|
2024-04-19 21:44:30 +01:00
|
|
|
SELECT_LEFT_TAB = "SELECT_LEFT_TAB",
|
|
|
|
SELECT_RIGHT_TAB = "SELECT_RIGHT_TAB",
|
|
|
|
CLOSE_TAB = "CLOSE_TAB",
|
2024-04-30 18:03:27 +01:00
|
|
|
SEARCH = "SEARCH",
|
|
|
|
CLEAR_SEARCH = "CLEAR_SEARCH",
|
2024-04-17 19:19:09 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* 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.
|
|
|
|
|
2024-04-19 17:43:27 +01:00
|
|
|
[KeyboardAction.NEW_QUERY]: ["$mod+J", "Alt+N Q"],
|
2024-04-23 17:08:29 +01:00
|
|
|
[KeyboardAction.EXECUTE_ITEM]: ["Shift+Enter", "F5"],
|
2024-04-19 17:43:27 +01:00
|
|
|
[KeyboardAction.CANCEL_OR_DISCARD]: ["Escape"],
|
2024-04-17 19:19:09 +01:00
|
|
|
[KeyboardAction.SAVE_ITEM]: ["$mod+S"],
|
2024-04-24 23:11:51 +01:00
|
|
|
[KeyboardAction.DOWNLOAD_ITEM]: ["$mod+Shift+S"],
|
2024-04-17 19:19:09 +01:00
|
|
|
[KeyboardAction.OPEN_QUERY]: ["$mod+O"],
|
|
|
|
[KeyboardAction.OPEN_QUERY_FROM_DISK]: ["$mod+Shift+O"],
|
2024-04-19 17:43:27 +01:00
|
|
|
[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"],
|
2024-04-23 23:46:41 +01:00
|
|
|
[KeyboardAction.SELECT_LEFT_TAB]: ["$mod+Alt+[", "$mod+Shift+F6"],
|
|
|
|
[KeyboardAction.SELECT_RIGHT_TAB]: ["$mod+Alt+]", "$mod+F6"],
|
2024-04-19 21:44:30 +01:00
|
|
|
[KeyboardAction.CLOSE_TAB]: ["$mod+Alt+W"],
|
2024-04-30 18:03:27 +01:00
|
|
|
[KeyboardAction.SEARCH]: ["$mod+Shift+F"],
|
|
|
|
[KeyboardAction.CLEAR_SEARCH]: ["$mod+Shift+C"],
|
2024-04-17 19:19:09 +01:00
|
|
|
};
|
|
|
|
|
|
|
|
interface KeyboardShortcutState {
|
|
|
|
/**
|
|
|
|
* A set of all the keyboard shortcuts handlers.
|
|
|
|
*/
|
|
|
|
allHandlers: KeyboardHandlerMap;
|
|
|
|
|
|
|
|
/**
|
2024-04-19 21:44:30 +01:00
|
|
|
* A set of all the groups of keyboard shortcuts handlers.
|
|
|
|
*/
|
|
|
|
groups: Partial<Record<KeyboardActionGroup, KeyboardHandlerMap>>;
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Sets the keyboard shortcut handlers for the given group.
|
2024-04-17 19:19:09 +01:00
|
|
|
*/
|
2024-04-19 21:44:30 +01:00
|
|
|
setHandlers: (group: KeyboardActionGroup, handlers: KeyboardHandlerMap) => void;
|
2024-04-17 19:19:09 +01:00
|
|
|
}
|
|
|
|
|
2024-04-30 18:03:27 +01:00
|
|
|
export type KeyboardHandlerSetter = (handlers: KeyboardHandlerMap) => void;
|
|
|
|
|
2024-04-19 21:44:30 +01:00
|
|
|
/**
|
|
|
|
* 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.
|
|
|
|
*/
|
2024-04-30 18:03:27 +01:00
|
|
|
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, {});
|
|
|
|
};
|
2024-04-19 21:44:30 +01:00
|
|
|
|
|
|
|
const useKeyboardActionHandlers: UseStore<KeyboardShortcutState> = create((set, get) => ({
|
2024-04-17 19:19:09 +01:00
|
|
|
allHandlers: {},
|
2024-04-19 21:44:30 +01:00
|
|
|
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 });
|
2024-04-17 19:19:09 +01:00
|
|
|
},
|
|
|
|
}));
|
|
|
|
|
|
|
|
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 = {};
|
2024-04-19 21:44:30 +01:00
|
|
|
eachKey(bindings).forEach((action) => {
|
2024-04-17 19:19:09 +01:00
|
|
|
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}</>;
|
|
|
|
}
|
2024-04-19 21:44:30 +01:00
|
|
|
|
|
|
|
/** A _typed_ version of `Object.keys` that preserves the original key type */
|
|
|
|
function eachKey<K extends string | number | symbol, V>(record: Partial<Record<K, V>>): K[] {
|
|
|
|
return Object.keys(record) as K[];
|
|
|
|
}
|