2025-07-04 11:29:23 +08:00
|
|
|
|
<template>
|
|
|
|
|
|
<div ref="containerRef" :class="['marquee-container relative overflow-hidden', containerClass]">
|
|
|
|
|
|
<div
|
|
|
|
|
|
ref="contentRef"
|
|
|
|
|
|
class="marquee-content flex gap-[50px] whitespace-nowrap"
|
|
|
|
|
|
:style="{
|
|
|
|
|
|
'--marquee-gap': '50px',
|
|
|
|
|
|
'--initial-offset': initialOffset + 'px' // 传递初始偏移变量
|
|
|
|
|
|
}"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div ref="originalRef" class="original-content flex gap-[50px]">
|
|
|
|
|
|
<slot />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="clone-content flex gap-[50px]">
|
|
|
|
|
|
<slot />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script lang="ts" setup>
|
|
|
|
|
|
import { ref, onMounted, watchEffect } from 'vue'
|
|
|
|
|
|
|
|
|
|
|
|
// 接收初始偏移量props
|
|
|
|
|
|
const props = withDefaults(
|
|
|
|
|
|
defineProps<{
|
|
|
|
|
|
duration: number
|
|
|
|
|
|
reverse?: boolean
|
|
|
|
|
|
repeatCount?: number | string
|
|
|
|
|
|
pauseOnHover?: boolean
|
|
|
|
|
|
containerClass?: string
|
|
|
|
|
|
initialOffset?: number // 新增:初始偏移量(px,负数向左偏)
|
|
|
|
|
|
}>(),
|
|
|
|
|
|
{
|
|
|
|
|
|
reverse: false,
|
|
|
|
|
|
repeatCount: 3,
|
|
|
|
|
|
pauseOnHover: true,
|
|
|
|
|
|
initialOffset: 0, // 默认无偏移
|
|
|
|
|
|
containerClass: '' // 添加默认值为空字符串
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
const containerRef = ref<HTMLDivElement | null>(null)
|
|
|
|
|
|
const contentRef = ref<HTMLDivElement | null>(null)
|
|
|
|
|
|
const originalRef = ref<HTMLDivElement | null>(null)
|
|
|
|
|
|
const originalWidth = ref(0)
|
|
|
|
|
|
|
|
|
|
|
|
// 计算单份内容宽度
|
|
|
|
|
|
const calculateWidth = () => {
|
|
|
|
|
|
if (originalRef.value) {
|
|
|
|
|
|
originalWidth.value = originalRef.value.offsetWidth
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 动态生成动画(包含初始偏移)
|
|
|
|
|
|
const updateAnimation = () => {
|
|
|
|
|
|
if (!contentRef.value || !originalRef.value) return
|
|
|
|
|
|
|
|
|
|
|
|
// 动画方向
|
|
|
|
|
|
const direction = props.reverse ? 'reverse' : ''
|
|
|
|
|
|
contentRef.value.style.animation = `marquee ${props.duration}s linear infinite ${direction}`
|
|
|
|
|
|
|
|
|
|
|
|
// 关键帧:从初始偏移位置开始滚动
|
|
|
|
|
|
const style = document.createElement('style')
|
|
|
|
|
|
style.id = 'marquee-keyframes'
|
|
|
|
|
|
style.textContent = `
|
2025-07-11 14:51:12 +08:00
|
|
|
|
@keyframes marquee {
|
|
|
|
|
|
0% { transform: translateX(var(--initial-offset)); }
|
|
|
|
|
|
/* 滚动距离 = 原始内容宽度,确保克隆内容衔接 */
|
|
|
|
|
|
100% { transform: translateX(calc(var(--initial-offset) - ${originalWidth.value}px)); }
|
|
|
|
|
|
}
|
|
|
|
|
|
`
|
2025-07-04 11:29:23 +08:00
|
|
|
|
|
|
|
|
|
|
// 替换旧关键帧
|
|
|
|
|
|
const existingStyle = document.head.querySelector('#marquee-keyframes')
|
|
|
|
|
|
if (existingStyle) document.head.removeChild(existingStyle)
|
|
|
|
|
|
document.head.appendChild(style)
|
|
|
|
|
|
}
|
2025-07-11 14:51:12 +08:00
|
|
|
|
// 图片加载完成后重新计算宽度
|
|
|
|
|
|
const watchImagesLoad = () => {
|
|
|
|
|
|
if (!originalRef.value) return
|
|
|
|
|
|
// 获取插槽中的所有图片
|
|
|
|
|
|
const images = originalRef.value.querySelectorAll('img')
|
|
|
|
|
|
if (images.length === 0) return
|
2025-07-04 11:29:23 +08:00
|
|
|
|
|
2025-07-11 14:51:12 +08:00
|
|
|
|
// 监听所有图片加载完成
|
|
|
|
|
|
let loadedCount = 0
|
|
|
|
|
|
images.forEach(img => {
|
|
|
|
|
|
// 图片已加载完成
|
|
|
|
|
|
if (img.complete) {
|
|
|
|
|
|
loadedCount++
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 图片加载完成后触发
|
|
|
|
|
|
img.addEventListener('load', () => {
|
|
|
|
|
|
loadedCount++
|
|
|
|
|
|
if (loadedCount === images.length) {
|
|
|
|
|
|
nextTick(calculateWidth) // 所有图片加载后重新计算宽度
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
2025-07-04 11:29:23 +08:00
|
|
|
|
// 初始化与监听
|
|
|
|
|
|
onMounted(() => {
|
2025-07-11 14:51:12 +08:00
|
|
|
|
nextTick(() => {
|
|
|
|
|
|
// 确保DOM渲染完成
|
|
|
|
|
|
calculateWidth()
|
|
|
|
|
|
watchImagesLoad() // 监听图片加载
|
|
|
|
|
|
updateAnimation()
|
|
|
|
|
|
})
|
2025-07-04 11:29:23 +08:00
|
|
|
|
})
|
|
|
|
|
|
watchEffect(() => {
|
|
|
|
|
|
calculateWidth()
|
|
|
|
|
|
updateAnimation()
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 悬停暂停逻辑
|
|
|
|
|
|
watchEffect(() => {
|
|
|
|
|
|
if (containerRef.value && props.pauseOnHover) {
|
|
|
|
|
|
containerRef.value.addEventListener('mouseenter', () => {
|
|
|
|
|
|
if (contentRef.value) contentRef.value.style.animationPlayState = 'paused'
|
|
|
|
|
|
})
|
|
|
|
|
|
containerRef.value.addEventListener('mouseleave', () => {
|
|
|
|
|
|
if (contentRef.value) contentRef.value.style.animationPlayState = 'running'
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped lang="less">
|
|
|
|
|
|
.marquee-content {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-wrap: nowrap;
|
|
|
|
|
|
transition: transform 0.3s ease;
|
|
|
|
|
|
}
|
|
|
|
|
|
.original-content,
|
|
|
|
|
|
.clone-content {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-wrap: nowrap;
|
|
|
|
|
|
}
|
|
|
|
|
|
</style>
|