mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-29 18:52:19 +00:00
perf: virtualize FormDropdownMenu to reduce DOM nodes and image requests (#8476)
## Summary Virtualize the FormDropdownMenu to only render visible items, fixing slow dropdown performance on cloud. ## Changes - Integrate `VirtualGrid` into `FormDropdownMenu` for virtualized rendering - Add computed properties for grid configuration per layout mode (grid/list/list-small) - Extend `VirtualGrid` slot to provide original item index for O(1) lookups - Change container from `max-h-[640px]` to fixed `h-[640px]` for proper virtualization ## Review Focus - VirtualGrid integration within the popover context - Layout mode switching with `:key="layoutMode"` to force re-render - Grid style computed properties match original Tailwind classes ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8476-perf-virtualize-FormDropdownMenu-to-reduce-DOM-nodes-and-image-requests-2f86d73d365081b3a79dd5e0b84df944) by [Unito](https://www.unito.io) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Dropdowns now render with a virtualized grid/list (stable indexes, responsive sizing) and show an empty-state icon when no items exist. * **Bug Fixes** * Reduced layout shift and rendering glitches with improved spacer/scroll calculations and more reliable media measurement. * **Style** * Simplified media rendering (standard img/video), unified item visuals and hover/background behavior. * **Tests** * Added unit and end-to-end tests for virtualization, indexing, layouts, dynamic updates, and empty states. * **Breaking Changes** * Dropdown item/selection shapes and related component props/events were updated (adapter changes may be required). <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
189
src/components/common/VirtualGrid.test.ts
Normal file
189
src/components/common/VirtualGrid.test.ts
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import type { Ref } from 'vue'
|
||||||
|
import { nextTick, ref } from 'vue'
|
||||||
|
|
||||||
|
import VirtualGrid from './VirtualGrid.vue'
|
||||||
|
|
||||||
|
type TestItem = { key: string; name: string }
|
||||||
|
|
||||||
|
let mockedWidth: Ref<number>
|
||||||
|
let mockedHeight: Ref<number>
|
||||||
|
let mockedScrollY: Ref<number>
|
||||||
|
|
||||||
|
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 })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockedWidth = ref(400)
|
||||||
|
mockedHeight = ref(200)
|
||||||
|
mockedScrollY = ref(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
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>
|
<template>
|
||||||
<div
|
<div
|
||||||
ref="container"
|
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="topSpacerStyle" />
|
||||||
<div :style="mergedGridStyle">
|
<div :style="mergedGridStyle">
|
||||||
<div
|
<div
|
||||||
v-for="item in renderedItems"
|
v-for="(item, i) in renderedItems"
|
||||||
:key="item.key"
|
:key="item.key"
|
||||||
class="transition-[width] duration-150 ease-out"
|
|
||||||
data-virtual-grid-item
|
data-virtual-grid-item
|
||||||
>
|
>
|
||||||
<slot name="item" :item="item" />
|
<slot name="item" :item :index="state.start + i" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div :style="bottomSpacerStyle" />
|
<div :style="bottomSpacerStyle" />
|
||||||
@@ -66,9 +65,10 @@ const { y: scrollY } = useScroll(container, {
|
|||||||
eventListenerOptions: { passive: true }
|
eventListenerOptions: { passive: true }
|
||||||
})
|
})
|
||||||
|
|
||||||
const cols = computed(() =>
|
const cols = computed(() => {
|
||||||
Math.min(Math.floor(width.value / itemWidth.value) || 1, maxColumns)
|
if (maxColumns !== Infinity) return maxColumns
|
||||||
)
|
return Math.floor(width.value / itemWidth.value) || 1
|
||||||
|
})
|
||||||
|
|
||||||
const mergedGridStyle = computed<CSSProperties>(() => {
|
const mergedGridStyle = computed<CSSProperties>(() => {
|
||||||
if (maxColumns === Infinity) return gridStyle
|
if (maxColumns === Infinity) return gridStyle
|
||||||
@@ -101,8 +101,9 @@ const renderedItems = computed(() =>
|
|||||||
isValidGrid.value ? items.slice(state.value.start, state.value.end) : []
|
isValidGrid.value ? items.slice(state.value.start, state.value.end) : []
|
||||||
)
|
)
|
||||||
|
|
||||||
function rowsToHeight(rows: number): string {
|
function rowsToHeight(itemsCount: number): string {
|
||||||
return `${(rows / cols.value) * itemHeight.value}px`
|
const rows = Math.ceil(itemsCount / cols.value)
|
||||||
|
return `${rows * itemHeight.value}px`
|
||||||
}
|
}
|
||||||
const topSpacerStyle = computed<CSSProperties>(() => ({
|
const topSpacerStyle = computed<CSSProperties>(() => ({
|
||||||
height: rowsToHeight(state.value.start)
|
height: rowsToHeight(state.value.start)
|
||||||
@@ -118,11 +119,10 @@ whenever(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
const updateItemSize = () => {
|
function updateItemSize(): void {
|
||||||
if (container.value) {
|
if (container.value) {
|
||||||
const firstItem = container.value.querySelector('[data-virtual-grid-item]')
|
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 (!firstItem?.clientHeight || !firstItem?.clientWidth) return
|
||||||
|
|
||||||
if (itemHeight.value !== firstItem.clientHeight) {
|
if (itemHeight.value !== firstItem.clientHeight) {
|
||||||
|
|||||||
@@ -2459,7 +2459,8 @@
|
|||||||
"placeholderVideo": "Select video...",
|
"placeholderVideo": "Select video...",
|
||||||
"placeholderMesh": "Select mesh...",
|
"placeholderMesh": "Select mesh...",
|
||||||
"placeholderModel": "Select model...",
|
"placeholderModel": "Select model...",
|
||||||
"placeholderUnknown": "Select media..."
|
"placeholderUnknown": "Select media...",
|
||||||
|
"maxSelectionReached": "Maximum selection limit reached"
|
||||||
},
|
},
|
||||||
"valueControl": {
|
"valueControl": {
|
||||||
"header": {
|
"header": {
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ function handleSelection(item: FormDropdownItem, index: number) {
|
|||||||
sel.clear()
|
sel.clear()
|
||||||
sel.add(item.id)
|
sel.add(item.id)
|
||||||
} else {
|
} else {
|
||||||
toastStore.addAlert(`Maximum selection limit reached`)
|
toastStore.addAlert(t('widgets.uploadSelect.maxSelectionReached'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ const theButtonStyle = computed(() =>
|
|||||||
<div
|
<div
|
||||||
:class="
|
:class="
|
||||||
cn(WidgetInputBaseClass, 'flex text-base leading-none', {
|
cn(WidgetInputBaseClass, 'flex text-base leading-none', {
|
||||||
'opacity-50 cursor-not-allowed !outline-zinc-300/10': disabled
|
'opacity-50 cursor-not-allowed outline-zinc-300/10': disabled
|
||||||
})
|
})
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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">
|
<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 {
|
import type {
|
||||||
FilterOption,
|
FilterOption,
|
||||||
@@ -30,7 +31,18 @@ interface Props {
|
|||||||
baseModelOptions?: FilterOption[]
|
baseModelOptions?: FilterOption[]
|
||||||
}
|
}
|
||||||
|
|
||||||
defineProps<Props>()
|
const {
|
||||||
|
items,
|
||||||
|
isSelected,
|
||||||
|
filterOptions,
|
||||||
|
sortOptions,
|
||||||
|
searcher,
|
||||||
|
updateKey,
|
||||||
|
showOwnershipFilter,
|
||||||
|
ownershipOptions,
|
||||||
|
showBaseModelFilter,
|
||||||
|
baseModelOptions
|
||||||
|
} = defineProps<Props>()
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'item-click', item: FormDropdownItem, index: number): void
|
(e: 'item-click', item: FormDropdownItem, index: number): void
|
||||||
}>()
|
}>()
|
||||||
@@ -41,11 +53,48 @@ const sortSelected = defineModel<string>('sortSelected')
|
|||||||
const searchQuery = defineModel<string>('searchQuery')
|
const searchQuery = defineModel<string>('searchQuery')
|
||||||
const ownershipSelected = defineModel<OwnershipOption>('ownershipSelected')
|
const ownershipSelected = defineModel<OwnershipOption>('ownershipSelected')
|
||||||
const baseModelSelected = defineModel<Set<string>>('baseModelSelected')
|
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[]>(() =>
|
||||||
|
items.map((item) => ({
|
||||||
|
...item,
|
||||||
|
key: String(item.id)
|
||||||
|
}))
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<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
|
<FormDropdownMenuFilter
|
||||||
v-if="filterOptions.length > 0"
|
v-if="filterOptions.length > 0"
|
||||||
@@ -66,34 +115,30 @@ const baseModelSelected = defineModel<Set<string>>('baseModelSelected')
|
|||||||
:show-base-model-filter
|
:show-base-model-filter
|
||||||
:base-model-options
|
:base-model-options
|
||||||
/>
|
/>
|
||||||
<div class="relative flex h-full mt-2 overflow-y-scroll">
|
<div
|
||||||
<div
|
v-if="items.length === 0"
|
||||||
:class="
|
class="flex h-50 items-center justify-center"
|
||||||
cn(
|
>
|
||||||
'h-full max-h-full grid gap-x-2 gap-y-4 overflow-y-auto px-4 pt-4 pb-4 w-full',
|
<i
|
||||||
{
|
:title="$t('g.noItems')"
|
||||||
'grid-cols-4': layoutMode === 'grid',
|
:aria-label="$t('g.noItems')"
|
||||||
'grid-cols-1 gap-y-2': layoutMode === 'list',
|
class="icon-[lucide--circle-off] size-30 text-muted-foreground/20"
|
||||||
'grid-cols-1 gap-y-1': layoutMode === 'list-small'
|
/>
|
||||||
}
|
</div>
|
||||||
)
|
<VirtualGrid
|
||||||
"
|
v-else
|
||||||
>
|
:key="layoutMode"
|
||||||
<div class="pointer-events-none absolute inset-x-3 top-0 z-10 h-5" />
|
:items="virtualItems"
|
||||||
<div
|
:grid-style
|
||||||
v-if="items.length === 0"
|
:max-columns="layoutConfig.maxColumns"
|
||||||
class="h-50 col-span-full flex items-center justify-center"
|
:default-item-height="layoutConfig.itemHeight"
|
||||||
>
|
:default-item-width="layoutConfig.itemWidth"
|
||||||
<i
|
:buffer-rows="2"
|
||||||
:title="$t('g.noItems')"
|
class="mt-2 min-h-0 flex-1"
|
||||||
:aria-label="$t('g.noItems')"
|
>
|
||||||
class="icon-[lucide--circle-off] size-30 text-zinc-500/20"
|
<template #item="{ item, index }">
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<FormDropdownMenuItem
|
<FormDropdownMenuItem
|
||||||
v-for="(item, index) in items"
|
:index
|
||||||
:key="item.id"
|
|
||||||
:index="index"
|
|
||||||
:selected="isSelected(item, index)"
|
:selected="isSelected(item, index)"
|
||||||
:preview-url="item.preview_url ?? ''"
|
:preview-url="item.preview_url ?? ''"
|
||||||
:name="item.name"
|
:name="item.name"
|
||||||
@@ -101,7 +146,7 @@ const baseModelSelected = defineModel<Set<string>>('baseModelSelected')
|
|||||||
:layout="layoutMode"
|
:layout="layoutMode"
|
||||||
@click="emit('item-click', item, index)"
|
@click="emit('item-click', item, index)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</template>
|
||||||
</div>
|
</VirtualGrid>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, inject, ref } from 'vue'
|
import { computed, inject, ref } from 'vue'
|
||||||
|
|
||||||
import LazyImage from '@/components/common/LazyImage.vue'
|
|
||||||
import { cn } from '@/utils/tailwindUtil'
|
import { cn } from '@/utils/tailwindUtil'
|
||||||
|
|
||||||
import { AssetKindKey } from './types'
|
import { AssetKindKey } from './types'
|
||||||
@@ -57,7 +56,7 @@ function handleVideoLoad(event: Event) {
|
|||||||
:class="
|
:class="
|
||||||
cn(
|
cn(
|
||||||
'flex gap-1 select-none group/item cursor-pointer bg-component-node-widget-background',
|
'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-col text-center': layout === 'grid',
|
||||||
'flex-row text-left max-h-16 rounded-lg hover:scale-102 active:scale-98':
|
'flex-row text-left max-h-16 rounded-lg hover:scale-102 active:scale-98':
|
||||||
@@ -79,7 +78,7 @@ function handleVideoLoad(event: Event) {
|
|||||||
cn(
|
cn(
|
||||||
'relative',
|
'relative',
|
||||||
'w-full aspect-square overflow-hidden outline-1 outline-offset-[-1px] outline-interface-stroke',
|
'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',
|
'min-w-16 max-w-16 rounded-l-lg': layout === 'list',
|
||||||
'rounded-sm group-hover/item:scale-108 group-active/item:scale-95':
|
'rounded-sm group-hover/item:scale-108 group-active/item:scale-95':
|
||||||
@@ -108,11 +107,12 @@ function handleVideoLoad(event: Event) {
|
|||||||
muted
|
muted
|
||||||
@loadeddata="handleVideoLoad"
|
@loadeddata="handleVideoLoad"
|
||||||
/>
|
/>
|
||||||
<LazyImage
|
<img
|
||||||
v-else-if="previewUrl"
|
v-else-if="previewUrl"
|
||||||
:src="previewUrl"
|
:src="previewUrl"
|
||||||
:alt="name"
|
:alt="name"
|
||||||
image-class="size-full object-cover"
|
draggable="false"
|
||||||
|
class="size-full object-cover"
|
||||||
@load="handleImageLoad"
|
@load="handleImageLoad"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
|
|||||||
Reference in New Issue
Block a user