16 changed files with 463 additions and 13 deletions
@ -0,0 +1,266 @@
@@ -0,0 +1,266 @@
|
||||
import { Button } from '@/components/ui/button' |
||||
import { |
||||
Dialog, |
||||
DialogContent, |
||||
DialogDescription, |
||||
DialogHeader, |
||||
DialogTitle |
||||
} from '@/components/ui/dialog' |
||||
import { |
||||
isRadixDialogOpen, |
||||
OPEN_NEW_POST_SHORTCUT_KEY, |
||||
shouldIgnoreKeyboardShortcutEvent |
||||
} from '@/lib/keyboard-shortcuts' |
||||
import postEditorService from '@/services/post-editor.service' |
||||
import { CircleHelp } from 'lucide-react' |
||||
import { |
||||
createContext, |
||||
useCallback, |
||||
useContext, |
||||
useEffect, |
||||
useMemo, |
||||
useState, |
||||
type ReactNode |
||||
} from 'react' |
||||
import { useTranslation } from 'react-i18next' |
||||
|
||||
type KeyboardShortcutsHelpContextValue = { |
||||
openHelp: () => void |
||||
} |
||||
|
||||
const KeyboardShortcutsHelpContext = createContext<KeyboardShortcutsHelpContextValue | null>(null) |
||||
|
||||
export function useKeyboardShortcutsHelp(): KeyboardShortcutsHelpContextValue { |
||||
const ctx = useContext(KeyboardShortcutsHelpContext) |
||||
if (!ctx) { |
||||
throw new Error('useKeyboardShortcutsHelp must be used within KeyboardShortcutsHelpProvider') |
||||
} |
||||
return ctx |
||||
} |
||||
|
||||
function Kbd({ children }: { children: ReactNode }) { |
||||
return ( |
||||
<kbd className="pointer-events-none inline-flex h-6 min-w-[1.25rem] shrink-0 items-center justify-center rounded border border-border bg-muted px-1.5 font-mono text-[11px] font-medium text-muted-foreground"> |
||||
{children} |
||||
</kbd> |
||||
) |
||||
} |
||||
|
||||
function KbdRow({ keys, label }: { keys: ReactNode; label: string }) { |
||||
return ( |
||||
<div className="flex flex-col gap-1.5 sm:flex-row sm:items-start sm:justify-between sm:gap-4"> |
||||
<span className="text-muted-foreground leading-snug">{label}</span> |
||||
<div className="flex flex-wrap items-center gap-1 sm:justify-end">{keys}</div> |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
export function KeyboardShortcutsHelpProvider({ children }: { children: ReactNode }) { |
||||
const [open, setOpen] = useState(false) |
||||
const openHelp = useCallback(() => setOpen(true), []) |
||||
const { t } = useTranslation() |
||||
|
||||
useEffect(() => { |
||||
const onKeyDown = (e: KeyboardEvent) => { |
||||
if (open) return |
||||
if (shouldIgnoreKeyboardShortcutEvent(e.target)) return |
||||
if (isRadixDialogOpen()) return |
||||
|
||||
const isQuestionMark = |
||||
e.key === '?' || (e.shiftKey && e.code === 'Slash' && !e.ctrlKey && !e.metaKey && !e.altKey) |
||||
|
||||
if (isQuestionMark && !e.ctrlKey && !e.metaKey && !e.altKey) { |
||||
e.preventDefault() |
||||
setOpen(true) |
||||
return |
||||
} |
||||
|
||||
if (e.key === 'F1' && !e.ctrlKey && !e.metaKey && !e.altKey) { |
||||
e.preventDefault() |
||||
setOpen(true) |
||||
return |
||||
} |
||||
|
||||
if ( |
||||
e.altKey && |
||||
e.shiftKey && |
||||
e.key.toLowerCase() === OPEN_NEW_POST_SHORTCUT_KEY && |
||||
!e.ctrlKey && |
||||
!e.metaKey |
||||
) { |
||||
e.preventDefault() |
||||
postEditorService.requestOpenNewPost() |
||||
} |
||||
} |
||||
|
||||
document.addEventListener('keydown', onKeyDown, true) |
||||
return () => document.removeEventListener('keydown', onKeyDown, true) |
||||
}, [open]) |
||||
|
||||
const value = useMemo(() => ({ openHelp }), [openHelp]) |
||||
|
||||
return ( |
||||
<KeyboardShortcutsHelpContext.Provider value={value}> |
||||
{children} |
||||
<Dialog open={open} onOpenChange={setOpen}> |
||||
<DialogContent className="max-w-lg max-h-[min(85vh,32rem)] overflow-y-auto"> |
||||
<DialogHeader> |
||||
<DialogTitle>{t('shortcuts.title')}</DialogTitle> |
||||
<DialogDescription>{t('shortcuts.intro')}</DialogDescription> |
||||
</DialogHeader> |
||||
<div className="space-y-4 pt-2 text-sm"> |
||||
<section className="space-y-3"> |
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"> |
||||
{t('shortcuts.sectionApp')} |
||||
</h3> |
||||
<div className="space-y-3"> |
||||
<KbdRow |
||||
label={t('shortcuts.openHelp')} |
||||
keys={ |
||||
<> |
||||
<Kbd>?</Kbd> |
||||
<span className="text-muted-foreground px-0.5">{t('shortcuts.or')}</span> |
||||
<Kbd>F1</Kbd> |
||||
</> |
||||
} |
||||
/> |
||||
<KbdRow |
||||
label={t('shortcuts.focusPrimary')} |
||||
keys={ |
||||
<> |
||||
<Kbd>Shift</Kbd> |
||||
<span className="text-muted-foreground">+</span> |
||||
<Kbd>Alt</Kbd> |
||||
<span className="text-muted-foreground">+</span> |
||||
<Kbd>F</Kbd> |
||||
</> |
||||
} |
||||
/> |
||||
<KbdRow |
||||
label={t('shortcuts.focusSecondary')} |
||||
keys={ |
||||
<> |
||||
<Kbd>Shift</Kbd> |
||||
<span className="text-muted-foreground">+</span> |
||||
<Kbd>Alt</Kbd> |
||||
<span className="text-muted-foreground">+</span> |
||||
<Kbd>S</Kbd> |
||||
</> |
||||
} |
||||
/> |
||||
<KbdRow |
||||
label={t('shortcuts.newNote')} |
||||
keys={ |
||||
<> |
||||
<Kbd>Shift</Kbd> |
||||
<span className="text-muted-foreground">+</span> |
||||
<Kbd>Alt</Kbd> |
||||
<span className="text-muted-foreground">+</span> |
||||
<Kbd>N</Kbd> |
||||
</> |
||||
} |
||||
/> |
||||
</div> |
||||
</section> |
||||
<section className="space-y-3"> |
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"> |
||||
{t('shortcuts.sectionSearch')} |
||||
</h3> |
||||
<div className="space-y-3"> |
||||
<KbdRow |
||||
label={t('shortcuts.searchSuggest')} |
||||
keys={ |
||||
<> |
||||
<Kbd>↑</Kbd> |
||||
<Kbd>↓</Kbd> |
||||
<span className="text-muted-foreground px-1">{t('shortcuts.then')}</span> |
||||
<Kbd>Enter</Kbd> |
||||
</> |
||||
} |
||||
/> |
||||
<KbdRow |
||||
label={t('shortcuts.searchDismiss')} |
||||
keys={<Kbd>Esc</Kbd>} |
||||
/> |
||||
</div> |
||||
</section> |
||||
<section className="space-y-3"> |
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"> |
||||
{t('shortcuts.sectionStandard')} |
||||
</h3> |
||||
<div className="space-y-3"> |
||||
<KbdRow |
||||
label={t('shortcuts.tabNavigate')} |
||||
keys={ |
||||
<> |
||||
<Kbd>Tab</Kbd> |
||||
<span className="text-muted-foreground px-1">{t('shortcuts.or')}</span> |
||||
<Kbd>Shift</Kbd> |
||||
<span className="text-muted-foreground">+</span> |
||||
<Kbd>Tab</Kbd> |
||||
</> |
||||
} |
||||
/> |
||||
<KbdRow |
||||
label={t('shortcuts.activate')} |
||||
keys={ |
||||
<> |
||||
<Kbd>Enter</Kbd> |
||||
<span className="text-muted-foreground px-1">{t('shortcuts.or')}</span> |
||||
<Kbd>Space</Kbd> |
||||
</> |
||||
} |
||||
/> |
||||
<KbdRow |
||||
label={t('shortcuts.closeOverlays')} |
||||
keys={<Kbd>Esc</Kbd>} |
||||
/> |
||||
<KbdRow |
||||
label={t('shortcuts.scrollWhenFocused')} |
||||
keys={ |
||||
<> |
||||
<Kbd>↑</Kbd> |
||||
<Kbd>↓</Kbd> |
||||
<Kbd>PgUp</Kbd> |
||||
<Kbd>PgDn</Kbd> |
||||
<Kbd>Home</Kbd> |
||||
<Kbd>End</Kbd> |
||||
</> |
||||
} |
||||
/> |
||||
<KbdRow |
||||
label={t('shortcuts.browserBack')} |
||||
keys={ |
||||
<> |
||||
<Kbd>Alt</Kbd> |
||||
<span className="text-muted-foreground">+</span> |
||||
<Kbd>←</Kbd> |
||||
</> |
||||
} |
||||
/> |
||||
</div> |
||||
</section> |
||||
</div> |
||||
</DialogContent> |
||||
</Dialog> |
||||
</KeyboardShortcutsHelpContext.Provider> |
||||
) |
||||
} |
||||
|
||||
/** Titlebar-sized help control (e.g. home feed, next to profile). */ |
||||
export function KeyboardShortcutsHelpButton() { |
||||
const { openHelp } = useKeyboardShortcutsHelp() |
||||
const { t } = useTranslation() |
||||
return ( |
||||
<Button |
||||
type="button" |
||||
variant="ghost" |
||||
size="titlebar-icon" |
||||
onClick={() => openHelp()} |
||||
title={t('shortcuts.title')} |
||||
aria-label={t('shortcuts.title')} |
||||
> |
||||
<CircleHelp /> |
||||
</Button> |
||||
) |
||||
} |
||||
@ -0,0 +1,13 @@
@@ -0,0 +1,13 @@
|
||||
import { useKeyboardShortcutsHelp } from '@/components/KeyboardShortcutsHelp' |
||||
import { CircleHelp } from 'lucide-react' |
||||
import SidebarItem from './SidebarItem' |
||||
|
||||
export default function KeyboardShortcutsHelpSidebarButton() { |
||||
const { openHelp } = useKeyboardShortcutsHelp() |
||||
|
||||
return ( |
||||
<SidebarItem title="shortcuts.title" onClick={openHelp}> |
||||
<CircleHelp strokeWidth={2.5} /> |
||||
</SidebarItem> |
||||
) |
||||
} |
||||
@ -0,0 +1,22 @@
@@ -0,0 +1,22 @@
|
||||
/** Shared guards for app-wide keyboard shortcuts (help, focus panes, etc.). */ |
||||
|
||||
export function isRadixDialogOpen(): boolean { |
||||
return !!document.querySelector('[data-radix-dialog-content][data-state="open"]') |
||||
} |
||||
|
||||
export function shouldIgnoreKeyboardShortcutEvent(target: EventTarget | null): boolean { |
||||
if (!(target instanceof HTMLElement)) return false |
||||
const el = target |
||||
const tag = el.tagName |
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true |
||||
if (el.isContentEditable) return true |
||||
if (el.closest('[contenteditable="true"]')) return true |
||||
if (el.closest('.ProseMirror')) return true |
||||
if (el.getAttribute('role') === 'textbox') return true |
||||
return false |
||||
} |
||||
|
||||
export const FOCUS_PRIMARY_SCROLL_SHORTCUT_KEY = 'f' |
||||
export const FOCUS_SECONDARY_SCROLL_SHORTCUT_KEY = 's' |
||||
/** Shift+Alt+N — open new note composer (handled in KeyboardShortcutsHelpProvider). */ |
||||
export const OPEN_NEW_POST_SHORTCUT_KEY = 'n' |
||||
Loading…
Reference in new issue