fix/refactor(frontend): hotkeyの改修 (#14157)
* improve(frontend): hotkeyの改修 (#234) (cherry picked from commit 678be147f4db709dadf25d007cc2e679e98a370e) * Change path, add missing script Co-authored-by: taiy <53635909+taiyme@users.noreply.github.com> * fix * fix * add missing keycodes * fix * update changelog --------- Co-authored-by: taiy <53635909+taiyme@users.noreply.github.com>
This commit is contained in:
parent
b61f270eae
commit
a5407131d4
|
@ -12,6 +12,8 @@
|
||||||
- Fix: コントロールパネルでベースロールのポリシーを編集してもUI上では変更が反映されない問題を修正
|
- Fix: コントロールパネルでベースロールのポリシーを編集してもUI上では変更が反映されない問題を修正
|
||||||
- Fix: アンテナの編集画面のボタンに隙間を追加
|
- Fix: アンテナの編集画面のボタンに隙間を追加
|
||||||
- Fix: テーマプレビューが見れない問題を修正
|
- Fix: テーマプレビューが見れない問題を修正
|
||||||
|
- Fix: ショートカットキーが連打できる問題を修正
|
||||||
|
(Cherry-picked from https://github.com/taiyme/misskey/pull/234)
|
||||||
|
|
||||||
### Server
|
### Server
|
||||||
- Feat: レートリミット制限に引っかかったときに`Retry-After`ヘッダーを返すように (#13949)
|
- Feat: レートリミット制限に引っかかったときに`Retry-After`ヘッダーを返すように (#13949)
|
||||||
|
|
|
@ -13,7 +13,6 @@ import * as sound from '@/scripts/sound.js';
|
||||||
import { $i, signout, updateAccount } from '@/account.js';
|
import { $i, signout, updateAccount } from '@/account.js';
|
||||||
import { instance } from '@/instance.js';
|
import { instance } from '@/instance.js';
|
||||||
import { ColdDeviceStorage, defaultStore } from '@/store.js';
|
import { ColdDeviceStorage, defaultStore } from '@/store.js';
|
||||||
import { makeHotkey } from '@/scripts/hotkey.js';
|
|
||||||
import { reactionPicker } from '@/scripts/reaction-picker.js';
|
import { reactionPicker } from '@/scripts/reaction-picker.js';
|
||||||
import { miLocalStorage } from '@/local-storage.js';
|
import { miLocalStorage } from '@/local-storage.js';
|
||||||
import { claimAchievement, claimedAchievements } from '@/scripts/achievements.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 { deckStore } from '@/ui/deck/deck-store.js';
|
||||||
import { emojiPicker } from '@/scripts/emoji-picker.js';
|
import { emojiPicker } from '@/scripts/emoji-picker.js';
|
||||||
import { mainRouter } from '@/router/main.js';
|
import { mainRouter } from '@/router/main.js';
|
||||||
|
import { type Keymap, makeHotkey } from '@/scripts/hotkey.js';
|
||||||
|
|
||||||
export async function mainBoot() {
|
export async function mainBoot() {
|
||||||
const { isClientUpdated } = await common(() => createApp(
|
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 {
|
try {
|
||||||
if (defaultStore.state.enableSeasonalScreenEffect) {
|
if (defaultStore.state.enableSeasonalScreenEffect) {
|
||||||
const month = new Date().getMonth() + 1;
|
const month = new Date().getMonth() + 1;
|
||||||
|
@ -105,9 +97,6 @@ export async function mainBoot() {
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($i) {
|
if ($i) {
|
||||||
// only add post shortcuts if logged in
|
|
||||||
hotkeys['p|n'] = post;
|
|
||||||
|
|
||||||
defaultStore.loaded.then(() => {
|
defaultStore.loaded.then(() => {
|
||||||
if (defaultStore.state.accountSetupWizard !== -1) {
|
if (defaultStore.state.accountSetupWizard !== -1) {
|
||||||
const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkUserSetupDialog.vue')), {}, {
|
const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkUserSetupDialog.vue')), {}, {
|
||||||
|
@ -334,7 +323,19 @@ export async function mainBoot() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// shortcut
|
// 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();
|
initializeSw();
|
||||||
}
|
}
|
||||||
|
|
|
@ -80,6 +80,7 @@ import type { MenuItem } from '@/types/menu.js';
|
||||||
import { defaultStore } from '@/store.js';
|
import { defaultStore } from '@/store.js';
|
||||||
import { i18n } from '@/i18n.js';
|
import { i18n } from '@/i18n.js';
|
||||||
import * as os from '@/os.js';
|
import * as os from '@/os.js';
|
||||||
|
import { type Keymap } from '@/scripts/hotkey.js';
|
||||||
import bytes from '@/filters/bytes.js';
|
import bytes from '@/filters/bytes.js';
|
||||||
import { hms } from '@/filters/hms.js';
|
import { hms } from '@/filters/hms.js';
|
||||||
import MkMediaRange from '@/components/MkMediaRange.vue';
|
import MkMediaRange from '@/components/MkMediaRange.vue';
|
||||||
|
@ -90,32 +91,44 @@ const props = defineProps<{
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const keymap = {
|
const keymap = {
|
||||||
'up': () => {
|
'up': {
|
||||||
|
allowRepeat: true,
|
||||||
|
callback: () => {
|
||||||
if (hasFocus() && audioEl.value) {
|
if (hasFocus() && audioEl.value) {
|
||||||
volume.value = Math.min(volume.value + 0.1, 1);
|
volume.value = Math.min(volume.value + 0.1, 1);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
'down': () => {
|
},
|
||||||
|
'down': {
|
||||||
|
allowRepeat: true,
|
||||||
|
callback: () => {
|
||||||
if (hasFocus() && audioEl.value) {
|
if (hasFocus() && audioEl.value) {
|
||||||
volume.value = Math.max(volume.value - 0.1, 0);
|
volume.value = Math.max(volume.value - 0.1, 0);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
'left': () => {
|
},
|
||||||
|
'left': {
|
||||||
|
allowRepeat: true,
|
||||||
|
callback: () => {
|
||||||
if (hasFocus() && audioEl.value) {
|
if (hasFocus() && audioEl.value) {
|
||||||
audioEl.value.currentTime = Math.max(audioEl.value.currentTime - 5, 0);
|
audioEl.value.currentTime = Math.max(audioEl.value.currentTime - 5, 0);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
'right': () => {
|
},
|
||||||
|
'right': {
|
||||||
|
allowRepeat: true,
|
||||||
|
callback: () => {
|
||||||
if (hasFocus() && audioEl.value) {
|
if (hasFocus() && audioEl.value) {
|
||||||
audioEl.value.currentTime = Math.min(audioEl.value.currentTime + 5, audioEl.value.duration);
|
audioEl.value.currentTime = Math.min(audioEl.value.currentTime + 5, audioEl.value.duration);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
},
|
||||||
'space': () => {
|
'space': () => {
|
||||||
if (hasFocus()) {
|
if (hasFocus()) {
|
||||||
togglePlayPause();
|
togglePlayPause();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
} as const satisfies Keymap;
|
||||||
|
|
||||||
// PlayerElもしくはその子要素にフォーカスがあるかどうか
|
// PlayerElもしくはその子要素にフォーカスがあるかどうか
|
||||||
function hasFocus() {
|
function hasFocus() {
|
||||||
|
|
|
@ -112,6 +112,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
import { ref, shallowRef, computed, watch, onDeactivated, onActivated, onMounted } from 'vue';
|
import { ref, shallowRef, computed, watch, onDeactivated, onActivated, onMounted } from 'vue';
|
||||||
import * as Misskey from 'misskey-js';
|
import * as Misskey from 'misskey-js';
|
||||||
import type { MenuItem } from '@/types/menu.js';
|
import type { MenuItem } from '@/types/menu.js';
|
||||||
|
import { type Keymap } from '@/scripts/hotkey.js';
|
||||||
import bytes from '@/filters/bytes.js';
|
import bytes from '@/filters/bytes.js';
|
||||||
import { hms } from '@/filters/hms.js';
|
import { hms } from '@/filters/hms.js';
|
||||||
import { defaultStore } from '@/store.js';
|
import { defaultStore } from '@/store.js';
|
||||||
|
@ -127,32 +128,44 @@ const props = defineProps<{
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const keymap = {
|
const keymap = {
|
||||||
'up': () => {
|
'up': {
|
||||||
|
allowRepeat: true,
|
||||||
|
callback: () => {
|
||||||
if (hasFocus() && videoEl.value) {
|
if (hasFocus() && videoEl.value) {
|
||||||
volume.value = Math.min(volume.value + 0.1, 1);
|
volume.value = Math.min(volume.value + 0.1, 1);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
'down': () => {
|
},
|
||||||
|
'down': {
|
||||||
|
allowRepeat: true,
|
||||||
|
callback: () => {
|
||||||
if (hasFocus() && videoEl.value) {
|
if (hasFocus() && videoEl.value) {
|
||||||
volume.value = Math.max(volume.value - 0.1, 0);
|
volume.value = Math.max(volume.value - 0.1, 0);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
'left': () => {
|
},
|
||||||
|
'left': {
|
||||||
|
allowRepeat: true,
|
||||||
|
callback: () => {
|
||||||
if (hasFocus() && videoEl.value) {
|
if (hasFocus() && videoEl.value) {
|
||||||
videoEl.value.currentTime = Math.max(videoEl.value.currentTime - 5, 0);
|
videoEl.value.currentTime = Math.max(videoEl.value.currentTime - 5, 0);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
'right': () => {
|
},
|
||||||
|
'right': {
|
||||||
|
allowRepeat: true,
|
||||||
|
callback: () => {
|
||||||
if (hasFocus() && videoEl.value) {
|
if (hasFocus() && videoEl.value) {
|
||||||
videoEl.value.currentTime = Math.min(videoEl.value.currentTime + 5, videoEl.value.duration);
|
videoEl.value.currentTime = Math.min(videoEl.value.currentTime + 5, videoEl.value.duration);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
},
|
||||||
'space': () => {
|
'space': () => {
|
||||||
if (hasFocus()) {
|
if (hasFocus()) {
|
||||||
togglePlayPause();
|
togglePlayPause();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
} as const satisfies Keymap;
|
||||||
|
|
||||||
// PlayerElもしくはその子要素にフォーカスがあるかどうか
|
// PlayerElもしくはその子要素にフォーカスがあるかどうか
|
||||||
function hasFocus() {
|
function hasFocus() {
|
||||||
|
|
|
@ -98,6 +98,7 @@ import { MenuItem, InnerMenuItem, MenuPending, MenuAction, MenuSwitch, MenuRadio
|
||||||
import * as os from '@/os.js';
|
import * as os from '@/os.js';
|
||||||
import { i18n } from '@/i18n.js';
|
import { i18n } from '@/i18n.js';
|
||||||
import { isTouchUsing } from '@/scripts/touch.js';
|
import { isTouchUsing } from '@/scripts/touch.js';
|
||||||
|
import { type Keymap } from '@/scripts/hotkey.js';
|
||||||
|
|
||||||
const childrenCache = new WeakMap<MenuParent, MenuItem[]>();
|
const childrenCache = new WeakMap<MenuParent, MenuItem[]>();
|
||||||
</script>
|
</script>
|
||||||
|
@ -125,11 +126,20 @@ const items2 = ref<InnerMenuItem[]>();
|
||||||
|
|
||||||
const child = shallowRef<InstanceType<typeof XChild>>();
|
const child = shallowRef<InstanceType<typeof XChild>>();
|
||||||
|
|
||||||
const keymap = computed(() => ({
|
const keymap = {
|
||||||
'up|k|shift+tab': focusUp,
|
'up|k|shift+tab': {
|
||||||
'down|j|tab': focusDown,
|
allowRepeat: true,
|
||||||
'esc': close,
|
callback: () => focusUp(),
|
||||||
}));
|
},
|
||||||
|
'down|j|tab': {
|
||||||
|
allowRepeat: true,
|
||||||
|
callback: () => focusDown(),
|
||||||
|
},
|
||||||
|
'esc': {
|
||||||
|
allowRepeat: true,
|
||||||
|
callback: () => close(false),
|
||||||
|
},
|
||||||
|
} as const satisfies Keymap;
|
||||||
|
|
||||||
const childShowingItem = ref<MenuItem | null>();
|
const childShowingItem = ref<MenuItem | null>();
|
||||||
|
|
||||||
|
|
|
@ -47,6 +47,7 @@ import * as os from '@/os.js';
|
||||||
import { isTouchUsing } from '@/scripts/touch.js';
|
import { isTouchUsing } from '@/scripts/touch.js';
|
||||||
import { defaultStore } from '@/store.js';
|
import { defaultStore } from '@/store.js';
|
||||||
import { deviceKind } from '@/scripts/device-kind.js';
|
import { deviceKind } from '@/scripts/device-kind.js';
|
||||||
|
import { type Keymap } from '@/scripts/hotkey.js';
|
||||||
|
|
||||||
function getFixedContainer(el: Element | null): Element | null {
|
function getFixedContainer(el: Element | null): Element | null {
|
||||||
if (el == null || el.tagName === 'BODY') return null;
|
if (el == null || el.tagName === 'BODY') return null;
|
||||||
|
@ -154,8 +155,11 @@ if (type.value === 'drawer') {
|
||||||
}
|
}
|
||||||
|
|
||||||
const keymap = {
|
const keymap = {
|
||||||
'esc': () => emit('esc'),
|
'esc': {
|
||||||
};
|
allowRepeat: true,
|
||||||
|
callback: () => emit('esc'),
|
||||||
|
},
|
||||||
|
} as const satisfies Keymap;
|
||||||
|
|
||||||
const MARGIN = 16;
|
const MARGIN = 16;
|
||||||
const SCROLLBAR_THICKNESS = 16;
|
const SCROLLBAR_THICKNESS = 16;
|
||||||
|
|
|
@ -198,6 +198,7 @@ import MkRippleEffect from '@/components/MkRippleEffect.vue';
|
||||||
import { showMovedDialog } from '@/scripts/show-moved-dialog.js';
|
import { showMovedDialog } from '@/scripts/show-moved-dialog.js';
|
||||||
import { shouldCollapsed } from '@/scripts/collapsed.js';
|
import { shouldCollapsed } from '@/scripts/collapsed.js';
|
||||||
import { isEnabledUrlPreview } from '@/instance.js';
|
import { isEnabledUrlPreview } from '@/instance.js';
|
||||||
|
import { type Keymap } from '@/scripts/hotkey.js';
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
note: Misskey.entities.Note;
|
note: Misskey.entities.Note;
|
||||||
|
@ -294,15 +295,53 @@ function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string
|
||||||
}
|
}
|
||||||
|
|
||||||
const keymap = {
|
const keymap = {
|
||||||
'r': () => reply(true),
|
'r': () => {
|
||||||
'e|a|plus': () => react(true),
|
if (renoteCollapsed.value) return;
|
||||||
'q': () => renote(true),
|
reply();
|
||||||
'up|k|shift+tab': focusBefore,
|
},
|
||||||
'down|j|tab': focusAfter,
|
'e|a|plus': () => {
|
||||||
'esc': blur,
|
if (renoteCollapsed.value) return;
|
||||||
'm|o': () => showMenu(true),
|
react();
|
||||||
's': () => showContent.value !== showContent.value,
|
},
|
||||||
};
|
'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) => {
|
provide('react', (reaction: string) => {
|
||||||
misskeyApi('notes/reactions/create', {
|
misskeyApi('notes/reactions/create', {
|
||||||
|
|
|
@ -233,6 +233,7 @@ import MkPagination, { type Paging } from '@/components/MkPagination.vue';
|
||||||
import MkReactionIcon from '@/components/MkReactionIcon.vue';
|
import MkReactionIcon from '@/components/MkReactionIcon.vue';
|
||||||
import MkButton from '@/components/MkButton.vue';
|
import MkButton from '@/components/MkButton.vue';
|
||||||
import { isEnabledUrlPreview } from '@/instance.js';
|
import { isEnabledUrlPreview } from '@/instance.js';
|
||||||
|
import { type Keymap } from '@/scripts/hotkey.js';
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
note: Misskey.entities.Note;
|
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 canRenote = computed(() => ['public', 'home'].includes(appearNote.value.visibility) || appearNote.value.userId === $i?.id);
|
||||||
|
|
||||||
const keymap = {
|
const keymap = {
|
||||||
'r': () => reply(true),
|
'r': () => reply(),
|
||||||
'e|a|plus': () => react(true),
|
'e|a|plus': () => react(),
|
||||||
'q': () => renote(true),
|
'q': () => renote(),
|
||||||
'esc': blur,
|
'm': () => showMenu(),
|
||||||
'm|o': () => showMenu(true),
|
'c': () => {
|
||||||
's': () => showContent.value !== showContent.value,
|
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) => {
|
provide('react', (reaction: string) => {
|
||||||
misskeyApi('notes/reactions/create', {
|
misskeyApi('notes/reactions/create', {
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Directive } from 'vue';
|
import { Directive } from 'vue';
|
||||||
import { makeHotkey } from '../scripts/hotkey.js';
|
import { makeHotkey } from '@/scripts/hotkey.js';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
mounted(el, binding) {
|
mounted(el, binding) {
|
||||||
|
|
|
@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<MkStickyContainer>
|
<MkStickyContainer>
|
||||||
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
|
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
|
||||||
<MkSpacer :contentMax="800">
|
<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 v-if="queue > 0" :class="$style.new"><button class="_buttonPrimary" :class="$style.newButton" @click="top()">{{ i18n.ts.newNoteRecived }}</button></div>
|
||||||
<div :class="$style.tl">
|
<div :class="$style.tl">
|
||||||
<MkTimeline
|
<MkTimeline
|
||||||
|
@ -44,9 +44,6 @@ const antenna = ref<Misskey.entities.Antenna | null>(null);
|
||||||
const queue = ref(0);
|
const queue = ref(0);
|
||||||
const rootEl = shallowRef<HTMLElement>();
|
const rootEl = shallowRef<HTMLElement>();
|
||||||
const tlEl = shallowRef<InstanceType<typeof MkTimeline>>();
|
const tlEl = shallowRef<InstanceType<typeof MkTimeline>>();
|
||||||
const keymap = computed(() => ({
|
|
||||||
't': focus,
|
|
||||||
}));
|
|
||||||
|
|
||||||
function queueUpdated(q) {
|
function queueUpdated(q) {
|
||||||
queue.value = q;
|
queue.value = q;
|
||||||
|
|
|
@ -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>
|
<template #header><MkPageHeader v-model:tab="src" :actions="headerActions" :tabs="$i ? headerTabs : headerTabsWhenNotLogin" :displayMyAvatar="true"/></template>
|
||||||
<MkSpacer :contentMax="800">
|
<MkSpacer :contentMax="800">
|
||||||
<MkHorizontalSwipe v-model:tab="src" :tabs="$i ? headerTabs : headerTabsWhenNotLogin">
|
<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()">
|
<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] }}
|
{{ i18n.ts._timelineDescription[src] }}
|
||||||
</MkInfo>
|
</MkInfo>
|
||||||
|
@ -58,9 +58,6 @@ provide('shouldOmitHeaderTitle', true);
|
||||||
|
|
||||||
const isLocalTimelineAvailable = ($i == null && instance.policies.ltlAvailable) || ($i != null && $i.policies.ltlAvailable);
|
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 isGlobalTimelineAvailable = ($i == null && instance.policies.gtlAvailable) || ($i != null && $i.policies.gtlAvailable);
|
||||||
const keymap = {
|
|
||||||
't': focus,
|
|
||||||
};
|
|
||||||
|
|
||||||
const tlComponent = shallowRef<InstanceType<typeof MkTimeline>>();
|
const tlComponent = shallowRef<InstanceType<typeof MkTimeline>>();
|
||||||
const rootEl = shallowRef<HTMLElement>();
|
const rootEl = shallowRef<HTMLElement>();
|
||||||
|
|
|
@ -3,93 +3,132 @@
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* 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 = {
|
type Pattern = {
|
||||||
which: string[];
|
which: string[];
|
||||||
ctrl?: boolean;
|
ctrl: boolean;
|
||||||
shift?: boolean;
|
alt: boolean;
|
||||||
alt?: boolean;
|
shift: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Action = {
|
type Action = {
|
||||||
patterns: Pattern[];
|
patterns: Pattern[];
|
||||||
callback: Callback;
|
callback: CallbackFunction;
|
||||||
allowRepeat: boolean;
|
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 MODIFIER_KEYS = ['ctrl', 'alt', 'shift'];
|
||||||
const result = {
|
|
||||||
patterns: [],
|
|
||||||
callback,
|
|
||||||
allowRepeat: true,
|
|
||||||
} as Action;
|
|
||||||
|
|
||||||
if (patterns.match(/^\(.*\)$/) !== null) {
|
const IGNORE_ELEMENTS = ['input', 'textarea'];
|
||||||
result.allowRepeat = false;
|
//#endregion
|
||||||
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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
//#region impl
|
||||||
export const makeHotkey = (keymap: Keymap) => {
|
export const makeHotkey = (keymap: Keymap) => {
|
||||||
const actions = parseKeymap(keymap);
|
const actions = parseKeymap(keymap);
|
||||||
|
|
||||||
return (ev: KeyboardEvent) => {
|
return (ev: KeyboardEvent) => {
|
||||||
if (document.activeElement) {
|
if ('pswp' in window && window.pswp != null) return;
|
||||||
if (ignoreElements.some(el => document.activeElement!.matches(el))) return;
|
if (document.activeElement != null) {
|
||||||
if (document.activeElement.attributes['contenteditable']) return;
|
if (IGNORE_ELEMENTS.includes(document.activeElement.tagName.toLowerCase())) return;
|
||||||
|
if ((document.activeElement as HTMLElement).isContentEditable) return;
|
||||||
}
|
}
|
||||||
|
for (const { patterns, callback, options } of actions) {
|
||||||
for (const action of actions) {
|
if (matchPatterns(ev, patterns, options)) {
|
||||||
const matched = match(ev, action.patterns);
|
|
||||||
|
|
||||||
if (matched) {
|
|
||||||
if (!action.allowRepeat && ev.repeat) return;
|
|
||||||
|
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
action.callback(ev);
|
callback(ev);
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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
|
||||||
|
|
|
@ -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'],
|
|
||||||
};
|
|
Loading…
Reference in a new issue