用 Electron + TipTap 写一个兼容 Obsidian 的 Markdown 工作记录工具:无损往返、复制到钉钉不丢图、同步盘不丢稿
项目开源:github.com/qianx77/Kit... (AGPL-3.0)

先说做了个什么
我是做算法的,任务记在 To Do,实验记录写在 Obsidian。用久了有三个痛点:表格不好做 (增删一列都要手改 |)、笔记复制到钉钉图片全丢 、没有周报(任务和记录分在两个软件,周五要两边来回翻)。
于是写了 KiteNote:To Do 式的日 / 周任务,每个任务一键生成一篇所见即所得的 Markdown 笔记,周五一键复制周报。数据就是文件夹里的普通 .md,可以直接用 Obsidian 打开。

技术栈:Electron + React + TypeScript + TipTap(ProseMirror)+ markdown-it。
下面聊聊做的过程中最费劲的四个问题。
一、Markdown 无损往返:打开、编辑、保存,不能把 Obsidian 的写法改坏
所见即所得编辑器的内部是一棵 ProseMirror 文档树,保存时要再序列化回 Markdown。只要「解析 → 序列化」不是恒等的,用户打开一篇笔记、改一个字、保存,文件里别处的写法就可能被悄悄改掉。对一个「直接打开你现有 Obsidian 库」的工具来说,这是不能接受的。
Obsidian 有不少标准 Markdown 之外的语法,每一种都要单独处理:
| 语法 | 例子 | 处理方式 |
|---|---|---|
| 双链 | `[[实验/exp-001 | 基线]]` |
| 嵌入 | ![[loss.png]] |
解析为图片节点,用 data-embed 记住原始写法,保存时不改成  |
| 公式 | $\alpha$、$$...$$ |
自定义 inline / block 规则,KaTeX 渲染 |
| 高亮 | ==重点== |
自定义 mark |
| Callout | > [!note] 标题 |
仍按普通引用块存,只加显示样式(ProseMirror Decoration),不改动文档内容 |
| Tasks 插件 | - [ ] 任务 ⏫ ✅ 2026-10-27 |
任务行本身是普通 Markdown,emoji 原样保留 |
做法是给 markdown-it 加自定义规则,同时为每种节点写对应的序列化逻辑。验证方式也很朴素:拿自己真实库里的 60 多篇笔记做往返测试,「解析 → 序列化」后逐字比对。
完全恒等做不到。有些写法渲染效果相同,序列化时只能输出一种形式,比如多余的 \_ 转义、|a|b| 和 | a | b |、列表缩进。我的选择是不偷偷改:打开时预先跑一遍往返,如果保存后文本会变,就在笔记顶部提示「保存时会规范化格式」,需要保留原样的可以切到源码模式编辑。
二、复制到钉钉不丢图
Obsidian 的图片是本地相对路径,复制到钉钉文档,钉钉拿不到本机文件,图片就丢了。
KiteNote 里图片通过自定义协议 vault:// 加载。复制时,在主进程把 HTML 里所有 vault:// 图片读出来,换成 base64 的 data: URL,再写进剪贴板:
ts
for (const src of new Set(srcs)) {
const rel = relFromVaultUrl(src)
const buf = await vault.readBinary(rel)
const mime = MIME[path.extname(rel).toLowerCase()] ?? 'image/png'
html = html.split(src).join(`data:${mime};base64,${buf.toString('base64')}`)
}
clipboard.write({ html: fullHtml, text })
两个细节:
- 只选中一张图时,额外写一份位图。 有些应用只认剪贴板里的图片格式、不认 HTML,这样粘到哪里都能得到图片。
- 在 HTML 里夹带原始 Markdown。 富文本一来一回会丢信息,双链会变成普通文字、公式会变成渲染结果。所以复制时在 HTML 末尾加一段注释,钉钉等外部应用会忽略它;粘贴回 KiteNote 时检测到这段注释,就直接用原始 Markdown,双链、公式、待办都能原样还原:
ts
const MARK_RE = /<!--kitenote-md:([A-Za-z0-9+/=]+)-->/
export function withMarkdown(html: string, md: string): string {
return `${html}<!--kitenote-md:${toBase64(md)}-->`
}
三、表格:所见即所得,但存下来还是 Markdown
表格是花时间最多的部分。鼠标悬停时,列上方、行左侧出现手柄,点一下插入、删除、对齐;拖动边线调列宽;单元格里也能放图片。

麻烦在于 Markdown 表格存不了列宽,也不支持合并单元格。我的处理是:
- 普通表格存成标准 Markdown 表格;
- 调过列宽或合并过单元格的,存成 HTML
<table>(Obsidian 能正常显示); - 右键「恢复平均列宽」,又变回 Markdown 表格。
宁可显式换一种格式,也不丢用户调好的布局。
四、两台电脑用同步盘:保存前先比对,绝不覆盖
库就是普通文件夹,放进百度网盘、坚果云、OneDrive 都行。我自己是公司一台、家里一台交替写,这就带来一个问题:A 电脑正在编辑某篇笔记,同步盘这时把 B 电脑改过的版本拉了下来。如果编辑器按原来的逻辑自动保存,B 的改动就被 A 覆盖了,同步盘再把 A 的版本传回去,B 白天写的内容就没了。
解决办法:每次保存前,先比对磁盘上的内容和打开时读到的版本。
ts
async writeChecked(rel: string, content: string, expected: string | null, device = '') {
let current: string | null
try { current = await this.read(rel) } catch { current = null }
if (current === content) return null
if (current === null || current === expected) {
await this.write(rel, content) // 没被外部改过:正常保存
return null
}
const copy = await this.conflictPath(rel, device)
await this.write(copy, content, true) // 被改过:原文件不动,本机内容另存
return copy // 界面据此提示用户合并
}
- 磁盘内容和打开时一样:正常保存;
- 被外部改过:原文件保留同步来的版本,本机改动另存为
xxx (冲突副本 电脑名 时间).md,编辑器重新加载并弹出提示; - 文件被外部删了:照常写回,正在编辑的内容不丢。
另外两个配套细节:
- 写入用「临时文件 + 改名」,避免同步盘读到写了一半的文件;
- 文件监听忽略自己 1.5 秒内的写入,否则自己保存一次,就会被当成「外部修改」又重新加载一次。
最后
KiteNote 目前只有 Windows 版,界面只有中文,代码在 GitHub 上:
如果你也在用 Obsidian,欢迎拿自己的库试试,哪种写法被改坏了,麻烦在 Issues 里贴个例子。觉得有用的话点个 Star,谢谢。