From 64a19790bef81a995ced684b858b3b5c45aaa5fc Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Sat, 27 Sep 2025 22:29:46 +0100 Subject: [PATCH] feat(frontend): add configurable tool hotkeys --- frontend/src/App.tsx | 21 +- .../components/hotkeys/ShortcutDisplay.tsx | 28 ++ .../settings/HotkeySettingsSection.tsx | 220 +++++++++++++ .../src/components/settings/SettingsModal.tsx | 25 ++ .../src/components/shared/QuickAccessBar.tsx | 5 +- .../tools/toolPicker/ToolButton.tsx | 26 +- frontend/src/contexts/HotkeyContext.tsx | 292 ++++++++++++++++++ frontend/src/utils/hotkeys.ts | 164 ++++++++++ 8 files changed, 767 insertions(+), 14 deletions(-) create mode 100644 frontend/src/components/hotkeys/ShortcutDisplay.tsx create mode 100644 frontend/src/components/settings/HotkeySettingsSection.tsx create mode 100644 frontend/src/components/settings/SettingsModal.tsx create mode 100644 frontend/src/contexts/HotkeyContext.tsx create mode 100644 frontend/src/utils/hotkeys.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index da9a14eb5..af2e5c90b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { - - - - - - - - - + + + + + + + + + + + diff --git a/frontend/src/components/hotkeys/ShortcutDisplay.tsx b/frontend/src/components/hotkeys/ShortcutDisplay.tsx new file mode 100644 index 000000000..e1702c702 --- /dev/null +++ b/frontend/src/components/hotkeys/ShortcutDisplay.tsx @@ -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 ( + + {parts.map((part, index) => ( + + {part} + + ))} + + ); +} + +export default ShortcutDisplay; diff --git a/frontend/src/components/settings/HotkeySettingsSection.tsx b/frontend/src/components/settings/HotkeySettingsSection.tsx new file mode 100644 index 000000000..f7399bc09 --- /dev/null +++ b/frontend/src/components/settings/HotkeySettingsSection.tsx @@ -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(null); + const [errorMessage, setErrorMessage] = useState(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 ( + + + Press the new shortcut (Esc to cancel) + + {errorMessage && ( + + {errorMessage} + + )} + + ); + } + + if (!shortcut) { + return Not assigned; + } + + return ; + }; + + return ( + + +
+ + Keyboard Shortcuts + + + Click change to set a custom shortcut. Use modifiers like {platform === 'mac' ? '⌘' : 'Ctrl'} + {platform === 'mac' ? '⌥' : 'Alt'} + Shift to avoid conflicts. + +
+ +
+ + + + + Tool + Shortcut + Actions + + + + {tools.map(([toolId, tool]) => { + const isEditing = editingTool === toolId; + const hasCustom = toolId in customHotkeys; + + return ( + + + + {tool.name} + + {tool.description} + + + + {renderShortcutCell(toolId)} + + + {isEditing ? ( + + ) : ( + + )} + + + + + ); + })} + +
+ + {tools.length === 0 && ( + + No tools available for configuration. + + )} + + {editingTool && ( + + Press the new key combination now. Use Escape to cancel. + + )} +
+ ); +} + +export default HotkeySettingsSection; diff --git a/frontend/src/components/settings/SettingsModal.tsx b/frontend/src/components/settings/SettingsModal.tsx new file mode 100644 index 000000000..97e755a89 --- /dev/null +++ b/frontend/src/components/settings/SettingsModal.tsx @@ -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 ( + + + + Hotkeys + + + + + + + ); +} + +export default SettingsModal; diff --git a/frontend/src/components/shared/QuickAccessBar.tsx b/frontend/src/components/shared/QuickAccessBar.tsx index 2ebe45002..21afc724a 100644 --- a/frontend/src/components/shared/QuickAccessBar.tsx +++ b/frontend/src/components/shared/QuickAccessBar.tsx @@ -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((_, ref) => { - {/* setConfigModalOpen(false)} - /> */} + /> ); }); diff --git a/frontend/src/components/tools/toolPicker/ToolButton.tsx b/frontend/src/components/tools/toolPicker/ToolButton.tsx index 236cbb49f..fd9f4290f 100644 --- a/frontend/src/components/tools/toolPicker/ToolButton.tsx +++ b/frontend/src/components/tools/toolPicker/ToolButton.tsx @@ -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 = ({ 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 = ({ 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 - ? (Coming soon: {tool.description}) - : tool.description; + const tooltipContent = ( +
+ {isUnavailable ? ( + Coming soon: {tool.description} + ) : ( + {tool.description} + )} + {!isUnavailable && shortcut && ( +
+ + {t('settings.hotkeys.shortcutLabel', 'Shortcut')}: + + +
+ )} +
+ ); const buttonContent = ( <> diff --git a/frontend/src/contexts/HotkeyContext.tsx b/frontend/src/contexts/HotkeyContext.tsx new file mode 100644 index 000000000..cf675b399 --- /dev/null +++ b/frontend/src/contexts/HotkeyContext.tsx @@ -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; + +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(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).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, + 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, + [toolRegistry] + ); + const isMac = useMemo(() => detectIsMac(), []); + + const [customHotkeys, setCustomHotkeys] = useState(() => 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(); + 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(() => ({ + 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 {children}; +} + +export function useHotkeys(): HotkeyContextValue { + const context = useContext(HotkeyContext); + if (!context) { + throw new Error('useHotkeys must be used within a HotkeyProvider'); + } + return context; +} diff --git a/frontend/src/utils/hotkeys.ts b/frontend/src/utils/hotkeys.ts new file mode 100644 index 000000000..b323e3fb0 --- /dev/null +++ b/frontend/src/utils/hotkeys.ts @@ -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 = { + meta: '⌘', + ctrl: '⌃', + alt: '⌥', + shift: '⇧', +}; + +const DEFAULT_SYMBOLS: Record = { + meta: 'Win', + ctrl: 'Ctrl', + alt: 'Alt', + shift: 'Shift', +}; + +const SPECIAL_KEY_LABELS: Record = { + 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"]'); +}