mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-28 16:27:32 +00:00
Compare commits
1 Commits
pysssss/no
...
pablo_hack
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
714a11872f |
1
.github/workflows/release-version-bump.yaml
vendored
1
.github/workflows/release-version-bump.yaml
vendored
@@ -30,7 +30,6 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
bump-version:
|
||||
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
1
.github/workflows/weekly-docs-check.yaml
vendored
1
.github/workflows/weekly-docs-check.yaml
vendored
@@ -18,7 +18,6 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
docs-check:
|
||||
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -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' },
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.4 KiB |
@@ -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' },
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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>')
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
}}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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]
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />' }
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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[] = []
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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) }"
|
||||
>•</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>
|
||||
|
||||
90
src/components/searchbox/v2/NodeSearchFilterPanel.vue
Normal file
90
src/components/searchbox/v2/NodeSearchFilterPanel.vue
Normal 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) }"
|
||||
>•</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>
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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) }"> • </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) }">
|
||||
•
|
||||
</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>) {
|
||||
|
||||
@@ -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"> </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>
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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) }"
|
||||
>
|
||||
•
|
||||
</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>
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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)', () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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": "عضو",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": "ترتيب معرفات العقد عند حفظ سير العمل"
|
||||
},
|
||||
|
||||
@@ -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 1–50 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…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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": "عضو",
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user