Compare commits

...

18 Commits

Author SHA1 Message Date
Deep Mehta
d785a49320 Merge branch 'main' into refactor/model-node-mappings-data 2026-03-20 14:54:55 -07:00
pythongosssss
c90a5402b4 feat: App mode - double click to rename widget (#10341)
## Summary

Allows users to rename widgets by double clicking the label

## Changes

- **What**: Uses EditableText component to allow inline renaming

## Screenshots (if applicable)


https://github.com/user-attachments/assets/f5cbb908-14cf-4dfa-8eb2-1024284effef

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10341-feat-App-mode-double-click-to-rename-widget-3296d73d36508146bbccf8c29f56dc96)
by [Unito](https://www.unito.io)
2026-03-20 14:35:09 -07:00
pythongosssss
7501a3eefc fix: App mode - Widget dropdowns clipped in sidebar (#10338)
## Summary

Popover components for graph mode are appendTo self so scale/translate
works, however in the sidebar this causes them to be clipped by the
parent overflow. This adds a provide/inject flag to change these to be
appended to the body.

## Changes

- **What**: 
- add append to injection for overriding where popovers are mounted
- ensure dropdowns respect this flag
- extract enterAppModeWithInputs helper
- tests

Before:  
<img width="225" height="140" alt="image"
src="https://github.com/user-attachments/assets/bd83b0cd-49a9-45dd-8344-4c10221444fc"
/>

After:  
<img width="238" height="225" alt="image"
src="https://github.com/user-attachments/assets/286e28e9-b37d-4ffc-91a9-7c340757d3fc"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10338-fix-App-mode-Widget-dropdowns-clipped-in-sidebar-3296d73d365081e2ba38e3e82006d65e)
by [Unito](https://www.unito.io)
2026-03-20 14:18:54 -07:00
Matt Miller
3b85227089 fix: hide API key login option on Cloud (#10343)
## Summary
- Hide the "Comfy API Key" login button and help text from the sign-in
modal when running on Cloud
- API key auth is only used on local ComfyUI; Cloud has
Firebase-whitelisted Google/GitHub auth
- The button was appearing on Cloud as a relic of shared code between
Cloud and local ComfyUI

## Context
[Discussion with Robin in
#proj-cloud-frontend](https://comfy-organization.slack.com/archives/C09FY39CC3V/p1773977756997559)
— API key auth only works on local because Firebase requires whitelisted
domains. On Cloud, SSO (Google/GitHub) works natively, so the API key
option is unnecessary and confusing.

<img width="470" height="865" alt="Screenshot 2026-03-20 at 9 53 20 AM"
src="https://github.com/user-attachments/assets/5bbdcbaf-243c-48c6-9bd0-aaae815925ea"
/>

## Test plan
- [ ] Verify login modal on local ComfyUI still shows the "Comfy API
Key" button
- [ ] Verify login modal on cloud.comfy.org no longer shows the "Comfy
API Key" button
2026-03-20 10:14:36 -07:00
Johnpaul Chiwetelu
944f78adf4 feat: import/export keybinding presets (#9681)
## Summary
- Add keybinding preset system: save, load, switch, import, export, and
delete named keybinding sets stored via `/api/userdata/keybindings/`
- Preset selector dropdown with "Save Changes" button for modified
custom presets, and "Import keybinding preset" action
- More-options menu in header row with save as new, reset, delete,
import, and export actions
- Search box and menu teleported to settings dialog header (matching
templates modal layout)
- 11 unit tests for preset service CRUD operations

Fixes #1084
Fixes #1085

## Test plan
- [ ] Open Settings > Keybinding, verify search box and "..." menu
appear in header
- [ ] Modify a keybinding, verify "Default *" shows modified indicator
- [ ] Use "Save as new preset" from menu, verify preset appears in
dropdown
- [ ] Switch between presets, verify unsaved changes prompt
- [ ] Export preset, import it back, verify bindings restored
- [ ] Delete a custom preset, verify reset to default
- [ ] Verify "Save Changes" button appears only on modified custom
presets
- [ ] Run `pnpm vitest run
src/platform/keybindings/presetService.test.ts`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9681-feat-import-export-keybinding-presets-31e6d73d3650810f88e4d21b3df3e2dd)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-03-20 12:24:31 +01:00
Deep Mehta
f15476e33f Merge branch 'refactor/model-node-mappings-data' of https://github.com/Comfy-Org/ComfyUI_frontend into refactor/model-node-mappings-data 2026-03-18 16:44:12 -07:00
Deep Mehta
114eeb3d3d refactor: move modelNodeMappings to src/platform/assets/mappings/
Move the data file to the assets platform directory per review feedback.
Update import path and CODEOWNERS entry accordingly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 16:43:35 -07:00
Deep Mehta
cb3a88a9e2 Merge branch 'main' into refactor/model-node-mappings-data 2026-03-18 16:40:59 -07:00
Deep Mehta
08845025c0 Merge branch 'refactor/model-node-mappings-data' of https://github.com/Comfy-Org/ComfyUI_frontend into refactor/model-node-mappings-data 2026-03-18 16:40:12 -07:00
Deep Mehta
9d9b3784a0 chore: add CODEOWNERS entry for model-to-node mappings
Add @deepme987 as code owner of modelNodeMappings.ts so model
backlink PRs can be reviewed and merged by the cloud team without
requiring frontend team approval.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 16:39:23 -07:00
Deep Mehta
18023c0ed1 Merge branch 'main' into refactor/model-node-mappings-data 2026-03-18 12:12:57 -07:00
GitHub Action
cc05ad2d34 [automated] Apply ESLint and Oxfmt fixes 2026-03-18 19:06:16 +00:00
Deep Mehta
b0f8b4c56a Update src/stores/modelNodeMappings.ts
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-03-18 12:03:29 -07:00
Deep Mehta
b3f01ac565 Merge branch 'main' into refactor/model-node-mappings-data 2026-03-18 11:13:40 -07:00
Deep Mehta
941620f485 Merge branch 'refactor/model-node-mappings-data' of https://github.com/Comfy-Org/ComfyUI_frontend into refactor/model-node-mappings-data 2026-03-18 10:45:05 -07:00
Deep Mehta
7ea5ea581b Merge remote-tracking branch 'origin/main' into refactor/model-node-mappings-data
# Conflicts:
#	src/stores/modelToNodeStore.ts
2026-03-18 10:44:18 -07:00
Deep Mehta
d92b9912a2 Merge branch 'main' into refactor/model-node-mappings-data 2026-03-18 09:56:06 -07:00
Deep Mehta
57c21d9467 refactor: extract model-to-node mappings into data file
Move all quickRegister() mapping data from modelToNodeStore.ts into a
separate modelNodeMappings.ts constants file. The store now iterates
over this data array instead of having 75 inline quickRegister() calls.

This makes adding new model-to-node mappings a pure data change (no
store logic touched), enabling separate CODEOWNERS for the data file.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 21:15:52 -07:00
31 changed files with 2222 additions and 400 deletions

View File

@@ -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/

View File

@@ -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()
}
}

View File

@@ -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]

View 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)
})
})

View File

@@ -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)

View 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' }
)
})
})

View File

@@ -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"]')

View File

@@ -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>

View File

@@ -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(

View File

@@ -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 }"

View File

@@ -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>

View File

@@ -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'])

View File

@@ -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'

View File

@@ -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)
}))
})

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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
}))
}

View File

@@ -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",

View 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]>

View File

@@ -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
}

View 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'
)
})
})
})

View 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
}
}

View File

@@ -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>

View File

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

View File

@@ -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',

View File

@@ -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: {

View File

@@ -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: {

View File

@@ -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(),

View File

@@ -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 {

View File

@@ -638,6 +638,7 @@ export default defineConfig({
optimizeDeps: {
exclude: ['@comfyorg/comfyui-electron-types'],
include: ['primevue/datatable', 'primevue/column'],
entries: ['index.html']
},