弹出窗口式脚注:写法说明
写脚注只需要做两件事:正文里写编号引用,文末写编号定义。剩下的(编号、底部列表、悬停弹窗)全部自动生成。1这篇说明本身就是用同一套脚注写的,你看到的每个编号都能点开。
想先看效果,去 脚注样例 页把鼠标移到任意编号上。
最小例子
这句话需要一个注解。[^1]
文末写它的内容:
[^1]: 这就是注解内容。
渲染结果是:正文里出现一个上标编号 1,鼠标悬停或键盘聚焦时弹出注释内容;触屏上则点一下编号弹出。页面底部会同时生成标准的脚注列表,点编号仍可跳过去。2底部列表是权威副本,弹窗只是它的一个"预览窗口"——所以关掉 CSS 或脚本时,底部条目照常在,功能不会坏。
要点
| 事项 | 说明 |
|---|---|
| 编号写什么 | 随便写。[^1] [^2]、[^注一]、[^privacy] 都行,显示出来的序号由文章顺序自动决定,不必自己数 |
| 定义写在哪 | 惯例是全文末尾;定义与引用靠标签配对,不靠位置 |
| 一处引用多次 | 同一个标签在正文里写两次,底部条目会自动长出两个返回箭头 |
| 注解里的 Markdown | 加粗、行内代码、链接、列表、多段,都能正常渲染 |
| 多段注解 | 第二段起用空行 + 四个空格缩进续写(见下) |
| 弹窗何时出现 | 鼠标悬停、或键盘 Tab 聚焦到编号时;触屏上点一下编号弹出(再点一下才跳转);打印时不出现 |
| 弹窗出现在哪 | 优先在编号上方,上方放不下就翻到下方;贴到屏幕边缘会自动平移回视口内;注解太长则限高并在内部滚动 |
| 从正文跳到注解 | 点正文里的编号,文末对应那条注解会滚到视口正中,不会紧贴在吸顶导航栏下面 |
| 数量 | 不限,编号自动连续 |
| 从注解返回正文 | 点底部条目里的 ↩,正文里对应的那个编号会滚到视口正中并短暂高亮,方便接着读 |
多段注解的写法:
[^3]: 第一段。
第二段要缩进四个空格,这样才归属于同一条注解。
三个坑
^[内联脚注] 不支持有些 Markdown 方言支持把注解直接写在正文里(^[像这样])。本站不支持——实测会原样输出成 ^[像这样] 这几个字符。
注解必须写在文末的定义里。3这一条的标签写成了「指南」两个字,用来验证非数字标签是否可用。
- 标签别写错:引用写了
[^3]而定义写成[^三],这一个会退化成普通文字(构建不报错,但读者看到的是一串乱码)。 - 定义要顶格:
[^3]:必须从行首开始写;行首有空格会被当成正文,而不是定义。4常见于从别处粘贴 Markdown 时不小心带了缩进。
两个方向的跳转
页面上脚注的跳转有两个方向,两边都做过专门处理,都不像浏览器默认那样顶格对齐:
| 方向 | 怎么触发 | 落点 |
|---|---|---|
| 正文 → 注解 | 点正文里的编号 | 文末对应那条注解滚到视口垂直居中,并短暂高亮 |
| 注解 → 正文 | 点底部条目尾部的 ↩ | 正文里对应的编号滚到视口垂直居中,并短暂高亮 |
为什么要改:浏览器原生片段导航只保证目标"进入视口",对齐方式是顶边对齐。于是
- 点编号跳到注解时,注解的第一行会贴着吸顶导航栏下方,长注解还得自己往下滚;
- 点
↩回正文时,正文里那半个字的编号停在页面最上沿,在一整段文字里根本找不到。
改成居中之后,落点在视口正中,视线不用重新定位;再配一秒钟的高亮,长段落里也能一眼认出。
两个方向都由脚本在点击时实时测量高度与位置,所以桌面端、移动端、明暗主题表现一致, 也不会因为页头 / 吸顶导航栏 / 页面剩余可滚空间不足而跑偏。
不需要任何写法:编号与 ↩ 都由编译器自动生成,这条交互自动生效。
触屏:点一下弹窗,再点一下跳转
手指没有"悬停"这个动作,所以触屏上的点击被拆成两段:
| 动作 | 结果 |
|---|---|
| 点一下编号 | 就地弹出该条注解(不跳转、不改地址栏) |
| 再点同一个编号 | 收起弹窗并跳转——文末那条注解滚到视口垂直居中并短暂高亮 |
| 点另一个编号 | 换成它的弹窗(不跳转) |
| 点正文别处 / 点弹窗空白处 | 收起弹窗 |
| 滚动页面、旋转屏幕、按 Esc | 收起弹窗 |
弹窗会自己找位置,而且两个平台是同一套算法(src/clientModules/footnotePopoverFit.js):
优先出现在编号上方,上方放不下就翻到下方(小三角跟着掉头),
水平方向贴到屏幕边缘时会自动平移回视口内,绝不会盖住编号本身——否则触屏上第二下就点不到了。
注解特别长、一屏装不下时,弹窗会限高并在内部滚动,并且不会把滚动传给页面。
指针设备(鼠标/触控板)的显示与交互完全不变:仍然是"悬停弹窗、点击即跳转、Tab 聚焦弹窗"。
变的只是"显示在哪儿"——它跟触屏共用上面那套定位算法,所以窄窗口、贴近页首页尾、
长注解这些情况下弹窗不再探出视口或被裁掉(从前那套"窄窗口改为向左展开"的桌面专属规则已删除,
它绕开了平移量、也与触屏不一致)。
判断依据是与样式表一致的那条媒体查询 (hover: hover) and (pointer: fine) ——
它绑定的是输入设备而不是屏幕宽度,所以桌面浏览器把窗口缩窄不会触发触屏逻辑。
也正因如此,带触摸屏的笔记本/外接键鼠的平板会被当作指针设备,用不到这套两段式点击。
想改样式
弹窗的外观全在 src/css/custom.css 里,搜 .fnPop 即可:
.fnPop:弹窗本体(宽度上限、底色、阴影、字号);.fnPop::after:指向编号的小三角;.fnRef:hover/.fnRef:focus-within:指针设备何时显示;.fnRef[data-fn-open]:触屏的"已点开"状态;[data-fn-flip='below']/[data-fn-scroll]:两个平台共用的落位状态(翻面、限高内部滚动);.fnPopP.fnPopUl.fnPopLi.fnPopPre:注解内部的块级元素(原因见下)。
配色用的是主题变量(--ifm-background-surface-color 等),所以明暗模式自动适配。
写给维护者
这套脚注由三块拼成:弹窗本体(纯静态)、弹窗定位(两个平台共用一套算法) 与 跳转的居中落点。
三块都挂在 src/theme/Root.js 上,互不耦合。
弹窗部分
- 插件在
plugins/footnote-popover/index.js,通过docusaurus.config.js的docs.rehypePlugins挂载, 构建期把文末注解复制到每个引用旁边。它本身纯静态、无客户端 JS、不依赖 hydration。 - 它只作用于 docs。想让公告栏(blog)也有:把同一个插件加到
blog.rehypePlugins。 - 副本里的块级标签会被改写成
<span>+ 类名(.fnPopP、.fnPopUl…)。这是必须的: 弹窗挂在正文<p>内部,而 HTML 解析器遇到块级标签会自动闭合外层<p>, 注解内容就会被甩出弹窗、变成页面上可见的文字。构建不会报错,只在日志里留一行 SSG warning。 - 因此:如果在注解里用到了插件尚未覆盖的块级标签,构建日志会出现
HTML minifier diagnostic - error ... <path>,看到就该给plugins/footnote-popover/index.js里的BLOCK_CLASS表补一项。 - ⚠️ "藏起来"这条规则必须写在媒体查询之外。
custom.css里先把.fnPop设为display: none(基础层,无条件生效),再由@media (hover: hover) and (pointer: fine)把display: block放回来。反过来写(把display/visibility一起关在查询里)会出一个 只在真机上才暴露的错:手机匹配不到该查询 → 副本退化成普通inlinespan → 注解原文被直接铺进正文,每段后面多出一整行文字。 桌面端预览完全看不出来。
弹窗定位(两个平台共用)
- 算法在
src/clientModules/footnotePopoverFit.js,四步:量内容真实高度 → 定竖直方向与高度上限 → 定水平平移 → 判断是否需要内部滚动。它只写两个 CSS 变量(--fn-pop-max/--fn-pop-shift) 和两个状态属性(data-fn-flip/data-fn-scroll),几何全留在custom.css。 - 谁来调用它,按输入设备分工(两条门控查询互为反面,同一时刻只有一个模块在工作):
src/clientModules/footnoteTouchPopover.js(触屏):第一下点开时调用,并负责"再点一下才跳转";src/clientModules/footnotePopoverHover.js(指针设备):pointerover/focusin时调用, 不改变可见性——显示与否仍完全由:hover/:focus-within决定,本模块只把弹窗塞进视口。
- ⚠️ 量尺寸前必须保证元素不是
display: none:隐藏时getBoundingClientRect()全是 0。 触屏路径先挂[data-fn-open],所以本来就是可量的;指针路径的显示由伪类决定,而 "伪类何时生效"与"事件处理器何时执行"的先后不适合依赖,于是fitPopover()统一兜底: 若量之前仍是display: none就临时打开、量完还原(此时visibility: hidden,不会闪一下)。 - ⚠️ 曾经有一条
@media (max-width: 996px)的桌面专属分支(窄窗口改为从编号向左展开),已删除: 那条规则的transform不含--fn-pop-shift,会绕开平移量,窄窗口里贴边平移直接失效; 而且它是只属于桌面的第二套几何,与触屏不一致。现在贴边统一由平移量处理。
跳转的居中落点
- 逻辑在
src/clientModules/footnoteScrollCenter.js,由src/theme/Root.js(包了一层官方 Root, 不产生额外 DOM)在应用挂载后安装一个 document 级委托监听,所以 SPA 换页不用重新绑定。 - 一个监听同时管两个方向,方向由
href指向的 id 形状判定(#user-content-fn-1是注解条目、#user-content-fnref-1是正文编号),data-footnote-ref/data-footnote-backref只作旁证—— 上游哪天改了属性名也还认得出来。 - 为什么不用
scrollIntoView({block: 'center'}):它会把元素自身的scroll-margin一起算进 "对齐区域"。Docusaurus 给每个带id的锚点目标都加了anchorTargetStickyNavbar(scroll-margin-top= 导航栏 3.75rem + 0.5rem,见@docusaurus/theme-common的anchorUtils), 而文末<li id="user-content-fn-1">正是这种元素——用scrollIntoView居中会整体下移约 34px。 所以这里自己算scrollTop:几何完全可控,scroll-margin一概不参与。 - 算的时候顺带处理三件容易跑偏的事:① 目标可能不在文档那一层,所以从内往外找最近的可滚动祖先,
找不到就用文档滚动容器;② 结果先夹进
[0, scrollHeight - 视口高],页尾附近不会强行居中; ③ 再夹进"完整可见"区间——上不过吸顶导航栏(它的高度实时量,且只在它确实吸顶时才算), 下不贴视口下沿。元素比可用高度还高时(移动端上的长注解)退回"开头对齐到导航栏下方", 保证从第一行开始可读。 - 平滑滚动结束后会再校正一次:移动端地址栏在滚动中收放会改变视口高度,落点可能偏掉几像素, 偏差超过 2px 就静默纠一次(用即时滚动,不再触发第二段动画),900ms 后不再干预。
preventDefault()之后原生片段导航的"焦点起点"不会自动挪,所以脚本用focus({preventScroll: true})补上(目标<li>不可聚焦时先补tabindex="-1"), 键盘读者按 Tab 会从落点继续,与原生跳转一致。- 最后加一个 1.6s 的
.fnRefFlash高亮(样式在src/css/custom.css,明暗主题各一套配色)。 - 为什么刻意不更新 URL 的 hash:实测仅用
history.pushState改 hash,路由层不会跟着滚; 但这里仍不碰 history——将来某个 Docusaurus 版本一旦补上"hash 变化就滚动"的逻辑 (ClientLifecyclesDispatcher的scrollAfterNavigation做的就是顶格对齐),居中会被悄悄覆盖。 代价只是地址栏停在跳转前那个 hash 上,读者无感。 - 禁用 JS 时功能不坏:退回浏览器原生的"顶格跳转"(落点停在导航栏高度 + 0.5rem 处, 也就是原生片段导航的默认位置)。