mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-20 06:20:11 +00:00
Add text ticker overflow description
This commit is contained in:
101
src/components/common/TextTicker.test.ts
Normal file
101
src/components/common/TextTicker.test.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { nextTick } from 'vue'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import TextTicker from './TextTicker.vue'
|
||||
|
||||
function mockOverflow(
|
||||
el: HTMLElement,
|
||||
{ scrollWidth, clientWidth }: { scrollWidth: number; clientWidth: number }
|
||||
) {
|
||||
Object.defineProperty(el, 'scrollWidth', {
|
||||
value: scrollWidth,
|
||||
configurable: true
|
||||
})
|
||||
Object.defineProperty(el, 'clientWidth', {
|
||||
value: clientWidth,
|
||||
configurable: true
|
||||
})
|
||||
}
|
||||
|
||||
describe('TextTicker', () => {
|
||||
let rafCallbacks: ((time: number) => void)[]
|
||||
|
||||
beforeEach(() => {
|
||||
rafCallbacks = []
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
|
||||
rafCallbacks.push(cb)
|
||||
return rafCallbacks.length
|
||||
})
|
||||
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('renders slot content', () => {
|
||||
const wrapper = mount(TextTicker, {
|
||||
slots: { default: 'Hello World' }
|
||||
})
|
||||
expect(wrapper.text()).toBe('Hello World')
|
||||
})
|
||||
|
||||
it('scrolls on hover when content overflows', async () => {
|
||||
const wrapper = mount(TextTicker, {
|
||||
slots: { default: 'Very long text that overflows' },
|
||||
props: { speed: 100 }
|
||||
})
|
||||
|
||||
const el = wrapper.element as HTMLElement
|
||||
mockOverflow(el, { scrollWidth: 300, clientWidth: 100 })
|
||||
|
||||
// Allow useElementHover to set up listeners after mount
|
||||
await nextTick()
|
||||
await wrapper.trigger('mouseenter')
|
||||
await nextTick()
|
||||
|
||||
expect(rafCallbacks.length).toBeGreaterThan(0)
|
||||
|
||||
// Simulate animation frame - should set scrollLeft
|
||||
rafCallbacks[0](performance.now() + 500)
|
||||
expect(el.scrollLeft).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('resets scroll position on mouse leave', async () => {
|
||||
const wrapper = mount(TextTicker, {
|
||||
slots: { default: 'Very long text that overflows' },
|
||||
props: { speed: 100 }
|
||||
})
|
||||
|
||||
const el = wrapper.element as HTMLElement
|
||||
mockOverflow(el, { scrollWidth: 300, clientWidth: 100 })
|
||||
|
||||
await nextTick()
|
||||
await wrapper.trigger('mouseenter')
|
||||
await nextTick()
|
||||
|
||||
rafCallbacks[0](performance.now() + 500)
|
||||
expect(el.scrollLeft).toBeGreaterThan(0)
|
||||
|
||||
await wrapper.trigger('mouseleave')
|
||||
await nextTick()
|
||||
|
||||
expect(el.scrollLeft).toBe(0)
|
||||
})
|
||||
|
||||
it('does not scroll when content fits', async () => {
|
||||
const wrapper = mount(TextTicker, {
|
||||
slots: { default: 'Short' }
|
||||
})
|
||||
|
||||
const el = wrapper.element as HTMLElement
|
||||
mockOverflow(el, { scrollWidth: 50, clientWidth: 100 })
|
||||
|
||||
await nextTick()
|
||||
await wrapper.trigger('mouseenter')
|
||||
await nextTick()
|
||||
|
||||
expect(rafCallbacks.length).toBe(0)
|
||||
})
|
||||
})
|
||||
69
src/components/common/TextTicker.vue
Normal file
69
src/components/common/TextTicker.vue
Normal file
@@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<div
|
||||
ref="containerRef"
|
||||
:class="
|
||||
cn('overflow-hidden whitespace-nowrap', !isScrolling && 'text-ellipsis')
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useElementHover } from '@vueuse/core'
|
||||
import { onBeforeUnmount, ref, watch } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { speed = 80 } = defineProps<{
|
||||
/** Scroll speed in pixels per second */
|
||||
speed?: number
|
||||
}>()
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
const isHovered = useElementHover(containerRef)
|
||||
const isScrolling = ref(false)
|
||||
let animationId: number | null = null
|
||||
|
||||
function startScroll() {
|
||||
const el = containerRef.value
|
||||
if (!el) return
|
||||
|
||||
const overflow = el.scrollWidth - el.clientWidth
|
||||
if (overflow <= 0) return
|
||||
|
||||
isScrolling.value = true
|
||||
const duration = (overflow / speed) * 1000
|
||||
const startTime = performance.now()
|
||||
|
||||
function animate(currentTime: number) {
|
||||
const elapsed = currentTime - startTime
|
||||
const progress = Math.min(elapsed / duration, 1)
|
||||
el!.scrollLeft = overflow * progress
|
||||
|
||||
if (progress < 1) {
|
||||
animationId = requestAnimationFrame(animate)
|
||||
}
|
||||
}
|
||||
|
||||
animationId = requestAnimationFrame(animate)
|
||||
}
|
||||
|
||||
function stopScroll() {
|
||||
if (animationId !== null) {
|
||||
cancelAnimationFrame(animationId)
|
||||
animationId = null
|
||||
}
|
||||
if (containerRef.value) {
|
||||
containerRef.value.scrollLeft = 0
|
||||
}
|
||||
isScrolling.value = false
|
||||
}
|
||||
|
||||
watch(isHovered, (hovered) => {
|
||||
if (hovered) startScroll()
|
||||
else stopScroll()
|
||||
})
|
||||
|
||||
onBeforeUnmount(stopScroll)
|
||||
</script>
|
||||
@@ -2,7 +2,7 @@
|
||||
<div
|
||||
class="option-container flex w-full cursor-pointer items-center justify-between overflow-hidden"
|
||||
>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<div class="flex flex-col gap-0.5 overflow-hidden">
|
||||
<div class="font-semibold text-foreground flex items-center gap-2">
|
||||
<span v-if="isBookmarked && !hideBookmarkIcon">
|
||||
<i class="pi pi-bookmark-fill mr-1 text-sm" />
|
||||
@@ -32,9 +32,9 @@
|
||||
>
|
||||
{{ nodeDef.nodeSource.displayText }}
|
||||
</span>
|
||||
<span v-if="nodeDef.description" class="truncate">
|
||||
<TextTicker v-if="nodeDef.description">
|
||||
{{ nodeDef.description }}
|
||||
</span>
|
||||
</TextTicker>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="showCategory"
|
||||
@@ -81,6 +81,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import TextTicker from '@/components/common/TextTicker.vue'
|
||||
import NodePricingBadge from '@/components/node/NodePricingBadge.vue'
|
||||
import NodeProviderBadge from '@/components/node/NodeProviderBadge.vue'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
|
||||
Reference in New Issue
Block a user