mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-12-18 20:04:17 +01:00
feat(frontend): add configurable tool hotkeys
This commit is contained in:
parent
30987dcad2
commit
64a19790be
@ -15,6 +15,7 @@ import "./index.css";
|
||||
import { RightRailProvider } from "./contexts/RightRailContext";
|
||||
import { ViewerProvider } from "./contexts/ViewerContext";
|
||||
import { SignatureProvider } from "./contexts/SignatureContext";
|
||||
import { HotkeyProvider } from "./contexts/HotkeyContext";
|
||||
|
||||
// Import file ID debugging helpers (development only)
|
||||
import "./utils/fileIdSafety";
|
||||
@ -44,15 +45,17 @@ export default function App() {
|
||||
<NavigationProvider>
|
||||
<FilesModalProvider>
|
||||
<ToolWorkflowProvider>
|
||||
<SidebarProvider>
|
||||
<ViewerProvider>
|
||||
<SignatureProvider>
|
||||
<RightRailProvider>
|
||||
<HomePage />
|
||||
</RightRailProvider>
|
||||
</SignatureProvider>
|
||||
</ViewerProvider>
|
||||
</SidebarProvider>
|
||||
<HotkeyProvider>
|
||||
<SidebarProvider>
|
||||
<ViewerProvider>
|
||||
<SignatureProvider>
|
||||
<RightRailProvider>
|
||||
<HomePage />
|
||||
</RightRailProvider>
|
||||
</SignatureProvider>
|
||||
</ViewerProvider>
|
||||
</SidebarProvider>
|
||||
</HotkeyProvider>
|
||||
</ToolWorkflowProvider>
|
||||
</FilesModalProvider>
|
||||
</NavigationProvider>
|
||||
|
||||
28
frontend/src/components/hotkeys/ShortcutDisplay.tsx
Normal file
28
frontend/src/components/hotkeys/ShortcutDisplay.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
import { Group, Kbd } from '@mantine/core';
|
||||
import React from 'react';
|
||||
import { useHotkeys } from '../../contexts/HotkeyContext';
|
||||
|
||||
interface ShortcutDisplayProps {
|
||||
shortcut?: string;
|
||||
}
|
||||
|
||||
export function ShortcutDisplay({ shortcut }: ShortcutDisplayProps) {
|
||||
const { formatShortcut } = useHotkeys();
|
||||
|
||||
if (!shortcut) return null;
|
||||
|
||||
const parts = formatShortcut(shortcut);
|
||||
if (!parts.length) return null;
|
||||
|
||||
return (
|
||||
<Group gap={4} wrap="nowrap" align="center" style={{ flexWrap: 'nowrap' }}>
|
||||
{parts.map((part, index) => (
|
||||
<Kbd key={`${part}-${index}`} style={{ fontSize: '0.75rem' }}>
|
||||
{part}
|
||||
</Kbd>
|
||||
))}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export default ShortcutDisplay;
|
||||
220
frontend/src/components/settings/HotkeySettingsSection.tsx
Normal file
220
frontend/src/components/settings/HotkeySettingsSection.tsx
Normal file
@ -0,0 +1,220 @@
|
||||
import { Alert, Button, Group, Stack, Table, Text } from '@mantine/core';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
|
||||
import { useHotkeys } from '../../contexts/HotkeyContext';
|
||||
import ShortcutDisplay from '../hotkeys/ShortcutDisplay';
|
||||
import { captureShortcut } from '../../utils/hotkeys';
|
||||
|
||||
interface HotkeySettingsSectionProps {
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
const headerStyle: React.CSSProperties = { fontWeight: 600 };
|
||||
|
||||
export function HotkeySettingsSection({ isOpen }: HotkeySettingsSectionProps) {
|
||||
const { toolRegistry } = useToolWorkflow();
|
||||
const {
|
||||
getShortcutForTool,
|
||||
updateHotkey,
|
||||
resetHotkey,
|
||||
resetAllHotkeys,
|
||||
customHotkeys,
|
||||
isShortcutAvailable,
|
||||
setCaptureActive,
|
||||
platform,
|
||||
} = useHotkeys();
|
||||
|
||||
const [editingTool, setEditingTool] = useState<string | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
const tools = useMemo(
|
||||
() => Object.entries(toolRegistry || {}),
|
||||
[toolRegistry]
|
||||
);
|
||||
|
||||
const stopEditing = useCallback(() => {
|
||||
setEditingTool(null);
|
||||
setCaptureActive(false);
|
||||
setErrorMessage(null);
|
||||
}, [setCaptureActive]);
|
||||
|
||||
const startEditing = useCallback((toolId: string) => {
|
||||
setCaptureActive(true);
|
||||
setEditingTool(toolId);
|
||||
setErrorMessage(null);
|
||||
}, [setCaptureActive]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen && editingTool) {
|
||||
stopEditing();
|
||||
}
|
||||
}, [isOpen, editingTool, stopEditing]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setCaptureActive(false);
|
||||
};
|
||||
}, [setCaptureActive]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editingTool) return;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
stopEditing();
|
||||
return;
|
||||
}
|
||||
|
||||
const result = captureShortcut(event);
|
||||
if (!result.keyToken) {
|
||||
setErrorMessage('Press a supported key.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.modifiers.length === 0) {
|
||||
setErrorMessage('Include at least one modifier key.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.shortcut) {
|
||||
setErrorMessage('Press a supported key combination.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isShortcutAvailable(result.shortcut, editingTool)) {
|
||||
setErrorMessage('That shortcut is already assigned.');
|
||||
return;
|
||||
}
|
||||
|
||||
updateHotkey(editingTool, result.shortcut);
|
||||
stopEditing();
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown, true);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown, true);
|
||||
}, [editingTool, isShortcutAvailable, stopEditing, updateHotkey]);
|
||||
|
||||
const renderShortcutCell = (toolId: string) => {
|
||||
const shortcut = getShortcutForTool(toolId);
|
||||
|
||||
if (editingTool === toolId) {
|
||||
return (
|
||||
<Stack gap={4} align="flex-start">
|
||||
<Text size="sm" c="blue">
|
||||
Press the new shortcut (Esc to cancel)
|
||||
</Text>
|
||||
{errorMessage && (
|
||||
<Text size="xs" c="red">
|
||||
{errorMessage}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (!shortcut) {
|
||||
return <Text size="sm" c="dimmed">Not assigned</Text>;
|
||||
}
|
||||
|
||||
return <ShortcutDisplay shortcut={shortcut} />;
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" align="flex-end">
|
||||
<div>
|
||||
<Text size="lg" fw={600} mb={4}>
|
||||
Keyboard Shortcuts
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Click change to set a custom shortcut. Use modifiers like {platform === 'mac' ? '⌘' : 'Ctrl'} + {platform === 'mac' ? '⌥' : 'Alt'} + Shift to avoid conflicts.
|
||||
</Text>
|
||||
</div>
|
||||
<Button
|
||||
variant="light"
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
resetAllHotkeys();
|
||||
stopEditing();
|
||||
}}
|
||||
>
|
||||
Restore defaults
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Table striped highlightOnHover withColumnBorders>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th style={headerStyle}>Tool</Table.Th>
|
||||
<Table.Th style={headerStyle}>Shortcut</Table.Th>
|
||||
<Table.Th style={headerStyle}>Actions</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{tools.map(([toolId, tool]) => {
|
||||
const isEditing = editingTool === toolId;
|
||||
const hasCustom = toolId in customHotkeys;
|
||||
|
||||
return (
|
||||
<Table.Tr key={toolId}>
|
||||
<Table.Td>
|
||||
<Stack gap={2}>
|
||||
<Text fw={500}>{tool.name}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{tool.description}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Table.Td>
|
||||
<Table.Td>{renderShortcutCell(toolId)}</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs">
|
||||
{isEditing ? (
|
||||
<Button size="xs" variant="light" color="gray" onClick={stopEditing}>
|
||||
Cancel
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="xs" variant="light" onClick={() => startEditing(toolId)}>
|
||||
Change
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={() => {
|
||||
resetHotkey(toolId);
|
||||
if (isEditing) {
|
||||
stopEditing();
|
||||
}
|
||||
}}
|
||||
disabled={!hasCustom}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
|
||||
{tools.length === 0 && (
|
||||
<Text size="sm" c="dimmed">
|
||||
No tools available for configuration.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{editingTool && (
|
||||
<Alert color="blue" title="Recording shortcut" variant="light">
|
||||
Press the new key combination now. Use Escape to cancel.
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export default HotkeySettingsSection;
|
||||
25
frontend/src/components/settings/SettingsModal.tsx
Normal file
25
frontend/src/components/settings/SettingsModal.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import { Modal, Tabs } from '@mantine/core';
|
||||
import React from 'react';
|
||||
import HotkeySettingsSection from './HotkeySettingsSection';
|
||||
|
||||
interface SettingsModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function SettingsModal({ opened, onClose }: SettingsModalProps) {
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="Settings" size="lg" centered>
|
||||
<Tabs defaultValue="hotkeys" keepMounted={false}>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="hotkeys">Hotkeys</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
<Tabs.Panel value="hotkeys" mt="md">
|
||||
<HotkeySettingsSection isOpen={opened} />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default SettingsModal;
|
||||
@ -12,6 +12,7 @@ import { ButtonConfig } from '../../types/sidebar';
|
||||
import './quickAccessBar/QuickAccessBar.css';
|
||||
import AllToolsNavButton from './AllToolsNavButton';
|
||||
import ActiveToolButton from "./quickAccessBar/ActiveToolButton";
|
||||
import SettingsModal from '../settings/SettingsModal';
|
||||
import {
|
||||
isNavButtonActive,
|
||||
getNavButtonStyle,
|
||||
@ -241,10 +242,10 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* <AppConfigModal
|
||||
<SettingsModal
|
||||
opened={configModalOpen}
|
||||
onClose={() => setConfigModalOpen(false)}
|
||||
/> */}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@ -5,6 +5,9 @@ import { ToolRegistryEntry } from "../../../data/toolsTaxonomy";
|
||||
import { useToolNavigation } from "../../../hooks/useToolNavigation";
|
||||
import { handleUnlessSpecialClick } from "../../../utils/clickHandlers";
|
||||
import FitText from "../../shared/FitText";
|
||||
import { useHotkeys } from "../../../contexts/HotkeyContext";
|
||||
import ShortcutDisplay from "../../hotkeys/ShortcutDisplay";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface ToolButtonProps {
|
||||
id: string;
|
||||
@ -18,7 +21,10 @@ interface ToolButtonProps {
|
||||
|
||||
const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect, disableNavigation = false, matchedSynonym }) => {
|
||||
// Special case: read and multiTool are navigational tools that are always available
|
||||
const { t } = useTranslation();
|
||||
const { getShortcutForTool } = useHotkeys();
|
||||
const isUnavailable = !tool.component && !tool.link && id !== 'read' && id !== 'multiTool';
|
||||
const shortcut = !isUnavailable ? getShortcutForTool(id) : undefined;
|
||||
const { getToolNavigation } = useToolNavigation();
|
||||
|
||||
const handleClick = (id: string) => {
|
||||
@ -35,9 +41,23 @@ const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect,
|
||||
// Get navigation props for URL support (only if navigation is not disabled)
|
||||
const navProps = !isUnavailable && !tool.link && !disableNavigation ? getToolNavigation(id, tool) : null;
|
||||
|
||||
const tooltipContent = isUnavailable
|
||||
? (<span><strong>Coming soon:</strong> {tool.description}</span>)
|
||||
: tool.description;
|
||||
const tooltipContent = (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.375rem' }}>
|
||||
{isUnavailable ? (
|
||||
<span><strong>Coming soon:</strong> {tool.description}</span>
|
||||
) : (
|
||||
<span>{tool.description}</span>
|
||||
)}
|
||||
{!isUnavailable && shortcut && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.35rem', flexWrap: 'nowrap' }}>
|
||||
<span style={{ fontSize: '0.75rem', color: 'var(--mantine-color-dimmed)' }}>
|
||||
{t('settings.hotkeys.shortcutLabel', 'Shortcut')}:
|
||||
</span>
|
||||
<ShortcutDisplay shortcut={shortcut} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const buttonContent = (
|
||||
<>
|
||||
|
||||
292
frontend/src/contexts/HotkeyContext.tsx
Normal file
292
frontend/src/contexts/HotkeyContext.tsx
Normal file
@ -0,0 +1,292 @@
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { ToolRegistryEntry } from '../data/toolsTaxonomy';
|
||||
import { useToolWorkflow } from './ToolWorkflowContext';
|
||||
import { ToolId, isValidToolId } from '../types/toolId';
|
||||
import {
|
||||
KEY_SEQUENCE,
|
||||
captureShortcut,
|
||||
detectIsMac,
|
||||
formatShortcutParts,
|
||||
isEditableElement,
|
||||
mapKeyToToken,
|
||||
normalizeShortcutString,
|
||||
SupportedModifier,
|
||||
} from '../utils/hotkeys';
|
||||
|
||||
const STORAGE_KEY = 'stirling.hotkeys';
|
||||
|
||||
type HotkeyMap = Record<string, string>;
|
||||
|
||||
interface HotkeyContextValue {
|
||||
hotkeys: HotkeyMap;
|
||||
defaultHotkeys: HotkeyMap;
|
||||
customHotkeys: HotkeyMap;
|
||||
getShortcutForTool: (toolId: string) => string | undefined;
|
||||
formatShortcut: (shortcut: string) => string[];
|
||||
updateHotkey: (toolId: string, shortcut: string) => void;
|
||||
resetHotkey: (toolId: string) => void;
|
||||
resetAllHotkeys: () => void;
|
||||
isShortcutAvailable: (shortcut: string, excludeToolId?: string) => boolean;
|
||||
setCaptureActive: (active: boolean) => void;
|
||||
platform: 'mac' | 'windows';
|
||||
}
|
||||
|
||||
const HotkeyContext = createContext<HotkeyContextValue | undefined>(undefined);
|
||||
|
||||
function loadStoredHotkeys(): HotkeyMap {
|
||||
if (typeof window === 'undefined') return {};
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return {};
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== 'object') return {};
|
||||
|
||||
const sanitized: HotkeyMap = {};
|
||||
Object.entries(parsed as Record<string, unknown>).forEach(([toolId, shortcut]) => {
|
||||
if (typeof shortcut !== 'string') return;
|
||||
const normalized = normalizeShortcutString(shortcut);
|
||||
if (normalized) {
|
||||
sanitized[toolId] = normalized;
|
||||
}
|
||||
});
|
||||
return sanitized;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function persistHotkeys(hotkeys: HotkeyMap) {
|
||||
if (typeof window === 'undefined') return;
|
||||
if (Object.keys(hotkeys).length === 0) {
|
||||
window.localStorage.removeItem(STORAGE_KEY);
|
||||
return;
|
||||
}
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(hotkeys));
|
||||
}
|
||||
|
||||
function generateDefaultHotkeys(
|
||||
registry: Record<string, ToolRegistryEntry>,
|
||||
isMac: boolean
|
||||
): HotkeyMap {
|
||||
const toolIds = Object.keys(registry);
|
||||
if (toolIds.length === 0) return {};
|
||||
|
||||
const primaryModifier: SupportedModifier = isMac ? 'meta' : 'ctrl';
|
||||
const defaults: HotkeyMap = {};
|
||||
|
||||
toolIds.forEach((toolId, index) => {
|
||||
const keyCandidate = KEY_SEQUENCE[index % KEY_SEQUENCE.length];
|
||||
if (!keyCandidate) return;
|
||||
const token = mapKeyToToken(keyCandidate);
|
||||
if (!token) return;
|
||||
|
||||
const cycle = Math.floor(index / KEY_SEQUENCE.length);
|
||||
let combination = `${primaryModifier}+alt+shift+${token}`;
|
||||
if (cycle === 1) {
|
||||
combination = `${primaryModifier}+shift+${token}`;
|
||||
} else if (cycle === 2) {
|
||||
combination = `${primaryModifier}+alt+${token}`;
|
||||
} else if (cycle >= 3) {
|
||||
combination = `${primaryModifier}+${token}`;
|
||||
}
|
||||
|
||||
const normalized = normalizeShortcutString(combination);
|
||||
if (normalized) {
|
||||
defaults[toolId] = normalized;
|
||||
}
|
||||
});
|
||||
|
||||
return defaults;
|
||||
}
|
||||
|
||||
export function HotkeyProvider({ children }: { children: React.ReactNode }) {
|
||||
const { toolRegistry, handleToolSelect } = useToolWorkflow();
|
||||
const registry = useMemo(
|
||||
() => (toolRegistry || {}) as Record<string, ToolRegistryEntry>,
|
||||
[toolRegistry]
|
||||
);
|
||||
const isMac = useMemo(() => detectIsMac(), []);
|
||||
|
||||
const [customHotkeys, setCustomHotkeys] = useState<HotkeyMap>(() => loadStoredHotkeys());
|
||||
const [captureActiveState, setCaptureActiveState] = useState(false);
|
||||
const captureActiveRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
captureActiveRef.current = captureActiveState;
|
||||
}, [captureActiveState]);
|
||||
|
||||
const defaultHotkeys = useMemo(
|
||||
() => generateDefaultHotkeys(registry, isMac),
|
||||
[registry, isMac]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setCustomHotkeys(prev => {
|
||||
const filteredEntries = Object.entries(prev).filter(([toolId]) => toolId in registry);
|
||||
if (filteredEntries.length === Object.keys(prev).length) {
|
||||
return prev;
|
||||
}
|
||||
return Object.fromEntries(filteredEntries);
|
||||
});
|
||||
}, [registry]);
|
||||
|
||||
useEffect(() => {
|
||||
persistHotkeys(customHotkeys);
|
||||
}, [customHotkeys]);
|
||||
|
||||
const hotkeys = useMemo(() => {
|
||||
const map: HotkeyMap = { ...defaultHotkeys };
|
||||
Object.entries(customHotkeys).forEach(([toolId, shortcut]) => {
|
||||
map[toolId] = shortcut;
|
||||
});
|
||||
return map;
|
||||
}, [defaultHotkeys, customHotkeys]);
|
||||
|
||||
const shortcutLookup = useMemo(() => {
|
||||
const lookup = new Map<string, string>();
|
||||
Object.entries(hotkeys).forEach(([toolId, shortcut]) => {
|
||||
const normalized = normalizeShortcutString(shortcut);
|
||||
if (normalized) {
|
||||
lookup.set(normalized, toolId);
|
||||
}
|
||||
});
|
||||
return lookup;
|
||||
}, [hotkeys]);
|
||||
|
||||
const setCaptureActive = useCallback((active: boolean) => {
|
||||
setCaptureActiveState(prev => (prev === active ? prev : active));
|
||||
}, []);
|
||||
|
||||
const getShortcutForTool = useCallback(
|
||||
(toolId: string) => hotkeys[toolId],
|
||||
[hotkeys]
|
||||
);
|
||||
|
||||
const formatShortcut = useCallback(
|
||||
(shortcut: string) => formatShortcutParts(shortcut, isMac),
|
||||
[isMac]
|
||||
);
|
||||
|
||||
const isShortcutAvailable = useCallback(
|
||||
(shortcut: string, excludeToolId?: string) => {
|
||||
const normalized = normalizeShortcutString(shortcut);
|
||||
if (!normalized) return false;
|
||||
const assignedTool = shortcutLookup.get(normalized);
|
||||
return !assignedTool || assignedTool === excludeToolId;
|
||||
},
|
||||
[shortcutLookup]
|
||||
);
|
||||
|
||||
const updateHotkey = useCallback(
|
||||
(toolId: string, shortcut: string) => {
|
||||
const normalized = normalizeShortcutString(shortcut);
|
||||
if (!normalized) return;
|
||||
|
||||
setCustomHotkeys(prev => {
|
||||
const defaultValue = defaultHotkeys[toolId];
|
||||
if (defaultValue === normalized) {
|
||||
if (!(toolId in prev)) return prev;
|
||||
const { [toolId]: _removed, ...rest } = prev;
|
||||
return rest;
|
||||
}
|
||||
if (prev[toolId] === normalized) return prev;
|
||||
return { ...prev, [toolId]: normalized };
|
||||
});
|
||||
},
|
||||
[defaultHotkeys]
|
||||
);
|
||||
|
||||
const resetHotkey = useCallback((toolId: string) => {
|
||||
setCustomHotkeys(prev => {
|
||||
if (!(toolId in prev)) return prev;
|
||||
const { [toolId]: _removed, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const resetAllHotkeys = useCallback(() => {
|
||||
setCustomHotkeys({});
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
if (event.repeat) return;
|
||||
if (captureActiveRef.current) return;
|
||||
if (isEditableElement(event.target)) return;
|
||||
|
||||
const { shortcut } = captureShortcut(event);
|
||||
if (!shortcut) return;
|
||||
const normalized = normalizeShortcutString(shortcut);
|
||||
if (!normalized) return;
|
||||
|
||||
const toolId = shortcutLookup.get(normalized);
|
||||
if (!toolId) return;
|
||||
|
||||
const tool = registry[toolId];
|
||||
if (!tool) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
if (tool.link) {
|
||||
window.open(tool.link, '_blank', 'noopener,noreferrer');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isValidToolId(toolId)) {
|
||||
handleToolSelect(toolId as ToolId);
|
||||
}
|
||||
},
|
||||
[handleToolSelect, registry, shortcutLookup]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const listener = (event: KeyboardEvent) => handleKeyDown(event);
|
||||
window.addEventListener('keydown', listener, true);
|
||||
return () => window.removeEventListener('keydown', listener, true);
|
||||
}, [handleKeyDown]);
|
||||
|
||||
const contextValue = useMemo<HotkeyContextValue>(() => ({
|
||||
hotkeys,
|
||||
defaultHotkeys,
|
||||
customHotkeys,
|
||||
getShortcutForTool,
|
||||
formatShortcut,
|
||||
updateHotkey,
|
||||
resetHotkey,
|
||||
resetAllHotkeys,
|
||||
isShortcutAvailable,
|
||||
setCaptureActive,
|
||||
platform: isMac ? 'mac' : 'windows',
|
||||
}), [
|
||||
hotkeys,
|
||||
defaultHotkeys,
|
||||
customHotkeys,
|
||||
getShortcutForTool,
|
||||
formatShortcut,
|
||||
updateHotkey,
|
||||
resetHotkey,
|
||||
resetAllHotkeys,
|
||||
isShortcutAvailable,
|
||||
setCaptureActive,
|
||||
isMac,
|
||||
]);
|
||||
|
||||
return <HotkeyContext.Provider value={contextValue}>{children}</HotkeyContext.Provider>;
|
||||
}
|
||||
|
||||
export function useHotkeys(): HotkeyContextValue {
|
||||
const context = useContext(HotkeyContext);
|
||||
if (!context) {
|
||||
throw new Error('useHotkeys must be used within a HotkeyProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
164
frontend/src/utils/hotkeys.ts
Normal file
164
frontend/src/utils/hotkeys.ts
Normal file
@ -0,0 +1,164 @@
|
||||
export type SupportedModifier = 'ctrl' | 'meta' | 'alt' | 'shift';
|
||||
|
||||
const MODIFIER_ORDER: SupportedModifier[] = ['ctrl', 'meta', 'alt', 'shift'];
|
||||
const DISPLAY_ORDER: SupportedModifier[] = ['meta', 'ctrl', 'alt', 'shift'];
|
||||
|
||||
const MAC_SYMBOLS: Record<SupportedModifier, string> = {
|
||||
meta: '⌘',
|
||||
ctrl: '⌃',
|
||||
alt: '⌥',
|
||||
shift: '⇧',
|
||||
};
|
||||
|
||||
const DEFAULT_SYMBOLS: Record<SupportedModifier, string> = {
|
||||
meta: 'Win',
|
||||
ctrl: 'Ctrl',
|
||||
alt: 'Alt',
|
||||
shift: 'Shift',
|
||||
};
|
||||
|
||||
const SPECIAL_KEY_LABELS: Record<string, string> = {
|
||||
space: 'Space',
|
||||
escape: 'Esc',
|
||||
enter: 'Enter',
|
||||
tab: 'Tab',
|
||||
backspace: 'Backspace',
|
||||
delete: 'Delete',
|
||||
insert: 'Insert',
|
||||
home: 'Home',
|
||||
end: 'End',
|
||||
pageup: 'Page Up',
|
||||
pagedown: 'Page Down',
|
||||
arrowup: '↑',
|
||||
arrowdown: '↓',
|
||||
arrowleft: '←',
|
||||
arrowright: '→',
|
||||
};
|
||||
|
||||
export const KEY_SEQUENCE: string[] = [
|
||||
'1', '2', '3', '4', '5', '6', '7', '8', '9', '0',
|
||||
'Q', 'W', 'E', 'R', 'T', 'Y', 'U', 'I', 'O', 'P',
|
||||
'A', 'S', 'D', 'F', 'G', 'H', 'J', 'K', 'L',
|
||||
'Z', 'X', 'C', 'V', 'B', 'N', 'M',
|
||||
'[', ']', ';', "'", ',', '.', '/', '\\', '-', '=',
|
||||
'F1', 'F2', 'F3', 'F4', 'F5', 'F6', 'F7', 'F8', 'F9', 'F10', 'F11', 'F12',
|
||||
'F13', 'F14', 'F15', 'F16', 'F17', 'F18', 'F19', 'F20', 'F21', 'F22', 'F23', 'F24',
|
||||
];
|
||||
|
||||
export interface ShortcutCapture {
|
||||
shortcut: string | null;
|
||||
keyToken: string | null;
|
||||
modifiers: SupportedModifier[];
|
||||
}
|
||||
|
||||
export function detectIsMac(): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
const platform = window.navigator?.userAgentData?.platform || window.navigator?.platform || '';
|
||||
return /mac|iphone|ipad|ipod/i.test(platform);
|
||||
}
|
||||
|
||||
export function mapKeyToToken(key: string): string | null {
|
||||
if (!key) return null;
|
||||
const lower = key.toLowerCase();
|
||||
|
||||
if (lower === ' ') return 'space';
|
||||
if (lower === 'escape') return 'escape';
|
||||
if (lower === 'tab') return 'tab';
|
||||
if (lower === 'enter') return 'enter';
|
||||
if (lower === 'backspace') return 'backspace';
|
||||
if (lower === 'delete') return 'delete';
|
||||
if (lower === 'insert') return 'insert';
|
||||
if (lower === 'home') return 'home';
|
||||
if (lower === 'end') return 'end';
|
||||
if (lower === 'pageup') return 'pageup';
|
||||
if (lower === 'pagedown') return 'pagedown';
|
||||
if (lower === 'arrowup' || lower === 'arrowdown' || lower === 'arrowleft' || lower === 'arrowright') {
|
||||
return lower;
|
||||
}
|
||||
|
||||
if (/^f\d{1,2}$/i.test(key)) {
|
||||
return lower;
|
||||
}
|
||||
|
||||
if (key.length === 1) {
|
||||
return lower;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeTokens(modifiers: SupportedModifier[], keyToken: string): string {
|
||||
const uniqueModifiers = Array.from(new Set(modifiers));
|
||||
uniqueModifiers.sort((a, b) => MODIFIER_ORDER.indexOf(a) - MODIFIER_ORDER.indexOf(b));
|
||||
return [...uniqueModifiers, keyToken].join('+');
|
||||
}
|
||||
|
||||
export function normalizeShortcutString(shortcut: string | null | undefined): string | null {
|
||||
if (!shortcut) return null;
|
||||
const parts = shortcut
|
||||
.split('+')
|
||||
.map(part => part.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
|
||||
if (parts.length === 0) return null;
|
||||
|
||||
const modifiers: SupportedModifier[] = [];
|
||||
let keyToken: string | null = null;
|
||||
|
||||
parts.forEach(part => {
|
||||
if ((MODIFIER_ORDER as string[]).includes(part)) {
|
||||
modifiers.push(part as SupportedModifier);
|
||||
} else if (!keyToken) {
|
||||
keyToken = part;
|
||||
}
|
||||
});
|
||||
|
||||
if (!keyToken) return null;
|
||||
return normalizeTokens(modifiers, keyToken);
|
||||
}
|
||||
|
||||
export function captureShortcut(event: KeyboardEvent): ShortcutCapture {
|
||||
const modifiers: SupportedModifier[] = [];
|
||||
if (event.ctrlKey) modifiers.push('ctrl');
|
||||
if (event.metaKey) modifiers.push('meta');
|
||||
if (event.altKey) modifiers.push('alt');
|
||||
if (event.shiftKey) modifiers.push('shift');
|
||||
|
||||
const keyToken = mapKeyToToken(event.key);
|
||||
if (!keyToken) {
|
||||
return { shortcut: null, keyToken: null, modifiers };
|
||||
}
|
||||
|
||||
if (modifiers.length === 0) {
|
||||
return { shortcut: null, keyToken, modifiers };
|
||||
}
|
||||
|
||||
const shortcut = normalizeTokens(modifiers, keyToken);
|
||||
return { shortcut, keyToken, modifiers };
|
||||
}
|
||||
|
||||
export function formatShortcutParts(shortcut: string, isMac: boolean): string[] {
|
||||
const normalized = normalizeShortcutString(shortcut);
|
||||
if (!normalized) return [];
|
||||
|
||||
const parts = normalized.split('+');
|
||||
const keyToken = parts.pop();
|
||||
if (!keyToken) return [];
|
||||
|
||||
const modifierSymbols = parts
|
||||
.map(part => part as SupportedModifier)
|
||||
.sort((a, b) => DISPLAY_ORDER.indexOf(a) - DISPLAY_ORDER.indexOf(b))
|
||||
.map(part => (isMac ? MAC_SYMBOLS[part] : DEFAULT_SYMBOLS[part]));
|
||||
|
||||
const keyLabel = SPECIAL_KEY_LABELS[keyToken] || keyToken.toUpperCase();
|
||||
|
||||
return [...modifierSymbols, keyLabel];
|
||||
}
|
||||
|
||||
export function isEditableElement(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
const tag = target.tagName.toLowerCase();
|
||||
if (tag === 'input' || tag === 'textarea' || tag === 'select') return true;
|
||||
if (target.isContentEditable) return true;
|
||||
return !!target.closest('[contenteditable="true"]');
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user