跳转到内容
关系图谱

obsidian-canvas:把 Obsidian 白板嵌进文章

2,561 字 9 分钟

属性 1
series
VaultKit 包指南

在 Obsidian 里画的白板(.canvas 文件,即开放格式 JSON Canvas),能不能像一张图片一样直接嵌进已发布的文章里?@vaultkit/vitepress-plugin-obsidian-canvas 的答案是:能,而且是构建期一次成型、客户端零运行时的静态嵌入——节点、连线、箭头、标签、颜色全部保真,没有一行浏览器端 JavaScript。

本文通读这个包的全部导出与实现细节:renderCanvas 纯函数的每个选项、随包导出的完整 TypeScript 类型、CSS 变量与预设色板,以及贝塞尔连线的锚点推断这类源码里才写清楚的机制。本站主题已内置集成,你在本站文章里看到的白板嵌入就是它渲染的。

为什么需要它

Obsidian 的 Canvas 是笔记工作流里非常高频的思考工具,但发布侧一直是个空白:官方 Publish 之外,几乎没有把 .canvas 文件渲染成网页的轻量方案。已有的一些实现要么把整个交互式画布引擎搬进浏览器(依赖重、首屏慢),要么只支持截图导出(内容失活,链接点不了)。

这个包选择了第三条路:把 JSON Canvas 解析成静态 HTML + SVG

  • 节点是绝对定位的 div,保留原始坐标系,超出容器滚动查看
  • 连线是一层 SVG path,贝塞尔曲线、箭头、标签齐备
  • 文本节点里的 Markdown、文件节点指向的笔记链接,全部经回调委托宿主渲染与解析——插件本身不依赖 vitepress、不依赖 markdown-it、没有任何 peer 依赖,任何构建工具都能复用这个纯函数内核

安装

sh
pnpm add -D @vaultkit/vitepress-plugin-obsidian-canvas

若使用 @vaultkit/vitepress-theme-vault 主题则开箱即带,无需单独安装——主题已经接好 ![[白板.canvas]] 的 Markdown 嵌入语法(见下文)。

快速上手

renderCanvas 是一个纯函数:吃 JSON Canvas 对象,吐 HTML 字符串。在任意构建期上下文(markdown-it 插件、data loader、脚本)里调用:

ts
import { readFileSync } from 'node:fs'
import { renderCanvas } from '@vaultkit/vitepress-plugin-obsidian-canvas'

const canvas = JSON.parse(readFileSync('思考.canvas', 'utf8'))
const html = renderCanvas(canvas, {
  renderMarkdown: source => md.render(source),
  resolveFile: file => ({ href: '/note-url', label: '笔记标题' }),
})

样式须在主题入口引入一次(预设色板明暗两态、点阵背景都在其中):

ts
// .vitepress/theme/index.ts
import '@vaultkit/vitepress-plugin-obsidian-canvas/styles/index.css'

全部导出一览

包的公开导出分两类。

函数:只有一个 renderCanvas(canvas, options?),返回 HTML 字符串。

类型(JSON Canvas spec 1.0 子集 + 渲染选项):

  • JsonCanvas——顶层结构 { nodes?, edges? }
  • CanvasNodeBase——节点公共字段:id / type / x / y / width / height / color?(预设 '1'-'6''#RRGGBB'
  • CanvasTextNode——type: 'text',携带 text(Markdown 源)
  • CanvasFileNode——type: 'file',携带 file 与可选 subpath(如 '#标题',Obsidian 扩展)
  • CanvasLinkNode——type: 'link',携带 url(外链卡)
  • CanvasGroupNode——type: 'group',携带可选 label(虚线群组)
  • CanvasNode——以上四种节点的联合类型
  • CanvasSide——锚点边 'top' | 'right' | 'bottom' | 'left'
  • CanvasEdge——连线:fromNode / toNode / fromSide? / toSide? / fromEnd? / toEnd? / color? / label?
  • RenderCanvasOptions——渲染选项(下节逐项详解)
  • ResolvedFileTarget——resolveFile 回调的返回结构

RenderCanvasOptions 逐项详解

renderMarkdown

  • 类型:(markdown: string) => string
  • 默认值:转义 + 换行转 <br>

文本节点的 Markdown 渲染回调。缺省实现只做 HTML 转义和换行处理——足够安全但没有排版。想让白板里的文本节点支持加粗、列表、代码块,把宿主的 markdown-it 实例接进来:

ts
const html = renderCanvas(canvas, {
  renderMarkdown: source => md.render(source),
})

本站主题在这里接的是嵌套渲染管线renderNested),意味着白板文本节点里连 wikilink、callout 都能正常渲染,且循环嵌入检测与嵌入深度沿用同一个嵌入栈。

resolveFile

  • 类型:(file: string, subpath?: string) => ResolvedFileTarget
  • 默认值:不提供时一律渲染灰态

文件节点的解析回调,返回 ResolvedFileTarget

  • href——站内链接;未发布或不存在则省略,节点渲染为灰态(带「未发布或不存在」的 title 提示)
  • label——展示名,缺省取文件名(自动剥离路径与 .md 后缀)
  • imageSrc——图片资源地址;返回了它,节点就直接内嵌为 <img>object-fit: cover 铺满、懒加载)

三种形态在一个回调里分流:

ts
const html = renderCanvas(canvas, {
  resolveFile: (file, subpath) => {
    if (/\.(?:png|jpe?g|webp)$/i.test(file))
      return { imageSrc: `/assets/${file}`, label: file }
    const note = findPublishedNote(file)
    if (!note)
      return {} // 灰态
    return { href: note.url + (subpath ? `#${slugify(subpath.slice(1))}` : ''), label: note.title }
  },
})

subpath 是 Obsidian 的扩展字段:白板里可以链接到笔记的某个标题(#标题),宿主负责把它 slug 化拼进锚点。

maxHeight

  • 类型:number
  • 默认值:520

容器最大高度(px),超出滚动。大型白板建议调高,配合外层滚动容器移动端也能横向滑动:

ts
renderCanvas(canvas, { maxHeight: 720 })

ariaLabel

  • 类型:string
  • 默认值:'Canvas 白板'

容器的无障碍名。整个画布以 role="img" 输出(对读屏器而言是一张「图」,内部节点不参与朗读树),aria-label 就是这张图的替代文本。按白板内容命名:

ts
renderCanvas(canvas, { ariaLabel: 'Canvas 白板:产品架构梳理' })

细节:容器同时带 data-pagefind-ignore,白板内容不会污染 Pagefind 的全文搜索索引。

设计机制:连线是怎么画出来的

这是本包最讲究的部分,值得展开。

锚点与贝塞尔控制点

每条连线从起点节点的某条边(fromSide)出发,到终点节点的某条边(toSide)结束,锚点取该边中点。控制点沿锚点法向出射——从节点上边出发就先垂直向上走,从右边出发就先水平向右走——出射距离为 max(40, 两锚点距离 × 0.55)。这个 0.55 的张力系数(源码常量 EDGE_TENSION)配上 40px 的下限,是对 Obsidian 画布视觉的近似复刻:近距离连线不会挤成直线,远距离连线弧度自然舒展。

未指定锚点时的推断

Obsidian 里随手拉的连线经常不带 fromSide / toSide。此时按两节点中心的相对方位推断:横向偏移大于纵向就走左右边(目标在右则 right → left),否则走上下边。这与 Obsidian 的缺省行为一致,保证老白板文件不做任何修补也能正确渲染。

箭头与标签

箭头方向遵循 Obsidian 语义:缺省 fromEnd = 'none'toEnd = 'arrow'——即只有终点有箭头;两端都要箭头或都不要,在 .canvas 文件里显式声明即可。连线标签放在三次贝塞尔曲线 t = 0.5 处的几何中点,坐标由闭式公式 (start + 3c₁ + 3c₂ + end) / 8 直接算出,无需采样;标签文字带 4px 的背景色描边(paint-order: stroke),压在线上也清晰可读。极端弯折的曲线下几何中点可能稍偏离视觉中心,这是文档明示的已知取舍。

层叠顺序

JSON Canvas 没有显式 z-index。渲染时 group 节点垫底,其余节点按 y 坐标排序——上方的节点先画、下方的后画,视觉稳定且与 Obsidian 的观感一致。

颜色系统

预设色 '1'-'6'(红/橙/黄/绿/青/紫)映射为 CSS 类 oc-color-1oc-color-6,色值对齐 Obsidian 画布色板,明暗两态由 CSS 变量切换。自定义 #RRGGBB 则内联生成同款「淡底 + 实边」:边框用原色,背景用 color-mix(in srgb, 原色 10%, transparent)

在本站:![[白板.canvas]] 一行嵌入

本站主题(@vaultkit/vitepress-theme-vault)把这个包接进了 Obsidian 的嵌入语法。在文章里写:

md
![[产品架构.canvas]]

构建期主题的 canvas-embed.ts 处理器完成全部接线:

  1. 文件定位——在发布面目录(notes + attachments)的文件索引里按文件名或带路径全匹配找到 .canvas 文件;同名冲突或不存在时输出行动导向的提示块
  2. 循环检测——嵌入栈里已有该文件(白板 A 的文本节点里又嵌白板 A)则截断,提示循环嵌入
  3. 文本节点——走主题的嵌套 Markdown 渲染,wikilink、callout 全语法可用
  4. 文件节点——图片类扩展名走父页相对路径进 Vite 资产管线(指纹化文件名);笔记类按已发布笔记索引解析成站内链接,#标题 子路径自动 slug 化为锚点;未发布的笔记灰态显示
  5. dev HMR——.canvas 文件挂进 env.includes,Obsidian 里改白板、浏览器里的文章页即时热更新

也就是说在本站写作时你什么都不用配——白板文件放进 vault,一行 ![[]] 嵌入,效果在本站文章现场即可看到。

高级与边界

  • SSR 与 noExternal:本包是纯字符串渲染函数,产物不含 .vue 文件、只在构建期的 Node 环境运行,因此它是本系列中唯一不需要 vite.ssr.noExternal 配置的包。但别忘了客户端样式:styles/index.css 必须在主题入口引入一次,否则画布没有点阵背景、预设色板全部失效
  • 静态渲染:无拖拽、无缩放。容器内是原始坐标系 + 滚动条,移动端横向可滑。想要可交互画布,这个包刻意不做——那是另一个量级的依赖
  • 容错:空画布输出「(空白画布)」占位;连线引用了不存在的节点 id 时打印警告并跳过该线,不影响其余渲染
  • 无障碍role="img" + aria-label 让整个白板对读屏器呈现为单一图形,避免几十个绝对定位节点被逐个朗读成噪音

CSS 变量

变量默认值说明
--oc-node-bgvar(--vp-c-bg-soft)节点底色
--oc-node-bordervar(--vp-c-divider)节点边框色
--oc-edgevar(--vp-c-text-3)连线与箭头色

三个变量都定义在 .oc-canvas 容器上,站点级覆盖一行搞定:

css
.oc-canvas {
  --oc-edge: var(--vp-c-brand-1);
}

预设色 1-6 的类(.oc-color-*)内部也是改写这两个节点变量实现的,同一套机制。

常见问题

Q:白板里的图片显示不出来?resolveFile 回调没返回 imageSrc。图片文件需要宿主解析成可访问的资源地址——本站主题走的是父页相对路径 + Vite 资产管线;裸用时返回 public 目录下的绝对路径也可以。

Q:文本节点里的 Markdown 只有纯文本? 缺省 renderMarkdown 只做转义。把宿主的 markdown-it render 接进来即可获得完整排版。

Q:连线标签为什么偏离了曲线中心? 标签位置是三次贝塞尔 t = 0.5 的几何中点,极端弯折(比如两节点几乎重叠又强制指定对侧锚点)时会偏。调整白板布局或去掉显式锚点让推断接管即可。

相关链接