tldraw 自定义笔画宽度:用 StyleProp 与 DrawShapeUtil.configure 将内置 4 档画笔大小替换为 12 档数值选择器
2026/9/9 21:43:36 网站建设 项目流程

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(画笔/涂鸦)工具只有smlxl四档预设粗细,在很多标注、演示、手写板场景中不够用。本文以 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类的文档注释,它的特殊之处有两点:

  1. 同一个值可以同时批量设置到多个形状上(例如全选多个形状后改颜色);
  2. 最近一次使用的值会被编辑器自动记忆,并应用到之后新画的形状上

这正是"样式"与普通 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:内置DrawShapeUtiloptions中定义的默认解析函数(DrawShapeUtil.tsx 第 67-81 行),它读取shape.props.color/fill/size,产出strokeColorstrokeWidthfillColorpatternFillFallbackColor等展示值;
  • 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)。
  • override getDefaultProps():在调用super.getDefaultProps()得到内置默认 props 后,再补上strokeSize: strokeSizeStyle.defaultValue(即4),确保新建的 draw 形状带合法默认值。

把自定义 util 装进编辑器

const shapeUtils = [CustomDrawShapeUtil]

DrawShapeUtil的静态type'draw'TldrawshapeUtilsprop 中同名类型(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 工具被激活(如本示例onMounteditor.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 中,方便开发者参考每个控件的写法。

两个易错点

  1. 必须转发propsDefaultStylePanel<DefaultStylePanel {...props}>):编辑器在移动端把样式面板渲染到工具栏的 popover 中时会传入isMobile等 props,不透传会导致移动端布局异常(示例源码注释 [6] 特别提醒了这一点);
  2. 注册方式CustomStylePanel通过TLComponentsStylePanel槽位传入<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 工具的定制,但其方法论可直接复用到任意自定义形状上:

  1. StyleProp.define/StyleProp.defineEnum定义你自己的样式,注意用带命名空间的唯一 ID;
  2. 用 declaration merging 扩充形状 props 的类型,保证类型安全;
  3. 继承并configure目标 ShapeUtil,在getCustomDisplayValues中把你样式的值映射为展示值;ShapeUtil的通用机制(渲染、几何、导出统一经getDisplayValues合并)保证各环节一致;
  4. useStylePanelContext写一个自绘 picker,处理undefined(回退内置控件)与mixed(不点亮)两种状态;
  5. 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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询