feat: 跨实例同步移植第9轮(游戏设置编辑器)
Sync source to CNB / Sync Git ref (push) Has been cancelled

搬入 game-settings-modal(游戏选项精细编辑,10 文件):设置行编辑器、按键绑定、语言选择与文案表。新增 app-settings-modal provider 与 instance-settings-context 转发,并把 instance-settings 的 closeModal 改为支持关闭回调。SyncSettings 恢复游戏设置入口。补齐 game-options 相关 en-US / zh-CN 文案。
This commit is contained in:
AxTps committed 2026-10-04 09:18:33 +08:00
1 parent 83ee962c16
commit 4f220201b3
17 files changed
+5392 -5

No files matched your search

@@ -35,6 +35,7 @@ import CommandHistoryModal from '../settings/instances/command-history-modal.vue
import SyncedServersModal from '../settings/instances/servers-modal.vue'
import SyncedPacksModal from '../settings/instances/SyncedPacksModal.vue'
import SyncSourceModal from '../settings/instances/SyncSourceModal.vue'
import GameSettingsModal from '../settings/instances/game-settings-modal/index.vue'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
@@ -42,6 +43,7 @@ const queryClient = useQueryClient()
const syncedPacksModal = ref<InstanceType<typeof SyncedPacksModal>>()
const commandHistoryModal = ref<InstanceType<typeof CommandHistoryModal>>()
const syncedServersModal = ref<InstanceType<typeof SyncedServersModal>>()
const gameSettingsModal = ref<InstanceType<typeof GameSettingsModal> | null>(null)
const messages = defineMessages({
globalOptionsTitle: {
@@ -513,8 +515,7 @@ function editGlobalOption(row: (typeof globalRows)[number]) {
if (row.editable === 'resourcepack' || row.editable === 'datapack') {
void syncedPacksModal.value?.show(row.editable)
} else if (row.editable === 'game-settings') {
// Game settings modal is ported in a later pass.
return
openGameSettings()
} else if (row.editable === 'commands') {
void commandHistoryModal.value?.show()
} else {
@@ -522,6 +523,15 @@ function editGlobalOption(row: (typeof globalRows)[number]) {
}
}
function openGameSettings() {
if (!hasGameOptionsToEdit.value) return
gameSettingsModal.value?.show()
}
async function handleGameSettingsSaved() {
await invalidateSyncedOptions()
}
function clearBaseSource() {
@@ -537,6 +547,7 @@ onScopeDispose(clearBaseSource)
<SyncedPacksModal ref="syncedPacksModal" />
<CommandHistoryModal ref="commandHistoryModal" />
<SyncedServersModal ref="syncedServersModal" />
<GameSettingsModal ref="gameSettingsModal" @saved="handleGameSettingsSaved" />
<SyncSourceModal
ref="baseModal"
@@ -55,8 +55,9 @@ let serverMetadataGeneration = 0
const instanceRef = computed(() => props.instance)
const tabbedModal = ref<InstanceType<typeof TabbedModal> | null>(null)
function hide() {
function hide(onAfterClose?: () => void) {
tabbedModal.value?.hide()
if (onAfterClose) nextTick(onAfterClose)
}
provideInstanceSettings({
@@ -0,0 +1,62 @@
<script setup lang="ts">
import { Button } from '@modrinth/ui'
defineProps<{
modelValue?: boolean
disabled?: boolean
label: string
onLabel: string
offLabel: string
placeholder?: string
}>()
const emit = defineEmits<{
'update:model-value': [value: boolean]
}>()
</script>
<template>
<div
v-tooltip="modelValue === undefined ? placeholder : undefined"
role="group"
:aria-label="label"
class="inline-flex h-9 items-center gap-0.5 rounded-xl border border-solid border-surface-5 p-px shadow-[0_1px_3px_rgba(0,0,0,0.15)]"
:class="{ 'opacity-50': disabled }"
>
<Button
type="quiet"
size="sm"
:interaction="modelValue === true ? 'none' : 'surface'"
class="boolean-control-on !rounded-[10px] !px-3 !font-medium disabled:!opacity-100"
:class="modelValue === true ? '!text-green' : '!text-[var(--color-text-primary)]'"
:aria-pressed="modelValue === true"
:disabled="disabled"
@click="emit('update:model-value', true)"
>
{{ onLabel }}
</Button>
<Button
type="quiet"
size="sm"
:interaction="modelValue === false ? 'none' : 'surface'"
class="boolean-control-off !rounded-[10px] !px-3 !font-medium !text-[var(--color-text-primary)] disabled:!opacity-100"
:aria-pressed="modelValue === false"
:disabled="disabled"
@click="emit('update:model-value', false)"
>
{{ offLabel }}
</Button>
</div>
</template>
<style scoped>
.boolean-control-on[aria-pressed='true'] {
background-color: color-mix(in srgb, var(--color-green) 30%, var(--surface-3));
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-green) 60%, transparent);
}
.boolean-control-off[aria-pressed='true'] {
background-color: var(--surface-4);
box-shadow: inset 0 0 0 1px var(--surface-5);
}
</style>
@@ -0,0 +1,163 @@
import { toRaw } from 'vue'
import type {
EditableGameSetting,
GameOptionCanonicalValue,
GameSettingChange,
GameSettingsEditorState,
} from '@/helpers/game-options'
function clonePlainValue(value: unknown): unknown {
if (Array.isArray(value)) {
return toRaw(value).map(clonePlainValue)
}
if (value !== null && typeof value === 'object') {
return Object.fromEntries(
Object.entries(toRaw(value)).map(([key, nestedValue]) => [key, clonePlainValue(nestedValue)]),
)
}
return value
}
export function cloneGameSettingsState(state: GameSettingsEditorState): GameSettingsEditorState {
return clonePlainValue(state) as GameSettingsEditorState
}
export function canonicalValuesEqual(
left: GameOptionCanonicalValue | null | undefined,
right: GameOptionCanonicalValue | null | undefined,
): boolean {
return JSON.stringify(left ?? null) === JSON.stringify(right ?? null)
}
export function gameSettingChanges(
base: GameSettingsEditorState | null,
draft: GameSettingsEditorState | null,
forcedValueOptionIds: ReadonlySet<string> = new Set(),
): GameSettingChange[] {
if (!base || !draft) return []
const baseSettings = new Map(base.settings.map((setting) => [setting.option_id, setting]))
return draft.settings.flatMap((setting) => {
const previous = baseSettings.get(setting.option_id)
if (!previous) return []
const syncChanged = previous.sync_enabled !== setting.sync_enabled
const valueChanged = !canonicalValuesEqual(previous.canonical_value, setting.canonical_value)
const promoteLocalValue =
forcedValueOptionIds.has(setting.option_id) &&
previous.value_state !== 'canonical' &&
setting.canonical_value !== null &&
setting.canonical_value !== undefined
if (!syncChanged && !valueChanged && !promoteLocalValue) return []
return [
{
option_id: setting.option_id,
base_option_revision: previous.option_revision,
...(syncChanged ? { sync_enabled: setting.sync_enabled } : {}),
...(valueChanged || promoteLocalValue
? { canonical_value: setting.canonical_value ?? null }
: {}),
},
]
})
}
export function gameSettingNumberScale(setting: EditableGameSetting): number {
if (setting.editor.unit !== 'percent') return 1
return setting.option_id === 'sensitivity' ? 200 : 100
}
export function canonicalValueText(setting: EditableGameSetting): string {
const value = setting.canonical_value
if (!value) return ''
switch (value.type) {
case 'bool':
return value.value ? 'true' : 'false'
case 'integer':
case 'decimal':
return setting.editor.unit === 'percent'
? String(Number((Number(value.value) * gameSettingNumberScale(setting)).toFixed(8)))
: String(value.value)
case 'string_list':
return value.value.join(', ')
default:
return value.value
}
}
export function canonicalBooleanValue(setting: EditableGameSetting): boolean | undefined {
return setting.canonical_value?.type === 'bool' ? setting.canonical_value.value : undefined
}
export function isKeybindSetting(setting: EditableGameSetting): boolean {
return (
setting.editor.type === 'key_binding' ||
(setting.editor.type === 'external_raw' && !!setting.raw_key?.startsWith('key_'))
)
}
export function canonicalValueFromInput(
setting: EditableGameSetting,
value: string | number | boolean | undefined,
): GameOptionCanonicalValue | null {
if (value === undefined || value === '') return null
switch (setting.editor.type) {
case 'bool':
return { type: 'bool', value: Boolean(value) }
case 'integer': {
const parsed = Number(value)
return Number.isSafeInteger(parsed) ? { type: 'integer', value: parsed } : null
}
case 'decimal': {
const parsed = Number(value)
if (!Number.isFinite(parsed)) return null
return {
type: 'decimal',
value:
setting.editor.unit === 'percent'
? String(Number((parsed / gameSettingNumberScale(setting)).toFixed(8)))
: String(value),
}
}
case 'enum':
return { type: 'enum', value: String(value) }
case 'string_list':
return {
type: 'string_list',
value: String(value)
.split(',')
.map((item) => item.trim())
.filter(Boolean),
}
case 'key_binding':
return { type: 'key_binding', value: String(value) }
case 'external_raw':
return { type: 'external_raw', value: String(value) }
default:
return { type: 'text', value: String(value) }
}
}
export function settingSearchText(
setting: EditableGameSetting,
label: string,
description: string,
): string {
return [label, description, setting.option_id, setting.raw_key]
.filter(Boolean)
.join(' ')
.toLocaleLowerCase()
}
export function settingCanBeEnabled(setting: EditableGameSetting): boolean {
return (
!setting.controlled &&
!setting.validation_error &&
!['mixed', 'unset', 'invalid'].includes(setting.value_state) &&
(setting.compatibility.total_participating === 0 || setting.compatibility.will_receive > 0)
)
}
@@ -0,0 +1,483 @@
<script setup lang="ts">
import {
EyeIcon,
LanguagesIcon,
LinkIcon,
MessageIcon,
MonitorIcon,
RefreshCwIcon,
SaveIcon,
SearchIcon,
SettingsIcon,
ShirtIcon,
TagCategoryAudioIcon,
TagCategoryGamepad2Icon,
UnlinkIcon,
WrenchIcon,
XIcon,
} from '@modrinth/assets'
import {
Button,
commonMessages,
defineMessages,
FloatingActionBar,
StyledInput,
TabbedModal,
type TabbedModalTab,
useVIntl,
} from '@modrinth/ui'
import type { Component } from 'vue'
import { computed, inject, ref, watch } from 'vue'
import { type RouteLocationRaw, useRouter } from 'vue-router'
import type { EditableGameSetting, GameSettingCategory } from '@/helpers/game-options'
import { injectInstanceSettings } from '@/pages/instance/components/settings-modal/instance-settings-context'
import { appSettingsModalContextKey } from '@/providers/app-settings-modal'
import {
canonicalValueText,
isKeybindSetting,
settingCanBeEnabled,
settingSearchText,
} from './editors'
import { minecraftKeybindConflictKey } from './keybinds'
import {
formatGameSettingDescription,
formatGameSettingLabel,
gameSettingCategoryMessage,
} from './messages'
import GameSettingRow from './row.vue'
import { useGameSettingsEditor } from './use-editor'
import { useGameSettingLabels } from './use-labels'
const props = defineProps<{
instanceId?: string
}>()
const emit = defineEmits<{
saved: []
}>()
const { formatMessage } = useVIntl()
const router = useRouter()
const appSettingsModal = inject(appSettingsModalContextKey, null)
const instanceSettings = injectInstanceSettings(null)
const messages = defineMessages({
title: {
id: 'app.settings.synced-options.game-settings.title',
defaultMessage: 'Game settings',
},
syncTitle: {
id: 'app.settings.synced-options.game-settings.sync-title',
defaultMessage: 'Sync game settings',
},
syncedOptionsDescription: {
id: 'app.settings.synced-options.game-settings.sync-description',
defaultMessage:
'Synced options are applied to every instance and override the values included with a modpack.',
},
search: {
id: 'app.settings.synced-options.game-settings.search',
defaultMessage: 'Search settings...',
},
enableAll: {
id: 'app.settings.synced-options.game-settings.enable-all',
defaultMessage: 'Sync all',
},
disableAll: {
id: 'app.settings.synced-options.game-settings.disable-all',
defaultMessage: 'Unsync all',
},
customEmpty: {
id: 'app.settings.synced-options.game-settings.custom-empty',
defaultMessage:
'Settings added by mods will appear here after Modrinth finds them in one of your instances.',
},
empty: {
id: 'app.settings.synced-options.game-settings.empty',
defaultMessage: 'No settings match your search or filter.',
},
loading: {
id: 'app.settings.synced-options.game-settings.loading',
defaultMessage: 'Loading settings...',
},
loadFailed: {
id: 'app.settings.synced-options.game-settings.load-failed',
defaultMessage: 'We couldn’t load your game settings.',
},
retry: {
id: 'app.settings.synced-options.game-settings.retry',
defaultMessage: 'Retry',
},
saveSettings: {
id: 'app.settings.synced-options.game-settings.save',
defaultMessage: 'Save settings',
},
unsavedChanges: {
id: 'app.settings.synced-options.game-settings.unsaved-changes',
defaultMessage: 'You have unsaved changes.',
},
})
const modal = ref<InstanceType<typeof TabbedModal> | null>(null)
const activeCategoryId = ref('')
const search = ref('')
const opened = ref(false)
let allowClose = false
const {
draftState,
isLocalEditor,
isDirty,
hasBlockingDraft,
loading,
loadError,
saving,
load,
reset: resetEditor,
cancelChanges,
setSyncEnabled,
setCanonicalValue,
save,
saveAndWait,
} = useGameSettingsEditor(
() => props.instanceId,
() => emit('saved'),
)
const categoryIcons: Record<string, Component> = {
skin_customization: ShirtIcon,
video: MonitorIcon,
video_settings: MonitorIcon,
language: LanguagesIcon,
music_and_sound: TagCategoryAudioIcon,
controls: TagCategoryGamepad2Icon,
chat: MessageIcon,
chat_settings: MessageIcon,
accessibility: EyeIcon,
custom: WrenchIcon,
custom_settings: WrenchIcon,
}
const localeLabels = useGameSettingLabels(
opened,
() => props.instanceId,
() => draftState.value?.settings ?? [],
)
function settingLabel(setting: EditableGameSetting) {
if (setting.kind === 'external') {
return (
localeLabels.value[setting.option_id]?.label ?? formatGameSettingLabel(formatMessage, setting)
)
}
return formatGameSettingLabel(formatMessage, setting)
}
const categories = computed<GameSettingCategory[]>(() => {
if (!draftState.value) return []
const settings = draftState.value.settings
const visible = draftState.value.categories.filter(
(category) =>
category.is_custom || settings.some((setting) => setting.category_id === category.id),
)
if (!visible.some((category) => category.is_custom || category.id === 'custom_settings')) {
visible.push({
id: 'custom_settings',
is_custom: true,
})
}
return visible
})
const categoryTabs = computed<TabbedModalTab[]>(() =>
categories.value.map((category) => ({
name: gameSettingCategoryMessage(category),
icon: categoryIcon(category),
})),
)
const activeCategory = computed(() =>
categories.value.find((category) => category.id === activeCategoryId.value),
)
const categorySettings = computed(() => {
if (!draftState.value) return []
const query = search.value.trim().toLocaleLowerCase()
return draftState.value.settings.filter((setting) => {
if (!query && setting.category_id !== activeCategoryId.value) return false
if (
query &&
!settingSearchText(
setting,
settingLabel(setting),
setting.kind === 'external'
? (localeLabels.value[setting.option_id]?.source?.project?.title ??
localeLabels.value[setting.option_id]?.source?.file_name ??
'')
: formatGameSettingDescription(formatMessage, setting),
).includes(query)
)
return false
return true
})
})
const keybindConflicts = computed(() => {
if (!draftState.value) return new Map<string, string[]>()
const bindings = new Map<string, EditableGameSetting[]>()
for (const setting of draftState.value.settings) {
if (!isKeybindSetting(setting)) continue
const key = minecraftKeybindConflictKey(setting.option_id, canonicalValueText(setting))
if (!key) continue
bindings.set(key, [...(bindings.get(key) ?? []), setting])
}
const conflicts = new Map<string, string[]>()
for (const settings of bindings.values()) {
if (settings.length < 2) continue
for (const setting of settings) {
conflicts.set(
setting.option_id,
settings
.filter((candidate) => candidate.option_id !== setting.option_id)
.map((candidate) => settingLabel(candidate)),
)
}
}
return conflicts
})
const enableCandidates = computed(() =>
categorySettings.value.filter((setting) => !setting.sync_enabled && settingCanBeEnabled(setting)),
)
const disableCandidates = computed(() =>
categorySettings.value.filter((setting) => setting.sync_enabled && !setting.controlled),
)
const toggleableCategorySettings = computed(() =>
categorySettings.value.filter((setting) => !setting.controlled),
)
const allCategorySettingsSynced = computed(
() =>
toggleableCategorySettings.value.length > 0 &&
toggleableCategorySettings.value.every((setting) => setting.sync_enabled),
)
const modalTitle = computed(() =>
formatMessage(isLocalEditor.value ? messages.title : messages.syncTitle),
)
function categoryIcon(category: GameSettingCategory): Component {
return categoryIcons[category.id] ?? SettingsIcon
}
function changeCategory(_fromIndex: number, toIndex: number): boolean {
const category = categories.value[toIndex]
if (!category) return false
activeCategoryId.value = category.id
search.value = ''
return true
}
watch(
categories,
() => {
if (!categories.value.some((category) => category.id === activeCategoryId.value)) {
activeCategoryId.value = categories.value[0]?.id ?? 'custom_settings'
}
const index = categories.value.findIndex((category) => category.id === activeCategoryId.value)
if (index >= 0) modal.value?.setTab(index)
},
{ flush: 'sync' },
)
function show() {
opened.value = true
allowClose = false
search.value = ''
void load()
modal.value?.show()
}
function hide() {
modal.value?.hide()
}
function reset() {
opened.value = false
resetEditor()
allowClose = false
}
function beforeHide(): boolean {
if (allowClose || !isDirty.value) return true
void saveBeforeClose()
return false
}
async function saveBeforeClose() {
const saved = await saveAndWait()
if (!saved) return
allowClose = true
modal.value?.hide()
}
async function openSource(location: RouteLocationRaw) {
if (isDirty.value || saving.value) return
if (appSettingsModal && !appSettingsModal.close()) return
allowClose = true
modal.value?.hide()
if (instanceSettings?.closeModal) {
instanceSettings.closeModal(() => void router.push(location))
} else {
await router.push(location)
}
}
function toggleVisibleSync() {
const enabled = !allCategorySettingsSynced.value
const candidates = enabled ? enableCandidates.value : disableCandidates.value
setSyncEnabled(
candidates.map((setting) => setting.option_id),
enabled,
)
}
defineExpose({ show, hide })
</script>
<template>
<TabbedModal
ref="modal"
:tabs="categoryTabs"
:header="modalTitle"
:before-hide="beforeHide"
:before-tab-change="changeCategory"
:hide-tab-selection="search.trim().length > 0"
:on-after-hide="reset"
:floating-action-bar-shown="isDirty"
max-width="min(1080px, calc(95vw - 2rem))"
width="min(1080px, calc(95vw - 2rem))"
>
<template #sidebar-header>
<div class="pb-4">
<StyledInput
v-model="search"
:icon="SearchIcon"
type="search"
autocomplete="off"
:placeholder="formatMessage(messages.search)"
:aria-label="formatMessage(messages.search)"
wrapper-class="w-full shrink-0"
/>
</div>
</template>
<template #content>
<div class="flex min-h-full min-w-0 flex-col">
<div
v-if="!isLocalEditor && (loading || loadError || categorySettings.length > 0)"
class="mb-4 flex shrink-0 flex-wrap items-start justify-between gap-4 border-0 border-b border-solid border-surface-4 pb-4"
>
<p class="m-0 min-w-60 flex-1 text-[var(--color-text-default)]">
{{ formatMessage(messages.syncedOptionsDescription) }}
</p>
<div class="ml-auto flex w-48 justify-end">
<Button
size="lg"
:disabled="
allCategorySettingsSynced
? disableCandidates.length === 0
: enableCandidates.length === 0
"
@click="toggleVisibleSync"
>
<LinkIcon v-if="allCategorySettingsSynced" aria-hidden="true" />
<UnlinkIcon v-else aria-hidden="true" />
{{
formatMessage(allCategorySettingsSynced ? messages.disableAll : messages.enableAll)
}}
</Button>
</div>
</div>
<div
v-if="loading"
role="status"
:aria-label="formatMessage(messages.loading)"
class="flex flex-1 flex-col gap-4"
>
<div
v-for="row in 5"
:key="row"
class="h-20 animate-pulse rounded-xl bg-surface-3"
aria-hidden="true"
/>
</div>
<div
v-else-if="loadError"
class="flex min-h-40 flex-1 flex-col items-center justify-center gap-3 text-[var(--color-text-tertiary)]"
>
<p class="m-0">{{ formatMessage(messages.loadFailed) }}</p>
<Button @click="load">
<RefreshCwIcon aria-hidden="true" />
{{ formatMessage(messages.retry) }}
</Button>
</div>
<div v-else class="min-h-0 flex-1">
<div
v-if="categorySettings.length === 0"
class="flex h-full min-h-40 items-center justify-center px-8 text-center text-[var(--color-text-tertiary)]"
>
{{
activeCategory?.is_custom && !search
? formatMessage(messages.customEmpty)
: formatMessage(messages.empty)
}}
</div>
<div v-else class="flex flex-col gap-4">
<GameSettingRow
v-for="setting in categorySettings"
:key="setting.option_id"
:setting="setting"
:locale-label="localeLabels[setting.option_id]"
:keybind-conflicts="keybindConflicts.get(setting.option_id)"
:show-sync-toggle="!isLocalEditor"
:source-navigation-disabled="isDirty || saving"
@open-source="openSource"
@update:sync-enabled="setSyncEnabled([setting.option_id], $event)"
@update:canonical-value="setCanonicalValue(setting.option_id, $event)"
/>
</div>
</div>
</div>
</template>
<template #floating-action-bar>
<FloatingActionBar v-if="isDirty" shown inline :aria-label="modalTitle">
<p class="m-0 text-sm font-semibold md:text-base">
{{ formatMessage(messages.unsavedChanges) }}
</p>
<div class="ml-auto flex gap-2">
<Button type="outlined" @click="cancelChanges">
<XIcon aria-hidden="true" />
{{ formatMessage(commonMessages.cancelButton) }}
</Button>
<Button
type="colored"
color="brand"
:disabled="!isDirty || loading || saving || hasBlockingDraft"
:loading="saving"
@click="save"
>
<SaveIcon aria-hidden="true" />
{{ formatMessage(messages.saveSettings) }}
</Button>
</div>
</FloatingActionBar>
</template>
</TabbedModal>
</template>
@@ -0,0 +1,235 @@
<script setup lang="ts">
import { TriangleAlertIcon } from '@modrinth/assets'
import { Button, defineMessages, useVIntl } from '@modrinth/ui'
import { computed, onBeforeUnmount, ref, useId, watch } from 'vue'
import {
activateKeybindRecording,
deactivateKeybindRecording,
formatMinecraftKeybind,
minecraftKeyTokenFromKeyboardEvent,
minecraftMouseTokenFromButton,
} from './keybinds'
const props = withDefaults(
defineProps<{
modelValue: string
settingLabel: string
conflicts?: string[]
mixed?: boolean
disabled?: boolean
}>(),
{
conflicts: () => [],
mixed: false,
disabled: false,
},
)
const emit = defineEmits<{
'update:model-value': [value: string]
}>()
const { formatMessage, locale } = useVIntl()
const statusId = useId()
const conflictId = useId()
const recording = ref(false)
const statusMessage = ref('')
const controlElement = ref<HTMLElement | null>(null)
let suppressNextClick = false
const messages = defineMessages({
listen: {
id: 'app.settings.game-options.keybind.listen',
defaultMessage: 'Press a key…',
},
choose: {
id: 'app.settings.game-options.keybind.choose',
defaultMessage: 'Choose a key',
},
mixed: {
id: 'app.settings.game-options.keybind.mixed',
defaultMessage: 'Different across instances',
},
change: {
id: 'app.settings.game-options.keybind.change',
defaultMessage: '{setting}: {binding}. Activate to change the binding.',
},
listeningStatus: {
id: 'app.settings.game-options.keybind.listening-status',
defaultMessage: 'Listening for a key or mouse button. Press Escape to clear this binding.',
},
recordingLabel: {
id: 'app.settings.game-options.keybind.recording-label',
defaultMessage:
'{setting}: listening for a key or mouse button. Press Escape to clear this binding.',
},
unsupportedStatus: {
id: 'app.settings.game-options.keybind.unsupported-status',
defaultMessage: 'That input cannot be used by Minecraft. Press another key.',
},
cancelledStatus: {
id: 'app.settings.game-options.keybind.cancelled-status',
defaultMessage: 'Key binding change cancelled.',
},
assignedStatus: {
id: 'app.settings.game-options.keybind.assigned-status',
defaultMessage: '{setting} is now bound to {binding}.',
},
conflict: {
id: 'app.settings.game-options.keybind.conflict-description',
defaultMessage: 'This is already used by {settings}.',
},
})
const isMac = /Mac|iPhone|iPad|iPod/.test(navigator.platform)
const bindingLabel = computed(() => {
if (props.mixed) return formatMessage(messages.mixed)
if (!props.modelValue) return formatMessage(messages.choose)
return formatMinecraftKeybind(formatMessage, props.modelValue, isMac)
})
const visibleLabel = computed(() =>
recording.value ? formatMessage(messages.listen) : bindingLabel.value,
)
const conflictSettings = computed(() =>
new Intl.ListFormat(locale.value, { style: 'long', type: 'conjunction' }).format(
props.conflicts.map((setting) => `‘${setting}’`),
),
)
const conflictMessage = computed(() =>
props.conflicts.length
? formatMessage(messages.conflict, { settings: conflictSettings.value })
: '',
)
const accessibleLabel = computed(() =>
formatMessage(recording.value ? messages.recordingLabel : messages.change, {
setting: props.settingLabel,
binding: bindingLabel.value,
}),
)
function startRecording() {
if (props.disabled || recording.value) return
activateKeybindRecording(cancelRecording)
recording.value = true
statusMessage.value = formatMessage(messages.listeningStatus)
window.addEventListener('keydown', handleKeydown, true)
window.addEventListener('pointerdown', handlePointerDown, true)
window.addEventListener('blur', cancelRecording)
}
function stopRecording() {
recording.value = false
window.removeEventListener('keydown', handleKeydown, true)
window.removeEventListener('pointerdown', handlePointerDown, true)
window.removeEventListener('blur', cancelRecording)
deactivateKeybindRecording(cancelRecording)
}
function cancelRecording() {
if (!recording.value) return
stopRecording()
statusMessage.value = formatMessage(messages.cancelledStatus)
}
function assign(value: string) {
if (!recording.value) return
stopRecording()
emit('update:model-value', value)
statusMessage.value = formatMessage(messages.assignedStatus, {
setting: props.settingLabel,
binding: formatMinecraftKeybind(formatMessage, value, isMac),
})
}
function handleClick(event: MouseEvent) {
if (suppressNextClick) {
suppressNextClick = false
event.preventDefault()
event.stopPropagation()
return
}
startRecording()
}
function handleKeydown(event: KeyboardEvent) {
if (!recording.value) return
event.preventDefault()
event.stopPropagation()
if (event.code === 'Escape') {
assign('key.keyboard.unknown')
return
}
if (event.repeat) return
const token = event.isComposing ? null : minecraftKeyTokenFromKeyboardEvent(event)
if (!token) {
statusMessage.value = formatMessage(messages.unsupportedStatus)
return
}
assign(token)
}
function handlePointerDown(event: PointerEvent) {
if (!recording.value) return
if (event.pointerType !== 'mouse') return
const otherKeybindControl =
event.target instanceof Element
? event.target.closest<HTMLElement>('[data-game-keybind-control]')
: null
if (otherKeybindControl && otherKeybindControl !== controlElement.value) return
event.preventDefault()
event.stopPropagation()
const token = minecraftMouseTokenFromButton(event.button)
if (!token) {
statusMessage.value = formatMessage(messages.unsupportedStatus)
return
}
suppressNextClick = event.button === 0 && otherKeybindControl === controlElement.value
assign(token)
}
watch(
() => props.disabled,
(disabled) => {
if (disabled) cancelRecording()
},
)
onBeforeUnmount(cancelRecording)
</script>
<template>
<div class="flex w-full min-w-0 items-center gap-1.5">
<span
ref="controlElement"
v-tooltip="!recording && conflictMessage ? conflictMessage : undefined"
data-game-keybind-control
class="flex min-w-0 flex-1"
>
<Button
:type="recording || conflicts.length ? 'outlined' : 'base'"
:color="recording ? 'brand' : conflicts.length ? 'orange' : undefined"
size="md"
:disabled="disabled"
:aria-label="accessibleLabel"
:aria-describedby="recording ? statusId : conflicts.length ? conflictId : undefined"
class="w-full overflow-hidden"
@click="handleClick"
@contextmenu.prevent.stop
@auxclick.prevent.stop
>
<TriangleAlertIcon v-if="!recording && conflicts.length" aria-hidden="true" />
<span class="min-w-0 truncate">{{ visibleLabel }}</span>
</Button>
</span>
<span :id="statusId" class="visually-hidden" role="status" aria-live="polite">
{{ statusMessage }}
</span>
<span v-if="conflicts.length" :id="conflictId" class="visually-hidden">
{{ conflictMessage }}
</span>
</div>
</template>
@@ -0,0 +1,426 @@
import { defineMessages, type MessageDescriptor, type VIntlFormatters } from '@modrinth/ui'
type FormatMessage = VIntlFormatters['formatMessage']
type StopKeybindRecording = () => void
let stopActiveKeybindRecording: StopKeybindRecording | null = null
export function activateKeybindRecording(stop: StopKeybindRecording) {
if (stopActiveKeybindRecording === stop) return
const stopPrevious = stopActiveKeybindRecording
stopActiveKeybindRecording = stop
stopPrevious?.()
}
export function deactivateKeybindRecording(stop: StopKeybindRecording) {
if (stopActiveKeybindRecording === stop) {
stopActiveKeybindRecording = null
}
}
const keyMessages = defineMessages({
unbound: {
id: 'app.settings.game-options.keybind.key.unbound',
defaultMessage: 'Unbound',
},
escape: {
id: 'app.settings.game-options.keybind.key.escape',
defaultMessage: 'Escape',
},
backspace: {
id: 'app.settings.game-options.keybind.key.backspace',
defaultMessage: 'Backspace',
},
tab: {
id: 'app.settings.game-options.keybind.key.tab',
defaultMessage: 'Tab',
},
enter: {
id: 'app.settings.game-options.keybind.key.enter',
defaultMessage: 'Enter',
},
leftControl: {
id: 'app.settings.game-options.keybind.key.left-control',
defaultMessage: 'Left Ctrl',
},
rightControl: {
id: 'app.settings.game-options.keybind.key.right-control',
defaultMessage: 'Right Ctrl',
},
leftShift: {
id: 'app.settings.game-options.keybind.key.left-shift',
defaultMessage: 'Left Shift',
},
rightShift: {
id: 'app.settings.game-options.keybind.key.right-shift',
defaultMessage: 'Right Shift',
},
leftAlt: {
id: 'app.settings.game-options.keybind.key.left-alt',
defaultMessage: 'Left Alt',
},
rightAlt: {
id: 'app.settings.game-options.keybind.key.right-alt',
defaultMessage: 'Right Alt',
},
space: {
id: 'app.settings.game-options.keybind.key.space',
defaultMessage: 'Space',
},
capsLock: {
id: 'app.settings.game-options.keybind.key.caps-lock',
defaultMessage: 'Caps Lock',
},
numLock: {
id: 'app.settings.game-options.keybind.key.num-lock',
defaultMessage: 'Num Lock',
},
scrollLock: {
id: 'app.settings.game-options.keybind.key.scroll-lock',
defaultMessage: 'Scroll Lock',
},
printScreen: {
id: 'app.settings.game-options.keybind.key.print-screen',
defaultMessage: 'Print Screen',
},
pause: {
id: 'app.settings.game-options.keybind.key.pause',
defaultMessage: 'Pause',
},
home: {
id: 'app.settings.game-options.keybind.key.home',
defaultMessage: 'Home',
},
end: {
id: 'app.settings.game-options.keybind.key.end',
defaultMessage: 'End',
},
pageUp: {
id: 'app.settings.game-options.keybind.key.page-up',
defaultMessage: 'Page Up',
},
pageDown: {
id: 'app.settings.game-options.keybind.key.page-down',
defaultMessage: 'Page Down',
},
insert: {
id: 'app.settings.game-options.keybind.key.insert',
defaultMessage: 'Insert',
},
delete: {
id: 'app.settings.game-options.keybind.key.delete',
defaultMessage: 'Delete',
},
arrowUp: {
id: 'app.settings.game-options.keybind.key.arrow-up',
defaultMessage: 'Up Arrow',
},
arrowDown: {
id: 'app.settings.game-options.keybind.key.arrow-down',
defaultMessage: 'Down Arrow',
},
arrowLeft: {
id: 'app.settings.game-options.keybind.key.arrow-left',
defaultMessage: 'Left Arrow',
},
arrowRight: {
id: 'app.settings.game-options.keybind.key.arrow-right',
defaultMessage: 'Right Arrow',
},
leftSuper: {
id: 'app.settings.game-options.keybind.key.left-super',
defaultMessage: 'Left Super',
},
rightSuper: {
id: 'app.settings.game-options.keybind.key.right-super',
defaultMessage: 'Right Super',
},
leftCommand: {
id: 'app.settings.game-options.keybind.key.left-command',
defaultMessage: 'Left Command',
},
rightCommand: {
id: 'app.settings.game-options.keybind.key.right-command',
defaultMessage: 'Right Command',
},
menu: {
id: 'app.settings.game-options.keybind.key.menu',
defaultMessage: 'Menu',
},
keypadKey: {
id: 'app.settings.game-options.keybind.key.keypad-key',
defaultMessage: 'Numpad {key}',
},
leftMouse: {
id: 'app.settings.game-options.keybind.mouse.left',
defaultMessage: 'Left Mouse',
},
rightMouse: {
id: 'app.settings.game-options.keybind.mouse.right',
defaultMessage: 'Right Mouse',
},
middleMouse: {
id: 'app.settings.game-options.keybind.mouse.middle',
defaultMessage: 'Middle Mouse',
},
mouseButton: {
id: 'app.settings.game-options.keybind.mouse.button',
defaultMessage: 'Mouse {button}',
},
scancode: {
id: 'app.settings.game-options.keybind.scancode',
defaultMessage: 'Scancode {code}',
},
unknownKey: {
id: 'app.settings.game-options.keybind.key.unknown',
defaultMessage: 'Unknown key',
},
controlModifier: {
id: 'app.settings.game-options.keybind.modifier.control',
defaultMessage: 'Ctrl',
},
shiftModifier: {
id: 'app.settings.game-options.keybind.modifier.shift',
defaultMessage: 'Shift',
},
altModifier: {
id: 'app.settings.game-options.keybind.modifier.alt',
defaultMessage: 'Alt',
},
commandModifier: {
id: 'app.settings.game-options.keybind.modifier.command',
defaultMessage: 'Command',
},
superModifier: {
id: 'app.settings.game-options.keybind.modifier.super',
defaultMessage: 'Super',
},
})
const keyboardCodeTokens: Record<string, string> = {
Escape: 'escape',
Minus: 'minus',
Equal: 'equal',
Backspace: 'backspace',
Tab: 'tab',
BracketLeft: 'left.bracket',
BracketRight: 'right.bracket',
Enter: 'enter',
ControlLeft: 'left.control',
Semicolon: 'semicolon',
Quote: 'apostrophe',
Backquote: 'grave.accent',
ShiftLeft: 'left.shift',
Backslash: 'backslash',
Comma: 'comma',
Period: 'period',
Slash: 'slash',
ShiftRight: 'right.shift',
AltLeft: 'left.alt',
Space: 'space',
CapsLock: 'caps.lock',
NumLock: 'num.lock',
ScrollLock: 'scroll.lock',
NumpadSubtract: 'keypad.subtract',
NumpadAdd: 'keypad.add',
NumpadDecimal: 'keypad.decimal',
NumpadComma: 'keypad.decimal',
NumpadEqual: 'keypad.equal',
NumpadEnter: 'keypad.enter',
ControlRight: 'right.control',
NumpadDivide: 'keypad.divide',
NumpadMultiply: 'keypad.multiply',
PrintScreen: 'print.screen',
AltRight: 'right.alt',
Pause: 'pause',
Home: 'home',
ArrowUp: 'up',
PageUp: 'page.up',
ArrowLeft: 'left',
ArrowRight: 'right',
End: 'end',
ArrowDown: 'down',
PageDown: 'page.down',
Insert: 'insert',
Delete: 'delete',
MetaLeft: 'left.win',
MetaRight: 'right.win',
ContextMenu: 'menu',
}
const keyboardLabels: Record<string, MessageDescriptor | string> = {
escape: keyMessages.escape,
minus: '-',
equal: '=',
backspace: keyMessages.backspace,
tab: keyMessages.tab,
'left.bracket': '[',
'right.bracket': ']',
enter: keyMessages.enter,
'left.control': keyMessages.leftControl,
semicolon: ';',
apostrophe: "'",
'grave.accent': '`',
'left.shift': keyMessages.leftShift,
backslash: '\\',
comma: ',',
period: '.',
slash: '/',
'right.shift': keyMessages.rightShift,
'left.alt': keyMessages.leftAlt,
space: keyMessages.space,
'caps.lock': keyMessages.capsLock,
'num.lock': keyMessages.numLock,
'scroll.lock': keyMessages.scrollLock,
'keypad.subtract': '-',
'keypad.add': '+',
'keypad.decimal': '.',
'keypad.equal': '=',
'keypad.enter': keyMessages.enter,
'right.control': keyMessages.rightControl,
'keypad.divide': '/',
'keypad.multiply': '*',
'print.screen': keyMessages.printScreen,
'right.alt': keyMessages.rightAlt,
pause: keyMessages.pause,
home: keyMessages.home,
up: keyMessages.arrowUp,
'page.up': keyMessages.pageUp,
left: keyMessages.arrowLeft,
right: keyMessages.arrowRight,
end: keyMessages.end,
down: keyMessages.arrowDown,
'page.down': keyMessages.pageDown,
insert: keyMessages.insert,
delete: keyMessages.delete,
menu: keyMessages.menu,
}
function splitKeybind(value: string): [key: string, modifier?: string] {
const separator = value.indexOf(':')
return separator === -1 ? [value] : [value.slice(0, separator), value.slice(separator + 1)]
}
function keyboardIdentifierFromCode(code: string): string | null {
const letter = /^Key([A-Z])$/.exec(code)
if (letter) return letter[1].toLowerCase()
const digit = /^Digit([0-9])$/.exec(code)
if (digit) return digit[1]
const functionKey = /^F([1-9]|1[0-9]|2[0-5])$/.exec(code)
if (functionKey) return `f${functionKey[1]}`
const keypadDigit = /^Numpad([0-9])$/.exec(code)
if (keypadDigit) return `keypad.${keypadDigit[1]}`
return keyboardCodeTokens[code] ?? null
}
export function minecraftKeyTokenFromKeyboardEvent(event: KeyboardEvent): string | null {
const identifier = keyboardIdentifierFromCode(event.code)
return identifier ? `key.keyboard.${identifier}` : null
}
export function minecraftMouseTokenFromButton(button: number): string | null {
if (!Number.isInteger(button) || button < 0 || button > 15) return null
if (button === 0) return 'key.mouse.left'
if (button === 1) return 'key.mouse.middle'
if (button === 2) return 'key.mouse.right'
return `key.mouse.${button + 1}`
}
function formatKeyboardLabel(
formatMessage: FormatMessage,
identifier: string,
isMac: boolean,
): string {
if (identifier === 'unknown') return formatMessage(keyMessages.unbound)
if (/^[a-z]$/.test(identifier)) return identifier.toUpperCase()
if (/^[0-9]$/.test(identifier)) return identifier
if (/^f([1-9]|1[0-9]|2[0-5])$/.test(identifier)) return identifier.toUpperCase()
const keypad = /^keypad\.(.+)$/.exec(identifier)
if (keypad) {
const label = keyboardLabels[identifier]
const key = typeof label === 'string' ? label : label ? formatMessage(label) : keypad[1]
return formatMessage(keyMessages.keypadKey, { key })
}
if (identifier === 'left.win') {
return formatMessage(isMac ? keyMessages.leftCommand : keyMessages.leftSuper)
}
if (identifier === 'right.win') {
return formatMessage(isMac ? keyMessages.rightCommand : keyMessages.rightSuper)
}
const label = keyboardLabels[identifier]
if (typeof label === 'string') return label
if (label) return formatMessage(label)
return identifier
.split('.')
.map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`)
.join(' ')
}
function formatModifier(formatMessage: FormatMessage, modifier: string, isMac: boolean): string {
switch (modifier.toUpperCase()) {
case 'CONTROL':
case 'CTRL':
return formatMessage(keyMessages.controlModifier)
case 'SHIFT':
return formatMessage(keyMessages.shiftModifier)
case 'ALT':
return formatMessage(keyMessages.altModifier)
case 'META':
case 'SUPER':
return formatMessage(isMac ? keyMessages.commandModifier : keyMessages.superModifier)
default:
return modifier
}
}
export function formatMinecraftKeybind(
formatMessage: FormatMessage,
value: string,
isMac: boolean,
): string {
const [key, modifier] = splitKeybind(value)
let label: string
if (key.startsWith('key.keyboard.')) {
label = formatKeyboardLabel(formatMessage, key.slice('key.keyboard.'.length), isMac)
} else if (key === 'key.mouse.left') {
label = formatMessage(keyMessages.leftMouse)
} else if (key === 'key.mouse.right') {
label = formatMessage(keyMessages.rightMouse)
} else if (key === 'key.mouse.middle') {
label = formatMessage(keyMessages.middleMouse)
} else if (key.startsWith('key.mouse.')) {
label = formatMessage(keyMessages.mouseButton, {
button: key.slice('key.mouse.'.length),
})
} else if (key.startsWith('scancode.')) {
label = formatMessage(keyMessages.scancode, { code: key.slice('scancode.'.length) })
} else {
label = formatMessage(keyMessages.unknownKey)
}
if (!modifier || key === 'key.keyboard.unknown') return label
const modifiers = modifier
.split(/[+:]/)
.filter(Boolean)
.map((part) => formatModifier(formatMessage, part, isMac))
return [...modifiers, label].join(' + ')
}
export function minecraftKeybindConflictKey(optionId: string, value: string): string | null {
const normalized = value.trim().toLowerCase()
if (!normalized || normalized === 'key.keyboard.unknown') return null
if (optionId === 'key.debug.modifier') return null
if (optionId.startsWith('key.debug.') && optionId !== 'key.debug.overlay') {
return `debug:${normalized}`
}
return `direct:${normalized}`
}
@@ -0,0 +1,152 @@
import type { ComboboxOption } from '@modrinth/ui'
/** Java Edition language names and regions from https://github.com/misode/mcmeta/blob/assets/pack.mcmeta. */
const languageNames: Record<string, string> = {
en_us: 'English (US)',
af_za: 'Afrikaans (Suid-Afrika)',
esan: 'Andalûh (Andaluçía)',
enp: 'Anglish (Oned Riches)',
ast_es: 'Asturianu (Asturies)',
az_az: 'Azərbaycanca (Azərbaycan)',
id_id: 'Bahasa Indonesia (Indonesia)',
ms_my: 'Bahasa Melayu (Malaysia)',
tzo_mx: "Bats'i k'op (Jobel)",
qid: 'Bhs. Indonesia edjaän lama (Indonesia tempo doeloe)',
be_latn: 'Biełaruskaja (Biełaruś)',
bar: 'Boarisch (Bayern)',
bs_ba: 'Bosanski (Bosna i Hercegovina)',
brb: 'Braobans (Braobant)',
br_fr: 'Brezhoneg (Breizh)',
ca_es: 'Català (Catalunya)',
val_es: 'Català (Valencià) (País Valencià)',
cy_gb: 'Cymraeg (Cymru)',
qcb_es: 'Cántabru/Montañés (Cantabria)',
da_dk: 'Dansk (Danmark)',
se_no: 'Davvisámegiella (Sápmi)',
de_at: 'Deitsch (Österreich)',
de_de: 'Deutsch (Deutschland)',
et_ee: 'Eesti keel (Eesti)',
en_au: 'English (Australia)',
en_ca: 'English (Canada)',
en_nz: 'English (New Zealand)',
en_gb: 'English (United Kingdom)',
es_ar: 'Español (Argentina)',
es_cl: 'Español (Chile)',
es_ec: 'Español (Ecuador)',
es_es: 'Español (España)',
es_mx: 'Español (México)',
es_uy: 'Español (Uruguay)',
es_ve: 'Español (Venezuela)',
eo_uy: 'Esperanto (Esperantujo)',
eu_es: 'Euskara (Euskal Herria)',
fil_ph: 'Filipino (Pilipinas)',
fr_ca: 'Français (Canada)',
fr_fr: 'Français (France)',
fr_ch: 'Français (Suisse)',
fy_nl: 'Frysk (Fryslân)',
fra_de: 'Fränggisch (Franggn)',
fur_it: 'Furlan (Friûl)',
fo_fo: 'Føroyskt (Føroyar)',
ga_ie: 'Gaeilge (Éire)',
gl_es: 'Galego (Galicia / Galiza)',
go_fr: 'Galo (Bertègn)',
gd_gb: 'Gàidhlig (Alba)',
hr_hr: 'Hrvatski (Hrvatska)',
hn_no: 'Høgnorsk (Norig)',
io_en: 'Ido (Idia)',
ig_ng: 'Igbo (Naigeria)',
it_it: 'Italiano (Italia)',
kw_gb: 'Kernewek (Kernow)',
ksh: 'Kölsch/Ripoarisch (Rhingland)',
lol_us: 'LOLCAT (Kingdom of Cats)',
la_la: 'Latina (Latium)',
lv_lv: 'Latviešu (Latvija)',
lt_lt: 'Lietuvių (Lietuva)',
li_li: 'Limburgs (Limburg)',
lmo: 'Lombard (Lombardia)',
lb_lu: 'Lëtzebuergesch (Lëtzebuerg)',
hu_hu: 'Magyar (Magyarország)',
mt_mt: 'Malti (Malta)',
isv: 'Medžuslovjansky (Slovjanščina)',
nah: 'Mēxikatlahtōlli (Mēxiko)',
nl_nl: 'Nederlands (Nederland)',
pls: 'Ngiiwà (Ndanìꞌngà)',
no_no: 'Norsk bokmål (Norge)',
nn_no: 'Norsk nynorsk (Noreg)',
uz_uz: "O'zbekcha (O'zbekiston)",
oc_fr: 'Occitan (Occitània)',
en_pt: 'Pirate Speak (The Seven Seas)',
nds_de: 'Plattdüütsch (Düütschland)',
pl_pl: 'Polski (Polska)',
pt_br: 'Português (Brasil)',
pt_pt: 'Português (Portugal)',
qya_aa: 'Quenya (Arda)',
ro_ro: 'Română (România)',
de_ch: 'Schwiizerdütsch (Schwiiz)',
enws: 'Shakespearean English (Kingdom of England)',
sq_al: 'Shqip (Shqipëri)',
sk_sk: 'Slovenčina (Slovensko)',
sl_si: 'Slovenščina (Slovenija)',
so_so: 'Soomaali (Soomaaliya)',
sr_cs: 'Srpski (Srbija)',
fi_fi: 'Suomi (Suomi)',
sv_se: 'Svenska (Sverige)',
sxu: 'Säggs’sch (Saggsn)',
tl_ph: 'Tagalog (Pilipinas)',
vi_vn: 'Tiếng Việt (Việt Nam)',
tr_tr: 'Türkçe (Türkiye)',
vp_vl: 'Viossa (Vilant)',
nl_be: 'Vlaams (België)',
vec_it: 'Vèneto (Veneto)',
vro: 'Võro (Eesti)',
yo_ng: 'Yorùbá (Nàìjíríà)',
jbo_en: "la .lojban. (la jbogu'e)",
tlh_aa: "tlhIngan Hol (tlhIngan wo')",
tok: 'toki pona (kulupu pona)',
is_is: 'Íslenska (Ísland)',
ovd: 'Övdalska (Swerre)',
cs_cz: 'Čeština (Česko)',
szl: 'Ślōnski (Gōrny Ślōnsk)',
en_ud: 'ɥsᴉꞁᵷuƎ (uʍoᗡ ǝpᴉsd∩)',
haw_us: 'ʻŌlelo Hawaiʻi (Hawaiʻi)',
el_gr: 'Ελληνικά (Ελλάδα)',
ba_ru: 'Башҡортса (Башҡортостан, Рәсәй)',
be_by: 'Беларуская (Беларусь)',
bg_bg: 'Български (България)',
hal_ua: 'Галицка (Галичина, Вкраїна)',
ky_kg: 'Кыргызча (Кыргызстан)',
mk_mk: 'Македонски (Северна Македонија)',
mn_mn: 'Монгол (Монгол Улс)',
ry_ua: 'Руснацькый (Пудкарпатя, Украина)',
ru_ru: 'Русский (Россия)',
rpr: 'Русскій дореформенный (Россійская имперія)',
sah_sah: 'Сахалыы (Cаха Сирэ)',
sr_sp: 'Српски (Србија)',
tt_ru: 'Татарча (Татарстан, Рәсәй)',
uk_ua: 'Українська (Україна)',
cv_cu: 'Чӑвашла (Чӑваш Ен, Раҫҫей)',
kk_kz: 'Қазақша (Қазақстан)',
hy_am: 'Հայերեն (Հայաստան)',
yi_de: 'ייִדיש (אשכנזיש יידן)',
he_il: 'עברית (ישראל)',
ar_sa: 'العربية (العالم العربي)',
zlm_arab: 'بهاس ملايو (مليسيا)',
fa_ir: 'فارسی (ايران)',
hi_in: 'हिंदी (भारत)',
ta_in: 'தமிழ் (இந்தியா)',
kn_in: 'ಕನ್ನಡ (ಭಾರತ)',
th_th: 'ไทย (ประเทศไทย)',
lo_la: 'ລາວ (ປະເທດລາວ)',
ka_ge: 'ქართული (საქართველო)',
lzh: '文言 (華夏)',
ja_jp: '日本語 (日本)',
zh_cn: '简体中文 (中国大陆)',
zh_tw: '繁體中文 (台灣)',
zh_hk: '繁體中文 (香港特別行政區)',
ko_kr: '한국어 (대한민국)',
got_de: '𐌲𐌿𐍄𐍂𐌰𐌶𐌳𐌰 (𐌲𐌿𐍄𐌸𐌹𐌿𐌳𐌰)',
}
export const minecraftLanguageOptions: ComboboxOption<string>[] = Object.entries(languageNames)
.map(([value, label]) => ({ value, label, searchTerms: [value] }))
.sort((a, b) => a.label.localeCompare(b.label))
@@ -0,0 +1,384 @@
<script setup lang="ts">
import { LinkIcon, UnknownIcon, UnlinkIcon } from '@modrinth/assets'
import {
Avatar,
Combobox,
type ComboboxOption,
defineMessages,
IconButton,
Slider,
StyledInput,
truncatedTooltip,
useVIntl,
} from '@modrinth/ui'
import { computed, ref } from 'vue'
import { type RouteLocationRaw, RouterLink } from 'vue-router'
import type {
EditableGameSetting,
GameOptionCanonicalValue,
GameSettingLocaleLabel,
} from '@/helpers/game-options'
import GameSettingBooleanControl from './boolean-control.vue'
import {
canonicalBooleanValue,
canonicalValueFromInput,
canonicalValueText,
gameSettingNumberScale,
isKeybindSetting,
settingCanBeEnabled,
} from './editors'
import GameKeybindInput from './keybind-input.vue'
import { minecraftLanguageOptions } from './languages'
import {
formatGameSettingChoice,
formatGameSettingDescription,
formatGameSettingLabel,
formatGameSettingValidation,
presentationMessages,
} from './messages'
const props = withDefaults(
defineProps<{
setting: EditableGameSetting
localeLabel?: GameSettingLocaleLabel
keybindConflicts?: string[]
disabled?: boolean
showSyncToggle?: boolean
sourceNavigationDisabled?: boolean
}>(),
{
keybindConflicts: () => [],
disabled: false,
showSyncToggle: true,
sourceNavigationDisabled: false,
},
)
const emit = defineEmits<{
'update:sync-enabled': [enabled: boolean]
'update:canonical-value': [value: GameOptionCanonicalValue | null]
'open-source': [location: RouteLocationRaw]
}>()
const { formatMessage } = useVIntl()
const settingLabelRef = ref<HTMLElement | null>(null)
const messages = defineMessages({
on: {
id: 'app.settings.synced-options.game-settings.value-on',
defaultMessage: 'On',
},
off: {
id: 'app.settings.synced-options.game-settings.value-off',
defaultMessage: 'Off',
},
mixed: {
id: 'app.settings.synced-options.game-settings.value-mixed',
defaultMessage: 'Different across instances',
},
unset: {
id: 'app.settings.synced-options.game-settings.value-unset',
defaultMessage: 'Choose a value',
},
syncSetting: {
id: 'app.settings.synced-options.game-settings.sync-setting',
defaultMessage: 'Sync {setting} across instances',
},
unsyncSetting: {
id: 'app.settings.synced-options.game-settings.unsync-setting',
defaultMessage: 'Stop syncing {setting} across instances',
},
syncedAcrossInstances: {
id: 'app.settings.synced-options.game-settings.synced-across-instances',
defaultMessage: 'Synced across instances',
},
notBeingSynced: {
id: 'app.settings.synced-options.game-settings.not-being-synced',
defaultMessage: 'Not being synced',
},
})
const settingLabel = computed(() => {
if (props.setting.kind === 'external') {
return props.localeLabel?.label ?? formatGameSettingLabel(formatMessage, props.setting)
}
return formatGameSettingLabel(formatMessage, props.setting)
})
const settingDescription = computed(() =>
formatGameSettingDescription(formatMessage, props.setting),
)
const settingSource = computed(() =>
props.setting.kind === 'external' ? props.localeLabel?.source : undefined,
)
const sourceLocation = computed<RouteLocationRaw>(() => {
const source = settingSource.value
if (source?.project) {
return { path: `/project/${source.project.id}` }
}
return {
name: 'InstanceContent',
params: { id: source?.instance_id },
query: { highlight: source?.file_path },
}
})
const valueText = computed(() => canonicalValueText(props.setting))
const languageOptions = computed<ComboboxOption<string>[]>(() => {
const value = valueText.value
if (value && !minecraftLanguageOptions.some((option) => option.value === value)) {
return [{ value, label: value }, ...minecraftLanguageOptions]
}
return minecraftLanguageOptions
})
const enumOptions = computed<ComboboxOption<string>[]>(() =>
(props.setting.editor.choices ?? []).map((choice) => ({
value: choice.value,
label:
props.setting.kind === 'external'
? (props.localeLabel?.choices[choice.value] ?? choice.value)
: formatGameSettingChoice(formatMessage, props.setting.option_id, choice.value),
})),
)
const isNumber = computed(
() => props.setting.editor.type === 'integer' || props.setting.editor.type === 'decimal',
)
const isSlider = computed(
() => isNumber.value && props.setting.editor.min != null && props.setting.editor.max != null,
)
const booleanValue = computed(() => canonicalBooleanValue(props.setting))
const numberScale = computed(() => gameSettingNumberScale(props.setting))
const inputMin = computed(() =>
props.setting.editor.min === null || props.setting.editor.min === undefined
? undefined
: props.setting.editor.min * numberScale.value,
)
const inputMax = computed(() =>
props.setting.editor.max === null || props.setting.editor.max === undefined
? undefined
: props.setting.editor.max * numberScale.value,
)
const inputStep = computed(() =>
props.setting.editor.step === null || props.setting.editor.step === undefined
? undefined
: props.setting.editor.step * numberScale.value,
)
const sliderValue = computed(() => {
if (valueText.value === '') return null
const value = Number(valueText.value)
return Number.isFinite(value) ? value : null
})
const inputValue = computed(() =>
isNumber.value ? (sliderValue.value ?? undefined) : valueText.value,
)
const editorDisabled = computed(
() =>
props.disabled ||
props.setting.controlled ||
(props.showSyncToggle && !props.setting.sync_enabled),
)
const syncToggleDisabled = computed(
() =>
props.disabled ||
props.setting.controlled ||
(!props.setting.sync_enabled && !settingCanBeEnabled(props.setting)),
)
const placeholder = computed(() => {
if (props.setting.value_state === 'mixed') return formatMessage(messages.mixed)
if (props.setting.value_state === 'unset') return formatMessage(messages.unset)
if (props.setting.editor.type === 'external_raw') {
return formatMessage(presentationMessages.customValuePlaceholder)
}
return undefined
})
const validationMessage = computed(() =>
formatGameSettingValidation(formatMessage, props.setting.validation_error),
)
const syncDisabledReason = computed(() => {
if (validationMessage.value) return validationMessage.value
if (props.setting.value_state === 'invalid') {
return formatMessage(presentationMessages.validationInvalidValue)
}
if (props.setting.value_state === 'mixed' || props.setting.value_state === 'unset') {
return formatMessage(presentationMessages.validationMissingValue)
}
if (props.setting.controlled) {
return formatMessage(presentationMessages.bucketLauncherControlled, { count: 1 })
}
if (
props.setting.compatibility.total_participating > 0 &&
props.setting.compatibility.will_receive === 0
) {
return formatMessage(presentationMessages.compatibilityNone)
}
return undefined
})
const syncActionLabel = computed(() =>
formatMessage(props.setting.sync_enabled ? messages.unsyncSetting : messages.syncSetting, {
setting: settingLabel.value,
}),
)
const syncStatusTooltip = computed(() =>
formatMessage(
props.setting.sync_enabled ? messages.syncedAcrossInstances : messages.notBeingSynced,
),
)
function updateValue(value: string | number | boolean | undefined) {
emit('update:canonical-value', canonicalValueFromInput(props.setting, value))
}
</script>
<template>
<div
class="grid min-h-[54px] min-w-0 items-center gap-2"
:class="
isSlider
? showSyncToggle
? 'grid-cols-[minmax(10rem,0.65fr)_minmax(0,1.35fr)_2.25rem]'
: 'grid-cols-[minmax(10rem,0.65fr)_minmax(0,1.35fr)]'
: showSyncToggle
? 'grid-cols-[minmax(0,1fr)_12rem_2.25rem]'
: 'grid-cols-[minmax(0,1fr)_12rem]'
"
>
<div class="min-w-0 pr-2">
<div class="flex items-center gap-2">
<h3
ref="settingLabelRef"
v-tooltip="truncatedTooltip(settingLabelRef, settingLabel)"
class="m-0 truncate text-lg font-semibold text-[var(--color-text-primary)]"
>
{{ settingLabel }}
</h3>
<span
v-if="validationMessage"
v-tooltip="validationMessage"
tabindex="0"
role="img"
class="flex shrink-0 rounded-md text-orange outline-none focus-visible:ring-4 focus-visible:ring-brand-shadow"
>
<UnknownIcon class="size-4" aria-hidden="true" />
</span>
</div>
<RouterLink v-if="settingSource" v-slot="{ href }" :to="sourceLocation" custom>
<a
:href="sourceNavigationDisabled ? undefined : href"
:aria-disabled="sourceNavigationDisabled || undefined"
class="mt-2 inline-flex max-w-full items-center gap-1.5 text-[var(--color-text-default)]"
:class="sourceNavigationDisabled ? 'cursor-default' : 'hover:underline'"
@click.prevent="!sourceNavigationDisabled && emit('open-source', sourceLocation)"
>
<Avatar
v-if="settingSource.project"
:src="settingSource.project.icon_url"
size="1.25rem"
no-shadow
/>
<span class="truncate">{{
settingSource.project?.title ?? settingSource.file_name
}}</span>
</a>
</RouterLink>
<p v-else-if="settingDescription" class="m-0 mt-0.5 text-[var(--color-text-default)]">
{{ settingDescription }}
</p>
</div>
<div class="flex min-w-0 items-center justify-end">
<Slider
v-if="isSlider"
size="standard"
:model-value="sliderValue"
:min="inputMin ?? 0"
:max="inputMax ?? 100"
:step="inputStep ?? 1"
:snap-points="setting.category_id === 'music_and_sound' ? [0, 50, 100] : []"
:snap-range="5"
:unit="setting.editor.unit === 'percent' ? '%' : undefined"
:placeholder="placeholder"
:aria-label="settingLabel"
:disabled="editorDisabled"
@update:model-value="updateValue"
/>
<GameSettingBooleanControl
v-else-if="setting.editor.type === 'bool'"
:model-value="booleanValue"
:label="settingLabel"
:on-label="formatMessage(messages.on)"
:off-label="formatMessage(messages.off)"
:placeholder="placeholder"
:disabled="editorDisabled"
@update:model-value="updateValue"
/>
<Combobox
v-else-if="setting.option_id === 'language'"
:model-value="valueText"
:options="languageOptions"
:placeholder="placeholder"
:disabled="editorDisabled"
:aria-label="settingLabel"
:search-input-attrs="{ 'aria-label': settingLabel }"
searchable
select-search-text-on-focus
class="min-w-0"
@update:model-value="updateValue"
/>
<Combobox
v-else-if="setting.editor.type === 'enum'"
:model-value="valueText"
:options="enumOptions"
:placeholder="placeholder"
:disabled="editorDisabled"
:aria-label="settingLabel"
class="min-w-0"
@update:model-value="updateValue"
/>
<GameKeybindInput
v-else-if="isKeybindSetting(setting)"
:model-value="valueText"
:setting-label="settingLabel"
:conflicts="keybindConflicts"
:mixed="setting.value_state === 'mixed'"
:disabled="editorDisabled"
@update:model-value="updateValue"
/>
<StyledInput
v-else
:model-value="inputValue"
:type="isNumber ? 'number' : 'text'"
:min="inputMin"
:max="inputMax"
:step="inputStep"
:placeholder="placeholder"
:disabled="editorDisabled"
:aria-label="settingLabel"
wrapper-class="w-full"
@update:model-value="updateValue"
/>
</div>
<span
v-if="showSyncToggle"
v-tooltip="syncToggleDisabled && syncDisabledReason ? syncDisabledReason : syncStatusTooltip"
class="flex justify-center"
>
<IconButton
:type="setting.sync_enabled ? 'chip' : 'base'"
:color="setting.sync_enabled ? 'blue' : undefined"
:label="syncActionLabel"
:disabled="syncToggleDisabled"
:aria-pressed="setting.sync_enabled"
@click="emit('update:sync-enabled', !setting.sync_enabled)"
>
<LinkIcon v-if="setting.sync_enabled" aria-hidden="true" />
<UnlinkIcon v-else aria-hidden="true" />
</IconButton>
</span>
</div>
</template>
@@ -0,0 +1,448 @@
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, type MaybeRefOrGetter, nextTick, onScopeDispose, ref, toValue, watch } from 'vue'
import {
type EditableGameSetting,
type GameOptionCanonicalValue,
type GameSettingChange,
type GameSettingsEditorState,
gameSettingsKeys,
gameSettingsQueryOptions,
get_local_game_options_config,
get_synced_game_options_config,
preview_local_game_option_changes,
preview_synced_game_option_changes,
save_local_game_option_changes,
save_synced_game_option_changes,
type UpdateGameSettingsRequest,
} from '@/helpers/game-options'
import { canonicalValuesEqual, cloneGameSettingsState, gameSettingChanges } from './editors'
export function useGameSettingsEditor(
instanceId: MaybeRefOrGetter<string | undefined>,
onSaved: () => void,
) {
const { addNotification, handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const queryClient = useQueryClient()
const messages = defineMessages({
conflictTitle: {
id: 'app.settings.synced-options.game-settings.conflict-title',
defaultMessage: 'These settings changed elsewhere',
},
conflictText: {
id: 'app.settings.synced-options.game-settings.conflict-text',
defaultMessage: 'We loaded the latest settings. Check your changes, then save again.',
},
})
const baseState = ref<GameSettingsEditorState | null>(null)
const draftState = ref<GameSettingsEditorState | null>(null)
const touchedValueOptionIds = ref<Set<string>>(new Set())
let previewTimer: ReturnType<typeof setTimeout> | null = null
let previewGeneration = 0
let loadGeneration = 0
let saveGeneration = 0
const editorInstanceId = ref<string>()
const isLocalEditor = computed(() => !!editorInstanceId.value)
const dirtyChanges = computed(() =>
gameSettingChanges(baseState.value, draftState.value, touchedValueOptionIds.value),
)
const isDirty = computed(() => dirtyChanges.value.length > 0)
const dirtyOptionIds = computed(
() => new Set(dirtyChanges.value.map((change) => change.option_id)),
)
const hasBlockingDraft = computed(
() =>
draftState.value?.settings.some(
(setting) =>
dirtyOptionIds.value.has(setting.option_id) &&
(isLocalEditor.value || setting.sync_enabled) &&
(!!setting.validation_error ||
['mixed', 'unset', 'invalid'].includes(setting.value_state)),
) ?? false,
)
const stateQuery = useQuery(
computed(() => ({
...gameSettingsQueryOptions(editorInstanceId.value),
enabled: false,
})),
)
const loading = computed(
() => !draftState.value && (stateQuery.isPending.value || stateQuery.isFetching.value),
)
const loadError = computed(() => !draftState.value && stateQuery.isError.value)
const previewMutation = useMutation({
mutationFn: ({
instanceId,
request,
}: {
instanceId?: string
request: UpdateGameSettingsRequest
}) =>
instanceId
? preview_local_game_option_changes(instanceId, request)
: preview_synced_game_option_changes(request),
})
const saveMutation = useMutation({
mutationFn: saveDraft,
onError: handleError,
})
function applyState(state: GameSettingsEditorState) {
baseState.value = cloneGameSettingsState(state)
draftState.value = cloneGameSettingsState(state)
touchedValueOptionIds.value = new Set()
}
watch(stateQuery.data, (state) => {
if (state && draftState.value && !isDirty.value && !saveMutation.isPending.value) {
applyState(state)
}
})
async function load() {
const generation = ++loadGeneration
cancelPreview()
editorInstanceId.value = toValue(instanceId)
const options = gameSettingsQueryOptions(editorInstanceId.value)
const cached = !editorInstanceId.value && queryClient.getQueryData(options.queryKey)
if (cached) {
applyState(cached)
try {
// A previously cached empty state is common immediately after enabling
// sync. Refresh it before returning so the editor does not stay empty.
const refreshed = await queryClient.fetchQuery({ ...options, staleTime: 0 })
if (generation !== loadGeneration) return false
applyState(refreshed)
return true
} catch (error) {
if (generation !== loadGeneration) return false
handleError(error)
return true
}
}
await nextTick()
const result = await queryClient
.fetchQuery({ ...options, staleTime: 0 })
.then((data) => ({ data, isError: false, error: null as unknown }))
.catch((error) => ({ data: undefined, isError: true, error }))
if (generation !== loadGeneration) return false
if (result.isError) {
handleError(result.error)
return false
}
if (!result.data) return false
applyState(result.data)
return true
}
function cancelPreview() {
if (previewTimer) clearTimeout(previewTimer)
previewTimer = null
previewGeneration++
}
function reset() {
cancelPreview()
loadGeneration++
saveGeneration++
baseState.value = null
draftState.value = null
touchedValueOptionIds.value = new Set()
saveMutation.reset()
}
function cancelChanges() {
if (!baseState.value) return
cancelPreview()
draftState.value = cloneGameSettingsState(baseState.value)
touchedValueOptionIds.value = new Set()
}
function setSyncEnabled(optionIds: readonly string[], enabled: boolean) {
if (!draftState.value) return
const ids = new Set(optionIds)
draftState.value.settings = draftState.value.settings.map((setting) =>
ids.has(setting.option_id)
? { ...setting, sync_enabled: enabled, validation_error: null }
: setting,
)
schedulePreview()
}
function schedulePreview() {
cancelPreview()
previewTimer = setTimeout(() => void preview(), 350)
}
function save() {
if (saveMutation.isPending.value || loading.value || hasBlockingDraft.value || !isDirty.value)
return
saveMutation.mutate()
}
async function saveAndWait(): Promise<boolean> {
if (loading.value || hasBlockingDraft.value || !isDirty.value) return !hasBlockingDraft.value
try {
await saveMutation.mutateAsync()
return true
} catch {
return false
}
}
function editorRequest(): UpdateGameSettingsRequest | null {
if (!baseState.value || !draftState.value) return null
return {
expected_summary_revision: draftState.value.summary_revision,
expected_canonical_revision: draftState.value.canonical_revision,
expected_catalog_revision: draftState.value.catalog_revision,
changes: dirtyChanges.value,
}
}
function setCanonicalValue(optionId: string, value: GameOptionCanonicalValue | null) {
if (!draftState.value) return
const currentSetting = draftState.value.settings.find(
(setting) => setting.option_id === optionId,
)
const baseSetting = baseState.value?.settings.find((setting) => setting.option_id === optionId)
if (!currentSetting) return
const revertedToBase =
!!baseSetting &&
!canonicalValuesEqual(currentSetting.canonical_value, baseSetting.canonical_value) &&
canonicalValuesEqual(value, baseSetting.canonical_value)
const touchedOptionIds = new Set(touchedValueOptionIds.value)
if (revertedToBase) {
touchedOptionIds.delete(optionId)
} else {
touchedOptionIds.add(optionId)
}
touchedValueOptionIds.value = touchedOptionIds
draftState.value.settings = draftState.value.settings.map((setting) =>
setting.option_id === optionId
? {
...setting,
canonical_value: value,
value_state: revertedToBase ? baseSetting.value_state : value ? 'canonical' : 'unset',
validation_error: revertedToBase ? baseSetting.validation_error : null,
}
: setting,
)
schedulePreview()
}
function mergePreview(preview: GameSettingsEditorState) {
if (!baseState.value || !draftState.value) return
const previousBase = cloneGameSettingsState(baseState.value)
const previousDraft = cloneGameSettingsState(draftState.value)
const dirtyIds = new Set(
gameSettingChanges(previousBase, previousDraft, touchedValueOptionIds.value).map(
(change) => change.option_id,
),
)
const stagedSettings = new Map(
previousDraft.settings
.filter((setting) => dirtyIds.has(setting.option_id))
.map((setting) => [setting.option_id, setting]),
)
const previousBaseSettings = new Map(
previousBase.settings.map((setting) => [setting.option_id, setting]),
)
const nextBase = cloneGameSettingsState(preview)
const nextDraft = cloneGameSettingsState(preview)
nextBase.settings = nextBase.settings.map((setting) =>
dirtyIds.has(setting.option_id)
? { ...(previousBaseSettings.get(setting.option_id) ?? setting) }
: setting,
)
nextDraft.settings = nextDraft.settings.map((setting): EditableGameSetting => {
const staged = stagedSettings.get(setting.option_id)
if (!staged) return setting
return {
...setting,
sync_enabled: staged.sync_enabled,
canonical_value: staged.canonical_value,
option_revision: staged.option_revision,
}
})
baseState.value = nextBase
draftState.value = nextDraft
}
async function preview() {
const request = editorRequest()
if (!request || request.changes.length === 0) {
previewGeneration++
if (baseState.value) draftState.value = cloneGameSettingsState(baseState.value)
return
}
const generation = ++previewGeneration
try {
const previewState = await previewMutation.mutateAsync({
instanceId: editorInstanceId.value,
request,
})
if (generation !== previewGeneration) return
mergePreview(previewState)
} catch {
return
}
}
function applyChangesToRefreshedState(
refreshed: GameSettingsEditorState,
stagedDraft: GameSettingsEditorState,
changes: GameSettingChange[],
touchedOptionIds: ReadonlySet<string>,
conflictOptionIds: ReadonlySet<string> = new Set(),
) {
const changesById = new Map(changes.map((change) => [change.option_id, change]))
const stagedSettings = new Map(
stagedDraft.settings.map((setting) => [setting.option_id, setting]),
)
const nextBase = cloneGameSettingsState(refreshed)
const nextDraft = cloneGameSettingsState(refreshed)
nextDraft.settings = nextDraft.settings.map((setting) => {
const change = changesById.get(setting.option_id)
const staged = stagedSettings.get(setting.option_id)
if (!change || !staged) return setting
return {
...setting,
...(change.sync_enabled !== undefined ? { sync_enabled: staged.sync_enabled } : {}),
...(change.canonical_value !== undefined
? {
canonical_value: staged.canonical_value,
value_state: staged.value_state,
}
: {}),
validation_error: conflictOptionIds.has(setting.option_id)
? 'changed_since_opened'
: staged.validation_error,
}
})
baseState.value = nextBase
draftState.value = nextDraft
touchedValueOptionIds.value = new Set(
[...touchedOptionIds].filter(
(optionId) => changesById.get(optionId)?.canonical_value !== undefined,
),
)
}
async function saveDraft() {
const request = editorRequest()
if (!request || request.changes.length === 0 || !baseState.value || !draftState.value) return
cancelPreview()
const targetInstanceId = editorInstanceId.value
const generation = ++saveGeneration
const previousBase = cloneGameSettingsState(baseState.value)
const optimisticState = cloneGameSettingsState(draftState.value)
const previouslyTouchedOptionIds = new Set(touchedValueOptionIds.value)
baseState.value = cloneGameSettingsState(optimisticState)
touchedValueOptionIds.value = new Set()
try {
await queryClient.cancelQueries({
queryKey: gameSettingsQueryOptions(targetInstanceId).queryKey,
})
const result = targetInstanceId
? await save_local_game_option_changes(targetInstanceId, request)
: await save_synced_game_option_changes(request)
const refreshed =
result.state ??
(targetInstanceId
? await get_local_game_options_config(targetInstanceId)
: await get_synced_game_options_config())
queryClient.setQueryData(gameSettingsQueryOptions(targetInstanceId).queryKey, refreshed)
if (targetInstanceId) {
void queryClient.invalidateQueries({ queryKey: gameSettingsKeys.synced })
}
if (result.conflicts?.length) {
if (generation !== saveGeneration || !draftState.value) return
previewGeneration++
const stagedDraft = cloneGameSettingsState(draftState.value)
const retainedTouchedOptionIds = new Set([
...previouslyTouchedOptionIds,
...touchedValueOptionIds.value,
])
const stagedChanges = gameSettingChanges(
previousBase,
stagedDraft,
retainedTouchedOptionIds,
)
const conflicts = new Set(result.conflicts)
applyChangesToRefreshedState(
refreshed,
stagedDraft,
stagedChanges,
retainedTouchedOptionIds,
conflicts,
)
addNotification({
type: 'warning',
title: formatMessage(messages.conflictTitle),
text: formatMessage(messages.conflictText),
})
if (isDirty.value) {
schedulePreview()
}
return
}
if (generation !== saveGeneration) {
onSaved()
return
}
if (!baseState.value || !draftState.value) return
previewGeneration++
const stagedDraft = cloneGameSettingsState(draftState.value)
const touchedSinceSave = new Set(touchedValueOptionIds.value)
const changesSinceSave = gameSettingChanges(optimisticState, stagedDraft, touchedSinceSave)
applyChangesToRefreshedState(refreshed, stagedDraft, changesSinceSave, touchedSinceSave)
if (changesSinceSave.length > 0) schedulePreview()
onSaved()
} catch (error) {
if (generation === saveGeneration && draftState.value) {
previewGeneration++
baseState.value = previousBase
touchedValueOptionIds.value = new Set([
...previouslyTouchedOptionIds,
...touchedValueOptionIds.value,
])
if (dirtyChanges.value.length > 0) schedulePreview()
}
throw error
}
}
onScopeDispose(reset)
return {
draftState,
isLocalEditor,
isDirty,
hasBlockingDraft,
loading,
loadError,
saving: saveMutation.isPending,
load,
reset,
cancelChanges,
setSyncEnabled,
setCanonicalValue,
save,
saveAndWait,
}
}
@@ -0,0 +1,138 @@
import { useDebugLogger, useVIntl } from '@modrinth/ui'
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import { computed, type MaybeRefOrGetter, onScopeDispose, shallowRef, toValue, watch } from 'vue'
import {
type EditableGameSetting,
type GameSettingLocaleLabel,
get_game_setting_locale_labels,
} from '@/helpers/game-options'
export function useGameSettingLabels(
opened: MaybeRefOrGetter<boolean>,
instanceId: MaybeRefOrGetter<string | undefined>,
settings: MaybeRefOrGetter<EditableGameSetting[]>,
) {
const debug = useDebugLogger('GameSettingLabels')
const { locale } = useVIntl()
const labels = shallowRef<Record<string, GameSettingLocaleLabel>>({})
const optionIds = computed(() =>
toValue(settings)
.filter((setting) => setting.kind === 'external')
.map((setting) => setting.option_id)
.sort(),
)
let generation = 0
let stopListening: UnlistenFn | undefined
let refreshSources = false
debug('setup', { opened: toValue(opened), instanceId: toValue(instanceId), locale: locale.value })
async function refresh(reason: string) {
if (!toValue(opened) || !optionIds.value.length) {
debug('refresh skipped', {
reason,
opened: toValue(opened),
optionCount: optionIds.value.length,
})
return
}
const request = ++generation
const reindex = refreshSources
const requestedIds = [...optionIds.value]
const started = performance.now()
refreshSources = false
debug('request started', {
request,
reason,
instanceId: toValue(instanceId),
locale: locale.value,
reindex,
optionIds: requestedIds,
})
try {
const result = await get_game_setting_locale_labels(
toValue(instanceId),
locale.value,
requestedIds,
reindex,
)
const applied = request === generation && toValue(opened)
debug('request completed', {
request,
generation,
applied,
opened: toValue(opened),
elapsedMs: Math.round(performance.now() - started),
returnedCount: Object.keys(result.settings).length,
missingOptionIds: requestedIds.filter((id) => !result.settings[id]),
labelSample: Object.entries(result.settings).slice(0, 8),
})
if (applied) labels.value = result.settings
} catch (error) {
debug('request failed', {
request,
reason,
elapsedMs: Math.round(performance.now() - started),
error,
})
}
}
watch(
() => toValue(opened),
(active, _, onCleanup) => {
debug('modal state changed', { active, generation, optionCount: optionIds.value.length })
let cancelled = false
onCleanup(() => {
cancelled = true
stopListening?.()
stopListening = undefined
generation++
labels.value = {}
debug('modal cleanup: listener removed and labels cleared', { generation })
})
if (!active) return
refreshSources = true
debug('registering locale update listener')
void listen('game-option-locales-updated', () => {
debug('received game-option-locales-updated')
void refresh('locale update event')
})
.then((unlisten) => {
debug('locale update listener registered', { cancelled })
if (cancelled) unlisten()
else {
stopListening = unlisten
void refresh('listener registered')
}
})
.catch((error) => {
debug('locale update listener failed', { cancelled, error })
if (!cancelled) void refresh('listener failed')
})
},
{ flush: 'sync' },
)
watch([locale, () => toValue(instanceId), () => optionIds.value.join('\n')], () => {
generation++
labels.value = {}
debug('locale, instance or option IDs changed: labels cleared', {
generation,
locale: locale.value,
instanceId: toValue(instanceId),
optionCount: optionIds.value.length,
})
void refresh('request inputs changed')
})
onScopeDispose(() => {
generation++
stopListening?.()
labels.value = {}
debug('scope disposed', { generation })
})
return labels
}