|
@@ -4,15 +4,19 @@
|
|
|
* 背景:
|
|
* 背景:
|
|
|
* PPTist 原生只支持 animate.css 的固定类名(fadeIn / slideIn 等),
|
|
* PPTist 原生只支持 animate.css 的固定类名(fadeIn / slideIn 等),
|
|
|
* 而 PowerPoint / WPS 的动画体系是「预设 + 参数」:
|
|
* 而 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 {
|
|
export interface PptxMotion {
|
|
@@ -45,6 +49,16 @@ interface PlayContext {
|
|
|
|
|
|
|
|
const wait = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms))
|
|
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 动画并等待结束。
|
|
* 执行一段 WAAPI 动画并等待结束。
|
|
|
* 被取消(元素卸载 / 新动画打断)时也安全 resolve,避免流程卡死。
|
|
* 被取消(元素卸载 / 新动画打断)时也安全 resolve,避免流程卡死。
|
|
@@ -63,7 +77,7 @@ function run(node: HTMLElement, keyframes: Keyframe[], options: KeyframeAnimatio
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/** 统一的 fill 策略:退场保留终态,入场/强调结束回到常态 */
|
|
/** 统一的 fill 策略:退场保留终态,入场/强调结束回到常态 */
|
|
|
-function fillOf(type: 'in' | 'out' | 'attention'): FillMode {
|
|
|
|
|
|
|
+function fillOf(type: 'in' | 'out'): FillMode {
|
|
|
if (type === 'out') return 'forwards'
|
|
if (type === 'out') return 'forwards'
|
|
|
return 'none'
|
|
return 'none'
|
|
|
}
|
|
}
|
|
@@ -206,7 +220,7 @@ function checkerboardKeyframes(type: 'in' | 'out'): Keyframe[] {
|
|
|
]
|
|
]
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-/** 圆形 / 菱形 / 十字 / 楔形:用 clip-path 从中心扩张 */
|
|
|
|
|
|
|
+/** 圆形 / 菱形 / 十字 / 楔形 / 盒状:用 clip-path 从中心扩张 */
|
|
|
function shapeKeyframes(shape: string, type: 'in' | 'out'): Keyframe[] {
|
|
function shapeKeyframes(shape: string, type: 'in' | 'out'): Keyframe[] {
|
|
|
const startByShape: Record<string, string> = {
|
|
const startByShape: Record<string, string> = {
|
|
|
circle: 'circle(0% at 50% 50%)',
|
|
circle: 'circle(0% at 50% 50%)',
|
|
@@ -250,6 +264,32 @@ function wheelKeyframes(type: 'in' | 'out'): Keyframe[] {
|
|
|
return type === 'in' ? frames : [...frames].reverse()
|
|
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`
|
|
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[] {
|
|
function flyKeyframes(type: 'in' | 'out', direction: string | null, ctx: PlayContext): Keyframe[] {
|
|
|
const map: Record<string, string> = {
|
|
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)`,
|
|
right: `translate(${px(ctx.slideW)}, 0px)`,
|
|
|
top: `translate(0px, ${px(-ctx.slideH)})`,
|
|
top: `translate(0px, ${px(-ctx.slideH)})`,
|
|
|
bottom: `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 offset = map[direction || 'left'] || map.left
|
|
|
const none = 'translate(0px, 0px)'
|
|
const none = 'translate(0px, 0px)'
|
|
@@ -301,45 +332,6 @@ function peekKeyframes(type: 'in' | 'out', direction: string | null): Keyframe[]
|
|
|
: [{ transform: none }, { transform: offset }]
|
|
: [{ 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[] {
|
|
function bounceKeyframes(type: 'in' | 'out'): Keyframe[] {
|
|
|
const frames: 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 }]
|
|
: [{ 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[] {
|
|
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 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) {
|
|
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})` }]
|
|
return [{ transform: 'scale(1)' }, { transform: `scale(${scale})` }]
|
|
|
}
|
|
}
|
|
|
case 'transparency': {
|
|
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) }]
|
|
return [{ opacity: String(from) }, { opacity: String(to) }]
|
|
|
}
|
|
}
|
|
|
case 'bold':
|
|
case 'bold':
|
|
|
return [{ fontWeight: '400' }, { fontWeight: '700' }]
|
|
return [{ fontWeight: '400' }, { fontWeight: '700' }]
|
|
|
case 'fontsize': {
|
|
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':
|
|
case 'flash':
|
|
|
return [
|
|
return [
|
|
@@ -487,10 +740,116 @@ function emphasisKeyframes(name: string, anim: PptxAnimationRecord): Keyframe[]
|
|
|
{ transform: 'translateX(0)', offset: 1 },
|
|
{ transform: 'translateX(0)', offset: 1 },
|
|
|
]
|
|
]
|
|
|
default:
|
|
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 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 props = anim.properties || {}
|
|
|
const direction = anim.direction || null
|
|
const direction = anim.direction || null
|
|
|
|
|
+
|
|
|
|
|
+ cancelNodeAnimations(node)
|
|
|
|
|
+
|
|
|
const options: KeyframeAnimationOptions = {
|
|
const options: KeyframeAnimationOptions = {
|
|
|
duration: Math.max(duration, 1),
|
|
duration: Math.max(duration, 1),
|
|
|
delay,
|
|
delay,
|
|
@@ -510,18 +881,24 @@ async function playOne(node: HTMLElement, anim: PptxAnimationRecord, ctx: PlayCo
|
|
|
easing: 'ease',
|
|
easing: 'ease',
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // 动作路径(可能出现在任意类型中,必须优先于强调分支处理)
|
|
|
|
|
|
|
+ /* ---------- 1. 动作路径(最高优先级:可能叠加任意类型) ---------- */
|
|
|
if (name === 'motion') {
|
|
if (name === 'motion') {
|
|
|
const frames = anim.path ? pathKeyframes(anim.path, type, ctx) : []
|
|
const frames = anim.path ? pathKeyframes(anim.path, type, ctx) : []
|
|
|
if (frames.length) {
|
|
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
|
|
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
|
|
return
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // 出现 / 消失:瞬时切换可见性(用 1ms 动画,便于取消时自动还原)
|
|
|
|
|
|
|
+ /* ---------- 2. 出现 / 消失:瞬时切换可见性 ---------- */
|
|
|
if (name === 'appear' || name === 'disappear') {
|
|
if (name === 'appear' || name === 'disappear') {
|
|
|
const toOpacity = name === 'appear' ? 1 : 0
|
|
const toOpacity = name === 'appear' ? 1 : 0
|
|
|
await run(node, [{ opacity: String(1 - toOpacity) }, { opacity: String(toOpacity) }], {
|
|
await run(node, [{ opacity: String(1 - toOpacity) }, { opacity: String(toOpacity) }], {
|
|
@@ -532,19 +909,87 @@ async function playOne(node: HTMLElement, anim: PptxAnimationRecord, ctx: PlayCo
|
|
|
return
|
|
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 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
|
|
return
|
|
|
}
|
|
}
|
|
|
await run(node, emphasisKeyframes(name, anim), options)
|
|
await run(node, emphasisKeyframes(name, anim), options)
|
|
|
return
|
|
return
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // 几何擦除类
|
|
|
|
|
|
|
+ /* ---------- 4. 几何遮罩类 ---------- */
|
|
|
if (name === 'wipe') {
|
|
if (name === 'wipe') {
|
|
|
await run(node, wipeKeyframes(type, direction), { ...options, easing: 'linear' })
|
|
await run(node, wipeKeyframes(type, direction), { ...options, easing: 'linear' })
|
|
|
return
|
|
return
|
|
@@ -553,7 +998,7 @@ async function playOne(node: HTMLElement, anim: PptxAnimationRecord, ctx: PlayCo
|
|
|
await run(node, blindsKeyframes(type, direction), { ...options, easing: 'ease-in' })
|
|
await run(node, blindsKeyframes(type, direction), { ...options, easing: 'ease-in' })
|
|
|
return
|
|
return
|
|
|
}
|
|
}
|
|
|
- if (name === 'randbars' || name === 'strips') {
|
|
|
|
|
|
|
+ if (name === 'randbars' || name === 'randombar' || name === 'strips') {
|
|
|
await run(node, blindsKeyframes(type, direction, 7), { ...options, easing: 'ease-in' })
|
|
await run(node, blindsKeyframes(type, direction, 7), { ...options, easing: 'ease-in' })
|
|
|
return
|
|
return
|
|
|
}
|
|
}
|
|
@@ -569,14 +1014,26 @@ async function playOne(node: HTMLElement, anim: PptxAnimationRecord, ctx: PlayCo
|
|
|
await run(node, wheelKeyframes(type), { ...options, easing: 'linear' })
|
|
await run(node, wheelKeyframes(type), { ...options, easing: 'linear' })
|
|
|
return
|
|
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') {
|
|
if (name === 'dissolve') {
|
|
|
await run(node, type === 'in'
|
|
await run(node, type === 'in'
|
|
|
? [{ opacity: 0, filter: 'blur(2px)' }, { opacity: 1, filter: 'blur(0px)' }]
|
|
? [{ opacity: 0, filter: 'blur(2px)' }, { opacity: 1, filter: 'blur(0px)' }]
|
|
|
: [{ opacity: 1, filter: 'blur(0px)' }, { opacity: 0, filter: 'blur(2px)' }], options)
|
|
: [{ opacity: 1, filter: 'blur(0px)' }, { opacity: 0, filter: 'blur(2px)' }], options)
|
|
|
return
|
|
return
|
|
|
}
|
|
}
|
|
|
|
|
+ if (name === 'flash') {
|
|
|
|
|
+ await run(node, emphasisKeyframes('flash', anim), options)
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- // 位移类
|
|
|
|
|
|
|
+ /* ---------- 5. 位移类 ---------- */
|
|
|
if (name === 'fly') {
|
|
if (name === 'fly') {
|
|
|
await run(node, flyKeyframes(type, direction, ctx), { ...options, easing: 'ease-out' })
|
|
await run(node, flyKeyframes(type, direction, ctx), { ...options, easing: 'ease-out' })
|
|
|
return
|
|
return
|
|
@@ -585,65 +1042,83 @@ async function playOne(node: HTMLElement, anim: PptxAnimationRecord, ctx: PlayCo
|
|
|
await run(node, peekKeyframes(type, direction), { ...options, easing: 'ease-out' })
|
|
await run(node, peekKeyframes(type, direction), { ...options, easing: 'ease-out' })
|
|
|
return
|
|
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') {
|
|
if (name === 'bounce') {
|
|
|
await run(node, bounceKeyframes(type), options)
|
|
await run(node, bounceKeyframes(type), options)
|
|
|
return
|
|
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') {
|
|
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
|
|
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'
|
|
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
|
|
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
|
|
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 total = Math.max(anim.duration || 500, 1)
|
|
|
const baseDelay = anim.delay || 0
|
|
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++) {
|
|
for (let i = 0; i < targets.length; i++) {
|
|
|
// 退场时反向播放(最后一段先消失),与 WPS 表现一致
|
|
// 退场时反向播放(最后一段先消失),与 WPS 表现一致
|
|
|
const target = anim.type === 'out' ? targets[targets.length - 1 - i] : targets[i]
|
|
const target = anim.type === 'out' ? targets[targets.length - 1 - i] : targets[i]
|
|
|
const delay = i === 0 ? baseDelay : 0
|
|
const delay = i === 0 ? baseDelay : 0
|
|
|
- await playOne(target, anim, ctx, perDuration, delay)
|
|
|
|
|
|
|
+ await playOne(target, anim, ctx, perDuration, delay, el)
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|