Просмотр исходного кода

Merge pull request #10807 from growilabs/imprv/migrate-to-tinykeys

imprv: Migrate react-hotkeys to tinykeys
Yuki Takei 1 месяц назад
Родитель
Сommit
957c9e67a8
26 измененных файлов с 992 добавлено и 346 удалено
  1. 153 0
      .kiro/specs/hotkeys/design.md
  2. 101 0
      .kiro/specs/hotkeys/requirements.md
  3. 23 0
      .kiro/specs/hotkeys/spec.json
  4. 29 0
      .kiro/specs/hotkeys/tasks.md
  5. 1 1
      apps/app/package.json
  6. 0 77
      apps/app/src/client/components/Hotkeys/HotkeysDetector.jsx
  7. 0 81
      apps/app/src/client/components/Hotkeys/HotkeysManager.jsx
  8. 100 0
      apps/app/src/client/components/Hotkeys/HotkeysManager.spec.tsx
  9. 99 0
      apps/app/src/client/components/Hotkeys/HotkeysManager.tsx
  10. 0 32
      apps/app/src/client/components/Hotkeys/Subscribers/CreatePage.jsx
  11. 40 0
      apps/app/src/client/components/Hotkeys/Subscribers/CreatePage.spec.tsx
  12. 29 0
      apps/app/src/client/components/Hotkeys/Subscribers/CreatePage.tsx
  13. 130 0
      apps/app/src/client/components/Hotkeys/Subscribers/EditPage.spec.tsx
  14. 8 5
      apps/app/src/client/components/Hotkeys/Subscribers/EditPage.tsx
  15. 69 0
      apps/app/src/client/components/Hotkeys/Subscribers/FocusToGlobalSearch.spec.tsx
  16. 15 10
      apps/app/src/client/components/Hotkeys/Subscribers/FocusToGlobalSearch.tsx
  17. 61 0
      apps/app/src/client/components/Hotkeys/Subscribers/ShowShortcutsModal.spec.tsx
  18. 12 12
      apps/app/src/client/components/Hotkeys/Subscribers/ShowShortcutsModal.tsx
  19. 0 30
      apps/app/src/client/components/Hotkeys/Subscribers/ShowStaffCredit.jsx
  20. 39 0
      apps/app/src/client/components/Hotkeys/Subscribers/ShowStaffCredit.spec.tsx
  21. 19 0
      apps/app/src/client/components/Hotkeys/Subscribers/ShowStaffCredit.tsx
  22. 0 24
      apps/app/src/client/components/Hotkeys/Subscribers/SwitchToMirrorMode.jsx
  23. 33 0
      apps/app/src/client/components/Hotkeys/Subscribers/SwitchToMirrorMode.spec.tsx
  24. 23 0
      apps/app/src/client/components/Hotkeys/Subscribers/SwitchToMirrorMode.tsx
  25. 0 61
      apps/app/src/client/models/HotkeyStroke.js
  26. 8 13
      pnpm-lock.yaml

+ 153 - 0
.kiro/specs/hotkeys/design.md

@@ -0,0 +1,153 @@
+# 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**:
+```typescript
+// 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**:
+```typescript
+// CreatePage.tsx
+export const hotkeyBindings: HotkeyBindingDef = {
+  keys: 'c',
+  category: 'single',
+};
+
+export const CreatePage = ({ onDeleteRender }: Props): null => { /* ... */ };
+```
+
+```typescript
+// ShowShortcutsModal.tsx
+export const hotkeyBindings: HotkeyBindingDef = {
+  keys: ['Control+/', 'Meta+/'],
+  category: 'modifier',
+};
+```
+
+**HotkeysManager usage**:
+```typescript
+// 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.
+

+ 101 - 0
.kiro/specs/hotkeys/requirements.md

@@ -0,0 +1,101 @@
+# Requirements Document
+
+## Introduction
+
+GROWI currently uses `react-hotkeys` (v2.0.0, 91 modules in async chunk) to manage keyboard shortcuts via a custom subscriber pattern. The library is identified as an optimization target due to its module footprint. This specification covers the migration from `react-hotkeys` to `tinykeys`, a lightweight (~400B) keyboard shortcut library, while preserving all existing hotkey functionality and the subscriber-based architecture.
+
+### Current Architecture Overview
+
+- **HotkeysDetector**: Wraps `react-hotkeys`'s `GlobalHotKeys` to capture key events and convert them to custom key expressions
+- **HotkeyStroke**: State machine model for multi-key sequence detection (e.g., Konami codes)
+- **HotkeysManager**: Orchestrator that maps strokes to subscriber components and manages their lifecycle
+- **Subscribers**: 6 components (CreatePage, EditPage, FocusToGlobalSearch, ShowShortcutsModal, ShowStaffCredit, SwitchToMirrorMode) that self-define hotkeys via static `getHotkeyStrokes()`
+
+### Registered Hotkeys
+
+| Shortcut | Action |
+|----------|--------|
+| `c` | Open page creation modal |
+| `e` | Start page editing |
+| `/` | Focus global search |
+| `Ctrl+/` or `Meta+/` | Open shortcuts help modal |
+| `↑↑↓↓←→←→BA` | Show staff credits (Konami code) |
+| `XXBBAAYYA↓←` | Switch to mirror mode (Konami code) |
+
+## Requirements
+
+### Requirement 1: Replace react-hotkeys Dependency with tinykeys
+
+**Objective:** As a developer, I want to replace `react-hotkeys` with `tinykeys`, so that the application's async chunk module count is reduced and the hotkey system uses a modern, lightweight library.
+
+#### Acceptance Criteria
+
+1. The GROWI application shall use `tinykeys` as the keyboard shortcut library instead of `react-hotkeys`.
+2. When the migration is complete, the `react-hotkeys` package shall be removed from `package.json` dependencies.
+3. The GROWI application shall not increase the total async chunk module count compared to the current `react-hotkeys` implementation.
+
+### Requirement 2: Preserve Single-Key Shortcut Functionality
+
+**Objective:** As a user, I want single-key shortcuts to continue working after the migration, so that my workflow is not disrupted.
+
+#### Acceptance Criteria
+
+1. When the user presses the `c` key (outside an input/textarea/editable element), the Hotkeys system shall open the page creation modal.
+2. When the user presses the `e` key (outside an input/textarea/editable element), the Hotkeys system shall start page editing if the page is editable and no modal is open.
+3. When the user presses the `/` key (outside an input/textarea/editable element), the Hotkeys system shall open the global search modal.
+
+### Requirement 3: Preserve Modifier-Key Shortcut Functionality
+
+**Objective:** As a user, I want modifier-key shortcuts to continue working after the migration, so that keyboard shortcut help remains accessible.
+
+#### Acceptance Criteria
+
+1. When the user presses `Ctrl+/` (or `Meta+/` on macOS), the Hotkeys system shall open the shortcuts help modal.
+
+### Requirement 4: Preserve Multi-Key Sequence (Konami Code) Functionality
+
+**Objective:** As a user, I want multi-key sequences (Konami codes) to continue working after the migration, so that easter egg features remain accessible.
+
+#### Acceptance Criteria
+
+1. When the user enters the key sequence `↑↑↓↓←→←→BA`, the Hotkeys system shall show the staff credits modal.
+2. When the user enters the key sequence `XXBBAAYYA↓←`, the Hotkeys system shall apply the mirror mode CSS class to the document body.
+3. While a multi-key sequence is in progress, the Hotkeys system shall track partial matches and reset if an incorrect key is pressed.
+
+### Requirement 5: Input Element Focus Guard
+
+**Objective:** As a user, I want single-key shortcuts to not fire when I am typing in an input field, so that keyboard shortcuts do not interfere with text entry.
+
+#### Acceptance Criteria
+
+1. While an `<input>`, `<textarea>`, or `contenteditable` element is focused, the Hotkeys system shall suppress single-key shortcuts (e.g., `c`, `e`, `/`).
+2. While an `<input>`, `<textarea>`, or `contenteditable` element is focused, the Hotkeys system shall still allow modifier-key shortcuts (e.g., `Ctrl+/`).
+
+### Requirement 6: Lifecycle Management and Cleanup
+
+**Objective:** As a developer, I want hotkey bindings to be properly registered and cleaned up on component mount/unmount, so that there are no memory leaks or stale handlers.
+
+#### Acceptance Criteria
+
+1. When a layout component (BasicLayout or AdminLayout) mounts, the Hotkeys system shall register all hotkey bindings.
+2. When a layout component unmounts, the Hotkeys system shall unsubscribe all hotkey bindings.
+3. The Hotkeys system shall provide a cleanup mechanism compatible with React's `useEffect` return pattern.
+
+### Requirement 7: Maintain Subscriber Component Architecture
+
+**Objective:** As a developer, I want the subscriber-based architecture to be preserved or appropriately modernized, so that adding or modifying hotkeys remains straightforward.
+
+#### Acceptance Criteria
+
+1. The Hotkeys system shall support a pattern where each hotkey action is defined as an independent unit (component or handler) with its own key binding definition.
+2. When a new hotkey action is added, the developer shall be able to define it without modifying the core hotkey detection logic.
+3. The Hotkeys system shall support dynamic rendering of subscriber components when their associated hotkey fires.
+
+### Requirement 8: TypeScript Migration
+
+**Objective:** As a developer, I want the migrated hotkey system to use TypeScript, so that the code benefits from type safety and better IDE support.
+
+#### Acceptance Criteria
+
+1. The Hotkeys system shall be implemented in TypeScript (`.ts`/`.tsx` files) rather than JavaScript (`.js`/`.jsx`).
+2. The Hotkeys system shall export typed interfaces for hotkey definitions and handler signatures.

+ 23 - 0
.kiro/specs/hotkeys/spec.json

@@ -0,0 +1,23 @@
+{
+  "feature_name": "hotkeys",
+  "created_at": "2026-02-20T00:00:00.000Z",
+  "updated_at": "2026-02-24T12:00:00.000Z",
+  "language": "en",
+  "phase": "implementation-complete",
+  "approvals": {
+    "requirements": {
+      "generated": true,
+      "approved": true
+    },
+    "design": {
+      "generated": true,
+      "approved": true
+    },
+    "tasks": {
+      "generated": true,
+      "approved": true
+    }
+  },
+  "ready_for_implementation": true,
+  "cleanup_completed": true
+}

+ 29 - 0
.kiro/specs/hotkeys/tasks.md

@@ -0,0 +1,29 @@
+# Implementation Tasks
+
+## Summary
+
+All tasks completed. Migrated from `react-hotkeys` to `tinykeys` with subscriber-owned binding definitions and full TypeScript conversion.
+
+| Task | Description | Requirements |
+|------|-------------|--------------|
+| 1 | Write HotkeysManager tests (TDD) | 2, 3, 5 |
+| 2 | Rewrite HotkeysManager with tinykeys | 1, 2, 3, 4, 5, 6, 8 |
+| 3 | Remove legacy hotkey infrastructure | 1, 7 |
+| 4 | Verify quality and module reduction (-92 modules) | 1 |
+| 5 | Convert 4 JSX subscribers to TypeScript, fix bugs, unify patterns | 7, 8 |
+| 6.1 | Define shared types, add binding exports to all subscribers | 7, 8 |
+| 6.2 | Refactor HotkeysManager to build binding map from subscriber exports | 6, 7 |
+| 7 | Verify refactoring preserves all existing behavior | 1, 2, 3, 4, 5 |
+
+## Requirements Coverage
+
+| Requirement | Tasks |
+|-------------|-------|
+| 1. Replace react-hotkeys with tinykeys | 2, 3, 4, 7 |
+| 2. Preserve single-key shortcuts | 1, 2, 7 |
+| 3. Preserve modifier-key shortcuts | 1, 2, 7 |
+| 4. Preserve multi-key sequences | 2, 7 |
+| 5. Input element focus guard | 1, 2, 7 |
+| 6. Lifecycle management and cleanup | 2, 6.2 |
+| 7. Subscriber component architecture | 3, 5, 6.1, 6.2 |
+| 8. TypeScript migration | 2, 5, 6.1 |

+ 1 - 1
apps/app/package.json

@@ -329,7 +329,6 @@
     "react-dnd-html5-backend": "^14.1.0",
     "react-dropzone": "^14.2.3",
     "react-hook-form": "^7.45.4",
-    "react-hotkeys": "^2.0.0",
     "react-input-autosize": "^3.0.0",
     "react-toastify": "^9.1.3",
     "rehype-rewrite": "^4.0.2",
@@ -340,6 +339,7 @@
     "source-map-loader": "^4.0.1",
     "supertest": "^7.1.4",
     "swagger2openapi": "^7.0.8",
+    "tinykeys": "^3.0.0",
     "unist-util-is": "^6.0.0",
     "unist-util-visit-parents": "^6.0.0"
   }

+ 0 - 77
apps/app/src/client/components/Hotkeys/HotkeysDetector.jsx

@@ -1,77 +0,0 @@
-import React, { useCallback, useMemo } from 'react';
-import PropTypes from 'prop-types';
-import { GlobalHotKeys } from 'react-hotkeys';
-
-import HotkeyStroke from '~/client/models/HotkeyStroke';
-
-const HotkeysDetector = (props) => {
-  const { keySet, strokeSet, onDetected } = props;
-
-  // memorize HotkeyStroke instances
-  const hotkeyStrokes = useMemo(() => {
-    const strokes = Array.from(strokeSet);
-    return strokes.map((stroke) => new HotkeyStroke(stroke));
-  }, [strokeSet]);
-
-  /**
-   * return key expression string includes modifier
-   */
-  const getKeyExpression = useCallback((event) => {
-    let eventKey = event.key;
-
-    if (event.ctrlKey) {
-      eventKey += '+ctrl';
-    }
-    if (event.metaKey) {
-      eventKey += '+meta';
-    }
-    if (event.altKey) {
-      eventKey += '+alt';
-    }
-    if (event.shiftKey) {
-      eventKey += '+shift';
-    }
-
-    return eventKey;
-  }, []);
-
-  /**
-   * evaluate the key user pressed and trigger onDetected
-   */
-  const checkHandler = useCallback(
-    (event) => {
-      const eventKey = getKeyExpression(event);
-
-      hotkeyStrokes.forEach((hotkeyStroke) => {
-        // if any stroke is completed
-        if (hotkeyStroke.evaluate(eventKey)) {
-          // cancel the key event
-          event.preventDefault();
-          // invoke detected handler
-          onDetected(hotkeyStroke.stroke);
-        }
-      });
-    },
-    [hotkeyStrokes, getKeyExpression, onDetected],
-  );
-
-  // memorize keyMap for GlobalHotKeys
-  const keyMap = useMemo(() => {
-    return { check: Array.from(keySet) };
-  }, [keySet]);
-
-  // memorize handlers for GlobalHotKeys
-  const handlers = useMemo(() => {
-    return { check: checkHandler };
-  }, [checkHandler]);
-
-  return <GlobalHotKeys keyMap={keyMap} handlers={handlers} />;
-};
-
-HotkeysDetector.propTypes = {
-  onDetected: PropTypes.func.isRequired,
-  keySet: PropTypes.instanceOf(Set).isRequired,
-  strokeSet: PropTypes.instanceOf(Set).isRequired,
-};
-
-export default HotkeysDetector;

+ 0 - 81
apps/app/src/client/components/Hotkeys/HotkeysManager.jsx

@@ -1,81 +0,0 @@
-import React, { useState } from 'react';
-
-import HotkeysDetector from './HotkeysDetector';
-import CreatePage from './Subscribers/CreatePage';
-import EditPage from './Subscribers/EditPage';
-import FocusToGlobalSearch from './Subscribers/FocusToGlobalSearch';
-import ShowShortcutsModal from './Subscribers/ShowShortcutsModal';
-import ShowStaffCredit from './Subscribers/ShowStaffCredit';
-import SwitchToMirrorMode from './Subscribers/SwitchToMirrorMode';
-
-// define supported components list
-const SUPPORTED_COMPONENTS = [
-  ShowStaffCredit,
-  SwitchToMirrorMode,
-  ShowShortcutsModal,
-  CreatePage,
-  EditPage,
-  FocusToGlobalSearch,
-];
-
-const KEY_SET = new Set();
-const STROKE_SET = new Set();
-const STROKE_TO_COMPONENT_MAP = {};
-
-SUPPORTED_COMPONENTS.forEach((comp) => {
-  const strokes = comp.getHotkeyStrokes();
-
-  strokes.forEach((stroke) => {
-    // register key
-    stroke.forEach((key) => {
-      KEY_SET.add(key);
-    });
-    // register stroke
-    STROKE_SET.add(stroke);
-    // register component
-    const componentList = STROKE_TO_COMPONENT_MAP[stroke] || [];
-    componentList.push(comp);
-    STROKE_TO_COMPONENT_MAP[stroke.toString()] = componentList;
-  });
-});
-
-const HotkeysManager = (props) => {
-  const [view, setView] = useState([]);
-
-  /**
-   * delete the instance in state.view
-   */
-  const deleteRender = (instance) => {
-    const index = view.lastIndexOf(instance);
-
-    const newView = view.slice(); // shallow copy
-    newView.splice(index, 1);
-    setView(newView);
-  };
-
-  /**
-   * activates when one of the hotkey strokes gets determined from HotkeysDetector
-   */
-  const onDetected = (strokeDetermined) => {
-    const key = (Math.random() * 1000).toString();
-    const components = STROKE_TO_COMPONENT_MAP[strokeDetermined.toString()];
-
-    const newViews = components.map((Component) => (
-      <Component key={key} onDeleteRender={deleteRender} />
-    ));
-    setView(view.concat(newViews).flat());
-  };
-
-  return (
-    <>
-      <HotkeysDetector
-        onDetected={(stroke) => onDetected(stroke)}
-        keySet={KEY_SET}
-        strokeSet={STROKE_SET}
-      />
-      {view}
-    </>
-  );
-};
-
-export default HotkeysManager;

+ 100 - 0
apps/app/src/client/components/Hotkeys/HotkeysManager.spec.tsx

@@ -0,0 +1,100 @@
+import { act, cleanup, render } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+// Mock all subscriber components as simple render trackers with their binding definitions
+vi.mock('./Subscribers/EditPage', () => ({
+  EditPage: vi.fn(() => null),
+  hotkeyBindings: { keys: 'e', category: 'single' },
+}));
+vi.mock('./Subscribers/CreatePage', () => ({
+  CreatePage: vi.fn(() => null),
+  hotkeyBindings: { keys: 'c', category: 'single' },
+}));
+vi.mock('./Subscribers/FocusToGlobalSearch', () => ({
+  FocusToGlobalSearch: vi.fn(() => null),
+  hotkeyBindings: { keys: '/', category: 'single' },
+}));
+vi.mock('./Subscribers/ShowShortcutsModal', () => ({
+  ShowShortcutsModal: vi.fn(() => null),
+  hotkeyBindings: { keys: ['Control+/', 'Meta+/'], category: 'modifier' },
+}));
+vi.mock('./Subscribers/ShowStaffCredit', () => ({
+  ShowStaffCredit: vi.fn(() => null),
+  hotkeyBindings: {
+    keys: 'ArrowUp ArrowUp ArrowDown ArrowDown ArrowLeft ArrowRight ArrowLeft ArrowRight b a',
+    category: 'modifier',
+  },
+}));
+vi.mock('./Subscribers/SwitchToMirrorMode', () => ({
+  SwitchToMirrorMode: vi.fn(() => null),
+  hotkeyBindings: {
+    keys: 'x x b b a y a y ArrowDown ArrowLeft',
+    category: 'modifier',
+  },
+}));
+
+const { default: HotkeysManager } = await import('./HotkeysManager');
+const { EditPage } = await import('./Subscribers/EditPage');
+const { ShowShortcutsModal } = await import('./Subscribers/ShowShortcutsModal');
+
+afterEach(() => {
+  cleanup();
+  vi.clearAllMocks();
+});
+
+const pressKey = (key: string, options: Partial<KeyboardEventInit> = {}) => {
+  const event = new KeyboardEvent('keydown', {
+    key,
+    bubbles: true,
+    cancelable: true,
+    ...options,
+  });
+  // happy-dom does not wire ctrlKey/metaKey to getModifierState — override for tinykeys
+  Object.defineProperty(event, 'getModifierState', {
+    value: (mod: string) => {
+      if (mod === 'Control') return !!options.ctrlKey;
+      if (mod === 'Meta') return !!options.metaKey;
+      if (mod === 'Shift') return !!options.shiftKey;
+      if (mod === 'Alt') return !!options.altKey;
+      return false;
+    },
+  });
+  window.dispatchEvent(event);
+};
+
+describe('HotkeysManager', () => {
+  it('renders the corresponding subscriber when a single key is pressed', () => {
+    render(<HotkeysManager />);
+    act(() => {
+      pressKey('e');
+    });
+    expect(EditPage).toHaveBeenCalled();
+  });
+
+  it('renders the corresponding subscriber when a modifier key combo is pressed', () => {
+    render(<HotkeysManager />);
+    act(() => {
+      pressKey('/', { ctrlKey: true });
+    });
+    expect(ShowShortcutsModal).toHaveBeenCalled();
+  });
+
+  it('does NOT trigger single-key shortcut when target is an editable element', () => {
+    render(<HotkeysManager />);
+    const input = document.createElement('input');
+    document.body.appendChild(input);
+
+    act(() => {
+      input.dispatchEvent(
+        new KeyboardEvent('keydown', {
+          key: 'e',
+          bubbles: true,
+          cancelable: true,
+        }),
+      );
+    });
+    expect(EditPage).not.toHaveBeenCalled();
+
+    document.body.removeChild(input);
+  });
+});

+ 99 - 0
apps/app/src/client/components/Hotkeys/HotkeysManager.tsx

@@ -0,0 +1,99 @@
+import type { JSX } from 'react';
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { tinykeys } from 'tinykeys';
+
+import * as createPage from './Subscribers/CreatePage';
+import * as editPage from './Subscribers/EditPage';
+import * as focusToGlobalSearch from './Subscribers/FocusToGlobalSearch';
+import * as showShortcutsModal from './Subscribers/ShowShortcutsModal';
+import * as showStaffCredit from './Subscribers/ShowStaffCredit';
+import * as switchToMirrorMode from './Subscribers/SwitchToMirrorMode';
+
+export type HotkeyCategory = 'single' | 'modifier';
+
+export type HotkeyBindingDef = {
+  keys: string | string[];
+  category: HotkeyCategory;
+};
+
+type SubscriberComponent = React.ComponentType<{ onDeleteRender: () => void }>;
+
+type HotkeySubscriber = {
+  component: SubscriberComponent;
+  bindings: HotkeyBindingDef;
+};
+
+const subscribers: HotkeySubscriber[] = [
+  { component: editPage.EditPage, bindings: editPage.hotkeyBindings },
+  { component: createPage.CreatePage, bindings: createPage.hotkeyBindings },
+  {
+    component: focusToGlobalSearch.FocusToGlobalSearch,
+    bindings: focusToGlobalSearch.hotkeyBindings,
+  },
+  {
+    component: showShortcutsModal.ShowShortcutsModal,
+    bindings: showShortcutsModal.hotkeyBindings,
+  },
+  {
+    component: showStaffCredit.ShowStaffCredit,
+    bindings: showStaffCredit.hotkeyBindings,
+  },
+  {
+    component: switchToMirrorMode.SwitchToMirrorMode,
+    bindings: switchToMirrorMode.hotkeyBindings,
+  },
+];
+
+const isEditableTarget = (event: KeyboardEvent): boolean => {
+  const target = event.target as HTMLElement | null;
+  if (target == null) return false;
+  const { tagName } = target;
+  if (tagName === 'INPUT' || tagName === 'TEXTAREA' || tagName === 'SELECT') {
+    return true;
+  }
+  return target.isContentEditable;
+};
+
+const HotkeysManager = (): JSX.Element => {
+  const [views, setViews] = useState<JSX.Element[]>([]);
+  const nextKeyRef = useRef(0);
+
+  const addView = useCallback((Component: SubscriberComponent) => {
+    const viewKey = String(nextKeyRef.current++);
+    const deleteRender = () => {
+      setViews((prev) => prev.filter((v) => v.key !== viewKey));
+    };
+    setViews((prev) => [
+      ...prev,
+      <Component key={viewKey} onDeleteRender={deleteRender} />,
+    ]);
+  }, []);
+
+  useEffect(() => {
+    const createHandler =
+      (component: SubscriberComponent, category: HotkeyCategory) =>
+      (event: KeyboardEvent) => {
+        if (category === 'single' && isEditableTarget(event)) return;
+        event.preventDefault();
+        addView(component);
+      };
+
+    const bindingMap: Record<string, (event: KeyboardEvent) => void> = {};
+    for (const { component, bindings } of subscribers) {
+      const handler = createHandler(component, bindings.category);
+      const keys = Array.isArray(bindings.keys)
+        ? bindings.keys
+        : [bindings.keys];
+      for (const key of keys) {
+        bindingMap[key] = handler;
+      }
+    }
+
+    const unsubscribe = tinykeys(window, bindingMap);
+    return unsubscribe;
+  }, [addView]);
+
+  return <>{views}</>;
+};
+
+export default HotkeysManager;

+ 0 - 32
apps/app/src/client/components/Hotkeys/Subscribers/CreatePage.jsx

@@ -1,32 +0,0 @@
-import React, { useEffect } from 'react';
-import PropTypes from 'prop-types';
-
-import { useCurrentPagePath } from '~/states/page';
-import { usePageCreateModalActions } from '~/states/ui/modal/page-create';
-
-const CreatePage = React.memo((props) => {
-  const { open: openCreateModal } = usePageCreateModalActions();
-  const currentPath = useCurrentPagePath();
-
-  // setup effect
-  useEffect(() => {
-    openCreateModal(currentPath ?? '');
-
-    // remove this
-    props.onDeleteRender(this);
-  }, [currentPath, openCreateModal, props]);
-
-  return <></>;
-});
-
-CreatePage.propTypes = {
-  onDeleteRender: PropTypes.func.isRequired,
-};
-
-CreatePage.getHotkeyStrokes = () => {
-  return [['c']];
-};
-
-CreatePage.displayName = 'CreatePage';
-
-export default CreatePage;

+ 40 - 0
apps/app/src/client/components/Hotkeys/Subscribers/CreatePage.spec.tsx

@@ -0,0 +1,40 @@
+import { cleanup, render } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+const mockOpen = vi.hoisted(() => vi.fn());
+
+vi.mock('~/states/page', () => ({
+  useCurrentPagePath: vi.fn(() => '/test/page'),
+}));
+vi.mock('~/states/ui/modal/page-create', () => ({
+  usePageCreateModalActions: vi.fn(() => ({ open: mockOpen })),
+}));
+
+const { CreatePage, hotkeyBindings } = await import('./CreatePage');
+
+afterEach(() => {
+  cleanup();
+  vi.clearAllMocks();
+});
+
+describe('CreatePage', () => {
+  describe('hotkeyBindings', () => {
+    it('defines "c" key as single category', () => {
+      expect(hotkeyBindings).toEqual({
+        keys: 'c',
+        category: 'single',
+      });
+    });
+  });
+
+  describe('behavior', () => {
+    it('opens create modal with current page path and calls onDeleteRender', () => {
+      const onDeleteRender = vi.fn();
+
+      render(<CreatePage onDeleteRender={onDeleteRender} />);
+
+      expect(mockOpen).toHaveBeenCalledWith('/test/page');
+      expect(onDeleteRender).toHaveBeenCalledOnce();
+    });
+  });
+});

+ 29 - 0
apps/app/src/client/components/Hotkeys/Subscribers/CreatePage.tsx

@@ -0,0 +1,29 @@
+import { useEffect } from 'react';
+
+import { useCurrentPagePath } from '~/states/page';
+import { usePageCreateModalActions } from '~/states/ui/modal/page-create';
+
+import type { HotkeyBindingDef } from '../HotkeysManager';
+
+type Props = {
+  onDeleteRender: () => void;
+};
+
+export const hotkeyBindings: HotkeyBindingDef = {
+  keys: 'c',
+  category: 'single',
+};
+
+const CreatePage = ({ onDeleteRender }: Props): null => {
+  const { open: openCreateModal } = usePageCreateModalActions();
+  const currentPath = useCurrentPagePath();
+
+  useEffect(() => {
+    openCreateModal(currentPath ?? '');
+    onDeleteRender();
+  }, [currentPath, openCreateModal, onDeleteRender]);
+
+  return null;
+};
+
+export { CreatePage };

+ 130 - 0
apps/app/src/client/components/Hotkeys/Subscribers/EditPage.spec.tsx

@@ -0,0 +1,130 @@
+import { cleanup, render, waitFor } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+const mockStartEditing = vi.hoisted(() => vi.fn());
+const mockToastError = vi.hoisted(() => vi.fn());
+const mockUseIsEditable = vi.hoisted(() => vi.fn());
+const mockUseCurrentPagePath = vi.hoisted(() => vi.fn());
+const mockUseCurrentPathname = vi.hoisted(() => vi.fn());
+
+vi.mock('next-i18next', () => ({
+  useTranslation: () => ({
+    t: (key: string, opts?: Record<string, string>) =>
+      `${key}:${JSON.stringify(opts)}`,
+  }),
+}));
+vi.mock('~/client/services/use-start-editing', () => ({
+  useStartEditing: () => mockStartEditing,
+}));
+vi.mock('~/client/util/toastr', () => ({
+  toastError: mockToastError,
+}));
+vi.mock('~/states/global', () => ({
+  useCurrentPathname: mockUseCurrentPathname,
+}));
+vi.mock('~/states/page', () => ({
+  useCurrentPagePath: mockUseCurrentPagePath,
+  useIsEditable: mockUseIsEditable,
+}));
+
+const { EditPage, hotkeyBindings } = await import('./EditPage');
+
+afterEach(() => {
+  cleanup();
+  vi.restoreAllMocks();
+});
+
+describe('EditPage', () => {
+  describe('hotkeyBindings', () => {
+    it('defines "e" key as single category', () => {
+      expect(hotkeyBindings).toEqual({
+        keys: 'e',
+        category: 'single',
+      });
+    });
+  });
+
+  describe('behavior', () => {
+    it('calls startEditing with current page path and then onDeleteRender', async () => {
+      mockUseIsEditable.mockReturnValue(true);
+      mockUseCurrentPagePath.mockReturnValue('/test/page');
+      mockUseCurrentPathname.mockReturnValue('/fallback');
+      mockStartEditing.mockResolvedValue(undefined);
+      const onDeleteRender = vi.fn();
+
+      render(<EditPage onDeleteRender={onDeleteRender} />);
+
+      await waitFor(() => {
+        expect(mockStartEditing).toHaveBeenCalledWith('/test/page');
+        expect(onDeleteRender).toHaveBeenCalledOnce();
+      });
+    });
+
+    it('falls back to currentPathname when currentPagePath is null', async () => {
+      mockUseIsEditable.mockReturnValue(true);
+      mockUseCurrentPagePath.mockReturnValue(null);
+      mockUseCurrentPathname.mockReturnValue('/fallback/path');
+      mockStartEditing.mockResolvedValue(undefined);
+      const onDeleteRender = vi.fn();
+
+      render(<EditPage onDeleteRender={onDeleteRender} />);
+
+      await waitFor(() => {
+        expect(mockStartEditing).toHaveBeenCalledWith('/fallback/path');
+      });
+    });
+
+    it('does not call startEditing when page is not editable', async () => {
+      mockUseIsEditable.mockReturnValue(false);
+      mockUseCurrentPagePath.mockReturnValue('/test/page');
+      mockUseCurrentPathname.mockReturnValue('/fallback');
+      const onDeleteRender = vi.fn();
+
+      render(<EditPage onDeleteRender={onDeleteRender} />);
+
+      // Give async useEffect time to execute
+      await waitFor(() => {
+        expect(mockStartEditing).not.toHaveBeenCalled();
+      });
+    });
+
+    it('does not call startEditing when a modal is open', async () => {
+      mockUseIsEditable.mockReturnValue(true);
+      mockUseCurrentPagePath.mockReturnValue('/test/page');
+      mockUseCurrentPathname.mockReturnValue('/fallback');
+
+      // Simulate an open Bootstrap modal in the DOM
+      // happy-dom does not fully support multi-class getElementsByClassName,
+      // so we spy on the boundary (DOM API) directly
+      const mockCollection = [document.createElement('div')];
+      vi.spyOn(document, 'getElementsByClassName').mockReturnValue(
+        mockCollection as unknown as HTMLCollectionOf<Element>,
+      );
+
+      const onDeleteRender = vi.fn();
+
+      render(<EditPage onDeleteRender={onDeleteRender} />);
+
+      await waitFor(() => {
+        expect(mockStartEditing).not.toHaveBeenCalled();
+      });
+    });
+
+    it('shows toast error when startEditing fails', async () => {
+      mockUseIsEditable.mockReturnValue(true);
+      mockUseCurrentPagePath.mockReturnValue('/failing/page');
+      mockUseCurrentPathname.mockReturnValue('/fallback');
+      mockStartEditing.mockRejectedValue(new Error('edit failed'));
+      const onDeleteRender = vi.fn();
+
+      render(<EditPage onDeleteRender={onDeleteRender} />);
+
+      await waitFor(() => {
+        expect(mockToastError).toHaveBeenCalledWith(
+          expect.stringContaining('toaster.create_failed'),
+        );
+        expect(onDeleteRender).toHaveBeenCalledOnce();
+      });
+    });
+  });
+});

+ 8 - 5
apps/app/src/client/components/Hotkeys/Subscribers/EditPage.tsx

@@ -6,10 +6,17 @@ import { toastError } from '~/client/util/toastr';
 import { useCurrentPathname } from '~/states/global';
 import { useCurrentPagePath, useIsEditable } from '~/states/page';
 
+import type { HotkeyBindingDef } from '../HotkeysManager';
+
 type Props = {
   onDeleteRender: () => void;
 };
 
+export const hotkeyBindings: HotkeyBindingDef = {
+  keys: 'e',
+  category: 'single',
+};
+
 /**
  * Custom hook for edit page logic
  */
@@ -68,8 +75,4 @@ const EditPage = (props: Props): null => {
   return null;
 };
 
-EditPage.getHotkeyStrokes = () => {
-  return [['e']];
-};
-
-export default EditPage;
+export { EditPage };

+ 69 - 0
apps/app/src/client/components/Hotkeys/Subscribers/FocusToGlobalSearch.spec.tsx

@@ -0,0 +1,69 @@
+import { cleanup, render } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+const mockOpen = vi.hoisted(() => vi.fn());
+const mockUseIsEditable = vi.hoisted(() => vi.fn());
+const mockUseSearchModalStatus = vi.hoisted(() => vi.fn());
+
+vi.mock('~/states/page', () => ({
+  useIsEditable: mockUseIsEditable,
+}));
+vi.mock('~/features/search/client/states/modal/search', () => ({
+  useSearchModalStatus: mockUseSearchModalStatus,
+  useSearchModalActions: vi.fn(() => ({ open: mockOpen })),
+}));
+
+const { FocusToGlobalSearch, hotkeyBindings } = await import(
+  './FocusToGlobalSearch'
+);
+
+afterEach(() => {
+  cleanup();
+  vi.clearAllMocks();
+});
+
+describe('FocusToGlobalSearch', () => {
+  describe('hotkeyBindings', () => {
+    it('defines "/" key as single category', () => {
+      expect(hotkeyBindings).toEqual({
+        keys: '/',
+        category: 'single',
+      });
+    });
+  });
+
+  describe('behavior', () => {
+    it('opens search modal when editable and not already opened, then calls onDeleteRender', () => {
+      mockUseIsEditable.mockReturnValue(true);
+      mockUseSearchModalStatus.mockReturnValue({ isOpened: false });
+      const onDeleteRender = vi.fn();
+
+      render(<FocusToGlobalSearch onDeleteRender={onDeleteRender} />);
+
+      expect(mockOpen).toHaveBeenCalledOnce();
+      expect(onDeleteRender).toHaveBeenCalledOnce();
+    });
+
+    it('does not open search modal when not editable', () => {
+      mockUseIsEditable.mockReturnValue(false);
+      mockUseSearchModalStatus.mockReturnValue({ isOpened: false });
+      const onDeleteRender = vi.fn();
+
+      render(<FocusToGlobalSearch onDeleteRender={onDeleteRender} />);
+
+      expect(mockOpen).not.toHaveBeenCalled();
+      expect(onDeleteRender).not.toHaveBeenCalled();
+    });
+
+    it('does not open search modal when already opened', () => {
+      mockUseIsEditable.mockReturnValue(true);
+      mockUseSearchModalStatus.mockReturnValue({ isOpened: true });
+      const onDeleteRender = vi.fn();
+
+      render(<FocusToGlobalSearch onDeleteRender={onDeleteRender} />);
+
+      expect(mockOpen).not.toHaveBeenCalled();
+      expect(onDeleteRender).not.toHaveBeenCalled();
+    });
+  });
+});

+ 15 - 10
apps/app/src/client/components/Hotkeys/Subscribers/FocusToGlobalSearch.jsx → apps/app/src/client/components/Hotkeys/Subscribers/FocusToGlobalSearch.tsx

@@ -6,12 +6,22 @@ import {
 } from '~/features/search/client/states/modal/search';
 import { useIsEditable } from '~/states/page';
 
-const FocusToGlobalSearch = (props) => {
+import type { HotkeyBindingDef } from '../HotkeysManager';
+
+type Props = {
+  onDeleteRender: () => void;
+};
+
+export const hotkeyBindings: HotkeyBindingDef = {
+  keys: '/',
+  category: 'single',
+};
+
+const FocusToGlobalSearch = ({ onDeleteRender }: Props): null => {
   const isEditable = useIsEditable();
   const searchModalData = useSearchModalStatus();
   const { open: openSearchModal } = useSearchModalActions();
 
-  // setup effect
   useEffect(() => {
     if (!isEditable) {
       return;
@@ -19,16 +29,11 @@ const FocusToGlobalSearch = (props) => {
 
     if (!searchModalData.isOpened) {
       openSearchModal();
-      // remove this
-      props.onDeleteRender();
+      onDeleteRender();
     }
-  }, [isEditable, openSearchModal, props, searchModalData.isOpened]);
+  }, [isEditable, openSearchModal, onDeleteRender, searchModalData.isOpened]);
 
   return null;
 };
 
-FocusToGlobalSearch.getHotkeyStrokes = () => {
-  return [['/']];
-};
-
-export default FocusToGlobalSearch;
+export { FocusToGlobalSearch };

+ 61 - 0
apps/app/src/client/components/Hotkeys/Subscribers/ShowShortcutsModal.spec.tsx

@@ -0,0 +1,61 @@
+import { cleanup, render } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+const mockOpen = vi.hoisted(() => vi.fn());
+const mockUseShortcutsModalStatus = vi.hoisted(() => vi.fn());
+
+vi.mock('~/states/ui/modal/shortcuts', () => ({
+  useShortcutsModalStatus: mockUseShortcutsModalStatus,
+  useShortcutsModalActions: vi.fn(() => ({ open: mockOpen })),
+}));
+
+const { ShowShortcutsModal, hotkeyBindings } = await import(
+  './ShowShortcutsModal'
+);
+
+afterEach(() => {
+  cleanup();
+  vi.clearAllMocks();
+});
+
+describe('ShowShortcutsModal', () => {
+  describe('hotkeyBindings', () => {
+    it('defines Ctrl+/ and Meta+/ as modifier category', () => {
+      expect(hotkeyBindings).toEqual({
+        keys: ['Control+/', 'Meta+/'],
+        category: 'modifier',
+      });
+    });
+  });
+
+  describe('behavior', () => {
+    it('opens shortcuts modal when not already opened and calls onDeleteRender', () => {
+      mockUseShortcutsModalStatus.mockReturnValue({ isOpened: false });
+      const onDeleteRender = vi.fn();
+
+      render(<ShowShortcutsModal onDeleteRender={onDeleteRender} />);
+
+      expect(mockOpen).toHaveBeenCalledOnce();
+      expect(onDeleteRender).toHaveBeenCalledOnce();
+    });
+
+    it('does not open modal when already opened', () => {
+      mockUseShortcutsModalStatus.mockReturnValue({ isOpened: true });
+      const onDeleteRender = vi.fn();
+
+      render(<ShowShortcutsModal onDeleteRender={onDeleteRender} />);
+
+      expect(mockOpen).not.toHaveBeenCalled();
+      expect(onDeleteRender).not.toHaveBeenCalled();
+    });
+
+    it('does not open modal when status is null', () => {
+      mockUseShortcutsModalStatus.mockReturnValue(null);
+      const onDeleteRender = vi.fn();
+
+      render(<ShowShortcutsModal onDeleteRender={onDeleteRender} />);
+
+      expect(mockOpen).not.toHaveBeenCalled();
+    });
+  });
+});

+ 12 - 12
apps/app/src/client/components/Hotkeys/Subscribers/ShowShortcutsModal.tsx

@@ -1,20 +1,25 @@
-import React, { type JSX, useEffect } from 'react';
+import { useEffect } from 'react';
 
 import {
   useShortcutsModalActions,
   useShortcutsModalStatus,
 } from '~/states/ui/modal/shortcuts';
 
+import type { HotkeyBindingDef } from '../HotkeysManager';
+
 type Props = {
   onDeleteRender: () => void;
 };
-const ShowShortcutsModal = (props: Props): JSX.Element => {
+
+export const hotkeyBindings: HotkeyBindingDef = {
+  keys: ['Control+/', 'Meta+/'],
+  category: 'modifier',
+};
+
+const ShowShortcutsModal = ({ onDeleteRender }: Props): null => {
   const status = useShortcutsModalStatus();
   const { open } = useShortcutsModalActions();
 
-  const { onDeleteRender } = props;
-
-  // setup effect
   useEffect(() => {
     if (status == null) {
       return;
@@ -22,16 +27,11 @@ const ShowShortcutsModal = (props: Props): JSX.Element => {
 
     if (!status.isOpened) {
       open();
-      // remove this
       onDeleteRender();
     }
   }, [onDeleteRender, open, status]);
 
-  return <></>;
-};
-
-ShowShortcutsModal.getHotkeyStrokes = () => {
-  return [['/+ctrl'], ['/+meta']];
+  return null;
 };
 
-export default ShowShortcutsModal;
+export { ShowShortcutsModal };

+ 0 - 30
apps/app/src/client/components/Hotkeys/Subscribers/ShowStaffCredit.jsx

@@ -1,30 +0,0 @@
-import PropTypes from 'prop-types';
-
-import StaffCredit from '../../StaffCredit/StaffCredit';
-
-const ShowStaffCredit = (props) => {
-  return <StaffCredit onClosed={() => props.onDeleteRender(this)} />;
-};
-
-ShowStaffCredit.propTypes = {
-  onDeleteRender: PropTypes.func.isRequired,
-};
-
-ShowStaffCredit.getHotkeyStrokes = () => {
-  return [
-    [
-      'ArrowUp',
-      'ArrowUp',
-      'ArrowDown',
-      'ArrowDown',
-      'ArrowLeft',
-      'ArrowRight',
-      'ArrowLeft',
-      'ArrowRight',
-      'b',
-      'a',
-    ],
-  ];
-};
-
-export default ShowStaffCredit;

+ 39 - 0
apps/app/src/client/components/Hotkeys/Subscribers/ShowStaffCredit.spec.tsx

@@ -0,0 +1,39 @@
+import { cleanup, render, screen } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+vi.mock('../../StaffCredit/StaffCredit', () => ({
+  default: vi.fn(() => <div data-testid="staff-credit">StaffCredit</div>),
+}));
+
+const { default: StaffCredit } = await import('../../StaffCredit/StaffCredit');
+const { ShowStaffCredit, hotkeyBindings } = await import('./ShowStaffCredit');
+
+afterEach(() => {
+  cleanup();
+  vi.clearAllMocks();
+});
+
+describe('ShowStaffCredit', () => {
+  describe('hotkeyBindings', () => {
+    it('defines the Konami code sequence as modifier category', () => {
+      expect(hotkeyBindings).toEqual({
+        keys: 'ArrowUp ArrowUp ArrowDown ArrowDown ArrowLeft ArrowRight ArrowLeft ArrowRight b a',
+        category: 'modifier',
+      });
+    });
+  });
+
+  describe('behavior', () => {
+    it('renders StaffCredit with onDeleteRender passed as onClosed', () => {
+      const onDeleteRender = vi.fn();
+
+      render(<ShowStaffCredit onDeleteRender={onDeleteRender} />);
+
+      expect(StaffCredit).toHaveBeenCalledWith(
+        expect.objectContaining({ onClosed: onDeleteRender }),
+        expect.anything(),
+      );
+      expect(screen.getByTestId('staff-credit')).toBeDefined();
+    });
+  });
+});

+ 19 - 0
apps/app/src/client/components/Hotkeys/Subscribers/ShowStaffCredit.tsx

@@ -0,0 +1,19 @@
+import type { JSX } from 'react';
+
+import StaffCredit from '../../StaffCredit/StaffCredit';
+import type { HotkeyBindingDef } from '../HotkeysManager';
+
+type Props = {
+  onDeleteRender: () => void;
+};
+
+export const hotkeyBindings: HotkeyBindingDef = {
+  keys: 'ArrowUp ArrowUp ArrowDown ArrowDown ArrowLeft ArrowRight ArrowLeft ArrowRight b a',
+  category: 'modifier',
+};
+
+const ShowStaffCredit = ({ onDeleteRender }: Props): JSX.Element => {
+  return <StaffCredit onClosed={onDeleteRender} />;
+};
+
+export { ShowStaffCredit };

+ 0 - 24
apps/app/src/client/components/Hotkeys/Subscribers/SwitchToMirrorMode.jsx

@@ -1,24 +0,0 @@
-import React, { useEffect } from 'react';
-import PropTypes from 'prop-types';
-
-const SwitchToMirrorMode = (props) => {
-  // setup effect
-  useEffect(() => {
-    document.body.classList.add('mirror');
-
-    // remove this
-    props.onDeleteRender(this);
-  }, [props]);
-
-  return <></>;
-};
-
-SwitchToMirrorMode.propTypes = {
-  onDeleteRender: PropTypes.func.isRequired,
-};
-
-SwitchToMirrorMode.getHotkeyStrokes = () => {
-  return [['x', 'x', 'b', 'b', 'a', 'y', 'a', 'y', 'ArrowDown', 'ArrowLeft']];
-};
-
-export default SwitchToMirrorMode;

+ 33 - 0
apps/app/src/client/components/Hotkeys/Subscribers/SwitchToMirrorMode.spec.tsx

@@ -0,0 +1,33 @@
+import { cleanup, render } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { hotkeyBindings, SwitchToMirrorMode } from './SwitchToMirrorMode';
+
+afterEach(() => {
+  cleanup();
+  document.body.classList.remove('mirror');
+});
+
+describe('SwitchToMirrorMode', () => {
+  describe('hotkeyBindings', () => {
+    it('defines the Konami-style key sequence as modifier category', () => {
+      expect(hotkeyBindings).toEqual({
+        keys: 'x x b b a y a y ArrowDown ArrowLeft',
+        category: 'modifier',
+      });
+    });
+  });
+
+  describe('behavior', () => {
+    it('adds "mirror" class to document.body and calls onDeleteRender', () => {
+      const onDeleteRender = vi.fn();
+
+      expect(document.body.classList.contains('mirror')).toBe(false);
+
+      render(<SwitchToMirrorMode onDeleteRender={onDeleteRender} />);
+
+      expect(document.body.classList.contains('mirror')).toBe(true);
+      expect(onDeleteRender).toHaveBeenCalledOnce();
+    });
+  });
+});

+ 23 - 0
apps/app/src/client/components/Hotkeys/Subscribers/SwitchToMirrorMode.tsx

@@ -0,0 +1,23 @@
+import { useEffect } from 'react';
+
+import type { HotkeyBindingDef } from '../HotkeysManager';
+
+type Props = {
+  onDeleteRender: () => void;
+};
+
+export const hotkeyBindings: HotkeyBindingDef = {
+  keys: 'x x b b a y a y ArrowDown ArrowLeft',
+  category: 'modifier',
+};
+
+const SwitchToMirrorMode = ({ onDeleteRender }: Props): null => {
+  useEffect(() => {
+    document.body.classList.add('mirror');
+    onDeleteRender();
+  }, [onDeleteRender]);
+
+  return null;
+};
+
+export { SwitchToMirrorMode };

+ 0 - 61
apps/app/src/client/models/HotkeyStroke.js

@@ -1,61 +0,0 @@
-import loggerFactory from '~/utils/logger';
-
-const logger = loggerFactory('growi:cli:HotkeyStroke');
-
-export default class HotkeyStroke {
-  constructor(stroke) {
-    this.stroke = stroke;
-    this.activeIndices = [];
-  }
-
-  get firstKey() {
-    return this.stroke[0];
-  }
-
-  /**
-   * Evaluate whether the specified key completes stroke or not
-   * @param {string} key
-   * @return T/F whether the specified key completes stroke or not
-   */
-  evaluate(key) {
-    if (key === this.firstKey) {
-      // add a new active index
-      this.activeIndices.push(0);
-    }
-
-    let isCompleted = false;
-    this.activeIndices = this.activeIndices
-      .map((index) => {
-        // return null when key does not match
-        if (key !== this.stroke[index]) {
-          return null;
-        }
-
-        const nextIndex = index + 1;
-
-        if (this.stroke.length <= nextIndex) {
-          isCompleted = true;
-          return null;
-        }
-
-        return nextIndex;
-      })
-      // exclude null
-      .filter((index) => index != null);
-
-    // reset if completed
-    if (isCompleted) {
-      this.activeIndices = [];
-    }
-
-    logger.debug(
-      'activeIndices for [',
-      this.stroke,
-      '] => [',
-      this.activeIndices,
-      ']',
-    );
-
-    return isCompleted;
-  }
-}

+ 8 - 13
pnpm-lock.yaml

@@ -947,9 +947,6 @@ importers:
       react-hook-form:
         specifier: ^7.45.4
         version: 7.52.0(react@18.2.0)
-      react-hotkeys:
-        specifier: ^2.0.0
-        version: 2.0.0(react@18.2.0)
       react-input-autosize:
         specifier: ^3.0.0
         version: 3.0.0(react@18.2.0)
@@ -980,6 +977,9 @@ importers:
       swagger2openapi:
         specifier: ^7.0.8
         version: 7.0.8(encoding@0.1.13)
+      tinykeys:
+        specifier: ^3.0.0
+        version: 3.0.0
       unist-util-is:
         specifier: ^6.0.0
         version: 6.0.0
@@ -11920,11 +11920,6 @@ packages:
     peerDependencies:
       react: ^16.8.0 || ^17 || ^18 || ^19
 
-  react-hotkeys@2.0.0:
-    resolution: {integrity: sha512-3n3OU8vLX/pfcJrR3xJ1zlww6KS1kEJt0Whxc4FiGV+MJrQ1mYSYI3qS/11d2MJDFm8IhOXMTFQirfu6AVOF6Q==}
-    peerDependencies:
-      react: '>= 0.14.0'
-
   react-i18next@15.1.1:
     resolution: {integrity: sha512-R/Vg9wIli2P3FfeI8o1eNJUJue5LWpFsQePCHdQDmX0Co3zkr6kdT8gAseb/yGeWbNz1Txc4bKDQuZYsC0kQfw==}
     peerDependencies:
@@ -13322,6 +13317,9 @@ packages:
     resolution: {integrity: sha512-NbBoFBpqfcgd1tCiO8Lkfdk+xrA7mlLR9zgvZcZWQQwU63XAfUePyd6wZBaU93Hqw347lHnwFzttAkemHzzz4g==}
     engines: {node: '>=12.0.0'}
 
+  tinykeys@3.0.0:
+    resolution: {integrity: sha512-nazawuGv5zx6MuDfDY0rmfXjuOGhD5XU2z0GLURQ1nzl0RUe9OuCJq+0u8xxJZINHe+mr7nw8PWYYZ9WhMFujw==}
+
   tinypool@1.0.1:
     resolution: {integrity: sha512-URZYihUbRPcGv95En+sz6MfghfIc2OJ1sv/RmhWZLouPY0/8Vo80viwPvg3dlaS9fuq7fQMEfgRRK7BBZThBEA==}
     engines: {node: ^18.0.0 || >=20.0.0}
@@ -27370,11 +27368,6 @@ snapshots:
     dependencies:
       react: 18.2.0
 
-  react-hotkeys@2.0.0(react@18.2.0):
-    dependencies:
-      prop-types: 15.8.1
-      react: 18.2.0
-
   react-i18next@15.1.1(i18next@23.16.5)(react-dom@18.2.0(react@18.2.0))(react@18.2.0):
     dependencies:
       '@babel/runtime': 7.25.4
@@ -29223,6 +29216,8 @@ snapshots:
       fdir: 6.3.0(picomatch@4.0.2)
       picomatch: 4.0.2
 
+  tinykeys@3.0.0: {}
+
   tinypool@1.0.1: {}
 
   tinyrainbow@1.2.0: {}