initial commit

This commit is contained in:
Ben
2025-08-16 17:11:22 +02:00
commit 1843c51e49
11 changed files with 2125 additions and 0 deletions

88
src/createKeyHandler.ts Normal file
View 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
View File

@@ -0,0 +1 @@
export { createKeyHandler } from './createKeyHandler';

89
src/keyTree.ts Normal file
View 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
View 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);
}
}

View 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
View 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
View 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;
}