diff --git a/client/src/components/Dashboard/Dashboard.tsx b/client/src/components/Dashboard/Dashboard.tsx index 5dad87c28..f140b4625 100644 --- a/client/src/components/Dashboard/Dashboard.tsx +++ b/client/src/components/Dashboard/Dashboard.tsx @@ -11,6 +11,7 @@ import { ReactComponent as DisconnectedIcon } from '@/assets/icons/disconnected. import { ReactComponent as SettingsIcon } from '@/assets/icons/settings.svg'; import SettingsModal from './SettingsModal'; import { startSocketWatcher } from '@/store/middleware/socketMiddleware'; +import useLayoutShortcuts from '@/hooks/useLayoutShortcuts'; export default function Dashboard() { const socket = useSelector((state: RootState) => state.socket); @@ -25,6 +26,8 @@ export default function Dashboard() { const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false); + useLayoutShortcuts(); + useEffect(() => { dispatch(getLayoutPreset()); diff --git a/client/src/components/Dashboard/SettingsModal.tsx b/client/src/components/Dashboard/SettingsModal.tsx index 63cd887bd..f7ca98aeb 100644 --- a/client/src/components/Dashboard/SettingsModal.tsx +++ b/client/src/components/Dashboard/SettingsModal.tsx @@ -1,12 +1,14 @@ import { CSSProperties, Fragment, useId } from 'react'; -import { Dialog, Transition } from '@headlessui/react'; +import { Dialog, Disclosure, Transition } from '@headlessui/react'; import clsx from 'clsx'; import { ReactComponent as PaletteIcon } from '@/assets/icons/palette.svg'; import { ReactComponent as DarkIcon } from '@/assets/icons/dark_mode.svg'; import { ReactComponent as LightIcon } from '@/assets/icons/light_mode.svg'; +import { ReactComponent as ExpandMoreIcon } from '@/assets/icons/expand_more.svg'; import { colors, Colors, useTheme, useThemeDispatch } from '@/hooks/useTheme'; +import { MODIFIER_NOTE, SHORTCUTS } from '@/hooks/useLayoutShortcuts'; export default function SettingsModal({ isOpen, @@ -141,6 +143,47 @@ export default function SettingsModal({ ))} + {/* Keyboard Shortcuts */} + + {({ open }) => ( +
+ +

+ + ⌨ + {' '} + Keyboard Shortcuts +

+ +
+
+ +

+ {MODIFIER_NOTE} +

+ + + {SHORTCUTS.map(({ keys, description }) => ( + + + + + ))} + +
{description} + + {keys} + +
+
+
+ )} +
diff --git a/client/src/enums/LayoutPreset.tsx b/client/src/enums/LayoutPreset.tsx index a2353ac36..b032cffd2 100644 --- a/client/src/enums/LayoutPreset.tsx +++ b/client/src/enums/LayoutPreset.tsx @@ -13,6 +13,9 @@ import TelemetryView from '@/components/views/TelemetryView'; import FieldView from '@/components/views/FieldView/FieldView'; import HardwareView from '@/components/views/HardwareView'; +// When adding a preset here (and to LAYOUT_DETAILS below), also add it to +// PRESET_ORDER in @/hooks/useLayoutShortcuts.ts so it gets a keyboard +// shortcut and is included in the Alt/Option + [ and ] cycling order. const LayoutPreset = { DEFAULT: 'DEFAULT', FIELD: 'FIELD', diff --git a/client/src/hooks/useLayoutShortcuts.ts b/client/src/hooks/useLayoutShortcuts.ts new file mode 100644 index 000000000..cabf58e2e --- /dev/null +++ b/client/src/hooks/useLayoutShortcuts.ts @@ -0,0 +1,95 @@ +import { useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; + +import LayoutPreset, { LayoutPresetType } from '@/enums/LayoutPreset'; +import { saveLayoutPreset } from '@/store/actions/settings'; +import { RootState } from '@/store/reducers'; + +export const isMac = + typeof navigator !== 'undefined' && + /Mac|iP(hone|ad|od)/.test(navigator.platform); + +// Alt (Option on Mac) is used as the modifier because Ctrl/Cmd + digit and +// Cmd + [ / ] are taken by browser tab and history navigation. +const MODIFIER_LABEL = isMac ? '⌥' : 'Alt'; + +export const MODIFIER_NOTE = isMac + ? '⌥ is the Option key (Alt on Windows/Linux)' + : 'Alt is ⌥ Option on Mac'; + +// Every preset in LayoutPreset should be listed here. Array position +// determines the digit shortcut (index 0 = Alt/Option + 1), so append new +// presets at the end to keep existing shortcuts stable. Max 9 entries. +export const PRESET_ORDER: LayoutPresetType[] = [ + LayoutPreset.DEFAULT, + LayoutPreset.FIELD, + LayoutPreset.GRAPH, + LayoutPreset.HARDWARE, + LayoutPreset.ORIGINAL, + LayoutPreset.CONFIGURABLE, +]; + +export type ShortcutInfo = { + keys: string; + description: string; +}; + +export const SHORTCUTS: ShortcutInfo[] = [ + { keys: `${MODIFIER_LABEL} + ]`, description: 'Next view' }, + { keys: `${MODIFIER_LABEL} + [`, description: 'Previous view' }, + ...PRESET_ORDER.map((preset, i) => ({ + keys: `${MODIFIER_LABEL} + ${i + 1}`, + description: `${LayoutPreset.getName(preset)} view`, + })), +]; + +function isEditableTarget(target: EventTarget | null) { + if (!(target instanceof HTMLElement)) return false; + return ( + target.isContentEditable || + ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName) + ); +} + +export default function useLayoutShortcuts() { + const layoutPreset = useSelector( + (state: RootState) => state.settings.layoutPreset, + ); + const dispatch = useDispatch(); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (!e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return; + if (isEditableTarget(e.target)) return; + + const currentIndex = PRESET_ORDER.indexOf( + layoutPreset as LayoutPresetType, + ); + + let nextPreset: LayoutPresetType | undefined; + // e.code identifies the physical key so shortcuts still match on Mac, + // where Option + key produces special characters in e.key + if (e.code === 'BracketRight') { + nextPreset = PRESET_ORDER[(currentIndex + 1) % PRESET_ORDER.length]; + } else if (e.code === 'BracketLeft') { + nextPreset = + PRESET_ORDER[ + (currentIndex - 1 + PRESET_ORDER.length) % PRESET_ORDER.length + ]; + } else { + const digitMatch = e.code.match(/^Digit(\d)$/); + if (digitMatch) { + nextPreset = PRESET_ORDER[parseInt(digitMatch[1], 10) - 1]; + } + } + + if (nextPreset === undefined) return; + + e.preventDefault(); + dispatch(saveLayoutPreset(nextPreset)); + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [layoutPreset, dispatch]); +}