Skip to content

Commit

Permalink
Add functionality for customizing keyboard shortcuts
Browse files Browse the repository at this point in the history
Signed-off-by: Axel Boberg <[email protected]>
  • Loading branch information
axelboberg committed Mar 8, 2024
1 parent bb73670 commit 58da384
Show file tree
Hide file tree
Showing 9 changed files with 245 additions and 4 deletions.
77 changes: 77 additions & 0 deletions api/shortcuts.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,17 @@
* description: String,
* trigger: String[]
* }} ShortcutSpec
*
* @typedef {{
* trigger: String[]
* }} ShortcutOverrideSpec
*/

const state = require('./state')
const commands = require('./commands')

const InvalidArgumentError = require('./error/InvalidArgumentError')

/**
* Make a shortcut available
* to the application
Expand Down Expand Up @@ -42,6 +48,77 @@ exports.getShortcut = getShortcut
*/
async function getShortcuts () {
const index = state.getLocalState()?._shortcuts
const overrides = state.getLocalState()?._userDefaults?.shortcuts

return Object.values(index || {})
.map(shortcut => {
return {
...shortcut,
...(overrides[shortcut.id] || {})
}
})
}
exports.getShortcuts = getShortcuts

/**
* Register a new shortcut override
*
* Note that the override will be registered
* to the user defaults state for the current
* main process and not necessarily the local
* user
*
* @param { String } id An identifier of the shortcut to override
* @param { ShortcutOverrideSpec } spec A specification to use as an override
* @returns { Promise.<void> }
*/
async function registerShortcutOverride (id, spec) {
if (typeof spec !== 'object') {
throw new InvalidArgumentError('The provided \'spec\' must be a shortcut override specification')
}

if (typeof id !== 'string') {
throw new InvalidArgumentError('The provided \'id\' must be a string')
}

const currentOverride = await state.get(`_userDefaults.shortcuts.${id}`)
const set = { [id]: spec }

if (currentOverride) {
set[id] = { $replace: spec }
}

state.apply({
_userDefaults: {
shortcuts: set
}
})
}
exports.registerShortcutOverride = registerShortcutOverride

/**
* Clear any override for a
* specific shortcut by its id
* @param { String } id
*/
async function clearShortcutOverride (id) {
state.apply({
_userDefaults: {
shortcuts: {
[id]: { $delete: true }
}
}
})
}
exports.clearShortcutOverride = clearShortcutOverride

/**
* Get a shortcut override specification
* for a shortcut by its id
* @param { String } id
* @returns { Promise.<ShortcutOverrideSpec | undefined> }
*/
async function getShortcutOverride (id) {
return state.get(`_userDefaults.shortcuts.${id}`)
}
exports.getShortcutOverride = getShortcutOverride
8 changes: 8 additions & 0 deletions app/assets/icons/edit-detail.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions app/assets/icons/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import person from './person.svg'
import warning from './warning.svg'
import selector from './selector.svg'
import arrowRight from './arrow-right.svg'
import editDetail from './edit-detail.svg'
import placeholder from './placeholder.svg'
import preferences from './preferences.svg'

Expand All @@ -20,6 +21,7 @@ export default {
warning,
selector,
arrowRight,
editDetail,
placeholder,
preferences
}
46 changes: 46 additions & 0 deletions app/components/Popup/shortcut.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
.PopupShortcut-actions {
display: flex;
margin-top: 20px;
justify-content: space-around;
}

.PopupShortcut-confirmAction {
font-weight: 500;
}

.PopupShortcut-preview {
display: flex;
height: 2em;

padding: 0 10px;

align-items: center;
justify-content: center;

animation-name: PopupShortcut-preview-fade;
animation-duration: 1s;
animation-iteration-count: infinite;
}

@keyframes PopupShortcut-preview-fade {
0% {
opacity: 1;
}

50% {
opacity: 0.5;
}

100% {
opacity: 1;
}
}

.PopupShortcut-description {
width: 100%;
margin-top: 5px;
margin-bottom: 10px;

text-align: center;
opacity: 0.5;
}
47 changes: 47 additions & 0 deletions app/components/Popup/shortcut.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import React from 'react'
import { Popup } from '.'

import './shortcut.css'

import * as shortcuts from '../../utils/shortcuts'

export function PopupShortcut ({ open, shortcut, onChange = () => {} }) {
const [trigger, setTrigger] = React.useState(shortcut?.trigger)

React.useEffect(() => {
setTrigger(shortcut?.trigger)
}, [shortcut?.trigger])

React.useEffect(() => {
if (!open || !onChange) {
return
}

function onKeyDown (e) {
setTrigger(shortcuts.getPressed())
}

window.addEventListener('keydown', onKeyDown)
return () => {
window.removeEventListener('keydown', onKeyDown)
}
}, [open])

return (
<Popup open={open}>
<div className='PopupShortcut-description'>
Press a key combination
</div>
<div className='PopupShortcut-preview'>
{
(trigger || []).join(' + ')
}
</div>
<div className='PopupShortcut-actions'>
<button className='Button--secondary' onClick={() => onChange(undefined)}>Cancel</button>
<button className='Button--secondary' onClick={() => onChange(-1)}>Reset</button>
<button className='Button--primary' onClick={() => onChange(trigger)}>OK</button>
</div>
</Popup>
)
}
46 changes: 43 additions & 3 deletions app/components/PreferencesShortcutsInput/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,65 @@ import './style.css'

import { SharedContext } from '../../sharedContext'

import { Icon } from '../Icon'
import { PopupShortcut } from '../Popup/shortcut'

import * as api from '../../api'

export function PreferencesShortcutsInput () {
const [state] = React.useContext(SharedContext)
const [popupShortcut, setPopupShortcut] = React.useState()

async function handleNewTrigger (newTrigger, shortcut) {
setPopupShortcut(undefined)
const bridge = await api.load()

if (!newTrigger) {
return
}

/*
Clear the override if it is reset or
the new trigger is the same as the default one
*/
if (newTrigger === -1 || newTrigger.sort().join(',') === shortcut?.trigger?.sort().join(',')) {
bridge.shortcuts.clearShortcutOverride(shortcut.id)
return
}

/*
Register a new shortcut override
*/
if (newTrigger) {
bridge.shortcuts.registerShortcutOverride(shortcut.id, {
trigger: newTrigger
})
return
}
}

return (
<div className='PreferencesShortcutsInput'>
<PopupShortcut open={!!popupShortcut} shortcut={popupShortcut?.merged} onChange={(newTrigger, shortcut) => handleNewTrigger(newTrigger, popupShortcut?.shortcut)} />
<ol className='PreferencesShortcutsInput-list'>
{
Object.values(state?._shortcuts || {})
.sort((a, b) => a.id > b.id)
.map(shortcut => {
const override = state?._userDefaults?.shortcuts?.[shortcut.id] || {}

return (
<li key={shortcut.id} className='PreferencesShortcutsInput-listItem'>
<li key={shortcut.id} className={`PreferencesShortcutsInput-listItem ${override?.trigger ? 'has-changed' : ''}`}>
<div className={`PreferencesShortcutsInput-description ${!(shortcut?.description) ? 'is-empty' : ''}`}>
{shortcut?.description || 'No description available'}
</div>
<div className='PreferencesShortcutsInput-trigger'>
<div className='PreferencesShortcutsInput-trigger' onClick={() => setPopupShortcut({ shortcut, merged: { ...shortcut, ...override } })}>
{
(shortcut?.trigger || []).join(' + ')
(override?.trigger || shortcut?.trigger || []).join(' + ')
}
<span className='PreferencesShortcutsInput-icon'>
<Icon name='editDetail' />
</span>
</div>
</li>
)
Expand Down
12 changes: 12 additions & 0 deletions app/components/PreferencesShortcutsInput/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,19 @@
}

.PreferencesShortcutsInput-trigger {
display: flex;

text-align: right;

flex-shrink: 0;
opacity: 0.5;
}

.PreferencesShortcutsInput-listItem.has-changed .PreferencesShortcutsInput-trigger {
font-weight: 500;
opacity: 1;
}

.PreferencesShortcutsInput-icon {
margin-left: 5px;
}
9 changes: 9 additions & 0 deletions app/utils/shortcuts.js
Original file line number Diff line number Diff line change
Expand Up @@ -122,3 +122,12 @@ export async function registerKeyDown (e) {
export function registerKeyUp (e) {
keys.clear()
}

/**
* Get the currently pressed
* keys as an array
* @returns { String[] }
*/
export function getPressed () {
return Array.from(keys.values())
}
2 changes: 1 addition & 1 deletion shared/merge.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ function mergeDeep (targetObj, sourceObj) {
if (sourceObj[key]?.$delete) {
if (Array.isArray(targetObj)) {
targetObj.splice(key, 1)
} else {
} else if (Object.prototype.hasOwnProperty.call(targetObj, key)) {
delete targetObj[key]
}
continue
Expand Down

0 comments on commit 58da384

Please sign in to comment.