在 tldraw 中为调色板运行时添加自定义颜色与字体:color-picker 示例的完整实现剖析
tldraw 官方仓库提供了一套成熟的自定义主题体系:你可以通过模块增广(module augmentation)扩展颜色与字体的"槽位",再用稳定的 themes prop 预先注册全部槽位,最后借助 editor.updateTheme() 把用户实时选取的颜色与字体推入活动主题。本文以仓库内 color-picker 示例 为蓝本,结合其完整实现 ColorPickerExample.tsx,逐层拆解"自定义调色板 + 自定义字体面板 + 持久化 + 撤销重做"这一整套可复用的工程方案。读完本文,你将掌握如何在自己基于 tldraw SDK 的应用中安全地扩展默认样式枚举、让自定义样式随文档持久化与跨标签页同步,并在清除自定义样式时修复引用它们的图形。
示例要解决的问题
默认情况下,tldraw 编辑器只提供一套固定的内置颜色(black、grey、blue、red……)与四种内置字体(draw、sans、serif、mono),样式面板中的可选项也是据此生成的。color-picker 示例演示的是一个高频业务诉求:
让用户像往"我的颜色"里存色号一样,在运行期往编辑器的主题调色板里追加自己的颜色与字体,而不是在代码里硬编码一个静态主题。
示例在画布左上角放了一个小型工具栏(由 color-picker.css 负责定位与外观),包含三类按钮:
- + Add color:通过原生
<input type="color">取色器取一个十六进制色值,追加为新颜色; - + Add font:通过一个精选的 Google Fonts 下拉菜单,追加一种字体;
- Clear custom styles:当存在自定义颜色或字体时出现,一键清空并复位所有引用它们的图形。
每条新记录会立即出现在右侧样式面板中,并且直接应用到当前选中的图形上。README 末尾还给出了一个很关键的验收动作:添加一种颜色 → 应用到图形 → 按一次撤销,观察颜色槽位与使用它的图形被作为一个整体回退。
理解 tldraw 的主题与"槽位"模型
要读懂这个示例,先要理解 tldraw 的主题数据结构。核心类型全部定义在 schema 包中:packages/tlschema/src/styles/TLTheme.ts 内给出了三层结构。
单个颜色 TLDefaultColor(见 TLTheme.ts)不是一个字符串,而是一组语义角色,分别对应描边、填充、便签背景、选中框等不同渲染上下文:
| 角色 | 用途 |
|---|---|
solid |
实色主体 |
semi |
半透明变体,用于半透明填充 |
pattern |
纹理/图案用色 |
fill / linedFill |
填充色与其浅色变体 |
frameHeadingStroke / frameHeadingFill / frameStroke / frameFill / frameText |
边框组件(frame)的各类上下文 |
noteFill / noteText |
便签(note)的底色与文字 |
highlightSrgb / highlightP3 |
高亮笔的 sRGB 与 P3 色域值 |
颜色调色板 TLThemeDefaultColors 除了一组必须存在的 UI 基础色(text、background、selectionStroke、brushFill 等,见 TLThemeUiColorKeys),还按名字挂载上述的 TLDefaultColor。字体调色板 TLThemeFonts 默认只有 draw、sans、serif、mono 四种键,每个值 TLThemeFont 是 { fontFamily, faces?, icon? }(见 TLThemeFont)。一个完整主题 TLTheme 则由 id、fontSize、lineHeight、strokeWidth、fonts 与 colors: { light, dark } 组成(见 TLTheme)。
关键点:默认类型里这些键是封闭的,类型系统不知道 custom-2 或 gf-1 是什么。示例中的所有自定义内容,本质上都是在回答"怎么安全地往这套封闭体系里加键"。
第一步:用模块增广把"自定义槽位"告诉类型系统
示例给用户预留了两种槽位:20 个自定义颜色槽位(custom-1 到 custom-20)与 10 个自定义字体槽位(gf-1 到 gf-10,gf 即 Google Font),并以 CustomColorKey、CustomFontKey 两个字符串字面量联合类型记录下来。
真正让这些键"合法化"的是对 @tldraw/tlschema 的模块增广(见 ColorPickerExample.tsx):
declare module '@tldraw/tlschema' {
interface TLThemeDefaultColors {
'custom-1': TLDefaultColor
// ... 直到 custom-20
}
interface TLThemeFonts {
'gf-1': TLThemeFont
// ... 直到 gf-10
}
}
tlschema 官方文档注释 说明这正是为扩展设计的入口——通过增广 TLThemeDefaultColors / TLThemeFonts,即可向调色板加入自定义颜色与字体。type 注释同时点出了原因(见 ColorPickerExample.tsx):运行时只会真正填充用户添加过的槽位,但 TypeScript 需要具体的键才能推导出 TLDefaultColorStyle / TLDefaultFontStyle 这类联合类型。
第二步:用稳定的 themes prop 预注册全部槽位,保证持久化校验通过
增广解决了编译期类型问题,但还有更隐蔽的运行期校验问题:tldraw 的 shape 记录把样式值(例如 color: "custom-2")持久化在 store 里,schema 在加载时会对这些值做枚举校验。如果某个存盘文档引用了引擎不认识的 custom-5,加载就会失败或被清洗。
示例的策略是:把所有槽位"先全部注册、再选择性填充"。它用 useState 生成一个只计算一次的完整主题(占位槽位全部填黑色与 sans-serif),作为稳定的 themes prop 传给 <Tldraw>(见 ColorPickerExample.tsx 与 组件渲染部分):
const [initialThemes] = useState<Partial<TLThemes>>(() => ({ default: buildCompleteTheme() }))
为什么要稳定且完整?源码中有两处注释把底层机制讲得很透:
- TldrawEditor.tsx 里,编辑器挂载时会调用
resolveThemes(...)后执行registerColorsFromThemes(resolvedThemes)与registerFontsFromThemes(resolvedThemes),并注明:"用户也应该把 themes 传给 createTLStore,以便在数据载入 store 之前完成注册"; - createTLStore.ts 在构造 store、加载持久化文档之前就调用了同样的注册函数。
也就是说,themes prop 的槽位会先于"读盘 → 校验"被执行。完整注册带来的三个直接收益(见 buildCompleteTheme 的注释):
- 持久化的、或来自其他标签页的图形永远不可能引用枚举不认识的槽位,加载不会崩、也不会在每次渲染被丢弃;
- 枚举从不被"收窄",因此后续增删调色板条目不会反过来使存量图形失效;
- 占位值永远不会被用户看到——样式面板与画布读取的是下方将提到的 live theme,其中只包含调色板里真实存在的槽位。
第三步:editor.updateTheme() 把真实值推入活动主题
注册归注册,展示归展示。真正的可见颜色/字体由 buildTheme(hexes, fonts) 构造,再通过 editor.updateTheme() 覆盖进名为 default 的活动主题。
buildTheme 会以 DEFAULT_THEME 为底,分别拷贝出 light/dark 两套颜色与字体表,再只把调色板里实际存在的槽位填进去(见 ColorPickerExample.tsx)。其中单个十六进制色值如何变成一个完整的 TLDefaultColor,由 makeColor 负责(见 makeColor):用一个十六进制色同时填充所有"实色"角色(solid、pattern、fill、frameStroke、noteText 等),再用 solid + '33' 生成 20% 透明度的半透明变体去填充所有"semi/浅色"角色(semi、linedFill、noteFill 等):
function makeColor(solid: string): TLDefaultColor {
const translucent = solid + '33'
return { solid, semi: translucent, pattern: solid, fill: solid, /* ... */ }
}
对应的字体条目则只包含 fontFamily 与一个 "Aa" 预览图标(见 makeFontEntry)。
editor.updateTheme() 是 Editor 的公开 API(见 Editor.ts),底层由 ThemeManager.updateTheme 实现——它把整个主题按 id 存入 _themes 这个响应式 Atom(见 ThemeManager.ts),因此样式面板、画布都会立即响应。
编辑器 onMount 后、以及每次调色板变化时都会重新执行这段同步逻辑(见 ColorPickerExample.tsx):
useEffect(() => {
if (!editor) return
editor.updateTheme(buildTheme(palette.hexes, palette.fonts))
if (palette.fonts.length > 0) ensureGoogleFontsLoaded()
}, [editor, palette])
值得一提的是静态注册与动态展示被刻意分离了:themes prop 里注册的是"完整但占位"的主题(校验用),updateTheme 推入的是"真实且精简"的主题(展示用)。两者共用一个 buildTheme,只是入参不同——buildCompleteTheme 用 20 个 #000000 与 10 个 sans-serif 占位填充(见 ColorPickerExample.tsx)。
第四步:把调色板本体存进文档 meta
自定义颜色/字体的槽位键被写进了 shape 的样式属性,但"每个 custom-* 槽位到底映射到哪个 hex / 哪个字体族"这份映射关系存在哪里?示例选择了一个非常巧妙的落点:文档级记录(document record)的 meta。
相关读写函数见 readRawPalette 与 writePalette。写入走的是 store 的原子更新,把调色板合并进 meta.colorPickerPalette:
function writePalette(editor: Editor, palette: Palette) {
editor.store.update(TLDOCUMENT_ID, (doc) => ({
...doc,
meta: { ...doc.meta, [PALETTE_META_KEY]: palette as unknown as JsonObject },
}))
}
这份实现注释把设计动机讲得非常直白(见 ColorPickerExample.tsx):调色板不放在 React state 或独立的 localStorage 副本里,而是放进文档本身,是因为文档会随 persistenceKey 自动持久化、由 store 的同步机制自动跨标签页传播、并被并入统一的撤销/重做历史——三者零额外管道,且永远与引用它的图形原子地绑定在一起。
读取侧同样是响应式的:useValue 订阅 editor.getDocumentSettings().meta[PALETTE_META_KEY],useMemo 再做一次规整(见 ColorPickerExample.tsx)。由于 meta 是自由 JSON,读取时必须宽容:parsePalette 会校验数组元素类型并截断到上限,以防御手改或损坏的文档(见 parsePalette)。
第五步:清除时的"修复"逻辑——样式槽位与图形的同步回退
新增容易,清空却有个容易踩的坑:一旦调色板被清空,custom-* / gf-* 槽位会从 live theme 中消失,但之前引用了这些槽位的图形(以及"下一个图形将使用的样式" stylesForNextShape)仍然指着它们,会渲染成空槽位。
示例的解法是在同一个 editor.run 批处理里同时做两件事(见 clearCustomStyles):
editor.run(() => {
repairShapesUsingCustomStyles(editor)
writePalette(editor, { hexes: [], fonts: [] })
})
repairShapesUsingCustomStyles 会扫描 store 里所有 shape,把 color / labelColor 指向 custom-* 的、以及 font 指向 gf-* 的,统一改回内置默认值(black / draw,见 DEFAULT_COLOR/DEFAULT_FONT 定义 与 repairShapesUsingCustomStyles);同时对每个标签页独立的 stylesForNextShape 做同样的复位(见 ColorPickerExample.tsx)。
结合 README 的验收步骤就能理解这一设计的用意:把"写空调色板"和"复位引用图形"放进同一个 editor.run,它们就形成一个撤销单元——undo 时两者一起恢复,redo 时两者一起清空,调色板永远不会与引用它的图形发生漂移。
第六步:工具栏 UI——原生取色器、字体下拉与界面文案
工具栏本身是渲染在 <Tldraw> 内部的普通 React DOM,并通过 onPointerDown={(e) => e.stopPropagation()} 阻断事件落到画布上(见 ColorPickerExample.tsx)。
取色器的 staging 交互(见 AddColorButton):原生 <input type="color"> 被绝对定位且透明化隐藏(见 color-picker.css),点击 "+ Add color" 时通过 inputRef.current?.click() 唤起系统取色器。由于原生取色器在拖动时会持续触发 onChange,直接提交会造成"拖到一半就加进去"的误操作,所以它把当前值放入 staged 状态做预览(色板色块 + 大写的 hex 文本),只有点击明确的 Add 按钮才真正调用 addColor(staged) 提交;取消按钮则随时可以放弃本次选择。
提交动作 addColor(见 ColorPickerExample.tsx)演示了"一次 editor.run 搞定三件事"的典型写法:写入新 palette → updateTheme 把新颜色放入 live theme(这样选中图形立刻以新色重绘)→ 若存在选中图形,则通过 editor.setStyleForSelectedShapes(DefaultColorStyle, 'custom-N') 直接把新槽位赋给选中图形。注释特别强调:因为完整 themes prop 已经让枚举认识该槽位,这次的样式赋值不会触发校验失败(见 ColorPickerExample.tsx)。
字体下拉(见 AddFontButton)则是"一次点击 = 一次提交"的原子操作,不需要 staging;已添加过的字体会在列表里显示并置灰(Added 角标)。字体真正可被渲染依赖两件事:
ensureGoogleFontsLoaded()动态向<head>注入一条<link>,一次性加载全部候选字体族(见 ColorPickerExample.tsx),这样下拉预览与画布文字才能以目标字体族呈现;- 字体族字符串随
gf-N槽位进入 live theme,文本工具拿到fontFamily即可使用。
界面文案:gf-N、custom-N 这类程序化槽位名不能直接展示给用户。示例用 TLUiOverrides 的 translations 钩子批量生成了 color-style.custom-N → "Custom N" 与 font-style.gf-N → "Google font N" 两套文案(见 ColorPickerExample.tsx),让样式面板下拉里显示的是人类可读名称。
槽位上限与整体数据流总结
整个方案的参数约束非常清晰,两个常量把扩展边界定死(见 ColorPickerExample.tsx):
MAX_CUSTOM_COLORS = 20:最多 20 个自定义颜色槽位,addColor在达到上限后直接返回,对应按钮也被disabled并给出提示文案;MAX_CUSTOM_FONTS = 10:最多 10 个自定义字体槽位,行为同上。
从代码路径可以完整还原一条数据流:
原生取色器 / Google 字体下拉
→ editor.run {
writePalette(把 palette 写入 document.meta) // 持久化 / 跨标签同步 / 撤销重做
editor.updateTheme(buildTheme(...)) // 推入 live theme,样式面板与画布即时响应
setStyleForSelectedShapes(...) // 应用到选中图形(如存在)
}
→ useValue 响应式读回 meta → useMemo 规整 → useEffect 再次 updateTheme(覆盖 undo/redo 回放)
想实际体验时,在 apps/examples 示例应用中找到本示例(它的 README frontmatter 里注册了 ColorPickerExample.tsx 组件与 color, picker, theme, palette 等检索关键词),即可操作左上角工具栏。你可以在本机画布上完成 README 建议的完整闭环:添加一个颜色 → 应用到某个图形 → 按下撤销,观察该颜色槽位与使用它的图形作为同一撤销单元被一起回退;随后新开一个标签页,验证调色板已通过 color-picker-example 这个 persistenceKey(见 ColorPickerExample.tsx)持久化并同步到所有标签页。若要照搬这套模式到自己的产品里,只需替换三处:槽位命名与数量、makeColor 想呈现的角色变体、以及你自己的 persistenceKey。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0631
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
video-shotcraftAI宣传片skill,使用 Remotion 制作电影级产品视频:提供106 张镜头配方卡和可复用的视频魔板。适用于 Claude Code 与 Codex以及所有其他智能体Markdown00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python09
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00