jack 1 week ago
parent
commit
4ec2b2f541
3 changed files with 779 additions and 32 deletions
  1. 14 5
      src/hooks/useImport.ts
  2. 677 0
      src/views/Screen/hooks/pptxAnimation.ts
  3. 88 27
      src/views/Screen/hooks/useExecPlay.ts

+ 14 - 5
src/hooks/useImport.ts

@@ -207,7 +207,8 @@ function mapTransitionToTurningMode(transition: any): string | null {
  * pptxtojson 的 effectType: 'entrance' | 'exit' | 'emphasis' | 'path' | 'appear'
  */
 function mapEffectType(effectType: string | null): 'in' | 'out' | 'attention' {
-  if (effectType === 'entrance' || effectType === 'appear' || effectType === 'path') return 'in'
+  // ★ 动作路径(path)是元素可见状态下的移动,不能当作入场(否则播放前会被隐藏)
+  if (effectType === 'entrance' || effectType === 'appear') return 'in'
   if (effectType === 'exit') return 'out'
   return 'attention'
 }
@@ -1673,11 +1674,10 @@ export default () => {
 
           const type = mapEffectType(anim.effectType)
 
-          // ★ 时长压缩:PowerPoint 的动画时长通常偏长(如 1000ms 淡入),
-          // 且导入的动画多为 auto 自动连播(上百个),若不压缩会显得非常慢。
-          // 把超过 500ms 的时长压缩到 500ms,保持流畅节奏。
+          // ★ 时长:保留原稿时长(PowerPoint/WPS 的 500~2000ms 是设计值),
+          // 只做上限保护,避免个别异常值拖长整页节奏。
           const rawDuration = anim.duration || 500
-          const duration = Math.min(rawDuration, 500)
+          const duration = Math.min(Math.max(rawDuration, 1), 5000)
 
           animations.push({
             id: nanoid(10),
@@ -1690,6 +1690,15 @@ export default () => {
             groupIndex: anim.groupIndex ?? 0,
             animIndex: anim.animIndex ?? 0,
             direction: anim.direction || null,
+            // ★ 以下为 WPS/PowerPoint 精确播放所需字段(由 Screen 动画引擎消费)
+            effectName: anim.effectName || null,
+            motion: anim.motion || null,
+            filter: (anim as any).filter || null,
+            preset: anim.preset || null,
+            path: anim.path || null,
+            build: anim.build || 'all',
+            pRg: anim.pRg || null,
+            properties: anim.properties || {},
             raw: anim,
           })
         }

+ 677 - 0
src/views/Screen/hooks/pptxAnimation.ts

@@ -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()
+  })
+}

+ 88 - 27
src/views/Screen/hooks/useExecPlay.ts

@@ -6,10 +6,11 @@ import { lang } from '@/main'
 import { KEYS } from '@/configs/hotkey'
 import { ANIMATION_CLASS_PREFIX } from '@/configs/animation'
 import message from '@/utils/message'
+import { playPptxAnimation } from './pptxAnimation'
 
 export default () => {
   const slidesStore = useSlidesStore()
-  const { slides, slideIndex, formatedAnimations } = storeToRefs(slidesStore)
+  const { slides, slideIndex, formatedAnimations, viewportSize, viewportRatio } = storeToRefs(slidesStore)
 
   // 当前页的元素动画执行到的位置
   const animationIndex = ref(0)
@@ -31,6 +32,22 @@ export default () => {
     }
   }
 
+  // ★ 记录被动画引擎修改过的元素,用于撤销/切页时清除动画效果
+  const animatedEls = new Set<HTMLElement>()
+  const cancelAnimationEffects = () => {
+    animatedEls.forEach(el => {
+      try {
+        el.getAnimations().forEach(animation => animation.cancel())
+      }
+      catch { /* 忽略 */ }
+      el.style.removeProperty('--animate-duration')
+      Array.from(el.classList).forEach(classname => {
+        if (classname.indexOf(ANIMATION_CLASS_PREFIX) !== -1) el.classList.remove(classname, `${ANIMATION_CLASS_PREFIX}animated`)
+      })
+    })
+    animatedEls.clear()
+  }
+
   // 执行元素动画
   const runAnimation = () => {
     // 正在执行动画时,禁止其他新的动画开始
@@ -77,6 +94,35 @@ export default () => {
         checkAndFinish()
         continue
       }
+      animatedEls.add(elRef)
+
+      // 单个动画的收尾(推进计数),只执行一次
+      let oneFinished = false
+      const finishOne = () => {
+        if (oneFinished) return
+        oneFinished = true
+        endAnimationCount += 1
+        checkAndFinish()
+      }
+
+      const animDelay = Math.max((animation as any).delay || 0, 0)
+
+      // ★ PPTX / WPS 导入的动画:走精确动画引擎
+      //   支持位移(浮入/升起)、几何擦除(擦除/百叶窗/棋盘/轮子)、
+      //   缩放旋转组合、动作路径、以及按段落/字词的文字构建。
+      if ((animation as any).effectName) {
+        const ctx = {
+          slideW: viewportSize.value,
+          slideH: viewportSize.value * viewportRatio.value,
+        }
+        playPptxAnimation(elRef, animation as any, ctx)
+          .then(finishOne)
+          .catch(finishOne)
+        // 兜底:动画异常时也能收尾
+        const fallbackTimer = setTimeout(finishOne, animDelay + (animation.duration || 500) + 1200)
+        fallbackTimers.push(fallbackTimer)
+        continue
+      }
 
       const animationName = `${ANIMATION_CLASS_PREFIX}${animation.effect}`
 
@@ -85,33 +131,39 @@ export default () => {
       for (const classname of elRef.classList) {
         if (classname.indexOf(ANIMATION_CLASS_PREFIX) !== -1) elRef.classList.remove(classname, `${ANIMATION_CLASS_PREFIX}animated`)
       }
-      
-      // 执行动画
-      elRef.style.setProperty('--animate-duration', `${animation.duration}ms`)
-      elRef.classList.add(animationName, `${ANIMATION_CLASS_PREFIX}animated`)
 
-      // 单个动画的收尾(清理类名 + 推进计数),只执行一次
-      let oneFinished = false
-      const finishOne = () => {
-        if (oneFinished) return
-        oneFinished = true
-        elRef.style.removeProperty('--animate-duration')
-        if (animation.type !== 'out') {
-          elRef.classList.remove(animationName, `${ANIMATION_CLASS_PREFIX}animated`)
+      // 执行动画
+      const playByClass = () => {
+        elRef.style.setProperty('--animate-duration', `${animation.duration}ms`)
+        elRef.classList.add(animationName, `${ANIMATION_CLASS_PREFIX}animated`)
+
+        // 单个动画的收尾(清理类名 + 推进计数)
+        const finishLegacy = () => {
+          elRef.style.removeProperty('--animate-duration')
+          if (animation.type !== 'out') {
+            elRef.classList.remove(animationName, `${ANIMATION_CLASS_PREFIX}animated`)
+          }
+          finishOne()
         }
-        endAnimationCount += 1
-        checkAndFinish()
-      }
 
-      // 正常结束
-      const handleAnimationEnd = () => finishOne()
-      elRef.addEventListener('animationend', handleAnimationEnd, { once: true })
-      // 动画被中断(如元素隐藏/卸载、CSS 动画被强制停止)也会触发
-      elRef.addEventListener('animationcancel', handleAnimationEnd, { once: true })
+        // 正常结束
+        elRef.addEventListener('animationend', finishLegacy, { once: true })
+        // 动画被中断(如元素隐藏/卸载、CSS 动画被强制停止)也会触发
+        elRef.addEventListener('animationcancel', finishLegacy, { once: true })
 
-      // ★ 兜底:即使 animationend/animationcancel 都未触发,也在 duration + 缓冲后强制收尾
-      const fallbackTimer = setTimeout(finishOne, animation.duration + 200)
-      fallbackTimers.push(fallbackTimer)
+        // ★ 兜底:即使 animationend/animationcancel 都未触发,也在 duration + 缓冲后强制收尾
+        const fallbackTimer = setTimeout(finishLegacy, animation.duration + 200)
+        fallbackTimers.push(fallbackTimer)
+      }
+
+      // ★ 支持延迟播放(PPTX 的 delay 之前被丢弃,导致节奏与原稿不一致)
+      if (animDelay) {
+        const delayTimer = setTimeout(playByClass, animDelay)
+        fallbackTimers.push(delayTimer)
+      }
+      else {
+        playByClass()
+      }
     }
   }
 
@@ -129,14 +181,16 @@ export default () => {
     animationIndex.value -= 1
     const { animations } = formatedAnimations.value[animationIndex.value]
 
+    cancelAnimationEffects()
+
     for (const animation of animations) {
       const elRef: HTMLElement | null = document.querySelector(`#screen-element-${animation.elId} [class^=base-element-]`)
       if (!elRef) continue
-      
+
       elRef.style.removeProperty('--animate-duration')
-      for (const classname of elRef.classList) {
+      Array.from(elRef.classList).forEach(classname => {
         if (classname.indexOf(ANIMATION_CLASS_PREFIX) !== -1) elRef.classList.remove(classname, `${ANIMATION_CLASS_PREFIX}animated`)
-      }
+      })
     }
 
     // 如果撤销时该位置有且仅有强调动画,则继续执行一次撤销
@@ -157,6 +211,7 @@ export default () => {
   onUnmounted(() => {
     closeAutoPlay()
     clearFallbackTimers()
+    cancelAnimationEffects()
     inAnimation.value = false
   })
 
@@ -179,6 +234,7 @@ export default () => {
       revokeAnimation()
     }
     else if (slideIndex.value > 0) {
+      cancelAnimationEffects()
       slidesStore.updateSlideIndex(slideIndex.value - 1)
       if (slideIndex.value < playedSlidesMinIndex.value) {
         animationIndex.value = 0
@@ -197,6 +253,7 @@ export default () => {
       runAnimation()
     }
     else if (slideIndex.value < slides.value.length - 1) {
+      cancelAnimationEffects()
       slidesStore.updateSlideIndex(slideIndex.value + 1)
       animationIndex.value = 0
       inAnimation.value = false
@@ -273,20 +330,24 @@ export default () => {
 
   // 切换到上一张/上一张幻灯片(无视元素的入场动画)
   const turnPrevSlide = () => {
+    cancelAnimationEffects()
     slidesStore.updateSlideIndex(slideIndex.value - 1)
     animationIndex.value = 0
   }
   const turnNextSlide = () => {
+    cancelAnimationEffects()
     slidesStore.updateSlideIndex(slideIndex.value + 1)
     animationIndex.value = 0
   }
 
   // 切换幻灯片到指定的页面
   const turnSlideToIndex = (index: number) => {
+    cancelAnimationEffects()
     slidesStore.updateSlideIndex(index)
     animationIndex.value = 0
   }
   const turnSlideToId = (id: string) => {
+    cancelAnimationEffects()
     const index = slides.value.findIndex(slide => slide.id === id)
     if (index !== -1) {
       slidesStore.updateSlideIndex(index)