3 次代碼提交 2edb7788ed ... 1bb2c82d8b

作者 SHA1 備註 提交日期
  jack 1bb2c82d8b update 1 周之前
  jack d088268967 Merge branch 'master' of https://git.cocorobo.cn/jack/PPT 1 周之前
  jack 4ec2b2f541 update 1 周之前

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

+ 67 - 10
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'
 
     // 未命中,退化为淡入/淡出
@@ -207,7 +241,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'
 }
@@ -1333,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
 
@@ -1399,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),
@@ -1748,11 +1797,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),
@@ -1765,6 +1813,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,
           })
         }

+ 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) => {

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

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

+ 119 - 38
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 () => {
+export default (options: { keyboard?: boolean, autoPlay?: boolean } = {}) => {
   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,38 @@ 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)
+        // 兜底:动画异常时也能收尾
+        // 兜底:动画异常时也能收尾。
+        // 逐字/逐段动画是「总时长均分到每个单元」,实际耗时会略长于 duration,
+        // 原先 +1200ms 会在字数多时提前判定完成,导致下一条动画抢跑。
+        const fallbackTimer = setTimeout(finishOne, animDelay + (animation.duration || 500) * 2 + 2500)
+        fallbackTimers.push(fallbackTimer)
+        continue
+      }
 
       const animationName = `${ANIMATION_CLASS_PREFIX}${animation.effect}`
 
@@ -85,37 +134,46 @@ 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(finishLegacy, animation.duration + 200)
+        fallbackTimers.push(fallbackTimer)
+      }
 
-      // ★ 兜底:即使 animationend/animationcancel 都未触发,也在 duration + 缓冲后强制收尾
-      const fallbackTimer = setTimeout(finishOne, animation.duration + 200)
-      fallbackTimers.push(fallbackTimer)
+      // ★ 支持延迟播放(PPTX 的 delay 之前被丢弃,导致节奏与原稿不一致)
+      if (animDelay) {
+        const delayTimer = setTimeout(playByClass, animDelay)
+        fallbackTimers.push(delayTimer)
+      }
+      else {
+        playByClass()
+      }
     }
   }
 
   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')
@@ -129,14 +187,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 +217,7 @@ export default () => {
   onUnmounted(() => {
     closeAutoPlay()
     clearFallbackTimers()
+    cancelAnimationEffects()
     inAnimation.value = false
   })
 
@@ -179,6 +240,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 +259,7 @@ export default () => {
       runAnimation()
     }
     else if (slideIndex.value < slides.value.length - 1) {
+      cancelAnimationEffects()
       slidesStore.updateSlideIndex(slideIndex.value + 1)
       animationIndex.value = 0
       inAnimation.value = false
@@ -256,37 +319,55 @@ 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 = () => {
+    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)

+ 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"