10 changed files with 586 additions and 37 deletions
@ -0,0 +1,154 @@ |
|||||||
|
/** |
||||||
|
* Keyboard shortcuts service |
||||||
|
* Provides global keyboard shortcuts for the application |
||||||
|
*/ |
||||||
|
|
||||||
|
export interface KeyboardShortcut { |
||||||
|
key: string; |
||||||
|
ctrl?: boolean; |
||||||
|
shift?: boolean; |
||||||
|
alt?: boolean; |
||||||
|
meta?: boolean; |
||||||
|
description: string; |
||||||
|
category: 'navigation' | 'actions' | 'forms' | 'general'; |
||||||
|
} |
||||||
|
|
||||||
|
export const KEYBOARD_SHORTCUTS: KeyboardShortcut[] = [ |
||||||
|
// Navigation
|
||||||
|
{ key: 'j', description: 'Navigate to next post/thread', category: 'navigation' }, |
||||||
|
{ key: 'k', description: 'Navigate to previous post/thread', category: 'navigation' }, |
||||||
|
{ key: '/', description: 'Focus search box', category: 'navigation' }, |
||||||
|
{ key: 'Escape', description: 'Close modals/drawers', category: 'navigation' }, |
||||||
|
|
||||||
|
// Actions
|
||||||
|
{ key: 'r', description: 'Reply to current post/thread', category: 'actions' }, |
||||||
|
{ key: 'z', description: 'Zap current post/thread', category: 'actions' }, |
||||||
|
{ key: 'u', description: 'Upvote current post/thread', category: 'actions' }, |
||||||
|
{ key: 'd', description: 'Downvote current post/thread', category: 'actions' }, |
||||||
|
{ key: 'b', description: 'Bookmark current post/thread', category: 'actions' }, |
||||||
|
|
||||||
|
// Forms
|
||||||
|
{ key: 'Enter', ctrl: true, description: 'Submit reply/form (when in textarea)', category: 'forms' }, |
||||||
|
{ key: 'Enter', meta: true, description: 'Submit reply/form (Mac: Cmd+Enter)', category: 'forms' }, |
||||||
|
|
||||||
|
// General
|
||||||
|
{ key: '?', description: 'Show keyboard shortcuts help', category: 'general' }, |
||||||
|
]; |
||||||
|
|
||||||
|
export type ShortcutHandler = (event: KeyboardEvent) => void | boolean; |
||||||
|
|
||||||
|
class KeyboardShortcutsManager { |
||||||
|
private handlers: Map<string, Set<ShortcutHandler>> = new Map(); |
||||||
|
private enabled = true; |
||||||
|
private currentContext: string | null = null; |
||||||
|
|
||||||
|
constructor() { |
||||||
|
if (typeof window !== 'undefined') { |
||||||
|
window.addEventListener('keydown', this.handleKeyDown.bind(this)); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private getShortcutKey(event: KeyboardEvent): string { |
||||||
|
const parts: string[] = []; |
||||||
|
if (event.ctrlKey) parts.push('ctrl'); |
||||||
|
if (event.shiftKey) parts.push('shift'); |
||||||
|
if (event.altKey) parts.push('alt'); |
||||||
|
if (event.metaKey) parts.push('meta'); |
||||||
|
parts.push(event.key.toLowerCase()); |
||||||
|
return parts.join('+'); |
||||||
|
} |
||||||
|
|
||||||
|
private handleKeyDown(event: KeyboardEvent) { |
||||||
|
if (!this.enabled) return; |
||||||
|
|
||||||
|
// Don't trigger shortcuts when typing in inputs, textareas, or contenteditable
|
||||||
|
const target = event.target as HTMLElement; |
||||||
|
if ( |
||||||
|
target.tagName === 'INPUT' || |
||||||
|
target.tagName === 'TEXTAREA' || |
||||||
|
target.isContentEditable || |
||||||
|
target.closest('[contenteditable="true"]') |
||||||
|
) { |
||||||
|
// Allow CTRL+ENTER and CMD+ENTER in textareas
|
||||||
|
if ( |
||||||
|
(event.ctrlKey || event.metaKey) && |
||||||
|
event.key === 'Enter' && |
||||||
|
target.tagName === 'TEXTAREA' |
||||||
|
) { |
||||||
|
// Let the textarea handle it
|
||||||
|
return; |
||||||
|
} |
||||||
|
// Allow ? to show help from anywhere
|
||||||
|
if (event.key === '?' && !event.ctrlKey && !event.metaKey && !event.altKey) { |
||||||
|
// Continue to process
|
||||||
|
} else { |
||||||
|
return; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const shortcutKey = this.getShortcutKey(event); |
||||||
|
const handlers = this.handlers.get(shortcutKey); |
||||||
|
|
||||||
|
if (handlers && handlers.size > 0) { |
||||||
|
for (const handler of handlers) { |
||||||
|
const result = handler(event); |
||||||
|
if (result === false) { |
||||||
|
// Handler explicitly prevented default
|
||||||
|
event.preventDefault(); |
||||||
|
event.stopPropagation(); |
||||||
|
return; |
||||||
|
} |
||||||
|
if (result !== undefined) { |
||||||
|
// Handler returned a value, assume it handled the event
|
||||||
|
event.preventDefault(); |
||||||
|
event.stopPropagation(); |
||||||
|
return; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Register a keyboard shortcut handler |
||||||
|
* @param shortcut The shortcut key combination (e.g., 'ctrl+k', 'j', 'shift+r') |
||||||
|
* @param handler The handler function |
||||||
|
* @returns A function to unregister the handler |
||||||
|
*/ |
||||||
|
register(shortcut: string, handler: ShortcutHandler): () => void { |
||||||
|
const normalized = shortcut.toLowerCase(); |
||||||
|
if (!this.handlers.has(normalized)) { |
||||||
|
this.handlers.set(normalized, new Set()); |
||||||
|
} |
||||||
|
this.handlers.get(normalized)!.add(handler); |
||||||
|
|
||||||
|
return () => { |
||||||
|
const handlers = this.handlers.get(normalized); |
||||||
|
if (handlers) { |
||||||
|
handlers.delete(handler); |
||||||
|
if (handlers.size === 0) { |
||||||
|
this.handlers.delete(normalized); |
||||||
|
} |
||||||
|
} |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Enable or disable keyboard shortcuts globally |
||||||
|
*/ |
||||||
|
setEnabled(enabled: boolean) { |
||||||
|
this.enabled = enabled; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Set the current context (for context-specific shortcuts) |
||||||
|
*/ |
||||||
|
setContext(context: string | null) { |
||||||
|
this.currentContext = context; |
||||||
|
} |
||||||
|
|
||||||
|
getContext(): string | null { |
||||||
|
return this.currentContext; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export const keyboardShortcuts = new KeyboardShortcutsManager(); |
||||||
Loading…
Reference in new issue