feat(frontend): add configurable tool hotkeys

This commit is contained in:
Anthony Stirling 2025-09-27 22:29:46 +01:00
parent 30987dcad2
commit 64a19790be
8 changed files with 767 additions and 14 deletions

View File

@ -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>

View 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;

View 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;

View 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;

View File

@ -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>
);
});

View File

@ -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 = (
<>

View 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;
}

View 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"]');
}