|
|
@@ -0,0 +1,1154 @@
|
|
|
+/**
|
|
|
+ * PPTX / WPS 动画播放引擎
|
|
|
+ *
|
|
|
+ * 背景:
|
|
|
+ * PPTist 原生只支持 animate.css 的固定类名(fadeIn / slideIn 等),
|
|
|
+ * 而 PowerPoint / WPS 的动画体系是「预设 + 参数」:
|
|
|
+ * - 几何擦除类:wipe / blinds / checkerboard / wheel / wedge / box / circle / diamond / plus / split
|
|
|
+ * - 位移类:float / rise / fly / crawl / credits(由 ppt_x / ppt_y 的位移轨迹决定)
|
|
|
+ * - 组合类:scale 通道 = 缩放进入;rotate 通道 = 旋转进入;两者叠加 = 旋转放大
|
|
|
+ * - 属性类:填充色 / 线条色 / 字号 / 字体 / 字形 / 透明度 / 数字滚动
|
|
|
+ * - 动作路径:animMotion 的 SVG path(支持 M/L/H/V/C/S/Q/T/A/Z 全命令)
|
|
|
+ * - 文字构建:按段落 / 按字词 / 按字母逐级播放,以及聚拢 / 分散
|
|
|
+ *
|
|
|
+ * 关键设计:
|
|
|
+ * PowerPoint 的一个预设动画往往由多个并行通道组成
|
|
|
+ * (例如「旋转进入」= ppt_w/ppt_h 0→原尺寸 + style.rotation 360→0 + fade),
|
|
|
+ * 所以这里不做「一个名字一个硬编码」的映射,而是把通道合成一条 transform 关键帧序列。
|
|
|
+ *
|
|
|
+ * 本引擎使用 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 动画,避免多次播放后 transform 叠加 */
|
|
|
+function cancelNodeAnimations(node: HTMLElement) {
|
|
|
+ try {
|
|
|
+ for (const animation of node.getAnimations()) animation.cancel()
|
|
|
+ }
|
|
|
+ catch {
|
|
|
+ /* 某些环境不支持,忽略 */
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 执行一段 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'): 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 splitKeyframes(type: 'in' | 'out', direction: string | null): Keyframe[] {
|
|
|
+ const horizontal = direction !== 'vertical'
|
|
|
+ const closed = horizontal ? 'inset(0% 50% 0% 50%)' : 'inset(50% 0% 50% 0%)'
|
|
|
+ const open = 'inset(0% 0% 0% 0%)'
|
|
|
+ return type === 'in'
|
|
|
+ ? [{ clipPath: closed }, { clipPath: open }]
|
|
|
+ : [{ clipPath: open }, { clipPath: closed }]
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 阶梯状:沿纵向逐条推进(WPS 的「阶梯状」进入/退出)。
|
|
|
+ * 用 steps 缓动制造一格一格的停顿感。
|
|
|
+ */
|
|
|
+function stairsKeyframes(type: 'in' | 'out', direction: string | null): Keyframe[] {
|
|
|
+ const fromTop = direction !== 'up'
|
|
|
+ const hidden = fromTop ? 'inset(0% 0% 100% 0%)' : 'inset(100% 0% 0% 0%)'
|
|
|
+ const full = 'inset(0% 0% 0% 0%)'
|
|
|
+ return type === 'in'
|
|
|
+ ? [{ clipPath: hidden }, { clipPath: full }]
|
|
|
+ : [{ clipPath: full }, { clipPath: hidden }]
|
|
|
+}
|
|
|
+
|
|
|
+/* =========================================================================
|
|
|
+ * 位移 / 缩放 / 旋转类动画
|
|
|
+ * ========================================================================= */
|
|
|
+
|
|
|
+function px(value: number) {
|
|
|
+ return `${value}px`
|
|
|
+}
|
|
|
+
|
|
|
+/** 飞入:从画布边缘滑入(无淡入) */
|
|
|
+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)})`,
|
|
|
+ topLeft: `translate(${px(-ctx.slideW)}, ${px(-ctx.slideH)})`,
|
|
|
+ topRight: `translate(${px(ctx.slideW)}, ${px(-ctx.slideH)})`,
|
|
|
+ bottomLeft: `translate(${px(-ctx.slideW)}, ${px(ctx.slideH)})`,
|
|
|
+ bottomRight: `translate(${px(ctx.slideW)}, ${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 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 }]
|
|
|
+}
|
|
|
+
|
|
|
+/* =========================================================================
|
|
|
+ * 通道合成动画(缩放 / 旋转 / 位移 / 透明度叠加)
|
|
|
+ * ========================================================================= */
|
|
|
+
|
|
|
+interface ChannelState {
|
|
|
+ scale: number
|
|
|
+ rotate: number
|
|
|
+ tx: number
|
|
|
+ ty: number
|
|
|
+ opacity: number
|
|
|
+}
|
|
|
+
|
|
|
+function transformOf(s: ChannelState) {
|
|
|
+ return `translate(${px(s.tx)}, ${px(s.ty)}) scale(${s.scale}) rotate(${s.rotate}deg)`
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 把 properties 里的 scale / rotate / motion / opacity 通道合成关键帧。
|
|
|
+ *
|
|
|
+ * 这是「缩放进入」「旋转进入」「旋转放大」「浮入」「缓慢进入」的统一实现:
|
|
|
+ * 这些效果在 XML 里只是通道组合不同,不应写成互斥分支。
|
|
|
+ */
|
|
|
+function composeChannelKeyframes(
|
|
|
+ type: 'in' | 'out',
|
|
|
+ anim: PptxAnimationRecord,
|
|
|
+ ctx: PlayContext,
|
|
|
+ opts: { defaultScaleFrom?: number, defaultOpacity?: number } = {},
|
|
|
+): Keyframe[] | null {
|
|
|
+ const props = anim.properties || {}
|
|
|
+ const hasScale = props.scaleFrom !== undefined || props.scaleTo !== undefined
|
|
|
+ const hasRotate = props.rotateFrom !== undefined && props.rotateTo !== undefined
|
|
|
+ const hasMotion = !!anim.motion && (Math.abs(anim.motion.dx) > 1e-9 || Math.abs(anim.motion.dy) > 1e-9)
|
|
|
+ const hasOpacity = props.opacityFrom !== undefined && props.opacityTo !== undefined
|
|
|
+
|
|
|
+ if (!hasScale && !hasRotate && !hasMotion && !hasOpacity && opts.defaultScaleFrom === undefined) return null
|
|
|
+
|
|
|
+ const dx = anim.motion ? anim.motion.dx * ctx.slideW : 0
|
|
|
+ const dy = anim.motion ? anim.motion.dy * ctx.slideH : 0
|
|
|
+
|
|
|
+ const scaleFrom = hasScale
|
|
|
+ ? Number(props.scaleFrom)
|
|
|
+ : (opts.defaultScaleFrom !== undefined ? opts.defaultScaleFrom : 1)
|
|
|
+ const scaleTo = hasScale ? Number(props.scaleTo) : 1
|
|
|
+ const rotateFrom = hasRotate ? Number(props.rotateFrom) : 0
|
|
|
+ const rotateTo = hasRotate ? Number(props.rotateTo) : 0
|
|
|
+
|
|
|
+ // 进入/退出:默认从「偏移态」到「常态」,透明度跟随 fade 语义
|
|
|
+ const fadeIn = opts.defaultOpacity !== undefined
|
|
|
+ ? opts.defaultOpacity
|
|
|
+ : (hasOpacity ? Number(props.opacityFrom) : 0)
|
|
|
+
|
|
|
+ let from: ChannelState
|
|
|
+ let to: ChannelState
|
|
|
+
|
|
|
+ if (type === 'in') {
|
|
|
+ from = { scale: scaleFrom, rotate: rotateFrom, tx: dx, ty: dy, opacity: fadeIn }
|
|
|
+ to = { scale: scaleTo, rotate: rotateTo, tx: 0, ty: 0, opacity: 1 }
|
|
|
+ }
|
|
|
+ else {
|
|
|
+ from = { scale: scaleTo, rotate: rotateTo, tx: 0, ty: 0, opacity: 1 }
|
|
|
+ to = { scale: scaleFrom, rotate: rotateFrom, tx: dx, ty: dy, opacity: hasOpacity ? Number(props.opacityTo) : 0 }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 两侧状态完全一致时视为无效果,交由默认淡入处理
|
|
|
+ const same = from.scale === to.scale && from.rotate === to.rotate &&
|
|
|
+ from.tx === to.tx && from.ty === to.ty && from.opacity === to.opacity
|
|
|
+ if (same) return null
|
|
|
+
|
|
|
+ return [
|
|
|
+ { transform: transformOf(from), opacity: String(from.opacity) },
|
|
|
+ { transform: transformOf(to), opacity: String(to.opacity) },
|
|
|
+ ]
|
|
|
+}
|
|
|
+
|
|
|
+/* =========================================================================
|
|
|
+ * 动作路径
|
|
|
+ * ========================================================================= */
|
|
|
+
|
|
|
+/**
|
|
|
+ * 解析 PowerPoint 的 SVG 路径(animMotion 的 path 属性)并采样成位移关键帧。
|
|
|
+ *
|
|
|
+ * 必须支持全部命令,因为 WPS 的「动作路径」库(八边形 / 八角星 / 等边三角形 /
|
|
|
+ * 橄榄球形 / 泪滴形 / 菱形 / 六边形 …)全部用 path 字符串表达。
|
|
|
+ *
|
|
|
+ * ⚠ 关键坑:PPT 写出的坐标常是科学计数法(如 3.75E-06),
|
|
|
+ * 旧实现把 E 当成「End」命令,会把数字截断成 3.75,导致路径完全错乱。
|
|
|
+ * 这里用「字母后不跟数字/正负号」的判定,避免误伤指数记号。
|
|
|
+ */
|
|
|
+function pathKeyframes(path: string, type: 'in' | 'out', ctx: PlayContext): Keyframe[] {
|
|
|
+ const points = sampleSvgPath(path)
|
|
|
+ if (!points.length) return []
|
|
|
+
|
|
|
+ // PPT 的 animMotion 路径以「元素当前位置」为原点,
|
|
|
+ // 而路径的 M 点常写成形状自身坐标(如八边形的 M 0.1 0)。
|
|
|
+ // 必须以首点为基准平移,否则元素会先瞬移到路径起点再开始走。
|
|
|
+ const origin = points[0]
|
|
|
+ const seq = points.map(p => ({ x: p.x - origin.x, y: p.y - origin.y }))
|
|
|
+
|
|
|
+ return seq.map((p, index) => ({
|
|
|
+ transform: `translate(${px(p.x * ctx.slideW)}, ${px(p.y * ctx.slideH)})`,
|
|
|
+ offset: seq.length === 1 ? 1 : index / (seq.length - 1),
|
|
|
+ easing: 'linear',
|
|
|
+ }))
|
|
|
+}
|
|
|
+
|
|
|
+interface Pt { x: number, y: number }
|
|
|
+
|
|
|
+/** 采样 SVG 路径为点序列(坐标单位 = 幻灯片宽高比例) */
|
|
|
+function sampleSvgPath(path: string): Pt[] {
|
|
|
+ if (!path) return []
|
|
|
+
|
|
|
+ // 词法:命令字母 + 数字(含科学计数法)
|
|
|
+ const tokens = path.match(/[MmLlHhVvCcSsQqTtAaZzEe]|-?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?/g) || []
|
|
|
+ const points: Pt[] = []
|
|
|
+
|
|
|
+ let i = 0
|
|
|
+ let cmd = ''
|
|
|
+ let cur: Pt = { x: 0, y: 0 }
|
|
|
+ let startPt: Pt = { x: 0, y: 0 }
|
|
|
+ let lastCtrl: Pt | null = null
|
|
|
+
|
|
|
+ const num = () => {
|
|
|
+ const v = parseFloat(tokens[i])
|
|
|
+ i += 1
|
|
|
+ return isNaN(v) ? 0 : v
|
|
|
+ }
|
|
|
+ const push = (p: Pt, relative: boolean) => {
|
|
|
+ const next = relative ? { x: cur.x + p.x, y: cur.y + p.y } : p
|
|
|
+ cur = next
|
|
|
+ points.push({ ...next })
|
|
|
+ }
|
|
|
+
|
|
|
+ const CURVE_SEGMENTS = 10
|
|
|
+
|
|
|
+ while (i < tokens.length) {
|
|
|
+ const token = tokens[i]
|
|
|
+ if (/^[A-Za-z]$/.test(token)) {
|
|
|
+ // 科学计数法里的 e/E 不是命令:单独出现且后面无数字时才算终止符
|
|
|
+ if ((token === 'E' || token === 'e') && !/[MmLlHhVvCcSsQqTtAaZz]/.test(cmd)) break
|
|
|
+ cmd = token
|
|
|
+ i += 1
|
|
|
+ if (cmd === 'Z' || cmd === 'z') {
|
|
|
+ cur = { ...startPt }
|
|
|
+ points.push({ ...cur })
|
|
|
+ lastCtrl = null
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ continue
|
|
|
+ }
|
|
|
+
|
|
|
+ // 数字:按当前命令消费参数
|
|
|
+ switch (cmd) {
|
|
|
+ case 'M':
|
|
|
+ case 'm': {
|
|
|
+ const relative = cmd === 'm'
|
|
|
+ const p = { x: num(), y: num() }
|
|
|
+ push(p, relative)
|
|
|
+ startPt = { ...cur }
|
|
|
+ lastCtrl = null
|
|
|
+ // 后续连续坐标视为 lineto
|
|
|
+ cmd = relative ? 'l' : 'L'
|
|
|
+ break
|
|
|
+ }
|
|
|
+ case 'L':
|
|
|
+ case 'l': {
|
|
|
+ push({ x: num(), y: num() }, cmd === 'l')
|
|
|
+ lastCtrl = null
|
|
|
+ break
|
|
|
+ }
|
|
|
+ case 'H':
|
|
|
+ case 'h': {
|
|
|
+ const v = num()
|
|
|
+ cur = { x: cmd === 'h' ? cur.x + v : v, y: cur.y }
|
|
|
+ points.push({ ...cur })
|
|
|
+ lastCtrl = null
|
|
|
+ break
|
|
|
+ }
|
|
|
+ case 'V':
|
|
|
+ case 'v': {
|
|
|
+ const v = num()
|
|
|
+ cur = { x: cur.x, y: cmd === 'v' ? cur.y + v : v }
|
|
|
+ points.push({ ...cur })
|
|
|
+ lastCtrl = null
|
|
|
+ break
|
|
|
+ }
|
|
|
+ case 'C':
|
|
|
+ case 'c': {
|
|
|
+ const relative = cmd === 'c'
|
|
|
+ const base = { ...cur }
|
|
|
+ const c1 = { x: num(), y: num() }
|
|
|
+ const c2 = { x: num(), y: num() }
|
|
|
+ const end = { x: num(), y: num() }
|
|
|
+ const abs = (p: Pt) => relative ? { x: base.x + p.x, y: base.y + p.y } : p
|
|
|
+ const p1 = abs(c1), p2 = abs(c2), p3 = abs(end)
|
|
|
+ for (let s = 1; s <= CURVE_SEGMENTS; s++) {
|
|
|
+ const t = s / CURVE_SEGMENTS
|
|
|
+ const mt = 1 - t
|
|
|
+ points.push({
|
|
|
+ x: mt * mt * mt * base.x + 3 * mt * mt * t * p1.x + 3 * mt * t * t * p2.x + t * t * t * p3.x,
|
|
|
+ y: mt * mt * mt * base.y + 3 * mt * mt * t * p1.y + 3 * mt * t * t * p2.y + t * t * t * p3.y,
|
|
|
+ })
|
|
|
+ }
|
|
|
+ cur = { ...p3 }
|
|
|
+ lastCtrl = p2
|
|
|
+ break
|
|
|
+ }
|
|
|
+ case 'S':
|
|
|
+ case 's': {
|
|
|
+ const relative = cmd === 's'
|
|
|
+ const base = { ...cur }
|
|
|
+ const c2 = { x: num(), y: num() }
|
|
|
+ const end = { x: num(), y: num() }
|
|
|
+ const abs = (p: Pt) => relative ? { x: base.x + p.x, y: base.y + p.y } : p
|
|
|
+ const p1: Pt = lastCtrl
|
|
|
+ ? { x: 2 * base.x - lastCtrl.x, y: 2 * base.y - lastCtrl.y }
|
|
|
+ : base
|
|
|
+ const p2 = abs(c2)
|
|
|
+ const p3 = abs(end)
|
|
|
+ for (let s = 1; s <= CURVE_SEGMENTS; s++) {
|
|
|
+ const t = s / CURVE_SEGMENTS
|
|
|
+ const mt = 1 - t
|
|
|
+ points.push({
|
|
|
+ x: mt * mt * mt * base.x + 3 * mt * mt * t * p1.x + 3 * mt * t * t * p2.x + t * t * t * p3.x,
|
|
|
+ y: mt * mt * mt * base.y + 3 * mt * mt * t * p1.y + 3 * mt * t * t * p2.y + t * t * t * p3.y,
|
|
|
+ })
|
|
|
+ }
|
|
|
+ cur = { ...p3 }
|
|
|
+ lastCtrl = p2
|
|
|
+ break
|
|
|
+ }
|
|
|
+ case 'Q':
|
|
|
+ case 'q': {
|
|
|
+ const relative = cmd === 'q'
|
|
|
+ const base = { ...cur }
|
|
|
+ const c1 = { x: num(), y: num() }
|
|
|
+ const end = { x: num(), y: num() }
|
|
|
+ const abs = (p: Pt) => relative ? { x: base.x + p.x, y: base.y + p.y } : p
|
|
|
+ const p1 = abs(c1)
|
|
|
+ const p2 = abs(end)
|
|
|
+ for (let s = 1; s <= CURVE_SEGMENTS; s++) {
|
|
|
+ const t = s / CURVE_SEGMENTS
|
|
|
+ const mt = 1 - t
|
|
|
+ points.push({
|
|
|
+ x: mt * mt * base.x + 2 * mt * t * p1.x + t * t * p2.x,
|
|
|
+ y: mt * mt * base.y + 2 * mt * t * p1.y + t * t * p2.y,
|
|
|
+ })
|
|
|
+ }
|
|
|
+ cur = { ...p2 }
|
|
|
+ lastCtrl = p1
|
|
|
+ break
|
|
|
+ }
|
|
|
+ case 'T':
|
|
|
+ case 't': {
|
|
|
+ const relative = cmd === 't'
|
|
|
+ const base = { ...cur }
|
|
|
+ const end = { x: num(), y: num() }
|
|
|
+ const p2 = relative ? { x: base.x + end.x, y: base.y + end.y } : end
|
|
|
+ const p1: Pt = lastCtrl
|
|
|
+ ? { x: 2 * base.x - lastCtrl.x, y: 2 * base.y - lastCtrl.y }
|
|
|
+ : base
|
|
|
+ for (let s = 1; s <= CURVE_SEGMENTS; s++) {
|
|
|
+ const t = s / CURVE_SEGMENTS
|
|
|
+ const mt = 1 - t
|
|
|
+ points.push({
|
|
|
+ x: mt * mt * base.x + 2 * mt * t * p1.x + t * t * p2.x,
|
|
|
+ y: mt * mt * base.y + 2 * mt * t * p1.y + t * t * p2.y,
|
|
|
+ })
|
|
|
+ }
|
|
|
+ cur = { ...p2 }
|
|
|
+ lastCtrl = p1
|
|
|
+ break
|
|
|
+ }
|
|
|
+ case 'A':
|
|
|
+ case 'a': {
|
|
|
+ // 椭圆弧:rx ry rot largeArc sweep x y
|
|
|
+ const relative = cmd === 'a'
|
|
|
+ const rx = Math.abs(num())
|
|
|
+ const ry = Math.abs(num())
|
|
|
+ num() // x-axis-rotation:路径上的旋转对本场景影响极小,忽略
|
|
|
+ num() // largeArc
|
|
|
+ num() // sweep
|
|
|
+ const ex = num()
|
|
|
+ const ey = num()
|
|
|
+ const target = relative ? { x: cur.x + ex, y: cur.y + ey } : { x: ex, y: ey }
|
|
|
+ const from = { ...cur }
|
|
|
+ const N = 16
|
|
|
+ for (let s = 1; s <= N; s++) {
|
|
|
+ const t = s / N
|
|
|
+ // 用椭圆参数化近似(起点/终点夹角足够还原形状)
|
|
|
+ const angle = Math.PI * t
|
|
|
+ points.push({
|
|
|
+ x: from.x + (target.x - from.x) * t + Math.sin(angle) * (rx - Math.abs(target.x - from.x) / 2) * 0.5,
|
|
|
+ y: from.y + (target.y - from.y) * t - Math.sin(angle) * ry * 0.5 * (1 - t),
|
|
|
+ })
|
|
|
+ }
|
|
|
+ cur = { ...target }
|
|
|
+ lastCtrl = null
|
|
|
+ break
|
|
|
+ }
|
|
|
+ default:
|
|
|
+ i += 1
|
|
|
+ break
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return points
|
|
|
+}
|
|
|
+
|
|
|
+/* =========================================================================
|
|
|
+ * 强调类动画
|
|
|
+ * ========================================================================= */
|
|
|
+
|
|
|
+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 ? Number(props.from) : 1
|
|
|
+ const to = props.to !== undefined ? Number(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 ? parseFloat(props.to) : 48
|
|
|
+ // PPTX 的字号单位是 1/100 磅;WPS 有时直接写磅
|
|
|
+ const pt = to > 1000 ? to / 100 : to
|
|
|
+ const scale = Math.max(pt / 24, 0.2)
|
|
|
+ return [{ transform: 'scale(1)' }, { transform: `scale(${scale})` }]
|
|
|
+ }
|
|
|
+ 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:
|
|
|
+ // 未识别的强调效果:绝不能返回「opacity 1 → 1」的空动画,
|
|
|
+ // 那等于用户点了播放却什么都没发生。退化为一次轻微脉冲,至少可见。
|
|
|
+ return [
|
|
|
+ { transform: 'scale(1)', offset: 0 },
|
|
|
+ { transform: 'scale(1.08)', offset: 0.5 },
|
|
|
+ { transform: 'scale(1)', offset: 1 },
|
|
|
+ ]
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/* =========================================================================
|
|
|
+ * 文本特效:聚拢渐入 / 分散淡出
|
|
|
+ * ========================================================================= */
|
|
|
+
|
|
|
+/**
|
|
|
+ * 计算单个文字单元相对容器中心的「向外方向」。
|
|
|
+ * 聚拢渐入 = 从该方向的外侧飞回原位;分散淡出 = 沿该方向散开。
|
|
|
+ */
|
|
|
+function radialDirection(node: HTMLElement, root: HTMLElement): { x: number, y: number } {
|
|
|
+ try {
|
|
|
+ const n = node.getBoundingClientRect()
|
|
|
+ const r = root.getBoundingClientRect()
|
|
|
+ const cx = r.left + r.width / 2
|
|
|
+ const cy = r.top + r.height / 2
|
|
|
+ const nx = n.left + n.width / 2
|
|
|
+ const ny = n.top + n.height / 2
|
|
|
+ const dx = nx - cx
|
|
|
+ const dy = ny - cy
|
|
|
+ const len = Math.hypot(dx, dy) || 1
|
|
|
+ return { x: dx / len, y: dy / len }
|
|
|
+ }
|
|
|
+ catch {
|
|
|
+ return { x: -1, y: 0 }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/** 聚拢渐入 / 分散淡出:每个字按自身方位从外向内收拢或向外散开 */
|
|
|
+function gatherKeyframes(node: HTMLElement, root: HTMLElement, type: 'in' | 'out', distance = 220): Keyframe[] {
|
|
|
+ const dir = radialDirection(node, root)
|
|
|
+ const offset = `translate(${px(dir.x * distance)}, ${px(dir.y * distance)}) scale(0.5)`
|
|
|
+ const none = 'translate(0px, 0px) scale(1)'
|
|
|
+ return type === 'in'
|
|
|
+ ? [{ transform: offset, opacity: 0 }, { transform: none, opacity: 1 }]
|
|
|
+ : [{ transform: none, opacity: 1 }, { transform: offset, opacity: 0 }]
|
|
|
+}
|
|
|
+
|
|
|
+/* =========================================================================
|
|
|
+ * 数字滚动(WPS「动态数字」)
|
|
|
+ * ========================================================================= */
|
|
|
+
|
|
|
+/** 在元素内找到含数字的文本节点,逐帧滚动到目标值 */
|
|
|
+async function playDynamicNumber(node: HTMLElement, from: any, to: any, duration: number, delay: number) {
|
|
|
+ const startValue = parseFloat(String(from))
|
|
|
+ const endValue = parseFloat(String(to))
|
|
|
+ if (isNaN(startValue) || isNaN(endValue)) return
|
|
|
+
|
|
|
+ const walker = document.createTreeWalker(node, NodeFilter.SHOW_TEXT)
|
|
|
+ let target: Text | null = null
|
|
|
+ let current = walker.nextNode()
|
|
|
+ while (current) {
|
|
|
+ if (/\d/.test(current.nodeValue || '')) {
|
|
|
+ target = current as Text
|
|
|
+ break
|
|
|
+ }
|
|
|
+ current = walker.nextNode()
|
|
|
+ }
|
|
|
+ if (!target) return
|
|
|
+
|
|
|
+ const original = target.nodeValue || ''
|
|
|
+ const match = original.match(/-?\d+(?:\.\d+)?/)
|
|
|
+ if (!match) return
|
|
|
+
|
|
|
+ const prefix = original.slice(0, match.index)
|
|
|
+ const suffix = original.slice((match.index || 0) + match[0].length)
|
|
|
+ const decimals = (match[0].split('.')[1] || '').length
|
|
|
+
|
|
|
+ if (delay > 0) await wait(delay)
|
|
|
+
|
|
|
+ const startTime = performance.now()
|
|
|
+ const settle = (value: number) => {
|
|
|
+ target!.nodeValue = `${prefix}${value.toFixed(decimals)}${suffix}`
|
|
|
+ }
|
|
|
+
|
|
|
+ await new Promise<void>(resolve => {
|
|
|
+ let done = false
|
|
|
+ const finish = (value: number) => {
|
|
|
+ if (done) return
|
|
|
+ done = true
|
|
|
+ settle(value)
|
|
|
+ clearTimeout(timer)
|
|
|
+ clearTimeout(guard)
|
|
|
+ resolve()
|
|
|
+ }
|
|
|
+ // ★ 用 setTimeout 而不是 requestAnimationFrame 驱动:
|
|
|
+ // rAF 在后台标签页 / 低功耗模式会被节流到 1fps 甚至完全停止,
|
|
|
+ // 会让数字长时间停在起始值,且 await 无法推进导致整条动画链卡死。
|
|
|
+ let timer = 0
|
|
|
+ const guard = setTimeout(() => finish(endValue), Math.max(duration, 1) + 300)
|
|
|
+
|
|
|
+ const tick = () => {
|
|
|
+ if (done) return
|
|
|
+ const t = Math.min((performance.now() - startTime) / Math.max(duration, 1), 1)
|
|
|
+ settle(startValue + (endValue - startValue) * t)
|
|
|
+ if (t < 1) timer = window.setTimeout(tick, 16)
|
|
|
+ else finish(endValue)
|
|
|
+ }
|
|
|
+ timer = window.setTimeout(tick, 16)
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+/* =========================================================================
|
|
|
+ * 主入口
|
|
|
+ * ========================================================================= */
|
|
|
+
|
|
|
+/**
|
|
|
+ * 播放单个目标的动画
|
|
|
+ */
|
|
|
+async function playOne(
|
|
|
+ node: HTMLElement,
|
|
|
+ anim: PptxAnimationRecord,
|
|
|
+ ctx: PlayContext,
|
|
|
+ duration: number,
|
|
|
+ delay: number,
|
|
|
+ root: HTMLElement,
|
|
|
+): Promise<void> {
|
|
|
+ const name = (anim.effectName || 'fade').toLowerCase()
|
|
|
+ // 强调类已在上面的分支单独处理,这里收窄为 in/out,避免几何效果的参数类型冲突
|
|
|
+ const type: 'in' | 'out' = anim.type === 'out' ? 'out' : 'in'
|
|
|
+ const rawType = anim.type
|
|
|
+ const props = anim.properties || {}
|
|
|
+ const direction = anim.direction || null
|
|
|
+
|
|
|
+ cancelNodeAnimations(node)
|
|
|
+
|
|
|
+ const options: KeyframeAnimationOptions = {
|
|
|
+ duration: Math.max(duration, 1),
|
|
|
+ delay,
|
|
|
+ fill: fillOf(type),
|
|
|
+ easing: 'ease',
|
|
|
+ }
|
|
|
+
|
|
|
+ /* ---------- 1. 动作路径(最高优先级:可能叠加任意类型) ---------- */
|
|
|
+ if (name === 'motion') {
|
|
|
+ const frames = anim.path ? pathKeyframes(anim.path, type, ctx) : []
|
|
|
+ if (frames.length) {
|
|
|
+ // 路径动画结束要停在终点
|
|
|
+ await run(node, frames, { ...options, easing: 'linear', fill: 'forwards' })
|
|
|
+ return
|
|
|
+ }
|
|
|
+ const composed = composeChannelKeyframes(type, anim, ctx)
|
|
|
+ if (composed) {
|
|
|
+ await run(node, composed, options)
|
|
|
+ return
|
|
|
+ }
|
|
|
+ await run(node, [{ transform: 'translate(0px, 0px)' }, { transform: 'translate(0px, 0px)' }], options)
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ /* ---------- 2. 出现 / 消失:瞬时切换可见性 ---------- */
|
|
|
+ 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
|
|
|
+ }
|
|
|
+
|
|
|
+ /* ---------- 3. 强调类 ---------- */
|
|
|
+ if (rawType === 'attention') {
|
|
|
+ // 动态数字:数字滚动
|
|
|
+ if (name === 'dynamicnumber') {
|
|
|
+ await playDynamicNumber(node, props.from, props.to, duration, delay)
|
|
|
+ return
|
|
|
+ }
|
|
|
+ // 更改填充:填充色过渡(文本元素则改文字颜色)
|
|
|
+ if (name === 'fillcolor' || name === 'colorchange') {
|
|
|
+ const toColor = props.clrTo || props.to || (props.fillColor && props.fillColor.to)
|
|
|
+ const fromColor = getComputedStyle(node).color
|
|
|
+ if (!toColor) return
|
|
|
+ const isText = !!node.querySelector('.ProseMirror-static')
|
|
|
+ const property = isText ? 'color' : 'backgroundColor'
|
|
|
+ const startColor = isText ? fromColor : (getComputedStyle(node).backgroundColor || 'transparent')
|
|
|
+ await run(node, [{ [property]: startColor }, { [property]: toColor }], { ...options, fill: 'forwards' })
|
|
|
+ return
|
|
|
+ }
|
|
|
+ // 更改线条:边框色过渡
|
|
|
+ if (name === 'linecolor') {
|
|
|
+ const toColor = props.lineColor && props.lineColor.to
|
|
|
+ if (!toColor) return
|
|
|
+ const startColor = getComputedStyle(node).borderColor || 'transparent'
|
|
|
+ await run(node, [
|
|
|
+ { borderColor: startColor },
|
|
|
+ { borderColor: toColor },
|
|
|
+ ], { ...options, fill: 'forwards' })
|
|
|
+ return
|
|
|
+ }
|
|
|
+ // 更改字体:临时刻切换字体
|
|
|
+ if (name === 'fontname') {
|
|
|
+ const font = (props.fontName && props.fontName.to) || props.to
|
|
|
+ if (!font) return
|
|
|
+ const startFont = getComputedStyle(node).fontFamily
|
|
|
+ await run(node, [
|
|
|
+ { fontFamily: startFont },
|
|
|
+ { fontFamily: `"${String(font).replace(/"/g, '')}"` },
|
|
|
+ ], { ...options, fill: 'forwards' })
|
|
|
+ return
|
|
|
+ }
|
|
|
+ // 更改字形:斜体 / 下划线 / 删除线
|
|
|
+ if (name === 'fontstyle') {
|
|
|
+ const italic = props.italic && String(props.italic.to) !== '0'
|
|
|
+ const underline = props.underline && String(props.underline.to) !== '0'
|
|
|
+ const frames: Keyframe[] = [{ fontStyle: 'normal', textDecoration: 'none' }]
|
|
|
+ frames.push({ fontStyle: italic ? 'italic' : 'normal', textDecoration: underline ? 'underline' : 'none' })
|
|
|
+ await run(node, frames, { ...options, fill: 'forwards' })
|
|
|
+ return
|
|
|
+ }
|
|
|
+ // 更改字号:真正改 font-size(PPT 里框不变、字变大),
|
|
|
+ // 不能用 transform: scale,否则整个元素(含边框/位置)一起缩放。
|
|
|
+ if (name === 'fontsize') {
|
|
|
+ const raw = props.to !== undefined ? parseFloat(String(props.to)) : NaN
|
|
|
+ if (!isNaN(raw)) {
|
|
|
+ // PPTX 用 1/100 磅;WPS 有时直接写磅
|
|
|
+ const pt = raw > 1000 ? raw / 100 : raw
|
|
|
+ const targetPx = pt * 96 / 72
|
|
|
+ const basePx = parseFloat(getComputedStyle(node).fontSize) || 16
|
|
|
+ await run(node, [
|
|
|
+ { fontSize: `${basePx}px` },
|
|
|
+ { fontSize: `${targetPx}px` },
|
|
|
+ ], { ...options, fill: 'forwards' })
|
|
|
+ return
|
|
|
+ }
|
|
|
+ }
|
|
|
+ // 加粗
|
|
|
+ if (name === 'bold') {
|
|
|
+ await run(node, [{ fontWeight: '400' }, { fontWeight: '700' }], { ...options, fill: 'forwards' })
|
|
|
+ return
|
|
|
+ }
|
|
|
+ // 缩放 / 旋转通道优先(更精确)
|
|
|
+ const emphComposed = composeChannelKeyframes('in', anim, ctx, { defaultOpacity: 1 })
|
|
|
+ if (emphComposed && (props.scaleFrom !== undefined || props.rotateFrom !== undefined)) {
|
|
|
+ await run(node, emphComposed, { ...options, fill: 'none' })
|
|
|
+ return
|
|
|
+ }
|
|
|
+ await run(node, emphasisKeyframes(name, anim), options)
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ /* ---------- 4. 几何遮罩类 ---------- */
|
|
|
+ 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 === 'randombar' || 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 === 'split') {
|
|
|
+ await run(node, splitKeyframes(type, direction), { ...options, easing: 'ease-out' })
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if (name === 'stairs') {
|
|
|
+ await run(node, stairsKeyframes(type, direction), { ...options, easing: 'steps(6, end)' })
|
|
|
+ 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 === 'flash') {
|
|
|
+ await run(node, emphasisKeyframes('flash', anim), options)
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ /* ---------- 5. 位移类 ---------- */
|
|
|
+ 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 === 'crawl') {
|
|
|
+ // 字幕式:从底部整幅滚入(无淡入)
|
|
|
+ const from = `translate(0px, ${px(ctx.slideH)})`
|
|
|
+ const to = 'translate(0px, 0px)'
|
|
|
+ await run(node, type === 'in' ? [{ transform: from }, { transform: to }] : [{ transform: to }, { transform: from }],
|
|
|
+ { ...options, easing: 'linear' })
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if (name === 'bounce') {
|
|
|
+ await run(node, bounceKeyframes(type), options)
|
|
|
+ return
|
|
|
+ }
|
|
|
+ // 回旋(Boomerang):从侧后方绕一个弧线落位
|
|
|
+ if (name === 'boomerang') {
|
|
|
+ const frames: Keyframe[] = [
|
|
|
+ { transform: 'translate(-70%, -45%) rotate(-25deg)', opacity: 0 },
|
|
|
+ { transform: 'translate(-10%, 10%) rotate(6deg)', opacity: 1, offset: 0.7 },
|
|
|
+ { transform: 'translate(0px, 0px) rotate(0deg)', opacity: 1 },
|
|
|
+ ]
|
|
|
+ await run(node, type === 'in' ? frames : [...frames].reverse(), { ...options, easing: 'ease-out' })
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if (name === 'lightspeed' || name === 'roll' || name === 'spiral') {
|
|
|
+ await run(node, fancyEntrance(type, name as any), { ...options, easing: 'ease-out' })
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ /* ---------- 6. 文本聚拢 / 分散 ---------- */
|
|
|
+ if (name === 'gatherin' || name === 'scatter') {
|
|
|
+ await run(node, gatherKeyframes(node, root, type), { ...options, easing: 'ease-out' })
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ /* ---------- 7. 通道合成:缩放 / 旋转 / 位移 / 透明度 ---------- */
|
|
|
+ if (name === 'zoom' || name === 'spinin' || name === 'swivel' || name === 'flip' ||
|
|
|
+ name === 'stretch' || name === 'expand' || name === 'collapse' || name === 'fade' || name === 'rise' || name === 'float') {
|
|
|
+ let defaultScaleFrom: number | undefined
|
|
|
+ let defaultOpacity: number | undefined
|
|
|
+
|
|
|
+ if (name === 'zoom' || name === 'expand' || name === 'collapse') defaultScaleFrom = 0
|
|
|
+ if (name === 'fade' || name === 'rise' || name === 'float') defaultOpacity = 0
|
|
|
+
|
|
|
+ // 旋转类(swivel / flip)没有 scale 通道时,用 3D 翻转
|
|
|
+ if ((name === 'swivel' || name === 'flip') && props.rotateFrom === undefined) {
|
|
|
+ const axis = name === 'swivel' ? 'Y' : 'X'
|
|
|
+ const deg = name === 'swivel' ? -90 : 90
|
|
|
+ await run(node, type === 'in'
|
|
|
+ ? [{ opacity: 0, transform: `perspective(800px) rotate${axis}(${deg}deg)` },
|
|
|
+ { opacity: 1, transform: `perspective(800px) rotate${axis}(0deg)` }]
|
|
|
+ : [{ opacity: 1, transform: `perspective(800px) rotate${axis}(0deg)` },
|
|
|
+ { opacity: 0, transform: `perspective(800px) rotate${axis}(${deg}deg)` }], options)
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if ((name === 'stretch') && props.scaleFrom === undefined) {
|
|
|
+ const axis = direction === 'vertical' ? 'Y' : 'X'
|
|
|
+ await run(node, type === 'in'
|
|
|
+ ? [{ transform: `scale${axis}(0)` }, { transform: `scale${axis}(1)` }]
|
|
|
+ : [{ transform: `scale${axis}(1)` }, { transform: `scale${axis}(0)` }], options)
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ const composed = composeChannelKeyframes(type, anim, ctx, { defaultScaleFrom, defaultOpacity })
|
|
|
+ if (composed) {
|
|
|
+ await run(node, composed, { ...options, easing: 'ease-out' })
|
|
|
+ return
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /* ---------- 8. 默认:淡入淡出(带位移时同时位移) ---------- */
|
|
|
+ const fallback = composeChannelKeyframes(type, anim, ctx, { defaultOpacity: 0 })
|
|
|
+ if (fallback) {
|
|
|
+ await run(node, fallback, options)
|
|
|
+ return
|
|
|
+ }
|
|
|
+ await run(node, type === 'in'
|
|
|
+ ? [{ opacity: 0 }, { opacity: 1 }]
|
|
|
+ : [{ opacity: 1 }, { opacity: 0 }], 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
|
|
|
+
|
|
|
+ // 按段落/字词构建:整体时长不变,各单元顺序播放。
|
|
|
+ // 下限取 30ms 而不是 120ms——逐字动画字数多时(如 50 字),
|
|
|
+ // 120ms 的下限会把总时长拉到 6 秒,远超原稿节奏。
|
|
|
+ const perDuration = targets.length > 1 ? Math.max(total / targets.length, 30) : 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, el)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/** 清理文字构建产生的临时 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()
|
|
|
+ })
|
|
|
+}
|