tldraw 自定义笔画宽度:用 StyleProp 与 DrawShapeUtil.configure 将内置 4 档画笔大小替换为 12 档数值选择器
【免费下载链接】tldrawBuild infinite canvas apps in React with the tldraw SDK. World's best, top-most agent recommended #1 five star SDK.项目地址: https://gitcode.com/GitHub_Trending/tl/tldraw
tldraw SDK 默认的 draw(画笔/涂鸦)工具只有s、m、l、xl四档预设粗细,在很多标注、演示、手写板场景中不够用。本文以 tldraw 官方示例库中的 stroke-size-picker 示例(位于 apps/examples/src/examples/ui/stroke-size-picker)为蓝本,完整讲解如何通过StyleProp.define定义数值型样式、借助DrawShapeUtil.configure让渲染/命中测试/图片导出统一使用自定义笔画宽度,并组装一个只对 draw 工具生效的十二档自定义样式面板。读完你可以在自己的 tldraw 应用中实现任意粒度的数值型画笔宽度,并学会一套"自定义 style 样式 + 定制样式面板"的可复用套路。
一、示例目标与背景:为什么默认的画笔粗细不够用
在阅读实现之前,先理解 tldraw 内置大小样式的约束:
- tldraw 内置的
DefaultSizeStyle是一个枚举型样式(enum style),可用值只有['s', 'm', 'l', 'xl']四个,定义位于 packages/tlschema/src/styles/TLSizeStyle.ts:
export const DefaultSizeStyle = StyleProp.defineEnum('tldraw:size', { defaultValue: 'm', values: ['s', 'm', 'l', 'xl'], })- draw 形状的默认笔画宽度正是通过这张枚举表换算出来的。内置常量表定义在 packages/tldraw/src/lib/shapes/shared/default-shape-constants.ts:
export const STROKE_SIZES: Record<TLDefaultSizeStyle, number> = { s: 1, m: 1.75, l: 2.5, xl: 5, }- 该换算在 DrawShapeUtil.tsx 的
options.getDefaultDisplayValues中完成:strokeWidth: theme.strokeWidth * STROKE_SIZES[size]。
本示例的目标正是打破这种"四档枚举 → 换算系数"的间接模型:直接为 draw 形状赋予一个数值型笔画宽度 style(默认 4,可选范围 1~32 共 12 个预设),并让样式面板在"draw 工具处于激活状态或选中了 draw 形状"时显示这 12 个预设点,其它形状(geo、箭头、文字等)继续使用内置的四档尺寸选择器。
示例由三个文件组成:
| 文件 | 作用 |
|---|---|
| StrokeSizePickerExample.tsx | 全部实现逻辑:样式定义、自定义 shape util、选择器组件、面板组装 |
| stroke-size-picker.css | 十二档按钮的网格布局、悬停与激活态样式 |
| README.md | 示例说明与文档元数据 |
二、步骤 1:用 StyleProp.define 定义数值型笔画宽度样式
示例的第一步是定义一个名为example:strokeSize的数值型样式:
const strokeSizeStyle = StyleProp.define('example:strokeSize', { defaultValue: 4, type: T.number, })这里的StyleProp(样式属性)是 tldraw 中一类特殊的形状属性。查看 packages/tlschema/src/styles/StyleProp.ts 中StyleProp类的文档注释,它的特殊之处有两点:
- 同一个值可以同时批量设置到多个形状上(例如全选多个形状后改颜色);
- 最近一次使用的值会被编辑器自动记忆,并应用到之后新画的形状上。
这正是"样式"与普通 props 的本质区别。从源码看,StyleProp.define的签名是(StyleProp.ts 第 47-53 行):
static define<Type>(uniqueId: string, options: { defaultValue: Type; type?: T.Validatable<Type> }) { const { defaultValue, type = T.any } = options return new StyleProp<Type>(uniqueId, defaultValue, type) }要点:
uniqueId必须全局唯一,官方建议用"应用名/库名 + 语义"作为前缀(本示例使用example:前缀,你的应用应替换为自己的命名空间,例如myapp:strokeSize),避免与其它插件或形状 util 冲突;defaultValue是新建 draw 形状时的默认笔画宽度(此处为4);type是可选的数据校验器,用于 store 的 validator 校验持久化数据。示例使用T.number(从tldraw包导出的T,其真实来源是@tldraw/validate)。如果你需要固定取值集合的样式,也可以改用StyleProp.defineEnum(StyleProp.ts 第 75-81 行)。
为什么选择 style 而不是普通 prop?代码注释(示例源码注释 [1])解释得很清楚:正因为它是样式,编辑器才会"记住最近一次值用于下一个新画的形状""在样式面板相关时显示它""当多选形状取值不一致时报告mixed(混合)状态"。如果你把它做成普通 prop,这些机制将全部丢失。
三、步骤 2:通过声明合并(declaration merging)补全类型
为了让shape.props.strokeSize在 TypeScript 下获得完整类型提示,示例使用了模块声明合并:
declare module '@tldraw/tlschema' { interface TLDrawShapeProps { strokeSize: number } }tldraw 的 schema 包(@tldraw/tlschema)中TLDrawShapeProps接口声明了 draw 形状全部 props 的类型。因为稍后我们要在自定义 util 中为 props 追加一个额外字段,在类型层面也同步加上strokeSize: number,从而让全文件范围内对shape.props.strokeSize的读写都被类型系统覆盖。这与"源码注释 [2]"描述的行为一致。
四、步骤 3:用 DrawShapeUtil.configure 定制 draw 工具并接入渲染
这是整个方案最核心的一步。tldraw 为DrawShapeUtil预留了configure类方法,示例通过它覆盖 draw 形状"展示值"(display values)的解析逻辑:
class CustomDrawShapeUtil extends DrawShapeUtil.configure({ getCustomDisplayValues(_editor, shape) { return { strokeWidth: shape.props.strokeSize } }, }) { static override props = { ...drawShapeProps, strokeSize: strokeSizeStyle } override getDefaultProps() { return { ...super.getDefaultProps(), strokeSize: strokeSizeStyle.defaultValue } } } const shapeUtils = [CustomDrawShapeUtil]这里涉及 tldraw 两套"展示值"(display values)机制,需要理解清楚其分工:
getDefaultDisplayValues:内置DrawShapeUtil的options中定义的默认解析函数(DrawShapeUtil.tsx 第 67-81 行),它读取shape.props.color/fill/size,产出strokeColor、strokeWidth、fillColor、patternFillFallbackColor等展示值;getCustomDisplayValues:是ShapeOptionsWithDisplayValues接口要求提供的"覆盖钩子"(getDisplayValues.ts 第 14-19 行),默认实现返回空对象。
两套值最终由getDisplayValues合并(getDisplayValues.ts 第 43-46 行):
const values = { ...util.options.getDefaultDisplayValues(util.editor, shape, theme, resolvedColorMode), ...util.options.getCustomDisplayValues(util.editor, shape, theme, resolvedColorMode), }也就是说:getCustomDisplayValues返回的{ strokeWidth: shape.props.strokeSize }会覆盖默认的theme.strokeWidth * STROKE_SIZES[size],最终 draw 形状的笔画宽度直接等于我们赋的数值。由于 draw 形状的渲染、几何命中测试(getGeometry依赖getDisplayValues(this, shape).strokeWidth,见 DrawShapeUtil.tsx 第 120 行)以及图片导出都统一经过getDisplayValues这条通道(该函数还带 WeakMap 缓存,见 getDisplayValues.ts 第 22-48 行),所以一处覆盖,四处生效——渲染、命中测试、导出都一致地使用自定义数值宽度。
子类的两处关键覆写
类体内还有两处静态配置:
static override props = { ...drawShapeProps, strokeSize: strokeSizeStyle }:先展开内置 draw 形状的全部 props 定义,再追加我们的样式。这样:- 编辑器样式系统(styles system)能感知
strokeSizeStyle并参与记忆/应用; - store 的 validator 会把
strokeSize当作可校验字段(通过StyleProp自带的validate)。
- 编辑器样式系统(styles system)能感知
override getDefaultProps():在调用super.getDefaultProps()得到内置默认 props 后,再补上strokeSize: strokeSizeStyle.defaultValue(即4),确保新建的 draw 形状带合法默认值。
把自定义 util 装进编辑器
const shapeUtils = [CustomDrawShapeUtil]DrawShapeUtil的静态type是'draw',Tldraw的shapeUtilsprop 中同名类型(draw)的自定义 util 会替换内置的 draw shape util,因此默认的 draw 工具会自动拾取我们定制后的版本,无需额外改工具定义——正如源码注释 [3] 所说明的。
五、步骤 4:编写十二档预设的数值选择器组件
在 CSS(stroke-size-picker.css)和 JSX 之外,选择器本体是一个完全自绘的按钮组:
const STROKE_SIZE_PRESETS = [1, 2, 3, 4, 6, 8, 10, 12, 16, 20, 26, 32] function StrokeSizePicker() { const { styles, onValueChange, onHistoryMark } = useStylePanelContext() const strokeSize = styles.get(strokeSizeStyle) // 当前上下文不包含该样式时,回退到内置四档选择器 if (strokeSize === undefined) return <StylePanelSizePicker /> const value = strokeSize.type === 'mixed' ? null : strokeSize.value return ( <div className="stroke-size-picker"> {STROKE_SIZE_PRESETS.map((size) => ( <button key={size} className="stroke-size-picker__preset" >export interface StylePanelContext { styles: ReadonlySharedStyleMap enhancedA11yMode: boolean onHistoryMark(id: string): void onValueChange<T>(style: StyleProp<T>, value: T): void onOpacityChange(opacity: number): void }本示例用到的三项含义:
styles:只读样式映射表。styles.get(strokeSizeStyle)返回当前上下文相关的样式条目,可能形如{ type: 'shared', value: 4 }(选中形状取值一致)或{ type: 'mixed' }(多选形状取值不一致),在上下文不包含该样式时为undefined;onValueChange(style, value):官方推荐的"改样式"入口。看 StylePanelContext.tsx 第 36-57 行 的实现,它在一个editor.run事务中依次执行setStyleForSelectedShapes(style, value)(选中形状立即生效)与setStyleForNextShapes(style, value)(记住为下一个形状的默认值),与内置 picker 行为完全一致;它还检测用户是否按住加速键(Ctrl/Cmd)来区分"是否要把样式带给下一个形状",以及触发set-style分析埋点;onHistoryMark('set stroke size'):对应editor.markHistoryStoppingPoint(id),在修改前标记历史记录点,让一次点击成为可被"撤销"独立回退的单个操作。
处理 mixed 与"点大小随数值增长"
value在 mixed 状态下为null,此时没有任何预设按钮点亮(data-active均为 false);- 每个预设点用一个圆形
div表示,直径由4 + size / 2计算(例如 size=1 → 4.5px,size=32 → 20px),让用户通过圆点视觉大小直观感知粗细; - 网格采用
grid-template-columns: repeat(6, 1fr),12 个按钮排成两行,激活态(data-active='true')与悬停态由 stroke-size-picker.css 中的--tl-color-hint/--tl-color-muted-2等 tldraw 主题变量着色,保证与其它面板控件观感统一。
六、步骤 5:让选择器"只在 draw 相关时出现"
这是本示例很巧妙的一处条件渲染:
const strokeSize = styles.get(strokeSizeStyle) if (strokeSize === undefined) return <StylePanelSizePicker />为什么styles里没有strokeSizeStyle时就要回退到内置选择器?这由 tldraw 样式面板的构建方式决定:面板的styles映射只包含"与当前激活工具/选中形状相关的样式"。也就是说:
- 当draw 工具被激活(如本示例
onMount中editor.setCurrentTool('draw'))或选中范围内包含 draw 形状时,由于 draw 形状 props 里带了strokeSizeStyle,样式映射才会包含它,此时渲染我们自绘的十二档选择器; - 而当选中 geo 形状、箭头、文字、线等仍使用内置
size样式(四档枚举)的形状时,映射中不含strokeSizeStyle,代码自动渲染内置的StylePanelSizePicker(四档s/m/l/xl)。
因此自定义样式面板对外呈现的是"智能切换":画 draw 用 12 档数值,画其它形状退回官方四档。用户无需在两个界面间手动跳转。
七、步骤 6:像搭积木一样组装样式面板
最后一步是用DefaultStylePanel+StylePanelSection把内置控件按需重组,替换掉"尺寸选择器"这一个槽位:
function CustomStylePanel(props: TLUiStylePanelProps) { return ( <DefaultStylePanel {...props}> <StylePanelSection> <StylePanelColorPicker /> <StylePanelOpacityPicker /> </StylePanelSection> <StylePanelSection> <StylePanelFillPicker /> <StylePanelDashPicker /> <StrokeSizePicker /> {/* 原来的 StylePanelSizePicker 被替换 */} </StylePanelSection> <StylePanelSection> <StylePanelFontPicker /> <StylePanelTextAlignPicker /> <StylePanelLabelAlignPicker /> </StylePanelSection> <StylePanelSection> <StylePanelGeoShapePicker /> <StylePanelArrowKindPicker /> <StylePanelArrowheadPicker /> <StylePanelSplinePicker /> </StylePanelSection> </DefaultStylePanel> ) } const components: TLComponents = { StylePanel: CustomStylePanel, }与默认面板逐行对照
将上面的 JSX 与官方默认面板内容 DefaultStylePanelContent.tsx 逐行对照可以发现,唯一差异是第 2 个 Section 中把<StylePanelSizePicker />换成了<StrokeSizePicker />,其余控件(颜色、透明度、填充、虚线、字体、对齐、图形类型、箭头、样条线)原封不动。这体现了 tldraw UI 的组合式设计:面板不是黑盒,而是由可独立复用的StylePanel*控件 +StylePanelSection分组构成,官方甚至把每个控件的源码都公开在 DefaultStylePanelContent.tsx 中,方便开发者参考每个控件的写法。
两个易错点
- 必须转发
props给DefaultStylePanel(<DefaultStylePanel {...props}>):编辑器在移动端把样式面板渲染到工具栏的 popover 中时会传入isMobile等 props,不透传会导致移动端布局异常(示例源码注释 [6] 特别提醒了这一点); - 注册方式:
CustomStylePanel通过TLComponents的StylePanel槽位传入<Tldraw components={components} ... />,替换的是整个样式面板组件,而不是面板里的某个控件。
完整挂载
export default function StrokeSizePickerExample() { return ( <div className="tldraw__editor"> <Tldraw shapeUtils={shapeUtils} components={components} onMount={(editor) => { editor.setCurrentTool('draw') // 进入 draw 工具,展示自定义选择器 }} /> </div> ) }配合导入'tldraw/tldraw.css'与示例自身的样式文件,shapeUtils提供替换后的 draw util,components提供定制面板。
八、把方案抽象成通用套路
本示例虽然是针对 draw 工具的定制,但其方法论可直接复用到任意自定义形状上:
- 用
StyleProp.define/StyleProp.defineEnum定义你自己的样式,注意用带命名空间的唯一 ID; - 用 declaration merging 扩充形状 props 的类型,保证类型安全;
- 继承并
configure目标 ShapeUtil,在getCustomDisplayValues中把你样式的值映射为展示值;ShapeUtil的通用机制(渲染、几何、导出统一经getDisplayValues合并)保证各环节一致; - 用
useStylePanelContext写一个自绘 picker,处理undefined(回退内置控件)与mixed(不点亮)两种状态; - 用
DefaultStylePanel重新组装面板,只替换需要替换的槽位并透传props。
如果想了解"自定义样式配合完全自定义形状(而非改造内置形状)"的写法,可以继续阅读 tldraw 官方示例集中关于自定义形状与自定义样式的示例源码(同位于 apps/examples/src/examples 下),两者配合可实现对形状外观体系的完全掌控。
九、如何本地运行该示例
该示例位于独立的 examples 应用(包名examples.tldraw.com)中,其启动脚本定义在 apps/examples/package.json:
yarn dev # 在 apps/examples 目录下运行,等价于 vite --host启动后打开 Vite 提供的本地地址,在示例列表中找到Stroke size picker即可交互验证:页面加载后会自动切到 draw 工具,样式面板中显示 12 个大小递增的圆点;画出几条不同数值的笔迹后切换到选择工具点选其它形状,尺寸选择器会自动恢复为内置四档。你也可以在 DrawShapeUtil.tsx 与 StylePanelContext.tsx 中打断点,观察getDisplayValues的合并结果与onValueChange内部如何对选中/下一个形状分别设置样式,从而把本文中的每一步实现与 SDK 源码一一对应起来。
【免费下载链接】tldrawBuild infinite canvas apps in React with the tldraw SDK. World's best, top-most agent recommended #1 five star SDK.项目地址: https://gitcode.com/GitHub_Trending/tl/tldraw
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考