You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
312 lines
9.7 KiB
312 lines
9.7 KiB
import MarkdownArticle from '@/components/Note/MarkdownArticle/MarkdownArticle' |
|
import { Button } from '@/components/ui/button' |
|
import { |
|
Dialog, |
|
DialogContent, |
|
DialogDescription, |
|
DialogHeader, |
|
DialogTitle |
|
} from '@/components/ui/dialog' |
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' |
|
import { createFakeEvent } from '@/lib/event' |
|
import { |
|
isRadixDialogOpen, |
|
OPEN_NEW_POST_SHORTCUT_KEY, |
|
shouldIgnoreKeyboardShortcutEvent |
|
} from '@/lib/keyboard-shortcuts' |
|
import { cn } from '@/lib/utils' |
|
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' |
|
import readmeMarkdown from '../../../README.md?raw' |
|
|
|
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> |
|
) |
|
} |
|
|
|
function ShortcutsPanel() { |
|
const { t } = useTranslation() |
|
return ( |
|
<div className="space-y-4 pt-1 text-sm"> |
|
<p className="text-sm text-muted-foreground">{t('shortcuts.intro')}</p> |
|
<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="px-0.5 text-muted-foreground">{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="px-1 text-muted-foreground">{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="px-1 text-muted-foreground">{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="px-1 text-muted-foreground">{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> |
|
) |
|
} |
|
|
|
function ReadmeOverviewPanel({ className }: { className?: string }) { |
|
const readmeEvent = useMemo( |
|
() => |
|
createFakeEvent({ |
|
id: '0'.repeat(64), |
|
pubkey: '0'.repeat(64), |
|
content: readmeMarkdown, |
|
created_at: 0, |
|
kind: 1, |
|
tags: [], |
|
sig: '0'.repeat(128) |
|
}), |
|
[] |
|
) |
|
|
|
return ( |
|
<div className={cn('min-w-0 pt-1', className)}> |
|
<MarkdownArticle event={readmeEvent} hideMetadata className="text-sm" /> |
|
</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="flex max-h-[min(88vh,40rem)] max-w-2xl flex-col gap-0 overflow-hidden p-6 sm:max-w-2xl"> |
|
<DialogHeader className="shrink-0 space-y-1 pb-2 pr-8 text-left"> |
|
<DialogTitle>{t('help.title')}</DialogTitle> |
|
<DialogDescription className="sr-only">{t('shortcuts.intro')}</DialogDescription> |
|
</DialogHeader> |
|
<Tabs defaultValue="shortcuts" className="flex min-h-0 flex-1 flex-col gap-2"> |
|
<TabsList className="grid w-full shrink-0 grid-cols-2"> |
|
<TabsTrigger value="shortcuts">{t('help.tabShortcuts')}</TabsTrigger> |
|
<TabsTrigger value="overview">{t('help.tabOverview')}</TabsTrigger> |
|
</TabsList> |
|
<TabsContent |
|
value="shortcuts" |
|
className="mt-0 max-h-[min(62vh,32rem)] min-h-0 flex-1 overflow-y-auto overscroll-contain pr-4 [scrollbar-gutter:stable] data-[state=inactive]:hidden" |
|
> |
|
<ShortcutsPanel /> |
|
</TabsContent> |
|
<TabsContent |
|
value="overview" |
|
className="mt-0 max-h-[min(62vh,32rem)] min-h-0 flex-1 overflow-y-auto overscroll-contain pr-4 [scrollbar-gutter:stable] data-[state=inactive]:hidden" |
|
> |
|
<ReadmeOverviewPanel /> |
|
</TabsContent> |
|
</Tabs> |
|
</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('help.title')} |
|
aria-label={t('help.title')} |
|
> |
|
<CircleHelp /> |
|
</Button> |
|
) |
|
}
|
|
|