useExecPlay.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316
  1. import { nextTick, onMounted, onUnmounted, ref } from 'vue'
  2. import { throttle } from 'lodash'
  3. import { storeToRefs } from 'pinia'
  4. import { useSlidesStore } from '@/store'
  5. import { lang } from '@/main'
  6. import { KEYS } from '@/configs/hotkey'
  7. import { ANIMATION_CLASS_PREFIX } from '@/configs/animation'
  8. import message from '@/utils/message'
  9. export default () => {
  10. const slidesStore = useSlidesStore()
  11. const { slides, slideIndex, formatedAnimations } = storeToRefs(slidesStore)
  12. // 当前页的元素动画执行到的位置
  13. const animationIndex = ref(0)
  14. // 动画执行状态
  15. const inAnimation = ref(false)
  16. // 最小已播放页面索引
  17. const playedSlidesMinIndex = ref(slideIndex.value)
  18. // ★ 兜底定时器集合:动画若因中断/元素卸载导致 animationend 不触发,
  19. // 用定时器强制收尾,避免 inAnimation 卡死(进而翻页/退出失效)
  20. const fallbackTimers: ReturnType<typeof setTimeout>[] = []
  21. // 清理所有兜底定时器
  22. const clearFallbackTimers = () => {
  23. while (fallbackTimers.length) {
  24. clearTimeout(fallbackTimers.shift())
  25. }
  26. }
  27. // 执行元素动画
  28. const runAnimation = () => {
  29. // 正在执行动画时,禁止其他新的动画开始
  30. if (inAnimation.value) return
  31. // ★ 越界保护:autoNext 自动连播到最后一条时,animationIndex 会等于 length,
  32. // 此时没有更多动画可播,直接复位 inAnimation 避免卡死
  33. if (animationIndex.value >= formatedAnimations.value.length) {
  34. inAnimation.value = false
  35. return
  36. }
  37. const { animations, autoNext } = formatedAnimations.value[animationIndex.value]
  38. animationIndex.value += 1
  39. // 标记开始执行动画
  40. inAnimation.value = true
  41. let endAnimationCount = 0
  42. let finished = false
  43. // 统一收尾:当该位置的全部动画都结束(或不存在元素)后,标记完成并尝试继续
  44. const checkAndFinish = () => {
  45. if (finished) return
  46. if (endAnimationCount === animations.length) {
  47. finished = true
  48. inAnimation.value = false
  49. if (autoNext) runAnimation()
  50. }
  51. }
  52. // 该位置没有任何动画(理论上不会发生),直接收尾避免卡死
  53. if (animations.length === 0) {
  54. checkAndFinish()
  55. return
  56. }
  57. // 依次执行该位置中的全部动画
  58. for (const animation of animations) {
  59. const elRef: HTMLElement | null = document.querySelector(`#screen-element-${animation.elId} [class^=base-element-]`)
  60. if (!elRef) {
  61. // 元素尚未渲染或不存在:视为该动画立即完成,避免 inAnimation 卡死导致翻页/退出失效
  62. endAnimationCount += 1
  63. checkAndFinish()
  64. continue
  65. }
  66. const animationName = `${ANIMATION_CLASS_PREFIX}${animation.effect}`
  67. // 执行动画前先清除原有的动画状态(如果有)
  68. elRef.style.removeProperty('--animate-duration')
  69. for (const classname of elRef.classList) {
  70. if (classname.indexOf(ANIMATION_CLASS_PREFIX) !== -1) elRef.classList.remove(classname, `${ANIMATION_CLASS_PREFIX}animated`)
  71. }
  72. // 执行动画
  73. elRef.style.setProperty('--animate-duration', `${animation.duration}ms`)
  74. elRef.classList.add(animationName, `${ANIMATION_CLASS_PREFIX}animated`)
  75. // 单个动画的收尾(清理类名 + 推进计数),只执行一次
  76. let oneFinished = false
  77. const finishOne = () => {
  78. if (oneFinished) return
  79. oneFinished = true
  80. elRef.style.removeProperty('--animate-duration')
  81. if (animation.type !== 'out') {
  82. elRef.classList.remove(animationName, `${ANIMATION_CLASS_PREFIX}animated`)
  83. }
  84. endAnimationCount += 1
  85. checkAndFinish()
  86. }
  87. // 正常结束
  88. const handleAnimationEnd = () => finishOne()
  89. elRef.addEventListener('animationend', handleAnimationEnd, { once: true })
  90. // 动画被中断(如元素隐藏/卸载、CSS 动画被强制停止)也会触发
  91. elRef.addEventListener('animationcancel', handleAnimationEnd, { once: true })
  92. // ★ 兜底:即使 animationend/animationcancel 都未触发,也在 duration + 缓冲后强制收尾
  93. const fallbackTimer = setTimeout(finishOne, animation.duration + 200)
  94. fallbackTimers.push(fallbackTimer)
  95. }
  96. }
  97. onMounted(() => {
  98. const firstAnimations = formatedAnimations.value[0]
  99. if (firstAnimations && firstAnimations.animations.length) {
  100. const autoExecFirstAnimations = firstAnimations.animations.every(item => item.trigger === 'auto' || item.trigger === 'meantime')
  101. // 等 DOM 完全渲染后再自动播放,避免元素尚未挂载导致 elRef 为 null
  102. if (autoExecFirstAnimations) nextTick(() => runAnimation())
  103. }
  104. })
  105. // 撤销元素动画,除了将索引前移外,还需要清除动画状态
  106. const revokeAnimation = () => {
  107. animationIndex.value -= 1
  108. const { animations } = formatedAnimations.value[animationIndex.value]
  109. for (const animation of animations) {
  110. const elRef: HTMLElement | null = document.querySelector(`#screen-element-${animation.elId} [class^=base-element-]`)
  111. if (!elRef) continue
  112. elRef.style.removeProperty('--animate-duration')
  113. for (const classname of elRef.classList) {
  114. if (classname.indexOf(ANIMATION_CLASS_PREFIX) !== -1) elRef.classList.remove(classname, `${ANIMATION_CLASS_PREFIX}animated`)
  115. }
  116. }
  117. // 如果撤销时该位置有且仅有强调动画,则继续执行一次撤销
  118. if (animations.every(item => item.type === 'attention')) execPrev()
  119. }
  120. // 关闭自动播放
  121. const autoPlayTimer = ref(0)
  122. const closeAutoPlay = () => {
  123. if (autoPlayTimer.value) {
  124. clearInterval(autoPlayTimer.value)
  125. autoPlayTimer.value = 0
  126. }
  127. }
  128. // ★ 组件卸载时彻底清理:自动播放定时器 + 动画兜底定时器 + 动画状态,
  129. // 避免按 ESC 退出放映时,未播完的动画残留导致状态卡死
  130. onUnmounted(() => {
  131. closeAutoPlay()
  132. clearFallbackTimers()
  133. inAnimation.value = false
  134. })
  135. // 循环放映
  136. const loopPlay = ref(false)
  137. const setLoopPlay = (loop: boolean) => {
  138. loopPlay.value = loop
  139. }
  140. const throttleMassage = throttle(function(msg) {
  141. message.success(msg)
  142. }, 1000, { leading: true, trailing: false })
  143. // 向上/向下播放
  144. // 遇到元素动画时,优先执行动画播放,无动画则执行翻页
  145. // 向上播放遇到动画时,仅撤销到动画执行前的状态,不需要反向播放动画
  146. // 撤回到上一页时,若该页从未播放过(意味着不存在动画状态),需要将动画索引置为最小值(初始状态),否则置为最大值(最终状态)
  147. const execPrev = () => {
  148. if (formatedAnimations.value.length && animationIndex.value > 0) {
  149. revokeAnimation()
  150. }
  151. else if (slideIndex.value > 0) {
  152. slidesStore.updateSlideIndex(slideIndex.value - 1)
  153. if (slideIndex.value < playedSlidesMinIndex.value) {
  154. animationIndex.value = 0
  155. playedSlidesMinIndex.value = slideIndex.value
  156. }
  157. else animationIndex.value = formatedAnimations.value.length
  158. }
  159. else {
  160. if (loopPlay.value) turnSlideToIndex(slides.value.length - 1)
  161. else throttleMassage(lang.ssFirstPage)
  162. }
  163. inAnimation.value = false
  164. }
  165. const execNext = () => {
  166. if (formatedAnimations.value.length && animationIndex.value < formatedAnimations.value.length) {
  167. runAnimation()
  168. }
  169. else if (slideIndex.value < slides.value.length - 1) {
  170. slidesStore.updateSlideIndex(slideIndex.value + 1)
  171. animationIndex.value = 0
  172. inAnimation.value = false
  173. }
  174. else {
  175. if (loopPlay.value) turnSlideToIndex(0)
  176. else {
  177. throttleMassage(lang.ssLastPage)
  178. closeAutoPlay()
  179. }
  180. inAnimation.value = false
  181. }
  182. }
  183. // 自动播放
  184. const autoPlayInterval = ref(2500)
  185. const autoPlay = () => {
  186. closeAutoPlay()
  187. message.success(lang.ssAutoPlayStart)
  188. autoPlayTimer.value = setInterval(execNext, autoPlayInterval.value)
  189. }
  190. const setAutoPlayInterval = (interval: number) => {
  191. closeAutoPlay()
  192. autoPlayInterval.value = interval
  193. autoPlay()
  194. }
  195. // 鼠标滚动翻页
  196. const mousewheelListener = throttle(function(e: WheelEvent) {
  197. if (e.deltaY < 0) execPrev()
  198. else if (e.deltaY > 0) execNext()
  199. }, 500, { leading: true, trailing: false })
  200. // 触摸屏上下滑动翻页
  201. const touchInfo = ref<{ x: number; y: number; } | null>(null)
  202. const touchStartListener = (e: TouchEvent) => {
  203. touchInfo.value = {
  204. x: e.changedTouches[0].pageX,
  205. y: e.changedTouches[0].pageY,
  206. }
  207. }
  208. const touchEndListener = (e: TouchEvent) => {
  209. if (!touchInfo.value) return
  210. const offsetX = Math.abs(touchInfo.value.x - e.changedTouches[0].pageX)
  211. const offsetY = e.changedTouches[0].pageY - touchInfo.value.y
  212. if ( Math.abs(offsetY) > offsetX && Math.abs(offsetY) > 50 ) {
  213. touchInfo.value = null
  214. if (offsetY > 0) execPrev()
  215. else execNext()
  216. }
  217. }
  218. // 快捷键翻页
  219. const keydownListener = (e: KeyboardEvent) => {
  220. const key = e.key.toUpperCase()
  221. if (key === KEYS.UP || key === KEYS.LEFT || key === KEYS.PAGEUP) execPrev()
  222. else if (
  223. key === KEYS.DOWN ||
  224. key === KEYS.RIGHT ||
  225. // key === KEYS.SPACE ||
  226. // key === KEYS.ENTER ||
  227. key === KEYS.PAGEDOWN
  228. ) execNext()
  229. }
  230. onMounted(() => document.addEventListener('keydown', keydownListener))
  231. onUnmounted(() => document.removeEventListener('keydown', keydownListener))
  232. // 切换到上一张/上一张幻灯片(无视元素的入场动画)
  233. const turnPrevSlide = () => {
  234. slidesStore.updateSlideIndex(slideIndex.value - 1)
  235. animationIndex.value = 0
  236. }
  237. const turnNextSlide = () => {
  238. slidesStore.updateSlideIndex(slideIndex.value + 1)
  239. animationIndex.value = 0
  240. }
  241. // 切换幻灯片到指定的页面
  242. const turnSlideToIndex = (index: number) => {
  243. slidesStore.updateSlideIndex(index)
  244. animationIndex.value = 0
  245. }
  246. const turnSlideToId = (id: string) => {
  247. const index = slides.value.findIndex(slide => slide.id === id)
  248. if (index !== -1) {
  249. slidesStore.updateSlideIndex(index)
  250. animationIndex.value = 0
  251. }
  252. }
  253. return {
  254. autoPlayTimer,
  255. autoPlayInterval,
  256. setAutoPlayInterval,
  257. autoPlay,
  258. closeAutoPlay,
  259. loopPlay,
  260. setLoopPlay,
  261. mousewheelListener,
  262. touchStartListener,
  263. touchEndListener,
  264. turnPrevSlide,
  265. turnNextSlide,
  266. turnSlideToIndex,
  267. turnSlideToId,
  268. execPrev,
  269. execNext,
  270. animationIndex,
  271. }
  272. }