feat(keybinds): add keybindStore with persist, conflict detection, and tests
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { useKeybindStore } from './keybindStore';
|
||||
|
||||
beforeEach(() => {
|
||||
useKeybindStore.setState({ keybinds: [] });
|
||||
});
|
||||
|
||||
describe('keybindStore', () => {
|
||||
it('starts with no keybinds', () => {
|
||||
expect(useKeybindStore.getState().keybinds).toEqual([]);
|
||||
});
|
||||
|
||||
it('adds a keybind', () => {
|
||||
useKeybindStore.getState().setKeybind({
|
||||
actionId: 'toggleMute',
|
||||
keys: [42, 50],
|
||||
displayLabel: 'Shift + M',
|
||||
});
|
||||
const kb = useKeybindStore.getState().keybinds;
|
||||
expect(kb).toHaveLength(1);
|
||||
expect(kb[0].actionId).toBe('toggleMute');
|
||||
expect(kb[0].keys).toEqual([42, 50]); // sorted
|
||||
});
|
||||
|
||||
it('sorts keys on save', () => {
|
||||
useKeybindStore.getState().setKeybind({
|
||||
actionId: 'toggleMute',
|
||||
keys: [50, 42],
|
||||
displayLabel: 'Shift + M',
|
||||
});
|
||||
expect(useKeybindStore.getState().keybinds[0].keys).toEqual([42, 50]);
|
||||
});
|
||||
|
||||
it('replaces existing keybind for same action', () => {
|
||||
const store = useKeybindStore.getState();
|
||||
store.setKeybind({ actionId: 'toggleMute', keys: [42], displayLabel: 'Shift' });
|
||||
store.setKeybind({ actionId: 'toggleMute', keys: [50], displayLabel: 'M' });
|
||||
expect(useKeybindStore.getState().keybinds).toHaveLength(1);
|
||||
expect(useKeybindStore.getState().keybinds[0].displayLabel).toBe('M');
|
||||
});
|
||||
|
||||
it('removes a keybind', () => {
|
||||
useKeybindStore.getState().setKeybind({
|
||||
actionId: 'toggleMute',
|
||||
keys: [42],
|
||||
displayLabel: 'Shift',
|
||||
});
|
||||
useKeybindStore.getState().removeKeybind('toggleMute');
|
||||
expect(useKeybindStore.getState().keybinds).toEqual([]);
|
||||
});
|
||||
|
||||
it('detects conflicts', () => {
|
||||
useKeybindStore.getState().setKeybind({
|
||||
actionId: 'toggleMute',
|
||||
keys: [42, 50],
|
||||
displayLabel: 'Shift + M',
|
||||
});
|
||||
const conflict = useKeybindStore.getState().findConflict([42, 50]);
|
||||
expect(conflict).not.toBeNull();
|
||||
expect(conflict!.actionId).toBe('toggleMute');
|
||||
});
|
||||
|
||||
it('excludes specified action from conflict check', () => {
|
||||
useKeybindStore.getState().setKeybind({
|
||||
actionId: 'toggleMute',
|
||||
keys: [42, 50],
|
||||
displayLabel: 'Shift + M',
|
||||
});
|
||||
const conflict = useKeybindStore.getState().findConflict([42, 50], undefined, 'toggleMute');
|
||||
expect(conflict).toBeNull();
|
||||
});
|
||||
|
||||
it('detects mouse button conflicts', () => {
|
||||
useKeybindStore.getState().setKeybind({
|
||||
actionId: 'toggleMute',
|
||||
keys: [],
|
||||
mouseButton: 4,
|
||||
displayLabel: 'Mouse 4',
|
||||
});
|
||||
const conflict = useKeybindStore.getState().findConflict([], 4);
|
||||
expect(conflict).not.toBeNull();
|
||||
});
|
||||
|
||||
it('rejects blacklisted mouse buttons', () => {
|
||||
useKeybindStore.getState().setKeybind({
|
||||
actionId: 'toggleMute',
|
||||
keys: [],
|
||||
mouseButton: 1, // left click — blacklisted
|
||||
displayLabel: 'Mouse 1',
|
||||
});
|
||||
expect(useKeybindStore.getState().keybinds).toEqual([]);
|
||||
});
|
||||
|
||||
it('supports modifier + mouse button combos', () => {
|
||||
useKeybindStore.getState().setKeybind({
|
||||
actionId: 'pushToTalk',
|
||||
keys: [42],
|
||||
mouseButton: 4,
|
||||
displayLabel: 'Shift + Mouse 4',
|
||||
});
|
||||
const kb = useKeybindStore.getState().keybinds;
|
||||
expect(kb).toHaveLength(1);
|
||||
expect(kb[0].keys).toEqual([42]);
|
||||
expect(kb[0].mouseButton).toBe(4);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
export interface Keybind {
|
||||
actionId: string;
|
||||
keys: number[]; // uIOhook keycodes, sorted ascending
|
||||
mouseButton?: number; // uIOhook mouse button (3=middle, 4=back, 5=forward)
|
||||
displayLabel: string; // human-readable, captured at record time
|
||||
}
|
||||
|
||||
export const BINDABLE_ACTIONS = [
|
||||
{ id: 'toggleMute', label: 'Toggle Mute', type: 'toggle' as const },
|
||||
{ id: 'toggleDeafen', label: 'Toggle Deafen', type: 'toggle' as const },
|
||||
{ id: 'pushToTalk', label: 'Push to Talk', type: 'hold' as const },
|
||||
{ id: 'toggleCamera', label: 'Toggle Camera', type: 'toggle' as const },
|
||||
{ id: 'toggleScreenShare', label: 'Toggle Screen Share', type: 'toggle' as const },
|
||||
{ id: 'disconnect', label: 'Disconnect', type: 'toggle' as const },
|
||||
] as const;
|
||||
|
||||
/** Mouse buttons that must not be bound (would break OS interaction) */
|
||||
const BLACKLISTED_MOUSE_BUTTONS = new Set([1, 2]); // left, right
|
||||
|
||||
interface KeybindState {
|
||||
keybinds: Keybind[];
|
||||
setKeybind: (keybind: Keybind) => void;
|
||||
removeKeybind: (actionId: string) => void;
|
||||
findConflict: (keys: number[], mouseButton?: number, excludeActionId?: string) => Keybind | null;
|
||||
}
|
||||
|
||||
function keybindsEqual(a: Keybind, b: { keys: number[]; mouseButton?: number }): boolean {
|
||||
if (a.keys.length !== b.keys.length) return false;
|
||||
if (a.mouseButton !== b.mouseButton) return false;
|
||||
return a.keys.every((k, i) => k === b.keys[i]);
|
||||
}
|
||||
|
||||
export const useKeybindStore = create<KeybindState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
keybinds: [],
|
||||
|
||||
setKeybind: (keybind: Keybind) => {
|
||||
if (keybind.mouseButton && BLACKLISTED_MOUSE_BUTTONS.has(keybind.mouseButton)) return;
|
||||
const sorted = { ...keybind, keys: [...keybind.keys].sort((a, b) => a - b) };
|
||||
set((state) => ({
|
||||
keybinds: [
|
||||
...state.keybinds.filter((kb) => kb.actionId !== sorted.actionId),
|
||||
sorted,
|
||||
],
|
||||
}));
|
||||
},
|
||||
|
||||
removeKeybind: (actionId: string) => {
|
||||
set((state) => ({
|
||||
keybinds: state.keybinds.filter((kb) => kb.actionId !== actionId),
|
||||
}));
|
||||
},
|
||||
|
||||
findConflict: (keys: number[], mouseButton?: number, excludeActionId?: string) => {
|
||||
const sorted = [...keys].sort((a, b) => a - b);
|
||||
return get().keybinds.find(
|
||||
(kb) => kb.actionId !== excludeActionId && keybindsEqual(kb, { keys: sorted, mouseButton })
|
||||
) ?? null;
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'backspace-keybinds',
|
||||
version: 1,
|
||||
}
|
||||
)
|
||||
);
|
||||
Reference in New Issue
Block a user