mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 16:13:15 +00:00
feat: add UI hotkey wiring and Cmd/Ctrl+B sidebar toggle (#1514)
* feat: add UI hotkey wiring and Cmd/Ctrl+B sidebar toggle Add a reusable useHotkey hook for registering global keyboard shortcuts (with 'mod' resolving to Cmd on macOS, Ctrl elsewhere), and wire up the first shortcut: mod+b toggles the left sidebar. Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> * fix: ignore key repeat for sidebar toggle hotkey Holding Cmd/Ctrl+B fired repeated keydown events, toggling the sidebar multiple times. Add an ignoreRepeat option to useHotkey and enable it for the sidebar toggle so one held press toggles once. Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> --------- Co-authored-by: Brendan Whiting <16016903+bwhiting2356@users.noreply.github.com> Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> Co-authored-by: Johannes du Plessis <51395795+johannes117@users.noreply.github.com>
This commit is contained in:
parent
258b5b4034
commit
7dac89a3b4
2 changed files with 126 additions and 0 deletions
|
|
@ -1,6 +1,7 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { SidebarSimpleIcon } from "@phosphor-icons/react";
|
||||
|
||||
import { useHotkey } from "@/lib/hotkeys";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const STORAGE_WIDTH = "open-swe.sidebar.width";
|
||||
|
|
@ -43,6 +44,8 @@ export function useSidebarLayout() {
|
|||
|
||||
const toggle = useCallback(() => setCollapsed(!collapsed), [collapsed, setCollapsed]);
|
||||
|
||||
useHotkey("mod+b", toggle, { enableInFormFields: true, ignoreRepeat: true });
|
||||
|
||||
const closeOnMobile = useCallback(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
// State-only: don't persist, so the desktop collapsed preference is preserved.
|
||||
|
|
|
|||
123
ui/src/lib/hotkeys.ts
Normal file
123
ui/src/lib/hotkeys.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import { useEffect, useRef } from "react"
|
||||
|
||||
export interface HotkeyOptions {
|
||||
enabled?: boolean
|
||||
preventDefault?: boolean
|
||||
enableInFormFields?: boolean
|
||||
ignoreRepeat?: boolean
|
||||
}
|
||||
|
||||
interface ParsedCombo {
|
||||
key: string
|
||||
mod: boolean
|
||||
meta: boolean
|
||||
ctrl: boolean
|
||||
alt: boolean
|
||||
shift: boolean
|
||||
}
|
||||
|
||||
function isMac(): boolean {
|
||||
if (typeof navigator === "undefined") return false
|
||||
return /mac|iphone|ipad|ipod/i.test(navigator.platform || navigator.userAgent)
|
||||
}
|
||||
|
||||
/** Parse a combo string like "mod+b" or "shift+escape" into its parts. */
|
||||
function parseCombo(combo: string): ParsedCombo {
|
||||
const parsed: ParsedCombo = {
|
||||
key: "",
|
||||
mod: false,
|
||||
meta: false,
|
||||
ctrl: false,
|
||||
alt: false,
|
||||
shift: false,
|
||||
}
|
||||
for (const part of combo
|
||||
.toLowerCase()
|
||||
.split("+")
|
||||
.map((p) => p.trim())) {
|
||||
switch (part) {
|
||||
case "":
|
||||
break
|
||||
case "mod":
|
||||
parsed.mod = true
|
||||
break
|
||||
case "meta":
|
||||
case "cmd":
|
||||
case "command":
|
||||
parsed.meta = true
|
||||
break
|
||||
case "ctrl":
|
||||
case "control":
|
||||
parsed.ctrl = true
|
||||
break
|
||||
case "alt":
|
||||
case "option":
|
||||
parsed.alt = true
|
||||
break
|
||||
case "shift":
|
||||
parsed.shift = true
|
||||
break
|
||||
default:
|
||||
parsed.key = part
|
||||
}
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
function eventMatchesCombo(event: KeyboardEvent, combo: ParsedCombo): boolean {
|
||||
if (event.key.toLowerCase() !== combo.key) return false
|
||||
const mac = isMac()
|
||||
const expectMeta = combo.meta || (combo.mod && mac)
|
||||
const expectCtrl = combo.ctrl || (combo.mod && !mac)
|
||||
return (
|
||||
event.metaKey === expectMeta &&
|
||||
event.ctrlKey === expectCtrl &&
|
||||
event.altKey === combo.alt &&
|
||||
event.shiftKey === combo.shift
|
||||
)
|
||||
}
|
||||
|
||||
function isFormField(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) return false
|
||||
return (
|
||||
target.tagName === "INPUT" ||
|
||||
target.tagName === "TEXTAREA" ||
|
||||
target.tagName === "SELECT" ||
|
||||
target.isContentEditable
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a global keyboard shortcut. Use "mod" for the platform meta key
|
||||
* (Cmd on macOS, Ctrl elsewhere). Accepts one combo or several aliases.
|
||||
*/
|
||||
export function useHotkey(
|
||||
combo: string | string[],
|
||||
handler: (event: KeyboardEvent) => void,
|
||||
options: HotkeyOptions = {}
|
||||
) {
|
||||
const {
|
||||
enabled = true,
|
||||
preventDefault = true,
|
||||
enableInFormFields = false,
|
||||
ignoreRepeat = false,
|
||||
} = options
|
||||
const handlerRef = useRef(handler)
|
||||
handlerRef.current = handler
|
||||
|
||||
const comboKey = Array.isArray(combo) ? combo.join(",") : combo
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || typeof window === "undefined") return
|
||||
const combos = comboKey.split(",").map(parseCombo)
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (ignoreRepeat && event.repeat) return
|
||||
if (!enableInFormFields && isFormField(event.target)) return
|
||||
if (!combos.some((c) => eventMatchesCombo(event, c))) return
|
||||
if (preventDefault) event.preventDefault()
|
||||
handlerRef.current(event)
|
||||
}
|
||||
window.addEventListener("keydown", onKeyDown)
|
||||
return () => window.removeEventListener("keydown", onKeyDown)
|
||||
}, [enabled, preventDefault, enableInFormFields, ignoreRepeat, comboKey])
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue