jack 1 week ago
parent
commit
1bb2c82d8b

+ 26 - 6
src/hooks/useGlobalHotkey.ts

@@ -1,6 +1,6 @@
-import { onMounted, onUnmounted } from 'vue'
+import { onActivated, onDeactivated, onMounted, onUnmounted } from 'vue'
 import { storeToRefs } from 'pinia'
-import { useMainStore, useSlidesStore, useKeyboardStore } from '@/store'
+import { useMainStore, useSlidesStore, useKeyboardStore, useScreenStore } from '@/store'
 import { ElementOrderCommands } from '@/types/edit'
 import { KEYS } from '@/configs/hotkey'
 
@@ -19,6 +19,7 @@ import useScaleCanvas from './useScaleCanvas'
 export default () => {
   const mainStore = useMainStore()
   const keyboardStore = useKeyboardStore()
+  const screenStore = useScreenStore()
   const {
     activeElementIdList,
     disableHotkeys,
@@ -29,6 +30,7 @@ export default () => {
     showSearchPanel,
   } = storeToRefs(mainStore)
   const { currentSlide } = storeToRefs(useSlidesStore())
+  const { screening } = storeToRefs(screenStore)
   const { ctrlKeyState, shiftKeyState, spaceKeyState } = storeToRefs(keyboardStore)
 
   const {
@@ -133,6 +135,16 @@ export default () => {
   }
 
   const keydownListener = (e: KeyboardEvent) => {
+    // ★ 放映期间编辑器快捷键必须彻底失效。
+    // App.vue 中 Editor 位于 KeepAlive 内,进入放映时只是被 deactivate(不会 unmount),
+    // 其 document 上的 keydown 监听因此仍然存活。此时按 →/← 会被处理两次:
+    //   ① 放映层 useExecPlay → execNext()(播动画/翻页,正确那条)
+    //   ② 残留的本监听 → move() → 无选中元素时 updateSlideIndex() 直接 +1 页
+    // 结果就是「一次按键跳两页、动画还没播就被顶过去」。
+    // 点右上角放映按钮时,点击使画布失焦(editorAreaFocus=false),本监听恰好提前 return,
+    // 所以两个入口表现不一致 —— 差异就在这里,而不是渲染层的尺寸问题。
+    if (screening.value) return
+
     const { ctrlKey, shiftKey, altKey, metaKey } = e
     const ctrlOrMetaKeyActive = ctrlKey || metaKey
     
@@ -319,14 +331,22 @@ export default () => {
     if (spaceKeyState.value) keyboardStore.setSpaceKeyState(false)
   }
 
-  onMounted(() => {
+  const attachHotkey = () => {
     document.addEventListener('keydown', keydownListener)
     document.addEventListener('keyup', keyupListener)
     window.addEventListener('blur', keyupListener)
-  })
-  onUnmounted(() => {
+  }
+  const detachHotkey = () => {
     document.removeEventListener('keydown', keydownListener)
     document.removeEventListener('keyup', keyupListener)
     window.removeEventListener('blur', keyupListener)
-  })
+  }
+
+  onMounted(attachHotkey)
+  onUnmounted(detachHotkey)
+  // ★ KeepAlive 场景:Editor 被放映/切换视图切走时只是 deactivate,onUnmounted 不会触发。
+  // 这里在 deactivate 时摘掉监听、activate 时挂回,避免多个编辑器实例的监听叠加
+  // (切换过 editor → editor3 后会同时存在多份,一次按键能跳好几页)。
+  onActivated(attachHotkey)
+  onDeactivated(detachHotkey)
 }

+ 53 - 5
src/hooks/useImport.ts

@@ -106,8 +106,16 @@ function mapEffectName(effectName: string | null, type: 'in' | 'out' | 'attentio
         return 'pulse' // 缩放强调 -> 脉搏
       case 'colorChange':
         return 'flash' // 颜色变化强调 -> 闪烁
-      case 'property':
-        return 'pulse' // 属性强调 -> 脉搏
+      case 'fontSize':
+        return 'pulse' // 字号变化 -> 脉搏
+      case 'fontName':
+      case 'fontStyle':
+        return 'pulse' // 字体/字形变化 -> 脉搏
+      case 'fillColor':
+      case 'lineColor':
+        return 'flash' // 填充/线条变色 -> 闪烁
+      case 'dynamicNumber':
+        return 'pulse' // 数字滚动 -> 脉搏
       default:
         return 'pulse' // 其他强调 -> 脉搏
     }
@@ -121,22 +129,48 @@ function mapEffectName(effectName: string | null, type: 'in' | 'out' | 'attentio
     case 'property':
       return isOut ? 'fadeOut' : 'fadeIn'
 
-    // 擦除 / 百叶窗 -> 滑入滑出
+    // 擦除 / 百叶窗 / 条纹 / 棋盘 / 形状 -> 滑入滑出
     case 'wipe':
     case 'blinds':
+    case 'randbars':
+    case 'randombar':
+    case 'strips':
+    case 'checkerboard':
+    case 'box':
+    case 'circle':
+    case 'diamond':
+    case 'plus':
+    case 'wedge':
+    case 'barn':
+    case 'iris':
+    case 'wheel':
+    case 'dissolve':
+    case 'split':
+    case 'stairs':
+    case 'flash':
       return isOut ? `slideOut${suffix || 'Right'}` : `slideIn${suffix || 'Left'}`
 
-    // 飞入 / 飞出
+    // 飞入 / 飞出 / 字幕
     case 'fly':
     case 'motion':
+    case 'crawl':
+    case 'peek':
       return isOut ? `slideOut${suffix || 'Right'}` : `slideIn${suffix || 'Left'}`
 
     // 旋转
     case 'spin':
+    case 'spinIn':
+    case 'swivel':
+    case 'flip':
       return isOut ? 'rotateOut' : 'rotateIn'
 
     // 缩放
     case 'growShrink':
+    case 'zoom':
+    case 'expand':
+    case 'collapse':
+    case 'stretch':
+    case 'bounce':
       return isOut ? 'zoomOut' : 'zoomIn'
 
     // 未命中,退化为淡入/淡出
@@ -1334,6 +1368,9 @@ export default () => {
                 top: el.top,
                 rotate: 0,
                 autoplay: false,
+                // ★ PPTX 视频的封面帧:视频未加载/无法解码时用它兜底,
+                //   否则整页只剩一片空白(WPS 里显示的是封面图)。
+                poster: (el as any).poster || undefined,
               } as any
               if (spid) element.spid = spid
 
@@ -1400,7 +1437,18 @@ export default () => {
                   : `;width:${w}px;height:${h}px;`
               }
 
-              const style = baseStyle + extraStyle
+              // ★ 形状内文字的默认行距:必须显式补 1.2。
+              //   文本型元素(type=text)在 BaseTextElement 的 .element-content 上有内联
+              //   lineHeight: 1.2;但形状型元素(type=shape)的 BaseShapeElement 只有
+              //   CSS 的 line-height: 1.5(App 内新建形状的默认值)。
+              //   ProseMirror 的 <p> 现在输出 line-height: inherit(不再硬编码 1.2,
+              //   否则会覆盖 li 上的 spcPts 固定行距),于是 shape 里的段落继承到 1.5,
+              //   比 PPT 单倍行距(≈1.2×字号)多 0.15em —— 单行文字基线整体下移
+              //   (20.16pt 字号实测 4.00px,即 3u,表现为「标题往下跑」)。
+              //   段落自身声明了行距(li/p 的 line-height)时优先级更高,这里不影响。
+              let style = baseStyle + extraStyle
+              if (!/line-height/i.test(style)) style += 'line-height: 1.2;'
+
               const element: PPTShapeElement = {
                 type: 'shape',
                 id: nanoid(10),

+ 29 - 3
src/utils/prosemirror/schema/nodes.ts

@@ -238,7 +238,14 @@ const listItem: NodeSpec = {
       style += `line-height: ${lineHeight};`
     }
     else {
-      style += `line-height: 1.2;`
+      // ★ 不再硬编码 1.2:没有自己的行距时继承父级。
+      //   关键场景是 <li style="line-height:32pt"> 里的隐式 <p>——
+      //   PM 从 li 的纯文本生成 p,p 的 attrs 拿不到 li 的行距,
+      //   硬编码 1.2 会把 li 上的固定行距覆盖掉,导致 32pt 行距的段落
+      //   按 1.2×字号 排版(首行整体上移,文字戳出形状框)。
+      //   继承后 li 内 p 得到 42.67px;独立段落继承 .element-content 的 1.2,
+      //   与改动前一致。
+      style += `line-height: inherit;`
     }
     if (paddingTop) style += `padding-top: ${paddingTop};`
     if (paddingRight) style += `padding-right: ${paddingRight};`
@@ -318,6 +325,18 @@ const paragraph: NodeSpec = {
         const el = dom as HTMLElement
         const style = el.style
 
+        // ★ li 内的 p 必须继承 li 的固定行距。
+        //   pptxtojson 把 PPT 的 a:pPr/a:lnSpc 输出成 <li style="line-height:32pt">,
+        //   ProseMirror 会把 li 的内容包进一个 <p>;而 p 的 toDOM 在 lineHeight
+        //   为空时会硬编码 line-height:1.2,把 li 上的固定行距整个覆盖掉。
+        //   结果:32pt 行距的元素实际按 1.2×18pt≈21.6pt 排版,首行下移 10pt,
+        //   文字戳出形状框(第 34 页「实验材料」那行)。
+        //   li 上没有行距时保持原逻辑(空字符串 → toDOM 回退 1.2)。
+        let lineHeight = style.lineHeight || ''
+        if (!lineHeight && el.parentElement && el.parentElement.tagName === 'LI') {
+          lineHeight = (el.parentElement as HTMLElement).style.lineHeight || ''
+        }
+
         return {
           textAlign: style.textAlign || '',
           textAlignLast: style.textAlignLast || '',
@@ -330,7 +349,7 @@ const paragraph: NodeSpec = {
           marginBottom: style.marginBottom || '',
           marginLeft: style.marginLeft || '',
           marginRight: style.marginRight || '',
-          lineHeight: style.lineHeight || '',
+          lineHeight,
           paddingTop: style.paddingTop || '',
           paddingRight: style.paddingRight || '',
           paddingBottom: style.paddingBottom || '',
@@ -397,7 +416,14 @@ const paragraph: NodeSpec = {
       style += `line-height: ${lineHeight};`
     }
     else {
-      style += `line-height: 1.2;`
+      // ★ 不再硬编码 1.2:没有自己的行距时继承父级。
+      //   关键场景是 <li style="line-height:32pt"> 里的隐式 <p>——
+      //   PM 从 li 的纯文本生成 p,p 的 attrs 拿不到 li 的行距,
+      //   硬编码 1.2 会把 li 上的固定行距覆盖掉,导致 32pt 行距的段落
+      //   按 1.2×字号 排版(首行整体上移,文字戳出形状框)。
+      //   继承后 li 内 p 得到 42.67px;独立段落继承 .element-content 的 1.2,
+      //   与改动前一致。
+      style += `line-height: inherit;`
     }
     if (paddingTop) style += `padding-top: ${paddingTop};`
     if (paddingRight) style += `padding-right: ${paddingRight};`

+ 4 - 1
src/views/Screen/BaseView.vue

@@ -55,7 +55,10 @@
       </div>
     </div>
 
-    <BottomThumbnails v-if="bottomThumbnailsVisible" />
+    <!-- ★ 翻页方法由主实例透传:BottomThumbnails 若自己 useExecPlay(),
+         会拿到一份独立的 animationIndex,点缩略图跳页后主实例索引不重置,
+         导致后续动画错位/不播。 -->
+    <BottomThumbnails v-if="bottomThumbnailsVisible" :turnSlideToIndex="turnSlideToIndex" />
   </div>
 </template>
 

+ 4 - 2
src/views/Screen/BottomThumbnails.vue

@@ -22,15 +22,17 @@ import { nextTick, useTemplateRef, watch } from 'vue'
 import { storeToRefs } from 'pinia'
 import { useSlidesStore } from '@/store'
 import useLoadSlides from '@/hooks/useLoadSlides'
-import useExecPlay from './hooks/useExecPlay'
 
 import ThumbnailSlide from '@/views/components/ThumbnailSlide/index.vue'
 
+defineProps<{
+  turnSlideToIndex: (index: number) => void
+}>()
+
 const { slides, slideIndex, viewportRatio } = storeToRefs(useSlidesStore())
 
 const thumbnailsRef = useTemplateRef<HTMLElement>('thumbnailsRef')
 
-const { turnSlideToIndex } = useExecPlay()
 const { slidesLoadLimit } = useLoadSlides()
 
 const handleMousewheelThumbnails = (e: WheelEvent) => {

+ 640 - 163
src/views/Screen/hooks/pptxAnimation.ts

@@ -4,15 +4,19 @@
  * 背景:
  *   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
- *     - 文字构建:按段落 / 按字词 / 按字母逐级播放
+ *     - 几何擦除类: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 全命令)
+ *     - 文字构建:按段落 / 按字词 / 按字母逐级播放,以及聚拢 / 分散
  *
- * 本引擎用 Web Animations API + 遮罩(mask / clip-path)精确还原这些效果,
- * 不依赖 animate.css,避免「所有动画都变成淡入」。
+ * 关键设计:
+ *   PowerPoint 的一个预设动画往往由多个并行通道组成
+ *   (例如「旋转进入」= ppt_w/ppt_h 0→原尺寸 + style.rotation 360→0 + fade),
+ *   所以这里不做「一个名字一个硬编码」的映射,而是把通道合成一条 transform 关键帧序列。
+ *
+ * 本引擎使用 Web Animations API + 遮罩(mask / clip-path),不依赖 animate.css。
  */
 
 export interface PptxMotion {
@@ -45,6 +49,16 @@ interface PlayContext {
 
 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,避免流程卡死。
@@ -63,7 +77,7 @@ function run(node: HTMLElement, keyframes: Keyframe[], options: KeyframeAnimatio
 }
 
 /** 统一的 fill 策略:退场保留终态,入场/强调结束回到常态 */
-function fillOf(type: 'in' | 'out' | 'attention'): FillMode {
+function fillOf(type: 'in' | 'out'): FillMode {
   if (type === 'out') return 'forwards'
   return 'none'
 }
@@ -206,7 +220,7 @@ function checkerboardKeyframes(type: 'in' | 'out'): Keyframe[] {
     ]
 }
 
-/** 圆形 / 菱形 / 十字 / 楔形:用 clip-path 从中心扩张 */
+/** 圆形 / 菱形 / 十字 / 楔形 / 盒状:用 clip-path 从中心扩张 */
 function shapeKeyframes(shape: string, type: 'in' | 'out'): Keyframe[] {
   const startByShape: Record<string, string> = {
     circle: 'circle(0% at 50% 50%)',
@@ -250,6 +264,32 @@ function wheelKeyframes(type: 'in' | 'out'): Keyframe[] {
   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 }]
+}
+
 /* =========================================================================
  * 位移 / 缩放 / 旋转类动画
  * ========================================================================= */
@@ -258,19 +298,6 @@ 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> = {
@@ -278,6 +305,10 @@ function flyKeyframes(type: 'in' | 'out', direction: string | null, ctx: PlayCon
     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)'
@@ -301,45 +332,6 @@ function peekKeyframes(type: 'in' | 'out', direction: string | null): Keyframe[]
     : [{ 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[] = [
@@ -368,54 +360,312 @@ function fancyEntrance(type: 'in' | 'out', kind: 'lightSpeed' | 'roll' | 'spiral
     : [{ 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 的 M/L/C 路径(M 0 0 L .2 0 E)采样成位移关键帧 */
+/**
+ * 解析 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 tokens = path.replace(/([MLCQZmlcqzEe])/g, ' $1 ').trim().split(/\s+/)
-  const points: { x: number, y: number }[] = []
+  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 current = { x: 0, y: 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 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 })
+    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
       }
-      i += 3
+      continue
     }
-    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 })
+
+    // 数字:按当前命令消费参数
+    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
       }
-      i += 7
-    }
-    else if (cmd === 'E' || cmd === 'e' || cmd === 'Z' || cmd === 'z') {
-      break
-    }
-    else {
-      i += 1
+      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
     }
   }
 
-  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
+  return points
 }
 
 /* =========================================================================
@@ -441,15 +691,18 @@ function emphasisKeyframes(name: string, anim: PptxAnimationRecord): Keyframe[]
       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
+      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 ? props.to : 48
-      return [{ fontSize: '1em' }, { fontSize: `${to / 100}em` }]
+      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 [
@@ -487,10 +740,116 @@ function emphasisKeyframes(name: string, anim: PptxAnimationRecord): Keyframe[]
         { transform: 'translateX(0)', offset: 1 },
       ]
     default:
-      return [{ opacity: 1 }, { opacity: 1 }]
+      // 未识别的强调效果:绝不能返回「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)
+  })
+}
+
 /* =========================================================================
  * 主入口
  * ========================================================================= */
@@ -498,11 +857,23 @@ function emphasisKeyframes(name: string, anim: PptxAnimationRecord): Keyframe[]
 /**
  * 播放单个目标的动画
  */
-async function playOne(node: HTMLElement, anim: PptxAnimationRecord, ctx: PlayContext, duration: number, delay: number): Promise<void> {
+async function playOne(
+  node: HTMLElement,
+  anim: PptxAnimationRecord,
+  ctx: PlayContext,
+  duration: number,
+  delay: number,
+  root: HTMLElement,
+): Promise<void> {
   const name = (anim.effectName || 'fade').toLowerCase()
-  const type = anim.type
+  // 强调类已在上面的分支单独处理,这里收窄为 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,
@@ -510,18 +881,24 @@ async function playOne(node: HTMLElement, anim: PptxAnimationRecord, ctx: PlayCo
     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: type === 'out' ? 'forwards' : 'none' })
+      // 路径动画结束要停在终点
+      await run(node, frames, { ...options, easing: 'linear', fill: 'forwards' })
       return
     }
-    await run(node, fadeWithMotion(type, anim.motion, ctx), options)
+    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
   }
 
-  // 出现 / 消失:瞬时切换可见性(用 1ms 动画,便于取消时自动还原)
+  /* ---------- 2. 出现 / 消失:瞬时切换可见性 ---------- */
   if (name === 'appear' || name === 'disappear') {
     const toOpacity = name === 'appear' ? 1 : 0
     await run(node, [{ opacity: String(1 - toOpacity) }, { opacity: String(toOpacity) }], {
@@ -532,19 +909,87 @@ async function playOne(node: HTMLElement, anim: PptxAnimationRecord, ctx: PlayCo
     return
   }
 
-  // 强调类
-  if (type === 'attention') {
-    if (name === 'colorchange') {
+  /* ---------- 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
-      const toColor = props.clrTo || props.to || fromColor
-      await run(node, [{ color: fromColor }, { color: toColor }], { ...options, fill: 'forwards' })
+      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
@@ -553,7 +998,7 @@ async function playOne(node: HTMLElement, anim: PptxAnimationRecord, ctx: PlayCo
     await run(node, blindsKeyframes(type, direction), { ...options, easing: 'ease-in' })
     return
   }
-  if (name === 'randbars' || name === 'strips') {
+  if (name === 'randbars' || name === 'randombar' || name === 'strips') {
     await run(node, blindsKeyframes(type, direction, 7), { ...options, easing: 'ease-in' })
     return
   }
@@ -569,14 +1014,26 @@ async function playOne(node: HTMLElement, anim: PptxAnimationRecord, ctx: PlayCo
     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
@@ -585,65 +1042,83 @@ async function playOne(node: HTMLElement, anim: PptxAnimationRecord, ctx: PlayCo
     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)
+    await run(node, fancyEntrance(type, name as any), { ...options, easing: 'ease-out' })
     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 })
+  /* ---------- 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, ...rotateFrames[0] }, { opacity: 1, ...rotateFrames[1] }]
-        : [{ opacity: 1, ...rotateFrames[0] }, { opacity: 0, ...rotateFrames[1] }], options)
+        ? [{ 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
     }
-    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)
+
+  /* ---------- 8. 默认:淡入淡出(带位移时同时位移) ---------- */
+  const fallback = composeChannelKeyframes(type, anim, ctx, { defaultOpacity: 0 })
+  if (fallback) {
+    await run(node, fallback, options)
     return
   }
-
-  // 默认:淡入淡出(带位移时同时位移,即「浮入 / 升起 / 字幕」)
-  await run(node, fadeWithMotion(type, anim.motion, ctx), options)
+  await run(node, type === 'in'
+    ? [{ opacity: 0 }, { opacity: 1 }]
+    : [{ opacity: 1 }, { opacity: 0 }], options)
 }
 
 /**
@@ -655,14 +1130,16 @@ export async function playPptxAnimation(el: HTMLElement, anim: PptxAnimationReco
   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
+  // 按段落/字词构建:整体时长不变,各单元顺序播放。
+  // 下限取 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)
+    await playOne(target, anim, ctx, perDuration, delay, el)
   }
 }
 

+ 32 - 12
src/views/Screen/hooks/useExecPlay.ts

@@ -8,7 +8,7 @@ import { ANIMATION_CLASS_PREFIX } from '@/configs/animation'
 import message from '@/utils/message'
 import { playPptxAnimation } from './pptxAnimation'
 
-export default () => {
+export default (options: { keyboard?: boolean, autoPlay?: boolean } = {}) => {
   const slidesStore = useSlidesStore()
   const { slides, slideIndex, formatedAnimations, viewportSize, viewportRatio } = storeToRefs(slidesStore)
 
@@ -119,7 +119,10 @@ export default () => {
           .then(finishOne)
           .catch(finishOne)
         // 兜底:动画异常时也能收尾
-        const fallbackTimer = setTimeout(finishOne, animDelay + (animation.duration || 500) + 1200)
+        // 兜底:动画异常时也能收尾。
+        // 逐字/逐段动画是「总时长均分到每个单元」,实际耗时会略长于 duration,
+        // 原先 +1200ms 会在字数多时提前判定完成,导致下一条动画抢跑。
+        const fallbackTimer = setTimeout(finishOne, animDelay + (animation.duration || 500) * 2 + 2500)
         fallbackTimers.push(fallbackTimer)
         continue
       }
@@ -168,6 +171,9 @@ export default () => {
   }
 
   onMounted(() => {
+    // 仅供读取方法的实例(如缩略图栏)不参与自动播放,否则会重复播一遍首条动画
+    if (options.autoPlay === false) return
+
     const firstAnimations = formatedAnimations.value[0]
     if (firstAnimations && firstAnimations.animations.length) {
       const autoExecFirstAnimations = firstAnimations.animations.every(item => item.trigger === 'auto' || item.trigger === 'meantime')
@@ -313,20 +319,34 @@ export default () => {
 
   // 快捷键翻页
   const keydownListener = (e: KeyboardEvent) => {
+    // 焦点在输入框/可编辑区域时放行(倒计时设置、备注等),不要把方向键变成翻页
+    const target = e.target as HTMLElement | null
+    if (target && (target.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/i.test(target.tagName))) return
+
     const key = e.key.toUpperCase()
 
-    if (key === KEYS.UP || key === KEYS.LEFT || key === KEYS.PAGEUP) execPrev()
-    else if (
-      key === KEYS.DOWN || 
-      key === KEYS.RIGHT ||
-      // key === KEYS.SPACE || 
-      // key === KEYS.ENTER ||
-      key === KEYS.PAGEDOWN
-    ) execNext()
+    const isPrev = key === KEYS.UP || key === KEYS.LEFT || key === KEYS.PAGEUP
+    const isNext = key === KEYS.DOWN || key === KEYS.RIGHT || key === KEYS.PAGEDOWN
+    // 只处理翻页键,其余按键放行
+    if (!isPrev && !isNext) return
+    // 阻止方向键/翻页键触发页面滚动
+    e.preventDefault()
+
+    if (isPrev) execPrev()
+    else execNext()
   }
 
-  onMounted(() => document.addEventListener('keydown', keydownListener))
-  onUnmounted(() => document.removeEventListener('keydown', keydownListener))
+  onMounted(() => {
+    // ★ 键盘监听只允许由放映主视图(BaseView / PresenterView)注册。
+    // useExecPlay 内部状态(animationIndex / inAnimation)是每个实例独立的,
+    // 缩略图栏等组件若也注册键盘,就会与主视图各翻一页 → 一次按键跳两页,
+    // 且两边索引互相错位导致动画不播。
+    if (options.keyboard === false) return
+    document.addEventListener('keydown', keydownListener)
+  })
+  onUnmounted(() => {
+    document.removeEventListener('keydown', keydownListener)
+  })
 
   // 切换到上一张/上一张幻灯片(无视元素的入场动画)
   const turnPrevSlide = () => {

+ 30 - 7
src/views/Screen/hooks/useSlideSize.ts

@@ -12,36 +12,59 @@ export default (wrapRef?: ShallowRef<HTMLElement | null>) => {
   // 计算和更新幻灯片内容的尺寸(按比例自适应屏幕)
   const setSlideContentSize = () => {
     const slideWrapRef = wrapRef?.value || document.body
-    const winWidth = slideWrapRef.clientWidth
-    const winHeight = slideWrapRef.clientHeight
+    // ★ 容错:放映层刚挂载时(尤其是与「进入全屏」同一帧发生),
+    //   body/容器的 clientWidth 可能还是 0 或者全屏前的旧尺寸,
+    //   算出来的 scale 会变成 0 或明显偏小,导致整页元素被裁掉、看起来「元素缺失」。
+    //   依次回退:容器 → window → 保持上一次的有效值。
+    let winWidth = slideWrapRef.clientWidth || window.innerWidth || 0
+    let winHeight = slideWrapRef.clientHeight || window.innerHeight || 0
+    const ratio = viewportRatio.value
+    if (!winWidth || !winHeight || !ratio) return
+
     let width, height
 
-    if (winHeight / winWidth === viewportRatio.value) {
+    if (winHeight / winWidth === ratio) {
       width = winWidth
       height = winHeight
     }
-    else if (winHeight / winWidth > viewportRatio.value) {
+    else if (winHeight / winWidth > ratio) {
       width = winWidth
-      height = winWidth * viewportRatio.value
+      height = winWidth * ratio
     }
     else {
-      width = winHeight / viewportRatio.value
+      width = winHeight / ratio
       height = winHeight
     }
     slideWidth.value = width
     slideHeight.value = height
   }
 
+  // ★ 进入/退出全屏、设备方向变化时都要重算:
+  //   全屏切换在部分环境下不会触发 window.resize(尤其是内嵌 iframe 中按 F5 放映时),
+  //   只监听 resize 会让 scale 停留在全屏前的值,放映画面与预览不一致。
+  const onFullscreenChange = () => {
+    setSlideContentSize()
+    // 全屏动画结束后再补算一次,避免读到过渡中的中间尺寸
+    window.setTimeout(setSlideContentSize, 120)
+    window.setTimeout(setSlideContentSize, 400)
+  }
+
   onMounted(() => {
     setSlideContentSize()
     window.addEventListener('resize', setSlideContentSize)
+    window.addEventListener('orientationchange', setSlideContentSize)
+    document.addEventListener('fullscreenchange', onFullscreenChange)
+    document.addEventListener('webkitfullscreenchange', onFullscreenChange as EventListener)
   })
   onUnmounted(() => {
     window.removeEventListener('resize', setSlideContentSize)
+    window.removeEventListener('orientationchange', setSlideContentSize)
+    document.removeEventListener('fullscreenchange', onFullscreenChange)
+    document.removeEventListener('webkitfullscreenchange', onFullscreenChange as EventListener)
   })
 
   return {
     slideWidth,
     slideHeight,
   }
-}
+}

+ 1 - 1
src/views/components/element/ImageElement/ImageOutline/ImageEllipseOutline.vue

@@ -10,7 +10,7 @@
       vector-effect="non-scaling-stroke" 
       stroke-linecap="butt" 
       stroke-miterlimit="8"
-      fill="#FFFFFF"
+      fill="transparent"
       :cx="width / 2" 
       :cy="height / 2"
       :rx="width / 2" 

+ 1 - 1
src/views/components/element/ImageElement/ImageOutline/ImagePolygonOutline.vue

@@ -10,7 +10,7 @@
       vector-effect="non-scaling-stroke" 
       stroke-linecap="butt" 
       stroke-miterlimit="8"
-      fill="#FFFFFF"
+      fill="transparent"
       :d="createPath(width, height)"
       :stroke="outlineColor"
       :stroke-width="outlineWidth" 

+ 1 - 1
src/views/components/element/ImageElement/ImageOutline/ImageRectOutline.vue

@@ -10,7 +10,7 @@
       vector-effect="non-scaling-stroke" 
       stroke-linecap="butt" 
       stroke-miterlimit="8"
-      fill="#FFFFFF"
+      fill="transparent"
       :rx="radius" 
       :ry="radius"
       :width="width"