Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 45 additions & 53 deletions apps/docs/content/sdk-features/click-detection.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,9 @@ updated_at: 12/29/2025
keywords:
- click
- double-click
- triple-click
- quadruple-click
- overflow click
- pointer
- ClickManager
- multi-click
status: published
date: 12/20/2025
readability: 9
Expand All @@ -19,54 +17,53 @@ accuracy: 10
notes: Could add a simpler opening hook per voice guide ("Have five minutes?"-style).
---

In tldraw, the click detection system lets you respond to double, triple, and quadruple clicks. The [ClickManager](?) tracks consecutive pointer down events using a state machine, dispatching click events when timing and distance thresholds are met.
In tldraw, the click detection system turns a pair of nearby clicks into a `double_click` event. The [ClickManager](?) tracks consecutive pointer down events using a state machine, dispatching a double-click event when timing and distance thresholds are met.

This powers text editing features like word selection on double-click and paragraph selection on triple-click. You can use these events to add custom multi-click behaviors in your own shapes and tools.
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 or advances to the next click level. Each state has a timeout that determines how long to wait for the next click before settling on the current level.
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), giving users time to initiate a double-click sequence. Subsequent clicks use the shorter `multiClickDurationMs` (200ms by default), requiring faster input for triple and quadruple clicks. This pattern matches common operating system behavior.
Two timeout durations control the detection speed. The first click uses `doubleClickDurationMs` (450ms by default), giving users time to initiate a double-click sequence. 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:

| State | Description |
| ------------------ | ------------------------------------------ |
| `idle` | No active click sequence |
| `pendingDouble` | First click registered, waiting for second |
| `pendingTriple` | Second click registered, waiting for third |
| `pendingQuadruple` | Third click registered, waiting for fourth |
| `pendingOverflow` | Fourth click registered, waiting for fifth |
| `overflow` | More than four clicks detected |
| State | Description |
| ----------------- | --------------------------------------------- |
| `idle` | No active click sequence |
| `pendingDouble` | First click registered, waiting for second |
| `pendingOverflow` | Double-click registered, waiting for overflow |
| `overflow` | Extra clicks detected after the double-click |

When a pointer down event arrives, the state advances to the next pending state and sets a timeout. If the timeout expires before the next click, the manager dispatches a settle event for the current click level and returns to idle. If another pointer down arrives before the timeout, the state advances and a click event is dispatched immediately.
The second pointer down dispatches a `double_click` event immediately and 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 click sequence resets to idle.
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

Click events are dispatched with a `phase` property indicating when in the sequence the event fired:

| Phase | When it fires |
| -------- | -------------------------------------------------------------- |
| `down` | Immediately when a multi-click is detected during pointer down |
| `up` | During pointer up for multi-clicks that are still pending |
| `settle` | When the timeout expires without further clicks |
| Phase | When it fires |
| ------------- | ------------------------------------------------------------------- |
| `down` | On the second pointer down, when the double-click is detected |
| `up` | On the matching pointer up while the double-click is still pending |
| `settle-down` | When the timeout expires without overflow while the pointer is down |
| `settle-up` | When the timeout expires without overflow after the pointer is up |

The phase system lets tools and shapes respond at different points in the click sequence. For example, the hand tool waits for the `settle` phase before zooming in—this avoids triggering a zoom if the user is about to triple-click.
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 multi-click detection during click-drag operations. The movement threshold differs for coarse pointers (touchscreens) and fine pointers (mouse, stylus).
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 differs for coarse pointers (touchscreens) and fine pointers (mouse, stylus).

## 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 on double-click:
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'
Expand All @@ -76,14 +73,9 @@ class ZoomTool extends StateNode {
static override id = 'zoom'

override onDoubleClick(info: TLClickEventInfo) {
if (info.phase !== 'settle') return
if (info.phase !== 'down') return
this.editor.zoomIn(info.point, { animation: { duration: 200 } })
}

override onTripleClick(info: TLClickEventInfo) {
if (info.phase !== 'settle') return
this.editor.zoomOut(info.point, { animation: { duration: 200 } })
}
}

const customTools = [ZoomTool]
Expand All @@ -102,9 +94,9 @@ export default function App() {
}
```

Tools can handle [StateNode#onDoubleClick](?), [StateNode#onTripleClick](?), and [StateNode#onQuadrupleClick](?) events. Each receives a [TLClickEventInfo](?) object with details about the click.
Tools can handle [StateNode#onDoubleClick](?) events. The handler receives a [TLClickEventInfo](?) object with details about the click. 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.

Shape utilities handle double-clicks through the [ShapeUtil#onDoubleClick](?) method. Return a partial shape object to apply changes:
The select tool routes shape double-clicks to [ShapeUtil#onDoubleClick](?). Return a partial shape object to apply changes:

```tsx
override onDoubleClick(shape: MyShape) {
Expand All @@ -118,31 +110,31 @@ override onDoubleClick(shape: MyShape) {

The [TLClickEventInfo](?) type includes these properties:

| Property | Type | Description |
| ----------- | ------------------------------------------------------- | ------------------------------------------------------ |
| `type` | `'click'` | Event type identifier |
| `name` | `'double_click' \| 'triple_click' \| 'quadruple_click'` | Which multi-click event this is |
| `point` | `VecLike` | Pointer position in client space |
| `pointerId` | `number` | Unique identifier for the pointer |
| `button` | `number` | Mouse button (0 = left, 1 = middle, 2 = right) |
| `phase` | `'down' \| 'up' \| 'settle'` | When in the click sequence this fired |
| `target` | `'canvas' \| 'selection' \| 'shape' \| 'handle'` | What was clicked |
| `shape` | `TLShape \| undefined` | The shape, when target is `'shape'` or `'handle'` |
| `handle` | `TLHandle \| undefined` | The handle, when target is `'handle'` |
| `shiftKey` | `boolean` | Whether Shift was held |
| `altKey` | `boolean` | Whether Alt/Option was held |
| `ctrlKey` | `boolean` | Whether Control was held |
| `metaKey` | `boolean` | Whether Meta/Command was held |
| `accelKey` | `boolean` | Platform accelerator key (Cmd on Mac, Ctrl on Windows) |
| Property | Type | Description |
| ----------- | ------------------------------------------------ | ------------------------------------------------------ |
| `type` | `'click'` | Event type identifier |
| `name` | `'double_click'` | Which click event this is |
| `point` | `VecLike` | Pointer position in client space |
| `pointerId` | `number` | Unique identifier for the pointer |
| `button` | `number` | Mouse 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'` | What was clicked |
| `shape` | `TLShape \| undefined` | The shape, when target is `'shape'` or `'handle'` |
| `handle` | `TLHandle \| undefined` | The handle, when target is `'handle'` |
| `shiftKey` | `boolean` | Whether Shift was held |
| `altKey` | `boolean` | Whether Alt/Option was held |
| `ctrlKey` | `boolean` | Whether Control was held |
| `metaKey` | `boolean` | Whether Meta/Command was held |
| `accelKey` | `boolean` | Platform accelerator key (Cmd on Mac, Ctrl on Windows) |

## Timing configuration

Click timing is configured through the editor's [options](/sdk-features/options):

| Option | Default | Description |
| ----------------------- | ------- | -------------------------------------------------------- |
| `doubleClickDurationMs` | 450ms | Time window for the first click to become a double-click |
| `multiClickDurationMs` | 200ms | Time window for subsequent clicks in the sequence |
| Option | Default | Description |
| ----------------------- | ------- | --------------------------------------------------------- |
| `doubleClickDurationMs` | 450ms | Time window for the first click to become a double-click |
| `multiClickDurationMs` | 200ms | Double-click settle delay and overflow suppression window |

## Related examples

Expand Down
2 changes: 1 addition & 1 deletion apps/docs/content/sdk-features/events.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ Input events have different types:
| Type | Names | Description |
| ---------- | ----------------------------------------------------------------------------------------- | ----------------------------------- |
| `pointer` | `pointer_down`, `pointer_move`, `pointer_up`, `right_click`, `middle_click`, `long_press` | Mouse, touch, and pen interactions |
| `click` | `double_click`, `triple_click`, `quadruple_click` | Multi-click sequences |
| `click` | `double_click` | Double-click sequences |
| `keyboard` | `key_down`, `key_up`, `key_repeat` | Keyboard input |
| `wheel` | `wheel` | Scroll wheel and trackpad scrolling |
| `pinch` | `pinch_start`, `pinch`, `pinch_end` | Two-finger pinch gestures |
Expand Down
16 changes: 8 additions & 8 deletions apps/docs/content/sdk-features/input-handling.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -151,19 +151,19 @@ When an input event occurs, the editor processes it through these stages:
1. The browser fires a native DOM event
2. The editor's UI layer captures the event and transforms it into a typed event info object
3. For pointer, pinch, and wheel events, the editor calls `updateFromEvent()` on the `InputsManager`
4. For pointer events, the editor dispatches to the `ClickManager` for multi-click detection (double-click, triple-click, etc.)
4. For pointer events, the editor dispatches to the `ClickManager` for double-click detection and overflow suppression
5. The editor sends the event to the state machine via `root.handleEvent()`
6. The state machine propagates the event through active tool states

The typed event info objects are:

| Event type | Info type |
| -------------------------- | --------------------- |
| Pointer events | `TLPointerEventInfo` |
| Click events (multi-click) | `TLClickEventInfo` |
| Keyboard events | `TLKeyboardEventInfo` |
| Wheel events | `TLWheelEventInfo` |
| Pinch events | `TLPinchEventInfo` |
| Event type | Info type |
| --------------- | --------------------- |
| Pointer events | `TLPointerEventInfo` |
| Click events | `TLClickEventInfo` |
| Keyboard events | `TLKeyboardEventInfo` |
| Wheel events | `TLWheelEventInfo` |
| Pinch events | `TLPinchEventInfo` |

In collaborative sessions, `updateFromEvent()` also updates the user's pointer presence record in the store, broadcasting pointer position to other users.

Expand Down
2 changes: 1 addition & 1 deletion apps/docs/content/sdk-features/options.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ Configure how the editor interprets user input timing:
| Option | Default | Description |
| ----------------------- | ------- | ------------------------------------------------- |
| `doubleClickDurationMs` | 450 | Maximum interval for double-click detection |
| `multiClickDurationMs` | 200 | Maximum interval for triple/quadruple click |
| `multiClickDurationMs` | 200 | Double-click settle delay and overflow window |
| `longPressDurationMs` | 500 | Duration to trigger long press |
| `animationMediumMs` | 320 | Duration for camera animations (zoom, pan to fit) |

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,7 @@ export function GroupSettingsDialog({ groupId, onClose }: GroupSettingsDialogPro
{member.userName}
{member.userId === app.getUser().id ? ` (${youMsg})` : ''}
</span>
{isOwner && member.userId !== app.getUser().id ? (
{isOwner && (member.userId !== app.getUser().id || ownersCount > 1) ? (
<MemberRoleSelect
value={member.role}
disabled={member.role === 'owner' && ownersCount <= 1}
Expand Down
1 change: 1 addition & 0 deletions apps/dotcom/zero-cache/.env
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@ ZERO_MUTATE_URL="http://localhost:8787/app/zero/mutate"
ZERO_QUERY_URL="http://localhost:8787/app/zero/query"
ZERO_ENABLE_CRUD_MUTATIONS=false
ZERO_LOG_LEVEL="info"
ZERO_ENABLE_STARTUP_MESSAGE=0
DO_NOT_TRACK=1
2 changes: 2 additions & 0 deletions apps/examples/e2e/fixtures/menus/StylePanel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export class StylePanel {
readonly stylesArray: string[]
readonly colors: { [key: string]: Locator }
readonly fill: { [key: string]: Locator }
readonly fillExtra: Locator
readonly dash: { [key: string]: Locator }
readonly size: { [key: string]: Locator }
readonly font: { [key: string]: Locator }
Expand Down Expand Up @@ -39,6 +40,7 @@ export class StylePanel {
semi: this.page.getByTestId('style.fill.semi'),
solid: this.page.getByTestId('style.fill.solid'),
}
this.fillExtra = this.page.getByTestId('style.fill-extra')
this.dash = {
draw: this.page.getByTestId('style.dash.draw'),
dashed: this.page.getByTestId('style.dash.dashed'),
Expand Down
2 changes: 1 addition & 1 deletion apps/examples/e2e/tests/test-kbds.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -661,7 +661,7 @@ test.describe('Shape Navigation', () => {

// Select both shapes
await page.keyboard.press('v')
await page.mouse.click(310, 50)
await page.mouse.move(310, 50)
await page.mouse.down()
await page.mouse.move(310, 50)
await page.mouse.move(700, 500)
Expand Down
17 changes: 17 additions & 0 deletions apps/examples/e2e/tests/test-style-panel.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,23 @@ test.describe('Style selection behaviour', () => {
await stylePanel.isActive(blue)
})

test('the fill dropdown trigger tooltip describes the dropdown, not the current fill', async ({
isMobile,
stylePanel,
toolbar,
}) => {
const { solid } = stylePanel.fill
if (isMobile) {
await toolbar.mobileStylesButton.click()
}

// when the current fill lives outside the dropdown (the default "none", or "solid"),
// the trigger tooltip should just name the style, not a value the dropdown can't show
await expect(stylePanel.fillExtra).toHaveAttribute('aria-label', 'Fill')
await solid.click()
await expect(stylePanel.fillExtra).toHaveAttribute('aria-label', 'Fill')
})

test('selecting a style changes the style of the shapes', async ({
page,
stylePanel,
Expand Down
8 changes: 8 additions & 0 deletions apps/examples/src/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@
</head>
<body>
<div id="root"></div>
<script>
// Disable React's console.createTask instrumentation — it wraps every
// component render in a "Run console task" that clutters the Performance
// flame chart without adding useful information.
console.createTask = () => {
// noop
}
</script>
<script type="module" src="./index.tsx"></script>
<noscript>You need to enable JavaScript to run tldraw. ✌️</noscript>
</body>
Expand Down
Loading
Loading