- 设置页新增「键盘快捷键」分类,可自定义快捷键绑定 - 支持快速滚动(Home/End/PageUp/PageDown)与导航跳转(Ctrl/Cmd+数字) - 快捷键绑定与开关按动作独立持久化,默认关闭 - 新增 ui 包 keybinding 组件与工具(KeybindingModal/KeybindingChips 等)
This commit is contained in:
1 parent
47bdb1e79c
commit
2e92887eb2
24 files changed
+2432
-5
No files matched your search
@@ -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>
|
||||
Reference in new issue
Block a user