-
Notifications
You must be signed in to change notification settings - Fork 0
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Vim Mode 3 - Update hotkey infrastructure to work around various issues with react-hotkeys #2
Vim Mode 3 - Update hotkey infrastructure to work around various issues with react-hotkeys #2
Conversation
/** | ||
* React Hotkeys treats j/J as different keys, which is buggy when detecting key chords. | ||
* For example, if I press these keys: | ||
* | ||
* [shift down, j down, shift up, j up] | ||
* | ||
* React Hotkeys interprets it like so: | ||
* | ||
* ---------Shift+J------- | ||
* | | | ||
* [Shift+J down, J down, Shift+J up, j up] | ||
* | | | ||
* ----Not Released---- | ||
* | ||
* React Hotkeys thinks that the "J" key is still down, which breaks all hotkeys until you | ||
* Un-focus the browser window (resetting the key history). | ||
* | ||
* To workaround this issue, we normalize j/J to be the "same key" using the keycode, | ||
* That way, a "J" keydown is stored as a "j" keydown in the key history, so a "j" keyup | ||
* will "release" it. | ||
* | ||
* Related issue: https://github.com/greena13/react-hotkeys/issues/249 | ||
*/ | ||
customKeyCodes: CODE_TO_KEY, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is the issue that CODE_TO_KEY
from roam-unofficial#63 is trying to solve
8ce0c2b
to
c695d4c
Compare
7a191c3
to
fc942da
Compare
8b9e383
to
988e7df
Compare
ff2ad2d
to
f8627e8
Compare
988e7df
to
6e43e9e
Compare
2c1d602
to
044eb8d
Compare
044eb8d
to
7bce72a
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
the necessity of this PR makes me sad.
also would benefit from tests
@@ -0,0 +1,46 @@ | |||
// A "KeySequence" is a series of key chords, such as "g g" or "control+w j" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
that's a weird place to have this comment :)
@@ -0,0 +1,38 @@ | |||
import {GlobalHotKeys, KeyMap} from 'react-hotkeys' |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this whole fix thing probably deserves it's own subdirectory in core
vs being under common
* The list contains multiple alternatives trigger the same handler. | ||
* See https://github.com/greena13/react-hotkeys#alternative-hotkeys | ||
*/ | ||
export const allowHoldingShiftedKeys = (keySequence: KeySequence): KeyChord[] | KeySequence => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
these functions could use the Specs
const convertUppercaseToLowercasePlusShift = (keyChord: KeyChord): KeyChord => { | ||
const keys = keyChord.split('+') | ||
const key = keys.pop() | ||
const modifiers = keys.filter(key => key.toLowerCase()) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
filter
? this seems like it should have been a map maybe?
*/ | ||
const convertUppercaseToLowercasePlusShift = (keyChord: KeyChord): KeyChord => { | ||
const keys = keyChord.split('+') | ||
const key = keys.pop() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this should signify more explicitly what it is, maybe letter
(though it probably does not have to be a letter 🤔)?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I extracted KeySequence.keySequencesForBothHeldAndInitialPress
. I've been struggling to come up with good names for these hotkey wrappers :/
const keyChordMightBeSimulated = (keyChord: KeyChord) => | ||
KEYS_THAT_WE_ALSO_SIMULATE.some(key => keyChord.includes(key)) | ||
|
||
let executingHandler = 0 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this feels like it should be a class
*/ | ||
const clearKeyPressesAfterFinishingKeySequence = (handler: Handler): Handler => async event => { | ||
await handler(event) | ||
KeyEventManager.getInstance()._clearKeyHistory() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
is this a global object for all instances of react-hotkeys?
return this.keySequence.includes(' ') | ||
} | ||
|
||
fixedKeySequence() { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Even though we fix them here, I don't think fixed
should be part of the name. It's the internal detail of implementation
const actionToHotkey: Dictionary<Hotkey> = {} | ||
Object.keys(actionToKeySequence).forEach(action => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: consider using reduce
for this
singleChordHotkeys() { | ||
return new Hotkeys( | ||
pickBy(this.actionToHotkey, hotkey => !hotkey.usesMultipleKeyChords()) | ||
) | ||
} | ||
|
||
multiChordHotkeys() { | ||
return new Hotkeys( | ||
pickBy(this.actionToHotkey, hotkey => hotkey.usesMultipleKeyChords()) | ||
) | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
these don't feel like they conceptually belong inside this object. maybe make them static functions?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I decided to inline these after extracting classes for KeyChord
/ KeySequence
, since "Hotkey" as the combination of "Key+Handler" didn't seem as useful of a definition anymore.
1afad04
to
7ca2fb6
Compare
7bce72a
to
6432fa9
Compare
Various utilities for browser APIs, generic data manipulation
Fix tests by updating import paths
…-hotkeys and provide a more convenient interface that does what you'd expect
6432fa9
to
c82f1d9
Compare
I rebased on master, and added some tests. I also extracted classes for |
package.json
Outdated
@@ -18,8 +18,10 @@ | |||
"license": "MIT", | |||
"dependencies": { | |||
"@popperjs/core": "^2.4.0", | |||
"@types/immutable": "^3.8.7", |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I believe types should go to dev deps
import {zipObjects} from 'src/core/common/object' | ||
|
||
describe(zipObjects, () => { | ||
it('bundles values matching the same key into a tuple', async () => { |
This comment was marked as resolved.
This comment was marked as resolved.
Sorry, something went wrong.
*/ | ||
export type Handler = (event: KeyboardEvent) => Promise<any> | undefined | ||
|
||
let executingHandler = 0 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
comments didn't seem to get carried over, as file moved 🤔 anyhow - I believe this module should be a class #2 (comment)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Are you thinking something like a Command Object style class with an execute method?
class KeyHandler {
private static executingHandler = 0
private handler: Handler
private keySequence: KeySequence
constructor(keySequence: KeySequence, handler: Handler) {
this.keySequence = keySequence;
this.handler = handler;
}
async execute(event: KeyboardEvent) {
await this.executeIfTriggeredByUser(event)
if (this.keySequence.usesMultipleKeyChords()) {
clearKeyHistory()
}
}
private async executeIfNothingElseIsExecuting(event: KeyboardEvent) {
if (KeyHandler.executingHandler === 0) {
await this.executeAndTrack(event)
}
}
private async executeAndTrack(event: KeyboardEvent) {
KeyHandler.executingHandler += 1
try {
await this.handler(event)
} catch (error) {
console.error(error)
}
KeyHandler.executingHandler -= 1
}
/**
* If we artificially simulate a key press, that keypress should not
* trigger our own hotkeys.
*
* For example, simulating "Esc" to unfocus a block should not trigger
* our own hotkey for "Esc".
*
* @return a decorated version of a handler that does nothing if other
* handlers are running
*/
private async executeIfTriggeredByUser(event: KeyboardEvent) {
if (this.keySequence.mightBeSimulated()) {
await this.executeIfNothingElseIsExecuting(event)
} else {
await this.executeAndTrack(event)
}
}
}
Or alternatively, keeping the decorator style, but use a class:
class KeyHandler {
// ...
private handler: Handler
constructor(handler: Handler) {
this.handler = handler;
}
preventWhileOtherHandlersAreExecuting(): KeyHandler {
return new KeyHandler(
// ... decorate this.handler
)
}
// ... same with other methods
}
I personally find decorator+class style less readable, because it adds indirection. I find command object style maybe a bit more readable, but less composable.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
My first concern was to encapsulate the global state here more clearly.
I suppose it's kind of already done on a module level. So we kind of have a singleton represented by a module. So maybe my concern is coming from mostly working with languages with different module system..
But does this approach work well with multiple instances of react-hotkeys? I suppose in our case we want this to be global between 2 instances we create. Not sure it's made clear enough though that it has the global state via the current interface 🤔
I think I'd probably prefer to stay with the decorator approach as the current handler interface is what we use throughout the project and is what expected by react-hotkeys. So I think even if we use command object - we'd just use it for this specific case and would need to wrap it back to that interface to pass it on to the react-hotkeys
Also unfortunate that we can't make objects invokable.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In order to communicate to readers - "WARNING, this module uses global state, decorating one handler will affect the behavior of already decorated handlers", I'll try the following:
- Include a docstring for
adaptHandlerToReactHotkeys
warning that it uses a shared counter, and recommend that they look at the test cases. - Inline
adaptHandlerToReactHotkeys
, and renamedontTriggerWhenKeyPressIsSimulated
=>forbidConcurrentHandling
(naming it based on what it does rather what it's intended purpose is)
const keyChord = this.keyChords[0] | ||
if (keyChord.isCapitalLetter() && !this.usesMultipleKeyChords()) { | ||
return [this, this.map(keyChord => keyChord.convertCapitalToShiftAndLowercase())] | ||
} | ||
|
||
return [this.map(keyChord => keyChord.convertCapitalToShiftAndLowercase())] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm confused on why we need a first special case?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh wow, I'm no longer able to reproduce the bug that I thought I needed the first case to solve.
I might've been using different react-hotkeys
configurations (like simulateMissingKeyPressEvents
and ignoreRepeatedEventsWhenKeyHeldDown
) at the time I created this workaround, but I don't need it anymore so I'll just delete it.
So I guess the purpose of this is just reduced to allowing capital letters as shorthand for shift-*
now.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is it worth it just for that?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'll cut this functionality out. It'll allow a decent amount of code to be deleted
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I decided to keep KeyChord
, even though it doesn't really have functionality, because I think it helps communicate the taxonomy of Key*
related objects.
Also I kept KEY_TO_SHIFTED
because it's still used for shift+clicking hints: #4
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also I kept KEY_TO_SHIFTED because it's still used for shift+clicking hints: #4
hmm, can't we just add shift
modifier there?
this.modifiers = modifiers | ||
} | ||
|
||
isCapitalLetter(): string | undefined { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this name seems to imply that it'd return boolean. but it returns the unshifted version of the letter ? so maybe something like unshiftedKey
property instead?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'll inline this function, since the other usage is no longer needed due to getting rid of the special case above
convertCapitalToShiftAndLowercase(): KeyChord { | ||
const unshifted = this.isCapitalLetter() | ||
if (unshifted) { | ||
return new KeyChord(unshifted, this.modifiers.add('shift')) |
This comment was marked as resolved.
This comment was marked as resolved.
Sorry, something went wrong.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, this.modifiers.add('shift')
returns a copy of the set with shift
added. I guess it's immutable in the sense that I'm leaving the old reference alone, but I'm still "changing" the set.
I used an immutable set, soKeyChord
would behave like an immutable value object.
* @return the keychord with capitals converted to shift+lowercase, or null if | ||
* the key is already lowercase | ||
*/ | ||
convertCapitalToShiftAndLowercase(): KeyChord { |
This comment was marked as resolved.
This comment was marked as resolved.
Sorry, something went wrong.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh I think that's what I'm doing already?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
oh darn, indeed 😅
*/ | ||
export const clearKeyPressesAfterFinishingKeySequence = (handler: Handler): Handler => async event => { | ||
await handler(event) | ||
KeyEventManager.getInstance()._clearKeyHistory() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Upon further investigation, it does seem that KeyEventManager
is global to all instances of react-hotkeys: https://sourcegraph.com/github.com/greena13/react-hotkeys/-/blob/src/lib/KeyEventManager.js#L23:3
:/
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
that may end up being problematic. add some logging here?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You mean like console.warn('clearing react-hotkeys history')
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
something like that
Nice, I do believe it made things clearer :) |
… into both it's shifted and capital versions
tests/ts/common/object.test.ts
Outdated
}) | ||
}) | ||
|
||
it('uses undefined when there is no matching key', async () => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
hmm, this is kind of unexpected. Not an empty dict?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'll exclude keys instead of returning tuples with undefined
This is part of a larger change to support vim style navigation (roam-unofficial#63)
This wraps
react-hotkeys
to make it behave in a more intuitive and correct way.These are the main use cases that hinder vim mode, and their proposed fix:
shift
before letting go ofV
v
key was never released, blocking all future shortcutsshift
-v
-shift up
-v up
should trigger theV
hotkey without blocking future hotkeysJ
shift+j
. If you bindshift+j
, only the initial press works.shift+j
, and when holdingshift+j
u
u
such ascmd+u
stop workingEsc
Esc
(in order to unfocus a block for example), then it will actually trigger theEsc
hotkeyg g
I acknowledge that a longer-term solution is better, but figured that this was a good enough band-aid until we figure out what to do about roam-unofficial#68