mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-08 22:48:14 -05:00
fix(shortcut): fixed global keyboard commands provider to follow latest ref pattern (#2569)
* fix(shortcut): fixed global commands provider to follow best practices * cleanup * ack PR comment
This commit is contained in:
@@ -14,11 +14,6 @@ import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('GlobalCommands')
|
||||
|
||||
/**
|
||||
* Detects if the current platform is macOS.
|
||||
*
|
||||
* @returns True if running on macOS, false otherwise
|
||||
*/
|
||||
function isMacPlatform(): boolean {
|
||||
if (typeof window === 'undefined') return false
|
||||
return (
|
||||
@@ -27,18 +22,6 @@ function isMacPlatform(): boolean {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a parsed keyboard shortcut.
|
||||
*
|
||||
* We support the following modifiers:
|
||||
* - Mod: maps to Meta on macOS, Ctrl on other platforms
|
||||
* - Ctrl, Meta, Shift, Alt
|
||||
*
|
||||
* Examples:
|
||||
* - "Mod+A"
|
||||
* - "Mod+Shift+T"
|
||||
* - "Meta+K"
|
||||
*/
|
||||
export interface ParsedShortcut {
|
||||
key: string
|
||||
mod?: boolean
|
||||
@@ -48,24 +31,10 @@ export interface ParsedShortcut {
|
||||
alt?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Declarative command registration.
|
||||
*/
|
||||
export interface GlobalCommand {
|
||||
/** Unique id for the command. If omitted, one is generated. */
|
||||
id?: string
|
||||
/** Shortcut string in the form "Mod+Shift+T", "Mod+A", "Meta+K", etc. */
|
||||
shortcut: string
|
||||
/**
|
||||
* Whether to allow the command to run inside editable elements like inputs,
|
||||
* textareas or contenteditable. Defaults to true to ensure browser defaults
|
||||
* are overridden when desired.
|
||||
*/
|
||||
allowInEditable?: boolean
|
||||
/**
|
||||
* Handler invoked when the shortcut is matched. Use this to trigger actions
|
||||
* like navigation or dispatching application events.
|
||||
*/
|
||||
handler: (event: KeyboardEvent) => void
|
||||
}
|
||||
|
||||
@@ -80,16 +49,13 @@ interface GlobalCommandsContextValue {
|
||||
|
||||
const GlobalCommandsContext = createContext<GlobalCommandsContextValue | null>(null)
|
||||
|
||||
/**
|
||||
* Parses a human-readable shortcut into a structured representation.
|
||||
*/
|
||||
function parseShortcut(shortcut: string): ParsedShortcut {
|
||||
const parts = shortcut.split('+').map((p) => p.trim())
|
||||
const modifiers = new Set(parts.slice(0, -1).map((p) => p.toLowerCase()))
|
||||
const last = parts[parts.length - 1]
|
||||
|
||||
return {
|
||||
key: last.length === 1 ? last.toLowerCase() : last, // keep non-letter keys verbatim
|
||||
key: last.length === 1 ? last.toLowerCase() : last,
|
||||
mod: modifiers.has('mod'),
|
||||
ctrl: modifiers.has('ctrl'),
|
||||
meta: modifiers.has('meta') || modifiers.has('cmd') || modifiers.has('command'),
|
||||
@@ -98,16 +64,10 @@ function parseShortcut(shortcut: string): ParsedShortcut {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a KeyboardEvent matches a parsed shortcut, honoring platform-specific
|
||||
* interpretation of "Mod" (Meta on macOS, Ctrl elsewhere).
|
||||
*/
|
||||
function matchesShortcut(e: KeyboardEvent, parsed: ParsedShortcut): boolean {
|
||||
const isMac = isMacPlatform()
|
||||
const expectedCtrl = parsed.ctrl || (parsed.mod ? !isMac : false)
|
||||
const expectedMeta = parsed.meta || (parsed.mod ? isMac : false)
|
||||
|
||||
// Normalize key for comparison: for letters compare lowercase
|
||||
const eventKey = e.key.length === 1 ? e.key.toLowerCase() : e.key
|
||||
|
||||
return (
|
||||
@@ -119,10 +79,6 @@ function matchesShortcut(e: KeyboardEvent, parsed: ParsedShortcut): boolean {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider that captures global keyboard shortcuts and routes them to
|
||||
* registered commands. Commands can be registered from any descendant component.
|
||||
*/
|
||||
export function GlobalCommandsProvider({ children }: { children: ReactNode }) {
|
||||
const registryRef = useRef<Map<string, RegistryCommand>>(new Map())
|
||||
const isMac = useMemo(() => isMacPlatform(), [])
|
||||
@@ -140,13 +96,11 @@ export function GlobalCommandsProvider({ children }: { children: ReactNode }) {
|
||||
allowInEditable: cmd.allowInEditable ?? true,
|
||||
})
|
||||
createdIds.push(id)
|
||||
logger.info('Registered global command', { id, shortcut: cmd.shortcut })
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (const id of createdIds) {
|
||||
registryRef.current.delete(id)
|
||||
logger.info('Unregistered global command', { id })
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
@@ -155,8 +109,6 @@ export function GlobalCommandsProvider({ children }: { children: ReactNode }) {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.isComposing) return
|
||||
|
||||
// Evaluate matches in registration order (latest registration wins naturally
|
||||
// due to replacement on same id). Break on first match.
|
||||
for (const [, cmd] of registryRef.current) {
|
||||
if (!cmd.allowInEditable) {
|
||||
const ae = document.activeElement
|
||||
@@ -168,16 +120,8 @@ export function GlobalCommandsProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
|
||||
if (matchesShortcut(e, cmd.parsed)) {
|
||||
// Always override default browser behavior for matched commands.
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
logger.info('Executing global command', {
|
||||
id: cmd.id,
|
||||
shortcut: cmd.shortcut,
|
||||
key: e.key,
|
||||
isMac,
|
||||
path: typeof window !== 'undefined' ? window.location.pathname : undefined,
|
||||
})
|
||||
try {
|
||||
cmd.handler(e)
|
||||
} catch (err) {
|
||||
@@ -197,22 +141,28 @@ export function GlobalCommandsProvider({ children }: { children: ReactNode }) {
|
||||
return <GlobalCommandsContext.Provider value={value}>{children}</GlobalCommandsContext.Provider>
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a set of global commands for the lifetime of the component.
|
||||
*
|
||||
* Returns nothing; cleanup is automatic on unmount.
|
||||
*/
|
||||
export function useRegisterGlobalCommands(commands: GlobalCommand[] | (() => GlobalCommand[])) {
|
||||
const ctx = useContext(GlobalCommandsContext)
|
||||
if (!ctx) {
|
||||
throw new Error('useRegisterGlobalCommands must be used within GlobalCommandsProvider')
|
||||
}
|
||||
|
||||
const commandsRef = useRef<GlobalCommand[]>([])
|
||||
const list = typeof commands === 'function' ? commands() : commands
|
||||
commandsRef.current = list
|
||||
|
||||
useEffect(() => {
|
||||
const list = typeof commands === 'function' ? commands() : commands
|
||||
const unregister = ctx.register(list)
|
||||
const wrappedCommands = commandsRef.current.map((cmd) => ({
|
||||
...cmd,
|
||||
handler: (event: KeyboardEvent) => {
|
||||
const currentCmd = commandsRef.current.find((c) => c.id === cmd.id)
|
||||
if (currentCmd) {
|
||||
currentCmd.handler(event)
|
||||
}
|
||||
},
|
||||
}))
|
||||
const unregister = ctx.register(wrappedCommands)
|
||||
return unregister
|
||||
// We intentionally want to register once for the given commands
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
}
|
||||
|
||||
@@ -1055,7 +1055,7 @@ export function Chat() {
|
||||
{isStreaming ? (
|
||||
<Button
|
||||
onClick={handleStopStreaming}
|
||||
className='h-[22px] w-[22px] rounded-full p-0 transition-colors !bg-[var(--c-C0C0C0)] hover:!bg-[var(--c-D0D0D0)]'
|
||||
className='!bg-[var(--c-C0C0C0)] hover:!bg-[var(--c-D0D0D0)] h-[22px] w-[22px] rounded-full p-0 transition-colors'
|
||||
>
|
||||
<Square className='h-2.5 w-2.5 fill-black text-black' />
|
||||
</Button>
|
||||
|
||||
@@ -133,6 +133,13 @@ export function Panel() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels the currently executing workflow
|
||||
*/
|
||||
const cancelWorkflow = useCallback(async () => {
|
||||
await handleCancelExecution()
|
||||
}, [handleCancelExecution])
|
||||
|
||||
/**
|
||||
* Runs the workflow with usage limit check
|
||||
*/
|
||||
@@ -144,13 +151,6 @@ export function Panel() {
|
||||
await handleRunWorkflow()
|
||||
}, [usageExceeded, handleRunWorkflow])
|
||||
|
||||
/**
|
||||
* Cancels the currently executing workflow
|
||||
*/
|
||||
const cancelWorkflow = useCallback(async () => {
|
||||
await handleCancelExecution()
|
||||
}, [handleCancelExecution])
|
||||
|
||||
// Chat state
|
||||
const { isChatOpen, setIsChatOpen } = useChatStore()
|
||||
const { isOpen: isVariablesOpen, setIsOpen: setVariablesOpen } = useVariablesStore()
|
||||
@@ -300,7 +300,6 @@ export function Panel() {
|
||||
{
|
||||
id: 'run-workflow',
|
||||
handler: () => {
|
||||
// Do exactly what the Run button does
|
||||
if (isExecuting) {
|
||||
void cancelWorkflow()
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user