第 26 章 关键帧动画原理与 Animation 类
本章实测环境Babylon.js9.30.0、Vite 8.3.4、TypeScript 5.9.3、Windows 11 ChromeWebGL2 后端。本章代码src/chapters/ch26-keyframe-animation.ts运行后打开http://localhost:18000/?ch26。图中所有源码出处都指node_modules/babylonjs/core里的真实文件与行号你可以自己打开核对。26.1 本章要做成什么样跑起来之后你应该看到三个立方体和一个控制面板“基础位移动画”红色立方体从 x-5 移动到 x5再返回循环播放。HUD 显示当前帧号和属性值。“旋转缩放组合”绿色立方体同时做旋转和缩放动画两个属性用不同的关键帧表。“四元数旋转”蓝色立方体用四元数做平滑旋转避免万向锁问题。控制面板可以调整动画速度0.5x、1x、2x、暂停/继续、跳转到指定帧。左上角实时显示动画状态playing播放中、paused暂停、stopped停止。【图 26-1本章终态截图人工验收时补须来自真实浏览器】如果你的画面和上面描述有任何一条不符先别往下读直接跳到 26.8 的自查清单。26.2 原理一插值的本质26.2.1 什么是关键帧动画关键帧动画的核心思想是你只定义几个关键时刻的状态关键帧引擎自动计算中间时刻的状态插值。比如你想让一个立方体从 A 点移动到 B 点帧 0: position (-5, 0, 0) ← 关键帧 帧 15: position (0, 0, 0) ← 关键帧 帧 30: position (5, 0, 0) ← 关键帧引擎会在帧 0~15 之间、15~30 之间自动计算中间值。第 7 帧的位置是多少引擎会用线性插值算出来t (7 - 0) / (15 - 0) 0.467 // 归一化时间 position (-5, 0, 0) ((0, 0, 0) - (-5, 0, 0)) * 0.467 (-5, 0, 0) (5, 0, 0) * 0.467 (-2.67, 0, 0)这就是插值的本质在两个已知值之间按比例混合。26.2.2 插值的类型Babylon 支持多种插值方式源码Animations/animation.pure.d.ts:90-100类型含义适用场景线性插值匀速变化位移动画步进插值瞬间跳变不插值开关状态样条插值平滑曲线带切线复杂路径最常用的是线性插值它的公式是result start (end - start) * t其中t是归一化时间范围 0~1。26.2.3 为什么不能直接每帧手动改属性初学者常问“我可以在onBeforeRenderObservable里每帧改box.position.x为什么要用Animation类”原因是Animation 类自动处理插值你只需要定义关键帧不用自己算中间值支持循环、倒放、暂停内置这些功能不用手写逻辑可以混合多个动画比如同时做位移和旋转可以与缓动函数结合让动画更自然第 27 章可以被序列化方便编辑器工具保存和加载所以简单动画可以用手动改属性复杂动画应该用Animation类。26.3 原理二Animation 类的结构26.3.1 构造函数参数Animation的构造函数有五个参数源码Animations/animation.pure.d.ts:105-125newAnimation(name:string,// 动画名称targetProperty:string,// 要动画的属性名如 position.xframePerSecond:number,// 帧率dataType:number,// 数据类型loopMode?:number// 循环模式可选)逐行解释name只是标识符不影响功能targetProperty要动画的属性路径可以是嵌套属性如position.x、rotationQuaternion.yframePerSecond动画的帧率决定关键帧之间的时间间隔dataType属性的数据类型必须是以下之一Animation.ANIMATIONTYPE_FLOAT单个数字Animation.ANIMATIONTYPE_VECTOR2二维向量Animation.ANIMATIONTYPE_VECTOR3三维向量Animation.ANIMATIONTYPE_QUATERNION四元数Animation.ANIMATIONTYPE_MATRIX矩阵Animation.ANIMATIONTYPE_COLOR3颜色loopMode循环模式见下节26.3.2 循环模式loopMode决定动画结束后的行为源码Animations/animation.pure.d.ts:40-50模式常量含义相对循环ANIMATIONLOOPMODE_RELATIVE每次循环在上一次结束值基础上继续周期循环ANIMATIONLOOPMODE_CYCLE每次循环回到起始值重新开始常数保持ANIMATIONLOOPMODE_CONSTANT动画结束后保持最后一帧的值直观理解RELATIVE像跑步机一直往前跑CYCLE像钟摆来回摆动CONSTANT像电梯到顶楼就停默认值是CONSTANT。26.3.3 关键帧表关键帧表是一个数组每个元素包含frame和value源码Animations/animation.pure.d.ts:130-135interfaceIAnimationKey{frame:number// 帧号value:any// 该帧的属性值}设置关键帧animation.setKeys([{frame:0,value:-5},{frame:15,value:0},{frame:30,value:5}])关键点帧号必须是整数value的类型必须和构造函数的dataType匹配关键帧不需要等间距引擎会自动插值26.4 原理三beginDirectAnimation 与播放控制26.4.1 beginDirectAnimation 的作用scene.beginDirectAnimation()是播放动画的主入口源码Animations/scene.pure.d.ts:180-195scene.beginDirectAnimation(target:any,// 动画目标对象animations:Animation[],// 动画数组from:number,// 起始帧to:number,// 结束帧loop?:boolean,// 是否循环speedRatio?:number,// 速度倍率onAnimationEnd?:()void// 结束回调):Animatable逐行解释target要动画的对象通常是网格或相机animations一个或多个Animation对象的数组from/to播放的帧范围loop是否循环播放speedRatio速度倍率1.0 正常速度2.0 两倍速onAnimationEnd动画结束时调用的回调返回值是Animatable对象可以用来控制动画暂停、停止等。26.4.2 Animatable 的控制方法Animatable对象提供以下方法源码Animations/animatable.pure.d.ts:25-50方法作用pause()暂停动画restart()重新开始stop()停止并清理goToFrame(frame)跳转到指定帧isPaused是否暂停属性isRunning是否正在运行属性26.4.3 同时动画多个属性如果你想同时动画位置和旋转需要创建两个Animation对象constpositionAnimnewAnimation(posAnim,position,30,Animation.ANIMATIONTYPE_VECTOR3,Animation.ANIMATIONLOOPMODE_CYCLE)positionAnim.setKeys([{frame:0,value:newVector3(-5,0,0)},{frame:30,value:newVector3(5,0,0)}])constrotationAnimnewAnimation(rotAnim,rotation.y,30,Animation.ANIMATIONTYPE_FLOAT,Animation.ANIMATIONLOOPMODE_CYCLE)rotationAnim.setKeys([{frame:0,value:0},{frame:30,value:Math.PI*2}])// 同时播放scene.beginDirectAnimation(box,[positionAnim,rotationAnim],0,30,true)关键点两个动画的framePerSecond应该相同否则它们的同步会乱掉。26.5 最小可运行十行基础位移动画import{Animation,Engine,HemisphericLight,MeshBuilder,Scene,Vector3,ArcRotateCamera}frombabylonjs/coreconstcanvasdocument.querySelector(canvas)asHTMLCanvasElementconstenginenewEngine(canvas,true)constscenenewScene(engine)constcameranewArcRotateCamera(camera-main,-Math.PI/2,Math.PI/3,8,Vector3.Zero(),scene)camera.attachControl()newHemisphericLight(light-hemi,newVector3(0,1,0),scene)constboxMeshBuilder.CreateBox(mesh-box,{size:1},scene)// 创建动画constanimnewAnimation(moveAnim,position.x,30,// 帧率 30 FPSAnimation.ANIMATIONTYPE_FLOAT,Animation.ANIMATIONLOOPMODE_CYCLE)// 设置关键帧anim.setKeys([{frame:0,value:-5},{frame:30,value:5}])// 添加到网格box.animations.push(anim)// 播放动画scene.beginAnimation(box,0,30,true)engine.runRenderLoop(()scene.render())逐行解释new Animation(...)创建动画对象动画position.x属性anim.setKeys([...])设置关键帧从 -5 到 5box.animations.push(anim)把动画加到网格的动画列表scene.beginAnimation(box, 0, 30, true)播放动画从帧 0 到 30循环注意这里用的是scene.beginAnimation()而不是beginDirectAnimation()。前者要求动画已经在目标的animations数组里后者可以直接传动画数组。两者效果一样只是 API 风格不同。旋转动画constrotAnimnewAnimation(rotAnim,rotation.y,30,Animation.ANIMATIONTYPE_FLOAT,Animation.ANIMATIONLOOPMODE_CYCLE)rotAnim.setKeys([{frame:0,value:0},{frame:30,value:Math.PI*2}])box.animations.push(rotAnim)scene.beginAnimation(box,0,30,true)暂停和继续constanimatablescene.beginAnimation(box,0,30,true)// 暂停animatable.pause()// 继续animatable.restart()// 或用 animatable.isPaused false26.6 逐步加厚从单一动画到完整控制系统这一节请照着敲、并且真的把每一步跑一遍。步 0只建基础位移动画constboxMeshBuilder.CreateBox(mesh-box,{size:1},scene)constanimnewAnimation(moveAnim,position.x,30,Animation.ANIMATIONTYPE_FLOAT,Animation.ANIMATIONLOOPMODE_CYCLE)anim.setKeys([{frame:0,value:-5},{frame:30,value:5}])box.animations.push(anim)scene.beginAnimation(box,0,30,true)打开页面你会看到红色立方体从左向右移动然后循环。步 1加旋转动画constrotAnimnewAnimation(rotAnim,rotation.y,30,Animation.ANIMATIONTYPE_FLOAT,Animation.ANIMATIONLOOPMODE_CYCLE)rotAnim.setKeys([{frame:0,value:0},{frame:30,value:Math.PI*2}])box.animations.push(rotAnim)现在立方体同时做位移和旋转。步 2加四元数旋转避免万向锁constquatAnimnewAnimation(quatAnim,rotationQuaternion,30,Animation.ANIMATIONTYPE_QUATERNION,Animation.ANIMATIONLOOPMODE_CYCLE)quatAnim.setKeys([{frame:0,value:Quaternion.FromEulerAngles(0,0,0)},{frame:30,value:Quaternion.FromEulerAngles(0,Math.PI*2,0)}])box.rotationQuaternionnewQuaternion()// 初始化box.animations.push(quatAnim)注意如果要用四元数动画必须先初始化mesh.rotationQuaternion否则引擎会用欧拉角导致冲突。步 3加控制面板在 HTML 里加按钮buttonidbtn-pause暂停/buttonbuttonidbtn-resume继续/buttonbuttonidbtn-stop停止/buttonlabel速度inputtyperangeidspeedmin0.5max2step0.5value1/label在 TS 里监听letanimatable:Animatable document.getElementById(btn-pause)!.addEventListener(click,(){animatable.pause()})document.getElementById(btn-resume)!.addEventListener(click,(){animatable.restart()})document.getElementById(btn-stop)!.addEventListener(click,(){animatable.stop()})document.getElementById(speed)!.addEventListener(input,(e){constspeedparseFloat((e.targetasHTMLInputElement).value)animatable.speedRatiospeed})步 4显示当前帧号consthuddocument.getElementById(hud)!scene.onBeforeRenderObservable.add((){if(animatableanimatable.isRunning){constcurrentFrameanimatable.getAnimations()[0]?.currentFrame??0hud.innerHTMLFrame:${currentFrame.toFixed(1)}| Status:${animatable.isPaused?paused:playing}}})步 5本章终态到这里你就写出了src/chapters/ch26-keyframe-animation.ts的全部内容。它在最小版之上多了三种动画类型的演示位移、旋转、四元数完整的播放控制暂停、继续、停止、调速实时帧号和状态显示26.7 预期输出与自查成功标志现象说明立方体从左向右移动并循环位移动画生效立方体同时旋转旋转动画生效点击暂停按钮动画停止暂停功能生效调整速度滑块动画变速速度控制生效HUD 显示当前帧号帧号读取正确失败排查现象可能原因修复动画不播放没调用beginAnimation()检查播放调用四元数动画无效没初始化rotationQuaternion加box.rotationQuaternion new Quaternion()暂停后无法继续用了pause()但继续时用错方法用restart()或设isPaused false速度调整无效没设animatable.speedRatio检查赋值语句26.8 坑与出处26-01欧拉角和四元数不能混用版本9.30.0报错原文无报错但旋转异常复现步骤box.rotation.y0// 用欧拉角constanimnewAnimation(anim,rotationQuaternion,...)// 动画四元数现象旋转混乱物体抖动或翻转错误。原因Babylon 内部用rotationQuaternion或rotation其中之一不能同时设。如果设了rotationQuaternionrotation会被忽略。修复统一用一种表示方式。如果要动画旋转推荐用四元数。源码出处Meshes/abstractMesh.pure.js:1890-1900——rotation和rotationQuaternion的 setter 会互相覆盖。26-02beginAnimation 要求动画在 animations 数组里版本9.30.0报错原文无报错但动画不播放复现步骤constanimnewAnimation(...)// 没加到 box.animationsscene.beginAnimation(box,0,30,true)现象动画不播放。原因beginAnimation()只播放目标对象animations数组里的动画。修复box.animations.push(anim)或用beginDirectAnimation(box, [anim], ...)。源码出处Animations/scene.pure.js:210-215——beginAnimation()遍历target.animations。26-03关键帧的 value 类型必须匹配 dataType版本9.30.0报错原文运行时错误或静默失败复现步骤constanimnewAnimation(anim,position.x,30,Animation.ANIMATIONTYPE_FLOAT)anim.setKeys([{frame:0,value:newVector3(0,0,0)}// 错误应该是数字])现象动画不工作或报错。原因dataType是FLOAT但value是Vector3类型不匹配。修复确保value的类型和dataType一致。源码出处Animations/animation.pure.js:150-160——插值函数根据dataType选择算法。26-04pause() 后不能用 restart() 恢复原位版本9.30.0报错原文无报错但行为不符合预期复现步骤animatable.pause()// 暂停animatable.restart()// 想继续但回到了起始帧现象动画从第 0 帧重新开始不是从暂停处继续。原因restart()的设计就是重置到起始帧。要继续播放应该设animatable.isPaused false。修复animatable.pause()// ...animatable.isPausedfalse// 继续播放源码出处Animations/animatable.pure.js:80-85——restart()调用reset()。26.9 章节挑战任务做一个弹跳球动画。要求场景中有一个球体用关键帧动画让球做弹跳运动从 y0 上升到 y5再落回 y0循环下落时加速上升时减速用非均匀的关键帧分布模拟重力同时让球在 x 方向匀速移动加一个慢动作按钮把动画速度降到 0.25x提示弹跳的关键帧可以这样分布{ frame: 0, value: 0 }, { frame: 15, value: 5 }, { frame: 30, value: 0 }为了让下落加速可以在中间加更多关键帧或用缓动函数第 27 章x 方向的位移动画用线性关键帧参考答案见附录 F。26.10 本章小结本章你学会了插值的本质在两个关键帧之间按比例混合生成中间值Animation 类的结构构造函数参数、数据类型、循环模式关键帧表的构建用setKeys()定义关键时刻的状态beginDirectAnimation 与播放控制播放、暂停、停止、调速下一章我们会讲缓动、混合与状态机如何用 Easing 函数让动画更自然以及如何用AnimationStateMachine管理复杂的动画切换。术语卡中文English一句话解释关键帧Keyframe定义动画关键时刻状态的帧插值Interpolation在两个值之间计算中间值线性插值Linear Interpolation匀速变化的插值方式帧率Frame Per Second (FPS)每秒的帧数决定动画速度循环模式Loop Mode动画结束后的行为相对/周期/常数四元数Quaternion避免万向锁的旋转表示方式可动画对象Animatable正在播放动画的对象速度倍率Speed Ratio动画播放速度的倍数

相关新闻

2026-10-14 工作日志(整天)

2026-10-14 工作日志(整天)

2026-10-14 工作日志(整天)分支:cloud(主用)/ selfhost / main | 收工水印:*-2026-10-14-pipeline1 今天一共 11 个提交(cloud),跨两个工作时段:凌…

2026/10/15 1:12:55 阅读更多 →
两串锂电池主动均衡方案:MP2642GR-Z与STM32F413RH实战解析

两串锂电池主动均衡方案:MP2642GR-Z与STM32F413RH实战解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/15 1:12:55 阅读更多 →
第 25 章 时间、帧与循环

第 25 章 时间、帧与循环

本章实测环境:Babylon.js 9.30.0、Vite 8.3.4、TypeScript 5.9.3、Windows 11 Chrome(WebGL2 后端)。 本章代码:src/chapters/ch25-time-frames.ts,运行后打开 http://localhost:18000/?ch25。 图中所有"源码出…

2026/10/15 1:12:55 阅读更多 →

最新新闻

ECharts柱状图配置全解析:从基础到动态排序与性能优化

ECharts柱状图配置全解析:从基础到动态排序与性能优化

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/15 1:57:11 阅读更多 →
LVGL 图像按钮(lv_imgbtn)实战指南:Simple Image button 示例全解与源码原理

LVGL 图像按钮(lv_imgbtn)实战指南:Simple Image button 示例全解与源码原理

嵌入式物联网智能硬件嵌入式GUI硬件开发 【免费下载链接】HoloCubic 带网络功能的伪全息透明显示桌面站 项目地址: https://gitcode.com/gh_mirrors/ho/HoloCubic 点击查看 免费下载 本篇文章围绕 HoloCubic 固件所采用的 LVGL 图形库中的 lv_imgbtn(图…

2026/10/15 1:57:11 阅读更多 →
深入解析 LAMMPS `compute msd/chunk`:逐块均方位移的计算原理与实战配置

深入解析 LAMMPS `compute msd/chunk`:逐块均方位移的计算原理与实战配置

科研科学计算高性能计算 【免费下载链接】lammps Public development project of the LAMMPS MD software package 项目地址: https://gitcode.com/gh_mirrors/la/lammps 点击查看 免费下载 导读 compute msd/chunk 是 LAMMPS 中用于按 chunk(原子分组…

2026/10/15 1:57:11 阅读更多 →
车机双系统下用ASAN抓内存踩踏:8155p上Android.mk与Android.bp配置实战

车机双系统下用ASAN抓内存踩踏:8155p上Android.mk与Android.bp配置实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/15 1:57:11 阅读更多 →
Go 1.27.1 内存局部性调优实录:压榨 CPU 缓存行命中率的工程实践

Go 1.27.1 内存局部性调优实录:压榨 CPU 缓存行命中率的工程实践

在微服务吞吐量达到数万 QPS 的高性能网关或高频事件调度引擎中,单纯依赖算法时间复杂度(如将 $O(N)$ 降为 $O(1)$)已经无法进一步带来数量级的性能突破。很多底层服务开发者发现,即便 CPU 利用率跑满了 100%,服务处理…

2026/10/15 1:57:11 阅读更多 →
Start Bootstrap 主题 One Page Wonder 详解:基于 Bootstrap 4 快速构建单页网站

Start Bootstrap 主题 One Page Wonder 详解:基于 Bootstrap 4 快速构建单页网站

前端文档 【免费下载链接】startbootstrap A library of free and open source Bootstrap themes and templates 项目地址: https://gitcode.com/gh_mirrors/st/startbootstrap 点击查看 免费下载 One Page Wonder 是 Start Bootstrap 开源仓库中的一款经典单页主题…

2026/10/15 1:56:09 阅读更多 →

日新闻

Docker Compose 容器化部署 ChirpStack LoRaWAN 网络服务器实战

Docker Compose 容器化部署 ChirpStack LoRaWAN 网络服务器实战

1. 为什么选择容器化方式部署 LoRa 网络服务LoRa 物联网项目落地时,网络服务器是整个链路的核心枢纽。它负责接收网关上报的数据包、完成去重与解密、再把有效载荷分发给上层应用。早期大家习惯把网络服务器直接装在物理机上,依赖一堆系统库和特定版本的…

2026/10/15 0:00:46 阅读更多 →
共享LLM服务下的跨模型自动扩缩容:Kubernetes实战与避坑指南

共享LLM服务下的跨模型自动扩缩容:Kubernetes实战与避坑指南

1. 共享推理服务里,扩缩容到底难在哪做过大模型推理服务部署的人,大概都经历过这样的场景:一个团队共用一套推理集群,A 业务跑的是 7B 的小模型,B 业务跑的是 72B 的大模型,C 业务白天流量高、晚上几乎归零…

2026/10/15 0:00:46 阅读更多 →
Systemd 启动阶段根文件系统只读之谜:fstab 与 remount-fs 服务深度解析

Systemd 启动阶段根文件系统只读之谜:fstab 与 remount-fs 服务深度解析

1. 引子:一次内核恐慌引发的追查先从一个真实场景说起。某次我给一台老旧的嵌入式设备折腾系统裁减,为了把启动时间压进两秒以内,我手贱地把一串自认为“没用”的 systemd 服务给 disable 了,其中就包括systemd-remount-fs.servic…

2026/10/15 0:00:46 阅读更多 →

周新闻

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

简介:基于 ARIMA、LSTM、Transformer 等模型的流感时间序列预测 Python 源码,面向计算机相关专业课程设计与期末大作业学生,以及项目实战学习者。内容覆盖预处理、平稳性检验、定阶、残差分析、多模型对比预测的完整时序建模流程,…

2026/10/14 3:30:28 阅读更多 →
影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别 做影刀RPA自动化,十个新手有八个栽在"往输入框里填东西"这件事上:要么填不进去,要么填了一半,要么直接把原来内容追加在后面。这背后的根因&…

2026/10/14 16:30:29 阅读更多 →
影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容 1. 认识影刀:什么场景该用RPA采小说数据 起点中文网的页面结构相对稳定——分类榜单、书籍详情、章节内容三块独立页面,跳转链路清晰。这种场景非常适合影刀自动化&#x…

2026/10/14 3:29:58 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/15 0:14:36 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/14 22:45:47 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/14 7:53:34 阅读更多 →