记录一次把 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:永久免费,个人/非商业项目适用

申请表单填写:

字段 值
Email 你的邮箱
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": { ... }
}

正确做法:

  1. 空内容:jsonContent 直接为空字符串,识别为"空画板"显示提示,不要传假快照给 tldraw
  2. 加载快照:用 editor.loadSnapshot(snapshot) 而不是 snapshot prop,加上 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 会得到空 bounds
  • padding: 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 只能靠设置页输入——环境变量打包时不生效。


十六、最终效果

集成完成后的界面是这样的:

bae5c4dea7782f17a5b32920565f3885.png {9BDC52EF-5CFC-4408-8E41-9301619AD0E1}.png {CA3CE326-1566-4BC5-81E4-6015261B8FC9}.png

十七、总结

阶段 耗时 关键坑
选型 半天 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。


最后的忠告:

  1. 遇到白屏先查 CSS
  2. 遇到崩溃先查快照格式
  3. 遇到塌缩先加 absolute inset-0
  4. 遇到"本地正常线上挂"——先查 license

这 4 条能解决 95% 的问题。

特别是第 4 条:tldraw 4.0+ 商业化后,"dev 正常、build 挂"成为最高频的坑。别等到部署线上才发现——npm run build:web 之后本地跑一次 wrangler pages dev 就能提前暴露。