返回 oh-my-ppt
element-animation.ts
根目录 / src / main / animation / element-animation.ts
1 import * as cheerio from 'cheerio'
2 import type { AnyNode } from 'domhandler'
3 import {
4 isDataAnimFrom,
5 isDataAnimSequence,
6 isDataAnimType,
7 isElementAnimationEditableFrom,
8 isElementAnimationEditableType,
9 normalizeDataAnimTrigger,
10 type DataAnimType,
11 type ElementAnimationConfig,
12 type ElementAnimationPatch
13 } from '../../shared/element-animation'
14
15 const DEFAULT_DURATION_MS = 600
16 const DIRECTIONAL_TYPES = new Set<DataAnimType>(['fly-in', 'wipe', 'exit-fly', 'exit-wipe'])
17 const ELEMENT_ANIMATION_ATTRIBUTES = [
18 'data-anim',
19 'data-anim-trigger',
20 'data-anim-sequence',
21 'data-anim-click-group',
22 'data-anim-from',
23 'data-anim-delay',
24 'data-anim-stagger',
25 'data-anim-duration',
26 'data-anim-path',
27 'data-anim-easing',
28 'data-anim-repeat',
29 'data-anim-direction',
30 'data-ppt-anim-initialized'
31 ] as const
32
33 function resolveUniqueTarget(
34 $: cheerio.CheerioAPI,
35 selector: string
36 ): cheerio.Cheerio<AnyNode> {
37 let target: cheerio.Cheerio<AnyNode>
38 try {
39 target = $(selector)
40 } catch {
41 throw new Error('元素 selector 无效')
42 }
43 if (target.length === 0) throw new Error('无法定位动画元素:页面内容可能已经变化')
44 if (target.length > 1) throw new Error('动画元素 selector 命中多个目标,请重新选择元素')
45 return target.first()
46 }
47
48 function readConfigFromTarget(
49 target: cheerio.Cheerio<AnyNode>
50 ): ElementAnimationConfig | null {
51 const rawType = (target.attr('data-anim') || '').trim().toLowerCase()
52 if (!rawType) return null
53 if (!isDataAnimType(rawType)) throw new Error(`当前元素包含不支持的动画类型:${rawType}`)
54
55 const rawTrigger = (target.attr('data-anim-trigger') || 'load').trim().toLowerCase()
56 // Tolerate legacy aliases (on-click / after-previous / with-previous) so old pages
57 // stay editable instead of failing to load in the picker.
58 const normalizedTrigger = normalizeDataAnimTrigger(rawTrigger)
59 if (!normalizedTrigger) {
60 throw new Error(`当前元素包含不支持的动画触发方式:${rawTrigger}`)
61 }
62
63 const rawDuration = (target.attr('data-anim-duration') || '').trim()
64 const durationMs = rawDuration ? Number(rawDuration) : DEFAULT_DURATION_MS
65 const rawFrom = (target.attr('data-anim-from') || '').trim().toLowerCase()
66 const rawSequence = (target.attr('data-anim-sequence') || '').trim().toLowerCase()
67 const rawStagger = (target.attr('data-anim-stagger') || '').trim()
68
69 return {
70 type: rawType,
71 trigger: normalizedTrigger,
72 durationMs: Number.isFinite(durationMs) ? durationMs : DEFAULT_DURATION_MS,
73 from: isDataAnimFrom(rawFrom) ? rawFrom : undefined,
74 delay: (target.attr('data-anim-delay') || '').trim() || undefined,
75 staggerMs: rawStagger && Number.isFinite(Number(rawStagger)) ? Number(rawStagger) : undefined,
76 sequence: isDataAnimSequence(rawSequence) ? rawSequence : undefined,
77 clickGroup: (target.attr('data-anim-click-group') || '').trim() || undefined,
78 path: (target.attr('data-anim-path') || '').trim() || undefined
79 }
80 }
81
82 function normalizePatch(patch: ElementAnimationPatch): ElementAnimationPatch {
83 const normalized: ElementAnimationPatch = {}
84 if (Object.prototype.hasOwnProperty.call(patch, 'type')) {
85 if (patch.type !== null && !isElementAnimationEditableType(patch.type)) {
86 throw new Error('元素动画类型无效')
87 }
88 normalized.type = patch.type
89 }
90 if (patch.trigger !== undefined) {
91 if (patch.trigger !== 'load' && patch.trigger !== 'click') {
92 throw new Error('元素动画触发方式无效')
93 }
94 normalized.trigger = patch.trigger
95 }
96 if (patch.durationMs !== undefined) {
97 const durationMs = Number(patch.durationMs)
98 if (!Number.isFinite(durationMs) || durationMs < 100 || durationMs > 5000) {
99 throw new Error('元素动画时长必须在 100-5000ms 之间')
100 }
101 normalized.durationMs = Math.round(durationMs)
102 }
103 if (Object.prototype.hasOwnProperty.call(patch, 'from')) {
104 if (patch.from !== null && !isElementAnimationEditableFrom(patch.from)) {
105 throw new Error('元素动画方向无效')
106 }
107 normalized.from = patch.from
108 }
109 return normalized
110 }
111
112 export function parseElementAnimationConfig(
113 html: string,
114 selector: string
115 ): ElementAnimationConfig | null {
116 const $ = cheerio.load(html, { scriptingEnabled: false })
117 return readConfigFromTarget(resolveUniqueTarget($, selector))
118 }
119
120 export function patchElementAnimationConfig(
121 html: string,
122 selector: string,
123 patch: ElementAnimationPatch
124 ): { html: string; config: ElementAnimationConfig | null; changed: boolean } {
125 const normalizedPatch = normalizePatch(patch)
126 const $ = cheerio.load(html, { scriptingEnabled: false })
127 const target = resolveUniqueTarget($, selector)
128 const previousAttributes = JSON.stringify(target.attr())
129
130 if (normalizedPatch.type === null) {
131 for (const attribute of ELEMENT_ANIMATION_ATTRIBUTES) target.removeAttr(attribute)
132 } else {
133 const currentType = (target.attr('data-anim') || '').trim().toLowerCase()
134 if (normalizedPatch.type !== undefined) {
135 target.attr('data-anim', normalizedPatch.type)
136 target.removeAttr('data-anim-path')
137 if (!DIRECTIONAL_TYPES.has(normalizedPatch.type)) {
138 target.removeAttr('data-anim-from')
139 }
140 } else if (!currentType) {
141 throw new Error('请先选择元素动画类型')
142 }
143
144 if (normalizedPatch.trigger === 'click') {
145 target.attr('data-anim-trigger', 'click')
146 target.removeAttr('data-anim-sequence')
147 } else if (normalizedPatch.trigger === 'load') {
148 target.removeAttr('data-anim-trigger')
149 target.removeAttr('data-anim-click-group')
150 }
151
152 if (normalizedPatch.durationMs !== undefined) {
153 target.attr('data-anim-duration', String(normalizedPatch.durationMs))
154 }
155
156 if (normalizedPatch.from === null) {
157 target.removeAttr('data-anim-from')
158 } else if (normalizedPatch.from !== undefined) {
159 const nextType = (target.attr('data-anim') || '').trim().toLowerCase()
160 if (!isDataAnimType(nextType) || !DIRECTIONAL_TYPES.has(nextType)) {
161 throw new Error('当前动画类型不支持方向设置')
162 }
163 target.attr('data-anim-from', normalizedPatch.from)
164 }
165
166 const nextTrigger = (target.attr('data-anim-trigger') || 'load').trim().toLowerCase()
167 if (nextTrigger !== 'click') target.removeAttr('data-anim-click-group')
168 if (nextTrigger === 'click') target.removeAttr('data-anim-sequence')
169 }
170
171 const changed = JSON.stringify(target.attr()) !== previousAttributes
172 const nextHtml = changed ? $.html() : html
173 return {
174 html: nextHtml,
175 config: readConfigFromTarget(resolveUniqueTarget($, selector)),
176 changed
177 }
178 }
179
179 lines TYPESCRIPT