mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-05 15:40:10 +00:00
feat: add useIntersectionObserver, useLazyPagination, useTemplateFiltering, and mediaCacheService for improved component functionality
This commit is contained in:
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
|
||||
}
|
||||
}
|
||||
221
src/services/mediaCacheService.ts
Normal file
221
src/services/mediaCacheService.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import { shallowRef } 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 = shallowRef(new Map<string, CachedMedia>())
|
||||
private readonly maxSize: number
|
||||
private readonly maxAge: number
|
||||
private cleanupInterval: number | null = null
|
||||
|
||||
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 cacheMap = this.cache.value
|
||||
const keysToDelete: string[] = []
|
||||
|
||||
// Find expired entries
|
||||
for (const [key, entry] of Array.from(cacheMap.entries())) {
|
||||
if (now - entry.lastAccessed > this.maxAge) {
|
||||
keysToDelete.push(key)
|
||||
// Revoke object URL to free memory
|
||||
if (entry.objectUrl) {
|
||||
URL.revokeObjectURL(entry.objectUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove expired entries
|
||||
if (keysToDelete.length > 0) {
|
||||
const newCache = new Map(cacheMap)
|
||||
keysToDelete.forEach((key) => newCache.delete(key))
|
||||
this.cache.value = newCache
|
||||
}
|
||||
|
||||
// If still over size limit, remove oldest entries
|
||||
if (cacheMap.size > this.maxSize) {
|
||||
const entries = Array.from(cacheMap.entries())
|
||||
entries.sort((a, b) => a[1].lastAccessed - b[1].lastAccessed)
|
||||
|
||||
const toRemove = entries.slice(0, cacheMap.size - this.maxSize)
|
||||
const newCache = new Map(cacheMap)
|
||||
|
||||
toRemove.forEach(([key, entry]) => {
|
||||
if (entry.objectUrl) {
|
||||
URL.revokeObjectURL(entry.objectUrl)
|
||||
}
|
||||
newCache.delete(key)
|
||||
})
|
||||
|
||||
this.cache.value = newCache
|
||||
}
|
||||
}
|
||||
|
||||
async getCachedMedia(src: string): Promise<CachedMedia> {
|
||||
const cacheMap = this.cache.value
|
||||
let entry = cacheMap.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
|
||||
const newCache = new Map(cacheMap)
|
||||
newCache.set(src, entry)
|
||||
this.cache.value = newCache
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
const finalCache = new Map(this.cache.value)
|
||||
finalCache.set(src, updatedEntry)
|
||||
this.cache.value = finalCache
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
const errorCache = new Map(this.cache.value)
|
||||
errorCache.set(src, errorEntry)
|
||||
this.cache.value = errorCache
|
||||
|
||||
return errorEntry
|
||||
}
|
||||
}
|
||||
|
||||
getCacheStats() {
|
||||
const cacheMap = this.cache.value
|
||||
return {
|
||||
size: cacheMap.size,
|
||||
maxSize: this.maxSize,
|
||||
entries: Array.from(cacheMap.keys())
|
||||
}
|
||||
}
|
||||
|
||||
clearCache() {
|
||||
const cacheMap = this.cache.value
|
||||
// Revoke all object URLs
|
||||
for (const entry of Array.from(cacheMap.values())) {
|
||||
if (entry.objectUrl) {
|
||||
URL.revokeObjectURL(entry.objectUrl)
|
||||
}
|
||||
}
|
||||
this.cache.value = new Map()
|
||||
}
|
||||
|
||||
preloadMedia(urls: string[]) {
|
||||
// Preload media in the background without blocking
|
||||
urls.forEach((url) => {
|
||||
if (!this.cache.value.has(url)) {
|
||||
// Don't await - fire and forget
|
||||
this.getCachedMedia(url).catch(() => {
|
||||
// Ignore preload errors
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.cleanupInterval) {
|
||||
clearInterval(this.cleanupInterval)
|
||||
this.cleanupInterval = null
|
||||
}
|
||||
this.clearCache()
|
||||
}
|
||||
}
|
||||
|
||||
// Global instance
|
||||
let mediaCacheInstance: MediaCacheService | null = null
|
||||
|
||||
export function useMediaCache(options?: MediaCacheOptions) {
|
||||
if (!mediaCacheInstance) {
|
||||
mediaCacheInstance = new MediaCacheService(options)
|
||||
}
|
||||
|
||||
const getCachedMedia = (src: string) =>
|
||||
mediaCacheInstance!.getCachedMedia(src)
|
||||
const getCacheStats = () => mediaCacheInstance!.getCacheStats()
|
||||
const clearCache = () => mediaCacheInstance!.clearCache()
|
||||
const preloadMedia = (urls: string[]) =>
|
||||
mediaCacheInstance!.preloadMedia(urls)
|
||||
|
||||
return {
|
||||
getCachedMedia,
|
||||
getCacheStats,
|
||||
clearCache,
|
||||
preloadMedia,
|
||||
cache: mediaCacheInstance.cache
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup on page unload
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('beforeunload', () => {
|
||||
if (mediaCacheInstance) {
|
||||
mediaCacheInstance.destroy()
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user