记录一次把 tldraw 画板嵌入 Markdown 编辑器的完整过程,包括体积焦虑、CSS 丢失、快照崩坏、白屏、License 报错等 10 个真实坑。
一、需求:笔记里能画图
我的笔记应用是基于 Next.js 15 + Dexie + Cloudflare D1 的"离线优先"架构。核心诉求:
- 双击可编辑,编辑完就保存回笔记
- 不占数据库,画板数据作为笔记内容的一部分
- 不卡,首屏不能因为画板库而变慢
- 纯离线,Electron 打包后也能用
先后评估了 4 个方案:
| 方案 | 体积 | 结论 |
|---|---|---|
| Excalidraw | 12MB | ❌ 太大,且和 React 18 冲突 |
| Mermaid | 300KB | ❌ 只能代码画图,和已有的 dot 重复 |
| 自研 Canvas 画板 | 5KB | ⚠️ 只够涂鸦,没预装图形 |
| tldraw | ~30KB gzip | ✅ 预装图形完整,React 原生组件 |
最终选了 tldraw。下面是踩坑全记录。
二、架构设计:画板 = 笔记里的代码块
核心思路:画板数据就是一段 JSON,作为 Markdown 代码块的一部分存在笔记里。
今天的思考:
```tldraw
{"document":{"store":{"shape:xxx":{...}}}}
```
继续写别的……
这样做的好处:
- 零 schema 变更:D1 的
Notes.content字段不用改 - 天然同步:随笔记内容走
useNoteSync→ Dexie → D1 - 天然备份:本地文件夹备份、导出 JSON 都自动包含
- 天然版本控制:将来做版本历史,画板一起进快照
代价:长笔记里的 JSON 会很占字符数。一个中等复杂度的画板大概 5~20KB,如果笔记里放 10 个画板就是 200KB。同步压力可接受,但要注意。
三、坑 0(踩得最深):License 不是可选项
这个坑放在最前面,因为它差点让整个集成前功尽弃。
tldraw 从 4.0 起转为商业授权模式。关键规则:
- 开发环境(
NODE_ENV=development):无需 license,随便用 - 生产环境(
next build后的一切场景):必须提供 license key,否则:- 水印 + 几秒后画板 canvas 被内部逻辑清空
- Console 报
No tldraw license key provided! - DOM 里出现
<div data-testid="tl-license-expired"> - 表现:画板瞬间消失,看起来像白屏
最容易踩的是:本地 npm run dev 一切正常,一 npm run build:web 部署到线上就崩,127.0.0.1:8788 也崩——因为它判断的是 NODE_ENV,不是"本地/线上"。
解决方案:申请 Hobby License(永久免费)
访问 https://tldraw.dev/pricing:
- ❌ 100-day free trial:100 天后付费,不适合长期个人项目
- ❌ Commercial license:付费,按价值定价
- ✅ Hobby License:永久免费,个人/非商业项目适用
申请表单填写:
| 字段 | 值 |
|---|---|
| 你的邮箱 | |
| Project website | https://your-domain.com |
| Project domain | your-domain.com(不要带 https://) |
| Project description | 强调 personal use / no commercial / no paid users / not distributed for sale |
审核期间,官方会先发一个 14 天临时 key 让你继续开发。等正式审批通过,会发永久 key,换掉即可。
代码接入:localStorage + 环境变量双支持
关键难点:Web 部署可以用环境变量,Electron 打包用不了——所以必须支持用户在设置页自己填。
优先级:localStorage > 环境变量,兼顾两种场景。
// TLDrawBlock.tsx 里
const [storedLicense, setStoredLicense] = useState<string>('');
useEffect(() => {
const read = () => {
setStoredLicense(localStorage.getItem('tldraw_license_key') || '');
};
read();
window.addEventListener('storage', read); // 跨 tab
window.addEventListener('tldraw-license-changed', read); // 同 tab
return () => {
window.removeEventListener('storage', read);
window.removeEventListener('tldraw-license-changed', read);
};
}, []);
const tldrawLicense =
storedLicense || (process.env.NEXT_PUBLIC_TLDRAW_LICENSE_KEY as string) || '';
// 两处 <Tldraw> 都带上
<Tldraw licenseKey={tldrawLicense} ... />
设置页加一个输入框:
localStorage.setItem('tldraw_license_key', tldrawLicenseKey);
window.dispatchEvent(new Event('tldraw-license-changed')); // 立即生效,无需刷新
Cloudflare Pages 环境变量(Web 部署):
NEXT_PUBLIC_TLDRAW_LICENSE_KEY = tldraw-xxxxx-xxxxx-xxxxx
Electron 打包:用户在设置页粘贴一次,存 localStorage。
这一套下来,Web / Electron / 本地预览三种环境全覆盖。
四、坑 1:装包就是第一条红线
npm install tldraw
别装 @tldraw/tldraw(旧包名),tldraw 3.x 的官方包就是 tldraw。
装完验证版本:
cat node_modules/tldraw/package.json | grep '"version"'
版本决定 API:
- 3.x+:
editor.getSnapshot()/editor.loadSnapshot()/editor.zoomToFit({ animation, padding }) - 2.x:API 名称类似但签名不同(
zoomToFit({ duration, padding }))
版本还决定 license 策略:
- 2.x:MIT 协议,永久免费,但旧数据格式和 3.x+ 不兼容
- 3.x:无 license 要求(可带水印)
- 4.x+:商业授权,生产环境必须 license
五、坑 2:CSS 必须手动引入
tldraw 内部虽然写了 import './tldraw.css',但通过 next/dynamic 动态导入时,CSS 会被 tree-shake 掉。
表现:tldraw 组件渲染了,但全是白屏,Console 无任何报错。这是最迷惑的一个坑。
解决:在 src/app/globals.css 顶部手动引入:
@import 'tldraw/tldraw.css';
@import "tailwindcss";
/* 其他样式 */
不要只在组件里 import 'tldraw/tldraw.css'——动态导入时同样可能失效。
六、坑 3:next.config.js 加 transpilePackages
tldraw 是纯 ESM 包,Next.js 有时不认:
// next.config.js
const nextConfig = {
transpilePackages: ['tldraw'], // ✅ 加上
// ...
};
改完必须重启 dev server(next.config.js 不会热更新)。
七、坑 4:容器塌缩成一条竖线
tldraw 不会自动撑满父容器。如果你把它放在一个 <div className="flex-1"> 里,它会塌缩到 0 高度,视觉上是"一条竖线"。
正确写法:外层 relative + 内层 absolute inset-0:
<div className="flex-1 relative">
<div style={{ position: 'absolute', inset: 0 }}>
<Tldraw ... />
</div>
</div>
inset: 0 等价于 top: 0; right: 0; bottom: 0; left: 0,是解决塌缩的决定性修复。
八、坑 5:快照格式错误导致崩溃
我第一版给空画板的模板是:
{"store": {}}
结果 tldraw 直接弹 Something went wrong 页面。原因是 tldraw 3.x 的快照结构必须是:
{
"document": {
"store": { "document:xxx": {...}, "shape:xxx": {...} },
"schema": { ... }
},
"session": { ... }
}
正确做法:
- 空内容:
jsonContent直接为空字符串,识别为"空画板"显示提示,不要传假快照给 tldraw - 加载快照:用
editor.loadSnapshot(snapshot)而不是snapshotprop,加上try/catch兜底
function loadAndFit(editor: any, snapshot: any) {
try {
editor.loadSnapshot(snapshot);
} catch (e) {
console.warn('loadSnapshot 失败:', e);
return;
}
// 等一帧让 shape 渲染,再 zoomToFit
setTimeout(() => {
if (editor.getCurrentPageShapeIds().size > 0) {
editor.zoomToFit({ animation: { duration: 0 }, padding: 40 });
}
}, 80);
}
九、坑 6:预览区一片空白
画好的图形,在预览区只看到空白。原因是 tldraw 保持上次的相机位置,如果图形在相机视野外就看不到。
解决:加载完快照后调用 editor.zoomToFit()。
关键细节:
setTimeout 80ms:loadSnapshot是异步的,立刻zoomToFit会得到空 boundspadding: 40:四周留白,图形不顶边animation: { duration: 0 }:预览区不需要动画,瞬间到位
保存时也要清理 session:
const cleanSnap = { document: snap.document };
丢掉 session(包含相机位置、选中态),下次打开重新 zoomToFit。代价是用户上次的缩放级别丢失,但换来"永远看到全景"——对笔记场景,内容可见 > 状态保持。
十、坑 7:预览区 UI 藏不掉
hideUi prop 有时不生效(版本不同行为不同)。稳妥做法是 CSS 强隐藏:
<style jsx global>{`
.preview-tldraw .tlui-layout__top,
.preview-tldraw .tlui-layout__bottom,
.preview-tldraw .tlui-menu-zone,
.preview-tldraw .tlui-style-panel,
.preview-tldraw .tlui-navigation-panel,
.preview-tldraw .tlui-help-menu,
.preview-tldraw .tlui-debug-panel,
.preview-tldraw .tl-watermark_SEE-LICENSE {
display: none !important;
}
`}</style>
再配合 pointerEvents: 'none',预览区就变成纯静态展示了。
十一、坑 8:多画板的正则冲突
onSave 回写时用的是:
const regex = /```tldraw[\s\S]*?```/;
这个正则只匹配第一个 tldraw 块。如果笔记里有 2 个画板,编辑第 2 个会误改第 1 个。
坑中坑:如果你给每个画板做了 signature 匹配(比如用内容 hash),渲染时和 onSave 时的算法必须一致:
// 渲染时
const signature = simpleHash(blockRawCode);
// onSave 时(必须用同一个算法!)
const innerSig = simpleHash(String(inner).trim());
if (innerSig === signature) { /* 替换 */ }
不要一边用 slice(0, 64)、另一边用 simpleHash——永远匹配不上,表现为"编辑保存没反应"。
长期方案:给代码块加唯一 ID:
```tldraw id=abc123
{...}
```
解析时读 id,保存时按 id 匹配替换。
十二、坑 9:5.x → 2.x 降级导致旧数据无法打开
如果一开始用 5.x 画了图,后来因为 license 问题降级到 2.4.6,会遇到:
Error migrating store Incompatible schema?
Failed to migrate store: migration-error
原因:5.x 和 2.x 的快照 schema 版本号不同:
| 字段 | 2.4.6 | 5.x |
|---|---|---|
com.tldraw.store |
4 | 5 |
com.tldraw.instance |
25 | 26 |
com.tldraw.shape.geo |
9 | 12 |
旧数据直接崩。
临时方案:加 checkSnapshot 检测 + 友好提示,不崩就行:
function checkSnapshot(jsonContent: string): { ok: boolean; data: any; reason?: string } {
// ... 解析逻辑
const storeSeq = parsed.document?.schema?.sequences?.['com.tldraw.store'];
if (typeof storeSeq === 'number' && storeSeq >= 5) {
return {
ok: false,
data: null,
reason: `此画板由 tldraw 3.x+ 创建(schema v${storeSeq}),当前版本无法打开。`,
};
}
return { ok: true, data: parsed };
}
正解:别降级,去申请 Hobby License,留在 5.x。
十三、最终代码骨架
// src/components/TLDrawBlock.tsx
'use client';
import 'tldraw/tldraw.css';
import React, { useMemo, useRef, useState, useCallback, useEffect } from 'react';
import dynamic from 'next/dynamic';
const Tldraw = dynamic(
() => import('tldraw').then((mod) => mod.Tldraw),
{ ssr: false }
);
function isValidSnapshot(data: any) {
return !!(data?.document?.store && Object.keys(data.document.store).length > 0);
}
function loadAndFit(editor: any, snapshot: any) {
try { editor.loadSnapshot(snapshot); } catch {}
setTimeout(() => {
if (editor.getCurrentPageShapeIds().size > 0) {
// ✅ 5.x 参数格式
editor.zoomToFit({ animation: { duration: 0 }, padding: 40 });
}
}, 80);
}
export function TLDrawBlock({ jsonContent, onSave }: TLDrawBlockProps) {
const [isEditing, setIsEditing] = useState(false);
const [isMaximized, setIsMaximized] = useState(false);
const editorRef = useRef<any>(null);
// ✅ License 状态
const [storedLicense, setStoredLicense] = useState<string>('');
useEffect(() => {
const read = () => setStoredLicense(localStorage.getItem('tldraw_license_key') || '');
read();
window.addEventListener('storage', read);
window.addEventListener('tldraw-license-changed', read);
return () => {
window.removeEventListener('storage', read);
window.removeEventListener('tldraw-license-changed', read);
};
}, []);
const tldrawLicense =
storedLicense || (process.env.NEXT_PUBLIC_TLDRAW_LICENSE_KEY as string) || '';
const snapshot = useMemo(() => {
try {
const parsed = JSON.parse(jsonContent);
return isValidSnapshot(parsed) ? parsed : null;
} catch { return null; }
}, [jsonContent]);
const handleSave = useCallback(() => {
if (!editorRef.current) return setIsEditing(false);
try {
const snap = editorRef.current.getSnapshot();
onSave(JSON.stringify({ document: snap.document }));
} catch (e) {
console.error('保存失败:', e);
} finally {
setIsEditing(false);
}
}, [onSave]);
return (
<>
{/* 预览区 */}
<div
className="my-4 border rounded-lg overflow-hidden bg-white relative"
style={{ height: 400 }}
onDoubleClick={() => setIsEditing(true)}
>
{snapshot ? (
<div className="absolute inset-0 preview-tldraw" style={{ pointerEvents: 'none' }}>
<Tldraw
licenseKey={tldrawLicense} {/* ✅ 关键 */}
onMount={(e) => loadAndFit(e, snapshot)}
hideUi
/>
</div>
) : (
<div className="w-full h-full flex items-center justify-center text-gray-400">
🎨 空画板 · 双击开始绘画
</div>
)}
</div>
{/* 编辑 Modal */}
{isEditing && (
<div className="fixed inset-0 z-[9999] bg-black/60 flex items-center justify-center p-4">
<div
className="bg-white rounded-xl shadow-2xl flex flex-col overflow-hidden"
style={{
width: isMaximized ? '100vw' : '100%',
maxWidth: isMaximized ? '100vw' : '80rem',
height: isMaximized ? '100vh' : '85vh',
}}
>
<div className="flex items-center justify-between p-3 border-b">
<h3 className="font-bold">编辑画板</h3>
<div className="flex gap-2">
<button onClick={() => setIsMaximized(v => !v)}>
{isMaximized ? '🗗 还原' : '⛶ 最大化'}
</button>
<button onClick={() => setIsEditing(false)}>取消</button>
<button onClick={handleSave}>保存并关闭</button>
</div>
</div>
<div className="flex-1 relative">
<div style={{ position: 'absolute', inset: 0 }}>
<Tldraw
licenseKey={tldrawLicense} {/* ✅ 关键 */}
onMount={(editor: any) => {
if (snapshot) loadAndFit(editor, snapshot);
editorRef.current = editor;
}}
/>
</div>
</div>
</div>
</div>
)}
<style jsx global>{`
.preview-tldraw .tlui-layout__top,
.preview-tldraw .tlui-layout__bottom,
.preview-tldraw .tlui-menu-zone,
.preview-tldraw .tlui-style-panel {
display: none !important;
}
`}</style>
</>
);
}
十四、性能实测
| 指标 | 数值 |
|---|---|
| tldraw 包大小(gzip) | ~30KB(核心)+ 懒加载的字体/语言包 |
| 首屏影响 | 0(动态导入,只有双击才加载) |
| 单个画板渲染时间 | ~200ms(含 zoomToFit) |
| 一篇笔记 3 个画板 | 预览区同时挂 3 个 tldraw 实例,内存 +30MB |
| 建议上限 | 单笔记 ≤ 5 个画板 |
性能优化点:
- 动态导入,编辑器代码只在双击时下载
- 预览区
pointerEvents: none,避免事件冒泡 - 保存时丢弃 session,减小 JSON 体积
十五、注意事项
数据格式不可变
tldraw 的快照结构不同版本不兼容。如果未来 tldraw 升到 6.x,旧的快照数据可能读不出来。
建议:在 JSON 里加一个 version 字段:
{ "version": 1, "document": {...} }
未来迁移时按 version 分支处理。
License 要定期检查
Hobby License 是永久的,但临时 key 会过期(14 天)。申请后:
- 收到永久 key → 替换环境变量 + 设置页里的值
- 忘了换 → 过期后生产环境画板再次失效
建议:设个日历提醒,过期前 3 天换。
分享页也要支持
你的分享页 share/page.tsx 如果没做 tldraw 拦截,访客看到的是一大坨 JSON。需要在分享页的 previewComponents 里也加一段 tldraw 分支,并且也要传 licenseKey(否则分享页画板同样几秒后消失)。
Electron 打包
next build 会把 tldraw 的 chunk 打进 .vercel/output。因为用了动态导入,主 bundle 不受影响,但离线包里会多几 MB。可接受。
Electron 场景的 license 只能靠设置页输入——环境变量打包时不生效。
十六、最终效果
集成完成后的界面是这样的:

十七、总结
| 阶段 | 耗时 | 关键坑 |
|---|---|---|
| 选型 | 半天 | Excalidraw 12MB / Mermaid 和 dot 重复 |
| 初次集成 | 1 小时 | 容器塌缩成竖线 |
| CSS 调试 | 2 小时 | 动态导入导致 CSS 丢失 |
| 快照崩溃 | 1 小时 | 假快照 {store:{}} 引发崩溃 |
| zoomToFit | 1 小时 | loadSnapshot 后需等一帧 |
| 打磨(最大化/UI 隐藏) | 1 小时 | CSS 隐藏优先于 hideUi |
| License 折腾 | 半天 | 5.x 商业授权,申请 Hobby License |
| 降级/回退 | 1 小时 | 2.x 和 5.x 快照不兼容 |
总计约 2 个工作日,换来一个 30KB 的、离线可用的、双击可编辑的矢量画板。
对比 Excalidraw 的 12MB,体积小 400 倍,功能覆盖 80% 的日常画图场景。对笔记应用而言,这笔账算得很值——前提是拿到免费的 Hobby License。
最后的忠告:
- 遇到白屏先查 CSS
- 遇到崩溃先查快照格式
- 遇到塌缩先加
absolute inset-0 - 遇到"本地正常线上挂"——先查 license
这 4 条能解决 95% 的问题。
特别是第 4 条:tldraw 4.0+ 商业化后,"dev 正常、build 挂"成为最高频的坑。别等到部署线上才发现——npm run build:web 之后本地跑一次 wrangler pages dev 就能提前暴露。
评论区(0 条)
发表评论⏳ 加载编辑器…