diff --git a/package.json b/package.json index 10d7069c38..72c281c52b 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,6 @@ "fast-array-diff": "1.1.0", "fast-base64": "0.1.8", "glob": "8.0.3", - "hotkeys-js": "3.13.6", "immer": "9.0.21", "markdown-it": "13.0.2", "midi-file": "1.2.4", diff --git a/src/components/App.vue b/src/components/App.vue index 1dc3451b31..6712656d82 100644 --- a/src/components/App.vue +++ b/src/components/App.vue @@ -20,7 +20,7 @@ diff --git a/src/components/Menu/MenuBar/MenuBar.vue b/src/components/Menu/MenuBar/MenuBar.vue index 4efd2c0f46..ccba686cb3 100644 --- a/src/components/Menu/MenuBar/MenuBar.vue +++ b/src/components/Menu/MenuBar/MenuBar.vue @@ -32,8 +32,9 @@ import { MenuItemData, MenuItemRoot } from "../type"; import MenuButton from "../MenuButton.vue"; import TitleBarButtons from "./TitleBarButtons.vue"; import TitleBarEditorSwitcher from "./TitleBarEditorSwitcher.vue"; +import { EditorType } from "@/type/preload"; import { useStore } from "@/store"; -import { HotkeyAction, useHotkeyManager } from "@/plugins/hotkeyPlugin"; +import { useHotkeyManager } from "@/plugins/hotkeyPlugin"; import { useEngineIcons } from "@/composables/useEngineIcons"; const props = defineProps<{ @@ -42,7 +43,7 @@ const props = defineProps<{ /** 「編集」メニューのサブメニュー */ editSubMenuData: MenuItemData[]; /** エディタの種類 */ - editor: "talk" | "song"; + editor: EditorType; }>(); const store = useStore(); @@ -499,34 +500,23 @@ watch(uiLocked, () => { } }); -/** - * 全エディタに対してホットキーを登録する - * FIXME: hotkeyPlugin側で全エディタに対して登録できるようにする - */ -function registerHotkeyForAllEditors(action: Omit) { - registerHotkeyWithCleanup({ - editor: "talk", - ...action, - }); - registerHotkeyWithCleanup({ - editor: "song", - ...action, - }); -} - -registerHotkeyForAllEditors({ +registerHotkeyWithCleanup({ + editor: "talk&song", callback: createNewProject, name: "新規プロジェクト", }); -registerHotkeyForAllEditors({ +registerHotkeyWithCleanup({ + editor: "talk&song", callback: saveProject, name: "プロジェクトを上書き保存", }); -registerHotkeyForAllEditors({ +registerHotkeyWithCleanup({ + editor: "talk&song", callback: saveProjectAs, name: "プロジェクトを名前を付けて保存", }); -registerHotkeyForAllEditors({ +registerHotkeyWithCleanup({ + editor: "talk&song", callback: importProject, name: "プロジェクトを読み込む", }); diff --git a/src/plugins/hotkeyPlugin.ts b/src/plugins/hotkeyPlugin.ts index c187075ef7..4301cfc7ca 100644 --- a/src/plugins/hotkeyPlugin.ts +++ b/src/plugins/hotkeyPlugin.ts @@ -4,15 +4,15 @@ * HotkeyAction: 実行する処理の名前とコールバックのペア * HotkeySetting: ユーザーが設定できるもの。ActionとCobinationのペア * Combination: ショートカットキーを文字列で表したもの - * binding: hotkeys-js に登録したコールバック - * bindingKey: hotkeys-js で使う、キーの文字列表記 + * binding: 登録したコールバック + * bindingKey: キーの文字列表記 */ import { Plugin, inject, onMounted, onUnmounted } from "vue"; -import hotkeys from "hotkeys-js"; import { HotkeyActionNameType, HotkeyCombination, HotkeySettingType, + EditorType, } from "@/type/preload"; import { createLogger } from "@/domain/frontend/log"; @@ -33,7 +33,7 @@ export const useHotkeyManager = () => { return { hotkeyManager, registerHotkeyWithCleanup }; }; -type Editor = "talk" | "song"; +type Editor = "talk" | "song" | "talk&song"; type BindingKey = string & { __brand: "BindingKey" }; // BindingKey専用のブランド型 @@ -51,22 +51,6 @@ export type HotkeyAction = { callback: (e: KeyboardEvent) => void; }; -export type HotkeysJs = { - ( - key: BindingKey, - options: { - scope: string; - }, - callback: (e: KeyboardEvent) => void, - ): void; - unbind: (key: BindingKey, scope: string) => void; - setScope: (scope: string) => void; -}; - -// デフォルトはテキストボックス内でショートカットキー無効なので有効にする -hotkeys.filter = () => { - return true; -}; type Log = (message: string, ...args: unknown[]) => void; type RegisteredCombination = { @@ -92,20 +76,17 @@ const isNotSameHotkeyTarget = (a: HotkeyTarget) => (b: HotkeyTarget) => { export class HotkeyManager { /** 登録されたHotkeyAction */ private actions: HotkeyAction[] = []; + /** スコープ */ + private scope: EditorType | undefined; /** ユーザーのショートカットキー設定 */ private settings: HotkeySettingType[] | undefined; // ユーザーのショートカットキー設定 - /** hotkeys-jsに登録されたショートカットキーの組み合わせ */ + /** 登録されたショートカットキーの組み合わせ */ private registeredCombinations: RegisteredCombination[] = []; - private hotkeys: HotkeysJs; private log: Log; - constructor( - hotkeys_: HotkeysJs = hotkeys, - log: Log = createLogger("HotkeyManager").info, - ) { + constructor(log: Log = createLogger("HotkeyManager").info) { this.log = log; - this.hotkeys = hotkeys_; } /** @@ -170,7 +151,6 @@ export class HotkeyManager { for (const combination of combinations) { const bindingKey = combinationToBindingKey(combination.combination); this.log("Unbind:", bindingKey, "in", combination.editor); - this.hotkeys.unbind(bindingKey, combination.editor); this.registeredCombinations = this.registeredCombinations.filter( isNotSameHotkeyTarget(combination), ); @@ -188,33 +168,6 @@ export class HotkeyManager { "in", action.editor, ); - this.hotkeys( - combinationToBindingKey(setting.combination), - { scope: action.editor }, - (e) => { - const element = e.target; - // メニュー項目ではショートカットキーを無効化 - if ( - element instanceof HTMLElement && - element.classList.contains("q-item") - ) { - return; - } - if (!action.enableInTextbox) { - if ( - element instanceof HTMLElement && - (element.tagName === "INPUT" || - element.tagName === "SELECT" || - element.tagName === "TEXTAREA" || - element.contentEditable === "true") - ) { - return; - } - } - e.preventDefault(); - action.callback(e); - }, - ); this.registeredCombinations = this.registeredCombinations.filter( isNotSameHotkeyTarget(action), ); @@ -265,27 +218,78 @@ export class HotkeyManager { /** * エディタが変更されたときに呼び出される。 */ - onEditorChange(editor: "talk" | "song"): void { - this.hotkeys.setScope(editor); + onEditorChange(editor: EditorType): void { + this.scope = editor; this.log("Editor changed to", editor); } + + keyInput(e: KeyboardEvent): void { + const element = e.target; + + if (this.scope == undefined) { + console.error("hotkeyPluginのスコープが未設定です"); + return; + } + + // メニュー項目・ダイアログではショートカットキーを無効化 + if ( + element instanceof HTMLElement && + (element.getAttribute("role") == "menu" || + element.classList.contains("q-dialog__inner")) + ) { + return; + } + + const isInTextbox = + element instanceof HTMLElement && + (element.tagName === "INPUT" || + element.tagName === "SELECT" || + element.tagName === "TEXTAREA" || + element.contentEditable === "true"); + + const combination = combinationToBindingKey(eventToCombination(e)); + + const actions = this.actions + .filter((item) => !isInTextbox || item.enableInTextbox) + .filter( + (item) => + combinationToBindingKey(this.getSetting(item).combination) == + combination, + ) + .filter((item) => { + if (item.editor === "talk&song") { + return this.scope === "talk" || this.scope === "song"; + } else if (item.editor === "talk") { + return this.scope === "talk"; + } else if (item.editor === "song") { + return this.scope === "song"; + } else { + console.error("scopeに対する処理が設定されていません"); + } + }); + if (actions.length == 0) { + return; + } + e.preventDefault(); + actions.forEach((action) => action.callback(e)); + } + + /** + * 現在登録されているHotkeyActionをすべて取得する + */ + getAllActions(): HotkeyAction[] { + return this.actions; + } } -/** hotkeys-js用のキーに変換する */ +/** 判定用のキーに変換する */ const combinationToBindingKey = ( combination: HotkeyCombination, ): BindingKey => { // MetaキーはCommandキーとして扱う - // NOTE: hotkeys-jsにはWinキーが無く、Commandキーとして扱われている // NOTE: Metaキーは以前採用していたmousetrapがそうだった名残り - // NOTE: hotkeys-jsでは方向キーのarrowプレフィックスが不要 - const bindingKey = combination - .toLowerCase() - .split(" ") - .map((key) => (key === "meta" ? "command" : key)) - .map((key) => key.replace("arrow", "")) - .join("+"); - return bindingKey as BindingKey; + // 順番が違うものも一致させるために並べ替え + return combination.split(" ").sort().join(" ") as BindingKey; }; export const hotkeyPlugin: Plugin = { diff --git a/tests/unit/lib/hotkeyManager.spec.ts b/tests/unit/lib/hotkeyManager.spec.ts index 57c1997d10..9aefe31927 100644 --- a/tests/unit/lib/hotkeyManager.spec.ts +++ b/tests/unit/lib/hotkeyManager.spec.ts @@ -1,54 +1,44 @@ import { describe, it, expect, beforeEach } from "vitest"; -import { HotkeyManager, HotkeysJs, HotkeyAction } from "@/plugins/hotkeyPlugin"; +import { HotkeyManager, HotkeyAction } from "@/plugins/hotkeyPlugin"; import { HotkeyCombination, HotkeySettingType } from "@/type/preload"; -type DummyHotkeysJs = HotkeysJs & { - registeredHotkeys: { - key: string; - scope: string; - // callbackを持たせると比較が面倒になるので持たせない - // callback: (e: KeyboardEvent) => void; - }[]; - currentScope: string; +type DummyKeyboardEvent = { + key: string; + code: string; + target: EventTarget | null; + ctrlKey: boolean; + shiftKey: boolean; + altKey: boolean; + metaKey: boolean; + preventDefault: () => void; }; -const createHotkeyManager = (): { - hotkeyManager: HotkeyManager; - dummyHotkeysJs: DummyHotkeysJs; -} => { - const registeredHotkeys: DummyHotkeysJs["registeredHotkeys"] = []; - const dummyHotkeysJs: DummyHotkeysJs = ( - key: string, - { scope }: { scope: string }, - ) => { - if (registeredHotkeys.some((h) => h.key === key && h.scope === scope)) { - throw new Error("assert: duplicate key"); - } - registeredHotkeys.push({ key, scope }); - }; - dummyHotkeysJs.unbind = (key: string) => { - const index = dummyHotkeysJs.registeredHotkeys.findIndex( - (h) => h.key === key, - ); - if (index === -1) { - throw new Error("assert: unknown binding"); - } - registeredHotkeys.splice(index, 1); - }; - dummyHotkeysJs.setScope = (scope: string) => { - dummyHotkeysJs.currentScope = scope; - }; - dummyHotkeysJs.registeredHotkeys = registeredHotkeys; - dummyHotkeysJs.currentScope = "talk"; - return { - hotkeyManager: new HotkeyManager(dummyHotkeysJs, () => { +const createDummyInput = ( + combinationKey: string, + combinationCode: string, +): DummyKeyboardEvent => { + const dummyInput: DummyKeyboardEvent = { + key: combinationKey, + code: combinationCode, + target: null, + ctrlKey: false, + shiftKey: false, + altKey: false, + metaKey: false, + preventDefault: () => { /* noop */ - }), - dummyHotkeysJs, + }, }; + return dummyInput; +}; + +const createHotkeyManager = (): HotkeyManager => { + return new HotkeyManager(() => { + /* noop */ + }); }; it("registerできる", () => { - const { hotkeyManager } = createHotkeyManager(); + const hotkeyManager = createHotkeyManager(); hotkeyManager.register({ editor: "talk", name: "音声書き出し", @@ -59,13 +49,11 @@ it("registerできる", () => { }); it("unregisterできる", () => { - const { hotkeyManager, dummyHotkeysJs } = createHotkeyManager(); + const hotkeyManager = createHotkeyManager(); const action = { editor: "talk", name: "音声書き出し", - callback: () => { - /* noop */ - }, + callback: vi.fn(), } as const; hotkeyManager.load([ { @@ -73,21 +61,19 @@ it("unregisterできる", () => { combination: HotkeyCombination("1"), }, ]); + hotkeyManager.onEditorChange("talk"); hotkeyManager.register(action); - expect(dummyHotkeysJs.registeredHotkeys).toEqual([ - { key: "1", scope: "talk" }, - ]); + expect(hotkeyManager.getAllActions()).toStrictEqual([action]); + hotkeyManager.unregister(action); - expect(dummyHotkeysJs.registeredHotkeys).toEqual([]); + expect(hotkeyManager.getAllActions()).toStrictEqual([]); }); -const callback = () => { - /* noop */ -}; +const callback = vi.fn(); const dummyAction: HotkeyAction = { editor: "talk", name: "音声書き出し", - callback, + callback: callback, }; const createDummySetting = (combination: string): HotkeySettingType => ({ action: "音声書き出し", @@ -96,63 +82,61 @@ const createDummySetting = (combination: string): HotkeySettingType => ({ describe("設定変更", () => { let hotkeyManager: HotkeyManager; - let dummyHotkeysJs: DummyHotkeysJs; beforeEach(() => { - const { hotkeyManager: hotkeyManager_, dummyHotkeysJs: dummyHotkeysJs_ } = - createHotkeyManager(); + const hotkeyManager_ = createHotkeyManager(); hotkeyManager = hotkeyManager_; - dummyHotkeysJs = dummyHotkeysJs_; + hotkeyManager.onEditorChange("talk"); + callback.mockClear(); }); it("設定を登録するとhotkeysが更新される", () => { hotkeyManager.register(dummyAction); hotkeyManager.load([createDummySetting("1")]); - expect(dummyHotkeysJs.registeredHotkeys).toEqual([ - { key: "1", scope: "talk" }, - ]); + hotkeyManager.keyInput(createDummyInput("1", "Digit1") as KeyboardEvent); + expect(callback).toHaveBeenCalledTimes(1); }); it("設定を更新するとhotkeysが更新される", () => { hotkeyManager.register(dummyAction); hotkeyManager.load([createDummySetting("1")]); - hotkeyManager.replace(createDummySetting("a")); + hotkeyManager.replace(createDummySetting("A")); + hotkeyManager.keyInput(createDummyInput("a", "KeyA") as KeyboardEvent); - expect(dummyHotkeysJs.registeredHotkeys).toEqual([ - { key: "a", scope: "talk" }, - ]); + expect(callback).toHaveBeenCalledTimes(1); }); it("未割り当てにするとhotkeysから削除される", () => { hotkeyManager.register(dummyAction); hotkeyManager.load([createDummySetting("1")]); hotkeyManager.replace(createDummySetting("")); - expect(dummyHotkeysJs.registeredHotkeys).toEqual([]); + hotkeyManager.keyInput(createDummyInput("1", "Digit1") as KeyboardEvent); + expect(callback).toHaveBeenCalledTimes(0); }); it("未割り当てから割り当てるとhotkeysが更新される", () => { hotkeyManager.register(dummyAction); hotkeyManager.load([createDummySetting("")]); - expect(dummyHotkeysJs.registeredHotkeys).toEqual([]); + hotkeyManager.keyInput(createDummyInput("1", "Digit1") as KeyboardEvent); + expect(callback).toHaveBeenCalledTimes(0); + hotkeyManager.replace(createDummySetting("1")); - expect(dummyHotkeysJs.registeredHotkeys).toEqual([ - { key: "1", scope: "talk" }, - ]); + hotkeyManager.keyInput(createDummyInput("1", "Digit1") as KeyboardEvent); + expect(callback).toHaveBeenCalledTimes(1); }); it("割り当て -> 未割り当て -> 割り当てでhotkeysが更新される", () => { hotkeyManager.register(dummyAction); hotkeyManager.load([createDummySetting("1")]); - expect(dummyHotkeysJs.registeredHotkeys).toEqual([ - { key: "1", scope: "talk" }, - ]); + hotkeyManager.keyInput(createDummyInput("1", "Digit1") as KeyboardEvent); + expect(callback).toHaveBeenCalledTimes(1); hotkeyManager.replace(createDummySetting("")); - expect(dummyHotkeysJs.registeredHotkeys).toEqual([]); + hotkeyManager.keyInput(createDummyInput("1", "Digit1") as KeyboardEvent); + expect(callback).toHaveBeenCalledTimes(1); // 呼び出し回数が増えない - hotkeyManager.replace(createDummySetting("a")); - expect(dummyHotkeysJs.registeredHotkeys).toEqual([ - { key: "a", scope: "talk" }, - ]); + hotkeyManager.replace(createDummySetting("A")); + hotkeyManager.keyInput(createDummyInput("a", "KeyA") as KeyboardEvent); + expect(callback).toHaveBeenCalledTimes(2); }); });