tldraw 箭头文字标签完全指南:用 richText、labelPosition、labelColor 打造可控标注
本指南基于 tldraw 官方示例 apps/examples/src/examples/editor-api/arrow-labels 展开,讲解如何在箭头(arrow)形状上创建文字标签,并通过 labelPosition(标签沿箭杆的位置)、labelColor(独立的标签文字颜色)、font(字体)以及 bend(弯曲箭头)等属性精确控制标签的外观与位置。读完本指南,你将掌握基于 tldraw SDK 的 toRichText 富文本机制,以及用 editor.createShapes 编程式构建标注箭头、驱动交互编辑的完整思路。
一句话理解本示例
在 tldraw 中,箭头标签并不是独立形状,而是箭头形状自己的 richText 属性——创建箭头时通过 toRichText('...') 传入文本即可。示例代码在画布上排布出一组箭头矩阵,逐项演示了四件事:
labelPosition:用 0~1 之间的小数表示标签在箭杆上的位置,0 靠起点、1 靠终点;labelColor:标签文字颜色,独立于箭头本体的color;font:四种字体(draw/sans/serif/mono);bend:将箭头弯曲后,标签仍会沿曲线(而非弦)居中摆放。
同时,示例还展示了两个开箱即用的交互:拖动标签沿箭头滑动、双击标签编辑文字。
示例文件与运行方式
示例代码与清单位于:
- 组件实现:apps/examples/src/examples/editor-api/arrow-labels/ArrowLabelsExample.tsx
- 示例元信息(标题、关键词、优先级):apps/examples/src/examples/editor-api/arrow-labels/README.md
源码顶部只依赖两个入口:
import { createShapeId, Tldraw, toRichText } from 'tldraw'
import 'tldraw/tldraw.css'
也就是说,你不需要引入任何额外插件或编辑器内部模块,所有能力都通过 tldraw 包公开的 API 暴露。完整组件用法是:
export default function ArrowLabelsExample() {
return (
<div className="tldraw__editor">
<Tldraw
onMount={(editor) => {
if (editor.getCurrentPageShapeIds().size > 0) return
editor.createShapes([/* ...箭头形状列表... */])
editor.zoomToFit({ animation: { duration: 0 } })
}}
/>
</div>
)
}
onMount 中先通过 getCurrentPageShapeIds().size > 0 做幂等保护(避免重复挂载时重复创建图形),然后一次性批量创建所有箭头,最后 zoomToFit 把画布视野对齐到全部内容,方便一眼看到网格布局。
标签数据模型:richText 与 toRichText
箭头标签并非 string,而是富文本结构。在 tldraw 的 schema 中,TLArrowShapeProps 有一个 richText: TLRichText 字段,对应定义见 packages/tlschema/src/shapes/TLArrowShape.ts,其验证器如下:
export const arrowShapeProps: RecordProps<TLArrowShape> = {
// ...
bend: T.number,
richText: richTextValidator,
labelPosition: T.number,
// ...
}
TLRichText 采用「文档-块」结构(doc → 段落 paragraph → 文本 text),验证器定义在 packages/tlschema/src/misc/TLRichText.ts:
export const richTextValidator = T.object({
type: T.string,
content: T.arrayOf(T.unknown),
attrs: T.any.optional(),
})
直接手写这种结构很繁琐,所以官方提供了 toRichText(text) 帮助函数(同一文件内实现)。它的规则是:把输入字符串按 \n 拆行,每一行生成一个 paragraph,空行保留为空段落:
export function toRichText(text: string): TLRichText {
const lines = text.split('\n')
const content = lines.map((text) => {
if (!text) {
return { type: 'paragraph' }
}
return {
type: 'paragraph',
content: [{ type: 'text', text }],
}
})
return { type: 'doc', content }
}
因此多行标签天然得到支持:toRichText('第一行\n第二行') 会产生两个段落,这与箭头标签支持多行排版和回车换行的交互编辑行为是对应的。也正因标签走的是富文本管线,它的字号、字体族渲染复用 tldraw 全局的字体测量系统(详见下文「字体」小节对 editor.textMeasure 的引用)。
最基础的带标签箭头
示例中的第一组箭头(序号 [1])是最简形态:
{
id: createShapeId(),
type: 'arrow',
x: 100,
y: 100,
props: {
start: { x: 0, y: 0 },
end: { x: 300, y: 0 },
richText: toRichText('Default label'),
labelPosition: 0.5,
},
}
要点说明:
start/end是箭头两端在形状局部坐标系中的点(这里从(0,0)画到(300,0),即一条水平箭头);注意此时端点类型是自由点坐标,箭头不绑定任何其他形状;richText: toRichText('Default label')设置标签文本;labelPosition: 0.5是默认值,把标签放在箭杆正中央。
labelPosition 的含义是「标签中心在箭杆上的相对位置」,0 表示起点端,1 表示终点端。schema 中对其默认值的填充也能佐证「居中即默认」:在 packages/tlschema/src/shapes/TLArrowShape.ts 的迁移代码里,新老数据都统一写入 props.labelPosition = 0.5。
拖动标签:labelPosition 的交互版用法
labelPosition 不只是创建参数,你可以在运行期把标签当做一个可拖动的「手柄」。示例导读明确建议:选中箭头后拖动标签沿箭杆滑动,或双击标签直接编辑文字。这两个交互背后由 packages/tldraw/src/lib/shapes/arrow/ArrowShapeUtil.tsx 的 arrow shape util 驱动,工具栏中的双击编辑则由 packages/tldraw/src/lib/shapes/arrow/toolStates/Idle.tsx 中 startEditingShape(...) 触发。
从源码结构看(packages/tldraw/src/lib/shapes/arrow/arrowLabel.ts),渲染系统在把 labelPosition 映射成屏幕坐标时并非机械地取「整条线段的分数点」,而是:
- 先用「标签自带尺寸」反推一个可用的位置区间
getArrowLabelRange——因为标签占据空间,当它靠近箭头起点/终点(尤其是带有箭头头arrowhead或绑定了形状时)不能被画出箭头端点; - 再用
clamp把labelPosition限制在该区间内(getClampedPosition),例如start/end端有箭头头或绑定时区间为[range.start, range.end],否则为[0, 1]; - 最终通过几何体上的
bodyGeom.interpolateAlongEdge(clampedPosition)计算标签中心点。
也就是说,你写的 labelPosition 是「期望位置」,引擎会保证标签始终留在箭杆有效区段内、不越出端点。对直线与圆弧箭头,几何体分别是 Edge2d 与 Arc2d(见 getArrowBodyGeometry),所以圆弧上的标签沿弧长插值,拖动时也贴合曲线移动。
值得补充的性能细节:arrowLabel.ts 用 createComputedCache 缓存了标签尺寸测量结果,并且其 areRecordsEqual 做了特殊优化——如果两个版本之间只有 labelPosition 变化,则跳过尺寸重算(见 arrowLabel.ts)。这保证了「拖动标签」这个高频操作不会反复触发昂贵的文本测量。
独立文字颜色:labelColor 与 color
示例第二组(序号 [2])展示了 labelColor 的核心价值——文字颜色与箭杆颜色解耦:
{
id: createShapeId(),
type: 'arrow',
x: 100,
y: 200,
props: {
start: { x: 0, y: 0 },
end: { x: 300, y: 0 },
richText: toRichText('Start'),
labelPosition: 0.2,
color: 'blue',
labelColor: 'red',
},
}
这里箭杆设为 blue,标签文字却是 red。示例矩阵里还依次展示了 labelColor: 'red'(起点附近)、labelColor: 'violet'(中点)、labelColor: 'green'(终点附近)三种位置组合。
官方注释给出的使用动机非常实用:当箭杆本身是浅色时,独立设置深色标签文字可以保证对比度与可读性。
在 schema 层面,color 与 labelColor 是两套独立的 StyleProp(DefaultColorStyle 与 DefaultLabelColorStyle,见 TLArrowShape.ts),二者共用 tldraw 的颜色取值集合。这也说明标签颜色只影响文字本身,不会改变箭头线段与箭头头的颜色。
字体:font 的四种取值与字号来源
示例第三组(序号 [3])依次创建了四支横向箭头,分别使用 font: 'draw' | 'sans' | 'serif' | 'mono':
{ id: createShapeId(), type: 'arrow', x: 550, y: 100, props: { /*...*/ richText: toRichText('Draw font'), font: 'draw' } },
{ id: createShapeId(), type: 'arrow', x: 550, y: 200, props: { /*...*/ richText: toRichText('Sans font'), font: 'sans' } },
{ id: createShapeId(), type: 'arrow', x: 550, y: 300, props: { /*...*/ richText: toRichText('Serif font'), font: 'serif' } },
{ id: createShapeId(), type: 'arrow', x: 550, y: 400, props: { /*...*/ richText: toRichText('Mono font'), font: 'mono' } },
draw:手写风格字体,tldraw 默认风格的标志性字体;sans/serif:常规无衬线与衬线字体;mono:等宽字体,适合代码片段类标签。
示例代码末尾的注释还澄清了一个容易混淆的点:箭头没有独立的「标签字号」属性,标签的字号由 size 属性决定——而这个 size 属性同时也是箭杆线宽所共享的(STROKE_SIZES 根据 size 映射线宽,见 getLabelToArrowPadding 对 STROKE_SIZES[shape.props.size] 的引用,arrowLabel.ts)。渲染标签时实际使用的是由 size、scale 与主题推导出的 labelFontSize、labelLineHeight、labelFontFamily 等显示值,并通过 editor.textMeasure.measureHtml 测量文本占据的宽高。
弯曲箭头上的标签:bend + labelPosition
示例最后一支箭头(序号 [4])把前面所有能力组合到了一起:
{
id: createShapeId(),
type: 'arrow',
x: 300,
y: 475,
props: {
start: { x: 0, y: 0 },
end: { x: 400, y: 150 },
bend: 50,
richText: toRichText('Curved arrow'),
labelPosition: 0.5,
font: 'sans',
color: 'violet',
size: 'm',
},
}
这里 end 不再是水平方向,而是斜向下 (400,150),bend: 50 给箭头施加 50 的弯曲量,使它变成一段弧线。重点注释是:
bendcurves the arrow, and the label is positioned along the curve rather than the chord.
即:标签的 labelPosition: 0.5 落在弧线的中点而非连接两端点的直弦中点。这正对应上文提到的实现:圆弧箭头的 body geometry 是 Arc2d,interpolateAlongEdge(0.5) 沿弧长插值,从而让标签「贴」在弯曲路径的视觉正中。
完整可运行代码
以下是整合后的完整示例(保留官方代码注释结构),可直接作为独立组件的骨架:
import { createShapeId, Tldraw, toRichText } from 'tldraw'
import 'tldraw/tldraw.css'
export default function ArrowLabelsExample() {
return (
<div className="tldraw__editor">
<Tldraw
onMount={(editor) => {
if (editor.getCurrentPageShapeIds().size > 0) return
editor.createShapes([
// [1] 最简标签:居中(labelPosition 默认 0.5)
{
id: createShapeId(),
type: 'arrow',
x: 100, y: 100,
props: {
start: { x: 0, y: 0 },
end: { x: 300, y: 0 },
richText: toRichText('Default label'),
labelPosition: 0.5,
},
},
// [2] 独立的标签颜色 + 不同 labelPosition
{
id: createShapeId(), type: 'arrow', x: 100, y: 200,
props: { start: { x: 0, y: 0 }, end: { x: 300, y: 0 },
richText: toRichText('Start'), labelPosition: 0.2,
color: 'blue', labelColor: 'red' },
},
{
id: createShapeId(), type: 'arrow', x: 100, y: 300,
props: { start: { x: 0, y: 0 }, end: { x: 300, y: 0 },
richText: toRichText('Middle'), labelPosition: 0.5,
color: 'blue', labelColor: 'violet' },
},
{
id: createShapeId(), type: 'arrow', x: 100, y: 400,
props: { start: { x: 0, y: 0 }, end: { x: 300, y: 0 },
richText: toRichText('End'), labelPosition: 0.8,
color: 'blue', labelColor: 'green' },
},
// [3] 四种字体
{
id: createShapeId(), type: 'arrow', x: 550, y: 100,
props: { start: { x: 0, y: 0 }, end: { x: 300, y: 0 },
richText: toRichText('Draw font'), font: 'draw' },
},
{
id: createShapeId(), type: 'arrow', x: 550, y: 200,
props: { start: { x: 0, y: 0 }, end: { x: 300, y: 0 },
richText: toRichText('Sans font'), font: 'sans' },
},
{
id: createShapeId(), type: 'arrow', x: 550, y: 300,
props: { start: { x: 0, y: 0 }, end: { x: 300, y: 0 },
richText: toRichText('Serif font'), font: 'serif' },
},
{
id: createShapeId(), type: 'arrow', x: 550, y: 400,
props: { start: { x: 0, y: 0 }, end: { x: 300, y: 0 },
richText: toRichText('Mono font'), font: 'mono' },
},
// [4] 弯曲箭头上的标签(沿弧线而非弦)
{
id: createShapeId(), type: 'arrow', x: 300, y: 475,
props: {
start: { x: 0, y: 0 }, end: { x: 400, y: 150 },
bend: 50,
richText: toRichText('Curved arrow'),
labelPosition: 0.5,
font: 'sans', color: 'violet', size: 'm',
},
},
])
editor.zoomToFit({ animation: { duration: 0 } })
}}
/>
</div>
)
}
空标签与编辑态的实现细节
有两点底层行为值得留意(均在 arrowLabel.ts 中体现):
- 空标签不渲染尺寸:当标签文本为空且不在编辑态时,
getArrowLabelPosition会走短路逻辑,把标签盒宽高视为 0,仅把中心点放在箭杆中点——视觉上就是「没有标签盒子」; - 编辑态下标签可正常生长:一旦进入双击编辑,
isEditing为 true,便走完整测量与换行逻辑,文本随输入动态重排。此外测量时会用isEmptyRichText判断空内容,并把最小宽度按一个字符'i'测量(见 arrowLabel.ts),确保点击空标签也能命中可编辑区域。
标签的几何体在 ArrowShapeUtil.tsx 中通过 getArrowLabelPosition 计算并放入 Group2d 的 children[1](children[0] 是箭杆路径);isOverArrowLabel 正是通过命中检测这个子几何体来判断「鼠标是否悬浮在标签上」(见 arrowLabel.ts),从而把悬停、拖动、双击编辑与箭杆本身的框选/拖拽逻辑区分开。
数据演进:为什么箭头标签经历过迁移
如果浏览 packages/tlschema/src/shapes/TLArrowShape.ts 中的 arrowShapeVersions,可以看到箭头形状的 schema 演进史:
export const arrowShapeVersions = createShapePropsMigrationIds('arrow', {
AddLabelColor: 1,
AddIsPrecise: 2,
AddLabelPosition: 3,
ExtractBindings: 4,
AddScale: 5,
AddElbow: 6,
AddRichText: 7,
AddRichTextAttrs: 8,
})
与标签直接相关的关键版本是 AddLabelColor(补齐 labelColor,默认 black)、AddLabelPosition(新老数据统一写入 0.5)以及 AddRichText——它把旧的纯文本 text 字段迁移成富文本 richText(props.richText = toRichText(props.text) 后删除 text,见迁移序列 TLArrowShape.ts)。这说明本文介绍的 richText 模型是较新的、面向文档化富文本的存储形态;如果你在处理历史存量文档,迁移系统会自动完成这类转换。
测试佐证
仓库中 packages/tldraw/src/lib/shapes/arrow/ArrowShapeUtil.test.ts 覆盖了箭头形状的关键行为,例如:
it('should create an arrow with a label', ...)验证带标签箭头的创建;- 测试夹具中显式构造
labelPosition: 0.5等 props(见该文件内标签相关用例),印证了标签属性在运行期可被正常读写。
如果你要为本指南的用法补测试或验证 schema 行为,可以从 packages/tlschema/src/migrations.test.ts 与上述 ArrowShapeUtil 测试入手。
小结
把本示例拆解到底,核心可迁移知识只有四条:
- 标签数据 = 箭头形状的
richTextprop,用toRichText(plainText)构造,天然支持多行段落; labelPosition是 0~1 的「沿箭杆相对位置」,拖动标签会写回该值,渲染引擎负责在端点/箭头头处自动夹紧;labelColor与color分离,浅色箭杆配深色标签可获得更好的对比度;- 字体由
font选择(draw/sans/serif/mono),字号由size决定(与线宽同源),bend弯曲后标签沿弧线摆放。
这套模式可以直接复用到你自己的标注型应用中——无论是流程图、架构图还是注释工具,只需在 onMount 或运行期用 editor.createShapes 传入上述 props,就能得到与编辑器原生交互完全一致的箭头标签体验。
延伸阅读
- 箭头标签定位与测量的运行时实现:packages/tldraw/src/lib/shapes/arrow/arrowLabel.ts
- 箭头形状 UI / 渲染 / 手柄逻辑:packages/tldraw/src/lib/shapes/arrow/ArrowShapeUtil.tsx
- 箭头形状 schema、验证器与迁移:packages/tlschema/src/shapes/TLArrowShape.ts
- 富文本结构定义与
toRichText实现:packages/tlschema/src/misc/TLRichText.ts - 示例测试:packages/tldraw/src/lib/shapes/arrow/ArrowShapeUtil.test.ts
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0627
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00