mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-21 21:07:33 +00:00
Compare commits
18 Commits
refactor/e
...
refactor/m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d785a49320 | ||
|
|
c90a5402b4 | ||
|
|
7501a3eefc | ||
|
|
3b85227089 | ||
|
|
944f78adf4 | ||
|
|
f15476e33f | ||
|
|
114eeb3d3d | ||
|
|
cb3a88a9e2 | ||
|
|
08845025c0 | ||
|
|
9d9b3784a0 | ||
|
|
18023c0ed1 | ||
|
|
cc05ad2d34 | ||
|
|
b0f8b4c56a | ||
|
|
b3f01ac565 | ||
|
|
941620f485 | ||
|
|
7ea5ea581b | ||
|
|
d92b9912a2 | ||
|
|
57c21d9467 |
@@ -51,6 +51,9 @@
|
||||
# Manager
|
||||
/src/workbench/extensions/manager/ @viva-jinyi @christian-byrne @ltdrdata
|
||||
|
||||
# Model-to-node mappings (cloud team)
|
||||
/src/platform/assets/mappings/ @deepme987
|
||||
|
||||
# LLM Instructions (blank on purpose)
|
||||
.claude/
|
||||
.cursor/
|
||||
|
||||
@@ -68,6 +68,41 @@ export class AppModeHelper {
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject linearData into the current graph and enter app mode.
|
||||
*
|
||||
* Serializes the graph, injects linearData with the given inputs and
|
||||
* auto-detected output node IDs, then reloads so the appModeStore
|
||||
* picks up the data via its activeWorkflow watcher.
|
||||
*
|
||||
* @param inputs - Widget selections as [nodeId, widgetName] tuples
|
||||
*/
|
||||
async enterAppModeWithInputs(inputs: [string, string][]) {
|
||||
await this.page.evaluate(async (inputTuples) => {
|
||||
const graph = window.app!.graph
|
||||
if (!graph) return
|
||||
|
||||
const outputNodeIds = graph.nodes
|
||||
.filter(
|
||||
(n: { type?: string }) =>
|
||||
n.type === 'SaveImage' || n.type === 'PreviewImage'
|
||||
)
|
||||
.map((n: { id: number | string }) => String(n.id))
|
||||
|
||||
const workflow = graph.serialize() as unknown as Record<string, unknown>
|
||||
const extra = (workflow.extra ?? {}) as Record<string, unknown>
|
||||
extra.linearData = { inputs: inputTuples, outputs: outputNodeIds }
|
||||
workflow.extra = extra
|
||||
await window.app!.loadGraphData(
|
||||
workflow as unknown as Parameters<
|
||||
NonNullable<typeof window.app>['loadGraphData']
|
||||
>[0]
|
||||
)
|
||||
}, inputs)
|
||||
await this.comfyPage.nextFrame()
|
||||
await this.toggleAppMode()
|
||||
}
|
||||
|
||||
/** The linear-mode widget list container (visible in app mode). */
|
||||
get linearWidgets(): Locator {
|
||||
return this.page.locator('[data-testid="linear-widgets"]')
|
||||
@@ -125,4 +160,42 @@ export class AppModeHelper {
|
||||
await dialogInput.waitFor({ state: 'hidden' })
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a builder IoItem via the popover menu "Rename" action.
|
||||
* @param title The current widget title shown in the IoItem.
|
||||
* @param newName The new name to assign.
|
||||
*/
|
||||
async renameBuilderInputViaMenu(title: string, newName: string) {
|
||||
const menu = this.getBuilderInputItemMenu(title)
|
||||
await menu.click()
|
||||
await this.page.getByText('Rename', { exact: true }).click()
|
||||
|
||||
const input = this.page
|
||||
.getByTestId(TestIds.builder.ioItemTitle)
|
||||
.getByRole('textbox')
|
||||
await input.fill(newName)
|
||||
await this.page.keyboard.press('Enter')
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a builder IoItem by double-clicking its title to trigger
|
||||
* inline editing.
|
||||
* @param title The current widget title shown in the IoItem.
|
||||
* @param newName The new name to assign.
|
||||
*/
|
||||
async renameBuilderInput(title: string, newName: string) {
|
||||
const titleEl = this.page
|
||||
.getByTestId(TestIds.builder.ioItemTitle)
|
||||
.filter({ hasText: title })
|
||||
await titleEl.dblclick()
|
||||
|
||||
const input = this.page
|
||||
.getByTestId(TestIds.builder.ioItemTitle)
|
||||
.getByRole('textbox')
|
||||
await input.fill(newName)
|
||||
await this.page.keyboard.press('Enter')
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
@@ -61,6 +64,7 @@ export const TestIds = {
|
||||
},
|
||||
builder: {
|
||||
ioItem: 'builder-io-item',
|
||||
ioItemTitle: 'builder-io-item-title',
|
||||
widgetActionsMenu: 'widget-actions-menu'
|
||||
},
|
||||
breadcrumb: {
|
||||
@@ -83,6 +87,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]
|
||||
|
||||
168
browser_tests/tests/appModeDropdownClipping.spec.ts
Normal file
168
browser_tests/tests/appModeDropdownClipping.spec.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../fixtures/ComfyPage'
|
||||
|
||||
/**
|
||||
* Default workflow widget inputs as [nodeId, widgetName] tuples.
|
||||
* All widgets from the default graph are selected so the panel scrolls,
|
||||
* pushing the last widget's dropdown to the clipping boundary.
|
||||
*/
|
||||
const DEFAULT_INPUTS: [string, string][] = [
|
||||
['4', 'ckpt_name'],
|
||||
['6', 'text'],
|
||||
['7', 'text'],
|
||||
['5', 'width'],
|
||||
['5', 'height'],
|
||||
['5', 'batch_size'],
|
||||
['3', 'seed'],
|
||||
['3', 'steps'],
|
||||
['3', 'cfg'],
|
||||
['3', 'sampler_name'],
|
||||
['3', 'scheduler'],
|
||||
['3', 'denoise'],
|
||||
['9', 'filename_prefix']
|
||||
]
|
||||
|
||||
function isClippedByAnyAncestor(el: Element): boolean {
|
||||
const child = el.getBoundingClientRect()
|
||||
let parent = el.parentElement
|
||||
|
||||
while (parent) {
|
||||
const overflow = getComputedStyle(parent).overflow
|
||||
if (overflow !== 'visible') {
|
||||
const p = parent.getBoundingClientRect()
|
||||
if (
|
||||
child.top < p.top ||
|
||||
child.bottom > p.bottom ||
|
||||
child.left < p.left ||
|
||||
child.right > p.right
|
||||
) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
parent = parent.parentElement
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Add a node to the graph by type and return its ID. */
|
||||
async function addNode(page: Page, nodeType: string): Promise<string> {
|
||||
return page.evaluate((type) => {
|
||||
const node = window.app!.graph.add(
|
||||
window.LiteGraph!.createNode(type, undefined, {})
|
||||
)
|
||||
return String(node!.id)
|
||||
}, nodeType)
|
||||
}
|
||||
|
||||
test.describe('App mode dropdown clipping', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.app!.api.serverFeatureFlags.value = {
|
||||
...window.app!.api.serverFeatureFlags.value,
|
||||
linear_toggle_enabled: true
|
||||
}
|
||||
})
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
})
|
||||
|
||||
test('Select dropdown is not clipped in app mode panel', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const saveVideoId = await addNode(comfyPage.page, 'SaveVideo')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const inputs: [string, string][] = [
|
||||
...DEFAULT_INPUTS,
|
||||
[saveVideoId, 'codec']
|
||||
]
|
||||
await comfyPage.appMode.enterAppModeWithInputs(inputs)
|
||||
|
||||
await expect(comfyPage.appMode.linearWidgets).toBeVisible({
|
||||
timeout: 5000
|
||||
})
|
||||
|
||||
// Scroll to bottom so the codec widget is at the clipping edge
|
||||
const widgetList = comfyPage.appMode.linearWidgets
|
||||
await widgetList.evaluate((el) =>
|
||||
el.scrollTo({ top: el.scrollHeight, behavior: 'instant' })
|
||||
)
|
||||
|
||||
// Click the codec select (combobox role with aria-label from WidgetSelectDefault)
|
||||
const codecSelect = widgetList.getByRole('combobox', { name: 'codec' })
|
||||
await codecSelect.click()
|
||||
|
||||
const overlay = comfyPage.page.locator('.p-select-overlay').first()
|
||||
await expect(overlay).toBeVisible({ timeout: 5000 })
|
||||
|
||||
const isInViewport = await overlay.evaluate((el) => {
|
||||
const rect = el.getBoundingClientRect()
|
||||
return (
|
||||
rect.top >= 0 &&
|
||||
rect.left >= 0 &&
|
||||
rect.bottom <= window.innerHeight &&
|
||||
rect.right <= window.innerWidth
|
||||
)
|
||||
})
|
||||
expect(isInViewport).toBe(true)
|
||||
|
||||
const isClipped = await overlay.evaluate(isClippedByAnyAncestor)
|
||||
expect(isClipped).toBe(false)
|
||||
})
|
||||
|
||||
test('FormDropdown popup is not clipped in app mode panel', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const loadImageId = await addNode(comfyPage.page, 'LoadImage')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const inputs: [string, string][] = [
|
||||
...DEFAULT_INPUTS,
|
||||
[loadImageId, 'image']
|
||||
]
|
||||
await comfyPage.appMode.enterAppModeWithInputs(inputs)
|
||||
|
||||
await expect(comfyPage.appMode.linearWidgets).toBeVisible({
|
||||
timeout: 5000
|
||||
})
|
||||
|
||||
// Scroll to bottom so the image widget is at the clipping edge
|
||||
const widgetList = comfyPage.appMode.linearWidgets
|
||||
await widgetList.evaluate((el) =>
|
||||
el.scrollTo({ top: el.scrollHeight, behavior: 'instant' })
|
||||
)
|
||||
|
||||
// Click the FormDropdown trigger button for the image widget.
|
||||
// The button emits 'select-click' which toggles the Popover.
|
||||
const imageRow = widgetList.locator(
|
||||
'div:has(> div > span:text-is("image"))'
|
||||
)
|
||||
const dropdownButton = imageRow.locator('button:has(> span)').first()
|
||||
await dropdownButton.click()
|
||||
|
||||
// The unstyled PrimeVue Popover renders with role="dialog".
|
||||
// Locate the one containing the image grid (filter buttons like "All", "Inputs").
|
||||
const popover = comfyPage.page
|
||||
.getByRole('dialog')
|
||||
.filter({ has: comfyPage.page.getByRole('button', { name: 'All' }) })
|
||||
.first()
|
||||
await expect(popover).toBeVisible({ timeout: 5000 })
|
||||
|
||||
const isInViewport = await popover.evaluate((el) => {
|
||||
const rect = el.getBoundingClientRect()
|
||||
return (
|
||||
rect.top >= 0 &&
|
||||
rect.left >= 0 &&
|
||||
rect.bottom <= window.innerHeight &&
|
||||
rect.right <= window.innerWidth
|
||||
)
|
||||
})
|
||||
expect(isInViewport).toBe(true)
|
||||
|
||||
const isClipped = await popover.evaluate(isClippedByAnyAncestor)
|
||||
expect(isClipped).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -82,7 +82,9 @@ test.describe('App mode widget rename', { tag: ['@ui', '@subgraph'] }, () => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
})
|
||||
|
||||
test('Rename from builder input-select sidebar', async ({ comfyPage }) => {
|
||||
test('Rename from builder input-select sidebar via menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { appMode } = comfyPage
|
||||
await setupSubgraphBuilder(comfyPage)
|
||||
|
||||
@@ -91,11 +93,11 @@ test.describe('App mode widget rename', { tag: ['@ui', '@subgraph'] }, () => {
|
||||
|
||||
const menu = appMode.getBuilderInputItemMenu('seed')
|
||||
await expect(menu).toBeVisible({ timeout: 5000 })
|
||||
await appMode.renameWidget(menu, 'Builder Input Seed')
|
||||
await appMode.renameBuilderInputViaMenu('seed', 'Builder Input Seed')
|
||||
|
||||
// Verify in app mode after save/reload
|
||||
await appMode.exitBuilder()
|
||||
const workflowName = `${new Date().getTime()} builder-input`
|
||||
const workflowName = `${new Date().getTime()} builder-input-menu`
|
||||
await saveAndReopenInAppMode(comfyPage, workflowName)
|
||||
|
||||
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 })
|
||||
@@ -104,6 +106,24 @@ test.describe('App mode widget rename', { tag: ['@ui', '@subgraph'] }, () => {
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Rename from builder input-select sidebar via double-click', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { appMode } = comfyPage
|
||||
await setupSubgraphBuilder(comfyPage)
|
||||
|
||||
await appMode.goToInputs()
|
||||
|
||||
await appMode.renameBuilderInput('seed', 'Dblclick Seed')
|
||||
|
||||
await appMode.exitBuilder()
|
||||
const workflowName = `${new Date().getTime()} builder-input-dblclick`
|
||||
await saveAndReopenInAppMode(comfyPage, workflowName)
|
||||
|
||||
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 })
|
||||
await expect(appMode.linearWidgets.getByText('Dblclick Seed')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Rename from builder preview sidebar', async ({ comfyPage }) => {
|
||||
const { appMode } = comfyPage
|
||||
await setupSubgraphBuilder(comfyPage)
|
||||
|
||||
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' }
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,3 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
@@ -11,58 +9,10 @@ test.describe('Linear Mode', { tag: '@ui' }, () => {
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
async function enterAppMode(comfyPage: {
|
||||
page: Page
|
||||
nextFrame: () => Promise<void>
|
||||
}) {
|
||||
// LinearControls requires hasOutputs to be true. Serialize the current
|
||||
// graph, inject linearData with output node IDs, then reload so the
|
||||
// appModeStore picks up the outputs via its activeWorkflow watcher.
|
||||
await comfyPage.page.evaluate(async () => {
|
||||
const graph = window.app!.graph
|
||||
if (!graph) return
|
||||
|
||||
const outputNodeIds = graph.nodes
|
||||
.filter(
|
||||
(n: { type?: string }) =>
|
||||
n.type === 'SaveImage' || n.type === 'PreviewImage'
|
||||
)
|
||||
.map((n: { id: number | string }) => String(n.id))
|
||||
|
||||
// Serialize, inject linearData, and reload to sync stores
|
||||
const workflow = graph.serialize() as unknown as Record<string, unknown>
|
||||
const extra = (workflow.extra ?? {}) as Record<string, unknown>
|
||||
extra.linearData = { inputs: [], outputs: outputNodeIds }
|
||||
workflow.extra = extra
|
||||
await window.app!.loadGraphData(
|
||||
workflow as unknown as Parameters<
|
||||
NonNullable<typeof window.app>['loadGraphData']
|
||||
>[0]
|
||||
)
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Toggle to app mode via the command which sets canvasStore.linearMode
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.app!.extensionManager.command.execute('Comfy.ToggleLinear')
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
async function enterGraphMode(comfyPage: {
|
||||
page: Page
|
||||
nextFrame: () => Promise<void>
|
||||
}) {
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.app!.extensionManager.command.execute('Comfy.ToggleLinear')
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
test('Displays linear controls when app mode active', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await enterAppMode(comfyPage)
|
||||
await comfyPage.appMode.enterAppModeWithInputs([])
|
||||
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-testid="linear-widgets"]')
|
||||
@@ -70,7 +20,7 @@ test.describe('Linear Mode', { tag: '@ui' }, () => {
|
||||
})
|
||||
|
||||
test('Run button visible in linear mode', async ({ comfyPage }) => {
|
||||
await enterAppMode(comfyPage)
|
||||
await comfyPage.appMode.enterAppModeWithInputs([])
|
||||
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-testid="linear-run-button"]')
|
||||
@@ -78,7 +28,7 @@ test.describe('Linear Mode', { tag: '@ui' }, () => {
|
||||
})
|
||||
|
||||
test('Workflow info section visible', async ({ comfyPage }) => {
|
||||
await enterAppMode(comfyPage)
|
||||
await comfyPage.appMode.enterAppModeWithInputs([])
|
||||
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-testid="linear-workflow-info"]')
|
||||
@@ -86,13 +36,13 @@ test.describe('Linear Mode', { tag: '@ui' }, () => {
|
||||
})
|
||||
|
||||
test('Returns to graph mode', async ({ comfyPage }) => {
|
||||
await enterAppMode(comfyPage)
|
||||
await comfyPage.appMode.enterAppModeWithInputs([])
|
||||
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-testid="linear-widgets"]')
|
||||
).toBeVisible({ timeout: 5000 })
|
||||
|
||||
await enterGraphMode(comfyPage)
|
||||
await comfyPage.appMode.toggleAppMode()
|
||||
|
||||
await expect(comfyPage.canvas).toBeVisible({ timeout: 5000 })
|
||||
await expect(
|
||||
@@ -101,7 +51,7 @@ test.describe('Linear Mode', { tag: '@ui' }, () => {
|
||||
})
|
||||
|
||||
test('Canvas not visible in app mode', async ({ comfyPage }) => {
|
||||
await enterAppMode(comfyPage)
|
||||
await comfyPage.appMode.enterAppModeWithInputs([])
|
||||
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-testid="linear-widgets"]')
|
||||
|
||||
@@ -25,7 +25,7 @@ import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteracti
|
||||
import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue'
|
||||
import { app } from '@/scripts/app'
|
||||
import { DOMWidgetImpl } from '@/scripts/domWidget'
|
||||
import { promptRenameWidget } from '@/utils/widgetUtil'
|
||||
import { renameWidget } from '@/utils/widgetUtil'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { nodeTypeValidForApp, useAppModeStore } from '@/stores/appModeStore'
|
||||
import { resolveNodeWidget } from '@/utils/litegraphUtil'
|
||||
@@ -63,7 +63,7 @@ const inputsWithState = computed(() =>
|
||||
widgetName,
|
||||
label: widget.label,
|
||||
subLabel: node.title,
|
||||
rename: () => promptRenameWidget(widget, node, t)
|
||||
canRename: true
|
||||
}
|
||||
})
|
||||
)
|
||||
@@ -74,6 +74,16 @@ const outputsWithState = computed<[NodeId, string][]>(() =>
|
||||
])
|
||||
)
|
||||
|
||||
function inlineRenameInput(
|
||||
nodeId: NodeId,
|
||||
widgetName: string,
|
||||
newLabel: string
|
||||
) {
|
||||
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
|
||||
if (!node || !widget) return
|
||||
renameWidget(widget, node, newLabel)
|
||||
}
|
||||
|
||||
function getHovered(
|
||||
e: MouseEvent
|
||||
): undefined | [LGraphNode, undefined] | [LGraphNode, IBaseWidget] {
|
||||
@@ -234,7 +244,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
|
||||
widgetName,
|
||||
label,
|
||||
subLabel,
|
||||
rename
|
||||
canRename
|
||||
} in inputsWithState"
|
||||
:key="`${nodeId}: ${widgetName}`"
|
||||
:class="
|
||||
@@ -242,7 +252,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
|
||||
"
|
||||
:title="label ?? widgetName"
|
||||
:sub-title="subLabel"
|
||||
:rename
|
||||
:can-rename="canRename"
|
||||
:remove="
|
||||
() =>
|
||||
remove(
|
||||
@@ -250,6 +260,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
|
||||
([id, name]) => nodeId == id && widgetName === name
|
||||
)
|
||||
"
|
||||
@rename="inlineRenameInput(nodeId, widgetName, $event)"
|
||||
/>
|
||||
</DraggableList>
|
||||
</PropertiesAccordionItem>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { extractVueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { OverlayAppendToKey } from '@/composables/useTransformCompatOverlayProps'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
|
||||
@@ -44,6 +45,7 @@ const appModeStore = useAppModeStore()
|
||||
const maskEditor = useMaskEditor()
|
||||
|
||||
provide(HideLayoutFieldKey, true)
|
||||
provide(OverlayAppendToKey, 'body')
|
||||
|
||||
const graphNodes = shallowRef<LGraphNode[]>(app.rootGraph.nodes)
|
||||
useEventListener(
|
||||
|
||||
@@ -2,31 +2,43 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import EditableText from '@/components/common/EditableText.vue'
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const titleTooltip = ref<string | null>(null)
|
||||
const subTitleTooltip = ref<string | null>(null)
|
||||
const isEditing = ref(false)
|
||||
|
||||
function isTruncated(e: MouseEvent): boolean {
|
||||
const el = e.currentTarget as HTMLElement
|
||||
return el.scrollWidth > el.clientWidth
|
||||
}
|
||||
const { rename, remove } = defineProps<{
|
||||
const { title, canRename, remove } = defineProps<{
|
||||
title: string
|
||||
subTitle?: string
|
||||
rename?: () => void
|
||||
canRename?: boolean
|
||||
remove?: () => void
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
rename: [newName: string]
|
||||
}>()
|
||||
|
||||
function onEditComplete(newName: string) {
|
||||
isEditing.value = false
|
||||
const trimmed = newName.trim()
|
||||
if (trimmed && trimmed !== title) emit('rename', trimmed)
|
||||
}
|
||||
|
||||
const entries = computed(() => {
|
||||
const items = []
|
||||
if (rename)
|
||||
if (canRename)
|
||||
items.push({
|
||||
label: t('g.rename'),
|
||||
command: rename,
|
||||
command: () => setTimeout(() => (isEditing.value = true)),
|
||||
icon: 'icon-[lucide--pencil]'
|
||||
})
|
||||
if (remove)
|
||||
@@ -43,13 +55,24 @@ const entries = computed(() => {
|
||||
class="my-2 flex items-center-safe gap-2 rounded-lg p-2"
|
||||
data-testid="builder-io-item"
|
||||
>
|
||||
<div class="drag-handle mr-auto flex min-w-0 flex-col gap-1">
|
||||
<div
|
||||
v-tooltip.left="{ value: titleTooltip, showDelay: 300 }"
|
||||
class="drag-handle truncate text-sm"
|
||||
<div class="drag-handle mr-auto flex w-full min-w-0 flex-col gap-1">
|
||||
<EditableText
|
||||
:model-value="title"
|
||||
:is-editing="isEditing"
|
||||
:input-attrs="{ class: 'p-1' }"
|
||||
:class="
|
||||
cn(
|
||||
'drag-handle h-5 text-sm',
|
||||
isEditing && 'relative -top-0.5 -left-1 -mt-px mb-px -ml-px',
|
||||
!isEditing && 'truncate'
|
||||
)
|
||||
"
|
||||
data-testid="builder-io-item-title"
|
||||
@mouseenter="titleTooltip = isTruncated($event) ? title : null"
|
||||
v-text="title"
|
||||
label-class="drag-handle"
|
||||
label-type="div"
|
||||
@dblclick="canRename && (isEditing = true)"
|
||||
@edit="onEditComplete"
|
||||
@cancel="isEditing = false"
|
||||
/>
|
||||
<div
|
||||
v-tooltip.left="{ value: subTitleTooltip, showDelay: 300 }"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<div class="editable-text">
|
||||
<span v-if="!isEditing">
|
||||
<component :is="labelType" v-if="!isEditing" :class="labelClass">
|
||||
{{ modelValue }}
|
||||
</span>
|
||||
</component>
|
||||
<!-- Avoid double triggering finishEditing event when keydown.enter is triggered -->
|
||||
<InputText
|
||||
v-else
|
||||
@@ -35,11 +35,15 @@ import { nextTick, ref, watch } from 'vue'
|
||||
const {
|
||||
modelValue,
|
||||
isEditing = false,
|
||||
inputAttrs = {}
|
||||
inputAttrs = {},
|
||||
labelClass = '',
|
||||
labelType = 'span'
|
||||
} = defineProps<{
|
||||
modelValue: string
|
||||
isEditing?: boolean
|
||||
inputAttrs?: Record<string, string>
|
||||
labelClass?: string
|
||||
labelType?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['edit', 'cancel'])
|
||||
|
||||
@@ -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>
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { HintedString } from '@primevue/core'
|
||||
import { computed } from 'vue'
|
||||
import type { InjectionKey } from 'vue'
|
||||
import { computed, inject } from 'vue'
|
||||
|
||||
/**
|
||||
* Options for configuring transform-compatible overlay props
|
||||
@@ -15,6 +16,10 @@ interface TransformCompatOverlayOptions {
|
||||
// autoZIndex?: boolean
|
||||
}
|
||||
|
||||
export const OverlayAppendToKey: InjectionKey<
|
||||
HintedString<'body' | 'self'> | undefined | HTMLElement
|
||||
> = Symbol('OverlayAppendTo')
|
||||
|
||||
/**
|
||||
* Composable that provides props to make PrimeVue overlay components
|
||||
* compatible with CSS-transformed parent elements.
|
||||
@@ -41,8 +46,10 @@ interface TransformCompatOverlayOptions {
|
||||
export function useTransformCompatOverlayProps(
|
||||
overrides: TransformCompatOverlayOptions = {}
|
||||
) {
|
||||
const injectedAppendTo = inject(OverlayAppendToKey, undefined)
|
||||
|
||||
return computed(() => ({
|
||||
appendTo: 'self' as const,
|
||||
appendTo: injectedAppendTo ?? ('self' as const),
|
||||
...overrides
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
174
src/platform/assets/mappings/modelNodeMappings.ts
Normal file
174
src/platform/assets/mappings/modelNodeMappings.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Default mappings from model directories to loader nodes.
|
||||
*
|
||||
* Each entry maps a model folder (as it appears in the model browser)
|
||||
* to the node class that loads models from that folder and the
|
||||
* input key where the model name is inserted.
|
||||
*
|
||||
* An empty key ('') means the node auto-loads models without a widget
|
||||
* selector (createModelNodeFromAsset skips widget assignment).
|
||||
*
|
||||
* Hierarchical fallback is handled by the store: "a/b/c" tries
|
||||
* "a/b/c" → "a/b" → "a", so registering a parent directory covers
|
||||
* all its children unless a more specific entry exists.
|
||||
*
|
||||
* Format: [modelDirectory, nodeClass, inputKey]
|
||||
*/
|
||||
export const MODEL_NODE_MAPPINGS: ReadonlyArray<
|
||||
readonly [string, string, string]
|
||||
> = [
|
||||
// ---- ComfyUI core loaders ----
|
||||
['checkpoints', 'CheckpointLoaderSimple', 'ckpt_name'],
|
||||
['checkpoints', 'ImageOnlyCheckpointLoader', 'ckpt_name'],
|
||||
['loras', 'LoraLoader', 'lora_name'],
|
||||
['loras', 'LoraLoaderModelOnly', 'lora_name'],
|
||||
['vae', 'VAELoader', 'vae_name'],
|
||||
['controlnet', 'ControlNetLoader', 'control_net_name'],
|
||||
['diffusion_models', 'UNETLoader', 'unet_name'],
|
||||
['upscale_models', 'UpscaleModelLoader', 'model_name'],
|
||||
['style_models', 'StyleModelLoader', 'style_model_name'],
|
||||
['gligen', 'GLIGENLoader', 'gligen_name'],
|
||||
['clip_vision', 'CLIPVisionLoader', 'clip_name'],
|
||||
['text_encoders', 'CLIPLoader', 'clip_name'],
|
||||
['audio_encoders', 'AudioEncoderLoader', 'audio_encoder_name'],
|
||||
['model_patches', 'ModelPatchLoader', 'name'],
|
||||
['latent_upscale_models', 'LatentUpscaleModelLoader', 'model_name'],
|
||||
['clip', 'CLIPVisionLoader', 'clip_name'],
|
||||
|
||||
// ---- AnimateDiff (comfyui-animatediff-evolved) ----
|
||||
['animatediff_models', 'ADE_LoadAnimateDiffModel', 'model_name'],
|
||||
['animatediff_motion_lora', 'ADE_AnimateDiffLoRALoader', 'name'],
|
||||
|
||||
// ---- Chatterbox TTS (ComfyUI-Fill-Nodes) ----
|
||||
['chatterbox/chatterbox', 'FL_ChatterboxTTS', ''],
|
||||
['chatterbox/chatterbox_turbo', 'FL_ChatterboxTurboTTS', ''],
|
||||
['chatterbox/chatterbox_multilingual', 'FL_ChatterboxMultilingualTTS', ''],
|
||||
['chatterbox/chatterbox_vc', 'FL_ChatterboxVC', ''],
|
||||
|
||||
// ---- SAM / SAM2 (comfyui-segment-anything-2, comfyui-impact-pack) ----
|
||||
['sam2', 'DownloadAndLoadSAM2Model', 'model'],
|
||||
['sams', 'SAMLoader', 'model_name'],
|
||||
|
||||
// ---- SAM3 3D segmentation (comfyui-sam3) ----
|
||||
['sam3', 'LoadSAM3Model', 'model_path'],
|
||||
|
||||
// ---- Ultralytics detection (comfyui-impact-subpack) ----
|
||||
['ultralytics', 'UltralyticsDetectorProvider', 'model_name'],
|
||||
|
||||
// ---- DepthAnything (comfyui-depthanythingv2, comfyui-depthanythingv3) ----
|
||||
['depthanything', 'DownloadAndLoadDepthAnythingV2Model', 'model'],
|
||||
['depthanything3', 'DownloadAndLoadDepthAnythingV3Model', 'model'],
|
||||
|
||||
// ---- IP-Adapter (comfyui_ipadapter_plus) ----
|
||||
['ipadapter', 'IPAdapterModelLoader', 'ipadapter_file'],
|
||||
|
||||
// ---- Segformer (comfyui_layerstyle) ----
|
||||
['segformer_b2_clothes', 'LS_LoadSegformerModel', 'model_name'],
|
||||
['segformer_b3_clothes', 'LS_LoadSegformerModel', 'model_name'],
|
||||
['segformer_b3_fashion', 'LS_LoadSegformerModel', 'model_name'],
|
||||
|
||||
// ---- NLF pose estimation (ComfyUI-WanVideoWrapper) ----
|
||||
['nlf', 'LoadNLFModel', 'nlf_model'],
|
||||
|
||||
// ---- FlashVSR video super-resolution (ComfyUI-FlashVSR_Ultra_Fast) ----
|
||||
['FlashVSR', 'FlashVSRNode', ''],
|
||||
['FlashVSR-v1.1', 'FlashVSRNode', ''],
|
||||
|
||||
// ---- SEEDVR2 video upscaling (comfyui-seedvr2) ----
|
||||
['SEEDVR2', 'SeedVR2LoadDiTModel', 'model'],
|
||||
|
||||
// ---- Qwen VL vision-language (comfyui-qwen-vl) ----
|
||||
['LLM/Qwen-VL/Qwen2.5-VL-3B-Instruct', 'AILab_QwenVL', 'model_name'],
|
||||
['LLM/Qwen-VL/Qwen2.5-VL-7B-Instruct', 'AILab_QwenVL', 'model_name'],
|
||||
['LLM/Qwen-VL/Qwen3-VL-2B-Instruct', 'AILab_QwenVL', 'model_name'],
|
||||
['LLM/Qwen-VL/Qwen3-VL-2B-Thinking', 'AILab_QwenVL', 'model_name'],
|
||||
['LLM/Qwen-VL/Qwen3-VL-4B-Instruct', 'AILab_QwenVL', 'model_name'],
|
||||
['LLM/Qwen-VL/Qwen3-VL-4B-Thinking', 'AILab_QwenVL', 'model_name'],
|
||||
['LLM/Qwen-VL/Qwen3-VL-8B-Instruct', 'AILab_QwenVL', 'model_name'],
|
||||
['LLM/Qwen-VL/Qwen3-VL-8B-Thinking', 'AILab_QwenVL', 'model_name'],
|
||||
['LLM/Qwen-VL/Qwen3-VL-32B-Instruct', 'AILab_QwenVL', 'model_name'],
|
||||
['LLM/Qwen-VL/Qwen3-VL-32B-Thinking', 'AILab_QwenVL', 'model_name'],
|
||||
['LLM/Qwen-VL/Qwen3-0.6B', 'AILab_QwenVL_PromptEnhancer', 'model_name'],
|
||||
[
|
||||
'LLM/Qwen-VL/Qwen3-4B-Instruct-2507',
|
||||
'AILab_QwenVL_PromptEnhancer',
|
||||
'model_name'
|
||||
],
|
||||
['LLM/checkpoints', 'LoadChatGLM3', 'chatglm3_checkpoint'],
|
||||
|
||||
// ---- Qwen3 TTS (ComfyUI-FunBox) ----
|
||||
['qwen-tts', 'FB_Qwen3TTSVoiceClone', 'model_choice'],
|
||||
|
||||
// ---- LivePortrait (comfyui-liveportrait) ----
|
||||
['liveportrait', 'DownloadAndLoadLivePortraitModels', ''],
|
||||
|
||||
// ---- MimicMotion (ComfyUI-MimicMotionWrapper) ----
|
||||
['mimicmotion', 'DownloadAndLoadMimicMotionModel', 'model'],
|
||||
['dwpose', 'MimicMotionGetPoses', ''],
|
||||
|
||||
// ---- Face parsing (comfyui_face_parsing) ----
|
||||
['face_parsing', 'FaceParsingModelLoader(FaceParsing)', ''],
|
||||
|
||||
// ---- Kolors (ComfyUI-KolorsWrapper) ----
|
||||
['diffusers', 'DownloadAndLoadKolorsModel', 'model'],
|
||||
|
||||
// ---- RIFE video frame interpolation (ComfyUI-RIFE) ----
|
||||
['rife', 'RIFE VFI', 'ckpt_name'],
|
||||
|
||||
// ---- UltraShape 3D model generation ----
|
||||
['UltraShape', 'UltraShapeLoadModel', 'checkpoint'],
|
||||
|
||||
// ---- SHaRP depth estimation ----
|
||||
['sharp', 'LoadSharpModel', 'checkpoint_path'],
|
||||
|
||||
// ---- ONNX upscale models ----
|
||||
['onnx', 'UpscaleModelLoader', 'model_name'],
|
||||
|
||||
// ---- Detection models (vitpose, yolo) ----
|
||||
['detection', 'OnnxDetectionModelLoader', 'yolo_model'],
|
||||
|
||||
// ---- HunyuanVideo text encoders (ComfyUI-HunyuanVideoWrapper) ----
|
||||
[
|
||||
'LLM/llava-llama-3-8b-text-encoder-tokenizer',
|
||||
'DownloadAndLoadHyVideoTextEncoder',
|
||||
'llm_model'
|
||||
],
|
||||
[
|
||||
'LLM/llava-llama-3-8b-v1_1-transformers',
|
||||
'DownloadAndLoadHyVideoTextEncoder',
|
||||
'llm_model'
|
||||
],
|
||||
|
||||
// ---- CogVideoX (comfyui-cogvideoxwrapper) ----
|
||||
['CogVideo', 'DownloadAndLoadCogVideoModel', ''],
|
||||
['CogVideo/GGUF', 'DownloadAndLoadCogVideoGGUFModel', 'model'],
|
||||
['CogVideo/ControlNet', 'DownloadAndLoadCogVideoControlNet', ''],
|
||||
|
||||
// ---- DynamiCrafter (ComfyUI-DynamiCrafterWrapper) ----
|
||||
['checkpoints/dynamicrafter', 'DownloadAndLoadDynamiCrafterModel', 'model'],
|
||||
[
|
||||
'checkpoints/dynamicrafter/controlnet',
|
||||
'DownloadAndLoadDynamiCrafterCNModel',
|
||||
'model'
|
||||
],
|
||||
|
||||
// ---- LayerStyle (ComfyUI_LayerStyle_Advance) ----
|
||||
['BEN', 'LS_LoadBenModel', 'model'],
|
||||
['BiRefNet/pth', 'LS_LoadBiRefNetModel', 'model'],
|
||||
['onnx/human-parts', 'LS_HumanPartsUltra', ''],
|
||||
['lama', 'LaMa', 'lama_model'],
|
||||
|
||||
// ---- Inpaint (comfyui-inpaint-nodes) ----
|
||||
['inpaint', 'INPAINT_LoadInpaintModel', 'model_name'],
|
||||
|
||||
// ---- LayerDiffuse (comfyui-layerdiffuse) ----
|
||||
['layer_model', 'LayeredDiffusionApply', 'config'],
|
||||
|
||||
// ---- LTX Video prompt enhancer (ComfyUI-LTXTricks) ----
|
||||
['LLM/Llama-3.2-3B-Instruct', 'LTXVPromptEnhancerLoader', 'llm_name'],
|
||||
[
|
||||
'LLM/Florence-2-large-PromptGen-v2.0',
|
||||
'LTXVPromptEnhancerLoader',
|
||||
'image_captioner_name'
|
||||
]
|
||||
] as const satisfies ReadonlyArray<readonly [string, string, string]>
|
||||
@@ -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',
|
||||
|
||||
@@ -4,6 +4,7 @@ import Popover from 'primevue/popover'
|
||||
import { computed, ref, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
|
||||
import type {
|
||||
@@ -50,6 +51,7 @@ interface Props {
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
const overlayProps = useTransformCompatOverlayProps()
|
||||
|
||||
const {
|
||||
placeholder,
|
||||
@@ -209,6 +211,7 @@ function handleSelection(item: FormDropdownItem, index: number) {
|
||||
ref="popoverRef"
|
||||
:dismissable="true"
|
||||
:close-on-escape="true"
|
||||
:append-to="overlayProps.appendTo"
|
||||
unstyled
|
||||
:pt="{
|
||||
root: {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ref, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
|
||||
import type {
|
||||
FilterOption,
|
||||
OwnershipFilterOption,
|
||||
@@ -15,6 +16,7 @@ import FormSearchInput from '../FormSearchInput.vue'
|
||||
import type { LayoutMode, SortOption } from './types'
|
||||
|
||||
const { t } = useI18n()
|
||||
const overlayProps = useTransformCompatOverlayProps()
|
||||
|
||||
defineProps<{
|
||||
sortOptions: SortOption[]
|
||||
@@ -132,6 +134,7 @@ function toggleBaseModelSelection(item: FilterOption) {
|
||||
ref="sortPopoverRef"
|
||||
:dismissable="true"
|
||||
:close-on-escape="true"
|
||||
:append-to="overlayProps.appendTo"
|
||||
unstyled
|
||||
:pt="{
|
||||
root: {
|
||||
@@ -194,6 +197,7 @@ function toggleBaseModelSelection(item: FilterOption) {
|
||||
ref="ownershipPopoverRef"
|
||||
:dismissable="true"
|
||||
:close-on-escape="true"
|
||||
:append-to="overlayProps.appendTo"
|
||||
unstyled
|
||||
:pt="{
|
||||
root: {
|
||||
@@ -256,6 +260,7 @@ function toggleBaseModelSelection(item: FilterOption) {
|
||||
ref="baseModelPopoverRef"
|
||||
:dismissable="true"
|
||||
:close-on-escape="true"
|
||||
:append-to="overlayProps.appendTo"
|
||||
unstyled
|
||||
:pt="{
|
||||
root: {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { MODEL_NODE_MAPPINGS } from '@/platform/assets/mappings/modelNodeMappings'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
|
||||
@@ -156,254 +157,9 @@ export const useModelToNodeStore = defineStore('modelToNode', () => {
|
||||
}
|
||||
haveDefaultsLoaded.value = true
|
||||
|
||||
quickRegister('checkpoints', 'CheckpointLoaderSimple', 'ckpt_name')
|
||||
quickRegister('checkpoints', 'ImageOnlyCheckpointLoader', 'ckpt_name')
|
||||
quickRegister('loras', 'LoraLoader', 'lora_name')
|
||||
quickRegister('loras', 'LoraLoaderModelOnly', 'lora_name')
|
||||
quickRegister('vae', 'VAELoader', 'vae_name')
|
||||
quickRegister('controlnet', 'ControlNetLoader', 'control_net_name')
|
||||
quickRegister('diffusion_models', 'UNETLoader', 'unet_name')
|
||||
quickRegister('upscale_models', 'UpscaleModelLoader', 'model_name')
|
||||
quickRegister('style_models', 'StyleModelLoader', 'style_model_name')
|
||||
quickRegister('gligen', 'GLIGENLoader', 'gligen_name')
|
||||
quickRegister('clip_vision', 'CLIPVisionLoader', 'clip_name')
|
||||
quickRegister('text_encoders', 'CLIPLoader', 'clip_name')
|
||||
quickRegister('audio_encoders', 'AudioEncoderLoader', 'audio_encoder_name')
|
||||
quickRegister('model_patches', 'ModelPatchLoader', 'name')
|
||||
quickRegister(
|
||||
'animatediff_models',
|
||||
'ADE_LoadAnimateDiffModel',
|
||||
'model_name'
|
||||
)
|
||||
quickRegister(
|
||||
'animatediff_motion_lora',
|
||||
'ADE_AnimateDiffLoRALoader',
|
||||
'name'
|
||||
)
|
||||
|
||||
// Chatterbox TTS nodes: empty key means the node auto-loads models without
|
||||
// a widget selector (createModelNodeFromAsset skips widget assignment)
|
||||
quickRegister('chatterbox/chatterbox', 'FL_ChatterboxTTS', '')
|
||||
quickRegister('chatterbox/chatterbox_turbo', 'FL_ChatterboxTurboTTS', '')
|
||||
quickRegister(
|
||||
'chatterbox/chatterbox_multilingual',
|
||||
'FL_ChatterboxMultilingualTTS',
|
||||
''
|
||||
)
|
||||
quickRegister('chatterbox/chatterbox_vc', 'FL_ChatterboxVC', '')
|
||||
|
||||
// Latent upscale models (ComfyUI core - nodes_hunyuan.py)
|
||||
quickRegister(
|
||||
'latent_upscale_models',
|
||||
'LatentUpscaleModelLoader',
|
||||
'model_name'
|
||||
)
|
||||
|
||||
// SAM/SAM2 segmentation models (comfyui-segment-anything-2, comfyui-impact-pack)
|
||||
quickRegister('sam2', 'DownloadAndLoadSAM2Model', 'model')
|
||||
quickRegister('sams', 'SAMLoader', 'model_name')
|
||||
|
||||
// Ultralytics detection models (comfyui-impact-subpack)
|
||||
// Note: ultralytics/bbox and ultralytics/segm fall back to this via hierarchical lookup
|
||||
quickRegister('ultralytics', 'UltralyticsDetectorProvider', 'model_name')
|
||||
|
||||
// DepthAnything models (comfyui-depthanythingv2)
|
||||
quickRegister(
|
||||
'depthanything',
|
||||
'DownloadAndLoadDepthAnythingV2Model',
|
||||
'model'
|
||||
)
|
||||
|
||||
// IP-Adapter models (comfyui_ipadapter_plus)
|
||||
quickRegister('ipadapter', 'IPAdapterModelLoader', 'ipadapter_file')
|
||||
|
||||
// Segformer clothing/fashion segmentation models (comfyui_layerstyle)
|
||||
quickRegister('segformer_b2_clothes', 'LS_LoadSegformerModel', 'model_name')
|
||||
quickRegister('segformer_b3_clothes', 'LS_LoadSegformerModel', 'model_name')
|
||||
quickRegister('segformer_b3_fashion', 'LS_LoadSegformerModel', 'model_name')
|
||||
|
||||
// NLF pose estimation models (ComfyUI-WanVideoWrapper)
|
||||
quickRegister('nlf', 'LoadNLFModel', 'nlf_model')
|
||||
|
||||
// FlashVSR video super-resolution (ComfyUI-FlashVSR_Ultra_Fast)
|
||||
// Empty key means the node auto-loads models without a widget selector
|
||||
quickRegister('FlashVSR', 'FlashVSRNode', '')
|
||||
quickRegister('FlashVSR-v1.1', 'FlashVSRNode', '')
|
||||
|
||||
// SEEDVR2 video upscaling (comfyui-seedvr2)
|
||||
quickRegister('SEEDVR2', 'SeedVR2LoadDiTModel', 'model')
|
||||
|
||||
// Qwen VL vision-language models (comfyui-qwen-vl)
|
||||
// Register each specific path to avoid LLM fallback catching unrelated models
|
||||
// (e.g., LLM/llava-* should NOT map to AILab_QwenVL)
|
||||
quickRegister(
|
||||
'LLM/Qwen-VL/Qwen2.5-VL-3B-Instruct',
|
||||
'AILab_QwenVL',
|
||||
'model_name'
|
||||
)
|
||||
quickRegister(
|
||||
'LLM/Qwen-VL/Qwen2.5-VL-7B-Instruct',
|
||||
'AILab_QwenVL',
|
||||
'model_name'
|
||||
)
|
||||
quickRegister(
|
||||
'LLM/Qwen-VL/Qwen3-VL-2B-Instruct',
|
||||
'AILab_QwenVL',
|
||||
'model_name'
|
||||
)
|
||||
quickRegister(
|
||||
'LLM/Qwen-VL/Qwen3-VL-2B-Thinking',
|
||||
'AILab_QwenVL',
|
||||
'model_name'
|
||||
)
|
||||
quickRegister(
|
||||
'LLM/Qwen-VL/Qwen3-VL-4B-Instruct',
|
||||
'AILab_QwenVL',
|
||||
'model_name'
|
||||
)
|
||||
quickRegister(
|
||||
'LLM/Qwen-VL/Qwen3-VL-4B-Thinking',
|
||||
'AILab_QwenVL',
|
||||
'model_name'
|
||||
)
|
||||
quickRegister(
|
||||
'LLM/Qwen-VL/Qwen3-VL-8B-Instruct',
|
||||
'AILab_QwenVL',
|
||||
'model_name'
|
||||
)
|
||||
quickRegister(
|
||||
'LLM/Qwen-VL/Qwen3-VL-8B-Thinking',
|
||||
'AILab_QwenVL',
|
||||
'model_name'
|
||||
)
|
||||
quickRegister(
|
||||
'LLM/Qwen-VL/Qwen3-VL-32B-Instruct',
|
||||
'AILab_QwenVL',
|
||||
'model_name'
|
||||
)
|
||||
quickRegister(
|
||||
'LLM/Qwen-VL/Qwen3-VL-32B-Thinking',
|
||||
'AILab_QwenVL',
|
||||
'model_name'
|
||||
)
|
||||
quickRegister(
|
||||
'LLM/Qwen-VL/Qwen3-0.6B',
|
||||
'AILab_QwenVL_PromptEnhancer',
|
||||
'model_name'
|
||||
)
|
||||
quickRegister(
|
||||
'LLM/Qwen-VL/Qwen3-4B-Instruct-2507',
|
||||
'AILab_QwenVL_PromptEnhancer',
|
||||
'model_name'
|
||||
)
|
||||
quickRegister('LLM/checkpoints', 'LoadChatGLM3', 'chatglm3_checkpoint')
|
||||
|
||||
// Qwen3 TTS speech models (ComfyUI-FunBox)
|
||||
// Top-level 'qwen-tts' catches all qwen-tts/* subdirs via hierarchical fallback
|
||||
quickRegister('qwen-tts', 'FB_Qwen3TTSVoiceClone', 'model_choice')
|
||||
|
||||
// DepthAnything V3 models (comfyui-depthanythingv2)
|
||||
quickRegister(
|
||||
'depthanything3',
|
||||
'DownloadAndLoadDepthAnythingV3Model',
|
||||
'model'
|
||||
)
|
||||
|
||||
// LivePortrait face animation models (comfyui-liveportrait)
|
||||
quickRegister('liveportrait', 'DownloadAndLoadLivePortraitModels', '')
|
||||
|
||||
// MimicMotion video generation models (ComfyUI-MimicMotionWrapper)
|
||||
quickRegister('mimicmotion', 'DownloadAndLoadMimicMotionModel', 'model')
|
||||
quickRegister('dwpose', 'MimicMotionGetPoses', '')
|
||||
|
||||
// Face parsing segmentation models (comfyui_face_parsing)
|
||||
quickRegister('face_parsing', 'FaceParsingModelLoader(FaceParsing)', '')
|
||||
|
||||
// Kolors image generation models (ComfyUI-KolorsWrapper)
|
||||
// Top-level 'diffusers' catches diffusers/Kolors/* subdirs
|
||||
quickRegister('diffusers', 'DownloadAndLoadKolorsModel', 'model')
|
||||
|
||||
// CLIP models for HunyuanVideo (clip/clip-vit-large-patch14 subdir)
|
||||
quickRegister('clip', 'CLIPVisionLoader', 'clip_name')
|
||||
|
||||
// RIFE video frame interpolation (ComfyUI-RIFE)
|
||||
quickRegister('rife', 'RIFE VFI', 'ckpt_name')
|
||||
|
||||
// SAM3 3D segmentation models (comfyui-sam3)
|
||||
quickRegister('sam3', 'LoadSAM3Model', 'model_path')
|
||||
|
||||
// UltraShape 3D model generation
|
||||
quickRegister('UltraShape', 'UltraShapeLoadModel', 'checkpoint')
|
||||
|
||||
// SHaRP depth estimation
|
||||
quickRegister('sharp', 'LoadSharpModel', 'checkpoint_path')
|
||||
|
||||
// ONNX upscale models (used by OnnxDetectionModelLoader and upscale nodes)
|
||||
quickRegister('onnx', 'UpscaleModelLoader', 'model_name')
|
||||
|
||||
// Detection models (vitpose, yolo)
|
||||
quickRegister('detection', 'OnnxDetectionModelLoader', 'yolo_model')
|
||||
|
||||
// HunyuanVideo text encoders (ComfyUI-HunyuanVideoWrapper)
|
||||
quickRegister(
|
||||
'LLM/llava-llama-3-8b-text-encoder-tokenizer',
|
||||
'DownloadAndLoadHyVideoTextEncoder',
|
||||
'llm_model'
|
||||
)
|
||||
quickRegister(
|
||||
'LLM/llava-llama-3-8b-v1_1-transformers',
|
||||
'DownloadAndLoadHyVideoTextEncoder',
|
||||
'llm_model'
|
||||
)
|
||||
|
||||
// CogVideoX models (comfyui-cogvideoxwrapper)
|
||||
quickRegister('CogVideo/GGUF', 'DownloadAndLoadCogVideoGGUFModel', 'model')
|
||||
// Empty key: HF-download node — don't activate asset browser for the combo widget
|
||||
quickRegister(
|
||||
'CogVideo/ControlNet',
|
||||
'DownloadAndLoadCogVideoControlNet',
|
||||
''
|
||||
)
|
||||
|
||||
// DynamiCrafter models (ComfyUI-DynamiCrafterWrapper)
|
||||
quickRegister(
|
||||
'checkpoints/dynamicrafter',
|
||||
'DownloadAndLoadDynamiCrafterModel',
|
||||
'model'
|
||||
)
|
||||
quickRegister(
|
||||
'checkpoints/dynamicrafter/controlnet',
|
||||
'DownloadAndLoadDynamiCrafterCNModel',
|
||||
'model'
|
||||
)
|
||||
|
||||
// LayerStyle models (ComfyUI_LayerStyle_Advance)
|
||||
quickRegister('BEN', 'LS_LoadBenModel', 'model')
|
||||
quickRegister('BiRefNet/pth', 'LS_LoadBiRefNetModel', 'model')
|
||||
quickRegister('onnx/human-parts', 'LS_HumanPartsUltra', '')
|
||||
quickRegister('lama', 'LaMa', 'lama_model')
|
||||
|
||||
// CogVideoX video generation models (comfyui-cogvideoxwrapper)
|
||||
// Empty key: HF-download node — don't activate asset browser for the combo widget
|
||||
quickRegister('CogVideo', 'DownloadAndLoadCogVideoModel', '')
|
||||
|
||||
// Inpaint models (comfyui-inpaint-nodes)
|
||||
quickRegister('inpaint', 'INPAINT_LoadInpaintModel', 'model_name')
|
||||
|
||||
// LayerDiffuse transparent image generation (comfyui-layerdiffuse)
|
||||
quickRegister('layer_model', 'LayeredDiffusionApply', 'config')
|
||||
|
||||
// LTX Video prompt enhancer models (ComfyUI-LTXTricks)
|
||||
quickRegister(
|
||||
'LLM/Llama-3.2-3B-Instruct',
|
||||
'LTXVPromptEnhancerLoader',
|
||||
'llm_name'
|
||||
)
|
||||
quickRegister(
|
||||
'LLM/Florence-2-large-PromptGen-v2.0',
|
||||
'LTXVPromptEnhancerLoader',
|
||||
'image_captioner_name'
|
||||
)
|
||||
for (const [modelType, nodeClass, key] of MODEL_NODE_MAPPINGS) {
|
||||
quickRegister(modelType, nodeClass, key)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -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