mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-21 07:16:29 +00:00
Compare commits
1 Commits
refactor/e
...
test/cover
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75860fc322 |
4
.github/workflows/ci-website-e2e.yaml
vendored
4
.github/workflows/ci-website-e2e.yaml
vendored
@@ -2,7 +2,7 @@ name: 'CI: Website E2E'
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches: [main, website/*]
|
||||
paths:
|
||||
- 'apps/website/**'
|
||||
- 'packages/design-system/**'
|
||||
@@ -17,7 +17,7 @@ on:
|
||||
- 'pnpm-lock.yaml'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.repository }}-${{ github.head_ref || github.ref }}
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -4,12 +4,12 @@ const translations = {
|
||||
// HeroSection
|
||||
'hero.title': {
|
||||
en: 'Professional Control\nof Visual AI',
|
||||
'zh-CN': '视觉 AI 的\n最强可控性'
|
||||
'zh-CN': '视觉 AI 的\n专业控制'
|
||||
},
|
||||
'hero.subtitle': {
|
||||
en: 'Comfy is the AI creation engine for visual professionals who demand control over every model, every parameter, and every output.',
|
||||
'zh-CN':
|
||||
'Comfy 是面向专业视觉人士的 AI 创作引擎。您可以精确掌控每个模型、每个参数和每个输出。'
|
||||
'Comfy 是面向视觉专业人士的 AI 创作引擎,让您掌控每个模型、每个参数和每个输出。'
|
||||
},
|
||||
|
||||
// ProductShowcaseSection
|
||||
@@ -20,11 +20,11 @@ const translations = {
|
||||
},
|
||||
'showcase.subtitle2': {
|
||||
en: 'Start from a community template or build from scratch.',
|
||||
'zh-CN': '从工作流模板开始,或从零构建。'
|
||||
'zh-CN': '从社区模板开始,或从零构建。'
|
||||
},
|
||||
'showcase.feature1.title': {
|
||||
en: 'Full Control with Nodes',
|
||||
'zh-CN': '节点带来的可控性'
|
||||
'zh-CN': '节点式完全控制'
|
||||
},
|
||||
'showcase.feature1.description': {
|
||||
en: 'Build powerful AI pipelines by connecting nodes on an infinite canvas. Every model, parameter, and processing step is visible and adjustable.',
|
||||
@@ -49,8 +49,8 @@ const translations = {
|
||||
'zh-CN':
|
||||
'浏览和混搭数千个社区共享的工作流。从经过验证的模板开始,按需自定义。'
|
||||
},
|
||||
'showcase.badgeHow': { en: 'HOW', 'zh-CN': '了解' },
|
||||
'showcase.badgeWorks': { en: 'WORKS', 'zh-CN': '运行方式' },
|
||||
'showcase.badgeHow': { en: 'HOW', 'zh-CN': '如何' },
|
||||
'showcase.badgeWorks': { en: 'WORKS', 'zh-CN': '运作' },
|
||||
|
||||
// UseCaseSection
|
||||
'useCase.label': {
|
||||
@@ -83,7 +83,8 @@ const translations = {
|
||||
},
|
||||
'useCase.body': {
|
||||
en: 'Powered by 60,000+ nodes, thousands of workflows,\nand a community that builds faster than any one company could.',
|
||||
'zh-CN': '60,000+ 节点,数千条工作流,\n一个比任何公司速度都更快的社区。'
|
||||
'zh-CN':
|
||||
'由 60,000+ 节点、数千个工作流\n和一个比任何公司都更快构建的社区驱动。'
|
||||
},
|
||||
'useCase.cta': {
|
||||
en: 'EXPLORE WORKFLOWS',
|
||||
@@ -163,7 +164,7 @@ const translations = {
|
||||
},
|
||||
'products.local.cta': {
|
||||
en: 'SEE LOCAL FEATURES',
|
||||
'zh-CN': '查看本地版属性'
|
||||
'zh-CN': '查看本地版特性'
|
||||
},
|
||||
'products.cloud.title': {
|
||||
en: 'Comfy\nCloud',
|
||||
@@ -175,7 +176,7 @@ const translations = {
|
||||
},
|
||||
'products.cloud.cta': {
|
||||
en: 'SEE CLOUD FEATURES',
|
||||
'zh-CN': '查看云端属性'
|
||||
'zh-CN': '查看云端特性'
|
||||
},
|
||||
'products.api.title': {
|
||||
en: 'Comfy\nAPI',
|
||||
@@ -187,7 +188,7 @@ const translations = {
|
||||
},
|
||||
'products.api.cta': {
|
||||
en: 'SEE API FEATURES',
|
||||
'zh-CN': '查看 API 属性'
|
||||
'zh-CN': '查看 API 特性'
|
||||
},
|
||||
'products.enterprise.title': {
|
||||
en: 'Comfy\nEnterprise',
|
||||
@@ -199,7 +200,7 @@ const translations = {
|
||||
},
|
||||
'products.enterprise.cta': {
|
||||
en: 'SEE ENTERPRISE FEATURES',
|
||||
'zh-CN': '查看企业版属性'
|
||||
'zh-CN': '查看企业版特性'
|
||||
},
|
||||
|
||||
// CaseStudySpotlightSection
|
||||
@@ -1214,7 +1215,7 @@ const translations = {
|
||||
'pricing.included.feature4.description': {
|
||||
en: 'All plans will include a monthly pool of credits that are spent on active workflow runtime and <a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">Partner Nodes</a> like Nano Banana Pro.',
|
||||
'zh-CN':
|
||||
'所有计划均包含每月积分池,可用于工作流运行和<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">合作伙伴节点</a>(如 Nano Banana Pro)。'
|
||||
'所有计划均包含每月积分池,可用于工作流运行和<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">合作节点</a>(如 Nano Banana Pro)。'
|
||||
},
|
||||
'pricing.included.feature5.title': {
|
||||
en: 'Add more credits anytime',
|
||||
@@ -1244,12 +1245,12 @@ const translations = {
|
||||
},
|
||||
'pricing.included.feature8.title': {
|
||||
en: 'Partner Nodes',
|
||||
'zh-CN': '合作伙伴节点'
|
||||
'zh-CN': '合作节点'
|
||||
},
|
||||
'pricing.included.feature8.description': {
|
||||
en: 'Run <strong>proprietary models</strong> through Comfy\'s <a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">Partner Nodes</a>, such as Nano Banana. The amount of credits each node uses depends on the model and parameters you set in the node, but these credits are the same ones that your monthly subscription comes with. These credits can also be used across Comfy Cloud and local ComfyUI. Read more about Partner nodes <a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">here</a>.',
|
||||
'zh-CN':
|
||||
'通过 Comfy 的<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">合作伙伴节点</a>运行<strong>专有模型</strong>,如 Nano Banana。每个节点消耗的积分取决于所用模型和参数设置,且与月度订阅积分通用。积分可在 Comfy Cloud 和本地 ComfyUI 间通用。了解更多关于合作伙伴节点的信息请点击<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">此处</a>。'
|
||||
'通过 Comfy 的<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">合作节点</a>运行<strong>专有模型</strong>,如 Nano Banana。每个节点消耗的积分取决于所用模型和参数设置,且与月度订阅积分通用。积分可在 Comfy Cloud 和本地 ComfyUI 间通用。了解更多关于合作节点的信息请点击<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">此处</a>。'
|
||||
},
|
||||
'pricing.included.feature9.title': {
|
||||
en: 'Job queue',
|
||||
|
||||
@@ -10,7 +10,7 @@ import GetStartedSection from '../../components/home/GetStartedSection.vue'
|
||||
import BuildWhatSection from '../../components/home/BuildWhatSection.vue'
|
||||
---
|
||||
|
||||
<BaseLayout title="Comfy — 视觉 AI 的最强可控性">
|
||||
<BaseLayout title="Comfy — 视觉 AI 的专业控制">
|
||||
<HeroSection locale="zh-CN" client:load />
|
||||
<SocialProofBarSection />
|
||||
<ProductShowcaseSection locale="zh-CN" client:load />
|
||||
|
||||
@@ -51,7 +51,6 @@ DISABLE_VUE_PLUGINS=true
|
||||
# Test against dev server (recommended) or backend directly
|
||||
PLAYWRIGHT_TEST_URL=http://localhost:5173 # Dev server
|
||||
# PLAYWRIGHT_TEST_URL=http://localhost:8188 # Direct backend
|
||||
PLAYWRIGHT_SETUP_API_URL=http://localhost:8188 # Setup/auth API when using the dev server URL above
|
||||
|
||||
# Path to ComfyUI for backing up user data/settings before tests
|
||||
TEST_COMFYUI_DIR=/path/to/your/ComfyUI
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
{
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 1,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 2,
|
||||
"type": "LoadImage",
|
||||
"pos": [50, 50],
|
||||
"size": [400, 314],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [1]
|
||||
},
|
||||
{
|
||||
"name": "MASK",
|
||||
"type": "MASK",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "LoadImage"
|
||||
},
|
||||
"widgets_values": ["example.png", "image"]
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"type": "Painter",
|
||||
"pos": [450, 50],
|
||||
"size": [450, 550],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"link": 1
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"name": "MASK",
|
||||
"type": "MASK",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "Painter"
|
||||
},
|
||||
"widgets_values": ["", 512, 512, "#000000"]
|
||||
}
|
||||
],
|
||||
"links": [[1, 2, 0, 1, 0, "IMAGE"]],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"offset": [0, 0],
|
||||
"scale": 1
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -137,7 +137,6 @@ class ComfyMenu {
|
||||
|
||||
export class ComfyPage {
|
||||
public readonly url: string
|
||||
public readonly apiUrl: string
|
||||
// All canvas position operations are based on default view of canvas.
|
||||
public readonly canvas: Locator
|
||||
public readonly selectionToolbox: Locator
|
||||
@@ -196,7 +195,6 @@ export class ComfyPage {
|
||||
public readonly request: APIRequestContext
|
||||
) {
|
||||
this.url = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
|
||||
this.apiUrl = process.env.PLAYWRIGHT_SETUP_API_URL || this.url
|
||||
this.canvas = page.locator('#graph-canvas')
|
||||
this.selectionToolbox = page.getByTestId(TestIds.selectionToolbox.root)
|
||||
this.widgetTextBox = page.getByPlaceholder('text').nth(1)
|
||||
@@ -238,7 +236,7 @@ export class ComfyPage {
|
||||
}
|
||||
|
||||
async setupUser(username: string) {
|
||||
const res = await this.request.get(`${this.apiUrl}/api/users`)
|
||||
const res = await this.request.get(`${this.url}/api/users`)
|
||||
if (res.status() !== 200)
|
||||
throw new Error(`Failed to retrieve users: ${await res.text()}`)
|
||||
|
||||
@@ -252,7 +250,7 @@ export class ComfyPage {
|
||||
}
|
||||
|
||||
async createUser(username: string) {
|
||||
const resp = await this.request.post(`${this.apiUrl}/api/users`, {
|
||||
const resp = await this.request.post(`${this.url}/api/users`, {
|
||||
data: { username }
|
||||
})
|
||||
|
||||
@@ -264,7 +262,7 @@ export class ComfyPage {
|
||||
|
||||
async setupSettings(settings: Record<string, unknown>) {
|
||||
const resp = await this.request.post(
|
||||
`${this.apiUrl}/api/devtools/set_settings`,
|
||||
`${this.url}/api/devtools/set_settings`,
|
||||
{
|
||||
data: settings
|
||||
}
|
||||
|
||||
@@ -5,14 +5,12 @@ import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
export class ComfyNodeSearchBoxV2 {
|
||||
readonly dialog: Locator
|
||||
readonly input: Locator
|
||||
readonly filterSearch: Locator
|
||||
readonly results: Locator
|
||||
readonly filterOptions: Locator
|
||||
|
||||
constructor(readonly page: Page) {
|
||||
this.dialog = page.getByRole('search')
|
||||
this.input = this.dialog.getByRole('combobox')
|
||||
this.filterSearch = this.dialog.getByRole('textbox', { name: 'Search' })
|
||||
this.input = this.dialog.locator('input[type="text"]')
|
||||
this.results = this.dialog.getByTestId('result-item')
|
||||
this.filterOptions = this.dialog.getByTestId('filter-option')
|
||||
}
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import { BaseDialog } from '@e2e/fixtures/components/BaseDialog'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
export class PublishDialog extends BaseDialog {
|
||||
readonly nav: Locator
|
||||
readonly footer: Locator
|
||||
readonly savePrompt: Locator
|
||||
readonly describeStep: Locator
|
||||
readonly finishStep: Locator
|
||||
readonly profilePrompt: Locator
|
||||
readonly gateFlow: Locator
|
||||
readonly nameInput: Locator
|
||||
readonly descriptionTextarea: Locator
|
||||
readonly tagsInput: Locator
|
||||
readonly backButton: Locator
|
||||
readonly nextButton: Locator
|
||||
readonly publishButton: Locator
|
||||
|
||||
constructor(page: Page) {
|
||||
super(page, TestIds.publish.dialog)
|
||||
this.nav = this.root.getByTestId(TestIds.publish.nav)
|
||||
this.footer = this.root.getByTestId(TestIds.publish.footer)
|
||||
this.savePrompt = this.root.getByTestId(TestIds.publish.savePrompt)
|
||||
this.describeStep = this.root.getByTestId(TestIds.publish.describeStep)
|
||||
this.finishStep = this.root.getByTestId(TestIds.publish.finishStep)
|
||||
this.profilePrompt = this.root.getByTestId(TestIds.publish.profilePrompt)
|
||||
this.gateFlow = this.root.getByTestId(TestIds.publish.gateFlow)
|
||||
this.nameInput = this.root.getByTestId(TestIds.publish.nameInput)
|
||||
this.descriptionTextarea = this.describeStep.locator('textarea')
|
||||
this.tagsInput = this.root.getByTestId(TestIds.publish.tagsInput)
|
||||
this.backButton = this.footer.getByRole('button', { name: 'Back' })
|
||||
this.nextButton = this.footer.getByRole('button', { name: 'Next' })
|
||||
this.publishButton = this.footer.getByRole('button', {
|
||||
name: 'Publish to ComfyHub'
|
||||
})
|
||||
}
|
||||
|
||||
// Uses showPublishDialog() via Vite-bundled lazy imports that work in both
|
||||
// dev and production, rather than clicking through the UI.
|
||||
async open(): Promise<void> {
|
||||
await this.page.evaluate(async () => {
|
||||
await window.app!.extensionManager.dialog.showPublishDialog()
|
||||
})
|
||||
await this.waitForVisible()
|
||||
}
|
||||
|
||||
tagSuggestion(name: string): Locator {
|
||||
return this.describeStep.getByText(name, { exact: true })
|
||||
}
|
||||
|
||||
navStep(label: string): Locator {
|
||||
return this.nav.getByRole('button', { name: label })
|
||||
}
|
||||
|
||||
currentNavStep(): Locator {
|
||||
return this.nav.locator('[aria-current="step"]')
|
||||
}
|
||||
|
||||
async goNext(): Promise<void> {
|
||||
await this.nextButton.click()
|
||||
}
|
||||
|
||||
async goBack(): Promise<void> {
|
||||
await this.backButton.click()
|
||||
}
|
||||
|
||||
async goToStep(label: string): Promise<void> {
|
||||
await this.navStep(label).click()
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,9 @@
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
import type {
|
||||
CreateAssetExportData,
|
||||
CreateAssetExportResponse,
|
||||
JobsListResponse,
|
||||
ListAssetsResponse
|
||||
} from '@comfyorg/ingest-types'
|
||||
import type { JobsListResponse } from '@comfyorg/ingest-types'
|
||||
|
||||
import type {
|
||||
JobDetail,
|
||||
RawJobListItem
|
||||
} from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
|
||||
const jobsListRoutePattern = '**/api/jobs?*'
|
||||
const assetsListRoutePattern = /\/api\/assets(?:\?.*)?$/
|
||||
const assetExportRoutePattern = '**/api/assets/export'
|
||||
const jobsListRoutePattern = /\/api\/jobs(?:\?.*)?$/
|
||||
const inputFilesRoutePattern = /\/internal\/files\/input(?:\?.*)?$/
|
||||
const historyRoutePattern = /\/api\/history$/
|
||||
|
||||
@@ -168,23 +158,12 @@ function getExecutionDuration(job: RawJobListItem): number {
|
||||
|
||||
export class AssetsHelper {
|
||||
private jobsRouteHandler: ((route: Route) => Promise<void>) | null = null
|
||||
private cloudAssetsRouteHandler: ((route: Route) => Promise<void>) | null =
|
||||
null
|
||||
private assetExportRouteHandler: ((route: Route) => Promise<void>) | null =
|
||||
null
|
||||
private inputFilesRouteHandler: ((route: Route) => Promise<void>) | null =
|
||||
null
|
||||
private deleteHistoryRouteHandler: ((route: Route) => Promise<void>) | null =
|
||||
null
|
||||
private generatedJobs: RawJobListItem[] = []
|
||||
private cloudAssetsResponse: ListAssetsResponse | null = null
|
||||
private assetExportRequests: CreateAssetExportData['body'][] = []
|
||||
private assetExportResponse: CreateAssetExportResponse | null = null
|
||||
private importedFiles: string[] = []
|
||||
private readonly jobDetailRouteHandlers = new Map<
|
||||
string,
|
||||
(route: Route) => Promise<void>
|
||||
>()
|
||||
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
@@ -261,82 +240,6 @@ export class AssetsHelper {
|
||||
await this.page.route(jobsListRoutePattern, this.jobsRouteHandler)
|
||||
}
|
||||
|
||||
async mockCloudAssets(response: ListAssetsResponse): Promise<void> {
|
||||
this.cloudAssetsResponse = response
|
||||
|
||||
if (this.cloudAssetsRouteHandler) {
|
||||
return
|
||||
}
|
||||
|
||||
this.cloudAssetsRouteHandler = async (route: Route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(this.cloudAssetsResponse)
|
||||
})
|
||||
}
|
||||
|
||||
await this.page.route(assetsListRoutePattern, this.cloudAssetsRouteHandler)
|
||||
}
|
||||
|
||||
async mockEmptyCloudAssets(): Promise<void> {
|
||||
await this.mockCloudAssets({
|
||||
assets: [],
|
||||
total: 0,
|
||||
has_more: false
|
||||
})
|
||||
}
|
||||
|
||||
async captureAssetExportRequests(
|
||||
response: CreateAssetExportResponse = {
|
||||
task_id: 'asset-export-task',
|
||||
status: 'created'
|
||||
}
|
||||
): Promise<CreateAssetExportData['body'][]> {
|
||||
this.assetExportRequests = []
|
||||
this.assetExportResponse = response
|
||||
|
||||
if (this.assetExportRouteHandler) {
|
||||
return this.assetExportRequests
|
||||
}
|
||||
|
||||
this.assetExportRouteHandler = async (route: Route) => {
|
||||
this.assetExportRequests.push(
|
||||
route.request().postDataJSON() as CreateAssetExportData['body']
|
||||
)
|
||||
|
||||
await route.fulfill({
|
||||
status: 202,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(this.assetExportResponse)
|
||||
})
|
||||
}
|
||||
|
||||
await this.page.route(assetExportRoutePattern, this.assetExportRouteHandler)
|
||||
|
||||
return this.assetExportRequests
|
||||
}
|
||||
|
||||
async mockJobDetail(jobId: string, detail: JobDetail): Promise<void> {
|
||||
const pattern = `**/api/jobs/${encodeURIComponent(jobId)}`
|
||||
const existingHandler = this.jobDetailRouteHandlers.get(pattern)
|
||||
|
||||
if (existingHandler) {
|
||||
await this.page.unroute(pattern, existingHandler)
|
||||
}
|
||||
|
||||
const handler = async (route: Route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(detail)
|
||||
})
|
||||
}
|
||||
|
||||
this.jobDetailRouteHandlers.set(pattern, handler)
|
||||
await this.page.route(pattern, handler)
|
||||
}
|
||||
|
||||
async mockInputFiles(files: string[]): Promise<void> {
|
||||
this.importedFiles = [...files]
|
||||
|
||||
@@ -392,9 +295,6 @@ export class AssetsHelper {
|
||||
|
||||
async clearMocks(): Promise<void> {
|
||||
this.generatedJobs = []
|
||||
this.cloudAssetsResponse = null
|
||||
this.assetExportRequests = []
|
||||
this.assetExportResponse = null
|
||||
this.importedFiles = []
|
||||
|
||||
if (this.jobsRouteHandler) {
|
||||
@@ -402,22 +302,6 @@ export class AssetsHelper {
|
||||
this.jobsRouteHandler = null
|
||||
}
|
||||
|
||||
if (this.cloudAssetsRouteHandler) {
|
||||
await this.page.unroute(
|
||||
assetsListRoutePattern,
|
||||
this.cloudAssetsRouteHandler
|
||||
)
|
||||
this.cloudAssetsRouteHandler = null
|
||||
}
|
||||
|
||||
if (this.assetExportRouteHandler) {
|
||||
await this.page.unroute(
|
||||
assetExportRoutePattern,
|
||||
this.assetExportRouteHandler
|
||||
)
|
||||
this.assetExportRouteHandler = null
|
||||
}
|
||||
|
||||
if (this.inputFilesRouteHandler) {
|
||||
await this.page.unroute(
|
||||
inputFilesRoutePattern,
|
||||
@@ -433,10 +317,5 @@ export class AssetsHelper {
|
||||
)
|
||||
this.deleteHistoryRouteHandler = null
|
||||
}
|
||||
|
||||
for (const [pattern, handler] of this.jobDetailRouteHandlers) {
|
||||
await this.page.unroute(pattern, handler)
|
||||
}
|
||||
this.jobDetailRouteHandlers.clear()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,11 +13,7 @@ import type { Page } from '@playwright/test'
|
||||
* so the SDK believes a user is signed in. Must be called before navigation.
|
||||
*/
|
||||
export class CloudAuthHelper {
|
||||
private readonly appUrl: string
|
||||
|
||||
constructor(private readonly page: Page) {
|
||||
this.appUrl = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
|
||||
}
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
/**
|
||||
* Set up all auth mocks. Must be called before `comfyPage.setup()`.
|
||||
@@ -38,7 +34,7 @@ export class CloudAuthHelper {
|
||||
*/
|
||||
private async seedFirebaseIndexedDB(): Promise<void> {
|
||||
// Navigate to a lightweight endpoint to get a same-origin context
|
||||
await this.page.goto(`${this.appUrl}/api/users`)
|
||||
await this.page.goto('http://localhost:8188/api/users')
|
||||
|
||||
await this.page.evaluate(() => {
|
||||
const MOCK_USER_DATA = {
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import type { Locator } from '@playwright/test'
|
||||
|
||||
import type {
|
||||
GraphAddOptions,
|
||||
LGraph,
|
||||
LGraphNode
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type {
|
||||
ComfyWorkflowJSON,
|
||||
NodeId
|
||||
@@ -43,45 +39,6 @@ export class NodeOperationsHelper {
|
||||
})
|
||||
}
|
||||
|
||||
async getSelectedNodeIds(): Promise<NodeId[]> {
|
||||
return await this.page.evaluate(() => {
|
||||
const selected = window.app?.canvas?.selected_nodes
|
||||
if (!selected) return []
|
||||
return Object.keys(selected).map(Number)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a node to the graph by type.
|
||||
* @param type - The node type (e.g. 'KSampler', 'VAEDecode')
|
||||
* @param options - GraphAddOptions (ghost, skipComputeOrder). When ghost is
|
||||
* true and cursorPosition is provided, a synthetic MouseEvent is created
|
||||
* as the dragEvent.
|
||||
* @param cursorPosition - Client coordinates for ghost placement dragEvent
|
||||
*/
|
||||
async addNode(
|
||||
type: string,
|
||||
options?: Omit<GraphAddOptions, 'dragEvent'>,
|
||||
cursorPosition?: Position
|
||||
): Promise<NodeReference> {
|
||||
const id = await this.page.evaluate(
|
||||
([nodeType, opts, cursor]) => {
|
||||
const node = window.LiteGraph!.createNode(nodeType)!
|
||||
const addOpts: Record<string, unknown> = { ...opts }
|
||||
if (opts?.ghost && cursor) {
|
||||
addOpts.dragEvent = new MouseEvent('click', {
|
||||
clientX: cursor.x,
|
||||
clientY: cursor.y
|
||||
})
|
||||
}
|
||||
window.app!.graph.add(node, addOpts as GraphAddOptions)
|
||||
return node.id
|
||||
},
|
||||
[type, options ?? {}, cursorPosition ?? null] as const
|
||||
)
|
||||
return new NodeReference(id, this.comfyPage)
|
||||
}
|
||||
|
||||
/** Remove all nodes from the graph and clean. */
|
||||
async clearGraph() {
|
||||
await this.comfyPage.settings.setSetting('Comfy.ConfirmClear', false)
|
||||
|
||||
@@ -1,231 +0,0 @@
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
|
||||
import type {
|
||||
AssetInfo,
|
||||
HubAssetUploadUrlResponse,
|
||||
HubLabelInfo,
|
||||
HubLabelListResponse,
|
||||
HubProfile,
|
||||
WorkflowPublishInfo
|
||||
} from '@comfyorg/ingest-types'
|
||||
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import { PublishDialog } from '@e2e/fixtures/components/PublishDialog'
|
||||
|
||||
import type { ShareableAssetsResponse } from '@/schemas/apiSchema'
|
||||
|
||||
const DEFAULT_PROFILE: HubProfile = {
|
||||
username: 'testuser',
|
||||
display_name: 'Test User',
|
||||
description: 'A test creator',
|
||||
avatar_url: undefined
|
||||
}
|
||||
|
||||
const DEFAULT_TAG_LABELS: HubLabelInfo[] = [
|
||||
{ name: 'anime', display_name: 'anime', type: 'tag' },
|
||||
{ name: 'upscale', display_name: 'upscale', type: 'tag' },
|
||||
{ name: 'faceswap', display_name: 'faceswap', type: 'tag' },
|
||||
{ name: 'img2img', display_name: 'img2img', type: 'tag' },
|
||||
{ name: 'controlnet', display_name: 'controlnet', type: 'tag' }
|
||||
]
|
||||
|
||||
const DEFAULT_PUBLISH_RESPONSE: WorkflowPublishInfo = {
|
||||
workflow_id: 'test-workflow-id-456',
|
||||
share_id: 'test-share-id-123',
|
||||
publish_time: new Date().toISOString(),
|
||||
listed: true,
|
||||
assets: []
|
||||
}
|
||||
|
||||
const DEFAULT_UPLOAD_URL_RESPONSE: HubAssetUploadUrlResponse = {
|
||||
upload_url: 'https://mock-s3.example.com/upload',
|
||||
public_url: 'https://mock-s3.example.com/asset.png',
|
||||
token: 'mock-upload-token'
|
||||
}
|
||||
|
||||
export class PublishApiHelper {
|
||||
private routeHandlers: Array<{
|
||||
pattern: string
|
||||
handler: (route: Route) => Promise<void>
|
||||
}> = []
|
||||
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
async mockProfile(profile: HubProfile | null): Promise<void> {
|
||||
await this.addRoute('**/hub/profiles/me', async (route) => {
|
||||
if (route.request().method() !== 'GET') {
|
||||
await route.continue()
|
||||
return
|
||||
}
|
||||
if (profile === null) {
|
||||
await route.fulfill({ status: 404, body: 'Not found' })
|
||||
} else {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(profile)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async mockTagLabels(
|
||||
labels: HubLabelInfo[] = DEFAULT_TAG_LABELS
|
||||
): Promise<void> {
|
||||
const response: HubLabelListResponse = { labels }
|
||||
await this.addRoute('**/hub/labels**', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(response)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async mockPublishStatus(
|
||||
status: 'unpublished' | WorkflowPublishInfo
|
||||
): Promise<void> {
|
||||
await this.addRoute('**/userdata/*/publish', async (route) => {
|
||||
if (route.request().method() !== 'GET') {
|
||||
await route.continue()
|
||||
return
|
||||
}
|
||||
if (status === 'unpublished') {
|
||||
await route.fulfill({ status: 404, body: 'Not found' })
|
||||
} else {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(status)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async mockShareableAssets(assets: AssetInfo[] = []): Promise<void> {
|
||||
const response: ShareableAssetsResponse = { assets }
|
||||
await this.addRoute('**/assets/from-workflow', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(response)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async mockPublishWorkflow(
|
||||
response: WorkflowPublishInfo = DEFAULT_PUBLISH_RESPONSE
|
||||
): Promise<void> {
|
||||
await this.removeRoutes('**/hub/workflows')
|
||||
await this.addRoute('**/hub/workflows', async (route) => {
|
||||
if (route.request().method() !== 'POST') {
|
||||
await route.continue()
|
||||
return
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(response)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async mockPublishWorkflowError(
|
||||
statusCode = 500,
|
||||
message = 'Failed to publish workflow'
|
||||
): Promise<void> {
|
||||
await this.removeRoutes('**/hub/workflows')
|
||||
await this.addRoute('**/hub/workflows', async (route) => {
|
||||
if (route.request().method() !== 'POST') {
|
||||
await route.continue()
|
||||
return
|
||||
}
|
||||
await route.fulfill({
|
||||
status: statusCode,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ message })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async mockUploadUrl(
|
||||
response: HubAssetUploadUrlResponse = DEFAULT_UPLOAD_URL_RESPONSE
|
||||
): Promise<void> {
|
||||
await this.addRoute('**/hub/assets/upload-url', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(response)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async setupDefaultMocks(options?: {
|
||||
hasProfile?: boolean
|
||||
hasPrivateAssets?: boolean
|
||||
}): Promise<void> {
|
||||
const { hasProfile = true, hasPrivateAssets = false } = options ?? {}
|
||||
|
||||
await this.mockProfile(hasProfile ? DEFAULT_PROFILE : null)
|
||||
await this.mockTagLabels()
|
||||
await this.mockPublishStatus('unpublished')
|
||||
await this.mockShareableAssets(
|
||||
hasPrivateAssets
|
||||
? [
|
||||
{
|
||||
id: 'asset-1',
|
||||
name: 'my_model.safetensors',
|
||||
preview_url: '',
|
||||
storage_url: '',
|
||||
model: true,
|
||||
public: false,
|
||||
in_library: true
|
||||
}
|
||||
]
|
||||
: []
|
||||
)
|
||||
await this.mockPublishWorkflow()
|
||||
await this.mockUploadUrl()
|
||||
}
|
||||
|
||||
async cleanup(): Promise<void> {
|
||||
for (const { pattern, handler } of this.routeHandlers) {
|
||||
await this.page.unroute(pattern, handler)
|
||||
}
|
||||
this.routeHandlers = []
|
||||
}
|
||||
|
||||
private async addRoute(
|
||||
pattern: string,
|
||||
handler: (route: Route) => Promise<void>
|
||||
): Promise<void> {
|
||||
this.routeHandlers.push({ pattern, handler })
|
||||
await this.page.route(pattern, handler)
|
||||
}
|
||||
|
||||
private async removeRoutes(pattern: string): Promise<void> {
|
||||
const handlers = this.routeHandlers.filter(
|
||||
(route) => route.pattern === pattern
|
||||
)
|
||||
for (const { handler } of handlers) {
|
||||
await this.page.unroute(pattern, handler)
|
||||
}
|
||||
this.routeHandlers = this.routeHandlers.filter(
|
||||
(route) => route.pattern !== pattern
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export const publishFixture = comfyPageFixture.extend<{
|
||||
publishApi: PublishApiHelper
|
||||
publishDialog: PublishDialog
|
||||
}>({
|
||||
publishApi: async ({ comfyPage }, use) => {
|
||||
const helper = new PublishApiHelper(comfyPage.page)
|
||||
await use(helper)
|
||||
await helper.cleanup()
|
||||
},
|
||||
publishDialog: async ({ comfyPage }, use) => {
|
||||
await use(new PublishDialog(comfyPage.page))
|
||||
}
|
||||
})
|
||||
@@ -59,9 +59,6 @@ export const TestIds = {
|
||||
missingModelCopyName: 'missing-model-copy-name',
|
||||
missingModelCopyUrl: 'missing-model-copy-url',
|
||||
missingModelDownload: 'missing-model-download',
|
||||
missingModelActions: 'missing-model-actions',
|
||||
missingModelDownloadAll: 'missing-model-download-all',
|
||||
missingModelRefresh: 'missing-model-refresh',
|
||||
missingModelImportUnsupported: 'missing-model-import-unsupported',
|
||||
missingMediaGroup: 'error-group-missing-media',
|
||||
missingMediaRow: 'missing-media-row',
|
||||
@@ -212,18 +209,6 @@ export const TestIds = {
|
||||
imageLoadError: 'error-loading-image',
|
||||
videoLoadError: 'error-loading-video'
|
||||
},
|
||||
publish: {
|
||||
dialog: 'publish-dialog',
|
||||
savePrompt: 'publish-save-prompt',
|
||||
describeStep: 'publish-describe-step',
|
||||
finishStep: 'publish-finish-step',
|
||||
footer: 'publish-footer',
|
||||
profilePrompt: 'publish-profile-prompt',
|
||||
nav: 'publish-nav',
|
||||
gateFlow: 'publish-gate-flow',
|
||||
nameInput: 'publish-name-input',
|
||||
tagsInput: 'publish-tags-input'
|
||||
},
|
||||
loading: {
|
||||
overlay: 'loading-overlay'
|
||||
},
|
||||
|
||||
@@ -56,13 +56,7 @@ export function writePerfReport(
|
||||
gitSha = process.env.GITHUB_SHA ?? 'local',
|
||||
branch = process.env.GITHUB_HEAD_REF ?? 'local'
|
||||
) {
|
||||
let entries
|
||||
try {
|
||||
entries = readdirSync('test-results', { withFileTypes: true })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (!entries.length) return
|
||||
if (!readdirSync('test-results', { withFileTypes: true }).length) return
|
||||
|
||||
let tempFiles: string[]
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
export type PromotedWidgetEntry = [string, string]
|
||||
type PromotedWidgetEntry = [string, string]
|
||||
|
||||
function isPromotedWidgetEntry(entry: unknown): entry is PromotedWidgetEntry {
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Route } from '@playwright/test'
|
||||
|
||||
import type { Asset, ListAssetsResponse } from '@comfyorg/ingest-types'
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
@@ -13,35 +12,25 @@ function makeAssetsResponse(assets: Asset[]): ListAssetsResponse {
|
||||
}
|
||||
|
||||
const CLOUD_ASSETS: Asset[] = [STABLE_CHECKPOINT, STABLE_CHECKPOINT_2]
|
||||
const WAITING_FOR_WIDGET_TYPE = 'waiting:type'
|
||||
const WAITING_FOR_WIDGET_VALUE = 'waiting:value'
|
||||
|
||||
// Stub /api/assets before the app loads. The local ComfyUI backend has no
|
||||
// /api/assets endpoint (returns 503), which poisons the assets store on
|
||||
// first load. Narrow pattern avoids intercepting static /assets/*.js bundles.
|
||||
//
|
||||
// TODO: Consider moving this stub into ComfyPage fixture for all @cloud tests.
|
||||
const test = comfyPageFixture.extend<{
|
||||
cloudAssetRequests: string[]
|
||||
stubCloudAssets: void
|
||||
}>({
|
||||
cloudAssetRequests: async ({ page: _page }, use) => {
|
||||
await use([])
|
||||
},
|
||||
const test = comfyPageFixture.extend<{ stubCloudAssets: void }>({
|
||||
stubCloudAssets: [
|
||||
async ({ cloudAssetRequests, page }, use) => {
|
||||
const pattern = /\/api\/assets(?:\?.*)?$/
|
||||
const assetsRouteHandler = (route: Route) => {
|
||||
cloudAssetRequests.push(route.request().url())
|
||||
return route.fulfill({
|
||||
async ({ page }, use) => {
|
||||
const pattern = '**/api/assets?*'
|
||||
await page.route(pattern, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(makeAssetsResponse(CLOUD_ASSETS))
|
||||
})
|
||||
}
|
||||
await page.route(pattern, assetsRouteHandler)
|
||||
)
|
||||
await use()
|
||||
await page.unroute(pattern, assetsRouteHandler)
|
||||
await page.unroute(pattern)
|
||||
},
|
||||
{ auto: true }
|
||||
]
|
||||
@@ -53,36 +42,23 @@ test.describe('Asset-supported node default value', { tag: '@cloud' }, () => {
|
||||
})
|
||||
|
||||
test('should use first cloud asset when server default is not in assets', async ({
|
||||
cloudAssetRequests,
|
||||
comfyPage
|
||||
}) => {
|
||||
// Wait for the checkpoint asset query to complete and the existing widget
|
||||
// to upgrade into asset mode before creating a fresh node. The current
|
||||
// default node may keep a previously resolved value; what matters is that
|
||||
// new nodes resolve against the cloud asset list after the fetch.
|
||||
await expect
|
||||
.poll(() =>
|
||||
cloudAssetRequests.some((url) => {
|
||||
const includeTags =
|
||||
new URL(url).searchParams.get('include_tags') ?? ''
|
||||
return includeTags.split(',').includes('checkpoints')
|
||||
})
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
// The default workflow contains a CheckpointLoaderSimple node whose
|
||||
// server default (from object_info) is a local file not in cloud assets.
|
||||
// Wait for the existing node's asset widget to mount, confirming the
|
||||
// assets store has been populated from the stub before adding a new node.
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
comfyPage.page.evaluate((waitingForWidgetType) => {
|
||||
comfyPage.page.evaluate(() => {
|
||||
const node = window.app!.graph.nodes.find(
|
||||
(n: { type: string }) => n.type === 'CheckpointLoaderSimple'
|
||||
)
|
||||
return (
|
||||
node?.widgets?.find(
|
||||
(w: { name: string }) => w.name === 'ckpt_name'
|
||||
)?.type ?? waitingForWidgetType
|
||||
)
|
||||
}, WAITING_FOR_WIDGET_TYPE),
|
||||
return node?.widgets?.find(
|
||||
(w: { name: string }) => w.name === 'ckpt_name'
|
||||
)?.type
|
||||
}),
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
.toBe('asset')
|
||||
@@ -105,22 +81,15 @@ test.describe('Asset-supported node default value', { tag: '@cloud' }, () => {
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
comfyPage.page.evaluate(
|
||||
({ id, waitingForWidgetType, waitingForWidgetValue }) => {
|
||||
const node = window.app!.graph.getNodeById(id)
|
||||
const widget = node?.widgets?.find(
|
||||
(w: { name: string }) => w.name === 'ckpt_name'
|
||||
)
|
||||
if (widget?.type !== 'asset') return waitingForWidgetType
|
||||
const val = String(widget?.value ?? '')
|
||||
return val === 'Select model' ? waitingForWidgetValue : val
|
||||
},
|
||||
{
|
||||
id: nodeId,
|
||||
waitingForWidgetType: WAITING_FOR_WIDGET_TYPE,
|
||||
waitingForWidgetValue: WAITING_FOR_WIDGET_VALUE
|
||||
}
|
||||
),
|
||||
comfyPage.page.evaluate((id) => {
|
||||
const node = window.app!.graph.getNodeById(id)
|
||||
const widget = node?.widgets?.find(
|
||||
(w: { name: string }) => w.name === 'ckpt_name'
|
||||
)
|
||||
if (widget?.type !== 'asset') return 'waiting:type'
|
||||
const val = String(widget?.value ?? '')
|
||||
return val === 'Select model' ? 'waiting:value' : val
|
||||
}, nodeId),
|
||||
{ timeout: 15_000 }
|
||||
)
|
||||
.toBe(CLOUD_ASSETS[0].name)
|
||||
|
||||
@@ -1,306 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import type { PublishDialog } from '@e2e/fixtures/components/PublishDialog'
|
||||
|
||||
import { publishFixture as test } from '@e2e/fixtures/helpers/PublishApiHelper'
|
||||
|
||||
const PUBLISH_FEATURE_FLAGS = {
|
||||
comfyhub_upload_enabled: true,
|
||||
comfyhub_profile_gate_enabled: true
|
||||
} as const
|
||||
|
||||
async function saveAndOpenPublishDialog(
|
||||
comfyPage: ComfyPage,
|
||||
dialog: PublishDialog,
|
||||
workflowName: string
|
||||
): Promise<void> {
|
||||
await comfyPage.menu.topbar.saveWorkflow(workflowName)
|
||||
const overwriteDialog = comfyPage.page.locator(
|
||||
'.p-dialog:has-text("Overwrite")'
|
||||
)
|
||||
// Bounded wait: point-in-time isVisible() can miss dialogs that open
|
||||
// slightly after saveWorkflow() resolves.
|
||||
try {
|
||||
await overwriteDialog.waitFor({ state: 'visible', timeout: 500 })
|
||||
await comfyPage.confirmDialog.click('overwrite')
|
||||
} catch {
|
||||
// No overwrite dialog — workflow name was unique.
|
||||
}
|
||||
|
||||
await dialog.open()
|
||||
}
|
||||
|
||||
test.describe('Publish dialog - wizard navigation', () => {
|
||||
test.beforeEach(async ({ comfyPage, publishApi, publishDialog }) => {
|
||||
await comfyPage.featureFlags.setFlags(PUBLISH_FEATURE_FLAGS)
|
||||
await publishApi.setupDefaultMocks()
|
||||
await saveAndOpenPublishDialog(comfyPage, publishDialog, 'test-publish-wf')
|
||||
})
|
||||
|
||||
test('opens on the Describe step by default', async ({ publishDialog }) => {
|
||||
await expect(publishDialog.describeStep).toBeVisible()
|
||||
await expect(publishDialog.nameInput).toBeVisible()
|
||||
await expect(publishDialog.descriptionTextarea).toBeVisible()
|
||||
})
|
||||
|
||||
test('pre-fills workflow name from active workflow', async ({
|
||||
publishDialog
|
||||
}) => {
|
||||
await expect(publishDialog.nameInput).toHaveValue(/test-publish-wf/)
|
||||
})
|
||||
|
||||
test('Next button navigates to Examples step', async ({ publishDialog }) => {
|
||||
await publishDialog.goNext()
|
||||
await expect(publishDialog.describeStep).toBeHidden()
|
||||
// Examples step should show thumbnail toggle and upload area
|
||||
await expect(
|
||||
publishDialog.root.getByText('Select a thumbnail')
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Back button returns to Describe step from Examples', async ({
|
||||
publishDialog
|
||||
}) => {
|
||||
await publishDialog.goNext()
|
||||
await expect(publishDialog.describeStep).toBeHidden()
|
||||
|
||||
await publishDialog.goBack()
|
||||
await expect(publishDialog.describeStep).toBeVisible()
|
||||
})
|
||||
|
||||
test('navigates through all steps to Finish', async ({ publishDialog }) => {
|
||||
await publishDialog.goNext() // → Examples
|
||||
await publishDialog.goNext() // → Finish
|
||||
await expect(publishDialog.finishStep).toBeVisible()
|
||||
await expect(publishDialog.publishButton).toBeVisible()
|
||||
})
|
||||
|
||||
test('clicking nav step navigates directly', async ({ publishDialog }) => {
|
||||
await publishDialog.goToStep('Finish publishing')
|
||||
await expect(publishDialog.finishStep).toBeVisible()
|
||||
|
||||
await publishDialog.goToStep('Describe your workflow')
|
||||
await expect(publishDialog.describeStep).toBeVisible()
|
||||
})
|
||||
|
||||
test('closes dialog via Escape key', async ({ comfyPage, publishDialog }) => {
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await expect(publishDialog.root).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Publish dialog - Describe step', () => {
|
||||
test.beforeEach(async ({ comfyPage, publishApi, publishDialog }) => {
|
||||
await comfyPage.featureFlags.setFlags(PUBLISH_FEATURE_FLAGS)
|
||||
await publishApi.setupDefaultMocks()
|
||||
await saveAndOpenPublishDialog(comfyPage, publishDialog, 'test-describe-wf')
|
||||
})
|
||||
|
||||
test('allows editing the workflow name', async ({ publishDialog }) => {
|
||||
await publishDialog.nameInput.clear()
|
||||
await publishDialog.nameInput.fill('My Custom Workflow')
|
||||
await expect(publishDialog.nameInput).toHaveValue('My Custom Workflow')
|
||||
})
|
||||
|
||||
test('allows editing the description', async ({ publishDialog }) => {
|
||||
await publishDialog.descriptionTextarea.fill(
|
||||
'A great workflow for anime art'
|
||||
)
|
||||
await expect(publishDialog.descriptionTextarea).toHaveValue(
|
||||
'A great workflow for anime art'
|
||||
)
|
||||
})
|
||||
|
||||
test('displays tag suggestions from mocked API', async ({
|
||||
publishDialog
|
||||
}) => {
|
||||
await expect(publishDialog.root.getByText('anime')).toBeVisible()
|
||||
await expect(publishDialog.root.getByText('upscale')).toBeVisible()
|
||||
})
|
||||
|
||||
// TODO(#11548): Tag click emits update:tags but the tag does not appear in
|
||||
// the active list during E2E. Needs investigation of the parent state
|
||||
// binding.
|
||||
test.fixme('clicking a tag suggestion adds it', async ({ publishDialog }) => {
|
||||
await publishDialog.root.getByText('anime').click()
|
||||
|
||||
await expect(publishDialog.tagsInput.getByText('anime')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Publish dialog - Examples step', () => {
|
||||
test.beforeEach(async ({ comfyPage, publishApi, publishDialog }) => {
|
||||
await comfyPage.featureFlags.setFlags(PUBLISH_FEATURE_FLAGS)
|
||||
await publishApi.setupDefaultMocks()
|
||||
await saveAndOpenPublishDialog(comfyPage, publishDialog, 'test-examples-wf')
|
||||
await publishDialog.goNext() // Navigate to Examples step
|
||||
})
|
||||
|
||||
test('shows thumbnail type toggle options', async ({ publishDialog }) => {
|
||||
await expect(
|
||||
publishDialog.root.getByText('Image', { exact: true })
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
publishDialog.root.getByText('Video', { exact: true })
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
publishDialog.root.getByText('Image comparison', { exact: true })
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('shows example image upload tile', async ({ publishDialog }) => {
|
||||
await expect(
|
||||
publishDialog.root.getByRole('button', { name: 'Upload example image' })
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Publish dialog - Finish step with profile', () => {
|
||||
test.beforeEach(async ({ comfyPage, publishApi, publishDialog }) => {
|
||||
await comfyPage.featureFlags.setFlags(PUBLISH_FEATURE_FLAGS)
|
||||
await publishApi.setupDefaultMocks({ hasProfile: true })
|
||||
await saveAndOpenPublishDialog(comfyPage, publishDialog, 'test-finish-wf')
|
||||
await publishDialog.goToStep('Finish publishing')
|
||||
})
|
||||
|
||||
test('shows profile card with username', async ({ publishDialog }) => {
|
||||
await expect(publishDialog.finishStep).toBeVisible()
|
||||
await expect(publishDialog.root.getByText('@testuser')).toBeVisible()
|
||||
await expect(publishDialog.root.getByText('Test User')).toBeVisible()
|
||||
})
|
||||
|
||||
test('publish button is enabled when no private assets', async ({
|
||||
publishDialog
|
||||
}) => {
|
||||
await expect(publishDialog.publishButton).toBeEnabled()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Publish dialog - Finish step with private assets', () => {
|
||||
test.beforeEach(async ({ comfyPage, publishApi, publishDialog }) => {
|
||||
await comfyPage.featureFlags.setFlags(PUBLISH_FEATURE_FLAGS)
|
||||
await publishApi.setupDefaultMocks({
|
||||
hasProfile: true,
|
||||
hasPrivateAssets: true
|
||||
})
|
||||
await saveAndOpenPublishDialog(comfyPage, publishDialog, 'test-assets-wf')
|
||||
await publishDialog.goToStep('Finish publishing')
|
||||
})
|
||||
|
||||
test('publish button is disabled until assets acknowledged', async ({
|
||||
publishDialog
|
||||
}) => {
|
||||
await expect(publishDialog.finishStep).toBeVisible()
|
||||
await expect(publishDialog.publishButton).toBeDisabled()
|
||||
|
||||
const checkbox = publishDialog.finishStep.getByRole('checkbox')
|
||||
await checkbox.check()
|
||||
|
||||
await expect(publishDialog.publishButton).toBeEnabled()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Publish dialog - no profile', () => {
|
||||
test.beforeEach(async ({ comfyPage, publishApi, publishDialog }) => {
|
||||
await comfyPage.featureFlags.setFlags(PUBLISH_FEATURE_FLAGS)
|
||||
await publishApi.setupDefaultMocks({ hasProfile: false })
|
||||
await saveAndOpenPublishDialog(
|
||||
comfyPage,
|
||||
publishDialog,
|
||||
'test-noprofile-wf'
|
||||
)
|
||||
await publishDialog.goToStep('Finish publishing')
|
||||
})
|
||||
|
||||
test('shows profile creation prompt when user has no profile', async ({
|
||||
publishDialog
|
||||
}) => {
|
||||
await expect(publishDialog.profilePrompt).toBeVisible()
|
||||
await expect(
|
||||
publishDialog.root.getByText('Create a profile to publish to ComfyHub')
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('clicking create profile CTA shows profile creation form', async ({
|
||||
publishDialog
|
||||
}) => {
|
||||
await publishDialog.root
|
||||
.getByRole('button', { name: 'Create a profile' })
|
||||
.click()
|
||||
await expect(publishDialog.gateFlow).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Publish dialog - unsaved workflow', () => {
|
||||
test.beforeEach(async ({ comfyPage, publishApi }) => {
|
||||
await comfyPage.featureFlags.setFlags(PUBLISH_FEATURE_FLAGS)
|
||||
await publishApi.setupDefaultMocks()
|
||||
// Don't save workflow — open dialog on the default temporary workflow
|
||||
})
|
||||
|
||||
test('shows save prompt for temporary workflow', async ({
|
||||
comfyPage,
|
||||
publishDialog
|
||||
}) => {
|
||||
// Create a new workflow to ensure it's temporary
|
||||
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
|
||||
await publishDialog.open()
|
||||
|
||||
await expect(publishDialog.savePrompt).toBeVisible()
|
||||
await expect(
|
||||
publishDialog.root.getByText(
|
||||
'You must save your workflow before publishing'
|
||||
)
|
||||
).toBeVisible()
|
||||
// Nav should be hidden when save is required
|
||||
await expect(publishDialog.nav).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Publish dialog - submission', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.featureFlags.setFlags(PUBLISH_FEATURE_FLAGS)
|
||||
})
|
||||
|
||||
test('successful publish closes dialog', async ({
|
||||
comfyPage,
|
||||
publishApi,
|
||||
publishDialog
|
||||
}) => {
|
||||
await publishApi.setupDefaultMocks({ hasProfile: true })
|
||||
await saveAndOpenPublishDialog(comfyPage, publishDialog, 'test-submit-wf')
|
||||
await publishDialog.goToStep('Finish publishing')
|
||||
await expect(publishDialog.finishStep).toBeVisible()
|
||||
|
||||
await publishDialog.publishButton.click()
|
||||
await expect(publishDialog.root).toBeHidden({ timeout: 10_000 })
|
||||
})
|
||||
|
||||
test('failed publish shows error toast', async ({
|
||||
comfyPage,
|
||||
publishApi,
|
||||
publishDialog
|
||||
}) => {
|
||||
await publishApi.setupDefaultMocks({ hasProfile: true })
|
||||
// Override publish mock with error response
|
||||
await publishApi.mockPublishWorkflowError(500, 'Internal error')
|
||||
|
||||
await saveAndOpenPublishDialog(
|
||||
comfyPage,
|
||||
publishDialog,
|
||||
'test-submit-fail-wf'
|
||||
)
|
||||
await publishDialog.goToStep('Finish publishing')
|
||||
await expect(publishDialog.finishStep).toBeVisible()
|
||||
|
||||
await publishDialog.publishButton.click()
|
||||
|
||||
// Error toast should appear
|
||||
await expect(comfyPage.toast.visibleToasts.first()).toBeVisible({
|
||||
timeout: 10_000
|
||||
})
|
||||
// Dialog should remain open
|
||||
await expect(publishDialog.root).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -22,14 +22,18 @@ async function addGhostAtCenter(comfyPage: ComfyPage) {
|
||||
await comfyPage.page.mouse.move(centerX, centerY)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const nodeRef = await comfyPage.nodeOps.addNode(
|
||||
'VAEDecode',
|
||||
{ ghost: true },
|
||||
{ x: centerX, y: centerY }
|
||||
const nodeId = await comfyPage.page.evaluate(
|
||||
([clientX, clientY]) => {
|
||||
const node = window.LiteGraph!.createNode('VAEDecode')!
|
||||
const event = new MouseEvent('click', { clientX, clientY })
|
||||
window.app!.graph.add(node, { ghost: true, dragEvent: event })
|
||||
return node.id
|
||||
},
|
||||
[centerX, centerY] as const
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
return { nodeId: nodeRef.id, centerX, centerY }
|
||||
return { nodeId, centerX, centerY }
|
||||
}
|
||||
|
||||
function getNodeById(comfyPage: ComfyPage, nodeId: number | string) {
|
||||
@@ -76,6 +80,7 @@ for (const mode of ['litegraph', 'vue'] as const) {
|
||||
},
|
||||
[centerX, centerY] as const
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(Math.abs(result.diffX)).toBeLessThan(5)
|
||||
expect(Math.abs(result.diffY)).toBeLessThan(5)
|
||||
@@ -148,127 +153,5 @@ for (const mode of ['litegraph', 'vue'] as const) {
|
||||
const after = await getNodeById(comfyPage, nodeId)
|
||||
expect(after).toBeNull()
|
||||
})
|
||||
|
||||
test('moving ghost onto existing node and clicking places correctly', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Get existing KSampler node from the default workflow
|
||||
const [ksamplerRef] =
|
||||
await comfyPage.nodeOps.getNodeRefsByTitle('KSampler')
|
||||
const ksamplerPos = await ksamplerRef.getPosition()
|
||||
const ksamplerSize = await ksamplerRef.getSize()
|
||||
const targetX = Math.round(ksamplerPos.x + ksamplerSize.width / 2)
|
||||
const targetY = Math.round(ksamplerPos.y + ksamplerSize.height / 2)
|
||||
|
||||
// Start ghost placement away from the existing node
|
||||
const startX = 50
|
||||
const startY = 50
|
||||
await comfyPage.page.mouse.move(startX, startY, { steps: 20 })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const ghostRef = await comfyPage.nodeOps.addNode(
|
||||
'VAEDecode',
|
||||
{ ghost: true },
|
||||
{ x: startX, y: startY }
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Move ghost onto the existing node
|
||||
await comfyPage.page.mouse.move(targetX, targetY, { steps: 20 })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Click to finalize — on top of the existing node
|
||||
await comfyPage.page.mouse.click(targetX, targetY)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Ghost should be placed (no longer ghost)
|
||||
const ghostResult = await getNodeById(comfyPage, ghostRef.id)
|
||||
expect(ghostResult).not.toBeNull()
|
||||
expect(ghostResult!.ghost).toBe(false)
|
||||
|
||||
// Ghost node should have moved from its start position toward where we clicked
|
||||
const ghostPos = await ghostRef.getPosition()
|
||||
expect(
|
||||
Math.abs(ghostPos.x - startX) > 20 || Math.abs(ghostPos.y - startY) > 20
|
||||
).toBe(true)
|
||||
|
||||
// Existing node should NOT be selected
|
||||
const selectedIds = await comfyPage.nodeOps.getSelectedNodeIds()
|
||||
expect(selectedIds).not.toContain(ksamplerRef.id)
|
||||
})
|
||||
|
||||
test(
|
||||
'subgraph blueprint added from search box enters ghost mode',
|
||||
{ tag: ['@subgraph'] },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.NodeSearchBoxImpl',
|
||||
'default'
|
||||
)
|
||||
await comfyPage.searchBoxV2.reload(comfyPage)
|
||||
|
||||
// Convert a node to a subgraph and publish it as a blueprint
|
||||
const nodeRef = await comfyPage.nodeOps.getNodeRefById('3')
|
||||
await nodeRef.click('title')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.command.executeCommand('Comfy.Graph.ConvertToSubgraph')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.nextFrame()
|
||||
const subgraphNodes =
|
||||
await comfyPage.nodeOps.getNodeRefsByTitle('New Subgraph')
|
||||
expect(subgraphNodes).toHaveLength(1)
|
||||
const subgraphNode = subgraphNodes[0]
|
||||
|
||||
const blueprintName = `ghost-test-${Date.now()}`
|
||||
await subgraphNode.click('title')
|
||||
await comfyPage.command.executeCommand('Comfy.PublishSubgraph', {
|
||||
name: blueprintName
|
||||
})
|
||||
await expect(comfyPage.visibleToasts).toHaveCount(1, { timeout: 5000 })
|
||||
await comfyPage.toast.closeToasts(1)
|
||||
|
||||
const nodeCountBefore = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
|
||||
// Open v2 search box and search for the published blueprint
|
||||
await comfyPage.canvasOps.doubleClick()
|
||||
const { searchBoxV2 } = comfyPage
|
||||
await expect(searchBoxV2.input).toBeVisible()
|
||||
|
||||
await searchBoxV2.input.fill(blueprintName)
|
||||
await expect(searchBoxV2.results.first()).toBeVisible()
|
||||
|
||||
// Click the result to add the node (v2 search box uses ghost mode)
|
||||
await searchBoxV2.results.first().click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// A new node should exist on the graph in ghost mode
|
||||
const nodeCountAfter = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
expect(nodeCountAfter).toBe(nodeCountBefore + 1)
|
||||
|
||||
const ghostNodeId = await comfyPage.page.evaluate(() => {
|
||||
return window.app!.canvas.state.ghostNodeId
|
||||
})
|
||||
expect(ghostNodeId).not.toBeNull()
|
||||
|
||||
const ghostState = await getNodeById(comfyPage, ghostNodeId!)
|
||||
expect(ghostState).not.toBeNull()
|
||||
expect(ghostState!.ghost).toBe(true)
|
||||
|
||||
// Wait for search box to close, then click to confirm placement
|
||||
await expect(searchBoxV2.input).toBeHidden()
|
||||
await comfyPage.nextFrame()
|
||||
const viewport = comfyPage.page.viewportSize()!
|
||||
await comfyPage.page.mouse.click(
|
||||
Math.round(viewport.width / 2),
|
||||
Math.round(viewport.height / 2)
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const afterPlace = await getNodeById(comfyPage, ghostNodeId!)
|
||||
expect(afterPlace).not.toBeNull()
|
||||
expect(afterPlace!.ghost).toBe(false)
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -56,9 +56,7 @@ test.describe('Node search box V2', { tag: '@node' }, () => {
|
||||
})
|
||||
|
||||
test.describe('Category navigation', () => {
|
||||
test('Bookmarked filter shows only bookmarked nodes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test('Favorites shows only bookmarked nodes', async ({ comfyPage }) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
await comfyPage.settings.setSetting('Comfy.NodeLibrary.Bookmarks.V2', [
|
||||
'KSampler'
|
||||
@@ -68,7 +66,7 @@ test.describe('Node search box V2', { tag: '@node' }, () => {
|
||||
await comfyPage.canvasOps.doubleClick()
|
||||
await expect(searchBoxV2.input).toBeVisible()
|
||||
|
||||
await searchBoxV2.filterBarButton('Bookmarked').click()
|
||||
await searchBoxV2.categoryButton('favorites').click()
|
||||
|
||||
await expect(searchBoxV2.results).toHaveCount(1)
|
||||
await expect(searchBoxV2.results.first()).toContainText('KSampler')
|
||||
@@ -103,7 +101,7 @@ test.describe('Node search box V2', { tag: '@node' }, () => {
|
||||
await expect(searchBoxV2.filterOptions.first()).toBeVisible()
|
||||
|
||||
// Type to narrow and select MODEL
|
||||
await searchBoxV2.filterSearch.fill('MODEL')
|
||||
await searchBoxV2.input.fill('MODEL')
|
||||
await searchBoxV2.filterOptions
|
||||
.filter({ hasText: 'MODEL' })
|
||||
.first()
|
||||
|
||||
@@ -97,7 +97,7 @@ test.describe('Node search box V2 extended', { tag: '@node' }, () => {
|
||||
// Apply Input filter with MODEL type
|
||||
await searchBoxV2.filterBarButton('Input').click()
|
||||
await expect(searchBoxV2.filterOptions.first()).toBeVisible()
|
||||
await searchBoxV2.filterSearch.fill('MODEL')
|
||||
await searchBoxV2.input.fill('MODEL')
|
||||
await searchBoxV2.filterOptions
|
||||
.filter({ hasText: 'MODEL' })
|
||||
.first()
|
||||
|
||||
@@ -163,7 +163,7 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
.poll(() => cursor.evaluate((el: HTMLElement) => el.style.transform))
|
||||
.not.toBe(transform1)
|
||||
|
||||
await comfyPage.page.mouse.move(box.x + box.width + 50, box.y)
|
||||
await comfyPage.page.mouse.move(0, 0)
|
||||
await expect(cursor).toBeHidden()
|
||||
})
|
||||
|
||||
@@ -187,10 +187,7 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
box.y + box.height * 0.5,
|
||||
{ steps: 10 }
|
||||
)
|
||||
await comfyPage.page.mouse.move(
|
||||
box.x + box.width + 20,
|
||||
box.y + box.height * 0.5
|
||||
)
|
||||
await comfyPage.page.mouse.move(box.x - 20, box.y + box.height * 0.5)
|
||||
await comfyPage.page.mouse.up()
|
||||
|
||||
await comfyPage.nextFrame()
|
||||
@@ -411,7 +408,6 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
|
||||
await expect
|
||||
.poll(() => canvas.evaluate((el: HTMLCanvasElement) => el.width))
|
||||
// default 512 + slider step 64 = 576
|
||||
.toBe(576)
|
||||
})
|
||||
|
||||
@@ -497,29 +493,6 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
.toBe(false)
|
||||
}
|
||||
)
|
||||
|
||||
test('Clear on empty canvas is harmless', async ({ comfyPage }) => {
|
||||
const painterWidget = comfyPage.vueNodes
|
||||
.getNodeLocator('1')
|
||||
.locator('.widget-expands')
|
||||
const canvas = painterWidget.locator('canvas')
|
||||
|
||||
await expect
|
||||
.poll(() => hasCanvasContent(canvas), {
|
||||
message: 'canvas should start empty'
|
||||
})
|
||||
.toBe(false)
|
||||
|
||||
await painterWidget
|
||||
.getByTestId('painter-clear-button')
|
||||
.dispatchEvent('click')
|
||||
|
||||
await expect
|
||||
.poll(() => hasCanvasContent(canvas), {
|
||||
message: 'canvas should still be empty after clearing empty canvas'
|
||||
})
|
||||
.toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Serialization', () => {
|
||||
@@ -587,6 +560,36 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
})
|
||||
|
||||
test.describe('Eraser', () => {
|
||||
test('Eraser removes previously drawn content', async ({ comfyPage }) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const painterWidget = node.locator('.widget-expands')
|
||||
const canvas = painterWidget.locator('canvas')
|
||||
await expect(canvas).toBeVisible()
|
||||
|
||||
await drawStroke(comfyPage.page, canvas)
|
||||
await comfyPage.nextFrame()
|
||||
await expect.poll(() => hasCanvasContent(canvas)).toBe(true)
|
||||
|
||||
await painterWidget.getByRole('button', { name: 'Eraser' }).click()
|
||||
await drawStroke(comfyPage.page, canvas)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
canvas.evaluate((el: HTMLCanvasElement) => {
|
||||
const ctx = el.getContext('2d')
|
||||
if (!ctx) return false
|
||||
const cx = Math.floor(el.width / 2)
|
||||
const cy = Math.floor(el.height / 2)
|
||||
const { data } = ctx.getImageData(cx - 5, cy - 5, 10, 10)
|
||||
return data.every((v, i) => i % 4 !== 3 || v === 0)
|
||||
}),
|
||||
{ message: 'erased area should be transparent' }
|
||||
)
|
||||
.toBe(true)
|
||||
})
|
||||
|
||||
test('Eraser on empty canvas adds no content', async ({ comfyPage }) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const painterWidget = node.locator('.widget-expands')
|
||||
@@ -601,318 +604,18 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Serialization — unchanged canvas', () => {
|
||||
test(
|
||||
'Unchanged canvas does not re-upload on second serialization',
|
||||
{ tag: '@slow' },
|
||||
async ({ comfyPage }) => {
|
||||
let uploadCount = 0
|
||||
|
||||
await comfyPage.page.route('**/upload/image', async (route) => {
|
||||
uploadCount++
|
||||
const mockResponse: UploadImageResponse = { name: 'painter-test.png' }
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockResponse)
|
||||
})
|
||||
})
|
||||
|
||||
const canvas = comfyPage.vueNodes
|
||||
.getNodeLocator('1')
|
||||
.locator('.widget-expands canvas')
|
||||
|
||||
await drawStroke(comfyPage.page, canvas)
|
||||
await triggerSerialization(comfyPage.page)
|
||||
expect(uploadCount, 'first serialization should upload once').toBe(1)
|
||||
|
||||
await triggerSerialization(comfyPage.page)
|
||||
expect(
|
||||
uploadCount,
|
||||
'second serialization without new drawing should not re-upload'
|
||||
).toBe(1)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
test.describe('Settings persistence', () => {
|
||||
test('Tool selection is saved to node properties', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const painterWidget = comfyPage.vueNodes
|
||||
.getNodeLocator('1')
|
||||
.locator('.widget-expands')
|
||||
|
||||
await painterWidget.getByRole('button', { name: 'Eraser' }).click()
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
const graph = window.graph as TestGraphAccess | undefined
|
||||
return graph?._nodes_by_id?.['1']?.properties?.painterTool as
|
||||
| string
|
||||
| undefined
|
||||
}),
|
||||
{ message: 'painterTool property should update to eraser' }
|
||||
)
|
||||
.toBe('eraser')
|
||||
})
|
||||
|
||||
test('Brush size change is saved to node properties', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const sizeRow = comfyPage.vueNodes
|
||||
.getNodeLocator('1')
|
||||
.locator('.widget-expands')
|
||||
.getByTestId('painter-size-row')
|
||||
const sizeSlider = sizeRow.getByRole('slider')
|
||||
|
||||
await expect(
|
||||
sizeRow.getByTestId('painter-size-value'),
|
||||
'brush size should start at default 20'
|
||||
).toHaveText('20')
|
||||
|
||||
await sizeSlider.focus()
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await sizeSlider.press('ArrowRight')
|
||||
}
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
const graph = window.graph as TestGraphAccess | undefined
|
||||
return graph?._nodes_by_id?.['1']?.properties
|
||||
?.painterBrushSize as number | undefined
|
||||
}),
|
||||
{ message: 'painterBrushSize property should update to 30' }
|
||||
)
|
||||
.toBe(30)
|
||||
})
|
||||
})
|
||||
|
||||
test('Controls collapse to single column in compact mode', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const painterWidget = comfyPage.vueNodes
|
||||
.getNodeLocator('1')
|
||||
.locator('.widget-expands')
|
||||
const toolLabel = painterWidget.getByText('Tool', { exact: true })
|
||||
|
||||
await expect(
|
||||
toolLabel,
|
||||
'tool label should be visible in two-column layout'
|
||||
).toBeVisible()
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const graph = window.graph as TestGraphAccess | undefined
|
||||
const node = graph?._nodes_by_id?.['1']
|
||||
if (node) {
|
||||
node.size = [200, 400]
|
||||
window.app!.canvas.setDirty(true, true)
|
||||
}
|
||||
})
|
||||
|
||||
await expect(
|
||||
toolLabel,
|
||||
'tool label should hide in compact single-column layout'
|
||||
).toBeHidden()
|
||||
})
|
||||
|
||||
test('Multiple sequential strokes at different positions all accumulate', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test('Multiple strokes accumulate on the canvas', async ({ comfyPage }) => {
|
||||
const canvas = comfyPage.vueNodes
|
||||
.getNodeLocator('1')
|
||||
.locator('.widget-expands canvas')
|
||||
await expect(canvas).toBeVisible()
|
||||
|
||||
await drawStroke(comfyPage.page, canvas, { yPct: 0.25 })
|
||||
await drawStroke(comfyPage.page, canvas, { yPct: 0.5 })
|
||||
await drawStroke(comfyPage.page, canvas, { yPct: 0.75 })
|
||||
await drawStroke(comfyPage.page, canvas, { yPct: 0.3 })
|
||||
await comfyPage.nextFrame()
|
||||
await expect.poll(() => hasCanvasContent(canvas)).toBe(true)
|
||||
|
||||
const hasContentAtRow = (yFraction: number) =>
|
||||
canvas.evaluate((el: HTMLCanvasElement, y: number) => {
|
||||
const ctx = el.getContext('2d')
|
||||
if (!ctx) return false
|
||||
const cy = Math.floor(el.height * y)
|
||||
const { data } = ctx.getImageData(0, cy - 5, el.width, 10)
|
||||
for (let i = 3; i < data.length; i += 4) {
|
||||
if (data[i] > 0) return true
|
||||
}
|
||||
return false
|
||||
}, yFraction)
|
||||
|
||||
await expect
|
||||
.poll(() => hasContentAtRow(0.25), {
|
||||
message: 'top stroke should be present'
|
||||
})
|
||||
.toBe(true)
|
||||
await expect
|
||||
.poll(() => hasContentAtRow(0.5), {
|
||||
message: 'middle stroke should be present'
|
||||
})
|
||||
.toBe(true)
|
||||
await expect
|
||||
.poll(() => hasContentAtRow(0.75), {
|
||||
message: 'bottom stroke should be present'
|
||||
})
|
||||
.toBe(true)
|
||||
await drawStroke(comfyPage.page, canvas, { yPct: 0.7 })
|
||||
await comfyPage.nextFrame()
|
||||
await expect.poll(() => hasCanvasContent(canvas)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe(
|
||||
'Painter — input image connection',
|
||||
{ tag: ['@widget', '@vue-nodes', '@slow'] },
|
||||
() => {
|
||||
test.setTimeout(60_000)
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.page.evaluate(() => window.app?.graph?.clear())
|
||||
await comfyPage.workflow.loadWorkflow('widgets/painter_with_input')
|
||||
})
|
||||
|
||||
test('Width, height, and bg_color controls hide when input is connected', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const painterWidget = comfyPage.vueNodes
|
||||
.getNodeLocator('1')
|
||||
.locator('.widget-expands')
|
||||
|
||||
await expect(
|
||||
painterWidget.getByTestId('painter-width-row'),
|
||||
'width row should be hidden when input is connected'
|
||||
).toBeHidden()
|
||||
await expect(
|
||||
painterWidget.getByTestId('painter-height-row'),
|
||||
'height row should be hidden when input is connected'
|
||||
).toBeHidden()
|
||||
await expect(
|
||||
painterWidget.getByTestId('painter-bg-color-row'),
|
||||
'background color row should be hidden when input is connected'
|
||||
).toBeHidden()
|
||||
await expect(
|
||||
painterWidget.getByTestId('painter-dimension-text'),
|
||||
'dimension text should be visible when input is connected'
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Canvas resizes to match input image dimensions after execution', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.runButton.click()
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const img = node.locator('.widget-expands img')
|
||||
await expect(
|
||||
img,
|
||||
'input image should appear after execution'
|
||||
).toBeVisible({
|
||||
timeout: 30_000
|
||||
})
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
img.evaluate(
|
||||
(el: HTMLImageElement) => el.complete && el.naturalWidth > 0
|
||||
),
|
||||
{
|
||||
message: 'input image should be fully decoded',
|
||||
timeout: 30_000
|
||||
}
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
const { nw, nh } = await img.evaluate((el: HTMLImageElement) => ({
|
||||
nw: el.naturalWidth,
|
||||
nh: el.naturalHeight
|
||||
}))
|
||||
|
||||
const canvas = node.locator('.widget-expands canvas')
|
||||
await expect
|
||||
.poll(() => canvas.evaluate((el: HTMLCanvasElement) => el.width), {
|
||||
message: 'canvas width should match input image natural width'
|
||||
})
|
||||
.toBe(nw)
|
||||
await expect
|
||||
.poll(() => canvas.evaluate((el: HTMLCanvasElement) => el.height), {
|
||||
message: 'canvas height should match input image natural height'
|
||||
})
|
||||
.toBe(nh)
|
||||
})
|
||||
|
||||
test('Drawing over input image produces content on canvas', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.runButton.click()
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const img = node.locator('.widget-expands img')
|
||||
await expect(
|
||||
img,
|
||||
'input image should appear after execution'
|
||||
).toBeVisible({
|
||||
timeout: 30_000
|
||||
})
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
img.evaluate(
|
||||
(el: HTMLImageElement) => el.complete && el.naturalWidth > 0
|
||||
),
|
||||
{ message: 'input image should be fully decoded', timeout: 30_000 }
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
const nw = await img.evaluate((el: HTMLImageElement) => el.naturalWidth)
|
||||
const canvas = node.locator('.widget-expands canvas')
|
||||
await expect
|
||||
.poll(() => canvas.evaluate((el: HTMLCanvasElement) => el.width), {
|
||||
message: 'canvas should resize to match input image width',
|
||||
timeout: 15_000
|
||||
})
|
||||
.toBe(nw)
|
||||
|
||||
// Use dispatchEvent to bypass the LiteGraph canvas z-index overlay that
|
||||
// intercepts coordinate-based hit testing from page.mouse
|
||||
const box = await canvas.boundingBox()
|
||||
if (!box) throw new Error('Canvas bounding box not found')
|
||||
const startX = box.x + box.width * 0.3
|
||||
const endX = box.x + box.width * 0.7
|
||||
const midY = box.y + box.height * 0.5
|
||||
const pointerOpts = {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
pointerId: 1,
|
||||
button: 0,
|
||||
isPrimary: true
|
||||
}
|
||||
await canvas.dispatchEvent('pointerdown', {
|
||||
...pointerOpts,
|
||||
clientX: startX,
|
||||
clientY: midY
|
||||
})
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
await canvas.dispatchEvent('pointermove', {
|
||||
...pointerOpts,
|
||||
clientX: startX + (endX - startX) * (i / 10),
|
||||
clientY: midY
|
||||
})
|
||||
}
|
||||
await canvas.dispatchEvent('pointerup', {
|
||||
...pointerOpts,
|
||||
clientX: endX,
|
||||
clientY: midY
|
||||
})
|
||||
|
||||
await expect
|
||||
.poll(() => hasCanvasContent(canvas), {
|
||||
message: 'drawing over input image should produce canvas content'
|
||||
})
|
||||
.toBe(true)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -99,58 +99,5 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
|
||||
)
|
||||
await expect(downloadButton.first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should render Download all and Refresh actions for one downloadable model', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingModelActions)
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingModelDownloadAll)
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingModelRefresh)
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should clear resolved missing model when Refresh is clicked', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
|
||||
await comfyPage.page.route(/\/object_info$/, async (route) => {
|
||||
const response = await route.fetch()
|
||||
const objectInfo = await response.json()
|
||||
const ckptName =
|
||||
objectInfo.CheckpointLoaderSimple.input.required.ckpt_name
|
||||
ckptName[0] = [...ckptName[0], 'fake_model.safetensors']
|
||||
await route.fulfill({ response, json: objectInfo })
|
||||
})
|
||||
|
||||
const objectInfoResponse = comfyPage.page.waitForResponse((response) => {
|
||||
const url = new URL(response.url())
|
||||
return url.pathname.endsWith('/object_info') && response.ok()
|
||||
})
|
||||
const modelFoldersResponse = comfyPage.page.waitForResponse(
|
||||
(response) => {
|
||||
const url = new URL(response.url())
|
||||
return url.pathname.endsWith('/experiment/models') && response.ok()
|
||||
}
|
||||
)
|
||||
const refreshButton = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelRefresh
|
||||
)
|
||||
|
||||
await Promise.all([
|
||||
objectInfoResponse,
|
||||
modelFoldersResponse,
|
||||
refreshButton.click()
|
||||
])
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingModelsGroup)
|
||||
).toBeHidden()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,10 +5,7 @@ import {
|
||||
createMockJob,
|
||||
createMockJobs
|
||||
} from '@e2e/fixtures/helpers/AssetsHelper'
|
||||
import type {
|
||||
JobDetail,
|
||||
RawJobListItem
|
||||
} from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared fixtures
|
||||
@@ -65,37 +62,6 @@ const SAMPLE_IMPORTED_FILES = [
|
||||
'audio_clip.wav'
|
||||
]
|
||||
|
||||
const JOB_GAMMA_DETAIL: JobDetail = {
|
||||
...SAMPLE_JOBS[2],
|
||||
outputs: {
|
||||
'3': {
|
||||
images: [
|
||||
{
|
||||
filename: 'abstract_art.png',
|
||||
subfolder: '',
|
||||
type: 'output'
|
||||
},
|
||||
{
|
||||
filename: 'abstract_art_alt.png',
|
||||
subfolder: '',
|
||||
type: 'output'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const cloudTest = test.extend<{ mockCloudAssetSidebarData: void }>({
|
||||
mockCloudAssetSidebarData: async ({ comfyPage }, use) => {
|
||||
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
|
||||
await comfyPage.assets.mockEmptyCloudAssets()
|
||||
|
||||
await use()
|
||||
|
||||
await comfyPage.assets.clearMocks()
|
||||
}
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 1. Empty states
|
||||
// ==========================================================================
|
||||
@@ -667,96 +633,6 @@ test.describe('Assets sidebar - bulk actions', () => {
|
||||
})
|
||||
})
|
||||
|
||||
cloudTest.describe('Assets sidebar - cloud exports', { tag: '@cloud' }, () => {
|
||||
cloudTest(
|
||||
'Single job selection uses preserve naming strategy',
|
||||
async ({ comfyPage, mockCloudAssetSidebarData }) => {
|
||||
void mockCloudAssetSidebarData
|
||||
const exportRequests = await comfyPage.assets.captureAssetExportRequests()
|
||||
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.assetCards.first().click()
|
||||
await expect(tab.downloadSelectedButton).toBeVisible()
|
||||
|
||||
await tab.downloadSelectedButton.click()
|
||||
|
||||
await expect.poll(() => exportRequests).toHaveLength(1)
|
||||
|
||||
const payload = exportRequests[0]
|
||||
expect(payload.job_ids).toEqual(['job-gamma'])
|
||||
expect(payload.job_asset_name_filters).toBeUndefined()
|
||||
expect(payload.naming_strategy).toBe('preserve')
|
||||
}
|
||||
)
|
||||
|
||||
cloudTest(
|
||||
'Multiple selected assets from one job use preserve naming strategy',
|
||||
async ({ comfyPage, mockCloudAssetSidebarData }) => {
|
||||
void mockCloudAssetSidebarData
|
||||
const exportRequests = await comfyPage.assets.captureAssetExportRequests()
|
||||
await comfyPage.assets.mockJobDetail('job-gamma', JOB_GAMMA_DETAIL)
|
||||
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.assetCards
|
||||
.first()
|
||||
.getByRole('button', { name: 'See more outputs' })
|
||||
.click()
|
||||
await expect(tab.backToAssetsButton).toBeVisible()
|
||||
await expect.poll(() => tab.assetCards.count()).toBe(2)
|
||||
|
||||
await tab.assetCards.first().click()
|
||||
await comfyPage.page.keyboard.down('Control')
|
||||
await tab.assetCards.nth(1).click()
|
||||
await comfyPage.page.keyboard.up('Control')
|
||||
|
||||
await expect(tab.selectedCards).toHaveCount(2)
|
||||
await tab.downloadSelectedButton.click()
|
||||
|
||||
await expect.poll(() => exportRequests).toHaveLength(1)
|
||||
|
||||
const payload = exportRequests[0]
|
||||
expect(payload.job_ids).toEqual(['job-gamma'])
|
||||
expect(payload.job_asset_name_filters?.['job-gamma']?.toSorted()).toEqual(
|
||||
['abstract_art.png', 'abstract_art_alt.png']
|
||||
)
|
||||
expect(payload.naming_strategy).toBe('preserve')
|
||||
}
|
||||
)
|
||||
|
||||
cloudTest(
|
||||
'Multiple selected jobs use job-time naming strategy',
|
||||
async ({ comfyPage, mockCloudAssetSidebarData }) => {
|
||||
void mockCloudAssetSidebarData
|
||||
const exportRequests = await comfyPage.assets.captureAssetExportRequests()
|
||||
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.assetCards.nth(1).click()
|
||||
await comfyPage.page.keyboard.down('Control')
|
||||
await tab.assetCards.nth(2).click()
|
||||
await comfyPage.page.keyboard.up('Control')
|
||||
|
||||
await expect(tab.selectedCards).toHaveCount(2)
|
||||
await tab.downloadSelectedButton.click()
|
||||
|
||||
await expect.poll(() => exportRequests).toHaveLength(1)
|
||||
|
||||
const payload = exportRequests[0]
|
||||
expect(payload.job_ids?.toSorted()).toEqual(['job-alpha', 'job-beta'])
|
||||
expect(payload.job_asset_name_filters).toBeUndefined()
|
||||
expect(payload.naming_strategy).toBe('group_by_job_time')
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 9. Pagination
|
||||
// ==========================================================================
|
||||
|
||||
@@ -1,73 +1,39 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { comfyExpect, comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { SubgraphHelper } from '@e2e/fixtures/helpers/SubgraphHelper'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import type { PromotedWidgetEntry } from '@e2e/helpers/promotedWidgets'
|
||||
import {
|
||||
getPromotedWidgetCount,
|
||||
getPromotedWidgetNames,
|
||||
getPromotedWidgets
|
||||
} from '@e2e/helpers/promotedWidgets'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { getPromotedWidgets } from '@e2e/helpers/promotedWidgets'
|
||||
|
||||
const DUPLICATE_IDS_WORKFLOW = 'subgraphs/subgraph-nested-duplicate-ids'
|
||||
const LEGACY_PREFIXED_WORKFLOW =
|
||||
'subgraphs/nested-subgraph-legacy-prefixed-proxy-widgets'
|
||||
const MULTI_INSTANCE_WORKFLOW =
|
||||
'subgraphs/subgraph-multi-instance-promoted-text-values'
|
||||
|
||||
async function expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
comfyPage: ComfyPage,
|
||||
hostSubgraphNodeId: string,
|
||||
widgets: PromotedWidgetEntry[]
|
||||
) {
|
||||
expect(widgets.length).toBeGreaterThan(0)
|
||||
|
||||
const interiorNodeIds = widgets.map(([id]) => id)
|
||||
const results = await comfyPage.page.evaluate(
|
||||
([hostId, ids]) => {
|
||||
const graph = window.app!.graph!
|
||||
const hostNode = graph.getNodeById(Number(hostId))
|
||||
if (!hostNode?.isSubgraphNode()) return ids.map(() => false)
|
||||
|
||||
return ids.map((id) => {
|
||||
const interiorNode = hostNode.subgraph.getNodeById(Number(id))
|
||||
return interiorNode !== null && interiorNode !== undefined
|
||||
})
|
||||
},
|
||||
[hostSubgraphNodeId, interiorNodeIds] as const
|
||||
)
|
||||
|
||||
expect(results).toEqual(widgets.map(() => true))
|
||||
}
|
||||
|
||||
async function getPromotedHostWidgetValues(
|
||||
comfyPage: ComfyPage,
|
||||
nodeIds: string[]
|
||||
) {
|
||||
return comfyPage.page.evaluate((ids) => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
|
||||
return ids.map((id) => {
|
||||
const node = graph.getNodeById(id)
|
||||
if (
|
||||
!node ||
|
||||
typeof node.isSubgraphNode !== 'function' ||
|
||||
!node.isSubgraphNode()
|
||||
) {
|
||||
return { id, values: [] as unknown[] }
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
values: (node.widgets ?? []).map((widget) => widget.value)
|
||||
}
|
||||
})
|
||||
}, nodeIds)
|
||||
}
|
||||
|
||||
test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
|
||||
const getPromotedHostWidgetValues = async (
|
||||
comfyPage: ComfyPage,
|
||||
nodeIds: string[]
|
||||
) => {
|
||||
return comfyPage.page.evaluate((ids) => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
|
||||
return ids.map((id) => {
|
||||
const node = graph.getNodeById(id)
|
||||
if (
|
||||
!node ||
|
||||
typeof node.isSubgraphNode !== 'function' ||
|
||||
!node.isSubgraphNode()
|
||||
) {
|
||||
return { id, values: [] as unknown[] }
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
values: (node.widgets ?? []).map((widget) => widget.value)
|
||||
}
|
||||
})
|
||||
}, nodeIds)
|
||||
}
|
||||
|
||||
test('Promoted widget remains usable after serialize and reload', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
@@ -120,434 +86,54 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
|
||||
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(false)
|
||||
})
|
||||
|
||||
test.describe('Deterministic proxyWidgets Hydrate', () => {
|
||||
test('proxyWidgets entries map to real interior node IDs after load', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
|
||||
const widgets = await getPromotedWidgets(comfyPage, '11')
|
||||
expect(widgets.length).toBeGreaterThan(0)
|
||||
|
||||
for (const [interiorNodeId] of widgets) {
|
||||
expect(Number(interiorNodeId)).toBeGreaterThan(0)
|
||||
}
|
||||
|
||||
await expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
comfyPage,
|
||||
'11',
|
||||
widgets
|
||||
)
|
||||
})
|
||||
|
||||
test('proxyWidgets entries survive double round-trip without drift', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-multiple-promoted-widgets'
|
||||
)
|
||||
|
||||
const initialWidgets = await getPromotedWidgets(comfyPage, '11')
|
||||
expect(initialWidgets.length).toBeGreaterThan(0)
|
||||
await expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
comfyPage,
|
||||
'11',
|
||||
initialWidgets
|
||||
)
|
||||
|
||||
await comfyPage.subgraph.serializeAndReload()
|
||||
|
||||
const afterFirst = await getPromotedWidgets(comfyPage, '11')
|
||||
await expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
comfyPage,
|
||||
'11',
|
||||
afterFirst
|
||||
)
|
||||
|
||||
await comfyPage.subgraph.serializeAndReload()
|
||||
|
||||
const afterSecond = await getPromotedWidgets(comfyPage, '11')
|
||||
await expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
comfyPage,
|
||||
'11',
|
||||
afterSecond
|
||||
)
|
||||
|
||||
expect(afterFirst).toEqual(initialWidgets)
|
||||
expect(afterSecond).toEqual(initialWidgets)
|
||||
})
|
||||
|
||||
test('Compressed target_slot (-1) entries are hydrated to real IDs', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-compressed-target-slot'
|
||||
)
|
||||
|
||||
const widgets = await getPromotedWidgets(comfyPage, '2')
|
||||
expect(widgets.length).toBeGreaterThan(0)
|
||||
|
||||
for (const [interiorNodeId] of widgets) {
|
||||
expect(interiorNodeId).not.toBe('-1')
|
||||
expect(Number(interiorNodeId)).toBeGreaterThan(0)
|
||||
}
|
||||
|
||||
await expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
comfyPage,
|
||||
'2',
|
||||
widgets
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Legacy And Round-Trip Coverage', () => {
|
||||
let previousUseNewMenu: unknown
|
||||
|
||||
test.describe('Legacy prefixed proxyWidget normalization', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
previousUseNewMenu =
|
||||
await comfyPage.settings.getSetting('Comfy.UseNewMenu')
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.UseNewMenu',
|
||||
previousUseNewMenu
|
||||
)
|
||||
})
|
||||
|
||||
test('Legacy -1 proxyWidgets entries are hydrated to concrete interior node IDs', async ({
|
||||
test('Legacy-prefixed promoted widget renders with the normalized label after load', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-compressed-target-slot'
|
||||
)
|
||||
await comfyPage.workflow.loadWorkflow(LEGACY_PREFIXED_WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const promotedWidgets = await getPromotedWidgets(comfyPage, '2')
|
||||
expect(promotedWidgets.length).toBeGreaterThan(0)
|
||||
expect(
|
||||
promotedWidgets.some(([interiorNodeId]) => interiorNodeId === '-1')
|
||||
).toBe(false)
|
||||
expect(
|
||||
promotedWidgets.some(
|
||||
([interiorNodeId, widgetName]) =>
|
||||
interiorNodeId !== '-1' && widgetName === 'batch_size'
|
||||
)
|
||||
).toBe(true)
|
||||
const outerNode = comfyPage.vueNodes.getNodeLocator('5')
|
||||
await expect(outerNode).toBeVisible()
|
||||
|
||||
const textarea = outerNode
|
||||
.getByRole('textbox', { name: 'string_a' })
|
||||
.first()
|
||||
await expect(textarea).toBeVisible()
|
||||
await expect(textarea).toBeDisabled()
|
||||
})
|
||||
|
||||
test('Promoted widgets survive serialize -> loadGraphData round-trip', async ({
|
||||
test('Multiple instances of the same subgraph keep distinct promoted widget values after load and reload', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
const workflowName =
|
||||
'subgraphs/subgraph-multi-instance-promoted-text-values'
|
||||
const hostNodeIds = ['11', '12', '13']
|
||||
const expectedValues = ['Alpha\n', 'Beta\n', 'Gamma\n']
|
||||
|
||||
const beforePromoted = await getPromotedWidgetNames(comfyPage, '11')
|
||||
expect(beforePromoted).toContain('text')
|
||||
await comfyPage.workflow.loadWorkflow(workflowName)
|
||||
|
||||
const initialValues = await getPromotedHostWidgetValues(
|
||||
comfyPage,
|
||||
hostNodeIds
|
||||
)
|
||||
expect(initialValues.map(({ values }) => values[0])).toEqual(
|
||||
expectedValues
|
||||
)
|
||||
|
||||
await comfyPage.subgraph.serializeAndReload()
|
||||
|
||||
const afterPromoted = await getPromotedWidgetNames(comfyPage, '11')
|
||||
expect(afterPromoted).toContain('text')
|
||||
|
||||
const widgetCount = await getPromotedWidgetCount(comfyPage, '11')
|
||||
expect(widgetCount).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('Multi-link input representative stays stable through save/reload', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-multiple-promoted-widgets'
|
||||
const reloadedValues = await getPromotedHostWidgetValues(
|
||||
comfyPage,
|
||||
hostNodeIds
|
||||
)
|
||||
|
||||
const beforeSnapshot = await getPromotedWidgets(comfyPage, '11')
|
||||
expect(beforeSnapshot.length).toBeGreaterThan(0)
|
||||
|
||||
await comfyPage.subgraph.serializeAndReload()
|
||||
|
||||
const afterSnapshot = await getPromotedWidgets(comfyPage, '11')
|
||||
expect(afterSnapshot).toEqual(beforeSnapshot)
|
||||
})
|
||||
|
||||
test('Cloning a subgraph node keeps promoted widget entries on original and clone', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
expect(reloadedValues.map(({ values }) => values[0])).toEqual(
|
||||
expectedValues
|
||||
)
|
||||
|
||||
const originalNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
const originalPos = await originalNode.getPosition()
|
||||
|
||||
await comfyPage.page.mouse.move(originalPos.x + 16, originalPos.y + 16)
|
||||
await comfyPage.page.keyboard.down('Alt')
|
||||
try {
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.page.mouse.move(originalPos.x + 72, originalPos.y + 72)
|
||||
await comfyPage.page.mouse.up()
|
||||
} finally {
|
||||
await comfyPage.page.keyboard.up('Alt')
|
||||
}
|
||||
|
||||
async function collectSubgraphNodeIds() {
|
||||
return comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
return graph.nodes
|
||||
.filter(
|
||||
(n) =>
|
||||
typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
|
||||
)
|
||||
.map((n) => String(n.id))
|
||||
})
|
||||
}
|
||||
|
||||
await expect
|
||||
.poll(async () => (await collectSubgraphNodeIds()).length)
|
||||
.toBeGreaterThan(1)
|
||||
|
||||
const subgraphNodeIds = await collectSubgraphNodeIds()
|
||||
for (const nodeId of subgraphNodeIds) {
|
||||
const promotedWidgets = await getPromotedWidgets(comfyPage, nodeId)
|
||||
expect(promotedWidgets.length).toBeGreaterThan(0)
|
||||
expect(
|
||||
promotedWidgets.some(([, widgetName]) => widgetName === 'text')
|
||||
).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Duplicate ID Remapping', () => {
|
||||
test('All node IDs are globally unique after loading', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(DUPLICATE_IDS_WORKFLOW)
|
||||
|
||||
const result = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
const allGraphs = [graph, ...graph.subgraphs.values()]
|
||||
const allIds = allGraphs
|
||||
.flatMap((g) => g._nodes)
|
||||
.map((n) => n.id)
|
||||
.filter((id): id is number => typeof id === 'number')
|
||||
|
||||
return { allIds, uniqueCount: new Set(allIds).size }
|
||||
})
|
||||
|
||||
expect(result.uniqueCount).toBe(result.allIds.length)
|
||||
expect(result.allIds.length).toBeGreaterThanOrEqual(10)
|
||||
})
|
||||
|
||||
test('Root graph node IDs are preserved as canonical', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(DUPLICATE_IDS_WORKFLOW)
|
||||
|
||||
const rootIds = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
return graph._nodes
|
||||
.map((n) => n.id)
|
||||
.filter((id): id is number => typeof id === 'number')
|
||||
.sort((a, b) => a - b)
|
||||
})
|
||||
|
||||
expect(rootIds).toEqual([1, 2, 5])
|
||||
})
|
||||
|
||||
test('Promoted widget tuples are stable after full page reload boot path', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(DUPLICATE_IDS_WORKFLOW)
|
||||
|
||||
const beforeSnapshot =
|
||||
await comfyPage.subgraph.getHostPromotedTupleSnapshot()
|
||||
expect(beforeSnapshot.length).toBeGreaterThan(0)
|
||||
expect(
|
||||
beforeSnapshot.some(({ promotedWidgets }) => promotedWidgets.length > 0)
|
||||
).toBe(true)
|
||||
|
||||
await comfyPage.page.reload()
|
||||
await comfyPage.page.waitForFunction(() => !!window.app)
|
||||
await comfyPage.workflow.loadWorkflow(DUPLICATE_IDS_WORKFLOW)
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.subgraph.getHostPromotedTupleSnapshot(), {
|
||||
timeout: 5_000
|
||||
})
|
||||
.toEqual(beforeSnapshot)
|
||||
})
|
||||
|
||||
test('All links reference valid nodes in their graph', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(DUPLICATE_IDS_WORKFLOW)
|
||||
|
||||
const invalidLinks = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
const labeledGraphs: [string, typeof graph][] = [
|
||||
['root', graph],
|
||||
...[...graph.subgraphs.entries()].map(
|
||||
([id, sg]) => [`subgraph:${id}`, sg] as [string, typeof graph]
|
||||
)
|
||||
]
|
||||
|
||||
const SENTINEL_IDS = new Set([-1, -10, -20])
|
||||
const isSentinelNodeId = (id: number | string): id is number =>
|
||||
typeof id === 'number' && SENTINEL_IDS.has(id)
|
||||
|
||||
const checkEndpoint = (
|
||||
label: string,
|
||||
kind: 'origin_id' | 'target_id',
|
||||
id: number | string,
|
||||
g: typeof graph
|
||||
): string | null => {
|
||||
if (isSentinelNodeId(id)) return null
|
||||
if (typeof id !== 'number' || !g._nodes_by_id[id]) {
|
||||
return `${label}: ${kind} ${id} invalid or not found`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
return labeledGraphs.flatMap(([label, g]) =>
|
||||
[...g._links.values()].flatMap((link) =>
|
||||
[
|
||||
checkEndpoint(label, 'origin_id', link.origin_id, g),
|
||||
checkEndpoint(label, 'target_id', link.target_id, g)
|
||||
].filter((e): e is string => e !== null)
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
expect(invalidLinks).toEqual([])
|
||||
})
|
||||
|
||||
test('Subgraph navigation works after ID remapping', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(DUPLICATE_IDS_WORKFLOW)
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('5')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
|
||||
await comfyPage.keyboard.press('Escape')
|
||||
|
||||
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Regression test for legacy-prefixed proxyWidget normalization.
|
||||
*
|
||||
* Older serialized workflows stored proxyWidget entries with prefixed widget
|
||||
* names like "6: 3: string_a" instead of plain "string_a". This caused
|
||||
* resolution failures during configure, resulting in missing promoted widgets.
|
||||
*
|
||||
* The fixture contains an outer SubgraphNode (id 5) whose proxyWidgets array
|
||||
* has a legacy-prefixed entry: ["6", "6: 3: string_a"]. After normalization
|
||||
* the promoted widget should render with the clean name "string_a".
|
||||
*
|
||||
* See: https://github.com/Comfy-Org/ComfyUI_frontend/pull/10573
|
||||
*/
|
||||
test.describe(
|
||||
'Legacy Prefixed proxyWidget Normalization',
|
||||
{ tag: ['@subgraph', '@widget'] },
|
||||
() => {
|
||||
let previousVueNodesEnabled: unknown
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
previousVueNodesEnabled = await comfyPage.settings.getSetting(
|
||||
'Comfy.VueNodes.Enabled'
|
||||
)
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.VueNodes.Enabled',
|
||||
previousVueNodesEnabled
|
||||
)
|
||||
})
|
||||
|
||||
test('Loads without console warnings about failed widget resolution', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { warnings, dispose } = SubgraphHelper.collectConsoleWarnings(
|
||||
comfyPage.page
|
||||
)
|
||||
|
||||
try {
|
||||
await comfyPage.workflow.loadWorkflow(LEGACY_PREFIXED_WORKFLOW)
|
||||
|
||||
comfyExpect(warnings).toEqual([])
|
||||
} finally {
|
||||
dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test('Legacy-prefixed promoted widget renders with the normalized label after load', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(LEGACY_PREFIXED_WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const outerNode = comfyPage.vueNodes.getNodeLocator('5')
|
||||
await expect(outerNode).toBeVisible()
|
||||
|
||||
const textarea = outerNode
|
||||
.getByRole('textbox', { name: 'string_a' })
|
||||
.first()
|
||||
await expect(textarea).toBeVisible()
|
||||
await expect(textarea).toBeDisabled()
|
||||
})
|
||||
|
||||
test('No legacy-prefixed or disconnected widgets remain on the node', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(LEGACY_PREFIXED_WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const outerNode = comfyPage.vueNodes.getNodeLocator('5')
|
||||
await expect(outerNode).toBeVisible()
|
||||
|
||||
const widgetRows = outerNode.getByTestId(TestIds.widgets.widget)
|
||||
await expect(widgetRows).toHaveCount(2)
|
||||
|
||||
for (const row of await widgetRows.all()) {
|
||||
await expect(
|
||||
row.getByLabel('string_a', { exact: true })
|
||||
).toBeVisible()
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
test('Multiple instances of the same subgraph keep distinct promoted widget values after load and reload', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const hostNodeIds = ['11', '12', '13']
|
||||
const expectedValues = ['Alpha\n', 'Beta\n', 'Gamma\n']
|
||||
|
||||
await comfyPage.workflow.loadWorkflow(MULTI_INSTANCE_WORKFLOW)
|
||||
|
||||
const initialValues = await getPromotedHostWidgetValues(
|
||||
comfyPage,
|
||||
hostNodeIds
|
||||
)
|
||||
expect(initialValues.map(({ values }) => values[0])).toEqual(expectedValues)
|
||||
|
||||
await comfyPage.subgraph.serializeAndReload()
|
||||
|
||||
const reloadedValues = await getPromotedHostWidgetValues(
|
||||
comfyPage,
|
||||
hostNodeIds
|
||||
)
|
||||
expect(reloadedValues.map(({ values }) => values[0])).toEqual(
|
||||
expectedValues
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -285,12 +285,9 @@ export default defineConfig([
|
||||
'Use vi.mock() with vi.hoisted() instead of vi.doMock(). See docs/testing/vitest-patterns.md'
|
||||
}
|
||||
],
|
||||
// Tests routinely define stub and harness components side-by-side with
|
||||
// the system under test and stub emits for documentation only — these
|
||||
// production-SFC rules are noise in a test file.
|
||||
'vue/one-component-per-file': 'off',
|
||||
'vue/no-reserved-component-names': 'off',
|
||||
'vue/no-unused-emit-declarations': 'off'
|
||||
// Tests routinely define stub and harness components side-by-side with the
|
||||
// system under test, which is a distinct use case from production SFCs.
|
||||
'vue/one-component-per-file': 'off'
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -17,9 +17,6 @@ const config: KnipConfig = {
|
||||
entry: ['src/i18n.ts'],
|
||||
project: ['src/**/*.{js,ts,vue}']
|
||||
},
|
||||
'packages/design-system': {
|
||||
project: ['src/**/*.{css,js,ts}']
|
||||
},
|
||||
'packages/tailwind-utils': {
|
||||
project: ['src/**/*.{js,ts}']
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.44.12",
|
||||
"version": "1.44.11",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
@@ -589,6 +589,8 @@
|
||||
background-color: color-mix(in srgb, currentColor 20%, transparent);
|
||||
font-weight: 700;
|
||||
border-radius: 0.25rem;
|
||||
padding: 0 0.125rem;
|
||||
margin: -0.125rem 0.125rem;
|
||||
}
|
||||
|
||||
@utility scrollbar-hide {
|
||||
|
||||
4
packages/ingest-types/src/types.gen.ts
generated
4
packages/ingest-types/src/types.gen.ts
generated
@@ -3825,14 +3825,14 @@ export type CreateAssetExportData = {
|
||||
/**
|
||||
* Strategy for naming files in the ZIP:
|
||||
* - group_by_job_id: Group assets by job ID as a parent directory (e.g., "833a1b5c-beab-436a-ae8e-f07e7cd7b2c4/ComfyUI_00001_.png")
|
||||
* - group_by_job_time: Group assets by job execution time as parent directories
|
||||
* - prepend_job_id: Prepend job ID to filenames for uniqueness (e.g., "833a1b5c-beab-436a-ae8e-f07e7cd7b2c4_ComfyUI_00001_.png")
|
||||
* - preserve: Use original asset names, skip duplicates (first one wins)
|
||||
* - asset_id: Use the asset ID as the filename (e.g., "833a1b5c-beab-436a-ae8e-f07e7cd7b2c4.png")
|
||||
*
|
||||
*/
|
||||
naming_strategy?:
|
||||
| 'group_by_job_id'
|
||||
| 'group_by_job_time'
|
||||
| 'prepend_job_id'
|
||||
| 'preserve'
|
||||
| 'asset_id'
|
||||
/**
|
||||
|
||||
2
packages/ingest-types/src/zod.gen.ts
generated
2
packages/ingest-types/src/zod.gen.ts
generated
@@ -1818,7 +1818,7 @@ export const zCreateAssetExportData = z.object({
|
||||
job_ids: z.array(z.string()).optional(),
|
||||
asset_ids: z.array(z.string()).optional(),
|
||||
naming_strategy: z
|
||||
.enum(['group_by_job_id', 'group_by_job_time', 'preserve', 'asset_id'])
|
||||
.enum(['group_by_job_id', 'prepend_job_id', 'preserve', 'asset_id'])
|
||||
.optional(),
|
||||
job_asset_name_filters: z.record(z.array(z.string()).min(1)).optional()
|
||||
}),
|
||||
|
||||
@@ -14495,7 +14495,7 @@ export interface components {
|
||||
* @description The ID of the model to call
|
||||
* @enum {string}
|
||||
*/
|
||||
model: "wan2.5-t2v-preview" | "wan2.5-i2v-preview" | "wan2.6-t2v" | "wan2.6-i2v" | "wan2.6-r2v" | "wan2.7-i2v" | "wan2.7-t2v" | "wan2.7-r2v" | "wan2.7-videoedit" | "happyhorse-1.0-t2v" | "happyhorse-1.0-i2v" | "happyhorse-1.0-r2v" | "happyhorse-1.0-video-edit";
|
||||
model: "wan2.5-t2v-preview" | "wan2.5-i2v-preview" | "wan2.6-t2v" | "wan2.6-i2v" | "wan2.6-r2v" | "wan2.7-i2v" | "wan2.7-t2v" | "wan2.7-r2v" | "wan2.7-videoedit";
|
||||
/** @description Enter basic information, such as prompt words, etc. */
|
||||
input: {
|
||||
/**
|
||||
|
||||
@@ -202,28 +202,6 @@ describe('formatUtil', () => {
|
||||
'<span class="highlight">foo</span> bar <span class="highlight">foo</span>'
|
||||
)
|
||||
})
|
||||
|
||||
it('should highlight cross-word matches', () => {
|
||||
const result = highlightQuery('convert image to mask', 'geto', false)
|
||||
expect(result).toBe(
|
||||
'convert ima<span class="highlight">ge to</span> mask'
|
||||
)
|
||||
})
|
||||
|
||||
it('should not match across line breaks', () => {
|
||||
const result = highlightQuery('ge\nto', 'geto', false)
|
||||
expect(result).toBe('ge\nto')
|
||||
})
|
||||
|
||||
it('should not match across tabs', () => {
|
||||
const result = highlightQuery('ge\tto', 'geto', false)
|
||||
expect(result).toBe('ge\tto')
|
||||
})
|
||||
|
||||
it('should not match across multiple spaces', () => {
|
||||
const result = highlightQuery('ge to', 'geto', false)
|
||||
expect(result).toBe('ge to')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getFilenameDetails', () => {
|
||||
|
||||
@@ -74,14 +74,10 @@ export function highlightQuery(
|
||||
text = DOMPurify.sanitize(text)
|
||||
}
|
||||
|
||||
// Escape special regex characters, then join with an optional single
|
||||
// space so cross-word matches (e.g. "geto" → "imaGE TO") are
|
||||
// highlighted without spanning tabs, newlines, or multi-space gaps.
|
||||
const pattern = Array.from(query)
|
||||
.map((ch) => ch.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
||||
.join('[ ]?')
|
||||
// Escape special regex characters in the query string
|
||||
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
|
||||
const regex = new RegExp(`(${pattern})`, 'gi')
|
||||
const regex = new RegExp(`(${escapedQuery})`, 'gi')
|
||||
return text.replace(regex, '<span class="highlight">$1</span>')
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
import { clsx } from 'clsx'
|
||||
import type { ClassArray } from 'clsx'
|
||||
import { extendTailwindMerge } from 'tailwind-merge'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export type { ClassValue } from 'clsx'
|
||||
|
||||
const twMerge = extendTailwindMerge({
|
||||
extend: {
|
||||
classGroups: {
|
||||
'font-size': ['text-xxs', 'text-xxxs']
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export function cn(...inputs: ClassArray) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ const ScrubableNumberInputStub = defineComponent({
|
||||
step: { type: Number, default: 1 },
|
||||
disabled: { type: Boolean, default: false }
|
||||
},
|
||||
|
||||
// eslint-disable-next-line vue/no-unused-emit-declarations
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<input
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
/* eslint-disable vue/no-reserved-component-names */
|
||||
/* eslint-disable vue/no-unused-emit-declarations */
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable vue/no-reserved-component-names */
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -99,7 +100,7 @@ const WidgetBoundingBoxStub = defineComponent({
|
||||
modelValue: { type: Object, default: () => ({}) },
|
||||
disabled: { type: Boolean, default: false }
|
||||
},
|
||||
|
||||
// eslint-disable-next-line vue/no-unused-emit-declarations
|
||||
emits: ['update:modelValue'],
|
||||
template: `<div data-testid="bbox-child"
|
||||
:data-disabled="String(disabled)"
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
<template>
|
||||
<span
|
||||
:class="
|
||||
cn(
|
||||
'flex h-5 shrink-0 items-center bg-component-node-widget-background p-1 text-xs',
|
||||
rest ? 'rounded-l-full pr-1' : 'rounded-full'
|
||||
)
|
||||
"
|
||||
>
|
||||
<i class="icon-[lucide--component] h-full bg-amber-400" />
|
||||
<span class="truncate" v-text="text" />
|
||||
</span>
|
||||
<span
|
||||
v-if="rest"
|
||||
class="-ml-2.5 max-w-max min-w-0 grow basis-0 truncate rounded-r-full bg-component-node-widget-background"
|
||||
>
|
||||
<span class="pr-2" v-text="rest" />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
defineProps<{
|
||||
text: string
|
||||
rest?: string
|
||||
}>()
|
||||
</script>
|
||||
@@ -1,14 +1,9 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col overflow-hidden rounded-lg border border-border-default bg-base-background"
|
||||
:style="{ width: `${BASE_WIDTH_PX * (scaleFactor / BASE_SCALE)}px` }"
|
||||
class="flex w-50 flex-col overflow-hidden rounded-2xl border border-border-default bg-base-background"
|
||||
>
|
||||
<div ref="previewContainerRef" class="overflow-hidden p-3">
|
||||
<div
|
||||
ref="previewWrapperRef"
|
||||
class="origin-top-left"
|
||||
:style="{ transform: `scale(${scaleFactor})` }"
|
||||
>
|
||||
<div ref="previewWrapperRef" class="origin-top-left scale-50">
|
||||
<LGraphNodePreview :node-def="nodeDef" position="relative" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -23,21 +18,21 @@
|
||||
<!-- Category Path -->
|
||||
<p
|
||||
v-if="showCategoryPath && nodeDef.category"
|
||||
class="-mt-1 truncate text-xs text-muted-foreground"
|
||||
class="-mt-1 text-xs text-muted-foreground"
|
||||
>
|
||||
{{ categoryPath }}
|
||||
{{ nodeDef.category.replaceAll('/', ' > ') }}
|
||||
</p>
|
||||
|
||||
<!-- Badges -->
|
||||
<div class="flex flex-wrap gap-2 overflow-hidden empty:hidden">
|
||||
<NodePricingBadge class="max-w-full truncate" :node-def="nodeDef" />
|
||||
<div class="flex flex-wrap gap-2 empty:hidden">
|
||||
<NodePricingBadge :node-def="nodeDef" />
|
||||
<NodeProviderBadge :node-def="nodeDef" />
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<p
|
||||
v-if="nodeDef.description"
|
||||
class="m-0 max-h-[30vh] overflow-y-auto text-2xs/normal font-normal text-muted-foreground"
|
||||
class="m-0 text-2xs/normal font-normal text-muted-foreground"
|
||||
>
|
||||
{{ nodeDef.description }}
|
||||
</p>
|
||||
@@ -104,20 +99,17 @@ import NodeProviderBadge from '@/components/node/NodeProviderBadge.vue'
|
||||
import LGraphNodePreview from '@/renderer/extensions/vueNodes/components/LGraphNodePreview.vue'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
|
||||
const BASE_WIDTH_PX = 200
|
||||
const BASE_SCALE = 0.5
|
||||
const SCALE_FACTOR = 0.5
|
||||
const PREVIEW_CONTAINER_PADDING_PX = 24
|
||||
|
||||
const {
|
||||
nodeDef,
|
||||
showInputsAndOutputs = true,
|
||||
showCategoryPath = false,
|
||||
scaleFactor = 0.5
|
||||
showCategoryPath = false
|
||||
} = defineProps<{
|
||||
nodeDef: ComfyNodeDefImpl
|
||||
showInputsAndOutputs?: boolean
|
||||
showCategoryPath?: boolean
|
||||
scaleFactor?: number
|
||||
}>()
|
||||
|
||||
const previewContainerRef = ref<HTMLElement>()
|
||||
@@ -126,13 +118,11 @@ const previewWrapperRef = ref<HTMLElement>()
|
||||
useResizeObserver(previewWrapperRef, (entries) => {
|
||||
const entry = entries[0]
|
||||
if (entry && previewContainerRef.value) {
|
||||
const scaledHeight = entry.contentRect.height * scaleFactor
|
||||
const scaledHeight = entry.contentRect.height * SCALE_FACTOR
|
||||
previewContainerRef.value.style.height = `${scaledHeight + PREVIEW_CONTAINER_PADDING_PX}px`
|
||||
}
|
||||
})
|
||||
|
||||
const categoryPath = computed(() => nodeDef.category?.replaceAll('/', ' / '))
|
||||
|
||||
const inputs = computed(() => {
|
||||
if (!nodeDef.inputs) return []
|
||||
return Object.entries(nodeDef.inputs)
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
<template>
|
||||
<span v-if="nodeDef.api_node && priceLabel">
|
||||
<CreditBadge :text="priceLabel" />
|
||||
</span>
|
||||
<BadgePill
|
||||
v-if="nodeDef.api_node"
|
||||
v-show="priceLabel"
|
||||
:text="priceLabel"
|
||||
icon="icon-[comfy--credits]"
|
||||
border-style="#f59e0b"
|
||||
filled
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import CreditBadge from '@/components/node/CreditBadge.vue'
|
||||
import BadgePill from '@/components/common/BadgePill.vue'
|
||||
import { evaluateNodeDefPricing } from '@/composables/node/useNodePricing'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ const RangeEditorStub = defineComponent({
|
||||
histogram: { type: Object, default: null },
|
||||
display: { type: String, default: '' }
|
||||
},
|
||||
|
||||
// eslint-disable-next-line vue/no-unused-emit-declarations
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<div data-testid="range-editor"
|
||||
|
||||
@@ -5,8 +5,6 @@ import PrimeVue from 'primevue/config'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import TabErrors from './TabErrors.vue'
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import type { MissingModelCandidate } from '@/platform/missingModel/types'
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
@@ -52,12 +50,6 @@ describe('TabErrors.vue', () => {
|
||||
rightSidePanel: {
|
||||
noErrors: 'No errors',
|
||||
noneSearchDesc: 'No results found',
|
||||
missingModels: {
|
||||
missingModelsTitle: 'Missing Models',
|
||||
downloadAll: 'Download all',
|
||||
refresh: 'Refresh',
|
||||
refreshing: 'Refreshing missing models.'
|
||||
},
|
||||
promptErrors: {
|
||||
prompt_no_outputs: {
|
||||
desc: 'Prompt has no outputs'
|
||||
@@ -90,7 +82,7 @@ describe('TabErrors.vue', () => {
|
||||
template: '<div><slot name="label" /><slot /></div>'
|
||||
},
|
||||
Button: {
|
||||
template: '<button v-bind="$attrs"><slot /></button>'
|
||||
template: '<button><slot /></button>'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -249,58 +241,4 @@ describe('TabErrors.vue', () => {
|
||||
expect(screen.getByTestId('runtime-error-panel')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('RuntimeError: Out of memory')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('shows missing model Refresh in the section header when no model is downloadable', async () => {
|
||||
const missingModel = {
|
||||
nodeId: '1',
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
widgetName: 'ckpt_name',
|
||||
name: 'local-only.safetensors',
|
||||
directory: 'checkpoints',
|
||||
isMissing: true,
|
||||
isAssetSupported: true
|
||||
} satisfies MissingModelCandidate
|
||||
|
||||
const { user } = renderComponent({
|
||||
missingModel: {
|
||||
missingModelCandidates: [missingModel]
|
||||
}
|
||||
})
|
||||
const missingModelStore = useMissingModelStore()
|
||||
|
||||
expect(screen.getByText('Missing Models (1)')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByTestId('missing-model-actions')
|
||||
).not.toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByTestId('missing-model-header-refresh'))
|
||||
|
||||
expect(missingModelStore.refreshMissingModels).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps missing model Refresh in the card actions when models are downloadable', () => {
|
||||
const missingModel = {
|
||||
nodeId: '1',
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
widgetName: 'ckpt_name',
|
||||
name: 'downloadable.safetensors',
|
||||
url: 'https://huggingface.co/comfy/test/resolve/main/downloadable.safetensors',
|
||||
directory: 'checkpoints',
|
||||
isMissing: true,
|
||||
isAssetSupported: true
|
||||
} satisfies MissingModelCandidate
|
||||
|
||||
renderComponent({
|
||||
missingModel: {
|
||||
missingModelCandidates: [missingModel]
|
||||
}
|
||||
})
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('missing-model-header-refresh')
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('missing-model-actions')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /Download all/ })).toBeVisible()
|
||||
expect(screen.getByRole('button', { name: 'Refresh' })).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -101,6 +101,18 @@
|
||||
: t('rightSidePanel.missingNodePacks.installAll')
|
||||
}}
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="
|
||||
group.type === 'missing_model' &&
|
||||
downloadableModels.length > 1
|
||||
"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="mr-2 h-8 shrink-0 rounded-lg text-sm"
|
||||
@click.stop="downloadAllModels"
|
||||
>
|
||||
{{ downloadAllLabel }}
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="group.type === 'swap_nodes'"
|
||||
v-tooltip.top="
|
||||
@@ -116,47 +128,6 @@
|
||||
>
|
||||
{{ t('nodeReplacement.replaceAll', 'Replace All') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="
|
||||
group.type === 'missing_model' &&
|
||||
showMissingModelHeaderRefresh
|
||||
"
|
||||
data-testid="missing-model-header-refresh"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="mr-2 h-8 shrink-0 rounded-lg text-sm"
|
||||
:aria-busy="missingModelStore.isRefreshingMissingModels"
|
||||
:aria-disabled="missingModelStore.isRefreshingMissingModels"
|
||||
@click.stop="handleMissingModelRefresh"
|
||||
>
|
||||
<DotSpinner
|
||||
v-if="missingModelStore.isRefreshingMissingModels"
|
||||
aria-hidden="true"
|
||||
duration="1s"
|
||||
:size="12"
|
||||
/>
|
||||
<i
|
||||
v-else
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--refresh-cw] size-4 shrink-0"
|
||||
/>
|
||||
{{ t('rightSidePanel.missingModels.refresh') }}
|
||||
</Button>
|
||||
<span
|
||||
v-if="
|
||||
group.type === 'missing_model' &&
|
||||
showMissingModelHeaderRefresh
|
||||
"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
class="sr-only"
|
||||
>
|
||||
{{
|
||||
missingModelStore.isRefreshingMissingModels
|
||||
? t('rightSidePanel.missingModels.refreshing')
|
||||
: ''
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -267,10 +238,14 @@ import SwapNodesCard from '@/platform/nodeReplacement/components/SwapNodesCard.v
|
||||
import MissingModelCard from '@/platform/missingModel/components/MissingModelCard.vue'
|
||||
import MissingMediaCard from '@/platform/missingMedia/components/MissingMediaCard.vue'
|
||||
import { isCloud, isDesktop, isNightly } from '@/platform/distribution/types'
|
||||
import {
|
||||
downloadModel,
|
||||
isModelDownloadable
|
||||
} from '@/platform/missingModel/missingModelDownload'
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { formatSize } from '@/utils/formatUtil'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import DotSpinner from '@/components/common/DotSpinner.vue'
|
||||
import { getDownloadableModels } from '@/platform/missingModel/missingModelViewUtils'
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { usePackInstall } from '@/workbench/extensions/manager/composables/nodePack/usePackInstall'
|
||||
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
|
||||
import { useErrorActions } from './useErrorActions'
|
||||
@@ -292,7 +267,6 @@ const { focusNode, enterSubgraph } = useFocusNode()
|
||||
const { openGitHubIssues, contactSupport } = useErrorActions()
|
||||
const settingStore = useSettingStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const missingModelStore = useMissingModelStore()
|
||||
const { shouldShowManagerButtons, shouldShowInstallButton, openManager } =
|
||||
useManagerState()
|
||||
const { missingNodePacks } = useMissingNodes()
|
||||
@@ -333,23 +307,6 @@ const {
|
||||
swapNodeGroups
|
||||
} = useErrorGroups(searchQuery, t)
|
||||
|
||||
const missingModelDownloadableModels = computed(() => {
|
||||
if (isCloud) return []
|
||||
|
||||
return getDownloadableModels(missingModelGroups.value)
|
||||
})
|
||||
|
||||
const showMissingModelHeaderRefresh = computed(
|
||||
() =>
|
||||
!isCloud &&
|
||||
missingModelGroups.value.length > 0 &&
|
||||
missingModelDownloadableModels.value.length === 0
|
||||
)
|
||||
|
||||
function handleMissingModelRefresh() {
|
||||
void missingModelStore.refreshMissingModels()
|
||||
}
|
||||
|
||||
const singleRuntimeErrorGroup = computed(() => {
|
||||
if (filteredGroups.value.length !== 1) return null
|
||||
const group = filteredGroups.value[0]
|
||||
@@ -364,6 +321,45 @@ const singleRuntimeErrorCard = computed(
|
||||
() => singleRuntimeErrorGroup.value?.cards[0] ?? null
|
||||
)
|
||||
|
||||
const missingModelStore = useMissingModelStore()
|
||||
|
||||
const downloadableModels = computed(() => {
|
||||
if (isCloud) return []
|
||||
return missingModelGroups.value.flatMap((group) =>
|
||||
group.models
|
||||
.filter(
|
||||
(m) =>
|
||||
m.representative.url &&
|
||||
m.representative.directory &&
|
||||
isModelDownloadable({
|
||||
name: m.representative.name,
|
||||
url: m.representative.url,
|
||||
directory: m.representative.directory
|
||||
})
|
||||
)
|
||||
.map((m) => ({
|
||||
name: m.representative.name,
|
||||
url: m.representative.url!,
|
||||
directory: m.representative.directory!
|
||||
}))
|
||||
)
|
||||
})
|
||||
|
||||
const downloadAllLabel = computed(() => {
|
||||
const base = t('rightSidePanel.missingModels.downloadAll')
|
||||
const total = downloadableModels.value.reduce(
|
||||
(sum, m) => sum + (missingModelStore.fileSizes[m.url] ?? 0),
|
||||
0
|
||||
)
|
||||
return total > 0 ? `${base} (${formatSize(total)})` : base
|
||||
})
|
||||
|
||||
function downloadAllModels() {
|
||||
for (const model of downloadableModels.value) {
|
||||
downloadModel(model, missingModelStore.folderPaths)
|
||||
}
|
||||
}
|
||||
|
||||
const isAllCollapsed = computed({
|
||||
get() {
|
||||
return filteredGroups.value.every((g) => isSectionCollapsed(g.title))
|
||||
|
||||
@@ -8,17 +8,15 @@ import Button from '@/components/ui/button/Button.vue'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import {
|
||||
demoteWidget,
|
||||
getPromotableWidgets,
|
||||
getSourceNodeId,
|
||||
getWidgetName,
|
||||
isLinkedPromotion,
|
||||
isRecommendedWidget,
|
||||
promoteWidget,
|
||||
pruneDisconnected
|
||||
} from '@/core/graph/subgraph/promotionUtils'
|
||||
import type { WidgetItem } from '@/core/graph/subgraph/promotionPolicy'
|
||||
import {
|
||||
getPromotableWidgets,
|
||||
isRecommendedWidget
|
||||
} from '@/core/graph/subgraph/promotionPolicy'
|
||||
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'
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
:pt="{
|
||||
root: {
|
||||
class: useSearchBoxV2
|
||||
? 'w-full max-w-[56rem] min-w-[32rem] max-md:min-w-0 bg-transparent border-0 overflow-visible'
|
||||
? 'w-4/5 min-w-[32rem] max-w-[56rem] border-0 bg-transparent mt-[10vh] max-md:w-[95%] max-md:min-w-0 overflow-visible'
|
||||
: 'invisible-dialog-root'
|
||||
},
|
||||
mask: {
|
||||
@@ -36,9 +36,7 @@
|
||||
v-if="hoveredNodeDef && enableNodePreview"
|
||||
:key="hoveredNodeDef.name"
|
||||
:node-def="hoveredNodeDef"
|
||||
:scale-factor="0.625"
|
||||
show-category-path
|
||||
inert
|
||||
class="absolute top-0 left-full ml-3"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { render, screen, waitFor } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import NodeSearchCategorySidebar from '@/components/searchbox/v2/NodeSearchCategorySidebar.vue'
|
||||
import {
|
||||
@@ -10,12 +11,12 @@ import {
|
||||
} from '@/components/searchbox/v2/__test__/testUtils'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
|
||||
type SidebarProps = Partial<{
|
||||
selectedCategory: string
|
||||
hidePresets: boolean
|
||||
rootLabel: string
|
||||
rootKey: string
|
||||
}>
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: vi.fn(() => ({
|
||||
get: vi.fn(() => undefined),
|
||||
set: vi.fn()
|
||||
}))
|
||||
}))
|
||||
|
||||
describe('NodeSearchCategorySidebar', () => {
|
||||
beforeEach(() => {
|
||||
@@ -23,30 +24,35 @@ describe('NodeSearchCategorySidebar', () => {
|
||||
setupTestPinia()
|
||||
})
|
||||
|
||||
function createRender(props: SidebarProps = {}) {
|
||||
async function createRender(props = {}) {
|
||||
const user = userEvent.setup()
|
||||
const onUpdateSelectedCategory = vi.fn<(value: string) => void>()
|
||||
const initialProps: SidebarProps & { selectedCategory: string } = {
|
||||
selectedCategory: 'most-relevant',
|
||||
...props
|
||||
const onUpdateSelectedCategory = vi.fn()
|
||||
const baseProps = { selectedCategory: 'most-relevant', ...props }
|
||||
|
||||
let currentProps = { ...baseProps }
|
||||
let rerenderFn: (
|
||||
p: typeof baseProps & Record<string, unknown>
|
||||
) => void = () => {}
|
||||
|
||||
function makeProps(overrides = {}) {
|
||||
const merged = { ...currentProps, ...overrides }
|
||||
return {
|
||||
...merged,
|
||||
'onUpdate:selectedCategory': (val: string) => {
|
||||
onUpdateSelectedCategory(val)
|
||||
currentProps = { ...currentProps, selectedCategory: val }
|
||||
rerenderFn(makeProps())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result = render(NodeSearchCategorySidebar, {
|
||||
props: {
|
||||
...initialProps,
|
||||
'onUpdate:selectedCategory': onUpdateSelectedCategory
|
||||
},
|
||||
props: makeProps(),
|
||||
global: { plugins: [testI18n] }
|
||||
})
|
||||
|
||||
const rerender = (overrides: SidebarProps) =>
|
||||
result.rerender({
|
||||
...initialProps,
|
||||
...overrides,
|
||||
'onUpdate:selectedCategory': onUpdateSelectedCategory
|
||||
})
|
||||
|
||||
return { user, onUpdateSelectedCategory, rerender }
|
||||
rerenderFn = (p) => result.rerender(p)
|
||||
await nextTick()
|
||||
return { user, onUpdateSelectedCategory }
|
||||
}
|
||||
|
||||
async function clickCategory(
|
||||
@@ -54,26 +60,40 @@ describe('NodeSearchCategorySidebar', () => {
|
||||
text: string,
|
||||
exact = false
|
||||
) {
|
||||
const candidates = [
|
||||
...screen.queryAllByRole('button'),
|
||||
...screen.queryAllByRole('treeitem')
|
||||
]
|
||||
const btn = candidates.find((b) =>
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const btn = buttons.find((b) =>
|
||||
exact ? b.textContent?.trim() === text : b.textContent?.includes(text)
|
||||
)
|
||||
expect(btn, `Expected to find a button with text "${text}"`).toBeDefined()
|
||||
await user.click(btn!)
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
describe('preset categories', () => {
|
||||
it('should render Most relevant preset category', () => {
|
||||
createRender()
|
||||
it('should render all preset categories', async () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({
|
||||
name: 'EssentialNode',
|
||||
essentials_category: 'basic',
|
||||
python_module: 'comfy_essentials'
|
||||
})
|
||||
])
|
||||
await nextTick()
|
||||
|
||||
await createRender()
|
||||
|
||||
expect(screen.getByText('Most relevant')).toBeInTheDocument()
|
||||
expect(screen.getByText('Recents')).toBeInTheDocument()
|
||||
expect(screen.getByText('Favorites')).toBeInTheDocument()
|
||||
expect(screen.getByText('Essentials')).toBeInTheDocument()
|
||||
expect(screen.getByText('Blueprints')).toBeInTheDocument()
|
||||
expect(screen.getByText('Partner')).toBeInTheDocument()
|
||||
expect(screen.getByText('Comfy')).toBeInTheDocument()
|
||||
expect(screen.getByText('Extensions')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should mark the selected preset category as selected', () => {
|
||||
createRender({ selectedCategory: 'most-relevant' })
|
||||
it('should mark the selected preset category as selected', async () => {
|
||||
await createRender({ selectedCategory: 'most-relevant' })
|
||||
|
||||
expect(screen.getByTestId('category-most-relevant')).toHaveAttribute(
|
||||
'aria-current',
|
||||
@@ -82,30 +102,26 @@ describe('NodeSearchCategorySidebar', () => {
|
||||
})
|
||||
|
||||
it('should emit update:selectedCategory when preset is clicked', async () => {
|
||||
const { user, onUpdateSelectedCategory } = createRender({
|
||||
const { user, onUpdateSelectedCategory } = await createRender({
|
||||
selectedCategory: 'most-relevant'
|
||||
})
|
||||
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({ name: 'Node1', category: 'sampling' })
|
||||
])
|
||||
await clickCategory(user, 'Favorites')
|
||||
|
||||
await screen.findByText('sampling')
|
||||
await clickCategory(user, 'sampling')
|
||||
|
||||
expect(onUpdateSelectedCategory).toHaveBeenCalledWith('sampling')
|
||||
expect(onUpdateSelectedCategory).toHaveBeenCalledWith('favorites')
|
||||
})
|
||||
})
|
||||
|
||||
describe('category tree', () => {
|
||||
it('should render top-level categories from node definitions', () => {
|
||||
it('should render top-level categories from node definitions', async () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({ name: 'Node1', category: 'sampling' }),
|
||||
createMockNodeDef({ name: 'Node2', category: 'loaders' }),
|
||||
createMockNodeDef({ name: 'Node3', category: 'conditioning' })
|
||||
])
|
||||
await nextTick()
|
||||
|
||||
createRender()
|
||||
await createRender()
|
||||
|
||||
expect(screen.getByText('sampling')).toBeInTheDocument()
|
||||
expect(screen.getByText('loaders')).toBeInTheDocument()
|
||||
@@ -116,8 +132,9 @@ describe('NodeSearchCategorySidebar', () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({ name: 'Node1', category: 'sampling' })
|
||||
])
|
||||
await nextTick()
|
||||
|
||||
const { user, onUpdateSelectedCategory } = createRender()
|
||||
const { user, onUpdateSelectedCategory } = await createRender()
|
||||
|
||||
await clickCategory(user, 'sampling')
|
||||
|
||||
@@ -130,18 +147,20 @@ describe('NodeSearchCategorySidebar', () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({ name: 'Node1', category: 'sampling' }),
|
||||
createMockNodeDef({ name: 'Node2', category: 'sampling/advanced' }),
|
||||
createMockNodeDef({ name: 'Node3', category: 'sampling/basic' }),
|
||||
createMockNodeDef({ name: 'Node4', category: 'loaders' })
|
||||
createMockNodeDef({ name: 'Node3', category: 'sampling/basic' })
|
||||
])
|
||||
await nextTick()
|
||||
|
||||
const { user } = createRender()
|
||||
const { user } = await createRender()
|
||||
|
||||
expect(screen.queryByText('advanced')).not.toBeInTheDocument()
|
||||
|
||||
await clickCategory(user, 'sampling')
|
||||
|
||||
await screen.findByText('advanced')
|
||||
expect(screen.getByText('basic')).toBeInTheDocument()
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('advanced')).toBeInTheDocument()
|
||||
expect(screen.getByText('basic')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should collapse sibling category when another is expanded', async () => {
|
||||
@@ -151,12 +170,15 @@ describe('NodeSearchCategorySidebar', () => {
|
||||
createMockNodeDef({ name: 'Node3', category: 'image' }),
|
||||
createMockNodeDef({ name: 'Node4', category: 'image/upscale' })
|
||||
])
|
||||
await nextTick()
|
||||
|
||||
const { user } = createRender()
|
||||
const { user } = await createRender()
|
||||
|
||||
// Expand sampling
|
||||
await clickCategory(user, 'sampling', true)
|
||||
await screen.findByText('advanced')
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('advanced')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Expand image — sampling should collapse
|
||||
await clickCategory(user, 'image', true)
|
||||
@@ -170,15 +192,17 @@ describe('NodeSearchCategorySidebar', () => {
|
||||
it('should emit update:selectedCategory when subcategory is clicked', async () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({ name: 'Node1', category: 'sampling' }),
|
||||
createMockNodeDef({ name: 'Node2', category: 'sampling/advanced' }),
|
||||
createMockNodeDef({ name: 'Node3', category: 'loaders' })
|
||||
createMockNodeDef({ name: 'Node2', category: 'sampling/advanced' })
|
||||
])
|
||||
await nextTick()
|
||||
|
||||
const { user, onUpdateSelectedCategory } = createRender()
|
||||
const { user, onUpdateSelectedCategory } = await createRender()
|
||||
|
||||
// Expand sampling category
|
||||
await clickCategory(user, 'sampling', true)
|
||||
await screen.findByText('advanced')
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('advanced')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Click on advanced subcategory
|
||||
await clickCategory(user, 'advanced')
|
||||
@@ -189,12 +213,13 @@ describe('NodeSearchCategorySidebar', () => {
|
||||
})
|
||||
|
||||
describe('category selection highlighting', () => {
|
||||
it('should mark selected top-level category as selected', () => {
|
||||
it('should mark selected top-level category as selected', async () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({ name: 'Node1', category: 'sampling' })
|
||||
])
|
||||
await nextTick()
|
||||
|
||||
createRender({ selectedCategory: 'sampling' })
|
||||
await createRender({ selectedCategory: 'sampling' })
|
||||
|
||||
expect(screen.getByTestId('category-sampling')).toHaveAttribute(
|
||||
'aria-current',
|
||||
@@ -205,17 +230,19 @@ describe('NodeSearchCategorySidebar', () => {
|
||||
it('should emit selected subcategory when expanded', async () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({ name: 'Node1', category: 'sampling' }),
|
||||
createMockNodeDef({ name: 'Node2', category: 'sampling/advanced' }),
|
||||
createMockNodeDef({ name: 'Node3', category: 'loaders' })
|
||||
createMockNodeDef({ name: 'Node2', category: 'sampling/advanced' })
|
||||
])
|
||||
await nextTick()
|
||||
|
||||
const { user, onUpdateSelectedCategory } = createRender({
|
||||
const { user, onUpdateSelectedCategory } = await createRender({
|
||||
selectedCategory: 'most-relevant'
|
||||
})
|
||||
|
||||
// Expand and click subcategory
|
||||
await clickCategory(user, 'sampling', true)
|
||||
await screen.findByText('advanced')
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('advanced')).toBeInTheDocument()
|
||||
})
|
||||
await clickCategory(user, 'advanced')
|
||||
|
||||
const calls = onUpdateSelectedCategory.mock.calls
|
||||
@@ -223,121 +250,15 @@ describe('NodeSearchCategorySidebar', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('hidePresets prop', () => {
|
||||
it('should hide preset categories when hidePresets is true', () => {
|
||||
createRender({ hidePresets: true })
|
||||
|
||||
expect(screen.queryByText('Most relevant')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should emit category without root/ prefix', async () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({ name: 'Node1', category: 'sampling' })
|
||||
])
|
||||
|
||||
const { user, onUpdateSelectedCategory } = createRender()
|
||||
|
||||
await clickCategory(user, 'sampling')
|
||||
|
||||
expect(onUpdateSelectedCategory).toHaveBeenCalledWith('sampling')
|
||||
})
|
||||
|
||||
describe('rootLabel wrapping', () => {
|
||||
it('should wrap multiple top-level categories under rootLabel key', async () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({ name: 'N1', category: 'sampling' }),
|
||||
createMockNodeDef({ name: 'N2', category: 'loaders' })
|
||||
])
|
||||
|
||||
const { user, onUpdateSelectedCategory } = createRender({
|
||||
rootLabel: 'Extensions',
|
||||
rootKey: 'custom'
|
||||
})
|
||||
|
||||
expect(screen.getByText('Extensions')).toBeInTheDocument()
|
||||
|
||||
// Expand the wrapper root
|
||||
const customBtn = screen.getByTestId('category-custom')
|
||||
expect(customBtn).toBeInTheDocument()
|
||||
await user.click(customBtn)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('sampling')).toBeInTheDocument()
|
||||
expect(screen.getByText('loaders')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Subcategories should be prefixed with the root key
|
||||
expect(screen.getByTestId('category-custom/sampling')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByTestId('category-custom/sampling'))
|
||||
const calls = onUpdateSelectedCategory.mock.calls
|
||||
expect(calls[calls.length - 1]).toEqual(['custom/sampling'])
|
||||
})
|
||||
|
||||
it('should derive root key from rootLabel when rootKey is not provided', async () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({ name: 'N1', category: 'sampling' }),
|
||||
createMockNodeDef({ name: 'N2', category: 'loaders' })
|
||||
])
|
||||
|
||||
const { user, onUpdateSelectedCategory } = createRender({
|
||||
rootLabel: 'Custom'
|
||||
})
|
||||
|
||||
await user.click(screen.getByTestId('category-custom'))
|
||||
await user.click(await screen.findByTestId('category-custom/sampling'))
|
||||
|
||||
const calls = onUpdateSelectedCategory.mock.calls
|
||||
expect(calls[calls.length - 1]).toEqual(['custom/sampling'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('external selectedCategory updates', () => {
|
||||
it('should update expanded state when selectedCategory changes externally', async () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({ name: 'Node1', category: 'sampling' }),
|
||||
createMockNodeDef({ name: 'Node2', category: 'sampling/advanced' }),
|
||||
createMockNodeDef({ name: 'Node3', category: 'loaders' })
|
||||
])
|
||||
|
||||
const { rerender } = createRender({
|
||||
selectedCategory: 'most-relevant'
|
||||
})
|
||||
|
||||
expect(screen.queryByText('advanced')).not.toBeInTheDocument()
|
||||
|
||||
await rerender({ selectedCategory: 'sampling' })
|
||||
|
||||
await screen.findByText('advanced')
|
||||
})
|
||||
})
|
||||
|
||||
it('should emit autoExpand when there is a single root category', () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({ name: 'Node1', category: 'api' }),
|
||||
createMockNodeDef({ name: 'Node2', category: 'api/image' })
|
||||
])
|
||||
|
||||
const onAutoExpand = vi.fn()
|
||||
render(NodeSearchCategorySidebar, {
|
||||
props: {
|
||||
selectedCategory: 'most-relevant',
|
||||
onAutoExpand: onAutoExpand
|
||||
},
|
||||
global: { plugins: [testI18n] }
|
||||
})
|
||||
|
||||
expect(onAutoExpand).toHaveBeenCalledWith('api')
|
||||
})
|
||||
|
||||
it('should support deeply nested categories', async () => {
|
||||
it('should support deeply nested categories (3+ levels)', async () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({ name: 'Node1', category: 'api' }),
|
||||
createMockNodeDef({ name: 'Node2', category: 'api/image' }),
|
||||
createMockNodeDef({ name: 'Node3', category: 'api/image/BFL' })
|
||||
])
|
||||
await nextTick()
|
||||
|
||||
const { user, onUpdateSelectedCategory } = createRender()
|
||||
const { user, onUpdateSelectedCategory } = await createRender()
|
||||
|
||||
// Only top-level visible initially
|
||||
expect(screen.getByText('api')).toBeInTheDocument()
|
||||
@@ -346,12 +267,16 @@ describe('NodeSearchCategorySidebar', () => {
|
||||
|
||||
// Expand api
|
||||
await clickCategory(user, 'api', true)
|
||||
await screen.findByText('image')
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('image')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.queryByText('BFL')).not.toBeInTheDocument()
|
||||
|
||||
// Expand image
|
||||
await clickCategory(user, 'image', true)
|
||||
await screen.findByText('BFL')
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('BFL')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Click BFL and verify emission
|
||||
await clickCategory(user, 'BFL', true)
|
||||
@@ -360,179 +285,16 @@ describe('NodeSearchCategorySidebar', () => {
|
||||
expect(calls[calls.length - 1]).toEqual(['api/image/BFL'])
|
||||
})
|
||||
|
||||
describe('keyboard navigation', () => {
|
||||
it('should expand a collapsed tree node on ArrowRight', async () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({ name: 'Node1', category: 'sampling' }),
|
||||
createMockNodeDef({ name: 'Node2', category: 'sampling/advanced' }),
|
||||
createMockNodeDef({ name: 'Node3', category: 'loaders' })
|
||||
])
|
||||
it('should emit category without root/ prefix', async () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({ name: 'Node1', category: 'sampling' })
|
||||
])
|
||||
await nextTick()
|
||||
|
||||
const { user, onUpdateSelectedCategory } = createRender()
|
||||
const { user, onUpdateSelectedCategory } = await createRender()
|
||||
|
||||
expect(screen.queryByText('advanced')).not.toBeInTheDocument()
|
||||
await clickCategory(user, 'sampling')
|
||||
|
||||
const samplingBtn = screen.getByTestId('category-sampling')
|
||||
samplingBtn.focus()
|
||||
await user.keyboard('{ArrowRight}')
|
||||
|
||||
// Should have emitted select for sampling, expanding it
|
||||
expect(onUpdateSelectedCategory).toHaveBeenCalledWith('sampling')
|
||||
})
|
||||
|
||||
it('should collapse an expanded tree node on ArrowLeft', async () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({ name: 'Node1', category: 'sampling' }),
|
||||
createMockNodeDef({ name: 'Node2', category: 'sampling/advanced' }),
|
||||
createMockNodeDef({ name: 'Node3', category: 'loaders' })
|
||||
])
|
||||
|
||||
const { user } = createRender()
|
||||
|
||||
// First expand sampling by clicking
|
||||
await clickCategory(user, 'sampling', true)
|
||||
await screen.findByText('advanced')
|
||||
|
||||
const samplingBtn = screen.getByTestId('category-sampling')
|
||||
samplingBtn.focus()
|
||||
await user.keyboard('{ArrowLeft}')
|
||||
|
||||
// Collapse toggles internal state; children should be hidden
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('advanced')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should focus first child on ArrowRight when already expanded', async () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({ name: 'Node1', category: 'sampling' }),
|
||||
createMockNodeDef({ name: 'Node2', category: 'sampling/advanced' }),
|
||||
createMockNodeDef({ name: 'Node3', category: 'loaders' })
|
||||
])
|
||||
|
||||
const { user } = createRender()
|
||||
await clickCategory(user, 'sampling', true)
|
||||
await screen.findByText('advanced')
|
||||
|
||||
const samplingBtn = screen.getByTestId('category-sampling')
|
||||
samplingBtn.focus()
|
||||
await user.keyboard('{ArrowRight}')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('category-sampling/advanced')).toHaveFocus()
|
||||
})
|
||||
})
|
||||
|
||||
it('should focus parent on ArrowLeft from a leaf or collapsed node', async () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({ name: 'Node1', category: 'sampling' }),
|
||||
createMockNodeDef({ name: 'Node2', category: 'sampling/advanced' }),
|
||||
createMockNodeDef({ name: 'Node3', category: 'loaders' })
|
||||
])
|
||||
|
||||
const { user } = createRender()
|
||||
await clickCategory(user, 'sampling', true)
|
||||
await screen.findByText('advanced')
|
||||
|
||||
screen.getByTestId('category-sampling/advanced').focus()
|
||||
await user.keyboard('{ArrowLeft}')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('category-sampling')).toHaveFocus()
|
||||
})
|
||||
})
|
||||
|
||||
it('should collapse sampling on ArrowLeft, not just its expanded child', async () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({ name: 'Node1', category: 'sampling' }),
|
||||
createMockNodeDef({
|
||||
name: 'Node2',
|
||||
category: 'sampling/custom_sampling'
|
||||
}),
|
||||
createMockNodeDef({
|
||||
name: 'Node3',
|
||||
category: 'sampling/custom_sampling/child'
|
||||
}),
|
||||
createMockNodeDef({ name: 'Node4', category: 'loaders' })
|
||||
])
|
||||
|
||||
const { user } = createRender()
|
||||
|
||||
// Step 1: Expand sampling
|
||||
await clickCategory(user, 'sampling', true)
|
||||
await screen.findByText('custom_sampling')
|
||||
|
||||
// Step 2: Expand custom_sampling
|
||||
await clickCategory(user, 'custom_sampling', true)
|
||||
await screen.findByText('child')
|
||||
|
||||
// Step 3: Navigate back to sampling (keyboard focus only)
|
||||
const samplingBtn = screen.getByTestId('category-sampling')
|
||||
samplingBtn.focus()
|
||||
|
||||
// Step 4: Press left on sampling
|
||||
await user.keyboard('{ArrowLeft}')
|
||||
|
||||
// Sampling should collapse entirely — custom_sampling should not be visible
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('custom_sampling')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should collapse 4-deep tree to parent of level 2 on ArrowLeft', async () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({ name: 'N1', category: 'a' }),
|
||||
createMockNodeDef({ name: 'N2', category: 'a/b' }),
|
||||
createMockNodeDef({ name: 'N3', category: 'a/b/c' }),
|
||||
createMockNodeDef({ name: 'N4', category: 'a/b/c/d' }),
|
||||
createMockNodeDef({ name: 'N5', category: 'other' })
|
||||
])
|
||||
|
||||
const { user } = createRender()
|
||||
|
||||
// Expand a → a/b → a/b/c
|
||||
await clickCategory(user, 'a', true)
|
||||
await screen.findByText('b')
|
||||
|
||||
await clickCategory(user, 'b', true)
|
||||
await screen.findByText('c')
|
||||
|
||||
await clickCategory(user, 'c', true)
|
||||
await screen.findByText('d')
|
||||
|
||||
// Focus level 2 (a/b) and press ArrowLeft
|
||||
const bBtn = screen.getByTestId('category-a/b')
|
||||
bBtn.focus()
|
||||
|
||||
await user.keyboard('{ArrowLeft}')
|
||||
|
||||
// Level 2 and below should collapse, but level 1 (a) stays expanded
|
||||
// so 'b' is still visible but 'c' and 'd' are not
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('c')).not.toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByText('b')).toBeInTheDocument()
|
||||
expect(screen.queryByText('d')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should set aria-expanded on tree nodes with children', () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({ name: 'Node1', category: 'sampling' }),
|
||||
createMockNodeDef({ name: 'Node2', category: 'sampling/advanced' }),
|
||||
createMockNodeDef({ name: 'Node3', category: 'loaders' })
|
||||
])
|
||||
|
||||
createRender()
|
||||
|
||||
expect(screen.getByTestId('category-sampling')).toHaveAttribute(
|
||||
'aria-expanded',
|
||||
'false'
|
||||
)
|
||||
|
||||
// Leaf node should not have aria-expanded
|
||||
expect(screen.getByTestId('category-loaders')).not.toHaveAttribute(
|
||||
'aria-expanded'
|
||||
)
|
||||
})
|
||||
expect(onUpdateSelectedCategory).toHaveBeenCalledWith('sampling')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,109 +1,105 @@
|
||||
<template>
|
||||
<RovingFocusGroup
|
||||
as="div"
|
||||
orientation="vertical"
|
||||
:loop="true"
|
||||
class="group/categories flex min-h-0 flex-col overflow-y-auto py-2.5 select-none"
|
||||
>
|
||||
<div class="flex min-h-0 flex-col overflow-y-auto py-2.5">
|
||||
<!-- Preset categories -->
|
||||
<div v-if="!hidePresets" class="flex flex-col px-3">
|
||||
<RovingFocusItem
|
||||
<div class="flex flex-col px-1">
|
||||
<button
|
||||
v-for="preset in topCategories"
|
||||
:key="preset.id"
|
||||
as-child
|
||||
type="button"
|
||||
:data-testid="`category-${preset.id}`"
|
||||
:aria-current="selectedCategory === preset.id || undefined"
|
||||
:class="categoryBtnClass(preset.id)"
|
||||
@click="selectCategory(preset.id)"
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
:data-testid="`category-${preset.id}`"
|
||||
:aria-current="selectedCategory === preset.id || undefined"
|
||||
:class="categoryBtnClass(preset.id)"
|
||||
@click="selectCategory(preset.id)"
|
||||
>
|
||||
{{ preset.label }}
|
||||
</Button>
|
||||
</RovingFocusItem>
|
||||
{{ preset.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Source categories -->
|
||||
<div class="my-2 flex flex-col border-y border-border-subtle px-1 py-2">
|
||||
<button
|
||||
v-for="preset in sourceCategories"
|
||||
:key="preset.id"
|
||||
type="button"
|
||||
:data-testid="`category-${preset.id}`"
|
||||
:aria-current="selectedCategory === preset.id || undefined"
|
||||
:class="categoryBtnClass(preset.id)"
|
||||
@click="selectCategory(preset.id)"
|
||||
>
|
||||
{{ preset.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Category tree -->
|
||||
<div
|
||||
role="tree"
|
||||
:aria-label="t('g.category')"
|
||||
:class="
|
||||
cn(
|
||||
'flex flex-col px-3',
|
||||
!hidePresets && 'mt-2 border-t border-border-subtle pt-2'
|
||||
)
|
||||
"
|
||||
>
|
||||
<div class="flex flex-col px-1">
|
||||
<NodeSearchCategoryTreeNode
|
||||
v-for="category in categoryTree"
|
||||
:key="category.key"
|
||||
:node="category"
|
||||
:selected-category="selectedCategory"
|
||||
:expanded-category="expandedCategory"
|
||||
:hide-chevrons="hideChevrons"
|
||||
:selected-collapsed="selectedCollapsed"
|
||||
@select="selectCategory"
|
||||
@collapse="collapseCategory"
|
||||
/>
|
||||
</div>
|
||||
</RovingFocusGroup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export const DEFAULT_CATEGORY = 'most-relevant'
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { RovingFocusGroup, RovingFocusItem } from 'reka-ui'
|
||||
|
||||
import NodeSearchCategoryTreeNode, {
|
||||
CATEGORY_SELECTED_CLASS,
|
||||
CATEGORY_UNSELECTED_CLASS
|
||||
} from '@/components/searchbox/v2/NodeSearchCategoryTreeNode.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { CategoryNode } from '@/components/searchbox/v2/NodeSearchCategoryTreeNode.vue'
|
||||
import { nodeOrganizationService } from '@/services/nodeOrganizationService'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { NodeSourceType } from '@/types/nodeSource'
|
||||
import type { TreeNode } from '@/types/treeExplorerTypes'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const {
|
||||
hideChevrons = false,
|
||||
hidePresets = false,
|
||||
nodeDefs,
|
||||
rootLabel,
|
||||
rootKey
|
||||
} = defineProps<{
|
||||
hideChevrons?: boolean
|
||||
hidePresets?: boolean
|
||||
nodeDefs?: ComfyNodeDefImpl[]
|
||||
rootLabel?: string
|
||||
rootKey?: string
|
||||
}>()
|
||||
|
||||
const selectedCategory = defineModel<string>('selectedCategory', {
|
||||
required: true
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
autoExpand: [key: string]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
|
||||
const topCategories = computed(() => [
|
||||
{ id: DEFAULT_CATEGORY, label: t('g.mostRelevant') }
|
||||
{ id: 'most-relevant', label: t('g.mostRelevant') },
|
||||
{ id: 'recents', label: t('g.recents') },
|
||||
{ id: 'favorites', label: t('g.favorites') }
|
||||
])
|
||||
|
||||
const hasEssentialNodes = computed(() =>
|
||||
nodeDefStore.visibleNodeDefs.some(
|
||||
(n) => n.nodeSource.type === NodeSourceType.Essentials
|
||||
)
|
||||
)
|
||||
|
||||
const sourceCategories = computed(() => {
|
||||
const categories = []
|
||||
if (hasEssentialNodes.value) {
|
||||
categories.push({ id: 'essentials', label: t('g.essentials') })
|
||||
}
|
||||
categories.push(
|
||||
{
|
||||
id: 'blueprints',
|
||||
label: t('sideToolbar.nodeLibraryTab.filterOptions.blueprints')
|
||||
},
|
||||
{ id: 'partner', label: t('g.partner') },
|
||||
{ id: 'comfy', label: t('g.comfy') },
|
||||
{ id: 'extensions', label: t('g.extensions') }
|
||||
)
|
||||
return categories
|
||||
})
|
||||
|
||||
const categoryTree = computed<CategoryNode[]>(() => {
|
||||
const defs = nodeDefs ?? nodeDefStore.visibleNodeDefs
|
||||
const tree = nodeOrganizationService.organizeNodes(defs, {
|
||||
groupBy: 'category'
|
||||
})
|
||||
const tree = nodeOrganizationService.organizeNodes(
|
||||
nodeDefStore.visibleNodeDefs,
|
||||
{ groupBy: 'category' }
|
||||
)
|
||||
|
||||
const stripRootPrefix = (key: string) => key.replace(/^root\//, '')
|
||||
|
||||
@@ -118,84 +114,28 @@ const categoryTree = computed<CategoryNode[]>(() => {
|
||||
}
|
||||
}
|
||||
|
||||
const nodes = (tree.children ?? [])
|
||||
return (tree.children ?? [])
|
||||
.filter((node): node is TreeNode => !node.leaf)
|
||||
.map(mapNode)
|
||||
|
||||
if (rootLabel && nodes.length > 1) {
|
||||
const key = rootKey ?? rootLabel.toLowerCase()
|
||||
function prefixKeys(node: CategoryNode): CategoryNode {
|
||||
return {
|
||||
key: key + '/' + node.key,
|
||||
label: node.label,
|
||||
...(node.children?.length
|
||||
? { children: node.children.map(prefixKeys) }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
return [{ key, label: rootLabel, children: nodes.map(prefixKeys) }]
|
||||
}
|
||||
|
||||
return nodes
|
||||
})
|
||||
|
||||
// Notify parent when there is only a single root category to auto-expand
|
||||
watch(
|
||||
categoryTree,
|
||||
(nodes) => {
|
||||
if (nodes.length === 1 && nodes[0].children?.length) {
|
||||
const rootKey = nodes[0].key
|
||||
if (
|
||||
selectedCategory.value !== rootKey &&
|
||||
!selectedCategory.value.startsWith(rootKey + '/')
|
||||
) {
|
||||
emit('autoExpand', rootKey)
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function categoryBtnClass(id: string) {
|
||||
return cn(
|
||||
'h-auto justify-start bg-transparent py-2.5 pr-3 text-sm font-normal',
|
||||
hideChevrons ? 'pl-3' : 'pl-9',
|
||||
'cursor-pointer rounded-sm border-none bg-transparent px-3 py-2.5 text-left text-sm transition-colors',
|
||||
selectedCategory.value === id
|
||||
? CATEGORY_SELECTED_CLASS
|
||||
: CATEGORY_UNSELECTED_CLASS
|
||||
)
|
||||
}
|
||||
|
||||
const expandedCategory = ref(selectedCategory.value)
|
||||
// Skips the watch when selectCategory/collapseCategory set selectedCategory,
|
||||
// so their expandedCategory toggle isn't immediately undone.
|
||||
let lastEmittedCategory = ''
|
||||
|
||||
watch(selectedCategory, (val) => {
|
||||
if (val !== lastEmittedCategory) {
|
||||
expandedCategory.value = val
|
||||
}
|
||||
lastEmittedCategory = ''
|
||||
})
|
||||
|
||||
function parentCategory(key: string): string {
|
||||
const i = key.lastIndexOf('/')
|
||||
return i > 0 ? key.slice(0, i) : ''
|
||||
}
|
||||
const selectedCollapsed = ref(false)
|
||||
|
||||
function selectCategory(categoryId: string) {
|
||||
if (expandedCategory.value === categoryId) {
|
||||
expandedCategory.value = parentCategory(categoryId)
|
||||
if (selectedCategory.value === categoryId) {
|
||||
selectedCollapsed.value = !selectedCollapsed.value
|
||||
} else {
|
||||
expandedCategory.value = categoryId
|
||||
selectedCollapsed.value = false
|
||||
selectedCategory.value = categoryId
|
||||
}
|
||||
lastEmittedCategory = categoryId
|
||||
selectedCategory.value = categoryId
|
||||
}
|
||||
|
||||
function collapseCategory(categoryId: string) {
|
||||
expandedCategory.value = parentCategory(categoryId)
|
||||
lastEmittedCategory = categoryId
|
||||
selectedCategory.value = categoryId
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,66 +1,32 @@
|
||||
<template>
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
:data-testid="`category-${node.key}`"
|
||||
:aria-current="selectedCategory === node.key || undefined"
|
||||
:style="{ paddingLeft: `${0.75 + depth * 1.25}rem` }"
|
||||
:class="
|
||||
cn(
|
||||
selectedCategory === node.key &&
|
||||
isExpanded &&
|
||||
node.children?.length &&
|
||||
'rounded-lg bg-secondary-background'
|
||||
'w-full cursor-pointer rounded-sm border-none bg-transparent py-2.5 pr-3 text-left text-sm transition-colors',
|
||||
selectedCategory === node.key
|
||||
? CATEGORY_SELECTED_CLASS
|
||||
: CATEGORY_UNSELECTED_CLASS
|
||||
)
|
||||
"
|
||||
@click="$emit('select', node.key)"
|
||||
>
|
||||
<RovingFocusItem as-child>
|
||||
<Button
|
||||
ref="buttonEl"
|
||||
type="button"
|
||||
role="treeitem"
|
||||
:data-testid="`category-${node.key}`"
|
||||
:aria-current="selectedCategory === node.key || undefined"
|
||||
:aria-expanded="node.children?.length ? isExpanded : undefined"
|
||||
:style="{ paddingLeft: `${0.75 + depth * 1.25}rem` }"
|
||||
:class="
|
||||
cn(
|
||||
'h-auto w-full gap-2 bg-transparent py-2.5 pr-3 text-left text-sm font-normal',
|
||||
selectedCategory === node.key
|
||||
? CATEGORY_SELECTED_CLASS
|
||||
: CATEGORY_UNSELECTED_CLASS
|
||||
)
|
||||
"
|
||||
@click="$emit('select', node.key)"
|
||||
@keydown.right.prevent="handleRight"
|
||||
@keydown.left.prevent="handleLeft"
|
||||
>
|
||||
<i
|
||||
v-if="!hideChevrons"
|
||||
:class="
|
||||
cn(
|
||||
'size-4 shrink-0 text-muted-foreground transition-[transform,opacity] duration-150',
|
||||
node.children?.length
|
||||
? 'icon-[lucide--chevron-down] opacity-0 group-hover/categories:opacity-100 group-has-focus-visible/categories:opacity-100'
|
||||
: '',
|
||||
node.children?.length && !isExpanded && '-rotate-90'
|
||||
)
|
||||
"
|
||||
/>
|
||||
<span class="flex-1 truncate">{{ node.label }}</span>
|
||||
</Button>
|
||||
</RovingFocusItem>
|
||||
<div v-if="isExpanded && node.children?.length" role="group">
|
||||
<NodeSearchCategoryTreeNode
|
||||
v-for="child in node.children"
|
||||
:key="child.key"
|
||||
ref="childRefs"
|
||||
:node="child"
|
||||
:depth="depth + 1"
|
||||
:selected-category="selectedCategory"
|
||||
:expanded-category="expandedCategory"
|
||||
:hide-chevrons="hideChevrons"
|
||||
:focus-parent="focusSelf"
|
||||
@select="$emit('select', $event)"
|
||||
@collapse="$emit('collapse', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{{ node.label }}
|
||||
</button>
|
||||
<template v-if="isExpanded && node.children?.length">
|
||||
<NodeSearchCategoryTreeNode
|
||||
v-for="child in node.children"
|
||||
:key="child.key"
|
||||
:node="child"
|
||||
:depth="depth + 1"
|
||||
:selected-category="selectedCategory"
|
||||
:selected-collapsed="selectedCollapsed"
|
||||
@select="$emit('select', $event)"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
@@ -71,75 +37,34 @@ export interface CategoryNode {
|
||||
}
|
||||
|
||||
export const CATEGORY_SELECTED_CLASS =
|
||||
'bg-secondary-background-hover text-foreground'
|
||||
'bg-secondary-background-hover font-semibold text-foreground'
|
||||
export const CATEGORY_UNSELECTED_CLASS =
|
||||
'text-muted-foreground hover:bg-secondary-background-hover hover:text-foreground'
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { RovingFocusItem } from 'reka-ui'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const {
|
||||
node,
|
||||
depth = 0,
|
||||
selectedCategory,
|
||||
expandedCategory,
|
||||
hideChevrons = false,
|
||||
focusParent
|
||||
selectedCollapsed = false
|
||||
} = defineProps<{
|
||||
node: CategoryNode
|
||||
depth?: number
|
||||
selectedCategory: string
|
||||
expandedCategory: string
|
||||
hideChevrons?: boolean
|
||||
focusParent?: () => void
|
||||
selectedCollapsed?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
defineEmits<{
|
||||
select: [key: string]
|
||||
collapse: [key: string]
|
||||
}>()
|
||||
|
||||
const buttonEl = ref<InstanceType<typeof Button>>()
|
||||
const childRefs = ref<{ focus?: () => void }[]>([])
|
||||
|
||||
function focusSelf() {
|
||||
const el = buttonEl.value?.$el as HTMLElement | undefined
|
||||
el?.focus()
|
||||
}
|
||||
|
||||
defineExpose({ focus: focusSelf })
|
||||
|
||||
const isExpanded = computed(
|
||||
() =>
|
||||
expandedCategory === node.key || expandedCategory.startsWith(node.key + '/')
|
||||
)
|
||||
|
||||
function handleRight() {
|
||||
if (!node.children?.length) return
|
||||
if (!isExpanded.value) {
|
||||
emit('select', node.key)
|
||||
return
|
||||
}
|
||||
nextTick(() => {
|
||||
childRefs.value[0]?.focus?.()
|
||||
})
|
||||
}
|
||||
|
||||
function handleLeft() {
|
||||
if (node.children?.length && isExpanded.value) {
|
||||
if (expandedCategory.startsWith(node.key + '/')) {
|
||||
emit('collapse', node.key)
|
||||
} else {
|
||||
emit('select', node.key)
|
||||
}
|
||||
return
|
||||
}
|
||||
focusParent?.()
|
||||
}
|
||||
const isExpanded = computed(() => {
|
||||
if (selectedCategory === node.key) return !selectedCollapsed
|
||||
return selectedCategory.startsWith(node.key + '/')
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { render, screen, waitFor } from '@testing-library/vue'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import NodeSearchContent from '@/components/searchbox/v2/NodeSearchContent.vue'
|
||||
import {
|
||||
@@ -8,28 +9,33 @@ import {
|
||||
setupTestPinia,
|
||||
testI18n
|
||||
} from '@/components/searchbox/v2/__test__/testUtils'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { useNodeDefStore, useNodeFrequencyStore } from '@/stores/nodeDefStore'
|
||||
import { NodeSourceType } from '@/types/nodeSource'
|
||||
import type { FuseFilterWithValue } from '@/utils/fuseUtil'
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: vi.fn(() => ({
|
||||
get: vi.fn((key: string) => {
|
||||
if (key === 'Comfy.NodeLibrary.Bookmarks.V2') return []
|
||||
if (key === 'Comfy.NodeLibrary.BookmarksCustomization') return {}
|
||||
return undefined
|
||||
}),
|
||||
set: vi.fn()
|
||||
}))
|
||||
}))
|
||||
|
||||
describe('NodeSearchContent', () => {
|
||||
beforeEach(() => {
|
||||
setupTestPinia()
|
||||
vi.restoreAllMocks()
|
||||
const settings = useSettingStore()
|
||||
settings.settingValues['Comfy.NodeLibrary.Bookmarks.V2'] = []
|
||||
settings.settingValues['Comfy.NodeLibrary.BookmarksCustomization'] = {}
|
||||
})
|
||||
|
||||
function renderComponent(props = {}) {
|
||||
async function renderComponent(props = {}) {
|
||||
const user = userEvent.setup()
|
||||
const onAddNode = vi.fn()
|
||||
const onHoverNode = vi.fn()
|
||||
const onRemoveFilter =
|
||||
vi.fn<(f: FuseFilterWithValue<ComfyNodeDefImpl, string>) => void>()
|
||||
const onRemoveFilter = vi.fn()
|
||||
const onAddFilter = vi.fn()
|
||||
render(NodeSearchContent, {
|
||||
props: {
|
||||
@@ -57,40 +63,18 @@ describe('NodeSearchContent', () => {
|
||||
}
|
||||
}
|
||||
})
|
||||
await nextTick()
|
||||
return { user, onAddNode, onHoverNode, onRemoveFilter, onAddFilter }
|
||||
}
|
||||
|
||||
function mockBookmarks(
|
||||
isBookmarked: boolean | ((node: ComfyNodeDefImpl) => boolean) = true,
|
||||
bookmarkList: string[] = []
|
||||
) {
|
||||
const bookmarkStore = useNodeBookmarkStore()
|
||||
if (typeof isBookmarked === 'function') {
|
||||
vi.spyOn(bookmarkStore, 'isBookmarked').mockImplementation(isBookmarked)
|
||||
} else {
|
||||
vi.spyOn(bookmarkStore, 'isBookmarked').mockReturnValue(isBookmarked)
|
||||
}
|
||||
vi.spyOn(bookmarkStore, 'bookmarks', 'get').mockReturnValue(bookmarkList)
|
||||
}
|
||||
|
||||
function clickFilterBarButton(
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
text: string
|
||||
) {
|
||||
const btn = screen
|
||||
.getAllByRole('button')
|
||||
.find((b) => b.textContent?.trim() === text)
|
||||
expect(btn, `Expected filter button "${text}"`).toBeDefined()
|
||||
return user.click(btn!)
|
||||
}
|
||||
|
||||
async function setupFavorites(
|
||||
nodes: Parameters<typeof createMockNodeDef>[0][]
|
||||
) {
|
||||
useNodeDefStore().updateNodeDefs(nodes.map(createMockNodeDef))
|
||||
mockBookmarks(true, ['placeholder'])
|
||||
const result = renderComponent()
|
||||
await clickFilterBarButton(result.user, 'Bookmarked')
|
||||
vi.spyOn(useNodeBookmarkStore(), 'isBookmarked').mockReturnValue(true)
|
||||
const result = await renderComponent()
|
||||
await result.user.click(screen.getByTestId('category-favorites'))
|
||||
await nextTick()
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -108,13 +92,11 @@ describe('NodeSearchContent', () => {
|
||||
useNodeDefStore().nodeDefsByName['FrequentNode']
|
||||
])
|
||||
|
||||
renderComponent()
|
||||
await renderComponent()
|
||||
|
||||
await waitFor(() => {
|
||||
const items = screen.getAllByTestId('node-item')
|
||||
expect(items).toHaveLength(1)
|
||||
expect(items[0]).toHaveTextContent('Frequent Node')
|
||||
})
|
||||
const items = screen.getAllByTestId('node-item')
|
||||
expect(items).toHaveLength(1)
|
||||
expect(items[0]).toHaveTextContent('Frequent Node')
|
||||
})
|
||||
|
||||
it('should show only bookmarked nodes when Favorites is selected', async () => {
|
||||
@@ -128,31 +110,30 @@ describe('NodeSearchContent', () => {
|
||||
display_name: 'Regular Node'
|
||||
})
|
||||
])
|
||||
mockBookmarks(
|
||||
(node: ComfyNodeDefImpl) => node.name === 'BookmarkedNode',
|
||||
['BookmarkedNode']
|
||||
vi.spyOn(useNodeBookmarkStore(), 'isBookmarked').mockImplementation(
|
||||
(node: ComfyNodeDefImpl) => node.name === 'BookmarkedNode'
|
||||
)
|
||||
|
||||
const { user } = renderComponent()
|
||||
await clickFilterBarButton(user, 'Bookmarked')
|
||||
const { user } = await renderComponent()
|
||||
await user.click(screen.getByTestId('category-favorites'))
|
||||
await nextTick()
|
||||
|
||||
await waitFor(() => {
|
||||
const items = screen.getAllByTestId('node-item')
|
||||
expect(items).toHaveLength(1)
|
||||
expect(items[0]).toHaveTextContent('Bookmarked')
|
||||
})
|
||||
const items = screen.getAllByTestId('node-item')
|
||||
expect(items).toHaveLength(1)
|
||||
expect(items[0]).toHaveTextContent('Bookmarked')
|
||||
})
|
||||
|
||||
it('should show empty state when no bookmarks exist', async () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({ name: 'Node1', display_name: 'Node One' })
|
||||
])
|
||||
mockBookmarks(false, ['placeholder'])
|
||||
vi.spyOn(useNodeBookmarkStore(), 'isBookmarked').mockReturnValue(false)
|
||||
|
||||
const { user } = renderComponent()
|
||||
await clickFilterBarButton(user, 'Bookmarked')
|
||||
const { user } = await renderComponent()
|
||||
await user.click(screen.getByTestId('category-favorites'))
|
||||
await nextTick()
|
||||
|
||||
expect(await screen.findByText('No Results')).toBeInTheDocument()
|
||||
expect(screen.getByText('No results')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show only CustomNodes when Extensions is selected', async () => {
|
||||
@@ -168,6 +149,7 @@ describe('NodeSearchContent', () => {
|
||||
python_module: 'custom_nodes.my_extension'
|
||||
})
|
||||
])
|
||||
await nextTick()
|
||||
|
||||
expect(useNodeDefStore().nodeDefsByName['CoreNode'].nodeSource.type).toBe(
|
||||
NodeSourceType.Core
|
||||
@@ -176,17 +158,16 @@ describe('NodeSearchContent', () => {
|
||||
useNodeDefStore().nodeDefsByName['CustomNode'].nodeSource.type
|
||||
).toBe(NodeSourceType.CustomNodes)
|
||||
|
||||
const { user } = renderComponent()
|
||||
await clickFilterBarButton(user, 'Extensions')
|
||||
const { user } = await renderComponent()
|
||||
await user.click(screen.getByTestId('category-extensions'))
|
||||
await nextTick()
|
||||
|
||||
await waitFor(() => {
|
||||
const items = screen.getAllByTestId('node-item')
|
||||
expect(items).toHaveLength(1)
|
||||
expect(items[0]).toHaveTextContent('Custom Node')
|
||||
})
|
||||
const items = screen.getAllByTestId('node-item')
|
||||
expect(items).toHaveLength(1)
|
||||
expect(items[0]).toHaveTextContent('Custom Node')
|
||||
})
|
||||
|
||||
it('should hide Essentials filter button when no essential nodes exist', () => {
|
||||
it('should hide Essentials category when no essential nodes exist', async () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({
|
||||
name: 'RegularNode',
|
||||
@@ -194,11 +175,10 @@ describe('NodeSearchContent', () => {
|
||||
})
|
||||
])
|
||||
|
||||
renderComponent()
|
||||
const texts = screen
|
||||
.getAllByRole('button')
|
||||
.map((b) => b.textContent?.trim())
|
||||
expect(texts).not.toContain('Essentials')
|
||||
await renderComponent()
|
||||
expect(
|
||||
screen.queryByTestId('category-essentials')
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show only essential nodes when Essentials is selected', async () => {
|
||||
@@ -213,70 +193,15 @@ describe('NodeSearchContent', () => {
|
||||
display_name: 'Regular Node'
|
||||
})
|
||||
])
|
||||
await nextTick()
|
||||
|
||||
const { user } = renderComponent()
|
||||
await clickFilterBarButton(user, 'Essentials')
|
||||
const { user } = await renderComponent()
|
||||
await user.click(screen.getByTestId('category-essentials'))
|
||||
await nextTick()
|
||||
|
||||
await waitFor(() => {
|
||||
const items = screen.getAllByTestId('node-item')
|
||||
expect(items).toHaveLength(1)
|
||||
expect(items[0]).toHaveTextContent('Essential Node')
|
||||
})
|
||||
})
|
||||
|
||||
it('should show only API nodes when Partner Nodes filter is active', async () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({
|
||||
name: 'ApiNode',
|
||||
display_name: 'API Node',
|
||||
api_node: true
|
||||
}),
|
||||
createMockNodeDef({
|
||||
name: 'RegularNode',
|
||||
display_name: 'Regular Node'
|
||||
})
|
||||
])
|
||||
|
||||
const { user } = renderComponent()
|
||||
await clickFilterBarButton(user, 'Partner')
|
||||
|
||||
await waitFor(() => {
|
||||
const items = screen.getAllByTestId('node-item')
|
||||
expect(items).toHaveLength(1)
|
||||
expect(items[0]).toHaveTextContent('API Node')
|
||||
})
|
||||
})
|
||||
|
||||
it('should toggle filter off when clicking the active filter button again', async () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({
|
||||
name: 'CoreNode',
|
||||
display_name: 'Core Node',
|
||||
python_module: 'nodes'
|
||||
}),
|
||||
createMockNodeDef({
|
||||
name: 'CustomNode',
|
||||
display_name: 'Custom Node',
|
||||
python_module: 'custom_nodes.my_extension'
|
||||
})
|
||||
])
|
||||
|
||||
vi.spyOn(useNodeFrequencyStore(), 'topNodeDefs', 'get').mockReturnValue([
|
||||
useNodeDefStore().nodeDefsByName['CoreNode'],
|
||||
useNodeDefStore().nodeDefsByName['CustomNode']
|
||||
])
|
||||
|
||||
const { user } = renderComponent()
|
||||
|
||||
await clickFilterBarButton(user, 'Extensions')
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('node-item')).toHaveLength(1)
|
||||
})
|
||||
|
||||
await clickFilterBarButton(user, 'Extensions')
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('node-item')).toHaveLength(2)
|
||||
})
|
||||
const items = screen.getAllByTestId('node-item')
|
||||
expect(items).toHaveLength(1)
|
||||
expect(items[0]).toHaveTextContent('Essential Node')
|
||||
})
|
||||
|
||||
it('should include subcategory nodes when parent category is selected', async () => {
|
||||
@@ -298,20 +223,19 @@ describe('NodeSearchContent', () => {
|
||||
})
|
||||
])
|
||||
|
||||
const { user } = renderComponent()
|
||||
await user.click(await screen.findByTestId('category-sampling'))
|
||||
const { user } = await renderComponent()
|
||||
await user.click(screen.getByTestId('category-sampling'))
|
||||
await nextTick()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('node-item')).toHaveLength(2)
|
||||
})
|
||||
const texts = screen.getAllByTestId('node-item').map((i) => i.textContent)
|
||||
expect(texts).toHaveLength(2)
|
||||
expect(texts).toContain('KSampler')
|
||||
expect(texts).toContain('KSampler Advanced')
|
||||
})
|
||||
})
|
||||
|
||||
describe('search and category interaction', () => {
|
||||
it('should search within selected category', async () => {
|
||||
it('should override category to most-relevant when search query is active', async () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({
|
||||
name: 'KSampler',
|
||||
@@ -325,38 +249,35 @@ describe('NodeSearchContent', () => {
|
||||
})
|
||||
])
|
||||
|
||||
const { user } = renderComponent()
|
||||
await user.click(await screen.findByTestId('category-sampling'))
|
||||
const { user } = await renderComponent()
|
||||
await user.click(screen.getByTestId('category-sampling'))
|
||||
await nextTick()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('node-item')).toHaveLength(1)
|
||||
})
|
||||
expect(screen.getAllByTestId('node-item')).toHaveLength(1)
|
||||
|
||||
const input = screen.getByRole('combobox')
|
||||
await user.type(input, 'Load')
|
||||
await nextTick()
|
||||
|
||||
await waitFor(() => {
|
||||
const texts = screen
|
||||
.queryAllByTestId('node-item')
|
||||
.map((i) => i.textContent)
|
||||
expect(texts.some((t) => t?.includes('Load Checkpoint'))).toBe(false)
|
||||
})
|
||||
const texts = screen.getAllByTestId('node-item').map((i) => i.textContent)
|
||||
expect(texts.some((t) => t?.includes('Load Checkpoint'))).toBe(true)
|
||||
})
|
||||
|
||||
it('should preserve search query when category changes', async () => {
|
||||
it('should clear search query when category changes', async () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({ name: 'TestNode', display_name: 'Test Node' })
|
||||
])
|
||||
mockBookmarks(true, ['placeholder'])
|
||||
|
||||
const { user } = renderComponent()
|
||||
const { user } = await renderComponent()
|
||||
|
||||
const input = screen.getByRole('combobox')
|
||||
await user.type(input, 'test query')
|
||||
await nextTick()
|
||||
expect(input).toHaveValue('test query')
|
||||
|
||||
await clickFilterBarButton(user, 'Bookmarked')
|
||||
expect(input).toHaveValue('test query')
|
||||
await user.click(screen.getByTestId('category-favorites'))
|
||||
await nextTick()
|
||||
expect(input).toHaveValue('')
|
||||
})
|
||||
|
||||
it('should reset selected index when search query changes', async () => {
|
||||
@@ -368,20 +289,18 @@ describe('NodeSearchContent', () => {
|
||||
const input = screen.getByRole('combobox')
|
||||
await user.click(input)
|
||||
await user.keyboard('{ArrowDown}')
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('result-item')[1]).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true'
|
||||
)
|
||||
})
|
||||
await nextTick()
|
||||
expect(screen.getAllByTestId('result-item')[1]).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true'
|
||||
)
|
||||
|
||||
await user.type(input, 'Node')
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('result-item')[0]).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true'
|
||||
)
|
||||
})
|
||||
await nextTick()
|
||||
expect(screen.getAllByTestId('result-item')[0]).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true'
|
||||
)
|
||||
})
|
||||
|
||||
it('should reset selected index when category changes', async () => {
|
||||
@@ -392,16 +311,17 @@ describe('NodeSearchContent', () => {
|
||||
|
||||
await user.click(screen.getByRole('combobox'))
|
||||
await user.keyboard('{ArrowDown}')
|
||||
await nextTick()
|
||||
|
||||
await clickFilterBarButton(user, 'Bookmarked')
|
||||
await clickFilterBarButton(user, 'Bookmarked')
|
||||
await user.click(screen.getByTestId('category-most-relevant'))
|
||||
await nextTick()
|
||||
await user.click(screen.getByTestId('category-favorites'))
|
||||
await nextTick()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('result-item')[0]).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true'
|
||||
)
|
||||
})
|
||||
expect(screen.getAllByTestId('result-item')[0]).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -422,19 +342,24 @@ describe('NodeSearchContent', () => {
|
||||
expect(selectedIndex()).toBe(0)
|
||||
|
||||
await user.keyboard('{ArrowDown}')
|
||||
await waitFor(() => expect(selectedIndex()).toBe(1))
|
||||
await nextTick()
|
||||
expect(selectedIndex()).toBe(1)
|
||||
|
||||
await user.keyboard('{ArrowDown}')
|
||||
await waitFor(() => expect(selectedIndex()).toBe(2))
|
||||
await nextTick()
|
||||
expect(selectedIndex()).toBe(2)
|
||||
|
||||
await user.keyboard('{ArrowUp}')
|
||||
await waitFor(() => expect(selectedIndex()).toBe(1))
|
||||
await nextTick()
|
||||
expect(selectedIndex()).toBe(1)
|
||||
|
||||
// Navigate to first, then try going above — should clamp
|
||||
await user.keyboard('{ArrowUp}')
|
||||
await waitFor(() => expect(selectedIndex()).toBe(0))
|
||||
await nextTick()
|
||||
expect(selectedIndex()).toBe(0)
|
||||
|
||||
await user.keyboard('{ArrowUp}')
|
||||
await nextTick()
|
||||
expect(selectedIndex()).toBe(0)
|
||||
})
|
||||
|
||||
@@ -445,6 +370,7 @@ describe('NodeSearchContent', () => {
|
||||
|
||||
await user.click(screen.getByRole('combobox'))
|
||||
await user.keyboard('{Enter}')
|
||||
await nextTick()
|
||||
|
||||
expect(onAddNode).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 'TestNode' })
|
||||
@@ -459,10 +385,9 @@ describe('NodeSearchContent', () => {
|
||||
|
||||
const results = screen.getAllByTestId('result-item')
|
||||
await user.hover(results[1])
|
||||
await nextTick()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(results[1]).toHaveAttribute('aria-selected', 'true')
|
||||
})
|
||||
expect(results[1]).toHaveAttribute('aria-selected', 'true')
|
||||
})
|
||||
|
||||
it('should add node on click', async () => {
|
||||
@@ -471,54 +396,13 @@ describe('NodeSearchContent', () => {
|
||||
])
|
||||
|
||||
await user.click(screen.getAllByTestId('result-item')[0])
|
||||
await nextTick()
|
||||
|
||||
expect(onAddNode).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 'TestNode' }),
|
||||
expect.any(PointerEvent)
|
||||
)
|
||||
})
|
||||
|
||||
it('should navigate results with ArrowDown/ArrowUp from a focused result item', async () => {
|
||||
const { user } = await setupFavorites([
|
||||
{ name: 'Node1', display_name: 'Node One' },
|
||||
{ name: 'Node2', display_name: 'Node Two' },
|
||||
{ name: 'Node3', display_name: 'Node Three' }
|
||||
])
|
||||
|
||||
const results = screen.getAllByTestId('result-item')
|
||||
results[0].focus()
|
||||
await user.keyboard('{ArrowDown}')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('result-item')[1]).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true'
|
||||
)
|
||||
})
|
||||
|
||||
screen.getAllByTestId('result-item')[1].focus()
|
||||
await user.keyboard('{ArrowDown}')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('result-item')[2]).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should select node with Enter from a focused result item', async () => {
|
||||
const { user, onAddNode } = await setupFavorites([
|
||||
{ name: 'TestNode', display_name: 'Test Node' }
|
||||
])
|
||||
|
||||
screen.getAllByTestId('result-item')[0].focus()
|
||||
await user.keyboard('{Enter}')
|
||||
|
||||
expect(onAddNode).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 'TestNode' })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hoverNode emission', () => {
|
||||
@@ -527,27 +411,26 @@ describe('NodeSearchContent', () => {
|
||||
{ name: 'HoverNode', display_name: 'Hover Node' }
|
||||
])
|
||||
|
||||
await waitFor(() => {
|
||||
const calls = onHoverNode.mock.calls
|
||||
expect(calls[calls.length - 1][0]).toMatchObject({ name: 'HoverNode' })
|
||||
const calls = onHoverNode.mock.calls
|
||||
expect(calls[calls.length - 1][0]).toMatchObject({
|
||||
name: 'HoverNode'
|
||||
})
|
||||
})
|
||||
|
||||
it('should emit null hoverNode when no results', async () => {
|
||||
mockBookmarks(false, ['placeholder'])
|
||||
const { user, onHoverNode } = renderComponent()
|
||||
const { user, onHoverNode } = await renderComponent()
|
||||
|
||||
await clickFilterBarButton(user, 'Bookmarked')
|
||||
vi.spyOn(useNodeBookmarkStore(), 'isBookmarked').mockReturnValue(false)
|
||||
await user.click(screen.getByTestId('category-favorites'))
|
||||
await nextTick()
|
||||
|
||||
await waitFor(() => {
|
||||
const calls = onHoverNode.mock.calls
|
||||
expect(calls[calls.length - 1][0]).toBeNull()
|
||||
})
|
||||
const calls = onHoverNode.mock.calls
|
||||
expect(calls[calls.length - 1][0]).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('filter integration', () => {
|
||||
it('should display active filters in the input area', () => {
|
||||
it('should display active filters in the input area', async () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({
|
||||
name: 'ImageNode',
|
||||
@@ -556,7 +439,7 @@ describe('NodeSearchContent', () => {
|
||||
})
|
||||
])
|
||||
|
||||
renderComponent({
|
||||
await renderComponent({
|
||||
filters: [
|
||||
{
|
||||
filterDef: useNodeDefStore().nodeSearchService.inputTypeFilter,
|
||||
@@ -591,11 +474,13 @@ describe('NodeSearchContent', () => {
|
||||
|
||||
it('should emit removeFilter on backspace', async () => {
|
||||
const filters = createFilters(1)
|
||||
const { user, onRemoveFilter } = renderComponent({ filters })
|
||||
const { user, onRemoveFilter } = await renderComponent({ filters })
|
||||
|
||||
await user.click(screen.getByRole('combobox'))
|
||||
await user.keyboard('{Backspace}')
|
||||
await nextTick()
|
||||
await user.keyboard('{Backspace}')
|
||||
await nextTick()
|
||||
|
||||
expect(onRemoveFilter).toHaveBeenCalledTimes(1)
|
||||
expect(onRemoveFilter).toHaveBeenCalledWith(
|
||||
@@ -604,102 +489,247 @@ describe('NodeSearchContent', () => {
|
||||
})
|
||||
|
||||
it('should not interact with chips when no filters exist', async () => {
|
||||
const { user, onRemoveFilter } = renderComponent({ filters: [] })
|
||||
const { user, onRemoveFilter } = await renderComponent({ filters: [] })
|
||||
|
||||
await user.click(screen.getByRole('combobox'))
|
||||
await user.keyboard('{Backspace}')
|
||||
await nextTick()
|
||||
|
||||
expect(onRemoveFilter).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should remove chip when clicking its delete button', async () => {
|
||||
const filters = createFilters(1)
|
||||
const { user, onRemoveFilter } = renderComponent({ filters })
|
||||
const { user, onRemoveFilter } = await renderComponent({ filters })
|
||||
|
||||
await user.click(screen.getByTestId('chip-delete'))
|
||||
await nextTick()
|
||||
|
||||
expect(onRemoveFilter).toHaveBeenCalledTimes(1)
|
||||
expect(onRemoveFilter).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ value: 'IMAGE' })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should emit removeFilter for every filter in a group when cleared', async () => {
|
||||
describe('filter selection mode', () => {
|
||||
function setupNodesWithTypes() {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({
|
||||
name: 'ImageNode',
|
||||
display_name: 'Image Node',
|
||||
input: { required: { image: ['IMAGE', {}] } }
|
||||
input: { required: { image: ['IMAGE', {}] } },
|
||||
output: ['IMAGE']
|
||||
}),
|
||||
createMockNodeDef({
|
||||
name: 'LatentNode',
|
||||
display_name: 'Latent Node',
|
||||
input: { required: { latent: ['LATENT', {}] } }
|
||||
input: { required: { latent: ['LATENT', {}] } },
|
||||
output: ['LATENT']
|
||||
}),
|
||||
createMockNodeDef({
|
||||
name: 'ModelNode',
|
||||
display_name: 'Model Node',
|
||||
input: { required: { model: ['MODEL', {}] } },
|
||||
output: ['MODEL']
|
||||
})
|
||||
])
|
||||
const inputFilter = useNodeDefStore().nodeSearchService.inputTypeFilter
|
||||
const filters = [
|
||||
{ filterDef: inputFilter, value: 'IMAGE' },
|
||||
{ filterDef: inputFilter, value: 'LATENT' }
|
||||
]
|
||||
}
|
||||
|
||||
const { user, onRemoveFilter } = renderComponent({ filters })
|
||||
function findFilterBarButton(label: string) {
|
||||
return screen.getAllByRole('button').find((b) => b.textContent === label)
|
||||
}
|
||||
|
||||
const inputBtn = screen.getByRole('button', { name: /Input/ })
|
||||
await user.click(inputBtn)
|
||||
async function enterFilterMode(user: ReturnType<typeof userEvent.setup>) {
|
||||
const btn = findFilterBarButton('Input')
|
||||
expect(btn).toBeDefined()
|
||||
await user.click(btn!)
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
const clearBtn = await screen.findByRole('button', { name: 'Clear all' })
|
||||
await user.click(clearBtn)
|
||||
function hasSidebar() {
|
||||
return screen.queryByTestId('category-most-relevant') !== null
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onRemoveFilter).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
const removedValues = onRemoveFilter.mock.calls.map(([f]) => f.value)
|
||||
expect(removedValues).toEqual(expect.arrayContaining(['IMAGE', 'LATENT']))
|
||||
it('should enter filter mode when a filter chip is selected', async () => {
|
||||
setupNodesWithTypes()
|
||||
const { user } = await renderComponent()
|
||||
|
||||
expect(hasSidebar()).toBe(true)
|
||||
|
||||
await enterFilterMode(user)
|
||||
|
||||
expect(hasSidebar()).toBe(false)
|
||||
expect(screen.getAllByTestId('filter-option').length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('rootFilter + category + search combination', () => {
|
||||
it('should intersect rootFilter, selected category, and search query', async () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({
|
||||
name: 'CustomSampler',
|
||||
display_name: 'Custom Sampler',
|
||||
category: 'sampling',
|
||||
python_module: 'custom_nodes.my_extension'
|
||||
}),
|
||||
createMockNodeDef({
|
||||
name: 'CustomLoader',
|
||||
display_name: 'Custom Loader',
|
||||
category: 'loaders',
|
||||
python_module: 'custom_nodes.my_extension'
|
||||
}),
|
||||
createMockNodeDef({
|
||||
name: 'CoreSampler',
|
||||
display_name: 'Core Sampler',
|
||||
category: 'sampling',
|
||||
python_module: 'nodes'
|
||||
it('should show available filter options sorted alphabetically', async () => {
|
||||
setupNodesWithTypes()
|
||||
const { user } = await renderComponent()
|
||||
await enterFilterMode(user)
|
||||
|
||||
const texts = screen.getAllByTestId('filter-option').map(
|
||||
(o) =>
|
||||
/* eslint-disable testing-library/no-node-access */
|
||||
(o.querySelectorAll('span')[0] as HTMLElement)?.textContent
|
||||
?.replace(/^[•·]\s*/, '')
|
||||
.trim() ?? ''
|
||||
/* eslint-enable testing-library/no-node-access */
|
||||
)
|
||||
expect(texts).toContain('IMAGE')
|
||||
expect(texts).toContain('LATENT')
|
||||
expect(texts).toContain('MODEL')
|
||||
expect(texts).toEqual([...texts].sort())
|
||||
})
|
||||
|
||||
it('should filter options when typing in filter mode', async () => {
|
||||
setupNodesWithTypes()
|
||||
const { user } = await renderComponent()
|
||||
await enterFilterMode(user)
|
||||
|
||||
await user.type(screen.getByRole('combobox'), 'IMAGE')
|
||||
await nextTick()
|
||||
|
||||
const texts = screen.getAllByTestId('filter-option').map(
|
||||
(o) =>
|
||||
/* eslint-disable testing-library/no-node-access */
|
||||
(o.querySelectorAll('span')[0] as HTMLElement)?.textContent
|
||||
?.replace(/^[•·]\s*/, '')
|
||||
.trim() ?? ''
|
||||
/* eslint-enable testing-library/no-node-access */
|
||||
)
|
||||
expect(texts).toContain('IMAGE')
|
||||
expect(texts).not.toContain('MODEL')
|
||||
})
|
||||
|
||||
it('should show no results when filter query has no matches', async () => {
|
||||
setupNodesWithTypes()
|
||||
const { user } = await renderComponent()
|
||||
await enterFilterMode(user)
|
||||
|
||||
await user.type(screen.getByRole('combobox'), 'NONEXISTENT_TYPE')
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByText('No results')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should emit addFilter when a filter option is clicked', async () => {
|
||||
setupNodesWithTypes()
|
||||
const { user, onAddFilter } = await renderComponent()
|
||||
await enterFilterMode(user)
|
||||
|
||||
const imageOption = screen
|
||||
.getAllByTestId('filter-option')
|
||||
.find((o) => o.textContent?.includes('IMAGE'))
|
||||
await user.click(imageOption!)
|
||||
await nextTick()
|
||||
|
||||
expect(onAddFilter).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
filterDef: expect.objectContaining({ id: 'input' }),
|
||||
value: 'IMAGE'
|
||||
})
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
const { user } = renderComponent()
|
||||
it('should exit filter mode after applying a filter', async () => {
|
||||
setupNodesWithTypes()
|
||||
const { user } = await renderComponent()
|
||||
await enterFilterMode(user)
|
||||
|
||||
await clickFilterBarButton(user, 'Extensions')
|
||||
const samplingBtn = await screen.findByTestId('category-custom/sampling')
|
||||
await user.click(samplingBtn)
|
||||
await user.click(screen.getAllByTestId('filter-option')[0])
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
|
||||
expect(hasSidebar()).toBe(true)
|
||||
})
|
||||
|
||||
it('should emit addFilter when Enter is pressed on selected option', async () => {
|
||||
setupNodesWithTypes()
|
||||
const { user, onAddFilter } = await renderComponent()
|
||||
await enterFilterMode(user)
|
||||
|
||||
await user.click(screen.getByRole('combobox'))
|
||||
await user.keyboard('{Enter}')
|
||||
await nextTick()
|
||||
|
||||
expect(onAddFilter).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
filterDef: expect.objectContaining({ id: 'input' }),
|
||||
value: 'IMAGE'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('should navigate filter options with ArrowDown/ArrowUp', async () => {
|
||||
setupNodesWithTypes()
|
||||
const { user } = await renderComponent()
|
||||
await enterFilterMode(user)
|
||||
|
||||
await user.click(screen.getByRole('combobox'))
|
||||
|
||||
expect(screen.getAllByTestId('filter-option')[0]).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true'
|
||||
)
|
||||
|
||||
await user.keyboard('{ArrowDown}')
|
||||
await nextTick()
|
||||
expect(screen.getAllByTestId('filter-option')[1]).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true'
|
||||
)
|
||||
|
||||
await user.keyboard('{ArrowUp}')
|
||||
await nextTick()
|
||||
expect(screen.getAllByTestId('filter-option')[0]).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true'
|
||||
)
|
||||
})
|
||||
|
||||
it('should toggle filter mode off when same chip is clicked again', async () => {
|
||||
setupNodesWithTypes()
|
||||
const { user } = await renderComponent()
|
||||
await enterFilterMode(user)
|
||||
|
||||
await user.click(findFilterBarButton('Input')!)
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
|
||||
expect(hasSidebar()).toBe(true)
|
||||
})
|
||||
|
||||
it('should reset filter query when re-entering filter mode', async () => {
|
||||
setupNodesWithTypes()
|
||||
const { user } = await renderComponent()
|
||||
await enterFilterMode(user)
|
||||
|
||||
const input = screen.getByRole('combobox')
|
||||
await user.type(input, 'Custom')
|
||||
await user.type(input, 'IMAGE')
|
||||
await nextTick()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryAllByTestId('node-item')).toHaveLength(1)
|
||||
})
|
||||
const texts = screen
|
||||
.queryAllByTestId('node-item')
|
||||
.map((i) => i.textContent)
|
||||
expect(texts).toContain('Custom Sampler')
|
||||
expect(texts).not.toContain('Core Sampler')
|
||||
expect(texts).not.toContain('Custom Loader')
|
||||
await user.click(findFilterBarButton('Input')!)
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
|
||||
await enterFilterMode(user)
|
||||
|
||||
expect(input).toHaveValue('')
|
||||
})
|
||||
|
||||
it('should exit filter mode when cancel button is clicked', async () => {
|
||||
setupNodesWithTypes()
|
||||
const { user } = await renderComponent()
|
||||
await enterFilterMode(user)
|
||||
|
||||
expect(hasSidebar()).toBe(false)
|
||||
|
||||
await user.click(screen.getByTestId('cancel-filter'))
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
|
||||
expect(hasSidebar()).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,130 +1,107 @@
|
||||
<template>
|
||||
<FocusScope as-child loop>
|
||||
<div
|
||||
ref="dialogRef"
|
||||
class="flex h-[min(80vh,750px)] w-full flex-col overflow-hidden rounded-lg border border-interface-stroke bg-base-background"
|
||||
>
|
||||
<!-- Search input row -->
|
||||
<NodeSearchInput
|
||||
ref="searchInputRef"
|
||||
v-model:search-query="searchQuery"
|
||||
:filters="filters"
|
||||
@remove-filter="emit('removeFilter', $event)"
|
||||
@navigate-down="navigateResults(1)"
|
||||
@navigate-up="navigateResults(-1)"
|
||||
@select-current="selectCurrentResult"
|
||||
<div
|
||||
ref="dialogRef"
|
||||
class="flex max-h-[50vh] min-h-[400px] w-full flex-col overflow-hidden rounded-lg border border-interface-stroke bg-base-background"
|
||||
>
|
||||
<!-- Search input row -->
|
||||
<NodeSearchInput
|
||||
ref="searchInputRef"
|
||||
v-model:search-query="searchQuery"
|
||||
v-model:filter-query="filterQuery"
|
||||
:filters="filters"
|
||||
:active-filter="activeFilter"
|
||||
@remove-filter="emit('removeFilter', $event)"
|
||||
@cancel-filter="cancelFilter"
|
||||
@navigate-down="onKeyDown"
|
||||
@navigate-up="onKeyUp"
|
||||
@select-current="onKeyEnter"
|
||||
/>
|
||||
|
||||
<!-- Filter header row -->
|
||||
<div class="flex items-center">
|
||||
<NodeSearchFilterBar
|
||||
class="flex-1"
|
||||
:active-chip-key="activeFilter?.key"
|
||||
@select-chip="onSelectFilterChip"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Content area -->
|
||||
<div class="flex min-h-0 flex-1 overflow-hidden">
|
||||
<!-- Category sidebar (hidden in filter mode) -->
|
||||
<NodeSearchCategorySidebar
|
||||
v-if="!activeFilter"
|
||||
v-model:selected-category="sidebarCategory"
|
||||
class="w-52 shrink-0"
|
||||
/>
|
||||
|
||||
<!-- Filter header row -->
|
||||
<div class="flex items-center">
|
||||
<NodeSearchFilterBar
|
||||
class="flex-1"
|
||||
:filters="filters"
|
||||
:active-category="rootFilter"
|
||||
:has-favorites="nodeBookmarkStore.bookmarks.length > 0"
|
||||
:has-essential-nodes="nodeAvailability.essential"
|
||||
:has-blueprint-nodes="nodeAvailability.blueprint"
|
||||
:has-partner-nodes="nodeAvailability.partner"
|
||||
:has-custom-nodes="nodeAvailability.custom"
|
||||
@toggle-filter="onToggleFilter"
|
||||
@clear-filter-group="onClearFilterGroup"
|
||||
@focus-search="nextTick(() => searchInputRef?.focus())"
|
||||
@select-category="onSelectCategory"
|
||||
/>
|
||||
</div>
|
||||
<!-- Filter options list (filter selection mode) -->
|
||||
<NodeSearchFilterPanel
|
||||
v-if="activeFilter"
|
||||
ref="filterPanelRef"
|
||||
v-model:query="filterQuery"
|
||||
:chip="activeFilter"
|
||||
@apply="onFilterApply"
|
||||
/>
|
||||
|
||||
<!-- Content area -->
|
||||
<div class="flex min-h-0 flex-1 overflow-hidden">
|
||||
<!-- Category sidebar -->
|
||||
<NodeSearchCategorySidebar
|
||||
v-model:selected-category="sidebarCategory"
|
||||
class="w-52 shrink-0"
|
||||
:hide-chevrons="!anyTreeCategoryHasChildren"
|
||||
:hide-presets="rootFilter !== null"
|
||||
:node-defs="rootFilteredNodeDefs"
|
||||
:root-label="rootFilterLabel"
|
||||
:root-key="rootFilter ?? undefined"
|
||||
@auto-expand="selectedCategory = $event"
|
||||
/>
|
||||
|
||||
<!-- Results list -->
|
||||
<!-- Results list (normal mode) -->
|
||||
<div
|
||||
v-else
|
||||
id="results-list"
|
||||
role="listbox"
|
||||
class="flex-1 overflow-y-auto py-2"
|
||||
>
|
||||
<div
|
||||
id="results-list"
|
||||
role="listbox"
|
||||
tabindex="-1"
|
||||
class="flex-1 overflow-y-auto py-2 pr-3 pl-1 select-none"
|
||||
@pointermove="onPointerMove"
|
||||
v-for="(node, index) in displayedResults"
|
||||
:id="`result-item-${index}`"
|
||||
:key="node.name"
|
||||
role="option"
|
||||
data-testid="result-item"
|
||||
:aria-selected="index === selectedIndex"
|
||||
:class="
|
||||
cn(
|
||||
'flex h-14 cursor-pointer items-center px-4',
|
||||
index === selectedIndex && 'bg-secondary-background-hover'
|
||||
)
|
||||
"
|
||||
@click="emit('addNode', node, $event)"
|
||||
@mouseenter="selectedIndex = index"
|
||||
>
|
||||
<div
|
||||
v-for="(node, index) in displayedResults"
|
||||
:id="`result-item-${index}`"
|
||||
:key="node.name"
|
||||
role="option"
|
||||
data-testid="result-item"
|
||||
:tabindex="index === selectedIndex ? 0 : -1"
|
||||
:aria-selected="index === selectedIndex"
|
||||
:class="
|
||||
cn(
|
||||
'flex h-14 cursor-pointer items-center rounded-lg px-4 outline-none focus-visible:ring-2 focus-visible:ring-primary',
|
||||
index === selectedIndex && 'bg-secondary-background'
|
||||
)
|
||||
"
|
||||
@click="emit('addNode', node, $event)"
|
||||
@keydown.down.prevent="navigateResults(1, true)"
|
||||
@keydown.up.prevent="navigateResults(-1, true)"
|
||||
@keydown.enter.prevent="selectCurrentResult"
|
||||
>
|
||||
<NodeSearchListItem
|
||||
:node-def="node"
|
||||
:current-query="searchQuery"
|
||||
show-description
|
||||
:show-source-badge="rootFilter !== 'essentials'"
|
||||
:hide-bookmark-icon="selectedCategory === 'favorites'"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="displayedResults.length === 0"
|
||||
class="px-4 py-8 text-center text-muted-foreground"
|
||||
>
|
||||
{{ $t('g.noResults') }}
|
||||
</div>
|
||||
<NodeSearchListItem
|
||||
:node-def="node"
|
||||
:current-query="searchQuery"
|
||||
show-description
|
||||
:show-source-badge="effectiveCategory !== 'essentials'"
|
||||
:hide-bookmark-icon="effectiveCategory === 'favorites'"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="displayedResults.length === 0"
|
||||
class="px-4 py-8 text-center text-muted-foreground"
|
||||
>
|
||||
{{ $t('g.noResults') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FocusScope>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { FocusScope } from 'reka-ui'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { FilterChip } from '@/components/searchbox/v2/NodeSearchFilterBar.vue'
|
||||
import NodeSearchFilterBar from '@/components/searchbox/v2/NodeSearchFilterBar.vue'
|
||||
import NodeSearchCategorySidebar, {
|
||||
DEFAULT_CATEGORY
|
||||
} from '@/components/searchbox/v2/NodeSearchCategorySidebar.vue'
|
||||
import NodeSearchCategorySidebar from '@/components/searchbox/v2/NodeSearchCategorySidebar.vue'
|
||||
import NodeSearchFilterPanel from '@/components/searchbox/v2/NodeSearchFilterPanel.vue'
|
||||
import NodeSearchInput from '@/components/searchbox/v2/NodeSearchInput.vue'
|
||||
import NodeSearchListItem from '@/components/searchbox/v2/NodeSearchListItem.vue'
|
||||
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { useNodeDefStore, useNodeFrequencyStore } from '@/stores/nodeDefStore'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import {
|
||||
BLUEPRINT_CATEGORY,
|
||||
isCustomNode,
|
||||
isEssentialNode,
|
||||
NodeSourceType
|
||||
} from '@/types/nodeSource'
|
||||
import type { FuseFilter, FuseFilterWithValue } from '@/utils/fuseUtil'
|
||||
import { NodeSourceType } from '@/types/nodeSource'
|
||||
import type { FuseFilterWithValue } from '@/utils/fuseUtil'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const sourceCategoryFilters: Record<string, (n: ComfyNodeDefImpl) => boolean> =
|
||||
{
|
||||
essentials: isEssentialNode,
|
||||
comfy: (n) => n.nodeSource.type === NodeSourceType.Core,
|
||||
custom: isCustomNode
|
||||
}
|
||||
|
||||
const { filters } = defineProps<{
|
||||
filters: FuseFilterWithValue<ComfyNodeDefImpl, string>[]
|
||||
}>()
|
||||
@@ -136,102 +113,57 @@ const emit = defineEmits<{
|
||||
hoverNode: [nodeDef: ComfyNodeDefImpl | null]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { flags } = useFeatureFlags()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const nodeFrequencyStore = useNodeFrequencyStore()
|
||||
const nodeBookmarkStore = useNodeBookmarkStore()
|
||||
|
||||
const nodeAvailability = computed(() => {
|
||||
let essential = false
|
||||
let blueprint = false
|
||||
let partner = false
|
||||
let custom = false
|
||||
for (const n of nodeDefStore.visibleNodeDefs) {
|
||||
if (!essential && flags.nodeLibraryEssentialsEnabled && isEssentialNode(n))
|
||||
essential = true
|
||||
if (!blueprint && n.category.startsWith(BLUEPRINT_CATEGORY))
|
||||
blueprint = true
|
||||
if (!partner && n.api_node) partner = true
|
||||
if (!custom && isCustomNode(n)) custom = true
|
||||
if (essential && blueprint && partner && custom) break
|
||||
}
|
||||
return { essential, blueprint, partner, custom }
|
||||
})
|
||||
|
||||
const dialogRef = ref<HTMLElement>()
|
||||
const searchInputRef = ref<InstanceType<typeof NodeSearchInput>>()
|
||||
const filterPanelRef = ref<InstanceType<typeof NodeSearchFilterPanel>>()
|
||||
|
||||
const searchQuery = ref('')
|
||||
const selectedCategory = ref(DEFAULT_CATEGORY)
|
||||
const selectedCategory = ref('most-relevant')
|
||||
const selectedIndex = ref(0)
|
||||
|
||||
// Root filter from filter bar category buttons (radio toggle)
|
||||
const rootFilter = ref<string | null>(null)
|
||||
const activeFilter = ref<FilterChip | null>(null)
|
||||
const filterQuery = ref('')
|
||||
|
||||
const rootFilterLabel = computed(() => {
|
||||
switch (rootFilter.value) {
|
||||
case 'favorites':
|
||||
return t('g.bookmarked')
|
||||
case BLUEPRINT_CATEGORY:
|
||||
return t('g.blueprints')
|
||||
case 'partner-nodes':
|
||||
return t('g.partner')
|
||||
case 'essentials':
|
||||
return t('g.essentials')
|
||||
case 'comfy':
|
||||
return t('g.comfy')
|
||||
case 'custom':
|
||||
return t('g.extensions')
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
})
|
||||
|
||||
const rootFilteredNodeDefs = computed(() => {
|
||||
if (!rootFilter.value) return nodeDefStore.visibleNodeDefs
|
||||
const allNodes = nodeDefStore.visibleNodeDefs
|
||||
const sourceFilter = sourceCategoryFilters[rootFilter.value]
|
||||
if (sourceFilter) return allNodes.filter(sourceFilter)
|
||||
switch (rootFilter.value) {
|
||||
case 'favorites':
|
||||
return allNodes.filter((n) => nodeBookmarkStore.isBookmarked(n))
|
||||
case BLUEPRINT_CATEGORY:
|
||||
return allNodes.filter((n) => n.category.startsWith(rootFilter.value!))
|
||||
case 'partner-nodes':
|
||||
return allNodes.filter((n) => n.api_node)
|
||||
default:
|
||||
return allNodes
|
||||
}
|
||||
})
|
||||
|
||||
function onToggleFilter(
|
||||
filterDef: FuseFilter<ComfyNodeDefImpl, string>,
|
||||
value: string
|
||||
) {
|
||||
const existing = filters.find(
|
||||
(f) => f.filterDef.id === filterDef.id && f.value === value
|
||||
)
|
||||
if (existing) {
|
||||
emit('removeFilter', existing)
|
||||
} else {
|
||||
emit('addFilter', { filterDef, value })
|
||||
function lockDialogHeight() {
|
||||
if (dialogRef.value) {
|
||||
dialogRef.value.style.height = `${dialogRef.value.offsetHeight}px`
|
||||
}
|
||||
}
|
||||
|
||||
function onClearFilterGroup(filterId: string) {
|
||||
for (const f of filters.filter((f) => f.filterDef.id === filterId)) {
|
||||
emit('removeFilter', f)
|
||||
function unlockDialogHeight() {
|
||||
if (dialogRef.value) {
|
||||
dialogRef.value.style.height = ''
|
||||
}
|
||||
}
|
||||
|
||||
function onSelectCategory(category: string) {
|
||||
if (rootFilter.value === category) {
|
||||
rootFilter.value = null
|
||||
} else {
|
||||
rootFilter.value = category
|
||||
function onSelectFilterChip(chip: FilterChip) {
|
||||
if (activeFilter.value?.key === chip.key) {
|
||||
cancelFilter()
|
||||
return
|
||||
}
|
||||
selectedCategory.value = DEFAULT_CATEGORY
|
||||
lockDialogHeight()
|
||||
activeFilter.value = chip
|
||||
filterQuery.value = ''
|
||||
nextTick(() => searchInputRef.value?.focus())
|
||||
}
|
||||
|
||||
function onFilterApply(value: string) {
|
||||
if (!activeFilter.value) return
|
||||
emit('addFilter', { filterDef: activeFilter.value.filter, value })
|
||||
activeFilter.value = null
|
||||
filterQuery.value = ''
|
||||
unlockDialogHeight()
|
||||
nextTick(() => searchInputRef.value?.focus())
|
||||
}
|
||||
|
||||
function cancelFilter() {
|
||||
activeFilter.value = null
|
||||
filterQuery.value = ''
|
||||
unlockDialogHeight()
|
||||
nextTick(() => searchInputRef.value?.focus())
|
||||
}
|
||||
|
||||
@@ -244,68 +176,67 @@ const searchResults = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
const effectiveCategory = computed(() =>
|
||||
searchQuery.value ? 'most-relevant' : selectedCategory.value
|
||||
)
|
||||
|
||||
const sidebarCategory = computed({
|
||||
get: () => selectedCategory.value,
|
||||
get: () => effectiveCategory.value,
|
||||
set: (category: string) => {
|
||||
selectedCategory.value = category
|
||||
searchQuery.value = ''
|
||||
}
|
||||
})
|
||||
|
||||
// Check if any tree category has children (for chevron visibility)
|
||||
const anyTreeCategoryHasChildren = computed(() =>
|
||||
rootFilteredNodeDefs.value.some((n) => n.category.includes('/'))
|
||||
)
|
||||
|
||||
function getMostRelevantResults(baseNodes: ComfyNodeDefImpl[]) {
|
||||
if (searchQuery.value || filters.length > 0) {
|
||||
const searched = searchResults.value
|
||||
if (!rootFilter.value) return searched
|
||||
const rootSet = new Set(baseNodes.map((n) => n.name))
|
||||
return searched.filter((n) => rootSet.has(n.name))
|
||||
}
|
||||
return rootFilter.value ? baseNodes : nodeFrequencyStore.topNodeDefs
|
||||
}
|
||||
|
||||
function getCategoryResults(baseNodes: ComfyNodeDefImpl[], category: string) {
|
||||
if (rootFilter.value && category === rootFilter.value) return baseNodes
|
||||
const rootPrefix = rootFilter.value ? rootFilter.value + '/' : ''
|
||||
const categoryPath = category.startsWith(rootPrefix)
|
||||
? category.slice(rootPrefix.length)
|
||||
: category
|
||||
return baseNodes.filter((n) => {
|
||||
const nodeCategory = n.category.startsWith(rootPrefix)
|
||||
? n.category.slice(rootPrefix.length)
|
||||
: n.category
|
||||
return (
|
||||
nodeCategory === categoryPath ||
|
||||
nodeCategory.startsWith(categoryPath + '/')
|
||||
)
|
||||
})
|
||||
function matchesFilters(node: ComfyNodeDefImpl): boolean {
|
||||
return filters.every(({ filterDef, value }) => filterDef.matches(node, value))
|
||||
}
|
||||
|
||||
const displayedResults = computed<ComfyNodeDefImpl[]>(() => {
|
||||
const baseNodes = rootFilteredNodeDefs.value
|
||||
const category = selectedCategory.value
|
||||
const allNodes = nodeDefStore.visibleNodeDefs
|
||||
|
||||
if (category === DEFAULT_CATEGORY) return getMostRelevantResults(baseNodes)
|
||||
|
||||
const hasSearch = searchQuery.value || filters.length > 0
|
||||
let source: ComfyNodeDefImpl[]
|
||||
if (hasSearch) {
|
||||
const searched = searchResults.value
|
||||
if (rootFilter.value) {
|
||||
const rootSet = new Set(baseNodes.map((n) => n.name))
|
||||
source = searched.filter((n) => rootSet.has(n.name))
|
||||
} else {
|
||||
source = searched
|
||||
}
|
||||
} else {
|
||||
source = baseNodes
|
||||
let results: ComfyNodeDefImpl[]
|
||||
switch (effectiveCategory.value) {
|
||||
case 'most-relevant':
|
||||
return searchResults.value
|
||||
case 'favorites':
|
||||
results = allNodes.filter((n) => nodeBookmarkStore.isBookmarked(n))
|
||||
break
|
||||
case 'essentials':
|
||||
results = allNodes.filter(
|
||||
(n) => n.nodeSource.type === NodeSourceType.Essentials
|
||||
)
|
||||
break
|
||||
case 'recents':
|
||||
return searchResults.value
|
||||
case 'blueprints':
|
||||
results = allNodes.filter(
|
||||
(n) => n.nodeSource.type === NodeSourceType.Blueprint
|
||||
)
|
||||
break
|
||||
case 'partner':
|
||||
results = allNodes.filter((n) => n.api_node)
|
||||
break
|
||||
case 'comfy':
|
||||
results = allNodes.filter(
|
||||
(n) => n.nodeSource.type === NodeSourceType.Core
|
||||
)
|
||||
break
|
||||
case 'extensions':
|
||||
results = allNodes.filter(
|
||||
(n) => n.nodeSource.type === NodeSourceType.CustomNodes
|
||||
)
|
||||
break
|
||||
default:
|
||||
results = allNodes.filter(
|
||||
(n) =>
|
||||
n.category === effectiveCategory.value ||
|
||||
n.category.startsWith(effectiveCategory.value + '/')
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
const sourceFilter = sourceCategoryFilters[category]
|
||||
if (sourceFilter) return source.filter(sourceFilter)
|
||||
return getCategoryResults(source, category)
|
||||
return filters.length > 0 ? results.filter(matchesFilters) : results
|
||||
})
|
||||
|
||||
const hoveredNodeDef = computed(
|
||||
@@ -320,28 +251,42 @@ watch(
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch([selectedCategory, searchQuery, rootFilter, () => filters.length], () => {
|
||||
watch([selectedCategory, searchQuery, () => filters], () => {
|
||||
selectedIndex.value = 0
|
||||
})
|
||||
|
||||
function onPointerMove(event: PointerEvent) {
|
||||
const item = (event.target as HTMLElement).closest('[role=option]')
|
||||
if (!item) return
|
||||
const index = Number(item.id.replace('result-item-', ''))
|
||||
if (!isNaN(index) && index !== selectedIndex.value)
|
||||
selectedIndex.value = index
|
||||
function onKeyDown() {
|
||||
if (activeFilter.value) {
|
||||
filterPanelRef.value?.navigate(1)
|
||||
} else {
|
||||
navigateResults(1)
|
||||
}
|
||||
}
|
||||
|
||||
function navigateResults(direction: number, focusItem = false) {
|
||||
function onKeyUp() {
|
||||
if (activeFilter.value) {
|
||||
filterPanelRef.value?.navigate(-1)
|
||||
} else {
|
||||
navigateResults(-1)
|
||||
}
|
||||
}
|
||||
|
||||
function onKeyEnter() {
|
||||
if (activeFilter.value) {
|
||||
filterPanelRef.value?.selectCurrent()
|
||||
} else {
|
||||
selectCurrentResult()
|
||||
}
|
||||
}
|
||||
|
||||
function navigateResults(direction: number) {
|
||||
const newIndex = selectedIndex.value + direction
|
||||
if (newIndex >= 0 && newIndex < displayedResults.value.length) {
|
||||
selectedIndex.value = newIndex
|
||||
nextTick(() => {
|
||||
const el = dialogRef.value?.querySelector(
|
||||
`#result-item-${newIndex}`
|
||||
) as HTMLElement | null
|
||||
el?.scrollIntoView({ block: 'nearest' })
|
||||
if (focusItem) el?.focus()
|
||||
dialogRef.value
|
||||
?.querySelector(`#result-item-${newIndex}`)
|
||||
?.scrollIntoView({ block: 'nearest' })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { render, within } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
@@ -13,11 +13,7 @@ import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: vi.fn(() => ({
|
||||
get: vi.fn((key: string) => {
|
||||
if (key === 'Comfy.NodeLibrary.Bookmarks.V2') return []
|
||||
if (key === 'Comfy.NodeLibrary.BookmarksCustomization') return {}
|
||||
return undefined
|
||||
}),
|
||||
get: vi.fn(() => undefined),
|
||||
set: vi.fn()
|
||||
}))
|
||||
}))
|
||||
@@ -37,81 +33,57 @@ describe(NodeSearchFilterBar, () => {
|
||||
|
||||
async function createRender(props = {}) {
|
||||
const user = userEvent.setup()
|
||||
const onSelectCategory = vi.fn()
|
||||
render(NodeSearchFilterBar, {
|
||||
props: { onSelectCategory, ...props },
|
||||
global: {
|
||||
plugins: [testI18n],
|
||||
stubs: {
|
||||
NodeSearchTypeFilterPopover: {
|
||||
template: '<div data-testid="popover"><slot /></div>',
|
||||
props: ['chip', 'selectedValues']
|
||||
}
|
||||
}
|
||||
}
|
||||
const onSelectChip = vi.fn()
|
||||
const { container } = render(NodeSearchFilterBar, {
|
||||
props: { onSelectChip, ...props },
|
||||
global: { plugins: [testI18n] }
|
||||
})
|
||||
await nextTick()
|
||||
return { user, onSelectCategory }
|
||||
const view = within(container as HTMLElement)
|
||||
return { user, onSelectChip, view }
|
||||
}
|
||||
|
||||
it('should render Extensions button and Input/Output popover triggers', async () => {
|
||||
await createRender({ hasCustomNodes: true })
|
||||
it('should render all filter chips', async () => {
|
||||
const { view } = await createRender()
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const texts = buttons.map((b) => b.textContent?.trim())
|
||||
expect(texts).toContain('Extensions')
|
||||
expect(texts).toContain('Input')
|
||||
expect(texts).toContain('Output')
|
||||
const buttons = view.getAllByRole('button')
|
||||
expect(buttons).toHaveLength(6)
|
||||
expect(buttons[0]).toHaveTextContent('Blueprints')
|
||||
expect(buttons[1]).toHaveTextContent('Partner Nodes')
|
||||
expect(buttons[2]).toHaveTextContent('Essentials')
|
||||
expect(buttons[3]).toHaveTextContent('Extensions')
|
||||
expect(buttons[4]).toHaveTextContent('Input')
|
||||
expect(buttons[5]).toHaveTextContent('Output')
|
||||
})
|
||||
|
||||
it('should always render Comfy button', async () => {
|
||||
await createRender()
|
||||
const texts = screen
|
||||
.getAllByRole('button')
|
||||
.map((b) => b.textContent?.trim())
|
||||
expect(texts).toContain('Comfy')
|
||||
})
|
||||
it('should mark active chip as pressed when activeChipKey matches', async () => {
|
||||
const { view } = await createRender({ activeChipKey: 'input' })
|
||||
|
||||
it('should render conditional category buttons when matching nodes exist', async () => {
|
||||
await createRender({
|
||||
hasFavorites: true,
|
||||
hasEssentialNodes: true,
|
||||
hasBlueprintNodes: true,
|
||||
hasPartnerNodes: true
|
||||
})
|
||||
const texts = screen
|
||||
.getAllByRole('button')
|
||||
.map((b) => b.textContent?.trim())
|
||||
expect(texts).toContain('Bookmarked')
|
||||
expect(texts).toContain('Blueprints')
|
||||
expect(texts).toContain('Partner')
|
||||
expect(texts).toContain('Essentials')
|
||||
})
|
||||
|
||||
it('should not render Extensions button when no custom nodes exist', async () => {
|
||||
await createRender()
|
||||
const texts = screen
|
||||
.getAllByRole('button')
|
||||
.map((b) => b.textContent?.trim())
|
||||
expect(texts).not.toContain('Extensions')
|
||||
})
|
||||
|
||||
it('should emit selectCategory when category button is clicked', async () => {
|
||||
const { user, onSelectCategory } = await createRender({
|
||||
hasCustomNodes: true
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Extensions' }))
|
||||
|
||||
expect(onSelectCategory).toHaveBeenCalledWith('custom')
|
||||
})
|
||||
|
||||
it('should apply active styling when activeCategory matches', async () => {
|
||||
await createRender({ activeCategory: 'custom', hasCustomNodes: true })
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Extensions' })).toHaveAttribute(
|
||||
expect(view.getByRole('button', { name: 'Input' })).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'true'
|
||||
)
|
||||
})
|
||||
|
||||
it('should not mark chips as pressed when activeChipKey does not match', async () => {
|
||||
const { view } = await createRender({ activeChipKey: null })
|
||||
|
||||
view.getAllByRole('button').forEach((btn) => {
|
||||
expect(btn).toHaveAttribute('aria-pressed', 'false')
|
||||
})
|
||||
})
|
||||
|
||||
it('should emit selectChip with chip data when clicked', async () => {
|
||||
const { user, onSelectChip, view } = await createRender()
|
||||
|
||||
await user.click(view.getByRole('button', { name: 'Input' }))
|
||||
|
||||
expect(onSelectChip).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
key: 'input',
|
||||
label: 'Input',
|
||||
filter: expect.anything()
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,43 +1,22 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-2.5 px-3">
|
||||
<!-- Category filter buttons -->
|
||||
<div class="flex items-center gap-2 px-2 py-1.5">
|
||||
<button
|
||||
v-for="btn in categoryButtons"
|
||||
:key="btn.id"
|
||||
v-for="chip in chips"
|
||||
:key="chip.key"
|
||||
type="button"
|
||||
:aria-pressed="activeCategory === btn.id"
|
||||
:class="chipClass(activeCategory === btn.id)"
|
||||
@click="emit('selectCategory', btn.id)"
|
||||
:aria-pressed="activeChipKey === chip.key"
|
||||
:class="
|
||||
cn(
|
||||
'flex-auto cursor-pointer rounded-md border border-secondary-background px-3 py-1 text-sm transition-colors',
|
||||
activeChipKey === chip.key
|
||||
? 'text-foreground bg-secondary-background'
|
||||
: 'bg-transparent text-muted-foreground hover:border-base-foreground/60 hover:text-base-foreground/60'
|
||||
)
|
||||
"
|
||||
@click="emit('selectChip', chip)"
|
||||
>
|
||||
{{ btn.label }}
|
||||
{{ chip.label }}
|
||||
</button>
|
||||
|
||||
<div class="h-5 w-px shrink-0 bg-border-subtle" />
|
||||
|
||||
<!-- Type filter popovers (Input / Output) -->
|
||||
<NodeSearchTypeFilterPopover
|
||||
v-for="tf in typeFilters"
|
||||
:key="tf.chip.key"
|
||||
:chip="tf.chip"
|
||||
:selected-values="tf.values"
|
||||
@toggle="(v) => emit('toggleFilter', tf.chip.filter, v)"
|
||||
@clear="emit('clearFilterGroup', tf.chip.filter.id)"
|
||||
@escape-close="emit('focusSearch')"
|
||||
>
|
||||
<button type="button" :class="chipClass(false, tf.values.length > 0)">
|
||||
<span v-if="tf.values.length > 0" class="flex items-center">
|
||||
<span
|
||||
v-for="val in tf.values.slice(0, MAX_VISIBLE_DOTS)"
|
||||
:key="val"
|
||||
class="-mx-[2px] text-lg leading-none"
|
||||
:style="{ color: getLinkTypeColor(val) }"
|
||||
>•</span
|
||||
>
|
||||
</span>
|
||||
{{ tf.chip.label }}
|
||||
<i class="icon-[lucide--chevron-down] size-3.5" />
|
||||
</button>
|
||||
</NodeSearchTypeFilterPopover>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -56,97 +35,53 @@ export interface FilterChip {
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import NodeSearchTypeFilterPopover from '@/components/searchbox/v2/NodeSearchTypeFilterPopover.vue'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { BLUEPRINT_CATEGORY } from '@/types/nodeSource'
|
||||
import type { FuseFilterWithValue } from '@/utils/fuseUtil'
|
||||
import { getLinkTypeColor } from '@/utils/litegraphUtil'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const {
|
||||
filters = [],
|
||||
activeCategory = null,
|
||||
hasFavorites = false,
|
||||
hasEssentialNodes = false,
|
||||
hasBlueprintNodes = false,
|
||||
hasPartnerNodes = false,
|
||||
hasCustomNodes = false
|
||||
} = defineProps<{
|
||||
filters?: FuseFilterWithValue<ComfyNodeDefImpl, string>[]
|
||||
activeCategory?: string | null
|
||||
hasFavorites?: boolean
|
||||
hasEssentialNodes?: boolean
|
||||
hasBlueprintNodes?: boolean
|
||||
hasPartnerNodes?: boolean
|
||||
hasCustomNodes?: boolean
|
||||
const { activeChipKey = null } = defineProps<{
|
||||
activeChipKey?: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
toggleFilter: [filterDef: FuseFilter<ComfyNodeDefImpl, string>, value: string]
|
||||
clearFilterGroup: [filterId: string]
|
||||
focusSearch: []
|
||||
selectCategory: [category: string]
|
||||
selectChip: [chip: FilterChip]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
|
||||
const MAX_VISIBLE_DOTS = 4
|
||||
|
||||
const categoryButtons = computed(() => {
|
||||
const buttons: { id: string; label: string }[] = []
|
||||
if (hasFavorites) {
|
||||
buttons.push({ id: 'favorites', label: t('g.bookmarked') })
|
||||
}
|
||||
if (hasBlueprintNodes) {
|
||||
buttons.push({ id: BLUEPRINT_CATEGORY, label: t('g.blueprints') })
|
||||
}
|
||||
if (hasPartnerNodes) {
|
||||
buttons.push({ id: 'partner-nodes', label: t('g.partner') })
|
||||
}
|
||||
if (hasEssentialNodes) {
|
||||
buttons.push({ id: 'essentials', label: t('g.essentials') })
|
||||
}
|
||||
buttons.push({ id: 'comfy', label: t('g.comfy') })
|
||||
if (hasCustomNodes) {
|
||||
buttons.push({ id: 'custom', label: t('g.extensions') })
|
||||
}
|
||||
return buttons
|
||||
const chips = computed<FilterChip[]>(() => {
|
||||
const searchService = nodeDefStore.nodeSearchService
|
||||
return [
|
||||
{
|
||||
key: 'blueprints',
|
||||
label: t('sideToolbar.nodeLibraryTab.filterOptions.blueprints'),
|
||||
filter: searchService.nodeSourceFilter
|
||||
},
|
||||
{
|
||||
key: 'partnerNodes',
|
||||
label: t('sideToolbar.nodeLibraryTab.filterOptions.partnerNodes'),
|
||||
filter: searchService.nodeSourceFilter
|
||||
},
|
||||
{
|
||||
key: 'essentials',
|
||||
label: t('g.essentials'),
|
||||
filter: searchService.nodeSourceFilter
|
||||
},
|
||||
{
|
||||
key: 'extensions',
|
||||
label: t('g.extensions'),
|
||||
filter: searchService.nodeSourceFilter
|
||||
},
|
||||
{
|
||||
key: 'input',
|
||||
label: t('g.input'),
|
||||
filter: searchService.inputTypeFilter
|
||||
},
|
||||
{
|
||||
key: 'output',
|
||||
label: t('g.output'),
|
||||
filter: searchService.outputTypeFilter
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const inputChip = computed<FilterChip>(() => ({
|
||||
key: 'input',
|
||||
label: t('g.input'),
|
||||
filter: nodeDefStore.nodeSearchService.inputTypeFilter
|
||||
}))
|
||||
|
||||
const outputChip = computed<FilterChip>(() => ({
|
||||
key: 'output',
|
||||
label: t('g.output'),
|
||||
filter: nodeDefStore.nodeSearchService.outputTypeFilter
|
||||
}))
|
||||
|
||||
const selectedInputValues = computed(() =>
|
||||
filters.filter((f) => f.filterDef.id === 'input').map((f) => f.value)
|
||||
)
|
||||
|
||||
const selectedOutputValues = computed(() =>
|
||||
filters.filter((f) => f.filterDef.id === 'output').map((f) => f.value)
|
||||
)
|
||||
|
||||
const typeFilters = computed(() => [
|
||||
{ chip: inputChip.value, values: selectedInputValues.value },
|
||||
{ chip: outputChip.value, values: selectedOutputValues.value }
|
||||
])
|
||||
|
||||
function chipClass(isActive: boolean, hasSelections = false) {
|
||||
return cn(
|
||||
'flex cursor-pointer items-center justify-center gap-1 rounded-md border border-secondary-background px-3 py-1 font-inter text-sm transition-colors',
|
||||
isActive
|
||||
? 'border-base-foreground bg-base-foreground text-base-background'
|
||||
: hasSelections
|
||||
? 'border-base-foreground/60 bg-transparent text-base-foreground/60 hover:border-base-foreground/60 hover:text-base-foreground/60'
|
||||
: 'bg-transparent text-muted-foreground hover:border-base-foreground/60 hover:text-base-foreground/60'
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
90
src/components/searchbox/v2/NodeSearchFilterPanel.vue
Normal file
90
src/components/searchbox/v2/NodeSearchFilterPanel.vue
Normal file
@@ -0,0 +1,90 @@
|
||||
<template>
|
||||
<div
|
||||
id="filter-options-list"
|
||||
ref="listRef"
|
||||
role="listbox"
|
||||
class="flex-1 overflow-y-auto py-2"
|
||||
>
|
||||
<div
|
||||
v-for="(option, index) in options"
|
||||
:id="`filter-option-${index}`"
|
||||
:key="option"
|
||||
role="option"
|
||||
data-testid="filter-option"
|
||||
:aria-selected="index === selectedIndex"
|
||||
:class="
|
||||
cn(
|
||||
'cursor-pointer px-6 py-1.5',
|
||||
index === selectedIndex && 'bg-secondary-background-hover'
|
||||
)
|
||||
"
|
||||
@click="emit('apply', option)"
|
||||
@mouseenter="selectedIndex = index"
|
||||
>
|
||||
<span class="text-foreground text-base font-semibold">
|
||||
<span class="mr-1 text-2xl" :style="{ color: getLinkTypeColor(option) }"
|
||||
>•</span
|
||||
>
|
||||
{{ option }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="options.length === 0"
|
||||
class="px-4 py-8 text-center text-muted-foreground"
|
||||
>
|
||||
{{ $t('g.noResults') }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
|
||||
import type { FilterChip } from '@/components/searchbox/v2/NodeSearchFilterBar.vue'
|
||||
import { getLinkTypeColor } from '@/utils/litegraphUtil'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { chip } = defineProps<{
|
||||
chip: FilterChip
|
||||
}>()
|
||||
|
||||
const query = defineModel<string>('query', { required: true })
|
||||
|
||||
const emit = defineEmits<{
|
||||
apply: [value: string]
|
||||
}>()
|
||||
|
||||
const listRef = ref<HTMLElement>()
|
||||
const selectedIndex = ref(0)
|
||||
|
||||
const options = computed(() => {
|
||||
const { fuseSearch } = chip.filter
|
||||
if (query.value) {
|
||||
return fuseSearch.search(query.value).slice(0, 64)
|
||||
}
|
||||
return fuseSearch.data.slice().sort()
|
||||
})
|
||||
|
||||
watch(query, () => {
|
||||
selectedIndex.value = 0
|
||||
})
|
||||
|
||||
function navigate(direction: number) {
|
||||
const newIndex = selectedIndex.value + direction
|
||||
if (newIndex >= 0 && newIndex < options.value.length) {
|
||||
selectedIndex.value = newIndex
|
||||
nextTick(() => {
|
||||
listRef.value
|
||||
?.querySelector(`#filter-option-${newIndex}`)
|
||||
?.scrollIntoView({ block: 'nearest' })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function selectCurrent() {
|
||||
const option = options.value[selectedIndex.value]
|
||||
if (option) emit('apply', option)
|
||||
}
|
||||
|
||||
defineExpose({ navigate, selectCurrent })
|
||||
</script>
|
||||
@@ -2,6 +2,7 @@ import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { FilterChip } from '@/components/searchbox/v2/NodeSearchFilterBar.vue'
|
||||
import NodeSearchInput from '@/components/searchbox/v2/NodeSearchInput.vue'
|
||||
import {
|
||||
setupTestPinia,
|
||||
@@ -18,11 +19,7 @@ vi.mock('@/utils/litegraphUtil', () => ({
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: vi.fn(() => ({
|
||||
get: vi.fn((key: string) => {
|
||||
if (key === 'Comfy.NodeLibrary.Bookmarks.V2') return []
|
||||
if (key === 'Comfy.NodeLibrary.BookmarksCustomization') return {}
|
||||
return undefined
|
||||
}),
|
||||
get: vi.fn(),
|
||||
set: vi.fn()
|
||||
}))
|
||||
}))
|
||||
@@ -43,6 +40,20 @@ function createFilter(
|
||||
}
|
||||
}
|
||||
|
||||
function createActiveFilter(label: string): FilterChip {
|
||||
return {
|
||||
key: label.toLowerCase(),
|
||||
label,
|
||||
filter: {
|
||||
id: label.toLowerCase(),
|
||||
matches: vi.fn(() => true)
|
||||
} as Partial<FuseFilter<ComfyNodeDefImpl, string>> as FuseFilter<
|
||||
ComfyNodeDefImpl,
|
||||
string
|
||||
>
|
||||
}
|
||||
}
|
||||
|
||||
describe('NodeSearchInput', () => {
|
||||
beforeEach(() => {
|
||||
setupTestPinia()
|
||||
@@ -52,19 +63,27 @@ describe('NodeSearchInput', () => {
|
||||
function createRender(
|
||||
props: Partial<{
|
||||
filters: FuseFilterWithValue<ComfyNodeDefImpl, string>[]
|
||||
activeFilter: FilterChip | null
|
||||
searchQuery: string
|
||||
filterQuery: string
|
||||
}> = {}
|
||||
) {
|
||||
const user = userEvent.setup()
|
||||
const onUpdateSearchQuery = vi.fn()
|
||||
const onUpdateFilterQuery = vi.fn()
|
||||
const onCancelFilter = vi.fn()
|
||||
const onSelectCurrent = vi.fn()
|
||||
const onNavigateDown = vi.fn()
|
||||
const onNavigateUp = vi.fn()
|
||||
render(NodeSearchInput, {
|
||||
props: {
|
||||
filters: [],
|
||||
activeFilter: null,
|
||||
searchQuery: '',
|
||||
filterQuery: '',
|
||||
'onUpdate:searchQuery': onUpdateSearchQuery,
|
||||
'onUpdate:filterQuery': onUpdateFilterQuery,
|
||||
onCancelFilter,
|
||||
onSelectCurrent,
|
||||
onNavigateDown,
|
||||
onNavigateUp,
|
||||
@@ -75,20 +94,43 @@ describe('NodeSearchInput', () => {
|
||||
return {
|
||||
user,
|
||||
onUpdateSearchQuery,
|
||||
onUpdateFilterQuery,
|
||||
onCancelFilter,
|
||||
onSelectCurrent,
|
||||
onNavigateDown,
|
||||
onNavigateUp
|
||||
}
|
||||
}
|
||||
|
||||
it('should route input to searchQuery', async () => {
|
||||
it('should route input to searchQuery when no active filter', async () => {
|
||||
const { user, onUpdateSearchQuery } = createRender()
|
||||
await user.type(screen.getByRole('combobox'), 'test search')
|
||||
|
||||
expect(onUpdateSearchQuery).toHaveBeenLastCalledWith('test search')
|
||||
})
|
||||
|
||||
it('should show add node placeholder', () => {
|
||||
it('should route input to filterQuery when active filter is set', async () => {
|
||||
const { user, onUpdateFilterQuery, onUpdateSearchQuery } = createRender({
|
||||
activeFilter: createActiveFilter('Input')
|
||||
})
|
||||
await user.type(screen.getByRole('combobox'), 'IMAGE')
|
||||
|
||||
expect(onUpdateFilterQuery).toHaveBeenLastCalledWith('IMAGE')
|
||||
expect(onUpdateSearchQuery).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show filter label placeholder when active filter is set', () => {
|
||||
createRender({
|
||||
activeFilter: createActiveFilter('Input')
|
||||
})
|
||||
|
||||
expect(screen.getByRole('combobox')).toHaveAttribute(
|
||||
'placeholder',
|
||||
expect.stringContaining('input')
|
||||
)
|
||||
})
|
||||
|
||||
it('should show add node placeholder when no active filter', () => {
|
||||
createRender()
|
||||
|
||||
expect(screen.getByRole('combobox')).toHaveAttribute(
|
||||
@@ -97,7 +139,16 @@ describe('NodeSearchInput', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('should show filter chips when filters are present', () => {
|
||||
it('should hide filter chips when active filter is set', () => {
|
||||
createRender({
|
||||
filters: [createFilter('input', 'IMAGE')],
|
||||
activeFilter: createActiveFilter('Input')
|
||||
})
|
||||
|
||||
expect(screen.queryAllByTestId('filter-chip')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should show filter chips when no active filter', () => {
|
||||
createRender({
|
||||
filters: [createFilter('input', 'IMAGE')]
|
||||
})
|
||||
@@ -105,6 +156,16 @@ describe('NodeSearchInput', () => {
|
||||
expect(screen.getAllByTestId('filter-chip')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should emit cancelFilter when cancel button is clicked', async () => {
|
||||
const { user, onCancelFilter } = createRender({
|
||||
activeFilter: createActiveFilter('Input')
|
||||
})
|
||||
|
||||
await user.click(screen.getByTestId('cancel-filter'))
|
||||
|
||||
expect(onCancelFilter).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('should emit selectCurrent on Enter', async () => {
|
||||
const { user, onSelectCurrent } = createRender()
|
||||
|
||||
|
||||
@@ -7,41 +7,61 @@
|
||||
@remove-tag="onRemoveTag"
|
||||
@click="inputRef?.focus()"
|
||||
>
|
||||
<!-- Applied filter chips -->
|
||||
<TagsInputItem
|
||||
v-for="filter in filters"
|
||||
:key="filterKey(filter)"
|
||||
:value="filterKey(filter)"
|
||||
data-testid="filter-chip"
|
||||
class="-my-1 inline-flex items-center gap-1 rounded-lg bg-base-background px-2 py-1 data-[state=active]:ring-2 data-[state=active]:ring-primary"
|
||||
<!-- Active filter label (filter selection mode) -->
|
||||
<span
|
||||
v-if="activeFilter"
|
||||
class="text-foreground -my-1 inline-flex shrink-0 items-center gap-1 rounded-lg bg-base-background px-2 py-1 text-sm opacity-80"
|
||||
>
|
||||
<span class="text-sm opacity-80">
|
||||
{{ t(`g.${filter.filterDef.id}`) }}:
|
||||
</span>
|
||||
<span :style="{ color: getLinkTypeColor(filter.value) }"> • </span>
|
||||
<span class="text-sm">{{ filter.value }}</span>
|
||||
<TagsInputItemDelete
|
||||
as="button"
|
||||
{{ activeFilter.label }}:
|
||||
<button
|
||||
type="button"
|
||||
data-testid="chip-delete"
|
||||
data-testid="cancel-filter"
|
||||
class="aspect-square cursor-pointer rounded-full border-none bg-transparent text-muted-foreground hover:text-base-foreground"
|
||||
:aria-label="$t('g.remove')"
|
||||
class="ml-1 flex aspect-square cursor-pointer items-center justify-center rounded-full border-none bg-transparent text-muted-foreground hover:text-base-foreground"
|
||||
@click="emit('cancelFilter')"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-3" />
|
||||
</TagsInputItemDelete>
|
||||
</TagsInputItem>
|
||||
<i class="pi pi-times text-xs" />
|
||||
</button>
|
||||
</span>
|
||||
<!-- Applied filter chips -->
|
||||
<template v-if="!activeFilter">
|
||||
<TagsInputItem
|
||||
v-for="filter in filters"
|
||||
:key="filterKey(filter)"
|
||||
:value="filterKey(filter)"
|
||||
data-testid="filter-chip"
|
||||
class="-my-1 inline-flex items-center gap-1 rounded-lg bg-base-background px-2 py-1 data-[state=active]:ring-2 data-[state=active]:ring-primary"
|
||||
>
|
||||
<span class="text-sm opacity-80">
|
||||
{{ t(`g.${filter.filterDef.id}`) }}:
|
||||
</span>
|
||||
<span :style="{ color: getLinkTypeColor(filter.value) }">
|
||||
•
|
||||
</span>
|
||||
<span class="text-sm">{{ filter.value }}</span>
|
||||
<TagsInputItemDelete
|
||||
as="button"
|
||||
type="button"
|
||||
data-testid="chip-delete"
|
||||
:aria-label="$t('g.remove')"
|
||||
class="ml-1 aspect-square cursor-pointer rounded-full border-none bg-transparent text-muted-foreground hover:text-base-foreground"
|
||||
>
|
||||
<i class="pi pi-times text-xs" />
|
||||
</TagsInputItemDelete>
|
||||
</TagsInputItem>
|
||||
</template>
|
||||
<TagsInputInput as-child>
|
||||
<input
|
||||
ref="inputRef"
|
||||
v-model="searchQuery"
|
||||
v-model="inputValue"
|
||||
type="text"
|
||||
role="combobox"
|
||||
aria-autocomplete="list"
|
||||
:aria-expanded="true"
|
||||
aria-controls="results-list"
|
||||
:aria-label="t('g.addNode')"
|
||||
:placeholder="t('g.addNode')"
|
||||
class="text-foreground h-6 min-w-[min(300px,80vw)] flex-1 border-none bg-transparent font-inter text-sm outline-none placeholder:text-muted-foreground"
|
||||
:aria-controls="activeFilter ? 'filter-options-list' : 'results-list'"
|
||||
:aria-label="inputPlaceholder"
|
||||
:placeholder="inputPlaceholder"
|
||||
class="text-foreground h-6 min-w-[min(300px,80vw)] flex-1 border-none bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
||||
@keydown.enter.prevent="emit('selectCurrent')"
|
||||
@keydown.down.prevent="emit('navigateDown')"
|
||||
@keydown.up.prevent="emit('navigateUp')"
|
||||
@@ -61,18 +81,22 @@ import {
|
||||
TagsInputRoot
|
||||
} from 'reka-ui'
|
||||
|
||||
import type { FilterChip } from '@/components/searchbox/v2/NodeSearchFilterBar.vue'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import type { FuseFilterWithValue } from '@/utils/fuseUtil'
|
||||
import { getLinkTypeColor } from '@/utils/litegraphUtil'
|
||||
|
||||
const { filters } = defineProps<{
|
||||
const { filters, activeFilter } = defineProps<{
|
||||
filters: FuseFilterWithValue<ComfyNodeDefImpl, string>[]
|
||||
activeFilter: FilterChip | null
|
||||
}>()
|
||||
|
||||
const searchQuery = defineModel<string>('searchQuery', { required: true })
|
||||
const filterQuery = defineModel<string>('filterQuery', { required: true })
|
||||
|
||||
const emit = defineEmits<{
|
||||
removeFilter: [filter: FuseFilterWithValue<ComfyNodeDefImpl, string>]
|
||||
cancelFilter: []
|
||||
navigateDown: []
|
||||
navigateUp: []
|
||||
selectCurrent: []
|
||||
@@ -81,6 +105,23 @@ const emit = defineEmits<{
|
||||
const { t } = useI18n()
|
||||
const inputRef = ref<HTMLInputElement>()
|
||||
|
||||
const inputValue = computed({
|
||||
get: () => (activeFilter ? filterQuery.value : searchQuery.value),
|
||||
set: (value: string) => {
|
||||
if (activeFilter) {
|
||||
filterQuery.value = value
|
||||
} else {
|
||||
searchQuery.value = value
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const inputPlaceholder = computed(() =>
|
||||
activeFilter
|
||||
? t('g.filterByType', { type: activeFilter.label.toLowerCase() })
|
||||
: t('g.addNode')
|
||||
)
|
||||
|
||||
const tagValues = computed(() => filters.map(filterKey))
|
||||
|
||||
function filterKey(filter: FuseFilterWithValue<ComfyNodeDefImpl, string>) {
|
||||
|
||||
@@ -1,292 +0,0 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ComponentProps } from 'vue-component-type-helpers'
|
||||
|
||||
import NodeSearchListItem from '@/components/searchbox/v2/NodeSearchListItem.vue'
|
||||
import {
|
||||
createMockNodeDef,
|
||||
setupTestPinia,
|
||||
testI18n
|
||||
} from '@/components/searchbox/v2/__test__/testUtils'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useNodeFrequencyStore } from '@/stores/nodeDefStore'
|
||||
|
||||
function renderItem(
|
||||
props: Partial<ComponentProps<typeof NodeSearchListItem>> = {}
|
||||
) {
|
||||
return render(NodeSearchListItem, {
|
||||
props: { nodeDef: createMockNodeDef(), currentQuery: '', ...props },
|
||||
global: {
|
||||
plugins: [testI18n],
|
||||
stubs: {
|
||||
NodePricingBadge: {
|
||||
template: '<div data-testid="pricing-badge" />',
|
||||
props: ['nodeDef']
|
||||
},
|
||||
ComfyLogo: { template: '<div data-testid="comfy-logo" />' }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('NodeSearchListItem', () => {
|
||||
beforeEach(() => {
|
||||
setupTestPinia()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('id name badge', () => {
|
||||
it('shows id name when ShowIdName setting is enabled', () => {
|
||||
useSettingStore().settingValues['Comfy.NodeSearchBoxImpl.ShowIdName'] =
|
||||
true
|
||||
renderItem({
|
||||
nodeDef: createMockNodeDef({
|
||||
name: 'KSamplerNode',
|
||||
display_name: 'KSampler'
|
||||
})
|
||||
})
|
||||
expect(screen.getByText('KSamplerNode')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides id name by default', () => {
|
||||
renderItem({
|
||||
nodeDef: createMockNodeDef({
|
||||
name: 'KSamplerNode',
|
||||
display_name: 'KSampler'
|
||||
})
|
||||
})
|
||||
expect(screen.queryByText('KSamplerNode')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('showDescription mode', () => {
|
||||
it('renders description text', () => {
|
||||
renderItem({
|
||||
nodeDef: createMockNodeDef({ description: 'A sampler node' }),
|
||||
showDescription: true
|
||||
})
|
||||
expect(screen.getByText('A sampler node')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders category when ShowCategory setting is enabled', () => {
|
||||
useSettingStore().settingValues['Comfy.NodeSearchBoxImpl.ShowCategory'] =
|
||||
true
|
||||
renderItem({
|
||||
nodeDef: createMockNodeDef({ category: 'sampling/advanced' }),
|
||||
showDescription: true
|
||||
})
|
||||
expect(screen.getByText('sampling / advanced')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides category by default', () => {
|
||||
renderItem({
|
||||
nodeDef: createMockNodeDef({ category: 'sampling' }),
|
||||
showDescription: true
|
||||
})
|
||||
expect(screen.queryByText('sampling')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('source badge', () => {
|
||||
it('renders core comfy badge for non-custom node when showSourceBadge is true', () => {
|
||||
renderItem({
|
||||
nodeDef: createMockNodeDef({ python_module: 'nodes' }),
|
||||
showDescription: true,
|
||||
showSourceBadge: true
|
||||
})
|
||||
expect(screen.getByTestId('comfy-logo')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders custom node badge for custom node when showSourceBadge is true', () => {
|
||||
renderItem({
|
||||
nodeDef: createMockNodeDef({
|
||||
python_module: 'custom_nodes.my_extension',
|
||||
display_name: 'CustomNode'
|
||||
}),
|
||||
showDescription: true,
|
||||
showSourceBadge: true
|
||||
})
|
||||
expect(screen.getByText('my_extension')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render source badge when showSourceBadge is false', () => {
|
||||
renderItem({
|
||||
nodeDef: createMockNodeDef({ python_module: 'nodes' }),
|
||||
showDescription: true,
|
||||
showSourceBadge: false
|
||||
})
|
||||
expect(screen.queryByTestId('comfy-logo')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders essentials badge for essentials node when showSourceBadge is true', () => {
|
||||
renderItem({
|
||||
nodeDef: createMockNodeDef({
|
||||
python_module: 'custom_nodes.my_essentials',
|
||||
essentials_category: 'essentials'
|
||||
}),
|
||||
showDescription: true,
|
||||
showSourceBadge: true
|
||||
})
|
||||
expect(screen.getByText('my_essentials')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders blueprint badge for blueprint node when showSourceBadge is true', () => {
|
||||
renderItem({
|
||||
nodeDef: createMockNodeDef({
|
||||
python_module: 'blueprint.my_blueprint'
|
||||
}),
|
||||
showDescription: true,
|
||||
showSourceBadge: true
|
||||
})
|
||||
expect(screen.getByText('Blueprint')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('API node provider badge', () => {
|
||||
it('renders provider badge only when nodeDef.api_node is true', () => {
|
||||
renderItem({
|
||||
nodeDef: createMockNodeDef({
|
||||
api_node: true,
|
||||
category: 'api/image/BFL'
|
||||
})
|
||||
})
|
||||
expect(screen.getByText('BFL')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render provider badge when nodeDef.api_node is false', () => {
|
||||
renderItem({
|
||||
nodeDef: createMockNodeDef({
|
||||
api_node: false,
|
||||
category: 'api/image/BFL'
|
||||
})
|
||||
})
|
||||
expect(screen.queryByText('BFL')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('status flags', () => {
|
||||
it('shows deprecated label when deprecated', () => {
|
||||
renderItem({ nodeDef: createMockNodeDef({ deprecated: true }) })
|
||||
expect(screen.getByText('DEPR')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows experimental label when experimental', () => {
|
||||
renderItem({ nodeDef: createMockNodeDef({ experimental: true }) })
|
||||
expect(screen.getByText('BETA')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows devOnly label when dev_only is set', () => {
|
||||
renderItem({ nodeDef: createMockNodeDef({ dev_only: true }) })
|
||||
expect(screen.getByText('DEV')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show flags in description mode', () => {
|
||||
renderItem({
|
||||
nodeDef: createMockNodeDef({ deprecated: true, experimental: true }),
|
||||
showDescription: true
|
||||
})
|
||||
expect(screen.queryByText('DEPR')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('BETA')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('node frequency badge', () => {
|
||||
it('shows frequency when ShowNodeFrequency is enabled and frequency > 0', () => {
|
||||
useSettingStore().settingValues[
|
||||
'Comfy.NodeSearchBoxImpl.ShowNodeFrequency'
|
||||
] = true
|
||||
vi.spyOn(useNodeFrequencyStore(), 'getNodeFrequency').mockReturnValue(
|
||||
1500
|
||||
)
|
||||
renderItem({ nodeDef: createMockNodeDef() })
|
||||
const badge = screen.getByTestId('frequency-badge')
|
||||
expect(badge).toBeInTheDocument()
|
||||
expect(badge.textContent).toMatch(/1\.5k/i)
|
||||
})
|
||||
|
||||
it('hides frequency when frequency is 0 even if setting is enabled', () => {
|
||||
useSettingStore().settingValues[
|
||||
'Comfy.NodeSearchBoxImpl.ShowNodeFrequency'
|
||||
] = true
|
||||
vi.spyOn(useNodeFrequencyStore(), 'getNodeFrequency').mockReturnValue(0)
|
||||
renderItem({ nodeDef: createMockNodeDef() })
|
||||
expect(screen.queryByTestId('frequency-badge')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides frequency when setting is disabled even if frequency > 0', () => {
|
||||
useSettingStore().settingValues[
|
||||
'Comfy.NodeSearchBoxImpl.ShowNodeFrequency'
|
||||
] = false
|
||||
vi.spyOn(useNodeFrequencyStore(), 'getNodeFrequency').mockReturnValue(
|
||||
9999
|
||||
)
|
||||
renderItem({ nodeDef: createMockNodeDef() })
|
||||
expect(screen.queryByTestId('frequency-badge')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('bookmark icon', () => {
|
||||
it('shows bookmark icon when node is bookmarked', () => {
|
||||
useSettingStore().settingValues['Comfy.NodeLibrary.Bookmarks.V2'] = [
|
||||
'TestNode'
|
||||
]
|
||||
renderItem({ nodeDef: createMockNodeDef({ name: 'TestNode' }) })
|
||||
expect(
|
||||
screen.getByRole('img', { name: 'Bookmarked' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show bookmark icon when node is not bookmarked', () => {
|
||||
renderItem({ nodeDef: createMockNodeDef({ name: 'TestNode' }) })
|
||||
expect(
|
||||
screen.queryByRole('img', { name: 'Bookmarked' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides bookmark icon when hideBookmarkIcon prop is true', () => {
|
||||
useSettingStore().settingValues['Comfy.NodeLibrary.Bookmarks.V2'] = [
|
||||
'TestNode'
|
||||
]
|
||||
renderItem({
|
||||
nodeDef: createMockNodeDef({ name: 'TestNode' }),
|
||||
hideBookmarkIcon: true
|
||||
})
|
||||
expect(
|
||||
screen.queryByRole('img', { name: 'Bookmarked' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('query highlighting', () => {
|
||||
it('wraps matching portion of display_name in a highlight span', () => {
|
||||
renderItem({
|
||||
nodeDef: createMockNodeDef({ display_name: 'KSampler Advanced' }),
|
||||
currentQuery: 'Sampler'
|
||||
})
|
||||
expect(
|
||||
screen.getByText('Sampler', { selector: 'span.highlight' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not wrap anything when currentQuery is empty', () => {
|
||||
renderItem({
|
||||
nodeDef: createMockNodeDef({ display_name: 'KSampler' }),
|
||||
currentQuery: ''
|
||||
})
|
||||
expect(
|
||||
screen.queryByText('KSampler', { selector: 'span.highlight' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('node source display text', () => {
|
||||
it('shows custom node source displayText in non-description mode', () => {
|
||||
renderItem({
|
||||
nodeDef: createMockNodeDef({
|
||||
python_module: 'custom_nodes.my_extension'
|
||||
})
|
||||
})
|
||||
expect(screen.getByText('my_extension')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -2,84 +2,46 @@
|
||||
<div
|
||||
class="option-container flex w-full cursor-pointer items-center justify-between overflow-hidden"
|
||||
>
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1 overflow-hidden">
|
||||
<!-- Row 1: Name (left) + badges (right) -->
|
||||
<div class="text-foreground flex items-center gap-2 text-sm">
|
||||
<span
|
||||
v-if="isBookmarked && !hideBookmarkIcon"
|
||||
role="img"
|
||||
:aria-label="$t('g.bookmarked')"
|
||||
>
|
||||
<i aria-hidden="true" class="pi pi-bookmark-fill mr-1 text-sm" />
|
||||
<div class="flex flex-col gap-0.5 overflow-hidden">
|
||||
<div class="text-foreground flex items-center gap-2 font-semibold">
|
||||
<span v-if="isBookmarked && !hideBookmarkIcon">
|
||||
<i class="pi pi-bookmark-fill mr-1 text-sm" />
|
||||
</span>
|
||||
<span
|
||||
class="truncate"
|
||||
v-html="highlightQuery(nodeDef.display_name, currentQuery)"
|
||||
/>
|
||||
<span v-html="highlightQuery(nodeDef.display_name, currentQuery)" />
|
||||
<span v-if="showIdName"> </span>
|
||||
<span
|
||||
v-if="showIdName"
|
||||
class="shrink-0 rounded-sm bg-secondary-background px-1.5 py-0.5 text-xs text-muted-foreground"
|
||||
class="rounded-sm bg-secondary-background px-1.5 py-0.5 text-xs text-muted-foreground"
|
||||
v-html="highlightQuery(nodeDef.name, currentQuery)"
|
||||
/>
|
||||
|
||||
<template v-if="showDescription">
|
||||
<div class="flex-1" />
|
||||
<div class="flex shrink-0 items-center gap-1">
|
||||
<span
|
||||
v-if="showSourceBadge && isCore"
|
||||
aria-hidden="true"
|
||||
class="flex size-[18px] shrink-0 items-center justify-center rounded-full bg-secondary-background-hover/80"
|
||||
>
|
||||
<ComfyLogo :size="10" mode="fill" color="currentColor" />
|
||||
</span>
|
||||
<span
|
||||
v-else-if="
|
||||
showSourceBadge &&
|
||||
nodeDef.nodeSource.type !== NodeSourceType.Unknown
|
||||
"
|
||||
:class="badgePillClass"
|
||||
>
|
||||
<span class="truncate text-2xs">
|
||||
{{ nodeDef.nodeSource.displayText }}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span
|
||||
v-if="nodeDef.api_node && providerName"
|
||||
:class="badgePillClass"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--component] size-3 text-amber-400"
|
||||
/>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
:class="cn(getProviderIcon(providerName), 'size-3')"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<NodePricingBadge :node-def="nodeDef" />
|
||||
<NodeProviderBadge v-if="nodeDef.api_node" :node-def="nodeDef" />
|
||||
</template>
|
||||
<NodePricingBadge :node-def="nodeDef" />
|
||||
<NodeProviderBadge v-if="nodeDef.api_node" :node-def="nodeDef" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="showDescription"
|
||||
class="flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground"
|
||||
class="flex items-center gap-1 text-2xs text-muted-foreground"
|
||||
>
|
||||
<span v-if="showCategory" class="max-w-2/5 shrink-0 truncate">
|
||||
{{ nodeDef.category.replaceAll('/', ' / ') }}
|
||||
</span>
|
||||
<span
|
||||
v-if="nodeDef.description && showCategory"
|
||||
class="h-3 w-px shrink-0 bg-border-default"
|
||||
/>
|
||||
<TextTicker v-if="nodeDef.description" class="min-w-0 flex-1">
|
||||
v-if="
|
||||
showSourceBadge &&
|
||||
nodeDef.nodeSource.type !== NodeSourceType.Core &&
|
||||
nodeDef.nodeSource.type !== NodeSourceType.Unknown
|
||||
"
|
||||
class="border-border mr-0.5 inline-flex shrink-0 rounded-sm border bg-base-foreground/5 px-1.5 py-0.5 text-xs text-base-foreground/70"
|
||||
>
|
||||
{{ nodeDef.nodeSource.displayText }}
|
||||
</span>
|
||||
<TextTicker v-if="nodeDef.description">
|
||||
{{ nodeDef.description }}
|
||||
</TextTicker>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="showCategory"
|
||||
class="option-category truncate text-sm font-light text-muted"
|
||||
>
|
||||
{{ nodeDef.category.replaceAll('/', ' > ') }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!showDescription" class="flex items-center gap-1">
|
||||
<span
|
||||
@@ -102,7 +64,6 @@
|
||||
</span>
|
||||
<span
|
||||
v-if="showNodeFrequency && nodeFrequency > 0"
|
||||
data-testid="frequency-badge"
|
||||
class="rounded-sm bg-secondary-background px-1.5 py-0.5 text-xs text-muted-foreground"
|
||||
>
|
||||
{{ formatNumberWithSuffix(nodeFrequency, { roundToInt: true }) }}
|
||||
@@ -121,17 +82,14 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import TextTicker from '@/components/common/TextTicker.vue'
|
||||
import ComfyLogo from '@/components/icons/ComfyLogo.vue'
|
||||
import NodePricingBadge from '@/components/node/NodePricingBadge.vue'
|
||||
import NodeProviderBadge from '@/components/node/NodeProviderBadge.vue'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { useNodeFrequencyStore } from '@/stores/nodeDefStore'
|
||||
import { CORE_NODE_MODULES, NodeSourceType } from '@/types/nodeSource'
|
||||
import { getProviderIcon, getProviderName } from '@/utils/categoryUtil'
|
||||
import { NodeSourceType } from '@/types/nodeSource'
|
||||
import { formatNumberWithSuffix, highlightQuery } from '@/utils/formatUtil'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const {
|
||||
nodeDef,
|
||||
@@ -147,9 +105,6 @@ const {
|
||||
hideBookmarkIcon?: boolean
|
||||
}>()
|
||||
|
||||
const badgePillClass =
|
||||
'flex h-[18px] max-w-28 shrink-0 items-center justify-center gap-1 rounded-full bg-secondary-background-hover/80 px-2'
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const showCategory = computed(() =>
|
||||
settingStore.get('Comfy.NodeSearchBoxImpl.ShowCategory')
|
||||
@@ -167,8 +122,4 @@ const nodeFrequency = computed(() =>
|
||||
|
||||
const nodeBookmarkStore = useNodeBookmarkStore()
|
||||
const isBookmarked = computed(() => nodeBookmarkStore.isBookmarked(nodeDef))
|
||||
const providerName = computed(() => getProviderName(nodeDef.category))
|
||||
const isCore = computed(() =>
|
||||
CORE_NODE_MODULES.includes(nodeDef.python_module.split('.')[0])
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
import { render, screen, waitFor } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import NodeSearchTypeFilterPopover from '@/components/searchbox/v2/NodeSearchTypeFilterPopover.vue'
|
||||
import type { FilterChip } from '@/components/searchbox/v2/NodeSearchFilterBar.vue'
|
||||
import { testI18n } from '@/components/searchbox/v2/__test__/testUtils'
|
||||
|
||||
function createMockChip(
|
||||
data: string[] = ['IMAGE', 'LATENT', 'MODEL']
|
||||
): FilterChip {
|
||||
return {
|
||||
key: 'input',
|
||||
label: 'Input',
|
||||
filter: {
|
||||
id: 'input',
|
||||
matches: vi.fn(),
|
||||
fuseSearch: {
|
||||
search: vi.fn((query: string) =>
|
||||
data.filter((d) => d.toLowerCase().includes(query.toLowerCase()))
|
||||
),
|
||||
data
|
||||
}
|
||||
} as unknown as FilterChip['filter']
|
||||
}
|
||||
}
|
||||
|
||||
describe(NodeSearchTypeFilterPopover, () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
function createRender(
|
||||
props: {
|
||||
chip?: FilterChip
|
||||
selectedValues?: string[]
|
||||
} = {}
|
||||
) {
|
||||
const user = userEvent.setup()
|
||||
const onToggle = vi.fn()
|
||||
const onClear = vi.fn()
|
||||
const onEscapeClose = vi.fn()
|
||||
render(NodeSearchTypeFilterPopover, {
|
||||
props: {
|
||||
chip: props.chip ?? createMockChip(),
|
||||
selectedValues: props.selectedValues ?? [],
|
||||
onToggle,
|
||||
onClear,
|
||||
onEscapeClose
|
||||
},
|
||||
slots: {
|
||||
default: '<button data-testid="trigger">Input</button>'
|
||||
},
|
||||
global: { plugins: [testI18n] }
|
||||
})
|
||||
return { user, onToggle, onClear, onEscapeClose }
|
||||
}
|
||||
|
||||
async function openPopover(user: ReturnType<typeof userEvent.setup>) {
|
||||
await user.click(screen.getByTestId('trigger'))
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
it('should render the trigger slot', () => {
|
||||
createRender()
|
||||
expect(screen.getByTestId('trigger')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show popover content when trigger is clicked', async () => {
|
||||
const { user } = createRender()
|
||||
await openPopover(user)
|
||||
expect(screen.getByRole('listbox')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display all options sorted alphabetically', async () => {
|
||||
const { user } = createRender({
|
||||
chip: createMockChip(['MODEL', 'IMAGE', 'LATENT'])
|
||||
})
|
||||
await openPopover(user)
|
||||
|
||||
const options = screen.getAllByRole('option')
|
||||
expect(options).toHaveLength(3)
|
||||
const texts = options.map((o) => o.textContent?.trim())
|
||||
expect(texts[0]).toContain('IMAGE')
|
||||
expect(texts[1]).toContain('LATENT')
|
||||
expect(texts[2]).toContain('MODEL')
|
||||
})
|
||||
|
||||
it('should show selected count text', async () => {
|
||||
const { user } = createRender({ selectedValues: ['IMAGE', 'LATENT'] })
|
||||
await openPopover(user)
|
||||
|
||||
expect(screen.getByText(/2 items selected/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show clear all button only when values are selected', async () => {
|
||||
const { user } = createRender({ selectedValues: [] })
|
||||
await openPopover(user)
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const clearBtn = buttons.find((b) => b.textContent?.includes('Clear all'))
|
||||
expect(clearBtn).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should show clear all button when values are selected', async () => {
|
||||
const { user } = createRender({ selectedValues: ['IMAGE'] })
|
||||
await openPopover(user)
|
||||
|
||||
expect(
|
||||
screen
|
||||
.getAllByRole('button')
|
||||
.find((b) => b.textContent?.includes('Clear all'))
|
||||
).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should emit clear when clear all button is clicked', async () => {
|
||||
const { user, onClear } = createRender({ selectedValues: ['IMAGE'] })
|
||||
await openPopover(user)
|
||||
|
||||
const clearBtn = screen
|
||||
.getAllByRole('button')
|
||||
.find((b) => b.textContent?.includes('Clear all'))!
|
||||
await user.click(clearBtn)
|
||||
await nextTick()
|
||||
|
||||
expect(onClear).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('should emit toggle when an option is clicked', async () => {
|
||||
const { user, onToggle } = createRender()
|
||||
await openPopover(user)
|
||||
|
||||
await user.click(screen.getAllByRole('option')[0])
|
||||
await nextTick()
|
||||
|
||||
expect(onToggle).toHaveBeenCalledWith('IMAGE')
|
||||
})
|
||||
|
||||
it('should filter options via search input', async () => {
|
||||
const { user } = createRender()
|
||||
await openPopover(user)
|
||||
|
||||
await user.type(screen.getByRole('textbox'), 'IMAGE')
|
||||
await nextTick()
|
||||
|
||||
const options = screen.getAllByRole('option')
|
||||
expect(options).toHaveLength(1)
|
||||
expect(options[0].textContent).toContain('IMAGE')
|
||||
})
|
||||
|
||||
it('should show no results when search matches nothing', async () => {
|
||||
const { user } = createRender()
|
||||
await openPopover(user)
|
||||
|
||||
await user.type(screen.getByRole('textbox'), 'NONEXISTENT')
|
||||
await nextTick()
|
||||
|
||||
expect(screen.queryAllByRole('option')).toHaveLength(0)
|
||||
expect(screen.getByText('No Results')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should emit escapeClose and close the popover when Escape is pressed', async () => {
|
||||
const { user, onEscapeClose } = createRender()
|
||||
await openPopover(user)
|
||||
expect(screen.getByRole('listbox')).toBeInTheDocument()
|
||||
|
||||
await user.keyboard('{Escape}')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
|
||||
})
|
||||
expect(onEscapeClose).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,175 +0,0 @@
|
||||
<template>
|
||||
<PopoverRoot v-model:open="open" @update:open="onOpenChange">
|
||||
<PopoverTrigger as-child>
|
||||
<slot />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="bottom"
|
||||
:side-offset="4"
|
||||
:collision-padding="10"
|
||||
class="data-[state=open]:data-[side=bottom]:animate-slideUpAndFade z-1001 w-64 rounded-lg border border-border-default bg-base-background px-4 py-1 shadow-interface will-change-[transform,opacity]"
|
||||
@open-auto-focus="onOpenAutoFocus"
|
||||
@close-auto-focus="onCloseAutoFocus"
|
||||
@escape-key-down.prevent
|
||||
@keydown.escape.stop="closeWithEscape"
|
||||
>
|
||||
<ListboxRoot
|
||||
multiple
|
||||
selection-behavior="toggle"
|
||||
:model-value="selectedValues"
|
||||
@update:model-value="onSelectionChange"
|
||||
>
|
||||
<div
|
||||
class="mt-2 flex h-8 items-center gap-2 rounded-sm border border-border-default px-2"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--search] size-4 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
<ListboxFilter
|
||||
ref="searchFilterRef"
|
||||
v-model="searchQuery"
|
||||
:placeholder="t('g.search')"
|
||||
class="text-foreground size-full border-none bg-transparent font-inter text-sm outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between py-3">
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{
|
||||
t(
|
||||
'g.itemsSelected',
|
||||
{ count: selectedValues.length },
|
||||
selectedValues.length
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
<button
|
||||
v-if="selectedValues.length > 0"
|
||||
type="button"
|
||||
class="cursor-pointer border-none bg-transparent font-inter text-sm text-base-foreground"
|
||||
@click="emit('clear')"
|
||||
>
|
||||
{{ t('g.clearAll') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="h-px bg-border-default" />
|
||||
|
||||
<ListboxContent class="max-h-64 overflow-y-auto py-3">
|
||||
<ListboxItem
|
||||
v-for="option in filteredOptions"
|
||||
:key="option"
|
||||
:value="option"
|
||||
data-testid="filter-option"
|
||||
class="text-foreground flex cursor-pointer items-center gap-2 rounded-sm px-1 py-2 text-sm outline-none data-highlighted:bg-secondary-background-hover"
|
||||
>
|
||||
<span
|
||||
:class="
|
||||
cn(
|
||||
'flex size-4 shrink-0 items-center justify-center rounded-sm border border-border-default',
|
||||
selectedSet.has(option) &&
|
||||
'text-primary-foreground border-primary bg-primary'
|
||||
)
|
||||
"
|
||||
>
|
||||
<i
|
||||
v-if="selectedSet.has(option)"
|
||||
class="icon-[lucide--check] size-3"
|
||||
/>
|
||||
</span>
|
||||
<span class="truncate">{{ option }}</span>
|
||||
<span
|
||||
class="mr-1 ml-auto text-lg leading-none"
|
||||
:style="{ color: getLinkTypeColor(option) }"
|
||||
>
|
||||
•
|
||||
</span>
|
||||
</ListboxItem>
|
||||
<div
|
||||
v-if="filteredOptions.length === 0"
|
||||
class="px-1 py-4 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
{{ t('g.noResults') }}
|
||||
</div>
|
||||
</ListboxContent>
|
||||
</ListboxRoot>
|
||||
</PopoverContent>
|
||||
</PopoverRoot>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { AcceptableValue } from 'reka-ui'
|
||||
import {
|
||||
ListboxContent,
|
||||
ListboxFilter,
|
||||
ListboxItem,
|
||||
ListboxRoot,
|
||||
PopoverContent,
|
||||
PopoverRoot,
|
||||
PopoverTrigger
|
||||
} from 'reka-ui'
|
||||
|
||||
import type { FilterChip } from '@/components/searchbox/v2/NodeSearchFilterBar.vue'
|
||||
import { getLinkTypeColor } from '@/utils/litegraphUtil'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { chip, selectedValues } = defineProps<{
|
||||
chip: FilterChip
|
||||
selectedValues: string[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
toggle: [value: string]
|
||||
clear: []
|
||||
escapeClose: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const open = ref(false)
|
||||
const closedWithEscape = ref(false)
|
||||
const searchQuery = ref('')
|
||||
const searchFilterRef = ref<InstanceType<typeof ListboxFilter>>()
|
||||
|
||||
function onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) searchQuery.value = ''
|
||||
}
|
||||
|
||||
const selectedSet = computed(() => new Set(selectedValues))
|
||||
|
||||
function onSelectionChange(value: AcceptableValue) {
|
||||
const newValues = value as string[]
|
||||
const added = newValues.find((v) => !selectedSet.value.has(v))
|
||||
const removed = selectedValues.find((v) => !newValues.includes(v))
|
||||
const toggled = added ?? removed
|
||||
if (toggled) emit('toggle', toggled)
|
||||
}
|
||||
|
||||
const filteredOptions = computed(() => {
|
||||
const { fuseSearch } = chip.filter
|
||||
if (searchQuery.value) {
|
||||
return fuseSearch.search(searchQuery.value).slice(0, 64)
|
||||
}
|
||||
return fuseSearch.data.slice().sort()
|
||||
})
|
||||
|
||||
function closeWithEscape() {
|
||||
closedWithEscape.value = true
|
||||
open.value = false
|
||||
}
|
||||
|
||||
function onOpenAutoFocus(event: Event) {
|
||||
event.preventDefault()
|
||||
const el = searchFilterRef.value?.$el as HTMLInputElement | undefined
|
||||
el?.focus()
|
||||
}
|
||||
|
||||
function onCloseAutoFocus(event: Event) {
|
||||
if (closedWithEscape.value) {
|
||||
event.preventDefault()
|
||||
closedWithEscape.value = false
|
||||
emit('escapeClose')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -2,14 +2,12 @@ import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
import { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
|
||||
export function createMockNodeDef(
|
||||
overrides: Partial<ComfyNodeDef> = {}
|
||||
): ComfyNodeDefImpl {
|
||||
return new ComfyNodeDefImpl({
|
||||
): ComfyNodeDef {
|
||||
return {
|
||||
name: 'TestNode',
|
||||
display_name: 'Test Node',
|
||||
category: 'test',
|
||||
@@ -23,7 +21,7 @@ export function createMockNodeDef(
|
||||
deprecated: false,
|
||||
experimental: false,
|
||||
...overrides
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function setupTestPinia() {
|
||||
@@ -33,5 +31,34 @@ export function setupTestPinia() {
|
||||
export const testI18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
messages: {
|
||||
en: {
|
||||
g: {
|
||||
addNode: 'Add a node...',
|
||||
filterBy: 'Filter by:',
|
||||
mostRelevant: 'Most relevant',
|
||||
recents: 'Recents',
|
||||
favorites: 'Favorites',
|
||||
essentials: 'Essentials',
|
||||
custom: 'Custom',
|
||||
comfy: 'Comfy',
|
||||
partner: 'Partner',
|
||||
extensions: 'Extensions',
|
||||
noResults: 'No results',
|
||||
filterByType: 'Filter by {type}...',
|
||||
input: 'Input',
|
||||
output: 'Output',
|
||||
source: 'Source',
|
||||
search: 'Search'
|
||||
},
|
||||
sideToolbar: {
|
||||
nodeLibraryTab: {
|
||||
filterOptions: {
|
||||
blueprints: 'Blueprints',
|
||||
partnerNodes: 'Partner Nodes'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,513 +0,0 @@
|
||||
import {
|
||||
afterAll,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi
|
||||
} from 'vitest'
|
||||
|
||||
import { GPUBrushRenderer } from './GPUBrushRenderer'
|
||||
|
||||
// WebGPU globals are not available in happy-dom
|
||||
beforeAll(() => {
|
||||
vi.stubGlobal('GPUBufferUsage', {
|
||||
VERTEX: 0x0020,
|
||||
INDEX: 0x0010,
|
||||
COPY_DST: 0x0008,
|
||||
UNIFORM: 0x0040
|
||||
})
|
||||
vi.stubGlobal('GPUTextureUsage', {
|
||||
RENDER_ATTACHMENT: 0x0010,
|
||||
TEXTURE_BINDING: 0x0004,
|
||||
COPY_SRC: 0x0001
|
||||
})
|
||||
vi.stubGlobal('GPUShaderStage', { VERTEX: 0x1, FRAGMENT: 0x2 })
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
vi.mock('typegpu', () => ({
|
||||
tgpu: { resolve: vi.fn(() => '/* mock wgsl */') }
|
||||
}))
|
||||
|
||||
vi.mock('typegpu/data', () => ({
|
||||
struct: vi.fn(() => ({})),
|
||||
vec2f: {},
|
||||
vec3f: {},
|
||||
f32: {},
|
||||
u32: {},
|
||||
location: vi.fn(() => ({})),
|
||||
builtin: { position: {} },
|
||||
sizeOf: vi.fn(() => 16)
|
||||
}))
|
||||
|
||||
let pipelineCounter = 0
|
||||
|
||||
function createMockPass() {
|
||||
return {
|
||||
setPipeline: vi.fn(),
|
||||
setBindGroup: vi.fn(),
|
||||
setVertexBuffer: vi.fn(),
|
||||
setIndexBuffer: vi.fn(),
|
||||
drawIndexed: vi.fn(),
|
||||
draw: vi.fn(),
|
||||
dispatchWorkgroups: vi.fn(),
|
||||
end: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
function createMockEncoder() {
|
||||
const renderPass = createMockPass()
|
||||
const computePass = createMockPass()
|
||||
return {
|
||||
beginRenderPass: vi.fn(() => renderPass),
|
||||
beginComputePass: vi.fn(() => computePass),
|
||||
finish: vi.fn(() => 'command-buffer'),
|
||||
_renderPass: renderPass,
|
||||
_computePass: computePass
|
||||
}
|
||||
}
|
||||
|
||||
function createMockTexture(
|
||||
width = 512,
|
||||
height = 512
|
||||
): GPUTexture & { _view: GPUTextureView } {
|
||||
const view = {} as GPUTextureView
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
createView: vi.fn(() => view),
|
||||
destroy: vi.fn(),
|
||||
_view: view
|
||||
} as unknown as GPUTexture & { _view: GPUTextureView }
|
||||
}
|
||||
|
||||
function createMockDevice() {
|
||||
const encoder = createMockEncoder()
|
||||
const device = {
|
||||
createBuffer: vi.fn((desc: GPUBufferDescriptor) => ({
|
||||
size: desc.size,
|
||||
getMappedRange: vi.fn(() => new ArrayBuffer(desc.size)),
|
||||
unmap: vi.fn(),
|
||||
destroy: vi.fn()
|
||||
})),
|
||||
createShaderModule: vi.fn(() => ({})),
|
||||
createBindGroupLayout: vi.fn(() => ({})),
|
||||
createBindGroup: vi.fn(() => ({})),
|
||||
createPipelineLayout: vi.fn(() => ({})),
|
||||
createRenderPipeline: vi.fn(() => ({
|
||||
_id: `pipeline-${pipelineCounter++}`
|
||||
})),
|
||||
createComputePipeline: vi.fn(
|
||||
() =>
|
||||
({
|
||||
getBindGroupLayout: vi.fn(() => ({}))
|
||||
}) as unknown as GPUComputePipeline
|
||||
),
|
||||
createTexture: vi.fn((desc: { size: number[] }) =>
|
||||
createMockTexture(desc.size[0], desc.size[1])
|
||||
),
|
||||
createCommandEncoder: vi.fn(() => encoder),
|
||||
queue: {
|
||||
writeBuffer: vi.fn(),
|
||||
submit: vi.fn()
|
||||
},
|
||||
_encoder: encoder
|
||||
}
|
||||
return device as unknown as GPUDevice & {
|
||||
_encoder: ReturnType<typeof createMockEncoder>
|
||||
}
|
||||
}
|
||||
|
||||
describe('GPUBrushRenderer', () => {
|
||||
// Pipeline creation order in constructor:
|
||||
// 0: render, 1: accumulate, 2: blit,
|
||||
// 3: composite, 4: compositePreview, 5: erase, 6: erasePreview
|
||||
const PIPELINE_INDEX = {
|
||||
composite: 3,
|
||||
compositePreview: 4,
|
||||
erase: 5,
|
||||
erasePreview: 6
|
||||
}
|
||||
|
||||
function getPipeline(index: number) {
|
||||
return (device.createRenderPipeline as ReturnType<typeof vi.fn>).mock
|
||||
.results[index].value
|
||||
}
|
||||
|
||||
let device: ReturnType<typeof createMockDevice>
|
||||
let renderer: GPUBrushRenderer
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
pipelineCounter = 0
|
||||
device = createMockDevice()
|
||||
renderer = new GPUBrushRenderer(device)
|
||||
})
|
||||
|
||||
describe('constructor', () => {
|
||||
it('creates required GPU buffers', () => {
|
||||
// quad vertex, index, instance, uniform = 4 buffers
|
||||
expect(device.createBuffer).toHaveBeenCalledTimes(4)
|
||||
})
|
||||
|
||||
it('creates shader modules', () => {
|
||||
// brushVertex, brushFragment, blit, composite (×4), readback = 8
|
||||
expect(device.createShaderModule).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('creates bind group layouts for uniforms and textures', () => {
|
||||
expect(device.createBindGroupLayout).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('creates all render and compute pipelines', () => {
|
||||
// render, accumulate, blit, composite, compositePreview,
|
||||
// erase, erasePreview = 7 render pipelines
|
||||
expect(device.createRenderPipeline).toHaveBeenCalledTimes(7)
|
||||
// readback = 1 compute pipeline
|
||||
expect(device.createComputePipeline).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('prepareStroke', () => {
|
||||
it('creates a new texture when none exists', () => {
|
||||
renderer.prepareStroke(256, 256)
|
||||
expect(device.createTexture).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ size: [256, 256], format: 'rgba8unorm' })
|
||||
)
|
||||
})
|
||||
|
||||
it('clears the accumulation texture via a render pass', () => {
|
||||
renderer.prepareStroke(256, 256)
|
||||
const encoder = device._encoder
|
||||
expect(encoder.beginRenderPass).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
colorAttachments: expect.arrayContaining([
|
||||
expect.objectContaining({ loadOp: 'clear' })
|
||||
])
|
||||
})
|
||||
)
|
||||
expect(encoder._renderPass.end).toHaveBeenCalled()
|
||||
expect(device.queue.submit).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reuses the texture when dimensions match', () => {
|
||||
renderer.prepareStroke(256, 256)
|
||||
const callCount = (device.createTexture as ReturnType<typeof vi.fn>).mock
|
||||
.calls.length
|
||||
renderer.prepareStroke(256, 256)
|
||||
expect(device.createTexture).toHaveBeenCalledTimes(callCount)
|
||||
})
|
||||
|
||||
it('recreates the texture when dimensions change', () => {
|
||||
renderer.prepareStroke(256, 256)
|
||||
renderer.prepareStroke(512, 512)
|
||||
expect(device.createTexture).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('renderStrokeToAccumulator', () => {
|
||||
const settings = {
|
||||
size: 10,
|
||||
opacity: 1,
|
||||
hardness: 0.5,
|
||||
color: [1, 1, 1] as [number, number, number],
|
||||
width: 256,
|
||||
height: 256,
|
||||
brushShape: 0
|
||||
}
|
||||
|
||||
it('does nothing when no stroke texture is prepared', () => {
|
||||
renderer.renderStrokeToAccumulator(
|
||||
[{ x: 0, y: 0, pressure: 1 }],
|
||||
settings
|
||||
)
|
||||
expect(device.queue.writeBuffer).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('writes uniforms and instance data then submits', () => {
|
||||
renderer.prepareStroke(256, 256)
|
||||
vi.clearAllMocks()
|
||||
|
||||
const points = [
|
||||
{ x: 10, y: 20, pressure: 0.5 },
|
||||
{ x: 30, y: 40, pressure: 1.0 }
|
||||
]
|
||||
renderer.renderStrokeToAccumulator(points, settings)
|
||||
|
||||
// uniform + instance data = 2 writeBuffer calls
|
||||
expect(device.queue.writeBuffer).toHaveBeenCalledTimes(2)
|
||||
expect(device.queue.submit).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not render when points array is empty', () => {
|
||||
renderer.prepareStroke(256, 256)
|
||||
vi.clearAllMocks()
|
||||
|
||||
renderer.renderStrokeToAccumulator([], settings)
|
||||
// writeBuffer is never called because renderStrokeInternal returns early
|
||||
expect(device.queue.writeBuffer).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('renderStroke', () => {
|
||||
const settings = {
|
||||
size: 10,
|
||||
opacity: 1,
|
||||
hardness: 0.5,
|
||||
color: [1, 0, 0] as [number, number, number],
|
||||
width: 512,
|
||||
height: 512,
|
||||
brushShape: 0
|
||||
}
|
||||
|
||||
it('renders points directly to the target view', () => {
|
||||
const targetView = {} as GPUTextureView
|
||||
const points = [{ x: 5, y: 5, pressure: 1 }]
|
||||
|
||||
renderer.renderStroke(targetView, points, settings)
|
||||
|
||||
expect(device.queue.writeBuffer).toHaveBeenCalledTimes(2)
|
||||
const encoder = device._encoder
|
||||
expect(encoder._renderPass.setPipeline).toHaveBeenCalled()
|
||||
expect(encoder._renderPass.drawIndexed).toHaveBeenCalledWith(6, 1)
|
||||
})
|
||||
|
||||
it('skips rendering for empty points', () => {
|
||||
const targetView = {} as GPUTextureView
|
||||
renderer.renderStroke(targetView, [], settings)
|
||||
expect(device.queue.writeBuffer).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('compositeStroke', () => {
|
||||
const settings = {
|
||||
opacity: 0.8,
|
||||
color: [1, 0, 0] as [number, number, number],
|
||||
hardness: 0.5,
|
||||
screenSize: [512, 512] as [number, number],
|
||||
brushShape: 0
|
||||
}
|
||||
|
||||
it('does nothing when no stroke texture exists', () => {
|
||||
const targetView = {} as GPUTextureView
|
||||
renderer.compositeStroke(targetView, settings)
|
||||
expect(device.queue.writeBuffer).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('writes uniforms and submits a composite pass', () => {
|
||||
renderer.prepareStroke(512, 512)
|
||||
vi.clearAllMocks()
|
||||
|
||||
const targetView = {} as GPUTextureView
|
||||
renderer.compositeStroke(targetView, settings)
|
||||
|
||||
expect(device.queue.writeBuffer).toHaveBeenCalledTimes(1)
|
||||
expect(device.createBindGroup).toHaveBeenCalled()
|
||||
expect(device.queue.submit).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses erase pipeline when isErasing is true', () => {
|
||||
const erasePipeline = getPipeline(PIPELINE_INDEX.erase)
|
||||
renderer.prepareStroke(512, 512)
|
||||
vi.clearAllMocks()
|
||||
|
||||
const targetView = {} as GPUTextureView
|
||||
renderer.compositeStroke(targetView, { ...settings, isErasing: true })
|
||||
|
||||
const encoder = device._encoder
|
||||
expect(encoder._renderPass.setPipeline).toHaveBeenCalledWith(
|
||||
erasePipeline
|
||||
)
|
||||
expect(encoder._renderPass.draw).toHaveBeenCalledWith(3)
|
||||
})
|
||||
|
||||
it('uses composite pipeline when isErasing is false', () => {
|
||||
const compositePipeline = getPipeline(PIPELINE_INDEX.composite)
|
||||
renderer.prepareStroke(512, 512)
|
||||
vi.clearAllMocks()
|
||||
|
||||
const targetView = {} as GPUTextureView
|
||||
renderer.compositeStroke(targetView, settings)
|
||||
|
||||
const encoder = device._encoder
|
||||
expect(encoder._renderPass.setPipeline).toHaveBeenCalledWith(
|
||||
compositePipeline
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('blitToCanvas', () => {
|
||||
const mockCtx = {
|
||||
getCurrentTexture: vi.fn(() => createMockTexture())
|
||||
} as unknown as GPUCanvasContext
|
||||
|
||||
const settings = {
|
||||
opacity: 1,
|
||||
color: [1, 1, 1] as [number, number, number],
|
||||
hardness: 0.5,
|
||||
screenSize: [512, 512] as [number, number],
|
||||
brushShape: 0
|
||||
}
|
||||
|
||||
it('clears destination when no background texture is provided', () => {
|
||||
renderer.blitToCanvas(mockCtx, settings)
|
||||
|
||||
const encoder = device._encoder
|
||||
expect(encoder.beginRenderPass).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
colorAttachments: expect.arrayContaining([
|
||||
expect.objectContaining({ loadOp: 'clear' })
|
||||
])
|
||||
})
|
||||
)
|
||||
expect(device.queue.submit).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('draws background texture when provided', () => {
|
||||
const bgTexture = createMockTexture()
|
||||
renderer.blitToCanvas(mockCtx, settings, bgTexture)
|
||||
|
||||
expect(device.createBindGroup).toHaveBeenCalled()
|
||||
expect(device.queue.submit).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('composites the stroke texture when prepared', () => {
|
||||
renderer.prepareStroke(512, 512)
|
||||
vi.clearAllMocks()
|
||||
|
||||
renderer.blitToCanvas(mockCtx, settings)
|
||||
|
||||
// Writes uniforms for the preview pass
|
||||
expect(device.queue.writeBuffer).toHaveBeenCalled()
|
||||
expect(device.queue.submit).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses erase preview pipeline when isErasing is true', () => {
|
||||
const erasePreviewPipeline = getPipeline(PIPELINE_INDEX.erasePreview)
|
||||
renderer.prepareStroke(512, 512)
|
||||
vi.clearAllMocks()
|
||||
|
||||
renderer.blitToCanvas(mockCtx, { ...settings, isErasing: true })
|
||||
|
||||
const encoder = device._encoder
|
||||
expect(encoder._renderPass.setPipeline).toHaveBeenCalledWith(
|
||||
erasePreviewPipeline
|
||||
)
|
||||
})
|
||||
|
||||
it('uses composite preview pipeline when isErasing is false', () => {
|
||||
const compositePreviewPipeline = getPipeline(
|
||||
PIPELINE_INDEX.compositePreview
|
||||
)
|
||||
renderer.prepareStroke(512, 512)
|
||||
vi.clearAllMocks()
|
||||
|
||||
renderer.blitToCanvas(mockCtx, settings)
|
||||
|
||||
const encoder = device._encoder
|
||||
expect(encoder._renderPass.setPipeline).toHaveBeenCalledWith(
|
||||
compositePreviewPipeline
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearPreview', () => {
|
||||
it('submits a clear render pass', () => {
|
||||
const mockCtx = {
|
||||
getCurrentTexture: vi.fn(() => createMockTexture())
|
||||
} as unknown as GPUCanvasContext
|
||||
|
||||
renderer.clearPreview(mockCtx)
|
||||
|
||||
const encoder = device._encoder
|
||||
expect(encoder.beginRenderPass).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
colorAttachments: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
loadOp: 'clear',
|
||||
clearValue: { r: 0, g: 0, b: 0, a: 0 }
|
||||
})
|
||||
])
|
||||
})
|
||||
)
|
||||
expect(encoder._renderPass.end).toHaveBeenCalled()
|
||||
expect(device.queue.submit).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('prepareReadback', () => {
|
||||
it('creates a bind group and dispatches a compute pass', () => {
|
||||
const texture = createMockTexture(64, 64)
|
||||
const outputBuffer = { size: 64 * 64 * 4 } as GPUBuffer
|
||||
|
||||
renderer.prepareReadback(texture, outputBuffer)
|
||||
|
||||
expect(device.queue.submit).toHaveBeenCalled()
|
||||
const encoder = device._encoder
|
||||
expect(encoder.beginComputePass).toHaveBeenCalled()
|
||||
expect(encoder._computePass.setPipeline).toHaveBeenCalled()
|
||||
expect(encoder._computePass.dispatchWorkgroups).toHaveBeenCalledWith(
|
||||
Math.ceil(64 / 8),
|
||||
Math.ceil(64 / 8)
|
||||
)
|
||||
})
|
||||
|
||||
it('reuses the bind group for the same texture and buffer', () => {
|
||||
const texture = createMockTexture(64, 64)
|
||||
const outputBuffer = { size: 64 * 64 * 4 } as GPUBuffer
|
||||
|
||||
renderer.prepareReadback(texture, outputBuffer)
|
||||
const firstCallCount = (
|
||||
device.createBindGroup as ReturnType<typeof vi.fn>
|
||||
).mock.calls.length
|
||||
|
||||
renderer.prepareReadback(texture, outputBuffer)
|
||||
// +1 from constructor for mainUniformBindGroup is already counted
|
||||
expect(device.createBindGroup).toHaveBeenCalledTimes(firstCallCount)
|
||||
})
|
||||
|
||||
it('recreates the bind group when texture changes', () => {
|
||||
const texture1 = createMockTexture(64, 64)
|
||||
const texture2 = createMockTexture(128, 128)
|
||||
const outputBuffer = { size: 128 * 128 * 4 } as GPUBuffer
|
||||
|
||||
renderer.prepareReadback(texture1, outputBuffer)
|
||||
const afterFirst = (device.createBindGroup as ReturnType<typeof vi.fn>)
|
||||
.mock.calls.length
|
||||
|
||||
renderer.prepareReadback(texture2, outputBuffer)
|
||||
expect(device.createBindGroup).toHaveBeenCalledTimes(afterFirst + 1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('destroy', () => {
|
||||
it('destroys all GPU buffers', () => {
|
||||
renderer.destroy()
|
||||
|
||||
// 4 buffers created in constructor
|
||||
const buffers = (device.createBuffer as ReturnType<typeof vi.fn>).mock
|
||||
.results
|
||||
for (const result of buffers) {
|
||||
expect(result.value.destroy).toHaveBeenCalled()
|
||||
}
|
||||
})
|
||||
|
||||
it('destroys the stroke texture if one was created', () => {
|
||||
renderer.prepareStroke(256, 256)
|
||||
const texture = (device.createTexture as ReturnType<typeof vi.fn>).mock
|
||||
.results[0].value
|
||||
|
||||
renderer.destroy()
|
||||
expect(texture.destroy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not throw when no stroke texture exists', () => {
|
||||
expect(() => renderer.destroy()).not.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,427 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
calculateDragPan,
|
||||
calculateFitView,
|
||||
calculatePanZoomStyles,
|
||||
calculateSingleTouchPan,
|
||||
calculateZoomAroundPoint,
|
||||
clampZoom,
|
||||
easeOutCubic,
|
||||
getCursorPoint,
|
||||
getDistanceBetweenPoints,
|
||||
getMidpoint,
|
||||
getWheelZoomFactor,
|
||||
interpolateView,
|
||||
isDoubleTap
|
||||
} from './panZoomUtils'
|
||||
|
||||
describe('panZoomUtils', () => {
|
||||
describe('getDistanceBetweenPoints', () => {
|
||||
it('returns 0 for same point', () => {
|
||||
expect(getDistanceBetweenPoints({ x: 5, y: 5 }, { x: 5, y: 5 })).toBe(0)
|
||||
})
|
||||
|
||||
it('calculates horizontal distance', () => {
|
||||
expect(getDistanceBetweenPoints({ x: 0, y: 0 }, { x: 3, y: 0 })).toBe(3)
|
||||
})
|
||||
|
||||
it('calculates diagonal distance', () => {
|
||||
expect(getDistanceBetweenPoints({ x: 0, y: 0 }, { x: 3, y: 4 })).toBe(5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getMidpoint', () => {
|
||||
it('returns midpoint of two points', () => {
|
||||
expect(getMidpoint({ x: 0, y: 0 }, { x: 10, y: 20 })).toEqual({
|
||||
x: 5,
|
||||
y: 10
|
||||
})
|
||||
})
|
||||
|
||||
it('returns same point when both are identical', () => {
|
||||
expect(getMidpoint({ x: 7, y: 3 }, { x: 7, y: 3 })).toEqual({
|
||||
x: 7,
|
||||
y: 3
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('clampZoom', () => {
|
||||
it('returns value within bounds unchanged', () => {
|
||||
expect(clampZoom(1)).toBe(1)
|
||||
expect(clampZoom(5)).toBe(5)
|
||||
})
|
||||
|
||||
it('clamps to minimum 0.2', () => {
|
||||
expect(clampZoom(0.1)).toBe(0.2)
|
||||
expect(clampZoom(0)).toBe(0.2)
|
||||
expect(clampZoom(-5)).toBe(0.2)
|
||||
})
|
||||
|
||||
it('clamps to maximum 10.0', () => {
|
||||
expect(clampZoom(15)).toBe(10)
|
||||
expect(clampZoom(10.1)).toBe(10)
|
||||
})
|
||||
|
||||
it('preserves boundary values exactly', () => {
|
||||
expect(clampZoom(0.2)).toBe(0.2)
|
||||
expect(clampZoom(10)).toBe(10)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getWheelZoomFactor', () => {
|
||||
it('returns 1.1 for negative deltaY (scroll up = zoom in)', () => {
|
||||
expect(getWheelZoomFactor(-100)).toBe(1.1)
|
||||
expect(getWheelZoomFactor(-1)).toBe(1.1)
|
||||
})
|
||||
|
||||
it('returns 0.9 for positive deltaY (scroll down = zoom out)', () => {
|
||||
expect(getWheelZoomFactor(100)).toBe(0.9)
|
||||
expect(getWheelZoomFactor(1)).toBe(0.9)
|
||||
})
|
||||
|
||||
it('returns 0.9 for zero deltaY', () => {
|
||||
expect(getWheelZoomFactor(0)).toBe(0.9)
|
||||
})
|
||||
})
|
||||
|
||||
describe('calculateZoomAroundPoint', () => {
|
||||
it('zooms in and adjusts pan toward focal point', () => {
|
||||
const result = calculateZoomAroundPoint(
|
||||
1.0,
|
||||
1.1,
|
||||
{ x: 0, y: 0 },
|
||||
400,
|
||||
300
|
||||
)
|
||||
|
||||
expect(result.zoomRatio).toBeCloseTo(1.1)
|
||||
expect(result.panOffset.x).toBeLessThan(0)
|
||||
expect(result.panOffset.y).toBeLessThan(0)
|
||||
})
|
||||
|
||||
it('zooms out and adjusts pan away from focal point', () => {
|
||||
const result = calculateZoomAroundPoint(
|
||||
1.0,
|
||||
0.9,
|
||||
{ x: 0, y: 0 },
|
||||
400,
|
||||
300
|
||||
)
|
||||
|
||||
expect(result.zoomRatio).toBeCloseTo(0.9)
|
||||
expect(result.panOffset.x).toBeGreaterThan(0)
|
||||
expect(result.panOffset.y).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('clamps zoom to upper bound', () => {
|
||||
const result = calculateZoomAroundPoint(9.5, 2.0, { x: 0, y: 0 }, 0, 0)
|
||||
|
||||
expect(result.zoomRatio).toBe(10)
|
||||
})
|
||||
|
||||
it('clamps zoom to lower bound', () => {
|
||||
const result = calculateZoomAroundPoint(0.3, 0.5, { x: 0, y: 0 }, 0, 0)
|
||||
|
||||
expect(result.zoomRatio).toBe(0.2)
|
||||
})
|
||||
|
||||
it('does not shift pan when focal point is at origin', () => {
|
||||
const result = calculateZoomAroundPoint(
|
||||
1.0,
|
||||
1.5,
|
||||
{ x: 100, y: 200 },
|
||||
0,
|
||||
0
|
||||
)
|
||||
|
||||
expect(result.panOffset.x).toBe(100)
|
||||
expect(result.panOffset.y).toBe(200)
|
||||
})
|
||||
})
|
||||
|
||||
describe('calculateDragPan', () => {
|
||||
it('returns initial pan when no movement', () => {
|
||||
const result = calculateDragPan(
|
||||
{ x: 100, y: 200 },
|
||||
{ x: 100, y: 200 },
|
||||
{ x: 50, y: 60 }
|
||||
)
|
||||
|
||||
expect(result).toEqual({ x: 50, y: 60 })
|
||||
})
|
||||
|
||||
it('offsets pan by mouse delta', () => {
|
||||
const result = calculateDragPan(
|
||||
{ x: 100, y: 200 },
|
||||
{ x: 150, y: 250 },
|
||||
{ x: 0, y: 0 }
|
||||
)
|
||||
|
||||
expect(result).toEqual({ x: 50, y: 50 })
|
||||
})
|
||||
|
||||
it('preserves initial pan as base', () => {
|
||||
const result = calculateDragPan(
|
||||
{ x: 100, y: 200 },
|
||||
{ x: 80, y: 180 },
|
||||
{ x: 300, y: 400 }
|
||||
)
|
||||
|
||||
expect(result).toEqual({ x: 280, y: 380 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('calculateSingleTouchPan', () => {
|
||||
it('returns unchanged pan when no movement', () => {
|
||||
const result = calculateSingleTouchPan(
|
||||
{ x: 100, y: 200 },
|
||||
{ x: 100, y: 200 },
|
||||
{ x: 50, y: 60 }
|
||||
)
|
||||
|
||||
expect(result).toEqual({ x: 50, y: 60 })
|
||||
})
|
||||
|
||||
it('adds touch delta to pan', () => {
|
||||
const result = calculateSingleTouchPan(
|
||||
{ x: 100, y: 200 },
|
||||
{ x: 150, y: 250 },
|
||||
{ x: 10, y: 20 }
|
||||
)
|
||||
|
||||
expect(result).toEqual({ x: 60, y: 70 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('getCursorPoint', () => {
|
||||
it('subtracts pan offset from client point', () => {
|
||||
expect(getCursorPoint({ x: 500, y: 400 }, { x: 100, y: 50 })).toEqual({
|
||||
x: 400,
|
||||
y: 350
|
||||
})
|
||||
})
|
||||
|
||||
it('returns client point when offset is zero', () => {
|
||||
expect(getCursorPoint({ x: 200, y: 300 }, { x: 0, y: 0 })).toEqual({
|
||||
x: 200,
|
||||
y: 300
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('isDoubleTap', () => {
|
||||
it('returns true when within delay', () => {
|
||||
expect(isDoubleTap(1100, 1000, 300)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when outside delay', () => {
|
||||
expect(isDoubleTap(1500, 1000, 300)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when exactly at delay boundary', () => {
|
||||
expect(isDoubleTap(1300, 1000, 300)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true when lastTapTime is 0 and currentTime is small', () => {
|
||||
expect(isDoubleTap(100, 0, 300)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('easeOutCubic', () => {
|
||||
it('returns 0 at start', () => {
|
||||
expect(easeOutCubic(0)).toBe(0)
|
||||
})
|
||||
|
||||
it('returns 1 at end', () => {
|
||||
expect(easeOutCubic(1)).toBe(1)
|
||||
})
|
||||
|
||||
it('returns value between 0 and 1 for midpoint', () => {
|
||||
const mid = easeOutCubic(0.5)
|
||||
expect(mid).toBeGreaterThan(0)
|
||||
expect(mid).toBeLessThan(1)
|
||||
})
|
||||
|
||||
it('decelerates (second half has less change than first)', () => {
|
||||
const firstHalf = easeOutCubic(0.5)
|
||||
const secondHalf = easeOutCubic(1) - easeOutCubic(0.5)
|
||||
expect(firstHalf).toBeGreaterThan(secondHalf)
|
||||
})
|
||||
})
|
||||
|
||||
describe('interpolateView', () => {
|
||||
it('returns start values at progress 0', () => {
|
||||
const result = interpolateView(
|
||||
1,
|
||||
2,
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 100, y: 200 },
|
||||
0
|
||||
)
|
||||
|
||||
expect(result.zoomRatio).toBe(1)
|
||||
expect(result.panOffset).toEqual({ x: 0, y: 0 })
|
||||
})
|
||||
|
||||
it('returns target values at progress 1', () => {
|
||||
const result = interpolateView(
|
||||
1,
|
||||
2,
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 100, y: 200 },
|
||||
1
|
||||
)
|
||||
|
||||
expect(result.zoomRatio).toBe(2)
|
||||
expect(result.panOffset).toEqual({ x: 100, y: 200 })
|
||||
})
|
||||
|
||||
it('returns halfway values at progress 0.5', () => {
|
||||
const result = interpolateView(
|
||||
1,
|
||||
3,
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 100, y: 200 },
|
||||
0.5
|
||||
)
|
||||
|
||||
expect(result.zoomRatio).toBe(2)
|
||||
expect(result.panOffset).toEqual({ x: 50, y: 100 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('calculateFitView', () => {
|
||||
it('fits landscape image width-constrained', () => {
|
||||
const result = calculateFitView({
|
||||
rootWidth: 1200,
|
||||
rootHeight: 800,
|
||||
imageWidth: 1000,
|
||||
imageHeight: 500,
|
||||
toolPanelWidth: 64,
|
||||
sidePanelWidth: 220
|
||||
})
|
||||
|
||||
const availableWidth = 1200 - 64 - 220
|
||||
expect(result.zoomRatio).toBeCloseTo(availableWidth / 1000)
|
||||
expect(result.fittedWidth).toBeCloseTo(availableWidth)
|
||||
expect(result.panOffset.x).toBe(64)
|
||||
})
|
||||
|
||||
it('fits portrait image height-constrained', () => {
|
||||
const result = calculateFitView({
|
||||
rootWidth: 1200,
|
||||
rootHeight: 800,
|
||||
imageWidth: 400,
|
||||
imageHeight: 1000,
|
||||
toolPanelWidth: 64,
|
||||
sidePanelWidth: 220
|
||||
})
|
||||
|
||||
expect(result.zoomRatio).toBeCloseTo(800 / 1000)
|
||||
expect(result.fittedHeight).toBeCloseTo(800)
|
||||
})
|
||||
|
||||
it('centers vertically for width-constrained images', () => {
|
||||
const result = calculateFitView({
|
||||
rootWidth: 1200,
|
||||
rootHeight: 800,
|
||||
imageWidth: 1000,
|
||||
imageHeight: 500,
|
||||
toolPanelWidth: 64,
|
||||
sidePanelWidth: 220
|
||||
})
|
||||
|
||||
expect(result.panOffset.y).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('centers horizontally for height-constrained images', () => {
|
||||
const result = calculateFitView({
|
||||
rootWidth: 1200,
|
||||
rootHeight: 800,
|
||||
imageWidth: 400,
|
||||
imageHeight: 1000,
|
||||
toolPanelWidth: 64,
|
||||
sidePanelWidth: 220
|
||||
})
|
||||
|
||||
expect(result.panOffset.x).toBeGreaterThan(64)
|
||||
})
|
||||
|
||||
it('accounts for panel widths in available space', () => {
|
||||
const withPanels = calculateFitView({
|
||||
rootWidth: 1200,
|
||||
rootHeight: 800,
|
||||
imageWidth: 800,
|
||||
imageHeight: 600,
|
||||
toolPanelWidth: 64,
|
||||
sidePanelWidth: 220
|
||||
})
|
||||
|
||||
const withoutPanels = calculateFitView({
|
||||
rootWidth: 1200,
|
||||
rootHeight: 800,
|
||||
imageWidth: 800,
|
||||
imageHeight: 600,
|
||||
toolPanelWidth: 0,
|
||||
sidePanelWidth: 0
|
||||
})
|
||||
|
||||
expect(withPanels.zoomRatio).toBeLessThan(withoutPanels.zoomRatio)
|
||||
expect(withPanels.panOffset.x).toBeGreaterThanOrEqual(64)
|
||||
})
|
||||
|
||||
it('offsets pan.x by exactly toolPanelWidth for width-constrained fit', () => {
|
||||
const result = calculateFitView({
|
||||
rootWidth: 1200,
|
||||
rootHeight: 800,
|
||||
imageWidth: 2000,
|
||||
imageHeight: 500,
|
||||
toolPanelWidth: 80,
|
||||
sidePanelWidth: 200
|
||||
})
|
||||
|
||||
expect(result.panOffset.x).toBe(80)
|
||||
})
|
||||
|
||||
it('offsets pan.x by toolPanelWidth + centering for height-constrained fit', () => {
|
||||
const result = calculateFitView({
|
||||
rootWidth: 1200,
|
||||
rootHeight: 800,
|
||||
imageWidth: 400,
|
||||
imageHeight: 1000,
|
||||
toolPanelWidth: 64,
|
||||
sidePanelWidth: 220
|
||||
})
|
||||
|
||||
const availableWidth = 1200 - 64 - 220
|
||||
const expectedX = (availableWidth - result.fittedWidth) / 2 + 64
|
||||
|
||||
expect(result.panOffset.x).toBeCloseTo(expectedX)
|
||||
})
|
||||
})
|
||||
|
||||
describe('calculatePanZoomStyles', () => {
|
||||
it('computes container styles from zoom and offset', () => {
|
||||
const result = calculatePanZoomStyles(800, 600, 1.5, {
|
||||
x: 100,
|
||||
y: 50
|
||||
})
|
||||
|
||||
expect(result.rawWidth).toBe(1200)
|
||||
expect(result.rawHeight).toBe(900)
|
||||
expect(result.containerWidth).toBe('1200px')
|
||||
expect(result.containerHeight).toBe('900px')
|
||||
expect(result.containerLeft).toBe('100px')
|
||||
expect(result.containerTop).toBe('50px')
|
||||
})
|
||||
|
||||
it('handles zoom ratio of 1', () => {
|
||||
const result = calculatePanZoomStyles(800, 600, 1, { x: 0, y: 0 })
|
||||
|
||||
expect(result.rawWidth).toBe(800)
|
||||
expect(result.rawHeight).toBe(600)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,178 +0,0 @@
|
||||
import type { Offset, Point } from '@/extensions/core/maskeditor/types'
|
||||
|
||||
const ZOOM_MIN = 0.2
|
||||
const ZOOM_MAX = 10.0
|
||||
|
||||
interface FitViewParams {
|
||||
rootWidth: number
|
||||
rootHeight: number
|
||||
imageWidth: number
|
||||
imageHeight: number
|
||||
toolPanelWidth: number
|
||||
sidePanelWidth: number
|
||||
}
|
||||
|
||||
interface FitViewResult {
|
||||
zoomRatio: number
|
||||
panOffset: Offset
|
||||
fittedWidth: number
|
||||
fittedHeight: number
|
||||
}
|
||||
|
||||
export function calculateFitView(params: FitViewParams): FitViewResult {
|
||||
const {
|
||||
rootWidth,
|
||||
rootHeight,
|
||||
imageWidth,
|
||||
imageHeight,
|
||||
toolPanelWidth,
|
||||
sidePanelWidth
|
||||
} = params
|
||||
|
||||
const availableWidth = rootWidth - sidePanelWidth - toolPanelWidth
|
||||
const availableHeight = rootHeight
|
||||
|
||||
const zoomRatioWidth = availableWidth / imageWidth
|
||||
const zoomRatioHeight = availableHeight / imageHeight
|
||||
const zoomRatio = Math.min(zoomRatioWidth, zoomRatioHeight)
|
||||
|
||||
const aspectRatio = imageWidth / imageHeight
|
||||
const panOffset: Offset = { x: toolPanelWidth, y: 0 }
|
||||
|
||||
let fittedWidth: number
|
||||
let fittedHeight: number
|
||||
|
||||
if (zoomRatioHeight > zoomRatioWidth) {
|
||||
fittedWidth = availableWidth
|
||||
fittedHeight = fittedWidth / aspectRatio
|
||||
panOffset.y = (availableHeight - fittedHeight) / 2
|
||||
} else {
|
||||
fittedHeight = availableHeight
|
||||
fittedWidth = fittedHeight * aspectRatio
|
||||
panOffset.x = (availableWidth - fittedWidth) / 2 + toolPanelWidth
|
||||
}
|
||||
|
||||
return { zoomRatio, panOffset, fittedWidth, fittedHeight }
|
||||
}
|
||||
|
||||
export function clampZoom(zoom: number): number {
|
||||
return Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, zoom))
|
||||
}
|
||||
|
||||
export function getWheelZoomFactor(deltaY: number): number {
|
||||
return deltaY < 0 ? 1.1 : 0.9
|
||||
}
|
||||
|
||||
export function calculateZoomAroundPoint(
|
||||
currentZoom: number,
|
||||
zoomFactor: number,
|
||||
panOffset: Offset,
|
||||
focalX: number,
|
||||
focalY: number
|
||||
): { zoomRatio: number; panOffset: Offset } {
|
||||
const newZoom = clampZoom(currentZoom * zoomFactor)
|
||||
const scaleFactor = newZoom / currentZoom
|
||||
|
||||
return {
|
||||
zoomRatio: newZoom,
|
||||
panOffset: {
|
||||
x: panOffset.x + focalX - focalX * scaleFactor,
|
||||
y: panOffset.y + focalY - focalY * scaleFactor
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function calculateDragPan(
|
||||
mouseDownPoint: Point,
|
||||
currentPoint: Point,
|
||||
initialPan: Offset
|
||||
): Offset {
|
||||
return {
|
||||
x: initialPan.x - (mouseDownPoint.x - currentPoint.x),
|
||||
y: initialPan.y - (mouseDownPoint.y - currentPoint.y)
|
||||
}
|
||||
}
|
||||
|
||||
export function calculateSingleTouchPan(
|
||||
lastPoint: Point,
|
||||
currentPoint: Point,
|
||||
panOffset: Offset
|
||||
): Offset {
|
||||
return {
|
||||
x: panOffset.x + (currentPoint.x - lastPoint.x),
|
||||
y: panOffset.y + (currentPoint.y - lastPoint.y)
|
||||
}
|
||||
}
|
||||
|
||||
export function getDistanceBetweenPoints(a: Point, b: Point): number {
|
||||
const dx = a.x - b.x
|
||||
const dy = a.y - b.y
|
||||
return Math.sqrt(dx * dx + dy * dy)
|
||||
}
|
||||
|
||||
export function getMidpoint(a: Point, b: Point): Point {
|
||||
return {
|
||||
x: (a.x + b.x) / 2,
|
||||
y: (a.y + b.y) / 2
|
||||
}
|
||||
}
|
||||
|
||||
export function getCursorPoint(clientPoint: Point, panOffset: Offset): Point {
|
||||
return {
|
||||
x: clientPoint.x - panOffset.x,
|
||||
y: clientPoint.y - panOffset.y
|
||||
}
|
||||
}
|
||||
|
||||
export function isDoubleTap(
|
||||
currentTime: number,
|
||||
lastTapTime: number,
|
||||
delay: number
|
||||
): boolean {
|
||||
return currentTime - lastTapTime < delay
|
||||
}
|
||||
|
||||
export function easeOutCubic(progress: number): number {
|
||||
return 1 - Math.pow(1 - progress, 3)
|
||||
}
|
||||
|
||||
export function interpolateView(
|
||||
startZoom: number,
|
||||
targetZoom: number,
|
||||
startPan: Offset,
|
||||
targetPan: Offset,
|
||||
easedProgress: number
|
||||
): { zoomRatio: number; panOffset: Offset } {
|
||||
return {
|
||||
zoomRatio: startZoom + (targetZoom - startZoom) * easedProgress,
|
||||
panOffset: {
|
||||
x: startPan.x + (targetPan.x - startPan.x) * easedProgress,
|
||||
y: startPan.y + (targetPan.y - startPan.y) * easedProgress
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function calculatePanZoomStyles(
|
||||
imageWidth: number,
|
||||
imageHeight: number,
|
||||
zoomRatio: number,
|
||||
panOffset: Offset
|
||||
): {
|
||||
rawWidth: number
|
||||
rawHeight: number
|
||||
containerLeft: string
|
||||
containerTop: string
|
||||
containerWidth: string
|
||||
containerHeight: string
|
||||
} {
|
||||
const rawWidth = imageWidth * zoomRatio
|
||||
const rawHeight = imageHeight * zoomRatio
|
||||
return {
|
||||
rawWidth,
|
||||
rawHeight,
|
||||
containerLeft: `${panOffset.x}px`,
|
||||
containerTop: `${panOffset.y}px`,
|
||||
containerWidth: `${rawWidth}px`,
|
||||
containerHeight: `${rawHeight}px`
|
||||
}
|
||||
}
|
||||
@@ -1,459 +0,0 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { usePanAndZoom } from '@/composables/maskeditor/usePanAndZoom'
|
||||
|
||||
interface IMockStore {
|
||||
canvasContainer: HTMLElement | null
|
||||
maskCanvas: HTMLCanvasElement | null
|
||||
rgbCanvas: HTMLCanvasElement | null
|
||||
isPanning: boolean
|
||||
brushVisible: boolean
|
||||
displayZoomRatio: number
|
||||
resetZoomTrigger: number
|
||||
canvasHistory: { undo: ReturnType<typeof vi.fn> }
|
||||
setCursorPoint: ReturnType<typeof vi.fn>
|
||||
setPanOffset: ReturnType<typeof vi.fn>
|
||||
setZoomRatio: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
const { mockStore } = vi.hoisted(() => {
|
||||
const mockStore: IMockStore = {
|
||||
canvasContainer: null,
|
||||
maskCanvas: null,
|
||||
rgbCanvas: null,
|
||||
isPanning: false,
|
||||
brushVisible: true,
|
||||
displayZoomRatio: 1,
|
||||
resetZoomTrigger: 0,
|
||||
canvasHistory: { undo: vi.fn() },
|
||||
setCursorPoint: vi.fn(),
|
||||
setPanOffset: vi.fn(),
|
||||
setZoomRatio: vi.fn()
|
||||
}
|
||||
return { mockStore }
|
||||
})
|
||||
|
||||
vi.mock('@/stores/maskEditorStore', () => ({
|
||||
useMaskEditorStore: vi.fn(() => mockStore)
|
||||
}))
|
||||
|
||||
function createMockElement(width = 1200, height = 800): HTMLElement {
|
||||
return {
|
||||
clientWidth: width,
|
||||
clientHeight: height,
|
||||
style: {} as CSSStyleDeclaration,
|
||||
getBoundingClientRect: () =>
|
||||
({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width,
|
||||
height,
|
||||
right: width,
|
||||
bottom: height
|
||||
}) as DOMRect
|
||||
} as unknown as HTMLElement
|
||||
}
|
||||
|
||||
function createMockCanvas(width: number, height: number): HTMLCanvasElement {
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
clientWidth: width,
|
||||
clientHeight: height,
|
||||
style: {} as CSSStyleDeclaration,
|
||||
getBoundingClientRect: () =>
|
||||
({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width,
|
||||
height,
|
||||
right: width,
|
||||
bottom: height
|
||||
}) as DOMRect
|
||||
} as unknown as HTMLCanvasElement
|
||||
}
|
||||
|
||||
function createMockImage(width: number, height: number): HTMLImageElement {
|
||||
return { width, height } as HTMLImageElement
|
||||
}
|
||||
|
||||
function createTouchList(...points: { x: number; y: number }[]): TouchList {
|
||||
const touches = points.map((p) => ({ clientX: p.x, clientY: p.y }) as Touch)
|
||||
return Object.assign(touches, {
|
||||
length: touches.length,
|
||||
item: (i: number) => touches[i]
|
||||
}) as unknown as TouchList
|
||||
}
|
||||
|
||||
function createTouchEvent(touches: TouchList): TouchEvent {
|
||||
return {
|
||||
touches,
|
||||
preventDefault: vi.fn()
|
||||
} as unknown as TouchEvent
|
||||
}
|
||||
|
||||
async function initComposable() {
|
||||
const pz = usePanAndZoom()
|
||||
const img = createMockImage(800, 600)
|
||||
const root = createMockElement()
|
||||
const container = createMockElement()
|
||||
const canvas = createMockCanvas(800, 600)
|
||||
mockStore.canvasContainer = container as unknown as HTMLElement
|
||||
mockStore.maskCanvas = canvas
|
||||
await pz.initializeCanvasPanZoom(img, root)
|
||||
vi.clearAllMocks()
|
||||
return { pz, canvas }
|
||||
}
|
||||
|
||||
describe('usePanAndZoom', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
|
||||
mockStore.canvasContainer = null
|
||||
mockStore.maskCanvas = null
|
||||
mockStore.rgbCanvas = null
|
||||
mockStore.isPanning = false
|
||||
mockStore.brushVisible = true
|
||||
mockStore.displayZoomRatio = 1
|
||||
mockStore.resetZoomTrigger = 0
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('initializeCanvasPanZoom', () => {
|
||||
it('sets zoom and pan on the store', async () => {
|
||||
const pz = usePanAndZoom()
|
||||
const container = createMockElement()
|
||||
mockStore.canvasContainer = container as unknown as HTMLElement
|
||||
|
||||
await pz.initializeCanvasPanZoom(
|
||||
createMockImage(800, 600),
|
||||
createMockElement()
|
||||
)
|
||||
|
||||
expect(mockStore.setZoomRatio).toHaveBeenCalledOnce()
|
||||
expect(mockStore.setPanOffset).toHaveBeenCalledOnce()
|
||||
|
||||
const zoom = vi.mocked(mockStore.setZoomRatio).mock.calls[0][0]
|
||||
expect(zoom).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('accounts for panel widths via setPanOffset', async () => {
|
||||
const pz = usePanAndZoom()
|
||||
mockStore.canvasContainer = createMockElement() as unknown as HTMLElement
|
||||
|
||||
const toolPanel = createMockElement()
|
||||
vi.spyOn(toolPanel, 'getBoundingClientRect').mockReturnValue({
|
||||
width: 64
|
||||
} as DOMRect)
|
||||
const sidePanel = createMockElement()
|
||||
vi.spyOn(sidePanel, 'getBoundingClientRect').mockReturnValue({
|
||||
width: 220
|
||||
} as DOMRect)
|
||||
|
||||
await pz.initializeCanvasPanZoom(
|
||||
createMockImage(800, 600),
|
||||
createMockElement(),
|
||||
toolPanel,
|
||||
sidePanel
|
||||
)
|
||||
|
||||
const offset = vi.mocked(mockStore.setPanOffset).mock.calls[0][0]
|
||||
expect(offset.x).toBeGreaterThanOrEqual(64)
|
||||
})
|
||||
|
||||
it('syncs rgbCanvas dimensions when they differ', async () => {
|
||||
const pz = usePanAndZoom()
|
||||
const rgbCanvas = createMockCanvas(400, 300)
|
||||
mockStore.canvasContainer = createMockElement() as unknown as HTMLElement
|
||||
mockStore.rgbCanvas = rgbCanvas
|
||||
|
||||
await pz.initializeCanvasPanZoom(
|
||||
createMockImage(800, 600),
|
||||
createMockElement()
|
||||
)
|
||||
|
||||
expect(rgbCanvas.width).toBe(800)
|
||||
expect(rgbCanvas.height).toBe(600)
|
||||
})
|
||||
})
|
||||
|
||||
describe('handlePanStart / handlePanMove', () => {
|
||||
it('sets isPanning on the store', () => {
|
||||
const pz = usePanAndZoom()
|
||||
pz.handlePanStart({ clientX: 100, clientY: 200 } as PointerEvent)
|
||||
expect(mockStore.isPanning).toBe(true)
|
||||
})
|
||||
|
||||
it('updates pan offset on move', async () => {
|
||||
const { pz } = await initComposable()
|
||||
|
||||
pz.handlePanStart({ clientX: 100, clientY: 200 } as PointerEvent)
|
||||
await pz.handlePanMove({
|
||||
clientX: 150,
|
||||
clientY: 250
|
||||
} as PointerEvent)
|
||||
|
||||
expect(mockStore.setPanOffset).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('throws if move called without start', async () => {
|
||||
const pz = usePanAndZoom()
|
||||
await expect(
|
||||
pz.handlePanMove({ clientX: 0, clientY: 0 } as PointerEvent)
|
||||
).rejects.toThrow('mouseDownPoint is null')
|
||||
})
|
||||
})
|
||||
|
||||
describe('zoom', () => {
|
||||
it('zooms in with negative deltaY and updates store', async () => {
|
||||
const { pz } = await initComposable()
|
||||
const initialZoom = vi.mocked(mockStore.setZoomRatio).mock.calls[0]?.[0]
|
||||
|
||||
await pz.zoom({
|
||||
clientX: 400,
|
||||
clientY: 300,
|
||||
deltaY: -100
|
||||
} as WheelEvent)
|
||||
|
||||
const zoomValue = vi.mocked(mockStore.setZoomRatio).mock.calls[0][0]
|
||||
expect(zoomValue).toBeGreaterThan(initialZoom ?? 0)
|
||||
})
|
||||
|
||||
it('zooms out with positive deltaY producing smaller zoom', async () => {
|
||||
const { pz } = await initComposable()
|
||||
|
||||
await pz.zoom({
|
||||
clientX: 400,
|
||||
clientY: 300,
|
||||
deltaY: -100
|
||||
} as WheelEvent)
|
||||
|
||||
const zoomIn = vi.mocked(mockStore.setZoomRatio).mock.calls[0][0]
|
||||
vi.clearAllMocks()
|
||||
|
||||
await pz.zoom({
|
||||
clientX: 400,
|
||||
clientY: 300,
|
||||
deltaY: 100
|
||||
} as WheelEvent)
|
||||
|
||||
const zoomOut = vi.mocked(mockStore.setZoomRatio).mock.calls[0][0]
|
||||
expect(zoomOut).toBeLessThan(zoomIn)
|
||||
})
|
||||
|
||||
it('clamps zoom at lower bound after many zoom-outs', async () => {
|
||||
const { pz } = await initComposable()
|
||||
|
||||
for (let i = 0; i < 50; i++) {
|
||||
await pz.zoom({
|
||||
clientX: 400,
|
||||
clientY: 300,
|
||||
deltaY: 100
|
||||
} as WheelEvent)
|
||||
}
|
||||
|
||||
const calls = mockStore.setZoomRatio.mock.calls
|
||||
expect(calls[calls.length - 1][0]).toBeGreaterThanOrEqual(0.2)
|
||||
})
|
||||
|
||||
it('clamps zoom at upper bound after many zoom-ins', async () => {
|
||||
const { pz } = await initComposable()
|
||||
|
||||
for (let i = 0; i < 100; i++) {
|
||||
await pz.zoom({
|
||||
clientX: 400,
|
||||
clientY: 300,
|
||||
deltaY: -100
|
||||
} as WheelEvent)
|
||||
}
|
||||
|
||||
const calls = mockStore.setZoomRatio.mock.calls
|
||||
expect(calls[calls.length - 1][0]).toBeLessThanOrEqual(10)
|
||||
})
|
||||
|
||||
it('returns early when maskCanvas is null', async () => {
|
||||
const pz = usePanAndZoom()
|
||||
const container = createMockElement()
|
||||
mockStore.canvasContainer = container as unknown as HTMLElement
|
||||
mockStore.maskCanvas = null
|
||||
await pz.initializeCanvasPanZoom(
|
||||
createMockImage(800, 600),
|
||||
createMockElement()
|
||||
)
|
||||
vi.clearAllMocks()
|
||||
|
||||
await pz.zoom({
|
||||
clientX: 400,
|
||||
clientY: 300,
|
||||
deltaY: -100
|
||||
} as WheelEvent)
|
||||
|
||||
expect(mockStore.setPanOffset).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('updates cursor position after zooming', async () => {
|
||||
const { pz } = await initComposable()
|
||||
|
||||
await pz.zoom({
|
||||
clientX: 300,
|
||||
clientY: 200,
|
||||
deltaY: -100
|
||||
} as WheelEvent)
|
||||
|
||||
expect(mockStore.setCursorPoint).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateCursorPosition', () => {
|
||||
it('calls store.setCursorPoint', async () => {
|
||||
const { pz } = await initComposable()
|
||||
|
||||
pz.updateCursorPosition({ x: 500, y: 400 })
|
||||
|
||||
expect(mockStore.setCursorPoint).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('invalidatePanZoom', () => {
|
||||
it('warns and returns early when image is missing', async () => {
|
||||
const consoleWarnSpy = vi
|
||||
.spyOn(console, 'warn')
|
||||
.mockImplementation(() => {})
|
||||
|
||||
try {
|
||||
const pz = usePanAndZoom()
|
||||
await pz.invalidatePanZoom()
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
'Missing required properties for pan/zoom'
|
||||
)
|
||||
} finally {
|
||||
consoleWarnSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('touch handlers', () => {
|
||||
it('sets brushVisible false on single touch', () => {
|
||||
const pz = usePanAndZoom()
|
||||
pz.handleTouchStart(createTouchEvent(createTouchList({ x: 1, y: 2 })))
|
||||
expect(mockStore.brushVisible).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores touch when pen pointer is active', () => {
|
||||
const pz = usePanAndZoom()
|
||||
pz.addPenPointerId(1)
|
||||
pz.handleTouchStart(createTouchEvent(createTouchList({ x: 1, y: 2 })))
|
||||
expect(mockStore.brushVisible).toBe(true)
|
||||
})
|
||||
|
||||
it('triggers undo on two-finger double-tap', () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
try {
|
||||
const pz = usePanAndZoom()
|
||||
const touches = createTouchList({ x: 100, y: 200 }, { x: 300, y: 200 })
|
||||
|
||||
pz.handleTouchStart(createTouchEvent(touches))
|
||||
vi.advanceTimersByTime(100)
|
||||
pz.handleTouchStart(createTouchEvent(touches))
|
||||
|
||||
expect(mockStore.canvasHistory.undo).toHaveBeenCalled()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('single-touch move pans the canvas', async () => {
|
||||
const { pz } = await initComposable()
|
||||
|
||||
pz.handleTouchStart(createTouchEvent(createTouchList({ x: 100, y: 200 })))
|
||||
vi.clearAllMocks()
|
||||
|
||||
await pz.handleTouchMove(
|
||||
createTouchEvent(createTouchList({ x: 150, y: 250 }))
|
||||
)
|
||||
|
||||
expect(mockStore.setPanOffset).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('two-finger move performs pinch zoom', async () => {
|
||||
const { pz } = await initComposable()
|
||||
|
||||
pz.handleTouchStart(
|
||||
createTouchEvent(
|
||||
createTouchList({ x: 200, y: 300 }, { x: 400, y: 300 })
|
||||
)
|
||||
)
|
||||
vi.clearAllMocks()
|
||||
|
||||
await pz.handleTouchMove(
|
||||
createTouchEvent(
|
||||
createTouchList({ x: 150, y: 300 }, { x: 450, y: 300 })
|
||||
)
|
||||
)
|
||||
|
||||
expect(mockStore.setZoomRatio).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('touch move is ignored when pen is active', async () => {
|
||||
const pz = usePanAndZoom()
|
||||
pz.addPenPointerId(1)
|
||||
|
||||
await pz.handleTouchMove(
|
||||
createTouchEvent(createTouchList({ x: 100, y: 200 }))
|
||||
)
|
||||
|
||||
expect(mockStore.setPanOffset).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handleTouchEnd calls preventDefault', () => {
|
||||
const pz = usePanAndZoom()
|
||||
const event = createTouchEvent(createTouchList({ x: 200, y: 300 }))
|
||||
pz.handleTouchEnd(event)
|
||||
expect(event.preventDefault).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('pen pointer management', () => {
|
||||
it('blocks touch input while pen is active', () => {
|
||||
const pz = usePanAndZoom()
|
||||
pz.addPenPointerId(5)
|
||||
|
||||
pz.handleTouchStart(createTouchEvent(createTouchList({ x: 0, y: 0 })))
|
||||
expect(mockStore.brushVisible).toBe(true)
|
||||
})
|
||||
|
||||
it('does not add duplicate ids', () => {
|
||||
const pz = usePanAndZoom()
|
||||
pz.addPenPointerId(5)
|
||||
pz.addPenPointerId(5)
|
||||
pz.removePenPointerId(5)
|
||||
|
||||
pz.handleTouchStart(createTouchEvent(createTouchList({ x: 0, y: 0 })))
|
||||
expect(mockStore.brushVisible).toBe(false)
|
||||
})
|
||||
|
||||
it('re-enables touch after removing pen id', () => {
|
||||
const pz = usePanAndZoom()
|
||||
pz.addPenPointerId(5)
|
||||
pz.removePenPointerId(5)
|
||||
|
||||
pz.handleTouchStart(createTouchEvent(createTouchList({ x: 0, y: 0 })))
|
||||
expect(mockStore.brushVisible).toBe(false)
|
||||
})
|
||||
|
||||
it('no-ops when removing non-existent id', () => {
|
||||
const pz = usePanAndZoom()
|
||||
pz.removePenPointerId(999)
|
||||
|
||||
pz.handleTouchStart(createTouchEvent(createTouchList({ x: 0, y: 0 })))
|
||||
expect(mockStore.brushVisible).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,23 +1,7 @@
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import type { Offset, Point } from '@/extensions/core/maskeditor/types'
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
|
||||
import {
|
||||
calculateDragPan,
|
||||
calculateFitView,
|
||||
calculatePanZoomStyles,
|
||||
calculateSingleTouchPan,
|
||||
calculateZoomAroundPoint,
|
||||
easeOutCubic,
|
||||
getCursorPoint,
|
||||
getDistanceBetweenPoints,
|
||||
getMidpoint,
|
||||
getWheelZoomFactor,
|
||||
interpolateView,
|
||||
isDoubleTap
|
||||
} from './panZoomUtils'
|
||||
|
||||
export function usePanAndZoom() {
|
||||
const store = useMaskEditorStore()
|
||||
|
||||
@@ -51,10 +35,24 @@ export function usePanAndZoom() {
|
||||
const cursorPoint = ref<Point>({ x: 0, y: 0 })
|
||||
const penPointerIdList = ref<number[]>([])
|
||||
|
||||
const getTouchDistance = (touches: TouchList): number => {
|
||||
const dx = touches[0].clientX - touches[1].clientX
|
||||
const dy = touches[0].clientY - touches[1].clientY
|
||||
return Math.sqrt(dx * dx + dy * dy)
|
||||
}
|
||||
|
||||
const getTouchMidpoint = (touches: TouchList): Point => {
|
||||
return {
|
||||
x: (touches[0].clientX + touches[1].clientX) / 2,
|
||||
y: (touches[0].clientY + touches[1].clientY) / 2
|
||||
}
|
||||
}
|
||||
|
||||
const updateCursorPosition = (clientPoint: Point): void => {
|
||||
const point = getCursorPoint(clientPoint, pan_offset.value)
|
||||
cursorPoint.value = point
|
||||
store.setCursorPoint(point)
|
||||
const cursorX = clientPoint.x - pan_offset.value.x
|
||||
const cursorY = clientPoint.y - pan_offset.value.y
|
||||
cursorPoint.value = { x: cursorX, y: cursorY }
|
||||
store.setCursorPoint({ x: cursorX, y: cursorY })
|
||||
}
|
||||
|
||||
const handleDoubleTap = (): void => {
|
||||
@@ -62,6 +60,7 @@ export function usePanAndZoom() {
|
||||
}
|
||||
|
||||
const invalidatePanZoom = async (): Promise<void> => {
|
||||
// Single validation check upfront
|
||||
if (
|
||||
!image.value?.width ||
|
||||
!image.value?.height ||
|
||||
@@ -72,12 +71,8 @@ export function usePanAndZoom() {
|
||||
return
|
||||
}
|
||||
|
||||
const styles = calculatePanZoomStyles(
|
||||
image.value.width,
|
||||
image.value.height,
|
||||
zoom_ratio.value,
|
||||
pan_offset.value
|
||||
)
|
||||
const raw_width = image.value.width * zoom_ratio.value
|
||||
const raw_height = image.value.height * zoom_ratio.value
|
||||
|
||||
if (!canvasContainer.value) {
|
||||
canvasContainer.value = store.canvasContainer
|
||||
@@ -85,10 +80,10 @@ export function usePanAndZoom() {
|
||||
if (!canvasContainer.value) return
|
||||
|
||||
Object.assign(canvasContainer.value.style, {
|
||||
width: styles.containerWidth,
|
||||
height: styles.containerHeight,
|
||||
left: styles.containerLeft,
|
||||
top: styles.containerTop
|
||||
width: `${raw_width}px`,
|
||||
height: `${raw_height}px`,
|
||||
left: `${pan_offset.value.x}px`,
|
||||
top: `${pan_offset.value.y}px`
|
||||
})
|
||||
|
||||
if (!rgbCanvas.value) {
|
||||
@@ -103,8 +98,8 @@ export function usePanAndZoom() {
|
||||
rgbCanvas.value.height = image.value.height
|
||||
}
|
||||
|
||||
rgbCanvas.value.style.width = styles.containerWidth
|
||||
rgbCanvas.value.style.height = styles.containerHeight
|
||||
rgbCanvas.value.style.width = `${raw_width}px`
|
||||
rgbCanvas.value.style.height = `${raw_height}px`
|
||||
}
|
||||
|
||||
store.setPanOffset(pan_offset.value)
|
||||
@@ -123,11 +118,13 @@ export function usePanAndZoom() {
|
||||
throw new Error('mouseDownPoint is null')
|
||||
}
|
||||
|
||||
pan_offset.value = calculateDragPan(
|
||||
mouseDownPoint.value,
|
||||
{ x: event.clientX, y: event.clientY },
|
||||
initialPan.value
|
||||
)
|
||||
const deltaX = mouseDownPoint.value.x - event.clientX
|
||||
const deltaY = mouseDownPoint.value.y - event.clientY
|
||||
|
||||
const pan_x = initialPan.value.x - deltaX
|
||||
const pan_y = initialPan.value.y - deltaY
|
||||
|
||||
pan_offset.value = { x: pan_x, y: pan_y }
|
||||
|
||||
await invalidatePanZoom()
|
||||
}
|
||||
@@ -138,22 +135,17 @@ export function usePanAndZoom() {
|
||||
return
|
||||
}
|
||||
|
||||
pan_offset.value = calculateSingleTouchPan(
|
||||
lastTouchPoint.value,
|
||||
{ x: touch.clientX, y: touch.clientY },
|
||||
pan_offset.value
|
||||
)
|
||||
const deltaX = touch.clientX - lastTouchPoint.value.x
|
||||
const deltaY = touch.clientY - lastTouchPoint.value.y
|
||||
|
||||
pan_offset.value.x += deltaX
|
||||
pan_offset.value.y += deltaY
|
||||
|
||||
await invalidatePanZoom()
|
||||
|
||||
lastTouchPoint.value = { x: touch.clientX, y: touch.clientY }
|
||||
}
|
||||
|
||||
const touchToPoint = (touch: Touch): Point => ({
|
||||
x: touch.clientX,
|
||||
y: touch.clientY
|
||||
})
|
||||
|
||||
const handleTouchStart = (event: TouchEvent): void => {
|
||||
event.preventDefault()
|
||||
|
||||
@@ -163,22 +155,23 @@ export function usePanAndZoom() {
|
||||
|
||||
if (event.touches.length === 2) {
|
||||
const currentTime = new Date().getTime()
|
||||
const tapTimeDiff = currentTime - lastTwoFingerTap.value
|
||||
|
||||
if (isDoubleTap(currentTime, lastTwoFingerTap.value, DOUBLE_TAP_DELAY)) {
|
||||
if (tapTimeDiff < DOUBLE_TAP_DELAY) {
|
||||
handleDoubleTap()
|
||||
lastTwoFingerTap.value = 0
|
||||
} else {
|
||||
lastTwoFingerTap.value = currentTime
|
||||
|
||||
const p0 = touchToPoint(event.touches[0])
|
||||
const p1 = touchToPoint(event.touches[1])
|
||||
|
||||
isTouchZooming.value = true
|
||||
lastTouchZoomDistance.value = getDistanceBetweenPoints(p0, p1)
|
||||
lastTouchMidPoint.value = getMidpoint(p0, p1)
|
||||
lastTouchZoomDistance.value = getTouchDistance(event.touches)
|
||||
lastTouchMidPoint.value = getTouchMidpoint(event.touches)
|
||||
}
|
||||
} else if (event.touches.length === 1) {
|
||||
lastTouchPoint.value = touchToPoint(event.touches[0])
|
||||
lastTouchPoint.value = {
|
||||
x: event.touches[0].clientX,
|
||||
y: event.touches[0].clientY
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,38 +183,37 @@ export function usePanAndZoom() {
|
||||
lastTwoFingerTap.value = 0
|
||||
|
||||
if (isTouchZooming.value && event.touches.length === 2) {
|
||||
const p0 = touchToPoint(event.touches[0])
|
||||
const p1 = touchToPoint(event.touches[1])
|
||||
const newDistance = getTouchDistance(event.touches)
|
||||
const zoomFactor = newDistance / lastTouchZoomDistance.value
|
||||
const oldZoom = zoom_ratio.value
|
||||
zoom_ratio.value = Math.max(
|
||||
0.2,
|
||||
Math.min(10.0, zoom_ratio.value * zoomFactor)
|
||||
)
|
||||
const newZoom = zoom_ratio.value
|
||||
|
||||
const newDistance = getDistanceBetweenPoints(p0, p1)
|
||||
const pinchFactor = newDistance / lastTouchZoomDistance.value
|
||||
const midpoint = getMidpoint(p0, p1)
|
||||
const midpoint = getTouchMidpoint(event.touches)
|
||||
|
||||
// Apply midpoint drag
|
||||
const draggedPan: Offset = {
|
||||
x: pan_offset.value.x + midpoint.x - lastTouchMidPoint.value.x,
|
||||
y: pan_offset.value.y + midpoint.y - lastTouchMidPoint.value.y
|
||||
if (lastTouchMidPoint.value) {
|
||||
const deltaX = midpoint.x - lastTouchMidPoint.value.x
|
||||
const deltaY = midpoint.y - lastTouchMidPoint.value.y
|
||||
|
||||
pan_offset.value.x += deltaX
|
||||
pan_offset.value.y += deltaY
|
||||
}
|
||||
|
||||
if (!maskCanvas.value) {
|
||||
if (maskCanvas.value === null) {
|
||||
maskCanvas.value = store.maskCanvas
|
||||
}
|
||||
if (!maskCanvas.value) return
|
||||
|
||||
const rect = maskCanvas.value.getBoundingClientRect()
|
||||
const focalX = midpoint.x - rect.left
|
||||
const focalY = midpoint.y - rect.top
|
||||
const touchX = midpoint.x - rect.left
|
||||
const touchY = midpoint.y - rect.top
|
||||
|
||||
const result = calculateZoomAroundPoint(
|
||||
zoom_ratio.value,
|
||||
pinchFactor,
|
||||
draggedPan,
|
||||
focalX,
|
||||
focalY
|
||||
)
|
||||
|
||||
zoom_ratio.value = result.zoomRatio
|
||||
pan_offset.value = result.panOffset
|
||||
const scaleFactor = newZoom / oldZoom
|
||||
pan_offset.value.x += touchX - touchX * scaleFactor
|
||||
pan_offset.value.y += touchY - touchY * scaleFactor
|
||||
|
||||
await invalidatePanZoom()
|
||||
lastTouchZoomDistance.value = newDistance
|
||||
@@ -237,7 +229,10 @@ export function usePanAndZoom() {
|
||||
const lastTouch = event.touches[0]
|
||||
|
||||
if (lastTouch) {
|
||||
lastTouchPoint.value = touchToPoint(lastTouch)
|
||||
lastTouchPoint.value = {
|
||||
x: lastTouch.clientX,
|
||||
y: lastTouch.clientY
|
||||
}
|
||||
} else {
|
||||
isTouchZooming.value = false
|
||||
lastTouchMidPoint.value = { x: 0, y: 0 }
|
||||
@@ -247,29 +242,31 @@ export function usePanAndZoom() {
|
||||
const zoom = async (event: WheelEvent): Promise<void> => {
|
||||
const cursorPosition = { x: event.clientX, y: event.clientY }
|
||||
|
||||
const oldZoom = zoom_ratio.value
|
||||
const zoomFactor = event.deltaY < 0 ? 1.1 : 0.9
|
||||
zoom_ratio.value = Math.max(
|
||||
0.2,
|
||||
Math.min(10.0, zoom_ratio.value * zoomFactor)
|
||||
)
|
||||
const newZoom = zoom_ratio.value
|
||||
|
||||
if (!maskCanvas.value) {
|
||||
maskCanvas.value = store.maskCanvas
|
||||
}
|
||||
if (!maskCanvas.value) return
|
||||
|
||||
const rect = maskCanvas.value.getBoundingClientRect()
|
||||
const focalX = cursorPosition.x - rect.left
|
||||
const focalY = cursorPosition.y - rect.top
|
||||
const mouseX = cursorPosition.x - rect.left
|
||||
const mouseY = cursorPosition.y - rect.top
|
||||
|
||||
const result = calculateZoomAroundPoint(
|
||||
zoom_ratio.value,
|
||||
getWheelZoomFactor(event.deltaY),
|
||||
pan_offset.value,
|
||||
focalX,
|
||||
focalY
|
||||
)
|
||||
|
||||
zoom_ratio.value = result.zoomRatio
|
||||
pan_offset.value = result.panOffset
|
||||
const scaleFactor = newZoom / oldZoom
|
||||
pan_offset.value.x += mouseX - mouseX * scaleFactor
|
||||
pan_offset.value.y += mouseY - mouseY * scaleFactor
|
||||
|
||||
await invalidatePanZoom()
|
||||
|
||||
const newImageWidth = maskCanvas.value.clientWidth
|
||||
|
||||
const zoomRatio = newImageWidth / imageRootWidth.value
|
||||
|
||||
interpolatedZoomRatio.value = zoomRatio
|
||||
@@ -298,31 +295,40 @@ export function usePanAndZoom() {
|
||||
|
||||
const { sidePanelWidth, toolPanelWidth } = getPanelDimensions()
|
||||
|
||||
const fitResult = calculateFitView({
|
||||
rootWidth: rootElement.value.clientWidth,
|
||||
rootHeight: rootElement.value.clientHeight,
|
||||
imageWidth: image.value.width,
|
||||
imageHeight: image.value.height,
|
||||
toolPanelWidth,
|
||||
sidePanelWidth
|
||||
})
|
||||
const availableWidth =
|
||||
rootElement.value.clientWidth - sidePanelWidth - toolPanelWidth
|
||||
const availableHeight = rootElement.value.clientHeight
|
||||
|
||||
// Calculate target zoom
|
||||
const zoomRatioWidth = availableWidth / image.value.width
|
||||
const zoomRatioHeight = availableHeight / image.value.height
|
||||
const targetZoom = Math.min(zoomRatioWidth, zoomRatioHeight)
|
||||
|
||||
const aspectRatio = image.value.width / image.value.height
|
||||
let finalWidth: number
|
||||
let finalHeight: number
|
||||
|
||||
const targetPan = { x: toolPanelWidth, y: 0 }
|
||||
|
||||
if (zoomRatioHeight > zoomRatioWidth) {
|
||||
finalWidth = availableWidth
|
||||
finalHeight = finalWidth / aspectRatio
|
||||
targetPan.y = (availableHeight - finalHeight) / 2
|
||||
} else {
|
||||
finalHeight = availableHeight
|
||||
finalWidth = finalHeight * aspectRatio
|
||||
targetPan.x = (availableWidth - finalWidth) / 2 + toolPanelWidth
|
||||
}
|
||||
|
||||
const startTime = performance.now()
|
||||
const animate = async (currentTime: number) => {
|
||||
const elapsed = currentTime - startTime
|
||||
const progress = Math.min(elapsed / duration, 1)
|
||||
const eased = easeOutCubic(progress)
|
||||
const eased = 1 - Math.pow(1 - progress, 3)
|
||||
|
||||
const frame = interpolateView(
|
||||
startZoom,
|
||||
fitResult.zoomRatio,
|
||||
startPan,
|
||||
fitResult.panOffset,
|
||||
eased
|
||||
)
|
||||
|
||||
zoom_ratio.value = frame.zoomRatio
|
||||
pan_offset.value = frame.panOffset
|
||||
zoom_ratio.value = startZoom + (targetZoom - startZoom) * eased
|
||||
pan_offset.value.x = startPan.x + (targetPan.x - startPan.x) * eased
|
||||
pan_offset.value.y = startPan.y + (targetPan.y - startPan.y) * eased
|
||||
|
||||
await invalidatePanZoom()
|
||||
|
||||
@@ -350,24 +356,38 @@ export function usePanAndZoom() {
|
||||
|
||||
const { sidePanelWidth, toolPanelWidth } = getPanelDimensions()
|
||||
|
||||
const fitResult = calculateFitView({
|
||||
rootWidth: root.clientWidth,
|
||||
rootHeight: root.clientHeight,
|
||||
imageWidth: img.width,
|
||||
imageHeight: img.height,
|
||||
toolPanelWidth,
|
||||
sidePanelWidth
|
||||
})
|
||||
const availableWidth = root.clientWidth - sidePanelWidth - toolPanelWidth
|
||||
const availableHeight = root.clientHeight
|
||||
|
||||
const zoomRatioWidth = availableWidth / img.width
|
||||
const zoomRatioHeight = availableHeight / img.height
|
||||
|
||||
const aspectRatio = img.width / img.height
|
||||
|
||||
let finalWidth: number
|
||||
let finalHeight: number
|
||||
|
||||
const panOffset: Offset = { x: toolPanelWidth, y: 0 }
|
||||
|
||||
if (zoomRatioHeight > zoomRatioWidth) {
|
||||
finalWidth = availableWidth
|
||||
finalHeight = finalWidth / aspectRatio
|
||||
panOffset.y = (availableHeight - finalHeight) / 2
|
||||
} else {
|
||||
finalHeight = availableHeight
|
||||
finalWidth = finalHeight * aspectRatio
|
||||
panOffset.x = (availableWidth - finalWidth) / 2 + toolPanelWidth
|
||||
}
|
||||
|
||||
if (image.value === null) {
|
||||
image.value = img
|
||||
}
|
||||
|
||||
imageRootWidth.value = fitResult.fittedWidth
|
||||
imageRootHeight.value = fitResult.fittedHeight
|
||||
imageRootWidth.value = finalWidth
|
||||
imageRootHeight.value = finalHeight
|
||||
|
||||
zoom_ratio.value = fitResult.zoomRatio
|
||||
pan_offset.value = fitResult.panOffset
|
||||
zoom_ratio.value = Math.min(zoomRatioWidth, zoomRatioHeight)
|
||||
pan_offset.value = panOffset
|
||||
|
||||
penPointerIdList.value = []
|
||||
|
||||
|
||||
@@ -359,7 +359,7 @@ describe('usePainter', () => {
|
||||
expect(result).toBe('')
|
||||
})
|
||||
|
||||
it('returns empty string when canvas has no strokes even if modelValue is set', async () => {
|
||||
it('returns existing modelValue when not dirty', async () => {
|
||||
const maskWidget = makeWidget('mask', '')
|
||||
mockWidgets.push(maskWidget)
|
||||
|
||||
@@ -367,11 +367,20 @@ describe('usePainter', () => {
|
||||
modelValue.value = 'painter/existing.png [temp]'
|
||||
|
||||
const result = await maskWidget.serializeValue!({} as LGraphNode, 0)
|
||||
// isCanvasEmpty() is true (no strokes drawn), so returns ''
|
||||
expect(result).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('restoreCanvas', () => {
|
||||
it('builds correct URL from modelValue on mount', () => {
|
||||
const { modelValue } = mountPainter()
|
||||
// Before mount, set the modelValue
|
||||
// restoreCanvas is called in onMounted, so we test by observing api.apiURL calls
|
||||
// With empty modelValue, restoreCanvas exits early
|
||||
expect(modelValue.value).toBe('')
|
||||
})
|
||||
|
||||
it('calls api.apiURL with parsed filename params when modelValue is set', () => {
|
||||
vi.mocked(api.apiURL).mockClear()
|
||||
|
||||
@@ -415,27 +424,6 @@ describe('usePainter', () => {
|
||||
|
||||
expect(mockSetPointerCapture).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('tolerates setPointerCapture throwing for synthetic events', () => {
|
||||
const { painter } = mountPainter()
|
||||
|
||||
const event = new PointerEvent('pointerdown', { button: 0, pointerId: 1 })
|
||||
Object.defineProperty(event, 'target', {
|
||||
value: {
|
||||
setPointerCapture: vi.fn(() => {
|
||||
throw new DOMException('NotFoundError')
|
||||
}),
|
||||
getBoundingClientRect: vi.fn(() => ({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 100,
|
||||
height: 100
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
expect(() => painter.handlePointerDown(event)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('handlePointerUp', () => {
|
||||
@@ -454,21 +442,5 @@ describe('usePainter', () => {
|
||||
|
||||
expect(mockReleasePointerCapture).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('tolerates releasePointerCapture throwing for synthetic events', () => {
|
||||
const { painter } = mountPainter()
|
||||
|
||||
const event = {
|
||||
button: 0,
|
||||
pointerId: 1,
|
||||
target: {
|
||||
releasePointerCapture: vi.fn(() => {
|
||||
throw new DOMException('NotFoundError')
|
||||
})
|
||||
}
|
||||
} as unknown as PointerEvent
|
||||
|
||||
expect(() => painter.handlePointerUp(event)).not.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -525,14 +525,10 @@ export function usePainter(nodeId: string, options: UsePainterOptions) {
|
||||
|
||||
function handlePointerDown(e: PointerEvent) {
|
||||
if (e.button !== 0) return
|
||||
;(e.target as HTMLElement).setPointerCapture(e.pointerId)
|
||||
cacheCanvasRect()
|
||||
updateCursorPos(e)
|
||||
startStroke(e)
|
||||
try {
|
||||
;(e.target as HTMLElement).setPointerCapture(e.pointerId)
|
||||
} catch {
|
||||
// setPointerCapture may throw for synthetic events (e.g. in tests)
|
||||
}
|
||||
}
|
||||
|
||||
let pendingMoveEvent: PointerEvent | null = null
|
||||
@@ -562,11 +558,7 @@ export function usePainter(nodeId: string, options: UsePainterOptions) {
|
||||
cancelAnimationFrame(rafId)
|
||||
flushPendingStroke()
|
||||
}
|
||||
try {
|
||||
;(e.target as HTMLElement).releasePointerCapture(e.pointerId)
|
||||
} catch {
|
||||
// releasePointerCapture may throw for synthetic events (e.g. in tests)
|
||||
}
|
||||
;(e.target as HTMLElement).releasePointerCapture(e.pointerId)
|
||||
endStroke()
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import { nextTick, ref, shallowRef } from 'vue'
|
||||
import { nodeToLoad3dMap, useLoad3d } from '@/composables/useLoad3d'
|
||||
import Load3d from '@/extensions/core/load3d/Load3d'
|
||||
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
|
||||
import { createLoad3d } from '@/extensions/core/load3d/createLoad3d'
|
||||
import type { Size } from '@/lib/litegraph/src/interfaces'
|
||||
import type { LGraph } from '@/lib/litegraph/src/LGraph'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
@@ -20,10 +19,6 @@ vi.mock('@/extensions/core/load3d/Load3d', () => ({
|
||||
default: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/extensions/core/load3d/createLoad3d', () => ({
|
||||
createLoad3d: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/extensions/core/load3d/Load3dUtils', () => ({
|
||||
default: {
|
||||
splitFilePath: vi.fn(),
|
||||
@@ -166,7 +161,6 @@ describe('useLoad3d', () => {
|
||||
Object.assign(this, mockLoad3d)
|
||||
return this
|
||||
})
|
||||
vi.mocked(createLoad3d).mockImplementation(() => mockLoad3d as Load3d)
|
||||
|
||||
mockToastStore = {
|
||||
addAlert: vi.fn()
|
||||
@@ -187,7 +181,7 @@ describe('useLoad3d', () => {
|
||||
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
expect(createLoad3d).toHaveBeenCalledWith(
|
||||
expect(Load3d).toHaveBeenCalledWith(
|
||||
containerRef,
|
||||
expect.objectContaining({
|
||||
width: 512,
|
||||
@@ -297,7 +291,7 @@ describe('useLoad3d', () => {
|
||||
})
|
||||
|
||||
it('should handle initialization errors', async () => {
|
||||
vi.mocked(createLoad3d).mockImplementationOnce(() => {
|
||||
vi.mocked(Load3d).mockImplementationOnce(function () {
|
||||
throw new Error('Load3d creation failed')
|
||||
})
|
||||
|
||||
@@ -316,7 +310,7 @@ describe('useLoad3d', () => {
|
||||
|
||||
await composable.initializeLoad3d(null!)
|
||||
|
||||
expect(createLoad3d).not.toHaveBeenCalled()
|
||||
expect(Load3d).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should accept ref as parameter', () => {
|
||||
@@ -1035,7 +1029,7 @@ describe('useLoad3d', () => {
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
// Should not throw and should use defaults
|
||||
expect(createLoad3d).toHaveBeenCalled()
|
||||
expect(Load3d).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle background image with existing config', async () => {
|
||||
|
||||
@@ -5,9 +5,8 @@ import { getActivePinia } from 'pinia'
|
||||
import { ref, toRaw, watch } from 'vue'
|
||||
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import type Load3d from '@/extensions/core/load3d/Load3d'
|
||||
import Load3d from '@/extensions/core/load3d/Load3d'
|
||||
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
|
||||
import { createLoad3d } from '@/extensions/core/load3d/createLoad3d'
|
||||
import {
|
||||
isAssetPreviewSupported,
|
||||
persistThumbnail
|
||||
@@ -112,7 +111,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
isPreview.value = true
|
||||
}
|
||||
|
||||
load3d = createLoad3d(containerRef, {
|
||||
load3d = new Load3d(containerRef, {
|
||||
width: widthWidget?.value as number | undefined,
|
||||
height: heightWidget?.value as number | undefined,
|
||||
// Provide dynamic dimension getter for reactive updates
|
||||
|
||||
@@ -4,7 +4,6 @@ import { nextTick } from 'vue'
|
||||
import { useLoad3dViewer } from '@/composables/useLoad3dViewer'
|
||||
import Load3d from '@/extensions/core/load3d/Load3d'
|
||||
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
|
||||
import { createLoad3d } from '@/extensions/core/load3d/createLoad3d'
|
||||
import type { LGraph } from '@/lib/litegraph/src/LGraph'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
@@ -33,10 +32,6 @@ vi.mock('@/extensions/core/load3d/Load3d', () => ({
|
||||
default: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/extensions/core/load3d/createLoad3d', () => ({
|
||||
createLoad3d: vi.fn()
|
||||
}))
|
||||
|
||||
function createMockSceneManager(): Load3d['sceneManager'] {
|
||||
const mock: Partial<Load3d['sceneManager']> = {
|
||||
scene: {} as Load3d['sceneManager']['scene'],
|
||||
@@ -153,7 +148,6 @@ describe('useLoad3dViewer', () => {
|
||||
vi.mocked(Load3d).mockImplementation(function () {
|
||||
Object.assign(this, mockLoad3d)
|
||||
})
|
||||
vi.mocked(createLoad3d).mockImplementation(() => mockLoad3d as Load3d)
|
||||
|
||||
mockLoad3dService = {
|
||||
copyLoad3dState: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -183,7 +177,7 @@ describe('useLoad3dViewer', () => {
|
||||
|
||||
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
|
||||
|
||||
expect(createLoad3d).toHaveBeenCalledWith(containerRef, {
|
||||
expect(Load3d).toHaveBeenCalledWith(containerRef, {
|
||||
width: undefined,
|
||||
height: undefined,
|
||||
getDimensions: undefined,
|
||||
@@ -225,7 +219,7 @@ describe('useLoad3dViewer', () => {
|
||||
})
|
||||
|
||||
it('should handle initialization errors', async () => {
|
||||
vi.mocked(createLoad3d).mockImplementationOnce(() => {
|
||||
vi.mocked(Load3d).mockImplementationOnce(function () {
|
||||
throw new Error('Load3d creation failed')
|
||||
})
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { ref, toRaw, watch } from 'vue'
|
||||
import QuickLRU from '@alloc/quick-lru'
|
||||
|
||||
import type Load3d from '@/extensions/core/load3d/Load3d'
|
||||
import Load3d from '@/extensions/core/load3d/Load3d'
|
||||
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
|
||||
import { createLoad3d } from '@/extensions/core/load3d/createLoad3d'
|
||||
import type {
|
||||
AnimationItem,
|
||||
BackgroundRenderModeType,
|
||||
@@ -315,7 +314,7 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
|
||||
const hasTargetDimensions = !!(width && height)
|
||||
|
||||
load3d = createLoad3d(containerRef, {
|
||||
load3d = new Load3d(containerRef, {
|
||||
width: width ? (toRaw(width).value as number) : undefined,
|
||||
height: height ? (toRaw(height).value as number) : undefined,
|
||||
getDimensions: hasTargetDimensions
|
||||
@@ -443,7 +442,7 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
|
||||
isStandaloneMode.value = true
|
||||
|
||||
load3d = createLoad3d(containerRef, {
|
||||
load3d = new Load3d(containerRef, {
|
||||
width: 800,
|
||||
height: 600,
|
||||
isViewerMode: true
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { CANVAS_IMAGE_PREVIEW_WIDGET } from '@/composables/node/canvasImagePreviewTypes'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
import type { WidgetItem } from './promotionPolicy'
|
||||
import {
|
||||
getPromotableWidgets,
|
||||
isPreviewPseudoWidget,
|
||||
isRecommendedWidget
|
||||
} from './promotionPolicy'
|
||||
|
||||
function widget(
|
||||
overrides: Partial<
|
||||
Pick<IBaseWidget, 'name' | 'serialize' | 'type' | 'options'>
|
||||
>
|
||||
): IBaseWidget {
|
||||
return fromPartial<IBaseWidget>({ name: 'widget', ...overrides })
|
||||
}
|
||||
|
||||
function widgetItem(
|
||||
nodeType: string,
|
||||
widgetName: string,
|
||||
overrides: Partial<IBaseWidget> = {}
|
||||
): WidgetItem {
|
||||
const node = { title: nodeType, id: 1, type: nodeType }
|
||||
const w = fromPartial<IBaseWidget>({
|
||||
name: widgetName,
|
||||
computedDisabled: false,
|
||||
...overrides
|
||||
})
|
||||
return [node, w]
|
||||
}
|
||||
|
||||
describe('isPreviewPseudoWidget', () => {
|
||||
it('returns true for $$-prefixed widget names', () => {
|
||||
expect(
|
||||
isPreviewPseudoWidget(widget({ name: '$$canvas-image-preview' }))
|
||||
).toBe(true)
|
||||
expect(isPreviewPseudoWidget(widget({ name: '$$anything' }))).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true for serialize:false with type "preview"', () => {
|
||||
expect(
|
||||
isPreviewPseudoWidget(
|
||||
widget({ name: 'videopreview', serialize: false, type: 'preview' })
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true for options.serialize:false with type "preview" (VHS pattern)', () => {
|
||||
expect(
|
||||
isPreviewPseudoWidget(
|
||||
widget({
|
||||
name: 'videopreview',
|
||||
type: 'preview',
|
||||
options: { serialize: false }
|
||||
})
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true for serialize:false with type "video"', () => {
|
||||
expect(
|
||||
isPreviewPseudoWidget(
|
||||
widget({ name: 'vid', serialize: false, type: 'video' })
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true for serialize:false with type "audioUI"', () => {
|
||||
expect(
|
||||
isPreviewPseudoWidget(
|
||||
widget({ name: 'audio', serialize: false, type: 'audioUI' })
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for type "preview" when serialize is not false', () => {
|
||||
expect(
|
||||
isPreviewPseudoWidget(
|
||||
widget({ name: 'videopreview', serialize: true, type: 'preview' })
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for regular widgets', () => {
|
||||
expect(
|
||||
isPreviewPseudoWidget(widget({ name: 'seed', type: 'number' }))
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for serialize:false with unknown type', () => {
|
||||
expect(
|
||||
isPreviewPseudoWidget(
|
||||
widget({ name: 'text', serialize: false, type: 'customtext' })
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getPromotableWidgets', () => {
|
||||
it('adds virtual canvas preview widget for PreviewImage nodes', () => {
|
||||
const node = new LGraphNode('PreviewImage')
|
||||
node.type = 'PreviewImage'
|
||||
|
||||
const widgets = getPromotableWidgets(node)
|
||||
|
||||
expect(
|
||||
widgets.some((widget) => widget.name === CANVAS_IMAGE_PREVIEW_WIDGET)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('adds virtual canvas preview widget for SaveImage nodes', () => {
|
||||
const node = new LGraphNode('SaveImage')
|
||||
node.type = 'SaveImage'
|
||||
|
||||
const widgets = getPromotableWidgets(node)
|
||||
|
||||
expect(
|
||||
widgets.some((widget) => widget.name === CANVAS_IMAGE_PREVIEW_WIDGET)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('adds virtual canvas preview widget for GLSLShader nodes', () => {
|
||||
const node = new LGraphNode('GLSLShader')
|
||||
node.type = 'GLSLShader'
|
||||
|
||||
const widgets = getPromotableWidgets(node)
|
||||
|
||||
expect(
|
||||
widgets.some((widget) => widget.name === CANVAS_IMAGE_PREVIEW_WIDGET)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('does not add virtual canvas preview widget for non-image nodes', () => {
|
||||
const node = new LGraphNode('TextNode')
|
||||
node.addOutput('TEXT', 'STRING')
|
||||
|
||||
const widgets = getPromotableWidgets(node)
|
||||
|
||||
expect(
|
||||
widgets.some((widget) => widget.name === CANVAS_IMAGE_PREVIEW_WIDGET)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('does not add virtual canvas preview widget for ImageInvert nodes', () => {
|
||||
const node = new LGraphNode('ImageInvert')
|
||||
node.type = 'ImageInvert'
|
||||
|
||||
const widgets = getPromotableWidgets(node)
|
||||
|
||||
expect(
|
||||
widgets.some((widget) => widget.name === CANVAS_IMAGE_PREVIEW_WIDGET)
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isRecommendedWidget', () => {
|
||||
it('returns true for widgets on recommended node types', () => {
|
||||
expect(isRecommendedWidget(widgetItem('CLIPTextEncode', 'text'))).toBe(true)
|
||||
expect(isRecommendedWidget(widgetItem('LoadImage', 'image'))).toBe(true)
|
||||
expect(isRecommendedWidget(widgetItem('SaveImage', 'filename'))).toBe(true)
|
||||
expect(isRecommendedWidget(widgetItem('PreviewImage', 'anything'))).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('returns true for seed widgets regardless of node type', () => {
|
||||
expect(isRecommendedWidget(widgetItem('KSampler', 'seed'))).toBe(true)
|
||||
expect(isRecommendedWidget(widgetItem('KSamplerAdvanced', 'seed'))).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('returns false for non-recommended node and widget combinations', () => {
|
||||
expect(isRecommendedWidget(widgetItem('KSampler', 'steps'))).toBe(false)
|
||||
expect(isRecommendedWidget(widgetItem('VAEDecode', 'samples'))).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when widget is computedDisabled', () => {
|
||||
expect(
|
||||
isRecommendedWidget(
|
||||
widgetItem('CLIPTextEncode', 'text', { computedDisabled: true })
|
||||
)
|
||||
).toBe(false)
|
||||
expect(
|
||||
isRecommendedWidget(
|
||||
widgetItem('KSampler', 'seed', { computedDisabled: true })
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,69 +0,0 @@
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets.ts'
|
||||
import {
|
||||
CANVAS_IMAGE_PREVIEW_WIDGET,
|
||||
supportsVirtualCanvasImagePreview
|
||||
} from '@/composables/node/canvasImagePreviewTypes'
|
||||
|
||||
export type PartialNode = Pick<LGraphNode, 'title' | 'id' | 'type'>
|
||||
|
||||
export type WidgetItem = [PartialNode, IBaseWidget]
|
||||
|
||||
/** Known non-$$ preview widget types added by core or popular extensions. */
|
||||
const PREVIEW_WIDGET_TYPES = new Set(['preview', 'video', 'audioUI'])
|
||||
|
||||
/**
|
||||
* Returns true for pseudo-widgets that display media previews and should
|
||||
* be auto-promoted when their node is inside a subgraph.
|
||||
* Matches the core `$$` convention as well as custom-node patterns
|
||||
* (e.g. VHS `videopreview` with type `"preview"`).
|
||||
*/
|
||||
export function isPreviewPseudoWidget(widget: IBaseWidget): boolean {
|
||||
if (widget.name.startsWith('$$')) return true
|
||||
// Custom nodes may set serialize on the widget or in options
|
||||
if (widget.serialize !== false && widget.options?.serialize !== false)
|
||||
return false
|
||||
if (typeof widget.type === 'string' && PREVIEW_WIDGET_TYPES.has(widget.type))
|
||||
return true
|
||||
return false
|
||||
}
|
||||
|
||||
const recommendedNodes = [
|
||||
'CLIPTextEncode',
|
||||
'LoadImage',
|
||||
'SaveImage',
|
||||
'PreviewImage'
|
||||
]
|
||||
const recommendedWidgetNames = ['seed']
|
||||
export function isRecommendedWidget([node, widget]: WidgetItem) {
|
||||
return (
|
||||
!widget.computedDisabled &&
|
||||
(recommendedNodes.includes(node.type) ||
|
||||
recommendedWidgetNames.includes(widget.name))
|
||||
)
|
||||
}
|
||||
|
||||
function createVirtualCanvasImagePreviewWidget(): IBaseWidget {
|
||||
return {
|
||||
name: CANVAS_IMAGE_PREVIEW_WIDGET,
|
||||
type: 'IMAGE_PREVIEW',
|
||||
options: { serialize: false },
|
||||
serialize: false,
|
||||
y: 0,
|
||||
computedDisabled: false
|
||||
}
|
||||
}
|
||||
|
||||
export function getPromotableWidgets(node: LGraphNode): IBaseWidget[] {
|
||||
const widgets = [...(node.widgets ?? [])]
|
||||
|
||||
const hasCanvasPreviewWidget = widgets.some(
|
||||
(widget) => widget.name === CANVAS_IMAGE_PREVIEW_WIDGET
|
||||
)
|
||||
const supportsVirtualPreview = supportsVirtualCanvasImagePreview(node)
|
||||
if (!hasCanvasPreviewWidget && supportsVirtualPreview) {
|
||||
widgets.push(createVirtualCanvasImagePreviewWidget())
|
||||
}
|
||||
|
||||
return widgets
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { CANVAS_IMAGE_PREVIEW_WIDGET } from '@/composables/node/canvasImagePreviewTypes'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
createTestSubgraph,
|
||||
@@ -17,12 +17,95 @@ vi.mock('@/services/litegraphService', () => ({
|
||||
}))
|
||||
|
||||
import {
|
||||
CANVAS_IMAGE_PREVIEW_WIDGET,
|
||||
getPromotableWidgets,
|
||||
hasUnpromotedWidgets,
|
||||
isLinkedPromotion,
|
||||
isPreviewPseudoWidget,
|
||||
promoteRecommendedWidgets,
|
||||
pruneDisconnected
|
||||
} from './promotionUtils'
|
||||
|
||||
function widget(
|
||||
overrides: Partial<
|
||||
Pick<IBaseWidget, 'name' | 'serialize' | 'type' | 'options'>
|
||||
>
|
||||
): IBaseWidget {
|
||||
return fromPartial<IBaseWidget>({ name: 'widget', ...overrides })
|
||||
}
|
||||
|
||||
describe('isPreviewPseudoWidget', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('returns true for $$-prefixed widget names', () => {
|
||||
expect(
|
||||
isPreviewPseudoWidget(widget({ name: '$$canvas-image-preview' }))
|
||||
).toBe(true)
|
||||
expect(isPreviewPseudoWidget(widget({ name: '$$anything' }))).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true for serialize:false with type "preview"', () => {
|
||||
expect(
|
||||
isPreviewPseudoWidget(
|
||||
widget({ name: 'videopreview', serialize: false, type: 'preview' })
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true for options.serialize:false with type "preview" (VHS pattern)', () => {
|
||||
expect(
|
||||
isPreviewPseudoWidget(
|
||||
widget({
|
||||
name: 'videopreview',
|
||||
type: 'preview',
|
||||
options: { serialize: false }
|
||||
})
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true for serialize:false with type "video"', () => {
|
||||
expect(
|
||||
isPreviewPseudoWidget(
|
||||
widget({ name: 'vid', serialize: false, type: 'video' })
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true for serialize:false with type "audioUI"', () => {
|
||||
expect(
|
||||
isPreviewPseudoWidget(
|
||||
widget({ name: 'audio', serialize: false, type: 'audioUI' })
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for type "preview" when serialize is not false', () => {
|
||||
expect(
|
||||
isPreviewPseudoWidget(
|
||||
widget({ name: 'videopreview', serialize: true, type: 'preview' })
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for regular widgets', () => {
|
||||
expect(
|
||||
isPreviewPseudoWidget(widget({ name: 'seed', type: 'number' }))
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for serialize:false with unknown type', () => {
|
||||
expect(
|
||||
isPreviewPseudoWidget(
|
||||
widget({ name: 'text', serialize: false, type: 'customtext' })
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('pruneDisconnected', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
@@ -86,6 +169,63 @@ describe('pruneDisconnected', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('getPromotableWidgets', () => {
|
||||
it('adds virtual canvas preview widget for PreviewImage nodes', () => {
|
||||
const node = new LGraphNode('PreviewImage')
|
||||
node.type = 'PreviewImage'
|
||||
|
||||
const widgets = getPromotableWidgets(node)
|
||||
|
||||
expect(
|
||||
widgets.some((widget) => widget.name === CANVAS_IMAGE_PREVIEW_WIDGET)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('adds virtual canvas preview widget for SaveImage nodes', () => {
|
||||
const node = new LGraphNode('SaveImage')
|
||||
node.type = 'SaveImage'
|
||||
|
||||
const widgets = getPromotableWidgets(node)
|
||||
|
||||
expect(
|
||||
widgets.some((widget) => widget.name === CANVAS_IMAGE_PREVIEW_WIDGET)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('adds virtual canvas preview widget for GLSLShader nodes', () => {
|
||||
const node = new LGraphNode('GLSLShader')
|
||||
node.type = 'GLSLShader'
|
||||
|
||||
const widgets = getPromotableWidgets(node)
|
||||
|
||||
expect(
|
||||
widgets.some((widget) => widget.name === CANVAS_IMAGE_PREVIEW_WIDGET)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('does not add virtual canvas preview widget for non-image nodes', () => {
|
||||
const node = new LGraphNode('TextNode')
|
||||
node.addOutput('TEXT', 'STRING')
|
||||
|
||||
const widgets = getPromotableWidgets(node)
|
||||
|
||||
expect(
|
||||
widgets.some((widget) => widget.name === CANVAS_IMAGE_PREVIEW_WIDGET)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('does not add virtual canvas preview widget for ImageInvert nodes', () => {
|
||||
const node = new LGraphNode('ImageInvert')
|
||||
node.type = 'ImageInvert'
|
||||
|
||||
const widgets = getPromotableWidgets(node)
|
||||
|
||||
expect(
|
||||
widgets.some((widget) => widget.name === CANVAS_IMAGE_PREVIEW_WIDGET)
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('promoteRecommendedWidgets', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
import * as Sentry from '@sentry/vue'
|
||||
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import type {
|
||||
PartialNode,
|
||||
WidgetItem
|
||||
} from '@/core/graph/subgraph/promotionPolicy'
|
||||
import {
|
||||
getPromotableWidgets,
|
||||
isPreviewPseudoWidget,
|
||||
isRecommendedWidget
|
||||
} from '@/core/graph/subgraph/promotionPolicy'
|
||||
import { t } from '@/i18n'
|
||||
import type {
|
||||
IContextMenuValue,
|
||||
@@ -27,6 +18,11 @@ import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
|
||||
|
||||
type PartialNode = Pick<LGraphNode, 'title' | 'id' | 'type'>
|
||||
|
||||
export type WidgetItem = [PartialNode, IBaseWidget]
|
||||
export { CANVAS_IMAGE_PREVIEW_WIDGET }
|
||||
|
||||
export function getWidgetName(w: IBaseWidget): string {
|
||||
return isPromotedWidgetView(w) ? w.sourceWidgetName : w.name
|
||||
}
|
||||
@@ -78,6 +74,25 @@ function refreshPromotedWidgetRendering(parents: SubgraphNode[]): void {
|
||||
useCanvasStore().canvas?.setDirty(true, true)
|
||||
}
|
||||
|
||||
/** Known non-$$ preview widget types added by core or popular extensions. */
|
||||
const PREVIEW_WIDGET_TYPES = new Set(['preview', 'video', 'audioUI'])
|
||||
|
||||
/**
|
||||
* Returns true for pseudo-widgets that display media previews and should
|
||||
* be auto-promoted when their node is inside a subgraph.
|
||||
* Matches the core `$$` convention as well as custom-node patterns
|
||||
* (e.g. VHS `videopreview` with type `"preview"`).
|
||||
*/
|
||||
export function isPreviewPseudoWidget(widget: IBaseWidget): boolean {
|
||||
if (widget.name.startsWith('$$')) return true
|
||||
// Custom nodes may set serialize on the widget or in options
|
||||
if (widget.serialize !== false && widget.options?.serialize !== false)
|
||||
return false
|
||||
if (typeof widget.type === 'string' && PREVIEW_WIDGET_TYPES.has(widget.type))
|
||||
return true
|
||||
return false
|
||||
}
|
||||
|
||||
export function promoteWidget(
|
||||
node: PartialNode,
|
||||
widget: IBaseWidget,
|
||||
@@ -184,6 +199,50 @@ export function tryToggleWidgetPromotion() {
|
||||
else demoteWidget(node, widget, parents)
|
||||
}
|
||||
|
||||
const recommendedNodes = [
|
||||
'CLIPTextEncode',
|
||||
'LoadImage',
|
||||
'SaveImage',
|
||||
'PreviewImage'
|
||||
]
|
||||
const recommendedWidgetNames = ['seed']
|
||||
export function isRecommendedWidget([node, widget]: WidgetItem) {
|
||||
return (
|
||||
!widget.computedDisabled &&
|
||||
(recommendedNodes.includes(node.type) ||
|
||||
recommendedWidgetNames.includes(widget.name))
|
||||
)
|
||||
}
|
||||
|
||||
function supportsVirtualPreviewWidget(node: LGraphNode): boolean {
|
||||
return supportsVirtualCanvasImagePreview(node)
|
||||
}
|
||||
|
||||
function createVirtualCanvasImagePreviewWidget(): IBaseWidget {
|
||||
return {
|
||||
name: CANVAS_IMAGE_PREVIEW_WIDGET,
|
||||
type: 'IMAGE_PREVIEW',
|
||||
options: { serialize: false },
|
||||
serialize: false,
|
||||
y: 0,
|
||||
computedDisabled: false
|
||||
}
|
||||
}
|
||||
|
||||
export function getPromotableWidgets(node: LGraphNode): IBaseWidget[] {
|
||||
const widgets = [...(node.widgets ?? [])]
|
||||
|
||||
const hasCanvasPreviewWidget = widgets.some(
|
||||
(widget) => widget.name === CANVAS_IMAGE_PREVIEW_WIDGET
|
||||
)
|
||||
const supportsVirtualPreview = supportsVirtualPreviewWidget(node)
|
||||
if (!hasCanvasPreviewWidget && supportsVirtualPreview) {
|
||||
widgets.push(createVirtualCanvasImagePreviewWidget())
|
||||
}
|
||||
|
||||
return widgets
|
||||
}
|
||||
|
||||
function nodeWidgets(n: LGraphNode): WidgetItem[] {
|
||||
return getPromotableWidgets(n).map((w: IBaseWidget) => [n, w])
|
||||
}
|
||||
|
||||
@@ -19,9 +19,8 @@ import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import type { NodeOutputWith } from '@/schemas/apiSchema'
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
|
||||
type Matrix = number[][]
|
||||
type Load3dPreviewOutput = NodeOutputWith<{
|
||||
result?: [string?, CameraState?, string?, Matrix?, Matrix?]
|
||||
result?: [string?, CameraState?, string?]
|
||||
}>
|
||||
import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { api } from '@/scripts/api'
|
||||
@@ -517,8 +516,6 @@ useExtensionService().registerExtension({
|
||||
|
||||
const cameraState = result?.[1]
|
||||
const bgImagePath = result?.[2]
|
||||
const extrinsics = result?.[3]
|
||||
const intrinsics = result?.[4]
|
||||
|
||||
modelWidget.value = filePath?.replaceAll('\\', '/')
|
||||
|
||||
@@ -536,27 +533,6 @@ useExtensionService().registerExtension({
|
||||
if (bgImagePath) {
|
||||
load3d.setBackgroundImage(bgImagePath)
|
||||
}
|
||||
|
||||
if (filePath && extrinsics && intrinsics) {
|
||||
// configure(settings) above triggered loadModel for this
|
||||
// execution; capture its generation so that if a newer
|
||||
// execution queues another load before whenLoadIdle resolves,
|
||||
// we don't apply this execution's matrices on top of that
|
||||
// newer model.
|
||||
const targetGeneration = load3d.currentLoadGeneration
|
||||
void load3d
|
||||
.whenLoadIdle()
|
||||
.then(() => {
|
||||
if (load3d.currentLoadGeneration !== targetGeneration) return
|
||||
load3d.setCameraFromMatrices(extrinsics, intrinsics)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
'Failed to apply camera matrices from Preview3D output:',
|
||||
error
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -37,6 +37,11 @@ type SceneManagerStub = {
|
||||
dispose: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
type Load3dPrivate = {
|
||||
setGizmo(model: THREE.Object3D): void
|
||||
setupCamera(size: THREE.Vector3, center: THREE.Vector3): void
|
||||
}
|
||||
|
||||
function makeGizmoStub(): GizmoStub {
|
||||
return {
|
||||
setEnabled: vi.fn(),
|
||||
@@ -92,7 +97,6 @@ function makeInstance() {
|
||||
controlsManager,
|
||||
viewHelperManager,
|
||||
animationManager,
|
||||
adapterRef: { current: null },
|
||||
forceRender: vi.fn(),
|
||||
handleResize: vi.fn()
|
||||
})
|
||||
@@ -204,29 +208,6 @@ describe('Load3d', () => {
|
||||
expect(ctx.forceRender).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('clearModel nulls adapterRef.current so capability queries fall back to defaults', () => {
|
||||
Object.assign(ctx.load3d, {
|
||||
adapterRef: { current: { kind: 'splat' } }
|
||||
})
|
||||
let adapterDuringModelManagerClear:
|
||||
| { kind: string; current?: unknown }
|
||||
| null
|
||||
| undefined
|
||||
ctx.modelManager.clearModel.mockImplementation(() => {
|
||||
adapterDuringModelManagerClear = (
|
||||
ctx.load3d as unknown as { adapterRef: { current: unknown } }
|
||||
).adapterRef.current as { kind: string } | null
|
||||
})
|
||||
|
||||
ctx.load3d.clearModel()
|
||||
|
||||
expect(adapterDuringModelManagerClear).toEqual({ kind: 'splat' })
|
||||
expect(
|
||||
(ctx.load3d as unknown as { adapterRef: { current: unknown } })
|
||||
.adapterRef.current
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('toggleCamera updates both controls and gizmo with the active camera', () => {
|
||||
ctx.load3d.toggleCamera('orthographic')
|
||||
|
||||
@@ -241,6 +222,23 @@ describe('Load3d', () => {
|
||||
)
|
||||
expect(ctx.viewHelperManager.recreateViewHelper).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('setGizmo (private) forwards the model to gizmoManager.setupForModel', () => {
|
||||
const model = new THREE.Object3D()
|
||||
|
||||
;(ctx.load3d as unknown as Load3dPrivate).setGizmo(model)
|
||||
|
||||
expect(ctx.gizmo.setupForModel).toHaveBeenCalledWith(model)
|
||||
})
|
||||
|
||||
it('setupCamera (private) forwards size and center to cameraManager', () => {
|
||||
const size = new THREE.Vector3(1, 2, 3)
|
||||
const center = new THREE.Vector3(4, 5, 6)
|
||||
|
||||
;(ctx.load3d as unknown as Load3dPrivate).setupCamera(size, center)
|
||||
|
||||
expect(ctx.cameraManager.setupForModel).toHaveBeenCalledWith(size, center)
|
||||
})
|
||||
})
|
||||
|
||||
describe('viewport wiring', () => {
|
||||
@@ -475,7 +473,7 @@ describe('Load3d', () => {
|
||||
function makeWithAdapter(kind: 'mesh' | 'pointCloud' | 'splat' | null) {
|
||||
const adapter = kind === null ? null : { kind }
|
||||
Object.assign(ctx.load3d, {
|
||||
adapterRef: { current: adapter }
|
||||
loaderManager: { getCurrentAdapter: vi.fn(() => adapter) }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -496,185 +494,6 @@ describe('Load3d', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('setCameraFromMatrices', () => {
|
||||
it('derives the camera pose from extrinsics+intrinsics and applies it via setCameraState + setFOV', () => {
|
||||
const setCameraState = vi.fn()
|
||||
const setFOVImpl = vi.fn()
|
||||
const getCameraState = vi.fn(() => ({
|
||||
position: new THREE.Vector3(0, 0, 0),
|
||||
target: new THREE.Vector3(0, 0, 0),
|
||||
zoom: 1.5,
|
||||
cameraType: 'orthographic' as const
|
||||
}))
|
||||
|
||||
Object.assign(ctx.load3d, {
|
||||
setCameraState,
|
||||
setFOV: setFOVImpl,
|
||||
cameraManager: { ...ctx.cameraManager, getCameraState }
|
||||
})
|
||||
|
||||
// Identity rotation, zero translation, fy=cy=1 → fovY = 2*atan(1) = 90°.
|
||||
// OpenCV → three.js flips Y/Z, so position (0,0,0) stays at origin
|
||||
// and forward (0,0,1) → target (0,0,-1).
|
||||
const extrinsics = [
|
||||
[1, 0, 0, 0],
|
||||
[0, 1, 0, 0],
|
||||
[0, 0, 1, 0],
|
||||
[0, 0, 0, 1]
|
||||
]
|
||||
const intrinsics = [
|
||||
[1, 0, 0],
|
||||
[0, 1, 1],
|
||||
[0, 0, 1]
|
||||
]
|
||||
|
||||
ctx.load3d.setCameraFromMatrices(extrinsics, intrinsics)
|
||||
|
||||
expect(setCameraState).toHaveBeenCalledOnce()
|
||||
const stateArg = setCameraState.mock.calls[0][0] as {
|
||||
position: THREE.Vector3
|
||||
target: THREE.Vector3
|
||||
zoom: number
|
||||
cameraType: string
|
||||
}
|
||||
expect(stateArg.position.x).toBeCloseTo(0)
|
||||
expect(stateArg.position.y).toBeCloseTo(0)
|
||||
expect(stateArg.position.z).toBeCloseTo(0)
|
||||
expect(stateArg.target.x).toBeCloseTo(0)
|
||||
expect(stateArg.target.y).toBeCloseTo(0)
|
||||
expect(stateArg.target.z).toBeCloseTo(-1)
|
||||
// Zoom and cameraType must be preserved from the current state.
|
||||
expect(stateArg.zoom).toBe(1.5)
|
||||
expect(stateArg.cameraType).toBe('orthographic')
|
||||
|
||||
expect(setFOVImpl).toHaveBeenCalledOnce()
|
||||
expect(setFOVImpl.mock.calls[0][0]).toBeCloseTo(90)
|
||||
})
|
||||
})
|
||||
|
||||
describe('whenLoadIdle', () => {
|
||||
it('resolves immediately when no load is in flight', async () => {
|
||||
Object.assign(ctx.load3d, { loadingPromise: null })
|
||||
await expect(ctx.load3d.whenLoadIdle()).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('waits for the current loadingPromise to settle', async () => {
|
||||
let resolveLoad!: () => void
|
||||
const p = new Promise<void>((resolve) => {
|
||||
resolveLoad = resolve
|
||||
})
|
||||
Object.assign(ctx.load3d, { loadingPromise: p })
|
||||
|
||||
const idle = ctx.load3d.whenLoadIdle()
|
||||
let settled = false
|
||||
void idle.then(() => {
|
||||
settled = true
|
||||
})
|
||||
|
||||
await Promise.resolve()
|
||||
expect(settled).toBe(false)
|
||||
|
||||
resolveLoad()
|
||||
|
||||
Object.assign(ctx.load3d, { loadingPromise: null })
|
||||
await idle
|
||||
expect(settled).toBe(true)
|
||||
})
|
||||
|
||||
it('drains a chained sequence of loads before resolving', async () => {
|
||||
let resolveFirst!: () => void
|
||||
const first = new Promise<void>((resolve) => {
|
||||
resolveFirst = resolve
|
||||
})
|
||||
let resolveSecond!: () => void
|
||||
const second = new Promise<void>((resolve) => {
|
||||
resolveSecond = resolve
|
||||
})
|
||||
|
||||
Object.assign(ctx.load3d, { loadingPromise: first })
|
||||
void first.then(() => {
|
||||
Object.assign(ctx.load3d, { loadingPromise: second })
|
||||
})
|
||||
|
||||
const idle = ctx.load3d.whenLoadIdle()
|
||||
let settled = false
|
||||
void idle.then(() => {
|
||||
settled = true
|
||||
})
|
||||
|
||||
resolveFirst()
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
expect(settled).toBe(false)
|
||||
|
||||
resolveSecond()
|
||||
Object.assign(ctx.load3d, { loadingPromise: null })
|
||||
await idle
|
||||
expect(settled).toBe(true)
|
||||
})
|
||||
|
||||
it('swallows a rejected loadingPromise and continues draining', async () => {
|
||||
const failing = Promise.reject(new Error('boom'))
|
||||
failing.catch(() => {})
|
||||
Object.assign(ctx.load3d, { loadingPromise: failing })
|
||||
|
||||
const idle = ctx.load3d.whenLoadIdle()
|
||||
Object.assign(ctx.load3d, { loadingPromise: null })
|
||||
|
||||
await expect(idle).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('currentLoadGeneration', () => {
|
||||
it('starts at 0', () => {
|
||||
const fresh = Object.create(Load3d.prototype) as Load3d
|
||||
Object.assign(fresh, {
|
||||
_loadGeneration: 0
|
||||
})
|
||||
expect(fresh.currentLoadGeneration).toBe(0)
|
||||
})
|
||||
|
||||
it('ticks synchronously on every loadModel call, before any await', async () => {
|
||||
const internal = vi.fn().mockResolvedValue(undefined)
|
||||
Object.assign(ctx.load3d, {
|
||||
_loadGeneration: 0,
|
||||
loadingPromise: null,
|
||||
_loadModelInternal: internal
|
||||
})
|
||||
|
||||
const baseline = ctx.load3d.currentLoadGeneration
|
||||
|
||||
const p1 = ctx.load3d.loadModel('api/view?filename=a.glb')
|
||||
expect(ctx.load3d.currentLoadGeneration).toBe(baseline + 1)
|
||||
const p2 = ctx.load3d.loadModel('api/view?filename=b.glb')
|
||||
expect(ctx.load3d.currentLoadGeneration).toBe(baseline + 2)
|
||||
|
||||
await Promise.all([p1, p2])
|
||||
})
|
||||
|
||||
it('lets a chained whenLoadIdle continuation skip when a newer load was queued in between', async () => {
|
||||
const internal = vi.fn().mockResolvedValue(undefined)
|
||||
Object.assign(ctx.load3d, {
|
||||
_loadGeneration: 0,
|
||||
loadingPromise: null,
|
||||
_loadModelInternal: internal
|
||||
})
|
||||
|
||||
const aGeneration = ctx.load3d.currentLoadGeneration
|
||||
const aPromise = ctx.load3d.loadModel('api/view?filename=a.glb')
|
||||
const aTarget = ctx.load3d.currentLoadGeneration
|
||||
expect(aTarget).toBe(aGeneration + 1)
|
||||
|
||||
const bPromise = ctx.load3d.loadModel('api/view?filename=b.glb')
|
||||
expect(ctx.load3d.currentLoadGeneration).toBe(aGeneration + 2)
|
||||
|
||||
await Promise.all([aPromise, bPromise])
|
||||
|
||||
const apply = vi.fn()
|
||||
if (ctx.load3d.currentLoadGeneration === aTarget) apply()
|
||||
expect(apply).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('captureScene', () => {
|
||||
it('hides the gizmo helper during capture and restores it after success', async () => {
|
||||
const captureResult = { scene: 'a', mask: 'b', normal: 'c' }
|
||||
|
||||
@@ -1,21 +1,18 @@
|
||||
import * as THREE from 'three'
|
||||
|
||||
import type { AnimationManager } from './AnimationManager'
|
||||
import type { CameraManager } from './CameraManager'
|
||||
import type { ControlsManager } from './ControlsManager'
|
||||
import type { EventManager } from './EventManager'
|
||||
import type { GizmoManager } from './GizmoManager'
|
||||
import type { HDRIManager } from './HDRIManager'
|
||||
import type { LightingManager } from './LightingManager'
|
||||
import type { LoaderManager } from './LoaderManager'
|
||||
import { AnimationManager } from './AnimationManager'
|
||||
import { CameraManager } from './CameraManager'
|
||||
import { ControlsManager } from './ControlsManager'
|
||||
import { EventManager } from './EventManager'
|
||||
import { HDRIManager } from './HDRIManager'
|
||||
import { GizmoManager } from './GizmoManager'
|
||||
import { LightingManager } from './LightingManager'
|
||||
import { LoaderManager } from './LoaderManager'
|
||||
import { ModelExporter } from './ModelExporter'
|
||||
import { DEFAULT_MODEL_CAPABILITIES } from './ModelAdapter'
|
||||
import type { AdapterRef, ModelAdapterCapabilities } from './ModelAdapter'
|
||||
import type { RecordingManager } from './RecordingManager'
|
||||
import type { SceneManager } from './SceneManager'
|
||||
import type { SceneModelManager } from './SceneModelManager'
|
||||
import type { ViewHelperManager } from './ViewHelperManager'
|
||||
import { computeCameraFromMatrices } from './cameraFromMatrices'
|
||||
import { RecordingManager } from './RecordingManager'
|
||||
import { SceneManager } from './SceneManager'
|
||||
import { SceneModelManager } from './SceneModelManager'
|
||||
import { ViewHelperManager } from './ViewHelperManager'
|
||||
import type {
|
||||
CameraState,
|
||||
CaptureResult,
|
||||
@@ -30,23 +27,6 @@ import type { RenderLoopHandle } from './load3dRenderLoop'
|
||||
import { startRenderLoop } from './load3dRenderLoop'
|
||||
import { computeLetterboxedViewport, isLoad3dActive } from './load3dViewport'
|
||||
|
||||
export type Load3dDeps = {
|
||||
renderer: THREE.WebGLRenderer
|
||||
eventManager: EventManager
|
||||
sceneManager: SceneManager
|
||||
cameraManager: CameraManager
|
||||
controlsManager: ControlsManager
|
||||
lightingManager: LightingManager
|
||||
hdriManager: HDRIManager
|
||||
viewHelperManager: ViewHelperManager
|
||||
loaderManager: LoaderManager
|
||||
modelManager: SceneModelManager
|
||||
recordingManager: RecordingManager
|
||||
animationManager: AnimationManager
|
||||
gizmoManager: GizmoManager
|
||||
adapterRef: AdapterRef
|
||||
}
|
||||
|
||||
function positionThumbnailCamera(
|
||||
camera: THREE.PerspectiveCamera,
|
||||
model: THREE.Object3D
|
||||
@@ -71,7 +51,6 @@ class Load3d {
|
||||
protected clock: THREE.Clock
|
||||
private renderLoop: RenderLoopHandle | null = null
|
||||
private loadingPromise: Promise<void> | null = null
|
||||
private _loadGeneration: number = 0
|
||||
private onContextMenuCallback?: (event: MouseEvent) => void
|
||||
private getDimensionsCallback?: () => { width: number; height: number } | null
|
||||
|
||||
@@ -87,7 +66,6 @@ class Load3d {
|
||||
recordingManager: RecordingManager
|
||||
animationManager: AnimationManager
|
||||
gizmoManager: GizmoManager
|
||||
adapterRef: AdapterRef
|
||||
|
||||
STATUS_MOUSE_ON_NODE: boolean
|
||||
STATUS_MOUSE_ON_SCENE: boolean
|
||||
@@ -102,11 +80,7 @@ class Load3d {
|
||||
private disposeContextMenuGuard: (() => void) | null = null
|
||||
private resizeObserver: ResizeObserver | null = null
|
||||
|
||||
constructor(
|
||||
container: Element | HTMLElement,
|
||||
deps: Load3dDeps,
|
||||
options: Load3DOptions = {}
|
||||
) {
|
||||
constructor(container: Element | HTMLElement, options: Load3DOptions = {}) {
|
||||
this.clock = new THREE.Clock()
|
||||
this.isViewerMode = options.isViewerMode || false
|
||||
this.onContextMenuCallback = options.onContextMenu
|
||||
@@ -118,20 +92,90 @@ class Load3d {
|
||||
this.targetAspectRatio = options.width / options.height
|
||||
}
|
||||
|
||||
this.renderer = deps.renderer
|
||||
this.eventManager = deps.eventManager
|
||||
this.sceneManager = deps.sceneManager
|
||||
this.cameraManager = deps.cameraManager
|
||||
this.controlsManager = deps.controlsManager
|
||||
this.lightingManager = deps.lightingManager
|
||||
this.hdriManager = deps.hdriManager
|
||||
this.viewHelperManager = deps.viewHelperManager
|
||||
this.loaderManager = deps.loaderManager
|
||||
this.modelManager = deps.modelManager
|
||||
this.recordingManager = deps.recordingManager
|
||||
this.animationManager = deps.animationManager
|
||||
this.gizmoManager = deps.gizmoManager
|
||||
this.adapterRef = deps.adapterRef
|
||||
this.renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true })
|
||||
this.renderer.setSize(300, 300)
|
||||
this.renderer.setClearColor(0x282828)
|
||||
this.renderer.autoClear = false
|
||||
this.renderer.outputColorSpace = THREE.SRGBColorSpace
|
||||
this.renderer.domElement.classList.add(
|
||||
'absolute',
|
||||
'inset-0',
|
||||
'h-full',
|
||||
'w-full',
|
||||
'outline-none'
|
||||
)
|
||||
container.appendChild(this.renderer.domElement)
|
||||
|
||||
this.eventManager = new EventManager()
|
||||
|
||||
this.sceneManager = new SceneManager(
|
||||
this.renderer,
|
||||
this.getActiveCamera.bind(this),
|
||||
this.getControls.bind(this),
|
||||
this.eventManager
|
||||
)
|
||||
|
||||
this.cameraManager = new CameraManager(this.renderer, this.eventManager)
|
||||
|
||||
this.controlsManager = new ControlsManager(
|
||||
this.renderer,
|
||||
this.cameraManager.activeCamera,
|
||||
this.eventManager
|
||||
)
|
||||
|
||||
this.cameraManager.setControls(this.controlsManager.controls)
|
||||
|
||||
this.lightingManager = new LightingManager(
|
||||
this.sceneManager.scene,
|
||||
this.eventManager
|
||||
)
|
||||
|
||||
this.hdriManager = new HDRIManager(
|
||||
this.sceneManager.scene,
|
||||
this.renderer,
|
||||
this.eventManager
|
||||
)
|
||||
|
||||
this.viewHelperManager = new ViewHelperManager(
|
||||
this.renderer,
|
||||
this.getActiveCamera.bind(this),
|
||||
this.getControls.bind(this),
|
||||
this.eventManager
|
||||
)
|
||||
|
||||
this.modelManager = new SceneModelManager(
|
||||
this.sceneManager.scene,
|
||||
this.renderer,
|
||||
this.eventManager,
|
||||
this.getActiveCamera.bind(this),
|
||||
this.setupCamera.bind(this),
|
||||
this.setGizmo.bind(this)
|
||||
)
|
||||
|
||||
this.loaderManager = new LoaderManager(this.modelManager, this.eventManager)
|
||||
|
||||
this.recordingManager = new RecordingManager(
|
||||
this.sceneManager.scene,
|
||||
this.renderer,
|
||||
this.eventManager
|
||||
)
|
||||
|
||||
this.animationManager = new AnimationManager(this.eventManager)
|
||||
|
||||
this.gizmoManager = new GizmoManager(
|
||||
this.sceneManager.scene,
|
||||
this.renderer,
|
||||
this.controlsManager.controls,
|
||||
this.getActiveCamera.bind(this),
|
||||
() => {
|
||||
const transform = this.gizmoManager.getTransform()
|
||||
this.eventManager.emitEvent('gizmoTransformChange', {
|
||||
...transform,
|
||||
enabled: this.gizmoManager.isEnabled(),
|
||||
mode: this.gizmoManager.getMode()
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
this.sceneManager.init()
|
||||
this.cameraManager.init()
|
||||
@@ -290,6 +334,22 @@ class Load3d {
|
||||
this.renderer.setScissorTest(false)
|
||||
}
|
||||
|
||||
private getActiveCamera(): THREE.Camera {
|
||||
return this.cameraManager.activeCamera
|
||||
}
|
||||
|
||||
private getControls() {
|
||||
return this.controlsManager.controls
|
||||
}
|
||||
|
||||
private setGizmo(model: THREE.Object3D): void {
|
||||
this.gizmoManager.setupForModel(model)
|
||||
}
|
||||
|
||||
private setupCamera(size: THREE.Vector3, center: THREE.Vector3): void {
|
||||
this.cameraManager.setupForModel(size, center)
|
||||
}
|
||||
|
||||
private startAnimation(): void {
|
||||
this.renderLoop = startRenderLoop({
|
||||
tick: () => {
|
||||
@@ -465,44 +525,12 @@ class Load3d {
|
||||
this.forceRender()
|
||||
}
|
||||
|
||||
setCameraFromMatrices(
|
||||
extrinsics: readonly (readonly number[])[],
|
||||
intrinsics: readonly (readonly number[])[]
|
||||
): void {
|
||||
const { position, target, fovYDegrees } = computeCameraFromMatrices(
|
||||
extrinsics,
|
||||
intrinsics
|
||||
)
|
||||
const current = this.cameraManager.getCameraState()
|
||||
this.setCameraState({
|
||||
position: new THREE.Vector3(position[0], position[1], position[2]),
|
||||
target: new THREE.Vector3(target[0], target[1], target[2]),
|
||||
zoom: current.zoom,
|
||||
cameraType: current.cameraType
|
||||
})
|
||||
this.setFOV(fovYDegrees)
|
||||
}
|
||||
|
||||
setMaterialMode(mode: MaterialMode): void {
|
||||
this.modelManager.setMaterialMode(mode)
|
||||
this.forceRender()
|
||||
}
|
||||
|
||||
/**
|
||||
* Monotonic counter that ticks once per loadModel call, **before** any
|
||||
* await. Callers can capture this immediately after triggering a load and
|
||||
* later compare against `currentLoadGeneration` to verify their load is
|
||||
* still the latest one — useful when chaining post-load work
|
||||
* (e.g. applying camera matrices) through `whenLoadIdle()`, which would
|
||||
* otherwise wait for any newer queued load and apply stale state to it.
|
||||
*/
|
||||
get currentLoadGeneration(): number {
|
||||
return this._loadGeneration
|
||||
}
|
||||
|
||||
async loadModel(url: string, originalFileName?: string): Promise<void> {
|
||||
this._loadGeneration += 1
|
||||
|
||||
if (this.loadingPromise) {
|
||||
try {
|
||||
await this.loadingPromise
|
||||
@@ -513,16 +541,6 @@ class Load3d {
|
||||
return this.loadingPromise
|
||||
}
|
||||
|
||||
async whenLoadIdle(): Promise<void> {
|
||||
let last: Promise<void> | null = null
|
||||
while (this.loadingPromise && this.loadingPromise !== last) {
|
||||
last = this.loadingPromise
|
||||
try {
|
||||
await last
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
|
||||
private async _loadModelInternal(
|
||||
url: string,
|
||||
originalFileName?: string
|
||||
@@ -549,22 +567,17 @@ class Load3d {
|
||||
}
|
||||
|
||||
isSplatModel(): boolean {
|
||||
return this.adapterRef.current?.kind === 'splat'
|
||||
return this.loaderManager.getCurrentAdapter()?.kind === 'splat'
|
||||
}
|
||||
|
||||
isPlyModel(): boolean {
|
||||
return this.adapterRef.current?.kind === 'pointCloud'
|
||||
}
|
||||
|
||||
getCurrentModelCapabilities(): ModelAdapterCapabilities {
|
||||
return this.adapterRef.current?.capabilities ?? DEFAULT_MODEL_CAPABILITIES
|
||||
return this.loaderManager.getCurrentAdapter()?.kind === 'pointCloud'
|
||||
}
|
||||
|
||||
clearModel(): void {
|
||||
this.animationManager.dispose()
|
||||
this.gizmoManager.detach()
|
||||
this.modelManager.clearModel()
|
||||
this.adapterRef.current = null
|
||||
this.forceRender()
|
||||
}
|
||||
|
||||
@@ -799,19 +812,16 @@ class Load3d {
|
||||
}
|
||||
|
||||
public setGizmoEnabled(enabled: boolean): void {
|
||||
if (enabled && !this.getCurrentModelCapabilities().gizmoTransform) return
|
||||
this.gizmoManager.setEnabled(enabled)
|
||||
this.forceRender()
|
||||
}
|
||||
|
||||
public setGizmoMode(mode: GizmoMode): void {
|
||||
if (!this.getCurrentModelCapabilities().gizmoTransform) return
|
||||
this.gizmoManager.setMode(mode)
|
||||
this.forceRender()
|
||||
}
|
||||
|
||||
public resetGizmoTransform(): void {
|
||||
if (!this.getCurrentModelCapabilities().gizmoTransform) return
|
||||
this.gizmoManager.reset()
|
||||
this.forceRender()
|
||||
}
|
||||
@@ -821,7 +831,6 @@ class Load3d {
|
||||
rotation: { x: number; y: number; z: number },
|
||||
scale?: { x: number; y: number; z: number }
|
||||
): void {
|
||||
if (!this.getCurrentModelCapabilities().gizmoTransform) return
|
||||
this.gizmoManager.applyTransform(position, rotation, scale)
|
||||
this.forceRender()
|
||||
}
|
||||
@@ -867,7 +876,6 @@ class Load3d {
|
||||
this.viewHelperManager.dispose()
|
||||
this.loaderManager.dispose()
|
||||
this.modelManager.dispose()
|
||||
this.adapterRef.current = null
|
||||
this.recordingManager.dispose()
|
||||
this.animationManager.dispose()
|
||||
this.gizmoManager.dispose()
|
||||
|
||||
@@ -141,9 +141,10 @@ describe('LoaderManager', () => {
|
||||
expect(lm.getCurrentAdapter()).toBeNull()
|
||||
})
|
||||
|
||||
it('stays null when the adapter rejects (does not publish stale adapter)', async () => {
|
||||
it('stays null when the adapter rejects', async () => {
|
||||
const { lm } = makeLoaderManager()
|
||||
|
||||
// Seed with a previously-successful mesh load so we can prove a later
|
||||
// failed splat load does not leave the splat adapter published.
|
||||
meshLoad.mockResolvedValueOnce(new THREE.Object3D())
|
||||
await lm.loadModel('api/view?filename=cube.glb')
|
||||
expect(lm.getCurrentAdapter()?.kind).toBe('mesh')
|
||||
@@ -195,13 +196,10 @@ describe('LoaderManager', () => {
|
||||
}
|
||||
|
||||
let adapterDuringClear: ModelAdapter | null | undefined
|
||||
const adapterRef = { current: oldAdapter as ModelAdapter | null }
|
||||
const lm = new LoaderManager(
|
||||
modelManager,
|
||||
eventManager,
|
||||
[oldAdapter],
|
||||
adapterRef
|
||||
)
|
||||
const lm = new LoaderManager(modelManager, eventManager, [oldAdapter])
|
||||
// Prime the loader with an active adapter, then trigger a new load.
|
||||
;(lm as unknown as { _currentAdapter: ModelAdapter })._currentAdapter =
|
||||
oldAdapter
|
||||
;(modelManager.clearModel as ReturnType<typeof vi.fn>).mockImplementation(
|
||||
() => {
|
||||
adapterDuringClear = lm.getCurrentAdapter()
|
||||
@@ -215,29 +213,6 @@ describe('LoaderManager', () => {
|
||||
|
||||
expect(adapterDuringClear).toBe(oldAdapter)
|
||||
})
|
||||
|
||||
it('does not let a slow stale load clobber adapterRef after a newer load took over', async () => {
|
||||
const { lm } = makeLoaderManager()
|
||||
|
||||
let resolveSplatLoad!: (model: THREE.Object3D) => void
|
||||
const slowSplatLoad = new Promise<THREE.Object3D>((resolve) => {
|
||||
resolveSplatLoad = resolve
|
||||
})
|
||||
splatLoad.mockReturnValueOnce(slowSplatLoad)
|
||||
meshLoad.mockResolvedValueOnce(new THREE.Object3D())
|
||||
|
||||
const aPromise = lm.loadModel('api/view?filename=a.splat')
|
||||
|
||||
await Promise.resolve()
|
||||
|
||||
await lm.loadModel('api/view?filename=b.glb')
|
||||
expect(lm.getCurrentAdapter()?.kind).toBe('mesh')
|
||||
|
||||
resolveSplatLoad(new THREE.Object3D())
|
||||
await aPromise
|
||||
|
||||
expect(lm.getCurrentAdapter()?.kind).toBe('mesh')
|
||||
})
|
||||
})
|
||||
|
||||
describe('pickAdapter', () => {
|
||||
|
||||
@@ -4,14 +4,13 @@ import { t } from '@/i18n'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
|
||||
import { MeshModelAdapter } from './MeshModelAdapter'
|
||||
import { createAdapterRef } from './ModelAdapter'
|
||||
import type { AdapterRef, ModelAdapter, ModelLoadContext } from './ModelAdapter'
|
||||
import type { ModelAdapter, ModelLoadContext } from './ModelAdapter'
|
||||
import { PointCloudModelAdapter, getPLYEngine } from './PointCloudModelAdapter'
|
||||
import { SplatModelAdapter } from './SplatModelAdapter'
|
||||
import type {
|
||||
EventManagerInterface,
|
||||
LoaderManagerInterface,
|
||||
ModelManagerInterface
|
||||
import {
|
||||
type EventManagerInterface,
|
||||
type LoaderManagerInterface,
|
||||
type ModelManagerInterface
|
||||
} from './interfaces'
|
||||
|
||||
/**
|
||||
@@ -30,23 +29,21 @@ export class LoaderManager implements LoaderManagerInterface {
|
||||
private readonly modelManager: ModelManagerInterface
|
||||
private readonly eventManager: EventManagerInterface
|
||||
private readonly adapters: ModelAdapter[]
|
||||
private readonly adapterRef: AdapterRef
|
||||
private currentLoadId: number = 0
|
||||
private _currentAdapter: ModelAdapter | null = null
|
||||
|
||||
constructor(
|
||||
modelManager: ModelManagerInterface,
|
||||
eventManager: EventManagerInterface,
|
||||
adapters?: readonly ModelAdapter[],
|
||||
adapterRef?: AdapterRef
|
||||
adapters?: readonly ModelAdapter[]
|
||||
) {
|
||||
this.modelManager = modelManager
|
||||
this.eventManager = eventManager
|
||||
this.adapters = adapters ? [...adapters] : defaultAdapters()
|
||||
this.adapterRef = adapterRef ?? createAdapterRef()
|
||||
}
|
||||
|
||||
getCurrentAdapter(): ModelAdapter | null {
|
||||
return this.adapterRef.current
|
||||
return this._currentAdapter
|
||||
}
|
||||
|
||||
init(): void {}
|
||||
@@ -60,7 +57,7 @@ export class LoaderManager implements LoaderManagerInterface {
|
||||
this.eventManager.emitEvent('modelLoadingStart', null)
|
||||
|
||||
this.modelManager.clearModel()
|
||||
this.adapterRef.current = null
|
||||
this._currentAdapter = null
|
||||
|
||||
this.modelManager.originalURL = url
|
||||
|
||||
@@ -86,23 +83,18 @@ export class LoaderManager implements LoaderManagerInterface {
|
||||
const result = await this.loadModelInternal(url, fileExtension)
|
||||
|
||||
if (loadId !== this.currentLoadId) {
|
||||
// A newer loadModel has superseded us — do not publish our adapter
|
||||
// and do not setup the model. Whichever load is current owns the
|
||||
// shared state.
|
||||
return
|
||||
}
|
||||
|
||||
if (result) {
|
||||
// Publish only after the staleness check so a slow older load
|
||||
// can't clobber adapterRef.current that a newer load already
|
||||
// wrote (or cleared).
|
||||
this.adapterRef.current = result.adapter
|
||||
if (result && result.model) {
|
||||
this._currentAdapter = result.adapter
|
||||
await this.modelManager.setupModel(result.model)
|
||||
}
|
||||
|
||||
this.eventManager.emitEvent('modelLoadingEnd', null)
|
||||
} catch (error) {
|
||||
if (loadId === this.currentLoadId) {
|
||||
this._currentAdapter = null
|
||||
this.eventManager.emitEvent('modelLoadingEnd', null)
|
||||
console.error('Error loading model:', error)
|
||||
useToastStore().addAlert(t('toastMessages.errorLoadingModel'))
|
||||
@@ -143,7 +135,7 @@ export class LoaderManager implements LoaderManagerInterface {
|
||||
private async loadModelInternal(
|
||||
url: string,
|
||||
fileExtension: string
|
||||
): Promise<{ model: THREE.Object3D; adapter: ModelAdapter } | null> {
|
||||
): Promise<{ adapter: ModelAdapter; model: THREE.Object3D | null } | null> {
|
||||
const params = new URLSearchParams(url.split('?')[1])
|
||||
const filename = params.get('filename')
|
||||
|
||||
@@ -165,6 +157,6 @@ export class LoaderManager implements LoaderManagerInterface {
|
||||
if (!adapter) return null
|
||||
|
||||
const model = await adapter.load(this.createLoadContext(), path, filename)
|
||||
return model ? { model, adapter } : null
|
||||
return { adapter, model }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,16 +64,6 @@ export const DEFAULT_MODEL_CAPABILITIES: ModelAdapterCapabilities = {
|
||||
fitTargetSize: 5
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutable handle to the currently active ModelAdapter. A single ref is
|
||||
* created in `createLoad3d` and shared between LoaderManager (writer) and
|
||||
* SceneModelManager + Load3d (readers), so capability/bounds/dispose lookups
|
||||
* don't depend on construction order between those collaborators.
|
||||
*/
|
||||
export type AdapterRef = { current: ModelAdapter | null }
|
||||
|
||||
export const createAdapterRef = (): AdapterRef => ({ current: null })
|
||||
|
||||
export interface ModelAdapter {
|
||||
readonly kind: ModelAdapterKind
|
||||
readonly extensions: readonly string[]
|
||||
@@ -83,29 +73,6 @@ export interface ModelAdapter {
|
||||
path: string,
|
||||
filename: string
|
||||
): Promise<THREE.Object3D | null>
|
||||
/**
|
||||
* Optional. Return a world-space AABB for the given model. Adapters for
|
||||
* renderers whose geometry is not walked by `Box3.setFromObject` (e.g.
|
||||
* Gaussian splats) implement this; the default path uses
|
||||
* `Box3.setFromObject` when this is absent.
|
||||
*/
|
||||
computeBounds?(model: THREE.Object3D): THREE.Box3 | null
|
||||
/**
|
||||
* Optional. Release renderer-owned resources on this model beyond what
|
||||
* THREE's geometry/material.dispose covers (e.g. sparkjs SplatMesh
|
||||
* internal GPU/worker state). Called when the model is removed from the
|
||||
* scene due to a reload or teardown. Missing for adapters whose models
|
||||
* the default traversal already handles.
|
||||
*/
|
||||
disposeModel?(model: THREE.Object3D): void
|
||||
/**
|
||||
* Optional. Default camera placement for adapters that opt out of
|
||||
* fit-to-viewer (capabilities.fitToViewer === false). The size/center
|
||||
* pair is forwarded to CameraManager.setupForModel as if the model had
|
||||
* been normalized. Splat geometry is self-sized and uses this to seat
|
||||
* the camera at a known-good distance.
|
||||
*/
|
||||
defaultCameraPose?(): { size: THREE.Vector3; center: THREE.Vector3 }
|
||||
}
|
||||
|
||||
export async function fetchModelData(
|
||||
|
||||
@@ -65,10 +65,10 @@ describe('PointCloudModelAdapter', () => {
|
||||
expect([...adapter.extensions]).toEqual(['ply'])
|
||||
})
|
||||
|
||||
it('identifies as pointCloud with material rebuild + fit-to-viewer + lighting + export, gizmo disabled', () => {
|
||||
it('identifies as pointCloud with rebuild + gizmo/fit disabled', () => {
|
||||
const adapter = new PointCloudModelAdapter()
|
||||
expect(adapter.kind).toBe('pointCloud')
|
||||
expect(adapter.capabilities.fitToViewer).toBe(true)
|
||||
expect(adapter.capabilities.fitToViewer).toBe(false)
|
||||
expect(adapter.capabilities.requiresMaterialRebuild).toBe(true)
|
||||
expect(adapter.capabilities.gizmoTransform).toBe(false)
|
||||
expect(adapter.capabilities.lighting).toBe(true)
|
||||
|
||||
@@ -10,7 +10,6 @@ import type {
|
||||
ModelAdapterCapabilities,
|
||||
ModelLoadContext
|
||||
} from './ModelAdapter'
|
||||
import type { MaterialMode } from './interfaces'
|
||||
import { FastPLYLoader } from './loader/FastPLYLoader'
|
||||
|
||||
export function getPLYEngine(): string {
|
||||
@@ -21,7 +20,7 @@ export class PointCloudModelAdapter implements ModelAdapter {
|
||||
readonly kind = 'pointCloud' as const
|
||||
readonly extensions = ['ply'] as const
|
||||
readonly capabilities: ModelAdapterCapabilities = {
|
||||
fitToViewer: true,
|
||||
fitToViewer: false,
|
||||
requiresMaterialRebuild: true,
|
||||
gizmoTransform: false,
|
||||
lighting: true,
|
||||
@@ -44,7 +43,7 @@ export class PointCloudModelAdapter implements ModelAdapter {
|
||||
const plyGeometry =
|
||||
isASCII && getPLYEngine() === 'fastply'
|
||||
? this.fastPlyLoader.parse(arrayBuffer)
|
||||
: (this.plyLoader.setPath(path), this.plyLoader.parse(arrayBuffer))
|
||||
: this.plyLoader.parse(arrayBuffer)
|
||||
|
||||
ctx.setOriginalModel(plyGeometry)
|
||||
plyGeometry.computeVertexNormals()
|
||||
@@ -119,43 +118,3 @@ function buildMeshGroup(
|
||||
group.add(mesh)
|
||||
return group
|
||||
}
|
||||
|
||||
export function buildPointCloudForMaterialMode(
|
||||
originalGeometry: THREE.BufferGeometry,
|
||||
mode: MaterialMode,
|
||||
standardMaterial: THREE.MeshStandardMaterial,
|
||||
originalMaterials: WeakMap<THREE.Mesh, THREE.Material | THREE.Material[]>
|
||||
): THREE.Group {
|
||||
const geometry = originalGeometry.clone()
|
||||
const hasVertexColors = geometry.attributes.color !== undefined
|
||||
|
||||
const ctx: ModelLoadContext = {
|
||||
setOriginalModel: () => {},
|
||||
registerOriginalMaterial: (mesh, material) =>
|
||||
originalMaterials.set(mesh, material),
|
||||
standardMaterial,
|
||||
materialMode: mode
|
||||
}
|
||||
|
||||
if (mode === 'pointCloud') {
|
||||
return buildPointsGroup(ctx, geometry, hasVertexColors)
|
||||
}
|
||||
|
||||
const group = buildMeshGroup(ctx, geometry, hasVertexColors)
|
||||
|
||||
if (mode === 'normal' || mode === 'wireframe') {
|
||||
const mesh = group.children[0] as THREE.Mesh
|
||||
mesh.material =
|
||||
mode === 'normal'
|
||||
? new THREE.MeshNormalMaterial({
|
||||
flatShading: false,
|
||||
side: THREE.DoubleSide
|
||||
})
|
||||
: new THREE.MeshBasicMaterial({
|
||||
color: 0xffffff,
|
||||
wireframe: true
|
||||
})
|
||||
}
|
||||
|
||||
return group
|
||||
}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import * as THREE from 'three'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { DEFAULT_MODEL_CAPABILITIES } from './ModelAdapter'
|
||||
import type { ModelAdapterCapabilities } from './ModelAdapter'
|
||||
import { SceneModelManager } from './SceneModelManager'
|
||||
import type { EventManagerInterface } from './interfaces'
|
||||
import { SceneModelManager } from './SceneModelManager'
|
||||
|
||||
function createMockRenderer(): THREE.WebGLRenderer {
|
||||
return {
|
||||
@@ -25,7 +23,6 @@ function createManager(
|
||||
overrides: {
|
||||
scene?: THREE.Scene
|
||||
eventManager?: EventManagerInterface
|
||||
capabilities?: Partial<ModelAdapterCapabilities>
|
||||
} = {}
|
||||
) {
|
||||
const scene = overrides.scene ?? new THREE.Scene()
|
||||
@@ -35,10 +32,6 @@ function createManager(
|
||||
const getActiveCamera = () => camera
|
||||
const setupCamera = vi.fn()
|
||||
const setupGizmo = vi.fn()
|
||||
const capabilities: ModelAdapterCapabilities = {
|
||||
...DEFAULT_MODEL_CAPABILITIES,
|
||||
...overrides.capabilities
|
||||
}
|
||||
|
||||
const manager = new SceneModelManager(
|
||||
scene,
|
||||
@@ -46,8 +39,7 @@ function createManager(
|
||||
eventManager,
|
||||
getActiveCamera,
|
||||
setupCamera,
|
||||
setupGizmo,
|
||||
() => capabilities
|
||||
setupGizmo
|
||||
)
|
||||
|
||||
return {
|
||||
@@ -61,37 +53,6 @@ function createManager(
|
||||
}
|
||||
}
|
||||
|
||||
function createManagerWithPose(opts: {
|
||||
capabilities?: Partial<ModelAdapterCapabilities>
|
||||
pose: { size: THREE.Vector3; center: THREE.Vector3 } | null
|
||||
}) {
|
||||
const scene = new THREE.Scene()
|
||||
const renderer = createMockRenderer()
|
||||
const eventManager = createMockEventManager()
|
||||
const camera = new THREE.PerspectiveCamera()
|
||||
const setupCamera = vi.fn()
|
||||
const setupGizmo = vi.fn()
|
||||
const capabilities: ModelAdapterCapabilities = {
|
||||
...DEFAULT_MODEL_CAPABILITIES,
|
||||
...opts.capabilities
|
||||
}
|
||||
|
||||
const manager = new SceneModelManager(
|
||||
scene,
|
||||
renderer,
|
||||
eventManager,
|
||||
() => camera,
|
||||
setupCamera,
|
||||
setupGizmo,
|
||||
() => capabilities,
|
||||
() => null,
|
||||
() => {},
|
||||
() => opts.pose
|
||||
)
|
||||
|
||||
return { manager, scene, setupCamera, setupGizmo }
|
||||
}
|
||||
|
||||
function createMeshModel(name = 'TestModel'): THREE.Group {
|
||||
const geometry = new THREE.BoxGeometry(1, 1, 1)
|
||||
const material = new THREE.MeshStandardMaterial({ color: 0xff0000 })
|
||||
@@ -229,47 +190,6 @@ describe('SceneModelManager', () => {
|
||||
'+z'
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the adapter default pose when fitToViewer is disabled and a pose is provided', async () => {
|
||||
const pose = {
|
||||
size: new THREE.Vector3(5, 5, 5),
|
||||
center: new THREE.Vector3(0, 2.5, 0)
|
||||
}
|
||||
const { manager, scene, setupCamera, setupGizmo } = createManagerWithPose(
|
||||
{
|
||||
capabilities: { fitToViewer: false },
|
||||
pose
|
||||
}
|
||||
)
|
||||
const model = createMeshModel()
|
||||
|
||||
await manager.setupModel(model)
|
||||
|
||||
expect(scene.children).toContain(model)
|
||||
expect(setupCamera).toHaveBeenCalledWith(pose.size, pose.center)
|
||||
|
||||
expect(setupGizmo).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to the full setup path when fitToViewer is disabled but no default pose is provided', async () => {
|
||||
const { manager, scene, setupCamera, setupGizmo } = createManagerWithPose(
|
||||
{
|
||||
capabilities: { fitToViewer: false },
|
||||
pose: null
|
||||
}
|
||||
)
|
||||
const model = createMeshModel()
|
||||
|
||||
await manager.setupModel(model)
|
||||
|
||||
expect(scene.children).toContain(model)
|
||||
|
||||
expect(setupCamera).toHaveBeenCalled()
|
||||
const callArgs = setupCamera.mock.calls[0]
|
||||
expect(callArgs[0]).toBeInstanceOf(THREE.Vector3)
|
||||
expect(callArgs[1]).toBeInstanceOf(THREE.Vector3)
|
||||
expect(setupGizmo).toHaveBeenCalledWith(model)
|
||||
})
|
||||
})
|
||||
|
||||
describe('setOriginalModel', () => {
|
||||
@@ -655,11 +575,29 @@ describe('SceneModelManager', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('containsSplatMesh', () => {
|
||||
it('returns false when no model', () => {
|
||||
const { manager } = createManager()
|
||||
expect(manager.containsSplatMesh()).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for regular model', async () => {
|
||||
const { manager } = createManager()
|
||||
const model = createMeshModel()
|
||||
await manager.setupModel(model)
|
||||
|
||||
expect(manager.containsSplatMesh()).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for explicit null argument', () => {
|
||||
const { manager } = createManager()
|
||||
expect(manager.containsSplatMesh(null)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('PLY mode switching', () => {
|
||||
function createPLYManager() {
|
||||
const ctx = createManager({
|
||||
capabilities: { requiresMaterialRebuild: true }
|
||||
})
|
||||
const ctx = createManager()
|
||||
const geometry = new THREE.BufferGeometry()
|
||||
geometry.setAttribute(
|
||||
'position',
|
||||
@@ -717,9 +655,7 @@ describe('SceneModelManager', () => {
|
||||
})
|
||||
|
||||
it('uses vertex colors when available', () => {
|
||||
const { manager, scene } = createManager({
|
||||
capabilities: { requiresMaterialRebuild: true }
|
||||
})
|
||||
const { manager, scene } = createManager()
|
||||
const geometry = new THREE.BufferGeometry()
|
||||
geometry.setAttribute(
|
||||
'position',
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import { SplatMesh } from '@sparkjsdev/spark'
|
||||
import * as THREE from 'three'
|
||||
import type { GLTF } from 'three/examples/jsm/loaders/GLTFLoader'
|
||||
import { type GLTF } from 'three/examples/jsm/loaders/GLTFLoader'
|
||||
|
||||
import { DEFAULT_MODEL_CAPABILITIES } from './ModelAdapter'
|
||||
import type { ModelAdapterCapabilities } from './ModelAdapter'
|
||||
import { buildPointCloudForMaterialMode } from './PointCloudModelAdapter'
|
||||
import type {
|
||||
EventManagerInterface,
|
||||
MaterialMode,
|
||||
ModelManagerInterface,
|
||||
UpDirection
|
||||
import {
|
||||
type EventManagerInterface,
|
||||
type MaterialMode,
|
||||
type ModelManagerInterface,
|
||||
type UpDirection
|
||||
} from './interfaces'
|
||||
|
||||
export class SceneModelManager implements ModelManagerInterface {
|
||||
@@ -41,13 +39,6 @@ export class SceneModelManager implements ModelManagerInterface {
|
||||
private activeCamera: THREE.Camera
|
||||
private setupCamera: (size: THREE.Vector3, center: THREE.Vector3) => void
|
||||
private setupGizmo: (model: THREE.Object3D) => void
|
||||
private getCurrentCapabilities: () => ModelAdapterCapabilities
|
||||
private getBoundsFromAdapter: (model: THREE.Object3D) => THREE.Box3 | null
|
||||
private disposeModelViaAdapter: (model: THREE.Object3D) => void
|
||||
private getDefaultCameraPose: () => {
|
||||
size: THREE.Vector3
|
||||
center: THREE.Vector3
|
||||
} | null
|
||||
|
||||
constructor(
|
||||
scene: THREE.Scene,
|
||||
@@ -55,16 +46,7 @@ export class SceneModelManager implements ModelManagerInterface {
|
||||
eventManager: EventManagerInterface,
|
||||
getActiveCamera: () => THREE.Camera,
|
||||
setupCamera: (size: THREE.Vector3, center: THREE.Vector3) => void,
|
||||
setupGizmo: (model: THREE.Object3D) => void,
|
||||
getCurrentCapabilities: () => ModelAdapterCapabilities = () =>
|
||||
DEFAULT_MODEL_CAPABILITIES,
|
||||
getBoundsFromAdapter: (model: THREE.Object3D) => THREE.Box3 | null = () =>
|
||||
null,
|
||||
disposeModelViaAdapter: (model: THREE.Object3D) => void = () => {},
|
||||
getDefaultCameraPose: () => {
|
||||
size: THREE.Vector3
|
||||
center: THREE.Vector3
|
||||
} | null = () => null
|
||||
setupGizmo: (model: THREE.Object3D) => void
|
||||
) {
|
||||
this.scene = scene
|
||||
this.renderer = renderer
|
||||
@@ -73,10 +55,6 @@ export class SceneModelManager implements ModelManagerInterface {
|
||||
this.setupCamera = setupCamera
|
||||
this.textureLoader = new THREE.TextureLoader()
|
||||
this.setupGizmo = setupGizmo
|
||||
this.getCurrentCapabilities = getCurrentCapabilities
|
||||
this.getBoundsFromAdapter = getBoundsFromAdapter
|
||||
this.disposeModelViaAdapter = disposeModelViaAdapter
|
||||
this.getDefaultCameraPose = getDefaultCameraPose
|
||||
|
||||
this.normalMaterial = new THREE.MeshNormalMaterial({
|
||||
flatShading: false,
|
||||
@@ -126,11 +104,23 @@ export class SceneModelManager implements ModelManagerInterface {
|
||||
})
|
||||
}
|
||||
|
||||
private removeAllMainModelsFromScene(): void {
|
||||
private handlePLYModeSwitch(mode: MaterialMode): void {
|
||||
if (!(this.originalModel instanceof THREE.BufferGeometry)) {
|
||||
return
|
||||
}
|
||||
|
||||
const plyGeometry = this.originalModel.clone()
|
||||
const hasVertexColors = plyGeometry.attributes.color !== undefined
|
||||
|
||||
// Find and remove ALL MainModel instances by name to ensure deletion
|
||||
const oldMainModels: THREE.Object3D[] = []
|
||||
this.scene.traverse((obj) => {
|
||||
if (obj.name === 'MainModel') oldMainModels.push(obj)
|
||||
if (obj.name === 'MainModel') {
|
||||
oldMainModels.push(obj)
|
||||
}
|
||||
})
|
||||
|
||||
// Remove and dispose all found MainModels
|
||||
oldMainModels.forEach((oldModel) => {
|
||||
oldModel.traverse((child) => {
|
||||
if (child instanceof THREE.Mesh || child instanceof THREE.Points) {
|
||||
@@ -142,31 +132,103 @@ export class SceneModelManager implements ModelManagerInterface {
|
||||
}
|
||||
}
|
||||
})
|
||||
this.disposeModelViaAdapter(oldModel)
|
||||
this.scene.remove(oldModel)
|
||||
})
|
||||
}
|
||||
|
||||
private rebuildForMaterialMode(mode: MaterialMode): void {
|
||||
if (!(this.originalModel instanceof THREE.BufferGeometry)) return
|
||||
|
||||
this.removeAllMainModelsFromScene()
|
||||
this.currentModel = null
|
||||
|
||||
const newModel = buildPointCloudForMaterialMode(
|
||||
this.originalModel,
|
||||
mode,
|
||||
this.standardMaterial,
|
||||
this.originalMaterials
|
||||
)
|
||||
let newModel: THREE.Object3D
|
||||
|
||||
if (mode === 'pointCloud') {
|
||||
// Use Points rendering for point cloud mode
|
||||
plyGeometry.computeBoundingSphere()
|
||||
if (plyGeometry.boundingSphere) {
|
||||
const center = plyGeometry.boundingSphere.center
|
||||
const radius = plyGeometry.boundingSphere.radius
|
||||
|
||||
plyGeometry.translate(-center.x, -center.y, -center.z)
|
||||
|
||||
if (radius > 0) {
|
||||
const scale = 1.0 / radius
|
||||
plyGeometry.scale(scale, scale, scale)
|
||||
}
|
||||
}
|
||||
|
||||
const pointMaterial = hasVertexColors
|
||||
? new THREE.PointsMaterial({
|
||||
size: 0.005,
|
||||
vertexColors: true,
|
||||
sizeAttenuation: true
|
||||
})
|
||||
: new THREE.PointsMaterial({
|
||||
size: 0.005,
|
||||
color: 0xcccccc,
|
||||
sizeAttenuation: true
|
||||
})
|
||||
|
||||
const points = new THREE.Points(plyGeometry, pointMaterial)
|
||||
newModel = new THREE.Group()
|
||||
newModel.add(points)
|
||||
} else {
|
||||
// Use Mesh rendering for other modes
|
||||
let meshMaterial: THREE.Material = hasVertexColors
|
||||
? new THREE.MeshStandardMaterial({
|
||||
vertexColors: true,
|
||||
metalness: 0.0,
|
||||
roughness: 0.5,
|
||||
side: THREE.DoubleSide
|
||||
})
|
||||
: this.standardMaterial.clone()
|
||||
|
||||
if (
|
||||
!hasVertexColors &&
|
||||
meshMaterial instanceof THREE.MeshStandardMaterial
|
||||
) {
|
||||
meshMaterial.side = THREE.DoubleSide
|
||||
}
|
||||
|
||||
const mesh = new THREE.Mesh(plyGeometry, meshMaterial)
|
||||
this.originalMaterials.set(mesh, meshMaterial)
|
||||
|
||||
newModel = new THREE.Group()
|
||||
newModel.add(mesh)
|
||||
|
||||
// Apply the requested material mode
|
||||
if (mode === 'normal') {
|
||||
mesh.material = new THREE.MeshNormalMaterial({
|
||||
flatShading: false,
|
||||
side: THREE.DoubleSide
|
||||
})
|
||||
} else if (mode === 'wireframe') {
|
||||
mesh.material = new THREE.MeshBasicMaterial({
|
||||
color: 0xffffff,
|
||||
wireframe: true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Double check: remove any remaining MainModel before adding new one
|
||||
const remainingMainModels: THREE.Object3D[] = []
|
||||
this.scene.traverse((obj) => {
|
||||
if (obj.name === 'MainModel') {
|
||||
remainingMainModels.push(obj)
|
||||
}
|
||||
})
|
||||
remainingMainModels.forEach((obj) => this.scene.remove(obj))
|
||||
|
||||
this.currentModel = newModel
|
||||
newModel.name = 'MainModel'
|
||||
|
||||
if (mode !== 'pointCloud') {
|
||||
// Setup the new model
|
||||
if (mode === 'pointCloud') {
|
||||
this.scene.add(newModel)
|
||||
} else {
|
||||
const box = new THREE.Box3().setFromObject(newModel)
|
||||
const size = box.getSize(new THREE.Vector3())
|
||||
const center = box.getCenter(new THREE.Vector3())
|
||||
|
||||
const maxDim = Math.max(size.x, size.y, size.z)
|
||||
const targetSize = this.getCurrentCapabilities().fitTargetSize
|
||||
const targetSize = 5
|
||||
const scale = targetSize / maxDim
|
||||
newModel.scale.multiplyScalar(scale)
|
||||
|
||||
@@ -175,10 +237,9 @@ export class SceneModelManager implements ModelManagerInterface {
|
||||
box.getSize(size)
|
||||
|
||||
newModel.position.set(-center.x, -box.min.y, -center.z)
|
||||
this.scene.add(newModel)
|
||||
}
|
||||
|
||||
this.scene.add(newModel)
|
||||
this.currentModel = newModel
|
||||
this.eventManager.emitEvent('materialModeChange', mode)
|
||||
}
|
||||
|
||||
@@ -189,8 +250,9 @@ export class SceneModelManager implements ModelManagerInterface {
|
||||
|
||||
this.materialMode = mode
|
||||
|
||||
if (this.getCurrentCapabilities().requiresMaterialRebuild) {
|
||||
this.rebuildForMaterialMode(mode)
|
||||
// Handle PLY files specially - they need to be recreated for mode switch
|
||||
if (this.originalModel instanceof THREE.BufferGeometry) {
|
||||
this.handlePLYModeSwitch(mode)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -337,7 +399,6 @@ export class SceneModelManager implements ModelManagerInterface {
|
||||
}
|
||||
}
|
||||
})
|
||||
this.disposeModelViaAdapter(obj)
|
||||
})
|
||||
|
||||
this.reset()
|
||||
@@ -427,23 +488,19 @@ export class SceneModelManager implements ModelManagerInterface {
|
||||
this.scene.add(this.currentModel)
|
||||
}
|
||||
|
||||
private computeWorldBounds(model: THREE.Object3D): THREE.Box3 {
|
||||
return (
|
||||
this.getBoundsFromAdapter(model) ?? new THREE.Box3().setFromObject(model)
|
||||
)
|
||||
}
|
||||
|
||||
async setupModel(model: THREE.Object3D): Promise<void> {
|
||||
this.currentModel = model
|
||||
model.name = 'MainModel'
|
||||
|
||||
if (!this.getCurrentCapabilities().fitToViewer) {
|
||||
const pose = this.getDefaultCameraPose()
|
||||
if (pose) {
|
||||
this.scene.add(model)
|
||||
this.setupCamera(pose.size, pose.center)
|
||||
return
|
||||
}
|
||||
// Check if model is or contains a SplatMesh (3D Gaussian Splatting)
|
||||
const isSplatModel = this.containsSplatMesh(model)
|
||||
|
||||
if (isSplatModel) {
|
||||
// SplatMesh handles its own rendering, just add to scene
|
||||
this.scene.add(model)
|
||||
// Set a default camera distance for splat models
|
||||
this.setupCamera(new THREE.Vector3(5, 5, 5), new THREE.Vector3(0, 2.5, 0))
|
||||
return
|
||||
}
|
||||
|
||||
this.scene.add(model)
|
||||
@@ -457,7 +514,7 @@ export class SceneModelManager implements ModelManagerInterface {
|
||||
}
|
||||
this.setupModelMaterials(model)
|
||||
|
||||
const box = this.computeWorldBounds(model)
|
||||
const box = new THREE.Box3().setFromObject(model)
|
||||
const size = box.getSize(new THREE.Vector3())
|
||||
const center = box.getCenter(new THREE.Vector3())
|
||||
|
||||
@@ -467,31 +524,32 @@ export class SceneModelManager implements ModelManagerInterface {
|
||||
}
|
||||
|
||||
fitToViewer(): void {
|
||||
if (!this.currentModel || !this.getCurrentCapabilities().fitToViewer) return
|
||||
if (!this.currentModel || this.containsSplatMesh()) return
|
||||
const model = this.currentModel
|
||||
|
||||
// Reset transform to compute from raw geometry (idempotent)
|
||||
model.scale.set(1, 1, 1)
|
||||
model.position.set(0, 0, 0)
|
||||
model.rotation.set(0, 0, 0)
|
||||
|
||||
const box = this.computeWorldBounds(model)
|
||||
const box = new THREE.Box3().setFromObject(model)
|
||||
const size = box.getSize(new THREE.Vector3())
|
||||
const center = box.getCenter(new THREE.Vector3())
|
||||
|
||||
const maxDim = Math.max(size.x, size.y, size.z)
|
||||
if (maxDim === 0) return
|
||||
|
||||
const targetSize = this.getCurrentCapabilities().fitTargetSize
|
||||
const targetSize = 5
|
||||
const scale = targetSize / maxDim
|
||||
model.scale.set(scale, scale, scale)
|
||||
|
||||
const scaledBox = this.computeWorldBounds(model)
|
||||
scaledBox.getCenter(center)
|
||||
scaledBox.getSize(size)
|
||||
box.setFromObject(model)
|
||||
box.getCenter(center)
|
||||
box.getSize(size)
|
||||
|
||||
model.position.set(-center.x, -scaledBox.min.y, -center.z)
|
||||
model.position.set(-center.x, -box.min.y, -center.z)
|
||||
|
||||
const newBox = this.computeWorldBounds(model)
|
||||
const newBox = new THREE.Box3().setFromObject(model)
|
||||
const newSize = newBox.getSize(new THREE.Vector3())
|
||||
const newCenter = newBox.getCenter(new THREE.Vector3())
|
||||
|
||||
@@ -499,6 +557,17 @@ export class SceneModelManager implements ModelManagerInterface {
|
||||
this.setupGizmo(model)
|
||||
}
|
||||
|
||||
containsSplatMesh(model?: THREE.Object3D | null): boolean {
|
||||
const target = model ?? this.currentModel
|
||||
if (!target) return false
|
||||
if (target instanceof SplatMesh) return true
|
||||
let found = false
|
||||
target.traverse((child) => {
|
||||
if (child instanceof SplatMesh) found = true
|
||||
})
|
||||
return found
|
||||
}
|
||||
|
||||
setOriginalModel(model: THREE.Object3D | THREE.BufferGeometry | GLTF): void {
|
||||
this.originalModel = model
|
||||
}
|
||||
|
||||
@@ -1,39 +1,21 @@
|
||||
import * as THREE from 'three'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import * as ModelAdapterModule from './ModelAdapter'
|
||||
import type { ModelLoadContext } from './ModelAdapter'
|
||||
import * as ModelAdapterModule from './ModelAdapter'
|
||||
import { SplatModelAdapter } from './SplatModelAdapter'
|
||||
|
||||
const splatMeshSpies = {
|
||||
ctor: vi.fn<(opts: { fileBytes: ArrayBuffer }) => void>(),
|
||||
dispose: vi.fn(),
|
||||
getBoundingBox: vi.fn(
|
||||
() =>
|
||||
new THREE.Box3(new THREE.Vector3(-1, -1, -1), new THREE.Vector3(1, 1, 1))
|
||||
),
|
||||
updateWorldMatrix: vi.fn()
|
||||
}
|
||||
const { splatMeshCtor } = vi.hoisted(() => ({
|
||||
splatMeshCtor: vi.fn<(opts: { fileBytes: ArrayBuffer }) => void>()
|
||||
}))
|
||||
|
||||
vi.mock('@sparkjsdev/spark', async () => {
|
||||
const three = await import('three')
|
||||
return {
|
||||
SplatMesh: class extends three.Object3D {
|
||||
initialized = Promise.resolve()
|
||||
dispose = splatMeshSpies.dispose
|
||||
getBoundingBox = splatMeshSpies.getBoundingBox
|
||||
|
||||
constructor(opts: { fileBytes: ArrayBuffer }) {
|
||||
super()
|
||||
splatMeshSpies.ctor(opts)
|
||||
}
|
||||
|
||||
override updateWorldMatrix(
|
||||
force: boolean,
|
||||
updateChildren: boolean
|
||||
): void {
|
||||
splatMeshSpies.updateWorldMatrix(force, updateChildren)
|
||||
super.updateWorldMatrix(force, updateChildren)
|
||||
splatMeshCtor(opts)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -50,13 +32,7 @@ function makeContext(): ModelLoadContext {
|
||||
|
||||
describe('SplatModelAdapter', () => {
|
||||
beforeEach(() => {
|
||||
splatMeshSpies.ctor.mockClear()
|
||||
splatMeshSpies.dispose.mockClear()
|
||||
splatMeshSpies.getBoundingBox.mockClear()
|
||||
splatMeshSpies.updateWorldMatrix.mockClear()
|
||||
vi.spyOn(ModelAdapterModule, 'fetchModelData').mockResolvedValue(
|
||||
new ArrayBuffer(8)
|
||||
)
|
||||
splatMeshCtor.mockReset()
|
||||
})
|
||||
|
||||
it('exposes splat capabilities on the adapter', () => {
|
||||
@@ -85,7 +61,7 @@ describe('SplatModelAdapter', () => {
|
||||
'/api/view?',
|
||||
'scene.splat'
|
||||
)
|
||||
expect(splatMeshSpies.ctor).toHaveBeenCalledWith({ fileBytes: buf })
|
||||
expect(splatMeshCtor).toHaveBeenCalledWith({ fileBytes: buf })
|
||||
expect(result).toBeInstanceOf(THREE.Group)
|
||||
expect(result.children).toHaveLength(1)
|
||||
|
||||
@@ -93,20 +69,6 @@ describe('SplatModelAdapter', () => {
|
||||
expect(ctx.setOriginalModel).toHaveBeenCalledWith(result.children[0])
|
||||
})
|
||||
|
||||
it('rotates the splat 180° around X (OpenCV → three.js convention)', async () => {
|
||||
const result = await new SplatModelAdapter().load(
|
||||
makeContext(),
|
||||
'/api/view?',
|
||||
'scene.splat'
|
||||
)
|
||||
|
||||
const splat = result.children[0]
|
||||
expect(splat.quaternion.x).toBe(1)
|
||||
expect(splat.quaternion.y).toBe(0)
|
||||
expect(splat.quaternion.z).toBe(0)
|
||||
expect(splat.quaternion.w).toBe(0)
|
||||
})
|
||||
|
||||
it('propagates fetch errors', async () => {
|
||||
vi.spyOn(ModelAdapterModule, 'fetchModelData').mockRejectedValue(
|
||||
new Error('Failed to fetch model: 500')
|
||||
@@ -117,72 +79,4 @@ describe('SplatModelAdapter', () => {
|
||||
adapter.load(makeContext(), '/api/view?', 'scene.splat')
|
||||
).rejects.toThrow('Failed to fetch model: 500')
|
||||
})
|
||||
|
||||
describe('computeBounds', () => {
|
||||
it('returns the SplatMesh bounding box transformed to world space', async () => {
|
||||
const adapter = new SplatModelAdapter()
|
||||
const group = await adapter.load(
|
||||
makeContext(),
|
||||
'/api/view?',
|
||||
'scene.splat'
|
||||
)
|
||||
const splat = group.children[0]
|
||||
splat.position.set(10, 0, 0)
|
||||
|
||||
const bounds = adapter.computeBounds(group)
|
||||
|
||||
expect(bounds).toBeInstanceOf(THREE.Box3)
|
||||
expect(splatMeshSpies.getBoundingBox).toHaveBeenCalledWith(false)
|
||||
expect(splatMeshSpies.updateWorldMatrix).toHaveBeenCalledWith(true, false)
|
||||
// Local bbox was [-1,-1,-1]→[1,1,1]; world matrix translates by +10 X
|
||||
// (with the splat's quaternion applied to the inner mesh).
|
||||
expect(bounds!.min.x).toBeCloseTo(9)
|
||||
expect(bounds!.max.x).toBeCloseTo(11)
|
||||
})
|
||||
|
||||
it('returns null when the first child is not a SplatMesh', () => {
|
||||
const adapter = new SplatModelAdapter()
|
||||
const group = new THREE.Group()
|
||||
group.add(new THREE.Mesh())
|
||||
|
||||
expect(adapter.computeBounds(group)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('disposeModel', () => {
|
||||
it('calls dispose on every SplatMesh in the model tree', async () => {
|
||||
const adapter = new SplatModelAdapter()
|
||||
const group = await adapter.load(
|
||||
makeContext(),
|
||||
'/api/view?',
|
||||
'scene.splat'
|
||||
)
|
||||
|
||||
adapter.disposeModel(group)
|
||||
|
||||
expect(splatMeshSpies.dispose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('is a no-op when the tree has no SplatMesh', () => {
|
||||
const adapter = new SplatModelAdapter()
|
||||
const group = new THREE.Group()
|
||||
group.add(new THREE.Mesh())
|
||||
|
||||
expect(() => adapter.disposeModel(group)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('defaultCameraPose', () => {
|
||||
it('returns the (5,5,5) / (0,2.5,0) seat for self-sized splats', () => {
|
||||
const adapter = new SplatModelAdapter()
|
||||
const pose = adapter.defaultCameraPose()
|
||||
|
||||
expect(pose.size.x).toBe(5)
|
||||
expect(pose.size.y).toBe(5)
|
||||
expect(pose.size.z).toBe(5)
|
||||
expect(pose.center.x).toBe(0)
|
||||
expect(pose.center.y).toBe(2.5)
|
||||
expect(pose.center.z).toBe(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,13 +12,13 @@ export class SplatModelAdapter implements ModelAdapter {
|
||||
readonly kind = 'splat' as const
|
||||
readonly extensions = ['spz', 'splat', 'ksplat'] as const
|
||||
readonly capabilities: ModelAdapterCapabilities = {
|
||||
fitToViewer: true,
|
||||
fitToViewer: false,
|
||||
requiresMaterialRebuild: false,
|
||||
gizmoTransform: true,
|
||||
gizmoTransform: false,
|
||||
lighting: false,
|
||||
exportable: false,
|
||||
materialModes: [],
|
||||
fitTargetSize: 20
|
||||
fitTargetSize: 5
|
||||
}
|
||||
|
||||
async load(
|
||||
@@ -29,34 +29,10 @@ export class SplatModelAdapter implements ModelAdapter {
|
||||
const arrayBuffer = await fetchModelData(path, filename)
|
||||
|
||||
const splatMesh = new SplatMesh({ fileBytes: arrayBuffer })
|
||||
await splatMesh.initialized
|
||||
splatMesh.quaternion.set(1, 0, 0, 0)
|
||||
ctx.setOriginalModel(splatMesh)
|
||||
|
||||
const splatGroup = new THREE.Group()
|
||||
splatGroup.add(splatMesh)
|
||||
return splatGroup
|
||||
}
|
||||
|
||||
computeBounds(model: THREE.Object3D): THREE.Box3 | null {
|
||||
const splat = model.children[0]
|
||||
if (!(splat instanceof SplatMesh)) return null
|
||||
splat.updateWorldMatrix(true, false)
|
||||
return splat.getBoundingBox(false).clone().applyMatrix4(splat.matrixWorld)
|
||||
}
|
||||
|
||||
disposeModel(model: THREE.Object3D): void {
|
||||
model.traverse((child) => {
|
||||
if (child instanceof SplatMesh) {
|
||||
child.dispose()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
defaultCameraPose(): { size: THREE.Vector3; center: THREE.Vector3 } {
|
||||
return {
|
||||
size: new THREE.Vector3(5, 5, 5),
|
||||
center: new THREE.Vector3(0, 2.5, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { computeCameraFromMatrices } from './cameraFromMatrices'
|
||||
|
||||
const IDENTITY_R = [
|
||||
[1, 0, 0],
|
||||
[0, 1, 0],
|
||||
[0, 0, 1]
|
||||
] as const
|
||||
|
||||
function extrinsics(
|
||||
r: readonly (readonly number[])[],
|
||||
t: readonly number[]
|
||||
): number[][] {
|
||||
return [
|
||||
[r[0][0], r[0][1], r[0][2], t[0]],
|
||||
[r[1][0], r[1][1], r[1][2], t[1]],
|
||||
[r[2][0], r[2][1], r[2][2], t[2]],
|
||||
[0, 0, 0, 1]
|
||||
]
|
||||
}
|
||||
|
||||
function intrinsics(
|
||||
fx: number,
|
||||
fy: number,
|
||||
cx: number,
|
||||
cy: number
|
||||
): number[][] {
|
||||
return [
|
||||
[fx, 0, cx],
|
||||
[0, fy, cy],
|
||||
[0, 0, 1]
|
||||
]
|
||||
}
|
||||
|
||||
function closeTo(received: readonly number[], expected: readonly number[]) {
|
||||
expect(received.length).toBe(expected.length)
|
||||
for (let i = 0; i < expected.length; i++) {
|
||||
expect(received[i]).toBeCloseTo(expected[i], 6)
|
||||
}
|
||||
}
|
||||
|
||||
describe('computeCameraFromMatrices', () => {
|
||||
it('places camera at origin when extrinsics are identity', () => {
|
||||
const result = computeCameraFromMatrices(
|
||||
extrinsics(IDENTITY_R, [0, 0, 0]),
|
||||
intrinsics(500, 500, 320, 240)
|
||||
)
|
||||
|
||||
closeTo(result.position, [0, 0, 0])
|
||||
// Identity forward (0,0,1) in OpenCV world -> after 180° rotation about X
|
||||
// becomes (0,0,-1) in three.js world (camera looks toward -Z, same as
|
||||
// three.js PerspectiveCamera default).
|
||||
closeTo(result.target, [0, 0, -1])
|
||||
})
|
||||
|
||||
it('computes position as -R^T * t for a pure-translation extrinsic (Z flipped to three.js)', () => {
|
||||
// World-to-camera t = (0, 0, -5) means world origin is 5 units behind
|
||||
// camera in OpenCV frame. -R^T * t = (0, 0, 5) in OpenCV world.
|
||||
// After world-rotation 180° about X: three.js position = (0, 0, -5).
|
||||
const result = computeCameraFromMatrices(
|
||||
extrinsics(IDENTITY_R, [0, 0, -5]),
|
||||
intrinsics(500, 500, 320, 240)
|
||||
)
|
||||
|
||||
closeTo(result.position, [0, 0, -5])
|
||||
// Target is one step along camera +Z in OpenCV = (0, 0, 6), then Z-flip
|
||||
// gives three.js target = (0, 0, -6).
|
||||
closeTo(result.target, [0, 0, -6])
|
||||
})
|
||||
|
||||
it('rotates forward direction using the third row of R', () => {
|
||||
// R whose third row = (1, 0, 0): camera +Z axis points along world +X
|
||||
// in OpenCV. X is not flipped by the OpenCV->three.js rotation, so the
|
||||
// forward ray stays along +X in three.js world.
|
||||
const r = [
|
||||
[0, 0, -1],
|
||||
[0, 1, 0],
|
||||
[1, 0, 0]
|
||||
]
|
||||
|
||||
const result = computeCameraFromMatrices(
|
||||
extrinsics(r, [0, 0, 0]),
|
||||
intrinsics(500, 500, 320, 240)
|
||||
)
|
||||
|
||||
closeTo(result.position, [0, 0, 0])
|
||||
closeTo(result.target, [1, 0, 0])
|
||||
})
|
||||
|
||||
it('applies Y-flip to convert OpenCV Y-down to three.js Y-up', () => {
|
||||
// Camera at OpenCV world Y = 3 (below origin in Y-down world).
|
||||
// After 180° rotation about X: three.js Y = -3 (below in Y-up world).
|
||||
const result = computeCameraFromMatrices(
|
||||
extrinsics(IDENTITY_R, [0, -3, 0]),
|
||||
intrinsics(500, 500, 320, 240)
|
||||
)
|
||||
|
||||
closeTo(result.position, [0, -3, 0])
|
||||
})
|
||||
|
||||
it('computes vertical FOV from fy and cy', () => {
|
||||
// fy = 500, cy = 250 → fov_y = 2 * atan(0.5) ≈ 53.13°
|
||||
const result = computeCameraFromMatrices(
|
||||
extrinsics(IDENTITY_R, [0, 0, 0]),
|
||||
intrinsics(500, 500, 320, 250)
|
||||
)
|
||||
|
||||
expect(result.fovYDegrees).toBeCloseTo(53.1301023542, 6)
|
||||
})
|
||||
|
||||
it('throws when extrinsics is not 4x4', () => {
|
||||
expect(() =>
|
||||
computeCameraFromMatrices(
|
||||
[
|
||||
[1, 0, 0],
|
||||
[0, 1, 0],
|
||||
[0, 0, 1]
|
||||
],
|
||||
intrinsics(500, 500, 320, 240)
|
||||
)
|
||||
).toThrow(/extrinsics/)
|
||||
})
|
||||
|
||||
it('throws when intrinsics is not 3x3', () => {
|
||||
expect(() =>
|
||||
computeCameraFromMatrices(extrinsics(IDENTITY_R, [0, 0, 0]), [
|
||||
[500, 0, 320, 0],
|
||||
[0, 500, 240, 0],
|
||||
[0, 0, 1, 0]
|
||||
])
|
||||
).toThrow(/intrinsics/)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['zero', 0],
|
||||
['NaN', Number.NaN],
|
||||
['Infinity', Number.POSITIVE_INFINITY]
|
||||
])(
|
||||
'throws when fy is %s rather than producing a NaN/Infinite FOV',
|
||||
(_label, fy) => {
|
||||
expect(() =>
|
||||
computeCameraFromMatrices(
|
||||
extrinsics(IDENTITY_R, [0, 0, 0]),
|
||||
intrinsics(500, fy, 320, 240)
|
||||
)
|
||||
).toThrow(/fy/)
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -1,94 +0,0 @@
|
||||
/**
|
||||
* Compute a three.js camera pose (position, target, vertical FOV) from a
|
||||
* pair of OpenCV-convention camera matrices as produced by SHARP / COLMAP /
|
||||
* other SfM pipelines.
|
||||
*
|
||||
* Extrinsics: 4x4 world-to-camera matrix E = [R | t; 0 0 0 1]
|
||||
* - R is the 3x3 rotation block
|
||||
* - t is the 3x1 translation block (rightmost column, top three rows)
|
||||
* Intrinsics: 3x3 camera matrix K = [[fx, 0, cx], [0, fy, cy], [0, 0, 1]]
|
||||
*
|
||||
* OpenCV convention: X right, Y down, Z forward.
|
||||
* three.js convention: X right, Y up, Z backward.
|
||||
*
|
||||
* Camera position in world space = -R^T * t
|
||||
* Forward ray in world space = third row of R (camera's +Z axis)
|
||||
* Vertical FOV (radians) = 2 * atan(cy / fy)
|
||||
*
|
||||
* The whole world is rotated 180° around X to align OpenCV Y-down/Z-forward
|
||||
* with three.js Y-up/Z-back (same rotation applied to splats at load time
|
||||
* via SplatMesh.quaternion.set(1, 0, 0, 0)). That rotation flips both Y and Z.
|
||||
*/
|
||||
type Vec3 = [number, number, number]
|
||||
|
||||
interface CameraFromMatricesResult {
|
||||
position: Vec3
|
||||
target: Vec3
|
||||
fovYDegrees: number
|
||||
}
|
||||
|
||||
export function computeCameraFromMatrices(
|
||||
extrinsics: readonly (readonly number[])[],
|
||||
intrinsics: readonly (readonly number[])[]
|
||||
): CameraFromMatricesResult {
|
||||
assertMatrixShape(extrinsics, 4, 4, 'extrinsics')
|
||||
assertMatrixShape(intrinsics, 3, 3, 'intrinsics')
|
||||
|
||||
const r00 = extrinsics[0][0]
|
||||
const r01 = extrinsics[0][1]
|
||||
const r02 = extrinsics[0][2]
|
||||
const r10 = extrinsics[1][0]
|
||||
const r11 = extrinsics[1][1]
|
||||
const r12 = extrinsics[1][2]
|
||||
const r20 = extrinsics[2][0]
|
||||
const r21 = extrinsics[2][1]
|
||||
const r22 = extrinsics[2][2]
|
||||
|
||||
const tx = extrinsics[0][3]
|
||||
const ty = extrinsics[1][3]
|
||||
const tz = extrinsics[2][3]
|
||||
|
||||
const posX = -(r00 * tx + r10 * ty + r20 * tz)
|
||||
const posY = -(r01 * tx + r11 * ty + r21 * tz)
|
||||
const posZ = -(r02 * tx + r12 * ty + r22 * tz)
|
||||
|
||||
const targetX = posX + r20
|
||||
const targetY = posY + r21
|
||||
const targetZ = posZ + r22
|
||||
|
||||
const fy = intrinsics[1][1]
|
||||
const cy = intrinsics[1][2]
|
||||
if (!Number.isFinite(fy) || fy === 0) {
|
||||
throw new Error(
|
||||
`intrinsics[1][1] (fy) must be a non-zero finite number, got ${fy}`
|
||||
)
|
||||
}
|
||||
const fovYRad = 2 * Math.atan(cy / fy)
|
||||
const fovYDegrees = (fovYRad * 180) / Math.PI
|
||||
|
||||
return {
|
||||
position: [posX, -posY, -posZ],
|
||||
target: [targetX, -targetY, -targetZ],
|
||||
fovYDegrees
|
||||
}
|
||||
}
|
||||
|
||||
function assertMatrixShape(
|
||||
matrix: readonly (readonly number[])[],
|
||||
rows: number,
|
||||
cols: number,
|
||||
name: string
|
||||
): void {
|
||||
if (matrix.length !== rows) {
|
||||
throw new Error(
|
||||
`${name} must be ${rows}x${cols}, got ${matrix.length} rows`
|
||||
)
|
||||
}
|
||||
for (let i = 0; i < rows; i++) {
|
||||
if (matrix[i].length !== cols) {
|
||||
throw new Error(
|
||||
`${name} row ${i} must have ${cols} columns, got ${matrix[i].length}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,278 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { DEFAULT_MODEL_CAPABILITIES } from './ModelAdapter'
|
||||
import type { ModelAdapter, ModelAdapterCapabilities } from './ModelAdapter'
|
||||
import { createLoad3d } from './createLoad3d'
|
||||
|
||||
const { rendererCtor } = vi.hoisted(() => ({
|
||||
rendererCtor: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('three', async () => {
|
||||
const actual = await vi.importActual<typeof import('three')>('three')
|
||||
return {
|
||||
...actual,
|
||||
WebGLRenderer: class {
|
||||
domElement = document.createElement('canvas')
|
||||
autoClear = false
|
||||
outputColorSpace = ''
|
||||
constructor(opts: unknown) {
|
||||
rendererCtor(opts)
|
||||
}
|
||||
setSize() {}
|
||||
setClearColor() {}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('./SceneManager', () => ({
|
||||
SceneManager: class {
|
||||
scene = { __scene: true }
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('./CameraManager', () => ({
|
||||
CameraManager: class {
|
||||
activeCamera = { __camera: true }
|
||||
setControls = vi.fn()
|
||||
setupForModel = vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('./ControlsManager', () => ({
|
||||
ControlsManager: class {
|
||||
controls = { __controls: true }
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('./LightingManager', () => ({
|
||||
LightingManager: class {}
|
||||
}))
|
||||
|
||||
vi.mock('./HDRIManager', () => ({
|
||||
HDRIManager: class {}
|
||||
}))
|
||||
|
||||
vi.mock('./ViewHelperManager', () => ({
|
||||
ViewHelperManager: class {}
|
||||
}))
|
||||
|
||||
vi.mock('./SceneModelManager', () => ({
|
||||
SceneModelManager: class {
|
||||
getCurrentCapabilities: () => unknown
|
||||
getBoundsFromAdapter: (model: unknown) => unknown
|
||||
disposeModelViaAdapter: (model: unknown) => unknown
|
||||
getDefaultCameraPose: () => unknown
|
||||
constructor(
|
||||
_scene: unknown,
|
||||
_renderer: unknown,
|
||||
_eventManager: unknown,
|
||||
_getActiveCamera: unknown,
|
||||
_setupCamera: unknown,
|
||||
_setupGizmo: unknown,
|
||||
getCurrentCapabilities: () => unknown,
|
||||
getBoundsFromAdapter: (model: unknown) => unknown,
|
||||
disposeModelViaAdapter: (model: unknown) => unknown,
|
||||
getDefaultCameraPose: () => unknown
|
||||
) {
|
||||
this.getCurrentCapabilities = getCurrentCapabilities
|
||||
this.getBoundsFromAdapter = getBoundsFromAdapter
|
||||
this.disposeModelViaAdapter = disposeModelViaAdapter
|
||||
this.getDefaultCameraPose = getDefaultCameraPose
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('./LoaderManager', () => ({
|
||||
LoaderManager: class {
|
||||
adapterRefArg: unknown
|
||||
constructor(
|
||||
_modelManager: unknown,
|
||||
_eventManager: unknown,
|
||||
_adapters: unknown,
|
||||
adapterRef: unknown
|
||||
) {
|
||||
this.adapterRefArg = adapterRef
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('./RecordingManager', () => ({
|
||||
RecordingManager: class {}
|
||||
}))
|
||||
|
||||
vi.mock('./AnimationManager', () => ({
|
||||
AnimationManager: class {}
|
||||
}))
|
||||
|
||||
vi.mock('./GizmoManager', () => ({
|
||||
GizmoManager: class {
|
||||
setupForModel = vi.fn()
|
||||
getTransform = vi.fn(() => ({}))
|
||||
isEnabled = vi.fn(() => false)
|
||||
getMode = vi.fn(() => 'translate')
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('./Load3d', () => ({
|
||||
default: class {
|
||||
deps: unknown
|
||||
options: unknown
|
||||
constructor(_container: unknown, deps: unknown, options: unknown) {
|
||||
this.deps = deps
|
||||
this.options = options
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
type FakeLoaderManager = { adapterRefArg: { current: ModelAdapter | null } }
|
||||
type FakeSceneModelManager = {
|
||||
getCurrentCapabilities: () => unknown
|
||||
getBoundsFromAdapter: (model: unknown) => unknown
|
||||
disposeModelViaAdapter: (model: unknown) => void
|
||||
getDefaultCameraPose: () => unknown
|
||||
}
|
||||
type FakeLoad3d = {
|
||||
deps: {
|
||||
adapterRef: { current: ModelAdapter | null }
|
||||
loaderManager: FakeLoaderManager
|
||||
modelManager: FakeSceneModelManager
|
||||
}
|
||||
options: unknown
|
||||
}
|
||||
|
||||
function createContainer(): HTMLElement {
|
||||
const container = document.createElement('div')
|
||||
// Stub appendChild — we only care that one was called, not what was attached.
|
||||
container.appendChild = vi.fn().mockReturnValue(container)
|
||||
return container
|
||||
}
|
||||
|
||||
function makeAdapter(overrides: Partial<ModelAdapter> = {}): ModelAdapter {
|
||||
return {
|
||||
kind: 'mesh',
|
||||
extensions: [],
|
||||
capabilities: DEFAULT_MODEL_CAPABILITIES,
|
||||
load: vi.fn().mockResolvedValue(null),
|
||||
...overrides
|
||||
} satisfies ModelAdapter
|
||||
}
|
||||
|
||||
describe('createLoad3d', () => {
|
||||
it('constructs the renderer with alpha + antialias and appends it to the container', () => {
|
||||
rendererCtor.mockClear()
|
||||
const container = createContainer()
|
||||
|
||||
createLoad3d(container)
|
||||
|
||||
expect(rendererCtor).toHaveBeenCalledWith({ alpha: true, antialias: true })
|
||||
expect(container.appendChild).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('forwards Load3DOptions to the Load3d constructor', () => {
|
||||
const container = createContainer()
|
||||
const options = { width: 640, height: 480, isViewerMode: true }
|
||||
|
||||
const instance = createLoad3d(container, options) as unknown as FakeLoad3d
|
||||
|
||||
expect(instance.options).toEqual(options)
|
||||
})
|
||||
|
||||
it('shares one AdapterRef between LoaderManager and SceneModelManager lambdas', () => {
|
||||
const container = createContainer()
|
||||
const instance = createLoad3d(container) as unknown as FakeLoad3d
|
||||
|
||||
const adapterRef = instance.deps.adapterRef
|
||||
expect(adapterRef.current).toBeNull()
|
||||
|
||||
const loaderRef = instance.deps.loaderManager.adapterRefArg
|
||||
expect(loaderRef).toBe(adapterRef)
|
||||
})
|
||||
|
||||
describe('SceneModelManager capability lambdas (default — no adapter loaded)', () => {
|
||||
it('getCurrentCapabilities falls back to DEFAULT_MODEL_CAPABILITIES', () => {
|
||||
const instance = createLoad3d(createContainer()) as unknown as FakeLoad3d
|
||||
|
||||
expect(instance.deps.modelManager.getCurrentCapabilities()).toEqual(
|
||||
DEFAULT_MODEL_CAPABILITIES
|
||||
)
|
||||
})
|
||||
|
||||
it('getBoundsFromAdapter returns null', () => {
|
||||
const instance = createLoad3d(createContainer()) as unknown as FakeLoad3d
|
||||
expect(
|
||||
instance.deps.modelManager.getBoundsFromAdapter({} as never)
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('disposeModelViaAdapter is a no-op', () => {
|
||||
const instance = createLoad3d(createContainer()) as unknown as FakeLoad3d
|
||||
expect(() =>
|
||||
instance.deps.modelManager.disposeModelViaAdapter({} as never)
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
it('getDefaultCameraPose returns null', () => {
|
||||
const instance = createLoad3d(createContainer()) as unknown as FakeLoad3d
|
||||
expect(instance.deps.modelManager.getDefaultCameraPose()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('SceneModelManager capability lambdas (after adapter is published)', () => {
|
||||
function withAdapter(adapter: ModelAdapter) {
|
||||
const instance = createLoad3d(createContainer()) as unknown as FakeLoad3d
|
||||
instance.deps.adapterRef.current = adapter
|
||||
return instance
|
||||
}
|
||||
|
||||
it('getCurrentCapabilities reads the published adapter capabilities', () => {
|
||||
const caps: ModelAdapterCapabilities = {
|
||||
...DEFAULT_MODEL_CAPABILITIES,
|
||||
gizmoTransform: false,
|
||||
materialModes: []
|
||||
}
|
||||
const instance = withAdapter(makeAdapter({ capabilities: caps }))
|
||||
|
||||
expect(instance.deps.modelManager.getCurrentCapabilities()).toBe(caps)
|
||||
})
|
||||
|
||||
it('getBoundsFromAdapter delegates to adapter.computeBounds', () => {
|
||||
const computeBounds = vi.fn().mockReturnValue('bbox-result')
|
||||
const instance = withAdapter(makeAdapter({ computeBounds }))
|
||||
const model = { fake: 'model' }
|
||||
|
||||
const result = instance.deps.modelManager.getBoundsFromAdapter(
|
||||
model as never
|
||||
)
|
||||
|
||||
expect(computeBounds).toHaveBeenCalledWith(model)
|
||||
expect(result).toBe('bbox-result')
|
||||
})
|
||||
|
||||
it('getBoundsFromAdapter returns null when adapter has no computeBounds', () => {
|
||||
const instance = withAdapter(makeAdapter())
|
||||
expect(
|
||||
instance.deps.modelManager.getBoundsFromAdapter({} as never)
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('disposeModelViaAdapter delegates to adapter.disposeModel', () => {
|
||||
const disposeModel = vi.fn()
|
||||
const instance = withAdapter(makeAdapter({ disposeModel }))
|
||||
const model = { fake: 'model' }
|
||||
|
||||
instance.deps.modelManager.disposeModelViaAdapter(model as never)
|
||||
|
||||
expect(disposeModel).toHaveBeenCalledWith(model)
|
||||
})
|
||||
|
||||
it('getDefaultCameraPose delegates to adapter.defaultCameraPose', () => {
|
||||
const pose = { size: { x: 5 }, center: { x: 0 } }
|
||||
const defaultCameraPose = vi.fn().mockReturnValue(pose)
|
||||
const instance = withAdapter(makeAdapter({ defaultCameraPose }))
|
||||
|
||||
expect(instance.deps.modelManager.getDefaultCameraPose()).toBe(pose)
|
||||
expect(defaultCameraPose).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,144 +0,0 @@
|
||||
import * as THREE from 'three'
|
||||
|
||||
import { AnimationManager } from './AnimationManager'
|
||||
import { CameraManager } from './CameraManager'
|
||||
import { ControlsManager } from './ControlsManager'
|
||||
import { EventManager } from './EventManager'
|
||||
import { GizmoManager } from './GizmoManager'
|
||||
import { HDRIManager } from './HDRIManager'
|
||||
import { LightingManager } from './LightingManager'
|
||||
import Load3d from './Load3d'
|
||||
import type { Load3dDeps } from './Load3d'
|
||||
import { LoaderManager } from './LoaderManager'
|
||||
import { createAdapterRef, DEFAULT_MODEL_CAPABILITIES } from './ModelAdapter'
|
||||
import { RecordingManager } from './RecordingManager'
|
||||
import { SceneManager } from './SceneManager'
|
||||
import { SceneModelManager } from './SceneModelManager'
|
||||
import { ViewHelperManager } from './ViewHelperManager'
|
||||
import type { Load3DOptions } from './interfaces'
|
||||
|
||||
function createRenderer(container: Element | HTMLElement): THREE.WebGLRenderer {
|
||||
const renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true })
|
||||
renderer.setSize(300, 300)
|
||||
renderer.setClearColor(0x282828)
|
||||
renderer.autoClear = false
|
||||
renderer.outputColorSpace = THREE.SRGBColorSpace
|
||||
renderer.domElement.classList.add(
|
||||
'absolute',
|
||||
'inset-0',
|
||||
'h-full',
|
||||
'w-full',
|
||||
'outline-none'
|
||||
)
|
||||
container.appendChild(renderer.domElement)
|
||||
return renderer
|
||||
}
|
||||
|
||||
function buildLoad3dDeps(container: Element | HTMLElement): Load3dDeps {
|
||||
const renderer = createRenderer(container)
|
||||
const eventManager = new EventManager()
|
||||
// Shared mutable handle: LoaderManager writes the active adapter on each
|
||||
// load; SceneModelManager reads it for capability/bounds/dispose lookups
|
||||
// without depending on construction order.
|
||||
const adapterRef = createAdapterRef()
|
||||
|
||||
let cameraManager: CameraManager
|
||||
let controlsManager: ControlsManager
|
||||
let gizmoManager: GizmoManager
|
||||
|
||||
const getActiveCamera = (): THREE.Camera => cameraManager.activeCamera
|
||||
const getControls = () => controlsManager.controls
|
||||
|
||||
const sceneManager = new SceneManager(
|
||||
renderer,
|
||||
getActiveCamera,
|
||||
getControls,
|
||||
eventManager
|
||||
)
|
||||
|
||||
cameraManager = new CameraManager(renderer, eventManager)
|
||||
controlsManager = new ControlsManager(
|
||||
renderer,
|
||||
cameraManager.activeCamera,
|
||||
eventManager
|
||||
)
|
||||
cameraManager.setControls(controlsManager.controls)
|
||||
|
||||
const lightingManager = new LightingManager(sceneManager.scene, eventManager)
|
||||
const hdriManager = new HDRIManager(
|
||||
sceneManager.scene,
|
||||
renderer,
|
||||
eventManager
|
||||
)
|
||||
const viewHelperManager = new ViewHelperManager(
|
||||
renderer,
|
||||
getActiveCamera,
|
||||
getControls,
|
||||
eventManager
|
||||
)
|
||||
|
||||
const modelManager = new SceneModelManager(
|
||||
sceneManager.scene,
|
||||
renderer,
|
||||
eventManager,
|
||||
getActiveCamera,
|
||||
(size, center) => cameraManager.setupForModel(size, center),
|
||||
(model) => gizmoManager.setupForModel(model),
|
||||
() => adapterRef.current?.capabilities ?? DEFAULT_MODEL_CAPABILITIES,
|
||||
(model) => adapterRef.current?.computeBounds?.(model) ?? null,
|
||||
(model) => adapterRef.current?.disposeModel?.(model),
|
||||
() => adapterRef.current?.defaultCameraPose?.() ?? null
|
||||
)
|
||||
|
||||
const loaderManager = new LoaderManager(
|
||||
modelManager,
|
||||
eventManager,
|
||||
undefined,
|
||||
adapterRef
|
||||
)
|
||||
const recordingManager = new RecordingManager(
|
||||
sceneManager.scene,
|
||||
renderer,
|
||||
eventManager
|
||||
)
|
||||
const animationManager = new AnimationManager(eventManager)
|
||||
|
||||
gizmoManager = new GizmoManager(
|
||||
sceneManager.scene,
|
||||
renderer,
|
||||
controlsManager.controls,
|
||||
getActiveCamera,
|
||||
() => {
|
||||
const transform = gizmoManager.getTransform()
|
||||
eventManager.emitEvent('gizmoTransformChange', {
|
||||
...transform,
|
||||
enabled: gizmoManager.isEnabled(),
|
||||
mode: gizmoManager.getMode()
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
renderer,
|
||||
eventManager,
|
||||
sceneManager,
|
||||
cameraManager,
|
||||
controlsManager,
|
||||
lightingManager,
|
||||
hdriManager,
|
||||
viewHelperManager,
|
||||
loaderManager,
|
||||
modelManager,
|
||||
recordingManager,
|
||||
animationManager,
|
||||
gizmoManager,
|
||||
adapterRef
|
||||
}
|
||||
}
|
||||
|
||||
export function createLoad3d(
|
||||
container: Element | HTMLElement,
|
||||
options?: Load3DOptions
|
||||
): Load3d {
|
||||
return new Load3d(container, buildLoad3dDeps(container), options)
|
||||
}
|
||||
@@ -107,13 +107,15 @@ useExtensionService().registerExtension({
|
||||
|
||||
if (isAssetPreviewSupported()) {
|
||||
const filename = fileInfo.filename ?? ''
|
||||
|
||||
void load3d
|
||||
.whenLoadIdle()
|
||||
.then(() => load3d.captureThumbnail(256, 256))
|
||||
.then((dataUrl) => fetch(dataUrl).then((r) => r.blob()))
|
||||
.then((blob) => persistThumbnail(filename, blob))
|
||||
.catch(() => {})
|
||||
const onModelLoaded = () => {
|
||||
load3d.removeEventListener('modelLoadingEnd', onModelLoaded)
|
||||
load3d
|
||||
.captureThumbnail(256, 256)
|
||||
.then((dataUrl) => fetch(dataUrl).then((r) => r.blob()))
|
||||
.then((blob) => persistThumbnail(filename, blob))
|
||||
.catch(() => {})
|
||||
}
|
||||
load3d.addEventListener('modelLoadingEnd', onModelLoaded)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -844,15 +844,6 @@ export class LGraph
|
||||
return this.elapsed_time
|
||||
}
|
||||
|
||||
/**
|
||||
* Increments the internal version counter.
|
||||
* Currently only read for debug display in {@link LGraphCanvas.renderInfo}.
|
||||
* Centralized so a future VersionSystem can intercept, batch, or replace it.
|
||||
*/
|
||||
incrementVersion(): void {
|
||||
this._version++
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Will be removed in 0.9
|
||||
* Sends an event to all the nodes, useful to trigger stuff
|
||||
@@ -966,7 +957,7 @@ export class LGraph
|
||||
this.setDirtyCanvas(true)
|
||||
this.change()
|
||||
node.graph = this
|
||||
this.incrementVersion()
|
||||
this._version++
|
||||
return
|
||||
}
|
||||
|
||||
@@ -999,7 +990,7 @@ export class LGraph
|
||||
}
|
||||
|
||||
node.graph = this
|
||||
this.incrementVersion()
|
||||
this._version++
|
||||
|
||||
// Register all widgets with the WidgetValueStore now that node has a
|
||||
// valid ID and graph reference.
|
||||
@@ -1052,7 +1043,7 @@ export class LGraph
|
||||
this._groups.splice(index, 1)
|
||||
}
|
||||
node.graph = undefined
|
||||
this.incrementVersion()
|
||||
this._version++
|
||||
this.setDirtyCanvas(true, true)
|
||||
this.change()
|
||||
return
|
||||
@@ -1119,7 +1110,7 @@ export class LGraph
|
||||
node.onRemoved?.()
|
||||
|
||||
node.graph = null
|
||||
this.incrementVersion()
|
||||
this._version++
|
||||
|
||||
// remove from canvas render
|
||||
const { list_of_graphcanvas } = this
|
||||
@@ -2729,7 +2720,7 @@ export class LGraph
|
||||
this.updateExecutionOrder()
|
||||
|
||||
this.onConfigure?.(data)
|
||||
this.incrementVersion()
|
||||
this._version++
|
||||
|
||||
// Ensure the primary canvas is set to the correct graph
|
||||
const { primaryCanvas } = this
|
||||
|
||||
@@ -2223,7 +2223,6 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
if (this.state.ghostNodeId != null) {
|
||||
if (e.button === 0) this.finalizeGhostPlacement(false)
|
||||
if (e.button === 2) this.finalizeGhostPlacement(true)
|
||||
this.canvas.focus()
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
return
|
||||
@@ -3082,7 +3081,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
if (oldValue != widget.value) {
|
||||
node.onWidgetChanged?.(widget.name, widget.value, oldValue, widget)
|
||||
if (!node.graph) throw new NullGraphError()
|
||||
node.graph.incrementVersion()
|
||||
node.graph._version++
|
||||
}
|
||||
|
||||
// Clean up state var
|
||||
@@ -3680,10 +3679,6 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
}
|
||||
|
||||
this.state.ghostNodeId = node.id
|
||||
this.dispatchEvent('litegraph:ghost-placement', {
|
||||
active: true,
|
||||
nodeId: node.id
|
||||
})
|
||||
|
||||
this.deselectAll()
|
||||
this.select(node)
|
||||
@@ -3714,10 +3709,6 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
|
||||
this.state.ghostNodeId = null
|
||||
this.isDragging = false
|
||||
this.dispatchEvent('litegraph:ghost-placement', {
|
||||
active: false,
|
||||
nodeId
|
||||
})
|
||||
this._autoPan?.stop()
|
||||
this._autoPan = null
|
||||
|
||||
@@ -7897,7 +7888,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
}
|
||||
node.properties[property] = value
|
||||
if (node.graph) {
|
||||
node.graph.incrementVersion()
|
||||
node.graph._version++
|
||||
}
|
||||
node.onPropertyChanged?.(property, value)
|
||||
options.onclose?.()
|
||||
|
||||
@@ -830,7 +830,7 @@ export class LGraphNode
|
||||
*/
|
||||
configure(info: ISerialisedNode): void {
|
||||
if (this.graph) {
|
||||
this.graph.incrementVersion()
|
||||
this.graph._version++
|
||||
}
|
||||
if (info.id === -1) info.id = this.id
|
||||
for (const j in info) {
|
||||
@@ -2989,7 +2989,7 @@ export class LGraphNode
|
||||
}
|
||||
}
|
||||
}
|
||||
graph.incrementVersion()
|
||||
graph._version++
|
||||
|
||||
// link has been created now, so its updated
|
||||
this.onConnectionsChange?.(
|
||||
@@ -3138,7 +3138,7 @@ export class LGraphNode
|
||||
|
||||
// remove the link from the links pool
|
||||
link_info.disconnect(graph, 'input')
|
||||
graph.incrementVersion()
|
||||
graph._version++
|
||||
|
||||
// link_info hasn't been modified so its ok
|
||||
target.onConnectionsChange?.(
|
||||
@@ -3176,7 +3176,7 @@ export class LGraphNode
|
||||
}
|
||||
|
||||
const target = graph.getNodeById(link_info.target_id)
|
||||
graph.incrementVersion()
|
||||
graph._version++
|
||||
|
||||
if (target) {
|
||||
const input = target.inputs[link_info.target_slot]
|
||||
@@ -3304,7 +3304,7 @@ export class LGraphNode
|
||||
}
|
||||
|
||||
link_info.disconnect(graph, keepReroutes ? 'output' : undefined)
|
||||
if (graph) graph.incrementVersion()
|
||||
if (graph) graph._version++
|
||||
|
||||
this.onConnectionsChange?.(
|
||||
NodeSlotType.INPUT,
|
||||
@@ -3539,7 +3539,7 @@ export class LGraphNode
|
||||
collapse(force?: boolean): void {
|
||||
if (!this.collapsible && !force) return
|
||||
if (!this.graph) throw new NullGraphError()
|
||||
this.graph.incrementVersion()
|
||||
this.graph._version++
|
||||
this.flags.collapsed = !this.flags.collapsed
|
||||
this.setDirtyCanvas(true, true)
|
||||
}
|
||||
@@ -3550,7 +3550,7 @@ export class LGraphNode
|
||||
toggleAdvanced() {
|
||||
if (!this.hasAdvancedWidgets()) return
|
||||
if (!this.graph) throw new NullGraphError()
|
||||
this.graph.incrementVersion()
|
||||
this.graph._version++
|
||||
this.showAdvanced = !this.showAdvanced
|
||||
this.expandToFitContent()
|
||||
this.setDirtyCanvas(true, true)
|
||||
@@ -3567,7 +3567,7 @@ export class LGraphNode
|
||||
pin(v?: boolean): void {
|
||||
if (!this.graph) throw new NullGraphError()
|
||||
|
||||
this.graph.incrementVersion()
|
||||
this.graph._version++
|
||||
this.flags.pinned = v ?? !this.flags.pinned
|
||||
this.resizable = !this.pinned
|
||||
if (!this.pinned) this.flags.pinned = undefined
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { LGraph } from '@/lib/litegraph/src/LGraph'
|
||||
import type { LGraphButton } from '@/lib/litegraph/src/LGraphButton'
|
||||
import type { LGraphGroup } from '@/lib/litegraph/src/LGraphGroup'
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { ConnectingLink } from '@/lib/litegraph/src/interfaces'
|
||||
import type { Subgraph } from '@/lib/litegraph/src/subgraph/Subgraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
@@ -53,10 +53,4 @@ export interface LGraphCanvasEventMap {
|
||||
node: LGraphNode
|
||||
button: LGraphButton
|
||||
}
|
||||
|
||||
/** Ghost placement mode has started or ended. */
|
||||
'litegraph:ghost-placement': {
|
||||
active: boolean
|
||||
nodeId: NodeId
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,7 +134,7 @@ export class SubgraphInput extends SubgraphSlot {
|
||||
}
|
||||
}
|
||||
}
|
||||
subgraph.incrementVersion()
|
||||
subgraph._version++
|
||||
|
||||
node.onConnectionsChange?.(NodeSlotType.INPUT, inputIndex, true, link, slot)
|
||||
|
||||
|
||||
@@ -187,7 +187,7 @@ export class SubgraphInputNode
|
||||
|
||||
const subgraphInputIndex = link.origin_slot
|
||||
link.disconnect(subgraph, 'output')
|
||||
subgraph.incrementVersion()
|
||||
subgraph._version++
|
||||
|
||||
const subgraphInput = this.slots.at(subgraphInputIndex)
|
||||
if (!subgraphInput) {
|
||||
|
||||
@@ -258,71 +258,4 @@ describe('SubgraphNode multi-instance widget isolation', () => {
|
||||
const serialized = instance.serialize()
|
||||
expect(serialized.widgets_values).toBeUndefined()
|
||||
})
|
||||
|
||||
// it.fails pins the open #10849 SubgraphNode.configure regression on Main;
|
||||
// drop the marker once the inline-proxyWidgets-state fix lands.
|
||||
it.fails('falls back to source widget value when proxyWidgets is in legacy 2-tuple shape (regression for #10849)', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
|
||||
const SOURCE_DEFAULT = 42
|
||||
const LEGACY_NOISE = 999
|
||||
|
||||
const { node } = createNodeWithWidget('TestNode', SOURCE_DEFAULT)
|
||||
subgraph.add(node)
|
||||
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
|
||||
|
||||
const instance = createTestSubgraphNode(subgraph, { id: 801 })
|
||||
instance.configure({
|
||||
id: 801,
|
||||
type: subgraph.id,
|
||||
pos: [100, 100],
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
mode: 0,
|
||||
order: 0,
|
||||
flags: {},
|
||||
properties: { proxyWidgets: [['-1', 'widget']] },
|
||||
widgets_values: [LEGACY_NOISE]
|
||||
})
|
||||
|
||||
const widget = instance.widgets?.[0]
|
||||
expect(widget?.value).toBe(SOURCE_DEFAULT)
|
||||
expect(widget?.serializeValue?.(instance, 0)).toBe(SOURCE_DEFAULT)
|
||||
})
|
||||
|
||||
it.fails('does not corrupt unbound promoted widgets when widgets_values length mismatches view count (regression for #10849)', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
|
||||
const SOURCE_DEFAULT = 42
|
||||
const LEGACY_NOISE_A = 111
|
||||
const LEGACY_NOISE_B = 222
|
||||
|
||||
const { node } = createNodeWithWidget('TestNode', SOURCE_DEFAULT)
|
||||
subgraph.add(node)
|
||||
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
|
||||
|
||||
const instance = createTestSubgraphNode(subgraph, { id: 802 })
|
||||
instance.configure({
|
||||
id: 802,
|
||||
type: subgraph.id,
|
||||
pos: [100, 100],
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
mode: 0,
|
||||
order: 0,
|
||||
flags: {},
|
||||
properties: { proxyWidgets: [['-1', 'widget']] },
|
||||
widgets_values: [LEGACY_NOISE_A, LEGACY_NOISE_B]
|
||||
})
|
||||
|
||||
const widget = instance.widgets?.[0]
|
||||
expect(widget?.value).toBe(SOURCE_DEFAULT)
|
||||
expect(widget?.value).not.toBe(LEGACY_NOISE_A)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -99,7 +99,7 @@ export class SubgraphOutput extends SubgraphSlot {
|
||||
}
|
||||
}
|
||||
}
|
||||
subgraph.incrementVersion()
|
||||
subgraph._version++
|
||||
|
||||
node.onConnectionsChange?.(
|
||||
NodeSlotType.OUTPUT,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user