diff --git a/packages/frontend/src/boot/main-boot.ts b/packages/frontend/src/boot/main-boot.ts index 509c9044b5b1..4bc66126a9a8 100644 --- a/packages/frontend/src/boot/main-boot.ts +++ b/packages/frontend/src/boot/main-boot.ts @@ -12,7 +12,6 @@ import { useStream } from '@/stream.js'; import * as sound from '@/scripts/sound.js'; import { $i, signout, updateAccount } from '@/account.js'; import { ColdDeviceStorage, defaultStore } from '@/store.js'; -import { makeHotkey } from '@/scripts/hotkey.js'; import { reactionPicker } from '@/scripts/reaction-picker.js'; import { miLocalStorage } from '@/local-storage.js'; import { claimAchievement, claimedAchievements } from '@/scripts/achievements.js'; @@ -20,6 +19,7 @@ import { initializeSw } from '@/scripts/initialize-sw.js'; import { deckStore } from '@/ui/deck/deck-store.js'; import { emojiPicker } from '@/scripts/emoji-picker.js'; import { mainRouter } from '@/router/main.js'; +import { makeHotkey } from '@/scripts/tms/hotkey.js'; export async function mainBoot() { const { isClientUpdated } = await common(() => createApp( diff --git a/packages/frontend/src/directives/hotkey.ts b/packages/frontend/src/directives/hotkey.ts index 4e60582dc70e..097d12e2f780 100644 --- a/packages/frontend/src/directives/hotkey.ts +++ b/packages/frontend/src/directives/hotkey.ts @@ -4,7 +4,7 @@ */ import { Directive } from 'vue'; -import { makeHotkey } from '../scripts/hotkey.js'; +import { makeHotkey } from '@/scripts/tms/hotkey.js'; export default { mounted(el, binding) { diff --git a/packages/frontend/src/scripts/hotkey.ts b/packages/frontend/src/scripts/hotkey.ts deleted file mode 100644 index 0a04b7518da3..000000000000 --- a/packages/frontend/src/scripts/hotkey.ts +++ /dev/null @@ -1,96 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import keyCode from './keycode.js'; - -type Callback = (ev: KeyboardEvent) => void; - -type Keymap = Record; - -type Pattern = { - which: string[]; - ctrl?: boolean; - shift?: boolean; - alt?: boolean; -}; - -type Action = { - patterns: Pattern[]; - callback: Callback; - allowRepeat: boolean; -}; - -const parseKeymap = (keymap: Keymap) => Object.entries(keymap).map(([patterns, callback]): Action => { - const result = { - patterns: [], - callback, - allowRepeat: true, - } as Action; - - if (patterns.match(/^\(.*\)$/) !== null) { - result.allowRepeat = false; - patterns = patterns.slice(1, -1); - } - - result.patterns = patterns.split('|').map(part => { - const pattern = { - which: [], - ctrl: false, - alt: false, - shift: false, - } as Pattern; - - const keys = part.trim().split('+').map(x => x.trim().toLowerCase()); - for (const key of keys) { - switch (key) { - case 'ctrl': pattern.ctrl = true; break; - case 'alt': pattern.alt = true; break; - case 'shift': pattern.shift = true; break; - default: pattern.which = keyCode(key).map(k => k.toLowerCase()); - } - } - - return pattern; - }); - - return result; -}); - -const ignoreElements = ['input', 'textarea']; - -function match(ev: KeyboardEvent, patterns: Action['patterns']): boolean { - const key = ev.key.toLowerCase(); - return patterns.some(pattern => pattern.which.includes(key) && - pattern.ctrl === ev.ctrlKey && - pattern.shift === ev.shiftKey && - pattern.alt === ev.altKey && - !ev.metaKey, - ); -} - -export const makeHotkey = (keymap: Keymap) => { - const actions = parseKeymap(keymap); - - return (ev: KeyboardEvent) => { - if (document.activeElement) { - if (ignoreElements.some(el => document.activeElement!.matches(el))) return; - if (document.activeElement.attributes['contenteditable']) return; - } - if ('pswp' in window && window.pswp != null) return; - - for (const action of actions) { - const matched = match(ev, action.patterns); - - if (matched) { - if (!action.allowRepeat && ev.repeat) return; - - ev.preventDefault(); - ev.stopPropagation(); - action.callback(ev); - break; - } - } - }; -}; diff --git a/packages/frontend/src/scripts/keycode.ts b/packages/frontend/src/scripts/keycode.ts deleted file mode 100644 index 397757cfefdb..000000000000 --- a/packages/frontend/src/scripts/keycode.ts +++ /dev/null @@ -1,25 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -export default (input: string): string[] => { - if (Object.keys(aliases).some(a => a.toLowerCase() === input.toLowerCase())) { - const codes = aliases[input]; - return Array.isArray(codes) ? codes : [codes]; - } else { - return [input]; - } -}; - -// https://developer.mozilla.org/ja/docs/Web/API/UI_Events/Keyboard_event_key_values -export const aliases = { - 'esc': 'Escape', - 'enter': 'Enter', - 'space': ' ', - 'up': 'ArrowUp', - 'down': 'ArrowDown', - 'left': 'ArrowLeft', - 'right': 'ArrowRight', - 'plus': ['+', ';'], -}; diff --git a/packages/frontend/src/scripts/tms/hotkey.ts b/packages/frontend/src/scripts/tms/hotkey.ts new file mode 100644 index 000000000000..bd8c0eec67c8 --- /dev/null +++ b/packages/frontend/src/scripts/tms/hotkey.ts @@ -0,0 +1,106 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { getHTMLElementOrNull } from '@/scripts/tms/get-or-null.js'; + +//#region types +type Callback = (ev: KeyboardEvent) => unknown; + +type Keymap = Record; + +type Pattern = { + which: string[]; + ctrl: boolean; + alt: boolean; + shift: boolean; +}; + +type Action = { + patterns: Pattern[]; + callback: Callback; +}; +//#endregion + +//#region consts +const KEY_ALIASES = { + 'esc': 'Escape', + 'enter': 'Enter', + 'space': ' ', + 'up': 'ArrowUp', + 'down': 'ArrowDown', + 'left': 'ArrowLeft', + 'right': 'ArrowRight', + 'plus': ['+', ';'], +}; + +const IGNORE_ELEMENTS = ['input', 'textarea']; + +const ALLOW_REPEAT_KEYS = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight']; +//#endregion + +//#region impl +export const makeHotkey = (keymap: Keymap) => { + const actions = parseKeymap(keymap); + return (ev: KeyboardEvent) => { + if (ev.repeat && !ALLOW_REPEAT_KEYS.includes(ev.key)) return; + if ('pswp' in window && window.pswp != null) return; + if (document.activeElement != null) { + if (IGNORE_ELEMENTS.includes(document.activeElement.tagName.toLowerCase())) return; + if (getHTMLElementOrNull(document.activeElement)?.isContentEditable) return; + } + for (const { patterns, callback } of actions) { + if (matchPatterns(ev, patterns)) { + ev.preventDefault(); + ev.stopPropagation(); + callback(ev); + } + } + }; +}; + +const parseKeymap = (keymap: Keymap) => { + return Object.entries(keymap).map(([rawPatterns, callback]) => { + const patterns = parsePatterns(rawPatterns); + return { patterns, callback } as const satisfies Action; + }); +}; + +const parsePatterns = (rawPatterns: keyof Keymap) => { + return rawPatterns.split('|').map(part => { + const keys = part.split('+').map(x => x.trim().toLowerCase()); + const which = parseKeyCode(keys.findLast(x => !['ctrl', 'alt', 'shift'].includes(x))); + const ctrl = keys.includes('ctrl'); + const alt = keys.includes('alt'); + const shift = keys.includes('shift'); + return { which, ctrl, alt, shift } as const satisfies Pattern; + }); +}; + +const matchPatterns = (ev: KeyboardEvent, patterns: Action['patterns']) => { + const key = ev.key.toLowerCase(); + return patterns.some(({ which, ctrl, shift, alt }) => { + if (!which.includes(key)) return false; + if (ctrl !== (ev.ctrlKey || ev.metaKey)) return false; + if (alt !== ev.altKey) return false; + if (shift !== ev.shiftKey) return false; + return true; + }); +}; + +const parseKeyCode = (input?: string | null) => { + if (input == null) return []; + const raw = getValueByKey(KEY_ALIASES, input); + if (raw == null) return [input]; + if (typeof raw === 'string') return [raw]; + return [...raw]; +}; + +const getValueByKey = < + T extends Record, + K extends keyof T | keyof any, +>(obj: T, key: K) => { + return obj[key] as K extends keyof T ? T[K] : T[keyof T] | undefined; +}; +//#endregion