Compare commits

...

6 Commits

Author SHA1 Message Date
ComfyUI Wiki
965cb0db7e Add png and ico favicon 2026-05-23 02:34:55 +08:00
ComfyUI Wiki
eae19b27ee Update favicon progress images 2026-05-22 10:18:07 +08:00
ComfyUI Wiki
4b78f3401a Update favicon and favicon progress with new logo 2026-05-22 01:32:54 +08:00
Terry Jia
52d77e6ee0 chore: upgrade sparkjs to 2.x and three to 0.184 (#12396)
## Summary
- Spark 2.x requires SparkRenderer in scene tree; add it in SceneManager
and protect it in clearModel so model reloads don't dispose the splat
renderer.
- three 0.184 OrbitControls listens on ownerDocument; drop redundant
pointermove/up .stop in Load3D containers so the document listener can
receive events.
- Narrow Texture.image type for 0.184 strict typing.
2026-05-21 10:06:20 -04:00
jaeone94
f1f65cff61 feat: select top asset widget FormDropdown result on Enter (#12209)
## Summary

Allow asset/media FormDropdown searches to select the top filtered
result when the user presses Enter. This covers image, video, audio,
mesh, model-like asset selects, and other `WidgetSelectDropdown`-backed
media widgets.

## Implementation Scope

This PR implements a **top-result Enter shortcut** for the custom
asset/media dropdown path only:

- In scope: `WidgetSelectDropdown` -> `FormDropdown` asset/media
widgets.
- In scope: while the dropdown is open, single-select, and the search
text is non-empty, the first current search result becomes the Enter
candidate.
- In scope: pressing Enter in the search input selects that
candidate/top result through the existing selection path.
- In scope: candidate feedback for this shortcut, including visual
candidate styling and a polite screen-reader announcement for the
current top result.
- In scope: stale async search protection, empty-query/no-result no-op
behavior, multi-select guard behavior, and focus return to the trigger
after Enter selection closes the menu.
- Out of scope: plain combo widgets (`WidgetSelectDefault` /
`SelectPlus`). That path is PrimeVue-based and should be handled
separately from this focused asset-widget PR.
- Out of scope: full combobox/listbox keyboard navigation, including
Tab-to-list focus, ArrowUp/ArrowDown candidate movement, Home/End
behavior, scroll-to-active-item behavior, and a full ARIA
combobox/listbox refactor.

Follow-up arrow-key navigation should validate the interaction model
separately. This PR keeps the candidate state narrow and localized so
that future work can either extend it into movable active-item state or
replace it as part of a fuller combobox/listbox implementation.

## Changes

- **What**: Added an explicit Enter event from `FormSearchInput`, routed
it through the FormDropdown menu actions, and selected the current top
search result in `FormDropdown`.
- **What**: Kept the existing `computedAsync` + debounced filtering path
for normal typing, while Enter performs a one-off search against the
latest input before selecting. Stale async Enter results are ignored if
the query or item source changes before resolution.
- **What**: Prevented closed FormDropdown state from treating the full
unfiltered list as current search results, limited Enter-to-select to
single-select dropdowns, and made empty search Enter a no-op.
- **What**: Returned focus to the dropdown trigger after single-select
selection closes the menu.
- **What**: Added candidate styling for the first current FormDropdown
result while a search query is active so the Enter target is visible to
users.
- **What**: Added a polite screen-reader announcement for the current
top result candidate.
- **What**: Fixed the FormDropdownMenuActions `baseModelSelected` model
default to use a `Set` factory instead of a shared instance.
- **What**: Added unit coverage for the search Enter event, FormDropdown
selection behavior, focus return, debounce/Enter behavior, stale async
Enter protection, empty-query no-op behavior, closed-state stale result
protection, multi-select guard behavior, and candidate announcement
behavior. Added App Mode E2E coverage for asset FormDropdown Enter
selection.
- **What**: Extracted reusable app-mode dropdown fixture helpers and
updated the existing FormDropdown clipping test to use the shared
helper.

## Review Focus

Please focus review on the asset/media FormDropdown path, especially
`getTopSearchResult()`, the single-select/empty-query guards, stale
async search protection, trigger focus return after selection, and
candidate feedback in grid/list layouts.

The plain combo path and full arrow-key navigation are intentionally
left for separate follow-up work.

## Screenshots (if applicable)



https://github.com/user-attachments/assets/3eb3456d-93a3-4959-91a3-188f8116ccc9



Validation performed:

- Latest final-commit validation:
- `pnpm test:unit
src/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.test.ts
src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdown.test.ts
src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdownMenuActions.test.ts
src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdownMenu.test.ts`
- Commit hook: `pnpm exec stylelint ...`, `pnpm exec oxfmt --write ...`,
`pnpm exec oxlint --type-aware --fix ...`, `pnpm exec eslint --cache
--fix ...`, `pnpm typecheck`
  - Push hook: `pnpm knip --cache`
  - `git diff --check`
- Earlier branch validation for this flow:
  - `pnpm install`
  - `pnpm typecheck:browser`
- `PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:5173
PLAYWRIGHT_SETUP_API_URL=http://localhost:8188 pnpm test:browser --
--project=chromium browser_tests/tests/appMode.spec.ts -g "Drag and
Drop|FormDropdown search Enter selects the top filtered item"
--reporter=list`
- `PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:5173
PLAYWRIGHT_SETUP_API_URL=http://localhost:8188 pnpm test:browser --
--project=chromium browser_tests/tests/appMode.spec.ts -g "FormDropdown
search Enter selects the top filtered item" --reporter=list`
- `PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:5173
PLAYWRIGHT_SETUP_API_URL=http://localhost:8188 pnpm test:browser --
--project=chromium browser_tests/tests/appModeDropdownClipping.spec.ts
-g "FormDropdown popup is not clipped" --reporter=list`
2026-05-21 02:26:26 +00:00
Alexander Brown
b0144db644 build: migrate pnpm config to v11 (#12195)
## Summary

Migrate pnpm configuration to the v11 layout and clean up stale v10-era
references.

## Changes

- **What**: Moves pnpm settings into `pnpm-workspace.yaml`, converts
build dependency policy to `allowBuilds`, removes stale workspace
`packageManager` pins, and updates global install commands in CI.
- **Dependencies**: No new dependencies.

## Review Focus

- Confirm pnpm v11 workspace settings match the former `.npmrc`
behavior.
- Confirm CI global install syntax is compatible with pnpm v11.

## Test Plan

- `pnpm install --frozen-lockfile`
- `pnpm exec oxfmt --check pnpm-workspace.yaml
packages/shared-frontend-utils/package.json
packages/registry-types/package.json packages/ingest-types/package.json
packages/design-system/package.json
.github/workflows/weekly-docs-check.yaml
.github/workflows/pr-claude-review.yaml`
- commit hook: `pnpm typecheck`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12195-build-migrate-pnpm-config-to-v11-35e6d73d36508116a821dbc71db94cd1)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-05-21 01:53:09 +00:00
47 changed files with 617 additions and 1130 deletions

View File

@@ -39,7 +39,7 @@ jobs:
- name: Install dependencies for analysis tools
run: |
pnpm install -g typescript @vue/compiler-sfc
pnpm add -g typescript @vue/compiler-sfc
- name: Run Claude PR Review
uses: anthropics/claude-code-action@ff34ce0ff04a470bd3fa56c1ef391c8f1c19f8e9 # v1.0.38

View File

@@ -40,11 +40,11 @@ jobs:
- name: Install dependencies for analysis tools
run: |
# Check if packages are already available locally
if ! pnpm list typescript @vue/compiler-sfc >/dev/null 2>&1; then
if ! pnpm list -g typescript @vue/compiler-sfc >/dev/null 2>&1; then
echo "Installing TypeScript and Vue compiler globally..."
pnpm install -g typescript @vue/compiler-sfc
pnpm add -g typescript @vue/compiler-sfc
else
echo "TypeScript and Vue compiler already available locally"
echo "TypeScript and Vue compiler already available globally"
fi
- name: Run Claude Documentation Review

3
.npmrc
View File

@@ -1,3 +0,0 @@
ignore-workspace-root-check=true
catalog-mode=prefer
public-hoist-pattern[]=@parcel/watcher

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 937 B

View File

@@ -72,6 +72,9 @@ const websiteJsonLd = {
<title>{title}</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="icon" href="/favicon.png" type="image/png" />
<link rel="icon" href="/favicon.ico" sizes="any" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="canonical" href={canonicalURL.href} />
<link rel="preconnect" href="https://www.googletagmanager.com" />
<link rel="dns-prefetch" href="https://www.googletagmanager.com" />

View File

@@ -2,10 +2,24 @@ import type { Locator } from '@playwright/test'
export class WidgetSelectDropdownFixture {
public readonly selection: Locator
public readonly trigger: Locator
constructor(public readonly root: Locator) {
this.trigger = root.locator('button:has(> span)').first()
this.selection = root.locator('button span span')
}
async open(): Promise<void> {
await this.trigger.click()
}
async searchAndSelectTop(popover: Locator, query: string): Promise<void> {
await this.open()
const searchInput = popover.getByRole('textbox')
await searchInput.fill(query)
await searchInput.press('Enter')
}
async selectedItem(): Promise<string> {
return await this.selection.innerText()
}

View File

@@ -1,6 +1,7 @@
import type { Locator, Page } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { WidgetSelectDropdownFixture } from '@e2e/fixtures/components/WidgetSelectDropdown'
/**
* Helper for interacting with widgets rendered in app mode (linear view).
@@ -24,6 +25,11 @@ export class AppModeWidgetHelper {
return this.container.locator(`[data-widget-key="${key}"]`)
}
/** Get a FormDropdown widget by its key (e.g. "10:image"). */
getSelectDropdown(key: string): WidgetSelectDropdownFixture {
return new WidgetSelectDropdownFixture(this.getWidgetItem(key))
}
/** Fill a textarea widget (e.g. CLIP Text Encode prompt). */
async fillTextarea(key: string, value: string) {
const widget = this.getWidgetItem(key)

View File

@@ -2,7 +2,6 @@ import {
comfyPageFixture as test,
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
import { WidgetSelectDropdownFixture } from '@e2e/fixtures/components/WidgetSelectDropdown'
import { TestIds } from '@e2e/fixtures/selectors'
test.describe('App mode usage', () => {
@@ -31,9 +30,7 @@ test.describe('App mode usage', () => {
await expect(loadImage).toBeVisible()
})
const imageInput = new WidgetSelectDropdownFixture(
comfyPage.appMode.linearWidgets.locator('.lg-node-widget')
)
const imageInput = comfyPage.appMode.widgets.getSelectDropdown('10:image')
await test.step('Enter app mode with image input', async () => {
await comfyPage.appMode.enterAppModeWithInputs([['10', 'image']])
@@ -107,6 +104,45 @@ test.describe('App mode usage', () => {
//verify values are consistent with litegraph
})
test('FormDropdown search Enter selects the top filtered item', async ({
comfyPage
}) => {
await comfyPage.appMode.enableLinearMode()
const loadImageNode = await comfyPage.nodeOps.addNode('LoadImage')
await comfyPage.nextFrame()
const fileComboWidget = await loadImageNode.getWidget(0)
const targetImage = String(await fileComboWidget.getValue())
const initialImage = 'not-selected.png'
await comfyPage.page.evaluate(
([nodeId, value]) => {
const node = window.app!.graph!.getNodeById(nodeId)
const widget = node?.widgets?.[0]
if (!widget) throw new Error(`Image widget not found: ${nodeId}`)
widget.value = value
},
[loadImageNode.id, initialImage] as const
)
await expect.poll(() => fileComboWidget.getValue()).toBe(initialImage)
await comfyPage.appMode.enterAppModeWithInputs([
[String(loadImageNode.id), 'image']
])
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
const imageInput = comfyPage.appMode.widgets.getSelectDropdown(
`${loadImageNode.id}:image`
)
const popover = comfyPage.appMode.imagePickerPopover
await expect(imageInput.root).toBeVisible()
await imageInput.searchAndSelectTop(popover, targetImage)
await expect(popover).toBeHidden()
await expect(imageInput.selection).toHaveText(targetImage)
await expect.poll(() => fileComboWidget.getValue()).toBe(targetImage)
})
test.describe('Mobile', { tag: ['@mobile'] }, () => {
test('panel navigation', async ({ comfyPage }) => {
const { mobile } = comfyPage.appMode

View File

@@ -131,13 +131,10 @@ test.describe('App mode dropdown clipping', { tag: '@ui' }, () => {
el.scrollTo({ top: el.scrollHeight, behavior: 'instant' })
)
// Click the FormDropdown trigger button for the image widget.
// The button emits 'select-click' which toggles the Popover.
const imageRow = widgetList.locator(
'div:has(> div > span:text-is("image"))'
const imageInput = comfyPage.appMode.widgets.getSelectDropdown(
`${loadImageId}:image`
)
const dropdownButton = imageRow.locator('button:has(> span)').first()
await dropdownButton.click()
await imageInput.open()
// The unstyled PrimeVue Popover renders with role="dialog".
// Locate the one containing the image grid (filter buttons like "All", "Inputs").

View File

@@ -37,7 +37,7 @@
"lint:desktop": "nx run @comfyorg/desktop-ui:lint",
"locale": "lobe-i18n locale",
"oxlint": "oxlint src browser_tests --type-aware",
"prepare": "husky || true && git config blame.ignoreRevsFile .git-blame-ignore-revs || true",
"prepare": "pnpm exec husky || true && git config blame.ignoreRevsFile .git-blame-ignore-revs || true",
"preview": "nx preview",
"storybook": "nx storybook",
"storybook:desktop": "nx run @comfyorg/desktop-ui:storybook",
@@ -113,7 +113,7 @@
"primevue": "catalog:",
"reka-ui": "catalog:",
"semver": "^7.7.2",
"three": "^0.170.0",
"three": "catalog:",
"tiptap-markdown": "^0.8.10",
"typegpu": "catalog:",
"vee-validate": "catalog:",
@@ -211,20 +211,7 @@
},
"engines": {
"node": "24.x",
"pnpm": ">=10"
"pnpm": ">=11"
},
"packageManager": "pnpm@10.33.0",
"pnpm": {
"overrides": {
"vite": "catalog:"
},
"ignoredBuiltDependencies": [
"@firebase/util",
"core-js",
"protobufjs",
"sharp",
"unrs-resolver",
"vue-demi"
]
}
"packageManager": "pnpm@11.1.1"
}

View File

@@ -20,7 +20,6 @@
"tailwindcss": "catalog:",
"typescript": "catalog:"
},
"packageManager": "pnpm@10.17.1",
"nx": {
"tags": [
"scope:shared",

View File

@@ -16,7 +16,6 @@
"devDependencies": {
"@hey-api/openapi-ts": "0.93.0"
},
"packageManager": "pnpm@10.17.1",
"nx": {
"tags": [
"scope:shared",

View File

@@ -6,7 +6,6 @@
"exports": {
".": "./src/comfyRegistryTypes.ts"
},
"packageManager": "pnpm@10.17.1",
"nx": {
"tags": [
"scope:shared",

View File

@@ -18,6 +18,5 @@
},
"devDependencies": {
"typescript": "catalog:"
},
"packageManager": "pnpm@10.17.1"
}
}

1116
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,11 @@ packages:
- apps/**
- packages/**
ignoreWorkspaceRootCheck: true
catalogMode: prefer
publicHoistPattern:
- '@parcel/watcher'
catalog:
'@alloc/quick-lru': ^5.2.0
'@astrojs/check': ^0.9.8
@@ -31,7 +36,7 @@ catalog:
'@primevue/themes': ^4.2.5
'@sentry/vite-plugin': ^4.6.0
'@sentry/vue': ^10.32.1
'@sparkjsdev/spark': ^0.1.10
'@sparkjsdev/spark': ^2.1.0
'@storybook/addon-docs': ^10.2.10
'@storybook/addon-mcp': 0.1.6
'@storybook/vue3': ^10.2.10
@@ -54,7 +59,7 @@ catalog:
'@types/jsdom': ^21.1.7
'@types/node': ^24.1.0
'@types/semver': ^7.7.0
'@types/three': ^0.170.0
'@types/three': ^0.184.1
'@vee-validate/zod': ^4.15.1
'@vercel/analytics': ^2.0.1
'@vitejs/plugin-vue': ^6.0.0
@@ -113,7 +118,7 @@ catalog:
storybook: ^10.2.10
stylelint: ^16.26.1
tailwindcss: ^4.3.0
three: ^0.170.0
three: ^0.184.0
tailwindcss-primeui: ^0.6.1
tsx: ^4.15.6
tw-animate-css: ^1.3.8
@@ -144,22 +149,20 @@ catalog:
cleanupUnusedCatalogs: true
ignoredBuiltDependencies:
- '@firebase/util'
- protobufjs
- unrs-resolver
- vue-demi
onlyBuiltDependencies:
- '@playwright/browser-chromium'
- '@playwright/browser-firefox'
- '@playwright/browser-webkit'
- '@sentry/cli'
- '@tailwindcss/oxide'
- esbuild
- nx
- oxc-resolver
allowBuilds:
'@firebase/util': false
'@sentry/cli': true
'@tailwindcss/oxide': true
core-js: false
esbuild: true
nx: true
oxc-resolver: true
protobufjs: false
sharp: false
unrs-resolver: false
vue-demi: false
overrides:
vite: 'catalog:'
'@tiptap/pm': 2.27.2
'@types/eslint': '-'

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 647 B

After

Width:  |  Height:  |  Size: 274 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 674 B

After

Width:  |  Height:  |  Size: 277 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 674 B

After

Width:  |  Height:  |  Size: 269 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 674 B

After

Width:  |  Height:  |  Size: 284 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 698 B

After

Width:  |  Height:  |  Size: 281 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 700 B

After

Width:  |  Height:  |  Size: 277 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 702 B

After

Width:  |  Height:  |  Size: 280 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 705 B

After

Width:  |  Height:  |  Size: 302 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 708 B

After

Width:  |  Height:  |  Size: 285 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 705 B

After

Width:  |  Height:  |  Size: 296 B

View File

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

View File

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

View File

@@ -25,17 +25,19 @@ type Searcher = NonNullable<ComponentProps<typeof AsyncSearchInput>['searcher']>
function renderSearch(
initialQuery: string = '',
searcher?: Searcher,
updateKey?: { value: unknown }
updateKey?: { value: unknown },
onEnter?: (event: KeyboardEvent) => void
) {
const query = ref(initialQuery)
const key = updateKey
const Harness = defineComponent({
components: { AsyncSearchInput },
setup: () => ({ query, searcher, key }),
setup: () => ({ query, searcher, key, onEnter }),
template: `<AsyncSearchInput
v-model="query"
:searcher="searcher"
:update-key="key"
@enter="onEnter"
/>`
})
const utils = render(Harness, { global: { plugins: [i18n] } })
@@ -63,6 +65,14 @@ describe('AsyncSearchInput', () => {
await user.type(screen.getByRole('textbox'), 'abc')
expect(query.value).toBe('abc')
})
it('emits enter when the user presses Enter in the textbox', async () => {
const onEnter = vi.fn()
renderSearch('', undefined, undefined, onEnter)
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
await user.type(screen.getByRole('textbox'), '{Enter}')
expect(onEnter).toHaveBeenCalledTimes(1)
})
})
describe('Clear button', () => {

View File

@@ -23,6 +23,9 @@ const {
debounceMaxWaitMs?: number
class?: HTMLAttributes['class']
}>()
const emit = defineEmits<{
enter: [event: KeyboardEvent]
}>()
const searchQuery = defineModel<string>({ default: '' })
@@ -62,6 +65,11 @@ function handleFocus(event: FocusEvent) {
target.select()
}
}
function handleKeydownEnter(event: KeyboardEvent) {
if (event.isComposing) return
emit('enter', event)
}
</script>
<template>
@@ -97,6 +105,7 @@ function handleFocus(event: FocusEvent) {
:placeholder="$t('g.searchPlaceholder', { subject: '' })"
:autofocus
@focus="handleFocus"
@keydown.enter="handleKeydownEnter"
/>
<button
v-if="searchQuery.trim().length > 0"

View File

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

View File

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

View File

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

View File

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

View File

@@ -2709,7 +2709,8 @@
"placeholderMesh": "Select mesh...",
"placeholderModel": "Select model...",
"placeholderUnknown": "Select media...",
"maxSelectionReached": "Maximum selection limit reached"
"maxSelectionReached": "Maximum selection limit reached",
"topResult": "Top result: {result}"
},
"valueControl": {
"header": {

View File

@@ -1,6 +1,7 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import PrimeVue from 'primevue/config'
import { ref } from 'vue'
import { createI18n } from 'vue-i18n'
import { describe, expect, it, vi } from 'vitest'
@@ -31,16 +32,29 @@ const MockFormDropdownMenu = {
'showOwnershipFilter',
'ownershipOptions',
'showBaseModelFilter',
'baseModelOptions'
'baseModelOptions',
'candidateIndex',
'candidateLabel'
],
template:
'<div class="mock-menu" data-testid="dropdown-menu" :data-items="JSON.stringify(items)" />'
template: `<div class="mock-menu" data-testid="dropdown-menu" :data-candidate-index="candidateIndex" :data-candidate-label="candidateLabel ?? ''" :data-items="JSON.stringify(items)">
<button type="button" @click="$emit('search-enter')">Search enter</button>
</div>`
}
const MockFormDropdownInput = {
name: 'FormDropdownInput',
setup(
_: unknown,
{ expose }: { expose: (exposed: { focus: () => void }) => void }
) {
const triggerButton = ref<HTMLButtonElement>()
expose({
focus: () => triggerButton.value?.focus()
})
return { triggerButton }
},
template:
'<button class="mock-dropdown-trigger" @click="$emit(\'select-click\', $event)">Open</button>'
'<button ref="triggerButton" class="mock-dropdown-trigger" @click="$emit(\'select-click\', $event)">Open</button>'
}
const MockPopover = {
@@ -54,7 +68,9 @@ interface MountDropdownOptions {
items: FormDropdownItem[],
onCleanup: (cleanupFn: () => void) => void
) => Promise<FormDropdownItem[]>
multiple?: boolean | number
searchQuery?: string
onUpdateSelected?: (selected: Set<string>) => void
}
function flushPromises() {
@@ -67,7 +83,13 @@ function mountDropdown(
) {
const user = userEvent.setup()
const result = render(FormDropdown, {
props: { items, ...options },
props: {
items,
multiple: options.multiple,
searcher: options.searcher,
searchQuery: options.searchQuery,
'onUpdate:selected': options.onUpdateSelected
},
global: {
plugins: [PrimeVue, i18n],
stubs: {
@@ -85,6 +107,21 @@ function getMenuItems(): FormDropdownItem[] {
return JSON.parse(menuEl.getAttribute('data-items') ?? '[]')
}
function getCandidateIndex(): number {
const menuEl = screen.getByTestId('dropdown-menu')
return Number(menuEl.getAttribute('data-candidate-index'))
}
function getCandidateLabel(): string {
const menuEl = screen.getByTestId('dropdown-menu')
return menuEl.getAttribute('data-candidate-label') ?? ''
}
async function openDropdown(user: ReturnType<typeof userEvent.setup>) {
await user.click(screen.getByRole('button', { name: 'Open' }))
await flushPromises()
}
describe('FormDropdown', () => {
describe('filteredItems updates when items prop changes', () => {
it('updates displayed items when items prop changes', async () => {
@@ -174,17 +211,170 @@ describe('FormDropdown', () => {
sourceItems.filter((item) => item.id === 'keep')
)
const { container, user } = mountDropdown(
const { user } = mountDropdown(
[createItem('keep', 'alpha'), createItem('drop', 'beta')],
{ searcher }
)
await flushPromises()
// eslint-disable-next-line testing-library/no-node-access
await user.click(container.querySelector('.mock-dropdown-trigger')!)
await flushPromises()
await openDropdown(user)
expect(searcher).toHaveBeenCalled()
expect(getMenuItems().map((item) => item.id)).toEqual(['keep'])
})
it('selects the top matching item when Enter is pressed in search', async () => {
const onUpdateSelected = vi.fn()
const { user } = mountDropdown(
[createItem('beta', 'beta.ckpt'), createItem('alpha', 'alpha.ckpt')],
{ searchQuery: 'alp', onUpdateSelected }
)
await openDropdown(user)
await user.click(screen.getByRole('button', { name: 'Search enter' }))
await flushPromises()
expect(onUpdateSelected).toHaveBeenCalledWith(new Set(['alpha']))
expect(screen.getByRole('button', { name: 'Open' })).toHaveFocus()
})
it('does not select when Enter is pressed with an empty search query', async () => {
const onUpdateSelected = vi.fn()
const { user } = mountDropdown(
[createItem('beta', 'beta.ckpt'), createItem('alpha', 'alpha.ckpt')],
{ onUpdateSelected }
)
await openDropdown(user)
await user.click(screen.getByRole('button', { name: 'Search enter' }))
await flushPromises()
expect(onUpdateSelected).not.toHaveBeenCalled()
})
it('does not treat closed full-list items as current search results', async () => {
const onUpdateSelected = vi.fn()
const { user } = mountDropdown(
[createItem('beta', 'beta.ckpt'), createItem('alpha', 'alpha.ckpt')],
{ searchQuery: 'alp', onUpdateSelected }
)
await flushPromises()
expect(getCandidateIndex()).toBe(-1)
await user.click(screen.getByRole('button', { name: 'Search enter' }))
await flushPromises()
expect(onUpdateSelected).not.toHaveBeenCalled()
})
it('searches the latest query before selecting the top search result', async () => {
const onUpdateSelected = vi.fn()
const searcher = vi.fn(
async (query: string, sourceItems: FormDropdownItem[]) => {
if (query.trim() === '') return sourceItems
return sourceItems.filter((item) => item.name.includes(query))
}
)
const items = [
createItem('beta', 'beta.ckpt'),
createItem('alpha', 'alpha.ckpt')
]
const { rerender, user } = mountDropdown(items, {
searcher,
onUpdateSelected
})
await openDropdown(user)
await rerender({
items,
searcher,
searchQuery: 'alp',
'onUpdate:selected': onUpdateSelected
})
expect(getCandidateIndex()).toBe(-1)
await user.click(screen.getByRole('button', { name: 'Search enter' }))
await flushPromises()
expect(onUpdateSelected).toHaveBeenCalledWith(new Set(['alpha']))
expect(searcher).toHaveBeenCalledWith('alp', items, expect.any(Function))
})
it('provides the candidate label for screen reader announcement', async () => {
const { user } = mountDropdown(
[createItem('beta', 'beta.ckpt'), createItem('alpha', 'alpha.ckpt')],
{ searchQuery: 'alp' }
)
await openDropdown(user)
expect(getCandidateIndex()).toBe(0)
expect(getCandidateLabel()).toBe('alpha.ckpt')
})
it('does not select a stale result if the query changes before Enter search resolves', async () => {
const onUpdateSelected = vi.fn()
let resolveAlphaSearch: () => void = () => {}
const searcher = vi.fn((query: string, sourceItems: FormDropdownItem[]) => {
if (query === 'alp') {
return new Promise<FormDropdownItem[]>((resolve) => {
resolveAlphaSearch = () =>
resolve(sourceItems.filter((item) => item.name.includes(query)))
})
}
if (query.trim() === '') return Promise.resolve(sourceItems)
return Promise.resolve(
sourceItems.filter((item) => item.name.includes(query))
)
})
const items = [
createItem('beta', 'beta.ckpt'),
createItem('alpha', 'alpha.ckpt')
]
const { rerender, user } = mountDropdown(items, {
searcher,
onUpdateSelected
})
await openDropdown(user)
await rerender({
items,
searcher,
searchQuery: 'alp',
'onUpdate:selected': onUpdateSelected
})
await user.click(screen.getByRole('button', { name: 'Search enter' }))
await rerender({
items,
searcher,
searchQuery: 'bet',
'onUpdate:selected': onUpdateSelected
})
resolveAlphaSearch()
await flushPromises()
expect(searcher).toHaveBeenCalledWith('alp', items, expect.any(Function))
expect(onUpdateSelected).not.toHaveBeenCalled()
})
it('does not select a search result from multi-select dropdowns', async () => {
const onUpdateSelected = vi.fn()
const { user } = mountDropdown(
[createItem('beta', 'beta.ckpt'), createItem('alpha', 'alpha.ckpt')],
{ multiple: true, searchQuery: 'alp', onUpdateSelected }
)
await openDropdown(user)
expect(getCandidateIndex()).toBe(-1)
await user.click(screen.getByRole('button', { name: 'Search enter' }))
await flushPromises()
expect(onUpdateSelected).not.toHaveBeenCalled()
})
})

View File

@@ -94,28 +94,47 @@ const isOpen = defineModel<boolean>('isOpen', { default: false })
const toastStore = useToastStore()
const popoverRef = ref<InstanceType<typeof Popover>>()
const triggerRef = useTemplateRef('triggerRef')
const triggerAnchorRef = useTemplateRef<HTMLElement>('triggerAnchorRef')
const triggerRef =
useTemplateRef<InstanceType<typeof FormDropdownInput>>('triggerRef')
const displayedSearchQuery = ref('')
const isFiltering = ref(false)
const maxSelectable = computed(() => {
if (multiple === true) return Infinity
if (typeof multiple === 'number') return multiple
return 1
})
const isSingleSelect = computed(() => maxSelectable.value === 1)
const debouncedSearchQuery = refDebounced(searchQuery, 250, { maxWait: 1000 })
const filteredItems = computedAsync(async (onCancel) => {
if (!isOpen.value) {
return items
}
const filteredItems = computedAsync(
async (onCancel) => {
if (!isOpen.value) {
displayedSearchQuery.value = ''
return items
}
let cleanupFn: (() => void) | undefined
onCancel(() => cleanupFn?.())
const result = await searcher(debouncedSearchQuery.value, items, (cb) => {
cleanupFn = cb
})
return result
}, items)
const query = debouncedSearchQuery.value
let cancelled = false
let cleanupFn: (() => void) | undefined
onCancel(() => {
cancelled = true
cleanupFn?.()
})
const result = await searcher(query, items, (cb) => {
cleanupFn = cb
})
if (!cancelled) displayedSearchQuery.value = query
return result
},
items,
{
evaluating: isFiltering
}
)
const defaultSorter = computed<SortOption['sorter']>(() => {
const sorter = sortOptions.find((option) => option.id === 'default')?.sorter
@@ -135,6 +154,23 @@ const sortedItems = computed(() => {
return selectedSorter.value({ items: filteredItems.value }) || []
})
const isShowingCurrentSearchResults = computed(
() =>
isOpen.value &&
isSingleSelect.value &&
!isFiltering.value &&
searchQuery.value.trim() !== '' &&
displayedSearchQuery.value === searchQuery.value &&
sortedItems.value.length > 0
)
const candidateIndex = computed(() =>
isShowingCurrentSearchResults.value ? 0 : -1
)
const candidateLabel = computed(() => {
const candidate = sortedItems.value[candidateIndex.value]
return candidate?.label ?? candidate?.name
})
function internalIsSelected(item: FormDropdownItem, index: number): boolean {
return isSelected(selected.value, item, index)
@@ -142,17 +178,23 @@ function internalIsSelected(item: FormDropdownItem, index: number): boolean {
const toggleDropdown = (event: Event) => {
if (disabled) return
if (popoverRef.value && triggerRef.value) {
popoverRef.value.toggle?.(event, triggerRef.value)
if (popoverRef.value && triggerAnchorRef.value) {
popoverRef.value.toggle?.(event, triggerAnchorRef.value)
isOpen.value = !isOpen.value
}
}
const closeDropdown = () => {
function focusTrigger() {
triggerRef.value?.focus()
}
const closeDropdown = ({ restoreFocus = false } = {}) => {
if (popoverRef.value) {
popoverRef.value.hide?.()
isOpen.value = false
}
if (restoreFocus) focusTrigger()
}
function handleFileChange(event: Event) {
@@ -184,14 +226,47 @@ function handleSelection(item: FormDropdownItem, index: number) {
selected.value = new Set(sel)
if (maxSelectable.value === 1) {
closeDropdown()
closeDropdown({ restoreFocus: true })
}
}
async function getTopSearchResult() {
const query = searchQuery.value
if (query.trim() === '') return
const sourceItems = items
const matches =
isShowingCurrentSearchResults.value && displayedSearchQuery.value === query
? filteredItems.value
: await searcher(query, sourceItems, () => {})
if (query !== searchQuery.value || sourceItems !== items || !isOpen.value) {
return
}
return selectedSorter.value({ items: matches })?.[0]
}
async function selectTopSearchResult() {
try {
if (disabled || !isOpen.value || !isSingleSelect.value) return
const topResult = await getTopSearchResult()
if (!topResult) return
handleSelection(topResult, 0)
} catch (error) {
console.error('[FormDropdown] search selection failed', error)
}
}
function handleSearchEnter() {
void selectTopSearchResult()
}
</script>
<template>
<div ref="triggerRef">
<div ref="triggerAnchorRef">
<FormDropdownInput
ref="triggerRef"
:files
:is-open
:placeholder="placeholderText"
@@ -235,9 +310,12 @@ function handleSelection(item: FormDropdownItem, index: number) {
:base-model-options
:disabled
:items="sortedItems"
:candidate-index
:candidate-label
:is-selected="internalIsSelected"
:max-selectable
@close="closeDropdown"
@search-enter="handleSearchEnter"
@item-click="handleSelection"
/>
</Popover>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
@@ -40,6 +40,14 @@ const theButtonStyle = computed(() =>
selectedItems.value.length > 0 && 'text-text-primary'
)
)
const buttonRef = ref<HTMLButtonElement>()
function focus() {
buttonRef.value?.focus()
}
defineExpose({ focus })
</script>
<template>
@@ -51,6 +59,7 @@ const theButtonStyle = computed(() =>
"
>
<button
ref="buttonRef"
:class="
cn(
theButtonStyle,

View File

@@ -25,6 +25,8 @@ interface Props {
ownershipOptions?: OwnershipFilterOption[]
showBaseModelFilter?: boolean
baseModelOptions?: FilterOption[]
candidateIndex?: number
candidateLabel?: string
}
const {
@@ -35,10 +37,13 @@ const {
showOwnershipFilter,
ownershipOptions,
showBaseModelFilter,
baseModelOptions
baseModelOptions,
candidateIndex = -1,
candidateLabel
} = defineProps<Props>()
const emit = defineEmits<{
(e: 'item-click', item: FormDropdownItem, index: number): void
(e: 'search-enter'): void
}>()
const filterSelected = defineModel<string>('filterSelected')
@@ -130,6 +135,8 @@ const onWheel = (event: WheelEvent) => {
:ownership-options
:show-base-model-filter
:base-model-options
:candidate-label
@search-enter="emit('search-enter')"
/>
<div
v-if="items.length === 0"
@@ -155,6 +162,7 @@ const onWheel = (event: WheelEvent) => {
<template #item="{ item, index }">
<FormDropdownMenuItem
:index
:candidate="index === candidateIndex"
:selected="isSelected(item, index)"
:preview-url="item.preview_url ?? ''"
:name="item.name"

View File

@@ -71,6 +71,8 @@ type MenuProps = {
sortSelected?: string
ownershipSelected?: OwnershipOption
baseModelSelected?: Set<string>
candidateLabel?: string
onSearchEnter?: () => void
}
function renderMenu(props: MenuProps = {}) {
@@ -98,7 +100,9 @@ function renderMenu(props: MenuProps = {}) {
ownershipOptions: ownershipOptionsProp,
baseModelOptions: baseModelOptionsProp,
showOwnershipFilter: props.showOwnershipFilter ?? false,
showBaseModelFilter: props.showBaseModelFilter ?? false
showBaseModelFilter: props.showBaseModelFilter ?? false,
candidateLabel: props.candidateLabel,
onSearchEnter: () => props.onSearchEnter?.()
}),
template: `
<FormDropdownMenuActions
@@ -112,6 +116,8 @@ function renderMenu(props: MenuProps = {}) {
:ownership-options
:show-base-model-filter
:base-model-options
:candidate-label
@search-enter="onSearchEnter"
/>
`
})
@@ -163,6 +169,20 @@ describe('FormDropdownMenuActions', () => {
await user.clear(screen.getByRole('textbox'))
expect(searchQuery.value).toBe('')
})
it('emits search-enter when Enter is pressed in the textbox', async () => {
const onSearchEnter = vi.fn()
const { user } = renderMenu({ onSearchEnter })
await user.type(screen.getByRole('textbox'), '{Enter}')
expect(onSearchEnter).toHaveBeenCalledTimes(1)
})
it('announces the current top result to screen readers', () => {
renderMenu({ candidateLabel: 'alpha.ckpt' })
const status = screen.getByRole('status')
expect(status).toHaveAttribute('aria-live', 'polite')
expect(status).toHaveTextContent('Top result: alpha.ckpt')
})
})
describe('Sort popover', () => {

View File

@@ -22,6 +22,10 @@ defineProps<{
ownershipOptions?: OwnershipFilterOption[]
showBaseModelFilter?: boolean
baseModelOptions?: FilterOption[]
candidateLabel?: string
}>()
const emit = defineEmits<{
(e: 'search-enter'): void
}>()
const layoutMode = defineModel<LayoutMode>('layoutMode')
@@ -31,7 +35,7 @@ const ownershipSelected = defineModel<OwnershipOption>('ownershipSelected', {
default: 'all'
})
const baseModelSelected = defineModel<Set<string>>('baseModelSelected', {
default: new Set()
default: () => new Set()
})
const actionButtonStyle = cn(
@@ -95,6 +99,11 @@ function toggleBaseModelSelection(item: FilterOption) {
? new Set([...current].filter((v) => v !== item.value))
: new Set([...current, item.value])
}
function handleSearchEnter(event: KeyboardEvent) {
event.preventDefault()
emit('search-enter')
}
</script>
<template>
@@ -109,7 +118,17 @@ function toggleBaseModelSelection(item: FilterOption) {
'focus-within:ring-0 focus-within:outline-component-node-widget-background-highlighted/80'
)
"
@enter="handleSearchEnter"
/>
<span
v-if="candidateLabel"
role="status"
aria-live="polite"
aria-atomic="true"
class="sr-only"
>
{{ t('widgets.uploadSelect.topResult', { result: candidateLabel }) }}
</span>
<Button
ref="sortTriggerRef"

View File

@@ -102,7 +102,11 @@ function handleVideoLoad(event: Event) {
// selection
'ring-2 ring-component-node-widget-background-highlighted':
layout === 'list' && selected
}
},
candidate &&
!selected &&
layout !== 'grid' &&
'bg-component-node-widget-background-hovered'
)
"
@click="handleClick"
@@ -122,7 +126,7 @@ function handleVideoLoad(event: Event) {
layout === 'grid',
// selection
'ring-2 ring-component-node-widget-background-highlighted':
layout === 'grid' && selected
layout === 'grid' && (selected || candidate)
}
)
"

View File

@@ -44,6 +44,7 @@ export interface FormDropdownInputProps {
export interface FormDropdownMenuItemProps {
index: number
selected: boolean
candidate?: boolean
previewUrl: string
name: string
label?: string

View File

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