Compare commits
6 Commits
v1.45.12
...
favicon-up
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
965cb0db7e | ||
|
|
eae19b27ee | ||
|
|
4b78f3401a | ||
|
|
52d77e6ee0 | ||
|
|
f1f65cff61 | ||
|
|
b0144db644 |
2
.github/workflows/pr-claude-review.yaml
vendored
@@ -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
|
||||
|
||||
6
.github/workflows/weekly-docs-check.yaml
vendored
@@ -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
@@ -1,3 +0,0 @@
|
||||
ignore-workspace-root-check=true
|
||||
catalog-mode=prefer
|
||||
public-hoist-pattern[]=@parcel/watcher
|
||||
BIN
apps/website/public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
apps/website/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
apps/website/public/favicon.png
Normal file
|
After Width: | Height: | Size: 937 B |
@@ -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" />
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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").
|
||||
|
||||
21
package.json
@@ -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"
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@
|
||||
"tailwindcss": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"packageManager": "pnpm@10.17.1",
|
||||
"nx": {
|
||||
"tags": [
|
||||
"scope:shared",
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
"devDependencies": {
|
||||
"@hey-api/openapi-ts": "0.93.0"
|
||||
},
|
||||
"packageManager": "pnpm@10.17.1",
|
||||
"nx": {
|
||||
"tags": [
|
||||
"scope:shared",
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
"exports": {
|
||||
".": "./src/comfyRegistryTypes.ts"
|
||||
},
|
||||
"packageManager": "pnpm@10.17.1",
|
||||
"nx": {
|
||||
"tags": [
|
||||
"scope:shared",
|
||||
|
||||
@@ -18,6 +18,5 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"packageManager": "pnpm@10.17.1"
|
||||
}
|
||||
}
|
||||
|
||||
1116
pnpm-lock.yaml
generated
@@ -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': '-'
|
||||
|
||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 9.4 KiB |
|
Before Width: | Height: | Size: 647 B After Width: | Height: | Size: 274 B |
|
Before Width: | Height: | Size: 674 B After Width: | Height: | Size: 277 B |
|
Before Width: | Height: | Size: 674 B After Width: | Height: | Size: 269 B |
|
Before Width: | Height: | Size: 674 B After Width: | Height: | Size: 284 B |
|
Before Width: | Height: | Size: 698 B After Width: | Height: | Size: 281 B |
|
Before Width: | Height: | Size: 700 B After Width: | Height: | Size: 277 B |
|
Before Width: | Height: | Size: 702 B After Width: | Height: | Size: 280 B |
|
Before Width: | Height: | Size: 705 B After Width: | Height: | Size: 302 B |
|
Before Width: | Height: | Size: 708 B After Width: | Height: | Size: 285 B |
|
Before Width: | Height: | Size: 705 B After Width: | Height: | Size: 296 B |
@@ -4,8 +4,6 @@
|
||||
@mouseenter="handleMouseEnter"
|
||||
@mouseleave="handleMouseLeave"
|
||||
@pointerdown.stop
|
||||
@pointermove.stop
|
||||
@pointerup.stop
|
||||
>
|
||||
<Load3DScene
|
||||
v-if="node"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
"
|
||||
|
||||
@@ -44,6 +44,7 @@ export interface FormDropdownInputProps {
|
||||
export interface FormDropdownMenuItemProps {
|
||||
index: number
|
||||
selected: boolean
|
||||
candidate?: boolean
|
||||
previewUrl: string
|
||||
name: string
|
||||
label?: string
|
||||
|
||||
@@ -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 {
|
||||
|
||||