Files
Starlight_Lancher/apps/app-frontend/src/store/theme.ts
T
AxTps 2e92887eb2
Sync source to CNB / Sync Git ref (push) Skipped
feat: 新增键盘快捷键设置
- 设置页新增「键盘快捷键」分类,可自定义快捷键绑定
- 支持快速滚动(Home/End/PageUp/PageDown)与导航跳转(Ctrl/Cmd+数字)
- 快捷键绑定与开关按动作独立持久化,默认关闭
- 新增 ui 包 keybinding 组件与工具(KeybindingModal/KeybindingChips 等)
2026-10-11 11:14:46 +08:00

349 lines
11 KiB
TypeScript

import { defineStore } from 'pinia'
import {
DEFAULT_MONO_FONT_STACK,
DEFAULT_UI_FONT_STACK,
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 = {
project_background: false,
page_path: false,
worlds_tab: false,
worlds_in_home: true,
server_project_qa: false,
show_version_environment_column: false,
server_ram_as_bytes_always_on: false,
always_show_app_controls: false,
skip_non_essential_warnings: false,
skip_unknown_pack_warning: false,
i18n_debug: false,
multiplayer_in_app: false,
show_instance_play_time: true,
page_transitions: true,
advanced_filters_collapsed: true,
auto_install_dependencies: true,
force_anniversary_celebration: false,
}
export const THEME_OPTIONS = ['dark', 'light', 'oled', 'system'] as const
export const ACCENT_COLOR_OPTIONS = ['pink', 'orange', 'green', 'blue', 'purple'] as const
export const DEFAULT_CUSTOM_ACCENT_COLOR = '#db2777'
export type FeatureFlag = keyof typeof DEFAULT_FEATURE_FLAGS
export type FeatureFlags = Record<FeatureFlag, boolean>
export type ColorTheme = (typeof THEME_OPTIONS)[number]
export type AccentColor = (typeof ACCENT_COLOR_OPTIONS)[number]
export type CustomAccentColor = `custom:#${string}`
export type AccentColorSetting = AccentColor | 'system' | CustomAccentColor
export type HomeLayout = 'standard' | 'minimal'
export type CloseBehavior = 'ask' | 'close' | 'lightweight'
/**
* Extracts the `#rrggbb` hex from a `custom:#rrggbb` accent setting,
* or returns null if the value is not a valid custom accent.
*/
export function parseCustomAccentColor(value: string): string | null {
const match = /^custom:(#[0-9a-fA-F]{6})$/.exec(value)
return match ? match[1].toLowerCase() : null
}
export function hexToHsl(hex: string): { h: number; s: number; l: number } {
const n = parseInt(hex.slice(1), 16)
const r = ((n >> 16) & 255) / 255
const g = ((n >> 8) & 255) / 255
const b = (n & 255) / 255
const max = Math.max(r, g, b)
const min = Math.min(r, g, b)
const l = (max + min) / 2
const d = max - min
const s = d === 0 ? 0 : d / (1 - Math.abs(2 * l - 1))
let h = 0
if (d !== 0) {
if (max === r) h = ((g - b) / d) % 6
else if (max === g) h = (b - r) / d + 2
else h = (r - g) / d + 4
h *= 60
if (h < 0) h += 360
}
return { h, s: s * 100, l: l * 100 }
}
export function hslToHex(h: number, s: number, l: number): string {
const sn = s / 100
const ln = l / 100
const c = (1 - Math.abs(2 * ln - 1)) * sn
const hp = (((h % 360) + 360) % 360) / 60
const x = c * (1 - Math.abs((hp % 2) - 1))
let [r, g, b] = [0, 0, 0]
if (hp < 1) [r, g, b] = [c, x, 0]
else if (hp < 2) [r, g, b] = [x, c, 0]
else if (hp < 3) [r, g, b] = [0, c, x]
else if (hp < 4) [r, g, b] = [0, x, c]
else if (hp < 5) [r, g, b] = [x, 0, c]
else [r, g, b] = [c, 0, x]
const m = ln - c / 2
const toHex = (channel: number) =>
Math.round((channel + m) * 255)
.toString(16)
.padStart(2, '0')
return `#${toHex(r)}${toHex(g)}${toHex(b)}`
}
export function applySystemAccentHue(systemHex: string): string {
const { h } = hexToHsl(systemHex)
const { s, l } = hexToHsl(DEFAULT_CUSTOM_ACCENT_COLOR)
return hslToHex(h, s, l)
}
const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max)
/**
* Derives per-theme accent variants from a single base color, mirroring the
* relationship between the preset accents' light and dark values: hue is kept,
* the light variant is clamped dark enough for light surfaces, and the dark
* variant is lifted ~18 lightness points with a saturation boost.
*/
export function deriveAccentVariants(hex: string): { light: string; dark: string } {
const { h, s, l } = hexToHsl(hex)
const lightL = clamp(l, 34, 57)
return {
light: hslToHex(h, s, lightL),
dark: hslToHex(h, Math.min(s * 1.15, 100), Math.min(lightL + 18, 80)),
}
}
export type ThemeStore = {
selectedTheme: ColorTheme
selectedAccentColor: AccentColorSetting
systemAccentColor: string | null
systemAccentSupported: boolean | null
advancedRendering: boolean
hideNametagSkinsPage: boolean
toggleSidebar: boolean
customBackgroundPath: string | null
customBackgroundBlur: number
customBackgroundOpacity: number
transparentBackground: boolean
transparentBackgroundOpacity: number
transparentBackgroundBlur: boolean
sidebarInstanceCount: number
autoHideDownloadsButton: boolean
homeLayout: HomeLayout
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
uiFont: string | null
monoFont: string | null
}
export const DEFAULT_THEME_STORE: ThemeStore = {
selectedTheme: 'dark',
selectedAccentColor: 'pink',
systemAccentColor: null,
systemAccentSupported: null,
advancedRendering: true,
hideNametagSkinsPage: false,
toggleSidebar: false,
customBackgroundPath: null,
customBackgroundBlur: 12,
customBackgroundOpacity: 65,
transparentBackground: false,
transparentBackgroundOpacity: 55,
transparentBackgroundBlur: false,
sidebarInstanceCount: 0,
autoHideDownloadsButton: false,
homeLayout: 'standard',
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,
uiFont: null,
monoFont: null,
}
export const useTheming = defineStore('themeStore', {
state: () => DEFAULT_THEME_STORE,
actions: {
setThemeState(newTheme: ColorTheme) {
if (THEME_OPTIONS.includes(newTheme)) {
this.selectedTheme = newTheme
} else {
console.warn('Selected theme is not present. Check themeOptions.')
}
this.setThemeClass()
},
setAccentColor(newAccentColor: AccentColorSetting) {
if (
newAccentColor === 'system' ||
parseCustomAccentColor(newAccentColor) !== null ||
ACCENT_COLOR_OPTIONS.includes(newAccentColor as AccentColor)
) {
this.selectedAccentColor = newAccentColor
} else {
console.warn('Selected accent color is not available.')
}
const html = document.documentElement
for (const accentColor of ACCENT_COLOR_OPTIONS) {
html.classList.remove(`accent-${accentColor}`)
}
html.classList.remove('accent-custom')
const customHex =
parseCustomAccentColor(this.selectedAccentColor) ??
(this.selectedAccentColor === 'system' ? this.systemAccentColor : null)
if (customHex) {
const variants = deriveAccentVariants(customHex)
html.style.setProperty('--custom-accent-light', variants.light)
html.style.setProperty('--custom-accent-dark', variants.dark)
html.classList.add('accent-custom')
} else if (this.selectedAccentColor === 'system') {
html.style.removeProperty('--custom-accent-light')
html.style.removeProperty('--custom-accent-dark')
html.classList.add('accent-pink')
} else {
html.style.removeProperty('--custom-accent-light')
html.style.removeProperty('--custom-accent-dark')
html.classList.add(`accent-${this.selectedAccentColor}`)
}
},
setSystemAccentColor(systemHex: string) {
if (!/^#[0-9a-fA-F]{6}$/.test(systemHex)) {
this.setSystemAccentUnavailable()
return
}
this.systemAccentColor = applySystemAccentHue(systemHex)
this.systemAccentSupported = true
if (this.selectedAccentColor === 'system') this.setAccentColor('system')
},
setSystemAccentUnavailable() {
this.systemAccentColor = null
this.systemAccentSupported = false
if (this.selectedAccentColor === 'system') this.setAccentColor('system')
},
setThemeClass() {
const html = document.getElementsByTagName('html')[0]
for (const theme of THEME_OPTIONS) {
html.classList.remove(`${theme}-mode`)
}
systemThemeMq?.removeEventListener('change', this.setThemeClass)
systemThemeMq = null
let theme = this.selectedTheme
if (this.selectedTheme === 'system') {
systemThemeMq = window.matchMedia('(prefers-color-scheme: dark)')
systemThemeMq.addEventListener('change', this.setThemeClass)
theme = systemThemeMq.matches ? 'dark' : 'light'
}
html.classList.add(`${theme}-mode`)
},
/**
* Applies the transparent window mode to the root element. The palette
* lives on `body` in `global.scss`; this only supplies the toggle and the
* alpha every translucent surface is derived from.
*/
setTransparentBackgroundClass() {
const html = document.documentElement
html.classList.toggle('transparent-window', this.transparentBackground)
html.style.setProperty(
'--transparent-window-alpha',
`${Math.min(Math.max(this.transparentBackgroundOpacity, 0), 100)}%`,
)
},
/**
* The font tokens are declared on `body` in the shared defaults, so the
* launcher picks up the chosen family by overriding the CSS variable there.
*/
setUiFont() {
const body = document.body
if (this.uiFont) {
body.style.setProperty(
'--font-standard',
resolveFontFamily(this.uiFont, DEFAULT_UI_FONT_STACK),
)
} else {
body.style.removeProperty('--font-standard')
}
},
setMonoFont() {
const body = document.body
if (this.monoFont) {
body.style.setProperty(
'--mono-font',
resolveFontFamily(this.monoFont, DEFAULT_MONO_FONT_STACK),
)
} else {
body.style.removeProperty('--mono-font')
}
},
getFeatureFlag(key: FeatureFlag) {
return this.featureFlags[key] ?? DEFAULT_FEATURE_FLAGS[key]
},
getThemeOptions() {
return THEME_OPTIONS
},
},
})