Skip to content

Commit

Permalink
feat(keybindings): generic way of adding keybindings (#191)
Browse files Browse the repository at this point in the history
* 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
cristinecula authored Nov 23, 2024
1 parent 919220d commit 016d364
Show file tree
Hide file tree
Showing 11 changed files with 354 additions and 6 deletions.
12 changes: 12 additions & 0 deletions demo/keybindings.html
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>
150 changes: 150 additions & 0 deletions demo/keybindings/demo-keybindings.ts
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%;
}
`,
],
}),
);
9 changes: 5 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,12 @@
"./elements/*": "./dist/elements/*.js",
"./directives/*": "./dist/directives/*.js",
"./hooks/*": "./dist/hooks/*.js",
"./memoize": "./dist/memoize.js"
"./memoize": "./dist/memoize.js",
"./keybindings": "./dist/keybindings/index.js",
"./keybindings/*": "./dist/keybindings/*.js"
},
"dependencies": {
"@pionjs/pion": "^2.0.0"
"@pionjs/pion": "^2.7.1"
},
"devDependencies": {
"@commitlint/cli": "^18.0.0",
Expand Down
37 changes: 37 additions & 0 deletions src/keybindings/README.md
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>`;
};
```
7 changes: 7 additions & 0 deletions src/keybindings/context.ts
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);
5 changes: 5 additions & 0 deletions src/keybindings/index.ts
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';
19 changes: 19 additions & 0 deletions src/keybindings/types.ts
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;
10 changes: 10 additions & 0 deletions src/keybindings/use-activity.ts
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);
};
53 changes: 53 additions & 0 deletions src/keybindings/use-keybindings.ts
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;
};
52 changes: 52 additions & 0 deletions src/keybindings/utils.ts
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;
};

0 comments on commit 016d364

Please sign in to comment.