initial commit
This commit is contained in:
88
src/createKeyHandler.ts
Normal file
88
src/createKeyHandler.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { KeybindEmitter } from "./keybindEmitter";
|
||||
import { KeyTree } from "./keyTree";
|
||||
import { KeybindEventTypes, ModifierKey, type CommandMap, type KeyCommand, type KeyHandlerReturn, type Modifiers } from "./types";
|
||||
|
||||
export function createKeyHandler(commandMaps : CommandMap[], timeout : number = 5000): KeyHandlerReturn | null {
|
||||
const emitter = new KeybindEmitter();
|
||||
const tree = KeyTree.buildTree(commandMaps);
|
||||
if (tree === null) return null;
|
||||
|
||||
const sequenceProgress : KeyCommand[] = [];
|
||||
var currentTreeNode : KeyTree | null = null;
|
||||
var timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const tryResetTimeout = () => {
|
||||
if (timeoutId !== null){
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = null;
|
||||
}
|
||||
};
|
||||
|
||||
const tryResetFields = () => {
|
||||
currentTreeNode = null;
|
||||
sequenceProgress.length = 0;
|
||||
tryResetTimeout();
|
||||
};
|
||||
|
||||
const startTimeout = () => {
|
||||
timeoutId = setTimeout(() => {
|
||||
emitter.emit({type: KeybindEventTypes.SequenceTimeout});
|
||||
tryResetFields();
|
||||
}, timeout);
|
||||
};
|
||||
|
||||
const handler = (event: KeyboardEvent) => {
|
||||
const command = eventToKeyCommand(event);
|
||||
|
||||
// Get next tree node
|
||||
if (currentTreeNode === null){
|
||||
currentTreeNode = tree.hasKeyCommand(command);
|
||||
}
|
||||
else{
|
||||
currentTreeNode = currentTreeNode.hasKeyCommand(command);
|
||||
}
|
||||
|
||||
// We either:
|
||||
// 1. Reached the end of a sequence
|
||||
// 2. Entered a nonexistant sequence (currentTreeNode === null)
|
||||
// 3. Are partway through a sequence
|
||||
// If 2, emit no match, reset all vars.
|
||||
// Else, if 1: call callback, reset all vars.
|
||||
// Else, if 3: set timeout to reset sequence.
|
||||
|
||||
if (currentTreeNode === null){
|
||||
emitter.emit({type: KeybindEventTypes.SequenceNoMatch});
|
||||
tryResetFields();
|
||||
}
|
||||
else {
|
||||
sequenceProgress.push(command);
|
||||
if (currentTreeNode.isEnd){
|
||||
emitter.emit({type: KeybindEventTypes.SequenceProgress, pressedKeys: sequenceProgress });
|
||||
emitter.emit({type: KeybindEventTypes.CommandMatched, callback: currentTreeNode.callback})
|
||||
tryResetFields();
|
||||
}
|
||||
else{
|
||||
tryResetTimeout();
|
||||
emitter.emit({type: KeybindEventTypes.SequenceProgress, pressedKeys: sequenceProgress });
|
||||
startTimeout();
|
||||
}
|
||||
}
|
||||
};
|
||||
return {handler, emitter};
|
||||
}
|
||||
|
||||
function eventToKeyCommand(event: KeyboardEvent): KeyCommand{
|
||||
const mods = getModifiers(event);
|
||||
return {
|
||||
key: event.code,
|
||||
modifiers: mods
|
||||
};
|
||||
}
|
||||
function getModifiers(event: KeyboardEvent): Modifiers {
|
||||
const mods: Modifiers = [];
|
||||
if (event.shiftKey) mods.push(ModifierKey.Shift);
|
||||
if (event.ctrlKey) mods.push(ModifierKey.Control);
|
||||
if (event.altKey) mods.push(ModifierKey.Alt);
|
||||
if (event.metaKey) mods.push(ModifierKey.Meta);
|
||||
return mods;
|
||||
}
|
||||
1
src/index.ts
Normal file
1
src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { createKeyHandler } from './createKeyHandler';
|
||||
89
src/keyTree.ts
Normal file
89
src/keyTree.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import type { CommandMap, KeyCommand } from "./types";
|
||||
|
||||
// Like a trie, but for keys!
|
||||
export class KeyTree {
|
||||
#children : Record<string, KeyTree> = {};
|
||||
#endcallback : (() => void) | null = null;
|
||||
|
||||
public get isEnd(){
|
||||
return this.#endcallback !== null;
|
||||
}
|
||||
|
||||
public get children(){
|
||||
return this.#children;
|
||||
}
|
||||
|
||||
setCallback(callback : () => void): void {
|
||||
this.#endcallback = callback;
|
||||
}
|
||||
|
||||
public get callback(): (() => void) | null {
|
||||
return this.#endcallback;
|
||||
}
|
||||
|
||||
hasKeyCommand(keyCommand: KeyCommand): KeyTree | null {
|
||||
const searchTree = this.#children[this.keyCommandToString(keyCommand)];
|
||||
if (searchTree !== undefined)
|
||||
return searchTree;
|
||||
return null;
|
||||
}
|
||||
|
||||
canAddCommands(keyCommands: KeyCommand[]): boolean{
|
||||
var t = this;
|
||||
for (var command of keyCommands)
|
||||
{
|
||||
if (t.isEnd) return false;
|
||||
var t2 = t.hasKeyCommand(command);
|
||||
if (t2 === undefined) return true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
addCommand(keyCommand: KeyCommand, callback : (() => void) | null): KeyTree | null{
|
||||
const t = this.hasKeyCommand(keyCommand);
|
||||
if (t === null){
|
||||
const t2 = new KeyTree();
|
||||
this.children[this.keyCommandToString(keyCommand)] = t2;
|
||||
if (callback != null)
|
||||
t2.setCallback(callback);
|
||||
return t2;
|
||||
}
|
||||
else{
|
||||
if (t.isEnd || callback != null) return null;
|
||||
|
||||
return t;
|
||||
}
|
||||
}
|
||||
|
||||
addCommands(commandMap : CommandMap): boolean {
|
||||
// First traverse and see if it's possible to add
|
||||
if (!this.canAddCommands(commandMap.command)) return false;
|
||||
|
||||
let t : KeyTree = this;
|
||||
for (const [ix, command] of commandMap.command.entries()){
|
||||
const isLast = ix === commandMap.command.length - 1;
|
||||
const callback = isLast ? commandMap.callback : null;
|
||||
var t2 = t.addCommand(command, callback);
|
||||
if (t2 === null) return false;
|
||||
t = t2;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
keyCommandToString(keyCommand: KeyCommand): string{
|
||||
const sortedModifiers = [...keyCommand.modifiers].sort();
|
||||
const modifierString = sortedModifiers.join('+');
|
||||
const separator = sortedModifiers.length > 0 ? '+' : '';
|
||||
return `${modifierString}${separator}${keyCommand.key}`;
|
||||
}
|
||||
|
||||
static buildTree = (commandMaps : CommandMap[]) : KeyTree | null =>{
|
||||
const tree = new KeyTree();
|
||||
for (const commandMap of commandMaps){
|
||||
if (!tree.addCommands(commandMap)) return null;
|
||||
}
|
||||
return tree;
|
||||
}
|
||||
|
||||
}
|
||||
22
src/keybindEmitter.ts
Normal file
22
src/keybindEmitter.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { KeybindEvent } from "./types";
|
||||
|
||||
type Listener = (event: KeybindEvent) => void;
|
||||
|
||||
export class KeybindEmitter {
|
||||
private listeners: Listener[] = [];
|
||||
|
||||
on(listener: Listener) {
|
||||
this.listeners.push(listener);
|
||||
return () => this.off(listener);
|
||||
}
|
||||
|
||||
off(listener: Listener) {
|
||||
this.listeners = this.listeners.filter(l => l !== listener);
|
||||
}
|
||||
|
||||
emit<T extends KeybindEvent["type"]>(
|
||||
event: Extract<KeybindEvent, { type: T }>
|
||||
) {
|
||||
for (const l of this.listeners) l(event);
|
||||
}
|
||||
}
|
||||
155
src/test/createKeyHandler.test.ts
Normal file
155
src/test/createKeyHandler.test.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { createKeyHandler } from "../createKeyHandler";
|
||||
import { makeCommand } from "./keyTree.test";
|
||||
import { KeybindEventTypes } from "../types";
|
||||
|
||||
describe("createKeyHandler", () => {
|
||||
it("emits the right event for matching key", () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const command = makeCommand("KeyA");
|
||||
const callback = vi.fn();
|
||||
const keyHandlerReturn = createKeyHandler([
|
||||
{
|
||||
command: [command],
|
||||
callback: callback
|
||||
}
|
||||
]);
|
||||
expect(keyHandlerReturn).not.toBe(null);
|
||||
|
||||
const handler = keyHandlerReturn?.handler;
|
||||
const emitter = keyHandlerReturn?.emitter;
|
||||
const listener = vi.fn();
|
||||
emitter?.on(listener);
|
||||
|
||||
const event = { code: command.key, shiftKey: false, ctrlKey: false, altKey: false, metaKey: false } as KeyboardEvent;
|
||||
handler?.(event);
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(2);
|
||||
|
||||
expect(listener).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: KeybindEventTypes.SequenceProgress,
|
||||
pressedKeys: expect.any(Array)
|
||||
}));
|
||||
|
||||
expect(listener).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: KeybindEventTypes.CommandMatched,
|
||||
callback: expect.any(Function)
|
||||
}));
|
||||
|
||||
const commandMatchedEvent = listener.mock.calls.find(
|
||||
call => call[0].type === KeybindEventTypes.CommandMatched
|
||||
)?.[0];
|
||||
|
||||
expect(commandMatchedEvent).toBeDefined();
|
||||
commandMatchedEvent?.callback();
|
||||
expect(callback).toHaveBeenCalled();
|
||||
|
||||
});
|
||||
|
||||
it("emits sequenceTimeout if timeout occurs", () => {
|
||||
vi.useFakeTimers();
|
||||
const delay = 1000;
|
||||
|
||||
// Two-key sequence
|
||||
const command1 = makeCommand("KeyA");
|
||||
const command2 = makeCommand("KeyB");
|
||||
const callback = vi.fn();
|
||||
|
||||
const keyHandlerReturn = createKeyHandler([
|
||||
{ command: [command1, command2], callback }
|
||||
], delay);
|
||||
|
||||
const handler = keyHandlerReturn?.handler;
|
||||
const emitter = keyHandlerReturn?.emitter;
|
||||
const listener = vi.fn();
|
||||
emitter?.on(listener);
|
||||
|
||||
// Press only the first key
|
||||
const event1 = { code: command1.key, shiftKey: false, ctrlKey: false, altKey: false, metaKey: false } as KeyboardEvent;
|
||||
handler?.(event1);
|
||||
|
||||
// SequenceProgress should be emitted for first key
|
||||
expect(listener).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: KeybindEventTypes.SequenceProgress,
|
||||
pressedKeys: expect.any(Array),
|
||||
})
|
||||
);
|
||||
|
||||
// Advance timers past the delay to trigger timeout
|
||||
vi.advanceTimersByTime(delay * 2);
|
||||
|
||||
expect(listener).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: KeybindEventTypes.SequenceTimeout })
|
||||
);
|
||||
|
||||
// Only 2 emissions
|
||||
expect(listener).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("emits ending output twice if key pressed twice", () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const command = makeCommand("KeyA");
|
||||
const callback = vi.fn();
|
||||
const keyHandlerReturn = createKeyHandler([
|
||||
{ command: [command], callback }
|
||||
]);
|
||||
|
||||
const handler = keyHandlerReturn?.handler;
|
||||
const emitter = keyHandlerReturn?.emitter;
|
||||
const listener = vi.fn();
|
||||
emitter?.on(listener);
|
||||
|
||||
const event = { code: command.key, shiftKey: false, ctrlKey: false, altKey: false, metaKey: false } as KeyboardEvent;
|
||||
|
||||
// Press key twice
|
||||
handler?.(event);
|
||||
handler?.(event);
|
||||
|
||||
const matchedCalls = listener.mock.calls.filter(
|
||||
call => call[0].type === KeybindEventTypes.CommandMatched
|
||||
);
|
||||
|
||||
expect(matchedCalls.length).toBe(2);
|
||||
|
||||
matchedCalls.forEach(call => call[0].callback());
|
||||
expect(callback).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("emits sequenceProgress when first key of a sequence is pressed", () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const command1 = makeCommand("KeyA");
|
||||
const command2 = makeCommand("KeyB");
|
||||
const callback = vi.fn();
|
||||
const keyHandlerReturn = createKeyHandler([
|
||||
{ command: [command1, command2], callback }
|
||||
]);
|
||||
|
||||
const handler = keyHandlerReturn?.handler;
|
||||
const emitter = keyHandlerReturn?.emitter;
|
||||
const listener = vi.fn();
|
||||
emitter?.on(listener);
|
||||
|
||||
const event = { code: command1.key, shiftKey: false, ctrlKey: false, altKey: false, metaKey: false } as KeyboardEvent;
|
||||
handler?.(event);
|
||||
|
||||
expect(listener).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: KeybindEventTypes.SequenceProgress,
|
||||
pressedKeys: expect.any(Array)
|
||||
})
|
||||
);
|
||||
|
||||
// Ensure callback hasn't been called yet
|
||||
const matchedCalls = listener.mock.calls.filter(
|
||||
call => call[0].type === KeybindEventTypes.CommandMatched
|
||||
);
|
||||
expect(matchedCalls.length).toBe(0);
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
79
src/test/keyTree.test.ts
Normal file
79
src/test/keyTree.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { createKeyHandler } from "../createKeyHandler";
|
||||
import { ModifierKey, type CommandMap, type KeyCommand } from "../types";
|
||||
import { KeyTree } from "../keyTree";
|
||||
|
||||
export const makeCommand = (key: string, modifiers: ModifierKey[] = []): KeyCommand => ({
|
||||
key,
|
||||
modifiers,
|
||||
});
|
||||
|
||||
export const makeCommandMap = (command: KeyCommand[], callback: () => void): CommandMap => ({
|
||||
command,
|
||||
callback
|
||||
})
|
||||
describe("KeyTree", () => {
|
||||
it("adding single command works", () => {
|
||||
const command = makeCommand("KeyA");
|
||||
const callback = vi.fn();
|
||||
const commandMap = makeCommandMap([command], callback);
|
||||
const tree = KeyTree.buildTree([commandMap]);
|
||||
expect(tree).toBeInstanceOf(KeyTree);
|
||||
|
||||
const child = tree?.hasKeyCommand(command);
|
||||
expect(child).not.toBeNull();
|
||||
expect(child?.isEnd).toBe(true);
|
||||
|
||||
child?.callback?.();
|
||||
expect(callback).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("prevents adding conflicting commands", () => {
|
||||
const command = makeCommand("KeyA");
|
||||
const callback = vi.fn();
|
||||
const commandMap1 = makeCommandMap([command], callback);
|
||||
const commandMap2 = makeCommandMap([command], callback);
|
||||
const tree = KeyTree.buildTree([commandMap1, commandMap2]);
|
||||
expect(tree).toBe(null);
|
||||
});
|
||||
|
||||
it("allows branching commands", () => {
|
||||
const commandA = makeCommand("KeyA");
|
||||
const commandB = makeCommand("KeyB");
|
||||
const commandC = makeCommand("KeyC");
|
||||
const callback1 = vi.fn();
|
||||
const callback2 = vi.fn();
|
||||
const commandMap1 = makeCommandMap([commandA, commandB], callback1);
|
||||
const commandMap2 = makeCommandMap([commandA, commandC], callback2);
|
||||
const tree = KeyTree.buildTree([commandMap1, commandMap2]);
|
||||
expect(tree).toBeInstanceOf(KeyTree);
|
||||
|
||||
const child1 = tree?.hasKeyCommand(commandA)?.hasKeyCommand(commandB);
|
||||
expect(child1).not.toBeNull();
|
||||
expect(child1?.isEnd).toBe(true);
|
||||
|
||||
child1?.callback?.();
|
||||
expect(callback1).toHaveBeenCalled();
|
||||
|
||||
const child2 = tree?.hasKeyCommand(commandA)?.hasKeyCommand(commandC);
|
||||
expect(child2).not.toBeNull();
|
||||
expect(child2?.isEnd).toBe(true);
|
||||
|
||||
child2?.callback?.();
|
||||
expect(callback2).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("creates consistent key string with modifiers", () => {
|
||||
const tree = new KeyTree();
|
||||
const keyStr = tree.keyCommandToString(makeCommand("A", [ModifierKey.Alt, ModifierKey.Control, ModifierKey.Shift]));
|
||||
const keyStr2 = tree.keyCommandToString(makeCommand("A", [ModifierKey.Shift, ModifierKey.Control, ModifierKey.Alt]));
|
||||
expect(keyStr).toBe(keyStr2);
|
||||
});
|
||||
|
||||
it("doesn't create matching key string with subset modifiers", () => {
|
||||
const tree = new KeyTree();
|
||||
const keyStr = tree.keyCommandToString(makeCommand("A", [ModifierKey.Alt, ModifierKey.Control, ModifierKey.Shift]));
|
||||
const keyStr2 = tree.keyCommandToString(makeCommand("A", [ModifierKey.Shift, ModifierKey.Control]));
|
||||
expect(keyStr).not.toBe(keyStr2);
|
||||
})
|
||||
});
|
||||
41
src/types.ts
Normal file
41
src/types.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { KeybindEmitter } from "./keybindEmitter";
|
||||
|
||||
export type KeyCode = string;
|
||||
export enum ModifierKey {
|
||||
Shift = 'Shift',
|
||||
Control = 'Ctrl',
|
||||
Alt = 'Alt',
|
||||
Meta = 'Meta'
|
||||
}
|
||||
export type Modifiers = Array<ModifierKey>;
|
||||
|
||||
export interface KeyCommand{
|
||||
key: KeyCode;
|
||||
modifiers: Modifiers;
|
||||
}
|
||||
|
||||
export interface CommandMap {
|
||||
command: KeyCommand[];
|
||||
callback: () => void;
|
||||
}
|
||||
|
||||
export const KeybindEventTypes = {
|
||||
SequenceProgress: "sequenceProgress",
|
||||
CommandMatched: "commandMatched",
|
||||
SequenceTimeout: "sequenceTimeout",
|
||||
SequenceNoMatch: "sequenceNoMatch",
|
||||
} as const;
|
||||
|
||||
export type KeybindEventType = (typeof KeybindEventTypes)[keyof typeof KeybindEventTypes];
|
||||
|
||||
export type KeybindEvent =
|
||||
| { type: typeof KeybindEventTypes.SequenceProgress; pressedKeys: KeyCommand[] }
|
||||
| { type: typeof KeybindEventTypes.CommandMatched; callback: (() => void) | null }
|
||||
| { type: typeof KeybindEventTypes.SequenceTimeout; }
|
||||
| { type: typeof KeybindEventTypes.SequenceNoMatch; };
|
||||
|
||||
export type KeyEventHandler = (event: KeyboardEvent) => void;
|
||||
export interface KeyHandlerReturn {
|
||||
handler: KeyEventHandler;
|
||||
emitter: KeybindEmitter;
|
||||
}
|
||||
Reference in New Issue
Block a user