Compare commits

..

1 Commits

Author SHA1 Message Date
PabloWiedemann
714a11872f feat: add outline button variant
Add a new `outline` variant to the Button component with transparent
background, subtle border stroke, and hover state. Automatically
reflected in Storybook via the existing AllVariants story.
2026-03-21 17:35:50 -07:00
200 changed files with 3035 additions and 9748 deletions

View File

@@ -30,7 +30,6 @@ concurrency:
jobs:
bump-version:
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
runs-on: ubuntu-latest
permissions:
contents: write

View File

@@ -18,7 +18,6 @@ concurrency:
jobs:
docs-check:
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
runs-on: ubuntu-latest
timeout-minutes: 45
steps:

View File

@@ -51,9 +51,6 @@
# 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

@@ -1,407 +0,0 @@
{
"id": "0cc04f4c-d744-462d-8638-4e5f5e3947e7",
"revision": 0,
"last_node_id": 19,
"last_link_id": 24,
"nodes": [
{
"id": 14,
"type": "CLIPLoader",
"pos": [143.16716182216328, 290.16372862874033],
"size": [270, 117.3125],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "CLIP",
"type": "CLIP",
"links": [21]
}
],
"properties": {
"Node name for S&R": "CLIPLoader"
},
"widgets_values": [null, "stable_diffusion", "default"]
},
{
"id": 18,
"type": "PreviewImage",
"pos": [1305.1455526601603, 472.17095792625025],
"size": [225, 48],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 24
}
],
"outputs": [],
"properties": {
"Node name for S&R": "PreviewImage"
},
"widgets_values": []
},
{
"id": 19,
"type": "314bbb9f-f1cc-456c-b14f-2ba92bd4a597",
"pos": [794.198171390827, 452.45433419677147],
"size": [225, 172],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"label": "renamed_clip",
"name": "clip",
"type": "CLIP",
"link": 21
},
{
"label": "renamed_seed",
"name": "seed",
"type": "INT",
"widget": {
"name": "seed"
},
"link": 22
},
{
"label": "renamed_vae",
"name": "vae",
"type": "VAE",
"link": 23
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [24]
}
],
"title": "Input Test Subgraph",
"properties": {
"proxyWidgets": [
["12", "seed"],
["15", "text"]
]
},
"widgets_values": []
},
{
"id": 13,
"type": "PrimitiveInt",
"pos": [155.04048166054417, 773.3816055422594],
"size": [270, 82],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "INT",
"type": "INT",
"links": [22]
}
],
"title": "Seed Int",
"properties": {
"Node name for S&R": "PrimitiveInt"
},
"widgets_values": [0, "randomize"]
},
{
"id": 17,
"type": "VAELoader",
"pos": [163.6043676075426, 543.9624492717659],
"size": [270, 82.65625],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "VAE",
"type": "VAE",
"links": [23]
}
],
"properties": {
"Node name for S&R": "VAELoader"
},
"widgets_values": ["pixel_space"]
}
],
"links": [
[21, 14, 0, 19, 0, "CLIP"],
[22, 13, 0, 19, 1, "INT"],
[23, 17, 0, 19, 2, "VAE"],
[24, 19, 0, 18, 0, "IMAGE"]
],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "314bbb9f-f1cc-456c-b14f-2ba92bd4a597",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 19,
"lastLinkId": 24,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Input Test Subgraph",
"inputNode": {
"id": -10,
"bounding": [
358.8694807105848, 439.23932667242485, 123.14453125,
99.99999999999994
]
},
"outputNode": {
"id": -20,
"bounding": [1408.5510580294986, 463.2512895126797, 120, 60]
},
"inputs": [
{
"id": "cfaad2dc-7758-412c-a4ac-dc2e6d37b28c",
"name": "clip",
"type": "CLIP",
"linkIds": [16],
"localized_name": "clip",
"label": "renamed_clip",
"pos": [462.0140119605848, 459.23932667242485]
},
{
"id": "2e4600ea-e1b1-42ca-b43a-e066fd080774",
"name": "seed",
"type": "INT",
"linkIds": [15],
"localized_name": "seed",
"label": "renamed_seed",
"pos": [462.0140119605848, 479.23932667242485]
},
{
"id": "86ed2da7-db02-454a-9362-70a3fa3e91bf",
"name": "vae",
"type": "VAE",
"linkIds": [19],
"localized_name": "vae",
"label": "renamed_vae",
"pos": [462.0140119605848, 499.23932667242485]
}
],
"outputs": [
{
"id": "8670d1a7-0d44-4688-b7dd-d4b423f1aee0",
"name": "IMAGE",
"type": "IMAGE",
"linkIds": [20],
"localized_name": "IMAGE",
"pos": [1428.5510580294986, 483.2512895126797]
}
],
"widgets": [],
"nodes": [
{
"id": 12,
"type": "KSampler",
"pos": [769.2424728654022, 512.726159169824],
"size": [270, 262],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "model",
"name": "model",
"type": "MODEL",
"link": null
},
{
"localized_name": "positive",
"name": "positive",
"type": "CONDITIONING",
"link": 17
},
{
"localized_name": "negative",
"name": "negative",
"type": "CONDITIONING",
"link": null
},
{
"localized_name": "latent_image",
"name": "latent_image",
"type": "LATENT",
"link": null
},
{
"localized_name": "seed",
"name": "seed",
"type": "INT",
"widget": {
"name": "seed"
},
"link": 15
}
],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"links": [18]
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [0, "randomize", 20, 8, "euler", "simple", 1]
},
{
"id": 16,
"type": "VAEDecode",
"pos": [1208.5510580294986, 469.21581253470083],
"size": [140, 46],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"localized_name": "samples",
"name": "samples",
"type": "LATENT",
"link": 18
},
{
"localized_name": "vae",
"name": "vae",
"type": "VAE",
"link": 19
}
],
"outputs": [
{
"localized_name": "IMAGE",
"name": "IMAGE",
"type": "IMAGE",
"links": [20]
}
],
"properties": {
"Node name for S&R": "VAEDecode"
},
"widgets_values": []
},
{
"id": 15,
"type": "CLIPTextEncode",
"pos": [681.4596332342014, 243.17567172890932],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": 16
},
{
"label": "renamed_from_sidepanel",
"localized_name": "text",
"name": "text",
"type": "STRING",
"widget": {
"name": "text"
},
"link": null
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [17]
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [""]
}
],
"groups": [],
"links": [
{
"id": 17,
"origin_id": 15,
"origin_slot": 0,
"target_id": 12,
"target_slot": 1,
"type": "CONDITIONING"
},
{
"id": 18,
"origin_id": 12,
"origin_slot": 0,
"target_id": 16,
"target_slot": 0,
"type": "LATENT"
},
{
"id": 16,
"origin_id": -10,
"origin_slot": 0,
"target_id": 15,
"target_slot": 0,
"type": "CLIP"
},
{
"id": 15,
"origin_id": -10,
"origin_slot": 1,
"target_id": 12,
"target_slot": 4,
"type": "INT"
},
{
"id": 19,
"origin_id": -10,
"origin_slot": 2,
"target_id": 16,
"target_slot": 1,
"type": "VAE"
},
{
"id": 20,
"origin_id": 16,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "IMAGE"
}
],
"extra": {}
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 0.6727925600199565,
"offset": [446.69747171876463, 99.95078257277316]
}
},
"version": 0.4
}

View File

@@ -216,7 +216,7 @@ export class ComfyPage {
this.workflowUploadInput = page.locator('#comfy-file-input')
this.searchBox = new ComfyNodeSearchBox(page)
this.searchBoxV2 = new ComfyNodeSearchBoxV2(this)
this.searchBoxV2 = new ComfyNodeSearchBoxV2(page)
this.menu = new ComfyMenu(page)
this.actionbar = new ComfyActionbar(page)
this.templates = new ComfyTemplates(page)

View File

@@ -1,25 +1,18 @@
import type { Locator } from '@playwright/test'
import type { Locator, Page } from '@playwright/test'
import type { ComfyPage } from '../ComfyPage'
export class ComfyNodeSearchBoxV2 {
readonly dialog: Locator
readonly input: Locator
readonly filterSearch: Locator
readonly results: Locator
readonly filterOptions: Locator
readonly filterChips: Locator
readonly noResults: Locator
constructor(private comfyPage: ComfyPage) {
const page = comfyPage.page
constructor(readonly page: Page) {
this.dialog = page.getByRole('search')
this.input = this.dialog.getByRole('combobox')
this.filterSearch = this.dialog.getByRole('textbox', { name: 'Search' })
this.input = this.dialog.locator('input[type="text"]')
this.results = this.dialog.getByTestId('result-item')
this.filterOptions = this.dialog.getByTestId('filter-option')
this.filterChips = this.dialog.getByTestId('filter-chip')
this.noResults = this.dialog.getByTestId('no-results')
}
categoryButton(categoryId: string): Locator {
@@ -30,37 +23,7 @@ export class ComfyNodeSearchBoxV2 {
return this.dialog.getByRole('button', { name })
}
async applyTypeFilter(
filterName: 'Input' | 'Output',
typeName: string
): Promise<void> {
await this.filterBarButton(filterName).click()
await this.filterOptions.first().waitFor({ state: 'visible' })
await this.filterSearch.fill(typeName)
await this.filterOptions.filter({ hasText: typeName }).first().click()
// Close the popover by clicking the trigger button again
await this.filterBarButton(filterName).click()
await this.filterOptions.first().waitFor({ state: 'hidden' })
}
async removeFilterChip(index: number = 0): Promise<void> {
await this.filterChips.nth(index).getByTestId('chip-delete').click()
}
async getResultCount(): Promise<number> {
await this.results.first().waitFor({ state: 'visible' })
return this.results.count()
}
async open(): Promise<void> {
await this.comfyPage.command.executeCommand('Workspace.SearchBox.Toggle')
await this.input.waitFor({ state: 'visible' })
}
async enableV2Search(): Promise<void> {
await this.comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl',
'default'
)
async reload(comfyPage: ComfyPage) {
await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'default')
}
}

View File

@@ -1,7 +1,6 @@
import type { Locator } from '@playwright/test'
import type {
GraphAddOptions,
LGraph,
LGraphNode
} from '../../../src/lib/litegraph/src/litegraph'
@@ -24,12 +23,6 @@ export class NodeOperationsHelper {
})
}
async getLinkCount(): Promise<number> {
return await this.page.evaluate(() => {
return window.app?.rootGraph?.links?.size ?? 0
})
}
async getSelectedGraphNodesCount(): Promise<number> {
return await this.page.evaluate(() => {
return (
@@ -40,45 +33,6 @@ export class NodeOperationsHelper {
})
}
async getSelectedNodeIds(): Promise<NodeId[]> {
return await this.page.evaluate(() => {
const selected = window.app?.canvas?.selected_nodes
if (!selected) return []
return Object.keys(selected).map(Number)
})
}
/**
* Add a node to the graph by type.
* @param type - The node type (e.g. 'KSampler', 'VAEDecode')
* @param options - GraphAddOptions (ghost, skipComputeOrder). When ghost is
* true and cursorPosition is provided, a synthetic MouseEvent is created
* as the dragEvent.
* @param cursorPosition - Client coordinates for ghost placement dragEvent
*/
async addNode(
type: string,
options?: Omit<GraphAddOptions, 'dragEvent'>,
cursorPosition?: Position
): Promise<NodeReference> {
const id = await this.page.evaluate(
([nodeType, opts, cursor]) => {
const node = window.LiteGraph!.createNode(nodeType)!
const addOpts: Record<string, unknown> = { ...opts }
if (opts?.ghost && cursor) {
addOpts.dragEvent = new MouseEvent('click', {
clientX: cursor.x,
clientY: cursor.y
})
}
window.app!.graph.add(node, addOpts as GraphAddOptions)
return node.id
},
[type, options ?? {}, cursorPosition ?? null] as const
)
return new NodeReference(id, this.comfyPage)
}
/** Reads from `window.app.graph` (the root workflow graph). */
async getNodeCount(): Promise<number> {
return await this.page.evaluate(() => window.app!.graph.nodes.length)

View File

@@ -28,15 +28,10 @@ export const TestIds = {
settingsTabAbout: 'settings-tab-about',
confirm: 'confirm-dialog',
errorOverlay: 'error-overlay',
errorOverlaySeeErrors: 'error-overlay-see-errors',
runtimeErrorPanel: 'runtime-error-panel',
missingNodeCard: 'missing-node-card',
errorCardFindOnGithub: 'error-card-find-on-github',
errorCardCopy: 'error-card-copy',
about: 'about-panel',
whatsNewSection: 'whats-new-section',
missingNodePacksGroup: 'error-group-missing-node',
missingModelsGroup: 'error-group-missing-model'
whatsNewSection: 'whats-new-section'
},
keybindings: {
presetMenu: 'keybinding-preset-menu'
@@ -81,10 +76,6 @@ export const TestIds = {
},
user: {
currentUserIndicator: 'current-user-indicator'
},
errors: {
imageLoadError: 'error-loading-image',
videoLoadError: 'error-loading-video'
}
} as const
@@ -110,4 +101,3 @@ export type TestIdValue =
(id: string) => string
>
| (typeof TestIds.user)[keyof typeof TestIds.user]
| (typeof TestIds.errors)[keyof typeof TestIds.errors]

View File

@@ -1,5 +1,3 @@
import type { Page } from '@playwright/test'
import type { LGraph, Subgraph } from '../../src/lib/litegraph/src/litegraph'
import { isSubgraph } from '../../src/utils/typeGuardUtil'
@@ -16,30 +14,3 @@ export function assertSubgraph(
)
}
}
/**
* Returns the widget-input slot Y position and the node title height
* for the promoted "text" input on the SubgraphNode.
*
* The slot Y should be at the widget row, not the header. A value near
* zero or negative indicates the slot is positioned at the header (the bug).
*/
export function getTextSlotPosition(page: Page, nodeId: string) {
return page.evaluate((id) => {
const node = window.app!.canvas.graph!.getNodeById(id)
if (!node) return null
const titleHeight = window.LiteGraph!.NODE_TITLE_HEIGHT
for (const input of node.inputs) {
if (!input.widget || input.type !== 'STRING') continue
return {
hasPos: !!input.pos,
posY: input.pos?.[1] ?? null,
widgetName: input.widget.name,
titleHeight
}
}
return null
}, nodeId)
}

View File

@@ -28,7 +28,7 @@ test.describe('Missing nodes in Error Overlay', { tag: '@ui' }, () => {
)
await expect(errorOverlay).toBeVisible()
const missingNodesTitle = errorOverlay.getByText(/Missing Node Packs/)
const missingNodesTitle = comfyPage.page.getByText(/Missing Node Packs/)
await expect(missingNodesTitle).toBeVisible()
})
@@ -42,13 +42,11 @@ test.describe('Missing nodes in Error Overlay', { tag: '@ui' }, () => {
)
await expect(errorOverlay).toBeVisible()
const missingNodesTitle = errorOverlay.getByText(/Missing Node Packs/)
const missingNodesTitle = comfyPage.page.getByText(/Missing Node Packs/)
await expect(missingNodesTitle).toBeVisible()
// Click "See Errors" to open the errors tab and verify subgraph node content
await errorOverlay
.getByTestId(TestIds.dialogs.errorOverlaySeeErrors)
.click()
await errorOverlay.getByRole('button', { name: 'See Errors' }).click()
await expect(errorOverlay).not.toBeVisible()
const missingNodeCard = comfyPage.page.getByTestId(
@@ -77,9 +75,7 @@ test.describe('Missing nodes in Error Overlay', { tag: '@ui' }, () => {
await expect(errorOverlay).toBeVisible()
// Click "See Errors" to open the right side panel errors tab
await errorOverlay
.getByTestId(TestIds.dialogs.errorOverlaySeeErrors)
.click()
await errorOverlay.getByRole('button', { name: 'See Errors' }).click()
await expect(errorOverlay).not.toBeVisible()
// Verify MissingNodeCard is rendered in the errors tab
@@ -169,19 +165,17 @@ test.describe('Error actions in Errors Tab', { tag: '@ui' }, () => {
TestIds.dialogs.errorOverlay
)
await expect(errorOverlay).toBeVisible()
await errorOverlay
.getByTestId(TestIds.dialogs.errorOverlaySeeErrors)
.click()
await errorOverlay.getByRole('button', { name: 'See Errors' }).click()
await expect(errorOverlay).not.toBeVisible()
// Verify Find on GitHub button is present in the error card
const findOnGithubButton = comfyPage.page.getByTestId(
TestIds.dialogs.errorCardFindOnGithub
)
const findOnGithubButton = comfyPage.page.getByRole('button', {
name: 'Find on GitHub'
})
await expect(findOnGithubButton).toBeVisible()
// Verify Copy button is present in the error card
const copyButton = comfyPage.page.getByTestId(TestIds.dialogs.errorCardCopy)
const copyButton = comfyPage.page.getByRole('button', { name: 'Copy' })
await expect(copyButton).toBeVisible()
})
})
@@ -210,7 +204,7 @@ test.describe('Missing models in Error Tab', () => {
)
await expect(errorOverlay).toBeVisible()
const missingModelsTitle = errorOverlay.getByText(/Missing Models/)
const missingModelsTitle = comfyPage.page.getByText(/Missing Models/)
await expect(missingModelsTitle).toBeVisible()
})
@@ -226,7 +220,7 @@ test.describe('Missing models in Error Tab', () => {
)
await expect(errorOverlay).toBeVisible()
const missingModelsTitle = errorOverlay.getByText(/Missing Models/)
const missingModelsTitle = comfyPage.page.getByText(/Missing Models/)
await expect(missingModelsTitle).toBeVisible()
})
@@ -237,10 +231,13 @@ test.describe('Missing models in Error Tab', () => {
'missing/model_metadata_widget_mismatch'
)
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
).not.toBeVisible()
await expect(comfyPage.page.getByText(/Missing Models/)).not.toBeVisible()
const missingModelsTitle = comfyPage.page.getByText(/Missing Models/)
await expect(missingModelsTitle).not.toBeVisible()
const errorOverlay = comfyPage.page.getByTestId(
TestIds.dialogs.errorOverlay
)
await expect(errorOverlay).not.toBeVisible()
})
// Flaky test after parallelization

View File

@@ -764,13 +764,13 @@ test.describe('Load workflow', { tag: '@screenshot' }, () => {
)
})
const generateUniqueFilename = (extension = '') =>
`${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}${extension}`
test.describe('Restore all open workflows on reload', () => {
let workflowA: string
let workflowB: string
const generateUniqueFilename = (extension = '') =>
`${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}${extension}`
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
@@ -829,82 +829,6 @@ test.describe('Load workflow', { tag: '@screenshot' }, () => {
})
})
test.describe('Restore workflow tabs after browser restart', () => {
let workflowA: string
let workflowB: string
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
workflowA = generateUniqueFilename()
await comfyPage.menu.topbar.saveWorkflow(workflowA)
workflowB = generateUniqueFilename()
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
await comfyPage.menu.topbar.saveWorkflow(workflowB)
// Wait for localStorage fallback pointers to be written
await comfyPage.page.waitForFunction(() => {
for (let i = 0; i < window.localStorage.length; i++) {
const key = window.localStorage.key(i)
if (key?.startsWith('Comfy.Workflow.LastOpenPaths:')) {
return true
}
}
return false
})
// Simulate browser restart: clear sessionStorage (lost on close)
// but keep localStorage (survives browser restart)
await comfyPage.page.evaluate(() => {
sessionStorage.clear()
})
await comfyPage.setup({ clearStorage: false })
})
test('Restores topbar workflow tabs after browser restart', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.Workflow.WorkflowTabsPosition',
'Topbar'
)
// Wait for both restored tabs to render (localStorage fallback is async)
await expect(
comfyPage.page.locator('.workflow-tabs .workflow-label', {
hasText: workflowA
})
).toBeVisible()
const tabs = await comfyPage.menu.topbar.getTabNames()
const activeWorkflowName = await comfyPage.menu.topbar.getActiveTabName()
expect(tabs).toEqual(expect.arrayContaining([workflowA, workflowB]))
expect(tabs.indexOf(workflowA)).toBeLessThan(tabs.indexOf(workflowB))
expect(activeWorkflowName).toEqual(workflowB)
})
test('Restores sidebar workflows after browser restart', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.Workflow.WorkflowTabsPosition',
'Sidebar'
)
await comfyPage.menu.workflowsTab.open()
const openWorkflows =
await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()
const activeWorkflowName =
await comfyPage.menu.workflowsTab.getActiveWorkflowName()
expect(openWorkflows).toEqual(
expect.arrayContaining([workflowA, workflowB])
)
expect(openWorkflows.indexOf(workflowA)).toBeLessThan(
openWorkflows.indexOf(workflowB)
)
expect(activeWorkflowName).toEqual(workflowB)
})
})
test('Auto fit view after loading workflow', async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.EnableWorkflowViewRestore',

View File

@@ -23,14 +23,18 @@ async function addGhostAtCenter(comfyPage: ComfyPage) {
await comfyPage.page.mouse.move(centerX, centerY)
await comfyPage.nextFrame()
const nodeRef = await comfyPage.nodeOps.addNode(
'VAEDecode',
{ ghost: true },
{ x: centerX, y: centerY }
const nodeId = await comfyPage.page.evaluate(
([clientX, clientY]) => {
const node = window.LiteGraph!.createNode('VAEDecode')!
const event = new MouseEvent('click', { clientX, clientY })
window.app!.graph.add(node, { ghost: true, dragEvent: event })
return node.id
},
[centerX, centerY] as const
)
await comfyPage.nextFrame()
return { nodeId: nodeRef.id, centerX, centerY }
return { nodeId, centerX, centerY }
}
function getNodeById(comfyPage: ComfyPage, nodeId: number | string) {
@@ -78,6 +82,7 @@ for (const mode of ['litegraph', 'vue'] as const) {
},
[centerX, centerY] as const
)
await comfyPage.nextFrame()
expect(Math.abs(result.diffX)).toBeLessThan(5)
expect(Math.abs(result.diffY)).toBeLessThan(5)
@@ -153,53 +158,5 @@ for (const mode of ['litegraph', 'vue'] as const) {
const after = await getNodeById(comfyPage, nodeId)
expect(after).toBeNull()
})
test('moving ghost onto existing node and clicking places correctly', async ({
comfyPage
}) => {
// Get existing KSampler node from the default workflow
const [ksamplerRef] =
await comfyPage.nodeOps.getNodeRefsByTitle('KSampler')
const ksamplerPos = await ksamplerRef.getPosition()
const ksamplerSize = await ksamplerRef.getSize()
const targetX = Math.round(ksamplerPos.x + ksamplerSize.width / 2)
const targetY = Math.round(ksamplerPos.y + ksamplerSize.height / 2)
// Start ghost placement away from the existing node
const startX = 50
const startY = 50
await comfyPage.page.mouse.move(startX, startY, { steps: 20 })
await comfyPage.nextFrame()
const ghostRef = await comfyPage.nodeOps.addNode(
'VAEDecode',
{ ghost: true },
{ x: startX, y: startY }
)
await comfyPage.nextFrame()
// Move ghost onto the existing node
await comfyPage.page.mouse.move(targetX, targetY, { steps: 20 })
await comfyPage.nextFrame()
// Click to finalize — on top of the existing node
await comfyPage.page.mouse.click(targetX, targetY)
await comfyPage.nextFrame()
// Ghost should be placed (no longer ghost)
const ghostResult = await getNodeById(comfyPage, ghostRef.id)
expect(ghostResult).not.toBeNull()
expect(ghostResult!.ghost).toBe(false)
// Ghost node should have moved from its start position toward where we clicked
const ghostPos = await ghostRef.getPosition()
expect(
Math.abs(ghostPos.x - startX) > 20 || Math.abs(ghostPos.y - startY) > 20
).toBe(true)
// Existing node should NOT be selected
const selectedIds = await comfyPage.nodeOps.getSelectedNodeIds()
expect(selectedIds).not.toContain(ksamplerRef.id)
})
})
}

View File

@@ -5,7 +5,8 @@ import {
test.describe('Node search box V2', { tag: '@node' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.searchBoxV2.enableV2Search()
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'default')
await comfyPage.settings.setSetting(
'Comfy.LinkRelease.Action',
'search box'
@@ -14,13 +15,15 @@ test.describe('Node search box V2', { tag: '@node' }, () => {
'Comfy.LinkRelease.ActionShift',
'search box'
)
await comfyPage.searchBoxV2.reload(comfyPage)
})
test('Can open search and add node', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
await searchBoxV2.open()
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
await searchBoxV2.input.fill('KSampler')
await expect(searchBoxV2.results.first()).toBeVisible()
@@ -36,7 +39,8 @@ test.describe('Node search box V2', { tag: '@node' }, () => {
const { searchBoxV2 } = comfyPage
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
await searchBoxV2.open()
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
// Default results should be visible without typing
await expect(searchBoxV2.results.first()).toBeVisible()
@@ -50,16 +54,17 @@ test.describe('Node search box V2', { tag: '@node' }, () => {
})
test.describe('Category navigation', () => {
test('Bookmarked filter shows only bookmarked nodes', async ({
comfyPage
}) => {
test('Favorites shows only bookmarked nodes', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await comfyPage.settings.setSetting('Comfy.NodeLibrary.Bookmarks.V2', [
'KSampler'
])
await searchBoxV2.open()
await searchBoxV2.reload(comfyPage)
await searchBoxV2.filterBarButton('Bookmarked').click()
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
await searchBoxV2.categoryButton('favorites').click()
await expect(searchBoxV2.results).toHaveCount(1)
await expect(searchBoxV2.results.first()).toContainText('KSampler')
@@ -70,7 +75,8 @@ test.describe('Node search box V2', { tag: '@node' }, () => {
}) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
await searchBoxV2.categoryButton('sampling').click()
@@ -84,7 +90,8 @@ test.describe('Node search box V2', { tag: '@node' }, () => {
test('Can filter by input type via filter bar', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
// Click "Input" filter chip in the filter bar
await searchBoxV2.filterBarButton('Input').click()
@@ -93,7 +100,7 @@ test.describe('Node search box V2', { tag: '@node' }, () => {
await expect(searchBoxV2.filterOptions.first()).toBeVisible()
// Type to narrow and select MODEL
await searchBoxV2.filterSearch.fill('MODEL')
await searchBoxV2.input.fill('MODEL')
await searchBoxV2.filterOptions
.filter({ hasText: 'MODEL' })
.first()
@@ -112,7 +119,8 @@ test.describe('Node search box V2', { tag: '@node' }, () => {
const { searchBoxV2 } = comfyPage
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
await searchBoxV2.open()
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
await searchBoxV2.input.fill('KSampler')
const results = searchBoxV2.results

View File

@@ -5,7 +5,8 @@ import {
test.describe('Node search box V2 extended', { tag: '@node' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.searchBoxV2.enableV2Search()
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'default')
await comfyPage.settings.setSetting(
'Comfy.LinkRelease.Action',
'search box'
@@ -14,12 +15,13 @@ test.describe('Node search box V2 extended', { tag: '@node' }, () => {
'Comfy.LinkRelease.ActionShift',
'search box'
)
await comfyPage.searchBoxV2.reload(comfyPage)
})
test('Double-click on empty canvas opens search', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await comfyPage.page.mouse.dblclick(200, 200, { delay: 5 })
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
await expect(searchBoxV2.dialog).toBeVisible()
})
@@ -30,7 +32,8 @@ test.describe('Node search box V2 extended', { tag: '@node' }, () => {
const { searchBoxV2 } = comfyPage
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
await searchBoxV2.open()
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
await searchBoxV2.input.fill('KSampler')
await expect(searchBoxV2.results.first()).toBeVisible()
@@ -42,43 +45,29 @@ test.describe('Node search box V2 extended', { tag: '@node' }, () => {
expect(newCount).toBe(initialCount)
})
test('Reopening search after Enter has no persisted state', async ({
comfyPage
}) => {
test('Search clears when reopening', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
await searchBoxV2.input.fill('KSampler')
await expect(searchBoxV2.results.first()).toBeVisible()
await comfyPage.page.keyboard.press('Enter')
await expect(searchBoxV2.input).not.toBeVisible()
await searchBoxV2.open()
await expect(searchBoxV2.input).toHaveValue('')
await expect(searchBoxV2.filterChips).toHaveCount(0)
})
test('Reopening search after Escape has no persisted state', async ({
comfyPage
}) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
await searchBoxV2.input.fill('KSampler')
await expect(searchBoxV2.results.first()).toBeVisible()
await comfyPage.page.keyboard.press('Escape')
await expect(searchBoxV2.input).not.toBeVisible()
await searchBoxV2.open()
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
await expect(searchBoxV2.input).toHaveValue('')
await expect(searchBoxV2.filterChips).toHaveCount(0)
})
test.describe('Category navigation', () => {
test('Category navigation updates results', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
await searchBoxV2.categoryButton('sampling').click()
await expect(searchBoxV2.results.first()).toBeVisible()
@@ -96,270 +85,59 @@ test.describe('Node search box V2 extended', { tag: '@node' }, () => {
test('Filter chip removal restores results', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
// Search first to get a result set below the 64-item cap
await searchBoxV2.input.fill('Load')
const unfilteredCount = await searchBoxV2.getResultCount()
// Record initial result text for comparison
await expect(searchBoxV2.results.first()).toBeVisible()
const unfilteredResults = await searchBoxV2.results.allTextContents()
// Apply Input filter with MODEL type
await searchBoxV2.applyTypeFilter('Input', 'MODEL')
await expect(searchBoxV2.filterChips.first()).toBeVisible()
const filteredCount = await searchBoxV2.getResultCount()
expect(filteredCount).not.toBe(unfilteredCount)
await searchBoxV2.filterBarButton('Input').click()
await expect(searchBoxV2.filterOptions.first()).toBeVisible()
await searchBoxV2.input.fill('MODEL')
await searchBoxV2.filterOptions
.filter({ hasText: 'MODEL' })
.first()
.click()
// Verify filter chip appeared and results changed
const filterChip = searchBoxV2.dialog.locator(
'[data-testid="filter-chip"]'
)
await expect(filterChip).toBeVisible()
await expect(searchBoxV2.results.first()).toBeVisible()
const filteredResults = await searchBoxV2.results.allTextContents()
expect(filteredResults).not.toEqual(unfilteredResults)
// Remove filter by clicking the chip delete button
await searchBoxV2.removeFilterChip()
await filterChip.getByTestId('chip-delete').click()
// Filter chip should be removed and count restored
await expect(searchBoxV2.filterChips).toHaveCount(0)
const restoredCount = await searchBoxV2.getResultCount()
expect(restoredCount).toBe(unfilteredCount)
})
})
test.describe('Link release', () => {
test('Link release opens search with pre-applied type filter', async ({
comfyPage
}) => {
const { searchBoxV2 } = comfyPage
await comfyPage.canvasOps.disconnectEdge()
await expect(searchBoxV2.input).toBeVisible()
// disconnectEdge pulls a CLIP link - should have a filter chip
await expect(searchBoxV2.filterChips).toHaveCount(1)
await expect(searchBoxV2.filterChips.first()).toContainText('CLIP')
})
test('Link release auto-connects added node', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
const nodeCountBefore = await comfyPage.nodeOps.getGraphNodesCount()
const linkCountBefore = await comfyPage.nodeOps.getLinkCount()
await comfyPage.canvasOps.disconnectEdge()
await expect(searchBoxV2.input).toBeVisible()
// Search for a node that accepts CLIP input and select it
await searchBoxV2.input.fill('CLIP Text Encode')
// Filter chip should be removed
await expect(filterChip).not.toBeVisible()
await expect(searchBoxV2.results.first()).toBeVisible()
await comfyPage.page.keyboard.press('Enter')
await expect(searchBoxV2.input).not.toBeVisible()
// A new node should have been added and auto-connected
const nodeCountAfter = await comfyPage.nodeOps.getGraphNodesCount()
expect(nodeCountAfter).toBe(nodeCountBefore + 1)
const linkCountAfter = await comfyPage.nodeOps.getLinkCount()
expect(linkCountAfter).toBe(linkCountBefore)
})
})
test.describe('Filter combinations', () => {
test('Output type filter filters results', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
// Search first so both counts use the search service path
await searchBoxV2.input.fill('Load')
const unfilteredCount = await searchBoxV2.getResultCount()
await searchBoxV2.applyTypeFilter('Output', 'IMAGE')
await expect(searchBoxV2.filterChips).toHaveCount(1)
const filteredCount = await searchBoxV2.getResultCount()
expect(filteredCount).not.toBe(unfilteredCount)
})
test('Multiple type filters (Input + Output) narrows results', async ({
test.describe('Keyboard navigation', () => {
test('ArrowUp on first item keeps first selected', async ({
comfyPage
}) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
await searchBoxV2.applyTypeFilter('Input', 'MODEL')
await expect(searchBoxV2.filterChips).toHaveCount(1)
const singleFilterCount = await searchBoxV2.getResultCount()
await searchBoxV2.applyTypeFilter('Output', 'LATENT')
await expect(searchBoxV2.filterChips).toHaveCount(2)
const dualFilterCount = await searchBoxV2.getResultCount()
expect(dualFilterCount).toBeLessThan(singleFilterCount)
})
test('Root filter + search query narrows results', async ({
comfyPage
}) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
// Search without root filter
await searchBoxV2.input.fill('Sampler')
const unfilteredCount = await searchBoxV2.getResultCount()
// Apply Comfy root filter on top of search
await searchBoxV2.filterBarButton('Comfy').click()
const filteredCount = await searchBoxV2.getResultCount()
// Root filter should narrow or maintain the result set
expect(filteredCount).toBeLessThan(unfilteredCount)
expect(filteredCount).toBeGreaterThan(0)
})
test('Root filter + category selection', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
// Click "Comfy" root filter
await searchBoxV2.filterBarButton('Comfy').click()
const comfyCount = await searchBoxV2.getResultCount()
// Under root filter, categories are prefixed (e.g. comfy/sampling)
await searchBoxV2.categoryButton('comfy/sampling').click()
const comfySamplingCount = await searchBoxV2.getResultCount()
expect(comfySamplingCount).toBeLessThan(comfyCount)
})
})
test.describe('Category sidebar', () => {
test('Category tree expand and collapse', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
// Click a parent category to expand it
const samplingBtn = searchBoxV2.categoryButton('sampling')
await samplingBtn.click()
// Look for subcategories (e.g. sampling/custom_sampling)
const subcategory = searchBoxV2.categoryButton('sampling/custom_sampling')
await expect(subcategory).toBeVisible()
// Click sampling again to collapse
await samplingBtn.click()
await expect(subcategory).not.toBeVisible()
})
test('Subcategory narrows results to subset', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
// Select parent category
await searchBoxV2.categoryButton('sampling').click()
const parentCount = await searchBoxV2.getResultCount()
// Select subcategory
const subcategory = searchBoxV2.categoryButton('sampling/custom_sampling')
await expect(subcategory).toBeVisible()
await subcategory.click()
const childCount = await searchBoxV2.getResultCount()
expect(childCount).toBeLessThan(parentCount)
})
test('Most relevant resets category filter', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
const defaultCount = await searchBoxV2.getResultCount()
// Select a category
await searchBoxV2.categoryButton('sampling').click()
const samplingCount = await searchBoxV2.getResultCount()
expect(samplingCount).not.toBe(defaultCount)
// Click "Most relevant" to reset
await searchBoxV2.categoryButton('most-relevant').click()
const resetCount = await searchBoxV2.getResultCount()
expect(resetCount).toBe(defaultCount)
})
})
test.describe('Search behavior', () => {
test('Click on result item adds node', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
await searchBoxV2.open()
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
await searchBoxV2.input.fill('KSampler')
await expect(searchBoxV2.results.first()).toBeVisible()
const results = searchBoxV2.results
await expect(results.first()).toBeVisible()
await searchBoxV2.results.first().click()
await expect(searchBoxV2.input).not.toBeVisible()
// First result should be selected by default
await expect(results.first()).toHaveAttribute('aria-selected', 'true')
const newCount = await comfyPage.nodeOps.getGraphNodesCount()
expect(newCount).toBe(initialCount + 1)
})
test('Search narrows results progressively', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
await searchBoxV2.input.fill('S')
const count1 = await searchBoxV2.getResultCount()
await searchBoxV2.input.fill('Sa')
const count2 = await searchBoxV2.getResultCount()
await searchBoxV2.input.fill('Sampler')
const count3 = await searchBoxV2.getResultCount()
expect(count2).toBeLessThan(count1)
expect(count3).toBeLessThan(count2)
})
test('No results shown for nonsensical query', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
await searchBoxV2.input.fill('zzzxxxyyy_nonexistent_node')
await expect(searchBoxV2.noResults).toBeVisible()
await expect(searchBoxV2.results).toHaveCount(0)
})
})
test.describe('Filter chip interaction', () => {
test('Multiple filter chips displayed', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
await searchBoxV2.applyTypeFilter('Input', 'MODEL')
await searchBoxV2.applyTypeFilter('Output', 'LATENT')
await expect(searchBoxV2.filterChips).toHaveCount(2)
await expect(searchBoxV2.filterChips.first()).toContainText('MODEL')
await expect(searchBoxV2.filterChips.nth(1)).toContainText('LATENT')
})
})
test.describe('Settings-driven behavior', () => {
test('Node ID name shown when setting enabled', async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl.ShowIdName',
true
)
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
await searchBoxV2.input.fill('VAE Decode')
await expect(searchBoxV2.results.first()).toBeVisible()
const firstResult = searchBoxV2.results.first()
const idBadge = firstResult.getByTestId('node-id-badge')
await expect(idBadge).toBeVisible()
await expect(idBadge).toContainText('VAEDecode')
// ArrowUp on first item should keep first selected
await comfyPage.page.keyboard.press('ArrowUp')
await expect(results.first()).toHaveAttribute('aria-selected', 'true')
})
})
})

View File

@@ -1,86 +0,0 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { getTextSlotPosition } from '../helpers/subgraphTestUtils'
test.describe(
'Subgraph promoted widget-input slot position',
{ tag: '@subgraph' },
() => {
test('Promoted text widget slot is positioned at widget row, not header', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
// Render a few frames so arrange() runs
await comfyPage.nextFrame()
await comfyPage.nextFrame()
const result = await getTextSlotPosition(comfyPage.page, '11')
expect(result).not.toBeNull()
expect(result!.hasPos).toBe(true)
// The slot Y position should be well below the title area.
// If it's near 0 or negative, the slot is stuck at the header (the bug).
expect(result!.posY).toBeGreaterThan(result!.titleHeight)
})
test('Slot position remains correct after renaming subgraph input label', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
await comfyPage.nextFrame()
// Verify initial position is correct
const before = await getTextSlotPosition(comfyPage.page, '11')
expect(before).not.toBeNull()
expect(before!.hasPos).toBe(true)
expect(before!.posY).toBeGreaterThan(before!.titleHeight)
// Navigate into subgraph and rename the text input
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
const initialLabel = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph
if (!graph || !('inputNode' in graph)) return null
const textInput = graph.inputs?.find(
(i: { type: string }) => i.type === 'STRING'
)
return textInput?.label || textInput?.name || null
})
if (!initialLabel)
throw new Error('Could not find STRING input in subgraph')
await comfyPage.subgraph.rightClickInputSlot(initialLabel)
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
await comfyPage.nextFrame()
const dialog = '.graphdialog input'
await comfyPage.page.waitForSelector(dialog, { state: 'visible' })
await comfyPage.page.fill(dialog, '')
await comfyPage.page.fill(dialog, 'my_custom_prompt')
await comfyPage.page.keyboard.press('Enter')
await comfyPage.page.waitForSelector(dialog, { state: 'hidden' })
// Navigate back to parent graph
await comfyPage.subgraph.exitViaBreadcrumb()
// Verify slot position is still at the widget row after rename
const after = await getTextSlotPosition(comfyPage.page, '11')
expect(after).not.toBeNull()
expect(after!.hasPos).toBe(true)
expect(after!.posY).toBeGreaterThan(after!.titleHeight)
// widget.name is the stable identity key — it does NOT change on rename.
// The display label is on input.label, read via PromotedWidgetView.label.
expect(after!.widgetName).not.toBe('my_custom_prompt')
})
}
)

View File

@@ -1,57 +0,0 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { getPromotedWidgetNames } from '../helpers/promotedWidgets'
test.describe(
'Subgraph promoted widget DOM position',
{ tag: '@subgraph' },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test('Promoted seed widget renders in node body, not header', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('default')
// Convert KSampler (id 3) to subgraph — seed is auto-promoted.
const ksampler = await comfyPage.nodeOps.getNodeRefById('3')
await ksampler.click('title')
const subgraphNode = await ksampler.convertToSubgraph()
await comfyPage.nextFrame()
// Enable Vue nodes now that the subgraph has been created
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
const subgraphNodeId = String(subgraphNode.id)
const promotedNames = await getPromotedWidgetNames(
comfyPage,
subgraphNodeId
)
expect(promotedNames).toContain('seed')
// Wait for Vue nodes to render
await comfyPage.vueNodes.waitForNodes()
const nodeLocator = comfyPage.vueNodes.getNodeLocator(subgraphNodeId)
await expect(nodeLocator).toBeVisible()
// The seed widget should be visible inside the node body
const seedWidget = nodeLocator.getByLabel('seed', { exact: true }).first()
await expect(seedWidget).toBeVisible()
// Verify widget is inside the node body, not the header
const headerBox = await nodeLocator
.locator('[data-testid^="node-header-"]')
.boundingBox()
const widgetBox = await seedWidget.boundingBox()
expect(headerBox).not.toBeNull()
expect(widgetBox).not.toBeNull()
// Widget top should be below the header bottom
expect(widgetBox!.y).toBeGreaterThan(headerBox!.y + headerBox!.height)
})
}
)

View File

@@ -1,117 +0,0 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
const WORKFLOW = 'subgraphs/test-values-input-subgraph'
const RENAMED_LABEL = 'my_seed'
/**
* Regression test for subgraph input slot rename propagation.
*
* Renaming a SubgraphInput slot (e.g. "seed") inside the subgraph must
* update the promoted widget label shown on the parent SubgraphNode and
* keep the widget positioned in the node body (not the header).
*
* See: https://github.com/Comfy-Org/ComfyUI_frontend/pull/10195
*/
test.describe(
'Subgraph input slot rename propagation',
{ tag: ['@subgraph', '@widget'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
})
test('Renaming a subgraph input slot updates the widget label on the parent node', async ({
comfyPage
}) => {
const { page } = comfyPage
// 1. Load workflow with subgraph containing a promoted seed widget input
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
const sgNode = comfyPage.vueNodes.getNodeLocator('19')
await expect(sgNode).toBeVisible()
// 2. Verify the seed widget is visible on the parent node
const seedWidget = sgNode.getByLabel('seed', { exact: true })
await expect(seedWidget).toBeVisible()
// Verify widget is in the node body, not the header
const headerBox = await sgNode
.locator('[data-testid^="node-header-"]')
.boundingBox()
const widgetBox = await seedWidget.boundingBox()
expect(headerBox).not.toBeNull()
expect(widgetBox).not.toBeNull()
expect(widgetBox!.y).toBeGreaterThan(headerBox!.y + headerBox!.height)
// 3. Enter the subgraph and rename the seed slot.
// The subgraph IO rename uses canvas.prompt() which requires the
// litegraph context menu, so temporarily disable Vue nodes.
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
await comfyPage.nextFrame()
const sgNodeRef = await comfyPage.nodeOps.getNodeRefById('19')
await sgNodeRef.navigateIntoSubgraph()
// Find the seed SubgraphInput slot
const seedSlotName = await page.evaluate(() => {
const graph = window.app!.canvas.graph
if (!graph) return null
const inputs = (
graph as { inputs?: Array<{ name: string; type: string }> }
).inputs
return inputs?.find((i) => i.name.includes('seed'))?.name ?? null
})
expect(seedSlotName).not.toBeNull()
// 4. Right-click the seed input slot and rename it
await comfyPage.subgraph.rightClickInputSlot(seedSlotName!)
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
await comfyPage.nextFrame()
const dialog = '.graphdialog input'
await page.waitForSelector(dialog, { state: 'visible' })
await page.fill(dialog, '')
await page.fill(dialog, RENAMED_LABEL)
await page.keyboard.press('Enter')
await page.waitForSelector(dialog, { state: 'hidden' })
// 5. Navigate back to parent graph and re-enable Vue nodes
await comfyPage.subgraph.exitViaBreadcrumb()
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.vueNodes.waitForNodes()
// 6. Verify the widget label updated to the renamed value
const sgNodeAfter = comfyPage.vueNodes.getNodeLocator('19')
await expect(sgNodeAfter).toBeVisible()
const updatedLabel = await page.evaluate(() => {
const node = window.app!.canvas.graph!.getNodeById('19')
if (!node) return null
const w = node.widgets?.find((w: { name: string }) =>
w.name.includes('seed')
)
return w?.label || w?.name || null
})
expect(updatedLabel).toBe(RENAMED_LABEL)
// 7. Verify the widget is still in the body, not the header
const seedWidgetAfter = sgNodeAfter.getByLabel('seed', { exact: true })
await expect(seedWidgetAfter).toBeVisible()
const headerAfter = await sgNodeAfter
.locator('[data-testid^="node-header-"]')
.boundingBox()
const widgetAfter = await seedWidgetAfter.boundingBox()
expect(headerAfter).not.toBeNull()
expect(widgetAfter).not.toBeNull()
expect(widgetAfter!.y).toBeGreaterThan(
headerAfter!.y + headerAfter!.height
)
})
}
)

View File

@@ -206,31 +206,6 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
await expect(nav).toBeVisible() // Nav should be visible at tablet size
})
test(
'select components in filter bar render correctly',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
await expect(comfyPage.templates.content).toBeVisible()
// Wait for filter bar select components to render
const dialog = comfyPage.page.getByRole('dialog')
const sortBySelect = dialog.getByRole('combobox', { name: /Sort/ })
await expect(sortBySelect).toBeVisible()
// Screenshot the filter bar containing MultiSelect and SingleSelect
const filterBar = sortBySelect.locator(
'xpath=ancestor::div[contains(@class, "justify-between")]'
)
await expect(filterBar).toHaveScreenshot(
'template-filter-bar-select-components.png',
{
mask: [comfyPage.page.locator('.p-toast')]
}
)
}
)
test(
'template cards descriptions adjust height dynamically',
{ tag: '@screenshot' },

View File

@@ -47,46 +47,6 @@ test.describe('Vue Node Moving', () => {
}
)
test('should not move node when pointer moves less than drag threshold', async ({
comfyPage
}) => {
const headerPos = await getLoadCheckpointHeaderPos(comfyPage)
// Move only 2px — below the 3px drag threshold in useNodePointerInteractions
await comfyPage.page.mouse.move(headerPos.x, headerPos.y)
await comfyPage.page.mouse.down()
await comfyPage.page.mouse.move(headerPos.x + 2, headerPos.y + 1, {
steps: 5
})
await comfyPage.page.mouse.up()
await comfyPage.nextFrame()
const afterPos = await getLoadCheckpointHeaderPos(comfyPage)
expect(afterPos.x).toBeCloseTo(headerPos.x, 0)
expect(afterPos.y).toBeCloseTo(headerPos.y, 0)
// The small movement should have selected the node, not dragged it
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(1)
})
test('should move node when pointer moves beyond drag threshold', async ({
comfyPage
}) => {
const headerPos = await getLoadCheckpointHeaderPos(comfyPage)
// Move 50px — well beyond the 3px drag threshold
await comfyPage.page.mouse.move(headerPos.x, headerPos.y)
await comfyPage.page.mouse.down()
await comfyPage.page.mouse.move(headerPos.x + 50, headerPos.y + 50, {
steps: 20
})
await comfyPage.page.mouse.up()
await comfyPage.nextFrame()
const afterPos = await getLoadCheckpointHeaderPos(comfyPage)
await expectPosChanged(headerPos, afterPos)
})
test(
'@mobile should allow moving nodes by dragging on touch devices',
{ tag: '@screenshot' },

View File

@@ -2,7 +2,6 @@ import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../../../fixtures/ComfyPage'
import { TestIds } from '../../../../fixtures/selectors'
test.describe('Vue Upload Widgets', () => {
test.beforeEach(async ({ comfyPage }) => {
@@ -20,14 +19,10 @@ test.describe('Vue Upload Widgets', () => {
).not.toBeVisible()
await expect
.poll(() =>
comfyPage.page.getByTestId(TestIds.errors.imageLoadError).count()
)
.poll(() => comfyPage.page.getByText('Error loading image').count())
.toBeGreaterThan(0)
await expect
.poll(() =>
comfyPage.page.getByTestId(TestIds.errors.videoLoadError).count()
)
.poll(() => comfyPage.page.getByText('Error loading video').count())
.toBeGreaterThan(0)
})
})

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.43.3",
"version": "1.43.2",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",

View File

@@ -619,6 +619,8 @@
background-color: color-mix(in srgb, currentColor 20%, transparent);
font-weight: 700;
border-radius: 0.25rem;
padding: 0 0.125rem;
margin: -0.125rem 0.125rem;
}
@utility scrollbar-hide {

View File

@@ -200,13 +200,6 @@ describe('formatUtil', () => {
'<span class="highlight">foo</span> bar <span class="highlight">foo</span>'
)
})
it('should highlight cross-word matches', () => {
const result = highlightQuery('convert image to mask', 'geto', false)
expect(result).toBe(
'convert ima<span class="highlight">ge to</span> mask'
)
})
})
describe('getFilenameDetails', () => {

View File

@@ -74,14 +74,10 @@ export function highlightQuery(
text = DOMPurify.sanitize(text)
}
// Escape special regex characters, then join with optional
// whitespace so cross-word matches (e.g. "geto" → "imaGE TO") are
// highlighted correctly.
const pattern = Array.from(query)
.map((ch) => ch.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
.join('\\s*')
// Escape special regex characters in the query string
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const regex = new RegExp(`(${pattern})`, 'gi')
const regex = new RegExp(`(${escapedQuery})`, 'gi')
return text.replace(regex, '<span class="highlight">$1</span>')
}

View File

@@ -1,17 +1,9 @@
import { clsx } from 'clsx'
import type { ClassArray } from 'clsx'
import { extendTailwindMerge } from 'tailwind-merge'
import { twMerge } from 'tailwind-merge'
export type { ClassValue } from 'clsx'
const twMerge = extendTailwindMerge({
extend: {
classGroups: {
'font-size': ['text-xxs', 'text-xxxs']
}
}
})
export function cn(...inputs: ClassArray) {
return twMerge(clsx(inputs))
}

View File

@@ -186,13 +186,13 @@ const toggleState = () => {
}
const signInWithGoogle = async () => {
if (await authActions.signInWithGoogle({ isNewUser: !isSignIn.value })) {
if (await authActions.signInWithGoogle()) {
onSuccess()
}
}
const signInWithGithub = async () => {
if (await authActions.signInWithGithub({ isNewUser: !isSignIn.value })) {
if (await authActions.signInWithGithub()) {
onSuccess()
}
}

View File

@@ -19,7 +19,10 @@ const props = defineProps<{
const queryString = computed(() => props.errorMessage + ' is:issue')
function openGitHubIssues() {
/**
* Open GitHub issues search and track telemetry.
*/
const openGitHubIssues = () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'error_dialog_find_existing_issues_clicked'
})

View File

@@ -49,12 +49,7 @@
<Button variant="muted-textonly" size="unset" @click="dismiss">
{{ t('g.dismiss') }}
</Button>
<Button
variant="secondary"
size="lg"
data-testid="error-overlay-see-errors"
@click="seeErrors"
>
<Button variant="secondary" size="lg" @click="seeErrors">
{{
appMode ? t('linearMode.error.goto') : t('errorOverlay.seeErrors')
}}

View File

@@ -195,7 +195,6 @@ import { forEachNode } from '@/utils/graphTraversalUtil'
import SelectionRectangle from './SelectionRectangle.vue'
import { isCloud } from '@/platform/distribution/types'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useCreateWorkspaceUrlLoader } from '@/platform/workspace/composables/useCreateWorkspaceUrlLoader'
import { useInviteUrlLoader } from '@/platform/workspace/composables/useInviteUrlLoader'
const { t } = useI18n()
@@ -456,9 +455,8 @@ useEventListener(
const comfyAppReady = ref(false)
const workflowPersistence = useWorkflowPersistence()
const { flags } = useFeatureFlags()
// Set up URL loaders during setup phase so useRoute/useRouter work correctly
// Set up invite loader during setup phase so useRoute/useRouter work correctly
const inviteUrlLoader = isCloud ? useInviteUrlLoader() : null
const createWorkspaceUrlLoader = isCloud ? useCreateWorkspaceUrlLoader() : null
useCanvasDrop(canvasRef)
useLitegraphSettings()
useNodeBadge()
@@ -578,18 +576,6 @@ onMounted(async () => {
await inviteUrlLoader.loadInviteFromUrl()
}
// Open create workspace dialog from URL if present (e.g., ?create_workspace=1)
if (createWorkspaceUrlLoader && flags.teamWorkspacesEnabled) {
try {
await createWorkspaceUrlLoader.loadCreateWorkspaceFromUrl()
} catch (error) {
console.error(
'[GraphCanvas] Failed to load create workspace from URL:',
error
)
}
}
// Initialize release store to fetch releases from comfy-api (fire-and-forget)
const { useReleaseStore } =
await import('@/platform/updates/common/releaseStore')

View File

@@ -1,60 +0,0 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import MultiSelect from './MultiSelect.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: {
multiSelectDropdown: 'Multi-select dropdown',
noResultsFound: 'No results found',
search: 'Search',
clearAll: 'Clear all',
itemsSelected: 'Items selected'
}
}
}
})
describe('MultiSelect', () => {
function createWrapper() {
return mount(MultiSelect, {
attachTo: document.body,
global: {
plugins: [i18n]
},
props: {
modelValue: [],
label: 'Category',
options: [
{ name: 'One', value: 'one' },
{ name: 'Two', value: 'two' }
]
}
})
}
it('keeps open-state border styling available while the dropdown is open', async () => {
const wrapper = createWrapper()
const trigger = wrapper.get('button[aria-haspopup="listbox"]')
expect(trigger.classes()).toContain(
'data-[state=open]:border-node-component-border'
)
expect(trigger.attributes('aria-expanded')).toBe('false')
await trigger.trigger('click')
await nextTick()
expect(trigger.attributes('aria-expanded')).toBe('true')
expect(trigger.attributes('data-state')).toBe('open')
wrapper.unmount()
})
})

View File

@@ -1,215 +1,207 @@
<template>
<ComboboxRoot
<!--
Note: Unlike SingleSelect, we don't need an explicit options prop because:
1. Our value template only shows a static label (not dynamic based on selection)
2. We display a count badge instead of actual selected labels
3. All PrimeVue props (including options) are passed via v-bind="$attrs"
option-label="name" is required because our option template directly accesses option.name
max-selected-labels="0" is required to show count badge instead of selected item labels
-->
<MultiSelect
v-model="selectedItems"
multiple
by="value"
:disabled
ignore-filter
:reset-search-term-on-select="false"
v-bind="{ ...$attrs, options: filteredOptions }"
option-label="name"
unstyled
:max-selected-labels="0"
:pt="{
root: ({ props }: MultiSelectPassThroughMethodOptions) => ({
class: cn(
'relative inline-flex cursor-pointer select-none',
size === 'md' ? 'h-8' : 'h-10',
'rounded-lg bg-secondary-background text-base-foreground',
'transition-all duration-200 ease-in-out',
'hover:bg-secondary-background-hover',
'border-[2.5px] border-solid',
selectedCount > 0 ? 'border-base-foreground' : 'border-transparent',
'focus-within:border-base-foreground',
props.disabled &&
'cursor-default opacity-30 hover:bg-secondary-background'
)
}),
labelContainer: {
class: cn(
'flex flex-1 items-center overflow-hidden py-2 whitespace-nowrap',
size === 'md' ? 'pl-3' : 'pl-4'
)
},
label: {
class: 'p-0'
},
dropdown: {
class: 'flex shrink-0 cursor-pointer items-center justify-center px-3'
},
header: () => ({
class:
showSearchBox || showSelectedCount || showClearButton
? 'block'
: 'hidden'
}),
// Overlay & list visuals unchanged
overlay: {
class: cn(
'mt-2 rounded-lg p-2',
'bg-base-background',
'text-base-foreground',
'border border-solid border-border-default'
)
},
listContainer: () => ({
style: { maxHeight: `min(${listMaxHeight}, 50vh)` },
class: 'scrollbar-custom'
}),
list: {
class: 'flex flex-col gap-0 p-0 m-0 list-none border-none text-sm'
},
// Option row hover and focus tone
option: ({ context }: MultiSelectPassThroughMethodOptions) => ({
class: cn(
'flex h-10 cursor-pointer items-center gap-2 rounded-lg px-2',
'hover:bg-secondary-background-hover',
// Add focus/highlight state for keyboard navigation
context?.focused &&
'bg-secondary-background-selected hover:bg-secondary-background-selected'
)
}),
// Hide built-in checkboxes entirely via PT (no :deep)
pcHeaderCheckbox: {
root: { class: 'hidden' },
style: { display: 'none' }
},
pcOptionCheckbox: {
root: { class: 'hidden' },
style: { display: 'none' }
},
emptyMessage: {
class: 'px-3 pb-4 text-sm text-muted-foreground'
}
}"
:aria-label="label || t('g.multiSelectDropdown')"
role="combobox"
:aria-expanded="false"
aria-haspopup="listbox"
:tabindex="0"
>
<ComboboxAnchor as-child>
<ComboboxTrigger
v-bind="$attrs"
:aria-label="label || t('g.multiSelectDropdown')"
:class="
cn(
'relative inline-flex cursor-pointer items-center select-none',
size === 'md' ? 'h-8' : 'h-10',
'rounded-lg bg-secondary-background text-base-foreground',
'transition-all duration-200 ease-in-out',
'hover:bg-secondary-background-hover',
'border-[2.5px] border-solid border-transparent',
selectedCount > 0
? 'border-base-foreground'
: 'focus-visible:border-node-component-border data-[state=open]:border-node-component-border',
disabled &&
'cursor-default opacity-30 hover:bg-secondary-background'
)
"
>
<template
v-if="showSearchBox || showSelectedCount || showClearButton"
#header
>
<div class="flex flex-col px-2 pt-2 pb-0">
<SearchInput
v-if="showSearchBox"
v-model="searchQuery"
:class="showSelectedCount || showClearButton ? 'mb-2' : ''"
:placeholder="searchPlaceholder"
size="sm"
/>
<div
:class="
cn(
'flex flex-1 items-center overflow-hidden py-2 whitespace-nowrap',
size === 'md' ? 'pl-3' : 'pl-4'
)
"
v-if="showSelectedCount || showClearButton"
class="mt-2 flex items-center justify-between"
>
<span :class="size === 'md' ? 'text-xs' : 'text-sm'">
{{ label }}
</span>
<span
v-if="selectedCount > 0"
class="pointer-events-none absolute -top-2 -right-2 z-10 flex size-5 items-center justify-center rounded-full bg-base-foreground text-xs font-semibold text-base-background"
v-if="showSelectedCount"
class="px-1 text-sm text-base-foreground"
>
{{ selectedCount }}
{{
selectedCount > 0
? $t('g.itemsSelected', { selectedCount })
: $t('g.itemSelected', { selectedCount })
}}
</span>
<Button
v-if="showClearButton"
variant="textonly"
size="md"
@click.stop="selectedItems = []"
>
{{ $t('g.clearAll') }}
</Button>
</div>
<div
class="flex shrink-0 cursor-pointer items-center justify-center px-3"
>
<i class="icon-[lucide--chevron-down] text-muted-foreground" />
</div>
</ComboboxTrigger>
</ComboboxAnchor>
<div class="my-4 h-px bg-border-default"></div>
</div>
</template>
<ComboboxPortal>
<ComboboxContent
position="popper"
:side-offset="8"
align="start"
<!-- Trigger value (keep text scale identical) -->
<template #value>
<span :class="size === 'md' ? 'text-xs' : 'text-sm'">
{{ label }}
</span>
<span
v-if="selectedCount > 0"
class="pointer-events-none absolute -top-2 -right-2 z-10 flex size-5 items-center justify-center rounded-full bg-base-foreground text-xs font-semibold text-base-background"
>
{{ selectedCount }}
</span>
</template>
<!-- Chevron size identical to current -->
<template #dropdownicon>
<i class="icon-[lucide--chevron-down] text-muted-foreground" />
</template>
<!-- Custom option row: square checkbox + label (unchanged layout/colors) -->
<template #option="slotProps">
<div
role="button"
class="flex cursor-pointer items-center gap-2"
:style="popoverStyle"
:class="
cn(
'z-3000 overflow-hidden',
'rounded-lg p-2',
'bg-base-background text-base-foreground',
'border border-solid border-border-default',
'shadow-md',
'data-[state=closed]:animate-out data-[state=open]:animate-in',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
'data-[side=bottom]:slide-in-from-top-2'
)
"
@focus-outside="preventFocusDismiss"
>
<div
v-if="showSearchBox || showSelectedCount || showClearButton"
class="flex flex-col px-2 pt-2 pb-0"
>
<div
v-if="showSearchBox"
:class="
cn(
'flex items-center gap-2 rounded-lg border border-solid border-border-default px-3 py-1.5',
(showSelectedCount || showClearButton) && 'mb-2'
)
"
>
<i
class="icon-[lucide--search] shrink-0 text-sm text-muted-foreground"
/>
<ComboboxInput
v-model="searchQuery"
:placeholder="searchPlaceholder ?? t('g.search')"
class="w-full border-none bg-transparent text-sm outline-none"
/>
</div>
<div
v-if="showSelectedCount || showClearButton"
class="mt-2 flex items-center justify-between"
>
<span
v-if="showSelectedCount"
class="px-1 text-sm text-base-foreground"
>
{{ $t('g.itemsSelected', { count: selectedCount }) }}
</span>
<Button
v-if="showClearButton"
variant="textonly"
size="md"
@click.stop="selectedItems = []"
>
{{ $t('g.clearAll') }}
</Button>
</div>
<div class="my-4 h-px bg-border-default" />
</div>
<ComboboxViewport
class="flex size-4 shrink-0 items-center justify-center rounded-sm p-0.5 transition-all duration-200"
:class="
cn(
'flex flex-col gap-0 p-0 text-sm',
'scrollbar-custom overflow-y-auto',
'min-w-(--reka-combobox-trigger-width)'
)
slotProps.selected
? 'bg-primary-background'
: 'bg-secondary-background'
"
:style="{ maxHeight: `min(${listMaxHeight}, 50vh)` }"
>
<ComboboxItem
v-for="opt in filteredOptions"
:key="opt.value"
:value="opt"
:class="
cn(
'group flex h-10 shrink-0 cursor-pointer items-center gap-2 rounded-lg px-2 outline-none',
'hover:bg-secondary-background-hover',
'data-highlighted:bg-secondary-background-selected data-highlighted:hover:bg-secondary-background-selected'
)
"
>
<div
class="flex size-4 shrink-0 items-center justify-center rounded-sm transition-all duration-200 group-data-[state=checked]:bg-primary-background group-data-[state=unchecked]:bg-secondary-background [&>span]:flex"
>
<ComboboxItemIndicator>
<i
class="icon-[lucide--check] text-xs font-bold text-base-foreground"
/>
</ComboboxItemIndicator>
</div>
<span>{{ opt.name }}</span>
</ComboboxItem>
<ComboboxEmpty class="px-3 pb-4 text-sm text-muted-foreground">
{{ $t('g.noResultsFound') }}
</ComboboxEmpty>
</ComboboxViewport>
</ComboboxContent>
</ComboboxPortal>
</ComboboxRoot>
<i
v-if="slotProps.selected"
class="text-bold icon-[lucide--check] text-xs text-base-foreground"
/>
</div>
<span>
{{ slotProps.option.name }}
</span>
</div>
</template>
</MultiSelect>
</template>
<script setup lang="ts">
import { useFuse } from '@vueuse/integrations/useFuse'
import type { UseFuseOptions } from '@vueuse/integrations/useFuse'
import type { FocusOutsideEvent } from 'reka-ui'
import {
ComboboxAnchor,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxItemIndicator,
ComboboxPortal,
ComboboxRoot,
ComboboxTrigger,
ComboboxViewport
} from 'reka-ui'
import { computed } from 'vue'
import type { MultiSelectPassThroughMethodOptions } from 'primevue/multiselect'
import MultiSelect from 'primevue/multiselect'
import { computed, useAttrs } from 'vue'
import { useI18n } from 'vue-i18n'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import Button from '@/components/ui/button/Button.vue'
import { usePopoverSizing } from '@/composables/usePopoverSizing'
import { cn } from '@/utils/tailwindUtil'
import type { SelectOption } from './types'
type Option = SelectOption
defineOptions({
inheritAttrs: false
})
const {
label,
options = [],
size = 'lg',
disabled = false,
showSearchBox = false,
showSelectedCount = false,
showClearButton = false,
searchPlaceholder,
listMaxHeight = '28rem',
popoverMinWidth,
popoverMaxWidth
} = defineProps<{
interface Props {
/** Input label shown on the trigger button */
label?: string
/** Available options */
options?: SelectOption[]
/** Trigger size: 'lg' (40px, Interface) or 'md' (32px, Node) */
size?: 'lg' | 'md'
/** Disable the select */
disabled?: boolean
/** Show search box in the panel header */
showSearchBox?: boolean
/** Show selected count text in the panel header */
@@ -224,9 +216,22 @@ const {
popoverMinWidth?: string
/** Maximum width of the popover (default: auto) */
popoverMaxWidth?: string
}>()
// Note: options prop is intentionally omitted.
// It's passed via $attrs to maximize PrimeVue API compatibility
}
const {
label,
size = 'lg',
showSearchBox = false,
showSelectedCount = false,
showClearButton = false,
searchPlaceholder = 'Search...',
listMaxHeight = '28rem',
popoverMinWidth,
popoverMaxWidth
} = defineProps<Props>()
const selectedItems = defineModel<SelectOption[]>({
const selectedItems = defineModel<Option[]>({
required: true
})
const searchQuery = defineModel<string>('searchQuery', { default: '' })
@@ -234,16 +239,15 @@ const searchQuery = defineModel<string>('searchQuery', { default: '' })
const { t } = useI18n()
const selectedCount = computed(() => selectedItems.value.length)
function preventFocusDismiss(event: FocusOutsideEvent) {
event.preventDefault()
}
const popoverStyle = usePopoverSizing({
minWidth: popoverMinWidth,
maxWidth: popoverMaxWidth
})
const attrs = useAttrs()
const originalOptions = computed(() => (attrs.options as Option[]) || [])
const fuseOptions: UseFuseOptions<SelectOption> = {
// Use VueUse's useFuse for better reactivity and performance
const fuseOptions: UseFuseOptions<Option> = {
fuseOptions: {
keys: ['name', 'value'],
threshold: 0.3,
@@ -252,20 +256,23 @@ const fuseOptions: UseFuseOptions<SelectOption> = {
matchAllWhenSearchEmpty: true
}
const { results } = useFuse(searchQuery, () => options, fuseOptions)
const { results } = useFuse(searchQuery, originalOptions, fuseOptions)
// Filter options based on search, but always include selected items
const filteredOptions = computed(() => {
if (!searchQuery.value || searchQuery.value.trim() === '') {
return options
return originalOptions.value
}
// results.value already contains the search results from useFuse
const searchResults = results.value.map(
(result: { item: SelectOption }) => result.item
(result: { item: Option }) => result.item
)
// Include selected items that aren't in search results
const selectedButNotInResults = selectedItems.value.filter(
(item) =>
!searchResults.some((result: SelectOption) => result.value === item.value)
!searchResults.some((result: Option) => result.value === item.value)
)
return [...selectedButNotInResults, ...searchResults]

View File

@@ -1,12 +1,21 @@
<template>
<SelectRoot v-model="selectedItem" :disabled>
<SelectTrigger
v-bind="$attrs"
:aria-label="label || t('g.singleSelectDropdown')"
:aria-busy="loading || undefined"
:aria-invalid="invalid || undefined"
:class="
cn(
<!--
Note: We explicitly pass options here (not just via $attrs) because:
1. Our custom value template needs options to look up labels from values
2. PrimeVue's value slot only provides 'value' and 'placeholder', not the selected item's label
3. We need to maintain the icon slot functionality in the value template
option-label="name" is required because our option template directly accesses option.name
-->
<Select
v-model="selectedItem"
v-bind="$attrs"
:options="options"
option-label="name"
option-value="value"
unstyled
:pt="{
root: ({ props }: SelectPassThroughMethodOptions<SelectOption>) => ({
class: cn(
'relative inline-flex cursor-pointer items-center select-none',
size === 'md' ? 'h-8' : 'h-10',
'rounded-lg',
@@ -14,107 +23,121 @@
'transition-all duration-200 ease-in-out',
'hover:bg-secondary-background-hover',
'border-[2.5px] border-solid',
invalid ? 'border-destructive-background' : 'border-transparent',
'focus:border-node-component-border focus:outline-none',
'disabled:cursor-default disabled:opacity-30 disabled:hover:bg-secondary-background'
invalid
? 'border-destructive-background'
: 'border-transparent focus-within:border-node-component-border',
props.disabled &&
'cursor-default opacity-30 hover:bg-secondary-background'
)
"
>
}),
label: {
class: cn(
'flex flex-1 items-center py-2 whitespace-nowrap outline-hidden',
size === 'md' ? 'pl-3' : 'pl-4'
)
},
dropdown: {
class:
// Right chevron touch area
'flex shrink-0 items-center justify-center px-3 py-2'
},
overlay: {
class: cn(
'mt-2 rounded-lg p-2',
'bg-base-background text-base-foreground',
'border border-solid border-border-default'
)
},
listContainer: () => ({
style: `max-height: min(${listMaxHeight}, 50vh)`,
class: 'scrollbar-custom'
}),
list: {
class:
// Same list tone/size as MultiSelect
'flex flex-col gap-0 p-0 m-0 list-none border-none text-sm'
},
option: ({ context }: SelectPassThroughMethodOptions<SelectOption>) => ({
class: cn(
// Row layout
'flex items-center justify-between gap-3 rounded-sm px-2 py-3',
'hover:bg-secondary-background-hover',
// Add focus state for keyboard navigation
context.focused && 'bg-secondary-background-hover',
// Selected state + check icon
context.selected &&
'bg-secondary-background-selected hover:bg-secondary-background-selected'
)
}),
optionLabel: {
class: 'truncate'
},
optionGroupLabel: {
class: 'px-3 py-2 text-xs uppercase tracking-wide text-muted-foreground'
},
emptyMessage: {
class: 'px-3 py-2 text-sm text-muted-foreground'
}
}"
:aria-label="label || t('g.singleSelectDropdown')"
:aria-busy="loading || undefined"
:aria-invalid="invalid || undefined"
role="combobox"
:aria-expanded="false"
aria-haspopup="listbox"
:tabindex="0"
>
<!-- Trigger value -->
<template #value="slotProps">
<div
:class="
cn(
'flex flex-1 items-center gap-2 overflow-hidden py-2',
size === 'md' ? 'pl-3 text-xs' : 'pl-4 text-sm'
)
cn('flex items-center gap-2', size === 'md' ? 'text-xs' : 'text-sm')
"
>
<i
v-if="loading"
class="icon-[lucide--loader-circle] shrink-0 animate-spin text-muted-foreground"
class="icon-[lucide--loader-circle] animate-spin text-muted-foreground"
/>
<slot v-else name="icon" />
<SelectValue :placeholder="label" class="truncate" />
</div>
<div
class="flex shrink-0 cursor-pointer items-center justify-center px-3"
>
<i class="icon-[lucide--chevron-down] text-muted-foreground" />
</div>
</SelectTrigger>
<SelectPortal>
<SelectContent
position="popper"
:side-offset="8"
align="start"
:style="optionStyle"
:class="
cn(
'z-3000 overflow-hidden',
'rounded-lg p-2',
'bg-base-background text-base-foreground',
'border border-solid border-border-default',
'shadow-md',
'min-w-(--reka-select-trigger-width)',
'data-[state=closed]:animate-out data-[state=open]:animate-in',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
'data-[side=bottom]:slide-in-from-top-2'
)
"
>
<SelectViewport
:style="{ maxHeight: `min(${listMaxHeight}, 50vh)` }"
class="scrollbar-custom w-full"
<span
v-if="slotProps.value !== null && slotProps.value !== undefined"
class="text-base-foreground"
>
<SelectItem
v-for="opt in options"
:key="opt.value"
:value="opt.value"
:class="
cn(
'relative flex w-full cursor-pointer items-center justify-between select-none',
'gap-3 rounded-sm px-2 py-3 text-sm outline-none',
'hover:bg-secondary-background-hover',
'focus:bg-secondary-background-hover',
'data-[state=checked]:bg-secondary-background-selected',
'data-[state=checked]:hover:bg-secondary-background-selected'
)
"
>
<SelectItemText class="truncate">
{{ opt.name }}
</SelectItemText>
<SelectItemIndicator
class="flex shrink-0 items-center justify-center"
>
<i
class="icon-[lucide--check] text-base-foreground"
aria-hidden="true"
/>
</SelectItemIndicator>
</SelectItem>
</SelectViewport>
</SelectContent>
</SelectPortal>
</SelectRoot>
{{ getLabel(slotProps.value) }}
</span>
<span v-else class="text-base-foreground">
{{ label }}
</span>
</div>
</template>
<!-- Trigger caret (hidden when loading) -->
<template #dropdownicon>
<i
v-if="!loading"
class="icon-[lucide--chevron-down] text-muted-foreground"
/>
</template>
<!-- Option row -->
<template #option="{ option, selected }">
<div
class="flex w-full items-center justify-between gap-3"
:style="optionStyle"
>
<span class="truncate">{{ option.name }}</span>
<i v-if="selected" class="icon-[lucide--check] text-base-foreground" />
</div>
</template>
</Select>
</template>
<script setup lang="ts">
import {
SelectContent,
SelectItem,
SelectItemIndicator,
SelectItemText,
SelectPortal,
SelectRoot,
SelectTrigger,
SelectValue,
SelectViewport
} from 'reka-ui'
import type { SelectPassThroughMethodOptions } from 'primevue/select'
import Select from 'primevue/select'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { usePopoverSizing } from '@/composables/usePopoverSizing'
import { cn } from '@/utils/tailwindUtil'
import type { SelectOption } from './types'
@@ -129,12 +152,16 @@ const {
size = 'lg',
invalid = false,
loading = false,
disabled = false,
listMaxHeight = '28rem',
popoverMinWidth,
popoverMaxWidth
} = defineProps<{
label?: string
/**
* Required for displaying the selected item's label.
* Cannot rely on $attrs alone because we need to access options
* in getLabel() to map values to their display names.
*/
options?: SelectOption[]
/** Trigger size: 'lg' (40px, Interface) or 'md' (32px, Node) */
size?: 'lg' | 'md'
@@ -142,8 +169,6 @@ const {
invalid?: boolean
/** Show loading spinner instead of chevron */
loading?: boolean
/** Disable the select */
disabled?: boolean
/** Maximum height of the dropdown panel (default: 28rem) */
listMaxHeight?: string
/** Minimum width of the popover (default: auto) */
@@ -156,8 +181,26 @@ const selectedItem = defineModel<string | undefined>({ required: true })
const { t } = useI18n()
const optionStyle = usePopoverSizing({
minWidth: popoverMinWidth,
maxWidth: popoverMaxWidth
/**
* Maps a value to its display label.
* Necessary because PrimeVue's value slot doesn't provide the selected item's label,
* only the raw value. We need this to show the correct text when an item is selected.
*/
const getLabel = (val: string | null | undefined) => {
if (val == null) return label ?? ''
if (!options) return label ?? ''
const found = options.find((o) => o.value === val)
return found ? found.name : (label ?? '')
}
// Extract complex style logic from template
const optionStyle = computed(() => {
if (!popoverMinWidth && !popoverMaxWidth) return undefined
const styles: string[] = []
if (popoverMinWidth) styles.push(`min-width: ${popoverMinWidth}`)
if (popoverMaxWidth) styles.push(`max-width: ${popoverMaxWidth}`)
return styles.join('; ')
})
</script>

View File

@@ -14,11 +14,6 @@ const meta: Meta<typeof Loader> = {
control: 'select',
options: ['sm', 'md', 'lg'],
description: 'Spinner size: sm (16px), md (32px), lg (48px)'
},
variant: {
control: 'select',
options: ['loader', 'loader-circle'],
description: 'The type of loader displayed'
}
}
}

View File

@@ -1,28 +0,0 @@
<template>
<span
:class="
cn(
'flex h-5 shrink-0 items-center bg-component-node-widget-background p-1 text-xs',
rest ? 'rounded-l-full pr-1' : 'rounded-full'
)
"
>
<i class="icon-[lucide--component] h-full bg-amber-400" />
<span class="truncate" v-text="text" />
</span>
<span
v-if="rest"
class="-ml-2.5 max-w-max min-w-0 grow basis-0 truncate rounded-r-full bg-component-node-widget-background"
>
<span class="pr-2" v-text="rest" />
</span>
</template>
<script setup lang="ts">
import { cn } from '@/utils/tailwindUtil'
defineProps<{
text: string
rest?: string
}>()
</script>

View File

@@ -1,14 +1,9 @@
<template>
<div
class="flex flex-col overflow-hidden rounded-lg border border-border-default bg-base-background"
:style="{ width: `${BASE_WIDTH_PX * (scaleFactor / BASE_SCALE)}px` }"
class="flex w-50 flex-col overflow-hidden rounded-2xl border border-border-default bg-base-background"
>
<div ref="previewContainerRef" class="overflow-hidden p-3">
<div
ref="previewWrapperRef"
class="origin-top-left"
:style="{ transform: `scale(${scaleFactor})` }"
>
<div ref="previewWrapperRef" class="origin-top-left scale-50">
<LGraphNodePreview :node-def="nodeDef" position="relative" />
</div>
</div>
@@ -23,21 +18,21 @@
<!-- Category Path -->
<p
v-if="showCategoryPath && nodeDef.category"
class="-mt-1 truncate text-xs text-muted-foreground"
class="-mt-1 text-xs text-muted-foreground"
>
{{ categoryPath }}
{{ nodeDef.category.replaceAll('/', ' > ') }}
</p>
<!-- Badges -->
<div class="flex flex-wrap gap-2 overflow-hidden empty:hidden">
<NodePricingBadge class="max-w-full truncate" :node-def="nodeDef" />
<div class="flex flex-wrap gap-2 empty:hidden">
<NodePricingBadge :node-def="nodeDef" />
<NodeProviderBadge :node-def="nodeDef" />
</div>
<!-- Description -->
<p
v-if="nodeDef.description"
class="m-0 max-h-[30vh] overflow-y-auto text-xs/normal font-normal text-muted-foreground"
class="m-0 text-[11px] leading-normal font-normal text-muted-foreground"
>
{{ nodeDef.description }}
</p>
@@ -104,20 +99,17 @@ import NodeProviderBadge from '@/components/node/NodeProviderBadge.vue'
import LGraphNodePreview from '@/renderer/extensions/vueNodes/components/LGraphNodePreview.vue'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
const BASE_WIDTH_PX = 200
const BASE_SCALE = 0.5
const SCALE_FACTOR = 0.5
const PREVIEW_CONTAINER_PADDING_PX = 24
const {
nodeDef,
showInputsAndOutputs = true,
showCategoryPath = false,
scaleFactor = 0.5
showCategoryPath = false
} = defineProps<{
nodeDef: ComfyNodeDefImpl
showInputsAndOutputs?: boolean
showCategoryPath?: boolean
scaleFactor?: number
}>()
const previewContainerRef = ref<HTMLElement>()
@@ -126,13 +118,11 @@ const previewWrapperRef = ref<HTMLElement>()
useResizeObserver(previewWrapperRef, (entries) => {
const entry = entries[0]
if (entry && previewContainerRef.value) {
const scaledHeight = entry.contentRect.height * scaleFactor
const scaledHeight = entry.contentRect.height * SCALE_FACTOR
previewContainerRef.value.style.height = `${scaledHeight + PREVIEW_CONTAINER_PADDING_PX}px`
}
})
const categoryPath = computed(() => nodeDef.category?.replaceAll('/', ' / '))
const inputs = computed(() => {
if (!nodeDef.inputs) return []
return Object.entries(nodeDef.inputs)

View File

@@ -1,13 +1,18 @@
<template>
<span v-if="nodeDef.api_node && priceLabel">
<CreditBadge :text="priceLabel" />
</span>
<BadgePill
v-if="nodeDef.api_node"
v-show="priceLabel"
:text="priceLabel"
icon="icon-[comfy--credits]"
border-style="#f59e0b"
filled
/>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import CreditBadge from '@/components/node/CreditBadge.vue'
import BadgePill from '@/components/common/BadgePill.vue'
import { evaluateNodeDefPricing } from '@/composables/node/useNodePricing'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'

View File

@@ -9,15 +9,12 @@ import TabList from '@/components/tab/TabList.vue'
import Button from '@/components/ui/button/Button.vue'
import { useGraphHierarchy } from '@/composables/graph/useGraphHierarchy'
import { st } from '@/i18n'
import { app } from '@/scripts/app'
import { getActiveGraphNodeIds } from '@/utils/graphTraversalUtil'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import type { RightSidePanelTab } from '@/stores/workspace/rightSidePanelStore'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
@@ -41,21 +38,12 @@ import TabErrors from './errors/TabErrors.vue'
const canvasStore = useCanvasStore()
const executionErrorStore = useExecutionErrorStore()
const missingModelStore = useMissingModelStore()
const missingNodesErrorStore = useMissingNodesErrorStore()
const rightSidePanelStore = useRightSidePanelStore()
const settingStore = useSettingStore()
const { t } = useI18n()
const { hasAnyError, allErrorExecutionIds } = storeToRefs(executionErrorStore)
const activeMissingNodeGraphIds = computed<Set<string>>(() => {
if (!app.isGraphReady) return new Set()
return getActiveGraphNodeIds(
app.rootGraph,
canvasStore.currentGraph ?? app.rootGraph,
missingNodesErrorStore.missingAncestorExecutionIds
)
})
const { hasAnyError, allErrorExecutionIds, activeMissingNodeGraphIds } =
storeToRefs(executionErrorStore)
const { activeMissingModelGraphIds } = storeToRefs(missingModelStore)

View File

@@ -237,11 +237,6 @@ describe('ErrorNodeCard.vue', () => {
// Report is still generated with fallback log message
expect(mockGenerateErrorReport).toHaveBeenCalledOnce()
expect(mockGenerateErrorReport).toHaveBeenCalledWith(
expect.objectContaining({
serverLogs: 'Failed to retrieve server logs'
})
)
expect(wrapper.text()).toContain('ComfyUI Error Report')
})

View File

@@ -90,7 +90,6 @@
variant="secondary"
size="sm"
class="h-8 w-2/3 justify-center gap-1 rounded-lg text-xs"
data-testid="error-card-find-on-github"
@click="handleCheckGithub(error)"
>
{{ t('g.findOnGithub') }}
@@ -100,7 +99,6 @@
variant="secondary"
size="sm"
class="h-8 w-1/3 justify-center gap-1 rounded-lg text-xs"
data-testid="error-card-copy"
@click="handleCopyError(idx)"
>
{{ t('g.copy') }}
@@ -127,10 +125,12 @@
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useExternalLink } from '@/composables/useExternalLink'
import { useTelemetry } from '@/platform/telemetry'
import { useCommandStore } from '@/stores/commandStore'
import { cn } from '@/utils/tailwindUtil'
import type { ErrorCardData, ErrorItem } from './types'
import { useErrorActions } from './useErrorActions'
import { useErrorReport } from './useErrorReport'
const {
@@ -154,8 +154,10 @@ const emit = defineEmits<{
}>()
const { t } = useI18n()
const telemetry = useTelemetry()
const { staticUrls } = useExternalLink()
const commandStore = useCommandStore()
const { displayedDetailsMap } = useErrorReport(() => card)
const { findOnGitHub, contactSupport: handleGetHelp } = useErrorActions()
function handleLocateNode() {
if (card.nodeId) {
@@ -176,6 +178,23 @@ function handleCopyError(idx: number) {
}
function handleCheckGithub(error: ErrorItem) {
findOnGitHub(error.message)
telemetry?.trackUiButtonClicked({
button_id: 'error_tab_find_existing_issues_clicked'
})
const query = encodeURIComponent(error.message + ' is:issue')
window.open(
`${staticUrls.githubIssues}?q=${query}`,
'_blank',
'noopener,noreferrer'
)
}
function handleGetHelp() {
telemetry?.trackHelpResourceClicked({
resource_type: 'help_feedback',
is_external: true,
source: 'error_dialog'
})
commandStore.execute('Comfy.ContactSupport')
}
</script>

View File

@@ -1,5 +1,7 @@
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import PrimeVue from 'primevue/config'
import { ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
@@ -40,25 +42,23 @@ vi.mock('@/stores/systemStatsStore', () => ({
})
}))
const mockApplyChanges = vi.hoisted(() => vi.fn())
const mockIsRestarting = vi.hoisted(() => ({ value: false }))
const mockApplyChanges = vi.fn()
const mockIsRestarting = ref(false)
vi.mock('@/workbench/extensions/manager/composables/useApplyChanges', () => ({
useApplyChanges: () => ({
get isRestarting() {
return mockIsRestarting.value
},
isRestarting: mockIsRestarting,
applyChanges: mockApplyChanges
})
}))
const mockIsPackInstalled = vi.hoisted(() => vi.fn(() => false))
const mockIsPackInstalled = vi.fn(() => false)
vi.mock('@/workbench/extensions/manager/stores/comfyManagerStore', () => ({
useComfyManagerStore: () => ({
isPackInstalled: mockIsPackInstalled
})
}))
const mockShouldShowManagerButtons = vi.hoisted(() => ({ value: false }))
const mockShouldShowManagerButtons = { value: false }
vi.mock('@/workbench/extensions/manager/composables/useManagerState', () => ({
useManagerState: () => ({
shouldShowManagerButtons: mockShouldShowManagerButtons
@@ -128,7 +128,7 @@ function mountCard(
...props
},
global: {
plugins: [createTestingPinia({ createSpy: vi.fn }), i18n],
plugins: [createTestingPinia({ createSpy: vi.fn }), PrimeVue, i18n],
stubs: {
DotSpinner: { template: '<span role="status" aria-label="loading" />' }
}

View File

@@ -209,9 +209,12 @@ describe('TabErrors.vue', () => {
}
})
const copyButton = wrapper.find('[data-testid="error-card-copy"]')
expect(copyButton.exists()).toBe(true)
await copyButton.trigger('click')
// Find the copy button by text (rendered inside ErrorNodeCard)
const copyButton = wrapper
.findAll('button')
.find((btn) => btn.text().includes('Copy'))
expect(copyButton).toBeTruthy()
await copyButton!.trigger('click')
expect(mockCopy).toHaveBeenCalledWith('Test message\n\nTest details')
})
@@ -242,9 +245,5 @@ describe('TabErrors.vue', () => {
// Should render in the dedicated runtime error panel, not inside accordion
const runtimePanel = wrapper.find('[data-testid="runtime-error-panel"]')
expect(runtimePanel.exists()).toBe(true)
// Verify the error message appears exactly once (not duplicated in accordion)
expect(
wrapper.text().match(/RuntimeError: Out of memory/g) ?? []
).toHaveLength(1)
})
})

View File

@@ -53,7 +53,6 @@
<PropertiesAccordionItem
v-for="group in filteredGroups"
:key="group.title"
:data-testid="'error-group-' + group.type.replaceAll('_', '-')"
:collapse="isSectionCollapsed(group.title) && !isSearching"
class="border-b border-interface-stroke"
:size="getGroupSize(group)"
@@ -210,9 +209,12 @@
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useCommandStore } from '@/stores/commandStore'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { useFocusNode } from '@/composables/canvas/useFocusNode'
import { useExternalLink } from '@/composables/useExternalLink'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
@@ -236,7 +238,6 @@ import Button from '@/components/ui/button/Button.vue'
import DotSpinner from '@/components/common/DotSpinner.vue'
import { usePackInstall } from '@/workbench/extensions/manager/composables/nodePack/usePackInstall'
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
import { useErrorActions } from './useErrorActions'
import { useErrorGroups } from './useErrorGroups'
import type { SwapNodeGroup } from './useErrorGroups'
import type { ErrorGroup } from './types'
@@ -245,7 +246,7 @@ import { useNodeReplacement } from '@/platform/nodeReplacement/useNodeReplacemen
const { t } = useI18n()
const { copyToClipboard } = useCopyToClipboard()
const { focusNode, enterSubgraph } = useFocusNode()
const { openGitHubIssues, contactSupport } = useErrorActions()
const { staticUrls } = useExternalLink()
const settingStore = useSettingStore()
const rightSidePanelStore = useRightSidePanelStore()
const { shouldShowManagerButtons, shouldShowInstallButton, openManager } =
@@ -371,13 +372,13 @@ watch(
if (!graphNodeId) return
const prefix = `${graphNodeId}:`
for (const group of allErrorGroups.value) {
if (group.type !== 'execution') continue
const hasMatch = group.cards.some(
(card) =>
card.graphNodeId === graphNodeId ||
(card.nodeId?.startsWith(prefix) ?? false)
)
const hasMatch =
group.type === 'execution' &&
group.cards.some(
(card) =>
card.graphNodeId === graphNodeId ||
(card.nodeId?.startsWith(prefix) ?? false)
)
setSectionCollapsed(group.title, !hasMatch)
}
rightSidePanelStore.focusedErrorNodeId = null
@@ -417,4 +418,20 @@ function handleReplaceAll() {
function handleEnterSubgraph(nodeId: string) {
enterSubgraph(nodeId, errorNodeCache.value)
}
function openGitHubIssues() {
useTelemetry()?.trackUiButtonClicked({
button_id: 'error_tab_github_issues_clicked'
})
window.open(staticUrls.githubIssues, '_blank', 'noopener,noreferrer')
}
async function contactSupport() {
useTelemetry()?.trackHelpResourceClicked({
resource_type: 'help_feedback',
is_external: true,
source: 'error_dialog'
})
useCommandStore().execute('Comfy.ContactSupport')
}
</script>

View File

@@ -47,7 +47,7 @@ vi.mock('@/utils/executableGroupNodeDto', () => ({
isGroupNode: vi.fn(() => false)
}))
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useErrorGroups } from './useErrorGroups'
function makeMissingNodeType(
@@ -80,7 +80,8 @@ describe('swapNodeGroups computed', () => {
})
function getSwapNodeGroups(nodeTypes: MissingNodeType[]) {
useMissingNodesErrorStore().surfaceMissingNodes(nodeTypes)
const store = useExecutionErrorStore()
store.surfaceMissingNodes(nodeTypes)
const searchQuery = ref('')
const t = (key: string) => key

View File

@@ -1,39 +0,0 @@
import { useCommandStore } from '@/stores/commandStore'
import { useExternalLink } from '@/composables/useExternalLink'
import { useTelemetry } from '@/platform/telemetry'
export function useErrorActions() {
const telemetry = useTelemetry()
const commandStore = useCommandStore()
const { staticUrls } = useExternalLink()
function openGitHubIssues() {
telemetry?.trackUiButtonClicked({
button_id: 'error_tab_github_issues_clicked'
})
window.open(staticUrls.githubIssues, '_blank', 'noopener,noreferrer')
}
function contactSupport() {
telemetry?.trackHelpResourceClicked({
resource_type: 'help_feedback',
is_external: true,
source: 'error_dialog'
})
void commandStore.execute('Comfy.ContactSupport')
}
function findOnGitHub(errorMessage: string) {
telemetry?.trackUiButtonClicked({
button_id: 'error_tab_find_existing_issues_clicked'
})
const query = encodeURIComponent(errorMessage + ' is:issue')
window.open(
`${staticUrls.githubIssues}?q=${query}`,
'_blank',
'noopener,noreferrer'
)
}
return { openGitHubIssues, contactSupport, findOnGitHub }
}

View File

@@ -58,7 +58,6 @@ vi.mock(
)
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { useErrorGroups } from './useErrorGroups'
function makeMissingNodeType(
@@ -127,9 +126,8 @@ describe('useErrorGroups', () => {
})
it('groups non-replaceable nodes by cnrId', async () => {
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
missingNodesStore.setMissingNodeTypes([
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
makeMissingNodeType('NodeA', { cnrId: 'pack-1' }),
makeMissingNodeType('NodeB', { cnrId: 'pack-1', nodeId: '2' }),
makeMissingNodeType('NodeC', { cnrId: 'pack-2', nodeId: '3' })
@@ -148,9 +146,8 @@ describe('useErrorGroups', () => {
})
it('excludes replaceable nodes from missingPackGroups', async () => {
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
missingNodesStore.setMissingNodeTypes([
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
makeMissingNodeType('OldNode', {
isReplaceable: true,
replacement: { new_node_id: 'NewNode' }
@@ -167,9 +164,8 @@ describe('useErrorGroups', () => {
})
it('groups nodes without cnrId under null packId', async () => {
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
missingNodesStore.setMissingNodeTypes([
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
makeMissingNodeType('UnknownNode', { nodeId: '1' }),
makeMissingNodeType('AnotherUnknown', { nodeId: '2' })
])
@@ -181,9 +177,8 @@ describe('useErrorGroups', () => {
})
it('sorts groups alphabetically with null packId last', async () => {
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
missingNodesStore.setMissingNodeTypes([
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
makeMissingNodeType('NodeA', { cnrId: 'zebra-pack' }),
makeMissingNodeType('NodeB', { nodeId: '2' }),
makeMissingNodeType('NodeC', { cnrId: 'alpha-pack', nodeId: '3' })
@@ -195,9 +190,8 @@ describe('useErrorGroups', () => {
})
it('sorts nodeTypes within each group alphabetically by type then nodeId', async () => {
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
missingNodesStore.setMissingNodeTypes([
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
makeMissingNodeType('NodeB', { cnrId: 'pack-1', nodeId: '2' }),
makeMissingNodeType('NodeA', { cnrId: 'pack-1', nodeId: '3' }),
makeMissingNodeType('NodeA', { cnrId: 'pack-1', nodeId: '1' })
@@ -212,9 +206,8 @@ describe('useErrorGroups', () => {
})
it('handles string nodeType entries', async () => {
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
missingNodesStore.setMissingNodeTypes([
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
'StringGroupNode' as unknown as MissingNodeType
])
await nextTick()
@@ -231,9 +224,8 @@ describe('useErrorGroups', () => {
})
it('includes missing_node group when missing nodes exist', async () => {
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
missingNodesStore.setMissingNodeTypes([
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
makeMissingNodeType('NodeA', { cnrId: 'pack-1' })
])
await nextTick()
@@ -245,9 +237,8 @@ describe('useErrorGroups', () => {
})
it('includes swap_nodes group when replaceable nodes exist', async () => {
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
missingNodesStore.setMissingNodeTypes([
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
makeMissingNodeType('OldNode', {
isReplaceable: true,
replacement: { new_node_id: 'NewNode' }
@@ -262,9 +253,8 @@ describe('useErrorGroups', () => {
})
it('includes both swap_nodes and missing_node when both exist', async () => {
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
missingNodesStore.setMissingNodeTypes([
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
makeMissingNodeType('OldNode', {
isReplaceable: true,
replacement: { new_node_id: 'NewNode' }
@@ -282,9 +272,8 @@ describe('useErrorGroups', () => {
})
it('swap_nodes has lower priority than missing_node', async () => {
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
missingNodesStore.setMissingNodeTypes([
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
makeMissingNodeType('OldNode', {
isReplaceable: true,
replacement: { new_node_id: 'NewNode' }
@@ -544,18 +533,13 @@ describe('useErrorGroups', () => {
})
it('includes missing node group title as message', async () => {
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
missingNodesStore.setMissingNodeTypes([
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
makeMissingNodeType('NodeA', { cnrId: 'pack-1' })
])
await nextTick()
const missingGroup = groups.allErrorGroups.value.find(
(g) => g.type === 'missing_node'
)
expect(missingGroup).toBeDefined()
expect(groups.groupedErrorMessages.value).toContain(missingGroup!.title)
expect(groups.groupedErrorMessages.value.length).toBeGreaterThan(0)
})
})

View File

@@ -5,7 +5,6 @@ import type { IFuseOptions } from 'fuse.js'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { app } from '@/scripts/app'
@@ -196,8 +195,12 @@ function searchErrorGroups(groups: ErrorGroup[], query: string) {
cardIndex: ci,
searchableNodeId: card.nodeId ?? '',
searchableNodeTitle: card.nodeTitle ?? '',
searchableMessage: card.errors.map((e) => e.message).join(' '),
searchableDetails: card.errors.map((e) => e.details ?? '').join(' ')
searchableMessage: card.errors
.map((e: ErrorItem) => e.message)
.join(' '),
searchableDetails: card.errors
.map((e: ErrorItem) => e.details ?? '')
.join(' ')
})
}
}
@@ -237,7 +240,6 @@ export function useErrorGroups(
t: (key: string) => string
) {
const executionErrorStore = useExecutionErrorStore()
const missingNodesStore = useMissingNodesErrorStore()
const missingModelStore = useMissingModelStore()
const canvasStore = useCanvasStore()
const { inferPackFromNodeName } = useComfyRegistryStore()
@@ -283,7 +285,7 @@ export function useErrorGroups(
const missingNodeCache = computed(() => {
const map = new Map<string, LGraphNode>()
const nodeTypes = missingNodesStore.missingNodesError?.nodeTypes ?? []
const nodeTypes = executionErrorStore.missingNodesError?.nodeTypes ?? []
for (const nodeType of nodeTypes) {
if (typeof nodeType === 'string') continue
if (nodeType.nodeId == null) continue
@@ -405,7 +407,7 @@ export function useErrorGroups(
const asyncResolvedIds = ref<Map<string, string | null>>(new Map())
const pendingTypes = computed(() =>
(missingNodesStore.missingNodesError?.nodeTypes ?? []).filter(
(executionErrorStore.missingNodesError?.nodeTypes ?? []).filter(
(n): n is Exclude<MissingNodeType, string> =>
typeof n !== 'string' && !n.cnrId
)
@@ -446,8 +448,6 @@ export function useErrorGroups(
for (const r of results) {
if (r.status === 'fulfilled') {
final.set(r.value.type, r.value.packId)
} else {
console.warn('Failed to resolve pack ID:', r.reason)
}
}
// Clear any remaining RESOLVING markers for failed lookups
@@ -459,18 +459,8 @@ export function useErrorGroups(
{ immediate: true }
)
// Evict stale entries when missing nodes are cleared
watch(
() => missingNodesStore.missingNodesError,
(error) => {
if (!error && asyncResolvedIds.value.size > 0) {
asyncResolvedIds.value = new Map()
}
}
)
const missingPackGroups = computed<MissingPackGroup[]>(() => {
const nodeTypes = missingNodesStore.missingNodesError?.nodeTypes ?? []
const nodeTypes = executionErrorStore.missingNodesError?.nodeTypes ?? []
const map = new Map<
string | null,
{ nodeTypes: MissingNodeType[]; isResolving: boolean }
@@ -532,7 +522,7 @@ export function useErrorGroups(
})
const swapNodeGroups = computed<SwapNodeGroup[]>(() => {
const nodeTypes = missingNodesStore.missingNodesError?.nodeTypes ?? []
const nodeTypes = executionErrorStore.missingNodesError?.nodeTypes ?? []
const map = new Map<string, SwapNodeGroup>()
for (const nodeType of nodeTypes) {
@@ -556,7 +546,7 @@ export function useErrorGroups(
/** Builds an ErrorGroup from missingNodesError. Returns [] when none present. */
function buildMissingNodeGroups(): ErrorGroup[] {
const error = missingNodesStore.missingNodesError
const error = executionErrorStore.missingNodesError
if (!error) return []
const groups: ErrorGroup[] = []

View File

@@ -2,8 +2,6 @@ import { computed, onMounted, onUnmounted, reactive, toValue } from 'vue'
import type { MaybeRefOrGetter } from 'vue'
import { until } from '@vueuse/core'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
@@ -42,33 +40,24 @@ export function useErrorReport(cardSource: MaybeRefOrGetter<ErrorCardData>) {
if (runtimeErrors.length === 0) return
if (!systemStatsStore.systemStats) {
if (systemStatsStore.isLoading) {
await until(systemStatsStore.isLoading).toBe(false)
} else {
try {
await systemStatsStore.refetchSystemStats()
} catch (e) {
console.warn('Failed to fetch system stats for error report:', e)
return
}
try {
await systemStatsStore.refetchSystemStats()
} catch {
return
}
}
if (!systemStatsStore.systemStats || cancelled) return
if (cancelled || !systemStatsStore.systemStats) return
let logs: string
try {
logs = await api.getLogs()
} catch {
logs = 'Failed to retrieve server logs'
}
const logs = await api
.getLogs()
.catch(() => 'Failed to retrieve server logs')
if (cancelled) return
const workflow = (() => {
try {
return app.rootGraph.serialize()
} catch (e) {
console.warn('Failed to serialize workflow for error report:', e)
return null
}
})()
if (!workflow) return
const workflow = app.rootGraph.serialize()
for (const { error, idx } of runtimeErrors) {
try {
@@ -83,8 +72,8 @@ export function useErrorReport(cardSource: MaybeRefOrGetter<ErrorCardData>) {
workflow
})
enrichedDetails[idx] = report
} catch (e) {
console.warn('Failed to generate error report:', e)
} catch {
// Fallback: keep original error.details
}
}
})

View File

@@ -7,7 +7,7 @@
:pt="{
root: {
class: useSearchBoxV2
? 'w-full max-w-[56rem] min-w-[32rem] max-md:min-w-0 bg-transparent border-0 overflow-visible'
? 'w-4/5 min-w-[32rem] max-w-[56rem] border-0 bg-transparent mt-[10vh] max-md:w-[95%] max-md:min-w-0 overflow-visible'
: 'invisible-dialog-root'
},
mask: {
@@ -36,9 +36,7 @@
v-if="hoveredNodeDef && enableNodePreview"
:key="hoveredNodeDef.name"
:node-def="hoveredNodeDef"
:scale-factor="0.625"
show-category-path
inert
class="absolute top-0 left-full ml-3"
/>
</div>

View File

@@ -1,47 +1,32 @@
import type { VueWrapper } from '@vue/test-utils'
import { mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import NodeSearchCategorySidebar, {
DEFAULT_CATEGORY
} from '@/components/searchbox/v2/NodeSearchCategorySidebar.vue'
import NodeSearchCategorySidebar from '@/components/searchbox/v2/NodeSearchCategorySidebar.vue'
import {
createMockNodeDef,
setupTestPinia,
testI18n
} from '@/components/searchbox/v2/__test__/testUtils'
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: vi.fn(() => ({
get: vi.fn((key: string) => {
if (key === 'Comfy.NodeLibrary.Bookmarks.V2') return []
if (key === 'Comfy.NodeLibrary.BookmarksCustomization') return {}
return undefined
}),
get: vi.fn(() => undefined),
set: vi.fn()
}))
}))
describe('NodeSearchCategorySidebar', () => {
let wrapper: VueWrapper
beforeEach(() => {
vi.restoreAllMocks()
setupTestPinia()
})
afterEach(() => {
wrapper?.unmount()
})
async function createWrapper(props = {}) {
wrapper = mount(NodeSearchCategorySidebar, {
props: { selectedCategory: DEFAULT_CATEGORY, ...props },
global: { plugins: [testI18n] },
attachTo: document.body
const wrapper = mount(NodeSearchCategorySidebar, {
props: { selectedCategory: 'most-relevant', ...props },
global: { plugins: [testI18n] }
})
await nextTick()
return wrapper
@@ -61,29 +46,30 @@ describe('NodeSearchCategorySidebar', () => {
}
describe('preset categories', () => {
it('should always show Most relevant', async () => {
const wrapper = await createWrapper()
expect(wrapper.text()).toContain('Most relevant')
})
it('should not show Favorites in sidebar', async () => {
vi.spyOn(useNodeBookmarkStore(), 'bookmarks', 'get').mockReturnValue([
'some-bookmark'
it('should render all preset categories', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'EssentialNode',
essentials_category: 'basic',
python_module: 'comfy_essentials'
})
])
const wrapper = await createWrapper()
expect(wrapper.text()).not.toContain('Favorites')
})
await nextTick()
it('should not show source categories in sidebar', async () => {
const wrapper = await createWrapper()
expect(wrapper.text()).not.toContain('Extensions')
expect(wrapper.text()).not.toContain('Essentials')
expect(wrapper.text()).toContain('Most relevant')
expect(wrapper.text()).toContain('Recents')
expect(wrapper.text()).toContain('Favorites')
expect(wrapper.text()).toContain('Essentials')
expect(wrapper.text()).toContain('Blueprints')
expect(wrapper.text()).toContain('Partner')
expect(wrapper.text()).toContain('Comfy')
expect(wrapper.text()).toContain('Extensions')
})
it('should mark the selected preset category as selected', async () => {
const wrapper = await createWrapper({
selectedCategory: DEFAULT_CATEGORY
})
const wrapper = await createWrapper({ selectedCategory: 'most-relevant' })
const mostRelevantBtn = wrapper.find(
'[data-testid="category-most-relevant"]'
@@ -91,6 +77,17 @@ describe('NodeSearchCategorySidebar', () => {
expect(mostRelevantBtn.attributes('aria-current')).toBe('true')
})
it('should emit update:selectedCategory when preset is clicked', async () => {
const wrapper = await createWrapper({ selectedCategory: 'most-relevant' })
await clickCategory(wrapper, 'Favorites')
expect(wrapper.emitted('update:selectedCategory')).toBeTruthy()
expect(wrapper.emitted('update:selectedCategory')![0]).toEqual([
'favorites'
])
})
})
describe('category tree', () => {
@@ -130,8 +127,7 @@ describe('NodeSearchCategorySidebar', () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({ name: 'Node1', category: 'sampling' }),
createMockNodeDef({ name: 'Node2', category: 'sampling/advanced' }),
createMockNodeDef({ name: 'Node3', category: 'sampling/basic' }),
createMockNodeDef({ name: 'Node4', category: 'loaders' })
createMockNodeDef({ name: 'Node3', category: 'sampling/basic' })
])
await nextTick()
@@ -170,8 +166,7 @@ describe('NodeSearchCategorySidebar', () => {
it('should emit update:selectedCategory when subcategory is clicked', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({ name: 'Node1', category: 'sampling' }),
createMockNodeDef({ name: 'Node2', category: 'sampling/advanced' }),
createMockNodeDef({ name: 'Node3', category: 'loaders' })
createMockNodeDef({ name: 'Node2', category: 'sampling/advanced' })
])
await nextTick()
@@ -207,14 +202,11 @@ describe('NodeSearchCategorySidebar', () => {
it('should emit selected subcategory when expanded', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({ name: 'Node1', category: 'sampling' }),
createMockNodeDef({ name: 'Node2', category: 'sampling/advanced' }),
createMockNodeDef({ name: 'Node3', category: 'loaders' })
createMockNodeDef({ name: 'Node2', category: 'sampling/advanced' })
])
await nextTick()
const wrapper = await createWrapper({
selectedCategory: DEFAULT_CATEGORY
})
const wrapper = await createWrapper({ selectedCategory: 'most-relevant' })
// Expand and click subcategory
await clickCategory(wrapper, 'sampling', true)
@@ -225,16 +217,7 @@ describe('NodeSearchCategorySidebar', () => {
})
})
describe('hidePresets prop', () => {
it('should hide preset categories when hidePresets is true', async () => {
const wrapper = await createWrapper({ hidePresets: true })
expect(wrapper.text()).not.toContain('Most relevant')
expect(wrapper.text()).not.toContain('Custom')
})
})
it('should emit autoExpand for single root and support deeply nested categories', async () => {
it('should support deeply nested categories (3+ levels)', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({ name: 'Node1', category: 'api' }),
createMockNodeDef({ name: 'Node2', category: 'api/image' }),
@@ -244,14 +227,14 @@ describe('NodeSearchCategorySidebar', () => {
const wrapper = await createWrapper()
// Single root emits autoExpand
expect(wrapper.emitted('autoExpand')?.[0]).toEqual(['api'])
// Simulate parent handling autoExpand
await wrapper.setProps({ selectedCategory: 'api' })
await nextTick()
// Only top-level visible initially
expect(wrapper.text()).toContain('api')
expect(wrapper.text()).not.toContain('image')
expect(wrapper.text()).not.toContain('BFL')
// Expand api
await clickCategory(wrapper, 'api', true)
expect(wrapper.text()).toContain('image')
expect(wrapper.text()).not.toContain('BFL')
@@ -279,202 +262,4 @@ describe('NodeSearchCategorySidebar', () => {
expect(wrapper.emitted('update:selectedCategory')![0][0]).toBe('sampling')
})
describe('keyboard navigation', () => {
it('should expand a collapsed tree node on ArrowRight', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({ name: 'Node1', category: 'sampling' }),
createMockNodeDef({ name: 'Node2', category: 'sampling/advanced' }),
createMockNodeDef({ name: 'Node3', category: 'loaders' })
])
await nextTick()
const wrapper = await createWrapper()
expect(wrapper.text()).not.toContain('advanced')
const samplingBtn = wrapper.find('[data-testid="category-sampling"]')
await samplingBtn.trigger('keydown', { key: 'ArrowRight' })
await nextTick()
// Should have emitted select for sampling, expanding it
expect(wrapper.emitted('update:selectedCategory')).toBeTruthy()
expect(wrapper.emitted('update:selectedCategory')![0]).toEqual([
'sampling'
])
})
it('should collapse an expanded tree node on ArrowLeft', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({ name: 'Node1', category: 'sampling' }),
createMockNodeDef({ name: 'Node2', category: 'sampling/advanced' }),
createMockNodeDef({ name: 'Node3', category: 'loaders' })
])
await nextTick()
// First expand sampling by clicking
const wrapper = await createWrapper()
await clickCategory(wrapper, 'sampling', true)
expect(wrapper.text()).toContain('advanced')
const samplingBtn = wrapper.find('[data-testid="category-sampling"]')
await samplingBtn.trigger('keydown', { key: 'ArrowLeft' })
await nextTick()
// Collapse toggles internal state; children should be hidden
expect(wrapper.text()).not.toContain('advanced')
})
it('should focus first child on ArrowRight when already expanded', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({ name: 'Node1', category: 'sampling' }),
createMockNodeDef({ name: 'Node2', category: 'sampling/advanced' }),
createMockNodeDef({ name: 'Node3', category: 'loaders' })
])
await nextTick()
const wrapper = await createWrapper()
await clickCategory(wrapper, 'sampling', true)
expect(wrapper.text()).toContain('advanced')
const samplingBtn = wrapper.find('[data-testid="category-sampling"]')
await samplingBtn.trigger('keydown', { key: 'ArrowRight' })
await nextTick()
const advancedBtn = wrapper.find(
'[data-testid="category-sampling/advanced"]'
)
expect(advancedBtn.element).toBe(document.activeElement)
})
it('should focus parent on ArrowLeft from a leaf or collapsed node', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({ name: 'Node1', category: 'sampling' }),
createMockNodeDef({ name: 'Node2', category: 'sampling/advanced' }),
createMockNodeDef({ name: 'Node3', category: 'loaders' })
])
await nextTick()
const wrapper = await createWrapper()
await clickCategory(wrapper, 'sampling', true)
const advancedBtn = wrapper.find(
'[data-testid="category-sampling/advanced"]'
)
await advancedBtn.trigger('keydown', { key: 'ArrowLeft' })
await nextTick()
const samplingBtn = wrapper.find('[data-testid="category-sampling"]')
expect(samplingBtn.element).toBe(document.activeElement)
})
it('should collapse sampling on ArrowLeft, not just its expanded child', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({ name: 'Node1', category: 'sampling' }),
createMockNodeDef({
name: 'Node2',
category: 'sampling/custom_sampling'
}),
createMockNodeDef({
name: 'Node3',
category: 'sampling/custom_sampling/child'
}),
createMockNodeDef({ name: 'Node4', category: 'loaders' })
])
await nextTick()
const wrapper = await createWrapper()
// Step 1: Expand sampling
await clickCategory(wrapper, 'sampling', true)
await wrapper.setProps({ selectedCategory: 'sampling' })
await nextTick()
expect(wrapper.text()).toContain('custom_sampling')
// Step 2: Expand custom_sampling
await clickCategory(wrapper, 'custom_sampling', true)
await wrapper.setProps({ selectedCategory: 'sampling/custom_sampling' })
await nextTick()
expect(wrapper.text()).toContain('child')
// Step 3: Navigate back to sampling (keyboard focus only)
const samplingBtn = wrapper.find('[data-testid="category-sampling"]')
;(samplingBtn.element as HTMLElement).focus()
await nextTick()
// Step 4: Press left on sampling
await samplingBtn.trigger('keydown', { key: 'ArrowLeft' })
await nextTick()
// Sampling should collapse entirely — custom_sampling should not be visible
expect(wrapper.text()).not.toContain('custom_sampling')
})
it('should collapse 4-deep tree to parent of level 2 on ArrowLeft', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({ name: 'N1', category: 'a' }),
createMockNodeDef({ name: 'N2', category: 'a/b' }),
createMockNodeDef({ name: 'N3', category: 'a/b/c' }),
createMockNodeDef({ name: 'N4', category: 'a/b/c/d' }),
createMockNodeDef({ name: 'N5', category: 'other' })
])
await nextTick()
const wrapper = await createWrapper()
// Expand a → a/b → a/b/c → a/b/c/d
await clickCategory(wrapper, 'a', true)
await wrapper.setProps({ selectedCategory: 'a' })
await nextTick()
expect(wrapper.text()).toContain('b')
await clickCategory(wrapper, 'b', true)
await wrapper.setProps({ selectedCategory: 'a/b' })
await nextTick()
expect(wrapper.text()).toContain('c')
await clickCategory(wrapper, 'c', true)
await wrapper.setProps({ selectedCategory: 'a/b/c' })
await nextTick()
expect(wrapper.text()).toContain('d')
// Focus level 2 (a/b) and press ArrowLeft
const bBtn = wrapper.find('[data-testid="category-a/b"]')
;(bBtn.element as HTMLElement).focus()
await nextTick()
await bBtn.trigger('keydown', { key: 'ArrowLeft' })
await nextTick()
// Level 2 and below should collapse, but level 1 (a) stays expanded
// so 'b' is still visible but 'c' and 'd' are not
expect(wrapper.text()).toContain('b')
expect(wrapper.text()).not.toContain('c')
expect(wrapper.text()).not.toContain('d')
})
it('should set aria-expanded on tree nodes with children', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({ name: 'Node1', category: 'sampling' }),
createMockNodeDef({ name: 'Node2', category: 'sampling/advanced' }),
createMockNodeDef({ name: 'Node3', category: 'loaders' })
])
await nextTick()
const wrapper = await createWrapper()
const samplingTreeItem = wrapper
.find('[data-testid="category-sampling"]')
.element.closest('[role="treeitem"]')!
expect(samplingTreeItem.getAttribute('aria-expanded')).toBe('false')
// Leaf node should not have aria-expanded
const loadersTreeItem = wrapper
.find('[data-testid="category-loaders"]')
.element.closest('[role="treeitem"]')!
expect(loadersTreeItem.getAttribute('aria-expanded')).toBeNull()
})
})
})

View File

@@ -1,62 +1,52 @@
<template>
<RovingFocusGroup
as="div"
orientation="vertical"
:loop="true"
class="group/categories flex min-h-0 flex-col overflow-y-auto py-2.5 select-none"
>
<div class="flex min-h-0 flex-col overflow-y-auto py-2.5">
<!-- Preset categories -->
<div v-if="!hidePresets" class="flex flex-col px-3">
<RovingFocusItem
<div class="flex flex-col px-1">
<button
v-for="preset in topCategories"
:key="preset.id"
as-child
type="button"
:data-testid="`category-${preset.id}`"
:aria-current="selectedCategory === preset.id || undefined"
:class="categoryBtnClass(preset.id)"
@click="selectCategory(preset.id)"
>
<button
type="button"
:data-testid="`category-${preset.id}`"
:aria-current="selectedCategory === preset.id || undefined"
:class="categoryBtnClass(preset.id)"
@click="selectCategory(preset.id)"
>
{{ preset.label }}
</button>
</RovingFocusItem>
{{ preset.label }}
</button>
</div>
<!-- Source categories -->
<div class="my-2 flex flex-col border-y border-border-subtle px-1 py-2">
<button
v-for="preset in sourceCategories"
:key="preset.id"
type="button"
:data-testid="`category-${preset.id}`"
:aria-current="selectedCategory === preset.id || undefined"
:class="categoryBtnClass(preset.id)"
@click="selectCategory(preset.id)"
>
{{ preset.label }}
</button>
</div>
<!-- Category tree -->
<div
role="tree"
:aria-label="t('g.category')"
:class="
cn(
'flex flex-col px-3',
!hidePresets && 'mt-2 border-t border-border-subtle pt-2'
)
"
>
<div class="flex flex-col px-1">
<NodeSearchCategoryTreeNode
v-for="category in categoryTree"
:key="category.key"
:node="category"
:selected-category="selectedCategory"
:expanded-category="expandedCategory"
:hide-chevrons="hideChevrons"
:selected-collapsed="selectedCollapsed"
@select="selectCategory"
@collapse="collapseCategory"
/>
</div>
</RovingFocusGroup>
</div>
</template>
<script lang="ts">
export const DEFAULT_CATEGORY = 'most-relevant'
</script>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { RovingFocusGroup, RovingFocusItem } from 'reka-ui'
import NodeSearchCategoryTreeNode, {
CATEGORY_SELECTED_CLASS,
@@ -64,45 +54,52 @@ import NodeSearchCategoryTreeNode, {
} from '@/components/searchbox/v2/NodeSearchCategoryTreeNode.vue'
import type { CategoryNode } from '@/components/searchbox/v2/NodeSearchCategoryTreeNode.vue'
import { nodeOrganizationService } from '@/services/nodeOrganizationService'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { NodeSourceType } from '@/types/nodeSource'
import type { TreeNode } from '@/types/treeExplorerTypes'
import { cn } from '@/utils/tailwindUtil'
const {
hideChevrons = false,
hidePresets = false,
nodeDefs,
rootLabel,
rootKey
} = defineProps<{
hideChevrons?: boolean
hidePresets?: boolean
nodeDefs?: ComfyNodeDefImpl[]
rootLabel?: string
rootKey?: string
}>()
const selectedCategory = defineModel<string>('selectedCategory', {
required: true
})
const emit = defineEmits<{
autoExpand: [key: string]
}>()
const { t } = useI18n()
const nodeDefStore = useNodeDefStore()
const topCategories = computed(() => [
{ id: DEFAULT_CATEGORY, label: t('g.mostRelevant') }
{ id: 'most-relevant', label: t('g.mostRelevant') },
{ id: 'recents', label: t('g.recents') },
{ id: 'favorites', label: t('g.favorites') }
])
const hasEssentialNodes = computed(() =>
nodeDefStore.visibleNodeDefs.some(
(n) => n.nodeSource.type === NodeSourceType.Essentials
)
)
const sourceCategories = computed(() => {
const categories = []
if (hasEssentialNodes.value) {
categories.push({ id: 'essentials', label: t('g.essentials') })
}
categories.push(
{
id: 'blueprints',
label: t('sideToolbar.nodeLibraryTab.filterOptions.blueprints')
},
{ id: 'partner', label: t('g.partner') },
{ id: 'comfy', label: t('g.comfy') },
{ id: 'extensions', label: t('g.extensions') }
)
return categories
})
const categoryTree = computed<CategoryNode[]>(() => {
const defs = nodeDefs ?? nodeDefStore.visibleNodeDefs
const tree = nodeOrganizationService.organizeNodes(defs, {
groupBy: 'category'
})
const tree = nodeOrganizationService.organizeNodes(
nodeDefStore.visibleNodeDefs,
{ groupBy: 'category' }
)
const stripRootPrefix = (key: string) => key.replace(/^root\//, '')
@@ -117,82 +114,28 @@ const categoryTree = computed<CategoryNode[]>(() => {
}
}
const nodes = (tree.children ?? [])
return (tree.children ?? [])
.filter((node): node is TreeNode => !node.leaf)
.map(mapNode)
if (rootLabel && nodes.length > 1) {
const key = rootKey ?? rootLabel.toLowerCase()
function prefixKeys(node: CategoryNode): CategoryNode {
return {
key: key + '/' + node.key,
label: node.label,
...(node.children?.length
? { children: node.children.map(prefixKeys) }
: {})
}
}
return [{ key, label: rootLabel, children: nodes.map(prefixKeys) }]
}
return nodes
})
// Notify parent when there is only a single root category to auto-expand
watch(
categoryTree,
(nodes) => {
if (nodes.length === 1 && nodes[0].children?.length) {
const rootKey = nodes[0].key
if (
selectedCategory.value !== rootKey &&
!selectedCategory.value.startsWith(rootKey + '/')
) {
emit('autoExpand', rootKey)
}
}
},
{ immediate: true }
)
function categoryBtnClass(id: string) {
return cn(
'cursor-pointer rounded-lg border-none bg-transparent py-2.5 pr-3 text-left font-inter text-sm transition-colors',
hideChevrons ? 'pl-3' : 'pl-9',
'cursor-pointer rounded-sm border-none bg-transparent px-3 py-2.5 text-left text-sm transition-colors',
selectedCategory.value === id
? CATEGORY_SELECTED_CLASS
: CATEGORY_UNSELECTED_CLASS
)
}
const expandedCategory = ref(selectedCategory.value)
let lastEmittedCategory = ''
watch(selectedCategory, (val) => {
if (val !== lastEmittedCategory) {
expandedCategory.value = val
}
lastEmittedCategory = ''
})
function parentCategory(key: string): string {
const i = key.lastIndexOf('/')
return i > 0 ? key.slice(0, i) : ''
}
const selectedCollapsed = ref(false)
function selectCategory(categoryId: string) {
if (expandedCategory.value === categoryId) {
expandedCategory.value = parentCategory(categoryId)
if (selectedCategory.value === categoryId) {
selectedCollapsed.value = !selectedCollapsed.value
} else {
expandedCategory.value = categoryId
selectedCollapsed.value = false
selectedCategory.value = categoryId
}
lastEmittedCategory = categoryId
selectedCategory.value = categoryId
}
function collapseCategory(categoryId: string) {
expandedCategory.value = parentCategory(categoryId)
lastEmittedCategory = categoryId
selectedCategory.value = categoryId
}
</script>

View File

@@ -1,66 +1,32 @@
<template>
<div
<button
type="button"
:data-testid="`category-${node.key}`"
:aria-current="selectedCategory === node.key || undefined"
:style="{ paddingLeft: `${0.75 + depth * 1.25}rem` }"
:class="
cn(
selectedCategory === node.key &&
isExpanded &&
node.children?.length &&
'rounded-lg bg-secondary-background'
'w-full cursor-pointer rounded-sm border-none bg-transparent py-2.5 pr-3 text-left text-sm transition-colors',
selectedCategory === node.key
? CATEGORY_SELECTED_CLASS
: CATEGORY_UNSELECTED_CLASS
)
"
@click="$emit('select', node.key)"
>
<RovingFocusItem as-child>
<button
ref="buttonEl"
type="button"
role="treeitem"
:data-testid="`category-${node.key}`"
:aria-current="selectedCategory === node.key || undefined"
:aria-expanded="node.children?.length ? isExpanded : undefined"
:style="{ paddingLeft: `${0.75 + depth * 1.25}rem` }"
:class="
cn(
'flex w-full cursor-pointer items-center gap-2 rounded-lg border-none bg-transparent py-2.5 pr-3 text-left font-inter text-sm transition-colors',
selectedCategory === node.key
? CATEGORY_SELECTED_CLASS
: CATEGORY_UNSELECTED_CLASS
)
"
@click="$emit('select', node.key)"
@keydown.right.prevent="handleRight"
@keydown.left.prevent="handleLeft"
>
<i
v-if="!hideChevrons"
:class="
cn(
'size-4 shrink-0 text-muted-foreground transition-[transform,opacity] duration-150',
node.children?.length
? 'icon-[lucide--chevron-down] opacity-0 group-hover/categories:opacity-100 group-has-focus-visible/categories:opacity-100'
: '',
node.children?.length && !isExpanded && '-rotate-90'
)
"
/>
<span class="flex-1 truncate">{{ node.label }}</span>
</button>
</RovingFocusItem>
<div v-if="isExpanded && node.children?.length" role="group">
<NodeSearchCategoryTreeNode
v-for="child in node.children"
:key="child.key"
ref="childRefs"
:node="child"
:depth="depth + 1"
:selected-category="selectedCategory"
:expanded-category="expandedCategory"
:hide-chevrons="hideChevrons"
:focus-parent="() => buttonEl?.focus()"
@select="$emit('select', $event)"
@collapse="$emit('collapse', $event)"
/>
</div>
</div>
{{ node.label }}
</button>
<template v-if="isExpanded && node.children?.length">
<NodeSearchCategoryTreeNode
v-for="child in node.children"
:key="child.key"
:node="child"
:depth="depth + 1"
:selected-category="selectedCategory"
:selected-collapsed="selectedCollapsed"
@select="$emit('select', $event)"
/>
</template>
</template>
<script lang="ts">
@@ -71,14 +37,13 @@ export interface CategoryNode {
}
export const CATEGORY_SELECTED_CLASS =
'bg-secondary-background-hover text-foreground'
'bg-secondary-background-hover font-semibold text-foreground'
export const CATEGORY_UNSELECTED_CLASS =
'text-muted-foreground hover:bg-secondary-background-hover hover:text-foreground'
</script>
<script setup lang="ts">
import { computed, nextTick, ref } from 'vue'
import { RovingFocusItem } from 'reka-ui'
import { computed } from 'vue'
import { cn } from '@/utils/tailwindUtil'
@@ -86,53 +51,20 @@ const {
node,
depth = 0,
selectedCategory,
expandedCategory,
hideChevrons = false,
focusParent
selectedCollapsed = false
} = defineProps<{
node: CategoryNode
depth?: number
selectedCategory: string
expandedCategory: string
hideChevrons?: boolean
focusParent?: () => void
selectedCollapsed?: boolean
}>()
const emit = defineEmits<{
defineEmits<{
select: [key: string]
collapse: [key: string]
}>()
const buttonEl = ref<HTMLButtonElement>()
const childRefs = ref<{ focus?: () => void }[]>([])
defineExpose({ focus: () => buttonEl.value?.focus() })
const isExpanded = computed(
() =>
expandedCategory === node.key || expandedCategory.startsWith(node.key + '/')
)
function handleRight() {
if (!node.children?.length) return
if (!isExpanded.value) {
emit('select', node.key)
return
}
nextTick(() => {
childRefs.value[0]?.focus?.()
})
}
function handleLeft() {
if (node.children?.length && isExpanded.value) {
if (expandedCategory.startsWith(node.key + '/')) {
emit('collapse', node.key)
} else {
emit('select', node.key)
}
return
}
focusParent?.()
}
const isExpanded = computed(() => {
if (selectedCategory === node.key) return !selectedCollapsed
return selectedCategory.startsWith(node.key + '/')
})
</script>

View File

@@ -3,8 +3,8 @@ import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import NodeSearchCategorySidebar from '@/components/searchbox/v2/NodeSearchCategorySidebar.vue'
import NodeSearchContent from '@/components/searchbox/v2/NodeSearchContent.vue'
import NodeSearchFilterBar from '@/components/searchbox/v2/NodeSearchFilterBar.vue'
import {
createMockNodeDef,
setupTestPinia,
@@ -55,35 +55,13 @@ describe('NodeSearchContent', () => {
return wrapper
}
function mockBookmarks(
isBookmarked: boolean | ((node: ComfyNodeDefImpl) => boolean) = true,
bookmarkList: string[] = []
) {
const bookmarkStore = useNodeBookmarkStore()
if (typeof isBookmarked === 'function') {
vi.spyOn(bookmarkStore, 'isBookmarked').mockImplementation(isBookmarked)
} else {
vi.spyOn(bookmarkStore, 'isBookmarked').mockReturnValue(isBookmarked)
}
vi.spyOn(bookmarkStore, 'bookmarks', 'get').mockReturnValue(bookmarkList)
}
function clickFilterButton(wrapper: VueWrapper, text: string) {
const btn = wrapper
.findComponent(NodeSearchFilterBar)
.findAll('button')
.find((b) => b.text() === text)
expect(btn, `Expected filter button "${text}"`).toBeDefined()
return btn!.trigger('click')
}
async function setupFavorites(
nodes: Parameters<typeof createMockNodeDef>[0][]
) {
useNodeDefStore().updateNodeDefs(nodes.map(createMockNodeDef))
mockBookmarks(true, ['placeholder'])
vi.spyOn(useNodeBookmarkStore(), 'isBookmarked').mockReturnValue(true)
const wrapper = await createWrapper()
await clickFilterButton(wrapper, 'Bookmarked')
await wrapper.find('[data-testid="category-favorites"]').trigger('click')
await nextTick()
return wrapper
}
@@ -128,13 +106,12 @@ describe('NodeSearchContent', () => {
display_name: 'Regular Node'
})
])
mockBookmarks(
(node: ComfyNodeDefImpl) => node.name === 'BookmarkedNode',
['BookmarkedNode']
vi.spyOn(useNodeBookmarkStore(), 'isBookmarked').mockImplementation(
(node: ComfyNodeDefImpl) => node.name === 'BookmarkedNode'
)
const wrapper = await createWrapper()
await clickFilterButton(wrapper, 'Bookmarked')
await wrapper.find('[data-testid="category-favorites"]').trigger('click')
await nextTick()
const items = getNodeItems(wrapper)
@@ -146,15 +123,83 @@ describe('NodeSearchContent', () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({ name: 'Node1', display_name: 'Node One' })
])
mockBookmarks(false, ['placeholder'])
vi.spyOn(useNodeBookmarkStore(), 'isBookmarked').mockReturnValue(false)
const wrapper = await createWrapper()
await clickFilterButton(wrapper, 'Bookmarked')
await wrapper.find('[data-testid="category-favorites"]').trigger('click')
await nextTick()
expect(wrapper.text()).toContain('No results')
})
it('should show only CustomNodes when Extensions is selected', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'CoreNode',
display_name: 'Core Node',
python_module: 'nodes'
}),
createMockNodeDef({
name: 'CustomNode',
display_name: 'Custom Node',
python_module: 'custom_nodes.my_extension'
})
])
await nextTick()
expect(useNodeDefStore().nodeDefsByName['CoreNode'].nodeSource.type).toBe(
NodeSourceType.Core
)
expect(
useNodeDefStore().nodeDefsByName['CustomNode'].nodeSource.type
).toBe(NodeSourceType.CustomNodes)
const wrapper = await createWrapper()
await wrapper.find('[data-testid="category-extensions"]').trigger('click')
await nextTick()
const items = getNodeItems(wrapper)
expect(items).toHaveLength(1)
expect(items[0].text()).toContain('Custom Node')
})
it('should hide Essentials category when no essential nodes exist', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'RegularNode',
display_name: 'Regular Node'
})
])
const wrapper = await createWrapper()
expect(wrapper.find('[data-testid="category-essentials"]').exists()).toBe(
false
)
})
it('should show only essential nodes when Essentials is selected', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'EssentialNode',
display_name: 'Essential Node',
essentials_category: 'basic'
}),
createMockNodeDef({
name: 'RegularNode',
display_name: 'Regular Node'
})
])
await nextTick()
const wrapper = await createWrapper()
await wrapper.find('[data-testid="category-essentials"]').trigger('click')
await nextTick()
const items = getNodeItems(wrapper)
expect(items).toHaveLength(1)
expect(items[0].text()).toContain('Essential Node')
})
it('should include subcategory nodes when parent category is selected', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
@@ -185,137 +230,8 @@ describe('NodeSearchContent', () => {
})
})
describe('root filter (filter bar categories)', () => {
it('should show only non-Core nodes when Extensions root filter is active', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'CoreNode',
display_name: 'Core Node',
python_module: 'nodes'
}),
createMockNodeDef({
name: 'CustomNode',
display_name: 'Custom Node',
python_module: 'custom_nodes.my_extension'
})
])
await nextTick()
expect(useNodeDefStore().nodeDefsByName['CoreNode'].nodeSource.type).toBe(
NodeSourceType.Core
)
expect(
useNodeDefStore().nodeDefsByName['CustomNode'].nodeSource.type
).toBe(NodeSourceType.CustomNodes)
const wrapper = await createWrapper()
const extensionsBtn = wrapper
.findAll('button')
.find((b) => b.text().includes('Extensions'))
expect(extensionsBtn).toBeTruthy()
await extensionsBtn!.trigger('click')
await nextTick()
const items = getNodeItems(wrapper)
expect(items).toHaveLength(1)
expect(items[0].text()).toContain('Custom Node')
})
it('should show only essential nodes when Essentials root filter is active', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'EssentialNode',
display_name: 'Essential Node',
essentials_category: 'basic'
}),
createMockNodeDef({
name: 'RegularNode',
display_name: 'Regular Node'
})
])
await nextTick()
const wrapper = await createWrapper()
const filterBar = wrapper.findComponent(NodeSearchFilterBar)
const essentialsBtn = filterBar
.findAll('button')
.find((b) => b.text().includes('Essentials'))
expect(essentialsBtn).toBeTruthy()
await essentialsBtn!.trigger('click')
await nextTick()
const items = getNodeItems(wrapper)
expect(items).toHaveLength(1)
expect(items[0].text()).toContain('Essential Node')
})
it('should show only API nodes when Partner Nodes root filter is active', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'ApiNode',
display_name: 'API Node',
api_node: true
}),
createMockNodeDef({
name: 'RegularNode',
display_name: 'Regular Node'
})
])
const wrapper = await createWrapper()
const filterBar = wrapper.findComponent(NodeSearchFilterBar)
const partnerBtn = filterBar
.findAll('button')
.find((b) => b.text().includes('Partner'))
expect(partnerBtn).toBeTruthy()
await partnerBtn!.trigger('click')
await nextTick()
const items = getNodeItems(wrapper)
expect(items).toHaveLength(1)
expect(items[0].text()).toContain('API Node')
})
it('should toggle root filter off when clicking the active category button', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'CoreNode',
display_name: 'Core Node',
python_module: 'nodes'
}),
createMockNodeDef({
name: 'CustomNode',
display_name: 'Custom Node',
python_module: 'custom_nodes.my_extension'
})
])
await nextTick()
vi.spyOn(useNodeFrequencyStore(), 'topNodeDefs', 'get').mockReturnValue([
useNodeDefStore().nodeDefsByName['CoreNode'],
useNodeDefStore().nodeDefsByName['CustomNode']
])
const wrapper = await createWrapper()
const filterBar = wrapper.findComponent(NodeSearchFilterBar)
const extensionsBtn = filterBar
.findAll('button')
.find((b) => b.text().includes('Extensions'))!
// Activate
await extensionsBtn.trigger('click')
await nextTick()
expect(getNodeItems(wrapper)).toHaveLength(1)
// Deactivate (toggle off)
await extensionsBtn.trigger('click')
await nextTick()
expect(getNodeItems(wrapper)).toHaveLength(2)
})
})
describe('search and category interaction', () => {
it('should search within selected category', async () => {
it('should override category to most-relevant when search query is active', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'KSampler',
@@ -340,14 +256,13 @@ describe('NodeSearchContent', () => {
await nextTick()
const texts = getNodeItems(wrapper).map((i) => i.text())
expect(texts.some((t) => t.includes('Load Checkpoint'))).toBe(false)
expect(texts.some((t) => t.includes('Load Checkpoint'))).toBe(true)
})
it('should preserve search query when category changes', async () => {
it('should clear search query when category changes', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({ name: 'TestNode', display_name: 'Test Node' })
])
mockBookmarks(true, ['placeholder'])
const wrapper = await createWrapper()
@@ -356,9 +271,9 @@ describe('NodeSearchContent', () => {
await nextTick()
expect((input.element as HTMLInputElement).value).toBe('test query')
await clickFilterButton(wrapper, 'Bookmarked')
await wrapper.find('[data-testid="category-favorites"]').trigger('click')
await nextTick()
expect((input.element as HTMLInputElement).value).toBe('test query')
expect((input.element as HTMLInputElement).value).toBe('')
})
it('should reset selected index when search query changes', async () => {
@@ -391,10 +306,11 @@ describe('NodeSearchContent', () => {
await input.trigger('keydown', { key: 'ArrowDown' })
await nextTick()
// Toggle Bookmarked off (back to default) then on again to reset index
await clickFilterButton(wrapper, 'Bookmarked')
await wrapper
.find('[data-testid="category-most-relevant"]')
.trigger('click')
await nextTick()
await clickFilterButton(wrapper, 'Bookmarked')
await wrapper.find('[data-testid="category-favorites"]').trigger('click')
await nextTick()
expect(getResultItems(wrapper)[0].attributes('aria-selected')).toBe(
@@ -457,63 +373,19 @@ describe('NodeSearchContent', () => {
})
})
it('should select item on hover via pointermove', async () => {
it('should select item on hover', async () => {
const wrapper = await setupFavorites([
{ name: 'Node1', display_name: 'Node One' },
{ name: 'Node2', display_name: 'Node Two' }
])
const results = getResultItems(wrapper)
await results[1].trigger('pointermove')
await results[1].trigger('mouseenter')
await nextTick()
expect(results[1].attributes('aria-selected')).toBe('true')
})
it('should navigate results with ArrowDown/ArrowUp from a focused result item', async () => {
const wrapper = await setupFavorites([
{ name: 'Node1', display_name: 'Node One' },
{ name: 'Node2', display_name: 'Node Two' },
{ name: 'Node3', display_name: 'Node Three' }
])
const results = getResultItems(wrapper)
await results[0].trigger('keydown', { key: 'ArrowDown' })
await nextTick()
expect(getResultItems(wrapper)[1].attributes('aria-selected')).toBe(
'true'
)
await getResultItems(wrapper)[1].trigger('keydown', { key: 'ArrowDown' })
await nextTick()
expect(getResultItems(wrapper)[2].attributes('aria-selected')).toBe(
'true'
)
await getResultItems(wrapper)[2].trigger('keydown', { key: 'ArrowUp' })
await nextTick()
expect(getResultItems(wrapper)[1].attributes('aria-selected')).toBe(
'true'
)
})
it('should select node with Enter from a focused result item', async () => {
const wrapper = await setupFavorites([
{ name: 'TestNode', display_name: 'Test Node' }
])
await getResultItems(wrapper)[0].trigger('keydown', { key: 'Enter' })
await nextTick()
expect(wrapper.emitted('addNode')).toBeTruthy()
expect(wrapper.emitted('addNode')![0][0]).toMatchObject({
name: 'TestNode'
})
})
it('should add node on click', async () => {
const wrapper = await setupFavorites([
{ name: 'TestNode', display_name: 'Test Node' }
@@ -541,10 +413,10 @@ describe('NodeSearchContent', () => {
})
it('should emit null hoverNode when no results', async () => {
mockBookmarks(false, ['placeholder'])
const wrapper = await createWrapper()
await clickFilterButton(wrapper, 'Bookmarked')
vi.spyOn(useNodeBookmarkStore(), 'isBookmarked').mockReturnValue(false)
await wrapper.find('[data-testid="category-favorites"]').trigger('click')
await nextTick()
const emitted = wrapper.emitted('hoverNode')!
@@ -637,4 +509,221 @@ describe('NodeSearchContent', () => {
})
})
})
describe('filter selection mode', () => {
function setupNodesWithTypes() {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'ImageNode',
display_name: 'Image Node',
input: { required: { image: ['IMAGE', {}] } },
output: ['IMAGE']
}),
createMockNodeDef({
name: 'LatentNode',
display_name: 'Latent Node',
input: { required: { latent: ['LATENT', {}] } },
output: ['LATENT']
}),
createMockNodeDef({
name: 'ModelNode',
display_name: 'Model Node',
input: { required: { model: ['MODEL', {}] } },
output: ['MODEL']
})
])
}
function findFilterBarButton(wrapper: VueWrapper, label: string) {
return wrapper
.findAll('button[aria-pressed]')
.find((b) => b.text() === label)
}
async function enterFilterMode(wrapper: VueWrapper) {
await findFilterBarButton(wrapper, 'Input')!.trigger('click')
await nextTick()
}
function getFilterOptions(wrapper: VueWrapper) {
return wrapper.findAll('[data-testid="filter-option"]')
}
function getFilterOptionTexts(wrapper: VueWrapper) {
return getFilterOptions(wrapper).map(
(o) =>
o
.findAll('span')[0]
?.text()
.replace(/^[•·]\s*/, '')
.trim() ?? ''
)
}
function hasSidebar(wrapper: VueWrapper) {
return wrapper.findComponent(NodeSearchCategorySidebar).exists()
}
it('should enter filter mode when a filter chip is selected', async () => {
setupNodesWithTypes()
const wrapper = await createWrapper()
expect(hasSidebar(wrapper)).toBe(true)
await enterFilterMode(wrapper)
expect(hasSidebar(wrapper)).toBe(false)
expect(getFilterOptions(wrapper).length).toBeGreaterThan(0)
})
it('should show available filter options sorted alphabetically', async () => {
setupNodesWithTypes()
const wrapper = await createWrapper()
await enterFilterMode(wrapper)
const texts = getFilterOptionTexts(wrapper)
expect(texts).toContain('IMAGE')
expect(texts).toContain('LATENT')
expect(texts).toContain('MODEL')
expect(texts).toEqual([...texts].sort())
})
it('should filter options when typing in filter mode', async () => {
setupNodesWithTypes()
const wrapper = await createWrapper()
await enterFilterMode(wrapper)
await wrapper.find('input[type="text"]').setValue('IMAGE')
await nextTick()
const texts = getFilterOptionTexts(wrapper)
expect(texts).toContain('IMAGE')
expect(texts).not.toContain('MODEL')
})
it('should show no results when filter query has no matches', async () => {
setupNodesWithTypes()
const wrapper = await createWrapper()
await enterFilterMode(wrapper)
await wrapper.find('input[type="text"]').setValue('NONEXISTENT_TYPE')
await nextTick()
expect(wrapper.text()).toContain('No results')
})
it('should emit addFilter when a filter option is clicked', async () => {
setupNodesWithTypes()
const wrapper = await createWrapper()
await enterFilterMode(wrapper)
const imageOption = getFilterOptions(wrapper).find((o) =>
o.text().includes('IMAGE')
)
await imageOption!.trigger('click')
await nextTick()
expect(wrapper.emitted('addFilter')![0][0]).toMatchObject({
filterDef: expect.objectContaining({ id: 'input' }),
value: 'IMAGE'
})
})
it('should exit filter mode after applying a filter', async () => {
setupNodesWithTypes()
const wrapper = await createWrapper()
await enterFilterMode(wrapper)
await getFilterOptions(wrapper)[0].trigger('click')
await nextTick()
await nextTick()
expect(hasSidebar(wrapper)).toBe(true)
})
it('should emit addFilter when Enter is pressed on selected option', async () => {
setupNodesWithTypes()
const wrapper = await createWrapper()
await enterFilterMode(wrapper)
await wrapper
.find('input[type="text"]')
.trigger('keydown', { key: 'Enter' })
await nextTick()
expect(wrapper.emitted('addFilter')![0][0]).toMatchObject({
filterDef: expect.objectContaining({ id: 'input' }),
value: 'IMAGE'
})
})
it('should navigate filter options with ArrowDown/ArrowUp', async () => {
setupNodesWithTypes()
const wrapper = await createWrapper()
await enterFilterMode(wrapper)
const input = wrapper.find('input[type="text"]')
expect(getFilterOptions(wrapper)[0].attributes('aria-selected')).toBe(
'true'
)
await input.trigger('keydown', { key: 'ArrowDown' })
await nextTick()
expect(getFilterOptions(wrapper)[1].attributes('aria-selected')).toBe(
'true'
)
await input.trigger('keydown', { key: 'ArrowUp' })
await nextTick()
expect(getFilterOptions(wrapper)[0].attributes('aria-selected')).toBe(
'true'
)
})
it('should toggle filter mode off when same chip is clicked again', async () => {
setupNodesWithTypes()
const wrapper = await createWrapper()
await enterFilterMode(wrapper)
await findFilterBarButton(wrapper, 'Input')!.trigger('click')
await nextTick()
await nextTick()
expect(hasSidebar(wrapper)).toBe(true)
})
it('should reset filter query when re-entering filter mode', async () => {
setupNodesWithTypes()
const wrapper = await createWrapper()
await enterFilterMode(wrapper)
const input = wrapper.find('input[type="text"]')
await input.setValue('IMAGE')
await nextTick()
await findFilterBarButton(wrapper, 'Input')!.trigger('click')
await nextTick()
await nextTick()
await enterFilterMode(wrapper)
expect((input.element as HTMLInputElement).value).toBe('')
})
it('should exit filter mode when cancel button is clicked', async () => {
setupNodesWithTypes()
const wrapper = await createWrapper()
await enterFilterMode(wrapper)
expect(hasSidebar(wrapper)).toBe(false)
const cancelBtn = wrapper.find('[data-testid="cancel-filter"]')
await cancelBtn.trigger('click')
await nextTick()
await nextTick()
expect(hasSidebar(wrapper)).toBe(true)
})
})
})

View File

@@ -1,130 +1,107 @@
<template>
<FocusScope as-child loop>
<div
ref="dialogRef"
class="flex h-[min(80vh,750px)] w-full flex-col overflow-hidden rounded-lg border border-interface-stroke bg-base-background"
>
<!-- Search input row -->
<NodeSearchInput
ref="searchInputRef"
v-model:search-query="searchQuery"
:filters="filters"
@remove-filter="emit('removeFilter', $event)"
@navigate-down="navigateResults(1)"
@navigate-up="navigateResults(-1)"
@select-current="selectCurrentResult"
<div
ref="dialogRef"
class="flex max-h-[50vh] min-h-[400px] w-full flex-col overflow-hidden rounded-lg border border-interface-stroke bg-base-background"
>
<!-- Search input row -->
<NodeSearchInput
ref="searchInputRef"
v-model:search-query="searchQuery"
v-model:filter-query="filterQuery"
:filters="filters"
:active-filter="activeFilter"
@remove-filter="emit('removeFilter', $event)"
@cancel-filter="cancelFilter"
@navigate-down="onKeyDown"
@navigate-up="onKeyUp"
@select-current="onKeyEnter"
/>
<!-- Filter header row -->
<div class="flex items-center">
<NodeSearchFilterBar
class="flex-1"
:active-chip-key="activeFilter?.key"
@select-chip="onSelectFilterChip"
/>
</div>
<!-- Content area -->
<div class="flex min-h-0 flex-1 overflow-hidden">
<!-- Category sidebar (hidden in filter mode) -->
<NodeSearchCategorySidebar
v-if="!activeFilter"
v-model:selected-category="sidebarCategory"
class="w-52 shrink-0"
/>
<!-- Filter header row -->
<div class="flex items-center">
<NodeSearchFilterBar
class="flex-1"
:filters="filters"
:active-category="rootFilter"
:has-favorites="nodeBookmarkStore.bookmarks.length > 0"
:has-essential-nodes="nodeAvailability.essential"
:has-blueprint-nodes="nodeAvailability.blueprint"
:has-partner-nodes="nodeAvailability.partner"
:has-custom-nodes="nodeAvailability.custom"
@toggle-filter="onToggleFilter"
@clear-filter-group="onClearFilterGroup"
@focus-search="nextTick(() => searchInputRef?.focus())"
@select-category="onSelectCategory"
/>
</div>
<!-- Filter options list (filter selection mode) -->
<NodeSearchFilterPanel
v-if="activeFilter"
ref="filterPanelRef"
v-model:query="filterQuery"
:chip="activeFilter"
@apply="onFilterApply"
/>
<!-- Content area -->
<div class="flex min-h-0 flex-1 overflow-hidden">
<!-- Category sidebar -->
<NodeSearchCategorySidebar
v-model:selected-category="sidebarCategory"
class="w-52 shrink-0"
:hide-chevrons="!anyTreeCategoryHasChildren"
:hide-presets="rootFilter !== null"
:node-defs="rootFilteredNodeDefs"
:root-label="rootFilterLabel"
:root-key="rootFilter ?? undefined"
@auto-expand="selectedCategory = $event"
/>
<!-- Results list -->
<!-- Results list (normal mode) -->
<div
v-else
id="results-list"
role="listbox"
class="flex-1 overflow-y-auto py-2"
>
<div
id="results-list"
role="listbox"
tabindex="-1"
class="flex-1 overflow-y-auto py-2 pr-3 pl-1 select-none"
@pointermove="onPointerMove"
v-for="(node, index) in displayedResults"
:id="`result-item-${index}`"
:key="node.name"
role="option"
data-testid="result-item"
:aria-selected="index === selectedIndex"
:class="
cn(
'flex h-14 cursor-pointer items-center px-4',
index === selectedIndex && 'bg-secondary-background-hover'
)
"
@click="emit('addNode', node, $event)"
@mouseenter="selectedIndex = index"
>
<div
v-for="(node, index) in displayedResults"
:id="`result-item-${index}`"
:key="node.name"
role="option"
data-testid="result-item"
:tabindex="index === selectedIndex ? 0 : -1"
:aria-selected="index === selectedIndex"
:class="
cn(
'flex h-14 cursor-pointer items-center rounded-lg px-4 outline-none focus-visible:ring-2 focus-visible:ring-primary',
index === selectedIndex && 'bg-secondary-background'
)
"
@click="emit('addNode', node, $event)"
@keydown.down.prevent="navigateResults(1, true)"
@keydown.up.prevent="navigateResults(-1, true)"
@keydown.enter.prevent="selectCurrentResult"
>
<NodeSearchListItem
:node-def="node"
:current-query="searchQuery"
show-description
:show-source-badge="rootFilter !== 'essentials'"
:hide-bookmark-icon="effectiveCategory === 'favorites'"
/>
</div>
<div
v-if="displayedResults.length === 0"
data-testid="no-results"
class="px-4 py-8 text-center text-muted-foreground"
>
{{ $t('g.noResults') }}
</div>
<NodeSearchListItem
:node-def="node"
:current-query="searchQuery"
show-description
:show-source-badge="effectiveCategory !== 'essentials'"
:hide-bookmark-icon="effectiveCategory === 'favorites'"
/>
</div>
<div
v-if="displayedResults.length === 0"
class="px-4 py-8 text-center text-muted-foreground"
>
{{ $t('g.noResults') }}
</div>
</div>
</div>
</FocusScope>
</div>
</template>
<script setup lang="ts">
import { FocusScope } from 'reka-ui'
import { computed, nextTick, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import type { FilterChip } from '@/components/searchbox/v2/NodeSearchFilterBar.vue'
import NodeSearchFilterBar from '@/components/searchbox/v2/NodeSearchFilterBar.vue'
import NodeSearchCategorySidebar, {
DEFAULT_CATEGORY
} from '@/components/searchbox/v2/NodeSearchCategorySidebar.vue'
import NodeSearchCategorySidebar from '@/components/searchbox/v2/NodeSearchCategorySidebar.vue'
import NodeSearchFilterPanel from '@/components/searchbox/v2/NodeSearchFilterPanel.vue'
import NodeSearchInput from '@/components/searchbox/v2/NodeSearchInput.vue'
import NodeSearchListItem from '@/components/searchbox/v2/NodeSearchListItem.vue'
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useNodeDefStore, useNodeFrequencyStore } from '@/stores/nodeDefStore'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import {
BLUEPRINT_CATEGORY,
isCustomNode,
isEssentialNode
} from '@/types/nodeSource'
import type { FuseFilter, FuseFilterWithValue } from '@/utils/fuseUtil'
import { NodeSourceType } from '@/types/nodeSource'
import type { FuseFilterWithValue } from '@/utils/fuseUtil'
import { cn } from '@/utils/tailwindUtil'
const sourceCategoryFilters: Record<string, (n: ComfyNodeDefImpl) => boolean> =
{
essentials: isEssentialNode,
comfy: (n) => !isCustomNode(n),
custom: isCustomNode
}
const { filters } = defineProps<{
filters: FuseFilterWithValue<ComfyNodeDefImpl, string>[]
}>()
@@ -136,102 +113,57 @@ const emit = defineEmits<{
hoverNode: [nodeDef: ComfyNodeDefImpl | null]
}>()
const { t } = useI18n()
const { flags } = useFeatureFlags()
const nodeDefStore = useNodeDefStore()
const nodeFrequencyStore = useNodeFrequencyStore()
const nodeBookmarkStore = useNodeBookmarkStore()
const nodeAvailability = computed(() => {
let essential = false
let blueprint = false
let partner = false
let custom = false
for (const n of nodeDefStore.visibleNodeDefs) {
if (!essential && flags.nodeLibraryEssentialsEnabled && isEssentialNode(n))
essential = true
if (!blueprint && n.category.startsWith(BLUEPRINT_CATEGORY))
blueprint = true
if (!partner && n.api_node) partner = true
if (!custom && isCustomNode(n)) custom = true
if (essential && blueprint && partner && custom) break
}
return { essential, blueprint, partner, custom }
})
const dialogRef = ref<HTMLElement>()
const searchInputRef = ref<InstanceType<typeof NodeSearchInput>>()
const filterPanelRef = ref<InstanceType<typeof NodeSearchFilterPanel>>()
const searchQuery = ref('')
const selectedCategory = ref(DEFAULT_CATEGORY)
const selectedCategory = ref('most-relevant')
const selectedIndex = ref(0)
// Root filter from filter bar category buttons (radio toggle)
const rootFilter = ref<string | null>(null)
const activeFilter = ref<FilterChip | null>(null)
const filterQuery = ref('')
const rootFilterLabel = computed(() => {
switch (rootFilter.value) {
case 'favorites':
return t('g.bookmarked')
case BLUEPRINT_CATEGORY:
return t('g.blueprints')
case 'partner-nodes':
return t('g.partner')
case 'essentials':
return t('g.essentials')
case 'comfy':
return t('g.comfy')
case 'custom':
return t('g.extensions')
default:
return undefined
}
})
const rootFilteredNodeDefs = computed(() => {
if (!rootFilter.value) return nodeDefStore.visibleNodeDefs
const allNodes = nodeDefStore.visibleNodeDefs
const sourceFilter = sourceCategoryFilters[rootFilter.value]
if (sourceFilter) return allNodes.filter(sourceFilter)
switch (rootFilter.value) {
case 'favorites':
return allNodes.filter((n) => nodeBookmarkStore.isBookmarked(n))
case BLUEPRINT_CATEGORY:
return allNodes.filter((n) => n.category.startsWith(rootFilter.value!))
case 'partner-nodes':
return allNodes.filter((n) => n.api_node)
default:
return allNodes
}
})
function onToggleFilter(
filterDef: FuseFilter<ComfyNodeDefImpl, string>,
value: string
) {
const existing = filters.find(
(f) => f.filterDef.id === filterDef.id && f.value === value
)
if (existing) {
emit('removeFilter', existing)
} else {
emit('addFilter', { filterDef, value })
function lockDialogHeight() {
if (dialogRef.value) {
dialogRef.value.style.height = `${dialogRef.value.offsetHeight}px`
}
}
function onClearFilterGroup(filterId: string) {
for (const f of filters.filter((f) => f.filterDef.id === filterId)) {
emit('removeFilter', f)
function unlockDialogHeight() {
if (dialogRef.value) {
dialogRef.value.style.height = ''
}
}
function onSelectCategory(category: string) {
if (rootFilter.value === category) {
rootFilter.value = null
} else {
rootFilter.value = category
function onSelectFilterChip(chip: FilterChip) {
if (activeFilter.value?.key === chip.key) {
cancelFilter()
return
}
selectedCategory.value = DEFAULT_CATEGORY
lockDialogHeight()
activeFilter.value = chip
filterQuery.value = ''
nextTick(() => searchInputRef.value?.focus())
}
function onFilterApply(value: string) {
if (!activeFilter.value) return
emit('addFilter', { filterDef: activeFilter.value.filter, value })
activeFilter.value = null
filterQuery.value = ''
unlockDialogHeight()
nextTick(() => searchInputRef.value?.focus())
}
function cancelFilter() {
activeFilter.value = null
filterQuery.value = ''
unlockDialogHeight()
nextTick(() => searchInputRef.value?.focus())
}
@@ -244,70 +176,67 @@ const searchResults = computed(() => {
})
})
const effectiveCategory = computed(() => selectedCategory.value)
const effectiveCategory = computed(() =>
searchQuery.value ? 'most-relevant' : selectedCategory.value
)
const sidebarCategory = computed({
get: () => effectiveCategory.value,
set: (category: string) => {
selectedCategory.value = category
searchQuery.value = ''
}
})
// Check if any tree category has children (for chevron visibility)
const anyTreeCategoryHasChildren = computed(() =>
rootFilteredNodeDefs.value.some((n) => n.category.includes('/'))
)
function getMostRelevantResults(baseNodes: ComfyNodeDefImpl[]) {
if (searchQuery.value || filters.length > 0) {
const searched = searchResults.value
if (!rootFilter.value) return searched
const rootSet = new Set(baseNodes.map((n) => n.name))
return searched.filter((n) => rootSet.has(n.name))
}
return rootFilter.value ? baseNodes : nodeFrequencyStore.topNodeDefs
}
function getCategoryResults(baseNodes: ComfyNodeDefImpl[], category: string) {
if (rootFilter.value && category === rootFilter.value) return baseNodes
const rootPrefix = rootFilter.value ? rootFilter.value + '/' : ''
const categoryPath = category.startsWith(rootPrefix)
? category.slice(rootPrefix.length)
: category
return baseNodes.filter((n) => {
const nodeCategory = n.category.startsWith(rootPrefix)
? n.category.slice(rootPrefix.length)
: n.category
return (
nodeCategory === categoryPath ||
nodeCategory.startsWith(categoryPath + '/')
)
})
function matchesFilters(node: ComfyNodeDefImpl): boolean {
return filters.every(({ filterDef, value }) => filterDef.matches(node, value))
}
const displayedResults = computed<ComfyNodeDefImpl[]>(() => {
const baseNodes = rootFilteredNodeDefs.value
const category = effectiveCategory.value
const allNodes = nodeDefStore.visibleNodeDefs
if (category === DEFAULT_CATEGORY) return getMostRelevantResults(baseNodes)
const hasSearch = searchQuery.value || filters.length > 0
let source: ComfyNodeDefImpl[]
if (hasSearch) {
const searched = searchResults.value
if (rootFilter.value) {
const rootSet = new Set(baseNodes.map((n) => n.name))
source = searched.filter((n) => rootSet.has(n.name))
} else {
source = searched
}
} else {
source = baseNodes
let results: ComfyNodeDefImpl[]
switch (effectiveCategory.value) {
case 'most-relevant':
return searchResults.value
case 'favorites':
results = allNodes.filter((n) => nodeBookmarkStore.isBookmarked(n))
break
case 'essentials':
results = allNodes.filter(
(n) => n.nodeSource.type === NodeSourceType.Essentials
)
break
case 'recents':
return searchResults.value
case 'blueprints':
results = allNodes.filter(
(n) => n.nodeSource.type === NodeSourceType.Blueprint
)
break
case 'partner':
results = allNodes.filter((n) => n.api_node)
break
case 'comfy':
results = allNodes.filter(
(n) => n.nodeSource.type === NodeSourceType.Core
)
break
case 'extensions':
results = allNodes.filter(
(n) => n.nodeSource.type === NodeSourceType.CustomNodes
)
break
default:
results = allNodes.filter(
(n) =>
n.category === effectiveCategory.value ||
n.category.startsWith(effectiveCategory.value + '/')
)
break
}
const sourceFilter = sourceCategoryFilters[category]
if (sourceFilter) return source.filter(sourceFilter)
return getCategoryResults(source, category)
return filters.length > 0 ? results.filter(matchesFilters) : results
})
const hoveredNodeDef = computed(
@@ -322,28 +251,42 @@ watch(
{ immediate: true }
)
watch([selectedCategory, searchQuery, rootFilter, () => filters.length], () => {
watch([selectedCategory, searchQuery, () => filters], () => {
selectedIndex.value = 0
})
function onPointerMove(event: PointerEvent) {
const item = (event.target as HTMLElement).closest('[role=option]')
if (!item) return
const index = Number(item.id.replace('result-item-', ''))
if (!isNaN(index) && index !== selectedIndex.value)
selectedIndex.value = index
function onKeyDown() {
if (activeFilter.value) {
filterPanelRef.value?.navigate(1)
} else {
navigateResults(1)
}
}
function navigateResults(direction: number, focusItem = false) {
function onKeyUp() {
if (activeFilter.value) {
filterPanelRef.value?.navigate(-1)
} else {
navigateResults(-1)
}
}
function onKeyEnter() {
if (activeFilter.value) {
filterPanelRef.value?.selectCurrent()
} else {
selectCurrentResult()
}
}
function navigateResults(direction: number) {
const newIndex = selectedIndex.value + direction
if (newIndex >= 0 && newIndex < displayedResults.value.length) {
selectedIndex.value = newIndex
nextTick(() => {
const el = dialogRef.value?.querySelector(
`#result-item-${newIndex}`
) as HTMLElement | null
el?.scrollIntoView({ block: 'nearest' })
if (focusItem) el?.focus()
dialogRef.value
?.querySelector(`#result-item-${newIndex}`)
?.scrollIntoView({ block: 'nearest' })
})
}
}

View File

@@ -12,11 +12,7 @@ import { useNodeDefStore } from '@/stores/nodeDefStore'
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: vi.fn(() => ({
get: vi.fn((key: string) => {
if (key === 'Comfy.NodeLibrary.Bookmarks.V2') return []
if (key === 'Comfy.NodeLibrary.BookmarksCustomization') return {}
return undefined
}),
get: vi.fn(() => undefined),
set: vi.fn()
}))
}))
@@ -37,79 +33,51 @@ describe(NodeSearchFilterBar, () => {
async function createWrapper(props = {}) {
const wrapper = mount(NodeSearchFilterBar, {
props,
global: {
plugins: [testI18n],
stubs: {
NodeSearchTypeFilterPopover: {
template: '<div data-testid="popover"><slot /></div>',
props: ['chip', 'selectedValues']
}
}
}
global: { plugins: [testI18n] }
})
await nextTick()
return wrapper
}
it('should render Extensions button and Input/Output popover triggers', async () => {
const wrapper = await createWrapper({ hasCustomNodes: true })
const buttons = wrapper.findAll('button')
const texts = buttons.map((b) => b.text())
expect(texts).toContain('Extensions')
expect(texts).toContain('Input')
expect(texts).toContain('Output')
})
it('should always render Comfy button', async () => {
const wrapper = await createWrapper()
const texts = wrapper.findAll('button').map((b) => b.text())
expect(texts).toContain('Comfy')
})
it('should render conditional category buttons when matching nodes exist', async () => {
const wrapper = await createWrapper({
hasFavorites: true,
hasEssentialNodes: true,
hasBlueprintNodes: true,
hasPartnerNodes: true
})
const texts = wrapper.findAll('button').map((b) => b.text())
expect(texts).toContain('Bookmarked')
expect(texts).toContain('Blueprints')
expect(texts).toContain('Partner')
expect(texts).toContain('Essentials')
})
it('should not render Extensions button when no custom nodes exist', async () => {
it('should render all filter chips', async () => {
const wrapper = await createWrapper()
const buttons = wrapper.findAll('button')
const texts = buttons.map((b) => b.text())
expect(texts).not.toContain('Extensions')
expect(buttons).toHaveLength(6)
expect(buttons[0].text()).toBe('Blueprints')
expect(buttons[1].text()).toBe('Partner Nodes')
expect(buttons[2].text()).toBe('Essentials')
expect(buttons[3].text()).toBe('Extensions')
expect(buttons[4].text()).toBe('Input')
expect(buttons[5].text()).toBe('Output')
})
it('should emit selectCategory when category button is clicked', async () => {
const wrapper = await createWrapper({ hasCustomNodes: true })
it('should mark active chip as pressed when activeChipKey matches', async () => {
const wrapper = await createWrapper({ activeChipKey: 'input' })
const extensionsBtn = wrapper
.findAll('button')
.find((b) => b.text() === 'Extensions')!
await extensionsBtn.trigger('click')
expect(wrapper.emitted('selectCategory')![0]).toEqual(['custom'])
const inputBtn = wrapper.findAll('button').find((b) => b.text() === 'Input')
expect(inputBtn?.attributes('aria-pressed')).toBe('true')
})
it('should apply active styling when activeCategory matches', async () => {
const wrapper = await createWrapper({
activeCategory: 'custom',
hasCustomNodes: true
it('should not mark chips as pressed when activeChipKey does not match', async () => {
const wrapper = await createWrapper({ activeChipKey: null })
wrapper.findAll('button').forEach((btn) => {
expect(btn.attributes('aria-pressed')).toBe('false')
})
})
const extensionsBtn = wrapper
.findAll('button')
.find((b) => b.text() === 'Extensions')!
it('should emit selectChip with chip data when clicked', async () => {
const wrapper = await createWrapper()
expect(extensionsBtn.attributes('aria-pressed')).toBe('true')
const inputBtn = wrapper.findAll('button').find((b) => b.text() === 'Input')
await inputBtn?.trigger('click')
const emitted = wrapper.emitted('selectChip')!
expect(emitted[0][0]).toMatchObject({
key: 'input',
label: 'Input',
filter: expect.anything()
})
})
})

View File

@@ -1,43 +1,22 @@
<template>
<div class="flex items-center gap-2.5 px-3">
<!-- Category filter buttons -->
<div class="flex items-center gap-2 px-2 py-1.5">
<button
v-for="btn in categoryButtons"
:key="btn.id"
v-for="chip in chips"
:key="chip.key"
type="button"
:aria-pressed="activeCategory === btn.id"
:class="chipClass(activeCategory === btn.id)"
@click="emit('selectCategory', btn.id)"
:aria-pressed="activeChipKey === chip.key"
:class="
cn(
'flex-auto cursor-pointer rounded-md border border-secondary-background px-3 py-1 text-sm transition-colors',
activeChipKey === chip.key
? 'text-foreground bg-secondary-background'
: 'bg-transparent text-muted-foreground hover:border-base-foreground/60 hover:text-base-foreground/60'
)
"
@click="emit('selectChip', chip)"
>
{{ btn.label }}
{{ chip.label }}
</button>
<div class="h-5 w-px shrink-0 bg-border-subtle" />
<!-- Type filter popovers (Input / Output) -->
<NodeSearchTypeFilterPopover
v-for="tf in typeFilters"
:key="tf.chip.key"
:chip="tf.chip"
:selected-values="tf.values"
@toggle="(v) => emit('toggleFilter', tf.chip.filter, v)"
@clear="emit('clearFilterGroup', tf.chip.filter.id)"
@escape-close="emit('focusSearch')"
>
<button type="button" :class="chipClass(false, tf.values.length > 0)">
<span v-if="tf.values.length > 0" class="flex items-center">
<span
v-for="val in tf.values.slice(0, MAX_VISIBLE_DOTS)"
:key="val"
class="-mx-[2px] text-lg leading-none"
:style="{ color: getLinkTypeColor(val) }"
>&bull;</span
>
</span>
{{ tf.chip.label }}
<i class="icon-[lucide--chevron-down] size-3.5" />
</button>
</NodeSearchTypeFilterPopover>
</div>
</template>
@@ -56,97 +35,53 @@ export interface FilterChip {
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import NodeSearchTypeFilterPopover from '@/components/searchbox/v2/NodeSearchTypeFilterPopover.vue'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { BLUEPRINT_CATEGORY } from '@/types/nodeSource'
import type { FuseFilterWithValue } from '@/utils/fuseUtil'
import { getLinkTypeColor } from '@/utils/litegraphUtil'
import { cn } from '@/utils/tailwindUtil'
const {
filters = [],
activeCategory = null,
hasFavorites = false,
hasEssentialNodes = false,
hasBlueprintNodes = false,
hasPartnerNodes = false,
hasCustomNodes = false
} = defineProps<{
filters?: FuseFilterWithValue<ComfyNodeDefImpl, string>[]
activeCategory?: string | null
hasFavorites?: boolean
hasEssentialNodes?: boolean
hasBlueprintNodes?: boolean
hasPartnerNodes?: boolean
hasCustomNodes?: boolean
const { activeChipKey = null } = defineProps<{
activeChipKey?: string | null
}>()
const emit = defineEmits<{
toggleFilter: [filterDef: FuseFilter<ComfyNodeDefImpl, string>, value: string]
clearFilterGroup: [filterId: string]
focusSearch: []
selectCategory: [category: string]
selectChip: [chip: FilterChip]
}>()
const { t } = useI18n()
const nodeDefStore = useNodeDefStore()
const MAX_VISIBLE_DOTS = 4
const categoryButtons = computed(() => {
const buttons: { id: string; label: string }[] = []
if (hasFavorites) {
buttons.push({ id: 'favorites', label: t('g.bookmarked') })
}
if (hasBlueprintNodes) {
buttons.push({ id: BLUEPRINT_CATEGORY, label: t('g.blueprints') })
}
if (hasPartnerNodes) {
buttons.push({ id: 'partner-nodes', label: t('g.partner') })
}
if (hasEssentialNodes) {
buttons.push({ id: 'essentials', label: t('g.essentials') })
}
buttons.push({ id: 'comfy', label: t('g.comfy') })
if (hasCustomNodes) {
buttons.push({ id: 'custom', label: t('g.extensions') })
}
return buttons
const chips = computed<FilterChip[]>(() => {
const searchService = nodeDefStore.nodeSearchService
return [
{
key: 'blueprints',
label: t('sideToolbar.nodeLibraryTab.filterOptions.blueprints'),
filter: searchService.nodeSourceFilter
},
{
key: 'partnerNodes',
label: t('sideToolbar.nodeLibraryTab.filterOptions.partnerNodes'),
filter: searchService.nodeSourceFilter
},
{
key: 'essentials',
label: t('g.essentials'),
filter: searchService.nodeSourceFilter
},
{
key: 'extensions',
label: t('g.extensions'),
filter: searchService.nodeSourceFilter
},
{
key: 'input',
label: t('g.input'),
filter: searchService.inputTypeFilter
},
{
key: 'output',
label: t('g.output'),
filter: searchService.outputTypeFilter
}
]
})
const inputChip = computed<FilterChip>(() => ({
key: 'input',
label: t('g.input'),
filter: nodeDefStore.nodeSearchService.inputTypeFilter
}))
const outputChip = computed<FilterChip>(() => ({
key: 'output',
label: t('g.output'),
filter: nodeDefStore.nodeSearchService.outputTypeFilter
}))
const selectedInputValues = computed(() =>
filters.filter((f) => f.filterDef.id === 'input').map((f) => f.value)
)
const selectedOutputValues = computed(() =>
filters.filter((f) => f.filterDef.id === 'output').map((f) => f.value)
)
const typeFilters = computed(() => [
{ chip: inputChip.value, values: selectedInputValues.value },
{ chip: outputChip.value, values: selectedOutputValues.value }
])
function chipClass(isActive: boolean, hasSelections = false) {
return cn(
'flex cursor-pointer items-center justify-center gap-1 rounded-md border border-secondary-background px-3 py-1 font-inter text-sm transition-colors',
isActive
? 'border-base-foreground bg-base-foreground text-base-background'
: hasSelections
? 'border-base-foreground/60 bg-transparent text-base-foreground/60 hover:border-base-foreground/60 hover:text-base-foreground/60'
: 'bg-transparent text-muted-foreground hover:border-base-foreground/60 hover:text-base-foreground/60'
)
}
</script>

View File

@@ -0,0 +1,90 @@
<template>
<div
id="filter-options-list"
ref="listRef"
role="listbox"
class="flex-1 overflow-y-auto py-2"
>
<div
v-for="(option, index) in options"
:id="`filter-option-${index}`"
:key="option"
role="option"
data-testid="filter-option"
:aria-selected="index === selectedIndex"
:class="
cn(
'cursor-pointer px-6 py-1.5',
index === selectedIndex && 'bg-secondary-background-hover'
)
"
@click="emit('apply', option)"
@mouseenter="selectedIndex = index"
>
<span class="text-foreground text-base font-semibold">
<span class="mr-1 text-2xl" :style="{ color: getLinkTypeColor(option) }"
>&bull;</span
>
{{ option }}
</span>
</div>
<div
v-if="options.length === 0"
class="px-4 py-8 text-center text-muted-foreground"
>
{{ $t('g.noResults') }}
</div>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import type { FilterChip } from '@/components/searchbox/v2/NodeSearchFilterBar.vue'
import { getLinkTypeColor } from '@/utils/litegraphUtil'
import { cn } from '@/utils/tailwindUtil'
const { chip } = defineProps<{
chip: FilterChip
}>()
const query = defineModel<string>('query', { required: true })
const emit = defineEmits<{
apply: [value: string]
}>()
const listRef = ref<HTMLElement>()
const selectedIndex = ref(0)
const options = computed(() => {
const { fuseSearch } = chip.filter
if (query.value) {
return fuseSearch.search(query.value).slice(0, 64)
}
return fuseSearch.data.slice().sort()
})
watch(query, () => {
selectedIndex.value = 0
})
function navigate(direction: number) {
const newIndex = selectedIndex.value + direction
if (newIndex >= 0 && newIndex < options.value.length) {
selectedIndex.value = newIndex
nextTick(() => {
listRef.value
?.querySelector(`#filter-option-${newIndex}`)
?.scrollIntoView({ block: 'nearest' })
})
}
}
function selectCurrent() {
const option = options.value[selectedIndex.value]
if (option) emit('apply', option)
}
defineExpose({ navigate, selectCurrent })
</script>

View File

@@ -1,6 +1,7 @@
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { FilterChip } from '@/components/searchbox/v2/NodeSearchFilterBar.vue'
import NodeSearchInput from '@/components/searchbox/v2/NodeSearchInput.vue'
import {
setupTestPinia,
@@ -17,11 +18,7 @@ vi.mock('@/utils/litegraphUtil', () => ({
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: vi.fn(() => ({
get: vi.fn((key: string) => {
if (key === 'Comfy.NodeLibrary.Bookmarks.V2') return []
if (key === 'Comfy.NodeLibrary.BookmarksCustomization') return {}
return undefined
}),
get: vi.fn(),
set: vi.fn()
}))
}))
@@ -42,6 +39,20 @@ function createFilter(
}
}
function createActiveFilter(label: string): FilterChip {
return {
key: label.toLowerCase(),
label,
filter: {
id: label.toLowerCase(),
matches: vi.fn(() => true)
} as Partial<FuseFilter<ComfyNodeDefImpl, string>> as FuseFilter<
ComfyNodeDefImpl,
string
>
}
}
describe('NodeSearchInput', () => {
beforeEach(() => {
setupTestPinia()
@@ -51,27 +62,51 @@ describe('NodeSearchInput', () => {
function createWrapper(
props: Partial<{
filters: FuseFilterWithValue<ComfyNodeDefImpl, string>[]
activeFilter: FilterChip | null
searchQuery: string
filterQuery: string
}> = {}
) {
return mount(NodeSearchInput, {
props: {
filters: [],
activeFilter: null,
searchQuery: '',
filterQuery: '',
...props
},
global: { plugins: [testI18n] }
})
}
it('should route input to searchQuery', async () => {
it('should route input to searchQuery when no active filter', async () => {
const wrapper = createWrapper()
await wrapper.find('input').setValue('test search')
expect(wrapper.emitted('update:searchQuery')![0]).toEqual(['test search'])
})
it('should show add node placeholder', () => {
it('should route input to filterQuery when active filter is set', async () => {
const wrapper = createWrapper({
activeFilter: createActiveFilter('Input')
})
await wrapper.find('input').setValue('IMAGE')
expect(wrapper.emitted('update:filterQuery')![0]).toEqual(['IMAGE'])
expect(wrapper.emitted('update:searchQuery')).toBeUndefined()
})
it('should show filter label placeholder when active filter is set', () => {
const wrapper = createWrapper({
activeFilter: createActiveFilter('Input')
})
expect(
(wrapper.find('input').element as HTMLInputElement).placeholder
).toContain('input')
})
it('should show add node placeholder when no active filter', () => {
const wrapper = createWrapper()
expect(
@@ -79,7 +114,16 @@ describe('NodeSearchInput', () => {
).toContain('Add a node')
})
it('should show filter chips when filters are present', () => {
it('should hide filter chips when active filter is set', () => {
const wrapper = createWrapper({
filters: [createFilter('input', 'IMAGE')],
activeFilter: createActiveFilter('Input')
})
expect(wrapper.findAll('[data-testid="filter-chip"]')).toHaveLength(0)
})
it('should show filter chips when no active filter', () => {
const wrapper = createWrapper({
filters: [createFilter('input', 'IMAGE')]
})
@@ -87,6 +131,16 @@ describe('NodeSearchInput', () => {
expect(wrapper.findAll('[data-testid="filter-chip"]')).toHaveLength(1)
})
it('should emit cancelFilter when cancel button is clicked', async () => {
const wrapper = createWrapper({
activeFilter: createActiveFilter('Input')
})
await wrapper.find('[data-testid="cancel-filter"]').trigger('click')
expect(wrapper.emitted('cancelFilter')).toHaveLength(1)
})
it('should emit selectCurrent on Enter', async () => {
const wrapper = createWrapper()

View File

@@ -7,41 +7,61 @@
@remove-tag="onRemoveTag"
@click="inputRef?.focus()"
>
<!-- Applied filter chips -->
<TagsInputItem
v-for="filter in filters"
:key="filterKey(filter)"
:value="filterKey(filter)"
data-testid="filter-chip"
class="-my-1 inline-flex items-center gap-1 rounded-lg bg-base-background px-2 py-1 data-[state=active]:ring-2 data-[state=active]:ring-primary"
<!-- Active filter label (filter selection mode) -->
<span
v-if="activeFilter"
class="text-foreground -my-1 inline-flex shrink-0 items-center gap-1 rounded-lg bg-base-background px-2 py-1 text-sm opacity-80"
>
<span class="text-sm opacity-80">
{{ t(`g.${filter.filterDef.id}`) }}:
</span>
<span :style="{ color: getLinkTypeColor(filter.value) }"> &bull; </span>
<span class="text-sm">{{ filter.value }}</span>
<TagsInputItemDelete
as="button"
{{ activeFilter.label }}:
<button
type="button"
data-testid="chip-delete"
data-testid="cancel-filter"
class="aspect-square cursor-pointer rounded-full border-none bg-transparent text-muted-foreground hover:text-base-foreground"
:aria-label="$t('g.remove')"
class="ml-1 flex aspect-square cursor-pointer items-center justify-center rounded-full border-none bg-transparent text-muted-foreground hover:text-base-foreground"
@click="emit('cancelFilter')"
>
<i class="icon-[lucide--x] size-3" />
</TagsInputItemDelete>
</TagsInputItem>
<i class="pi pi-times text-xs" />
</button>
</span>
<!-- Applied filter chips -->
<template v-if="!activeFilter">
<TagsInputItem
v-for="filter in filters"
:key="filterKey(filter)"
:value="filterKey(filter)"
data-testid="filter-chip"
class="-my-1 inline-flex items-center gap-1 rounded-lg bg-base-background px-2 py-1 data-[state=active]:ring-2 data-[state=active]:ring-primary"
>
<span class="text-sm opacity-80">
{{ t(`g.${filter.filterDef.id}`) }}:
</span>
<span :style="{ color: getLinkTypeColor(filter.value) }">
&bull;
</span>
<span class="text-sm">{{ filter.value }}</span>
<TagsInputItemDelete
as="button"
type="button"
data-testid="chip-delete"
:aria-label="$t('g.remove')"
class="ml-1 aspect-square cursor-pointer rounded-full border-none bg-transparent text-muted-foreground hover:text-base-foreground"
>
<i class="pi pi-times text-xs" />
</TagsInputItemDelete>
</TagsInputItem>
</template>
<TagsInputInput as-child>
<input
ref="inputRef"
v-model="searchQuery"
v-model="inputValue"
type="text"
role="combobox"
aria-autocomplete="list"
:aria-expanded="true"
aria-controls="results-list"
:aria-label="t('g.addNode')"
:placeholder="t('g.addNode')"
class="text-foreground h-6 min-w-[min(300px,80vw)] flex-1 border-none bg-transparent font-inter text-sm outline-none placeholder:text-muted-foreground"
:aria-controls="activeFilter ? 'filter-options-list' : 'results-list'"
:aria-label="inputPlaceholder"
:placeholder="inputPlaceholder"
class="text-foreground h-6 min-w-[min(300px,80vw)] flex-1 border-none bg-transparent text-sm outline-none placeholder:text-muted-foreground"
@keydown.enter.prevent="emit('selectCurrent')"
@keydown.down.prevent="emit('navigateDown')"
@keydown.up.prevent="emit('navigateUp')"
@@ -61,18 +81,22 @@ import {
TagsInputRoot
} from 'reka-ui'
import type { FilterChip } from '@/components/searchbox/v2/NodeSearchFilterBar.vue'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import type { FuseFilterWithValue } from '@/utils/fuseUtil'
import { getLinkTypeColor } from '@/utils/litegraphUtil'
const { filters } = defineProps<{
const { filters, activeFilter } = defineProps<{
filters: FuseFilterWithValue<ComfyNodeDefImpl, string>[]
activeFilter: FilterChip | null
}>()
const searchQuery = defineModel<string>('searchQuery', { required: true })
const filterQuery = defineModel<string>('filterQuery', { required: true })
const emit = defineEmits<{
removeFilter: [filter: FuseFilterWithValue<ComfyNodeDefImpl, string>]
cancelFilter: []
navigateDown: []
navigateUp: []
selectCurrent: []
@@ -81,6 +105,23 @@ const emit = defineEmits<{
const { t } = useI18n()
const inputRef = ref<HTMLInputElement>()
const inputValue = computed({
get: () => (activeFilter ? filterQuery.value : searchQuery.value),
set: (value: string) => {
if (activeFilter) {
filterQuery.value = value
} else {
searchQuery.value = value
}
}
})
const inputPlaceholder = computed(() =>
activeFilter
? t('g.filterByType', { type: activeFilter.label.toLowerCase() })
: t('g.addNode')
)
const tagValues = computed(() => filters.map(filterKey))
function filterKey(filter: FuseFilterWithValue<ComfyNodeDefImpl, string>) {

View File

@@ -2,78 +2,46 @@
<div
class="option-container flex w-full cursor-pointer items-center justify-between overflow-hidden"
>
<div class="flex min-w-0 flex-1 flex-col gap-1 overflow-hidden">
<!-- Row 1: Name (left) + badges (right) -->
<div class="text-foreground flex items-center gap-2 text-sm">
<div class="flex flex-col gap-0.5 overflow-hidden">
<div class="text-foreground flex items-center gap-2 font-semibold">
<span v-if="isBookmarked && !hideBookmarkIcon">
<i class="pi pi-bookmark-fill mr-1 text-sm" />
</span>
<span
class="truncate"
v-html="highlightQuery(nodeDef.display_name, currentQuery)"
/>
<span v-html="highlightQuery(nodeDef.display_name, currentQuery)" />
<span v-if="showIdName">&nbsp;</span>
<span
v-if="showIdName"
data-testid="node-id-badge"
class="shrink-0 rounded-sm bg-secondary-background px-1.5 py-0.5 text-xs text-muted-foreground"
class="rounded-sm bg-secondary-background px-1.5 py-0.5 text-xs text-muted-foreground"
v-html="highlightQuery(nodeDef.name, currentQuery)"
/>
<template v-if="showDescription">
<div class="flex-1" />
<div class="flex shrink-0 items-center gap-1">
<span
v-if="showSourceBadge && !isCustom"
aria-hidden="true"
class="flex size-[18px] shrink-0 items-center justify-center rounded-full bg-secondary-background-hover/80"
>
<ComfyLogo :size="10" mode="fill" color="currentColor" />
</span>
<span
v-else-if="showSourceBadge && isCustom"
:class="badgePillClass"
>
<span class="truncate text-[10px]">
{{ nodeDef.nodeSource.displayText }}
</span>
</span>
<span
v-if="nodeDef.api_node && providerName"
:class="badgePillClass"
>
<i
aria-hidden="true"
class="icon-[lucide--component] size-3 text-amber-400"
/>
<i
aria-hidden="true"
:class="cn(getProviderIcon(providerName), 'size-3')"
/>
</span>
</div>
</template>
<template v-else>
<NodePricingBadge :node-def="nodeDef" />
<NodeProviderBadge v-if="nodeDef.api_node" :node-def="nodeDef" />
</template>
<NodePricingBadge :node-def="nodeDef" />
<NodeProviderBadge v-if="nodeDef.api_node" :node-def="nodeDef" />
</div>
<div
v-if="showDescription"
class="flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground"
class="flex items-center gap-1 text-[11px] text-muted-foreground"
>
<span v-if="showCategory" class="max-w-2/5 shrink-0 truncate">
{{ nodeDef.category.replaceAll('/', ' / ') }}
</span>
<span
v-if="nodeDef.description && showCategory"
class="h-3 w-px shrink-0 bg-border-default"
/>
<TextTicker v-if="nodeDef.description" class="min-w-0 flex-1">
v-if="
showSourceBadge &&
nodeDef.nodeSource.type !== NodeSourceType.Core &&
nodeDef.nodeSource.type !== NodeSourceType.Unknown
"
class="border-border mr-0.5 inline-flex shrink-0 rounded-sm border bg-base-foreground/5 px-1.5 py-0.5 text-xs text-base-foreground/70"
>
{{ nodeDef.nodeSource.displayText }}
</span>
<TextTicker v-if="nodeDef.description">
{{ nodeDef.description }}
</TextTicker>
</div>
<div
v-else-if="showCategory"
class="option-category truncate text-sm font-light text-muted"
>
{{ nodeDef.category.replaceAll('/', ' > ') }}
</div>
</div>
<div v-if="!showDescription" class="flex items-center gap-1">
<span
@@ -114,20 +82,14 @@
import { computed } from 'vue'
import TextTicker from '@/components/common/TextTicker.vue'
import ComfyLogo from '@/components/icons/ComfyLogo.vue'
import NodePricingBadge from '@/components/node/NodePricingBadge.vue'
import NodeProviderBadge from '@/components/node/NodeProviderBadge.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useNodeFrequencyStore } from '@/stores/nodeDefStore'
import {
isCustomNode as isCustomNodeDef,
NodeSourceType
} from '@/types/nodeSource'
import { getProviderIcon, getProviderName } from '@/utils/categoryUtil'
import { NodeSourceType } from '@/types/nodeSource'
import { formatNumberWithSuffix, highlightQuery } from '@/utils/formatUtil'
import { cn } from '@/utils/tailwindUtil'
const {
nodeDef,
@@ -143,9 +105,6 @@ const {
hideBookmarkIcon?: boolean
}>()
const badgePillClass =
'flex h-[18px] max-w-28 shrink-0 items-center justify-center gap-1 rounded-full bg-secondary-background-hover/80 px-2'
const settingStore = useSettingStore()
const showCategory = computed(() =>
settingStore.get('Comfy.NodeSearchBoxImpl.ShowCategory')
@@ -163,6 +122,4 @@ const nodeFrequency = computed(() =>
const nodeBookmarkStore = useNodeBookmarkStore()
const isBookmarked = computed(() => nodeBookmarkStore.isBookmarked(nodeDef))
const providerName = computed(() => getProviderName(nodeDef.category))
const isCustom = computed(() => isCustomNodeDef(nodeDef))
</script>

View File

@@ -1,168 +0,0 @@
import { mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import NodeSearchTypeFilterPopover from '@/components/searchbox/v2/NodeSearchTypeFilterPopover.vue'
import type { FilterChip } from '@/components/searchbox/v2/NodeSearchFilterBar.vue'
import { testI18n } from '@/components/searchbox/v2/__test__/testUtils'
function createMockChip(
data: string[] = ['IMAGE', 'LATENT', 'MODEL']
): FilterChip {
return {
key: 'input',
label: 'Input',
filter: {
id: 'input',
matches: vi.fn(),
fuseSearch: {
search: vi.fn((query: string) =>
data.filter((d) => d.toLowerCase().includes(query.toLowerCase()))
),
data
}
} as unknown as FilterChip['filter']
}
}
describe(NodeSearchTypeFilterPopover, () => {
let wrapper: ReturnType<typeof mount>
beforeEach(() => {
vi.restoreAllMocks()
})
afterEach(() => {
wrapper?.unmount()
})
function createWrapper(
props: {
chip?: FilterChip
selectedValues?: string[]
} = {}
) {
wrapper = mount(NodeSearchTypeFilterPopover, {
props: {
chip: props.chip ?? createMockChip(),
selectedValues: props.selectedValues ?? []
},
slots: {
default: '<button data-testid="trigger">Input</button>'
},
global: {
plugins: [testI18n]
},
attachTo: document.body
})
return wrapper
}
async function openPopover(w: ReturnType<typeof mount>) {
await w.find('[data-testid="trigger"]').trigger('click')
await nextTick()
await nextTick()
}
function getOptions() {
return wrapper.findAll('[role="option"]')
}
it('should render the trigger slot', () => {
createWrapper()
expect(wrapper.find('[data-testid="trigger"]').exists()).toBe(true)
})
it('should show popover content when trigger is clicked', async () => {
createWrapper()
await openPopover(wrapper)
expect(wrapper.find('[role="listbox"]').exists()).toBe(true)
})
it('should display all options sorted alphabetically', async () => {
createWrapper({ chip: createMockChip(['MODEL', 'IMAGE', 'LATENT']) })
await openPopover(wrapper)
const options = getOptions()
expect(options).toHaveLength(3)
const texts = options.map((o) => o.text().trim())
expect(texts[0]).toContain('IMAGE')
expect(texts[1]).toContain('LATENT')
expect(texts[2]).toContain('MODEL')
})
it('should show selected count text', async () => {
createWrapper({ selectedValues: ['IMAGE', 'LATENT'] })
await openPopover(wrapper)
expect(wrapper.text()).toContain('2 items selected')
})
it('should show clear all button only when values are selected', async () => {
createWrapper({ selectedValues: [] })
await openPopover(wrapper)
const buttons = wrapper.findAll('button')
const clearBtn = buttons.find((b) => b.text().includes('Clear all'))
expect(clearBtn).toBeUndefined()
})
it('should show clear all button when values are selected', async () => {
createWrapper({ selectedValues: ['IMAGE'] })
await openPopover(wrapper)
const buttons = wrapper.findAll('button')
const clearBtn = buttons.find((b) => b.text().includes('Clear all'))
expect(clearBtn).toBeTruthy()
})
it('should emit clear when clear all button is clicked', async () => {
createWrapper({ selectedValues: ['IMAGE'] })
await openPopover(wrapper)
const clearBtn = wrapper
.findAll('button')
.find((b) => b.text().includes('Clear all'))!
await clearBtn.trigger('click')
await nextTick()
expect(wrapper.emitted('clear')).toHaveLength(1)
})
it('should emit toggle when an option is clicked', async () => {
createWrapper()
await openPopover(wrapper)
const options = getOptions()
await options[0].trigger('click')
await nextTick()
expect(wrapper.emitted('toggle')).toBeTruthy()
expect(wrapper.emitted('toggle')![0][0]).toBe('IMAGE')
})
it('should filter options via search input', async () => {
createWrapper()
await openPopover(wrapper)
const searchInput = wrapper.find('input')
await searchInput.setValue('IMAGE')
await nextTick()
const options = getOptions()
expect(options).toHaveLength(1)
expect(options[0].text()).toContain('IMAGE')
})
it('should show no results when search matches nothing', async () => {
createWrapper()
await openPopover(wrapper)
const searchInput = wrapper.find('input')
await searchInput.setValue('NONEXISTENT')
await nextTick()
expect(getOptions()).toHaveLength(0)
expect(wrapper.text()).toContain('No results')
})
})

View File

@@ -1,175 +0,0 @@
<template>
<PopoverRoot v-model:open="open" @update:open="onOpenChange">
<PopoverTrigger as-child>
<slot />
</PopoverTrigger>
<PopoverContent
side="bottom"
:side-offset="4"
:collision-padding="10"
class="data-[state=open]:data-[side=bottom]:animate-slideUpAndFade z-1001 w-64 rounded-lg border border-border-default bg-base-background px-4 py-1 shadow-interface will-change-[transform,opacity]"
@open-auto-focus="onOpenAutoFocus"
@close-auto-focus="onCloseAutoFocus"
@escape-key-down.prevent
@keydown.escape.stop="closeWithEscape"
>
<ListboxRoot
multiple
selection-behavior="toggle"
:model-value="selectedValues"
@update:model-value="onSelectionChange"
>
<div
class="mt-2 flex h-8 items-center gap-2 rounded-sm border border-border-default px-2"
>
<i
class="icon-[lucide--search] size-4 shrink-0 text-muted-foreground"
/>
<ListboxFilter
ref="searchFilterRef"
v-model="searchQuery"
:placeholder="t('g.search')"
class="text-foreground size-full border-none bg-transparent font-inter text-sm outline-none placeholder:text-muted-foreground"
/>
</div>
<div class="flex items-center justify-between py-3">
<span class="text-sm text-muted-foreground">
{{
t(
'g.itemsSelected',
{ count: selectedValues.length },
selectedValues.length
)
}}
</span>
<button
v-if="selectedValues.length > 0"
type="button"
class="cursor-pointer border-none bg-transparent font-inter text-sm text-base-foreground"
@click="emit('clear')"
>
{{ t('g.clearAll') }}
</button>
</div>
<div class="h-px bg-border-default" />
<ListboxContent class="max-h-64 overflow-y-auto py-3">
<ListboxItem
v-for="option in filteredOptions"
:key="option"
:value="option"
data-testid="filter-option"
class="text-foreground flex cursor-pointer items-center gap-2 rounded-sm px-1 py-2 text-sm outline-none data-highlighted:bg-secondary-background-hover"
>
<span
:class="
cn(
'flex size-4 shrink-0 items-center justify-center rounded-sm border border-border-default',
selectedSet.has(option) &&
'text-primary-foreground border-primary bg-primary'
)
"
>
<i
v-if="selectedSet.has(option)"
class="icon-[lucide--check] size-3"
/>
</span>
<span class="truncate">{{ option }}</span>
<span
class="mr-1 ml-auto text-lg leading-none"
:style="{ color: getLinkTypeColor(option) }"
>
&bull;
</span>
</ListboxItem>
<div
v-if="filteredOptions.length === 0"
class="px-1 py-4 text-center text-sm text-muted-foreground"
>
{{ t('g.noResults') }}
</div>
</ListboxContent>
</ListboxRoot>
</PopoverContent>
</PopoverRoot>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import type { AcceptableValue } from 'reka-ui'
import {
ListboxContent,
ListboxFilter,
ListboxItem,
ListboxRoot,
PopoverContent,
PopoverRoot,
PopoverTrigger
} from 'reka-ui'
import type { FilterChip } from '@/components/searchbox/v2/NodeSearchFilterBar.vue'
import { getLinkTypeColor } from '@/utils/litegraphUtil'
import { cn } from '@/utils/tailwindUtil'
const { chip, selectedValues } = defineProps<{
chip: FilterChip
selectedValues: string[]
}>()
const emit = defineEmits<{
toggle: [value: string]
clear: []
escapeClose: []
}>()
const { t } = useI18n()
const open = ref(false)
const closedWithEscape = ref(false)
const searchQuery = ref('')
const searchFilterRef = ref<InstanceType<typeof ListboxFilter>>()
function onOpenChange(isOpen: boolean) {
if (!isOpen) searchQuery.value = ''
}
const selectedSet = computed(() => new Set(selectedValues))
function onSelectionChange(value: AcceptableValue) {
const newValues = value as string[]
const added = newValues.find((v) => !selectedSet.value.has(v))
const removed = selectedValues.find((v) => !newValues.includes(v))
const toggled = added ?? removed
if (toggled) emit('toggle', toggled)
}
const filteredOptions = computed(() => {
const { fuseSearch } = chip.filter
if (searchQuery.value) {
return fuseSearch.search(searchQuery.value).slice(0, 64)
}
return fuseSearch.data.slice().sort()
})
function closeWithEscape() {
closedWithEscape.value = true
open.value = false
}
function onOpenAutoFocus(event: Event) {
event.preventDefault()
const el = searchFilterRef.value?.$el as HTMLInputElement | undefined
el?.focus()
}
function onCloseAutoFocus(event: Event) {
if (closedWithEscape.value) {
event.preventDefault()
closedWithEscape.value = false
emit('escapeClose')
}
}
</script>

View File

@@ -39,9 +39,7 @@ export const testI18n = createI18n({
mostRelevant: 'Most relevant',
recents: 'Recents',
favorites: 'Favorites',
bookmarked: 'Bookmarked',
essentials: 'Essentials',
category: 'Category',
custom: 'Custom',
comfy: 'Comfy',
partner: 'Partner',
@@ -51,13 +49,15 @@ export const testI18n = createI18n({
input: 'Input',
output: 'Output',
source: 'Source',
search: 'Search',
blueprints: 'Blueprints',
partnerNodes: 'Partner Nodes',
remove: 'Remove',
itemsSelected:
'No items selected | {count} item selected | {count} items selected',
clearAll: 'Clear all'
search: 'Search'
},
sideToolbar: {
nodeLibraryTab: {
filterOptions: {
blueprints: 'Blueprints',
partnerNodes: 'Partner Nodes'
}
}
}
}
}

View File

@@ -23,7 +23,9 @@ export const buttonVariants = cva({
'overlay-white': 'bg-white text-gray-600 hover:bg-white/90',
base: 'bg-base-background text-base-foreground hover:bg-secondary-background-hover',
gradient:
'border-transparent bg-(image:--subscription-button-gradient) text-white hover:opacity-90'
'border-transparent bg-(image:--subscription-button-gradient) text-white hover:opacity-90',
outline:
'border border-solid border-border-subtle bg-transparent text-base-foreground hover:bg-secondary-background-hover'
},
size: {
sm: 'h-6 rounded-sm px-2 py-1 text-xs',
@@ -55,7 +57,8 @@ const variants = [
'link',
'base',
'overlay-white',
'gradient'
'gradient',
'outline'
] as const satisfies Array<ButtonVariants['variant']>
const sizes = [
'sm',

View File

@@ -140,19 +140,13 @@ export const useFirebaseAuthActions = () => {
return result
}, reportError)
const signInWithGoogle = wrapWithErrorHandlingAsync(
async (options?: { isNewUser?: boolean }) => {
return await authStore.loginWithGoogle(options)
},
reportError
)
const signInWithGoogle = wrapWithErrorHandlingAsync(async () => {
return await authStore.loginWithGoogle()
}, reportError)
const signInWithGithub = wrapWithErrorHandlingAsync(
async (options?: { isNewUser?: boolean }) => {
return await authStore.loginWithGithub(options)
},
reportError
)
const signInWithGithub = wrapWithErrorHandlingAsync(async () => {
return await authStore.loginWithGithub()
}, reportError)
const signInWithEmail = wrapWithErrorHandlingAsync(
async (email: string, password: string) => {

View File

@@ -315,45 +315,6 @@ describe('installErrorClearingHooks lifecycle', () => {
cleanup()
expect(graph.onNodeAdded).toBe(originalHook)
})
it('restores original node callbacks when a node is removed', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
node.addInput('clip', 'CLIP')
node.addWidget('number', 'steps', 20, () => undefined, {})
const originalOnConnectionsChange = vi.fn()
const originalOnWidgetChanged = vi.fn()
node.onConnectionsChange = originalOnConnectionsChange
node.onWidgetChanged = originalOnWidgetChanged
graph.add(node)
installErrorClearingHooks(graph)
// Callbacks should be chained (not the originals)
expect(node.onConnectionsChange).not.toBe(originalOnConnectionsChange)
expect(node.onWidgetChanged).not.toBe(originalOnWidgetChanged)
// Simulate node removal via the graph hook
graph.onNodeRemoved!(node)
// Original callbacks should be restored
expect(node.onConnectionsChange).toBe(originalOnConnectionsChange)
expect(node.onWidgetChanged).toBe(originalOnWidgetChanged)
})
it('does not double-wrap callbacks when installErrorClearingHooks is called twice', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
node.addInput('clip', 'CLIP')
graph.add(node)
installErrorClearingHooks(graph)
const chainedAfterFirst = node.onConnectionsChange
// Install again on the same graph — should be a no-op for existing nodes
installErrorClearingHooks(graph)
expect(node.onConnectionsChange).toBe(chainedAfterFirst)
})
})
describe('clearWidgetRelatedErrors parameter routing', () => {

View File

@@ -35,22 +35,10 @@ function resolvePromotedExecId(
const hookedNodes = new WeakSet<LGraphNode>()
type OriginalCallbacks = {
onConnectionsChange: LGraphNode['onConnectionsChange']
onWidgetChanged: LGraphNode['onWidgetChanged']
}
const originalCallbacks = new WeakMap<LGraphNode, OriginalCallbacks>()
function installNodeHooks(node: LGraphNode): void {
if (hookedNodes.has(node)) return
hookedNodes.add(node)
originalCallbacks.set(node, {
onConnectionsChange: node.onConnectionsChange,
onWidgetChanged: node.onWidgetChanged
})
node.onConnectionsChange = useChainCallback(
node.onConnectionsChange,
function (type, slotIndex, isConnected) {
@@ -94,15 +82,6 @@ function installNodeHooks(node: LGraphNode): void {
)
}
function restoreNodeHooks(node: LGraphNode): void {
const originals = originalCallbacks.get(node)
if (!originals) return
node.onConnectionsChange = originals.onConnectionsChange
node.onWidgetChanged = originals.onWidgetChanged
originalCallbacks.delete(node)
hookedNodes.delete(node)
}
function installNodeHooksRecursive(node: LGraphNode): void {
installNodeHooks(node)
if (node.isSubgraphNode?.()) {
@@ -112,15 +91,6 @@ function installNodeHooksRecursive(node: LGraphNode): void {
}
}
function restoreNodeHooksRecursive(node: LGraphNode): void {
restoreNodeHooks(node)
if (node.isSubgraphNode?.()) {
for (const innerNode of node.subgraph._nodes ?? []) {
restoreNodeHooksRecursive(innerNode)
}
}
}
export function installErrorClearingHooks(graph: LGraph): () => void {
for (const node of graph._nodes ?? []) {
installNodeHooksRecursive(node)
@@ -132,17 +102,7 @@ export function installErrorClearingHooks(graph: LGraph): () => void {
originalOnNodeAdded?.call(this, node)
}
const originalOnNodeRemoved = graph.onNodeRemoved
graph.onNodeRemoved = function (node: LGraphNode) {
restoreNodeHooksRecursive(node)
originalOnNodeRemoved?.call(this, node)
}
return () => {
for (const node of graph._nodes ?? []) {
restoreNodeHooksRecursive(node)
}
graph.onNodeAdded = originalOnNodeAdded || undefined
graph.onNodeRemoved = originalOnNodeRemoved || undefined
}
}

View File

@@ -197,16 +197,14 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
const subgraphNode = createTestSubgraphNode(subgraph, { id: 123 })
// Create a PromotedWidgetView with identityName="value" (subgraph input
// Create a PromotedWidgetView with displayName="value" (subgraph input
// slot name) and sourceWidgetName="prompt" (interior widget name).
// PromotedWidgetView.name returns "value" (identity), safeWidgetMapper
// sets SafeWidgetData.name to sourceWidgetName ("prompt").
// PromotedWidgetView.name returns "value", but safeWidgetMapper sets
// SafeWidgetData.name to sourceWidgetName ("prompt").
const promotedView = createPromotedWidgetView(
subgraphNode,
'10',
'prompt',
'value',
undefined,
'value'
)

View File

@@ -92,10 +92,6 @@ export interface SafeWidgetData {
* execution ID (e.g. `"65:42"` vs the host node's `"65"`).
*/
sourceExecutionId?: string
/** Tooltip text from the resolved widget. */
tooltip?: string
/** For promoted widgets, the display label from the subgraph input slot. */
promotedLabel?: string
}
export interface VueNodeData {
@@ -356,8 +352,7 @@ function safeWidgetMapper(
sourceNode && app.rootGraph
? (getExecutionIdByNode(app.rootGraph, sourceNode) ?? undefined)
: undefined,
tooltip: widget.tooltip,
promotedLabel: isPromotedWidgetView(widget) ? widget.label : undefined
tooltip: widget.tooltip
}
} catch (error) {
console.warn(
@@ -808,8 +803,6 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
if (slotLabelEvent.slotType !== NodeSlotType.INPUT && nodeRef.outputs) {
nodeRef.outputs = [...nodeRef.outputs]
}
// Re-extract widget data so promotedLabel reflects the rename
vueNodeData.set(nodeId, extractVueNodeData(nodeRef))
}
}

View File

@@ -1,111 +0,0 @@
import type { Ref } from 'vue'
import { computed, watch } from 'vue'
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useSettingStore } from '@/platform/settings/settingStore'
import { app } from '@/scripts/app'
import type { NodeError } from '@/schemas/apiSchema'
import { getParentExecutionIds } from '@/types/nodeIdentification'
import { forEachNode, getNodeByExecutionId } from '@/utils/graphTraversalUtil'
function setNodeHasErrors(node: LGraphNode, hasErrors: boolean): void {
if (node.has_errors === hasErrors) return
const oldValue = node.has_errors
node.has_errors = hasErrors
node.graph?.trigger('node:property:changed', {
type: 'node:property:changed',
nodeId: node.id,
property: 'has_errors',
oldValue,
newValue: hasErrors
})
}
/**
* Single-pass reconciliation of node error flags.
* Collects the set of nodes that should have errors, then walks all nodes
* once, setting each flag exactly once. This avoids the redundant
* true→false→true transition (and duplicate events) that a clear-then-apply
* approach would cause.
*/
function reconcileNodeErrorFlags(
rootGraph: LGraph,
nodeErrors: Record<string, NodeError> | null,
missingModelExecIds: Set<string>
): void {
// Collect nodes and slot info that should be flagged
// Includes both error-owning nodes and their ancestor containers
const flaggedNodes = new Set<LGraphNode>()
const errorSlots = new Map<LGraphNode, Set<string>>()
if (nodeErrors) {
for (const [executionId, nodeError] of Object.entries(nodeErrors)) {
const node = getNodeByExecutionId(rootGraph, executionId)
if (!node) continue
flaggedNodes.add(node)
const slotNames = new Set<string>()
for (const error of nodeError.errors) {
const name = error.extra_info?.input_name
if (name) slotNames.add(name)
}
if (slotNames.size > 0) errorSlots.set(node, slotNames)
for (const parentId of getParentExecutionIds(executionId)) {
const parentNode = getNodeByExecutionId(rootGraph, parentId)
if (parentNode) flaggedNodes.add(parentNode)
}
}
}
for (const execId of missingModelExecIds) {
const node = getNodeByExecutionId(rootGraph, execId)
if (node) flaggedNodes.add(node)
}
forEachNode(rootGraph, (node) => {
setNodeHasErrors(node, flaggedNodes.has(node))
if (node.inputs) {
const nodeSlotNames = errorSlots.get(node)
for (const slot of node.inputs) {
slot.hasErrors = !!nodeSlotNames?.has(slot.name)
}
}
})
}
export function useNodeErrorFlagSync(
lastNodeErrors: Ref<Record<string, NodeError> | null>,
missingModelStore: ReturnType<typeof useMissingModelStore>
): () => void {
const settingStore = useSettingStore()
const showErrorsTab = computed(() =>
settingStore.get('Comfy.RightSidePanel.ShowErrorsTab')
)
const stop = watch(
[
lastNodeErrors,
() => missingModelStore.missingModelNodeIds,
showErrorsTab
],
() => {
if (!app.isGraphReady) return
// Legacy (LGraphNode) only: suppress missing-model error flags when
// the Errors tab is hidden, since legacy nodes lack the per-widget
// red highlight that Vue nodes use to indicate *why* a node has errors.
// Vue nodes compute hasAnyError independently and are unaffected.
reconcileNodeErrorFlags(
app.rootGraph,
lastNodeErrors.value,
showErrorsTab.value
? missingModelStore.missingModelAncestorExecutionIds
: new Set()
)
},
{ flush: 'post' }
)
return stop
}

View File

@@ -86,24 +86,6 @@ function useVueNodeLifecycleIndividual() {
() => !shouldRenderVueNodes.value,
() => {
disposeNodeManagerAndSyncs()
// Force arrange() on all nodes so input.pos is computed before
// the first legacy drawConnections frame (which may run before
// drawNode on the foreground canvas).
const graph = comfyApp.canvas?.graph
if (!graph) {
comfyApp.canvas?.setDirty(true, true)
return
}
for (const node of graph._nodes) {
if (node.flags.collapsed) continue
try {
node.arrange()
} catch {
/* skip nodes not fully initialized */
}
}
comfyApp.canvas?.setDirty(true, true)
}
)

View File

@@ -1,65 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
exceedsClickThreshold,
useClickDragGuard
} from '@/composables/useClickDragGuard'
describe('exceedsClickThreshold', () => {
it('returns false when distance is within threshold', () => {
expect(exceedsClickThreshold({ x: 0, y: 0 }, { x: 2, y: 2 }, 5)).toBe(false)
})
it('returns true when distance exceeds threshold', () => {
expect(exceedsClickThreshold({ x: 0, y: 0 }, { x: 3, y: 5 }, 5)).toBe(true)
})
it('returns false when distance exactly equals threshold', () => {
expect(exceedsClickThreshold({ x: 0, y: 0 }, { x: 3, y: 4 }, 5)).toBe(false)
})
it('handles negative deltas', () => {
expect(exceedsClickThreshold({ x: 10, y: 10 }, { x: 4, y: 2 }, 5)).toBe(
true
)
})
})
describe('useClickDragGuard', () => {
it('reports no drag when pointer has not moved', () => {
const guard = useClickDragGuard(5)
guard.recordStart({ clientX: 100, clientY: 200 })
expect(guard.wasDragged({ clientX: 100, clientY: 200 })).toBe(false)
})
it('reports no drag when movement is within threshold', () => {
const guard = useClickDragGuard(5)
guard.recordStart({ clientX: 100, clientY: 200 })
expect(guard.wasDragged({ clientX: 103, clientY: 204 })).toBe(false)
})
it('reports drag when movement exceeds threshold', () => {
const guard = useClickDragGuard(5)
guard.recordStart({ clientX: 100, clientY: 200 })
expect(guard.wasDragged({ clientX: 106, clientY: 200 })).toBe(true)
})
it('returns false when no start has been recorded', () => {
const guard = useClickDragGuard(5)
expect(guard.wasDragged({ clientX: 100, clientY: 200 })).toBe(false)
})
it('returns false after reset', () => {
const guard = useClickDragGuard(5)
guard.recordStart({ clientX: 100, clientY: 200 })
guard.reset()
expect(guard.wasDragged({ clientX: 200, clientY: 300 })).toBe(false)
})
it('respects custom threshold', () => {
const guard = useClickDragGuard(3)
guard.recordStart({ clientX: 0, clientY: 0 })
expect(guard.wasDragged({ clientX: 3, clientY: 0 })).toBe(false)
expect(guard.wasDragged({ clientX: 4, clientY: 0 })).toBe(true)
})
})

View File

@@ -1,41 +0,0 @@
interface PointerPosition {
readonly x: number
readonly y: number
}
function squaredDistance(a: PointerPosition, b: PointerPosition): number {
const dx = a.x - b.x
const dy = a.y - b.y
return dx * dx + dy * dy
}
export function exceedsClickThreshold(
start: PointerPosition,
end: PointerPosition,
threshold: number
): boolean {
return squaredDistance(start, end) > threshold * threshold
}
export function useClickDragGuard(threshold: number = 5) {
let start: PointerPosition | null = null
function recordStart(e: { clientX: number; clientY: number }) {
start = { x: e.clientX, y: e.clientY }
}
function wasDragged(e: { clientX: number; clientY: number }): boolean {
if (!start) return false
return exceedsClickThreshold(
start,
{ x: e.clientX, y: e.clientY },
threshold
)
}
function reset() {
start = null
}
return { recordStart, wasDragged, reset }
}

View File

@@ -107,27 +107,6 @@ export const ESSENTIALS_CATEGORY_CANONICAL: ReadonlyMap<
EssentialsCategory
> = new Map(ESSENTIALS_CATEGORIES.map((c) => [c.toLowerCase(), c]))
/**
* Precomputed rank map: category → display order index.
* Used for sorting essentials folders in their canonical order.
*/
export const ESSENTIALS_CATEGORY_RANK: ReadonlyMap<string, number> = new Map(
ESSENTIALS_CATEGORIES.map((c, i) => [c, i])
)
/**
* Precomputed rank maps: category → (node name → display order index).
* Used for sorting nodes within each essentials folder.
*/
export const ESSENTIALS_NODE_RANK: Partial<
Record<EssentialsCategory, ReadonlyMap<string, number>>
> = Object.fromEntries(
Object.entries(ESSENTIALS_NODES).map(([category, nodes]) => [
category,
new Map(nodes.map((name, i) => [name, i]))
])
)
/**
* "Novel" toolkit nodes for telemetry — basics excluded.
* Derived from ESSENTIALS_NODES minus the 'basics' category.

View File

@@ -138,18 +138,15 @@ describe(createPromotedWidgetView, () => {
expect(view.name).toBe('myWidget')
})
test('name uses identityName when provided, label uses displayName', () => {
test('name uses displayName when provided', () => {
const [subgraphNode] = setupSubgraph()
const view = createPromotedWidgetView(
subgraphNode,
'1',
'myWidget',
'Custom Label',
undefined,
'my_slot'
'Custom Label'
)
expect(view.name).toBe('my_slot')
expect(view.label).toBe('Custom Label')
expect(view.name).toBe('Custom Label')
})
test('node getter returns the subgraphNode', () => {
@@ -337,11 +334,11 @@ describe(createPromotedWidgetView, () => {
innerNode.addWidget('text', 'myWidget', 'val', () => {})
const bareId = String(innerNode.id)
// No displayName → label is undefined (rendering uses widget.label ?? widget.name)
// No displayName → falls back to widgetName
const view1 = createPromotedWidgetView(subgraphNode, bareId, 'myWidget')
expect(view1.label).toBeUndefined()
expect(view1.label).toBe('myWidget')
// With displayName → label falls back to displayName
// With displayName → falls back to displayName
const view2 = createPromotedWidgetView(
subgraphNode,
bareId,
@@ -1015,9 +1012,7 @@ describe('SubgraphNode.widgets getter', () => {
const afterRename = promotedWidgets(subgraphNode)[0]
if (!afterRename) throw new Error('Expected linked promoted view')
// .name stays as identity (subgraph input name), .label updates for display
expect(afterRename.name).toBe('seed')
expect(afterRename.label).toBe('seed_renamed')
expect(afterRename.name).toBe('seed_renamed')
})
test('caches view objects across getter calls (stable references)', () => {

View File

@@ -27,12 +27,6 @@ import type { PromotedWidgetView as IPromotedWidgetView } from './promotedWidget
export type { PromotedWidgetView } from './promotedWidgetTypes'
export { isPromotedWidgetView } from './promotedWidgetTypes'
interface SubgraphSlotRef {
name: string
label?: string
displayName?: string
}
function isWidgetValue(value: unknown): value is IBaseWidget['value'] {
if (value === undefined) return true
if (typeof value === 'string') return true
@@ -56,16 +50,14 @@ export function createPromotedWidgetView(
nodeId: string,
widgetName: string,
displayName?: string,
disambiguatingSourceNodeId?: string,
identityName?: string
disambiguatingSourceNodeId?: string
): IPromotedWidgetView {
return new PromotedWidgetView(
subgraphNode,
nodeId,
widgetName,
displayName,
disambiguatingSourceNodeId,
identityName
disambiguatingSourceNodeId
)
}
@@ -91,17 +83,12 @@ class PromotedWidgetView implements IPromotedWidgetView {
private cachedDeepestByFrame?: { node: LGraphNode; widget: IBaseWidget }
private cachedDeepestFrame = -1
/** Cached reference to the bound subgraph slot, set at construction. */
private _boundSlot?: SubgraphSlotRef
private _boundSlotVersion = -1
constructor(
private readonly subgraphNode: SubgraphNode,
nodeId: string,
widgetName: string,
private readonly displayName?: string,
readonly disambiguatingSourceNodeId?: string,
private readonly identityName?: string
readonly disambiguatingSourceNodeId?: string
) {
this.sourceNodeId = nodeId
this.sourceWidgetName = widgetName
@@ -113,7 +100,7 @@ class PromotedWidgetView implements IPromotedWidgetView {
}
get name(): string {
return this.identityName ?? this.sourceWidgetName
return this.displayName ?? this.sourceWidgetName
}
get y(): number {
@@ -201,58 +188,15 @@ class PromotedWidgetView implements IPromotedWidgetView {
}
get label(): string | undefined {
const slot = this.getBoundSubgraphSlot()
if (slot) return slot.label ?? slot.displayName ?? slot.name
// Fall back to persisted widget state (survives save/reload before
// the slot binding is established) then to construction displayName.
const state = this.getWidgetState()
return state?.label ?? this.displayName
return state?.label ?? this.displayName ?? this.sourceWidgetName
}
set label(value: string | undefined) {
const slot = this.getBoundSubgraphSlot()
if (slot) slot.label = value || undefined
// Also persist to widget state store for save/reload resilience
const state = this.getWidgetState()
if (state) state.label = value
}
/**
* Returns the cached bound subgraph slot reference, refreshing only when
* the subgraph node's input list has changed (length mismatch).
*
* Note: Using length as the cache key works because the returned reference
* is the same mutable slot object. When slot properties (label, name) change,
* the caller reads fresh values from that reference. The cache only needs
* to invalidate when slots are added or removed, which changes length.
*/
private getBoundSubgraphSlot(): SubgraphSlotRef | undefined {
const version = this.subgraphNode.inputs?.length ?? 0
if (this._boundSlotVersion === version) return this._boundSlot
this._boundSlot = this.findBoundSubgraphSlot()
this._boundSlotVersion = version
return this._boundSlot
}
private findBoundSubgraphSlot(): SubgraphSlotRef | undefined {
for (const input of this.subgraphNode.inputs ?? []) {
const slot = input._subgraphSlot as SubgraphSlotRef | undefined
if (!slot) continue
const w = input._widget
if (
w &&
isPromotedWidgetView(w) &&
w.sourceNodeId === this.sourceNodeId &&
w.sourceWidgetName === this.sourceWidgetName
) {
return slot
}
}
return undefined
}
get hidden(): boolean {
return this.resolveDeepest()?.widget.hidden ?? false
}
@@ -294,27 +238,21 @@ class PromotedWidgetView implements IPromotedWidgetView {
const originalComputedHeight = projected.computedHeight
const originalComputedDisabled = projected.computedDisabled
const originalLabel = projected.label
projected.y = this.y
projected.computedHeight = this.computedHeight
projected.computedDisabled = this.computedDisabled
projected.value = this.value
projected.label = this.label
try {
projected.drawWidget(ctx, {
width: widgetWidth,
showText: !lowQuality,
suppressPromotedOutline: true,
previewImages: resolved.node.imgs
})
} finally {
projected.y = originalY
projected.computedHeight = originalComputedHeight
projected.computedDisabled = originalComputedDisabled
projected.label = originalLabel
}
projected.drawWidget(ctx, {
width: widgetWidth,
showText: !lowQuality,
suppressPromotedOutline: true,
previewImages: resolved.node.imgs
})
projected.y = originalY
projected.computedHeight = originalComputedHeight
projected.computedDisabled = originalComputedDisabled
}
onPointerDown(

View File

@@ -30,7 +30,6 @@ describe('PrimitiveFloat widget type bridging', () => {
})
Object.defineProperty(widget.options, 'gradient_stops', {
enumerable: true,
get: () => properties.gradient_stops,
set: (v) => {
properties.gradient_stops = v
@@ -83,20 +82,6 @@ describe('PrimitiveFloat widget type bridging', () => {
expect(widget.options.gradient_stops).toBe(stops)
})
it('gradient_stops survives object spread', () => {
const { properties, widget } = createMockNodeAndWidget()
applyFloatPropertyBridges(properties, widget)
const stops = [
{ offset: 0, color: [0, 255, 255] },
{ offset: 1, color: [255, 0, 0] }
]
properties.gradient_stops = stops
const spread = { ...widget.options }
expect(spread.gradient_stops).toBe(stops)
})
it('writes gradient_stops back to properties', () => {
const { properties, widget } = createMockNodeAndWidget()
applyFloatPropertyBridges(properties, widget)

View File

@@ -169,7 +169,6 @@ function onCustomFloatCreated(this: LGraphNode) {
})
Object.defineProperty(valueWidget.options, 'gradient_stops', {
enumerable: true,
get: () => this.properties.gradient_stops,
set: (v) => {
this.properties.gradient_stops = v

View File

@@ -1,7 +1,5 @@
import * as THREE from 'three'
import { exceedsClickThreshold } from '@/composables/useClickDragGuard'
import { AnimationManager } from './AnimationManager'
import { CameraManager } from './CameraManager'
import { ControlsManager } from './ControlsManager'
@@ -70,7 +68,9 @@ class Load3d {
targetAspectRatio: number = 1
isViewerMode: boolean = false
private rightMouseStart: { x: number; y: number } = { x: 0, y: 0 }
// Context menu tracking
private rightMouseDownX: number = 0
private rightMouseDownY: number = 0
private rightMouseMoved: boolean = false
private readonly dragThreshold: number = 5
private contextMenuAbortController: AbortController | null = null
@@ -197,20 +197,18 @@ class Load3d {
const mousedownHandler = (e: MouseEvent) => {
if (e.button === 2) {
this.rightMouseStart = { x: e.clientX, y: e.clientY }
this.rightMouseDownX = e.clientX
this.rightMouseDownY = e.clientY
this.rightMouseMoved = false
}
}
const mousemoveHandler = (e: MouseEvent) => {
if (e.buttons === 2) {
if (
exceedsClickThreshold(
this.rightMouseStart,
{ x: e.clientX, y: e.clientY },
this.dragThreshold
)
) {
const dx = Math.abs(e.clientX - this.rightMouseDownX)
const dy = Math.abs(e.clientY - this.rightMouseDownY)
if (dx > this.dragThreshold || dy > this.dragThreshold) {
this.rightMouseMoved = true
}
}
@@ -219,13 +217,12 @@ class Load3d {
const contextmenuHandler = (e: MouseEvent) => {
if (this.isViewerMode) return
const dx = Math.abs(e.clientX - this.rightMouseDownX)
const dy = Math.abs(e.clientY - this.rightMouseDownY)
const wasDragging =
this.rightMouseMoved ||
exceedsClickThreshold(
this.rightMouseStart,
{ x: e.clientX, y: e.clientY },
this.dragThreshold
)
dx > this.dragThreshold ||
dy > this.dragThreshold
this.rightMouseMoved = false

View File

@@ -7,8 +7,7 @@ import {
LGraph,
LGraphNode,
LiteGraph,
LLink,
Reroute
LLink
} from '@/lib/litegraph/src/litegraph'
import type { SerialisableGraph } from '@/lib/litegraph/src/types/serialisation'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
@@ -18,7 +17,6 @@ import {
createTestSubgraphData,
createTestSubgraphNode
} from './subgraph/__fixtures__/subgraphHelpers'
import { subgraphTest } from './subgraph/__fixtures__/subgraphFixtures'
import {
duplicateLinksRoot,
@@ -100,42 +98,6 @@ describe('LGraph', () => {
const fromOldSchema = new LGraph(oldSchemaGraph)
expect(fromOldSchema).toMatchSnapshot('oldSchemaGraph')
})
subgraphTest('should snap slots to same y-level', ({ emptySubgraph }) => {
const node = new LGraphNode('testname')
node.addInput('test', 'IMAGE')
emptySubgraph.add(node)
emptySubgraph.inputNode.pos = [0, 0]
// Reroute needs offset of ~20y to align with first slot
const reroute = new Reroute(1, emptySubgraph, [0, 20])
node.snapToGrid(10)
reroute.snapToGrid(10)
emptySubgraph.inputNode.snapToGrid(10)
node.arrange()
emptySubgraph.inputNode.arrange()
const yPos = node.getInputPos(0)[1]
expect(reroute.pos[1]).toBe(yPos)
expect(emptySubgraph.inputNode.emptySlot.pos[1]).toBe(yPos)
// Assign non-equal positions and repeat
emptySubgraph.inputNode.pos = [0, 43]
node.pos = [0, 50]
reroute.pos = [0, 63]
node.snapToGrid(10)
reroute.snapToGrid(10)
emptySubgraph.inputNode.snapToGrid(10)
node.arrange()
emptySubgraph.inputNode.arrange()
const yPos2 = node.getInputPos(0)[1]
expect(reroute.pos[1]).toBe(yPos2)
expect(emptySubgraph.inputNode.emptySlot.pos[1]).toBe(yPos2)
})
})
describe('Floating Links / Reroutes', () => {

View File

@@ -1,207 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
LGraph,
LGraphCanvas,
LGraphNode,
LiteGraph
} from '@/lib/litegraph/src/litegraph'
import { LLink } from '@/lib/litegraph/src/LLink'
import { createMockCanvas2DContext } from '@/utils/__tests__/litegraphTestUtils'
vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
layoutStore: {
querySlotAtPoint: vi.fn(),
queryRerouteAtPoint: vi.fn(),
getNodeLayoutRef: vi.fn(() => ({ value: null })),
getSlotLayout: vi.fn(),
setSource: vi.fn(),
batchUpdateNodeBounds: vi.fn(),
getCurrentSource: vi.fn(() => 'test'),
getCurrentActor: vi.fn(() => 'test'),
applyOperation: vi.fn(),
pendingSlotSync: false
}
}))
function createMockCtx(): CanvasRenderingContext2D {
return createMockCanvas2DContext({
translate: vi.fn(),
scale: vi.fn(),
fillText: vi.fn(),
measureText: vi.fn().mockReturnValue({ width: 50 }),
closePath: vi.fn(),
rect: vi.fn(),
clip: vi.fn(),
setTransform: vi.fn(),
roundRect: vi.fn(),
getTransform: vi
.fn()
.mockReturnValue({ a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }),
createLinearGradient: vi.fn().mockReturnValue({
addColorStop: vi.fn()
}),
bezierCurveTo: vi.fn(),
quadraticCurveTo: vi.fn(),
isPointInStroke: vi.fn().mockReturnValue(false),
globalAlpha: 1,
textAlign: 'left' as CanvasTextAlign,
textBaseline: 'alphabetic' as CanvasTextBaseline,
shadowColor: '',
shadowBlur: 0,
shadowOffsetX: 0,
shadowOffsetY: 0,
imageSmoothingEnabled: true
})
}
/**
* Creates a link between two nodes by directly mutating graph state,
* bypassing the layout store integration in connect().
*/
function createTestLink(
graph: LGraph,
sourceNode: LGraphNode,
outputSlot: number,
targetNode: LGraphNode,
inputSlot: number
): LLink {
const linkId = ++graph.state.lastLinkId
const link = new LLink(
linkId,
sourceNode.outputs[outputSlot].type,
sourceNode.id,
outputSlot,
targetNode.id,
inputSlot
)
graph._links.set(linkId, link)
sourceNode.outputs[outputSlot].links ??= []
sourceNode.outputs[outputSlot].links!.push(linkId)
targetNode.inputs[inputSlot].link = linkId
return link
}
describe('drawConnections widget-input slot positioning', () => {
let graph: LGraph
let canvas: LGraphCanvas
let canvasElement: HTMLCanvasElement
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createTestingPinia())
canvasElement = document.createElement('canvas')
canvasElement.width = 800
canvasElement.height = 600
canvasElement.getContext = vi.fn().mockReturnValue(createMockCtx())
canvasElement.getBoundingClientRect = vi.fn().mockReturnValue({
left: 0,
top: 0,
width: 800,
height: 600
})
graph = new LGraph()
canvas = new LGraphCanvas(canvasElement, graph, {
skip_render: true
})
LiteGraph.vueNodesMode = false
})
afterEach(() => {
LiteGraph.vueNodesMode = false
})
it('arranges widget-input slots before rendering links', () => {
const sourceNode = new LGraphNode('Source')
sourceNode.pos = [0, 100]
sourceNode.size = [150, 60]
sourceNode.addOutput('out', 'STRING')
graph.add(sourceNode)
const targetNode = new LGraphNode('Target')
targetNode.pos = [300, 100]
targetNode.size = [200, 120]
const widget = targetNode.addWidget('text', 'value', '', null)
const input = targetNode.addInput('value', 'STRING')
input.widget = { name: 'value' }
graph.add(targetNode)
createTestLink(graph, sourceNode, 0, targetNode, 0)
// Before drawConnections, input.pos should not be set
expect(input.pos).toBeUndefined()
canvas.drawConnections(createMockCtx())
// After drawConnections, input.pos should be set to the widget row
expect(input.pos).toBeDefined()
expect(input.pos![1]).toBeGreaterThan(0)
const offset = LiteGraph.NODE_SLOT_HEIGHT * 0.5
expect(input.pos![1]).toBe(widget.y + offset)
})
it('does not re-arrange nodes whose widget-input slots already have positions', () => {
const sourceNode = new LGraphNode('Source')
sourceNode.pos = [0, 100]
sourceNode.size = [150, 60]
sourceNode.addOutput('out', 'STRING')
graph.add(sourceNode)
const targetNode = new LGraphNode('Target')
targetNode.pos = [300, 100]
targetNode.size = [200, 120]
targetNode.addWidget('text', 'value', '', null)
const input = targetNode.addInput('value', 'STRING')
input.widget = { name: 'value' }
graph.add(targetNode)
createTestLink(graph, sourceNode, 0, targetNode, 0)
// Pre-arrange so input.pos is already set
targetNode._setConcreteSlots()
targetNode.arrange()
expect(input.pos).toBeDefined()
const arrangeSpy = vi.spyOn(targetNode, 'arrange')
canvas.drawConnections(createMockCtx())
expect(arrangeSpy).not.toHaveBeenCalled()
})
it('positions widget-input slots when display name differs from slot.widget.name', () => {
const sourceNode = new LGraphNode('Source')
sourceNode.pos = [0, 100]
sourceNode.size = [150, 60]
sourceNode.addOutput('out', 'STRING')
graph.add(sourceNode)
const targetNode = new LGraphNode('Target')
targetNode.pos = [300, 100]
targetNode.size = [200, 120]
// Widget has a display name that differs from the slot's widget.name
// (simulates a renamed subgraph label)
const widget = targetNode.addWidget('text', 'renamed_label', '', null)
const input = targetNode.addInput('renamed_label', 'STRING')
input.widget = { name: 'original_name' }
// Bind the widget as the slot's _widget (preferred over name-map lookup)
input._widget = widget
graph.add(targetNode)
createTestLink(graph, sourceNode, 0, targetNode, 0)
canvas.drawConnections(createMockCtx())
expect(input.pos).toBeDefined()
const offset = LiteGraph.NODE_SLOT_HEIGHT * 0.5
expect(input.pos![1]).toBe(widget.y + offset)
})
})

View File

@@ -2222,7 +2222,6 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
if (this.state.ghostNodeId != null) {
if (e.button === 0) this.finalizeGhostPlacement(false)
if (e.button === 2) this.finalizeGhostPlacement(true)
this.canvas.focus()
e.stopPropagation()
e.preventDefault()
return
@@ -3680,10 +3679,6 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
}
this.state.ghostNodeId = node.id
this.dispatchEvent('litegraph:ghost-placement', {
active: true,
nodeId: node.id
})
this.deselectAll()
this.select(node)
@@ -3714,10 +3709,6 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
this.state.ghostNodeId = null
this.isDragging = false
this.dispatchEvent('litegraph:ghost-placement', {
active: false,
nodeId
})
this._autoPan?.stop()
this._autoPan = null
@@ -5891,8 +5882,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
drawSnapGuide(
ctx: CanvasRenderingContext2D,
item: Positionable,
shape = RenderShape.ROUND,
{ offsetToSlot }: { offsetToSlot?: boolean } = {}
shape = RenderShape.ROUND
) {
const snapGuide = temp
snapGuide.set(item.boundingRect)
@@ -5900,10 +5890,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
// Not all items have pos equal to top-left of bounds
const { pos } = item
const offsetX = pos[0] - snapGuide[0]
const offsetY =
pos[1] -
snapGuide[1] -
(offsetToSlot ? LiteGraph.NODE_SLOT_HEIGHT * 0.7 : 0)
const offsetY = pos[1] - snapGuide[1]
// Normalise boundingRect to pos to snap
snapGuide[0] += offsetX
@@ -5963,19 +5950,6 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
ctx.globalAlpha = this.editor_alpha
// for every node
const nodes = graph._nodes
// Ensure widget-input slot positions are computed before rendering links.
// arrange() sets input.pos for widget-backed slots, but is normally called
// in drawNode (foreground canvas). drawConnections runs on the background
// canvas, which may render before drawNode has executed for this frame.
// The dirty flag avoids a per-frame O(N) scan of all inputs.
for (const node of nodes) {
if (node.flags.collapsed || !node._widgetSlotsDirty) continue
node._setConcreteSlots()
node.arrange()
}
for (const node of nodes) {
// for every input (we render just inputs because it is easier as every slot can only have one input)
const { inputs } = node
@@ -6093,9 +6067,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
this.isDragging &&
this.selectedItems.has(reroute)
) {
this.drawSnapGuide(ctx, reroute, RenderShape.CIRCLE, {
offsetToSlot: true
})
this.drawSnapGuide(ctx, reroute, RenderShape.CIRCLE)
}
reroute.draw(ctx, this._pattern)

View File

@@ -295,12 +295,6 @@ export class LGraphNode
*/
freeWidgetSpace?: number
/**
* Set to true when widget-backed input slot positions need recalculation.
* Cleared after arrange() runs. Avoids per-frame O(N) scans in drawConnections.
*/
_widgetSlotsDirty = false
locked?: boolean
/** Execution order, automatically computed during run @see {@link LGraph.computeExecutionOrder} */
@@ -1998,7 +1992,6 @@ export class LGraphNode
this.widgets ||= []
const widget = toConcreteWidget(custom_widget, this, false) ?? custom_widget
this.widgets.push(widget)
this._widgetSlotsDirty = true
// Only register with store if node has a valid ID (is already in a graph).
// If the node isn't in a graph yet (id === -1), registration happens
@@ -2038,11 +2031,9 @@ export class LGraphNode
if (input._widget === widget) {
input._widget = undefined
input.widget = undefined
input.pos = undefined
}
}
}
this._widgetSlotsDirty = true
widget.onRemove?.()
this.widgets.splice(widgetIndex, 1)
@@ -4215,29 +4206,40 @@ export class LGraphNode
* Arranges the layout of the node's widget input slots.
*/
private _arrangeWidgetInputSlots(): void {
if (!this.widgets?.length) return
if (!this.widgets) return
// Build a name→widget map for fast lookup.
const widgetByName = new Map<string, IBaseWidget>()
for (const w of this.widgets) widgetByName.set(w.name, w)
const slotByWidgetName = new Map<
string,
INodeInputSlot & { index: number }
>()
// Set widget-backed slot positions from widget Y coordinates.
// In Vue mode, promoted widget inputs are not rendered as <InputSlot>
// components (NodeSlots filters them out), so they have no DOM-registered
// position. input.pos serves as the fallback for getSlotPosition().
for (const [i, slot] of this._concreteInputs.entries()) {
for (const [i, slot] of this.inputs.entries()) {
if (!isWidgetInputSlot(slot)) continue
// Prefer the slot's direct _widget binding (1:1 for promoted inputs).
// Fall back to name-map lookup for regular nodes without _widget set.
// Note: the name-map is ambiguous if two promoted inputs share a label;
// _widget avoids this since it is a direct reference.
const widget = slot._widget ?? widgetByName.get(slot.widget.name)
if (!widget) continue
slotByWidgetName.set(slot.widget.name, { ...slot, index: i })
}
if (!slotByWidgetName.size) return
const offset = LiteGraph.NODE_SLOT_HEIGHT * 0.5
slot.pos = [offset, widget.y + offset]
this._measureSlot(slot, i, true)
// Only set custom pos if not using Vue positioning
// Vue positioning calculates widget slot positions dynamically
if (!LiteGraph.vueNodesMode) {
for (const widget of this.widgets) {
const slot = slotByWidgetName.get(widget.name)
if (!slot) continue
const actualSlot = this._concreteInputs[slot.index]
const offset = LiteGraph.NODE_SLOT_HEIGHT * 0.5
actualSlot.pos = [offset, widget.y + offset]
this._measureSlot(actualSlot, slot.index, true)
}
} else {
// For Vue positioning, just measure the slots without setting pos
for (const widget of this.widgets) {
const slot = slotByWidgetName.get(widget.name)
if (!slot) continue
this._measureSlot(this._concreteInputs[slot.index], slot.index, true)
}
}
}
@@ -4267,7 +4269,6 @@ export class LGraphNode
: 0
this._arrangeWidgets(widgetStartY)
this._arrangeWidgetInputSlots()
this._widgetSlotsDirty = false
}
/**

View File

@@ -16,7 +16,6 @@ import type {
ReadOnlyRect,
ReadonlyLinkNetwork
} from './interfaces'
import { LiteGraph } from './litegraph'
import { distance, isPointInRect } from './measure'
import type { Serialisable, SerialisableReroute } from './types/serialisation'
@@ -429,10 +428,9 @@ export class Reroute
snapToGrid(snapTo: number): boolean {
if (!snapTo) return false
const offsetY = LiteGraph.NODE_SLOT_HEIGHT * 0.7
const { pos } = this
pos[0] = snapTo * Math.round(pos[0] / snapTo)
pos[1] = snapTo * Math.round((pos[1] - offsetY) / snapTo) + offsetY
pos[1] = snapTo * Math.round(pos[1] / snapTo)
return true
}

View File

@@ -1,7 +1,7 @@
import type { LGraph } from '@/lib/litegraph/src/LGraph'
import type { LGraphButton } from '@/lib/litegraph/src/LGraphButton'
import type { LGraphGroup } from '@/lib/litegraph/src/LGraphGroup'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { ConnectingLink } from '@/lib/litegraph/src/interfaces'
import type { Subgraph } from '@/lib/litegraph/src/subgraph/Subgraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
@@ -53,10 +53,4 @@ export interface LGraphCanvasEventMap {
node: LGraphNode
button: LGraphButton
}
/** Ghost placement mode has started or ended. */
'litegraph:ghost-placement': {
active: boolean
nodeId: NodeId
}
}

View File

@@ -1,8 +1,8 @@
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
type ViewManagerEntry = PromotedWidgetSource & {
viewKey?: string
}
type ViewManagerEntry = PromotedWidgetSource & { viewKey?: string }
type CreateView<TView> = (entry: ViewManagerEntry) => TView
/**
* Reconciles promoted widget entries to stable view instances.
@@ -15,9 +15,9 @@ export class PromotedWidgetViewManager<TView> {
private cachedViews: TView[] | null = null
private cachedEntryKeys: string[] | null = null
reconcile<TEntry extends ViewManagerEntry>(
entries: readonly TEntry[],
createView: (entry: TEntry) => TView
reconcile(
entries: readonly ViewManagerEntry[],
createView: CreateView<TView>
): TView[] {
const entryKeys = entries.map((entry) =>
this.makeKey(entry.sourceNodeId, entry.sourceWidgetName, entry.viewKey)

View File

@@ -36,7 +36,7 @@ export abstract class SubgraphIONodeBase<
{
static margin = 10
static minWidth = 100
static roundedRadius = 14 // Matches NODE_SLOT_HEIGHT * 0.7 for slot alignment
static roundedRadius = 10
private readonly _boundingRect: Rectangle = new Rectangle()

View File

@@ -9,7 +9,7 @@ import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import type { ExportedSubgraphInstance } from '@/lib/litegraph/src/types/serialisation'
import { LGraph, LGraphNode, SubgraphNode } from '@/lib/litegraph/src/litegraph'
import { LGraph, SubgraphNode } from '@/lib/litegraph/src/litegraph'
import { subgraphTest } from './__fixtures__/subgraphFixtures'
import {
@@ -196,258 +196,6 @@ describe('SubgraphNode Synchronization', () => {
expect(subgraphNode.outputs[0].label).toBe('newOutput')
})
it('should keep input.widget.name stable after rename (onGraphConfigured safety)', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'text', type: 'STRING' }]
})
const interiorNode = new LGraphNode('Interior')
const input = interiorNode.addInput('value', 'STRING')
input.widget = { name: 'value' }
interiorNode.addOutput('out', 'STRING')
interiorNode.addWidget('text', 'value', '', () => {})
subgraph.add(interiorNode)
subgraph.inputNode.slots[0].connect(interiorNode.inputs[0], interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph)
const promotedInput = subgraphNode.inputs[0]
expect(promotedInput.widget).toBeDefined()
const originalWidgetName = promotedInput.widget!.name
// Rename the subgraph input label
subgraph.inputs[0].label = 'my_custom_prompt'
subgraph.events.dispatch('renaming-input', {
input: subgraph.inputs[0],
index: 0,
oldName: 'text',
newName: 'my_custom_prompt'
})
// widget.name stays as the internal name — NOT the display label
expect(promotedInput.widget!.name).toBe(originalWidgetName)
// The display label is on input.label (live-read via PromotedWidgetView.label)
expect(promotedInput.label).toBe('my_custom_prompt')
// input.widget.name should still match a widget in node.widgets
const matchingWidget = subgraphNode.widgets?.find(
(w) => w.name === promotedInput.widget!.name
)
expect(matchingWidget).toBeDefined()
})
it('should preserve renamed label through serialize/configure round-trip', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'seed', type: 'INT' }]
})
const interiorNode = new LGraphNode('Interior')
const input = interiorNode.addInput('value', 'INT')
input.widget = { name: 'value' }
interiorNode.addOutput('out', 'INT')
interiorNode.addWidget('number', 'value', 0, () => {})
subgraph.add(interiorNode)
subgraph.inputNode.slots[0].connect(interiorNode.inputs[0], interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph)
const promotedWidget = subgraphNode.widgets?.[0]
expect(promotedWidget).toBeDefined()
// Rename via the subgraph slot (simulates right-click rename)
subgraph.inputs[0].label = 'My Seed'
subgraphNode.inputs[0].label = 'My Seed'
subgraph.events.dispatch('renaming-input', {
input: subgraph.inputs[0],
index: 0,
oldName: 'seed',
newName: 'My Seed'
})
// Label should be visible before round-trip
const widgetBeforeRoundTrip = subgraphNode.widgets?.[0]
expect(widgetBeforeRoundTrip!.label || widgetBeforeRoundTrip!.name).toBe(
'My Seed'
)
// Serialize and reconfigure (simulates save/reload)
const serialized = subgraphNode.serialize()
subgraphNode.configure(serialized)
// Label should survive the round-trip
const widgetAfterRoundTrip = subgraphNode.widgets?.[0]
expect(widgetAfterRoundTrip).toBeDefined()
expect(widgetAfterRoundTrip!.label || widgetAfterRoundTrip!.name).toBe(
'My Seed'
)
})
})
describe('SubgraphNode widget name collision on rename', () => {
it('should not collapse two inputs when renamed to the same label', () => {
const subgraph = createTestSubgraph({
inputs: [
{ name: 'prompt_a', type: 'STRING' },
{ name: 'prompt_b', type: 'STRING' }
]
})
// Create two interior nodes with widgets
const nodeA = new LGraphNode('NodeA')
nodeA.addInput('value', 'STRING')
nodeA.inputs[0].widget = { name: 'value' }
nodeA.addOutput('out', 'STRING')
nodeA.addWidget('text', 'value', '', () => {})
subgraph.add(nodeA)
subgraph.inputNode.slots[0].connect(nodeA.inputs[0], nodeA)
const nodeB = new LGraphNode('NodeB')
nodeB.addInput('value', 'STRING')
nodeB.inputs[0].widget = { name: 'value' }
nodeB.addOutput('out', 'STRING')
nodeB.addWidget('text', 'value', '', () => {})
subgraph.add(nodeB)
subgraph.inputNode.slots[1].connect(nodeB.inputs[0], nodeB)
const subgraphNode = createTestSubgraphNode(subgraph)
expect(subgraphNode.inputs).toHaveLength(2)
// widget.name is now nodeId:widgetName (stable composite key)
const key0 = subgraphNode.inputs[0].widget?.name
const key1 = subgraphNode.inputs[1].widget?.name
expect(key0).toBeDefined()
expect(key1).toBeDefined()
expect(key0).not.toBe(key1)
// Rename prompt_b to same LABEL as prompt_a
subgraph.inputs[1].label = 'prompt_a'
subgraph.events.dispatch('renaming-input', {
input: subgraph.inputs[1],
index: 1,
oldName: 'prompt_b',
newName: 'prompt_a'
})
// Both inputs survive — widget.name stays as composite key, no collision
expect(subgraphNode.inputs).toHaveLength(2)
expect(subgraphNode.inputs[0].widget?.name).toBe(key0)
expect(subgraphNode.inputs[1].widget?.name).toBe(key1)
// Display labels: input[1] was renamed
expect(subgraphNode.inputs[1].label).toBe('prompt_a')
// Distinct _widget bindings
expect(subgraphNode.inputs[0]._widget).toBeDefined()
expect(subgraphNode.inputs[1]._widget).toBeDefined()
expect(subgraphNode.inputs[0]._widget).not.toBe(
subgraphNode.inputs[1]._widget
)
})
it('should keep unique widget.name keys even with duplicate labels', () => {
const subgraph = createTestSubgraph({
inputs: [
{ name: 'seed', type: 'INT' },
{ name: 'seed2', type: 'INT' }
]
})
const nodeA = new LGraphNode('NodeA')
nodeA.addInput('value', 'INT')
nodeA.inputs[0].widget = { name: 'value' }
nodeA.addOutput('out', 'INT')
nodeA.addWidget('number', 'value', 0, () => {})
subgraph.add(nodeA)
subgraph.inputNode.slots[0].connect(nodeA.inputs[0], nodeA)
const nodeB = new LGraphNode('NodeB')
nodeB.addInput('value', 'INT')
nodeB.inputs[0].widget = { name: 'value' }
nodeB.addOutput('out', 'INT')
nodeB.addWidget('number', 'value', 0, () => {})
subgraph.add(nodeB)
subgraph.inputNode.slots[1].connect(nodeB.inputs[0], nodeB)
const subgraphNode = createTestSubgraphNode(subgraph)
const key0 = subgraphNode.inputs[0].widget?.name
const key1 = subgraphNode.inputs[1].widget?.name
// Keys should be unique composite identifiers (nodeId:widgetName)
expect(key0).toBeDefined()
expect(key1).toBeDefined()
expect(key0).not.toBe(key1)
// Rename seed2 to "seed" — duplicate display label
subgraph.inputs[1].label = 'seed'
subgraph.events.dispatch('renaming-input', {
input: subgraph.inputs[1],
index: 1,
oldName: 'seed2',
newName: 'seed'
})
// Widget keys remain stable — rename only affects display label
expect(subgraphNode.inputs[0].widget?.name).toBe(key0)
expect(subgraphNode.inputs[1].widget?.name).toBe(key1)
// Distinct _widget bindings survive the rename
expect(subgraphNode.inputs[0]._widget).toBeDefined()
expect(subgraphNode.inputs[1]._widget).toBeDefined()
expect(subgraphNode.inputs[0]._widget).not.toBe(
subgraphNode.inputs[1]._widget
)
})
it('should not lose input when onGraphConfigured runs after duplicate rename', () => {
const subgraph = createTestSubgraph({
inputs: [
{ name: 'alpha', type: 'STRING' },
{ name: 'beta', type: 'STRING' }
]
})
const nodeA = new LGraphNode('NodeA')
nodeA.addInput('value', 'STRING')
nodeA.inputs[0].widget = { name: 'value' }
nodeA.addOutput('out', 'STRING')
nodeA.addWidget('text', 'value', '', () => {})
subgraph.add(nodeA)
subgraph.inputNode.slots[0].connect(nodeA.inputs[0], nodeA)
const nodeB = new LGraphNode('NodeB')
nodeB.addInput('value', 'STRING')
nodeB.inputs[0].widget = { name: 'value' }
nodeB.addOutput('out', 'STRING')
nodeB.addWidget('text', 'value', '', () => {})
subgraph.add(nodeB)
subgraph.inputNode.slots[1].connect(nodeB.inputs[0], nodeB)
const subgraphNode = createTestSubgraphNode(subgraph)
// Rename beta to "alpha" — collision
subgraph.inputs[1].label = 'alpha'
subgraph.events.dispatch('renaming-input', {
input: subgraph.inputs[1],
index: 1,
oldName: 'beta',
newName: 'alpha'
})
// Simulate onGraphConfigured check: for each input with widget,
// find a matching widget by name. If not found, the input gets removed.
for (const input of subgraphNode.inputs) {
if (!input.widget) continue
const name = input.widget.name
const w = subgraphNode.widgets?.find((w) => w.name === name)
// Every input should find at least one matching widget
expect(w).toBeDefined()
}
// Both inputs should survive
expect(subgraphNode.inputs).toHaveLength(2)
})
})
describe('SubgraphNode Lifecycle', () => {

View File

@@ -63,8 +63,6 @@ workflowSvg.src =
type LinkedPromotionEntry = PromotedWidgetSource & {
inputName: string
inputKey: string
/** The subgraph input slot's internal name (stable identity). */
slotName: string
}
// Pre-rasterize the SVG to a bitmap canvas to avoid Firefox re-processing
// the SVG's internal stylesheet on every ctx.drawImage() call per frame.
@@ -194,7 +192,6 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
linkedEntries.push({
inputName: input.label ?? input.name,
inputKey: String(subgraphInput.id),
slotName: subgraphInput.name,
sourceNodeId: boundWidget.sourceNodeId,
sourceWidgetName: boundWidget.sourceWidgetName
})
@@ -209,7 +206,6 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
linkedEntries.push({
inputName: input.label ?? input.name,
inputKey: String(subgraphInput.id),
slotName: subgraphInput.name,
...resolved
})
}
@@ -281,8 +277,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
entry.sourceNodeId,
entry.sourceWidgetName,
entry.viewKey ? displayNameByViewKey.get(entry.viewKey) : undefined,
entry.disambiguatingSourceNodeId,
entry.slotName
entry.disambiguatingSourceNodeId
)
)
@@ -338,7 +333,6 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
sourceWidgetName: string
viewKey?: string
disambiguatingSourceNodeId?: string
slotName?: string
}>
} {
const { fallbackStoredEntries } = this._collectLinkedAndFallbackEntries(
@@ -568,22 +562,17 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
sourceNodeId: string
sourceWidgetName: string
viewKey: string
disambiguatingSourceNodeId?: string
slotName: string
}> {
return linkedEntries.map(
({
inputKey,
inputName,
slotName,
sourceNodeId,
sourceWidgetName,
disambiguatingSourceNodeId
}) => ({
sourceNodeId,
sourceWidgetName,
slotName,
disambiguatingSourceNodeId,
viewKey: this._makePromotionViewKey(
inputKey,
sourceNodeId,
@@ -791,12 +780,9 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
if (!input) throw new Error('Subgraph input not found')
input.label = newName
// Do NOT change input.widget.name — it is the stable internal
// identifier used by onGraphConfigured (widgetInputs.ts) to match
// inputs to widgets. Changing it to the display label would cause
// collisions when two promoted inputs share the same label.
// Display is handled via input.label and _widget.label.
if (input._widget) input._widget.label = newName
if (input._widget) {
input._widget.label = newName
}
this._invalidatePromotedViewsCache()
this.graph?.trigger('node:slot-label:changed', {
nodeId: this.id,
@@ -1148,13 +1134,6 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
}
}
/**
* Binds a promoted widget view to a subgraph input slot.
*
* Creates or retrieves a {@link PromotedWidgetView}, registers it in the
* promotion store, sets up the prototype chain for multi-level subgraph
* nesting, and dispatches the `widget-promoted` event.
*/
private _setWidget(
subgraphInput: Readonly<SubgraphInput>,
input: INodeInputSlot,
@@ -1208,10 +1187,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
})
}
// Create/retrieve the view from cache.
// The cache key uses `input.name` (the slot's internal name) rather
// than `subgraphInput.name` because nested subgraphs may remap
// the internal name independently of the interior node.
// Create/retrieve the view from cache
const view = this._promotedViewManager.getOrCreate(
nodeId,
widgetName,
@@ -1221,8 +1197,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
nodeId,
widgetName,
input.label ?? subgraphInput.name,
sourceNodeId,
subgraphInput.name
sourceNodeId
),
this._makePromotionViewKey(
String(subgraphInput.id),
@@ -1236,9 +1211,6 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
// NOTE: This code creates linked chains of prototypes for passing across
// multiple levels of subgraphs. As part of this, it intentionally avoids
// creating new objects. Have care when making changes.
// Use subgraphInput.name as the stable identity — unique per subgraph
// slot, immune to label renames. Matches PromotedWidgetView.name.
// Display is handled via widget.label / PromotedWidgetView.label.
input.widget ??= { name: subgraphInput.name }
input.widget.name = subgraphInput.name
if (inputWidget) Object.setPrototypeOf(input.widget, inputWidget)

View File

@@ -15,14 +15,6 @@
"message": "يحتوي سير العمل هذا على عقد API، والتي تتطلب تسجيل دخولك إلى حسابك لتشغيلها.",
"title": "تسجيل الدخول مطلوب لاستخدام عقد API"
},
"appBuilder": {
"vueNodeSwitch": {
"content": "لأفضل تجربة، يستخدم منشئ التطبيقات Nodes 2.0. يمكنك العودة بعد بناء التطبيق من القائمة الرئيسية.",
"dismiss": "تجاهل",
"dontShowAgain": "عدم الإظهار مرة أخرى",
"title": "تم التبديل إلى Nodes 2.0"
}
},
"assetBrowser": {
"allCategory": "جميع {category}",
"allModels": "جميع النماذج",
@@ -888,9 +880,7 @@
"extensionFileHint": "قد يكون السبب هو السكربت التالي",
"loadWorkflowTitle": "تم إلغاء التحميل بسبب خطأ في إعادة تحميل بيانات سير العمل",
"noStackTrace": "لا توجد تتبع للمكدس متاحة",
"promptExecutionError": "فشل تنفيذ الطلب",
"accessRestrictedTitle": "Access Restricted",
"accessRestrictedMessage": "Your account is not authorized for this feature."
"promptExecutionError": "فشل تنفيذ الطلب"
},
"errorOverlay": {
"errorCount": "{count} خطأ | {count} أخطاء",
@@ -1074,14 +1064,11 @@
"filterBy": "تصفية حسب:",
"filterByType": "تصفية حسب {type}...",
"findIssues": "العثور على مشاكل",
"findOnGithub": "ابحث في GitHub",
"frameNodes": "تأطير العقد",
"frontendNewer": "إصدار الواجهة الأمامية {frontendVersion} قد لا يكون متوافقاً مع الإصدار الخلفي {backendVersion}.",
"frontendOutdated": "إصدار الواجهة الأمامية {frontendVersion} قديم. يتطلب الإصدار الخلفي {requiredVersion} أو أحدث.",
"gallery": "المعرض",
"galleryImage": "صورة المعرض",
"galleryThumbnail": "صورة مصغرة للمعرض",
"getHelpAction": "الحصول على المساعدة",
"goToNode": "الانتقال إلى العقدة",
"graphNavigation": "التنقل في الرسم البياني",
"halfSpeed": "0.5x",
@@ -1090,7 +1077,6 @@
"icon": "أيقونة",
"imageDoesNotExist": "الصورة غير موجودة",
"imageFailedToLoad": "فشل تحميل الصورة",
"imageLightbox": "معاينة الصورة",
"imagePreview": "معاينة الصورة - استخدم مفاتيح الأسهم للتنقل بين الصور",
"imageUrl": "رابط الصورة",
"import": "استيراد",
@@ -1111,32 +1097,6 @@
"jobIdCopied": "تم نسخ معرف المهمة إلى الحافظة",
"keybinding": "اختصار لوحة المفاتيح",
"keybindingAlreadyExists": "الاختصار موجود بالفعل في",
"keybindingPresets": {
"default": "الإعداد المسبق الافتراضي",
"deletePreset": "حذف الإعداد المسبق",
"deletePresetFailed": "فشل في حذف الإعداد المسبق \"{name}\"",
"deletePresetTitle": "حذف الإعداد المسبق الحالي؟",
"deletePresetWarning": "سيتم حذف هذا الإعداد المسبق. لا يمكن التراجع عن ذلك.",
"discardAndSwitch": "تجاهل والانتقال",
"exportPreset": "تصدير الإعداد المسبق",
"importKeybindingPreset": "استيراد إعداد مفاتيح الاختصار",
"importPreset": "استيراد الإعداد المسبق",
"invalidPresetFile": "يجب أن يكون ملف الإعداد المسبق ملف JSON صالح تم تصديره من ComfyUI",
"invalidPresetName": "يجب ألا يكون اسم الإعداد المسبق فارغًا أو \"default\" أو يبدأ بنقطة أو يحتوي على فواصل مسار أو ينتهي بـ .json",
"loadPresetFailed": "فشل في تحميل الإعداد المسبق \"{name}\"",
"overwritePresetMessage": "يوجد إعداد مسبق باسم \"{name}\" بالفعل. هل تريد استبداله؟",
"overwritePresetTitle": "استبدال الإعداد المسبق",
"presetDeleted": "تم حذف الإعداد المسبق \"{name}\"",
"presetImported": "تم استيراد إعداد مفاتيح الاختصار",
"presetNamePrompt": "أدخل اسمًا للإعداد المسبق",
"presetSaved": "تم حفظ الإعداد المسبق \"{name}\"",
"resetToDefault": "إعادة التعيين إلى الافتراضي",
"saveAndSwitch": "حفظ والانتقال",
"saveAsNewPreset": "حفظ كإعداد مخصص جديد",
"saveChanges": "حفظ التغييرات",
"unsavedChangesMessage": "لديك تغييرات غير محفوظة ستفقد إذا انتقلت دون حفظ.",
"unsavedChangesTo": "تغييرات غير محفوظة على {name}"
},
"keybindings": "اختصارات لوحة المفاتيح",
"learnMore": "اعرف المزيد",
"listening": "جاري الاستماع...",
@@ -1193,8 +1153,6 @@
"output": "إخراج",
"overwrite": "الكتابة فوق",
"partner": "شريك",
"pause": "إيقاف مؤقت",
"play": "تشغيل",
"playPause": "تشغيل/إيقاف مؤقت",
"playRecording": "تشغيل التسجيل",
"playbackSpeed": "سرعة التشغيل",
@@ -1202,7 +1160,6 @@
"preloadError": "فشل تحميل مورد مطلوب. يرجى إعادة تحميل الصفحة.",
"preloadErrorTitle": "خطأ في التحميل",
"preview": "معاينة",
"previous": "السابق",
"previousImage": "الصورة السابقة",
"profile": "الملف الشخصي",
"progressCountOf": "من",
@@ -1277,8 +1234,6 @@
"showReport": "عرض التقرير",
"showRightPanel": "إظهار اللوحة اليمنى",
"singleSelectDropdown": "قائمة منسدلة اختيار واحد",
"skipToEnd": "الانتقال للنهاية",
"skipToStart": "الانتقال للبداية",
"sort": "فرز",
"source": "المصدر",
"startRecording": "بدء التسجيل",
@@ -1877,7 +1832,6 @@
"mirrorVertical": "انعكاس عمودي",
"negative": "سلبي",
"opacity": "الشفافية",
"openMaskEditor": "فتح في محرر القناع",
"paintBucketSettings": "إعدادات دلو الطلاء",
"paintLayer": "طبقة الطلاء",
"redo": "إعادة",
@@ -2192,7 +2146,6 @@
"Moonvalley Marey": "مون فالي ماري",
"OpenAI": "OpenAI",
"PixVerse": "PixVerse",
"Quiver": "Quiver",
"Recraft": "Recraft",
"Reve": "Reve",
"Rodin": "رودان",
@@ -2468,8 +2421,6 @@
"favoritesNoneDesc": "ستظهر المدخلات التي تضعها في المفضلة هنا",
"favoritesNoneHint": "في علامة تبويب المعلمات، انقر على {moreIcon} بجانب أي إدخال لإضافته هنا",
"favoritesNoneTooltip": "قم بوضع نجمة على الأدوات للوصول السريع إليها دون اختيار العقد",
"findOnGithubTooltip": "ابحث في مشكلات GitHub عن مشاكل مشابهة",
"getHelpTooltip": "أبلغ عن هذا الخطأ وسنساعدك في حله",
"globalSettings": {
"canvas": "اللوحة",
"connectionLinks": "روابط الاتصال",
@@ -3122,6 +3073,7 @@
"title": "تم إلغاء اشتراكك"
},
"changeTo": "تغيير إلى {plan}",
"chooseBestPlanWorkspace": "اختر أفضل خطة لمساحة العمل الخاصة بك",
"comfyCloud": "Comfy Cloud",
"comfyCloudLogo": "شعار Comfy Cloud",
"contactOwnerToSubscribe": "يرجى التواصل مع مالك مساحة العمل للاشتراك",
@@ -3151,7 +3103,6 @@
},
"gpuLabel": "RTX 6000 Pro (ذاكرة 96GB VRAM)",
"haveQuestions": "هل لديك أسئلة أو ترغب في معرفة المزيد عن المؤسسات؟",
"inviteUpTo": "ادعُ حتى",
"invoiceHistory": "سجل الفواتير",
"learnMore": "معرفة المزيد",
"managePayment": "إدارة الدفع",
@@ -3177,16 +3128,13 @@
"monthlyCreditsPerMemberLabel": "الرصيد الشهري / عضو",
"monthlyCreditsRollover": "سيتم ترحيل هذا الرصيد إلى الشهر التالي",
"mostPopular": "الأكثر شيوعًا",
"needTeamWorkspace": "هل تحتاج إلى مساحة عمل للفريق؟",
"nextBillingCycle": "دورة الفوترة التالية",
"nextMonthInvoice": "فاتورة الشهر القادم",
"partnerNodesBalance": "رصيد \"عُقَد الشريك\"",
"partnerNodesCredits": "رصيد العقد الشريكة",
"partnerNodesDescription": "لتشغيل النماذج التجارية/المملوكة",
"perMonth": "دولار أمريكي / شهر",
"personalWorkspace": "مساحة العمل الشخصية",
"plansAndPricing": "الخطط والأسعار",
"plansForWorkspace": "الخطط لمساحة العمل {workspace}",
"prepaidCreditsInfo": "رصيد تم شراؤه بشكل منفصل ولا ينتهي صلاحيته",
"prepaidDescription": "رصيد مسبق الدفع",
"preview": {
@@ -3222,7 +3170,6 @@
"resubscribe": "إعادة الاشتراك",
"resubscribeSuccess": "تمت إعادة تفعيل الاشتراك بنجاح",
"resubscribeTo": "إعادة الاشتراك في {plan}",
"soloUseOnly": "للاستخدام الفردي فقط",
"subscribeForMore": "ترقية",
"subscribeNow": "اشترك الآن",
"subscribeTo": "اشترك في {plan}",
@@ -3230,7 +3177,6 @@
"subscribeToRun": "اشتراك",
"subscribeToRunFull": "الاشتراك للتشغيل",
"subscriptionRequiredMessage": "الاشتراك مطلوب للأعضاء لتشغيل سير العمل على السحابة",
"teamWorkspace": "مساحة عمل الفريق",
"tierNameYearly": "{name} سنوي",
"tiers": {
"creator": {
@@ -3282,18 +3228,6 @@
"duplicateTab": "تكرار التبويب",
"removeFromBookmarks": "إزالة من العلامات"
},
"teamWorkspacesDialog": {
"confirmCallbackFailed": "تم إنشاء مساحة العمل لكن الإعداد غير مكتمل",
"createWorkspace": "إنشاء مساحة عمل",
"namePlaceholder": "مثال: فريق التسويق",
"nameValidationError": "يجب أن يكون الاسم من ١ إلى ٥٠ حرفًا باستخدام الحروف أو الأرقام أو المسافات أو علامات الترقيم الشائعة.",
"newWorkspace": "مساحة عمل جديدة",
"subtitle": "انتقل إلى مساحة عمل موجودة أو أنشئ مساحة عمل جديدة",
"subtitleNoWorkspaces": "أنشئ مساحة عمل فريق جديدة لمشاركة الرصيد",
"switch": "تبديل",
"title": "مساحات عمل الفريق",
"yourTeamWorkspaces": "مساحات عمل فريقك"
},
"templateWidgets": {
"sort": {
"searchPlaceholder": "بحث..."
@@ -3680,7 +3614,6 @@
},
"workspaceSwitcher": {
"createWorkspace": "إنشاء مساحة عمل جديدة",
"failedToSwitch": "فشل في تبديل مساحة العمل",
"maxWorkspacesReached": "يمكنك امتلاك ١٠ مساحات عمل فقط. احذف واحدة لإنشاء مساحة جديدة.",
"personal": "شخصي",
"roleMember": "عضو",

View File

@@ -1736,10 +1736,6 @@
"name": "closed_loop",
"tooltip": "ما إذا كان سيتم إغلاق حلقة نافذة السياق؛ تنطبق فقط على الجداول الحلقية."
},
"cond_retain_index_list": {
"name": "cond_retain_index_list",
"tooltip": "قائمة مؤشرات latent التي سيتم الاحتفاظ بها في موترات التكييف لكل نافذة. على سبيل المثال، تعيين هذه القيمة إلى '0' سيستخدم صورة البداية الأولية لكل نافذة."
},
"context_length": {
"name": "context_length",
"tooltip": "طول نافذة السياق."
@@ -1771,10 +1767,6 @@
"model": {
"name": "model",
"tooltip": "النموذج المراد تطبيق نوافذ السياق عليه أثناء أخذ العينات."
},
"split_conds_to_windows": {
"name": "split_conds_to_windows",
"tooltip": "هل تريد تقسيم التكييفات المتعددة (التي تم إنشاؤها بواسطة ConditionCombine) إلى كل نافذة بناءً على مؤشر المنطقة."
}
},
"outputs": {
@@ -3823,10 +3815,6 @@
},
"1": {
"tooltip": null
},
"2": {
"name": "thought_image",
"tooltip": "الصورة الأولى من عملية تفكير النموذج. متوفرة فقط عند مستوى التفكير العالي ونمط IMAGE+TEXT."
}
}
},
@@ -11961,91 +11949,6 @@
}
}
},
"QuiverImageToSVGNode": {
"description": "تحويل صورة نقطية إلى SVG باستخدام Quiver AI.",
"display_name": "Quiver تحويل صورة إلى SVG",
"inputs": {
"auto_crop": {
"name": "auto_crop",
"tooltip": "قص تلقائي للعنصر الرئيسي في الصورة."
},
"control_after_generate": {
"name": "control after generate"
},
"image": {
"name": "image",
"tooltip": "الصورة المدخلة لتحويلها إلى متجهات."
},
"model": {
"name": "model",
"tooltip": "النموذج المستخدم لتحويل الصورة إلى SVG."
},
"model_presence_penalty": {
"name": "presence_penalty"
},
"model_target_size": {
"name": "target_size"
},
"model_temperature": {
"name": "temperature"
},
"model_top_p": {
"name": "top_p"
},
"seed": {
"name": "seed",
"tooltip": "البذرة لتحديد ما إذا كان يجب إعادة تشغيل العقدة؛ النتائج الفعلية غير حتمية بغض النظر عن البذرة."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"QuiverTextToSVGNode": {
"description": "إنشاء SVG من وصف نصي باستخدام Quiver AI.",
"display_name": "Quiver تحويل نص إلى SVG",
"inputs": {
"control_after_generate": {
"name": "control after generate"
},
"instructions": {
"name": "instructions",
"tooltip": "إرشادات إضافية حول الأسلوب أو التنسيق."
},
"model": {
"name": "model",
"tooltip": "النموذج المستخدم لإنشاء SVG."
},
"model_presence_penalty": {
"name": "presence_penalty"
},
"model_temperature": {
"name": "temperature"
},
"model_top_p": {
"name": "top_p"
},
"prompt": {
"name": "prompt",
"tooltip": "وصف نصي لمخرجات SVG المطلوبة."
},
"reference_images": {
"name": "reference_images",
"tooltip": "حتى ٤ صور مرجعية لتوجيه عملية الإنشاء."
},
"seed": {
"name": "seed",
"tooltip": "البذرة لتحديد ما إذا كان يجب إعادة تشغيل العقدة؛ النتائج الفعلية غير حتمية بغض النظر عن البذرة."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"QwenImageDiffsynthControlnet": {
"display_name": "QwenImageDiffsynthControlnet",
"inputs": {

View File

@@ -25,10 +25,6 @@
},
"tooltip": "مخصص: استبدال شريط عنوان النظام بالقائمة العلوية لـ ComfyUI"
},
"Comfy_Appearance_DisableAnimations": {
"name": "تعطيل الرسوم المتحركة",
"tooltip": "يقوم بإيقاف معظم الرسوم المتحركة والانتقالات في CSS. يسرّع الاستدلال عندما يتم استخدام وحدة معالجة الرسوميات للعرض أيضًا في التوليد."
},
"Comfy_Canvas_BackgroundImage": {
"name": "صورة خلفية اللوحة",
"tooltip": "رابط صورة لخلفية اللوحة. يمكنك النقر بزر الفأرة الأيمن على صورة في لوحة النتائج واختيار \"تعيين كخلفية\" لاستخدامها، أو رفع صورتك الخاصة باستخدام زر الرفع."
@@ -99,10 +95,6 @@
"name": "عدد أرقام التقريب العشرية لأدوات التحكم العائمة [0 = تلقائي]",
"tooltip": "(يتطلب إعادة تحميل الصفحة)"
},
"Comfy_Graph_AutoPanSpeed": {
"name": "سرعة التحريك التلقائي",
"tooltip": "السرعة القصوى عند التحريك التلقائي بسحب المؤشر إلى حافة اللوحة. اضبطها على 0 لتعطيل التحريك التلقائي."
},
"Comfy_Graph_CanvasInfo": {
"name": "عرض معلومات اللوحة في الزاوية السفلى اليسرى (الإطارات في الثانية، إلخ)"
},
@@ -462,6 +454,9 @@
"Comfy_Workflow_ShowMissingModelsWarning": {
"name": "عرض تحذير النماذج المفقودة"
},
"Comfy_Workflow_ShowMissingNodesWarning": {
"name": "عرض تحذير العقد المفقودة"
},
"Comfy_Workflow_SortNodeIdOnSave": {
"name": "ترتيب معرفات العقد عند حفظ سير العمل"
},

View File

@@ -34,8 +34,6 @@
"imageLightbox": "Image preview",
"imagePreview": "Image preview - Use arrow keys to navigate between images",
"videoPreview": "Video preview - Use arrow keys to navigate between videos",
"viewGrid": "Grid view",
"imageGallery": "image gallery",
"galleryImage": "Gallery image",
"galleryThumbnail": "Gallery thumbnail",
"previousImage": "Previous image",
@@ -207,7 +205,6 @@
"filterByType": "Filter by {type}...",
"mostRelevant": "Most relevant",
"favorites": "Favorites",
"bookmarked": "Bookmarked",
"essentials": "Essentials",
"input": "Input",
"output": "Output",
@@ -279,7 +276,8 @@
"clearAll": "Clear all",
"copyURL": "Copy URL",
"releaseTitle": "{package} {version} Release",
"itemsSelected": "No items selected | {count} item selected | {count} items selected",
"itemSelected": "{selectedCount} item selected",
"itemsSelected": "{selectedCount} items selected",
"multiSelectDropdown": "Multi-select dropdown",
"singleSelectDropdown": "Single-select dropdown",
"progressCountOf": "of",
@@ -367,8 +365,6 @@
"preloadErrorTitle": "Loading Error",
"recents": "Recents",
"partner": "Partner",
"blueprints": "Blueprints",
"partnerNodes": "Partner Nodes",
"collapseAll": "Collapse all",
"expandAll": "Expand all"
},
@@ -1724,7 +1720,6 @@
"photomaker": "photomaker",
"PixVerse": "PixVerse",
"primitive": "primitive",
"Quiver": "Quiver",
"Recraft": "Recraft",
"edit_models": "edit_models",
"Reve": "Reve",
@@ -1889,9 +1884,7 @@
"loadWorkflowTitle": "Loading aborted due to error reloading workflow data",
"noStackTrace": "No stacktrace available",
"extensionFileHint": "This may be due to the following script",
"promptExecutionError": "Prompt execution failed",
"accessRestrictedTitle": "Access Restricted",
"accessRestrictedMessage": "Your account is not authorized for this feature."
"promptExecutionError": "Prompt execution failed"
},
"apiNodesSignInDialog": {
"title": "Sign In Required to Use API Nodes",
@@ -2296,12 +2289,7 @@
"topupTimeout": "Top-up verification timed out"
},
"subscription": {
"plansForWorkspace": "Plans for {workspace}",
"personalWorkspace": "Personal Workspace",
"teamWorkspace": "Team Workspace",
"soloUseOnly": "Solo use only",
"needTeamWorkspace": "Need team workspace?",
"inviteUpTo": "Invite up to",
"chooseBestPlanWorkspace": "Choose the best plan for your workspace",
"title": "Subscription",
"titleUnsubscribed": "Subscribe to Comfy Cloud",
"comfyCloud": "Comfy Cloud",
@@ -2613,18 +2601,6 @@
"failedToFetchWorkspaces": "Failed to load workspaces"
}
},
"teamWorkspacesDialog": {
"title": "Team Workspaces",
"subtitle": "Switch to an existing one or create a new workspace",
"subtitleNoWorkspaces": "Create a new team workspace to share credits",
"confirmCallbackFailed": "Workspace created but setup incomplete",
"yourTeamWorkspaces": "Your team workspaces",
"switch": "Switch",
"newWorkspace": "New workspace",
"namePlaceholder": "e.g. Marketing Team",
"createWorkspace": "Create workspace",
"nameValidationError": "Name must be 150 characters using letters, numbers, spaces, or common punctuation."
},
"workspaceSwitcher": {
"switchWorkspace": "Switch workspace",
"subscribe": "Subscribe",
@@ -2632,8 +2608,7 @@
"roleOwner": "Owner",
"roleMember": "Member",
"createWorkspace": "Create new workspace",
"maxWorkspacesReached": "You can only own 10 workspaces. Delete one to create a new one.",
"failedToSwitch": "Failed to switch workspace"
"maxWorkspacesReached": "You can only own 10 workspaces. Delete one to create a new one."
},
"selectionToolbox": {
"executeButton": {
@@ -3709,18 +3684,5 @@
"footer": "ComfyUI stays free and open source. Cloud is optional.",
"continueLocally": "Continue Locally",
"exploreCloud": "Try Cloud for Free"
},
"execution": {
"generating": "Generating…",
"saving": "Saving…",
"loading": "Loading…",
"encoding": "Encoding…",
"decoding": "Decoding…",
"processing": "Processing…",
"resizing": "Resizing…",
"generatingVideo": "Generating video…",
"training": "Training…",
"processingVideo": "Processing video…",
"running": "Running…"
}
}

View File

@@ -1767,14 +1767,6 @@
"freenoise": {
"name": "freenoise",
"tooltip": "Whether to apply FreeNoise noise shuffling, improves window blending."
},
"cond_retain_index_list": {
"name": "cond_retain_index_list",
"tooltip": "List of latent indices to retain in the conditioning tensors for each window, for example setting this to '0' will use the initial start image for each window."
},
"split_conds_to_windows": {
"name": "split_conds_to_windows",
"tooltip": "Whether to split multiple conditionings (created by ConditionCombine) to each window based on region index."
}
},
"outputs": {
@@ -3723,10 +3715,6 @@
},
"1": {
"tooltip": null
},
"2": {
"name": "thought_image",
"tooltip": "First image from the model's thinking process. Only available with thinking_level HIGH and IMAGE+TEXT modality."
}
}
},
@@ -11961,91 +11949,6 @@
}
}
},
"QuiverImageToSVGNode": {
"display_name": "Quiver Image to SVG",
"description": "Vectorize a raster image into SVG using Quiver AI.",
"inputs": {
"image": {
"name": "image",
"tooltip": "Input image to vectorize."
},
"auto_crop": {
"name": "auto_crop",
"tooltip": "Automatically crop to the dominant subject."
},
"model": {
"name": "model",
"tooltip": "Model to use for SVG vectorization."
},
"seed": {
"name": "seed",
"tooltip": "Seed to determine if node should re-run; actual results are nondeterministic regardless of seed."
},
"control_after_generate": {
"name": "control after generate"
},
"model_presence_penalty": {
"name": "presence_penalty"
},
"model_target_size": {
"name": "target_size"
},
"model_temperature": {
"name": "temperature"
},
"model_top_p": {
"name": "top_p"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"QuiverTextToSVGNode": {
"display_name": "Quiver Text to SVG",
"description": "Generate an SVG from a text prompt using Quiver AI.",
"inputs": {
"prompt": {
"name": "prompt",
"tooltip": "Text description of the desired SVG output."
},
"model": {
"name": "model",
"tooltip": "Model to use for SVG generation."
},
"seed": {
"name": "seed",
"tooltip": "Seed to determine if node should re-run; actual results are nondeterministic regardless of seed."
},
"instructions": {
"name": "instructions",
"tooltip": "Additional style or formatting guidance."
},
"reference_images": {
"name": "reference_images",
"tooltip": "Up to 4 reference images to guide the generation."
},
"control_after_generate": {
"name": "control after generate"
},
"model_presence_penalty": {
"name": "presence_penalty"
},
"model_temperature": {
"name": "temperature"
},
"model_top_p": {
"name": "top_p"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"QwenImageDiffsynthControlnet": {
"display_name": "QwenImageDiffsynthControlnet",
"inputs": {

View File

@@ -25,10 +25,6 @@
"custom": "custom"
}
},
"Comfy_Appearance_DisableAnimations": {
"name": "Disable animations",
"tooltip": "Turns off most CSS animations and transitions. Speeds up inference when the display GPU is also used for generation."
},
"Comfy_Canvas_BackgroundImage": {
"name": "Canvas background image",
"tooltip": "Image URL for the canvas background. You can right-click an image in the outputs panel and select \"Set as Background\" to use it, or upload your own image using the upload button."
@@ -99,10 +95,6 @@
"name": "Float widget rounding decimal places [0 = auto].",
"tooltip": "(requires page reload)"
},
"Comfy_Graph_AutoPanSpeed": {
"name": "Auto-pan speed",
"tooltip": "Maximum speed when auto-panning by dragging to the canvas edge. Set to 0 to disable auto-panning."
},
"Comfy_Graph_CanvasInfo": {
"name": "Show canvas info on bottom left corner (fps, etc.)"
},
@@ -128,6 +120,10 @@
"name": "Live selection",
"tooltip": "When enabled, nodes are selected/deselected in real-time as you drag the selection rectangle, similar to other design tools."
},
"Comfy_Graph_AutoPanSpeed": {
"name": "Auto-pan speed",
"tooltip": "Maximum speed when auto-panning by dragging to the canvas edge. Set to 0 to disable auto-panning."
},
"Comfy_Graph_ZoomSpeed": {
"name": "Canvas zoom speed"
},
@@ -313,7 +309,8 @@
"tooltip": "Only applies to the default implementation"
},
"Comfy_NodeSearchBoxImpl_ShowCategory": {
"name": "Show node category in search results"
"name": "Show node category in search results",
"tooltip": "Only applies to v1 (legacy)"
},
"Comfy_NodeSearchBoxImpl_ShowIdName": {
"name": "Show node id name in search results",

View File

@@ -15,14 +15,6 @@
"message": "Este flujo de trabajo contiene nodos de API, que requieren que inicies sesión en tu cuenta para poder ejecutar.",
"title": "Se requiere iniciar sesión para usar los nodos de API"
},
"appBuilder": {
"vueNodeSwitch": {
"content": "Para la mejor experiencia, el constructor de aplicaciones utiliza Nodes 2.0. Puedes volver después de construir la aplicación desde el menú principal.",
"dismiss": "Descartar",
"dontShowAgain": "No mostrar de nuevo",
"title": "Cambiado a Nodes 2.0"
}
},
"assetBrowser": {
"allCategory": "Todo {category}",
"allModels": "Todos los modelos",
@@ -888,9 +880,7 @@
"extensionFileHint": "Esto puede deberse al siguiente script",
"loadWorkflowTitle": "La carga se interrumpió debido a un error al recargar los datos del flujo de trabajo",
"noStackTrace": "No hay seguimiento de pila disponible",
"promptExecutionError": "La ejecución del prompt falló",
"accessRestrictedTitle": "Access Restricted",
"accessRestrictedMessage": "Your account is not authorized for this feature."
"promptExecutionError": "La ejecución del prompt falló"
},
"errorOverlay": {
"errorCount": "{count} ERROR | {count} ERRORES",
@@ -1074,14 +1064,11 @@
"filterBy": "Filtrar por:",
"filterByType": "Filtrar por {type}...",
"findIssues": "Encontrar problemas",
"findOnGithub": "Buscar en GitHub",
"frameNodes": "Enmarcar Nodos",
"frontendNewer": "La versión del frontend {frontendVersion} puede no ser compatible con la versión del backend {backendVersion}.",
"frontendOutdated": "La versión del frontend {frontendVersion} está desactualizada. El backend requiere la versión {requiredVersion} o superior.",
"gallery": "Galería",
"galleryImage": "Imagen de galería",
"galleryThumbnail": "Miniatura de galería",
"getHelpAction": "Obtener ayuda",
"goToNode": "Ir al nodo",
"graphNavigation": "Navegación de gráficos",
"halfSpeed": "0.5x",
@@ -1090,7 +1077,6 @@
"icon": "Icono",
"imageDoesNotExist": "La imagen no existe",
"imageFailedToLoad": "Falló la carga de la imagen",
"imageLightbox": "Vista previa de imagen",
"imagePreview": "Vista previa de imagen - Usa las teclas de flecha para navegar entre imágenes",
"imageUrl": "URL de la imagen",
"import": "Importar",
@@ -1111,32 +1097,6 @@
"jobIdCopied": "ID de trabajo copiado al portapapeles",
"keybinding": "Combinación de teclas",
"keybindingAlreadyExists": "La combinación de teclas ya existe en",
"keybindingPresets": {
"default": "Preajuste predeterminado",
"deletePreset": "Eliminar preajuste",
"deletePresetFailed": "No se pudo eliminar el preajuste \"{name}\"",
"deletePresetTitle": "¿Eliminar el preajuste actual?",
"deletePresetWarning": "Este preajuste será eliminado. Esto no se puede deshacer.",
"discardAndSwitch": "Descartar y cambiar",
"exportPreset": "Exportar preajuste",
"importKeybindingPreset": "Importar preajuste de atajos",
"importPreset": "Importar preajuste",
"invalidPresetFile": "El archivo de preajuste debe ser un JSON válido exportado desde ComfyUI",
"invalidPresetName": "El nombre del preajuste no debe estar vacío, ser \"default\", comenzar con un punto, contener separadores de ruta o terminar en .json",
"loadPresetFailed": "No se pudo cargar el preajuste \"{name}\"",
"overwritePresetMessage": "Ya existe un preajuste llamado \"{name}\". ¿Sobrescribirlo?",
"overwritePresetTitle": "Sobrescribir preajuste",
"presetDeleted": "Preajuste \"{name}\" eliminado",
"presetImported": "Preajuste de atajos importado",
"presetNamePrompt": "Introduce un nombre para el preajuste",
"presetSaved": "Preajuste \"{name}\" guardado",
"resetToDefault": "Restablecer a predeterminado",
"saveAndSwitch": "Guardar y cambiar",
"saveAsNewPreset": "Guardar como nuevo preajuste",
"saveChanges": "Guardar cambios",
"unsavedChangesMessage": "Tienes cambios no guardados que se perderán si cambias sin guardar.",
"unsavedChangesTo": "Cambios no guardados en {name}"
},
"keybindings": "Atajos de teclado",
"learnMore": "Aprende más",
"listening": "Escuchando...",
@@ -1193,8 +1153,6 @@
"output": "Salida",
"overwrite": "Sobrescribir",
"partner": "Socio",
"pause": "Pausar",
"play": "Reproducir",
"playPause": "Reproducir/Pausar",
"playRecording": "Reproducir grabación",
"playbackSpeed": "Velocidad de reproducción",
@@ -1202,7 +1160,6 @@
"preloadError": "No se pudo cargar un recurso necesario. Por favor, recarga la página.",
"preloadErrorTitle": "Error de carga",
"preview": "VISTA PREVIA",
"previous": "Anterior",
"previousImage": "Imagen anterior",
"profile": "Perfil",
"progressCountOf": "de",
@@ -1277,8 +1234,6 @@
"showReport": "Mostrar informe",
"showRightPanel": "Mostrar panel derecho",
"singleSelectDropdown": "Menú desplegable de selección única",
"skipToEnd": "Ir al final",
"skipToStart": "Ir al inicio",
"sort": "Ordenar",
"source": "Fuente",
"startRecording": "Iniciar grabación",
@@ -1877,7 +1832,6 @@
"mirrorVertical": "Espejar verticalmente",
"negative": "Negativo",
"opacity": "Opacidad",
"openMaskEditor": "Abrir en el editor de máscaras",
"paintBucketSettings": "Configuración del bote de pintura",
"paintLayer": "Capa de pintura",
"redo": "Rehacer",
@@ -2192,7 +2146,6 @@
"Moonvalley Marey": "Moonvalley Marey",
"OpenAI": "OpenAI",
"PixVerse": "PixVerse",
"Quiver": "Quiver",
"Recraft": "Recraft",
"Reve": "Reve",
"Rodin": "Rodin",
@@ -2468,8 +2421,6 @@
"favoritesNoneDesc": "Las entradas que marques como favoritas aparecerán aquí",
"favoritesNoneHint": "En la pestaña Parámetros, haz clic en {moreIcon} en cualquier entrada para añadirla aquí",
"favoritesNoneTooltip": "Marca widgets con estrella para acceder rápidamente sin seleccionar nodos",
"findOnGithubTooltip": "Buscar problemas relacionados en GitHub",
"getHelpTooltip": "Informa de este error y te ayudaremos a resolverlo",
"globalSettings": {
"canvas": "LIENZO",
"connectionLinks": "ENLACES DE CONEXIÓN",
@@ -3122,6 +3073,7 @@
"title": "Tu suscripción ha sido cancelada"
},
"changeTo": "Cambiar a {plan}",
"chooseBestPlanWorkspace": "Elige el mejor plan para tu espacio de trabajo",
"comfyCloud": "Comfy Cloud",
"comfyCloudLogo": "Logo de Comfy Cloud",
"contactOwnerToSubscribe": "Contacta al propietario del espacio de trabajo para suscribirte",
@@ -3151,7 +3103,6 @@
},
"gpuLabel": "RTX 6000 Pro (96GB VRAM)",
"haveQuestions": "¿Tienes preguntas o buscas soluciones empresariales?",
"inviteUpTo": "Invita hasta",
"invoiceHistory": "Historial de facturas",
"learnMore": "Más información",
"managePayment": "Gestionar pago",
@@ -3177,16 +3128,13 @@
"monthlyCreditsPerMemberLabel": "Créditos mensuales / miembro",
"monthlyCreditsRollover": "Estos créditos se transferirán al próximo mes",
"mostPopular": "Más popular",
"needTeamWorkspace": "¿Necesitas un espacio de trabajo en equipo?",
"nextBillingCycle": "próximo ciclo de facturación",
"nextMonthInvoice": "Factura del próximo mes",
"partnerNodesBalance": "Saldo de créditos de \"Nodos de Partners\"",
"partnerNodesCredits": "Créditos de Nodos de Socio",
"partnerNodesDescription": "Para ejecutar modelos comerciales/propietarios",
"perMonth": "USD / mes",
"personalWorkspace": "Espacio de trabajo personal",
"plansAndPricing": "Planes y precios",
"plansForWorkspace": "Planes para {workspace}",
"prepaidCreditsInfo": "Créditos comprados por separado que no expiran",
"prepaidDescription": "Créditos prepagados",
"preview": {
@@ -3222,7 +3170,6 @@
"resubscribe": "Volver a suscribirse",
"resubscribeSuccess": "¡Suscripción reactivada correctamente!",
"resubscribeTo": "Volver a suscribirse a {plan}",
"soloUseOnly": "Solo para uso individual",
"subscribeForMore": "Mejorar",
"subscribeNow": "Suscribirse Ahora",
"subscribeTo": "Suscribirse a {plan}",
@@ -3230,7 +3177,6 @@
"subscribeToRun": "Suscribirse",
"subscribeToRunFull": "Suscribirse a Ejecutar",
"subscriptionRequiredMessage": "Se requiere una suscripción para que los miembros ejecuten flujos de trabajo en la nube",
"teamWorkspace": "Espacio de trabajo en equipo",
"tierNameYearly": "{name} Anual",
"tiers": {
"creator": {
@@ -3282,18 +3228,6 @@
"duplicateTab": "Duplicar pestaña",
"removeFromBookmarks": "Eliminar de marcadores"
},
"teamWorkspacesDialog": {
"confirmCallbackFailed": "Espacio de trabajo creado pero la configuración está incompleta",
"createWorkspace": "Crear espacio de trabajo",
"namePlaceholder": "ej. Equipo de Marketing",
"nameValidationError": "El nombre debe tener entre 1 y 50 caracteres usando letras, números, espacios o signos de puntuación comunes.",
"newWorkspace": "Nuevo espacio de trabajo",
"subtitle": "Cambia a uno existente o crea un nuevo espacio de trabajo",
"subtitleNoWorkspaces": "Crea un nuevo espacio de trabajo en equipo para compartir créditos",
"switch": "Cambiar",
"title": "Espacios de trabajo en equipo",
"yourTeamWorkspaces": "Tus espacios de trabajo en equipo"
},
"templateWidgets": {
"sort": {
"searchPlaceholder": "Buscar..."
@@ -3680,7 +3614,6 @@
},
"workspaceSwitcher": {
"createWorkspace": "Crear nuevo espacio de trabajo",
"failedToSwitch": "No se pudo cambiar el espacio de trabajo",
"maxWorkspacesReached": "Solo puedes ser propietario de 10 espacios de trabajo. Elimina uno para crear uno nuevo.",
"personal": "Personal",
"roleMember": "Miembro",

View File

@@ -1736,10 +1736,6 @@
"name": "bucle_cerrado",
"tooltip": "Si se debe cerrar el bucle de la ventana de contexto; solo aplicable a programaciones en bucle."
},
"cond_retain_index_list": {
"name": "cond_retain_index_list",
"tooltip": "Lista de índices latentes que se conservarán en los tensores de condicionamiento para cada ventana; por ejemplo, si se establece en '0', se usará la imagen inicial de inicio para cada ventana."
},
"context_length": {
"name": "longitud_contexto",
"tooltip": "La longitud de la ventana de contexto."
@@ -1771,10 +1767,6 @@
"model": {
"name": "modelo",
"tooltip": "El modelo al que aplicar ventanas de contexto durante el muestreo."
},
"split_conds_to_windows": {
"name": "split_conds_to_windows",
"tooltip": "Indica si se deben dividir múltiples condicionamientos (creados por ConditionCombine) en cada ventana según el índice de la región."
}
},
"outputs": {
@@ -3823,10 +3815,6 @@
},
"1": {
"tooltip": null
},
"2": {
"name": "thought_image",
"tooltip": "Primera imagen del proceso de pensamiento del modelo. Solo disponible con nivel de pensamiento ALTO y modalidad IMAGEN+TEXTO."
}
}
},
@@ -11961,91 +11949,6 @@
}
}
},
"QuiverImageToSVGNode": {
"description": "Vectoriza una imagen ráster a SVG usando Quiver AI.",
"display_name": "Quiver Imagen a SVG",
"inputs": {
"auto_crop": {
"name": "recorte_automático",
"tooltip": "Recorta automáticamente al sujeto dominante."
},
"control_after_generate": {
"name": "control después de generar"
},
"image": {
"name": "imagen",
"tooltip": "Imagen de entrada para vectorizar."
},
"model": {
"name": "modelo",
"tooltip": "Modelo a utilizar para la vectorización SVG."
},
"model_presence_penalty": {
"name": "penalización_de_presencia"
},
"model_target_size": {
"name": "tamaño_objetivo"
},
"model_temperature": {
"name": "temperatura"
},
"model_top_p": {
"name": "top_p"
},
"seed": {
"name": "semilla",
"tooltip": "Semilla para determinar si el nodo debe ejecutarse de nuevo; los resultados reales son no deterministas independientemente de la semilla."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"QuiverTextToSVGNode": {
"description": "Genera un SVG a partir de un prompt de texto usando Quiver AI.",
"display_name": "Quiver Texto a SVG",
"inputs": {
"control_after_generate": {
"name": "control después de generar"
},
"instructions": {
"name": "instrucciones",
"tooltip": "Guía adicional de estilo o formato."
},
"model": {
"name": "modelo",
"tooltip": "Modelo a utilizar para la generación de SVG."
},
"model_presence_penalty": {
"name": "penalización_de_presencia"
},
"model_temperature": {
"name": "temperatura"
},
"model_top_p": {
"name": "top_p"
},
"prompt": {
"name": "prompt",
"tooltip": "Descripción en texto del SVG deseado."
},
"reference_images": {
"name": "imágenes_de_referencia",
"tooltip": "Hasta 4 imágenes de referencia para guiar la generación."
},
"seed": {
"name": "semilla",
"tooltip": "Semilla para determinar si el nodo debe ejecutarse de nuevo; los resultados reales son no deterministas independientemente de la semilla."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"QwenImageDiffsynthControlnet": {
"display_name": "QwenImageDiffsynthControlnet",
"inputs": {

View File

@@ -25,10 +25,6 @@
},
"tooltip": "Personalizado: Reemplace la barra de título del sistema con el menú superior de ComfyUI"
},
"Comfy_Appearance_DisableAnimations": {
"name": "Desactivar animaciones",
"tooltip": "Desactiva la mayoría de las animaciones y transiciones CSS. Acelera la inferencia cuando la GPU de pantalla también se utiliza para la generación."
},
"Comfy_Canvas_BackgroundImage": {
"name": "Imagen de fondo del lienzo",
"tooltip": "URL de la imagen para el fondo del lienzo. Puedes hacer clic derecho en una imagen del panel de resultados y seleccionar \"Establecer como fondo\" para usarla."
@@ -99,10 +95,6 @@
"name": "Decimales de redondeo del widget flotante [0 = automático].",
"tooltip": "(requiere recargar la página)"
},
"Comfy_Graph_AutoPanSpeed": {
"name": "Velocidad de auto-desplazamiento",
"tooltip": "Velocidad máxima al auto-desplazar arrastrando hacia el borde del lienzo. Establece en 0 para desactivar el auto-desplazamiento."
},
"Comfy_Graph_CanvasInfo": {
"name": "Mostrar información del lienzo en la esquina inferior izquierda (fps, etc.)"
},
@@ -462,6 +454,9 @@
"Comfy_Workflow_ShowMissingModelsWarning": {
"name": "Mostrar advertencia de modelos faltantes"
},
"Comfy_Workflow_ShowMissingNodesWarning": {
"name": "Mostrar advertencia de nodos faltantes"
},
"Comfy_Workflow_SortNodeIdOnSave": {
"name": "Ordenar IDs de nodos al guardar el flujo de trabajo"
},

View File

@@ -15,14 +15,6 @@
"message": "این workflow شامل API Node است که برای اجرا نیاز به ورود به حساب کاربری دارد.",
"title": "ورود برای استفاده از API Nodeها لازم است"
},
"appBuilder": {
"vueNodeSwitch": {
"content": "برای بهترین تجربه، سازنده اپلیکیشن از Nodes 2.0 استفاده می‌کند. پس از ساخت اپلیکیشن می‌توانید از منوی اصلی به نسخه قبلی بازگردید.",
"dismiss": "بستن",
"dontShowAgain": "دیگر نمایش نده",
"title": "به Nodes 2.0 منتقل شدید"
}
},
"assetBrowser": {
"allCategory": "همه {category}",
"allModels": "همه مدل‌ها",
@@ -888,9 +880,7 @@
"extensionFileHint": "این ممکن است به دلیل اسکریپت زیر باشد",
"loadWorkflowTitle": "بارگذاری به دلیل خطا در بارگذاری مجدد داده‌های workflow متوقف شد",
"noStackTrace": "هیچ stacktraceی موجود نیست",
"promptExecutionError": "اجرای prompt با شکست مواجه شد",
"accessRestrictedTitle": "Access Restricted",
"accessRestrictedMessage": "Your account is not authorized for this feature."
"promptExecutionError": "اجرای prompt با شکست مواجه شد"
},
"errorOverlay": {
"errorCount": "{count} خطا",
@@ -1074,14 +1064,11 @@
"filterBy": "فیلتر بر اساس:",
"filterByType": "فیلتر بر اساس {type}...",
"findIssues": "یافتن مشکلات",
"findOnGithub": "یافتن در GitHub",
"frameNodes": "قاب‌بندی nodeها",
"frontendNewer": "نسخه فرانت‌اند {frontendVersion} ممکن است با نسخه بک‌اند {backendVersion} ناسازگار باشد.",
"frontendOutdated": "نسخه فرانت‌اند {frontendVersion} قدیمی است. بک‌اند به نسخه {requiredVersion} یا بالاتر نیاز دارد.",
"gallery": "گالری",
"galleryImage": "تصویر گالری",
"galleryThumbnail": "تصویر بندانگشتی گالری",
"getHelpAction": "دریافت راهنما",
"goToNode": "رفتن به node",
"graphNavigation": "ناوبری گراف",
"halfSpeed": "۰.۵x",
@@ -1090,7 +1077,6 @@
"icon": "آیکون",
"imageDoesNotExist": "تصویر وجود ندارد",
"imageFailedToLoad": "بارگذاری تصویر ناموفق بود",
"imageLightbox": "پیش‌نمایش تصویر",
"imagePreview": "پیش‌نمایش تصویر - برای جابجایی بین تصاویر از کلیدهای جهت‌دار استفاده کنید",
"imageUrl": "آدرس تصویر",
"import": "وارد کردن",
@@ -1111,32 +1097,6 @@
"jobIdCopied": "شناسه وظیفه در کلیپ‌بورد کپی شد",
"keybinding": "کلید میانبر",
"keybindingAlreadyExists": "کلید میانبر قبلاً وجود دارد در",
"keybindingPresets": {
"default": "پیش‌تنظیم پیش‌فرض",
"deletePreset": "حذف پیش‌تنظیم",
"deletePresetFailed": "حذف پیش‌تنظیم «{name}» ناموفق بود",
"deletePresetTitle": "پیش‌تنظیم فعلی حذف شود؟",
"deletePresetWarning": "این پیش‌تنظیم حذف خواهد شد. این عمل قابل بازگشت نیست.",
"discardAndSwitch": "رد کردن و جابجایی",
"exportPreset": "خروجی گرفتن از پیش‌تنظیم",
"importKeybindingPreset": "وارد کردن پیش‌تنظیم کلیدها",
"importPreset": "وارد کردن پیش‌تنظیم",
"invalidPresetFile": "فایل پیش‌تنظیم باید یک JSON معتبر باشد که از ComfyUI خروجی گرفته شده است",
"invalidPresetName": "نام پیش‌تنظیم نباید خالی، «default»، با نقطه شروع شود، شامل جداکننده مسیر باشد یا با .json پایان یابد",
"loadPresetFailed": "بارگذاری پیش‌تنظیم «{name}» ناموفق بود",
"overwritePresetMessage": "پیش‌تنظیمی با نام «{name}» وجود دارد. جایگزین شود؟",
"overwritePresetTitle": "جایگزینی پیش‌تنظیم",
"presetDeleted": "پیش‌تنظیم «{name}» حذف شد",
"presetImported": "پیش‌تنظیم کلیدها وارد شد",
"presetNamePrompt": "یک نام برای پیش‌تنظیم وارد کنید",
"presetSaved": "پیش‌تنظیم «{name}» ذخیره شد",
"resetToDefault": "بازنشانی به پیش‌فرض",
"saveAndSwitch": "ذخیره و جابجایی",
"saveAsNewPreset": "ذخیره به عنوان پیش‌تنظیم جدید",
"saveChanges": "ذخیره تغییرات",
"unsavedChangesMessage": "تغییرات ذخیره‌نشده‌ای دارید که در صورت جابجایی بدون ذخیره، از بین خواهند رفت.",
"unsavedChangesTo": "تغییرات ذخیره‌نشده برای {name}"
},
"keybindings": "کلیدهای میانبر",
"learnMore": "اطلاعات بیشتر",
"listening": "در حال گوش دادن...",
@@ -1193,8 +1153,6 @@
"output": "خروجی",
"overwrite": "جایگزینی",
"partner": "همکار",
"pause": "توقف",
"play": "پخش",
"playPause": "پخش/توقف",
"playRecording": "پخش ضبط",
"playbackSpeed": "سرعت پخش",
@@ -1202,7 +1160,6 @@
"preloadError": "یک منبع مورد نیاز بارگذاری نشد. لطفاً صفحه را مجدداً بارگذاری کنید.",
"preloadErrorTitle": "خطا در بارگذاری",
"preview": "پیش‌نمایش",
"previous": "قبلی",
"previousImage": "تصویر قبلی",
"profile": "پروفایل",
"progressCountOf": "از",
@@ -1277,8 +1234,6 @@
"showReport": "نمایش گزارش",
"showRightPanel": "نمایش پنل راست",
"singleSelectDropdown": "لیست کشویی تک‌انتخابی",
"skipToEnd": "رفتن به انتها",
"skipToStart": "رفتن به ابتدا",
"sort": "مرتب‌سازی",
"source": "منبع",
"startRecording": "شروع ضبط",
@@ -1877,7 +1832,6 @@
"mirrorVertical": "آینه عمودی",
"negative": "نگاتیو",
"opacity": "شفافیت",
"openMaskEditor": "باز کردن در Mask Editor",
"paintBucketSettings": "تنظیمات سطل رنگ",
"paintLayer": "لایه نقاشی",
"redo": "بازانجام",
@@ -2192,7 +2146,6 @@
"Moonvalley Marey": "Moonvalley Marey",
"OpenAI": "OpenAI",
"PixVerse": "PixVerse",
"Quiver": "Quiver",
"Recraft": "Recraft",
"Reve": "Reve",
"Rodin": "Rodin",
@@ -2468,8 +2421,6 @@
"favoritesNoneDesc": "ورودی‌هایی که به علاقه‌مندی‌ها اضافه کنید اینجا نمایش داده می‌شوند",
"favoritesNoneHint": "در تب پارامترها، روی {moreIcon} هر ورودی کلیک کنید تا اینجا اضافه شود",
"favoritesNoneTooltip": "برای دسترسی سریع، ویجت‌ها را ستاره‌دار کنید تا بدون انتخاب nodeها به آن‌ها دسترسی داشته باشید",
"findOnGithubTooltip": "جستجوی مشکلات مرتبط در GitHub",
"getHelpTooltip": "گزارش این خطا و دریافت راهنمایی برای رفع آن",
"globalSettings": {
"canvas": "canvas",
"connectionLinks": "اتصالات",
@@ -3134,6 +3085,7 @@
"title": "اشتراک شما لغو شده است"
},
"changeTo": "تغییر به {plan}",
"chooseBestPlanWorkspace": "بهترین طرح را برای فضای کاری خود انتخاب کنید",
"comfyCloud": "Comfy Cloud",
"comfyCloudLogo": "لوگوی Comfy Cloud",
"contactOwnerToSubscribe": "برای فعال‌سازی اشتراک با مالک محیط کاری تماس بگیرید",
@@ -3163,7 +3115,6 @@
},
"gpuLabel": "RTX 6000 Pro (۹۶ گیگابایت VRAM)",
"haveQuestions": "سوالی دارید یا به دنبال راهکار سازمانی هستید؟",
"inviteUpTo": "دعوت تا سقف",
"invoiceHistory": "تاریخچه فاکتورها",
"learnMore": "اطلاعات بیشتر",
"managePayment": "مدیریت پرداخت",
@@ -3189,16 +3140,13 @@
"monthlyCreditsPerMemberLabel": "اعتبار ماهانه / هر عضو",
"monthlyCreditsRollover": "این اعتبارها به ماه بعد منتقل می‌شوند",
"mostPopular": "محبوب‌ترین",
"needTeamWorkspace": "به فضای کاری تیمی نیاز دارید؟",
"nextBillingCycle": "چرخه صورتحساب بعدی",
"nextMonthInvoice": "صورتحساب ماه آینده",
"partnerNodesBalance": "اعتبار «Partner Nodes»",
"partnerNodesCredits": "قیمت‌گذاری Partner Nodes",
"partnerNodesDescription": "برای اجرای مدل‌های تجاری/اختصاصی",
"perMonth": "/ ماه",
"personalWorkspace": "فضای کاری شخصی",
"plansAndPricing": "طرح‌ها و قیمت‌ها",
"plansForWorkspace": "طرح‌ها برای {workspace}",
"prepaidCreditsInfo": "اعتبارهای پیش‌پرداخت تا یک سال پس از تاریخ خرید منقضی می‌شوند.",
"prepaidDescription": "اعتبارهای پیش‌پرداخت",
"preview": {
@@ -3234,7 +3182,6 @@
"resubscribe": "تمدید اشتراک",
"resubscribeSuccess": "اشتراک با موفقیت فعال شد",
"resubscribeTo": "تمدید اشتراک {plan}",
"soloUseOnly": "فقط برای استفاده فردی",
"subscribeForMore": "ارتقاء",
"subscribeNow": "هم‌اکنون اشتراک بگیرید",
"subscribeTo": "اشتراک در {plan}",
@@ -3242,7 +3189,6 @@
"subscribeToRun": "اشتراک",
"subscribeToRunFull": "اشتراک برای اجرا",
"subscriptionRequiredMessage": "برای اجرای workflowها در Cloud، اشتراک لازم است.",
"teamWorkspace": "فضای کاری تیمی",
"tierNameYearly": "{name} سالانه",
"tiers": {
"creator": {
@@ -3294,18 +3240,6 @@
"duplicateTab": "ایجاد تب مشابه",
"removeFromBookmarks": "حذف از نشانک‌ها"
},
"teamWorkspacesDialog": {
"confirmCallbackFailed": "فضای کاری ایجاد شد اما راه‌اندازی کامل نشد",
"createWorkspace": "ایجاد فضای کاری",
"namePlaceholder": "مثلاً تیم بازاریابی",
"nameValidationError": "نام باید بین ۱ تا ۵۰ کاراکتر و شامل حروف، اعداد، فاصله یا علائم نگارشی رایج باشد.",
"newWorkspace": "فضای کاری جدید",
"subtitle": "به یکی از فضاهای موجود بروید یا فضای کاری جدیدی ایجاد کنید",
"subtitleNoWorkspaces": "برای اشتراک‌گذاری اعتبارها، فضای کاری تیمی جدیدی ایجاد کنید",
"switch": "تغییر",
"title": "فضاهای کاری تیمی",
"yourTeamWorkspaces": "فضاهای کاری تیمی شما"
},
"templateWidgets": {
"sort": {
"searchPlaceholder": "جستجو..."
@@ -3692,7 +3626,6 @@
},
"workspaceSwitcher": {
"createWorkspace": "ایجاد محیط کاری جدید",
"failedToSwitch": "تغییر فضای کاری ناموفق بود",
"maxWorkspacesReached": "شما فقط می‌توانید مالک ۱۰ محیط کاری باشید. برای ایجاد محیط کاری جدید، یکی را حذف کنید.",
"personal": "شخصی",
"roleMember": "عضو",

View File

@@ -1736,10 +1736,6 @@
"name": "حلقه بسته",
"tooltip": "آیا حلقه پنجره زمینه بسته شود؛ فقط برای برنامه‌ریزی حلقه‌ای قابل استفاده است."
},
"cond_retain_index_list": {
"name": "cond_retain_index_list",
"tooltip": "فهرست اندیس‌های latent که باید در تنسورهای شرطی برای هر پنجره حفظ شوند؛ برای مثال، اگر این مقدار را '۰' قرار دهید، تصویر ابتدایی برای هر پنجره استفاده خواهد شد."
},
"context_length": {
"name": "طول پنجره زمینه",
"tooltip": "طول پنجره زمینه."
@@ -1771,10 +1767,6 @@
"model": {
"name": "مدل",
"tooltip": "مدلی که پنجره‌های زمینه هنگام نمونه‌گیری بر آن اعمال می‌شود."
},
"split_conds_to_windows": {
"name": "split_conds_to_windows",
"tooltip": "آیا شرط‌های متعدد (ایجاد شده توسط ConditionCombine) بر اساس اندیس ناحیه به هر پنجره تقسیم شوند یا خیر."
}
},
"outputs": {
@@ -3823,10 +3815,6 @@
},
"1": {
"tooltip": null
},
"2": {
"name": "thought_image",
"tooltip": "اولین تصویر از فرایند تفکر مدل. فقط در حالت thinking_level بالا و مدالیته IMAGE+TEXT در دسترس است."
}
}
},
@@ -11961,91 +11949,6 @@
}
}
},
"QuiverImageToSVGNode": {
"description": "بردارسازی یک تصویر شطرنجی به SVG با استفاده از Quiver AI.",
"display_name": "تبدیل تصویر Quiver به SVG",
"inputs": {
"auto_crop": {
"name": "auto_crop",
"tooltip": "برش خودکار به سوژه غالب."
},
"control_after_generate": {
"name": "control after generate"
},
"image": {
"name": "image",
"tooltip": "تصویر ورودی برای بردارسازی."
},
"model": {
"name": "model",
"tooltip": "مدل مورد استفاده برای بردارسازی SVG."
},
"model_presence_penalty": {
"name": "presence_penalty"
},
"model_target_size": {
"name": "target_size"
},
"model_temperature": {
"name": "temperature"
},
"model_top_p": {
"name": "top_p"
},
"seed": {
"name": "seed",
"tooltip": "Seed برای تعیین اجرای مجدد node؛ نتایج واقعی صرف‌نظر از seed غیرقطعی هستند."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"QuiverTextToSVGNode": {
"description": "تولید یک SVG از طریق پرامپت متنی با استفاده از Quiver AI.",
"display_name": "تبدیل متن Quiver به SVG",
"inputs": {
"control_after_generate": {
"name": "control after generate"
},
"instructions": {
"name": "instructions",
"tooltip": "راهنمایی‌های اضافی برای سبک یا قالب‌بندی."
},
"model": {
"name": "model",
"tooltip": "مدل مورد استفاده برای تولید SVG."
},
"model_presence_penalty": {
"name": "presence_penalty"
},
"model_temperature": {
"name": "temperature"
},
"model_top_p": {
"name": "top_p"
},
"prompt": {
"name": "prompt",
"tooltip": "توضیح متنی برای خروجی SVG مورد نظر."
},
"reference_images": {
"name": "reference_images",
"tooltip": "حداکثر ۴ تصویر مرجع برای راهنمایی تولید."
},
"seed": {
"name": "seed",
"tooltip": "Seed برای تعیین اجرای مجدد node؛ نتایج واقعی صرف‌نظر از seed غیرقطعی هستند."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"QwenImageDiffsynthControlnet": {
"display_name": "QwenImageDiffsynthControlnet",
"inputs": {

Some files were not shown because too many files have changed in this diff Show More