diff --git a/src/Novelly.Web/src/App.tsx b/src/Novelly.Web/src/App.tsx index 6b623c4..79c0b6f 100644 --- a/src/Novelly.Web/src/App.tsx +++ b/src/Novelly.Web/src/App.tsx @@ -8,21 +8,26 @@ import ChaptersPage from './pages/ChaptersPage' import ChapterPage from './pages/ChapterPage' import AgentPage from './pages/AgentPage' import SettingsPage from './pages/SettingsPage' +import { HotkeysProvider } from './keyboard/HotkeysContext' +import { HelpOverlay } from './keyboard/HelpOverlay' export default function App() { return ( - - } /> - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - } /> - + + + + } /> + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + } /> + + ) } diff --git a/src/Novelly.Web/src/keyboard/HelpOverlay.tsx b/src/Novelly.Web/src/keyboard/HelpOverlay.tsx new file mode 100644 index 0000000..5a078b1 --- /dev/null +++ b/src/Novelly.Web/src/keyboard/HelpOverlay.tsx @@ -0,0 +1,109 @@ +import { useState } from 'react' +import { useHotkey, useHotkeysList } from './HotkeysContext' + +const formatToken = (token: string) => { + if (token === 'mod') return '⌘/Ctrl' + if (token.length === 1) return token.toUpperCase() + return token +} + +function KeyChip({ token }: { token: string }) { + return ( + + {formatToken(token)} + + ) +} + +function KeySequence({ keys }: { keys: string }) { + const chords = keys.split(' ') + return ( + + {chords.map((chord, chordIndex) => ( + + {chordIndex > 0 && then} + {chord.split('+').map((token, tokenIndex, tokens) => ( + + + {tokenIndex < tokens.length - 1 && +} + + ))} + + ))} + + ) +} + +export function HelpOverlay() { + const [open, setOpen] = useState(false) + const shortcuts = useHotkeysList() + + useHotkey('?', 'Toggle this help', () => setOpen((v) => !v), { group: 'Global' }) + useHotkey('Escape', 'Close help', () => setOpen(false), { group: 'Global', enabled: open }) + + const groups = new Map() + for (const shortcut of shortcuts) { + if (!groups.has(shortcut.group)) groups.set(shortcut.group, []) + groups.get(shortcut.group)!.push(shortcut) + } + const orderedGroups = [...groups.entries()].sort(([a], [b]) => + a === 'Global' ? -1 : b === 'Global' ? 1 : a.localeCompare(b), + ) + + return ( + <> + setOpen(true)} + aria-label="Keyboard shortcuts" + title="Keyboard shortcuts (?)" + > + ? + + + {open && ( + setOpen(false)}> + + + )} + > + ) +} diff --git a/src/Novelly.Web/src/keyboard/HotkeysContext.tsx b/src/Novelly.Web/src/keyboard/HotkeysContext.tsx new file mode 100644 index 0000000..45f3d79 --- /dev/null +++ b/src/Novelly.Web/src/keyboard/HotkeysContext.tsx @@ -0,0 +1,149 @@ +import { createContext, useCallback, useContext, useEffect, useId, useMemo, useRef, useState, type ReactNode } from 'react' + +export interface HotkeyEntry { + id: string + keys: string + description: string + group: string + enabled: boolean + allowInInputs: boolean + run: () => void +} + +interface HotkeysActions { + register: (entry: HotkeyEntry) => void + unregister: (id: string) => void +} + +// Split so registering a hotkey (stable actions) never invalidates every other +// hotkey's effect just because the entries list (read only by the help sidebar) changed. +const HotkeysActionsContext = createContext(null) +const HotkeysEntriesContext = createContext([]) + +const isTypingTarget = (el: EventTarget | null) => { + if (!(el instanceof HTMLElement)) return false + return el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT' || el.isContentEditable +} + +const normalizeKey = (e: KeyboardEvent) => (e.key.length === 1 ? e.key.toLowerCase() : e.key) + +const withMods = (e: KeyboardEvent, base: string) => ((e.metaKey || e.ctrlKey) && base !== 'Escape' ? `mod+${base}` : base) + +const CHORD_TIMEOUT_MS = 1500 + +export function HotkeysProvider({ children }: { children: ReactNode }) { + const registryRef = useRef(new Map()) + const [entries, setEntries] = useState([]) + const chordRef = useRef<{ prefix: string; timer: number } | null>(null) + + const register = useCallback((entry: HotkeyEntry) => { + registryRef.current.set(entry.id, entry) + setEntries([...registryRef.current.values()]) + }, []) + + const unregister = useCallback((id: string) => { + registryRef.current.delete(id) + setEntries([...registryRef.current.values()]) + }, []) + + useEffect(() => { + const dispatch = (keys: string, onlyAllowInInputs = false) => { + const matches = [...registryRef.current.values()].filter( + (entry) => entry.keys === keys && entry.enabled && (!onlyAllowInInputs || entry.allowInInputs), + ) + matches.forEach((entry) => entry.run()) + return matches.length > 0 + } + + const clearChord = () => { + if (chordRef.current) window.clearTimeout(chordRef.current.timer) + chordRef.current = null + } + + const onKeyDown = (e: KeyboardEvent) => { + const base = normalizeKey(e) + const typing = isTypingTarget(e.target) + + if (base === 'Escape') { + clearChord() + dispatch('Escape') + return + } + + const modded = withMods(e, base) + + if (typing) { + if (dispatch(modded, true)) e.preventDefault() + return + } + + if (chordRef.current) { + const candidate = `${chordRef.current.prefix} ${base}` + clearChord() + if (dispatch(candidate)) { + e.preventDefault() + return + } + } + + if (dispatch(modded) || dispatch(base)) { + e.preventDefault() + return + } + + const isChordPrefix = [...registryRef.current.values()].some((entry) => entry.keys.startsWith(`${base} `)) + if (isChordPrefix) { + const timer = window.setTimeout(clearChord, CHORD_TIMEOUT_MS) + chordRef.current = { prefix: base, timer } + } + } + + window.addEventListener('keydown', onKeyDown) + return () => { + window.removeEventListener('keydown', onKeyDown) + clearChord() + } + }, []) + + const actions = useMemo(() => ({ register, unregister }), [register, unregister]) + + return ( + + {children} + + ) +} + +/** Registers a keyboard shortcut and (while mounted) lists it in the help sidebar. + * + * `keys` is either a single token ("n", "?", "Escape", "mod+Enter") or a two-key + * chord ("g d"). Chords never fire while a text field is focused; single keys are + * ignored while typing unless `allowInInputs` is set. + */ +export function useHotkey( + keys: string, + description: string, + handler: () => void, + options?: { group?: string; enabled?: boolean; allowInInputs?: boolean }, +) { + const actions = useContext(HotkeysActionsContext) + const id = useId() + const handlerRef = useRef(handler) + const enabled = options?.enabled ?? true + const group = options?.group ?? 'General' + const allowInInputs = options?.allowInInputs ?? false + + useEffect(() => { + handlerRef.current = handler + }) + + useEffect(() => { + if (!actions) return + actions.register({ id, keys, description, group, enabled, allowInInputs, run: () => handlerRef.current() }) + return () => actions.unregister(id) + }, [actions, id, keys, description, group, enabled, allowInInputs]) +} + +export function useHotkeysList() { + return useContext(HotkeysEntriesContext) +} diff --git a/src/Novelly.Web/src/pages/AgentPage.tsx b/src/Novelly.Web/src/pages/AgentPage.tsx index 5d7e9d5..5096e0e 100644 --- a/src/Novelly.Web/src/pages/AgentPage.tsx +++ b/src/Novelly.Web/src/pages/AgentPage.tsx @@ -3,6 +3,7 @@ import { useParams } from 'react-router-dom' import { useConversation, useConversations, useSendAgentMessage } from '../api/hooks' import type { AgentMessage } from '../api/types' import { ErrorNote, Spinner } from '../components/ui' +import { useHotkey } from '../keyboard/HotkeysContext' const starters = [ 'Read the brief and tell me what the outline is missing.', @@ -33,6 +34,13 @@ export default function AgentPage() { ) } + useHotkey('n', 'New conversation', () => setConversationId(undefined), { group: 'Agent' }) + useHotkey('mod+Enter', 'Send message', () => submit(draft), { + group: 'Agent', + allowInInputs: true, + enabled: draft.trim().length > 0 && !send.isPending, + }) + return (