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