mirror of
https://github.com/stan-smith/FossFLOW.git
synced 2025-12-24 06:58:48 -05:00
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:
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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'}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
39
packages/fossflow-lib/src/config/hotkeys.ts
Normal file
39
packages/fossflow-lib/src/config/hotkeys.ts
Normal 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';
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 & {
|
||||
|
||||
Reference in New Issue
Block a user