tldraw 自定义形状与自定义样式:用 StyleProp.defineEnum 打造专属"评分"样式并接入样式面板
【免费下载链接】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 仓库中的官方示例 shape-with-custom-styles 为骨架,完整讲解如何在自定义 ShapeUtil 上声明一套全新的样式属性(style prop):从StyleProp.defineEnum定义样式、把样式注册进形状 props、到通过useRelevantStyles与editor.setStyleForSelectedShapes把下拉控件接入默认样式面板。读完你不仅能复刻一个带"rating(评分)"样式的自定义形状,还能理解 tldraw 样式的三个底层机制——跨选区共享值、"mixed(混合)"态、以及新形状自动继承最近使用的值。
1. 什么是 tldraw 的 style prop:先理解"样式"与普通属性的区别
在深入代码之前,先建立一个关键认知:style prop 是 shape props 中一类被编辑器特殊对待的属性。示例 README 用一句话概括了它的两个规则:
- 同一个样式值可以被同时设置到大量形状上(多选批量修改);
- 最近一次使用过的值会被自动保存,并应用到之后新建的形状上。
这一语义在 StyleProp 的类注释 中被进一步明确:比如 tldraw 默认形状的DefaultColorStyle,你在 tldraw.com 上选中多个形状改颜色,颜色会同步应用到它们全部;接着画一个新形状,新形状会自动继承你刚刚设置的颜色。
也就是说,判断某个 shape prop 是否"升级"为样式,唯一依据是——这个 prop 的 validator 是不是一个StyleProp实例。ShapeUtil 的props校验器里凡是出现StyleProp的条目,编辑器都会自动把它纳入跨选区的样式跟踪,并在样式面板(style panel)中呈现。
2. 用 StyleProp.defineEnum 定义一个"评分"枚举样式
示例的核心是一个自定义的 rating(星级评分)样式。定义它的代码只有一行,对应源码中的[1]标注:
const myRatingStyle = StyleProp.defineEnum('example:rating', { defaultValue: 1, values: [1, 2, 3, 4, 5], })其背后对应 StyleProp.defineEnum 的静态工厂实现:
static defineEnum<const Values extends readonly unknown[]>( uniqueId: string, options: { defaultValue: Values[number]; values: Values } ) { const { defaultValue, values } = options return new EnumStyleProp<Values[number]>(uniqueId, defaultValue, values) }几点值得展开说明:
- uniqueId 必须全局唯一:示例使用
'example:rating',源码注释明确建议用"你的应用/库名"作前缀,避免与 tldraw 内置样式冲突(内置样式使用'tldraw:color'、'tldraw:size'等命名空间)。示例源码的[1]注释也再次强调:"这个 id 在编辑器的所有样式中必须唯一。" values是编译期常量数组:defineEnum要求values使用readonly数组并通过as const风格约束类型。在EnumStyleProp内部会基于values生成一个枚举字面量校验器T.literalEnum(...values)(见 EnumStyleProp 构造逻辑),任何非法值(例如6或字符串'good')在形状数据被校验时都会报错,从而保证存储的数据永远是白名单内的合法值。- 可以在运行时扩展或收窄枚举:
EnumStyleProp暴露了addValues(...newValues)与removeValues(...valuesToRemove)两个公开方法,用于运行时向内置样式(比如自定义颜色)追加/移除取值,并自动重建底层 validator(EnumStyleProp.addValues / removeValues)。示例未使用,但这是给 tldraw 内置样式"加料"的常用手段。 - 类型抽取:用
T.TypeOf<typeof myRatingStyle>可以把样式的合法值集合提取为 TypeScript 类型(示例[2]):
type MyRatingStyle = T.TypeOf<typeof myRatingStyle> // 此时 MyRatingStyle 等价于 1 | 2 | 3 | 4 | 5如果你只需要一个不限取值范围的数值/字符串样式,StyleProp.define(uniqueId, { defaultValue, type })是另一种更通用的入口(StyleProp.define),它接受任意T.Validatable类型作为值域校验器,例如StyleProp.define('myApp:width', { defaultValue: 1, type: T.number })。
3. 类型注册:把样式声明进 TLGlobalShapePropsMap
示例中有一小段容易被忽略但很关键的模块扩充(TypeScriptdeclare module),它把新形状类型及其 props 形状声明进 tldraw 的全局类型映射,使TLShape<'myshapewithcustomstyles'>能获得正确的泛型提示:
const MY_SHAPE_WITH_CUSTOM_STYLES_TYPE = 'myshapewithcustomstyles' declare module 'tldraw' { export interface TLGlobalShapePropsMap { [MY_SHAPE_WITH_CUSTOM_STYLES_TYPE]: { w: number h: number rating: MyRatingStyle } } }之后TLShape<typeof MY_SHAPE_WITH_CUSTOM_STYLES_TYPE>就会自动展开为"携带{ w, h, rating }props 的形状"类型。这是 tldraw 新式自定义形状的注册惯例:只有先扩充TLGlobalShapePropsMap,编辑器内部的TLShapePartial、getDefaultProps等类型推导才会认得你的新形状。
4. ShapeUtil 集成:把 StyleProp 放进 props,渲染时读取 rating
自定义形状本体是一个继承BaseBoxShapeUtil的 class,示例[3]、[4]、[5]分别对应三个要点:
class MyShapeUtil extends BaseBoxShapeUtil<IMyShape> { static override type = MY_SHAPE_WITH_CUSTOM_STYLES_TYPE // [3] 把 myRatingStyle 作为 props 之一:validator 是 StyleProp => 被当作样式 static override props = { w: T.number, h: T.number, rating: myRatingStyle, } getDefaultProps(): IMyShape['props'] { return { w: 300, h: 300, rating: 4, // [4] 注意:样式属性的默认值会在创建形状时被"覆盖" } } component(shape: IMyShape) { // [5] 在组件内部,样式和普通 prop 一样直接读取 const stars = ['☆', '☆', '☆', '☆', '☆'] for (let i = 0; i < shape.props.rating; i++) { stars[i] = '★' } return ( <HTMLContainer id={shape.id} style={{ backgroundColor: 'var(--tl-color-low-border)', overflow: 'hidden' }} > {stars} </HTMLContainer> ) } getIndicatorPath(shape: IMyShape) { const path = new Path2D() path.rect(0, 0, shape.props.w, shape.props.h) return path } }这里藏着一个新手最容易踩坑、也最能体现 style prop 特性的知识点([4]注释):getDefaultProps里写的rating: 4并不会生效!当一个 prop 是样式时,编辑器在创建形状时不会使用getDefaultProps中给的值,而是改取"下一次形状应使用的样式值"(editor 的 style-for-next-shape 状态),该值要么是样式的默认值(这里defineEnum传入的defaultValue: 1),要么是用户最近一次手动设置的值。换言之:getDefaultProps 中的样式取值只充当"兜底",真正决定初始表现的是样式状态机。这是样式区别于普通 props 的专属行为。
渲染层面用的是HTMLContainer包裹实心星/空心星文本,通过shape.props.rating在运行时把前 N 个星填实——样式被当作普通 prop 一样随形状数据驱动 UI。getIndicatorPath则返回一个与形状等宽的矩形Path2D,用于拖拽缩放等交互时的选中指示。
5. 自定义样式面板:useRelevantStyles + editor.setStyleForSelectedShapes
5.1 读取"相关样式":useRelevantStyles
要让样式出现在面板里,需要先拿到"与当前选择相关的样式值"。示例通过useRelevantStyles完成([6]):
const styles = useRelevantStyles() if (!styles) return null const rating = styles.get(myRatingStyle)useRelevantStyles的实现位于 useRelevantStyles.ts,其内部逻辑可概括为:
- 默认只检查 tldraw 内置样式集合
[color, dash, fill, size](见文件顶部的selectToolStyles); - 核心调用链是
new SharedStyleMap(editor.getSharedStyles())——editor.getSharedStyles()汇总当前选中形状在全部样式上的取值; - 当处于 select 工具且没有任何选中形状时,它会用
editor.getStyleForNextShape(...)把"下一次将使用的样式值"填进结果里,让面板在"无选区"状态下依然能预览/修改即将生效的默认值; - 只有当有形状被选中、处于带
shapeType的形状工具、或样式集合非空时才返回结果,否则返回null(调用方据此决定是否隐藏面板)。
返回值是一个ReadonlySharedStyleMap,其中每个样式条目只有三种状态:一个明确的共享值、或者'mixed'(表示选中形状们的该样式取值不一致)。示例select的value正是围绕这一枚举写的:
value={rating.type === 'mixed' ? '' : rating.value}当多选的两个形状 rating 不同(例如一个是 4、一个是 5)时,rating.type === 'mixed'成立,下拉框会显示空值并附带一个Mixed选项——这就是示例 README 中"试着同时选中两个形状看看 mixed 状态"的底层原理。而SharedStyleMap本身定义在 SharedStylesMap.ts,负责把跨形状的取值折叠为"共享值或 mixed"。
5.2 写回样式:setStyleForSelectedShapes 与 setStyleForNextShapes
下拉框onChange中把新值同时写给了"选中形状"和"后续新建的形状":
onChange={(e) => { const value = myRatingStyle.validate(+e.currentTarget.value) editor.run(() => { editor.markHistoryStoppingPoint() editor.setStyleForSelectedShapes(myRatingStyle, value) editor.setStyleForNextShapes(myRatingStyle, value) }) }}逐行解释这段"标准样式写入模式":
myRatingStyle.validate(+e.currentTarget.value):先把 DOM 字符串用样式的 validator 转成合法值(+转数字后,T.literalEnum会校验其是否属于 1~5);editor.markHistoryStoppingPoint():在撤销栈上标记一个历史停靠点,把后续两个操作归并为一条可撤销记录;editor.setStyleForSelectedShapes(myRatingStyle, value):遍历当前选中的形状,仅对"确实声明了该样式 prop"的形状类型生成TLShapePartial并批量updateShapes(见 Editor.ts 中的 setStyleForSelectedShapes)。底层用styleProps[shape.type].get(style)反查"样式 -> prop 键名"的映射,所以没声明 rating 样式的内置形状会被自动跳过,不会误写;editor.setStyleForNextShapes(myRatingStyle, value):把值写入getInstanceState().stylesForNextShape映射,从而影响之后新建形状的初始值(Editor.ts 中的 setStyleForNextShapes)。
这段代码与 tldraw 默认样式面板的写入逻辑完全一致——你实际上在用自己的控件复刻内置面板的行为。editor.run保证上述历史标记与两次样式写入原子地合并进同一个撤销事务。
5.3 用 DefaultStylePanel 做底座
自定义面板并不需要从零绘制整套 UI,而是包裹 tldraw 导出的DefaultStylePanel/DefaultStylePanelContent,先渲染出官方默认面板的全部内容,再追加自己的 rating 下拉框:
function CustomStylePanel() { const editor = useEditor() const styles = useRelevantStyles() if (!styles) return null const rating = styles.get(myRatingStyle) return ( <DefaultStylePanel> <DefaultStylePanelContent /> {rating !== undefined && ( <div> <select style={{ width: '100%', padding: 4 }} value={rating.type === 'mixed' ? '' : rating.value} onChange={/* 上述写入逻辑 */} > {rating.type === 'mixed' ? <option value="">Mixed</option> : null} <option value={1}>1</option> <option value={2}>2</option> <option value={3}>3</option> <option value={4}>4</option> <option value={5}>5</option> </select> </div> )} </DefaultStylePanel> ) }注意条件渲染rating !== undefined:styles.get(myRatingStyle)只有在选中形状恰好都声明了 rating 样式时才返回条目;当面板上没有任何自定义形状被选中时,myRatingStyle不在 shared styles 里,下拉框自然隐藏,避免在纯内置形状选区上显示无意义的评分控件。DefaultStylePanel的具体实现位于 DefaultStylePanel.tsx,它负责承载DefaultStylePanelContent及样式面板的通用外壳样式。
6. 装配与运行:shapeUtils、components 与初始场景
示例[7]、[8]演示了最终的装配方式。第一步是把 ShapeUtil 和面板组件都定义在 React 组件之外,避免每次渲染都重建导致编辑器状态失效:
const shapeUtils = [MyShapeUtil] const components: TLComponents = { StylePanel: CustomStylePanel, }接着把它们传入<Tldraw>:
export default function ShapeWithCustomStylesExample() { return ( <div className="tldraw__editor"> <Tldraw shapeUtils={shapeUtils} components={components} onMount={(editor) => { editor.createShape({ type: 'myshapewithcustomstyles', x: 100, y: 100 }) editor.selectAll() editor.createShape({ type: 'myshapewithcustomstyles', x: 450, y: 250, props: { rating: 5 }, }) }} /> </div> ) }其中onMount里的初始化逻辑([8])恰恰是验证样式行为的最佳实验场景:
- 第一个形状没有显式指定 rating,因此它采用编辑器的 style-for-next-shape 值——也就是
defineEnum里的defaultValue: 1,而不是getDefaultProps里的 4(再次呼应[4]的规则); editor.selectAll()把刚创建的第一个形状选中,随后创建的第二个形状不在选区中;- 第二个形状显式指定
props: { rating: 5 },所以它直接用 5。
最终画布上出现两个 300×300 的大方块:左侧显示 1 颗实心星(4 颗空心),右侧显示 5 颗实心星。此时:
- 单击任一形状,样式面板会显示其当前 rating,通过下拉框可改到 1~5;
- 用框选同时选中两个形状,下拉框即呈现
Mixed(混合)空态,这正是理解跨选区共享样式语义的最佳演示。
7. 关联资源与下一步
围绕本文主题,可以在仓库中继续探索以下几处印证材料:
- 完整可运行示例:ShapeWithCustomStylesExample.tsx(文件底部 1~8 号注释逐条解释了每一处设计动机);
- StyleProp 的全部定义入口(
define/defineEnum/EnumStyleProp.addValues/removeValues):packages/tlschema/src/styles/StyleProp.ts; - 内置样式如何用同一套 API 声明(可作为模仿范本):TLColorStyle.ts、TLSizeStyle.ts 等
styles/目录下的文件; - 样式在编辑器中的完整命令面(
getSharedStyles、getStyleForNextShape、setStyleForNextShape、setStyleForSelectedShapes等):packages/editor/src/lib/editor/Editor.ts; - 相关 UI 层 hook 与组件:useRelevantStyles.ts、DefaultStylePanel.tsx;
- 覆盖样式行为的测试用例:styles2.test.tsx、StylePanel.test.tsx,可用来验证 multi-select 共享值与 mixed 状态的预期行为。
如果你的形状想直接复用 tldraw 自带的颜色、线宽、填充等样式而不是自定义新样式,参考同目录族的 shape-with-tldraw-styles 示例(仓库内对应实现位于 apps/examples/src/examples 下);若需要更精细地定制样式面板 UI,官方还提供了 stroke size picker 相关的示例可供对照。把本文的 rating 样式替换成你业务需要的任意枚举(如节点状态、优先级、类型标签),即可把"样式系统"完整地复用到自己的无限画布应用里。
【免费下载链接】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),仅供参考