| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677 |
- /**
- * PPTX / WPS 动画播放引擎
- *
- * 背景:
- * PPTist 原生只支持 animate.css 的固定类名(fadeIn / slideIn 等),
- * 而 PowerPoint / WPS 的动画体系是「预设 + 参数」:
- * - 几何擦除类:wipe / blinds / checkerboard / wheel / wedge / box / circle / diamond / plus
- * - 位移类:floats / rise / fly / credits(由 ppt_x / ppt_y 的位移轨迹决定)
- * - 组合类:fade + scale = 缩放进入;fade + rotate = 生长旋转
- * - 属性类:透明度 / 颜色 / 字号 / 加粗
- * - 动作路径:animMotion 的 SVG path
- * - 文字构建:按段落 / 按字词 / 按字母逐级播放
- *
- * 本引擎用 Web Animations API + 遮罩(mask / clip-path)精确还原这些效果,
- * 不依赖 animate.css,避免「所有动画都变成淡入」。
- */
- export interface PptxMotion {
- dx: number
- dy: number
- }
- export interface PptxAnimationRecord {
- effectName?: string | null
- direction?: string | null
- motion?: PptxMotion | null
- path?: string | null
- build?: string
- pRg?: { st: number, end: number } | null
- properties?: Record<string, any>
- filter?: string | null
- type: 'in' | 'out' | 'attention'
- duration: number
- delay?: number
- }
- interface PlayContext {
- slideW: number
- slideH: number
- }
- /* =========================================================================
- * 基础工具
- * ========================================================================= */
- const wait = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms))
- /**
- * 执行一段 WAAPI 动画并等待结束。
- * 被取消(元素卸载 / 新动画打断)时也安全 resolve,避免流程卡死。
- */
- function run(node: HTMLElement, keyframes: Keyframe[], options: KeyframeAnimationOptions): Promise<void> {
- let animation: Animation
- try {
- animation = node.animate(keyframes, options)
- }
- catch {
- return Promise.resolve()
- }
- return animation.finished
- .then(() => { /* noop */ })
- .catch(() => { /* 取消时忽略 */ })
- }
- /** 统一的 fill 策略:退场保留终态,入场/强调结束回到常态 */
- function fillOf(type: 'in' | 'out' | 'attention'): FillMode {
- if (type === 'out') return 'forwards'
- return 'none'
- }
- /* =========================================================================
- * 文字构建(按段落 / 按字词 / 按字母)
- * ========================================================================= */
- const UNIT_ATTR = 'data-wb-anim-unit'
- /** 把文本节点按词/字拆成带标记的 span(只拆一次) */
- function wrapTextUnits(root: HTMLElement, mode: 'word' | 'char') {
- if (root.querySelector(`[${UNIT_ATTR}]`)) return
- const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
- acceptNode(node) {
- if (!node.nodeValue || !node.nodeValue.trim()) return NodeFilter.FILTER_REJECT
- return NodeFilter.FILTER_ACCEPT
- },
- })
- const textNodes: Text[] = []
- let current = walker.nextNode()
- while (current) {
- textNodes.push(current as Text)
- current = walker.nextNode()
- }
- for (const textNode of textNodes) {
- const text = textNode.nodeValue || ''
- const parts = mode === 'char'
- ? Array.from(text)
- : text.split(/(\s+)/).filter(part => part !== '')
- const fragment = document.createDocumentFragment()
- for (const part of parts) {
- if (!part.trim()) {
- fragment.appendChild(document.createTextNode(part))
- continue
- }
- const span = document.createElement('span')
- span.setAttribute(UNIT_ATTR, '1')
- span.textContent = part
- fragment.appendChild(span)
- }
- textNode.parentNode?.replaceChild(fragment, textNode)
- }
- }
- /**
- * 解析动画的实际作用目标:
- * - 整元素
- * - 按段落:逐个 p / li
- * - 按字词/字母:拆 span 后逐个
- * pRg(段落区间)用于只播放指定段落。
- */
- function resolveTargets(el: HTMLElement, anim: PptxAnimationRecord): HTMLElement[] {
- const build = anim.build || 'all'
- if (build === 'all' || build === 'whole') return [el]
- const root = el.querySelector('.ProseMirror-static') as HTMLElement | null
- if (!root) return [el]
- let nodes: HTMLElement[] = []
- if (build === 'p' || build === 'para' || build === 'paragraph') {
- nodes = Array.from(root.querySelectorAll('p, li')) as HTMLElement[]
- if (!nodes.length) nodes = [root]
- }
- else {
- const mode = (build === 'c' || build === 'char' || build === 'letter') ? 'char' : 'word'
- wrapTextUnits(root, mode)
- nodes = Array.from(root.querySelectorAll(`[${UNIT_ATTR}]`)) as HTMLElement[]
- if (!nodes.length) nodes = [root]
- }
- if (anim.pRg && typeof anim.pRg.st === 'number') {
- const sliced = nodes.slice(anim.pRg.st, anim.pRg.end + 1)
- if (sliced.length) nodes = sliced
- }
- return nodes.length ? nodes : [el]
- }
- /* =========================================================================
- * 几何遮罩类动画
- * ========================================================================= */
- /** 擦除:从某个方向推出 */
- function wipeKeyframes(type: 'in' | 'out', direction: string | null): Keyframe[] {
- const hidden: Record<string, string> = {
- left: 'inset(0% 100% 0% 0%)',
- right: 'inset(0% 0% 0% 100%)',
- top: 'inset(0% 0% 100% 0%)',
- bottom: 'inset(100% 0% 0% 0%)',
- }
- const full = 'inset(0% 0% 0% 0%)'
- const start = hidden[direction || 'left'] || hidden.left
- // 入场:从遮挡 -> 全显;退场:全显 -> 遮挡
- return type === 'in'
- ? [{ clipPath: start }, { clipPath: full }]
- : [{ clipPath: full }, { clipPath: start }]
- }
- /** 百叶窗 / 条纹:用重复线性渐变做遮罩,遮罩尺寸从 0 长到满 */
- function blindsKeyframes(type: 'in' | 'out', direction: string | null, stripe = 12): Keyframe[] {
- const vertical = direction === 'vertical'
- // 竖百叶(竖直条纹)用 to right;横百叶用 to bottom
- const gradient = vertical
- ? `repeating-linear-gradient(to right, #000 0, #000 ${stripe}%, transparent ${stripe}%, transparent ${stripe * 2}%)`
- : `repeating-linear-gradient(to bottom, #000 0, #000 ${stripe}%, transparent ${stripe}%, transparent ${stripe * 2}%)`
- const empty = vertical ? '0% 100%' : '100% 0%'
- const full = '100% 100%'
- const base = { maskImage: gradient, WebkitMaskImage: gradient, maskRepeat: 'no-repeat', WebkitMaskRepeat: 'no-repeat' }
- return type === 'in'
- ? [
- { ...base, maskSize: empty, WebkitMaskSize: empty } as Keyframe,
- { ...base, maskSize: full, WebkitMaskSize: full } as Keyframe,
- ]
- : [
- { ...base, maskSize: full, WebkitMaskSize: full } as Keyframe,
- { ...base, maskSize: empty, WebkitMaskSize: empty } as Keyframe,
- ]
- }
- /** 棋盘:逐格显现 */
- function checkerboardKeyframes(type: 'in' | 'out'): Keyframe[] {
- const gradient = 'repeating-conic-gradient(#000 0% 25%, transparent 0% 50%)'
- const base = { maskImage: gradient, WebkitMaskImage: gradient, maskRepeat: 'no-repeat', WebkitMaskRepeat: 'no-repeat' }
- const small = '6% 6%'
- const full = '100% 100%'
- return type === 'in'
- ? [
- { ...base, maskSize: small, WebkitMaskSize: small } as Keyframe,
- { ...base, maskSize: full, WebkitMaskSize: full } as Keyframe,
- ]
- : [
- { ...base, maskSize: full, WebkitMaskSize: full } as Keyframe,
- { ...base, maskSize: small, WebkitMaskSize: small } as Keyframe,
- ]
- }
- /** 圆形 / 菱形 / 十字 / 楔形:用 clip-path 从中心扩张 */
- function shapeKeyframes(shape: string, type: 'in' | 'out'): Keyframe[] {
- const startByShape: Record<string, string> = {
- circle: 'circle(0% at 50% 50%)',
- iris: 'circle(0% at 50% 50%)',
- diamond: 'polygon(50% 50%, 50% 50%, 50% 50%, 50% 50%)',
- plus: 'polygon(50% 50%, 50% 50%, 50% 50%, 50% 50%)',
- wedge: 'polygon(50% 50%, 50% 50%, 50% 50%)',
- box: 'inset(48% 48% 48% 48%)',
- barn: 'inset(0% 50% 0% 50%)',
- }
- const endByShape: Record<string, string> = {
- circle: 'circle(75% at 50% 50%)',
- iris: 'circle(75% at 50% 50%)',
- diamond: 'polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%)',
- 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%)',
- wedge: 'polygon(50% 0%, 100% 100%, 0% 100%)',
- box: 'inset(0% 0% 0% 0%)',
- barn: 'inset(0% 0% 0% 0%)',
- }
- const start = startByShape[shape] || startByShape.circle
- const end = endByShape[shape] || endByShape.circle
- return type === 'in'
- ? [{ clipPath: start }, { clipPath: end }]
- : [{ clipPath: end }, { clipPath: start }]
- }
- /** 轮子:用扇形逐步扫过(8 个关键帧近似) */
- function wheelKeyframes(type: 'in' | 'out'): Keyframe[] {
- const sector = (angle: number) => {
- if (angle >= 360) return 'polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)'
- const rad = (angle - 90) * Math.PI / 180
- const points = ['50% 50%']
- const cx = 50, cy = 50
- const r = 80
- points.push(`${cx + r * Math.cos(-Math.PI / 2)},${cy + r * Math.sin(-Math.PI / 2)}`)
- points.push(`${cx + r * Math.cos(rad)},${cy + r * Math.sin(rad)}`)
- return `polygon(${points.join(', ')})`
- }
- const steps = [0, 45, 90, 135, 180, 225, 270, 315, 360]
- const frames = steps.map(step => ({ clipPath: sector(step), easing: 'linear' }))
- return type === 'in' ? frames : [...frames].reverse()
- }
- /* =========================================================================
- * 位移 / 缩放 / 旋转类动画
- * ========================================================================= */
- function px(value: number) {
- return `${value}px`
- }
- /** 淡入淡出 + 位移(浮入 / 升起 / 字幕 / 飞入的近似) */
- function fadeWithMotion(type: 'in' | 'out', motion: PptxMotion | null | undefined, ctx: PlayContext): Keyframe[] {
- const dx = motion ? motion.dx * ctx.slideW : 0
- const dy = motion ? motion.dy * ctx.slideH : 0
- const opacityFrom = type === 'in' ? 0 : 1
- const opacityTo = type === 'in' ? 1 : 0
- const transformNone = 'translate(0px, 0px)'
- const transformOffset = `translate(${px(dx)}, ${px(dy)})`
- return type === 'in'
- ? [{ opacity: opacityFrom, transform: transformOffset }, { opacity: opacityTo, transform: transformNone }]
- : [{ opacity: opacityFrom, transform: transformNone }, { opacity: opacityTo, transform: transformOffset }]
- }
- /** 飞入:从画布边缘滑入(无淡入) */
- function flyKeyframes(type: 'in' | 'out', direction: string | null, ctx: PlayContext): Keyframe[] {
- const map: Record<string, string> = {
- left: `translate(${px(-ctx.slideW)}, 0px)`,
- right: `translate(${px(ctx.slideW)}, 0px)`,
- top: `translate(0px, ${px(-ctx.slideH)})`,
- bottom: `translate(0px, ${px(ctx.slideH)})`,
- }
- const offset = map[direction || 'left'] || map.left
- const none = 'translate(0px, 0px)'
- return type === 'in'
- ? [{ transform: offset }, { transform: none }]
- : [{ transform: none }, { transform: offset }]
- }
- /** 窥视:从元素自身边缘滑入(无淡入) */
- function peekKeyframes(type: 'in' | 'out', direction: string | null): Keyframe[] {
- const map: Record<string, string> = {
- left: 'translate(-100%, 0%)',
- right: 'translate(100%, 0%)',
- top: 'translate(0%, -100%)',
- bottom: 'translate(0%, 100%)',
- }
- const offset = map[direction || 'left'] || map.left
- const none = 'translate(0%, 0%)'
- return type === 'in'
- ? [{ transform: offset }, { transform: none }]
- : [{ transform: none }, { transform: offset }]
- }
- /** 缩放 / 生长旋转 / 翻转 / 旋转木马 / 展开折叠 / 拉伸 */
- function transformEntrance(type: 'in' | 'out', opts: {
- fromScale?: number
- toScale?: number
- fromRotate?: number
- axis?: 'x' | 'y' | null
- stretch?: 'x' | 'y' | null
- }): Keyframe[] {
- const fromScale = opts.fromScale === undefined ? 0 : opts.fromScale
- const toScale = opts.toScale === undefined ? 1 : opts.toScale
- const fromRotate = opts.fromRotate || 0
- let from = `scale(${fromScale})`
- let to = `scale(${toScale})`
- if (opts.axis === 'y') {
- from = `perspective(800px) rotateY(${type === 'in' ? -90 : 90}deg)`
- to = 'perspective(800px) rotateY(0deg)'
- }
- else if (opts.axis === 'x') {
- from = `perspective(800px) rotateX(${type === 'in' ? 90 : -90}deg)`
- to = 'perspective(800px) rotateX(0deg)'
- }
- else if (opts.stretch === 'x') {
- from = 'scaleX(0)'
- to = 'scaleX(1)'
- }
- else if (opts.stretch === 'y') {
- from = 'scaleY(0)'
- to = 'scaleY(1)'
- }
- else if (fromRotate) {
- from = `scale(${fromScale}) rotate(${fromRotate}deg)`
- to = `scale(${toScale}) rotate(0deg)`
- }
- return type === 'in' ? [{ transform: from }, { transform: to }] : [{ transform: to }, { transform: from }]
- }
- /** 弹跳进入 */
- function bounceKeyframes(type: 'in' | 'out'): Keyframe[] {
- const frames: Keyframe[] = [
- { transform: 'scale(0.3)', opacity: 0, offset: 0 },
- { transform: 'scale(1.05)', opacity: 1, offset: 0.6 },
- { transform: 'scale(0.95)', opacity: 1, offset: 0.8 },
- { transform: 'scale(1)', opacity: 1, offset: 1 },
- ]
- return type === 'in' ? frames : [...frames].reverse()
- }
- /** 光速 / 翻滚 / 螺旋 */
- function fancyEntrance(type: 'in' | 'out', kind: 'lightSpeed' | 'roll' | 'spiral'): Keyframe[] {
- let from = 'translateX(-100%) skewX(-30deg)'
- let to = 'translateX(0%) skewX(0deg)'
- if (kind === 'roll') {
- from = 'translateX(-100%) rotate(-120deg)'
- to = 'translateX(0%) rotate(0deg)'
- }
- if (kind === 'spiral') {
- from = 'rotate(-540deg) scale(0.1)'
- to = 'rotate(0deg) scale(1)'
- }
- return type === 'in'
- ? [{ transform: from, opacity: 0 }, { transform: to, opacity: 1 }]
- : [{ transform: to, opacity: 1 }, { transform: from, opacity: 0 }]
- }
- /* =========================================================================
- * 动作路径
- * ========================================================================= */
- /** 把 PowerPoint 的 M/L/C 路径(M 0 0 L .2 0 E)采样成位移关键帧 */
- function pathKeyframes(path: string, type: 'in' | 'out', ctx: PlayContext): Keyframe[] {
- const tokens = path.replace(/([MLCQZmlcqzEe])/g, ' $1 ').trim().split(/\s+/)
- const points: { x: number, y: number }[] = []
- let i = 0
- let current = { x: 0, y: 0 }
- while (i < tokens.length) {
- const cmd = tokens[i]
- if (cmd === 'M' || cmd === 'L' || cmd === 'm' || cmd === 'l') {
- const x = parseFloat(tokens[i + 1])
- const y = parseFloat(tokens[i + 2])
- if (!isNaN(x) && !isNaN(y)) {
- current = { x, y }
- points.push({ ...current })
- }
- i += 3
- }
- else if (cmd === 'C' || cmd === 'c') {
- // 三次贝塞尔:取终点做线性近似(足够还原路径走向)
- const x = parseFloat(tokens[i + 5])
- const y = parseFloat(tokens[i + 6])
- if (!isNaN(x) && !isNaN(y)) {
- current = { x, y }
- points.push({ ...current })
- }
- i += 7
- }
- else if (cmd === 'E' || cmd === 'e' || cmd === 'Z' || cmd === 'z') {
- break
- }
- else {
- i += 1
- }
- }
- if (!points.length) return []
- const frames = points.map((p, index) => ({
- transform: `translate(${px(p.x * ctx.slideW)}, ${px(p.y * ctx.slideH)})`,
- offset: points.length === 1 ? 1 : index / (points.length - 1),
- easing: 'linear',
- }))
- const withEnd = [{ transform: 'translate(0px, 0px)', offset: 0, easing: 'linear' }, ...frames]
- return type === 'in' ? withEnd : withEnd
- }
- /* =========================================================================
- * 强调类动画
- * ========================================================================= */
- function emphasisKeyframes(name: string, anim: PptxAnimationRecord): Keyframe[] {
- const props = anim.properties || {}
- switch (name) {
- case 'pulse':
- return [{ transform: 'scale(1)' }, { transform: 'scale(1.12)' }, { transform: 'scale(1)' }]
- case 'spin': {
- const by = props.by !== undefined && props.by !== null ? props.by : 360
- const deg = by > 1000 ? by / 60000 : by
- return [{ transform: 'rotate(0deg)' }, { transform: `rotate(${deg}deg)` }]
- }
- case 'growshrink': {
- const to = props.to !== undefined && props.to !== null ? props.to : 1.5
- const scale = to > 10 ? to / 100000 : to
- if (props.loopBack !== false) {
- return [{ transform: 'scale(1)' }, { transform: `scale(${scale})` }, { transform: 'scale(1)' }]
- }
- return [{ transform: 'scale(1)' }, { transform: `scale(${scale})` }]
- }
- case 'transparency': {
- const from = props.from !== undefined ? props.from : 1
- const to = props.to !== undefined ? props.to : 0.5
- return [{ opacity: String(from) }, { opacity: String(to) }]
- }
- case 'bold':
- return [{ fontWeight: '400' }, { fontWeight: '700' }]
- case 'fontsize': {
- const to = props.to !== undefined ? props.to : 48
- return [{ fontSize: '1em' }, { fontSize: `${to / 100}em` }]
- }
- case 'flash':
- return [
- { opacity: 1, offset: 0 },
- { opacity: 0, offset: 0.25 },
- { opacity: 1, offset: 0.5 },
- { opacity: 0, offset: 0.75 },
- { opacity: 1, offset: 1 },
- ]
- case 'wave':
- return [
- { transform: 'rotate(0deg)', offset: 0 },
- { transform: 'rotate(8deg)', offset: 0.2 },
- { transform: 'rotate(-8deg)', offset: 0.4 },
- { transform: 'rotate(8deg)', offset: 0.6 },
- { transform: 'rotate(-8deg)', offset: 0.8 },
- { transform: 'rotate(0deg)', offset: 1 },
- ]
- case 'teeter':
- return [
- { transform: 'rotate(0deg)', offset: 0 },
- { transform: 'rotate(-12deg)', offset: 0.2 },
- { transform: 'rotate(10deg)', offset: 0.4 },
- { transform: 'rotate(-6deg)', offset: 0.6 },
- { transform: 'rotate(4deg)', offset: 0.8 },
- { transform: 'rotate(0deg)', offset: 1 },
- ]
- case 'shake':
- return [
- { transform: 'translateX(0)', offset: 0 },
- { transform: 'translateX(-8%)', offset: 0.2 },
- { transform: 'translateX(8%)', offset: 0.4 },
- { transform: 'translateX(-6%)', offset: 0.6 },
- { transform: 'translateX(6%)', offset: 0.8 },
- { transform: 'translateX(0)', offset: 1 },
- ]
- default:
- return [{ opacity: 1 }, { opacity: 1 }]
- }
- }
- /* =========================================================================
- * 主入口
- * ========================================================================= */
- /**
- * 播放单个目标的动画
- */
- async function playOne(node: HTMLElement, anim: PptxAnimationRecord, ctx: PlayContext, duration: number, delay: number): Promise<void> {
- const name = (anim.effectName || 'fade').toLowerCase()
- const type = anim.type
- const props = anim.properties || {}
- const direction = anim.direction || null
- const options: KeyframeAnimationOptions = {
- duration: Math.max(duration, 1),
- delay,
- fill: fillOf(type),
- easing: 'ease',
- }
- // 动作路径(可能出现在任意类型中,必须优先于强调分支处理)
- if (name === 'motion') {
- const frames = anim.path ? pathKeyframes(anim.path, type, ctx) : []
- if (frames.length) {
- await run(node, frames, { ...options, easing: 'linear', fill: type === 'out' ? 'forwards' : 'none' })
- return
- }
- await run(node, fadeWithMotion(type, anim.motion, ctx), options)
- return
- }
- // 出现 / 消失:瞬时切换可见性(用 1ms 动画,便于取消时自动还原)
- if (name === 'appear' || name === 'disappear') {
- const toOpacity = name === 'appear' ? 1 : 0
- await run(node, [{ opacity: String(1 - toOpacity) }, { opacity: String(toOpacity) }], {
- duration: 1,
- delay,
- fill: 'forwards',
- })
- return
- }
- // 强调类
- if (type === 'attention') {
- if (name === 'colorchange') {
- const fromColor = getComputedStyle(node).color
- const toColor = props.clrTo || props.to || fromColor
- await run(node, [{ color: fromColor }, { color: toColor }], { ...options, fill: 'forwards' })
- return
- }
- await run(node, emphasisKeyframes(name, anim), options)
- return
- }
- // 几何擦除类
- if (name === 'wipe') {
- await run(node, wipeKeyframes(type, direction), { ...options, easing: 'linear' })
- return
- }
- if (name === 'blinds') {
- await run(node, blindsKeyframes(type, direction), { ...options, easing: 'ease-in' })
- return
- }
- if (name === 'randbars' || name === 'strips') {
- await run(node, blindsKeyframes(type, direction, 7), { ...options, easing: 'ease-in' })
- return
- }
- if (name === 'checkerboard') {
- await run(node, checkerboardKeyframes(type), { ...options, easing: 'ease-in' })
- return
- }
- if (['circle', 'iris', 'diamond', 'plus', 'wedge', 'box', 'barn'].includes(name)) {
- await run(node, shapeKeyframes(name, type), { ...options, easing: 'ease-out' })
- return
- }
- if (name === 'wheel') {
- await run(node, wheelKeyframes(type), { ...options, easing: 'linear' })
- return
- }
- if (name === 'dissolve') {
- await run(node, type === 'in'
- ? [{ opacity: 0, filter: 'blur(2px)' }, { opacity: 1, filter: 'blur(0px)' }]
- : [{ opacity: 1, filter: 'blur(0px)' }, { opacity: 0, filter: 'blur(2px)' }], options)
- return
- }
- // 位移类
- if (name === 'fly') {
- await run(node, flyKeyframes(type, direction, ctx), { ...options, easing: 'ease-out' })
- return
- }
- if (name === 'peek') {
- await run(node, peekKeyframes(type, direction), { ...options, easing: 'ease-out' })
- return
- }
- if (name === 'bounce') {
- await run(node, bounceKeyframes(type), options)
- return
- }
- if (name === 'lightspeed' || name === 'roll' || name === 'spiral') {
- await run(node, fancyEntrance(type, name as any), options)
- return
- }
- // 缩放 / 旋转 / 翻转 / 拉伸 / 组合淡入
- const hasScale = props.scaleFrom !== undefined || props.scaleTo !== undefined
- const hasRotate = props.rotateFrom !== undefined
- if (name === 'zoom' || (name === 'fade' && (hasScale || hasRotate))) {
- const frames = transformEntrance(type, {
- fromScale: hasScale ? props.scaleFrom : 0.4,
- toScale: hasScale ? props.scaleTo : 1,
- fromRotate: hasRotate ? props.rotateFrom : 0,
- })
- if (hasRotate && !hasScale) {
- const rotateFrames = transformEntrance(type, { fromScale: 1, toScale: 1, fromRotate: props.rotateFrom })
- await run(node, type === 'in'
- ? [{ opacity: 0, ...rotateFrames[0] }, { opacity: 1, ...rotateFrames[1] }]
- : [{ opacity: 1, ...rotateFrames[0] }, { opacity: 0, ...rotateFrames[1] }], options)
- return
- }
- await run(node, type === 'in'
- ? [{ opacity: 0, ...frames[0] }, { opacity: 1, ...frames[1] }]
- : [{ opacity: 1, ...frames[0] }, { opacity: 0, ...frames[1] }], options)
- return
- }
- if (name === 'swivel') {
- await run(node, [
- { opacity: 0, transform: 'perspective(800px) rotateY(-90deg)' },
- { opacity: 1, transform: 'perspective(800px) rotateY(0deg)' },
- ], options)
- return
- }
- if (name === 'flip') {
- await run(node, [
- { opacity: 0, transform: 'perspective(800px) rotateX(90deg)' },
- { opacity: 1, transform: 'perspective(800px) rotateX(0deg)' },
- ], options)
- return
- }
- if (name === 'stretch') {
- await run(node, transformEntrance(type, { stretch: direction === 'vertical' ? 'y' : 'x' }), options)
- return
- }
- if (name === 'expand') {
- await run(node, transformEntrance(type, { fromScale: 0.1, toScale: 1 }), options)
- return
- }
- if (name === 'collapse') {
- await run(node, transformEntrance(type, { fromScale: 0, toScale: 1 }), options)
- return
- }
- // 默认:淡入淡出(带位移时同时位移,即「浮入 / 升起 / 字幕」)
- await run(node, fadeWithMotion(type, anim.motion, ctx), options)
- }
- /**
- * 播放入场/退场/强调动画(支持文字分级构建)
- * @returns Promise,全部播完(或被取消)后 resolve
- */
- export async function playPptxAnimation(el: HTMLElement, anim: PptxAnimationRecord, ctx: PlayContext): Promise<void> {
- const targets = resolveTargets(el, anim)
- const total = Math.max(anim.duration || 500, 1)
- const baseDelay = anim.delay || 0
- // 按段落/字词构建:整体时长不变,各单元顺序播放
- const perDuration = targets.length > 1 ? Math.max(total / targets.length, 120) : total
- for (let i = 0; i < targets.length; i++) {
- // 退场时反向播放(最后一段先消失),与 WPS 表现一致
- const target = anim.type === 'out' ? targets[targets.length - 1 - i] : targets[i]
- const delay = i === 0 ? baseDelay : 0
- await playOne(target, anim, ctx, perDuration, delay)
- }
- }
- /** 清理文字构建产生的临时 span(切换页面时调用) */
- export function cleanupTextBuild(root: ParentNode = document) {
- root.querySelectorAll(`[${UNIT_ATTR}]`).forEach(span => {
- const parent = span.parentNode
- if (!parent) return
- parent.replaceChild(document.createTextNode(span.textContent || ''), span)
- parent.normalize()
- })
- }
|