mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-08-02 18:10:18 +05:30
feat(renderer): useShortcut hook (generic key combo binding)
- 'mod+s' / 'mod+shift+s' / 'Tab' / etc. - mod = meta (Mac) or ctrl (others) - suppresses when <input>/<textarea>/contentEditable focused - preventDefault on match - TDD: 15 unit tests covering parser, match, suppression, unmount
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* Bind a keyboard shortcut to a callback.
|
||||
*
|
||||
* Combos use `+` separators. Tokens:
|
||||
* - `mod` → meta (Mac) or ctrl (others)
|
||||
* - `ctrl` → ctrl only
|
||||
* - `meta` → meta only
|
||||
* - `alt` → alt / option
|
||||
* - `shift` → shift
|
||||
* - any single char (case-insensitive), e.g. `s`, `S`
|
||||
* - special names: `Tab`, `Enter`, `Escape`, `Space`, `ArrowUp/Down/Left/Right`
|
||||
*
|
||||
* The hook is suppressed when the keydown target is an `<input>`, `<textarea>`,
|
||||
* or contentEditable element, mirroring the standard browser shortcut pattern.
|
||||
*
|
||||
* On match, `event.preventDefault()` is called.
|
||||
*/
|
||||
export function useShortcut(combo: string, callback: (e: KeyboardEvent) => void): void {
|
||||
useEffect(() => {
|
||||
const tokens = parseCombo(combo);
|
||||
|
||||
function isTypingTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
const tag = target.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA') return true;
|
||||
if (target.isContentEditable) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function handle(e: KeyboardEvent): void {
|
||||
if (isTypingTarget(e.target)) return;
|
||||
if (!matchCombo(tokens, e)) return;
|
||||
e.preventDefault();
|
||||
callback(e);
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handle);
|
||||
return () => window.removeEventListener('keydown', handle);
|
||||
}, [combo, callback]);
|
||||
}
|
||||
|
||||
interface ComboSpec {
|
||||
mod?: boolean;
|
||||
ctrl?: boolean;
|
||||
meta?: boolean;
|
||||
alt?: boolean;
|
||||
shift?: boolean;
|
||||
key: string; // lowercased
|
||||
}
|
||||
|
||||
function parseCombo(combo: string): ComboSpec {
|
||||
const parts = combo
|
||||
.split('+')
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean);
|
||||
const spec: ComboSpec = { key: '' };
|
||||
for (const p of parts) {
|
||||
const lower = p.toLowerCase();
|
||||
if (lower === 'mod') spec.mod = true;
|
||||
else if (lower === 'ctrl' || lower === 'control') spec.ctrl = true;
|
||||
else if (lower === 'meta' || lower === 'cmd' || lower === 'command') spec.meta = true;
|
||||
else if (lower === 'alt' || lower === 'option') spec.alt = true;
|
||||
else if (lower === 'shift') spec.shift = true;
|
||||
else spec.key = lower;
|
||||
}
|
||||
return spec;
|
||||
}
|
||||
|
||||
function matchCombo(spec: ComboSpec, e: KeyboardEvent): boolean {
|
||||
const key = e.key.toLowerCase();
|
||||
if (key !== spec.key) return false;
|
||||
|
||||
if (spec.mod) {
|
||||
if (!(e.metaKey || e.ctrlKey)) return false;
|
||||
}
|
||||
if (spec.ctrl && !e.ctrlKey) return false;
|
||||
if (spec.meta && !e.metaKey) return false;
|
||||
if (spec.alt && !e.altKey) return false;
|
||||
if (spec.shift && !e.shiftKey) return false;
|
||||
|
||||
// Reject extra modifiers that the spec did not ask for.
|
||||
// (e.g. spec says "mod+s" but user pressed "mod+shift+s" → no match.)
|
||||
if (!spec.shift && e.shiftKey) return false;
|
||||
if (!spec.alt && e.altKey) return false;
|
||||
// ctrl/meta: if spec did not require mod, ctrl or meta must be absent.
|
||||
if (!spec.mod && !spec.ctrl && e.ctrlKey) return false;
|
||||
if (!spec.mod && !spec.meta && e.metaKey) return false;
|
||||
// If spec said "mod" only, ctrl+meta is allowed (mod is either-or).
|
||||
if (spec.mod && !spec.ctrl && !spec.meta) {
|
||||
// ok
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { fireEvent } from '@testing-library/dom';
|
||||
import { useShortcut } from '@/hooks/use-shortcut';
|
||||
|
||||
describe('useShortcut', () => {
|
||||
beforeEach(() => {
|
||||
// No-op: each test sets up its own keydown
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('calls the callback when the shortcut matches (mod+s)', () => {
|
||||
const cb = vi.fn();
|
||||
renderHook(() => useShortcut('mod+s', cb));
|
||||
fireEvent.keyDown(window, { key: 's', ctrlKey: true });
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('recognizes metaKey as well as ctrlKey (Mac shortcut)', () => {
|
||||
const cb = vi.fn();
|
||||
renderHook(() => useShortcut('mod+s', cb));
|
||||
fireEvent.keyDown(window, { key: 's', metaKey: true });
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('matches plain keys without modifier (just "k")', () => {
|
||||
const cb = vi.fn();
|
||||
renderHook(() => useShortcut('k', cb));
|
||||
fireEvent.keyDown(window, { key: 'k' });
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('matches shift variants (mod+shift+s)', () => {
|
||||
const cb = vi.fn();
|
||||
renderHook(() => useShortcut('mod+shift+s', cb));
|
||||
fireEvent.keyDown(window, { key: 'S', ctrlKey: true, shiftKey: true });
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does NOT fire when shift is required but missing', () => {
|
||||
const cb = vi.fn();
|
||||
renderHook(() => useShortcut('mod+shift+s', cb));
|
||||
fireEvent.keyDown(window, { key: 's', ctrlKey: true });
|
||||
expect(cb).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does NOT fire when the wrong key is pressed', () => {
|
||||
const cb = vi.fn();
|
||||
renderHook(() => useShortcut('mod+s', cb));
|
||||
fireEvent.keyDown(window, { key: 'x', ctrlKey: true });
|
||||
expect(cb).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does NOT fire when no modifier is held but one is required', () => {
|
||||
const cb = vi.fn();
|
||||
renderHook(() => useShortcut('mod+s', cb));
|
||||
fireEvent.keyDown(window, { key: 's' });
|
||||
expect(cb).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does NOT fire when the target is an <input>', () => {
|
||||
const cb = vi.fn();
|
||||
renderHook(() => useShortcut('mod+s', cb));
|
||||
const input = document.createElement('input');
|
||||
document.body.appendChild(input);
|
||||
fireEvent.keyDown(input, { key: 's', ctrlKey: true });
|
||||
document.body.removeChild(input);
|
||||
expect(cb).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does NOT fire when the target is a <textarea>', () => {
|
||||
const cb = vi.fn();
|
||||
renderHook(() => useShortcut('mod+s', cb));
|
||||
const ta = document.createElement('textarea');
|
||||
document.body.appendChild(ta);
|
||||
fireEvent.keyDown(ta, { key: 's', ctrlKey: true });
|
||||
document.body.removeChild(ta);
|
||||
expect(cb).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does NOT fire when the target is contentEditable', () => {
|
||||
const cb = vi.fn();
|
||||
renderHook(() => useShortcut('mod+s', cb));
|
||||
const div = document.createElement('div');
|
||||
Object.defineProperty(div, 'isContentEditable', { value: true, configurable: true });
|
||||
document.body.appendChild(div);
|
||||
fireEvent.keyDown(div, { key: 's', ctrlKey: true });
|
||||
document.body.removeChild(div);
|
||||
expect(cb).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls preventDefault when the shortcut matches', () => {
|
||||
const cb = vi.fn();
|
||||
renderHook(() => useShortcut('mod+s', cb));
|
||||
const event = new KeyboardEvent('keydown', { key: 's', ctrlKey: true, bubbles: true, cancelable: true });
|
||||
window.dispatchEvent(event);
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
});
|
||||
|
||||
it('does NOT call preventDefault when the shortcut does NOT match', () => {
|
||||
const cb = vi.fn();
|
||||
renderHook(() => useShortcut('mod+s', cb));
|
||||
const event = new KeyboardEvent('keydown', { key: 'x', ctrlKey: true, bubbles: true, cancelable: true });
|
||||
window.dispatchEvent(event);
|
||||
expect(event.defaultPrevented).toBe(false);
|
||||
});
|
||||
|
||||
it('supports the special "Tab" key as a non-modifier shortcut', () => {
|
||||
const cb = vi.fn();
|
||||
renderHook(() => useShortcut('mod+tab', cb));
|
||||
fireEvent.keyDown(window, { key: 'Tab', ctrlKey: true });
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('unbinds the listener on unmount', () => {
|
||||
const cb = vi.fn();
|
||||
const { unmount } = renderHook(() => useShortcut('mod+s', cb));
|
||||
unmount();
|
||||
fireEvent.keyDown(window, { key: 's', ctrlKey: true });
|
||||
expect(cb).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('updates the bound callback when the function reference changes', () => {
|
||||
const a = vi.fn();
|
||||
const b = vi.fn();
|
||||
const { rerender } = renderHook(({ cb }) => useShortcut('mod+s', cb), {
|
||||
initialProps: { cb: a },
|
||||
});
|
||||
rerender({ cb: b });
|
||||
fireEvent.keyDown(window, { key: 's', ctrlKey: true });
|
||||
expect(a).not.toHaveBeenCalled();
|
||||
expect(b).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user