首页
/ 在 tldraw 中为调色板运行时添加自定义颜色与字体:color-picker 示例的完整实现剖析

在 tldraw 中为调色板运行时添加自定义颜色与字体:color-picker 示例的完整实现剖析

2026-09-08 12:42:22作者:江焘钦

tldraw 官方仓库提供了一套成熟的自定义主题体系:你可以通过模块增广(module augmentation)扩展颜色与字体的"槽位",再用稳定的 themes prop 预先注册全部槽位,最后借助 editor.updateTheme() 把用户实时选取的颜色与字体推入活动主题。本文以仓库内 color-picker 示例 为蓝本,结合其完整实现 ColorPickerExample.tsx,逐层拆解"自定义调色板 + 自定义字体面板 + 持久化 + 撤销重做"这一整套可复用的工程方案。读完本文,你将掌握如何在自己基于 tldraw SDK 的应用中安全地扩展默认样式枚举、让自定义样式随文档持久化与跨标签页同步,并在清除自定义样式时修复引用它们的图形。

示例要解决的问题

默认情况下,tldraw 编辑器只提供一套固定的内置颜色(black、grey、blue、red……)与四种内置字体(drawsansserifmono),样式面板中的可选项也是据此生成的。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 基础色(textbackgroundselectionStrokebrushFill 等,见 TLThemeUiColorKeys),还按名字挂载上述的 TLDefaultColor字体调色板 TLThemeFonts 默认只有 drawsansserifmono 四种键,每个值 TLThemeFont{ fontFamily, faces?, icon? }(见 TLThemeFont)。一个完整主题 TLTheme 则由 idfontSizelineHeightstrokeWidthfontscolors: { light, dark } 组成(见 TLTheme)。

关键点:默认类型里这些键是封闭的,类型系统不知道 custom-2gf-1 是什么。示例中的所有自定义内容,本质上都是在回答"怎么安全地往这套封闭体系里加键"。

第一步:用模块增广把"自定义槽位"告诉类型系统

示例给用户预留了两种槽位:20 个自定义颜色槽位custom-1custom-20)与 10 个自定义字体槽位gf-1gf-10,gf 即 Google Font),并以 CustomColorKeyCustomFontKey 两个字符串字面量联合类型记录下来。

真正让这些键"合法化"的是对 @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 的注释):

  1. 持久化的、或来自其他标签页的图形永远不可能引用枚举不认识的槽位,加载不会崩、也不会在每次渲染被丢弃;
  2. 枚举从不被"收窄",因此后续增删调色板条目不会反过来使存量图形失效;
  3. 占位值永远不会被用户看到——样式面板与画布读取的是下方将提到的 live theme,其中只包含调色板里真实存在的槽位。

第三步:editor.updateTheme() 把真实值推入活动主题

注册归注册,展示归展示。真正的可见颜色/字体由 buildTheme(hexes, fonts) 构造,再通过 editor.updateTheme() 覆盖进名为 default 的活动主题。

buildTheme 会以 DEFAULT_THEME 为底,分别拷贝出 light/dark 两套颜色与字体表,再只把调色板里实际存在的槽位填进去(见 ColorPickerExample.tsx)。其中单个十六进制色值如何变成一个完整的 TLDefaultColor,由 makeColor 负责(见 makeColor):用一个十六进制色同时填充所有"实色"角色(solidpatternfillframeStrokenoteText 等),再用 solid + '33' 生成 20% 透明度的半透明变体去填充所有"semi/浅色"角色(semilinedFillnoteFill 等):

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

相关读写函数见 readRawPalettewritePalette。写入走的是 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 角标)。字体真正可被渲染依赖两件事:

  1. ensureGoogleFontsLoaded() 动态向 <head> 注入一条 <link>,一次性加载全部候选字体族(见 ColorPickerExample.tsx),这样下拉预览与画布文字才能以目标字体族呈现;
  2. 字体族字符串随 gf-N 槽位进入 live theme,文本工具拿到 fontFamily 即可使用。

界面文案gf-Ncustom-N 这类程序化槽位名不能直接展示给用户。示例用 TLUiOverridestranslations 钩子批量生成了 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

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
docsdocs
暂无描述
Markdown
899
5.83 K
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.14 K
2.76 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
860
1.35 K
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
925
1.85 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.84 K
1.02 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
533
601
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.37 K
1.46 K
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
548
395
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.04 K
525