diff --git a/ui/src/App.tsx b/ui/src/App.tsx index d9cc252a..12a41381 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -13,6 +13,7 @@ import { Presentation, Search as SearchIcon, Sun, + HelpCircle, } from "lucide-react"; import { undoFileOp } from "@kw/stores/fileOpsStore"; import type { KiwiTreeHandle } from "./components/KiwiTree"; @@ -47,7 +48,14 @@ import { usePinnedPages } from "./hooks/usePinnedPages"; import { useKeybindings } from "./hooks/useKeybindings"; import { useUIConfig } from "./hooks/useUIConfig"; import { usePreferences } from "./hooks/usePreferences"; -import { formatChordDisplay, matchBoundAction, type KeybindingAction } from "./lib/kiwiKeybindings"; +import { + formatChordDisplay, + isShortcutBlockedTarget, + isStandaloneHelpKey, + matchBoundAction, + shouldDispatchKeybinding, + type KeybindingAction, +} from "./lib/kiwiKeybindings"; import { resolveOverlayDismiss } from "./lib/overlayDismiss"; import { hasDeepLinkPath, resolveDashboardPath, resolveStartPage, shouldApplyStartPage } from "./lib/startPage"; import { formatDocumentTitle } from "./lib/pageTitle"; @@ -312,10 +320,30 @@ export default function App() { useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.defaultPrevented) return; + const state = stateRef.current; + + if ( + isStandaloneHelpKey(e) && + !isShortcutBlockedTarget(e.target) && + !state.editing + ) { + e.preventDefault(); + setShortcutsOpen((v) => !v); + return; + } + const action = matchBoundAction(e, bindings); if (!action) return; - const state = stateRef.current; + if ( + !shouldDispatchKeybinding(action, e.target, { + editing: state.editing, + splitEditing: false, + }) + ) { + return; + } + switch (action) { case "search": e.preventDefault(); @@ -966,6 +994,25 @@ const handleSpaceSwitch = useCallback(() => { bindings={bindings} conflicts={conflicts} /> + {!shortcutsOpen && ( + + + + + + Keyboard shortcuts ({formatChordDisplay(bindings.shortcuts_help)}) + + + )} ); } diff --git a/ui/src/components/KeyboardShortcuts.tsx b/ui/src/components/KeyboardShortcuts.tsx index 56589edb..96d5c25c 100644 --- a/ui/src/components/KeyboardShortcuts.tsx +++ b/ui/src/components/KeyboardShortcuts.tsx @@ -1,13 +1,16 @@ import { Keyboard } from "lucide-react"; import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, -} from "@kw/components/ui/dialog"; + CommandDialog, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@kw/components/ui/command"; import { formatChordDisplay, SHORTCUT_SECTIONS, + shortcutSearchValue, type KeybindingAction, } from "../lib/kiwiKeybindings"; @@ -18,45 +21,51 @@ type Props = { conflicts?: { chord: string; actions: string[] }[]; }; +function ShortcutKeys({ chord }: { chord: string }) { + return ( + + {formatChordDisplay(chord)} + + ); +} + export function KeyboardShortcuts({ open, onOpenChange, bindings, conflicts = [] }: Props) { return ( - - - - - - Keyboard shortcuts - - - {conflicts.length > 0 && ( -
- Conflicting bindings detected:{" "} - {conflicts.map((c) => `${c.actions.join(" / ")} (${formatChordDisplay(c.chord)})`).join("; ")} -
- )} -
- {SHORTCUT_SECTIONS.map((s) => ( -
-
- {s.section} -
-
- {s.items.map((item) => ( -
- {item.label} - - {formatChordDisplay(bindings[item.action])} - -
- ))} -
-
- ))} + +
+ + Keyboard shortcuts +
+ {conflicts.length > 0 && ( +
+ Conflicting bindings detected:{" "} + {conflicts.map((c) => `${c.actions.join(" / ")} (${formatChordDisplay(c.chord)})`).join("; ")}
- -
+ )} + + + No shortcuts found. + {SHORTCUT_SECTIONS.map((section) => ( + + {section.items.map((item) => ( + {}} + > + {item.label} + + + ))} + + ))} + + ); } diff --git a/ui/src/lib/kiwiKeybindings.test.ts b/ui/src/lib/kiwiKeybindings.test.ts index ec8f4c94..378d88ee 100644 --- a/ui/src/lib/kiwiKeybindings.test.ts +++ b/ui/src/lib/kiwiKeybindings.test.ts @@ -4,9 +4,14 @@ import { buildChordIndex, eventMatchesChord, formatChordDisplay, + isMacPlatform, + isShortcutBlockedTarget, + isStandaloneHelpKey, + isTypingTarget, matchBoundAction, mergeKeybindings, normalizeChord, + shouldDispatchKeybinding, } from "./kiwiKeybindings"; describe("normalizeChord", () => { @@ -101,3 +106,69 @@ describe("formatChordDisplay", () => { expect(formatChordDisplay("mod+k")).toMatch(/K/i); }); }); + +describe("isTypingTarget", () => { + it("detects input elements", () => { + const input = { tagName: "INPUT", isContentEditable: false } as HTMLElement; + const button = { tagName: "BUTTON", isContentEditable: false } as HTMLElement; + expect(isTypingTarget(input)).toBe(true); + expect(isTypingTarget(button)).toBe(false); + }); +}); + +describe("isShortcutBlockedTarget", () => { + it("blocks CodeMirror and ProseMirror editor surfaces", () => { + const cmChild = { + tagName: "DIV", + isContentEditable: false, + closest: (sel: string) => (sel.includes("cm-editor") ? {} : null), + } as unknown as HTMLElement; + const proseChild = { + tagName: "P", + isContentEditable: false, + closest: (sel: string) => (sel.includes("ProseMirror") ? {} : null), + } as unknown as HTMLElement; + expect(isShortcutBlockedTarget(cmChild)).toBe(true); + expect(isShortcutBlockedTarget(proseChild)).toBe(true); + }); +}); + +describe("isStandaloneHelpKey", () => { + it("matches bare question mark without modifiers", () => { + const help = { + key: "?", + metaKey: false, + ctrlKey: false, + altKey: false, + shiftKey: true, + } as KeyboardEvent; + const modHelp = { ...help, metaKey: true } as KeyboardEvent; + expect(isStandaloneHelpKey(help)).toBe(true); + expect(isStandaloneHelpKey(modHelp)).toBe(false); + }); +}); + +describe("shouldDispatchKeybinding", () => { + it("allows help and close overlay while typing", () => { + const input = { tagName: "INPUT", isContentEditable: false } as HTMLElement; + expect(shouldDispatchKeybinding("shortcuts_help", input, { editing: true, splitEditing: false })).toBe(true); + expect(shouldDispatchKeybinding("close_overlay", input, { editing: true, splitEditing: false })).toBe(true); + }); + + it("blocks navigation shortcuts in inputs and while editing", () => { + const input = { tagName: "INPUT", isContentEditable: false } as HTMLElement; + const body = { tagName: "BODY", isContentEditable: false, closest: () => null } as unknown as HTMLElement; + expect(shouldDispatchKeybinding("search", input, { editing: false, splitEditing: false })).toBe(false); + expect(shouldDispatchKeybinding("search", body, { editing: true, splitEditing: false })).toBe(false); + expect(shouldDispatchKeybinding("search", body, { editing: false, splitEditing: false })).toBe(true); + }); +}); + +describe("isMacPlatform", () => { + it("detects macOS from platform string", () => { + const original = navigator.platform; + Object.defineProperty(navigator, "platform", { value: "MacIntel", configurable: true }); + expect(isMacPlatform()).toBe(true); + Object.defineProperty(navigator, "platform", { value: original, configurable: true }); + }); +}); diff --git a/ui/src/lib/kiwiKeybindings.ts b/ui/src/lib/kiwiKeybindings.ts index 6c6bc94a..d116311e 100644 --- a/ui/src/lib/kiwiKeybindings.ts +++ b/ui/src/lib/kiwiKeybindings.ts @@ -129,8 +129,15 @@ export function eventMatchesChord(e: KeyboardEvent, chord: string): boolean { return eventKey === parsed.key; } +export function isMacPlatform(): boolean { + if (typeof navigator === "undefined") return false; + const ua = navigator as Navigator & { userAgentData?: { platform?: string } }; + const platform = ua.userAgentData?.platform ?? navigator.platform; + return /mac/i.test(platform); +} + export function formatChordDisplay(chord: string): string { - const isMac = typeof navigator !== "undefined" && navigator.platform.includes("Mac"); + const isMac = isMacPlatform(); const parsed = parseChord(chord); const parts: string[] = []; if (parsed.mod) parts.push(isMac ? "⌘" : "Ctrl"); @@ -168,6 +175,46 @@ export type ShortcutSection = { items: { action: KeybindingAction; label: string }[]; }; +export function isTypingTarget(target: EventTarget | null): boolean { + if (!target || typeof target !== "object") return false; + const el = target as HTMLElement; + if (el.isContentEditable) return true; + const tag = el.tagName ?? ""; + return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT"; +} + +const SHORTCUT_BLOCKED_SELECTOR = ".cm-editor, .ProseMirror, [cmdk-input-wrapper]"; + +export function isShortcutBlockedTarget(target: EventTarget | null): boolean { + if (isTypingTarget(target)) return true; + const el = target as HTMLElement | null; + if (el && typeof el.closest === "function" && el.closest(SHORTCUT_BLOCKED_SELECTOR)) return true; + return false; +} + +/** Bare `?` (no modifiers) opens the cheat sheet when not typing in an editor/input. */ +export function isStandaloneHelpKey(e: KeyboardEvent): boolean { + if (e.metaKey || e.ctrlKey || e.altKey) return false; + const key = e.key.length === 1 ? e.key.toLowerCase() : e.key.toLowerCase(); + return key === "?"; +} + +export function shouldDispatchKeybinding( + action: KeybindingAction, + target: EventTarget | null, + ctx: { editing: boolean; splitEditing: boolean }, +): boolean { + if (action === "close_overlay" || action === "shortcuts_help") return true; + if (action === "save" || action === "toggle_mode") return true; + if (isShortcutBlockedTarget(target)) return false; + if (ctx.editing || ctx.splitEditing) return false; + return true; +} + +export function shortcutSearchValue(section: string, label: string, chord: string): string { + return `${label} ${section} ${formatChordDisplay(chord)} ${chord}`; +} + export const SHORTCUT_SECTIONS: ShortcutSection[] = [ { section: "Navigation",