The GROWI hotkey system manages keyboard shortcuts globally. It uses tinykeys (~400 byte) as the key binding engine and a subscriber component pattern to execute actions when hotkeys fire.
BasicLayout / AdminLayout
└─ HotkeysManager (loaded via next/dynamic, ssr: false)
├─ tinykeys(window, bindings) — registers all key bindings
└─ renders subscriber components on demand:
├─ EditPage
├─ CreatePage
├─ FocusToGlobalSearch
├─ ShowShortcutsModal
├─ ShowStaffCredit
└─ SwitchToMirrorMode
| File | Role |
|---|---|
src/client/components/Hotkeys/HotkeysManager.tsx |
Core orchestrator — binds all keys via tinykeys, renders subscribers |
src/client/components/Hotkeys/Subscribers/*.tsx |
Individual action handlers rendered when their hotkey fires |
src/components/Layout/BasicLayout.tsx |
Mounts HotkeysManager via next/dynamic({ ssr: false }) |
src/components/Layout/AdminLayout.tsx |
Mounts HotkeysManager via next/dynamic({ ssr: false }) |
Decision: Use tinykeys (v3) instead of react-hotkeys (v2).
Rationale:
react-hotkeys contributes 91 modules to async chunks; tinykeys is 1 module (~400 bytes)Control+/), and multi-key sequences (ArrowUp ArrowUp ...)HotkeyStroke) or detection wrapper (HotkeysDetector)Trade-off: tinykeys has no React integration — key binding is done imperatively in a useEffect hook rather than declaratively via JSX props. This is acceptable given the simplicity of the binding map.
Decision: Each subscriber component exports its own hotkeyBindings metadata alongside its React component. HotkeysManager imports these definitions and auto-builds the tinykeys binding map — it never hardcodes specific keys or subscriber references.
Rationale:
HotkeysManager needs no modificationType contract:
// Shared type definition in HotkeysManager.tsx or a shared types file
type HotkeyCategory = 'single' | 'modifier';
type HotkeyBindingDef = {
keys: string | string[]; // tinykeys key expression(s)
category: HotkeyCategory; // determines handler wrapper (single = input guard, modifier = no guard)
};
type HotkeySubscriber = {
component: React.ComponentType<{ onDeleteRender: () => void }>;
bindings: HotkeyBindingDef;
};
Subscriber example:
// CreatePage.tsx
export const hotkeyBindings: HotkeyBindingDef = {
keys: 'c',
category: 'single',
};
export const CreatePage = ({ onDeleteRender }: Props): null => { /* ... */ };
// ShowShortcutsModal.tsx
export const hotkeyBindings: HotkeyBindingDef = {
keys: ['Control+/', 'Meta+/'],
category: 'modifier',
};
HotkeysManager usage:
// HotkeysManager.tsx
import * as createPage from './Subscribers/CreatePage';
import * as editPage from './Subscribers/EditPage';
// ... other subscribers
const subscribers: HotkeySubscriber[] = [
{ component: createPage.CreatePage, bindings: createPage.hotkeyBindings },
{ component: editPage.EditPage, bindings: editPage.hotkeyBindings },
// ...
];
// In useEffect: iterate subscribers to build tinykeys binding map
Trade-off: Slightly more structure than a plain object literal, but the pattern is minimal and each subscriber file is fully self-contained.
Decision: Subscriber components are rendered into the React tree only when their hotkey fires, and self-remove after executing their action.
Rationale:
onDeleteRender() after completing their effect to clean upDecision: singleKeyHandler and modifierKeyHandler are separated.
Rationale:
e, c, /) must be suppressed when the user is typing in input/textarea/contenteditable elementsControl+/, Meta+/) and multi-key sequences should fire regardless of focus, as they are unlikely to conflict with text entryisEditableTarget() check is applied only to single-key handlersDecision: HotkeysManager is loaded via next/dynamic({ ssr: false }).
Rationale:
| Requirement | Deviation | Justification |
|---|---|---|
| Req 8 AC 2: "export typed interfaces for hotkey definitions" | HotkeyBindingDef and HotkeySubscriber types are exported for subscriber use but not published as a package API |
These types are internal to the Hotkeys module; no external consumers need them |
Note (task 5): Req 8 AC 1 is now fully satisfied — all 6 subscriber components converted from
.jsxto.tsxwith TypeScriptPropstypes and named exports. Note (D2 revision): Req 7 AC 2 is now fully satisfied — subscriber-owned binding definitions mean adding a hotkey requires only creating a new subscriber file.
| Category | Format | Example |
|---|---|---|
| Single key | "key" |
e, c, "/" |
| Modifier combo | "Modifier+key" |
"Control+/", "Meta+/" |
| Multi-key sequence | "key1 key2 key3 ..." (space-separated) |
"ArrowUp ArrowUp ArrowDown ArrowDown ..." |
| Platform modifier | "$mod+key" |
"$mod+/" (Control on Windows/Linux, Meta on macOS) |
Note: The current implementation uses explicit
Control+/andMeta+/rather than$mod+/to match the original behavior.