Compare commits

..

1 Commits

Author SHA1 Message Date
Rizumu Ayaka
bb0293a4e8 feat: Tweeq-style drag scrubbing for ScrubableNumberInput
X-axis drags value, Y-axis adjusts sensitivity with an SVG dot-ruler
overlay showing the active precision. Pointer-lock hides the cursor
during scrub and returns it to the press position on release.
2026-05-22 19:00:58 +08:00
72 changed files with 1469 additions and 11709 deletions

View File

@@ -1,5 +1,5 @@
{
"fetchedAt": "2026-05-22T00:07:48.353Z",
"fetchedAt": "2026-05-12T16:10:34.114Z",
"departments": [
{
"name": "DESIGN",
@@ -36,14 +36,14 @@
"id": "6a6d865eeb3c10a8",
"title": "Senior Software Engineer, Frontend",
"department": "Engineering",
"location": "Remote",
"location": "San Francisco",
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/c3e0584d-5490-491f-aae4-b5922ef63fd2"
},
{
"id": "1b4f7f1da9616e14",
"title": "Senior Software Engineer, Backend Generalist",
"department": "Engineering",
"location": "Remote",
"location": "San Francisco",
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/732f8b39-076d-4847-afe3-f54d4451607e"
},
{
@@ -71,14 +71,14 @@
"id": "91604c4182a1bc3c",
"title": "Software Engineer, Core ComfyUI Contributor",
"department": "Engineering",
"location": "Remote",
"location": "San Francisco",
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/7d4062d6-d500-445a-9a5f-014971af259f"
},
{
"id": "a1dbc0576ab14034",
"title": "Software Engineer, ComfyUI Desktop",
"department": "Engineering",
"location": "Remote",
"location": "San Francisco",
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/ad2f76cb-a787-47d8-81c5-7e7f917747c0"
},
{
@@ -105,21 +105,21 @@
"id": "23dd98cab77ff459",
"title": "Freelance Motion Designer",
"department": "Marketing",
"location": "Remote",
"location": "San Francisco",
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/a7ccc2b4-4d9d-4e04-b39c-28a711995b5b"
},
{
"id": "a998b9fc973ff3c0",
"title": "Creative Artist",
"department": "Marketing",
"location": "Remote",
"location": "San Francisco",
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/19ba10aa-4961-45e8-8473-66a8a7a8079d"
},
{
"id": "3e730938026d6e70",
"title": "Graphic Designer",
"department": "Marketing",
"location": "Remote",
"location": "San Francisco",
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/49fa0b07-3fa1-4a3a-b2c6-d2cc684ad63f"
},
{
@@ -135,20 +135,6 @@
"department": "Marketing",
"location": "San Francisco",
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/89d3ff75-2055-4e92-9c69-81feff55627c"
},
{
"id": "e11f8b9e58dbea81",
"title": "Creative Producer",
"department": "Marketing",
"location": "Remote",
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/7be2d690-7a2b-4ebf-b1c4-6907b273d3d9"
},
{
"id": "6eac654593208ec3",
"title": "Forward Deployed Creative Technologist",
"department": "Marketing",
"location": "San Francisco",
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/af49c05f-dcd8-4c3d-a464-43eb3b1c6efc"
}
]
},

File diff suppressed because one or more lines are too long

View File

@@ -2,8 +2,7 @@ import { describe, expect, it, vi } from 'vitest'
import {
DEFAULT_REGISTRY_BASE_URL,
fetchRegistryPacks,
fetchRegistryPacksWithNodes
fetchRegistryPacks
} from './cloudNodes.registry'
function jsonResponse(
@@ -143,315 +142,3 @@ describe('fetchRegistryPacks', () => {
expect(result.size).toBe(0)
})
})
describe('fetchRegistryPacksWithNodes', () => {
it('fetches pack metadata and comfy nodes for each pack', async () => {
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
const url = new URL(String(input))
// Pack metadata request
if (url.pathname === '/nodes') {
return jsonResponse({
nodes: [
{
id: 'comfyui-impact-pack',
name: 'ComfyUI Impact Pack',
repository: 'https://github.com/ltdrdata/ComfyUI-Impact-Pack',
latest_version: { version: '8.0.0', createdAt: '2026-01-01' }
}
]
})
}
// Comfy nodes request
if (url.pathname.includes('/comfy-nodes')) {
return jsonResponse({
comfy_nodes: [
{ comfy_node_name: 'FaceDetailer', category: 'detailer' },
{ comfy_node_name: 'DetailerForEach', category: 'detailer' }
],
totalNumberOfPages: 1
})
}
return new Response('Not found', { status: 404 })
})
const result = await fetchRegistryPacksWithNodes(['comfyui-impact-pack'], {
fetchImpl: fetchImpl as typeof fetch
})
expect(result.size).toBe(1)
const packData = result.get('comfyui-impact-pack')
expect(packData).not.toBeNull()
expect(packData?.pack.name).toBe('ComfyUI Impact Pack')
expect(packData?.nodes).toHaveLength(2)
expect(packData?.nodes[0]?.comfy_node_name).toBe('FaceDetailer')
})
it('handles pagination for comfy nodes', async () => {
let comfyNodesCallCount = 0
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
const url = new URL(String(input))
if (url.pathname === '/nodes') {
return jsonResponse({
nodes: [
{
id: 'big-pack',
name: 'Big Pack',
latest_version: { version: '1.0.0' }
}
]
})
}
if (url.pathname.includes('/comfy-nodes')) {
comfyNodesCallCount++
const page = Number(url.searchParams.get('page') ?? '1')
if (page === 1) {
return jsonResponse({
comfy_nodes: [
{ comfy_node_name: 'Node1', category: 'cat1' },
{ comfy_node_name: 'Node2', category: 'cat1' }
],
totalNumberOfPages: 2
})
} else {
return jsonResponse({
comfy_nodes: [{ comfy_node_name: 'Node3', category: 'cat2' }],
totalNumberOfPages: 2
})
}
}
return new Response('Not found', { status: 404 })
})
const result = await fetchRegistryPacksWithNodes(['big-pack'], {
fetchImpl: fetchImpl as typeof fetch
})
expect(comfyNodesCallCount).toBe(2)
const packData = result.get('big-pack')
expect(packData?.nodes).toHaveLength(3)
})
it('returns null for packs without latest_version', async () => {
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
const url = new URL(String(input))
if (url.pathname === '/nodes') {
return jsonResponse({
nodes: [
{
id: 'no-version-pack',
name: 'No Version Pack',
latest_version: null
}
]
})
}
return new Response('Not found', { status: 404 })
})
const result = await fetchRegistryPacksWithNodes(['no-version-pack'], {
fetchImpl: fetchImpl as typeof fetch
})
expect(result.get('no-version-pack')).toBeNull()
})
it('returns empty nodes array when comfy-nodes request fails', async () => {
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
const url = new URL(String(input))
if (url.pathname === '/nodes') {
return jsonResponse({
nodes: [
{
id: 'failing-pack',
name: 'Failing Pack',
latest_version: { version: '1.0.0' }
}
]
})
}
if (url.pathname.includes('/comfy-nodes')) {
return new Response('Server error', { status: 500 })
}
return new Response('Not found', { status: 404 })
})
const result = await fetchRegistryPacksWithNodes(['failing-pack'], {
fetchImpl: fetchImpl as typeof fetch
})
const packData = result.get('failing-pack')
expect(packData).not.toBeNull()
expect(packData?.pack.name).toBe('Failing Pack')
expect(packData?.nodes).toHaveLength(0)
})
it('handles null comfy_nodes in response', async () => {
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
const url = new URL(String(input))
if (url.pathname === '/nodes') {
return jsonResponse({
nodes: [
{
id: 'null-nodes-pack',
name: 'Null Nodes Pack',
latest_version: { version: '1.0.0' }
}
]
})
}
if (url.pathname.includes('/comfy-nodes')) {
return jsonResponse({
comfy_nodes: null,
totalNumberOfPages: 1
})
}
return new Response('Not found', { status: 404 })
})
const result = await fetchRegistryPacksWithNodes(['null-nodes-pack'], {
fetchImpl: fetchImpl as typeof fetch
})
const packData = result.get('null-nodes-pack')
expect(packData?.nodes).toHaveLength(0)
})
it('fetches nodes for multiple packs in parallel', async () => {
const packIds = ['pack-a', 'pack-b', 'pack-c']
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
const url = new URL(String(input))
if (url.pathname === '/nodes') {
const requestedIds = url.searchParams.getAll('node_id')
return jsonResponse({
nodes: requestedIds.map((id) => ({
id,
name: id.toUpperCase(),
latest_version: { version: '1.0.0' }
}))
})
}
if (url.pathname.includes('/comfy-nodes')) {
const packId = url.pathname.split('/nodes/')[1]?.split('/')[0]
return jsonResponse({
comfy_nodes: [
{ comfy_node_name: `${packId}-node`, category: 'test' }
],
totalNumberOfPages: 1
})
}
return new Response('Not found', { status: 404 })
})
const result = await fetchRegistryPacksWithNodes(packIds, {
fetchImpl: fetchImpl as typeof fetch
})
expect(result.size).toBe(3)
for (const packId of packIds) {
const packData = result.get(packId)
expect(packData).not.toBeNull()
expect(packData?.nodes[0]?.comfy_node_name).toBe(`${packId}-node`)
}
})
it('retries comfy-nodes fetch once on failure', async () => {
let comfyNodesAttempts = 0
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
const url = new URL(String(input))
if (url.pathname === '/nodes') {
return jsonResponse({
nodes: [
{
id: 'retry-pack',
name: 'Retry Pack',
latest_version: { version: '1.0.0' }
}
]
})
}
if (url.pathname.includes('/comfy-nodes')) {
comfyNodesAttempts++
if (comfyNodesAttempts === 1) {
return new Response('Server error', { status: 500 })
}
return jsonResponse({
comfy_nodes: [{ comfy_node_name: 'RetryNode', category: 'test' }],
totalNumberOfPages: 1
})
}
return new Response('Not found', { status: 404 })
})
const result = await fetchRegistryPacksWithNodes(['retry-pack'], {
fetchImpl: fetchImpl as typeof fetch
})
expect(comfyNodesAttempts).toBe(2)
const packData = result.get('retry-pack')
expect(packData?.nodes).toHaveLength(1)
expect(packData?.nodes[0]?.comfy_node_name).toBe('RetryNode')
})
it('normalizes null boolean fields in comfy nodes', async () => {
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
const url = new URL(String(input))
if (url.pathname === '/nodes') {
return jsonResponse({
nodes: [
{
id: 'bool-pack',
name: 'Bool Pack',
latest_version: { version: '1.0.0' }
}
]
})
}
if (url.pathname.includes('/comfy-nodes')) {
return jsonResponse({
comfy_nodes: [
{
comfy_node_name: 'TestNode',
category: 'test',
deprecated: null,
experimental: null
}
],
totalNumberOfPages: 1
})
}
return new Response('Not found', { status: 404 })
})
const result = await fetchRegistryPacksWithNodes(['bool-pack'], {
fetchImpl: fetchImpl as typeof fetch
})
const packData = result.get('bool-pack')
expect(packData?.nodes[0]?.deprecated).toBeUndefined()
expect(packData?.nodes[0]?.experimental).toBeUndefined()
})
})

View File

@@ -5,10 +5,8 @@ import type { components } from '@comfyorg/registry-types'
export const DEFAULT_REGISTRY_BASE_URL = 'https://api.comfy.org'
const DEFAULT_TIMEOUT_MS = 5_000
const BATCH_SIZE = 50
const COMFY_NODES_PAGE_SIZE = 500
export type RegistryPack = components['schemas']['Node']
export type RegistryComfyNode = components['schemas']['ComfyNode']
function nullToUndefined<T>(value: T | null | undefined): T | undefined {
return value ?? undefined
@@ -60,29 +58,6 @@ const RegistryListResponseSchema = z
})
.passthrough()
const RegistryComfyNodeSchema = z
.object({
comfy_node_name: optionalString,
category: optionalString,
description: optionalString,
deprecated: z
.boolean()
.nullish()
.transform((v) => v ?? undefined),
experimental: z
.boolean()
.nullish()
.transform((v) => v ?? undefined)
})
.passthrough()
const RegistryComfyNodesResponseSchema = z
.object({
comfy_nodes: z.array(RegistryComfyNodeSchema).nullish(),
totalNumberOfPages: z.number().nullish()
})
.passthrough()
interface FetchRegistryOptions {
baseUrl?: string
timeoutMs?: number
@@ -147,142 +122,6 @@ export async function fetchRegistryPacks(
return resolved
}
export interface RegistryPackWithNodes {
pack: RegistryPack
nodes: RegistryComfyNode[]
}
export async function fetchRegistryPacksWithNodes(
packIds: readonly string[],
options: FetchRegistryOptions = {}
): Promise<Map<string, RegistryPackWithNodes | null>> {
const packs = await fetchRegistryPacks(packIds, options)
const baseUrl = options.baseUrl ?? DEFAULT_REGISTRY_BASE_URL
const timeoutMs = clampTimeoutMs(options.timeoutMs)
const fetchImpl = options.fetchImpl ?? fetch
const entries = await Promise.all(
[...packs.entries()].map(
async ([packId, pack]): Promise<
[string, RegistryPackWithNodes | null]
> => {
if (!pack?.latest_version?.version) {
return [packId, null]
}
const nodes = await fetchComfyNodesForPack(
fetchImpl,
baseUrl,
packId,
pack.latest_version.version,
timeoutMs
)
return [packId, { pack, nodes }]
}
)
)
return new Map(entries)
}
async function fetchComfyNodesForPack(
fetchImpl: typeof fetch,
baseUrl: string,
packId: string,
version: string,
timeoutMs: number
): Promise<RegistryComfyNode[]> {
const allNodes: RegistryComfyNode[] = []
let page = 1
let totalPages = 1
while (page <= totalPages) {
const result = await fetchComfyNodesPageWithRetry(
fetchImpl,
baseUrl,
packId,
version,
page,
timeoutMs
)
if (!result) break
allNodes.push(...result.nodes)
totalPages = result.totalPages
page++
}
return allNodes
}
async function fetchComfyNodesPageWithRetry(
fetchImpl: typeof fetch,
baseUrl: string,
packId: string,
version: string,
page: number,
timeoutMs: number
): Promise<{ nodes: RegistryComfyNode[]; totalPages: number } | null> {
const firstAttempt = await fetchComfyNodesPage(
fetchImpl,
baseUrl,
packId,
version,
page,
timeoutMs
)
if (firstAttempt) return firstAttempt
// Retry once on failure
return fetchComfyNodesPage(
fetchImpl,
baseUrl,
packId,
version,
page,
timeoutMs
)
}
async function fetchComfyNodesPage(
fetchImpl: typeof fetch,
baseUrl: string,
packId: string,
version: string,
page: number,
timeoutMs: number
): Promise<{ nodes: RegistryComfyNode[]; totalPages: number } | null> {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), timeoutMs)
try {
const url = `${baseUrl}/nodes/${encodeURIComponent(packId)}/versions/${encodeURIComponent(version)}/comfy-nodes?limit=${COMFY_NODES_PAGE_SIZE}&page=${page}`
const res = await fetchImpl(url, {
method: 'GET',
headers: { Accept: 'application/json' },
signal: controller.signal
})
if (!res.ok) return null
const rawBody: unknown = await res.json()
const parsed = RegistryComfyNodesResponseSchema.safeParse(rawBody)
if (!parsed.success) return null
return {
nodes: (parsed.data.comfy_nodes ?? []) as RegistryComfyNode[],
totalPages: parsed.data.totalNumberOfPages ?? 1
}
} catch {
return null
} finally {
clearTimeout(timer)
}
}
async function fetchBatchWithRetry(
fetchImpl: typeof fetch,
baseUrl: string,

View File

@@ -8,16 +8,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { NodesSnapshot } from '../data/cloudNodes'
import type * as ObjectInfoParser from '@comfyorg/object-info-parser'
import type { RegistryPackWithNodes } from './cloudNodes.registry'
const fetchRegistryPacksWithNodesMock = vi.hoisted(() =>
vi.fn(async () => new Map<string, RegistryPackWithNodes | null>())
)
const fetchRegistryPacksMock = vi.hoisted(() => vi.fn(async () => new Map()))
const sanitizeCallSpy = vi.hoisted(() => vi.fn())
vi.mock('./cloudNodes.registry', () => ({
DEFAULT_REGISTRY_BASE_URL: 'https://api.comfy.org',
fetchRegistryPacksWithNodes: fetchRegistryPacksWithNodesMock
fetchRegistryPacks: fetchRegistryPacksMock
}))
vi.mock('@comfyorg/object-info-parser', async (importOriginal) => {
@@ -94,8 +90,8 @@ describe('fetchCloudNodesForBuild', () => {
beforeEach(() => {
resetCloudNodesFetcherForTests()
fetchRegistryPacksWithNodesMock.mockReset()
fetchRegistryPacksWithNodesMock.mockResolvedValue(new Map())
fetchRegistryPacksMock.mockReset()
fetchRegistryPacksMock.mockResolvedValue(new Map())
sanitizeCallSpy.mockReset()
delete process.env.WEBSITE_CLOUD_API_KEY
})
@@ -106,21 +102,14 @@ describe('fetchCloudNodesForBuild', () => {
})
it('returns fresh when API succeeds', async () => {
fetchRegistryPacksWithNodesMock.mockResolvedValue(
new Map<string, RegistryPackWithNodes | null>([
fetchRegistryPacksMock.mockResolvedValue(
new Map([
[
'comfyui-impact-pack',
{
pack: {
id: 'comfyui-impact-pack',
name: 'ComfyUI Impact Pack',
repository: 'https://github.com/ltdrdata/ComfyUI-Impact-Pack',
latest_version: { version: '1.0.0' }
},
nodes: [
{ comfy_node_name: 'FaceDetailer', category: 'detailer' },
{ comfy_node_name: 'DetailerForEach', category: 'detailer' }
]
id: 'comfyui-impact-pack',
name: 'ComfyUI Impact Pack',
repository: 'https://github.com/ltdrdata/ComfyUI-Impact-Pack'
}
]
])
@@ -140,10 +129,6 @@ describe('fetchCloudNodesForBuild', () => {
expect(outcome.snapshot.packs[0]?.repoUrl).toBe(
'https://github.com/ltdrdata/ComfyUI-Impact-Pack'
)
// Nodes should come from registry, not object_info
expect(outcome.snapshot.packs[0]?.nodes).toHaveLength(2)
expect(outcome.snapshot.packs[0]?.nodes[0]?.name).toBe('DetailerForEach')
expect(outcome.snapshot.packs[0]?.nodes[1]?.name).toBe('FaceDetailer')
})
it('drops invalid nodes individually and keeps valid nodes', async () => {
@@ -312,7 +297,7 @@ describe('fetchCloudNodesForBuild', () => {
})
it('returns fresh even when registry enrichment fails', async () => {
fetchRegistryPacksWithNodesMock.mockResolvedValue(new Map())
fetchRegistryPacksMock.mockResolvedValue(new Map())
const fetchImpl = vi.fn(async () => response({ ImpactNode: validNode() }))
const outcome = await fetchCloudNodesForBuild({
apiKey: KEY,
@@ -320,8 +305,5 @@ describe('fetchCloudNodesForBuild', () => {
fetchImpl: fetchImpl as typeof fetch
})
expect(outcome.status).toBe('fresh')
// Falls back to object_info nodes when registry fails
if (outcome.status !== 'fresh') return
expect(outcome.snapshot.packs[0]?.nodes[0]?.name).toBe('ImpactNode')
})
})

View File

@@ -6,15 +6,12 @@ import {
validateComfyNodeDef
} from '@comfyorg/object-info-parser'
import type {
RegistryComfyNode,
RegistryPackWithNodes
} from './cloudNodes.registry'
import type { RegistryPack } from './cloudNodes.registry'
import type { NodesSnapshot, Pack, PackNode } from '../data/cloudNodes'
import bundledSnapshot from '../data/cloud-nodes.snapshot.json' with { type: 'json' }
import { isNodesSnapshot } from '../data/cloudNodes'
import { fetchRegistryPacksWithNodes } from './cloudNodes.registry'
import { fetchRegistryPacks } from './cloudNodes.registry'
import { CloudNodesEnvelopeSchema } from './cloudNodes.schema'
const DEFAULT_BASE_URL = 'https://cloud.comfy.org'
@@ -238,28 +235,26 @@ async function parseCloudNodes(
const sanitizedDefs = sanitizeUserContent(
validDefs as Record<string, NonNullable<(typeof validDefs)[string]>>
)
// Use object_info to determine which packs are cloud-supported
const grouped = groupNodesByPack(sanitizedDefs)
const packIds = grouped.map((pack) => pack.id)
// Fetch full pack metadata and node list from registry
let registryMap = new Map<string, RegistryPackWithNodes | null>()
let registryMap = new Map<string, RegistryPack | null>()
try {
registryMap = await fetchRegistryPacksWithNodes(packIds, {
fetchImpl: options.fetchImpl
})
registryMap = await fetchRegistryPacks(
grouped.map((pack) => pack.id),
{ fetchImpl: options.fetchImpl }
)
} catch {
registryMap = new Map()
}
const packs = grouped
.map((pack) => {
const registryData = registryMap.get(pack.id)
// Use registry nodes if available, otherwise fall back to object_info nodes
return toDomainPack(pack.id, pack.displayName, pack.nodes, registryData)
})
.filter((pack) => pack.nodes.length > 0)
const packs = grouped.map((pack) =>
toDomainPack(
pack.id,
pack.displayName,
pack.nodes,
registryMap.get(pack.id)
)
)
return { kind: 'ok', packs, droppedNodes }
}
@@ -279,7 +274,7 @@ function safeExternalUrl(value: string | undefined): string | undefined {
function toDomainPack(
packId: string,
fallbackDisplayName: string,
objectInfoNodes: Array<{
nodes: Array<{
className: string
def: {
display_name: string
@@ -289,18 +284,8 @@ function toDomainPack(
experimental?: boolean
}
}>,
registryData: RegistryPackWithNodes | null | undefined
registryPack: RegistryPack | null | undefined
): Pack {
const registryPack = registryData?.pack
// Prefer registry nodes if available, fall back to object_info nodes
const nodes =
registryData?.nodes && registryData.nodes.length > 0
? registryData.nodes
.map((node) => toDomainNodeFromRegistry(node))
.filter((n): n is PackNode => n !== null)
: objectInfoNodes.map((node) => toDomainNode(node.className, node.def))
return {
id: packId,
registryId: registryPack?.id,
@@ -323,20 +308,9 @@ function toDomainPack(
registryPack?.latest_version?.createdAt ?? registryPack?.created_at,
supportedOs: registryPack?.supported_os,
supportedAccelerators: registryPack?.supported_accelerators,
nodes: nodes.sort((a, b) => a.displayName.localeCompare(b.displayName))
}
}
function toDomainNodeFromRegistry(node: RegistryComfyNode): PackNode | null {
if (!node.comfy_node_name) return null
return {
name: node.comfy_node_name,
displayName: node.comfy_node_name,
category: node.category || '',
description: node.description || undefined,
deprecated: node.deprecated,
experimental: node.experimental
nodes: nodes
.map((node) => toDomainNode(node.className, node.def))
.sort((a, b) => a.displayName.localeCompare(b.displayName))
}
}

View File

@@ -1,10 +1,8 @@
import { expect } from '@playwright/test'
import type { Locator } from '@playwright/test'
import type { RootCategoryId } from '@/components/searchbox/v2/rootCategories'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import type { Position } from '@e2e/fixtures/types'
const { searchBoxV2 } = TestIds
@@ -86,12 +84,11 @@ export class ComfyNodeSearchBoxV2 {
await this.input.waitFor({ state: 'visible' })
}
async openByDoubleClickCanvas(position?: Position) {
const { x, y } = position ?? { x: 200, y: 200 }
async openByDoubleClickCanvas(): Promise<void> {
// Use page.mouse.dblclick (not canvas.dblclick) so the z-999 Vue overlay
// does not intercept; coords target a viewport spot that is on the canvas
// and clear of both the side toolbar and any default-graph nodes.
await this.comfyPage.page.mouse.dblclick(x, y, { delay: 5 })
await this.comfyPage.page.mouse.dblclick(200, 200, { delay: 5 })
}
async ensureV2Search(): Promise<void> {
@@ -112,14 +109,4 @@ export class ComfyNodeSearchBoxV2 {
'search box'
)
}
async addNode(query: string, options: { position?: Position } = {}) {
const position = options.position ?? { x: 200, y: 200 }
await this.openByDoubleClickCanvas(position)
await this.input.fill(query)
await expect(this.results.first()).toContainText(query)
await this.comfyPage.page.keyboard.press('Enter')
await expect(this.dialog).toBeHidden()
await this.comfyPage.page.mouse.click(position.x, position.y)
}
}

View File

@@ -216,6 +216,16 @@ export class NodeOperationsHelper {
}
}
async convertAllNodesToGroupNode(groupNodeName: string): Promise<void> {
await this.comfyPage.canvas.press('Control+a')
const node = await this.getFirstNodeRef()
if (!node) {
throw new Error('No nodes found to convert')
}
await node.clickContextMenuOption('Convert to Group Node')
await this.fillPromptDialog(groupNodeName)
}
async fillPromptDialog(value: string): Promise<void> {
await this.promptDialogInput.fill(value)
await this.page.keyboard.press('Enter')

View File

@@ -514,6 +514,17 @@ export class NodeReference {
const ctx = this.comfyPage.page.locator('.litecontextmenu')
await ctx.getByText(optionText).click()
}
async convertToGroupNode(groupNodeName: string = 'GroupNode') {
await this.clickContextMenuOption('Convert to Group Node')
await this.comfyPage.nodeOps.fillPromptDialog(groupNodeName)
const nodes = await this.comfyPage.nodeOps.getNodeRefsByType(
`workflow>${groupNodeName}`
)
if (nodes.length !== 1) {
throw new Error(`Did not find single group node (found=${nodes.length})`)
}
return nodes[0]
}
async convertToSubgraph() {
await this.clickContextMenuOption('Convert to Subgraph')
await this.comfyPage.nextFrame()

View File

@@ -5,7 +5,12 @@ import {
import { TestIds } from '@e2e/fixtures/selectors'
test.describe('App mode usage', () => {
test('Drag and Drop @vue-nodes', async ({ comfyPage, comfyFiles }) => {
test('Drag and Drop', async ({ comfyPage, comfyFiles }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl',
'v1 (legacy)'
)
const { centerPanel } = comfyPage.appMode
await comfyPage.appMode.enterAppModeWithInputs([['3', 'seed']])
await expect(centerPanel, 'Enter app mode').toBeVisible()
@@ -19,7 +24,8 @@ test.describe('App mode usage', () => {
//prep a load image
await test.step('Add a load image node', async () => {
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.searchBoxV2.addNode('Load Image')
await comfyPage.page.mouse.dblclick(200, 200, { delay: 5 })
await comfyPage.searchBox.fillAndSelectFirstNode('Load Image')
const loadImage = await comfyPage.vueNodes.getNodeLocator('10')
await expect(loadImage).toBeVisible()
})

View File

@@ -75,28 +75,33 @@ test.describe('App mode builder selection', () => {
})
test('Marks canvas readOnly', async ({ comfyPage }) => {
await comfyPage.searchBoxV2.openByDoubleClickCanvas()
await comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl',
'v1 (legacy)'
)
await comfyPage.page.mouse.dblclick(100, 100, { delay: 5 })
await expect(
comfyPage.searchBoxV2.input,
comfyPage.searchBox.input,
'Canvas is initially editable'
).toBeVisible()
).toHaveCount(1)
await comfyPage.page.keyboard.press('Escape')
await comfyPage.appMode.enterBuilder()
await comfyPage.appMode.steps.goToInputs()
await comfyPage.searchBoxV2.openByDoubleClickCanvas()
await comfyPage.page.mouse.dblclick(100, 100, { delay: 5 })
await expect(
comfyPage.searchBoxV2.input,
comfyPage.searchBox.input,
'Entering builder makes the canvas readonly'
).toBeHidden()
).toHaveCount(0)
await comfyPage.page.keyboard.press('Space')
await comfyPage.searchBoxV2.openByDoubleClickCanvas()
await comfyPage.page.mouse.dblclick(100, 100, { delay: 5 })
await expect(
comfyPage.searchBoxV2.input,
comfyPage.searchBox.input,
'Canvas remains readonly after pressing space'
).toBeHidden()
).toHaveCount(0)
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
// oxlint-disable-next-line playwright/no-force-option -- Node container has conditional pointer-events:none that blocks actionability
@@ -107,10 +112,10 @@ test.describe('App mode builder selection', () => {
).toBeHidden()
await comfyPage.page.keyboard.press('Escape')
await comfyPage.searchBoxV2.openByDoubleClickCanvas()
await comfyPage.page.mouse.dblclick(100, 100, { delay: 5 })
await expect(
comfyPage.searchBoxV2.input,
comfyPage.searchBox.input,
'Canvas is no longer readonly after exiting'
).toBeVisible()
).toHaveCount(1)
})
})

View File

@@ -7,14 +7,9 @@ import {
} from '@e2e/fixtures/ComfyPage'
import type { NodeLibrarySidebarTab } from '@e2e/fixtures/components/SidebarTab'
import { TestIds } from '@e2e/fixtures/selectors'
import { DefaultGraphPositions } from '@e2e/fixtures/constants/defaultGraphPositions'
import type { NodeReference } from '@e2e/fixtures/utils/litegraphUtils'
const LOADED_WORKFLOW = 'groupnodes/group_node_v1.3.3'
const GROUP_NODE_NAME = 'group_node'
const GROUP_NODE_CATEGORY = 'group nodes>workflow'
const GROUP_NODE_TYPE = `workflow>${GROUP_NODE_NAME}`
const GROUP_NODE_BOOKMARK = GROUP_NODE_TYPE
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', false)
@@ -23,19 +18,22 @@ test.beforeEach(async ({ comfyPage }) => {
test.describe('Group Node', { tag: '@node' }, () => {
test.describe('Node library sidebar', () => {
const groupNodeName = 'DefautWorkflowGroupNode'
const groupNodeCategory = 'group nodes>workflow'
const groupNodeBookmarkName = `workflow>${groupNodeName}`
let libraryTab: NodeLibrarySidebarTab
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
libraryTab = comfyPage.menu.nodeLibraryTab
await comfyPage.workflow.loadWorkflow(LOADED_WORKFLOW)
await comfyPage.nodeOps.convertAllNodesToGroupNode(groupNodeName)
await libraryTab.open()
})
test('Is added to node library sidebar', async ({
comfyPage: _comfyPage
}) => {
await expect(libraryTab.getFolder(GROUP_NODE_CATEGORY)).toHaveCount(1)
await expect(libraryTab.getFolder(groupNodeCategory)).toHaveCount(1)
})
test('Can be added to canvas using node library sidebar', async ({
@@ -43,8 +41,9 @@ test.describe('Group Node', { tag: '@node' }, () => {
}) => {
const initialNodeCount = await comfyPage.nodeOps.getGraphNodesCount()
await libraryTab.getFolder(GROUP_NODE_CATEGORY).click()
await libraryTab.getNode(GROUP_NODE_NAME).click()
// Add group node from node library sidebar
await libraryTab.getFolder(groupNodeCategory).click()
await libraryTab.getNode(groupNodeName).click()
// Verify the node is added to the canvas
await expect
@@ -53,9 +52,9 @@ test.describe('Group Node', { tag: '@node' }, () => {
})
test('Can be bookmarked and unbookmarked', async ({ comfyPage }) => {
await libraryTab.getFolder(GROUP_NODE_CATEGORY).click()
await libraryTab.getFolder(groupNodeCategory).click()
await libraryTab
.getNode(GROUP_NODE_NAME)
.getNode(groupNodeName)
.locator('.bookmark-button')
.click()
@@ -64,12 +63,13 @@ test.describe('Group Node', { tag: '@node' }, () => {
.poll(() =>
comfyPage.settings.getSetting('Comfy.NodeLibrary.Bookmarks.V2')
)
.toEqual([GROUP_NODE_BOOKMARK])
.toEqual([groupNodeBookmarkName])
// Verify the bookmark node with the same name is added to the tree
await expect(libraryTab.getNode(GROUP_NODE_NAME)).not.toHaveCount(0)
await expect(libraryTab.getNode(groupNodeName)).not.toHaveCount(0)
// Unbookmark the node
await libraryTab
.getNode(GROUP_NODE_NAME)
.getNode(groupNodeName)
.locator('.bookmark-button')
.first()
.click()
@@ -83,9 +83,9 @@ test.describe('Group Node', { tag: '@node' }, () => {
})
test('Displays preview on bookmark hover', async ({ comfyPage }) => {
await libraryTab.getFolder(GROUP_NODE_CATEGORY).click()
await libraryTab.getFolder(groupNodeCategory).click()
await libraryTab
.getNode(GROUP_NODE_NAME)
.getNode(groupNodeName)
.locator('.bookmark-button')
.click()
await comfyPage.page
@@ -96,57 +96,72 @@ test.describe('Group Node', { tag: '@node' }, () => {
comfyPage.page.locator('.node-lib-node-preview')
).toBeVisible()
await libraryTab
.getNode(GROUP_NODE_NAME)
.getNode(groupNodeName)
.locator('.bookmark-button')
.first()
.click()
})
})
test('Can be added to canvas using search', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow(LOADED_WORKFLOW)
await comfyPage.canvasOps.doubleClick()
await comfyPage.nextFrame()
await comfyPage.searchBox.input.waitFor({ state: 'visible' })
await comfyPage.searchBox.input.fill(GROUP_NODE_NAME)
await comfyPage.searchBox.dropdown.waitFor({ state: 'visible' })
test(
'Can be added to canvas using search',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
const groupNodeName = 'DefautWorkflowGroupNode'
await comfyPage.nodeOps.convertAllNodesToGroupNode(groupNodeName)
await comfyPage.canvasOps.doubleClick()
await comfyPage.nextFrame()
await comfyPage.searchBox.input.waitFor({ state: 'visible' })
await comfyPage.searchBox.input.fill(groupNodeName)
await comfyPage.searchBox.dropdown.waitFor({ state: 'visible' })
const exactGroupNodeResult = comfyPage.searchBox.dropdown
.locator(`li[aria-label="${GROUP_NODE_NAME}"]`)
.first()
await expect(exactGroupNodeResult).toBeVisible()
await exactGroupNodeResult.click()
const exactGroupNodeResult = comfyPage.searchBox.dropdown
.locator(`li[aria-label="${groupNodeName}"]`)
.first()
await expect(exactGroupNodeResult).toBeVisible()
await exactGroupNodeResult.click()
await expect
.poll(() => comfyPage.nodeOps.getNodeRefsByType(GROUP_NODE_TYPE))
.toHaveLength(2)
})
await expect(comfyPage.canvas).toHaveScreenshot(
'group-node-copy-added-from-search.png'
)
}
)
test('Displays tooltip on title hover', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.EnableTooltips', true)
await comfyPage.workflow.loadWorkflow(LOADED_WORKFLOW)
const groupNode = await comfyPage.nodeOps.getFirstNodeRef()
if (!groupNode)
throw new Error(`Group node not found in workflow ${LOADED_WORKFLOW}`)
const pos = await groupNode.getPosition()
await comfyPage.page.mouse.move(pos.x + 40, pos.y + 10)
await comfyPage.nodeOps.convertAllNodesToGroupNode('Group Node')
await comfyPage.page.mouse.move(47, 173)
await expect(comfyPage.page.locator('.node-tooltip')).toBeVisible()
})
test('Manage group opens with the correct group selected', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.workflow.loadWorkflow(LOADED_WORKFLOW)
const groupNode = await comfyPage.nodeOps.getFirstNodeRef()
if (!groupNode)
throw new Error(`Group node not found in workflow ${LOADED_WORKFLOW}`)
const makeGroup = async (name: string, type1: string, type2: string) => {
const node1 = (await comfyPage.nodeOps.getNodeRefsByType(type1))[0]
const node2 = (await comfyPage.nodeOps.getNodeRefsByType(type2))[0]
await node1.click('title')
await node2.click('title', {
modifiers: ['Shift']
})
return await node2.convertToGroupNode(name)
}
const manage = await groupNode.manageGroupNode()
const group1 = await makeGroup(
'g1',
'CLIPTextEncode',
'CheckpointLoaderSimple'
)
const group2 = await makeGroup('g2', 'EmptyLatentImage', 'KSampler')
const manage1 = await group1.manageGroupNode()
await comfyPage.nextFrame()
await expect(manage.selectedNodeTypeSelect).toHaveValue(GROUP_NODE_NAME)
await manage.close()
await expect(manage.root).toBeHidden()
await expect(manage1.selectedNodeTypeSelect).toHaveValue('g1')
await manage1.close()
await expect(manage1.root).toBeHidden()
const manage2 = await group2.manageGroupNode()
await expect(manage2.selectedNodeTypeSelect).toHaveValue('g2')
})
test('Preserves hidden input configuration when containing duplicate node types', async ({
@@ -186,6 +201,42 @@ test.describe('Group Node', { tag: '@node' }, () => {
.toBe(2)
})
test('Reconnects inputs after configuration changed via manage dialog save', async ({
comfyPage
}) => {
const expectSingleNode = async (type: string) => {
const nodes = await comfyPage.nodeOps.getNodeRefsByType(type)
expect(nodes).toHaveLength(1)
return nodes[0]
}
const latent = await expectSingleNode('EmptyLatentImage')
const sampler = await expectSingleNode('KSampler')
// Remove existing link
const samplerInput = await sampler.getInput(0)
await samplerInput.removeLinks()
// Group latent + sampler
await latent.click('title', {
modifiers: ['Shift']
})
await sampler.click('title', {
modifiers: ['Shift']
})
const groupNode = await sampler.convertToGroupNode()
// Connect node to group
const ckpt = await expectSingleNode('CheckpointLoaderSimple')
const input = await ckpt.connectOutput(0, groupNode, 0)
await expect.poll(() => input.getLinkCount()).toBe(1)
// Modify the group node via manage dialog
const manage = await groupNode.manageGroupNode()
await manage.selectNode('KSampler')
await manage.changeTab('Inputs')
await manage.setLabel('model', 'test')
await manage.save()
await manage.close()
// Ensure the link is still present
await expect.poll(() => input.getLinkCount()).toBe(1)
})
test('Loads from a workflow using the legacy path separator ("/")', async ({
comfyPage
}) => {
@@ -198,6 +249,11 @@ test.describe('Group Node', { tag: '@node' }, () => {
test.describe('Copy and paste', () => {
let groupNode: NodeReference | null
const WORKFLOW_NAME = 'groupnodes/group_node_v1.3.3'
const GROUP_NODE_CATEGORY = 'group nodes>workflow'
const GROUP_NODE_PREFIX = 'workflow>'
const GROUP_NODE_NAME = 'group_node' // Node name in given workflow
const GROUP_NODE_TYPE = `${GROUP_NODE_PREFIX}${GROUP_NODE_NAME}`
const isRegisteredLitegraph = async (comfyPage: ComfyPage) => {
return await comfyPage.page.evaluate((nodeType: string) => {
@@ -226,10 +282,10 @@ test.describe('Group Node', { tag: '@node' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.workflow.loadWorkflow(LOADED_WORKFLOW)
await comfyPage.workflow.loadWorkflow(WORKFLOW_NAME)
groupNode = await comfyPage.nodeOps.getFirstNodeRef()
if (!groupNode)
throw new Error(`Group node not found in workflow ${LOADED_WORKFLOW}`)
throw new Error(`Group node not found in workflow ${WORKFLOW_NAME}`)
await groupNode.copy()
})
@@ -243,7 +299,10 @@ test.describe('Group Node', { tag: '@node' }, () => {
test('Copies and pastes group node after clearing workflow', async ({
comfyPage
}) => {
// Set setting
await comfyPage.settings.setSetting('Comfy.ConfirmClear', false)
// Clear workflow
await comfyPage.command.executeCommand('Comfy.ClearWorkflow')
await comfyPage.clipboard.paste()
@@ -283,6 +342,24 @@ test.describe('Group Node', { tag: '@node' }, () => {
})
})
})
test.describe('Keybindings', () => {
test('Convert to group node, no selection', async ({ comfyPage }) => {
await expect(comfyPage.toast.visibleToasts).toHaveCount(0)
await comfyPage.page.keyboard.press('Alt+g')
await expect(comfyPage.toast.visibleToasts).toHaveCount(1)
})
test('Convert to group node, selected 1 node', async ({ comfyPage }) => {
await expect(comfyPage.toast.visibleToasts).toHaveCount(0)
await comfyPage.canvas.click({
position: DefaultGraphPositions.textEncodeNode1
})
await comfyPage.nextFrame()
await comfyPage.page.keyboard.press('Alt+g')
await expect(comfyPage.toast.visibleToasts).toHaveCount(1)
})
})
})
test('Convert to subgraph unpacks the group Node @vue-nodes', async ({

View File

@@ -1,5 +1,4 @@
import {
ComfyPage,
comfyPageFixture as test,
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
@@ -44,45 +43,4 @@ test.describe('Linear Mode', { tag: '@ui' }, () => {
await expect(comfyPage.page.getByTestId('linear-widgets')).toBeVisible()
await expect(comfyPage.canvas).toBeHidden()
})
test('Spinner persists until workflow loaded', async ({
page,
request
}, testInfo) => {
const comfyPage = new ComfyPage(page, request)
const { parallelIndex } = testInfo
const username = `playwright-test-${parallelIndex}`
const userId = await comfyPage.setupUser(username)
comfyPage.userIds[parallelIndex] = userId
await page.goto(`${comfyPage.url}/api/users`)
await page.evaluate((id) => {
localStorage.clear()
sessionStorage.clear()
localStorage.setItem('Comfy.userId', id)
}, comfyPage.id)
const splash = page.locator('#splash-loader')
let notifyWorkflowRequested!: () => void
const workflowRequested = new Promise<void>(
(r) => (notifyWorkflowRequested = r)
)
let unblockRequest!: () => void
const requestUnblocked = new Promise<void>((r) => (unblockRequest = r))
await page.route('**/templates/default.json', async (route) => {
notifyWorkflowRequested()
await requestUnblocked
return route.continue()
})
await comfyPage.goto({ url: `${comfyPage.url}/?template=default` })
await workflowRequested
await comfyPage.nextFrame()
await expect(splash).toBeVisible()
unblockRequest()
await expect(splash).toBeHidden()
})
})

View File

@@ -9,7 +9,7 @@ test.describe(
() => {
test.beforeEach(async ({ comfyPage }) => {
// Keep the viewport well below the menu content height so overflow is guaranteed.
await comfyPage.page.setViewportSize({ width: 1280, height: 420 })
await comfyPage.page.setViewportSize({ width: 1280, height: 520 })
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')

View File

@@ -46,7 +46,7 @@ test.describe(
test('Shape popover opens even when the menu must scroll', async ({
comfyPage
}) => {
await comfyPage.page.setViewportSize({ width: 1280, height: 420 })
await comfyPage.page.setViewportSize({ width: 1280, height: 520 })
const menu = await openMoreOptionsMenu(comfyPage, 'KSampler')
const rootList = menu.locator(':scope > ul')

View File

@@ -5,6 +5,7 @@ import {
test('Price badge displays on subgraphs @vue-nodes', async ({ comfyPage }) => {
const apiNodeName = 'Node With Price Badge'
await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'v1 (legacy)')
const priceBadge = comfyPage.page.locator('.lg-node-header i + span')
const apiNode = comfyPage.vueNodes.getNodeByTitle(apiNodeName)
@@ -12,7 +13,9 @@ test('Price badge displays on subgraphs @vue-nodes', async ({ comfyPage }) => {
await comfyPage.menu.topbar.newWorkflowButton.click()
await comfyPage.nextFrame()
await comfyPage.searchBoxV2.addNode(apiNodeName)
await comfyPage.page.mouse.dblclick(500, 500, { delay: 5 })
await comfyPage.searchBox.fillAndSelectFirstNode(apiNodeName)
await expect(comfyPage.searchBox.input).toBeHidden()
await expect(apiNode, 'Add partner node').toBeVisible()
await expect(apiNode.locator(priceBadge), 'Has price badge').toBeVisible()

View File

@@ -35,6 +35,23 @@ test.describe(
'add-group-group-added.png'
)
})
test('Can convert to group node', async ({ comfyPage }) => {
await comfyPage.nodeOps.selectNodes(['CLIP Text Encode (Prompt)'])
await expect(comfyPage.canvas).toHaveScreenshot('selected-2-nodes.png')
await comfyPage.canvasOps.rightClick()
await comfyPage.contextMenu.clickMenuItem(
'Convert to Group Node (Deprecated)'
)
await comfyPage.contextMenu.waitForHidden()
await comfyPage.nodeOps.promptDialogInput.fill('GroupNode2CLIP')
await comfyPage.page.keyboard.press('Enter')
await comfyPage.nodeOps.promptDialogInput.waitFor({ state: 'hidden' })
await comfyPage.expectScreenshot(
comfyPage.canvas,
'right-click-node-group-node.png'
)
})
}
)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 103 KiB

View File

@@ -129,26 +129,4 @@ test.describe('Node library sidebar V2', () => {
await expect(tab.nodePreview, 'Preview displays on hover').toBeVisible()
await expect(tab.nodePreview).toContainText('Inverts the image')
})
test('Click-to-place from sidebar selects the newly added node', async ({
comfyPage
}) => {
const tab = comfyPage.menu.nodeLibraryTabV2
await comfyPage.nodeOps.clearGraph()
await tab.expandFolder('sampling')
const canvasBox = (await comfyPage.canvas.boundingBox())!
const target = {
x: canvasBox.width / 2,
y: canvasBox.height / 2
}
await tab.getNode('KSampler (Advanced)').click()
await comfyPage.canvas.click({ position: target })
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(1)
await expect
.poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount())
.toBe(1)
})
})

View File

@@ -5,6 +5,10 @@ import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl',
'v1 (legacy)'
)
})
test.describe('Subgraph Clipboard Operations', () => {
@@ -54,7 +58,8 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
await comfyPage.searchBoxV2.addNode('Note')
await comfyPage.canvasOps.doubleClick()
await comfyPage.searchBox.fillAndSelectFirstNode('Note')
await comfyPage.nextFrame()
const initialCount = await comfyPage.subgraph.getNodeCount()

View File

@@ -745,19 +745,20 @@ test('Link already promoted widget @vue-nodes', async ({ comfyPage }) => {
})
test('Can promote multiple previews @vue-nodes', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'v1 (legacy)')
await comfyPage.menu.topbar.newWorkflowButton.click()
await comfyPage.nextFrame()
await test.step('Add and rename a Load Image node', async () => {
const position = { x: 300, y: 300 }
await comfyPage.searchBoxV2.addNode('Load Image', { position })
await comfyPage.page.mouse.dblclick(300, 300, { delay: 5 })
await comfyPage.searchBox.fillAndSelectFirstNode('Load Image')
const loadImage = await comfyPage.vueNodes.getFixtureByTitle('Load Image')
await loadImage.setTitle('Character Reference')
})
await test.step('Add a second Load Image node', async () => {
const position = { x: 600, y: 300 }
await comfyPage.searchBoxV2.addNode('Load Image', { position })
await comfyPage.page.mouse.dblclick(600, 300, { delay: 5 })
await comfyPage.searchBox.fillAndSelectFirstNode('Load Image')
})
await test.step('Convert both nodes to subgraph', async () => {

View File

@@ -1082,10 +1082,17 @@ test.describe(
comfyPage,
comfyMouse
}) => {
await comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl',
'v1 (legacy)'
)
// Setup workflow with a KSampler node
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
await comfyPage.nodeOps.waitForGraphNodes(0)
await comfyPage.searchBoxV2.addNode('KSampler')
await comfyPage.command.executeCommand('Workspace.SearchBox.Toggle')
await comfyPage.nextFrame()
await comfyPage.searchBox.fillAndSelectFirstNode('KSampler')
await comfyPage.nodeOps.waitForGraphNodes(1)
// Convert the KSampler node to a subgraph

View File

@@ -507,6 +507,25 @@ test.describe('Vue Node Context Menu', { tag: '@vue-nodes' }, () => {
.toBe(initialGroupCount + 1)
})
test('should convert to group node via context menu', async ({
comfyPage
}) => {
await openMultiNodeContextMenu(comfyPage, nodeTitles)
await clickExactMenuItem(comfyPage, 'Convert to Group Node')
await comfyPage.nodeOps.promptDialogInput.waitFor({ state: 'visible' })
await comfyPage.nodeOps.fillPromptDialog('TestGroupNode')
await expect
.poll(async () => {
const groupNodes = await comfyPage.nodeOps.getNodeRefsByType(
'workflow>TestGroupNode'
)
return groupNodes.length
})
.toBe(1)
})
test('should convert selected nodes to subgraph via context menu', async ({
comfyPage
}) => {

View File

@@ -19,19 +19,3 @@ test('Can display a slot mismatched from widget type', async ({
await expect(width.locator('path[fill*="INT"]')).toBeVisible()
await expect(width.locator('path[fill*="FLOAT"]')).toBeVisible()
})
test('MatchType updates output color @vue-nodes', async ({ comfyPage }) => {
await comfyPage.menu.topbar.newWorkflowButton.click()
await comfyPage.nextFrame()
await comfyPage.searchBoxV2.addNode('Load Image')
const loadImage = await comfyPage.vueNodes.getFixtureByTitle('Load Image')
await comfyPage.searchBoxV2.addNode('Switch', {
position: { x: 600, y: 200 }
})
const switchNode = await comfyPage.vueNodes.getFixtureByTitle('switch')
await loadImage.getSlot('MASK').dragTo(switchNode.getSlot('on_false'))
const slotEl = switchNode.getSlot('output').locator('.slot-dot')
await expect.poll(() => slotEl.getAttribute('style')).toContain('MASK')
})

View File

@@ -9,6 +9,8 @@ const file1 = 'workflow.mp4' as const
const file2 = 'workflow.webm' as const
test('@vue-nodes Load Video', async ({ comfyPage, comfyFiles }) => {
await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'v1 (legacy)')
const loadVideoNode = comfyPage.vueNodes.getNodeByTitle('Load Video')
const loadVideo = new VideoPreview(loadVideoNode)
@@ -16,7 +18,9 @@ test('@vue-nodes Load Video', async ({ comfyPage, comfyFiles }) => {
await comfyPage.menu.topbar.newWorkflowButton.click()
await comfyPage.nextFrame()
await comfyPage.searchBoxV2.addNode('Load Video')
await comfyPage.page.mouse.dblclick(500, 300, { delay: 5 })
await comfyPage.searchBox.fillAndSelectFirstNode('Load Video')
await expect(loadVideoNode).toHaveCount(1)
await expect(loadVideoNode).toBeVisible()
})

View File

@@ -5,6 +5,8 @@ import {
} from '@e2e/fixtures/ComfyPage'
test('@vue-nodes Audio Widget', async ({ comfyPage, comfyFiles }) => {
await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'v1 (legacy)')
const loadAudioNode = comfyPage.vueNodes.getNodeByTitle('Load Audio')
const audioPreview = new AudioPreview(loadAudioNode)
@@ -12,7 +14,9 @@ test('@vue-nodes Audio Widget', async ({ comfyPage, comfyFiles }) => {
await comfyPage.menu.topbar.newWorkflowButton.click()
await comfyPage.nextFrame()
await comfyPage.searchBoxV2.addNode('Load Audio')
//await comfyPage.canvasOps.doubleClick()
await comfyPage.page.mouse.dblclick(500, 500, { delay: 5 })
await comfyPage.searchBox.fillAndSelectFirstNode('Load Audio')
await expect(loadAudioNode).toBeVisible()
})

View File

@@ -113,7 +113,7 @@
"primevue": "catalog:",
"reka-ui": "catalog:",
"semver": "^7.7.2",
"three": "catalog:",
"three": "^0.170.0",
"tiptap-markdown": "^0.8.10",
"typegpu": "catalog:",
"vee-validate": "catalog:",

78
pnpm-lock.yaml generated
View File

@@ -91,8 +91,8 @@ catalogs:
specifier: ^10.32.1
version: 10.32.1
'@sparkjsdev/spark':
specifier: ^2.1.0
version: 2.1.0
specifier: ^0.1.10
version: 0.1.10
'@storybook/addon-docs':
specifier: ^10.2.10
version: 10.2.10
@@ -157,8 +157,8 @@ catalogs:
specifier: ^7.7.0
version: 7.7.0
'@types/three':
specifier: ^0.184.1
version: 0.184.1
specifier: ^0.170.0
version: 0.170.0
'@vee-validate/zod':
specifier: ^4.15.1
version: 4.15.1
@@ -337,8 +337,8 @@ catalogs:
specifier: ^0.6.1
version: 0.6.1
three:
specifier: ^0.184.0
version: 0.184.0
specifier: ^0.170.0
version: 0.170.0
tsx:
specifier: ^4.15.6
version: 4.19.4
@@ -438,7 +438,7 @@ importers:
version: link:packages/design-system
'@comfyorg/fbx-exporter-three':
specifier: ^1.0.1
version: 1.0.1(@types/three@0.184.1)(three@0.184.0)
version: 1.0.1(@types/three@0.170.0)(three@0.170.0)
'@comfyorg/object-info-parser':
specifier: workspace:*
version: link:packages/object-info-parser
@@ -483,7 +483,7 @@ importers:
version: 10.32.1(pinia@3.0.4(typescript@5.9.3)(vue@3.5.13(typescript@5.9.3)))(vue@3.5.13(typescript@5.9.3))
'@sparkjsdev/spark':
specifier: 'catalog:'
version: 2.1.0(three@0.184.0)
version: 0.1.10
'@tanstack/vue-virtual':
specifier: 'catalog:'
version: 3.13.12(vue@3.5.13(typescript@5.9.3))
@@ -596,8 +596,8 @@ importers:
specifier: ^7.7.2
version: 7.7.4
three:
specifier: 'catalog:'
version: 0.184.0
specifier: ^0.170.0
version: 0.170.0
tiptap-markdown:
specifier: ^0.8.10
version: 0.8.10(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))
@@ -621,7 +621,7 @@ importers:
version: 3.2.1(consola@3.4.2)(firebase@11.6.0)(vue@3.5.13(typescript@5.9.3))
wwobjloader2:
specifier: 'catalog:'
version: 6.2.1(three@0.184.0)
version: 6.2.1(three@0.170.0)
yjs:
specifier: 'catalog:'
version: 13.6.27
@@ -706,7 +706,7 @@ importers:
version: 7.7.0
'@types/three':
specifier: 'catalog:'
version: 0.184.1
version: 0.170.0
'@vitejs/plugin-vue':
specifier: 'catalog:'
version: 6.0.3(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.13(typescript@5.9.3))
@@ -980,7 +980,7 @@ importers:
version: 1.358.1
three:
specifier: 'catalog:'
version: 0.184.0
version: 0.170.0
vue:
specifier: 'catalog:'
version: 3.5.13(typescript@5.9.3)
@@ -1850,9 +1850,6 @@ packages:
'@cyberalien/svg-utils@1.1.1':
resolution: {integrity: sha512-i05Cnpzeezf3eJAXLx7aFirTYYoq5D1XUItp1XsjqkerNJh//6BG9sOYHbiO7v0KYMvJAx3kosrZaRcNlQPdsA==}
'@dimforge/rapier3d-compat@0.12.0':
resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==}
'@dual-bundle/import-meta-resolve@4.2.1':
resolution: {integrity: sha512-id+7YRUgoUX6CgV0DtuhirQWodeeA7Lf4i2x71JS/vtA5pRb/hIGWlw+G6MeXvsM+MXrz0VAydTGElX1rAfgPg==}
@@ -3951,10 +3948,8 @@ packages:
resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==}
engines: {node: '>=18'}
'@sparkjsdev/spark@2.1.0':
resolution: {integrity: sha512-BRw+MuMzx0B3K8fDLQygt2OHEhYUV+41RX7btq9pZ3rCVrq42o57jW34VAIvC7JO/84DJh/1AutACV9ym6BfVg==}
peerDependencies:
three: '>=0.180.0'
'@sparkjsdev/spark@0.1.10':
resolution: {integrity: sha512-CiijdZQuj7KPDUqIZPiEqyUkJCYo1JqR05vq/V+ElxMwqR7L70ZuZDyIKcasjZHSiPB8pGRMH8HZGqUKO9aRPQ==}
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
@@ -4429,8 +4424,8 @@ packages:
'@types/stats.js@0.17.3':
resolution: {integrity: sha512-pXNfAD3KHOdif9EQXZ9deK82HVNaXP5ZIF5RP2QG6OQFNTaY2YIetfrE9t528vEreGQvEPRDDc8muaoYeK0SxQ==}
'@types/three@0.184.1':
resolution: {integrity: sha512-6q4VdiqVsrTRqmk62/BnlcAvIrnDM0zf2ZDVKI5kZiniWrSaOHaQzmbp+BNzoggc/8tgW412pL//wZIxu2PPTA==}
'@types/three@0.170.0':
resolution: {integrity: sha512-CUm2uckq+zkCY7ZbFpviRttY+6f9fvwm6YqSqPfA5K22s9w7R4VnA3rzJse8kHVvuzLcTx+CjNCs2NYe0QFAyg==}
'@types/tough-cookie@4.0.5':
resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==}
@@ -7446,8 +7441,8 @@ packages:
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
engines: {node: '>= 8'}
meshoptimizer@1.1.1:
resolution: {integrity: sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==}
meshoptimizer@0.18.1:
resolution: {integrity: sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw==}
micromark-core-commonmark@2.0.3:
resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==}
@@ -8828,8 +8823,8 @@ packages:
engines: {node: '>=10'}
hasBin: true
three@0.184.0:
resolution: {integrity: sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg==}
three@0.170.0:
resolution: {integrity: sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ==}
tiny-inflate@1.0.3:
resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==}
@@ -10880,12 +10875,12 @@ snapshots:
'@comfyorg/comfyui-electron-types@0.6.2': {}
'@comfyorg/fbx-exporter-three@1.0.1(@types/three@0.184.1)(three@0.184.0)':
'@comfyorg/fbx-exporter-three@1.0.1(@types/three@0.170.0)(three@0.170.0)':
dependencies:
fflate: 0.8.2
three: 0.184.0
three: 0.170.0
optionalDependencies:
'@types/three': 0.184.1
'@types/three': 0.170.0
'@csstools/color-helpers@5.1.0': {}
@@ -10922,8 +10917,6 @@ snapshots:
dependencies:
'@iconify/types': 2.0.0
'@dimforge/rapier3d-compat@0.12.0': {}
'@dual-bundle/import-meta-resolve@4.2.1': {}
'@emmetio/abbreviation@2.3.3':
@@ -12887,10 +12880,9 @@ snapshots:
'@sindresorhus/merge-streams@4.0.0': {}
'@sparkjsdev/spark@2.1.0(three@0.184.0)':
'@sparkjsdev/spark@0.1.10':
dependencies:
fflate: 0.8.2
three: 0.184.0
'@standard-schema/spec@1.1.0': {}
@@ -13413,14 +13405,14 @@ snapshots:
'@types/stats.js@0.17.3': {}
'@types/three@0.184.1':
'@types/three@0.170.0':
dependencies:
'@dimforge/rapier3d-compat': 0.12.0
'@tweenjs/tween.js': 23.1.3
'@types/stats.js': 0.17.3
'@types/webxr': 0.5.20
'@webgpu/types': 0.1.66
fflate: 0.8.2
meshoptimizer: 1.1.1
meshoptimizer: 0.18.1
'@types/tough-cookie@4.0.5': {}
@@ -16918,7 +16910,7 @@ snapshots:
merge2@1.4.1: {}
meshoptimizer@1.1.1: {}
meshoptimizer@0.18.1: {}
micromark-core-commonmark@2.0.3:
dependencies:
@@ -18780,7 +18772,7 @@ snapshots:
commander: 2.20.3
source-map-support: 0.5.21
three@0.184.0: {}
three@0.170.0: {}
tiny-inflate@1.0.3: {}
@@ -19839,16 +19831,16 @@ snapshots:
wtd-core@3.0.0: {}
wtd-three-ext@3.0.0(three@0.184.0):
wtd-three-ext@3.0.0(three@0.170.0):
dependencies:
three: 0.184.0
three: 0.170.0
wtd-core: 3.0.0
wwobjloader2@6.2.1(three@0.184.0):
wwobjloader2@6.2.1(three@0.170.0):
dependencies:
three: 0.184.0
three: 0.170.0
wtd-core: 3.0.0
wtd-three-ext: 3.0.0(three@0.184.0)
wtd-three-ext: 3.0.0(three@0.170.0)
xdg-basedir@5.1.0: {}

View File

@@ -36,7 +36,7 @@ catalog:
'@primevue/themes': ^4.2.5
'@sentry/vite-plugin': ^4.6.0
'@sentry/vue': ^10.32.1
'@sparkjsdev/spark': ^2.1.0
'@sparkjsdev/spark': ^0.1.10
'@storybook/addon-docs': ^10.2.10
'@storybook/addon-mcp': 0.1.6
'@storybook/vue3': ^10.2.10
@@ -59,7 +59,7 @@ catalog:
'@types/jsdom': ^21.1.7
'@types/node': ^24.1.0
'@types/semver': ^7.7.0
'@types/three': ^0.184.1
'@types/three': ^0.170.0
'@vee-validate/zod': ^4.15.1
'@vercel/analytics': ^2.0.1
'@vitejs/plugin-vue': ^6.0.0
@@ -118,7 +118,7 @@ catalog:
storybook: ^10.2.10
stylelint: ^16.26.1
tailwindcss: ^4.3.0
three: ^0.184.0
three: ^0.170.0
tailwindcss-primeui: ^0.6.1
tsx: ^4.15.6
tw-animate-css: ^1.3.8

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 274 B

After

Width:  |  Height:  |  Size: 647 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 277 B

After

Width:  |  Height:  |  Size: 674 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 269 B

After

Width:  |  Height:  |  Size: 674 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 284 B

After

Width:  |  Height:  |  Size: 674 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 281 B

After

Width:  |  Height:  |  Size: 698 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 277 B

After

Width:  |  Height:  |  Size: 700 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 280 B

After

Width:  |  Height:  |  Size: 702 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 302 B

After

Width:  |  Height:  |  Size: 705 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 285 B

After

Width:  |  Height:  |  Size: 708 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 296 B

After

Width:  |  Height:  |  Size: 705 B

View File

@@ -1,52 +1,55 @@
<template>
<div
ref="container"
class="flex h-7 rounded-lg bg-component-node-widget-background text-xs text-component-node-foreground"
class="relative flex h-7 rounded-lg bg-component-node-widget-background text-xs text-component-node-foreground"
>
<slot name="background" />
<Button
v-if="!hideButtons"
:aria-label="t('g.decrement')"
data-testid="decrement"
class="aspect-8/7 h-full rounded-r-none hover:bg-base-foreground/20 disabled:opacity-30"
:class="
cn(
'aspect-8/7 h-full rounded-r-none hover:bg-base-foreground/20 disabled:opacity-30',
dragging && 'opacity-0!'
)
"
variant="muted-textonly"
:disabled="!canDecrement"
tabindex="-1"
@click="modelValue = clamp(modelValue - step)"
@click="scrub.setValue(clamp(modelValue - step))"
>
<i class="pi pi-minus" />
</Button>
<div class="relative my-0.25 min-w-[4ch] flex-1 py-1.5">
<div class="relative my-px min-w-[4ch] flex-1 py-1.5">
<input
ref="inputField"
v-bind="inputAttrs"
:value="displayValue ?? modelValue"
:disabled
:class="
cn(
'absolute inset-0 truncate border-0 bg-transparent p-1 text-sm focus:outline-0'
)
"
class="absolute inset-0 truncate border-0 bg-transparent p-1 text-sm focus:outline-0"
inputmode="decimal"
autocomplete="off"
autocorrect="off"
spellcheck="false"
@blur="handleBlur"
@keyup.enter="handleBlur"
@keydown.up.prevent="updateValueBy(step)"
@keydown.down.prevent="updateValueBy(-step)"
@keydown.page-up.prevent="updateValueBy(10 * step)"
@keydown.page-down.prevent="updateValueBy(-10 * step)"
@keydown.up.prevent="scrub.setValue(clamp(modelValue + step))"
@keydown.down.prevent="scrub.setValue(clamp(modelValue - step))"
@keydown.page-up.prevent="scrub.setValue(clamp(modelValue + 10 * step))"
@keydown.page-down.prevent="
scrub.setValue(clamp(modelValue - 10 * step))
"
/>
<div
ref="swipeElement"
:class="
cn(
'absolute inset-0 z-10 cursor-ew-resize touch-pan-y',
'absolute inset-0 z-10 touch-pan-y',
dragging ? 'cursor-grabbing' : 'cursor-ew-resize',
textEdit && 'pointer-events-none hidden'
)
"
@pointerup="handlePointerUp"
/>
</div>
<slot />
@@ -54,25 +57,73 @@
v-if="!hideButtons"
:aria-label="t('g.increment')"
data-testid="increment"
class="aspect-8/7 h-full rounded-l-none hover:bg-base-foreground/20 disabled:opacity-30"
:class="
cn(
'aspect-8/7 h-full rounded-l-none hover:bg-base-foreground/20 disabled:opacity-30',
dragging && 'opacity-0!'
)
"
variant="muted-textonly"
:disabled="!canIncrement"
tabindex="-1"
@click="modelValue = clamp(modelValue + step)"
@click="scrub.setValue(clamp(modelValue + step))"
>
<i class="pi pi-plus" />
</Button>
<BallRuler
v-if="dragging"
:state="scrub.state"
:width="containerWidth"
:min
:max
:has-bar="barVisible"
/>
<div
v-if="dragging"
class="pointer-events-none absolute inset-0 text-component-node-foreground"
aria-hidden="true"
>
<i
class="absolute top-0 left-1/2 icon-[lucide--chevron-up] size-3 -translate-x-1/2 opacity-25"
/>
<i
class="absolute bottom-0 left-1/2 icon-[lucide--chevron-down] size-3 -translate-x-1/2 opacity-25"
/>
<i
class="absolute top-1/2 left-0 icon-[lucide--chevron-left] size-3 -translate-y-1/2 opacity-25"
/>
<i
class="absolute top-1/2 right-0 icon-[lucide--chevron-right] size-3 -translate-y-1/2 opacity-25"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { onClickOutside, usePointerSwipe, whenever } from '@vueuse/core'
import { computed, ref, useTemplateRef } from 'vue'
import { onClickOutside, useElementSize } from '@vueuse/core'
import { clamp as _clamp } from 'es-toolkit'
import { computed, ref, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { cn } from '@comfyorg/tailwind-utils'
import BallRuler from './scrubableNumberInput/BallRuler.vue'
import { useDragGesture } from './scrubableNumberInput/useDragGesture'
import { useScrubValue } from './scrubableNumberInput/useScrubValue'
// ---- Tunable sensitivity envelope -------------------------------------------
// They translate the Y-axis sensitivity range into
// concrete drag-distance promises so the bounds feel meaningful:
// - DRAG_PX_FOR_FULL_RANGE: at *max* sensitivity, dragging this many screen
// pixels traverses the entire [min, max] range. Smaller = faster, easier
// to overshoot. Larger = more conservative, harder to overshoot.
// - DRAG_PX_PER_STEP_AT_FLOOR: at *min* sensitivity, dragging this many
// screen pixels advances by exactly one `step`. Larger = finer control
// when dialing slow. Smaller = floor isn't as fine.
const DRAG_PX_FOR_FULL_RANGE = 250
const DRAG_PX_PER_STEP_AT_FLOOR = 25
const {
min = -Number.MAX_VALUE,
max = Number.MAX_VALUE,
@@ -97,17 +148,90 @@ const modelValue = defineModel<number>({ default: 0 })
const container = useTemplateRef<HTMLDivElement>('container')
const inputField = useTemplateRef<HTMLInputElement>('inputField')
const swipeElement = useTemplateRef('swipeElement')
const swipeElement = useTemplateRef<HTMLDivElement>('swipeElement')
const textEdit = ref(false)
// useElementSize is backed by ResizeObserver.contentRect, which reports in
// logical CSS pixels and ignores ancestor transforms (canvas zoom). That's
// exactly the unit the SVG and the bar fill's `width: %` both render in, so
// no zoom compensation is needed anywhere.
const { width: containerWidth } = useElementSize(container)
const hasFiniteRange = computed(
() => Number.isFinite(min) && Number.isFinite(max) && max > min
)
const barVisible = computed(
() => hasFiniteRange.value && containerWidth.value > 0
)
function clamp(value: number): number {
return _clamp(value, min, max)
}
function quantize(value: number): number {
return step > 0 ? Math.round(value / step) * step : value
}
function validate(value: number): number {
return clamp(quantize(value))
}
const stepSize = computed(() => (step > 0 ? step : 1))
const scrub = useScrubValue({
initial: modelValue.value,
// Step-based sensitivity: at speedMult=1, one step per screen pixel of
// drag. Intentionally independent of canvas zoom and input-field width —
// pointer-lock hides the cursor, so there's no "drag full bar = full range"
// affordance to preserve.
baseSpeed: stepSize,
// Floor: at minimum sensitivity, advancing one step takes
// DRAG_PX_PER_STEP_AT_FLOOR pixels. Derived: step * speedMult = step / px
// ⇒ speedMult = 1 / px.
minSpeed: 1 / DRAG_PX_PER_STEP_AT_FLOOR,
// Ceiling: at maximum sensitivity, traversing the entire range takes
// DRAG_PX_FOR_FULL_RANGE pixels. Derived: step * speedMult * px = range
// ⇒ speedMult = range / (step * px). Falls back to a generous constant
// when the range is unbounded (no calibration target).
maxSpeed: computed(() =>
hasFiniteRange.value
? (max - min) / (stepSize.value * DRAG_PX_FOR_FULL_RANGE)
: 1000
),
validate: (v) => (hasFiniteRange.value ? validate(v) : v),
onChange: (v) => {
modelValue.value = v
}
})
watch(modelValue, (v) => {
if (v !== scrub.state.value) scrub.setValue(v)
})
const { dragging } = useDragGesture(swipeElement, {
disabled: computed(() => disabled || textEdit.value),
lockPointer: true,
// A plain click should focus the input — not hide the cursor. So pointer
// lock and onDragStart only fire once the user has *committed* to a scrub:
// either by crossing the movement threshold, or by holding still past the
// long-press delay (matches Tweeq's default).
dragDelaySeconds: 0.5,
// Drag always modifies from the *current* value — no jump-to-click. The
// gesture deltas via interpretGesture are inherently relative.
onDragStart: () => scrub.reset(),
onDrag: (dx, dy) => scrub.apply(dx, dy),
onDragEnd: () => {
if (step > 0) scrub.setValue(validate(scrub.state.value))
},
onClick: () => {
textEdit.value = true
inputField.value?.focus()
inputField.value?.select()
}
})
onClickOutside(container, () => {
if (textEdit.value) textEdit.value = false
})
function clamp(value: number): number {
return Math.min(max, Math.max(min, value))
}
const canDecrement = computed(() => modelValue.value > min && !disabled)
const canIncrement = computed(() => modelValue.value < max && !disabled)
@@ -120,34 +244,10 @@ function handleBlur(e: Event) {
? undefined
: Number(raw)
if (parsed != null && !isNaN(parsed)) {
modelValue.value = clamp(parsed)
scrub.setValue(clamp(parsed))
} else {
target.value = displayValue ?? String(modelValue.value)
}
textEdit.value = false
}
let dragDelta = 0
function handlePointerUp() {
if (isSwiping.value) return
textEdit.value = true
inputField.value?.focus()
inputField.value?.select()
}
const { distanceX, isSwiping } = usePointerSwipe(swipeElement, {
onSwipeEnd: () => (dragDelta = 0)
})
whenever(distanceX, () => {
if (disabled) return
const delta = ((distanceX.value - dragDelta) / 10) | 0
dragDelta += delta * 10
modelValue.value = clamp(modelValue.value - delta * step)
})
function updateValueBy(delta: number) {
modelValue.value = Math.min(max, Math.max(min, modelValue.value + delta))
}
</script>

View File

@@ -0,0 +1,70 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { ScrubState } from './useScrubValue'
const {
state,
width,
min,
max,
hasBar = false
} = defineProps<{
state: Readonly<ScrubState>
/** Container width in *logical* CSS pixels (post-zoom dimensions divided by zoom). */
width: number
min: number
max: number
/** Anchor mode: bar mode pins a ball to the handle; free mode pins to center at value=0. */
hasBar?: boolean
}>()
function mod(a: number, n: number): number {
return ((a % n) + n) % n
}
function smoothstep(a: number, b: number, x: number): number {
const t = Math.max(0, Math.min(1, (x - a) / (b - a)))
return t * t * (3 - 2 * t)
}
const dashOffset = computed(() =>
hasBar
? ((state.value - min) / (max - min)) * width
: width / 2 - state.value / state.speedMult
)
const layers = computed(() =>
Array.from({ length: 3 }, (_, i) => {
const precision = mod(-Math.log10(state.speedMult) + i, 3)
return {
precision,
gap: Math.pow(10, precision),
opacity: Math.pow(smoothstep(1, 2, precision), 0.5)
}
}).filter((layer) => layer.opacity >= 0.01)
)
</script>
<template>
<svg
class="pointer-events-none absolute inset-0 size-full overflow-hidden rounded-lg"
aria-hidden="true"
>
<line
v-for="layer in layers"
:key="layer.precision"
:x1="0"
:x2="width"
y1="50%"
y2="50%"
fill="none"
stroke-linecap="round"
:stroke="`color-mix(in srgb, var(--p-primary-color), var(--p-text-muted-color) ${state.weight * 100}%)`"
:stroke-width="4 - state.weight"
:stroke-dasharray="`0 ${layer.gap}`"
:stroke-dashoffset="-dashOffset"
:opacity="layer.opacity"
/>
</svg>
</template>

View File

@@ -0,0 +1,78 @@
import { describe, expect, it } from 'vitest'
import type { GestureState } from './interpretGesture'
import { interpretGesture } from './interpretGesture'
function baseState(overrides: Partial<GestureState> = {}): GestureState {
return {
dirAvg: [1, 0],
speedMult: 1,
baseSpeed: 1,
...overrides
}
}
describe('interpretGesture', () => {
it('pure horizontal drag → all delta becomes value change, sensitivity unchanged', () => {
const r = interpretGesture(baseState(), 10, 0)
expect(r.weight).toBe(1)
expect(r.valueDelta).toBe(10)
expect(r.speedMultNext).toBe(1)
})
it('pure vertical drag → no value change, sensitivity scales by 0.98^dy', () => {
const r = interpretGesture(baseState({ dirAvg: [0, 1] }), 0, 10)
expect(r.weight).toBe(0)
expect(r.valueDelta).toBe(0)
expect(r.speedMultNext).toBeCloseTo(Math.pow(0.98, 10), 10)
})
it('zero delta is a no-op for value and sensitivity', () => {
const r = interpretGesture(baseState(), 0, 0)
expect(r.valueDelta).toBe(0)
expect(r.speedMultNext).toBe(1)
})
it('respects modifierSpeed', () => {
const r = interpretGesture(baseState({ modifierSpeed: 10 }), 5, 0)
expect(r.valueDelta).toBe(50)
})
it('clamps speedMult to [minSpeed, maxSpeed]', () => {
const veryFast = interpretGesture(
baseState({ dirAvg: [0, 1], speedMult: 0.5, minSpeed: 0.1, maxSpeed: 1 }),
0,
-1000
)
expect(veryFast.speedMultNext).toBe(1)
const verySlow = interpretGesture(
baseState({ dirAvg: [0, 1], speedMult: 0.5, minSpeed: 0.1, maxSpeed: 1 }),
0,
1000
)
expect(verySlow.speedMultNext).toBe(0.1)
})
it('diagonal drag blends value and sensitivity proportionally', () => {
// Slightly-vertical-leaning dirAvg with small deltas → normalized x
// component lands in the smoothstep transition zone (0.4..0.6).
const r = interpretGesture(baseState({ dirAvg: [0.5, 0.866] }), 1, 2)
expect(r.weight).toBeGreaterThan(0)
expect(r.weight).toBeLessThan(1)
expect(r.valueDelta).toBeGreaterThan(0)
expect(r.valueDelta).toBeLessThan(1)
expect(r.speedMultNext).toBeLessThan(1)
})
it('dirAvgNext is normalized (unit length)', () => {
const r = interpretGesture(baseState(), 7, 13)
expect(Math.hypot(...r.dirAvgNext)).toBeCloseTo(1, 10)
})
it('is deterministic — same inputs produce same outputs', () => {
const a = interpretGesture(baseState({ speedMult: 0.3 }), 4, -2)
const b = interpretGesture(baseState({ speedMult: 0.3 }), 4, -2)
expect(a).toEqual(b)
})
})

View File

@@ -0,0 +1,75 @@
/**
* Pure algorithmic core of the Tweeq-style drag scrub. No Vue, no DOM.
*
* Given a smoothed direction average and the current sensitivity multiplier,
* decides how much of an incoming pointer delta becomes a value change
* (X-axis intent) vs. a sensitivity change (Y-axis intent), with a smooth
* crossfade between the two so diagonal motion never feels jerky.
*/
export interface GestureState {
/** EMA of |delta|, normalized to unit length. */
dirAvg: [number, number]
/** Current sensitivity multiplier. */
speedMult: number
/** Value-units per pixel at speedMult = 1. */
baseSpeed: number
/** Lower clamp on speedMult. Default 1e-4. */
minSpeed?: number
/** Upper clamp on speedMult. Default 1 (bounded) or 1000 (unbounded). */
maxSpeed?: number
/** Extra multiplier from external sources (modifier keys). Default 1. */
modifierSpeed?: number
}
interface GestureUpdate {
dirAvgNext: [number, number]
/** How "horizontal" the gesture is, ∈ [0, 1]. */
weight: number
/** Amount to add to the value this tick. */
valueDelta: number
/** New sensitivity multiplier (already clamped). */
speedMultNext: number
}
export function interpretGesture(
state: GestureState,
dx: number,
dy: number
): GestureUpdate {
const absX = Math.abs(dx)
const absY = Math.abs(dy)
const dirAvgNext = normalize([
state.dirAvg[0] * 0.9 + absX * 0.1,
state.dirAvg[1] * 0.9 + absY * 0.1
])
const weight = smoothstep(0.4, 0.6, Math.abs(dirAvgNext[0]))
const modifierSpeed = state.modifierSpeed ?? 1
const valueDelta =
dx * state.baseSpeed * state.speedMult * modifierSpeed * weight
const speedMultRaw = state.speedMult * Math.pow(0.98, dy)
const speedMultNext = clamp(
speedMultRaw * (1 - weight) + state.speedMult * weight,
state.minSpeed ?? 1e-4,
state.maxSpeed ?? 1
)
return { dirAvgNext, weight, valueDelta, speedMultNext }
}
function smoothstep(a: number, b: number, x: number): number {
const t = Math.max(0, Math.min(1, (x - a) / (b - a)))
return t * t * (3 - 2 * t)
}
function normalize([x, y]: readonly [number, number]): [number, number] {
const m = Math.hypot(x, y) || 1
return [x / m, y / m]
}
function clamp(v: number, a: number, b: number): number {
return Math.min(b, Math.max(a, v))
}

View File

@@ -0,0 +1,141 @@
import { useEventListener, usePointerLock } from '@vueuse/core'
import type { MaybeRef } from 'vue'
import { readonly, ref, unref } from 'vue'
type DragPointerType = 'mouse' | 'pen' | 'touch'
interface DragGestureOptions {
/** Engage pointerLock for the duration of the drag. */
lockPointer?: MaybeRef<boolean>
disabled?: MaybeRef<boolean>
/** Hold-to-drag delay in seconds. Set to 0 for instant drag. Default 0. */
dragDelaySeconds?: number
/** Pointer types accepted. Default mouse + pen + touch. */
pointerType?: DragPointerType[]
onDragStart?: (event: PointerEvent) => void
/** dx/dy are in physical pixels with browser-zoom compensated out. */
onDrag?: (dx: number, dy: number, event: PointerEvent) => void
onDragEnd?: (event: PointerEvent) => void
/** Fires when the pointer is released without ever crossing the drag threshold. */
onClick?: (event: PointerEvent) => void
}
/**
* DOM-only pointer wrangling. Hides ~100 lines of finicky pointer-event
* choreography behind a 4-callback interface:
* - pointer capture so the drag survives leaving the element
* - optional pointer lock (for unbounded scrubbing)
* - drag-vs-click discrimination (timer + distance threshold)
* - pointercancel / pointerleave fallbacks so onDragEnd always fires
* - primary-button / pointer-type filtering
* - browser zoom compensation (event.movementX scaled by 1/zoom)
*
* Consumers receive dx/dy and don't need to know any of the above exists.
*/
export function useDragGesture(
target: MaybeRef<HTMLElement | null | undefined>,
options: DragGestureOptions = {}
): { dragging: Readonly<ReturnType<typeof ref<boolean>>> } {
const dragging = ref(false)
const allowedTypes = options.pointerType ?? ['mouse', 'pen', 'touch']
const dragDelay = options.dragDelaySeconds ?? 0
const { lock, unlock } = usePointerLock(target)
let pointerId: number | null = null
let pointerDownAt: [number, number] | null = null
let dragDelayTimer: ReturnType<typeof setTimeout> | undefined
let pointerLocked = false
function teardown() {
if (dragDelayTimer !== undefined) {
clearTimeout(dragDelayTimer)
dragDelayTimer = undefined
}
pointerDownAt = null
pointerId = null
}
function fireStart(event: PointerEvent) {
dragging.value = true
if (unref(options.lockPointer) && !pointerLocked) {
pointerLocked = true
void lock(event).catch(() => {
pointerLocked = false
})
}
options.onDragStart?.(event)
}
function onPointerDown(event: PointerEvent) {
if (unref(options.disabled)) return
if (event.button !== 0 || !event.isPrimary) return
if (!allowedTypes.includes(event.pointerType as DragPointerType)) return
pointerId = event.pointerId
pointerDownAt = [event.clientX, event.clientY]
const el = unref(target)
el?.setPointerCapture(pointerId)
// Drag commitment is decided later — either by the movement-distance
// threshold in onPointerMove, or by this long-press timer expiring while
// the pointer is still down. Until then it's just a potential click.
if (dragDelay === 0) return
dragDelayTimer = setTimeout(() => fireStart(event), dragDelay * 1000)
}
function onPointerMove(event: PointerEvent) {
if (pointerId !== event.pointerId || pointerDownAt === null) return
if (!dragging.value) {
// Lock engages inside fireStart, not yet — so clientX/Y is still valid
// for the drag-vs-click distance threshold.
const minDist = event.pointerType === 'mouse' ? 1 : 5
const moved = Math.hypot(
event.clientX - pointerDownAt[0],
event.clientY - pointerDownAt[1]
)
if (moved < minDist) return
if (dragDelayTimer !== undefined) {
clearTimeout(dragDelayTimer)
dragDelayTimer = undefined
}
fireStart(event)
}
// Compensate for browser zoom (Cmd +/-). event.movementX/Y report in
// device-pixel-like units that don't honor the browser zoom level; the
// ratio outerWidth/innerWidth backs that out.
const browserZoom = window.outerWidth / window.innerWidth || 1
const dx = (event.movementX || 0) / browserZoom
const dy = (event.movementY || 0) / browserZoom
options.onDrag?.(dx, dy, event)
}
function onPointerUp(event: PointerEvent) {
if (pointerId !== event.pointerId) return
const el = unref(target)
el?.releasePointerCapture(event.pointerId)
const wasDragging = dragging.value
if (pointerLocked) {
void unlock()
pointerLocked = false
}
if (wasDragging) {
options.onDragEnd?.(event)
} else {
options.onClick?.(event)
}
dragging.value = false
teardown()
}
useEventListener(target, 'pointerdown', onPointerDown)
useEventListener(target, 'pointermove', onPointerMove)
useEventListener(target, 'pointerup', onPointerUp)
useEventListener(target, 'pointercancel', onPointerUp)
useEventListener(target, 'pointerleave', onPointerUp)
return { dragging: readonly(dragging) }
}

View File

@@ -0,0 +1,119 @@
import type { MaybeRef } from 'vue'
import { computed, reactive, readonly, unref, watch } from 'vue'
import type { GestureState } from './interpretGesture'
import { interpretGesture } from './interpretGesture'
interface ScrubOptions {
/** Initial value. Pulled once during setup; later sync via setValue(). */
initial: number
/** Value-units per pixel at speedMult = 1. Reactive. */
baseSpeed: MaybeRef<number>
/** Min sensitivity multiplier. */
minSpeed?: MaybeRef<number>
/** Max sensitivity multiplier. */
maxSpeed?: MaybeRef<number>
/** External multiplier (e.g. modifier keys). */
modifierSpeed?: MaybeRef<number>
/** Optional post-mutation validator (clamp/quantize/snap composition). */
validate?: (value: number) => number
/** Notified whenever value changes from inside (apply / setValue). */
onChange?: (value: number) => void
}
export interface ScrubState {
readonly value: number
readonly speedMult: number
/** Horizontality weight from the latest apply(). Useful for visuals. */
readonly weight: number
}
interface ScrubInstance {
state: Readonly<ScrubState>
/** Feed one pointer-tick into the scrub algorithm. */
apply(dx: number, dy: number): void
/** Reset gesture-internal state (speedMult, direction, weight). Does NOT touch value. */
reset(): void
/** Replace the current value (e.g. external model update, jump-to-click). */
setValue(value: number): void
}
/**
* Owns all mutable scrub state for a single input. Hides direction EMA,
* accumulator, and validator chain from callers — apply/reset/setValue are
* the only mutators.
*
* The raw accumulator (`internal.raw`) is intentionally NOT quantized. Tiny
* per-frame deltas under one step would otherwise be rounded to zero and
* the value would never move. By accumulating raw and quantizing only on
* the way out, sub-step motion accrues until it crosses a step boundary.
*/
export function useScrubValue(opts: ScrubOptions): ScrubInstance {
const internal = reactive({
raw: opts.initial,
speedMult: 1,
weight: 1,
dirAvg: [1, 0] as [number, number]
})
const validated = computed(() =>
opts.validate ? opts.validate(internal.raw) : internal.raw
)
let lastEmitted = validated.value
watch(validated, (v) => {
if (v === lastEmitted) return
lastEmitted = v
opts.onChange?.(v)
})
function apply(dx: number, dy: number) {
const gestureState: GestureState = {
dirAvg: internal.dirAvg,
speedMult: internal.speedMult,
baseSpeed: unref(opts.baseSpeed),
minSpeed: unref(opts.minSpeed),
maxSpeed: unref(opts.maxSpeed),
modifierSpeed: unref(opts.modifierSpeed)
}
const { dirAvgNext, weight, valueDelta, speedMultNext } = interpretGesture(
gestureState,
dx,
dy
)
internal.dirAvg = dirAvgNext
internal.weight = weight
internal.speedMult = speedMultNext
internal.raw += valueDelta
}
/**
* Reset transient gesture state — direction EMA and weight — that carries
* no meaning between drags. Intentionally does NOT touch `speedMult`: the
* Y-axis sensitivity the user dialed in during one drag persists into the
* next, so a slip-up release doesn't force them to re-calibrate.
*/
function reset() {
internal.weight = 1
internal.dirAvg = [1, 0]
}
/**
* Replace the raw accumulator. Use to snap to a clean value after a drag
* (orchestrator calls setValue(state.value) at drag end to discard any
* sub-step residual), or to sync with an external model change.
*/
function setValue(value: number) {
internal.raw = value
}
const state = readonly(
reactive({
value: validated,
speedMult: computed(() => internal.speedMult),
weight: computed(() => internal.weight)
})
) as Readonly<ScrubState>
return { state, apply, reset, setValue }
}

View File

@@ -744,6 +744,10 @@ const sortOptions = computed(() => [
value: 'popular'
},
{ name: t('templateWorkflows.sort.newest', 'Newest'), value: 'newest' },
{
name: t('templateWorkflows.sort.vramLowToHigh', 'VRAM Usage (Low to High)'),
value: 'vram-low-to-high'
},
{
name: t(
'templateWorkflows.sort.modelSizeLowToHigh',

View File

@@ -541,26 +541,32 @@ onMounted(async () => {
}
vueNodeLifecycle.setupEmptyGraphListener()
// Load color palette
colorPaletteStore.customPalettes = settingStore.get(
'Comfy.CustomColorPalettes'
)
// Restore saved workflow and workflow tabs state
await workflowPersistence.initializeWorkflow()
await workflowPersistence.restoreWorkflowTabsState()
await workflowPersistence.loadTemplateFromUrlIfPresent()
} finally {
workspaceStore.spinner = false
}
await workflowPersistence.loadSharedWorkflowFromUrlIfPresent()
comfyApp.canvas.onSelectionChange = useChainCallback(
comfyApp.canvas.onSelectionChange,
() => canvasStore.updateSelectedItems()
)
// Load color palette
colorPaletteStore.customPalettes = settingStore.get(
'Comfy.CustomColorPalettes'
)
// Restore saved workflow and workflow tabs state
await workflowPersistence.initializeWorkflow()
await workflowPersistence.restoreWorkflowTabsState()
const sharedWorkflowLoadStatus =
await workflowPersistence.loadSharedWorkflowFromUrlIfPresent()
// Load template from URL if present
if (sharedWorkflowLoadStatus === 'not-present') {
await workflowPersistence.loadTemplateFromUrlIfPresent()
}
// Accept workspace invite from URL if present (e.g., ?invite=TOKEN)
// WorkspaceAuthGate ensures flag state is resolved before GraphCanvas mounts
if (inviteUrlLoader && flags.teamWorkspacesEnabled) {

View File

@@ -4,6 +4,8 @@
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
@pointerdown.stop
@pointermove.stop
@pointerup.stop
>
<Load3DScene
v-if="node"

View File

@@ -5,7 +5,11 @@
data-capture-wheel="true"
tabindex="-1"
@pointerdown.stop="focusContainer"
@pointermove.stop
@pointerup.stop
@mousedown.stop
@mousemove.stop
@mouseup.stop
@contextmenu.stop.prevent
@dragover.prevent.stop="handleDragOver"
@dragleave.stop="handleDragLeave"

View File

@@ -53,6 +53,7 @@ const sortOptions: SelectOption[] = [
{ name: 'Recommended', value: 'recommended' },
{ name: 'Popular', value: 'popular' },
{ name: 'Newest', value: 'newest' },
{ name: 'VRAM Usage (Low to High)', value: 'vram-low-to-high' },
{ name: 'Model Size (Low to High)', value: 'model-size-low-to-high' },
{ name: 'Alphabetical (A-Z)', value: 'alphabetical' }
]

View File

@@ -245,7 +245,9 @@ const MENU_ORDER: string[] = [
'Paste Image',
'Save Image',
'Copy (Clipspace)',
'Paste (Clipspace)'
'Paste (Clipspace)',
// Fallback for other core items
'Convert to Group Node (Deprecated)'
]
/**

View File

@@ -45,7 +45,8 @@ export interface SubMenuOption {
}
export enum BadgeVariant {
NEW = 'new'
NEW = 'new',
DEPRECATED = 'deprecated'
}
// Global singleton for NodeOptions component reference

View File

@@ -72,14 +72,14 @@ describe('useSelectionMenuOptions - multiple nodes options', () => {
expect(mocks.frameNodes).toHaveBeenCalledOnce()
})
it('does not include a Convert to Group Node option', () => {
it('returns Convert to Group Node option from getMultipleNodesOptions', () => {
const { getMultipleNodesOptions } = useSelectionMenuOptions()
const options = getMultipleNodesOptions()
const groupNodeOption = options.find(
(opt) => opt.label === 'contextMenu.Convert to Group Node'
)
expect(groupNodeOption).toBeUndefined()
expect(groupNodeOption).toBeDefined()
})
})

View File

@@ -1,6 +1,8 @@
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useCommandStore } from '@/stores/commandStore'
import { useFrameNodes } from './useFrameNodes'
import { BadgeVariant } from './useMoreOptionsMenu'
import type { MenuOption } from './useMoreOptionsMenu'
@@ -100,13 +102,28 @@ export function useSelectionMenuOptions() {
return options
}
const getMultipleNodesOptions = (): MenuOption[] => [
{
label: t('g.frameNodes'),
icon: 'icon-[lucide--frame]',
action: frameNodes
const getMultipleNodesOptions = (): MenuOption[] => {
const convertToGroupNodes = () => {
const commandStore = useCommandStore()
void commandStore.execute(
'Comfy.GroupNode.ConvertSelectedNodesToGroupNode'
)
}
]
return [
{
label: t('contextMenu.Convert to Group Node'),
icon: 'icon-[lucide--group]',
action: convertToGroupNodes,
badge: BadgeVariant.DEPRECATED
},
{
label: t('g.frameNodes'),
icon: 'icon-[lucide--frame]',
action: frameNodes
}
]
}
const getAlignmentOptions = (): MenuOption[] => [
{

View File

@@ -3,27 +3,20 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import type { useNodeDragToCanvas as UseNodeDragToCanvasType } from './useNodeDragToCanvas'
const {
mockAddNodeOnGraph,
mockConvertEventToCanvasOffset,
mockSelectItems,
mockCanvas
} = vi.hoisted(() => {
const mockConvertEventToCanvasOffset = vi.fn()
const mockSelectItems = vi.fn()
return {
mockAddNodeOnGraph: vi.fn(),
mockConvertEventToCanvasOffset,
mockSelectItems,
mockCanvas: {
canvas: {
getBoundingClientRect: vi.fn()
},
convertEventToCanvasOffset: mockConvertEventToCanvasOffset,
selectItems: mockSelectItems
const { mockAddNodeOnGraph, mockConvertEventToCanvasOffset, mockCanvas } =
vi.hoisted(() => {
const mockConvertEventToCanvasOffset = vi.fn()
return {
mockAddNodeOnGraph: vi.fn(),
mockConvertEventToCanvasOffset,
mockCanvas: {
canvas: {
getBoundingClientRect: vi.fn()
},
convertEventToCanvasOffset: mockConvertEventToCanvasOffset
}
}
}
})
})
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: vi.fn(() => ({
@@ -126,11 +119,6 @@ describe('useNodeDragToCanvas', () => {
'pointermove',
expect.any(Function)
)
expect(addEventListenerSpy).toHaveBeenCalledWith(
'pointerdown',
expect.any(Function),
true
)
expect(addEventListenerSpy).toHaveBeenCalledWith(
'pointerup',
expect.any(Function),
@@ -251,57 +239,6 @@ describe('useNodeDragToCanvas', () => {
expect(isDragging.value).toBe(true)
})
it('should select the placed node when one is returned from the graph', () => {
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
left: 0,
right: 500,
top: 0,
bottom: 500
})
mockConvertEventToCanvasOffset.mockReturnValue([150, 150])
const placedNode = { id: 1 }
mockAddNodeOnGraph.mockReturnValue(placedNode)
const { startDrag, setupGlobalListeners } = useNodeDragToCanvas()
setupGlobalListeners()
startDrag(mockNodeDef)
document.dispatchEvent(
new PointerEvent('pointerup', {
clientX: 250,
clientY: 250,
bubbles: true
})
)
expect(mockSelectItems).toHaveBeenCalledWith([placedNode])
})
it('should not call selectItems when graph returns no node', () => {
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
left: 0,
right: 500,
top: 0,
bottom: 500
})
mockConvertEventToCanvasOffset.mockReturnValue([150, 150])
mockAddNodeOnGraph.mockReturnValue(null)
const { startDrag, setupGlobalListeners } = useNodeDragToCanvas()
setupGlobalListeners()
startDrag(mockNodeDef)
document.dispatchEvent(
new PointerEvent('pointerup', {
clientX: 250,
clientY: 250,
bubbles: true
})
)
expect(mockSelectItems).not.toHaveBeenCalled()
})
it('should not add node on pointerup when in native drag mode', () => {
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
left: 0,
@@ -402,58 +339,4 @@ describe('useNodeDragToCanvas', () => {
expect(dragMode.value).toBe('click')
})
})
describe('blockCommitPointerDown', () => {
function dispatchPointerDown(x: number, y: number) {
const event = new PointerEvent('pointerdown', {
clientX: x,
clientY: y,
bubbles: true,
cancelable: true
})
const stopSpy = vi.spyOn(event, 'stopImmediatePropagation')
document.dispatchEvent(event)
return stopSpy
}
beforeEach(() => {
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
left: 0,
right: 500,
top: 0,
bottom: 500
})
})
it('should stop propagation when in click-drag mode over canvas', () => {
const { startDrag, setupGlobalListeners } = useNodeDragToCanvas()
setupGlobalListeners()
startDrag(mockNodeDef)
expect(dispatchPointerDown(250, 250)).toHaveBeenCalled()
})
it('should not stop propagation when not dragging', () => {
const { setupGlobalListeners } = useNodeDragToCanvas()
setupGlobalListeners()
expect(dispatchPointerDown(250, 250)).not.toHaveBeenCalled()
})
it('should not stop propagation in native drag mode', () => {
const { startDrag, setupGlobalListeners } = useNodeDragToCanvas()
setupGlobalListeners()
startDrag(mockNodeDef, 'native')
expect(dispatchPointerDown(250, 250)).not.toHaveBeenCalled()
})
it('should not stop propagation when pointer is outside canvas', () => {
const { startDrag, setupGlobalListeners } = useNodeDragToCanvas()
setupGlobalListeners()
startDrag(mockNodeDef)
expect(dispatchPointerDown(600, 250)).not.toHaveBeenCalled()
})
})
})

View File

@@ -22,33 +22,31 @@ function cancelDrag() {
dragMode.value = 'click'
}
function isOverCanvas(clientX: number, clientY: number): boolean {
const canvasElement = useCanvasStore().canvas?.canvas as
| HTMLCanvasElement
| undefined
if (!canvasElement) return false
function addNodeAtPosition(clientX: number, clientY: number): boolean {
if (!draggedNode.value) return false
const canvasStore = useCanvasStore()
const canvas = canvasStore.canvas
if (!canvas) return false
const canvasElement = canvas.canvas as HTMLCanvasElement
const rect = canvasElement.getBoundingClientRect()
return (
const isOverCanvas =
clientX >= rect.left &&
clientX <= rect.right &&
clientY >= rect.top &&
clientY <= rect.bottom
)
}
function addNodeAtPosition(clientX: number, clientY: number): boolean {
if (!draggedNode.value) return false
const canvas = useCanvasStore().canvas
if (!canvas) return false
if (!isOverCanvas(clientX, clientY)) return false
const pos = canvas.convertEventToCanvasOffset({
clientX,
clientY
} as PointerEvent)
const node = useLitegraphService().addNodeOnGraph(draggedNode.value, { pos })
if (node) canvas.selectItems([node])
return true
if (isOverCanvas) {
const pos = canvas.convertEventToCanvasOffset({
clientX,
clientY
} as PointerEvent)
const litegraphService = useLitegraphService()
litegraphService.addNodeOnGraph(draggedNode.value, { pos })
return true
}
return false
}
function endDrag(e: PointerEvent) {
@@ -66,19 +64,11 @@ function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') cancelDrag()
}
// Prevent LiteGraph's empty-canvas hit-test from deselecting the placed node on pointerup.
function blockCommitPointerDown(e: PointerEvent) {
if (!isDragging.value || dragMode.value !== 'click') return
if (!isOverCanvas(e.clientX, e.clientY)) return
e.stopImmediatePropagation()
}
function setupGlobalListeners() {
if (listenersSetup) return
listenersSetup = true
document.addEventListener('pointermove', updatePosition)
document.addEventListener('pointerdown', blockCommitPointerDown, true)
document.addEventListener('pointerup', endDrag, true)
document.addEventListener('keydown', handleKeydown)
}
@@ -88,7 +78,6 @@ function cleanupGlobalListeners() {
listenersSetup = false
document.removeEventListener('pointermove', updatePosition)
document.removeEventListener('pointerdown', blockCommitPointerDown, true)
document.removeEventListener('pointerup', endDrag, true)
document.removeEventListener('keydown', handleKeydown)

View File

@@ -78,6 +78,59 @@ describe('useTemplateFiltering', () => {
vi.unstubAllGlobals()
})
it('sorts templates by VRAM from low to high and pushes missing values last', () => {
const gb = (value: number) => value * 1024 ** 3
const templates = ref<TemplateInfo[]>([
{
name: 'missing-vram',
description: 'no vram value',
mediaType: 'image',
mediaSubtype: 'png'
},
{
name: 'highest-vram',
description: 'high usage',
mediaType: 'image',
mediaSubtype: 'png',
vram: gb(12)
},
{
name: 'mid-vram',
description: 'medium usage',
mediaType: 'image',
mediaSubtype: 'png',
vram: gb(7.5)
},
{
name: 'low-vram',
description: 'low usage',
mediaType: 'image',
mediaSubtype: 'png',
vram: gb(5)
},
{
name: 'zero-vram',
description: 'unknown usage',
mediaType: 'image',
mediaSubtype: 'png',
vram: 0
}
])
const { sortBy, filteredTemplates } = useTemplateFiltering(templates)
sortBy.value = 'vram-low-to-high'
expect(filteredTemplates.value.map((template) => template.name)).toEqual([
'low-vram',
'mid-vram',
'highest-vram',
'missing-vram',
'zero-vram'
])
})
it('filters by search text, models, tags, and license with debounce handling', async () => {
vi.useFakeTimers()

View File

@@ -220,6 +220,17 @@ export function useTemplateFiltering(
})
})
const getVramMetric = (template: TemplateInfo) => {
if (
typeof template.vram === 'number' &&
Number.isFinite(template.vram) &&
template.vram > 0
) {
return template.vram
}
return Number.POSITIVE_INFINITY
}
watch(
filteredByRunsOn,
(templates) => {
@@ -268,6 +279,22 @@ export function useTemplateFiltering(
const dateB = new Date(b.date || '1970-01-01')
return dateB.getTime() - dateA.getTime()
})
case 'vram-low-to-high':
return templates.sort((a, b) => {
const vramA = getVramMetric(a)
const vramB = getVramMetric(b)
if (vramA === vramB) {
const nameA = a.title || a.name || ''
const nameB = b.title || b.name || ''
return nameA.localeCompare(nameB)
}
if (vramA === Number.POSITIVE_INFINITY) return 1
if (vramB === Number.POSITIVE_INFINITY) return -1
return vramA - vramB
})
case 'model-size-low-to-high':
return templates.sort((a, b) => {
const sizeA =

View File

@@ -321,9 +321,12 @@ function withComfyMatchType(node: LGraphNode): asserts node is MatchTypeNode {
if (!outputType) throw new Error('invalid connection')
this.outputs.forEach((output, idx) => {
if (!(outputGroups?.[idx] == matchKey)) return
this.outputs[idx] = shallowReactive(this.outputs[idx])
changeOutputType(this, output, outputType)
})
// Force Vue reactivity update for output slot types.
// Outputs are wrapped in shallowReactive by useGraphNodeManager,
// so mutating output.type alone doesn't trigger re-render.
this.outputs = [...this.outputs]
app.canvas?.setDirty(true, true)
}
)

View File

@@ -1833,6 +1833,38 @@ const replaceLegacySeparators = (nodes: ComfyNode[]): void => {
}
}
/**
* Convert selected nodes to a group node
* @throws {Error} if no nodes are selected
* @throws {Error} if a group node is already selected
* @throws {Error} if a group node is selected
*
* The context menu item should not be available if any of the above conditions are met.
* The error is automatically handled by the commandStore when the command is executed.
*/
async function convertSelectedNodesToGroupNode() {
const nodes = Object.values(app.canvas.selected_nodes ?? {})
if (nodes.length === 0) {
throw new Error('No nodes selected')
}
if (nodes.length === 1) {
throw new Error('Please select multiple nodes to convert to group node')
}
for (const node of nodes) {
if (node instanceof SubgraphNode) {
throw new Error('Selected nodes contain a subgraph node')
}
if (GroupNodeHandler.isGroupNode(node)) {
throw new Error('Selected nodes contain a group node')
}
}
return await GroupNodeHandler.fromNodes(nodes)
}
const convertDisabled = (selected: LGraphNode[]) =>
selected.length < 2 || !!selected.find((n) => GroupNodeHandler.isGroupNode(n))
function ungroupSelectedGroupNodes() {
const nodes = Object.values(app.canvas.selected_nodes ?? {})
for (const node of nodes) {
@@ -1868,6 +1900,13 @@ let globalDefs: Record<string, ComfyNodeDef>
const ext: ComfyExtension = {
name: id,
commands: [
{
id: 'Comfy.GroupNode.ConvertSelectedNodesToGroupNode',
label: 'Convert selected nodes to group node',
icon: 'pi pi-sitemap',
versionAdded: '1.3.17',
function: () => convertSelectedNodesToGroupNode()
},
{
id: 'Comfy.GroupNode.UngroupSelectedGroupNodes',
label: 'Ungroup selected group nodes',
@@ -1885,6 +1924,13 @@ const ext: ComfyExtension = {
}
],
keybindings: [
{
commandId: 'Comfy.GroupNode.ConvertSelectedNodesToGroupNode',
combo: {
alt: true,
key: 'g'
}
},
{
commandId: 'Comfy.GroupNode.UngroupSelectedGroupNodes',
combo: {
@@ -1896,13 +1942,42 @@ const ext: ComfyExtension = {
],
getCanvasMenuItems(canvas): IContextMenuValue[] {
const items: IContextMenuValue[] = []
const selected = Object.values(canvas.selected_nodes ?? {})
const convertEnabled = !convertDisabled(selected)
items.push({
content: `Convert to Group Node (Deprecated)`,
disabled: !convertEnabled,
// @ts-expect-error async callback - legacy menu API doesn't expect Promise
callback: async () => convertSelectedNodesToGroupNode()
})
const groups = canvas.graph?.extra?.groupNodes
const manageDisabled = !groups || !Object.keys(groups).length
items.push({
content: `Manage Group Nodes`,
disabled: manageDisabled,
callback: () => manageGroupNodes()
})
return items
},
getNodeMenuItems(node): IContextMenuValue[] {
if (GroupNodeHandler.isGroupNode(node)) {
return []
}
const selected = Object.values(app.canvas.selected_nodes ?? {})
const convertEnabled = !convertDisabled(selected)
return [
{
content: `Manage Group Nodes`,
disabled: manageDisabled,
callback: () => manageGroupNodes()
content: `Convert to Group Node (Deprecated)`,
disabled: !convertEnabled,
// @ts-expect-error async callback - legacy menu API doesn't expect Promise
callback: async () => convertSelectedNodesToGroupNode()
}
]
},

View File

@@ -1,4 +1,3 @@
import { SparkRenderer } from '@sparkjsdev/spark'
import * as THREE from 'three'
import type { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -138,13 +137,6 @@ describe('SceneManager', () => {
expect(manager.scene.children).toContain(manager.gridHelper)
})
it('adds a SparkRenderer to the scene so SplatMesh instances render', () => {
const sparkRenderers = manager.scene.children.filter(
(child) => child instanceof SparkRenderer
)
expect(sparkRenderers).toHaveLength(1)
})
it('builds a separate background scene with a tiled mesh', () => {
expect(manager.backgroundScene).toBeInstanceOf(THREE.Scene)
expect(manager.backgroundMesh).toBeInstanceOf(THREE.Mesh)

View File

@@ -1,4 +1,3 @@
import { SparkRenderer } from '@sparkjsdev/spark'
import * as THREE from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
@@ -12,7 +11,6 @@ import {
export class SceneManager implements SceneManagerInterface {
scene!: THREE.Scene
gridHelper: THREE.GridHelper
private sparkRenderer: SparkRenderer
backgroundScene!: THREE.Scene
backgroundCamera: THREE.OrthographicCamera
@@ -44,12 +42,6 @@ export class SceneManager implements SceneManagerInterface {
this.getActiveCamera = getActiveCamera
// Spark 2.x requires a SparkRenderer in the scene tree to render SplatMesh
// (Gaussian splat) instances; without it splats are silent no-ops. Kept
// alive across model reloads by SceneModelManager.clearModel.
this.sparkRenderer = new SparkRenderer({ renderer })
this.scene.add(this.sparkRenderer)
this.gridHelper = new THREE.GridHelper(20, 20)
this.gridHelper.position.set(0, 0, 0)
this.scene.add(this.gridHelper)
@@ -285,8 +277,8 @@ export class SceneManager implements SceneManagerInterface {
if (!material.map) return
const image = backgroundTexture.image as { width: number; height: number }
const imageAspect = image.width / image.height
const imageAspect =
backgroundTexture.image.width / backgroundTexture.image.height
const targetAspect = targetWidth / targetHeight
if (imageAspect > targetAspect) {

View File

@@ -1,4 +1,3 @@
import { SparkRenderer } from '@sparkjsdev/spark'
import * as THREE from 'three'
import { describe, expect, it, vi } from 'vitest'
@@ -356,20 +355,6 @@ describe('SceneModelManager', () => {
expect(geoDispose).toHaveBeenCalled()
expect(matDispose).toHaveBeenCalled()
})
it('preserves SparkRenderer across model reloads', async () => {
const { manager, scene } = createManager()
const sparkRenderer = new SparkRenderer({
renderer: {} as THREE.WebGLRenderer
})
scene.add(sparkRenderer)
const model = createMeshModel()
await manager.setupModel(model)
manager.clearModel()
expect(scene.children).toContain(sparkRenderer)
})
})
describe('reset', () => {

View File

@@ -1,4 +1,3 @@
import { SparkRenderer } from '@sparkjsdev/spark'
import * as THREE from 'three'
import type { GLTF } from 'three/examples/jsm/loaders/GLTFLoader'
@@ -318,7 +317,6 @@ export class SceneModelManager implements ModelManagerInterface {
object instanceof THREE.GridHelper ||
object instanceof THREE.Light ||
object instanceof THREE.Camera ||
object instanceof SparkRenderer ||
object.name === 'GizmoTransformControls'
if (!isEnvironmentObject) {

View File

@@ -158,6 +158,9 @@
"Comfy_Graph_UnpackSubgraph": {
"label": "Unpack the selected Subgraph"
},
"Comfy_GroupNode_ConvertSelectedNodesToGroupNode": {
"label": "Convert selected nodes to group node"
},
"Comfy_GroupNode_ManageGroupNodes": {
"label": "Manage group nodes"
},

View File

@@ -584,6 +584,7 @@
"Copy (Clipspace)": "Copy (Clipspace)",
"Add Node": "Add Node",
"Add Group": "Add Group",
"Convert to Group Node": "Convert to Group Node",
"Manage Group Nodes": "Manage Group Nodes",
"Add Group For Selected Nodes": "Add Group For Selected Nodes",
"Save Selected as Template": "Save Selected as Template",
@@ -1111,6 +1112,7 @@
"alphabetical": "A → Z",
"newest": "Newest",
"searchPlaceholder": "Search...",
"vramLowToHigh": "VRAM Usage (Low to High)",
"modelSizeLowToHigh": "Model Size (Low to High)",
"default": "Default"
},
@@ -1379,6 +1381,7 @@
"Group Selected Nodes": "Group Selected Nodes",
"Toggle promotion of hovered widget": "Toggle promotion of hovered widget",
"Unpack the selected Subgraph": "Unpack the selected Subgraph",
"Convert selected nodes to group node": "Convert selected nodes to group node",
"Manage group nodes": "Manage group nodes",
"Ungroup selected group nodes": "Ungroup selected group nodes",
"About ComfyUI": "About ComfyUI",

View File

@@ -3,19 +3,11 @@ import { vi } from 'vitest'
import 'vue'
// Mock @sparkjsdev/spark which uses WASM that doesn't work in Node.js
vi.mock('@sparkjsdev/spark', async () => {
const three = await import('three')
return {
SplatMesh: class SplatMesh {
constructor() {}
},
SparkRenderer: class SparkRenderer extends three.Object3D {
constructor() {
super()
}
}
vi.mock('@sparkjsdev/spark', () => ({
SplatMesh: class SplatMesh {
constructor() {}
}
})
}))
// Augment Window interface for tests
declare global {