diff --git a/apps/sim/app/workspace/[workspaceId]/providers/global-commands-provider.test.tsx b/apps/sim/app/workspace/[workspaceId]/providers/global-commands-provider.test.tsx index 84dfd588e18..e6bf085f9ff 100644 --- a/apps/sim/app/workspace/[workspaceId]/providers/global-commands-provider.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/providers/global-commands-provider.test.tsx @@ -19,6 +19,11 @@ function RegisterModK({ handler }: { handler: () => void }) { return null } +function RegisterModKOutsideEditable({ handler }: { handler: () => void }) { + useRegisterGlobalCommands([{ id: 'search', shortcut: 'Mod+K', handler, allowInEditable: false }]) + return null +} + let container: HTMLDivElement let root: Root @@ -87,3 +92,45 @@ describe('GlobalCommandsProvider owned-shortcut yielding', () => { expect(handler).toHaveBeenCalledTimes(1) }) }) + +describe('GlobalCommandsProvider editable guard', () => { + it('skips a non-editable command when focus is in an input', () => { + const handler = vi.fn() + mount( + + + + + ) + ;(container.querySelector('input') as HTMLInputElement).focus() + pressModK() + expect(handler).not.toHaveBeenCalled() + }) + + it('skips a non-editable command when focus is in a contenteditable element', () => { + const handler = vi.fn() + mount( + + +
+ + ) + ;(container.querySelector('[contenteditable]') as HTMLElement).focus() + pressModK() + expect(handler).toHaveBeenCalledTimes(0) + }) + + it('fires a non-editable command when focus is in a contenteditable="false" element', () => { + const handler = vi.fn() + mount( + + + {/* biome-ignore lint/a11y/noNoninteractiveTabindex: focusable stand-in for a read-only editor */} +
+ + ) + ;(container.querySelector('[contenteditable]') as HTMLElement).focus() + pressModK() + expect(handler).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/providers/global-commands-provider.tsx b/apps/sim/app/workspace/[workspaceId]/providers/global-commands-provider.tsx index 039cda318a7..b03ce328735 100644 --- a/apps/sim/app/workspace/[workspaceId]/providers/global-commands-provider.tsx +++ b/apps/sim/app/workspace/[workspaceId]/providers/global-commands-provider.tsx @@ -93,6 +93,18 @@ function shortcutSignature(parsed: ParsedShortcut, isMac: boolean): string { return `${parsed.key}|${+ctrl}|${+meta}|${+!!parsed.shift}|${+!!parsed.alt}` } +/** + * Whether `element` is an editable region for the purposes of the editable guard. + * `contenteditable="false"` (e.g. a rich-text editor in read-only mode) is not editable, + * so commands with `allowInEditable: false` still fire while such an element has focus. + */ +function isEditableElement(element: Element | null): boolean { + if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) return true + if (!(element instanceof HTMLElement)) return false + const contentEditable = element.getAttribute('contenteditable') + return contentEditable !== null && contentEditable.toLowerCase() !== 'false' +} + /** * Whether the focused element (or an ancestor) declares it owns `parsed` via a comma-separated * `data-owned-shortcuts` attribute (e.g. a rich-text editor that binds `Mod+K` to links). Such a @@ -141,14 +153,7 @@ export function GlobalCommandsProvider({ children }: { children: ReactNode }) { if (e.isComposing) return for (const [, cmd] of registryRef.current) { - if (!cmd.allowInEditable) { - const ae = document.activeElement - const isEditable = - ae instanceof HTMLInputElement || - ae instanceof HTMLTextAreaElement || - ae?.hasAttribute('contenteditable') - if (isEditable) continue - } + if (!cmd.allowInEditable && isEditableElement(document.activeElement)) continue if (matchesShortcut(e, cmd.parsed)) { if (focusedElementOwnsShortcut(cmd.parsed, isMac)) continue diff --git a/apps/sim/app/workspace/[workspaceId]/utils/commands-utils.ts b/apps/sim/app/workspace/[workspaceId]/utils/commands-utils.ts index 24a6eead3ad..4aabc11bc9e 100644 --- a/apps/sim/app/workspace/[workspaceId]/utils/commands-utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/utils/commands-utils.ts @@ -17,6 +17,7 @@ export type CommandId = | 'clear-terminal-console' | 'focus-toolbar-search' | 'fit-to-view' + | 'toggle-sidebar' /** * Static metadata for a global command. @@ -92,6 +93,11 @@ export const COMMAND_DEFINITIONS: Record = { shortcut: 'Mod+Shift+F', allowInEditable: false, }, + 'toggle-sidebar': { + id: 'toggle-sidebar', + shortcut: 'Mod+B', + allowInEditable: false, + }, } /** diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx index 6929727cc15..66a04dfd0d4 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx @@ -120,18 +120,20 @@ export function SidebarTooltip({ label, enabled, side = 'right', + shortcut, }: { children: React.ReactElement label: string enabled: boolean side?: 'right' | 'bottom' + shortcut?: string }) { if (!enabled) return children return ( {children} -

{label}

+ {shortcut ? {label} :

{label}

}
) @@ -1234,6 +1236,12 @@ export const Sidebar = memo(function Sidebar({ isCollapsed }: SidebarProps) { handleCreateWorkflow() }, }, + { + id: 'toggle-sidebar', + handler: () => { + toggleCollapsed() + }, + }, ]) ) @@ -1284,7 +1292,12 @@ export const Sidebar = memo(function Sidebar({ isCollapsed }: SidebarProps) { isCollapsed={isCollapsed} onExpandSidebar={toggleCollapsed} /> - +