跳到主要内容

弹出窗口式脚注:写法说明

写脚注只需要做两件事:正文里写编号引用,文末写编号定义。剩下的(编号、底部列表、悬停弹窗)全部自动生成。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 一起关在查询里)会出一个 只在真机上才暴露的错:手机匹配不到该查询 → 副本退化成普通 inline span → 注解原文被直接铺进正文,每段后面多出一整行文字。 桌面端预览完全看不出来。

弹窗定位(两个平台共用)

  • 算法在 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 处, 也就是原生片段导航的默认位置)。

Footnotes​

  1. 这篇说明本身就是用同一套脚注写的,你看到的每个编号都能点开。 ↩

  2. 底部列表是权威副本,弹窗只是它的一个"预览窗口"——所以关掉 CSS 或脚本时,底部条目照常在,功能不会坏。 ↩

  3. 这一条的标签写成了「指南」两个字,用来验证非数字标签是否可用。 ↩

  4. 常见于从别处粘贴 Markdown 时不小心带了缩进。 ↩