From dce5aed2b75f35d8586bfba899afd79fc455fac5 Mon Sep 17 00:00:00 2001 From: Saksham Singh Rathore Date: Wed, 29 Oct 2025 23:40:43 +0530 Subject: [PATCH] feat: Add comprehensive keyboard shortcuts and VS Code-style command palette - Implement KeyboardShortcutManager for shortcut registration and handling - Add CommandRegistry singleton for centralized command management - Create CommandPalette component with fuzzy search and keyboard navigation - Add KeyboardShortcutsHelp dialog with categorized shortcuts display - Configure 40+ default shortcuts across 6 categories (Tools, Edit, Canvas, etc.) - Integrate keyboard shortcuts into Canvas component - Add comprehensive unit tests (55+ test cases) - Update documentation Features: VS Code-style command palette (Ctrl+K) Keyboard shortcuts help dialog (Ctrl+/) Platform-specific key display ( for Mac, Ctrl for Windows) Smart input field detection Recent commands tracking Fuzzy search with keyword matching Single-key tool shortcuts (P, E, R, C, L, A) Undo/Redo shortcuts (Ctrl+Z, Ctrl+Shift+Z) Closes #[45] --- KEYBOARD_SHORTCUTS.md | 392 +++++++++++++++ frontend/src/components/Canvas.js | 283 +++++++++++ frontend/src/components/CommandPalette.jsx | 402 ++++++++++++++++ .../src/components/KeyboardShortcutsHelp.jsx | 276 +++++++++++ frontend/src/config/shortcuts.js | 451 ++++++++++++++++++ frontend/src/services/CommandRegistry.js | 296 ++++++++++++ frontend/src/services/KeyboardShortcuts.js | 296 ++++++++++++ .../__tests__/CommandRegistry.test.js | 421 ++++++++++++++++ .../__tests__/KeyboardShortcuts.test.js | 441 +++++++++++++++++ frontend/src/styles/KeyboardShortcuts.css | 182 +++++++ 10 files changed, 3440 insertions(+) create mode 100644 KEYBOARD_SHORTCUTS.md create mode 100644 frontend/src/components/CommandPalette.jsx create mode 100644 frontend/src/components/KeyboardShortcutsHelp.jsx create mode 100644 frontend/src/config/shortcuts.js create mode 100644 frontend/src/services/CommandRegistry.js create mode 100644 frontend/src/services/KeyboardShortcuts.js create mode 100644 frontend/src/services/__tests__/CommandRegistry.test.js create mode 100644 frontend/src/services/__tests__/KeyboardShortcuts.test.js create mode 100644 frontend/src/styles/KeyboardShortcuts.css diff --git a/KEYBOARD_SHORTCUTS.md b/KEYBOARD_SHORTCUTS.md new file mode 100644 index 00000000..e36a44bd --- /dev/null +++ b/KEYBOARD_SHORTCUTS.md @@ -0,0 +1,392 @@ +# Keyboard Shortcuts & Command Palette + +## Overview + +ResCanvas now features a comprehensive keyboard shortcuts system and VS Code-style command palette for power users to work efficiently without mouse/toolbar interactions. + +## Features + +### ⌨️ Keyboard Shortcuts + +- **Tools**: Single-key shortcuts (P, E, R, C, L, A) for quick tool switching +- **Edit**: Ctrl+Z/Ctrl+Shift+Z for undo/redo +- **Canvas**: Ctrl+R refresh, Ctrl+Shift+K clear +- **Commands**: Ctrl+K command palette, Ctrl+/ shortcuts help + +### 🎯 Command Palette (Ctrl+K) + +VS Code-style quick access to all commands: +- Fuzzy search with keyword matching +- Keyboard navigation (↑↓ arrows, Enter to select) +- Recent commands tracking +- Category grouping +- Shortcut display for each command + +### 📖 Keyboard Shortcuts Help (Ctrl+/) + +Comprehensive shortcut reference: +- Organized by category (Tools, Edit, Canvas, Commands) +- Tab navigation between categories +- Search/filter functionality +- Platform-specific display (⌘ for Mac, Ctrl for Windows/Linux) + +## Usage + +### Opening Dialogs + +```javascript +// Command Palette +Press: Ctrl+K (Cmd+K on Mac) + +// Shortcuts Help +Press: Ctrl+/ (Cmd+/ on Mac) + +// Cancel/Close +Press: Escape +``` + +### Available Shortcuts + +#### Tools (No modifiers) +- `P` - Pen tool +- `E` - Eraser +- `R` - Rectangle +- `C` - Circle +- `L` - Line +- `A` - Arrow + +#### Edit Operations +- `Ctrl+Z` - Undo +- `Ctrl+Shift+Z` - Redo + +#### Canvas Operations +- `Ctrl+R` - Refresh canvas +- `Ctrl+Shift+K` - Clear canvas +- `Ctrl+,` - Canvas settings (if available) + +#### Commands +- `Ctrl+K` - Command palette +- `Ctrl+/` - Keyboard shortcuts help +- `Escape` - Cancel current action + +## Architecture + +### Core Services + +#### KeyboardShortcutManager (`frontend/src/services/KeyboardShortcuts.js`) + +Manages registration and execution of keyboard shortcuts: + +```javascript +import { KeyboardShortcutManager } from '../services/KeyboardShortcuts'; + +const manager = new KeyboardShortcutManager(); + +// Register a shortcut +manager.register( + 'k', // key + { ctrl: true }, // modifiers + () => openCommandPalette(), // action + 'Open Command Palette', // description + 'Commands' // category +); + +// Handle key events +document.addEventListener('keydown', (e) => manager.handleKeyDown(e)); +``` + +Features: +- Conflict detection and warnings +- Input field detection (shortcuts disabled in text inputs) +- Platform-aware display (⌘/Ctrl) +- Enable/disable shortcuts dynamically +- Category grouping + +#### CommandRegistry (`frontend/src/services/CommandRegistry.js`) + +Central registry for all executable commands: + +```javascript +import { commandRegistry } from '../services/CommandRegistry'; + +// Register a command +commandRegistry.register({ + id: 'canvas.clear', + label: 'Clear Canvas', + description: 'Remove all strokes from canvas', + keywords: ['delete', 'erase', 'reset'], + action: () => clearCanvas(), + category: 'Canvas', + shortcut: { key: 'k', modifiers: { ctrl: true, shift: true } }, + enabled: () => editingEnabled // Optional condition +}); + +// Execute command +await commandRegistry.execute('canvas.clear'); + +// Search commands +const results = commandRegistry.search('clear'); +``` + +Features: +- Command search with keyword matching +- Enabled/visible state functions +- Event listeners for execution tracking +- Category organization +- Batch registration + +### Components + +#### CommandPalette (`frontend/src/components/CommandPalette.jsx`) + +VS Code-style command search and execution: + +```jsx + setCommandPaletteOpen(false)} + commands={commandRegistry.getAll()} + onExecute={(command) => command.action()} +/> +``` + +Features: +- Fuzzy search with keyword matching +- Keyboard navigation (↑↓, Enter, Escape) +- Recent commands tracking (localStorage) +- Category headers +- Shortcut display +- Empty state messaging + +#### KeyboardShortcutsHelp (`frontend/src/components/KeyboardShortcutsHelp.jsx`) + +Comprehensive shortcuts reference: + +```jsx + setShortcutsHelpOpen(false)} + shortcuts={manager.getAllShortcuts()} +/> +``` + +Features: +- Tab navigation by category +- Search/filter shortcuts +- Platform-specific key display +- Quick tips section +- Responsive design + +### Configuration + +#### Default Shortcuts (`frontend/src/config/shortcuts.js`) + +Centralized shortcut definitions: + +```javascript +export const DEFAULT_SHORTCUTS = [ + { + id: 'tool.pen', + key: 'p', + modifiers: {}, + label: 'Select Pen Tool', + description: 'Switch to pen/brush tool for drawing', + category: 'Tools', + keywords: ['draw', 'brush', 'pencil'] + }, + // ... more shortcuts +]; +``` + +## Integration + +### Canvas Component + +The Canvas component registers all shortcuts on mount: + +```javascript +// In Canvas.js +useEffect(() => { + const manager = new KeyboardShortcutManager(); + + // Register commands + commandRegistry.register({ + id: 'edit.undo', + label: 'Undo', + action: undo, + shortcut: { key: 'z', modifiers: { ctrl: true } } + }); + + // Register keyboard shortcuts + manager.register('z', { ctrl: true }, undo, 'Undo', 'Edit'); + + // Add global listener + const handleKeyDown = (e) => manager.handleKeyDown(e); + document.addEventListener('keydown', handleKeyDown); + + return () => { + document.removeEventListener('keydown', handleKeyDown); + manager.clear(); + }; +}, [dependencies]); +``` + +### Adding New Shortcuts + +1. **Define the action function** in your component +2. **Add command to registry** in the useEffect hook: + +```javascript +commandRegistry.register({ + id: 'feature.myAction', + label: 'My Action', + description: 'Description of what it does', + keywords: ['search', 'terms'], + action: myActionFunction, + category: 'Feature', + shortcut: { key: 'm', modifiers: { ctrl: true } }, + enabled: () => someCondition // Optional +}); +``` + +3. **Register keyboard shortcut**: + +```javascript +manager.register( + 'm', + { ctrl: true }, + myActionFunction, + 'My Action', + 'Feature' +); +``` + +## Best Practices + +### 1. Use Descriptive Labels +```javascript +// Good +label: 'Clear Canvas' +description: 'Remove all strokes from canvas' + +// Bad +label: 'Clear' +description: 'Clears stuff' +``` + +### 2. Add Keywords for Searchability +```javascript +keywords: ['delete', 'erase', 'reset', 'remove'] // Good +keywords: [] // Bad +``` + +### 3. Check Conditions Before Execution +```javascript +action: () => { + if (!editingEnabled) { + showSnackbar('Action disabled in view-only mode'); + return; + } + performAction(); +} +``` + +### 4. Provide Feedback +```javascript +action: () => { + setDrawMode('pen'); + showSnackbar('Pen tool selected'); // User feedback +} +``` + +### 5. Avoid Input Field Conflicts +The KeyboardShortcutManager automatically disables shortcuts when typing in input fields. For special cases: + +```javascript +manager.register( + 'enter', + {}, + submitForm, + 'Submit Form', + 'Actions', + false // allowInInput = false (default) +); +``` + +## Testing + +### Manual Testing Checklist + +- [ ] Ctrl+K opens command palette +- [ ] Ctrl+/ opens shortcuts help +- [ ] Escape closes dialogs +- [ ] Tool shortcuts (P, E, R, C, L, A) switch tools +- [ ] Ctrl+Z / Ctrl+Shift+Z perform undo/redo +- [ ] Ctrl+R refreshes canvas +- [ ] Shortcuts disabled when typing in text fields +- [ ] Command palette search works +- [ ] Recent commands are tracked +- [ ] Arrow keys navigate command palette +- [ ] Enter executes selected command + +### Unit Tests + +```javascript +// Example test for KeyboardShortcutManager +import { KeyboardShortcutManager } from '../services/KeyboardShortcuts'; + +test('registers and executes shortcut', () => { + const manager = new KeyboardShortcutManager(); + const mockAction = jest.fn(); + + manager.register('k', { ctrl: true }, mockAction); + + const event = new KeyboardEvent('keydown', { + key: 'k', + ctrlKey: true + }); + + manager.handleKeyDown(event); + + expect(mockAction).toHaveBeenCalled(); +}); +``` + +## Troubleshooting + +### Shortcuts Not Working + +1. **Check console for conflicts**: The manager logs conflicts when registering duplicate shortcuts +2. **Verify enabledcondition**: Commands with `enabled: () => false` won't execute +3. **Check input focus**: Shortcuts are disabled in text inputs by default +4. **Inspect event listeners**: Ensure the global keydown listener is attached + +### Command Palette Empty + +1. **Verify command registration**: Check `commandRegistry.getAll()` in console +2. **Check visible conditions**: Commands with `visible: () => false` won't appear +3. **Clear browser cache**: Recent commands stored in localStorage may cause issues + +### Platform-Specific Issues + +- **Mac**: Uses `⌘` (Command) key instead of Ctrl +- **Detection**: Based on `navigator.platform.toUpperCase().indexOf('MAC')` +- **Both supported**: `event.ctrlKey || event.metaKey` handles both + +## Future Enhancements + +- [ ] User-customizable shortcut mappings +- [ ] Macro recording (repeat action sequences) +- [ ] Vim-mode keybindings +- [ ] Quick command history (Ctrl+P style) +- [ ] Contextual command suggestions +- [ ] Shortcut conflict resolution UI +- [ ] Export/import shortcut configurations +- [ ] Workspace-specific shortcuts + +## Resources + +- **VS Code Shortcuts**: Inspiration for command palette UX +- **Figma Shortcuts**: Reference for design tool workflows +- **Material-UI**: Component library used for dialogs +- **Web Keyboard API**: MDN documentation for key events diff --git a/frontend/src/components/Canvas.js b/frontend/src/components/Canvas.js index 5035b7c1..0799d8b4 100644 --- a/frontend/src/components/Canvas.js +++ b/frontend/src/components/Canvas.js @@ -17,6 +17,11 @@ import { CircularProgress, } from '@mui/material'; import SafeSnackbar from './SafeSnackbar'; +import CommandPalette from './CommandPalette'; +import KeyboardShortcutsHelp from './KeyboardShortcutsHelp'; +import { KeyboardShortcutManager } from '../services/KeyboardShortcuts'; +import { commandRegistry } from '../services/CommandRegistry'; +import { DEFAULT_SHORTCUTS } from '../config/shortcuts'; import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'; import ChevronRightIcon from '@mui/icons-material/ChevronRight'; @@ -147,6 +152,11 @@ function Canvas({ const showLocalSnack = (msg, duration = 4000) => setLocalSnack({ open: true, message: String(msg), duration }); const closeLocalSnack = () => setLocalSnack({ open: false, message: '', duration: 4000 }); + // Keyboard shortcuts state + const [commandPaletteOpen, setCommandPaletteOpen] = useState(false); + const [shortcutsHelpOpen, setShortcutsHelpOpen] = useState(false); + const shortcutManagerRef = useRef(null); + const roomUiRef = useRef({}); const previousSelectedUserRef = useRef(null); // Track previous selectedUser to detect changes const isRefreshingSelectedUserRef = useRef(false); // Prevent concurrent refreshes @@ -555,6 +565,256 @@ function Canvas({ performRefresh(currentSerialized); }, [selectedUser, currentRoomId]); + // ==================== KEYBOARD SHORTCUTS SETUP ==================== + // Register keyboard shortcuts and commands + useEffect(() => { + // Initialize shortcut manager + if (!shortcutManagerRef.current) { + shortcutManagerRef.current = new KeyboardShortcutManager(); + } + + const manager = shortcutManagerRef.current; + + // Register all commands with the command registry + const commands = [ + // Command Palette & Help + { + id: 'commands.palette', + label: 'Open Command Palette', + description: 'Quick access to all commands', + keywords: ['palette', 'search', 'find'], + category: 'Commands', + action: () => setCommandPaletteOpen(true), + shortcut: { key: 'k', modifiers: { ctrl: true } } + }, + { + id: 'commands.shortcuts', + label: 'Show Keyboard Shortcuts', + description: 'View all available keyboard shortcuts', + keywords: ['help', 'shortcuts', 'keys'], + category: 'Commands', + action: () => setShortcutsHelpOpen(true), + shortcut: { key: '/', modifiers: { ctrl: true } } + }, + { + id: 'commands.cancel', + label: 'Cancel / Escape', + description: 'Cancel current action or close dialogs', + keywords: ['cancel', 'escape', 'close'], + category: 'Commands', + action: () => { + if (commandPaletteOpen) setCommandPaletteOpen(false); + else if (shortcutsHelpOpen) setShortcutsHelpOpen(false); + else if (drawing) setDrawing(false); + }, + shortcut: { key: 'Escape', modifiers: {} } + }, + + // Edit Operations + { + id: 'edit.undo', + label: 'Undo', + description: 'Undo the last action', + keywords: ['undo', 'revert'], + category: 'Edit', + action: undo, + shortcut: { key: 'z', modifiers: { ctrl: true } }, + enabled: () => editingEnabled && undoStack.length > 0 + }, + { + id: 'edit.redo', + label: 'Redo', + description: 'Redo the last undone action', + keywords: ['redo', 'repeat'], + category: 'Edit', + action: redo, + shortcut: { key: 'z', modifiers: { ctrl: true, shift: true } }, + enabled: () => editingEnabled && redoStack.length > 0 + }, + + // Canvas Operations + { + id: 'canvas.clear', + label: 'Clear Canvas', + description: 'Remove all strokes from canvas', + keywords: ['clear', 'delete', 'reset'], + category: 'Canvas', + action: () => { + if (editingEnabled) { + setClearDialogOpen(true); + } else { + showLocalSnack('Canvas clearing is disabled in view-only mode'); + } + }, + shortcut: { key: 'k', modifiers: { ctrl: true, shift: true } }, + enabled: () => editingEnabled + }, + { + id: 'canvas.refresh', + label: 'Refresh Canvas', + description: 'Reload canvas from server', + keywords: ['refresh', 'reload'], + category: 'Canvas', + action: refreshCanvasButtonHandler, + shortcut: { key: 'r', modifiers: { ctrl: true } } + }, + { + id: 'canvas.settings', + label: 'Canvas Settings', + description: 'Open canvas settings', + keywords: ['settings', 'preferences'], + category: 'Canvas', + action: () => { + if (onOpenSettings) onOpenSettings(); + }, + shortcut: { key: ',', modifiers: { ctrl: true } }, + visible: () => !!onOpenSettings + }, + + // Tools + { + id: 'tool.pen', + label: 'Select Pen Tool', + description: 'Switch to freehand drawing', + keywords: ['pen', 'draw', 'brush'], + category: 'Tools', + action: () => { + if (editingEnabled) { + setDrawMode('freehand'); + showLocalSnack('Pen tool selected'); + } + }, + shortcut: { key: 'p', modifiers: {} }, + enabled: () => editingEnabled + }, + { + id: 'tool.eraser', + label: 'Select Eraser', + description: 'Switch to eraser mode', + keywords: ['eraser', 'erase', 'remove'], + category: 'Tools', + action: () => { + if (editingEnabled) { + setDrawMode('eraser'); + showLocalSnack('Eraser selected'); + } + }, + shortcut: { key: 'e', modifiers: {} }, + enabled: () => editingEnabled + }, + { + id: 'tool.rectangle', + label: 'Select Rectangle Tool', + description: 'Draw rectangles and squares', + keywords: ['rectangle', 'rect', 'square'], + category: 'Tools', + action: () => { + if (editingEnabled) { + setDrawMode('shape'); + setShapeType('rectangle'); + showLocalSnack('Rectangle tool selected'); + } + }, + shortcut: { key: 'r', modifiers: {} }, + enabled: () => editingEnabled + }, + { + id: 'tool.circle', + label: 'Select Circle Tool', + description: 'Draw circles and ellipses', + keywords: ['circle', 'oval', 'ellipse'], + category: 'Tools', + action: () => { + if (editingEnabled) { + setDrawMode('shape'); + setShapeType('circle'); + showLocalSnack('Circle tool selected'); + } + }, + shortcut: { key: 'c', modifiers: {} }, + enabled: () => editingEnabled + }, + { + id: 'tool.line', + label: 'Select Line Tool', + description: 'Draw straight lines', + keywords: ['line', 'straight'], + category: 'Tools', + action: () => { + if (editingEnabled) { + setDrawMode('shape'); + setShapeType('line'); + showLocalSnack('Line tool selected'); + } + }, + shortcut: { key: 'l', modifiers: {} }, + enabled: () => editingEnabled + }, + { + id: 'tool.arrow', + label: 'Select Arrow Tool', + description: 'Draw arrows', + keywords: ['arrow', 'pointer'], + category: 'Tools', + action: () => { + if (editingEnabled) { + setDrawMode('shape'); + setShapeType('arrow'); + showLocalSnack('Arrow tool selected'); + } + }, + shortcut: { key: 'a', modifiers: {} }, + enabled: () => editingEnabled + } + ]; + + // Register commands with command registry + commandRegistry.clear(); + commands.forEach(cmd => commandRegistry.register(cmd)); + + // Register keyboard shortcuts + manager.clear(); + commands.forEach(cmd => { + if (cmd.shortcut) { + manager.register( + cmd.shortcut.key, + cmd.shortcut.modifiers, + () => { + // Check if command is enabled before executing + if (cmd.enabled && !cmd.enabled()) { + return; + } + cmd.action(); + }, + cmd.label, + cmd.category + ); + } + }); + + // Add global keyboard event listener + const handleKeyDown = (event) => manager.handleKeyDown(event); + document.addEventListener('keydown', handleKeyDown); + + // Cleanup + return () => { + document.removeEventListener('keydown', handleKeyDown); + manager.clear(); + }; + }, [ + editingEnabled, + undoStack, + redoStack, + undo, + redo, + refreshCanvasButtonHandler, + onOpenSettings, + commandPaletteOpen, + shortcutsHelpOpen, + drawing + ]); + // ==================== END KEYBOARD SHORTCUTS SETUP ==================== + const initializeUserData = () => { const uniqueUserId = auth?.user?.id || `user_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`; const username = auth?.user?.username || "MainUser"; @@ -2176,6 +2436,29 @@ function Canvas({ + + {/* Command Palette - Quick command search and execution */} + setCommandPaletteOpen(false)} + commands={commandRegistry.getAll()} + onExecute={(command) => { + try { + command.action(); + } catch (error) { + console.error('[Canvas] Error executing command:', error); + showLocalSnack('Error executing command'); + } + }} + /> + + {/* Keyboard Shortcuts Help Dialog */} + setShortcutsHelpOpen(false)} + shortcuts={shortcutManagerRef.current?.getAllShortcuts() || []} + /> + ); diff --git a/frontend/src/components/CommandPalette.jsx b/frontend/src/components/CommandPalette.jsx new file mode 100644 index 00000000..1a72aed2 --- /dev/null +++ b/frontend/src/components/CommandPalette.jsx @@ -0,0 +1,402 @@ +import React, { useState, useEffect, useRef, useCallback } from 'react'; +import { + Dialog, + TextField, + List, + ListItem, + ListItemText, + Chip, + Box, + Typography, + Divider, + InputAdornment, + Paper +} from '@mui/material'; +import SearchIcon from '@mui/icons-material/Search'; +import KeyboardIcon from '@mui/icons-material/Keyboard'; +import '../styles/KeyboardShortcuts.css'; + +/** + * Command Palette Component + * + * VS Code-style command palette for quick action discovery and execution. + * Features: + * - Fuzzy search with keyword matching + * - Keyboard navigation (arrow keys, Enter, Escape) + * - Command categorization + * - Shortcut display + * - Recent commands tracking + * + * @param {boolean} open - Whether the palette is open + * @param {function} onClose - Callback when palette closes + * @param {Array} commands - Array of command objects from CommandRegistry + * @param {function} onExecute - Callback when command is executed + */ +export function CommandPalette({ open, onClose, commands = [], onExecute }) { + const [search, setSearch] = useState(''); + const [filteredCommands, setFilteredCommands] = useState([]); + const [selectedIndex, setSelectedIndex] = useState(0); + const [recentCommands, setRecentCommands] = useState([]); + const listRef = useRef(null); + const inputRef = useRef(null); + + // Load recent commands from localStorage + useEffect(() => { + if (open) { + try { + const stored = localStorage.getItem('rescanvas_recent_commands'); + if (stored) { + setRecentCommands(JSON.parse(stored).slice(0, 5)); + } + } catch (error) { + console.error('[CommandPalette] Error loading recent commands:', error); + } + } + }, [open]); + + // Filter commands based on search + useEffect(() => { + if (!commands || commands.length === 0) { + setFilteredCommands([]); + return; + } + + if (!search || search.trim() === '') { + // Show recent commands first, then all commands + const recentCommandObjs = recentCommands + .map(id => commands.find(cmd => cmd.id === id)) + .filter(Boolean); + + const otherCommands = commands.filter(cmd => + !recentCommands.includes(cmd.id) + ); + + setFilteredCommands([...recentCommandObjs, ...otherCommands]); + setSelectedIndex(0); + return; + } + + const normalizedSearch = search.toLowerCase().trim(); + const searchWords = normalizedSearch.split(/\s+/); + + const filtered = commands + .filter(cmd => { + const searchText = [ + cmd.label, + cmd.description, + cmd.category, + ...(cmd.keywords || []) + ].join(' ').toLowerCase(); + + // Match all search words + return searchWords.every(word => searchText.includes(word)); + }) + .sort((a, b) => { + // Prioritize exact label matches + const aLabelMatch = a.label.toLowerCase().includes(normalizedSearch); + const bLabelMatch = b.label.toLowerCase().includes(normalizedSearch); + + if (aLabelMatch && !bLabelMatch) return -1; + if (!aLabelMatch && bLabelMatch) return 1; + + // Then by category match + const aCategoryMatch = a.category.toLowerCase().includes(normalizedSearch); + const bCategoryMatch = b.category.toLowerCase().includes(normalizedSearch); + + if (aCategoryMatch && !bCategoryMatch) return -1; + if (!aCategoryMatch && bCategoryMatch) return 1; + + // Finally alphabetically + return a.label.localeCompare(b.label); + }); + + setFilteredCommands(filtered); + setSelectedIndex(0); + }, [search, commands, recentCommands]); + + // Reset state when opening + useEffect(() => { + if (open) { + setSearch(''); + setSelectedIndex(0); + // Focus input after a short delay to ensure dialog is mounted + setTimeout(() => { + if (inputRef.current) { + inputRef.current.focus(); + } + }, 50); + } + }, [open]); + + // Scroll selected item into view + useEffect(() => { + if (listRef.current && filteredCommands.length > 0) { + const selectedElement = listRef.current.children[selectedIndex]; + if (selectedElement) { + selectedElement.scrollIntoView({ + block: 'nearest', + behavior: 'smooth' + }); + } + } + }, [selectedIndex, filteredCommands]); + + // Execute command + const executeCommand = useCallback((command) => { + if (!command) return; + + // Add to recent commands + const newRecent = [ + command.id, + ...recentCommands.filter(id => id !== command.id) + ].slice(0, 5); + + setRecentCommands(newRecent); + try { + localStorage.setItem('rescanvas_recent_commands', JSON.stringify(newRecent)); + } catch (error) { + console.error('[CommandPalette] Error saving recent commands:', error); + } + + // Execute command + if (onExecute) { + onExecute(command); + } else if (command.action) { + try { + command.action(); + } catch (error) { + console.error('[CommandPalette] Error executing command:', error); + } + } + + // Close palette + onClose(); + }, [onExecute, onClose, recentCommands]); + + // Handle keyboard navigation + const handleKeyDown = useCallback((event) => { + switch (event.key) { + case 'ArrowDown': + event.preventDefault(); + setSelectedIndex(prev => + prev < filteredCommands.length - 1 ? prev + 1 : prev + ); + break; + + case 'ArrowUp': + event.preventDefault(); + setSelectedIndex(prev => prev > 0 ? prev - 1 : prev); + break; + + case 'Enter': + event.preventDefault(); + if (filteredCommands[selectedIndex]) { + executeCommand(filteredCommands[selectedIndex]); + } + break; + + case 'Escape': + event.preventDefault(); + onClose(); + break; + + default: + break; + } + }, [filteredCommands, selectedIndex, executeCommand, onClose]); + + // Format shortcut for display + const formatShortcut = (command) => { + if (!command.shortcut) return null; + + const { key, modifiers } = command.shortcut; + const parts = []; + const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0; + + if (modifiers.ctrl) parts.push(isMac ? '⌘' : 'Ctrl'); + if (modifiers.shift) parts.push(isMac ? '⇧' : 'Shift'); + if (modifiers.alt) parts.push(isMac ? '⌥' : 'Alt'); + + const displayKey = key.length === 1 ? key.toUpperCase() : key; + parts.push(displayKey); + + return parts.join(' + '); + }; + + // Group commands by category for display + const groupedCommands = filteredCommands.reduce((acc, cmd, idx) => { + const prevCmd = idx > 0 ? filteredCommands[idx - 1] : null; + const showCategoryHeader = !prevCmd || prevCmd.category !== cmd.category; + + acc.push({ command: cmd, showCategoryHeader, index: idx }); + return acc; + }, []); + + return ( + { + if (inputRef.current) { + inputRef.current.focus(); + } + } + }} + > + + setSearch(e.target.value)} + onKeyDown={handleKeyDown} + variant="outlined" + size="medium" + InputProps={{ + startAdornment: ( + + + + ), + sx: { + '& .MuiOutlinedInput-notchedOutline': { + border: 'none', + borderBottom: '1px solid', + borderColor: 'divider', + borderRadius: 0 + }, + '&:hover .MuiOutlinedInput-notchedOutline': { + borderColor: 'primary.main' + }, + '&.Mui-focused .MuiOutlinedInput-notchedOutline': { + borderColor: 'primary.main', + borderWidth: '2px' + } + } + }} + sx={{ mb: 1 }} + /> + + {filteredCommands.length > 0 && ( + + {filteredCommands.length} command{filteredCommands.length !== 1 ? 's' : ''} found + + )} + + + + {groupedCommands.length === 0 ? ( + + + + No commands found + + + Try a different search term + + + ) : ( + groupedCommands.map(({ command, showCategoryHeader, index }) => ( + + {showCategoryHeader && ( + <> + {index > 0 && } + + + {command.category} + + + + )} + + executeCommand(command)} + sx={{ + py: 1.5, + px: 2, + cursor: 'pointer', + '&.Mui-selected': { + bgcolor: 'primary.light', + '&:hover': { + bgcolor: 'primary.light' + } + }, + '&:hover': { + bgcolor: 'action.hover' + } + }} + > + + {command.shortcut && ( + + )} + + + )) + )} + + + + + ↑↓ Navigate + Enter Select + Esc Close + + + + ); +} + +export default CommandPalette; diff --git a/frontend/src/components/KeyboardShortcutsHelp.jsx b/frontend/src/components/KeyboardShortcutsHelp.jsx new file mode 100644 index 00000000..80bc79ce --- /dev/null +++ b/frontend/src/components/KeyboardShortcutsHelp.jsx @@ -0,0 +1,276 @@ +import React, { useMemo } from 'react'; +import { + Dialog, + DialogTitle, + DialogContent, + IconButton, + Box, + Typography, + Table, + TableBody, + TableRow, + TableCell, + Chip, + Paper, + Tabs, + Tab, + InputAdornment, + TextField +} from '@mui/material'; +import CloseIcon from '@mui/icons-material/Close'; +import SearchIcon from '@mui/icons-material/Search'; +import KeyboardIcon from '@mui/icons-material/Keyboard'; +import '../styles/KeyboardShortcuts.css'; + +/** + * Keyboard Shortcuts Help Dialog + * + * Displays all available keyboard shortcuts organized by category. + * Features: + * - Categorized display + * - Tab navigation between categories + * - Search/filter shortcuts + * - Platform-specific key display (Cmd vs Ctrl) + * + * @param {boolean} open - Whether the dialog is open + * @param {function} onClose - Callback when dialog closes + * @param {Array} shortcuts - Array of shortcut objects from KeyboardShortcutManager + */ +export function KeyboardShortcutsHelp({ open, onClose, shortcuts = [] }) { + const [selectedTab, setSelectedTab] = React.useState(0); + const [searchQuery, setSearchQuery] = React.useState(''); + + // Group shortcuts by category + const groupedShortcuts = useMemo(() => { + const filtered = searchQuery + ? shortcuts.filter(shortcut => + shortcut.label?.toLowerCase().includes(searchQuery.toLowerCase()) || + shortcut.description?.toLowerCase().includes(searchQuery.toLowerCase()) || + shortcut.category?.toLowerCase().includes(searchQuery.toLowerCase()) + ) + : shortcuts; + + return filtered.reduce((acc, shortcut) => { + const category = shortcut.category || 'General'; + if (!acc[category]) { + acc[category] = []; + } + acc[category].push(shortcut); + return acc; + }, {}); + }, [shortcuts, searchQuery]); + + const categories = Object.keys(groupedShortcuts).sort(); + const currentCategory = categories[selectedTab] || categories[0]; + + // Format shortcut for display + const formatShortcut = (shortcut) => { + const parts = []; + const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0; + + if (shortcut.modifiers?.ctrl) parts.push(isMac ? '⌘' : 'Ctrl'); + if (shortcut.modifiers?.shift) parts.push(isMac ? '⇧' : 'Shift'); + if (shortcut.modifiers?.alt) parts.push(isMac ? '⌥' : 'Alt'); + + const displayKey = shortcut.key?.length === 1 + ? shortcut.key.toUpperCase() + : shortcut.key; + parts.push(displayKey); + + return parts.join(' + '); + }; + + // Reset state when dialog closes + React.useEffect(() => { + if (!open) { + setSearchQuery(''); + setSelectedTab(0); + } + }, [open]); + + return ( + + + + + Keyboard Shortcuts + + + + + + + + setSearchQuery(e.target.value)} + InputProps={{ + startAdornment: ( + + + + ) + }} + sx={{ maxWidth: 400 }} + /> + + + {categories.length === 0 ? ( + + + + + No shortcuts found + + {searchQuery && ( + + Try a different search term + + )} + + + ) : ( + <> + setSelectedTab(newValue)} + variant="scrollable" + scrollButtons="auto" + sx={{ + borderBottom: 1, + borderColor: 'divider', + px: 2 + }} + > + {categories.map((category, index) => ( + + ))} + + + + {categories.map((category, index) => ( + + ))} + + {/* Quick Tips Section */} + + + 💡 Quick Tips + + + • Press Ctrl/Cmd + K to open the command palette for quick access + + + • Use Escape to cancel any ongoing action or close dialogs + + + • Shortcuts work globally except when typing in text fields + + + • Tool shortcuts (P, E, T, etc.) are single keys without modifiers + + + + {/* Platform Notice */} + + + {navigator.platform.toUpperCase().indexOf('MAC') >= 0 ? ( + <>Displaying Mac keyboard shortcuts (⌘ = Command, ⌥ = Option, ⇧ = Shift) + ) : ( + <>Displaying Windows/Linux keyboard shortcuts + )} + + + + + )} + + ); +} + +export default KeyboardShortcutsHelp; diff --git a/frontend/src/config/shortcuts.js b/frontend/src/config/shortcuts.js new file mode 100644 index 00000000..add519fb --- /dev/null +++ b/frontend/src/config/shortcuts.js @@ -0,0 +1,451 @@ +/** + * Default Keyboard Shortcuts Configuration for ResCanvas + * + * Defines all keyboard shortcuts for the application. + * These will be registered with the KeyboardShortcutManager on app initialization. + * + * Categories: + * - Tools: Drawing tool selection + * - Edit: Clipboard and history operations + * - View: Zoom, pan, and display controls + * - Canvas: Canvas-specific operations + * - Commands: Meta commands (palette, help, settings) + * - Selection: Selection manipulation + */ + +export const DEFAULT_SHORTCUTS = [ + // ==================== TOOLS ==================== + { + id: 'tool.pen', + key: 'p', + modifiers: {}, + label: 'Select Pen Tool', + description: 'Switch to pen/brush tool for drawing', + category: 'Tools', + keywords: ['draw', 'brush', 'pencil'] + }, + { + id: 'tool.eraser', + key: 'e', + modifiers: {}, + label: 'Select Eraser', + description: 'Switch to eraser tool', + category: 'Tools', + keywords: ['erase', 'delete', 'remove'] + }, + { + id: 'tool.text', + key: 't', + modifiers: {}, + label: 'Select Text Tool', + description: 'Switch to text tool for adding labels', + category: 'Tools', + keywords: ['text', 'label', 'type'] + }, + { + id: 'tool.rectangle', + key: 'r', + modifiers: {}, + label: 'Select Rectangle', + description: 'Draw rectangles and squares', + category: 'Tools', + keywords: ['rect', 'square', 'box'] + }, + { + id: 'tool.circle', + key: 'c', + modifiers: {}, + label: 'Select Circle', + description: 'Draw circles and ellipses', + category: 'Tools', + keywords: ['circle', 'ellipse', 'oval'] + }, + { + id: 'tool.line', + key: 'l', + modifiers: {}, + label: 'Select Line', + description: 'Draw straight lines', + category: 'Tools', + keywords: ['line', 'straight'] + }, + { + id: 'tool.arrow', + key: 'a', + modifiers: {}, + label: 'Select Arrow', + description: 'Draw arrows and connectors', + category: 'Tools', + keywords: ['arrow', 'pointer', 'connector'] + }, + { + id: 'tool.fill', + key: 'f', + modifiers: {}, + label: 'Select Fill Bucket', + description: 'Fill areas with color', + category: 'Tools', + keywords: ['fill', 'bucket', 'paint'] + }, + { + id: 'tool.selection', + key: 'v', + modifiers: {}, + label: 'Select Selection Tool', + description: 'Select and manipulate objects', + category: 'Tools', + keywords: ['select', 'move', 'transform'] + }, + { + id: 'tool.hand', + key: 'h', + modifiers: {}, + label: 'Select Hand/Pan Tool', + description: 'Pan around the canvas', + category: 'Tools', + keywords: ['hand', 'pan', 'move'] + }, + + // ==================== EDIT ==================== + { + id: 'edit.undo', + key: 'z', + modifiers: { ctrl: true }, + label: 'Undo', + description: 'Undo the last action', + category: 'Edit', + keywords: ['undo', 'revert', 'back'] + }, + { + id: 'edit.redo', + key: 'z', + modifiers: { ctrl: true, shift: true }, + label: 'Redo', + description: 'Redo the last undone action', + category: 'Edit', + keywords: ['redo', 'forward', 'repeat'] + }, + { + id: 'edit.copy', + key: 'c', + modifiers: { ctrl: true }, + label: 'Copy', + description: 'Copy selected elements to clipboard', + category: 'Edit', + keywords: ['copy', 'duplicate', 'clipboard'] + }, + { + id: 'edit.cut', + key: 'x', + modifiers: { ctrl: true }, + label: 'Cut', + description: 'Cut selected elements to clipboard', + category: 'Edit', + keywords: ['cut', 'remove', 'clipboard'] + }, + { + id: 'edit.paste', + key: 'v', + modifiers: { ctrl: true }, + label: 'Paste', + description: 'Paste from clipboard', + category: 'Edit', + keywords: ['paste', 'insert', 'clipboard'] + }, + { + id: 'edit.duplicate', + key: 'd', + modifiers: { ctrl: true }, + label: 'Duplicate', + description: 'Duplicate selected elements', + category: 'Edit', + keywords: ['duplicate', 'copy', 'clone'] + }, + { + id: 'edit.selectAll', + key: 'a', + modifiers: { ctrl: true }, + label: 'Select All', + description: 'Select all elements on canvas', + category: 'Edit', + keywords: ['select', 'all'] + }, + { + id: 'edit.delete', + key: 'Delete', + modifiers: {}, + label: 'Delete', + description: 'Delete selected elements', + category: 'Edit', + keywords: ['delete', 'remove', 'erase'] + }, + { + id: 'edit.delete.alt', + key: 'Backspace', + modifiers: {}, + label: 'Delete (Backspace)', + description: 'Delete selected elements', + category: 'Edit', + keywords: ['delete', 'remove', 'erase'] + }, + + // ==================== VIEW ==================== + { + id: 'view.zoomIn', + key: '=', + modifiers: { ctrl: true }, + label: 'Zoom In', + description: 'Zoom in on the canvas', + category: 'View', + keywords: ['zoom', 'in', 'magnify', 'larger'] + }, + { + id: 'view.zoomIn.alt', + key: '+', + modifiers: { ctrl: true }, + label: 'Zoom In (+)', + description: 'Zoom in on the canvas', + category: 'View', + keywords: ['zoom', 'in', 'magnify', 'larger'] + }, + { + id: 'view.zoomOut', + key: '-', + modifiers: { ctrl: true }, + label: 'Zoom Out', + description: 'Zoom out on the canvas', + category: 'View', + keywords: ['zoom', 'out', 'smaller'] + }, + { + id: 'view.zoomReset', + key: '0', + modifiers: { ctrl: true }, + label: 'Reset Zoom', + description: 'Reset zoom to 100%', + category: 'View', + keywords: ['zoom', 'reset', '100%', 'actual'] + }, + { + id: 'view.fitToScreen', + key: '1', + modifiers: { ctrl: true }, + label: 'Fit to Screen', + description: 'Fit entire canvas to screen', + category: 'View', + keywords: ['fit', 'screen', 'zoom', 'all'] + }, + { + id: 'view.fullscreen', + key: 'f', + modifiers: { ctrl: true, shift: true }, + label: 'Toggle Fullscreen', + description: 'Enter or exit fullscreen mode', + category: 'View', + keywords: ['fullscreen', 'maximize', 'full'] + }, + { + id: 'view.toggleGrid', + key: 'g', + modifiers: { ctrl: true }, + label: 'Toggle Grid', + description: 'Show or hide the grid', + category: 'View', + keywords: ['grid', 'guides', 'toggle'] + }, + { + id: 'view.toggleRulers', + key: 'r', + modifiers: { ctrl: true }, + label: 'Toggle Rulers', + description: 'Show or hide rulers', + category: 'View', + keywords: ['rulers', 'guides', 'toggle'] + }, + + // ==================== CANVAS ==================== + { + id: 'canvas.clear', + key: 'k', + modifiers: { ctrl: true, shift: true }, + label: 'Clear Canvas', + description: 'Remove all strokes from canvas', + category: 'Canvas', + keywords: ['clear', 'delete', 'reset', 'erase'] + }, + { + id: 'canvas.export', + key: 's', + modifiers: { ctrl: true, shift: true }, + label: 'Export Canvas', + description: 'Export canvas as image', + category: 'Canvas', + keywords: ['export', 'save', 'download', 'image'] + }, + { + id: 'canvas.share', + key: 'i', + modifiers: { ctrl: true, shift: true }, + label: 'Share Canvas', + description: 'Share canvas with collaborators', + category: 'Canvas', + keywords: ['share', 'invite', 'collaborate'] + }, + { + id: 'canvas.settings', + key: ',', + modifiers: { ctrl: true }, + label: 'Canvas Settings', + description: 'Open canvas settings', + category: 'Canvas', + keywords: ['settings', 'preferences', 'configure'] + }, + + // ==================== SELECTION ==================== + { + id: 'selection.bringForward', + key: ']', + modifiers: { ctrl: true }, + label: 'Bring Forward', + description: 'Move selection one layer forward', + category: 'Selection', + keywords: ['layer', 'forward', 'up', 'order'] + }, + { + id: 'selection.sendBackward', + key: '[', + modifiers: { ctrl: true }, + label: 'Send Backward', + description: 'Move selection one layer backward', + category: 'Selection', + keywords: ['layer', 'backward', 'down', 'order'] + }, + { + id: 'selection.bringToFront', + key: ']', + modifiers: { ctrl: true, shift: true }, + label: 'Bring to Front', + description: 'Move selection to top layer', + category: 'Selection', + keywords: ['layer', 'front', 'top', 'order'] + }, + { + id: 'selection.sendToBack', + key: '[', + modifiers: { ctrl: true, shift: true }, + label: 'Send to Back', + description: 'Move selection to bottom layer', + category: 'Selection', + keywords: ['layer', 'back', 'bottom', 'order'] + }, + { + id: 'selection.group', + key: 'g', + modifiers: { ctrl: true }, + label: 'Group Selection', + description: 'Group selected elements', + category: 'Selection', + keywords: ['group', 'combine'] + }, + { + id: 'selection.ungroup', + key: 'g', + modifiers: { ctrl: true, shift: true }, + label: 'Ungroup Selection', + description: 'Ungroup selected elements', + category: 'Selection', + keywords: ['ungroup', 'separate'] + }, + + // ==================== COMMANDS ==================== + { + id: 'commands.palette', + key: 'k', + modifiers: { ctrl: true }, + label: 'Command Palette', + description: 'Open command palette', + category: 'Commands', + keywords: ['palette', 'search', 'commands', 'actions'] + }, + { + id: 'commands.shortcuts', + key: '/', + modifiers: { ctrl: true }, + label: 'Keyboard Shortcuts', + description: 'Show keyboard shortcuts help', + category: 'Commands', + keywords: ['shortcuts', 'help', 'keyboard', 'keys'] + }, + { + id: 'commands.shortcuts.alt', + key: '?', + modifiers: { ctrl: true }, + label: 'Keyboard Shortcuts (?)', + description: 'Show keyboard shortcuts help', + category: 'Commands', + keywords: ['shortcuts', 'help', 'keyboard', 'keys'] + }, + { + id: 'commands.cancel', + key: 'Escape', + modifiers: {}, + label: 'Cancel', + description: 'Cancel current action or close dialog', + category: 'Commands', + keywords: ['cancel', 'escape', 'close', 'exit'] + } +]; + +/** + * Get shortcuts grouped by category + */ +export function getShortcutsByCategory() { + const grouped = {}; + + DEFAULT_SHORTCUTS.forEach(shortcut => { + if (!grouped[shortcut.category]) { + grouped[shortcut.category] = []; + } + grouped[shortcut.category].push(shortcut); + }); + + return grouped; +} + +/** + * Get shortcut by ID + */ +export function getShortcutById(id) { + return DEFAULT_SHORTCUTS.find(s => s.id === id); +} + +/** + * Check if key combination is registered + */ +export function hasShortcut(key, modifiers) { + return DEFAULT_SHORTCUTS.some(s => + s.key === key && + s.modifiers.ctrl === modifiers.ctrl && + s.modifiers.shift === modifiers.shift && + s.modifiers.alt === modifiers.alt + ); +} + +/** + * Format shortcut for display + */ +export function formatShortcutDisplay(shortcut) { + const parts = []; + const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0; + + if (shortcut.modifiers.ctrl) parts.push(isMac ? '⌘' : 'Ctrl'); + if (shortcut.modifiers.shift) parts.push(isMac ? '⇧' : 'Shift'); + if (shortcut.modifiers.alt) parts.push(isMac ? '⌥' : 'Alt'); + + const displayKey = shortcut.key.length === 1 + ? shortcut.key.toUpperCase() + : shortcut.key; + parts.push(displayKey); + + return parts.join(' + '); +} diff --git a/frontend/src/services/CommandRegistry.js b/frontend/src/services/CommandRegistry.js new file mode 100644 index 00000000..4328a61a --- /dev/null +++ b/frontend/src/services/CommandRegistry.js @@ -0,0 +1,296 @@ +/** + * Command Registry for ResCanvas + * + * Central registry for all executable commands in the application. + * Used by the Command Palette to discover and execute actions. + * + * Usage: + * import { commandRegistry } from './CommandRegistry'; + * + * commandRegistry.register({ + * id: 'canvas.clear', + * label: 'Clear Canvas', + * description: 'Remove all strokes', + * keywords: ['delete', 'erase', 'reset'], + * action: () => clearCanvas(), + * category: 'Canvas', + * shortcut: { key: 'k', modifiers: { ctrl: true, shift: true } } + * }); + */ + +export class CommandRegistry { + constructor() { + this.commands = new Map(); + this.listeners = []; + } + + /** + * Register a command + * @param {Object} command - Command configuration + * @param {string} command.id - Unique identifier (e.g., 'canvas.clear') + * @param {string} command.label - Display label + * @param {string} command.description - Longer description + * @param {Array} command.keywords - Search keywords + * @param {Function} command.action - Action to execute + * @param {string} command.category - Category for grouping + * @param {Object} command.shortcut - Keyboard shortcut { key, modifiers } + * @param {string} command.icon - Icon name/component + * @param {Function} command.enabled - Function returning boolean for enabled state + * @param {Function} command.visible - Function returning boolean for visibility + */ + register(command) { + if (!command.id) { + console.error('[CommandRegistry] Command must have an id:', command); + return false; + } + + if (!command.action || typeof command.action !== 'function') { + console.error('[CommandRegistry] Command must have an action function:', command); + return false; + } + + if (this.commands.has(command.id)) { + console.warn(`[CommandRegistry] Command "${command.id}" already registered. Overwriting.`); + } + + const fullCommand = { + id: command.id, + label: command.label || command.id, + description: command.description || '', + keywords: command.keywords || [], + action: command.action, + category: command.category || 'General', + shortcut: command.shortcut || null, + icon: command.icon || null, + enabled: command.enabled || (() => true), + visible: command.visible || (() => true), + registeredAt: Date.now() + }; + + this.commands.set(command.id, fullCommand); + this.notifyListeners('register', fullCommand); + + return true; + } + + /** + * Unregister a command + */ + unregister(commandId) { + const command = this.commands.get(commandId); + const deleted = this.commands.delete(commandId); + + if (deleted) { + this.notifyListeners('unregister', command); + } + + return deleted; + } + + /** + * Execute a command by ID + */ + async execute(commandId, ...args) { + const command = this.commands.get(commandId); + + if (!command) { + console.warn(`[CommandRegistry] Command "${commandId}" not found`); + return { success: false, error: 'Command not found' }; + } + + if (typeof command.enabled === 'function' && !command.enabled()) { + console.warn(`[CommandRegistry] Command "${commandId}" is disabled`); + return { success: false, error: 'Command is disabled' }; + } + + try { + this.notifyListeners('before-execute', command); + const result = await command.action(...args); + this.notifyListeners('after-execute', command, result); + + return { success: true, result }; + } catch (error) { + console.error(`[CommandRegistry] Error executing command "${commandId}":`, error); + this.notifyListeners('error', command, error); + + return { success: false, error: error.message }; + } + } + + /** + * Get a command by ID + */ + get(commandId) { + return this.commands.get(commandId); + } + + /** + * Check if command exists + */ + has(commandId) { + return this.commands.has(commandId); + } + + /** + * Get all registered commands + */ + getAll() { + return Array.from(this.commands.values()).filter(cmd => { + try { + return typeof cmd.visible === 'function' ? cmd.visible() : true; + } catch (error) { + console.error(`[CommandRegistry] Error checking visibility for "${cmd.id}":`, error); + return true; + } + }); + } + + /** + * Get commands by category + */ + getByCategory(category) { + return this.getAll().filter(cmd => cmd.category === category); + } + + /** + * Get all categories + */ + getCategories() { + const categories = new Set(); + this.commands.forEach(cmd => categories.add(cmd.category)); + return Array.from(categories).sort(); + } + + /** + * Search commands by query + * Searches in label, description, keywords, and category + */ + search(query) { + if (!query || query.trim() === '') { + return this.getAll(); + } + + const normalizedQuery = query.toLowerCase().trim(); + const words = normalizedQuery.split(/\s+/); + + return this.getAll().filter(cmd => { + const searchText = [ + cmd.label, + cmd.description, + cmd.category, + ...cmd.keywords + ].join(' ').toLowerCase(); + + // Match all words in the query + return words.every(word => searchText.includes(word)); + }).sort((a, b) => { + // Prioritize exact label matches + const aLabelMatch = a.label.toLowerCase().includes(normalizedQuery); + const bLabelMatch = b.label.toLowerCase().includes(normalizedQuery); + + if (aLabelMatch && !bLabelMatch) return -1; + if (!aLabelMatch && bLabelMatch) return 1; + + // Then by category match + const aCategoryMatch = a.category.toLowerCase().includes(normalizedQuery); + const bCategoryMatch = b.category.toLowerCase().includes(normalizedQuery); + + if (aCategoryMatch && !bCategoryMatch) return -1; + if (!aCategoryMatch && bCategoryMatch) return 1; + + // Finally alphabetically + return a.label.localeCompare(b.label); + }); + } + + /** + * Get commands with keyboard shortcuts + */ + getCommandsWithShortcuts() { + return this.getAll().filter(cmd => cmd.shortcut !== null); + } + + /** + * Clear all commands + */ + clear() { + const commandIds = Array.from(this.commands.keys()); + this.commands.clear(); + this.notifyListeners('clear', commandIds); + } + + /** + * Register a listener for registry events + * Events: 'register', 'unregister', 'before-execute', 'after-execute', 'error', 'clear' + */ + addListener(callback) { + this.listeners.push(callback); + return () => { + this.listeners = this.listeners.filter(l => l !== callback); + }; + } + + /** + * Notify all listeners of an event + */ + notifyListeners(event, ...args) { + this.listeners.forEach(listener => { + try { + listener(event, ...args); + } catch (error) { + console.error('[CommandRegistry] Error in listener:', error); + } + }); + } + + /** + * Get registry statistics + */ + getStats() { + const all = this.getAll(); + return { + total: all.length, + byCategory: this.getCategories().reduce((acc, cat) => { + acc[cat] = this.getByCategory(cat).length; + return acc; + }, {}), + withShortcuts: this.getCommandsWithShortcuts().length, + enabled: all.filter(cmd => cmd.enabled()).length, + visible: all.length // Already filtered by getAll() + }; + } + + /** + * Batch register multiple commands + */ + registerBatch(commands) { + const results = commands.map(cmd => ({ + id: cmd.id, + success: this.register(cmd) + })); + + return results; + } + + /** + * Export commands as JSON (for debugging/persistence) + */ + export() { + return Array.from(this.commands.entries()).map(([id, cmd]) => ({ + id, + label: cmd.label, + description: cmd.description, + keywords: cmd.keywords, + category: cmd.category, + shortcut: cmd.shortcut, + icon: cmd.icon, + registeredAt: cmd.registeredAt + })); + } +} + +// Export singleton instance +export const commandRegistry = new CommandRegistry(); + +// Export for testing/advanced use cases +export default CommandRegistry; diff --git a/frontend/src/services/KeyboardShortcuts.js b/frontend/src/services/KeyboardShortcuts.js new file mode 100644 index 00000000..95761f30 --- /dev/null +++ b/frontend/src/services/KeyboardShortcuts.js @@ -0,0 +1,296 @@ +/** + * Keyboard Shortcut Manager for ResCanvas + * + * Handles registration, execution, and conflict detection of keyboard shortcuts. + * Supports modifier keys (Ctrl/Cmd, Shift, Alt) and prevents conflicts with + * input elements. + * + * Usage: + * const manager = new KeyboardShortcutManager(); + * manager.register('k', { ctrl: true }, () => openCommandPalette(), 'Open Command Palette'); + * manager.enable(); + */ + +export class KeyboardShortcutManager { + constructor() { + this.shortcuts = new Map(); + this.enabled = true; + this.activeModifiers = { ctrl: false, shift: false, alt: false }; + this.conflictWarnings = []; + } + + /** + * Register a keyboard shortcut + * @param {string} key - The key to bind (e.g., 'k', 'Enter', 'Escape') + * @param {Object} modifiers - Modifier keys { ctrl, shift, alt } + * @param {Function} action - Callback function to execute + * @param {string} description - Human-readable description + * @param {string} category - Category for grouping (e.g., 'Tools', 'Edit') + * @param {boolean} allowInInput - Allow execution even in input fields + */ + register(key, modifiers = {}, action, description, category = 'General', allowInInput = false) { + const shortcutKey = this.getShortcutKey(key, modifiers); + + // Warn about conflicts + if (this.shortcuts.has(shortcutKey)) { + const existing = this.shortcuts.get(shortcutKey); + console.warn(`[KeyboardShortcuts] Conflict detected: ${shortcutKey} already bound to "${existing.description}". Overwriting with "${description}".`); + this.conflictWarnings.push({ + key: shortcutKey, + existing: existing.description, + new: description + }); + } + + this.shortcuts.set(shortcutKey, { + key, + modifiers, + action, + description, + category, + allowInInput, + enabled: true, + registeredAt: Date.now() + }); + + return shortcutKey; + } + + /** + * Unregister a keyboard shortcut + */ + unregister(key, modifiers = {}) { + const shortcutKey = this.getShortcutKey(key, modifiers); + return this.shortcuts.delete(shortcutKey); + } + + /** + * Handle keydown events + */ + handleKeyDown(event) { + if (!this.enabled) return; + + // Determine modifiers + const modifiers = { + ctrl: event.ctrlKey || event.metaKey, // Support both Ctrl (Windows/Linux) and Cmd (Mac) + shift: event.shiftKey, + alt: event.altKey + }; + + const shortcutKey = this.getShortcutKey(event.key, modifiers); + const shortcut = this.shortcuts.get(shortcutKey); + + if (shortcut && shortcut.enabled) { + // Check if we should ignore (in input field) + if (!shortcut.allowInInput && this.isInputElement(event.target)) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + try { + shortcut.action(event); + } catch (error) { + console.error(`[KeyboardShortcuts] Error executing shortcut "${shortcutKey}":`, error); + } + } + } + + /** + * Generate a unique key for the shortcut map + */ + getShortcutKey(key, modifiers) { + const parts = []; + if (modifiers.ctrl) parts.push('Ctrl'); + if (modifiers.shift) parts.push('Shift'); + if (modifiers.alt) parts.push('Alt'); + + // Normalize key name + const normalizedKey = this.normalizeKey(key); + parts.push(normalizedKey); + + return parts.join('+'); + } + + /** + * Normalize key names for consistency + */ + normalizeKey(key) { + // Special keys that need normalization + const keyMap = { + ' ': 'Space', + '+': 'Plus', + '=': 'Equal', + '-': 'Minus', + '_': 'Underscore', + '[': 'BracketLeft', + ']': 'BracketRight', + '{': 'BraceLeft', + '}': 'BraceRight', + '/': 'Slash', + '\\': 'Backslash', + ',': 'Comma', + '.': 'Period', + '<': 'Less', + '>': 'Greater', + '?': 'Question' + }; + + // Return mapped key or lowercase original + return keyMap[key] || key.toLowerCase(); + } + + /** + * Check if element is an input field where shortcuts should be disabled + */ + isInputElement(element) { + if (!element) return false; + + const tagName = element.tagName.toLowerCase(); + const inputTypes = ['text', 'password', 'email', 'search', 'tel', 'url', 'number']; + + // Check for input/textarea + if (tagName === 'textarea') return true; + if (tagName === 'input' && inputTypes.includes(element.type?.toLowerCase())) return true; + + // Check for contenteditable + if (element.isContentEditable) return true; + + // Check for Material-UI input wrappers + if (element.closest('.MuiInputBase-root') || + element.closest('[contenteditable="true"]')) { + return true; + } + + return false; + } + + /** + * Disable all shortcuts + */ + disable() { + this.enabled = false; + } + + /** + * Enable all shortcuts + */ + enable() { + this.enabled = true; + } + + /** + * Toggle enabled state + */ + toggle() { + this.enabled = !this.enabled; + return this.enabled; + } + + /** + * Get all registered shortcuts + */ + getAllShortcuts() { + return Array.from(this.shortcuts.entries()).map(([key, shortcut]) => ({ + shortcutKey: key, + ...shortcut + })); + } + + /** + * Get shortcuts grouped by category + */ + getShortcutsByCategory() { + const shortcuts = this.getAllShortcuts(); + const grouped = {}; + + shortcuts.forEach(shortcut => { + if (!grouped[shortcut.category]) { + grouped[shortcut.category] = []; + } + grouped[shortcut.category].push(shortcut); + }); + + return grouped; + } + + /** + * Get conflict warnings + */ + getConflicts() { + return this.conflictWarnings; + } + + /** + * Clear all registered shortcuts + */ + clear() { + this.shortcuts.clear(); + this.conflictWarnings = []; + } + + /** + * Format shortcut for display (e.g., "Ctrl + K") + */ + formatShortcut(key, modifiers) { + const parts = []; + + // Use platform-specific naming + const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0; + + if (modifiers.ctrl) parts.push(isMac ? '⌘' : 'Ctrl'); + if (modifiers.shift) parts.push(isMac ? '⇧' : 'Shift'); + if (modifiers.alt) parts.push(isMac ? '⌥' : 'Alt'); + + // Capitalize key for display + const displayKey = key.length === 1 ? key.toUpperCase() : key; + parts.push(displayKey); + + return parts.join(' + '); + } + + /** + * Check if a shortcut is registered + */ + has(key, modifiers = {}) { + const shortcutKey = this.getShortcutKey(key, modifiers); + return this.shortcuts.has(shortcutKey); + } + + /** + * Enable/disable specific shortcut + */ + setShortcutEnabled(key, modifiers, enabled) { + const shortcutKey = this.getShortcutKey(key, modifiers); + const shortcut = this.shortcuts.get(shortcutKey); + + if (shortcut) { + shortcut.enabled = enabled; + return true; + } + + return false; + } +} + +// Export singleton instance for convenience +export const keyboardShortcuts = new KeyboardShortcutManager(); + +// Export key constants for convenience +export const Keys = { + ESCAPE: 'Escape', + ENTER: 'Enter', + SPACE: 'Space', + TAB: 'Tab', + BACKSPACE: 'Backspace', + DELETE: 'Delete', + ARROW_UP: 'ArrowUp', + ARROW_DOWN: 'ArrowDown', + ARROW_LEFT: 'ArrowLeft', + ARROW_RIGHT: 'ArrowRight', + HOME: 'Home', + END: 'End', + PAGE_UP: 'PageUp', + PAGE_DOWN: 'PageDown' +}; diff --git a/frontend/src/services/__tests__/CommandRegistry.test.js b/frontend/src/services/__tests__/CommandRegistry.test.js new file mode 100644 index 00000000..99cd8cba --- /dev/null +++ b/frontend/src/services/__tests__/CommandRegistry.test.js @@ -0,0 +1,421 @@ +/** + * @jest-environment jsdom + */ + +import { CommandRegistry } from '../CommandRegistry'; + +describe('CommandRegistry', () => { + let registry; + + beforeEach(() => { + registry = new CommandRegistry(); + }); + + describe('register', () => { + it('should register a new command', () => { + const command = { + id: 'test.command', + label: 'Test Command', + action: jest.fn() + }; + + registry.register(command); + + const all = registry.getAll(); + expect(all).toHaveLength(1); + expect(all[0].id).toBe('test.command'); + }); + + it('should throw error for duplicate command ID', () => { + const command1 = { id: 'test.command', label: 'First', action: jest.fn() }; + const command2 = { id: 'test.command', label: 'Second', action: jest.fn() }; + + registry.register(command1); + + expect(() => registry.register(command2)).toThrow('Command with id test.command already registered'); + }); + + it('should use id as label if label not provided', () => { + const command = { id: 'test.command', action: jest.fn() }; + + registry.register(command); + + const all = registry.getAll(); + expect(all[0].label).toBe('test.command'); + }); + + it('should default to empty arrays for keywords and tags', () => { + const command = { id: 'test.command', action: jest.fn() }; + + registry.register(command); + + const all = registry.getAll(); + expect(all[0].keywords).toEqual([]); + expect(all[0].tags).toEqual([]); + }); + }); + + describe('unregister', () => { + it('should remove a registered command', () => { + registry.register({ id: 'test.command', action: jest.fn() }); + + expect(registry.getAll()).toHaveLength(1); + + registry.unregister('test.command'); + + expect(registry.getAll()).toHaveLength(0); + }); + + it('should do nothing when unregistering non-existent command', () => { + expect(() => registry.unregister('non.existent')).not.toThrow(); + }); + }); + + describe('execute', () => { + it('should execute registered command', async () => { + const action = jest.fn().mockResolvedValue('result'); + registry.register({ id: 'test.command', action }); + + const result = await registry.execute('test.command'); + + expect(action).toHaveBeenCalled(); + expect(result).toBe('result'); + }); + + it('should throw error for non-existent command', async () => { + await expect(registry.execute('non.existent')).rejects.toThrow('Command non.existent not found'); + }); + + it('should not execute disabled command', async () => { + const action = jest.fn(); + registry.register({ + id: 'test.command', + action, + enabled: () => false + }); + + await expect(registry.execute('test.command')).rejects.toThrow('Command test.command is disabled'); + expect(action).not.toHaveBeenCalled(); + }); + + it('should call onBeforeExecute and onAfterExecute listeners', async () => { + const action = jest.fn().mockResolvedValue('result'); + const beforeListener = jest.fn(); + const afterListener = jest.fn(); + + registry.on('beforeExecute', beforeListener); + registry.on('afterExecute', afterListener); + + registry.register({ id: 'test.command', action }); + + await registry.execute('test.command'); + + expect(beforeListener).toHaveBeenCalledWith(expect.objectContaining({ id: 'test.command' })); + expect(afterListener).toHaveBeenCalledWith(expect.objectContaining({ id: 'test.command' }), 'result'); + }); + + it('should call onError listener when action throws', async () => { + const error = new Error('Test error'); + const action = jest.fn().mockRejectedValue(error); + const errorListener = jest.fn(); + + registry.on('error', errorListener); + registry.register({ id: 'test.command', action }); + + await expect(registry.execute('test.command')).rejects.toThrow('Test error'); + expect(errorListener).toHaveBeenCalledWith(expect.objectContaining({ id: 'test.command' }), error); + }); + }); + + describe('search', () => { + beforeEach(() => { + registry.register({ + id: 'edit.undo', + label: 'Undo', + description: 'Undo last action', + keywords: ['revert', 'back'], + category: 'Edit' + }); + + registry.register({ + id: 'edit.redo', + label: 'Redo', + description: 'Redo last undone action', + keywords: ['forward', 'again'], + category: 'Edit' + }); + + registry.register({ + id: 'canvas.clear', + label: 'Clear Canvas', + description: 'Remove all strokes', + keywords: ['delete', 'erase', 'reset'], + category: 'Canvas' + }); + }); + + it('should search by label', () => { + const results = registry.search('undo'); + + expect(results).toHaveLength(1); + expect(results[0].id).toBe('edit.undo'); + }); + + it('should search by description', () => { + const results = registry.search('remove all'); + + expect(results.length).toBeGreaterThan(0); + expect(results[0].id).toBe('canvas.clear'); + }); + + it('should search by keywords', () => { + const results = registry.search('revert'); + + expect(results).toHaveLength(1); + expect(results[0].id).toBe('edit.undo'); + }); + + it('should be case-insensitive', () => { + const results = registry.search('UNDO'); + + expect(results).toHaveLength(1); + expect(results[0].id).toBe('edit.undo'); + }); + + it('should return empty array for no matches', () => { + const results = registry.search('nonexistent'); + + expect(results).toEqual([]); + }); + + it('should return all commands for empty query', () => { + const results = registry.search(''); + + expect(results).toHaveLength(3); + }); + + it('should filter out invisible commands', () => { + registry.register({ + id: 'hidden.command', + label: 'Hidden', + visible: () => false + }); + + const results = registry.search(''); + + expect(results).toHaveLength(3); // Should not include hidden command + }); + + it('should match partial words', () => { + const results = registry.search('cle'); + + expect(results.some(r => r.id === 'canvas.clear')).toBe(true); + }); + + it('should match across multiple fields', () => { + const results = registry.search('clear'); + + expect(results.length).toBeGreaterThan(0); + expect(results.some(r => r.id === 'canvas.clear')).toBe(true); + }); + }); + + describe('getByCategory', () => { + beforeEach(() => { + registry.register({ id: 'edit.undo', label: 'Undo', category: 'Edit' }); + registry.register({ id: 'edit.redo', label: 'Redo', category: 'Edit' }); + registry.register({ id: 'canvas.clear', label: 'Clear', category: 'Canvas' }); + }); + + it('should return commands by category', () => { + const editCommands = registry.getByCategory('Edit'); + + expect(editCommands).toHaveLength(2); + expect(editCommands.every(c => c.category === 'Edit')).toBe(true); + }); + + it('should return empty array for non-existent category', () => { + const results = registry.getByCategory('NonExistent'); + + expect(results).toEqual([]); + }); + + it('should handle undefined category', () => { + registry.register({ id: 'no.category', label: 'No Category' }); + + const results = registry.getByCategory(undefined); + + expect(results).toHaveLength(1); + expect(results[0].id).toBe('no.category'); + }); + }); + + describe('getAll', () => { + it('should return all registered commands', () => { + registry.register({ id: 'cmd1', label: 'Command 1' }); + registry.register({ id: 'cmd2', label: 'Command 2' }); + registry.register({ id: 'cmd3', label: 'Command 3' }); + + const all = registry.getAll(); + + expect(all).toHaveLength(3); + }); + + it('should return empty array when no commands registered', () => { + expect(registry.getAll()).toEqual([]); + }); + + it('should return copies, not references to internal state', () => { + registry.register({ id: 'cmd1', label: 'Command 1' }); + + const all = registry.getAll(); + all[0].label = 'Modified'; + + const allAgain = registry.getAll(); + expect(allAgain[0].label).toBe('Command 1'); // Original unchanged + }); + }); + + describe('getStats', () => { + beforeEach(() => { + registry.register({ id: 'edit.undo', category: 'Edit', enabled: () => true }); + registry.register({ id: 'edit.redo', category: 'Edit', enabled: () => false }); + registry.register({ id: 'canvas.clear', category: 'Canvas', visible: () => false }); + registry.register({ id: 'tools.pen', category: 'Tools' }); + }); + + it('should return correct command count', () => { + const stats = registry.getStats(); + expect(stats.total).toBe(4); + }); + + it('should count enabled commands', () => { + const stats = registry.getStats(); + expect(stats.enabled).toBe(1); + }); + + it('should count disabled commands', () => { + const stats = registry.getStats(); + expect(stats.disabled).toBe(1); + }); + + it('should count categories', () => { + const stats = registry.getStats(); + expect(stats.categories).toBe(3); + }); + }); + + describe('event listeners', () => { + it('should register event listeners', () => { + const listener = jest.fn(); + + registry.on('beforeExecute', listener); + + expect(registry._listeners.beforeExecute).toContain(listener); + }); + + it('should call multiple listeners', async () => { + const listener1 = jest.fn(); + const listener2 = jest.fn(); + + registry.on('beforeExecute', listener1); + registry.on('beforeExecute', listener2); + + registry.register({ id: 'test.command', action: jest.fn() }); + + await registry.execute('test.command'); + + expect(listener1).toHaveBeenCalled(); + expect(listener2).toHaveBeenCalled(); + }); + + it('should handle listener errors gracefully', async () => { + const errorListener = jest.fn(() => { + throw new Error('Listener error'); + }); + const action = jest.fn(); + + registry.on('beforeExecute', errorListener); + registry.register({ id: 'test.command', action }); + + // Should not throw, execution continues + await expect(registry.execute('test.command')).resolves.toBeUndefined(); + expect(action).toHaveBeenCalled(); + }); + }); + + describe('batch registration', () => { + it('should register multiple commands at once', () => { + const commands = [ + { id: 'cmd1', label: 'Command 1' }, + { id: 'cmd2', label: 'Command 2' }, + { id: 'cmd3', label: 'Command 3' } + ]; + + commands.forEach(cmd => registry.register(cmd)); + + expect(registry.getAll()).toHaveLength(3); + }); + }); + + describe('enabled/visible conditions', () => { + it('should evaluate enabled condition dynamically', () => { + let isEnabled = true; + registry.register({ + id: 'test.command', + action: jest.fn(), + enabled: () => isEnabled + }); + + const command1 = registry.getAll()[0]; + expect(command1.enabled()).toBe(true); + + isEnabled = false; + + const command2 = registry.getAll()[0]; + expect(command2.enabled()).toBe(false); + }); + + it('should evaluate visible condition dynamically', () => { + let isVisible = true; + registry.register({ + id: 'test.command', + action: jest.fn(), + visible: () => isVisible + }); + + expect(registry.search('').length).toBe(1); + + isVisible = false; + + expect(registry.search('').length).toBe(0); + }); + + it('should default enabled to true', () => { + registry.register({ id: 'test.command', action: jest.fn() }); + + const command = registry.getAll()[0]; + expect(command.enabled()).toBe(true); + }); + + it('should default visible to true', () => { + registry.register({ id: 'test.command', action: jest.fn() }); + + const command = registry.getAll()[0]; + expect(command.visible()).toBe(true); + }); + }); + + describe('clear', () => { + it('should remove all commands', () => { + registry.register({ id: 'cmd1', label: 'Command 1' }); + registry.register({ id: 'cmd2', label: 'Command 2' }); + + expect(registry.getAll()).toHaveLength(2); + + registry.clear(); + + expect(registry.getAll()).toHaveLength(0); + }); + }); +}); diff --git a/frontend/src/services/__tests__/KeyboardShortcuts.test.js b/frontend/src/services/__tests__/KeyboardShortcuts.test.js new file mode 100644 index 00000000..8f2b7207 --- /dev/null +++ b/frontend/src/services/__tests__/KeyboardShortcuts.test.js @@ -0,0 +1,441 @@ +/** + * @jest-environment jsdom + */ + +import { KeyboardShortcutManager } from '../KeyboardShortcuts'; + +describe('KeyboardShortcutManager', () => { + let manager; + + beforeEach(() => { + manager = new KeyboardShortcutManager(); + // Mock console methods to avoid noise + jest.spyOn(console, 'warn').mockImplementation(() => {}); + jest.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('register', () => { + it('should register a new shortcut', () => { + const action = jest.fn(); + const id = manager.register('k', { ctrl: true }, action, 'Test Shortcut', 'Test'); + + expect(id).toBeDefined(); + expect(manager.getAllShortcuts()).toHaveLength(1); + }); + + it('should generate unique IDs for shortcuts', () => { + const action1 = jest.fn(); + const action2 = jest.fn(); + + const id1 = manager.register('k', { ctrl: true }, action1); + const id2 = manager.register('p', {}, action2); + + expect(id1).not.toBe(id2); + }); + + it('should warn about conflicting shortcuts', () => { + const action1 = jest.fn(); + const action2 = jest.fn(); + + manager.register('k', { ctrl: true }, action1, 'First', 'Test'); + manager.register('k', { ctrl: true }, action2, 'Second', 'Test'); + + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining('Shortcut conflict detected') + ); + }); + + it('should handle shortcuts without modifiers', () => { + const action = jest.fn(); + const id = manager.register('p', {}, action, 'Pen Tool', 'Tools'); + + expect(id).toBeDefined(); + expect(manager.getAllShortcuts()[0].modifiers).toEqual({}); + }); + }); + + describe('unregister', () => { + it('should remove a registered shortcut', () => { + const action = jest.fn(); + const id = manager.register('k', { ctrl: true }, action); + + expect(manager.getAllShortcuts()).toHaveLength(1); + + manager.unregister(id); + + expect(manager.getAllShortcuts()).toHaveLength(0); + }); + + it('should do nothing when unregistering non-existent ID', () => { + expect(() => manager.unregister('non-existent')).not.toThrow(); + }); + }); + + describe('handleKeyDown', () => { + it('should execute action for matching shortcut', () => { + const action = jest.fn(); + manager.register('k', { ctrl: true }, action); + + const event = new KeyboardEvent('keydown', { + key: 'k', + ctrlKey: true, + bubbles: true + }); + + manager.handleKeyDown(event); + + expect(action).toHaveBeenCalled(); + }); + + it('should not execute when modifiers do not match', () => { + const action = jest.fn(); + manager.register('k', { ctrl: true, shift: true }, action); + + const event = new KeyboardEvent('keydown', { + key: 'k', + ctrlKey: true, + shiftKey: false + }); + + manager.handleKeyDown(event); + + expect(action).not.toHaveBeenCalled(); + }); + + it('should not execute when disabled', () => { + const action = jest.fn(); + manager.register('k', { ctrl: true }, action); + manager.disable(); + + const event = new KeyboardEvent('keydown', { + key: 'k', + ctrlKey: true + }); + + manager.handleKeyDown(event); + + expect(action).not.toHaveBeenCalled(); + }); + + it('should handle case-insensitive key matching', () => { + const action = jest.fn(); + manager.register('k', { ctrl: true }, action); + + const event = new KeyboardEvent('keydown', { + key: 'K', + ctrlKey: true + }); + + manager.handleKeyDown(event); + + expect(action).toHaveBeenCalled(); + }); + + it('should accept both Ctrl and Meta (Command) keys', () => { + const action = jest.fn(); + manager.register('k', { ctrl: true }, action); + + // Test with metaKey (Command on Mac) + const event = new KeyboardEvent('keydown', { + key: 'k', + metaKey: true + }); + + manager.handleKeyDown(event); + + expect(action).toHaveBeenCalled(); + }); + + it('should prevent default and stop propagation on match', () => { + const action = jest.fn(); + manager.register('k', { ctrl: true }, action); + + const event = new KeyboardEvent('keydown', { + key: 'k', + ctrlKey: true + }); + + const preventDefaultSpy = jest.spyOn(event, 'preventDefault'); + const stopPropagationSpy = jest.spyOn(event, 'stopPropagation'); + + manager.handleKeyDown(event); + + expect(preventDefaultSpy).toHaveBeenCalled(); + expect(stopPropagationSpy).toHaveBeenCalled(); + }); + + it('should ignore events in input elements', () => { + const action = jest.fn(); + manager.register('k', { ctrl: true }, action); + + // Create a mock input element + const input = document.createElement('input'); + document.body.appendChild(input); + + const event = new KeyboardEvent('keydown', { + key: 'k', + ctrlKey: true, + bubbles: true + }); + + Object.defineProperty(event, 'target', { value: input, configurable: true }); + + manager.handleKeyDown(event); + + expect(action).not.toHaveBeenCalled(); + + document.body.removeChild(input); + }); + + it('should ignore events in textarea elements', () => { + const action = jest.fn(); + manager.register('k', { ctrl: true }, action); + + const textarea = document.createElement('textarea'); + document.body.appendChild(textarea); + + const event = new KeyboardEvent('keydown', { + key: 'k', + ctrlKey: true + }); + + Object.defineProperty(event, 'target', { value: textarea, configurable: true }); + + manager.handleKeyDown(event); + + expect(action).not.toHaveBeenCalled(); + + document.body.removeChild(textarea); + }); + + it('should ignore events in contenteditable elements', () => { + const action = jest.fn(); + manager.register('k', { ctrl: true }, action); + + const div = document.createElement('div'); + div.contentEditable = 'true'; + document.body.appendChild(div); + + const event = new KeyboardEvent('keydown', { + key: 'k', + ctrlKey: true + }); + + Object.defineProperty(event, 'target', { value: div, configurable: true }); + + manager.handleKeyDown(event); + + expect(action).not.toHaveBeenCalled(); + + document.body.removeChild(div); + }); + }); + + describe('formatShortcut', () => { + it('should format shortcut with Ctrl modifier on Windows', () => { + // Mock Windows platform + Object.defineProperty(navigator, 'platform', { + value: 'Win32', + configurable: true + }); + + const formatted = manager.formatShortcut({ key: 'k', modifiers: { ctrl: true } }); + expect(formatted).toBe('Ctrl+K'); + }); + + it('should format shortcut with Command symbol on Mac', () => { + // Mock Mac platform + Object.defineProperty(navigator, 'platform', { + value: 'MacIntel', + configurable: true + }); + + const formatted = manager.formatShortcut({ key: 'k', modifiers: { ctrl: true } }); + expect(formatted).toBe('⌘K'); + }); + + it('should format multiple modifiers', () => { + const formatted = manager.formatShortcut({ + key: 'k', + modifiers: { ctrl: true, shift: true, alt: true } + }); + + expect(formatted).toContain('K'); + expect(formatted.split('+').length).toBe(4); // Ctrl+Shift+Alt+K + }); + + it('should handle shortcuts without modifiers', () => { + const formatted = manager.formatShortcut({ key: 'p', modifiers: {} }); + expect(formatted).toBe('P'); + }); + + it('should capitalize single letters', () => { + const formatted = manager.formatShortcut({ key: 'a', modifiers: {} }); + expect(formatted).toBe('A'); + }); + }); + + describe('getAllShortcuts', () => { + it('should return all registered shortcuts', () => { + manager.register('k', { ctrl: true }, jest.fn(), 'First', 'Test'); + manager.register('p', {}, jest.fn(), 'Second', 'Test'); + manager.register('z', { ctrl: true }, jest.fn(), 'Third', 'Edit'); + + const shortcuts = manager.getAllShortcuts(); + + expect(shortcuts).toHaveLength(3); + expect(shortcuts[0].description).toBe('First'); + expect(shortcuts[1].description).toBe('Second'); + expect(shortcuts[2].description).toBe('Third'); + }); + + it('should return empty array when no shortcuts registered', () => { + expect(manager.getAllShortcuts()).toEqual([]); + }); + }); + + describe('getShortcutsByCategory', () => { + it('should filter shortcuts by category', () => { + manager.register('k', { ctrl: true }, jest.fn(), 'Command', 'Commands'); + manager.register('p', {}, jest.fn(), 'Tool', 'Tools'); + manager.register('z', { ctrl: true }, jest.fn(), 'Edit', 'Edit'); + + const commands = manager.getShortcutsByCategory('Commands'); + expect(commands).toHaveLength(1); + expect(commands[0].description).toBe('Command'); + + const tools = manager.getShortcutsByCategory('Tools'); + expect(tools).toHaveLength(1); + expect(tools[0].description).toBe('Tool'); + }); + + it('should return empty array for non-existent category', () => { + manager.register('k', { ctrl: true }, jest.fn(), 'Test', 'Test'); + + expect(manager.getShortcutsByCategory('NonExistent')).toEqual([]); + }); + }); + + describe('enable/disable', () => { + it('should enable shortcuts after being disabled', () => { + const action = jest.fn(); + manager.register('k', { ctrl: true }, action); + + manager.disable(); + manager.enable(); + + const event = new KeyboardEvent('keydown', { + key: 'k', + ctrlKey: true + }); + + manager.handleKeyDown(event); + + expect(action).toHaveBeenCalled(); + }); + + it('should start enabled by default', () => { + const action = jest.fn(); + manager.register('k', { ctrl: true }, action); + + const event = new KeyboardEvent('keydown', { + key: 'k', + ctrlKey: true + }); + + manager.handleKeyDown(event); + + expect(action).toHaveBeenCalled(); + }); + }); + + describe('clear', () => { + it('should remove all shortcuts', () => { + manager.register('k', { ctrl: true }, jest.fn()); + manager.register('p', {}, jest.fn()); + manager.register('z', { ctrl: true }, jest.fn()); + + expect(manager.getAllShortcuts()).toHaveLength(3); + + manager.clear(); + + expect(manager.getAllShortcuts()).toHaveLength(0); + }); + }); + + describe('edge cases', () => { + it('should handle special keys', () => { + const action = jest.fn(); + manager.register('Enter', { ctrl: true }, action); + + const event = new KeyboardEvent('keydown', { + key: 'Enter', + ctrlKey: true + }); + + manager.handleKeyDown(event); + + expect(action).toHaveBeenCalled(); + }); + + it('should handle slash key', () => { + const action = jest.fn(); + manager.register('/', { ctrl: true }, action); + + const event = new KeyboardEvent('keydown', { + key: '/', + ctrlKey: true + }); + + manager.handleKeyDown(event); + + expect(action).toHaveBeenCalled(); + }); + + it('should not throw on null action', () => { + manager.register('k', { ctrl: true }, null); + + const event = new KeyboardEvent('keydown', { + key: 'k', + ctrlKey: true + }); + + expect(() => manager.handleKeyDown(event)).not.toThrow(); + }); + + it('should handle multiple actions for same key with different modifiers', () => { + const action1 = jest.fn(); + const action2 = jest.fn(); + + manager.register('z', { ctrl: true }, action1, 'Undo', 'Edit'); + manager.register('z', { ctrl: true, shift: true }, action2, 'Redo', 'Edit'); + + // Test Ctrl+Z + const event1 = new KeyboardEvent('keydown', { + key: 'z', + ctrlKey: true, + shiftKey: false + }); + manager.handleKeyDown(event1); + expect(action1).toHaveBeenCalled(); + expect(action2).not.toHaveBeenCalled(); + + action1.mockClear(); + action2.mockClear(); + + // Test Ctrl+Shift+Z + const event2 = new KeyboardEvent('keydown', { + key: 'z', + ctrlKey: true, + shiftKey: true + }); + manager.handleKeyDown(event2); + expect(action1).not.toHaveBeenCalled(); + expect(action2).toHaveBeenCalled(); + }); + }); +}); diff --git a/frontend/src/styles/KeyboardShortcuts.css b/frontend/src/styles/KeyboardShortcuts.css new file mode 100644 index 00000000..032431b2 --- /dev/null +++ b/frontend/src/styles/KeyboardShortcuts.css @@ -0,0 +1,182 @@ +/* Keyboard Shortcuts & Command Palette Styles */ + +/* Command Palette Dialog */ +.command-palette-dialog { + animation: slideIn 0.2s ease-out; +} + +@keyframes slideIn { + from { + transform: translateY(-20px); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +} + +/* Command Palette List Items */ +.command-palette-item { + transition: all 0.15s ease; + border-left: 3px solid transparent; +} + +.command-palette-item:hover, +.command-palette-item.selected { + border-left-color: var(--primary-color, #1976d2); + transform: translateX(2px); +} + +.command-palette-item.selected { + background-color: rgba(25, 118, 210, 0.08); +} + +/* Shortcut Chips */ +.shortcut-chip { + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', 'monospace'; + font-size: 0.75rem; + min-width: 24px; + height: 24px; + padding: 0 6px; + border-radius: 4px; + display: inline-flex; + align-items: center; + justify-content: center; + background-color: rgba(0, 0, 0, 0.04); + border: 1px solid rgba(0, 0, 0, 0.12); + color: rgba(0, 0, 0, 0.87); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); +} + +.shortcut-chip + .shortcut-chip { + margin-left: 4px; +} + +/* Dark mode support */ +@media (prefers-color-scheme: dark) { + .shortcut-chip { + background-color: rgba(255, 255, 255, 0.08); + border-color: rgba(255, 255, 255, 0.12); + color: rgba(255, 255, 255, 0.87); + } + + .command-palette-item.selected { + background-color: rgba(144, 202, 249, 0.16); + } +} + +/* Keyboard Shortcuts Help Dialog */ +.shortcuts-help-category { + margin-bottom: 24px; +} + +.shortcuts-help-category:last-child { + margin-bottom: 0; +} + +.shortcuts-help-table { + width: 100%; + border-collapse: collapse; +} + +.shortcuts-help-table tbody tr { + transition: background-color 0.15s ease; +} + +.shortcuts-help-table tbody tr:hover { + background-color: rgba(0, 0, 0, 0.02); +} + +@media (prefers-color-scheme: dark) { + .shortcuts-help-table tbody tr:hover { + background-color: rgba(255, 255, 255, 0.03); + } +} + +/* Command Category Headers */ +.command-category-header { + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: rgba(0, 0, 0, 0.6); + padding: 8px 16px; + margin-top: 8px; +} + +@media (prefers-color-scheme: dark) { + .command-category-header { + color: rgba(255, 255, 255, 0.6); + } +} + +/* Search Input Focus States */ +.command-palette-search:focus-within { + box-shadow: 0 2px 8px rgba(25, 118, 210, 0.2); +} + +/* Empty State */ +.command-palette-empty { + padding: 40px 24px; + text-align: center; + color: rgba(0, 0, 0, 0.4); +} + +@media (prefers-color-scheme: dark) { + .command-palette-empty { + color: rgba(255, 255, 255, 0.4); + } +} + +/* Responsive Adjustments */ +@media (max-width: 600px) { + .command-palette-dialog { + margin: 16px; + } + + .shortcut-chip { + font-size: 0.65rem; + padding: 0 4px; + min-width: 20px; + height: 20px; + } +} + +/* Animation for category transitions */ +.shortcuts-tab-panel { + animation: fadeInUp 0.2s ease-out; +} + +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Accessibility - Focus visible styles */ +.command-palette-item:focus-visible, +.shortcut-chip:focus-visible { + outline: 2px solid var(--primary-color, #1976d2); + outline-offset: 2px; +} + +/* Loading states */ +.command-palette-loading { + display: flex; + align-items: center; + justify-content: center; + padding: 40px; + color: rgba(0, 0, 0, 0.4); +} + +@media (prefers-color-scheme: dark) { + .command-palette-loading { + color: rgba(255, 255, 255, 0.4); + } +}