pages235/src/react/KeybindingsScreen.tsx
Vitaly Turovsky 7824cf64a2 fix lint
2025-04-09 05:46:40 +03:00

367 lines
12 KiB
TypeScript

import { useState, useEffect, useRef, createContext, useContext } from 'react'
import { UserOverridesConfig } from 'contro-max/build/types/store'
import { AllKeyCodes } from 'contro-max/build/types/keyCodes'
import { contro as controEx } from '../controls'
import { hideModal } from '../globalState'
import PixelartIcon, { pixelartIcons } from './PixelartIcon'
import KeybindingsCustom, { CustomCommandsMap } from './KeybindingsCustom'
import { BindingActionsContext } from './KeybindingsScreenProvider'
import Button from './Button'
import Screen from './Screen'
import Keybinding from './Keybinding'
import styles from './KeybindingsScreen.module.css'
type HandleClick = (group: string, action: string, index: number, type: string | null) => void
type setBinding = (data: any, group: string, command: string, buttonIndex: number) => void
export const Context = createContext({
isPS: false as boolean | undefined,
userConfig: controEx?.userConfig ?? {} as UserOverridesConfig | undefined,
setUserConfig (config) { },
handleClick: (() => { }) as HandleClick,
bindsMap: { keyboard: {} as any, gamepad: {} as any }
})
export default ({
contro,
isPS,
}: {
contro: typeof controEx,
isPS?: boolean
}) => {
const containerRef = useRef<HTMLDivElement | null>(null)
const bindsMap = useRef({ keyboard: {} as any, gamepad: {} as any })
const { commands } = contro.inputSchema
const [userConfig, setUserConfig] = useState(contro.userConfig ?? {})
const [awaitingInputType, setAwaitingInputType] = useState(null as null | 'keyboard' | 'gamepad')
const [groupName, setGroupName] = useState('')
const [actionName, setActionName] = useState('')
const [buttonNum, setButtonNum] = useState(0)
const { updateBinds } = useContext(BindingActionsContext)
const [customCommands, setCustomCommands] = useState<CustomCommandsMap>(userConfig.custom as CustomCommandsMap ?? {})
const updateCurrBind = (group: string, action: string) => {
setGroupName(prev => group)
setActionName(prev => action)
}
const handleClick: HandleClick = (group, action, index, type) => {
(document.activeElement as HTMLElement)?.blur()
setAwaitingInputType(type as any)
updateCurrBind(group, action)
setButtonNum(prev => index)
}
const setBinding: setBinding = (data, group, command, buttonIndex) => {
setUserConfig(prev => {
const newConfig = { ...prev }
newConfig[group] ??= {}
newConfig[group][command] ??= {}
// keys and buttons should always exist in commands
const type = 'code' in data ? 'keys' : 'button' in data ? 'gamepad' : null
if (type) {
newConfig[group][command][type] ??= group === 'custom' ? [] : [...contro.inputSchema.commands[group][command][type]]
newConfig[group][command][type]![buttonIndex] = data.code ?? data.button
}
return newConfig
})
}
const resetBinding = (group: string, command: string, inputType: string) => {
if (!userConfig?.[group]?.[command]) return
setUserConfig(prev => {
const newConfig = { ...prev }
const prop = inputType === 'keyboard' ? 'keys' : 'gamepad'
newConfig[group][command][prop] = undefined
return newConfig
})
}
useEffect(() => {
updateBinds(userConfig)
setCustomCommands({ ...userConfig.custom as CustomCommandsMap })
updateBindMap()
}, [userConfig])
const updateBinding = (data: any) => {
if ((!data.state && awaitingInputType) || !awaitingInputType) {
setAwaitingInputType(null)
return
}
if ('code' in data) {
if (data.state && [...contro.pressedKeys].includes(data.code)) return
if (data.code === 'Escape' || ['Mouse0', 'Mouse1', 'Mouse2'].includes(data.code)) {
setAwaitingInputType(null)
return
}
const pressedModifiers = [...contro.pressedKeys].filter(key => /^(Meta|Control|Alt|Shift)?$/.test(key))
setBinding(
{ code: pressedModifiers.length ? `${pressedModifiers[0]}+${data.code}` : data.code, state: true },
groupName,
actionName,
buttonNum
)
}
if ('button' in data) {
contro.enabled = false
void Promise.resolve().then(() => { contro.enabled = true })
setBinding(data, groupName, actionName, buttonNum)
}
}
const updateBindMap = () => {
bindsMap.current = { keyboard: {} as any, gamepad: {} as any }
if (commands) {
for (const [group, actions] of Object.entries(commands)) {
for (const [action, { keys, gamepad }] of Object.entries(actions)) {
if (keys) {
let currKeys
if (userConfig?.[group]?.[action]?.keys) {
currKeys = userConfig[group][action].keys
} else {
currKeys = keys
}
for (const [index, key] of currKeys.entries()) {
bindsMap.current.keyboard[key] ??= []
if (!bindsMap.current.keyboard[key].some(obj => obj.group === group && obj.action === action && obj.index === index)) {
bindsMap.current.keyboard[key].push({ group, action, index })
}
}
}
if (gamepad) {
let currButtons
if (userConfig?.[group]?.[action]?.gamepad) {
currButtons = userConfig[group][action].gamepad
} else {
currButtons = gamepad
}
if (currButtons.length > 0) {
bindsMap.current.gamepad[currButtons[0]] ??= []
bindsMap.current.gamepad[currButtons[0]].push({ group, action, index: 0 })
}
}
}
}
}
}
// fill binds map
useEffect(() => {
updateBindMap()
}, [])
useEffect(() => {
if (!awaitingInputType) return
contro.on('pressedKeyOrButtonChanged', updateBinding)
const preventDefault = (e) => e.preventDefault()
document.addEventListener('keydown', preventDefault, { passive: false })
return () => {
contro.off('pressedKeyOrButtonChanged', updateBinding)
document.removeEventListener('keydown', preventDefault)
}
}, [groupName, actionName, awaitingInputType])
return <Context.Provider value={{
isPS,
userConfig,
setUserConfig,
handleClick,
bindsMap: bindsMap.current
}}
>
<Screen title="Keybindings" backdrop>
{awaitingInputType && <AwaitingInputOverlay isGamepad={awaitingInputType === 'gamepad'} />}
<div
className={styles.container}
ref={containerRef}
>
<Button
onClick={() => { hideModal() }}
style={{ alignSelf: 'center' }}
>Back
</Button>
{Object.entries(commands).map(([group, actions], index) => {
if (group === 'custom') return null
return <div key={`group-container-${group}-${index}`} className={styles.group}>
<div className={styles['group-category']}>{group}</div>
{group === 'general' ? (
<div style={{
color: 'rgba(255, 255, 255, 0.7)',
fontSize: '6px',
textAlign: 'center'
}}
>
Note: Left, right and middle click keybindings are hardcoded and cannot be changed currently.
</div>
) : null}
{Object.entries(actions).map(([action, { keys, gamepad }]) => {
return <div key={`action-container-${action}`} className={styles.actionBinds}>
<div className={styles.actionName}>{parseActionName(action)}</div>
<Button
onClick={() => {
updateCurrBind(group, action)
resetBinding(group, action, 'keyboard')
}}
style={{ opacity: userConfig?.[group]?.[action]?.keys?.length ? 1 : 0 }}
className={styles['undo-keyboard']}
icon={pixelartIcons.undo}
/>
{[0, 1].map((key, index) => <ButtonWithMatchesAlert
key={`keyboard-${group}-${action}-${index}`}
group={group}
action={action}
index={index}
inputType="keyboard"
keys={keys}
gamepad={gamepad}
/>)}
<Button
key={`keyboard-${group}-${action}`}
onClick={() => {
updateCurrBind(group, action)
resetBinding(group, action, 'gamepad')
}}
style={{
opacity: userConfig?.[group]?.[action]?.gamepad?.length ? 1 : 0,
width: '0px'
}}
className={`${styles['undo-gamepad']} ${styles['margin-left']}`}
icon={pixelartIcons.undo}
/>
<ButtonWithMatchesAlert
key={`gamepad-${group}-${action}`}
group={group}
action={action}
index={0}
inputType="gamepad"
keys={keys}
gamepad={gamepad}
/>
</div>
})}
</div>
})}
<KeybindingsCustom
customCommands={customCommands}
updateCurrBind={updateCurrBind}
resetBinding={resetBinding}
/>
</div>
</Screen>
</Context.Provider>
}
export const ButtonWithMatchesAlert = ({
group,
action,
index,
inputType,
keys,
gamepad,
}) => {
const { isPS, userConfig, handleClick, bindsMap } = useContext(Context)
const [buttonSign, setButtonSign] = useState('')
useEffect(() => {
const type = inputType === 'keyboard' ? 'keys' : 'gamepad'
const customValue = userConfig?.[group]?.[action]?.[type]?.[index]
if (customValue) {
setButtonSign(customValue)
} else if (type === 'keys') {
setButtonSign(keys?.length ? keys[index] : '')
} else {
setButtonSign(gamepad?.[0] ?? '')
}
}, [userConfig, isPS])
return <div
key={`warning-container-${inputType}-${action}`}
className={`${styles['warning-container']}`}
>
<Button
key={`${inputType}-${group}-${action}-${index}`}
onClick={() => handleClick(group, action, index, inputType)}
className={`${styles.button}`}
>
<Keybinding type={inputType} val={buttonSign as AllKeyCodes} />
</Button>
{userConfig?.[group]?.[action]?.[inputType === 'keyboard' ? 'keys' : 'gamepad']?.some(key => Object.keys(bindsMap[inputType]).includes(key)
&& bindsMap[inputType][key].length > 1
&& bindsMap[inputType][key].some(prop => prop.index === index
&& prop.group === group
&& prop.action === action)) ? (
//@ts-format-ignore-region
<div id={`bind-warning-${group}-${action}-${inputType}-${index}`} className={styles['matched-bind-warning']}>
<PixelartIcon
iconName="alert"
width={5}
styles={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
marginRight: '2px'
}}
/>
<div>
This bind is already in use. <span />
</div>
</div>
)
//@ts-format-ignore-endregion
: null}
</div>
}
export const AwaitingInputOverlay = ({ isGamepad }) => {
return <div
style={{
position: 'fixed',
inset: 0,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
flexDirection: 'column',
backgroundColor: 'rgba(0, 0, 0, 0.5)',
color: 'white',
fontSize: 20,
zIndex: 10,
textAlign: 'center',
}}
onContextMenu={e => e.preventDefault()}
>
<div>
{isGamepad ? 'Press the button on the gamepad ' : 'Press the key, side mouse button '}
or ESC to cancel.
</div>
<Button
onClick={() => {
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
}}
>
Cancel
</Button>
</div>
}
const parseActionName = (action: string) => {
const parts = action.split(/(?=[A-Z])/)
parts[0] = parts[0].charAt(0).toUpperCase() + parts[0].slice(1)
return parts.join(' ')
}