398 lines
13 KiB
TypeScript
398 lines
13 KiB
TypeScript
import { useState, useEffect, useRef, createContext, useContext } from 'react'
|
|
import { UserOverridesConfig } from 'contro-max/build/types/store'
|
|
import { ModifierOnlyKeys } from 'contro-max/build/types/keyCodes'
|
|
import { contro as controEx } from '../controls'
|
|
import { hideModal } from '../globalState'
|
|
import triangle from './ps_icons/playstation_triangle_console_controller_gamepad_icon.svg'
|
|
import square from './ps_icons/playstation_square_console_controller_gamepad_icon.svg'
|
|
import circle from './ps_icons/circle_playstation_console_controller_gamepad_icon.svg'
|
|
import cross from './ps_icons/cross_playstation_console_controller_gamepad_icon.svg'
|
|
import PixelartIcon from './PixelartIcon'
|
|
import KeybindingsCustom, { CustomCommandsMap } from './KeybindingsCustom'
|
|
import { BindingActionsContext } from './KeybindingsScreenProvider'
|
|
import Button from './Button'
|
|
import Screen from './Screen'
|
|
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,
|
|
parseBindingName (binding) { return '' as string },
|
|
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,
|
|
parseBindingName,
|
|
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, parseBindingName, bindsMap } = useContext(Context)
|
|
const [buttonSign, setButtonSign] = useState('')
|
|
|
|
useEffect(() => {
|
|
const type = inputType === 'keyboard' ? 'keys' : 'gamepad'
|
|
|
|
const customValue = userConfig?.[group]?.[action]?.[type]?.[index]
|
|
if (customValue) {
|
|
if (type === 'keys') {
|
|
setButtonSign(parseBindingName(customValue))
|
|
} else {
|
|
setButtonSign(isPS && buttonsMap[customValue] ? buttonsMap[customValue] : customValue)
|
|
}
|
|
} else if (type === 'keys') {
|
|
setButtonSign(keys?.length ? parseBindingName(keys[index]) : '')
|
|
} else {
|
|
setButtonSign(gamepad?.[0] ?
|
|
isPS ?
|
|
buttonsMap[gamepad[0]] ?? gamepad[0]
|
|
: 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}`}>
|
|
{buttonSign}
|
|
</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
|
|
)
|
|
) ? (
|
|
<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></span>
|
|
</div>
|
|
</div>
|
|
) : 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(' ')
|
|
}
|
|
|
|
const parseBindingName = (binding: string | undefined) => {
|
|
if (!binding) return ''
|
|
const cut = binding.replaceAll(/(Numpad|Digit|Key)/g, '')
|
|
|
|
const parts = cut.includes('+') ? cut.split('+') : [cut]
|
|
for (let i = 0; i < parts.length; i++) {
|
|
parts[i] = parts[i].split(/(?=[A-Z\d])/).reverse().join(' ')
|
|
}
|
|
return parts.join(' + ')
|
|
}
|
|
|
|
const buttonsMap = {
|
|
'A': cross,
|
|
'B': circle,
|
|
'X': square,
|
|
'Y': triangle
|
|
}
|