Cubic bezier curve shape
A custom shape with interactive bezier curve editing.
import {
createShapeId,
Tldraw,
type Editor,
type StateNode,
type TLAnyOverlayUtilConstructor,
type TLAnyShapeUtilConstructor,
type TLHandle,
type TLOverlay,
type TLPointerEventInfo,
type TLShapeId,
} from 'tldraw'
import { BezierShapeHandleOverlayUtil } from './BezierShapeHandleOverlayUtil'
import { BezierCurveShapeUtil } from './CubicBezierShape'
import { SneakyUndoRedoWhileEditing } from './SneakyUndoRedoWhileEditing'
const customShapes: TLAnyShapeUtilConstructor[] = [BezierCurveShapeUtil]
type ShapeHandleOverlay = TLOverlay<{ shapeId: TLShapeId; handle: TLHandle }>
type PointingHandleState = StateNode & {
info?: TLPointerEventInfo & { target: 'handle' }
}
function getPointingHandleInfo(
state: PointingHandleState,
fallback?: TLPointerEventInfo
): (TLPointerEventInfo & { target: 'handle' }) | undefined {
if (state.info?.target === 'handle') return state.info
if (fallback?.target === 'handle') return fallback
return undefined
}
const customOverlays: TLAnyOverlayUtilConstructor[] = [BezierShapeHandleOverlayUtil]
function startEditingBezierShape(editor: Editor, id: TLShapeId) {
editor.setEditingShape(id)
editor.setCurrentTool('select.editing_shape')
}
function getBezierHandleOverlayAtPoint(editor: Editor): ShapeHandleOverlay | null {
const overlay = editor.overlays.getOverlayAtPoint(
editor.inputs.getCurrentPagePoint(),
editor.options.hitTestMargin / editor.getZoomLevel()
)
if (!overlay || overlay.type !== 'shape_handle') return null
const shapeHandleOverlay = overlay as ShapeHandleOverlay
const shape = editor.getShape(shapeHandleOverlay.props.shapeId)
if (!shape || !editor.isShapeOfType(shape, 'bezier-curve')) return null
return shapeHandleOverlay
}
function updateHoveredBezierHandle(editor: Editor) {
const editingShape = editor.getEditingShape()
if (!editingShape || !editor.isShapeOfType(editingShape, 'bezier-curve')) return false
const overlay = getBezierHandleOverlayAtPoint(editor)
const previousOverlayId = editor.overlays.getHoveredOverlayId()
if (overlay) {
editor.overlays.setHoveredOverlay(overlay.id)
editor.setHoveredShape(null)
const cursor = editor.overlays.getOverlayUtil(overlay).getCursor(overlay)
if (cursor) {
editor.setCursor({ type: cursor, rotation: editor.getSelectionRotation() })
}
return true
}
if (previousOverlayId) {
editor.setCursor({ type: 'default', rotation: 0 })
}
editor.overlays.setHoveredOverlay(null)
return false
}
export default function BezierCurveShapeExample() {
return (
<div className="tldraw__editor">
<Tldraw
// [9]
components={{}}
overlayUtils={customOverlays}
shapeUtils={customShapes}
onMount={(editor) => {
editor.user.updateUserPreferences({ isSnapMode: true })
const viewportPageBounds = editor.getViewportPageBounds()
const centerX = viewportPageBounds.center.x
const centerY = viewportPageBounds.center.y
const id = createShapeId()
editor.createShape({
id,
type: 'bezier-curve',
x: centerX - 200,
y: centerY - 150,
})
// Select and edit the shape on appear
editor.select(id)
startEditingBezierShape(editor, id)
// [10]
// Get state nodes with proper type safety
const pointingHandleState =
editor.getStateDescendant<PointingHandleState>('select.pointing_handle')
const editingShapeState = editor.getStateDescendant<StateNode>('select.editing_shape')
if (!pointingHandleState) {
throw new Error('SelectTool pointing_handle state not found')
}
if (!editingShapeState) {
throw new Error('SelectTool editing_shape state not found')
}
// store original handlers with proper binding
const originalHandlers = {
pointingHandle: {
onPointerMove: pointingHandleState.onPointerMove?.bind(pointingHandleState),
onPointerUp: pointingHandleState.onPointerUp?.bind(pointingHandleState),
},
editingShape: {
onPointerDown: editingShapeState.onPointerDown?.bind(editingShapeState),
onPointerMove: editingShapeState.onPointerMove?.bind(editingShapeState),
},
}
// clicking on start or end point should not go to select.idle
pointingHandleState.onPointerUp = (info: TLPointerEventInfo) => {
const handleInfo = getPointingHandleInfo(pointingHandleState, info)
if (!handleInfo) {
originalHandlers.pointingHandle.onPointerUp?.(info)
return
}
if (
handleInfo.accelKey &&
editor.isShapeOfType(handleInfo.shape, 'bezier-curve') &&
handleInfo.target === 'handle'
) {
switch (handleInfo.handle.id) {
case 'cp1': {
editor.updateShape({
id: handleInfo.shape.id,
type: 'bezier-curve',
props: {
cp1: {
x: handleInfo.shape.props.start.x,
y: handleInfo.shape.props.start.y,
},
},
})
startEditingBezierShape(editor, handleInfo.shape.id)
return
}
case 'cp2': {
editor.updateShape({
id: handleInfo.shape.id,
type: 'bezier-curve',
props: {
cp2: { x: handleInfo.shape.props.end.x, y: handleInfo.shape.props.end.y },
},
})
startEditingBezierShape(editor, handleInfo.shape.id)
return
}
}
}
if (
editor.isShapeOfType(handleInfo.shape, 'bezier-curve') &&
handleInfo.target === 'handle'
) {
startEditingBezierShape(editor, handleInfo.shape.id)
return
}
originalHandlers.pointingHandle.onPointerUp?.(info)
}
// return to editing state after dragging a handle
pointingHandleState.onPointerMove = (info: TLPointerEventInfo) => {
if (!editor.inputs.getIsDragging()) {
originalHandlers.pointingHandle.onPointerMove?.(info)
return
}
const handleInfo = getPointingHandleInfo(pointingHandleState, info)
if (!handleInfo) {
originalHandlers.pointingHandle.onPointerMove?.(info)
return
}
if (editor.isShapeOfType(handleInfo.shape, 'bezier-curve')) {
editor.updateInstanceState({ isToolLocked: true })
editor.setCurrentTool('select.dragging_handle', {
...handleInfo,
onInteractionEnd: () => {
startEditingBezierShape(editor, handleInfo.shape.id)
},
})
return
}
originalHandlers.pointingHandle.onPointerMove?.(info)
}
editingShapeState.onPointerDown = (info: TLPointerEventInfo) => {
const editingShape = editor.getEditingShape()
if (editingShape && editor.isShapeOfType(editingShape, 'bezier-curve')) {
const overlay = getBezierHandleOverlayAtPoint(editor)
if (overlay) {
editor.setCurrentTool('select.pointing_handle', {
...info,
target: 'handle',
shape: editingShape,
handle: overlay.props.handle,
})
return
}
}
originalHandlers.editingShape.onPointerDown?.(info)
}
// allow translating in editing state
editingShapeState.onPointerMove = (info: TLPointerEventInfo) => {
if (updateHoveredBezierHandle(editor) && !editor.inputs.getIsDragging()) return
if (editor.inputs.getIsDragging()) {
const editingShape = editor.getEditingShape()
if (editingShape && editor.isShapeOfType(editingShape, 'bezier-curve')) {
editor.updateInstanceState({ isToolLocked: true })
editor.setCurrentTool('select.translating', {
...info,
target: 'shape',
shape: editingShape,
onInteractionEnd: () => {
startEditingBezierShape(editor, editingShape.id)
},
})
return
}
}
originalHandlers.editingShape.onPointerMove?.(info)
}
}}
>
{/* 11 */}
<SneakyUndoRedoWhileEditing />
</Tldraw>
</div>
)
}
/*
Introduction:
This example demonstrates how to create a cubic bezier curve shape with interactive handles.
[9]
Use a custom shape handle overlay to show handles for bezier curves when editing, pointing, or
dragging handles while preserving the default behavior for other shapes.
[10]
Override state node methods to enable three custom interactions:
1. Meta + click on cp1/cp2 handles collapses them to their associated start/end points
2. After dragging any handle, stay in editing mode (instead of returning to select.idle)
3. Allow translating the curve while in editing mode by detecting drag and transitioning to select.translating
[11]
Add a sneaky undo/redo while editing. This is a hack to allow undo/redo while editing a shape.
It's not a perfect solution, but it's a workaround for the fact that tldraw doesn't support
undo/redo while editing a shape. Sometimes you gotta hack it.
These overrides maintain the editing context, allowing fluid adjustments without losing handle visibility.
*/
This example demonstrates how to create a cubic bezier curve shape with draggable control handles. It includes a custom pen tool for entering edit mode and shows how to customize handle behavior and snapping.
The shape features four handles (start, end, and two control points) that can be dragged to adjust the curve. Control points snap to the start and end positions, and moving the endpoints automatically shifts their associated control points to maintain smooth editing.
Is this page helpful?
Prev
Custom tool (screenshot)Next
Custom shape and tool