Back to Tldraw

Click detection

apps/docs/content/sdk-features/click-detection.mdx

5.4.08.4 KB
Original Source

In tldraw, the click detection system turns a pair of nearby clicks into a double_click event. The ClickManager tracks consecutive pointer downs with a small state machine and emits double_click when the timing and distance thresholds are met.

After a double-click, extra nearby clicks are treated as overflow. Overflow clicks do not dispatch additional click events; they suppress the sequence long enough to prevent a rapid double-double-click from becoming two double-clicks.

How it works

When a pointer down event occurs, the manager either starts a new sequence, detects a double-click, or moves the sequence into overflow. Each state has a timeout that determines how long to wait before returning to idle.

Two timeout durations control the detection speed. The first click uses doubleClickDurationMs (450ms by default), which is how long the user has to make the second click. After a double-click, multiClickDurationMs (200ms by default) controls both the settle delay and the overflow suppression window. The option name is historical; it now controls only the post-double-click window.

State transitions

The click state machine progresses through these states:

StateDescription
idleNo active click sequence
pendingDoubleFirst click registered, waiting for second
pendingOverflowDouble-click registered, waiting for overflow
overflowExtra clicks detected after the double-click

The second pointer down still reaches the state chart as a pointer_down, followed immediately by a double_click event, and the manager starts waiting for overflow. If the timeout expires before another click, the manager dispatches a double-click settle event and returns to idle. If another pointer down arrives first, the sequence moves to overflow and no further click events are dispatched until the overflow timeout expires.

Distance validation

Consecutive clicks must occur within a maximum distance of 40 pixels (screen space). If pointer down events are farther apart, the new pointer down starts a fresh click sequence instead.

Click event phases

Each click event carries a phase that says when in the sequence it fired:

PhaseWhen it fires
downOn the second pointer down, when the double-click is detected
upOn the matching pointer up while the double-click is still pending
settle-downWhen the timeout expires without overflow while the pointer is down
settle-upWhen the timeout expires without overflow after the pointer is up

The phase system lets tools respond at different points in the click sequence. Most default selection and cropping behavior responds on the down phase, so it starts on the second pointer down. The hand tool's double-click zoom responds on settle-up, so an overflow click can still cancel the pending zoom.

Movement cancellation

If the pointer moves too far during a pending click sequence, the system cancels the sequence and returns to idle. This prevents double-click detection during click-drag operations. The movement threshold is dragDistanceSquared for fine pointers (mouse, stylus) and coarseDragDistanceSquared for coarse pointers (touchscreens), both editor options.

The manager only sees pointer events that match the current pen mode: in pen mode, finger taps don't produce double-clicks, and outside pen mode, pen taps don't.

Handling click events

Tools receive click events through handler methods defined in the TLEventHandlers interface. Here's a complete example of a custom tool that zooms in as soon as a double-click is detected:

tsx
import { StateNode, TLClickEventInfo, Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'

class ZoomTool extends StateNode {
	static override id = 'zoom'

	override onDoubleClick(info: TLClickEventInfo) {
		if (info.phase !== 'down') return
		// info.point is in client space; zoomIn wants a point relative to the viewport
		const screenPoint = this.editor.inputs.getCurrentScreenPoint()
		this.editor.zoomIn(screenPoint, { animation: { duration: 200 } })
	}
}

const customTools = [ZoomTool]

export default function App() {
	return (
		<div style={{ position: 'fixed', inset: 0 }}>
			<Tldraw
				tools={customTools}
				onMount={(editor) => {
					editor.setCurrentTool('zoom')
				}}
			/>
		</div>
	)
}

Use phase: 'down' for behavior that should start on the second pointer down, or a settle phase for behavior that should wait until the overflow window has passed.

The select tool routes shape double-clicks to ShapeUtil#onDoubleClick, and double-clicks on handles, edges, and corners to ShapeUtil#onDoubleClickHandle, ShapeUtil#onDoubleClickEdge, and ShapeUtil#onDoubleClickCorner. Return a partial shape object to apply changes:

tsx
override onDoubleClick(shape: MyShape) {
	return {
		id: shape.id,
		type: shape.type,
		props: { expanded: !shape.props.expanded },
	}
}

The TLClickEventInfo type includes these properties:

PropertyTypeDescription
type'click'Event type identifier
name'double_click'Which click event this is
pointVecLikePointer position in client space
pointerIdnumberUnique identifier for the pointer
buttonnumberMouse button (0 = left, 1 = middle, 2 = right)
phase'down' | 'up' | 'settle-down' | 'settle-up'When in the click sequence this fired
target'canvas' | 'selection' | 'shape' | 'handle' | 'overlay'What was clicked
shapeTLShape | undefinedThe shape, when target is 'shape' or 'handle'
handleTLHandle | TLSelectionHandle | undefinedThe handle, when target is 'handle' or 'selection'
overlayTLOverlay | undefinedThe overlay, when target is 'overlay'
shiftKeybooleanWhether Shift was held
altKeybooleanWhether Alt/Option was held
ctrlKeybooleanWhether Control (or Command) was held
metaKeybooleanWhether Meta/Command was held
accelKeybooleanPlatform accelerator key (Cmd on Mac, Ctrl on Windows)

Timing configuration

Click timing is configured through the editor's options:

OptionDefaultDescription
doubleClickDurationMs450msTime window for the first click to become a double-click
multiClickDurationMs200msDouble-click settle delay and overflow suppression window