tldraw SDK 自定义三次贝塞尔曲线形状实战:从 ShapeUtil 手柄拖拽到编辑状态管理
本指南基于 tldraw 仓库中 apps/examples/src/examples/shapes/tools/cubic-bezier-shape 的完整示例,讲解如何在 tldraw SDK 中从零实现一个带可拖拽控制点手柄的三次贝塞尔曲线形状(cubic bezier curve shape),并保持曲线在编辑状态(editing state)下完成手柄拖拽、控制点吸附折叠、整条曲线弯曲等交互。读完本文,你将掌握自定义 ShapeUtil 的关键方法(getGeometry、getHandles、getHandleSnapGeometry、onHandleDrag)、通过 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]):
Cmd/Ctrl + 拖动start/end:改而移动该端点的控制点(cp1/cp2);- 拖动 start/end:端点本身移动,且其控制点跟随相同位移(delta),曲线整体形态不变;
- 拖动 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 + 拖动 完成弯曲。这依赖 onTranslateStart 与 onTranslate 两个钩子:
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_shape、select.pointing_handle、select.dragging_handle 状态下且形状被选中时显示控制线。同时 hideSelectionBoundsBg、hideSelectionBoundsFg、hideResizeHandles 在编辑状态下隐藏选择框与缩放手柄,toSvg 提供导出所需的 SVG 渲染。
第七步:让手柄在编辑状态下不消失——自定义 Overlay Util
默认的手柄 Overlay ShapeHandleOverlayUtil 只会在 select.idle 与 select.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_handle、select.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.tsx 的 onMount 中完成:
-
创建形状并进入编辑:
editor.createShape({ id, type: 'bezier-curve', x, y })后调用editor.select(id)与startEditingBezierShape(内部执行editor.setEditingShape(id)与editor.setCurrentTool('select.editing_shape'))。 -
运行时包装 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平移,结束后同样回到编辑状态。
-
编辑状态下的手柄悬停与光标:
updateHoveredBezierHandle用editor.overlays.getOverlayAtPoint做手柄命中检测,更新editor.overlays.setHoveredOverlay并设置 grab 光标。
第九步:编辑状态下的撤销/重做
文本类形状在编辑时撤销/重做快捷键归文本编辑器所有,编辑器中会忽略 Cmd/Ctrl+Z。SneakyUndoRedoWhileEditing.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 校验、getGeometry、getHandles、getHandleSnapGeometry、onHandleDrag、onTranslateStart/onTranslate)、TLGlobalShapePropsMap类型扩展、ShapeHandleOverlayUtil覆写、Overlay 注入——这些都有稳定支撑。 - hacky 部分:通过
getStateDescendant在运行时 patch select 工具的状态节点事件处理器,依赖select工具内部的状态 id 与事件流,不属于公开 API,可能随版本升级而失效;SneakyUndoRedoWhileEditing同样是对编辑状态下撤销行为的临时补救。从源码结构看(select.pointing_handle、select.dragging_handle等状态名在 packages/tldraw 的 SelectTool 实现中定义),这些内部状态在后续版本中可能改名或重构。
在自己的应用中复刻本示例时,建议:形状本身、手柄与吸附全部使用公开 API;对"保持编辑状态"的需求优先考虑官方未来的状态机扩展能力,仅在当前版本内把状态节点 patch 作为受控的临时方案,并在升级 tldraw 后回归验证。
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 StartedRust0631
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python09
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00