feat: Add configurable hotkey system for tools

Implements a comprehensive hotkey system with three configurable profiles:
- QWERTY profile: Q, W, E, R, T, Y for tool selection
- SMNRCT profile: S, M, N, R, C, T for tool selection (default)
- None: Disables all hotkeys

Features:
- Settings dialog accessible from main menu for hotkey configuration
- Visual indicators showing active hotkeys in tool tooltips
- Hotkeys automatically disabled when typing in text fields
- Persistent hotkey profile selection across sessions

Closes #59
This commit is contained in:
Stan
2025-08-14 11:49:32 +01:00
parent dea6a1e934
commit ef258dff17
9 changed files with 278 additions and 10 deletions

View File

@@ -0,0 +1,84 @@
import React from 'react';
import {
Box,
Select,
MenuItem,
FormControl,
InputLabel,
Typography,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow
} from '@mui/material';
import { useUiStateStore } from 'src/stores/uiStateStore';
import { HOTKEY_PROFILES, HotkeyProfile } from 'src/config/hotkeys';
export const HotkeySettings = () => {
const hotkeyProfile = useUiStateStore((state) => state.hotkeyProfile);
const setHotkeyProfile = useUiStateStore((state) => state.actions.setHotkeyProfile);
const currentMapping = HOTKEY_PROFILES[hotkeyProfile];
const tools = [
{ name: 'Select', key: currentMapping.select },
{ name: 'Pan', key: currentMapping.pan },
{ name: 'Add Item', key: currentMapping.addItem },
{ name: 'Rectangle', key: currentMapping.rectangle },
{ name: 'Connector', key: currentMapping.connector },
{ name: 'Text', key: currentMapping.text }
];
return (
<Box sx={{ p: 2 }}>
<Typography variant="h6" gutterBottom>
Hotkey Settings
</Typography>
<FormControl fullWidth sx={{ mb: 3 }}>
<InputLabel>Hotkey Profile</InputLabel>
<Select
value={hotkeyProfile}
label="Hotkey Profile"
onChange={(e) => setHotkeyProfile(e.target.value as HotkeyProfile)}
>
<MenuItem value="qwerty">QWERTY (Q, W, E, R, T, Y)</MenuItem>
<MenuItem value="smnrct">SMNRCT (S, M, N, R, C, T)</MenuItem>
<MenuItem value="none">No Hotkeys</MenuItem>
</Select>
</FormControl>
{hotkeyProfile !== 'none' && (
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Tool</TableCell>
<TableCell>Hotkey</TableCell>
</TableRow>
</TableHead>
<TableBody>
{tools.map((tool) => (
<TableRow key={tool.name}>
<TableCell>{tool.name}</TableCell>
<TableCell>
<Typography variant="body2" sx={{ fontFamily: 'monospace' }}>
{tool.key ? tool.key.toUpperCase() : '-'}
</Typography>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
<Typography variant="caption" color="text.secondary" sx={{ mt: 2, display: 'block' }}>
Note: Hotkeys work when not typing in text fields
</Typography>
</Box>
);
};

View File

@@ -9,7 +9,8 @@ import {
FolderOpen as FolderOpenIcon,
DeleteOutline as DeleteOutlineIcon,
Undo as UndoIcon,
Redo as RedoIcon
Redo as RedoIcon,
Settings as SettingsIcon
} from '@mui/icons-material';
import { UiElement } from 'src/components/UiElement/UiElement';
import { IconButton } from 'src/components/IconButton/IconButton';
@@ -125,6 +126,11 @@ export const MainMenu = () => {
uiStateActions.setIsMainMenuOpen(false);
}, [redo, uiStateActions]);
const onOpenSettings = useCallback(() => {
uiStateActions.setIsMainMenuOpen(false);
uiStateActions.setDialog(DialogTypeEnum.SETTINGS);
}, [uiStateActions]);
const sectionVisibility = useMemo(() => {
return {
actions: Boolean(
@@ -222,6 +228,12 @@ export const MainMenu = () => {
</MenuItem>
)}
<Divider />
<MenuItem onClick={onOpenSettings} Icon={<SettingsIcon />}>
Settings
</MenuItem>
{sectionVisibility.links && (
<>
<Divider />

View File

@@ -0,0 +1,54 @@
import React from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
IconButton
} from '@mui/material';
import { Close as CloseIcon } from '@mui/icons-material';
import { useUiStateStore } from 'src/stores/uiStateStore';
import { HotkeySettings } from '../HotkeySettings/HotkeySettings';
export const SettingsDialog = () => {
const dialog = useUiStateStore((state) => state.dialog);
const setDialog = useUiStateStore((state) => state.actions.setDialog);
const isOpen = dialog === 'SETTINGS';
const handleClose = () => {
setDialog(null);
};
return (
<Dialog
open={isOpen}
onClose={handleClose}
maxWidth="sm"
fullWidth
>
<DialogTitle>
Settings
<IconButton
aria-label="close"
onClick={handleClose}
sx={{
position: 'absolute',
right: 8,
top: 8,
color: (theme) => theme.palette.grey[500],
}}
>
<CloseIcon />
</IconButton>
</DialogTitle>
<DialogContent dividers>
<HotkeySettings />
</DialogContent>
<DialogActions>
<Button onClick={handleClose}>Close</Button>
</DialogActions>
</Dialog>
);
};

View File

@@ -18,6 +18,7 @@ import { useScene } from 'src/hooks/useScene';
import { useHistory } from 'src/hooks/useHistory';
import { TEXTBOX_DEFAULTS } from 'src/config';
import { generateId } from 'src/utils';
import { HOTKEY_PROFILES } from 'src/config/hotkeys';
export const ToolMenu = () => {
const { createTextBox } = useScene();
@@ -31,6 +32,11 @@ export const ToolMenu = () => {
const mousePosition = useUiStateStore((state) => {
return state.mouse.position.tile;
});
const hotkeyProfile = useUiStateStore((state) => {
return state.hotkeyProfile;
});
const hotkeys = HOTKEY_PROFILES[hotkeyProfile];
const handleUndo = useCallback(() => {
undo();
@@ -75,7 +81,7 @@ export const ToolMenu = () => {
{/* Main Tools */}
<IconButton
name="Select"
name={`Select${hotkeys.select ? ` (${hotkeys.select.toUpperCase()})` : ''}`}
Icon={<NearMeIcon />}
onClick={() => {
uiStateStoreActions.setMode({
@@ -87,7 +93,7 @@ export const ToolMenu = () => {
isActive={mode.type === 'CURSOR' || mode.type === 'DRAG_ITEMS'}
/>
<IconButton
name="Pan"
name={`Pan${hotkeys.pan ? ` (${hotkeys.pan.toUpperCase()})` : ''}`}
Icon={<PanToolIcon />}
onClick={() => {
uiStateStoreActions.setMode({
@@ -100,7 +106,7 @@ export const ToolMenu = () => {
isActive={mode.type === 'PAN'}
/>
<IconButton
name="Add item"
name={`Add item${hotkeys.addItem ? ` (${hotkeys.addItem.toUpperCase()})` : ''}`}
Icon={<AddIcon />}
onClick={() => {
uiStateStoreActions.setItemControls({
@@ -115,7 +121,7 @@ export const ToolMenu = () => {
isActive={mode.type === 'PLACE_ICON'}
/>
<IconButton
name="Rectangle"
name={`Rectangle${hotkeys.rectangle ? ` (${hotkeys.rectangle.toUpperCase()})` : ''}`}
Icon={<CropSquareIcon />}
onClick={() => {
uiStateStoreActions.setMode({
@@ -127,7 +133,7 @@ export const ToolMenu = () => {
isActive={mode.type === 'RECTANGLE.DRAW'}
/>
<IconButton
name="Connector"
name={`Connector${hotkeys.connector ? ` (${hotkeys.connector.toUpperCase()})` : ''}`}
Icon={<ConnectorIcon />}
onClick={() => {
uiStateStoreActions.setMode({
@@ -139,7 +145,7 @@ export const ToolMenu = () => {
isActive={mode.type === 'CONNECTOR'}
/>
<IconButton
name="Text"
name={`Text${hotkeys.text ? ` (${hotkeys.text.toUpperCase()})` : ''}`}
Icon={<TitleIcon />}
onClick={createTextBoxProxy}
isActive={mode.type === 'TEXTBOX'}

View File

@@ -17,6 +17,7 @@ import { useScene } from 'src/hooks/useScene';
import { useModelStore } from 'src/stores/modelStore';
import { ExportImageDialog } from '../ExportImageDialog/ExportImageDialog';
import { HelpDialog } from '../HelpDialog/HelpDialog';
import { SettingsDialog } from '../SettingsDialog/SettingsDialog';
const ToolsEnum = {
MAIN_MENU: 'MAIN_MENU',
@@ -237,6 +238,8 @@ export const UiOverlay = () => {
{dialog === DialogTypeEnum.HELP && <HelpDialog />}
{dialog === DialogTypeEnum.SETTINGS && <SettingsDialog />}
<SceneLayer>
<Box ref={contextMenuAnchorRef} />
<ContextMenuManager anchorEl={contextMenuAnchorRef.current} />

View File

@@ -0,0 +1,39 @@
export type HotkeyProfile = 'qwerty' | 'smnrct' | 'none';
export interface HotkeyMapping {
select: string | null;
pan: string | null;
addItem: string | null;
rectangle: string | null;
connector: string | null;
text: string | null;
}
export const HOTKEY_PROFILES: Record<HotkeyProfile, HotkeyMapping> = {
qwerty: {
select: 'q',
pan: 'w',
addItem: 'e',
rectangle: 'r',
connector: 't',
text: 'y'
},
smnrct: {
select: 's',
pan: 'm',
addItem: 'n',
rectangle: 'r',
connector: 'c',
text: 't'
},
none: {
select: null,
pan: null,
addItem: null,
rectangle: null,
connector: null,
text: null
}
};
export const DEFAULT_HOTKEY_PROFILE: HotkeyProfile = 'smnrct';

View File

@@ -3,10 +3,12 @@ import { useModelStore } from 'src/stores/modelStore';
import { useUiStateStore } from 'src/stores/uiStateStore';
import { ModeActions, State, SlimMouseEvent } from 'src/types';
import { DialogTypeEnum } from 'src/types/ui';
import { getMouse, getItemAtTile } from 'src/utils';
import { getMouse, getItemAtTile, generateId } from 'src/utils';
import { useResizeObserver } from 'src/hooks/useResizeObserver';
import { useScene } from 'src/hooks/useScene';
import { useHistory } from 'src/hooks/useHistory';
import { HOTKEY_PROFILES } from 'src/config/hotkeys';
import { TEXTBOX_DEFAULTS } from 'src/config';
import { Cursor } from './modes/Cursor';
import { DragItems } from './modes/DragItems';
import { DrawRectangle } from './modes/Rectangle/DrawRectangle';
@@ -52,6 +54,7 @@ export const useInteractionManager = () => {
const scene = useScene();
const { size: rendererSize } = useResizeObserver(uiState.rendererEl);
const { undo, redo, canUndo, canRedo } = useHistory();
const { createTextBox } = scene;
// Keyboard shortcuts for undo/redo
useEffect(() => {
@@ -92,13 +95,71 @@ export const useInteractionManager = () => {
e.preventDefault();
uiState.actions.setDialog(DialogTypeEnum.HELP);
}
// Tool hotkeys
const hotkeyMapping = HOTKEY_PROFILES[uiState.hotkeyProfile];
const key = e.key.toLowerCase();
// Check if key matches any hotkey
if (hotkeyMapping.select && key === hotkeyMapping.select) {
e.preventDefault();
uiState.actions.setMode({
type: 'CURSOR',
showCursor: true,
mousedownItem: null
});
} else if (hotkeyMapping.pan && key === hotkeyMapping.pan) {
e.preventDefault();
uiState.actions.setMode({
type: 'PAN',
showCursor: false
});
uiState.actions.setItemControls(null);
} else if (hotkeyMapping.addItem && key === hotkeyMapping.addItem) {
e.preventDefault();
uiState.actions.setItemControls({
type: 'ADD_ITEM'
});
uiState.actions.setMode({
type: 'PLACE_ICON',
showCursor: true,
id: null
});
} else if (hotkeyMapping.rectangle && key === hotkeyMapping.rectangle) {
e.preventDefault();
uiState.actions.setMode({
type: 'RECTANGLE.DRAW',
showCursor: true,
id: null
});
} else if (hotkeyMapping.connector && key === hotkeyMapping.connector) {
e.preventDefault();
uiState.actions.setMode({
type: 'CONNECTOR',
id: null,
showCursor: true
});
} else if (hotkeyMapping.text && key === hotkeyMapping.text) {
e.preventDefault();
const textBoxId = generateId();
createTextBox({
...TEXTBOX_DEFAULTS,
id: textBoxId,
tile: uiState.mouse.position.tile
});
uiState.actions.setMode({
type: 'TEXTBOX',
showCursor: false,
id: textBoxId
});
}
};
window.addEventListener('keydown', handleKeyDown);
return () => {
return window.removeEventListener('keydown', handleKeyDown);
};
}, [undo, redo, canUndo, canRedo]);
}, [undo, redo, canUndo, canRedo, uiState.hotkeyProfile, uiState.actions, createTextBox, uiState.mouse.position.tile]);
const onMouseEvent = useCallback(
(e: SlimMouseEvent) => {

View File

@@ -8,6 +8,7 @@ import {
} from 'src/utils';
import { UiStateStore } from 'src/types';
import { INITIAL_UI_STATE } from 'src/config';
import { DEFAULT_HOTKEY_PROFILE } from 'src/config/hotkeys';
const initialState = () => {
return createStore<UiStateStore>((set, get) => {
@@ -30,6 +31,7 @@ const initialState = () => {
},
itemControls: null,
enableDebugTools: false,
hotkeyProfile: DEFAULT_HOTKEY_PROFILE,
actions: {
setView: (view) => {
set({ view });
@@ -91,6 +93,9 @@ const initialState = () => {
},
setRendererEl: (el) => {
set({ rendererEl: el });
},
setHotkeyProfile: (hotkeyProfile) => {
set({ hotkeyProfile });
}
}
};

View File

@@ -1,6 +1,7 @@
import { Coords, EditorModeEnum, MainMenuOptions } from './common';
import { Icon } from './model';
import { ItemReference } from './scene';
import { HotkeyProfile } from 'src/config/hotkeys';
interface AddItemControls {
type: 'ADD_ITEM';
@@ -115,7 +116,8 @@ export type IconCollectionStateWithIcons = IconCollectionState & {
export const DialogTypeEnum = {
EXPORT_IMAGE: 'EXPORT_IMAGE',
HELP: 'HELP'
HELP: 'HELP',
SETTINGS: 'SETTINGS'
} as const;
export interface ContextMenu {
@@ -148,6 +150,7 @@ export interface UiState {
mouse: Mouse;
rendererEl: HTMLDivElement | null;
enableDebugTools: boolean;
hotkeyProfile: HotkeyProfile;
}
export interface UiStateActions {
@@ -168,6 +171,7 @@ export interface UiStateActions {
setMouse: (mouse: Mouse) => void;
setRendererEl: (el: HTMLDivElement) => void;
setEnableDebugTools: (enabled: boolean) => void;
setHotkeyProfile: (profile: HotkeyProfile) => void;
}
export type UiStateStore = UiState & {