diff --git a/packages/frontend/src/boot/main-boot.ts b/packages/frontend/src/boot/main-boot.ts index 509c9044b5b1..cab8caf36f4b 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 { type Keymap, makeHotkey } from '@/scripts/tms/hotkey.js'; export async function mainBoot() { const { isClientUpdated } = await common(() => createApp( @@ -66,14 +66,6 @@ export async function mainBoot() { }); } - const hotkeys = { - 'd': (): void => { - defaultStore.set('darkMode', !defaultStore.state.darkMode); - }, - 's': (): void => { - mainRouter.push('/search'); - }, - }; try { if (defaultStore.state.enableSeasonalScreenEffect) { const month = new Date().getMonth() + 1; @@ -102,9 +94,6 @@ export async function mainBoot() { } if ($i) { - // only add post shortcuts if logged in - hotkeys['p|n'] = post; - defaultStore.loaded.then(() => { if (defaultStore.state.accountSetupWizard !== -1) { popup(defineAsyncComponent(() => import('@/components/MkUserSetupDialog.vue')), {}, {}, 'closed'); @@ -308,7 +297,19 @@ export async function mainBoot() { } // shortcut - document.addEventListener('keydown', makeHotkey(hotkeys), { passive: false }); + const keymap = { + 'p|n': () => { + if ($i == null) return; + post(); + }, + 'd': () => { + defaultStore.set('darkMode', !defaultStore.state.darkMode); + }, + 's': () => { + mainRouter.push('/search'); + }, + } as const satisfies Keymap; + document.addEventListener('keydown', makeHotkey(keymap), { passive: false }); initializeSw(); } diff --git a/packages/frontend/src/components/MkMediaAudio.vue b/packages/frontend/src/components/MkMediaAudio.vue index 858c18a5fda9..c85bce20a186 100644 --- a/packages/frontend/src/components/MkMediaAudio.vue +++ b/packages/frontend/src/components/MkMediaAudio.vue @@ -112,6 +112,7 @@ import { i18n } from '@/i18n.js'; import { popupMenu } from '@/os.js'; import { defaultStore } from '@/store.js'; import { getMediaMenu } from '@/scripts/tms/get-media-menu.js'; +import { type Keymap } from '@/scripts/tms/hotkey.js'; import { useReactiveDriveFile } from '@/scripts/tms/use-reactive-drive-file.js'; import bytes from '@/filters/bytes.js'; import { hms } from '@/filters/hms.js'; @@ -184,32 +185,44 @@ const showAudioMenu = (ev: MouseEvent) => { }; const keymap = { - 'up': () => { - if (hasFocus() && audioEl.value) { - volume.value = Math.min(volume.value + 0.1, 1); - } + 'up': { + allowRepeat: true, + callback: () => { + if (hasFocus() && audioEl.value) { + volume.value = Math.min(volume.value + 0.1, 1); + } + }, }, - 'down': () => { - if (hasFocus() && audioEl.value) { - volume.value = Math.max(volume.value - 0.1, 0); - } + 'down': { + allowRepeat: true, + callback: () => { + if (hasFocus() && audioEl.value) { + volume.value = Math.max(volume.value - 0.1, 0); + } + }, }, - 'left': () => { - if (hasFocus() && audioEl.value) { - audioEl.value.currentTime = Math.max(audioEl.value.currentTime - 5, 0); - } + 'left': { + allowRepeat: true, + callback: () => { + if (hasFocus() && audioEl.value) { + audioEl.value.currentTime = Math.max(audioEl.value.currentTime - 5, 0); + } + }, }, - 'right': () => { - if (hasFocus() && audioEl.value) { - audioEl.value.currentTime = Math.min(audioEl.value.currentTime + 5, audioEl.value.duration); - } + 'right': { + allowRepeat: true, + callback: () => { + if (hasFocus() && audioEl.value) { + audioEl.value.currentTime = Math.min(audioEl.value.currentTime + 5, audioEl.value.duration); + } + }, }, 'space': () => { if (hasFocus()) { togglePlayPause(); } }, -}; +} as const satisfies Keymap; // PlayerElもしくはその子要素にフォーカスがあるかどうか function hasFocus() { diff --git a/packages/frontend/src/components/MkMediaVideo.vue b/packages/frontend/src/components/MkMediaVideo.vue index ef5d31f71f95..cc9b5cb23652 100644 --- a/packages/frontend/src/components/MkMediaVideo.vue +++ b/packages/frontend/src/components/MkMediaVideo.vue @@ -160,6 +160,7 @@ import { defaultStore } from '@/store.js'; import { isFullscreenNotSupported } from '@/scripts/device-kind.js'; import hasAudio from '@/scripts/media-has-audio.js'; import { getMediaMenu } from '@/scripts/tms/get-media-menu.js'; +import { type Keymap } from '@/scripts/tms/hotkey.js'; import { useReactiveDriveFile } from '@/scripts/tms/use-reactive-drive-file.js'; import bytes from '@/filters/bytes.js'; import { hms } from '@/filters/hms.js'; @@ -247,32 +248,44 @@ const showVideoMenu = (ev: MouseEvent) => { }; const keymap = { - 'up': () => { - if (hasFocus() && videoEl.value) { - volume.value = Math.min(volume.value + 0.1, 1); - } + 'up': { + allowRepeat: true, + callback: () => { + if (hasFocus() && videoEl.value) { + volume.value = Math.min(volume.value + 0.1, 1); + } + }, }, - 'down': () => { - if (hasFocus() && videoEl.value) { - volume.value = Math.max(volume.value - 0.1, 0); - } + 'down': { + allowRepeat: true, + callback: () => { + if (hasFocus() && videoEl.value) { + volume.value = Math.max(volume.value - 0.1, 0); + } + }, }, - 'left': () => { - if (hasFocus() && videoEl.value) { - videoEl.value.currentTime = Math.max(videoEl.value.currentTime - 5, 0); - } + 'left': { + allowRepeat: true, + callback: () => { + if (hasFocus() && videoEl.value) { + videoEl.value.currentTime = Math.max(videoEl.value.currentTime - 5, 0); + } + }, }, - 'right': () => { - if (hasFocus() && videoEl.value) { - videoEl.value.currentTime = Math.min(videoEl.value.currentTime + 5, videoEl.value.duration); - } + 'right': { + allowRepeat: true, + callback: () => { + if (hasFocus() && videoEl.value) { + videoEl.value.currentTime = Math.min(videoEl.value.currentTime + 5, videoEl.value.duration); + } + }, }, 'space': () => { if (hasFocus()) { togglePlayPause(); } }, -}; +} as const satisfies Keymap; // PlayerElもしくはその子要素にフォーカスがあるかどうか function hasFocus() { diff --git a/packages/frontend/src/components/MkMenu.vue b/packages/frontend/src/components/MkMenu.vue index 1c3077d3c000..8cae16e37cf6 100644 --- a/packages/frontend/src/components/MkMenu.vue +++ b/packages/frontend/src/components/MkMenu.vue @@ -109,6 +109,7 @@ import { i18n } from '@/i18n.js'; import { isTouchUsing } from '@/scripts/touch.js'; import { focusParent, isFocusable } from '@/scripts/tms/focus.js'; import { getNodeOrNull } from '@/scripts/tms/get-or-null.js'; +import { type Keymap } from '@/scripts/tms/hotkey.js'; const childrenCache = new WeakMap(); @@ -139,10 +140,19 @@ const items2 = ref(); const child = shallowRef>(); const keymap = { - 'up|k|shift+tab': () => focusUp(), - 'down|j|tab': () => focusDown(), - 'esc': () => close(false), -}; + 'up|k|shift+tab': { + allowRepeat: true, + callback: () => focusUp(), + }, + 'down|j|tab': { + allowRepeat: true, + callback: () => focusDown(), + }, + 'esc': { + allowRepeat: true, + callback: () => close(false), + }, +} as const satisfies Keymap; const childShowingItem = ref(); diff --git a/packages/frontend/src/components/MkModal.vue b/packages/frontend/src/components/MkModal.vue index 28a8d8de9df8..30f90c854696 100644 --- a/packages/frontend/src/components/MkModal.vue +++ b/packages/frontend/src/components/MkModal.vue @@ -47,6 +47,7 @@ import * as os from '@/os.js'; import { isTouchUsing } from '@/scripts/touch.js'; import { defaultStore } from '@/store.js'; import { deviceKind } from '@/scripts/device-kind.js'; +import { type Keymap } from '@/scripts/tms/hotkey.js'; function getFixedContainer(el: Element | null): Element | null { if (el == null || el.tagName === 'BODY') return null; @@ -154,8 +155,11 @@ if (type.value === 'drawer') { } const keymap = { - 'esc': () => emit('esc'), -}; + 'esc': { + allowRepeat: true, + callback: () => emit('esc'), + }, +} as const satisfies Keymap; const MARGIN = 16; const SCROLLBAR_THICKNESS = 16; diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue index 13ae36dd45e6..be7caf39f650 100644 --- a/packages/frontend/src/components/MkNote.vue +++ b/packages/frontend/src/components/MkNote.vue @@ -199,6 +199,7 @@ import { shouldCollapsed } from '@/scripts/collapsed.js'; import { isEnabledUrlPreview } from '@/instance.js'; import { focusPrev, focusNext } from '@/scripts/tms/focus.js'; import { getAppearNote } from '@/scripts/tms/get-appear-note.js'; +import { type Keymap } from '@/scripts/tms/hotkey.js'; import { isQuote, isRenote } from '@/scripts/tms/is-renote.js'; import { tmsStore } from '@/tms/store.js'; @@ -326,10 +327,19 @@ const keymap = { collapsed.value = !collapsed.value; } }, - 'esc': () => blur(), - 'up|k|shift+tab': () => focusBefore(), - 'down|j|tab': () => focusAfter(), -}; + 'esc': { + allowRepeat: true, + callback: () => blur(), + }, + 'up|k|shift+tab': { + allowRepeat: true, + callback: () => focusBefore(), + }, + 'down|j|tab': { + allowRepeat: true, + callback: () => focusAfter(), + }, +} as const satisfies Keymap; provide('react', (reaction: string) => { misskeyApi('notes/reactions/create', { diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue index 09bdff4810ae..6988a91c7c8b 100644 --- a/packages/frontend/src/components/MkNoteDetailed.vue +++ b/packages/frontend/src/components/MkNoteDetailed.vue @@ -236,6 +236,7 @@ import MkReactionIcon from '@/components/MkReactionIcon.vue'; import MkButton from '@/components/MkButton.vue'; import { isEnabledUrlPreview } from '@/instance.js'; import { getAppearNote } from '@/scripts/tms/get-appear-note.js'; +import { type Keymap } from '@/scripts/tms/hotkey.js'; import { isQuote, isRenote } from '@/scripts/tms/is-renote.js'; import { tmsStore } from '@/tms/store.js'; @@ -308,8 +309,11 @@ const keymap = { showContent.value = !showContent.value; } }, - 'esc': () => blur(), -}; + 'esc': { + allowRepeat: true, + callback: () => blur(), + }, +} as const satisfies Keymap; provide('react', (reaction: string) => { misskeyApi('notes/reactions/create', { 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/pages/antenna-timeline.vue b/packages/frontend/src/pages/antenna-timeline.vue index d22b7f24f0f4..969ee0b8546c 100644 --- a/packages/frontend/src/pages/antenna-timeline.vue +++ b/packages/frontend/src/pages/antenna-timeline.vue @@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only -
+
(null); const queue = ref(0); const rootEl = shallowRef(); const tlEl = shallowRef>(); -const keymap = computed(() => ({ - 't': focus, -})); function queueUpdated(q) { queue.value = q; diff --git a/packages/frontend/src/pages/timeline.vue b/packages/frontend/src/pages/timeline.vue index 640a912b450c..8ae04738c50e 100644 --- a/packages/frontend/src/pages/timeline.vue +++ b/packages/frontend/src/pages/timeline.vue @@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only -
+
{{ i18n.ts._timelineDescription[src] }} @@ -58,9 +58,6 @@ provide('shouldOmitHeaderTitle', true); const isLocalTimelineAvailable = ($i == null && instance.policies.ltlAvailable) || ($i != null && $i.policies.ltlAvailable); const isGlobalTimelineAvailable = ($i == null && instance.policies.gtlAvailable) || ($i != null && $i.policies.gtlAvailable); -const keymap = { - 't': focus, -}; const tlComponent = shallowRef>(); const rootEl = shallowRef(); 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..339d4a4d84c5 --- /dev/null +++ b/packages/frontend/src/scripts/tms/hotkey.ts @@ -0,0 +1,136 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { getHTMLElementOrNull } from '@/scripts/tms/get-or-null.js'; + +//#region types +export type Keymap = Record; + +type CallbackFunction = (ev: KeyboardEvent) => unknown; + +type CallbackObject = { + callback: CallbackFunction; + allowRepeat?: boolean; +}; + +type Pattern = { + which: string[]; + ctrl: boolean; + alt: boolean; + shift: boolean; +}; + +type Action = { + patterns: Pattern[]; + callback: CallbackFunction; + options: Required>; +}; +//#endregion + +//#region consts +const KEY_ALIASES = { + 'esc': 'Escape', + 'enter': 'Enter', + 'space': ' ', + 'up': 'ArrowUp', + 'down': 'ArrowDown', + 'left': 'ArrowLeft', + 'right': 'ArrowRight', + 'plus': ['+', ';'], +}; + +const MODIFIER_KEYS = ['ctrl', 'alt', 'shift']; + +const IGNORE_ELEMENTS = ['input', 'textarea']; +//#endregion + +//#region impl +export const makeHotkey = (keymap: Keymap) => { + const actions = parseKeymap(keymap); + return (ev: KeyboardEvent) => { + 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, options } of actions) { + if (matchPatterns(ev, patterns, options)) { + ev.preventDefault(); + ev.stopPropagation(); + callback(ev); + } + } + }; +}; + +const parseKeymap = (keymap: Keymap) => { + return Object.entries(keymap).map(([rawPatterns, rawCallback]) => { + const patterns = parsePatterns(rawPatterns); + const callback = parseCallback(rawCallback); + const options = parseOptions(rawCallback); + return { patterns, callback, options } as const satisfies Action; + }); +}; + +const parsePatterns = (rawPatterns: keyof Keymap) => { + return rawPatterns.split('|').map(part => { + const keys = part.split('+').map(trimLower); + const which = parseKeyCode(keys.findLast(x => !MODIFIER_KEYS.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 parseCallback = (rawCallback: Keymap[keyof Keymap]) => { + if (typeof rawCallback === 'object') { + return rawCallback.callback; + } + return rawCallback; +}; + +const parseOptions = (rawCallback: Keymap[keyof Keymap]) => { + const defaultOptions = { + allowRepeat: false, + } as const satisfies Action['options']; + if (typeof rawCallback === 'object') { + const { callback, ...rawOptions } = rawCallback; + const options = { ...defaultOptions, ...rawOptions }; + return { ...options } as const satisfies Action['options']; + } + return { ...defaultOptions } as const satisfies Action['options']; +}; + +const matchPatterns = (ev: KeyboardEvent, patterns: Action['patterns'], options: Action['options']) => { + if (ev.repeat && !options.allowRepeat) return false; + 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 [trimLower(raw)]; + return raw.map(trimLower); +}; + +const getValueByKey = < + T extends Record, + K extends keyof T | keyof any, + R extends K extends keyof T ? T[K] : T[keyof T] | undefined, +>(obj: T, key: K) => { + return obj[key] as R; +}; + +const trimLower = (str: string) => str.trim().toLowerCase(); +//#endregion