From 2cabb2fad8e16053a98a4b75678c3a3a638650f7 Mon Sep 17 00:00:00 2001 From: Aashish Anand <84689683+AshAnand34@users.noreply.github.com> Date: Tue, 27 May 2025 14:25:39 -0700 Subject: [PATCH] Keyboard shortcut support (#3890) * Add keyboard shortcuts help feature * Minor lint fixes * Enhance keyboard shortcuts feature by updating help shortcut and adding keyboard shortcuts button in Quick Links. Include new translation for keyboard shortcuts in locale. * Added documentation of keyboard shortcuts * refactor keyboard shortcuts to not render on every page (like login) limit shortcuts to admins update locales remove keyboard shortcut button - move to resources remove readme entry * move translation key --------- Co-authored-by: timothycarambat --- frontend/src/App.jsx | 2 + .../KeyboardShortcutsHelp/index.jsx | 60 ++++++++ .../src/components/PrivateRoute/index.jsx | 29 ++-- frontend/src/locales/en/common.js | 15 ++ .../src/pages/Main/Home/Resources/index.jsx | 14 ++ frontend/src/utils/keyboardShortcuts.js | 139 ++++++++++++++++++ 6 files changed, 249 insertions(+), 10 deletions(-) create mode 100644 frontend/src/components/KeyboardShortcutsHelp/index.jsx create mode 100644 frontend/src/utils/keyboardShortcuts.js diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 0bd74bfd..07ff2bad 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -17,6 +17,7 @@ import { PfpProvider } from "./PfpContext"; import { LogoProvider } from "./LogoContext"; import { FullScreenLoader } from "./components/Preloader"; import { ThemeProvider } from "./ThemeContext"; +import KeyboardShortcutsHelp from "@/components/KeyboardShortcutsHelp"; const Main = lazy(() => import("@/pages/Main")); const InvitePage = lazy(() => import("@/pages/Invite")); @@ -269,6 +270,7 @@ export default function App() { /> + diff --git a/frontend/src/components/KeyboardShortcutsHelp/index.jsx b/frontend/src/components/KeyboardShortcutsHelp/index.jsx new file mode 100644 index 00000000..021398f1 --- /dev/null +++ b/frontend/src/components/KeyboardShortcutsHelp/index.jsx @@ -0,0 +1,60 @@ +import React, { useEffect, useState } from "react"; +import { X } from "@phosphor-icons/react"; +import { useTranslation } from "react-i18next"; +import { + SHORTCUTS, + isMac, + KEYBOARD_SHORTCUTS_HELP_EVENT, +} from "@/utils/keyboardShortcuts"; + +export default function KeyboardShortcutsHelp() { + const [isOpen, setIsOpen] = useState(false); + const { t } = useTranslation(); + + useEffect(() => { + window.addEventListener(KEYBOARD_SHORTCUTS_HELP_EVENT, () => + setIsOpen((prev) => !prev) + ); + return () => { + window.removeEventListener(KEYBOARD_SHORTCUTS_HELP_EVENT, () => + setIsOpen(false) + ); + }; + }, []); + + if (!isOpen) return null; + return ( +
+
+
+

+ {t("keyboard-shortcuts.title")} +

+ +
+ +
+ {Object.entries(SHORTCUTS).map(([key, shortcut]) => ( +
+ + {t(`keyboard-shortcuts.shortcuts.${shortcut.translationKey}`)} + + + {isMac ? key : key.replace("⌘", "Ctrl")} + +
+ ))} +
+
+
+ ); +} diff --git a/frontend/src/components/PrivateRoute/index.jsx b/frontend/src/components/PrivateRoute/index.jsx index 98308fe4..0a3759fc 100644 --- a/frontend/src/components/PrivateRoute/index.jsx +++ b/frontend/src/components/PrivateRoute/index.jsx @@ -7,6 +7,7 @@ import { AUTH_TIMESTAMP, AUTH_TOKEN, AUTH_USER } from "@/utils/constants"; import { userFromStorage } from "@/utils/request"; import System from "@/models/system"; import UserMenu from "../UserMenu"; +import { KeyboardShortcutWrapper } from "@/utils/keyboardShortcuts"; // Used only for Multi-user mode only as we permission specific pages based on auth role. // When in single user mode we just bypass any authchecks. @@ -95,11 +96,15 @@ export function AdminRoute({ Component, hideUserMenu = false }) { const user = userFromStorage(); return isAuthd && (user?.role === "admin" || !multiUserMode) ? ( hideUserMenu ? ( - - ) : ( - + - + + ) : ( + + + + + ) ) : ( @@ -119,9 +124,11 @@ export function ManagerRoute({ Component }) { const user = userFromStorage(); return isAuthd && (user?.role !== "default" || !multiUserMode) ? ( - - - + + + + + ) : ( ); @@ -136,9 +143,11 @@ export default function PrivateRoute({ Component }) { } return isAuthd ? ( - - - + + + + + ) : ( ); diff --git a/frontend/src/locales/en/common.js b/frontend/src/locales/en/common.js index 138e1250..908a8b47 100644 --- a/frontend/src/locales/en/common.js +++ b/frontend/src/locales/en/common.js @@ -233,6 +233,7 @@ const TRANSLATIONS = { docs: "Docs", star: "Star on Github", }, + keyboardShortcuts: "Keyboard Shortcuts", }, }, @@ -981,6 +982,20 @@ const TRANSLATIONS = { theme: "Theme Preference", language: "Preferred language", }, + + "keyboard-shortcuts": { + title: "Keyboard Shortcuts", + shortcuts: { + settings: "Open Settings", + workspaceSettings: "Open Current Workspace Settings", + home: "Go to Home", + workspaces: "Manage Workspaces", + apiKeys: "API Keys Settings", + llmPreferences: "LLM Preferences", + chatSettings: "Chat Settings", + help: "Show keyboard shortcuts help", + }, + }, }; export default TRANSLATIONS; diff --git a/frontend/src/pages/Main/Home/Resources/index.jsx b/frontend/src/pages/Main/Home/Resources/index.jsx index df5c30fa..f8530339 100644 --- a/frontend/src/pages/Main/Home/Resources/index.jsx +++ b/frontend/src/pages/Main/Home/Resources/index.jsx @@ -1,9 +1,16 @@ import paths from "@/utils/paths"; import { ArrowCircleUpRight } from "@phosphor-icons/react"; import { useTranslation } from "react-i18next"; +import { KEYBOARD_SHORTCUTS_HELP_EVENT } from "@/utils/keyboardShortcuts"; export default function Resources() { const { t } = useTranslation(); + const showKeyboardShortcuts = () => { + window.dispatchEvent( + new CustomEvent(KEYBOARD_SHORTCUTS_HELP_EVENT, { detail: { show: true } }) + ); + }; + return (

@@ -28,6 +35,13 @@ export default function Resources() { {t("main-page.resources.links.star")} +

); diff --git a/frontend/src/utils/keyboardShortcuts.js b/frontend/src/utils/keyboardShortcuts.js new file mode 100644 index 00000000..024afc4d --- /dev/null +++ b/frontend/src/utils/keyboardShortcuts.js @@ -0,0 +1,139 @@ +import paths from "./paths"; +import { useEffect, useState } from "react"; +import { userFromStorage } from "./request"; + +export const KEYBOARD_SHORTCUTS_HELP_EVENT = "keyboard-shortcuts-help"; +export const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0; +export const SHORTCUTS = { + "⌘ + ,": { + translationKey: "settings", + action: () => { + window.location.href = paths.settings.interface(); + }, + }, + "⌘ + H": { + translationKey: "home", + action: () => { + window.location.href = paths.home(); + }, + }, + "⌘ + I": { + translationKey: "workspaces", + action: () => { + window.location.href = paths.settings.workspaces(); + }, + }, + "⌘ + K": { + translationKey: "apiKeys", + action: () => { + window.location.href = paths.settings.apiKeys(); + }, + }, + "⌘ + L": { + translationKey: "llmPreferences", + action: () => { + window.location.href = paths.settings.llmPreference(); + }, + }, + "⌘ + Shift + C": { + translationKey: "chatSettings", + action: () => { + window.location.href = paths.settings.chat(); + }, + }, + "⌘ + Shift + ?": { + translationKey: "help", + action: () => { + window.dispatchEvent( + new CustomEvent(KEYBOARD_SHORTCUTS_HELP_EVENT, { + detail: { show: true }, + }) + ); + }, + }, + F1: { + translationKey: "help", + action: () => { + window.dispatchEvent( + new CustomEvent(KEYBOARD_SHORTCUTS_HELP_EVENT, { + detail: { show: true }, + }) + ); + }, + }, +}; + +const LISTENERS = {}; +const modifier = isMac ? "meta" : "ctrl"; +for (const key in SHORTCUTS) { + const listenerKey = key + .replace("⌘", modifier) + .replaceAll(" ", "") + .toLowerCase(); + LISTENERS[listenerKey] = SHORTCUTS[key].action; +} + +// Convert keyboard event to shortcut key +function getShortcutKey(event) { + let key = ""; + if (event.metaKey || event.ctrlKey) key += modifier + "+"; + if (event.shiftKey) key += "shift+"; + if (event.altKey) key += "alt+"; + + // Handle special keys + if (event.key === ",") key += ","; + // Handle question mark or slash for help shortcut + else if (event.key === "?" || event.key === "/") key += "?"; + else if (event.key === "Control") + return ""; // Ignore Control key by itself + else if (event.key === "Shift") + return ""; // Ignore Shift key by itself + else key += event.key.toLowerCase(); + return key; +} + +// Initialize keyboard shortcuts +export function initKeyboardShortcuts() { + function handleKeyDown(event) { + const shortcutKey = getShortcutKey(event); + if (!shortcutKey) return; + + const action = LISTENERS[shortcutKey]; + if (action) { + event.preventDefault(); + action(); + } + } + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); +} + +function useKeyboardShortcuts() { + useEffect(() => { + // If there is a user and the user is not an admin do not register the event listener + // since some of the shortcuts are only available in multi-user mode as admin + const user = userFromStorage(); + if (!!user && user?.role !== "admin") return; + + function handleHelpEvent(e) { + setShowHelp(e.detail.show); + } + window.addEventListener(KEYBOARD_SHORTCUTS_HELP_EVENT, handleHelpEvent); + const cleanup = initKeyboardShortcuts(); + + return () => { + cleanup(); + window.removeEventListener( + KEYBOARD_SHORTCUTS_HELP_EVENT, + handleHelpEvent + ); + }; + }, []); + return; +} + +export function KeyboardShortcutWrapper({ children }) { + useKeyboardShortcuts(); + return children; +}