从一个 Tooltip 组件到双轨批注体系:Revision 修订标注与 Follow 行动跟进的完整演进过程,以及 Context 注册、碰撞避让、打印降级、Hydration 修复的技术细节。
有两类内容天然需要批注。
第一类是演绎他人文章时的修订标注。转载或改编时,有一类信息很难安放——它不是原文,但又不适合放进正文。比如原作发布于特定语境,今天读来需要一句背景补充;或者某个概念已经迭代,直接改掉会破坏原文的叙述节奏。脚注太远,括号注释破坏行文,单独开一节又显得小题大做。
第二类是自己文章的事后更新。一篇写于某个时间点的文章,后来的认知可能和当时不同。与其悄悄修改正文、抹去历史,不如把「当时写的是什么」和「后来怎么看」都保留下来,让读者看到完整的思考轨迹。
最直接的参照是 Microsoft Word 的「修订」功能:删除线标出原文,高亮标出新文,右侧浮出批注卡片。这种形式把「改了什么」和「为什么改」分离得很清楚。本站的批注系统就是从这个想法出发,最终演化成两个互相配合的组件——Revision(修订批注)和 Follow(行动跟进)——以及背后一整套注册、对齐、碰撞避让的运行机制。
第一个版本只有几十行。Revision 是一个纯客户端组件,接收 note 字符串,用 useState 控制 hover 状态,直接在 <span> 里内联一个绝对定位的 tooltip:
export function Revision({ note, children }: Props) {
const [visible, setVisible] = useState(false);
return (
<span style={{ position: "relative" }}
onMouseEnter={() => setVisible(true)}
onMouseLeave={() => setVisible(false)}
>
<mark style={{ background: "#fef9c3", borderBottom: "1px dashed #ca8a04" }}>
{children}
</mark>
{visible && (
<span style={{ position: "absolute", bottom: "calc(100% + 6px)", /* ... */ }}>
<span style={{ opacity: 0.6, fontSize: "0.75rem" }}>编者注 </span>
{note}
</span>
)}
<span className="revision-print-note">(编者注:{note})</span>
</span>
);
}打印时通过 revision-print-note 控制显隐:屏幕上隐藏,打印时变为 inline,在括号内附加注释文本。
V1 够用,但只有「新文」没有「原文」,看不出修改了什么。随即迭代成 Word 风格——加入 original prop,用 <del> 标出删除线原文,tooltip 同时展示原文和编者注:
{original && (
<del style={{ color: "var(--revision-del-color, #dc2626)", opacity: 0.75 }}>
{original}
</del>
)}
<mark style={{ background: "var(--revision-bg, #dcfce7)" }}>
{children}
</mark>颜色全部用 CSS 变量,深色模式在全局 CSS 里单独定义一套,组件内不写任何主题判断逻辑。
单个 tooltip 的问题在于每条批注都是孤立的——视野里同时有多条标注时,读者需要逐一 hover 才能看到内容。Word 文档里批注卡片固定在右侧、垂直对齐到对应段落,这才是更自然的阅读方式。
要让侧栏知道每个 Revision 在哪里,方案是 Context。设计了四个操作:
interface RevisionContextValue {
annotations: AnnotationEntry[];
register: (id, note?, original?, date?, type?) => void;
unregister: (id) => void;
updatePosition: (id, top, sourceRight
每个 Revision 在 useEffect 里调用 register,卸载时调用 unregister。同时监听 scroll 和 resize,实时更新自己的垂直位置(getBoundingClientRect().top + window.scrollY)。
位置上报只在宽屏(≥1440px)启用,窄屏仍然走 tooltip:
const [wideScreen, setWideScreen] = useState(false);
useEffect(() => {
const mq = window.matchMedia("(min-width: 1440px)");
setWideScreen(mq.matches);
const handler = (e: MediaQueryListEvent) => setWideScreen(e.matches);
mq.addEventListener("change", handler);
return
AnnotationSidebar 从 context 取到所有批注,按 top 排序后计算最终渲染位置。当两条批注挨得太近时,后一条向下移,保证间距不小于 MIN_GAP:
const positions: number[] = [];
for (let i = 0; i < annotated.length; i++) {
let top = annotated[i].top - containerTop;
if (i > 0) {
const prevH = heights[annotated[i - 1].id] ?? 80;
const prevBottom
早期版本用固定高度 72px 估算,后来改用 ResizeObserver 监听实际渲染高度,解决了批注内容较多时卡片重叠的问题:
useEffect(() => {
const ro = new ResizeObserver(() => {
onHeightChange(ann.id, el.offsetHeight);
});
ro.observe(el);
return () => ro.disconnect();
}, [ann.id, onHeightChange]);中间曾经尝试用 SVG 三次贝塞尔曲线把正文标注右边缘连到批注卡片左边缘——鼠标悬停时变为实线并着色,平时用虚灰色。效果精致,但实现需要精确计算容器偏移,快速滚动时会短暂出现位置错位的闪烁。最终去掉连线,只保留 hover 时卡片边框高亮——视觉联系改由空间上的垂直对齐隐含。
侧栏批注卡片是绝对定位的独立 DOM 节点,直接隐藏会丢失内容;保留又会因 position: absolute 导致 PDF 出现空白页。
解决方案是在 Revision 的 JSX 里预渲染一个打印专用节点,平时 display: none,打印时变为 inline,内联在正文里:
{(original || note) && (
<span className="revision-print-note">
{original && <span style={{ textDecoration: "line-through" }}>{original}</span>}
{note && `(Chen 注:${note})`}
</span>
)}.revision-print-note { display: none; }
@media print {
.revision-print-note {
display: inline;
font-size: 0.85em;
color: #666;
font-style: italic;
}
mark { background: transparent !important; border-bottom: 1px solid
打印时高亮底色变透明,改用细下划线保留视觉区分;批注内容以「(Chen 注:…)」格式紧跟原文。
Revision 解决的是对文字内容的修订标注——无论是演绎他人文章还是更新自己的旧文。但还有另一类需求:在哲思文章里,记录某个行动或判断的后续结果。比如「当时决定这样处理某件事——后来发现是误判」。这是跨时间的标注,不是修订,而是跟进。
Follow 复用了同样的注册-上报-侧栏机制,颜色体系换成紫色(#7c3aed)以区别于 Revision 的绿色。它不展示原文/新文对比,而是直接标注一段行动文字,在侧栏或 tooltip 里显示跟进日期和内容。
在哲思文章里,Follow 配合 FollowList / FollowItem 提供了另一种呈现方式:用上标序号 [1] 在行内标记,文末用带虚线分隔的列表展开所有跟进条目。这让加密内容在解密后不必展开宽屏侧栏,也能看到完整的跟进脉络:
在彻底冷却前,问问自己:<Follow index="1">她的反应背后是什么?</Follow>
<FollowList>
<FollowItem index="1" date="2026-07-06"
note="得知全貌后才发现是我筛选机制的触发点出错了……" />
</FollowList>Follow 的演化经历了不少曲折——先后试了纯 CSS tooltip、hover sidebar、footnote style 等多种形态,最终稳定在「行内上标 + 末尾 FollowList」的结构上。加密内容的解密渲染用的是 serialize + MDXRemote,不是服务端静态渲染,这让客户端组件(包括 Follow)能在加密内容里正常运行。
早期版本用模块级计数器生成批注 ID:
let counter = 0;
export function useRevisionId() {
const ref = useRef<string | null>(null);
if (!ref.current) ref.current = `rev-${++counter}`;
return ref.current;
}服务端渲染和客户端 hydration(Chen 注:Next.js 的页面先在服务端渲染成 HTML,发送到浏览器后,React 再接管这段 HTML、绑定事件、初始化状态,这个过程叫 hydration。它要求服务端和客户端渲染出的 DOM 完全一致,否则 React 会报 mismatch 警告并强制重新渲染。) 各自独立计数,ID 序列不一致,产生 hydration mismatch 警告。修复方案是换用 React 18 的 useId(),它在服务端和客户端生成相同的确定性 ID:
export function useRevisionId() {
const id = useId();
return `rev${id.replace(/:/g, "-")}`;
}纯文本批注有时不够用,两种扩展先后加入。
内联代码:用正则把 `code` 转为带样式的 <code> 标签:
function renderInlineCode(note: string): string {
return note.replace(
/`([^`]+)`/g,
(_, code) => `<code style="font-family:var(--font-mono);...">${code}</code>`
);
}LaTeX 公式:批注里写 $\Delta$ 这样的数学符号,用 KaTeX 的 renderToString 内联到批注卡片里:
function renderNoteWithMath(note: string): string {
return note.replace(/\$([^$]+)\$/g, (_, expr) => {
try {
return katex.renderToString(expr, { throwOnError: false, displayMode: false });
两个函数在 AnnotationSidebar 里合并调用,渲染最终卡片内容。
导出为图片时,AnnotationSidebar 里的卡片和正文 <article> 是平级的独立 DOM 节点,直接截图 article 会丢失所有批注。
解决方案:每个 Revision 渲染时在 <span> 上写 data-rev-id 属性作为锚点。截图前,代码遍历所有批注卡片,根据 data-rev-id 找到正文里对应的 DOM 节点,把克隆的卡片插入其后,截图完成后再清理。这样截出来的图片里批注和正文并排,位置关系和宽屏侧栏的视觉效果一致。
批注系统最终由三层组成:
| 层 | 组件 / 机制 | 职责 |
|---|---|---|
| 标注层 | <Revision>, <Follow> | 行内渲染标注文字,向 context 上报位置 |
| 状态层 | RevisionContext | 注册表 + 位置同步 + active 状态 |
| 展示层 | AnnotationSidebar | 碰撞避让 + 卡片渲染 |
各层之间只通过 context 通信。AnnotationSidebar 不需要知道 Revision 的内部结构,Revision 也不需要知道侧栏如何布局——它只负责上报坐标。加入 Follow 时,只在 AnnotationEntry 加了一个 type 字段,侧栏根据 type 切换颜色,其余逻辑完全不变。
RevisionContext 是 per-page 的,所有批注的汇总视图目前不支持。