mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-05 21:20:12 +00:00
Scroll templates better (#4584)
This commit is contained in:
committed by
GitHub
parent
386eb9391a
commit
5e9b8785a5
124
src/components/common/LazyImage.vue
Normal file
124
src/components/common/LazyImage.vue
Normal file
@@ -0,0 +1,124 @@
|
||||
<template>
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="relative overflow-hidden w-full h-full flex items-center justify-center"
|
||||
>
|
||||
<Skeleton
|
||||
v-if="!isImageLoaded"
|
||||
width="100%"
|
||||
height="100%"
|
||||
class="absolute inset-0"
|
||||
/>
|
||||
<img
|
||||
v-show="isImageLoaded"
|
||||
ref="imageRef"
|
||||
:src="cachedSrc"
|
||||
:alt="alt"
|
||||
draggable="false"
|
||||
:class="imageClass"
|
||||
:style="imageStyle"
|
||||
@load="onImageLoad"
|
||||
@error="onImageError"
|
||||
/>
|
||||
<div
|
||||
v-if="hasError"
|
||||
class="absolute inset-0 flex items-center justify-center bg-surface-50 dark-theme:bg-surface-800 text-muted"
|
||||
>
|
||||
<i class="pi pi-image text-2xl" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Skeleton from 'primevue/skeleton'
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
import { useIntersectionObserver } from '@/composables/useIntersectionObserver'
|
||||
import { useMediaCache } from '@/services/mediaCacheService'
|
||||
|
||||
const {
|
||||
src,
|
||||
alt = '',
|
||||
imageClass = '',
|
||||
imageStyle,
|
||||
rootMargin = '300px'
|
||||
} = defineProps<{
|
||||
src: string
|
||||
alt?: string
|
||||
imageClass?: string | string[] | Record<string, boolean>
|
||||
imageStyle?: Record<string, any>
|
||||
rootMargin?: string
|
||||
}>()
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
const imageRef = ref<HTMLImageElement | null>(null)
|
||||
const isIntersecting = ref(false)
|
||||
const isImageLoaded = ref(false)
|
||||
const hasError = ref(false)
|
||||
const cachedSrc = ref<string | undefined>(undefined)
|
||||
|
||||
const { getCachedMedia, acquireUrl, releaseUrl } = useMediaCache()
|
||||
|
||||
// Use intersection observer to detect when the image container comes into view
|
||||
useIntersectionObserver(
|
||||
containerRef,
|
||||
(entries) => {
|
||||
const entry = entries[0]
|
||||
isIntersecting.value = entry?.isIntersecting ?? false
|
||||
},
|
||||
{
|
||||
rootMargin,
|
||||
threshold: 0.1
|
||||
}
|
||||
)
|
||||
|
||||
// Only start loading the image when it's in view
|
||||
const shouldLoad = computed(() => isIntersecting.value)
|
||||
|
||||
watch(
|
||||
shouldLoad,
|
||||
async (shouldLoad) => {
|
||||
if (shouldLoad && src && !cachedSrc.value && !hasError.value) {
|
||||
try {
|
||||
const cachedMedia = await getCachedMedia(src)
|
||||
if (cachedMedia.error) {
|
||||
hasError.value = true
|
||||
} else if (cachedMedia.objectUrl) {
|
||||
const acquiredUrl = acquireUrl(src)
|
||||
cachedSrc.value = acquiredUrl || cachedMedia.objectUrl
|
||||
} else {
|
||||
cachedSrc.value = src
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to load cached media:', error)
|
||||
cachedSrc.value = src
|
||||
}
|
||||
} else if (!shouldLoad) {
|
||||
if (cachedSrc.value?.startsWith('blob:')) {
|
||||
releaseUrl(src)
|
||||
}
|
||||
// Hide image when out of view
|
||||
isImageLoaded.value = false
|
||||
cachedSrc.value = undefined
|
||||
hasError.value = false
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const onImageLoad = () => {
|
||||
isImageLoaded.value = true
|
||||
hasError.value = false
|
||||
}
|
||||
|
||||
const onImageError = () => {
|
||||
hasError.value = true
|
||||
isImageLoaded.value = false
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
if (cachedSrc.value?.startsWith('blob:')) {
|
||||
releaseUrl(src)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
64
src/components/templates/TemplateSearchBar.vue
Normal file
64
src/components/templates/TemplateSearchBar.vue
Normal file
@@ -0,0 +1,64 @@
|
||||
<template>
|
||||
<div class="relative w-full p-4">
|
||||
<div class="h-12 flex items-center gap-4 justify-between">
|
||||
<div class="flex-1 max-w-md">
|
||||
<AutoComplete
|
||||
v-model.lazy="searchQuery"
|
||||
:placeholder="$t('templateWorkflows.searchPlaceholder')"
|
||||
:complete-on-focus="false"
|
||||
:delay="200"
|
||||
class="w-full"
|
||||
:pt="{
|
||||
pcInputText: {
|
||||
root: {
|
||||
class: 'w-full rounded-2xl'
|
||||
}
|
||||
},
|
||||
loader: {
|
||||
style: 'display: none'
|
||||
}
|
||||
}"
|
||||
:show-empty-message="false"
|
||||
@complete="() => {}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4 mt-2">
|
||||
<small
|
||||
v-if="searchQuery && filteredCount !== null"
|
||||
class="text-color-secondary"
|
||||
>
|
||||
{{ $t('g.resultsCount', { count: filteredCount }) }}
|
||||
</small>
|
||||
<Button
|
||||
v-if="searchQuery"
|
||||
text
|
||||
size="small"
|
||||
icon="pi pi-times"
|
||||
:label="$t('g.clearFilters')"
|
||||
@click="clearFilters"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import AutoComplete from 'primevue/autocomplete'
|
||||
import Button from 'primevue/button'
|
||||
|
||||
const { filteredCount } = defineProps<{
|
||||
filteredCount?: number | null
|
||||
}>()
|
||||
|
||||
const searchQuery = defineModel<string>('searchQuery', { default: '' })
|
||||
|
||||
const emit = defineEmits<{
|
||||
clearFilters: []
|
||||
}>()
|
||||
|
||||
const clearFilters = () => {
|
||||
searchQuery.value = ''
|
||||
emit('clearFilters')
|
||||
}
|
||||
</script>
|
||||
30
src/components/templates/TemplateWorkflowCardSkeleton.vue
Normal file
30
src/components/templates/TemplateWorkflowCardSkeleton.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<Card
|
||||
class="w-64 template-card rounded-2xl overflow-hidden shadow-elevation-2 dark-theme:bg-dark-elevation-1.5 h-full"
|
||||
:pt="{
|
||||
body: { class: 'p-0 h-full flex flex-col' }
|
||||
}"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-center">
|
||||
<div class="relative overflow-hidden rounded-t-lg">
|
||||
<Skeleton width="16rem" height="12rem" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="flex items-center px-4 py-3">
|
||||
<div class="flex-1 flex flex-col">
|
||||
<Skeleton width="80%" height="1.25rem" class="mb-2" />
|
||||
<Skeleton width="100%" height="0.875rem" class="mb-1" />
|
||||
<Skeleton width="90%" height="0.875rem" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Card from 'primevue/card'
|
||||
import Skeleton from 'primevue/skeleton'
|
||||
</script>
|
||||
@@ -1,5 +1,6 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import TemplateWorkflowView from '@/components/templates/TemplateWorkflowView.vue'
|
||||
import { TemplateInfo } from '@/types/workflowTemplateTypes'
|
||||
@@ -53,10 +54,46 @@ vi.mock('@/components/templates/TemplateWorkflowList.vue', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/templates/TemplateSearchBar.vue', () => ({
|
||||
default: {
|
||||
template: '<div class="mock-search-bar"></div>',
|
||||
props: ['searchQuery', 'filteredCount'],
|
||||
emits: ['update:searchQuery', 'clearFilters']
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/templates/TemplateWorkflowCardSkeleton.vue', () => ({
|
||||
default: {
|
||||
template: '<div class="mock-skeleton"></div>'
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
useLocalStorage: () => 'grid'
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useIntersectionObserver', () => ({
|
||||
useIntersectionObserver: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useLazyPagination', () => ({
|
||||
useLazyPagination: (items: any) => ({
|
||||
paginatedItems: items,
|
||||
isLoading: { value: false },
|
||||
hasMoreItems: { value: false },
|
||||
loadNextPage: vi.fn(),
|
||||
reset: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useTemplateFiltering', () => ({
|
||||
useTemplateFiltering: (templates: any) => ({
|
||||
searchQuery: { value: '' },
|
||||
filteredTemplates: templates,
|
||||
filteredCount: { value: templates.value?.length || 0 }
|
||||
})
|
||||
}))
|
||||
|
||||
describe('TemplateWorkflowView', () => {
|
||||
const createTemplate = (name: string): TemplateInfo => ({
|
||||
name,
|
||||
@@ -67,6 +104,18 @@ describe('TemplateWorkflowView', () => {
|
||||
})
|
||||
|
||||
const mountView = (props = {}) => {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
templateWorkflows: {
|
||||
loadingMore: 'Loading more...'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return mount(TemplateWorkflowView, {
|
||||
props: {
|
||||
title: 'Test Templates',
|
||||
@@ -79,6 +128,9 @@ describe('TemplateWorkflowView', () => {
|
||||
],
|
||||
loading: null,
|
||||
...props
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,24 +1,31 @@
|
||||
<template>
|
||||
<DataView
|
||||
:value="templates"
|
||||
:value="displayTemplates"
|
||||
:layout="layout"
|
||||
data-key="name"
|
||||
:lazy="true"
|
||||
pt:root="h-full grid grid-rows-[auto_1fr]"
|
||||
pt:root="h-full grid grid-rows-[auto_1fr_auto]"
|
||||
pt:content="p-2 overflow-auto"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex justify-between items-center">
|
||||
<h2 class="text-lg">{{ title }}</h2>
|
||||
<SelectButton
|
||||
v-model="layout"
|
||||
:options="['grid', 'list']"
|
||||
:allow-empty="false"
|
||||
>
|
||||
<template #option="{ option }">
|
||||
<i :class="[option === 'list' ? 'pi pi-bars' : 'pi pi-table']" />
|
||||
</template>
|
||||
</SelectButton>
|
||||
<div class="flex flex-col">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-lg">{{ title }}</h2>
|
||||
<SelectButton
|
||||
v-model="layout"
|
||||
:options="['grid', 'list']"
|
||||
:allow-empty="false"
|
||||
>
|
||||
<template #option="{ option }">
|
||||
<i :class="[option === 'list' ? 'pi pi-bars' : 'pi pi-table']" />
|
||||
</template>
|
||||
</SelectButton>
|
||||
</div>
|
||||
<TemplateSearchBar
|
||||
v-model:search-query="searchQuery"
|
||||
:filtered-count="filteredCount"
|
||||
@clear-filters="() => reset()"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -33,18 +40,35 @@
|
||||
</template>
|
||||
|
||||
<template #grid="{ items }">
|
||||
<div
|
||||
class="grid grid-cols-[repeat(auto-fill,minmax(16rem,1fr))] gap-x-4 gap-y-8 px-4 justify-items-center"
|
||||
>
|
||||
<TemplateWorkflowCard
|
||||
v-for="template in items"
|
||||
:key="template.name"
|
||||
:source-module="sourceModule"
|
||||
:template="template"
|
||||
:loading="loading === template.name"
|
||||
:category-title="categoryTitle"
|
||||
@load-workflow="onLoadWorkflow"
|
||||
/>
|
||||
<div>
|
||||
<div
|
||||
class="grid grid-cols-[repeat(auto-fill,minmax(16rem,1fr))] gap-x-4 gap-y-8 px-4 justify-items-center"
|
||||
>
|
||||
<TemplateWorkflowCard
|
||||
v-for="template in items"
|
||||
:key="template.name"
|
||||
:source-module="sourceModule"
|
||||
:template="template"
|
||||
:loading="loading === template.name"
|
||||
:category-title="categoryTitle"
|
||||
@load-workflow="onLoadWorkflow"
|
||||
/>
|
||||
<TemplateWorkflowCardSkeleton
|
||||
v-for="n in shouldUsePagination && isLoadingMore
|
||||
? skeletonCount
|
||||
: 0"
|
||||
:key="`skeleton-${n}`"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="shouldUsePagination && hasMoreTemplates"
|
||||
ref="loadTrigger"
|
||||
class="w-full h-4 flex justify-center items-center"
|
||||
>
|
||||
<div v-if="isLoadingMore" class="text-sm text-muted">
|
||||
{{ t('templateWorkflows.loadingMore') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</DataView>
|
||||
@@ -54,12 +78,21 @@
|
||||
import { useLocalStorage } from '@vueuse/core'
|
||||
import DataView from 'primevue/dataview'
|
||||
import SelectButton from 'primevue/selectbutton'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import TemplateSearchBar from '@/components/templates/TemplateSearchBar.vue'
|
||||
import TemplateWorkflowCard from '@/components/templates/TemplateWorkflowCard.vue'
|
||||
import TemplateWorkflowCardSkeleton from '@/components/templates/TemplateWorkflowCardSkeleton.vue'
|
||||
import TemplateWorkflowList from '@/components/templates/TemplateWorkflowList.vue'
|
||||
import { useIntersectionObserver } from '@/composables/useIntersectionObserver'
|
||||
import { useLazyPagination } from '@/composables/useLazyPagination'
|
||||
import { useTemplateFiltering } from '@/composables/useTemplateFiltering'
|
||||
import type { TemplateInfo } from '@/types/workflowTemplateTypes'
|
||||
|
||||
defineProps<{
|
||||
const { t } = useI18n()
|
||||
|
||||
const { title, sourceModule, categoryTitle, loading, templates } = defineProps<{
|
||||
title: string
|
||||
sourceModule: string
|
||||
categoryTitle: string
|
||||
@@ -72,6 +105,59 @@ const layout = useLocalStorage<'grid' | 'list'>(
|
||||
'grid'
|
||||
)
|
||||
|
||||
const skeletonCount = 6
|
||||
const loadTrigger = ref<HTMLElement | null>(null)
|
||||
|
||||
const templatesRef = computed(() => templates || [])
|
||||
|
||||
const { searchQuery, filteredTemplates, filteredCount } =
|
||||
useTemplateFiltering(templatesRef)
|
||||
|
||||
// When searching, show all results immediately without pagination
|
||||
// When not searching, use lazy pagination
|
||||
const shouldUsePagination = computed(() => !searchQuery.value.trim())
|
||||
|
||||
// Lazy pagination setup using filtered templates
|
||||
const {
|
||||
paginatedItems: paginatedTemplates,
|
||||
isLoading: isLoadingMore,
|
||||
hasMoreItems: hasMoreTemplates,
|
||||
loadNextPage,
|
||||
reset
|
||||
} = useLazyPagination(filteredTemplates, {
|
||||
itemsPerPage: 12
|
||||
})
|
||||
|
||||
// Final templates to display
|
||||
const displayTemplates = computed(() => {
|
||||
return shouldUsePagination.value
|
||||
? paginatedTemplates.value
|
||||
: filteredTemplates.value
|
||||
})
|
||||
// Intersection observer for auto-loading (only when not searching)
|
||||
useIntersectionObserver(
|
||||
loadTrigger,
|
||||
(entries) => {
|
||||
const entry = entries[0]
|
||||
if (
|
||||
entry?.isIntersecting &&
|
||||
shouldUsePagination.value &&
|
||||
hasMoreTemplates.value &&
|
||||
!isLoadingMore.value
|
||||
) {
|
||||
void loadNextPage()
|
||||
}
|
||||
},
|
||||
{
|
||||
rootMargin: '200px',
|
||||
threshold: 0.1
|
||||
}
|
||||
)
|
||||
|
||||
watch([() => templates, searchQuery], () => {
|
||||
reset()
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
loadWorkflow: [name: string]
|
||||
}>()
|
||||
|
||||
@@ -12,6 +12,15 @@ vi.mock('@/components/templates/thumbnails/BaseThumbnail.vue', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/common/LazyImage.vue', () => ({
|
||||
default: {
|
||||
name: 'LazyImage',
|
||||
template:
|
||||
'<img :src="src" :alt="alt" :class="imageClass" :style="imageStyle" draggable="false" />',
|
||||
props: ['src', 'alt', 'imageClass', 'imageStyle']
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
useMouseInElement: () => ({
|
||||
elementX: ref(50),
|
||||
@@ -35,23 +44,24 @@ describe('CompareSliderThumbnail', () => {
|
||||
|
||||
it('renders both base and overlay images', () => {
|
||||
const wrapper = mountThumbnail()
|
||||
const images = wrapper.findAll('img')
|
||||
expect(images.length).toBe(2)
|
||||
expect(images[0].attributes('src')).toBe('/base-image.jpg')
|
||||
expect(images[1].attributes('src')).toBe('/overlay-image.jpg')
|
||||
const lazyImages = wrapper.findAllComponents({ name: 'LazyImage' })
|
||||
expect(lazyImages.length).toBe(2)
|
||||
expect(lazyImages[0].props('src')).toBe('/base-image.jpg')
|
||||
expect(lazyImages[1].props('src')).toBe('/overlay-image.jpg')
|
||||
})
|
||||
|
||||
it('applies correct alt text to both images', () => {
|
||||
const wrapper = mountThumbnail({ alt: 'Custom Alt Text' })
|
||||
const images = wrapper.findAll('img')
|
||||
expect(images[0].attributes('alt')).toBe('Custom Alt Text')
|
||||
expect(images[1].attributes('alt')).toBe('Custom Alt Text')
|
||||
const lazyImages = wrapper.findAllComponents({ name: 'LazyImage' })
|
||||
expect(lazyImages[0].props('alt')).toBe('Custom Alt Text')
|
||||
expect(lazyImages[1].props('alt')).toBe('Custom Alt Text')
|
||||
})
|
||||
|
||||
it('applies clip-path style to overlay image', () => {
|
||||
const wrapper = mountThumbnail()
|
||||
const overlay = wrapper.findAll('img')[1]
|
||||
expect(overlay.attributes('style')).toContain('clip-path')
|
||||
const overlayLazyImage = wrapper.findAllComponents({ name: 'LazyImage' })[1]
|
||||
const imageStyle = overlayLazyImage.props('imageStyle')
|
||||
expect(imageStyle.clipPath).toContain('inset')
|
||||
})
|
||||
|
||||
it('renders slider divider', () => {
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
<template>
|
||||
<BaseThumbnail :is-hovered="isHovered">
|
||||
<img
|
||||
<LazyImage
|
||||
:src="baseImageSrc"
|
||||
:alt="alt"
|
||||
:class="
|
||||
:image-class="
|
||||
isVideoType
|
||||
? 'w-full h-full object-cover'
|
||||
: 'max-w-full max-h-64 object-contain'
|
||||
"
|
||||
/>
|
||||
<div ref="containerRef" class="absolute inset-0">
|
||||
<img
|
||||
<LazyImage
|
||||
:src="overlayImageSrc"
|
||||
:alt="alt"
|
||||
:class="
|
||||
:image-class="
|
||||
isVideoType
|
||||
? 'w-full h-full object-cover'
|
||||
: 'max-w-full max-h-64 object-contain'
|
||||
"
|
||||
:style="{
|
||||
:image-style="{
|
||||
clipPath: `inset(0 ${100 - sliderPosition}% 0 0)`
|
||||
}"
|
||||
/>
|
||||
@@ -36,6 +36,7 @@
|
||||
import { useMouseInElement } from '@vueuse/core'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import LazyImage from '@/components/common/LazyImage.vue'
|
||||
import BaseThumbnail from '@/components/templates/thumbnails/BaseThumbnail.vue'
|
||||
|
||||
const SLIDER_START_POSITION = 50
|
||||
|
||||
@@ -11,6 +11,15 @@ vi.mock('@/components/templates/thumbnails/BaseThumbnail.vue', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/common/LazyImage.vue', () => ({
|
||||
default: {
|
||||
name: 'LazyImage',
|
||||
template:
|
||||
'<img :src="src" :alt="alt" :class="imageClass" :style="imageStyle" draggable="false" />',
|
||||
props: ['src', 'alt', 'imageClass', 'imageStyle']
|
||||
}
|
||||
}))
|
||||
|
||||
describe('DefaultThumbnail', () => {
|
||||
const mountThumbnail = (props = {}) => {
|
||||
return mount(DefaultThumbnail, {
|
||||
@@ -25,9 +34,9 @@ describe('DefaultThumbnail', () => {
|
||||
|
||||
it('renders image with correct src and alt', () => {
|
||||
const wrapper = mountThumbnail()
|
||||
const img = wrapper.find('img')
|
||||
expect(img.attributes('src')).toBe('/test-image.jpg')
|
||||
expect(img.attributes('alt')).toBe('Test Image')
|
||||
const lazyImage = wrapper.findComponent({ name: 'LazyImage' })
|
||||
expect(lazyImage.props('src')).toBe('/test-image.jpg')
|
||||
expect(lazyImage.props('alt')).toBe('Test Image')
|
||||
})
|
||||
|
||||
it('applies scale transform when hovered', () => {
|
||||
@@ -35,35 +44,43 @@ describe('DefaultThumbnail', () => {
|
||||
isHovered: true,
|
||||
hoverZoom: 10
|
||||
})
|
||||
const img = wrapper.find('img')
|
||||
expect(img.attributes('style')).toContain('scale(1.1)')
|
||||
const lazyImage = wrapper.findComponent({ name: 'LazyImage' })
|
||||
expect(lazyImage.props('imageStyle')).toEqual({ transform: 'scale(1.1)' })
|
||||
})
|
||||
|
||||
it('does not apply scale transform when not hovered', () => {
|
||||
const wrapper = mountThumbnail({
|
||||
isHovered: false
|
||||
})
|
||||
const img = wrapper.find('img')
|
||||
expect(img.attributes('style')).toBeUndefined()
|
||||
const lazyImage = wrapper.findComponent({ name: 'LazyImage' })
|
||||
expect(lazyImage.props('imageStyle')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('applies video styling for video type', () => {
|
||||
const wrapper = mountThumbnail({
|
||||
isVideo: true
|
||||
})
|
||||
const img = wrapper.find('img')
|
||||
expect(img.classes()).toContain('w-full')
|
||||
expect(img.classes()).toContain('h-full')
|
||||
expect(img.classes()).toContain('object-cover')
|
||||
const lazyImage = wrapper.findComponent({ name: 'LazyImage' })
|
||||
const imageClass = lazyImage.props('imageClass')
|
||||
const classString = Array.isArray(imageClass)
|
||||
? imageClass.join(' ')
|
||||
: imageClass
|
||||
expect(classString).toContain('w-full')
|
||||
expect(classString).toContain('h-full')
|
||||
expect(classString).toContain('object-cover')
|
||||
})
|
||||
|
||||
it('applies image styling for non-video type', () => {
|
||||
const wrapper = mountThumbnail({
|
||||
isVideo: false
|
||||
})
|
||||
const img = wrapper.find('img')
|
||||
expect(img.classes()).toContain('max-w-full')
|
||||
expect(img.classes()).toContain('object-contain')
|
||||
const lazyImage = wrapper.findComponent({ name: 'LazyImage' })
|
||||
const imageClass = lazyImage.props('imageClass')
|
||||
const classString = Array.isArray(imageClass)
|
||||
? imageClass.join(' ')
|
||||
: imageClass
|
||||
expect(classString).toContain('max-w-full')
|
||||
expect(classString).toContain('object-contain')
|
||||
})
|
||||
|
||||
it('applies correct styling for webp images', () => {
|
||||
@@ -71,8 +88,12 @@ describe('DefaultThumbnail', () => {
|
||||
src: '/test-video.webp',
|
||||
isVideo: true
|
||||
})
|
||||
const img = wrapper.find('img')
|
||||
expect(img.classes()).toContain('object-cover')
|
||||
const lazyImage = wrapper.findComponent({ name: 'LazyImage' })
|
||||
const imageClass = lazyImage.props('imageClass')
|
||||
const classString = Array.isArray(imageClass)
|
||||
? imageClass.join(' ')
|
||||
: imageClass
|
||||
expect(classString).toContain('object-cover')
|
||||
})
|
||||
|
||||
it('image is not draggable', () => {
|
||||
@@ -83,11 +104,15 @@ describe('DefaultThumbnail', () => {
|
||||
|
||||
it('applies transition classes', () => {
|
||||
const wrapper = mountThumbnail()
|
||||
const img = wrapper.find('img')
|
||||
expect(img.classes()).toContain('transform-gpu')
|
||||
expect(img.classes()).toContain('transition-transform')
|
||||
expect(img.classes()).toContain('duration-300')
|
||||
expect(img.classes()).toContain('ease-out')
|
||||
const lazyImage = wrapper.findComponent({ name: 'LazyImage' })
|
||||
const imageClass = lazyImage.props('imageClass')
|
||||
const classString = Array.isArray(imageClass)
|
||||
? imageClass.join(' ')
|
||||
: imageClass
|
||||
expect(classString).toContain('transform-gpu')
|
||||
expect(classString).toContain('transition-transform')
|
||||
expect(classString).toContain('duration-300')
|
||||
expect(classString).toContain('ease-out')
|
||||
})
|
||||
|
||||
it('passes correct props to BaseThumbnail', () => {
|
||||
|
||||
@@ -1,25 +1,23 @@
|
||||
<template>
|
||||
<BaseThumbnail :hover-zoom="hoverZoom" :is-hovered="isHovered">
|
||||
<div class="overflow-hidden w-full h-full flex items-center justify-center">
|
||||
<img
|
||||
:src="src"
|
||||
:alt="alt"
|
||||
draggable="false"
|
||||
:class="[
|
||||
'transform-gpu transition-transform duration-300 ease-out',
|
||||
isVideoType
|
||||
? 'w-full h-full object-cover'
|
||||
: 'max-w-full max-h-64 object-contain'
|
||||
]"
|
||||
:style="
|
||||
isHovered ? { transform: `scale(${1 + hoverZoom / 100})` } : undefined
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<LazyImage
|
||||
:src="src"
|
||||
:alt="alt"
|
||||
:image-class="[
|
||||
'transform-gpu transition-transform duration-300 ease-out',
|
||||
isVideoType
|
||||
? 'w-full h-full object-cover'
|
||||
: 'max-w-full max-h-64 object-contain'
|
||||
]"
|
||||
:image-style="
|
||||
isHovered ? { transform: `scale(${1 + hoverZoom / 100})` } : undefined
|
||||
"
|
||||
/>
|
||||
</BaseThumbnail>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import LazyImage from '@/components/common/LazyImage.vue'
|
||||
import BaseThumbnail from '@/components/templates/thumbnails/BaseThumbnail.vue'
|
||||
|
||||
const { src, isVideo } = defineProps<{
|
||||
|
||||
@@ -11,6 +11,15 @@ vi.mock('@/components/templates/thumbnails/BaseThumbnail.vue', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/common/LazyImage.vue', () => ({
|
||||
default: {
|
||||
name: 'LazyImage',
|
||||
template:
|
||||
'<img :src="src" :alt="alt" :class="imageClass" :style="imageStyle" draggable="false" />',
|
||||
props: ['src', 'alt', 'imageClass', 'imageStyle']
|
||||
}
|
||||
}))
|
||||
|
||||
describe('HoverDissolveThumbnail', () => {
|
||||
const mountThumbnail = (props = {}) => {
|
||||
return mount(HoverDissolveThumbnail, {
|
||||
@@ -27,31 +36,39 @@ describe('HoverDissolveThumbnail', () => {
|
||||
|
||||
it('renders both base and overlay images', () => {
|
||||
const wrapper = mountThumbnail()
|
||||
const images = wrapper.findAll('img')
|
||||
expect(images.length).toBe(2)
|
||||
expect(images[0].attributes('src')).toBe('/base-image.jpg')
|
||||
expect(images[1].attributes('src')).toBe('/overlay-image.jpg')
|
||||
const lazyImages = wrapper.findAllComponents({ name: 'LazyImage' })
|
||||
expect(lazyImages.length).toBe(2)
|
||||
expect(lazyImages[0].props('src')).toBe('/base-image.jpg')
|
||||
expect(lazyImages[1].props('src')).toBe('/overlay-image.jpg')
|
||||
})
|
||||
|
||||
it('applies correct alt text to both images', () => {
|
||||
const wrapper = mountThumbnail({ alt: 'Custom Alt Text' })
|
||||
const images = wrapper.findAll('img')
|
||||
expect(images[0].attributes('alt')).toBe('Custom Alt Text')
|
||||
expect(images[1].attributes('alt')).toBe('Custom Alt Text')
|
||||
const lazyImages = wrapper.findAllComponents({ name: 'LazyImage' })
|
||||
expect(lazyImages[0].props('alt')).toBe('Custom Alt Text')
|
||||
expect(lazyImages[1].props('alt')).toBe('Custom Alt Text')
|
||||
})
|
||||
|
||||
it('makes overlay image visible when hovered', () => {
|
||||
const wrapper = mountThumbnail({ isHovered: true })
|
||||
const overlayImage = wrapper.findAll('img')[1]
|
||||
expect(overlayImage.classes()).toContain('opacity-100')
|
||||
expect(overlayImage.classes()).not.toContain('opacity-0')
|
||||
const overlayLazyImage = wrapper.findAllComponents({ name: 'LazyImage' })[1]
|
||||
const imageClass = overlayLazyImage.props('imageClass')
|
||||
const classString = Array.isArray(imageClass)
|
||||
? imageClass.join(' ')
|
||||
: imageClass
|
||||
expect(classString).toContain('opacity-100')
|
||||
expect(classString).not.toContain('opacity-0')
|
||||
})
|
||||
|
||||
it('makes overlay image hidden when not hovered', () => {
|
||||
const wrapper = mountThumbnail({ isHovered: false })
|
||||
const overlayImage = wrapper.findAll('img')[1]
|
||||
expect(overlayImage.classes()).toContain('opacity-0')
|
||||
expect(overlayImage.classes()).not.toContain('opacity-100')
|
||||
const overlayLazyImage = wrapper.findAllComponents({ name: 'LazyImage' })[1]
|
||||
const imageClass = overlayLazyImage.props('imageClass')
|
||||
const classString = Array.isArray(imageClass)
|
||||
? imageClass.join(' ')
|
||||
: imageClass
|
||||
expect(classString).toContain('opacity-0')
|
||||
expect(classString).not.toContain('opacity-100')
|
||||
})
|
||||
|
||||
it('passes isHovered prop to BaseThumbnail', () => {
|
||||
@@ -62,21 +79,33 @@ describe('HoverDissolveThumbnail', () => {
|
||||
|
||||
it('applies transition classes to overlay image', () => {
|
||||
const wrapper = mountThumbnail()
|
||||
const overlayImage = wrapper.findAll('img')[1]
|
||||
expect(overlayImage.classes()).toContain('transition-opacity')
|
||||
expect(overlayImage.classes()).toContain('duration-300')
|
||||
const overlayLazyImage = wrapper.findAllComponents({ name: 'LazyImage' })[1]
|
||||
const imageClass = overlayLazyImage.props('imageClass')
|
||||
const classString = Array.isArray(imageClass)
|
||||
? imageClass.join(' ')
|
||||
: imageClass
|
||||
expect(classString).toContain('transition-opacity')
|
||||
expect(classString).toContain('duration-300')
|
||||
})
|
||||
|
||||
it('applies correct positioning to both images', () => {
|
||||
const wrapper = mountThumbnail()
|
||||
const images = wrapper.findAll('img')
|
||||
const lazyImages = wrapper.findAllComponents({ name: 'LazyImage' })
|
||||
|
||||
// Check base image
|
||||
expect(images[0].classes()).toContain('absolute')
|
||||
expect(images[0].classes()).toContain('inset-0')
|
||||
const baseImageClass = lazyImages[0].props('imageClass')
|
||||
const baseClassString = Array.isArray(baseImageClass)
|
||||
? baseImageClass.join(' ')
|
||||
: baseImageClass
|
||||
expect(baseClassString).toContain('absolute')
|
||||
expect(baseClassString).toContain('inset-0')
|
||||
|
||||
// Check overlay image
|
||||
expect(images[1].classes()).toContain('absolute')
|
||||
expect(images[1].classes()).toContain('inset-0')
|
||||
const overlayImageClass = lazyImages[1].props('imageClass')
|
||||
const overlayClassString = Array.isArray(overlayImageClass)
|
||||
? overlayImageClass.join(' ')
|
||||
: overlayImageClass
|
||||
expect(overlayClassString).toContain('absolute')
|
||||
expect(overlayClassString).toContain('inset-0')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,37 +1,23 @@
|
||||
<template>
|
||||
<BaseThumbnail :is-hovered="isHovered">
|
||||
<div class="relative w-full h-full">
|
||||
<img
|
||||
:src="baseImageSrc"
|
||||
:alt="alt"
|
||||
draggable="false"
|
||||
class="absolute inset-0"
|
||||
:class="
|
||||
isVideoType
|
||||
? 'w-full h-full object-cover'
|
||||
: 'max-w-full max-h-64 object-contain'
|
||||
"
|
||||
/>
|
||||
<img
|
||||
<LazyImage :src="baseImageSrc" :alt="alt" :image-class="baseImageClass" />
|
||||
<LazyImage
|
||||
:src="overlayImageSrc"
|
||||
:alt="alt"
|
||||
draggable="false"
|
||||
class="absolute inset-0 transition-opacity duration-300"
|
||||
:class="[
|
||||
isVideoType
|
||||
? 'w-full h-full object-cover'
|
||||
: 'max-w-full max-h-64 object-contain',
|
||||
{ 'opacity-100': isHovered, 'opacity-0': !isHovered }
|
||||
]"
|
||||
:image-class="overlayImageClass"
|
||||
/>
|
||||
</div>
|
||||
</BaseThumbnail>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import LazyImage from '@/components/common/LazyImage.vue'
|
||||
import BaseThumbnail from '@/components/templates/thumbnails/BaseThumbnail.vue'
|
||||
|
||||
const { baseImageSrc, overlayImageSrc, isVideo } = defineProps<{
|
||||
const { baseImageSrc, overlayImageSrc, isVideo, isHovered } = defineProps<{
|
||||
baseImageSrc: string
|
||||
overlayImageSrc: string
|
||||
alt: string
|
||||
@@ -44,4 +30,17 @@ const isVideoType =
|
||||
baseImageSrc?.toLowerCase().endsWith('.webp') ||
|
||||
overlayImageSrc?.toLowerCase().endsWith('.webp') ||
|
||||
false
|
||||
|
||||
const baseImageClass = computed(() => {
|
||||
return `absolute inset-0 ${isVideoType ? 'w-full h-full object-cover' : 'max-w-full max-h-64 object-contain'}`
|
||||
})
|
||||
|
||||
const overlayImageClass = computed(() => {
|
||||
const baseClasses = 'absolute inset-0 transition-opacity duration-300'
|
||||
const sizeClasses = isVideoType
|
||||
? 'w-full h-full object-cover'
|
||||
: 'max-w-full max-h-64 object-contain'
|
||||
const opacityClasses = isHovered ? 'opacity-100' : 'opacity-0'
|
||||
return `${baseClasses} ${sizeClasses} ${opacityClasses}`
|
||||
})
|
||||
</script>
|
||||
|
||||
60
src/composables/useIntersectionObserver.ts
Normal file
60
src/composables/useIntersectionObserver.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { type Ref, onBeforeUnmount, ref, watch } from 'vue'
|
||||
|
||||
export interface UseIntersectionObserverOptions
|
||||
extends IntersectionObserverInit {
|
||||
immediate?: boolean
|
||||
}
|
||||
|
||||
export function useIntersectionObserver(
|
||||
target: Ref<Element | null>,
|
||||
callback: IntersectionObserverCallback,
|
||||
options: UseIntersectionObserverOptions = {}
|
||||
) {
|
||||
const { immediate = true, ...observerOptions } = options
|
||||
|
||||
const isSupported =
|
||||
typeof window !== 'undefined' && 'IntersectionObserver' in window
|
||||
const isIntersecting = ref(false)
|
||||
|
||||
let observer: IntersectionObserver | null = null
|
||||
|
||||
const cleanup = () => {
|
||||
if (observer) {
|
||||
observer.disconnect()
|
||||
observer = null
|
||||
}
|
||||
}
|
||||
|
||||
const observe = () => {
|
||||
cleanup()
|
||||
|
||||
if (!isSupported || !target.value) return
|
||||
|
||||
observer = new IntersectionObserver((entries) => {
|
||||
isIntersecting.value = entries.some((entry) => entry.isIntersecting)
|
||||
callback(entries, observer!)
|
||||
}, observerOptions)
|
||||
|
||||
observer.observe(target.value)
|
||||
}
|
||||
|
||||
const unobserve = () => {
|
||||
if (observer && target.value) {
|
||||
observer.unobserve(target.value)
|
||||
}
|
||||
}
|
||||
|
||||
if (immediate) {
|
||||
watch(target, observe, { immediate: true, flush: 'post' })
|
||||
}
|
||||
|
||||
onBeforeUnmount(cleanup)
|
||||
|
||||
return {
|
||||
isSupported,
|
||||
isIntersecting,
|
||||
observe,
|
||||
unobserve,
|
||||
cleanup
|
||||
}
|
||||
}
|
||||
107
src/composables/useLazyPagination.ts
Normal file
107
src/composables/useLazyPagination.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { type Ref, computed, ref, shallowRef, watch } from 'vue'
|
||||
|
||||
export interface LazyPaginationOptions {
|
||||
itemsPerPage?: number
|
||||
initialPage?: number
|
||||
}
|
||||
|
||||
export function useLazyPagination<T>(
|
||||
items: Ref<T[]> | T[],
|
||||
options: LazyPaginationOptions = {}
|
||||
) {
|
||||
const { itemsPerPage = 12, initialPage = 1 } = options
|
||||
|
||||
const currentPage = ref(initialPage)
|
||||
const isLoading = ref(false)
|
||||
const loadedPages = shallowRef(new Set<number>([]))
|
||||
|
||||
// Get reactive items array
|
||||
const itemsArray = computed(() => {
|
||||
const itemData = 'value' in items ? items.value : items
|
||||
return Array.isArray(itemData) ? itemData : []
|
||||
})
|
||||
|
||||
// Simulate pagination by slicing the items
|
||||
const paginatedItems = computed(() => {
|
||||
const itemData = itemsArray.value
|
||||
if (itemData.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const loadedPageNumbers = Array.from(loadedPages.value).sort(
|
||||
(a, b) => a - b
|
||||
)
|
||||
const maxLoadedPage = Math.max(...loadedPageNumbers, 0)
|
||||
const endIndex = maxLoadedPage * itemsPerPage
|
||||
return itemData.slice(0, endIndex)
|
||||
})
|
||||
|
||||
const hasMoreItems = computed(() => {
|
||||
const itemData = itemsArray.value
|
||||
if (itemData.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
const loadedPagesArray = Array.from(loadedPages.value)
|
||||
const maxLoadedPage = Math.max(...loadedPagesArray, 0)
|
||||
return maxLoadedPage * itemsPerPage < itemData.length
|
||||
})
|
||||
|
||||
const totalPages = computed(() => {
|
||||
const itemData = itemsArray.value
|
||||
if (itemData.length === 0) {
|
||||
return 0
|
||||
}
|
||||
return Math.ceil(itemData.length / itemsPerPage)
|
||||
})
|
||||
|
||||
const loadNextPage = async () => {
|
||||
if (isLoading.value || !hasMoreItems.value) return
|
||||
|
||||
isLoading.value = true
|
||||
const loadedPagesArray = Array.from(loadedPages.value)
|
||||
const nextPage = Math.max(...loadedPagesArray, 0) + 1
|
||||
|
||||
// Simulate network delay
|
||||
// await new Promise((resolve) => setTimeout(resolve, 5000))
|
||||
|
||||
const newLoadedPages = new Set(loadedPages.value)
|
||||
newLoadedPages.add(nextPage)
|
||||
loadedPages.value = newLoadedPages
|
||||
currentPage.value = nextPage
|
||||
isLoading.value = false
|
||||
}
|
||||
|
||||
// Initialize with first page
|
||||
watch(
|
||||
() => itemsArray.value.length,
|
||||
(length) => {
|
||||
if (length > 0 && loadedPages.value.size === 0) {
|
||||
loadedPages.value = new Set([1])
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const reset = () => {
|
||||
currentPage.value = initialPage
|
||||
loadedPages.value = new Set([])
|
||||
isLoading.value = false
|
||||
|
||||
// Immediately load first page if we have items
|
||||
const itemData = itemsArray.value
|
||||
if (itemData.length > 0) {
|
||||
loadedPages.value = new Set([1])
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
paginatedItems,
|
||||
isLoading,
|
||||
hasMoreItems,
|
||||
currentPage,
|
||||
totalPages,
|
||||
loadNextPage,
|
||||
reset
|
||||
}
|
||||
}
|
||||
56
src/composables/useTemplateFiltering.ts
Normal file
56
src/composables/useTemplateFiltering.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { type Ref, computed, ref } from 'vue'
|
||||
|
||||
import type { TemplateInfo } from '@/types/workflowTemplateTypes'
|
||||
|
||||
export interface TemplateFilterOptions {
|
||||
searchQuery?: string
|
||||
}
|
||||
|
||||
export function useTemplateFiltering(
|
||||
templates: Ref<TemplateInfo[]> | TemplateInfo[]
|
||||
) {
|
||||
const searchQuery = ref('')
|
||||
|
||||
const templatesArray = computed(() => {
|
||||
const templateData = 'value' in templates ? templates.value : templates
|
||||
return Array.isArray(templateData) ? templateData : []
|
||||
})
|
||||
|
||||
const filteredTemplates = computed(() => {
|
||||
const templateData = templatesArray.value
|
||||
if (templateData.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
if (!searchQuery.value.trim()) {
|
||||
return templateData
|
||||
}
|
||||
|
||||
const query = searchQuery.value.toLowerCase().trim()
|
||||
return templateData.filter((template) => {
|
||||
const searchableText = [
|
||||
template.name,
|
||||
template.description,
|
||||
template.sourceModule
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
|
||||
return searchableText.includes(query)
|
||||
})
|
||||
})
|
||||
|
||||
const resetFilters = () => {
|
||||
searchQuery.value = ''
|
||||
}
|
||||
|
||||
const filteredCount = computed(() => filteredTemplates.value.length)
|
||||
|
||||
return {
|
||||
searchQuery,
|
||||
filteredTemplates,
|
||||
filteredCount,
|
||||
resetFilters
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@
|
||||
"confirmed": "Confirmed",
|
||||
"reset": "Reset",
|
||||
"resetAll": "Reset All",
|
||||
"clearFilters": "Clear Filters",
|
||||
"resetAllKeybindingsTooltip": "Reset all keybindings to default",
|
||||
"customizeFolder": "Customize Folder",
|
||||
"icon": "Icon",
|
||||
@@ -549,6 +550,8 @@
|
||||
},
|
||||
"templateWorkflows": {
|
||||
"title": "Get Started with a Template",
|
||||
"loadingMore": "Loading more templates...",
|
||||
"searchPlaceholder": "Search templates...",
|
||||
"category": {
|
||||
"ComfyUI Examples": "ComfyUI Examples",
|
||||
"Custom Nodes": "Custom Nodes",
|
||||
|
||||
@@ -272,6 +272,7 @@
|
||||
"category": "Categoría",
|
||||
"choose_file_to_upload": "elige archivo para subir",
|
||||
"clear": "Limpiar",
|
||||
"clearFilters": "Borrar filtros",
|
||||
"close": "Cerrar",
|
||||
"color": "Color",
|
||||
"comingSoon": "Próximamente",
|
||||
@@ -1231,6 +1232,8 @@
|
||||
"Video": "Video",
|
||||
"Video API": "API de Video"
|
||||
},
|
||||
"loadingMore": "Cargando más plantillas...",
|
||||
"searchPlaceholder": "Buscar plantillas...",
|
||||
"template": {
|
||||
"3D": {
|
||||
"3d_hunyuan3d_image_to_model": "Hunyuan3D 2.0",
|
||||
|
||||
@@ -272,6 +272,7 @@
|
||||
"category": "Catégorie",
|
||||
"choose_file_to_upload": "choisissez le fichier à télécharger",
|
||||
"clear": "Effacer",
|
||||
"clearFilters": "Effacer les filtres",
|
||||
"close": "Fermer",
|
||||
"color": "Couleur",
|
||||
"comingSoon": "Bientôt disponible",
|
||||
@@ -1231,6 +1232,8 @@
|
||||
"Video": "Vidéo",
|
||||
"Video API": "API vidéo"
|
||||
},
|
||||
"loadingMore": "Chargement de plus de modèles...",
|
||||
"searchPlaceholder": "Rechercher des modèles...",
|
||||
"template": {
|
||||
"3D": {
|
||||
"3d_hunyuan3d_image_to_model": "Hunyuan3D",
|
||||
|
||||
@@ -272,6 +272,7 @@
|
||||
"category": "カテゴリ",
|
||||
"choose_file_to_upload": "アップロードするファイルを選択",
|
||||
"clear": "クリア",
|
||||
"clearFilters": "フィルターをクリア",
|
||||
"close": "閉じる",
|
||||
"color": "色",
|
||||
"comingSoon": "近日公開",
|
||||
@@ -1231,6 +1232,8 @@
|
||||
"Video": "ビデオ",
|
||||
"Video API": "動画API"
|
||||
},
|
||||
"loadingMore": "さらにテンプレートを読み込み中...",
|
||||
"searchPlaceholder": "テンプレートを検索...",
|
||||
"template": {
|
||||
"3D": {
|
||||
"3d_hunyuan3d_image_to_model": "Hunyuan3D",
|
||||
|
||||
@@ -272,6 +272,7 @@
|
||||
"category": "카테고리",
|
||||
"choose_file_to_upload": "업로드할 파일 선택",
|
||||
"clear": "지우기",
|
||||
"clearFilters": "필터 지우기",
|
||||
"close": "닫기",
|
||||
"color": "색상",
|
||||
"comingSoon": "곧 출시 예정",
|
||||
@@ -1231,6 +1232,8 @@
|
||||
"Video": "비디오",
|
||||
"Video API": "비디오 API"
|
||||
},
|
||||
"loadingMore": "템플릿을 더 불러오는 중...",
|
||||
"searchPlaceholder": "템플릿 검색...",
|
||||
"template": {
|
||||
"3D": {
|
||||
"3d_hunyuan3d_image_to_model": "Hunyuan3D 2.0",
|
||||
|
||||
@@ -272,6 +272,7 @@
|
||||
"category": "Категория",
|
||||
"choose_file_to_upload": "выберите файл для загрузки",
|
||||
"clear": "Очистить",
|
||||
"clearFilters": "Сбросить фильтры",
|
||||
"close": "Закрыть",
|
||||
"color": "Цвет",
|
||||
"comingSoon": "Скоро будет",
|
||||
@@ -1231,6 +1232,8 @@
|
||||
"Video": "Видео",
|
||||
"Video API": "Video API"
|
||||
},
|
||||
"loadingMore": "Загрузка дополнительных шаблонов...",
|
||||
"searchPlaceholder": "Поиск шаблонов...",
|
||||
"template": {
|
||||
"3D": {
|
||||
"3d_hunyuan3d_image_to_model": "Hunyuan3D",
|
||||
|
||||
@@ -272,6 +272,7 @@
|
||||
"category": "分類",
|
||||
"choose_file_to_upload": "選擇要上傳的檔案",
|
||||
"clear": "清除",
|
||||
"clearFilters": "清除篩選",
|
||||
"close": "關閉",
|
||||
"color": "顏色",
|
||||
"comingSoon": "即將推出",
|
||||
@@ -1231,6 +1232,8 @@
|
||||
"Video": "影片",
|
||||
"Video API": "影片 API"
|
||||
},
|
||||
"loadingMore": "正在載入更多範本...",
|
||||
"searchPlaceholder": "搜尋範本...",
|
||||
"template": {
|
||||
"3D": {
|
||||
"3d_hunyuan3d_image_to_model": "Hunyuan3D 2.0",
|
||||
|
||||
@@ -272,6 +272,7 @@
|
||||
"category": "类别",
|
||||
"choose_file_to_upload": "选择要上传的文件",
|
||||
"clear": "清除",
|
||||
"clearFilters": "清除篩選",
|
||||
"close": "关闭",
|
||||
"color": "颜色",
|
||||
"comingSoon": "即将推出",
|
||||
@@ -1231,6 +1232,8 @@
|
||||
"Video": "视频生成",
|
||||
"Video API": "视频 API"
|
||||
},
|
||||
"loadingMore": "正在載入更多範本...",
|
||||
"searchPlaceholder": "搜尋範本...",
|
||||
"template": {
|
||||
"3D": {
|
||||
"3d_hunyuan3d_image_to_model": "混元3D 2.0 图生模型",
|
||||
|
||||
226
src/services/mediaCacheService.ts
Normal file
226
src/services/mediaCacheService.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import { reactive } from 'vue'
|
||||
|
||||
export interface CachedMedia {
|
||||
src: string
|
||||
blob?: Blob
|
||||
objectUrl?: string
|
||||
error?: boolean
|
||||
isLoading: boolean
|
||||
lastAccessed: number
|
||||
}
|
||||
|
||||
export interface MediaCacheOptions {
|
||||
maxSize?: number
|
||||
maxAge?: number // in milliseconds
|
||||
preloadDistance?: number // pixels from viewport
|
||||
}
|
||||
|
||||
class MediaCacheService {
|
||||
public cache = reactive(new Map<string, CachedMedia>())
|
||||
private readonly maxSize: number
|
||||
private readonly maxAge: number
|
||||
private cleanupInterval: number | null = null
|
||||
private urlRefCount = new Map<string, number>()
|
||||
|
||||
constructor(options: MediaCacheOptions = {}) {
|
||||
this.maxSize = options.maxSize ?? 100
|
||||
this.maxAge = options.maxAge ?? 30 * 60 * 1000 // 30 minutes
|
||||
|
||||
// Start cleanup interval
|
||||
this.startCleanupInterval()
|
||||
}
|
||||
|
||||
private startCleanupInterval() {
|
||||
// Clean up every 5 minutes
|
||||
this.cleanupInterval = window.setInterval(
|
||||
() => {
|
||||
this.cleanup()
|
||||
},
|
||||
5 * 60 * 1000
|
||||
)
|
||||
}
|
||||
|
||||
private cleanup() {
|
||||
const now = Date.now()
|
||||
const keysToDelete: string[] = []
|
||||
|
||||
// Find expired entries
|
||||
for (const [key, entry] of Array.from(this.cache.entries())) {
|
||||
if (now - entry.lastAccessed > this.maxAge) {
|
||||
// Only revoke object URL if no components are using it
|
||||
if (entry.objectUrl) {
|
||||
const refCount = this.urlRefCount.get(entry.objectUrl) || 0
|
||||
if (refCount === 0) {
|
||||
URL.revokeObjectURL(entry.objectUrl)
|
||||
this.urlRefCount.delete(entry.objectUrl)
|
||||
keysToDelete.push(key)
|
||||
}
|
||||
// Don't delete cache entry if URL is still in use
|
||||
} else {
|
||||
keysToDelete.push(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove expired entries
|
||||
keysToDelete.forEach((key) => this.cache.delete(key))
|
||||
|
||||
// If still over size limit, remove oldest entries that aren't in use
|
||||
if (this.cache.size > this.maxSize) {
|
||||
const entries = Array.from(this.cache.entries())
|
||||
entries.sort((a, b) => a[1].lastAccessed - b[1].lastAccessed)
|
||||
|
||||
let removedCount = 0
|
||||
const targetRemoveCount = this.cache.size - this.maxSize
|
||||
|
||||
for (const [key, entry] of entries) {
|
||||
if (removedCount >= targetRemoveCount) break
|
||||
|
||||
if (entry.objectUrl) {
|
||||
const refCount = this.urlRefCount.get(entry.objectUrl) || 0
|
||||
if (refCount === 0) {
|
||||
URL.revokeObjectURL(entry.objectUrl)
|
||||
this.urlRefCount.delete(entry.objectUrl)
|
||||
this.cache.delete(key)
|
||||
removedCount++
|
||||
}
|
||||
} else {
|
||||
this.cache.delete(key)
|
||||
removedCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getCachedMedia(src: string): Promise<CachedMedia> {
|
||||
let entry = this.cache.get(src)
|
||||
|
||||
if (entry) {
|
||||
// Update last accessed time
|
||||
entry.lastAccessed = Date.now()
|
||||
return entry
|
||||
}
|
||||
|
||||
// Create new entry
|
||||
entry = {
|
||||
src,
|
||||
isLoading: true,
|
||||
lastAccessed: Date.now()
|
||||
}
|
||||
|
||||
// Update cache with loading entry
|
||||
this.cache.set(src, entry)
|
||||
|
||||
try {
|
||||
// Fetch the media
|
||||
const response = await fetch(src)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch: ${response.status}`)
|
||||
}
|
||||
|
||||
const blob = await response.blob()
|
||||
const objectUrl = URL.createObjectURL(blob)
|
||||
|
||||
// Update entry with successful result
|
||||
const updatedEntry: CachedMedia = {
|
||||
src,
|
||||
blob,
|
||||
objectUrl,
|
||||
isLoading: false,
|
||||
lastAccessed: Date.now()
|
||||
}
|
||||
|
||||
this.cache.set(src, updatedEntry)
|
||||
return updatedEntry
|
||||
} catch (error) {
|
||||
console.warn('Failed to cache media:', src, error)
|
||||
|
||||
// Update entry with error
|
||||
const errorEntry: CachedMedia = {
|
||||
src,
|
||||
error: true,
|
||||
isLoading: false,
|
||||
lastAccessed: Date.now()
|
||||
}
|
||||
|
||||
this.cache.set(src, errorEntry)
|
||||
return errorEntry
|
||||
}
|
||||
}
|
||||
|
||||
acquireUrl(src: string): string | undefined {
|
||||
const entry = this.cache.get(src)
|
||||
if (entry?.objectUrl) {
|
||||
const currentCount = this.urlRefCount.get(entry.objectUrl) || 0
|
||||
this.urlRefCount.set(entry.objectUrl, currentCount + 1)
|
||||
return entry.objectUrl
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
releaseUrl(src: string): void {
|
||||
const entry = this.cache.get(src)
|
||||
if (entry?.objectUrl) {
|
||||
const count = (this.urlRefCount.get(entry.objectUrl) || 1) - 1
|
||||
if (count <= 0) {
|
||||
URL.revokeObjectURL(entry.objectUrl)
|
||||
this.urlRefCount.delete(entry.objectUrl)
|
||||
// Remove from cache as well
|
||||
this.cache.delete(src)
|
||||
} else {
|
||||
this.urlRefCount.set(entry.objectUrl, count)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
clearCache() {
|
||||
// Revoke all object URLs
|
||||
for (const entry of Array.from(this.cache.values())) {
|
||||
if (entry.objectUrl) {
|
||||
URL.revokeObjectURL(entry.objectUrl)
|
||||
}
|
||||
}
|
||||
this.cache.clear()
|
||||
this.urlRefCount.clear()
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.cleanupInterval) {
|
||||
clearInterval(this.cleanupInterval)
|
||||
this.cleanupInterval = null
|
||||
}
|
||||
this.clearCache()
|
||||
}
|
||||
}
|
||||
|
||||
// Global instance
|
||||
export let mediaCacheInstance: MediaCacheService | null = null
|
||||
|
||||
export function useMediaCache(options?: MediaCacheOptions) {
|
||||
if (!mediaCacheInstance) {
|
||||
mediaCacheInstance = new MediaCacheService(options)
|
||||
}
|
||||
|
||||
const getCachedMedia = (src: string) =>
|
||||
mediaCacheInstance!.getCachedMedia(src)
|
||||
const clearCache = () => mediaCacheInstance!.clearCache()
|
||||
const acquireUrl = (src: string) => mediaCacheInstance!.acquireUrl(src)
|
||||
const releaseUrl = (src: string) => mediaCacheInstance!.releaseUrl(src)
|
||||
|
||||
return {
|
||||
getCachedMedia,
|
||||
clearCache,
|
||||
acquireUrl,
|
||||
releaseUrl,
|
||||
cache: mediaCacheInstance.cache
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup on page unload
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('beforeunload', () => {
|
||||
if (mediaCacheInstance) {
|
||||
mediaCacheInstance.destroy()
|
||||
}
|
||||
})
|
||||
}
|
||||
35
tests-ui/tests/services/mediaCacheService.test.ts
Normal file
35
tests-ui/tests/services/mediaCacheService.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useMediaCache } from '../../../src/services/mediaCacheService'
|
||||
|
||||
// Mock fetch
|
||||
global.fetch = vi.fn()
|
||||
global.URL = {
|
||||
createObjectURL: vi.fn(() => 'blob:mock-url'),
|
||||
revokeObjectURL: vi.fn()
|
||||
} as any
|
||||
|
||||
describe('mediaCacheService', () => {
|
||||
describe('URL reference counting', () => {
|
||||
it('should handle URL acquisition for non-existent cache entry', () => {
|
||||
const { acquireUrl } = useMediaCache()
|
||||
|
||||
const url = acquireUrl('non-existent.jpg')
|
||||
expect(url).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should handle URL release for non-existent cache entry', () => {
|
||||
const { releaseUrl } = useMediaCache()
|
||||
|
||||
// Should not throw error
|
||||
expect(() => releaseUrl('non-existent.jpg')).not.toThrow()
|
||||
})
|
||||
|
||||
it('should provide acquireUrl and releaseUrl methods', () => {
|
||||
const cache = useMediaCache()
|
||||
|
||||
expect(typeof cache.acquireUrl).toBe('function')
|
||||
expect(typeof cache.releaseUrl).toBe('function')
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user