-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(keybindings): generic way of adding keybindings (#191)
* feat(keybindings): generic way of adding keybindings New hooks to make adding keybindings a breeze. We use `activity` symbols to decouple the key combo from the key combo handler. Components define various activities and the app decides which keys trigger which activities. Obligatory ASCII Venn diagram: [ app { ] component } [ key combo { activity ] handler } Re AB#14428 * refactor: extract utils * fix: added missing exports
- Loading branch information
1 parent
919220d
commit 016d364
Showing
11 changed files
with
354 additions
and
6 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,12 @@ | ||
<!doctype html> | ||
<html lang="en"> | ||
<head> | ||
<meta charset="UTF-8" /> | ||
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> | ||
<title>Keybindings demo</title> | ||
<script type="module" src="./keybindings/demo-keybindings.ts"></script> | ||
</head> | ||
<body> | ||
<demo-keybindings></demo-keybindings> | ||
</body> | ||
</html> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,150 @@ | ||
/* eslint-disable no-alert */ | ||
import { component, css, html, useEffect, useState } from '@pionjs/pion'; | ||
import { KeyBinding } from '../../src/keybindings/types'; | ||
import { useKeybindings } from '../../src/keybindings/use-keybindings'; | ||
import { useActivity } from '../../src/keybindings/use-activity'; | ||
|
||
const twist = Symbol('twist'); | ||
const jump = Symbol('jump'); | ||
const add = Symbol('add'); | ||
const del = Symbol('delete'); | ||
|
||
const bindings: readonly KeyBinding[] = [ | ||
[{ key: 't' }, [twist], { title: 't', description: 'Do the twist!' }], | ||
[{ key: 'j' }, [jump], { title: 'j', description: 'Jump around!' }], | ||
[{ key: 'Enter' }, [add], { title: 'Enter', description: 'More items' }], | ||
[{ key: ' ' }, [add], { title: 'Space', description: 'More items' }], | ||
[ | ||
{ key: 'Backspace' }, | ||
[del], | ||
{ title: 'Backspace', description: 'Less items' }, | ||
], | ||
] as const; | ||
|
||
const DemoKeybindings = () => { | ||
const [n, setN] = useState(1); | ||
const register = useKeybindings(bindings); | ||
|
||
useEffect( | ||
() => | ||
register({ | ||
activity: del, | ||
callback: () => setN((n) => Math.max(0, n - 1)), | ||
}), | ||
[], | ||
); | ||
useEffect( | ||
() => | ||
register({ | ||
activity: add, | ||
callback: () => setN((n) => n + 1), | ||
}), | ||
[], | ||
); | ||
|
||
return html` <div> | ||
<textarea>Focus me and make sure that you can type</textarea> | ||
</div> | ||
<ul> | ||
${bindings.map( | ||
([, , details]) => | ||
html`<li>${details.title} - ${details.description}</li>`, | ||
)} | ||
</ul> | ||
<cosmoz-keybinding-provider .value=${register}> | ||
${Array.from(new Array(n)).map(() => html`<demo-element></demo-element>`)} | ||
<div class="no-go"></div> | ||
</cosmoz-keybinding-provider>`; | ||
}; | ||
|
||
customElements.define( | ||
'demo-keybindings', | ||
component(DemoKeybindings, { | ||
styleSheets: [ | ||
css` | ||
.no-go { | ||
width: 200px; | ||
height: 200px; | ||
position: absolute; | ||
top: 100px; | ||
left: 200px; | ||
background: red; | ||
opacity: 0.2; | ||
} | ||
`, | ||
], | ||
}), | ||
); | ||
|
||
const twisting: Keyframe[] = [ | ||
{ transform: 'rotate(0)' }, | ||
{ transform: 'rotate(360deg)' }, | ||
], | ||
jumping: Keyframe[] = [ | ||
{ transform: 'translateY(0)' }, | ||
{ transform: 'translateY(-40px)' }, | ||
{ transform: 'translateY(0px)' }, | ||
{ transform: 'translateY(-40px)' }, | ||
{ transform: 'translateY(0px)' }, | ||
], | ||
animate = (el: HTMLElement, animation: Keyframe[]) => () => { | ||
const timing: KeyframeAnimationOptions = { | ||
duration: 500, | ||
iterations: 1, | ||
easing: 'ease-in-out', | ||
composite: 'accumulate', | ||
}; | ||
|
||
el.animate(animation, timing); | ||
}; | ||
|
||
type Host = HTMLElement; | ||
|
||
const DemoElement = (host: Host) => { | ||
useEffect(animate(host, jumping), []); | ||
|
||
const [activation, setActivation] = useState(Symbol()); | ||
|
||
useActivity( | ||
{ activity: twist, callback: animate(host, twisting), element: () => host }, | ||
[activation], | ||
); | ||
useActivity( | ||
{ | ||
activity: jump, | ||
callback: animate(host, jumping), | ||
element: () => host, | ||
}, | ||
[activation], | ||
); | ||
|
||
return html`<div | ||
@click=${animate(host, twisting)} | ||
@mouseenter=${() => setActivation(Symbol())} | ||
></div>`; | ||
}; | ||
|
||
customElements.define( | ||
'demo-element', | ||
component(DemoElement, { | ||
styleSheets: [ | ||
css` | ||
:host { | ||
display: inline-block; | ||
width: 20px; | ||
height: 20px; | ||
margin: 1px; | ||
vertical-align: middle; | ||
text-align: center; | ||
background: lightgray; | ||
color: white; | ||
user-select: none; | ||
} | ||
div { | ||
height: 100%; | ||
} | ||
`, | ||
], | ||
}), | ||
); |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,37 @@ | ||
# Keybindings | ||
|
||
We use `activity` symbols to decouple the key combo from the key combo handler. Components define various activities and the app decides which keys trigger which activities. | ||
|
||
Obligatory ASCII Venn diagram: | ||
|
||
[ app { ] component } | ||
[ key combo { activity ] handler } | ||
|
||
## Example usage | ||
|
||
```ts | ||
// The component exports some activity symbols. | ||
export const twist = Symbol('twist'); | ||
|
||
// The component defines handlers for each activity | ||
useActivity({ activity: twist, callback: () => null, element: () => host }, []); | ||
``` | ||
|
||
```ts | ||
// The app defines the keybindings for each activity | ||
const bindings: readonly KeyBinding[] = [ | ||
[ | ||
{ key: 'f', ctrlKey: true }, | ||
[twist], | ||
{ title: 'ctrl + f', description: 'Do the twist!' }, | ||
], | ||
] as const; | ||
|
||
const DemoKeybindings = () => { | ||
const register = useKeybindings(bindings); | ||
|
||
return html`<cosmoz-keybinding-provider .value=${register}> | ||
... | ||
</cosmoz-keybinding-provider>`; | ||
}; | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
import { createContext } from '@pionjs/pion'; | ||
import { noop } from '../function'; | ||
import { RegisterFn } from './types'; | ||
|
||
export const Keybindings = createContext<RegisterFn>(() => noop); | ||
|
||
customElements.define('cosmoz-keybinding-provider', Keybindings.Provider); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
export type * from './types'; | ||
|
||
export * from './context'; | ||
export * from './use-activity'; | ||
export * from './use-keybindings'; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,19 @@ | ||
export type Activity = symbol; | ||
|
||
export type Matcher = Pick<KeyboardEvent, 'key'> & | ||
Partial<Pick<KeyboardEvent, 'ctrlKey' | 'metaKey' | 'altKey' | 'shiftKey'>>; | ||
|
||
export type Info = { | ||
title: string; | ||
description: string; | ||
}; | ||
|
||
export type KeyBinding = readonly [Matcher, Activity[], Info]; | ||
|
||
export type ActivityHandler = { | ||
activity: Activity; | ||
callback: () => void; | ||
element?: () => Element | null | undefined; | ||
}; | ||
|
||
export type RegisterFn = (handler: ActivityHandler) => () => void; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
import { useContext, useEffect } from '@pionjs/pion'; | ||
import { ActivityHandler } from './types'; | ||
import { Keybindings } from './context'; | ||
import { useMeta } from '../hooks/use-meta'; | ||
|
||
export const useActivity = (handler: ActivityHandler, deps: unknown[]) => { | ||
const register = useContext(Keybindings); | ||
const meta = useMeta(handler); | ||
useEffect(() => register(meta), deps); | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,53 @@ | ||
import { useCallback, useEffect } from '@pionjs/pion'; | ||
import { useMeta } from '../hooks/use-meta'; | ||
import { Activity, ActivityHandler, KeyBinding, RegisterFn } from './types'; | ||
import { focusIsInEditableArea, isInteractive, matches } from './utils'; | ||
|
||
type State = { | ||
bindings: readonly KeyBinding[]; | ||
[k: Activity]: ActivityHandler[]; | ||
}; | ||
|
||
export const useKeybindings = (bindings: readonly KeyBinding[]): RegisterFn => { | ||
const meta = useMeta<State>({ bindings }); | ||
|
||
useEffect(() => { | ||
const handler = (e: KeyboardEvent) => { | ||
if (e.defaultPrevented) { | ||
return; | ||
} | ||
|
||
const binding = meta.bindings.find(matches(e)); | ||
if (!binding) return; | ||
|
||
if (focusIsInEditableArea()) return; | ||
|
||
const [, activities] = binding; | ||
const handlers = activities.flatMap((activity) => meta[activity]); | ||
if (handlers.length === 0) return; | ||
|
||
// find first actionable handler | ||
const handler = handlers.find( | ||
(handler) => !handler.element || isInteractive(handler.element()), | ||
); | ||
if (!handler) return; | ||
|
||
e.preventDefault(); | ||
handler.callback(); | ||
}; | ||
document.addEventListener('keydown', handler, true); | ||
return () => document.removeEventListener('keydown', handler, true); | ||
}, []); | ||
|
||
const register = useCallback((handler: ActivityHandler) => { | ||
meta[handler.activity] = [handler, ...(meta[handler.activity] ?? [])]; | ||
|
||
return () => { | ||
meta[handler.activity] = meta[handler.activity]?.filter( | ||
(h) => h !== handler, | ||
); | ||
}; | ||
}, []); | ||
|
||
return register; | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,52 @@ | ||
import { KeyBinding, Matcher } from './types'; | ||
|
||
declare global { | ||
interface ObjectConstructor { | ||
entries(o: Matcher): [keyof Matcher, Matcher[keyof Matcher]][]; | ||
} | ||
} | ||
export const matches = | ||
(e: KeyboardEvent) => | ||
([matcher]: KeyBinding) => | ||
Object.entries(matcher).every(([key, value]) => e[key] === value); | ||
|
||
export const isInteractive = (el: Element | null | undefined) => { | ||
if (el == null) return false; | ||
|
||
const bounds = el.getBoundingClientRect(), | ||
root = el.getRootNode() as ShadowRoot | Document, | ||
topEl = root.elementFromPoint( | ||
bounds.x + bounds.width / 2, | ||
bounds.y + bounds.height / 2, | ||
); | ||
|
||
return el === topEl; | ||
}; | ||
|
||
const getActiveElement = ( | ||
root: Document | ShadowRoot = document, | ||
): Element | null => { | ||
const activeEl = root.activeElement; | ||
|
||
if (!activeEl) { | ||
return null; | ||
} | ||
|
||
if (activeEl.shadowRoot) { | ||
return getActiveElement(activeEl.shadowRoot); | ||
} | ||
|
||
return activeEl; | ||
}; | ||
|
||
export const focusIsInEditableArea = (): boolean => { | ||
const active = getActiveElement(document); | ||
|
||
if (!active) return false; | ||
if (active.matches('input, textarea')) return true; | ||
if ('isContentEditable' in active && active.isContentEditable) { | ||
return true; | ||
} | ||
|
||
return false; | ||
}; |