99dfbbde58
* feat(plugins): add keybindings and modal window capabilities
Add two independent plugin UI capabilities.
Keybindings: plugins declare keybindings in the manifest under
ui.keybindings ({id, default, description}), validated backend-side
(combo grammar + unique ids, requires a UI bundle) and exposed to the
frontend. Plugin JS binds a handler via registry.registerKeybinding(id,
handler); a global dispatcher fires it. Combos are user-overridable in
Settings > Keyboard Shortcuts (namespaced plugin:{pluginId}:{id}) with
conflict detection against core and other plugins.
Modals: host.openModal({title?, content, size?, dismissible?}) returns a
{ close() } handle and renders in a single host-owned Dialog isolated by
PluginErrorBoundary, cleaned up on plugin unload.
Fixture plugin, PLUGIN-API.md / spec, and a Playwright e2e demonstrate a
keybinding handler opening a modal.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(plugins): address review feedback on keybinding dispatch and validation
Core shortcuts now take precedence over plugin keybindings on a colliding
combo (defaultPrevented cooperation between the two capture-phase
dispatchers), a throwing plugin handler no longer aborts dispatch to other
plugins, and the keybinding combo grammar is tightened: the backend validator
and frontend parser both drop the undocumented control/super modifier
aliases and reject shift+digit/symbol combos that can never actually fire
(KeyboardEvent.key reports the shifted character, not the token).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(plugins): stop conflicting core shortcut from double-firing with plugin keybindings
The earlier defaultPrevented guard only covered the central useAppShortcuts
dispatcher. Per-component core shortcuts registered via useKeyboardShortcut
run in the bubble phase, after the capture-phase plugin keybinding dispatcher,
so a plugin-handled-and-prevented combo still triggered the core action too.
Guard useKeyboardShortcut (and use-editor-keybinds, another capture-phase core
listener) on event.defaultPrevented so exactly one action fires per combo.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(plugins): make core shortcuts always win over plugin keybindings
Panel-search, task-switcher, and other core listeners never checked
event.defaultPrevented, so a plugin bound to the same combo (e.g. Cmd/Ctrl+F)
could still fire alongside them. Instead of patching every core listener,
usePluginShortcuts now computes the set of effective core shortcut combos
(registry, resolved through user overrides, plus FIND_IN_PANEL/SAVE) and
skips invoking any plugin handler whose combo is in that set, so the
matching core listener always handles the event exactly once.
Also adds a hook-level regression test for useEditorKeybinds' existing
defaultPrevented guard, and fixes a leaked capture-phase window listener in
use-keyboard-shortcut.test.ts that could bleed into later tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(web): keep Cmd+K closing the command panel after shortcut-precedence guard
cmdk's built-in vim bindings intercept Ctrl/Cmd+K and +P as list-navigation
shortcuts and call preventDefault() before the event reaches our window-level
useKeyboardShortcut listeners. Once that hook started bailing on
event.defaultPrevented (added to fix core/plugin shortcut double-firing), the
palette's own Cmd+K close/reopen toggle got silently suppressed whenever focus
was inside the open panel. Disable cmdk's vim bindings on the command palette
so its Ctrl/Cmd+K and +P always reach the app-level toggle.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(plugins): document keybindings and modal window plugin APIs
Fill in the public author-facing docs that the internal plugin-keybindings
and modal-window contract (PLUGIN-API.md, spec.md) already covers: the
registry.registerKeybinding / host.openModal JS surface, the ui.keybindings
manifest field with its combo grammar, and a capability mention on the
Plugins overview page.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
176 lines
4.8 KiB
TypeScript
176 lines
4.8 KiB
TypeScript
/**
|
|
* Keyboard shortcut utility functions
|
|
*/
|
|
|
|
import type { KeyboardShortcut, Platform } from "./constants";
|
|
|
|
/**
|
|
* Returns true if the keydown event's target is a text input, textarea, or
|
|
* contenteditable element. Shared by every global keydown dispatcher
|
|
* (`useAppShortcuts`, `usePluginShortcuts`) so typing in a field never
|
|
* triggers a bound shortcut.
|
|
*/
|
|
export function isEditableKeydownTarget(event: KeyboardEvent): boolean {
|
|
const tag = (event.target as HTMLElement)?.tagName;
|
|
return (
|
|
tag === "INPUT" ||
|
|
tag === "TEXTAREA" ||
|
|
(event.target as HTMLElement)?.isContentEditable === true
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Detect the current platform
|
|
*/
|
|
export function detectPlatform(): Platform {
|
|
if (typeof navigator === "undefined") {
|
|
return "unknown";
|
|
}
|
|
|
|
const platform = navigator.platform.toLowerCase();
|
|
const userAgent = navigator.userAgent.toLowerCase();
|
|
|
|
if (platform.includes("mac") || userAgent.includes("mac")) {
|
|
return "mac";
|
|
}
|
|
|
|
if (platform.includes("win") || userAgent.includes("win")) {
|
|
return "windows";
|
|
}
|
|
|
|
if (platform.includes("linux") || userAgent.includes("linux")) {
|
|
return "linux";
|
|
}
|
|
|
|
return "unknown";
|
|
}
|
|
|
|
/**
|
|
* Check if the current platform is Mac
|
|
*/
|
|
export function isMac(): boolean {
|
|
return detectPlatform() === "mac";
|
|
}
|
|
|
|
/** Collect modifier display names based on platform. */
|
|
function collectModifierNames(
|
|
modifiers: KeyboardShortcut["modifiers"],
|
|
currentPlatform: Platform,
|
|
): string[] {
|
|
if (!modifiers) return [];
|
|
const parts: string[] = [];
|
|
|
|
if (modifiers.ctrlOrCmd) {
|
|
parts.push(currentPlatform === "mac" ? "⌘" : "Ctrl");
|
|
} else {
|
|
if (modifiers.ctrl) parts.push(currentPlatform === "mac" ? "⌃" : "Ctrl");
|
|
if (modifiers.cmd && currentPlatform === "mac") parts.push("⌘");
|
|
}
|
|
if (modifiers.alt) parts.push(currentPlatform === "mac" ? "⌥" : "Alt");
|
|
if (modifiers.shift) parts.push(currentPlatform === "mac" ? "⇧" : "Shift");
|
|
return parts;
|
|
}
|
|
|
|
/**
|
|
* Format a keyboard shortcut for display based on platform
|
|
* @param shortcut - The keyboard shortcut definition
|
|
* @param platform - Optional platform override (defaults to current platform)
|
|
* @returns Formatted string like "⌘+Enter" or "Ctrl+Enter"
|
|
*/
|
|
export function formatShortcut(shortcut: KeyboardShortcut, platform?: Platform): string {
|
|
const currentPlatform = platform ?? detectPlatform();
|
|
const parts = collectModifierNames(shortcut.modifiers, currentPlatform);
|
|
parts.push(formatKey(shortcut.key));
|
|
return parts.join("+");
|
|
}
|
|
|
|
/**
|
|
* Format a key for display
|
|
*/
|
|
function formatKey(key: string): string {
|
|
// Special keys
|
|
const specialKeys: Record<string, string> = {
|
|
Enter: "Enter",
|
|
Escape: "Esc",
|
|
" ": "Space",
|
|
Tab: "Tab",
|
|
Backspace: "Backspace",
|
|
Delete: "Del",
|
|
ArrowUp: "↑",
|
|
ArrowDown: "↓",
|
|
ArrowLeft: "←",
|
|
ArrowRight: "→",
|
|
};
|
|
|
|
if (key in specialKeys) {
|
|
return specialKeys[key];
|
|
}
|
|
|
|
// Uppercase single letters
|
|
if (key.length === 1) {
|
|
return key.toUpperCase();
|
|
}
|
|
|
|
return key;
|
|
}
|
|
|
|
/** Check if a modifier flag matches the event state. */
|
|
function modifierMatches(expected: boolean | undefined, actual: boolean): boolean {
|
|
return !!expected === actual;
|
|
}
|
|
|
|
/**
|
|
* Check if a keyboard event matches a shortcut definition
|
|
* @param event - The keyboard event
|
|
* @param shortcut - The shortcut definition to match against
|
|
* @returns true if the event matches the shortcut
|
|
*/
|
|
export function matchesShortcut(
|
|
event: KeyboardEvent | React.KeyboardEvent,
|
|
shortcut: KeyboardShortcut,
|
|
): boolean {
|
|
// Case-insensitive comparison for letter keys
|
|
const eventKey = event.key.toLowerCase();
|
|
const shortcutKey = shortcut.key.toLowerCase();
|
|
if (eventKey !== shortcutKey) return false;
|
|
|
|
if (!shortcut.modifiers) {
|
|
return !event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey;
|
|
}
|
|
|
|
const { ctrl, cmd, alt, shift, ctrlOrCmd } = shortcut.modifiers;
|
|
|
|
if (ctrlOrCmd) {
|
|
if (!event.metaKey && !event.ctrlKey) return false;
|
|
return modifierMatches(alt, event.altKey) && modifierMatches(shift, event.shiftKey);
|
|
}
|
|
|
|
return (
|
|
modifierMatches(ctrl, event.ctrlKey) &&
|
|
modifierMatches(cmd, event.metaKey) &&
|
|
modifierMatches(alt, event.altKey) &&
|
|
modifierMatches(shift, event.shiftKey)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Convert a keyboard shortcut definition to a CodeMirror keybinding string.
|
|
*/
|
|
export function shortcutToCodeMirrorKeybinding(shortcut: KeyboardShortcut): string {
|
|
const parts: string[] = [];
|
|
|
|
if (shortcut.modifiers) {
|
|
if (shortcut.modifiers.ctrlOrCmd) {
|
|
parts.push("Mod");
|
|
} else {
|
|
if (shortcut.modifiers.ctrl) parts.push("Ctrl");
|
|
if (shortcut.modifiers.cmd) parts.push("Mod");
|
|
}
|
|
if (shortcut.modifiers.alt) parts.push("Alt");
|
|
if (shortcut.modifiers.shift) parts.push("Shift");
|
|
}
|
|
|
|
parts.push(shortcut.key);
|
|
return parts.join("-");
|
|
}
|