modern-screenshot 把 DOM 序列化为 SVG foreignObject 再转 canvas,但批注卡片绝对定位在正文之外,需要手动克隆、重置定位、按 data-rev-id 插回对应锚点。记录图片导出的完整 DOM 重建过程。
上篇记录了 PDF 导出:通过 @media print 的分层 CSS,批注形态、颜色保留、分页控制都是声明式的——写好规则,浏览器执行,JavaScript 只需要调用 window.print()。
图片导出走的是另一条路。modern-screenshot(Chen 注:一个把 DOM 节点转为图片的开源库,原理是将 DOM 序列化为 SVG foreignObject,借助浏览器 SVG 渲染引擎处理样式后再转 canvas 输出 Blob。相比老牌的 html2canvas,对现代 CSS(变量、grid、伪元素)支持更好。) 的工作原理是把 DOM 节点序列化为 SVG <foreignObject>,借助浏览器的 SVG 渲染引擎处理样式,再转 canvas 生成图片。这个过程完全在 JavaScript 里,没有「打印时隐藏/显示」这样的 CSS 钩子可用——内容的组织方式、截图的范围、元素的定位,都必须手动处理。
本站图片导出面临的主要问题是:宽屏下的批注卡片(AnnotationSidebar)绝对定位在正文右侧,和 article 是平级的独立 DOM 节点。直接截图 article 不会带走它们,截出来的图里批注全部消失。
DOM 转图片的主流方案有两个:
| 方案 | 原理 | 主要限制 |
|---|---|---|
html2canvas | 逐像素重绘 DOM | 对复杂 CSS 兼容差,主动维护已停止 |
modern-screenshot | DOM → SVG foreignObject → canvas | 更忠实还原样式,持续维护 |
本站大量使用 CSS 变量(主题色、字体都通过 --var 注入),代码块里有 Shiki 生成的内联语法高亮,这两点都是 modern-screenshot 的优势场景。
动态 import 把它排出首屏 bundle,只在触发导出时才加载:
const { domToBlob } = await import("modern-screenshot");截图 wrapper 的背景色不能硬编码,否则深色模式下会得到白底图片:
const bg = getComputedStyle(document.documentElement)
.getPropertyValue("--bg").trim() || "#ffffff";
wrapper.style.cssText =
`padding:3rem;background:${bg};display:inline-block;box-sizing:border-box;`;getComputedStyle(document.documentElement).getPropertyValue("--bg") 读取当前主题下根元素上的自定义属性值,.trim() 处理前导/尾随空白(CSS 自定义属性值会原样保留空白字符),|| "#ffffff" 兜底。
这里只需要手动处理 wrapper 的背景色,因为 wrapper 是新创建的元素,没有继承任何现有样式。domToBlob 内部会遍历整个子树,读取每个节点的计算样式并内联,CSS 变量在这个过程中被正确解析——前提是元素已挂载到文档中。
domToBlob 需要元素挂载在文档里才能拿到正确的计算样式。但直接对页面上的元素截图会受到视口和布局约束:正文 article 的宽度受父容器约束,还可能被 overflow: hidden 的祖先裁剪,结果是「页面的一个视口片段」,而不是「这篇文章的完整内容」。
做法是克隆目标元素,把克隆体放进自定义 wrapper,再把 wrapper 临时挂载到 document.body:
const wrapper = document.createElement("div");
wrapper.style.cssText =
`padding:3rem;background:${bg};display:inline-block;box-sizing:border-box;`;
const header = article.closest("div")?.querySelector("header");
if (header) wrapper.appendChild(header.cloneNode(true));
wrapper.appendChild((article as HTMLElement).cloneNode
display:inline-block 让 wrapper 的宽度收缩到内容宽度,不被视口限制。padding:3rem 给图片四周留出边距。scale: 2 输出 2 倍物理像素,在 HiDPI 屏幕上分享不模糊。
removeChild 必须在 await 之后,finally 块里更安全:
try {
document.body.appendChild(wrapper);
const blob = await domToBlob(wrapper, { scale: 2 });
document.body.removeChild(wrapper);
// 下载...
} finally {
if (document.body.contains(wrapper)) document.body.removeChild(wrapper);
setExporting(false);
}文章页的导出按钮在 NoteLayout 里始终渲染,不感知是否加密。守卫发生在取节点那一步:
const article = document.querySelector(".prose")?.closest("article")
?? document.querySelector(".prose");
if (!article) return;ProtectedNote 在锁定状态下渲染的是密码输入表单,没有 .prose class,也没有 <article> 元素。querySelector 返回 null,函数直接 return。这和上篇里 PDF 的情况一样——依赖的都是「加密内容不进 DOM」这个服务端保证,而不是前端的显式判断。
AnnotationSidebar 是一个独立渲染的组件,通过 RevisionContext 接收批注数据,用 position: absolute; left: calc(50% + 28rem + 1.5rem) 定位在页面右侧。它不在 article 里,也不在 header 里——是 NoteLayout 最外层 <div> 的直接子节点:
<div style={{ position: "relative" }}>
<header>...</header>
<article className="prose">{children}</article>
<AnnotationSidebar /> {/* 在 article 之外,绝对定位 */}
</div>直接克隆 article 只会得到正文,批注全部丢失。如果改成克隆整个外层 <div>,侧栏卡片的绝对定位会在新的包含块里错位,结果更难处理。
要把侧栏卡片内联进正文克隆,需要知道哪张卡片对应正文里的哪个锚点。
AnnotationSidebar 的卡片顺序和 RevisionContext.annotations 数组的顺序一致(按垂直位置排序)。但顺序一致不够用——还需要在克隆体里找到对应的锚点节点才能插入。
解法是在 Revision 组件的 <span> 上加一个 data-rev-id 属性:
<span
ref={spanRef}
+ data-rev-id={hasAnnotation ? id : undefined}
style={{ position: "relative", display: "inline" }}
>id 由 useRevisionId() 生成(基于 React 的 useId()),SSR 和客户端一致,每个 Revision 实例全局唯一。只有 hasAnnotation(有 note 属性)的才写入,和侧栏里只渲染有 note 的卡片保持对应。
有了这个属性,可以建立从「侧栏卡片索引」到「原文锚点」的映射:
const sidebarCards = document.querySelectorAll<HTMLElement>(".annotation-sidebar > div");
const allAnchors = Array.from(document.querySelectorAll<HTMLElement>("[data-rev-id]"));
// 两个数组均按 DOM 中出现顺序,一一对应querySelectorAll 返回 DOM 顺序,AnnotationSidebar 内部按 DOM 顺序(文章从上到下)渲染——两者顺序一致,索引直接对齐。
sidebarCards.forEach((card) => {
const idx = Array.from(sidebarCards).indexOf(card);
const anchor = allAnchors[idx];
if (!anchor) return;
const revId = anchor.getAttribute("data-rev-id");
if (!revId) return;
const anchorClone
样式重置是必要的:侧栏卡片原本是 position: absolute; left: calc(50% + 28rem + 1.5rem); width: 11rem,克隆进 wrapper 后,left 会相对于 wrapper 的包含块计算,卡片会出现在距左边几百像素的位置,和文章内容完全分离。
重置为 position: relative; display: block 让卡片回到正常文档流,紧跟被批注文字后面另起一行。width: auto 让它自适应截图宽度。insertBefore(inlineCard, anchorClone.nextSibling) 把卡片插在锚点 <span> 后、紧接内容前。
AnnotationSidebar 在屏幕上有一套碰撞避让逻辑——当两个批注的 top 值太接近时,下方的卡片会被推低,防止视觉重叠。这个逻辑在截图里不再需要:内联后的卡片是文档流里的块级元素,浏览器排版引擎自动保证它们不重叠,间距自然。
哲思页面的每张卡片也有图片导出,但结构比文章简单——没有侧栏批注,不需要 header/article 拼接,直接以整张卡片为截图对象。
用 meta.date 作为卡片的 DOM id(格式 YYYY-MM-DD,每张唯一):
const handleExportImage = async () => {
const { domToBlob } = await import("modern-screenshot");
const card = document.getElementById(cardId); // cardId = meta.date
if (!card) return;
const wrapper = document.createElement("div");
const bg
URL.createObjectURL 创建临时对象 URL,a.click() 触发浏览器下载,revokeObjectURL 立即释放——不释放的话这个 URL 会在页面关闭前一直持有 Blob 内存。
加密处理和文章页一样:PrintBar(含两个导出按钮)只在 !isEncrypted 或 isUnlocked 的渲染分支里出现,锁定时按钮根本不进 DOM,无需额外判断。
回顾两篇的内容,PDF 和图片面对同样的内容结构,但每个问题的解法都不同:
| 导出 PDF | 导出图片 | |
|---|---|---|
| 批注呈现 | .revision-print-note 预先写入 DOM,CSS 控制显隐 | 侧栏卡片 clone 后重置定位、按 data-rev-id 内联插入 |
| 颜色处理 | @media print 固定 hex 值 + print-color-adjust: exact | getComputedStyle 读 CSS 变量,domToBlob 内联计算样式 |
| 布局控制 | CSS 声明式(display: none、page-break-*) | JavaScript 命令式(cloneNode、临时挂载、样式覆盖) |
| 边距/尺寸 | @page { margin: 18mm 20mm 20mm } | wrapper padding: 3rem、scale: 2 |
| 代码高亮 | var(--shiki-light) 覆盖 token 颜色 | domToBlob 序列化内联样式时自动携带 |
| 加密守卫 | 内容不进服务端 HTML,打印只得到表单 | 返回 null,直接 return |
PDF 的优势是浏览器对分页、字体嵌入、可访问性的原生支持;图片的优势是可以在 JavaScript 层面任意重组 DOM 内容,把分散在页面各处的元素合并进同一张图——这是 @media print 做不到的。
querySelector(".prose")