首页
/ tldraw 箭头文字标签完全指南:用 richText、labelPosition、labelColor 打造可控标注

tldraw 箭头文字标签完全指南:用 richText、labelPosition、labelColor 打造可控标注

2026-09-07 21:59:59作者:俞予舒Fleming

本指南基于 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:将箭头弯曲后,标签仍会沿曲线(而非弦)居中摆放。

同时,示例还展示了两个开箱即用的交互:拖动标签沿箭头滑动双击标签编辑文字

示例文件与运行方式

示例代码与清单位于:

源码顶部只依赖两个入口:

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 把画布视野对齐到全部内容,方便一眼看到网格布局。

标签数据模型:richTexttoRichText

箭头标签并非 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.tsxstartEditingShape(...) 触发。

从源码结构看(packages/tldraw/src/lib/shapes/arrow/arrowLabel.ts),渲染系统在把 labelPosition 映射成屏幕坐标时并非机械地取「整条线段的分数点」,而是:

  1. 先用「标签自带尺寸」反推一个可用的位置区间 getArrowLabelRange——因为标签占据空间,当它靠近箭头起点/终点(尤其是带有箭头头 arrowhead 或绑定了形状时)不能被画出箭头端点;
  2. 再用 clamplabelPosition 限制在该区间内(getClampedPosition),例如 start/end 端有箭头头或绑定时区间为 [range.start, range.end],否则为 [0, 1]
  3. 最终通过几何体上的 bodyGeom.interpolateAlongEdge(clampedPosition) 计算标签中心点。

也就是说,你写的 labelPosition 是「期望位置」,引擎会保证标签始终留在箭杆有效区段内、不越出端点。对直线与圆弧箭头,几何体分别是 Edge2dArc2d(见 getArrowBodyGeometry),所以圆弧上的标签沿弧长插值,拖动时也贴合曲线移动

值得补充的性能细节:arrowLabel.tscreateComputedCache 缓存了标签尺寸测量结果,并且其 areRecordsEqual 做了特殊优化——如果两个版本之间只有 labelPosition 变化,则跳过尺寸重算(见 arrowLabel.ts)。这保证了「拖动标签」这个高频操作不会反复触发昂贵的文本测量。

独立文字颜色:labelColorcolor

示例第二组(序号 [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 层面,colorlabelColor 是两套独立的 StyleProp(DefaultColorStyleDefaultLabelColorStyle,见 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 映射线宽,见 getLabelToArrowPaddingSTROKE_SIZES[shape.props.size] 的引用,arrowLabel.ts)。渲染标签时实际使用的是由 sizescale 与主题推导出的 labelFontSizelabelLineHeightlabelFontFamily 等显示值,并通过 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 的弯曲量,使它变成一段弧线。重点注释是:

bend curves the arrow, and the label is positioned along the curve rather than the chord.

即:标签的 labelPosition: 0.5 落在弧线的中点而非连接两端点的直弦中点。这正对应上文提到的实现:圆弧箭头的 body geometry 是 Arc2dinterpolateAlongEdge(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 中体现):

  1. 空标签不渲染尺寸:当标签文本为空且不在编辑态时,getArrowLabelPosition 会走短路逻辑,把标签盒宽高视为 0,仅把中心点放在箭杆中点——视觉上就是「没有标签盒子」;
  2. 编辑态下标签可正常生长:一旦进入双击编辑,isEditing 为 true,便走完整测量与换行逻辑,文本随输入动态重排。此外测量时会用 isEmptyRichText 判断空内容,并把最小宽度按一个字符 'i' 测量(见 arrowLabel.ts),确保点击空标签也能命中可编辑区域。

标签的几何体在 ArrowShapeUtil.tsx 中通过 getArrowLabelPosition 计算并放入 Group2dchildren[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 字段迁移成富文本 richTextprops.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 测试入手。

小结

把本示例拆解到底,核心可迁移知识只有四条:

  1. 标签数据 = 箭头形状的 richText prop,用 toRichText(plainText) 构造,天然支持多行段落;
  2. labelPosition 是 0~1 的「沿箭杆相对位置」,拖动标签会写回该值,渲染引擎负责在端点/箭头头处自动夹紧;
  3. labelColorcolor 分离,浅色箭杆配深色标签可获得更好的对比度;
  4. 字体由 font 选择(draw/sans/serif/mono),字号由 size 决定(与线宽同源),bend 弯曲后标签沿弧线摆放。

这套模式可以直接复用到你自己的标注型应用中——无论是流程图、架构图还是注释工具,只需在 onMount 或运行期用 editor.createShapes 传入上述 props,就能得到与编辑器原生交互完全一致的箭头标签体验。

延伸阅读

登录后查看全文
热门项目推荐
相关项目推荐