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