/** * PPTX / WPS 动画播放引擎 * * 背景: * PPTist 原生只支持 animate.css 的固定类名(fadeIn / slideIn 等), * 而 PowerPoint / WPS 的动画体系是「预设 + 参数」: * - 几何擦除类:wipe / blinds / checkerboard / wheel / wedge / box / circle / diamond / plus * - 位移类:floats / rise / fly / credits(由 ppt_x / ppt_y 的位移轨迹决定) * - 组合类:fade + scale = 缩放进入;fade + rotate = 生长旋转 * - 属性类:透明度 / 颜色 / 字号 / 加粗 * - 动作路径:animMotion 的 SVG path * - 文字构建:按段落 / 按字词 / 按字母逐级播放 * * 本引擎用 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 filter?: string | null type: 'in' | 'out' | 'attention' duration: number delay?: number } interface PlayContext { slideW: number slideH: number } /* ========================================================================= * 基础工具 * ========================================================================= */ const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) /** * 执行一段 WAAPI 动画并等待结束。 * 被取消(元素卸载 / 新动画打断)时也安全 resolve,避免流程卡死。 */ function run(node: HTMLElement, keyframes: Keyframe[], options: KeyframeAnimationOptions): Promise { 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' | 'attention'): 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 = { 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 = { 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 = { 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 px(value: number) { return `${value}px` } /** 淡入淡出 + 位移(浮入 / 升起 / 字幕 / 飞入的近似) */ function fadeWithMotion(type: 'in' | 'out', motion: PptxMotion | null | undefined, ctx: PlayContext): Keyframe[] { const dx = motion ? motion.dx * ctx.slideW : 0 const dy = motion ? motion.dy * ctx.slideH : 0 const opacityFrom = type === 'in' ? 0 : 1 const opacityTo = type === 'in' ? 1 : 0 const transformNone = 'translate(0px, 0px)' const transformOffset = `translate(${px(dx)}, ${px(dy)})` return type === 'in' ? [{ opacity: opacityFrom, transform: transformOffset }, { opacity: opacityTo, transform: transformNone }] : [{ opacity: opacityFrom, transform: transformNone }, { opacity: opacityTo, transform: transformOffset }] } /** 飞入:从画布边缘滑入(无淡入) */ function flyKeyframes(type: 'in' | 'out', direction: string | null, ctx: PlayContext): Keyframe[] { const map: Record = { left: `translate(${px(-ctx.slideW)}, 0px)`, right: `translate(${px(ctx.slideW)}, 0px)`, top: `translate(0px, ${px(-ctx.slideH)})`, bottom: `translate(0px, ${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 = { 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 transformEntrance(type: 'in' | 'out', opts: { fromScale?: number toScale?: number fromRotate?: number axis?: 'x' | 'y' | null stretch?: 'x' | 'y' | null }): Keyframe[] { const fromScale = opts.fromScale === undefined ? 0 : opts.fromScale const toScale = opts.toScale === undefined ? 1 : opts.toScale const fromRotate = opts.fromRotate || 0 let from = `scale(${fromScale})` let to = `scale(${toScale})` if (opts.axis === 'y') { from = `perspective(800px) rotateY(${type === 'in' ? -90 : 90}deg)` to = 'perspective(800px) rotateY(0deg)' } else if (opts.axis === 'x') { from = `perspective(800px) rotateX(${type === 'in' ? 90 : -90}deg)` to = 'perspective(800px) rotateX(0deg)' } else if (opts.stretch === 'x') { from = 'scaleX(0)' to = 'scaleX(1)' } else if (opts.stretch === 'y') { from = 'scaleY(0)' to = 'scaleY(1)' } else if (fromRotate) { from = `scale(${fromScale}) rotate(${fromRotate}deg)` to = `scale(${toScale}) rotate(0deg)` } return type === 'in' ? [{ transform: from }, { transform: to }] : [{ transform: to }, { transform: from }] } /** 弹跳进入 */ function bounceKeyframes(type: 'in' | 'out'): Keyframe[] { const frames: Keyframe[] = [ { 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 }] } /* ========================================================================= * 动作路径 * ========================================================================= */ /** 把 PowerPoint 的 M/L/C 路径(M 0 0 L .2 0 E)采样成位移关键帧 */ 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 }[] = [] let i = 0 let current = { x: 0, y: 0 } 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 }) } i += 3 } 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 }) } i += 7 } else if (cmd === 'E' || cmd === 'e' || cmd === 'Z' || cmd === 'z') { break } else { i += 1 } } 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 } /* ========================================================================= * 强调类动画 * ========================================================================= */ 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 ? props.from : 1 const to = props.to !== undefined ? props.to : 0.5 return [{ opacity: String(from) }, { opacity: String(to) }] } case 'bold': return [{ fontWeight: '400' }, { fontWeight: '700' }] case 'fontsize': { const to = props.to !== undefined ? props.to : 48 return [{ fontSize: '1em' }, { fontSize: `${to / 100}em` }] } 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: return [{ opacity: 1 }, { opacity: 1 }] } } /* ========================================================================= * 主入口 * ========================================================================= */ /** * 播放单个目标的动画 */ async function playOne(node: HTMLElement, anim: PptxAnimationRecord, ctx: PlayContext, duration: number, delay: number): Promise { const name = (anim.effectName || 'fade').toLowerCase() const type = anim.type const props = anim.properties || {} const direction = anim.direction || null const options: KeyframeAnimationOptions = { duration: Math.max(duration, 1), delay, fill: fillOf(type), easing: 'ease', } // 动作路径(可能出现在任意类型中,必须优先于强调分支处理) if (name === 'motion') { const frames = anim.path ? pathKeyframes(anim.path, type, ctx) : [] if (frames.length) { await run(node, frames, { ...options, easing: 'linear', fill: type === 'out' ? 'forwards' : 'none' }) return } await run(node, fadeWithMotion(type, anim.motion, ctx), options) return } // 出现 / 消失:瞬时切换可见性(用 1ms 动画,便于取消时自动还原) 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 } // 强调类 if (type === 'attention') { if (name === 'colorchange') { const fromColor = getComputedStyle(node).color const toColor = props.clrTo || props.to || fromColor await run(node, [{ color: fromColor }, { color: toColor }], { ...options, fill: 'forwards' }) return } await run(node, emphasisKeyframes(name, anim), options) return } // 几何擦除类 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 === '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 === '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 === '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 === 'bounce') { await run(node, bounceKeyframes(type), options) return } if (name === 'lightspeed' || name === 'roll' || name === 'spiral') { await run(node, fancyEntrance(type, name as any), options) 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 }) await run(node, type === 'in' ? [{ opacity: 0, ...rotateFrames[0] }, { opacity: 1, ...rotateFrames[1] }] : [{ opacity: 1, ...rotateFrames[0] }, { opacity: 0, ...rotateFrames[1] }], options) 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) return } // 默认:淡入淡出(带位移时同时位移,即「浮入 / 升起 / 字幕」) await run(node, fadeWithMotion(type, anim.motion, ctx), options) } /** * 播放入场/退场/强调动画(支持文字分级构建) * @returns Promise,全部播完(或被取消)后 resolve */ export async function playPptxAnimation(el: HTMLElement, anim: PptxAnimationRecord, ctx: PlayContext): Promise { const targets = resolveTargets(el, anim) const total = Math.max(anim.duration || 500, 1) const baseDelay = anim.delay || 0 // 按段落/字词构建:整体时长不变,各单元顺序播放 const perDuration = targets.length > 1 ? Math.max(total / targets.length, 120) : total 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) } } /** 清理文字构建产生的临时 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() }) }