diff --git a/CHANGELOG.md b/CHANGELOG.md
index f62f966451..1bcf721676 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,8 @@
 - Fix: コントロールパネルでベースロールのポリシーを編集してもUI上では変更が反映されない問題を修正 
 - Fix: アンテナの編集画面のボタンに隙間を追加
 - Fix: テーマプレビューが見れない問題を修正
+- Fix: ショートカットキーが連打できる問題を修正  
+  (Cherry-picked from https://github.com/taiyme/misskey/pull/234)
 
 ### Server
 - Feat: レートリミット制限に引っかかったときに`Retry-After`ヘッダーを返すように (#13949)
diff --git a/packages/frontend/src/boot/main-boot.ts b/packages/frontend/src/boot/main-boot.ts
index faf230a1a2..d327016317 100644
--- a/packages/frontend/src/boot/main-boot.ts
+++ b/packages/frontend/src/boot/main-boot.ts
@@ -13,7 +13,6 @@ import * as sound from '@/scripts/sound.js';
 import { $i, signout, updateAccount } from '@/account.js';
 import { instance } from '@/instance.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';
@@ -21,6 +20,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/hotkey.js';
 
 export async function mainBoot() {
 	const { isClientUpdated } = await common(() => createApp(
@@ -69,14 +69,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;
@@ -105,9 +97,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) {
 				const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkUserSetupDialog.vue')), {}, {
@@ -334,7 +323,19 @@ export async function mainBoot() {
 	}
 
 	// shortcut
-	document.addEventListener('keydown', makeHotkey(hotkeys));
+	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 ebd4fc9ca4..e8dfcc7768 100644
--- a/packages/frontend/src/components/MkMediaAudio.vue
+++ b/packages/frontend/src/components/MkMediaAudio.vue
@@ -80,6 +80,7 @@ import type { MenuItem } from '@/types/menu.js';
 import { defaultStore } from '@/store.js';
 import { i18n } from '@/i18n.js';
 import * as os from '@/os.js';
+import { type Keymap } from '@/scripts/hotkey.js';
 import bytes from '@/filters/bytes.js';
 import { hms } from '@/filters/hms.js';
 import MkMediaRange from '@/components/MkMediaRange.vue';
@@ -90,32 +91,44 @@ const props = defineProps<{
 }>();
 
 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 707d7c1501..7c46084c63 100644
--- a/packages/frontend/src/components/MkMediaVideo.vue
+++ b/packages/frontend/src/components/MkMediaVideo.vue
@@ -112,6 +112,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { ref, shallowRef, computed, watch, onDeactivated, onActivated, onMounted } from 'vue';
 import * as Misskey from 'misskey-js';
 import type { MenuItem } from '@/types/menu.js';
+import { type Keymap } from '@/scripts/hotkey.js';
 import bytes from '@/filters/bytes.js';
 import { hms } from '@/filters/hms.js';
 import { defaultStore } from '@/store.js';
@@ -127,32 +128,44 @@ const props = defineProps<{
 }>();
 
 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 d91239b9e2..119504f744 100644
--- a/packages/frontend/src/components/MkMenu.vue
+++ b/packages/frontend/src/components/MkMenu.vue
@@ -98,6 +98,7 @@ import { MenuItem, InnerMenuItem, MenuPending, MenuAction, MenuSwitch, MenuRadio
 import * as os from '@/os.js';
 import { i18n } from '@/i18n.js';
 import { isTouchUsing } from '@/scripts/touch.js';
+import { type Keymap } from '@/scripts/hotkey.js';
 
 const childrenCache = new WeakMap<MenuParent, MenuItem[]>();
 </script>
@@ -125,11 +126,20 @@ const items2 = ref<InnerMenuItem[]>();
 
 const child = shallowRef<InstanceType<typeof XChild>>();
 
-const keymap = computed(() => ({
-	'up|k|shift+tab': focusUp,
-	'down|j|tab': focusDown,
-	'esc': close,
-}));
+const keymap = {
+	'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<MenuItem | null>();
 
diff --git a/packages/frontend/src/components/MkModal.vue b/packages/frontend/src/components/MkModal.vue
index 9e69ab2207..264d8b6c9c 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/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 1313e4c58e..5f1820a379 100644
--- a/packages/frontend/src/components/MkNote.vue
+++ b/packages/frontend/src/components/MkNote.vue
@@ -198,6 +198,7 @@ import MkRippleEffect from '@/components/MkRippleEffect.vue';
 import { showMovedDialog } from '@/scripts/show-moved-dialog.js';
 import { shouldCollapsed } from '@/scripts/collapsed.js';
 import { isEnabledUrlPreview } from '@/instance.js';
+import { type Keymap } from '@/scripts/hotkey.js';
 
 const props = withDefaults(defineProps<{
 	note: Misskey.entities.Note;
@@ -294,15 +295,53 @@ function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string
 }
 
 const keymap = {
-	'r': () => reply(true),
-	'e|a|plus': () => react(true),
-	'q': () => renote(true),
-	'up|k|shift+tab': focusBefore,
-	'down|j|tab': focusAfter,
-	'esc': blur,
-	'm|o': () => showMenu(true),
-	's': () => showContent.value !== showContent.value,
-};
+	'r': () => {
+		if (renoteCollapsed.value) return;
+		reply();
+	},
+	'e|a|plus': () => {
+		if (renoteCollapsed.value) return;
+		react();
+	},
+	'q': () => {
+		if (renoteCollapsed.value) return;
+		renote();
+	},
+	'm': () => {
+		if (renoteCollapsed.value) return;
+		showMenu();
+	},
+	'c': () => {
+		if (renoteCollapsed.value) return;
+		if (!defaultStore.state.showClipButtonInNoteFooter) return;
+		clip();
+	},
+	'o': () => {
+		if (renoteCollapsed.value) return;
+		galleryEl.value?.openGallery();
+	},
+	'v|enter': () => {
+		if (renoteCollapsed.value) {
+			renoteCollapsed.value = false;
+		} else if (appearNote.value.cw != null) {
+			showContent.value = !showContent.value;
+		} else if (isLong) {
+			collapsed.value = !collapsed.value;
+		}
+	},
+	'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 bc1f416373..8f65e3b60a 100644
--- a/packages/frontend/src/components/MkNoteDetailed.vue
+++ b/packages/frontend/src/components/MkNoteDetailed.vue
@@ -233,6 +233,7 @@ import MkPagination, { type Paging } from '@/components/MkPagination.vue';
 import MkReactionIcon from '@/components/MkReactionIcon.vue';
 import MkButton from '@/components/MkButton.vue';
 import { isEnabledUrlPreview } from '@/instance.js';
+import { type Keymap } from '@/scripts/hotkey.js';
 
 const props = withDefaults(defineProps<{
 	note: Misskey.entities.Note;
@@ -294,13 +295,24 @@ const replies = ref<Misskey.entities.Note[]>([]);
 const canRenote = computed(() => ['public', 'home'].includes(appearNote.value.visibility) || appearNote.value.userId === $i?.id);
 
 const keymap = {
-	'r': () => reply(true),
-	'e|a|plus': () => react(true),
-	'q': () => renote(true),
-	'esc': blur,
-	'm|o': () => showMenu(true),
-	's': () => showContent.value !== showContent.value,
-};
+	'r': () => reply(),
+	'e|a|plus': () => react(),
+	'q': () => renote(),
+	'm': () => showMenu(),
+	'c': () => {
+		if (!defaultStore.state.showClipButtonInNoteFooter) return;
+		clip();
+	},
+	'v|enter': () => {
+		if (appearNote.value.cw != null) {
+			showContent.value = !showContent.value;
+		}
+	},
+	'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 b082b6edf2..0a7d136f18 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/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 273250d1d0..ea64e457e3 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
 <MkStickyContainer>
 	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
 	<MkSpacer :contentMax="800">
-		<div ref="rootEl" v-hotkey.global="keymap">
+		<div ref="rootEl">
 			<div v-if="queue > 0" :class="$style.new"><button class="_buttonPrimary" :class="$style.newButton" @click="top()">{{ i18n.ts.newNoteRecived }}</button></div>
 			<div :class="$style.tl">
 				<MkTimeline
@@ -44,9 +44,6 @@ const antenna = ref<Misskey.entities.Antenna | null>(null);
 const queue = ref(0);
 const rootEl = shallowRef<HTMLElement>();
 const tlEl = shallowRef<InstanceType<typeof MkTimeline>>();
-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 98744c6318..813cc326d0 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
 	<template #header><MkPageHeader v-model:tab="src" :actions="headerActions" :tabs="$i ? headerTabs : headerTabsWhenNotLogin" :displayMyAvatar="true"/></template>
 	<MkSpacer :contentMax="800">
 		<MkHorizontalSwipe v-model:tab="src" :tabs="$i ? headerTabs : headerTabsWhenNotLogin">
-			<div :key="src" ref="rootEl" v-hotkey.global="keymap">
+			<div :key="src" ref="rootEl">
 				<MkInfo v-if="['home', 'local', 'social', 'global'].includes(src) && !defaultStore.reactiveState.timelineTutorials.value[src]" style="margin-bottom: var(--margin);" closable @close="closeTutorial()">
 					{{ i18n.ts._timelineDescription[src] }}
 				</MkInfo>
@@ -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<InstanceType<typeof MkTimeline>>();
 const rootEl = shallowRef<HTMLElement>();
diff --git a/packages/frontend/src/scripts/hotkey.ts b/packages/frontend/src/scripts/hotkey.ts
index 0600bff893..fd79baa604 100644
--- a/packages/frontend/src/scripts/hotkey.ts
+++ b/packages/frontend/src/scripts/hotkey.ts
@@ -3,93 +3,132 @@
  * SPDX-License-Identifier: AGPL-3.0-only
  */
 
-import keyCode from './keycode.js';
+//#region types
+export type Keymap = Record<string, CallbackFunction | CallbackObject>;
 
-type Callback = (ev: KeyboardEvent) => void;
+type CallbackFunction = (ev: KeyboardEvent) => unknown;
 
-type Keymap = Record<string, Callback>;
+type CallbackObject = {
+	callback: CallbackFunction;
+	allowRepeat?: boolean;
+};
 
 type Pattern = {
 	which: string[];
-	ctrl?: boolean;
-	shift?: boolean;
-	alt?: boolean;
+	ctrl: boolean;
+	alt: boolean;
+	shift: boolean;
 };
 
 type Action = {
 	patterns: Pattern[];
-	callback: Callback;
-	allowRepeat: boolean;
+	callback: CallbackFunction;
+	options: Required<Omit<CallbackObject, 'callback'>>;
+};
+//#endregion
+
+//#region consts
+const KEY_ALIASES = {
+	'esc': 'Escape',
+	'enter': ['Enter', 'NumpadEnter'],
+	'space': [' ', 'Spacebar'],
+	'up': 'ArrowUp',
+	'down': 'ArrowDown',
+	'left': 'ArrowLeft',
+	'right': 'ArrowRight',
+	'plus': ['+', ';'],
 };
 
-const parseKeymap = (keymap: Keymap) => Object.entries(keymap).map(([patterns, callback]): Action => {
-	const result = {
-		patterns: [],
-		callback,
-		allowRepeat: true,
-	} as Action;
+const MODIFIER_KEYS = ['ctrl', 'alt', 'shift'];
 
-	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,
-	);
-}
+const IGNORE_ELEMENTS = ['input', 'textarea'];
+//#endregion
 
+//#region impl
 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;
+		if (document.activeElement != null) {
+			if (IGNORE_ELEMENTS.includes(document.activeElement.tagName.toLowerCase())) return;
+			if ((document.activeElement as HTMLElement).isContentEditable) return;
 		}
-
-		for (const action of actions) {
-			const matched = match(ev, action.patterns);
-
-			if (matched) {
-				if (!action.allowRepeat && ev.repeat) return;
-
+		for (const { patterns, callback, options } of actions) {
+			if (matchPatterns(ev, patterns, options)) {
 				ev.preventDefault();
 				ev.stopPropagation();
-				action.callback(ev);
-				break;
+				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<keyof any, unknown>,
+	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
diff --git a/packages/frontend/src/scripts/keycode.ts b/packages/frontend/src/scripts/keycode.ts
deleted file mode 100644
index 7ffceafada..0000000000
--- a/packages/frontend/src/scripts/keycode.ts
+++ /dev/null
@@ -1,24 +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];
-	}
-};
-
-export const aliases = {
-	'esc': 'Escape',
-	'enter': ['Enter', 'NumpadEnter'],
-	'space': [' ', 'Spacebar'],
-	'up': 'ArrowUp',
-	'down': 'ArrowDown',
-	'left': 'ArrowLeft',
-	'right': 'ArrowRight',
-	'plus': ['NumpadAdd', 'Semicolon'],
-};