design.md 6.4 KB

Technical Design

Architecture Overview

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.

Component Diagram

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

Key Files

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 })

Design Decisions

D1: tinykeys as Binding Engine

Decision: Use tinykeys (v3) instead of react-hotkeys (v2).

Rationale:

  • react-hotkeys contributes 91 modules to async chunks; tinykeys is 1 module (~400 bytes)
  • tinykeys natively supports single keys, modifier combos (Control+/), and multi-key sequences (ArrowUp ArrowUp ...)
  • No need for custom state machine (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.

D2: Subscriber-Owned Binding Definitions

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:

  • True "1 module = 1 hotkey" encapsulation: each subscriber owns its key binding, handler category, and action logic
  • Adding a new hotkey requires creating only one file (the new subscriber); HotkeysManager needs no modification
  • Fully satisfies Req 7 AC 2 ("define hotkey without modifying core detection logic")
  • Self-documenting: looking at a subscriber file tells you everything about that hotkey

Type 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.

D3: Subscriber Render-on-Fire Pattern

Decision: Subscriber components are rendered into the React tree only when their hotkey fires, and self-remove after executing their action.

Rationale:

  • Preserves the existing GROWI pattern where hotkey actions need access to React hooks (Jotai atoms, SWR, i18n, routing)
  • Components call onDeleteRender() after completing their effect to clean up
  • Uses a monotonically incrementing key ref to avoid React key collisions

D4: Two Handler Categories

Decision: singleKeyHandler and modifierKeyHandler are separated.

Rationale:

  • Single-key shortcuts (e, c, /) must be suppressed when the user is typing in input/textarea/contenteditable elements
  • Modifier-key shortcuts (Control+/, Meta+/) and multi-key sequences should fire regardless of focus, as they are unlikely to conflict with text entry
  • isEditableTarget() check is applied only to single-key handlers

D5: Client-Only Loading

Decision: HotkeysManager is loaded via next/dynamic({ ssr: false }).

Rationale:

  • Keyboard events are client-only; no SSR rendering is needed
  • Dynamic import keeps hotkey modules out of initial server-rendered chunks
  • Both BasicLayout and AdminLayout follow this pattern

Implementation Deviations from Requirements

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 .jsx to .tsx with TypeScript Props types 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.

Key Binding Format (tinykeys)

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+/ and Meta+/ rather than $mod+/ to match the original behavior.