Custom keyboard shortcuts for your browser
npm install
npm run dev # Chrome
npm run dev:firefox # Firefox
npm run build # Chrome
npm run build:firefox # Firefox
npm test # Run all tests
npm run test:watch # Watch mode
npm run test:coverage # With coverage
Built with WXT (Vite-based browser extension framework), Vue 3, and TypeScript.
src/
├── entrypoints/
│ ├── background.ts # Service worker
│ ├── content.ts # Content script (Mousetrap bindings)
│ └── options/ # Options page (Vue 3)
│ ├── App.vue
│ ├── index.html
│ └── main.ts
├── actions/
│ ├── action-handlers.ts # Action registry (Map-based dispatch)
│ ├── capture-screenshot.ts
│ └── last-used-tab.ts
└── utils/
├── actions-registry.ts # Action definitions & metadata
├── content-logic.ts # Content script pure logic
├── execute-script.ts # Scripting API wrapper
└── url-matching.ts # Glob/regex URL matching
Shortkeys is a cross-browser extension that lets users define custom keyboard shortcuts for browser actions (scrolling, tab management, navigation, running custom JavaScript, etc.). It's available on Chrome, Firefox, Edge, and Opera.
- Build system: WXT (Vite-based browser extension framework) —
wxt.config.ts - Language: TypeScript throughout
- UI framework: Vue 3 with Composition API (
<script setup>) - Testing: Vitest with 162+ tests
- Key dependency: Mousetrap for keyboard shortcut detection in content scripts
- Code editor: CodeMirror 6 for JavaScript action editing in the options page
src/
├── entrypoints/
│ ├── background.ts # Service worker: message handling, action dispatch
│ ├── content.ts # Content script: Mousetrap bindings, key activation
│ └── options/ # Options page (Vue 3 SPA)
│ ├── App.vue # Main options UI
│ ├── index.html
│ └── main.ts
├── actions/
│ ├── action-handlers.ts # Map-based action registry (all ~50 browser actions)
│ ├── capture-screenshot.ts # Screenshot via Chrome debugger protocol
│ └── last-used-tab.ts # Tab history tracking
├── components/
│ ├── CodeEditor.vue # CodeMirror 6 wrapper
│ └── SearchSelect.vue # Autocomplete dropdown
└── utils/
├── actions-registry.ts # Action definitions, categories, metadata
├── content-logic.ts # Pure functions: fetchConfig, shouldStopCallback
├── execute-script.ts # chrome.scripting.executeScript wrapper
└── url-matching.ts # globToRegex, isAllowedSite (blacklist/whitelist)
Actions are dispatched via a Record<string, ActionHandler> map in action-handlers.ts, NOT a long if/else chain. To add a new action: add an entry to ACTION_CATEGORIES in actions-registry.ts and a handler in action-handlers.ts.
Pure logic (URL matching, content script filtering, action metadata) lives in src/utils/ and is fully testable without browser mocks. Browser-dependent code lives in src/entrypoints/ and src/actions/.
- Content script sends
{action: 'getKeys', url: document.URL}to background - Background filters keys by
isAllowedSite()and responds - Content script binds keys via Mousetrap
- When a shortcut fires, content script sends the full
KeySettingobject to background - Background dispatches to the appropriate action handler
Custom JS runs in the page's MAIN world via chrome.userScripts. The background script serializes handler functions as strings and registers them. The content script dispatches a shortkeys_js_run CustomEvent to trigger execution. The chrome.userScripts API requires the user to enable "Allow User Scripts" in extension details — all calls are guarded with try/catch.
Use row.id (a stable UUID) as the :key for shortcut rows, NEVER row.key (the shortcut string), because row.key changes as the user types, causing Vue to re-render and lose focus.
npm run dev— WXT dev mode with hot reload (Chrome)npm run dev:firefox— WXT dev mode (Firefox)npm run build— Production build →.output/chrome-mv3/npm test— Run all Vitest testsnpm run test:watch— Watch modenpm run test:coverage— Coverage report
- Tests live in
tests/as*.test.tsfiles - Browser APIs are mocked via
vi.fn()on aglobalThis.browserobject executeScriptis mocked at the module level viavi.mock()- Integration tests verify cross-module behavior (URL filtering → key lookup → stopCallback)
- The action handler test dynamically generates a test for every action in the registry
- The
open_in_taboption for the options page is set via<meta name="manifest.open_in_tab" content="true">in the HTML (WXT convention), not inwxt.config.ts chrome.userScriptsmay be undefined if the user hasn't enabled the permission — always guard- The serialized
registerHandlersfunction inregisterUserScript()usesvar(notconst) for the handlers object so it's accessible in the closure aftertoString()serialization - Mousetrap's
stopCallbackprototype override must reference the sharedkeysarray from the content script's closure