feat: add comprehensive keyboard shortcuts system

Keyboard shortcuts:
- **Ctrl/Cmd + O**: Open file dialog
- **Ctrl/Cmd + Enter**: Start conversion
- **Ctrl/Cmd + S**: Download results (ZIP if multiple)
- **Ctrl/Cmd + R**: Reset converter
- **Ctrl/Cmd + /**: Show keyboard shortcuts help
- **?**: Show keyboard shortcuts help
- **Escape**: Close shortcuts modal

Implementation:
- Created useKeyboardShortcuts custom hook
- Platform-aware keyboard handling (Cmd on Mac, Ctrl elsewhere)
- KeyboardShortcutsModal component with beautiful UI
- Floating keyboard icon button (bottom-right)
- Visual shortcut display with kbd tags
- Context-aware shortcut execution (respects disabled states)
- Prevents default browser behavior for shortcuts

User experience:
- Floating help button always accessible
- Clean modal with shortcut descriptions
- Platform-specific key symbols (⌘ on Mac)
- Shortcuts disabled when modal is open
- Clear visual feedback for shortcuts
- Non-intrusive button placement

Features:
- Smart ref forwarding to FileUpload component
- Conditional shortcut execution based on app state
- Professional kbd styling for key combinations
- Responsive modal with backdrop
- Smooth animations

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-17 13:34:11 +01:00
parent 6b0bda05db
commit 3f4fcf39bc
4 changed files with 220 additions and 2 deletions

View File

@@ -1,7 +1,7 @@
'use client';
import * as React from 'react';
import { ArrowRight, ArrowDown } from 'lucide-react';
import { ArrowRight, ArrowDown, Keyboard } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/Card';
import { FileUpload } from './FileUpload';
@@ -22,6 +22,8 @@ import { convertWithImageMagick } from '@/lib/converters/imagemagickService';
import { addToHistory } from '@/lib/storage/history';
import { downloadBlobsAsZip, generateOutputFilename } from '@/lib/utils/fileUtils';
import { getPresetById, type FormatPreset } from '@/lib/utils/formatPresets';
import { useKeyboardShortcuts, type KeyboardShortcut } from '@/lib/hooks/useKeyboardShortcuts';
import { KeyboardShortcutsModal } from '@/components/ui/KeyboardShortcutsModal';
import type { ConversionJob, ConversionFormat, ConversionOptions } from '@/types/conversion';
export function FileConverter() {
@@ -33,6 +35,9 @@ export function FileConverter() {
const [compatibleFormats, setCompatibleFormats] = React.useState<ConversionFormat[]>([]);
const [conversionJobs, setConversionJobs] = React.useState<ConversionJob[]>([]);
const [conversionOptions, setConversionOptions] = React.useState<ConversionOptions>({});
const [showShortcutsModal, setShowShortcutsModal] = React.useState(false);
const fileInputRef = React.useRef<HTMLInputElement>(null);
// Detect input format when files are selected
React.useEffect(() => {
@@ -364,6 +369,69 @@ export function FileConverter() {
const isConvertDisabled = selectedFiles.length === 0 || !outputFormat || isConverting;
const completedCount = conversionJobs.filter(job => job.status === 'completed').length;
// Define keyboard shortcuts
const shortcuts: KeyboardShortcut[] = [
{
key: 'o',
ctrl: true,
description: 'Open file dialog',
action: () => {
if (!isConverting) {
fileInputRef.current?.click();
}
},
},
{
key: 'Enter',
ctrl: true,
description: 'Start conversion',
action: () => {
if (!isConvertDisabled) {
handleConvert();
}
},
},
{
key: 's',
ctrl: true,
description: 'Download results',
action: () => {
if (completedCount > 0) {
handleDownloadAll();
}
},
},
{
key: 'r',
ctrl: true,
description: 'Reset converter',
action: () => {
if (!isConverting) {
handleReset();
}
},
},
{
key: '/',
ctrl: true,
description: 'Show keyboard shortcuts',
action: () => setShowShortcutsModal(true),
},
{
key: 'Escape',
description: 'Close shortcuts modal',
action: () => setShowShortcutsModal(false),
},
{
key: '?',
description: 'Show keyboard shortcuts',
action: () => setShowShortcutsModal(true),
},
];
// Enable keyboard shortcuts
useKeyboardShortcuts(shortcuts, !showShortcutsModal);
return (
<div className="w-full max-w-4xl mx-auto space-y-6">
{/* Header */}
@@ -381,6 +449,7 @@ export function FileConverter() {
onFileRemove={handleFileRemove}
selectedFiles={selectedFiles}
disabled={isConverting}
inputRef={fileInputRef}
/>
{/* File Info - show first file */}
@@ -489,6 +558,22 @@ export function FileConverter() {
))}
</div>
)}
{/* Keyboard Shortcuts Button */}
<Button
onClick={() => setShowShortcutsModal(true)}
className="fixed bottom-6 right-6 rounded-full w-12 h-12 p-0 shadow-lg"
title="Keyboard Shortcuts (Ctrl+/)"
>
<Keyboard className="h-5 w-5" />
</Button>
{/* Keyboard Shortcuts Modal */}
<KeyboardShortcutsModal
shortcuts={shortcuts}
isOpen={showShortcutsModal}
onClose={() => setShowShortcutsModal(false)}
/>
</div>
);
}

View File

@@ -13,6 +13,7 @@ export interface FileUploadProps {
accept?: string;
maxSizeMB?: number;
disabled?: boolean;
inputRef?: React.RefObject<HTMLInputElement | null>;
}
export function FileUpload({
@@ -22,9 +23,10 @@ export function FileUpload({
accept,
maxSizeMB = 500,
disabled = false,
inputRef,
}: FileUploadProps) {
const [isDragging, setIsDragging] = React.useState(false);
const fileInputRef = React.useRef<HTMLInputElement>(null);
const fileInputRef = inputRef || React.useRef<HTMLInputElement>(null);
const handleDragEnter = (e: React.DragEvent) => {
e.preventDefault();

View File

@@ -0,0 +1,66 @@
'use client';
import * as React from 'react';
import { X, Keyboard } from 'lucide-react';
import { Button } from './Button';
import { Card } from './Card';
import { formatShortcut, type KeyboardShortcut } from '@/lib/hooks/useKeyboardShortcuts';
interface KeyboardShortcutsModalProps {
shortcuts: KeyboardShortcut[];
isOpen: boolean;
onClose: () => void;
}
export function KeyboardShortcutsModal({ shortcuts, isOpen, onClose }: KeyboardShortcutsModalProps) {
if (!isOpen) return null;
return (
<>
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/50 z-40 animate-fadeIn"
onClick={onClose}
/>
{/* Modal */}
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<Card className="w-full max-w-md max-h-[80vh] overflow-y-auto animate-fadeIn">
<div className="p-6">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-2">
<Keyboard className="h-5 w-5 text-primary" />
<h2 className="text-lg font-semibold">Keyboard Shortcuts</h2>
</div>
<Button variant="ghost" size="icon" onClick={onClose}>
<X className="h-4 w-4" />
</Button>
</div>
{/* Shortcuts List */}
<div className="space-y-3">
{shortcuts.map((shortcut, index) => (
<div
key={index}
className="flex items-center justify-between py-2 border-b border-border last:border-0"
>
<span className="text-sm text-foreground">{shortcut.description}</span>
<kbd className="px-2 py-1 text-xs font-semibold text-foreground bg-muted border border-border rounded">
{formatShortcut(shortcut)}
</kbd>
</div>
))}
</div>
{/* Footer */}
<div className="mt-6 text-xs text-muted-foreground text-center">
Press <kbd className="px-1.5 py-0.5 text-xs font-semibold bg-muted border border-border rounded">Esc</kbd> or{' '}
<kbd className="px-1.5 py-0.5 text-xs font-semibold bg-muted border border-border rounded">?</kbd> to close
</div>
</div>
</Card>
</div>
</>
);
}

View File

@@ -0,0 +1,65 @@
import { useEffect } from 'react';
export interface KeyboardShortcut {
key: string;
ctrl?: boolean;
alt?: boolean;
shift?: boolean;
description: string;
action: () => void;
}
/**
* Hook for managing keyboard shortcuts
*/
export function useKeyboardShortcuts(shortcuts: KeyboardShortcut[], enabled: boolean = true) {
useEffect(() => {
if (!enabled) return;
const handleKeyDown = (event: KeyboardEvent) => {
// Find matching shortcut
const shortcut = shortcuts.find((s) => {
const keyMatch = s.key.toLowerCase() === event.key.toLowerCase();
const ctrlMatch = s.ctrl ? (event.ctrlKey || event.metaKey) : !event.ctrlKey && !event.metaKey;
const altMatch = s.alt ? event.altKey : !event.altKey;
const shiftMatch = s.shift ? event.shiftKey : !event.shiftKey;
return keyMatch && ctrlMatch && altMatch && shiftMatch;
});
if (shortcut) {
event.preventDefault();
shortcut.action();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [shortcuts, enabled]);
}
/**
* Format shortcut key combination for display
*/
export function formatShortcut(shortcut: KeyboardShortcut): string {
const parts: string[] = [];
// Use Cmd on Mac, Ctrl on others
const isMac = typeof window !== 'undefined' && /Mac|iPhone|iPod|iPad/.test(navigator.platform);
if (shortcut.ctrl) {
parts.push(isMac ? '⌘' : 'Ctrl');
}
if (shortcut.alt) {
parts.push(isMac ? '⌥' : 'Alt');
}
if (shortcut.shift) {
parts.push(isMac ? '⇧' : 'Shift');
}
parts.push(shortcut.key.toUpperCase());
return parts.join(' + ');
}