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

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

No files matched your search

@@ -0,0 +1,23 @@
<template>
<span class="inline-flex flex-wrap items-center gap-1">
<kbd
v-for="(part, index) in parts"
:key="`${index}-${part}`"
aria-hidden="true"
class="rounded-md border border-solid border-surface-4 bg-surface-3 px-2 py-0.5 font-mono text-sm text-[var(--color-text-primary)]"
>
{{ part }}
</kbd>
<span class="sr-only">{{ text }}</span>
</span>
</template>
<script setup lang="ts">
import { useBindingParts, useBindingText } from '../../composables/use-keybinding-labels'
import type { KeyBinding } from '../../utils/keybinding'
const props = defineProps<{ binding: KeyBinding }>()
const parts = useBindingParts(() => props.binding)
const text = useBindingText(() => props.binding)
</script>
+1
View File
@@ -118,3 +118,4 @@ export { default as TimeFramePicker } from './TimeFramePicker.vue'
export { default as Timeline } from './Timeline.vue'
export { default as Toggle } from './Toggle.vue'
export { default as UnsavedChangesPopup } from './UnsavedChangesPopup.vue'
export { default as KeybindingChips } from './KeybindingChips.vue'
@@ -0,0 +1,256 @@
<template>
<div
ref="treeRef"
role="tree"
:aria-label="formatMessage(messages.treeLabel)"
class="max-h-[18rem] overflow-y-auto rounded-lg border border-solid border-surface-4 bg-surface-2 p-1"
@keydown="handleKeydown"
>
<div
v-for="(row, index) in visibleRows"
:key="row.id"
:ref="(element) => setRowRef(row.id, element)"
role="treeitem"
:tabindex="index === activeIndex ? 0 : -1"
:aria-level="row.level + 1"
:aria-expanded="row.expandable ? row.expanded : undefined"
:aria-selected="row.kind === 'item' ? row.selected : undefined"
:aria-disabled="row.disabled ? true : undefined"
class="flex cursor-pointer items-center gap-2 rounded-md py-2 pr-2 text-sm transition-colors duration-150"
:class="[
row.disabled ? 'cursor-not-allowed opacity-50' : 'hover:bg-surface-3',
row.selected
? 'bg-brand-highlight text-[var(--color-text-primary)]'
: 'text-[var(--color-text-default)]',
row.kind === 'item' ? '' : 'font-semibold text-[var(--color-text-primary)]',
]"
:style="{ paddingLeft: `${row.level * 1.25 + 0.5}rem` }"
@click="activateRow(row)"
@focus="activeIndex = index"
>
<ChevronRightIcon
v-if="row.expandable"
class="h-4 w-4 shrink-0 text-[var(--color-text-tertiary)] transition-transform duration-150"
:class="{ 'rotate-90': row.expanded }"
aria-hidden="true"
/>
<span v-else class="w-4 shrink-0" aria-hidden="true" />
<span class="min-w-0 flex-1 truncate">{{ row.label }}</span>
<span v-if="row.disabledReason" class="shrink-0 text-xs text-[var(--color-text-tertiary)]">
{{ row.disabledReason }}
</span>
</div>
</div>
</template>
<script setup lang="ts">
import { ChevronRightIcon } from '@modrinth/assets'
import { computed, nextTick, ref } from 'vue'
import { defineMessages, useVIntl } from '../../composables/i18n'
import { type KeyBinding, type KeyBindingDevice, keyCodeLabel } from '../../utils/keybinding'
import { KEYBINDING_CATALOG, type KeybindingCatalogItem } from '../../utils/keybinding-catalog'
const props = withDefaults(
defineProps<{
/** The combination being assembled, used to mark the chosen input. */
binding: KeyBinding | null
/** Reasons an input may not be used, keyed by code. */
disabledCodes?: Map<string, string>
}>(),
{ disabledCodes: () => new Map() },
)
const emit = defineEmits<{
select: [{ device: KeyBindingDevice; code: string }]
escape: []
}>()
const { formatMessage } = useVIntl()
const messages = defineMessages({
treeLabel: {
id: 'ui.keybinding-modal.tree-label',
defaultMessage: 'Inputs a shortcut can use',
},
})
interface TreeRow {
id: string
kind: 'device' | 'group' | 'item'
level: number
label: string
expandable: boolean
expanded: boolean
selected: boolean
disabled: boolean
disabledReason?: string
item?: KeybindingCatalogItem
}
const expanded = ref(new Set<string>(['keyboard']))
const activeIndex = ref(0)
const rowRefs = new Map<string, HTMLElement>()
function groupRowId(deviceId: string, groupId: string) {
return `${deviceId}:${groupId}`
}
function itemRowId(deviceId: string, groupId: string, code: string) {
return `${deviceId}:${groupId}:${code}`
}
function itemLabel(item: KeybindingCatalogItem) {
return item.label ? formatMessage(item.label) : keyCodeLabel(item.code)
}
function itemState(item: KeybindingCatalogItem) {
const reason = props.disabledCodes.get(item.code)
return { disabled: Boolean(reason), disabledReason: reason }
}
const visibleRows = computed<TreeRow[]>(() => {
const rows: TreeRow[] = []
for (const device of KEYBINDING_CATALOG) {
const deviceExpanded = expanded.value.has(device.id)
rows.push({
id: device.id,
kind: 'device',
level: 0,
label: formatMessage(device.label),
expandable: true,
expanded: deviceExpanded,
selected: false,
disabled: false,
})
if (!deviceExpanded) continue
for (const group of device.groups) {
const rowId = groupRowId(device.id, group.id)
const groupExpanded = expanded.value.has(rowId)
rows.push({
id: rowId,
kind: 'group',
level: 1,
label: formatMessage(group.label),
expandable: true,
expanded: groupExpanded,
selected: false,
disabled: false,
})
if (!groupExpanded) continue
for (const item of group.items) {
const state = itemState(item)
rows.push({
id: itemRowId(device.id, group.id, item.code),
kind: 'item',
level: 2,
label: itemLabel(item),
expandable: false,
expanded: false,
selected: props.binding?.device === item.device && props.binding?.code === item.code,
item,
...state,
})
}
}
}
return rows
})
function setRowRef(id: string, element: unknown) {
if (element instanceof HTMLElement) rowRefs.set(id, element)
else rowRefs.delete(id)
}
function focusRow(index: number) {
const row = visibleRows.value[index]
if (!row) return
activeIndex.value = index
void nextTick(() => rowRefs.get(row.id)?.focus())
}
function toggle(row: TreeRow) {
if (!row.expandable) return
const next = new Set(expanded.value)
if (next.has(row.id)) next.delete(row.id)
else next.add(row.id)
expanded.value = next
// Collapsing can leave the active row past the end, which would stop the
// keyboard from moving anywhere.
const lastIndex = visibleRows.value.length - 1
if (activeIndex.value > lastIndex) focusRow(lastIndex)
}
function parentIndexOf(row: TreeRow) {
for (let index = visibleRows.value.indexOf(row) - 1; index >= 0; index -= 1) {
if (visibleRows.value[index].level < row.level) return index
}
return -1
}
function activateRow(row: TreeRow) {
if (row.kind === 'item') {
if (row.disabled || !row.item) return
emit('select', { device: row.item.device, code: row.item.code })
return
}
toggle(row)
}
function handleKeydown(event: KeyboardEvent) {
const row = visibleRows.value[activeIndex.value]
if (!row) return
switch (event.key) {
case 'ArrowDown':
event.preventDefault()
focusRow(Math.min(activeIndex.value + 1, visibleRows.value.length - 1))
break
case 'ArrowUp':
event.preventDefault()
focusRow(Math.max(activeIndex.value - 1, 0))
break
case 'ArrowRight':
event.preventDefault()
if (row.expandable && !row.expanded) toggle(row)
else if (row.expandable) focusRow(activeIndex.value + 1)
break
case 'ArrowLeft': {
event.preventDefault()
if (row.expandable && row.expanded) {
toggle(row)
break
}
const parent = parentIndexOf(row)
if (parent !== -1) focusRow(parent)
break
}
case 'Home':
event.preventDefault()
focusRow(0)
break
case 'End':
event.preventDefault()
focusRow(visibleRows.value.length - 1)
break
case 'Enter':
case ' ':
event.preventDefault()
activateRow(row)
break
case 'Escape':
// Handled here so escape leaves the picker instead of the whole dialog.
event.preventDefault()
event.stopPropagation()
emit('escape')
break
default:
break
}
}
</script>
@@ -0,0 +1,574 @@
<template>
<NewModal
ref="modal"
:header="headerText"
max-width="540px"
:on-hide="handleModalHide"
:close-on-click-outside="false"
>
<div class="flex min-w-0 flex-col gap-4">
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex min-w-0 flex-col gap-1">
<span class="text-sm text-[var(--color-text-tertiary)]">{{
formatMessage(messages.current)
}}</span>
<KeybindingChips v-if="props.binding" :binding="props.binding" />
<span v-else class="text-sm text-[var(--color-text-tertiary)]">{{
formatMessage(messages.unset)
}}</span>
</div>
<Button v-if="canRestoreDefault" type="outlined" size="2xs" @click="restoreDefault"
>{{ formatMessage(messages.restoreDefault) }}
</Button>
</div>
<button
v-if="phase !== 'tree'"
type="button"
class="flex min-h-[7rem] cursor-pointer flex-col items-center justify-center gap-3 rounded-xl border-2 border-dashed p-4 transition-colors duration-200"
:class="
phase === 'listening'
? 'border-brand bg-brand-highlight'
: 'border-surface-4 bg-surface-2 hover:border-brand'
"
:disabled="phase !== 'idle'"
@click="beginListening"
>
<template v-if="phase === 'listening'">
<span class="text-base font-semibold text-[var(--color-text-primary)]">
{{ formatMessage(messages.listeningTitle) }}
</span>
<span v-if="liveParts.length" class="flex flex-wrap items-center justify-center gap-1">
<kbd
v-for="(part, index) in liveParts"
:key="`${index}-${part}`"
class="rounded-md border border-solid border-surface-4 bg-surface-3 px-2 py-0.5 font-mono text-sm text-[var(--color-text-primary)]"
>
{{ part }}
</kbd>
</span>
<span v-else class="text-sm text-[var(--color-text-tertiary)]">
{{ formatMessage(messages.listeningHint) }}
</span>
<span class="block h-1 w-full max-w-[16rem] overflow-hidden rounded-full bg-surface-4">
<span
:key="captureRun"
class="keybinding-capture-bar block h-full rounded-full bg-brand"
:style="{ animationDuration: `${props.listenDurationMs}ms` }"
/>
</span>
</template>
<template v-else-if="phase === 'verifying'">
<span class="text-base font-semibold text-[var(--color-text-primary)]">
{{ formatMessage(verified ? messages.verifiedTitle : messages.verifyingTitle) }}
</span>
<KeybindingChips v-if="candidate" :binding="candidate" />
<span v-if="!verified" class="text-sm text-[var(--color-text-tertiary)]">
{{ formatMessage(messages.verifyingHint) }}
</span>
</template>
<template v-else>
<span class="text-base font-semibold text-[var(--color-text-primary)]">
{{ formatMessage(messages.captureTitle) }}
</span>
<span class="text-sm text-[var(--color-text-tertiary)]">{{
formatMessage(messages.captureHint)
}}</span>
</template>
</button>
<template v-else>
<div
class="flex flex-col gap-2 rounded-xl border border-solid border-surface-4 bg-surface-2 p-3"
>
<span class="text-sm font-semibold text-[var(--color-text-primary)]">
{{ formatMessage(messages.modifiers) }}
</span>
<div class="flex flex-wrap gap-2">
<Button
v-for="modifier in modifierOptions"
:key="modifier.field"
size="2xs"
:type="modifierState[modifier.field] ? 'highlight' : 'outlined'"
:aria-pressed="modifierState[modifier.field]"
@click="modifierState[modifier.field] = !modifierState[modifier.field]"
>
{{ modifier.label }}
</Button>
</div>
</div>
<KeybindingChoiceTree
:binding="treeBinding"
:disabled-codes="treeDisabledCodes"
@select="selectTreeInput"
@escape="phase = 'idle'"
/>
</template>
<div class="flex flex-col gap-1" aria-live="polite">
<p v-if="issueText" class="m-0 text-sm text-red">{{ issueText }}</p>
<p v-else-if="mismatchText" class="m-0 text-sm text-orange">{{ mismatchText }}</p>
<p v-else-if="verified" class="m-0 text-sm text-green">
{{ formatMessage(messages.verifiedHint) }}
</p>
<p v-else-if="nothingCapturedText" class="m-0 text-sm text-[var(--color-text-tertiary)]">
{{ nothingCapturedText }}
</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<Button v-if="phase === 'tree'" type="outlined" @click="leaveTree"
>{{ formatMessage(messages.useCapture) }}
</Button>
<Button v-else type="outlined" @click="enterTree"
>{{ formatMessage(messages.useSpecial) }}
</Button>
<span v-if="phase === 'tree'" class="text-xs text-[var(--color-text-tertiary)]">
{{ formatMessage(messages.specialHint) }}
</span>
</div>
</div>
<template #actions>
<div class="flex justify-end gap-2">
<Button type="outlined" @click="cancel">{{ formatMessage(messages.cancel) }}</Button>
<Button type="colored" color="brand" :disabled="!canSave" @click="save"
>{{ formatMessage(messages.save) }}
</Button>
</div>
</template>
</NewModal>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { defineMessages, useVIntl } from '../../composables/i18n'
import { useKeybindingLabels } from '../../composables/use-keybinding-labels'
import {
type BindingConflict,
bindingFromKeyboardEvent,
bindingsEqual,
isModifierCode,
type KeyBinding,
keyCodeLabel,
validateBinding,
} from '../../utils/keybinding'
import { KEYBINDING_CATALOG } from '../../utils/keybinding-catalog'
import Button from '../base/buttons/Button.vue'
import KeybindingChips from '../base/KeybindingChips.vue'
import KeybindingChoiceTree from './KeybindingChoiceTree.vue'
import NewModal from './NewModal.vue'
const props = withDefaults(
defineProps<{
/** The action being configured, shown in the title. */
actionLabel?: string
/** The combination the action answers to right now. */
binding?: KeyBinding | null
/** The combination the action ships with, offered as a way back. */
defaultBinding?: KeyBinding | null
/** Combinations that belong to something else. */
conflicts?: BindingConflict[]
/** How long a recording may run before it settles on its own. */
listenDurationMs?: number
/** How long after the last key is released a recording settles. */
settleMs?: number
}>(),
{
actionLabel: undefined,
binding: null,
defaultBinding: null,
conflicts: () => [],
listenDurationMs: 5000,
settleMs: 700,
},
)
const emit = defineEmits<{
save: [binding: KeyBinding]
cancel: []
}>()
const { formatMessage } = useVIntl()
const labels = useKeybindingLabels()
const messages = defineMessages({
title: { id: 'ui.keybinding-modal.title', defaultMessage: 'Set a shortcut' },
titleFor: {
id: 'ui.keybinding-modal.title-for',
defaultMessage: 'Shortcut for {action}',
},
current: { id: 'ui.keybinding-modal.current', defaultMessage: 'Current shortcut' },
unset: { id: 'ui.keybinding-modal.unset', defaultMessage: 'Not set' },
restoreDefault: {
id: 'ui.keybinding-modal.restore-default',
defaultMessage: 'Restore default',
},
captureTitle: {
id: 'ui.keybinding-modal.capture-title',
defaultMessage: 'Click here to record',
},
captureHint: {
id: 'ui.keybinding-modal.capture-hint',
defaultMessage: 'Then press the keys you want this shortcut to use.',
},
listeningTitle: {
id: 'ui.keybinding-modal.listening-title',
defaultMessage: 'Listening\u2026',
},
listeningHint: {
id: 'ui.keybinding-modal.listening-hint',
defaultMessage: 'Press the keys you want to use.',
},
verifyingTitle: {
id: 'ui.keybinding-modal.verifying-title',
defaultMessage: 'Press it again to confirm',
},
verifyingHint: {
id: 'ui.keybinding-modal.verifying-hint',
defaultMessage: 'This checks the combination was recorded correctly.',
},
verifiedTitle: {
id: 'ui.keybinding-modal.verified-title',
defaultMessage: 'Confirmed',
},
verifiedHint: {
id: 'ui.keybinding-modal.verified-hint',
defaultMessage: 'Confirmed. Save to use this shortcut.',
},
issueConflict: {
id: 'ui.keybinding-modal.issue-conflict',
defaultMessage: 'Already used by {label}.',
},
issueReserved: {
id: 'ui.keybinding-modal.issue-reserved',
defaultMessage: '{label} is reserved by the launcher.',
},
issueNeedsModifier: {
id: 'ui.keybinding-modal.issue-needs-modifier',
defaultMessage: 'Hold Ctrl/Cmd, Alt or Shift as well, or pick a function or navigation key.',
},
mismatch: {
id: 'ui.keybinding-modal.mismatch',
defaultMessage: 'That was a different combination, so recording started again.',
},
nothingCaptured: {
id: 'ui.keybinding-modal.nothing-captured',
defaultMessage: 'No key was recorded.',
},
useSpecial: {
id: 'ui.keybinding-modal.use-special',
defaultMessage: 'Pick an input another way',
},
useCapture: {
id: 'ui.keybinding-modal.use-capture',
defaultMessage: 'Record a combination instead',
},
specialHint: {
id: 'ui.keybinding-modal.special-hint',
defaultMessage: 'Mouse buttons, the wheel and combinations with them can only be picked here.',
},
modifiers: { id: 'ui.keybinding-modal.modifiers', defaultMessage: 'Modifiers' },
save: { id: 'ui.keybinding-modal.save', defaultMessage: 'Save' },
cancel: { id: 'ui.keybinding-modal.cancel', defaultMessage: 'Cancel' },
})
type Phase = 'idle' | 'listening' | 'verifying' | 'tree'
const modal = ref<InstanceType<typeof NewModal>>()
const phase = ref<Phase>('idle')
const candidate = ref<KeyBinding | null>(null)
const livePressed = ref<KeyBinding | null>(null)
const captured = ref<KeyBinding | null>(null)
const mismatch = ref<KeyBinding | null>(null)
const nothingCaptured = ref(false)
const verified = ref(false)
const captureRun = ref(0)
const treeInput = ref<{ device: KeyBinding['device']; code: string } | null>(null)
const modifierState = reactive({ mod: false, shift: false, alt: false })
let listenTimer: number | undefined
let settleTimer: number | undefined
const headerText = computed(() =>
props.actionLabel
? formatMessage(messages.titleFor, { action: props.actionLabel })
: formatMessage(messages.title),
)
const modifierOptions = computed(() => [
{ field: 'mod' as const, label: labels.value.mod },
{ field: 'alt' as const, label: labels.value.alt },
{ field: 'shift' as const, label: labels.value.shift },
])
const liveParts = computed(() => {
const pressed = livePressed.value
if (!pressed) return []
const parts: string[] = []
if (pressed.mod) parts.push(labels.value.mod)
if (pressed.alt) parts.push(labels.value.alt)
if (pressed.shift) parts.push(labels.value.shift)
if (!isModifierCode(pressed.code)) parts.push(keyCodeLabel(pressed.code))
return parts
})
const treeBinding = computed<KeyBinding | null>(() =>
treeInput.value ? { ...treeInput.value, ...modifierState } : null,
)
const proposed = computed(() => (phase.value === 'tree' ? treeBinding.value : candidate.value))
const canRestoreDefault = computed(
() =>
Boolean(props.defaultBinding) &&
(!props.binding || !bindingsEqual(props.binding, props.defaultBinding as KeyBinding)),
)
const issue = computed(() =>
proposed.value ? validateBinding(proposed.value, props.conflicts) : null,
)
const issueText = computed(() => {
const value = issue.value
if (!value) return null
if (value.kind === 'needs-modifier') return formatMessage(messages.issueNeedsModifier)
return formatMessage(value.reserved ? messages.issueReserved : messages.issueConflict, {
label: value.label,
})
})
const mismatchText = computed(() => (mismatch.value ? formatMessage(messages.mismatch) : null))
const nothingCapturedText = computed(() =>
nothingCaptured.value ? formatMessage(messages.nothingCaptured) : null,
)
const isVerified = computed(() => phase.value === 'tree' || verified.value)
const canSave = computed(() => Boolean(proposed.value) && !issue.value && isVerified.value)
/** Inputs the picker must refuse, keyed by code, with the reason to show. */
const treeDisabledCodes = computed(() => {
const disabled = new Map<string, string>()
for (const device of KEYBINDING_CATALOG) {
for (const group of device.groups) {
for (const item of group.items) {
const found = validateBinding(
{ device: item.device, code: item.code, ...modifierState },
props.conflicts,
)
if (found?.kind === 'conflict') {
disabled.set(
item.code,
formatMessage(found.reserved ? messages.issueReserved : messages.issueConflict, {
label: found.label,
}),
)
}
}
}
}
return disabled
})
function clearTimers() {
window.clearTimeout(listenTimer)
window.clearTimeout(settleTimer)
listenTimer = undefined
settleTimer = undefined
}
function beginListening() {
clearTimers()
candidate.value = null
livePressed.value = null
captured.value = null
nothingCaptured.value = false
verified.value = false
captureRun.value += 1
phase.value = 'listening'
listenTimer = window.setTimeout(finishListening, props.listenDurationMs)
}
function finishListening() {
clearTimers()
if (!captured.value) {
nothingCaptured.value = true
phase.value = 'idle'
return
}
candidate.value = captured.value
mismatch.value = null
verified.value = false
phase.value = 'verifying'
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
// Leaving the recording is a step back, not closing the dialog.
event.preventDefault()
// Immediate: the dialog's own escape handler sits on the same node.
event.stopImmediatePropagation()
clearTimers()
phase.value = 'idle'
return
}
if (phase.value === 'listening') {
event.preventDefault()
event.stopImmediatePropagation()
livePressed.value = {
device: 'keyboard',
code: event.code,
mod: event.ctrlKey || event.metaKey,
shift: event.shiftKey,
alt: event.altKey,
}
const combination = bindingFromKeyboardEvent(event)
if (combination) {
captured.value = combination
window.clearTimeout(settleTimer)
settleTimer = undefined
}
return
}
if (phase.value === 'verifying') {
event.preventDefault()
event.stopImmediatePropagation()
const attempt = bindingFromKeyboardEvent(event)
if (!attempt || !candidate.value) return
if (bindingsEqual(attempt, candidate.value)) {
verified.value = true
mismatch.value = null
return
}
mismatch.value = attempt
beginListening()
}
}
function handleKeyup(event: KeyboardEvent) {
if (phase.value !== 'listening') return
event.stopPropagation()
const stillHeld = event.ctrlKey || event.metaKey || event.shiftKey || event.altKey
if (stillHeld || !captured.value) return
// Nothing is held any more, so the combination is as complete as it gets.
window.clearTimeout(settleTimer)
settleTimer = window.setTimeout(finishListening, props.settleMs)
}
function attachListeners() {
window.addEventListener('keydown', handleKeydown, true)
window.addEventListener('keyup', handleKeyup, true)
}
function detachListeners() {
window.removeEventListener('keydown', handleKeydown, true)
window.removeEventListener('keyup', handleKeyup, true)
}
watch(phase, (value) => {
if (value === 'listening' || value === 'verifying') attachListeners()
else detachListeners()
})
onBeforeUnmount(() => {
clearTimers()
detachListeners()
})
function enterTree() {
const source = candidate.value ?? props.binding
modifierState.mod = source?.mod ?? false
modifierState.alt = source?.alt ?? false
modifierState.shift = source?.shift ?? false
treeInput.value = source ? { device: source.device, code: source.code } : null
phase.value = 'tree'
}
function leaveTree() {
treeInput.value = null
phase.value = 'idle'
}
function selectTreeInput(input: { device: KeyBinding['device']; code: string }) {
treeInput.value = input
}
function restoreDefault() {
if (!props.defaultBinding) return
candidate.value = props.defaultBinding
mismatch.value = null
nothingCaptured.value = false
verified.value = true
phase.value = 'verifying'
}
function reset() {
clearTimers()
phase.value = 'idle'
candidate.value = null
livePressed.value = null
captured.value = null
mismatch.value = null
nothingCaptured.value = false
verified.value = false
treeInput.value = null
modifierState.mod = false
modifierState.alt = false
modifierState.shift = false
}
function handleModalHide() {
reset()
}
function show() {
reset()
modal.value?.show()
}
function hide() {
modal.value?.hide()
}
function save() {
const value = proposed.value
if (!value || !canSave.value) return
emit('save', value)
modal.value?.hide()
}
function cancel() {
emit('cancel')
modal.value?.hide()
}
defineExpose({ show, hide })
</script>
<style scoped>
@keyframes keybinding-capture-drain {
from {
width: 100%;
}
to {
width: 0%;
}
}
.keybinding-capture-bar {
animation-name: keybinding-capture-drain;
animation-timing-function: linear;
animation-fill-mode: forwards;
}
@media (prefers-reduced-motion: reduce) {
.keybinding-capture-bar {
animation: none;
width: 100%;
}
}
</style>
@@ -8,3 +8,4 @@ export { default as ShareModal } from './ShareModal.vue'
export type { Tab as TabbedModalTab } from './TabbedModal.vue'
export { default as TabbedModal } from './TabbedModal.vue'
export { default as UploadProgressModal } from './UploadProgressModal.vue'
export { default as KeybindingModal } from './KeybindingModal.vue'
+1
View File
@@ -30,3 +30,4 @@ export * from './use-loading-state-core'
export * from './use-ready-state'
export * from './use-server-image'
export * from './virtual-scroll'
export * from './use-keybinding-labels'
@@ -0,0 +1,54 @@
import { computed } from 'vue'
import {
bindingParts,
bindingText,
type KeyBinding,
type KeybindingLabels,
} from '../utils/keybinding'
import { MOUSE_CODE_MESSAGES } from '../utils/keybinding-catalog'
import { defineMessages, useVIntl } from './i18n'
const messages = defineMessages({
mod: { id: 'ui.keybinding-modal.mod', defaultMessage: 'Ctrl/Cmd' },
shift: { id: 'ui.keybinding-modal.shift', defaultMessage: 'Shift' },
alt: { id: 'ui.keybinding-modal.alt', defaultMessage: 'Alt' },
})
/**
* The names a combination is built from, in the reader's language. Shared so a
* shortcut reads the same wherever it is shown.
*/
export function useKeybindingLabels() {
const { formatMessage } = useVIntl()
return computed<KeybindingLabels>(() => ({
mod: formatMessage(messages.mod),
shift: formatMessage(messages.shift),
alt: formatMessage(messages.alt),
mouseButton: Object.fromEntries(
Object.entries(MOUSE_CODE_MESSAGES).map(([code, message]) => [code, formatMessage(message)]),
),
wheelUp: formatMessage(MOUSE_CODE_MESSAGES.WheelUp),
wheelDown: formatMessage(MOUSE_CODE_MESSAGES.WheelDown),
}))
}
/** Splits a combination into the pieces a key cap is drawn for. */
export function useBindingParts(binding: () => KeyBinding | null) {
const labels = useKeybindingLabels()
return computed(() => {
const value = binding()
return value ? bindingParts(value, labels.value) : []
})
}
export function useBindingText(binding: () => KeyBinding | null) {
const labels = useKeybindingLabels()
return computed(() => {
const value = binding()
return value ? bindingText(value, labels.value) : ''
})
}
+45 -1
View File
@@ -4861,5 +4861,49 @@
},
"skin.preview.armor.unequipped": {
"defaultMessage": "Unequipped"
}
},
"ui.keybinding-modal.alt": {"defaultMessage": "Alt"},
"ui.keybinding-modal.cancel": {"defaultMessage": "Cancel"},
"ui.keybinding-modal.capture-hint": {"defaultMessage": "Then press the keys you want this shortcut to use."},
"ui.keybinding-modal.capture-title": {"defaultMessage": "Click here to record"},
"ui.keybinding-modal.current": {"defaultMessage": "Current shortcut"},
"ui.keybinding-modal.device-keyboard": {"defaultMessage": "Keyboard"},
"ui.keybinding-modal.device-mouse": {"defaultMessage": "Mouse"},
"ui.keybinding-modal.group-digits": {"defaultMessage": "Digits"},
"ui.keybinding-modal.group-function": {"defaultMessage": "Function keys"},
"ui.keybinding-modal.group-letters": {"defaultMessage": "Letters"},
"ui.keybinding-modal.group-mouse-buttons": {"defaultMessage": "Buttons"},
"ui.keybinding-modal.group-navigation": {"defaultMessage": "Navigation keys"},
"ui.keybinding-modal.group-punctuation": {"defaultMessage": "Punctuation"},
"ui.keybinding-modal.group-wheel": {"defaultMessage": "Wheel"},
"ui.keybinding-modal.issue-conflict": {"defaultMessage": "Already used by {label}."},
"ui.keybinding-modal.issue-needs-modifier": {"defaultMessage": "Hold Ctrl/Cmd, Alt or Shift as well, or pick a function or navigation key."},
"ui.keybinding-modal.issue-reserved": {"defaultMessage": "{label} is reserved by the launcher."},
"ui.keybinding-modal.listening-hint": {"defaultMessage": "Press the keys you want to use."},
"ui.keybinding-modal.listening-title": {"defaultMessage": "Listening…"},
"ui.keybinding-modal.mismatch": {"defaultMessage": "That was a different combination, so recording started again."},
"ui.keybinding-modal.mod": {"defaultMessage": "Ctrl/Cmd"},
"ui.keybinding-modal.modifiers": {"defaultMessage": "Modifiers"},
"ui.keybinding-modal.mouse-back": {"defaultMessage": "Back button"},
"ui.keybinding-modal.mouse-forward": {"defaultMessage": "Forward button"},
"ui.keybinding-modal.mouse-left": {"defaultMessage": "Left click"},
"ui.keybinding-modal.mouse-middle": {"defaultMessage": "Middle click"},
"ui.keybinding-modal.mouse-right": {"defaultMessage": "Right click"},
"ui.keybinding-modal.nothing-captured": {"defaultMessage": "No key was recorded."},
"ui.keybinding-modal.restore-default": {"defaultMessage": "Restore default"},
"ui.keybinding-modal.save": {"defaultMessage": "Save"},
"ui.keybinding-modal.shift": {"defaultMessage": "Shift"},
"ui.keybinding-modal.special-hint": {"defaultMessage": "Mouse buttons, the wheel and combinations with them can only be picked here."},
"ui.keybinding-modal.title": {"defaultMessage": "Set a shortcut"},
"ui.keybinding-modal.title-for": {"defaultMessage": "Shortcut for {action}"},
"ui.keybinding-modal.tree-label": {"defaultMessage": "Inputs a shortcut can use"},
"ui.keybinding-modal.unset": {"defaultMessage": "Not set"},
"ui.keybinding-modal.use-capture": {"defaultMessage": "Record a combination instead"},
"ui.keybinding-modal.use-special": {"defaultMessage": "Pick an input another way"},
"ui.keybinding-modal.verified-hint": {"defaultMessage": "Confirmed. Save to use this shortcut."},
"ui.keybinding-modal.verified-title": {"defaultMessage": "Confirmed"},
"ui.keybinding-modal.verifying-hint": {"defaultMessage": "This checks the combination was recorded correctly."},
"ui.keybinding-modal.verifying-title": {"defaultMessage": "Press it again to confirm"},
"ui.keybinding-modal.wheel-down": {"defaultMessage": "Wheel down"},
"ui.keybinding-modal.wheel-up": {"defaultMessage": "Wheel up"}
}
+45 -1
View File
@@ -4882,5 +4882,49 @@
},
"skin.preview.armor.unequipped": {
"message": "未装备"
}
},
"ui.keybinding-modal.alt": {"defaultMessage": "Alt"},
"ui.keybinding-modal.cancel": {"defaultMessage": "取消"},
"ui.keybinding-modal.capture-hint": {"defaultMessage": "然后按下你想让这个快捷键使用的按键。"},
"ui.keybinding-modal.capture-title": {"defaultMessage": "点击此处开始录制"},
"ui.keybinding-modal.current": {"defaultMessage": "当前快捷键"},
"ui.keybinding-modal.device-keyboard": {"defaultMessage": "键盘"},
"ui.keybinding-modal.device-mouse": {"defaultMessage": "鼠标"},
"ui.keybinding-modal.group-digits": {"defaultMessage": "数字键"},
"ui.keybinding-modal.group-function": {"defaultMessage": "功能键"},
"ui.keybinding-modal.group-letters": {"defaultMessage": "字母键"},
"ui.keybinding-modal.group-mouse-buttons": {"defaultMessage": "鼠标键"},
"ui.keybinding-modal.group-navigation": {"defaultMessage": "导航键"},
"ui.keybinding-modal.group-punctuation": {"defaultMessage": "符号键"},
"ui.keybinding-modal.group-wheel": {"defaultMessage": "滚轮"},
"ui.keybinding-modal.issue-conflict": {"defaultMessage": "已被「{label}」占用。"},
"ui.keybinding-modal.issue-needs-modifier": {"defaultMessage": "请同时按住 Ctrl/Cmd、Alt 或 Shift,或改选功能键、导航键。"},
"ui.keybinding-modal.issue-reserved": {"defaultMessage": "「{label}」由启动器保留。"},
"ui.keybinding-modal.listening-hint": {"defaultMessage": "请按下你想使用的按键。"},
"ui.keybinding-modal.listening-title": {"defaultMessage": "正在监听…"},
"ui.keybinding-modal.mismatch": {"defaultMessage": "组合不一致,已重新开始录制。"},
"ui.keybinding-modal.mod": {"defaultMessage": "Ctrl/Cmd"},
"ui.keybinding-modal.modifiers": {"defaultMessage": "修饰键"},
"ui.keybinding-modal.mouse-back": {"defaultMessage": "后退键"},
"ui.keybinding-modal.mouse-forward": {"defaultMessage": "前进键"},
"ui.keybinding-modal.mouse-left": {"defaultMessage": "鼠标左键"},
"ui.keybinding-modal.mouse-middle": {"defaultMessage": "鼠标中键"},
"ui.keybinding-modal.mouse-right": {"defaultMessage": "鼠标右键"},
"ui.keybinding-modal.nothing-captured": {"defaultMessage": "没有录制到按键。"},
"ui.keybinding-modal.restore-default": {"defaultMessage": "恢复默认"},
"ui.keybinding-modal.save": {"defaultMessage": "保存"},
"ui.keybinding-modal.shift": {"defaultMessage": "Shift"},
"ui.keybinding-modal.special-hint": {"defaultMessage": "鼠标键、滚轮以及与它们组合的按键只能在这里选择。"},
"ui.keybinding-modal.title": {"defaultMessage": "设置快捷键"},
"ui.keybinding-modal.title-for": {"defaultMessage": "设置「{action}」的快捷键"},
"ui.keybinding-modal.tree-label": {"defaultMessage": "快捷键可用的输入"},
"ui.keybinding-modal.unset": {"defaultMessage": "未设置"},
"ui.keybinding-modal.use-capture": {"defaultMessage": "改用按键录制"},
"ui.keybinding-modal.use-special": {"defaultMessage": "改用特殊方式选择"},
"ui.keybinding-modal.verified-hint": {"defaultMessage": "已确认,保存后生效。"},
"ui.keybinding-modal.verified-title": {"defaultMessage": "已确认"},
"ui.keybinding-modal.verifying-hint": {"defaultMessage": "用于确认组合是否被正确录制。"},
"ui.keybinding-modal.verifying-title": {"defaultMessage": "请再按一次以确认"},
"ui.keybinding-modal.wheel-down": {"defaultMessage": "滚轮向下"},
"ui.keybinding-modal.wheel-up": {"defaultMessage": "滚轮向上"}
}
+2
View File
@@ -16,3 +16,5 @@ export * from './truncate'
export * from './version-compatibility'
export * from './vue-children'
export * from './webgl/skin-rendering'
export * from './keybinding'
export * from './keybinding-catalog'
+149
View File
@@ -0,0 +1,149 @@
import { defineMessages } from '../composables/i18n'
import type { KeyBindingDevice } from './keybinding'
/** Structurally identical to the i18n `MessageDescriptor`, without the dependency. */
export interface CatalogMessage {
id: string
defaultMessage: string
}
export interface KeybindingCatalogItem {
device: KeyBindingDevice
code: string
/** Set for inputs whose name has to be translated; key caps read the same everywhere. */
label?: CatalogMessage
}
export interface KeybindingCatalogGroup {
id: string
label: CatalogMessage
items: KeybindingCatalogItem[]
}
export interface KeybindingCatalogDevice {
id: KeyBindingDevice
label: CatalogMessage
groups: KeybindingCatalogGroup[]
}
const messages = defineMessages({
mouseLeft: { id: 'ui.keybinding-modal.mouse-left', defaultMessage: 'Left click' },
mouseMiddle: { id: 'ui.keybinding-modal.mouse-middle', defaultMessage: 'Middle click' },
mouseRight: { id: 'ui.keybinding-modal.mouse-right', defaultMessage: 'Right click' },
mouseBack: { id: 'ui.keybinding-modal.mouse-back', defaultMessage: 'Back button' },
mouseForward: { id: 'ui.keybinding-modal.mouse-forward', defaultMessage: 'Forward button' },
wheelUp: { id: 'ui.keybinding-modal.wheel-up', defaultMessage: 'Wheel up' },
wheelDown: { id: 'ui.keybinding-modal.wheel-down', defaultMessage: 'Wheel down' },
deviceKeyboard: { id: 'ui.keybinding-modal.device-keyboard', defaultMessage: 'Keyboard' },
deviceMouse: { id: 'ui.keybinding-modal.device-mouse', defaultMessage: 'Mouse' },
groupLetters: { id: 'ui.keybinding-modal.group-letters', defaultMessage: 'Letters' },
groupDigits: { id: 'ui.keybinding-modal.group-digits', defaultMessage: 'Digits' },
groupFunction: { id: 'ui.keybinding-modal.group-function', defaultMessage: 'Function keys' },
groupNavigation: {
id: 'ui.keybinding-modal.group-navigation',
defaultMessage: 'Navigation keys',
},
groupPunctuation: {
id: 'ui.keybinding-modal.group-punctuation',
defaultMessage: 'Punctuation',
},
groupMouseButtons: {
id: 'ui.keybinding-modal.group-mouse-buttons',
defaultMessage: 'Buttons',
},
groupWheel: { id: 'ui.keybinding-modal.group-wheel', defaultMessage: 'Wheel' },
})
/**
* Names for the pointer inputs. Shared by the picker tree and by the chips that
* render a saved combination, so both read the same way.
*/
export const MOUSE_CODE_MESSAGES: Record<string, CatalogMessage> = {
Mouse0: messages.mouseLeft,
Mouse1: messages.mouseMiddle,
Mouse2: messages.mouseRight,
Mouse3: messages.mouseBack,
Mouse4: messages.mouseForward,
WheelUp: messages.wheelUp,
WheelDown: messages.wheelDown,
}
const LETTER_CODES: KeybindingCatalogItem[] = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
.split('')
.map((letter) => ({ device: 'keyboard', code: `Key${letter}` }))
const DIGIT_CODES: KeybindingCatalogItem[] = '0123456789'
.split('')
.map((digit) => ({ device: 'keyboard', code: `Digit${digit}` }))
const FUNCTION_CODES: KeybindingCatalogItem[] = Array.from({ length: 12 }, (_, index) => ({
device: 'keyboard',
code: `F${index + 1}`,
}))
const NAVIGATION_CODES: KeybindingCatalogItem[] = [
'Home',
'End',
'PageUp',
'PageDown',
'Insert',
'Delete',
'ArrowUp',
'ArrowDown',
'ArrowLeft',
'ArrowRight',
].map((code) => ({ device: 'keyboard', code }))
const PUNCTUATION_CODES: KeybindingCatalogItem[] = [
'Comma',
'Period',
'Slash',
'Semicolon',
'Quote',
'BracketLeft',
'BracketRight',
'Backslash',
'Minus',
'Equal',
'Backquote',
'Space',
].map((code) => ({ device: 'keyboard', code }))
const MOUSE_ITEMS: KeybindingCatalogItem[] = Object.entries(MOUSE_CODE_MESSAGES).map(
([code, label]) => ({ device: 'mouse', code, label }),
)
/**
* Everything a shortcut can be attached to, grouped for the picker. Inputs that
* cannot be reproduced by pressing them - mouse buttons, the wheel, and any
* combination of those with the keyboard - are only reachable from here.
*/
export const KEYBINDING_CATALOG: KeybindingCatalogDevice[] = [
{
id: 'keyboard',
label: messages.deviceKeyboard,
groups: [
{ id: 'letters', label: messages.groupLetters, items: LETTER_CODES },
{ id: 'digits', label: messages.groupDigits, items: DIGIT_CODES },
{ id: 'function', label: messages.groupFunction, items: FUNCTION_CODES },
{ id: 'navigation', label: messages.groupNavigation, items: NAVIGATION_CODES },
{ id: 'punctuation', label: messages.groupPunctuation, items: PUNCTUATION_CODES },
],
},
{
id: 'mouse',
label: messages.deviceMouse,
groups: [
{
id: 'buttons',
label: messages.groupMouseButtons,
items: MOUSE_ITEMS.filter((item) => item.code.startsWith('Mouse')),
},
{
id: 'wheel',
label: messages.groupWheel,
items: MOUSE_ITEMS.filter((item) => item.code.startsWith('Wheel')),
},
],
},
]
+229
View File
@@ -0,0 +1,229 @@
/**
* The device a binding was recorded from. Keyboard bindings are matched against
* `KeyboardEvent`, mouse bindings against `MouseEvent` and `WheelEvent`.
*/
export type KeyBindingDevice = 'keyboard' | 'mouse'
/**
* A single shortcut. `mod` is the primary modifier held down: the launcher has
* always treated Ctrl and Cmd as one and the same, so a binding recorded on one
* platform keeps working on the other.
*/
export interface KeyBinding {
device: KeyBindingDevice
/** A `KeyboardEvent.code`, or one of the mouse codes below. */
code: string
mod: boolean
shift: boolean
alt: boolean
}
/** `MouseEvent.button`, in the order browsers report it. */
export const MOUSE_BUTTON_CODES = ['Mouse0', 'Mouse1', 'Mouse2', 'Mouse3', 'Mouse4'] as const
export const WHEEL_CODES = ['WheelUp', 'WheelDown'] as const
const MODIFIER_CODES = new Set([
'ControlLeft',
'ControlRight',
'ShiftLeft',
'ShiftRight',
'AltLeft',
'AltRight',
'MetaLeft',
'MetaRight',
'CapsLock',
])
/**
* Codes that may be bound on their own. Anything else has to carry a modifier,
* so a shortcut cannot swallow ordinary typing or list navigation.
*/
const MODIFIER_FREE_CODES = new Set<string>([
'Home',
'End',
'PageUp',
'PageDown',
...Array.from({ length: 24 }, (_, index) => `F${index + 1}`),
...MOUSE_BUTTON_CODES.filter((code) => code !== 'Mouse0'),
...WHEEL_CODES,
])
/**
* Codes that always need a modifier, whatever else is allowed. A bare left
* click is how the whole interface is operated, so it can never be a shortcut.
*/
const MODIFIER_REQUIRED_CODES = new Set<string>(['Mouse0'])
/** A combination someone else already answers to. */
export interface BindingConflict {
id: string
label: string
binding: KeyBinding
/** The shell or the operating system owns it rather than another action. */
reserved?: boolean
}
export type BindingIssue =
{ kind: 'conflict'; label: string; reserved: boolean } | { kind: 'needs-modifier' }
export interface KeybindingLabels {
mod: string
shift: string
alt: string
/** Keyed by mouse code, e.g. `Mouse0`. */
mouseButton: Record<string, string>
wheelUp: string
wheelDown: string
}
export function isModifierCode(code: string): boolean {
return MODIFIER_CODES.has(code)
}
export function isMouseCode(code: string): boolean {
return code.startsWith('Mouse') || (WHEEL_CODES as readonly string[]).includes(code)
}
/**
* Canonical form used to compare bindings, so two ways of recording the same
* combination still collide.
*/
export function bindingKey(binding: KeyBinding): string {
const modifiers = [binding.mod && 'mod', binding.alt && 'alt', binding.shift && 'shift']
.filter(Boolean)
.join('+')
return `${binding.device}|${binding.code}|${modifiers}`
}
export function bindingsEqual(a: KeyBinding, b: KeyBinding): boolean {
return bindingKey(a) === bindingKey(b)
}
function eventModifiers(event: MouseEvent | KeyboardEvent | WheelEvent) {
return {
mod: event.ctrlKey || event.metaKey,
shift: event.shiftKey,
alt: event.altKey,
}
}
/**
* The combination an event represents, or null while only modifiers are held -
* a lone Ctrl is not a shortcut.
*/
export function bindingFromKeyboardEvent(event: KeyboardEvent): KeyBinding | null {
if (event.repeat || isModifierCode(event.code)) return null
return { device: 'keyboard', code: event.code, ...eventModifiers(event) }
}
export function bindingFromMouseEvent(event: MouseEvent): KeyBinding | null {
const code = `Mouse${event.button}`
if (!(MOUSE_BUTTON_CODES as readonly string[]).includes(code)) return null
return { device: 'mouse', code, ...eventModifiers(event) }
}
export function bindingFromWheelEvent(event: WheelEvent): KeyBinding | null {
if (event.deltaY === 0) return null
const code = event.deltaY < 0 ? 'WheelUp' : 'WheelDown'
return { device: 'mouse', code, ...eventModifiers(event) }
}
function modifiersMatch(binding: KeyBinding, event: MouseEvent | KeyboardEvent | WheelEvent) {
const { mod, shift, alt } = eventModifiers(event)
return binding.mod === mod && binding.shift === shift && binding.alt === alt
}
export function bindingMatchesKeyboardEvent(binding: KeyBinding, event: KeyboardEvent): boolean {
return (
binding.device === 'keyboard' && binding.code === event.code && modifiersMatch(binding, event)
)
}
export function bindingMatchesMouseEvent(binding: KeyBinding, event: MouseEvent): boolean {
return (
binding.device === 'mouse' &&
binding.code === `Mouse${event.button}` &&
modifiersMatch(binding, event)
)
}
export function bindingMatchesWheelEvent(binding: KeyBinding, event: WheelEvent): boolean {
if (binding.device !== 'mouse' || event.deltaY === 0) return false
const code = event.deltaY < 0 ? 'WheelUp' : 'WheelDown'
return binding.code === code && modifiersMatch(binding, event)
}
/**
* Whether a combination may be saved, ignoring who it belongs to. Returns the
* problem to show instead of the binding when it may not.
*/
export function validateBinding(
binding: KeyBinding,
conflicts: BindingConflict[],
): BindingIssue | null {
const unmodified = !binding.mod && !binding.shift && !binding.alt
if (unmodified && MODIFIER_REQUIRED_CODES.has(binding.code)) {
return { kind: 'needs-modifier' }
}
if (unmodified && !MODIFIER_FREE_CODES.has(binding.code)) {
return { kind: 'needs-modifier' }
}
const conflict = conflicts.find((entry) => bindingsEqual(entry.binding, binding))
if (conflict) {
return { kind: 'conflict', label: conflict.label, reserved: conflict.reserved ?? false }
}
return null
}
const CODE_LABELS: Record<string, string> = {
ArrowUp: '\u2191',
ArrowDown: '\u2193',
ArrowLeft: '\u2190',
ArrowRight: '\u2192',
PageUp: 'Page Up',
PageDown: 'Page Down',
Escape: 'Esc',
Space: 'Space',
Insert: 'Insert',
Delete: 'Delete',
Comma: ',',
Period: '.',
Slash: '/',
Semicolon: ';',
Quote: "'",
BracketLeft: '[',
BracketRight: ']',
Backslash: '\\',
Minus: '-',
Equal: '=',
Backquote: '`',
}
/** How a single code reads on a key cap. */
export function keyCodeLabel(code: string): string {
if (code.startsWith('Key')) return code.slice(3)
if (code.startsWith('Digit')) return code.slice(5)
if (code.startsWith('Numpad')) return `Num ${code.slice(6)}`
return CODE_LABELS[code] ?? code
}
/** The combination split into the pieces a key cap is drawn for. */
export function bindingParts(binding: KeyBinding, labels: KeybindingLabels): string[] {
const parts: string[] = []
if (binding.mod) parts.push(labels.mod)
if (binding.alt) parts.push(labels.alt)
if (binding.shift) parts.push(labels.shift)
if (binding.code === 'WheelUp') parts.push(labels.wheelUp)
else if (binding.code === 'WheelDown') parts.push(labels.wheelDown)
else if (binding.device === 'mouse') parts.push(labels.mouseButton[binding.code] ?? binding.code)
else parts.push(keyCodeLabel(binding.code))
return parts
}
export function bindingText(binding: KeyBinding, labels: KeybindingLabels): string {
return bindingParts(binding, labels).join(' + ')
}