mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-20 20:37:33 +00:00
Compare commits
2 Commits
refactor/e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b85227089 | ||
|
|
944f78adf4 |
@@ -33,6 +33,9 @@ export const TestIds = {
|
||||
about: 'about-panel',
|
||||
whatsNewSection: 'whats-new-section'
|
||||
},
|
||||
keybindings: {
|
||||
presetMenu: 'keybinding-preset-menu'
|
||||
},
|
||||
topbar: {
|
||||
queueButton: 'queue-button',
|
||||
queueModeMenuTrigger: 'queue-mode-menu-trigger',
|
||||
@@ -83,6 +86,7 @@ export type TestIdValue =
|
||||
| (typeof TestIds.tree)[keyof typeof TestIds.tree]
|
||||
| (typeof TestIds.canvas)[keyof typeof TestIds.canvas]
|
||||
| (typeof TestIds.dialogs)[keyof typeof TestIds.dialogs]
|
||||
| (typeof TestIds.keybindings)[keyof typeof TestIds.keybindings]
|
||||
| (typeof TestIds.topbar)[keyof typeof TestIds.topbar]
|
||||
| (typeof TestIds.nodeLibrary)[keyof typeof TestIds.nodeLibrary]
|
||||
| (typeof TestIds.propertiesPanel)[keyof typeof TestIds.propertiesPanel]
|
||||
|
||||
257
browser_tests/tests/keybindingPresets.spec.ts
Normal file
257
browser_tests/tests/keybindingPresets.spec.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
import type { Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
const TEST_PRESET = {
|
||||
name: 'test-preset',
|
||||
newBindings: [
|
||||
{
|
||||
commandId: 'Comfy.Canvas.SelectAll',
|
||||
combo: { key: 'a', ctrl: true, shift: true },
|
||||
targetElementId: 'graph-canvas-container'
|
||||
}
|
||||
],
|
||||
unsetBindings: [
|
||||
{
|
||||
commandId: 'Comfy.Canvas.SelectAll',
|
||||
combo: { key: 'a', ctrl: true },
|
||||
targetElementId: 'graph-canvas-container'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
async function importPreset(page: Page, preset: typeof TEST_PRESET) {
|
||||
const menuButton = page.getByTestId('keybinding-preset-menu')
|
||||
await menuButton.click()
|
||||
|
||||
const fileChooserPromise = page.waitForEvent('filechooser')
|
||||
await page.getByRole('menuitem', { name: /Import preset/i }).click()
|
||||
const fileChooser = await fileChooserPromise
|
||||
|
||||
const presetPath = path.join(os.tmpdir(), 'test-preset.json')
|
||||
fs.writeFileSync(presetPath, JSON.stringify(preset))
|
||||
await fileChooser.setFiles(presetPath)
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.request.fetch(
|
||||
`${comfyPage.url}/api/userdata/keybindings%2Ftest-preset.json`,
|
||||
{ method: 'DELETE' }
|
||||
)
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Keybinding.CurrentPreset',
|
||||
'default'
|
||||
)
|
||||
})
|
||||
|
||||
test.describe('Keybinding Presets', { tag: '@keyboard' }, () => {
|
||||
test('Can import a preset, use remapped keybinding, and switch back to default', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test.setTimeout(30000)
|
||||
const { page } = comfyPage
|
||||
|
||||
// Verify default Ctrl+A select-all works
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.canvas.press('Control+a')
|
||||
await comfyPage.canvas.press('Delete')
|
||||
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
|
||||
|
||||
// Open keybinding settings panel
|
||||
await comfyPage.settingDialog.open()
|
||||
await comfyPage.settingDialog.category('Keybinding').click()
|
||||
|
||||
await importPreset(page, TEST_PRESET)
|
||||
|
||||
// Verify active preset switched to test-preset
|
||||
const presetTrigger = page
|
||||
.locator('#keybinding-panel-actions')
|
||||
.locator('button[role="combobox"]')
|
||||
await expect(presetTrigger).toContainText('test-preset')
|
||||
|
||||
// Wait for toast to auto-dismiss, then close settings via Escape
|
||||
await expect(comfyPage.toast.visibleToasts).toHaveCount(0, {
|
||||
timeout: 5000
|
||||
})
|
||||
await page.keyboard.press('Escape')
|
||||
await comfyPage.settingDialog.waitForHidden()
|
||||
|
||||
// Load workflow again, use new keybind Ctrl+Shift+A
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.canvas.press('Control+Shift+a')
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount())
|
||||
.toBeGreaterThan(0)
|
||||
await comfyPage.canvas.press('Delete')
|
||||
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
|
||||
|
||||
// Switch back to default preset
|
||||
await comfyPage.settingDialog.open()
|
||||
await comfyPage.settingDialog.category('Keybinding').click()
|
||||
|
||||
await presetTrigger.click()
|
||||
await page.getByRole('option', { name: /Default Preset/i }).click()
|
||||
|
||||
// Handle unsaved changes dialog if the preset was marked as modified
|
||||
const discardButton = page.getByRole('button', {
|
||||
name: /Discard and Switch/i
|
||||
})
|
||||
if (await discardButton.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await discardButton.click()
|
||||
}
|
||||
|
||||
await expect(presetTrigger).toContainText('Default Preset')
|
||||
|
||||
await page.keyboard.press('Escape')
|
||||
await comfyPage.settingDialog.waitForHidden()
|
||||
})
|
||||
|
||||
test('Can export a preset and re-import it', async ({ comfyPage }) => {
|
||||
test.setTimeout(30000)
|
||||
const { page } = comfyPage
|
||||
const menuButton = page.getByTestId('keybinding-preset-menu')
|
||||
|
||||
// Open keybinding settings panel
|
||||
await comfyPage.settingDialog.open()
|
||||
await comfyPage.settingDialog.category('Keybinding').click()
|
||||
|
||||
await importPreset(page, TEST_PRESET)
|
||||
|
||||
// Verify active preset switched to test-preset
|
||||
const presetTrigger = page
|
||||
.locator('#keybinding-panel-actions')
|
||||
.locator('button[role="combobox"]')
|
||||
await expect(presetTrigger).toContainText('test-preset')
|
||||
|
||||
// Wait for toast to auto-dismiss
|
||||
await expect(comfyPage.toast.visibleToasts).toHaveCount(0, {
|
||||
timeout: 5000
|
||||
})
|
||||
|
||||
// Export via ellipsis menu
|
||||
await menuButton.click()
|
||||
const downloadPromise = page.waitForEvent('download')
|
||||
await page.getByRole('menuitem', { name: /Export preset/i }).click()
|
||||
const download = await downloadPromise
|
||||
|
||||
// Verify filename contains test-preset
|
||||
expect(download.suggestedFilename()).toContain('test-preset')
|
||||
|
||||
// Close settings
|
||||
await page.keyboard.press('Escape')
|
||||
await comfyPage.settingDialog.waitForHidden()
|
||||
|
||||
// Verify the downloaded file is valid JSON with correct structure
|
||||
const downloadPath = await download.path()
|
||||
expect(downloadPath).toBeTruthy()
|
||||
const content = fs.readFileSync(downloadPath!, 'utf-8')
|
||||
const parsed = JSON.parse(content) as {
|
||||
name: string
|
||||
newBindings: unknown[]
|
||||
unsetBindings: unknown[]
|
||||
}
|
||||
expect(parsed).toHaveProperty('name')
|
||||
expect(parsed).toHaveProperty('newBindings')
|
||||
expect(parsed).toHaveProperty('unsetBindings')
|
||||
expect(parsed.name).toBe('test-preset')
|
||||
})
|
||||
|
||||
test('Can delete an imported preset', async ({ comfyPage }) => {
|
||||
test.setTimeout(30000)
|
||||
const { page } = comfyPage
|
||||
const menuButton = page.getByTestId('keybinding-preset-menu')
|
||||
|
||||
// Open keybinding settings panel
|
||||
await comfyPage.settingDialog.open()
|
||||
await comfyPage.settingDialog.category('Keybinding').click()
|
||||
|
||||
await importPreset(page, TEST_PRESET)
|
||||
|
||||
// Verify active preset switched to test-preset
|
||||
const presetTrigger = page
|
||||
.locator('#keybinding-panel-actions')
|
||||
.locator('button[role="combobox"]')
|
||||
await expect(presetTrigger).toContainText('test-preset')
|
||||
|
||||
// Wait for toast to auto-dismiss
|
||||
await expect(comfyPage.toast.visibleToasts).toHaveCount(0, {
|
||||
timeout: 5000
|
||||
})
|
||||
|
||||
// Delete via ellipsis menu
|
||||
await menuButton.click()
|
||||
await page.getByRole('menuitem', { name: /Delete preset/i }).click()
|
||||
|
||||
// Confirm deletion in the dialog
|
||||
const confirmDialog = page.getByRole('dialog', {
|
||||
name: /Delete the current preset/i
|
||||
})
|
||||
await confirmDialog.getByRole('button', { name: /Delete/i }).click()
|
||||
|
||||
// Verify preset trigger now shows Default Preset
|
||||
await expect(presetTrigger).toContainText('Default Preset')
|
||||
|
||||
// Close settings
|
||||
await page.keyboard.press('Escape')
|
||||
await comfyPage.settingDialog.waitForHidden()
|
||||
})
|
||||
|
||||
test('Can save modifications as a new preset', async ({ comfyPage }) => {
|
||||
test.setTimeout(30000)
|
||||
const { page } = comfyPage
|
||||
const menuButton = page.getByTestId('keybinding-preset-menu')
|
||||
|
||||
// Open keybinding settings panel
|
||||
await comfyPage.settingDialog.open()
|
||||
await comfyPage.settingDialog.category('Keybinding').click()
|
||||
|
||||
await importPreset(page, TEST_PRESET)
|
||||
|
||||
// Verify active preset switched to test-preset
|
||||
const presetTrigger = page
|
||||
.locator('#keybinding-panel-actions')
|
||||
.locator('button[role="combobox"]')
|
||||
await expect(presetTrigger).toContainText('test-preset')
|
||||
|
||||
// Wait for toast to auto-dismiss
|
||||
await expect(comfyPage.toast.visibleToasts).toHaveCount(0, {
|
||||
timeout: 5000
|
||||
})
|
||||
|
||||
// Save as new preset via ellipsis menu
|
||||
await menuButton.click()
|
||||
await page.getByRole('menuitem', { name: /Save as new preset/i }).click()
|
||||
|
||||
// Fill in the preset name in the prompt dialog
|
||||
const promptInput = page.locator('.prompt-dialog-content input')
|
||||
await promptInput.fill('my-custom-preset')
|
||||
await promptInput.press('Enter')
|
||||
|
||||
// Wait for toast to auto-dismiss
|
||||
await expect(comfyPage.toast.visibleToasts).toHaveCount(0, {
|
||||
timeout: 5000
|
||||
})
|
||||
|
||||
// Verify preset trigger shows my-custom-preset
|
||||
await expect(presetTrigger).toContainText('my-custom-preset')
|
||||
|
||||
// Close settings
|
||||
await page.keyboard.press('Escape')
|
||||
await comfyPage.settingDialog.waitForHidden()
|
||||
|
||||
// Cleanup: delete the my-custom-preset file
|
||||
await comfyPage.request.fetch(
|
||||
`${comfyPage.url}/api/userdata/keybindings%2Fmy-custom-preset.json`,
|
||||
{ method: 'DELETE' }
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -12,6 +12,7 @@ import { computed, toValue } from 'vue'
|
||||
import DropdownItem from '@/components/common/DropdownItem.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import type { ButtonVariants } from '../ui/button/button.variants'
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
@@ -23,6 +24,8 @@ const { itemClass: itemProp, contentClass: contentProp } = defineProps<{
|
||||
to?: string | HTMLElement
|
||||
itemClass?: string
|
||||
contentClass?: string
|
||||
buttonSize?: ButtonVariants['size']
|
||||
buttonClass?: string
|
||||
}>()
|
||||
|
||||
const itemClass = computed(() =>
|
||||
@@ -44,7 +47,7 @@ const contentClass = computed(() =>
|
||||
<DropdownMenuRoot>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<slot name="button">
|
||||
<Button size="icon">
|
||||
<Button :size="buttonSize ?? 'icon'" :class="buttonClass">
|
||||
<i :class="icon ?? 'icon-[lucide--menu]'" />
|
||||
</Button>
|
||||
</slot>
|
||||
|
||||
@@ -77,29 +77,31 @@
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
class="h-10"
|
||||
variant="secondary"
|
||||
@click="showApiKeyForm = true"
|
||||
>
|
||||
<img
|
||||
src="/assets/images/comfy-logo-mono.svg"
|
||||
class="mr-2 size-5"
|
||||
:alt="$t('g.comfy')"
|
||||
/>
|
||||
{{ t('auth.login.useApiKey') }}
|
||||
</Button>
|
||||
<small class="text-center text-muted">
|
||||
{{ t('auth.apiKey.helpText') }}
|
||||
<a
|
||||
:href="`${comfyPlatformBaseUrl}/login`"
|
||||
target="_blank"
|
||||
class="cursor-pointer text-blue-500"
|
||||
<template v-if="!isCloud">
|
||||
<Button
|
||||
type="button"
|
||||
class="h-10"
|
||||
variant="secondary"
|
||||
@click="showApiKeyForm = true"
|
||||
>
|
||||
{{ t('auth.apiKey.generateKey') }}
|
||||
</a>
|
||||
</small>
|
||||
<img
|
||||
src="/assets/images/comfy-logo-mono.svg"
|
||||
class="mr-2 size-5"
|
||||
:alt="$t('g.comfy')"
|
||||
/>
|
||||
{{ t('auth.login.useApiKey') }}
|
||||
</Button>
|
||||
<small class="text-center text-muted">
|
||||
{{ t('auth.apiKey.helpText') }}
|
||||
<a
|
||||
:href="`${comfyPlatformBaseUrl}/login`"
|
||||
target="_blank"
|
||||
class="cursor-pointer text-blue-500"
|
||||
>
|
||||
{{ t('auth.apiKey.generateKey') }}
|
||||
</a>
|
||||
</small>
|
||||
</template>
|
||||
<Message
|
||||
v-if="authActions.accessError.value"
|
||||
severity="info"
|
||||
@@ -152,6 +154,7 @@ import {
|
||||
remoteConfig
|
||||
} from '@/platform/remoteConfig/remoteConfig'
|
||||
import type { SignInData, SignUpData } from '@/schemas/signInSchema'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { isHostWhitelisted, normalizeHost } from '@/utils/hostWhitelist'
|
||||
import { isInChina } from '@/utils/networkUtil'
|
||||
|
||||
|
||||
@@ -1,9 +1,41 @@
|
||||
<template>
|
||||
<div class="keybinding-panel flex flex-col gap-2">
|
||||
<SearchInput
|
||||
v-model="filters['global'].value"
|
||||
:placeholder="$t('g.searchPlaceholder', { subject: $t('g.keybindings') })"
|
||||
/>
|
||||
<Teleport defer to="#keybinding-panel-header">
|
||||
<SearchInput
|
||||
v-model="filters['global'].value"
|
||||
class="max-w-96"
|
||||
size="lg"
|
||||
:placeholder="
|
||||
$t('g.searchPlaceholder', { subject: $t('g.keybindings') })
|
||||
"
|
||||
/>
|
||||
</Teleport>
|
||||
|
||||
<Teleport defer to="#keybinding-panel-actions">
|
||||
<div class="flex items-center gap-2">
|
||||
<KeybindingPresetToolbar
|
||||
:preset-names="presetNames"
|
||||
@presets-changed="refreshPresetList"
|
||||
/>
|
||||
<DropdownMenu
|
||||
:entries="menuEntries"
|
||||
icon="icon-[lucide--ellipsis]"
|
||||
item-class="text-sm gap-2"
|
||||
button-size="unset"
|
||||
button-class="size-10"
|
||||
>
|
||||
<template #button>
|
||||
<Button
|
||||
size="unset"
|
||||
class="size-10"
|
||||
data-testid="keybinding-preset-menu"
|
||||
>
|
||||
<i class="icon-[lucide--ellipsis]" />
|
||||
</Button>
|
||||
</template>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<ContextMenuRoot>
|
||||
<ContextMenuTrigger as-child>
|
||||
@@ -15,6 +47,9 @@
|
||||
data-key="id"
|
||||
:global-filter-fields="['id', 'label']"
|
||||
:filters="filters"
|
||||
:paginator="true"
|
||||
:rows="50"
|
||||
:rows-per-page-options="[25, 50, 100]"
|
||||
selection-mode="single"
|
||||
context-menu
|
||||
striped-rows
|
||||
@@ -77,11 +112,7 @@
|
||||
<span v-if="idx > 0" class="text-muted-foreground">,</span>
|
||||
<KeyComboDisplay
|
||||
:key-combo="binding.combo"
|
||||
:is-modified="
|
||||
keybindingStore.isCommandKeybindingModified(
|
||||
slotProps.data.id
|
||||
)
|
||||
"
|
||||
:is-modified="slotProps.data.isModified"
|
||||
/>
|
||||
</template>
|
||||
<span
|
||||
@@ -141,11 +172,7 @@
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
:aria-label="$t('g.reset')"
|
||||
:disabled="
|
||||
!keybindingStore.isCommandKeybindingModified(
|
||||
slotProps.data.id
|
||||
)
|
||||
"
|
||||
:disabled="!slotProps.data.isModified"
|
||||
@click="resetKeybinding(slotProps.data)"
|
||||
>
|
||||
<i class="icon-[lucide--rotate-ccw]" />
|
||||
@@ -177,11 +204,7 @@
|
||||
}}</span>
|
||||
<KeyComboDisplay
|
||||
:key-combo="binding.combo"
|
||||
:is-modified="
|
||||
keybindingStore.isCommandKeybindingModified(
|
||||
slotProps.data.id
|
||||
)
|
||||
"
|
||||
:is-modified="slotProps.data.isModified"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-row">
|
||||
@@ -234,10 +257,7 @@
|
||||
<ContextMenuSeparator class="my-1 h-px bg-border-subtle" />
|
||||
<ContextMenuItem
|
||||
class="flex cursor-pointer items-center gap-2 rounded-sm px-3 py-2 text-sm text-text-primary outline-none select-none hover:bg-node-component-surface-hovered focus:bg-node-component-surface-hovered data-disabled:cursor-default data-disabled:opacity-50"
|
||||
:disabled="
|
||||
!contextMenuTarget ||
|
||||
!keybindingStore.isCommandKeybindingModified(contextMenuTarget.id)
|
||||
"
|
||||
:disabled="!contextMenuTarget?.isModified"
|
||||
@select="ctxResetToDefault"
|
||||
>
|
||||
<i class="icon-[lucide--rotate-ccw] size-4" />
|
||||
@@ -270,6 +290,7 @@
|
||||
</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'
|
||||
@@ -282,9 +303,10 @@ import {
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger
|
||||
} from 'reka-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import DropdownMenu from '@/components/common/DropdownMenu.vue'
|
||||
import { showConfirmDialog } from '@/components/dialog/confirm/confirmDialog'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||
@@ -292,10 +314,13 @@ import { useEditKeybindingDialog } from '@/composables/useEditKeybindingDialog'
|
||||
import type { 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 { useDialogStore } from '@/stores/dialogStore'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
|
||||
import KeybindingPresetToolbar from './keybinding/KeybindingPresetToolbar.vue'
|
||||
import KeyComboDisplay from './keybinding/KeyComboDisplay.vue'
|
||||
|
||||
const filters = ref({
|
||||
@@ -304,15 +329,97 @@ const filters = ref({
|
||||
|
||||
const keybindingStore = useKeybindingStore()
|
||||
const keybindingService = useKeybindingService()
|
||||
const presetService = useKeybindingPresetService()
|
||||
const settingStore = useSettingStore()
|
||||
const commandStore = useCommandStore()
|
||||
const dialogStore = useDialogStore()
|
||||
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')
|
||||
if (currentName !== 'default') {
|
||||
const preset = await presetService.loadPreset(currentName)
|
||||
if (preset) {
|
||||
keybindingStore.savedPresetData = preset
|
||||
keybindingStore.currentPresetName = currentName
|
||||
} else {
|
||||
await presetService.switchToDefaultPreset()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => initPresets())
|
||||
|
||||
// "..." menu entries (teleported to header)
|
||||
async function saveAsNewPreset() {
|
||||
await presetService.promptAndSaveNewPreset()
|
||||
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
|
||||
keybindings: KeybindingImpl[]
|
||||
label: string
|
||||
source?: string
|
||||
isModified: boolean
|
||||
}
|
||||
|
||||
const commandsData = computed<ICommandData[]>(() => {
|
||||
@@ -323,7 +430,8 @@ const commandsData = computed<ICommandData[]>(() => {
|
||||
command.label ?? ''
|
||||
),
|
||||
keybindings: keybindingStore.getKeybindingsByCommandId(command.id),
|
||||
source: command.source
|
||||
source: command.source,
|
||||
isModified: keybindingStore.isCommandKeybindingModified(command.id)
|
||||
}))
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button v-if="showSaveButton" size="lg" @click="handleSavePreset">
|
||||
{{ $t('g.keybindingPresets.saveChanges') }}
|
||||
</Button>
|
||||
<Select v-model="selectedPreset">
|
||||
<SelectTrigger class="w-64">
|
||||
<SelectValue :placeholder="$t('g.keybindingPresets.default')">
|
||||
{{ displayLabel }}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent 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>
|
||||
<hr class="h-px max-w-60 border border-border-default" />
|
||||
<button
|
||||
class="relative flex w-full max-w-60 cursor-pointer items-center justify-between gap-3 rounded-sm border-none bg-transparent p-2 text-sm outline-none select-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--file-input] shrink-0 text-base-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</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 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 } = defineProps<{
|
||||
presetNames: string[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'presets-changed': []
|
||||
}>()
|
||||
|
||||
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) => {
|
||||
if (newValue !== keybindingStore.currentPresetName) {
|
||||
await presetService.switchPreset(newValue)
|
||||
selectedPreset.value = keybindingStore.currentPresetName
|
||||
emit('presets-changed')
|
||||
}
|
||||
})
|
||||
|
||||
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()
|
||||
emit('presets-changed')
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex w-full max-w-[420px] flex-col border-t border-border-default"
|
||||
>
|
||||
<div class="flex flex-col gap-4 p-4">
|
||||
<p class="m-0 text-sm text-muted-foreground">
|
||||
{{ $t('g.keybindingPresets.unsavedChangesMessage') }}
|
||||
</p>
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="textonly"
|
||||
class="text-muted-foreground"
|
||||
@click="onResult(null)"
|
||||
>
|
||||
{{ $t('g.cancel') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
class="bg-secondary-background"
|
||||
@click="onResult(false)"
|
||||
>
|
||||
{{ $t('g.keybindingPresets.discardAndSwitch') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
class="bg-base-foreground text-base-background"
|
||||
@click="onResult(true)"
|
||||
>
|
||||
{{ $t('g.keybindingPresets.saveAndSwitch') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
const { onResult } = defineProps<{
|
||||
onResult: (result: boolean | null) => void
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<div class="flex w-full items-center p-4">
|
||||
<p class="m-0 text-sm font-medium">
|
||||
{{ $t('g.keybindingPresets.unsavedChangesTo', { name: presetName }) }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { presetName } = defineProps<{
|
||||
presetName: string
|
||||
}>()
|
||||
</script>
|
||||
@@ -287,6 +287,32 @@
|
||||
"browserReservedKeybinding": "This shortcut is reserved by some browsers and may have unexpected results.",
|
||||
"browserReservedKeybindingTooltip": "This shortcut conflicts with browser-reserved shortcuts",
|
||||
"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",
|
||||
"unsavedChangesTo": "Unsaved changes to {name}",
|
||||
"unsavedChangesMessage": "You have unsaved changes that will be lost if you switch without saving.",
|
||||
"discardAndSwitch": "Discard and Switch",
|
||||
"saveAndSwitch": "Save and Switch",
|
||||
"deletePresetTitle": "Delete the current preset?",
|
||||
"deletePresetWarning": "This preset will be deleted. This cannot be undone.",
|
||||
"presetSaved": "Preset \"{name}\" saved",
|
||||
"presetDeleted": "Preset \"{name}\" deleted",
|
||||
"presetImported": "Keybinding preset imported",
|
||||
"invalidPresetFile": "Preset file must be valid JSON exported from ComfyUI",
|
||||
"invalidPresetName": "Preset name must not be empty, \"default\", start with a dot, contain path separators, or end with .json",
|
||||
"loadPresetFailed": "Failed to load preset \"{name}\"",
|
||||
"deletePresetFailed": "Failed to delete preset \"{name}\"",
|
||||
"overwritePresetTitle": "Overwrite Preset",
|
||||
"overwritePresetMessage": "A preset named \"{name}\" already exists. Overwrite it?",
|
||||
"presetNamePrompt": "Enter a name for the preset",
|
||||
"default": "Default Preset"
|
||||
},
|
||||
"commandProhibited": "Command {command} is prohibited. Contact an administrator for more information.",
|
||||
"startRecording": "Start Recording",
|
||||
"stopRecording": "Stop Recording",
|
||||
|
||||
@@ -1,15 +1,54 @@
|
||||
import { groupBy } from 'es-toolkit/compat'
|
||||
import { defineStore } from 'pinia'
|
||||
import type { Ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref, shallowRef } from 'vue'
|
||||
|
||||
import type { KeyComboImpl } from './keyCombo'
|
||||
import type { KeybindingImpl } from './keybinding'
|
||||
import { KeybindingImpl } from './keybinding'
|
||||
import type { KeybindingPreset } from './types'
|
||||
|
||||
export const useKeybindingStore = defineStore('keybinding', () => {
|
||||
const defaultKeybindings = ref<Record<string, KeybindingImpl>>({})
|
||||
const userKeybindings = ref<Record<string, KeybindingImpl>>({})
|
||||
const userUnsetKeybindings = ref<Record<string, KeybindingImpl>>({})
|
||||
const defaultKeybindings = shallowRef<Record<string, KeybindingImpl>>({})
|
||||
const userKeybindings = shallowRef<Record<string, KeybindingImpl>>({})
|
||||
const userUnsetKeybindings = shallowRef<Record<string, KeybindingImpl>>({})
|
||||
|
||||
const currentPresetName = ref('default')
|
||||
const savedPresetData = ref<KeybindingPreset | null>(null)
|
||||
|
||||
const serializeBinding = (b: KeybindingImpl) =>
|
||||
`${b.commandId}:${b.combo.serialize()}:${b.targetElementId ?? ''}`
|
||||
|
||||
const savedPresetSerialized = computed(() => {
|
||||
if (!savedPresetData.value) return null
|
||||
const savedNew = savedPresetData.value.newBindings
|
||||
.map((b) => serializeBinding(new KeybindingImpl(b)))
|
||||
.sort()
|
||||
.join('|')
|
||||
const savedUnset = savedPresetData.value.unsetBindings
|
||||
.map((b) => serializeBinding(new KeybindingImpl(b)))
|
||||
.sort()
|
||||
.join('|')
|
||||
return { savedNew, savedUnset }
|
||||
})
|
||||
|
||||
const isCurrentPresetModified = computed(() => {
|
||||
const newBindings = Object.values(userKeybindings.value)
|
||||
const unsetBindings = Object.values(userUnsetKeybindings.value)
|
||||
|
||||
if (currentPresetName.value === 'default') {
|
||||
return newBindings.length > 0 || unsetBindings.length > 0
|
||||
}
|
||||
|
||||
if (!savedPresetSerialized.value) return false
|
||||
|
||||
const currentNew = newBindings.map(serializeBinding).sort().join('|')
|
||||
const currentUnset = unsetBindings.map(serializeBinding).sort().join('|')
|
||||
|
||||
return (
|
||||
currentNew !== savedPresetSerialized.value.savedNew ||
|
||||
currentUnset !== savedPresetSerialized.value.savedUnset
|
||||
)
|
||||
})
|
||||
|
||||
function getUserKeybindings() {
|
||||
return userKeybindings.value
|
||||
@@ -77,7 +116,10 @@ export const useKeybindingStore = defineStore('keybinding', () => {
|
||||
}`
|
||||
)
|
||||
}
|
||||
target.value[keybinding.combo.serialize()] = keybinding
|
||||
target.value = {
|
||||
...target.value,
|
||||
[keybinding.combo.serialize()]: keybinding
|
||||
}
|
||||
}
|
||||
|
||||
function addDefaultKeybinding(keybinding: KeybindingImpl) {
|
||||
@@ -94,7 +136,9 @@ export const useKeybindingStore = defineStore('keybinding', () => {
|
||||
keybinding.equals(defaultKeybinding) &&
|
||||
keybinding.equals(userUnsetKeybinding)
|
||||
) {
|
||||
delete userUnsetKeybindings.value[keybinding.combo.serialize()]
|
||||
const updated = { ...userUnsetKeybindings.value }
|
||||
delete updated[keybinding.combo.serialize()]
|
||||
userUnsetKeybindings.value = updated
|
||||
return
|
||||
}
|
||||
|
||||
@@ -115,7 +159,9 @@ export const useKeybindingStore = defineStore('keybinding', () => {
|
||||
}
|
||||
|
||||
if (userKeybindings.value[serializedCombo]?.equals(keybinding)) {
|
||||
delete userKeybindings.value[serializedCombo]
|
||||
const updated = { ...userKeybindings.value }
|
||||
delete updated[serializedCombo]
|
||||
userKeybindings.value = updated
|
||||
return
|
||||
}
|
||||
|
||||
@@ -183,31 +229,53 @@ export const useKeybindingStore = defineStore('keybinding', () => {
|
||||
unsetKeybinding(binding)
|
||||
}
|
||||
|
||||
const updatedUnset = { ...userUnsetKeybindings.value }
|
||||
for (const defaultBinding of defaultBindings) {
|
||||
const serializedCombo = defaultBinding.combo.serialize()
|
||||
if (userUnsetKeybindings.value[serializedCombo]?.equals(defaultBinding)) {
|
||||
delete userUnsetKeybindings.value[serializedCombo]
|
||||
if (updatedUnset[serializedCombo]?.equals(defaultBinding)) {
|
||||
delete updatedUnset[serializedCombo]
|
||||
}
|
||||
}
|
||||
userUnsetKeybindings.value = updatedUnset
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const modifiedCommandIds = computed<Set<string>>(() => {
|
||||
const result = new Set<string>()
|
||||
const allCommandIds = new Set([
|
||||
...Object.keys(keybindingsByCommandId.value),
|
||||
...Object.keys(defaultKeybindingsByCommandId.value)
|
||||
])
|
||||
|
||||
for (const commandId of allCommandIds) {
|
||||
const currentBindings = keybindingsByCommandId.value[commandId] ?? []
|
||||
const defaultBindings =
|
||||
defaultKeybindingsByCommandId.value[commandId] ?? []
|
||||
|
||||
if (currentBindings.length !== defaultBindings.length) {
|
||||
result.add(commandId)
|
||||
continue
|
||||
}
|
||||
if (currentBindings.length === 0) continue
|
||||
|
||||
const sortedCurrent = [...currentBindings]
|
||||
.map((b) => b.combo.serialize())
|
||||
.sort()
|
||||
const sortedDefault = [...defaultBindings]
|
||||
.map((b) => b.combo.serialize())
|
||||
.sort()
|
||||
|
||||
if (sortedCurrent.some((combo, i) => combo !== sortedDefault[i])) {
|
||||
result.add(commandId)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
function isCommandKeybindingModified(commandId: string): boolean {
|
||||
const currentBindings = getKeybindingsByCommandId(commandId)
|
||||
const defaultBindings = defaultKeybindingsByCommandId.value[commandId] ?? []
|
||||
|
||||
if (currentBindings.length !== defaultBindings.length) return true
|
||||
if (currentBindings.length === 0) return false
|
||||
|
||||
const sortedCurrent = [...currentBindings]
|
||||
.map((b) => b.combo.serialize())
|
||||
.sort()
|
||||
const sortedDefault = [...defaultBindings]
|
||||
.map((b) => b.combo.serialize())
|
||||
.sort()
|
||||
|
||||
return sortedCurrent.some((combo, i) => combo !== sortedDefault[i])
|
||||
return modifiedCommandIds.value.has(commandId)
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -224,6 +292,9 @@ export const useKeybindingStore = defineStore('keybinding', () => {
|
||||
resetAllKeybindings,
|
||||
resetKeybindingForCommand,
|
||||
isCommandKeybindingModified,
|
||||
currentPresetName,
|
||||
savedPresetData,
|
||||
isCurrentPresetModified,
|
||||
removeAllKeybindingsForCommand,
|
||||
updateSpecificKeybinding
|
||||
}
|
||||
|
||||
665
src/platform/keybindings/presetService.test.ts
Normal file
665
src/platform/keybindings/presetService.test.ts
Normal file
@@ -0,0 +1,665 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { KeybindingImpl } from '@/platform/keybindings/keybinding'
|
||||
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
|
||||
import type { KeybindingPreset } from '@/platform/keybindings/types'
|
||||
|
||||
const mockApi = vi.hoisted(() => ({
|
||||
listUserDataFullInfo: vi.fn(),
|
||||
getUserData: vi.fn(),
|
||||
storeUserData: vi.fn(),
|
||||
deleteUserData: vi.fn()
|
||||
}))
|
||||
|
||||
const mockDownloadBlob = vi.hoisted(() => vi.fn())
|
||||
const mockUploadFile = vi.hoisted(() => vi.fn())
|
||||
const mockConfirm = vi.hoisted(() => vi.fn().mockResolvedValue(true))
|
||||
const mockPrompt = vi.hoisted(() => vi.fn().mockResolvedValue('test-preset'))
|
||||
const mockShowSmallLayoutDialog = vi.hoisted(() =>
|
||||
vi.fn().mockImplementation((options: Record<string, unknown>) => {
|
||||
const props = options.props as Record<string, unknown> | undefined
|
||||
const onResult = props?.onResult as ((v: boolean) => void) | undefined
|
||||
onResult?.(true)
|
||||
})
|
||||
)
|
||||
const mockSettingSet = vi.hoisted(() => vi.fn())
|
||||
const mockToastAdd = vi.hoisted(() => vi.fn())
|
||||
const mockPersistUserKeybindings = vi.hoisted(() =>
|
||||
vi.fn().mockResolvedValue(undefined)
|
||||
)
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: mockApi
|
||||
}))
|
||||
|
||||
vi.mock('@/base/common/downloadUtil', () => ({
|
||||
downloadBlob: mockDownloadBlob
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/utils', () => ({
|
||||
uploadFile: mockUploadFile
|
||||
}))
|
||||
|
||||
vi.mock('@/services/dialogService', () => ({
|
||||
useDialogService: () => ({
|
||||
confirm: mockConfirm,
|
||||
prompt: mockPrompt,
|
||||
showSmallLayoutDialog: mockShowSmallLayoutDialog
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
set: mockSettingSet,
|
||||
get: vi.fn().mockReturnValue('default')
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
useToastStore: () => ({
|
||||
add: mockToastAdd
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useErrorHandling', () => ({
|
||||
useErrorHandling: () => ({
|
||||
wrapWithErrorHandling: <T extends (...args: unknown[]) => unknown>(fn: T) =>
|
||||
fn,
|
||||
wrapWithErrorHandlingAsync: <T extends (...args: unknown[]) => unknown>(
|
||||
fn: T
|
||||
) => fn,
|
||||
toastErrorHandler: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/keybindings/keybindingService', () => ({
|
||||
useKeybindingService: () => ({
|
||||
persistUserKeybindings: mockPersistUserKeybindings
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/dialogStore', () => ({
|
||||
useDialogStore: () => ({
|
||||
showDialog: vi.fn(),
|
||||
closeDialog: vi.fn(),
|
||||
dialogStack: []
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: (key: string) => key
|
||||
}))
|
||||
|
||||
describe('useKeybindingPresetService', () => {
|
||||
let store: ReturnType<typeof useKeybindingStore>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
store = useKeybindingStore()
|
||||
})
|
||||
|
||||
async function getPresetService() {
|
||||
const { useKeybindingPresetService } = await import('./presetService')
|
||||
return useKeybindingPresetService()
|
||||
}
|
||||
|
||||
describe('listPresets', () => {
|
||||
it('parses API response correctly', async () => {
|
||||
mockApi.listUserDataFullInfo.mockResolvedValue([
|
||||
{ path: 'vim.json', size: 100, modified: 123 },
|
||||
{ path: 'emacs.json', size: 200, modified: 456 }
|
||||
])
|
||||
|
||||
const service = await getPresetService()
|
||||
const presets = await service.listPresets()
|
||||
|
||||
expect(mockApi.listUserDataFullInfo).toHaveBeenCalledWith('keybindings')
|
||||
expect(presets).toEqual(['vim', 'emacs'])
|
||||
})
|
||||
|
||||
it('returns empty array when no presets exist', async () => {
|
||||
mockApi.listUserDataFullInfo.mockResolvedValue([])
|
||||
|
||||
const service = await getPresetService()
|
||||
const presets = await service.listPresets()
|
||||
|
||||
expect(presets).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('savePreset', () => {
|
||||
it('calls storeUserData with correct path and data', async () => {
|
||||
mockApi.storeUserData.mockResolvedValue(new Response())
|
||||
|
||||
const keybinding = new KeybindingImpl({
|
||||
commandId: 'test.cmd',
|
||||
combo: { key: 'A', ctrl: true }
|
||||
})
|
||||
store.addUserKeybinding(keybinding)
|
||||
|
||||
const service = await getPresetService()
|
||||
await service.savePreset('my-preset')
|
||||
|
||||
expect(mockApi.storeUserData).toHaveBeenCalledWith(
|
||||
'keybindings/my-preset.json',
|
||||
expect.stringContaining('"name":"my-preset"'),
|
||||
{ overwrite: true, stringify: false }
|
||||
)
|
||||
expect(store.currentPresetName).toBe('my-preset')
|
||||
})
|
||||
|
||||
it('does not update store when storeUserData rejects', async () => {
|
||||
mockApi.storeUserData.mockRejectedValue(new Error('Server error'))
|
||||
|
||||
const keybinding = new KeybindingImpl({
|
||||
commandId: 'test.cmd',
|
||||
combo: { key: 'A', ctrl: true }
|
||||
})
|
||||
store.addUserKeybinding(keybinding)
|
||||
store.currentPresetName = 'old-preset'
|
||||
|
||||
const service = await getPresetService()
|
||||
await expect(service.savePreset('my-preset')).rejects.toThrow(
|
||||
'Server error'
|
||||
)
|
||||
|
||||
expect(store.currentPresetName).toBe('old-preset')
|
||||
expect(store.savedPresetData).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('deletePreset', () => {
|
||||
it('calls deleteUserData and resets to default if active', async () => {
|
||||
mockApi.deleteUserData.mockResolvedValue(
|
||||
new Response(null, { status: 200 })
|
||||
)
|
||||
|
||||
store.currentPresetName = 'vim'
|
||||
store.addUserKeybinding(
|
||||
new KeybindingImpl({
|
||||
commandId: 'test.cmd',
|
||||
combo: { key: 'A', ctrl: true }
|
||||
})
|
||||
)
|
||||
|
||||
const service = await getPresetService()
|
||||
await service.deletePreset('vim')
|
||||
|
||||
expect(mockApi.deleteUserData).toHaveBeenCalledWith(
|
||||
'keybindings/vim.json'
|
||||
)
|
||||
expect(store.currentPresetName).toBe('default')
|
||||
expect(Object.keys(store.getUserKeybindings())).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('throws when deleteUserData response is not ok', async () => {
|
||||
mockApi.deleteUserData.mockResolvedValue(
|
||||
new Response(null, { status: 500 })
|
||||
)
|
||||
|
||||
store.currentPresetName = 'vim'
|
||||
|
||||
const service = await getPresetService()
|
||||
await expect(service.deletePreset('vim')).rejects.toThrow(
|
||||
'g.keybindingPresets.deletePresetFailed'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('exportPreset', () => {
|
||||
it('calls downloadBlob with correct JSON', async () => {
|
||||
store.currentPresetName = 'my-preset'
|
||||
|
||||
const service = await getPresetService()
|
||||
service.exportPreset()
|
||||
|
||||
expect(mockDownloadBlob).toHaveBeenCalledWith(
|
||||
'my-preset.json',
|
||||
expect.any(Blob)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('importPreset', () => {
|
||||
it('validates and rejects invalid files', async () => {
|
||||
mockUploadFile.mockResolvedValue(
|
||||
new File(['{"invalid": true}'], 'bad.json', {
|
||||
type: 'application/json'
|
||||
})
|
||||
)
|
||||
|
||||
const service = await getPresetService()
|
||||
await expect(service.importPreset()).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('saves preset to storage and switches to it', async () => {
|
||||
const validPreset: KeybindingPreset = {
|
||||
name: 'imported',
|
||||
newBindings: [
|
||||
{ commandId: 'test.cmd', combo: { key: 'B', alt: true } }
|
||||
],
|
||||
unsetBindings: []
|
||||
}
|
||||
mockUploadFile.mockResolvedValue(
|
||||
new File([JSON.stringify(validPreset)], 'imported.json', {
|
||||
type: 'application/json'
|
||||
})
|
||||
)
|
||||
mockApi.storeUserData.mockResolvedValue(new Response())
|
||||
mockApi.getUserData.mockResolvedValue(
|
||||
new Response(JSON.stringify(validPreset), { status: 200 })
|
||||
)
|
||||
|
||||
const service = await getPresetService()
|
||||
await service.importPreset()
|
||||
|
||||
expect(mockApi.storeUserData).toHaveBeenCalledWith(
|
||||
'keybindings/imported.json',
|
||||
JSON.stringify(validPreset),
|
||||
{ overwrite: true, stringify: false }
|
||||
)
|
||||
expect(store.currentPresetName).toBe('imported')
|
||||
expect(Object.keys(store.getUserKeybindings())).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('presetFilePath sanitization', () => {
|
||||
it('rejects names with path separators', async () => {
|
||||
const service = await getPresetService()
|
||||
await expect(service.savePreset('../evil')).rejects.toThrow()
|
||||
await expect(service.savePreset('foo/bar')).rejects.toThrow()
|
||||
await expect(service.savePreset('foo\\bar')).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('rejects names starting with a dot', async () => {
|
||||
const service = await getPresetService()
|
||||
await expect(service.savePreset('.hidden')).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('rejects the reserved name "default"', async () => {
|
||||
const service = await getPresetService()
|
||||
await expect(service.savePreset('default')).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('rejects names ending with .json extension', async () => {
|
||||
const service = await getPresetService()
|
||||
await expect(service.savePreset('vim.json')).rejects.toThrow()
|
||||
await expect(service.savePreset('preset.JSON')).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('rejects empty names', async () => {
|
||||
const service = await getPresetService()
|
||||
await expect(service.savePreset('')).rejects.toThrow()
|
||||
await expect(service.savePreset(' ')).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadPreset name override', () => {
|
||||
it('overrides embedded name with the requested name', async () => {
|
||||
const presetData = {
|
||||
name: 'wrong-name',
|
||||
newBindings: [],
|
||||
unsetBindings: []
|
||||
}
|
||||
mockApi.getUserData.mockResolvedValue(
|
||||
new Response(JSON.stringify(presetData), { status: 200 })
|
||||
)
|
||||
|
||||
const service = await getPresetService()
|
||||
const loaded = await service.loadPreset('correct-name')
|
||||
|
||||
expect(loaded?.name).toBe('correct-name')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isCurrentPresetModified', () => {
|
||||
it('detects modifications when on default preset', () => {
|
||||
expect(store.isCurrentPresetModified).toBe(false)
|
||||
|
||||
store.addUserKeybinding(
|
||||
new KeybindingImpl({
|
||||
commandId: 'test.cmd',
|
||||
combo: { key: 'A', ctrl: true }
|
||||
})
|
||||
)
|
||||
|
||||
expect(store.isCurrentPresetModified).toBe(true)
|
||||
})
|
||||
|
||||
it('detects no modifications when saved data matches current state', () => {
|
||||
const keybinding = new KeybindingImpl({
|
||||
commandId: 'test.cmd',
|
||||
combo: { key: 'A', ctrl: true }
|
||||
})
|
||||
store.addUserKeybinding(keybinding)
|
||||
store.currentPresetName = 'my-preset'
|
||||
store.savedPresetData = {
|
||||
name: 'my-preset',
|
||||
newBindings: [
|
||||
{ commandId: 'test.cmd', combo: { key: 'A', ctrl: true } }
|
||||
],
|
||||
unsetBindings: []
|
||||
}
|
||||
|
||||
expect(store.isCurrentPresetModified).toBe(false)
|
||||
})
|
||||
|
||||
it('detects modifications when saved data differs from current state', () => {
|
||||
store.addUserKeybinding(
|
||||
new KeybindingImpl({
|
||||
commandId: 'test.cmd',
|
||||
combo: { key: 'A', ctrl: true }
|
||||
})
|
||||
)
|
||||
store.currentPresetName = 'my-preset'
|
||||
store.savedPresetData = {
|
||||
name: 'my-preset',
|
||||
newBindings: [
|
||||
{ commandId: 'test.cmd', combo: { key: 'B', alt: true } }
|
||||
],
|
||||
unsetBindings: []
|
||||
}
|
||||
|
||||
expect(store.isCurrentPresetModified).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyPreset', () => {
|
||||
it('resets keybindings and applies preset data', async () => {
|
||||
store.addUserKeybinding(
|
||||
new KeybindingImpl({
|
||||
commandId: 'old.cmd',
|
||||
combo: { key: 'Z', ctrl: true }
|
||||
})
|
||||
)
|
||||
|
||||
const preset: KeybindingPreset = {
|
||||
name: 'vim',
|
||||
newBindings: [
|
||||
{ commandId: 'new.cmd', combo: { key: 'A', ctrl: true } }
|
||||
],
|
||||
unsetBindings: []
|
||||
}
|
||||
|
||||
const service = await getPresetService()
|
||||
service.applyPreset(preset)
|
||||
|
||||
expect(store.currentPresetName).toBe('vim')
|
||||
expect(store.savedPresetData?.name).toBe('vim')
|
||||
expect(store.savedPresetData?.newBindings).toHaveLength(1)
|
||||
expect(store.savedPresetData?.newBindings[0].commandId).toBe('new.cmd')
|
||||
expect(Object.keys(store.getUserKeybindings())).toHaveLength(1)
|
||||
const bindings = Object.values(store.getUserKeybindings())
|
||||
expect(bindings[0].commandId).toBe('new.cmd')
|
||||
})
|
||||
})
|
||||
|
||||
describe('switchPreset', () => {
|
||||
it('discards unsaved changes when dialog returns false', async () => {
|
||||
store.addUserKeybinding(
|
||||
new KeybindingImpl({
|
||||
commandId: 'dirty.cmd',
|
||||
combo: { key: 'X', ctrl: true }
|
||||
})
|
||||
)
|
||||
|
||||
mockShowSmallLayoutDialog.mockImplementationOnce(
|
||||
(options: Record<string, unknown>) => {
|
||||
const props = options.props as Record<string, unknown> | undefined
|
||||
const onResult = props?.onResult as ((v: boolean) => void) | undefined
|
||||
onResult?.(false)
|
||||
}
|
||||
)
|
||||
|
||||
const targetPreset: KeybindingPreset = {
|
||||
name: 'vim',
|
||||
newBindings: [
|
||||
{ commandId: 'vim.cmd', combo: { key: 'J', ctrl: false } }
|
||||
],
|
||||
unsetBindings: []
|
||||
}
|
||||
mockApi.getUserData.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify(targetPreset), { status: 200 })
|
||||
)
|
||||
|
||||
const service = await getPresetService()
|
||||
await service.switchPreset('vim')
|
||||
|
||||
expect(store.currentPresetName).toBe('vim')
|
||||
})
|
||||
|
||||
it('saves unsaved changes when dialog returns true on non-default preset', async () => {
|
||||
store.currentPresetName = 'my-preset'
|
||||
store.savedPresetData = {
|
||||
name: 'my-preset',
|
||||
newBindings: [],
|
||||
unsetBindings: []
|
||||
}
|
||||
store.addUserKeybinding(
|
||||
new KeybindingImpl({
|
||||
commandId: 'dirty.cmd',
|
||||
combo: { key: 'X', ctrl: true }
|
||||
})
|
||||
)
|
||||
|
||||
mockApi.storeUserData.mockResolvedValueOnce(new Response())
|
||||
|
||||
const targetPreset: KeybindingPreset = {
|
||||
name: 'vim',
|
||||
newBindings: [
|
||||
{ commandId: 'vim.cmd', combo: { key: 'J', ctrl: false } }
|
||||
],
|
||||
unsetBindings: []
|
||||
}
|
||||
mockApi.getUserData.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify(targetPreset), { status: 200 })
|
||||
)
|
||||
|
||||
const service = await getPresetService()
|
||||
await service.switchPreset('vim')
|
||||
|
||||
expect(mockApi.storeUserData).toHaveBeenCalledWith(
|
||||
'keybindings/my-preset.json',
|
||||
expect.any(String),
|
||||
{ overwrite: true, stringify: false }
|
||||
)
|
||||
expect(store.currentPresetName).toBe('vim')
|
||||
})
|
||||
|
||||
it('cancels switch when dialog returns null', async () => {
|
||||
store.addUserKeybinding(
|
||||
new KeybindingImpl({
|
||||
commandId: 'dirty.cmd',
|
||||
combo: { key: 'X', ctrl: true }
|
||||
})
|
||||
)
|
||||
|
||||
mockShowSmallLayoutDialog.mockImplementationOnce(
|
||||
(options: Record<string, unknown>) => {
|
||||
const dialogComponentProps = options.dialogComponentProps as
|
||||
| Record<string, unknown>
|
||||
| undefined
|
||||
const onClose = dialogComponentProps?.onClose as
|
||||
| (() => void)
|
||||
| undefined
|
||||
onClose?.()
|
||||
}
|
||||
)
|
||||
|
||||
const service = await getPresetService()
|
||||
await service.switchPreset('vim')
|
||||
|
||||
expect(store.currentPresetName).toBe('default')
|
||||
expect(mockApi.getUserData).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('switches without dialog when preset is not modified', async () => {
|
||||
const targetPreset: KeybindingPreset = {
|
||||
name: 'vim',
|
||||
newBindings: [
|
||||
{ commandId: 'vim.cmd', combo: { key: 'J', ctrl: false } }
|
||||
],
|
||||
unsetBindings: []
|
||||
}
|
||||
mockApi.getUserData.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify(targetPreset), { status: 200 })
|
||||
)
|
||||
|
||||
const service = await getPresetService()
|
||||
await service.switchPreset('vim')
|
||||
|
||||
expect(mockShowSmallLayoutDialog).not.toHaveBeenCalled()
|
||||
expect(store.currentPresetName).toBe('vim')
|
||||
})
|
||||
})
|
||||
|
||||
describe('promptAndSaveNewPreset', () => {
|
||||
it('returns false when user cancels prompt', async () => {
|
||||
mockPrompt.mockResolvedValueOnce(null)
|
||||
|
||||
const service = await getPresetService()
|
||||
const result = await service.promptAndSaveNewPreset()
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when user enters empty name', async () => {
|
||||
mockPrompt.mockResolvedValueOnce(' ')
|
||||
|
||||
const service = await getPresetService()
|
||||
const result = await service.promptAndSaveNewPreset()
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('saves successfully with valid name', async () => {
|
||||
mockApi.listUserDataFullInfo.mockResolvedValueOnce([])
|
||||
mockApi.storeUserData.mockResolvedValueOnce(new Response())
|
||||
|
||||
const service = await getPresetService()
|
||||
const result = await service.promptAndSaveNewPreset()
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(mockApi.storeUserData).toHaveBeenCalledWith(
|
||||
'keybindings/test-preset.json',
|
||||
expect.any(String),
|
||||
{ overwrite: true, stringify: false }
|
||||
)
|
||||
})
|
||||
|
||||
it('confirms overwrite when preset name already exists', async () => {
|
||||
mockApi.listUserDataFullInfo.mockResolvedValueOnce([
|
||||
{ path: 'test-preset.json', size: 100, modified: 123 }
|
||||
])
|
||||
mockApi.storeUserData.mockResolvedValueOnce(new Response())
|
||||
|
||||
const service = await getPresetService()
|
||||
const result = await service.promptAndSaveNewPreset()
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(mockConfirm).toHaveBeenCalled()
|
||||
expect(mockApi.storeUserData).toHaveBeenCalledWith(
|
||||
'keybindings/test-preset.json',
|
||||
expect.any(String),
|
||||
{ overwrite: true, stringify: false }
|
||||
)
|
||||
})
|
||||
|
||||
it('returns false when user rejects overwrite', async () => {
|
||||
mockApi.listUserDataFullInfo.mockResolvedValueOnce([
|
||||
{ path: 'test-preset.json', size: 100, modified: 123 }
|
||||
])
|
||||
mockConfirm.mockResolvedValueOnce(false)
|
||||
|
||||
const service = await getPresetService()
|
||||
const result = await service.promptAndSaveNewPreset()
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(mockApi.storeUserData).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('switchToDefaultPreset', () => {
|
||||
it('resets bindings and updates store and settings', async () => {
|
||||
store.addUserKeybinding(
|
||||
new KeybindingImpl({
|
||||
commandId: 'test.cmd',
|
||||
combo: { key: 'A', ctrl: true }
|
||||
})
|
||||
)
|
||||
store.currentPresetName = 'vim'
|
||||
store.savedPresetData = {
|
||||
name: 'vim',
|
||||
newBindings: [],
|
||||
unsetBindings: []
|
||||
}
|
||||
|
||||
const service = await getPresetService()
|
||||
await service.switchToDefaultPreset()
|
||||
|
||||
expect(Object.keys(store.getUserKeybindings())).toHaveLength(0)
|
||||
expect(store.currentPresetName).toBe('default')
|
||||
expect(store.savedPresetData).toBeNull()
|
||||
expect(mockPersistUserKeybindings).toHaveBeenCalled()
|
||||
expect(mockSettingSet).toHaveBeenCalledWith(
|
||||
'Comfy.Keybinding.CurrentPreset',
|
||||
'default'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not reset bindings when resetBindings is false', async () => {
|
||||
store.addUserKeybinding(
|
||||
new KeybindingImpl({
|
||||
commandId: 'test.cmd',
|
||||
combo: { key: 'A', ctrl: true }
|
||||
})
|
||||
)
|
||||
store.currentPresetName = 'vim'
|
||||
|
||||
const service = await getPresetService()
|
||||
await service.switchToDefaultPreset({ resetBindings: false })
|
||||
|
||||
expect(Object.keys(store.getUserKeybindings())).toHaveLength(1)
|
||||
expect(store.currentPresetName).toBe('default')
|
||||
expect(store.savedPresetData).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadPreset error handling', () => {
|
||||
it('throws when API returns non-ok response', async () => {
|
||||
mockApi.getUserData.mockResolvedValueOnce(
|
||||
new Response(null, { status: 404 })
|
||||
)
|
||||
|
||||
const service = await getPresetService()
|
||||
await expect(service.loadPreset('missing')).rejects.toThrow(
|
||||
'g.keybindingPresets.loadPresetFailed'
|
||||
)
|
||||
})
|
||||
|
||||
it('throws when response contains invalid JSON', async () => {
|
||||
mockApi.getUserData.mockResolvedValueOnce(
|
||||
new Response('not-json{{{', { status: 200 })
|
||||
)
|
||||
|
||||
const service = await getPresetService()
|
||||
await expect(service.loadPreset('bad-json')).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('throws when Zod validation fails', async () => {
|
||||
mockApi.getUserData.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ name: 'valid', wrongField: true }), {
|
||||
status: 200
|
||||
})
|
||||
)
|
||||
|
||||
const service = await getPresetService()
|
||||
await expect(service.loadPreset('bad-schema')).rejects.toThrow(
|
||||
'g.keybindingPresets.invalidPresetFile'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
290
src/platform/keybindings/presetService.ts
Normal file
290
src/platform/keybindings/presetService.ts
Normal file
@@ -0,0 +1,290 @@
|
||||
import { toRaw } from 'vue'
|
||||
import { fromZodError } from 'zod-validation-error'
|
||||
|
||||
import { downloadBlob } from '@/base/common/downloadUtil'
|
||||
import UnsavedChangesContent from '@/components/dialog/content/setting/keybinding/UnsavedChangesContent.vue'
|
||||
import UnsavedChangesHeader from '@/components/dialog/content/setting/keybinding/UnsavedChangesHeader.vue'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { t } from '@/i18n'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { api } from '@/scripts/api'
|
||||
import { uploadFile } from '@/scripts/utils'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
import { KeybindingImpl } from './keybinding'
|
||||
import { useKeybindingService } from './keybindingService'
|
||||
import { useKeybindingStore } from './keybindingStore'
|
||||
import type { KeybindingPreset } from './types'
|
||||
import { zKeybindingPreset } from './types'
|
||||
|
||||
const PRESETS_DIR = 'keybindings'
|
||||
|
||||
function presetFilePath(name: string): string {
|
||||
const trimmed = name.trim()
|
||||
if (
|
||||
!trimmed ||
|
||||
trimmed === 'default' ||
|
||||
trimmed.toLowerCase().endsWith('.json') ||
|
||||
trimmed.includes('/') ||
|
||||
trimmed.includes('\\') ||
|
||||
trimmed.includes('..') ||
|
||||
trimmed.startsWith('.')
|
||||
) {
|
||||
throw new Error(t('g.keybindingPresets.invalidPresetName'))
|
||||
}
|
||||
return `${PRESETS_DIR}/${trimmed}.json`
|
||||
}
|
||||
|
||||
function buildPresetFromStore(
|
||||
name: string,
|
||||
keybindingStore: ReturnType<typeof useKeybindingStore>
|
||||
): KeybindingPreset {
|
||||
const newBindings = Object.values(toRaw(keybindingStore.getUserKeybindings()))
|
||||
const unsetBindings = Object.values(
|
||||
toRaw(keybindingStore.getUserUnsetKeybindings())
|
||||
)
|
||||
return { name, newBindings, unsetBindings }
|
||||
}
|
||||
|
||||
export function useKeybindingPresetService() {
|
||||
const keybindingStore = useKeybindingStore()
|
||||
const keybindingService = useKeybindingService()
|
||||
const settingStore = useSettingStore()
|
||||
const dialogService = useDialogService()
|
||||
const dialogStore = useDialogStore()
|
||||
const toast = useToastStore()
|
||||
const { wrapWithErrorHandlingAsync } = useErrorHandling()
|
||||
|
||||
async function switchToDefaultPreset({ resetBindings = true } = {}) {
|
||||
if (resetBindings) keybindingStore.resetAllKeybindings()
|
||||
keybindingStore.currentPresetName = 'default'
|
||||
keybindingStore.savedPresetData = null
|
||||
await keybindingService.persistUserKeybindings()
|
||||
await settingStore.set('Comfy.Keybinding.CurrentPreset', 'default')
|
||||
}
|
||||
|
||||
const UNSAVED_DIALOG_KEY = 'unsaved-keybinding-changes'
|
||||
|
||||
function showUnsavedChangesDialog(
|
||||
presetName: string
|
||||
): Promise<boolean | null> {
|
||||
return new Promise((resolve) => {
|
||||
dialogService.showSmallLayoutDialog({
|
||||
key: UNSAVED_DIALOG_KEY,
|
||||
headerComponent: UnsavedChangesHeader,
|
||||
headerProps: { presetName },
|
||||
component: UnsavedChangesContent,
|
||||
props: {
|
||||
onResult: (result: boolean | null) => {
|
||||
resolve(result)
|
||||
dialogStore.closeDialog({ key: UNSAVED_DIALOG_KEY })
|
||||
}
|
||||
},
|
||||
dialogComponentProps: {
|
||||
onClose: () => resolve(null)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function listPresets(): Promise<string[]> {
|
||||
const files = await api.listUserDataFullInfo(PRESETS_DIR)
|
||||
return files
|
||||
.map((f) => f.path.replace(/\.json$/, ''))
|
||||
.filter((name) => name.length > 0)
|
||||
}
|
||||
|
||||
async function loadPreset(name: string): Promise<KeybindingPreset> {
|
||||
const resp = await api.getUserData(presetFilePath(name))
|
||||
if (!resp.ok) {
|
||||
throw new Error(t('g.keybindingPresets.loadPresetFailed', { name }))
|
||||
}
|
||||
const data = await resp.json()
|
||||
const result = zKeybindingPreset.safeParse(data)
|
||||
if (!result.success) {
|
||||
throw new Error(
|
||||
t('g.keybindingPresets.invalidPresetFile') +
|
||||
': ' +
|
||||
fromZodError(result.error).message
|
||||
)
|
||||
}
|
||||
return { ...result.data, name }
|
||||
}
|
||||
|
||||
function applyPreset(preset: KeybindingPreset) {
|
||||
keybindingStore.resetAllKeybindings()
|
||||
for (const binding of preset.unsetBindings) {
|
||||
keybindingStore.unsetKeybinding(new KeybindingImpl(binding))
|
||||
}
|
||||
for (const binding of preset.newBindings) {
|
||||
keybindingStore.addUserKeybinding(new KeybindingImpl(binding))
|
||||
}
|
||||
// Snapshot savedPresetData from the store's actual state after applying,
|
||||
// because addUserKeybinding may auto-unset conflicting defaults beyond
|
||||
// what the raw preset specifies.
|
||||
keybindingStore.savedPresetData = buildPresetFromStore(
|
||||
preset.name,
|
||||
keybindingStore
|
||||
)
|
||||
keybindingStore.currentPresetName = preset.name
|
||||
}
|
||||
|
||||
async function savePreset(name: string) {
|
||||
const preset = buildPresetFromStore(name, keybindingStore)
|
||||
await api.storeUserData(presetFilePath(name), JSON.stringify(preset), {
|
||||
overwrite: true,
|
||||
stringify: false
|
||||
})
|
||||
keybindingStore.savedPresetData = preset
|
||||
keybindingStore.currentPresetName = name
|
||||
await keybindingService.persistUserKeybindings()
|
||||
await settingStore.set('Comfy.Keybinding.CurrentPreset', name)
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('g.keybindingPresets.presetSaved', { name }),
|
||||
life: 3000
|
||||
})
|
||||
}
|
||||
|
||||
async function deletePreset(name: string) {
|
||||
const confirmed = await dialogService.confirm({
|
||||
title: t('g.keybindingPresets.deletePresetTitle'),
|
||||
message: t('g.keybindingPresets.deletePresetWarning'),
|
||||
type: 'delete'
|
||||
})
|
||||
if (!confirmed) return
|
||||
|
||||
const resp = await api.deleteUserData(presetFilePath(name))
|
||||
if (!resp.ok) {
|
||||
throw new Error(t('g.keybindingPresets.deletePresetFailed', { name }))
|
||||
}
|
||||
|
||||
if (keybindingStore.currentPresetName === name) {
|
||||
await switchToDefaultPreset()
|
||||
}
|
||||
|
||||
toast.add({
|
||||
severity: 'info',
|
||||
summary: t('g.keybindingPresets.presetDeleted', { name }),
|
||||
life: 3000
|
||||
})
|
||||
}
|
||||
|
||||
function exportPreset() {
|
||||
const preset = buildPresetFromStore(
|
||||
keybindingStore.currentPresetName,
|
||||
keybindingStore
|
||||
)
|
||||
downloadBlob(
|
||||
`${preset.name}.json`,
|
||||
new Blob([JSON.stringify(preset, null, 2)], {
|
||||
type: 'application/json'
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
async function importPreset() {
|
||||
const file = await uploadFile('application/json')
|
||||
const text = await file.text()
|
||||
let data: unknown
|
||||
try {
|
||||
data = JSON.parse(text)
|
||||
} catch {
|
||||
throw new Error(t('g.keybindingPresets.invalidPresetFile'))
|
||||
}
|
||||
const result = zKeybindingPreset.safeParse(data)
|
||||
if (!result.success) {
|
||||
throw new Error(
|
||||
t('g.keybindingPresets.invalidPresetFile') +
|
||||
': ' +
|
||||
fromZodError(result.error).message
|
||||
)
|
||||
}
|
||||
const preset = result.data
|
||||
|
||||
// Save the imported preset file to storage
|
||||
await api.storeUserData(
|
||||
presetFilePath(preset.name),
|
||||
JSON.stringify(preset),
|
||||
{ overwrite: true, stringify: false }
|
||||
)
|
||||
|
||||
// Switch to the imported preset (handles dirty check)
|
||||
await switchPreset(preset.name)
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('g.keybindingPresets.presetImported'),
|
||||
life: 3000
|
||||
})
|
||||
}
|
||||
|
||||
async function promptAndSaveNewPreset(): Promise<boolean> {
|
||||
const name = await dialogService.prompt({
|
||||
title: t('g.keybindingPresets.saveAsNewPreset'),
|
||||
message: t('g.keybindingPresets.presetNamePrompt'),
|
||||
defaultValue: ''
|
||||
})
|
||||
if (!name) return false
|
||||
const trimmedName = name.trim()
|
||||
if (!trimmedName) return false
|
||||
const existingPresets = await listPresets()
|
||||
if (existingPresets.includes(trimmedName)) {
|
||||
const overwrite = await dialogService.confirm({
|
||||
title: t('g.keybindingPresets.overwritePresetTitle'),
|
||||
message: t('g.keybindingPresets.overwritePresetMessage', {
|
||||
name: trimmedName
|
||||
}),
|
||||
type: 'overwrite'
|
||||
})
|
||||
if (!overwrite) return false
|
||||
}
|
||||
await savePreset(trimmedName)
|
||||
return true
|
||||
}
|
||||
|
||||
async function switchPreset(targetName: string) {
|
||||
if (keybindingStore.isCurrentPresetModified) {
|
||||
const displayName =
|
||||
keybindingStore.currentPresetName === 'default'
|
||||
? t('g.keybindingPresets.default')
|
||||
: keybindingStore.currentPresetName
|
||||
const result = await showUnsavedChangesDialog(displayName)
|
||||
|
||||
if (result === null) return
|
||||
if (result) {
|
||||
if (keybindingStore.currentPresetName !== 'default') {
|
||||
await savePreset(keybindingStore.currentPresetName)
|
||||
} else {
|
||||
const saved = await promptAndSaveNewPreset()
|
||||
if (!saved) return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (targetName === 'default') {
|
||||
await switchToDefaultPreset()
|
||||
return
|
||||
}
|
||||
|
||||
const preset = await loadPreset(targetName)
|
||||
applyPreset(preset)
|
||||
await keybindingService.persistUserKeybindings()
|
||||
await settingStore.set('Comfy.Keybinding.CurrentPreset', targetName)
|
||||
}
|
||||
|
||||
return {
|
||||
listPresets: wrapWithErrorHandlingAsync(listPresets),
|
||||
loadPreset: wrapWithErrorHandlingAsync(loadPreset),
|
||||
savePreset: wrapWithErrorHandlingAsync(savePreset),
|
||||
deletePreset: wrapWithErrorHandlingAsync(deletePreset),
|
||||
exportPreset,
|
||||
importPreset: wrapWithErrorHandlingAsync(importPreset),
|
||||
switchPreset: wrapWithErrorHandlingAsync(switchPreset),
|
||||
switchToDefaultPreset: wrapWithErrorHandlingAsync(switchToDefaultPreset),
|
||||
promptAndSaveNewPreset: wrapWithErrorHandlingAsync(promptAndSaveNewPreset),
|
||||
applyPreset
|
||||
}
|
||||
}
|
||||
@@ -14,5 +14,12 @@ export const zKeybinding = z.object({
|
||||
targetElementId: z.string().optional()
|
||||
})
|
||||
|
||||
export const zKeybindingPreset = z.object({
|
||||
name: z.string().trim().min(1, 'Preset name cannot be empty'),
|
||||
newBindings: z.array(zKeybinding),
|
||||
unsetBindings: z.array(zKeybinding)
|
||||
})
|
||||
|
||||
export type KeyCombo = z.infer<typeof zKeyCombo>
|
||||
export type Keybinding = z.infer<typeof zKeybinding>
|
||||
export type KeybindingPreset = z.infer<typeof zKeybindingPreset>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -676,6 +676,13 @@ export const CORE_SETTINGS: SettingParams[] = [
|
||||
defaultValue: [] as Keybinding[],
|
||||
versionAdded: '1.3.7'
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Keybinding.CurrentPreset',
|
||||
name: 'Current keybinding preset name',
|
||||
type: 'hidden',
|
||||
defaultValue: 'default',
|
||||
versionAdded: '1.8.8'
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Extension.Disabled',
|
||||
name: 'Disabled extension names',
|
||||
|
||||
@@ -382,6 +382,7 @@ const zSettings = z.object({
|
||||
'Comfy.WorkflowActions.SeenItems': z.array(z.string()),
|
||||
'Comfy.Keybinding.UnsetBindings': z.array(zKeybinding),
|
||||
'Comfy.Keybinding.NewBindings': z.array(zKeybinding),
|
||||
'Comfy.Keybinding.CurrentPreset': z.string(),
|
||||
'Comfy.Extension.Disabled': z.array(z.string()),
|
||||
'Comfy.LinkRenderMode': z.number(),
|
||||
'Comfy.Node.AutoSnapLinkToSlot': z.boolean(),
|
||||
|
||||
@@ -638,6 +638,7 @@ export default defineConfig({
|
||||
|
||||
optimizeDeps: {
|
||||
exclude: ['@comfyorg/comfyui-electron-types'],
|
||||
include: ['primevue/datatable', 'primevue/column'],
|
||||
entries: ['index.html']
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user