From fb964cc01a5e7296895c7e1f3c64334976cc1835 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sun, 22 Mar 2026 20:32:22 +0100 Subject: [PATCH] feat(keybinds): add keybindStore with persist, conflict detection, and tests --- packages/web/src/stores/keybindStore.test.ts | 106 +++++++++++++++++++ packages/web/src/stores/keybindStore.ts | 70 ++++++++++++ 2 files changed, 176 insertions(+) create mode 100644 packages/web/src/stores/keybindStore.test.ts create mode 100644 packages/web/src/stores/keybindStore.ts diff --git a/packages/web/src/stores/keybindStore.test.ts b/packages/web/src/stores/keybindStore.test.ts new file mode 100644 index 00000000..0802fc39 --- /dev/null +++ b/packages/web/src/stores/keybindStore.test.ts @@ -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); + }); +}); diff --git a/packages/web/src/stores/keybindStore.ts b/packages/web/src/stores/keybindStore.ts new file mode 100644 index 00000000..3e909465 --- /dev/null +++ b/packages/web/src/stores/keybindStore.ts @@ -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()( + 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, + } + ) +);