Support keybinding customization (#1081)

* Basic keybinding panel

nit

Make row selectable

Reduce padding

Better key seq render

Show actions on demand

Turn off autocomplete

nit

Persist keybindings

Autofocus

Fix set unsetted keybinding bug

Refactor

Add reset button

Add back default keybinding logic

Report key conflict error

Adjust style

fix bug

Highlight modified keybindings

* Set current editing command's id as dialog header
This commit is contained in:
Chenlei Hu
2024-10-03 16:58:56 -04:00
committed by GitHub
parent 142882a8ff
commit 1775d43d90
10 changed files with 442 additions and 13 deletions

View File

@@ -54,6 +54,9 @@
<TabPanel key="about" value="About">
<AboutPanel />
</TabPanel>
<TabPanel key="keybinding" value="Keybinding">
<KeybindingPanel />
</TabPanel>
</TabPanels>
</Tabs>
</div>
@@ -74,6 +77,7 @@ import SearchBox from '@/components/common/SearchBox.vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import { flattenTree } from '@/utils/treeUtil'
import AboutPanel from './setting/AboutPanel.vue'
import KeybindingPanel from './setting/KeybindingPanel.vue'
interface ISettingGroup {
label: string
@@ -86,10 +90,17 @@ const aboutPanelNode: SettingTreeNode = {
children: []
}
const keybindingPanelNode: SettingTreeNode = {
key: 'keybinding',
label: 'Keybinding',
children: []
}
const settingStore = useSettingStore()
const settingRoot = computed<SettingTreeNode>(() => settingStore.settingTree)
const categories = computed<SettingTreeNode[]>(() => [
...(settingRoot.value.children || []),
keybindingPanelNode,
aboutPanelNode
])
const activeCategory = ref<SettingTreeNode | null>(null)

View File

@@ -0,0 +1,223 @@
<template>
<div class="keybinding-panel">
<DataTable
:value="commandsData"
v-model:selection="selectedCommandData"
selectionMode="single"
stripedRows
>
<Column field="actions" header="">
<template #body="slotProps">
<div class="actions invisible">
<Button
icon="pi pi-pencil"
class="p-button-text"
@click="editKeybinding(slotProps.data)"
/>
<Button
icon="pi pi-trash"
class="p-button-text p-button-danger"
@click="removeKeybinding(slotProps.data)"
:disabled="!slotProps.data.keybinding"
/>
</div>
</template>
</Column>
<Column field="id" header="Command ID" sortable></Column>
<Column field="keybinding" header="Keybinding">
<template #body="slotProps">
<KeyComboDisplay
v-if="slotProps.data.keybinding"
:keyCombo="slotProps.data.keybinding.combo"
:isModified="
keybindingStore.isCommandKeybindingModified(slotProps.data.id)
"
/>
<span v-else>-</span>
</template>
</Column>
</DataTable>
<Dialog
class="min-w-96"
v-model:visible="editDialogVisible"
modal
:header="currentEditingCommand?.id"
@hide="cancelEdit"
>
<div>
<InputText
class="mb-2 text-center"
ref="keybindingInput"
:modelValue="newBindingKeyCombo?.toString() ?? ''"
placeholder="Press keys for new binding"
@keydown.stop.prevent="captureKeybinding"
autocomplete="off"
fluid
:invalid="!!existingKeybindingOnCombo"
/>
<Message v-if="existingKeybindingOnCombo" severity="error">
Keybinding already exists on
<Tag
severity="secondary"
:value="existingKeybindingOnCombo.commandId"
/>
</Message>
</div>
<template #footer>
<Button
label="Save"
icon="pi pi-check"
@click="saveKeybinding"
:disabled="!!existingKeybindingOnCombo"
autofocus
/>
</template>
</Dialog>
<Button
class="mt-4"
:label="$t('reset')"
v-tooltip="$t('resetKeybindingsTooltip')"
icon="pi pi-trash"
severity="danger"
fluid
text
@click="resetKeybindings"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watchEffect } from 'vue'
import {
useKeybindingStore,
KeyComboImpl,
KeybindingImpl
} from '@/stores/keybindingStore'
import { useCommandStore } from '@/stores/commandStore'
import DataTable from 'primevue/datatable'
import Column from 'primevue/column'
import Button from 'primevue/button'
import Dialog from 'primevue/dialog'
import InputText from 'primevue/inputtext'
import Message from 'primevue/message'
import Tag from 'primevue/tag'
import KeyComboDisplay from './keybinding/KeyComboDisplay.vue'
import { useToast } from 'primevue/usetoast'
const keybindingStore = useKeybindingStore()
const commandStore = useCommandStore()
interface ICommandData {
id: string
keybinding: KeybindingImpl | null
}
const commandsData = computed<ICommandData[]>(() => {
return Object.values(commandStore.commands).map((command) => ({
id: command.id,
keybinding: keybindingStore.getKeybindingByCommandId(command.id)
}))
})
const selectedCommandData = ref<ICommandData | null>(null)
const editDialogVisible = ref(false)
const newBindingKeyCombo = ref<KeyComboImpl | null>(null)
const currentEditingCommand = ref<ICommandData | null>(null)
const keybindingInput = ref(null)
const existingKeybindingOnCombo = computed<KeybindingImpl | null>(() => {
if (!currentEditingCommand.value) {
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
)
) {
return null
}
if (!newBindingKeyCombo.value) {
return null
}
return keybindingStore.getKeybinding(newBindingKeyCombo.value)
})
function editKeybinding(commandData: ICommandData) {
currentEditingCommand.value = commandData
newBindingKeyCombo.value = commandData.keybinding
? commandData.keybinding.combo
: null
editDialogVisible.value = true
}
watchEffect(() => {
if (editDialogVisible.value) {
// nextTick doesn't work here, so we use a timeout instead
setTimeout(() => {
keybindingInput.value?.$el?.focus()
}, 300)
}
})
function removeKeybinding(commandData: ICommandData) {
if (commandData.keybinding) {
keybindingStore.unsetKeybinding(commandData.keybinding)
keybindingStore.persistUserKeybindings()
}
}
function captureKeybinding(event: KeyboardEvent) {
const keyCombo = KeyComboImpl.fromEvent(event)
newBindingKeyCombo.value = keyCombo
}
function cancelEdit() {
editDialogVisible.value = false
currentEditingCommand.value = null
newBindingKeyCombo.value = null
}
function saveKeybinding() {
if (currentEditingCommand.value && newBindingKeyCombo.value) {
const updated = keybindingStore.updateKeybindingOnCommand(
new KeybindingImpl({
commandId: currentEditingCommand.value.id,
combo: newBindingKeyCombo.value
})
)
if (updated) {
keybindingStore.persistUserKeybindings()
}
}
cancelEdit()
}
const toast = useToast()
async function resetKeybindings() {
keybindingStore.resetKeybindings()
await keybindingStore.persistUserKeybindings()
toast.add({
severity: 'info',
summary: 'Info',
detail: 'Keybindings reset',
life: 3000
})
}
</script>
<style scoped>
:deep(.p-datatable-tbody) > tr > td {
padding: 1px;
min-height: 2rem;
}
:deep(.p-datatable-row-selected) .actions,
:deep(.p-datatable-selectable-row:hover) .actions {
@apply visible;
}
</style>

View File

@@ -0,0 +1,28 @@
<template>
<span>
<template v-for="(sequence, index) in keySequences" :key="index">
<Tag :severity="isModified ? 'info' : 'secondary'">
{{ sequence }}
</Tag>
<span v-if="index < keySequences.length - 1" class="px-2">+</span>
</template>
</span>
</template>
<script setup lang="ts">
import Tag from 'primevue/tag'
import { KeyComboImpl } from '@/stores/keybindingStore'
import { computed } from 'vue'
const props = withDefaults(
defineProps<{
keyCombo: KeyComboImpl
isModified: boolean
}>(),
{
isModified: false
}
)
const keySequences = computed(() => props.keyCombo.getKeySequences())
</script>

View File

@@ -52,6 +52,7 @@ import {
useModelToNodeStore
} from '@/stores/modelToNodeStore'
import GraphCanvasMenu from '@/components/graph/GraphCanvasMenu.vue'
import { useKeybindingStore } from '@/stores/keybindingStore'
const emit = defineEmits(['ready'])
const canvasRef = ref<HTMLCanvasElement | null>(null)
@@ -200,6 +201,9 @@ onMounted(async () => {
}
})
// Load keybindings. This must be done after comfyApp loads settings.
useKeybindingStore().loadUserKeybindings()
// Migrate legacy bookmarks
useNodeBookmarkStore().migrateLegacyBookmarks()

View File

@@ -59,6 +59,6 @@ app.registerExtension({
}
}
window.addEventListener('keydown', keybindListener, true)
window.addEventListener('keydown', keybindListener)
}
})

View File

@@ -9,6 +9,7 @@ const messages = {
add: 'Add',
confirm: 'Confirm',
reset: 'Reset',
resetKeybindingsTooltip: 'Reset keybindings to default',
customizeFolder: 'Customize Folder',
icon: 'Icon',
color: 'Color',
@@ -112,6 +113,7 @@ const messages = {
add: '添加',
confirm: '确认',
reset: '重置',
resetKeybindingsTooltip: '重置键位',
customizeFolder: '定制文件夹',
icon: '图标',
color: '颜色',

View File

@@ -1,7 +1,7 @@
import { app } from '@/scripts/app'
import { api } from '@/scripts/api'
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { computed, ref } from 'vue'
import { globalTracker } from '@/scripts/changeTracker'
import { useSettingStore } from '@/stores/settingStore'
import { useToastStore } from '@/stores/toastStore'
@@ -32,12 +32,14 @@ const getTracker = () =>
export const useCommandStore = defineStore('command', () => {
const settingStore = useSettingStore()
const commands = ref<Record<string, ComfyCommand>>({})
const commandsById = ref<Record<string, ComfyCommand>>({})
const commands = computed(() => Object.values(commandsById.value))
const registerCommand = (command: ComfyCommand) => {
if (commands.value[command.id]) {
if (commandsById.value[command.id]) {
console.warn(`Command ${command.id} already registered`)
}
commands.value[command.id] = command
commandsById.value[command.id] = command
}
const commandDefinitions: ComfyCommand[] = [
@@ -311,15 +313,15 @@ export const useCommandStore = defineStore('command', () => {
commandDefinitions.forEach(registerCommand)
const getCommandFunction = (command: string) => {
return commands.value[command]?.function ?? (() => {})
return commandsById.value[command]?.function ?? (() => {})
}
const getCommand = (command: string) => {
return commands.value[command]
return commandsById.value[command]
}
const isRegistered = (command: string) => {
return !!commands.value[command]
return !!commandsById.value[command]
}
const loadExtensionCommands = (extension: ComfyExtension) => {
@@ -331,6 +333,7 @@ export const useCommandStore = defineStore('command', () => {
}
return {
commands,
getCommand,
getCommandFunction,
registerCommand,

View File

@@ -74,7 +74,7 @@ export class KeyComboImpl implements KeyCombo {
}
toString(): string {
return `${this.key} + ${this.ctrl ? 'Ctrl' : ''}${this.alt ? 'Alt' : ''}${this.shift ? 'Shift' : ''}`
return this.getKeySequences().join(' + ')
}
get hasModifier(): boolean {
@@ -84,6 +84,21 @@ export class KeyComboImpl implements KeyCombo {
get isModifier(): boolean {
return ['Control', 'Meta', 'Alt', 'Shift'].includes(this.key)
}
getKeySequences(): string[] {
const sequences: string[] = []
if (this.ctrl) {
sequences.push('Ctrl')
}
if (this.alt) {
sequences.push('Alt')
}
if (this.shift) {
sequences.push('Shift')
}
sequences.push(this.key)
return sequences
}
}
export const useKeybindingStore = defineStore('keybinding', () => {
@@ -123,6 +138,37 @@ export const useKeybindingStore = defineStore('keybinding', () => {
return keybindingByKeyCombo.value[combo.serialize()]
}
function createKeybindingsByCommandId(keybindings: KeybindingImpl[]) {
const result: Record<string, KeybindingImpl[]> = {}
for (const keybinding of keybindings) {
if (!(keybinding.commandId in result)) {
result[keybinding.commandId] = []
}
result[keybinding.commandId].push(keybinding)
}
return result
}
const keybindingsByCommandId = computed<Record<string, KeybindingImpl[]>>(
() => {
return createKeybindingsByCommandId(keybindings.value)
}
)
function getKeybindingsByCommandId(commandId: string) {
return keybindingsByCommandId.value[commandId] ?? []
}
const defaultKeybindingsByCommandId = computed<
Record<string, KeybindingImpl[]>
>(() => {
return createKeybindingsByCommandId(Object.values(defaultKeybindings.value))
})
function getKeybindingByCommandId(commandId: string) {
return getKeybindingsByCommandId(commandId)[0]
}
function addKeybinding(
target: Ref<Record<string, KeybindingImpl>>,
keybinding: KeybindingImpl,
@@ -145,9 +191,23 @@ export const useKeybindingStore = defineStore('keybinding', () => {
function addUserKeybinding(keybinding: KeybindingImpl) {
const defaultKeybinding =
defaultKeybindings.value[keybinding.combo.serialize()]
if (defaultKeybinding) {
const userUnsetKeybinding =
userUnsetKeybindings.value[keybinding.combo.serialize()]
// User is adding back a keybinding that was an unsetted default keybinding.
if (
keybinding.equals(defaultKeybinding) &&
keybinding.equals(userUnsetKeybinding)
) {
delete userUnsetKeybindings.value[keybinding.combo.serialize()]
return
}
// Unset keybinding on default keybinding if it exists and is not the same as userUnsetKeybinding
if (defaultKeybinding && !defaultKeybinding.equals(userUnsetKeybinding)) {
unsetKeybinding(defaultKeybinding)
}
addKeybinding(userKeybindings, keybinding, { existOk: true })
}
@@ -170,6 +230,23 @@ export const useKeybindingStore = defineStore('keybinding', () => {
throw new Error(`NOT_REACHED`)
}
/**
* Update the keybinding on given command if it is different from the current keybinding.
*
* @returns true if the keybinding is updated, false otherwise.
*/
function updateKeybindingOnCommand(keybinding: KeybindingImpl): boolean {
const currentKeybinding = getKeybindingByCommandId(keybinding.commandId)
if (currentKeybinding?.equals(keybinding)) {
return false
}
if (currentKeybinding) {
unsetKeybinding(currentKeybinding)
}
addUserKeybinding(keybinding)
return true
}
function loadUserKeybindings() {
const settingStore = useSettingStore()
// Unset bindings first as new bindings might conflict with default bindings.
@@ -204,14 +281,51 @@ export const useKeybindingStore = defineStore('keybinding', () => {
}
}
async function persistUserKeybindings() {
const settingStore = useSettingStore()
// TODO(https://github.com/Comfy-Org/ComfyUI_frontend/issues/1079):
// Allow setting multiple values at once in settingStore
await settingStore.set(
'Comfy.Keybinding.NewBindings',
Object.values(userKeybindings.value)
)
await settingStore.set(
'Comfy.Keybinding.UnsetBindings',
Object.values(userUnsetKeybindings.value)
)
}
function resetKeybindings() {
userKeybindings.value = {}
userUnsetKeybindings.value = {}
}
function isCommandKeybindingModified(commandId: string): boolean {
const currentKeybinding: KeybindingImpl | undefined =
getKeybindingByCommandId(commandId)
const defaultKeybinding: KeybindingImpl | undefined =
defaultKeybindingsByCommandId.value[commandId]?.[0]
return !(
(currentKeybinding === undefined && defaultKeybinding === undefined) ||
currentKeybinding?.equals(defaultKeybinding)
)
}
return {
keybindings,
getKeybinding,
getKeybindingsByCommandId,
getKeybindingByCommandId,
addDefaultKeybinding,
addUserKeybinding,
unsetKeybinding,
updateKeybindingOnCommand,
loadUserKeybindings,
loadCoreKeybindings,
loadExtensionKeybindings
loadExtensionKeybindings,
persistUserKeybindings,
resetKeybindings,
isCommandKeybindingModified
}
})

View File

@@ -67,9 +67,9 @@ export const useSettingStore = defineStore('setting', {
})
},
set<K extends keyof Settings>(key: K, value: Settings[K]) {
async set<K extends keyof Settings>(key: K, value: Settings[K]) {
this.settingValues[key] = value
app.ui.settings.setSettingValue(key, value)
await app.ui.settings.setSettingValueAsync(key, value)
},
get<K extends keyof Settings>(key: K): Settings[K] {

View File

@@ -53,6 +53,25 @@ describe('useKeybindingStore', () => {
expect(store.getKeybinding(userKeybinding.combo)).toEqual(userKeybinding)
})
it('Should allow binding to unsetted default keybindings', () => {
const store = useKeybindingStore()
const defaultKeybinding = new KeybindingImpl({
commandId: 'test.command1',
combo: { key: 'C', ctrl: true }
})
store.addDefaultKeybinding(defaultKeybinding)
store.unsetKeybinding(defaultKeybinding)
const userKeybinding = new KeybindingImpl({
commandId: 'test.command2',
combo: { key: 'C', ctrl: true }
})
store.addUserKeybinding(userKeybinding)
expect(store.keybindings).toHaveLength(1)
expect(store.getKeybinding(userKeybinding.combo)).toEqual(userKeybinding)
})
it('should unset user keybindings', () => {
const store = useKeybindingStore()
const keybinding = new KeybindingImpl({
@@ -119,4 +138,29 @@ describe('useKeybindingStore', () => {
expect(() => store.unsetKeybinding(keybinding)).toThrow()
})
it('should remove unset keybinding when adding back a default keybinding', () => {
const store = useKeybindingStore()
const defaultKeybinding = new KeybindingImpl({
commandId: 'test.command',
combo: { key: 'I', ctrl: true }
})
// Add default keybinding
store.addDefaultKeybinding(defaultKeybinding)
expect(store.keybindings).toHaveLength(1)
// Unset the default keybinding
store.unsetKeybinding(defaultKeybinding)
expect(store.keybindings).toHaveLength(0)
// Add the same keybinding as a user keybinding
store.addUserKeybinding(defaultKeybinding)
// Check that the keybinding is back and not in the unset list
expect(store.keybindings).toHaveLength(1)
expect(store.getKeybinding(defaultKeybinding.combo)).toEqual(
defaultKeybinding
)
})
})