首页
/ tldraw SDK 自定义三次贝塞尔曲线形状实战:从 ShapeUtil 手柄拖拽到编辑状态管理

tldraw SDK 自定义三次贝塞尔曲线形状实战:从 ShapeUtil 手柄拖拽到编辑状态管理

2026-09-08 19:44:00作者:宣海椒Queenly

本指南基于 tldraw 仓库中 apps/examples/src/examples/shapes/tools/cubic-bezier-shape 的完整示例,讲解如何在 tldraw SDK 中从零实现一个带可拖拽控制点手柄的三次贝塞尔曲线形状(cubic bezier curve shape),并保持曲线在编辑状态(editing state)下完成手柄拖拽、控制点吸附折叠、整条曲线弯曲等交互。读完本文,你将掌握自定义 ShapeUtil 的关键方法(getGeometrygetHandlesgetHandleSnapGeometryonHandleDrag)、通过 TLGlobalShapePropsMap 扩展全局形状类型系统,以及用自定义 Overlay Util 与状态节点包装技术让手柄在编辑状态下持续可见的完整方案。

示例总览:一个完整的自定义曲线形状

三次贝塞尔曲线由四个点定义:起点(start)、终点(end)和两个控制点(cp1、cp2)。该示例在 Tldraw 组件中注册了一个名为 bezier-curve 的自定义形状,并注入了自定义手柄 Overlay,实现如下交互:

  • 拖动四个手柄(start、end、cp1、cp2)调整曲线形态;
  • 拖动端点时,其相邻控制点跟随移动,保持曲线形状;
  • Cmd/Ctrl + 拖动 端点时,改为单独移动该端点的控制点;
  • 控制点可以吸附(snap)到端点上,把曲线该端折叠成一个尖角;
  • Cmd/Ctrl + 拖动 曲线本身时,两个控制点一起平移,实现"弯曲"整条曲线而形状位置不变;
  • 整个编辑过程中曲线始终停留在 select.editing_shape 状态,手柄不消失。

示例代码位于 cubic-bezier-shape 目录,共四个文件:

文件 职责
CubicBezierShape.tsx 自定义形状与 ShapeUtil 的全部核心逻辑
BezierShapeHandleOverlayUtil.ts 自定义手柄 Overlay,编辑状态下持续显示手柄
CubicBezierShapeExample.tsx 组件挂载、形状创建与 select 工具状态节点包装
SneakyUndoRedoWhileEditing.tsx 编辑状态下恢复 Cmd/Ctrl+Z 撤销/重做快捷键

运行示例

在仓库根目录安装依赖后,进入 examples 应用启动 Vite 开发服务器(scripts 定义见 apps/examples/package.json):

yarn install
yarn workspace examples.tldraw.com dev

启动后在示例导航中找到 "Cubic bezier curve shape" 即可体验。可以尝试:拖动手柄、Cmd/Ctrl + 点击 控制点将其折叠、Cmd/Ctrl + 拖动 曲线本身将其弯曲。

第一步:定义形状类型与 props

自定义形状首先要让 TypeScript 类型系统认识它。示例在 CubicBezierShape.tsx 顶部通过 declare module 扩展了 tldraw 的全局形状 props 映射:

const BEZIER_CURVE_TYPE = 'bezier-curve'

declare module 'tldraw' {
	export interface TLGlobalShapePropsMap {
		[BEZIER_CURVE_TYPE]: { start: VecLike; cp1: VecLike; cp2: VecLike; end: VecLike }
	}
}

export type MyBezierCurveShape = TLShape<typeof BEZIER_CURVE_TYPE>

随后定义 BezierCurveShapeUtil,它直接继承 ShapeUtil(而不是 BaseBoxShapeUtil),因为该形状没有 width/height 属性,其边界完全由曲线本身决定。四个点都用 vecModelValidator 校验:

export class BezierCurveShapeUtil extends ShapeUtil<MyBezierCurveShape> {
	static override type = BEZIER_CURVE_TYPE
	static override props: RecordProps<MyBezierCurveShape> = {
		start: vecModelValidator,
		cp1: vecModelValidator,
		cp2: vecModelValidator,
		end: vecModelValidator,
	}

	override getDefaultProps(): MyBezierCurveShape['props'] {
		return {
			start: { x: 0, y: 0 },
			cp1: { x: 0, y: 140 },
			cp2: { x: 350, y: 300 },
			end: { x: 400, y: 110 },
		}
	}

	override canEdit(): boolean {
		return true
	}
}

canEdit 返回 true 声明该形状可进入编辑状态,这是后续手柄交互的前提。

第二步:用 CubicBezier2d 提供几何

getGeometry 返回一个 CubicBezier2d 实例,编辑器基于它做命中测试(hit-testing)、边界计算与吸附:

override getGeometry(shape: MyBezierCurveShape): Geometry2d {
	return new CubicBezier2d({
		start: new Vec(shape.props.start.x, shape.props.start.y),
		cp1: new Vec(shape.props.cp1.x, shape.props.cp1.y),
		cp2: new Vec(shape.props.cp2.x, shape.props.cp2.y),
		end: new Vec(shape.props.end.x, shape.props.end.y),
	})
}

CubicBezier2d 是编辑器内置几何原语,实现在 packages/editor/src/lib/primitives/geometry/CubicBezier2d.ts。它继承自 Polyline2d,内部把贝塞尔曲线按 resolution(默认 10)采样为 11 个顶点,供命中测试、最近点与距离计算使用:

// CubicBezier2d.getVertices:对 t ∈ [0,1] 采样 10 段
for (let i = 0, n = this._resolution; i <= n; i++) {
	const t = i / n
	vertices.push(
		new Vec(
			(1 - t) * (1 - t) * (1 - t) * a.x + 3 * ((1 - t) * (1 - t)) * t * b.x + 3 * (1 - t) * (t * t) * c.x + t * t * t * d.x,
			(1 - t) * (1 - t) * (1 - t) * a.y + 3 * ((1 - t) * (1 - t)) * t * b.y + 3 * (1 - t) * (t * t) * c.y + t * t * t * d.y
		)
	)
}

关键点:CubicBezier2d 是非填充(unfilled)几何。因为曲线不是闭合填充区域,点击曲线边界框内部但远离曲线线段的位置不会命中形状。此外它还提供 getSvgPathData(first) 输出 SVG path 数据(M a Cb c d 格式)、GetAtT 采样任意 t 处的点、getLength 计算弧长,这些被示例的渲染与后续交互复用。

第三步:手柄:getHandles 与 getHandleSnapGeometry

getHandles 把四个点暴露为四个 vertex 类型的手柄。控制点手柄带有 snapReferenceHandleId,并且当控制点与端点重合(折叠成尖角)时会被隐藏:

override getHandles(shape: MyBezierCurveShape): TLHandle[] {
	const indices = [ZERO_INDEX_KEY, ...getIndicesAbove(ZERO_INDEX_KEY, 3)]

	let handles: TLHandle[] = [
		{ id: 'start', type: 'vertex', x: shape.props.start.x, y: shape.props.start.y, index: indices[0], snapType: 'align' },
		{ id: 'cp1', type: 'vertex', x: shape.props.cp1.x, y: shape.props.cp1.y, index: indices[1], snapType: 'align', snapReferenceHandleId: 'start' },
		{ id: 'cp2', type: 'vertex', x: shape.props.cp2.x, y: shape.props.cp2.y, index: indices[2], snapType: 'align' },
		{ id: 'end', type: 'vertex', x: shape.props.end.x, y: shape.props.end.y, index: indices[3], snapType: 'align' },
	]

	if (Vec.Equals(shape.props.cp1, shape.props.start)) {
		handles = handles.filter((handle) => handle.id !== 'cp1')
	}
	if (Vec.Equals(shape.props.cp2, shape.props.end)) {
		handles = handles.filter((handle) => handle.id !== 'cp2')
	}
	return handles
}

getHandleSnapGeometry 定义吸附行为。points 表示"其他形状的手柄可以吸附到本形状的这些点";getSelfSnapPoints 返回"本形状自己的手柄可以吸附到哪些点"。示例让控制点能吸附到两个端点(从而可折叠),端点之间也可以相互吸附:

override getHandleSnapGeometry(shape: MyBezierCurveShape): HandleSnapGeometry {
	return {
		points: [shape.props.start, shape.props.end],
		getSelfSnapPoints: (handle) => {
			if (handle.id === 'cp1' || handle.id === 'cp2') {
				return [shape.props.start, shape.props.end]
			}
			return handle.id === 'end' ? [shape.props.start] : [shape.props.end]
		},
	}
}

吸附阈值与吸附指示线由编辑器的 snap manager 统一处理(与缩放无关的命中阈值),示例还在 onMount 中通过 editor.user.updateUserPreferences({ isSnapMode: true }) 强制开启吸附模式,确保手柄吸附立即可见。

第四步:onHandleDrag 实现手柄拖拽

onHandleDrag 返回更新后的形状对象。它处理三种情况(见源码注释 [7]):

  1. Cmd/Ctrl + 拖动 start/end:改而移动该端点的控制点(cp1/cp2);
  2. 拖动 start/end:端点本身移动,且其控制点跟随相同位移(delta),曲线整体形态不变;
  3. 拖动 cp1/cp2:仅移动该控制点。
override onHandleDrag(shape: MyBezierCurveShape, info: TLHandleDragInfo<MyBezierCurveShape>) {
	const { handle } = info
	const { id, x, y } = handle
	let newProps: Partial<MyBezierCurveShape['props']> = {}

	// cmd/ctrl + drag on start or end moves that endpoint's control point instead
	if (this.editor.inputs.getCtrlKey()) {
		switch (id) {
			case 'start': return { ...shape, props: { ...shape.props, cp1: { x, y } } }
			case 'end': return { ...shape, props: { ...shape.props, cp2: { x, y } } }
		}
	}

	switch (id) {
		case 'start': {
			const delta = Vec.Sub(handle, shape.props.start)
			newProps = { start: { x, y }, cp1: { x: shape.props.cp1.x + delta.x, y: shape.props.cp1.y + delta.y } }
			break
		}
		case 'end': {
			const delta = Vec.Sub(handle, shape.props.end)
			newProps = { end: { x, y }, cp2: { x: shape.props.cp2.x + delta.x, y: shape.props.cp2.y + delta.y } }
			break
		}
		default: {
			newProps = { [id as 'cp1' | 'cp2']: { x, y } }
			break
		}
	}

	return { ...shape, props: { ...shape.props, ...newProps } }
}

第五步:Cmd/Ctrl + 拖动曲线本身实现"弯曲"

除了手柄,示例还支持直接在曲线上 Cmd/Ctrl + 拖动 完成弯曲。这依赖 onTranslateStartonTranslate 两个钩子:

override onTranslateStart(shape: MyBezierCurveShape) {
	// 在拖动开始时采样一次 meta 键,避免平移途中按下 cmd/ctrl 导致意外弯曲
	this.isCtrlKeyOnTranslateStart = this.editor.inputs.getCtrlKey()

	// 只有拖动起点落在曲线上(而非端点手柄上)才弯曲
	const handles = this.getHandles(shape)
	const startAndEndHandles = handles.filter((handle) => handle.id === 'start' || handle.id === 'end')
	if (!startAndEndHandles.length) return

	const hitStartOrEndHandle = startAndEndHandles.some((handle) => {
		const threshold = 8 / this.editor.getZoomLevel()
		const handleInPageSpace = this.editor.getShapePageTransform(shape).applyToPoint(handle)
		return Vec.Dist(handleInPageSpace, this.editor.inputs.getCurrentPagePoint()) < threshold
	})

	const hitCurve = this.editor.isPointInShape(shape, this.editor.inputs.getCurrentPagePoint(), {
		margin: 10 / this.editor.getZoomLevel(),
	})
	this.didHitCurveOnTranslateStart = hitCurve && !hitStartOrEndHandle
}

override onTranslate(initial: MyBezierCurveShape, current: MyBezierCurveShape) {
	if (this.isCtrlKeyOnTranslateStart && this.didHitCurveOnTranslateStart) {
		const delta = Vec.Sub(current, initial)
		const offsetX = Math.round(delta.x)
		const offsetY = Math.round(delta.y)
		return {
			...initial,
			props: {
				...initial.props,
				cp1: { x: initial.props.cp1.x + offsetX, y: initial.props.cp1.y + offsetY },
				cp2: { x: initial.props.cp2.x + offsetX, y: initial.props.cp2.y + offsetY },
			},
		}
	}
	return
}

实现要点:是否弯曲在 onTranslateStart 中一次性决定(按下 meta 键且命中点在曲线上而非端点),随后 onTranslate 不再返回 current 而是返回"仅平移两个控制点"的初始形状副本,于是曲线被弯曲而形状本身没有位移。阈值 8px 与 10px 除以缩放级别,保证在不同缩放下命中手感一致。

第六步:渲染曲线与控制线

component 负责在 React 中绘制形状。它使用 getSvgPathData(true) 生成路径,并在编辑或拖拽手柄期间绘制 start→cp1、end→cp2 两条虚线控制线。所有线宽都除以缩放级别,保证屏幕上恒为 1px 宽:

component(shape: MyBezierCurveShape) {
	const path = this.getGeometry(shape).getSvgPathData(true)
	const { start, end, cp1, cp2 } = shape.props
	const zoomLevel = this.editor.getZoomLevel()

	return (
		<HTMLContainer>
			<svg className="tl-svg-container">
				<path d={path} stroke="black" fill="transparent" />
				{this.shouldShowControlLines(shape) && (
					<>
						<line x1={start.x} y1={start.y} x2={cp1.x} y2={cp1.y} stroke="black"
							strokeWidth={1 / zoomLevel} strokeDasharray={`${6 / zoomLevel} ${6 / zoomLevel}`} opacity={0.5} />
						<line x1={end.x} y1={end.y} x2={cp2.x} y2={cp2.y} stroke="black"
							strokeWidth={1 / zoomLevel} strokeDasharray={`${6 / zoomLevel} ${6 / zoomLevel}`} opacity={0.5} />
					</>
				)}
			</svg>
		</HTMLContainer>
	)
}

shouldShowControlLines 只在 select.editing_shapeselect.pointing_handleselect.dragging_handle 状态下且形状被选中时显示控制线。同时 hideSelectionBoundsBghideSelectionBoundsFghideResizeHandles 在编辑状态下隐藏选择框与缩放手柄,toSvg 提供导出所需的 SVG 渲染。

第七步:让手柄在编辑状态下不消失——自定义 Overlay Util

默认的手柄 Overlay ShapeHandleOverlayUtil 只会在 select.idleselect.pointing_handle 状态下显示手柄,编辑状态下仅 note(便签)形状显示手柄。查看其 isActive 实现(packages/tldraw/src/lib/overlays/ShapeHandleOverlayUtil.ts):

override isActive(): boolean {
	const editor = this.editor
	if (editor.getIsReadonly() || editor.getInstanceState().isChangingStyle) return false
	const onlySelectedShape = editor.getOnlySelectedShape()
	if (!onlySelectedShape) return false
	const handles = editor.getShapeHandles(onlySelectedShape)
	if (!handles) return false

	if (editor.isInAny('select.idle', 'select.pointing_handle')) return true
	if (editor.isIn('select.editing_shape')) {
		return editor.isShapeOfType(onlySelectedShape, 'note')
	}
	return false
}

因此示例定义 BezierShapeHandleOverlayUtil 继承它并覆写 isActive:对 bezier-curve 形状,在 select.pointing_handleselect.dragging_handle 以及编辑状态下都保持手柄显示,其他形状回退到默认行为:

export class BezierShapeHandleOverlayUtil extends ShapeHandleOverlayUtil {
	static override type = 'shape_handle'

	override isActive(): boolean {
		const editor = this.editor
		const onlySelectedShape = editor.getOnlySelectedShape()

		if (!onlySelectedShape || !editor.isShapeOfType(onlySelectedShape, 'bezier-curve')) {
			return super.isActive()
		}

		if (editor.getIsReadonly() || editor.getInstanceState().isChangingStyle) return false
		if (!editor.getShapeHandles(onlySelectedShape)) return false

		if (editor.isInAny('select.pointing_handle', 'select.dragging_handle')) return true

		return (
			editor.getEditingShapeId() === onlySelectedShape.id && editor.isIn('select.editing_shape')
		)
	}
}

Overlay Util 通过 overlayUtils={customOverlays} 注入 Tldraw 组件,与默认 util 使用相同的 type: 'shape_handle',从而替换默认行为。

第八步:保持编辑状态的组件接线

示例的核心接线在 CubicBezierShapeExample.tsxonMount 中完成:

  1. 创建形状并进入编辑:editor.createShape({ id, type: 'bezier-curve', x, y }) 后调用 editor.select(id)startEditingBezierShape(内部执行 editor.setEditingShape(id)editor.setCurrentTool('select.editing_shape'))。

  2. 运行时包装 select 工具状态节点:正常情况下,select 工具的子状态(如 select.dragging_handle)在交互结束后会回到 select.idle,导致编辑结束。示例不 fork 整个 select 工具,而是用 editor.getStateDescendant('select.pointing_handle')editor.getStateDescendant('select.editing_shape') 拿到状态节点,包装其事件处理器并委托回原始实现(非 bezier 形状一律走原始逻辑)。包装后的处理器增加了三类交互:

    • Cmd/Ctrl + 点击 cp1/cp2 手柄将其折叠到对应端点(editor.updateShape 把控制点坐标设为端点坐标,然后重新进入编辑状态);
    • 拖动手柄结束后通过 onInteractionEnd 回调 startEditingBezierShape 回到 select.editing_shape 而不是 select.idle
    • 编辑状态下直接拖动曲线本身时切换到 select.translating 平移,结束后同样回到编辑状态。
  3. 编辑状态下的手柄悬停与光标updateHoveredBezierHandleeditor.overlays.getOverlayAtPoint 做手柄命中检测,更新 editor.overlays.setHoveredOverlay 并设置 grab 光标。

第九步:编辑状态下的撤销/重做

文本类形状在编辑时撤销/重做快捷键归文本编辑器所有,编辑器中会忽略 Cmd/Ctrl+ZSneakyUndoRedoWhileEditing.tsx 在 window 级别监听该快捷键,执行撤销/重做后把编辑形状恢复回来:

function handleKeydown(e: KeyboardEvent) {
	if (e.key === 'z' && (e.metaKey || e.ctrlKey)) {
		const editingShape = editor.getEditingShape()
		if (!editingShape) return
		if (e.shiftKey) {
			editor.redo()
			editor.setEditingShape(editingShape)
		} else {
			editor.undo()
			editor.setEditingShape(editingShape)
		}
	}
}
window.addEventListener('keydown', handleKeydown)

注意事项:哪些是公开 API,哪些是 hack

README 中明确标注了该示例的边界:

  • 推荐使用的公开 API:自定义 ShapeUtil(props 校验、getGeometrygetHandlesgetHandleSnapGeometryonHandleDragonTranslateStart/onTranslate)、TLGlobalShapePropsMap 类型扩展、ShapeHandleOverlayUtil 覆写、Overlay 注入——这些都有稳定支撑。
  • hacky 部分:通过 getStateDescendant 在运行时 patch select 工具的状态节点事件处理器,依赖 select 工具内部的状态 id 与事件流,不属于公开 API,可能随版本升级而失效SneakyUndoRedoWhileEditing 同样是对编辑状态下撤销行为的临时补救。从源码结构看(select.pointing_handleselect.dragging_handle 等状态名在 packages/tldraw 的 SelectTool 实现中定义),这些内部状态在后续版本中可能改名或重构。

在自己的应用中复刻本示例时,建议:形状本身、手柄与吸附全部使用公开 API;对"保持编辑状态"的需求优先考虑官方未来的状态机扩展能力,仅在当前版本内把状态节点 patch 作为受控的临时方案,并在升级 tldraw 后回归验证。

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

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.14 K
2.75 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
857
1.35 K
docsdocs
暂无描述
Markdown
899
5.82 K
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
920
1.85 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.8 K
1.02 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
532
596
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.02 K
521
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.36 K
1.46 K
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
548
392