资讯动态

tldraw 自定义形状与自定义样式:用 StyleProp.defineEnum 打造专属“评分“样式并接入样式面板

发布时间:2026/9/10 20:46:11 来源:尧图企业网站定制
tldraw 自定义形状与自定义样式用 StyleProp.defineEnum 打造专属评分样式并接入样式面板【免费下载链接】tldrawBuild infinite canvas apps in React with the tldraw SDK. Worlds 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 defineEnumconst Values extends readonly unknown[]( uniqueId: string, options: { defaultValue: Values[number]; values: Values } ) { const { defaultValue, values } options return new EnumStylePropValues[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)两个公开方法用于运行时向内置样式比如自定义颜色追加/移除取值并自动重建底层 validatorEnumStyleProp.addValues / removeValues。示例未使用但这是给 tldraw 内置样式加料的常用手段。类型抽取用T.TypeOftypeof myRatingStyle可以把样式的合法值集合提取为 TypeScript 类型示例[2]type MyRatingStyle T.TypeOftypeof 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 的全局类型映射使TLShapemyshapewithcustomstyles能获得正确的泛型提示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 } } }之后TLShapetypeof 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 BaseBoxShapeUtilIMyShape { 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.setStyleForSelectedShapes5.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~5editor.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 valueMixed/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 ! undefinedstyles.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, }接着把它们传入Tldrawexport default function ShapeWithCustomStylesExample() { return ( div classNametldraw__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/removeValuespackages/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. Worlds best, top-most agent recommended #1 five star SDK.项目地址: https://gitcode.com/GitHub_Trending/tl/tldraw创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价