Merge pull request #16 from ApTx2012/feat/ui
Sync source to CNB / Sync Git ref (push) Skipped
Repository checks / typos (push) Canceled after 0s
Repository checks / tombi (push) Canceled after 0s
Axolotl desktop CI / guardrails (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Axolotl desktop CI / website (push) Canceled after 0s
Rust checks / shear (push) Canceled after 0s

feat: 新增键盘快捷键设置
This commit is contained in:
AxTps authored and GitHub committed 2026-10-11 14:09:48 +08:00
commit 60f3fd8816
24 files changed
+2432 -5

No files matched your search

+3
View File
@@ -169,6 +169,7 @@ import { createServerInstall, provideServerInstall } from '@/providers/server-in
import { setupProviders } from '@/providers/setup'
import { setupAuthProvider } from '@/providers/setup/auth'
import { setupLoadingStateProvider } from '@/providers/setup/loading-state'
import { setupKeyboardShortcuts } from '@/composables/useKeyboardShortcuts'
import { useError } from '@/store/error.js'
import { useTheming } from '@/store/state'
@@ -184,6 +185,8 @@ const onSchematicWorkshopPage = computed(() => route.path === '/lab/schematic-pr
const isSchematicFile = (path: string) => /\.(litematic|schematic|schem)$/i.test(path)
const APP_LEFT_NAV_WIDTH = '4rem'
setupKeyboardShortcuts()
const discoverContentPath = computed(() => {
const projectType = route.params.projectType
if (
@@ -0,0 +1,231 @@
<script setup lang="ts">
import {
defineMessages,
type KeyBinding,
KeybindingChips,
KeybindingModal,
Toggle,
useVIntl,
} from '@modrinth/ui'
import { computed, ref } from 'vue'
import { getNavShortcutEnabled, setNavShortcutEnabled } from '@/helpers/nav-shortcut-state'
import { getQuickScrollEnabled, setQuickScrollEnabled } from '@/helpers/scroll-top-state'
import { NAV_ACTIONS, SCROLL_ACTIONS, type ShortcutAction } from '@/helpers/shortcut-actions'
import {
buildConflicts,
clearStoredBinding,
resolveAllBindings,
storeBinding,
} from '@/helpers/shortcut-bindings'
import { useTheming } from '@/store/theme'
import SettingsRow from './SettingsRow.vue'
import SettingsSection from './SettingsSection.vue'
const { formatMessage } = useVIntl()
const themeStore = useTheming()
themeStore.quickScrollEnabled = getQuickScrollEnabled()
themeStore.shortcutBindings = resolveAllBindings()
for (const action of NAV_ACTIONS) {
themeStore[action.enabledField] = getNavShortcutEnabled(action.id)
}
const messages = defineMessages({
scrollTitle: {
id: 'app.shortcut-settings.scroll-title',
defaultMessage: 'Quick scrolling',
},
scrollDescription: {
id: 'app.shortcut-settings.scroll-description',
defaultMessage: 'Move through long pages with the keyboard.',
},
enable: { id: 'app.shortcut-settings.enable', defaultMessage: 'Enable quick scrolling' },
navTitle: {
id: 'app.shortcut-settings.nav-title',
defaultMessage: 'Navigation shortcuts',
},
navDescription: {
id: 'app.shortcut-settings.nav-description',
defaultMessage: 'Jump to a menu item with its shortcut. Each shortcut is off until enabled.',
},
changeShortcut: {
id: 'app.shortcut-settings.change-shortcut',
defaultMessage: 'Change the shortcut for {action}',
},
})
function actionLabel(action: ShortcutAction) {
return formatMessage(action.label)
}
function actionDescription(action: ShortcutAction) {
return formatMessage(action.description)
}
function bindingFor(action: ShortcutAction): KeyBinding {
return themeStore.shortcutBindings[action.id] ?? action.defaultBinding
}
function sameBinding(left: KeyBinding, right: KeyBinding) {
return (
left.device === right.device &&
left.code === right.code &&
left.mod === right.mod &&
left.shift === right.shift &&
left.alt === right.alt
)
}
const editing = ref<ShortcutAction | null>(null)
const keybindingModal = ref<InstanceType<typeof KeybindingModal>>()
const editingLabel = computed(() => (editing.value ? actionLabel(editing.value) : undefined))
const editingBinding = computed(() => (editing.value ? bindingFor(editing.value) : null))
const editingDefault = computed(() => editing.value?.defaultBinding ?? null)
const editingConflicts = computed(() =>
editing.value
? buildConflicts(editing.value.id, (id) => {
const action = [...SCROLL_ACTIONS, ...NAV_ACTIONS].find((candidate) => candidate.id === id)
return action ? actionLabel(action) : id
})
: [],
)
function openShortcut(action: ShortcutAction) {
editing.value = action
keybindingModal.value?.show()
}
function saveShortcut(binding: KeyBinding) {
const action = editing.value
if (!action) return
// A combination matching the default is stored as "not changed", so a later
// change to the default still reaches this shortcut.
if (sameBinding(binding, action.defaultBinding)) clearStoredBinding(action.id)
else storeBinding(action.id, binding)
themeStore.shortcutBindings = { ...themeStore.shortcutBindings, [action.id]: binding }
}
function toggleQuickScroll(value: unknown) {
themeStore.quickScrollEnabled = !!value
setQuickScrollEnabled(themeStore.quickScrollEnabled)
}
function toggleNavShortcut(action: ShortcutAction, value: unknown) {
themeStore[action.enabledField] = !!value
setNavShortcutEnabled(action.id, !!value)
}
</script>
<template>
<div class="flex flex-col gap-6">
<SettingsSection>
<template #header>
<h2 class="m-0 text-lg font-semibold text-[var(--color-text-primary)]">
{{ formatMessage(messages.scrollTitle) }}
</h2>
<p class="m-0 mt-1 text-sm leading-relaxed text-[var(--color-text-tertiary)]">
{{ formatMessage(messages.scrollDescription) }}
</p>
</template>
<SettingsRow>
<template #label>
<span id="settings-target-shortcuts-enable" tabindex="-1">
{{ formatMessage(messages.enable) }}
</span>
</template>
<template #control>
<Toggle
id="quick-scroll-enabled"
:model-value="themeStore.quickScrollEnabled"
@update:model-value="toggleQuickScroll"
/>
</template>
</SettingsRow>
<SettingsRow v-for="action in SCROLL_ACTIONS" :key="action.id">
<template #label>
<span :id="`settings-target-${action.id}`" tabindex="-1">{{ actionLabel(action) }}</span>
</template>
<template #description>{{ actionDescription(action) }}</template>
<template #control>
<button
type="button"
class="keybinding-trigger"
:aria-label="formatMessage(messages.changeShortcut, { action: actionLabel(action) })"
@click="openShortcut(action)"
>
<KeybindingChips :binding="bindingFor(action)" />
</button>
</template>
</SettingsRow>
</SettingsSection>
<SettingsSection>
<template #header>
<h2
id="settings-target-shortcuts-nav"
tabindex="-1"
class="m-0 text-lg font-semibold text-[var(--color-text-primary)]"
>
{{ formatMessage(messages.navTitle) }}
</h2>
<p class="m-0 mt-1 text-sm leading-relaxed text-[var(--color-text-tertiary)]">
{{ formatMessage(messages.navDescription) }}
</p>
</template>
<SettingsRow v-for="action in NAV_ACTIONS" :key="action.id">
<template #label>{{ actionLabel(action) }}</template>
<template #description>{{ actionDescription(action) }}</template>
<template #control>
<div class="flex flex-wrap items-center justify-end gap-3">
<button
type="button"
class="keybinding-trigger"
:aria-label="formatMessage(messages.changeShortcut, { action: actionLabel(action) })"
@click="openShortcut(action)"
>
<KeybindingChips :binding="bindingFor(action)" />
</button>
<Toggle
:id="`nav-shortcut-${action.id}`"
:model-value="themeStore[action.enabledField]"
@update:model-value="(value) => toggleNavShortcut(action, value)"
/>
</div>
</template>
</SettingsRow>
</SettingsSection>
<KeybindingModal
ref="keybindingModal"
:action-label="editingLabel"
:binding="editingBinding"
:default-binding="editingDefault"
:conflicts="editingConflicts"
@save="saveShortcut"
/>
</div>
</template>
<style scoped>
.keybinding-trigger {
cursor: pointer;
border-radius: var(--radius-md);
border: 1px solid transparent;
background: transparent;
padding: 0.25rem 0.375rem;
transition:
background-color 0.15s ease,
border-color 0.15s ease;
}
.keybinding-trigger:hover,
.keybinding-trigger:focus-visible {
border-color: var(--surface-4);
background: var(--surface-3);
}
</style>
@@ -3,6 +3,7 @@ import type { MessageDescriptor } from '@modrinth/ui'
export type SettingsCategoryId =
| 'interface'
| 'home-navigation'
| 'shortcut-settings'
| 'language-translation'
| 'ai'
| 'java-performance'
@@ -45,6 +46,15 @@ export const settingsCategoryDefinitions: SettingsCategoryDefinition[] = [
group: 'launcher',
onboardingId: 'settings-tab-home-navigation',
},
{
id: 'shortcut-settings',
name: {
id: 'app.settings.tabs.shortcut-settings',
defaultMessage: 'Keyboard shortcuts',
},
group: 'launcher',
onboardingId: 'settings-tab-shortcut-settings',
},
{
id: 'language-translation',
name: {
@@ -7,6 +7,7 @@ import {
GameIcon,
GaugeIcon,
InfoIcon,
KeyboardIcon,
LanguagesIcon,
LayoutTemplateIcon,
PaintbrushIcon,
@@ -49,6 +50,10 @@ const categoryContent: Record<SettingsCategoryId, Pick<SettingsCategory, 'icon'
icon: LayoutTemplateIcon,
content: defineAsyncComponent(() => import('./HomeNavigationSettings.vue')),
},
'shortcut-settings': {
icon: KeyboardIcon,
content: defineAsyncComponent(() => import('./KeybindSettings.vue')),
},
'language-translation': {
icon: LanguagesIcon,
content: defineAsyncComponent(() => import('./LanguageTranslationSettings.vue')),
@@ -0,0 +1,215 @@
import {
type KeyBinding,
bindingMatchesKeyboardEvent,
bindingMatchesMouseEvent,
bindingMatchesWheelEvent,
useModalStack,
} from '@modrinth/ui'
import { computed, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useNetworkStatus } from '@/composables/useNetworkStatus'
import {
SCROLL_ACTIONS,
SHORTCUT_ACTIONS,
type ShortcutAction,
} from '@/helpers/shortcut-actions'
import { resolveAllBindings } from '@/helpers/shortcut-bindings'
import { getNavShortcutEnabled } from '@/helpers/nav-shortcut-state'
import { getQuickScrollEnabled } from '@/helpers/scroll-top-state'
import { useTheming } from '@/store/theme'
/** Editing surfaces keep these keys for themselves. */
function isEditableTarget(target: EventTarget | null) {
return (
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement ||
(target instanceof HTMLElement && target.isContentEditable)
)
}
function scrollsAtOwnLevel(element: Element) {
const { overflowY } = getComputedStyle(element)
return /(auto|scroll|overlay)/.test(overflowY) && element.scrollHeight > element.clientHeight
}
/** A container that scrolls the page itself rather than a widget on it. */
function isPageScroller(element: Element) {
return scrollsAtOwnLevel(element) && element.clientHeight >= window.innerHeight / 2
}
/** Nearest scrolling ancestor, whatever its size. */
function nearestScrollContainer(target: EventTarget | null) {
let element = target instanceof Element ? target : null
while (element) {
if (scrollsAtOwnLevel(element)) return element
element = element.parentElement
}
return null
}
/**
* The page's own scroller. Most pages scroll inside `.app-viewport`, but some
* screens (settings, consoles, studios) turn that element's scrolling off and
* host a full-height container inside it, so fall back to whatever fills the
* middle of the viewport.
*/
function resolvePageScroller() {
const viewport = document.querySelector<HTMLElement>('.app-viewport')
if (!viewport) return null
if (isPageScroller(viewport)) return viewport
const bounds = viewport.getBoundingClientRect()
let element: Element | null = document.elementFromPoint(
bounds.left + bounds.width / 2,
bounds.top + bounds.height / 2,
)
while (element) {
if (isPageScroller(element)) return element
element = element.parentElement
}
return viewport
}
export function setupKeyboardShortcuts() {
const themeStore = useTheming()
const { hasModal } = useModalStack()
const route = useRoute()
const router = useRouter()
const { offline } = useNetworkStatus()
themeStore.quickScrollEnabled = getQuickScrollEnabled()
for (const shortcut of SHORTCUT_ACTIONS) {
if (shortcut.group === 'nav') themeStore[shortcut.enabledField] = getNavShortcutEnabled(shortcut.id)
}
themeStore.shortcutBindings = resolveAllBindings()
/** The combination an action answers to now: recorded if there is one, default otherwise. */
function bindingFor(action: ShortcutAction): KeyBinding {
return themeStore.shortcutBindings[action.id] ?? action.defaultBinding
}
/** Whether an action is switched on and reachable in the current state. */
function shortcutIsAvailable(action: ShortcutAction): boolean {
if (!themeStore[action.enabledField]) return false
return !action.unavailable?.({
worldsTabEnabled: themeStore.featureFlags.worlds_tab,
offline: offline.value,
})
}
function findShortcut<E extends Event>(
event: E,
matches: (binding: KeyBinding, event: E) => boolean,
{ includeDisabled = false }: { includeDisabled?: boolean } = {},
): ShortcutAction | null {
if (hasModal.value) return null
if (isEditableTarget(event.target)) return null
for (const action of SHORTCUT_ACTIONS) {
if (!includeDisabled && !shortcutIsAvailable(action)) continue
if (matches(bindingFor(action), event)) return action
}
return null
}
/** Does what an action is for. Scrolling actions move the page's own scroller. */
function runShortcut(action: ShortcutAction, event: Event) {
if (action.target) {
router.push(action.target(route))
return
}
const focusedContainer = nearestScrollContainer(event.target)
if (focusedContainer && !isPageScroller(focusedContainer)) return
const scroller = resolvePageScroller()
if (!scroller) return
action.applyScroll?.(scroller)
}
function handleScrollShortcutKey(event: KeyboardEvent) {
if (event.isComposing) return
const match = findShortcut(event, bindingMatchesKeyboardEvent, { includeDisabled: true })
if (match?.group !== 'scroll') return
const focusedContainer = nearestScrollContainer(event.target)
if (focusedContainer && !isPageScroller(focusedContainer)) return
const scroller = resolvePageScroller()
if (!scroller) return
event.preventDefault()
if (!shortcutIsAvailable(match)) return
match.applyScroll?.(scroller)
}
function handleNavShortcutKey(event: KeyboardEvent) {
if (event.isComposing) return
const match = findShortcut(event, bindingMatchesKeyboardEvent)
if (match?.group !== 'nav') return
event.preventDefault()
runShortcut(match, event)
}
const hasPointerBindings = computed(() =>
SHORTCUT_ACTIONS.some((action) => bindingFor(action).device === 'mouse'),
)
function handleShortcutMouse(event: MouseEvent) {
if (!hasPointerBindings.value) return
const match = findShortcut(event, bindingMatchesMouseEvent)
if (!match) return
event.preventDefault()
event.stopPropagation()
runShortcut(match, event)
}
function handleShortcutWheel(event: WheelEvent) {
if (!hasPointerBindings.value) return
const match = findShortcut(event, bindingMatchesWheelEvent)
if (!match) return
event.preventDefault()
event.stopPropagation()
runShortcut(match, event)
}
function handleGlobalKeydown(event: KeyboardEvent) {
const key = event.key.toLowerCase()
const isFindShortcut = key === 'f' && (event.ctrlKey || event.metaKey)
const isBlockedDevtoolsShortcut = event.key === 'F12' && !themeStore.devMode
if (isFindShortcut || isBlockedDevtoolsShortcut) {
event.preventDefault()
event.stopPropagation()
}
handleScrollShortcutKey(event)
handleNavShortcutKey(event)
}
window.addEventListener('keydown', handleGlobalKeydown, true)
window.addEventListener('mousedown', handleShortcutMouse, true)
// Not passive: a bound wheel direction has to keep the page from scrolling.
window.addEventListener('wheel', handleShortcutWheel, { capture: true, passive: false })
onUnmounted(() => {
window.removeEventListener('keydown', handleGlobalKeydown, true)
window.removeEventListener('mousedown', handleShortcutMouse, true)
window.removeEventListener('wheel', handleShortcutWheel, { capture: true, passive: false })
})
return { bindingFor, shortcutIsAvailable, SCROLL_ACTIONS }
}
@@ -0,0 +1,14 @@
const NAV_SHORTCUT_STORAGE_PREFIX = 'starlight-shortcut-nav-'
/**
* Whether a navigation shortcut is enabled. Shortcuts are off by default;
* each one is turned on individually from the Shortcut settings page.
*/
export function getNavShortcutEnabled(id: string): boolean {
const value = localStorage.getItem(NAV_SHORTCUT_STORAGE_PREFIX + id)
return value === 'true'
}
export function setNavShortcutEnabled(id: string, enabled: boolean) {
localStorage.setItem(NAV_SHORTCUT_STORAGE_PREFIX + id, String(enabled))
}
@@ -0,0 +1,20 @@
const SHOW_SCROLL_TOP_STORAGE_KEY = 'starlight-show-scroll-top'
const QUICK_SCROLL_ENABLED_STORAGE_KEY = 'starlight-quick-scroll-enabled'
export function getShowScrollTop(): boolean {
const value = localStorage.getItem(SHOW_SCROLL_TOP_STORAGE_KEY)
return value !== 'false'
}
export function setShowScrollTop(show: boolean) {
localStorage.setItem(SHOW_SCROLL_TOP_STORAGE_KEY, String(show))
}
export function getQuickScrollEnabled(): boolean {
const value = localStorage.getItem(QUICK_SCROLL_ENABLED_STORAGE_KEY)
return value === 'true'
}
export function setQuickScrollEnabled(enabled: boolean) {
localStorage.setItem(QUICK_SCROLL_ENABLED_STORAGE_KEY, String(enabled))
}
@@ -0,0 +1,309 @@
import { defineMessages, type KeyBinding, type MessageDescriptor } from '@modrinth/ui'
import type { RouteLocationNormalizedLoaded } from 'vue-router'
import { getLastBrowseContentProjectType, isBrowseContentProjectType } from '@/helpers/settings'
/** Which half of the settings page an action belongs to. */
export type ShortcutGroupId = 'scroll' | 'nav'
/** Theme store fields that switch an action on. */
export type ShortcutEnabledField =
| 'quickScrollEnabled'
| 'shortcutNavHome'
| 'shortcutNavWorlds'
| 'shortcutNavDiscover'
| 'shortcutNavSkins'
| 'shortcutNavMultiplayer'
| 'shortcutNavLibrary'
| 'shortcutNavLab'
| 'shortcutNavDownloads'
| 'shortcutNavCreate'
| 'shortcutNavSettings'
export interface ShortcutContext {
worldsTabEnabled: boolean
offline: boolean
}
export interface ShortcutAction {
/** Stable id, used for the binding, the enable flag and the settings row. */
id: string
group: ShortcutGroupId
enabledField: ShortcutEnabledField
defaultBinding: KeyBinding
label: MessageDescriptor
description: MessageDescriptor
/** Navigation actions know where they lead. */
target?: (route: RouteLocationNormalizedLoaded) => string
/** Navigation actions that are not reachable right now. */
unavailable?: (context: ShortcutContext) => boolean
/** Scrolling actions move the page's own scroller. */
applyScroll?: (scroller: HTMLElement) => void
}
/**
* Declared here rather than assembled from the action ids, so the extractor can
* see them; an id built at runtime is one a translation pass would drop.
*/
const messages = defineMessages({
scrollTop: {
id: 'app.shortcut-settings.home-action',
defaultMessage: 'Scroll to the top',
},
scrollTopDescription: {
id: 'app.shortcut-settings.home-action-description',
defaultMessage: 'Moves the page you are reading to the top.',
},
scrollBottom: {
id: 'app.shortcut-settings.end-action',
defaultMessage: 'Scroll to the bottom',
},
scrollBottomDescription: {
id: 'app.shortcut-settings.end-action-description',
defaultMessage: 'Moves the page you are reading to the bottom.',
},
scrollUp: {
id: 'app.shortcut-settings.page-up-action',
defaultMessage: 'Scroll up one screen',
},
scrollUpDescription: {
id: 'app.shortcut-settings.page-up-action-description',
defaultMessage: 'Moves the page you are reading up by one screen.',
},
scrollDown: {
id: 'app.shortcut-settings.page-down-action',
defaultMessage: 'Scroll down one screen',
},
scrollDownDescription: {
id: 'app.shortcut-settings.page-down-action-description',
defaultMessage: 'Moves the page you are reading down by one screen.',
},
navHome: { id: 'app.shortcut-settings.nav-home', defaultMessage: 'Home' },
navHomeDescription: {
id: 'app.shortcut-settings.nav-home-description',
defaultMessage: 'Jump to the Home page.',
},
navWorlds: { id: 'app.shortcut-settings.nav-worlds', defaultMessage: 'Worlds' },
navWorldsDescription: {
id: 'app.shortcut-settings.nav-worlds-description',
defaultMessage: 'Jump to your worlds.',
},
navDiscover: {
id: 'app.shortcut-settings.nav-discover',
defaultMessage: 'Discover content',
},
navDiscoverDescription: {
id: 'app.shortcut-settings.nav-discover-description',
defaultMessage: 'Jump to browsing content.',
},
navSkins: { id: 'app.shortcut-settings.nav-skins', defaultMessage: 'Skin selector' },
navSkinsDescription: {
id: 'app.shortcut-settings.nav-skins-description',
defaultMessage: 'Jump to the skin selector.',
},
navMultiplayer: { id: 'app.shortcut-settings.nav-multiplayer', defaultMessage: 'Multiplayer' },
navMultiplayerDescription: {
id: 'app.shortcut-settings.nav-multiplayer-description',
defaultMessage: 'Jump to multiplayer.',
},
navLibrary: { id: 'app.shortcut-settings.nav-library', defaultMessage: 'Library' },
navLibraryDescription: {
id: 'app.shortcut-settings.nav-library-description',
defaultMessage: 'Jump to your library.',
},
navLab: { id: 'app.shortcut-settings.nav-lab', defaultMessage: 'Lab' },
navLabDescription: {
id: 'app.shortcut-settings.nav-lab-description',
defaultMessage: 'Jump to the Lab.',
},
navDownloads: { id: 'app.shortcut-settings.nav-downloads', defaultMessage: 'Downloads' },
navDownloadsDescription: {
id: 'app.shortcut-settings.nav-downloads-description',
defaultMessage: 'Jump to downloads.',
},
navCreate: { id: 'app.shortcut-settings.nav-create', defaultMessage: 'Create new instance' },
navCreateDescription: {
id: 'app.shortcut-settings.nav-create-description',
defaultMessage: 'Jump to creating a new instance.',
},
navSettings: { id: 'app.shortcut-settings.nav-settings', defaultMessage: 'Settings' },
navSettingsDescription: {
id: 'app.shortcut-settings.nav-settings-description',
defaultMessage: 'Jump to the settings.',
},
})
function keyboard(code: string, mod = false, alt = false, shift = false): KeyBinding {
return { device: 'keyboard', code, mod, alt, shift }
}
/**
* Resolves the Discover content target from the current route, so the nav
* button and the keyboard shortcut share one source of truth.
*/
export function discoverContentTarget(route: RouteLocationNormalizedLoaded): string {
const projectType = route.params.projectType
if (
!route.query.i &&
!route.query.sid &&
!route.query.wid &&
typeof projectType === 'string' &&
isBrowseContentProjectType(projectType)
) {
return `/browse/${projectType}`
}
return `/browse/${getLastBrowseContentProjectType()}`
}
/**
* Every shortcut the launcher answers to. The binding here is the one a fresh
* install uses; a recorded binding is stored separately and wins over it.
*/
export const SHORTCUT_ACTIONS: ShortcutAction[] = [
{
id: 'shortcutScrollTop',
group: 'scroll',
enabledField: 'quickScrollEnabled',
defaultBinding: keyboard('Home'),
label: messages.scrollTop,
description: messages.scrollTopDescription,
applyScroll: (scroller) => scroller.scrollTo({ top: 0, behavior: 'smooth' }),
},
{
id: 'shortcutScrollBottom',
group: 'scroll',
enabledField: 'quickScrollEnabled',
defaultBinding: keyboard('End'),
label: messages.scrollBottom,
description: messages.scrollBottomDescription,
applyScroll: (scroller) =>
scroller.scrollTo({ top: scroller.scrollHeight, behavior: 'smooth' }),
},
{
id: 'shortcutScrollUp',
group: 'scroll',
enabledField: 'quickScrollEnabled',
defaultBinding: keyboard('PageUp'),
label: messages.scrollUp,
description: messages.scrollUpDescription,
applyScroll: (scroller) => {
// Immediate scrolling keeps rapid key repeats responsive.
scroller.scrollTop = Math.max(0, scroller.scrollTop - scroller.clientHeight * 0.9)
},
},
{
id: 'shortcutScrollDown',
group: 'scroll',
enabledField: 'quickScrollEnabled',
defaultBinding: keyboard('PageDown'),
label: messages.scrollDown,
description: messages.scrollDownDescription,
applyScroll: (scroller) => {
scroller.scrollTop = Math.min(
scroller.scrollHeight,
scroller.scrollTop + scroller.clientHeight * 0.9,
)
},
},
{
id: 'shortcutNavHome',
group: 'nav',
enabledField: 'shortcutNavHome',
defaultBinding: keyboard('Digit1', true),
label: messages.navHome,
description: messages.navHomeDescription,
target: () => '/',
},
{
id: 'shortcutNavWorlds',
group: 'nav',
enabledField: 'shortcutNavWorlds',
defaultBinding: keyboard('Digit2', true),
label: messages.navWorlds,
description: messages.navWorldsDescription,
target: () => '/worlds',
unavailable: (context) => !context.worldsTabEnabled,
},
{
id: 'shortcutNavDiscover',
group: 'nav',
enabledField: 'shortcutNavDiscover',
defaultBinding: keyboard('Digit3', true),
label: messages.navDiscover,
description: messages.navDiscoverDescription,
target: (route) => discoverContentTarget(route),
unavailable: (context) => context.offline,
},
{
id: 'shortcutNavSkins',
group: 'nav',
enabledField: 'shortcutNavSkins',
defaultBinding: keyboard('Digit4', true),
label: messages.navSkins,
description: messages.navSkinsDescription,
target: () => '/skins',
},
{
id: 'shortcutNavMultiplayer',
group: 'nav',
enabledField: 'shortcutNavMultiplayer',
defaultBinding: keyboard('Digit5', true),
label: messages.navMultiplayer,
description: messages.navMultiplayerDescription,
target: () => '/multiplayer',
},
{
id: 'shortcutNavLibrary',
group: 'nav',
enabledField: 'shortcutNavLibrary',
defaultBinding: keyboard('Digit6', true),
label: messages.navLibrary,
description: messages.navLibraryDescription,
target: () => '/library',
},
{
id: 'shortcutNavLab',
group: 'nav',
enabledField: 'shortcutNavLab',
defaultBinding: keyboard('Digit7', true),
label: messages.navLab,
description: messages.navLabDescription,
target: () => '/lab',
},
{
id: 'shortcutNavDownloads',
group: 'nav',
enabledField: 'shortcutNavDownloads',
defaultBinding: keyboard('Digit8', true),
label: messages.navDownloads,
description: messages.navDownloadsDescription,
target: () => '/downloads',
},
{
id: 'shortcutNavCreate',
group: 'nav',
enabledField: 'shortcutNavCreate',
defaultBinding: keyboard('Digit9', true),
label: messages.navCreate,
description: messages.navCreateDescription,
target: () => '/create',
unavailable: (context) => context.offline,
},
{
id: 'shortcutNavSettings',
group: 'nav',
enabledField: 'shortcutNavSettings',
defaultBinding: keyboard('Comma', true),
label: messages.navSettings,
description: messages.navSettingsDescription,
target: () => '/settings',
},
]
export const SCROLL_ACTIONS = SHORTCUT_ACTIONS.filter((action) => action.group === 'scroll')
export const NAV_ACTIONS = SHORTCUT_ACTIONS.filter((action) => action.group === 'nav')
export function shortcutAction(id: string): ShortcutAction | undefined {
return SHORTCUT_ACTIONS.find((action) => action.id === id)
}
@@ -0,0 +1,120 @@
import type { BindingConflict, KeyBinding } from '@modrinth/ui'
import { SHORTCUT_ACTIONS, shortcutAction } from '@/helpers/shortcut-actions'
const BINDING_STORAGE_PREFIX = 'starlight-shortcut-binding-'
/**
* Combinations the shell or the operating system answers to before the launcher
* sees them. Saved shortcuts may not take these over.
*/
export const RESERVED_BINDINGS: BindingConflict[] = [
{
id: 'system-find',
label: 'Find in page (Ctrl/Cmd + F)',
binding: keyboard('KeyF', true),
reserved: true,
},
{
id: 'system-devtools',
label: 'Developer tools (F12)',
binding: keyboard('F12'),
reserved: true,
},
{
id: 'system-close',
label: 'Close window (Ctrl/Cmd + W)',
binding: keyboard('KeyW', true),
reserved: true,
},
{
id: 'system-quit',
label: 'Quit (Ctrl/Cmd + Q)',
binding: keyboard('KeyQ', true),
reserved: true,
},
{
id: 'system-reload',
label: 'Reload (Ctrl/Cmd + R)',
binding: keyboard('KeyR', true),
reserved: true,
},
{ id: 'system-refresh', label: 'Refresh (F5)', binding: keyboard('F5'), reserved: true },
{
id: 'system-exit',
label: 'Close window (Alt + F4)',
binding: keyboard('F4', false, true),
reserved: true,
},
]
function keyboard(code: string, mod = false, alt = false, shift = false): KeyBinding {
return { device: 'keyboard', code, mod, alt, shift }
}
function isKeyBinding(value: unknown): value is KeyBinding {
if (!value || typeof value !== 'object') return false
const candidate = value as Partial<KeyBinding>
return (
(candidate.device === 'keyboard' || candidate.device === 'mouse') &&
typeof candidate.code === 'string' &&
candidate.code.length > 0 &&
typeof candidate.mod === 'boolean' &&
typeof candidate.shift === 'boolean' &&
typeof candidate.alt === 'boolean'
)
}
/** The combination recorded for an action, if it has one. */
export function readStoredBinding(id: string): KeyBinding | null {
const raw = localStorage.getItem(BINDING_STORAGE_PREFIX + id)
if (!raw) return null
try {
const parsed = JSON.parse(raw)
return isKeyBinding(parsed) ? parsed : null
} catch {
// A value that cannot be read is treated as unset, so the action keeps
// working with its default rather than becoming unreachable.
return null
}
}
export function storeBinding(id: string, binding: KeyBinding) {
localStorage.setItem(BINDING_STORAGE_PREFIX + id, JSON.stringify(binding))
}
/** Drops the recorded combination, putting the action back on its default. */
export function clearStoredBinding(id: string) {
if (readStoredBinding(id)) localStorage.removeItem(BINDING_STORAGE_PREFIX + id)
}
/** The combination an action answers to now: the recorded one, or its default. */
export function resolveBinding(id: string): KeyBinding | null {
const action = shortcutAction(id)
if (!action) return null
return readStoredBinding(id) ?? action.defaultBinding
}
export function resolveAllBindings(): Record<string, KeyBinding> {
const bindings: Record<string, KeyBinding> = {}
for (const action of SHORTCUT_ACTIONS) {
bindings[action.id] = resolveBinding(action.id) ?? action.defaultBinding
}
return bindings
}
/**
* Everything a new combination would collide with: the other actions, plus the
* combinations the shell owns. `label` is what the dialog names in the error.
*/
export function buildConflicts(exceptId: string, label: (id: string) => string): BindingConflict[] {
const conflicts = SHORTCUT_ACTIONS.filter((action) => action.id !== exceptId).flatMap(
(action) => {
const binding = resolveBinding(action.id)
return binding ? [{ id: action.id, label: label(action.id), binding }] : []
},
)
return [...conflicts, ...RESERVED_BINDINGS]
}
+37 -1
View File
@@ -11600,4 +11600,40 @@
"app.library.context-menu.unpin-instance": {"message": "Unpin instance"},
"app.library.group.new": {"message": "New group"},
"app.library.selection.new-group": {"message": "New group"},
"app.library.selection.remove-from-group": {"message": "Remove from group"}}
"app.library.selection.remove-from-group": {"message": "Remove from group"},
"app.shortcut-settings.change-shortcut": {"message": "Change the shortcut for {action}"},
"app.shortcut-settings.enable": {"message": "Enable quick scrolling"},
"app.shortcut-settings.end-action": {"message": "Scroll to the bottom"},
"app.shortcut-settings.end-action-description": {"message": "Moves the page you are reading to the bottom."},
"app.shortcut-settings.home-action": {"message": "Scroll to the top"},
"app.shortcut-settings.home-action-description": {"message": "Moves the page you are reading to the top."},
"app.shortcut-settings.nav-create": {"message": "Create new instance"},
"app.shortcut-settings.nav-create-description": {"message": "Jump to creating a new instance."},
"app.shortcut-settings.nav-description": {"message": "Jump to a menu item with its shortcut. Each shortcut is off until enabled."},
"app.shortcut-settings.nav-discover": {"message": "Discover content"},
"app.shortcut-settings.nav-discover-description": {"message": "Jump to browsing content."},
"app.shortcut-settings.nav-downloads": {"message": "Downloads"},
"app.shortcut-settings.nav-downloads-description": {"message": "Jump to downloads."},
"app.shortcut-settings.nav-home": {"message": "Home"},
"app.shortcut-settings.nav-home-description": {"message": "Jump to the Home page."},
"app.shortcut-settings.nav-lab": {"message": "Lab"},
"app.shortcut-settings.nav-lab-description": {"message": "Jump to the Lab."},
"app.shortcut-settings.nav-library": {"message": "Library"},
"app.shortcut-settings.nav-library-description": {"message": "Jump to your library."},
"app.shortcut-settings.nav-multiplayer": {"message": "Multiplayer"},
"app.shortcut-settings.nav-multiplayer-description": {"message": "Jump to multiplayer."},
"app.shortcut-settings.nav-settings": {"message": "Settings"},
"app.shortcut-settings.nav-settings-description": {"message": "Jump to the settings."},
"app.shortcut-settings.nav-skins": {"message": "Skin selector"},
"app.shortcut-settings.nav-skins-description": {"message": "Jump to the skin selector."},
"app.shortcut-settings.nav-title": {"message": "Navigation shortcuts"},
"app.shortcut-settings.nav-worlds": {"message": "Worlds"},
"app.shortcut-settings.nav-worlds-description": {"message": "Jump to your worlds."},
"app.shortcut-settings.page-down-action": {"message": "Scroll down one screen"},
"app.shortcut-settings.page-down-action-description": {"message": "Moves the page you are reading down by one screen."},
"app.shortcut-settings.page-up-action": {"message": "Scroll up one screen"},
"app.shortcut-settings.page-up-action-description": {"message": "Moves the page you are reading up by one screen."},
"app.shortcut-settings.scroll-description": {"message": "Move through long pages with the keyboard."},
"app.shortcut-settings.scroll-title": {"message": "Quick scrolling"},
"app.settings.tabs.shortcut-settings": {"message": "Keyboard shortcuts"}
}
+37 -1
View File
@@ -11431,4 +11431,40 @@
"app.library.context-menu.unpin-instance": {"message": "取消置顶"},
"app.library.group.new": {"message": "新建分组"},
"app.library.selection.new-group": {"message": "新建分组"},
"app.library.selection.remove-from-group": {"message": "移出分组"}}
"app.library.selection.remove-from-group": {"message": "移出分组"},
"app.shortcut-settings.change-shortcut": {"message": "更改「{action}」的快捷键"},
"app.shortcut-settings.enable": {"message": "启用快捷滚动"},
"app.shortcut-settings.end-action": {"message": "滚动到页面底部"},
"app.shortcut-settings.end-action-description": {"message": "将当前页面移动到底部。"},
"app.shortcut-settings.home-action": {"message": "滚动到页面顶部"},
"app.shortcut-settings.home-action-description": {"message": "将当前页面移动到顶部。"},
"app.shortcut-settings.nav-create": {"message": "新建实例"},
"app.shortcut-settings.nav-create-description": {"message": "前往新建实例。"},
"app.shortcut-settings.nav-description": {"message": "使用各自的快捷键跳转到对应菜单项。每个快捷键默认关闭,需逐个启用。"},
"app.shortcut-settings.nav-discover": {"message": "发现内容"},
"app.shortcut-settings.nav-discover-description": {"message": "前往浏览内容。"},
"app.shortcut-settings.nav-downloads": {"message": "下载"},
"app.shortcut-settings.nav-downloads-description": {"message": "前往下载。"},
"app.shortcut-settings.nav-home": {"message": "首页"},
"app.shortcut-settings.nav-home-description": {"message": "前往首页。"},
"app.shortcut-settings.nav-lab": {"message": "实验室"},
"app.shortcut-settings.nav-lab-description": {"message": "前往实验室。"},
"app.shortcut-settings.nav-library": {"message": "内容库"},
"app.shortcut-settings.nav-library-description": {"message": "前往内容库。"},
"app.shortcut-settings.nav-multiplayer": {"message": "多人游戏"},
"app.shortcut-settings.nav-multiplayer-description": {"message": "前往多人游戏。"},
"app.shortcut-settings.nav-settings": {"message": "设置"},
"app.shortcut-settings.nav-settings-description": {"message": "前往设置。"},
"app.shortcut-settings.nav-skins": {"message": "皮肤选择器"},
"app.shortcut-settings.nav-skins-description": {"message": "前往皮肤选择器。"},
"app.shortcut-settings.nav-title": {"message": "导航快捷键"},
"app.shortcut-settings.nav-worlds": {"message": "世界"},
"app.shortcut-settings.nav-worlds-description": {"message": "前往你的世界。"},
"app.shortcut-settings.page-down-action": {"message": "向下滚动一屏"},
"app.shortcut-settings.page-down-action-description": {"message": "将当前页面向下移动一屏。"},
"app.shortcut-settings.page-up-action": {"message": "向上滚动一屏"},
"app.shortcut-settings.page-up-action-description": {"message": "将当前页面向上移动一屏。"},
"app.shortcut-settings.scroll-description": {"message": "使用键盘在长页面中快速滚动。"},
"app.shortcut-settings.scroll-title": {"message": "快速滚动"},
"app.settings.tabs.shortcut-settings": {"message": "键盘快捷键"}
}
+51 -1
View File
@@ -6,6 +6,18 @@ import {
resolveFontFamily,
} from '@/helpers/font-family.ts'
/**
* Mirrors `KeyBinding` from `@modrinth/ui`. Declared here so this module stays
* loadable without the UI package.
*/
export interface ShortcutBinding {
device: 'keyboard' | 'mouse'
code: string
mod: boolean
shift: boolean
alt: boolean
}
let systemThemeMq: MediaQueryList | null = null
export const DEFAULT_FEATURE_FLAGS = {
@@ -135,8 +147,30 @@ export type ThemeStore = {
minimalHomeInstanceId: string | null
closeBehavior: CloseBehavior
/** Whether the floating "back to top" button is shown on scrollable pages. */
showScrollTop: boolean
/** Whether quick scrolling shortcuts (Home/End/PageUp/PageDown) are active. */
quickScrollEnabled: boolean
/** Per-nav-item quick jump shortcuts (Ctrl/Cmd + number), off by default. */
shortcutNavHome: boolean
shortcutNavWorlds: boolean
shortcutNavDiscover: boolean
shortcutNavSkins: boolean
shortcutNavMultiplayer: boolean
shortcutNavLibrary: boolean
shortcutNavLab: boolean
shortcutNavDownloads: boolean
shortcutNavCreate: boolean
shortcutNavSettings: boolean
/**
* The combination every shortcut answers to, keyed by action id. Kept here so
* pages re-render when one is recorded; the value itself lives in local
* storage, next to the enable flags.
*/
shortcutBindings: Record<string, ShortcutBinding>
devMode: boolean
featureFlags: FeatureFlags
uiFont: string | null
monoFont: string | null
@@ -162,6 +196,22 @@ export const DEFAULT_THEME_STORE: ThemeStore = {
minimalHomeInstanceId: null,
closeBehavior: 'ask',
showScrollTop: true,
quickScrollEnabled: false,
shortcutNavHome: false,
shortcutNavWorlds: false,
shortcutNavDiscover: false,
shortcutNavSkins: false,
shortcutNavMultiplayer: false,
shortcutNavLibrary: false,
shortcutNavLab: false,
shortcutNavDownloads: false,
shortcutNavCreate: false,
shortcutNavSettings: false,
shortcutBindings: {},
devMode: false,
featureFlags: DEFAULT_FEATURE_FLAGS,
@@ -0,0 +1,23 @@
<template>
<span class="inline-flex flex-wrap items-center gap-1">
<kbd
v-for="(part, index) in parts"
:key="`${index}-${part}`"
aria-hidden="true"
class="rounded-md border border-solid border-surface-4 bg-surface-3 px-2 py-0.5 font-mono text-sm text-[var(--color-text-primary)]"
>
{{ part }}
</kbd>
<span class="sr-only">{{ text }}</span>
</span>
</template>
<script setup lang="ts">
import { useBindingParts, useBindingText } from '../../composables/use-keybinding-labels'
import type { KeyBinding } from '../../utils/keybinding'
const props = defineProps<{ binding: KeyBinding }>()
const parts = useBindingParts(() => props.binding)
const text = useBindingText(() => props.binding)
</script>
+1
View File
@@ -118,3 +118,4 @@ export { default as TimeFramePicker } from './TimeFramePicker.vue'
export { default as Timeline } from './Timeline.vue'
export { default as Toggle } from './Toggle.vue'
export { default as UnsavedChangesPopup } from './UnsavedChangesPopup.vue'
export { default as KeybindingChips } from './KeybindingChips.vue'
@@ -0,0 +1,256 @@
<template>
<div
ref="treeRef"
role="tree"
:aria-label="formatMessage(messages.treeLabel)"
class="max-h-[18rem] overflow-y-auto rounded-lg border border-solid border-surface-4 bg-surface-2 p-1"
@keydown="handleKeydown"
>
<div
v-for="(row, index) in visibleRows"
:key="row.id"
:ref="(element) => setRowRef(row.id, element)"
role="treeitem"
:tabindex="index === activeIndex ? 0 : -1"
:aria-level="row.level + 1"
:aria-expanded="row.expandable ? row.expanded : undefined"
:aria-selected="row.kind === 'item' ? row.selected : undefined"
:aria-disabled="row.disabled ? true : undefined"
class="flex cursor-pointer items-center gap-2 rounded-md py-2 pr-2 text-sm transition-colors duration-150"
:class="[
row.disabled ? 'cursor-not-allowed opacity-50' : 'hover:bg-surface-3',
row.selected
? 'bg-brand-highlight text-[var(--color-text-primary)]'
: 'text-[var(--color-text-default)]',
row.kind === 'item' ? '' : 'font-semibold text-[var(--color-text-primary)]',
]"
:style="{ paddingLeft: `${row.level * 1.25 + 0.5}rem` }"
@click="activateRow(row)"
@focus="activeIndex = index"
>
<ChevronRightIcon
v-if="row.expandable"
class="h-4 w-4 shrink-0 text-[var(--color-text-tertiary)] transition-transform duration-150"
:class="{ 'rotate-90': row.expanded }"
aria-hidden="true"
/>
<span v-else class="w-4 shrink-0" aria-hidden="true" />
<span class="min-w-0 flex-1 truncate">{{ row.label }}</span>
<span v-if="row.disabledReason" class="shrink-0 text-xs text-[var(--color-text-tertiary)]">
{{ row.disabledReason }}
</span>
</div>
</div>
</template>
<script setup lang="ts">
import { ChevronRightIcon } from '@modrinth/assets'
import { computed, nextTick, ref } from 'vue'
import { defineMessages, useVIntl } from '../../composables/i18n'
import { type KeyBinding, type KeyBindingDevice, keyCodeLabel } from '../../utils/keybinding'
import { KEYBINDING_CATALOG, type KeybindingCatalogItem } from '../../utils/keybinding-catalog'
const props = withDefaults(
defineProps<{
/** The combination being assembled, used to mark the chosen input. */
binding: KeyBinding | null
/** Reasons an input may not be used, keyed by code. */
disabledCodes?: Map<string, string>
}>(),
{ disabledCodes: () => new Map() },
)
const emit = defineEmits<{
select: [{ device: KeyBindingDevice; code: string }]
escape: []
}>()
const { formatMessage } = useVIntl()
const messages = defineMessages({
treeLabel: {
id: 'ui.keybinding-modal.tree-label',
defaultMessage: 'Inputs a shortcut can use',
},
})
interface TreeRow {
id: string
kind: 'device' | 'group' | 'item'
level: number
label: string
expandable: boolean
expanded: boolean
selected: boolean
disabled: boolean
disabledReason?: string
item?: KeybindingCatalogItem
}
const expanded = ref(new Set<string>(['keyboard']))
const activeIndex = ref(0)
const rowRefs = new Map<string, HTMLElement>()
function groupRowId(deviceId: string, groupId: string) {
return `${deviceId}:${groupId}`
}
function itemRowId(deviceId: string, groupId: string, code: string) {
return `${deviceId}:${groupId}:${code}`
}
function itemLabel(item: KeybindingCatalogItem) {
return item.label ? formatMessage(item.label) : keyCodeLabel(item.code)
}
function itemState(item: KeybindingCatalogItem) {
const reason = props.disabledCodes.get(item.code)
return { disabled: Boolean(reason), disabledReason: reason }
}
const visibleRows = computed<TreeRow[]>(() => {
const rows: TreeRow[] = []
for (const device of KEYBINDING_CATALOG) {
const deviceExpanded = expanded.value.has(device.id)
rows.push({
id: device.id,
kind: 'device',
level: 0,
label: formatMessage(device.label),
expandable: true,
expanded: deviceExpanded,
selected: false,
disabled: false,
})
if (!deviceExpanded) continue
for (const group of device.groups) {
const rowId = groupRowId(device.id, group.id)
const groupExpanded = expanded.value.has(rowId)
rows.push({
id: rowId,
kind: 'group',
level: 1,
label: formatMessage(group.label),
expandable: true,
expanded: groupExpanded,
selected: false,
disabled: false,
})
if (!groupExpanded) continue
for (const item of group.items) {
const state = itemState(item)
rows.push({
id: itemRowId(device.id, group.id, item.code),
kind: 'item',
level: 2,
label: itemLabel(item),
expandable: false,
expanded: false,
selected: props.binding?.device === item.device && props.binding?.code === item.code,
item,
...state,
})
}
}
}
return rows
})
function setRowRef(id: string, element: unknown) {
if (element instanceof HTMLElement) rowRefs.set(id, element)
else rowRefs.delete(id)
}
function focusRow(index: number) {
const row = visibleRows.value[index]
if (!row) return
activeIndex.value = index
void nextTick(() => rowRefs.get(row.id)?.focus())
}
function toggle(row: TreeRow) {
if (!row.expandable) return
const next = new Set(expanded.value)
if (next.has(row.id)) next.delete(row.id)
else next.add(row.id)
expanded.value = next
// Collapsing can leave the active row past the end, which would stop the
// keyboard from moving anywhere.
const lastIndex = visibleRows.value.length - 1
if (activeIndex.value > lastIndex) focusRow(lastIndex)
}
function parentIndexOf(row: TreeRow) {
for (let index = visibleRows.value.indexOf(row) - 1; index >= 0; index -= 1) {
if (visibleRows.value[index].level < row.level) return index
}
return -1
}
function activateRow(row: TreeRow) {
if (row.kind === 'item') {
if (row.disabled || !row.item) return
emit('select', { device: row.item.device, code: row.item.code })
return
}
toggle(row)
}
function handleKeydown(event: KeyboardEvent) {
const row = visibleRows.value[activeIndex.value]
if (!row) return
switch (event.key) {
case 'ArrowDown':
event.preventDefault()
focusRow(Math.min(activeIndex.value + 1, visibleRows.value.length - 1))
break
case 'ArrowUp':
event.preventDefault()
focusRow(Math.max(activeIndex.value - 1, 0))
break
case 'ArrowRight':
event.preventDefault()
if (row.expandable && !row.expanded) toggle(row)
else if (row.expandable) focusRow(activeIndex.value + 1)
break
case 'ArrowLeft': {
event.preventDefault()
if (row.expandable && row.expanded) {
toggle(row)
break
}
const parent = parentIndexOf(row)
if (parent !== -1) focusRow(parent)
break
}
case 'Home':
event.preventDefault()
focusRow(0)
break
case 'End':
event.preventDefault()
focusRow(visibleRows.value.length - 1)
break
case 'Enter':
case ' ':
event.preventDefault()
activateRow(row)
break
case 'Escape':
// Handled here so escape leaves the picker instead of the whole dialog.
event.preventDefault()
event.stopPropagation()
emit('escape')
break
default:
break
}
}
</script>
@@ -0,0 +1,574 @@
<template>
<NewModal
ref="modal"
:header="headerText"
max-width="540px"
:on-hide="handleModalHide"
:close-on-click-outside="false"
>
<div class="flex min-w-0 flex-col gap-4">
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex min-w-0 flex-col gap-1">
<span class="text-sm text-[var(--color-text-tertiary)]">{{
formatMessage(messages.current)
}}</span>
<KeybindingChips v-if="props.binding" :binding="props.binding" />
<span v-else class="text-sm text-[var(--color-text-tertiary)]">{{
formatMessage(messages.unset)
}}</span>
</div>
<Button v-if="canRestoreDefault" type="outlined" size="2xs" @click="restoreDefault"
>{{ formatMessage(messages.restoreDefault) }}
</Button>
</div>
<button
v-if="phase !== 'tree'"
type="button"
class="flex min-h-[7rem] cursor-pointer flex-col items-center justify-center gap-3 rounded-xl border-2 border-dashed p-4 transition-colors duration-200"
:class="
phase === 'listening'
? 'border-brand bg-brand-highlight'
: 'border-surface-4 bg-surface-2 hover:border-brand'
"
:disabled="phase !== 'idle'"
@click="beginListening"
>
<template v-if="phase === 'listening'">
<span class="text-base font-semibold text-[var(--color-text-primary)]">
{{ formatMessage(messages.listeningTitle) }}
</span>
<span v-if="liveParts.length" class="flex flex-wrap items-center justify-center gap-1">
<kbd
v-for="(part, index) in liveParts"
:key="`${index}-${part}`"
class="rounded-md border border-solid border-surface-4 bg-surface-3 px-2 py-0.5 font-mono text-sm text-[var(--color-text-primary)]"
>
{{ part }}
</kbd>
</span>
<span v-else class="text-sm text-[var(--color-text-tertiary)]">
{{ formatMessage(messages.listeningHint) }}
</span>
<span class="block h-1 w-full max-w-[16rem] overflow-hidden rounded-full bg-surface-4">
<span
:key="captureRun"
class="keybinding-capture-bar block h-full rounded-full bg-brand"
:style="{ animationDuration: `${props.listenDurationMs}ms` }"
/>
</span>
</template>
<template v-else-if="phase === 'verifying'">
<span class="text-base font-semibold text-[var(--color-text-primary)]">
{{ formatMessage(verified ? messages.verifiedTitle : messages.verifyingTitle) }}
</span>
<KeybindingChips v-if="candidate" :binding="candidate" />
<span v-if="!verified" class="text-sm text-[var(--color-text-tertiary)]">
{{ formatMessage(messages.verifyingHint) }}
</span>
</template>
<template v-else>
<span class="text-base font-semibold text-[var(--color-text-primary)]">
{{ formatMessage(messages.captureTitle) }}
</span>
<span class="text-sm text-[var(--color-text-tertiary)]">{{
formatMessage(messages.captureHint)
}}</span>
</template>
</button>
<template v-else>
<div
class="flex flex-col gap-2 rounded-xl border border-solid border-surface-4 bg-surface-2 p-3"
>
<span class="text-sm font-semibold text-[var(--color-text-primary)]">
{{ formatMessage(messages.modifiers) }}
</span>
<div class="flex flex-wrap gap-2">
<Button
v-for="modifier in modifierOptions"
:key="modifier.field"
size="2xs"
:type="modifierState[modifier.field] ? 'highlight' : 'outlined'"
:aria-pressed="modifierState[modifier.field]"
@click="modifierState[modifier.field] = !modifierState[modifier.field]"
>
{{ modifier.label }}
</Button>
</div>
</div>
<KeybindingChoiceTree
:binding="treeBinding"
:disabled-codes="treeDisabledCodes"
@select="selectTreeInput"
@escape="phase = 'idle'"
/>
</template>
<div class="flex flex-col gap-1" aria-live="polite">
<p v-if="issueText" class="m-0 text-sm text-red">{{ issueText }}</p>
<p v-else-if="mismatchText" class="m-0 text-sm text-orange">{{ mismatchText }}</p>
<p v-else-if="verified" class="m-0 text-sm text-green">
{{ formatMessage(messages.verifiedHint) }}
</p>
<p v-else-if="nothingCapturedText" class="m-0 text-sm text-[var(--color-text-tertiary)]">
{{ nothingCapturedText }}
</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<Button v-if="phase === 'tree'" type="outlined" @click="leaveTree"
>{{ formatMessage(messages.useCapture) }}
</Button>
<Button v-else type="outlined" @click="enterTree"
>{{ formatMessage(messages.useSpecial) }}
</Button>
<span v-if="phase === 'tree'" class="text-xs text-[var(--color-text-tertiary)]">
{{ formatMessage(messages.specialHint) }}
</span>
</div>
</div>
<template #actions>
<div class="flex justify-end gap-2">
<Button type="outlined" @click="cancel">{{ formatMessage(messages.cancel) }}</Button>
<Button type="colored" color="brand" :disabled="!canSave" @click="save"
>{{ formatMessage(messages.save) }}
</Button>
</div>
</template>
</NewModal>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { defineMessages, useVIntl } from '../../composables/i18n'
import { useKeybindingLabels } from '../../composables/use-keybinding-labels'
import {
type BindingConflict,
bindingFromKeyboardEvent,
bindingsEqual,
isModifierCode,
type KeyBinding,
keyCodeLabel,
validateBinding,
} from '../../utils/keybinding'
import { KEYBINDING_CATALOG } from '../../utils/keybinding-catalog'
import Button from '../base/buttons/Button.vue'
import KeybindingChips from '../base/KeybindingChips.vue'
import KeybindingChoiceTree from './KeybindingChoiceTree.vue'
import NewModal from './NewModal.vue'
const props = withDefaults(
defineProps<{
/** The action being configured, shown in the title. */
actionLabel?: string
/** The combination the action answers to right now. */
binding?: KeyBinding | null
/** The combination the action ships with, offered as a way back. */
defaultBinding?: KeyBinding | null
/** Combinations that belong to something else. */
conflicts?: BindingConflict[]
/** How long a recording may run before it settles on its own. */
listenDurationMs?: number
/** How long after the last key is released a recording settles. */
settleMs?: number
}>(),
{
actionLabel: undefined,
binding: null,
defaultBinding: null,
conflicts: () => [],
listenDurationMs: 5000,
settleMs: 700,
},
)
const emit = defineEmits<{
save: [binding: KeyBinding]
cancel: []
}>()
const { formatMessage } = useVIntl()
const labels = useKeybindingLabels()
const messages = defineMessages({
title: { id: 'ui.keybinding-modal.title', defaultMessage: 'Set a shortcut' },
titleFor: {
id: 'ui.keybinding-modal.title-for',
defaultMessage: 'Shortcut for {action}',
},
current: { id: 'ui.keybinding-modal.current', defaultMessage: 'Current shortcut' },
unset: { id: 'ui.keybinding-modal.unset', defaultMessage: 'Not set' },
restoreDefault: {
id: 'ui.keybinding-modal.restore-default',
defaultMessage: 'Restore default',
},
captureTitle: {
id: 'ui.keybinding-modal.capture-title',
defaultMessage: 'Click here to record',
},
captureHint: {
id: 'ui.keybinding-modal.capture-hint',
defaultMessage: 'Then press the keys you want this shortcut to use.',
},
listeningTitle: {
id: 'ui.keybinding-modal.listening-title',
defaultMessage: 'Listening\u2026',
},
listeningHint: {
id: 'ui.keybinding-modal.listening-hint',
defaultMessage: 'Press the keys you want to use.',
},
verifyingTitle: {
id: 'ui.keybinding-modal.verifying-title',
defaultMessage: 'Press it again to confirm',
},
verifyingHint: {
id: 'ui.keybinding-modal.verifying-hint',
defaultMessage: 'This checks the combination was recorded correctly.',
},
verifiedTitle: {
id: 'ui.keybinding-modal.verified-title',
defaultMessage: 'Confirmed',
},
verifiedHint: {
id: 'ui.keybinding-modal.verified-hint',
defaultMessage: 'Confirmed. Save to use this shortcut.',
},
issueConflict: {
id: 'ui.keybinding-modal.issue-conflict',
defaultMessage: 'Already used by {label}.',
},
issueReserved: {
id: 'ui.keybinding-modal.issue-reserved',
defaultMessage: '{label} is reserved by the launcher.',
},
issueNeedsModifier: {
id: 'ui.keybinding-modal.issue-needs-modifier',
defaultMessage: 'Hold Ctrl/Cmd, Alt or Shift as well, or pick a function or navigation key.',
},
mismatch: {
id: 'ui.keybinding-modal.mismatch',
defaultMessage: 'That was a different combination, so recording started again.',
},
nothingCaptured: {
id: 'ui.keybinding-modal.nothing-captured',
defaultMessage: 'No key was recorded.',
},
useSpecial: {
id: 'ui.keybinding-modal.use-special',
defaultMessage: 'Pick an input another way',
},
useCapture: {
id: 'ui.keybinding-modal.use-capture',
defaultMessage: 'Record a combination instead',
},
specialHint: {
id: 'ui.keybinding-modal.special-hint',
defaultMessage: 'Mouse buttons, the wheel and combinations with them can only be picked here.',
},
modifiers: { id: 'ui.keybinding-modal.modifiers', defaultMessage: 'Modifiers' },
save: { id: 'ui.keybinding-modal.save', defaultMessage: 'Save' },
cancel: { id: 'ui.keybinding-modal.cancel', defaultMessage: 'Cancel' },
})
type Phase = 'idle' | 'listening' | 'verifying' | 'tree'
const modal = ref<InstanceType<typeof NewModal>>()
const phase = ref<Phase>('idle')
const candidate = ref<KeyBinding | null>(null)
const livePressed = ref<KeyBinding | null>(null)
const captured = ref<KeyBinding | null>(null)
const mismatch = ref<KeyBinding | null>(null)
const nothingCaptured = ref(false)
const verified = ref(false)
const captureRun = ref(0)
const treeInput = ref<{ device: KeyBinding['device']; code: string } | null>(null)
const modifierState = reactive({ mod: false, shift: false, alt: false })
let listenTimer: number | undefined
let settleTimer: number | undefined
const headerText = computed(() =>
props.actionLabel
? formatMessage(messages.titleFor, { action: props.actionLabel })
: formatMessage(messages.title),
)
const modifierOptions = computed(() => [
{ field: 'mod' as const, label: labels.value.mod },
{ field: 'alt' as const, label: labels.value.alt },
{ field: 'shift' as const, label: labels.value.shift },
])
const liveParts = computed(() => {
const pressed = livePressed.value
if (!pressed) return []
const parts: string[] = []
if (pressed.mod) parts.push(labels.value.mod)
if (pressed.alt) parts.push(labels.value.alt)
if (pressed.shift) parts.push(labels.value.shift)
if (!isModifierCode(pressed.code)) parts.push(keyCodeLabel(pressed.code))
return parts
})
const treeBinding = computed<KeyBinding | null>(() =>
treeInput.value ? { ...treeInput.value, ...modifierState } : null,
)
const proposed = computed(() => (phase.value === 'tree' ? treeBinding.value : candidate.value))
const canRestoreDefault = computed(
() =>
Boolean(props.defaultBinding) &&
(!props.binding || !bindingsEqual(props.binding, props.defaultBinding as KeyBinding)),
)
const issue = computed(() =>
proposed.value ? validateBinding(proposed.value, props.conflicts) : null,
)
const issueText = computed(() => {
const value = issue.value
if (!value) return null
if (value.kind === 'needs-modifier') return formatMessage(messages.issueNeedsModifier)
return formatMessage(value.reserved ? messages.issueReserved : messages.issueConflict, {
label: value.label,
})
})
const mismatchText = computed(() => (mismatch.value ? formatMessage(messages.mismatch) : null))
const nothingCapturedText = computed(() =>
nothingCaptured.value ? formatMessage(messages.nothingCaptured) : null,
)
const isVerified = computed(() => phase.value === 'tree' || verified.value)
const canSave = computed(() => Boolean(proposed.value) && !issue.value && isVerified.value)
/** Inputs the picker must refuse, keyed by code, with the reason to show. */
const treeDisabledCodes = computed(() => {
const disabled = new Map<string, string>()
for (const device of KEYBINDING_CATALOG) {
for (const group of device.groups) {
for (const item of group.items) {
const found = validateBinding(
{ device: item.device, code: item.code, ...modifierState },
props.conflicts,
)
if (found?.kind === 'conflict') {
disabled.set(
item.code,
formatMessage(found.reserved ? messages.issueReserved : messages.issueConflict, {
label: found.label,
}),
)
}
}
}
}
return disabled
})
function clearTimers() {
window.clearTimeout(listenTimer)
window.clearTimeout(settleTimer)
listenTimer = undefined
settleTimer = undefined
}
function beginListening() {
clearTimers()
candidate.value = null
livePressed.value = null
captured.value = null
nothingCaptured.value = false
verified.value = false
captureRun.value += 1
phase.value = 'listening'
listenTimer = window.setTimeout(finishListening, props.listenDurationMs)
}
function finishListening() {
clearTimers()
if (!captured.value) {
nothingCaptured.value = true
phase.value = 'idle'
return
}
candidate.value = captured.value
mismatch.value = null
verified.value = false
phase.value = 'verifying'
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
// Leaving the recording is a step back, not closing the dialog.
event.preventDefault()
// Immediate: the dialog's own escape handler sits on the same node.
event.stopImmediatePropagation()
clearTimers()
phase.value = 'idle'
return
}
if (phase.value === 'listening') {
event.preventDefault()
event.stopImmediatePropagation()
livePressed.value = {
device: 'keyboard',
code: event.code,
mod: event.ctrlKey || event.metaKey,
shift: event.shiftKey,
alt: event.altKey,
}
const combination = bindingFromKeyboardEvent(event)
if (combination) {
captured.value = combination
window.clearTimeout(settleTimer)
settleTimer = undefined
}
return
}
if (phase.value === 'verifying') {
event.preventDefault()
event.stopImmediatePropagation()
const attempt = bindingFromKeyboardEvent(event)
if (!attempt || !candidate.value) return
if (bindingsEqual(attempt, candidate.value)) {
verified.value = true
mismatch.value = null
return
}
mismatch.value = attempt
beginListening()
}
}
function handleKeyup(event: KeyboardEvent) {
if (phase.value !== 'listening') return
event.stopPropagation()
const stillHeld = event.ctrlKey || event.metaKey || event.shiftKey || event.altKey
if (stillHeld || !captured.value) return
// Nothing is held any more, so the combination is as complete as it gets.
window.clearTimeout(settleTimer)
settleTimer = window.setTimeout(finishListening, props.settleMs)
}
function attachListeners() {
window.addEventListener('keydown', handleKeydown, true)
window.addEventListener('keyup', handleKeyup, true)
}
function detachListeners() {
window.removeEventListener('keydown', handleKeydown, true)
window.removeEventListener('keyup', handleKeyup, true)
}
watch(phase, (value) => {
if (value === 'listening' || value === 'verifying') attachListeners()
else detachListeners()
})
onBeforeUnmount(() => {
clearTimers()
detachListeners()
})
function enterTree() {
const source = candidate.value ?? props.binding
modifierState.mod = source?.mod ?? false
modifierState.alt = source?.alt ?? false
modifierState.shift = source?.shift ?? false
treeInput.value = source ? { device: source.device, code: source.code } : null
phase.value = 'tree'
}
function leaveTree() {
treeInput.value = null
phase.value = 'idle'
}
function selectTreeInput(input: { device: KeyBinding['device']; code: string }) {
treeInput.value = input
}
function restoreDefault() {
if (!props.defaultBinding) return
candidate.value = props.defaultBinding
mismatch.value = null
nothingCaptured.value = false
verified.value = true
phase.value = 'verifying'
}
function reset() {
clearTimers()
phase.value = 'idle'
candidate.value = null
livePressed.value = null
captured.value = null
mismatch.value = null
nothingCaptured.value = false
verified.value = false
treeInput.value = null
modifierState.mod = false
modifierState.alt = false
modifierState.shift = false
}
function handleModalHide() {
reset()
}
function show() {
reset()
modal.value?.show()
}
function hide() {
modal.value?.hide()
}
function save() {
const value = proposed.value
if (!value || !canSave.value) return
emit('save', value)
modal.value?.hide()
}
function cancel() {
emit('cancel')
modal.value?.hide()
}
defineExpose({ show, hide })
</script>
<style scoped>
@keyframes keybinding-capture-drain {
from {
width: 100%;
}
to {
width: 0%;
}
}
.keybinding-capture-bar {
animation-name: keybinding-capture-drain;
animation-timing-function: linear;
animation-fill-mode: forwards;
}
@media (prefers-reduced-motion: reduce) {
.keybinding-capture-bar {
animation: none;
width: 100%;
}
}
</style>
@@ -8,3 +8,4 @@ export { default as ShareModal } from './ShareModal.vue'
export type { Tab as TabbedModalTab } from './TabbedModal.vue'
export { default as TabbedModal } from './TabbedModal.vue'
export { default as UploadProgressModal } from './UploadProgressModal.vue'
export { default as KeybindingModal } from './KeybindingModal.vue'
+1
View File
@@ -30,3 +30,4 @@ export * from './use-loading-state-core'
export * from './use-ready-state'
export * from './use-server-image'
export * from './virtual-scroll'
export * from './use-keybinding-labels'
@@ -0,0 +1,54 @@
import { computed } from 'vue'
import {
bindingParts,
bindingText,
type KeyBinding,
type KeybindingLabels,
} from '../utils/keybinding'
import { MOUSE_CODE_MESSAGES } from '../utils/keybinding-catalog'
import { defineMessages, useVIntl } from './i18n'
const messages = defineMessages({
mod: { id: 'ui.keybinding-modal.mod', defaultMessage: 'Ctrl/Cmd' },
shift: { id: 'ui.keybinding-modal.shift', defaultMessage: 'Shift' },
alt: { id: 'ui.keybinding-modal.alt', defaultMessage: 'Alt' },
})
/**
* The names a combination is built from, in the reader's language. Shared so a
* shortcut reads the same wherever it is shown.
*/
export function useKeybindingLabels() {
const { formatMessage } = useVIntl()
return computed<KeybindingLabels>(() => ({
mod: formatMessage(messages.mod),
shift: formatMessage(messages.shift),
alt: formatMessage(messages.alt),
mouseButton: Object.fromEntries(
Object.entries(MOUSE_CODE_MESSAGES).map(([code, message]) => [code, formatMessage(message)]),
),
wheelUp: formatMessage(MOUSE_CODE_MESSAGES.WheelUp),
wheelDown: formatMessage(MOUSE_CODE_MESSAGES.WheelDown),
}))
}
/** Splits a combination into the pieces a key cap is drawn for. */
export function useBindingParts(binding: () => KeyBinding | null) {
const labels = useKeybindingLabels()
return computed(() => {
const value = binding()
return value ? bindingParts(value, labels.value) : []
})
}
export function useBindingText(binding: () => KeyBinding | null) {
const labels = useKeybindingLabels()
return computed(() => {
const value = binding()
return value ? bindingText(value, labels.value) : ''
})
}
+45 -1
View File
@@ -4861,5 +4861,49 @@
},
"skin.preview.armor.unequipped": {
"defaultMessage": "Unequipped"
}
},
"ui.keybinding-modal.alt": {"defaultMessage": "Alt"},
"ui.keybinding-modal.cancel": {"defaultMessage": "Cancel"},
"ui.keybinding-modal.capture-hint": {"defaultMessage": "Then press the keys you want this shortcut to use."},
"ui.keybinding-modal.capture-title": {"defaultMessage": "Click here to record"},
"ui.keybinding-modal.current": {"defaultMessage": "Current shortcut"},
"ui.keybinding-modal.device-keyboard": {"defaultMessage": "Keyboard"},
"ui.keybinding-modal.device-mouse": {"defaultMessage": "Mouse"},
"ui.keybinding-modal.group-digits": {"defaultMessage": "Digits"},
"ui.keybinding-modal.group-function": {"defaultMessage": "Function keys"},
"ui.keybinding-modal.group-letters": {"defaultMessage": "Letters"},
"ui.keybinding-modal.group-mouse-buttons": {"defaultMessage": "Buttons"},
"ui.keybinding-modal.group-navigation": {"defaultMessage": "Navigation keys"},
"ui.keybinding-modal.group-punctuation": {"defaultMessage": "Punctuation"},
"ui.keybinding-modal.group-wheel": {"defaultMessage": "Wheel"},
"ui.keybinding-modal.issue-conflict": {"defaultMessage": "Already used by {label}."},
"ui.keybinding-modal.issue-needs-modifier": {"defaultMessage": "Hold Ctrl/Cmd, Alt or Shift as well, or pick a function or navigation key."},
"ui.keybinding-modal.issue-reserved": {"defaultMessage": "{label} is reserved by the launcher."},
"ui.keybinding-modal.listening-hint": {"defaultMessage": "Press the keys you want to use."},
"ui.keybinding-modal.listening-title": {"defaultMessage": "Listening…"},
"ui.keybinding-modal.mismatch": {"defaultMessage": "That was a different combination, so recording started again."},
"ui.keybinding-modal.mod": {"defaultMessage": "Ctrl/Cmd"},
"ui.keybinding-modal.modifiers": {"defaultMessage": "Modifiers"},
"ui.keybinding-modal.mouse-back": {"defaultMessage": "Back button"},
"ui.keybinding-modal.mouse-forward": {"defaultMessage": "Forward button"},
"ui.keybinding-modal.mouse-left": {"defaultMessage": "Left click"},
"ui.keybinding-modal.mouse-middle": {"defaultMessage": "Middle click"},
"ui.keybinding-modal.mouse-right": {"defaultMessage": "Right click"},
"ui.keybinding-modal.nothing-captured": {"defaultMessage": "No key was recorded."},
"ui.keybinding-modal.restore-default": {"defaultMessage": "Restore default"},
"ui.keybinding-modal.save": {"defaultMessage": "Save"},
"ui.keybinding-modal.shift": {"defaultMessage": "Shift"},
"ui.keybinding-modal.special-hint": {"defaultMessage": "Mouse buttons, the wheel and combinations with them can only be picked here."},
"ui.keybinding-modal.title": {"defaultMessage": "Set a shortcut"},
"ui.keybinding-modal.title-for": {"defaultMessage": "Shortcut for {action}"},
"ui.keybinding-modal.tree-label": {"defaultMessage": "Inputs a shortcut can use"},
"ui.keybinding-modal.unset": {"defaultMessage": "Not set"},
"ui.keybinding-modal.use-capture": {"defaultMessage": "Record a combination instead"},
"ui.keybinding-modal.use-special": {"defaultMessage": "Pick an input another way"},
"ui.keybinding-modal.verified-hint": {"defaultMessage": "Confirmed. Save to use this shortcut."},
"ui.keybinding-modal.verified-title": {"defaultMessage": "Confirmed"},
"ui.keybinding-modal.verifying-hint": {"defaultMessage": "This checks the combination was recorded correctly."},
"ui.keybinding-modal.verifying-title": {"defaultMessage": "Press it again to confirm"},
"ui.keybinding-modal.wheel-down": {"defaultMessage": "Wheel down"},
"ui.keybinding-modal.wheel-up": {"defaultMessage": "Wheel up"}
}
+45 -1
View File
@@ -4882,5 +4882,49 @@
},
"skin.preview.armor.unequipped": {
"message": "未装备"
}
},
"ui.keybinding-modal.alt": {"defaultMessage": "Alt"},
"ui.keybinding-modal.cancel": {"defaultMessage": "取消"},
"ui.keybinding-modal.capture-hint": {"defaultMessage": "然后按下你想让这个快捷键使用的按键。"},
"ui.keybinding-modal.capture-title": {"defaultMessage": "点击此处开始录制"},
"ui.keybinding-modal.current": {"defaultMessage": "当前快捷键"},
"ui.keybinding-modal.device-keyboard": {"defaultMessage": "键盘"},
"ui.keybinding-modal.device-mouse": {"defaultMessage": "鼠标"},
"ui.keybinding-modal.group-digits": {"defaultMessage": "数字键"},
"ui.keybinding-modal.group-function": {"defaultMessage": "功能键"},
"ui.keybinding-modal.group-letters": {"defaultMessage": "字母键"},
"ui.keybinding-modal.group-mouse-buttons": {"defaultMessage": "鼠标键"},
"ui.keybinding-modal.group-navigation": {"defaultMessage": "导航键"},
"ui.keybinding-modal.group-punctuation": {"defaultMessage": "符号键"},
"ui.keybinding-modal.group-wheel": {"defaultMessage": "滚轮"},
"ui.keybinding-modal.issue-conflict": {"defaultMessage": "已被「{label}」占用。"},
"ui.keybinding-modal.issue-needs-modifier": {"defaultMessage": "请同时按住 Ctrl/Cmd、Alt 或 Shift,或改选功能键、导航键。"},
"ui.keybinding-modal.issue-reserved": {"defaultMessage": "「{label}」由启动器保留。"},
"ui.keybinding-modal.listening-hint": {"defaultMessage": "请按下你想使用的按键。"},
"ui.keybinding-modal.listening-title": {"defaultMessage": "正在监听…"},
"ui.keybinding-modal.mismatch": {"defaultMessage": "组合不一致,已重新开始录制。"},
"ui.keybinding-modal.mod": {"defaultMessage": "Ctrl/Cmd"},
"ui.keybinding-modal.modifiers": {"defaultMessage": "修饰键"},
"ui.keybinding-modal.mouse-back": {"defaultMessage": "后退键"},
"ui.keybinding-modal.mouse-forward": {"defaultMessage": "前进键"},
"ui.keybinding-modal.mouse-left": {"defaultMessage": "鼠标左键"},
"ui.keybinding-modal.mouse-middle": {"defaultMessage": "鼠标中键"},
"ui.keybinding-modal.mouse-right": {"defaultMessage": "鼠标右键"},
"ui.keybinding-modal.nothing-captured": {"defaultMessage": "没有录制到按键。"},
"ui.keybinding-modal.restore-default": {"defaultMessage": "恢复默认"},
"ui.keybinding-modal.save": {"defaultMessage": "保存"},
"ui.keybinding-modal.shift": {"defaultMessage": "Shift"},
"ui.keybinding-modal.special-hint": {"defaultMessage": "鼠标键、滚轮以及与它们组合的按键只能在这里选择。"},
"ui.keybinding-modal.title": {"defaultMessage": "设置快捷键"},
"ui.keybinding-modal.title-for": {"defaultMessage": "设置「{action}」的快捷键"},
"ui.keybinding-modal.tree-label": {"defaultMessage": "快捷键可用的输入"},
"ui.keybinding-modal.unset": {"defaultMessage": "未设置"},
"ui.keybinding-modal.use-capture": {"defaultMessage": "改用按键录制"},
"ui.keybinding-modal.use-special": {"defaultMessage": "改用特殊方式选择"},
"ui.keybinding-modal.verified-hint": {"defaultMessage": "已确认,保存后生效。"},
"ui.keybinding-modal.verified-title": {"defaultMessage": "已确认"},
"ui.keybinding-modal.verifying-hint": {"defaultMessage": "用于确认组合是否被正确录制。"},
"ui.keybinding-modal.verifying-title": {"defaultMessage": "请再按一次以确认"},
"ui.keybinding-modal.wheel-down": {"defaultMessage": "滚轮向下"},
"ui.keybinding-modal.wheel-up": {"defaultMessage": "滚轮向上"}
}
+2
View File
@@ -16,3 +16,5 @@ export * from './truncate'
export * from './version-compatibility'
export * from './vue-children'
export * from './webgl/skin-rendering'
export * from './keybinding'
export * from './keybinding-catalog'
+149
View File
@@ -0,0 +1,149 @@
import { defineMessages } from '../composables/i18n'
import type { KeyBindingDevice } from './keybinding'
/** Structurally identical to the i18n `MessageDescriptor`, without the dependency. */
export interface CatalogMessage {
id: string
defaultMessage: string
}
export interface KeybindingCatalogItem {
device: KeyBindingDevice
code: string
/** Set for inputs whose name has to be translated; key caps read the same everywhere. */
label?: CatalogMessage
}
export interface KeybindingCatalogGroup {
id: string
label: CatalogMessage
items: KeybindingCatalogItem[]
}
export interface KeybindingCatalogDevice {
id: KeyBindingDevice
label: CatalogMessage
groups: KeybindingCatalogGroup[]
}
const messages = defineMessages({
mouseLeft: { id: 'ui.keybinding-modal.mouse-left', defaultMessage: 'Left click' },
mouseMiddle: { id: 'ui.keybinding-modal.mouse-middle', defaultMessage: 'Middle click' },
mouseRight: { id: 'ui.keybinding-modal.mouse-right', defaultMessage: 'Right click' },
mouseBack: { id: 'ui.keybinding-modal.mouse-back', defaultMessage: 'Back button' },
mouseForward: { id: 'ui.keybinding-modal.mouse-forward', defaultMessage: 'Forward button' },
wheelUp: { id: 'ui.keybinding-modal.wheel-up', defaultMessage: 'Wheel up' },
wheelDown: { id: 'ui.keybinding-modal.wheel-down', defaultMessage: 'Wheel down' },
deviceKeyboard: { id: 'ui.keybinding-modal.device-keyboard', defaultMessage: 'Keyboard' },
deviceMouse: { id: 'ui.keybinding-modal.device-mouse', defaultMessage: 'Mouse' },
groupLetters: { id: 'ui.keybinding-modal.group-letters', defaultMessage: 'Letters' },
groupDigits: { id: 'ui.keybinding-modal.group-digits', defaultMessage: 'Digits' },
groupFunction: { id: 'ui.keybinding-modal.group-function', defaultMessage: 'Function keys' },
groupNavigation: {
id: 'ui.keybinding-modal.group-navigation',
defaultMessage: 'Navigation keys',
},
groupPunctuation: {
id: 'ui.keybinding-modal.group-punctuation',
defaultMessage: 'Punctuation',
},
groupMouseButtons: {
id: 'ui.keybinding-modal.group-mouse-buttons',
defaultMessage: 'Buttons',
},
groupWheel: { id: 'ui.keybinding-modal.group-wheel', defaultMessage: 'Wheel' },
})
/**
* Names for the pointer inputs. Shared by the picker tree and by the chips that
* render a saved combination, so both read the same way.
*/
export const MOUSE_CODE_MESSAGES: Record<string, CatalogMessage> = {
Mouse0: messages.mouseLeft,
Mouse1: messages.mouseMiddle,
Mouse2: messages.mouseRight,
Mouse3: messages.mouseBack,
Mouse4: messages.mouseForward,
WheelUp: messages.wheelUp,
WheelDown: messages.wheelDown,
}
const LETTER_CODES: KeybindingCatalogItem[] = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
.split('')
.map((letter) => ({ device: 'keyboard', code: `Key${letter}` }))
const DIGIT_CODES: KeybindingCatalogItem[] = '0123456789'
.split('')
.map((digit) => ({ device: 'keyboard', code: `Digit${digit}` }))
const FUNCTION_CODES: KeybindingCatalogItem[] = Array.from({ length: 12 }, (_, index) => ({
device: 'keyboard',
code: `F${index + 1}`,
}))
const NAVIGATION_CODES: KeybindingCatalogItem[] = [
'Home',
'End',
'PageUp',
'PageDown',
'Insert',
'Delete',
'ArrowUp',
'ArrowDown',
'ArrowLeft',
'ArrowRight',
].map((code) => ({ device: 'keyboard', code }))
const PUNCTUATION_CODES: KeybindingCatalogItem[] = [
'Comma',
'Period',
'Slash',
'Semicolon',
'Quote',
'BracketLeft',
'BracketRight',
'Backslash',
'Minus',
'Equal',
'Backquote',
'Space',
].map((code) => ({ device: 'keyboard', code }))
const MOUSE_ITEMS: KeybindingCatalogItem[] = Object.entries(MOUSE_CODE_MESSAGES).map(
([code, label]) => ({ device: 'mouse', code, label }),
)
/**
* Everything a shortcut can be attached to, grouped for the picker. Inputs that
* cannot be reproduced by pressing them - mouse buttons, the wheel, and any
* combination of those with the keyboard - are only reachable from here.
*/
export const KEYBINDING_CATALOG: KeybindingCatalogDevice[] = [
{
id: 'keyboard',
label: messages.deviceKeyboard,
groups: [
{ id: 'letters', label: messages.groupLetters, items: LETTER_CODES },
{ id: 'digits', label: messages.groupDigits, items: DIGIT_CODES },
{ id: 'function', label: messages.groupFunction, items: FUNCTION_CODES },
{ id: 'navigation', label: messages.groupNavigation, items: NAVIGATION_CODES },
{ id: 'punctuation', label: messages.groupPunctuation, items: PUNCTUATION_CODES },
],
},
{
id: 'mouse',
label: messages.deviceMouse,
groups: [
{
id: 'buttons',
label: messages.groupMouseButtons,
items: MOUSE_ITEMS.filter((item) => item.code.startsWith('Mouse')),
},
{
id: 'wheel',
label: messages.groupWheel,
items: MOUSE_ITEMS.filter((item) => item.code.startsWith('Wheel')),
},
],
},
]
+229
View File
@@ -0,0 +1,229 @@
/**
* The device a binding was recorded from. Keyboard bindings are matched against
* `KeyboardEvent`, mouse bindings against `MouseEvent` and `WheelEvent`.
*/
export type KeyBindingDevice = 'keyboard' | 'mouse'
/**
* A single shortcut. `mod` is the primary modifier held down: the launcher has
* always treated Ctrl and Cmd as one and the same, so a binding recorded on one
* platform keeps working on the other.
*/
export interface KeyBinding {
device: KeyBindingDevice
/** A `KeyboardEvent.code`, or one of the mouse codes below. */
code: string
mod: boolean
shift: boolean
alt: boolean
}
/** `MouseEvent.button`, in the order browsers report it. */
export const MOUSE_BUTTON_CODES = ['Mouse0', 'Mouse1', 'Mouse2', 'Mouse3', 'Mouse4'] as const
export const WHEEL_CODES = ['WheelUp', 'WheelDown'] as const
const MODIFIER_CODES = new Set([
'ControlLeft',
'ControlRight',
'ShiftLeft',
'ShiftRight',
'AltLeft',
'AltRight',
'MetaLeft',
'MetaRight',
'CapsLock',
])
/**
* Codes that may be bound on their own. Anything else has to carry a modifier,
* so a shortcut cannot swallow ordinary typing or list navigation.
*/
const MODIFIER_FREE_CODES = new Set<string>([
'Home',
'End',
'PageUp',
'PageDown',
...Array.from({ length: 24 }, (_, index) => `F${index + 1}`),
...MOUSE_BUTTON_CODES.filter((code) => code !== 'Mouse0'),
...WHEEL_CODES,
])
/**
* Codes that always need a modifier, whatever else is allowed. A bare left
* click is how the whole interface is operated, so it can never be a shortcut.
*/
const MODIFIER_REQUIRED_CODES = new Set<string>(['Mouse0'])
/** A combination someone else already answers to. */
export interface BindingConflict {
id: string
label: string
binding: KeyBinding
/** The shell or the operating system owns it rather than another action. */
reserved?: boolean
}
export type BindingIssue =
{ kind: 'conflict'; label: string; reserved: boolean } | { kind: 'needs-modifier' }
export interface KeybindingLabels {
mod: string
shift: string
alt: string
/** Keyed by mouse code, e.g. `Mouse0`. */
mouseButton: Record<string, string>
wheelUp: string
wheelDown: string
}
export function isModifierCode(code: string): boolean {
return MODIFIER_CODES.has(code)
}
export function isMouseCode(code: string): boolean {
return code.startsWith('Mouse') || (WHEEL_CODES as readonly string[]).includes(code)
}
/**
* Canonical form used to compare bindings, so two ways of recording the same
* combination still collide.
*/
export function bindingKey(binding: KeyBinding): string {
const modifiers = [binding.mod && 'mod', binding.alt && 'alt', binding.shift && 'shift']
.filter(Boolean)
.join('+')
return `${binding.device}|${binding.code}|${modifiers}`
}
export function bindingsEqual(a: KeyBinding, b: KeyBinding): boolean {
return bindingKey(a) === bindingKey(b)
}
function eventModifiers(event: MouseEvent | KeyboardEvent | WheelEvent) {
return {
mod: event.ctrlKey || event.metaKey,
shift: event.shiftKey,
alt: event.altKey,
}
}
/**
* The combination an event represents, or null while only modifiers are held -
* a lone Ctrl is not a shortcut.
*/
export function bindingFromKeyboardEvent(event: KeyboardEvent): KeyBinding | null {
if (event.repeat || isModifierCode(event.code)) return null
return { device: 'keyboard', code: event.code, ...eventModifiers(event) }
}
export function bindingFromMouseEvent(event: MouseEvent): KeyBinding | null {
const code = `Mouse${event.button}`
if (!(MOUSE_BUTTON_CODES as readonly string[]).includes(code)) return null
return { device: 'mouse', code, ...eventModifiers(event) }
}
export function bindingFromWheelEvent(event: WheelEvent): KeyBinding | null {
if (event.deltaY === 0) return null
const code = event.deltaY < 0 ? 'WheelUp' : 'WheelDown'
return { device: 'mouse', code, ...eventModifiers(event) }
}
function modifiersMatch(binding: KeyBinding, event: MouseEvent | KeyboardEvent | WheelEvent) {
const { mod, shift, alt } = eventModifiers(event)
return binding.mod === mod && binding.shift === shift && binding.alt === alt
}
export function bindingMatchesKeyboardEvent(binding: KeyBinding, event: KeyboardEvent): boolean {
return (
binding.device === 'keyboard' && binding.code === event.code && modifiersMatch(binding, event)
)
}
export function bindingMatchesMouseEvent(binding: KeyBinding, event: MouseEvent): boolean {
return (
binding.device === 'mouse' &&
binding.code === `Mouse${event.button}` &&
modifiersMatch(binding, event)
)
}
export function bindingMatchesWheelEvent(binding: KeyBinding, event: WheelEvent): boolean {
if (binding.device !== 'mouse' || event.deltaY === 0) return false
const code = event.deltaY < 0 ? 'WheelUp' : 'WheelDown'
return binding.code === code && modifiersMatch(binding, event)
}
/**
* Whether a combination may be saved, ignoring who it belongs to. Returns the
* problem to show instead of the binding when it may not.
*/
export function validateBinding(
binding: KeyBinding,
conflicts: BindingConflict[],
): BindingIssue | null {
const unmodified = !binding.mod && !binding.shift && !binding.alt
if (unmodified && MODIFIER_REQUIRED_CODES.has(binding.code)) {
return { kind: 'needs-modifier' }
}
if (unmodified && !MODIFIER_FREE_CODES.has(binding.code)) {
return { kind: 'needs-modifier' }
}
const conflict = conflicts.find((entry) => bindingsEqual(entry.binding, binding))
if (conflict) {
return { kind: 'conflict', label: conflict.label, reserved: conflict.reserved ?? false }
}
return null
}
const CODE_LABELS: Record<string, string> = {
ArrowUp: '\u2191',
ArrowDown: '\u2193',
ArrowLeft: '\u2190',
ArrowRight: '\u2192',
PageUp: 'Page Up',
PageDown: 'Page Down',
Escape: 'Esc',
Space: 'Space',
Insert: 'Insert',
Delete: 'Delete',
Comma: ',',
Period: '.',
Slash: '/',
Semicolon: ';',
Quote: "'",
BracketLeft: '[',
BracketRight: ']',
Backslash: '\\',
Minus: '-',
Equal: '=',
Backquote: '`',
}
/** How a single code reads on a key cap. */
export function keyCodeLabel(code: string): string {
if (code.startsWith('Key')) return code.slice(3)
if (code.startsWith('Digit')) return code.slice(5)
if (code.startsWith('Numpad')) return `Num ${code.slice(6)}`
return CODE_LABELS[code] ?? code
}
/** The combination split into the pieces a key cap is drawn for. */
export function bindingParts(binding: KeyBinding, labels: KeybindingLabels): string[] {
const parts: string[] = []
if (binding.mod) parts.push(labels.mod)
if (binding.alt) parts.push(labels.alt)
if (binding.shift) parts.push(labels.shift)
if (binding.code === 'WheelUp') parts.push(labels.wheelUp)
else if (binding.code === 'WheelDown') parts.push(labels.wheelDown)
else if (binding.device === 'mouse') parts.push(labels.mouseButton[binding.code] ?? binding.code)
else parts.push(keyCodeLabel(binding.code))
return parts
}
export function bindingText(binding: KeyBinding, labels: KeybindingLabels): string {
return bindingParts(binding, labels).join(' + ')
}