Merge remote-tracking branch 'origin/main' into feat/backend
Sync source to CNB / Sync Git ref (push) Skipped

This commit is contained in:
AxTps committed 2026-10-11 15:40:27 +08:00
commit fce6c60759
26 files changed
+2701 -246

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]
}
+36 -1
View File
@@ -11652,5 +11652,40 @@
"app.log-share.ai.tool-result": {"message": "Tool result received"},
"app.log-share.ai.limit": {"message": "Reached tool loop limit"},
"app.log-share.ai.working": {"message": "Analyzing with LogAgent…"},
"app.log-share.ai.failed": {"message": "LogAgent analysis failed: {message}"}
"app.log-share.ai.failed": {"message": "LogAgent analysis failed: {message}"},
"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"}
}
+36 -1
View File
@@ -11483,5 +11483,40 @@
"app.log-share.ai.tool-result": {"message": "已收到工具结果"},
"app.log-share.ai.limit": {"message": "已达到工具调用上限"},
"app.log-share.ai.working": {"message": "正在使用 LogAgent 分析…"},
"app.log-share.ai.failed": {"message": "LogAgent 分析失败:{message}"}
"app.log-share.ai.failed": {"message": "LogAgent 分析失败:{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,