Add keyboard shortcuts and a help sidebar
Global hotkey registry (src/keyboard) with chord support (g d, g c, ...) and a "?" help sidebar that lists whatever's registered on the current screen. Wired up nav chords plus the primary create action on each page (new novel/character/chapter/beat/scene, new conversation), and moved the agent's mod+Enter send through the same registry.
This commit is contained in:
@@ -8,9 +8,13 @@ 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 (
|
||||
<HotkeysProvider>
|
||||
<HelpOverlay />
|
||||
<Routes>
|
||||
<Route path="/" element={<ProjectsPage />} />
|
||||
<Route path="/projects/:projectId" element={<ProjectLayout />}>
|
||||
@@ -24,5 +28,6 @@ export default function App() {
|
||||
</Route>
|
||||
<Route path="*" element={<ProjectsPage />} />
|
||||
</Routes>
|
||||
</HotkeysProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<kbd
|
||||
className="rounded border px-1.5 py-0.5 font-mono text-xs"
|
||||
style={{ borderColor: 'var(--line)', background: 'var(--surface-sunken)' }}
|
||||
>
|
||||
{formatToken(token)}
|
||||
</kbd>
|
||||
)
|
||||
}
|
||||
|
||||
function KeySequence({ keys }: { keys: string }) {
|
||||
const chords = keys.split(' ')
|
||||
return (
|
||||
<span className="flex items-center gap-1">
|
||||
{chords.map((chord, chordIndex) => (
|
||||
<span key={chordIndex} className="flex items-center gap-1">
|
||||
{chordIndex > 0 && <span className="text-xs muted">then</span>}
|
||||
{chord.split('+').map((token, tokenIndex, tokens) => (
|
||||
<span key={tokenIndex} className="flex items-center gap-1">
|
||||
<KeyChip token={token} />
|
||||
{tokenIndex < tokens.length - 1 && <span className="text-xs muted">+</span>}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
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<string, typeof shortcuts>()
|
||||
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 (
|
||||
<>
|
||||
<button
|
||||
className="fixed top-3 right-4 z-40 flex h-8 w-8 items-center justify-center rounded-full text-sm font-semibold shadow-sm transition hover:shadow-md"
|
||||
style={{ background: 'var(--surface)', border: '1px solid var(--line)' }}
|
||||
onClick={() => setOpen(true)}
|
||||
aria-label="Keyboard shortcuts"
|
||||
title="Keyboard shortcuts (?)"
|
||||
>
|
||||
?
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="fixed inset-0 z-50 flex justify-end bg-black/40" onClick={() => setOpen(false)}>
|
||||
<aside
|
||||
className="flex h-full w-full max-w-sm flex-col overflow-y-auto p-5 shadow-xl"
|
||||
style={{ background: 'var(--surface)', borderLeft: '1px solid var(--line)' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">Keyboard shortcuts</h2>
|
||||
<button className="btn px-2 py-1" onClick={() => setOpen(false)} aria-label="Close">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="mb-4 text-sm muted">
|
||||
Shown here are the shortcuts available on the screen you're on. Shortcuts don't fire
|
||||
while a text field is focused, except where noted.
|
||||
</p>
|
||||
|
||||
{orderedGroups.length === 0 && <p className="text-sm muted">No shortcuts registered.</p>}
|
||||
|
||||
<div className="grid gap-5">
|
||||
{orderedGroups.map(([group, groupShortcuts]) => (
|
||||
<div key={group}>
|
||||
<h3 className="label mb-2">{group}</h3>
|
||||
<ul className="grid gap-2">
|
||||
{groupShortcuts.map((shortcut) => (
|
||||
<li key={shortcut.id} className="flex items-center justify-between gap-3 text-sm">
|
||||
<span>{shortcut.description}</span>
|
||||
<KeySequence keys={shortcut.keys} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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<HotkeysActions | null>(null)
|
||||
const HotkeysEntriesContext = createContext<HotkeyEntry[]>([])
|
||||
|
||||
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<string, HotkeyEntry>())
|
||||
const [entries, setEntries] = useState<HotkeyEntry[]>([])
|
||||
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 (
|
||||
<HotkeysActionsContext.Provider value={actions}>
|
||||
<HotkeysEntriesContext.Provider value={entries}>{children}</HotkeysEntriesContext.Provider>
|
||||
</HotkeysActionsContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
/** 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)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="grid gap-6 lg:grid-cols-[15rem_1fr]">
|
||||
<aside className="grid content-start gap-2">
|
||||
@@ -107,12 +115,6 @@ export default function AgentPage() {
|
||||
value={draft}
|
||||
placeholder="Ask about structure, a character's arc, or what the next beat should do…"
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault()
|
||||
submit(draft)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button className="btn btn-primary self-end" disabled={!draft.trim() || send.isPending}>
|
||||
Send
|
||||
|
||||
@@ -18,6 +18,7 @@ import { draftStatuses, type Beat, type Chapter, type Scene } from '../api/types
|
||||
import { AutoField, ErrorNote, Select, Spinner, StatusBadge } from '../components/ui'
|
||||
import { TagChip, TagEditor } from '../components/TagEditor'
|
||||
import { OpenQuestions } from '../components/OpenQuestions'
|
||||
import { useHotkey } from '../keyboard/HotkeysContext'
|
||||
|
||||
export default function ChapterPage() {
|
||||
const { projectId = '', chapterId = '' } = useParams()
|
||||
@@ -30,6 +31,9 @@ export default function ChapterPage() {
|
||||
const createBeat = useCreateBeat(chapterId, projectId)
|
||||
const createScene = useCreateScene(chapterId)
|
||||
|
||||
useHotkey('b', 'Add beat', () => createBeat.mutate({ title: 'New beat' }), { group: 'Chapter' })
|
||||
useHotkey('s', 'Add scene', () => createScene.mutate({ title: 'New scene' }), { group: 'Chapter' })
|
||||
|
||||
if (isPending) return <Spinner label="Loading chapter" />
|
||||
if (error) return <ErrorNote error={error} />
|
||||
if (!chapter) return null
|
||||
@@ -274,6 +278,7 @@ function BeatTable({
|
||||
onBlur={(e) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setEditingId(null)
|
||||
}}
|
||||
onKeyDown={(e) => e.key === 'Escape' && setEditingId(null)}
|
||||
>
|
||||
<td className="px-2 py-2 align-top">
|
||||
<div className="flex items-center gap-1">
|
||||
|
||||
@@ -2,12 +2,15 @@ import { Link, useParams } from 'react-router-dom'
|
||||
import { useChapters, useCreateChapter } from '../api/hooks'
|
||||
import { EmptyState, ErrorNote, Spinner, StatusBadge } from '../components/ui'
|
||||
import { TagChip } from '../components/TagEditor'
|
||||
import { useHotkey } from '../keyboard/HotkeysContext'
|
||||
|
||||
export default function ChaptersPage() {
|
||||
const { projectId = '' } = useParams()
|
||||
const { data: chapters, isPending, error } = useChapters(projectId)
|
||||
const create = useCreateChapter(projectId)
|
||||
|
||||
useHotkey('n', 'Add chapter', () => create.mutate({ title: 'Untitled chapter' }), { group: 'Chapters' })
|
||||
|
||||
if (isPending) return <Spinner label="Loading chapters" />
|
||||
if (error) return <ErrorNote error={error} />
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import { TagEditor } from '../components/TagEditor'
|
||||
import { CharacterArc } from '../components/CharacterArc'
|
||||
import { CharacterBeats } from '../components/CharacterBeats'
|
||||
import { OpenQuestions } from '../components/OpenQuestions'
|
||||
import { useHotkey } from '../keyboard/HotkeysContext'
|
||||
|
||||
export default function CharactersPage() {
|
||||
const { projectId = '' } = useParams()
|
||||
@@ -20,6 +21,8 @@ export default function CharactersPage() {
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
const [adding, setAdding] = useState(false)
|
||||
|
||||
useHotkey('n', 'Add character', () => setAdding(true), { group: 'Characters' })
|
||||
|
||||
if (isPending) return <Spinner label="Loading characters" />
|
||||
if (error) return <ErrorNote error={error} />
|
||||
|
||||
|
||||
@@ -1,13 +1,24 @@
|
||||
import { Outlet, useParams, Link } from 'react-router-dom'
|
||||
import { Outlet, useParams, Link, useNavigate } from 'react-router-dom'
|
||||
import { useProject, useUpdateProject } from '../api/hooks'
|
||||
import { projectPhases } from '../api/types'
|
||||
import { ErrorNote, Spinner } from '../components/ui'
|
||||
import { useHotkey } from '../keyboard/HotkeysContext'
|
||||
|
||||
export default function ProjectLayout() {
|
||||
const { projectId = '' } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const { data: project, isPending, error } = useProject(projectId)
|
||||
const update = useUpdateProject(projectId)
|
||||
|
||||
const goTo = (path: string) => navigate(path ? `/projects/${projectId}/${path}` : `/projects/${projectId}`)
|
||||
|
||||
useHotkey('g d', 'Go to dashboard', () => goTo(''), { group: 'Navigate' })
|
||||
useHotkey('g o', 'Go to outline', () => goTo('chapters'), { group: 'Navigate' })
|
||||
useHotkey('g c', 'Go to characters', () => goTo('characters'), { group: 'Navigate' })
|
||||
useHotkey('g t', 'Go to tags', () => goTo('tags'), { group: 'Navigate' })
|
||||
useHotkey('g a', 'Go to agent', () => goTo('agent'), { group: 'Navigate' })
|
||||
useHotkey('g s', 'Go to settings', () => goTo('settings'), { group: 'Navigate' })
|
||||
|
||||
return (
|
||||
<div className="min-h-full">
|
||||
<header className="sticky top-0 z-10 border-b" style={{ borderColor: 'var(--line)', background: 'var(--surface)' }}>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Link, useNavigate } from 'react-router-dom'
|
||||
import { useCreateProject, useProjects } from '../api/hooks'
|
||||
import { ImportDialog } from '../components/ImportDialog'
|
||||
import { EmptyState, ErrorNote, Modal, Spinner } from '../components/ui'
|
||||
import { useHotkey } from '../keyboard/HotkeysContext'
|
||||
|
||||
export default function ProjectsPage() {
|
||||
const { data: projects, isPending, error } = useProjects()
|
||||
@@ -10,6 +11,9 @@ export default function ProjectsPage() {
|
||||
const [importing, setImporting] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
|
||||
useHotkey('n', 'New novel', () => setCreating(true), { group: 'Novels' })
|
||||
useHotkey('i', 'Import from outline', () => setImporting(true), { group: 'Novels' })
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl px-6 py-12">
|
||||
<header className="mb-8 flex items-end justify-between gap-4">
|
||||
|
||||
Reference in New Issue
Block a user