面向哲思的编程与架构
笔记哲思阅读动态搜索RSS 订阅
切换到深色模式
搜索
RSS 订阅
切换到深色模式
© 2026 Vic Chen. All rights reserved.CC BY-NC-ND 4.0
← 笔记
「批注」功能的设计与演进

「批注」功能的设计与演进

2026年5月27日1,9376分钟
✦AI 生成摘要

从一个 Tooltip 组件到双轨批注体系:Revision 修订标注与 Follow 行动跟进的完整演进过程,以及 Context 注册、碰撞避让、打印降级、Hydration 修复的技术细节。


目录
  • 1. 为什么需要批注
  • 2. V1:行内 Tooltip
  • 3. V2:侧栏系统
  • 3.1 Context:注册与位置上报
  • 3.2 碰撞避让
  • 3.3 SVG 连线:做了,又删了
  • 4. 打印降级
  • 5. Follow:行动跟进批注
  • 6. Hydration 修复
  • 7. 批注内容的富文本支持
  • 8. 图片导出
  • 9. 架构回顾
  • 10. 尚未解决的问题
目录
  • 1. 为什么需要批注
  • 2. V1:行内 Tooltip
  • 3. V2:侧栏系统
  • 3.1 Context:注册与位置上报
  • 3.2 碰撞避让
  • 3.3 SVG 连线:做了,又删了
  • 4. 打印降级
  • 5. Follow:行动跟进批注
  • 6. Hydration 修复
  • 7. 批注内容的富文本支持
  • 8. 图片导出
  • 9. 架构回顾
  • 10. 尚未解决的问题
目录
  1. 1. 为什么需要批注
  2. 2. V1:行内 Tooltip
  3. 3. V2:侧栏系统
  4. 3.1 Context:注册与位置上报
  5. 3.2 碰撞避让
  6. 3.3 SVG 连线:做了,又删了
  7. 4. 打印降级
  8. 5. Follow:行动跟进批注
  9. 6. Hydration 修复
  10. 7. 批注内容的富文本支持
  11. 8. 图片导出
  12. 9. 架构回顾
  13. 10. 尚未解决的问题
前端Next.js
相关文章
  • 01
    Prefetch 的完整图景2026/07
  • 02
    用 View Transitions + Skeleton 消灭页面跳转的割裂感2026/07
  • 03
    「相关文章」功能的设计与实现2026/05
← 上一篇打磨 iOS Web Clip 体验(一):原生质感的下拉刷新
下一篇 →从阅读到知识(一):个人阅读档案的设计与实现

评论

© 2026 Vic Chen · 面向哲思的编程与架构CC BY-NC-ND 4.0

1. 为什么需要批注

有两类内容天然需要批注。

第一类是演绎他人文章时的修订标注。转载或改编时,有一类信息很难安放——它不是原文,但又不适合放进正文。比如原作发布于特定语境,今天读来需要一句背景补充;或者某个概念已经迭代,直接改掉会破坏原文的叙述节奏。脚注太远,括号注释破坏行文,单独开一节又显得小题大做。

第二类是自己文章的事后更新。一篇写于某个时间点的文章,后来的认知可能和当时不同。与其悄悄修改正文、抹去历史,不如把「当时写的是什么」和「后来怎么看」都保留下来,让读者看到完整的思考轨迹。

最直接的参照是 Microsoft Word 的「修订」功能:删除线标出原文,高亮标出新文,右侧浮出批注卡片。这种形式把「改了什么」和「为什么改」分离得很清楚。本站的批注系统就是从这个想法出发,最终演化成两个互相配合的组件——Revision(修订批注)和 Follow(行动跟进)——以及背后一整套注册、对齐、碰撞避让的运行机制。


2. V1:行内 Tooltip

第一个版本只有几十行。Revision 是一个纯客户端组件,接收 note 字符串,用 useState 控制 hover 状态,直接在 <span> 里内联一个绝对定位的 tooltip:

Revision.tsx — 初版
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 里单独定义一套,组件内不写任何主题判断逻辑。


3. V2:侧栏系统

单个 tooltip 的问题在于每条批注都是孤立的——视野里同时有多条标注时,读者需要逐一 hover 才能看到内容。Word 文档里批注卡片固定在右侧、垂直对齐到对应段落,这才是更自然的阅读方式。

3.1 Context:注册与位置上报

要让侧栏知道每个 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

3.2 碰撞避让

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]);

3.3 SVG 连线:做了,又删了

中间曾经尝试用 SVG 三次贝塞尔曲线把正文标注右边缘连到批注卡片左边缘——鼠标悬停时变为实线并着色,平时用虚灰色。效果精致,但实现需要精确计算容器偏移,快速滚动时会短暂出现位置错位的闪烁。最终去掉连线,只保留 hover 时卡片边框高亮——视觉联系改由空间上的垂直对齐隐含。


4. 打印降级

侧栏批注卡片是绝对定位的独立 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 注:…)」格式紧跟原文。


5. Follow:行动跟进批注

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)能在加密内容里正常运行。


6. Hydration 修复

早期版本用模块级计数器生成批注 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, "-")}`;
}

7. 批注内容的富文本支持

纯文本批注有时不够用,两种扩展先后加入。

内联代码:用正则把 `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 里合并调用,渲染最终卡片内容。


8. 图片导出

导出为图片时,AnnotationSidebar 里的卡片和正文 <article> 是平级的独立 DOM 节点,直接截图 article 会丢失所有批注。

解决方案:每个 Revision 渲染时在 <span> 上写 data-rev-id 属性作为锚点。截图前,代码遍历所有批注卡片,根据 data-rev-id 找到正文里对应的 DOM 节点,把克隆的卡片插入其后,截图完成后再清理。这样截出来的图片里批注和正文并排,位置关系和宽屏侧栏的视觉效果一致。


9. 架构回顾

批注系统最终由三层组成:

层组件 / 机制职责
标注层<Revision>, <Follow>行内渲染标注文字,向 context 上报位置
状态层RevisionContext注册表 + 位置同步 + active 状态
展示层AnnotationSidebar碰撞避让 + 卡片渲染

各层之间只通过 context 通信。AnnotationSidebar 不需要知道 Revision 的内部结构,Revision 也不需要知道侧栏如何布局——它只负责上报坐标。加入 Follow 时,只在 AnnotationEntry 加了一个 type 字段,侧栏根据 type 切换颜色,其余逻辑完全不变。


10. 尚未解决的问题

  • 移动端:侧栏在移动端隐藏,批注只能 hover 查看,而触屏不支持 hover。点击展开 tooltip 会和文字选择冲突,内联展开会破坏行文节奏,目前没有找到好的方案。
  • 锚点导航:批注卡片没有编号,无法通过链接直接定位到某条批注。
  • 跨文章检索:RevisionContext 是 per-page 的,所有批注的汇总视图目前不支持。
)
=>
void
;
setActive: (id, active) => void;
}
()
=>
mq.
removeEventListener
(
"change"
, handler);
}, []);
=
positions[i
-
1
]
+
prevH
+
MIN_GAP
;
if (top < prevBottom) top = prevBottom;
}
positions.push(top);
}
#999
!important
; }
}
} catch { return `$${expr}$`; }
});
}