mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-19 22:34:15 +00:00
perf: virtualize FormDropdownMenu and fix VirtualGrid jitter
- Integrate VirtualGrid into FormDropdownMenu for virtualized rendering - Fix jitter: overflow-anchor:none, scrollbar-gutter:stable, cols=maxColumns when finite - Remove transition-[width] from grid items, replace transition-all with explicit properties - Replace LazyImage with native img (redundant with virtualization) - Change max-h-[640px] to fixed h-[640px] for proper virtualization - Add unit tests for VirtualGrid and FormDropdownMenu - Add E2E test for image dropdown virtualization Amp-Thread-ID: https://ampcode.com/threads/T-019c5a71-66c8-76e9-95ed-671a1b4538da
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../../../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Image Dropdown Virtualization', { tag: '@widget' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
test('should virtualize items when dropdown has many entries', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.loadWorkflow('widgets/load_image_widget')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const totalItems = await comfyPage.page.evaluate(() => {
|
||||
const node = window['graph']._nodes_by_id['10']
|
||||
const widget = node.widgets.find(
|
||||
(w: { name: string }) => w.name === 'image'
|
||||
)
|
||||
const count = 60
|
||||
const values = Array.from(
|
||||
{ length: count },
|
||||
(_, i) => `test_image_${i}.png`
|
||||
)
|
||||
widget.options.values = values
|
||||
widget.value = values[0]
|
||||
return count
|
||||
})
|
||||
|
||||
const loadImageNode = comfyPage.vueNodes.getNodeByTitle('Load Image')
|
||||
const dropdownButton = loadImageNode.locator(
|
||||
'button:has(span:has-text("test_image_0.png"))'
|
||||
)
|
||||
await dropdownButton.waitFor({ state: 'visible' })
|
||||
await dropdownButton.click()
|
||||
|
||||
const virtualGridItems = comfyPage.page.locator(
|
||||
'[data-virtual-grid-item]'
|
||||
)
|
||||
await expect(virtualGridItems.first()).toBeVisible()
|
||||
|
||||
const renderedCount = await virtualGridItems.count()
|
||||
expect(renderedCount).toBeLessThan(totalItems)
|
||||
expect(renderedCount).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
180
src/components/common/VirtualGrid.test.ts
Normal file
180
src/components/common/VirtualGrid.test.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, ref } from 'vue'
|
||||
|
||||
import VirtualGrid from './VirtualGrid.vue'
|
||||
|
||||
type TestItem = { key: string; name: string }
|
||||
|
||||
const mockedWidth = ref(400)
|
||||
const mockedHeight = ref(200)
|
||||
const mockedScrollY = ref(0)
|
||||
|
||||
vi.mock('@vueuse/core', async () => {
|
||||
const actual = await vi.importActual<Record<string, unknown>>('@vueuse/core')
|
||||
return {
|
||||
...actual,
|
||||
useElementSize: () => ({ width: mockedWidth, height: mockedHeight }),
|
||||
useScroll: () => ({ y: mockedScrollY })
|
||||
}
|
||||
})
|
||||
|
||||
function createItems(count: number): TestItem[] {
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
key: `item-${i}`,
|
||||
name: `Item ${i}`
|
||||
}))
|
||||
}
|
||||
|
||||
describe('VirtualGrid', () => {
|
||||
const defaultGridStyle = {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(4, 1fr)',
|
||||
gap: '1rem'
|
||||
}
|
||||
|
||||
it('renders items within the visible range', async () => {
|
||||
const items = createItems(100)
|
||||
mockedWidth.value = 400
|
||||
mockedHeight.value = 200
|
||||
mockedScrollY.value = 0
|
||||
|
||||
const wrapper = mount(VirtualGrid<TestItem>, {
|
||||
props: {
|
||||
items,
|
||||
gridStyle: defaultGridStyle,
|
||||
defaultItemHeight: 100,
|
||||
defaultItemWidth: 100,
|
||||
maxColumns: 4,
|
||||
bufferRows: 1
|
||||
},
|
||||
slots: {
|
||||
item: `<template #item="{ item }">
|
||||
<div class="test-item">{{ item.name }}</div>
|
||||
</template>`
|
||||
},
|
||||
attachTo: document.body
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
const renderedItems = wrapper.findAll('.test-item')
|
||||
expect(renderedItems.length).toBeGreaterThan(0)
|
||||
expect(renderedItems.length).toBeLessThan(items.length)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('provides correct index in slot props', async () => {
|
||||
const items = createItems(20)
|
||||
const receivedIndices: number[] = []
|
||||
mockedWidth.value = 400
|
||||
mockedHeight.value = 200
|
||||
mockedScrollY.value = 0
|
||||
|
||||
const wrapper = mount(VirtualGrid<TestItem>, {
|
||||
props: {
|
||||
items,
|
||||
gridStyle: defaultGridStyle,
|
||||
defaultItemHeight: 50,
|
||||
defaultItemWidth: 100,
|
||||
maxColumns: 1,
|
||||
bufferRows: 0
|
||||
},
|
||||
slots: {
|
||||
item: ({ index }: { index: number }) => {
|
||||
receivedIndices.push(index)
|
||||
return null
|
||||
}
|
||||
},
|
||||
attachTo: document.body
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
expect(receivedIndices.length).toBeGreaterThan(0)
|
||||
expect(receivedIndices[0]).toBe(0)
|
||||
for (let i = 1; i < receivedIndices.length; i++) {
|
||||
expect(receivedIndices[i]).toBe(receivedIndices[i - 1] + 1)
|
||||
}
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('respects maxColumns prop', async () => {
|
||||
const items = createItems(10)
|
||||
mockedWidth.value = 400
|
||||
mockedHeight.value = 200
|
||||
mockedScrollY.value = 0
|
||||
|
||||
const wrapper = mount(VirtualGrid<TestItem>, {
|
||||
props: {
|
||||
items,
|
||||
gridStyle: defaultGridStyle,
|
||||
maxColumns: 2
|
||||
},
|
||||
attachTo: document.body
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
const gridElement = wrapper.find('[style*="display: grid"]')
|
||||
expect(gridElement.exists()).toBe(true)
|
||||
|
||||
const gridEl = gridElement.element as HTMLElement
|
||||
expect(gridEl.style.gridTemplateColumns).toBe('repeat(2, minmax(0, 1fr))')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('renders empty when no items provided', async () => {
|
||||
const wrapper = mount(VirtualGrid<TestItem>, {
|
||||
props: {
|
||||
items: [],
|
||||
gridStyle: defaultGridStyle
|
||||
},
|
||||
slots: {
|
||||
item: `<template #item="{ item }">
|
||||
<div class="test-item">{{ item.name }}</div>
|
||||
</template>`
|
||||
}
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
const renderedItems = wrapper.findAll('.test-item')
|
||||
expect(renderedItems.length).toBe(0)
|
||||
})
|
||||
|
||||
it('forces cols to maxColumns when maxColumns is finite', async () => {
|
||||
mockedWidth.value = 100
|
||||
mockedHeight.value = 200
|
||||
mockedScrollY.value = 0
|
||||
|
||||
const items = createItems(20)
|
||||
const wrapper = mount(VirtualGrid<TestItem>, {
|
||||
props: {
|
||||
items,
|
||||
gridStyle: defaultGridStyle,
|
||||
defaultItemHeight: 50,
|
||||
defaultItemWidth: 200,
|
||||
maxColumns: 4,
|
||||
bufferRows: 0
|
||||
},
|
||||
slots: {
|
||||
item: `<template #item="{ item }">
|
||||
<div class="test-item">{{ item.name }}</div>
|
||||
</template>`
|
||||
},
|
||||
attachTo: document.body
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
const renderedItems = wrapper.findAll('.test-item')
|
||||
expect(renderedItems.length).toBeGreaterThan(0)
|
||||
expect(renderedItems.length % 4).toBe(0)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -1,17 +1,16 @@
|
||||
<template>
|
||||
<div
|
||||
ref="container"
|
||||
class="h-full overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-(--dialog-surface)"
|
||||
class="h-full overflow-y-auto [overflow-anchor:none] [scrollbar-gutter:stable] scrollbar-thin scrollbar-track-transparent scrollbar-thumb-(--dialog-surface)"
|
||||
>
|
||||
<div :style="topSpacerStyle" />
|
||||
<div :style="mergedGridStyle">
|
||||
<div
|
||||
v-for="item in renderedItems"
|
||||
v-for="(item, i) in renderedItems"
|
||||
:key="item.key"
|
||||
class="transition-[width] duration-150 ease-out"
|
||||
data-virtual-grid-item
|
||||
>
|
||||
<slot name="item" :item="item" />
|
||||
<slot name="item" :item :index="state.start + i" />
|
||||
</div>
|
||||
</div>
|
||||
<div :style="bottomSpacerStyle" />
|
||||
@@ -66,9 +65,10 @@ const { y: scrollY } = useScroll(container, {
|
||||
eventListenerOptions: { passive: true }
|
||||
})
|
||||
|
||||
const cols = computed(() =>
|
||||
Math.min(Math.floor(width.value / itemWidth.value) || 1, maxColumns)
|
||||
)
|
||||
const cols = computed(() => {
|
||||
if (maxColumns !== Infinity) return maxColumns
|
||||
return Math.floor(width.value / itemWidth.value) || 1
|
||||
})
|
||||
|
||||
const mergedGridStyle = computed<CSSProperties>(() => {
|
||||
if (maxColumns === Infinity) return gridStyle
|
||||
@@ -118,11 +118,10 @@ whenever(
|
||||
}
|
||||
)
|
||||
|
||||
const updateItemSize = () => {
|
||||
function updateItemSize(): void {
|
||||
if (container.value) {
|
||||
const firstItem = container.value.querySelector('[data-virtual-grid-item]')
|
||||
|
||||
// Don't update item size if the first item is not rendered yet
|
||||
if (!firstItem?.clientHeight || !firstItem?.clientWidth) return
|
||||
|
||||
if (itemHeight.value !== firstItem.clientHeight) {
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import FormDropdownMenu from './FormDropdownMenu.vue'
|
||||
import type { FormDropdownItem, LayoutMode } from './types'
|
||||
|
||||
function createItem(id: string, name: string): FormDropdownItem {
|
||||
return {
|
||||
id,
|
||||
preview_url: '',
|
||||
name,
|
||||
label: name
|
||||
}
|
||||
}
|
||||
|
||||
describe('FormDropdownMenu', () => {
|
||||
const defaultProps = {
|
||||
items: [createItem('1', 'Item 1'), createItem('2', 'Item 2')],
|
||||
isSelected: () => false,
|
||||
filterOptions: [],
|
||||
sortOptions: []
|
||||
}
|
||||
|
||||
it('renders empty state when no items', async () => {
|
||||
const wrapper = mount(FormDropdownMenu, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
items: []
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
FormDropdownMenuFilter: true,
|
||||
FormDropdownMenuActions: true,
|
||||
VirtualGrid: true
|
||||
},
|
||||
mocks: {
|
||||
$t: (key: string) => key
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
const emptyIcon = wrapper.find('.icon-\\[lucide--circle-off\\]')
|
||||
expect(emptyIcon.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders VirtualGrid when items exist', async () => {
|
||||
const wrapper = mount(FormDropdownMenu, {
|
||||
props: defaultProps,
|
||||
global: {
|
||||
stubs: {
|
||||
FormDropdownMenuFilter: true,
|
||||
FormDropdownMenuActions: true,
|
||||
VirtualGrid: true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
const virtualGrid = wrapper.findComponent({ name: 'VirtualGrid' })
|
||||
expect(virtualGrid.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('transforms items to include key property for VirtualGrid', async () => {
|
||||
const items = [createItem('1', 'Item 1'), createItem('2', 'Item 2')]
|
||||
const wrapper = mount(FormDropdownMenu, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
items
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
FormDropdownMenuFilter: true,
|
||||
FormDropdownMenuActions: true,
|
||||
VirtualGrid: true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
const virtualGrid = wrapper.findComponent({ name: 'VirtualGrid' })
|
||||
const virtualItems = virtualGrid.props('items')
|
||||
|
||||
expect(virtualItems).toHaveLength(2)
|
||||
expect(virtualItems[0]).toHaveProperty('key', '1')
|
||||
expect(virtualItems[1]).toHaveProperty('key', '2')
|
||||
})
|
||||
|
||||
it('uses single column layout for list modes', async () => {
|
||||
const wrapper = mount(FormDropdownMenu, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
layoutMode: 'list' as LayoutMode
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
FormDropdownMenuFilter: true,
|
||||
FormDropdownMenuActions: true,
|
||||
VirtualGrid: true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
const virtualGrid = wrapper.findComponent({ name: 'VirtualGrid' })
|
||||
expect(virtualGrid.props('maxColumns')).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
import type { CSSProperties, MaybeRefOrGetter } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import VirtualGrid from '@/components/common/VirtualGrid.vue'
|
||||
|
||||
import type {
|
||||
FilterOption,
|
||||
@@ -30,7 +31,7 @@ interface Props {
|
||||
baseModelOptions?: FilterOption[]
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'item-click', item: FormDropdownItem, index: number): void
|
||||
}>()
|
||||
@@ -41,11 +42,48 @@ const sortSelected = defineModel<string>('sortSelected')
|
||||
const searchQuery = defineModel<string>('searchQuery')
|
||||
const ownershipSelected = defineModel<OwnershipOption>('ownershipSelected')
|
||||
const baseModelSelected = defineModel<Set<string>>('baseModelSelected')
|
||||
|
||||
type LayoutConfig = {
|
||||
maxColumns: number
|
||||
itemHeight: number
|
||||
itemWidth: number
|
||||
gap: string
|
||||
}
|
||||
|
||||
const LAYOUT_CONFIGS: Record<LayoutMode, LayoutConfig> = {
|
||||
grid: { maxColumns: 4, itemHeight: 120, itemWidth: 89, gap: '1rem 0.5rem' },
|
||||
list: { maxColumns: 1, itemHeight: 64, itemWidth: 380, gap: '0.5rem' },
|
||||
'list-small': {
|
||||
maxColumns: 1,
|
||||
itemHeight: 40,
|
||||
itemWidth: 380,
|
||||
gap: '0.25rem'
|
||||
}
|
||||
}
|
||||
|
||||
const layoutConfig = computed<LayoutConfig>(
|
||||
() => LAYOUT_CONFIGS[layoutMode.value ?? 'grid']
|
||||
)
|
||||
|
||||
const gridStyle = computed<CSSProperties>(() => ({
|
||||
display: 'grid',
|
||||
gap: layoutConfig.value.gap,
|
||||
padding: '1rem',
|
||||
width: '100%'
|
||||
}))
|
||||
|
||||
type VirtualDropdownItem = FormDropdownItem & { key: string }
|
||||
const virtualItems = computed<VirtualDropdownItem[]>(() =>
|
||||
props.items.map((item) => ({
|
||||
...item,
|
||||
key: String(item.id)
|
||||
}))
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex max-h-[640px] w-103 flex-col rounded-lg bg-component-node-background pt-4 outline outline-offset-[-1px] outline-node-component-border"
|
||||
class="flex h-[640px] w-103 flex-col rounded-lg bg-component-node-background pt-4 outline outline-offset-[-1px] outline-node-component-border"
|
||||
>
|
||||
<FormDropdownMenuFilter
|
||||
v-if="filterOptions.length > 0"
|
||||
@@ -66,33 +104,29 @@ const baseModelSelected = defineModel<Set<string>>('baseModelSelected')
|
||||
:show-base-model-filter
|
||||
:base-model-options
|
||||
/>
|
||||
<div class="relative flex h-full mt-2 overflow-y-scroll">
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'h-full max-h-full grid gap-x-2 gap-y-4 overflow-y-auto px-4 pt-4 pb-4 w-full',
|
||||
{
|
||||
'grid-cols-4': layoutMode === 'grid',
|
||||
'grid-cols-1 gap-y-2': layoutMode === 'list',
|
||||
'grid-cols-1 gap-y-1': layoutMode === 'list-small'
|
||||
}
|
||||
)
|
||||
"
|
||||
>
|
||||
<div class="pointer-events-none absolute inset-x-3 top-0 z-10 h-5" />
|
||||
<div
|
||||
v-if="items.length === 0"
|
||||
class="h-50 col-span-full flex items-center justify-center"
|
||||
>
|
||||
<i
|
||||
:title="$t('g.noItems')"
|
||||
:aria-label="$t('g.noItems')"
|
||||
class="icon-[lucide--circle-off] size-30 text-zinc-500/20"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="items.length === 0"
|
||||
class="flex h-50 items-center justify-center"
|
||||
>
|
||||
<i
|
||||
:title="$t('g.noItems')"
|
||||
:aria-label="$t('g.noItems')"
|
||||
class="icon-[lucide--circle-off] size-30 text-muted-foreground/20"
|
||||
/>
|
||||
</div>
|
||||
<VirtualGrid
|
||||
v-else
|
||||
:key="layoutMode"
|
||||
:items="virtualItems"
|
||||
:grid-style="gridStyle"
|
||||
:max-columns="layoutConfig.maxColumns"
|
||||
:default-item-height="layoutConfig.itemHeight"
|
||||
:default-item-width="layoutConfig.itemWidth"
|
||||
:buffer-rows="2"
|
||||
class="mt-2 min-h-0 flex-1"
|
||||
>
|
||||
<template #item="{ item, index }">
|
||||
<FormDropdownMenuItem
|
||||
v-for="(item, index) in items"
|
||||
:key="item.id"
|
||||
:index="index"
|
||||
:selected="isSelected(item, index)"
|
||||
:preview-url="item.preview_url ?? ''"
|
||||
@@ -101,7 +135,7 @@ const baseModelSelected = defineModel<Set<string>>('baseModelSelected')
|
||||
:layout="layoutMode"
|
||||
@click="emit('item-click', item, index)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</VirtualGrid>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, ref } from 'vue'
|
||||
|
||||
import LazyImage from '@/components/common/LazyImage.vue'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import { AssetKindKey } from './types'
|
||||
@@ -57,7 +56,7 @@ function handleVideoLoad(event: Event) {
|
||||
:class="
|
||||
cn(
|
||||
'flex gap-1 select-none group/item cursor-pointer bg-component-node-widget-background',
|
||||
'transition-all duration-150',
|
||||
'transition-[transform,box-shadow,background-color] duration-150',
|
||||
{
|
||||
'flex-col text-center': layout === 'grid',
|
||||
'flex-row text-left max-h-16 rounded-lg hover:scale-102 active:scale-98':
|
||||
@@ -79,7 +78,7 @@ function handleVideoLoad(event: Event) {
|
||||
cn(
|
||||
'relative',
|
||||
'w-full aspect-square overflow-hidden outline-1 outline-offset-[-1px] outline-interface-stroke',
|
||||
'transition-all duration-150',
|
||||
'transition-[transform,box-shadow] duration-150',
|
||||
{
|
||||
'min-w-16 max-w-16 rounded-l-lg': layout === 'list',
|
||||
'rounded-sm group-hover/item:scale-108 group-active/item:scale-95':
|
||||
@@ -108,11 +107,12 @@ function handleVideoLoad(event: Event) {
|
||||
muted
|
||||
@loadeddata="handleVideoLoad"
|
||||
/>
|
||||
<LazyImage
|
||||
<img
|
||||
v-else-if="previewUrl"
|
||||
:src="previewUrl"
|
||||
:alt="name"
|
||||
image-class="size-full object-cover"
|
||||
draggable="false"
|
||||
class="size-full object-cover"
|
||||
@load="handleImageLoad"
|
||||
/>
|
||||
<div
|
||||
|
||||
Reference in New Issue
Block a user