Skip to content

Commit 1d55e65

Browse files
JavaZerooclaude
andcommitted
feat: P3 — keyboard shortcuts + PWA
Keyboard shortcuts: - useKeyboardShortcuts hook: global keydown listener, supports 'mod+' (⌘ on macOS, Ctrl elsewhere), skips when focus is on input/textarea/contenteditable except for Escape which always reaches handlers. - ShortcutHelp modal with <kbd>-styled key combos, grouped by category; opens on '?', closes on Esc or backdrop click. - HelpCircle button in the sidebar header surfaces the modal so users can discover shortcuts without knowing the binding. - Bindings: ? (help) · Esc (close) · s (toggle sidebar) · c (toggle combined view) · 1-5 (switch Display Options tab). PWA: - vite-plugin-pwa with autoUpdate registration. - Manifest: name, theme color (#3b82f6), standalone display, relative start_url so GitHub Pages and Vercel both work without rebuild. - Workbox precaches app shell + Inter/JetBrains Mono fonts (~944 KB total) for full offline boot. Install prompt appears in Chromium-based browsers. Tests: +4 cases for formatBinding (103 passing). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 8f86848 commit 1d55e65

9 files changed

Lines changed: 6726 additions & 2358 deletions

File tree

package-lock.json

Lines changed: 6344 additions & 2356 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
"globals": "^16.0.0",
4646
"jsdom": "^24.0.0",
4747
"vite": "^6.3.5",
48+
"vite-plugin-pwa": "^1.3.0",
4849
"vitest": "^1.4.0"
4950
}
5051
}

public/locales/en/translation.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,17 @@
174174
"configModal.example3": "Start: 0, End: empty → shows all data points (default)",
175175
"configModal.cancel": "Cancel",
176176
"configModal.save": "Save Config",
177+
"shortcuts.title": "Keyboard shortcuts",
178+
"shortcuts.close": "Close shortcuts",
179+
"shortcuts.hint": "Shortcuts are disabled while typing in inputs. Esc always works.",
180+
"shortcuts.aria": "Show keyboard shortcuts",
181+
"shortcuts.groupGeneral": "General",
182+
"shortcuts.groupView": "View",
183+
"shortcuts.groupDisplay": "Display options",
184+
"shortcuts.help": "Show shortcuts",
185+
"shortcuts.escape": "Close dialog or help",
186+
"shortcuts.toggleSidebar": "Toggle sidebar",
187+
"shortcuts.toggleCombined": "Toggle combined view",
177188
"toast.parseError": "Failed to parse {{name}}: {{error}}",
178189
"toast.parseComplete": "Parsed {{name}}",
179190
"toast.storageQuotaExceeded": "Storage limit reached. File persistence has been disabled for this session.",

public/locales/zh/translation.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,17 @@
174174
"configModal.example3": "起始: 0, 结束: 留空 → 显示全部数据点(默认)",
175175
"configModal.cancel": "取消",
176176
"configModal.save": "保存配置",
177+
"shortcuts.title": "键盘快捷键",
178+
"shortcuts.close": "关闭快捷键面板",
179+
"shortcuts.hint": "在输入框聚焦时快捷键暂停(Esc 始终生效)。",
180+
"shortcuts.aria": "显示键盘快捷键",
181+
"shortcuts.groupGeneral": "通用",
182+
"shortcuts.groupView": "视图",
183+
"shortcuts.groupDisplay": "显示选项",
184+
"shortcuts.help": "显示快捷键",
185+
"shortcuts.escape": "关闭对话框或帮助",
186+
"shortcuts.toggleSidebar": "切换侧栏",
187+
"shortcuts.toggleCombined": "切换合并视图",
177188
"toast.parseError": "解析 {{name}} 失败:{{error}}",
178189
"toast.parseComplete": "已解析 {{name}}",
179190
"toast.storageQuotaExceeded": "存储空间已满,本次会话将不再持久化文件。",

src/App.jsx

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,10 @@ import { ThemeToggle } from './components/ThemeToggle';
1010
import { Header } from './components/Header';
1111
import { AnnotationsPanel } from './components/AnnotationsPanel.jsx';
1212
import { CollapsibleCardHeader } from './components/CollapsibleCardHeader.jsx';
13+
import { ShortcutHelp } from './components/ShortcutHelp.jsx';
1314
import { useCollapsedSection } from './utils/useCollapsedSection.js';
14-
import { PanelLeftClose, PanelLeftOpen } from 'lucide-react';
15+
import { useKeyboardShortcuts } from './utils/useKeyboardShortcuts.js';
16+
import { PanelLeftClose, PanelLeftOpen, HelpCircle } from 'lucide-react';
1517
import { mergeFilesWithReplacement } from './utils/mergeFiles.js';
1618
import { useToast } from './components/ToastContext.jsx';
1719
import { loadFiles as loadFilesFromStorage, saveFiles as saveFilesToStorage, clearFiles as clearFilesInStorage } from './utils/fileStorage.js';
@@ -122,6 +124,7 @@ function App() {
122124
try { localStorage.setItem('ui.displayTab', displayTab); } catch { /* noop */ }
123125
}, [displayTab]);
124126
const [displayOpen, setDisplayOpen] = useCollapsedSection('display', true);
127+
const [helpOpen, setHelpOpen] = useState(false);
125128
const savingDisabledRef = useRef(false);
126129
const enabledFiles = uploadedFiles.filter(file => file.enabled);
127130
const workerRef = useRef(null);
@@ -404,6 +407,23 @@ function App() {
404407
});
405408
}, [globalParsingConfig]);
406409

410+
// Global keyboard shortcuts. Memoized handlers via inline functions —
411+
// the hook reads latest bindings via ref, so closures stay fresh.
412+
useKeyboardShortcuts({
413+
'?': () => setHelpOpen(true),
414+
'Escape': () => {
415+
setHelpOpen(false);
416+
setConfigModalOpen(false);
417+
},
418+
's': () => setSidebarVisible(v => !v),
419+
'c': () => setChartConfig(prev => ({ ...prev, combinedView: !prev.combinedView })),
420+
'1': () => setDisplayTab('chart'),
421+
'2': () => setDisplayTab('smoothing'),
422+
'3': () => setDisplayTab('stats'),
423+
'4': () => setDisplayTab('performance'),
424+
'5': () => setDisplayTab('baseline')
425+
});
426+
407427
// Reset configuration
408428
const handleResetConfig = useCallback(() => {
409429
savingDisabledRef.current = true;
@@ -561,6 +581,14 @@ function App() {
561581
<div className="ml-auto flex items-center gap-2">
562582
<Header />
563583
<ThemeToggle />
584+
<button
585+
onClick={() => setHelpOpen(true)}
586+
className="p-1 text-gray-400 hover:text-gray-600 dark:text-gray-400 dark:hover:text-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500 rounded"
587+
aria-label={t('shortcuts.aria')}
588+
title={t('shortcuts.aria') + ' (?)'}
589+
>
590+
<HelpCircle size={16} aria-hidden="true" />
591+
</button>
564592
</div>
565593
<button
566594
onClick={() => setSidebarVisible(false)}
@@ -904,6 +932,37 @@ function App() {
904932
onSave={handleConfigSave}
905933
globalParsingConfig={globalParsingConfig}
906934
/>
935+
936+
<ShortcutHelp
937+
isOpen={helpOpen}
938+
onClose={() => setHelpOpen(false)}
939+
groups={[
940+
{
941+
title: t('shortcuts.groupGeneral'),
942+
items: [
943+
{ label: t('shortcuts.help'), key: '?' },
944+
{ label: t('shortcuts.escape'), key: 'Escape' }
945+
]
946+
},
947+
{
948+
title: t('shortcuts.groupView'),
949+
items: [
950+
{ label: t('shortcuts.toggleSidebar'), key: 's' },
951+
{ label: t('shortcuts.toggleCombined'), key: 'c' }
952+
]
953+
},
954+
{
955+
title: t('shortcuts.groupDisplay'),
956+
items: [
957+
{ label: t('display.tabChart'), key: '1' },
958+
{ label: t('display.tabSmoothing'), key: '2' },
959+
{ label: t('display.tabStats'), key: '3' },
960+
{ label: t('display.tabPerformance'), key: '4' },
961+
{ label: t('display.tabBaseline'), key: '5' }
962+
]
963+
}
964+
]}
965+
/>
907966
</div>
908967
);
909968
}

src/components/ShortcutHelp.jsx

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import React, { useEffect, useRef } from 'react';
2+
import { X, Keyboard } from 'lucide-react';
3+
import { useTranslation } from 'react-i18next';
4+
import { formatBinding } from '../utils/useKeyboardShortcuts.js';
5+
6+
// Visual representation of one or more keys, optionally joined by '+'.
7+
function KeyCombo({ binding }) {
8+
const tokens = formatBinding(binding);
9+
return (
10+
<span className="inline-flex items-center gap-0.5" aria-label={binding}>
11+
{tokens.map((tok, i) => (
12+
<React.Fragment key={i}>
13+
{i > 0 && <span className="text-gray-400 dark:text-gray-500 text-xs">+</span>}
14+
<kbd className="inline-flex items-center justify-center min-w-[1.5rem] h-6 px-1.5 text-[11px] font-medium font-mono text-gray-700 dark:text-gray-200 bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded shadow-[inset_0_-1px_0_rgb(0,0,0,0.06)]">
15+
{tok}
16+
</kbd>
17+
</React.Fragment>
18+
))}
19+
</span>
20+
);
21+
}
22+
23+
// Display a sequence of bindings, e.g. ['g', 'd'] = press g then d.
24+
function KeySequence({ keys }) {
25+
return (
26+
<span className="inline-flex items-center gap-1">
27+
{keys.map((k, i) => (
28+
<React.Fragment key={i}>
29+
{i > 0 && <span className="text-gray-400 dark:text-gray-500 text-xs">then</span>}
30+
<KeyCombo binding={k} />
31+
</React.Fragment>
32+
))}
33+
</span>
34+
);
35+
}
36+
37+
export function ShortcutHelp({ isOpen, onClose, groups }) {
38+
const { t } = useTranslation();
39+
const closeBtnRef = useRef(null);
40+
41+
// Send focus to the close button on open so screen readers announce the
42+
// dialog, and Escape always lands somewhere useful.
43+
useEffect(() => {
44+
if (isOpen && closeBtnRef.current) {
45+
closeBtnRef.current.focus();
46+
}
47+
}, [isOpen]);
48+
49+
if (!isOpen) return null;
50+
51+
return (
52+
<div
53+
className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 drag-overlay-fade-in"
54+
role="dialog"
55+
aria-modal="true"
56+
aria-labelledby="shortcut-help-title"
57+
onClick={onClose}
58+
>
59+
<div
60+
className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl max-w-lg w-full max-h-[85vh] overflow-y-auto drag-modal-scale-in"
61+
onClick={(e) => e.stopPropagation()}
62+
>
63+
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-200 dark:border-gray-700">
64+
<div className="flex items-center gap-2">
65+
<Keyboard size={18} className="text-blue-600 dark:text-blue-400" aria-hidden="true" />
66+
<h2 id="shortcut-help-title" className="text-base font-semibold text-gray-900 dark:text-gray-100">
67+
{t('shortcuts.title')}
68+
</h2>
69+
</div>
70+
<button
71+
ref={closeBtnRef}
72+
type="button"
73+
onClick={onClose}
74+
className="p-1 text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
75+
aria-label={t('shortcuts.close')}
76+
>
77+
<X size={18} aria-hidden="true" />
78+
</button>
79+
</div>
80+
81+
<div className="px-5 py-4 space-y-5">
82+
{groups.map((group) => (
83+
<section key={group.title} aria-labelledby={`group-${group.title}`}>
84+
<h3
85+
id={`group-${group.title}`}
86+
className="text-[11px] font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2"
87+
>
88+
{group.title}
89+
</h3>
90+
<ul className="space-y-1.5">
91+
{group.items.map((item) => (
92+
<li
93+
key={item.label}
94+
className="flex items-center justify-between text-sm text-gray-700 dark:text-gray-300"
95+
>
96+
<span>{item.label}</span>
97+
{item.sequence
98+
? <KeySequence keys={item.sequence} />
99+
: <KeyCombo binding={item.key} />}
100+
</li>
101+
))}
102+
</ul>
103+
</section>
104+
))}
105+
</div>
106+
107+
<div className="px-5 py-3 border-t border-gray-200 dark:border-gray-700 text-xs text-gray-500 dark:text-gray-400">
108+
{t('shortcuts.hint')}
109+
</div>
110+
</div>
111+
</div>
112+
);
113+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { formatBinding } from '../useKeyboardShortcuts';
3+
4+
// formatBinding is pure and platform-aware; we exercise the platform-neutral
5+
// paths since jsdom's navigator.platform isn't macOS-like by default.
6+
7+
describe('formatBinding', () => {
8+
it('returns the uppercase letter for single-key bindings', () => {
9+
expect(formatBinding('s')).toEqual(['S']);
10+
});
11+
12+
it('returns the verbatim name for special keys', () => {
13+
expect(formatBinding('Escape')).toEqual(['Escape']);
14+
});
15+
16+
it('orders modifiers before the key', () => {
17+
const tokens = formatBinding('shift+k');
18+
expect(tokens[tokens.length - 1]).toBe('K');
19+
expect(tokens.length).toBe(2);
20+
});
21+
22+
it('handles digit keys', () => {
23+
expect(formatBinding('1')).toEqual(['1']);
24+
});
25+
});

0 commit comments

Comments
 (0)