Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions client/src/components/Dashboard/Dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -25,6 +26,8 @@ export default function Dashboard() {

const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false);

useLayoutShortcuts();

useEffect(() => {
dispatch(getLayoutPreset());

Expand Down
45 changes: 44 additions & 1 deletion client/src/components/Dashboard/SettingsModal.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -141,6 +143,47 @@ export default function SettingsModal({
</div>
))}
</fieldset>
{/* Keyboard Shortcuts */}
<Disclosure>
{({ open }) => (
<div className="mt-5">
<Disclosure.Button className="flex w-full items-center justify-between px-6">
<h3 className="text-xl font-bold">
<span className="mr-1 inline-block translate-y-[-1px] text-gray-800 dark:text-slate-200">
⌨
</span>{' '}
Keyboard Shortcuts
</h3>
<ExpandMoreIcon
className={clsx(
'h-6 w-6 text-gray-800 transition-transform dark:text-slate-200',
open && 'rotate-180',
)}
/>
</Disclosure.Button>
<div className="w-[calc(100% - 0.75rem)] mx-3 mt-2 h-px bg-gray-300 dark:bg-slate-500" />
<Disclosure.Panel className="mt-3 px-6">
<p className="mb-2 text-xs text-gray-500 dark:text-slate-400">
{MODIFIER_NOTE}
</p>
<table className="w-full text-sm">
<tbody>
{SHORTCUTS.map(({ keys, description }) => (
<tr key={keys}>
<td className="py-1">{description}</td>
<td className="py-1 text-right">
<kbd className="rounded border border-gray-300 bg-gray-100 px-1.5 py-0.5 font-mono text-xs dark:border-slate-500 dark:bg-slate-600">
{keys}
</kbd>
</td>
</tr>
))}
</tbody>
</table>
</Disclosure.Panel>
</div>
)}
</Disclosure>
</Dialog.Panel>
</Transition.Child>
</div>
Expand Down
3 changes: 3 additions & 0 deletions client/src/enums/LayoutPreset.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
95 changes: 95 additions & 0 deletions client/src/hooks/useLayoutShortcuts.ts
Original file line number Diff line number Diff line change
@@ -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]);
}