Yuki Takei 1 месяц назад
Родитель
Сommit
b31df3802b

+ 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;

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

@@ -0,0 +1,125 @@
+import { act, cleanup, render } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+// Mock all subscriber components as simple render trackers
+vi.mock('./Subscribers/EditPage', () => ({ default: vi.fn(() => null) }));
+vi.mock('./Subscribers/CreatePage', () => ({ default: vi.fn(() => null) }));
+vi.mock('./Subscribers/FocusToGlobalSearch', () => ({
+  default: vi.fn(() => null),
+}));
+vi.mock('./Subscribers/ShowShortcutsModal', () => ({
+  default: vi.fn(() => null),
+}));
+vi.mock('./Subscribers/ShowStaffCredit', () => ({
+  default: vi.fn(() => null),
+}));
+vi.mock('./Subscribers/SwitchToMirrorMode', () => ({
+  default: vi.fn(() => null),
+}));
+
+const { default: HotkeysManager } = await import('./HotkeysManager');
+const { default: EditPage } = await import('./Subscribers/EditPage');
+const { default: CreatePage } = await import('./Subscribers/CreatePage');
+const { default: FocusToGlobalSearch } = await import(
+  './Subscribers/FocusToGlobalSearch'
+);
+const { default: 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,
+  });
+  // jsdom 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('triggers EditPage on "e" key press', () => {
+    render(<HotkeysManager />);
+    act(() => {
+      pressKey('e');
+    });
+    expect(EditPage).toHaveBeenCalled();
+  });
+
+  it('triggers CreatePage on "c" key press', () => {
+    render(<HotkeysManager />);
+    act(() => {
+      pressKey('c');
+    });
+    expect(CreatePage).toHaveBeenCalled();
+  });
+
+  it('triggers FocusToGlobalSearch on "/" key press', () => {
+    render(<HotkeysManager />);
+    act(() => {
+      pressKey('/');
+    });
+    expect(FocusToGlobalSearch).toHaveBeenCalled();
+  });
+
+  it('triggers ShowShortcutsModal on Ctrl+/ key press', () => {
+    render(<HotkeysManager />);
+    act(() => {
+      pressKey('/', { ctrlKey: true });
+    });
+    expect(ShowShortcutsModal).toHaveBeenCalled();
+  });
+
+  it('does NOT trigger shortcut when target is an input 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);
+  });
+
+  it('does NOT trigger shortcut when target is a textarea element', () => {
+    render(<HotkeysManager />);
+    const textarea = document.createElement('textarea');
+    document.body.appendChild(textarea);
+
+    act(() => {
+      textarea.dispatchEvent(
+        new KeyboardEvent('keydown', {
+          key: 'c',
+          bubbles: true,
+          cancelable: true,
+        }),
+      );
+    });
+    expect(CreatePage).not.toHaveBeenCalled();
+
+    document.body.removeChild(textarea);
+  });
+});

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

@@ -0,0 +1,71 @@
+import type { JSX } from 'react';
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { tinykeys } from 'tinykeys';
+
+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';
+
+type SubscriberComponent = React.ComponentType<{ onDeleteRender: () => void }>;
+
+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 singleKeyHandler =
+      (Component: SubscriberComponent) => (event: KeyboardEvent) => {
+        if (isEditableTarget(event)) return;
+        event.preventDefault();
+        addView(Component);
+      };
+
+    const modifierKeyHandler =
+      (Component: SubscriberComponent) => (event: KeyboardEvent) => {
+        event.preventDefault();
+        addView(Component);
+      };
+
+    const unsubscribe = tinykeys(window, {
+      e: singleKeyHandler(EditPage),
+      c: singleKeyHandler(CreatePage),
+      '/': singleKeyHandler(FocusToGlobalSearch),
+      'Control+/': modifierKeyHandler(ShowShortcutsModal),
+      'Meta+/': modifierKeyHandler(ShowShortcutsModal),
+      'ArrowUp ArrowUp ArrowDown ArrowDown ArrowLeft ArrowRight ArrowLeft ArrowRight b a':
+        modifierKeyHandler(ShowStaffCredit),
+      'x x b b a y a y ArrowDown ArrowLeft':
+        modifierKeyHandler(SwitchToMirrorMode),
+    });
+
+    return unsubscribe;
+  }, [addView]);
+
+  return <>{views}</>;
+};
+
+export default HotkeysManager;

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

@@ -23,10 +23,6 @@ CreatePage.propTypes = {
   onDeleteRender: PropTypes.func.isRequired,
   onDeleteRender: PropTypes.func.isRequired,
 };
 };
 
 
-CreatePage.getHotkeyStrokes = () => {
-  return [['c']];
-};
-
 CreatePage.displayName = 'CreatePage';
 CreatePage.displayName = 'CreatePage';
 
 
 export default CreatePage;
 export default CreatePage;

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

@@ -68,8 +68,4 @@ const EditPage = (props: Props): null => {
   return null;
   return null;
 };
 };
 
 
-EditPage.getHotkeyStrokes = () => {
-  return [['e']];
-};
-
 export default EditPage;
 export default EditPage;

+ 0 - 4
apps/app/src/client/components/Hotkeys/Subscribers/FocusToGlobalSearch.jsx

@@ -27,8 +27,4 @@ const FocusToGlobalSearch = (props) => {
   return null;
   return null;
 };
 };
 
 
-FocusToGlobalSearch.getHotkeyStrokes = () => {
-  return [['/']];
-};
-
 export default FocusToGlobalSearch;
 export default FocusToGlobalSearch;

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

@@ -30,8 +30,4 @@ const ShowShortcutsModal = (props: Props): JSX.Element => {
   return <></>;
   return <></>;
 };
 };
 
 
-ShowShortcutsModal.getHotkeyStrokes = () => {
-  return [['/+ctrl'], ['/+meta']];
-};
-
 export default ShowShortcutsModal;
 export default ShowShortcutsModal;

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

@@ -10,21 +10,4 @@ ShowStaffCredit.propTypes = {
   onDeleteRender: PropTypes.func.isRequired,
   onDeleteRender: PropTypes.func.isRequired,
 };
 };
 
 
-ShowStaffCredit.getHotkeyStrokes = () => {
-  return [
-    [
-      'ArrowUp',
-      'ArrowUp',
-      'ArrowDown',
-      'ArrowDown',
-      'ArrowLeft',
-      'ArrowRight',
-      'ArrowLeft',
-      'ArrowRight',
-      'b',
-      'a',
-    ],
-  ];
-};
-
 export default ShowStaffCredit;
 export default ShowStaffCredit;

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

@@ -17,8 +17,4 @@ SwitchToMirrorMode.propTypes = {
   onDeleteRender: PropTypes.func.isRequired,
   onDeleteRender: PropTypes.func.isRequired,
 };
 };
 
 
-SwitchToMirrorMode.getHotkeyStrokes = () => {
-  return [['x', 'x', 'b', 'b', 'a', 'y', 'a', 'y', 'ArrowDown', 'ArrowLeft']];
-};
-
 export default SwitchToMirrorMode;
 export default 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;
-  }
-}