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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,12 +69,13 @@ npx create-tldraw@latest
- **Agent** — AI agents that read, interpret, and modify canvas content
- **Workflow** — drag-and-drop node builder for automation pipelines, visual programming, and no-code platforms
- **Chat** — canvas-powered AI chat where users sketch, annotate, and mark up images alongside conversations
- **Image pipeline** — node-based builder for image generation pipelines
- **Branching chat** — AI chat with visual branching, letting users explore and compare different conversation paths
- **Shader** — WebGL shaders that respond to canvas interactions

## Local development

The development server runs the examples app at `localhost:5420`. Clone the repo, then enable [corepack](https://nodejs.org/api/corepack.html) for the correct yarn version:
The development server runs the examples app at `localhost:5420`. You'll need [Node.js](https://nodejs.org) `^20.0.0`. Clone the repo, then enable [corepack](https://nodejs.org/api/corepack.html) for the correct yarn version:

```bash
npm i -g corepack
Expand Down
24 changes: 16 additions & 8 deletions apps/docs/content/docs/handles.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,15 @@ The `HandleSnapGeometry` object has these properties:
Here's a speech bubble shape with a draggable tail handle:

```tsx
import { Polygon2d, ShapeUtil, TLHandle, TLHandleDragInfo, TLShape, ZERO_INDEX_KEY } from 'tldraw'
import {
Polygon2d,
ShapeUtil,
TLHandle,
TLHandleDragInfo,
TLShape,
Vec,
ZERO_INDEX_KEY,
} from 'tldraw'

const SPEECH_BUBBLE_TYPE = 'speech-bubble'

Expand All @@ -191,13 +199,13 @@ class SpeechBubbleUtil extends ShapeUtil<SpeechBubbleShape> {
const { w, h, tailX, tailY } = shape.props
return new Polygon2d({
points: [
{ x: 0, y: 0 },
{ x: w, y: 0 },
{ x: w, y: h },
{ x: w * 0.7, y: h },
{ x: tailX, y: tailY },
{ x: w * 0.3, y: h },
{ x: 0, y: h },
new Vec(0, 0),
new Vec(w, 0),
new Vec(w, h),
new Vec(w * 0.7, h),
new Vec(tailX, tailY),
new Vec(w * 0.3, h),
new Vec(0, h),
],
isFilled: true,
})
Expand Down
68 changes: 66 additions & 2 deletions apps/docs/content/docs/sync.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@ The best way to get started hosting your own backend is to clone and deploy [our

It uses:

- [Durable Objects](https://developers.cloudflare.com/durable-objects/) to provide a unique WebSocket server per room.
- [R2](https://developers.cloudflare.com/r2/) to persist document snapshots and store large binary assets like images and videos.
- [Durable Objects](https://developers.cloudflare.com/durable-objects/) to provide a unique WebSocket server per room. Room state is persisted automatically to the durable object's built-in SQLite storage.
- [R2](https://developers.cloudflare.com/r2/) to store large binary assets like images and videos.

There are some features that we have not provided and you might want to add yourself, such as authentication and authorization, rate limiting and size limiting for asset uploads, storing snapshots of documents over time for long-term history, and listing and searching for rooms.

Expand Down Expand Up @@ -181,6 +181,70 @@ const room = new TLSocketRoom({ storage })
`tablePrefix` option to avoid conflicts if you're sharing a database with other data.
</Callout>

### WebSocket hibernation

Some serverless platforms let WebSocket connections survive while the surrounding object hibernates. [Cloudflare Durable Objects](https://developers.cloudflare.com/durable-objects/best-practices/websockets/) is the most common example. The platform keeps the sockets open, but your in-memory state (including the `TLSocketRoom`) is gone when the object wakes back up. Every wake would force every client to reconnect from scratch.

`TLSocketRoom` exposes three APIs for hibernation. The `onSessionSnapshot` callback fires when a session has had no message activity for about 5 seconds, so you can persist its state. [TLSocketRoom#getSessionSnapshot](?) returns that snapshot on demand. [TLSocketRoom#handleSocketResume](?) restores a session straight into `Connected` state when the object wakes back up.

Here's the pattern in a Cloudflare Durable Object that uses the WebSocket Hibernation API:

```tsx
import {
DurableObjectSqliteSyncWrapper,
type SessionStateSnapshot,
SQLiteSyncStorage,
TLSocketRoom,
} from '@tldraw/sync-core'
import { TLRecord } from '@tldraw/tlschema'
import { DurableObject } from 'cloudflare:workers'

interface SocketAttachment {
sessionId: string
snapshot?: SessionStateSnapshot
}

export class TldrawDurableObject extends DurableObject {
private room: TLSocketRoom<TLRecord, void> | null = null

private getOrCreateRoom() {
if (this.room) return this.room

const sql = new DurableObjectSqliteSyncWrapper(this.ctx.storage)
const storage = new SQLiteSyncStorage<TLRecord>({ sql })

this.room = new TLSocketRoom<TLRecord, void>({
storage,
// Cloudflare keeps WebSockets alive across hibernation, so let it manage timeouts.
clientTimeout: Infinity,
// Persist each session's snapshot to its WebSocket attachment when it goes idle.
onSessionSnapshot: (sessionId, snapshot) => {
const ws = this.sessionIdToWs.get(sessionId)
if (ws) ws.serializeAttachment({ sessionId, snapshot })
},
})

// Resume any sessions whose sockets survived hibernation.
for (const ws of this.ctx.getWebSockets()) {
const attachment = ws.deserializeAttachment() as SocketAttachment | null
if (attachment?.snapshot) {
this.room.handleSocketResume({
sessionId: attachment.sessionId,
socket: ws,
snapshot: attachment.snapshot,
})
}
}

return this.room
}
}
```

Three pieces make this work. The `onSessionSnapshot` callback persists each session's state to its WebSocket's attachment so the snapshot is still around after hibernation. When the object wakes up with sockets still open, `handleSocketResume` replays each saved snapshot and the session lands straight in `Connected` state without the client noticing. Setting `clientTimeout: Infinity` disables the room's idle timer; hibernating platforms handle keep-alive themselves and would otherwise see the room disconnect perfectly fine clients.

For non-hibernating environments (Node servers, long-lived processes), you don't need any of this. The in-memory `TLSocketRoom` outlives individual sockets, and the default `clientTimeout` keeps idle sessions tidy.

### Asset storage

Tldraw also needs a way to store and retrieve large binary assets like images and videos.
Expand Down
108 changes: 106 additions & 2 deletions apps/docs/content/releases/next.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,25 @@ The following slots have been removed from `TLEditorComponents`. Subclass the ma

The corresponding `Default*` exports and `LiveCollaborators` have also been removed.

Customizing shape indicators — `ShapeIndicator`, `ShapeIndicators`, and `ShapeIndicatorErrorFallback` are gone. Indicators now render through each shape util's `getIndicatorPath()`. Override it to change how a shape's indicator is drawn.
**`ShapeUtil.indicator()` → `ShapeUtil.getIndicatorPath()`.** The shape util method that draws a shape's selection indicator no longer returns JSX. It now returns a `Path2D` (or a `TLIndicatorPath`) and is composited onto the canvas overlay layer. Every custom `ShapeUtil` that overrides `indicator` needs to migrate:

```tsx
// Before
override indicator(shape: MyShape) {
return <rect width={shape.props.w} height={shape.props.h} />
}

// After
override getIndicatorPath(shape: MyShape): Path2D {
const path = new Path2D()
path.rect(0, 0, shape.props.w, shape.props.h)
return path
}
```

For shapes whose indicator should match the shape's geometry, return `path` built from the same primitives. For placeholder shapes (e.g. an `ErrorShape`), return an empty `new Path2D()` and declare the return type explicitly so TypeScript doesn't infer `never`.

The React component slots `ShapeIndicator`, `ShapeIndicators`, and `ShapeIndicatorErrorFallback` are also removed from `TLEditorComponents` — there is no replacement React slot, since indicators now render through `getIndicatorPath()`.

Overlays cannot render React; they draw into a 2D canvas context. For React-based overlays, render an HTML layer above the canvas via a regular `TLEditorComponents` slot like `InFrontOfTheCanvas`.

Expand All @@ -134,6 +152,57 @@ Overriding them (or the removed selectors `.tl-brush`, `.tl-scribble`, `.tl-snap

</details>

### 💥 Tldraw component options

Several `<Tldraw>` props that previously lived at the top level have been consolidated under the `options` prop, and a few extension points moved off the component entirely.

<details>
<summary>Migration guide</summary>

**`cameraOptions`, `textOptions`, and `deepLinks` props moved into `options`:**

```tsx
// Before
<Tldraw cameraOptions={camOpts} textOptions={txtOpts} deepLinks />

// After
<Tldraw options={{ camera: camOpts, text: txtOpts, deepLinks: true }} />
```

**`embeds` prop removed; configure embed definitions on `EmbedShapeUtil`:**

```tsx
// Before
<Tldraw embeds={[...DEFAULT_EMBED_DEFINITIONS, customEmbed]} />

// After
import { EmbedShapeUtil } from 'tldraw'
const ConfiguredEmbedShapeUtil = EmbedShapeUtil.configure({
embedDefinitions: [...DEFAULT_EMBED_DEFINITIONS, customEmbed],
})
<Tldraw shapeUtils={[ConfiguredEmbedShapeUtil]} />
```

This silently compiles in some setups (the prop is unknown but JSX won't always reject it), so this is the kind of change that the typecheck won't always catch — search the source for `embeds=` and migrate any matches.

**`setDefaultEditorAssetUrls()` and `setDefaultUiAssetUrls()` are no longer part of the public API.** They still exist at runtime for now, but they're marked `@internal` and should not be relied on. Pass `assetUrls` to each `<Tldraw>` instead:

```tsx
// Before
import { setDefaultEditorAssetUrls, setDefaultUiAssetUrls } from 'tldraw'
setDefaultEditorAssetUrls(assetUrls)
setDefaultUiAssetUrls(assetUrls)

<Tldraw />

// After
<Tldraw assetUrls={assetUrls} />
```

Do not reach for module augmentation to re-expose the old globals — find each `<Tldraw>` mount point and pass `assetUrls` directly.

</details>

### Custom record types ([#8213](https://github.com/tldraw/tldraw/pull/8213))

You can now register custom record types in the tldraw store for persisting and synchronizing domain-specific data that doesn't fit into shapes, bindings, or assets. Custom records support scoping (document/session/presence), validation, migrations, and default properties.
Expand Down Expand Up @@ -229,7 +298,7 @@ class AudioAssetUtil extends AssetUtil<TLAudioAsset> {

Custom shapes can now opt into frame-like behavior: clipping children, acting as a parent on paste and drag-in, blocking erasure from inside, and supporting full-brush selection. Previously, frame behavior was hardcoded to the built-in `frame` type; the editor and tools now route frame checks through `editor.getShapeUtil(shape).isFrameLike(shape)`.

The easiest way to build one is to extend the new `BaseFrameLikeShapeUtil` abstract class, which provides sensible defaults for `isFrameLike`, `providesBackgroundForChildren`, `canReceiveNewChildrenOfType`, `getClipPath`, `onDragShapesIn`, and `onDragShapesOut`:
The easiest way to build one is to extend the new `BaseFrameLikeShapeUtil` abstract class, which provides sensible defaults for `isFrameLike`, `providesBackgroundForChildren`, `canReceiveNewChildrenOfType`, `canRemoveChildrenOfType`, `getClipPath`, `onDragShapesIn`, and `onDragShapesOut`:

```tsx
import { BaseFrameLikeShapeUtil, SVGContainer } from '@tldraw/editor'
Expand Down Expand Up @@ -282,6 +351,33 @@ const schema = createTLSchema({

Note shapes now track and display a "first edited by" attribution label in the bottom-right corner, showing who first added text to the note.

<details>
<summary>Migration guide</summary>

**`useTldrawUser` has been removed.** Previously it bundled user *preferences* (color, color scheme) and user *identity* (id, name) into a single object. These are now separate concerns:

- **Preferences** (color, color scheme, snap mode, etc.) are managed via `TLUserPreferences`. Set them with the editor's user-preferences API directly.
- **Identity** for attribution comes from a `TLUserStore` provider passed to the new `users` prop on `<Tldraw>`.

```tsx
// Before
const user = useTldrawUser({ userPreferences, setUserPreferences })
<Tldraw user={user} />

// After
<Tldraw
users={{
getCurrentUser: () => currentUserSignal,
resolve: (userId) => resolvedUserSignal(userId),
}}
colorScheme={userPreferences.colorScheme}
/>
```

If you only need preferences (no custom identity), pass `colorScheme` directly and seed preferences imperatively after mount; if you only need identity, pass `users`. Most apps want both.

</details>

### @tldraw/driver ([#7952](https://github.com/tldraw/tldraw/pull/7952))

A new `@tldraw/driver` package provides an imperative API for driving the tldraw editor programmatically. `Driver` wraps an `Editor` instance and exposes event dispatch, selection transforms, clipboard operations, and shape queries with fluent chaining.
Expand Down Expand Up @@ -387,6 +483,14 @@ New APIs include `handleSocketResume()` for restoring sessions from snapshots, `
- 💥 Change `notifyIfFileNotAllowed` signature from `(file, options)` to `(editor, file, options)`. ([#8031](https://github.com/tldraw/tldraw/pull/8031))
- 💥 Change `getAssetInfo` signature from `(file, options, assetId?)` to `(editor, file, assetId?)` and return `TLAsset | null` instead of throwing. ([#8031](https://github.com/tldraw/tldraw/pull/8031))
- 💥 Move the `Cmd+Shift+C` / `Ctrl+Shift+C` shortcut from "Copy as SVG" to "Copy as PNG". ([#8532](https://github.com/tldraw/tldraw/pull/8532))
- 💥 Replace `ShapeUtil.indicator()` (returned JSX) with `ShapeUtil.getIndicatorPath()` (returns `Path2D | TLIndicatorPath | undefined`). Indicators now render to the canvas overlay layer instead of as React elements. ([#8469](https://github.com/tldraw/tldraw/pull/8469))
- 💥 Move `cameraOptions`, `textOptions`, and `deepLinks` from top-level `<Tldraw>` props into the `options` prop (e.g. `options={{ camera, text, deepLinks }}`).
- 💥 Remove the `embeds` prop from `<Tldraw>`. Configure embed definitions via `EmbedShapeUtil.configure({ embedDefinitions })` and pass the configured util through `shapeUtils`.
- 💥 Demote `setDefaultEditorAssetUrls()` and `setDefaultUiAssetUrls()` to `@internal`. Pass `assetUrls` to each `<Tldraw>` instead.
- 💥 Remove `useTldrawUser`. Use the new `users` prop (`TLUserStore`) for identity and `TLUserPreferences` for preferences.
- 💥 Replace `TLDrawShapeSegment.points` with the helper `getPointsFromDrawSegment(segment, scaleX, scaleY)` so segment points respect the shape's current scale.
- 💥 Change `BindingUtil` hook params: `fromShapeType`/`toShapeType` are removed in favor of full `fromShape`/`toShape` records (read `fromShape.type` / `toShape.type` directly).
- 💥 Add `'middle-legacy'` (and other legacy values) to the `align` union resolved by `PlainTextLabel`/`RichTextLabel`. If your code maps `align` into `PlainTextLabel.textAlign`, narrow legacy values to one of `'start' | 'center' | 'end'` before passing them through.
- Add `TLTheme`, `TLThemeId`, `TLThemes`, `TLThemeDefaultColors`, `TLThemeColors`, `TLRemovedDefaultThemeColors`, `ThemeManager`, `getDisplayValues()`, `getColorValue()`, and `DEFAULT_THEME` for the new theme system. ([#8410](https://github.com/tldraw/tldraw/pull/8410))
- Add `themes` and `initialTheme` props to `<Tldraw>` and `<TldrawEditor>`. ([#8410](https://github.com/tldraw/tldraw/pull/8410))
- Add `getCurrentTheme()`, `setCurrentTheme()`, `getThemes()`, `getTheme()`, `updateTheme()`, `updateThemes()`, and `getColorMode()` to the editor. ([#8410](https://github.com/tldraw/tldraw/pull/8410))
Expand Down
35 changes: 34 additions & 1 deletion apps/docs/content/releases/v4.2.0.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,43 @@ This month's release includes many bug fixes and small API additions, along with

## What's new

### TipTap v3 ([#5717](https://github.com/tldraw/tldraw/pull/5717))
### 💥 TipTap v3 ([#5717](https://github.com/tldraw/tldraw/pull/5717))

We've upgraded TipTap from v2 to v3. If you've done any customization to our standard TipTap kit, please refer to TipTap's guide [How to upgrade Tiptap v2 to v3](https://tiptap.dev/docs/guides/upgrade-tiptap-v2) for breaking changes you might experience.

<details>
<summary>Migration guide</summary>

Starting in v4.2, tldraw bundles TipTap v3 as a transitive dependency. If your project pins `@tiptap/*` v2 packages directly, upgrade them to v3 *before* trying to fix any code-level errors — leaving v2 in place produces a dual install that surfaces as confusing type errors.

**Dual-install symptom.** Deeply-nested errors like *"Property 'getCharacterCount' is missing in type 'Editor' but required in type 'Editor'"*, with the two `Editor` types resolving to different paths (one under `node_modules/@tiptap/core`, the other under `node_modules/@tldraw/editor/node_modules/@tiptap/core`), are not API changes — they are the v2/v3 dual install. The `Editor` classes are structurally similar but nominally different. Finishing the v3 upgrade collapses the duplicate and these errors disappear without code changes.

**Code-level changes that affect tldraw users:**

- **Default → named exports.** Several extensions changed from default to named exports:

```ts
// Before
import StarterKit from '@tiptap/starter-kit'

// After
import { StarterKit } from '@tiptap/starter-kit'
```

- **`TextStyle` → `TextStyleKit`, and `FontFamily` moved.** The `FontFamily` extension is no longer in `@tiptap/extension-font-family`; it's part of `TextStyleKit` in `@tiptap/extension-text-style`. The `setFontFamily` and `setFontSize` chain commands only exist on `TextStyleKit`, not on bare `TextStyle`.

- **Transaction handler types.** Inline parameter type annotations on TipTap event handlers no longer work. Import the proper event-map type:

```ts
import { EditorEvents } from '@tiptap/core'
// ...
onTransaction: (props: EditorEvents['transaction']) => { /* ... */ }
```

- **Custom chained commands** still register via `declare module '@tiptap/core'` augmentation in v3 — the augmentation target is the same.

</details>

## API changes

- Add `Editor.setTool`/`Editor.removeTool` for dynamically altering the editor's tool state chart. ([#6909](https://github.com/tldraw/tldraw/pull/6909)) ([#7134](https://github.com/tldraw/tldraw/pull/7134))
Expand Down
Loading
Loading