|
|
@@ -0,0 +1,677 @@
|
|
|
+/**
|
|
|
+ * 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()
|
|
|
+ })
|
|
|
+}
|