plugins: add orbit.addShortcut() for global keyboard shortcuts

combos like mod+shift+k are matched in the chat keydown handler after the
built-in shortcuts. demo plugin binds mod+shift+d.
This commit is contained in:
Jean Chevronnet 2026-07-04 03:14:20 +00:00
parent b0ca4a1b4f
commit dafc417c61
No known key found for this signature in database
4 changed files with 46 additions and 0 deletions

View file

@ -41,4 +41,7 @@ Orbit.plugin('orbit-demo', (orbit, log) => {
help: 'Roll a die: /roll [sides]',
run: (args) => orbit.irc.say('\u{1F3B2} ' + (1 + Math.floor(Math.random() * Math.max(2, +args[0] || 6)))),
});
// Register a keyboard shortcut: mod+shift+d fires a notification.
orbit.addShortcut('mod+shift+d', () => orbit.notify('Demo plugin', 'shortcut fired'));
});

View file

@ -5,6 +5,7 @@ import { ConnectScreen } from './components/ConnectScreen';
import { Chat } from './components/Chat';
import { refreshPush } from './services/push';
import { getConfig } from './config';
import { usePluginRegistry, matchShortcut } from './plugins/registry';
// Shown while a site handoff connects, so visitors who already chose a pseudo
// never see the join form. A failure clears autoConnecting and falls back to it.
@ -72,6 +73,10 @@ export default function App() {
if (e.key === '?' && !isTyping()) {
e.preventDefault(); st.setModal(st.modal === 'shortcuts' ? '' : 'shortcuts'); return;
}
// Plugin-registered shortcuts (the built-ins above take priority).
for (const sc of usePluginRegistry.getState().shortcuts) {
if (matchShortcut(e, sc.combo)) { e.preventDefault(); try { sc.run(e); } catch { /* plugin threw */ } return; }
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);

View file

@ -105,6 +105,9 @@ export interface OrbitPluginApi {
addCommand: (name: string, spec: { run: (args: string[], rest: string) => void; help?: string }) => () => void;
/** Fire a desktop notification with the brand icon (asks permission once). */
notify: (title: string, body?: string) => void;
/** Register a global keyboard shortcut, e.g. "mod+shift+k" (mod = Cmd/Ctrl).
* Runs in the chat view; built-in shortcuts take priority. */
addShortcut: (combo: string, run: (e: KeyboardEvent) => void) => () => void;
}
function makeApi(name: string): OrbitPluginApi {
@ -167,6 +170,7 @@ function makeApi(name: string): OrbitPluginApi {
addMessageFilter: (fn) => usePluginRegistry.getState().addMessageFilter(name, fn),
addCommand: (cmd, spec) => usePluginRegistry.getState().addCommand(name, cmd, spec.run, spec.help),
notify: (title, body) => pluginNotify(title, body),
addShortcut: (combo, run) => usePluginRegistry.getState().addShortcut(name, combo, run),
};
}

View file

@ -68,6 +68,32 @@ export interface PluginCommand {
help?: string;
}
// A plugin-registered keyboard shortcut. `combo` is "+"-joined, e.g. "mod+k" or
// "alt+shift+r" (mod = Cmd on macOS, Ctrl elsewhere). Built-in shortcuts win.
export interface PluginShortcut {
id: string;
plugin: string;
combo: string;
run: (e: KeyboardEvent) => void;
}
const IS_MAC = typeof navigator !== 'undefined' && /mac/i.test(navigator.platform || '');
// True when a keydown event matches a "mod+shift+k"-style combo.
export function matchShortcut(e: KeyboardEvent, combo: string): boolean {
const want = { ctrl: false, meta: false, alt: false, shift: false };
let key = '';
for (const p of combo.toLowerCase().split('+').map((s) => s.trim()).filter(Boolean)) {
if (p === 'mod') { if (IS_MAC) want.meta = true; else want.ctrl = true; }
else if (p === 'ctrl' || p === 'control') want.ctrl = true;
else if (p === 'meta' || p === 'cmd' || p === 'super') want.meta = true;
else if (p === 'alt' || p === 'option') want.alt = true;
else if (p === 'shift') want.shift = true;
else key = p;
}
return e.ctrlKey === want.ctrl && e.metaKey === want.meta && e.altKey === want.alt
&& e.shiftKey === want.shift && (e.key || '').toLowerCase() === key;
}
interface RegistryState {
ui: PluginUi[];
decorators: PluginPerMessage[];
@ -75,12 +101,14 @@ interface RegistryState {
userActions: PluginPerUser[];
messageFilters: PluginFilter[];
commands: PluginCommand[];
shortcuts: PluginShortcut[];
addUi: (slot: UiSlot, plugin: string, render: () => ReactNode, meta?: PluginUi['meta']) => () => void;
addDecorator: (plugin: string, render: (m: MessageInfo) => ReactNode) => () => void;
addAction: (plugin: string, render: (m: MessageInfo) => ReactNode) => () => void;
addUserAction: (plugin: string, render: (ctx: UserActionCtx) => ReactNode) => () => void;
addMessageFilter: (plugin: string, fn: (m: FilterableMessage) => boolean) => () => void;
addCommand: (plugin: string, name: string, run: PluginCommand['run'], help?: string) => () => void;
addShortcut: (plugin: string, combo: string, run: PluginShortcut['run']) => () => void;
}
export const usePluginRegistry = create<RegistryState>((set) => ({
@ -90,6 +118,7 @@ export const usePluginRegistry = create<RegistryState>((set) => ({
userActions: [],
messageFilters: [],
commands: [],
shortcuts: [],
addUi: (slot, plugin, render, meta) => {
const id = `${plugin}:${slot}:${Math.random().toString(36).slice(2, 8)}`;
set((s) => ({ ui: [...s.ui, { id, plugin, slot, render, meta }] }));
@ -121,4 +150,9 @@ export const usePluginRegistry = create<RegistryState>((set) => ({
set((s) => ({ commands: [...s.commands, { id, plugin, name: clean, run, help }] }));
return () => set((s) => ({ commands: s.commands.filter((c) => c.id !== id) }));
},
addShortcut: (plugin, combo, run) => {
const id = `${plugin}:key:${Math.random().toString(36).slice(2, 8)}`;
set((s) => ({ shortcuts: [...s.shortcuts, { id, plugin, combo, run }] }));
return () => set((s) => ({ shortcuts: s.shortcuts.filter((k) => k.id !== id) }));
},
}));