Sync source to CNB / Sync Git ref (push) Skipped
- 设置页新增「键盘快捷键」分类,可自定义快捷键绑定 - 支持快速滚动(Home/End/PageUp/PageDown)与导航跳转(Ctrl/Cmd+数字) - 快捷键绑定与开关按动作独立持久化,默认关闭 - 新增 ui 包 keybinding 组件与工具(KeybindingModal/KeybindingChips 等)
310 lines
9.6 KiB
TypeScript
310 lines
9.6 KiB
TypeScript
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)
|
|
}
|