| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316 |
- import { nextTick, onMounted, onUnmounted, ref } from 'vue'
- import { throttle } from 'lodash'
- import { storeToRefs } from 'pinia'
- import { useSlidesStore } from '@/store'
- import { lang } from '@/main'
- import { KEYS } from '@/configs/hotkey'
- import { ANIMATION_CLASS_PREFIX } from '@/configs/animation'
- import message from '@/utils/message'
- export default () => {
- const slidesStore = useSlidesStore()
- const { slides, slideIndex, formatedAnimations } = storeToRefs(slidesStore)
- // 当前页的元素动画执行到的位置
- const animationIndex = ref(0)
- // 动画执行状态
- const inAnimation = ref(false)
- // 最小已播放页面索引
- const playedSlidesMinIndex = ref(slideIndex.value)
- // ★ 兜底定时器集合:动画若因中断/元素卸载导致 animationend 不触发,
- // 用定时器强制收尾,避免 inAnimation 卡死(进而翻页/退出失效)
- const fallbackTimers: ReturnType<typeof setTimeout>[] = []
- // 清理所有兜底定时器
- const clearFallbackTimers = () => {
- while (fallbackTimers.length) {
- clearTimeout(fallbackTimers.shift())
- }
- }
- // 执行元素动画
- const runAnimation = () => {
- // 正在执行动画时,禁止其他新的动画开始
- if (inAnimation.value) return
- // ★ 越界保护:autoNext 自动连播到最后一条时,animationIndex 会等于 length,
- // 此时没有更多动画可播,直接复位 inAnimation 避免卡死
- if (animationIndex.value >= formatedAnimations.value.length) {
- inAnimation.value = false
- return
- }
- const { animations, autoNext } = formatedAnimations.value[animationIndex.value]
- animationIndex.value += 1
- // 标记开始执行动画
- inAnimation.value = true
- let endAnimationCount = 0
- let finished = false
- // 统一收尾:当该位置的全部动画都结束(或不存在元素)后,标记完成并尝试继续
- const checkAndFinish = () => {
- if (finished) return
- if (endAnimationCount === animations.length) {
- finished = true
- inAnimation.value = false
- if (autoNext) runAnimation()
- }
- }
- // 该位置没有任何动画(理论上不会发生),直接收尾避免卡死
- if (animations.length === 0) {
- checkAndFinish()
- return
- }
- // 依次执行该位置中的全部动画
- for (const animation of animations) {
- const elRef: HTMLElement | null = document.querySelector(`#screen-element-${animation.elId} [class^=base-element-]`)
- if (!elRef) {
- // 元素尚未渲染或不存在:视为该动画立即完成,避免 inAnimation 卡死导致翻页/退出失效
- endAnimationCount += 1
- checkAndFinish()
- continue
- }
- const animationName = `${ANIMATION_CLASS_PREFIX}${animation.effect}`
- // 执行动画前先清除原有的动画状态(如果有)
- elRef.style.removeProperty('--animate-duration')
- for (const classname of elRef.classList) {
- if (classname.indexOf(ANIMATION_CLASS_PREFIX) !== -1) elRef.classList.remove(classname, `${ANIMATION_CLASS_PREFIX}animated`)
- }
-
- // 执行动画
- elRef.style.setProperty('--animate-duration', `${animation.duration}ms`)
- elRef.classList.add(animationName, `${ANIMATION_CLASS_PREFIX}animated`)
- // 单个动画的收尾(清理类名 + 推进计数),只执行一次
- let oneFinished = false
- const finishOne = () => {
- if (oneFinished) return
- oneFinished = true
- elRef.style.removeProperty('--animate-duration')
- if (animation.type !== 'out') {
- elRef.classList.remove(animationName, `${ANIMATION_CLASS_PREFIX}animated`)
- }
- endAnimationCount += 1
- checkAndFinish()
- }
- // 正常结束
- const handleAnimationEnd = () => finishOne()
- elRef.addEventListener('animationend', handleAnimationEnd, { once: true })
- // 动画被中断(如元素隐藏/卸载、CSS 动画被强制停止)也会触发
- elRef.addEventListener('animationcancel', handleAnimationEnd, { once: true })
- // ★ 兜底:即使 animationend/animationcancel 都未触发,也在 duration + 缓冲后强制收尾
- const fallbackTimer = setTimeout(finishOne, animation.duration + 200)
- fallbackTimers.push(fallbackTimer)
- }
- }
- onMounted(() => {
- const firstAnimations = formatedAnimations.value[0]
- if (firstAnimations && firstAnimations.animations.length) {
- const autoExecFirstAnimations = firstAnimations.animations.every(item => item.trigger === 'auto' || item.trigger === 'meantime')
- // 等 DOM 完全渲染后再自动播放,避免元素尚未挂载导致 elRef 为 null
- if (autoExecFirstAnimations) nextTick(() => runAnimation())
- }
- })
- // 撤销元素动画,除了将索引前移外,还需要清除动画状态
- const revokeAnimation = () => {
- animationIndex.value -= 1
- const { animations } = formatedAnimations.value[animationIndex.value]
- for (const animation of animations) {
- const elRef: HTMLElement | null = document.querySelector(`#screen-element-${animation.elId} [class^=base-element-]`)
- if (!elRef) continue
-
- elRef.style.removeProperty('--animate-duration')
- for (const classname of elRef.classList) {
- if (classname.indexOf(ANIMATION_CLASS_PREFIX) !== -1) elRef.classList.remove(classname, `${ANIMATION_CLASS_PREFIX}animated`)
- }
- }
- // 如果撤销时该位置有且仅有强调动画,则继续执行一次撤销
- if (animations.every(item => item.type === 'attention')) execPrev()
- }
- // 关闭自动播放
- const autoPlayTimer = ref(0)
- const closeAutoPlay = () => {
- if (autoPlayTimer.value) {
- clearInterval(autoPlayTimer.value)
- autoPlayTimer.value = 0
- }
- }
- // ★ 组件卸载时彻底清理:自动播放定时器 + 动画兜底定时器 + 动画状态,
- // 避免按 ESC 退出放映时,未播完的动画残留导致状态卡死
- onUnmounted(() => {
- closeAutoPlay()
- clearFallbackTimers()
- inAnimation.value = false
- })
- // 循环放映
- const loopPlay = ref(false)
- const setLoopPlay = (loop: boolean) => {
- loopPlay.value = loop
- }
- const throttleMassage = throttle(function(msg) {
- message.success(msg)
- }, 1000, { leading: true, trailing: false })
- // 向上/向下播放
- // 遇到元素动画时,优先执行动画播放,无动画则执行翻页
- // 向上播放遇到动画时,仅撤销到动画执行前的状态,不需要反向播放动画
- // 撤回到上一页时,若该页从未播放过(意味着不存在动画状态),需要将动画索引置为最小值(初始状态),否则置为最大值(最终状态)
- const execPrev = () => {
- if (formatedAnimations.value.length && animationIndex.value > 0) {
- revokeAnimation()
- }
- else if (slideIndex.value > 0) {
- slidesStore.updateSlideIndex(slideIndex.value - 1)
- if (slideIndex.value < playedSlidesMinIndex.value) {
- animationIndex.value = 0
- playedSlidesMinIndex.value = slideIndex.value
- }
- else animationIndex.value = formatedAnimations.value.length
- }
- else {
- if (loopPlay.value) turnSlideToIndex(slides.value.length - 1)
- else throttleMassage(lang.ssFirstPage)
- }
- inAnimation.value = false
- }
- const execNext = () => {
- if (formatedAnimations.value.length && animationIndex.value < formatedAnimations.value.length) {
- runAnimation()
- }
- else if (slideIndex.value < slides.value.length - 1) {
- slidesStore.updateSlideIndex(slideIndex.value + 1)
- animationIndex.value = 0
- inAnimation.value = false
- }
- else {
- if (loopPlay.value) turnSlideToIndex(0)
- else {
- throttleMassage(lang.ssLastPage)
- closeAutoPlay()
- }
- inAnimation.value = false
- }
- }
- // 自动播放
- const autoPlayInterval = ref(2500)
- const autoPlay = () => {
- closeAutoPlay()
- message.success(lang.ssAutoPlayStart)
- autoPlayTimer.value = setInterval(execNext, autoPlayInterval.value)
- }
- const setAutoPlayInterval = (interval: number) => {
- closeAutoPlay()
- autoPlayInterval.value = interval
- autoPlay()
- }
- // 鼠标滚动翻页
- const mousewheelListener = throttle(function(e: WheelEvent) {
- if (e.deltaY < 0) execPrev()
- else if (e.deltaY > 0) execNext()
- }, 500, { leading: true, trailing: false })
- // 触摸屏上下滑动翻页
- const touchInfo = ref<{ x: number; y: number; } | null>(null)
- const touchStartListener = (e: TouchEvent) => {
- touchInfo.value = {
- x: e.changedTouches[0].pageX,
- y: e.changedTouches[0].pageY,
- }
- }
- const touchEndListener = (e: TouchEvent) => {
- if (!touchInfo.value) return
- const offsetX = Math.abs(touchInfo.value.x - e.changedTouches[0].pageX)
- const offsetY = e.changedTouches[0].pageY - touchInfo.value.y
- if ( Math.abs(offsetY) > offsetX && Math.abs(offsetY) > 50 ) {
- touchInfo.value = null
- if (offsetY > 0) execPrev()
- else execNext()
- }
- }
- // 快捷键翻页
- const keydownListener = (e: KeyboardEvent) => {
- const key = e.key.toUpperCase()
- if (key === KEYS.UP || key === KEYS.LEFT || key === KEYS.PAGEUP) execPrev()
- else if (
- key === KEYS.DOWN ||
- key === KEYS.RIGHT ||
- // key === KEYS.SPACE ||
- // key === KEYS.ENTER ||
- key === KEYS.PAGEDOWN
- ) execNext()
- }
- onMounted(() => document.addEventListener('keydown', keydownListener))
- onUnmounted(() => document.removeEventListener('keydown', keydownListener))
- // 切换到上一张/上一张幻灯片(无视元素的入场动画)
- const turnPrevSlide = () => {
- slidesStore.updateSlideIndex(slideIndex.value - 1)
- animationIndex.value = 0
- }
- const turnNextSlide = () => {
- slidesStore.updateSlideIndex(slideIndex.value + 1)
- animationIndex.value = 0
- }
- // 切换幻灯片到指定的页面
- const turnSlideToIndex = (index: number) => {
- slidesStore.updateSlideIndex(index)
- animationIndex.value = 0
- }
- const turnSlideToId = (id: string) => {
- const index = slides.value.findIndex(slide => slide.id === id)
- if (index !== -1) {
- slidesStore.updateSlideIndex(index)
- animationIndex.value = 0
- }
- }
- return {
- autoPlayTimer,
- autoPlayInterval,
- setAutoPlayInterval,
- autoPlay,
- closeAutoPlay,
- loopPlay,
- setLoopPlay,
- mousewheelListener,
- touchStartListener,
- touchEndListener,
- turnPrevSlide,
- turnNextSlide,
- turnSlideToIndex,
- turnSlideToId,
- execPrev,
- execNext,
- animationIndex,
- }
- }
|