import { useEffect } from 'react'; import { useSitesStore } from '@/store/sites.ts'; import { useCoverageStore } from '@/store/coverage.ts'; import { useSettingsStore } from '@/store/settings.ts'; import { useToolStore } from '@/store/tools.ts'; import { useToastStore } from '@/components/ui/Toast.tsx'; interface ShortcutHandlers { onCalculate: () => void; onCloseForm: () => void; onShowShortcuts?: () => void; onDeleteRequest?: (siteId: string, siteName: string) => void; onUndo?: () => void; onRedo?: () => void; } function isInputActive(): boolean { const el = document.activeElement; if (!el) return false; const tag = el.tagName; return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT'; } export function useKeyboardShortcuts({ onCalculate, onCloseForm, onShowShortcuts, onDeleteRequest, onUndo, onRedo, }: ShortcutHandlers) { useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { const isMac = navigator.platform.toLowerCase().includes('mac'); const modKey = isMac ? e.metaKey : e.ctrlKey; // === Modifier shortcuts (work even in inputs) === if (modKey && e.key === 'Enter') { e.preventDefault(); onCalculate(); return; } // Undo/Redo: use e.code (layout-independent) as primary, e.key as fallback. // e.key can be uppercase 'Z' with Shift, or a control char on some layouts. // e.code is always 'KeyZ' / 'KeyY' regardless of modifier or layout. const isZ = e.code === 'KeyZ' || e.key.toLowerCase() === 'z'; const isY = e.code === 'KeyY' || e.key.toLowerCase() === 'y'; // Undo: Ctrl+Z (or Cmd+Z on Mac) if (modKey && isZ && !e.shiftKey) { e.preventDefault(); onUndo?.(); return; } // Redo: Ctrl+Shift+Z or Ctrl+Y (Cmd+Shift+Z or Cmd+Y on Mac) if (modKey && ((isZ && e.shiftKey) || isY)) { e.preventDefault(); onRedo?.(); return; } // Escape always works if (e.key === 'Escape') { useSitesStore.getState().selectSite(null); useToolStore.getState().clearTool(); onCloseForm(); return; } // === Skip remaining shortcuts if typing in an input === if (isInputActive()) return; // Shift combos (no browser conflicts) if (e.shiftKey && !modKey && !e.altKey) { switch (e.key.toUpperCase()) { case 'S': // Shift+S: New site (place mode) e.preventDefault(); useToolStore.getState().setActiveTool('site-placement'); useToastStore.getState().addToast('Click on map to place new site', 'info'); return; case 'C': // Shift+C: Clear coverage e.preventDefault(); useCoverageStore.getState().clearCoverage(); useToastStore.getState().addToast('Coverage cleared', 'info'); return; } } // Single letter shortcuts (no modifiers) if (!modKey && !e.altKey && !e.shiftKey) { switch (e.key.toLowerCase()) { case 'h': // H: Toggle heatmap useCoverageStore.getState().toggleHeatmap(); return; case 'g': // G: Toggle grid useSettingsStore.getState().setShowGrid( !useSettingsStore.getState().showGrid ); return; case 't': // T: Toggle terrain useSettingsStore.getState().setShowElevationOverlay( !useSettingsStore.getState().showElevationOverlay ); return; case 'r': // R: Toggle ruler / measurement useSettingsStore.getState().setMeasurementMode( !useSettingsStore.getState().measurementMode ); return; case 'f': // F: Fit to coverage — dispatch custom event for Map.tsx to handle window.dispatchEvent(new CustomEvent('rfcp:fit-bounds')); return; case 'delete': // Delete key: show confirmation dialog case 'backspace': { const selectedId = useSitesStore.getState().selectedSiteId; if (selectedId && onDeleteRequest) { const site = useSitesStore.getState().sites.find(s => s.id === selectedId); if (site) { e.preventDefault(); onDeleteRequest(site.id, site.name); } } } return; } // ? key for help (not shift on some layouts) if (e.key === '?') { onShowShortcuts?.(); return; } } // ? with shift (most layouts: shift+/) if (e.shiftKey && !modKey && !e.altKey && e.key === '?') { onShowShortcuts?.(); return; } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [onCalculate, onCloseForm, onShowShortcuts, onDeleteRequest, onUndo, onRedo]); }