Files
João Salavisa 99dfbbde58 feat(plugins): add keybindings and modal window capabilities (#1895)
* 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>
2026-07-24 11:45:28 +01:00

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("-");
}