From 1775d43d90acc41c19612a41bd6befd8803ef74b Mon Sep 17 00:00:00 2001 From: Chenlei Hu Date: Thu, 3 Oct 2024 16:58:56 -0400 Subject: [PATCH] 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 --- .../dialog/content/SettingDialogContent.vue | 11 + .../content/setting/KeybindingPanel.vue | 223 ++++++++++++++++++ .../setting/keybinding/KeyComboDisplay.vue | 28 +++ src/components/graph/GraphCanvas.vue | 4 + src/extensions/core/keybinds.ts | 2 +- src/i18n.ts | 2 + src/stores/commandStore.ts | 17 +- src/stores/keybindingStore.ts | 120 +++++++++- src/stores/settingStore.ts | 4 +- tests-ui/tests/store/keybindingStore.test.ts | 44 ++++ 10 files changed, 442 insertions(+), 13 deletions(-) create mode 100644 src/components/dialog/content/setting/KeybindingPanel.vue create mode 100644 src/components/dialog/content/setting/keybinding/KeyComboDisplay.vue diff --git a/src/components/dialog/content/SettingDialogContent.vue b/src/components/dialog/content/SettingDialogContent.vue index 3b529506d..730dd2c8b 100644 --- a/src/components/dialog/content/SettingDialogContent.vue +++ b/src/components/dialog/content/SettingDialogContent.vue @@ -54,6 +54,9 @@ + + + @@ -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(() => settingStore.settingTree) const categories = computed(() => [ ...(settingRoot.value.children || []), + keybindingPanelNode, aboutPanelNode ]) const activeCategory = ref(null) diff --git a/src/components/dialog/content/setting/KeybindingPanel.vue b/src/components/dialog/content/setting/KeybindingPanel.vue new file mode 100644 index 000000000..d6f9c1f03 --- /dev/null +++ b/src/components/dialog/content/setting/KeybindingPanel.vue @@ -0,0 +1,223 @@ + + + + + diff --git a/src/components/dialog/content/setting/keybinding/KeyComboDisplay.vue b/src/components/dialog/content/setting/keybinding/KeyComboDisplay.vue new file mode 100644 index 000000000..25ab289a6 --- /dev/null +++ b/src/components/dialog/content/setting/keybinding/KeyComboDisplay.vue @@ -0,0 +1,28 @@ + + + diff --git a/src/components/graph/GraphCanvas.vue b/src/components/graph/GraphCanvas.vue index 02cc7a1fa..4e81bdefd 100644 --- a/src/components/graph/GraphCanvas.vue +++ b/src/components/graph/GraphCanvas.vue @@ -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(null) @@ -200,6 +201,9 @@ onMounted(async () => { } }) + // Load keybindings. This must be done after comfyApp loads settings. + useKeybindingStore().loadUserKeybindings() + // Migrate legacy bookmarks useNodeBookmarkStore().migrateLegacyBookmarks() diff --git a/src/extensions/core/keybinds.ts b/src/extensions/core/keybinds.ts index 15fab32d0..6b8ab4bdb 100644 --- a/src/extensions/core/keybinds.ts +++ b/src/extensions/core/keybinds.ts @@ -59,6 +59,6 @@ app.registerExtension({ } } - window.addEventListener('keydown', keybindListener, true) + window.addEventListener('keydown', keybindListener) } }) diff --git a/src/i18n.ts b/src/i18n.ts index 89ed73496..850493410 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -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: '颜色', diff --git a/src/stores/commandStore.ts b/src/stores/commandStore.ts index 248018d1f..7263d2b9a 100644 --- a/src/stores/commandStore.ts +++ b/src/stores/commandStore.ts @@ -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>({}) + const commandsById = ref>({}) + 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, diff --git a/src/stores/keybindingStore.ts b/src/stores/keybindingStore.ts index d7bb42e82..924b23ea6 100644 --- a/src/stores/keybindingStore.ts +++ b/src/stores/keybindingStore.ts @@ -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 = {} + for (const keybinding of keybindings) { + if (!(keybinding.commandId in result)) { + result[keybinding.commandId] = [] + } + result[keybinding.commandId].push(keybinding) + } + return result + } + + const keybindingsByCommandId = computed>( + () => { + return createKeybindingsByCommandId(keybindings.value) + } + ) + + function getKeybindingsByCommandId(commandId: string) { + return keybindingsByCommandId.value[commandId] ?? [] + } + + const defaultKeybindingsByCommandId = computed< + Record + >(() => { + return createKeybindingsByCommandId(Object.values(defaultKeybindings.value)) + }) + + function getKeybindingByCommandId(commandId: string) { + return getKeybindingsByCommandId(commandId)[0] + } + function addKeybinding( target: Ref>, 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 } }) diff --git a/src/stores/settingStore.ts b/src/stores/settingStore.ts index 31ee389ca..ee08102e4 100644 --- a/src/stores/settingStore.ts +++ b/src/stores/settingStore.ts @@ -67,9 +67,9 @@ export const useSettingStore = defineStore('setting', { }) }, - set(key: K, value: Settings[K]) { + async set(key: K, value: Settings[K]) { this.settingValues[key] = value - app.ui.settings.setSettingValue(key, value) + await app.ui.settings.setSettingValueAsync(key, value) }, get(key: K): Settings[K] { diff --git a/tests-ui/tests/store/keybindingStore.test.ts b/tests-ui/tests/store/keybindingStore.test.ts index 97e64a059..c54005423 100644 --- a/tests-ui/tests/store/keybindingStore.test.ts +++ b/tests-ui/tests/store/keybindingStore.test.ts @@ -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 + ) + }) })