pptxAnimation.ts 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677
  1. /**
  2. * PPTX / WPS 动画播放引擎
  3. *
  4. * 背景:
  5. * PPTist 原生只支持 animate.css 的固定类名(fadeIn / slideIn 等),
  6. * 而 PowerPoint / WPS 的动画体系是「预设 + 参数」:
  7. * - 几何擦除类:wipe / blinds / checkerboard / wheel / wedge / box / circle / diamond / plus
  8. * - 位移类:floats / rise / fly / credits(由 ppt_x / ppt_y 的位移轨迹决定)
  9. * - 组合类:fade + scale = 缩放进入;fade + rotate = 生长旋转
  10. * - 属性类:透明度 / 颜色 / 字号 / 加粗
  11. * - 动作路径:animMotion 的 SVG path
  12. * - 文字构建:按段落 / 按字词 / 按字母逐级播放
  13. *
  14. * 本引擎用 Web Animations API + 遮罩(mask / clip-path)精确还原这些效果,
  15. * 不依赖 animate.css,避免「所有动画都变成淡入」。
  16. */
  17. export interface PptxMotion {
  18. dx: number
  19. dy: number
  20. }
  21. export interface PptxAnimationRecord {
  22. effectName?: string | null
  23. direction?: string | null
  24. motion?: PptxMotion | null
  25. path?: string | null
  26. build?: string
  27. pRg?: { st: number, end: number } | null
  28. properties?: Record<string, any>
  29. filter?: string | null
  30. type: 'in' | 'out' | 'attention'
  31. duration: number
  32. delay?: number
  33. }
  34. interface PlayContext {
  35. slideW: number
  36. slideH: number
  37. }
  38. /* =========================================================================
  39. * 基础工具
  40. * ========================================================================= */
  41. const wait = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms))
  42. /**
  43. * 执行一段 WAAPI 动画并等待结束。
  44. * 被取消(元素卸载 / 新动画打断)时也安全 resolve,避免流程卡死。
  45. */
  46. function run(node: HTMLElement, keyframes: Keyframe[], options: KeyframeAnimationOptions): Promise<void> {
  47. let animation: Animation
  48. try {
  49. animation = node.animate(keyframes, options)
  50. }
  51. catch {
  52. return Promise.resolve()
  53. }
  54. return animation.finished
  55. .then(() => { /* noop */ })
  56. .catch(() => { /* 取消时忽略 */ })
  57. }
  58. /** 统一的 fill 策略:退场保留终态,入场/强调结束回到常态 */
  59. function fillOf(type: 'in' | 'out' | 'attention'): FillMode {
  60. if (type === 'out') return 'forwards'
  61. return 'none'
  62. }
  63. /* =========================================================================
  64. * 文字构建(按段落 / 按字词 / 按字母)
  65. * ========================================================================= */
  66. const UNIT_ATTR = 'data-wb-anim-unit'
  67. /** 把文本节点按词/字拆成带标记的 span(只拆一次) */
  68. function wrapTextUnits(root: HTMLElement, mode: 'word' | 'char') {
  69. if (root.querySelector(`[${UNIT_ATTR}]`)) return
  70. const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
  71. acceptNode(node) {
  72. if (!node.nodeValue || !node.nodeValue.trim()) return NodeFilter.FILTER_REJECT
  73. return NodeFilter.FILTER_ACCEPT
  74. },
  75. })
  76. const textNodes: Text[] = []
  77. let current = walker.nextNode()
  78. while (current) {
  79. textNodes.push(current as Text)
  80. current = walker.nextNode()
  81. }
  82. for (const textNode of textNodes) {
  83. const text = textNode.nodeValue || ''
  84. const parts = mode === 'char'
  85. ? Array.from(text)
  86. : text.split(/(\s+)/).filter(part => part !== '')
  87. const fragment = document.createDocumentFragment()
  88. for (const part of parts) {
  89. if (!part.trim()) {
  90. fragment.appendChild(document.createTextNode(part))
  91. continue
  92. }
  93. const span = document.createElement('span')
  94. span.setAttribute(UNIT_ATTR, '1')
  95. span.textContent = part
  96. fragment.appendChild(span)
  97. }
  98. textNode.parentNode?.replaceChild(fragment, textNode)
  99. }
  100. }
  101. /**
  102. * 解析动画的实际作用目标:
  103. * - 整元素
  104. * - 按段落:逐个 p / li
  105. * - 按字词/字母:拆 span 后逐个
  106. * pRg(段落区间)用于只播放指定段落。
  107. */
  108. function resolveTargets(el: HTMLElement, anim: PptxAnimationRecord): HTMLElement[] {
  109. const build = anim.build || 'all'
  110. if (build === 'all' || build === 'whole') return [el]
  111. const root = el.querySelector('.ProseMirror-static') as HTMLElement | null
  112. if (!root) return [el]
  113. let nodes: HTMLElement[] = []
  114. if (build === 'p' || build === 'para' || build === 'paragraph') {
  115. nodes = Array.from(root.querySelectorAll('p, li')) as HTMLElement[]
  116. if (!nodes.length) nodes = [root]
  117. }
  118. else {
  119. const mode = (build === 'c' || build === 'char' || build === 'letter') ? 'char' : 'word'
  120. wrapTextUnits(root, mode)
  121. nodes = Array.from(root.querySelectorAll(`[${UNIT_ATTR}]`)) as HTMLElement[]
  122. if (!nodes.length) nodes = [root]
  123. }
  124. if (anim.pRg && typeof anim.pRg.st === 'number') {
  125. const sliced = nodes.slice(anim.pRg.st, anim.pRg.end + 1)
  126. if (sliced.length) nodes = sliced
  127. }
  128. return nodes.length ? nodes : [el]
  129. }
  130. /* =========================================================================
  131. * 几何遮罩类动画
  132. * ========================================================================= */
  133. /** 擦除:从某个方向推出 */
  134. function wipeKeyframes(type: 'in' | 'out', direction: string | null): Keyframe[] {
  135. const hidden: Record<string, string> = {
  136. left: 'inset(0% 100% 0% 0%)',
  137. right: 'inset(0% 0% 0% 100%)',
  138. top: 'inset(0% 0% 100% 0%)',
  139. bottom: 'inset(100% 0% 0% 0%)',
  140. }
  141. const full = 'inset(0% 0% 0% 0%)'
  142. const start = hidden[direction || 'left'] || hidden.left
  143. // 入场:从遮挡 -> 全显;退场:全显 -> 遮挡
  144. return type === 'in'
  145. ? [{ clipPath: start }, { clipPath: full }]
  146. : [{ clipPath: full }, { clipPath: start }]
  147. }
  148. /** 百叶窗 / 条纹:用重复线性渐变做遮罩,遮罩尺寸从 0 长到满 */
  149. function blindsKeyframes(type: 'in' | 'out', direction: string | null, stripe = 12): Keyframe[] {
  150. const vertical = direction === 'vertical'
  151. // 竖百叶(竖直条纹)用 to right;横百叶用 to bottom
  152. const gradient = vertical
  153. ? `repeating-linear-gradient(to right, #000 0, #000 ${stripe}%, transparent ${stripe}%, transparent ${stripe * 2}%)`
  154. : `repeating-linear-gradient(to bottom, #000 0, #000 ${stripe}%, transparent ${stripe}%, transparent ${stripe * 2}%)`
  155. const empty = vertical ? '0% 100%' : '100% 0%'
  156. const full = '100% 100%'
  157. const base = { maskImage: gradient, WebkitMaskImage: gradient, maskRepeat: 'no-repeat', WebkitMaskRepeat: 'no-repeat' }
  158. return type === 'in'
  159. ? [
  160. { ...base, maskSize: empty, WebkitMaskSize: empty } as Keyframe,
  161. { ...base, maskSize: full, WebkitMaskSize: full } as Keyframe,
  162. ]
  163. : [
  164. { ...base, maskSize: full, WebkitMaskSize: full } as Keyframe,
  165. { ...base, maskSize: empty, WebkitMaskSize: empty } as Keyframe,
  166. ]
  167. }
  168. /** 棋盘:逐格显现 */
  169. function checkerboardKeyframes(type: 'in' | 'out'): Keyframe[] {
  170. const gradient = 'repeating-conic-gradient(#000 0% 25%, transparent 0% 50%)'
  171. const base = { maskImage: gradient, WebkitMaskImage: gradient, maskRepeat: 'no-repeat', WebkitMaskRepeat: 'no-repeat' }
  172. const small = '6% 6%'
  173. const full = '100% 100%'
  174. return type === 'in'
  175. ? [
  176. { ...base, maskSize: small, WebkitMaskSize: small } as Keyframe,
  177. { ...base, maskSize: full, WebkitMaskSize: full } as Keyframe,
  178. ]
  179. : [
  180. { ...base, maskSize: full, WebkitMaskSize: full } as Keyframe,
  181. { ...base, maskSize: small, WebkitMaskSize: small } as Keyframe,
  182. ]
  183. }
  184. /** 圆形 / 菱形 / 十字 / 楔形:用 clip-path 从中心扩张 */
  185. function shapeKeyframes(shape: string, type: 'in' | 'out'): Keyframe[] {
  186. const startByShape: Record<string, string> = {
  187. circle: 'circle(0% at 50% 50%)',
  188. iris: 'circle(0% at 50% 50%)',
  189. diamond: 'polygon(50% 50%, 50% 50%, 50% 50%, 50% 50%)',
  190. plus: 'polygon(50% 50%, 50% 50%, 50% 50%, 50% 50%)',
  191. wedge: 'polygon(50% 50%, 50% 50%, 50% 50%)',
  192. box: 'inset(48% 48% 48% 48%)',
  193. barn: 'inset(0% 50% 0% 50%)',
  194. }
  195. const endByShape: Record<string, string> = {
  196. circle: 'circle(75% at 50% 50%)',
  197. iris: 'circle(75% at 50% 50%)',
  198. diamond: 'polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%)',
  199. plus: 'polygon(35% 0%, 65% 0%, 65% 35%, 100% 35%, 100% 65%, 65% 65%, 65% 100%, 35% 100%, 35% 65%, 0% 65%, 0% 35%, 35% 35%)',
  200. wedge: 'polygon(50% 0%, 100% 100%, 0% 100%)',
  201. box: 'inset(0% 0% 0% 0%)',
  202. barn: 'inset(0% 0% 0% 0%)',
  203. }
  204. const start = startByShape[shape] || startByShape.circle
  205. const end = endByShape[shape] || endByShape.circle
  206. return type === 'in'
  207. ? [{ clipPath: start }, { clipPath: end }]
  208. : [{ clipPath: end }, { clipPath: start }]
  209. }
  210. /** 轮子:用扇形逐步扫过(8 个关键帧近似) */
  211. function wheelKeyframes(type: 'in' | 'out'): Keyframe[] {
  212. const sector = (angle: number) => {
  213. if (angle >= 360) return 'polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)'
  214. const rad = (angle - 90) * Math.PI / 180
  215. const points = ['50% 50%']
  216. const cx = 50, cy = 50
  217. const r = 80
  218. points.push(`${cx + r * Math.cos(-Math.PI / 2)},${cy + r * Math.sin(-Math.PI / 2)}`)
  219. points.push(`${cx + r * Math.cos(rad)},${cy + r * Math.sin(rad)}`)
  220. return `polygon(${points.join(', ')})`
  221. }
  222. const steps = [0, 45, 90, 135, 180, 225, 270, 315, 360]
  223. const frames = steps.map(step => ({ clipPath: sector(step), easing: 'linear' }))
  224. return type === 'in' ? frames : [...frames].reverse()
  225. }
  226. /* =========================================================================
  227. * 位移 / 缩放 / 旋转类动画
  228. * ========================================================================= */
  229. function px(value: number) {
  230. return `${value}px`
  231. }
  232. /** 淡入淡出 + 位移(浮入 / 升起 / 字幕 / 飞入的近似) */
  233. function fadeWithMotion(type: 'in' | 'out', motion: PptxMotion | null | undefined, ctx: PlayContext): Keyframe[] {
  234. const dx = motion ? motion.dx * ctx.slideW : 0
  235. const dy = motion ? motion.dy * ctx.slideH : 0
  236. const opacityFrom = type === 'in' ? 0 : 1
  237. const opacityTo = type === 'in' ? 1 : 0
  238. const transformNone = 'translate(0px, 0px)'
  239. const transformOffset = `translate(${px(dx)}, ${px(dy)})`
  240. return type === 'in'
  241. ? [{ opacity: opacityFrom, transform: transformOffset }, { opacity: opacityTo, transform: transformNone }]
  242. : [{ opacity: opacityFrom, transform: transformNone }, { opacity: opacityTo, transform: transformOffset }]
  243. }
  244. /** 飞入:从画布边缘滑入(无淡入) */
  245. function flyKeyframes(type: 'in' | 'out', direction: string | null, ctx: PlayContext): Keyframe[] {
  246. const map: Record<string, string> = {
  247. left: `translate(${px(-ctx.slideW)}, 0px)`,
  248. right: `translate(${px(ctx.slideW)}, 0px)`,
  249. top: `translate(0px, ${px(-ctx.slideH)})`,
  250. bottom: `translate(0px, ${px(ctx.slideH)})`,
  251. }
  252. const offset = map[direction || 'left'] || map.left
  253. const none = 'translate(0px, 0px)'
  254. return type === 'in'
  255. ? [{ transform: offset }, { transform: none }]
  256. : [{ transform: none }, { transform: offset }]
  257. }
  258. /** 窥视:从元素自身边缘滑入(无淡入) */
  259. function peekKeyframes(type: 'in' | 'out', direction: string | null): Keyframe[] {
  260. const map: Record<string, string> = {
  261. left: 'translate(-100%, 0%)',
  262. right: 'translate(100%, 0%)',
  263. top: 'translate(0%, -100%)',
  264. bottom: 'translate(0%, 100%)',
  265. }
  266. const offset = map[direction || 'left'] || map.left
  267. const none = 'translate(0%, 0%)'
  268. return type === 'in'
  269. ? [{ transform: offset }, { transform: none }]
  270. : [{ transform: none }, { transform: offset }]
  271. }
  272. /** 缩放 / 生长旋转 / 翻转 / 旋转木马 / 展开折叠 / 拉伸 */
  273. function transformEntrance(type: 'in' | 'out', opts: {
  274. fromScale?: number
  275. toScale?: number
  276. fromRotate?: number
  277. axis?: 'x' | 'y' | null
  278. stretch?: 'x' | 'y' | null
  279. }): Keyframe[] {
  280. const fromScale = opts.fromScale === undefined ? 0 : opts.fromScale
  281. const toScale = opts.toScale === undefined ? 1 : opts.toScale
  282. const fromRotate = opts.fromRotate || 0
  283. let from = `scale(${fromScale})`
  284. let to = `scale(${toScale})`
  285. if (opts.axis === 'y') {
  286. from = `perspective(800px) rotateY(${type === 'in' ? -90 : 90}deg)`
  287. to = 'perspective(800px) rotateY(0deg)'
  288. }
  289. else if (opts.axis === 'x') {
  290. from = `perspective(800px) rotateX(${type === 'in' ? 90 : -90}deg)`
  291. to = 'perspective(800px) rotateX(0deg)'
  292. }
  293. else if (opts.stretch === 'x') {
  294. from = 'scaleX(0)'
  295. to = 'scaleX(1)'
  296. }
  297. else if (opts.stretch === 'y') {
  298. from = 'scaleY(0)'
  299. to = 'scaleY(1)'
  300. }
  301. else if (fromRotate) {
  302. from = `scale(${fromScale}) rotate(${fromRotate}deg)`
  303. to = `scale(${toScale}) rotate(0deg)`
  304. }
  305. return type === 'in' ? [{ transform: from }, { transform: to }] : [{ transform: to }, { transform: from }]
  306. }
  307. /** 弹跳进入 */
  308. function bounceKeyframes(type: 'in' | 'out'): Keyframe[] {
  309. const frames: Keyframe[] = [
  310. { transform: 'scale(0.3)', opacity: 0, offset: 0 },
  311. { transform: 'scale(1.05)', opacity: 1, offset: 0.6 },
  312. { transform: 'scale(0.95)', opacity: 1, offset: 0.8 },
  313. { transform: 'scale(1)', opacity: 1, offset: 1 },
  314. ]
  315. return type === 'in' ? frames : [...frames].reverse()
  316. }
  317. /** 光速 / 翻滚 / 螺旋 */
  318. function fancyEntrance(type: 'in' | 'out', kind: 'lightSpeed' | 'roll' | 'spiral'): Keyframe[] {
  319. let from = 'translateX(-100%) skewX(-30deg)'
  320. let to = 'translateX(0%) skewX(0deg)'
  321. if (kind === 'roll') {
  322. from = 'translateX(-100%) rotate(-120deg)'
  323. to = 'translateX(0%) rotate(0deg)'
  324. }
  325. if (kind === 'spiral') {
  326. from = 'rotate(-540deg) scale(0.1)'
  327. to = 'rotate(0deg) scale(1)'
  328. }
  329. return type === 'in'
  330. ? [{ transform: from, opacity: 0 }, { transform: to, opacity: 1 }]
  331. : [{ transform: to, opacity: 1 }, { transform: from, opacity: 0 }]
  332. }
  333. /* =========================================================================
  334. * 动作路径
  335. * ========================================================================= */
  336. /** 把 PowerPoint 的 M/L/C 路径(M 0 0 L .2 0 E)采样成位移关键帧 */
  337. function pathKeyframes(path: string, type: 'in' | 'out', ctx: PlayContext): Keyframe[] {
  338. const tokens = path.replace(/([MLCQZmlcqzEe])/g, ' $1 ').trim().split(/\s+/)
  339. const points: { x: number, y: number }[] = []
  340. let i = 0
  341. let current = { x: 0, y: 0 }
  342. while (i < tokens.length) {
  343. const cmd = tokens[i]
  344. if (cmd === 'M' || cmd === 'L' || cmd === 'm' || cmd === 'l') {
  345. const x = parseFloat(tokens[i + 1])
  346. const y = parseFloat(tokens[i + 2])
  347. if (!isNaN(x) && !isNaN(y)) {
  348. current = { x, y }
  349. points.push({ ...current })
  350. }
  351. i += 3
  352. }
  353. else if (cmd === 'C' || cmd === 'c') {
  354. // 三次贝塞尔:取终点做线性近似(足够还原路径走向)
  355. const x = parseFloat(tokens[i + 5])
  356. const y = parseFloat(tokens[i + 6])
  357. if (!isNaN(x) && !isNaN(y)) {
  358. current = { x, y }
  359. points.push({ ...current })
  360. }
  361. i += 7
  362. }
  363. else if (cmd === 'E' || cmd === 'e' || cmd === 'Z' || cmd === 'z') {
  364. break
  365. }
  366. else {
  367. i += 1
  368. }
  369. }
  370. if (!points.length) return []
  371. const frames = points.map((p, index) => ({
  372. transform: `translate(${px(p.x * ctx.slideW)}, ${px(p.y * ctx.slideH)})`,
  373. offset: points.length === 1 ? 1 : index / (points.length - 1),
  374. easing: 'linear',
  375. }))
  376. const withEnd = [{ transform: 'translate(0px, 0px)', offset: 0, easing: 'linear' }, ...frames]
  377. return type === 'in' ? withEnd : withEnd
  378. }
  379. /* =========================================================================
  380. * 强调类动画
  381. * ========================================================================= */
  382. function emphasisKeyframes(name: string, anim: PptxAnimationRecord): Keyframe[] {
  383. const props = anim.properties || {}
  384. switch (name) {
  385. case 'pulse':
  386. return [{ transform: 'scale(1)' }, { transform: 'scale(1.12)' }, { transform: 'scale(1)' }]
  387. case 'spin': {
  388. const by = props.by !== undefined && props.by !== null ? props.by : 360
  389. const deg = by > 1000 ? by / 60000 : by
  390. return [{ transform: 'rotate(0deg)' }, { transform: `rotate(${deg}deg)` }]
  391. }
  392. case 'growshrink': {
  393. const to = props.to !== undefined && props.to !== null ? props.to : 1.5
  394. const scale = to > 10 ? to / 100000 : to
  395. if (props.loopBack !== false) {
  396. return [{ transform: 'scale(1)' }, { transform: `scale(${scale})` }, { transform: 'scale(1)' }]
  397. }
  398. return [{ transform: 'scale(1)' }, { transform: `scale(${scale})` }]
  399. }
  400. case 'transparency': {
  401. const from = props.from !== undefined ? props.from : 1
  402. const to = props.to !== undefined ? props.to : 0.5
  403. return [{ opacity: String(from) }, { opacity: String(to) }]
  404. }
  405. case 'bold':
  406. return [{ fontWeight: '400' }, { fontWeight: '700' }]
  407. case 'fontsize': {
  408. const to = props.to !== undefined ? props.to : 48
  409. return [{ fontSize: '1em' }, { fontSize: `${to / 100}em` }]
  410. }
  411. case 'flash':
  412. return [
  413. { opacity: 1, offset: 0 },
  414. { opacity: 0, offset: 0.25 },
  415. { opacity: 1, offset: 0.5 },
  416. { opacity: 0, offset: 0.75 },
  417. { opacity: 1, offset: 1 },
  418. ]
  419. case 'wave':
  420. return [
  421. { transform: 'rotate(0deg)', offset: 0 },
  422. { transform: 'rotate(8deg)', offset: 0.2 },
  423. { transform: 'rotate(-8deg)', offset: 0.4 },
  424. { transform: 'rotate(8deg)', offset: 0.6 },
  425. { transform: 'rotate(-8deg)', offset: 0.8 },
  426. { transform: 'rotate(0deg)', offset: 1 },
  427. ]
  428. case 'teeter':
  429. return [
  430. { transform: 'rotate(0deg)', offset: 0 },
  431. { transform: 'rotate(-12deg)', offset: 0.2 },
  432. { transform: 'rotate(10deg)', offset: 0.4 },
  433. { transform: 'rotate(-6deg)', offset: 0.6 },
  434. { transform: 'rotate(4deg)', offset: 0.8 },
  435. { transform: 'rotate(0deg)', offset: 1 },
  436. ]
  437. case 'shake':
  438. return [
  439. { transform: 'translateX(0)', offset: 0 },
  440. { transform: 'translateX(-8%)', offset: 0.2 },
  441. { transform: 'translateX(8%)', offset: 0.4 },
  442. { transform: 'translateX(-6%)', offset: 0.6 },
  443. { transform: 'translateX(6%)', offset: 0.8 },
  444. { transform: 'translateX(0)', offset: 1 },
  445. ]
  446. default:
  447. return [{ opacity: 1 }, { opacity: 1 }]
  448. }
  449. }
  450. /* =========================================================================
  451. * 主入口
  452. * ========================================================================= */
  453. /**
  454. * 播放单个目标的动画
  455. */
  456. async function playOne(node: HTMLElement, anim: PptxAnimationRecord, ctx: PlayContext, duration: number, delay: number): Promise<void> {
  457. const name = (anim.effectName || 'fade').toLowerCase()
  458. const type = anim.type
  459. const props = anim.properties || {}
  460. const direction = anim.direction || null
  461. const options: KeyframeAnimationOptions = {
  462. duration: Math.max(duration, 1),
  463. delay,
  464. fill: fillOf(type),
  465. easing: 'ease',
  466. }
  467. // 动作路径(可能出现在任意类型中,必须优先于强调分支处理)
  468. if (name === 'motion') {
  469. const frames = anim.path ? pathKeyframes(anim.path, type, ctx) : []
  470. if (frames.length) {
  471. await run(node, frames, { ...options, easing: 'linear', fill: type === 'out' ? 'forwards' : 'none' })
  472. return
  473. }
  474. await run(node, fadeWithMotion(type, anim.motion, ctx), options)
  475. return
  476. }
  477. // 出现 / 消失:瞬时切换可见性(用 1ms 动画,便于取消时自动还原)
  478. if (name === 'appear' || name === 'disappear') {
  479. const toOpacity = name === 'appear' ? 1 : 0
  480. await run(node, [{ opacity: String(1 - toOpacity) }, { opacity: String(toOpacity) }], {
  481. duration: 1,
  482. delay,
  483. fill: 'forwards',
  484. })
  485. return
  486. }
  487. // 强调类
  488. if (type === 'attention') {
  489. if (name === 'colorchange') {
  490. const fromColor = getComputedStyle(node).color
  491. const toColor = props.clrTo || props.to || fromColor
  492. await run(node, [{ color: fromColor }, { color: toColor }], { ...options, fill: 'forwards' })
  493. return
  494. }
  495. await run(node, emphasisKeyframes(name, anim), options)
  496. return
  497. }
  498. // 几何擦除类
  499. if (name === 'wipe') {
  500. await run(node, wipeKeyframes(type, direction), { ...options, easing: 'linear' })
  501. return
  502. }
  503. if (name === 'blinds') {
  504. await run(node, blindsKeyframes(type, direction), { ...options, easing: 'ease-in' })
  505. return
  506. }
  507. if (name === 'randbars' || name === 'strips') {
  508. await run(node, blindsKeyframes(type, direction, 7), { ...options, easing: 'ease-in' })
  509. return
  510. }
  511. if (name === 'checkerboard') {
  512. await run(node, checkerboardKeyframes(type), { ...options, easing: 'ease-in' })
  513. return
  514. }
  515. if (['circle', 'iris', 'diamond', 'plus', 'wedge', 'box', 'barn'].includes(name)) {
  516. await run(node, shapeKeyframes(name, type), { ...options, easing: 'ease-out' })
  517. return
  518. }
  519. if (name === 'wheel') {
  520. await run(node, wheelKeyframes(type), { ...options, easing: 'linear' })
  521. return
  522. }
  523. if (name === 'dissolve') {
  524. await run(node, type === 'in'
  525. ? [{ opacity: 0, filter: 'blur(2px)' }, { opacity: 1, filter: 'blur(0px)' }]
  526. : [{ opacity: 1, filter: 'blur(0px)' }, { opacity: 0, filter: 'blur(2px)' }], options)
  527. return
  528. }
  529. // 位移类
  530. if (name === 'fly') {
  531. await run(node, flyKeyframes(type, direction, ctx), { ...options, easing: 'ease-out' })
  532. return
  533. }
  534. if (name === 'peek') {
  535. await run(node, peekKeyframes(type, direction), { ...options, easing: 'ease-out' })
  536. return
  537. }
  538. if (name === 'bounce') {
  539. await run(node, bounceKeyframes(type), options)
  540. return
  541. }
  542. if (name === 'lightspeed' || name === 'roll' || name === 'spiral') {
  543. await run(node, fancyEntrance(type, name as any), options)
  544. return
  545. }
  546. // 缩放 / 旋转 / 翻转 / 拉伸 / 组合淡入
  547. const hasScale = props.scaleFrom !== undefined || props.scaleTo !== undefined
  548. const hasRotate = props.rotateFrom !== undefined
  549. if (name === 'zoom' || (name === 'fade' && (hasScale || hasRotate))) {
  550. const frames = transformEntrance(type, {
  551. fromScale: hasScale ? props.scaleFrom : 0.4,
  552. toScale: hasScale ? props.scaleTo : 1,
  553. fromRotate: hasRotate ? props.rotateFrom : 0,
  554. })
  555. if (hasRotate && !hasScale) {
  556. const rotateFrames = transformEntrance(type, { fromScale: 1, toScale: 1, fromRotate: props.rotateFrom })
  557. await run(node, type === 'in'
  558. ? [{ opacity: 0, ...rotateFrames[0] }, { opacity: 1, ...rotateFrames[1] }]
  559. : [{ opacity: 1, ...rotateFrames[0] }, { opacity: 0, ...rotateFrames[1] }], options)
  560. return
  561. }
  562. await run(node, type === 'in'
  563. ? [{ opacity: 0, ...frames[0] }, { opacity: 1, ...frames[1] }]
  564. : [{ opacity: 1, ...frames[0] }, { opacity: 0, ...frames[1] }], options)
  565. return
  566. }
  567. if (name === 'swivel') {
  568. await run(node, [
  569. { opacity: 0, transform: 'perspective(800px) rotateY(-90deg)' },
  570. { opacity: 1, transform: 'perspective(800px) rotateY(0deg)' },
  571. ], options)
  572. return
  573. }
  574. if (name === 'flip') {
  575. await run(node, [
  576. { opacity: 0, transform: 'perspective(800px) rotateX(90deg)' },
  577. { opacity: 1, transform: 'perspective(800px) rotateX(0deg)' },
  578. ], options)
  579. return
  580. }
  581. if (name === 'stretch') {
  582. await run(node, transformEntrance(type, { stretch: direction === 'vertical' ? 'y' : 'x' }), options)
  583. return
  584. }
  585. if (name === 'expand') {
  586. await run(node, transformEntrance(type, { fromScale: 0.1, toScale: 1 }), options)
  587. return
  588. }
  589. if (name === 'collapse') {
  590. await run(node, transformEntrance(type, { fromScale: 0, toScale: 1 }), options)
  591. return
  592. }
  593. // 默认:淡入淡出(带位移时同时位移,即「浮入 / 升起 / 字幕」)
  594. await run(node, fadeWithMotion(type, anim.motion, ctx), options)
  595. }
  596. /**
  597. * 播放入场/退场/强调动画(支持文字分级构建)
  598. * @returns Promise,全部播完(或被取消)后 resolve
  599. */
  600. export async function playPptxAnimation(el: HTMLElement, anim: PptxAnimationRecord, ctx: PlayContext): Promise<void> {
  601. const targets = resolveTargets(el, anim)
  602. const total = Math.max(anim.duration || 500, 1)
  603. const baseDelay = anim.delay || 0
  604. // 按段落/字词构建:整体时长不变,各单元顺序播放
  605. const perDuration = targets.length > 1 ? Math.max(total / targets.length, 120) : total
  606. for (let i = 0; i < targets.length; i++) {
  607. // 退场时反向播放(最后一段先消失),与 WPS 表现一致
  608. const target = anim.type === 'out' ? targets[targets.length - 1 - i] : targets[i]
  609. const delay = i === 0 ? baseDelay : 0
  610. await playOne(target, anim, ctx, perDuration, delay)
  611. }
  612. }
  613. /** 清理文字构建产生的临时 span(切换页面时调用) */
  614. export function cleanupTextBuild(root: ParentNode = document) {
  615. root.querySelectorAll(`[${UNIT_ATTR}]`).forEach(span => {
  616. const parent = span.parentNode
  617. if (!parent) return
  618. parent.replaceChild(document.createTextNode(span.textContent || ''), span)
  619. parent.normalize()
  620. })
  621. }