Merge branch 'main' into glary/website-cloud-nodes-mock-and-slugify

This commit is contained in:
Alexander Brown
2026-05-15 07:38:12 -07:00
committed by GitHub
62 changed files with 1423 additions and 1145 deletions

View File

@@ -106,19 +106,12 @@ jobs:
- name: Generate HTML coverage report
if: steps.coverage-shards.outputs.has-coverage == 'true'
run: |
if [ ! -s coverage/playwright/coverage.lcov ]; then
echo "No coverage data; generating placeholder report."
mkdir -p coverage/html
WORKFLOW_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.event.workflow_run.id }}"
echo "<html><body><h1>No E2E coverage data available for this run.</h1><p><a href=\"${WORKFLOW_URL}\">View workflow run</a></p></body></html>" > coverage/html/index.html
exit 0
fi
genhtml coverage/playwright/coverage.lcov \
-o coverage/html \
--title "ComfyUI E2E Coverage" \
--no-function-coverage \
--precision 1 \
--ignore-errors source
--ignore-errors source,unmapped
- name: Upload HTML report artifact
if: steps.coverage-shards.outputs.has-coverage == 'true'

View File

@@ -2,6 +2,11 @@ import type {
TemplateInfo,
WorkflowTemplates
} from '@/platform/workflow/templates/types/template'
import { TemplateIncludeOnDistributionEnum } from '@/platform/workflow/templates/types/template'
const Cloud = TemplateIncludeOnDistributionEnum.Cloud
const Desktop = TemplateIncludeOnDistributionEnum.Desktop
const Local = TemplateIncludeOnDistributionEnum.Local
export function makeTemplate(
overrides: Partial<TemplateInfo> & Pick<TemplateInfo, 'name'>
@@ -26,3 +31,33 @@ export function mockTemplateIndex(
}
]
}
export const STABLE_CLOUD_TEMPLATE: TemplateInfo = makeTemplate({
name: 'cloud-stable',
title: 'Cloud Stable',
includeOnDistributions: [Cloud]
})
export const STABLE_DESKTOP_TEMPLATE: TemplateInfo = makeTemplate({
name: 'desktop-stable',
title: 'Desktop Stable',
includeOnDistributions: [Desktop]
})
export const STABLE_LOCAL_TEMPLATE: TemplateInfo = makeTemplate({
name: 'local-stable',
title: 'Local Stable',
includeOnDistributions: [Local]
})
export const STABLE_UNRESTRICTED_TEMPLATE: TemplateInfo = makeTemplate({
name: 'unrestricted-stable',
title: 'Unrestricted Stable'
})
export const ALL_DISTRIBUTION_TEMPLATES: TemplateInfo[] = [
STABLE_CLOUD_TEMPLATE,
STABLE_DESKTOP_TEMPLATE,
STABLE_LOCAL_TEMPLATE,
STABLE_UNRESTRICTED_TEMPLATE
]

View File

@@ -1,176 +0,0 @@
import type { Page, Route } from '@playwright/test'
import type {
JobDetailResponse,
JobEntry,
JobsListResponse
} from '@comfyorg/ingest-types'
const jobsListRoutePattern = /\/api\/jobs(?:\?.*)?$/
const jobDetailRoutePattern = /\/api\/jobs\/[^/?#]+(?:\?.*)?$/
const historyRoutePattern = /\/api\/history(?:\?.*)?$/
const defaultJobsListLimit = 100
export type MockJobRecord = {
listItem: JobEntry
detail: JobDetailResponse
}
function parsePositiveIntegerParam(url: URL, name: string): number | undefined {
const value = Number(url.searchParams.get(name))
return Number.isInteger(value) && value > 0 ? value : undefined
}
function getJobIdFromRequest(route: Route): string | null {
const url = new URL(route.request().url())
const jobId = url.pathname.split('/').at(-1)
return jobId ? decodeURIComponent(jobId) : null
}
export class JobsApiMock {
private listRouteHandler: ((route: Route) => Promise<void>) | null = null
private detailRouteHandler: ((route: Route) => Promise<void>) | null = null
private historyRouteHandler: ((route: Route) => Promise<void>) | null = null
private jobsById = new Map<string, MockJobRecord>()
constructor(private readonly page: Page) {}
async mockJobs(jobs: MockJobRecord[]): Promise<void> {
this.jobsById = new Map(
jobs.map(
(job) => [job.listItem.id, job] satisfies [string, MockJobRecord]
)
)
await this.ensureRoutesRegistered()
}
async clear(): Promise<void> {
this.jobsById.clear()
if (this.listRouteHandler) {
await this.page.unroute(jobsListRoutePattern, this.listRouteHandler)
this.listRouteHandler = null
}
if (this.detailRouteHandler) {
await this.page.unroute(jobDetailRoutePattern, this.detailRouteHandler)
this.detailRouteHandler = null
}
if (this.historyRouteHandler) {
await this.page.unroute(historyRoutePattern, this.historyRouteHandler)
this.historyRouteHandler = null
}
}
private async ensureRoutesRegistered(): Promise<void> {
if (!this.listRouteHandler) {
this.listRouteHandler = async (route: Route) => {
const url = new URL(route.request().url())
const statuses = url.searchParams
.get('status')
?.split(',')
.map((status) => status.trim())
.filter(Boolean)
let filteredJobs = Array.from(
this.jobsById.values(),
({ listItem }) => listItem
)
if (statuses?.length) {
filteredJobs = filteredJobs.filter((job) =>
statuses.includes(job.status)
)
}
const offset = parsePositiveIntegerParam(url, 'offset') ?? 0
const limit =
parsePositiveIntegerParam(url, 'limit') ?? defaultJobsListLimit
const total = filteredJobs.length
const visibleJobs = filteredJobs.slice(offset, offset + limit)
const response = {
jobs: visibleJobs,
pagination: {
offset,
limit,
total,
has_more: offset + visibleJobs.length < total
}
} satisfies JobsListResponse
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(response)
})
}
await this.page.route(jobsListRoutePattern, this.listRouteHandler)
}
if (!this.detailRouteHandler) {
this.detailRouteHandler = async (route: Route) => {
const jobId = getJobIdFromRequest(route)
const job = jobId ? this.jobsById.get(jobId) : undefined
if (!job) {
await route.fulfill({
status: 404,
contentType: 'application/json',
body: JSON.stringify({ error: 'Job not found' })
})
return
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(job.detail)
})
}
await this.page.route(jobDetailRoutePattern, this.detailRouteHandler)
}
if (!this.historyRouteHandler) {
this.historyRouteHandler = async (route: Route) => {
const request = route.request()
if (request.method() !== 'POST') {
await route.continue()
return
}
const requestBody = request.postDataJSON() as
| { delete?: string[]; clear?: boolean }
| undefined
if (requestBody?.clear) {
this.jobsById = new Map(
Array.from(this.jobsById).filter(([, job]) => {
const status = job.listItem.status
return status === 'pending' || status === 'in_progress'
})
)
}
if (requestBody?.delete?.length) {
for (const jobId of requestBody.delete) {
this.jobsById.delete(jobId)
}
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({})
})
}
await this.page.route(historyRoutePattern, this.historyRouteHandler)
}
}
}

View File

@@ -0,0 +1,198 @@
import type { Page, Route } from '@playwright/test'
import type {
TemplateInfo,
WorkflowTemplates
} from '@/platform/workflow/templates/types/template'
import { TemplateIncludeOnDistributionEnum } from '@/platform/workflow/templates/types/template'
import {
makeTemplate,
mockTemplateIndex
} from '@e2e/fixtures/data/templateFixtures'
/**
* Generate N deterministic templates, optionally restricted to a distribution.
*
* Lives here (not in `fixtures/data/`) because `fixtures/data/` is reserved
* for static test data with no executable fixture logic.
*/
function generateTemplates(
count: number,
distribution?: TemplateIncludeOnDistributionEnum
): TemplateInfo[] {
const slug = distribution ?? 'unrestricted'
return Array.from({ length: count }, (_, i) =>
makeTemplate({
name: `gen-${slug}-${String(i + 1).padStart(3, '0')}`,
title: `Generated ${slug} ${i + 1}`,
...(distribution ? { includeOnDistributions: [distribution] } : {})
})
)
}
export interface TemplateConfig {
readonly templates: readonly TemplateInfo[]
readonly index: readonly WorkflowTemplates[] | null
}
function emptyConfig(): TemplateConfig {
return { templates: [], index: null }
}
export type TemplateOperator = (config: TemplateConfig) => TemplateConfig
function cloneTemplates(templates: readonly TemplateInfo[]): TemplateInfo[] {
return templates.map((t) => structuredClone(t))
}
function cloneIndex(
index: readonly WorkflowTemplates[] | null
): WorkflowTemplates[] | null {
return index ? index.map((m) => structuredClone(m)) : null
}
function addTemplates(
config: TemplateConfig,
templates: TemplateInfo[]
): TemplateConfig {
return { ...config, templates: [...config.templates, ...templates] }
}
export function withTemplates(templates: TemplateInfo[]): TemplateOperator {
return (config) => addTemplates(config, templates)
}
export function withTemplate(template: TemplateInfo): TemplateOperator {
return (config) => addTemplates(config, [template])
}
export function withCloudTemplates(count: number): TemplateOperator {
return (config) =>
addTemplates(
config,
generateTemplates(count, TemplateIncludeOnDistributionEnum.Cloud)
)
}
export function withDesktopTemplates(count: number): TemplateOperator {
return (config) =>
addTemplates(
config,
generateTemplates(count, TemplateIncludeOnDistributionEnum.Desktop)
)
}
export function withLocalTemplates(count: number): TemplateOperator {
return (config) =>
addTemplates(
config,
generateTemplates(count, TemplateIncludeOnDistributionEnum.Local)
)
}
export function withUnrestrictedTemplates(count: number): TemplateOperator {
return (config) => addTemplates(config, generateTemplates(count))
}
/**
* Override the index payload entirely. Useful when a test needs a custom
* `WorkflowTemplates[]` shape (e.g. multiple modules).
*/
export function withRawIndex(index: WorkflowTemplates[]): TemplateOperator {
return (config) => ({ ...config, index })
}
export class TemplateHelper {
private templates: TemplateInfo[]
private index: WorkflowTemplates[] | null
private routeHandlers: Array<{
pattern: string
handler: (route: Route) => Promise<void>
}> = []
constructor(
private readonly page: Page,
config: TemplateConfig = emptyConfig()
) {
this.templates = cloneTemplates(config.templates)
this.index = cloneIndex(config.index)
}
configure(...operators: TemplateOperator[]): void {
const config = operators.reduce<TemplateConfig>(
(cfg, op) => op(cfg),
emptyConfig()
)
this.templates = cloneTemplates(config.templates)
this.index = cloneIndex(config.index)
}
async mock(): Promise<void> {
await this.mockIndex()
await this.mockThumbnails()
}
async mockIndex(): Promise<void> {
const indexHandler = async (route: Route) => {
const payload = this.index ?? mockTemplateIndex(this.templates)
await route.fulfill({
status: 200,
body: JSON.stringify(payload),
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store'
}
})
}
const indexPattern = '**/templates/index.json'
this.routeHandlers.push({ pattern: indexPattern, handler: indexHandler })
await this.page.route(indexPattern, indexHandler)
}
async mockThumbnails(): Promise<void> {
const thumbnailHandler = async (route: Route) => {
await route.fulfill({
status: 200,
path: 'browser_tests/assets/example.webp',
headers: {
'Content-Type': 'image/webp',
'Cache-Control': 'no-store'
}
})
}
const thumbnailPattern = '**/templates/**.webp'
this.routeHandlers.push({
pattern: thumbnailPattern,
handler: thumbnailHandler
})
await this.page.route(thumbnailPattern, thumbnailHandler)
}
getTemplates(): TemplateInfo[] {
return cloneTemplates(this.templates)
}
get templateCount(): number {
return this.templates.length
}
async clearMocks(): Promise<void> {
for (const { pattern, handler } of this.routeHandlers) {
await this.page.unroute(pattern, handler)
}
this.routeHandlers = []
this.templates = []
this.index = null
}
}
export function createTemplateHelper(
page: Page,
...operators: TemplateOperator[]
): TemplateHelper {
const config = operators.reduce<TemplateConfig>(
(cfg, op) => op(cfg),
emptyConfig()
)
return new TemplateHelper(page, config)
}

View File

@@ -1,15 +0,0 @@
import { test as base } from '@playwright/test'
import { JobsApiMock } from '@e2e/fixtures/helpers/JobsApiMock'
export const jobsApiMockFixture = base.extend<{
jobsApi: JobsApiMock
}>({
jobsApi: async ({ page }, use) => {
const jobsApi = new JobsApiMock(page)
await use(jobsApi)
await jobsApi.clear()
}
})

View File

@@ -0,0 +1,169 @@
import { test as base } from '@playwright/test'
import type { Page } from '@playwright/test'
import type { z } from 'zod'
import type {
JobStatus,
RawJobListItem,
zJobsListResponse
} from '@/platform/remote/comfyui/jobs/jobTypes'
type JobsListResponse = z.infer<typeof zJobsListResponse>
const terminalJobStatuses = [
'completed',
'failed',
'cancelled'
] as const satisfies readonly JobStatus[]
const activeJobStatuses = [
'in_progress',
'pending'
] as const satisfies readonly JobStatus[]
const defaultJobsListLimit = 200
const defaultScenarioHistoryLimit = 64
const defaultJobsListOffset = 0
const defaultRouteMockJobTimestamp = Date.UTC(2026, 0, 1, 12)
interface JobsListRoute {
statuses: readonly JobStatus[]
jobs: readonly RawJobListItem[]
limit?: number
offset?: number
}
interface JobsScenario {
history?: readonly RawJobListItem[]
queue?: readonly RawJobListItem[]
}
function hasExactStatuses(url: URL, statuses: readonly JobStatus[]): boolean {
const requestedStatuses = new Set(
url.searchParams.get('status')?.split(',') ?? []
)
return (
requestedStatuses.size === statuses.length &&
statuses.every((status) => requestedStatuses.has(status))
)
}
function searchParamNumber(url: URL, name: string, fallback: number): number {
const value = url.searchParams.get(name)
return value === null ? fallback : Number(value)
}
function hasJobsListPageParams(
url: URL,
{ limit, offset }: Pick<JobsListRoute, 'limit' | 'offset'>
): boolean {
return (
searchParamNumber(url, 'limit', defaultJobsListLimit) ===
(limit ?? defaultJobsListLimit) &&
searchParamNumber(url, 'offset', defaultJobsListOffset) ===
(offset ?? defaultJobsListOffset)
)
}
function isJobsListRequest(url: URL, route: JobsListRoute): boolean {
return (
url.pathname.endsWith('/api/jobs') &&
hasExactStatuses(url, route.statuses) &&
hasJobsListPageParams(url, route)
)
}
function createJobsListResponse({
jobs,
limit = defaultJobsListLimit,
offset = defaultJobsListOffset
}: Omit<JobsListRoute, 'statuses'>): JobsListResponse {
const pageJobs = jobs.slice(offset, offset + limit)
return {
jobs: pageJobs,
pagination: {
offset,
limit,
total: jobs.length,
has_more: offset + pageJobs.length < jobs.length
}
}
}
export function createRouteMockJob({
id,
...overrides
}: { id: string } & Partial<Omit<RawJobListItem, 'id'>>): RawJobListItem {
return {
id,
status: 'completed',
create_time: defaultRouteMockJobTimestamp,
execution_start_time: defaultRouteMockJobTimestamp,
execution_end_time: defaultRouteMockJobTimestamp + 5_000,
preview_output: {
filename: `output_${id}.png`,
subfolder: '',
type: 'output',
nodeId: '1',
mediaType: 'images'
},
outputs_count: 1,
...overrides
}
}
export class JobsRouteMocker {
constructor(private readonly page: Page) {}
async mockJobsHistory(
jobs: readonly RawJobListItem[],
limit = defaultJobsListLimit
): Promise<void> {
await this.mockJobsList({
statuses: terminalJobStatuses,
jobs,
limit
})
}
async mockJobsQueue(jobs: readonly RawJobListItem[]): Promise<void> {
await this.mockJobsList({
statuses: activeJobStatuses,
jobs
})
}
async mockJobsScenario({ history, queue }: JobsScenario): Promise<void> {
if (history) {
await this.mockJobsHistory(history, defaultScenarioHistoryLimit)
}
if (queue) {
await this.mockJobsQueue(queue)
}
}
async mockJobsList(route: JobsListRoute): Promise<void> {
const response = createJobsListResponse(route)
await this.page.route(
(url) => isJobsListRequest(url, route),
async (requestRoute) => {
if (requestRoute.request().method().toUpperCase() !== 'GET') {
await requestRoute.fallback()
return
}
await requestRoute.fulfill({ json: response })
}
)
}
}
export const jobsRouteFixture = base.extend<{
jobsRoutes: JobsRouteMocker
}>({
jobsRoutes: async ({ page }, use) => {
await use(new JobsRouteMocker(page))
await page.unrouteAll({ behavior: 'wait' })
}
})

View File

@@ -0,0 +1,16 @@
import { test as base } from '@playwright/test'
import type { TemplateHelper } from '@e2e/fixtures/helpers/TemplateHelper'
import { createTemplateHelper } from '@e2e/fixtures/helpers/TemplateHelper'
export const templateApiFixture = base.extend<{
templateApi: TemplateHelper
}>({
templateApi: async ({ page }, use) => {
const templateApi = createTemplateHelper(page)
await use(templateApi)
await templateApi.clearMocks()
}
})

View File

@@ -1,52 +0,0 @@
import type { JobDetailResponse, JobEntry } from '@comfyorg/ingest-types'
import type { MockJobRecord } from '@e2e/fixtures/helpers/JobsApiMock'
export function createMockJob(
overrides: Partial<JobEntry> & { id: string }
): JobEntry {
const now = Date.now()
return {
status: 'completed',
create_time: now,
execution_start_time: now,
execution_end_time: now + 5_000,
preview_output: {
filename: `output_${overrides.id}.png`,
subfolder: '',
type: 'output',
nodeId: '1',
mediaType: 'images'
},
outputs_count: 1,
...overrides
}
}
function isTerminalStatus(status: JobEntry['status']) {
return status === 'completed' || status === 'failed' || status === 'cancelled'
}
function createMockJobRecord(listItem: JobEntry): MockJobRecord {
const updateTime =
listItem.execution_end_time ??
listItem.execution_start_time ??
listItem.create_time
const detail: JobDetailResponse = {
...listItem,
update_time: updateTime,
...(isTerminalStatus(listItem.status) ? { outputs: {} } : {})
}
return {
listItem,
detail
}
}
export function createMockJobRecords(
listItems: readonly JobEntry[]
): MockJobRecord[] {
return listItems.map(createMockJobRecord)
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 93 KiB

View File

@@ -8,15 +8,15 @@ import {
} from '@e2e/fixtures/assetApiFixture'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { jobsApiMockFixture } from '@e2e/fixtures/jobsApiMockFixture'
import { TestIds } from '@e2e/fixtures/selectors'
import {
createMockJob,
createMockJobRecords
} from '@e2e/fixtures/utils/jobFixtures'
createRouteMockJob,
jobsRouteFixture
} from '@e2e/fixtures/jobsRouteFixture'
import { TestIds } from '@e2e/fixtures/selectors'
import { PropertiesPanelHelper } from '@e2e/tests/propertiesPanel/PropertiesPanelHelper'
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
const ossTest = mergeTests(comfyPageFixture, jobsApiMockFixture)
const ossTest = mergeTests(comfyPageFixture, jobsRouteFixture)
const outputHash =
'147257c95a3e957e0deee73a077cfec89da2d906dd086ca70a2b0c897a9591d6e.png'
const plainVideoFileName = 'plain_video.mp4'
@@ -213,9 +213,9 @@ async function expectNoMissingMediaDuringUpload(comfyPage: ComfyPage) {
.toBe(true)
}
function outputHistoryJobs() {
return createMockJobRecords([
createMockJob({
function outputHistoryJobs(): RawJobListItem[] {
return [
createRouteMockJob({
id: 'history-output-image',
preview_output: {
filename: 'ComfyUI_00001_.png',
@@ -225,7 +225,7 @@ function outputHistoryJobs() {
mediaType: 'images'
}
}),
createMockJob({
createRouteMockJob({
id: 'history-output-video',
preview_output: {
filename: 'clip.mp4',
@@ -235,7 +235,7 @@ function outputHistoryJobs() {
mediaType: 'video'
}
}),
createMockJob({
createRouteMockJob({
id: 'history-output-audio',
preview_output: {
filename: 'sound.wav',
@@ -245,7 +245,7 @@ function outputHistoryJobs() {
mediaType: 'audio'
}
})
])
]
}
ossTest.describe(
@@ -258,8 +258,9 @@ ossTest.describe(
ossTest(
'resolves annotated output media from job history',
async ({ comfyPage, jobsApi }) => {
await jobsApi.mockJobs(outputHistoryJobs())
async ({ comfyPage, jobsRoutes }) => {
await jobsRoutes.mockJobsHistory(outputHistoryJobs())
await jobsRoutes.mockJobsQueue([])
await comfyPage.workflow.loadWorkflow(
'missing/missing_media_output_annotations'

View File

@@ -1,56 +1,54 @@
import { expect, mergeTests } from '@playwright/test'
import type { JobEntry } from '@comfyorg/ingest-types'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import { jobsApiMockFixture } from '@e2e/fixtures/jobsApiMockFixture'
import {
createMockJob,
createMockJobRecords
} from '@e2e/fixtures/utils/jobFixtures'
createRouteMockJob,
jobsRouteFixture
} from '@e2e/fixtures/jobsRouteFixture'
import { TestIds } from '@e2e/fixtures/selectors'
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
const test = mergeTests(comfyPageFixture, jobsApiMockFixture)
const test = mergeTests(comfyPageFixture, jobsRouteFixture)
const mockJobTimestamp = Date.UTC(2026, 0, 1, 12)
const now = Date.now()
const MOCK_JOBS: JobEntry[] = [
createMockJob({
const MOCK_JOBS: RawJobListItem[] = [
createRouteMockJob({
id: 'job-completed-1',
status: 'completed',
create_time: now - 60_000,
execution_start_time: now - 60_000,
execution_end_time: now - 50_000,
create_time: mockJobTimestamp - 60_000,
execution_start_time: mockJobTimestamp - 60_000,
execution_end_time: mockJobTimestamp - 50_000,
outputs_count: 2
}),
createMockJob({
createRouteMockJob({
id: 'job-completed-2',
status: 'completed',
create_time: now - 120_000,
execution_start_time: now - 120_000,
execution_end_time: now - 115_000,
create_time: mockJobTimestamp - 120_000,
execution_start_time: mockJobTimestamp - 120_000,
execution_end_time: mockJobTimestamp - 115_000,
outputs_count: 1
}),
createMockJob({
createRouteMockJob({
id: 'job-failed-1',
status: 'failed',
create_time: now - 30_000,
execution_start_time: now - 30_000,
execution_end_time: now - 28_000,
create_time: mockJobTimestamp - 30_000,
execution_start_time: mockJobTimestamp - 30_000,
execution_end_time: mockJobTimestamp - 28_000,
outputs_count: 0
}),
createMockJob({
createRouteMockJob({
id: 'job-failed-bottom',
status: 'failed',
create_time: now - 180_000,
execution_start_time: now - 180_000,
execution_end_time: now - 178_000,
create_time: mockJobTimestamp - 180_000,
execution_start_time: mockJobTimestamp - 180_000,
execution_end_time: mockJobTimestamp - 178_000,
outputs_count: 0
})
]
test.describe('Queue overlay', () => {
test.beforeEach(async ({ comfyPage, jobsApi }) => {
await jobsApi.mockJobs(createMockJobRecords(MOCK_JOBS))
test.beforeEach(async ({ comfyPage, jobsRoutes }) => {
await jobsRoutes.mockJobsScenario({ history: MOCK_JOBS, queue: [] })
await comfyPage.settings.setSetting('Comfy.Minimap.Visible', false)
await comfyPage.settings.setSetting('Comfy.Queue.QPOV2', false)
await comfyPage.setup()

View File

@@ -1,13 +1,13 @@
import { expect } from '@playwright/test'
import { expect, mergeTests } from '@playwright/test'
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
import { TemplateIncludeOnDistributionEnum } from '@/platform/workflow/templates/types/template'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import {
makeTemplate,
mockTemplateIndex
} from '@e2e/fixtures/data/templateFixtures'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import { makeTemplate } from '@e2e/fixtures/data/templateFixtures'
import { withTemplates } from '@e2e/fixtures/helpers/TemplateHelper'
import { TestIds } from '@e2e/fixtures/selectors'
import { templateApiFixture } from '@e2e/fixtures/templateApiFixture'
const test = mergeTests(comfyPageFixture, templateApiFixture)
const Cloud = TemplateIncludeOnDistributionEnum.Cloud
const Desktop = TemplateIncludeOnDistributionEnum.Desktop
@@ -17,7 +17,7 @@ test.describe(
'Template distribution filtering count',
{ tag: '@cloud' },
() => {
test.beforeEach(async ({ comfyPage }) => {
test.beforeEach(async ({ comfyPage, templateApi }) => {
await comfyPage.settings.setSetting('Comfy.Templates.SelectedModels', [])
await comfyPage.settings.setSetting(
'Comfy.Templates.SelectedUseCases',
@@ -26,53 +26,37 @@ test.describe(
await comfyPage.settings.setSetting('Comfy.Templates.SelectedRunsOn', [])
await comfyPage.settings.setSetting('Comfy.Templates.SortBy', 'default')
await comfyPage.page.route('**/templates/**.webp', async (route) => {
await route.fulfill({
status: 200,
path: 'browser_tests/assets/example.webp',
headers: {
'Content-Type': 'image/webp',
'Cache-Control': 'no-store'
}
})
})
await templateApi.mockThumbnails()
})
test('displayed count matches visible cards when distribution filter excludes templates', async ({
comfyPage
comfyPage,
templateApi
}) => {
const templates: TemplateInfo[] = [
makeTemplate({
name: 'cloud-1',
title: 'Cloud One',
includeOnDistributions: [Cloud]
}),
makeTemplate({
name: 'cloud-2',
title: 'Cloud Two',
includeOnDistributions: [Cloud]
}),
makeTemplate({
name: 'desktop-hidden',
title: 'Desktop Hidden',
includeOnDistributions: [Desktop]
}),
makeTemplate({
name: 'universal',
title: 'Universal'
})
]
await comfyPage.page.route('**/templates/index.json', async (route) => {
await route.fulfill({
status: 200,
body: JSON.stringify(mockTemplateIndex(templates)),
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store'
}
})
})
templateApi.configure(
withTemplates([
makeTemplate({
name: 'cloud-1',
title: 'Cloud One',
includeOnDistributions: [Cloud]
}),
makeTemplate({
name: 'cloud-2',
title: 'Cloud Two',
includeOnDistributions: [Cloud]
}),
makeTemplate({
name: 'desktop-hidden',
title: 'Desktop Hidden',
includeOnDistributions: [Desktop]
}),
makeTemplate({
name: 'universal',
title: 'Universal'
})
])
)
await templateApi.mockIndex()
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
await expect(comfyPage.templates.content).toBeVisible()
@@ -86,45 +70,38 @@ test.describe(
})
test('filtered count reflects distribution + model filter together', async ({
comfyPage
comfyPage,
templateApi
}) => {
const templates: TemplateInfo[] = [
makeTemplate({
name: 'wan-cloud-1',
title: 'Wan Cloud 1',
models: ['Wan 2.2'],
includeOnDistributions: [Cloud]
}),
makeTemplate({
name: 'wan-cloud-2',
title: 'Wan Cloud 2',
models: ['Wan 2.2'],
includeOnDistributions: [Cloud]
}),
makeTemplate({
name: 'wan-desktop',
title: 'Wan Desktop',
models: ['Wan 2.2'],
includeOnDistributions: [Desktop]
}),
makeTemplate({
name: 'flux-cloud',
title: 'Flux Cloud',
models: ['Flux'],
includeOnDistributions: [Cloud]
})
]
await comfyPage.page.route('**/templates/index.json', async (route) => {
await route.fulfill({
status: 200,
body: JSON.stringify(mockTemplateIndex(templates)),
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store'
}
})
})
templateApi.configure(
withTemplates([
makeTemplate({
name: 'wan-cloud-1',
title: 'Wan Cloud 1',
models: ['Wan 2.2'],
includeOnDistributions: [Cloud]
}),
makeTemplate({
name: 'wan-cloud-2',
title: 'Wan Cloud 2',
models: ['Wan 2.2'],
includeOnDistributions: [Cloud]
}),
makeTemplate({
name: 'wan-desktop',
title: 'Wan Desktop',
models: ['Wan 2.2'],
includeOnDistributions: [Desktop]
}),
makeTemplate({
name: 'flux-cloud',
title: 'Flux Cloud',
models: ['Flux'],
includeOnDistributions: [Cloud]
})
])
)
await templateApi.mockIndex()
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
await expect(comfyPage.templates.content).toBeVisible()
@@ -144,36 +121,29 @@ test.describe(
})
test('desktop-only templates never leak into DOM on cloud distribution', async ({
comfyPage
comfyPage,
templateApi
}) => {
const templates: TemplateInfo[] = [
makeTemplate({
name: 'cloud-visible',
title: 'Cloud Visible',
includeOnDistributions: [Cloud]
}),
makeTemplate({
name: 'desktop-leak-check',
title: 'Desktop Leak Check',
includeOnDistributions: [Desktop]
}),
makeTemplate({
name: 'local-leak-check',
title: 'Local Leak Check',
includeOnDistributions: [Local]
})
]
await comfyPage.page.route('**/templates/index.json', async (route) => {
await route.fulfill({
status: 200,
body: JSON.stringify(mockTemplateIndex(templates)),
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store'
}
})
})
templateApi.configure(
withTemplates([
makeTemplate({
name: 'cloud-visible',
title: 'Cloud Visible',
includeOnDistributions: [Cloud]
}),
makeTemplate({
name: 'desktop-leak-check',
title: 'Desktop Leak Check',
includeOnDistributions: [Desktop]
}),
makeTemplate({
name: 'local-leak-check',
title: 'Local Leak Check',
includeOnDistributions: [Local]
})
])
)
await templateApi.mockIndex()
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
await expect(comfyPage.templates.content).toBeVisible()
@@ -200,28 +170,21 @@ test.describe(
})
test('templates without includeOnDistributions are visible on cloud', async ({
comfyPage
comfyPage,
templateApi
}) => {
const templates: TemplateInfo[] = [
makeTemplate({ name: 'unrestricted-1', title: 'Unrestricted 1' }),
makeTemplate({ name: 'unrestricted-2', title: 'Unrestricted 2' }),
makeTemplate({
name: 'cloud-only',
title: 'Cloud Only',
includeOnDistributions: [Cloud]
})
]
await comfyPage.page.route('**/templates/index.json', async (route) => {
await route.fulfill({
status: 200,
body: JSON.stringify(mockTemplateIndex(templates)),
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store'
}
})
})
templateApi.configure(
withTemplates([
makeTemplate({ name: 'unrestricted-1', title: 'Unrestricted 1' }),
makeTemplate({ name: 'unrestricted-2', title: 'Unrestricted 2' }),
makeTemplate({
name: 'cloud-only',
title: 'Cloud Only',
includeOnDistributions: [Cloud]
})
])
)
await templateApi.mockIndex()
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
await expect(comfyPage.templates.content).toBeVisible()
@@ -234,39 +197,32 @@ test.describe(
})
test('clear filters button resets to correct distribution-filtered total', async ({
comfyPage
comfyPage,
templateApi
}) => {
const templates: TemplateInfo[] = [
makeTemplate({
name: 'wan-cloud',
title: 'Wan Cloud',
models: ['Wan 2.2'],
includeOnDistributions: [Cloud]
}),
makeTemplate({
name: 'flux-cloud',
title: 'Flux Cloud',
models: ['Flux'],
includeOnDistributions: [Cloud]
}),
makeTemplate({
name: 'wan-desktop',
title: 'Wan Desktop',
models: ['Wan 2.2'],
includeOnDistributions: [Desktop]
})
]
await comfyPage.page.route('**/templates/index.json', async (route) => {
await route.fulfill({
status: 200,
body: JSON.stringify(mockTemplateIndex(templates)),
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store'
}
})
})
templateApi.configure(
withTemplates([
makeTemplate({
name: 'wan-cloud',
title: 'Wan Cloud',
models: ['Wan 2.2'],
includeOnDistributions: [Cloud]
}),
makeTemplate({
name: 'flux-cloud',
title: 'Flux Cloud',
models: ['Flux'],
includeOnDistributions: [Cloud]
}),
makeTemplate({
name: 'wan-desktop',
title: 'Wan Desktop',
models: ['Wan 2.2'],
includeOnDistributions: [Desktop]
})
])
)
await templateApi.mockIndex()
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
await expect(comfyPage.templates.content).toBeVisible()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 106 KiB

View File

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

723
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -36,7 +36,7 @@ catalog:
'@storybook/addon-mcp': 0.1.6
'@storybook/vue3': ^10.2.10
'@storybook/vue3-vite': ^10.2.10
'@tailwindcss/vite': ^4.2.0
'@tailwindcss/vite': ^4.3.0
'@tanstack/vue-virtual': ^3.13.12
'@testing-library/jest-dom': ^6.9.1
'@testing-library/user-event': ^14.6.1
@@ -112,7 +112,7 @@ catalog:
rollup-plugin-visualizer: ^6.0.4
storybook: ^10.2.10
stylelint: ^16.26.1
tailwindcss: ^4.2.0
tailwindcss: ^4.3.0
three: ^0.170.0
tailwindcss-primeui: ^0.6.1
tsx: ^4.15.6

View File

@@ -16,14 +16,6 @@ vi.mock('@/stores/nodeBookmarkStore', () => ({
})
}))
vi.mock('primevue/dialog', () => ({
default: {
name: 'Dialog',
template: '<div v-if="visible"><slot /><slot name="footer" /></div>',
props: ['visible']
}
}))
vi.mock('primevue/selectbutton', () => ({
default: {
name: 'SelectButton',
@@ -32,8 +24,29 @@ vi.mock('primevue/selectbutton', () => ({
}
}))
vi.mock('primevue/divider', () => ({
default: { name: 'Divider', template: '<hr />' }
vi.mock('@/components/ui/dialog/Dialog.vue', () => ({
default: { name: 'Dialog', template: '<div><slot /></div>' }
}))
vi.mock('@/components/ui/dialog/DialogPortal.vue', () => ({
default: { name: 'DialogPortal', template: '<div><slot /></div>' }
}))
vi.mock('@/components/ui/dialog/DialogOverlay.vue', () => ({
default: { name: 'DialogOverlay', template: '<div />' }
}))
vi.mock('@/components/ui/dialog/DialogContent.vue', () => ({
default: { name: 'DialogContent', template: '<div><slot /></div>' }
}))
vi.mock('@/components/ui/dialog/DialogHeader.vue', () => ({
default: { name: 'DialogHeader', template: '<div><slot /></div>' }
}))
vi.mock('@/components/ui/dialog/DialogFooter.vue', () => ({
default: { name: 'DialogFooter', template: '<div><slot /></div>' }
}))
vi.mock('@/components/ui/dialog/DialogTitle.vue', () => ({
default: { name: 'DialogTitle', template: '<div><slot /></div>' }
}))
vi.mock('@/components/ui/dialog/DialogClose.vue', () => ({
default: { name: 'DialogClose', template: '<button />' }
}))
vi.mock('@/components/common/ColorCustomizationSelector.vue', () => ({

View File

@@ -1,72 +1,111 @@
<template>
<Dialog v-model:visible="visible" :header="$t('g.customizeFolder')">
<div class="p-fluid">
<div class="field icon-field">
<label for="icon">{{ $t('g.icon') }}</label>
<SelectButton
v-model="selectedIcon"
:options="iconOptions"
option-label="name"
data-key="value"
>
<template #option="slotProps">
<i
:class="['pi', slotProps.option.value, 'mr-2']"
:style="{ color: finalColor }"
<Dialog v-model:open="visible" :modal="false">
<DialogPortal>
<DialogOverlay />
<DialogContent
size="md"
:aria-labelledby="titleId"
@pointer-down-outside="onPointerDownOutside"
>
<DialogHeader>
<DialogTitle :id="titleId">
{{ $t('g.customizeFolder') }}
</DialogTitle>
<DialogClose />
</DialogHeader>
<div class="flex flex-col gap-4 px-4 py-2">
<div class="flex flex-col gap-2">
<label for="customization-icon" class="text-sm font-medium">
{{ $t('g.icon') }}
</label>
<SelectButton
id="customization-icon"
v-model="selectedIcon"
:options="iconOptions"
option-label="name"
data-key="value"
>
<template #option="slotProps">
<i
:class="['pi', slotProps.option.value, 'mr-2']"
:style="{ color: finalColor }"
/>
</template>
</SelectButton>
</div>
<hr class="border-t border-border-subtle" />
<div class="flex flex-col gap-2">
<label for="customization-color" class="text-sm font-medium">
{{ $t('g.color') }}
</label>
<ColorCustomizationSelector
id="customization-color"
v-model="finalColor"
:color-options="colorOptions"
/>
</template>
</SelectButton>
</div>
<Divider />
<div class="field color-field">
<label for="color">{{ $t('g.color') }}</label>
<ColorCustomizationSelector
v-model="finalColor"
:color-options="colorOptions"
/>
</div>
</div>
<template #footer>
<Button variant="textonly" @click="resetCustomization">
<i class="pi pi-refresh" />
{{ $t('g.reset') }}
</Button>
<Button autofocus @click="confirmCustomization">
<i class="pi pi-check" />
{{ $t('g.confirm') }}
</Button>
</template>
</div>
</div>
<DialogFooter>
<Button variant="textonly" @click="resetCustomization">
<i class="pi pi-refresh" />
{{ $t('g.reset') }}
</Button>
<Button autofocus @click="confirmCustomization">
<i class="pi pi-check" />
{{ $t('g.confirm') }}
</Button>
</DialogFooter>
</DialogContent>
</DialogPortal>
</Dialog>
</template>
<script setup lang="ts">
import Dialog from 'primevue/dialog'
import Divider from 'primevue/divider'
import SelectButton from 'primevue/selectbutton'
import { computed, ref, watch } from 'vue'
import { ref, useId, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import ColorCustomizationSelector from '@/components/common/ColorCustomizationSelector.vue'
import Button from '@/components/ui/button/Button.vue'
import Dialog from '@/components/ui/dialog/Dialog.vue'
import DialogClose from '@/components/ui/dialog/DialogClose.vue'
import DialogContent from '@/components/ui/dialog/DialogContent.vue'
import DialogFooter from '@/components/ui/dialog/DialogFooter.vue'
import DialogHeader from '@/components/ui/dialog/DialogHeader.vue'
import DialogOverlay from '@/components/ui/dialog/DialogOverlay.vue'
import DialogPortal from '@/components/ui/dialog/DialogPortal.vue'
import DialogTitle from '@/components/ui/dialog/DialogTitle.vue'
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
const { t } = useI18n()
const props = defineProps<{
modelValue: boolean
const { initialIcon, initialColor } = defineProps<{
initialIcon?: string
initialColor?: string
}>()
const visible = defineModel<boolean>({ default: false })
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'confirm', icon: string, color: string): void
}>()
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
const titleId = useId()
// PrimeVue ColorPicker overlay teleports to body. Reka treats clicks on it as
// outside and would dismiss the dialog mid-color-pick. Treat any PrimeVue
// overlay click as inside.
const PRIMEVUE_OVERLAY_SELECTORS =
'.p-colorpicker-panel, .p-overlay, .p-overlay-mask'
function onPointerDownOutside(
event: CustomEvent<{ originalEvent: PointerEvent }>
) {
const target = event.detail.originalEvent.target
if (target instanceof Element && target.closest(PRIMEVUE_OVERLAY_SELECTORS)) {
event.preventDefault()
}
}
const nodeBookmarkStore = useNodeBookmarkStore()
@@ -95,30 +134,22 @@ const defaultIcon = iconOptions.find(
)
const selectedIcon = ref(defaultIcon ?? iconOptions[0])
const finalColor = ref(
props.initialColor || nodeBookmarkStore.defaultBookmarkColor
)
const finalColor = ref(initialColor || nodeBookmarkStore.defaultBookmarkColor)
const resetCustomization = () => {
selectedIcon.value =
iconOptions.find((option) => option.value === props.initialIcon) ??
iconOptions[0]
finalColor.value =
props.initialColor || nodeBookmarkStore.defaultBookmarkColor
iconOptions.find((option) => option.value === initialIcon) ?? iconOptions[0]
finalColor.value = initialColor || nodeBookmarkStore.defaultBookmarkColor
}
const confirmCustomization = () => {
emit('confirm', selectedIcon.value.value, finalColor.value)
closeDialog()
}
const closeDialog = () => {
visible.value = false
}
watch(
() => props.modelValue,
(newValue: boolean) => {
visible,
(newValue) => {
if (newValue) {
resetCustomization()
}
@@ -135,10 +166,4 @@ watch(
.p-selectbutton .p-button .pi {
font-size: 1.5rem;
}
.field {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
</style>

View File

@@ -27,7 +27,7 @@ const { t } = useI18n()
/>
<DialogContent
v-bind="$attrs"
class="data-[state=open]:animate-contentShow fixed top-[50%] left-[50%] z-1700 max-h-[85vh] w-[90vw] max-w-[450px] translate-x-[-50%] translate-y-[-50%] rounded-2xl border border-border-subtle bg-base-background p-2 shadow-sm"
class="data-[state=open]:animate-contentShow fixed top-[50%] left-[50%] z-1700 max-h-[85vh] w-[90vw] max-w-[450px] -translate-1/2 rounded-2xl border border-border-subtle bg-base-background p-2 shadow-sm"
>
<div
v-if="title"

View File

@@ -1,7 +1,7 @@
<template>
<div
ref="container"
class="scrollbar-thin scrollbar-track-transparent scrollbar-thumb-(--dialog-surface) h-full overflow-y-auto [overflow-anchor:none] [scrollbar-gutter:stable]"
class="h-full scrollbar-thin scrollbar-thumb-(--dialog-surface) scrollbar-track-transparent scrollbar-gutter-stable overflow-y-auto [overflow-anchor:none]"
>
<div :style="topSpacerStyle" />
<div :style="mergedGridStyle">

View File

@@ -14,7 +14,7 @@
</template>
<template #header>
<FormSearchInput
<AsyncSearchInput
v-model="searchInput"
:searcher="applySearchQuery"
:debounce-ms="400"
@@ -412,7 +412,7 @@ import CardBottom from '@/components/card/CardBottom.vue'
import CardContainer from '@/components/card/CardContainer.vue'
import CardTop from '@/components/card/CardTop.vue'
import Tag from '@/components/chip/Tag.vue'
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
import MultiSelect from '@/components/ui/multi-select/MultiSelect.vue'
import SingleSelect from '@/components/ui/single-select/SingleSelect.vue'
import AudioThumbnail from '@/components/templates/thumbnails/AudioThumbnail.vue'

View File

@@ -35,13 +35,13 @@
</Button>
</div>
<template v-if="reportOpen">
<Divider />
<ScrollPanel class="h-[400px] w-full max-w-[80vw]">
<hr class="border-t border-border-subtle" />
<div class="h-[400px] w-full max-w-[80vw] overflow-auto">
<pre class="wrap-break-word whitespace-pre-wrap">{{
reportContent
}}</pre>
</ScrollPanel>
<Divider />
</div>
<hr class="border-t border-border-subtle" />
</template>
<div class="flex justify-end gap-4">
<FindIssueButton
@@ -62,8 +62,6 @@
</template>
<script setup lang="ts">
import Divider from 'primevue/divider'
import ScrollPanel from 'primevue/scrollpanel'
import { useToast } from 'primevue/usetoast'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'

View File

@@ -11,7 +11,7 @@
>
<svg
viewBox="0 0 15 15"
class="pointer-events-none h-6.25 w-6.25 fill-current"
class="pointer-events-none size-6.25 fill-current"
>
<path
d="M8.77,12.18c-.25,0-.46-.2-.46-.46s.2-.46.46-.46c1.47,0,2.67-1.2,2.67-2.67,0-1.57-1.34-2.67-3.26-2.67h-3.98l1.43,1.43c.18.18.18.47,0,.64-.18.18-.47.18-.64,0l-2.21-2.21c-.18-.18-.18-.47,0-.64l2.21-2.21c.18-.18.47-.18.64,0,.18.18.18.47,0,.64l-1.43,1.43h3.98c2.45,0,4.17,1.47,4.17,3.58,0,1.97-1.61,3.58-3.58,3.58Z"
@@ -26,7 +26,7 @@
>
<svg
viewBox="0 0 15 15"
class="pointer-events-none h-6.25 w-6.25 fill-(--input-text)"
class="pointer-events-none size-6.25 fill-(--input-text)"
>
<path
class="cls-1"
@@ -44,7 +44,7 @@
>
<svg
viewBox="-6 -7 15 15"
class="pointer-events-none h-6.25 w-6.25 fill-(--input-text)"
class="pointer-events-none size-6.25 fill-(--input-text)"
>
<path
d="m2.25-2.625c.3452 0 .625.2798.625.625v5c0 .3452-.2798.625-.625.625h-5c-.3452 0-.625-.2798-.625-.625v-5c0-.3452.2798-.625.625-.625h5zm1.25.625v5c0 .6904-.5596 1.25-1.25 1.25h-5c-.6904 0-1.25-.5596-1.25-1.25v-5c0-.6904.5596-1.25 1.25-1.25h5c.6904 0 1.25.5596 1.25 1.25zm-.1673-2.3757-.4419.4419-1.5246-1.5246 1.5416-1.5417.442.4419-.7871.7872h.9373c1.3807 0 2.5 1.1193 2.5 2.5h-.625c0-1.0355-.8395-1.875-1.875-1.875h-.9375l.7702.7702z"
@@ -59,7 +59,7 @@
>
<svg
viewBox="-9 -7 15 15"
class="pointer-events-none h-6.25 w-6.25 fill-(--input-text)"
class="pointer-events-none size-6.25 fill-(--input-text)"
>
<g transform="scale(-1, 1)">
<path
@@ -76,7 +76,7 @@
>
<svg
viewBox="0 0 15 15"
class="pointer-events-none h-6.25 w-6.25 fill-(--input-text)"
class="pointer-events-none size-6.25 fill-(--input-text)"
>
<path
d="M7.5,1.5c-.28,0-.5.22-.5.5v11c0,.28.22.5.5.5s.5-.22.5-.5v-11c0-.28-.22-.5-.5-.5Z"
@@ -92,7 +92,7 @@
>
<svg
viewBox="0 0 15 15"
class="pointer-events-none h-6.25 w-6.25 fill-(--input-text)"
class="pointer-events-none size-6.25 fill-(--input-text)"
>
<path
d="M2,7.5c0-.28.22-.5.5-.5h11c.28,0,.5.22.5.5s-.22.5-.5.5h-11c-.28,0-.5-.22-.5-.5Z"

View File

@@ -370,7 +370,7 @@ function handleTitleCancel() {
</section>
<!-- Panel Content -->
<div class="scrollbar-thin flex-1 overflow-y-auto">
<div class="flex-1 scrollbar-thin overflow-y-auto">
<TabErrors v-if="activeTab === 'errors'" />
<template v-else-if="!hasSelection">
<TabGlobalParameters v-if="activeTab === 'parameters'" />

View File

@@ -82,7 +82,7 @@ describe('TabErrors.vue', () => {
})
],
stubs: {
FormSearchInput: {
AsyncSearchInput: {
template:
'<input @input="$emit(\'update:modelValue\', $event.target.value)" />'
},

View File

@@ -4,7 +4,7 @@
<div
class="flex min-w-0 shrink-0 items-center border-b border-interface-stroke px-4 pt-1 pb-4"
>
<FormSearchInput v-model="searchQuery" class="flex-1" />
<AsyncSearchInput v-model="searchQuery" class="flex-1" />
<CollapseToggleButton
v-model="isAllCollapsed"
:show="!isSearching && tabErrorGroups.length > 1"
@@ -260,7 +260,7 @@ import { NodeBadgeMode } from '@/types/nodeSource'
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
import CollapseToggleButton from '../layout/CollapseToggleButton.vue'
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
import ErrorNodeCard from './ErrorNodeCard.vue'
import MissingNodeCard from './MissingNodeCard.vue'
import SwapNodesCard from '@/platform/nodeReplacement/components/SwapNodesCard.vue'

View File

@@ -11,7 +11,7 @@ import {
} from 'vue'
import { useI18n } from 'vue-i18n'
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
import { DraggableList } from '@/scripts/ui/draggableList'
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
import type { ValidFavoritedWidget } from '@/stores/workspace/favoritedWidgetsStore'
@@ -119,7 +119,7 @@ function onCollapseUpdate() {
<div
class="flex items-center border-b border-interface-stroke px-4 pt-1 pb-4"
>
<FormSearchInput
<AsyncSearchInput
v-model="searchQuery"
:searcher
:update-key="favoritedWidgets"

View File

@@ -7,7 +7,7 @@ import CollapseToggleButton from '@/components/rightSidePanel/layout/CollapseTog
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/litegraph'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { computedSectionDataList, searchWidgetsAndNodes } from '../shared'
@@ -78,7 +78,7 @@ async function searcher(query: string) {
<div
class="flex items-center border-b border-interface-stroke px-4 pt-1 pb-4"
>
<FormSearchInput
<AsyncSearchInput
v-model="searchQuery"
:searcher
:update-key="widgetsSectionDataList"

View File

@@ -5,7 +5,7 @@ import { useI18n } from 'vue-i18n'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/litegraph'
import CollapseToggleButton from '@/components/rightSidePanel/layout/CollapseToggleButton.vue'
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
@@ -122,7 +122,7 @@ const advancedLabel = computed(() => {
<div
class="flex items-center border-b border-interface-stroke px-4 pt-1 pb-4"
>
<FormSearchInput
<AsyncSearchInput
v-model="searchQuery"
:searcher
:update-key="widgetsSectionDataList"

View File

@@ -17,7 +17,7 @@ import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { getWidgetName } from '@/core/graph/subgraph/promotionUtils'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
import CollapseToggleButton from '@/components/rightSidePanel/layout/CollapseToggleButton.vue'
import { DraggableList } from '@/scripts/ui/draggableList'
import { usePromotionStore } from '@/stores/promotionStore'
@@ -217,7 +217,7 @@ const label = computed(() => {
<div
class="flex items-center border-b border-interface-stroke px-4 pt-1 pb-4"
>
<FormSearchInput
<AsyncSearchInput
v-model="searchQuery"
:searcher
:update-key="widgetsList"

View File

@@ -20,7 +20,7 @@ import type { WidgetItem } from '@/core/graph/subgraph/promotionUtils'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
import { useLitegraphService } from '@/services/litegraphService'
import { usePromotionStore } from '@/stores/promotionStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
@@ -226,7 +226,7 @@ onMounted(() => {
<template>
<div v-if="activeNode" class="subgraph-edit-section flex h-full flex-col">
<div class="flex gap-2 border-b border-interface-stroke px-4 pt-1 pb-4">
<FormSearchInput v-model="searchQuery" />
<AsyncSearchInput v-model="searchQuery" />
</div>
<div class="flex-1">

View File

@@ -52,7 +52,7 @@
<span
v-for="val in tf.values.slice(0, MAX_VISIBLE_DOTS)"
:key="val"
class="-mx-[2px] text-lg leading-none"
class="mx-[-2px] text-lg leading-none"
:style="{ color: getLinkTypeColor(val) }"
>&bull;</span
>

View File

@@ -5,7 +5,7 @@ import { defineComponent, ref } from 'vue'
import type { ComponentProps } from 'vue-component-type-helpers'
import { createI18n } from 'vue-i18n'
import FormSearchInput from './FormSearchInput.vue'
import AsyncSearchInput from './AsyncSearchInput.vue'
const i18n = createI18n({
legacy: false,
@@ -20,7 +20,7 @@ const i18n = createI18n({
}
})
type Searcher = NonNullable<ComponentProps<typeof FormSearchInput>['searcher']>
type Searcher = NonNullable<ComponentProps<typeof AsyncSearchInput>['searcher']>
function renderSearch(
initialQuery: string = '',
@@ -30,9 +30,9 @@ function renderSearch(
const query = ref(initialQuery)
const key = updateKey
const Harness = defineComponent({
components: { FormSearchInput },
components: { AsyncSearchInput },
setup: () => ({ query, searcher, key }),
template: `<FormSearchInput
template: `<AsyncSearchInput
v-model="query"
:searcher="searcher"
:update-key="key"
@@ -42,7 +42,7 @@ function renderSearch(
return { ...utils, query, key }
}
describe('FormSearchInput', () => {
describe('AsyncSearchInput', () => {
beforeEach(() => {
vi.useFakeTimers()
})

View File

@@ -47,7 +47,7 @@ watch(
searcher(debouncedSearchQuery.value, (cb) => (cleanupFn = cb))
.catch((error) => {
console.error('[SidePanelSearch] searcher failed', error)
console.error('[AsyncSearchInput] searcher failed', error)
})
.finally(() => {
if (!isCleanup) isQuerying.value = false

View File

@@ -23,7 +23,7 @@ defineExpose({
v-model="modelValue"
:class="
cn(
'flex min-h-16 w-full rounded-lg border-none bg-secondary-background px-3 py-2 text-sm text-base-foreground placeholder:text-muted-foreground focus-visible:ring-1 focus-visible:ring-border-default focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50',
'flex min-h-16 w-full scrollbar-gutter-stable rounded-lg border-none bg-secondary-background px-3 py-2 text-sm text-base-foreground placeholder:text-muted-foreground focus-visible:ring-1 focus-visible:ring-border-default focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50',
className
)
"

View File

@@ -23,8 +23,19 @@ import type { CanvasPointerEvent } from './types/events'
* - {@link LGraphCanvas.processMouseUp}
*/
export class CanvasPointer {
/** Maximum time in milliseconds to ignore click drift */
static bufferTime = 150
/**
* Maximum time in milliseconds to ignore click drift.
*
* This is the upper bound on how long after pointerdown the system will wait
* before deciding "this is a drag, not a click" when the pointer hasn't moved
* past {@link maxClickDrift}. Keep this short — drags should feel instant.
* Disambiguation between click and drag is primarily handled by distance
* ({@link maxClickDrift}); this time threshold only matters when the user
* holds the pointer still then releases. ~2 frames at 60fps is plenty.
*
* Overridden at runtime by the `Comfy.Pointer.ClickBufferTime` user setting.
*/
static bufferTime = 32
/** Maximum gap between pointerup and pointerdown events to be considered as a double click */
static doubleClickTime = 300

View File

@@ -913,7 +913,8 @@
"extensionFileHint": "قد يكون السبب هو السكربت التالي",
"loadWorkflowTitle": "تم إلغاء التحميل بسبب خطأ في إعادة تحميل بيانات سير العمل",
"noStackTrace": "لا توجد تتبع للمكدس متاحة",
"promptExecutionError": "فشل تنفيذ الطلب"
"promptExecutionError": "فشل تنفيذ الطلب",
"queueOpenWorkflowFailedTitle": "فشل في فتح سير العمل"
},
"errorOverlay": {
"errorCount": "{count} خطأ | {count} أخطاء",
@@ -1041,6 +1042,7 @@
"comfy": "Comfy",
"comfyCloud": "Comfy Cloud",
"comfyOrgLogoAlt": "شعار ComfyOrg",
"comfyPackageOutdated": "إصدار {name} المثبت ({installedVersion}) أقل من الإصدار المطلوب ({requiredVersion}).",
"comingSoon": "قريباً",
"command": "أمر",
"commandProhibited": "الأمر {command} محظور. يرجى التواصل مع المسؤول لمزيد من المعلومات.",

View File

@@ -338,7 +338,7 @@
},
"Comfy_Pointer_ClickBufferTime": {
"name": "Pointer click drift delay",
"tooltip": "After pressing a pointer button down, this is the maximum time (in milliseconds) that pointer movement can be ignored for.\n\nHelps prevent objects from being unintentionally nudged if the pointer is moved whilst clicking."
"tooltip": "After pressing a pointer button down, this is the maximum time (in milliseconds) that pointer movement can be ignored for.\n\nHelps prevent objects from being unintentionally nudged if the pointer is moved whilst clicking.\n\nThe distance threshold (Pointer click drift) already disambiguates clicks from drags; this time threshold only matters when the pointer is held still then released. A long delay here forces every pointerdown to wait before drag begins, which feels laggy when click+dragging an unselected node. ~2 frames at 60fps is plenty."
},
"Comfy_Pointer_ClickDrift": {
"name": "Pointer click drift (maximum distance)",

View File

@@ -913,7 +913,8 @@
"extensionFileHint": "Esto puede deberse al siguiente script",
"loadWorkflowTitle": "La carga se interrumpió debido a un error al recargar los datos del flujo de trabajo",
"noStackTrace": "No hay seguimiento de pila disponible",
"promptExecutionError": "La ejecución del prompt falló"
"promptExecutionError": "La ejecución del prompt falló",
"queueOpenWorkflowFailedTitle": "No se pudo abrir el flujo de trabajo"
},
"errorOverlay": {
"errorCount": "{count} ERROR | {count} ERRORES",
@@ -1041,6 +1042,7 @@
"comfy": "Comfy",
"comfyCloud": "Comfy Cloud",
"comfyOrgLogoAlt": "Logo de ComfyOrg",
"comfyPackageOutdated": "La versión instalada de {name} ({installedVersion}) es inferior a la versión requerida ({requiredVersion}).",
"comingSoon": "Próximamente",
"command": "Comando",
"commandProhibited": "El comando {command} está prohibido. Contacta a un administrador para más información.",

View File

@@ -913,7 +913,8 @@
"extensionFileHint": "این ممکن است به دلیل اسکریپت زیر باشد",
"loadWorkflowTitle": "بارگذاری به دلیل خطا در بارگذاری مجدد داده‌های workflow متوقف شد",
"noStackTrace": "هیچ stacktraceی موجود نیست",
"promptExecutionError": "اجرای prompt با شکست مواجه شد"
"promptExecutionError": "اجرای prompt با شکست مواجه شد",
"queueOpenWorkflowFailedTitle": "باز کردن workflow با خطا مواجه شد"
},
"errorOverlay": {
"errorCount": "{count} خطا",
@@ -1041,6 +1042,7 @@
"comfy": "Comfy",
"comfyCloud": "Comfy Cloud",
"comfyOrgLogoAlt": "لوگوی ComfyOrg",
"comfyPackageOutdated": "نسخه نصب‌شده {name} با شماره {installedVersion} پایین‌تر از نسخه مورد نیاز {requiredVersion} است.",
"comingSoon": "به‌زودی",
"command": "دستور",
"commandProhibited": "دستور {command} مجاز نیست. برای اطلاعات بیشتر با مدیر تماس بگیرید.",

View File

@@ -913,7 +913,8 @@
"extensionFileHint": "Cela peut être dû au script suivant",
"loadWorkflowTitle": "Chargement interrompu en raison d'une erreur de rechargement des données de workflow",
"noStackTrace": "Aucune trace de pile disponible",
"promptExecutionError": "L'exécution de l'invite a échoué"
"promptExecutionError": "L'exécution de l'invite a échoué",
"queueOpenWorkflowFailedTitle": "Échec de l'ouverture du workflow"
},
"errorOverlay": {
"errorCount": "{count} ERREUR | {count} ERREURS",
@@ -1041,6 +1042,7 @@
"comfy": "Comfy",
"comfyCloud": "Comfy Cloud",
"comfyOrgLogoAlt": "Logo ComfyOrg",
"comfyPackageOutdated": "La version installée de {name} ({installedVersion}) est inférieure à la version requise ({requiredVersion}).",
"comingSoon": "Bientôt disponible",
"command": "Commande",
"commandProhibited": "La commande {command} est interdite. Contactez un administrateur pour plus d'informations.",

View File

@@ -913,7 +913,8 @@
"extensionFileHint": "これは次のスクリプトが原因かもしれません",
"loadWorkflowTitle": "ワークフローデータの再読み込みエラーにより、読み込みが中止されました",
"noStackTrace": "スタックトレースは利用できません",
"promptExecutionError": "プロンプトの実行に失敗しました"
"promptExecutionError": "プロンプトの実行に失敗しました",
"queueOpenWorkflowFailedTitle": "ワークフローのオープンに失敗しました"
},
"errorOverlay": {
"errorCount": "{count} 件のエラー",
@@ -1041,6 +1042,7 @@
"comfy": "Comfy",
"comfyCloud": "Comfy Cloud",
"comfyOrgLogoAlt": "ComfyOrgロゴ",
"comfyPackageOutdated": "インストールされている{name}のバージョン{installedVersion}は、必要なバージョン{requiredVersion}よりも低いです。",
"comingSoon": "近日公開",
"command": "コマンド",
"commandProhibited": "コマンド {command} は禁止されています。詳細は管理者にお問い合わせください。",

View File

@@ -913,7 +913,8 @@
"extensionFileHint": "다음 스크립트 때문일 수 있습니다",
"loadWorkflowTitle": "워크플로 데이터를 다시 로드하는 중 오류로 인해 로드가 중단되었습니다",
"noStackTrace": "스택 추적을 사용할 수 없습니다",
"promptExecutionError": "프롬프트 실행 실패"
"promptExecutionError": "프롬프트 실행 실패",
"queueOpenWorkflowFailedTitle": "워크플로우 열기에 실패했습니다"
},
"errorOverlay": {
"errorCount": "{count}개 오류",
@@ -1041,6 +1042,7 @@
"comfy": "Comfy",
"comfyCloud": "Comfy Cloud",
"comfyOrgLogoAlt": "ComfyOrg 로고",
"comfyPackageOutdated": "설치된 {name} 버전 {installedVersion}이(가) 필요한 버전 {requiredVersion}보다 낮습니다.",
"comingSoon": "곧 출시 예정",
"command": "명령",
"commandProhibited": "{command}는 금지된 명령입니다. 자세한 정보는 관리자에게 문의하십시오.",

View File

@@ -913,7 +913,8 @@
"extensionFileHint": "Isso pode ser devido ao seguinte script",
"loadWorkflowTitle": "Carregamento abortado devido a erro ao recarregar os dados do fluxo de trabalho",
"noStackTrace": "Nenhum stacktrace disponível",
"promptExecutionError": "Falha na execução do prompt"
"promptExecutionError": "Falha na execução do prompt",
"queueOpenWorkflowFailedTitle": "Falha ao abrir o workflow"
},
"errorOverlay": {
"errorCount": "{count} ERRO | {count} ERROS",
@@ -1041,6 +1042,7 @@
"comfy": "Comfy",
"comfyCloud": "Comfy Cloud",
"comfyOrgLogoAlt": "Logo do ComfyOrg",
"comfyPackageOutdated": "A versão instalada de {name} ({installedVersion}) é inferior à versão necessária ({requiredVersion}).",
"comingSoon": "Em breve",
"command": "Comando",
"commandProhibited": "O comando {command} é proibido. Entre em contato com um administrador para mais informações.",

View File

@@ -913,7 +913,8 @@
"extensionFileHint": "Это может быть связано со следующим скриптом",
"loadWorkflowTitle": "Загрузка прервана из-за ошибки при перезагрузке данных рабочего процесса",
"noStackTrace": "Стек вызовов недоступен",
"promptExecutionError": "Ошибка выполнения запроса"
"promptExecutionError": "Ошибка выполнения запроса",
"queueOpenWorkflowFailedTitle": "Не удалось открыть рабочий процесс"
},
"errorOverlay": {
"errorCount": "{count} ОШИБОК | {count} ОШИБКА | {count} ОШИБКИ",
@@ -1041,6 +1042,7 @@
"comfy": "Comfy",
"comfyCloud": "Comfy Cloud",
"comfyOrgLogoAlt": "Логотип ComfyOrg",
"comfyPackageOutdated": "Установленная версия {name} ({installedVersion}) ниже требуемой версии ({requiredVersion}).",
"comingSoon": "Скоро будет",
"command": "Команда",
"commandProhibited": "Команда {command} запрещена. Свяжитесь с администратором для получения дополнительной информации.",

View File

@@ -913,7 +913,8 @@
"extensionFileHint": "Bu, aşağıdaki komut dosyasından kaynaklanıyor olabilir",
"loadWorkflowTitle": "İş akışı verileri yeniden yüklenirken hata nedeniyle yükleme iptal edildi",
"noStackTrace": "Yığın izi mevcut değil",
"promptExecutionError": "İstem yürütmesi başarısız oldu"
"promptExecutionError": "İstem yürütmesi başarısız oldu",
"queueOpenWorkflowFailedTitle": "İş Akışıılamadı"
},
"errorOverlay": {
"errorCount": "{count} HATA | {count} HATA",
@@ -1041,6 +1042,7 @@
"comfy": "Comfy",
"comfyCloud": "Comfy Cloud",
"comfyOrgLogoAlt": "ComfyOrg Logosu",
"comfyPackageOutdated": "Yüklü {name} sürümü ({installedVersion}), gerekli sürümden ({requiredVersion}) daha eski.",
"comingSoon": "Çok Yakında",
"command": "Komut",
"commandProhibited": "{command} komutu yasak. Daha fazla bilgi için bir yöneticiyle iletişime geçin.",

View File

@@ -913,7 +913,8 @@
"extensionFileHint": "這可能是由於以下指令碼所致",
"loadWorkflowTitle": "由於重新載入工作流程資料時發生錯誤,已中止載入",
"noStackTrace": "沒有可用的堆疊追蹤",
"promptExecutionError": "提示執行失敗"
"promptExecutionError": "提示執行失敗",
"queueOpenWorkflowFailedTitle": "開啟工作流程失敗"
},
"errorOverlay": {
"errorCount": "{count} 個錯誤",
@@ -1041,6 +1042,7 @@
"comfy": "Comfy",
"comfyCloud": "Comfy Cloud",
"comfyOrgLogoAlt": "ComfyOrg 標誌",
"comfyPackageOutdated": "已安裝的 {name} 版本 {installedVersion} 低於所需版本 {requiredVersion}。",
"comingSoon": "即將推出",
"command": "指令",
"commandProhibited": "指令 {command} 已被禁止。如需更多資訊,請聯絡管理員。",

View File

@@ -913,7 +913,8 @@
"extensionFileHint": "这可能是由于以下脚本",
"loadWorkflowTitle": "由于重新加载工作流数据出错,加载被中止",
"noStackTrace": "无可用堆栈跟踪",
"promptExecutionError": "提示执行失败"
"promptExecutionError": "提示执行失败",
"queueOpenWorkflowFailedTitle": "打开工作流失败"
},
"errorOverlay": {
"errorCount": "{count}个错误",
@@ -1041,6 +1042,7 @@
"comfy": "舒适",
"comfyCloud": "Comfy Cloud",
"comfyOrgLogoAlt": "ComfyOrg 徽标",
"comfyPackageOutdated": "已安装的 {name} 版本 {installedVersion} 低于所需版本 {requiredVersion}。",
"comingSoon": "即将推出",
"command": "指令",
"commandProhibited": "命令 {command} 被禁止。请联系管理员了解更多信息。",

View File

@@ -1,48 +1,51 @@
<template>
<Dialog
v-model:visible="isVisible"
modal
:closable="false"
:close-on-escape="false"
:dismissable-mask="true"
:pt="{
root: { class: 'video-help-dialog' },
header: { class: '!hidden' },
content: { class: '!p-0' },
mask: { class: '!bg-black/70' }
}"
:style="{ width: '90vw' }"
>
<div class="relative">
<Button
variant="textonly"
size="icon"
class="absolute top-4 right-6 z-10"
:aria-label="$t('g.close')"
@click="isVisible = false"
>
<i class="pi pi-times text-sm" />
</Button>
<video
autoplay
muted
loop
<Dialog v-model:open="isVisible">
<DialogPortal>
<DialogOverlay class="bg-black/70" />
<DialogContent
size="full"
class="w-[90vw] border-0 bg-transparent p-0 shadow-none"
:aria-label="ariaLabel"
class="w-full rounded-lg"
:src="videoUrl"
@escape-key-down="onEscapeKeyDown"
>
{{ $t('g.videoFailedToLoad') }}
</video>
</div>
<VisuallyHidden as-child>
<DialogTitle>{{ ariaLabel }}</DialogTitle>
</VisuallyHidden>
<div class="relative">
<Button
variant="textonly"
size="icon"
class="absolute top-4 right-6 z-10"
:aria-label="$t('g.close')"
@click="isVisible = false"
>
<i class="pi pi-times text-sm" />
</Button>
<video
autoplay
muted
loop
:aria-label="ariaLabel"
class="w-full rounded-lg"
:src="videoUrl"
>
{{ $t('g.videoFailedToLoad') }}
</video>
</div>
</DialogContent>
</DialogPortal>
</Dialog>
</template>
<script setup lang="ts">
import { useEventListener } from '@vueuse/core'
import Dialog from 'primevue/dialog'
import { onWatcherCleanup, watch } from 'vue'
import { VisuallyHidden } from 'reka-ui'
import Button from '@/components/ui/button/Button.vue'
import Dialog from '@/components/ui/dialog/Dialog.vue'
import DialogContent from '@/components/ui/dialog/DialogContent.vue'
import DialogOverlay from '@/components/ui/dialog/DialogOverlay.vue'
import DialogPortal from '@/components/ui/dialog/DialogPortal.vue'
import DialogTitle from '@/components/ui/dialog/DialogTitle.vue'
const isVisible = defineModel<boolean>({ required: true })
@@ -51,27 +54,13 @@ const { videoUrl, ariaLabel = 'Help video' } = defineProps<{
ariaLabel?: string
}>()
const handleEscapeKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.stopImmediatePropagation()
event.stopPropagation()
event.preventDefault()
isVisible.value = false
}
// The dialog mounts inside other dialogs (e.g. UploadModelFooter inside an
// asset modal). Reka's Escape handling bubbles to the parent dialog and would
// close it as well. Stop propagation so only this dialog closes, and prevent
// Reka's default auto-dismiss so the close path stays solely under the model.
function onEscapeKeyDown(event: KeyboardEvent) {
event.preventDefault()
event.stopPropagation()
isVisible.value = false
}
// Add listener with capture phase to intercept before parent dialogs
// Only active when dialog is visible
watch(
isVisible,
(visible) => {
if (visible) {
const stop = useEventListener(document, 'keydown', handleEscapeKey, {
capture: true
})
onWatcherCleanup(stop)
}
},
{ immediate: true }
)
</script>

View File

@@ -196,7 +196,7 @@
rows="3"
:class="
cn(
'w-full resize-y rounded-lg border border-transparent bg-transparent px-3 py-2 text-sm text-component-node-foreground transition-colors outline-none focus:bg-component-node-widget-background',
'w-full resize-y scrollbar-gutter-stable rounded-lg border border-transparent bg-transparent px-3 py-2 text-sm text-component-node-foreground transition-colors outline-none focus:bg-component-node-widget-background',
isImmutable && 'cursor-not-allowed'
)
"

View File

@@ -21,7 +21,7 @@
loop
muted
playsinline
class="-ml-[20%] h-full min-w-5/4 object-cover p-0"
class="ml-[-20%] h-full min-w-5/4 object-cover p-0"
>
<source
src="/assets/images/cloud-subscription.webm"

View File

@@ -0,0 +1,98 @@
import { render } from '@testing-library/vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent } from 'vue'
import { createI18n } from 'vue-i18n'
import SecretFormDialog from './SecretFormDialog.vue'
vi.mock('../composables/useSecretForm', () => ({
useSecretForm: () => ({
form: { provider: '', name: '', secretValue: '' },
errors: {},
loading: false,
apiError: '',
providerOptions: [],
handleSubmit: vi.fn()
})
}))
vi.mock('primevue/inputtext', () => ({
default: { name: 'InputText', template: '<input />' }
}))
vi.mock('primevue/password', () => ({
default: { name: 'Password', template: '<input type="password" />' }
}))
let capturedPointerDownOutside: ((event: Event) => void) | null = null
vi.mock('@/components/ui/button/Button.vue', () => ({
default: { name: 'Button', template: '<button><slot /></button>' }
}))
vi.mock('@/components/ui/select/Select.vue', () => ({
default: { name: 'Select', template: '<div><slot /></div>' }
}))
vi.mock('@/components/ui/select/SelectContent.vue', () => ({
default: { name: 'SelectContent', template: '<div><slot /></div>' }
}))
vi.mock('@/components/ui/select/SelectItem.vue', () => ({
default: { name: 'SelectItem', template: '<div><slot /></div>' }
}))
vi.mock('@/components/ui/select/SelectTrigger.vue', () => ({
default: { name: 'SelectTrigger', template: '<div><slot /></div>' }
}))
vi.mock('@/components/ui/select/SelectValue.vue', () => ({
default: { name: 'SelectValue', template: '<span />' }
}))
vi.mock('@/components/ui/dialog/Dialog.vue', () => ({
default: { name: 'Dialog', template: '<div><slot /></div>' }
}))
vi.mock('@/components/ui/dialog/DialogPortal.vue', () => ({
default: { name: 'DialogPortal', template: '<div><slot /></div>' }
}))
vi.mock('@/components/ui/dialog/DialogOverlay.vue', () => ({
default: { name: 'DialogOverlay', template: '<div />' }
}))
vi.mock('@/components/ui/dialog/DialogContent.vue', () => ({
default: defineComponent({
name: 'DialogContent',
inheritAttrs: false,
setup(_, { attrs }) {
const onPointerDownOutside = (attrs as Record<string, unknown>)[
'onPointerDownOutside'
] as ((event: Event) => void) | undefined
capturedPointerDownOutside = onPointerDownOutside ?? null
},
template: '<div data-testid="dialog-content"><slot /></div>'
})
}))
vi.mock('@/components/ui/dialog/DialogHeader.vue', () => ({
default: { name: 'DialogHeader', template: '<div><slot /></div>' }
}))
vi.mock('@/components/ui/dialog/DialogTitle.vue', () => ({
default: { name: 'DialogTitle', template: '<div><slot /></div>' }
}))
vi.mock('@/components/ui/dialog/DialogClose.vue', () => ({
default: { name: 'DialogClose', template: '<button />' }
}))
const i18n = createI18n({ legacy: false, locale: 'en', messages: { en: {} } })
describe('SecretFormDialog', () => {
beforeEach(() => {
capturedPointerDownOutside = null
})
it('prevents backdrop pointer-down-outside from closing the dialog', () => {
render(SecretFormDialog, {
global: { plugins: [i18n] },
props: { visible: true }
})
expect(capturedPointerDownOutside).not.toBeNull()
const event = new CustomEvent('pointerDownOutside', { cancelable: true })
capturedPointerDownOutside!(event)
expect(event.defaultPrevented).toBe(true)
})
})

View File

@@ -1,106 +1,130 @@
<template>
<Dialog
v-model:visible="visible"
:header="
mode === 'create' ? $t('secrets.addSecret') : $t('secrets.editSecret')
"
modal
class="w-full max-w-md"
>
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
<div class="flex flex-col gap-1">
<label for="secret-provider" class="text-sm font-medium">
{{ $t('secrets.provider') }}
</label>
<Select v-model="form.provider" :disabled="mode === 'edit'">
<SelectTrigger id="secret-provider" class="w-full" autofocus>
<SelectValue :placeholder="$t('g.none')" />
</SelectTrigger>
<SelectContent disable-portal>
<SelectItem
v-for="option in providerOptions"
:key="option.value || 'none'"
:value="option.value"
:disabled="option.disabled"
>
{{ option.label }}
</SelectItem>
</SelectContent>
</Select>
<small v-if="errors.provider" class="text-red-500">
{{ errors.provider }}
</small>
</div>
<div class="flex flex-col gap-1">
<label for="secret-name" class="text-sm font-medium">
{{ $t('secrets.name') }}
</label>
<InputText
id="secret-name"
v-model="form.name"
:placeholder="$t('secrets.namePlaceholder')"
:class="{ 'p-invalid': errors.name }"
/>
<small v-if="errors.name" class="text-red-500">{{ errors.name }}</small>
</div>
<div class="flex flex-col gap-1">
<label for="secret-value" class="text-sm font-medium">
{{ $t('secrets.secretValue') }}
</label>
<Password
id="secret-value"
v-model="form.secretValue"
:placeholder="
mode === 'edit'
? $t('secrets.secretValuePlaceholderEdit')
: $t('secrets.secretValuePlaceholder')
"
:feedback="false"
toggle-mask
fluid
:class="{ 'p-invalid': errors.secretValue }"
/>
<small v-if="errors.secretValue" class="text-red-500">
{{ errors.secretValue }}
</small>
<small v-else class="text-muted">
{{
mode === 'edit'
? $t('secrets.secretValueHintEdit')
: $t('secrets.secretValueHint')
}}
</small>
</div>
<span v-if="apiError" class="text-destructive text-sm">
{{ apiError }}
</span>
<div class="flex justify-end gap-2 pt-2">
<Button
variant="secondary"
type="button"
tabindex="0"
@click="visible = false"
<Dialog v-model:open="visible">
<DialogPortal>
<DialogOverlay />
<DialogContent
size="md"
:aria-labelledby="titleId"
@pointer-down-outside.prevent
>
<DialogHeader>
<DialogTitle :id="titleId">
{{
mode === 'create'
? $t('secrets.addSecret')
: $t('secrets.editSecret')
}}
</DialogTitle>
<DialogClose />
</DialogHeader>
<form
class="flex flex-col gap-4 px-4 py-2"
@submit.prevent="handleSubmit"
>
{{ $t('g.cancel') }}
</Button>
<Button type="submit" tabindex="0" :loading="loading">
{{ $t('g.save') }}
</Button>
</div>
</form>
<div class="flex flex-col gap-1">
<label for="secret-provider" class="text-sm font-medium">
{{ $t('secrets.provider') }}
</label>
<Select v-model="form.provider" :disabled="mode === 'edit'">
<SelectTrigger id="secret-provider" class="w-full" autofocus>
<SelectValue :placeholder="$t('g.none')" />
</SelectTrigger>
<SelectContent disable-portal>
<SelectItem
v-for="option in providerOptions"
:key="option.value || 'none'"
:value="option.value"
:disabled="option.disabled"
>
{{ option.label }}
</SelectItem>
</SelectContent>
</Select>
<small v-if="errors.provider" class="text-red-500">
{{ errors.provider }}
</small>
</div>
<div class="flex flex-col gap-1">
<label for="secret-name" class="text-sm font-medium">
{{ $t('secrets.name') }}
</label>
<InputText
id="secret-name"
v-model="form.name"
:placeholder="$t('secrets.namePlaceholder')"
:class="{ 'p-invalid': errors.name }"
/>
<small v-if="errors.name" class="text-red-500">
{{ errors.name }}
</small>
</div>
<div class="flex flex-col gap-1">
<label for="secret-value" class="text-sm font-medium">
{{ $t('secrets.secretValue') }}
</label>
<Password
id="secret-value"
v-model="form.secretValue"
:placeholder="
mode === 'edit'
? $t('secrets.secretValuePlaceholderEdit')
: $t('secrets.secretValuePlaceholder')
"
:feedback="false"
toggle-mask
fluid
:class="{ 'p-invalid': errors.secretValue }"
/>
<small v-if="errors.secretValue" class="text-red-500">
{{ errors.secretValue }}
</small>
<small v-else class="text-muted">
{{
mode === 'edit'
? $t('secrets.secretValueHintEdit')
: $t('secrets.secretValueHint')
}}
</small>
</div>
<span v-if="apiError" class="text-destructive text-sm">
{{ apiError }}
</span>
<div class="flex justify-end gap-2 py-2">
<Button
variant="secondary"
type="button"
tabindex="0"
@click="visible = false"
>
{{ $t('g.cancel') }}
</Button>
<Button type="submit" tabindex="0" :loading="loading">
{{ $t('g.save') }}
</Button>
</div>
</form>
</DialogContent>
</DialogPortal>
</Dialog>
</template>
<script setup lang="ts">
import Dialog from 'primevue/dialog'
import InputText from 'primevue/inputtext'
import Password from 'primevue/password'
import { useId } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import Dialog from '@/components/ui/dialog/Dialog.vue'
import DialogClose from '@/components/ui/dialog/DialogClose.vue'
import DialogContent from '@/components/ui/dialog/DialogContent.vue'
import DialogHeader from '@/components/ui/dialog/DialogHeader.vue'
import DialogOverlay from '@/components/ui/dialog/DialogOverlay.vue'
import DialogPortal from '@/components/ui/dialog/DialogPortal.vue'
import DialogTitle from '@/components/ui/dialog/DialogTitle.vue'
import Select from '@/components/ui/select/Select.vue'
import SelectContent from '@/components/ui/select/SelectContent.vue'
import SelectItem from '@/components/ui/select/SelectItem.vue'
@@ -126,6 +150,8 @@ const emit = defineEmits<{
saved: []
}>()
const titleId = useId()
const { form, errors, loading, apiError, providerOptions, handleSubmit } =
useSecretForm({
mode,

View File

@@ -803,16 +803,17 @@ export const CORE_SETTINGS: SettingParams[] = [
category: ['LiteGraph', 'Pointer', 'ClickBufferTime'],
name: 'Pointer click drift delay',
tooltip:
'After pressing a pointer button down, this is the maximum time (in milliseconds) that pointer movement can be ignored for.\n\nHelps prevent objects from being unintentionally nudged if the pointer is moved whilst clicking.',
'After pressing a pointer button down, this is the maximum time (in milliseconds) that pointer movement can be ignored for.\n\nHelps prevent objects from being unintentionally nudged if the pointer is moved whilst clicking.\n\nThe distance threshold (Pointer click drift) already disambiguates clicks from drags; this time threshold only matters when the pointer is held still then released. A long delay here forces every pointerdown to wait before drag begins, which feels laggy when click+dragging an unselected node. ~2 frames at 60fps is plenty.',
experimental: true,
type: 'slider',
attrs: {
min: 0,
max: 1000,
step: 25
step: 1
},
defaultValue: 150,
versionAdded: '1.4.3'
defaultValue: 32,
versionAdded: '1.4.3',
versionModified: '1.44.19'
},
{
id: 'Comfy.Pointer.DoubleClickTime',

View File

@@ -58,7 +58,7 @@
cn(
'pointer-events-none absolute z-0 border-3 outline-none',
selectionShapeClass,
hasAnyError ? '-inset-[7px]' : '-inset-[3px]',
hasAnyError ? 'inset-[-7px]' : 'inset-[-3px]',
isSelected
? 'border-node-component-outline'
: 'border-node-stroke-executing'

View File

@@ -11,7 +11,7 @@ import type {
} from '@/platform/assets/types/filterTypes'
import { cn } from '@comfyorg/tailwind-utils'
import FormSearchInput from '../FormSearchInput.vue'
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
import type { LayoutMode, SortOption } from './types'
const { t } = useI18n()
@@ -99,7 +99,7 @@ function toggleBaseModelSelection(item: FilterOption) {
<template>
<div class="text-secondary flex gap-2 px-4">
<FormSearchInput
<AsyncSearchInput
v-model="searchQuery"
autofocus
:class="

View File

@@ -1,8 +1,7 @@
/**
* Phase 1 dialog migration regression net: when `dialogService.prompt()`,
* `dialogService.confirm()`, or `dialogService.showBillingComingSoonDialog()`
* is invoked, the dialog stack item must carry `renderer: 'reka'`. Catches
* accidental reverts of the Reka renderer flip.
* Dialog migration regression net: when callers in `dialogService` open a
* Reka-migrated dialog, the dialog stack item must carry `renderer: 'reka'`.
* Catches accidental reverts of the Reka renderer flip.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -34,7 +33,7 @@ vi.mock('@/composables/billing/useBillingContext', () => ({
import { useDialogService } from '@/services/dialogService'
describe('dialogService Reka renderer opt-in (Phase 1)', () => {
describe('dialogService Reka renderer opt-in', () => {
beforeEach(() => {
showDialog.mockReset()
})
@@ -60,4 +59,24 @@ describe('dialogService Reka renderer opt-in (Phase 1)', () => {
expect(args.dialogComponentProps.size).toBe('sm')
expect(args.dialogComponentProps.contentClass).toBe('max-w-[360px]')
})
it("showExecutionErrorDialog() sets renderer 'reka' and size 'lg'", () => {
useDialogService().showExecutionErrorDialog({
exception_type: 'RuntimeError',
exception_message: 'boom',
node_id: 1,
node_type: 'KSampler',
traceback: ['line 1', 'line 2']
})
const [args] = showDialog.mock.calls[0]
expect(args.dialogComponentProps.renderer).toBe('reka')
expect(args.dialogComponentProps.size).toBe('lg')
})
it("showErrorDialog() sets renderer 'reka' and size 'lg'", () => {
useDialogService().showErrorDialog(new Error('boom'))
const [args] = showDialog.mock.calls[0]
expect(args.dialogComponentProps.renderer).toBe('reka')
expect(args.dialogComponentProps.size).toBe('lg')
})
})

View File

@@ -99,6 +99,8 @@ export const useDialogService = () => {
component: ErrorDialogContent,
props,
dialogComponentProps: {
renderer: 'reka',
size: 'lg',
onClose: () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'error_dialog_closed'
@@ -163,6 +165,8 @@ export const useDialogService = () => {
component: ErrorDialogContent,
props,
dialogComponentProps: {
renderer: 'reka',
size: 'lg',
onClose: () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'error_dialog_closed'