From 83dc6fc30c9c47055ae7b717a56fd5db543e1498 Mon Sep 17 00:00:00 2001 From: CatLover <152669316+catloversg@users.noreply.github.com> Date: Sun, 8 Dec 2024 22:08:11 +0700 Subject: [PATCH 1/3] MISC: Add key binding feature --- src/@types/global.d.ts | 6 + src/GameOptions/ui/GameOptionsRoot.tsx | 15 +- src/GameOptions/ui/GameOptionsSidebar.tsx | 1 + src/GameOptions/ui/KeyBindingPage.tsx | 308 +++++++++++++++++ src/ScriptEditor/ui/ScriptEditorRoot.tsx | 37 ++- src/ScriptEditor/ui/Toolbar.tsx | 13 +- src/Settings/Settings.ts | 5 + src/Sidebar/ui/SidebarRoot.tsx | 177 ++++++---- src/ui/Router.ts | 2 +- src/utils/KeyBindingUtils.ts | 388 ++++++++++++++++++++++ src/utils/helpers/keyCodes.ts | 1 + 11 files changed, 867 insertions(+), 86 deletions(-) create mode 100644 src/GameOptions/ui/KeyBindingPage.tsx create mode 100644 src/utils/KeyBindingUtils.ts diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts index 670eb9e44b..6c99f3be30 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -60,3 +60,9 @@ module "monaco-vim" { }; }; } + +declare interface Navigator { + keyboard?: { + getLayoutMap?: () => Promise>; + }; +} diff --git a/src/GameOptions/ui/GameOptionsRoot.tsx b/src/GameOptions/ui/GameOptionsRoot.tsx index e29013bbf6..f206051d1d 100644 --- a/src/GameOptions/ui/GameOptionsRoot.tsx +++ b/src/GameOptions/ui/GameOptionsRoot.tsx @@ -1,5 +1,5 @@ import { Box, Container, Typography } from "@mui/material"; -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import { GameOptionsSidebar } from "./GameOptionsSidebar"; import { GameplayPage } from "./GameplayPage"; import { InterfacePage } from "./InterfacePage"; @@ -7,6 +7,7 @@ import { MiscPage } from "./MiscPage"; import { NumericDisplayPage } from "./NumericDisplayOptions"; import { RemoteAPIPage } from "./RemoteAPIPage"; import { SystemPage } from "./SystemPage"; +import { KeyBindingPage } from "./KeyBindingPage"; interface IProps { save: () => void; @@ -15,7 +16,16 @@ interface IProps { softReset: () => void; reactivateTutorial: () => void; } -export type OptionsTabName = "System" | "Interface" | "Numeric Display" | "Gameplay" | "Misc" | "Remote API"; + +export type OptionsTabName = + | "System" + | "Interface" + | "Numeric Display" + | "Gameplay" + | "Misc" + | "Remote API" + | "Key Binding"; + const tabs: Record = { System: , Interface: , @@ -23,6 +33,7 @@ const tabs: Record = { Gameplay: , Misc: , "Remote API": , + "Key Binding": , }; export function GameOptionsRoot(props: IProps): React.ReactElement { diff --git a/src/GameOptions/ui/GameOptionsSidebar.tsx b/src/GameOptions/ui/GameOptionsSidebar.tsx index 3c4394b3bb..f704ed1287 100644 --- a/src/GameOptions/ui/GameOptionsSidebar.tsx +++ b/src/GameOptions/ui/GameOptionsSidebar.tsx @@ -115,6 +115,7 @@ export const GameOptionsSidebar = (props: IProps): React.ReactElement => { + { + const conflicts: Set = determineKeyBindingTypes(Settings.KeyBindings, newCombination); + // Check if the new combination is the same as the current key binding. + if (conflicts.has(keyBindingType)) { + const currentKeyBinding = getKeyCombination(Settings.KeyBindings, keyBindingType, isPrimary); + if ( + currentKeyBinding && + currentKeyBinding.control === newCombination.control && + currentKeyBinding.alt === newCombination.alt && + currentKeyBinding.shift === newCombination.shift && + currentKeyBinding.meta === newCombination.meta && + currentKeyBinding.code === newCombination.code + ) { + conflicts.delete(keyBindingType); + } + } + // Common single-key hotkeys. + if ( + isKeyCombinationPressed(newCombination, { code: KEYCODE.ESC }) || + isKeyCombinationPressed(newCombination, { code: KEYCODE.ENTER }) || + isKeyCombinationPressed(newCombination, { code: KEYCODE.NUMPAD_ENTER }) || + isKeyCombinationPressed(newCombination, { code: KEYCODE.TAB }) + ) { + conflicts.add("Common hotkeys"); + } + // Copy - Paste - Cut + if (window.navigator.userAgent.includes("Mac")) { + if ( + isKeyCombinationPressed(newCombination, { meta: true, code: KEYCODE.C }) || + isKeyCombinationPressed(newCombination, { meta: true, code: KEYCODE.V }) || + isKeyCombinationPressed(newCombination, { meta: true, code: KEYCODE.X }) + ) { + conflicts.add("Common hotkeys"); + } + } else { + if ( + isKeyCombinationPressed(newCombination, { control: true, code: KEYCODE.C }) || + isKeyCombinationPressed(newCombination, { control: true, code: KEYCODE.V }) || + isKeyCombinationPressed(newCombination, { control: true, code: KEYCODE.X }) + ) { + conflicts.add("Common hotkeys"); + } + } + // Terminal-ClearScreen + if (isKeyCombinationPressed(newCombination, { control: true, code: KEYCODE.L })) { + conflicts.add("Terminal-ClearScreen"); + } + // Bash hotkeys + if ( + Settings.EnableBashHotkeys && + (isKeyCombinationPressed(newCombination, { control: true, code: KEYCODE.M }) || + isKeyCombinationPressed(newCombination, { control: true, code: KEYCODE.P }) || + isKeyCombinationPressed(newCombination, { control: true, code: KEYCODE.C }) || + isKeyCombinationPressed(newCombination, { control: true, code: KEYCODE.A }) || + isKeyCombinationPressed(newCombination, { control: true, code: KEYCODE.E }) || + isKeyCombinationPressed(newCombination, { control: true, code: KEYCODE.B }) || + isKeyCombinationPressed(newCombination, { alt: true, code: KEYCODE.B }) || + isKeyCombinationPressed(newCombination, { control: true, code: KEYCODE.F }) || + isKeyCombinationPressed(newCombination, { alt: true, code: KEYCODE.F }) || + isKeyCombinationPressed(newCombination, { control: true, code: KEYCODE.H }) || + isKeyCombinationPressed(newCombination, { control: true, code: KEYCODE.D }) || + isKeyCombinationPressed(newCombination, { control: true, code: KEYCODE.W }) || + isKeyCombinationPressed(newCombination, { alt: true, code: KEYCODE.D }) || + isKeyCombinationPressed(newCombination, { control: true, code: KEYCODE.U }) || + isKeyCombinationPressed(newCombination, { control: true, code: KEYCODE.K })) + ) { + conflicts.add("Bash hotkeys"); + } + // Remove spoilers in the list + if (!knowAboutBitverse()) { + for (const conflict of conflicts) { + if (!isSpoilerKeyBindingType(conflict)) { + continue; + } + conflicts.delete(conflict); + conflicts.add("Endgame content"); + } + } + return conflicts; +} + +function SettingUpKeyBindingModal({ + open, + onClose, + keyBindingType, + isPrimary, +}: { + open: boolean; + onClose: () => void; + keyBindingType: KeyBindingType; + isPrimary: boolean; +}): React.ReactElement { + const [combination, setCombination] = useState(getKeyCombination(Settings.KeyBindings, keyBindingType, isPrimary)); + const [conflicts, setConflicts] = useState( + combination ? determineConflictKeys(keyBindingType, isPrimary, combination) : new Set(), + ); + const handler = useCallback( + (event: KeyboardEvent) => { + event.preventDefault(); + if (event.getModifierState(event.key)) { + return; + } + + const newCombination = convertKeyboardEventToKeyCombination(event); + setCombination(newCombination); + setConflicts(determineConflictKeys(keyBindingType, isPrimary, newCombination)); + }, + [keyBindingType, isPrimary], + ); + + useEffect(() => { + const currentKeyCombination = getKeyCombination(Settings.KeyBindings, keyBindingType, isPrimary); + setCombination(currentKeyCombination); + setConflicts( + currentKeyCombination + ? determineConflictKeys(keyBindingType, isPrimary, currentKeyCombination) + : new Set(), + ); + // Add/remove handler and emit an event that notifies subscribers if the player is setting up key bindings. + if (open) { + document.addEventListener("keydown", handler); + KeyBindingEvents.emit(KeyBindingEventType.StartSettingUp); + } else { + document.removeEventListener("keydown", handler); + KeyBindingEvents.emit(KeyBindingEventType.StopSettingUp); + } + }, [open, keyBindingType, isPrimary, handler]); + + const onClickClear = () => { + setCombination(null); + setConflicts(new Set()); + }; + const onClickDefault = () => { + const defaultKeyCombination = getKeyCombination(defaultKeyBinding, keyBindingType, true); + setCombination(defaultKeyCombination); + setConflicts( + defaultKeyCombination + ? determineConflictKeys(keyBindingType, isPrimary, defaultKeyCombination) + : new Set(), + ); + }; + const onClickOK = () => { + Settings.KeyBindings[keyBindingType][isPrimary ? 0 : 1] = combination; + onClose(); + }; + const onClickCancel = () => { + onClose(); + }; + + return ( + +
+ Press the key you would like to use + + {parseKeyCombinationToString(combination)} + + + {conflicts.size === 0 ? "No conflicts detected" : `Conflicts: ${[...conflicts]}`} + +
+ + +
+
+ + +
+
+
+ ); +} + +export function KeyBindingPage(): React.ReactElement { + const [popupOpen, setPopupOpen] = useState(false); + const [keyBindingType, setKeyBindingType] = useState(SimplePage.Options); + const [isPrimary, setIsPrimary] = useState(true); + + const showModal = (keyBindingType: KeyBindingType, isPrimary: boolean) => { + setPopupOpen(true); + setKeyBindingType(keyBindingType); + setIsPrimary(isPrimary); + }; + + const onClickHowToUse = () => { + dialogBoxCreate( + <> + + You can assign up to 2 key combinations per "action". If a key combination is assigned to many actions, + pressing that key combination will perform all those actions. + +
+ + Some key combinations cannot be used. Your OS and browsers usually have some built-in key bindings that cannot + be overridden. For example, on Windows, Windows+R always opens the "Run" dialog. + +
+ + When you set up key bindings, the list of conflicts may contain "Endgame content". It means that the key + combination is currently used for features that you have not unlocked. + +
+ + On non-Apple keyboards, the "Windows" key (other names: win, start, super, meta, etc.) is shown as ⊞. On Apple + keyboards, the command key is shown as ⌘. + +
+ + Do NOT use the right Alt key and the AltGr key, especially if you don't use the US keyboard layout. On many + keyboard layouts, those keys cause problems with key bindings. + + , + ); + }; + knowAboutBitverse(); + + return ( + + +
+ + + {getRecordKeys(Settings.KeyBindings) + .filter( + (keyBindingType) => + knowAboutBitverse() || !(SpoilerKeyBindingTypes as unknown as string[]).includes(keyBindingType), + ) + .map((keyBindingType) => ( + + + + + + ))} + +
+ {keyBindingType} + + + + +
+ setPopupOpen(false)} + keyBindingType={keyBindingType} + isPrimary={isPrimary} + /> +
+ ); +} diff --git a/src/ScriptEditor/ui/ScriptEditorRoot.tsx b/src/ScriptEditor/ui/ScriptEditorRoot.tsx index 97d391d411..e4e5d8dad7 100644 --- a/src/ScriptEditor/ui/ScriptEditorRoot.tsx +++ b/src/ScriptEditor/ui/ScriptEditorRoot.tsx @@ -34,6 +34,11 @@ import { type AST, getFileType, parseAST } from "../../utils/ScriptTransformer"; import { RamCalculationErrorCode } from "../../Script/RamCalculationErrorCodes"; import { hasScriptExtension, isLegacyScript } from "../../Paths/ScriptFilePath"; import { exceptionAlert } from "../../utils/helpers/exceptionAlert"; +import { + convertKeyboardEventToKeyCombination, + determineKeyBindingTypes, + ScriptEditorAction, +} from "../../utils/KeyBindingUtils"; interface IProps { // Map of filename -> code @@ -114,18 +119,28 @@ function Root(props: IProps): React.ReactElement { useEffect(() => { function keydown(event: KeyboardEvent): void { - if (Settings.DisableHotkeys) return; - //Ctrl + b - if (event.code == "KeyB" && (event.ctrlKey || event.metaKey)) { - event.preventDefault(); - Router.toPage(Page.Terminal); + if (Settings.DisableHotkeys) { + return; } - - // CTRL/CMD + S - if (event.code == "KeyS" && (event.ctrlKey || event.metaKey)) { - event.preventDefault(); - event.stopPropagation(); - save(); + if (event.getModifierState(event.key)) { + return; + } + const keyBindingTypes = determineKeyBindingTypes( + Settings.KeyBindings, + convertKeyboardEventToKeyCombination(event), + ); + for (const keyBindingType of keyBindingTypes) { + switch (keyBindingType) { + case ScriptEditorAction.Save: + event.preventDefault(); + event.stopPropagation(); + save(); + break; + case ScriptEditorAction.GoToTerminal: + event.preventDefault(); + Router.toPage(Page.Terminal); + break; + } } } document.addEventListener("keydown", keydown); diff --git a/src/ScriptEditor/ui/Toolbar.tsx b/src/ScriptEditor/ui/Toolbar.tsx index b1ea3e78fa..fa1efcbcbe 100644 --- a/src/ScriptEditor/ui/Toolbar.tsx +++ b/src/ScriptEditor/ui/Toolbar.tsx @@ -22,6 +22,7 @@ import { Settings } from "../../Settings/Settings"; import { OptionsModal, OptionsModalProps } from "./OptionsModal"; import { useScriptEditorContext } from "./ScriptEditorContext"; import { NsApiDocumentationLink } from "../../ui/React/NsApiDocumentationLink"; +import { parseKeyCombinationsToString, ScriptEditorAction } from "../../utils/KeyBindingUtils"; type IStandaloneCodeEditor = monaco.editor.IStandaloneCodeEditor; @@ -68,10 +69,14 @@ export function Toolbar({ editor, onSave }: IProps) { - - + + + + + + diff --git a/src/Settings/Settings.ts b/src/Settings/Settings.ts index 8a24cf090c..a01293a06b 100644 --- a/src/Settings/Settings.ts +++ b/src/Settings/Settings.ts @@ -4,6 +4,7 @@ import { defaultStyles } from "../Themes/Styles"; import { CursorStyle, CursorBlinking, WordWrapOptions } from "../ScriptEditor/ui/Options"; import { defaultMonacoTheme } from "../ScriptEditor/ui/themes"; import { objectAssert } from "../utils/helpers/typeAssertion"; +import { defaultKeyBinding } from "../utils/KeyBindingUtils"; /** * This function won't be able to catch **all** invalid hostnames, and it's still fine. In order to validate a hostname @@ -156,6 +157,8 @@ export const Settings = { useEngineeringNotation: false, /** Whether to disable suffixes and always use exponential form (scientific or engineering). */ disableSuffixes: false, + /** Key bindings */ + KeyBindings: structuredClone(defaultKeyBinding), load(saveString: string) { const save: unknown = JSON.parse(saveString); @@ -164,11 +167,13 @@ export const Settings = { save.styles && Object.assign(Settings.styles, save.styles); save.overview && Object.assign(Settings.overview, save.overview); save.EditorTheme && Object.assign(Settings.EditorTheme, save.EditorTheme); + save.KeyBindings && Object.assign(Settings.KeyBindings, save.KeyBindings); Object.assign(Settings, save, { theme: Settings.theme, styles: Settings.styles, overview: Settings.overview, EditorTheme: Settings.EditorTheme, + KeyBindings: Settings.KeyBindings, }); /** * The hostname and port of RFA have not been validated properly, so the save data may contain invalid data. In that diff --git a/src/Sidebar/ui/SidebarRoot.tsx b/src/Sidebar/ui/SidebarRoot.tsx index 8b7278a717..32ba63bd92 100644 --- a/src/Sidebar/ui/SidebarRoot.tsx +++ b/src/Sidebar/ui/SidebarRoot.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useCallback, useState, useEffect } from "react"; +import React, { useMemo, useCallback, useState, useEffect, useRef } from "react"; import { KEYCODE } from "../../utils/helpers/keyCodes"; import { styled, Theme, CSSObject } from "@mui/material/styles"; import { makeStyles } from "tss-react/mui"; @@ -41,7 +41,7 @@ import LiveHelpIcon from "@mui/icons-material/LiveHelp"; import BorderInnerSharp from "@mui/icons-material/BorderInnerSharp"; import { Router } from "../../ui/GameRoot"; -import { Page, isSimplePage } from "../../ui/Router"; +import { ComplexPage, Page, SimplePage, isSimplePage } from "../../ui/Router"; import { SidebarAccordion } from "./SidebarAccordion"; import { Player } from "@player"; import { CONSTANTS } from "../../Constants"; @@ -57,6 +57,15 @@ import { Locations } from "../../Locations/Locations"; import { useCycleRerender } from "../../ui/React/hooks"; import { playerHasDiscoveredGo } from "../../Go/effects/effect"; import { knowAboutBitverse } from "../../BitNode/BitNodeUtils"; +import { + convertKeyboardEventToKeyCombination, + determineKeyBindingTypes, + KeyBindingEvents, + KeyBindingEventType, + ScriptEditorAction, + type KeyBindingType, +} from "../../utils/KeyBindingUtils"; +import { throwIfReachable } from "../../utils/helpers/throwIfReachable"; const RotatedDoubleArrowIcon = React.forwardRef(function RotatedDoubleArrowIcon( props: { color: "primary" | "secondary" | "error" }, @@ -108,6 +117,7 @@ const useStyles = makeStyles()((theme: Theme) => ({ })); export function SidebarRoot(props: { page: Page }): React.ReactElement { + const isSettingUpKeyBindings = useRef(false); useCycleRerender(); let flash: Page | null = null; @@ -178,81 +188,112 @@ export function SidebarRoot(props: { page: Page }): React.ReactElement { [flash], ); + const canGoToPage = useCallback( + (keyBindingType: KeyBindingType) => { + switch (keyBindingType) { + case SimplePage.Terminal: + case ComplexPage.ScriptEditor: + case SimplePage.ActiveScripts: + case SimplePage.CreateProgram: + case SimplePage.Stats: + case SimplePage.Hacknet: + case SimplePage.City: + case SimplePage.Travel: + case SimplePage.Milestones: + case ComplexPage.Documentation: + case SimplePage.Achievements: + case SimplePage.Options: + return true; + case SimplePage.StaneksGift: + return canStaneksGift; + case SimplePage.Factions: + return canOpenFactions; + case SimplePage.Augmentations: + return canOpenAugmentations; + case SimplePage.Sleeves: + return canOpenSleeves; + case SimplePage.Grafting: + // TODO: Change this after PR 1809 is merged. + return false; + case ComplexPage.Job: + return canJob; + case SimplePage.StockMarket: + return canStockMarket; + case SimplePage.Bladeburner: + return canBladeburner; + case SimplePage.Corporation: + return canCorporation; + case SimplePage.Gang: + return canGang; + case SimplePage.Go: + return canIPvGO; + case ScriptEditorAction.Save: + case ScriptEditorAction.GoToTerminal: + return false; + default: + throwIfReachable(keyBindingType); + } + return false; + }, + [ + canStaneksGift, + canOpenFactions, + canOpenAugmentations, + canOpenSleeves, + canJob, + canStockMarket, + canBladeburner, + canCorporation, + canGang, + canIPvGO, + ], + ); + + useEffect(() => { + const clearSubscription = KeyBindingEvents.subscribe((eventType) => { + if (eventType === KeyBindingEventType.StartSettingUp) { + isSettingUpKeyBindings.current = true; + } + if (eventType === KeyBindingEventType.StopSettingUp) { + isSettingUpKeyBindings.current = false; + } + }); + return clearSubscription; + }, []); + useEffect(() => { - // Shortcuts to navigate through the game - // Alt-t - Terminal - // Alt-c - Character - // Alt-e - Script editor - // Alt-s - Active scripts - // Alt-h - Hacknet Nodes - // Alt-w - City - // Alt-j - Job - // Alt-r - Travel Agency of current city - // Alt-p - Create program - // Alt-f - Factions - // Alt-a - Augmentations - // Alt-u - Tutorial - // Alt-o - Options - // Alt-b - Bladeburner - // Alt-g - Gang function handleShortcuts(this: Document, event: KeyboardEvent): void { - if (Settings.DisableHotkeys) return; - if ((Player.currentWork && Player.focus) || Router.page() === Page.BitVerse) return; - if (event.code === KEYCODE.T && event.altKey) { - event.preventDefault(); - clickPage(Page.Terminal); - } else if (event.code === KEYCODE.C && event.altKey) { - event.preventDefault(); - clickPage(Page.Stats); - } else if (event.code === KEYCODE.E && event.altKey) { - event.preventDefault(); - clickPage(Page.ScriptEditor); - } else if (event.code === KEYCODE.S && event.altKey) { - event.preventDefault(); - clickPage(Page.ActiveScripts); - } else if (event.code === KEYCODE.H && event.altKey) { - event.preventDefault(); - clickPage(Page.Hacknet); - } else if (event.code === KEYCODE.W && event.altKey) { - event.preventDefault(); - clickPage(Page.City); - } else if (event.code === KEYCODE.J && event.altKey && !event.ctrlKey && !event.metaKey && canJob) { - // ctrl/cmd + alt + j is shortcut to open Chrome dev tools - event.preventDefault(); - clickPage(Page.Job); - } else if (event.code === KEYCODE.R && event.altKey) { - event.preventDefault(); - clickPage(Page.Travel); - } else if (event.code === KEYCODE.P && event.altKey) { - event.preventDefault(); - clickPage(Page.CreateProgram); - } else if (event.code === KEYCODE.F && event.altKey) { - if (props.page == Page.Terminal && Settings.EnableBashHotkeys) { - return; + if (Settings.DisableHotkeys) { + return; + } + if (event.getModifierState(event.key)) { + return; + } + if (isSettingUpKeyBindings.current) { + return; + } + if ((Player.currentWork && Player.focus) || Router.page() === Page.BitVerse) { + return; + } + const keyBindingTypes = determineKeyBindingTypes( + Settings.KeyBindings, + convertKeyboardEventToKeyCombination(event), + ); + for (const keyBindingType of keyBindingTypes) { + if (keyBindingType === ScriptEditorAction.Save || keyBindingType === ScriptEditorAction.GoToTerminal) { + continue; + } + if (!canGoToPage(keyBindingType)) { + continue; } event.preventDefault(); - clickPage(Page.Factions); - } else if (event.code === KEYCODE.A && event.altKey) { - event.preventDefault(); - clickPage(Page.Augmentations); - } else if (event.code === KEYCODE.U && event.altKey) { - event.preventDefault(); - clickPage(Page.Documentation); - } else if (event.code === KEYCODE.O && event.altKey) { - event.preventDefault(); - clickPage(Page.Options); - } else if (event.code === KEYCODE.B && event.altKey && Player.bladeburner) { - event.preventDefault(); - clickPage(Page.Bladeburner); - } else if (event.code === KEYCODE.G && event.altKey && Player.gang) { - event.preventDefault(); - clickPage(Page.Gang); + clickPage(keyBindingType); } } document.addEventListener("keydown", handleShortcuts); return () => document.removeEventListener("keydown", handleShortcuts); - }, [canJob, clickPage, props.page]); + }, [canGoToPage, clickPage, props.page]); const { classes } = useStyles(); const [open, setOpen] = useState(Settings.IsSidebarOpened); diff --git a/src/ui/Router.ts b/src/ui/Router.ts index 53f9cc0d96..c02580701d 100644 --- a/src/ui/Router.ts +++ b/src/ui/Router.ts @@ -2,7 +2,7 @@ import type { ScriptFilePath } from "../Paths/ScriptFilePath"; import type { TextFilePath } from "../Paths/TextFilePath"; import type { Faction } from "../Faction/Faction"; import type { Location } from "../Locations/Location"; -import { SaveData } from "../types"; +import type { SaveData } from "../types"; // This enum doesn't need enum helper support for now /** diff --git a/src/utils/KeyBindingUtils.ts b/src/utils/KeyBindingUtils.ts new file mode 100644 index 0000000000..e8285e3f31 --- /dev/null +++ b/src/utils/KeyBindingUtils.ts @@ -0,0 +1,388 @@ +import { getRecordEntries } from "../Types/Record"; +import { ComplexPage, SimplePage } from "../ui/Router"; +import { EventEmitter } from "./EventEmitter"; +import { KEYCODE } from "./helpers/keyCodes"; + +export enum ScriptEditorAction { + Save = "ScriptEditor-Save", + GoToTerminal = "ScriptEditor-GoToTerminal", +} + +export const SpoilerKeyBindingTypes = [ + SimplePage.StaneksGift, + SimplePage.Sleeves, + SimplePage.Grafting, + SimplePage.Bladeburner, + SimplePage.Corporation, + SimplePage.Gang, +] as const; + +export const KeyBindingTypes = [ + SimplePage.Terminal, + ComplexPage.ScriptEditor, + SimplePage.ActiveScripts, + SimplePage.CreateProgram, + SimplePage.Stats, + SimplePage.Factions, + SimplePage.Augmentations, + SimplePage.Hacknet, + SimplePage.City, + SimplePage.Travel, + ComplexPage.Job, + SimplePage.StockMarket, + SimplePage.Go, + SimplePage.Milestones, + ComplexPage.Documentation, + SimplePage.Achievements, + SimplePage.Options, + ScriptEditorAction.Save, + ScriptEditorAction.GoToTerminal, + ...SpoilerKeyBindingTypes, +] as const; + +export type KeyBindingType = (typeof KeyBindingTypes)[number]; + +export type KeyCombination = { + control: boolean; + alt: boolean; + shift: boolean; + meta: boolean; + code: string; + key: string; +}; + +export const defaultKeyBinding: Record = { + [SimplePage.Terminal]: [ + { + control: false, + alt: true, + shift: false, + meta: false, + code: "KeyT", + key: "t", + }, + null, + ], + [ComplexPage.ScriptEditor]: [ + { + control: false, + alt: true, + shift: false, + meta: false, + code: "KeyE", + key: "e", + }, + null, + ], + [SimplePage.ActiveScripts]: [ + { + control: false, + alt: true, + shift: false, + meta: false, + code: "KeyS", + key: "s", + }, + null, + ], + [SimplePage.CreateProgram]: [ + { + control: false, + alt: true, + shift: false, + meta: false, + code: "KeyP", + key: "p", + }, + null, + ], + [SimplePage.StaneksGift]: [null, null], + [SimplePage.Stats]: [ + { + control: false, + alt: true, + shift: false, + meta: false, + code: "KeyC", + key: "c", + }, + null, + ], + [SimplePage.Factions]: [ + { + control: false, + alt: true, + shift: false, + meta: false, + code: "KeyF", + key: "f", + }, + null, + ], + [SimplePage.Augmentations]: [ + { + control: false, + alt: true, + shift: false, + meta: false, + code: "KeyA", + key: "a", + }, + null, + ], + [SimplePage.Hacknet]: [ + { + control: false, + alt: true, + shift: false, + meta: false, + code: "KeyH", + key: "h", + }, + null, + ], + [SimplePage.Sleeves]: [null, null], + [SimplePage.Grafting]: [null, null], + [SimplePage.City]: [ + { + control: false, + alt: true, + shift: false, + meta: false, + code: "KeyW", + key: "w", + }, + null, + ], + [SimplePage.Travel]: [ + { + control: false, + alt: true, + shift: false, + meta: false, + code: "KeyR", + key: "r", + }, + null, + ], + [ComplexPage.Job]: [ + { + control: false, + alt: true, + shift: false, + meta: false, + code: "KeyJ", + key: "j", + }, + null, + ], + [SimplePage.StockMarket]: [null, null], + [SimplePage.Bladeburner]: [ + { + control: false, + alt: true, + shift: false, + meta: false, + code: "KeyB", + key: "b", + }, + null, + ], + [SimplePage.Corporation]: [null, null], + [SimplePage.Gang]: [ + { + control: false, + alt: true, + shift: false, + meta: false, + code: "KeyG", + key: "g", + }, + null, + ], + [SimplePage.Go]: [null, null], + [SimplePage.Milestones]: [null, null], + [ComplexPage.Documentation]: [ + { + control: false, + alt: true, + shift: false, + meta: false, + code: "KeyU", + key: "u", + }, + null, + ], + [SimplePage.Achievements]: [null, null], + [SimplePage.Options]: [ + { + control: false, + alt: true, + shift: false, + meta: false, + code: "KeyO", + key: "o", + }, + null, + ], + "ScriptEditor-Save": [ + { + control: true, + alt: false, + shift: false, + meta: false, + code: "KeyS", + key: "s", + }, + { + control: false, + alt: false, + shift: false, + meta: true, + code: "KeyS", + key: "s", + }, + ], + "ScriptEditor-GoToTerminal": [ + { + control: true, + alt: false, + shift: false, + meta: false, + code: "KeyB", + key: "b", + }, + { + control: false, + alt: false, + shift: false, + meta: true, + code: "KeyB", + key: "b", + }, + ], +}; + +export function parseKeyCombinationToString(keyCombination: KeyCombination | null): string { + if (!keyCombination) { + return ""; + } + let result = ""; + if (keyCombination.control) { + result += "Ctrl + "; + } + if (keyCombination.alt) { + result += "Alt + "; + } + if (keyCombination.shift) { + result += "Shift + "; + } + if (keyCombination.meta) { + if (window.navigator.userAgent.includes("Mac")) { + result += "⌘ + "; + } else { + // Most non-Apple keyboards print a form of Windows icon on the key cap of the "meta" key. + result += "⊞ + "; + } + } + if (keyCombination.code === KEYCODE.SPACE) { + result += "Space"; + } else { + result += keyCombination.key; + } + return result; +} + +export function parseKeyCombinationsToString(keyCombinations: (KeyCombination | null)[]): string { + let result = ""; + for (const keyCombination of keyCombinations) { + if (!keyCombination) { + continue; + } + result += ` or ${parseKeyCombinationToString(keyCombination)}`; + } + if (result.startsWith(" or ")) { + return result.substring(4); + } + return result; +} + +export function getKeyCombination( + keyBindings: typeof defaultKeyBinding, + keyBindingType: KeyBindingType, + isPrimary: boolean, +): KeyCombination | null { + return keyBindings[keyBindingType][isPrimary ? 0 : 1]; +} + +export function convertKeyboardEventToKeyCombination(event: KeyboardEvent): KeyCombination { + return { + control: event.ctrlKey, + alt: event.altKey, + shift: event.shiftKey, + meta: event.metaKey, + code: event.code, + key: event.key, + }; +} + +export function determineKeyBindingTypes( + keyBindings: typeof defaultKeyBinding, + keyCombination: KeyCombination, +): Set { + const result = new Set(); + for (const [keyBindingType, combinations] of getRecordEntries(keyBindings)) { + for (const combination of combinations) { + if ( + !combination || + combination.control !== keyCombination.control || + combination.alt !== keyCombination.alt || + combination.shift !== keyCombination.shift || + combination.meta !== keyCombination.meta || + combination.code !== keyCombination.code + ) { + continue; + } + result.add(keyBindingType); + } + } + return result; +} + +export function isKeyCombinationPressed( + keyCombination: KeyCombination, + requiredCombination: { + control?: boolean; + alt?: boolean; + shift?: boolean; + meta?: boolean; + code: string; + }, +): boolean { + for (const key of ["control", "alt", "shift", "meta"] as const) { + if (requiredCombination[key] === undefined) { + requiredCombination[key] = false; + } + } + return ( + requiredCombination.control === keyCombination.control && + requiredCombination.alt === keyCombination.alt && + requiredCombination.shift === keyCombination.shift && + requiredCombination.meta === keyCombination.meta && + requiredCombination.code === keyCombination.code + ); +} + +/** + * This function can be called in situations that the parameter is a string, not just KeyBindingType. + */ +export function isSpoilerKeyBindingType(keyBindingType: string): boolean { + return SpoilerKeyBindingTypes.some((value) => value === keyBindingType); +} + +export enum KeyBindingEventType { + StartSettingUp, + StopSettingUp, +} + +export const KeyBindingEvents = new EventEmitter<[KeyBindingEventType]>(); diff --git a/src/utils/helpers/keyCodes.ts b/src/utils/helpers/keyCodes.ts index a976ca615c..fbc3810216 100644 --- a/src/utils/helpers/keyCodes.ts +++ b/src/utils/helpers/keyCodes.ts @@ -76,6 +76,7 @@ export enum KEYCODE { //CTRL: 17, // Check by `&& event.ctrlKey` //ALT: 18, // Check by `&& event.altKey` ENTER = "Enter", + NUMPAD_ENTER = "NumpadEnter", ESC = "Escape", TAB = "Tab", SPACE = "Space", From a46a26bc7fe821a4d6d73835a2ffb1be3da698d4 Mon Sep 17 00:00:00 2001 From: CatLover <152669316+catloversg@users.noreply.github.com> Date: Sun, 8 Dec 2024 22:21:55 +0700 Subject: [PATCH 2/3] Remove unused code --- src/@types/global.d.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts index 6c99f3be30..670eb9e44b 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -60,9 +60,3 @@ module "monaco-vim" { }; }; } - -declare interface Navigator { - keyboard?: { - getLayoutMap?: () => Promise>; - }; -} From 662ae5b644f8f3de8e42a36f15ab223fccc3d081 Mon Sep 17 00:00:00 2001 From: CatLover <152669316+catloversg@users.noreply.github.com> Date: Sun, 8 Dec 2024 22:29:01 +0700 Subject: [PATCH 3/3] Fix lint --- src/GameOptions/ui/GameOptionsRoot.tsx | 2 +- src/Sidebar/ui/SidebarRoot.tsx | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/GameOptions/ui/GameOptionsRoot.tsx b/src/GameOptions/ui/GameOptionsRoot.tsx index f206051d1d..f4c5d0903d 100644 --- a/src/GameOptions/ui/GameOptionsRoot.tsx +++ b/src/GameOptions/ui/GameOptionsRoot.tsx @@ -1,5 +1,5 @@ import { Box, Container, Typography } from "@mui/material"; -import React, { useEffect, useState } from "react"; +import React, { useState } from "react"; import { GameOptionsSidebar } from "./GameOptionsSidebar"; import { GameplayPage } from "./GameplayPage"; import { InterfacePage } from "./InterfacePage"; diff --git a/src/Sidebar/ui/SidebarRoot.tsx b/src/Sidebar/ui/SidebarRoot.tsx index 32ba63bd92..f6d559909b 100644 --- a/src/Sidebar/ui/SidebarRoot.tsx +++ b/src/Sidebar/ui/SidebarRoot.tsx @@ -1,5 +1,4 @@ import React, { useMemo, useCallback, useState, useEffect, useRef } from "react"; -import { KEYCODE } from "../../utils/helpers/keyCodes"; import { styled, Theme, CSSObject } from "@mui/material/styles"; import { makeStyles } from "tss-react/mui"; import MuiDrawer from "@mui/material/Drawer";