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.
256 lines
8.7 KiB
256 lines
8.7 KiB
import { |
|
Dialog, |
|
DialogContent, |
|
DialogDescription, |
|
DialogHeader, |
|
DialogTitle |
|
} from '@/components/ui/dialog' |
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' |
|
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 { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react' |
|
import { marked } from 'marked' |
|
import { KeyboardShortcutsHelpContext } from '@/contexts/keyboard-shortcuts-help-context' |
|
import { useTranslation } from 'react-i18next' |
|
import readmeMarkdown from '../../../README.md?raw' |
|
|
|
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>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 readmeHtml = useMemo(() => { |
|
// README is local project content; render it as regular markdown (not note-content parsing). |
|
const html = marked.parse(readmeMarkdown, { |
|
gfm: true, |
|
breaks: true |
|
}) |
|
const resolved = typeof html === 'string' ? html : '' |
|
return resolved.replace(/<a\s+href=/g, '<a target="_blank" rel="noopener noreferrer" href=') |
|
}, []) |
|
|
|
return ( |
|
<div |
|
className={cn( |
|
'min-w-0 pt-1 text-sm prose prose-sm dark:prose-invert max-w-none', |
|
'[&_a]:text-link-uri [&_a]:no-underline hover:[&_a]:text-primary hover:[&_a]:underline', |
|
'[&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_code]:rounded', |
|
'[&_pre]:bg-muted [&_pre]:p-3 [&_pre]:rounded-lg [&_pre]:overflow-x-auto', |
|
'[&_img]:max-w-full [&_img]:h-auto [&_img]:rounded-md', |
|
className |
|
)} |
|
dangerouslySetInnerHTML={{ __html: readmeHtml }} |
|
/> |
|
) |
|
} |
|
|
|
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 |
|
|
|
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> |
|
) |
|
}
|
|
|