feat: add keybinding preset UI with toolbar, menu, and header teleport

Add KeybindingPresetToolbar with preset selector dropdown, save changes
button, and import option. Integrate into KeybindingPanel with teleported
search box and more-options menu in the settings dialog header row.
This commit is contained in:
Johnpaul
2026-03-09 23:23:45 +01:00
parent bdbcba8f5c
commit 5a4a33aee0
4 changed files with 258 additions and 29 deletions

View File

@@ -1,8 +1,26 @@
<template>
<div class="keybinding-panel flex flex-col gap-2">
<SearchBox
v-model="filters['global'].value"
:placeholder="$t('g.searchPlaceholder', { subject: $t('g.keybindings') })"
<Teleport defer to="#keybinding-panel-header">
<SearchBox
v-model="filters['global'].value"
class="max-w-96"
:placeholder="
$t('g.searchPlaceholder', { subject: $t('g.keybindings') })
"
/>
</Teleport>
<Teleport defer to="#keybinding-panel-actions">
<DropdownMenu
:entries="menuEntries"
icon="icon-[lucide--ellipsis]"
item-class="text-sm gap-2"
/>
</Teleport>
<KeybindingPresetToolbar
:preset-names="presetNames"
:on-presets-changed="refreshPresetList"
/>
<DataTable
@@ -131,19 +149,11 @@
</Button>
</template>
</Dialog>
<Button
v-tooltip="$t('g.resetAllKeybindingsTooltip')"
class="mt-4 w-full"
variant="destructive-textonly"
@click="resetAllKeybindings"
>
<i class="pi pi-replay" />
{{ $t('g.resetAll') }}
</Button>
</div>
</template>
<script setup lang="ts">
import type { MenuItem } from 'primevue/menuitem'
import { FilterMatchMode } from '@primevue/core/api'
import Column from 'primevue/column'
import DataTable from 'primevue/datatable'
@@ -151,19 +161,23 @@ import Dialog from 'primevue/dialog'
import InputText from 'primevue/inputtext'
import Message from 'primevue/message'
import Tag from 'primevue/tag'
import { useToast } from 'primevue/usetoast'
import { computed, ref, watchEffect } from 'vue'
import { useI18n } from 'vue-i18n'
import DropdownMenu from '@/components/common/DropdownMenu.vue'
import SearchBox from '@/components/common/SearchBox.vue'
import Button from '@/components/ui/button/Button.vue'
import { KeyComboImpl } from '@/platform/keybindings/keyCombo'
import { KeybindingImpl } from '@/platform/keybindings/keybinding'
import { useKeybindingService } from '@/platform/keybindings/keybindingService'
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
import { useKeybindingPresetService } from '@/platform/keybindings/presetService'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogService } from '@/services/dialogService'
import { normalizeI18nKey } from '@/utils/formatUtil'
import KeybindingPresetToolbar from './keybinding/KeybindingPresetToolbar.vue'
import KeyComboDisplay from './keybinding/KeyComboDisplay.vue'
const filters = ref({
@@ -172,9 +186,95 @@ const filters = ref({
const keybindingStore = useKeybindingStore()
const keybindingService = useKeybindingService()
const presetService = useKeybindingPresetService()
const settingStore = useSettingStore()
const commandStore = useCommandStore()
const dialogService = useDialogService()
const { t } = useI18n()
const presetNames = ref<string[]>([])
async function refreshPresetList() {
presetNames.value = (await presetService.listPresets()) ?? []
}
async function initPresets() {
await refreshPresetList()
const currentName = settingStore.get('Comfy.Keybinding.CurrentPreset')
keybindingStore.currentPresetName = currentName
if (currentName !== 'default') {
const preset = await presetService.loadPreset(currentName)
if (preset) {
keybindingStore.savedPresetData = preset
}
}
}
initPresets()
// "..." menu entries (teleported to header)
async function saveAsNewPreset() {
const name = await dialogService.prompt({
title: t('g.keybindingPresets.saveAsNewPreset'),
message: t('g.keybindingPresets.presetNamePrompt'),
defaultValue: ''
})
if (!name) return
await presetService.savePreset(name)
refreshPresetList()
}
async function handleDeletePreset() {
await presetService.deletePreset(keybindingStore.currentPresetName)
refreshPresetList()
}
async function handleImportPreset() {
await presetService.importPreset()
refreshPresetList()
}
const showSaveAsNew = computed(
() =>
keybindingStore.currentPresetName === 'default' ||
keybindingStore.isCurrentPresetModified
)
const menuEntries = computed<MenuItem[]>(() => [
...(showSaveAsNew.value
? [
{
label: t('g.keybindingPresets.saveAsNewPreset'),
icon: 'icon-[lucide--save]',
command: saveAsNewPreset
}
]
: []),
{
label: t('g.keybindingPresets.resetToDefault'),
icon: 'icon-[lucide--rotate-cw]',
command: () =>
presetService.switchPreset('default').then(() => refreshPresetList())
},
{
label: t('g.keybindingPresets.deletePreset'),
icon: 'icon-[lucide--trash-2]',
disabled: keybindingStore.currentPresetName === 'default',
command: handleDeletePreset
},
{
label: t('g.keybindingPresets.importPreset'),
icon: 'icon-[lucide--file-input]',
command: handleImportPreset
},
{
label: t('g.keybindingPresets.exportPreset'),
icon: 'icon-[lucide--file-output]',
command: () => presetService.exportPreset()
}
])
// Keybinding table logic
interface ICommandData {
id: string
keybinding: KeybindingImpl | null
@@ -205,7 +305,6 @@ const existingKeybindingOnCombo = computed<KeybindingImpl | null>(() => {
return null
}
// If the new keybinding is the same as the current editing command, then don't show the error
if (
currentEditingCommand.value.keybinding?.combo?.equals(
newBindingKeyCombo.value
@@ -231,7 +330,6 @@ function editKeybinding(commandData: ICommandData) {
watchEffect(() => {
if (editDialogVisible.value) {
// nextTick doesn't work here, so we use a timeout instead
setTimeout(() => {
// @ts-expect-error - $el is an internal property of the InputText component
keybindingInput.value?.$el?.focus()
@@ -247,7 +345,6 @@ async function removeKeybinding(commandData: ICommandData) {
}
async function captureKeybinding(event: KeyboardEvent) {
// Allow the use of keyboard shortcuts when adding keyboard shortcuts
if (!event.shiftKey && !event.altKey && !event.ctrlKey && !event.metaKey) {
switch (event.key) {
case 'Escape':
@@ -289,16 +386,4 @@ async function resetKeybinding(commandData: ICommandData) {
)
}
}
const toast = useToast()
async function resetAllKeybindings() {
keybindingStore.resetAllKeybindings()
await keybindingService.persistUserKeybindings()
toast.add({
severity: 'info',
summary: 'Info',
detail: 'All keybindings reset',
life: 3000
})
}
</script>

View File

@@ -0,0 +1,112 @@
<template>
<div class="flex items-center justify-between">
<Select v-model="selectedPreset">
<SelectTrigger class="w-64">
<SelectValue :placeholder="$t('g.keybindingPresets.default')">
{{ displayLabel }}
</SelectValue>
</SelectTrigger>
<SelectContent
disable-portal
class="max-w-64 min-w-0 **:[[role=listbox]]:gap-1"
>
<div class="max-w-60">
<SelectItem
value="default"
class="max-w-60 p-2 data-[state=checked]:bg-transparent"
>
{{ $t('g.keybindingPresets.default') }}
</SelectItem>
<SelectItem
v-for="name in presetNames"
:key="name"
:value="name"
class="max-w-60 p-2 data-[state=checked]:bg-transparent"
>
{{ name }}
</SelectItem>
<SelectSeparator class="max-w-60" />
<button
class="relative flex w-full max-w-60 cursor-pointer select-none items-center justify-between gap-3 rounded-sm border-none bg-transparent p-2 text-sm outline-none hover:bg-secondary-background-hover focus:bg-secondary-background-hover"
@click.stop="handleImportFromDropdown"
>
<span class="truncate">
{{ $t('g.keybindingPresets.importKeybindingPreset') }}
</span>
<i
class="icon-[lucide--download] shrink-0 text-base-foreground"
aria-hidden="true"
/>
</button>
</div>
</SelectContent>
</Select>
<Button v-if="showSaveButton" size="lg" @click="handleSavePreset">
{{ $t('g.keybindingPresets.saveChanges') }}
</Button>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import Select from '@/components/ui/select/Select.vue'
import SelectContent from '@/components/ui/select/SelectContent.vue'
import SelectItem from '@/components/ui/select/SelectItem.vue'
import SelectSeparator from '@/components/ui/select/SelectSeparator.vue'
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
import SelectValue from '@/components/ui/select/SelectValue.vue'
import { useKeybindingPresetService } from '@/platform/keybindings/presetService'
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
const { presetNames, onPresetsChanged } = defineProps<{
presetNames: string[]
onPresetsChanged: () => void
}>()
const { t } = useI18n()
const keybindingStore = useKeybindingStore()
const presetService = useKeybindingPresetService()
const selectedPreset = ref(keybindingStore.currentPresetName)
const displayLabel = computed(() => {
const name =
selectedPreset.value === 'default'
? t('g.keybindingPresets.default')
: selectedPreset.value
return keybindingStore.isCurrentPresetModified ? `${name} *` : name
})
watch(selectedPreset, async (newValue, oldValue) => {
if (newValue !== oldValue) {
await presetService.switchPreset(newValue)
selectedPreset.value = keybindingStore.currentPresetName
onPresetsChanged()
}
})
watch(
() => keybindingStore.currentPresetName,
(name) => {
selectedPreset.value = name
}
)
const showSaveButton = computed(
() =>
keybindingStore.currentPresetName !== 'default' &&
keybindingStore.isCurrentPresetModified
)
async function handleSavePreset() {
await presetService.savePreset(keybindingStore.currentPresetName)
}
async function handleImportFromDropdown() {
await presetService.importPreset()
onPresetsChanged()
}
</script>

View File

@@ -261,6 +261,25 @@
"singleSelectDropdown": "Single-select dropdown",
"progressCountOf": "of",
"keybindingAlreadyExists": "Keybinding already exists on",
"keybindingPresets": {
"importPreset": "Import preset",
"importKeybindingPreset": "Import keybinding preset",
"exportPreset": "Export preset",
"saveChanges": "Save Changes",
"saveAsNewPreset": "Save as new preset",
"resetToDefault": "Reset to default",
"deletePreset": "Delete preset",
"unsavedChangesTitle": "Unsaved Changes",
"unsavedChangesMessage": "You have unsaved changes to the current keybinding preset. Would you like to save them before switching?",
"deletePresetTitle": "Delete Preset",
"deletePresetMessage": "Are you sure you want to delete preset \"{name}\"?",
"presetSaved": "Preset \"{name}\" saved",
"presetDeleted": "Preset \"{name}\" deleted",
"presetImported": "Keybinding preset imported",
"invalidPresetFile": "Invalid preset file",
"presetNamePrompt": "Enter a name for the preset",
"default": "Default"
},
"commandProhibited": "Command {command} is prohibited. Contact an administrator for more information.",
"startRecording": "Start Recording",
"stopRecording": "Stop Recording",

View File

@@ -41,7 +41,20 @@
</nav>
</template>
<template #header />
<template #header>
<div
v-if="activeCategoryKey === 'keybinding'"
id="keybinding-panel-header"
class="flex-1"
/>
</template>
<template #header-right-area>
<div
v-if="activeCategoryKey === 'keybinding'"
id="keybinding-panel-actions"
/>
</template>
<template #content>
<template v-if="activePanel">