snapshot of https://github.com/tldraw/tldraw.git (main, 2026-09-23; blobs > 131072B omitted)
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
This commit is contained in:
11
apps/docs/content/community/contributing.mdx
Normal file
11
apps/docs/content/community/contributing.mdx
Normal file
@@ -0,0 +1,11 @@
|
||||
---
|
||||
title: Contributing
|
||||
status: published
|
||||
author: steveruizok
|
||||
date: 3/22/2023
|
||||
order: 1
|
||||
---
|
||||
|
||||
tldraw's source code is available on GitHub at [github.com/tldraw/tldraw](https://github.com/tldraw/tldraw). While you can read the code, we are not accepting external contributions.
|
||||
|
||||
The best way to help improve tldraw is to share your feedback. To report a bug or request a feature, [open an issue](https://github.com/tldraw/tldraw/issues) on GitHub or let us know on [Discord](https://discord.tldraw.com/?utm_source=docs&utm_medium=organic&utm_campaign=sociallink). If a code example would help the discussion, please fork the repository and link to your branch in the issue.
|
||||
71
apps/docs/content/community/dependencies.mdx
Normal file
71
apps/docs/content/community/dependencies.mdx
Normal file
@@ -0,0 +1,71 @@
|
||||
---
|
||||
title: Dependencies
|
||||
description: The third-party packages that the tldraw SDK depends on, and the licenses they are published under.
|
||||
status: published
|
||||
order: 3
|
||||
keywords:
|
||||
- license
|
||||
- licenses
|
||||
- dependencies
|
||||
- third party
|
||||
- open source
|
||||
- compliance
|
||||
---
|
||||
|
||||
{/* This page is generated by `yarn license-report --prod`. Do not edit it by hand. */}
|
||||
|
||||
The tldraw SDK itself is published under the [tldraw license](/legal/tldraw-license). It also depends on the third-party packages listed below, each under its own license.
|
||||
|
||||
This covers the runtime dependencies that the `@tldraw/*` packages declare directly. It does not include their own transitive dependencies, our development and build tooling, or anything in the tldraw.com application. If you need a full transitive list for the exact versions you have installed, generate one from your own lockfile.
|
||||
|
||||
## Dependencies
|
||||
|
||||
| Dependency | Version | License | Used by |
|
||||
| -------------------------------------------------------------------- | ------- | ------- | -------------------------------------------- |
|
||||
| [@tiptap/core](https://github.com/ueberdosis/tiptap) | 3.13.0 | MIT | `commenting`, `editor`, `mentions`, `tldraw` |
|
||||
| [@tiptap/extension-code](https://github.com/ueberdosis/tiptap) | 3.13.0 | MIT | `tldraw` |
|
||||
| [@tiptap/extension-highlight](https://github.com/ueberdosis/tiptap) | 3.13.0 | MIT | `tldraw` |
|
||||
| [@tiptap/extension-list](https://github.com/ueberdosis/tiptap) | 3.13.0 | MIT | `tldraw` |
|
||||
| [@tiptap/extension-mention](https://github.com/ueberdosis/tiptap) | 3.13.0 | MIT | `mentions` |
|
||||
| [@tiptap/extension-typography](https://github.com/ueberdosis/tiptap) | 3.13.0 | MIT | `tldraw` |
|
||||
| [@tiptap/pm](https://github.com/ueberdosis/tiptap) | 3.13.0 | MIT | `commenting`, `editor`, `mentions`, `tldraw` |
|
||||
| [@tiptap/react](https://github.com/ueberdosis/tiptap) | 3.13.0 | MIT | `commenting`, `editor`, `mentions`, `tldraw` |
|
||||
| [@tiptap/starter-kit](https://github.com/ueberdosis/tiptap) | 3.13.0 | MIT | `tldraw` |
|
||||
| [@tiptap/suggestion](https://github.com/ueberdosis/tiptap) | 3.13.0 | MIT | `mentions` |
|
||||
| [classnames](https://github.com/JedWatson/classnames) | 2.5.1 | MIT | `editor`, `tldraw` |
|
||||
| [eventemitter3](https://github.com/primus/eventemitter3) | 4.0.7 | MIT | `editor` |
|
||||
| [idb](https://github.com/jakearchibald/idb) | 7.1.1 | ISC | `editor`, `tldraw` |
|
||||
| [is-plain-object](https://github.com/jonschlinkert/is-plain-object) | 5.0.0 | MIT | `editor` |
|
||||
| [lodash.isequal](https://github.com/lodash/lodash) | 4.5.0 | MIT | `utils` |
|
||||
| [lodash.isequalwith](https://github.com/lodash/lodash) | 4.4.0 | MIT | `utils` |
|
||||
| [lodash.throttle](https://github.com/lodash/lodash) | 4.1.1 | MIT | `utils` |
|
||||
| [lodash.uniq](https://github.com/lodash/lodash) | 4.5.0 | MIT | `utils` |
|
||||
| [lz-string](https://github.com/pieroxy/lz-string) | 1.5.0 | MIT | `tldraw` |
|
||||
| [mermaid](https://github.com/mermaid-js/mermaid) | 11.16.1 | MIT | `mermaid` |
|
||||
| [nanoevents](https://github.com/ai/nanoevents) | 7.0.1 | MIT | `sync`, `sync-core` |
|
||||
| [radix-ui](https://github.com/radix-ui/primitives) | 1.4.2 | MIT | `tldraw` |
|
||||
| [rbush](https://github.com/mourner/rbush) | 3.0.1 | MIT | `editor` |
|
||||
| [ws](https://github.com/websockets/ws) | 8.21.0 | MIT | `sync`, `sync-core` |
|
||||
|
||||
## Inline code
|
||||
|
||||
Some third-party code is included directly in our source rather than as a dependency. Each of these files carries its license in a comment at the top.
|
||||
|
||||
| File | License | Copyright |
|
||||
| ---------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
|
||||
| `packages/create-tldraw/src/group-select.ts` | [MIT](https://github.com/bombshell-dev/clack/blob/1adb270af5509ead3d22c4fa602b465f39c7a8c2/packages/prompts/LICENSE) | Nate Moore |
|
||||
| `packages/create-tldraw/src/wrap-ansi.ts` | [MIT](https://github.com/chalk/wrap-ansi/blob/163b878a6eb5d8c32b7bbea65036eeadc0e4def9/license) | Sindre Sorhus <sindresorhus@gmail.com> (https://sindresorhus.com) |
|
||||
| `packages/editor/src/lib/hooks/useScreenBounds.ts` | [MIT](https://github.com/excalidraw/excalidraw/blob/master/LICENSE) | |
|
||||
| `packages/editor/src/lib/hooks/useViewportHeight.ts` | [BSD](https://github.com/outline/rich-markdown-editor/blob/main/LICENSE) | 2020 General Outline, Inc (https://www.getoutline.com/) and individual contributors. |
|
||||
| `packages/editor/src/lib/utils/browserCanvasMaxSize.ts` | [MIT](https://github.com/jhildenbiddle/canvas-size/blob/master/LICENSE) | John Hildenbiddle |
|
||||
| `packages/store/src/lib/ImmutableMap.ts` | [MIT](https://github.com/immutable-js/immutable-js/blob/main/LICENSE) | 2014-present, Lee Byron and other contributors. |
|
||||
| `packages/tldraw/src/lib/shapes/shared/PathBuilder.tsx` | [Apache](https://github.com/adobe-webplatform/Snap.svg/blob/master/LICENSE) | |
|
||||
| `packages/tldraw/src/lib/ui/hooks/useKeyboardShortcuts.ts` | [MIT](https://github.com/jaywcjlove/hotkeys-js/blob/master/LICENSE) | 2015-present, Kenny Wong |
|
||||
| `packages/tldraw/src/lib/ui/kbd-utils.ts` | [MIT](https://github.com/jaywcjlove/hotkeys-js/blob/master/LICENSE) | 2015-present, Kenny Wong |
|
||||
| `packages/tldraw/src/lib/utils/svg/sanitizeSvg.ts` | MIT | 2015 Mario Heiderich |
|
||||
| `packages/utils/src/lib/bind.ts` | [MIT](https://github.com/NoHomey/bind-decorator/blob/master/License) | 2016 Ivo Stratev |
|
||||
| `packages/utils/src/lib/id.ts` | [MIT](https://github.com/ai/nanoid/blob/main/LICENSE) | 2017 Andrey Sitnik <andrey@sitnik.ru> |
|
||||
| `packages/utils/src/lib/media/apng.ts` | [MIT](https://github.com/vHeemstra/is-apng/blob/main/license) | Philip van Heemstra |
|
||||
| `packages/utils/src/lib/media/gif.ts` | MIT | 2016 Józef Sokołowski <j.k.sokolowski@gmail.com> |
|
||||
| `packages/utils/src/lib/media/png.ts` | [MIT](https://github.com/alexgorbatchev/crc/blob/master/LICENSE) | 2014 Alex Gorbatchev |
|
||||
| `packages/utils/src/lib/media/webp.ts` | [MIT](https://github.com/sindresorhus/is-webp/blob/main/license) | Sindre Sorhus <sindresorhus@gmail.com> (https://sindresorhus.com) |
|
||||
61
apps/docs/content/community/license.mdx
Normal file
61
apps/docs/content/community/license.mdx
Normal file
@@ -0,0 +1,61 @@
|
||||
---
|
||||
title: License
|
||||
status: published
|
||||
author: steveruizok
|
||||
date: 9/13/2023
|
||||
order: 2
|
||||
---
|
||||
|
||||
The tldraw SDK's source code and published packages are provided under the [tldraw license](/legal/tldraw-license).
|
||||
|
||||
Under its default terms, the tldraw SDK license permits use **only in development**.
|
||||
|
||||
To use the tldraw SDK **in production**, you need one of the following:
|
||||
|
||||
- a [trial license](#Trial-license) for evaluation
|
||||
- a [commercial license](#Commercial-license) for commercial projects
|
||||
- a [hobby license](#Hobby-license) for non-commercial projects
|
||||
|
||||
Trial, commercial, and hobby licenses each come with a license key. The SDK will work in production only when provided with a valid and active license key. See the [License keys](#License-keys) section for more information.
|
||||
|
||||
### Trial license
|
||||
|
||||
You can get a free 100-day trial license by completing [this form](https://tldraw.dev/get-a-license/trial). When you submit the form, we will immediately begin your free trial and email you a [license key](#License-keys). You are allowed only one trial license per commercial unit. Please do not abuse trial licenses and tell your friends not to, either. During your trial, the SDK [collects information](#Data-collection) about where it is used.
|
||||
|
||||
The license key will stop working when the trial ends. If you wish to continue using tldraw in production after this date, you will need to get either a [commercial license](#Commercial-license) or a [hobby license](#Hobby-license).
|
||||
|
||||
### Commercial license
|
||||
|
||||
You can request a commercial license by completing [this form](https://tldraw.dev/get-a-license/plans). When you submit the form, our sales team will be in touch to learn about your requirements and discuss pricing. Startup pricing may be available for small teams. Check our [pricing page](https://tldraw.dev/pricing) for the latest.
|
||||
|
||||
### Hobby license
|
||||
|
||||
For non-commercial projects, we also provide a discretionary hobby license. You can request a hobby license by completing [this form](https://tldraw.dev/get-a-license/hobby). When you submit the form, our team may issue a license or reach out to learn about your project. When using the tldraw SDK under a hobby license, the "made with tldraw" watermark must be shown on the canvas.
|
||||
|
||||
## License keys
|
||||
|
||||
The tldraw SDK enforces its license using **license keys**.
|
||||
|
||||
When you receive a trial, commercial, or hobby license, you will also receive a public license key that encodes information about your license. You can provide this license key to the SDK as shown below.
|
||||
|
||||
```tsx
|
||||
function App() {
|
||||
return <Tldraw licenseKey="tldraw-***********************" />
|
||||
}
|
||||
```
|
||||
|
||||
License keys are validated **on the client**. You can use them offline. They can be public. The tldraw SDK will not work in production without a valid license key.
|
||||
|
||||
## Data collection
|
||||
|
||||
When using the tldraw SDK under a commercial or hobby license, no information is sent to tldraw.
|
||||
|
||||
When using the tldraw SDK in production under a trial license, the tldraw SDK will ping tldraw's servers with a hash of the license key. This information is primarily for analytics purposes. No user data, canvas contents, or personally-identifiable information (PII) is sent or saved.
|
||||
|
||||
## Notes on open source
|
||||
|
||||
While the tldraw SDK is [source available](https://github.com/tldraw/tldraw), it is not permissively licensed. Many of our examples and demos are available under the MIT license, and you should feel free to use this code as if it were your own; however, any source code or packages covered by the tldraw SDK license would not be [Open Source](https://opensource.org/osd) by any definition.
|
||||
|
||||
If you wish to include tldraw in an open source project, you may do so but the SDK itself must remain under its original license. This means that you and your downstream users will require their own trial, commercial, or hobby license in order to use the SDK in production. In this case, please also see our [trademark guidelines](https://tldraw.dev/legal/trademark-guidelines) that limit the use of the tldraw name and branding.
|
||||
|
||||
The tldraw SDK is a product of a company. See [tldraw.dev](https://tldraw.dev/company) for more information about the company and the team.
|
||||
13
apps/docs/content/community/translations.mdx
Normal file
13
apps/docs/content/community/translations.mdx
Normal file
@@ -0,0 +1,13 @@
|
||||
---
|
||||
title: Translations
|
||||
status: published
|
||||
author: steveruizok
|
||||
date: 11/7/2023
|
||||
order: 0
|
||||
---
|
||||
|
||||
The tldraw [user interface](/docs/user-interface) is translated into more than forty languages. Where a key's translation is missing in the user's current language, the default (English) translation is used instead.
|
||||
|
||||
We manage our translations through [Lokalise](https://lokalise.com).
|
||||
|
||||
To learn how the SDK detects and applies languages, see [Internationalization](/sdk-features/internationalization).
|
||||
249
apps/docs/content/docs/ai.mdx
Normal file
249
apps/docs/content/docs/ai.mdx
Normal file
@@ -0,0 +1,249 @@
|
||||
---
|
||||
title: AI integrations
|
||||
status: published
|
||||
author: steveruizok
|
||||
date: 1/31/2026
|
||||
order: 7
|
||||
keywords:
|
||||
- ai
|
||||
- llm
|
||||
- agent
|
||||
- machine learning
|
||||
- automation
|
||||
- generative
|
||||
---
|
||||
|
||||
You can use language models to read the tldraw canvas and to create shapes on it. This page covers three patterns for doing that: using the canvas as an output surface for generated content, building visual workflows where AI models are nodes in a graph, and giving an agent direct control of the editor. It also covers exporting canvas content so a model can read it.
|
||||
|
||||
If you're feeding the docs themselves to a model, see [LLM documentation](/docs/llm-docs). To turn model-generated diagram text into shapes, see [Mermaid diagrams](/docs/mermaid). To let an agent simulate user input instead of calling editor methods, see [Driving the editor](/docs/driver).
|
||||
|
||||
## Canvas as output
|
||||
|
||||
The simplest AI integration uses the canvas as a surface for displaying generated content. When an AI model generates an image, website preview, or other visual output, you can place it on the canvas as a shape.
|
||||
|
||||
### Embedding generated content
|
||||
|
||||
Use the [EmbedShapeUtil](?) to display websites, or create custom shapes to render generated images and HTML content:
|
||||
|
||||
```tsx
|
||||
editor.createShape({
|
||||
type: 'embed',
|
||||
x: 100,
|
||||
y: 100,
|
||||
props: {
|
||||
url: 'https://generated-preview.example.com/abc123',
|
||||
w: 800,
|
||||
h: 600,
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Embeds from hosts that aren't in the util's embed definitions render in a restricted sandbox. To allow a host of your own, use `EmbedShapeUtil.configure({ embedDefinitions })`. For content you generate yourself, a custom shape (below) is usually the better fit.
|
||||
|
||||
For generated images, use the [AssetRecordType](?) to create an asset from a blob or URL, then create an image shape:
|
||||
|
||||
```tsx
|
||||
const asset = AssetRecordType.create({
|
||||
id: AssetRecordType.createId(),
|
||||
type: 'image',
|
||||
props: {
|
||||
src: generatedImageUrl,
|
||||
w: 512,
|
||||
h: 512,
|
||||
mimeType: 'image/png',
|
||||
name: 'generated-image.png',
|
||||
isAnimated: false,
|
||||
},
|
||||
})
|
||||
|
||||
editor.createAssets([asset])
|
||||
editor.createShape({
|
||||
type: 'image',
|
||||
x: 100,
|
||||
y: 100,
|
||||
props: {
|
||||
assetId: asset.id,
|
||||
w: 512,
|
||||
h: 512,
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Custom preview shapes
|
||||
|
||||
For richer AI output, create a custom shape that renders generated content. This approach works well for live HTML previews, interactive prototypes, or any content that needs special rendering:
|
||||
|
||||
```tsx
|
||||
import { HTMLContainer, ShapeUtil } from 'tldraw'
|
||||
|
||||
// Abbreviated: a full ShapeUtil also needs getDefaultProps, getGeometry, and
|
||||
// getIndicatorPath. See the Shapes docs for a complete custom shape.
|
||||
class PreviewShapeUtil extends ShapeUtil<PreviewShape> {
|
||||
static override type = 'preview' as const
|
||||
|
||||
component(shape: PreviewShape) {
|
||||
return (
|
||||
<HTMLContainer>
|
||||
<iframe
|
||||
srcDoc={shape.props.html}
|
||||
sandbox="allow-scripts"
|
||||
style={{ width: '100%', height: '100%', border: 'none' }}
|
||||
/>
|
||||
</HTMLContainer>
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This pattern is useful for "make real" style applications where users sketch a UI and an AI model generates working code to preview alongside the original drawing. See [Shapes](/docs/shapes) for how to write the rest of the util.
|
||||
|
||||
## Visual workflows
|
||||
|
||||
The tldraw binding system enables node-based visual programming where AI models can be part of a larger workflow. Shapes represent operations or data sources, and bindings connect them to form processing pipelines.
|
||||
|
||||
### Workflow architecture
|
||||
|
||||
In a visual workflow, each node is a custom shape with input and output ports. Connections between nodes are bindings that track relationships as shapes move. When data flows through the system, each node processes its inputs and produces outputs for downstream nodes.
|
||||
|
||||
See the [Workflow starter kit](/starter-kits/workflow) for a complete implementation of this pattern. The starter kit includes:
|
||||
|
||||
- Custom node shapes with configurable ports
|
||||
- A binding system for smart connections that update as nodes move
|
||||
- An execution engine that resolves dependencies and runs nodes in order
|
||||
- Tools for creating and managing connections
|
||||
|
||||
### Adding AI to workflows
|
||||
|
||||
To add AI capabilities to a workflow, create node types that call AI models:
|
||||
|
||||
```tsx
|
||||
// Abbreviated: a real NodeDefinition also declares a validator, ports, a default
|
||||
// value, and a body height. See the workflow starter kit for the full interface.
|
||||
class LLMNode extends NodeDefinition<LLMNodeData> {
|
||||
static type = 'llm'
|
||||
|
||||
async execute(shape, node, inputs) {
|
||||
const response = await fetch('/api/generate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ prompt: inputs.prompt }),
|
||||
})
|
||||
const result = await response.json()
|
||||
return { output: result.text }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Workflow systems let users compose AI operations visually: connect a prompt source to a model, route its output to other steps, and build a pipeline without writing code.
|
||||
|
||||
## AI agents
|
||||
|
||||
For full canvas control, you can give AI models direct access to read and manipulate shapes. An agent can observe what's on the canvas, understand spatial relationships, and create or modify shapes to accomplish tasks.
|
||||
|
||||
### Agent architecture
|
||||
|
||||
The [Agent starter kit](/starter-kits/agent) is a complete implementation. The agent gathers context from screenshots and structured shape data, applies the model's responses through a set of typed actions, and streams results onto the canvas as they arrive. Chat history carries across prompts.
|
||||
|
||||
### Using the agent programmatically
|
||||
|
||||
The agent exposes a simple API for triggering canvas operations:
|
||||
|
||||
```tsx
|
||||
// Inside a component wrapped by the starter kit's TldrawAgentAppProvider
|
||||
const agent = useAgent()
|
||||
|
||||
// Simple prompt
|
||||
agent.prompt('Draw a flowchart showing user authentication')
|
||||
|
||||
// With additional context
|
||||
agent.prompt({
|
||||
message: 'Add labels to these shapes',
|
||||
bounds: { x: 0, y: 0, w: 500, h: 400 },
|
||||
})
|
||||
```
|
||||
|
||||
### How agents see the canvas
|
||||
|
||||
The agent builds context from multiple sources:
|
||||
|
||||
- A screenshot of the current viewport
|
||||
- Simplified representations of shapes within view
|
||||
- Information about shape clusters outside the viewport
|
||||
- The user's current selection and recent actions
|
||||
- Conversation history from the session
|
||||
|
||||
### How agents manipulate the canvas
|
||||
|
||||
Agents perform operations through typed action schemas. Each action has a defined structure, and the agent system validates, sanitizes, and applies actions to the editor:
|
||||
|
||||
```tsx
|
||||
// Abbreviated from CreateActionUtil in the agent starter kit. The model describes
|
||||
// shapes in a simplified format that the util converts to a real shape record.
|
||||
class CreateActionUtil extends AgentActionUtil<CreateAction> {
|
||||
override applyAction(action: Streaming<CreateAction>, helpers: AgentHelpers) {
|
||||
const { shape } = action
|
||||
if (!shape || !shape._type) return
|
||||
|
||||
// Translate from the model's coordinate space back to the page
|
||||
const shapePartial = helpers.removeOffsetFromShapePartial(shape)
|
||||
const result = convertPartialFocusedShapeToTldrawShape(this.editor, shapePartial, {
|
||||
defaultShape: getDefaultShape(shape._type, action.complete),
|
||||
complete: action.complete,
|
||||
})
|
||||
if (!result.shape) return
|
||||
|
||||
this.editor.createShape(result.shape)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The sanitization layer handles common LLM mistakes. It corrects shape IDs that don't exist, ensures new IDs are unique, and normalizes coordinates.
|
||||
|
||||
## Reading canvas content
|
||||
|
||||
For applications where AI reads but doesn't modify the canvas, you can export canvas content for analysis.
|
||||
|
||||
### Screenshots
|
||||
|
||||
Use [Editor#getSvgString](?) or [Editor#toImage](?) to export the current view or specific regions:
|
||||
|
||||
```tsx
|
||||
const svg = await editor.getSvgString(editor.getCurrentPageShapes())
|
||||
const { blob } = await editor.toImage(editor.getCurrentPageShapes(), { format: 'png' })
|
||||
```
|
||||
|
||||
### Structured data
|
||||
|
||||
Access shape data directly from the store for text extraction or structured analysis. Use [ShapeUtil#getText](?) to extract a shape's text content as a plain string. It returns `undefined` for shapes with no text:
|
||||
|
||||
```tsx
|
||||
const shapes = editor.getCurrentPageShapes()
|
||||
|
||||
const textContent = shapes.map((shape) => ({
|
||||
id: shape.id,
|
||||
type: shape.type,
|
||||
text: editor.getShapeUtil(shape).getText(shape),
|
||||
bounds: editor.getShapePageBounds(shape),
|
||||
}))
|
||||
```
|
||||
|
||||
Sending both to the model works best: the image shows spatial relationships and styling, and the structured data gives exact text and positions.
|
||||
|
||||
## Starter kits
|
||||
|
||||
We provide several starter kits for AI integrations:
|
||||
|
||||
| Kit | Description |
|
||||
| ---------------------------------------------- | ---------------------------------------------------------------- |
|
||||
| [Agent](/starter-kits/agent) | Full agent system with visual context and canvas manipulation |
|
||||
| [Chat](/starter-kits/chat) | Canvas for sketching and annotation as context for chat |
|
||||
| [Branching chat](/starter-kits/branching-chat) | Visual conversation trees with AI responses |
|
||||
| [Workflow](/starter-kits/workflow) | Node-based visual programming that can incorporate AI operations |
|
||||
|
||||
## Related
|
||||
|
||||
- [LLM documentation](/docs/llm-docs) — Feeding the tldraw docs to a model
|
||||
- [Mermaid diagrams](/docs/mermaid) — Turning diagram text into shapes
|
||||
- [Driving the editor](/docs/driver) — Simulating user input programmatically
|
||||
- [Shapes](/docs/shapes) — Creating custom shapes for AI-generated content
|
||||
- [Bindings](/sdk-features/bindings) — Connecting shapes for workflow systems
|
||||
- [Image export](/sdk-features/image-export) — Exporting canvas content for AI analysis
|
||||
63
apps/docs/content/docs/assets.mdx
Normal file
63
apps/docs/content/docs/assets.mdx
Normal file
@@ -0,0 +1,63 @@
|
||||
---
|
||||
title: Assets
|
||||
status: published
|
||||
author: steveruizok
|
||||
date: 6/9/2023
|
||||
order: 5
|
||||
keywords:
|
||||
- image
|
||||
- video
|
||||
- file
|
||||
- images
|
||||
- videos
|
||||
- files
|
||||
---
|
||||
|
||||
Assets are records that store data about shared resources like images and videos. Shapes reference assets by ID rather than embedding files directly, so you can reuse the same image across multiple shapes without duplicating data.
|
||||
|
||||
For the complete guide to working with assets, see the [Assets](/sdk-features/assets) reference.
|
||||
|
||||
## Default storage behavior
|
||||
|
||||
[TLAssetStore](?) controls how assets are uploaded and resolved. The default depends on your store setup:
|
||||
|
||||
| Setup | Asset store | Where files go |
|
||||
| --------------------------------------------- | -------------------------------------------- | ----------------------------------------------------------------------------------------- |
|
||||
| In-memory only (default) | [inlineBase64AssetStore](?) | Data URLs inside the document |
|
||||
| [`persistenceKey`](/sdk-features/persistence) | Built in, overridable with the `assets` prop | The browser's [IndexedDB](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API) |
|
||||
| [Sync server](/docs/sync) | Your own `TLAssetStore`, passed to `useSync` | A storage service like S3 or R2 |
|
||||
|
||||
To use your own storage, implement `upload` and `resolve` and pass the store to `<Tldraw>` (or to `useSync`):
|
||||
|
||||
```tsx
|
||||
import { Tldraw, TLAssetStore } from 'tldraw'
|
||||
import 'tldraw/tldraw.css'
|
||||
|
||||
const myAssetStore: TLAssetStore = {
|
||||
async upload(asset, file) {
|
||||
const url = await uploadToMyServer(file)
|
||||
return { src: url }
|
||||
},
|
||||
resolve(asset) {
|
||||
return asset.props.src
|
||||
},
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0 }}>
|
||||
<Tldraw assets={myAssetStore} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
`resolve` also receives a [TLAssetContext](?) with the current screen scale and DPR, so you can serve a resized image instead of the original. Pasted and dropped files go through the [external content](/sdk-features/external-content) handlers before they become assets.
|
||||
|
||||
## Examples
|
||||
|
||||
- [Using hosted images](/examples/data/assets/hosted-images) — Reference images by URL without uploading them
|
||||
- [Customizing default asset options](/examples/configuration/asset-props) — Size and dimension limits for uploaded assets
|
||||
- [Handling pasted/dropped external content](/examples/data/assets/external-content-sources) — Turn pasted content into assets
|
||||
- [Simple asset store with server upload](https://github.com/tldraw/tldraw/blob/main/templates/simple-server-example/src/client/App.tsx) — Upload files to a Node server
|
||||
- [Asset store with image optimization](https://github.com/tldraw/tldraw/blob/main/packages/sync/src/useSyncDemo.ts) — The demo server's `createDemoAssetStore`, which resolves resized images
|
||||
105
apps/docs/content/docs/collaboration.mdx
Normal file
105
apps/docs/content/docs/collaboration.mdx
Normal file
@@ -0,0 +1,105 @@
|
||||
---
|
||||
title: Collaboration
|
||||
status: published
|
||||
author: alex
|
||||
date: 7/24/2024
|
||||
order: 6
|
||||
keywords:
|
||||
- data
|
||||
- sync
|
||||
- persistence
|
||||
- database
|
||||
- multiplayer
|
||||
- collaboration
|
||||
- server
|
||||
- websockets
|
||||
---
|
||||
|
||||
The tldraw SDK supports real-time collaboration. Add collaboration to your project with our [tldraw sync](/docs/sync) library, or use our low-level data APIs to integrate other backends.
|
||||
|
||||
<MaskedArticleImage
|
||||
gifSrc="/images/multiplayer.gif"
|
||||
maskSrc="/images/multiplayer_mask.png"
|
||||
alt="Multiplayer example using tldraw sync"
|
||||
/>
|
||||
|
||||
## Quick start
|
||||
|
||||
The fastest way to add multiplayer is with the `useSyncDemo` hook from [`@tldraw/sync`](/docs/sync). This connects to a hosted demo server that handles synchronization.
|
||||
|
||||
```bash
|
||||
npm install @tldraw/sync
|
||||
```
|
||||
|
||||
```tsx
|
||||
import { Tldraw } from 'tldraw'
|
||||
import { useSyncDemo } from '@tldraw/sync'
|
||||
import 'tldraw/tldraw.css'
|
||||
|
||||
export default function App() {
|
||||
const store = useSyncDemo({ roomId: 'my-company-my-unique-room-id' })
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0 }}>
|
||||
<Tldraw store={store} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Any apps connecting to the same room ID will enter a shared collaboration session. The room ID namespace is shared by everyone using the demo server, so prefix it with your company or project name. Open your project in an incognito window or different browser to test it out.
|
||||
|
||||
The demo server is great for prototyping, but data is deleted after about a day and rooms are publicly accessible. For production, you'll need to self-host the tldraw sync server. See our full guide on [tldraw sync](/docs/sync).
|
||||
|
||||
## Comments
|
||||
|
||||
Collaborators can also leave anchored comments on the canvas. The `@tldraw/commenting` package provides a comment tool and pins that stick to points and shapes. Each pin opens a thread popover with replies, mentions, reactions, and resolving, and a sidebar lists every thread.
|
||||
|
||||
```tsx
|
||||
import {
|
||||
CanvasComments,
|
||||
CommentAuthor,
|
||||
commentToolOverrides,
|
||||
commentTools,
|
||||
} from '@tldraw/commenting'
|
||||
import { useSync } from '@tldraw/sync'
|
||||
import { commentSchemaRecords, TLComponents, Tldraw } from 'tldraw'
|
||||
import '@tldraw/commenting/commenting.css'
|
||||
|
||||
// Your app's user directory, however you already hold it.
|
||||
const AUTHORS: Record<string, CommentAuthor> = {
|
||||
'user-123': { name: 'You', color: '#EC5E41' },
|
||||
}
|
||||
|
||||
const components: TLComponents = {
|
||||
InFrontOfTheCanvas: () => (
|
||||
<CanvasComments currentUserId="user-123" resolveAuthor={(id) => AUTHORS[id]} />
|
||||
),
|
||||
}
|
||||
|
||||
function MyApp() {
|
||||
const store = useSync({
|
||||
uri: 'wss://your-server.com/sync/my-room',
|
||||
assets: myAssetStore,
|
||||
records: commentSchemaRecords,
|
||||
})
|
||||
|
||||
return (
|
||||
<Tldraw
|
||||
store={store}
|
||||
tools={commentTools}
|
||||
overrides={[commentToolOverrides]}
|
||||
components={components}
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Comment records are opt-in, so register `commentSchemaRecords` on your server's schema too, and list the comment record types in the room's `objectTypes` (see [Comments and the object-store lane](/docs/sync#Comments-and-the-object-store-lane)). Commenting is a licensed feature. See the [commenting overview](/docs/commenting) for an introduction, or the [commenting reference](/sdk-features/commenting) for anchors, options, and server setup.
|
||||
|
||||
## Using other backends
|
||||
|
||||
While [tldraw sync](/docs/sync) is our recommended solution, the tldraw SDK works with any real-time data backend. For example, [Liveblocks offers a tldraw integration](https://liveblocks.io/examples/tldraw-whiteboard), and many teams have connected tldraw to their own infrastructure.
|
||||
|
||||
A custom backend has to handle a few things. Document changes need to get in and out of the [store](/sdk-features/store); see [Persistence](/docs/persistence) for the basics and the [collaboration deep dive](/sdk-features/collaboration) for building custom sync. Presence (cursor positions, selections, viewports) is shared through presence records, covered in the same deep dive; [Cursors](/sdk-features/cursors) explains how to customize collaborator cursors, and [User following](/sdk-features/user-following) covers tracking another user's viewport.
|
||||
|
||||
The visual side is handled by the default UI. Override components like `SharePanel` through [UI components](/sdk-features/ui-components). The [OverlayUtil](?) system renders collaborator cursors, brushes, scribbles, and selection indicators; see [Overlay utils](/sdk-features/overlay-utils).
|
||||
141
apps/docs/content/docs/commenting.mdx
Normal file
141
apps/docs/content/docs/commenting.mdx
Normal file
@@ -0,0 +1,141 @@
|
||||
---
|
||||
title: Commenting
|
||||
status: published
|
||||
author: steveruizok
|
||||
date: 7/27/2026
|
||||
order: 7
|
||||
keywords:
|
||||
- comment
|
||||
- comments
|
||||
- commenting
|
||||
- thread
|
||||
- annotation
|
||||
- feedback
|
||||
- review
|
||||
- collaboration
|
||||
---
|
||||
|
||||
In tldraw, a comment is a message pinned to a place on the canvas. Comments group into threads, one conversation per pin. Every comment records who wrote it, as an id your app resolves to a name.
|
||||
|
||||
The `@tldraw/commenting` package works at two levels. `CanvasComments` is a comments layer you render in front of the canvas, and it handles the whole flow. Everything it's built from is exported too, so you can replace any part of it.
|
||||
|
||||
Commenting is a licensed feature. It runs in development without a key. In production it needs a tldraw license that includes commenting.
|
||||
|
||||
## Quick start
|
||||
|
||||
Three pieces: register the comment record types, register the comment tool, and render the layer.
|
||||
|
||||
```tsx
|
||||
import {
|
||||
CanvasComments,
|
||||
CommentAuthor,
|
||||
commentToolOverrides,
|
||||
commentTools,
|
||||
} from '@tldraw/commenting'
|
||||
import { useMemo } from 'react'
|
||||
import { commentSchemaRecords, createTLSchema, createTLStore, TLComponents, Tldraw } from 'tldraw'
|
||||
import '@tldraw/commenting/commenting.css'
|
||||
import 'tldraw/tldraw.css'
|
||||
|
||||
const AUTHORS: Record<string, CommentAuthor> = { me: { name: 'You', color: '#EC5E41' } }
|
||||
const resolveAuthor = (id: string) => AUTHORS[id]
|
||||
|
||||
const components: TLComponents = {
|
||||
InFrontOfTheCanvas: () => <CanvasComments currentUserId="me" resolveAuthor={resolveAuthor} />,
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const store = useMemo(
|
||||
() => createTLStore({ schema: createTLSchema({ records: commentSchemaRecords }) }),
|
||||
[]
|
||||
)
|
||||
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0 }}>
|
||||
<Tldraw
|
||||
licenseKey={YOUR_LICENSE_KEY}
|
||||
store={store}
|
||||
tools={commentTools}
|
||||
overrides={[commentToolOverrides]}
|
||||
components={components}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Pick the comment tool from Quick Actions, or press `C`, then click the canvas to start a thread.
|
||||
|
||||
The layer's two required inputs are both about identity. `currentUserId` is the id stamped on whatever the user posts. `resolveAuthor` turns an author id into a name, plus an optional color and avatar image. Commenting reads no user directory of its own, so these are where you connect it to whatever your app already knows about its users. Together with the read-status and mention callbacks they make up the `CommentingContext`, which the sidebar takes too.
|
||||
|
||||
## Comments are records
|
||||
|
||||
Comment threads and comments are records in the editor's store, exactly like shapes. They aren't in the default schema, so you opt in by registering `commentSchemaRecords`. Once you have, comments persist and sync however your document already does. Adding a `persistenceKey` or a sync backend carries them along with no extra work.
|
||||
|
||||
It also means you can read and write them yourself: query the store for threads, seed a document with review notes, or build a panel that does something the built-in sidebar doesn't. Comment records aren't part of the `TLRecord` union, so use the package's typed helpers and hooks rather than `editor.store` directly.
|
||||
|
||||
```tsx
|
||||
import { useCommentThreads } from '@tldraw/commenting'
|
||||
import { useEditor } from 'tldraw'
|
||||
|
||||
function OpenThreadCount() {
|
||||
const editor = useEditor()
|
||||
const threads = useCommentThreads(editor)
|
||||
return <div>{threads.filter((thread) => !thread.resolved).length} open threads</div>
|
||||
}
|
||||
```
|
||||
|
||||
## Anchors
|
||||
|
||||
What pins a thread to the canvas is its anchor. A thread can anchor to a point on the page, to a shape it then follows as that shape moves and resizes, to a rectangular region, or to the page as a whole.
|
||||
|
||||
Clicking empty canvas gives you a point anchor and clicking a shape gives you a shape anchor. Region anchors are off by default (`enableRegions`); with them on, dragging the tool out covers an area.
|
||||
|
||||
Shape-anchored threads outlive their shape. Delete the shape and the thread converts to a point anchor where its pin last sat, so the conversation doesn't disappear along with the thing it was about.
|
||||
|
||||
## What you get
|
||||
|
||||
The comments layer covers the whole flow out of the box:
|
||||
|
||||
| Feature | Description |
|
||||
| ------------ | ----------------------------------------------------------------------- |
|
||||
| Threads | Pins on the canvas, opening to replies, edit, resolve, and delete. |
|
||||
| Shortcuts | `C` picks the tool, `Shift+C` hides the pins, `Escape` closes a thread. |
|
||||
| Mentions | `@`-mentions in composers, resolved against a roster you supply. |
|
||||
| Reactions | Emoji reactions on comments, with a pluggable palette. |
|
||||
| A sidebar | A filterable list of threads beside the canvas. |
|
||||
| Clustering | Nearby pins fold into count badges as you zoom out. |
|
||||
| Unread state | Pin badges and an unread filter, driven by your app's read data. |
|
||||
|
||||
Each of these has options, and each visible piece is a component slot you can replace. See [Commenting](/sdk-features/commenting) for the full guide.
|
||||
|
||||
## Configuring
|
||||
|
||||
Commenting options live on the tool. `CommentTool.configure()` returns a configured subclass to register, mirroring `ShapeUtil.configure`:
|
||||
|
||||
```tsx
|
||||
import { CommentTool } from '@tldraw/commenting'
|
||||
|
||||
const tools = [CommentTool.configure({ enableRegions: true })]
|
||||
```
|
||||
|
||||
The option worth knowing about first is `history`. Comment writes default to `'ignore'`, which keeps them off the undo stack, and that default matters in a shared document: an undoable resolve would revert a thread a collaborator has since reopened. Deletes are never undoable, whatever `history` says. See [Comments and undo](/sdk-features/commenting#Comments-and-undo).
|
||||
|
||||
## Permissions
|
||||
|
||||
`canComment` decides whether the viewer may participate, and the UI follows it: composers give way to a fallback slot and the action affordances hide. `canModifyComment` decides the writes that belong to someone in particular: editing a comment, deleting a comment, deleting a thread. Unset, only the comment's author may edit or delete it, and only the thread's creator may delete the thread. A callback widens that, say for a workspace admin who may remove anyone's comment, or narrows it.
|
||||
|
||||
They're UI-level controls and nothing more. Comment records carry a client-supplied author id, so rules about who may post, edit, or delete belong on your sync server, which checks each incoming record against the session's identity.
|
||||
|
||||
## Syncing
|
||||
|
||||
Comments sync like the rest of your document, with one registration on each side. Pass `records: commentSchemaRecords` to your sync hook, and the same map to `createTLSchema` on the server.
|
||||
|
||||
On the server you can go a step further and serve comments through the room's object-store lane. Lane records are gated by their own per-session permission rather than by `isReadonly`, which is how a viewer who can't edit the document can still comment. See [Syncing comments](/sdk-features/commenting#Syncing-comments).
|
||||
|
||||
## Related
|
||||
|
||||
- [Commenting](/sdk-features/commenting) — The full guide: anchors, options, components, and sync
|
||||
- [Collaboration](/docs/collaboration) — Adding multiplayer to your project
|
||||
- [tldraw sync](/docs/sync) — Running a sync server
|
||||
- [Commenting example](/examples/collaboration/commenting) — The flow end to end
|
||||
120
apps/docs/content/docs/driver.mdx
Normal file
120
apps/docs/content/docs/driver.mdx
Normal file
@@ -0,0 +1,120 @@
|
||||
---
|
||||
title: Driving the editor
|
||||
status: published
|
||||
author: steveruizok
|
||||
date: 4/28/2026
|
||||
order: 8
|
||||
keywords:
|
||||
- driver
|
||||
- automation
|
||||
- scripting
|
||||
- testing
|
||||
- repl
|
||||
- imperative
|
||||
- input
|
||||
- simulation
|
||||
---
|
||||
|
||||
You can drive the tldraw editor programmatically with the `@tldraw/driver` package. It wraps an [Editor](?) with an imperative, fluent API for simulating user input, using only public editor methods. You can use it for scripting, automation, REPL sessions, and writing tests.
|
||||
|
||||
## Quick start
|
||||
|
||||
Install the package alongside `tldraw`:
|
||||
|
||||
```bash
|
||||
npm install @tldraw/driver
|
||||
```
|
||||
|
||||
Wrap an [Editor](?) instance with a [Driver](?) and dispatch input:
|
||||
|
||||
```tsx
|
||||
import { Driver } from '@tldraw/driver'
|
||||
import { Tldraw } from 'tldraw'
|
||||
import 'tldraw/tldraw.css'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0 }}>
|
||||
<Tldraw
|
||||
onMount={(editor) => {
|
||||
const driver = new Driver(editor)
|
||||
|
||||
driver.click(100, 200).pointerDown(300, 400).pointerMove(500, 600).pointerUp()
|
||||
driver.keyPress('a')
|
||||
|
||||
return () => driver.dispose()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Every input method returns `this`, so calls can be chained. Coordinates default to the current pointer position when omitted, which is why `pointerUp()` needs none. Call `dispose` when you're done so the driver can clean up its side-effect handlers.
|
||||
|
||||
## Simulating input
|
||||
|
||||
Pointer, keyboard, wheel, and pinch events all flow through `editor.dispatch`, so they go through the editor's normal tool state machines. A pointerDown, pointerMove, pointerUp sequence with the draw tool active creates real draw shapes:
|
||||
|
||||
```ts
|
||||
editor.setCurrentTool('draw')
|
||||
driver.pointerDown(100, 100)
|
||||
driver.pointerMove(150, 120)
|
||||
driver.pointerMove(200, 140)
|
||||
driver.pointerUp()
|
||||
```
|
||||
|
||||
Pointer coordinates are in screen space. For page-space positions, use `editor.pageToScreen` to convert before dispatching.
|
||||
|
||||
Pointer methods take two optional trailing arguments. The third is either a partial `TLPointerEventInfo` or a shape id, which targets that shape. The fourth overrides modifier keys for that one event:
|
||||
|
||||
```ts
|
||||
driver.click(100, 100, shapeId, { shiftKey: true })
|
||||
```
|
||||
|
||||
Modifiers pressed with `keyDown` carry into later pointer events, because the driver reads modifier state from `editor.inputs`:
|
||||
|
||||
```ts
|
||||
driver.keyDown('Shift')
|
||||
driver.click(100, 100, { target: 'canvas' })
|
||||
driver.keyUp('Shift')
|
||||
```
|
||||
|
||||
Each input method emits a tick after dispatching. Tools that do work over several frames may need more; call `forceTick(count)` to emit extra ticks.
|
||||
|
||||
## Manipulating the selection
|
||||
|
||||
Selection helpers work in page coordinates and convert to screen space internally, so you can move, rotate, and resize the current selection without computing pointer paths by hand:
|
||||
|
||||
```ts
|
||||
driver.translateSelection(50, 0)
|
||||
driver.rotateSelection(Math.PI / 4)
|
||||
driver.resizeSelection({ scaleX: 2 }, 'bottom_right')
|
||||
```
|
||||
|
||||
## Clipboard
|
||||
|
||||
The driver keeps its own in-memory clipboard, independent of the system clipboard. Useful for scripted copy, cut, and paste flows and testing:
|
||||
|
||||
```ts
|
||||
driver.copy() // copies the current selection
|
||||
driver.paste({ x: 400, y: 400 }) // page point; ignored while Shift is held
|
||||
```
|
||||
|
||||
## Queries
|
||||
|
||||
The driver registers an `editor.sideEffects` handler that records every shape created while it's attached, whatever created it, so you can grab the most recent result of a scripted action:
|
||||
|
||||
```ts
|
||||
const shape = driver.getLastCreatedShape()
|
||||
const lastFive = driver.getLastCreatedShapes(5)
|
||||
```
|
||||
|
||||
See [Driver](?) for the full list of query helpers: shape and selection page centers, rotations, and arrows bound to a shape.
|
||||
|
||||
## Related
|
||||
|
||||
- [Driver](?) — Full reference for every input, selection, clipboard, and query method
|
||||
- [Editor](?) — The editor class the driver wraps
|
||||
- [Shapes](/docs/shapes) — Defining shape types that driver-produced input will interact with
|
||||
- [Tools](/docs/tools) — Understanding the tool state machines that process simulated events
|
||||
141
apps/docs/content/docs/editor.mdx
Normal file
141
apps/docs/content/docs/editor.mdx
Normal file
@@ -0,0 +1,141 @@
|
||||
---
|
||||
title: Editor
|
||||
status: published
|
||||
author: steveruizok
|
||||
date: 3/22/2023
|
||||
order: 1
|
||||
keywords:
|
||||
- editor
|
||||
- control
|
||||
- api
|
||||
- class
|
||||
---
|
||||
|
||||
The [Editor](?) class is the main way of controlling tldraw's editor. You can use it to manage the editor's internal state, make changes to the document, or respond to changes that have occurred.
|
||||
|
||||
By design, the editor's surface area is very large. Almost everything is available through it. Need to create some shapes? Use [Editor#createShapes](?). Need to delete them? Use [Editor#deleteShapes](?). Need a sorted array of every shape on the current page? Use [Editor#getCurrentPageShapesSorted](?).
|
||||
|
||||
## Accessing the editor
|
||||
|
||||
You can access the editor in two ways:
|
||||
|
||||
### The onMount callback
|
||||
|
||||
The [Tldraw](?) component's `onMount` callback provides the editor as the first argument.
|
||||
|
||||
```tsx
|
||||
function App() {
|
||||
return (
|
||||
<Tldraw
|
||||
onMount={(editor) => {
|
||||
// your editor code here
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### The useEditor hook
|
||||
|
||||
The [useEditor](?) hook returns the editor instance. Call it from a component rendered inside [Tldraw](?) (or [TldrawEditor](?)).
|
||||
|
||||
```tsx
|
||||
function InsideOfContext() {
|
||||
const editor = useEditor()
|
||||
// your editor code here
|
||||
return null
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Tldraw>
|
||||
<InsideOfContext />
|
||||
</Tldraw>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
> If you're using the subcomponents as shown in [this example](/examples/configuration/exploded), the editor instance is provided by the [TldrawEditor](?) component.
|
||||
|
||||
## Reactive state
|
||||
|
||||
The editor's state is reactive. Methods like [Editor#getSelectedShapeIds](?) or [Editor#getCurrentPageShapes](?) return values that automatically update when the underlying data changes. You can use these values directly in React components with the [track](?) wrapper or [useValue](?) hook.
|
||||
|
||||
```tsx
|
||||
import { track, useEditor, useValue } from 'tldraw'
|
||||
|
||||
export const SelectedShapeIdsCount = track(() => {
|
||||
const editor = useEditor()
|
||||
return <div>{editor.getSelectedShapeIds().length}</div>
|
||||
})
|
||||
|
||||
export function CurrentTool() {
|
||||
const editor = useEditor()
|
||||
const toolId = useValue('current tool', () => editor.getCurrentToolId(), [editor])
|
||||
return <div>{toolId}</div>
|
||||
}
|
||||
```
|
||||
|
||||
See the [Signals](/sdk-features/signals) article for more on tldraw's reactive state system.
|
||||
|
||||
## Batching changes
|
||||
|
||||
Each change to the editor happens within a transaction. You can batch multiple changes into a single transaction using the [Editor#run](?) method. Batching groups the changes into a single undo step and reduces overhead for persisting or distributing changes.
|
||||
|
||||
```ts
|
||||
// myShapes is an array of shape partials, each with an id from createShapeId()
|
||||
editor.run(() => {
|
||||
editor.createShapes(myShapes)
|
||||
editor.sendToBack(myShapes.map((shape) => shape.id))
|
||||
editor.selectNone()
|
||||
})
|
||||
```
|
||||
|
||||
The `run` method also accepts options to control history and locked shape behavior. Set `history` to `'ignore'` to leave undo/redo alone, or `'record-preserveRedoStack'` to record without clearing the redo stack:
|
||||
|
||||
```ts
|
||||
// Make changes without affecting undo/redo history
|
||||
editor.run(
|
||||
() => {
|
||||
editor.createShapes(myShapes)
|
||||
},
|
||||
{ history: 'ignore' }
|
||||
)
|
||||
|
||||
// Make changes to locked shapes
|
||||
editor.run(
|
||||
() => {
|
||||
editor.updateShapes(myLockedShapes)
|
||||
},
|
||||
{ ignoreShapeLock: true }
|
||||
)
|
||||
```
|
||||
|
||||
## Capabilities
|
||||
|
||||
The editor's methods and properties are organized around these areas:
|
||||
|
||||
| Area | Topic | Description |
|
||||
| ------------- | -------------------------------------------------- | ------------------------------------------------ |
|
||||
| Data | [Signals](/sdk-features/signals) | Reactive state primitives |
|
||||
| | [Store](/sdk-features/store) | The reactive database holding all records |
|
||||
| | [Shapes](/sdk-features/shapes) | Create, read, update, and delete shapes |
|
||||
| | [Bindings](/sdk-features/bindings) | Relationships between shapes |
|
||||
| | [Pages](/sdk-features/pages) | Manage document pages |
|
||||
| | [Assets](/sdk-features/assets) | Images, videos, and other media |
|
||||
| Interaction | [Tools](/sdk-features/tools) | The state machine that handles user input |
|
||||
| | [Selection](/sdk-features/selection) | Manage which shapes are selected |
|
||||
| | [Input handling](/sdk-features/input-handling) | Pointer and keyboard state |
|
||||
| | [Events](/sdk-features/events) | Subscribe to user interactions and state changes |
|
||||
| View | [Camera](/sdk-features/camera) | Control viewport position and zoom |
|
||||
| | [Coordinates](/sdk-features/coordinates) | Convert between screen and page space |
|
||||
| State | [Instance state](/sdk-features/instance-state) | Per-editor settings like current tool and focus |
|
||||
| | [Visibility](/sdk-features/visibility) | Control which shapes are shown |
|
||||
| | [History](/sdk-features/history) | Undo, redo, and history management |
|
||||
| | [Side effects](/sdk-features/side-effects) | React to record lifecycle changes |
|
||||
| Configuration | [User preferences](/sdk-features/user-preferences) | Cross-instance settings like dark mode |
|
||||
| | [Readonly mode](/sdk-features/readonly) | Disable editing |
|
||||
| | [Locked shapes](/sdk-features/locked-shapes) | Prevent changes to specific shapes |
|
||||
| Output | [Image export](/sdk-features/image-export) | Export to SVG, PNG, and other formats |
|
||||
|
||||
See the [Editor](?) API reference for the complete list of methods and properties.
|
||||
271
apps/docs/content/docs/handles.mdx
Normal file
271
apps/docs/content/docs/handles.mdx
Normal file
@@ -0,0 +1,271 @@
|
||||
---
|
||||
title: Handles
|
||||
status: published
|
||||
date: 3/22/2023
|
||||
order: 4
|
||||
keywords:
|
||||
- handles
|
||||
- custom
|
||||
- shapes
|
||||
- control points
|
||||
- snapping
|
||||
---
|
||||
|
||||
In tldraw, handles are interactive control points on shapes that let users manipulate shapes. Arrows have handles at their endpoints, lines have handles at each vertex, and notes have clone handles for quick duplication.
|
||||
|
||||
## Handle basics
|
||||
|
||||
Handles appear when a single shape is selected with the select tool. Each handle has a position, type, and optional snapping behavior. You define handles by implementing `getHandles` on your [ShapeUtil](?):
|
||||
|
||||
```tsx
|
||||
import { ShapeUtil, TLHandle, ZERO_INDEX_KEY } from 'tldraw'
|
||||
|
||||
class MyShapeUtil extends ShapeUtil<MyShape> {
|
||||
// ...
|
||||
|
||||
override getHandles(shape: MyShape): TLHandle[] {
|
||||
return [
|
||||
{
|
||||
id: 'point',
|
||||
type: 'vertex',
|
||||
index: ZERO_INDEX_KEY,
|
||||
x: shape.props.pointX,
|
||||
y: shape.props.pointY,
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Handle coordinates are in the shape's local coordinate system, where `(0, 0)` is the shape's top-left corner.
|
||||
|
||||
## Handle types
|
||||
|
||||
There are four handle types:
|
||||
|
||||
| Type | Description |
|
||||
| --------- | ----------------------------------------------------------------------------- |
|
||||
| `vertex` | A primary control point that defines part of the shape's geometry |
|
||||
| `virtual` | A secondary handle that isn't a vertex, like the arrow's midpoint bend handle |
|
||||
| `create` | A handle for adding new geometry, like inserting a point into a line segment |
|
||||
| `clone` | A handle for duplicating the shape, used by notes for quick adjacent copies |
|
||||
|
||||
Most custom shapes use `vertex` handles. The arrow shape uses a `virtual` handle for its midpoint, and the line shape uses `create` handles to let users add points between vertices.
|
||||
|
||||
## Responding to handle drags
|
||||
|
||||
When a user drags a handle, tldraw calls `onHandleDrag` with the updated handle position. Return a partial of the shape with the changed props:
|
||||
|
||||
```tsx
|
||||
import { ShapeUtil, TLHandleDragInfo } from 'tldraw'
|
||||
|
||||
class SpeechBubbleUtil extends ShapeUtil<SpeechBubbleShape> {
|
||||
// ...
|
||||
|
||||
override onHandleDrag(shape: SpeechBubbleShape, { handle }: TLHandleDragInfo<SpeechBubbleShape>) {
|
||||
return {
|
||||
props: { tailX: handle.x, tailY: handle.y },
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `handle` in [TLHandleDragInfo](?) carries the new `x` and `y` after any snapping. The info object also has these fields:
|
||||
|
||||
| Field | Description |
|
||||
| ----------------- | ----------------------------------------------------------------------- |
|
||||
| `initial` | The shape as it was when the drag started |
|
||||
| `isPrecise` | Whether the user is dragging precisely, for example by holding Alt |
|
||||
| `isCreatingShape` | Whether the handle drag is part of creating the shape, like a new arrow |
|
||||
|
||||
### Lifecycle callbacks
|
||||
|
||||
For more control over handle interactions, implement these additional methods:
|
||||
|
||||
| Method | When it's called |
|
||||
| -------------------- | ----------------------------------- |
|
||||
| `onHandleDragStart` | When the user starts dragging |
|
||||
| `onHandleDragEnd` | When the user releases the handle |
|
||||
| `onHandleDragCancel` | When the drag is cancelled (escape) |
|
||||
|
||||
## Handle snapping
|
||||
|
||||
Handles can snap to other shapes' geometry. Set `snapType` on the handle. Snapping engages while the user holds Ctrl (Cmd on Mac); with snap mode turned on in preferences, it's the reverse: snapping is on and Ctrl disables it. The older `canSnap: true` flag is deprecated; use `snapType: 'point'` instead.
|
||||
|
||||
```tsx
|
||||
{
|
||||
id: 'end',
|
||||
type: 'vertex',
|
||||
index: ZERO_INDEX_KEY,
|
||||
x: shape.props.endX,
|
||||
y: shape.props.endY,
|
||||
snapType: 'point', // Snap to points on other shapes
|
||||
}
|
||||
```
|
||||
|
||||
The `snapType` options are:
|
||||
|
||||
| Value | Behavior |
|
||||
| --------- | -------------------------------------------------------------------------------------- |
|
||||
| `'point'` | Snaps to key points on other shapes first, then to the nearest point on their outlines |
|
||||
| `'align'` | Snaps the handle's x and y independently to the x and y of key points on other shapes |
|
||||
|
||||
### Angle snapping
|
||||
|
||||
When the user holds Shift while dragging, handles snap to 15-degree angles. By default, the angle is measured relative to the next vertex handle on the shape. You can snap relative to a specific handle by setting `snapReferenceHandleId`:
|
||||
|
||||
```tsx
|
||||
{
|
||||
id: 'controlPoint',
|
||||
type: 'vertex',
|
||||
index: indices[1],
|
||||
x: shape.props.cpX,
|
||||
y: shape.props.cpY,
|
||||
snapType: 'align',
|
||||
snapReferenceHandleId: 'start', // Angle snaps relative to 'start' handle
|
||||
}
|
||||
```
|
||||
|
||||
Bezier curves use this so control points snap to angles relative to their associated endpoint.
|
||||
|
||||
### Custom snap geometry
|
||||
|
||||
By default, handles snap to a shape's outline (its geometry) and to no key points. Override `getHandleSnapGeometry` to customize what handles snap to:
|
||||
|
||||
```tsx
|
||||
import { HandleSnapGeometry, ShapeUtil } from 'tldraw'
|
||||
|
||||
class BezierCurveUtil extends ShapeUtil<BezierCurveShape> {
|
||||
// ...
|
||||
|
||||
override getHandleSnapGeometry(shape: BezierCurveShape): HandleSnapGeometry {
|
||||
return {
|
||||
// Points other shapes' handles can snap to
|
||||
points: [shape.props.start, shape.props.end],
|
||||
|
||||
// Points this shape's own handles can snap to (for self-snapping)
|
||||
getSelfSnapPoints: (handle) => {
|
||||
if (handle.id === 'controlPoint') {
|
||||
return [shape.props.start, shape.props.end]
|
||||
}
|
||||
return []
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `HandleSnapGeometry` object has these properties:
|
||||
|
||||
| Property | Description |
|
||||
| -------------------- | ------------------------------------------------------------------------- |
|
||||
| `outline` | Outline geometry to snap to (default: shape geometry; `null` disables it) |
|
||||
| `points` | Key points to snap to, like corners or centers (default: none) |
|
||||
| `getSelfSnapOutline` | Returns outline for self-snapping given a handle |
|
||||
| `getSelfSnapPoints` | Returns points for self-snapping given a handle |
|
||||
|
||||
## Reading handles
|
||||
|
||||
Use [Editor#getShapeHandles](?) to get the handles for any shape:
|
||||
|
||||
```ts
|
||||
const handles = editor.getShapeHandles(shape)
|
||||
if (handles) {
|
||||
for (const handle of handles) {
|
||||
console.log(handle.id, handle.x, handle.y)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Returns `undefined` if the shape doesn't have handles.
|
||||
|
||||
## Complete example
|
||||
|
||||
Here's a speech bubble shape with a draggable tail handle:
|
||||
|
||||
```tsx
|
||||
import {
|
||||
Polygon2d,
|
||||
ShapeUtil,
|
||||
SVGContainer,
|
||||
T,
|
||||
TLHandle,
|
||||
TLHandleDragInfo,
|
||||
TLShape,
|
||||
Vec,
|
||||
ZERO_INDEX_KEY,
|
||||
} from 'tldraw'
|
||||
|
||||
const SPEECH_BUBBLE_TYPE = 'speech-bubble'
|
||||
|
||||
declare module 'tldraw' {
|
||||
export interface TLGlobalShapePropsMap {
|
||||
[SPEECH_BUBBLE_TYPE]: { w: number; h: number; tailX: number; tailY: number }
|
||||
}
|
||||
}
|
||||
|
||||
type SpeechBubbleShape = TLShape<typeof SPEECH_BUBBLE_TYPE>
|
||||
|
||||
class SpeechBubbleUtil extends ShapeUtil<SpeechBubbleShape> {
|
||||
static override type = SPEECH_BUBBLE_TYPE
|
||||
static override props = { w: T.number, h: T.number, tailX: T.number, tailY: T.number }
|
||||
|
||||
getDefaultProps(): SpeechBubbleShape['props'] {
|
||||
return { w: 200, h: 100, tailX: 100, tailY: 150 }
|
||||
}
|
||||
|
||||
getGeometry(shape: SpeechBubbleShape) {
|
||||
const { w, h, tailX, tailY } = shape.props
|
||||
return new Polygon2d({
|
||||
points: [
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
override getHandles(shape: SpeechBubbleShape): TLHandle[] {
|
||||
return [
|
||||
{
|
||||
id: 'tail',
|
||||
type: 'vertex',
|
||||
label: 'Move tail',
|
||||
index: ZERO_INDEX_KEY,
|
||||
x: shape.props.tailX,
|
||||
y: shape.props.tailY,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
override onHandleDrag(shape: SpeechBubbleShape, { handle }: TLHandleDragInfo<SpeechBubbleShape>) {
|
||||
return {
|
||||
props: { tailX: handle.x, tailY: handle.y },
|
||||
}
|
||||
}
|
||||
|
||||
component(shape: SpeechBubbleShape) {
|
||||
const geometry = this.getGeometry(shape)
|
||||
return (
|
||||
<SVGContainer>
|
||||
<path d={geometry.getSvgPathData()} fill="white" stroke="black" />
|
||||
</SVGContainer>
|
||||
)
|
||||
}
|
||||
|
||||
getIndicatorPath(shape: SpeechBubbleShape) {
|
||||
const geometry = this.getGeometry(shape)
|
||||
return new Path2D(geometry.getSvgPathData())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
- [Custom shape with handles](/examples/shapes/tools/speech-bubble) — A speech bubble shape with a draggable tail handle
|
||||
- [Cubic bezier curve shape](/examples/shapes/tools/cubic-bezier-shape) — Multiple handles with custom snapping and control point behavior
|
||||
95
apps/docs/content/docs/indicators.mdx
Normal file
95
apps/docs/content/docs/indicators.mdx
Normal file
@@ -0,0 +1,95 @@
|
||||
---
|
||||
title: Indicators
|
||||
status: published
|
||||
author: steveruizok
|
||||
date: 3/22/2023
|
||||
order: 5
|
||||
keywords:
|
||||
- indicators
|
||||
- selection
|
||||
- hover
|
||||
- ShapeUtil
|
||||
- custom shapes
|
||||
---
|
||||
|
||||
Indicators are the outlines that appear around shapes when they're selected or hovered.
|
||||
|
||||
## How indicators work
|
||||
|
||||
Indicators are drawn on a canvas overlay in the theme's selection color, separate from the shape's own rendering. Each [ShapeUtil](?) defines its indicator by implementing the required [ShapeUtil#getIndicatorPath](?) method. It returns a `Path2D` in the shape's local coordinate space; the [ShapeIndicatorOverlayUtil](?) included with `<Tldraw>` transforms it into page space and strokes it. Return `undefined` to draw no indicator for a shape.
|
||||
|
||||
```tsx
|
||||
class CardShapeUtil extends ShapeUtil<CardShape> {
|
||||
// ...
|
||||
|
||||
getIndicatorPath(shape: CardShape) {
|
||||
const path = new Path2D()
|
||||
path.rect(0, 0, shape.props.w, shape.props.h)
|
||||
return path
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For shapes with more complex outlines, build the path from the shape's geometry:
|
||||
|
||||
```tsx
|
||||
getIndicatorPath(shape: MyShape) {
|
||||
return new Path2D(this.editor.getShapeGeometry(shape).toSimpleSvgPath())
|
||||
}
|
||||
```
|
||||
|
||||
See [Shapes](/docs/shapes) for the rest of the ShapeUtil.
|
||||
|
||||
## When indicators appear
|
||||
|
||||
The select tool shows indicators in these situations:
|
||||
|
||||
| State | Description |
|
||||
| -------- | ------------------------------------------------------------------------------------------------------------------ |
|
||||
| Selected | The shape is in the current selection |
|
||||
| Hovered | The pointer is over the shape while the select tool is idle or editing a shape (fine pointers only, not touch) |
|
||||
| Hinting | The shape is in [Editor#getHintingShapeIds](?), set with [Editor#setHintingShapes](?); drawn with a thicker stroke |
|
||||
|
||||
Indicators are hidden while the user is changing styles, while another tool is active, and for locked shapes.
|
||||
|
||||
All plain `Path2D` indicators are batched into a single stroke call, so many selected shapes stay cheap to draw.
|
||||
|
||||
## Complex canvas indicators
|
||||
|
||||
For indicators that need clipping or multiple paths (like arrows with labels), return a [TLIndicatorPath](?) object instead of a plain `Path2D`. The `clipPath` is applied as an even-odd clip region before stroking `path`, so to cut a hole for a label, add an outer rectangle plus the label rectangle. `additionalPaths` are stroked afterwards without the clip:
|
||||
|
||||
```tsx
|
||||
override getIndicatorPath(shape: MyShape): TLIndicatorPath | undefined {
|
||||
const bodyPath = new Path2D()
|
||||
bodyPath.moveTo(0, 0)
|
||||
bodyPath.lineTo(100, 100)
|
||||
|
||||
const arrowheadPath = new Path2D()
|
||||
arrowheadPath.moveTo(90, 95)
|
||||
arrowheadPath.lineTo(100, 100)
|
||||
arrowheadPath.lineTo(95, 90)
|
||||
|
||||
// Even-odd: the outer rect keeps everything, the inner rect punches a hole
|
||||
const clipPath = new Path2D()
|
||||
clipPath.rect(-100, -100, 300, 300)
|
||||
clipPath.rect(40, 40, 20, 20)
|
||||
|
||||
return {
|
||||
path: bodyPath,
|
||||
clipPath,
|
||||
additionalPaths: [arrowheadPath],
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Collaborator indicators
|
||||
|
||||
In multiplayer sessions, [CollaboratorShapeIndicatorOverlayUtil](?) draws other users' selections in each collaborator's color at reduced opacity, underneath the local indicators.
|
||||
|
||||
## Related topics
|
||||
|
||||
| Topic | Description |
|
||||
| ---------------------------------------------------------- | ------------------------------------------------------------------- |
|
||||
| [Shapes](/docs/shapes) | Creating custom shapes with ShapeUtil |
|
||||
| [User interface](/docs/user-interface) | Customizing tldraw's UI components |
|
||||
| [Custom indicators example](/examples/ui/indicators-logic) | Subclass `ShapeIndicatorOverlayUtil` to change when indicators show |
|
||||
56
apps/docs/content/docs/llm-docs.mdx
Normal file
56
apps/docs/content/docs/llm-docs.mdx
Normal file
@@ -0,0 +1,56 @@
|
||||
---
|
||||
title: LLM documentation
|
||||
status: published
|
||||
author: steveruizok
|
||||
date: 2/1/2026
|
||||
order: 9
|
||||
keywords:
|
||||
- llm
|
||||
- ai
|
||||
- language model
|
||||
- markdown
|
||||
- llms.txt
|
||||
- copy
|
||||
- context
|
||||
- agent
|
||||
---
|
||||
|
||||
The tldraw docs are available in formats built for large language models (LLMs): plain-text bundles you can hand to a coding assistant or agent, and a copy-as-markdown button on every article.
|
||||
|
||||
## llms.txt
|
||||
|
||||
We publish [tldraw.dev/llms.txt](https://tldraw.dev/llms.txt), following the [llms.txt convention](https://llmstxt.org/). It indexes the SDK feature articles, the examples, and the release notes, and links to several bundles of the same content. The bundles are regenerated on every docs build.
|
||||
|
||||
| File | Contents |
|
||||
| --------------------------------------------------------- | -------------------------------------------- |
|
||||
| [llms.txt](https://tldraw.dev/llms.txt) | Index with links to all resources |
|
||||
| [llms-full.txt](https://tldraw.dev/llms-full.txt) | SDK feature articles, releases, and examples |
|
||||
| [llms-docs.txt](https://tldraw.dev/llms-docs.txt) | SDK feature articles only |
|
||||
| [llms-releases.txt](https://tldraw.dev/llms-releases.txt) | Release notes only |
|
||||
| [llms-examples.txt](https://tldraw.dev/llms-examples.txt) | Examples with full source code |
|
||||
|
||||
The guides in this section of the site (under `/docs`) aren't in the bundles. Use the copy button below for those.
|
||||
|
||||
### Using llms.txt with AI assistants
|
||||
|
||||
When working with an AI coding assistant, you can provide context by including the relevant llms.txt file in your prompt or conversation. For example:
|
||||
|
||||
- Use `llms-docs.txt` when asking about SDK features, APIs, or implementation patterns
|
||||
- Use `llms-examples.txt` when looking for code examples or implementation references
|
||||
- Use `llms-releases.txt` when asking about recent changes or migration between versions
|
||||
- Use `llms-full.txt` when you need comprehensive context across all documentation
|
||||
|
||||
Many AI tools fetch URLs directly, so you can reference these files by URL in your prompts:
|
||||
|
||||
```
|
||||
Read https://tldraw.dev/llms-docs.txt, then add a custom shape to my app.
|
||||
```
|
||||
|
||||
## Copy markdown button
|
||||
|
||||
Every article on tldraw.dev has a "Copy markdown" button in its header. Click it to copy the article as markdown, ready to paste into an LLM conversation. The copy keeps headings, code blocks, and links, and strips the site navigation.
|
||||
|
||||
## Related
|
||||
|
||||
- [AI integrations](/docs/ai) — Using the canvas with AI models: generated content, workflows, and agents
|
||||
- [Mermaid diagrams](/docs/mermaid) — Turning model-generated diagram text into shapes
|
||||
152
apps/docs/content/docs/mermaid.mdx
Normal file
152
apps/docs/content/docs/mermaid.mdx
Normal file
@@ -0,0 +1,152 @@
|
||||
---
|
||||
title: Mermaid diagrams
|
||||
status: published
|
||||
author: guillaumerichard
|
||||
date: 4/28/2026
|
||||
order: 8
|
||||
keywords:
|
||||
- mermaid
|
||||
- diagram
|
||||
- flowchart
|
||||
- sequence
|
||||
- state
|
||||
- mindmap
|
||||
- integration
|
||||
- parse
|
||||
- render
|
||||
- visualization
|
||||
---
|
||||
|
||||
You can turn [Mermaid](https://mermaid.js.org/) diagram syntax into native, editable tldraw shapes with the `@tldraw/mermaid` package. Instead of rendering a static SVG, it parses Mermaid text and creates real geo shapes, arrows, and groups on the canvas. Users can move, resize, restyle, and connect them like any other shape.
|
||||
|
||||
## Quick start
|
||||
|
||||
Install the package alongside `tldraw`:
|
||||
|
||||
```bash
|
||||
npm install @tldraw/mermaid
|
||||
```
|
||||
|
||||
Then call [createMermaidDiagram](?) with an [Editor](?) and a Mermaid source string:
|
||||
|
||||
```tsx
|
||||
import { createMermaidDiagram } from '@tldraw/mermaid'
|
||||
|
||||
await createMermaidDiagram(
|
||||
editor,
|
||||
`
|
||||
flowchart TD
|
||||
A[Start] --> B{Decision}
|
||||
B -->|Yes| C[Do something]
|
||||
B -->|No| D[Do something else]
|
||||
`
|
||||
)
|
||||
```
|
||||
|
||||
By default the diagram is centered on the viewport, or on the pointer when the user's paste-at-cursor preference is on. Pass a `blueprintRender.position` to place it at a specific page point, and set `centerOnPosition: false` to anchor its top-left corner instead of its center. The finished diagram is a single group. Pass `mermaidConfig` to override Mermaid's layout settings, such as node spacing or font size.
|
||||
|
||||
## Supported diagram types
|
||||
|
||||
| Diagram type | Mermaid keyword | What you get |
|
||||
| ---------------- | --------------------------------- | ------------------------------------------------------------------------------ |
|
||||
| Flowchart | `flowchart`, `graph` | Geo shapes, arrows, subgraph containers |
|
||||
| Sequence diagram | `sequenceDiagram` | Actor shapes, lifelines, signal arrows, fragment containers, participant boxes |
|
||||
| State diagram | `stateDiagram`, `stateDiagram-v2` | State shapes, transitions, compound state containers, fork/join, choice |
|
||||
| Mindmap | `mindmap` | Colored geo shapes, parent-child edges, tree hierarchy |
|
||||
|
||||
Subgraphs, fragments, and compound states become geo rectangles with their child shapes parented to them.
|
||||
|
||||
For unsupported types such as pie, gantt, class, and ER, pass an `onUnsupportedDiagram` callback. Here it falls back to importing Mermaid's rendered SVG:
|
||||
|
||||
```tsx
|
||||
await createMermaidDiagram(editor, text, {
|
||||
async onUnsupportedDiagram(svgString) {
|
||||
await editor.putExternalContent({ type: 'svg-text', text: svgString })
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Without a callback, `createMermaidDiagram` throws a [MermaidDiagramError](?) for unsupported types and parse failures.
|
||||
|
||||
## Handling pasted Mermaid text
|
||||
|
||||
The most common integration is converting Mermaid text when users paste it onto the canvas. Register an external content handler that sniffs for a Mermaid keyword and dynamically imports the package:
|
||||
|
||||
```tsx
|
||||
import { useEffect } from 'react'
|
||||
import { defaultHandleExternalTextContent, useEditor } from 'tldraw'
|
||||
|
||||
const MERMAID_KEYWORD =
|
||||
/^\s*(flowchart|graph|sequenceDiagram|stateDiagram|classDiagram|erDiagram|gantt|pie|gitGraph|mindmap)/
|
||||
|
||||
export function MermaidPasteHandler() {
|
||||
const editor = useEditor()
|
||||
|
||||
useEffect(() => {
|
||||
editor.registerExternalContentHandler('text', async (content) => {
|
||||
if (!MERMAID_KEYWORD.test(content.text)) {
|
||||
await defaultHandleExternalTextContent(editor, content)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const { createMermaidDiagram } = await import('@tldraw/mermaid')
|
||||
await createMermaidDiagram(editor, content.text, {
|
||||
async onUnsupportedDiagram(svgString) {
|
||||
await editor.putExternalContent({ type: 'svg-text', text: svgString })
|
||||
},
|
||||
})
|
||||
} catch {
|
||||
await defaultHandleExternalTextContent(editor, content)
|
||||
}
|
||||
})
|
||||
}, [editor])
|
||||
|
||||
return null
|
||||
}
|
||||
```
|
||||
|
||||
Drop `<MermaidPasteHandler />` inside your [Tldraw](?) component and pasting Mermaid text will render it as shapes. The [Mermaid pasting example](/examples/use-cases/mermaid-pasting) extends this with markdown fence stripping and a toast for unsupported diagrams.
|
||||
|
||||
<Callout type="info">
|
||||
The `mermaid` dependency is ~2 MB. `createMermaidDiagram` loads it lazily on first call, and the
|
||||
regex above keeps it from being called for ordinary text, so users who never paste Mermaid don't
|
||||
pay the cost.
|
||||
</Callout>
|
||||
|
||||
## Customizing node shapes
|
||||
|
||||
By default, blueprint nodes are materialized as tldraw geo shapes. To render them as your own custom shape type instead, pass `mapNodeToRenderSpec` on `blueprintRender`:
|
||||
|
||||
```tsx
|
||||
await createMermaidDiagram(editor, text, {
|
||||
blueprintRender: {
|
||||
mapNodeToRenderSpec(input) {
|
||||
if (input.diagramKind === 'flowchart') {
|
||||
return {
|
||||
variant: 'shape',
|
||||
type: 'pipeline-step',
|
||||
props: { mermaidNodeId: input.nodeId },
|
||||
}
|
||||
}
|
||||
// Return undefined to keep the package default for this node.
|
||||
return undefined
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
The callback receives `diagramKind`, `nodeId`, `kind`, and the full [MermaidBlueprintNode](?), and returns a [MermaidBlueprintNodeRenderSpec](?): either a `geo` variant or a custom `shape` type that's registered on the editor. The package sets `w`, `h`, `fill`, `color`, `dash`, `size`, `richText`, `align`, and `verticalAlign` on every node it creates, so a custom shape's props must accept those keys or validation will fail. See [createMermaidDiagram](?) and [renderBlueprint](?) in the reference for the full API.
|
||||
|
||||
## Examples
|
||||
|
||||
- [Hundreds of Mermaid diagrams](/examples/use-cases/hundred-mermaids) — A runnable demo rendering many diagram types at once
|
||||
- [Customize Mermaid diagrams](/examples/use-cases/custom-shape-mermaids) — Converting Mermaid diagram nodes into custom shapes
|
||||
- [Pasting Mermaid code as shapes](/examples/use-cases/mermaid-pasting) — The paste handler above, end to end
|
||||
|
||||
## Related
|
||||
|
||||
- [createMermaidDiagram](?), [renderBlueprint](?), [MermaidDiagramError](?) — Entry points and errors
|
||||
- [DiagramMermaidBlueprint](?), [MermaidBlueprintNode](?), [MermaidBlueprintNodeRenderSpec](?) — The data model you can customize
|
||||
- [Shapes](/docs/shapes) — Define a custom shape you can map Mermaid nodes to
|
||||
- [External content handlers](/sdk-features/external-content) — More on `registerExternalContentHandler` for the paste integration
|
||||
109
apps/docs/content/docs/persistence.mdx
Normal file
109
apps/docs/content/docs/persistence.mdx
Normal file
@@ -0,0 +1,109 @@
|
||||
---
|
||||
title: Persistence
|
||||
status: published
|
||||
author: steveruizok
|
||||
date: 3/22/2023
|
||||
order: 4
|
||||
keywords:
|
||||
- data
|
||||
- sync
|
||||
- persistence
|
||||
- database
|
||||
- indexeddb
|
||||
- localstorage
|
||||
---
|
||||
|
||||
Persistence means storing the editor's state to a database and restoring it later. The simplest option is the `persistenceKey` prop, which saves to the browser automatically. Snapshots and the `store` prop give you more control, and migrations bring old data up to date.
|
||||
|
||||
## Local persistence
|
||||
|
||||
The simplest approach is the `persistenceKey` prop. This automatically saves the document and any uploaded [assets](/docs/assets) to IndexedDB and syncs across browser tabs:
|
||||
|
||||
```tsx
|
||||
import { Tldraw } from 'tldraw'
|
||||
import 'tldraw/tldraw.css'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0 }}>
|
||||
<Tldraw persistenceKey="my-document" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Each unique key represents a separate document. Two editors with the same key share the same document and stay synchronized.
|
||||
|
||||
## Snapshots
|
||||
|
||||
For custom storage backends, use [getSnapshot](?) and [loadSnapshot](?) to save and restore editor state as JSON:
|
||||
|
||||
```tsx
|
||||
import { getSnapshot, loadSnapshot } from 'tldraw'
|
||||
|
||||
// Save
|
||||
const { document, session } = getSnapshot(editor.store)
|
||||
await saveToDatabase(document)
|
||||
|
||||
// Load
|
||||
const saved = await loadFromDatabase()
|
||||
loadSnapshot(editor.store, { document: saved })
|
||||
```
|
||||
|
||||
The snapshot has two parts: `document` (shapes, pages, bindings) which you typically save to a server, and `session` (camera, selection, UI state) which you keep per-user locally. Loading a snapshot replaces the current document.
|
||||
|
||||
To load a snapshot once on startup, pass it to the `snapshot` prop of `<Tldraw>` instead. See [Persistence](/sdk-features/persistence) for complete coverage of snapshots, async loading with [TLStoreWithStatus](?), and auto-save patterns.
|
||||
|
||||
## The store
|
||||
|
||||
The editor's [store](/sdk-features/store) is a reactive database that holds all records. You can create a standalone store with [createTLStore](?), load data into it, and pass it to the editor:
|
||||
|
||||
```tsx
|
||||
import { useState } from 'react'
|
||||
import { createTLStore, loadSnapshot, Tldraw } from 'tldraw'
|
||||
|
||||
export default function App() {
|
||||
const [store] = useState(() => {
|
||||
const store = createTLStore()
|
||||
const saved = localStorage.getItem('my-drawing')
|
||||
if (saved) {
|
||||
loadSnapshot(store, JSON.parse(saved))
|
||||
}
|
||||
return store
|
||||
})
|
||||
|
||||
return <Tldraw store={store} />
|
||||
}
|
||||
```
|
||||
|
||||
See [Store](/sdk-features/store) for details on store operations, listening to changes, queries, and transactions.
|
||||
|
||||
## Multiplayer sync
|
||||
|
||||
For real-time collaboration, use the `@tldraw/sync` package and its [useSyncDemo](?) hook. Multiple users can edit the same document simultaneously, see each other's cursors, and follow each other's viewports:
|
||||
|
||||
```tsx
|
||||
import { useSyncDemo } from '@tldraw/sync'
|
||||
import { Tldraw } from 'tldraw'
|
||||
|
||||
export default function App() {
|
||||
const store = useSyncDemo({ roomId: 'my-room-id' })
|
||||
return <Tldraw store={store} />
|
||||
}
|
||||
```
|
||||
|
||||
See [Collaboration](/sdk-features/collaboration) for production setup, custom presence, authentication, and building custom sync solutions.
|
||||
|
||||
## Migrations
|
||||
|
||||
When you load a snapshot from an older schema version, the store migrates it automatically. For custom shapes, you can define migrations to handle changes to their props over time.
|
||||
|
||||
See [Persistence](/sdk-features/persistence#Migrations) for details on shape props migrations and general migrations.
|
||||
|
||||
## Examples
|
||||
|
||||
- [Persistence key](/examples/configuration/persistence-key) — Automatic local persistence with a single prop
|
||||
- [Snapshots](/examples/editor-api/snapshots) — Saving and loading editor state
|
||||
- [Local storage](/examples/data/assets/local-storage) — Custom persistence with throttled auto-save
|
||||
- [Store events](/examples/events/store-events) — Listening to store changes
|
||||
- [Shape with migrations](/examples/shapes/tools/shape-with-migrations) — Migrations for custom shape props
|
||||
205
apps/docs/content/docs/shapes.mdx
Normal file
205
apps/docs/content/docs/shapes.mdx
Normal file
@@ -0,0 +1,205 @@
|
||||
---
|
||||
title: Shapes
|
||||
status: published
|
||||
author: steveruizok
|
||||
date: 3/22/2023
|
||||
order: 2
|
||||
keywords:
|
||||
- custom
|
||||
- shapes
|
||||
- shapeutils
|
||||
- utils
|
||||
- meta
|
||||
- metadata
|
||||
---
|
||||
|
||||
In tldraw, a shape is something that can exist on the page, like an arrow, an image, or some text.
|
||||
|
||||
## Shape basics
|
||||
|
||||
Shapes are JSON records stored in the editor's [store](/sdk-features/store). Each shape has base properties (position, rotation, opacity) plus a `props` object for shape-specific data. Each shape type has a [ShapeUtil](?) class that defines its behavior: how it renders, its geometry for hit testing, and how it responds to interactions.
|
||||
|
||||
The [Tldraw](?) component includes [default shapes](/sdk-features/default-shapes) like geo, text, arrow, and draw. The only core shape (always present) is the [group](/sdk-features/groups). See [Shapes](/sdk-features/shapes) for the full shape system architecture, including ShapeUtil methods, lifecycle hooks, and configuration.
|
||||
|
||||
## Custom shapes
|
||||
|
||||
You can create your own shapes by defining a shape type and a ShapeUtil class.
|
||||
|
||||
> For a working example, see our [custom shapes example](/examples/shapes/tools/custom-shape).
|
||||
|
||||
### Defining the shape type
|
||||
|
||||
Register your shape's props using TypeScript module augmentation:
|
||||
|
||||
```ts
|
||||
import { TLShape } from 'tldraw'
|
||||
|
||||
const CARD_TYPE = 'card'
|
||||
|
||||
declare module 'tldraw' {
|
||||
export interface TLGlobalShapePropsMap {
|
||||
[CARD_TYPE]: { w: number; h: number }
|
||||
}
|
||||
}
|
||||
|
||||
type CardShape = TLShape<typeof CARD_TYPE>
|
||||
```
|
||||
|
||||
### Creating a ShapeUtil
|
||||
|
||||
Implement the required methods: `getDefaultProps`, `getGeometry`, `component`, and `getIndicatorPath`. Set `static props` so the store validates your shape's props; without it, `props` accepts any JSON value and typos or stale data pass through unchecked.
|
||||
|
||||
```tsx
|
||||
import { HTMLContainer, Rectangle2d, ShapeUtil, T } from 'tldraw'
|
||||
|
||||
class CardShapeUtil extends ShapeUtil<CardShape> {
|
||||
static override type = CARD_TYPE
|
||||
static override props = { w: T.number, h: T.number }
|
||||
|
||||
getDefaultProps(): CardShape['props'] {
|
||||
return { w: 100, h: 100 }
|
||||
}
|
||||
|
||||
getGeometry(shape: CardShape) {
|
||||
return new Rectangle2d({
|
||||
width: shape.props.w,
|
||||
height: shape.props.h,
|
||||
isFilled: true,
|
||||
})
|
||||
}
|
||||
|
||||
component(shape: CardShape) {
|
||||
return <HTMLContainer>Hello</HTMLContainer>
|
||||
}
|
||||
|
||||
getIndicatorPath(shape: CardShape) {
|
||||
const path = new Path2D()
|
||||
path.rect(0, 0, shape.props.w, shape.props.h)
|
||||
return path
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
See [Geometry](/sdk-features/geometry) for available geometry classes.
|
||||
|
||||
### Registering your shape
|
||||
|
||||
Pass your ShapeUtil to the [Tldraw](?) component:
|
||||
|
||||
```tsx
|
||||
export default function () {
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0 }}>
|
||||
<Tldraw
|
||||
shapeUtils={[CardShapeUtil]}
|
||||
onMount={(editor) => {
|
||||
editor.createShape({ type: 'card' })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Meta
|
||||
|
||||
Every shape has a `meta` property for your own data. Tldraw stores and syncs this data but doesn't use it itself. It's an escape hatch for attaching extra information to shapes, like the name of the user who created a shape or the date it was last changed.
|
||||
|
||||
Like `props`, the data in `meta` must be JSON-serializable. Shapes aren't the only records with a `meta` property: pages, bindings, assets, and the document record have one too.
|
||||
|
||||
By default, a shape's `meta` is an empty object typed as `JsonObject`. To type your meta data, use an intersection:
|
||||
|
||||
```ts
|
||||
type ShapeWithMyMeta = TLGeoShape & { meta: { createdBy: string } }
|
||||
|
||||
const shape = editor.getShape<ShapeWithMyMeta>(myGeoShapeId)
|
||||
```
|
||||
|
||||
You can update a shape's `meta` with [Editor#updateShapes](?), the same way you update its props:
|
||||
|
||||
```ts
|
||||
editor.updateShapes<ShapeWithMyMeta>([
|
||||
{
|
||||
id: myGeoShapeId,
|
||||
type: 'geo',
|
||||
meta: { createdBy: 'Steve' },
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
### Initial meta
|
||||
|
||||
When [Editor#createShapes](?) creates a shape, it merges the result of [Editor#getInitialMetaForShape](?) with any `meta` you passed. Your explicit `meta` wins. By default this method returns an empty object. Replace it to provide your own initial meta:
|
||||
|
||||
```tsx
|
||||
editor.getInitialMetaForShape = (shape) => {
|
||||
if (shape.type === 'text') {
|
||||
return { createdBy: currentUser.id, lastModified: Date.now() }
|
||||
}
|
||||
return { createdBy: currentUser.id }
|
||||
}
|
||||
```
|
||||
|
||||
> For a working example, see our [shape meta on create example](/examples/events/meta-on-create).
|
||||
|
||||
### Updating meta with side effects
|
||||
|
||||
To keep meta up to date as shapes change, register a side effect that runs before each shape update:
|
||||
|
||||
```tsx
|
||||
editor.sideEffects.registerBeforeChangeHandler('shape', (_prev, next, source) => {
|
||||
if (source !== 'user') return next
|
||||
return {
|
||||
...next,
|
||||
meta: { updatedBy: editor.user.getExternalId(), updatedAt: Date.now() },
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
Side effects can run on create, update, and delete for shapes and other records. See [Side effects](/sdk-features/side-effects) for the full API.
|
||||
|
||||
> For a working example, see our [shape meta on change example](/examples/events/meta-on-change).
|
||||
|
||||
### Validating meta
|
||||
|
||||
By default, the store accepts any JSON value in `meta`. To validate meta data at runtime, build your own schema with [createTLSchema](?) and pass validators for each shape type's meta:
|
||||
|
||||
```tsx
|
||||
import { useState } from 'react'
|
||||
import { createTLSchema, createTLStore, defaultShapeSchemas, T, Tldraw } from 'tldraw'
|
||||
|
||||
const schema = createTLSchema({
|
||||
shapes: {
|
||||
...defaultShapeSchemas,
|
||||
geo: {
|
||||
...defaultShapeSchemas.geo,
|
||||
meta: { createdBy: T.string },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export default function App() {
|
||||
const [store] = useState(() => createTLStore({ schema }))
|
||||
return <Tldraw store={store} />
|
||||
}
|
||||
```
|
||||
|
||||
When you build the schema yourself, include your custom shapes' `props` and `migrations` in the `shapes` map too. Bindings, assets, and user records accept meta validators the same way. For user records, see our [custom user metadata example](/examples/users/custom-user).
|
||||
|
||||
## Extending shapes
|
||||
|
||||
Extend [BaseBoxShapeUtil](?) for standard rectangular shape behavior. Use [ShapeUtil#configure](?) to customize a built-in shape's options without subclassing it.
|
||||
|
||||
## Related topics
|
||||
|
||||
| Topic | Description |
|
||||
| ---------------------------------------------- | ------------------------------------------------------------------ |
|
||||
| [Shapes](/sdk-features/shapes) | Full shape system architecture, ShapeUtil methods, lifecycle hooks |
|
||||
| [Default shapes](/sdk-features/default-shapes) | Built-in shape types and their properties |
|
||||
| [Geometry](/sdk-features/geometry) | Geometry classes for hit testing and bounds |
|
||||
| [Bindings](/sdk-features/bindings) | Connecting shapes together (like arrows) |
|
||||
| [Rich text](/sdk-features/rich-text) | Adding text labels to shapes |
|
||||
| [Shape clipping](/sdk-features/shape-clipping) | Clipping children within shape boundaries |
|
||||
| [Snapping](/sdk-features/snapping) | Shape snapping behavior |
|
||||
| [Persistence](/sdk-features/persistence) | Shape migrations and data persistence |
|
||||
| [Groups](/sdk-features/groups) | Grouping shapes together |
|
||||
584
apps/docs/content/docs/sync.mdx
Normal file
584
apps/docs/content/docs/sync.mdx
Normal file
@@ -0,0 +1,584 @@
|
||||
---
|
||||
title: tldraw sync
|
||||
status: published
|
||||
author: alex
|
||||
date: 7/11/2024
|
||||
order: 7
|
||||
keywords:
|
||||
- data
|
||||
- sync
|
||||
- persistence
|
||||
- database
|
||||
- multiplayer
|
||||
- collaboration
|
||||
- server
|
||||
- websockets
|
||||
readability: 8
|
||||
voice: 8
|
||||
completeness: 8
|
||||
accuracy: 8
|
||||
notes: 'Thorough sync architecture coverage with good code examples. Migration sections are comprehensive but lengthy.'
|
||||
---
|
||||
|
||||
You can add realtime multi-user collaboration to your tldraw app by using **tldraw sync**. It's our library for fast, fault-tolerant shared document syncing. We use it in production on our flagship app [tldraw.com](https://tldraw.com).
|
||||
|
||||
We offer a [hosted demo](/docs/collaboration) of tldraw sync which is suitable for prototyping. To use tldraw sync in production, you will need to host it yourself.
|
||||
|
||||
## Deploying tldraw sync
|
||||
|
||||
There are two main ways to go about hosting tldraw sync:
|
||||
|
||||
1. Deploy a full backend to Cloudflare using our template (recommended).
|
||||
2. Integrate tldraw sync into your own JavaScript backend using our examples and docs as a guide.
|
||||
|
||||
### Use our Cloudflare template
|
||||
|
||||
The best way to get started hosting your own backend is to clone and deploy [our Cloudflare template](https://github.com/tldraw/tldraw/tree/main/templates/sync-cloudflare). The template provides the same setup that runs on tldraw.com.
|
||||
|
||||
It uses:
|
||||
|
||||
- [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.
|
||||
|
||||
Make sure you also read the section below about [deployment concerns](#deployment-concerns).
|
||||
|
||||
[Get started with the Cloudflare template](https://github.com/tldraw/tldraw/tree/main/templates/sync-cloudflare).
|
||||
|
||||
### Integrate tldraw sync into your own backend
|
||||
|
||||
We recommend Cloudflare. The `@tldraw/sync-core` library also integrates tldraw sync into any JavaScript server environment that supports WebSockets.
|
||||
|
||||
We have a [simple Node.js server example](https://github.com/tldraw/tldraw/tree/main/templates/simple-server-example) to use as a reference for how things should be stitched together.
|
||||
|
||||
## What does a tldraw sync backend do?
|
||||
|
||||
A backend for tldraw sync consists of two or three parts:
|
||||
|
||||
- A **WebSocket server** that provides rooms for each shared document, and is responsible for synchronizing and persisting document state.
|
||||
- An **asset storage** provider for large binary files like images and videos.
|
||||
- (If using the built-in bookmark shape) An **unfurling service** to extract metadata about bookmark URLs.
|
||||
|
||||
On the frontend, there is just one part: the **sync client**, created using the [useSync](?) hook from the `@tldraw/sync` package.
|
||||
|
||||
Here's a simple client implementation:
|
||||
|
||||
```tsx
|
||||
import { Tldraw, TLAssetStore, Editor } from 'tldraw'
|
||||
import { useSync } from '@tldraw/sync'
|
||||
import 'tldraw/tldraw.css'
|
||||
import { uploadFileAndReturnUrl } from './assets'
|
||||
import { convertUrlToBookmarkAsset } from './unfurl'
|
||||
|
||||
function MyEditorComponent({ myRoomId }: { myRoomId: string }) {
|
||||
// This hook creates a sync client that manages the websocket connection to the server
|
||||
// and coordinates updates to the document state.
|
||||
const store = useSync({
|
||||
// This is how you tell the sync client which server and room to connect to.
|
||||
uri: `wss://my-custom-backend.com/connect/${myRoomId}`,
|
||||
// This is how you tell the sync client how to store and retrieve blobs.
|
||||
assets: myAssetStore,
|
||||
})
|
||||
// When the tldraw Editor mounts, you can register an asset handler for the bookmark URLs.
|
||||
return <Tldraw store={store} onMount={registerUrlHandler} />
|
||||
}
|
||||
|
||||
const myAssetStore: TLAssetStore = {
|
||||
upload(asset, file) {
|
||||
return uploadFileAndReturnUrl(file)
|
||||
},
|
||||
resolve(asset) {
|
||||
return asset.props.src
|
||||
},
|
||||
}
|
||||
|
||||
function registerUrlHandler(editor: Editor) {
|
||||
editor.registerExternalAssetHandler('url', async ({ url }) => {
|
||||
return await convertUrlToBookmarkAsset(url)
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
And [here's a full working example](https://github.com/tldraw/tldraw/blob/main/templates/simple-server-example/src/client/App.tsx) of the client-side code.
|
||||
|
||||
### WebSocket server
|
||||
|
||||
The `@tldraw/sync-core` package exports a class called [TLSocketRoom](?) that should be created server-side on a per-document basis.
|
||||
|
||||
`TLSocketRoom` stores the authoritative copy of the document, relays changes between sync clients over WebSockets, and exposes hooks for persisting the document when it changes.
|
||||
|
||||
Attach each incoming WebSocket to the room and forward its events:
|
||||
|
||||
```tsx
|
||||
room.handleSocketConnect({ sessionId, socket })
|
||||
// If your platform doesn't let the room add listeners to the socket itself
|
||||
// (Bun.serve, Cloudflare hibernation), forward events by hand:
|
||||
room.handleSocketMessage(sessionId, message)
|
||||
room.handleSocketClose(sessionId)
|
||||
```
|
||||
|
||||
<Callout type="info">
|
||||
You should make sure that there's only ever one `TLSocketRoom` globally for each room in your app.
|
||||
If there's more than one, users won't see each other and will overwrite others' changes. We use
|
||||
[Durable Objects](https://developers.cloudflare.com/durable-objects/) to achieve this on
|
||||
tldraw.com.
|
||||
</Callout>
|
||||
|
||||
Read the reference docs for [TLSocketRoom](?), and see an example of how to use it in the [simple server example](https://github.com/tldraw/tldraw/blob/main/templates/simple-server-example/src/server/rooms.ts).
|
||||
|
||||
### Storage backends
|
||||
|
||||
`TLSocketRoom` requires a storage backend to persist document state. The `@tldraw/sync-core` package provides two options:
|
||||
|
||||
#### InMemorySyncStorage (default)
|
||||
|
||||
[InMemorySyncStorage](?) keeps all document state in memory. It's simple to use, but data is lost when the process restarts. To persist data, pass an `onChange` callback to the constructor and save snapshots to your database.
|
||||
|
||||
```tsx
|
||||
import { InMemorySyncStorage, TLSocketRoom } from '@tldraw/sync-core'
|
||||
|
||||
const storage = new InMemorySyncStorage({
|
||||
snapshot: existingData, // optional: load from your database
|
||||
onChange() {
|
||||
// Save to your database when changes occur
|
||||
saveToDatabase(storage.getSnapshot())
|
||||
},
|
||||
})
|
||||
|
||||
const room = new TLSocketRoom({ storage })
|
||||
```
|
||||
|
||||
#### SQLiteSyncStorage (recommended for persistence)
|
||||
|
||||
[SQLiteSyncStorage](?) stores document state in SQLite. Data survives process restarts automatically. This is the recommended approach for production deployments.
|
||||
|
||||
##### Cloudflare Durable Objects
|
||||
|
||||
```tsx
|
||||
import { DurableObject } from 'cloudflare:workers'
|
||||
import { SQLiteSyncStorage, DurableObjectSqliteSyncWrapper, TLSocketRoom } from '@tldraw/sync-core'
|
||||
|
||||
export class TLSyncDurableObject extends DurableObject {
|
||||
private room: TLSocketRoom
|
||||
|
||||
constructor(ctx: DurableObjectState, env: Env) {
|
||||
super(ctx, env)
|
||||
const sql = new DurableObjectSqliteSyncWrapper(ctx.storage)
|
||||
const storage = new SQLiteSyncStorage({ sql })
|
||||
this.room = new TLSocketRoom({ storage })
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
##### Node.js with better-sqlite3 or node:sqlite
|
||||
|
||||
```tsx
|
||||
import Database from 'better-sqlite3' // replace with 'node:sqlite' if using
|
||||
import { SQLiteSyncStorage, NodeSqliteWrapper, TLSocketRoom } from '@tldraw/sync-core'
|
||||
|
||||
const db = new Database('rooms.db')
|
||||
const sql = new NodeSqliteWrapper(db)
|
||||
const storage = new SQLiteSyncStorage({ sql })
|
||||
const room = new TLSocketRoom({ storage })
|
||||
```
|
||||
|
||||
<Callout type="info">
|
||||
`SQLiteSyncStorage` automatically creates and manages its database tables. You can pass a
|
||||
`tablePrefix` option to the SQL wrapper 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. Without help, every wake forces every client to reconnect.
|
||||
|
||||
`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
|
||||
// Maps sessionId → ws. Populate this in your webSocketMessage handler.
|
||||
private readonly sessionIdToWs = new Map<string, WebSocket>()
|
||||
|
||||
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; the client never sees a reconnect. Setting `clientTimeout: Infinity` disables the room's idle timer; hibernating platforms handle keep-alive themselves and would otherwise see the room disconnect healthy clients.
|
||||
|
||||
For a complete implementation, including the hibernation event handlers that populate `sessionIdToWs`, see the [sync-cloudflare template](https://github.com/tldraw/tldraw/tree/main/templates/sync-cloudflare).
|
||||
|
||||
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` (20 seconds) removes idle sessions.
|
||||
|
||||
### Asset storage
|
||||
|
||||
Tldraw also needs a way to store and retrieve large binary assets like images and videos.
|
||||
|
||||
You'll need to make sure your backend can handle asset uploads & downloads, then implement
|
||||
[TLAssetStore](?) to connect it to tldraw.
|
||||
|
||||
- Read about [how assets work in tldraw](/docs/assets).
|
||||
- Read the [TLAssetStore](?) reference docs.
|
||||
- See a complete example of an asset store in the
|
||||
[`sync-cloudflare`](https://github.com/tldraw/tldraw/blob/main/templates/sync-cloudflare/client/multiplayerAssetStore.tsx)
|
||||
template.
|
||||
|
||||
### Unfurling service
|
||||
|
||||
If you want to use the built-in bookmark shape, you'll need to use or implement an unfurling service that returns metadata about URLs.
|
||||
|
||||
This should be registered with the [Editor](?) when it loads.
|
||||
|
||||
```tsx
|
||||
<Tldraw
|
||||
store={store}
|
||||
onMount={(editor) => {
|
||||
editor.registerExternalAssetHandler('url', unfurlBookmarkUrl)
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
Refer to the simple server example for example [client](https://github.com/tldraw/tldraw/blob/main/templates/simple-server-example/src/client/App.tsx) and [server](https://github.com/tldraw/tldraw/blob/main/templates/simple-server-example/src/server/unfurl.ts) code.
|
||||
|
||||
## Using tldraw sync in your app
|
||||
|
||||
### Custom shapes & bindings
|
||||
|
||||
`@tldraw/sync` validates the contents of your document and runs migrations to make sure clients of
|
||||
different versions can collaborate without issue. To support this, you need to make sure that both
|
||||
the sync client and server know about any custom shapes or bindings you've added.
|
||||
|
||||
#### On the client
|
||||
|
||||
You can pass `shapeUtils` and `bindingUtils` props to `useSync`. Unlike `<Tldraw />`,
|
||||
these don't automatically include tldraw's default shapes like arrows and rectangles. You should
|
||||
pass those in explicitly if you're using them:
|
||||
|
||||
```tsx
|
||||
import { useMemo } from 'react'
|
||||
import { Tldraw, defaultShapeUtils, defaultBindingUtils } from 'tldraw'
|
||||
import { useSync } from '@tldraw/sync'
|
||||
|
||||
function MyApp() {
|
||||
const store = useSync({
|
||||
uri: '...',
|
||||
assets: myAssetStore,
|
||||
shapeUtils: useMemo(() => [...customShapeUtils, ...defaultShapeUtils], []),
|
||||
bindingUtils: useMemo(() => [...customBindingUtils, ...defaultBindingUtils], []),
|
||||
})
|
||||
|
||||
return <Tldraw store={store} shapeUtils={customShapeUtils} bindingUtils={customBindingUtils} />
|
||||
}
|
||||
```
|
||||
|
||||
#### On the server
|
||||
|
||||
Use [createTLSchema](?) to create a store schema, and pass that into [TLSocketRoom](?). You can
|
||||
use shape/binding utils here, but the schema only reads three properties:
|
||||
[`props`](/reference/editor/ShapeUtil#props), `meta`, and
|
||||
[`migrations`](/sdk-features/persistence#Migrations). You need to provide the default shape
|
||||
schemas if you're using them.
|
||||
|
||||
```tsx
|
||||
import { createTLSchema, defaultShapeSchemas, defaultBindingSchemas } from '@tldraw/tlschema'
|
||||
import { TLSocketRoom } from '@tldraw/sync-core'
|
||||
|
||||
const schema = createTLSchema({
|
||||
shapes: {
|
||||
...defaultShapeSchemas,
|
||||
|
||||
myCustomShape: {
|
||||
// Validations for this shape's `props`.
|
||||
props: myCustomShapeProps,
|
||||
// Migrations between versions of this shape.
|
||||
migrations: myCustomShapeMigrations,
|
||||
},
|
||||
|
||||
// The schema knows about this shape, but it has no migrations or validation.
|
||||
mySimpleShape: {},
|
||||
},
|
||||
bindings: defaultBindingSchemas,
|
||||
})
|
||||
|
||||
// Later, in your app server:
|
||||
const room = new TLSocketRoom({
|
||||
schema: schema,
|
||||
// ...
|
||||
})
|
||||
```
|
||||
|
||||
Both `props` and `migrations` are optional. If you omit `props`, you won't have any server-side
|
||||
validation for your shape, which could result in bad data being stored. If you omit `migrations`,
|
||||
clients on different versions won't be able to collaborate without errors.
|
||||
|
||||
### Comments and the object-store lane
|
||||
|
||||
A room can serve some record types through a second partition, the object-store lane, which is persisted and permissioned separately from the document. Comments are the main use for it.
|
||||
|
||||
Comment threads, comments, and reactions are record types that aren't in the default schema. Register
|
||||
them with the `records` option on both ends: `useSync` on the client, [createTLSchema](?) on the
|
||||
server. Both sides must register the same types, or the connection will fail schema validation.
|
||||
|
||||
```ts
|
||||
import { TLSocketRoom } from '@tldraw/sync-core'
|
||||
import { commentSchemaRecords, createTLSchema } from '@tldraw/tlschema'
|
||||
|
||||
const schema = createTLSchema({ records: commentSchemaRecords })
|
||||
|
||||
const room = new TLSocketRoom({
|
||||
schema,
|
||||
objectTypes: ['comment', 'comment-thread', 'comment-reaction'],
|
||||
})
|
||||
```
|
||||
|
||||
`objectTypes` serves those record types through the room's object-store lane instead of the document
|
||||
lane. Lane records are persisted separately from the document, left out of document snapshots, and
|
||||
gated by a per-session `objectAccess` permission rather than by `isReadonly`. That's how a session
|
||||
can be allowed to comment without being allowed to edit:
|
||||
|
||||
```ts
|
||||
room.handleSocketConnect({
|
||||
sessionId,
|
||||
socket,
|
||||
isReadonly: true,
|
||||
objectAccess: 'write',
|
||||
})
|
||||
```
|
||||
|
||||
`objectAccess` is `'read'` or `'write'` and defaults to `'write'` on connect. Read the lane's contents with
|
||||
[TLSocketRoom#getCurrentObjectsSnapshot](?), and use the room's `onCommittedChanges` callback to
|
||||
mirror committed records into your own database. That callback only fires for client pushes, not for
|
||||
`updateStore`, `loadSnapshot`, or direct storage writes. For per-record rules like "only the author
|
||||
may edit a comment", pass authorizers to the room's `authorizeRecord` option (see [TLRecordAuthorizers](?)).
|
||||
See [Commenting](/sdk-features/commenting) for the client side and the ready-made comment authorizers.
|
||||
|
||||
### Deployment concerns
|
||||
|
||||
<Callout type="warning">
|
||||
You must make sure that the tldraw version in your client matches the version on the server. We
|
||||
don't guarantee server backwards compatibility forever, and very occasionally we might release a
|
||||
version where the backend cannot meaningfully support older clients, in which case tldraw will
|
||||
display a "please refresh the page" message. So you should make sure that the backend is updated
|
||||
at the same time as the client, and that the new backend is up and running just before the new
|
||||
client rolls out.
|
||||
</Callout>
|
||||
|
||||
### Migrating data from a legacy system
|
||||
|
||||
If you have been using some other solution for data sync, you can migrate your existing data to the tldraw sync format.
|
||||
|
||||
[SQLiteSyncStorage](?) accepts a [TLStoreSnapshot](?) directly, and [TLSocketRoom#loadSnapshot](?) accepts one for any storage backend, so you can add a backwards-compatibility layer that lazily imports data from your old system and converts it to a `TLStoreSnapshot`.
|
||||
|
||||
#### With SQLiteSyncStorage (recommended)
|
||||
|
||||
```tsx
|
||||
import { SQLiteSyncStorage, NodeSqliteWrapper, TLSocketRoom } from '@tldraw/sync-core'
|
||||
import Database from 'better-sqlite3'
|
||||
|
||||
function loadOrMakeRoom(roomId: string, db: Database.Database) {
|
||||
const sql = new NodeSqliteWrapper(db, { tablePrefix: `room_${roomId}_` })
|
||||
|
||||
// Check if we already have data in SQLite
|
||||
if (SQLiteSyncStorage.hasBeenInitialized(sql)) {
|
||||
const storage = new SQLiteSyncStorage({ sql })
|
||||
return new TLSocketRoom({ storage })
|
||||
}
|
||||
|
||||
// Try to load from legacy system
|
||||
const legacyData = loadRoomDataFromLegacyStore(roomId)
|
||||
if (legacyData) {
|
||||
const snapshot = convertOldDataToSnapshot(legacyData)
|
||||
const storage = new SQLiteSyncStorage({ sql, snapshot })
|
||||
deleteLegacyRoomData(roomId)
|
||||
return new TLSocketRoom({ storage })
|
||||
}
|
||||
|
||||
// No data - create a new empty room
|
||||
return new TLSocketRoom({ storage: new SQLiteSyncStorage({ sql }) })
|
||||
}
|
||||
```
|
||||
|
||||
#### With InMemorySyncStorage
|
||||
|
||||
```tsx
|
||||
import { InMemorySyncStorage, TLSocketRoom } from '@tldraw/sync-core'
|
||||
|
||||
async function loadOrMakeRoom(roomId: string) {
|
||||
// InMemorySyncStorage takes a RoomSnapshot, which is what getSnapshot() returns.
|
||||
const data = await loadRoomDataFromCurrentStore(roomId)
|
||||
if (data) {
|
||||
const storage = new InMemorySyncStorage({ snapshot: data })
|
||||
return new TLSocketRoom({ storage })
|
||||
}
|
||||
const legacyData = await loadRoomDataFromLegacyStore(roomId)
|
||||
if (legacyData) {
|
||||
// Convert your old data to a TLStoreSnapshot.
|
||||
const snapshot = convertOldDataToSnapshot(legacyData)
|
||||
// Load it into the room. loadSnapshot accepts a TLStoreSnapshot.
|
||||
const storage = new InMemorySyncStorage()
|
||||
const room = new TLSocketRoom({ storage })
|
||||
room.loadSnapshot(snapshot)
|
||||
// Save an updated copy of the snapshot in the new place
|
||||
// so that next time we can load it directly.
|
||||
await saveRoomData(roomId, storage.getSnapshot())
|
||||
// Optionally delete the old data.
|
||||
await deleteLegacyRoomData(roomId)
|
||||
// And finally return the room.
|
||||
return room
|
||||
}
|
||||
// If there's no data at all, just make a new blank room.
|
||||
return new TLSocketRoom({ storage: new InMemorySyncStorage() })
|
||||
}
|
||||
```
|
||||
|
||||
### Migrating from R2 to SQLite on Cloudflare
|
||||
|
||||
If you were previously using R2 to store room snapshots (as shown in earlier versions of the sync-cloudflare template), you can migrate to SQLite storage while preserving existing data.
|
||||
|
||||
#### Step 1: Update wrangler.toml if needed
|
||||
|
||||
If your Durable Object class was originally created _without_ SQLite support, you need to add a new migration with a new Durable Object class that uses SQLite storage.
|
||||
You need to keep the old class for at least one release because it can't be deleted while it's still being used, but you can convert it to an empty class.
|
||||
|
||||
```toml
|
||||
# Keep existing migration for old class
|
||||
[[migrations]]
|
||||
tag = "v1"
|
||||
new_classes = ["TldrawDurableObject"]
|
||||
|
||||
# Add new migration for SQLite-backed class
|
||||
[[migrations]]
|
||||
tag = "v2"
|
||||
new_sqlite_classes = ["TldrawDurableObjectSqlite"]
|
||||
|
||||
[durable_objects]
|
||||
bindings = [
|
||||
# Point to the new SQLite-backed class
|
||||
{ name = "TLDRAW_DURABLE_OBJECT", class_name = "TldrawDurableObjectSqlite" },
|
||||
]
|
||||
```
|
||||
|
||||
#### Step 2: Rename your Durable Object and add fallback loading
|
||||
|
||||
Rename your existing class (e.g. `TldrawDurableObject` → `TldrawDurableObjectSqlite`) and update the loading logic to check SQLite first, then fall back to R2:
|
||||
|
||||
```tsx
|
||||
import {
|
||||
DurableObjectSqliteSyncWrapper,
|
||||
RoomSnapshot,
|
||||
SQLiteSyncStorage,
|
||||
TLSocketRoom,
|
||||
} from '@tldraw/sync-core'
|
||||
import { TLRecord, createTLSchema, defaultShapeSchemas } from '@tldraw/tlschema'
|
||||
import { AutoRouter, error, IRequest } from 'itty-router'
|
||||
|
||||
// Empty stub for the old class name - required until migration is complete
|
||||
export class TldrawDurableObject {}
|
||||
|
||||
const schema = createTLSchema({ shapes: defaultShapeSchemas })
|
||||
|
||||
// Renamed from TldrawDurableObject
|
||||
export class TldrawDurableObjectSqlite {
|
||||
private roomPromise: Promise<TLSocketRoom<TLRecord, void>> | null = null
|
||||
private roomId: string | null = null
|
||||
|
||||
constructor(
|
||||
private ctx: DurableObjectState,
|
||||
private env: Env
|
||||
) {
|
||||
// Load roomId from DO storage (needed for R2 fallback)
|
||||
ctx.blockConcurrencyWhile(async () => {
|
||||
this.roomId = (await ctx.storage.get('roomId')) as string | null
|
||||
})
|
||||
}
|
||||
|
||||
// ... handleConnect, router, etc. stay the same ...
|
||||
|
||||
private async loadRoom(): Promise<TLSocketRoom<TLRecord, void>> {
|
||||
const sql = new DurableObjectSqliteSyncWrapper(this.ctx.storage)
|
||||
|
||||
// If SQLite already has data, use it directly
|
||||
if (SQLiteSyncStorage.hasBeenInitialized(sql)) {
|
||||
const storage = new SQLiteSyncStorage<TLRecord>({ sql })
|
||||
return new TLSocketRoom<TLRecord, void>({ schema, storage })
|
||||
}
|
||||
|
||||
// Try to load from R2 (legacy storage, only happens once per room)
|
||||
if (this.roomId) {
|
||||
const r2Object = await this.env.TLDRAW_BUCKET.get(`rooms/${this.roomId}`)
|
||||
if (r2Object) {
|
||||
const snapshot = (await r2Object.json()) as RoomSnapshot
|
||||
const storage = new SQLiteSyncStorage<TLRecord>({ sql, snapshot })
|
||||
|
||||
// Optionally delete the R2 object after successful migration
|
||||
// await this.env.TLDRAW_BUCKET.delete(`rooms/${this.roomId}`)
|
||||
|
||||
return new TLSocketRoom<TLRecord, void>({ schema, storage })
|
||||
}
|
||||
}
|
||||
|
||||
// No existing data - create fresh room
|
||||
const storage = new SQLiteSyncStorage<TLRecord>({ sql })
|
||||
return new TLSocketRoom<TLRecord, void>({ schema, storage })
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Step 3: Update your worker exports
|
||||
|
||||
Make sure both classes are exported from your worker entry point:
|
||||
|
||||
```tsx
|
||||
export { TldrawDurableObject, TldrawDurableObjectSqlite } from './TldrawDurableObject'
|
||||
```
|
||||
|
||||
The `loadRoom` function checks SQLite first (fast path for already-migrated rooms), falls back to R2 if SQLite is empty (one-time migration), and creates fresh storage for new rooms. You can optionally delete R2 data after migration to save storage costs.
|
||||
|
||||
Once all your rooms have been accessed at least once, the migration is complete. You can then remove the R2 fallback code and the stub `TldrawDurableObject` class.
|
||||
72
apps/docs/content/docs/tools.mdx
Normal file
72
apps/docs/content/docs/tools.mdx
Normal file
@@ -0,0 +1,72 @@
|
||||
---
|
||||
title: Tools
|
||||
status: published
|
||||
author: steveruizok
|
||||
date: 3/22/2023
|
||||
order: 3
|
||||
keywords:
|
||||
- custom
|
||||
- tools
|
||||
- state
|
||||
---
|
||||
|
||||
In tldraw, a **tool** is a top-level state in our state chart. The select tool, draw tool, and arrow tool are all examples of tools—each defines how the editor responds to user input while that tool is active.
|
||||
|
||||
<Image
|
||||
src="/images/api/tools.png"
|
||||
alt="A diagram showing the state chart of tldraw. The top row of states (apart from the Root state) are annotated as tools."
|
||||
caption="The first level of states in the state chart are known as tools."
|
||||
/>
|
||||
|
||||
## Default and custom tools
|
||||
|
||||
The `<Tldraw>` component includes default tools like [SelectTool](?), [HandTool](?), [DrawShapeTool](?), and [ArrowShapeTool](?). The core `@tldraw/editor` package has no built-in tools. If you use `<TldrawEditor>` directly, you provide your own.
|
||||
|
||||
You can create custom tools by extending [StateNode](?) and passing them to the `tools` prop:
|
||||
|
||||
```tsx
|
||||
import { StateNode, Tldraw, toRichText } from 'tldraw'
|
||||
|
||||
class StampTool extends StateNode {
|
||||
static override id = 'stamp'
|
||||
|
||||
override onPointerDown() {
|
||||
const { x, y } = this.editor.inputs.getCurrentPagePoint()
|
||||
this.editor.createShape({ type: 'text', x, y, props: { richText: toRichText('❤️') } })
|
||||
}
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return <Tldraw tools={[StampTool]} />
|
||||
}
|
||||
```
|
||||
|
||||
Registering a tool adds it to the state chart but not to the toolbar. To add a toolbar button and keyboard shortcut, see the [add tool to toolbar example](/examples/ui/add-tool-to-toolbar). Tools with multiple states declare child states with `static children()`; see the [Tools](/sdk-features/tools) guide.
|
||||
|
||||
## Changing tools
|
||||
|
||||
Change the active tool with [Editor#setCurrentTool](?), and read it with [Editor#getCurrentToolId](?):
|
||||
|
||||
```ts
|
||||
editor.setCurrentTool('select')
|
||||
editor.setCurrentTool('hand')
|
||||
editor.setCurrentTool('draw')
|
||||
editor.getCurrentToolId() // 'draw'
|
||||
```
|
||||
|
||||
## Learn more
|
||||
|
||||
| Guide | Covers |
|
||||
| ---------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
|
||||
| [Tools](/sdk-features/tools) | State hierarchy, event handling, child states, tool lock, creating custom tools, and overriding defaults |
|
||||
| [Events](/sdk-features/events) | How the editor dispatches events and how to subscribe to them |
|
||||
| [Input handling](/sdk-features/input-handling) | Pointer tracking, keyboard state, and the `editor.inputs` API |
|
||||
| [Ticks](/sdk-features/ticks) | Frame-synchronized updates for animations and continuous interactions |
|
||||
|
||||
## Examples
|
||||
|
||||
| Example | Description |
|
||||
| ----------------------------------------------------------------------- | ---------------------------------------------------- |
|
||||
| [Custom tool](/examples/shapes/tools/custom-tool) | A simple tool that adds stickers to the canvas |
|
||||
| [Tool with child states](/examples/shapes/tools/tool-with-child-states) | A tool with multiple states for complex interactions |
|
||||
| [Screenshot tool](/examples/shapes/tools/screenshot-tool) | A tool for capturing canvas regions |
|
||||
60
apps/docs/content/docs/user-interface.mdx
Normal file
60
apps/docs/content/docs/user-interface.mdx
Normal file
@@ -0,0 +1,60 @@
|
||||
---
|
||||
title: User interface
|
||||
status: published
|
||||
author: steveruizok
|
||||
date: 3/22/2023
|
||||
order: 3
|
||||
keywords:
|
||||
- ui
|
||||
- interface
|
||||
- tools
|
||||
- shapes
|
||||
- custom
|
||||
- button
|
||||
- toolbar
|
||||
- styles
|
||||
readability: 8
|
||||
voice: 8
|
||||
completeness: 8
|
||||
accuracy: 9
|
||||
notes: 'Brief intro that links to sdk-features articles.'
|
||||
---
|
||||
|
||||
The `tldraw` package includes a complete user interface with menus, toolbars, keyboard shortcuts, and style panels. You can hide it entirely, listen to events, or customize individual components.
|
||||
|
||||
Each of those is a prop on `<Tldraw>`: `hideUi` hides everything, `components` replaces or removes individual pieces, and `onUiEvent` reports every UI interaction:
|
||||
|
||||
```tsx
|
||||
import { Tldraw } from 'tldraw'
|
||||
import 'tldraw/tldraw.css'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0 }}>
|
||||
<Tldraw
|
||||
hideUi={false}
|
||||
components={{ PageMenu: null }}
|
||||
onUiEvent={(name, data) => console.log(name, data)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Learn more
|
||||
|
||||
- [UI components](/sdk-features/ui-components) — Component architecture, overrides, and customization
|
||||
- [UI primitives](/sdk-features/ui-primitives) — Buttons, menus, dialogs for building custom interfaces
|
||||
- [Actions](/sdk-features/actions) — Add or override actions and keyboard shortcuts
|
||||
- [Styles](/sdk-features/styles) — The style panel and custom style properties
|
||||
- [Themes](/sdk-features/themes) — Color modes and custom color palettes
|
||||
- [Accessibility](/sdk-features/accessibility) — Keyboard navigation and screen reader support
|
||||
- [Internationalization](/sdk-features/internationalization) — Translations and language support
|
||||
|
||||
## Examples
|
||||
|
||||
- [Hide UI](/examples/ui/hide-ui) — Hide the entire default interface
|
||||
- [Custom UI](/examples/ui/custom-ui) — Build a completely custom interface
|
||||
- [Hide UI components](/examples/ui/ui-components-hidden) — Selectively hide specific components
|
||||
- [Action overrides](/examples/ui/action-overrides) — Customize actions and shortcuts
|
||||
- [UI events](/examples/events/ui-events) — Track user interactions
|
||||
211
apps/docs/content/getting-started/installation.mdx
Normal file
211
apps/docs/content/getting-started/installation.mdx
Normal file
@@ -0,0 +1,211 @@
|
||||
---
|
||||
title: Installation
|
||||
status: published
|
||||
author: steveruizok
|
||||
date: 3/22/2023
|
||||
order: 1
|
||||
keywords:
|
||||
- hello world
|
||||
---
|
||||
|
||||
To use the tldraw SDK, first install the `tldraw` package:
|
||||
|
||||
```bash
|
||||
npm install tldraw
|
||||
```
|
||||
|
||||
Now import and use the [Tldraw](?) component inside of any React component. You will need React 18 or 19.
|
||||
|
||||
```tsx
|
||||
import { Tldraw } from 'tldraw'
|
||||
import 'tldraw/tldraw.css'
|
||||
|
||||
export default function () {
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0 }}>
|
||||
<Tldraw />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Wrapper
|
||||
|
||||
The [Tldraw](?) component must be wrapped in a parent container with an explicit size. Its height and width are set to `100%`, so it will fill its parent container.
|
||||
|
||||
### Accessing the editor
|
||||
|
||||
Use the `onMount` prop to access the [Editor](?) instance when it's ready:
|
||||
|
||||
```tsx
|
||||
function App() {
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0 }}>
|
||||
<Tldraw
|
||||
onMount={(editor) => {
|
||||
// Do something with the editor
|
||||
editor.selectAll()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
The callback can return a cleanup function that runs when the editor unmounts.
|
||||
|
||||
### CSS
|
||||
|
||||
In addition to the [Tldraw](?) component itself, you should also import the `tldraw.css` file from the `tldraw` package.
|
||||
|
||||
```tsx
|
||||
import 'tldraw/tldraw.css'
|
||||
```
|
||||
|
||||
You can alternatively import this file inside of another CSS file using the `@import` syntax.
|
||||
|
||||
```css
|
||||
@import url('tldraw/tldraw.css');
|
||||
```
|
||||
|
||||
If you'd like to deeply change the way that tldraw looks, you can copy the `tldraw.css` file into a new CSS file, make your changes, and import that instead.
|
||||
|
||||
### Fonts
|
||||
|
||||
The tldraw SDK bundles its own fonts for shapes: IBM Plex (Sans, Serif, Mono) and Shantell Sans (for draw-style text). These are loaded automatically from the CDN or self-hosted [static assets](#Static-assets).
|
||||
|
||||
The tldraw UI will inherit its font family. You can set this to any font you like. For example, to use Inter:
|
||||
|
||||
```css
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@500;700&display=swap');
|
||||
|
||||
.tl-container {
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
```
|
||||
|
||||
### HTML
|
||||
|
||||
If you're using the [Tldraw](?) component in a full-screen app, update your `index.html`'s meta viewport element as shown below.
|
||||
|
||||
```html
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
```
|
||||
|
||||
Without these viewport options, some features like safe area positioning won't work correctly.
|
||||
|
||||
## License
|
||||
|
||||
If you have a [license key](https://tldraw.dev/pricing), you can pass your key to the `licenseKey` prop.
|
||||
|
||||
```tsx
|
||||
<Tldraw licenseKey={YOUR_LICENSE_KEY} />
|
||||
```
|
||||
|
||||
To learn more about the license and license key, visit [our pricing page](https://tldraw.dev/pricing).
|
||||
|
||||
## Static assets
|
||||
|
||||
To use the [Tldraw](?) component, the app must be able to find certain assets. These are contained in the `embed-icons`, `fonts`, `icons`, and `translations` folders. We offer a few different ways of making these assets available to your app.
|
||||
|
||||
### Using a bundler
|
||||
|
||||
If you're using a bundler like webpack or rollup, you can import the assets directly from the `@tldraw/assets` package. Your bundler will copy the assets with your bundle and insert the correct URLs in their place.
|
||||
|
||||
There are three options:
|
||||
|
||||
- `import {getAssetUrlsByImport} from '@tldraw/assets/imports'`: import asset files using import statements. You'll need to configure your bundler to treat imports of .svg, .png, .json, and .woff2 files as external assets.
|
||||
- `import {getAssetUrlsByImport} from '@tldraw/assets/imports.vite'`: import asset files, appending `?url` to the asset path. This works correctly with [vite](https://vitejs.dev/guide/assets#explicit-url-imports) without any extra configuration needed.
|
||||
- `import {getAssetUrlsByMetaUrl} from '@tldraw/assets/urls'`: get asset urls using `new URL(path, import.meta.url)`. This is a standards-based approach that works natively in most modern browsers & bundlers.
|
||||
|
||||
Call the function, and pass the resulting `assetUrls` into the `Tldraw` component:
|
||||
|
||||
```tsx
|
||||
import { getAssetUrlsByMetaUrl } from '@tldraw/assets/urls'
|
||||
|
||||
const assetUrls = getAssetUrlsByMetaUrl()
|
||||
|
||||
<Tldraw assetUrls={assetUrls} />
|
||||
```
|
||||
|
||||
### Using a public CDN
|
||||
|
||||
By default, we serve these assets from a public CDN. This works without configuration and is a good starting point.
|
||||
|
||||
If you would like to customize some of the assets you can pass the customizations to our [Tldraw](?) component. For example, to use a custom icon for the `hand` tool you can do the following:
|
||||
|
||||
```tsx
|
||||
const assetUrls = {
|
||||
icons: {
|
||||
'tool-hand': './custom-tool-hand.svg',
|
||||
},
|
||||
}
|
||||
|
||||
<Tldraw assetUrls={assetUrls} />
|
||||
```
|
||||
|
||||
This will use the custom icon for the `hand` tool and the default assets for everything else.
|
||||
|
||||
### Self-hosting assets
|
||||
|
||||
You can also host these assets yourself:
|
||||
|
||||
1. Download the `embed-icons`, `fonts`, `icons`, and `translations` folders from the [assets folder](https://github.com/tldraw/tldraw/tree/main/assets) of the tldraw repository.
|
||||
2. Place the folders in your project's public path.
|
||||
3. Pass `assetUrls` prop to our `<Tldraw/>` component to let the component know where the assets live.
|
||||
|
||||
You can use our `getAssetUrls` helper function from the `@tldraw/assets` package to generate these urls for you.
|
||||
|
||||
```tsx
|
||||
import { getAssetUrls } from '@tldraw/assets/selfHosted'
|
||||
|
||||
const assetUrls = getAssetUrls()
|
||||
|
||||
<Tldraw assetUrls={assetUrls} />
|
||||
```
|
||||
|
||||
While these files must be available, you can overwrite the individual files: for example, by placing different icons under the same name or by modifying or adding translations.
|
||||
|
||||
If you use a CDN for hosting these files you can specify the base url of your assets. To recreate the above option of serving the assets from our CDN you would do the following:
|
||||
|
||||
```ts
|
||||
import { getDefaultCdnBaseUrl } from 'tldraw'
|
||||
|
||||
const assetUrls = getAssetUrls({
|
||||
baseUrl: getDefaultCdnBaseUrl(),
|
||||
})
|
||||
```
|
||||
|
||||
## Subcomponents
|
||||
|
||||
The [Tldraw](?) component combines two lower-level components: [TldrawEditor](?) and [TldrawUi](?). If you want more granular control, you can use those lower-level components directly. See the [exploded example](/examples/configuration/exploded) for reference.
|
||||
|
||||
### Customizing components
|
||||
|
||||
You can customize the appearance of the tldraw editor and UI using the [Tldraw](?) (or [TldrawEditor](?)) component's `components` prop. This prop accepts a [TLComponents](?) object, which combines [TLEditorComponents](?) (canvas-level components like the background and grid) with [TLUiComponents](?) (UI elements like toolbar, menus, and panels).
|
||||
|
||||
```tsx
|
||||
import { TLComponents } from 'tldraw'
|
||||
|
||||
const components: TLComponents = {
|
||||
// Editor components
|
||||
Background: YourCustomBackground,
|
||||
Grid: YourCustomGrid,
|
||||
|
||||
// UI components
|
||||
Toolbar: YourCustomToolbar,
|
||||
MainMenu: YourCustomMainMenu,
|
||||
StylePanel: YourCustomStylePanel,
|
||||
ContextMenu: YourCustomContextMenu,
|
||||
}
|
||||
|
||||
<Tldraw components={components} />
|
||||
```
|
||||
|
||||
Canvas overlay elements like the selection foreground, brush, snap indicators, and scribble are rendered via the [OverlayUtil](?) system rather than React components. You can customize them by extending the built-in overlay utils and passing them via the `overlayUtils` prop.
|
||||
|
||||
See the [TLEditorComponents](?) and [TLUiComponents](?) type definitions for the complete list of customizable components.
|
||||
|
||||
## Versioning
|
||||
|
||||
The tldraw SDK does not follow semantic versioning. To learn more, read about how we do [release versioning](/releases).
|
||||
159
apps/docs/content/getting-started/quick-start.mdx
Normal file
159
apps/docs/content/getting-started/quick-start.mdx
Normal file
@@ -0,0 +1,159 @@
|
||||
---
|
||||
title: Quick start
|
||||
status: published
|
||||
author: steveruizok
|
||||
date: 3/22/2023
|
||||
order: 0
|
||||
keywords:
|
||||
- hello world
|
||||
- intro
|
||||
- start
|
||||
- installation
|
||||
---
|
||||
|
||||
Have five minutes? Run this command in your terminal to explore tldraw [starter kits](/starter-kits):
|
||||
|
||||
```bash
|
||||
npm create tldraw@latest
|
||||
```
|
||||
|
||||
Have a little more time? Let's try out the tldraw SDK in a React project.
|
||||
|
||||
If you're new to React, we recommend using a [Vite template](https://vitejs.dev/guide/#scaffolding-your-first-vite-project) as a starter. We'll assume your project is already running locally.
|
||||
|
||||
## Getting started
|
||||
|
||||
First, install the `tldraw` package from npm:
|
||||
|
||||
```bash
|
||||
npm install tldraw
|
||||
```
|
||||
|
||||
Next, in your React project, import the `Tldraw` component and tldraw's CSS styles. Then render the `Tldraw` component inside a full screen container:
|
||||
|
||||
```tsx
|
||||
import { Tldraw } from 'tldraw'
|
||||
import 'tldraw/tldraw.css'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0 }}>
|
||||
<Tldraw />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
That's pretty much it! At this point, you should have a complete working single-user canvas. You can draw, write, add images and video, zoom, pan, copy and paste, undo and redo—everything you'd expect from a canvas.
|
||||
|
||||
You'll be starting from our default [shapes](/docs/shapes), [tools](/docs/tools), and [user interface](/docs/user-interface), but you can customize all of these things for your project if you wish. For now, let's show off a few more features.
|
||||
|
||||
## Local persistence
|
||||
|
||||
Let's add local persistence by passing a `persistenceKey` prop to the `Tldraw` component:
|
||||
|
||||
<FocusLines lines={[4]}>
|
||||
|
||||
```tsx
|
||||
export default function App() {
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0 }}>
|
||||
<Tldraw persistenceKey="example" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
</FocusLines>
|
||||
|
||||
The `persistenceKey` prop saves your project to the browser's storage so that it survives a refresh. It also synchronizes the project with other instances that share the same key—even in other browser tabs! Give it a try by opening your app in a second window.
|
||||
|
||||
## Real-time collaboration
|
||||
|
||||
To add support for multiple users collaborating in real time, you can use the [**tldraw sync**](/docs/sync) library. Its demo hook connects your app to tldraw's demo server, which hosts temporary rooms.
|
||||
|
||||
First, install the `@tldraw/sync` package:
|
||||
|
||||
```bash
|
||||
npm install @tldraw/sync
|
||||
```
|
||||
|
||||
Next, import the `useSyncDemo` hook from the `@tldraw/sync` package. Call it in your component with a unique ID and pass the store that it returns to the `Tldraw` component:
|
||||
|
||||
<FocusLines lines={[2,6,10]}>
|
||||
|
||||
```tsx
|
||||
import { Tldraw } from 'tldraw'
|
||||
import { useSyncDemo } from '@tldraw/sync'
|
||||
import 'tldraw/tldraw.css'
|
||||
|
||||
export default function App() {
|
||||
const store = useSyncDemo({ roomId: 'insert-any-string-here' })
|
||||
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0 }}>
|
||||
<Tldraw store={store} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
</FocusLines>
|
||||
|
||||
Try it out by opening your project in a second incognito window, or else access it from another device. You should see all of tldraw's multiplayer features: live cursors, user names, viewport following, cursor chat, and more.
|
||||
|
||||
If you want to go further with real-time collaboration, be sure to check out our guide to the [**tldraw sync**](/docs/sync) library.
|
||||
|
||||
## Controlling the canvas
|
||||
|
||||
The tldraw [editor](/docs/editor) has a runtime JavaScript API. Everything that can happen in tldraw can be done programmatically through the [Editor](?) instance.
|
||||
|
||||
For simplicity's sake, let's roll back our persistence and sync code. We can then use the `Tldraw` component's `onMount` callback to get access to the Editor instance. We'll use the editor to create a new shape on the canvas, select it, then slowly zoom to it.
|
||||
|
||||
```tsx
|
||||
import { Editor, Tldraw, toRichText } from 'tldraw'
|
||||
import 'tldraw/tldraw.css'
|
||||
|
||||
export default function App() {
|
||||
const handleMount = (editor: Editor) => {
|
||||
editor.createShape({
|
||||
type: 'text',
|
||||
x: 200,
|
||||
y: 200,
|
||||
props: {
|
||||
richText: toRichText('Hello world!'),
|
||||
},
|
||||
})
|
||||
|
||||
editor.selectAll()
|
||||
|
||||
editor.zoomToSelection({
|
||||
animation: { duration: 5000 },
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0 }}>
|
||||
<Tldraw onMount={handleMount} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
The Editor is tldraw's main interface for controlling the canvas. Be sure to check out the [Editor](?) API documentation for more information on what you can do with it, as well as our [guide on using the editor](/docs/editor).
|
||||
|
||||
## Next steps
|
||||
|
||||
Now that you've seen how the tldraw canvas works, you can:
|
||||
|
||||
- Create your own [shapes](/docs/shapes) and [tools](/docs/tools)
|
||||
- Customize the [user interface](/docs/user-interface)
|
||||
- Learn more about the [editor](/docs/editor)
|
||||
- Explore our [examples](/examples)
|
||||
- Build with our [starter kits](/starter-kits)
|
||||
|
||||
The SDK lets you customize shapes, tools, UI, and more. In addition to our long-form docs, we have dozens of examples in our [examples section](/examples) that cover more of its functionality. You can run these locally with the tldraw [GitHub repository](https://github.com/tldraw/tldraw).
|
||||
|
||||
## Join our community
|
||||
|
||||
If you build something great, please share it with us in our [#show-and-tell](https://discord.tldraw.com/?utm_source=docs&utm_medium=organic&utm_campaign=sociallink) channel on Discord. Good luck!
|
||||
76
apps/docs/content/getting-started/releases.mdx
Normal file
76
apps/docs/content/getting-started/releases.mdx
Normal file
@@ -0,0 +1,76 @@
|
||||
---
|
||||
title: Releases
|
||||
status: published
|
||||
author: steveruizok
|
||||
order: 3
|
||||
---
|
||||
|
||||
Release notes for all tldraw SDK versions. Each minor release has its own page documenting new features, breaking changes, API additions, improvements, and bug fixes. Patch releases are listed within their parent minor release.
|
||||
|
||||
For the original releases, see the [GitHub releases page](https://github.com/tldraw/tldraw/releases).
|
||||
|
||||
## How tldraw is versioned
|
||||
|
||||
Unlike many JavaScript packages distributed on [NPM](https://www.npmjs.com/), the tldraw SDK does not follow [semantic versioning](https://semver.org/) in its release versions. Instead:
|
||||
|
||||
- Major version bumps are rare. We reserve them for fundamental changes to how the SDK works.
|
||||
- Minor version bumps are released on a regular cadence, approximately monthly. **They may contain breaking changes**. We aim to make breaking changes as minimally disruptive as possible, but tldraw is actively evolving as we add new features. We recommend updating at a similar pace and checking the release notes.
|
||||
- Patch version bumps are for bugfixes and hotfixes that can't wait for the next cadence release.
|
||||
|
||||
## Latest tldraw versions
|
||||
|
||||
- Run `npm install tldraw` to get the latest minor version of tldraw
|
||||
- Use `npm install tldraw@next` to get the latest version of tldraw that is used on [tldraw.com](https://www.tldraw.com)
|
||||
- The latest changes to the `main` branch of tldraw are available to test through our [pre-release canary builds](https://www.npmjs.com/package/tldraw?activeTab=versions).
|
||||
|
||||
## Migration skill
|
||||
|
||||
We ship an experimental `tldraw-migrate` agent skill that helps a coding agent update your project to the latest version of the SDK. It detects your current version, fetches the relevant release notes, upgrades packages, and works through any type errors using the migration guides in our release notes. See our [migration skill guide](/releases/migration-skill) for details on how to install and use the skill.
|
||||
|
||||
## Next
|
||||
|
||||
- [next](/releases/next) - Changes for the upcoming release.
|
||||
|
||||
## v5.x
|
||||
|
||||
- [v5.4](/releases/v5.4.0) - A center align operation, a tleditors registry of mounted editors, faster signals and store layers, and a broad reliability pass across the SDK
|
||||
- [v5.3](/releases/v5.3.0) - Commenting with anchored threads on the canvas, new collaboration packages, geo shape flipping, crop snapping, and faster viewport culling
|
||||
- [v5.2](/releases/v5.2.0) - Faster freehand ink, a frame selection action, SDK-wide performance improvements, and breaking changes to the collaborator user-id types and Node support
|
||||
- [v5.1](/releases/v5.1.0) - Page menu redesign, copy-styles shortcut, selectable locked shapes, public translation APIs, and performance improvements
|
||||
- [v5.0](/releases/v5.0.0) - Custom themes, overlays, performance, extensiblity, and attribution
|
||||
|
||||
## v4.x
|
||||
|
||||
- [v4.5](/releases/v4.5.0) - Click-through on transparent pixels, SVG sanitization, configurable embed definitions
|
||||
- [v4.4](/releases/v4.4.0) - Image pipeline starter kit, performance improvements, quick zoom, canvas indicators
|
||||
- [v4.3](/releases/v4.3.0) - SQLite sync storage, improved custom shape types, reactive inputs, draw shape encoding
|
||||
- [v4.2](/releases/v4.2.0) - TipTap v3, dynamic tools, custom socket implementations
|
||||
- [v4.1](/releases/v4.1.0) - Shader starter kit, localStorage atoms, minimap filtering
|
||||
- [v4.0](/releases/v4.0.0) - Starter kits, WCAG 2.2 AA compliance, licensing updates
|
||||
|
||||
## v3.x
|
||||
|
||||
- [v3.15](/releases/v3.15.0) - `npm create tldraw` CLI, accessibility improvements
|
||||
- [v3.14](/releases/v3.14.0) - Contextual toolbars, PathBuilder API
|
||||
- [v3.13](/releases/v3.13.0) - Elbow arrows for technical diagrams
|
||||
- [v3.12](/releases/v3.12.0) - Accessibility focus, keyboard navigation
|
||||
- [v3.11](/releases/v3.11.0) - Rich text fixes, zoom improvements
|
||||
- [v3.10](/releases/v3.10.0) - Rich text as first-class primitive
|
||||
- [v3.9](/releases/v3.9.0) - Layout improvements, AtomMap class
|
||||
- [v3.8](/releases/v3.8.0) - React 19 compatibility, i18n expansion
|
||||
- [v3.7](/releases/v3.7.0) - Presence sync customization
|
||||
- [v3.6](/releases/v3.6.0) - Expanded action helpers
|
||||
- [v3.5](/releases/v3.5.0) - Grid snapping, layer improvements
|
||||
- [v3.4](/releases/v3.4.0) - Excalidraw compatibility
|
||||
- [v3.3](/releases/v3.3.0) - Readonly mode, sync improvements
|
||||
- [v3.2](/releases/v3.2.0) - Version alignment release
|
||||
- [v3.1](/releases/v3.1.0) - Shape visibility, server-side updates
|
||||
- [v3.0](/releases/v3.0.0) - New licensing, deep links, custom embeds
|
||||
|
||||
## v2.x
|
||||
|
||||
- [v2.4](/releases/v2.4.0)
|
||||
- [v2.3](/releases/v2.3.0)
|
||||
- [v2.2](/releases/v2.2.0)
|
||||
- [v2.1](/releases/v2.1.0)
|
||||
- [v2.0](/releases/v2.0.0) - Initial public release
|
||||
109
apps/docs/content/releases/migration-skill.mdx
Normal file
109
apps/docs/content/releases/migration-skill.mdx
Normal file
@@ -0,0 +1,109 @@
|
||||
---
|
||||
title: Migration skill
|
||||
description: Guide to using the tldraw-migrate agent skill to upgrade your project to a newer tldraw SDK version
|
||||
author: tldraw
|
||||
date: 05/06/2026
|
||||
order: 0
|
||||
status: published
|
||||
keywords:
|
||||
- migration
|
||||
- skill
|
||||
- upgrade
|
||||
- agent
|
||||
- claude
|
||||
- cursor
|
||||
---
|
||||
|
||||
The `tldraw-migrate` skill is an agent skill that helps you migrate a project to a newer version of the tldraw SDK. You point your coding agent at it, and it detects the previous version, fetches the release notes for everything between then and now, upgrades the packages, and works through any TypeScript errors using the migration guides published in our release notes.
|
||||
|
||||
<Callout type="info">
|
||||
This skill is experimental. It is designed to guide your migration, not to run it unattended. Treat its output as a starting point. Review the diff, run your own tests, and watch for changes the skill missed or got wrong.
|
||||
</Callout>
|
||||
|
||||
## What it does
|
||||
|
||||
The skill packages our migration workflow into a single command so an agent can apply it to your codebase:
|
||||
|
||||
- Detects the tldraw version your project is migrating from and the target you're migrating to.
|
||||
- Fetches the public release notes between those two versions and caches them next to the skill, along with the full SDK docs for ad-hoc lookup.
|
||||
- Inspects your project to figure out the package manager, which tldraw packages are installed, which import style you use, and how your typecheck and build scripts are configured.
|
||||
- Upgrades every tldraw package already in `package.json` to the resolved target.
|
||||
- Categorizes the resulting TypeScript errors by code, then fixes them in order of impact: React types first, then custom shape and binding registration, then API renames and removals, then TipTap, then anything left over.
|
||||
- Sweeps the project for `@deprecated` API usage and applies the recommended replacements.
|
||||
- Verifies with the project's typecheck and build, and audits the diff for new `as` casts or unsafe module augmentations.
|
||||
|
||||
The version-specific recipes come from the `<details><summary>Migration guide</summary>` blocks in our release notes. The skill doesn't carry its own copy of these recipes; it reads the release notes at run time, so a single skill works across releases.
|
||||
|
||||
## Adding the skill to your project
|
||||
|
||||
The easiest way to install the skill is to ask your agent:
|
||||
|
||||
```
|
||||
Using the instructions in the README file at https://github.com/tldraw/tldraw/blob/main/skills/tldraw-migrate/README.md upgrade my project to the latest version of tldraw using the migration skill
|
||||
```
|
||||
|
||||
For more control, see the [manual installation](#manual-installation) options below.
|
||||
|
||||
## How to use it
|
||||
|
||||
Once the skill is installed, ask your agent to migrate your project. It auto-detects your current version, reviews the SDK changes between then and the target, and proposes updates.
|
||||
|
||||
## Manual installation
|
||||
|
||||
Copy the entire `tldraw-migrate/` folder into one of the locations the skill probes, relative to your project root. The skill checks them in this order and uses the first one it finds:
|
||||
|
||||
| Path | Harness |
|
||||
| -------------------------------- | ------------------------------------ |
|
||||
| `.claude/skills/tldraw-migrate/` | Claude Code |
|
||||
| `.agents/skills/tldraw-migrate/` | Generic / open agent-skills spec |
|
||||
| `.codex/skills/tldraw-migrate/` | OpenAI Codex |
|
||||
| `.cursor/skills/tldraw-migrate/` | Cursor |
|
||||
| `skills/tldraw-migrate/` | Fallback / vendored alongside source |
|
||||
|
||||
If multiple harnesses share a project, install once and symlink the others to the same folder.
|
||||
|
||||
### One-shot install from GitHub
|
||||
|
||||
```sh
|
||||
DEST=.claude/skills/tldraw-migrate # or .agents/skills/..., .codex/skills/..., .cursor/skills/..., etc.
|
||||
mkdir -p "$DEST"
|
||||
curl -fsSL https://github.com/tldraw/tldraw/archive/refs/heads/main.tar.gz \
|
||||
| tar -xz --strip-components=3 -C "$DEST" tldraw-main/skills/tldraw-migrate
|
||||
```
|
||||
|
||||
To install from a local checkout of the tldraw monorepo, copy `skills/tldraw-migrate/` into one of the paths above directly.
|
||||
|
||||
### Updating an installed copy
|
||||
|
||||
Re-run the one-shot install above to overwrite `SKILL.md` and the helper scripts. The skill's content (release notes and docs) is auto-refreshed on every invocation, so you only need to update `SKILL.md` when the workflow changes.
|
||||
|
||||
### Custom install location
|
||||
|
||||
If you keep the skill somewhere outside the probed paths, export `SKILL_DIR` before invocation:
|
||||
|
||||
```sh
|
||||
SKILL_DIR=/abs/path/to/tldraw-migrate
|
||||
```
|
||||
|
||||
The auto-fetch blocks and internal grep examples in `SKILL.md` honor this variable.
|
||||
|
||||
## Where to find it
|
||||
|
||||
The canonical source is [`skills/tldraw-migrate/`](https://github.com/tldraw/tldraw/tree/main/skills/tldraw-migrate) in the [tldraw](https://github.com/tldraw/tldraw) monorepo. The folder is self-contained:
|
||||
|
||||
- `SKILL.md` — the workflow the agent follows.
|
||||
- `detect-versions.mjs` — resolves the "from" version.
|
||||
- `detect-target.mjs` — resolves the "to" version (defaults to `latest` but can take a tag like `canary`/`next`/`beta`, or a pre-release version).
|
||||
- `fetch-release-notes.mjs` — pulls release notes from the docs markdown files in the tldraw repo.
|
||||
- `references/` — auto-populated cache of fetched release notes and docs (created on first run; safe to gitignore).
|
||||
|
||||
GitHub is the deliberate source of truth: sourcing release notes from `main` rather than from a deployed docs site means migration recipes for unreleased versions are available to the skill the moment they land.
|
||||
|
||||
## Requirements
|
||||
|
||||
| Requirement | Purpose |
|
||||
| -------------- | ---------------------------------------------------------------------------------------------------------------------- |
|
||||
| Node ≥ 20 | Required for the helper scripts. They are zero-dependency ES modules. |
|
||||
| `curl` | Required for the docs fetch. |
|
||||
| `git` | Optional. Improves from-version detection (the skill compares `main`, `HEAD~1`, and the working tree). |
|
||||
| `GITHUB_TOKEN` | Optional. Raises GitHub's unauthenticated API rate limit (60/hr) so re-runs and long version ranges don't hit the cap. |
|
||||
145
apps/docs/content/releases/next.mdx
Normal file
145
apps/docs/content/releases/next.mdx
Normal file
@@ -0,0 +1,145 @@
|
||||
---
|
||||
title: Next release
|
||||
description: Changes coming in the next release
|
||||
author: tldraw
|
||||
date: 09/16/2026
|
||||
order: 0
|
||||
status: published
|
||||
last_version: v5.4.2
|
||||
---
|
||||
|
||||
This release is a broad reliability pass over the SDK. Shapes keep changes made by other commands while they are being dragged, resized, rotated, or cropped; touch gestures no longer stick when the browser cancels a pointer; selection, draw, line, note, frame, and arrow interactions survive shapes being deleted or locked mid-interaction; SVG exports embed every resource they reference; and there are fixes across the store, signals, sync, and local persistence. `tldraw` now re-exports TipTap's extension API, so custom rich text extensions no longer need their own `@tiptap/*` install, and `Editor.setCamera` rejects non-finite values instead of quietly coercing them.
|
||||
|
||||
## API changes
|
||||
|
||||
- 💥 **`Editor.setCamera`** now throws on a non-finite `x`, `y`, or `z` instead of silently coercing it to `0` or the current zoom level, and no longer mutates the point object passed to it. ([#9903](https://github.com/tldraw/tldraw/pull/9903))
|
||||
- 💥 **`store.query.filterHistory()`** now returns its own change counter rather than a value that always matches `store.history.get()`. After a rolled-back transaction it can be ahead of the store's counter, and it reports a change even for record types the transaction didn't touch. ([#10200](https://github.com/tldraw/tldraw/pull/10200))
|
||||
- Re-export TipTap's extension surface from `tldraw`: `Node`, `Mark`, `Extension`, `mergeAttributes`, and `getSchema`, the `Extensions` and `JSONContent` types, and the `TaskList` and `TaskItem` extensions. Custom rich text extensions no longer need their own `@tiptap/*` dependency, which also rules out loading two copies of TipTap. ([#10691](https://github.com/tldraw/tldraw/pull/10691))
|
||||
|
||||
```tsx
|
||||
import { Mark, mergeAttributes } from 'tldraw'
|
||||
```
|
||||
|
||||
- Add `MESSAGE_TOO_LARGE` to `TLSyncErrorCloseEventReason`. Clients that switch on the reason to pick an error message will want a case for it. ([#10810](https://github.com/tldraw/tldraw/pull/10810))
|
||||
- Add `LruCache.delete(key)` to `@tldraw/utils`. ([#10338](https://github.com/tldraw/tldraw/pull/10338))
|
||||
|
||||
## Improvements
|
||||
|
||||
- `npm create tldraw .` now scaffolds into the current directory, prompting first when the directory isn't empty. ([#10657](https://github.com/tldraw/tldraw/pull/10657))
|
||||
- Update the AI starter kit templates to current Claude, Gemini, and GPT model ids. ([#10058](https://github.com/tldraw/tldraw/pull/10058))
|
||||
|
||||
## Bug fixes
|
||||
|
||||
### Crashes
|
||||
|
||||
- Fix a crash that could replace the editor with the host app's error screen when a group's child record was removed during a transaction. Children that no longer resolve are skipped, and a group where none resolve falls back to a unit box. ([#10668](https://github.com/tldraw/tldraw/pull/10668))
|
||||
- Fix a crash when a shape is deleted between pointing at it and starting to drag it or one of its handles. ([#10179](https://github.com/tldraw/tldraw/pull/10179))
|
||||
- Fix a crash when a shape being translated, or the frame it is being dragged over, is deleted mid-drag. ([#10171](https://github.com/tldraw/tldraw/pull/10171))
|
||||
- Fix draw and highlight shapes created with no segments throwing in geometry and rendering. ([#10224](https://github.com/tldraw/tldraw/pull/10224))
|
||||
- Fix duplicating a zero-length line throwing. `LineShapeUtil.onBeforeCreate` no longer mutates the caller's points. ([#10248](https://github.com/tldraw/tldraw/pull/10248))
|
||||
- Fix a line shape with fewer than two points breaking hit testing for the whole page. ([#10222](https://github.com/tldraw/tldraw/pull/10222))
|
||||
- Fix large rooms failing to load in `InMemorySyncStorage` with a stack overflow. ([#10110](https://github.com/tldraw/tldraw/pull/10110))
|
||||
|
||||
### Selection and tools
|
||||
|
||||
- Fix shapes losing changes made by other commands while they are being translated, resized, rotated, or cropped. Pressing the rotate shortcut mid-drag, or a remote update arriving during a gesture, is no longer overwritten by the next pointer move. ([#9215](https://github.com/tldraw/tldraw/pull/9215))
|
||||
- Fix tapping a toolbar tool with a stylus, finger, or trackpad sometimes creating a shape instead of selecting the tool. Dragging a tool out of the toolbar now requires the pointer to leave the toolbar, and sliding along the toolbar no longer starts a drag. ([#10651](https://github.com/tldraw/tldraw/pull/10651))
|
||||
- Fix touch gestures staying stuck when the browser cancels the pointer, as it does for an edge swipe, an incoming call, or palm rejection. ([#10464](https://github.com/tldraw/tldraw/pull/10464))
|
||||
- Fix Tab, Shift+Tab, and Ctrl/Cmd+arrow keyboard traversal selecting locked shapes, which clicking and select all never do. ([#10482](https://github.com/tldraw/tldraw/pull/10482))
|
||||
- Fix the right Shift key not giving the large nudge step when moving shapes with the arrow keys. ([#10477](https://github.com/tldraw/tldraw/pull/10477))
|
||||
- Fix keyboard shape navigation, and `Editor.selectAdjacentShape('prev')`, selecting the second-to-last shape instead of the last one when nothing is selected. ([#10601](https://github.com/tldraw/tldraw/pull/10601))
|
||||
- Fix Ctrl/Cmd+arrow shape navigation preferring a nearer diagonal shape over an aligned one. ([#10339](https://github.com/tldraw/tldraw/pull/10339))
|
||||
- Fix pointer downs inside a rotated multi-selection dragging the wrong shapes, and clicking a selected locked shape with `selectLockedShapes` selecting the shape behind it. ([#10166](https://github.com/tldraw/tldraw/pull/10166))
|
||||
- Fix the select tool bailing to an old history mark, or crashing, when a resize starts with nothing selected. ([#10165](https://github.com/tldraw/tldraw/pull/10165))
|
||||
- Fix the select tool getting stuck after pressing an arrow label in a read-only editor or on a deleted arrow. ([#10183](https://github.com/tldraw/tldraw/pull/10183))
|
||||
- Use Cmd (Mac) or Ctrl to toggle snapping while dragging handles and wrap mode while brush selecting, matching translate, resize, and crop. ([#10172](https://github.com/tldraw/tldraw/pull/10172))
|
||||
- Fix cancelling a note clone-handle drag leaving the new note behind and switching to the note tool. ([#10187](https://github.com/tldraw/tldraw/pull/10187))
|
||||
- Fix the toolbar losing the originating tool's highlight while zooming with Z, and the zoom tool staying on when Z was pressed straight after clicking a toolbar button. ([#10185](https://github.com/tldraw/tldraw/pull/10185))
|
||||
- Fix the **Frame selection** action and shortcut doing nothing when a single shape is selected. Locked shapes, and arrows bound to shapes outside the new frame, are now left out of it. ([#10627](https://github.com/tldraw/tldraw/pull/10627))
|
||||
- Fix the enlarge and shrink shortcuts moving shapes away from the original selection's center when several shapes are selected. ([#10070](https://github.com/tldraw/tldraw/pull/10070))
|
||||
- Fix selecting arrows and hollow shapes larger than the viewport by clicking their edges. ([#10333](https://github.com/tldraw/tldraw/pull/10333))
|
||||
- Fix `Editor.animateShapes` moving locked shapes, and apply ancestor-lock semantics to delete, duplicate, group, and ungroup. ([#10334](https://github.com/tldraw/tldraw/pull/10334))
|
||||
- Fix `groupShapes` throwing for locked selections or orphaning shapes at the page limit, `ungroupShapes` deleting nested children, and both commands ignoring calls made outside the select tool. ([#10330](https://github.com/tldraw/tldraw/pull/10330))
|
||||
|
||||
### Camera and viewport
|
||||
|
||||
- Fix the camera not panning to a note created with Tab, Ctrl/Cmd+Enter, or a note's clone handle when the new note landed outside the viewport. ([#10486](https://github.com/tldraw/tldraw/pull/10486))
|
||||
- Fix camera coasting after a fling, and edge scrolling, running roughly twice as fast on a 120 Hz display as on a 60 Hz one. Both now scale by the elapsed tick time, so motion per second is the same at any refresh rate. ([#10501](https://github.com/tldraw/tldraw/pull/10501))
|
||||
- Fix a click landing during a camera animation or a fling being treated as a drag. ([#10714](https://github.com/tldraw/tldraw/pull/10714))
|
||||
- Fix `setCamera` resetting the zoom to 100% when called with a plain `{ x, y }` point. ([#10385](https://github.com/tldraw/tldraw/pull/10385))
|
||||
- Fix forced animated camera moves snapping back inside the camera constraints on their last frame. ([#10386](https://github.com/tldraw/tldraw/pull/10386))
|
||||
- Fix a viewport deep link with a zero-size viewport moving the camera to the page origin instead of falling back to fitting the page content. ([#10628](https://github.com/tldraw/tldraw/pull/10628))
|
||||
|
||||
### Arrows and shapes
|
||||
|
||||
- Fix arrow clusters in the stack, pack, and distribute commands depending on selection order, so an arrow selected before the shapes it binds no longer moves separately from them. ([#10382](https://github.com/tldraw/tldraw/pull/10382))
|
||||
- Fix arrow bindings flip-flopping while resizing a bound shape together with its arrow. ([#10197](https://github.com/tldraw/tldraw/pull/10197))
|
||||
- Fix rotated images jumping on the canvas when their aspect ratio or crop zoom changed, or when the image was replaced. ([#10233](https://github.com/tldraw/tldraw/pull/10233))
|
||||
- Fix image and video shapes keeping a removed asset on screen. ([#10250](https://github.com/tldraw/tldraw/pull/10250))
|
||||
- Fix line animations dropping points when the target line has more points than the starting one. ([#10247](https://github.com/tldraw/tldraw/pull/10247))
|
||||
- Fix highlight shape animations switching to the end shape's props immediately instead of interpolating from the nearer shape. ([#10246](https://github.com/tldraw/tldraw/pull/10246))
|
||||
- Fix draw shapes closing one update late when drawn with straight segments. ([#10242](https://github.com/tldraw/tldraw/pull/10242))
|
||||
- Fix the draw tool freezing the stroke after a quick Shift release during a straight segment. ([#10239](https://github.com/tldraw/tldraw/pull/10239))
|
||||
- Fix notes created inside a rotated frame appearing off-centre from the click. ([#10235](https://github.com/tldraw/tldraw/pull/10235))
|
||||
- Fix a bookmark keeping its old preview after its url changes. ([#10208](https://github.com/tldraw/tldraw/pull/10208))
|
||||
- Fix a frame's contents jumping when double-clicking a frame edge to fit it to its children. ([#10206](https://github.com/tldraw/tldraw/pull/10206))
|
||||
- Fix pasting Excalidraw drawings that use a custom stroke width. ([#10779](https://github.com/tldraw/tldraw/pull/10779))
|
||||
|
||||
### Editor
|
||||
|
||||
- Fix keyboard shortcuts being unable to bind the `+` key (`cmd++` was silently dropped and rendered as a bare modifier) and `shift+<glyph>` shortcuts such as `shift+:` never firing when the browser reports `:` with shift held. ([#10512](https://github.com/tldraw/tldraw/pull/10512))
|
||||
- Fix undo after switching pages from the page menu or with Alt+arrow also reverting the edit made before the switch. Switching pages is now its own undo step, matching the "Go back" button on the move-to-page toast. ([#10472](https://github.com/tldraw/tldraw/pull/10472))
|
||||
- Fix `TldrawEditor` recreating the editor when it re-renders with inline nested `camera`, `deepLinks`, `text`, or `gridSteps` options. ([#10373](https://github.com/tldraw/tldraw/pull/10373))
|
||||
- Fix preserve-focus mode blurring the editor on canvas clicks. ([#10374](https://github.com/tldraw/tldraw/pull/10374))
|
||||
- Fix double-clicking a custom shape that allows read-only editing doing nothing in a read-only editor. The gate now consults `ShapeUtil.canEditInReadonly` instead of a hard-coded list of shape types. ([#10173](https://github.com/tldraw/tldraw/pull/10173))
|
||||
- Fix binding and document-settings mutations going through in readonly mode, and index self-referential bindings once. ([#10340](https://github.com/tldraw/tldraw/pull/10340))
|
||||
- Fix selected text disappearing when a whole list is selected, such as by triple-clicking its bullet. ([#10808](https://github.com/tldraw/tldraw/pull/10808))
|
||||
- Fix text measurement for elements containing more than one text node. ([#10357](https://github.com/tldraw/tldraw/pull/10357))
|
||||
- Fix a license key that arrives after the first render being ignored, and stop watermark tracking failures surfacing as unhandled rejections. ([#10327](https://github.com/tldraw/tldraw/pull/10327))
|
||||
- Fix copied content dropping assets the asset store can't resolve. ([#10366](https://github.com/tldraw/tldraw/pull/10366))
|
||||
- Fix asset store removal errors in `deleteAssets` becoming unhandled rejections. They are now logged. ([#10368](https://github.com/tldraw/tldraw/pull/10368))
|
||||
- Fix geometry hit tests, nearest-point, and length calculations losing precision after a shape's SVG path data was generated. `getSvgPathData` rounded the geometry's stored points to two decimals in place, and geometries are cached per shape, so every later calculation used the rounded points. ([#10600](https://github.com/tldraw/tldraw/pull/10600))
|
||||
- Fix `Editor.getCurrentTheme()` returning `undefined` after `setCurrentTheme()` was called with an unregistered theme id. The call is now rejected and the current theme is kept. ([#10602](https://github.com/tldraw/tldraw/pull/10602))
|
||||
- Fix `Group2d.transform` dropping its ignored children, resetting the `isInternal`, `excludeFromShapeBounds`, and `isEmptyLabel` flags, and discarding its `opts` argument. It now matches the base `Geometry2d.transform` signature. ([#10603](https://github.com/tldraw/tldraw/pull/10603))
|
||||
- Fix embed hostname matching trusting lookalike hosts such as `tldraw.com.evil.io`, which were rendered with the matched definition's relaxed iframe permissions. A host must now be the pattern itself or a subdomain of it. ([#10196](https://github.com/tldraw/tldraw/pull/10196))
|
||||
|
||||
### Exports
|
||||
|
||||
- Fix exports resolving with a partial image when a shape's `toSvg` throws. Exports now fail fast, custom frame-like shapes export without the default frame util, and trimming an SVG export no longer allocates an oversized measurement canvas. ([#10342](https://github.com/tldraw/tldraw/pull/10342))
|
||||
- Fix exports missing resources and fonts: styles with several urls, font lists, and bare `@import` rules are now all embedded, failed fetches are retried, and stylesheets that import each other no longer hang the export. ([#10338](https://github.com/tldraw/tldraw/pull/10338))
|
||||
- Fix a failed animated-image or video export hanging every later export of the same asset, because the rejected promise was memoized. ([#10226](https://github.com/tldraw/tldraw/pull/10226))
|
||||
|
||||
### Store and signals
|
||||
|
||||
- Fix effects losing a dependency when a set during their first run re-triggered them. ([#10146](https://github.com/tldraw/tldraw/pull/10146))
|
||||
- Fix a side-effect handler being skipped when the previous handler for the same event removes itself while running. ([#10231](https://github.com/tldraw/tldraw/pull/10231))
|
||||
- Fix store changes made in the same frame as `dispose()` never reaching listeners. ([#10223](https://github.com/tldraw/tldraw/pull/10223))
|
||||
- Fix a throwing store listener stopping the other listeners from receiving the same change-set. ([#10216](https://github.com/tldraw/tldraw/pull/10216))
|
||||
- Fix `store.query.ids()` and `store.query.index()` staying stale after a query was read inside a transaction that then rolled back. ([#10200](https://github.com/tldraw/tldraw/pull/10200))
|
||||
|
||||
### Persistence and sync
|
||||
|
||||
- Fix local persistence silently dropping edits: changes another tab made while this one was still loading, edits queued in the last few hundred milliseconds before unmount, and writes still in flight when the database closed. ([#10332](https://github.com/tldraw/tldraw/pull/10332))
|
||||
- Fix sync stalling permanently when `getUri` rejected, which in the documented `useSync` pattern is an auth-token fetch. A rejected `getUri` is now retried on the normal backoff, and a closed `ClientWebSocketAdapter` no longer notifies listeners or reconnects after disposal. ([#10107](https://github.com/tldraw/tldraw/pull/10107))
|
||||
- Fix `useSync` still reporting a previous room's error after the `uri` changed to a new, healthy room. ([#10108](https://github.com/tldraw/tldraw/pull/10108))
|
||||
- Fix oversized chunked sync messages being buffered indefinitely. Assembly is now bounded by a size and chunk-count limit, and a message that exceeds it fails with a `MESSAGE_TOO_LARGE` sync error instead of disconnecting the client in a loop. ([#10810](https://github.com/tldraw/tldraw/pull/10810))
|
||||
- Fix `TLSocketRoom.closeSession` leaving the socket open when the close reason is over 123 bytes. The reason is now truncated and ends with `... (+N bytes)`. ([#10116](https://github.com/tldraw/tldraw/pull/10116))
|
||||
- Fix `TLSocketRoom.updateStore` not seeing or deleting object-lane records. ([#10114](https://github.com/tldraw/tldraw/pull/10114))
|
||||
- Fix authorizer errors and vetoes being swallowed when `TLSocketRoom` was created without a `log` option. ([#10117](https://github.com/tldraw/tldraw/pull/10117))
|
||||
- Fix text arriving corrupted when a message chunk boundary split a surrogate pair, such as an emoji. ([#10119](https://github.com/tldraw/tldraw/pull/10119))
|
||||
- Fix `TLSyncRoom.close()` leaving per-session timers that could send into closed sockets or emit `session_removed` and `room_became_empty` on a closed room. A socket that throws on close no longer leaves the rest open. ([#10120](https://github.com/tldraw/tldraw/pull/10120))
|
||||
- Fix `SQLiteSyncStorage` tombstone pruning throwing from a timer after the host closed the database, and make the prune transactional so a failure part-way cannot leave the history start ahead of the surviving tombstones. ([#10112](https://github.com/tldraw/tldraw/pull/10112))
|
||||
|
||||
### Mermaid conversion
|
||||
|
||||
- Fix converted sequence diagrams crowding tall notes, fragment titles, and participants that are created and destroyed close together. Rows are now placed where mermaid drew them rather than on an even grid. ([#10817](https://github.com/tldraw/tldraw/pull/10817))
|
||||
- Fix sequence diagram participant boxes being dropped, along with their labels and colors, on conversion. ([#10813](https://github.com/tldraw/tldraw/pull/10813))
|
||||
- Fix sequence diagrams dropping a participant's bottom box, and tilting their arrows, when the source uses `destroy`. ([#10750](https://github.com/tldraw/tldraw/pull/10750))
|
||||
- Fix self-loops in flowcharts and state diagrams drawing their label on top of the node. ([#10773](https://github.com/tldraw/tldraw/pull/10773))
|
||||
- Fix multi-line notes in sequence diagrams rendering far wider than their text needs, and labels breaking mid-word in diagrams that set their own font size. ([#10753](https://github.com/tldraw/tldraw/pull/10753))
|
||||
- Fix the `autonumber` options (`start`, `step`, `off`) being ignored in sequence diagrams. ([#10079](https://github.com/tldraw/tldraw/pull/10079))
|
||||
|
||||
### CLI
|
||||
|
||||
- Fix `create-tldraw --no-telemetry` being ignored. ([#10789](https://github.com/tldraw/tldraw/pull/10789))
|
||||
- Fix `create-tldraw -t <template>` crashing on startup. ([#10657](https://github.com/tldraw/tldraw/pull/10657))
|
||||
179
apps/docs/content/releases/v2.0.0.mdx
Normal file
179
apps/docs/content/releases/v2.0.0.mdx
Normal file
@@ -0,0 +1,179 @@
|
||||
---
|
||||
title: 'v2.0.0'
|
||||
created_at: 02/29/2024
|
||||
updated_at: 02/29/2024
|
||||
keywords:
|
||||
- changelog
|
||||
- release
|
||||
- v2.0
|
||||
- v2.0.0
|
||||
- v2.0.1
|
||||
- v2.0.2
|
||||
- initial-release
|
||||
- sdk
|
||||
- editor
|
||||
- shapes
|
||||
- tools
|
||||
status: published
|
||||
---
|
||||
|
||||
This is the initial public release of the tldraw SDK—a TypeScript library for creating infinite canvas experiences in React. After an extensive alpha and beta period, the SDK is ready for production use.
|
||||
|
||||
## What's new
|
||||
|
||||
### The editor
|
||||
|
||||
At the heart of tldraw is the `Editor` class, a single object that manages all editor state and provides the API for programmatic control. The editor maintains document state through a reactive store system containing JSON-serializable records.
|
||||
|
||||
```tsx
|
||||
import { Tldraw } from 'tldraw'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Tldraw
|
||||
onMount={(editor) => {
|
||||
// Full programmatic control
|
||||
editor.createShapes([{ type: 'geo', x: 100, y: 100 }])
|
||||
editor.selectAll()
|
||||
editor.zoomToFit()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Default shapes
|
||||
|
||||
The SDK ships with a complete set of default shapes:
|
||||
|
||||
- **Geo shapes**: Rectangle, ellipse, triangle, diamond, pentagon, hexagon, octagon, star, rhombus, oval, trapezoid, arrow-shapes, cloud, and x-box
|
||||
- **Draw**: Freehand drawing with pressure sensitivity support
|
||||
- **Arrow**: Connectable arrows with labels, multiple arrowhead styles, and curved/straight/elbow routing
|
||||
- **Text**: Rich text with font, size, and alignment options
|
||||
- **Note**: Sticky notes with customizable colors
|
||||
- **Image & Video**: Media embedding with cropping support
|
||||
- **Frame**: Grouping containers that clip their contents
|
||||
- **Line**: Multi-point lines with various spline options
|
||||
- **Highlight**: Transparent highlight strokes
|
||||
- **Embed**: Web content embedding (YouTube, Figma, Google Maps, and more)
|
||||
- **Bookmark**: URL previews with metadata
|
||||
|
||||
### Default tools
|
||||
|
||||
A full set of tools for interacting with the canvas:
|
||||
|
||||
- **Select**: Selection, multi-select, resize, rotate, and transform shapes
|
||||
- **Hand**: Pan the canvas
|
||||
- **Draw**: Freehand drawing
|
||||
- **Eraser**: Remove shapes by drawing over them
|
||||
- **Arrow, Line, Geo, Text, Note, Frame, Highlight**: Shape creation tools
|
||||
- **Laser**: Temporary pointer for presentations
|
||||
- **Zoom**: Zoom in/out with click or marquee
|
||||
|
||||
### State management
|
||||
|
||||
The SDK uses a reactive signals system for state management. All editor state is observable, with automatic dependency tracking to prevent unnecessary re-renders.
|
||||
|
||||
```tsx
|
||||
import { track, useEditor } from 'tldraw'
|
||||
|
||||
const SelectedShapeCount = track(() => {
|
||||
const editor = useEditor()
|
||||
return <div>{editor.getSelectedShapeIds().length} selected</div>
|
||||
})
|
||||
```
|
||||
|
||||
### The store
|
||||
|
||||
Document data lives in a `TLStore`—a reactive client-side database that supports:
|
||||
|
||||
- JSON-serializable records for shapes, pages, assets, and instance state
|
||||
- Reactive updates via signals
|
||||
- Snapshot export/import for persistence
|
||||
- Schema migrations for backwards compatibility
|
||||
|
||||
### Tool system
|
||||
|
||||
Tools are implemented as hierarchical state machines using `StateNode`. Events flow through a state chart, enabling complex multi-step interactions:
|
||||
|
||||
```tsx
|
||||
class MyTool extends StateNode {
|
||||
static id = 'my-tool'
|
||||
|
||||
onPointerDown(info) {
|
||||
const point = this.editor.inputs.currentPagePoint
|
||||
this.editor.createShape({ type: 'geo', x: point.x, y: point.y })
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Shape system
|
||||
|
||||
Every shape type has a `ShapeUtil` class defining its behavior—geometry, rendering, interactions, and more. Create custom shapes by extending `ShapeUtil`:
|
||||
|
||||
```tsx
|
||||
class CardShapeUtil extends ShapeUtil<CardShape> {
|
||||
static type = 'card'
|
||||
|
||||
getGeometry(shape) {
|
||||
return new Rectangle2d({ width: shape.props.w, height: shape.props.h })
|
||||
}
|
||||
|
||||
component(shape) {
|
||||
return <div className="card">{shape.props.title}</div>
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### User interface
|
||||
|
||||
A complete, responsive UI system with:
|
||||
|
||||
- Toolbar with tool selection and shape-specific options
|
||||
- Style panel for colors, fills, fonts, and more
|
||||
- Context menus with shape actions
|
||||
- Keyboard shortcuts dialog
|
||||
- Page management
|
||||
- Zoom controls and minimap
|
||||
- Actions menu and quick actions bar
|
||||
- Help menu and debug panel
|
||||
|
||||
All UI components can be overridden or extended via the `components` prop.
|
||||
|
||||
### Features
|
||||
|
||||
- **Multi-page documents**: Create and manage multiple pages per document
|
||||
- **Undo/redo**: Full history management with marks and batching
|
||||
- **Copy/paste**: Between pages, documents, and external applications
|
||||
- **Export**: SVG, PNG, and JSON export
|
||||
- **Zoom & pan**: Pinch, scroll, keyboard shortcuts, and programmatic control
|
||||
- **Grid & snapping**: Align shapes to grid and each other
|
||||
- **Grouping**: Group shapes together
|
||||
- **Locking**: Prevent shape modification
|
||||
- **Duplication**: Alt-drag to duplicate
|
||||
- **Alignment & distribution**: Align and distribute selected shapes
|
||||
- **Flip**: Horizontal and vertical flipping
|
||||
- **Styles**: Color, fill, dash, size, font, and more
|
||||
- **Dark mode**: Full dark mode support
|
||||
- **Localization**: 30+ languages supported
|
||||
- **Accessibility**: Keyboard navigation and screen reader support
|
||||
- **Touch support**: Full touch and pen input support
|
||||
- **Pressure sensitivity**: For supported devices
|
||||
|
||||
[View release on GitHub](https://github.com/tldraw/tldraw/releases/tag/v2.0.0)
|
||||
|
||||
---
|
||||
|
||||
## Patch releases
|
||||
|
||||
### v2.0.1
|
||||
|
||||
- Avoid randomness at init time, improving consistency for SSR and testing scenarios. ([#3076](https://github.com/tldraw/tldraw/pull/3076))
|
||||
|
||||
[View release on GitHub](https://github.com/tldraw/tldraw/releases/tag/v2.0.1)
|
||||
|
||||
### v2.0.2
|
||||
|
||||
- Fix JPG export functionality. ([#3199](https://github.com/tldraw/tldraw/pull/3199))
|
||||
|
||||
[View release on GitHub](https://github.com/tldraw/tldraw/releases/tag/v2.0.2)
|
||||
118
apps/docs/content/releases/v2.1.0.mdx
Normal file
118
apps/docs/content/releases/v2.1.0.mdx
Normal file
@@ -0,0 +1,118 @@
|
||||
---
|
||||
title: 'v2.1.0'
|
||||
status: published
|
||||
created_at: 4/23/2024
|
||||
updated_at: 4/23/2024
|
||||
keywords:
|
||||
- changelog
|
||||
- release
|
||||
- v2.1
|
||||
- v2.1.0
|
||||
- v2.1.1
|
||||
- v2.1.2
|
||||
- v2.1.3
|
||||
- v2.1.4
|
||||
- migrations
|
||||
- sticky-notes
|
||||
- performance
|
||||
- svg-exports
|
||||
- input-buffering
|
||||
---
|
||||
|
||||
This release includes completely redesigned sticky notes, an overhaul to our migration system, and significant performance improvements.
|
||||
|
||||
## What's new
|
||||
|
||||
### New stickies ([#3249](https://github.com/tldraw/tldraw/pull/3249))
|
||||
|
||||
Sticky notes have been completely redesigned with a fresh look and improved functionality.
|
||||
|
||||
### 💥 New migrations ([#3220](https://github.com/tldraw/tldraw/pull/3220))
|
||||
|
||||
The migrations system has been overhauled for better reliability and developer experience.
|
||||
|
||||
- The `Migrations` type is now called `LegacyMigrations`
|
||||
- The serialized schema format (returned by `StoreSchema.serialize()` and `Store.getSnapshot()`) has changed
|
||||
- `compareRecordVersions` and `RecordVersion` have been removed
|
||||
- `compareSchemas` is gone—use `schema.getMigrationsSince(prevSchema)` instead
|
||||
- `defineMigrations` has been deprecated. See [updating legacy shape migrations](https://tldraw.dev/docs/persistence#Updating-legacy-shape-migrations-defineMigrations) for upgrade instructions
|
||||
- `migrate` has been removed
|
||||
|
||||
### 💥 Input buffering ([#3223](https://github.com/tldraw/tldraw/pull/3223))
|
||||
|
||||
Events are now buffered and sent to state nodes every tick, instead of immediately. This unlocks big performance improvements but could introduce subtle issues with custom tools—make sure to test any custom tools thoroughly.
|
||||
|
||||
### 💥 React-powered SVG exports ([#3117](https://github.com/tldraw/tldraw/pull/3117))
|
||||
|
||||
If any of your shapes implement `toSvg` for exports, you'll need to replace your implementation with a new version that returns JSX instead of manually constructing SVG DOM nodes.
|
||||
|
||||
`editor.getSvg` is deprecated. Use `editor.getSvgElement` or `editor.getSvgString` instead.
|
||||
|
||||
### 💥 Component-based toolbar customization ([#3067](https://github.com/tldraw/tldraw/pull/3067))
|
||||
|
||||
If you're using the `toolbar` callback to override toolbar items, switch to using the new `Toolbar` component override. See the [toolbar groups example](https://tldraw.dev/examples/ui/toolbar-groups) for details.
|
||||
|
||||
## API changes
|
||||
|
||||
- 💥 **`getRenderingShapes`** no longer returns `isCulled`—use `getCulledShapes` instead.
|
||||
- Add `Editor.getStyleForNextShape`. ([#3039](https://github.com/tldraw/tldraw/pull/3039))
|
||||
- Add `usePreloadAssets` export. ([#3545](https://github.com/tldraw/tldraw/pull/3545))
|
||||
|
||||
## Improvements
|
||||
|
||||
- Improve color contrast. ([#3486](https://github.com/tldraw/tldraw/pull/3486))
|
||||
- Add long press event. ([#3275](https://github.com/tldraw/tldraw/pull/3275))
|
||||
- Add severity colors and icons to toasts. ([#2988](https://github.com/tldraw/tldraw/pull/2988))
|
||||
- Improve handling of broken images/videos. ([#2990](https://github.com/tldraw/tldraw/pull/2990))
|
||||
- Improve performance of draw shapes. ([#3464](https://github.com/tldraw/tldraw/pull/3464))
|
||||
- Use WebGL to draw the minimap. ([#3510](https://github.com/tldraw/tldraw/pull/3510))
|
||||
- Improve reactivity bookkeeping performance. ([#3471](https://github.com/tldraw/tldraw/pull/3471)) ([#3487](https://github.com/tldraw/tldraw/pull/3487))
|
||||
- Improve selection/erasing performance. ([#3454](https://github.com/tldraw/tldraw/pull/3454))
|
||||
- Improve performance of text shapes on iOS/Safari. ([#3429](https://github.com/tldraw/tldraw/pull/3429))
|
||||
- Reduce rendered DOM nodes for geo shapes and arrows. ([#3283](https://github.com/tldraw/tldraw/pull/3283))
|
||||
- Improve shape rendering performance. ([#3176](https://github.com/tldraw/tldraw/pull/3176))
|
||||
|
||||
## Bug fixes
|
||||
|
||||
- Fix copy error sound in Safari. ([#3536](https://github.com/tldraw/tldraw/pull/3536))
|
||||
- Fix arrow label positioning overlapping bound shapes. ([#3512](https://github.com/tldraw/tldraw/pull/3512))
|
||||
- Fix cursor chat button appearing when not in select tool. ([#3485](https://github.com/tldraw/tldraw/pull/3485))
|
||||
- Fix alt-duplicating shapes sometimes not working. ([#3488](https://github.com/tldraw/tldraw/pull/3488))
|
||||
- Fix camera sliding after pinch. ([#3462](https://github.com/tldraw/tldraw/pull/3462))
|
||||
- Fix text shapes overflowing their bounds when resized. ([#3327](https://github.com/tldraw/tldraw/pull/3327))
|
||||
- Fix incorrectly rotated handles on rotated cropping images. ([#3093](https://github.com/tldraw/tldraw/pull/3093))
|
||||
- Fix videos not being sized correctly. ([#3047](https://github.com/tldraw/tldraw/pull/3047))
|
||||
- Fix `localStorage` crash in React Native webviews. ([#3043](https://github.com/tldraw/tldraw/pull/3043))
|
||||
|
||||
[View release on GitHub](https://github.com/tldraw/tldraw/releases/tag/v2.1.0)
|
||||
|
||||
---
|
||||
|
||||
## Patch releases
|
||||
|
||||
### v2.1.1
|
||||
|
||||
- Fix missing export for `createShapePropsMigrationIds`, part of the new migrations API. ([#3594](https://github.com/tldraw/tldraw/pull/3594))
|
||||
- Add exports for `defaultEditorAssetUrls`, `PORTRAIT_BREAKPOINT`, `useDefaultColorTheme`, and `getPerfectDashProps`. ([#3594](https://github.com/tldraw/tldraw/pull/3594))
|
||||
|
||||
[View release on GitHub](https://github.com/tldraw/tldraw/releases/tag/v2.1.1)
|
||||
|
||||
### v2.1.2
|
||||
|
||||
- Revert a performance optimization that caused computed caches to sometimes not invalidate correctly, leading to stale data and crashes. ([#3611](https://github.com/tldraw/tldraw/pull/3611))
|
||||
|
||||
[View release on GitHub](https://github.com/tldraw/tldraw/releases/tag/v2.1.2)
|
||||
|
||||
### v2.1.3
|
||||
|
||||
- Expose migrations, validators, and versions from tlschema. ([#3613](https://github.com/tldraw/tldraw/pull/3613))
|
||||
- Add `defaultShapeSchemas` which can be passed directly to `createTLSchema`. ([#3613](https://github.com/tldraw/tldraw/pull/3613))
|
||||
|
||||
[View release on GitHub](https://github.com/tldraw/tldraw/releases/tag/v2.1.3)
|
||||
|
||||
### v2.1.4
|
||||
|
||||
- Fix text selection flakiness when clicking into text shapes. ([#3643](https://github.com/tldraw/tldraw/pull/3643))
|
||||
- Fix edit→edit not working correctly when unfilled geo shapes are on top of other shapes. ([#3643](https://github.com/tldraw/tldraw/pull/3643))
|
||||
|
||||
[View release on GitHub](https://github.com/tldraw/tldraw/releases/tag/v2.1.4)
|
||||
127
apps/docs/content/releases/v2.2.0.mdx
Normal file
127
apps/docs/content/releases/v2.2.0.mdx
Normal file
@@ -0,0 +1,127 @@
|
||||
---
|
||||
title: 'v2.2.0'
|
||||
created_at: 6/11/2024
|
||||
updated_at: 6/11/2024
|
||||
keywords:
|
||||
- changelog
|
||||
- release
|
||||
- v2.2
|
||||
- v2.2.0
|
||||
- v2.2.1
|
||||
- v2.2.2
|
||||
- v2.2.3
|
||||
- v2.2.4
|
||||
- bindings
|
||||
- camera-constraints
|
||||
- configurable-options
|
||||
- history
|
||||
status: published
|
||||
---
|
||||
|
||||
This release introduces the bindings system, camera constraints, and configurable options—major features that unlock new types of applications you can build with tldraw.
|
||||
|
||||
## What's new
|
||||
|
||||
### Bindings ([#3326](https://github.com/tldraw/tldraw/pull/3326))
|
||||
|
||||
Bindings allow you to create relationships between shapes. Our default arrow shapes are a great example: each end of the arrow can bind to another shape. When that shape moves, so does the arrow.
|
||||
|
||||
Before this change, arrows were hard-coded into the library. Now, with the bindings system, you can create arrows, constraint systems, visual programming environments, and much more.
|
||||
|
||||
Check out the [bindings guide](https://tldraw.dev/docs/editor#Bindings) for more information. ([#3780](https://github.com/tldraw/tldraw/pull/3780)) ([#3797](https://github.com/tldraw/tldraw/pull/3797)) ([#3800](https://github.com/tldraw/tldraw/pull/3800)) ([#3871](https://github.com/tldraw/tldraw/pull/3871))
|
||||
|
||||
### Camera constraints ([#3282](https://github.com/tldraw/tldraw/pull/3282))
|
||||
|
||||
You can now limit the camera to a fixed area of the canvas. This is useful for creating experiences that don't quite fit the "infinite canvas" paradigm: document annotators, image editors, slideshow creators, and more.
|
||||
|
||||
See the [camera constraints guide](https://tldraw.dev/docs/editor#Camera-options) for more information. ([#3747](https://github.com/tldraw/tldraw/pull/3747)) ([#3814](https://github.com/tldraw/tldraw/pull/3814))
|
||||
|
||||
### Configurable options ([#3799](https://github.com/tldraw/tldraw/pull/3799))
|
||||
|
||||
You can now override many options which were previously hard-coded constants. Pass an `options` prop into the tldraw component to change the maximum number of pages, grid steps, or other previously hard-coded values.
|
||||
|
||||
See [`TldrawOptions`](https://tldraw.dev/reference/editor/TldrawOptions) for details. ([#3900](https://github.com/tldraw/tldraw/pull/3900))
|
||||
|
||||
### 💥 Undo/redo changes
|
||||
|
||||
The history system has been significantly reworked:
|
||||
|
||||
1. **History options simplified**: `squashing`, `ephemeral`, and `preserveRedoStack` flags have been consolidated. Use `editor.history.ignore(fn)` or `editor.history.batch(fn, {history: 'record-preserveRedoStack'})` instead.
|
||||
|
||||
2. **Automatic recording**: Everything that touches the store is now recorded in undo/redo (unless part of `mergeRemoteChanges`). Use `editor.history.ignore(fn)` for changes you don't want recorded.
|
||||
|
||||
3. **Side effects captured**: Changes in side-effects are now captured by undo/redo. Wrap in `editor.history.ignore` for the old behavior.
|
||||
|
||||
## API changes
|
||||
|
||||
- 💥 **`canBind`** now accepts an options object instead of just the shape. See [`TLShapeUtilCanBindOpts`](https://tldraw.dev/reference/editor/TLShapeUtilCanBindOpts).
|
||||
- 💥 **`editor.sideEffects.registerBatchCompleteHandler`** replaced with `editor.sideEffects.registerOperationCompleteHandler`. ([#3748](https://github.com/tldraw/tldraw/pull/3748))
|
||||
- 💥 **`editor.getArrowInfo(shape)`** replaced with `getArrowInfo(editor, shape)`.
|
||||
- 💥 **`editor.getArrowsBoundTo(shape)`** removed—use `editor.getBindingsToShape(shape, 'arrow')` instead.
|
||||
- 💥 **Arrow shape `start` and `end` properties** are now always points. Use `getArrowBindings(editor, shape)` to check for bindings.
|
||||
- 💥 **Types renamed**: `ShapeProps` → `RecordProps`, `ShapePropsType` → `RecordPropsType`, `TLShapePropsMigrations` → `TLPropsMigrations`, `SchemaShapeInfo` → `SchemaPropsInfo`.
|
||||
- Add `editor.blur` method. ([#3875](https://github.com/tldraw/tldraw/pull/3875))
|
||||
- Add better defaults for `createTLStore`. ([#3886](https://github.com/tldraw/tldraw/pull/3886))
|
||||
- Add `getSnapshot` and `loadSnapshot` for easier document persistence. See [State Snapshots](https://tldraw.dev/docs/persistence#State-Snapshots). ([#3811](https://github.com/tldraw/tldraw/pull/3811))
|
||||
- Add `select` option to `Editor.groupShapes` and `Editor.ungroupShapes`. ([#3690](https://github.com/tldraw/tldraw/pull/3690))
|
||||
- `InFrontOfTheCanvas` now has access to the editor's UI context. ([#3782](https://github.com/tldraw/tldraw/pull/3782))
|
||||
- `useEditor` and other context-based hooks now throw an error when used out-of-context. ([#3750](https://github.com/tldraw/tldraw/pull/3750))
|
||||
- Add `defaultShapeSchemas` for custom multiplayer implementations. ([#3613](https://github.com/tldraw/tldraw/pull/3613))
|
||||
|
||||
## Improvements
|
||||
|
||||
- Add a heart shape to the geo shape set. ([#3787](https://github.com/tldraw/tldraw/pull/3787))
|
||||
- Improve rendering for bookmarks without preview images. ([#3856](https://github.com/tldraw/tldraw/pull/3856))
|
||||
- Improve undo/redo UX around image cropping. ([#3891](https://github.com/tldraw/tldraw/pull/3891))
|
||||
- Increase default limit of shapes per page from 2000 to 4000. ([#3716](https://github.com/tldraw/tldraw/pull/3716))
|
||||
- Expand accepted image types to include webp, webm, apng, and avif. ([#3730](https://github.com/tldraw/tldraw/pull/3730))
|
||||
- Prune unused assets when loading `.tldr` files. ([#3689](https://github.com/tldraw/tldraw/pull/3689))
|
||||
- Improve handling of mouse-type devices that support pressure (e.g., Wacom tablets). ([#3639](https://github.com/tldraw/tldraw/pull/3639))
|
||||
- Change text shapes to be left-aligned by default. ([#3627](https://github.com/tldraw/tldraw/pull/3627))
|
||||
- Add Desmos graph embed type. ([#3608](https://github.com/tldraw/tldraw/pull/3608))
|
||||
|
||||
## Bug fixes
|
||||
|
||||
- Fix 'insert media' undo removing other changes. ([#3910](https://github.com/tldraw/tldraw/pull/3910))
|
||||
- Fix referrer being sent for bookmarks and images. ([#3881](https://github.com/tldraw/tldraw/pull/3881))
|
||||
- Fix minimum drag distance being wrong when zoomed. ([#3873](https://github.com/tldraw/tldraw/pull/3873))
|
||||
- Fix cropped images not exporting properly. ([#3837](https://github.com/tldraw/tldraw/pull/3837))
|
||||
- Fix spacebar and middle mouse button panning. ([#3791](https://github.com/tldraw/tldraw/pull/3791)) ([#3792](https://github.com/tldraw/tldraw/pull/3792))
|
||||
- Fix cursor and shapes wiggling when following someone's viewport. ([#3695](https://github.com/tldraw/tldraw/pull/3695))
|
||||
- Fix cross-browser focus management issues. ([#3718](https://github.com/tldraw/tldraw/pull/3718))
|
||||
- Fix imports in Astro. ([#3742](https://github.com/tldraw/tldraw/pull/3742))
|
||||
- Fix copy/paste for patterned shapes as SVG/PNG. ([#3708](https://github.com/tldraw/tldraw/pull/3708))
|
||||
- Fix RTL text layout for SVG exports. ([#3680](https://github.com/tldraw/tldraw/pull/3680))
|
||||
- Fix clicking on minimap sometimes not changing viewport. ([#3617](https://github.com/tldraw/tldraw/pull/3617))
|
||||
|
||||
[View release on GitHub](https://github.com/tldraw/tldraw/releases/tag/v2.2.0)
|
||||
|
||||
---
|
||||
|
||||
## Patch releases
|
||||
|
||||
### v2.2.1
|
||||
|
||||
- Improve documentation and make `ArrowBindingUtil` public. ([#3958](https://github.com/tldraw/tldraw/pull/3958))
|
||||
- Add `editor.getSnapshot()` and `editor.loadSnapshot()` methods. ([#3958](https://github.com/tldraw/tldraw/pull/3958))
|
||||
- Fix CSS styling for bookmark elements. ([#3958](https://github.com/tldraw/tldraw/pull/3958))
|
||||
|
||||
[View release on GitHub](https://github.com/tldraw/tldraw/releases/tag/v2.2.1)
|
||||
|
||||
### v2.2.2
|
||||
|
||||
- Fix text measurement breaking when `Tldraw` was used with React strict mode. ([#4004](https://github.com/tldraw/tldraw/pull/4004))
|
||||
|
||||
[View release on GitHub](https://github.com/tldraw/tldraw/releases/tag/v2.2.2)
|
||||
|
||||
### v2.2.3
|
||||
|
||||
- Fix copy/paste functionality in Firefox 127+. ([#4006](https://github.com/tldraw/tldraw/pull/4006))
|
||||
|
||||
[View release on GitHub](https://github.com/tldraw/tldraw/releases/tag/v2.2.3)
|
||||
|
||||
### v2.2.4
|
||||
|
||||
- Fix copy/paste functionality for older versions of Firefox. ([#4011](https://github.com/tldraw/tldraw/pull/4011))
|
||||
|
||||
[View release on GitHub](https://github.com/tldraw/tldraw/releases/tag/v2.2.4)
|
||||
67
apps/docs/content/releases/v2.3.0.mdx
Normal file
67
apps/docs/content/releases/v2.3.0.mdx
Normal file
@@ -0,0 +1,67 @@
|
||||
---
|
||||
title: 'v2.3.0'
|
||||
created_at: 6/25/2024
|
||||
updated_at: 6/25/2024
|
||||
keywords:
|
||||
- changelog
|
||||
- release
|
||||
- v2.3
|
||||
- v2.3.0
|
||||
- indexeddb
|
||||
- cdn
|
||||
- image-performance
|
||||
- flatten
|
||||
- security
|
||||
status: published
|
||||
---
|
||||
|
||||
This release focuses on image and video storage improvements, a new fonts/icons CDN, image performance optimizations, and a new flatten feature.
|
||||
|
||||
## What's new
|
||||
|
||||
### Image and video storage improvements ([#3836](https://github.com/tldraw/tldraw/pull/3836))
|
||||
|
||||
When using local-only storage, images and videos are now stored in a separate IndexedDB table instead of as base64-encoded blobs in the room's JSON. This significantly improves performance and reduces the size of room data.
|
||||
|
||||
### Fonts and icons CDN ([#3923](https://github.com/tldraw/tldraw/pull/3923))
|
||||
|
||||
Improved our CDN for assets (fonts and icons). We were using unpkg before, but now we're using Cloudflare directly for more fine-grained control and better performance. ([#3927](https://github.com/tldraw/tldraw/pull/3927))
|
||||
|
||||
### Image level-of-detail ([#3827](https://github.com/tldraw/tldraw/pull/3827))
|
||||
|
||||
For higher-resolution images (over a couple megabytes), the image gets transformed to an appropriate size depending on your viewport, zoom level, and network speed. This helps with bandwidth and browser memory usage when you have multiple high-res images on the board.
|
||||
|
||||
### Flatten ([#3933](https://github.com/tldraw/tldraw/pull/3933))
|
||||
|
||||
You can now select multiple shapes and flatten them into a single image. For those moments when you need to press things together.
|
||||
|
||||
### Security improvements ([#3884](https://github.com/tldraw/tldraw/pull/3884))
|
||||
|
||||
Locked down referrer network requests to make sure we weren't leaking tldraw rooms to external media/iframe requests. ([#3881](https://github.com/tldraw/tldraw/pull/3881))
|
||||
|
||||
## API changes
|
||||
|
||||
- Add fill-fill style, accessible via `Alt`+`F`. ([#3966](https://github.com/tldraw/tldraw/pull/3966))
|
||||
- Make `ArrowBindingUtil` public. ([#3913](https://github.com/tldraw/tldraw/pull/3913))
|
||||
- Add `getSnapshot` and `loadSnapshot` methods to `Editor` class. ([#3912](https://github.com/tldraw/tldraw/pull/3912))
|
||||
|
||||
## Improvements
|
||||
|
||||
- Allow users to use system appearance (dark/light) mode. ([#3703](https://github.com/tldraw/tldraw/pull/3703))
|
||||
- Remove ability to rename document while in readonly mode. ([#3911](https://github.com/tldraw/tldraw/pull/3911))
|
||||
- Add delay and easing to edge scrolling. ([#3950](https://github.com/tldraw/tldraw/pull/3950))
|
||||
|
||||
## Bug fixes
|
||||
|
||||
- Fix copy/paste for older versions of Firefox. ([#4008](https://github.com/tldraw/tldraw/pull/4008))
|
||||
- Fix copy/paste in Firefox 127+. ([#4003](https://github.com/tldraw/tldraw/pull/4003))
|
||||
- Fix not allowing editing locked shapes when switching edit modes. ([#4007](https://github.com/tldraw/tldraw/pull/4007))
|
||||
- Fix text shape measurement in React strict mode. ([#4001](https://github.com/tldraw/tldraw/pull/4001))
|
||||
- Fix border color for following user. ([#3975](https://github.com/tldraw/tldraw/pull/3975))
|
||||
- Fix scale issue with new draw lines. ([#3971](https://github.com/tldraw/tldraw/pull/3971))
|
||||
- Fix edge scrolling at odd browser zoom levels. ([#3973](https://github.com/tldraw/tldraw/pull/3973))
|
||||
- Fix appearance of solid-style heart shapes. ([#3963](https://github.com/tldraw/tldraw/pull/3963))
|
||||
- Fix position of multiple assets when pasted/dropped onto canvas. ([#3965](https://github.com/tldraw/tldraw/pull/3965))
|
||||
- Fix indicator for stylus-drawn draw shapes. ([#3962](https://github.com/tldraw/tldraw/pull/3962))
|
||||
|
||||
[View release on GitHub](https://github.com/tldraw/tldraw/releases/tag/v2.3.0)
|
||||
149
apps/docs/content/releases/v2.4.0.mdx
Normal file
149
apps/docs/content/releases/v2.4.0.mdx
Normal file
@@ -0,0 +1,149 @@
|
||||
---
|
||||
title: 'v2.4.0'
|
||||
created_at: 7/22/2024
|
||||
updated_at: 7/22/2024
|
||||
keywords:
|
||||
- changelog
|
||||
- release
|
||||
- v2.4
|
||||
- v2.4.0
|
||||
- v2.4.1
|
||||
- v2.4.2
|
||||
- v2.4.3
|
||||
- v2.4.4
|
||||
- v2.4.5
|
||||
- v2.4.6
|
||||
- sync
|
||||
- interpolation
|
||||
- animation
|
||||
- assets
|
||||
- editor-run
|
||||
status: published
|
||||
---
|
||||
|
||||
This release introduces our sync engine as a general-use library, new animation options for shapes, support for image flipping, and many bug fixes and developer experience enhancements.
|
||||
|
||||
## What's new
|
||||
|
||||
### Sync ([#4031](https://github.com/tldraw/tldraw/pull/4031))
|
||||
|
||||
For the first time, we're releasing our real-time collaboration engine—the one we developed for tldraw.com—as a general library. The SDK still supports any backend for collaboration, but we hope this will be the easiest option for developers. See the new `sync` package in the repository and the multiplayer-demo example.
|
||||
|
||||
### Interpolation ([#4162](https://github.com/tldraw/tldraw/pull/4162))
|
||||
|
||||
You can use `Editor.animateShapes` to animate shapes on the canvas. Previously only position and rotation would animate. Now you can implement `ShapeUtil.getInterpolatedProps` to describe how your custom shape's other properties should animate.
|
||||
|
||||
```ts
|
||||
override getInterpolatedProps(
|
||||
startShape: Shape,
|
||||
endShape: Shape,
|
||||
t: number
|
||||
): Shape['props'] {
|
||||
return {
|
||||
...endShape.props,
|
||||
w: lerp(startShape.props.w, endShape.props.w, t),
|
||||
h: lerp(startShape.props.h, endShape.props.h, t),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Editor.run ([#4042](https://github.com/tldraw/tldraw/pull/4042))
|
||||
|
||||
The new `Editor.run` method runs a function within additional context. By default, the function runs inside a transaction, meaning all changes settle at once—improving performance and avoiding unnecessary renders. You can also use options to ignore history or ignore locked shapes.
|
||||
|
||||
```ts
|
||||
editor.run(
|
||||
() => {
|
||||
editor.createShapes(myShapes)
|
||||
editor.sendToBack(myShapes)
|
||||
editor.selectNone()
|
||||
},
|
||||
{ history: 'ignore' }
|
||||
)
|
||||
```
|
||||
|
||||
### Assets
|
||||
|
||||
As part of our work on sync, we have a new system for handling large assets like images and videos. Provide a [`TLAssetStore`](https://tldraw.dev/reference/tlschema/TLAssetStore) to control how assets are uploaded and retrieved. Use [`Editor.uploadAsset`](https://tldraw.dev/reference/editor/Editor#uploadAsset) and [`Editor.resolveAssetURL`](https://tldraw.dev/reference/editor/Editor#resolveAssetUrl) in your shapes and tools.
|
||||
|
||||
## API changes
|
||||
|
||||
- 💥 **`editor.history.ignore(cb)`** replaced with `editor.run(cb, {history: 'ignore'})`.
|
||||
- 💥 **`editor.batch`** deprecated, replaced with `editor.run`.
|
||||
- 💥 **`@tldraw/state` imports**: If you're importing from `@tldraw/state` directly, the `track` function and all hooks (e.g. `useValue`) have moved to `@tldraw/state-react`. ([#4170](https://github.com/tldraw/tldraw/pull/4170))
|
||||
- Add `DefaultMenuPanel` export. ([#4193](https://github.com/tldraw/tldraw/pull/4193))
|
||||
- Make `fileSize` property of `TLImageAsset` and `TLVideoAsset` optional. ([#4206](https://github.com/tldraw/tldraw/pull/4206))
|
||||
- Allow passing partial `TLEditorSnapshot` to `TldrawImage` and `useTLStore`. ([#4190](https://github.com/tldraw/tldraw/pull/4190))
|
||||
- Make `EffectScheduler` and `useStateTracking` public. ([#4155](https://github.com/tldraw/tldraw/pull/4155))
|
||||
- Add `setDefaultValue` to `StyleProp`. ([#4044](https://github.com/tldraw/tldraw/pull/4044))
|
||||
- Add `ShapeUtil.getInterpolatedProps`. ([#4162](https://github.com/tldraw/tldraw/pull/4162))
|
||||
- Add `Editor.run`, replacing `Editor.batch`. ([#4042](https://github.com/tldraw/tldraw/pull/4042))
|
||||
|
||||
## Improvements
|
||||
|
||||
- Add horizontal and vertical flipping for images. ([#4113](https://github.com/tldraw/tldraw/pull/4113))
|
||||
- Allow custom tools to decide whether they are affected by shape lock. ([#4208](https://github.com/tldraw/tldraw/pull/4208))
|
||||
- Serve icons as a single SVG instead of many individual requests. ([#4150](https://github.com/tldraw/tldraw/pull/4150))
|
||||
- Make paste-at-point behavior based on a user preference. ([#4104](https://github.com/tldraw/tldraw/pull/4104))
|
||||
- Show toast when uploading an unsupported file type or file that's too large. ([#4114](https://github.com/tldraw/tldraw/pull/4114))
|
||||
- Show toast when pasting fails due to missing clipboard permissions. ([#4117](https://github.com/tldraw/tldraw/pull/4117))
|
||||
- Add `ShapeIndicators` component for custom logic about when to display indicators. ([#4083](https://github.com/tldraw/tldraw/pull/4083))
|
||||
- Animate shape opacity changes. ([#4242](https://github.com/tldraw/tldraw/pull/4242))
|
||||
|
||||
## Bug fixes
|
||||
|
||||
- Fix font style export to SVG. ([#4195](https://github.com/tldraw/tldraw/pull/4195))
|
||||
- Fix `force` flag for camera methods. ([#4214](https://github.com/tldraw/tldraw/pull/4214))
|
||||
- Show user's color scheme in menu by default. ([#4184](https://github.com/tldraw/tldraw/pull/4184))
|
||||
- Fix padding for dynamically scaled text shapes. ([#4140](https://github.com/tldraw/tldraw/pull/4140))
|
||||
- Fix `cameraOptions` via React causing editor to re-mount. ([#4089](https://github.com/tldraw/tldraw/pull/4089))
|
||||
- Allow high-res images to be uploaded. ([#4198](https://github.com/tldraw/tldraw/pull/4198))
|
||||
- Prevent locked shapes from being updated, grouped, or ungrouped programmatically. ([#4042](https://github.com/tldraw/tldraw/pull/4042))
|
||||
- Fix `snapshots` prop in `createTLStore`. ([#4233](https://github.com/tldraw/tldraw/pull/4233))
|
||||
- Fix grid backgrounds with multiple tldraw instances. ([#4132](https://github.com/tldraw/tldraw/pull/4132))
|
||||
- Fix inputs staying in place while viewport-following. ([#4108](https://github.com/tldraw/tldraw/pull/4108))
|
||||
- Fix bookmarks rendering across devices. ([#4118](https://github.com/tldraw/tldraw/pull/4118))
|
||||
- Fix `InFrontOfTheCanvas` component stack order. ([#4024](https://github.com/tldraw/tldraw/pull/4024))
|
||||
- Fix frame headers not stopping edit on focus loss. ([#4092](https://github.com/tldraw/tldraw/pull/4092))
|
||||
|
||||
[View release on GitHub](https://github.com/tldraw/tldraw/releases/tag/v2.4.0)
|
||||
|
||||
---
|
||||
|
||||
## Patch releases
|
||||
|
||||
### v2.4.1
|
||||
|
||||
- Fix `assets` prop on `<Tldraw />` and `<TldrawEditor />` components not being respected. ([#4285](https://github.com/tldraw/tldraw/pull/4285))
|
||||
|
||||
[View release on GitHub](https://github.com/tldraw/tldraw/releases/tag/v2.4.1)
|
||||
|
||||
### v2.4.2
|
||||
|
||||
- Fix bugs and improve the sync engine and related packages. ([#4309](https://github.com/tldraw/tldraw/pull/4309))
|
||||
|
||||
[View release on GitHub](https://github.com/tldraw/tldraw/releases/tag/v2.4.2)
|
||||
|
||||
### v2.4.3
|
||||
|
||||
- Fix bugs in the tldraw package. ([#4328](https://github.com/tldraw/tldraw/pull/4328))
|
||||
|
||||
[View release on GitHub](https://github.com/tldraw/tldraw/releases/tag/v2.4.3)
|
||||
|
||||
### v2.4.4
|
||||
|
||||
- Fix bugs in the editor, sync, tldraw, and tlschema packages. ([#4352](https://github.com/tldraw/tldraw/pull/4352))
|
||||
|
||||
[View release on GitHub](https://github.com/tldraw/tldraw/releases/tag/v2.4.4)
|
||||
|
||||
### v2.4.5
|
||||
|
||||
- Fix unhandled promise rejection during strict mode in the editor. ([#4407](https://github.com/tldraw/tldraw/pull/4407))
|
||||
|
||||
[View release on GitHub](https://github.com/tldraw/tldraw/releases/tag/v2.4.5)
|
||||
|
||||
### v2.4.6
|
||||
|
||||
- Improve rendering performance in the store and tldraw packages. ([#4434](https://github.com/tldraw/tldraw/pull/4434))
|
||||
|
||||
[View release on GitHub](https://github.com/tldraw/tldraw/releases/tag/v2.4.6)
|
||||
133
apps/docs/content/releases/v3.0.0.mdx
Normal file
133
apps/docs/content/releases/v3.0.0.mdx
Normal file
@@ -0,0 +1,133 @@
|
||||
---
|
||||
title: 'v3.0.0'
|
||||
created_at: 9/13/2024
|
||||
updated_at: 12/20/2024
|
||||
keywords:
|
||||
- changelog
|
||||
- release
|
||||
- v3.0
|
||||
- v3.0.0
|
||||
- v3.0.1
|
||||
- v3.0.2
|
||||
- v3.0.3
|
||||
- licensing
|
||||
- deep-links
|
||||
- export
|
||||
- embeds
|
||||
- text-search
|
||||
status: published
|
||||
---
|
||||
|
||||
This release introduces a revised licensing model alongside deep links, enhanced image export, custom embeds, and a new text search API.
|
||||
|
||||
## What's new
|
||||
|
||||
### Licensing
|
||||
|
||||
The most significant change involves updated licenses permitting use in both commercial and non-commercial projects when displaying a "Made with tldraw" watermark. Users can now purchase Business Licenses to remove the watermark, with details available at [tldraw.dev](https://tldraw.dev). ([#4449](https://github.com/tldraw/tldraw/pull/4449), [#4517](https://github.com/tldraw/tldraw/pull/4517), [#4561](https://github.com/tldraw/tldraw/pull/4561))
|
||||
|
||||
### Deep links
|
||||
|
||||
New APIs enable creation of shareable URLs pointing to specific canvas locations, such as particular shapes or pages, with automatic URL updates as users navigate.
|
||||
|
||||
```typescript
|
||||
// Create a deep link to a specific shape
|
||||
const url = editor.createDeepLink({
|
||||
shapeId: 'shape-id',
|
||||
viewport: 'fit',
|
||||
})
|
||||
|
||||
// The URL updates automatically as you navigate
|
||||
editor.on('change', () => {
|
||||
const currentUrl = editor.getCurrentDeepLink()
|
||||
})
|
||||
```
|
||||
|
||||
See the [deep links guide](https://tldraw.dev/docs/editor#Deep-links) for more information. ([#4498](https://github.com/tldraw/tldraw/pull/4498))
|
||||
|
||||
### Enhanced image export
|
||||
|
||||
Custom shapes now export as images by default through HTML/CSS embedding within SVG files, eliminating the need for developers to implement custom SVG rendering methods.
|
||||
|
||||
```typescript
|
||||
// Custom shapes automatically export as images
|
||||
class MyShapeUtil extends ShapeUtil<MyShape> {
|
||||
// No need to implement toSvg() anymore
|
||||
component(shape: MyShape) {
|
||||
return <div className="my-shape">{/* ... */}</div>
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Custom embeds API
|
||||
|
||||
Developers can now customize which external websites (YouTube, Google Maps, CodeSandbox, etc.) appear as embeddable content, or add entirely new embed types.
|
||||
|
||||
```typescript
|
||||
// Add a custom embed type
|
||||
const customEmbedDef = {
|
||||
type: 'custom',
|
||||
title: 'My Custom Embed',
|
||||
hostnames: ['mysite.com'],
|
||||
toEmbedUrl: (url: string) => `https://mysite.com/embed/${url}`,
|
||||
fromEmbedUrl: (url: string) => url.replace('/embed/', '/')
|
||||
}
|
||||
|
||||
<Tldraw embedDefs={[customEmbedDef, ...defaultEmbedDefs]} />
|
||||
```
|
||||
|
||||
### Text search via getText API
|
||||
|
||||
A new `getText` API provides reliable access to human-readable shape content, facilitating text search implementation within applications.
|
||||
|
||||
```typescript
|
||||
// Search through all shapes for text content
|
||||
const searchResults = editor.getCurrentPageShapes().filter((shape) => {
|
||||
const text = editor.getText(shape.id)
|
||||
return text?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
})
|
||||
```
|
||||
|
||||
## API changes
|
||||
|
||||
- 💥 **`TLRotationSnapshot`** type removed from the public API. Use `editor.getShapePageTransform(shape).rotation()` instead.
|
||||
- 💥 **`onEditorMount`** callback in `createTLStore` renamed to `onMount` for consistency.
|
||||
- 💥 **`Editor.mark()`** deprecated in favor of `Editor.markHistoryStoppingPoint()` for clearer intent.
|
||||
- 💥 **`TLSvgOptions`** type renamed to `TLImageExportOptions` to better reflect its broader use in image export.
|
||||
- 💥 **Focus event behavior**: Calling `updateInstanceState({isFocused: true})` now triggers standard focus events consistently. Use `editor.focus()` for reliable focus behavior.
|
||||
- Add `editor.getText(shapeId)` method for accessing shape text content.
|
||||
- Add `editor.createDeepLink(options)` for generating shareable URLs.
|
||||
- Add `embedDefs` prop to Tldraw component for customizing embed types.
|
||||
- Expose additional shape export utilities for custom image generation.
|
||||
|
||||
## Improvements
|
||||
|
||||
- Improve bookmark rendering for URLs without preview images. ([#4460](https://github.com/tldraw/tldraw/pull/4460))
|
||||
- Improve handling of asset storage and persistence. ([#4542](https://github.com/tldraw/tldraw/pull/4542))
|
||||
- Improve documentation search functionality. ([#4485](https://github.com/tldraw/tldraw/pull/4485))
|
||||
- Refine watermark appearance and behavior. ([#4589](https://github.com/tldraw/tldraw/pull/4589), [#4622](https://github.com/tldraw/tldraw/pull/4622))
|
||||
|
||||
[View release on GitHub](https://github.com/tldraw/tldraw/releases/tag/v3.0.0)
|
||||
|
||||
---
|
||||
|
||||
## Patch releases
|
||||
|
||||
### v3.0.1
|
||||
|
||||
- Fix documentation search functionality and article issues. ([#4515](https://github.com/tldraw/tldraw/pull/4515))
|
||||
|
||||
[View release on GitHub](https://github.com/tldraw/tldraw/releases/tag/v3.0.1)
|
||||
|
||||
### v3.0.2
|
||||
|
||||
- Fix build caching issue causing "You have multiple versions of tldraw libraries installed" errors. ([#4525](https://github.com/tldraw/tldraw/pull/4525))
|
||||
- Fix container null error. ([#4524](https://github.com/tldraw/tldraw/pull/4524))
|
||||
|
||||
[View release on GitHub](https://github.com/tldraw/tldraw/releases/tag/v3.0.2)
|
||||
|
||||
### v3.0.3
|
||||
|
||||
- Fix bugs in the editor and tldraw packages. ([#4541](https://github.com/tldraw/tldraw/pull/4541))
|
||||
|
||||
[View release on GitHub](https://github.com/tldraw/tldraw/releases/tag/v3.0.3)
|
||||
41
apps/docs/content/releases/v3.1.0.mdx
Normal file
41
apps/docs/content/releases/v3.1.0.mdx
Normal file
@@ -0,0 +1,41 @@
|
||||
---
|
||||
title: 'v3.1.0'
|
||||
created_at: 9/25/2024
|
||||
updated_at: 9/25/2024
|
||||
keywords:
|
||||
- changelog
|
||||
- release
|
||||
- v3.1
|
||||
- v3.1.0
|
||||
- visibility
|
||||
- sync
|
||||
- server-side
|
||||
- assets
|
||||
status: published
|
||||
---
|
||||
|
||||
This release introduces shape visibility control and server-side data manipulation capabilities, alongside API enhancements and bug fixes.
|
||||
|
||||
## What's new
|
||||
|
||||
### Shape visibility control ([#4570](https://github.com/tldraw/tldraw/pull/4570))
|
||||
|
||||
The new `isShapeHidden` predicate option prevents shapes from being rendered or reacting to pointer events. This is useful for implementing permission systems in collaborative applications where certain shapes should be invisible to specific users.
|
||||
|
||||
### Server-side document manipulation ([#4581](https://github.com/tldraw/tldraw/pull/4581))
|
||||
|
||||
The tldraw sync server now supports server-side document manipulation through the `updateShapes` method, enabling integration with third-party services and automated document updates.
|
||||
|
||||
## API changes
|
||||
|
||||
- Add option to `Editor.rotateShapesBy` specifying rotation center point.
|
||||
- Add `useAsset` hook for resolving responsive asset versions.
|
||||
|
||||
## Bug fixes
|
||||
|
||||
- Fix arrow label text measurement calculations.
|
||||
- Fix arrow collaborator indicators overlapping labels.
|
||||
- Fix binding processing error handling.
|
||||
- Fix watermark opacity and animation behavior.
|
||||
|
||||
[View release on GitHub](https://github.com/tldraw/tldraw/releases/tag/v3.1.0)
|
||||
69
apps/docs/content/releases/v3.10.0.mdx
Normal file
69
apps/docs/content/releases/v3.10.0.mdx
Normal file
@@ -0,0 +1,69 @@
|
||||
---
|
||||
title: 'v3.10.0'
|
||||
created_at: 11/3/2025
|
||||
updated_at: 11/3/2025
|
||||
keywords:
|
||||
- changelog
|
||||
- release
|
||||
- v3.10
|
||||
- v3.10.0
|
||||
- v3.10.1
|
||||
- v3.10.2
|
||||
- v3.10.3
|
||||
- rich-text
|
||||
- tiptap
|
||||
- performance
|
||||
- collaboration
|
||||
status: published
|
||||
---
|
||||
|
||||
This release introduces rich text as a first-class primitive within the Editor, alongside various performance improvements and bug fixes.
|
||||
|
||||
## What's new
|
||||
|
||||
### Rich text support
|
||||
|
||||
The SDK now offers rich text as a first-class primitive. The implementation utilizes TipTap with its StarterKit extension plus additional customizations. Developers can configure this functionality through a `textOptions` property to customize TipTap and modify extensions.
|
||||
|
||||
## API changes
|
||||
|
||||
- 💥 **`text` property** on most shapes replaced with `richText`. When setting values, use `richText: toRichText('some string')` instead of `text: 'some string'`. When retrieving values, use `renderPlaintextFromRichText(props.text)` rather than `props.text`.
|
||||
- Add asset rehydration for .tldr files containing embedded base64 data.
|
||||
- Add `BrokenAssetIcon` display on file upload failures.
|
||||
- Add public collaboration hooks (`usePeerIds`, `usePresence`).
|
||||
- Add `userId` parameter to collaboration components.
|
||||
|
||||
## Bug fixes
|
||||
|
||||
- Fix performance regression affecting document loading.
|
||||
- Fix Firefox export/style embedding for foreignObjects.
|
||||
- Fix shape reparenting order preservation.
|
||||
- Fix resize functionality.
|
||||
- Fix Safari file pasting issues.
|
||||
- Fix SSR environment compatibility (core-js import removal).
|
||||
- Fix IME text duplication in Chrome.
|
||||
|
||||
[View release on GitHub](https://github.com/tldraw/tldraw/releases/tag/v3.10.0)
|
||||
|
||||
---
|
||||
|
||||
## Patch releases
|
||||
|
||||
### v3.10.1
|
||||
|
||||
- Fix API reference links in state packages. ([#5610](https://github.com/tldraw/tldraw/pull/5610))
|
||||
|
||||
[View release on GitHub](https://github.com/tldraw/tldraw/releases/tag/v3.10.1)
|
||||
|
||||
### v3.10.2
|
||||
|
||||
- Add `textOptions` to `<TldrawImage />` component. ([#5650](https://github.com/tldraw/tldraw/pull/5650))
|
||||
|
||||
[View release on GitHub](https://github.com/tldraw/tldraw/releases/tag/v3.10.2)
|
||||
|
||||
### v3.10.3
|
||||
|
||||
- Fix Inter font being embedded in exports. ([#5676](https://github.com/tldraw/tldraw/pull/5676))
|
||||
- Reduce excessive styling in exported content. ([#5676](https://github.com/tldraw/tldraw/pull/5676))
|
||||
|
||||
[View release on GitHub](https://github.com/tldraw/tldraw/releases/tag/v3.10.3)
|
||||
63
apps/docs/content/releases/v3.11.0.mdx
Normal file
63
apps/docs/content/releases/v3.11.0.mdx
Normal file
@@ -0,0 +1,63 @@
|
||||
---
|
||||
title: 'v3.11.0'
|
||||
created_at: 3/20/2025
|
||||
updated_at: 3/20/2025
|
||||
keywords:
|
||||
- changelog
|
||||
- release
|
||||
- v3.11
|
||||
- v3.11.0
|
||||
- v3.11.1
|
||||
- v3.11.2
|
||||
- rich-text
|
||||
- zoom
|
||||
- accessibility
|
||||
- react-19
|
||||
status: published
|
||||
---
|
||||
|
||||
This release addresses rich text issues following v3.10, adds zoom improvements, and introduces accessibility enhancements.
|
||||
|
||||
## API changes
|
||||
|
||||
- 💥 **`editor.user.getName()`** no longer returns "New user" when unnamed; returns empty string instead.
|
||||
- 💥 **`defaultUserPreferences.name`** changed from "New user" to empty string.
|
||||
- Export `RichTextSVG` from the package.
|
||||
|
||||
## Improvements
|
||||
|
||||
- Add a new minimum zoom step at 5%.
|
||||
- Add keyboard shortcuts (Shift +/Shift -) for zooming toward your cursor.
|
||||
- Add option to hit Enter to continue editing after selection in the style panel.
|
||||
- Improve rich text rendering performance with many shapes.
|
||||
- Improve hiding/showing shape indicators on large projects.
|
||||
- Add page navigation shortcuts (option + arrows).
|
||||
- Add support for satellite mode in Google Map embeds.
|
||||
- Add focus rings for accessibility.
|
||||
- Add nonce support for the editor security.
|
||||
|
||||
## Bug fixes
|
||||
|
||||
- Fix rich text numbered list geometry bounds.
|
||||
- Fix React 19 StrictMode compatibility.
|
||||
- Fix export styling issues.
|
||||
- Fix missing internationalization strings for rich text elements.
|
||||
|
||||
[View release on GitHub](https://github.com/tldraw/tldraw/releases/tag/v3.11.0)
|
||||
|
||||
---
|
||||
|
||||
## Patch releases
|
||||
|
||||
### v3.11.1
|
||||
|
||||
- Fix application of zoomSpeed setting to pinch gestures. ([#5696](https://github.com/tldraw/tldraw/pull/5696))
|
||||
- Fix unexpected borders in exports from Tailwind CSS pages. ([#5696](https://github.com/tldraw/tldraw/pull/5696))
|
||||
|
||||
[View release on GitHub](https://github.com/tldraw/tldraw/releases/tag/v3.11.1)
|
||||
|
||||
### v3.11.2
|
||||
|
||||
- Fix sync core bug. ([#6667](https://github.com/tldraw/tldraw/pull/6667))
|
||||
|
||||
[View release on GitHub](https://github.com/tldraw/tldraw/releases/tag/v3.11.2)
|
||||
65
apps/docs/content/releases/v3.12.0.mdx
Normal file
65
apps/docs/content/releases/v3.12.0.mdx
Normal file
@@ -0,0 +1,65 @@
|
||||
---
|
||||
title: 'v3.12.0'
|
||||
created_at: 4/15/2025
|
||||
updated_at: 4/15/2025
|
||||
keywords:
|
||||
- changelog
|
||||
- release
|
||||
- v3.12
|
||||
- v3.12.0
|
||||
- v3.12.1
|
||||
- v3.12.2
|
||||
- accessibility
|
||||
- keyboard-navigation
|
||||
- frames
|
||||
- geometry
|
||||
status: published
|
||||
---
|
||||
|
||||
This release focuses on accessibility improvements and API enhancements, with no breaking changes.
|
||||
|
||||
## API changes
|
||||
|
||||
- Add new `getShapeVisibility` prop replacing the deprecated `isShapeHidden`, allowing hidden children to override parent visibility states.
|
||||
- Allow frames to display colorful borders and labels by configuring `FrameShapeUtil` with `showColors: true`.
|
||||
- Add `Geometry2d` helper methods including `intersectLineSegment`, `intersectCircle`, `transform`, and filtering options.
|
||||
- Export two Tiptap extensions individually for rich text customization.
|
||||
|
||||
## Improvements
|
||||
|
||||
- Add keyboard-based selection movement between shapes using Tab and Cmd/Ctrl+Arrow keys.
|
||||
- Add screen reader announcements for tool and shape selection changes.
|
||||
- Add keyboard resizing capability via Cmd/Ctrl+Alt+Shift with +/- keys.
|
||||
- Improve semantic HTML5 tags and proper ARIA roles throughout the UI.
|
||||
- Simplify keyboard shortcut syntax (e.g., `'?!l'` becomes `'alt+shift+l'`).
|
||||
- Add atomic `Store.mergeRemoteChanges` with correct scope-triggered side effects.
|
||||
- Add smoother drawing on slower CPUs via `getCoalescedEvents`.
|
||||
- Add YouTube embed support for time/loop parameters.
|
||||
- Add optional asset URL overrides for unused Embed shape icons.
|
||||
|
||||
## Bug fixes
|
||||
|
||||
- Fix Firefox SVG pasting.
|
||||
- Fix TailwindCSS text export styling.
|
||||
- Fix image pasting efficiency.
|
||||
- Fix Miro shape compatibility.
|
||||
- Fix Safari trackpad zoom.
|
||||
- Fix performance regressions in rich text and iPad drawing.
|
||||
|
||||
[View release on GitHub](https://github.com/tldraw/tldraw/releases/tag/v3.12.0)
|
||||
|
||||
---
|
||||
|
||||
## Patch releases
|
||||
|
||||
### v3.12.1
|
||||
|
||||
- Fix group bounds containing text shapes. ([#5911](https://github.com/tldraw/tldraw/pull/5911))
|
||||
|
||||
[View release on GitHub](https://github.com/tldraw/tldraw/releases/tag/v3.12.1)
|
||||
|
||||
### v3.12.2
|
||||
|
||||
- Restore DPR constrained shape dimensions. ([#6034](https://github.com/tldraw/tldraw/pull/6034))
|
||||
|
||||
[View release on GitHub](https://github.com/tldraw/tldraw/releases/tag/v3.12.2)
|
||||
92
apps/docs/content/releases/v3.13.0.mdx
Normal file
92
apps/docs/content/releases/v3.13.0.mdx
Normal file
@@ -0,0 +1,92 @@
|
||||
---
|
||||
title: 'v3.13.0'
|
||||
created_at: 5/22/2025
|
||||
updated_at: 5/22/2025
|
||||
keywords:
|
||||
- changelog
|
||||
- release
|
||||
- v3.13
|
||||
- v3.13.0
|
||||
- v3.13.1
|
||||
- v3.13.2
|
||||
- v3.13.3
|
||||
- v3.13.4
|
||||
- elbow-arrows
|
||||
- accessibility
|
||||
- performance
|
||||
- overlays
|
||||
status: published
|
||||
---
|
||||
|
||||
This release introduces elbow arrows for technical diagramming and continues accessibility improvements toward WCAG compliance.
|
||||
|
||||
## What's new
|
||||
|
||||
### Elbow arrows
|
||||
|
||||
The arrow shape now supports a new elbow connector style. These arrows travel between points using straight lines joined at right angles rather than curved paths, making them ideal for technical diagrams. Access this option through the arrow tool's line style settings.
|
||||
|
||||
## API changes
|
||||
|
||||
- 💥 **`ShapeUtil.canEditInReadOnly`** renamed to `ShapeUtil.canEditInReadonly` (note capitalization change).
|
||||
- 💥 **Arrow target highlights** migrated from `setHintingShapes` to a custom `TldrawOverlays` component; users of `<TldrawEditor />` must pass this component to the `Overlays` slot.
|
||||
- Add text measurement API that accepts arbitrary style parameters.
|
||||
- Add new `Overlays` component for camera-following canvas overlays.
|
||||
- Add `hasCustomTabBehavior` prop to `RichTextLabel` for custom tab logic.
|
||||
|
||||
## Improvements
|
||||
|
||||
- Auto-select link text when editing shape links.
|
||||
- Snap pasted text and embeds to grid when enabled.
|
||||
- Allow Figma embeds to work with selection links.
|
||||
- Show visual block indicating label placement when editing arrow labels.
|
||||
- Include background color in single frame exports.
|
||||
- Allow frames to be double-click resized to fit contents.
|
||||
- Allow style panel to focus with keyboard shortcut.
|
||||
- Add proper labels to more UI icons and handles.
|
||||
- Add container navigation shortcuts for moving selections in/out of frames and groups.
|
||||
- Make embed shapes keyboard-tabbable.
|
||||
- Add keyboard navigation to toolbars throughout the application.
|
||||
- Show rich text editing shortcuts in the help menu.
|
||||
- Improve performance of programmatic rotation of selected shapes.
|
||||
- Improve performance of multi-shape dragging operations.
|
||||
- Optimize selection evaluation to prevent unnecessary re-evaluations.
|
||||
|
||||
## Bug fixes
|
||||
|
||||
- Fix flattened shape backgrounds.
|
||||
- Fix screenreader behavior.
|
||||
- Fix tool consistency.
|
||||
- Fix text rendering.
|
||||
- Fix selection accuracy.
|
||||
- Fix arrow flickering during rotation.
|
||||
|
||||
[View release on GitHub](https://github.com/tldraw/tldraw/releases/tag/v3.13.0)
|
||||
|
||||
---
|
||||
|
||||
## Patch releases
|
||||
|
||||
### v3.13.1
|
||||
|
||||
- Show elbow arrow outline indicator on hover when creating elbow arrows. ([#6099](https://github.com/tldraw/tldraw/pull/6099))
|
||||
|
||||
[View release on GitHub](https://github.com/tldraw/tldraw/releases/tag/v3.13.1)
|
||||
|
||||
### v3.13.2
|
||||
|
||||
- Fix LoadingScreen context bug where custom asset URL configuration was ignored. ([#6283](https://github.com/tldraw/tldraw/pull/6283))
|
||||
|
||||
[View release on GitHub](https://github.com/tldraw/tldraw/releases/tag/v3.13.2)
|
||||
|
||||
### v3.13.3
|
||||
|
||||
- Fix overriding builtin tools not working. ([#6330](https://github.com/tldraw/tldraw/pull/6330))
|
||||
|
||||
[View release on GitHub](https://github.com/tldraw/tldraw/releases/tag/v3.13.3)
|
||||
|
||||
### v3.13.4
|
||||
|
||||
- Fix cross-realm functionality issues. ([#6338](https://github.com/tldraw/tldraw/pull/6338))
|
||||
|
||||
[View release on GitHub](https://github.com/tldraw/tldraw/releases/tag/v3.13.4)
|
||||
75
apps/docs/content/releases/v3.14.0.mdx
Normal file
75
apps/docs/content/releases/v3.14.0.mdx
Normal file
@@ -0,0 +1,75 @@
|
||||
---
|
||||
title: 'v3.14.0'
|
||||
created_at: 3/7/2025
|
||||
updated_at: 3/7/2025
|
||||
keywords:
|
||||
- changelog
|
||||
- release
|
||||
- v3.14
|
||||
- v3.14.0
|
||||
- v3.14.1
|
||||
- v3.14.2
|
||||
- contextual-toolbar
|
||||
- image-editing
|
||||
- video-editing
|
||||
- pathbuilder
|
||||
- cropping
|
||||
status: published
|
||||
---
|
||||
|
||||
This release introduces contextual toolbars for image and video shapes, enhanced cropping options, and a new PathBuilder API.
|
||||
|
||||
## API changes
|
||||
|
||||
- 💥 **`Editor#getShapePageGeometry`** removed.
|
||||
- 💥 **`Editor#getShapesAtPoint`** now returns shapes in descending index order.
|
||||
- 💥 **Locked shapes** cannot be duplicated; pasting locked shapes unlocks them.
|
||||
- 💥 **`ShapeUtil.canDropShapes`** removed—drag-and-drop participation now requires implementing handlers.
|
||||
- Add `TldrawUiContextualToolbar` component enabling developers to create floating toolbars.
|
||||
- Add `PathBuilder` API for constructing tldraw-style inky and dashed SVGs.
|
||||
- Add arrow binding configuration for controlling when arrows should be exact and/or bind.
|
||||
- Add `measureScrollWidth` option for text measurement.
|
||||
- Add `defaultAutoplay` boolean property for `VideoShapeUtil`.
|
||||
|
||||
## Improvements
|
||||
|
||||
- Add image and video contextual toolbar with floating controls for enhanced interaction.
|
||||
- Add download functionality for image and video shapes to download original files.
|
||||
- Add advanced image cropping options.
|
||||
- Improve context menu keyboard support.
|
||||
- Improve style panel keyboard navigation.
|
||||
- Improve focus management.
|
||||
- Optimize copy operations.
|
||||
- Optimize arrow rendering with labels.
|
||||
- Optimize shape resizing.
|
||||
- Optimize keyboard interactions.
|
||||
- Optimize arrow dragging.
|
||||
- Improve text measurement caching.
|
||||
|
||||
## Bug fixes
|
||||
|
||||
- Fix page menu scrolling behavior.
|
||||
- Fix undo functionality for sticky creation.
|
||||
- Fix bookmark hyperlink handling.
|
||||
- Fix SVG output and vector calculations.
|
||||
- Fix frame resizing with multiple selections.
|
||||
- Fix HTML copy-paste positioning.
|
||||
- Fix keyboard shortcuts and text overflow issues.
|
||||
|
||||
[View release on GitHub](https://github.com/tldraw/tldraw/releases/tag/v3.14.0)
|
||||
|
||||
---
|
||||
|
||||
## Patch releases
|
||||
|
||||
### v3.14.1
|
||||
|
||||
- Fix text measurement bug where styles could leak from one measurement to the next. ([#6388](https://github.com/tldraw/tldraw/pull/6388))
|
||||
|
||||
[View release on GitHub](https://github.com/tldraw/tldraw/releases/tag/v3.14.1)
|
||||
|
||||
### v3.14.2
|
||||
|
||||
- Fix HTML entities being escaped in clipboard operations. ([#6396](https://github.com/tldraw/tldraw/pull/6396))
|
||||
|
||||
[View release on GitHub](https://github.com/tldraw/tldraw/releases/tag/v3.14.2)
|
||||
94
apps/docs/content/releases/v3.15.0.mdx
Normal file
94
apps/docs/content/releases/v3.15.0.mdx
Normal file
@@ -0,0 +1,94 @@
|
||||
---
|
||||
title: 'v3.15.0'
|
||||
created_at: 7/30/2025
|
||||
updated_at: 7/30/2025
|
||||
keywords:
|
||||
- changelog
|
||||
- release
|
||||
- v3.15
|
||||
- v3.15.0
|
||||
- v3.15.1
|
||||
- v3.15.2
|
||||
- v3.15.3
|
||||
- v3.15.4
|
||||
- v3.15.5
|
||||
- cli
|
||||
- npm-create-tldraw
|
||||
- accessibility
|
||||
- performance
|
||||
- handle-callbacks
|
||||
status: published
|
||||
---
|
||||
|
||||
This release introduces `npm create tldraw` for bootstrapping new projects, along with API additions, accessibility improvements, and performance optimizations.
|
||||
|
||||
## What's new
|
||||
|
||||
### npm create tldraw
|
||||
|
||||
Developers can now initialize new tldraw projects by running `npm create tldraw` in their terminal, selecting from available starter templates.
|
||||
|
||||
## API changes
|
||||
|
||||
- Add handle interaction callbacks: `onHandleDragStart`, `onHandleDragEnd`, and cancellation callbacks (`onTranslateCancel`, `onResizeCancel`, `onRotateCancel`, `onHandleDragCancel`).
|
||||
- Add JSX icon support for custom icons in tool overrides, action overrides, buttons, and menu items.
|
||||
- Add `StateNode.addChild` method enabling dynamic tool functionality additions.
|
||||
- Convert `DefaultSpinner` component to SVG format, now accepts standard SVG properties.
|
||||
- Export `HeartToolbarItem` component.
|
||||
|
||||
## Improvements
|
||||
|
||||
- Enhance "Select All" functionality to intelligently select within a shape's common parent context (e.g., selecting shapes within a frame without selecting the frame itself).
|
||||
- Add user preference option to disable keyboard shortcuts.
|
||||
- Prevent frame drops.
|
||||
- Optimize the `useValue` and `useReactor` hooks.
|
||||
- Enhance frame rendering efficiency.
|
||||
- Expand `aria-hidden` application.
|
||||
- Improve keyboard navigation.
|
||||
- Add proper `role="radiogroup"` assignment for toggle groups.
|
||||
- Correct aria slider values.
|
||||
|
||||
## Bug fixes
|
||||
|
||||
- Fix HTML entity escaping in pasted content.
|
||||
- Fix text measurement accuracy.
|
||||
- Fix arrow rendering between circles.
|
||||
- Fix SVG text outline rendering.
|
||||
- Fix image toolbar camera movement interactions.
|
||||
|
||||
[View release on GitHub](https://github.com/tldraw/tldraw/releases/tag/v3.15.0)
|
||||
|
||||
---
|
||||
|
||||
## Patch releases
|
||||
|
||||
### v3.15.1
|
||||
|
||||
- Fix room change event handling in the sync core library. ([#6543](https://github.com/tldraw/tldraw/pull/6543))
|
||||
|
||||
[View release on GitHub](https://github.com/tldraw/tldraw/releases/tag/v3.15.1)
|
||||
|
||||
### v3.15.2
|
||||
|
||||
- Fix editor remounting in a loop when given custom assetUrls. ([#6605](https://github.com/tldraw/tldraw/pull/6605))
|
||||
|
||||
[View release on GitHub](https://github.com/tldraw/tldraw/releases/tag/v3.15.2)
|
||||
|
||||
### v3.15.3
|
||||
|
||||
- Enhance callback functionality for shape utilities. ([#6634](https://github.com/tldraw/tldraw/pull/6634))
|
||||
|
||||
[View release on GitHub](https://github.com/tldraw/tldraw/releases/tag/v3.15.3)
|
||||
|
||||
### v3.15.4
|
||||
|
||||
- Fix potential bug in TLSyncClient. ([#6660](https://github.com/tldraw/tldraw/pull/6660))
|
||||
|
||||
[View release on GitHub](https://github.com/tldraw/tldraw/releases/tag/v3.15.4)
|
||||
|
||||
### v3.15.5
|
||||
|
||||
- Show license flags message as a warning. ([#6843](https://github.com/tldraw/tldraw/pull/6843))
|
||||
- Fix expired internal license verification. ([#6843](https://github.com/tldraw/tldraw/pull/6843))
|
||||
|
||||
[View release on GitHub](https://github.com/tldraw/tldraw/releases/tag/v3.15.5)
|
||||
15
apps/docs/content/releases/v3.2.0.mdx
Normal file
15
apps/docs/content/releases/v3.2.0.mdx
Normal file
@@ -0,0 +1,15 @@
|
||||
---
|
||||
title: 'v3.2.0'
|
||||
created_at: 9/26/2024
|
||||
updated_at: 9/26/2024
|
||||
keywords:
|
||||
- changelog
|
||||
- release
|
||||
- v3.2
|
||||
- v3.2.0
|
||||
status: published
|
||||
---
|
||||
|
||||
This release contains no code changes. It was published to align version numbers across packages. See the [v3.1.0 release notes](/releases/v3.1.0) for information about changes since v3.0.x.
|
||||
|
||||
[View release on GitHub](https://github.com/tldraw/tldraw/releases/tag/v3.2.0)
|
||||
74
apps/docs/content/releases/v3.3.0.mdx
Normal file
74
apps/docs/content/releases/v3.3.0.mdx
Normal file
@@ -0,0 +1,74 @@
|
||||
---
|
||||
title: 'v3.3.0'
|
||||
created_at: 9/10/2024
|
||||
updated_at: 9/10/2024
|
||||
keywords:
|
||||
- changelog
|
||||
- release
|
||||
- v3.3
|
||||
- v3.3.0
|
||||
- v3.3.1
|
||||
- v3.3.2
|
||||
- v3.3.3
|
||||
- readonly
|
||||
- sync
|
||||
- performance
|
||||
status: published
|
||||
---
|
||||
|
||||
This release introduces sync improvements, readonly mode support, and various performance enhancements.
|
||||
|
||||
## What's new
|
||||
|
||||
### Readonly mode
|
||||
|
||||
Servers can now restrict client editing while preserving presence updates using the new "readonly" mode. This enables use cases like view-only sharing and presentation modes where users can see each other's cursors but cannot modify the document.
|
||||
|
||||
### Sync server enhancements
|
||||
|
||||
New `TLSocketRoom` methods (`closeSession`, `getRecord`, and `getSessions`) provide finer control over sync connections and room state management.
|
||||
|
||||
## API changes
|
||||
|
||||
- Add editor option for quick action shortcut placement.
|
||||
- Add `editor.getIsReadonly()` helper method.
|
||||
|
||||
## Improvements
|
||||
|
||||
- Pass wheel events through toolbar elements for improved scrolling/zooming.
|
||||
- Allow Command/Control modifier keys to enable multi-select (previously shift-only).
|
||||
- Allow text tool to be locked.
|
||||
- Improve draw shape rendering performance during zoom interactions.
|
||||
|
||||
## Bug fixes
|
||||
|
||||
- Fix Safari arrow rendering.
|
||||
- Fix image drop navigation errors.
|
||||
- Fix watermark layering.
|
||||
- Fix geometry calculations.
|
||||
- Fix readonly mode label editing prevention.
|
||||
|
||||
[View release on GitHub](https://github.com/tldraw/tldraw/releases/tag/v3.3.0)
|
||||
|
||||
---
|
||||
|
||||
## Patch releases
|
||||
|
||||
### v3.3.1
|
||||
|
||||
- Fix meta key incorrectly triggering the shift key. ([#4703](https://github.com/tldraw/tldraw/pull/4703))
|
||||
|
||||
[View release on GitHub](https://github.com/tldraw/tldraw/releases/tag/v3.3.1)
|
||||
|
||||
### v3.3.2
|
||||
|
||||
- Fix dropped images being created twice. ([#4727](https://github.com/tldraw/tldraw/pull/4727))
|
||||
|
||||
[View release on GitHub](https://github.com/tldraw/tldraw/releases/tag/v3.3.2)
|
||||
|
||||
### v3.3.3
|
||||
|
||||
- Fix image duplication when dropping onto the canvas. ([#4789](https://github.com/tldraw/tldraw/pull/4789))
|
||||
- Fix additional keyboard modifier issues. ([#4789](https://github.com/tldraw/tldraw/pull/4789))
|
||||
|
||||
[View release on GitHub](https://github.com/tldraw/tldraw/releases/tag/v3.3.3)
|
||||
56
apps/docs/content/releases/v3.4.0.mdx
Normal file
56
apps/docs/content/releases/v3.4.0.mdx
Normal file
@@ -0,0 +1,56 @@
|
||||
---
|
||||
title: 'v3.4.0'
|
||||
created_at: 10/24/2024
|
||||
updated_at: 10/24/2024
|
||||
keywords:
|
||||
- changelog
|
||||
- release
|
||||
- v3.4
|
||||
- v3.4.0
|
||||
- v3.4.1
|
||||
- image-performance
|
||||
- video-performance
|
||||
- excalidraw
|
||||
- dom-ids
|
||||
status: published
|
||||
---
|
||||
|
||||
This release addresses UI bugs, enhances image/video performance, and improves Excalidraw content compatibility.
|
||||
|
||||
## API changes
|
||||
|
||||
- 💥 **`id` DOM attribute** on shapes removed in favor of a `data-shape-id` attribute on shape wrappers.
|
||||
- Add `labelColor` property to note shapes.
|
||||
- Add `useUniqueSafeId` and `useSharedSafeId` utilities for managing DOM IDs in tldraw components.
|
||||
|
||||
## Improvements
|
||||
|
||||
- Improve tooltip wording in the style panel.
|
||||
- Prevent arrow label indicators from displaying behind labels.
|
||||
- Limit page name length in the move-to-page menu.
|
||||
- Improve image and video rendering performance.
|
||||
|
||||
## Bug fixes
|
||||
|
||||
- Fix re-render loops caused by inline `options` in the Tldraw component.
|
||||
- Fix toolbar button outline border-radius alignment.
|
||||
- Fix color preservation when pasting Excalidraw content.
|
||||
- Fix unhighlighted menu triggers.
|
||||
- Fix drag event type handling.
|
||||
- Fix duplicate image creation on canvas drops.
|
||||
- Fix link indicator visibility on sticky notes.
|
||||
- Fix URL parsing in embed dialogs.
|
||||
- Fix cross-instance rendering conflicts.
|
||||
- Fix menu options in read-only mode.
|
||||
|
||||
[View release on GitHub](https://github.com/tldraw/tldraw/releases/tag/v3.4.0)
|
||||
|
||||
---
|
||||
|
||||
## Patch releases
|
||||
|
||||
### v3.4.1
|
||||
|
||||
- Fix arrows being clipped incorrectly when multiple tldraw instances or exports are present in the DOM. ([#4819](https://github.com/tldraw/tldraw/pull/4819))
|
||||
|
||||
[View release on GitHub](https://github.com/tldraw/tldraw/releases/tag/v3.4.1)
|
||||
62
apps/docs/content/releases/v3.5.0.mdx
Normal file
62
apps/docs/content/releases/v3.5.0.mdx
Normal file
@@ -0,0 +1,62 @@
|
||||
---
|
||||
title: 'v3.5.0'
|
||||
created_at: 11/26/2024
|
||||
updated_at: 11/26/2024
|
||||
keywords:
|
||||
- changelog
|
||||
- release
|
||||
- v3.5
|
||||
- v3.5.0
|
||||
- v3.5.1
|
||||
- grid-snapping
|
||||
- export
|
||||
- layering
|
||||
- performance
|
||||
status: published
|
||||
---
|
||||
|
||||
This release introduces enhancements to grid snapping during shape creation, improves layering mechanics, and provides better control over export functionality.
|
||||
|
||||
## What's new
|
||||
|
||||
### Grid snapping for shape creation ([#4894](https://github.com/tldraw/tldraw/pull/4894))
|
||||
|
||||
Shapes now automatically align to the grid when drawn in grid mode. The new `maybeSnapToGrid` function enables snapping for both click-created and drag-created shapes.
|
||||
|
||||
### Improved layering actions ([#4898](https://github.com/tldraw/tldraw/pull/4898))
|
||||
|
||||
The "bring forward" and "send backward" actions now only consider nearby overlapping shapes, making layer management more intuitive when working with complex canvases.
|
||||
|
||||
## API changes
|
||||
|
||||
- Add `scale` and `pixelRatio` parameters to export utilities for more precise control over export dimensions and quality.
|
||||
- Expose font size and stroke size APIs.
|
||||
- Add socket close reason for rate limiting.
|
||||
- Add option to toggle double-click text creation.
|
||||
|
||||
## Improvements
|
||||
|
||||
- Improve panning performance for note-heavy canvases and zoomed-out views.
|
||||
- Improve page menu hover interaction.
|
||||
- Add custom React provider support in SVG exports.
|
||||
- Switch default theme to light mode.
|
||||
- Improve frame heading right-click behavior.
|
||||
|
||||
## Bug fixes
|
||||
|
||||
- Fix off-screen shape zoom-to-edit.
|
||||
- Fix erasing state stickiness.
|
||||
- Fix frame heading drag interactions.
|
||||
- Fix Safari sleep-related crashes.
|
||||
|
||||
[View release on GitHub](https://github.com/tldraw/tldraw/releases/tag/v3.5.0)
|
||||
|
||||
---
|
||||
|
||||
## Patch releases
|
||||
|
||||
### v3.5.1
|
||||
|
||||
- Fix long press on inset canvases. ([#5037](https://github.com/tldraw/tldraw/pull/5037))
|
||||
|
||||
[View release on GitHub](https://github.com/tldraw/tldraw/releases/tag/v3.5.1)
|
||||
58
apps/docs/content/releases/v3.6.0.mdx
Normal file
58
apps/docs/content/releases/v3.6.0.mdx
Normal file
@@ -0,0 +1,58 @@
|
||||
---
|
||||
title: 'v3.6.0'
|
||||
created_at: 4/12/2024
|
||||
updated_at: 4/12/2024
|
||||
keywords:
|
||||
- changelog
|
||||
- release
|
||||
- v3.6
|
||||
- v3.6.0
|
||||
- v3.6.1
|
||||
- helpers
|
||||
- actions
|
||||
- embeds
|
||||
- export
|
||||
- clipboard
|
||||
- toasts
|
||||
status: published
|
||||
---
|
||||
|
||||
This release focuses on developer experience improvements with expanded helper functions and various bug fixes across the editor, embed system, and export functionality.
|
||||
|
||||
## What's new
|
||||
|
||||
### Expanded helper functions ([#5041](https://github.com/tldraw/tldraw/pull/5041))
|
||||
|
||||
Actions and toasts overrides now have access to a wider range of helper functions, including clipboard interactions, toast notifications, export utilities, printing, and more. This makes it easier to create custom actions and tools that integrate deeply with tldraw's functionality.
|
||||
|
||||
## API changes
|
||||
|
||||
- 💥 **`setEmbedDefinitions`** changed from an instance method to a static method on the `EmbedShapeUtil` class. Update your embed definitions to use `EmbedShapeUtil.setEmbedDefinitions()` instead of calling it on an instance. ([#5027](https://github.com/tldraw/tldraw/pull/5027))
|
||||
- 💥 **`ExtractOptionalKeys` and `ExtractRequiredKeys`** utility types removed. Use the new utility type for creating optional properties instead. ([#5055](https://github.com/tldraw/tldraw/pull/5055))
|
||||
|
||||
## Improvements
|
||||
|
||||
- Add utility type for making undefined properties optional, simplifying type definitions for shape props and other optional configurations. ([#5055](https://github.com/tldraw/tldraw/pull/5055))
|
||||
- Make notes properly snap to the grid after their position is updated, improving alignment consistency. ([#5010](https://github.com/tldraw/tldraw/pull/5010))
|
||||
- Add incremental derivation example demonstrating advanced computed value patterns. ([#5038](https://github.com/tldraw/tldraw/pull/5038))
|
||||
|
||||
## Bug fixes
|
||||
|
||||
- Fix asset resolution when copying and pasting multiple items, including proper handling of video assets. ([#5061](https://github.com/tldraw/tldraw/pull/5061))
|
||||
- Fix text clipping in scaled frames during export, ensuring all text content is visible in exported images. ([#5022](https://github.com/tldraw/tldraw/pull/5022))
|
||||
- Fix concurrent export operations interfering with each other's font loading. ([#5022](https://github.com/tldraw/tldraw/pull/5022))
|
||||
- Fix long-press point accuracy to ensure touch interactions register at the correct coordinates. ([#5032](https://github.com/tldraw/tldraw/pull/5032))
|
||||
- Fix export naming for single unnamed frames, which now receive appropriate default names. ([#4918](https://github.com/tldraw/tldraw/pull/4918))
|
||||
- Fix custom embeds not rendering correctly on initial mount. ([#5027](https://github.com/tldraw/tldraw/pull/5027))
|
||||
|
||||
[View release on GitHub](https://github.com/tldraw/tldraw/releases/tag/v3.6.0)
|
||||
|
||||
---
|
||||
|
||||
## Patch releases
|
||||
|
||||
### v3.6.1
|
||||
|
||||
- Fix bugs in the editor, state-react, and tldraw packages. ([#5132](https://github.com/tldraw/tldraw/pull/5132))
|
||||
|
||||
[View release on GitHub](https://github.com/tldraw/tldraw/releases/tag/v3.6.1)
|
||||
58
apps/docs/content/releases/v3.7.0.mdx
Normal file
58
apps/docs/content/releases/v3.7.0.mdx
Normal file
@@ -0,0 +1,58 @@
|
||||
---
|
||||
title: 'v3.7.0'
|
||||
created_at: 7/1/2025
|
||||
updated_at: 7/1/2025
|
||||
keywords:
|
||||
- changelog
|
||||
- release
|
||||
- v3.7
|
||||
- v3.7.0
|
||||
- v3.7.1
|
||||
- v3.7.2
|
||||
- presence
|
||||
- sync
|
||||
- performance
|
||||
status: published
|
||||
---
|
||||
|
||||
This release delivers bug fixes and performance enhancements, including customization options for presence data synchronization.
|
||||
|
||||
## What's new
|
||||
|
||||
### Presence sync customization ([#5149](https://github.com/tldraw/tldraw/pull/5149))
|
||||
|
||||
Developers can now customize what presence data is synced between clients, or disable presence syncing entirely. This enables optimization for high-latency connections and privacy-focused applications.
|
||||
|
||||
## API changes
|
||||
|
||||
- Allow `expandSelectionOutlinePx` to return a `Box` object to enable asymmetric expansion across different sides.
|
||||
- Modify behavior when using `editor.updateShape` to properly handle undefined values.
|
||||
|
||||
## Bug fixes
|
||||
|
||||
- Fix React Strict Mode listener bug affecting `store.listen`.
|
||||
- Fix image rotation and cropping functionality issues.
|
||||
- Fix CSS import failures during export/print operations.
|
||||
- Fix text measurement div leakage during development hot reloads.
|
||||
- Fix popover menu viewport overflow problems.
|
||||
- Fix GitHub Gist URL validation.
|
||||
- Fix image dimension limits for pasted content.
|
||||
- Fix pages menu and quick actions rendering.
|
||||
|
||||
[View release on GitHub](https://github.com/tldraw/tldraw/releases/tag/v3.7.0)
|
||||
|
||||
---
|
||||
|
||||
## Patch releases
|
||||
|
||||
### v3.7.1
|
||||
|
||||
- Pass custom migrations to `useLocalStore`. ([#5204](https://github.com/tldraw/tldraw/pull/5204))
|
||||
|
||||
[View release on GitHub](https://github.com/tldraw/tldraw/releases/tag/v3.7.1)
|
||||
|
||||
### v3.7.2
|
||||
|
||||
- Fix pasting files copied from the local filesystem with cmd-v. ([#5225](https://github.com/tldraw/tldraw/pull/5225))
|
||||
|
||||
[View release on GitHub](https://github.com/tldraw/tldraw/releases/tag/v3.7.2)
|
||||
58
apps/docs/content/releases/v3.8.0.mdx
Normal file
58
apps/docs/content/releases/v3.8.0.mdx
Normal file
@@ -0,0 +1,58 @@
|
||||
---
|
||||
title: 'v3.8.0'
|
||||
created_at: 12/2/2025
|
||||
updated_at: 12/2/2025
|
||||
keywords:
|
||||
- changelog
|
||||
- release
|
||||
- v3.8
|
||||
- v3.8.0
|
||||
- v3.8.1
|
||||
- performance
|
||||
- i18n
|
||||
- react-19
|
||||
- export
|
||||
status: published
|
||||
---
|
||||
|
||||
This release delivers performance enhancements, UI improvements, and developer experience upgrades alongside bug fixes.
|
||||
|
||||
## API changes
|
||||
|
||||
- 💥 **`maxDrawShapePoints`** moved to `DrawShapeUtil.configure({ maxPoints })`.
|
||||
- 💥 **Asset upload** now returns `{ src, meta? }` object instead of string.
|
||||
- 💥 **JSON export option** removed; `useImageOrVideoAssetUrl` requires width parameter.
|
||||
- 💥 **`TldrawUiToasts` and `TldrawUiDialogs`** renamed to `DefaultToasts` and `DefaultDialogs`.
|
||||
- Add new `Editor.toImage` method for refined export functionality.
|
||||
- Add custom content handlers for pasted tldraw and excalidraw content.
|
||||
- Add "before-event" listener for custom input event handling.
|
||||
- Add `onCrop` method for shape-specific cropping customization.
|
||||
|
||||
## Improvements
|
||||
|
||||
- Add `ShapeUtil.configure` utility for passing options to shape utilities.
|
||||
- Allow note shapes to resize by scale.
|
||||
- Add geometric tool shortcut (`g` key) for quick selection.
|
||||
- Add URL drag-and-drop to create bookmark shapes on canvas.
|
||||
- Expand support to 40+ languages via i18n enhancements.
|
||||
- Add React 19 compatibility.
|
||||
- Improve frame performance through computation relocation.
|
||||
|
||||
## Bug fixes
|
||||
|
||||
- Fix dialog and edit menu glitches.
|
||||
- Fix mousewheel scrolling on elements.
|
||||
- Fix image export width.
|
||||
- Fix text paste behavior via keyboard shortcuts.
|
||||
|
||||
[View release on GitHub](https://github.com/tldraw/tldraw/releases/tag/v3.8.0)
|
||||
|
||||
---
|
||||
|
||||
## Patch releases
|
||||
|
||||
### v3.8.1
|
||||
|
||||
- Fix `TldrawImage` not functioning properly with `format=png`. ([#5429](https://github.com/tldraw/tldraw/pull/5429))
|
||||
|
||||
[View release on GitHub](https://github.com/tldraw/tldraw/releases/tag/v3.8.1)
|
||||
39
apps/docs/content/releases/v3.9.0.mdx
Normal file
39
apps/docs/content/releases/v3.9.0.mdx
Normal file
@@ -0,0 +1,39 @@
|
||||
---
|
||||
title: 'v3.9.0'
|
||||
created_at: 3/3/2025
|
||||
updated_at: 3/3/2025
|
||||
keywords:
|
||||
- changelog
|
||||
- release
|
||||
- v3.9
|
||||
- v3.9.0
|
||||
- performance
|
||||
- cache
|
||||
- layout
|
||||
- atom-map
|
||||
status: published
|
||||
---
|
||||
|
||||
This release focuses on bug fixes and performance enhancements, laying groundwork for v3.10 features.
|
||||
|
||||
## API changes
|
||||
|
||||
- 💥 **`store.createSelectedComputedCache`** removed; use `store.createCache` with custom selector `computed` instead.
|
||||
- 💥 **`createComputerCache` API** modified: third argument now accepts options object with `areRecordsEqual` and `areResultsEqual` properties instead of single equality function.
|
||||
|
||||
## Improvements
|
||||
|
||||
- Improve layout operations including align, distribute, flip, and stack with better compatibility for arrows and overlapping shapes.
|
||||
- Add new `AtomMap` class to accelerate shape, binding, and record creation/deletion, available as a utility for building custom reactive maps.
|
||||
- Add horizontal padding to text-bound arrows for improved visual appearance.
|
||||
|
||||
## Bug fixes
|
||||
|
||||
- Fix pasted image size calculations.
|
||||
- Remove `canvas-size` dependency resolving bundler compatibility issues.
|
||||
- Fix SVG copy mimetype.
|
||||
- Fix text shape cloning crash.
|
||||
- Fix temporary image preview display during uploads.
|
||||
- Recognize `https://localhost` as development environment for licensing.
|
||||
|
||||
[View release on GitHub](https://github.com/tldraw/tldraw/releases/tag/v3.9.0)
|
||||
123
apps/docs/content/releases/v4.0.0.mdx
Normal file
123
apps/docs/content/releases/v4.0.0.mdx
Normal file
@@ -0,0 +1,123 @@
|
||||
---
|
||||
title: 'v4.0.0'
|
||||
created_at: 9/18/2025
|
||||
updated_at: 9/18/2025
|
||||
keywords:
|
||||
- changelog
|
||||
- release
|
||||
- v4.0
|
||||
- v4.0.0
|
||||
- v4.0.1
|
||||
- v4.0.2
|
||||
- v4.0.3
|
||||
- v4.0.4
|
||||
- starter-kits
|
||||
- cli
|
||||
- accessibility
|
||||
- licensing
|
||||
- rich-text
|
||||
status: published
|
||||
---
|
||||
|
||||
This major release introduces new starter kits, a CLI tool, WCAG 2.2 Level AA accessibility compliance, and significant API enhancements for building custom canvas applications.
|
||||
|
||||
## What's new
|
||||
|
||||
### npm create tldraw ([#6753](https://github.com/tldraw/tldraw/pull/6753))
|
||||
|
||||
A new CLI tool for quickly scaffolding tldraw projects. Run `npm create tldraw` to initialize projects using templates and starter kits.
|
||||
|
||||
### Starter kits
|
||||
|
||||
Five new MIT-licensed starter kits demonstrate different applications of tldraw's canvas:
|
||||
|
||||
- **[agent](https://tldraw.dev/starter-kits/agent)** - Cursor-style chatbot interface where conversations occur on the canvas with AI assistance
|
||||
- **[workflow](https://tldraw.dev/starter-kits/workflow)** - Node-and-wire visual programming interface for automation workflows and state machines
|
||||
- **[branching-chat](https://tldraw.dev/starter-kits/branching-chat)** - Multi-path conversation explorer for managing conversation branches
|
||||
- **[chat](https://tldraw.dev/starter-kits/chat)** - Canvas-based image annotation with integrated chatbot
|
||||
- **[multiplayer](https://tldraw.dev/starter-kits/multiplayer)** - Collaborative whiteboard foundation built on tldraw sync
|
||||
|
||||
### Licensing ([#6707](https://github.com/tldraw/tldraw/pull/6707))
|
||||
|
||||
Production deployments now require a license key. Development and localhost environments remain exempt. Available licenses include:
|
||||
|
||||
- Free 100-day trial license
|
||||
- Commercial license for commercial projects
|
||||
- Hobby license for non-commercial use
|
||||
|
||||
### Accessibility ([#6695](https://github.com/tldraw/tldraw/pull/6695))
|
||||
|
||||
Achieves WCAG 2.2 Level AA compliance with an accessibility mode, improved keyboard shortcuts, comprehensive aria-labels, and scrollable menus.
|
||||
|
||||
### Drag from toolbar ([#6681](https://github.com/tldraw/tldraw/pull/6681))
|
||||
|
||||
Create shapes by dragging directly from the toolbar, eliminating the click-then-draw workflow.
|
||||
|
||||
### Custom shape clipping ([#6649](https://github.com/tldraw/tldraw/pull/6649))
|
||||
|
||||
Shapes can now clip their children, enabling complex visual hierarchies and container-style shapes.
|
||||
|
||||
### Vertical toolbar support ([#6623](https://github.com/tldraw/tldraw/pull/6623))
|
||||
|
||||
The toolbar supports vertical orientation for alternative layout configurations.
|
||||
|
||||
### Rich text arrows ([#6594](https://github.com/tldraw/tldraw/pull/6594))
|
||||
|
||||
Arrow labels support rich text formatting including bold, italic, and text styling.
|
||||
|
||||
## API changes
|
||||
|
||||
- 💥 **CSS variables** now start with `--tl-` prefix. Update custom CSS: `--color-background` → `--tl-color-background`, `--space-4` → `--tl-space-4`. ([#6712](https://github.com/tldraw/tldraw/pull/6712))
|
||||
- 💥 **`Geometry2D.isLabel`** replaced with `excludeFromShapeBounds` flag. ([#6668](https://github.com/tldraw/tldraw/pull/6668))
|
||||
- 💥 **`open-url` event** now uses `destinationUrl` instead of `url`. ([#6641](https://github.com/tldraw/tldraw/pull/6641))
|
||||
- 💥 **Arrow shapes** now use `richText` instead of `text` property. Data migrates automatically, but direct access to `arrow.props.text` must update to `arrow.props.richText`. ([#6594](https://github.com/tldraw/tldraw/pull/6594))
|
||||
- 💥 **`@tldraw/ai` module** removed. Use the [agent starter kit](https://tldraw.dev/starter-kits/agent) instead.
|
||||
- 💥 **Event propagation behavior** changed. Use `editor.markEventAsHandled()` for custom event handling. ([#6592](https://github.com/tldraw/tldraw/pull/6592))
|
||||
- 💥 **Style panel** redesigned with legacy picker components removed. ([#6573](https://github.com/tldraw/tldraw/pull/6573))
|
||||
- 💥 **Toolbar CSS class names** updated for consistency. ([#6551](https://github.com/tldraw/tldraw/pull/6551))
|
||||
- Add custom messages in `TLSocketRoom` via `sendCustomMessage()` and `onCustomMessageReceived`. ([#6614](https://github.com/tldraw/tldraw/pull/6614)) (contributed by [@mootari](https://github.com/mootari))
|
||||
- Add `Editor.getShapesAtPoint()` with flexible margin options for precise hit testing. ([#6724](https://github.com/tldraw/tldraw/pull/6724))
|
||||
- Add `editor.toImageDataUrl()` for generating image data URLs directly from shapes. ([#6658](https://github.com/tldraw/tldraw/pull/6658))
|
||||
- Add `ShapeUtil.isExportBoundsContainer` and `canCull` methods for controlling export behavior and rendering optimization. ([#6649](https://github.com/tldraw/tldraw/pull/6649))
|
||||
- Add `EditorAtom` for managing UI state outside React components. ([#6687](https://github.com/tldraw/tldraw/pull/6687))
|
||||
- Add enhanced handle drag callbacks with timing events for custom drag interactions. ([#6702](https://github.com/tldraw/tldraw/pull/6702))
|
||||
|
||||
## Bug fixes
|
||||
|
||||
- Fix arrow labels being cut off in exports. ([#6783](https://github.com/tldraw/tldraw/pull/6783))
|
||||
- Fix PNG physical pixel dimensions parsing. ([#6771](https://github.com/tldraw/tldraw/pull/6771))
|
||||
- Fix pointer events on mobile firing inconsistently. ([#6738](https://github.com/tldraw/tldraw/pull/6738))
|
||||
- Fix Chrome embed positioning at non-100% zoom levels. ([#6724](https://github.com/tldraw/tldraw/pull/6724))
|
||||
- Fix hollow shapes not being properly bound-to by arrows. ([#6705](https://github.com/tldraw/tldraw/pull/6705))
|
||||
|
||||
[View release on GitHub](https://github.com/tldraw/tldraw/releases/tag/v4.0.0)
|
||||
|
||||
---
|
||||
|
||||
## Patch releases
|
||||
|
||||
### v4.0.1
|
||||
|
||||
- Fix template exports. ([#6804](https://github.com/tldraw/tldraw/pull/6804))
|
||||
- Fix documentation. ([#6805](https://github.com/tldraw/tldraw/pull/6805))
|
||||
|
||||
[View release on GitHub](https://github.com/tldraw/tldraw/releases/tag/v4.0.1)
|
||||
|
||||
### v4.0.2
|
||||
|
||||
- Fix focus mode SDK issue that caused problems when using focus mode in the SDK. ([#6815](https://github.com/tldraw/tldraw/pull/6815))
|
||||
|
||||
[View release on GitHub](https://github.com/tldraw/tldraw/releases/tag/v4.0.2)
|
||||
|
||||
### v4.0.3
|
||||
|
||||
- Add Claude 4.5 Sonnet to agent starter kit. ([#6852](https://github.com/tldraw/tldraw/pull/6852))
|
||||
- Ensure templates don't use internal functions. ([#6871](https://github.com/tldraw/tldraw/pull/6871))
|
||||
|
||||
[View release on GitHub](https://github.com/tldraw/tldraw/releases/tag/v4.0.3)
|
||||
|
||||
### v4.0.4
|
||||
|
||||
- Fix shape index jitter for the first shape created, preventing potential index collisions. ([#6884](https://github.com/tldraw/tldraw/pull/6884))
|
||||
|
||||
[View release on GitHub](https://github.com/tldraw/tldraw/releases/tag/v4.0.4)
|
||||
78
apps/docs/content/releases/v4.1.0.mdx
Normal file
78
apps/docs/content/releases/v4.1.0.mdx
Normal file
@@ -0,0 +1,78 @@
|
||||
---
|
||||
title: 'v4.1.0'
|
||||
created_at: 10/15/2025
|
||||
updated_at: 10/15/2025
|
||||
keywords:
|
||||
- changelog
|
||||
- release
|
||||
- v4.1
|
||||
- v4.1.0
|
||||
- v4.1.1
|
||||
- v4.1.2
|
||||
- shader
|
||||
- webgl
|
||||
- starter-kit
|
||||
- embed
|
||||
- minimap
|
||||
status: published
|
||||
---
|
||||
|
||||
This month's release introduces a new shader starter kit for WebGL integration, along with API additions for embed control, minimap filtering, and localStorage-backed atoms.
|
||||
|
||||
## What's new
|
||||
|
||||
### Shader starter kit ([#6847](https://github.com/tldraw/tldraw/pull/6847))
|
||||
|
||||
A new template demonstrating WebGL shader integration with tldraw for dynamic, interactive backgrounds that respond to canvas interactions. Includes four complete examples:
|
||||
|
||||
- **Fluid simulation** - Navier-Stokes dynamics
|
||||
- **Rainbow** - Animated gradients
|
||||
- **Shadows** - Raymarching and signed distance fields
|
||||
- **Minimal** - Bare-bones template for custom shaders
|
||||
|
||||
Features configurable parameters, localStorage persistence, and full TypeScript support.
|
||||
|
||||
## API changes
|
||||
|
||||
- Add `embedOnPaste` option to `EmbedDefinition` for opting out of automatic embed-on-paste behavior. ([#6874](https://github.com/tldraw/tldraw/pull/6874))
|
||||
- Add `hideInMinimap` option to `ShapeUtil` for excluding shapes from the minimap. ([#6327](https://github.com/tldraw/tldraw/pull/6327))
|
||||
- Add `snapType?: 'point' | 'align'` to `TLHandle` for finer control over handle snapping. The `canSnap` property is now deprecated. ([#6883](https://github.com/tldraw/tldraw/pull/6883))
|
||||
- Expose `Editor#getShapePageBounds` and `isRecordsDiffEmpty` as public methods. ([#6871](https://github.com/tldraw/tldraw/pull/6871))
|
||||
- Add `localStorageAtom` to `@tldraw/state` for atoms backed by localStorage with cross-tab syncing. ([#6876](https://github.com/tldraw/tldraw/pull/6876))
|
||||
- Add inline style picker component variants. ([#6920](https://github.com/tldraw/tldraw/pull/6920))
|
||||
|
||||
## Improvements
|
||||
|
||||
- Make bookmark titles clickable. ([#6326](https://github.com/tldraw/tldraw/pull/6326))
|
||||
- Optimize mobile top bar rendering. ([#6895](https://github.com/tldraw/tldraw/pull/6895))
|
||||
- Add numpad Enter support for entering edit mode. ([#6830](https://github.com/tldraw/tldraw/pull/6830))
|
||||
- Consolidate preferences menu items. ([#6802](https://github.com/tldraw/tldraw/pull/6802))
|
||||
|
||||
## Bug fixes
|
||||
|
||||
- Fix accessibility shortcut typo. ([#6811](https://github.com/tldraw/tldraw/pull/6811))
|
||||
- Fix focus mode action missing. ([#6812](https://github.com/tldraw/tldraw/pull/6812))
|
||||
- Fix arrow rendering edge case. ([#6799](https://github.com/tldraw/tldraw/pull/6799))
|
||||
- Fix empty window opening on anchor click. ([#6817](https://github.com/tldraw/tldraw/pull/6817))
|
||||
- Fix missing translation key. ([#6826](https://github.com/tldraw/tldraw/pull/6826))
|
||||
- Fix shape index race condition. ([#6884](https://github.com/tldraw/tldraw/pull/6884))
|
||||
- Fix zero geometry vertices edge case. ([#6885](https://github.com/tldraw/tldraw/pull/6885))
|
||||
- Fix style panel button overflow. ([#6920](https://github.com/tldraw/tldraw/pull/6920))
|
||||
|
||||
[View release on GitHub](https://github.com/tldraw/tldraw/releases/tag/v4.1.0)
|
||||
|
||||
---
|
||||
|
||||
## Patch releases
|
||||
|
||||
### v4.1.1
|
||||
|
||||
- Update the formatting of the `npm create tldraw` command in the CLI output.
|
||||
|
||||
[View release on GitHub](https://github.com/tldraw/tldraw/releases/tag/v4.1.1)
|
||||
|
||||
### v4.1.2
|
||||
|
||||
- Move `InFrontOfTheCanvas` component back out of the `.tl-canvas` element. ([#7021](https://github.com/tldraw/tldraw/pull/7021))
|
||||
|
||||
[View release on GitHub](https://github.com/tldraw/tldraw/releases/tag/v4.1.2)
|
||||
100
apps/docs/content/releases/v4.2.0.mdx
Normal file
100
apps/docs/content/releases/v4.2.0.mdx
Normal file
@@ -0,0 +1,100 @@
|
||||
---
|
||||
title: 'v4.2.0'
|
||||
created_at: 11/19/2025
|
||||
updated_at: 11/19/2025
|
||||
keywords:
|
||||
- changelog
|
||||
- release
|
||||
- v4.2
|
||||
- v4.2.0
|
||||
- v4.2.1
|
||||
- tiptap
|
||||
- rich-text
|
||||
- sync
|
||||
- api
|
||||
status: published
|
||||
---
|
||||
|
||||
This month's release includes many bug fixes and small API additions, along with a major version bump of our TipTap rich text editor.
|
||||
|
||||
## What's new
|
||||
|
||||
### 💥 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))
|
||||
- Allow using custom socket implementations with `useSync` via a new `connect` option. ([#6859](https://github.com/tldraw/tldraw/pull/6859)) (contributed by [@Digital39999](https://github.com/Digital39999))
|
||||
- Add support for nested property queries in the store. ([#6981](https://github.com/tldraw/tldraw/pull/6981))
|
||||
- Add `createBookmarkFromUrl` helper for easier bookmark shape creation. ([#6894](https://github.com/tldraw/tldraw/pull/6894))
|
||||
- Add custom reference points for handle angle snapping. ([#6987](https://github.com/tldraw/tldraw/pull/6987)) (contributed by [@naaa760](https://github.com/naaa760))
|
||||
- Add new "lined-fill" style, accessible via Option+Shift+F. ([#7034](https://github.com/tldraw/tldraw/pull/7034))
|
||||
- Add `onInteractionEnd` callback option. ([#6919](https://github.com/tldraw/tldraw/pull/6919))
|
||||
- Add `allowReferrer` option for window opening functions. ([#7004](https://github.com/tldraw/tldraw/pull/7004))
|
||||
- Remove defunct Excalidraw embed definition. ([#6897](https://github.com/tldraw/tldraw/pull/6897))
|
||||
|
||||
## Improvements
|
||||
|
||||
- Use window.prompt for renaming frames on mobile. ([#6838](https://github.com/tldraw/tldraw/pull/6838))
|
||||
- Optimize string append operations in sync protocol. ([#7007](https://github.com/tldraw/tldraw/pull/7007)) (contributed by [@quasor](https://github.com/quasor))
|
||||
|
||||
## Bug fixes
|
||||
|
||||
- Fix Replit embed URL handling. ([#6892](https://github.com/tldraw/tldraw/pull/6892))
|
||||
- Fix accessibility issue that caused a misleading warning in the console. ([#6904](https://github.com/tldraw/tldraw/pull/6904))
|
||||
- Fix convert-to-bookmark action. ([#6894](https://github.com/tldraw/tldraw/pull/6894))
|
||||
- Fix dropdown menu pointer handling. ([#7021](https://github.com/tldraw/tldraw/pull/7021))
|
||||
- Fix iOS Safari image uploads. ([#7037](https://github.com/tldraw/tldraw/pull/7037))
|
||||
- Fix Alt+Tab conflict on Windows. ([#7005](https://github.com/tldraw/tldraw/pull/7005))
|
||||
- Fix text focus issues. ([#7043](https://github.com/tldraw/tldraw/pull/7043))
|
||||
- Fix `TldrawEditor` usability without `TldrawUiContextProvider`. ([#7053](https://github.com/tldraw/tldraw/pull/7053))
|
||||
- Fix cross-realm clipboard functionality. ([#7026](https://github.com/tldraw/tldraw/pull/7026))
|
||||
- Fix iOS ink rendering via coalesced events. ([#6917](https://github.com/tldraw/tldraw/pull/6917))
|
||||
- Fix menu-open dragging behavior. ([#6918](https://github.com/tldraw/tldraw/pull/6918))
|
||||
|
||||
[View release on GitHub](https://github.com/tldraw/tldraw/releases/tag/v4.2.0)
|
||||
|
||||
---
|
||||
|
||||
## Patch releases
|
||||
|
||||
### v4.2.1
|
||||
|
||||
- Fix CommonJS/cjs builds that were broken in v4.2.0. ([#7282](https://github.com/tldraw/tldraw/pull/7282))
|
||||
|
||||
[View release on GitHub](https://github.com/tldraw/tldraw/releases/tag/v4.2.1)
|
||||
317
apps/docs/content/releases/v4.3.0.mdx
Normal file
317
apps/docs/content/releases/v4.3.0.mdx
Normal file
@@ -0,0 +1,317 @@
|
||||
---
|
||||
title: v4.3.0
|
||||
description: Examples
|
||||
author: tldraw
|
||||
date: 1/21/2026
|
||||
order: 0
|
||||
status: published
|
||||
---
|
||||
|
||||
[View on GitHub](https://github.com/tldraw/tldraw/releases/tag/v4.3.0)
|
||||
|
||||
This release introduces several significant changes: a new pattern for defining custom shape/binding typings, pluggable storage for `TLSocketRoom` with a new SQLite option, reactive `editor.inputs`, and optimized draw shape encoding. It also adds various other API improvements, performance optimizations, and bug fixes, including better support for React 19.
|
||||
|
||||
---
|
||||
|
||||
### 💥 New pattern for defining custom shape/binding types ([#7091](https://github.com/tldraw/tldraw/pull/7091))
|
||||
|
||||
We've improved the developer experience of working with custom shape and binding types. There's now less boilerplate and fewer gotchas when using tldraw APIs in a type-safe manner.
|
||||
|
||||
This is a minor breaking change at the type level—your code will still run, but you'll get TypeScript errors until you migrate.
|
||||
|
||||
<details>
|
||||
<summary>Migration guide</summary>
|
||||
|
||||
When _declaring_ types for custom shapes, you can now use TypeScript's module augmentation feature to provide more specific types for the custom shape.
|
||||
|
||||
Before:
|
||||
|
||||
```ts
|
||||
import { TLBaseShape } from 'tldraw'
|
||||
|
||||
// Shapes were defined by using the helper TLBaseShape type
|
||||
type MyShape = TLBaseShape`<'my-shape', { w: number; h: number; text: string }>`
|
||||
```
|
||||
|
||||
After:
|
||||
|
||||
```ts
|
||||
import { TLShape } from 'tldraw'
|
||||
|
||||
const MY_SHAPE = 'my-shape'
|
||||
|
||||
// We now use TypeScript's module augmentation feature to allow
|
||||
// extending the builtin TLShape type.
|
||||
declare module 'tldraw' {
|
||||
export interface TLGlobalShapePropsMap {
|
||||
[MY_SHAPE]: { w: number; h: number; text: string }
|
||||
}
|
||||
}
|
||||
|
||||
type MyShape = TLShape`<typeof MY_SHAPE>`
|
||||
```
|
||||
|
||||
The benefit of this new system is that Editor APIs such as `createShape` now know about your custom shapes automatically:
|
||||
|
||||
```ts
|
||||
// Just works - TypeScript validates props and provides autocomplete
|
||||
editor.createShape({ type: 'my-shape', props: { w: 100, h: 100, text: 'Hello' } })
|
||||
|
||||
// Will cause a TypeScript error for `text`
|
||||
editor.createShape({ type: 'my-shape', props: { w: 100, h: 100, text: 123 } })
|
||||
```
|
||||
|
||||
The same pattern applies to custom bindings — augment `TLGlobalBindingPropsMap` and use `TLBinding<'binding-name'>`:
|
||||
|
||||
```ts
|
||||
declare module 'tldraw' {
|
||||
export interface TLGlobalBindingPropsMap {
|
||||
'my-binding': { offset: number }
|
||||
}
|
||||
}
|
||||
|
||||
type MyBinding = TLBinding`<'my-binding'>`
|
||||
```
|
||||
|
||||
See the [Custom Shapes Guide](https://tldraw.dev/docs/shapes#Custom-shapes) and the [Pin Bindings example](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/shapes/tools/pin-bindings) for details.
|
||||
|
||||
**After registering, remove the `TLBaseShape` import** — it's no longer used and will trip lint rules that flag unused imports.
|
||||
|
||||
**Shape type names are global.** `TLGlobalShapePropsMap` is a single shared registry, so two custom shapes that previously used the same type name in different files now collide. Pick a unique name for each, and remember the rename ripples beyond the type alias:
|
||||
|
||||
- `static override type = '...'` on the `ShapeUtil` subclass
|
||||
- Every `editor.createShape({ type: '...' })` and `editor.updateShape({ type: '...' })` call site
|
||||
- The `TLGlobalShapePropsMap` entry itself
|
||||
- Any persisted snapshot or migration JSON that references the old name
|
||||
|
||||
**Use `as const` on the static `type` field.** TypeScript may widen `static override type = 'my-shape'` to `string`, which fails the new constraint. Add `as const` to keep it a string literal:
|
||||
|
||||
```ts
|
||||
class MyShapeUtil extends ShapeUtil`<MyShape>` {
|
||||
static override type = 'my-shape' as const
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
The same fix applies to `BaseBoxShapeTool` (and other tool) subclasses, which expose `static override shapeType = '...'`. If you see TS2416 on a tool's `shapeType`, add `as const` there too. The two fields are easy to confuse — check both.
|
||||
|
||||
**Heterogeneous `createShapes`/`updateShapes` arrays may need a cast.** Because `TLShape` is now a discriminated union over the global props map, a mapped array of mixed shape types no longer narrows automatically. For homogeneous arrays where every item shares one shape type, `as const` on the `type` field in the mapped object literal is enough. For genuinely heterogeneous arrays, go straight to `as TLShapePartial[]` (or `as TLCreateShapePartial[]` for `createShapes`):
|
||||
|
||||
```ts
|
||||
// Heterogeneous update — the union doesn't narrow
|
||||
editor.updateShapes(
|
||||
shapes.map((s) => ({ id: s.id, type: s.type, x: s.x + 10 })) as TLShapePartial[]
|
||||
)
|
||||
```
|
||||
|
||||
Don't reach for `satisfies TLShapePartial` here — `TLShapePartial` is distributive over the `TLShape` union, so `satisfies` enforces a literal-per-variant constraint that mapped-over heterogeneous shapes can't meet. The cast is the right tool for this specific shape of code.
|
||||
|
||||
</details>
|
||||
|
||||
(contributed by [@Andarist](https://github.com/Andarist))
|
||||
|
||||
---
|
||||
|
||||
### Pluggable storage for TLSocketRoom + SQLite support ([#7320](https://github.com/tldraw/tldraw/pull/7320), [#7123](https://github.com/tldraw/tldraw/pull/7123))
|
||||
|
||||
We've refactored the `TLSocketRoom` API to support a pluggable storage layer. We're providing two implementations:
|
||||
|
||||
- **`SQLiteSyncStorage`** – Automatically persists room state to SQLite. Recommended for production.
|
||||
- **`InMemorySyncStorage`** – Keeps state in memory with manual persistence via callbacks (previous built-in behavior).
|
||||
|
||||
**We recommend switching to `SQLiteSyncStorage` if your environment supports SQLite** (Cloudflare Durable Objects, Node.js, Bun, Deno). It provides automatic persistence, lower memory usage, and faster startup times.
|
||||
|
||||
<details>
|
||||
<summary>Why SQLite?</summary>
|
||||
|
||||
- **Automatic persistence**: Data survives process restarts without manual snapshot handling
|
||||
- **Lower memory usage**: No need to keep entire documents in memory
|
||||
- **Faster startup**: No need to load the document into memory before accepting socket connections
|
||||
- **Simpler code**: No more `onChange` callbacks and manual persistence logic
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Platform support</summary>
|
||||
|
||||
| Platform | Wrapper | SQLite Library |
|
||||
| -------------------------- | -------------------------------- | --------------------------------- |
|
||||
| Cloudflare Durable Objects | `DurableObjectSqliteSyncWrapper` | Built-in `ctx.storage` |
|
||||
| Node.js/Deno | `NodeSqliteWrapper` | `better-sqlite3` or `node:sqlite` |
|
||||
|
||||
See the [Cloudflare template](https://github.com/tldraw/tldraw-sync-cloudflare) and the [Node server example](https://github.com/tldraw/tldraw/tree/main/templates/simple-server-example) respectively. Bun support should be straightforward to add.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Migration guide</summary>
|
||||
|
||||
Existing code continues to work, however we have deprecated the following `TLSocketRoom` options:
|
||||
|
||||
- `initialSnapshot`
|
||||
- `onDataChange`
|
||||
|
||||
These are replaced by the new `storage` option. We've also deprecated the `TLSocketRoom.updateStore` method, which has been supplanted by `storage.transaction`.
|
||||
|
||||
Before:
|
||||
|
||||
```ts
|
||||
const existingSnapshot = loadExistingSnapshot()
|
||||
|
||||
const room = new TLSocketRoom({
|
||||
initialSnapshot: existingSnapshot,
|
||||
onDataChange: () => {
|
||||
persistSnapshot(room.getCurrentSnapshot())
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
If you want to keep the same behavior with in-memory document storage and manual persistence:
|
||||
|
||||
```ts
|
||||
import { InMemorySyncStorage, TLSocketRoom } from '@tldraw/sync-core'
|
||||
|
||||
const room = new TLSocketRoom({
|
||||
storage: new InMemorySyncStorage({
|
||||
snapshot: existingSnapshot,
|
||||
onChange() {
|
||||
saveToDatabase(storage.getSnapshot())
|
||||
},
|
||||
}),
|
||||
})
|
||||
```
|
||||
|
||||
However, we recommend switching to SQLite. Users of our Cloudflare template should follow the migration guide on the [sync docs page](https://tldraw.dev/docs/sync).
|
||||
|
||||
If you're using `TLSocketRoom` on Node, creating the room should end up looking something like this:
|
||||
|
||||
```ts
|
||||
import Database from 'better-sqlite3'
|
||||
import { SQLiteSyncStorage, NodeSqliteWrapper, TLSocketRoom, RoomSnapshot } from '@tldraw/sync-core'
|
||||
|
||||
async function createRoom(roomId: string) {
|
||||
const db = new Database(`path/to/${roomId}.db`)
|
||||
const sql = new NodeSqliteWrapper(db)
|
||||
let snapshot: RoomSnapshot | undefined = undefined
|
||||
if (!SQLiteSyncStorage.hasBeenInitialized(sql)) {
|
||||
// This db hasn't been used before, so if it's a pre-existing
|
||||
// document, load the legacy room snapshot
|
||||
snapshot = await loadExistingSnapshot()
|
||||
}
|
||||
|
||||
const storage = new SQLiteSyncStorage({ sql, snapshot })
|
||||
return new TLSocketRoom({
|
||||
storage,
|
||||
onSessionRemoved(room, args) {
|
||||
if (args.numSessionsRemaining === 0) {
|
||||
room.close()
|
||||
db.close()
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
### Optimized draw shape encoding ([#7364](https://github.com/tldraw/tldraw/pull/7364), [#7710](https://github.com/tldraw/tldraw/pull/7710))
|
||||
|
||||
Draw and highlight shape point data is now stored using a compact delta-encoded binary format instead of JSON arrays. This reduces storage size by approximately 80% while preserving stroke fidelity.
|
||||
|
||||
<details>
|
||||
<summary>Breaking change details</summary>
|
||||
|
||||
If you were reading or writing draw shape data programmatically you might need to update your code to use the new format.
|
||||
|
||||
- `TLDrawShapeSegment.points` renamed to `.path` and changed from `VecModel[]` to `string` (base64-encoded)
|
||||
- Added `scaleX` and `scaleY` properties to draw and highlight shapes
|
||||
- New exports: `b64Vecs` encoding utilities, e.g. `getPointsFromDrawSegment` helper. Use this if you need to manually read/write point data.
|
||||
|
||||
Existing documents are automatically migrated.
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
### Reactive inputs ([#7312](https://github.com/tldraw/tldraw/pull/7312))
|
||||
|
||||
Refactored `editor.inputs` to use reactive atoms via the new `InputsManager` class. All input state is now accessed via getter methods (e.g., `editor.inputs.getCurrentPagePoint()`, `editor.inputs.getShiftKey()`). Direct property access is deprecated but still supported for backwards compatibility.
|
||||
|
||||
## API changes
|
||||
|
||||
- 💥 **`DefaultTopPanel`** export removed from `tldraw`. The top panel component for displaying the offline indicator is now handled internally by `PeopleMenu`. ([#7568](https://github.com/tldraw/tldraw/pull/7568))
|
||||
- 💥 **`TextDirection`** export removed from `tldraw`. Use TipTap's native `TextDirection` extension instead. The `richTextValidator` now includes an optional `attrs` property - a migration may be necessary for older clients/custom shapes. ([#7304](https://github.com/tldraw/tldraw/pull/7304))
|
||||
- Add `tlenvReactive` atom to `@tldraw/editor` for reactive environment state tracking, including coarse pointer detection that updates when users switch between mouse and touch input. ([#7296](https://github.com/tldraw/tldraw/pull/7296))
|
||||
- Add `hideAllTooltips()` helper function for programmatically dismissing tooltips. ([#7288](https://github.com/tldraw/tldraw/pull/7288))
|
||||
- Add `zoomToFitPadding` option to `TldrawOptions` to customize the default padding used by zoom-to-fit operations. ([#7602](https://github.com/tldraw/tldraw/pull/7602))
|
||||
- Add `snapThreshold` option to `TldrawOptions` for configuring the snap distance, defaulting to 8 screen pixels. ([#7543](https://github.com/tldraw/tldraw/pull/7543))
|
||||
- Add `resizeChildren` configuration option to `FrameShapeUtil` to allow frame children to be resized proportionally when the frame is resized. ([#7526](https://github.com/tldraw/tldraw/pull/7526))
|
||||
- Add `Editor.canEditShape()` and `Editor.canCropShape()` methods to centralize shape permission checks. Add `ShapeUtil.canEditWhileLocked()` for shapes that remain editable when locked. ([#7361](https://github.com/tldraw/tldraw/pull/7361))
|
||||
- Add `editor.getDebouncedZoomLevel()` and `editor.getEfficientZoomLevel()` methods for improved zoom performance on dense canvases. Add `debouncedZoom` and `debouncedZoomThreshold` options. ([#7235](https://github.com/tldraw/tldraw/pull/7235))
|
||||
- Add configurable `showTextOutline` option to `TextShapeUtil`, `ArrowShapeUtil`, and `GeoShapeUtil` via `.configure()` pattern. ([#7314](https://github.com/tldraw/tldraw/pull/7314))
|
||||
- Export freehand stroke utilities: `getStroke`, `getStrokeOutlinePoints`, and `setStrokePointRadii`. ([#7400](https://github.com/tldraw/tldraw/pull/7400)) (contributed by [@VimHax](https://github.com/VimHax))
|
||||
- Add `Box.isValid()` method to check for finite coordinates. ([#7532](https://github.com/tldraw/tldraw/pull/7532))
|
||||
- Add `spacebarPanning` option to control whether spacebar activates pan mode. ([#7312](https://github.com/tldraw/tldraw/pull/7312))
|
||||
- Introduce pluggable `TLSyncStorage` API for `TLSocketRoom`. The `initialSnapshot` and `onDataChange` options are now deprecated in favor of the new `storage` option. ([#7123](https://github.com/tldraw/tldraw/pull/7123))
|
||||
- Export `DefaultLabelColorStyle` which is necessary for rich text in custom shapes. ([#7114](https://github.com/tldraw/tldraw/pull/7114))
|
||||
|
||||
### Improvements
|
||||
|
||||
- Improve coarse pointer detection by replacing CSS media queries with a reactive `data-coarse` attribute that updates when users switch between mouse and touch input. ([#7404](https://github.com/tldraw/tldraw/pull/7404))
|
||||
- Add CSS containment to main toolbar and text measurement element for improved rendering performance. ([#7406](https://github.com/tldraw/tldraw/pull/7406)) ([#7407](https://github.com/tldraw/tldraw/pull/7407))
|
||||
- Improve cross-realm support by scoping canvas event listeners to the editor container's ownerDocument. ([#7113](https://github.com/tldraw/tldraw/pull/7113))
|
||||
- Simplify ImmutableMap implementation for better code clarity. ([#7431](https://github.com/tldraw/tldraw/pull/7431))
|
||||
- Improve code readability in number validator by using `Number.isFinite()` instead of arithmetic trick. ([#7374](https://github.com/tldraw/tldraw/pull/7374))
|
||||
- Upgrade to React 19 with all necessary type and configuration changes for compatibility. ([#7317](https://github.com/tldraw/tldraw/pull/7317))
|
||||
- Improve signal graph traversal performance by eliminating per-recursion closure allocations. ([#7430](https://github.com/tldraw/tldraw/pull/7430))
|
||||
- Optimize object utility functions. ([#7432](https://github.com/tldraw/tldraw/pull/7432))
|
||||
- Use in-place sorting in parentsToChildren derivation. ([#7433](https://github.com/tldraw/tldraw/pull/7433))
|
||||
- Optimize notVisibleShapes derivation with inlined bounds checks. ([#7429](https://github.com/tldraw/tldraw/pull/7429))
|
||||
- Cache label size measurements with WeakCache for improved geo shape performance. ([#7412](https://github.com/tldraw/tldraw/pull/7412))
|
||||
- Optimize validators with fast paths and production inlining. ([#7373](https://github.com/tldraw/tldraw/pull/7373))
|
||||
|
||||
### Bug fixes
|
||||
|
||||
- Fix migrations for draw and highlight shapes to be idempotent, preventing errors when migrations run multiple times. ([#7389](https://github.com/tldraw/tldraw/pull/7389))
|
||||
- Fix dot detection in draw and highlight shapes after the point compression change. ([#7365](https://github.com/tldraw/tldraw/pull/7365))
|
||||
- Fix clicking a shape's text label while editing to re-focus the input and select all text. ([#7342](https://github.com/tldraw/tldraw/pull/7342))
|
||||
- Fix pasting at cursor to correctly account for frames and parent containers. ([#7277](https://github.com/tldraw/tldraw/pull/7277))
|
||||
- Fix editing mode to exit when dragging causes the text input to blur. ([#7291](https://github.com/tldraw/tldraw/pull/7291))
|
||||
- Fix CommonJS build issues with TipTap imports in rich text module. ([#7282](https://github.com/tldraw/tldraw/pull/7282))
|
||||
- Fix iOS automatically zooming in on input fields by ensuring 16px minimum font size. ([#7118](https://github.com/tldraw/tldraw/pull/7118))
|
||||
- Fix `distanceToLineSegment` returning squared distance instead of actual distance, causing hit testing (eraser, scribble select) to be too strict. ([#7610](https://github.com/tldraw/tldraw/pull/7610)) (contributed by [@arpit-goblins](https://github.com/arpit-goblins))
|
||||
- Fix `zoomToSelection` to toggle between 100% zoom and zoom-to-fit behavior. ([#7536](https://github.com/tldraw/tldraw/pull/7536))
|
||||
- Fix export of SVG markers by handling fragment-only URLs correctly. ([#7506](https://github.com/tldraw/tldraw/pull/7506)) (contributed by [@PidgeyBE](https://github.com/PidgeyBE))
|
||||
- Restore wheel and pinch canvas event emission. ([#6834](https://github.com/tldraw/tldraw/pull/6834)) (contributed by [@swdev33](https://github.com/swdev33))
|
||||
- Fix context menu submenu flickering when hovered. ([#6837](https://github.com/tldraw/tldraw/pull/6837)) (contributed by [@swdev33](https://github.com/swdev33))
|
||||
- Fix rotated shape positions not being restored after flipping twice. ([#7359](https://github.com/tldraw/tldraw/pull/7359))
|
||||
- Fix keyboard shortcuts and clipboard events not working when `hideUi` is true. ([#7367](https://github.com/tldraw/tldraw/pull/7367))
|
||||
- Fix elbow arrows routing incorrectly when using dynamic sizing at high zoom levels. ([#7424](https://github.com/tldraw/tldraw/pull/7424))
|
||||
- Allow fullscreen for embed shapes. ([#7417](https://github.com/tldraw/tldraw/pull/7417))
|
||||
- Move mobile rotate handle to bottom for image and video shapes to accommodate the contextual toolbar. ([#6727](https://github.com/tldraw/tldraw/pull/6727))
|
||||
- Fix dropdown menu items incorrectly displaying an icon on the right side. ([#7533](https://github.com/tldraw/tldraw/pull/7533))
|
||||
- Fix zoom menu showing debounced zoom level for canvases with many shapes. ([#7626](https://github.com/tldraw/tldraw/pull/7626))
|
||||
- Fix extra line appearing after bullet and ordered lists in text shapes. ([#7643](https://github.com/tldraw/tldraw/pull/7643)) (contributed by [@sahiee-dev](https://github.com/sahiee-dev))
|
||||
- Fix "Back to content" button not appearing when selected shapes are off-screen. ([#7649](https://github.com/tldraw/tldraw/pull/7649))
|
||||
- Fix dotted freehand lines becoming invisible at minimum zoom on Chrome. ([#7650](https://github.com/tldraw/tldraw/pull/7650))
|
||||
- Fix menu bar stretching to full width on mobile viewports instead of fitting content. ([#7568](https://github.com/tldraw/tldraw/pull/7568))
|
||||
|
||||
[View release on GitHub](https://github.com/tldraw/tldraw/releases/tag/v4.3.0)
|
||||
|
||||
---
|
||||
|
||||
## Patch releases
|
||||
|
||||
### v4.3.1
|
||||
|
||||
- Fix draw shape precision issues at high canvas coordinates by switching to delta encoding with a Float32 anchor point. ([#7710](https://github.com/tldraw/tldraw/pull/7710))
|
||||
|
||||
[View release on GitHub](https://github.com/tldraw/tldraw/releases/tag/v4.3.1)
|
||||
|
||||
### v4.3.2
|
||||
|
||||
- Fix crash when cropping custom shapes that don't include `isCircle` in their crop schema. ([#7931](https://github.com/tldraw/tldraw/pull/7931))
|
||||
|
||||
[View release on GitHub](https://github.com/tldraw/tldraw/releases/tag/v4.3.2)
|
||||
205
apps/docs/content/releases/v4.4.0.mdx
Normal file
205
apps/docs/content/releases/v4.4.0.mdx
Normal file
@@ -0,0 +1,205 @@
|
||||
---
|
||||
title: v4.4.0
|
||||
description: Consolidated options prop, quick zoom, fill styles dropdown, canvas indicators, R-tree indexing, and more
|
||||
author: tldraw
|
||||
date: 02/18/2026
|
||||
order: 0
|
||||
status: published
|
||||
keywords:
|
||||
- changelog
|
||||
- release
|
||||
- v4.4
|
||||
- v4.4.0
|
||||
- v4.4.1
|
||||
- options-prop
|
||||
- quick-zoom
|
||||
- canvas-indicators
|
||||
- r-tree
|
||||
- spatial-index
|
||||
- performance
|
||||
- laser-pointer
|
||||
- fill-styles
|
||||
- select-component
|
||||
- image-pipeline
|
||||
- agent-template
|
||||
- invert-zoom
|
||||
---
|
||||
|
||||
[View on GitHub](https://github.com/tldraw/tldraw/releases/tag/v4.4.0)
|
||||
|
||||
This release adds a consolidated `options` prop, quick zoom navigation, a fill styles dropdown, a new `TldrawUiSelect` component, and shape-aware binding checks. It also includes 2D canvas rendering for shape indicators, R-tree spatial indexing, telestrator-style laser behavior, significant performance improvements for large canvases, and various bug fixes.
|
||||
|
||||
## What's new
|
||||
|
||||
### 2D canvas rendering for shape indicators ([#7708](https://github.com/tldraw/tldraw/pull/7708))
|
||||
|
||||
Shape indicators (selection outlines, hover states) now render using a 2D canvas instead of SVG elements. This significantly improves performance when selecting or hovering over many shapes, with up to 25x faster rendering in some scenarios.
|
||||
|
||||
Custom shapes can opt into canvas indicators by implementing the new `getIndicatorPath()` method on their ShapeUtil, and marking `useLegactIndicator` to return false:
|
||||
|
||||
```tsx
|
||||
class MyShapeUtil extends ShapeUtil<MyShape> {
|
||||
getIndicatorPath(shape: MyShape): TLIndicatorPath | undefined {
|
||||
return {
|
||||
path: new Path2D(),
|
||||
// optional clip path for complex shapes like arrows with labels
|
||||
}
|
||||
}
|
||||
|
||||
// Return false to use the new canvas indicators (default is true for backwards compatibility)
|
||||
useLegacyIndicator(): boolean {
|
||||
return false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Quick zoom navigation ([#7801](https://github.com/tldraw/tldraw/pull/7801), [#7836](https://github.com/tldraw/tldraw/pull/7836))
|
||||
|
||||
Press `z` then hold Shift to zoom out and see the whole canvas ("eagle eye" view). A viewport brush appears showing where you'll zoom to. Move the cursor to pick a location and release Shift to zoom there. Press Escape to cancel and return to the original view.
|
||||
|
||||
### Fill styles dropdown ([#7885](https://github.com/tldraw/tldraw/pull/7885))
|
||||
|
||||
The style panel now exposes additional fill styles (pattern, fill, lined-fill) through a dropdown picker after the solid button. The first three fill options (none, semi, solid) remain as inline buttons.
|
||||
|
||||
### User preference: Invert mouse wheel zoom direction ([#7732](https://github.com/tldraw/tldraw/pull/7732))
|
||||
|
||||
Added a new user preference to invert mouse wheel zoom direction. Some users prefer "natural" scrolling behavior where scrolling down zooms out, which this option now enables.
|
||||
|
||||
Access it via Menu → Preferences → Input device → Invert mouse zoom.
|
||||
|
||||
### Performance improvements ([#7676](https://github.com/tldraw/tldraw/pull/7676), [#7826](https://github.com/tldraw/tldraw/pull/7826), [#7840](https://github.com/tldraw/tldraw/pull/7840), [#7657](https://github.com/tldraw/tldraw/pull/7657))
|
||||
|
||||
This release includes several performance improvements for large canvases:
|
||||
|
||||
- **R-tree spatial indexing**: Shape queries now use an R-tree (RBush) for O(log n) lookups instead of O(n) iteration, significantly improving brushing, scribble selection, and erasing with many shapes on the canvas. The spatial index is maintained internally and accessed through existing methods like `editor.getShapesAtPoint()` and `editor.getShapeAtPoint()`.
|
||||
- **Faster panning**: Hover hit-testing is now skipped during camera movement, reducing work while panning through large documents.
|
||||
- **Reduced allocations**: Optimized Set comparisons, reduced memory allocations, and added string hash caching for better performance in large canvases and multiplayer rooms.
|
||||
- **Smarter network scheduling**: Solo-mode network traffic is reduced by throttling to 1 FPS when no collaborators are present.
|
||||
|
||||
### Re-designed laser pointer ([#7681](https://github.com/tldraw/tldraw/pull/7681))
|
||||
|
||||
The laser pointer now behaves like a telestrator: all strokes remain visible while you're drawing and fade together when you stop. Previously, each stroke segment would fade independently, creating a trailing effect.
|
||||
|
||||
This is powered by a new generic session system on `ScribbleManager`. Sessions group multiple scribbles together and control how they fade:
|
||||
|
||||
```tsx
|
||||
// Start a grouped session (used internally by the laser tool)
|
||||
const sessionId = editor.scribbles.startSession({
|
||||
fadeMode: 'grouped',
|
||||
idleTimeoutMs: 1200,
|
||||
fadeDurationMs: 500,
|
||||
})
|
||||
|
||||
// Add scribbles and points to a session
|
||||
editor.scribbles.addScribbleToSession(sessionId, { color: 'laser' })
|
||||
editor.scribbles.addPointToSession(sessionId, scribbleId, x, y)
|
||||
|
||||
// Stop or clear a session
|
||||
editor.scribbles.stopSession(sessionId)
|
||||
editor.scribbles.clearSession(sessionId)
|
||||
```
|
||||
|
||||
New option in `TldrawOptions`:
|
||||
|
||||
- `laserFadeoutMs` (default: 500ms) - How long to fade all laser scribbles after the session ends
|
||||
|
||||
### Image pipeline starter template ([#7863](https://github.com/tldraw/tldraw/pull/7863))
|
||||
|
||||
A new "Image pipeline" starter template is available via `npx create-tldraw`. It provides a visual node-based canvas for building AI image generation workflows, with custom node shapes, typed port connections, pipeline regions with play/stop controls, and a DAG-based execution engine backed by a Cloudflare Worker API.
|
||||
|
||||
### Agent starter template improvements ([#7640](https://github.com/tldraw/tldraw/pull/7640))
|
||||
|
||||
The agent starter template has been restructured around a manager-based architecture for better modularity and extensibility. It now includes a mode system for controlling agent capabilities per mode, action schema registries, prompt part definitions, canvas linting, and user action tracking. The template also renames "SimpleShape" to "FocusedShape" for clarity.
|
||||
|
||||
### TldrawUiSelect component ([#7566](https://github.com/tldraw/tldraw/pull/7566))
|
||||
|
||||
New select dropdown primitive wrapping Radix UI's Select, following existing tldraw UI patterns.
|
||||
|
||||
### 🔜 Consolidated options prop ([#7888](https://github.com/tldraw/tldraw/pull/7888))
|
||||
|
||||
The `cameraOptions`, `textOptions`, and `deepLinks` props on `Tldraw`, `TldrawEditor`, and `TldrawImage` are now consolidated into the `options` prop. The standalone props are deprecated but still work for backward compatibility.
|
||||
|
||||
```tsx
|
||||
// Before
|
||||
<Tldraw cameraOptions={{ isLocked: true }} deepLinks textOptions={{ ... }} />
|
||||
|
||||
// After
|
||||
<Tldraw options={{ camera: { isLocked: true }, deepLinks: true, text: { ... } }} />
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>Migration guide</summary>
|
||||
|
||||
Replace standalone props with equivalent `options` fields:
|
||||
|
||||
- `cameraOptions={...}` → `options={{ camera: { ... } }}`
|
||||
- `textOptions={...}` → `options={{ text: { ... } }}`
|
||||
- `deepLinks` or `deepLinks={...}` → `options={{ deepLinks: true }}` or `options={{ deepLinks: { ... } }}`
|
||||
|
||||
The deprecated props still work. When both the deprecated prop and the `options` field are provided, the `options` value takes precedence.
|
||||
|
||||
</details>
|
||||
|
||||
## API changes
|
||||
|
||||
- 🔜 **`TldrawEditorBaseProps.cameraOptions`**, **`TldrawEditorBaseProps.textOptions`**, **`TldrawEditorBaseProps.deepLinks`** deprecated in favor of `options.camera`, `options.text`, `options.deepLinks`. ([#7888](https://github.com/tldraw/tldraw/pull/7888))
|
||||
- 🔜 **`TLShapeUtilCanBindOpts.fromShapeType`** and **`toShapeType`** replaced with `fromShape` and `toShape` accepting `TLShape | { type }`. ([#7821](https://github.com/tldraw/tldraw/pull/7821))
|
||||
- Remove `editor.spatialIndex` from public API. The spatial index is now internal; use `editor.getShapesAtPoint()` and `editor.getShapeAtPoint()` for shape queries. ([#7699](https://github.com/tldraw/tldraw/pull/7699))
|
||||
- Add `TldrawOptions.camera`, `TldrawOptions.text`, and `TldrawOptions.deepLinks` fields to the `options` prop. ([#7888](https://github.com/tldraw/tldraw/pull/7888))
|
||||
- Add `TldrawUiSelect`, `TldrawUiSelectTrigger`, `TldrawUiSelectValue`, `TldrawUiSelectContent`, `TldrawUiSelectItem` components and associated prop types. Add `iconTypes` export for enumerating available icons. ([#7566](https://github.com/tldraw/tldraw/pull/7566))
|
||||
- Add `useCanApplySelectionAction()` hook for checking if selection actions should be enabled. ([#7811](https://github.com/tldraw/tldraw/pull/7811))
|
||||
- Add `TLInstance.cameraState: 'idle' | 'moving'` for tracking camera movement state. ([#7826](https://github.com/tldraw/tldraw/pull/7826))
|
||||
- Add `quickZoomPreservesScreenBounds` to `TldrawOptions`. ([#7836](https://github.com/tldraw/tldraw/pull/7836))
|
||||
- Add `fillExtra` to `STYLES` object for additional fill style options. ([#7885](https://github.com/tldraw/tldraw/pull/7885))
|
||||
- Make `Editor.getShapeIdsInsideBounds()` public. ([#7863](https://github.com/tldraw/tldraw/pull/7863))
|
||||
- Add `ShapeUtil.getIndicatorPath()` method and `TLIndicatorPath` type for canvas-based indicator rendering. Add `ShapeUtil.useLegacyIndicator()` to control whether shapes use SVG or canvas indicators. ([#7708](https://github.com/tldraw/tldraw/pull/7708))
|
||||
- Add `isZoomDirectionInverted` to `TLUserPreferences` interface and `UserPreferencesManager.getIsZoomDirectionInverted()` method. Add `ToggleInvertZoomItem` component export and `toggle-invert-zoom` action. ([#7732](https://github.com/tldraw/tldraw/pull/7732))
|
||||
- Add `complete` to `TL_SCRIBBLE_STATES` enum and `ScribbleManager.complete(id)` method for marking scribbles as complete before fading. ([#7760](https://github.com/tldraw/tldraw/pull/7760))
|
||||
- Add scribble session system: `ScribbleManager.startSession()`, `stopSession()`, `clearSession()`, `extendSession()`, `isSessionActive()`, `addScribbleToSession()`, `addPointToSession()`, and `ScribbleSessionOptions` type. Add `LaserTool.getSessionId()`. Add `laserFadeoutMs` to `TldrawOptions`. ([#7681](https://github.com/tldraw/tldraw/pull/7681))
|
||||
- Add `FpsScheduler` class to create FPS-throttled function queues with configurable target rates. ([#7418](https://github.com/tldraw/tldraw/pull/7418))
|
||||
|
||||
## Improvements
|
||||
|
||||
- Improve performance in large canvases and multiplayer rooms by optimizing Set comparisons, reducing memory allocations, and caching string hashes. ([#7840](https://github.com/tldraw/tldraw/pull/7840))
|
||||
- Improve panning performance in large documents by skipping hover hit-testing during camera movement. ([#7826](https://github.com/tldraw/tldraw/pull/7826))
|
||||
- Improve performance when translating arrows together with bound shapes by skipping unnecessary reparenting. ([#7733](https://github.com/tldraw/tldraw/pull/7733))
|
||||
- Add R-tree spatial indexing for O(log n) shape queries, improving performance of brushing, selection, and erasing with many shapes. ([#7676](https://github.com/tldraw/tldraw/pull/7676))
|
||||
- Improve laser pointer to keep all strokes visible during a session (telestrator pattern), with strokes fading together when drawing stops. ([#7681](https://github.com/tldraw/tldraw/pull/7681))
|
||||
- Improve laser pointer strokes with proper taper when lifting the pointer by adding a 'complete' state to the scribble lifecycle. ([#7760](https://github.com/tldraw/tldraw/pull/7760))
|
||||
- Improve quick zoom to use zoom-to-fit instead of fixed 5%, add "quick peek" behavior, and add edge scrolling. ([#7836](https://github.com/tldraw/tldraw/pull/7836))
|
||||
- Remove `core-js` polyfill dependency from `@tldraw/editor`. ([#7769](https://github.com/tldraw/tldraw/pull/7769))
|
||||
- Reduce network traffic by squashing pending push requests before sending, so rapid edits result in fewer network calls. ([#7724](https://github.com/tldraw/tldraw/pull/7724))
|
||||
- Reduce solo-mode network traffic by using a dedicated FPS-based scheduler that throttles to 1 FPS when no collaborators are present. ([#7657](https://github.com/tldraw/tldraw/pull/7657))
|
||||
- Improve performance by separating UI and network scheduling into independent queues with configurable target FPS. ([#7418](https://github.com/tldraw/tldraw/pull/7418))
|
||||
- Improve frame label sizing on smaller viewports and when zoomed out. ([#7746](https://github.com/tldraw/tldraw/pull/7746))
|
||||
- Add image pipeline starter template with visual node-based AI image generation workflows. ([#7863](https://github.com/tldraw/tldraw/pull/7863))
|
||||
- Restructure agent starter template with manager-based architecture and mode system. ([#7640](https://github.com/tldraw/tldraw/pull/7640))
|
||||
|
||||
## Bug fixes
|
||||
|
||||
- Fix geo shapes with text not being resizable to a smaller size. ([#7878](https://github.com/tldraw/tldraw/pull/7878))
|
||||
- Fix shapes exploding when resizing unaligned arrows. ([#7855](https://github.com/tldraw/tldraw/pull/7855))
|
||||
- Fix Safari pinch zoom resetting selection to previous shapes. ([#7777](https://github.com/tldraw/tldraw/pull/7777))
|
||||
- Fix toggle-lock action firing when no shapes are selected. ([#7815](https://github.com/tldraw/tldraw/pull/7815))
|
||||
- Fix menu items (zoom to selection, cut, copy, delete) being enabled when not in select tool. ([#7811](https://github.com/tldraw/tldraw/pull/7811))
|
||||
- Fix excessive tab indentation in text shapes (now 2 spaces instead of 8). ([#7796](https://github.com/tldraw/tldraw/pull/7796))
|
||||
- Fix canvas-in-front elements (cursors, following indicators) appearing in front of UI panels instead of behind them. ([#7865](https://github.com/tldraw/tldraw/pull/7865))
|
||||
- Fix license console message colors for warnings and errors. ([#7850](https://github.com/tldraw/tldraw/pull/7850))
|
||||
- Fix asset resolution being delayed by 500ms when updating an image shape's asset. ([#7612](https://github.com/tldraw/tldraw/pull/7612))
|
||||
- Fix Durable Object SQLite migration in the multiplayer Cloudflare template. ([#7829](https://github.com/tldraw/tldraw/pull/7829), [#7832](https://github.com/tldraw/tldraw/pull/7832), [#7834](https://github.com/tldraw/tldraw/pull/7834), [#7835](https://github.com/tldraw/tldraw/pull/7835))
|
||||
- Fix `zoomToFit` and `getCurrentPageBounds` to ignore hidden shapes when computing bounds. ([#7770](https://github.com/tldraw/tldraw/pull/7770))
|
||||
- Fix collaborator shape indicators not rendering after the canvas indicator change. ([#7759](https://github.com/tldraw/tldraw/pull/7759))
|
||||
- Fix rich text content not updating correctly with message squashing due to reference comparison. ([#7758](https://github.com/tldraw/tldraw/pull/7758))
|
||||
- Fix keyboard shortcut menu item labels to use consistent ellipsis formatting. ([#7757](https://github.com/tldraw/tldraw/pull/7757))
|
||||
- Fix spatial index not removing shapes when moved to a different page. ([#7700](https://github.com/tldraw/tldraw/pull/7700))
|
||||
- Fix tldraw failing to load in CJS environments (tsx, ts-node, Jest) due to ESM-only rbush dependency. ([#7905](https://github.com/tldraw/tldraw/pull/7905))
|
||||
|
||||
---
|
||||
|
||||
## Patch releases
|
||||
|
||||
### v4.4.1
|
||||
|
||||
- Fix tooltip positioning when tldraw is rendered inside a container with CSS transforms. ([#8171](https://github.com/tldraw/tldraw/pull/8171))
|
||||
|
||||
[View release on GitHub](https://github.com/tldraw/tldraw/releases/tag/v4.4.1)
|
||||
169
apps/docs/content/releases/v4.5.0.mdx
Normal file
169
apps/docs/content/releases/v4.5.0.mdx
Normal file
@@ -0,0 +1,169 @@
|
||||
---
|
||||
title: v4.5.0
|
||||
description: Click-through on transparent pixels, SVG sanitization, configurable embed definitions, high-DPI image sizing, and more
|
||||
author: tldraw
|
||||
date: 03/18/2026
|
||||
order: 0
|
||||
status: published
|
||||
keywords:
|
||||
- changelog
|
||||
- release
|
||||
- v4.5
|
||||
- v4.5.0
|
||||
- v4.5.3
|
||||
- v4.5.4
|
||||
- v4.5.5
|
||||
- v4.5.6
|
||||
- transparent-pixels
|
||||
- embed-definitions
|
||||
- high-dpi
|
||||
- svg-sanitization
|
||||
- strip-types
|
||||
---
|
||||
|
||||
[View on GitHub](https://github.com/tldraw/tldraw/releases/tag/v4.5.0)
|
||||
|
||||
This release adds click-through on transparent image pixels, SVG sanitization for pasted and dropped content, configurable embed definitions via `EmbedShapeUtil.configure()`, and correct high-DPI image sizing across platforms. It also includes a new `Editor.resizeToBounds()` method, TypeScript enum-to-const refactoring for Node strip-types compatibility, and various other improvements and bug fixes.
|
||||
|
||||
## What's new
|
||||
|
||||
### Click-through on transparent image pixels ([#7942](https://github.com/tldraw/tldraw/pull/7942))
|
||||
|
||||
Clicking on transparent areas of PNG, WebP, GIF, and AVIF images now selects shapes behind the image instead of the image itself. This works with cropped, flipped, and circle-cropped images.
|
||||
|
||||
This is powered by a new `Geometry2d.ignoreHit(point)` method that allows geometries to reject successful hit tests.
|
||||
|
||||
### 🔜 Configurable embed definitions ([#8034](https://github.com/tldraw/tldraw/pull/8034))
|
||||
|
||||
Embed definitions are now configured through `EmbedShapeUtil.configure()` instead of the static `setEmbedDefinitions()` method, which has been deprecated. The `embeds` prop on `Tldraw` is deprecated in favor of this approach.
|
||||
|
||||
```tsx
|
||||
import { EmbedShapeUtil, DEFAULT_EMBED_DEFINITIONS } from 'tldraw'
|
||||
|
||||
const shapeUtils = [
|
||||
EmbedShapeUtil.configure({
|
||||
embedDefinitions: [...DEFAULT_EMBED_DEFINITIONS, myCustomEmbed],
|
||||
}),
|
||||
]
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>Migration guide</summary>
|
||||
|
||||
Before:
|
||||
|
||||
```ts
|
||||
EmbedShapeUtil.setEmbedDefinitions([...DEFAULT_EMBED_DEFINITIONS, myEmbed])
|
||||
```
|
||||
|
||||
After:
|
||||
|
||||
```ts
|
||||
const shapeUtils = [
|
||||
EmbedShapeUtil.configure({
|
||||
embedDefinitions: [...DEFAULT_EMBED_DEFINITIONS, myEmbed],
|
||||
}),
|
||||
]
|
||||
```
|
||||
|
||||
The `embeds` prop on `Tldraw` still works but is deprecated. Use `EmbedShapeUtil.configure()` instead.
|
||||
|
||||
</details>
|
||||
|
||||
### SVG sanitization ([#7896](https://github.com/tldraw/tldraw/pull/7896), [#8087](https://github.com/tldraw/tldraw/pull/8087))
|
||||
|
||||
Pasted and dropped SVG content is now sanitized against XSS and data exfiltration. The sanitizer uses an allowlist-based approach that blocks script injection, event handlers, dangerous URI schemes, and external resource loading while preserving tldraw's own SVG output (including `<foreignObject>` text rendering and CSS font references).
|
||||
|
||||
The sanitizer is also available as a public export for custom content handling:
|
||||
|
||||
```ts
|
||||
import { sanitizeSvg } from 'tldraw'
|
||||
|
||||
const cleanSvg = sanitizeSvg(untrustedSvgText)
|
||||
```
|
||||
|
||||
## API changes
|
||||
|
||||
- 🔜 **`EmbedShapeUtil.setEmbedDefinitions()`** deprecated. Use `EmbedShapeUtil.configure({ embedDefinitions: [...] })` instead. ([#8034](https://github.com/tldraw/tldraw/pull/8034))
|
||||
- 🔜 **`Tldraw` `embeds` prop** is deprecated. Configure embed definitions via `EmbedShapeUtil.configure()`. ([#8034](https://github.com/tldraw/tldraw/pull/8034))
|
||||
- Add `Geometry2d.ignoreHit(point)` for rejecting hit tests on transparent pixels. ([#7942](https://github.com/tldraw/tldraw/pull/7942))
|
||||
- Add `Editor.resizeToBounds(shapes, bounds)` for resizing shapes to fit target bounds. ([#8120](https://github.com/tldraw/tldraw/pull/8120))
|
||||
- Add `Editor.getResizeScaleFactor()` for computing the dynamic size scale factor at the current zoom level. ([#8042](https://github.com/tldraw/tldraw/pull/8042))
|
||||
- Add `sanitizeSvg(svgText: string)` export for sanitizing SVG content against XSS and data exfiltration. ([#7896](https://github.com/tldraw/tldraw/pull/7896))
|
||||
- Add experimental `experimental__onDropOnCanvas` option to intercept canvas drop events. Return `true` from the callback to prevent the editor's default drop behavior. ([#7911](https://github.com/tldraw/tldraw/pull/7911))
|
||||
|
||||
```tsx
|
||||
<Tldraw
|
||||
options={{
|
||||
experimental__onDropOnCanvas: (point, event) => {
|
||||
// Handle drop at page-space point
|
||||
return true // prevent default
|
||||
},
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
- Add optional `pixelRatio` property to `TLImageAsset` for correct high-DPI image sizing. Detected automatically from PNG metadata. ([#8163](https://github.com/tldraw/tldraw/pull/8163))
|
||||
- Replace TypeScript enums (`MigrationFailureReason`, `PORTRAIT_BREAKPOINT`) with `const` object + type alias pattern for compatibility with Node's built-in TypeScript support (strip-types). Values are unchanged. ([#8084](https://github.com/tldraw/tldraw/pull/8084))
|
||||
|
||||
## Improvements
|
||||
|
||||
- Improve arrow component rendering performance with finer-grained reactivity. ([#8167](https://github.com/tldraw/tldraw/pull/8167))
|
||||
- Simplify paste-parent selection to use `canReceiveNewChildrenOfType` instead of frame-specific checks. Prevent edge-only overlap from auto-reparenting pasted shapes into adjacent frames. ([#8057](https://github.com/tldraw/tldraw/pull/8057))
|
||||
- Save link and alt-text values when clicking outside the editor instead of discarding changes. ([#8037](https://github.com/tldraw/tldraw/pull/8037))
|
||||
- Add SVG sanitization on paste and file drop to prevent XSS and data exfiltration. ([#7896](https://github.com/tldraw/tldraw/pull/7896))
|
||||
- Fix circular dependencies across `@tldraw/state`, `@tldraw/editor`, and `@tldraw/tldraw` packages to improve compatibility with Jest mocking and tree-shaking. ([#7935](https://github.com/tldraw/tldraw/pull/7935))
|
||||
- Fix high-DPI image sizing to work correctly across macOS and Windows by detecting the source DPI baseline from PNG metadata. ([#8163](https://github.com/tldraw/tldraw/pull/8163))
|
||||
|
||||
## Bug fixes
|
||||
|
||||
- Fix shapes pasted with Ctrl+V not being parented to a frame when they land inside one. ([#7938](https://github.com/tldraw/tldraw/pull/7938))
|
||||
- Fix a crash when cropping custom shapes that don't include `isCircle` in their crop schema. ([#7931](https://github.com/tldraw/tldraw/pull/7931))
|
||||
- Fix a crash when loading session state without a `currentPageId` (e.g. when using deep links). ([#7994](https://github.com/tldraw/tldraw/pull/7994))
|
||||
- Fix U+2028/U+2029 line separators breaking chunked sync messages. ([#7918](https://github.com/tldraw/tldraw/pull/7918))
|
||||
- Fix "Download original" not triggering a download for cross-origin assets. ([#8090](https://github.com/tldraw/tldraw/pull/8090))
|
||||
- Fix arrow endpoints terminating at invisible clipped shape boundaries instead of at the frame edge. ([#7932](https://github.com/tldraw/tldraw/pull/7932))
|
||||
- Fix sticky notes having a hard shadow instead of a soft drop shadow when exported as SVG. ([#7934](https://github.com/tldraw/tldraw/pull/7934))
|
||||
- Fix rich text toolbar staying open when the editing shape is deleted by another user. ([#8050](https://github.com/tldraw/tldraw/pull/8050))
|
||||
- Fix SVG sanitizer stripping embedded SVG data URIs on `<image>` elements. Nested SVGs are now recursively sanitized instead of blocked. ([#8087](https://github.com/tldraw/tldraw/pull/8087))
|
||||
- Fix `TldrawSelectionForeground` crashing when used without `TldrawUiContextProvider`. ([#8011](https://github.com/tldraw/tldraw/pull/8011))
|
||||
- Fix dynamic-size shapes losing shadows and dashes too early when zoomed out. ([#8040](https://github.com/tldraw/tldraw/pull/8040))
|
||||
- Fix a crash when resizing draw or highlight shapes to zero width or height. ([#8067](https://github.com/tldraw/tldraw/pull/8067))
|
||||
- Fix crash when enabling Debug SVG with shapes on the canvas. ([#8101](https://github.com/tldraw/tldraw/pull/8101))
|
||||
- Fix `localOffset` mutation bug in `stretchShapes` when shapes have parent transforms. ([#8120](https://github.com/tldraw/tldraw/pull/8120))
|
||||
- Fix arrow SVG export producing invalid negative `<foreignObject>` dimensions when arrows have no text label. ([#8137](https://github.com/tldraw/tldraw/pull/8137))
|
||||
- Fix arrow endpoint flickering when anchors are at exact shape boundaries. ([#8130](https://github.com/tldraw/tldraw/pull/8130))
|
||||
- Fix shapes dragged from the toolbar not respecting dynamic size mode. ([#8042](https://github.com/tldraw/tldraw/pull/8042))
|
||||
- Fix dragging unselected shapes that have an `onClick` handler. ([#7936](https://github.com/tldraw/tldraw/pull/7936))
|
||||
- Fix false positive "multiple instances" warning in Next.js dev mode. ([#7933](https://github.com/tldraw/tldraw/pull/7933))
|
||||
- Fix missing alt text on rendered image shapes in some cases. ([#8158](https://github.com/tldraw/tldraw/pull/8158))
|
||||
- Fix drawing on tablets that report zero pen pressure. ([#5693](https://github.com/tldraw/tldraw/pull/5693))
|
||||
- Fix `create-tldraw` CLI to always create a subdirectory from the project name. ([#8161](https://github.com/tldraw/tldraw/pull/8161))
|
||||
|
||||
---
|
||||
|
||||
## Patch releases
|
||||
|
||||
### v4.5.3
|
||||
|
||||
- Fix dynamic import file extension handling. ([#8278](https://github.com/tldraw/tldraw/pull/8278))
|
||||
|
||||
[View release on GitHub](https://github.com/tldraw/tldraw/releases/tag/v4.5.3)
|
||||
|
||||
### v4.5.4
|
||||
|
||||
- Fix all shapes disappearing when a labeled arrow has zero length due to NaN propagation through the spatial index. ([#8351](https://github.com/tldraw/tldraw/pull/8351))
|
||||
|
||||
[View release on GitHub](https://github.com/tldraw/tldraw/releases/tag/v4.5.4)
|
||||
|
||||
### v4.5.5
|
||||
|
||||
- Fix camera state getting permanently stuck at 'moving' when the editor is disposed mid-camera-transition. ([#8396](https://github.com/tldraw/tldraw/pull/8396))
|
||||
|
||||
[View release on GitHub](https://github.com/tldraw/tldraw/releases/tag/v4.5.5)
|
||||
|
||||
### v4.5.6
|
||||
|
||||
- Fix crash when isolating curved arrows with degenerate binding geometry. ([#8390](https://github.com/tldraw/tldraw/pull/8390))
|
||||
|
||||
[View release on GitHub](https://github.com/tldraw/tldraw/releases/tag/v4.5.6)
|
||||
587
apps/docs/content/releases/v5.0.0.mdx
Normal file
587
apps/docs/content/releases/v5.0.0.mdx
Normal file
@@ -0,0 +1,587 @@
|
||||
---
|
||||
title: v5.0.0
|
||||
description: Custom theme system, performance improvements, extensibility, attribution, and more.
|
||||
author: tldraw
|
||||
date: 05/06/2026
|
||||
order: 0
|
||||
status: published
|
||||
keywords:
|
||||
- changelog
|
||||
- release
|
||||
- v5.0.0
|
||||
- custom styles
|
||||
- themes
|
||||
- overlays
|
||||
- attribution
|
||||
- mermaid
|
||||
---
|
||||
|
||||
This major release introduces a first-class theme system with display values, a new `OverlayUtil` system for canvas overlays, and extensibility APIs for custom record types, asset types, geo shapes, and frame-like shapes. Two new packages ship alongside it: `@tldraw/driver` for imperative editor control and `@tldraw/mermaid` for converting Mermaid diagrams into native shapes. It also adds a shape attribution system with the `TLUserStore` provider, clipboard and performance-measurement hooks, WebSocket hibernation in tlsync, RTL support, cross-window embedding, and various other improvements and bug fixes.
|
||||
|
||||
## What's new
|
||||
|
||||
### 💥 Custom themes with display values ([#8410](https://github.com/tldraw/tldraw/pull/8410))
|
||||
|
||||
A new first-class theme system replaces the previous approach where colors were hardcoded and resolved inline. Themes are named, registered objects that shape utils consume via a structured display values pipeline.
|
||||
|
||||
Register custom themes via `TLThemes` module augmentation for type-safe IDs, add or remove palette colors via `TLThemeDefaultColors` and `TLRemovedDefaultThemeColors`, and pass themes to the editor via the `themes` and `initialTheme` props:
|
||||
|
||||
```tsx
|
||||
<Tldraw themes={{ corporate: myCorporateTheme }} initialTheme="corporate" />
|
||||
```
|
||||
|
||||
Each default shape util defines `getDefaultDisplayValues` to resolve visual properties (colors, stroke widths, font sizes) from the current theme and color mode. Override display values for a default shape with `getCustomDisplayValues`:
|
||||
|
||||
```tsx
|
||||
const MyDrawShapeUtil = DrawShapeUtil.configure({
|
||||
getCustomDisplayValues(_editor, _shape, _theme, _colorMode) {
|
||||
return { strokeWidth: 10 }
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>Migration guide</summary>
|
||||
|
||||
The `inferDarkMode` prop has been renamed to `colorScheme` and changed from `boolean` to `'light' | 'dark' | 'system'`:
|
||||
|
||||
```tsx
|
||||
// Before
|
||||
<Tldraw inferDarkMode />
|
||||
|
||||
// After
|
||||
<Tldraw colorScheme="system" />
|
||||
```
|
||||
|
||||
`useIsDarkMode()` has been renamed to `useColorMode()` and returns `'dark' | 'light'` instead of `boolean`.
|
||||
|
||||
`getDefaultColorTheme()` and `DefaultColorThemePalette` have been removed. Use `editor.getCurrentTheme().colors[colorMode]` instead:
|
||||
|
||||
```tsx
|
||||
// Before
|
||||
const theme = getDefaultColorTheme({ isDarkMode })
|
||||
|
||||
// After
|
||||
const theme = editor.getCurrentTheme()
|
||||
const colors = theme.colors[editor.getColorMode()]
|
||||
```
|
||||
|
||||
`useDefaultColorTheme()` has been removed. Use `editor.getCurrentTheme()` and `useColorMode()` instead.
|
||||
|
||||
`FONT_FAMILIES`, `FONT_SIZES`, `LABEL_FONT_SIZES`, `STROKE_SIZES`, `TEXT_PROPS`, and `ARROW_LABEL_FONT_SIZES` have been removed — these are now resolved via display values.
|
||||
|
||||
`SvgExportContext.themeId` has been renamed to `SvgExportContext.colorMode` and changed from `string` to `'light' | 'dark'`.
|
||||
|
||||
`getColorValue()` now takes `TLThemeColors` as its first argument instead of `TLDefaultColorTheme`.
|
||||
|
||||
</details>
|
||||
|
||||
### 💥 Custom overlays ([#8469](https://github.com/tldraw/tldraw/pull/8469))
|
||||
|
||||
A new `OverlayUtil` system unifies canvas overlays. Built-in brushes, handles, scribbles, snap indicators, selection foreground, collaborator cursors, and arrow hints are now implemented as overlay utils and can be customized or extended via the `overlayUtils` prop.
|
||||
|
||||
Overlays now render in front of indicators in a unified `CanvasOverlays` layer, and the legacy default component implementations and their CSS have been removed.
|
||||
|
||||
<details>
|
||||
<summary>Migration guide</summary>
|
||||
|
||||
The legacy overlay components have been removed from `TLEditorComponents`. Canvas overlays are now rendered directly to a 2D canvas context by `OverlayUtil` classes, and customization happens by subclassing the default util rather than swapping a React component.
|
||||
|
||||
**Customizing a built-in overlay:** extend the default util and give it the same `static type`; it replaces the built-in when passed via `overlayUtils`:
|
||||
|
||||
```tsx
|
||||
// Before
|
||||
const components: TLEditorComponents = {
|
||||
Brush: MyBrushComponent,
|
||||
}
|
||||
|
||||
<Tldraw components={components} />
|
||||
|
||||
// After
|
||||
import { Tldraw, BrushOverlayUtil, type TLBrushOverlay } from 'tldraw'
|
||||
|
||||
class MyBrushOverlayUtil extends BrushOverlayUtil {
|
||||
override render(ctx: CanvasRenderingContext2D, overlays: TLBrushOverlay[]) {
|
||||
const overlay = overlays[0]
|
||||
if (!overlay) return
|
||||
const { x, y, w, h } = overlay.props
|
||||
const zoom = this.editor.getEfficientZoomLevel()
|
||||
ctx.fillStyle = 'rgba(0, 0, 255, 0.1)'
|
||||
ctx.strokeStyle = 'blue'
|
||||
ctx.lineWidth = 1 / zoom
|
||||
ctx.beginPath()
|
||||
ctx.rect(x, y, w, h)
|
||||
ctx.fill()
|
||||
ctx.stroke()
|
||||
}
|
||||
}
|
||||
|
||||
<Tldraw overlayUtils={[MyBrushOverlayUtil]} />
|
||||
```
|
||||
|
||||
The following slots have been removed from `TLEditorComponents`. Subclass the matching default overlay util instead:
|
||||
|
||||
- `Brush`, `ZoomBrush` → `BrushOverlayUtil`, `ZoomBrushOverlayUtil`
|
||||
- `Scribble` → `ScribbleOverlayUtil`
|
||||
- `SnapIndicator` → `SnapIndicatorOverlayUtil`
|
||||
- `Handle`, `Handles` → `ShapeHandleOverlayUtil`
|
||||
- `SelectionForeground`, `SelectionBackground` → `SelectionForegroundOverlayUtil`
|
||||
- `CollaboratorHint` → `CollaboratorHintOverlayUtil` (collaborator cursors/brushes/scribbles have their own utils too)
|
||||
|
||||
The corresponding `Default*` exports and `LiveCollaborators` have also been removed.
|
||||
|
||||
**`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`.
|
||||
|
||||
Overlay colors come from `TLTheme`, not CSS variables. Read them inside `render()` via `this.editor.getCurrentTheme().colors[this.editor.getColorMode()]` so overlays follow light/dark mode automatically.
|
||||
|
||||
These CSS variables have been removed: `--tl-color-snap`, `--tl-color-brush-fill`, `--tl-color-brush-stroke`, `--tl-color-laser`, `--tl-layer-overlays-custom`.
|
||||
|
||||
Overriding them (or the removed selectors `.tl-brush`, `.tl-scribble`, `.tl-snap-indicator`, `.tl-handle*`, `.tl-selection__fg__outline`, `.tl-corner-handle`, `.tl-text-handle`, `.tl-corner-crop-handle`, `.tl-mobile-rotate__*`) no longer has any effect. Port these to `TLTheme` entries or to the overlay util's `render()`.
|
||||
|
||||
</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.
|
||||
|
||||
```tsx
|
||||
import { createTLSchema, createCustomRecordId } from 'tldraw'
|
||||
|
||||
const schema = createTLSchema({
|
||||
records: [
|
||||
{
|
||||
typeName: 'marker',
|
||||
scope: 'document',
|
||||
validator: markerValidator,
|
||||
},
|
||||
],
|
||||
})
|
||||
```
|
||||
|
||||
TypeScript module augmentation via `TLGlobalRecordPropsMap` lets custom record types participate in the `TLRecord` union.
|
||||
|
||||
### Extensible geo shapes ([#8543](https://github.com/tldraw/tldraw/pull/8543))
|
||||
|
||||
`GeoShapeUtil` now supports custom geo types via a `customGeoTypes` option on `configure()`. Previously, adding a new geo type required forking or monkey-patching `GeoShapeUtil`; now custom types plug into the existing system and inherit labels, resizing, fill/dash/color styling, SVG export, and hyperlink support — while supplying their own path geometry, snap behavior, creation size, and style panel icon.
|
||||
|
||||
```tsx
|
||||
import { GeoShapeUtil, PathBuilder } from 'tldraw'
|
||||
|
||||
const MyGeoShapeUtil = GeoShapeUtil.configure({
|
||||
customGeoTypes: {
|
||||
'rounded-rect': {
|
||||
getPath: (w, h, shape) => {
|
||||
const r = Math.min(w, h) * 0.2
|
||||
return new PathBuilder()
|
||||
.moveTo(r, 0, { geometry: { isFilled: shape.props.fill !== 'none' } })
|
||||
.lineTo(w - r, 0)
|
||||
.circularArcTo(r, false, true, w, r)
|
||||
.lineTo(w, h - r)
|
||||
.circularArcTo(r, false, true, w - r, h)
|
||||
.lineTo(r, h)
|
||||
.circularArcTo(r, false, true, 0, h - r)
|
||||
.lineTo(0, r)
|
||||
.circularArcTo(r, false, true, r, 0)
|
||||
.close()
|
||||
},
|
||||
snapType: 'polygon',
|
||||
icon: 'geo-rectangle',
|
||||
defaultSize: { w: 200, h: 150 },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
<Tldraw shapeUtils={[MyGeoShapeUtil]} />
|
||||
```
|
||||
|
||||
Each definition supplies `getPath`, `snapType` (`'polygon'` for vertex + center snaps, `'blobby'` for center-only), and an `icon` for the style panel, plus optional `defaultSize` and `onDoubleClick` handler. Custom types appear alongside the built-ins in the geo picker.
|
||||
|
||||
### 💥 Extensible asset types ([#8031](https://github.com/tldraw/tldraw/pull/8031))
|
||||
|
||||
A new `AssetUtil` base class follows the `ShapeUtil` / `BindingUtil` pattern, making the asset system extensible. Previously, assets were a hardcoded union of image, video, and bookmark types. Now you can register custom asset types with their own MIME type handling, file-to-asset conversion, and shape creation logic.
|
||||
|
||||
```tsx
|
||||
import { AssetUtil } from '@tldraw/editor'
|
||||
|
||||
class AudioAssetUtil extends AssetUtil<TLAudioAsset> {
|
||||
static override type = 'audio' as const
|
||||
override getSupportedMimeTypes() {
|
||||
return ['audio/mpeg', 'audio/wav']
|
||||
}
|
||||
override async getAssetFromFile(editor, file) {
|
||||
/* ... */
|
||||
}
|
||||
}
|
||||
|
||||
;<Tldraw assetUtils={[AudioAssetUtil]} />
|
||||
```
|
||||
|
||||
`TLAssetStore` keeps `upload`/`resolve`/`remove` as cross-cutting concerns, while `AssetUtil` handles type-specific behavior: MIME types, file-to-asset metadata, and asset-to-shape creation.
|
||||
|
||||
<details>
|
||||
<summary>Migration guide</summary>
|
||||
|
||||
`assetValidator` has been removed. Use `imageAssetValidator`, `videoAssetValidator`, or `bookmarkAssetValidator` instead.
|
||||
|
||||
`getMediaAssetInfoPartial` has been removed. Use `AssetUtil.getAssetFromFile` instead.
|
||||
|
||||
`notifyIfFileNotAllowed` signature changed from `(file, options)` to `(editor, file, options)`.
|
||||
|
||||
`getAssetInfo` signature changed from `(file, options, assetId?)` to `(editor, file, assetId?)` and now returns `TLAsset | null` instead of throwing.
|
||||
|
||||
</details>
|
||||
|
||||
### Frame-like custom shapes ([#8331](https://github.com/tldraw/tldraw/pull/8331))
|
||||
|
||||
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`, `canRemoveChildrenOfType`, `getClipPath`, `onDragShapesIn`, and `onDragShapesOut`:
|
||||
|
||||
```tsx
|
||||
import { BaseFrameLikeShapeUtil, SVGContainer } from '@tldraw/editor'
|
||||
|
||||
class MyContainerUtil extends BaseFrameLikeShapeUtil<MyContainerShape> {
|
||||
static override type = 'my-container' as const
|
||||
static override props = myContainerShapeProps
|
||||
|
||||
override getDefaultProps() {
|
||||
return { w: 300, h: 200 }
|
||||
}
|
||||
|
||||
override component(shape: MyContainerShape) {
|
||||
return <SVGContainer>...</SVGContainer>
|
||||
}
|
||||
|
||||
override indicator(shape: MyContainerShape) {
|
||||
return <rect width={shape.props.w} height={shape.props.h} />
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Shapes that need frame behavior without inheriting from `BaseFrameLikeShapeUtil` can override `isFrameLike()` directly on any `ShapeUtil`. `FrameShapeUtil` now extends `BaseFrameLikeShapeUtil`, so existing frames are unaffected.
|
||||
|
||||
### Shape attribution and TLUserStore ([#8147](https://github.com/tldraw/tldraw/pull/8147))
|
||||
|
||||
A new shape attribution system tracks who created and last edited shapes. The `TLUserStore` provider interface connects tldraw to your auth system with reactive `Signal`-based methods: `getCurrentUser()` returns the active user for presence and attribution, while `resolve(userId)` resolves any user ID to display info.
|
||||
|
||||
```tsx
|
||||
<Tldraw
|
||||
users={{
|
||||
getCurrentUser: () => currentUserSignal,
|
||||
resolve: (userId) => resolvedUserSignal(userId),
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
User records are now document-scoped via the unified `TLUser` record type. SDK users can extend user records with custom validated metadata through `createTLSchema`:
|
||||
|
||||
```tsx
|
||||
const schema = createTLSchema({
|
||||
user: {
|
||||
meta: {
|
||||
isAdmin: T.boolean,
|
||||
department: T.string,
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
```tsx
|
||||
import { Driver } from '@tldraw/driver'
|
||||
|
||||
const driver = new Driver(editor)
|
||||
driver.pointerMove(100, 100).pointerDown().pointerMove(200, 200).pointerUp()
|
||||
```
|
||||
|
||||
This is useful for scripting, automation, agent workflows, and REPL-style interaction. The release includes a Scripter example that demonstrates the package.
|
||||
|
||||
### @tldraw/mermaid ([#8194](https://github.com/tldraw/tldraw/pull/8194), [#8285](https://github.com/tldraw/tldraw/pull/8285), [#8322](https://github.com/tldraw/tldraw/pull/8322))
|
||||
|
||||
A new `@tldraw/mermaid` package converts Mermaid diagram syntax into native tldraw shapes. Paste Mermaid text to create flowcharts, sequence diagrams, state diagrams, and mind maps as editable shapes on the canvas.
|
||||
|
||||
```tsx
|
||||
import { createMermaidDiagram } from '@tldraw/mermaid'
|
||||
|
||||
await createMermaidDiagram(
|
||||
editor,
|
||||
`
|
||||
graph TD
|
||||
A[Start] --> B{Decision}
|
||||
B -->|Yes| C[Action]
|
||||
B -->|No| D[End]
|
||||
`
|
||||
)
|
||||
```
|
||||
|
||||
The package parses Mermaid syntax, extracts layout from the rendered SVG, and produces a diagram-agnostic blueprint that gets rendered into geo shapes, arrows, and groups.
|
||||
|
||||
Node creation is extensible: pass `mapNodeToRenderSpec` per diagram type to map diagram nodes to different shapes, or use `createShape` in `BlueprintRenderingOptions` to take full control of how nodes are created on the canvas.
|
||||
|
||||
### Arbitrary iframe embeds ([#8306](https://github.com/tldraw/tldraw/pull/8306))
|
||||
|
||||
Paste any `<iframe>` embed code onto the canvas to create an embed shape. Previously only URLs matching known providers (YouTube, Google Maps, etc.) worked. Now any valid iframe with an HTTP(S) source creates an embed directly. It works with services like OpenStreetMap, SoundCloud, Loom, and more.
|
||||
|
||||
### Clipboard hooks ([#8290](https://github.com/tldraw/tldraw/pull/8290))
|
||||
|
||||
New `TldrawOptions` hooks let you intercept and customize clipboard copy, cut, and paste. `onBeforeCopyToClipboard` filters or transforms serialized content before it hits the clipboard, `onBeforePasteFromClipboard` filters parsed paste payloads before shapes are created, and `onClipboardPasteRaw` handles raw clipboard data before tldraw's default paste pipeline.
|
||||
|
||||
```tsx
|
||||
const options: Partial<TldrawOptions> = {
|
||||
onBeforeCopyToClipboard(info, content) {
|
||||
// filter shapes or transform content before copy/cut
|
||||
return content
|
||||
},
|
||||
onBeforePasteFromClipboard(info, content) {
|
||||
// filter or transform parsed paste content
|
||||
return content
|
||||
},
|
||||
onClipboardPasteRaw(info) {
|
||||
// handle raw clipboard data yourself; return false to cancel tldraw handling
|
||||
return false
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Cross-window embedding support ([#8196](https://github.com/tldraw/tldraw/pull/8196))
|
||||
|
||||
Tldraw now works correctly when embedded in iframes, Electron pop-out windows, and Obsidian plugins where the global `document` and `window` differ from the ones tldraw is mounted in. All bare `document` and `window` references have been replaced with container-aware alternatives.
|
||||
|
||||
New helpers `getOwnerDocument()` and `getOwnerWindow()` are exported from `@tldraw/editor`, along with `Editor.getContainerDocument()` and `Editor.getContainerWindow()` convenience methods.
|
||||
|
||||
### Performance measurement hooks ([#8421](https://github.com/tldraw/tldraw/pull/8421))
|
||||
|
||||
A new `editor.performance` API lets SDK users subscribe to editor performance events: interaction frame times (translating, resizing, drawing), camera pan/zoom sessions, shape create/update/delete operations, and undo/redo timings. Events aggregate frame-time percentiles (avg / median / p95 / p99) and, on Chromium, attach Long Animation Frame data with per-script attribution for diagnosing jank.
|
||||
|
||||
```tsx
|
||||
const unsubscribe = editor.performance.on('interaction-end', (event) => {
|
||||
// forward to analytics
|
||||
const { frameTimes, longAnimationFrames, ...metrics } = event
|
||||
track('editor.interaction', metrics)
|
||||
})
|
||||
```
|
||||
|
||||
The manager is zero-overhead when no listeners are attached. Custom tools opt in to interaction tracking via `static trackPerformance = true` on the `StateNode`. A `PerformanceApiAdapter` is also available for piping events into the browser Performance API for DevTools timelines.
|
||||
|
||||
### RTL support ([#8033](https://github.com/tldraw/tldraw/pull/8033))
|
||||
|
||||
The tldraw UI now supports right-to-left languages like Arabic. A new `useDirection()` hook returns `'ltr'` or `'rtl'` from the current translation context, and all Radix UI components and CSS have been updated to respect text direction. The `dir` attribute is set on `.tl-container`, and CSS uses logical properties (`margin-inline-start`, `inset-inline-end`, etc.) instead of physical ones.
|
||||
|
||||
### WebSocket hibernation in tlsync ([#8070](https://github.com/tldraw/tldraw/pull/8070))
|
||||
|
||||
`TLSocketRoom` now supports session resume and snapshot APIs for WebSocket hibernation environments like Cloudflare Durable Objects. Sessions can be suspended and restored without losing state, and the `sync-cloudflare` template has been updated to use the WebSocket Hibernation API.
|
||||
|
||||
New APIs include `handleSocketResume()` for restoring sessions from snapshots, `getSessionSnapshot()` for capturing session state, and an `onSessionSnapshot` callback for persisting snapshots to WebSocket attachments.
|
||||
|
||||
## API changes
|
||||
|
||||
- 💥 Remove `defaultColorNames`, `DefaultColorThemePalette`, `DefaultLabelColorStyle`, `TLDefaultColorTheme` type, and `getDefaultColorTheme()` from `@tldraw/tlschema`. Use `editor.getCurrentTheme().colors` instead. ([#8410](https://github.com/tldraw/tldraw/pull/8410))
|
||||
- 💥 Remove `ARROW_LABEL_FONT_SIZES`, `FONT_FAMILIES`, `FONT_SIZES`, `LABEL_FONT_SIZES`, `STROKE_SIZES`, `TEXT_PROPS`, and `useDefaultColorTheme()` from `@tldraw/tldraw`. These are now resolved via display values. ([#8410](https://github.com/tldraw/tldraw/pull/8410))
|
||||
- 💥 Rename `inferDarkMode` prop to `colorScheme` (`boolean` → `'light' | 'dark' | 'system'`). Rename `useIsDarkMode()` to `useColorMode()` (returns `'dark' | 'light'` instead of `boolean`). ([#8410](https://github.com/tldraw/tldraw/pull/8410))
|
||||
- 💥 Rename `SvgExportContext.themeId` to `SvgExportContext.colorMode` (`string` → `'light' | 'dark'`). ([#8410](https://github.com/tldraw/tldraw/pull/8410))
|
||||
- 💥 Change `getColorValue()` first argument from `TLDefaultColorTheme` to `TLThemeColors`. ([#8410](https://github.com/tldraw/tldraw/pull/8410))
|
||||
- 💥 Change `PlainTextLabelProps` and `RichTextLabelProps`: `font` → `fontFamily`, `align` → `textAlign`, `fill` removed. ([#8410](https://github.com/tldraw/tldraw/pull/8410))
|
||||
- 💥 Remove `Brush`, `ZoomBrush`, `Scribble`, `SnapIndicator`, `Handle`, `Handles`, `SelectionForeground`, `SelectionBackground`, `CollaboratorHint`, `ShapeIndicator`, `ShapeIndicators`, and `ShapeIndicatorErrorFallback` from `TLEditorComponents`, along with the corresponding `Default*` component exports and `LiveCollaborators`. Migrate custom overlay components to `OverlayUtil`; indicator customization moves to `ShapeUtil.getIndicatorPath()`. ([#8469](https://github.com/tldraw/tldraw/pull/8469))
|
||||
- 💥 Remove the CSS variables `--tl-color-snap`, `--tl-color-brush-fill`, `--tl-color-brush-stroke`, `--tl-color-laser`, and `--tl-layer-overlays-custom`, along with the overlay class selectors (`.tl-brush`, `.tl-scribble`, `.tl-snap-indicator`, `.tl-handle*`, `.tl-selection__fg__outline`, `.tl-corner-handle`, `.tl-text-handle`, `.tl-corner-crop-handle`, `.tl-mobile-rotate__*`). Overlay colors now come from `TLTheme`. ([#8469](https://github.com/tldraw/tldraw/pull/8469))
|
||||
- 💥 Remove `assetValidator`. Use `imageAssetValidator`, `videoAssetValidator`, or `bookmarkAssetValidator` instead. ([#8031](https://github.com/tldraw/tldraw/pull/8031))
|
||||
- 💥 Remove `getMediaAssetInfoPartial`. Use `AssetUtil.getAssetFromFile` instead. ([#8031](https://github.com/tldraw/tldraw/pull/8031))
|
||||
- 💥 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))
|
||||
- Add `getDefaultDisplayValues` and `getCustomDisplayValues` to shape util options for theme-aware visual properties. ([#8410](https://github.com/tldraw/tldraw/pull/8410))
|
||||
- Add `OverlayUtil`, `TLOverlay`, `OverlayManager`, `Editor.overlays`, `defaultOverlayUtils`, and the `overlayUtils` prop on `<Tldraw>` / `<TldrawEditor>` for registering and managing canvas overlays. ([#8469](https://github.com/tldraw/tldraw/pull/8469))
|
||||
- Add per-overlay utils: `BrushOverlayUtil`, `ZoomBrushOverlayUtil`, `ScribbleOverlayUtil`, `SnapIndicatorOverlayUtil`, `ShapeHandleOverlayUtil`, `SelectionForegroundOverlayUtil`, `ArrowHintOverlayUtil`, `CollaboratorBrushOverlayUtil`, `CollaboratorScribbleOverlayUtil`, `CollaboratorHintOverlayUtil`, and `CollaboratorCursorOverlayUtil`. ([#8469](https://github.com/tldraw/tldraw/pull/8469))
|
||||
- Add overlay-related colors to `TLTheme`. ([#8469](https://github.com/tldraw/tldraw/pull/8469))
|
||||
- Add `BaseFrameLikeShapeUtil` abstract class and `ShapeUtil.isFrameLike()` so custom shapes can opt into frame-like behaviors (paste parenting, full-brush selection, blocking erasure from inside, clipping children). `FrameShapeUtil` now extends `BaseFrameLikeShapeUtil`. ([#8331](https://github.com/tldraw/tldraw/pull/8331))
|
||||
- Add extensibility API for custom geo shape types via `GeoShapeUtil.configure({ customGeoTypes })`. Add `GeoTypeDefinition` interface for defining path geometry, snap behavior, creation size, style panel icon, and double-click handler while inheriting standard geo shape behavior. ([#8543](https://github.com/tldraw/tldraw/pull/8543))
|
||||
- Add `AssetUtil` base class with `configure()`, `getDefaultProps()`, `getSupportedMimeTypes()`, `getAssetFromFile()`, and `createShape()`. Add `assetUtils` prop to `<Tldraw>`. Add `Editor.getAssetUtil()`, `Editor.hasAssetUtil()`, and `Editor.getAssetUtilForMimeType()`. Add `TLGlobalAssetPropsMap` for type-safe custom asset registration. Add `createAssetRecordType()`, `defaultAssetSchemas`, and `assets` parameter to `createTLSchema()`. ([#8031](https://github.com/tldraw/tldraw/pull/8031))
|
||||
- Add `CustomRecordInfo` interface, `createCustomRecordId()`, `createCustomRecordMigrationIds()`, `createCustomRecordMigrationSequence()`, `isCustomRecord()`, `isCustomRecordId()` for custom record types. `createTLSchema()` and `createTLStore()` now accept a `records` option. ([#8213](https://github.com/tldraw/tldraw/pull/8213))
|
||||
- Add `TLUserStore` interface with `getCurrentUser()` and `resolve()` for connecting tldraw to auth systems. Add unified `TLUser` record type, `UserRecordType`, `createUserId`, `isUserId`, `userIdValidator`, and `createUserRecordType()` for extensible user schemas. Add `user` parameter to `createTLSchema()`. Add `Editor.getAttributionUser()`, `Editor.getAttributionUserId()`, and `Editor.getAttributionDisplayName()`. Add `textFirstEditedBy` prop to `TLNoteShapeProps`. ([#8147](https://github.com/tldraw/tldraw/pull/8147))
|
||||
- Add `@tldraw/driver` package with `Driver` class for imperative editor control. ([#7952](https://github.com/tldraw/tldraw/pull/7952))
|
||||
- Add `@tldraw/mermaid` package with `createMermaidDiagram()`, `renderBlueprint()`, and `MermaidDiagramError` for converting Mermaid syntax to tldraw shapes. ([#8194](https://github.com/tldraw/tldraw/pull/8194))
|
||||
- Add `mapNodeToRenderSpec` per-diagram-type option and `createShape` override to `@tldraw/mermaid` for customizing how diagram nodes are rendered as shapes. ([#8322](https://github.com/tldraw/tldraw/pull/8322))
|
||||
- Add `onBeforeCopyToClipboard`, `onBeforePasteFromClipboard`, and `onClipboardPasteRaw` hooks to `TldrawOptions` for intercepting clipboard operations. Add `TLClipboardWriteInfo` and `TLClipboardPasteRawInfo` types. Export `handleNativeOrMenuCopy` from `@tldraw/tldraw`. ([#8290](https://github.com/tldraw/tldraw/pull/8290))
|
||||
- Add `Cmd+Shift+V` / `Ctrl+Shift+V` shortcut to paste clipboard content as plain text. `Cmd+Shift+V` no longer toggles paste-at-cursor positioning. ([#8347](https://github.com/tldraw/tldraw/pull/8347))
|
||||
- Add support for pasting arbitrary `<iframe>` embed codes to create embed shapes from any service. ([#8306](https://github.com/tldraw/tldraw/pull/8306))
|
||||
- Add Canva to `DEFAULT_EMBED_DEFINITIONS` so Canva design URLs paste as iframe embeds. ([#8459](https://github.com/tldraw/tldraw/pull/8459))
|
||||
- Add `TldrawOptions.rightClickPanning` (default `true`) to gate the new right-click drag-to-pan behavior. Add `InputsManager.getIsRightPointing()` / `setIsRightPointing()`. ([#8501](https://github.com/tldraw/tldraw/pull/8501))
|
||||
- Add `Editor.performance` (`PerformanceManager`) with `on()`, `once()`, and `dispose()` for subscribing to interaction, camera, shape-operation, frame, and undo/redo performance events. Add `PerformanceApiAdapter` for piping events into the browser Performance API. Add `StateNode.trackPerformance` static field so custom tools can opt into interaction tracking. Add `TLPerfFrameTimeStats`, `TLPerfEventMap`, `TLInteractionStartPerfEvent`, `TLInteractionEndPerfEvent`, `TLCameraStartPerfEvent`, `TLCameraEndPerfEvent`, `TLShapeOperationPerfEvent`, `TLFramePerfEvent`, `TLUndoRedoPerfEvent`, `TLPerfLongAnimationFrame`, and `TLPerfLongAnimationFrameScript`. ([#8421](https://github.com/tldraw/tldraw/pull/8421))
|
||||
- Add `Editor.getContainerDocument()` and `Editor.getContainerWindow()` methods, and `getOwnerDocument()` / `getOwnerWindow()` helpers for cross-window embedding. ([#8196](https://github.com/tldraw/tldraw/pull/8196))
|
||||
- Add `handleSocketResume()`, `getSessionSnapshot()`, and `onSessionSnapshot` to `TLSocketRoom` for WebSocket hibernation support. Add `clientTimeout` option to `TLSyncRoom`. ([#8070](https://github.com/tldraw/tldraw/pull/8070))
|
||||
- Add `useDirection()` hook for RTL support. ([#8033](https://github.com/tldraw/tldraw/pull/8033))
|
||||
- Expose `PeopleMenu`, `PeopleMenuItem`, `PeopleMenuFacePile`, and `UserPresenceEditor` through `TldrawUiComponents` so the share panel sub-components can be overridden via the `<Tldraw />` `components` prop. ([#8346](https://github.com/tldraw/tldraw/pull/8346))
|
||||
- Add `'none'` to `TLDefaultDashStyle` for hiding shape borders while preserving fills. The option is not exposed in the style panel — it can only be set programmatically. Add `NonePathBuilderOpts`; `PathBuilder.toSvg()` now returns `null` for the `'none'` style. ([#8453](https://github.com/tldraw/tldraw/pull/8453))
|
||||
- Export `defaultHandleExternalFileReplaceContent`, which was previously referenced in docs but missing from the package's public exports. ([#8579](https://github.com/tldraw/tldraw/pull/8579))
|
||||
- Change `TLSvgExportOptions.padding` to accept `number | 'auto'`. The `'auto'` mode (now default) renders with padding then trims to visual content bounds. ([#8202](https://github.com/tldraw/tldraw/pull/8202))
|
||||
- Add `TextManager.measureHtmlBatch()` for batched DOM text measurement. ([#7949](https://github.com/tldraw/tldraw/pull/7949))
|
||||
- Add `'json'` to `TLCopyType` for copying shapes as JSON in debug mode. ([#8206](https://github.com/tldraw/tldraw/pull/8206))
|
||||
- Add `Vec.IsFinite()` for checking whether a vector has finite coordinates. ([#8176](https://github.com/tldraw/tldraw/pull/8176))
|
||||
- Change `Vec.PointsBetween()` to accept an optional `ease` parameter. ([#7977](https://github.com/tldraw/tldraw/pull/7977))
|
||||
|
||||
## Improvements
|
||||
|
||||
- Optimize geometry hot paths for hit testing: reduce allocations and function call overhead in `Vec`, `Edge2d`, `Circle2d`, `Arc2d`, `Polyline2d`, and intersection routines. Circle hit testing is up to 19x faster, polyline nearest-point is 6.8x faster. ([#8210](https://github.com/tldraw/tldraw/pull/8210))
|
||||
- Improve resize performance for multiple geo shapes with text labels by batching DOM measurements into a single pass per frame. ([#7949](https://github.com/tldraw/tldraw/pull/7949))
|
||||
- Exports now automatically trim to visual content bounds, capturing overflow like thick strokes and arrowheads without extra whitespace. ([#8202](https://github.com/tldraw/tldraw/pull/8202))
|
||||
- Add `Cmd+Shift+V` / `Ctrl+Shift+V` shortcut to paste clipboard content as plain text, stripping HTML and rich formatting. `Cmd+Shift+V` no longer toggles paste-at-cursor positioning — the "Paste at cursor" preference covers that use case. ([#8347](https://github.com/tldraw/tldraw/pull/8347))
|
||||
- Change `Cmd+Shift+C` / `Ctrl+Shift+C` to copy selected shapes as PNG instead of SVG, matching the default in most apps. The SVG copy path is still available from the Copy-as menu. ([#8532](https://github.com/tldraw/tldraw/pull/8532))
|
||||
- Right-click and drag now pans the camera, matching tools like Figma. A static right-click still opens the context menu on release. Gate via `TldrawOptions.rightClickPanning` (defaults to `true`). ([#8501](https://github.com/tldraw/tldraw/pull/8501))
|
||||
- Allow Cmd+click / Ctrl+click on style panel items to apply style changes only to selected shapes without updating defaults for future shapes. ([#8452](https://github.com/tldraw/tldraw/pull/8452))
|
||||
- Hide selection overlay when nudging shapes with arrow keys. ([#8447](https://github.com/tldraw/tldraw/pull/8447))
|
||||
- Move the debug mode toggle into the preferences submenu. ([#8259](https://github.com/tldraw/tldraw/pull/8259))
|
||||
- Tighten iframe referrer policy for embeds to send only the origin instead of the full URL to third-party embed providers. ([#8412](https://github.com/tldraw/tldraw/pull/8412))
|
||||
- Replace the `@use-gesture/react` dependency with custom gesture handling. Bundle size is smaller and a stale dependency is gone. ([#8392](https://github.com/tldraw/tldraw/pull/8392))
|
||||
|
||||
## Bug fixes
|
||||
|
||||
- Fix `bailToMark` silently discarding pending history changes when the target mark doesn't exist. ([#8260](https://github.com/tldraw/tldraw/pull/8260))
|
||||
- Fix `FocusManager.dispose()` not actually removing document event listeners due to `.bind()` creating new function references. ([#8232](https://github.com/tldraw/tldraw/pull/8232))
|
||||
- Fix camera state getting stuck at `'moving'` when the editor is disposed mid-transition (e.g. deep links under React strict mode), which blocked all pointer interactions on the canvas. ([#8396](https://github.com/tldraw/tldraw/pull/8396))
|
||||
- Fix leaked camera animations, following subscriptions, and stale menu state when the editor is disposed during active operations. ([#8422](https://github.com/tldraw/tldraw/pull/8422))
|
||||
- Fix a memory leak where disposed `Editor` instances were retained through a shared throttled `updateHoveredShapeId` closure. ([#8439](https://github.com/tldraw/tldraw/pull/8439))
|
||||
- Fix a memory leak where disposed `Editor` instances were retained through the `window.__tldraw__hardReset` global. ([#8476](https://github.com/tldraw/tldraw/pull/8476))
|
||||
- Fix crash when isolating curved arrows with degenerate binding geometry. ([#8176](https://github.com/tldraw/tldraw/pull/8176))
|
||||
- Fix all shapes disappearing when a labeled arrow has zero length (both endpoints overlapping), which previously propagated NaN values through the spatial index. ([#8329](https://github.com/tldraw/tldraw/pull/8329))
|
||||
- Fix crash from duplicate fractional index keys in `kickoutOccludedShapes` during multiplayer sync. ([#8448](https://github.com/tldraw/tldraw/pull/8448))
|
||||
- Fix crash in browsers without `OffscreenCanvas` support (e.g. older Safari) when preloading image alpha data. ([#8582](https://github.com/tldraw/tldraw/pull/8582))
|
||||
- Fix over-softened corners and end artifacts when shift-clicking to draw straight line segments. ([#7977](https://github.com/tldraw/tldraw/pull/7977))
|
||||
- Fix eraser not erasing shapes when starting a drag from inside a group's bounds. ([#8054](https://github.com/tldraw/tldraw/pull/8054))
|
||||
- Fix draw-shape loop-closing sensitivity so closing works more consistently across zoom levels. ([#8293](https://github.com/tldraw/tldraw/pull/8293))
|
||||
- Fix shadow artifact and oversized caret on notes and geo shapes at high zoom in dynamic size mode. ([#8378](https://github.com/tldraw/tldraw/pull/8378))
|
||||
- Fix pattern fill not scaling correctly with dynamic size mode. ([#8441](https://github.com/tldraw/tldraw/pull/8441))
|
||||
- Fix arrow labels rendered at incorrect size when editing at high zoom in dynamic size mode. ([#8451](https://github.com/tldraw/tldraw/pull/8451))
|
||||
- Fix rotation performance regression where CSS `transform: scale()` was applied to shape labels even when dynamic size was inactive, forcing an unnecessary compositing layer on every shape. ([#8570](https://github.com/tldraw/tldraw/pull/8570))
|
||||
- Fix spiky artifacts on draw-style geometric shapes by using a circular random offset distribution instead of a square one. ([#8466](https://github.com/tldraw/tldraw/pull/8466))
|
||||
- Fix pasting into editable text shapes when the clipboard contains tldraw data. ([#8192](https://github.com/tldraw/tldraw/pull/8192))
|
||||
- Fix slight positioning drift when pasting text onto the canvas. ([#8345](https://github.com/tldraw/tldraw/pull/8345))
|
||||
- Fix right-clicking inside a multi-selection over a filled background shape changing the selection. ([#8434](https://github.com/tldraw/tldraw/pull/8434))
|
||||
- Fix context menu briefly flashing when right-clicking to dismiss an already-open context menu. ([#8498](https://github.com/tldraw/tldraw/pull/8498))
|
||||
- Fix context menu items accidentally being selected when right-clicking and quickly moving the mouse. ([#8499](https://github.com/tldraw/tldraw/pull/8499))
|
||||
- Fix opacity slider drag not working on Safari due to `stopPropagation` blocking pointer events. ([#8519](https://github.com/tldraw/tldraw/pull/8519))
|
||||
- Fix quick zoom mode (Z + Shift) cursor anchor and brush misalignment when the tldraw container is offset from the top-left of the window. ([#8520](https://github.com/tldraw/tldraw/pull/8520))
|
||||
- Fix `Cmd+Shift+V` doing nothing when the clipboard contains an image; the shortcut now falls back to the regular paste flow when there is no plain-text payload. ([#8490](https://github.com/tldraw/tldraw/pull/8490))
|
||||
- Fix keyboard shortcuts that change styles not marking the change as a style update, causing subsequent shapes to use the wrong defaults. ([#8599](https://github.com/tldraw/tldraw/pull/8599)) (contributed by [@danieljamesross](https://github.com/danieljamesross))
|
||||
- Fix "back to content" button flickering when both it and the "move focus to canvas" button are visible. ([#8334](https://github.com/tldraw/tldraw/pull/8334))
|
||||
- Fix intermittent `ResizeObserver loop` browser warnings when hovering over toolbar buttons. ([#8574](https://github.com/tldraw/tldraw/pull/8574))
|
||||
- Fix missing sandbox attribute on GitHub Gist embeds. ([#8403](https://github.com/tldraw/tldraw/pull/8403))
|
||||
- Restrict sandbox permissions for unknown/arbitrary embeds to mitigate security risks from untrusted content. ([#8404](https://github.com/tldraw/tldraw/pull/8404))
|
||||
- Fix duplicate presence cursors appearing after login or logout in multiplayer sessions by filtering a session's own stale record from the connect handshake response. ([#8542](https://github.com/tldraw/tldraw/pull/8542))
|
||||
- Fix sticky notes preserving stale attribution when cloned via a nib. ([#8614](https://github.com/tldraw/tldraw/pull/8614))
|
||||
- Fix `Cmd+V` paste of tldraw-copied PNGs on Chrome 147 stable, where the custom-format clipboard entry returned a 0-byte blob. ([#8615](https://github.com/tldraw/tldraw/pull/8615))
|
||||
- Fix note author attribution overflowing the sticky note width in SVG and PNG exports. ([#8664](https://github.com/tldraw/tldraw/pull/8664))
|
||||
- Fix keyboard shortcuts not matching on alternative Latin keyboard layouts (Dvorak, Colemak, AZERTY) by replacing the `hotkeys-js` library with a small layout-aware native event matcher. ([#8669](https://github.com/tldraw/tldraw/pull/8669))
|
||||
- Fix TypeScript signature mismatch on `ShapeUtil` base-class methods so overrides that need a `shape` parameter can declare it without errors. ([#8521](https://github.com/tldraw/tldraw/pull/8521))
|
||||
50
apps/docs/content/releases/v5.1.0.mdx
Normal file
50
apps/docs/content/releases/v5.1.0.mdx
Normal file
@@ -0,0 +1,50 @@
|
||||
---
|
||||
title: v5.1.0
|
||||
description: Page menu redesign, a copy-styles shortcut, a selectLockedShapes option, public translation APIs, and more
|
||||
author: tldraw
|
||||
date: 06/03/2026
|
||||
order: 0
|
||||
status: published
|
||||
keywords:
|
||||
- changelog
|
||||
- release
|
||||
- v5.1
|
||||
- v5.1.0
|
||||
- page-menu
|
||||
- copy-styles
|
||||
- locked-shapes
|
||||
- translations
|
||||
---
|
||||
|
||||
[View on GitHub](https://github.com/tldraw/tldraw/releases/tag/v5.1.0)
|
||||
|
||||
This release redesigns the page menu around inline interaction, adds a keyboard shortcut to copy styles from a hovered shape, and adds a `selectLockedShapes` option for inspecting locked shapes, along with new public translation APIs, canvas performance improvements, and various rendering and UI bug fixes.
|
||||
|
||||
## What's new
|
||||
|
||||
### Page menu redesign ([#8836](https://github.com/tldraw/tldraw/pull/8836))
|
||||
|
||||
The page menu no longer has an explicit edit mode. Reorder pages by dragging a row directly, rename inline by double-clicking the label or pressing Enter, and drag the new resize handle at the bottom of the popover to adjust the list height — the height is persisted across sessions and a double-click on the handle resets it to the default. The current page is now indicated by a subtle background pill, the row submenu trigger reveals on hover, and the "Create new page" button is pinned to the footer of the popover.
|
||||
|
||||
## API changes
|
||||
|
||||
- Add a `selectLockedShapes` option to `TldrawOptions`. When enabled, locked shapes can be selected by left click or by brush/scribble selection while remaining protected from edits, moves, and deletes. ([#8860](https://github.com/tldraw/tldraw/pull/8860))
|
||||
- Export `TldrawUiTranslationProvider`, `AssetUrlsProvider`, and `useAssetUrls` as public API so components like `TldrawSelectionForeground` can be rendered without the full `TldrawUiContextProvider`. ([#8909](https://github.com/tldraw/tldraw/pull/8909))
|
||||
- Add `FontManager.dispose()`, `OverlayManager.dispose()`, and `OverlayUtil.dispose()` for cleaning up manager state across editor lifecycles. ([#8896](https://github.com/tldraw/tldraw/pull/8896))
|
||||
|
||||
## Improvements
|
||||
|
||||
- Improve performance on busy canvases — `getRenderingShapes()` now skips its sort step when only shape props, not the set of shape ids, have changed. ([#8784](https://github.com/tldraw/tldraw/pull/8784))
|
||||
- Improve drawing performance on pages with many shapes by skipping spatial index and culling recomputation when only shape props change. ([#8799](https://github.com/tldraw/tldraw/pull/8799), [#8804](https://github.com/tldraw/tldraw/pull/8804))
|
||||
|
||||
## Bug fixes
|
||||
|
||||
- Fix a misleading "license expired" console warning for perpetual licenses on covered versions. ([#8791](https://github.com/tldraw/tldraw/pull/8791))
|
||||
- Fix inconsistent tooltip behavior on the video toolbar by using `TldrawUiToolbarButton` for the replace and download buttons. ([#8794](https://github.com/tldraw/tldraw/pull/8794))
|
||||
- Fix the missing open-state hint on the page menu and zoom menu triggers when rendered outside the main toolbar. ([#8813](https://github.com/tldraw/tldraw/pull/8813))
|
||||
- Mark the tldraw UI layer with `role="document"` so toolbars, menus, and dialogs stay reachable to mobile screen readers like VoiceOver and TalkBack, which do not announce the outer canvas `role="application"`. ([#8901](https://github.com/tldraw/tldraw/pull/8901))
|
||||
- Fix selection edge resize handles overlapping corner handles, which made corners hard to grab on small shapes. ([#8926](https://github.com/tldraw/tldraw/pull/8926))
|
||||
- Fix a bug where deleting a shape inside a group could move the group to a different z-index. ([#8925](https://github.com/tldraw/tldraw/pull/8925))
|
||||
- Avoid console errors from calling `preventDefault` on non-cancelable events. ([#8910](https://github.com/tldraw/tldraw/pull/8910))
|
||||
- Only log the missing-translation warning once per session instead of once per `useTranslation` consumer. ([#8909](https://github.com/tldraw/tldraw/pull/8909))
|
||||
- Catch `image.decode()` rejections from the icon preload effect so they no longer surface as uncaught promise errors in the console. ([#8824](https://github.com/tldraw/tldraw/pull/8824))
|
||||
111
apps/docs/content/releases/v5.2.0.mdx
Normal file
111
apps/docs/content/releases/v5.2.0.mdx
Normal file
@@ -0,0 +1,111 @@
|
||||
---
|
||||
title: v5.2.0
|
||||
description: Faster freehand ink, a frame selection action, SDK-wide performance improvements, and breaking changes to the collaborator user-id types and Node support
|
||||
author: tldraw
|
||||
date: 07/01/2026
|
||||
order: 0
|
||||
status: published
|
||||
keywords:
|
||||
- changelog
|
||||
- release
|
||||
- v5.2
|
||||
- v5.2.0
|
||||
- freehand-ink
|
||||
- frame-selection
|
||||
- performance
|
||||
- breaking-changes
|
||||
---
|
||||
|
||||
[View on GitHub](https://github.com/tldraw/tldraw/releases/tag/v5.2.0)
|
||||
|
||||
This release brings in performance improvements across tldraw. The freehand ink algorithm now computes strokes 2–3x faster, shape resizing is quicker, and editor load time is reduced. Multi-click handling was simplified to double-click only to support improved mobile gestures. Plus it's easier to add shapes to frames, and dozens of bugs were fixed.
|
||||
|
||||
## What's new
|
||||
|
||||
### Faster freehand ink ([#9154](https://github.com/tldraw/tldraw/pull/9154))
|
||||
|
||||
We rewrote the freehand ink algorithm. Draw and highlighter strokes now compute about 2x faster (up to 3x for long strokes) and produce 57% smaller SVG path data, with no visible change to how strokes look. The rewrite removes two rarely-used stroke APIs; see the breaking changes in [API changes](#api-changes).
|
||||
|
||||
### Frame selection ([#8151](https://github.com/tldraw/tldraw/pull/8151))
|
||||
|
||||
A new "Frame selection" action wraps the selected shapes in a frame with `cmd+shift+f`. When a frame is selected, the same shortcut removes it.
|
||||
|
||||
## API changes
|
||||
|
||||
- 💥 Simplify multi-click handling to double-click only. `ClickManager` no longer tracks triple or quadruple clicks: the `triple_click` and `quadruple_click` events and the `onTripleClick` / `onQuadrupleClick` handlers have been removed, and `TLClickEventName` is now just `'double_click'`. ([#8897](https://github.com/tldraw/tldraw/pull/8897))
|
||||
- 💥 Move `ShapeIndicatorOverlayUtil` and `TLShapeIndicatorOverlay` from `@tldraw/editor` to `tldraw`. Both are still exported from `tldraw`; update any imports of these symbols that came directly from `@tldraw/editor`. ([#9018](https://github.com/tldraw/tldraw/pull/9018))
|
||||
- 💥 Drop support for the EOL Node 20. The minimum supported Node is now `>=22.12.0`, the first version with native `require()` of ES modules, so tldraw can depend on ESM-only packages. ([#9098](https://github.com/tldraw/tldraw/pull/9098))
|
||||
- 💥 Type the collaborator user-id APIs as `TLUserId` instead of `string`. `TLInstancePresence.userId` / `followingUserId`, `TLInstance.followingUserId` / `highlightedUserIds`, `Editor.startFollowingUser()`, `Editor.zoomToUser()`, `usePresence()`, `usePeerIds()`, and the people-menu prop types now use `TLUserId`. Runtime behavior is unchanged: `TLUserId` is a branded string, so cast plain strings at the call site, for example `editor.startFollowingUser(userId as TLUserId)`. ([#9002](https://github.com/tldraw/tldraw/pull/9002))
|
||||
- 💥 Remove `StrokePoint.vector` and `setStrokePointRadii` from the freehand stroke pipeline. The vector is derivable from consecutive points and radius/taper computation now happens inside `getStroke` and `svgInk`; nothing in the SDK consumed these outside the pipeline itself. ([#9154](https://github.com/tldraw/tldraw/pull/9154))
|
||||
- 💥 Remove the obsolete selection-background slot from `@tldraw/editor`: `DefaultSelectionBackground`, `TLSelectionBackgroundProps`, `TLEditorComponents.SelectionBackground`, and the `useTransform` helper. These were unused and have no replacement; remove any references to them. ([#9362](https://github.com/tldraw/tldraw/pull/9362))
|
||||
- Add an `embedConfig` option to `EmbedShapeUtil` for passing per-embed configuration such as API keys. The default Google Maps embed no longer reads `process.env.NEXT_PUBLIC_GC_API_KEY` — pass the key explicitly instead. ([#9068](https://github.com/tldraw/tldraw/pull/9068))
|
||||
|
||||
```ts
|
||||
EmbedShapeUtil.configure({
|
||||
embedConfig: { google_maps: { apiKey: '...' } },
|
||||
})
|
||||
```
|
||||
|
||||
- Add an `allowVideoAutoplay` option to `TldrawOptions`. When set to `false`, no video shape autoplays regardless of its per-shape `autoplay` prop — including pasted and restored shapes. Defaults to `true`. ([#8943](https://github.com/tldraw/tldraw/pull/8943))
|
||||
- Add `ShapeUtil.getContentElement` and `ShapeUtil.onReleaseContentElement`, an app-owned content element lifecycle that lets stateful shape content (such as cross-origin iframes) survive unmounting the editor. ([#9167](https://github.com/tldraw/tldraw/pull/9167))
|
||||
|
||||
## Improvements
|
||||
|
||||
- Open the context menu with a right-click from any tool, not just the select tool, and with a touch long-press while using the select tool. Long-pressing on a shape-creation tool no longer leaves a stray shape behind. ([#8948](https://github.com/tldraw/tldraw/pull/8948))
|
||||
- Add a `shift+q` shortcut that copies the styles of the hovered shape and applies them to the next shapes you create. ([#9028](https://github.com/tldraw/tldraw/pull/9028))
|
||||
- Add a double-tap-and-drag zoom gesture for touch devices. ([#7858](https://github.com/tldraw/tldraw/pull/7858))
|
||||
- As you drag out a frame, the frame tool now highlights the shapes it will enclose. ([#9102](https://github.com/tldraw/tldraw/pull/9102))
|
||||
- Pasted bookmarks now show an immediate loading placeholder with a spinner at the paste location, instead of waiting for metadata before appearing. ([#8663](https://github.com/tldraw/tldraw/pull/8663))
|
||||
- Add the missing keyboard shortcuts (copy as PNG, print, insert embed, highlight tool, frame selection, flatten, toggle locked, distribute horizontally/vertically) to the keyboard shortcuts dialog. ([#9285](https://github.com/tldraw/tldraw/pull/9285))
|
||||
- Make the selection corner rotate handles easier to grab. ([#9374](https://github.com/tldraw/tldraw/pull/9374))
|
||||
- Reduce the default indentation on rich-text lists so nested lists take up less horizontal space. ([#9287](https://github.com/tldraw/tldraw/pull/9287))
|
||||
- Hide the "back to content" button during the animation that pans back to the canvas when focusing it. ([#9278](https://github.com/tldraw/tldraw/pull/9278))
|
||||
- Remove the extraneous `title` tooltips on menu items. ([#9315](https://github.com/tldraw/tldraw/pull/9315))
|
||||
- `@tldraw/mermaid` now loads its ESM-only `mermaid` dependency lazily, so the package is requirable from CommonJS and mermaid only loads when a diagram is created. ([#9100](https://github.com/tldraw/tldraw/pull/9100))
|
||||
- Keep sync at full speed when the same multiplayer room is open in multiple tabs, windows, or devices — previously a room with no other users throttled network sync to once per second. ([#8988](https://github.com/tldraw/tldraw/pull/8988))
|
||||
- Include `DOCS.md` documentation in published npm packages, and a generated docs and release notes rollup in the `tldraw` package, so documentation is available when browsing package artifacts. ([#8503](https://github.com/tldraw/tldraw/pull/8503))
|
||||
- Shrink stored draw shape data for mouse, trackpad, and finger input by ~33% by omitting the redundant constant-pressure value from the encoded path. ([#8963](https://github.com/tldraw/tldraw/pull/8963))
|
||||
- Improve performance when resizing many shapes at once. ([#9153](https://github.com/tldraw/tldraw/pull/9153))
|
||||
- Improve change tracking and sync performance when many shapes change at once. ([#9151](https://github.com/tldraw/tldraw/pull/9151), [#9152](https://github.com/tldraw/tldraw/pull/9152))
|
||||
- Improve validation performance during high-frequency updates. ([#9150](https://github.com/tldraw/tldraw/pull/9150))
|
||||
- Speed up editor load by caching generated pattern fill defs and deduplicating icon preloads, so neither is rebuilt on every editor mount. ([#9345](https://github.com/tldraw/tldraw/pull/9345), [#9351](https://github.com/tldraw/tldraw/pull/9351))
|
||||
- Remove the `jittered-fractional-indexing` dependency and speed up index key generation and validation; newly generated index keys are slightly shorter. ([#9352](https://github.com/tldraw/tldraw/pull/9352))
|
||||
- Improve minimap rendering performance on large documents. ([#9355](https://github.com/tldraw/tldraw/pull/9355))
|
||||
|
||||
## Bug fixes
|
||||
|
||||
- Fix the fill style dropdown trigger showing a misleading tooltip for the currently selected fill. ([#9023](https://github.com/tldraw/tldraw/pull/9023))
|
||||
- Fix a two-finger pinch unintentionally changing the selection on touch devices. ([#9006](https://github.com/tldraw/tldraw/pull/9006))
|
||||
- Pasting content while dragging, translating, resizing, or rotating no longer steals selection from the shape being manipulated. ([#8976](https://github.com/tldraw/tldraw/pull/8976))
|
||||
- Fix a bug where pressing `Tab` while editing a list inside a note shape created a new note instead of indenting the list item. ([#8958](https://github.com/tldraw/tldraw/pull/8958))
|
||||
- Fix the viewport shifting slightly when zooming past the minimum or maximum zoom level. ([#8957](https://github.com/tldraw/tldraw/pull/8957))
|
||||
- Improve the contrast of disabled buttons and menu items in dark mode. ([#8931](https://github.com/tldraw/tldraw/pull/8931))
|
||||
- Fix `debounce(...).cancel()` leaving an awaited call hanging forever; it now rejects the pending promise. ([#8683](https://github.com/tldraw/tldraw/pull/8683))
|
||||
- Fix arrows jumping to incorrect positions after undoing or redoing shape deletions involving bindings. ([#8283](https://github.com/tldraw/tldraw/pull/8283))
|
||||
- Fix touch long-press on shape-creation tools so the context menu opens without leaving a stray shape behind. ([#8946](https://github.com/tldraw/tldraw/pull/8946))
|
||||
- Fix text labels drifting vertically between browsers (e.g. Chrome vs Safari) in multiplayer by snapping line-height to whole pixels. ([#8981](https://github.com/tldraw/tldraw/pull/8981))
|
||||
- Fix collaborators not appearing until they interact with the board, idle following peers not being hidden, the "following you" indicator not showing in the people menu, and a phantom cursor for your own other browser sessions. ([#9088](https://github.com/tldraw/tldraw/pull/9088), [#9002](https://github.com/tldraw/tldraw/pull/9002))
|
||||
- Fix releasing a modifier while still holding a shortcut key triggering the adjacent non-modifier shortcut. ([#9099](https://github.com/tldraw/tldraw/pull/9099))
|
||||
- Fix redo (`cmd+shift+z`) not firing after an undo (`cmd+z`) on macOS when cmd is kept held. ([#9139](https://github.com/tldraw/tldraw/pull/9139))
|
||||
- Fix releasing a fast right-click pan zooming the camera to the maximum zoom level. ([#9140](https://github.com/tldraw/tldraw/pull/9140))
|
||||
- Fix selection handles disappearing when pressing (but not yet dragging) a resize edge, resize corner, or rotate handle, and keep selection chrome consistent while pointing. ([#9179](https://github.com/tldraw/tldraw/pull/9179), [#9184](https://github.com/tldraw/tldraw/pull/9184))
|
||||
- Fix double-clicking an arrow handle no longer toggling the arrowhead on that end. ([#9180](https://github.com/tldraw/tldraw/pull/9180))
|
||||
- Fix the camera jumping when a one-finger hand-tool pan transitions into a two-finger pinch zoom. ([#9182](https://github.com/tldraw/tldraw/pull/9182))
|
||||
- Fix arrows becoming partially un-hoverable and un-clickable when bindings were created or updated without a shape change (e.g. `createBindings` after `createShapes`). ([#9183](https://github.com/tldraw/tldraw/pull/9183))
|
||||
- Fix camera state getting stuck at `moving` when the editor is disposed during camera movement (e.g. React strict mode with a shared store). ([#9201](https://github.com/tldraw/tldraw/pull/9201))
|
||||
- Fix the following indicator border being hidden beneath UI panels. ([#9210](https://github.com/tldraw/tldraw/pull/9210))
|
||||
- Fix the sync version-mismatch handshake reporting `CLIENT_TOO_OLD` when the server is the one running an older version; it now correctly reports `SERVER_TOO_OLD`. ([#9212](https://github.com/tldraw/tldraw/pull/9212))
|
||||
- Fix ctrl+click on macOS not selecting locked shapes or opening the context menu; it now behaves like a right-click. ([#9234](https://github.com/tldraw/tldraw/pull/9234))
|
||||
- Fix a crash that could occur when undo or redo restored an editing state for a shape that no longer exists. ([#9260](https://github.com/tldraw/tldraw/pull/9260))
|
||||
- Fix the line tool's minimum bend distance so it's measured in screen pixels. Shift-click now behaves the same at every zoom level. ([#9275](https://github.com/tldraw/tldraw/pull/9275))
|
||||
- Fix style scrubbing selecting the wrong option when you release the pointer over a different style section. ([#9280](https://github.com/tldraw/tldraw/pull/9280))
|
||||
- Fix empty rich text (an empty array) not being treated as an empty label. ([#9295](https://github.com/tldraw/tldraw/pull/9295))
|
||||
- Fix desktop graphics tablets (e.g. Wacom) incorrectly switching the editor into pen mode. Pen mode now auto-enables only for direct-display stylus input such as Apple Pencil on iPad or Surface Pen on a touchscreen. ([#9316](https://github.com/tldraw/tldraw/pull/9316))
|
||||
- Fix inconsistent easing when clicking the minimap to move the camera. ([#9355](https://github.com/tldraw/tldraw/pull/9355))
|
||||
- Fix sync clients crashing when a malformed WebSocket message is received. ([#9356](https://github.com/tldraw/tldraw/pull/9356))
|
||||
- Fix a crash when measuring viewport bounds after the editor document body is unavailable. ([#9357](https://github.com/tldraw/tldraw/pull/9357))
|
||||
- Fix highlighted rich text showing an outline in dark mode. ([#9358](https://github.com/tldraw/tldraw/pull/9358))
|
||||
- Fix text in exported images sometimes being sized with a fallback font when the export ran before the document's fonts had finished loading. ([#9372](https://github.com/tldraw/tldraw/pull/9372))
|
||||
- Fix a crash when a shape is deleted mid-click (e.g. by a remote user or an undo) while clicking on it. ([#9376](https://github.com/tldraw/tldraw/pull/9376))
|
||||
- Fix the license manager treating loopback hosts other than `localhost` (such as `127.0.0.1` and `::1`) as production environments. ([#9384](https://github.com/tldraw/tldraw/pull/9384))
|
||||
- Fix the crop of a grouped, cropped image shifting when the group is flipped by dragging a resize handle. ([#9395](https://github.com/tldraw/tldraw/pull/9395))
|
||||
127
apps/docs/content/releases/v5.3.0.mdx
Normal file
127
apps/docs/content/releases/v5.3.0.mdx
Normal file
@@ -0,0 +1,127 @@
|
||||
---
|
||||
title: v5.3.0
|
||||
description: Commenting with anchored threads on the canvas, new collaboration packages, geo shape flipping, crop snapping, and faster viewport culling
|
||||
author: tldraw
|
||||
date: 08/05/2026
|
||||
order: 0
|
||||
status: published
|
||||
keywords:
|
||||
- changelog
|
||||
- release
|
||||
- v5.3
|
||||
- v5.3.0
|
||||
- v5.3.1
|
||||
- v5.3.2
|
||||
- commenting
|
||||
- comments
|
||||
- collaboration
|
||||
- geo-flip
|
||||
- crop-snapping
|
||||
---
|
||||
|
||||
[View on GitHub](https://github.com/tldraw/tldraw/releases/tag/v5.3.0)
|
||||
|
||||
This release adds commenting: a set of new packages for anchored comment threads on the canvas, backed by comment records in the schema and sync support for non-document data. Commenting is the first piece of our new collaboration package, a separately licensed section of the SDK for premium collaboration features. The release also adds flipping for geo shapes, snapping and symmetric resizing while cropping images, an `assets` prop for `TldrawImage`, and a reactive `Editor.getIsMounted()` with a new `unmount` event. Viewport culling is faster on large pages, and there's a batch of fixes for crashes and interaction bugs across arrows, groups, and text.
|
||||
|
||||
## What's new
|
||||
|
||||
### Commenting ([#9471](https://github.com/tldraw/tldraw/pull/9471), [#9782](https://github.com/tldraw/tldraw/pull/9782), [#9850](https://github.com/tldraw/tldraw/pull/9850))
|
||||
|
||||
Comments are now a first-class primitive in the SDK. `@tldraw/commenting` ships a batteries-included canvas comments layer: threads anchor to a point, a shape (precisely or imprecisely), a text range, a region, or the page. You can reply, edit, react, resolve, and delete comments. The pins drag to re-anchor, and `?comment=<id>` deep links open a thread and bring the camera to its pin. Comment pins cluster together as you zoom out and separate back out as you zoom in.
|
||||
|
||||
Comment records live in `@tldraw/tlschema` as `TLCommentThread`, `TLComment`, and `TLCommentReaction`, and sync over a new generic object store in `@tldraw/sync-core`, so they ride alongside the document rather than inside it. On the server, `@tldraw/sync-collaboration` enforces write authorization, with a `canModifyComment` rule that lets a host widen the default owner-only permission. That way a workspace admin can moderate comments they don't own without granting anything beyond edit, delete, and resolve.
|
||||
|
||||
One rollout note: the comment record types ship with guard migrations, so once your server registers them, clients on an older SDK are rejected as too old and prompted to refresh. Roll out the upgraded client before or alongside the server change.
|
||||
|
||||
The layer is built to be taken apart. `ThreadRow` and `ThreadActions` are component slots, `CommentListItem` and `sortSidebarRows` are exported so a hand-built list can match the built-in one, and a host that supplies `getThreadHref` gets copy-link in the thread menu. On phones, the composer and the open thread stay above the software keyboard.
|
||||
|
||||
<details>
|
||||
<summary>New packages</summary>
|
||||
|
||||
| Package | What it provides |
|
||||
| ---------------------------- | ------------------------------------------------------------------------ |
|
||||
| `@tldraw/commenting` | Canvas comments layer, comment UI components, and the comment tool |
|
||||
| `@tldraw/mentions` | @-mention picker and TipTap node, shared by comments and shape rich text |
|
||||
| `@tldraw/collaboration` | Umbrella package for the collaboration pieces |
|
||||
| `@tldraw/sync-collaboration` | Server-side write authorization for collaboration features |
|
||||
|
||||
</details>
|
||||
|
||||
See the [commenting docs](https://tldraw.dev/docs/commenting) for setup.
|
||||
|
||||
## API changes
|
||||
|
||||
- 💥 Remove `usePassThroughMouseOverEvents` from `@tldraw/editor`. It re-dispatched `mouseover` at `.tl-canvas` so shapes under a floating panel stayed hovered, but the canvas only wires `onPointerEnter`, so the re-dispatched event arrived at an element with no listener for it. ([#9782](https://github.com/tldraw/tldraw/pull/9782))
|
||||
- Add `TLCommentThread`, `TLComment`, and `TLCommentReaction` records to `@tldraw/tlschema`, with `createCommentThread` and `createComment` helpers, id helpers, validators, and `commentSchemaRecords` for registering them. ([#9471](https://github.com/tldraw/tldraw/pull/9471), [#9782](https://github.com/tldraw/tldraw/pull/9782))
|
||||
- Add a generic object store to `@tldraw/sync-core` for syncing non-document records: `objectTypes` on the room and storage constructors, per-session `TLObjectStoreAccess`, `getObjectsSnapshot()`, `onCommittedChanges`, and an optional `TLSyncStorage.getObjectsByIds(ids)` for callers that need a few object-lane records rather than the whole lane. ([#9471](https://github.com/tldraw/tldraw/pull/9471), [#9782](https://github.com/tldraw/tldraw/pull/9782))
|
||||
- Add `objectAccess` to the `synced-remote` member of `RemoteTLStoreWithStatus`, so a store from `useSync` reports whether the session can write object-lane records such as comments. If you construct that type yourself, in test mocks for example, you now need to supply `objectAccess`. ([#9471](https://github.com/tldraw/tldraw/pull/9471))
|
||||
- Add `EditorPortal`, `EditorPortalProps`, and `useEditorPortalHost` to `@tldraw/editor`, for portalling canvas-anchored content into a host the editor renders last so its DOM order doesn't depend on mount timing. ([#9782](https://github.com/tldraw/tldraw/pull/9782))
|
||||
- Add `renderHtmlFromRichTextWithExtensions(richText, extensions)` for rendering rich text outside a shape's editor config. `renderHtmlFromRichText` now fills _every_ empty paragraph with a `<br />` and keeps the paragraph's attributes, so an empty paragraph renders as `<p dir="auto"><br /></p>` — worth knowing if you snapshot that output. ([#9782](https://github.com/tldraw/tldraw/pull/9782))
|
||||
- Add `flipX` and `flipY` to `TLGeoShapeProps`, with an `AddFlipProps` migration defaulting both to `false`, and add `PathBuilder.transform(mat)` for producing a transformed copy of a path. ([#9530](https://github.com/tldraw/tldraw/pull/9530))
|
||||
- Add an optional `sizeToContentAspectRatio` to `EmbedDefinition` and a public `EmbedShapeUtil.resolveAspectRatio(shape)`. When set, an embed corrects its size after creation to its content's real aspect ratio, resolved from the URL's OpenGraph metadata via the editor's url asset handler. Existing custom embed definitions are unaffected. ([#9613](https://github.com/tldraw/tldraw/pull/9613))
|
||||
- Add `ExportAsMenuGroup`, mirroring `CopyAsMenuGroup`, for the "Export as" submenu. ([#9589](https://github.com/tldraw/tldraw/pull/9589))
|
||||
- Add `EditorManager`, a public abstract base class for editor managers that own a subscription or resource, so they share one teardown contract. `InputsManager`, `TickManager`, `FocusManager`, and `TextManager` now extend it. ([#9204](https://github.com/tldraw/tldraw/pull/9204))
|
||||
- Populate `TLBaseExternalContent.sources` for file pastes, where it was previously always undefined, so custom `files` handlers and `onBeforePasteFromClipboard` can inspect the text, HTML, and url content that came alongside the files. ([#9763](https://github.com/tldraw/tldraw/pull/9763))
|
||||
- Add an `assets` prop to `TldrawImage`, so a snapshot's images render from a custom asset store the same way they do in `Tldraw`. Previously `TldrawImage` always used the inline base64 asset store, so images kept anywhere else were missing. ([#9697](https://github.com/tldraw/tldraw/pull/9697)) (based on an implementation by [@jon-dez](https://github.com/jon-dez))
|
||||
|
||||
```tsx
|
||||
<TldrawImage snapshot={snapshot} assets={assetStore} />
|
||||
```
|
||||
|
||||
- Add an `unmount` event to `TLEventMap` and make `Editor.getIsMounted()` a reactive value that tracks whether the editor's component is currently mounted. ([#9472](https://github.com/tldraw/tldraw/pull/9472), [#9483](https://github.com/tldraw/tldraw/pull/9483))
|
||||
- Make the shape hit-test margin pointer-aware and add a `coarseHitTestMargin` option: fine pointers (mouse and trackpad) use a tighter 3px default, coarse pointers (touch and pen) use 4px. ([#9492](https://github.com/tldraw/tldraw/pull/9492))
|
||||
- Add a `ShapeUtil.onDuplicate(source, duplicate)` callback, invoked by `Editor.duplicateShapes` and `Editor.putContentOntoCurrentPage`, so a shape can adjust its props when it is copied. Sticky notes now use it to attribute duplicated or pasted text to the current user instead of carrying over the original author. ([#9566](https://github.com/tldraw/tldraw/pull/9566))
|
||||
|
||||
## Improvements
|
||||
|
||||
- Geo shapes now flip. Previously flipping a selection left directional shapes such as the rhombus, trapezoid, and arrow geo shapes looking exactly the same on the other side of the mirror. `TLGeoShape` gains `flipX` and `flipY` props, matching the image shape, and a flip mirrors the shape's generated path. Geometry, hit-testing, the selection indicator, the canvas render, and SVG export all read from that path, so they stay in agreement. ([#9530](https://github.com/tldraw/tldraw/pull/9530))
|
||||
- When cropping an image, crop edges now snap to other shapes and the grid, with the usual snapping guidelines. Holding Alt resizes the crop region symmetrically from its center. ([#9424](https://github.com/tldraw/tldraw/pull/9424))
|
||||
- Size embeds from providers that report their content's dimensions (currently Vimeo) to that content's real aspect ratio, so a video is no longer letterboxed inside a 16:9 box. The corrected embed keeps the area of the original box, so a portrait video doesn't balloon over whatever sits above and below it. ([#9613](https://github.com/tldraw/tldraw/pull/9613), [#9756](https://github.com/tldraw/tldraw/pull/9756))
|
||||
- Hide the "Copy as", "Export as", and "Export file" submenus, and the main menu's export group, when a consumer has removed all of their actions through `overrides`, instead of leaving an empty submenu trigger behind. ([#9589](https://github.com/tldraw/tldraw/pull/9589))
|
||||
- Improve viewport culling performance on large pages: the spatial index now rechecks only the shapes affected by a change instead of every shape on the page. Custom shapes whose geometry depends on unrelated shapes (outside parent/child and binding relationships) are no longer automatically rechecked for culling when those shapes change; built-in shapes and standard patterns are unaffected. ([#9093](https://github.com/tldraw/tldraw/pull/9093))
|
||||
- Improve performance when deleting many shapes at once by batching page state cleanup. ([#9456](https://github.com/tldraw/tldraw/pull/9456))
|
||||
- Make long toast and dialog content wrap instead of overflowing, so long strings like URLs stay within the toast or dialog. ([#9546](https://github.com/tldraw/tldraw/pull/9546))
|
||||
- Delay the appearance of the "back to content" button by about a second so it no longer flickers during normal panning and zooming. Hiding stays immediate. ([#9448](https://github.com/tldraw/tldraw/pull/9448))
|
||||
- Remove the unnecessary ellipsis from the "Keyboard shortcuts" menu item. ([#9570](https://github.com/tldraw/tldraw/pull/9570))
|
||||
|
||||
## Bug fixes
|
||||
|
||||
- Fix zooming and panning not working while the cursor is over a non-scrollable UI overlay. The wheel pass-through guard treated any element whose content overflowed as scrollable, and a hover transform on a small overlay was enough to trigger that. Overlays now count as scrollable only when their computed `overflow` says so. ([#9698](https://github.com/tldraw/tldraw/pull/9698))
|
||||
- Fix a crash when dragging content from another browser tab whose URL isn't a valid link, which happens when an ad blocker rewrites the dragged URL to something like `about:blank#blocked`. Dropping one now shows an error toast instead of failing validation on the bookmark shape. ([#9598](https://github.com/tldraw/tldraw/pull/9598))
|
||||
- Fix the "URL" label wrapping onto two lines in the edit link and embed dialogs. ([#9663](https://github.com/tldraw/tldraw/pull/9663))
|
||||
- Fix rich text headings (H1–H6) rendering with their line-height too tight, causing overlapping lines. ([#9543](https://github.com/tldraw/tldraw/pull/9543))
|
||||
- Fix the style panel not highlighting the active fill correctly when the current fill lives in the overflow dropdown. ([#9509](https://github.com/tldraw/tldraw/pull/9509))
|
||||
- Fix the style panel Shape, Line, and spline dropdown popovers so they open with the same gap as the other dropdowns. ([#9491](https://github.com/tldraw/tldraw/pull/9491))
|
||||
- Fix double-clicking a shape inside an unfocused group immediately editing it; a double-click now drills one level into the group. ([#9488](https://github.com/tldraw/tldraw/pull/9488))
|
||||
- Fix selected clipped arrows so they can be dragged when their geometry extends outside a clipping parent. ([#9479](https://github.com/tldraw/tldraw/pull/9479))
|
||||
- Fix arrows bound to frames so they duplicate and delete with the frame without being clipped. ([#9470](https://github.com/tldraw/tldraw/pull/9470))
|
||||
- Fix a shape dragged from the toolbar to a frame beneath the toolbar not being parented to the frame. ([#9458](https://github.com/tldraw/tldraw/pull/9458))
|
||||
- Fix a missing `onChildrenChange` call when creating a shape directly within a parent shape. ([#9455](https://github.com/tldraw/tldraw/pull/9455))
|
||||
- Fix the reactive Meta (Cmd) key state not clearing on keyup until a pointer event on macOS, which affected `getMetaKey()` and `getAccelKey()`. ([#9404](https://github.com/tldraw/tldraw/pull/9404))
|
||||
- Fix a crash when dragging a handle on a line whose point keys differed from their indices. ([#9299](https://github.com/tldraw/tldraw/pull/9299))
|
||||
- Fix arrow bindings floating outside a geo shape after changing its geo type; precise bindings now re-snap to the nearest point on the new shape. ([#9281](https://github.com/tldraw/tldraw/pull/9281))
|
||||
- Fix an occasional crash in text measurement by iterating over grapheme clusters instead of individual characters, keeping emoji, flags, and accented characters intact. ([#9266](https://github.com/tldraw/tldraw/pull/9266))
|
||||
- Fix a crash ("Shape not found") when dragging a tool from the toolbar in a read-only room. ([#9225](https://github.com/tldraw/tldraw/pull/9225))
|
||||
- Fix sync clients not recovering from a malformed WebSocket message; they now restart the connection so a corrupt frame can no longer silently leave the client out of sync. ([#9380](https://github.com/tldraw/tldraw/pull/9380))
|
||||
- Fix uploaded asset handling for invalid R2 object names. ([#9495](https://github.com/tldraw/tldraw/pull/9495))
|
||||
- Fix a crash when a custom font failed to load after its editor had been disposed. ([#9494](https://github.com/tldraw/tldraw/pull/9494))
|
||||
- Fix license expiry being evaluated in local time, which could deactivate a license a calendar day early for users west of UTC; the expiry date is now treated as the last fully usable day in UTC. ([#9475](https://github.com/tldraw/tldraw/pull/9475))
|
||||
|
||||
---
|
||||
|
||||
## Patch releases
|
||||
|
||||
### v5.3.1
|
||||
|
||||
- Fix touches in part of the canvas being ignored on iOS when the editor was inset from the edge of the page, such as beside a sidebar. ([#9989](https://github.com/tldraw/tldraw/pull/9989))
|
||||
|
||||
[View release on GitHub](https://github.com/tldraw/tldraw/releases/tag/v5.3.1)
|
||||
|
||||
### v5.3.2
|
||||
|
||||
- Fix default UI components, such as `DefaultToolbar` and `DefaultQuickActionsContent`, crashing when mounted outside the `Tldraw` component. ([#10007](https://github.com/tldraw/tldraw/pull/10007))
|
||||
- Fix license validation treating native apps served from custom protocols as development environments. ([#10021](https://github.com/tldraw/tldraw/pull/10021))
|
||||
- Fix wildcard license host matching so `*.example.com` matches only that domain's subdomains. ([#10053](https://github.com/tldraw/tldraw/pull/10053))
|
||||
- Add product metadata to each published package's `package.json`, so which product or feature a package implements is clearer, and ship the missing `LICENSE.md` files. ([#9977](https://github.com/tldraw/tldraw/pull/9977))
|
||||
|
||||
[View release on GitHub](https://github.com/tldraw/tldraw/releases/tag/v5.3.2)
|
||||
199
apps/docs/content/releases/v5.4.0.mdx
Normal file
199
apps/docs/content/releases/v5.4.0.mdx
Normal file
@@ -0,0 +1,199 @@
|
||||
---
|
||||
title: v5.4.0
|
||||
description: A center align operation, a tleditors registry of mounted editors, faster signals and store layers, and a broad reliability pass across the SDK
|
||||
author: tldraw
|
||||
date: 09/02/2026
|
||||
order: 0
|
||||
status: published
|
||||
keywords:
|
||||
- changelog
|
||||
- release
|
||||
- v5.4
|
||||
- v5.4.0
|
||||
- v5.4.1
|
||||
- align-center
|
||||
- tleditors
|
||||
- reliability
|
||||
- signals
|
||||
---
|
||||
|
||||
[View on GitHub](https://github.com/tldraw/tldraw/releases/tag/v5.4.0)
|
||||
|
||||
This release adds a `center` operation to `Editor.alignShapes`, a `tleditors` registry of mounted editors, `iterateGraphemes` to `@tldraw/utils`, and two new reactive methods on `AtomMap`. It also deprecates `useViewportHeight`, speeds up the signals and store layers, and lands a broad reliability pass across the SDK, with over a hundred fixes to undo history, selection and tool interactions, cropping, arrows, the signals and store layers, sync, and local persistence.
|
||||
|
||||
## API changes
|
||||
|
||||
- 🔜 **`useViewportHeight()`** is deprecated. Read `window.visualViewport` directly instead. The hook returns only the visible viewport's bottom edge, and keeping a panel clear of the software keyboard generally needs the whole visible rectangle. ([#9826](https://github.com/tldraw/tldraw/pull/9826))
|
||||
- Add `'center'` as an `Editor.alignShapes` operation. It applies the existing `'center-horizontal'` and `'center-vertical'` operations together, so shapes center on both axes in one command. ([#9074](https://github.com/tldraw/tldraw/pull/9074))
|
||||
- Add `tleditors`, a reactive global registry of currently mounted editors. Use it to reach live editors from outside the React tree, for example from sidebar chrome or a keyboard shortcut handler. ([#9904](https://github.com/tldraw/tldraw/pull/9904))
|
||||
|
||||
```tsx
|
||||
import { tleditors, useValue } from 'tldraw'
|
||||
|
||||
const mounted = useValue('mounted editors', () => tleditors.getMounted(), [])
|
||||
```
|
||||
|
||||
- Add `getOrInsert(key, defaultValue)` and `getOrInsertComputed(key, callback)` to `AtomMap`, matching the `Map` methods added in newer JavaScript runtimes. Both are reactive, and the callback only runs when the key is absent. ([#9752](https://github.com/tldraw/tldraw/pull/9752))
|
||||
- Add `isPage(record)` to `@tldraw/tlschema`, a type guard that narrows an unknown record to `TLPage`, matching the guards already exported for the other record types. ([#9774](https://github.com/tldraw/tldraw/pull/9774))
|
||||
- Add `iterateGraphemes(str)` to `@tldraw/utils`. It iterates a string's grapheme clusters, using `Intl.Segmenter` when it's available and falling back to code-point iteration when it isn't. ([#9896](https://github.com/tldraw/tldraw/pull/9896))
|
||||
- `EmojiPickerProps.onSelect` now receives the originating event as an optional second argument. Custom reaction palettes can forward it to opt into shift multi-select. ([#9999](https://github.com/tldraw/tldraw/pull/9999))
|
||||
|
||||
## Improvements
|
||||
|
||||
- Shift-click an emoji in a comment's reaction palette to add several reactions without the palette closing. A plain click still applies the reaction and closes the palette. ([#9999](https://github.com/tldraw/tldraw/pull/9999))
|
||||
- Keyboard input now counts as collaborator presence activity, so peers no longer fade out and disappear while someone is typing. ([#9929](https://github.com/tldraw/tldraw/pull/9929))
|
||||
- Reduce per-pointer-event allocations in `Group2d` and `Editor` hit-testing. ([#8600](https://github.com/tldraw/tldraw/pull/8600))
|
||||
- Speed up signal reads in `@tldraw/state`. Capturing a parent used to scan the capturing signal's parent set twice, once to check for the parent and again to add it. ([#9690](https://github.com/tldraw/tldraw/pull/9690))
|
||||
- Reduce per-signal memory use and per-flush allocations in `@tldraw/state`. ([#10155](https://github.com/tldraw/tldraw/pull/10155))
|
||||
- Speed up editor hot paths: set lookups replace array scans in the selection side effect and the erasing check, `getCollaborators` makes a single pass over the presence records, and `getShapeUtil` only builds its assert message when the lookup fails. ([#10358](https://github.com/tldraw/tldraw/pull/10358))
|
||||
- Reduce the work and allocations in `@tldraw/store` history bookkeeping, so squashing record diffs and filtering query history no longer build intermediate copies on every tick. ([#9689](https://github.com/tldraw/tldraw/pull/9689), [#9869](https://github.com/tldraw/tldraw/pull/9869))
|
||||
- Move the `exportBackground` default (`true`) into the `TLInstance` record type in `@tldraw/tlschema`, so every code path that creates the instance record gets the same value. ([#10534](https://github.com/tldraw/tldraw/pull/10534))
|
||||
|
||||
## Bug fixes
|
||||
|
||||
### Crashes
|
||||
|
||||
- Fix the editor failing to load on older browsers that lack `Intl.Segmenter`, such as Firefox before 125. ([#9896](https://github.com/tldraw/tldraw/pull/9896))
|
||||
- Fix the frame loop stopping permanently when a `tick` or `frame` listener threw, which silently disabled camera animations, edge scrolling, and other frame-driven behavior for the rest of the session. ([#9946](https://github.com/tldraw/tldraw/pull/9946))
|
||||
- Fix a crash when computing a frame's geometry if its heading text couldn't be measured yet. ([#9496](https://github.com/tldraw/tldraw/pull/9496))
|
||||
- Fix a crash in hit testing when a custom frame-like shape uses a plain, non-group geometry. ([#10328](https://github.com/tldraw/tldraw/pull/10328))
|
||||
- Fix a crash when pressing Enter with a frame, video, or embed selected while the geo, arrow, or text tool is active. ([#10195](https://github.com/tldraw/tldraw/pull/10195))
|
||||
- Fix default UI components such as `DefaultToolbar` crashing when mounted outside the `Tldraw` component. ([#10007](https://github.com/tldraw/tldraw/pull/10007))
|
||||
|
||||
### Undo and history
|
||||
|
||||
- Fix undo after a cut also reverting the change made before it. ([#9920](https://github.com/tldraw/tldraw/pull/9920))
|
||||
- Fix several actions merging into the previous undo step instead of creating their own: **Unlock all**, changing an arrowhead, keyboard changes to the opacity and image zoom sliders, alt+double-clicking a rectangle into a check-box, saving alt text, and dropping a file or url onto the canvas. ([#10468](https://github.com/tldraw/tldraw/pull/10468), [#10469](https://github.com/tldraw/tldraw/pull/10469), [#10470](https://github.com/tldraw/tldraw/pull/10470), [#10473](https://github.com/tldraw/tldraw/pull/10473), [#10474](https://github.com/tldraw/tldraw/pull/10474), [#10478](https://github.com/tldraw/tldraw/pull/10478))
|
||||
- Fix exiting an empty text shape leaving a phantom undo entry: document changes made by `onEditEnd` are now recorded in history. ([#10369](https://github.com/tldraw/tldraw/pull/10369))
|
||||
- Fix undo after `bailToMark` removing records that were not part of the undone change. ([#10207](https://github.com/tldraw/tldraw/pull/10207))
|
||||
- Fix undo/redo not removing an optional record property that the change had deleted. ([#10215](https://github.com/tldraw/tldraw/pull/10215))
|
||||
- Fix collaboration-mode readonly syncs ending up in the user's undo history. ([#10372](https://github.com/tldraw/tldraw/pull/10372))
|
||||
- Fix changes made by `operationComplete` handlers after a remote merge being attributed to the remote source. ([#10229](https://github.com/tldraw/tldraw/pull/10229))
|
||||
|
||||
### Selection and tools
|
||||
|
||||
- Fix Escape being ignored while pressing on the canvas with the select tool. ([#10175](https://github.com/tldraw/tldraw/pull/10175))
|
||||
- Fix Escape during a scribble selection dropping the prior selection. ([#10489](https://github.com/tldraw/tldraw/pull/10489))
|
||||
- Fix Space+arrow and Alt+arrow also nudging the selected shapes. ([#10453](https://github.com/tldraw/tldraw/pull/10453))
|
||||
- Fix Alt not suppressing Shift/Ctrl additive selection when releasing the pointer over a selected shape. ([#10510](https://github.com/tldraw/tldraw/pull/10510))
|
||||
- Fix bookmarks and other non-resizable shapes being left behind when resizing a multi-selection. ([#10491](https://github.com/tldraw/tldraw/pull/10491))
|
||||
- Fix alt-cloning mid-drag reparenting the original shape instead of the clone when dropping into a frame. ([#10186](https://github.com/tldraw/tldraw/pull/10186))
|
||||
- Fix the cursor staying as a resize or rotate arrow when Ctrl/Cmd+dragging a selection handle to brush-select. ([#10487](https://github.com/tldraw/tldraw/pull/10487))
|
||||
- Fix handle snapping on rotated shapes landing off the snap target. ([#10168](https://github.com/tldraw/tldraw/pull/10168))
|
||||
- Fix an interrupted press with the geo, frame, or note tool (undo, opening a menu, or changing page before releasing) creating a default-size shape. ([#10506](https://github.com/tldraw/tldraw/pull/10506))
|
||||
- Fix an invisible empty text shape being left behind when switching tools while dragging out a text box. ([#10457](https://github.com/tldraw/tldraw/pull/10457))
|
||||
- Fix the text tool undoing earlier work when cancelling a pointer-down after a drag-created text shape. ([#10194](https://github.com/tldraw/tldraw/pull/10194))
|
||||
- Fix shift-click with the draw, highlight, and line tools editing a shape on the previous page after a page change. ([#10455](https://github.com/tldraw/tldraw/pull/10455))
|
||||
- Fix the draw tool's shift segment threshold depending on the zoom level. ([#10240](https://github.com/tldraw/tldraw/pull/10240))
|
||||
- Fix the first point of a pen stroke losing its pressure value. ([#10243](https://github.com/tldraw/tldraw/pull/10243))
|
||||
- Fix small, slowly drawn loops sometimes failing to close, because dense input sampling made the stroke measure shorter than it was. ([#9930](https://github.com/tldraw/tldraw/pull/9930))
|
||||
- Fix the eraser dropping a frame it was pressed on once the drag begins. ([#10507](https://github.com/tldraw/tldraw/pull/10507))
|
||||
- Fix a camera jump when a second finger touches down during a one-finger zoom with the hand tool. ([#10178](https://github.com/tldraw/tldraw/pull/10178))
|
||||
- Fix Escape ending a spacebar pan while Space is still held. ([#10504](https://github.com/tldraw/tldraw/pull/10504))
|
||||
- Fix tools whose `onEnter` transitions to a child state ending up with two active children. ([#10354](https://github.com/tldraw/tldraw/pull/10354))
|
||||
- Fix double clicks being cancelled by small pointer movements when the editor container is offset from the page origin. ([#10349](https://github.com/tldraw/tldraw/pull/10349))
|
||||
- Fix modifier keys (Shift, Alt, Ctrl, Cmd) briefly being treated as held at the start of a new click or drag right after being released, which could momentarily affect snapping and other modifier-dependent behavior. ([#9523](https://github.com/tldraw/tldraw/pull/9523))
|
||||
- Fix held keys, including Space panning, staying active after the window loses focus. ([#10500](https://github.com/tldraw/tldraw/pull/10500))
|
||||
|
||||
### Cropping
|
||||
|
||||
- Fix stale crop state after leaving crop mode by switching tools. ([#10167](https://github.com/tldraw/tldraw/pull/10167))
|
||||
- Fix Escape during a crop drag reverting the whole crop session instead of only the drag in progress. ([#10462](https://github.com/tldraw/tldraw/pull/10462))
|
||||
- Fix Escape in image crop mode clearing the selection. ([#10463](https://github.com/tldraw/tldraw/pull/10463))
|
||||
- Fix crop mode staying active when the cropping shape is deleted. ([#10370](https://github.com/tldraw/tldraw/pull/10370))
|
||||
- Fix pressing Enter on a selected image entering crop mode in a readonly editor. ([#10492](https://github.com/tldraw/tldraw/pull/10492))
|
||||
|
||||
### Arrows and shapes
|
||||
|
||||
- Fix arrows bound to custom shapes with non-standard `size` values rendering as `NaN`. ([#10214](https://github.com/tldraw/tldraw/pull/10214))
|
||||
- Fix elbow arrows with a free terminal sometimes routing through the bound shape. ([#10219](https://github.com/tldraw/tldraw/pull/10219))
|
||||
- Fix arrow geometry ignoring a custom stroke width from `getCustomDisplayValues`. ([#10230](https://github.com/tldraw/tldraw/pull/10230))
|
||||
- Fix the arrow binding target highlight staying visible after clicking an arrow handle without dragging. ([#10191](https://github.com/tldraw/tldraw/pull/10191))
|
||||
- Fix a stub arrow being left on the canvas when switching tools during an arrow press. ([#10461](https://github.com/tldraw/tldraw/pull/10461))
|
||||
- Fix arrow label drag reverting when a menu opens or undo is pressed mid-drag. ([#10493](https://github.com/tldraw/tldraw/pull/10493))
|
||||
- Fix Cmd+Enter / Tab on a note creating a duplicate on top of an existing neighbour. ([#10199](https://github.com/tldraw/tldraw/pull/10199))
|
||||
- Fix adjacent-note snapping under a scaled, grown note. ([#10204](https://github.com/tldraw/tldraw/pull/10204))
|
||||
- Fix the note tool not snapping a new note beside the note it just created when tool lock is on. ([#10484](https://github.com/tldraw/tldraw/pull/10484))
|
||||
- Fix attributed notes losing their font in exports. ([#10249](https://github.com/tldraw/tldraw/pull/10249))
|
||||
- Fix shift-extending a line inside a rotated frame placing the point incorrectly. ([#10238](https://github.com/tldraw/tldraw/pull/10238))
|
||||
- Fix the second diagonal of an x-box shape being treated as an outer edge. ([#10245](https://github.com/tldraw/tldraw/pull/10245))
|
||||
- Fix a replaced image being zoomed when the original had an explicit default crop. ([#10234](https://github.com/tldraw/tldraw/pull/10234))
|
||||
- Fix video shapes showing a floating spinner with no bounding box while their asset uploads or loads. They now show the same loading plate as image shapes. ([#9994](https://github.com/tldraw/tldraw/pull/9994))
|
||||
- Fix **Flatten to image** producing different groupings depending on selection order, and not merging clusters that were only connected through a bridging shape. ([#8661](https://github.com/tldraw/tldraw/pull/8661))
|
||||
|
||||
### Editor
|
||||
|
||||
- Fix dragging a frame out of its parent when grabbed over a nested child, and restore a child's original z-order when dragged back into a frame. ([#10329](https://github.com/tldraw/tldraw/pull/10329))
|
||||
- Fix camera constraints for bounds away from the page origin, and clamp padding against the correct viewport axis. ([#10337](https://github.com/tldraw/tldraw/pull/10337))
|
||||
- Fix `setCameraOptions({ zoomSteps: undefined })` breaking every later camera move. ([#10387](https://github.com/tldraw/tldraw/pull/10387))
|
||||
- Fix `slideCamera` zoom momentum driving the zoom to zero on a long frame. ([#10388](https://github.com/tldraw/tldraw/pull/10388))
|
||||
- Fix malformed viewport deep links moving the camera to the origin instead of falling back to fitting the page content. ([#10508](https://github.com/tldraw/tldraw/pull/10508))
|
||||
- Fix following a user whose own leader has left or has no presence yet. ([#10389](https://github.com/tldraw/tldraw/pull/10389))
|
||||
- Fix `getSelectionRotatedScreenBounds` going stale when the editor container moves. ([#10390](https://github.com/tldraw/tldraw/pull/10390))
|
||||
- Fix `duplicateShapes` throwing on unknown ids and offsetting nested duplicates twice. ([#10355](https://github.com/tldraw/tldraw/pull/10355))
|
||||
- Fix `packShapes` packing shapes of different heights into a single column. ([#10381](https://github.com/tldraw/tldraw/pull/10381))
|
||||
- Fix `stackShapes` reordering shapes when a gap is given. ([#10383](https://github.com/tldraw/tldraw/pull/10383))
|
||||
- Fix nested 270° shapes being dropped from axis-aligned shape clustering. ([#10384](https://github.com/tldraw/tldraw/pull/10384))
|
||||
- Fix `putContentOntoCurrentPage` mutating the content passed to it. ([#10367](https://github.com/tldraw/tldraw/pull/10367))
|
||||
- Fix `setErasingShapes` mutating its input array, so it accepts store-owned (frozen) arrays. ([#10371](https://github.com/tldraw/tldraw/pull/10371))
|
||||
- Fix the teardown returned by the `createTLStore` `onMount` option never running when the editor unmounts. ([#10375](https://github.com/tldraw/tldraw/pull/10375))
|
||||
- Fix drop events inside the canvas never reaching React drop handlers, and keep Safari gesture suppression working with multiple editors on a page. ([#10360](https://github.com/tldraw/tldraw/pull/10360))
|
||||
- Fix object URLs for locally persisted assets leaking across remounts. ([#10361](https://github.com/tldraw/tldraw/pull/10361))
|
||||
- Fix the click-capture overlay staying over the canvas after right-click panning with a menu open. ([#10359](https://github.com/tldraw/tldraw/pull/10359))
|
||||
- Fix hiding and showing menus by context id, and tolerate corrupt user-preference data in localStorage. ([#10344](https://github.com/tldraw/tldraw/pull/10344))
|
||||
- Fix `Vec.Slope`, `Vec.cross`, and `areAnglesCompatible` for negative angles, `rotateSelectionHandle` for negative rotations, and dotted dash arrays for very short paths. ([#10352](https://github.com/tldraw/tldraw/pull/10352))
|
||||
- Fix laser trails staying visible after changing page. ([#10509](https://github.com/tldraw/tldraw/pull/10509))
|
||||
- Fix locking a mixed selection of locked and unlocked shapes leaving the shapes selected. ([#10480](https://github.com/tldraw/tldraw/pull/10480))
|
||||
- Fix wheel event normalization passing through non-finite delta values. ([#9586](https://github.com/tldraw/tldraw/pull/9586))
|
||||
|
||||
### UI
|
||||
|
||||
- Fix the **Frame selection** shortcut (Ctrl+Alt+G) not working on Windows and Linux. ([#10481](https://github.com/tldraw/tldraw/pull/10481))
|
||||
- Fix pasted text shapes and bookmarks not being selected after paste. ([#10485](https://github.com/tldraw/tldraw/pull/10485))
|
||||
- Fix context-menu paste landing under the pointer instead of at the right-click point. ([#10488](https://github.com/tldraw/tldraw/pull/10488))
|
||||
- Fix file drops and pastes silently doing nothing when one of the files is an image that can't be decoded. The bad file is now skipped with an error toast and the other files are placed. ([#10495](https://github.com/tldraw/tldraw/pull/10495))
|
||||
- Fix the **Edit link** dialog staying open as an empty panel when its shape is deleted or deselected. ([#10496](https://github.com/tldraw/tldraw/pull/10496))
|
||||
- Fix wheel and trackpad scrolling over the minimap moving the camera at double rate. ([#10499](https://github.com/tldraw/tldraw/pull/10499))
|
||||
- Fix the actions menu button appearing, and opening empty, in readonly mode with the hand and zoom tools. ([#10503](https://github.com/tldraw/tldraw/pull/10503))
|
||||
- Fix closing a stacked dialog by touch also closing the dialog beneath it. ([#10002](https://github.com/tldraw/tldraw/pull/10002))
|
||||
- Fix the loading screen not honoring the system theme on a dark OS, and the reduce motion preference not following the OS setting until a reload. ([#10528](https://github.com/tldraw/tldraw/pull/10528))
|
||||
- Fix the storage read-failure alert promising a reload that never happened. ([#10530](https://github.com/tldraw/tldraw/pull/10530))
|
||||
- Fix the casing of the error fallback's refresh button. ([#10362](https://github.com/tldraw/tldraw/pull/10362))
|
||||
- Fix pasted links showing a broken preview image when the linked page has no Open Graph image. ([#9909](https://github.com/tldraw/tldraw/pull/9909))
|
||||
- Fix the counterclockwise rotation tooltip not showing its comma key. ([#9772](https://github.com/tldraw/tldraw/pull/9772))
|
||||
- Fix the spacing around the cursor chat item in the context menu. ([#9921](https://github.com/tldraw/tldraw/pull/9921))
|
||||
- Fix the people menu's collaborator rows not lining up with your own row above them: the follow icon sat too close to the right edge, and collaborator names sat slightly left of your own. ([#9900](https://github.com/tldraw/tldraw/pull/9900))
|
||||
- Fix the comment thread popover briefly rendering with a doubled shadow the first time a thread is opened. ([#9886](https://github.com/tldraw/tldraw/pull/9886))
|
||||
|
||||
### Persistence and sync
|
||||
|
||||
- Fix edits made in the last 350 ms before closing or reloading a tab being lost from local persistence. ([#10526](https://github.com/tldraw/tldraw/pull/10526))
|
||||
- Fix background tabs repeatedly dropping and re-establishing their sync connection when the browser throttles timers. ([#9964](https://github.com/tldraw/tldraw/pull/9964))
|
||||
- Fix `useSyncDemo` creating assets for uploads the server rejected. ([#10109](https://github.com/tldraw/tldraw/pull/10109))
|
||||
- Fix `loadSnapshot` failing on better-sqlite3, and `NodeSqliteWrapper` getting stuck in a transaction when `COMMIT` fails. ([#10111](https://github.com/tldraw/tldraw/pull/10111), [#10113](https://github.com/tldraw/tldraw/pull/10113))
|
||||
|
||||
### Signals and store
|
||||
|
||||
- Fix computeds returning stale values when read inside a transaction during an effect, from a deferred effect, or after a reactor restarts. ([#10145](https://github.com/tldraw/tldraw/pull/10145))
|
||||
- Fix effects with no signal dependencies re-running on every `start()`. ([#10148](https://github.com/tldraw/tldraw/pull/10148))
|
||||
- Fix nested transactions inside effects not rolling back with their parent. ([#10149](https://github.com/tldraw/tldraw/pull/10149))
|
||||
- Fix effects observing half-restored state when a `deferAsyncEffects` call throws. ([#10150](https://github.com/tldraw/tldraw/pull/10150))
|
||||
- Fix `@computed` on a subclass override that calls `super`, and `@computed()` with no arguments. ([#10151](https://github.com/tldraw/tldraw/pull/10151))
|
||||
- Fix computeds re-running their compute function on every read after their first derive throws. ([#10152](https://github.com/tldraw/tldraw/pull/10152))
|
||||
- Fix computeds read inside an atom's `computeDiff` never seeing that change, and an explicit `null` diff passed to `atom.set()` or returned from `withDiff()` clearing history. ([#10153](https://github.com/tldraw/tldraw/pull/10153))
|
||||
- Fix `localStorageAtom` throwing outside the browser. ([#10154](https://github.com/tldraw/tldraw/pull/10154))
|
||||
- Fix a stack overflow in `ImmutableMap` and `AtomMap` for keys named after `Object.prototype` members, and make `NaN` and `-0` key equality match native `Map`. ([#10202](https://github.com/tldraw/tldraw/pull/10202))
|
||||
- Fix `AtomMap` throwing when an entry is deleted mid-iteration. ([#10209](https://github.com/tldraw/tldraw/pull/10209))
|
||||
- Fix `StoreSchema.create` reporting a redundant migration dependency as circular. ([#10213](https://github.com/tldraw/tldraw/pull/10213))
|
||||
- Fix the `store.query.ids()` query creator being called with a computed's `(previousValue, epoch)` arguments. ([#10218](https://github.com/tldraw/tldraw/pull/10218))
|
||||
- Fix `CollectionDiff`s from `store.query.ids()` and `index()` containing an empty `added` or `removed` set after an add/remove round trip. ([#10221](https://github.com/tldraw/tldraw/pull/10221))
|
||||
|
||||
---
|
||||
|
||||
## Patch releases
|
||||
|
||||
### v5.4.1
|
||||
|
||||
- Fix the context menu not reopening after a left-click dismissal when radix-ui 1.5 or later is installed. ([#10567](https://github.com/tldraw/tldraw/pull/10567))
|
||||
|
||||
[View release on GitHub](https://github.com/tldraw/tldraw/releases/tag/v5.4.1)
|
||||
227
apps/docs/content/sdk-features/accessibility.mdx
Normal file
227
apps/docs/content/sdk-features/accessibility.mdx
Normal file
@@ -0,0 +1,227 @@
|
||||
---
|
||||
title: Accessibility
|
||||
created_at: 12/29/2025
|
||||
updated_at: 12/29/2025
|
||||
keywords:
|
||||
- accessibility
|
||||
- a11y
|
||||
- screen reader
|
||||
- keyboard navigation
|
||||
- reduced motion
|
||||
- aria
|
||||
- alt text
|
||||
status: published
|
||||
date: 12/29/2025
|
||||
readability: 9
|
||||
voice: 9
|
||||
completeness: 9
|
||||
accuracy: 9
|
||||
notes: Strong doc with good example links. Line 104 "can provide" and line 172 "prefer or require" are minor hedging. Best practices section has five items (three-item-list-adjacent).
|
||||
---
|
||||
|
||||
Tldraw includes accessibility features for keyboards and assistive technologies. The SDK announces shape selections to screen readers, supports keyboard navigation between shapes, respects reduced motion preferences, and provides hooks for custom shapes to supply descriptive text.
|
||||
|
||||
## Screen reader announcements
|
||||
|
||||
When users select shapes, tldraw announces the selection to screen readers through a live region. The announcement includes the shape type, any descriptive text, and the shape's position in reading order.
|
||||
|
||||
For a single shape selection, the announcement follows this pattern: "[description], [shape type]. [position] of [total]". For example, selecting an image with alt text might announce "A team photo, image. 3 of 7". Multiple selections announce the count: "4 shapes selected".
|
||||
|
||||
The announcement system uses the [DefaultA11yAnnouncer](?) component, which renders a visually hidden live region that screen readers monitor for changes. The [useA11y](?) hook provides programmatic access to announce custom messages:
|
||||
|
||||
```tsx
|
||||
import { Tldraw, useA11y } from 'tldraw'
|
||||
import 'tldraw/tldraw.css'
|
||||
|
||||
function CustomAnnouncement() {
|
||||
const a11y = useA11y()
|
||||
|
||||
const handleCustomAction = () => {
|
||||
a11y.announce({ msg: 'Custom action completed', priority: 'polite' })
|
||||
}
|
||||
|
||||
return <button onClick={handleCustomAction}>Do action</button>
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0 }}>
|
||||
<Tldraw>
|
||||
<CustomAnnouncement />
|
||||
</Tldraw>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
The `priority` option accepts `'polite'` or `'assertive'` and defaults to `'assertive'`. Polite announcements wait for a pause in speech, while assertive announcements interrupt immediately. See [TLUiA11y](?) for the message type.
|
||||
|
||||
> See the [screen reader accessibility example](/examples/ui/screen-reader-accessibility) for custom shape descriptions and announcements.
|
||||
|
||||
## Keyboard navigation
|
||||
|
||||
Users can navigate between shapes using the keyboard. With a shape selected, Tab moves selection to the next shape in reading order and Shift+Tab moves to the previous shape. Arrow keys with Ctrl/Cmd move selection to the nearest shape in that direction, and Ctrl/Cmd+Shift+Down or Up select a group's first child or its parent. [Editor#selectAdjacentShape](?) is the programmatic equivalent.
|
||||
|
||||
Tldraw determines reading order by analyzing shape positions on the canvas. Shapes are grouped into rows based on their vertical position, then sorted left-to-right within each row. This creates a natural top-to-bottom, left-to-right reading order similar to text. You can access this order programmatically with [Editor#getCurrentPageShapesInReadingOrder](?).
|
||||
|
||||
A "Skip to main content" link appears when users Tab into the editor. It selects the first shape on the canvas and zooms to it.
|
||||
|
||||
### Excluding shapes from keyboard navigation
|
||||
|
||||
Exclude custom shapes from keyboard navigation by overriding [ShapeUtil#canTabTo](?):
|
||||
|
||||
```tsx
|
||||
class DecorativeShapeUtil extends ShapeUtil<DecorativeShape> {
|
||||
// Decorative shapes don't receive keyboard focus
|
||||
canTabTo() {
|
||||
return false
|
||||
}
|
||||
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
Shapes that return `false` from `canTabTo()` are skipped during Tab navigation and excluded from reading order calculations.
|
||||
|
||||
## Shape descriptions for screen readers
|
||||
|
||||
When a shape is selected, the announcement includes descriptive text from two sources: the shape's text content and its ARIA descriptor. [ShapeUtil#getText](?) returns the shape's primary text content, while [ShapeUtil#getAriaDescriptor](?) provides alternative text specifically for accessibility purposes.
|
||||
|
||||
For most shapes, `getText()` is sufficient. The default implementation returns `undefined`, which produces announcements with just the shape type and position.
|
||||
|
||||
### Providing alt text for media shapes
|
||||
|
||||
Image and video shapes support an `altText` property that `getAriaDescriptor()` returns. Users can set alt text through the media toolbar when an image or video is selected:
|
||||
|
||||
```tsx
|
||||
// Setting alt text programmatically
|
||||
editor.updateShapes([
|
||||
{
|
||||
id: imageShape.id,
|
||||
type: 'image',
|
||||
props: { altText: 'A diagram showing the system architecture' },
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
### Custom shape descriptions
|
||||
|
||||
Give custom shapes screen reader descriptions by overriding [ShapeUtil#getAriaDescriptor](?):
|
||||
|
||||
```tsx
|
||||
class CardShapeUtil extends ShapeUtil<CardShape> {
|
||||
getAriaDescriptor(shape: CardShape) {
|
||||
// Return a description that makes sense when read aloud
|
||||
return `${shape.props.title}: ${shape.props.summary}`
|
||||
}
|
||||
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
If your shape has visible text, override [ShapeUtil#getText](?) instead. The announcement system checks `getAriaDescriptor()` first, then falls back to `getText()`:
|
||||
|
||||
```tsx
|
||||
class CardShapeUtil extends ShapeUtil<CardShape> {
|
||||
getText(shape: CardShape) {
|
||||
return shape.props.title
|
||||
}
|
||||
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## Reduced motion
|
||||
|
||||
The SDK respects user motion preferences through the `animationSpeed` user preference. When set to 0, animations are disabled. By default, this value matches the operating system's `prefers-reduced-motion` setting.
|
||||
|
||||
Use [usePrefersReducedMotion](?) in custom shape components to check whether to show animations:
|
||||
|
||||
```tsx
|
||||
import { usePrefersReducedMotion } from 'tldraw'
|
||||
|
||||
function AnimatedIndicator() {
|
||||
const prefersReducedMotion = usePrefersReducedMotion()
|
||||
|
||||
if (prefersReducedMotion) {
|
||||
return <StaticIndicator />
|
||||
}
|
||||
|
||||
return <PulsingIndicator />
|
||||
}
|
||||
```
|
||||
|
||||
The hook returns `true` when:
|
||||
|
||||
- The `animationSpeed` preference is 0 (the default when the OS prefers reduced motion)
|
||||
- When used outside an editor context, the operating system's reduced motion preference is enabled
|
||||
|
||||
Users can toggle reduced motion through the accessibility menu, found under Preferences in the main menu.
|
||||
|
||||
> See the [reduced motion example](/examples/configuration/reduced-motion) for a custom shape that respects motion preferences.
|
||||
|
||||
## Enhanced accessibility mode
|
||||
|
||||
The `enhancedA11yMode` user preference adds visible labels to UI elements that normally rely on icons alone. When enabled, the style panel shows text labels for each section like "Color", "Opacity", and "Align". This helps users who need more context than an icon provides.
|
||||
|
||||
Toggle this setting programmatically:
|
||||
|
||||
```tsx
|
||||
editor.user.updateUserPreferences({
|
||||
enhancedA11yMode: true,
|
||||
})
|
||||
```
|
||||
|
||||
## Disabling keyboard shortcuts
|
||||
|
||||
Assistive technologies often have their own keyboard commands that conflict with tldraw's shortcuts. The `areKeyboardShortcutsEnabled` preference lets users turn tldraw's shortcuts off:
|
||||
|
||||
```tsx
|
||||
editor.user.updateUserPreferences({
|
||||
areKeyboardShortcutsEnabled: false,
|
||||
})
|
||||
```
|
||||
|
||||
When disabled, tldraw's keyboard shortcuts don't interfere with assistive technology shortcuts. Basic navigation with Tab and arrow keys still works for shape selection.
|
||||
|
||||
## Accessibility menu
|
||||
|
||||
The default UI includes an accessibility submenu under Preferences in the main menu, with toggles for:
|
||||
|
||||
| Setting | Effect |
|
||||
| --------------------------- | --------------------------------------------- |
|
||||
| Reduce motion | Disables animations |
|
||||
| Keyboard shortcuts | Enables or disables tldraw keyboard shortcuts |
|
||||
| Enhanced accessibility mode | Shows visible labels on UI elements |
|
||||
|
||||
You can use these components individually to build custom accessibility controls:
|
||||
|
||||
```tsx
|
||||
import {
|
||||
ToggleReduceMotionItem,
|
||||
ToggleKeyboardShortcutsItem,
|
||||
ToggleEnhancedA11yModeItem,
|
||||
} from 'tldraw'
|
||||
```
|
||||
|
||||
See [AccessibilityMenu](?) for the default implementation.
|
||||
|
||||
## Best practices for custom shapes
|
||||
|
||||
Override `getAriaDescriptor()` or `getText()` to give screen reader users context about what the shape contains. A shape announced as "card" is less useful than "Meeting notes: Q4 planning session".
|
||||
|
||||
The shape's `component()` renders inside an HTML container, so the usual web accessibility rules apply: use semantic elements rather than styled divs, make interactive elements focusable and operable with the keyboard, and check `usePrefersReducedMotion()` before showing animations.
|
||||
|
||||
For more on creating custom shapes, see [Custom shapes](/docs/shapes).
|
||||
|
||||
## Debugging accessibility
|
||||
|
||||
Enable the `a11y` debug flag to log accessibility announcements to the console:
|
||||
|
||||
```tsx
|
||||
import { debugFlags } from 'tldraw'
|
||||
|
||||
debugFlags.a11y.set(true)
|
||||
```
|
||||
|
||||
With this flag enabled, the console shows each announcement and logs the accessible name of elements as they receive keyboard focus. This is useful for verifying that your custom shapes provide appropriate descriptions.
|
||||
315
apps/docs/content/sdk-features/actions.mdx
Normal file
315
apps/docs/content/sdk-features/actions.mdx
Normal file
@@ -0,0 +1,315 @@
|
||||
---
|
||||
title: Actions
|
||||
created_at: 12/25/2025
|
||||
updated_at: 12/29/2025
|
||||
keywords:
|
||||
- actions
|
||||
- commands
|
||||
- keyboard shortcuts
|
||||
- menus
|
||||
- overrides
|
||||
- customization
|
||||
status: published
|
||||
date: 12/25/2025
|
||||
readability: 8
|
||||
voice: 8
|
||||
completeness: 9
|
||||
accuracy: 9
|
||||
notes: Interface definition shows property order different from source. Helper table has slight differences from implementation details.
|
||||
---
|
||||
|
||||
Actions are named operations that users trigger from menus, keyboard shortcuts, or custom UI. Each action bundles an identifier, display metadata (label, icon, keyboard shortcut), and a handler function. Actions let you define operations like "undo", "group", or "export as PNG" once and invoke them from multiple places with consistent behavior.
|
||||
|
||||
```tsx
|
||||
import { Tldraw, TLUiOverrides } from 'tldraw'
|
||||
import 'tldraw/tldraw.css'
|
||||
|
||||
const overrides: TLUiOverrides = {
|
||||
actions(editor, actions, helpers) {
|
||||
// Add a custom action
|
||||
actions['show-selection-count'] = {
|
||||
id: 'show-selection-count',
|
||||
label: 'action.show-selection-count',
|
||||
kbd: 'shift+c',
|
||||
onSelect(source) {
|
||||
const count = editor.getSelectedShapeIds().length
|
||||
helpers.addToast({ title: `${count} shapes selected` })
|
||||
},
|
||||
}
|
||||
return actions
|
||||
},
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0 }}>
|
||||
<Tldraw overrides={overrides} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
The `tldraw` package includes nearly 100 default actions covering editing, arrangement, export, zoom, and preferences. You can override any of these or add your own through the `overrides` prop, typed as [TLUiOverrides](?).
|
||||
|
||||
## How actions work
|
||||
|
||||
Actions live in a React context inside the tldraw UI. When the UI mounts, it registers all default actions, applies any overrides you've provided, and makes them available through the [useActions](?) hook. Menus and toolbars look up actions by ID and render them with their labels, icons, and keyboard shortcuts.
|
||||
|
||||
Each action has an `onSelect` handler that receives a source parameter indicating where it was triggered:
|
||||
|
||||
```tsx
|
||||
const actions = useActions()
|
||||
const duplicateAction = actions['duplicate']
|
||||
|
||||
// Trigger programmatically
|
||||
duplicateAction.onSelect('toolbar')
|
||||
```
|
||||
|
||||
Keyboard shortcuts are bound automatically. The [useKeyboardShortcuts](?) hook parses each action's `kbd` property and registers hotkey handlers. When a shortcut fires, it calls the action's `onSelect` with `'kbd'` as the source.
|
||||
|
||||
## Action structure
|
||||
|
||||
The [TLUiActionItem](?) interface defines what an action contains:
|
||||
|
||||
| Property | Description |
|
||||
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `id` | Unique identifier for the action (e.g., `'duplicate'`, `'zoom-in'`) |
|
||||
| `label` | Translation key for display text. Can be a string or an object mapping contexts to different keys (see [Context-sensitive labels](#context-sensitive-labels)) |
|
||||
| `icon` | Icon name from tldraw's icon set, or a custom React element |
|
||||
| `kbd` | Keyboard shortcut string. Use commas to bind several combinations, e.g. `'cmd+g,ctrl+g'` (see [Keyboard shortcuts](#keyboard-shortcuts)) |
|
||||
| `readonlyOk` | When `true`, the action works in readonly mode. Defaults to `false` |
|
||||
| `checkbox` | When `true`, renders as a toggle with a checkmark indicator in menus |
|
||||
| `isRequiredA11yAction` | When `true`, the keyboard shortcut works even when shortcuts are normally disabled (e.g., while editing a shape). Used for accessibility actions |
|
||||
| `onSelect` | Handler called when the action is triggered. Receives a [TLUiEventSource](?) indicating the trigger origin (`'kbd'`, `'menu'`, `'toolbar'`, etc.) |
|
||||
|
||||
## Accessing actions
|
||||
|
||||
Use the [useActions](?) hook to get all registered actions:
|
||||
|
||||
```typescript
|
||||
import { useActions } from 'tldraw'
|
||||
|
||||
function MyComponent() {
|
||||
const actions = useActions()
|
||||
|
||||
return (
|
||||
<button onClick={() => actions['undo'].onSelect('toolbar')}>
|
||||
Undo
|
||||
</button>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
The hook returns a record mapping action IDs to action objects. You can iterate over it to build custom menus or filter actions by property.
|
||||
|
||||
## Default actions
|
||||
|
||||
The default actions cover most editing operations you'd expect in a canvas application. Some common ones, by category:
|
||||
|
||||
| Category | Action ids |
|
||||
| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Editing | `undo`, `redo`, `duplicate`, `delete`, `copy`, `cut`, `paste` |
|
||||
| Grouping | `group`, `ungroup` |
|
||||
| Arrangement | `bring-to-front`, `bring-forward`, `send-backward`, `send-to-back`, `align-left`, `align-center-horizontal`, `align-right`, `distribute-horizontal`, `distribute-vertical` |
|
||||
| Export | `export-as-svg`, `export-as-png`, `copy-as-svg`, `copy-as-png` |
|
||||
| Zoom | `zoom-in`, `zoom-out`, `zoom-to-100`, `zoom-to-fit`, `zoom-to-selection`, `select-zoom-tool` |
|
||||
| Preferences | `toggle-dark-mode`, `toggle-snap-mode`, `toggle-grid`, `toggle-focus-mode` |
|
||||
|
||||
The full list is in the [default actions source](https://github.com/tldraw/tldraw/blob/main/packages/tldraw/src/lib/ui/context/actions.tsx). Most actions guard themselves inside their handlers. For example, `group` and the arrangement actions do nothing unless shapes are selected and the select tool is active.
|
||||
|
||||
## Overriding actions
|
||||
|
||||
Pass an `overrides` prop to customize actions. The override function receives the editor, the default actions, and helper utilities:
|
||||
|
||||
```typescript
|
||||
import { Tldraw, TLUiOverrides } from 'tldraw'
|
||||
|
||||
const overrides: TLUiOverrides = {
|
||||
actions(editor, actions, helpers) {
|
||||
// Modify existing action
|
||||
actions['duplicate'].kbd = 'cmd+shift+d,ctrl+shift+d'
|
||||
|
||||
// Disable an action by removing it
|
||||
delete actions['print']
|
||||
|
||||
return actions
|
||||
},
|
||||
}
|
||||
|
||||
function App() {
|
||||
return <Tldraw overrides={overrides} />
|
||||
}
|
||||
```
|
||||
|
||||
The `copy`, `cut`, and `paste` shortcuts are handled by native clipboard events rather than the `kbd` system, so changing their `kbd` has no effect.
|
||||
|
||||
### Modifying behavior
|
||||
|
||||
To change what an action does, replace its `onSelect` handler:
|
||||
|
||||
```typescript
|
||||
const overrides: TLUiOverrides = {
|
||||
actions(editor, actions, helpers) {
|
||||
const originalDuplicate = actions['duplicate'].onSelect
|
||||
|
||||
actions['duplicate'].onSelect = async (source) => {
|
||||
console.log('Duplicating shapes...')
|
||||
await originalDuplicate(source)
|
||||
console.log('Done!')
|
||||
}
|
||||
|
||||
return actions
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
You can call the original handler before or after your custom logic, or replace it entirely.
|
||||
|
||||
### Adding custom actions
|
||||
|
||||
Add new actions by inserting them into the actions record:
|
||||
|
||||
```typescript
|
||||
const overrides: TLUiOverrides = {
|
||||
actions(editor, actions, helpers) {
|
||||
actions['my-custom-action'] = {
|
||||
id: 'my-custom-action',
|
||||
label: 'action.my-custom-action',
|
||||
kbd: 'cmd+shift+k,ctrl+shift+k',
|
||||
icon: 'external-link',
|
||||
onSelect(source) {
|
||||
const shapes = editor.getSelectedShapes()
|
||||
console.log('Custom action on', shapes.length, 'shapes')
|
||||
},
|
||||
}
|
||||
|
||||
return actions
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Custom actions integrate with the keyboard shortcut system automatically. To add them to menus, override the menu components; see [Actions in menus](#actions-in-menus) and the [custom menus example](/examples/ui/custom-menus).
|
||||
|
||||
### Using helper utilities
|
||||
|
||||
The override function receives a `helpers` object ([TLUiOverrideHelpers](?), the return value of [useDefaultHelpers](?)):
|
||||
|
||||
```typescript
|
||||
const overrides: TLUiOverrides = {
|
||||
actions(editor, actions, helpers) {
|
||||
actions['show-toast'] = {
|
||||
id: 'show-toast',
|
||||
label: 'action.show-toast',
|
||||
onSelect(source) {
|
||||
helpers.addToast({
|
||||
title: 'Hello!',
|
||||
description: 'This is a custom action.',
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
return actions
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Available helpers:
|
||||
|
||||
| Helper | Description |
|
||||
| ----------------------- | ------------------------------------ |
|
||||
| `addToast` | Show a toast notification |
|
||||
| `removeToast` | Remove a specific toast |
|
||||
| `clearToasts` | Remove all toasts |
|
||||
| `addDialog` | Open a dialog |
|
||||
| `removeDialog` | Close a specific dialog |
|
||||
| `clearDialogs` | Close all dialogs |
|
||||
| `msg` | Get a translated string by key |
|
||||
| `isMobile` | Boolean indicating mobile breakpoint |
|
||||
| `insertMedia` | Open file picker and insert media |
|
||||
| `replaceImage` | Replace selected image with new file |
|
||||
| `replaceVideo` | Replace selected video with new file |
|
||||
| `printSelectionOrPages` | Print selection or all pages |
|
||||
| `cut` | Cut selected shapes to clipboard |
|
||||
| `copy` | Copy selected shapes to clipboard |
|
||||
| `paste` | Paste from clipboard |
|
||||
| `copyAs` | Copy shapes as SVG or PNG |
|
||||
| `exportAs` | Export shapes as SVG, PNG, or JSON |
|
||||
| `getEmbedDefinition` | Get embed info for a URL |
|
||||
|
||||
## Keyboard shortcuts
|
||||
|
||||
Shortcuts use a simple string format with modifier keys separated by `+`. Use commas to bind several combinations to the same action. Every combination is active on every platform; the conventional `cmd+…,ctrl+…` pair covers Mac and everything else, and only the shortcut hint shown in menus ([TldrawUiKbd](?)) is platform-specific:
|
||||
|
||||
```typescript
|
||||
kbd: 'cmd+g,ctrl+g' // Cmd+G or Ctrl+G
|
||||
kbd: 'shift+1' // Shift+1
|
||||
kbd: 'cmd+shift+s,ctrl+shift+s' // Cmd+Shift+S or Ctrl+Shift+S
|
||||
```
|
||||
|
||||
Modifiers are `cmd` (alias `meta`), `ctrl`, `shift`, and `alt` (alias `option`). Special keys include `del`, `backspace`, `enter`, `escape`, `space`, and the arrow keys (`left`, `right`, `up`, `down`).
|
||||
|
||||
To bind the `+` key itself, double the trailing plus: `cmd++`. That matches an unshifted `+` key (German, Nordic and similar layouts, or the numpad). On layouts where `+` is shift plus another key, such as US, write `cmd+shift++`, which is the same binding as `cmd+shift+=`.
|
||||
|
||||
Shortcuts match the character the browser reports for the key press, not the physical keycap, with two exceptions: shifted number-row keys match by position, so write them as `shift+<digit>` on every layout, and when the reported character is non-ASCII (Cyrillic, Greek, macOS Option dead keys) matching falls back to the physical key's US QWERTY character, so `cmd+z` still works on a Russian layout. With shift held, the shifted and unshifted US spellings are the same binding, so `shift+:` and `shift+;` both fire when the browser reports `:` with shift held. Bind what the keyboard emits, not what the keycap says: on French AZERTY the `:` key emits `/` with shift held, so bind `shift+/`; on Japanese JIS the `^` key emits `~`, so bind `shift+~`. The characters `!`, `?` and `$` are reserved as legacy modifier markers and cannot be used as keys.
|
||||
|
||||
Shortcuts only fire while the editor is focused and the key event does not target a text input. They are also disabled when a menu is open, a shape is being edited, the editor has a crashing error, or the user has disabled keyboard shortcuts in preferences. In readonly mode, only actions with `readonlyOk` are bound. Actions marked with `isRequiredA11yAction: true` bypass the disabled check for accessibility purposes.
|
||||
|
||||
## Actions in menus
|
||||
|
||||
The default UI uses [TldrawUiMenuActionItem](?) to render actions in menus:
|
||||
|
||||
```typescript
|
||||
import { TldrawUiMenuActionItem, TldrawUiMenuGroup } from 'tldraw'
|
||||
|
||||
function CustomMenu() {
|
||||
return (
|
||||
<TldrawUiMenuGroup id="edit">
|
||||
<TldrawUiMenuActionItem actionId="undo" />
|
||||
<TldrawUiMenuActionItem actionId="redo" />
|
||||
<TldrawUiMenuActionItem actionId="duplicate" />
|
||||
</TldrawUiMenuGroup>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
This component looks up the action by ID and renders it with the correct label, icon, and shortcut hint. Pass `disabled` yourself if the item should be disabled. For toggle actions, use [TldrawUiMenuActionCheckboxItem](?) with a `checked` prop.
|
||||
|
||||
## Context-sensitive labels
|
||||
|
||||
Some actions show different labels depending on where they appear. The `label` property can be an object mapping menu context types ([TLUiMenuContextType](?), plus `default`) to translation keys:
|
||||
|
||||
```typescript
|
||||
actions['export-as-svg'] = {
|
||||
id: 'export-as-svg',
|
||||
label: {
|
||||
default: 'action.export-as-svg',
|
||||
menu: 'action.export-as-svg.short',
|
||||
'context-menu': 'action.export-as-svg.short',
|
||||
},
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
The menu component uses the appropriate label based on its context. If no specific label exists for a context, it falls back to `default`.
|
||||
|
||||
## Tracking action usage
|
||||
|
||||
The `source` parameter tells you where the action was triggered. Use this for analytics:
|
||||
|
||||
```tsx
|
||||
actions['custom-action'] = {
|
||||
id: 'custom-action',
|
||||
label: 'action.custom',
|
||||
kbd: 'cmd+k,ctrl+k',
|
||||
onSelect(source) {
|
||||
trackEvent('custom-action', { source })
|
||||
// source: 'kbd', 'menu', 'context-menu', 'toolbar', 'quick-actions', 'zoom-menu', etc.
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Related examples
|
||||
|
||||
- [Action overrides](/examples/ui/action-overrides) - Add custom actions and modify existing action shortcuts using the overrides prop.
|
||||
- [Keyboard shortcuts](/examples/ui/keyboard-shortcuts) - Change keyboard shortcuts for tools and actions.
|
||||
- [Custom menus](/examples/ui/custom-menus) - Build custom menus that use actions with proper labels and shortcuts.
|
||||
138
apps/docs/content/sdk-features/animation.mdx
Normal file
138
apps/docs/content/sdk-features/animation.mdx
Normal file
@@ -0,0 +1,138 @@
|
||||
---
|
||||
title: Animation
|
||||
created_at: 12/20/2025
|
||||
updated_at: 12/29/2025
|
||||
keywords:
|
||||
- animation
|
||||
- easing
|
||||
- interpolation
|
||||
- transition
|
||||
- animateShape
|
||||
status: published
|
||||
date: 12/20/2025
|
||||
readability: 9
|
||||
voice: 8
|
||||
completeness: 9
|
||||
accuracy: 9
|
||||
notes: Voice issues with "enable" and passive constructions ("are interpolated", "is useful for").
|
||||
---
|
||||
|
||||
The animation system drives smooth transitions for shapes and for the camera. Shape animations interpolate a shape's position, rotation, opacity, and props. Camera animations move the viewport for pans and zooms.
|
||||
|
||||
## How it works
|
||||
|
||||
Animations run on the editor's [tick system](/sdk-features/ticks). When you call [Editor#animateShape](?) or a camera method with an `animation` option, the editor subscribes to `tick` events, applies the easing function to the elapsed time, and interpolates between the start and end values until the animation completes.
|
||||
|
||||
Camera animations respect the user's animation speed preference; shape animations don't. See [User preferences](#user-preferences) below.
|
||||
|
||||
## Shape animations
|
||||
|
||||
Use [Editor#animateShape](?) to animate a single shape or [Editor#animateShapes](?) to animate several at once. The editor tracks each animating shape independently, so multiple animations can run at the same time:
|
||||
|
||||
```typescript
|
||||
import { createShapeId, EASINGS } from 'tldraw'
|
||||
|
||||
const shapeId = createShapeId('myshape')
|
||||
|
||||
editor.animateShape(
|
||||
{ id: shapeId, type: 'geo', x: 200, y: 100 },
|
||||
{ animation: { duration: 500, easing: EASINGS.easeOutCubic } }
|
||||
)
|
||||
```
|
||||
|
||||
### Animated properties
|
||||
|
||||
The editor linearly interpolates the properties common to every shape: `x`, `y`, `rotation` (in radians), and `opacity` (0 to 1).
|
||||
|
||||
For shape-specific props like width and height, the shape util implements [ShapeUtil#getInterpolatedProps](?). If a util doesn't implement it, the props jump to their end values on the first frame. This is how [BaseBoxShapeUtil](?) interpolates its dimensions (`lerp` is exported from `tldraw`):
|
||||
|
||||
```typescript
|
||||
getInterpolatedProps(startShape: Shape, endShape: Shape, t: number) {
|
||||
return {
|
||||
...endShape.props,
|
||||
w: lerp(startShape.props.w, endShape.props.w, t),
|
||||
h: lerp(startShape.props.h, endShape.props.h, t),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Animation lifecycle
|
||||
|
||||
Shape animations default to a duration of 500 ms and `linear` easing. Intermediate frames don't create history entries; when the animation finishes the editor calls `updateShapes()` with the final values, so a single undo restores the starting state.
|
||||
|
||||
You can interrupt an animation in two ways. Calling `updateShapes()` on an animating shape cancels its animation and applies the new values immediately. Starting a new animation for a shape cancels the existing one.
|
||||
|
||||
User interaction wins over ongoing animations. If you drag a shape that's animating, the animation stops and the shape follows your pointer.
|
||||
|
||||
## Camera animations
|
||||
|
||||
The camera-move methods ([Editor#setCamera](?), [Editor#zoomToBounds](?), [Editor#zoomToFit](?), and the rest) accept an `animation` option in [TLCameraMoveOptions](?). Without it, or with a `duration` of `0`, the camera jumps straight to the target. See [Camera](/sdk-features/camera) for the full set of methods.
|
||||
|
||||
```typescript
|
||||
editor.setCamera(
|
||||
{ x: 0, y: 0, z: 1 },
|
||||
{ animation: { duration: 320, easing: EASINGS.easeInOutCubic } }
|
||||
)
|
||||
```
|
||||
|
||||
Camera animations default to `easeInOutCubic` easing. They stop as soon as the user pans, zooms, or pinches, and you can stop them yourself with [Editor#stopCameraAnimation](?). If the camera is locked, camera methods do nothing unless you pass `force: true`.
|
||||
|
||||
### Zooming to bounds
|
||||
|
||||
Use `zoomToBounds()` to animate the camera so a specific area fills the viewport, for example to focus on shapes or build slideshow-style transitions. You can also cap the zoom with `targetZoom` and add screen-space padding with `inset`:
|
||||
|
||||
```typescript
|
||||
const bounds = { x: 0, y: 0, w: 800, h: 600 }
|
||||
editor.zoomToBounds(bounds, {
|
||||
animation: { duration: 500 },
|
||||
targetZoom: 1, // zoom to 100%
|
||||
inset: 50, // padding around the bounds in pixels
|
||||
})
|
||||
```
|
||||
|
||||
`zoomToFit()` is a convenience wrapper that zooms to fit all shapes on the current page:
|
||||
|
||||
```typescript
|
||||
editor.zoomToFit({ animation: { duration: 200 } })
|
||||
```
|
||||
|
||||
### Camera slide
|
||||
|
||||
[Editor#slideCamera](?) creates momentum-based camera movement that decelerates under friction:
|
||||
|
||||
```typescript
|
||||
editor.slideCamera({
|
||||
speed: 1,
|
||||
direction: { x: 1, y: 0 },
|
||||
friction: 0.1,
|
||||
})
|
||||
```
|
||||
|
||||
## Easing functions
|
||||
|
||||
Easing functions control the rate of change during an animation. Use an `easeOut` curve when responding to user actions (fast start, gentle settle), `easeIn` for exits (gentle start, quick finish), and `easeInOut` for autonomous moves like camera transitions.
|
||||
|
||||
[EASINGS](?) provides `linear` plus the standard `easeIn`, `easeOut`, and `easeInOut` variants of `Quad`, `Cubic`, `Quart`, `Quint`, `Sine`, and `Expo`, for example `EASINGS.easeOutCubic` or `EASINGS.easeInOutSine`.
|
||||
|
||||
## User preferences
|
||||
|
||||
Camera animations check `editor.user.getAnimationSpeed()` before running. This value is a speed multiplier: the editor divides animation durations by it, so users can speed up, slow down, or disable animations entirely. It defaults to `0` when the operating system reports `prefers-reduced-motion`.
|
||||
|
||||
When animation speed is zero, `setCamera()`, `zoomToBounds()`, `zoomToFit()`, and the other camera-move methods jump straight to the target, and `slideCamera()` does nothing. `animateShape()` and `animateShapes()` do not check this preference. If you need reduced motion support for shape animations, check the animation speed yourself:
|
||||
|
||||
```typescript
|
||||
if (editor.user.getAnimationSpeed() > 0) {
|
||||
editor.animateShape(
|
||||
{ id: shapeId, type: 'geo', x: 200, y: 100 },
|
||||
{ animation: { duration: 500 } }
|
||||
)
|
||||
} else {
|
||||
editor.updateShape({ id: shapeId, type: 'geo', x: 200, y: 100 })
|
||||
}
|
||||
```
|
||||
|
||||
## Related examples
|
||||
|
||||
- [Shape animation](/examples/editor-api/shape-animation) - Animate shapes with `animateShape` and easing functions.
|
||||
- [Slideshow](/examples/use-cases/slideshow) - Transition between slides with `zoomToBounds` and animation options.
|
||||
- [Reduced motion](/examples/configuration/reduced-motion) - Respect the user's animation speed preference and `prefers-reduced-motion`.
|
||||
160
apps/docs/content/sdk-features/arrow-shape.mdx
Normal file
160
apps/docs/content/sdk-features/arrow-shape.mdx
Normal file
@@ -0,0 +1,160 @@
|
||||
---
|
||||
title: Arrow shape
|
||||
created_at: 1/31/2026
|
||||
updated_at: 1/31/2026
|
||||
keywords:
|
||||
- arrow
|
||||
- connector
|
||||
- binding
|
||||
- elbow
|
||||
- arc
|
||||
- arrowhead
|
||||
status: draft
|
||||
date: 1/31/2026
|
||||
---
|
||||
|
||||
The arrow shape draws a connector between two points on the canvas. Arrows can bind to other shapes, so they stay attached when those shapes move. Arrows route as curved arcs or right-angled elbows, take one of nine arrowhead styles at each end, and can carry a rich text label.
|
||||
|
||||
## Creating arrows
|
||||
|
||||
An arrow is a shape of type `arrow`. Its `start` and `end` props are points relative to the shape's position:
|
||||
|
||||
```tsx
|
||||
import { Tldraw, toRichText } from 'tldraw'
|
||||
import 'tldraw/tldraw.css'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0 }}>
|
||||
<Tldraw
|
||||
onMount={(editor) => {
|
||||
editor.createShape({
|
||||
type: 'arrow',
|
||||
x: 100,
|
||||
y: 100,
|
||||
props: {
|
||||
start: { x: 0, y: 0 },
|
||||
end: { x: 300, y: 100 },
|
||||
richText: toRichText('connects to'),
|
||||
},
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
The full set of props on [TLArrowShape](?):
|
||||
|
||||
| Property | Type | Description |
|
||||
| ---------------- | ---------------------------- | ----------------------------------------------------- |
|
||||
| `kind` | `TLArrowShapeKind` | Routing mode: `arc` (default) or `elbow` |
|
||||
| `start` | `VecModel` | Start terminal position, relative to the shape origin |
|
||||
| `end` | `VecModel` | End terminal position |
|
||||
| `bend` | `number` | Curvature for arc arrows (`0` = straight) |
|
||||
| `elbowMidPoint` | `number` | Position (0–1) of the middle segment for elbow arrows |
|
||||
| `color` | `TLDefaultColorStyle` | Stroke color |
|
||||
| `fill` | `TLDefaultFillStyle` | Fill style for arrowheads |
|
||||
| `dash` | `TLDefaultDashStyle` | Stroke pattern |
|
||||
| `size` | `TLDefaultSizeStyle` | Stroke width preset |
|
||||
| `arrowheadStart` | `TLArrowShapeArrowheadStyle` | Arrowhead at the start (default `none`) |
|
||||
| `arrowheadEnd` | `TLArrowShapeArrowheadStyle` | Arrowhead at the end (default `arrow`) |
|
||||
| `richText` | `TLRichText` | Optional text label |
|
||||
| `labelPosition` | `number` | Label position along the arrow (0 = start, 1 = end) |
|
||||
| `labelColor` | `TLDefaultColorStyle` | Label text color |
|
||||
| `font` | `TLDefaultFontStyle` | Font family for the label |
|
||||
| `scale` | `number` | Scale factor applied to the shape |
|
||||
|
||||
## Binding arrows to shapes
|
||||
|
||||
When a user draws an arrow onto a shape, the editor creates an arrow binding: a record that connects one of the arrow's terminals to the target shape. The arrow recalculates its path whenever the bound shape moves, resizes, or rotates. When a terminal is bound, the editor computes its position from the binding and ignores the corresponding `start` or `end` prop.
|
||||
|
||||
To bind an arrow programmatically, create a binding of type `arrow` from the arrow to the target shape:
|
||||
|
||||
```tsx
|
||||
editor.createBinding({
|
||||
type: 'arrow',
|
||||
fromId: arrow.id,
|
||||
toId: targetShape.id,
|
||||
props: {
|
||||
terminal: 'end',
|
||||
normalizedAnchor: { x: 0.5, y: 0.5 },
|
||||
isPrecise: false,
|
||||
isExact: false,
|
||||
snap: 'none',
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
An arrow can have up to two bindings, one with `terminal: 'start'` and one with `terminal: 'end'`. Use [getArrowBindings](?) to read an arrow's current bindings. See [Bindings](/sdk-features/bindings) for how bindings work in general, and the [create arrow example](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/editor-api/create-arrow) for a complete implementation.
|
||||
|
||||
### Precise and exact bindings
|
||||
|
||||
Three binding props control where the arrow attaches. See [TLArrowBindingProps](?) for details.
|
||||
|
||||
| Prop | Effect |
|
||||
| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `normalizedAnchor` | The attachment point within the shape's bounds, where `{ x: 0, y: 0 }` is the top-left corner and `{ x: 1, y: 1 }` is the bottom-right |
|
||||
| `isPrecise` | When `true`, the arrow points at the `normalizedAnchor` position. When `false`, it points at the shape's center |
|
||||
| `isExact` | When `true`, the arrowhead travels all the way to the anchor point, entering the shape. When `false`, the arrow stops at the edge of the shape's geometry |
|
||||
|
||||
When drawing with the arrow tool, users get imprecise center bindings by default. Hovering over a spot inside a shape for a moment, pausing while dragging a terminal, or holding Alt switches to a precise binding at that spot. The `hoverPreciseTimeout` and `pointingPreciseTimeout` options control the delays, and holding Ctrl skips binding entirely (`shouldIgnoreTargets`).
|
||||
|
||||
## Routing: arc and elbow
|
||||
|
||||
The `kind` prop selects between the two routing modes. It's a style prop, so users can switch modes from the style panel.
|
||||
|
||||
Arc arrows (`kind: 'arc'`, the default) draw a straight line or a circular arc between their terminals. The `bend` prop is the perpendicular distance from the straight line to the arc's midpoint: `0` draws a straight line, positive and negative values curve to either side. Users adjust the bend by dragging the arrow's middle handle.
|
||||
|
||||
Elbow arrows (`kind: 'elbow'`) route with right-angled segments, like connectors in a flowchart. The `elbowMidPoint` prop (0–1) positions the middle segment between the two ends. The binding's `snap` prop records what the terminal snapped to when drawn: `'center'`, `'edge'`, `'edge-point'`, or `'none'`. The edge values are only produced for elbow arrows, which use them when routing.
|
||||
|
||||
## Arrowheads
|
||||
|
||||
The `arrowheadStart` and `arrowheadEnd` props set the style of each end independently. The available styles are `arrow`, `triangle`, `square`, `dot`, `pipe`, `diamond`, `inverted`, `bar`, and `none`. By default, arrows have `none` at the start and `arrow` at the end.
|
||||
|
||||
```tsx
|
||||
editor.updateShape({
|
||||
id: arrow.id,
|
||||
type: 'arrow',
|
||||
props: {
|
||||
arrowheadStart: 'dot',
|
||||
arrowheadEnd: 'triangle',
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Labels
|
||||
|
||||
Arrows can have a rich text label, stored in the `richText` prop. Use [toRichText](?) to create one from a plain string. The `labelPosition` prop (0–1) places the label along the arrow's path, with `0.5` (the default) at the midpoint. Users can drag the label along the arrow to reposition it. The `labelColor` and `font` props style the label's text.
|
||||
|
||||
## Configuration
|
||||
|
||||
[ArrowShapeUtil](?) exposes options for tuning arrow behavior, like snap distances, precise-targeting timeouts, and whether new arrows bind exactly. Use the static `configure` method to override them:
|
||||
|
||||
```tsx
|
||||
import { ArrowShapeUtil, Tldraw } from 'tldraw'
|
||||
import 'tldraw/tldraw.css'
|
||||
|
||||
const shapeUtils = [
|
||||
ArrowShapeUtil.configure({
|
||||
// Precise arrows point exactly at the spot the user picked
|
||||
shouldBeExact: (editor, isPrecise) => isPrecise,
|
||||
}),
|
||||
]
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0 }}>
|
||||
<Tldraw shapeUtils={shapeUtils} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
See [ArrowShapeOptions](?) for the full list of options.
|
||||
|
||||
## Related examples
|
||||
|
||||
- [Create arrow](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/editor-api/create-arrow) — Create an arrow between two shapes programmatically, including both bindings.
|
||||
- [Precise and exact arrows](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/configuration/arrows-precise-exact) — Configure the arrow tool so precise targets produce exact bindings.
|
||||
447
apps/docs/content/sdk-features/assets.mdx
Normal file
447
apps/docs/content/sdk-features/assets.mdx
Normal file
@@ -0,0 +1,447 @@
|
||||
---
|
||||
title: Assets
|
||||
created_at: 12/20/2025
|
||||
updated_at: 12/29/2025
|
||||
keywords:
|
||||
- assets
|
||||
- images
|
||||
- videos
|
||||
- bookmarks
|
||||
- files
|
||||
- upload
|
||||
- media
|
||||
- storage
|
||||
status: published
|
||||
date: 12/20/2025
|
||||
readability: 9
|
||||
voice: 9
|
||||
completeness: 9
|
||||
accuracy: 10
|
||||
notes: ''
|
||||
---
|
||||
|
||||
Assets are external resources like images, videos, and bookmarks that shapes display on the canvas. They're stored as separate records in the store and referenced by ID from shapes. This lets you reuse the same image across multiple shapes without duplicating data, and swap out storage backends without touching your shapes.
|
||||
|
||||
The SDK includes three asset types: image, video, and bookmark. Each asset record holds metadata (dimensions, MIME type, source URL) while the actual file lives wherever you want to put it. You provide upload and resolve handlers that tell tldraw how to store files and fetch them for rendering.
|
||||
|
||||
## How it works
|
||||
|
||||
### Asset records and the store
|
||||
|
||||
Assets live in the store alongside shapes and pages. Each asset record contains metadata (dimensions, MIME type, name) but not the actual file bytes—those live in your storage backend.
|
||||
|
||||
When someone drops an image onto the canvas, tldraw creates two records: an asset record with dimensions and metadata, and a shape record with position and size. The shape references the asset through its `assetId` property. Multiple shapes can reference the same asset. Deleting a shape never deletes its asset: call [Editor#deleteAssets](?) yourself when you know an asset is no longer referenced.
|
||||
|
||||
Asset records have a `props` object for type-specific properties and a `meta` object for your custom data. The `src` property in props holds the URL returned by your upload handler. This can be an HTTP URL, a data URL, or any string your resolve handler understands.
|
||||
|
||||
### Asset types
|
||||
|
||||
The SDK defines three built-in asset types.
|
||||
|
||||
**Image assets** store raster images like PNG, JPEG, or GIF. They track width, height, MIME type, animation status, file size, and an optional `pixelRatio` for @2x images. The `isAnimated` flag is true for animated GIF, WebP, AVIF, and APNG files.
|
||||
|
||||
```typescript
|
||||
const imageAsset: TLImageAsset = {
|
||||
id: 'asset:image123' as TLAssetId,
|
||||
typeName: 'asset',
|
||||
type: 'image',
|
||||
props: {
|
||||
w: 1920,
|
||||
h: 1080,
|
||||
name: 'photo.jpg',
|
||||
isAnimated: false,
|
||||
mimeType: 'image/jpeg', // can be null if unknown
|
||||
src: 'https://storage.example.com/uploads/photo.jpg', // can be null before upload
|
||||
fileSize: 245000, // optional
|
||||
pixelRatio: 2, // optional
|
||||
},
|
||||
meta: {},
|
||||
}
|
||||
```
|
||||
|
||||
**Video assets** store video files like MP4 or WebM. They have the same structure as image assets: dimensions, MIME type, source URL, and `isAnimated` (which is typically true for videos).
|
||||
|
||||
```typescript
|
||||
const videoAsset: TLVideoAsset = {
|
||||
id: 'asset:video456' as TLAssetId,
|
||||
typeName: 'asset',
|
||||
type: 'video',
|
||||
props: {
|
||||
w: 1920,
|
||||
h: 1080,
|
||||
name: 'clip.mp4',
|
||||
isAnimated: true,
|
||||
mimeType: 'video/mp4',
|
||||
src: 'https://storage.example.com/uploads/clip.mp4',
|
||||
fileSize: 5242880,
|
||||
},
|
||||
meta: {},
|
||||
}
|
||||
```
|
||||
|
||||
**Bookmark assets** store web page previews. When someone pastes a URL, tldraw fetches metadata from the page and creates a bookmark that renders as a preview card.
|
||||
|
||||
```typescript
|
||||
const bookmarkAsset: TLBookmarkAsset = {
|
||||
id: 'asset:bookmark1' as TLAssetId,
|
||||
typeName: 'asset',
|
||||
type: 'bookmark',
|
||||
props: {
|
||||
title: 'Example Website',
|
||||
description: 'A great example of web design',
|
||||
image: 'https://example.com/preview.jpg',
|
||||
favicon: 'https://example.com/favicon.ico',
|
||||
src: 'https://example.com',
|
||||
},
|
||||
meta: {},
|
||||
}
|
||||
```
|
||||
|
||||
### The TLAssetStore interface
|
||||
|
||||
[TLAssetStore](?) defines how tldraw talks to your storage backend. You provide an implementation when creating the editor, and tldraw calls your handlers whenever someone adds or accesses assets.
|
||||
|
||||
The default behavior depends on your store setup. With an in-memory store (the default), [inlineBase64AssetStore](?) converts every uploaded file to a data URL: quick for prototyping, but nothing persists across sessions. With a [`persistenceKey`](/sdk-features/persistence#The-persistenceKey-prop), assets are stored in the browser's [IndexedDB](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API) alongside the document. With a [sync server](/docs/sync), implement `TLAssetStore` yourself to upload files to S3, Google Cloud Storage, or your own API.
|
||||
|
||||
The interface has three methods:
|
||||
|
||||
| Method | Purpose |
|
||||
| --------- | --------------------------------------------------------------------------------- |
|
||||
| `upload` | Store a file and return its URL |
|
||||
| `resolve` | Return the URL to use when rendering an asset (optional, defaults to `props.src`) |
|
||||
| `remove` | Clean up files when assets are deleted (optional) |
|
||||
|
||||
The **upload** method receives an asset record (with metadata already populated) and the File to store. Return an object with `src` (the URL) and optionally `meta` (custom metadata to merge into the asset record). You also get an AbortSignal for cancellation.
|
||||
|
||||
```typescript
|
||||
async upload(asset: TLAsset, file: File, abortSignal?: AbortSignal): Promise<{ src: string; meta?: JsonObject }>
|
||||
```
|
||||
|
||||
The **resolve** method receives an asset and a [TLAssetContext](?) describing how the asset is being displayed. It can be sync or async. Return the URL to use for rendering, or `null` if the asset is unavailable (shapes then render a broken-asset placeholder). This is where you can get clever: return optimized thumbnails when zoomed out, high-resolution images for export, or add authentication tokens.
|
||||
|
||||
```typescript
|
||||
resolve(asset: TLAsset, ctx: TLAssetContext): Promise<string | null> | string | null
|
||||
```
|
||||
|
||||
The **remove** method receives asset IDs that are no longer needed. Clean up the stored files to free space. This method is optional.
|
||||
|
||||
```typescript
|
||||
async remove(assetIds: TLAssetId[]): Promise<void>
|
||||
```
|
||||
|
||||
Here's a minimal implementation that converts files to data URLs (good for prototyping, not so great for production):
|
||||
|
||||
```typescript
|
||||
import { Tldraw, TLAssetStore } from 'tldraw'
|
||||
import 'tldraw/tldraw.css'
|
||||
|
||||
const assetStore: TLAssetStore = {
|
||||
async upload(asset, file) {
|
||||
const dataUrl = await new Promise<string>((resolve) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(reader.result as string)
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
return { src: dataUrl }
|
||||
},
|
||||
|
||||
resolve(asset, ctx) {
|
||||
return asset.props.src
|
||||
},
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0 }}>
|
||||
<Tldraw assets={assetStore} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### The TLAssetContext
|
||||
|
||||
When resolving assets, tldraw gives you a [TLAssetContext](?) with information about the current render environment. Use this to optimize asset delivery.
|
||||
|
||||
| Property | Type | Description |
|
||||
| ------------------------- | ---------------- | ----------------------------------------------------------------------------------------------------------------- |
|
||||
| `screenScale` | `number` | How much the asset is scaled relative to native dimensions. A 1000px image rendered at 500px has screenScale 0.5. |
|
||||
| `steppedScreenScale` | `number` | screenScale rounded up to the next power of 2, useful for tiered caching. |
|
||||
| `dpr` | `number` | Device pixel ratio. Retina displays are 2 or 3. |
|
||||
| `networkEffectiveType` | `string \| null` | Browser's connection type: 'slow-2g', '2g', '3g', or '4g'. |
|
||||
| `shouldResolveToOriginal` | `boolean` | True for copy/paste and for SVG exports without an explicit `pixelRatio`. Return full quality. |
|
||||
|
||||
Here's a resolve handler that serves optimized images based on network conditions and zoom level:
|
||||
|
||||
```typescript
|
||||
resolve(asset, ctx) {
|
||||
const baseUrl = asset.props.src
|
||||
if (!baseUrl) return null
|
||||
|
||||
// For exports, always return original
|
||||
if (ctx.shouldResolveToOriginal) {
|
||||
return baseUrl
|
||||
}
|
||||
|
||||
// On slow connections, serve lower quality
|
||||
if (ctx.networkEffectiveType === 'slow-2g' || ctx.networkEffectiveType === '2g') {
|
||||
return `${baseUrl}?quality=low`
|
||||
}
|
||||
|
||||
// Serve resolution appropriate for current zoom
|
||||
const targetWidth = Math.ceil(asset.props.w * ctx.steppedScreenScale * ctx.dpr)
|
||||
return `${baseUrl}?w=${targetWidth}`
|
||||
}
|
||||
```
|
||||
|
||||
## Key components
|
||||
|
||||
### Editor asset methods
|
||||
|
||||
The [Editor](?) class provides methods for managing assets:
|
||||
|
||||
| Method | Description |
|
||||
| --------------------------- | ----------------------------------------- |
|
||||
| [Editor#createAssets](?) | Add asset records to the store |
|
||||
| [Editor#updateAssets](?) | Update existing assets |
|
||||
| [Editor#deleteAssets](?) | Remove assets and call the remove handler |
|
||||
| [Editor#getAsset](?) | Get an asset by ID |
|
||||
| [Editor#getAssets](?) | Get all assets in the store |
|
||||
| [Editor#resolveAssetUrl](?) | Resolve an asset ID to a renderable URL |
|
||||
|
||||
Asset operations happen outside the undo/redo history since they're typically part of larger operations like pasting images. You don't want "undo" to magically un-upload a file.
|
||||
|
||||
```typescript
|
||||
// Create an asset
|
||||
editor.createAssets([imageAsset])
|
||||
|
||||
// Update an asset. updateAssets shallow-merges the record, so spread the existing props
|
||||
editor.updateAssets([
|
||||
{
|
||||
...imageAsset,
|
||||
props: { ...imageAsset.props, name: 'new-name.jpg' },
|
||||
},
|
||||
])
|
||||
|
||||
// Get an asset with type safety
|
||||
const asset = editor.getAsset<TLImageAsset>(imageAsset.id)
|
||||
|
||||
// Resolve to a URL for rendering
|
||||
const url = await editor.resolveAssetUrl(imageAsset.id, { screenScale: 0.5 })
|
||||
|
||||
// Delete assets
|
||||
editor.deleteAssets([imageAsset.id])
|
||||
```
|
||||
|
||||
### Shape and asset relationships
|
||||
|
||||
Shapes reference assets through an `assetId` property in their props. Image shapes, video shapes, and bookmark shapes all follow this pattern. The shape stores position, size, rotation, and crop settings while the asset stores the media metadata and source URL.
|
||||
|
||||
This separation pays off:
|
||||
|
||||
- Update an asset's `src` and every shape referencing it updates immediately
|
||||
- Duplicate a shape without duplicating storage
|
||||
- Implement lazy loading where assets only load when shapes become visible
|
||||
|
||||
When you delete an asset, shapes referencing it render a broken-asset placeholder.
|
||||
|
||||
## Extension points
|
||||
|
||||
### Custom storage backends
|
||||
|
||||
Implement [TLAssetStore](?) to integrate with any storage backend. For local development, convert files to data URLs. For production, upload to S3, Google Cloud Storage, or your own API.
|
||||
|
||||
Here's an example that uploads to a custom API:
|
||||
|
||||
```typescript
|
||||
const assetStore: TLAssetStore = {
|
||||
async upload(asset, file, abortSignal) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('assetId', asset.id)
|
||||
|
||||
const response = await fetch('/api/upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
signal: abortSignal,
|
||||
})
|
||||
|
||||
const { url, uploadedAt } = await response.json()
|
||||
return {
|
||||
src: url,
|
||||
meta: { uploadedAt }, // Custom metadata gets merged into the asset
|
||||
}
|
||||
},
|
||||
|
||||
resolve(asset, ctx) {
|
||||
// Add auth token for private content
|
||||
const token = getAuthToken()
|
||||
return `${asset.props.src}?token=${token}`
|
||||
},
|
||||
|
||||
async remove(assetIds) {
|
||||
await fetch('/api/assets', {
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify({ ids: assetIds }),
|
||||
})
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Custom asset types
|
||||
|
||||
[AssetUtil](?) is the asset-side counterpart to `ShapeUtil`. Each asset type has one, and it defines which MIME types the type accepts, how to derive an asset record from a dropped file, and what default props new instances start with. The built-in `ImageAssetUtil`, `VideoAssetUtil`, and `BookmarkAssetUtil` live in `defaultAssetUtils`.
|
||||
|
||||
To add your own type, register its props on `TLGlobalAssetPropsMap` via TypeScript module augmentation, then implement an `AssetUtil` for it. Set `static props` so the store validates and migrates your records:
|
||||
|
||||
```typescript
|
||||
import { AssetUtil, T, TLAsset, TLAssetId } from 'tldraw'
|
||||
|
||||
const AUDIO_TYPE = 'audio'
|
||||
|
||||
declare module 'tldraw' {
|
||||
export interface TLGlobalAssetPropsMap {
|
||||
[AUDIO_TYPE]: {
|
||||
src: string | null
|
||||
mimeType: string | null
|
||||
name: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type TLAudioAsset = TLAsset<typeof AUDIO_TYPE>
|
||||
|
||||
class AudioAssetUtil extends AssetUtil<TLAudioAsset> {
|
||||
static override type = AUDIO_TYPE
|
||||
static override props = {
|
||||
src: T.string.nullable(),
|
||||
mimeType: T.string.nullable(),
|
||||
name: T.string,
|
||||
}
|
||||
|
||||
override getDefaultProps(): TLAudioAsset['props'] {
|
||||
return { src: null, mimeType: null, name: '' }
|
||||
}
|
||||
|
||||
override getSupportedMimeTypes() {
|
||||
return ['audio/mpeg', 'audio/wav', 'audio/ogg']
|
||||
}
|
||||
|
||||
override async getAssetFromFile(file: File, assetId: TLAssetId): Promise<TLAudioAsset | null> {
|
||||
return {
|
||||
id: assetId,
|
||||
typeName: 'asset',
|
||||
type: AUDIO_TYPE,
|
||||
props: {
|
||||
src: null, // populated by the asset store after upload
|
||||
mimeType: file.type,
|
||||
name: file.name,
|
||||
},
|
||||
meta: {},
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Pass the util to the `<Tldraw>` component. The default asset utils are always included; a custom util with the same `type` replaces the built-in one:
|
||||
|
||||
```tsx
|
||||
import { Tldraw } from 'tldraw'
|
||||
|
||||
const assetUtils = [AudioAssetUtil]
|
||||
|
||||
export default function App() {
|
||||
return <Tldraw assetUtils={assetUtils} />
|
||||
}
|
||||
```
|
||||
|
||||
When a file is dropped or pasted, the editor checks it against `maxAssetSize`, then finds the first registered util whose `acceptsMimeType()` returns true for the file's MIME type and calls its `getAssetFromFile()`. The returned asset record then flows through your `TLAssetStore.upload` handler, which assigns the final `src`. To place the asset on the canvas, a shape util must declare the asset type in `static handledAssetTypes` and implement `createShapeForAsset()`; that shape reads the resolved URL through `editor.resolveAssetUrl()` like the built-in shapes do. See the [custom asset type example](/examples/data/assets/custom-asset-type) for the full pattern.
|
||||
|
||||
### Configuring built-in asset utils
|
||||
|
||||
The simplest way to configure the built-in utils is through the `<Tldraw>` props `maxAssetSize`, `maxImageDimension`, `acceptedImageMimeTypes`, and `acceptedVideoMimeTypes`. For anything else, use [AssetUtil#configure](?) to tweak options on a built-in util without subclassing it. For example, lock image uploads down to PNG:
|
||||
|
||||
```tsx
|
||||
import { ImageAssetUtil, defaultAssetUtils, Tldraw } from 'tldraw'
|
||||
|
||||
const PngOnlyImageAssetUtil = ImageAssetUtil.configure({
|
||||
supportedMimeTypes: ['image/png'],
|
||||
})
|
||||
|
||||
const assetUtils = defaultAssetUtils.map((util) =>
|
||||
util === ImageAssetUtil ? PngOnlyImageAssetUtil : util
|
||||
)
|
||||
|
||||
<Tldraw assetUtils={assetUtils} />
|
||||
```
|
||||
|
||||
`ImageAssetUtil` exposes `maxDimension` and `supportedMimeTypes` options. `VideoAssetUtil` exposes `supportedMimeTypes`. If you also pass the matching `<Tldraw>` props, those win.
|
||||
|
||||
### Asset validation and migrations
|
||||
|
||||
Asset records use the migration system to evolve their schema. Each asset type has its own migration sequence that handles adding properties, renaming fields, and validating data. When you load a document with old asset records, migrations transform them to the current schema automatically.
|
||||
|
||||
Validators ensure asset data matches the expected structure at runtime. Setting `static props` on your `AssetUtil` is enough for the store to validate it. If you need a standalone validator, [createAssetValidator](?) builds one for a single asset type (id, `typeName`, `type` literal, props, and meta). Add migration sequences via `static migrations` to handle schema changes over time.
|
||||
|
||||
## Security
|
||||
|
||||
### SVG sanitization
|
||||
|
||||
SVG files can contain scripts, event handlers, and external resource references that execute during rendering. tldraw automatically sanitizes all SVGs on paste and file drop using an allowlist-based sanitizer that:
|
||||
|
||||
- Strips `<script>`, `<iframe>`, `<object>`, `<embed>`, and other dangerous elements
|
||||
- Removes all `on*` event handler attributes (`onerror`, `onload`, etc.)
|
||||
- Allows only `http:`, `https:`, and `mailto:` links
|
||||
- Restricts `<image>` and `<feImage>` hrefs to `data:` URIs only
|
||||
- Restricts `<use>` hrefs to fragment references (`#id`) only
|
||||
- Sanitizes CSS to remove `@import`, `expression()`, and external `url()` references
|
||||
- Preserves `<foreignObject>` content (needed for text rendering) with a separate HTML allowlist
|
||||
- Preserves `<style>` elements with `data:` font URLs (needed for embedded fonts)
|
||||
|
||||
For stronger guarantees, we recommend [DOMPurify](https://github.com/cure53/DOMPurify), a widely used and audited sanitizer. We don't bundle it (it's a ~17KB dependency), but if your app already uses it you can wire it into your external content handlers. Note that DOMPurify's default SVG profile strips `<foreignObject>` and `<style>` elements, which tldraw uses for text rendering and embedded fonts. Configure it to preserve those so tldraw's SVG output round-trips correctly.
|
||||
|
||||
If you're implementing custom external content handlers, you can also import our built-in sanitizer directly:
|
||||
|
||||
```typescript
|
||||
import { sanitizeSvg } from 'tldraw'
|
||||
|
||||
const sanitized = sanitizeSvg(svgText)
|
||||
if (!sanitized) {
|
||||
// SVG contained no safe content
|
||||
}
|
||||
```
|
||||
|
||||
### Recommended CSP policy
|
||||
|
||||
For defense in depth, we recommend deploying a Content Security Policy. This protects against attack vectors that sanitization alone cannot cover:
|
||||
|
||||
```
|
||||
Content-Security-Policy:
|
||||
default-src 'self';
|
||||
script-src 'self';
|
||||
style-src 'self' 'unsafe-inline';
|
||||
img-src 'self' data: blob: https://your-asset-domain.com;
|
||||
font-src 'self' data:;
|
||||
connect-src 'self' https://your-api.com;
|
||||
object-src 'none';
|
||||
base-uri 'self';
|
||||
```
|
||||
|
||||
- **`script-src 'self'`** prevents inline scripts in SVGs from executing
|
||||
- **`style-src 'self' 'unsafe-inline'`** allows tldraw's runtime inline styles while blocking external stylesheets
|
||||
- **`img-src 'self' data: blob:`** allows data URLs for embedded images and blob URLs for asset previews, while blocking loads to arbitrary external origins
|
||||
- **`object-src 'none'`** blocks `<object>` and `<embed>` elements entirely
|
||||
- **`base-uri 'self'`** prevents `<base>` tag injection that could redirect relative URLs
|
||||
|
||||
### Hosting assets on a separate domain
|
||||
|
||||
We recommend serving user-uploaded assets from a completely separate domain (e.g. `example-assets.com` rather than a subdomain like `assets.example.com`). This provides an extra layer of protection: if a malicious file somehow bypasses sanitization, browser same-origin policies prevent it from accessing cookies, storage, or APIs on your main domain.
|
||||
|
||||
## Related examples
|
||||
|
||||
- [Hosted images](/examples/data/assets/hosted-images) - Implement a TLAssetStore that uploads images to a server
|
||||
- [Local images](/examples/editor-api/local-images) - Create image shapes from local asset records
|
||||
- [Local videos](/examples/editor-api/local-videos) - Create video shapes from local asset records
|
||||
- [Asset options](/examples/configuration/asset-props) - Control allowed asset types, max size, and dimensions
|
||||
- [Custom asset type](/examples/data/assets/custom-asset-type) - Register a custom AssetUtil and a shape that renders it
|
||||
- [Static assets](/examples/data/assets/static-assets) - Pre-load custom fonts and icons
|
||||
217
apps/docs/content/sdk-features/attribution.mdx
Normal file
217
apps/docs/content/sdk-features/attribution.mdx
Normal file
@@ -0,0 +1,217 @@
|
||||
---
|
||||
title: Attribution
|
||||
created_at: 3/30/2026
|
||||
updated_at: 3/30/2026
|
||||
keywords:
|
||||
- attribution
|
||||
- user
|
||||
- users
|
||||
- TLUserStore
|
||||
- TLUser
|
||||
- getAttributionUserId
|
||||
- getAttributionDisplayName
|
||||
- getAttributionUser
|
||||
- getReferencedUserIds
|
||||
- createCachedUserResolve
|
||||
- textLastEditedBy
|
||||
- UserRecordType
|
||||
- createUserId
|
||||
status: published
|
||||
date: 3/30/2026
|
||||
---
|
||||
|
||||
The attribution system lets you track which users create or edit shapes. Connect tldraw to your auth system through a [TLUserStore](?) to resolve display names, render attribution labels, and persist user records alongside your document data. The built-in note shape uses attribution to show who last edited a note's text, and you can add similar tracking to custom shapes.
|
||||
|
||||
## User store
|
||||
|
||||
A `TLUserStore` provides a reactive `currentUser` signal for the active user and an optional `resolve` method for looking up other users by ID. Pass it as the `users` prop on the [Tldraw](?) component or the [useSync](?) hook:
|
||||
|
||||
```tsx
|
||||
import { computed, createUserId, Tldraw, TLUserStore, UserRecordType } from 'tldraw'
|
||||
import 'tldraw/tldraw.css'
|
||||
|
||||
const currentUser = computed('currentUser', () =>
|
||||
UserRecordType.create({
|
||||
id: createUserId('user-123'),
|
||||
name: 'Alice',
|
||||
color: '#e03131',
|
||||
})
|
||||
)
|
||||
|
||||
const users: TLUserStore = {
|
||||
currentUser,
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0 }}>
|
||||
<Tldraw users={users} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
When no `users` prop is provided, the editor derives the current user from [user preferences](/sdk-features/user-preferences). With `useSync`, other users are resolved from collaborator presence.
|
||||
|
||||
### Resolving other users
|
||||
|
||||
The optional `resolve` method looks up users by their raw ID string. The editor calls it first whenever it needs a display name or user record for an ID:
|
||||
|
||||
```tsx
|
||||
import {
|
||||
computed,
|
||||
createCachedUserResolve,
|
||||
createUserId,
|
||||
TLUserStore,
|
||||
UserRecordType,
|
||||
} from 'tldraw'
|
||||
|
||||
const users: TLUserStore = {
|
||||
currentUser: computed('currentUser', () =>
|
||||
UserRecordType.create({
|
||||
id: createUserId('user-123'),
|
||||
name: 'Alice',
|
||||
color: '#e03131',
|
||||
})
|
||||
),
|
||||
resolve: createCachedUserResolve((userId) => {
|
||||
// Look up the user from your auth system
|
||||
return myUserCache.get(userId) ?? null
|
||||
}),
|
||||
}
|
||||
```
|
||||
|
||||
The [createCachedUserResolve](?) helper wraps a lookup function so that each user ID gets a single stable reactive signal. If you write your own `resolve`, return the same signal for repeated calls with the same ID.
|
||||
|
||||
## How attribution works
|
||||
|
||||
Attribution is opt-in per shape type. Each shape util decides what to track and when to stamp a user ID. When you stamp a user ID with [Editor#getAttributionUserId](?), the editor also writes a corresponding `user:` record to the store so that display names survive across sessions, clipboard paste, and `.tldr` file exports.
|
||||
|
||||
### Note shape attribution
|
||||
|
||||
The built-in note shape tracks who last edited its text. Whenever a note's rich text changes and is non-empty, [NoteShapeUtil](?) sets the `textLastEditedBy` prop to the current user's ID via `editor.getAttributionUserId()`. Clearing the text resets it to `null`, and duplicating or pasting a note with text re-stamps the copy to the current user. The note renders the last editor's first name as a small label in the corner.
|
||||
|
||||
The label is rendered by the note shape util's `AttributionComponent` option, which you can override or hide with `NoteShapeUtil.configure`, passed through the `shapeUtils` prop. This applies on the canvas and in image exports:
|
||||
|
||||
```tsx
|
||||
import { NoteShapeUtil, Tldraw } from 'tldraw'
|
||||
|
||||
// Hide the attribution badge
|
||||
;<Tldraw shapeUtils={[NoteShapeUtil.configure({ AttributionComponent: null })]} />
|
||||
|
||||
// Or render your own
|
||||
;<Tldraw
|
||||
shapeUtils={[
|
||||
NoteShapeUtil.configure({
|
||||
AttributionComponent: ({ firstName, color }) => (
|
||||
<div className="tl-note__attribution" style={{ color }}>
|
||||
✍️ {firstName}
|
||||
</div>
|
||||
),
|
||||
}),
|
||||
]}
|
||||
/>
|
||||
```
|
||||
|
||||
## Reading attribution
|
||||
|
||||
The [Editor](?) provides three methods for working with attribution. [Editor#getAttributionUserId](?) returns the current user's raw ID string (without the `user:` prefix). [Editor#getAttributionDisplayName](?) and [Editor#getAttributionUser](?) check the `TLUserStore` first, then fall back to `user:` records in the store:
|
||||
|
||||
```tsx
|
||||
import { useEditor, useValue } from 'tldraw'
|
||||
|
||||
function AttributionLabel({ userId }: { userId: string }) {
|
||||
const editor = useEditor()
|
||||
|
||||
const name = useValue('attribution-name', () => editor.getAttributionDisplayName(userId), [
|
||||
editor,
|
||||
userId,
|
||||
])
|
||||
|
||||
if (!name) return null
|
||||
return <span>{name}</span>
|
||||
}
|
||||
```
|
||||
|
||||
## Custom shape attribution
|
||||
|
||||
Add attribution tracking to your own shapes by storing a user ID in your shape's props and overriding [ShapeUtil#getReferencedUserIds](?):
|
||||
|
||||
```tsx
|
||||
import { ShapeUtil, T, TLBaseShape } from 'tldraw'
|
||||
|
||||
type MyShape = TLBaseShape<
|
||||
'my-shape',
|
||||
{
|
||||
createdBy: string | null
|
||||
}
|
||||
>
|
||||
|
||||
class MyShapeUtil extends ShapeUtil<MyShape> {
|
||||
static override type = 'my-shape' as const
|
||||
static override props = {
|
||||
createdBy: T.string.nullable(),
|
||||
}
|
||||
|
||||
override getReferencedUserIds(shape: MyShape) {
|
||||
return shape.props.createdBy ? [shape.props.createdBy] : []
|
||||
}
|
||||
|
||||
// ... other ShapeUtil methods
|
||||
}
|
||||
```
|
||||
|
||||
When shapes are copied to the clipboard or exported, the editor includes the `user:` records returned by `getReferencedUserIds`. Display names then remain available on the other side.
|
||||
|
||||
To stamp the current user when creating shapes:
|
||||
|
||||
```tsx
|
||||
const userId = editor.getAttributionUserId()
|
||||
|
||||
editor.createShape({
|
||||
type: 'my-shape',
|
||||
props: {
|
||||
createdBy: userId,
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Extensible user records
|
||||
|
||||
Extend user records with custom metadata by passing validators to [createTLSchema](?):
|
||||
|
||||
```tsx
|
||||
import { createTLSchema, T } from 'tldraw'
|
||||
|
||||
const schema = createTLSchema({
|
||||
user: {
|
||||
meta: {
|
||||
department: T.string,
|
||||
isAdmin: T.boolean,
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Custom metadata is validated and persisted alongside the standard user fields. Access it through the `meta` property on [TLUser](?) records.
|
||||
|
||||
## API reference
|
||||
|
||||
| Symbol | Description |
|
||||
| ------------------------------------- | ----------------------------------------------------------------------- |
|
||||
| [Editor#getAttributionUserId](?) | Get the current user's ID for stamping shapes. Returns `string \| null` |
|
||||
| [Editor#getAttributionDisplayName](?) | Resolve a display name from a user ID. Returns `string \| null` |
|
||||
| [Editor#getAttributionUser](?) | Resolve a full [TLUser](?) record from a user ID |
|
||||
| [TLUserStore](?) | Interface for connecting to your auth/user system |
|
||||
| [TLUser](?) | A user record in the store |
|
||||
| [UserRecordType](?) | The default user record type |
|
||||
| [createUserId](?) | Create a typed user ID |
|
||||
| [createCachedUserResolve](?) | Create a cached resolve function for `TLUserStore` |
|
||||
| [createUserRecordType](?) | Build a user record type with custom meta validators |
|
||||
|
||||
For setting up user identity in multiplayer, see the [Collaboration](/sdk-features/collaboration#User-identity) page.
|
||||
|
||||
## Related examples
|
||||
|
||||
- [Attribution](/examples/users/attribution): basic attribution with a user switcher and attribution inspector
|
||||
- [Attribution timeline](/examples/users/attribution-timeline): timeline scrubber with per-user filtering
|
||||
234
apps/docs/content/sdk-features/bindings.mdx
Normal file
234
apps/docs/content/sdk-features/bindings.mdx
Normal file
@@ -0,0 +1,234 @@
|
||||
---
|
||||
title: Bindings
|
||||
created_at: 12/20/2025
|
||||
updated_at: 12/29/2025
|
||||
keywords:
|
||||
- bindings
|
||||
- relationships
|
||||
- arrows
|
||||
- connections
|
||||
- BindingUtil
|
||||
status: published
|
||||
date: 12/20/2025
|
||||
readability: 9
|
||||
voice: 8
|
||||
completeness: 9
|
||||
accuracy: 8
|
||||
notes: Minor passive phrasing in places. Add API links using [BindingUtil](?) format.
|
||||
---
|
||||
|
||||
Bindings create persistent relationships between shapes. When you draw an arrow to a rectangle, a binding stores that connection so the arrow stays attached when you move the rectangle. Bindings power features like arrows that follow shapes, stickers that stick to other shapes, and layout constraints that keep shapes aligned.
|
||||
|
||||
The SDK handles bookkeeping for you: when you delete a shape, its bindings are cleaned up automatically, and lifecycle hooks on the binding's [BindingUtil](?) let you react to changes.
|
||||
|
||||
## How it works
|
||||
|
||||
When you create a binding, the editor stores it as a record with `fromId` and `toId` fields pointing to shape IDs. The editor maintains an index of all bindings touching each shape. When either shape changes position, transforms, or gets deleted, the binding's [BindingUtil](?) receives callbacks that can update the bound shapes accordingly.
|
||||
|
||||
The system handles several scenarios automatically:
|
||||
|
||||
- When a shape is deleted, all its bindings are removed and their binding utils receive isolation and deletion callbacks
|
||||
- When shapes are copied, only bindings between copied shapes are duplicated
|
||||
- When shapes are moved to different pages, cross-page bindings are automatically removed
|
||||
- When both bound shapes are copied or duplicated together, the binding is copied with them
|
||||
|
||||
The bindings index is a computed value that updates incrementally as bindings change. Lookups are fast and never scan all records.
|
||||
|
||||
## Key concepts
|
||||
|
||||
### Directional relationships
|
||||
|
||||
Every binding has direction. The `fromId` points to the source shape, and the `toId` points to the target shape. For arrows, the arrow is always the "from" shape and the shape it points to is the "to" shape. This directionality determines which lifecycle hooks fire and lets the system know which shape "owns" the relationship.
|
||||
|
||||
The distinction matters when shapes change. If you move a rectangle that an arrow points to, the arrow binding's `onAfterChangeToShape` hook fires. If you move the arrow itself, `onAfterChangeFromShape` fires instead.
|
||||
|
||||
### Binding records
|
||||
|
||||
A binding record ([TLBaseBinding](?)) contains just enough information to identify the relationship and store relationship-specific data:
|
||||
|
||||
```typescript
|
||||
interface TLBaseBinding<Type, Props> {
|
||||
id: TLBindingId
|
||||
typeName: 'binding'
|
||||
type: Type
|
||||
fromId: TLShapeId
|
||||
toId: TLShapeId
|
||||
props: Props
|
||||
meta: JsonObject
|
||||
}
|
||||
```
|
||||
|
||||
The `props` field holds binding-specific data. Arrow bindings store the normalized anchor point on the target shape and whether the attachment is "precise" or should snap to the shape's edge. Custom bindings can store any data appropriate to the relationship type.
|
||||
|
||||
### BindingUtil lifecycle
|
||||
|
||||
Each binding type implements a [BindingUtil](?) class that responds to events throughout the binding's lifetime:
|
||||
|
||||
| Hooks | When they fire |
|
||||
| -------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `onBeforeCreate`, `onAfterCreate`, `onBeforeChange`, `onAfterChange` | The binding record itself is created or modified. The `onBefore*` hooks can return a replacement binding record. |
|
||||
| `onAfterChangeFromShape`, `onAfterChangeToShape` | A bound shape changes. These are the most common hooks for keeping shapes synchronized. Arrow bindings use them to update the arrow's position and parent when the target shape moves. |
|
||||
| `onBeforeDelete`, `onAfterDelete` | The binding record is removed. |
|
||||
| `onBeforeDeleteFromShape`, `onBeforeDeleteToShape` | A bound shape is about to be deleted. |
|
||||
| `onBeforeIsolateFromShape`, `onBeforeIsolateToShape` | The bound shapes are about to be separated (one is deleted, copied, or duplicated without the other). Use these to "bake in" the binding's current state before it disappears. |
|
||||
| `onOperationComplete` | All binding operations in a transaction have finished. Use it to compute aggregate updates across many related bindings. |
|
||||
|
||||
### Isolation vs deletion
|
||||
|
||||
Isolation callbacks handle a specific problem: when an arrow's target shape is deleted, the arrow shouldn't suddenly point to empty space. The `onBeforeIsolateFromShape` hook receives the binding and the `removedShape`, and lets the arrow update its terminal position to match the current attachment point before the binding is removed. The arrow then appears to "let go" of the shape naturally.
|
||||
|
||||
Isolation also occurs during copy and duplicate operations. If you copy an arrow but not its target, the copied arrow needs to convert its binding into a fixed position. The isolation callback handles this transformation.
|
||||
|
||||
Use isolation callbacks for consistency updates that should happen whenever shapes separate. Use `onBeforeDeleteFromShape` and `onBeforeDeleteToShape` for actions specific to deletion, like removing a sticker when its parent shape is deleted.
|
||||
|
||||
## API patterns
|
||||
|
||||
### Creating bindings
|
||||
|
||||
Create bindings using [Editor#createBinding](?) or [Editor#createBindings](?). You must provide the binding type, `fromId`, and `toId`. The BindingUtil supplies default props for anything you leave out. The editor checks both shapes' `canBind()` first and skips the binding if either refuses.
|
||||
|
||||
```typescript
|
||||
editor.createBinding({
|
||||
type: 'arrow',
|
||||
fromId: arrowShape.id,
|
||||
toId: targetShape.id,
|
||||
props: {
|
||||
terminal: 'end',
|
||||
normalizedAnchor: { x: 0.5, y: 0.5 },
|
||||
isPrecise: false,
|
||||
isExact: false,
|
||||
snap: 'none',
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Querying bindings
|
||||
|
||||
The editor provides several methods for finding bindings: [Editor#getBinding](?), [Editor#getBindingsFromShape](?), [Editor#getBindingsToShape](?), and [Editor#getBindingsInvolvingShape](?).
|
||||
|
||||
```typescript
|
||||
// Get a specific binding by ID
|
||||
const binding = editor.getBinding(bindingId)
|
||||
|
||||
// Get all bindings where this shape is the source
|
||||
const outgoing = editor.getBindingsFromShape(shape.id, 'arrow')
|
||||
|
||||
// Get all bindings where this shape is the target
|
||||
const incoming = editor.getBindingsToShape(shape.id, 'arrow')
|
||||
|
||||
// Get all bindings involving this shape (either direction)
|
||||
const all = editor.getBindingsInvolvingShape(shape.id, 'arrow')
|
||||
```
|
||||
|
||||
### Updating and deleting bindings
|
||||
|
||||
Update bindings with [Editor#updateBinding](?), passing a partial with the binding's `id` and `type`:
|
||||
|
||||
```typescript
|
||||
editor.updateBinding({
|
||||
id: binding.id,
|
||||
type: 'arrow',
|
||||
props: { normalizedAnchor: { x: 0.8, y: 0.2 } },
|
||||
})
|
||||
```
|
||||
|
||||
Delete bindings with [Editor#deleteBinding](?), or let the system remove them automatically when shapes are deleted. Pass `isolateShapes: true` to trigger isolation callbacks:
|
||||
|
||||
```typescript
|
||||
editor.deleteBinding(binding.id, { isolateShapes: true })
|
||||
```
|
||||
|
||||
### Controlling which shapes can bind
|
||||
|
||||
Shapes control whether they accept bindings by implementing [ShapeUtil#canBind](?):
|
||||
|
||||
```typescript
|
||||
class MyShapeUtil extends ShapeUtil<MyShape> {
|
||||
static override type = 'my-shape' as const
|
||||
|
||||
override canBind({ toShape, bindingType }: TLShapeUtilCanBindOpts) {
|
||||
// Only allow arrow bindings where this shape is the target
|
||||
return bindingType === 'arrow' && toShape.type === 'my-shape'
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The editor calls both shapes' `canBind()` methods before creating or updating a binding. If either returns false, the binding is skipped. Use [Editor#canBindShapes](?) to run the same check yourself.
|
||||
|
||||
## Extension points
|
||||
|
||||
Custom binding types let you create new kinds of relationships between shapes.
|
||||
|
||||
### Defining the binding type
|
||||
|
||||
First, extend the type system to include your binding's props. Use TypeScript's module augmentation to add your binding type to [TLGlobalBindingPropsMap](?), then derive the binding type from [TLBinding](?):
|
||||
|
||||
```typescript
|
||||
import { TLBinding, VecModel } from 'tldraw'
|
||||
|
||||
declare module 'tldraw' {
|
||||
export interface TLGlobalBindingPropsMap {
|
||||
myBinding: {
|
||||
anchor: VecModel
|
||||
strength: number
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type MyBinding = TLBinding<'myBinding'>
|
||||
```
|
||||
|
||||
### Implementing BindingUtil
|
||||
|
||||
Create a class extending [BindingUtil](?) with your binding type. At minimum, implement `getDefaultProps()`. Add lifecycle hooks based on what behavior you need:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
BindingOnShapeChangeOptions,
|
||||
BindingOnShapeIsolateOptions,
|
||||
BindingUtil,
|
||||
RecordProps,
|
||||
T,
|
||||
vecModelValidator,
|
||||
} from 'tldraw'
|
||||
|
||||
class MyBindingUtil extends BindingUtil<MyBinding> {
|
||||
static override type = 'myBinding' as const
|
||||
static override props: RecordProps<MyBinding> = {
|
||||
anchor: vecModelValidator,
|
||||
strength: T.number,
|
||||
}
|
||||
|
||||
override getDefaultProps() {
|
||||
return { anchor: { x: 0.5, y: 0.5 }, strength: 1 }
|
||||
}
|
||||
|
||||
override onAfterChangeToShape({ binding, shapeAfter }: BindingOnShapeChangeOptions<MyBinding>) {
|
||||
// Update the "from" shape when the "to" shape moves
|
||||
}
|
||||
|
||||
override onBeforeIsolateFromShape({
|
||||
binding,
|
||||
removedShape,
|
||||
}: BindingOnShapeIsolateOptions<MyBinding>) {
|
||||
// Bake in current state before the binding is removed
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Registering the binding
|
||||
|
||||
Pass your BindingUtil to the editor via the `bindingUtils` prop:
|
||||
|
||||
```tsx
|
||||
<Tldraw bindingUtils={[MyBindingUtil]} />
|
||||
```
|
||||
|
||||
## Related examples
|
||||
|
||||
The examples app includes several binding implementations that demonstrate different use cases:
|
||||
|
||||
- [Sticker bindings](/examples/shapes/tools/sticker-bindings) - Shapes that stick to other shapes and follow them when moved. Demonstrates `onAfterChangeToShape` for position updates and `onBeforeDeleteToShape` for cascading deletion.
|
||||
- [Pin bindings](/examples/shapes/tools/pin-bindings) - Pins that connect networks of shapes together, moving them as a group. Demonstrates `onOperationComplete` for computing aggregate updates across multiple related bindings.
|
||||
- [Layout bindings](/examples/shapes/tools/layout-bindings) - Constraining shapes to layout positions. Demonstrates using bindings to enforce spatial relationships between shapes.
|
||||
- [Arrow binding options](/examples/shapes/tools/arrow-binding-options) - Configuring how the built-in arrow binding attaches to shapes.
|
||||
226
apps/docs/content/sdk-features/camera.mdx
Normal file
226
apps/docs/content/sdk-features/camera.mdx
Normal file
@@ -0,0 +1,226 @@
|
||||
---
|
||||
title: Camera system
|
||||
created_at: 12/20/2025
|
||||
updated_at: 12/29/2025
|
||||
keywords:
|
||||
- camera
|
||||
- zoom
|
||||
- pan
|
||||
- viewport
|
||||
- constraints
|
||||
- animation
|
||||
status: published
|
||||
date: 12/20/2025
|
||||
readability: 9
|
||||
voice: 8
|
||||
completeness: 10
|
||||
accuracy: 9
|
||||
notes: Could use more direct "you" address and questions-as-transitions per voice guide. Some passive constructions remain.
|
||||
---
|
||||
|
||||
The camera controls which part of the infinite canvas is visible. It holds the viewport's position and zoom, converts between screen space and page space, and responds to user input like wheel, trackpad, keyboard, and touch. You can constrain it to a bounded area, move it programmatically with animation, and follow other users' viewports in collaborative sessions.
|
||||
|
||||
## How it works
|
||||
|
||||
The camera represents the viewport's position and zoom using three values: `x` and `y` for position in page space, and `z` for zoom level. A zoom of `1` means 100%, `0.5` is 50%, and `2` is 200%. The camera's `x` and `y` are the page-space offset of the viewport: the page point at the viewport's top-left corner is `(-x, -y)`.
|
||||
|
||||
The camera converts between screen space (browser pixels) and page space (the canvas). Use [Editor#screenToPage](?) to turn a mouse position into a canvas point and [Editor#pageToScreen](?) to go the other way. See [Coordinates](/sdk-features/coordinates) for the full set of conversions.
|
||||
|
||||
```typescript
|
||||
const pagePoint = editor.screenToPage({ x: event.clientX, y: event.clientY })
|
||||
const screenPoint = editor.pageToScreen({ x: shape.x, y: shape.y })
|
||||
```
|
||||
|
||||
Read the camera with [Editor#getCamera](?) or [Editor#getZoomLevel](?), and read the visible page area with [Editor#getViewportPageBounds](?). All of these are reactive, so you can use them in `track` components or `useValue`.
|
||||
|
||||
## Camera options
|
||||
|
||||
Configure the camera with [TLCameraOptions](?). Pass the initial options through the `options.camera` prop, or change them at runtime with [Editor#setCameraOptions](?), which re-applies the constraints to the current camera immediately:
|
||||
|
||||
```tsx
|
||||
<Tldraw options={{ camera: { wheelBehavior: 'zoom' } }} />
|
||||
```
|
||||
|
||||
```typescript
|
||||
editor.setCameraOptions({
|
||||
isLocked: false,
|
||||
wheelBehavior: 'pan',
|
||||
panSpeed: 1,
|
||||
zoomSpeed: 1,
|
||||
zoomSteps: [0.1, 0.25, 0.5, 1, 2, 4, 8],
|
||||
})
|
||||
```
|
||||
|
||||
Set `isLocked` to freeze the camera for fixed-viewport apps. Camera methods then do nothing unless you pass `force: true`.
|
||||
|
||||
`wheelBehavior` decides what the mouse wheel or trackpad scroll does: `'pan'`, `'zoom'`, or `'none'`. If the user has set an input mode in their preferences, that preference wins between the two: `'trackpad'` pans and `'mouse'` zooms. `'none'` disables the wheel regardless of the preference.
|
||||
|
||||
`panSpeed` and `zoomSpeed` are multipliers on input sensitivity. Values below 1 slow movement down, values above 1 speed it up.
|
||||
|
||||
`zoomSteps` lists the discrete zoom levels used by zoom in and zoom out. The first value is the minimum zoom and the last is the maximum; the camera clamps to this range even without constraints.
|
||||
|
||||
## Camera constraints
|
||||
|
||||
Camera constraints limit where users can navigate. Use them for presentations, guided experiences, or applications with fixed content areas:
|
||||
|
||||
```typescript
|
||||
editor.setCameraOptions({
|
||||
constraints: {
|
||||
bounds: { x: 0, y: 0, w: 1920, h: 1080 },
|
||||
padding: { x: 50, y: 50 },
|
||||
origin: { x: 0.5, y: 0.5 },
|
||||
initialZoom: 'fit-min',
|
||||
baseZoom: 'default',
|
||||
behavior: 'inside',
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
The `bounds` define the constrained area in page space. The camera restricts panning outside this rectangle based on the `behavior` setting.
|
||||
|
||||
The `padding` adds a screen-space margin inside the viewport so content doesn't touch the edges.
|
||||
|
||||
The `origin` positions the bounds within the viewport when an axis uses `'fixed'` behavior, when `'contain'` is zoomed out below the fit zoom, and when the camera resets. `{ x: 0.5, y: 0.5 }` centers the bounds; `{ x: 0, y: 0 }` aligns them top-left.
|
||||
|
||||
### Zoom fitting
|
||||
|
||||
`initialZoom` is the zoom the camera starts at and returns to on reset. `baseZoom` is the zoom that `zoomSteps` are multiplied by, so a step of `1` means "the base zoom" rather than 100%. Both accept the same values:
|
||||
|
||||
| Value | Description |
|
||||
| --------------- | ----------------------------------------------------------------------- |
|
||||
| `'default'` | 100% zoom |
|
||||
| `'fit-x'` | The bounds' width fills the viewport width |
|
||||
| `'fit-y'` | The bounds' height fills the viewport height |
|
||||
| `'fit-min'` | The smaller axis fills the viewport; the larger axis may extend past it |
|
||||
| `'fit-max'` | The larger axis fills the viewport, so the full bounds stay visible |
|
||||
| `'fit-x-100'` | `fit-x` or 100%, whichever is smaller |
|
||||
| `'fit-y-100'` | `fit-y` or 100%, whichever is smaller |
|
||||
| `'fit-min-100'` | `fit-min` or 100%, whichever is smaller |
|
||||
| `'fit-max-100'` | `fit-max` or 100%, whichever is smaller |
|
||||
|
||||
### Constraint behaviors
|
||||
|
||||
The `behavior` option controls how the bounds constrain camera movement:
|
||||
|
||||
| Value | Description |
|
||||
| ----------- | ------------------------------------------------------------------------------- |
|
||||
| `'free'` | The bounds are ignored |
|
||||
| `'fixed'` | The bounds are pinned at the origin; the user can't pan |
|
||||
| `'inside'` | The bounds stay completely within the viewport |
|
||||
| `'outside'` | The bounds stay touching the viewport |
|
||||
| `'contain'` | `'fixed'` when zoomed out below the fit zoom, `'inside'` when zoomed in past it |
|
||||
|
||||
Set behavior per axis for asymmetric constraints:
|
||||
|
||||
```typescript
|
||||
behavior: {
|
||||
x: 'free', // Horizontal panning unrestricted
|
||||
y: 'inside', // Vertical panning keeps bounds visible
|
||||
}
|
||||
```
|
||||
|
||||
## Camera methods
|
||||
|
||||
The camera-move methods below ([Editor#setCamera](?), [Editor#centerOnPoint](?), [Editor#zoomIn](?), [Editor#zoomOut](?), [Editor#zoomToFit](?), [Editor#zoomToSelection](?), [Editor#zoomToBounds](?), and [Editor#resetZoom](?)) accept optional [TLCameraMoveOptions](?):
|
||||
|
||||
- `animation` - animate the move with `duration` and `easing`
|
||||
- `immediate` - move the camera immediately rather than on the next tick
|
||||
- `force` - move the camera even when `isLocked` is true
|
||||
- `reset` - reset the camera to the constraints' initial zoom and origin
|
||||
|
||||
### Basic navigation
|
||||
|
||||
Move the camera to a specific position and zoom:
|
||||
|
||||
```typescript
|
||||
editor.setCamera({ x: -500, y: -300, z: 1.5 })
|
||||
```
|
||||
|
||||
Center the viewport on a point:
|
||||
|
||||
```typescript
|
||||
editor.centerOnPoint({ x: 1000, y: 500 })
|
||||
```
|
||||
|
||||
Zoom in or out. Both methods accept an optional screen point to zoom toward:
|
||||
|
||||
```typescript
|
||||
editor.zoomIn()
|
||||
editor.zoomOut()
|
||||
editor.zoomIn(editor.inputs.getCurrentScreenPoint(), { animation: { duration: 200 } })
|
||||
```
|
||||
|
||||
### Zoom to content
|
||||
|
||||
Focus the camera on shapes or bounds:
|
||||
|
||||
```typescript
|
||||
// Fit all shapes on the current page
|
||||
editor.zoomToFit()
|
||||
|
||||
// Fit the current selection
|
||||
editor.zoomToSelection()
|
||||
|
||||
// Reset zoom to 100%. With constraints, toggles between the initial zoom and 100%
|
||||
editor.resetZoom()
|
||||
|
||||
// Fit specific bounds with padding
|
||||
const bounds = { x: 0, y: 0, w: 1000, h: 800 }
|
||||
editor.zoomToBounds(bounds, { inset: 100 })
|
||||
```
|
||||
|
||||
`zoomToBounds` accepts `inset` to add screen-space padding around the bounds and `targetZoom` to cap the zoom level.
|
||||
|
||||
### Animated movement
|
||||
|
||||
Add smooth transitions with the `animation` option. The [EASINGS](?) object provides common easing functions:
|
||||
|
||||
```typescript
|
||||
import { EASINGS } from 'tldraw'
|
||||
|
||||
editor.setCamera(
|
||||
{ x: 0, y: 0, z: 1 },
|
||||
{
|
||||
animation: {
|
||||
duration: 500,
|
||||
easing: EASINGS.easeInOutCubic,
|
||||
},
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
Camera animations stop automatically when the user pans or zooms: user input takes precedence over programmatic movement. You can also stop them at any time with [Editor#stopCameraAnimation](?). See [Animation](/sdk-features/animation) for more on animation options and user preferences.
|
||||
|
||||
### Momentum scrolling
|
||||
|
||||
Use [Editor#slideCamera](?) for kinetic scrolling, for example to keep the camera moving after a gesture ends:
|
||||
|
||||
```typescript
|
||||
editor.slideCamera({
|
||||
speed: 1,
|
||||
direction: { x: 1, y: 0 },
|
||||
friction: 0.1,
|
||||
speedThreshold: 0.01,
|
||||
})
|
||||
```
|
||||
|
||||
`speed` is clamped to a maximum of `1` and `direction` sets the initial velocity. A `z` component on `direction` slides the zoom as well. `friction` controls how fast the camera decelerates (higher stops sooner) and defaults to `editor.options.cameraSlideFriction`. The slide ends once the speed drops below `speedThreshold`.
|
||||
|
||||
### Quick zoom navigation
|
||||
|
||||
The default tldraw UI has a quick zoom mode. Press `z` to select the zoom tool, then hold Shift. The camera zooms out to show your current viewport plus everything on the page, and a brush marks where you'll land. Move the cursor to place the brush and release Shift to zoom there. Press Escape to cancel and return to the original view.
|
||||
|
||||
## Collaboration features
|
||||
|
||||
Call [Editor#startFollowingUser](?) to track another user's viewport, or [Editor#zoomToUser](?) to jump to their cursor once. While following, the camera fits the other user's viewport inside yours: if the aspect ratios differ, the zoom adjusts so their whole viewport stays visible.
|
||||
|
||||
See [User following](/sdk-features/user-following) for details.
|
||||
|
||||
## Related examples
|
||||
|
||||
- [Camera options](/examples/configuration/camera-options) - Configure the camera's options and constraints, including zoom behavior, pan speed, and camera bounds.
|
||||
- [Image annotator](/examples/use-cases/image-annotator) - Configure camera options for a fixed-viewport annotation app.
|
||||
- [Slideshow (fixed camera)](/examples/use-cases/slideshow) - A slideshow with a fixed camera using camera constraints.
|
||||
- [Lock camera zoom](/examples/editor-api/lock-camera-zoom) - Lock the camera at a specific zoom level.
|
||||
- [Zoom to bounds](/examples/editor-api/zoom-to-bounds) - Zoom the camera to specific bounds with `zoomToBounds`.
|
||||
- [Scrollable container](/examples/layout/scroll) - Use the editor inside a scrollable container with mousewheel handling.
|
||||
148
apps/docs/content/sdk-features/click-detection.mdx
Normal file
148
apps/docs/content/sdk-features/click-detection.mdx
Normal file
@@ -0,0 +1,148 @@
|
||||
---
|
||||
title: Click detection
|
||||
created_at: 12/20/2025
|
||||
updated_at: 12/29/2025
|
||||
keywords:
|
||||
- click
|
||||
- double-click
|
||||
- overflow click
|
||||
- pointer
|
||||
- ClickManager
|
||||
status: published
|
||||
date: 12/20/2025
|
||||
readability: 9
|
||||
voice: 9
|
||||
completeness: 9
|
||||
accuracy: 10
|
||||
notes: Could add a simpler opening hook per voice guide ("Have five minutes?"-style).
|
||||
---
|
||||
|
||||
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:
|
||||
|
||||
| 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 |
|
||||
|
||||
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:
|
||||
|
||||
| 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 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](/sdk-features/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:
|
||||
|
||||
| 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' \| 'overlay'` | What was clicked |
|
||||
| `shape` | `TLShape \| undefined` | The shape, when target is `'shape'` or `'handle'` |
|
||||
| `handle` | `TLHandle \| TLSelectionHandle \| undefined` | The handle, when target is `'handle'` or `'selection'` |
|
||||
| `overlay` | `TLOverlay \| undefined` | The overlay, when target is `'overlay'` |
|
||||
| `shiftKey` | `boolean` | Whether Shift was held |
|
||||
| `altKey` | `boolean` | Whether Alt/Option was held |
|
||||
| `ctrlKey` | `boolean` | Whether Control (or Command) 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 | Double-click settle delay and overflow suppression window |
|
||||
|
||||
## Related examples
|
||||
|
||||
- [Canvas events](/examples/events/canvas-events) — logs pointer events including click sequences to understand the event flow
|
||||
- [Custom double-click behavior](/examples/events/custom-double-click-behavior) — overrides the default double-click handler in the SelectTool
|
||||
- [Shape with onDoubleClickEdge](/examples/shapes/tools/shape-with-onDoubleClickEdge) — implements `onDoubleClickEdge` in a custom shape
|
||||
173
apps/docs/content/sdk-features/clipboard.mdx
Normal file
173
apps/docs/content/sdk-features/clipboard.mdx
Normal file
@@ -0,0 +1,173 @@
|
||||
---
|
||||
title: Clipboard
|
||||
created_at: 12/20/2025
|
||||
updated_at: 12/29/2025
|
||||
keywords:
|
||||
- clipboard
|
||||
- copy
|
||||
- paste
|
||||
- cut
|
||||
- content
|
||||
status: published
|
||||
date: 12/20/2025
|
||||
readability: 9
|
||||
voice: 8
|
||||
completeness: 9
|
||||
accuracy: 10
|
||||
notes: ''
|
||||
---
|
||||
|
||||
The clipboard lets you copy, cut, and paste shapes within a single editor or between different editor instances. When you copy shapes, the editor serializes them along with their bindings and assets into a [TLContent](?) object. This format preserves document structure and relationships so shapes paste correctly elsewhere.
|
||||
|
||||
## How clipboard operations work
|
||||
|
||||
Clipboard operations have two flows: extracting content (copy/cut) and placing content (paste).
|
||||
|
||||
### Extracting content
|
||||
|
||||
When you copy or cut shapes, the editor calls [Editor#getContentFromCurrentPage](?) to serialize them into a [TLContent](?) object (or `undefined` if the selection is empty):
|
||||
|
||||
```ts
|
||||
const content = editor.getContentFromCurrentPage(editor.getSelectedShapeIds())
|
||||
// content contains shapes, bindings, assets, and schema
|
||||
```
|
||||
|
||||
This method collects the selected shapes and their descendants, gathers bindings between them, and includes any referenced assets. Root shapes (those whose parents aren't in the selection) get transformed to page coordinates so they paste at the correct position.
|
||||
|
||||
The method keeps only bindings where both the `fromId` and `toId` shapes are in the copied set. This prevents dangling references to shapes that won't exist in the pasted content.
|
||||
|
||||
### Placing content
|
||||
|
||||
[Editor#putContentOntoCurrentPage](?) handles paste operations. It takes [TLContent](?) and reconstructs shapes on the current page:
|
||||
|
||||
```ts
|
||||
// Paste at a specific point
|
||||
editor.putContentOntoCurrentPage(content, {
|
||||
point: { x: 100, y: 100 },
|
||||
select: true,
|
||||
})
|
||||
|
||||
// Paste and preserve original positions
|
||||
editor.putContentOntoCurrentPage(content, {
|
||||
preservePosition: true,
|
||||
})
|
||||
```
|
||||
|
||||
The method migrates the content through the store's schema system to handle version differences (it throws if `content.schema` is missing), remaps shape and binding IDs to prevent collisions, and finds an appropriate parent for the pasted shapes.
|
||||
|
||||
Parent selection depends on how you paste. With a `point`, the editor uses the deepest shape under that point that can receive every pasted root shape (via `ShapeUtil#canReceiveNewChildrenOfType`). Without a point, it looks at the current selection: for each selected shape it takes the nearest container that accepts the content (the shape itself, an accepting ancestor, or its parent), and if the selection spans several containers it uses their deepest common accepting ancestor. A shape is never pasted into itself. Shapes that land on the page are then reparented into any frame that contains their center.
|
||||
|
||||
### Browser clipboard integration
|
||||
|
||||
The editor writes clipboard data in multiple formats. For HTML-aware applications, it embeds serialized [TLContent](?) in a `<div data-tldraw>` element. For plain text, it extracts the text of the copied shapes.
|
||||
|
||||
The clipboard uses a versioned format with compression. Version 3 (the current format) stores assets as plain JSON and compresses other data using LZ compression. This keeps the payload small while asset information stays quickly accessible. Older version 1 and 2 payloads are still read on paste.
|
||||
|
||||
When pasting, the editor tries the browser's Clipboard API first because it preserves metadata that the clipboard event API strips out. If that fails, it falls back to reading from the paste event's clipboard data, and it prefers the event's files when the API only returned file names. The editor handles images, files, URLs, HTML, and plain text, routing each through the appropriate handler. To copy shapes as an image instead, use [copyAs](?), which shares the same pipeline.
|
||||
|
||||
Three hooks on [TldrawOptions](?) let you intercept these flows. `onClipboardPasteRaw` fires before tldraw parses the clipboard, so you can read the raw `ClipboardEvent` yourself; return `false` to short-circuit the default pipeline. `onBeforePasteFromClipboard` receives the parsed external content and can transform it or return `false` to cancel. `onBeforeCopyToClipboard` receives the [TLContent](?) about to be written and can transform it or return `false` to cancel the copy (for a cut, nothing is deleted).
|
||||
|
||||
### Asset resolution
|
||||
|
||||
Before writing to the clipboard, the editor calls [Editor#resolveAssetsInContent](?). Image and video assets whose `src` isn't already a data or http URL (for example, assets stored in IndexedDB) are resolved and inlined as data URLs; hosted URLs are copied unchanged:
|
||||
|
||||
```ts
|
||||
const content = editor.getContentFromCurrentPage(editor.getSelectedShapeIds())
|
||||
const resolved = await editor.resolveAssetsInContent(content)
|
||||
// local assets in resolved.assets now have data URLs for src
|
||||
```
|
||||
|
||||
This makes the content portable across editor instances without relying on URLs that only work in the source app.
|
||||
|
||||
### Cut operations
|
||||
|
||||
Cut combines copy and delete. The editor first copies the selected shapes to the clipboard, then deletes the originals, so a failed copy leaves your shapes intact.
|
||||
|
||||
### Pasting a link onto a shape
|
||||
|
||||
A url on the clipboard normally becomes a bookmark or an embed. When a single shape that can hold a link is selected — a geo, note, image, video, or bookmark shape — the url becomes that shape's link instead, and nothing is created. Embed shapes are excluded, since their `url` is the embedded content's source.
|
||||
|
||||
The clipboard signals this by setting `shapeId` on the `url` external content, so a custom `url` handler can take over or ignore it:
|
||||
|
||||
```ts
|
||||
editor.registerExternalContentHandler('url', async ({ url, point, shapeId }) => {
|
||||
// ignore shapeId to always create a bookmark
|
||||
})
|
||||
```
|
||||
|
||||
### Plain text paste
|
||||
|
||||
`Cmd+Shift+V` (or `Ctrl+Shift+V` on Windows and Linux) pastes the clipboard as plain text. HTML and rich formatting are stripped. This is the standard "paste without formatting" shortcut. It's handy when styled text from a browser or word processor would otherwise bring its fonts and colors onto the canvas with it. If the clipboard has no plain text (a copied PNG, say), the shortcut falls through to the normal paste.
|
||||
|
||||
## Content structure
|
||||
|
||||
The [TLContent](?) type defines the clipboard payload:
|
||||
|
||||
```ts
|
||||
interface TLContent {
|
||||
shapes: TLShape[]
|
||||
bindings: TLBinding[] | undefined
|
||||
rootShapeIds: TLShapeId[]
|
||||
assets: TLAsset[]
|
||||
schema: SerializedSchema
|
||||
users?: TLUser[]
|
||||
}
|
||||
```
|
||||
|
||||
- `shapes` contains all copied shapes in serialized form
|
||||
- `rootShapeIds` identifies which shapes have no parent in the copied set, distinguishing top-level shapes from nested children
|
||||
- `bindings` holds relationships between shapes, like arrows connected to boxes
|
||||
- `assets` includes images, videos, and other external resources
|
||||
- `schema` preserves the store schema version, so content from a different editor version can be migrated on paste
|
||||
- `users` carries any user records referenced by the copied shapes, so [attribution](/sdk-features/attribution) display names survive the paste; on paste, only users not already in the store are created
|
||||
|
||||
## Position handling
|
||||
|
||||
[Editor#putContentOntoCurrentPage](?) offers flexible positioning:
|
||||
|
||||
- By default, shapes paste in place if any of them is on screen; otherwise the group is centered in the viewport
|
||||
- When pasting into a selected container, shapes are centered in that container
|
||||
- With the `point` option, shapes are centered on that point. The UI passes the cursor position when the paste-at-cursor preference is on, when you paste from the context menu, or when you press `Cmd+Option+V` (`Ctrl+Alt+V`), which inverts the preference for one paste
|
||||
- The `preservePosition` option places shapes at their exact stored coordinates
|
||||
|
||||
The editor uses `preservePosition` internally when moving shapes between pages, where position preservation matters.
|
||||
|
||||
## ID remapping
|
||||
|
||||
Shape and binding IDs get remapped during paste to prevent collisions with existing shapes. The editor creates a mapping from old IDs to new IDs, then updates parent-child relationships and binding endpoints to match. Asset IDs are not remapped: assets already in the store are reused, and new ones are created under their original IDs.
|
||||
|
||||
The `preserveIds` option disables remapping. The editor uses it when moving shapes between pages, where the shapes should keep their existing IDs.
|
||||
|
||||
## External content handling
|
||||
|
||||
For non-tldraw content (images, URLs, plain text), use [Editor#putExternalContent](?) to route it through registered handlers:
|
||||
|
||||
```ts
|
||||
// Paste files at a specific point
|
||||
await editor.putExternalContent({
|
||||
type: 'files',
|
||||
files: droppedFiles,
|
||||
point: { x: 100, y: 200 },
|
||||
})
|
||||
|
||||
// Paste a URL
|
||||
await editor.putExternalContent({
|
||||
type: 'url',
|
||||
url: 'https://example.com/image.png',
|
||||
point: editor.inputs.getCurrentPagePoint(),
|
||||
})
|
||||
|
||||
// Paste text
|
||||
await editor.putExternalContent({
|
||||
type: 'text',
|
||||
text: 'Hello world',
|
||||
point: { x: 100, y: 100 },
|
||||
})
|
||||
```
|
||||
|
||||
Serialized tldraw content goes through the same route: `editor.putExternalContent({ type: 'tldraw', content })` marks a history stopping point, pastes with [Editor#putContentOntoCurrentPage](?), and selects the result. Register custom handlers with [Editor#registerExternalContentHandler](?) to customize how different content types are processed. See [External content](/sdk-features/external-content) for details on the handler system.
|
||||
|
||||
## Related examples
|
||||
|
||||
- [Custom paste behavior](/examples/data/assets/custom-paste) shows how to customize paste by registering an external content handler that changes where pasted shapes are positioned.
|
||||
- [External content sources](/examples/data/assets/external-content-sources) shows how to handle different content types when pasting into tldraw, including custom handling for HTML content.
|
||||
541
apps/docs/content/sdk-features/collaboration.mdx
Normal file
541
apps/docs/content/sdk-features/collaboration.mdx
Normal file
@@ -0,0 +1,541 @@
|
||||
---
|
||||
title: Collaboration
|
||||
created_at: 12/26/2025
|
||||
updated_at: 12/29/2025
|
||||
keywords:
|
||||
- multiplayer
|
||||
- sync
|
||||
- collaboration
|
||||
- real-time
|
||||
- presence
|
||||
- cursors
|
||||
- websocket
|
||||
- custom sync
|
||||
- store listeners
|
||||
- RecordsDiff
|
||||
status: published
|
||||
date: 12/26/2025
|
||||
readability: 9
|
||||
voice: 8
|
||||
completeness: 9
|
||||
accuracy: 9
|
||||
notes: Some sections could be more direct.
|
||||
---
|
||||
|
||||
The `@tldraw/sync` package provides real-time multiplayer collaboration for tldraw. Multiple users can edit the same document simultaneously, see each other's cursors, and follow each other's viewports. The sync system handles connection management, conflict resolution, and presence.
|
||||
|
||||
Collaboration requires a server component to coordinate changes between clients. Use tldraw's demo server for prototyping, or run your own server for production.
|
||||
|
||||
## Quick start with the demo server
|
||||
|
||||
The fastest way to add multiplayer is with [useSyncDemo](?). This hook connects to a hosted demo server that handles synchronization:
|
||||
|
||||
```tsx
|
||||
import { useSyncDemo } from '@tldraw/sync'
|
||||
import { Tldraw } from 'tldraw'
|
||||
import 'tldraw/tldraw.css'
|
||||
|
||||
export default function App() {
|
||||
const store = useSyncDemo({ roomId: 'my-room-id' })
|
||||
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0 }}>
|
||||
<Tldraw store={store} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Anyone who opens the app with the same `roomId` will see the same document and each other's cursors. The demo server is great for prototyping, but data is deleted after a day and rooms are publicly accessible by ID. Don't use it in production.
|
||||
|
||||
## Production setup with useSync
|
||||
|
||||
For production, use the [useSync](?) hook with your own server:
|
||||
|
||||
```tsx
|
||||
import { useSync } from '@tldraw/sync'
|
||||
import { Tldraw } from 'tldraw'
|
||||
import 'tldraw/tldraw.css'
|
||||
|
||||
export default function Room({ roomId }: { roomId: string }) {
|
||||
const store = useSync({
|
||||
uri: `wss://your-server.com/sync/${roomId}`,
|
||||
assets: myAssetStore,
|
||||
})
|
||||
|
||||
if (store.status === 'loading') {
|
||||
return <div>Connecting...</div>
|
||||
}
|
||||
|
||||
if (store.status === 'error') {
|
||||
return <div>Connection error: {store.error.message}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0 }}>
|
||||
<Tldraw store={store.store} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
The `useSync` hook returns a [RemoteTLStoreWithStatus](?) object with three possible states:
|
||||
|
||||
| Status | Description |
|
||||
| --------------- | -------------------------------------------------------------- |
|
||||
| `loading` | Establishing connection and performing initial sync |
|
||||
| `synced-remote` | Connected and syncing. Includes `store` and `connectionStatus` |
|
||||
| `error` | Connection failed. Includes `error` with details |
|
||||
|
||||
### Asset storage
|
||||
|
||||
Production setups require a [TLAssetStore](?) for handling images, videos, and other files:
|
||||
|
||||
```tsx
|
||||
const myAssetStore: TLAssetStore = {
|
||||
upload: async (asset, file) => {
|
||||
const response = await fetch('/api/upload', {
|
||||
method: 'POST',
|
||||
body: file,
|
||||
})
|
||||
const { url } = await response.json()
|
||||
return { src: url }
|
||||
},
|
||||
resolve: (asset, context) => {
|
||||
// context includes dpr, networkEffectiveType, and shouldResolveToOriginal
|
||||
return asset.props.src
|
||||
},
|
||||
}
|
||||
|
||||
const store = useSync({
|
||||
uri: `wss://your-server.com/sync/${roomId}`,
|
||||
assets: myAssetStore,
|
||||
})
|
||||
```
|
||||
|
||||
See the [Assets](/docs/assets) documentation for more on implementing asset stores.
|
||||
|
||||
## User identity
|
||||
|
||||
By default, users get a default name and a random color, stored in localStorage. To customize this, pass a [TLUserStore](?) as `users`, whose methods return reactive `Signal` values:
|
||||
|
||||
```tsx
|
||||
import { computed, UserRecordType, createUserId } from 'tldraw'
|
||||
|
||||
const currentUser = computed('currentUser', () =>
|
||||
UserRecordType.create({
|
||||
id: createUserId('user-123'),
|
||||
name: 'Alice',
|
||||
color: '#ff0000',
|
||||
})
|
||||
)
|
||||
|
||||
const store = useSyncDemo({
|
||||
roomId: 'my-room',
|
||||
users: {
|
||||
currentUser,
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
The `users` store is also used for shape attribution — the same `currentUser` signal stamps shapes with user IDs. See the [Attribution](/sdk-features/attribution) page for details on tracking which users create or edit shapes. Provide a `resolve` method to look up other users by ID:
|
||||
|
||||
```tsx
|
||||
const users: TLUserStore = {
|
||||
currentUser: myAuth.currentUser$,
|
||||
resolve: (userId) => myUserCache.getSignal(userId),
|
||||
}
|
||||
```
|
||||
|
||||
### Integrating with useTldrawCurrentUser
|
||||
|
||||
If you need to let users edit their preferences through tldraw's UI, use [useTldrawCurrentUser](?):
|
||||
|
||||
```tsx
|
||||
import { useSyncDemo } from '@tldraw/sync'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
atom,
|
||||
computed,
|
||||
createUserId,
|
||||
TLUserPreferences,
|
||||
TLUserStore,
|
||||
Tldraw,
|
||||
UserRecordType,
|
||||
useTldrawCurrentUser,
|
||||
} from 'tldraw'
|
||||
|
||||
export default function App({ roomId }: { roomId: string }) {
|
||||
const [userPreferences, setUserPreferences] = useState<TLUserPreferences>({
|
||||
id: 'user-123',
|
||||
name: 'Alice',
|
||||
color: 'coral',
|
||||
colorScheme: 'dark',
|
||||
})
|
||||
|
||||
const userPrefsAtom = useRef(atom('userPrefs', userPreferences)).current
|
||||
useEffect(() => {
|
||||
userPrefsAtom.set(userPreferences)
|
||||
}, [userPreferences, userPrefsAtom])
|
||||
|
||||
const users: TLUserStore = useMemo(() => {
|
||||
const currentUser = computed('currentUser', () => {
|
||||
const p = userPrefsAtom.get()
|
||||
return UserRecordType.create({
|
||||
id: createUserId(p.id),
|
||||
name: p.name ?? '',
|
||||
color: p.color ?? '',
|
||||
})
|
||||
})
|
||||
return { currentUser }
|
||||
}, [userPrefsAtom])
|
||||
|
||||
const store = useSyncDemo({ roomId, users })
|
||||
const user = useTldrawCurrentUser({ userPreferences, setUserPreferences })
|
||||
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0 }}>
|
||||
<Tldraw store={store} user={user} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Connection status
|
||||
|
||||
When using `useSync`, the store object includes connection status information:
|
||||
|
||||
```tsx
|
||||
const store = useSync({ uri, assets })
|
||||
|
||||
if (store.status === 'synced-remote') {
|
||||
// store.connectionStatus is 'online' or 'offline'
|
||||
console.log('Connection:', store.connectionStatus)
|
||||
}
|
||||
```
|
||||
|
||||
The connection status reflects the WebSocket connection state. When offline, changes are queued locally and sync when the connection resumes.
|
||||
|
||||
## Custom presence
|
||||
|
||||
The presence system controls what information is shared with other users. By default, it includes cursor position, selected shapes, and viewport bounds. You can customize this with `getUserPresence`, starting from [getDefaultUserPresence](?):
|
||||
|
||||
```tsx
|
||||
import { getDefaultUserPresence } from 'tldraw'
|
||||
|
||||
const store = useSyncDemo({
|
||||
roomId: 'my-room',
|
||||
getUserPresence(store, user) {
|
||||
const defaults = getDefaultUserPresence(store, user)
|
||||
if (!defaults) return null
|
||||
|
||||
return {
|
||||
...defaults,
|
||||
// Remove camera/viewport to disable follow functionality
|
||||
camera: undefined,
|
||||
}
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Return `null` from `getUserPresence` to hide this user's presence entirely. This is useful for spectator modes where you want a user to observe without appearing in the room.
|
||||
|
||||
## Authentication
|
||||
|
||||
To add authentication, generate the WebSocket URI dynamically:
|
||||
|
||||
```tsx
|
||||
const store = useSync({
|
||||
uri: async () => {
|
||||
const token = await getAuthToken()
|
||||
return `wss://your-server.com/sync/${roomId}?token=${token}`
|
||||
},
|
||||
assets: myAssetStore,
|
||||
})
|
||||
```
|
||||
|
||||
The `uri` option accepts a function that returns a string or Promise. This runs when establishing the connection and on reconnection, so tokens can refresh automatically.
|
||||
|
||||
## Running your own server
|
||||
|
||||
For production, you'll need to run a sync server. The `@tldraw/sync-core` package provides [TLSocketRoom](?) for server-side room management.
|
||||
|
||||
Our [Cloudflare Workers template](https://github.com/tldraw/tldraw/tree/main/templates/sync-cloudflare) is the same setup that runs tldraw.com. It includes:
|
||||
|
||||
- WebSocket sync via Durable Objects (one per room)
|
||||
- Asset storage with R2
|
||||
- Bookmark unfurling for URL previews
|
||||
|
||||
Get started with the template:
|
||||
|
||||
```bash
|
||||
npm create tldraw@latest -- --template multiplayer
|
||||
```
|
||||
|
||||
Or copy the relevant pieces into your existing infrastructure. See [Sync](/docs/sync) for other server setups.
|
||||
|
||||
### Server architecture
|
||||
|
||||
The sync server uses a room-based model:
|
||||
|
||||
1. Each document has a unique room ID
|
||||
2. Clients connect via WebSocket to their room
|
||||
3. The server maintains one `TLSocketRoom` per active room
|
||||
4. Changes broadcast to all connected clients in real-time
|
||||
5. The server is authoritative for conflict resolution
|
||||
|
||||
```
|
||||
┌─────────┐ ┌─────────────────┐ ┌─────────┐
|
||||
│ Client │────▶│ TLSocketRoom │◀────│ Client │
|
||||
└─────────┘ │ (per room) │ └─────────┘
|
||||
└────────┬────────┘
|
||||
│
|
||||
┌────▼────┐
|
||||
│ Storage │
|
||||
└─────────┘
|
||||
```
|
||||
|
||||
## Custom shapes and bindings
|
||||
|
||||
If you use custom shapes or bindings, register them with the sync hooks using schema options:
|
||||
|
||||
```tsx
|
||||
import { useSyncDemo } from '@tldraw/sync'
|
||||
import { Tldraw } from 'tldraw'
|
||||
import { MyCustomShapeUtil } from './MyCustomShape'
|
||||
import { MyCustomBindingUtil } from './MyCustomBinding'
|
||||
|
||||
const customShapes = [MyCustomShapeUtil]
|
||||
const customBindings = [MyCustomBindingUtil]
|
||||
|
||||
export default function App({ roomId }: { roomId: string }) {
|
||||
const store = useSyncDemo({
|
||||
roomId,
|
||||
shapeUtils: customShapes,
|
||||
bindingUtils: customBindings,
|
||||
})
|
||||
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0 }}>
|
||||
<Tldraw store={store} shapeUtils={customShapes} bindingUtils={customBindings} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Pass the shape and binding utilities to both the sync hook (for schema registration) and the Tldraw component (for rendering). If they don't match, shapes may fail to sync or render correctly.
|
||||
|
||||
`useSyncDemo` adds tldraw's default utilities for you. `useSync` doesn't: when you pass `shapeUtils` or `bindingUtils` to `useSync`, include the defaults yourself, for example `shapeUtils: [...defaultShapeUtils, MyCustomShapeUtil]`.
|
||||
|
||||
## Comments
|
||||
|
||||
Comment threads sync the same way, through record types you opt into with the `records` option:
|
||||
|
||||
```tsx
|
||||
import { useSync } from '@tldraw/sync'
|
||||
import { commentSchemaRecords } from 'tldraw'
|
||||
|
||||
const store = useSync({
|
||||
uri: `wss://your-server.com/sync/${roomId}`,
|
||||
assets: myAssetStore,
|
||||
records: commentSchemaRecords,
|
||||
})
|
||||
```
|
||||
|
||||
Your server's schema needs the same registration. On the server you can also serve comments through the room's object-store lane, which gates them by their own per-session permission. A viewer who can't edit the document can still comment. See [Commenting](/sdk-features/commenting) for the tool, the canvas layer, and the server setup.
|
||||
|
||||
## Building custom sync
|
||||
|
||||
The `@tldraw/sync` package handles connection management, reconnection, conflict resolution, and protocol versioning. For most applications, it's the right choice. You might need custom sync when integrating with existing infrastructure, using a different transport (like WebRTC), or implementing specialized conflict resolution.
|
||||
|
||||
tldraw's store provides the primitives you need to build your own sync layer.
|
||||
|
||||
### Listening to changes
|
||||
|
||||
The store's [Store#listen](?) method notifies you when records change:
|
||||
|
||||
```tsx
|
||||
const unsubscribe = editor.store.listen(
|
||||
(entry) => {
|
||||
// entry.changes contains all modifications
|
||||
// entry.source is 'user' (local) or 'remote'
|
||||
console.log('Changes:', entry.changes)
|
||||
},
|
||||
{ source: 'user', scope: 'document' }
|
||||
)
|
||||
|
||||
// Later: stop listening
|
||||
unsubscribe()
|
||||
```
|
||||
|
||||
Filter options narrow what changes you receive:
|
||||
|
||||
| Filter | Values | Description |
|
||||
| -------- | ------------------------------------------------ | ------------------------- |
|
||||
| `source` | `'user'`, `'remote'`, `'all'` | Who made the change |
|
||||
| `scope` | `'document'`, `'session'`, `'presence'`, `'all'` | What type of data changed |
|
||||
|
||||
For sync, you typically want `source: 'user'` (only local changes) and `scope: 'document'` (only persistent data).
|
||||
|
||||
### Change structure
|
||||
|
||||
Changes arrive as a [RecordsDiff](?) object with three categories:
|
||||
|
||||
```tsx
|
||||
interface RecordsDiff<R> {
|
||||
added: Record<string, R> // New records
|
||||
updated: Record<string, [from: R, to: R]> // Changed records (before/after)
|
||||
removed: Record<string, R> // Deleted records
|
||||
}
|
||||
```
|
||||
|
||||
Each entry is keyed by record ID. For updates, you get both the previous and current state, which is useful for conflict detection or generating patches.
|
||||
|
||||
### Applying remote changes
|
||||
|
||||
When you receive changes from other clients, wrap them in [Store#mergeRemoteChanges](?):
|
||||
|
||||
```tsx
|
||||
function applyRemoteChanges(records: TLRecord[], deletedIds: TLRecord['id'][]) {
|
||||
editor.store.mergeRemoteChanges(() => {
|
||||
if (records.length > 0) {
|
||||
editor.store.put(records)
|
||||
}
|
||||
if (deletedIds.length > 0) {
|
||||
editor.store.remove(deletedIds)
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
This marks the changes as `'remote'` source, so your own listener won't echo them back to the server. It also batches the operations into a single transaction, and remote changes never enter the undo/redo history.
|
||||
|
||||
### Snapshots and serialization
|
||||
|
||||
For initial sync or persistence, serialize the entire store:
|
||||
|
||||
```tsx
|
||||
// Get all document records as a plain object
|
||||
const data = editor.store.serialize('document')
|
||||
// Returns: { 'shape:abc': {...}, 'page:xyz': {...}, ... }
|
||||
|
||||
// Get a snapshot with schema information (recommended for persistence)
|
||||
const snapshot = editor.store.getStoreSnapshot('document')
|
||||
// Returns: { store: {...}, schema: {...} }
|
||||
|
||||
// Restore from snapshot (handles migrations automatically)
|
||||
editor.store.loadStoreSnapshot(snapshot)
|
||||
```
|
||||
|
||||
The snapshot format includes schema information, so tldraw can automatically migrate old data when your schema evolves.
|
||||
|
||||
### Presence records
|
||||
|
||||
User presence (cursors, selections, viewports) uses [InstancePresenceRecordType](?) records. Only `userId`, `userName`, and `currentPageId` are required; everything else has a default:
|
||||
|
||||
```tsx
|
||||
import { createUserId, InstancePresenceRecordType } from 'tldraw'
|
||||
|
||||
// Create a presence record for a remote user, keyed by that client's session id
|
||||
const presence = InstancePresenceRecordType.create({
|
||||
id: InstancePresenceRecordType.createId(remoteSessionId),
|
||||
userId: createUserId('user-123'),
|
||||
userName: 'Alice',
|
||||
color: '#ff6b6b',
|
||||
currentPageId: editor.getCurrentPageId(),
|
||||
cursor: { x: 100, y: 200, type: 'default', rotation: 0 },
|
||||
lastActivityTimestamp: Date.now(),
|
||||
})
|
||||
|
||||
// Add to store
|
||||
editor.store.put([presence])
|
||||
|
||||
// Update cursor position
|
||||
editor.store.update(presence.id, (record) => ({
|
||||
...record,
|
||||
cursor: { x: 150, y: 250, type: 'default', rotation: 0 },
|
||||
}))
|
||||
|
||||
// Remove when user disconnects
|
||||
editor.store.remove([presence.id])
|
||||
```
|
||||
|
||||
Listen for presence changes separately from document changes:
|
||||
|
||||
```tsx
|
||||
editor.store.listen(
|
||||
(entry) => {
|
||||
// Broadcast presence to other clients
|
||||
sendPresence(entry.changes)
|
||||
},
|
||||
{ source: 'user', scope: 'presence' }
|
||||
)
|
||||
```
|
||||
|
||||
### Example: simple broadcast sync
|
||||
|
||||
Here's a minimal example using a WebSocket for broadcast sync (no conflict resolution):
|
||||
|
||||
```tsx
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Tldraw, createTLStore, defaultBindingUtils, defaultShapeUtils } from 'tldraw'
|
||||
|
||||
function App() {
|
||||
const [store] = useState(() =>
|
||||
createTLStore({ shapeUtils: defaultShapeUtils, bindingUtils: defaultBindingUtils })
|
||||
)
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const ws = new WebSocket('wss://your-server.com/room/123')
|
||||
wsRef.current = ws
|
||||
|
||||
// Send local changes to server
|
||||
const unsubscribe = store.listen(
|
||||
(entry) => {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'changes',
|
||||
added: Object.values(entry.changes.added),
|
||||
updated: Object.values(entry.changes.updated).map(([, to]) => to),
|
||||
removed: Object.keys(entry.changes.removed),
|
||||
})
|
||||
)
|
||||
},
|
||||
{ source: 'user', scope: 'document' }
|
||||
)
|
||||
|
||||
// Apply remote changes
|
||||
ws.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data)
|
||||
if (msg.type === 'changes') {
|
||||
store.mergeRemoteChanges(() => {
|
||||
if (msg.added.length || msg.updated.length) {
|
||||
store.put([...msg.added, ...msg.updated])
|
||||
}
|
||||
if (msg.removed.length) {
|
||||
store.remove(msg.removed)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
unsubscribe()
|
||||
ws.close()
|
||||
}
|
||||
}, [store])
|
||||
|
||||
return <Tldraw store={store} />
|
||||
}
|
||||
```
|
||||
|
||||
This example omits initial state sync, reconnection handling, and conflict resolution. For production, start with `@tldraw/sync` and customize it, or study its implementation for how it handles these cases.
|
||||
|
||||
## Related examples
|
||||
|
||||
- [Multiplayer sync](/examples/collaboration/sync-demo) — Basic multiplayer setup with the demo server
|
||||
- [Custom user](/examples/users/sync-custom-user) — Setting custom user identity for multiplayer
|
||||
- [Custom presence](/examples/collaboration/sync-custom-presence) — Customizing presence data sent to collaborators
|
||||
- [Custom shapes](/examples/collaboration/sync-custom-shape) — Syncing custom shapes with multiplayer
|
||||
- [User presence](/examples/collaboration/user-presence) — Manually creating and updating presence records
|
||||
715
apps/docs/content/sdk-features/commenting.mdx
Normal file
715
apps/docs/content/sdk-features/commenting.mdx
Normal file
@@ -0,0 +1,715 @@
|
||||
---
|
||||
title: Commenting
|
||||
created_at: 7/27/2026
|
||||
updated_at: 7/27/2026
|
||||
keywords:
|
||||
- comment
|
||||
- comments
|
||||
- commenting
|
||||
- thread
|
||||
- annotation
|
||||
- review
|
||||
- feedback
|
||||
- mention
|
||||
- reaction
|
||||
- collaboration
|
||||
status: published
|
||||
date: 7/27/2026
|
||||
---
|
||||
|
||||
In tldraw, a comment is a message pinned to a place on the canvas. Comments group into threads, one conversation per pin. Every comment records who wrote it and comments can mention other people.
|
||||
|
||||
The `@tldraw/commenting` package works at two levels. `CanvasComments` is a comments layer you render in front of the canvas: it draws the pins, opens the threads, and reads and writes the records itself. The parts it's built from are exported too, so you can replace any of them or assemble your own layer from the pieces.
|
||||
|
||||
Commenting is a licensed feature. It runs in development without a key. In production it needs a tldraw license that includes commenting.
|
||||
|
||||
## Setup
|
||||
|
||||
There are three pieces: register the comment record types with your store, register the comment tool, and render the comments layer.
|
||||
|
||||
```tsx
|
||||
import {
|
||||
CanvasComments,
|
||||
CommentAuthor,
|
||||
commentTools,
|
||||
commentToolOverrides,
|
||||
} from '@tldraw/commenting'
|
||||
import { useMemo } from 'react'
|
||||
import { commentSchemaRecords, createTLSchema, createTLStore, TLComponents, Tldraw } from 'tldraw'
|
||||
import '@tldraw/commenting/commenting.css'
|
||||
import 'tldraw/tldraw.css'
|
||||
|
||||
// In production, a tldraw license key that includes commenting. Not needed in development.
|
||||
const YOUR_LICENSE_KEY = undefined
|
||||
|
||||
// Your app's user directory. Any id you can't resolve renders as an anonymous user.
|
||||
const AUTHORS: Record<string, CommentAuthor> = {
|
||||
me: { name: 'You', color: '#EC5E41' },
|
||||
ada: { name: 'Ada Lovelace', color: '#0E9F6E' },
|
||||
}
|
||||
const resolveAuthor = (id: string) => AUTHORS[id]
|
||||
|
||||
const components: TLComponents = {
|
||||
InFrontOfTheCanvas: () => <CanvasComments currentUserId="me" resolveAuthor={resolveAuthor} />,
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const store = useMemo(
|
||||
() => createTLStore({ schema: createTLSchema({ records: commentSchemaRecords }) }),
|
||||
[]
|
||||
)
|
||||
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0 }}>
|
||||
<Tldraw
|
||||
licenseKey={YOUR_LICENSE_KEY}
|
||||
store={store}
|
||||
tools={commentTools}
|
||||
overrides={[commentToolOverrides]}
|
||||
components={components}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Comments are records in the editor's store, exactly like shapes. [commentSchemaRecords](?) registers the `comment-thread`, `comment`, and `comment-reaction` types, which aren't in the default schema. Once they're registered, comments persist and sync however your document already does: add a `persistenceKey` or a sync backend and they come along with it.
|
||||
|
||||
`CanvasComments` needs two things, and both are about identity. `currentUserId` is the id of the person commenting, or `null` for a viewer who can read comments but not post them. `resolveAuthor` maps an author id to a `CommentAuthor`: a `name`, an optional `color` for their avatar and pin, and an optional `image`. Return `undefined` for an id you can't resolve and the layer falls back to an anonymous user.
|
||||
|
||||
These two props, plus the read-status and mention callbacks below, make up the `CommentingContext`. The sidebar takes the same context, so a host mounting both surfaces builds one object and spreads it into each.
|
||||
|
||||
Mount the layer through the `InFrontOfTheCanvas` component slot so it sits above the canvas but below the UI, and import `commenting.css` alongside `tldraw.css`.
|
||||
|
||||
<Callout type="warning">
|
||||
Without a license key that includes commenting, every commenting component renders nothing in
|
||||
production. The feature is fully enabled in development, so a missing key shows up as a blank
|
||||
canvas after you deploy. See [License key](/sdk-features/license-key).
|
||||
</Callout>
|
||||
|
||||
## Placing comments
|
||||
|
||||
`commentToolOverrides` puts the comment tool in Quick Actions with the `C` shortcut. With the tool active, clicking empty canvas starts a thread anchored to that point, and clicking a shape anchors a comment to the shape. You can also turn on region comments, where dragging the tool out covers an area. Either way the click only opens a composer: nothing is written to the store until the comment is posted.
|
||||
|
||||
Once a comment thread exists, its pin is the handle for everything else.
|
||||
|
||||
| Input | Result |
|
||||
| ----------- | -------------------------------------------------------------------- |
|
||||
| Click a pin | Opens the thread, with its replies and a reply composer. |
|
||||
| Drag a pin | Re-anchors the thread. Drop it on a shape to attach it to the shape. |
|
||||
| `Escape` | Closes the open thread. |
|
||||
| `Shift+C` | Hides and shows the pins on the canvas. |
|
||||
|
||||
In the built-in layer, resolve and delete-thread sit in the thread's header, and the edit and delete controls for a single comment appear only on the author's own comments by default. [`canModifyComment`](#who-can-edit-and-delete) changes who gets those controls, and [Syncing comments](#syncing-comments) covers enforcing the same rules on the server.
|
||||
|
||||
To put the show and hide toggle in a menu of your own, use `CommentsMenuItem`. It's a checkbox item wired to the same state as the `Shift+C` shortcut.
|
||||
|
||||
```tsx
|
||||
import { CommentsMenuItem } from '@tldraw/commenting'
|
||||
import { DefaultMainMenu, TldrawUiMenuGroup } from 'tldraw'
|
||||
|
||||
function MainMenu() {
|
||||
return (
|
||||
<DefaultMainMenu>
|
||||
<TldrawUiMenuGroup id="comments">
|
||||
<CommentsMenuItem />
|
||||
</TldrawUiMenuGroup>
|
||||
</DefaultMainMenu>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Configuring the tool
|
||||
|
||||
Commenting options live on the comment tool. Set them with `CommentTool.configure()`, which mirrors `ShapeUtil.configure` and returns a configured subclass to register:
|
||||
|
||||
```tsx
|
||||
import { CommentTool, commentToolOverrides } from '@tldraw/commenting'
|
||||
import { Tldraw } from 'tldraw'
|
||||
|
||||
const tools = [CommentTool.configure({ enableRegions: true })]
|
||||
|
||||
function App() {
|
||||
return <Tldraw tools={tools} overrides={[commentToolOverrides]} components={components} />
|
||||
}
|
||||
```
|
||||
|
||||
Options are fixed once the tool is registered, so this is static configuration only. Live values, like the current user and the author resolver, are the `CommentingContext` instead, passed as props to each surface. Calls to `configure` can be chained, and each one layers over the last.
|
||||
|
||||
The sections below introduce the relevant comment options, and [All options](#all-options) lists every one together.
|
||||
|
||||
## Anchors
|
||||
|
||||
A thread's `anchor` says where it lives. It's a discriminated union, so new anchor kinds can arrive without breaking existing threads.
|
||||
|
||||
| Anchor | Description |
|
||||
| -------- | ------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `point` | A fixed page point. What a click on empty canvas produces. |
|
||||
| `shape` | A shape, with `x`/`y` as a normalized (0–1) offset in its bounds. The pin keeps its spot as the shape moves and resizes. |
|
||||
| `region` | A rectangular area of the page, with the pin on one corner. |
|
||||
| `page` | The page itself, with no spatial anchor. These threads have no pin and surface in a list instead. |
|
||||
|
||||
Shape-anchored threads outlive their shape. Delete the shape and the thread converts to a `point` anchor where its pin last sat, so the conversation doesn't vanish with the thing it was about. Bring the shape back, whether by undoing the delete or by a page move that re-creates it, and the shape anchor is restored. The exception is a pin someone moved by hand in the meantime: a manual placement wins over the restore.
|
||||
|
||||
Threads follow their shape across pages too. Move the shape to another page and the thread's `pageId`, along with the denormalized `pageId` on each of its comments, updates to match. Comments do not move when you cut or copy a shape.
|
||||
|
||||
### Shape anchor precision
|
||||
|
||||
A comment that lands on a shape anchors in one of two ways. A **precise** anchor pins to the exact clicked spot. An **imprecise** one addresses the shape as a whole and renders its pin at a spot you choose, the `impreciseShapeAnchor`, which is the shape's top-right corner by default. Both track the shape as it moves and resizes, and both store the clicked `x`/`y`, so precision governs rendering rather than data.
|
||||
|
||||
Shape comments are precise by default. `shouldBePrecise` makes the call, and it receives the gesture: the target shape, the release point, and whether Alt was held.
|
||||
|
||||
```tsx
|
||||
// Precise on notes, shape-level everywhere else
|
||||
const tools = [
|
||||
CommentTool.configure({
|
||||
shouldBePrecise: (editor, { shapeId }) => editor.getShape(shapeId)?.type === 'note',
|
||||
}),
|
||||
]
|
||||
```
|
||||
|
||||
Return `() => false` for shape-level anchoring throughout, or `(editor, { altKey }) => altKey` to make precision a per-placement choice the user holds Alt for. The predicate governs new placements only. Anchors already stored render the way they were made.
|
||||
|
||||
## Comments and undo
|
||||
|
||||
Comment writes are not undoable by default. The `history` option governs all of them, including posting, replying, editing and resolving, and it defaults to `'ignore'`. It covers your own writes too: a record you write with `putCommentRecords` lands on the undo stack, or doesn't, exactly like one the built-in UI writes.
|
||||
|
||||
In a shared document an undoable delete resurrects a thread a collaborator already removed, and an undoable resolve reverts their newer state. `'record'` is safe in a single-player app, or when the comment store isn't synced.
|
||||
|
||||
Deleting is the one write `history` doesn't reach. It's never undoable, because of how deletes work: see [Deleting is a soft delete](#deleting-is-a-soft-delete).
|
||||
|
||||
Pin drags are the exception worth configuring separately. Re-anchoring a comment is a spatial edit that may reasonably undo alongside the shape move that prompted it, so `dragHistory` overrides `history` for drags alone:
|
||||
|
||||
```tsx
|
||||
CommentTool.configure({ dragHistory: 'record' })
|
||||
```
|
||||
|
||||
The [comments and undo example](/examples/collaboration/comment-history) lets you feel the difference: post a comment, press undo, and watch whether the thread count moves.
|
||||
|
||||
## Who can comment
|
||||
|
||||
Left unset, `canComment` allows participation whenever `currentUserId` is set. Participation covers composing threads and replies, editing and deleting your own comments, resolving threads, and moving pins. Pass a callback to decide for yourself:
|
||||
|
||||
```tsx
|
||||
CommentTool.configure({
|
||||
canComment: ({ currentUserId }) => currentUserId !== null && getRole() !== 'viewer',
|
||||
components: {
|
||||
ComposerFallback: ({ context }) => (context === 'thread' ? <SignInPrompt /> : null),
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
When `canComment` returns false, composers give way to the `ComposerFallback` slot and the action affordances hide. That slot's `context` says which surface is asking: the bottom of an open thread (`'thread'`), or the placement popover the tool opens (`'pending'`). Leave the slot unset and those surfaces render nothing.
|
||||
|
||||
`canComment` is read during render through `useCanComment`, so a callback that reads signals re-evaluates when they change.
|
||||
|
||||
## Who can edit and delete
|
||||
|
||||
`canComment` is about the viewer; `canModifyComment` is about the viewer and one particular record. It's asked for three writes: editing a comment, deleting a comment, and deleting a thread. Where it returns false, that affordance isn't rendered.
|
||||
|
||||
By default, only a comment's author can edit or delete it, and only a thread's creator can delete the thread. Pass a callback to widen that, composing with `defaultCanModifyComment` so the owner keeps what they already had:
|
||||
|
||||
```tsx
|
||||
import { CommentTool, defaultCanModifyComment } from '@tldraw/commenting'
|
||||
|
||||
CommentTool.configure({
|
||||
canModifyComment: (ctx) =>
|
||||
// Moderators may remove anything. Editing stays the author's, whoever you are.
|
||||
(ctx.action !== 'edit-comment' && isModerator(ctx.currentUserId)) ||
|
||||
defaultCanModifyComment(ctx),
|
||||
})
|
||||
```
|
||||
|
||||
The `ctx` carries the editor, the viewer's `currentUserId`, and the write itself as a discriminated union: `{ action: 'edit-comment' | 'delete-comment', comment }` or `{ action: 'delete-thread', thread }`. Narrowing works too — return false to close edits after an hour, or on a resolved thread.
|
||||
|
||||
Resolving, reopening, reacting, and moving a pin aren't asked about: none of them is anyone's in particular, so `canComment` is the only gate on them. `canModifyComment` is checked after `canComment`, so a viewer who may not participate gets no action affordances whatever it returns. Like `canComment`, it's read during render (through `useCanModifyComment`), so a callback that reads signals re-evaluates when they change.
|
||||
|
||||
Enforce the same rule on the server: `createCommentAuthorizers` takes a `canModifyComment` of its own, and that one is authoritative. See [Syncing comments](#syncing-comments).
|
||||
|
||||
<Callout type="warning">
|
||||
`canComment` and `canModifyComment` hide UI. They don't enforce anything. Comment records carry a
|
||||
client-supplied `createdBy` and `authorId`, and a client can write whatever your sync server
|
||||
accepts. Enforce permissions in your server's record authorization, and verify author ids against
|
||||
the session's authenticated identity. See [Syncing comments](#syncing-comments).
|
||||
</Callout>
|
||||
|
||||
## Mentions
|
||||
|
||||
Composers support `@`-mentions. Supply the roster with `getMentionSuggestions`, which can be synchronous or async:
|
||||
|
||||
```tsx
|
||||
import { CanvasComments, filterMentionMembers, MentionMember } from '@tldraw/commenting'
|
||||
|
||||
const MEMBERS: MentionMember[] = [
|
||||
{ id: 'me', name: 'You', color: '#EC5E41', you: true },
|
||||
{ id: 'ada', name: 'Ada Lovelace', color: '#0E9F6E' },
|
||||
{ id: 'grace', name: 'Grace Hopper', color: '#4465E9', secondary: 'grace@example.com' },
|
||||
]
|
||||
|
||||
function Comments() {
|
||||
return (
|
||||
<CanvasComments
|
||||
currentUserId="me"
|
||||
resolveAuthor={resolveAuthor}
|
||||
getMentionSuggestions={(query) => filterMentionMembers(MEMBERS, query)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
A `MentionMember` is a `CommentAuthor` plus an `id`, an optional `secondary` line for the picker, and `you` to mark the current user. `filterMentionMembers` does the matching; supply your own filter to query a server instead. To change how a row looks, pass `renderMentionSuggestion`.
|
||||
|
||||
A mention is a node in the body rather than text in it, so you can't find one by searching the string. To detect a mention, look for `{ type: 'mention', attrs: { id } }` in the body's `content` tree.
|
||||
|
||||
The mention components come from `@tldraw/mentions` and are re-exported here. That package also powers mentions in shape rich text. See [Rich text](/sdk-features/rich-text).
|
||||
|
||||
## Reactions
|
||||
|
||||
Hover a comment and open the picker to react with an emoji. A pill appears with a live count, and hovering it names who reacted.
|
||||
|
||||
Each reaction is its own `comment-reaction` record, one per (comment, user, emoji), rather than a field on the comment. Two people reacting at once therefore write different records and neither can clobber the other. The record id is derived from the triple, so re-picking an emoji toggles it. Registering `commentSchemaRecords` registers the reaction type along with the rest.
|
||||
|
||||
Reactions are multi-select by default, where each emoji toggles independently. Set `allowMultipleReactions: false` for single-select, where a new emoji replaces your existing one.
|
||||
|
||||
The palette is pluggable. A reaction's emoji is treated as an opaque token: the layer stores it, syncs it, and hands it to a renderer, and never assumes it's a glyph. So you can swap in tokens of your own.
|
||||
|
||||
```tsx
|
||||
CommentTool.configure({
|
||||
components: {
|
||||
ReactionContent: MyTokenRenderer, // how a token is drawn
|
||||
ReactionPalette: MyPalette, // what the add-reaction button opens
|
||||
},
|
||||
isAllowedReaction: (token) => isMyToken(token) || isAllowedReactionEmoji(token),
|
||||
})
|
||||
```
|
||||
|
||||
`isAllowedReaction` is enforced client-side. If arbitrary tokens would be a problem for you, validate them on your server too.
|
||||
|
||||
## Region comments
|
||||
|
||||
A region thread covers a rectangular area rather than a point. Regions are off by default, so the tool stays click-only until you turn them on:
|
||||
|
||||
```tsx
|
||||
CommentTool.configure({ enableRegions: true })
|
||||
```
|
||||
|
||||
That's the whole configuration. A region reveals its dashed box and resize handles while the pointer is inside it, moves by its pin, and resizes from its corners. The pin sits on whichever corner the creating drag was released on, which the anchor remembers.
|
||||
|
||||
## The sidebar
|
||||
|
||||
`CanvasCommentsSidebar` lists comment threads in a panel beside the canvas. Clicking a row brings that thread's pin into view and opens it.
|
||||
|
||||
Its open state is a signal you drive, so the toggle lives wherever your app wants it. `useCommentsSidebarOpen` reads it and `toggleCommentsSidebar` flips it; the underlying `commentsSidebarOpen` atom is exported too, for code that has an editor but no React context:
|
||||
|
||||
```tsx
|
||||
import {
|
||||
CanvasComments,
|
||||
CanvasCommentsSidebar,
|
||||
CommentingContext,
|
||||
toggleCommentsSidebar,
|
||||
useCommentsSidebarOpen,
|
||||
} from '@tldraw/commenting'
|
||||
import { TldrawUiButton, TldrawUiButtonLabel, useEditor } from 'tldraw'
|
||||
|
||||
function SidebarToggle() {
|
||||
const editor = useEditor()
|
||||
const open = useCommentsSidebarOpen()
|
||||
return (
|
||||
<TldrawUiButton type="normal" onClick={() => toggleCommentsSidebar(editor)}>
|
||||
<TldrawUiButtonLabel>{open ? 'Hide comments' : 'Comments'}</TldrawUiButtonLabel>
|
||||
</TldrawUiButton>
|
||||
)
|
||||
}
|
||||
|
||||
// Both surfaces read the same context, so build it once and spread it into each.
|
||||
const commenting: CommentingContext = { currentUserId: 'me', resolveAuthor }
|
||||
|
||||
const components: TLComponents = {
|
||||
InFrontOfTheCanvas: () => (
|
||||
<>
|
||||
<CanvasComments {...commenting} />
|
||||
<CanvasCommentsSidebar {...commenting} />
|
||||
</>
|
||||
),
|
||||
SharePanel: SidebarToggle,
|
||||
}
|
||||
```
|
||||
|
||||
The sidebar's filters cover resolved threads, only comments you made, only unread comments, and only the current page. They're held per editor, so a user's choices survive the panel closing. Pass `header` and `empty` to replace the chrome around the list.
|
||||
|
||||
## Unread state
|
||||
|
||||
The layer doesn't track who has read what. That data lives in your app. Supply it as two callbacks. `isCommentUnread` reports whether a comment is unread, and `onCommentsRead` fires once per report with every unread comment the user actually sees in an open thread, so you can record read receipts in one write.
|
||||
|
||||
```tsx
|
||||
<CanvasComments
|
||||
currentUserId="me"
|
||||
resolveAuthor={resolveAuthor}
|
||||
isCommentUnread={(commentId) => !readReceipts.has(commentId)}
|
||||
onCommentsRead={(commentIds) => markCommentsRead(commentIds)}
|
||||
onPostComment={(comment) => notifyMentionedUsers(comment)}
|
||||
/>
|
||||
```
|
||||
|
||||
Unread state drives the sidebar's unread filter and the receipts reported through `onCommentsRead`. Without `isCommentUnread`, the filter is hidden and nothing is reported. `onPostComment` fires when this user posts a comment through one of the built-in composers, whether a new thread or a reply. This is where notifications and mention emails belong. It does not fire for comments arriving over sync, so the sender is the one who notifies. See the [comment notifications example](/examples/collaboration/comment-notifications).
|
||||
|
||||
## Custom components
|
||||
|
||||
Every visible piece of the layer is a slot. Set them through the `components` option, and leave a slot unset to keep its default.
|
||||
|
||||
| Slot | Replaces |
|
||||
| ------------------ | ---------------------------------------------------------------------- |
|
||||
| `CommentBody` | A comment's body, normally the rich-text renderer. |
|
||||
| `PinContent` | A pin's inner content, normally the author's initial. |
|
||||
| `ThreadPreview` | A sidebar row's preview, normally the body as plain text. |
|
||||
| `ThreadRow` | A whole sidebar row, normally `CommentListItem`. |
|
||||
| `ThreadActions` | Adds controls to an open thread's header. Additive, not a replacement. |
|
||||
| `ComposerFallback` | What shows where a composer would sit when the viewer can't comment. |
|
||||
| `ReactionContent` | How a reaction token is drawn. |
|
||||
| `ReactionPalette` | What the add-reaction button opens. |
|
||||
| `ReactionTooltip` | The list of who reacted, shown on a reaction pill. |
|
||||
|
||||
```tsx
|
||||
import { CommentTool, richTextToPlaintext } from '@tldraw/commenting'
|
||||
import { TLComment } from 'tldraw'
|
||||
|
||||
function PriorityBody({ comment }: { comment: TLComment }) {
|
||||
const urgent = comment.meta.priority === 'urgent'
|
||||
return (
|
||||
<div className={urgent ? 'urgent-comment' : 'comment'}>{richTextToPlaintext(comment.body)}</div>
|
||||
)
|
||||
}
|
||||
|
||||
const tools = [CommentTool.configure({ components: { CommentBody: PriorityBody } })]
|
||||
```
|
||||
|
||||
Every comment record type carries a `meta` field the layer never reads. Use it for priorities, categories, external ticket ids, or anything else your app tracks alongside a comment.
|
||||
|
||||
`ThreadRow` and `ThreadActions` are the two slots that add to a surface rather than replace a piece of it, so they get the records behind what's on screen.
|
||||
|
||||
`ThreadRow` receives the summarized row along with the thread record, and the default row is exported. A row that only adds something can spread the props into `CommentListItem` rather than start over:
|
||||
|
||||
```tsx
|
||||
import { CommentListItem, CommentListItemRenderProps, CommentTool } from '@tldraw/commenting'
|
||||
import { TLCommentThread } from 'tldraw'
|
||||
|
||||
function StatusRow({ thread, ...row }: CommentListItemRenderProps & { thread: TLCommentThread }) {
|
||||
return (
|
||||
<div className="status-row">
|
||||
<CommentListItem {...row} />
|
||||
{thread.meta.status === 'blocked' && <span className="blocked">Blocked</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const tools = [CommentTool.configure({ components: { ThreadRow: StatusRow } })]
|
||||
```
|
||||
|
||||
`ThreadActions` adds controls to an open thread's header, ahead of the built-in resolve and dismiss buttons. This is where host verbs go: assign a thread, link it to a ticket, mark it as a to-do. It adds alongside the built-in actions rather than replacing them, so a thread never loses the ability to be resolved.
|
||||
|
||||
```tsx
|
||||
import { CommentTool } from '@tldraw/commenting'
|
||||
import { TLCommentThread } from 'tldraw'
|
||||
|
||||
function AssignAction({ thread }: { thread: TLCommentThread }) {
|
||||
return (
|
||||
<button type="button" className="tlui-cmt-thread__action" onClick={() => assign(thread.id)}>
|
||||
Assign
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
const tools = [CommentTool.configure({ components: { ThreadActions: AssignAction } })]
|
||||
```
|
||||
|
||||
A link affordance doesn't need this slot: see [Linking to a thread](#linking-to-a-thread).
|
||||
|
||||
## Working with comment records
|
||||
|
||||
Comment records aren't part of the `TLRecord` union, so `editor.store` doesn't know their types statically. These helpers own that reinterpretation and keep your call sites typed.
|
||||
|
||||
| Helper | Description |
|
||||
| ----------------------- | ------------------------------------------------------------------------------------ |
|
||||
| `getLiveCommentThreads` | The threads that render. |
|
||||
| `getLiveComments` | The comments that render. |
|
||||
| `getCommentThreads` | Every thread in the store, including deleted ones. |
|
||||
| `getComments` | Every comment in the store, including deleted ones. |
|
||||
| `getCommentRecord` | One record by id, or `undefined`. |
|
||||
| `putCommentRecords` | Write threads and comments. |
|
||||
| `removeCommentRecords` | Remove them by id. Rarely what you want — see [Writing comments](#writing-comments). |
|
||||
|
||||
Prefer the live reads. Deleting a comment doesn't remove its record: it flags it and lets the server prune it, as [Writing comments](#writing-comments) explains. So the unfiltered reads include records that nothing renders. `getLiveCommentThreads` also drops threads whose comments have all gone, which have no surface left.
|
||||
|
||||
Those reads are non-reactive. In React use the hooks instead, which read the live set: `useCommentThreads`, `useComments`, and `useThreadComments` for one thread's replies, oldest first.
|
||||
|
||||
```tsx
|
||||
import { useCommentThreads } from '@tldraw/commenting'
|
||||
import { useEditor } from 'tldraw'
|
||||
|
||||
function OpenThreadCount() {
|
||||
const editor = useEditor()
|
||||
const threads = useCommentThreads(editor)
|
||||
const open = threads.filter((thread) => !thread.resolved)
|
||||
return <div>{open.length} open threads</div>
|
||||
}
|
||||
```
|
||||
|
||||
## Writing comments
|
||||
|
||||
To post a thread yourself, whether you're seeding a document with review notes or importing comments from another system, build the records and write them:
|
||||
|
||||
```tsx
|
||||
import { putCommentRecords } from '@tldraw/commenting'
|
||||
import { createComment, createCommentThread, toRichText } from 'tldraw'
|
||||
|
||||
const thread = createCommentThread({
|
||||
pageId: editor.getCurrentPageId(),
|
||||
anchor: { type: 'point', x: 120, y: 240 },
|
||||
createdBy: 'ada',
|
||||
})
|
||||
|
||||
const comment = createComment({
|
||||
threadId: thread.id,
|
||||
pageId: thread.pageId,
|
||||
authorId: 'ada',
|
||||
body: toRichText('Can we make this arrow dashed?'),
|
||||
})
|
||||
|
||||
putCommentRecords(editor, [thread, comment])
|
||||
```
|
||||
|
||||
The other writes each have a rule attached (a timestamp to stamp, or the delete protocol below), so they come as functions. The built-in thread view calls exactly these, so a UI of your own behaves like the one in the box.
|
||||
|
||||
| Helper | Description |
|
||||
| ------------------------------- | ------------------------------------------------------ |
|
||||
| `editComment` | Replace a comment's body and mark it edited. |
|
||||
| `resolveThread`, `reopenThread` | Resolve a thread, stamping who and when, or reopen it. |
|
||||
| `deleteComment` | Delete a comment. |
|
||||
| `deleteThread` | Delete a thread and its whole conversation. |
|
||||
|
||||
```tsx
|
||||
import { deleteComment, resolveThread } from '@tldraw/commenting'
|
||||
|
||||
resolveThread(editor, thread, currentUserId)
|
||||
deleteComment(editor, comment)
|
||||
```
|
||||
|
||||
The record you pass says which comment or thread to act on; the change itself lands on the version currently in the store. So a record you took a copy of earlier is safe to pass. It won't put back a field that has moved since, such as a thread's anchor after its pinned shape moved, or a comment's body after its author edited it elsewhere. And it won't re-create a record that has already been deleted, which a plain `putCommentRecords` of a stale copy would. If the record is gone, the call does nothing.
|
||||
|
||||
### Deleting is a soft delete
|
||||
|
||||
`deleteComment` and `deleteThread` don't remove records. They set an `isDeleted` flag and leave the pruning to the server, which then removes the thread, its comments, and their reactions.
|
||||
|
||||
That indirection is what makes deleting safe in a shared document. A reaction belongs to whoever left it, not to whoever is deleting the comment underneath it, so no client should be removing it. And a server enforcing per-record permissions gets a write it can check against the session's identity rather than a deletion it can only refuse. `removeCommentRecords` is a hard delete, and such a server rejects it. Reach for it on a local, unsynced comment store.
|
||||
|
||||
The flag is write-once server-side, so deletes are never undoable, whatever `history` says: an undo clearing the flag would be vetoed and rebased rather than bring the comment back.
|
||||
|
||||
Deleting a thread's last comment leaves the thread with nothing to render. The record stays for the server to prune, since whoever deleted the comment may not be the thread's creator.
|
||||
|
||||
## Revealing a thread
|
||||
|
||||
To open a thread from outside the canvas, whether from a notification, a shared link, or a list of your own, call `revealThread` with a thread or comment id. `CanvasComments` serves the request: it waits for the records to arrive, switches pages if it needs to, zooms in if the pin is inside a cluster, then opens the thread.
|
||||
|
||||
```tsx
|
||||
import { revealThread } from '@tldraw/commenting'
|
||||
|
||||
// e.g. from a ?comment=<id> search param
|
||||
revealThread(editor, commentId)
|
||||
```
|
||||
|
||||
An unserved request is inert, so it's safe to call before the records have synced in. `useRevealThreadPending` returns the id of a request that hasn't been served yet, which is how you notice a deep link to a comment that no longer exists. Give it a grace period first, since a request also sits there while its records are still arriving. When the grace period elapses, re-check with `getRevealThreadPending(editor)` so you don't act on a request that cleared inside it.
|
||||
|
||||
For a thread you already hold, `focusThread(editor, thread)` centers and opens it directly.
|
||||
|
||||
## Linking to a thread
|
||||
|
||||
The other half of a deep link is producing one. Give the commenting context a `getThreadHref` and every surface that can link to a thread does:
|
||||
|
||||
```tsx
|
||||
<CanvasComments
|
||||
currentUserId="me"
|
||||
resolveAuthor={resolveAuthor}
|
||||
getThreadHref={(threadId) => `/file/${fileId}?comment=${encodeURIComponent(threadId)}`}
|
||||
/>
|
||||
```
|
||||
|
||||
Sidebar rows become anchors, so ctrl/cmd-click and middle-click open a thread in a new tab, and an open thread's header menu offers **Copy link**. A relative href is resolved against the current document before it reaches the clipboard, so what gets pasted is a whole URL.
|
||||
|
||||
Without `getThreadHref`, neither appears.
|
||||
|
||||
## Pin clustering
|
||||
|
||||
Zoom out far enough and pins pile on top of each other. Clustering folds nearby pins into a count badge as you zoom out, then splits them apart as you zoom back in. Splits happen at a wider spacing than merges, so pins don't flicker at the boundary. Clicking a badge zooms to just past the point where that cluster breaks up.
|
||||
|
||||
Clustering is on by default. Turn it off with `enableClustering: false`.
|
||||
|
||||
The work is precomputed once per comment add, remove, or move, so per-frame camera changes cost a single walk over a sorted event table. See the [comment clustering example](/examples/collaboration/comment-clustering).
|
||||
|
||||
## Syncing comments
|
||||
|
||||
Comment records don't ship in the default schema, so both ends of the connection have to register them, and they have to match. A client with the types talking to a server without them will fail schema validation.
|
||||
|
||||
On the client, pass `records` to the sync hook:
|
||||
|
||||
```tsx
|
||||
const store = useSync({
|
||||
uri: `wss://your-server.com/sync/${roomId}`,
|
||||
assets: myAssetStore,
|
||||
records: commentSchemaRecords,
|
||||
})
|
||||
```
|
||||
|
||||
On the server, pass the same map to [createTLSchema](?):
|
||||
|
||||
```ts
|
||||
import { TLSocketRoom } from '@tldraw/sync-core'
|
||||
import { commentSchemaRecords, createTLSchema } from '@tldraw/tlschema'
|
||||
|
||||
const schema = createTLSchema({ records: commentSchemaRecords })
|
||||
|
||||
const room = new TLSocketRoom({
|
||||
schema,
|
||||
// Serve comments through the object-store lane rather than the document
|
||||
objectTypes: ['comment', 'comment-thread', 'comment-reaction'],
|
||||
})
|
||||
```
|
||||
|
||||
`objectTypes` moves those record types onto a separate lane. Lane records are stored apart from the document and left out of document snapshots. More usefully, they're gated by their own per-session permission rather than by `isReadonly`, so a session can be allowed to comment without being allowed to edit:
|
||||
|
||||
```ts
|
||||
room.handleSocketConnect({
|
||||
sessionId,
|
||||
socket,
|
||||
isReadonly: true, // can't touch the document
|
||||
objectAccess: 'write', // but can still comment
|
||||
})
|
||||
```
|
||||
|
||||
`objectAccess` is `'read'` or `'write'` and defaults to `'write'`. To persist the lane separately, read it with [TLSocketRoom#getCurrentObjectsSnapshot](?). To mirror comments into your own database as they commit, use the room's `onCommittedChanges` callback:
|
||||
|
||||
```ts
|
||||
const room = new TLSocketRoom({
|
||||
schema,
|
||||
objectTypes: ['comment', 'comment-thread', 'comment-reaction'],
|
||||
onCommittedChanges({ diff }) {
|
||||
// Project comment records into Postgres for notifications and search
|
||||
projectComments(diff)
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
`onCommittedChanges` only fires for client pushes. Server-initiated writes don't trigger it, including `updateStore`, `loadSnapshot`, and writing to storage directly, so anything mirroring room state has to handle those paths itself.
|
||||
|
||||
This is also where per-record permissions belong. The lane's write access is all or nothing, so rules like "only the author may edit a comment" or "only the thread's creator may delete it" are enforced as your server validates each incoming record against the session's identity. `createCommentAuthorizers` is that validation, ready-made — pass it to the room's `authorizeRecord`:
|
||||
|
||||
```ts
|
||||
import { createCommentAuthorizers } from '@tldraw/sync-collaboration'
|
||||
|
||||
const room = new TLSocketRoom({
|
||||
schema,
|
||||
objectTypes: ['comment', 'comment-thread', 'comment-reaction'],
|
||||
authorizeRecord: {
|
||||
...createCommentAuthorizers<SessionMeta>({
|
||||
getUserId: (session) => session.meta.userId,
|
||||
// Moderators may take anything down. Editing stays the author's, whoever you are.
|
||||
canModifyComment: (ctx) =>
|
||||
(ctx.action !== 'edit-comment' && isModerator(ctx.session.meta)) ||
|
||||
ctx.userId === ctx.ownerId,
|
||||
}),
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
It stamps authorship from the session so nothing can be posted or resolved in someone else's name, keeps `isDeleted` write-once, and, through `canModifyComment`, decides who may edit a comment, delete a comment, or delete a thread. Left unset, `canModifyComment` defaults to the record's owner, matching the client. It's asked after the structural rules, so widening it grants those three writes and nothing else: attribution stays immutable, a soft delete stays write-once, and clients still can't hard-delete a record.
|
||||
|
||||
This is the same question the client's [`canModifyComment`](#who-can-edit-and-delete) answers, and the server's answer is the one that counts: the client's only decides which affordances the UI offers. Widen the two together. A moderator offered a delete the server then rejects sees the comment disappear and come back, with nothing to explain it.
|
||||
|
||||
`createCommentAuthorizers` also takes a `canComment` callback, checked before every comment write. It defaults to `({ isReadonly }) => !isReadonly`, so a read-only session can't post even with `objectAccess: 'write'`. To let read-only viewers comment, as in the `handleSocketConnect` example above, pass `canComment: () => true` (or a rule based on the session's `meta`).
|
||||
|
||||
## Building your own comments UI
|
||||
|
||||
`CanvasComments` is one way to assemble the parts, and every part it uses is exported. You can rebuild it, or build something quite different, from the same pieces.
|
||||
|
||||
| Export | Description |
|
||||
| ---------------------------------------- | ----------------------------------------------------------- |
|
||||
| `CommentTool`, `commentToolOverrides` | Placement: the tool state machine and its toolbar entry. |
|
||||
| `CommentPin`, `CountBadge` | The pin marker and the clustered-count badge. |
|
||||
| `CommentThread`, `CommentCard`, `Byline` | A thread and its comments. |
|
||||
| `CommentComposer`, `SendButton` | The composer and its send control. |
|
||||
| `CommentsList`, `CommentListItem` | A list of threads and one row of it. |
|
||||
| `EmptyState`, `sortSidebarRows` | The list's empty state, and the sidebar's ordering. |
|
||||
| `Avatar`, `Mention`, `MentionList` | Author avatars and the mention picker. |
|
||||
| `Reaction`, `Reactions` | A reaction pill and a comment's row of them. |
|
||||
| `anchorPagePoint`, `shapeAnchorAt` | Anchor math: page position from an anchor, and the reverse. |
|
||||
| `editComment`, `deleteComment` | The write verbs. See [Writing comments](#writing-comments). |
|
||||
| `registerCommentAnchorLifecycle` | Keeps shape-anchored threads alive across shape deletion. |
|
||||
|
||||
The presentational components take plain props and know nothing about the editor. The canvas layer, the tool, and the hooks build on them. `CanvasComments` registers the anchor lifecycle for you, so call `registerCommentAnchorLifecycle` yourself only if you're replacing the layer wholesale.
|
||||
|
||||
## All options
|
||||
|
||||
Every option below is set with `CommentTool.configure()`. Anything you leave unset falls back to `defaultCommentingOptions`.
|
||||
|
||||
### History
|
||||
|
||||
| Option | Default | Description |
|
||||
| ------------- | ----------- | ----------------------------------------------------------------------- |
|
||||
| `history` | `'ignore'` | How comment writes interact with the undo stack. |
|
||||
| `dragHistory` | `undefined` | History mode for pin drags specifically. Unset, drags follow `history`. |
|
||||
|
||||
### Anchoring
|
||||
|
||||
| Option | Default | Description |
|
||||
| ---------------------- | -------------- | --------------------------------------------------------- |
|
||||
| `shouldBePrecise` | `() => true` | Whether a shape placement anchors precisely. |
|
||||
| `impreciseShapeAnchor` | `{x: 1, y: 0}` | Where imprecise shape pins sit within the shape's bounds. |
|
||||
|
||||
### Permissions
|
||||
|
||||
| Option | Default | Description |
|
||||
| ------------------ | ----------- | ----------------------------------------------------------------------------------- |
|
||||
| `canComment` | `undefined` | Whether the viewer may participate. Unset, allowed whenever `currentUserId` is set. |
|
||||
| `canModifyComment` | `undefined` | Whether the viewer may edit or delete a record. Unset, each is its owner's to make. |
|
||||
|
||||
### Reactions
|
||||
|
||||
| Option | Default | Description |
|
||||
| ------------------------ | ------------- | ------------------------------------------------------------ |
|
||||
| `allowMultipleReactions` | `true` | Whether a user can hold more than one reaction on a comment. |
|
||||
| `isAllowedReaction` | emoji palette | Which reaction tokens may be written. |
|
||||
|
||||
### Regions
|
||||
|
||||
| Option | Default | Description |
|
||||
| --------------- | ------- | ---------------------------------------------------------- |
|
||||
| `enableRegions` | `false` | Whether dragging the comment tool creates a region anchor. |
|
||||
|
||||
### Clustering
|
||||
|
||||
| Option | Default | Description |
|
||||
| ------------------ | ------- | ----------------------------------------------------------- |
|
||||
| `enableClustering` | `true` | Fold nearby pins into count badges as the camera zooms out. |
|
||||
|
||||
### Components
|
||||
|
||||
| Option | Default | Description |
|
||||
| ------------ | ------- | ----------------------------------------------------------------- |
|
||||
| `components` | `{}` | Component overrides. See [Custom components](#custom-components). |
|
||||
|
||||
## Related articles
|
||||
|
||||
- [Commenting](/docs/commenting) — A shorter introduction to the feature
|
||||
- [Collaboration](/sdk-features/collaboration) — Presence, sync hooks, and custom sync
|
||||
- [tldraw sync](/docs/sync) — Running a sync server
|
||||
- [Rich text](/sdk-features/rich-text) — The `TLRichText` format comment bodies use
|
||||
- [License key](/sdk-features/license-key) — Enabling licensed features in production
|
||||
|
||||
## Related examples
|
||||
|
||||
- [Commenting](/examples/collaboration/commenting) — The full flow: tool, pins, threads, mentions
|
||||
- [Commenting sidebar](/examples/collaboration/commenting-sidebar) — A thread list panel beside the canvas
|
||||
- [Comment clustering](/examples/collaboration/comment-clustering) — Merging pins into count badges as you zoom out
|
||||
- [Comment anchors](/examples/collaboration/comment-anchors) — The ways a comment can attach to the canvas
|
||||
- [Shape comment precision](/examples/collaboration/comment-shape-precision) — Precise, shape-level, and Alt-gated anchoring
|
||||
- [Comments and undo](/examples/collaboration/comment-history) — How comment writes interact with the undo stack
|
||||
- [Region comments](/examples/collaboration/comment-regions) — Commenting on an area, and tuning the interaction
|
||||
150
apps/docs/content/sdk-features/coordinates.mdx
Normal file
150
apps/docs/content/sdk-features/coordinates.mdx
Normal file
@@ -0,0 +1,150 @@
|
||||
---
|
||||
title: Coordinates
|
||||
created_at: 12/20/2025
|
||||
updated_at: 12/29/2025
|
||||
keywords:
|
||||
- coordinates
|
||||
- screen
|
||||
- page
|
||||
- viewport
|
||||
- transform
|
||||
reviewed_by: steveruizok
|
||||
status: published
|
||||
date: 12/20/2025
|
||||
readability: 9
|
||||
voice: 8
|
||||
completeness: 9
|
||||
accuracy: 9
|
||||
notes: Could use more questions-as-transitions per voice guide. Consider adding visual diagram and API reference links.
|
||||
---
|
||||
|
||||
The editor uses three coordinate systems: screen space, viewport space, and page space. When you move the mouse over the canvas, the browser gives you screen coordinates. To create a shape at that location, you need to convert those coordinates to page space.
|
||||
|
||||
The editor provides methods to convert between these coordinate systems. You'll use these when building custom tools, positioning DOM overlays, or responding to pointer events. The [Camera](/sdk-features/camera) article explains the camera values these conversions depend on.
|
||||
|
||||
| Method | Description |
|
||||
| -------------------------- | ------------------------------------------------------------------- |
|
||||
| [Editor#screenToPage](?) | Convert a screen point to page space. |
|
||||
| [Editor#pageToScreen](?) | Convert a page point to screen space. |
|
||||
| [Editor#pageToViewport](?) | Convert a page point to viewport space (relative to the container). |
|
||||
|
||||
## The three coordinate systems
|
||||
|
||||
### Screen space
|
||||
|
||||
Screen space uses pixel coordinates from the browser window's top-left corner. These are the values you get from `MouseEvent.clientX` and `MouseEvent.clientY`. Screen coordinates include any space outside the editor container, like browser chrome or other page content.
|
||||
|
||||
### Viewport space
|
||||
|
||||
Viewport space uses pixel coordinates from the editor container's top-left corner. This accounts for where the editor sits on the page. If the editor is embedded in a scrollable element or positioned away from the browser's origin, viewport coordinates differ from screen coordinates by that offset.
|
||||
|
||||
The editor tracks the container's position in [Editor#getViewportScreenBounds](?).
|
||||
|
||||
### Page space
|
||||
|
||||
Page space is the infinite canvas itself. A shape at `x: 100, y: 200` stays at those coordinates regardless of how the user pans or zooms. Top-level shapes store their position in page space; children of frames and groups store it relative to their parent. See [Parenting](/sdk-features/parenting) for the helpers that convert between shape space and page space.
|
||||
|
||||
The camera determines which part of page space is visible. When you zoom in, the same page-space region takes up more screen pixels. When you pan, different page-space coordinates come into view.
|
||||
|
||||
## Coordinate transformations
|
||||
|
||||
### Screen to page space
|
||||
|
||||
Use [Editor#screenToPage](?) to convert screen coordinates to page coordinates. This is the most common transformation. It accounts for the editor container's position, the camera position, and the zoom level:
|
||||
|
||||
```typescript
|
||||
// Convert mouse event coordinates to page space
|
||||
const pagePoint = editor.screenToPage({ x: event.clientX, y: event.clientY })
|
||||
|
||||
// Create a shape at the clicked location
|
||||
editor.createShape({
|
||||
type: 'geo',
|
||||
x: pagePoint.x,
|
||||
y: pagePoint.y,
|
||||
props: { w: 100, h: 100, geo: 'rectangle' },
|
||||
})
|
||||
```
|
||||
|
||||
### Page to screen space
|
||||
|
||||
Use [Editor#pageToScreen](?) to convert page coordinates to screen coordinates, for example to position a DOM element that lives outside the editor container:
|
||||
|
||||
```typescript
|
||||
// Convert shape position to screen coordinates
|
||||
const shape = editor.getShape(shapeId)
|
||||
if (!shape) return
|
||||
const screenPoint = editor.pageToScreen({ x: shape.x, y: shape.y })
|
||||
|
||||
// Position a DOM element at the shape's screen location
|
||||
element.style.left = `${screenPoint.x}px`
|
||||
element.style.top = `${screenPoint.y}px`
|
||||
```
|
||||
|
||||
### Page to viewport space
|
||||
|
||||
Use [Editor#pageToViewport](?) to convert page coordinates to viewport coordinates. This is `pageToScreen()` without the container offset, so use it for canvas rendering or for positioning elements inside the editor container:
|
||||
|
||||
```typescript
|
||||
// Get viewport coordinates for a page point
|
||||
const viewportPoint = editor.pageToViewport({ x: 500, y: 300 })
|
||||
|
||||
// Check if a point is visible in the viewport
|
||||
const viewportBounds = editor.getViewportScreenBounds()
|
||||
const isVisible =
|
||||
viewportPoint.x >= 0 &&
|
||||
viewportPoint.x <= viewportBounds.w &&
|
||||
viewportPoint.y >= 0 &&
|
||||
viewportPoint.y <= viewportBounds.h
|
||||
```
|
||||
|
||||
## Viewport bounds
|
||||
|
||||
Use [Editor#getViewportScreenBounds](?) to get the editor container's position and size in screen space:
|
||||
|
||||
```typescript
|
||||
const screenBounds = editor.getViewportScreenBounds()
|
||||
// screenBounds.x, screenBounds.y - container position in screen space
|
||||
// screenBounds.w, screenBounds.h - container dimensions in pixels
|
||||
```
|
||||
|
||||
Use [Editor#getViewportPageBounds](?) to get the visible area in page space:
|
||||
|
||||
```typescript
|
||||
const pageBounds = editor.getViewportPageBounds()
|
||||
// pageBounds.x, pageBounds.y - top-left corner of visible area in page space
|
||||
// pageBounds.w, pageBounds.h - visible area dimensions in page units
|
||||
```
|
||||
|
||||
Both are reactive, so you can read them inside `track` components or `useValue` and re-render when the user pans or zooms. At higher zoom levels, the visible page-space area is smaller.
|
||||
|
||||
## Using the inputs manager
|
||||
|
||||
The editor's inputs manager tracks the current pointer position, so you can read it from anywhere in your code:
|
||||
|
||||
```typescript
|
||||
// Get the current pointer position
|
||||
const screenPoint = editor.inputs.getCurrentScreenPoint()
|
||||
const pagePoint = editor.inputs.getCurrentPagePoint()
|
||||
|
||||
// Get the position where the current drag started
|
||||
const originScreenPoint = editor.inputs.getOriginScreenPoint()
|
||||
const originPagePoint = editor.inputs.getOriginPagePoint()
|
||||
```
|
||||
|
||||
Despite the name, the inputs manager's "screen point" is relative to the editor container, which this article calls viewport space. Don't pass it to `screenToPage()` and don't subtract `getViewportScreenBounds()` from it.
|
||||
|
||||
Pointer event info is different: its `point` property holds `clientX`/`clientY`, so it is in screen space and converts with `screenToPage()`:
|
||||
|
||||
```typescript
|
||||
editor.on('event', (event) => {
|
||||
if (event.type === 'pointer' && event.name === 'pointer_down') {
|
||||
const pagePoint = editor.screenToPage(event.point)
|
||||
// Use pagePoint for shape manipulation
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Related examples
|
||||
|
||||
- [Reactive inputs](/examples/editor-api/reactive-inputs) - Display page and screen coordinates reactively as the pointer moves.
|
||||
- [Selection UI](/examples/ui/selection-ui) - Use selection bounds in screen space to position custom UI around shapes.
|
||||
75
apps/docs/content/sdk-features/cross-tab-sync.mdx
Normal file
75
apps/docs/content/sdk-features/cross-tab-sync.mdx
Normal file
@@ -0,0 +1,75 @@
|
||||
---
|
||||
title: Cross-tab sync
|
||||
created_at: 1/31/2026
|
||||
updated_at: 1/31/2026
|
||||
keywords:
|
||||
- cross-tab
|
||||
- broadcast channel
|
||||
- sync
|
||||
- multiple tabs
|
||||
- local sync
|
||||
status: draft
|
||||
date: 1/31/2026
|
||||
---
|
||||
|
||||
Cross-tab sync keeps a document in sync across browser tabs. When two tabs use the same `persistenceKey`, changes made in one tab appear in the other immediately. No server is required.
|
||||
|
||||
```tsx
|
||||
import { Tldraw } from 'tldraw'
|
||||
import 'tldraw/tldraw.css'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0 }}>
|
||||
<Tldraw persistenceKey="my-document" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Open this app in two tabs and draw in one of them. The other tab shows the shapes as you draw. Both tabs also persist the document to the same IndexedDB database — see [Persistence](/docs/persistence) for how storage works.
|
||||
|
||||
## How it works
|
||||
|
||||
Each tab opens a [BroadcastChannel](https://developer.mozilla.org/en-US/docs/Web/API/Broadcast_Channel_API) named after the persistence key. When the user changes the document, the editor posts the change as a diff to the channel. Other tabs merge incoming diffs into their stores with [Store#mergeRemoteChanges](?), so they don't echo the same changes back. The internal `useLocalStore` hook sets all of this up when you pass a `persistenceKey`.
|
||||
|
||||
The channel only carries live updates. The document itself persists to IndexedDB on a throttle (about 350ms), so a tab opened later loads the latest saved state from the database, then stays current through the channel.
|
||||
|
||||
## What syncs
|
||||
|
||||
| Data | Behavior |
|
||||
| -------------------------------------------------- | ---------------------- |
|
||||
| Document records (shapes, pages, bindings, assets) | Synced across tabs |
|
||||
| Session state (camera, selection, current page) | Per tab |
|
||||
| User preferences (name, color, color scheme) | Synced across all tabs |
|
||||
|
||||
Only document-scoped records sync between tabs. Each tab keeps its own camera position, selection, and other [instance state](/sdk-features/instance-state), so users can look at different parts of the same document in different tabs. That per-tab session state is still saved to IndexedDB, keyed by the `sessionId` prop (which defaults to [TAB_ID](?)), so it survives a reload.
|
||||
|
||||
tldraw's default user preferences sync across tabs on a separate channel, independent of the persistence key. If you manage preferences yourself through the `user` prop, syncing them is up to you — see [User preferences](/sdk-features/user-preferences).
|
||||
|
||||
## Schema versions
|
||||
|
||||
If you deploy a new version of your app while old tabs stay open, tabs can end up running different schema versions against the same document. Tabs announce their schema version on the channel and compare:
|
||||
|
||||
- A tab with an older schema reloads itself to pick up the new code
|
||||
- A tab with a newer schema tells older tabs to reload, then re-persists the full document
|
||||
|
||||
If a tab is still out of date right after reloading — for example, when a cached old version is being served — it stops with a schema mismatch error instead of reloading in a loop.
|
||||
|
||||
## Limitations
|
||||
|
||||
Cross-tab sync works within one browser on one origin. It doesn't sync between devices or users. For multiplayer collaboration, use [tldraw sync](/docs/sync) — see the [Collaboration](/sdk-features/collaboration) page.
|
||||
|
||||
It also requires the `persistenceKey` prop. If you create your own store and pass it through the `store` prop, you won't get cross-tab sync or local persistence. See [custom persistence with store](/sdk-features/persistence#custom-persistence-with-store) for handling that yourself.
|
||||
|
||||
There's no separate toggle: tabs that share a persistence key always sync. To keep tabs independent, give them different persistence keys.
|
||||
|
||||
## Related articles
|
||||
|
||||
- [Persistence](/docs/persistence) — Local storage with `persistenceKey`, snapshots, and migrations
|
||||
- [Collaboration](/sdk-features/collaboration) — Multiplayer sync between users with a server
|
||||
- [Instance state](/sdk-features/instance-state) — The per-tab state that doesn't sync
|
||||
|
||||
## Related examples
|
||||
|
||||
- [Persistence key](/examples/configuration/persistence-key) — Persist and sync the editor across tabs with a persistence key
|
||||
106
apps/docs/content/sdk-features/culling.mdx
Normal file
106
apps/docs/content/sdk-features/culling.mdx
Normal file
@@ -0,0 +1,106 @@
|
||||
---
|
||||
title: Culling
|
||||
created_at: 12/20/2025
|
||||
updated_at: 12/29/2025
|
||||
keywords:
|
||||
- culling
|
||||
- visibility
|
||||
- viewport
|
||||
- performance
|
||||
- rendering
|
||||
status: published
|
||||
date: 12/20/2025
|
||||
readability: 9
|
||||
voice: 8
|
||||
completeness: 9
|
||||
accuracy: 9
|
||||
notes: Example link path may need verification. Could add more contractions for voice.
|
||||
---
|
||||
|
||||
The culling system optimizes rendering performance by hiding shapes that are outside the viewport.
|
||||
|
||||
Culled shapes stay in the DOM with `display: none`, so they cost nothing to render, and they stay in the store, so they can still be selected, hit-tested, and exported. The culling set is an incremental derivation that updates as the camera moves or shapes change. See [Performance](/sdk-features/performance) for how culling fits with the other rendering optimizations.
|
||||
|
||||
## Using the culling APIs
|
||||
|
||||
The editor exposes two sets of shape IDs. [Editor#getNotVisibleShapes](?) returns the shapes whose page bounds don't intersect the viewport and whose shape util allows culling. [Editor#getCulledShapes](?) removes the selected shapes and the shape being edited from that set, so users can always see what they're working with. Both are reactive, and `getCulledShapes()` returns the same `Set` instance while its contents are unchanged, so it's cheap to read in `track` components or `useValue`.
|
||||
|
||||
```tsx
|
||||
import { Tldraw } from 'tldraw'
|
||||
import 'tldraw/tldraw.css'
|
||||
|
||||
export default function CullingExample() {
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0 }}>
|
||||
<Tldraw
|
||||
onMount={(editor) => {
|
||||
// Get the IDs of shapes outside the viewport (before selection filtering)
|
||||
const notVisible = editor.getNotVisibleShapes()
|
||||
|
||||
// Get the IDs of shapes that should not render (excludes selected/editing shapes)
|
||||
const culled = editor.getCulledShapes()
|
||||
|
||||
console.log('Not visible:', notVisible.size)
|
||||
console.log('Actually culled:', culled.size)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## How it works
|
||||
|
||||
The first layer queries the editor's spatial index for shapes whose page bounds intersect the viewport and marks everything else as not visible, skipping shapes whose util's `canCull` returns `false`. The second layer removes the selected shapes and the editing shape, so a user can scroll a shape partly or fully out of view while still seeing and interacting with it.
|
||||
|
||||
## Shape-level control
|
||||
|
||||
A shape type opts out of culling by overriding [ShapeUtil#canCull](?). The default returns `true`. When your override returns `false`, the shape never enters the not-visible set, so it never gets `display: none`:
|
||||
|
||||
```tsx
|
||||
import { ShapeUtil, TLBaseShape, RecordProps, T, Rectangle2d } from 'tldraw'
|
||||
|
||||
type MyShape = TLBaseShape<'my-shape', { w: number; h: number; hasGlow: boolean }>
|
||||
|
||||
class MyShapeUtil extends ShapeUtil<MyShape> {
|
||||
static override type = 'my-shape' as const
|
||||
static override props: RecordProps<MyShape> = {
|
||||
w: T.number,
|
||||
h: T.number,
|
||||
hasGlow: T.boolean,
|
||||
}
|
||||
|
||||
getDefaultProps(): MyShape['props'] {
|
||||
return { w: 100, h: 100, hasGlow: false }
|
||||
}
|
||||
|
||||
getGeometry(shape: MyShape) {
|
||||
return new Rectangle2d({ width: shape.props.w, height: shape.props.h, isFilled: true })
|
||||
}
|
||||
|
||||
override canCull(shape: MyShape): boolean {
|
||||
// Shapes with glow effects shouldn't be culled because
|
||||
// the glow might be visible even when the shape bounds aren't
|
||||
if (shape.props.hasGlow) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
component(shape: MyShape) {
|
||||
return <div style={{ width: shape.props.w, height: shape.props.h }} />
|
||||
}
|
||||
|
||||
getIndicatorPath(shape: MyShape) {
|
||||
const path = new Path2D()
|
||||
path.rect(0, 0, shape.props.w, shape.props.h)
|
||||
return path
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Shapes whose util overrides `canCull` are subscribed to individually inside the culling derivation, so a `canCull` that reads many props re-runs the derivation more often. Shapes using the default fast path don't pay this cost. Disable culling only for shapes that need it: visual effects that extend past the bounds, shapes that measure their DOM, or animations that should keep running off-screen. See [Performance](/sdk-features/performance#Disable-culling-only-when-necessary) for guidance.
|
||||
|
||||
## Related examples
|
||||
|
||||
- [Size from DOM](/examples/shapes/tools/size-from-dom) - A shape that disables culling because it measures its DOM element to determine size.
|
||||
210
apps/docs/content/sdk-features/cursor-chat.mdx
Normal file
210
apps/docs/content/sdk-features/cursor-chat.mdx
Normal file
@@ -0,0 +1,210 @@
|
||||
---
|
||||
title: Cursor chat
|
||||
created_at: 01/31/2026
|
||||
updated_at: 01/31/2026
|
||||
keywords:
|
||||
- cursor chat
|
||||
- chat
|
||||
- collaboration
|
||||
- presence
|
||||
- messaging
|
||||
- chatMessage
|
||||
- isChatting
|
||||
status: published
|
||||
date: 01/31/2026
|
||||
---
|
||||
|
||||
Cursor chat lets users send short messages that appear as bubbles near their cursor. It's designed for quick, ephemeral communication during collaborative sessions: a fast "look here" or "nice work" that doesn't interrupt the canvas workflow.
|
||||
|
||||
Cursor chat only appears when collaboration UI is enabled, which means the store has to be collaborative. The simplest way to get one is [useSyncDemo](?):
|
||||
|
||||
```tsx
|
||||
import { useSyncDemo } from '@tldraw/sync'
|
||||
import { Tldraw } from 'tldraw'
|
||||
import 'tldraw/tldraw.css'
|
||||
|
||||
export default function App() {
|
||||
const store = useSyncDemo({ roomId: 'my-cursor-chat-room' })
|
||||
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0 }}>
|
||||
<Tldraw
|
||||
store={store}
|
||||
onMount={(editor) => {
|
||||
// Show a message bubble at the cursor
|
||||
editor.updateInstanceState({ chatMessage: 'Hello from the canvas!' })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
On desktop, users press `/` to open the chat input, type their message (up to 64 characters), and press Enter to send. The message follows their cursor. Once the input closes, the message stays visible for two seconds and then clears.
|
||||
|
||||
## Chat state
|
||||
|
||||
Chat state lives in instance state with two properties:
|
||||
|
||||
| Property | Type | Description |
|
||||
| ------------- | --------- | --------------------------------------- |
|
||||
| `isChatting` | `boolean` | Whether the user is actively typing |
|
||||
| `chatMessage` | `string` | The current message (max 64 characters) |
|
||||
|
||||
Read the current state with [Editor#getInstanceState](?):
|
||||
|
||||
```tsx
|
||||
const { isChatting, chatMessage } = editor.getInstanceState()
|
||||
```
|
||||
|
||||
Update it with [Editor#updateInstanceState](?):
|
||||
|
||||
```tsx
|
||||
// Start chatting
|
||||
editor.updateInstanceState({ isChatting: true })
|
||||
|
||||
// Update the message
|
||||
editor.updateInstanceState({ chatMessage: 'Looking at this shape' })
|
||||
|
||||
// Stop chatting and clear the message
|
||||
editor.updateInstanceState({ isChatting: false, chatMessage: '' })
|
||||
```
|
||||
|
||||
Both properties are ephemeral—they don't persist to storage or survive page reloads.
|
||||
|
||||
## How it works
|
||||
|
||||
When a user starts chatting:
|
||||
|
||||
1. The `CursorChatBubble` component renders an input field at the cursor position
|
||||
2. The input tracks the cursor via `pointermove` events
|
||||
3. As the user types, `chatMessage` updates in instance state
|
||||
4. When they press Enter with text in the input, the input clears and the message becomes its placeholder; the input stays open for another message
|
||||
5. When they press Escape, press Enter with an empty input, or the input loses focus, `isChatting` becomes `false`
|
||||
6. The message stays visible for 2 seconds, then clears automatically
|
||||
|
||||
While the input is open, chat times out after 5 seconds of inactivity.
|
||||
|
||||
## Keyboard shortcuts
|
||||
|
||||
The default keyboard action for cursor chat is `/`. You can find it under the action ID `open-cursor-chat`. The action is only registered when collaboration UI is enabled, and the default context menu shows it as [CursorChatItem](?):
|
||||
|
||||
```tsx
|
||||
import { Tldraw, useActions } from 'tldraw'
|
||||
|
||||
function ChatButton() {
|
||||
const actions = useActions()
|
||||
|
||||
return (
|
||||
<button onClick={() => actions['open-cursor-chat'].onSelect('menu')}>Open cursor chat</button>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Inside the chat input:
|
||||
|
||||
| Key | Action |
|
||||
| -------- | ---------------------------------------------------------------------- |
|
||||
| `Enter` | Send the message (if content exists) or stop chatting (if input empty) |
|
||||
| `Escape` | Stop chatting |
|
||||
|
||||
## Presence synchronization
|
||||
|
||||
In multiplayer sessions, chat messages synchronize automatically through presence records. The `chatMessage` field in [TLInstancePresence](?) contains the message other users see:
|
||||
|
||||
```tsx
|
||||
import { createUserId, InstancePresenceRecordType } from 'tldraw'
|
||||
|
||||
// Inside onMount: create a remote user's presence with a chat message
|
||||
const peerPresence = InstancePresenceRecordType.create({
|
||||
id: InstancePresenceRecordType.createId(editor.store.id),
|
||||
currentPageId: editor.getCurrentPageId(),
|
||||
userId: createUserId('peer-1'),
|
||||
userName: 'Alice',
|
||||
cursor: { x: 100, y: 200, type: 'default', rotation: 0 },
|
||||
chatMessage: 'Check out this arrow!',
|
||||
})
|
||||
|
||||
editor.store.mergeRemoteChanges(() => {
|
||||
editor.store.put([peerPresence])
|
||||
})
|
||||
```
|
||||
|
||||
The presence derivation includes the local user's `chatMessage` from instance state, so changes broadcast to other users without any extra work.
|
||||
|
||||
## Customizing the chat bubble
|
||||
|
||||
You can replace the default chat bubble by providing a custom `CursorChatBubble` component through [TLUiComponents](?). The slot is only rendered when collaboration UI is enabled, so this example needs a collaborative store too:
|
||||
|
||||
```tsx
|
||||
import { useSyncDemo } from '@tldraw/sync'
|
||||
import { Tldraw, TLUiComponents, useEditor, track } from 'tldraw'
|
||||
import 'tldraw/tldraw.css'
|
||||
|
||||
const CustomCursorChat = track(function CustomCursorChat() {
|
||||
const editor = useEditor()
|
||||
const { isChatting, chatMessage } = editor.getInstanceState()
|
||||
|
||||
if (!isChatting && !chatMessage) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: 20,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
padding: '8px 16px',
|
||||
background: editor.user.getColor(),
|
||||
borderRadius: 8,
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
{isChatting ? 'Typing...' : chatMessage}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
const components: TLUiComponents = {
|
||||
CursorChatBubble: CustomCursorChat,
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const store = useSyncDemo({ roomId: 'my-cursor-chat-room' })
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0 }}>
|
||||
<Tldraw store={store} components={components} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Remote users' chat messages render as part of the collaborator cursor, in the DOM cursor layer. To customize how they appear, replace the `CollaboratorCursor` component. See [Cursors](/sdk-features/cursors#Rendering-collaborator-cursors) for details.
|
||||
|
||||
## Availability
|
||||
|
||||
Cursor chat requires:
|
||||
|
||||
- Collaboration enabled (`editor.store.props.collaboration !== undefined`)
|
||||
- A non-touch device (disabled on mobile/tablet)
|
||||
|
||||
Setting `isChatting` isn't gated, but the bubble won't render without both, so check availability before triggering chat programmatically:
|
||||
|
||||
```tsx
|
||||
const hasCollaboration = editor.store.props.collaboration !== undefined
|
||||
const isTouchDevice = editor.getInstanceState().isCoarsePointer
|
||||
|
||||
if (hasCollaboration && !isTouchDevice) {
|
||||
editor.updateInstanceState({ isChatting: true })
|
||||
}
|
||||
```
|
||||
|
||||
## Related articles
|
||||
|
||||
- [Cursors](/sdk-features/cursors) — Cursor types, colors, and collaborator cursor customization
|
||||
- [Collaboration](/sdk-features/collaboration) — Presence synchronization and multiplayer setup
|
||||
- [User preferences](/sdk-features/user-preferences) — User colors and identity
|
||||
|
||||
## Related examples
|
||||
|
||||
- [User presence](/examples/collaboration/user-presence) — Display collaborator cursors and chat messages
|
||||
226
apps/docs/content/sdk-features/cursors.mdx
Normal file
226
apps/docs/content/sdk-features/cursors.mdx
Normal file
@@ -0,0 +1,226 @@
|
||||
---
|
||||
title: Cursors
|
||||
created_at: 01/31/2026
|
||||
updated_at: 01/31/2026
|
||||
keywords:
|
||||
- cursor
|
||||
- pointer
|
||||
- setCursor
|
||||
- rotation
|
||||
- color
|
||||
- presence
|
||||
- collaborator
|
||||
status: published
|
||||
date: 01/31/2026
|
||||
---
|
||||
|
||||
The cursor system controls what cursor users see when interacting with the canvas. The current cursor is stored in instance state and changes automatically as users hover over different elements or use different tools. You can also set the cursor manually for custom tools.
|
||||
|
||||
## Cursor state
|
||||
|
||||
The cursor state is a [TLCursor](?): a [TLCursorType](?) and a rotation angle. Access it through [Editor#getInstanceState](?):
|
||||
|
||||
```typescript
|
||||
const { type, rotation } = editor.getInstanceState().cursor
|
||||
```
|
||||
|
||||
The `type` determines the visual appearance—like `'default'`, `'grab'`, or `'nwse-resize'`. The `rotation` is an angle in radians that rotates the cursor icon. Rotation is mainly used for resize and rotate cursors so they align with the shape being manipulated.
|
||||
|
||||
## Cursor types
|
||||
|
||||
tldraw supports these cursor types:
|
||||
|
||||
| Type | Description |
|
||||
| ------------- | ---------------------------------------------- |
|
||||
| `default` | Standard pointer arrow |
|
||||
| `pointer` | Hand indicating clickable element |
|
||||
| `cross` | Crosshair for precise positioning |
|
||||
| `comment` | Speech bubble for placing comments |
|
||||
| `grab` | Open hand for draggable content |
|
||||
| `grabbing` | Closed hand while dragging |
|
||||
| `text` | I-beam for text editing |
|
||||
| `move` | Four-way arrow for moving elements |
|
||||
| `zoom-in` | Magnifying glass with plus |
|
||||
| `zoom-out` | Magnifying glass with minus |
|
||||
| `ew-resize` | Horizontal resize (east-west) |
|
||||
| `ns-resize` | Vertical resize (north-south) |
|
||||
| `nesw-resize` | Diagonal resize (northeast-southwest) |
|
||||
| `nwse-resize` | Diagonal resize (northwest-southeast) |
|
||||
| `nesw-rotate` | Rotation handle (northeast-southwest position) |
|
||||
| `nwse-rotate` | Rotation handle (northwest-southeast position) |
|
||||
| `senw-rotate` | Rotation handle (southeast-northwest position) |
|
||||
| `swne-rotate` | Rotation handle (southwest-northeast position) |
|
||||
| `none` | Hidden cursor |
|
||||
|
||||
Static cursors like `default`, `pointer`, and `grab` are prerendered SVG cursors defined as CSS custom properties in tldraw's stylesheet (`--tl-cursor-default`, `--tl-cursor-grab`, and so on). Dynamic cursors like the resize and rotate types are generated at runtime as SVGs with the current rotation applied. The canvas reads the result from the `--tl-cursor` variable.
|
||||
|
||||
> `TLCursorType` also includes `resize-edge`, `resize-corner`, and `rotate` for schema compatibility. The default cursor rendering doesn't support them, so don't pass them to `setCursor`.
|
||||
|
||||
## Setting the cursor
|
||||
|
||||
Use [Editor#setCursor](?) to change the cursor:
|
||||
|
||||
```typescript
|
||||
editor.setCursor({ type: 'cross', rotation: 0 })
|
||||
```
|
||||
|
||||
You can update just the type or just the rotation—the other property keeps its current value:
|
||||
|
||||
```typescript
|
||||
// Change only the type
|
||||
editor.setCursor({ type: 'grab' })
|
||||
|
||||
// Change only the rotation
|
||||
editor.setCursor({ rotation: Math.PI / 4 })
|
||||
```
|
||||
|
||||
### Cursor rotation
|
||||
|
||||
Rotation is specified in radians. When users resize or rotate shapes that are themselves rotated, the cursor rotates to match:
|
||||
|
||||
```typescript
|
||||
// Get the selection's rotation and apply it to a resize cursor
|
||||
const selectionRotation = editor.getSelectionRotation()
|
||||
editor.setCursor({
|
||||
type: 'nwse-resize',
|
||||
rotation: selectionRotation,
|
||||
})
|
||||
```
|
||||
|
||||
This keeps the cursor aligned with the shape's edges rather than the screen axes. The default tools handle cursor rotation automatically. You only need to set it manually for custom tools.
|
||||
|
||||
## Cursors in custom tools
|
||||
|
||||
Custom tools typically set the cursor when entering a state and reset it when exiting:
|
||||
|
||||
```typescript
|
||||
import { StateNode } from 'tldraw'
|
||||
|
||||
export class MyCustomTool extends StateNode {
|
||||
static override id = 'my-tool'
|
||||
|
||||
override onEnter() {
|
||||
this.editor.setCursor({ type: 'cross', rotation: 0 })
|
||||
}
|
||||
|
||||
override onExit() {
|
||||
this.editor.setCursor({ type: 'default', rotation: 0 })
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For tools with child states, each state can set its own cursor. A drawing state might use `'cross'`, while a dragging state uses `'grabbing'`.
|
||||
|
||||
## Cursor colors
|
||||
|
||||
Dynamic cursors (resize and rotate types) receive the active [theme](/sdk-features/themes)'s `cursor` color for the current color mode: black in the default light theme, white in the default dark theme. The built-in SVGs use fixed black and white fills for contrast, so this color only shows in custom cursor SVGs.
|
||||
|
||||
## Collaborator cursors
|
||||
|
||||
In multiplayer sessions, each user's cursor appears on other users' canvases. These remote cursors use the user's presence color—a randomly assigned color from the user color palette.
|
||||
|
||||
### User color palette
|
||||
|
||||
When a user first loads tldraw, they're assigned a random color from the built-in `USER_COLORS` palette:
|
||||
|
||||
```typescript
|
||||
const USER_COLORS = [
|
||||
'#FF802B',
|
||||
'#EC5E41',
|
||||
'#F2555A',
|
||||
'#F04F88',
|
||||
'#E34BA9',
|
||||
'#BD54C6',
|
||||
'#9D5BD2',
|
||||
'#7B66DC',
|
||||
'#02B1CC',
|
||||
'#11B3A3',
|
||||
'#39B178',
|
||||
'#55B467',
|
||||
]
|
||||
```
|
||||
|
||||
You can read or change a user's color through user preferences:
|
||||
|
||||
```typescript
|
||||
// Get the user's color
|
||||
const color = editor.user.getColor()
|
||||
|
||||
// Set a specific color
|
||||
editor.user.updateUserPreferences({ color: '#FF802B' })
|
||||
```
|
||||
|
||||
### Rendering collaborator cursors
|
||||
|
||||
Remote cursors render as DOM elements in a dedicated layer stacked above the canvas and below the UI panels. Each visible collaborator gets a cursor showing:
|
||||
|
||||
- The cursor arrow in the user's color
|
||||
- The user's name as a label next to the cursor
|
||||
- Any active chat message in a bubble
|
||||
|
||||
A collaborator whose cursor is outside your viewport shows as a small hint arrow clamped to the viewport edge, pointing toward them. [CollaboratorHintOverlayUtil](?) draws the hints on the overlay canvas rather than as DOM elements.
|
||||
|
||||
To customize the cursor, pass your own component via the `components` prop's `CollaboratorCursor` slot (see [DefaultCursor](?) and [TLCursorProps](?)):
|
||||
|
||||
```tsx
|
||||
import { TLCursorProps, Tldraw } from 'tldraw'
|
||||
import 'tldraw/tldraw.css'
|
||||
|
||||
function CustomCollaboratorCursor({ point, color, name, zoom }: TLCursorProps) {
|
||||
if (!point) return null
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
// The layer is scaled by the camera, so counter-scale by 1 / zoom to keep the
|
||||
// cursor a constant on-screen size (the default components do the same).
|
||||
transform: `translate(${point.x}px, ${point.y}px) scale(${1 / zoom})`,
|
||||
transformOrigin: 'top left',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
{/* A dot instead of the default arrow */}
|
||||
<div style={{ width: 16, height: 16, borderRadius: '50%', backgroundColor: color }} />
|
||||
{name && <div style={{ color }}>{name}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0 }}>
|
||||
<Tldraw components={{ CollaboratorCursor: CustomCollaboratorCursor }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Cursor position in presence
|
||||
|
||||
Collaborator cursor positions are stored in presence records ([TLInstancePresence](?)). The cursor field includes position, type, and rotation:
|
||||
|
||||
```typescript
|
||||
{
|
||||
cursor: {
|
||||
x: number
|
||||
y: number
|
||||
type: TLCursorType
|
||||
rotation: number
|
||||
} | null
|
||||
}
|
||||
```
|
||||
|
||||
The editor automatically broadcasts cursor position updates to other users in the same room. `cursor` is `null` only when presence hasn't been populated yet or a custom presence derivation omits it. The editor hides a collaborator's cursor when it's outside your viewport or the user has been inactive.
|
||||
|
||||
## Related articles
|
||||
|
||||
- [Cursor chat](/sdk-features/cursor-chat) - Send ephemeral chat messages at the cursor position
|
||||
- [Tools](/sdk-features/tools) - Learn how tools handle input and set cursors
|
||||
- [Collaboration](/sdk-features/collaboration) - User presence and multiplayer features
|
||||
- [User preferences](/sdk-features/user-preferences) - Manage user colors and other preferences
|
||||
- [Overlay utils](/sdk-features/overlay-utils) - Canvas overlays, including the collaborator cursor hint overlay
|
||||
|
||||
## Related examples
|
||||
|
||||
- [Custom tool](/examples/shapes/tools/custom-tool) - Build a custom tool with cursor handling
|
||||
- [User presence](/examples/collaboration/user-presence) - Display collaborator cursors and presence
|
||||
146
apps/docs/content/sdk-features/deep-links.mdx
Normal file
146
apps/docs/content/sdk-features/deep-links.mdx
Normal file
@@ -0,0 +1,146 @@
|
||||
---
|
||||
title: Deep links
|
||||
created_at: 12/20/2025
|
||||
updated_at: 12/20/2025
|
||||
keywords:
|
||||
- deep link
|
||||
- URL
|
||||
- share
|
||||
- navigation
|
||||
- state
|
||||
status: published
|
||||
date: 12/20/2025
|
||||
readability: 9
|
||||
voice: 9
|
||||
completeness: 8
|
||||
accuracy: 10
|
||||
notes: ''
|
||||
---
|
||||
|
||||
Deep links serialize editor state into URL-safe strings. They let users share links that open the editor at specific locations: individual shapes, viewport positions, or entire pages.
|
||||
|
||||
The simplest way to enable deep links is with the `deepLinks` option:
|
||||
|
||||
```tsx
|
||||
import { Tldraw } from 'tldraw'
|
||||
import 'tldraw/tldraw.css'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0 }}>
|
||||
<Tldraw persistenceKey="example" options={{ deepLinks: true }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
With `deepLinks` enabled, the editor reads the `d` query parameter on mount and navigates to it, then keeps the URL up to date as users navigate. Anyone opening the URL sees the same page and viewport position.
|
||||
|
||||
For more control, use the editor methods directly. [Editor#createDeepLink](?) generates URLs with encoded state, [Editor#navigateToDeepLink](?) moves the editor to a specified location, and [Editor#registerDeepLinkListener](?) updates URLs automatically as users navigate.
|
||||
|
||||
## Deep link types
|
||||
|
||||
A [TLDeepLink](?) is one of three types:
|
||||
|
||||
| Type | Purpose | Encoded prefix |
|
||||
| ---------- | --------------------------------------------------- | -------------- |
|
||||
| `shapes` | Links to specific shapes, zooming to fit them | `s` |
|
||||
| `viewport` | Links to a bounding box view, with an optional page | `v` |
|
||||
| `page` | Links to a specific page, zoomed to fit its content | `p` |
|
||||
|
||||
## How it works
|
||||
|
||||
Deep links are encoded as compact strings with a single-character prefix identifying the type:
|
||||
|
||||
- Shape links (`s`) encode shape IDs separated by dots: `s<id1>.<id2>.<id3>`
|
||||
- Viewport links (`v`) encode rounded bounding box coordinates: `v<x>.<y>.<w>.<h>` with optional page ID
|
||||
- Page links (`p`) encode a page ID: `p<pageId>`
|
||||
|
||||
All IDs are URL-encoded to handle special characters. The default query parameter is `d`, but you can customize this with the `param` option. When navigating to a shapes deep link, the editor switches to the page containing the most shapes and zooms to fit them. Viewport links set the camera to the exact specified bounds. If the parameter is missing or invalid, or the shapes or page no longer exist, the editor zooms to fit the page content instead. Use [createDeepLinkString](?) and [parseDeepLinkString](?) to encode and decode these strings without an editor.
|
||||
|
||||
## API methods
|
||||
|
||||
### createDeepLink
|
||||
|
||||
Creates a URL with a deep link query parameter encoding the current viewport and page:
|
||||
|
||||
```ts
|
||||
// Create a link to the current viewport
|
||||
const url = editor.createDeepLink()
|
||||
navigator.clipboard.writeText(url.toString())
|
||||
```
|
||||
|
||||
Specify a target to link to specific shapes:
|
||||
|
||||
```ts
|
||||
// Link to currently selected shapes
|
||||
const url = editor.createDeepLink({
|
||||
to: { type: 'shapes', shapeIds: editor.getSelectedShapeIds() },
|
||||
})
|
||||
```
|
||||
|
||||
### navigateToDeepLink
|
||||
|
||||
Navigates the editor to the location specified by a deep link URL or object:
|
||||
|
||||
```ts
|
||||
import { TLShapeId } from 'tldraw'
|
||||
|
||||
// Navigate using the current URL's query parameter
|
||||
editor.navigateToDeepLink()
|
||||
|
||||
// Navigate to a specific URL
|
||||
editor.navigateToDeepLink({ url: 'https://example.com?d=v100.100.200.200' })
|
||||
|
||||
// Navigate directly to shapes
|
||||
editor.navigateToDeepLink({
|
||||
type: 'shapes',
|
||||
shapeIds: ['shape:abc' as TLShapeId, 'shape:xyz' as TLShapeId],
|
||||
})
|
||||
```
|
||||
|
||||
### registerDeepLinkListener
|
||||
|
||||
Sets up automatic URL updates as the viewport changes. The listener debounces updates (500ms by default) to avoid excessive history entries:
|
||||
|
||||
```ts
|
||||
// Use default behavior (replaces the current URL without adding history entries)
|
||||
const unlisten = editor.registerDeepLinkListener()
|
||||
|
||||
// Custom change handler with longer debounce
|
||||
const unlisten = editor.registerDeepLinkListener({
|
||||
onChange(url) {
|
||||
window.history.replaceState({}, document.title, url.toString())
|
||||
},
|
||||
debounceMs: 1000,
|
||||
})
|
||||
|
||||
// Clean up when done
|
||||
unlisten()
|
||||
```
|
||||
|
||||
The `deepLinks` option on the Tldraw component calls this for you. Pass a [TLDeepLinkOptions](?) object instead of `true` to customize it; both the option and the method accept the same fields:
|
||||
|
||||
| Option | Description |
|
||||
| ------------ | -------------------------------------------------------------------------------------------- |
|
||||
| `param` | The query parameter name. Defaults to `'d'` |
|
||||
| `debounceMs` | How long to wait before updating the URL. Defaults to `500` |
|
||||
| `getTarget` | Returns the [TLDeepLink](?) to encode. Defaults to the current page and viewport |
|
||||
| `getUrl` | Returns the URL to add the parameter to. If you supply this, you must also supply `onChange` |
|
||||
| `onChange` | Called with the updated URL. Defaults to `window.history.replaceState` |
|
||||
|
||||
```tsx
|
||||
<Tldraw
|
||||
options={{
|
||||
deepLinks: {
|
||||
param: 'view',
|
||||
getUrl: () => window.location.href,
|
||||
onChange: (url) => router.replace(url.toString()),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
## Related examples
|
||||
|
||||
- [Deep links](/examples/configuration/deep-links) - Using the `deepLinks` option and creating, parsing, and handling deep links manually with the editor methods.
|
||||
622
apps/docs/content/sdk-features/default-shapes.mdx
Normal file
622
apps/docs/content/sdk-features/default-shapes.mdx
Normal file
@@ -0,0 +1,622 @@
|
||||
---
|
||||
title: Default shapes
|
||||
created_at: 12/25/2025
|
||||
updated_at: 12/29/2025
|
||||
keywords:
|
||||
- shapes
|
||||
- default shapes
|
||||
- geo
|
||||
- text
|
||||
- arrow
|
||||
- draw
|
||||
- frame
|
||||
- note
|
||||
- image
|
||||
- video
|
||||
- embed
|
||||
- bookmark
|
||||
- line
|
||||
- highlight
|
||||
- group
|
||||
status: published
|
||||
date: 12/25/2025
|
||||
readability: 9
|
||||
voice: 8
|
||||
completeness: 9
|
||||
accuracy: 10
|
||||
notes: 'Passive constructions remain ("are created through", "is computed as"). Could link to more examples.'
|
||||
---
|
||||
|
||||
The tldraw package includes thirteen shape types that cover common diagramming and whiteboarding needs. Each shape type has a corresponding [ShapeUtil](?) that defines its rendering, geometry, and interaction behavior. You can use these shapes as-is, configure their behavior through options, or use them as reference when building custom shapes.
|
||||
|
||||
## Shape overview
|
||||
|
||||
The default shapes loosely fall into five categories based on their primary function:
|
||||
|
||||
| Category | Shape types | Description |
|
||||
| ---------- | ------------------------------------- | --------------------------------------------------------------------- |
|
||||
| Text | `text`, `note` | Text labels and sticky notes |
|
||||
| Drawing | `geo`, `draw`, `line`, `highlight` | Freehand strokes, multi-point lines, geometric shapes, and highlights |
|
||||
| Media | `image`, `video`, `bookmark`, `embed` | External content and media files |
|
||||
| Structural | `frame`, `group` | Organization and containment |
|
||||
| Connectors | `arrow` | Lines that connect shapes together |
|
||||
|
||||
## Basic shapes
|
||||
|
||||
### Geo
|
||||
|
||||
The geo shape renders geometric primitives with optional text labels. It supports 20 different geometric forms—rectangles, ellipses, triangles, stars, polygons, directional arrows, and special shapes like clouds and hearts. Geo shapes can display rich text labels with configurable alignment, which makes them the usual building block for flowcharts and diagrams.
|
||||
|
||||
For complete documentation on geometric forms, label positioning, tool interactions, and configuration, see [Geo shape](/sdk-features/geo-shape).
|
||||
|
||||
Quick reference:
|
||||
|
||||
| Property | Type | Description |
|
||||
| --------------- | ------------------------------- | ------------------------------------------- |
|
||||
| `geo` | `TLGeoShapeGeoStyle` | The geometric form |
|
||||
| `w` | `number` | Width in pixels |
|
||||
| `h` | `number` | Height in pixels |
|
||||
| `richText` | `TLRichText` | Text label displayed inside the shape |
|
||||
| `color` | `TLDefaultColorStyle` | Stroke/outline color |
|
||||
| `labelColor` | `TLDefaultColorStyle` | Text label color (separate from stroke) |
|
||||
| `fill` | `TLDefaultFillStyle` | Fill style |
|
||||
| `dash` | `TLDefaultDashStyle` | Stroke pattern |
|
||||
| `size` | `TLDefaultSizeStyle` | Size preset affecting stroke width |
|
||||
| `font` | `TLDefaultFontStyle` | Font family for the label |
|
||||
| `align` | `TLDefaultHorizontalAlignStyle` | Horizontal text alignment |
|
||||
| `verticalAlign` | `TLDefaultVerticalAlignStyle` | Vertical text alignment |
|
||||
| `growY` | `number` | Additional vertical space for text overflow |
|
||||
| `url` | `string` | Optional hyperlink URL |
|
||||
| `scale` | `number` | Scale factor applied to the shape |
|
||||
| `flipX` | `boolean` | Mirror the shape horizontally |
|
||||
| `flipY` | `boolean` | Mirror the shape vertically |
|
||||
|
||||
### Text
|
||||
|
||||
The text shape displays formatted text content with automatic sizing. Text shapes resize to fit their content by default, or you can set them to a fixed width with text wrapping. The shape supports rich text formatting including bold, italic, and other inline styles.
|
||||
|
||||
For a complete guide to text shapes, including auto-size vs fixed-width modes, the text tool's interaction patterns, and configuration options, see [Text shape](/sdk-features/text-shape).
|
||||
|
||||
```tsx
|
||||
editor.createShape({
|
||||
type: 'text',
|
||||
x: 100,
|
||||
y: 100,
|
||||
props: {
|
||||
richText: toRichText('Hello world'),
|
||||
color: 'black',
|
||||
size: 'm',
|
||||
font: 'draw',
|
||||
textAlign: 'start',
|
||||
autoSize: true,
|
||||
w: 200,
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Quick reference:
|
||||
|
||||
| Property | Type | Description |
|
||||
| ----------- | ------------------------- | ----------------------------------------------- |
|
||||
| `richText` | `TLRichText` | The text content with formatting |
|
||||
| `color` | `TLDefaultColorStyle` | Text color |
|
||||
| `size` | `TLDefaultSizeStyle` | Font size preset (`s`, `m`, `l`, `xl`) |
|
||||
| `font` | `TLDefaultFontStyle` | Font family (`draw`, `sans`, `serif`, `mono`) |
|
||||
| `textAlign` | `TLDefaultTextAlignStyle` | Horizontal alignment (`start`, `middle`, `end`) |
|
||||
| `autoSize` | `boolean` | When true, shape resizes to fit content |
|
||||
| `w` | `number` | Width when autoSize is false |
|
||||
| `scale` | `number` | Scale factor applied to the shape |
|
||||
|
||||
### Note
|
||||
|
||||
The note shape renders as a sticky note with a colored background and rich text content. Notes have special interaction behaviors: clone handles on the edges let you quickly create adjacent notes, and keyboard shortcuts navigate between them. Notes have a fixed base size but grow vertically to fit their content.
|
||||
|
||||
For a complete guide to note interactions, clone handles, keyboard navigation, and configuration, see [Note shape](/sdk-features/note-shape).
|
||||
|
||||
```tsx
|
||||
editor.createShape({
|
||||
type: 'note',
|
||||
x: 100,
|
||||
y: 100,
|
||||
props: {
|
||||
color: 'yellow',
|
||||
labelColor: 'black',
|
||||
richText: toRichText('Remember this'),
|
||||
size: 'm',
|
||||
font: 'draw',
|
||||
align: 'middle',
|
||||
verticalAlign: 'middle',
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Drawing shapes
|
||||
|
||||
### Draw
|
||||
|
||||
The draw shape captures freehand strokes and straight line segments. It supports pressure-sensitive input from pens and styluses, automatic shape closing, angle snapping when holding Shift, and hybrid freehand/straight-line drawing modes. Points are stored in a delta-encoded base64 format; strokes from devices without pressure are stored as 2D (`dim: 2`) and pen strokes keep the pressure value.
|
||||
|
||||
For complete documentation on the draw shape and draw tool, including drawing modes, pen support, angle snapping, and programmatic creation, see the [Draw shape](/sdk-features/draw-shape) article.
|
||||
|
||||
Quick reference:
|
||||
|
||||
| Property | Type | Description |
|
||||
| ------------ | ---------------------- | ------------------------------------------------------------------------------------- |
|
||||
| `color` | `TLDefaultColorStyle` | Stroke color |
|
||||
| `fill` | `TLDefaultFillStyle` | Fill style (applies when `isClosed` is true) |
|
||||
| `dash` | `TLDefaultDashStyle` | Stroke pattern |
|
||||
| `size` | `TLDefaultSizeStyle` | Stroke width preset |
|
||||
| `segments` | `TLDrawShapeSegment[]` | Array of segments with `type`, base64-encoded `path`, and optional `dim` (`2` or `3`) |
|
||||
| `isComplete` | `boolean` | Whether the user has finished drawing this stroke |
|
||||
| `isClosed` | `boolean` | Whether the path forms a closed shape |
|
||||
| `isPen` | `boolean` | Whether drawn with a stylus (enables pressure-based width) |
|
||||
| `scale` | `number` | Scale factor applied to the shape |
|
||||
| `scaleX` | `number` | Horizontal scale factor for lazy resize |
|
||||
| `scaleY` | `number` | Vertical scale factor for lazy resize |
|
||||
|
||||
### Line
|
||||
|
||||
The line shape creates multi-point lines with draggable handles. Unlike draw shapes, line shapes have explicit control points that you can manipulate after creation. Each point has an ID and a fractional index for ordering. You can add, remove, or reposition points. Lines support both straight segments and smooth cubic spline interpolation.
|
||||
|
||||
```tsx
|
||||
import { IndexKey } from 'tldraw'
|
||||
|
||||
editor.createShape({
|
||||
type: 'line',
|
||||
x: 100,
|
||||
y: 100,
|
||||
props: {
|
||||
color: 'black',
|
||||
dash: 'solid',
|
||||
size: 'm',
|
||||
spline: 'line',
|
||||
points: {
|
||||
a1: { id: 'a1', index: 'a1' as IndexKey, x: 0, y: 0 },
|
||||
a2: { id: 'a2', index: 'a2' as IndexKey, x: 100, y: 50 },
|
||||
a3: { id: 'a3', index: 'a3' as IndexKey, x: 200, y: 0 },
|
||||
},
|
||||
scale: 1,
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Properties:
|
||||
|
||||
| Property | Type | Description |
|
||||
| -------- | ---------------------------------- | ------------------------------------------------------------- |
|
||||
| `color` | `TLDefaultColorStyle` | Stroke color |
|
||||
| `dash` | `TLDefaultDashStyle` | Stroke pattern |
|
||||
| `size` | `TLDefaultSizeStyle` | Stroke width preset |
|
||||
| `spline` | `TLLineShapeSplineStyle` | Interpolation: `line` for straight, `cubic` for curves |
|
||||
| `points` | `Record<string, TLLineShapePoint>` | Dictionary of control points with IDs, indices, and positions |
|
||||
| `scale` | `number` | Scale factor applied to the shape |
|
||||
|
||||
Line shapes don't have configuration options.
|
||||
|
||||
### Highlight
|
||||
|
||||
The highlight shape works like draw but renders semi-transparently for marking up content. It draws two passes with configurable opacities to imitate a highlighter pen. Like draw shapes, highlights support pressure-sensitive input and automatic shape splitting for long strokes.
|
||||
|
||||
```tsx
|
||||
import { b64Vecs } from 'tldraw'
|
||||
|
||||
editor.createShape({
|
||||
type: 'highlight',
|
||||
x: 100,
|
||||
y: 100,
|
||||
props: {
|
||||
color: 'yellow',
|
||||
size: 'l',
|
||||
segments: [
|
||||
{
|
||||
type: 'free',
|
||||
path: b64Vecs.encodePoints([
|
||||
{ x: 0, y: 0, z: 0.5 },
|
||||
{ x: 100, y: 10, z: 0.5 },
|
||||
{ x: 200, y: 0, z: 0.5 },
|
||||
]),
|
||||
},
|
||||
],
|
||||
isComplete: true,
|
||||
isPen: false,
|
||||
scale: 1,
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Properties:
|
||||
|
||||
| Property | Type | Description |
|
||||
| ------------ | ---------------------- | ----------------------------------------------------------------------------------------- |
|
||||
| `color` | `TLDefaultColorStyle` | Highlight color (yellow, green, blue, etc.) |
|
||||
| `size` | `TLDefaultSizeStyle` | Stroke width preset |
|
||||
| `segments` | `TLDrawShapeSegment[]` | Array of segments with base64-encoded points (see [Draw shape](/sdk-features/draw-shape)) |
|
||||
| `isComplete` | `boolean` | Whether the user has finished this stroke |
|
||||
| `isPen` | `boolean` | Whether drawn with a stylus |
|
||||
| `scale` | `number` | Scale factor applied to the shape |
|
||||
| `scaleX` | `number` | Horizontal scale factor for lazy resize |
|
||||
| `scaleY` | `number` | Vertical scale factor for lazy resize |
|
||||
|
||||
Configuration options:
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
| ------------------- | -------- | ------- | ------------------------------------------------------------------------- |
|
||||
| `maxPointsPerShape` | `number` | `600` | Maximum points before starting a new shape. Same behavior as draw shapes. |
|
||||
|
||||
The highlight's `underlayOpacity` (default `0.82`) and `overlayOpacity` (default `0.35`) are display values that can be overridden via `getCustomDisplayValues`:
|
||||
|
||||
```tsx
|
||||
const ConfiguredHighlightUtil = HighlightShapeUtil.configure({
|
||||
maxPointsPerShape: 800,
|
||||
getCustomDisplayValues() {
|
||||
return { underlayOpacity: 0.7, overlayOpacity: 0.4 }
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Media shapes
|
||||
|
||||
### Image
|
||||
|
||||
The image shape displays raster images with support for cropping and flipping. Images link to asset records that store the actual image data (either as base64 or URLs). The shape maintains aspect ratio during resize and supports circular cropping.
|
||||
|
||||
```tsx
|
||||
editor.createShape({
|
||||
type: 'image',
|
||||
x: 100,
|
||||
y: 100,
|
||||
props: {
|
||||
w: 400,
|
||||
h: 300,
|
||||
assetId: 'asset:abc123' as TLAssetId,
|
||||
url: '',
|
||||
crop: null,
|
||||
flipX: false,
|
||||
flipY: false,
|
||||
playing: true,
|
||||
altText: 'Description for accessibility',
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Properties:
|
||||
|
||||
| Property | Type | Description |
|
||||
| --------- | --------------------- | ----------------------------------------------------------------------------------------------------- |
|
||||
| `w` | `number` | Display width in pixels |
|
||||
| `h` | `number` | Display height in pixels |
|
||||
| `assetId` | `TLAssetId \| null` | Reference to asset record containing image data |
|
||||
| `url` | `string` | Direct URL (used when no asset) |
|
||||
| `crop` | `TLShapeCrop \| null` | Crop region with `topLeft`, `bottomRight` (0-1), and `isCircle` |
|
||||
| `flipX` | `boolean` | Mirror the image horizontally |
|
||||
| `flipY` | `boolean` | Mirror the image vertically |
|
||||
| `playing` | `boolean` | Whether animated images (GIFs) should play (stored on the record; the default util always plays them) |
|
||||
| `altText` | `string` | Accessibility description |
|
||||
|
||||
Image shapes don't have configuration options. Image handling behavior is controlled through the editor's asset management system.
|
||||
|
||||
### Video
|
||||
|
||||
The video shape displays video content with playback controls. Like images, videos link to asset records. Videos autoplay by default and respect the user's `prefers-reduced-motion` setting. The `time` and `playing` props are stored on the record, but the default util does not drive playback from them.
|
||||
|
||||
```tsx
|
||||
editor.createShape({
|
||||
type: 'video',
|
||||
x: 100,
|
||||
y: 100,
|
||||
props: {
|
||||
w: 640,
|
||||
h: 480,
|
||||
assetId: 'asset:video123' as TLAssetId,
|
||||
url: '',
|
||||
time: 0,
|
||||
playing: false,
|
||||
autoplay: true,
|
||||
altText: 'Video description',
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Properties:
|
||||
|
||||
| Property | Type | Description |
|
||||
| ---------- | ------------------- | -------------------------------------- |
|
||||
| `w` | `number` | Display width in pixels |
|
||||
| `h` | `number` | Display height in pixels |
|
||||
| `assetId` | `TLAssetId \| null` | Reference to asset record |
|
||||
| `url` | `string` | Direct URL (used when no asset) |
|
||||
| `time` | `number` | Current playback position in seconds |
|
||||
| `playing` | `boolean` | Whether the video is currently playing |
|
||||
| `autoplay` | `boolean` | Whether to start playing automatically |
|
||||
| `altText` | `string` | Accessibility description |
|
||||
|
||||
Configuration options:
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
| ---------- | --------- | ------- | ----------------------------------------------- |
|
||||
| `autoplay` | `boolean` | `true` | Default autoplay behavior for new video shapes. |
|
||||
|
||||
```tsx
|
||||
const ConfiguredVideoUtil = VideoShapeUtil.configure({
|
||||
autoplay: false,
|
||||
})
|
||||
```
|
||||
|
||||
### Bookmark
|
||||
|
||||
The bookmark shape displays a URL as a card with metadata including title, description, and preview image. The editor creates a bookmark when you paste a URL onto the canvas. It fetches metadata from the URL and stores it in an associated asset record. Bookmark shapes have fixed dimensions and can't be resized.
|
||||
|
||||
```tsx
|
||||
editor.createShape({
|
||||
type: 'bookmark',
|
||||
x: 100,
|
||||
y: 100,
|
||||
props: {
|
||||
url: 'https://example.com',
|
||||
assetId: 'asset:bookmark123' as TLAssetId,
|
||||
w: 300,
|
||||
h: 320,
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Properties:
|
||||
|
||||
| Property | Type | Description |
|
||||
| --------- | ------------------- | ------------------------------------------------------- |
|
||||
| `url` | `string` | The bookmarked URL |
|
||||
| `assetId` | `TLAssetId \| null` | Reference to asset containing title, description, image |
|
||||
| `w` | `number` | Width (fixed, not user-resizable) |
|
||||
| `h` | `number` | Height (fixed, not user-resizable) |
|
||||
|
||||
Bookmark shapes don't have configuration options. URL metadata fetching is handled by the editor's external content handlers.
|
||||
|
||||
### Embed
|
||||
|
||||
The embed shape displays interactive content from external services (YouTube, Figma, CodeSandbox, and more) within an iframe. When you paste a URL from a supported service, tldraw automatically converts it to an interactive embed with appropriate dimensions.
|
||||
|
||||
For complete documentation on supported services, URL transformation, iframe security, custom embed definitions, and interaction modes, see [Embed shape](/sdk-features/embed-shape).
|
||||
|
||||
```tsx
|
||||
editor.createShape({
|
||||
type: 'embed',
|
||||
x: 100,
|
||||
y: 100,
|
||||
props: {
|
||||
url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
||||
w: 560,
|
||||
h: 315,
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Properties:
|
||||
|
||||
| Property | Type | Description |
|
||||
| -------- | -------- | ---------------------------------------------------- |
|
||||
| `url` | `string` | The original URL (converted to embed URL internally) |
|
||||
| `w` | `number` | Width of the embed container |
|
||||
| `h` | `number` | Height of the embed container |
|
||||
|
||||
## Structural shapes
|
||||
|
||||
### Frame
|
||||
|
||||
The frame shape provides a visual container for organizing shapes. Shapes inside a frame are clipped to its bounds and move with the frame when it's repositioned. Frames display a header with the frame's name, and optionally colored borders and backgrounds. They're useful for creating sections, organizing content into logical groups, or defining artboard-like regions for export.
|
||||
|
||||
For a complete guide, see [Frame shape](/sdk-features/frame-shape).
|
||||
|
||||
```tsx
|
||||
editor.createShape({
|
||||
type: 'frame',
|
||||
x: 100,
|
||||
y: 100,
|
||||
props: {
|
||||
w: 800,
|
||||
h: 600,
|
||||
name: 'Header Section',
|
||||
color: 'blue',
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Properties:
|
||||
|
||||
| Property | Type | Description |
|
||||
| -------- | --------------------- | ----------------------------------------------------- |
|
||||
| `w` | `number` | Frame width in pixels |
|
||||
| `h` | `number` | Frame height in pixels |
|
||||
| `name` | `string` | Label displayed in the frame header |
|
||||
| `color` | `TLDefaultColorStyle` | Color for border and header (when colors are enabled) |
|
||||
|
||||
Configuration options:
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
| ---------------- | --------- | ------- | ----------------------------------------------------------------------------------------------- |
|
||||
| `showColors` | `boolean` | `false` | When true, frames display colored borders and header backgrounds based on the `color` property. |
|
||||
| `resizeChildren` | `boolean` | `false` | When true, resizing the frame also scales the frame's children proportionally. |
|
||||
|
||||
When `showColors` is enabled, the frame's `color` property becomes a style that users can change from the style panel. When disabled, all frames appear with the same neutral styling.
|
||||
|
||||
```tsx
|
||||
const ConfiguredFrameUtil = FrameShapeUtil.configure({
|
||||
showColors: true,
|
||||
resizeChildren: true,
|
||||
})
|
||||
```
|
||||
|
||||
To build your own container shape that behaves like a frame, extend `BaseFrameLikeShapeUtil` rather than re-implementing every behavior on `FrameShapeUtil`. The base class provides defaults for clipping children, full-brush selection, blocking erasure from inside, and drag-and-drop reparenting — see [Shapes](/sdk-features/shapes#Frames) for details and the [portal shapes example](/examples/shapes/tools/portal-shapes) for a working implementation.
|
||||
|
||||
### Group
|
||||
|
||||
The group shape logically combines multiple shapes without visual representation. Groups let you move and transform shapes together while preserving their relative positions. The group's geometry is computed as the union of all child shapes' geometries. You create groups through the editor API rather than directly. A group deletes itself when it has one child or none left.
|
||||
|
||||
```tsx
|
||||
// Create a group from selected shapes
|
||||
editor.groupShapes(editor.getSelectedShapeIds())
|
||||
|
||||
// Ungroup a group
|
||||
editor.ungroupShapes([groupId])
|
||||
|
||||
// Access group children
|
||||
const children = editor.getSortedChildIdsForParent(groupId)
|
||||
```
|
||||
|
||||
Groups have no visual properties; their `props` object is empty. All visual characteristics come from their child shapes. Arrows can't bind to groups. Double-click a group to enter it and edit its children directly.
|
||||
|
||||
Group shapes don't have configuration options. For a complete guide, see [Groups](/sdk-features/groups).
|
||||
|
||||
## Connectors
|
||||
|
||||
### Arrow
|
||||
|
||||
The arrow shape creates lines that can bind to other shapes. Arrows update automatically when their connected shapes move. They support two routing modes: `arc` for smooth curves controlled by a bend parameter, and `elbow` for right-angle routing that navigates around obstacles. Arrows can display text labels positioned along their length, and offer multiple arrowhead styles for both terminals.
|
||||
|
||||
The arrow binding system creates connections automatically when arrow terminals are dragged near other shapes. Bindings can be "precise" (connecting to a specific point) or "imprecise" (connecting to the shape's center or edge).
|
||||
|
||||
```tsx
|
||||
editor.createShape({
|
||||
type: 'arrow',
|
||||
x: 100,
|
||||
y: 100,
|
||||
props: {
|
||||
kind: 'arc',
|
||||
start: { x: 0, y: 0 },
|
||||
end: { x: 200, y: 100 },
|
||||
bend: 0,
|
||||
color: 'black',
|
||||
fill: 'none',
|
||||
dash: 'solid',
|
||||
size: 'm',
|
||||
arrowheadStart: 'none',
|
||||
arrowheadEnd: 'arrow',
|
||||
font: 'draw',
|
||||
richText: toRichText(''),
|
||||
labelPosition: 0.5,
|
||||
labelColor: 'black',
|
||||
scale: 1,
|
||||
elbowMidPoint: 0.5,
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Properties:
|
||||
|
||||
| Property | Type | Description |
|
||||
| ---------------- | ---------------------------- | ------------------------------------------------------- |
|
||||
| `kind` | `TLArrowShapeKind` | Routing mode: `arc` for curved, `elbow` for right-angle |
|
||||
| `start` | `VecModel` | Start terminal position (relative to shape origin) |
|
||||
| `end` | `VecModel` | End terminal position |
|
||||
| `bend` | `number` | Curvature for arc arrows (0 = straight) |
|
||||
| `color` | `TLDefaultColorStyle` | Stroke color |
|
||||
| `fill` | `TLDefaultFillStyle` | Fill style (for arrowheads) |
|
||||
| `dash` | `TLDefaultDashStyle` | Stroke pattern |
|
||||
| `size` | `TLDefaultSizeStyle` | Stroke width preset |
|
||||
| `arrowheadStart` | `TLArrowShapeArrowheadStyle` | Start terminal style |
|
||||
| `arrowheadEnd` | `TLArrowShapeArrowheadStyle` | End terminal style |
|
||||
| `font` | `TLDefaultFontStyle` | Font family for label |
|
||||
| `richText` | `TLRichText` | Optional text label |
|
||||
| `labelPosition` | `number` | Label position along arrow (0 = start, 1 = end) |
|
||||
| `labelColor` | `TLDefaultColorStyle` | Label text color |
|
||||
| `scale` | `number` | Scale factor |
|
||||
| `elbowMidPoint` | `number` | Position of the midpoint handle for elbow arrows |
|
||||
|
||||
The available arrowhead styles are: `none`, `arrow`, `triangle`, `square`, `dot`, `pipe`, `diamond`, `inverted`, `bar`
|
||||
|
||||
Configuration options control snap behavior, timing, and rendering:
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
| ------------------------------------------- | ------------------------------------------------- | ------------------------------------ | ---------------------------------------------------------------------------------------------------- |
|
||||
| `expandElbowLegLength` | `Record<TLDefaultSizeStyle, number>` | `{ s: 28, m: 36, l: 44, xl: 66 }` | How far elbow arrows extend from target shapes, per size. |
|
||||
| `minElbowLegLength` | `Record<TLDefaultSizeStyle, number>` | Based on stroke width × 3 | Minimum length of an elbow arrow's leg segment. |
|
||||
| `minElbowHandleDistance` | `number` | `16` | Minimum screen pixels between two elbow handles. Closer handles are hidden. |
|
||||
| `arcArrowCenterSnapDistance` | `number` | `16` | Screen pixels at which arc arrows snap to target shape centers. Set to 0 to disable. |
|
||||
| `elbowArrowCenterSnapDistance` | `number` | `24` | Screen pixels at which elbow arrows snap to target shape centers. |
|
||||
| `elbowArrowEdgeSnapDistance` | `number` | `20` | Screen pixels at which elbow arrows snap to target shape edges. |
|
||||
| `elbowArrowPointSnapDistance` | `number` | `24` | Screen pixels at which elbow arrows snap to directional points (top, right, bottom, left) of shapes. |
|
||||
| `elbowArrowAxisSnapDistance` | `number` | `16` | Screen pixels at which elbow arrows snap to axes through shape centers. |
|
||||
| `labelCenterSnapDistance` | `number` | `10` | Screen pixels at which arrow labels snap to the arrow's center when dragged. |
|
||||
| `elbowMidpointSnapDistance` | `number` | `10` | Screen pixels at which elbow midpoint handles snap to the midpoint between shapes. |
|
||||
| `elbowMinSegmentLengthToShowMidpointHandle` | `number` | `20` | Minimum segment length before showing the midpoint drag handle. |
|
||||
| `hoverPreciseTimeout` | `number` | `600` | Milliseconds to wait while hovering before switching to precise targeting. |
|
||||
| `pointingPreciseTimeout` | `number` | `320` | Milliseconds to wait while pointing/dragging before switching to precise targeting. |
|
||||
| `shouldBeExact` | `(editor: Editor, isPrecise: boolean) => boolean` | Returns `editor.inputs.getAltKey()` | Function determining whether arrows stop exactly at pointer vs. at shape edges. |
|
||||
| `shouldIgnoreTargets` | `(editor: Editor) => boolean` | Returns `editor.inputs.getCtrlKey()` | Function determining whether to skip binding to target shapes. |
|
||||
| `showTextOutline` | `boolean` | `true` | Whether to show a text outline on arrow labels for readability. |
|
||||
|
||||
```tsx
|
||||
const ConfiguredArrowUtil = ArrowShapeUtil.configure({
|
||||
arcArrowCenterSnapDistance: 24,
|
||||
hoverPreciseTimeout: 400,
|
||||
showTextOutline: false,
|
||||
shouldBeExact: (editor) => editor.inputs.getAltKey() || editor.inputs.getShiftKey(),
|
||||
})
|
||||
```
|
||||
|
||||
## Common properties
|
||||
|
||||
All shapes share base properties defined in `TLBaseShape`:
|
||||
|
||||
```typescript
|
||||
interface TLBaseShape {
|
||||
id: TLShapeId
|
||||
type: string
|
||||
x: number // Position relative to parent
|
||||
y: number
|
||||
rotation: number // Rotation in radians
|
||||
index: IndexKey // Fractional index for z-ordering
|
||||
parentId: TLParentId // Page ID or parent shape ID
|
||||
isLocked: boolean
|
||||
opacity: number // 0-1
|
||||
props: object // Shape-specific properties
|
||||
meta: object // Custom metadata (for your application)
|
||||
}
|
||||
```
|
||||
|
||||
Most shapes also support common style properties through the style system:
|
||||
|
||||
| Style | Values | Description |
|
||||
| ------- | -------------------------------------------------------- | ----------------- |
|
||||
| `color` | `black`, `grey`, `light-violet`, etc. | Stroke/text color |
|
||||
| `fill` | `none`, `semi`, `solid`, `pattern`, `fill`, `lined-fill` | Fill style |
|
||||
| `dash` | `draw`, `solid`, `dashed`, `dotted`, `none` | Stroke pattern |
|
||||
| `size` | `s`, `m`, `l`, `xl` | Size preset |
|
||||
| `font` | `draw`, `sans`, `serif`, `mono` | Font family |
|
||||
|
||||
## Using configured shapes
|
||||
|
||||
To use configured shape utilities, pass them to the `shapeUtils` prop when initializing tldraw. Configured utilities replace the default utilities for their shape type:
|
||||
|
||||
```tsx
|
||||
import { Tldraw, ArrowShapeUtil, FrameShapeUtil, NoteShapeUtil } from 'tldraw'
|
||||
import 'tldraw/tldraw.css'
|
||||
|
||||
const ConfiguredArrowUtil = ArrowShapeUtil.configure({
|
||||
showTextOutline: false,
|
||||
hoverPreciseTimeout: 400,
|
||||
})
|
||||
|
||||
const ConfiguredFrameUtil = FrameShapeUtil.configure({
|
||||
showColors: true,
|
||||
})
|
||||
|
||||
const ConfiguredNoteUtil = NoteShapeUtil.configure({
|
||||
resizeMode: 'scale',
|
||||
})
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0 }}>
|
||||
<Tldraw shapeUtils={[ConfiguredArrowUtil, ConfiguredFrameUtil, ConfiguredNoteUtil]} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
You can also extend shape utilities to add custom behavior beyond configuration options. See the [custom shapes](/docs/shapes) guide for details.
|
||||
|
||||
## Related examples
|
||||
|
||||
- [Custom shape](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/shapes/tools/custom-shape): Create a custom shape utility to understand how default shapes are implemented.
|
||||
- [Custom styles](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/shapes/tools/shape-with-custom-styles): Add custom style properties similar to those used by default shapes.
|
||||
- [Shape options](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/configuration/configure-shape-util): Configure shape utility behavior using the configure() method.
|
||||
265
apps/docs/content/sdk-features/drag-and-drop.mdx
Normal file
265
apps/docs/content/sdk-features/drag-and-drop.mdx
Normal file
@@ -0,0 +1,265 @@
|
||||
---
|
||||
title: Drag and drop
|
||||
created_at: 2/4/2026
|
||||
updated_at: 2/4/2026
|
||||
keywords:
|
||||
- drag
|
||||
- drop
|
||||
- reparent
|
||||
- container
|
||||
- frame
|
||||
status: published
|
||||
date: 2/4/2026
|
||||
---
|
||||
|
||||
The drag and drop system lets shapes respond when other shapes are dragged over them. Frames use this to reparent shapes when you drag them inside. You can use the same callbacks in your [ShapeUtil](?) to build custom container shapes or any shape that should react to shapes being dragged over it.
|
||||
|
||||
If you want frame-like behavior (accept children, reparent them on drag in and out, clip them), extend [BaseFrameLikeShapeUtil](?), which implements all of the callbacks below. Implement them yourself when you need something different.
|
||||
|
||||
## Drag callbacks
|
||||
|
||||
While the select tool translates shapes, it tracks which shape (if any) is under the cursor using [Editor#getDraggingOverShape](?). Your shape util can implement these callbacks to respond:
|
||||
|
||||
| Callback | When it fires |
|
||||
| ------------------------------- | --------------------------------------------------------------------------------- |
|
||||
| [ShapeUtil#onDragShapesIn](?) | Shapes are first dragged over this shape |
|
||||
| [ShapeUtil#onDragShapesOver](?) | Shapes continue being dragged over this shape (on an interval, when cursor moves) |
|
||||
| [ShapeUtil#onDragShapesOut](?) | Shapes are dragged away from this shape |
|
||||
| [ShapeUtil#onDropShapesOver](?) | Shapes are dropped onto this shape |
|
||||
|
||||
The callbacks receive the target shape (the one being dragged over), an array of the shapes being dragged, and an info object with context about the drag operation. Locked shapes are never dragged, and if every child of a group is being dragged, the group is dragged instead of its children.
|
||||
|
||||
### onDragShapesIn
|
||||
|
||||
Called once when shapes first enter this shape's bounds. Use it to reparent shapes into a container:
|
||||
|
||||
```typescript
|
||||
override onDragShapesIn(shape: MyContainerShape, draggingShapes: TLShape[]) {
|
||||
// Only reparent if not already a child
|
||||
const newShapes = draggingShapes.filter((s) => s.parentId !== shape.id)
|
||||
if (newShapes.length > 0) {
|
||||
this.editor.reparentShapes(newShapes, shape.id)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### onDragShapesOver
|
||||
|
||||
Called on an interval while shapes are being dragged over this shape, but only when the cursor moves. Use it for visual feedback or custom snapping:
|
||||
|
||||
```typescript
|
||||
override onDragShapesOver(shape: MyGridShape, draggingShapes: TLShape[]) {
|
||||
// Snap shapes to grid cells while dragging
|
||||
for (const dragging of draggingShapes) {
|
||||
const snappedX = Math.round(dragging.x / CELL_SIZE) * CELL_SIZE
|
||||
const snappedY = Math.round(dragging.y / CELL_SIZE) * CELL_SIZE
|
||||
|
||||
if (dragging.x !== snappedX || dragging.y !== snappedY) {
|
||||
this.editor.updateShape({
|
||||
id: dragging.id,
|
||||
type: dragging.type,
|
||||
x: snappedX,
|
||||
y: snappedY,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### onDragShapesOut
|
||||
|
||||
Called when shapes are dragged away from this shape, including when the drag moves into another shape. Use it to reparent shapes back to the page:
|
||||
|
||||
```typescript
|
||||
override onDragShapesOut(
|
||||
shape: MyContainerShape,
|
||||
draggingShapes: TLShape[],
|
||||
info: TLDragShapesOutInfo
|
||||
) {
|
||||
// Check if we're dragging into another container
|
||||
if (info.nextDraggingOverShapeId) {
|
||||
// Let the next container handle it
|
||||
return
|
||||
}
|
||||
|
||||
// Reparent back to the page
|
||||
const children = draggingShapes.filter((s) => s.parentId === shape.id)
|
||||
if (children.length > 0) {
|
||||
this.editor.reparentShapes(children, this.editor.getCurrentPageId())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### onDropShapesOver
|
||||
|
||||
Called when shapes are dropped (mouse up) while over this shape. Use it for finalization logic:
|
||||
|
||||
```typescript
|
||||
override onDropShapesOver(shape: MySlotShape, draggingShapes: TLShape[]) {
|
||||
// Lock shapes in place after dropping
|
||||
for (const dragging of draggingShapes) {
|
||||
this.editor.updateShape({
|
||||
id: dragging.id,
|
||||
type: dragging.type,
|
||||
isLocked: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## The info object
|
||||
|
||||
All drag callbacks receive an info object with context about the drag operation. The exact type varies by callback:
|
||||
|
||||
| Callback | Info type |
|
||||
| ------------------ | ------------------------- |
|
||||
| `onDragShapesIn` | [TLDragShapesInInfo](?) |
|
||||
| `onDragShapesOver` | [TLDragShapesOverInfo](?) |
|
||||
| `onDragShapesOut` | [TLDragShapesOutInfo](?) |
|
||||
| `onDropShapesOver` | [TLDropShapesOverInfo](?) |
|
||||
|
||||
All info types share these common properties:
|
||||
|
||||
| Property | Description |
|
||||
| ---------------------------- | ----------------------------------------------------- |
|
||||
| `initialDraggingOverShapeId` | The shape that was under the cursor when drag started |
|
||||
| `initialParentIds` | Map of each shape's parent ID at drag start |
|
||||
| `initialIndices` | Map of each shape's z-index at drag start |
|
||||
|
||||
Some callbacks have additional properties:
|
||||
|
||||
| Property | Available in | Description |
|
||||
| ------------------------- | ----------------- | ---------------------------------------- |
|
||||
| `prevDraggingOverShapeId` | `onDragShapesIn` | The previous shape that was dragged over |
|
||||
| `nextDraggingOverShapeId` | `onDragShapesOut` | The next shape being dragged into |
|
||||
|
||||
The `initialParentIds` and `initialIndices` maps let you restore shapes to their original positions. Frames use them to preserve z-ordering when shapes are dragged back to their original parent.
|
||||
|
||||
## Determining what's under the cursor
|
||||
|
||||
[Editor#getDraggingOverShape](?) determines which shape is being dragged over by checking which shape's geometry contains the cursor point. It tests shapes from front to back, skipping locked shapes, hidden shapes, and the shapes being dragged, and returns the first hit.
|
||||
|
||||
Only shapes that implement drag callbacks are considered as drop targets. If your shape util doesn't override any drag callbacks, shapes will pass through it when being dragged. While shapes are dragged over a target that accepts them, the editor hints the target with [Editor#setHintingShapes](?).
|
||||
|
||||
## Controlling which shapes can be dropped
|
||||
|
||||
Use the [ShapeUtil#canReceiveNewChildrenOfType](?) method to control which shape types your container accepts. The editor uses it to decide whether `onDragShapesIn` and `onDropShapesOver` fire for a dragged shape:
|
||||
|
||||
```typescript
|
||||
override canReceiveNewChildrenOfType(shape: MyContainerShape, type: TLShape['type']) {
|
||||
// Only accept specific shape types
|
||||
return type === 'my-item' || type === 'geo'
|
||||
}
|
||||
```
|
||||
|
||||
The default is `false`, so any shape that should accept dropped children must override this method. `onDragShapesOver` is not gated by this method, which lets you provide visual feedback even when the target won't accept the drop.
|
||||
|
||||
## Controlling which shapes can be dragged out
|
||||
|
||||
Use the [ShapeUtil#canRemoveChildrenOfType](?) method to control which child shape types can be dragged out of your container. The editor uses it to decide whether `onDragShapesOut` fires for a child shape, and to decide whether to automatically [reparent](/sdk-features/parenting) a child that has moved outside its parent's geometry:
|
||||
|
||||
```typescript
|
||||
override canRemoveChildrenOfType(shape: MyContainerShape, type: TLShape['type']) {
|
||||
// Pin certain children in place; allow others to be removed
|
||||
return type !== 'pinned-item'
|
||||
}
|
||||
```
|
||||
|
||||
The default is `true`, so children can be dragged out of any container unless this method is overridden.
|
||||
|
||||
## Example: slot container
|
||||
|
||||
Here's a complete example of a slot-based container that accepts dropped shapes:
|
||||
|
||||
```tsx
|
||||
import {
|
||||
HTMLContainer,
|
||||
Rectangle2d,
|
||||
ShapeUtil,
|
||||
TLBaseShape,
|
||||
TLDragShapesOutInfo,
|
||||
TLShape,
|
||||
T,
|
||||
} from 'tldraw'
|
||||
|
||||
type SlotContainerShape = TLBaseShape<'slot-container', { slots: number }>
|
||||
|
||||
class SlotContainerShapeUtil extends ShapeUtil<SlotContainerShape> {
|
||||
static override type = 'slot-container' as const
|
||||
static override props = { slots: T.number }
|
||||
|
||||
getDefaultProps() {
|
||||
return { slots: 4 }
|
||||
}
|
||||
|
||||
getGeometry(shape: SlotContainerShape) {
|
||||
return new Rectangle2d({
|
||||
width: shape.props.slots * 100,
|
||||
height: 100,
|
||||
isFilled: true,
|
||||
})
|
||||
}
|
||||
|
||||
override canReceiveNewChildrenOfType(_shape: SlotContainerShape, type: string) {
|
||||
return type === 'geo' || type === 'text'
|
||||
}
|
||||
|
||||
override onDragShapesIn(shape: SlotContainerShape, draggingShapes: TLShape[]) {
|
||||
const newShapes = draggingShapes.filter((s) => s.parentId !== shape.id)
|
||||
if (newShapes.length > 0) {
|
||||
this.editor.reparentShapes(newShapes, shape.id)
|
||||
}
|
||||
}
|
||||
|
||||
override onDragShapesOut(
|
||||
shape: SlotContainerShape,
|
||||
draggingShapes: TLShape[],
|
||||
info: TLDragShapesOutInfo
|
||||
) {
|
||||
if (!info.nextDraggingOverShapeId) {
|
||||
const children = draggingShapes.filter((s) => s.parentId === shape.id)
|
||||
this.editor.reparentShapes(children, this.editor.getCurrentPageId())
|
||||
}
|
||||
}
|
||||
|
||||
component(shape: SlotContainerShape) {
|
||||
return (
|
||||
<HTMLContainer
|
||||
style={{
|
||||
backgroundColor: '#f0f0f0',
|
||||
border: '2px dashed #ccc',
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `repeat(${shape.props.slots}, 100px)`,
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: shape.props.slots }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
width: 100,
|
||||
height: 100,
|
||||
borderRight: i < shape.props.slots - 1 ? '1px dashed #ccc' : undefined,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</HTMLContainer>
|
||||
)
|
||||
}
|
||||
|
||||
getIndicatorPath(shape: SlotContainerShape) {
|
||||
const path = new Path2D()
|
||||
path.rect(0, 0, shape.props.slots * 100, 100)
|
||||
return path
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Related examples
|
||||
|
||||
- [Drag and drop](/examples/shapes/tools/drag-and-drop) - Custom shapes that can be dragged onto each other.
|
||||
- [Drag and drop tray](/examples/ui/drag-and-drop-tray) - Drag items from a custom UI into the canvas.
|
||||
|
||||
## Related docs
|
||||
|
||||
- [External content handling](/sdk-features/external-content) - Handle files, URLs, and other content dragged from outside the browser. The callbacks on this page are for shape-to-shape drag and drop within the canvas.
|
||||
- [Parenting and ancestors](/sdk-features/parenting) - Learn about parent-child relationships and the [Editor#reparentShapes](?) method.
|
||||
209
apps/docs/content/sdk-features/draw-shape.mdx
Normal file
209
apps/docs/content/sdk-features/draw-shape.mdx
Normal file
@@ -0,0 +1,209 @@
|
||||
---
|
||||
title: Draw shape
|
||||
created_at: 1/31/2026
|
||||
updated_at: 1/31/2026
|
||||
keywords:
|
||||
- draw
|
||||
- freehand
|
||||
- pen
|
||||
- stylus
|
||||
- pressure
|
||||
- drawing
|
||||
- sketch
|
||||
- stroke
|
||||
- straight line
|
||||
- shift
|
||||
- angle snap
|
||||
status: published
|
||||
date: 1/31/2026
|
||||
---
|
||||
|
||||
The draw shape captures freehand strokes and straight line segments. The draw tool produces pressure-sensitive strokes from pens and styluses, snaps straight lines to 15° angles when you hold Shift, and closes shapes automatically when a stroke ends near its start. Its keyboard shortcuts are D, B, and X, and holding Ctrl (or Cmd) while pressing down switches temporarily to the eraser.
|
||||
|
||||
## Drawing modes
|
||||
|
||||
The draw tool supports two segment types that you can switch between while drawing:
|
||||
|
||||
| Mode | Trigger | Behavior |
|
||||
| -------- | ------------------------ | ---------------------------------------------------------------------------- |
|
||||
| Freehand | Default | Captures natural hand motion with optional pressure sensitivity |
|
||||
| Straight | Hold Shift while drawing | Creates straight line segments that snap to 15° angle increments (see below) |
|
||||
|
||||
You can mix both modes in a single stroke. Start drawing freehand, then hold Shift to switch to straight lines. Release Shift to return to freehand. Each mode change creates a new segment in the shape.
|
||||
|
||||
### Freehand drawing
|
||||
|
||||
Freehand mode captures your natural hand motion. The tool records points as you drag and interpolates them into smooth curves. When you draw with a pen or stylus, the tool captures pressure data and produces variable-width strokes.
|
||||
|
||||
The stroke appearance depends on the dash style. The `draw` style uses the freehand algorithm to create hand-drawn strokes with natural width variation. The `solid`, `dashed`, and `dotted` styles render uniform-width strokes.
|
||||
|
||||
### Straight line mode
|
||||
|
||||
Hold Shift while drawing to create straight line segments. The line snaps to 15° angle increments (24 divisions of a full circle) relative to the previous point, which covers horizontal and vertical lines, 45° diagonals, and the 30° and 60° angles used in isometric drawings. Hold Ctrl to disable angle snapping temporarily.
|
||||
|
||||
Release Shift to continue with freehand drawing from the current endpoint. The transition creates a smooth connection between the straight segment and the freehand stroke.
|
||||
|
||||
### Extending previous strokes
|
||||
|
||||
If you've already drawn a stroke and want to continue from it, hold Shift and click to connect. The draw tool creates a straight line segment from the previous stroke's endpoint to your click position. Continue holding Shift and drag to extend with more straight segments, or release Shift to switch to freehand.
|
||||
|
||||
This connect-the-dots behavior only activates when you Shift+click after completing a previous stroke with the same draw tool session. It won't connect across different shapes or after switching tools.
|
||||
|
||||
## Pen and stylus support
|
||||
|
||||
The draw tool distinguishes between mouse/touch input and pen/stylus input. When it detects a pen or stylus, it enables pressure-sensitive rendering:
|
||||
|
||||
| Input type | Pressure behavior |
|
||||
| ----------- | ------------------------------------------------------------------ |
|
||||
| Mouse/touch | Simulates pressure based on velocity—faster strokes appear thinner |
|
||||
| Pen/stylus | Uses actual pressure data for variable stroke width |
|
||||
|
||||
The tool treats input as a pen when the browser reports a pen pointer with non-zero pressure, or when the pressure value is strictly between 0 and 0.5 or between 0.5 and 1. Mice report exactly 0.5.
|
||||
|
||||
The shape stores pen detection in the `isPen` property. This affects how the stroke renders—pen strokes use a different stroke profile optimized for real pressure data.
|
||||
|
||||
## Automatic shape closing
|
||||
|
||||
Draw shapes can automatically close when you bring the endpoint near the starting point. This creates filled shapes when combined with a fill style other than "none".
|
||||
|
||||
The shape closes when:
|
||||
|
||||
- The path length exceeds 4× the scaled stroke width
|
||||
- The endpoint is within a small distance of the starting point (roughly the stroke width plus a margin, boosted at low zoom levels)
|
||||
|
||||
When a shape closes, the shape sets `isClosed` to true and fills according to its fill style. Highlight shapes don't support closing.
|
||||
|
||||
## Line snapping
|
||||
|
||||
When drawing straight lines with Shift held, you can snap to previous segments in the current stroke. This helps create precise geometric constructions:
|
||||
|
||||
- Enable snap mode in user preferences, or hold Ctrl (when snap mode is disabled) to temporarily enable snapping
|
||||
- Hold Ctrl (when snap mode is enabled) to temporarily disable snapping
|
||||
- The tool snaps to the nearest point on earlier straight segments (excluding the current and previous segment) within 8 screen pixels
|
||||
|
||||
Visual snap indicators appear when snapping is active.
|
||||
|
||||
## Dynamic resize mode
|
||||
|
||||
When dynamic resize mode is enabled in user preferences, new draw shapes scale inversely with zoom level. Drawing while zoomed out creates shapes that appear the same size on screen as they would at 100% zoom. The shape's `scale` property stores this adjustment.
|
||||
|
||||
Access dynamic resize mode through [Editor#user](?):
|
||||
|
||||
```typescript
|
||||
// Check current mode
|
||||
const isDynamic = editor.user.getIsDynamicResizeMode()
|
||||
|
||||
// Enable dynamic resize mode
|
||||
editor.user.updateUserPreferences({ isDynamicSizeMode: true })
|
||||
```
|
||||
|
||||
## Shape properties
|
||||
|
||||
Draw shapes store their path data in a delta-encoded base64 format. Segments from pens and styluses store x, y, and z (pressure): the first point uses full Float32 precision (12 bytes) and each subsequent point is a Float16 delta (6 bytes). Segments from mice and touch input drop the constant pressure value and store only x and y, marked with `dim: 2` (8 bytes for the first point, 4 bytes per delta).
|
||||
|
||||
| Property | Type | Description |
|
||||
| ------------ | ---------------------- | ------------------------------------------------------------------------ |
|
||||
| `color` | `TLDefaultColorStyle` | Stroke color |
|
||||
| `fill` | `TLDefaultFillStyle` | Fill style (applies when `isClosed` is true) |
|
||||
| `dash` | `TLDefaultDashStyle` | Stroke pattern: `draw`, `solid`, `dashed`, `dotted`, `none` |
|
||||
| `size` | `TLDefaultSizeStyle` | Stroke width preset: `s`, `m`, `l`, `xl` |
|
||||
| `segments` | `TLDrawShapeSegment[]` | Array of segments with `type`, base64-encoded `path`, and optional `dim` |
|
||||
| `isComplete` | `boolean` | Whether the user has finished drawing this stroke |
|
||||
| `isClosed` | `boolean` | Whether the path forms a closed shape |
|
||||
| `isPen` | `boolean` | Whether drawn with a stylus (enables pressure-based width) |
|
||||
| `scale` | `number` | Scale factor applied to the shape |
|
||||
| `scaleX` | `number` | Horizontal scale factor for lazy resize |
|
||||
| `scaleY` | `number` | Vertical scale factor for lazy resize |
|
||||
|
||||
Each segment has a `type` of `'free'` or `'straight'`, a `path` containing the encoded points, and an optional `dim` of `2` (x and y only) or `3` (x, y, and pressure; the default when omitted). Resizing a draw shape doesn't re-encode its points; it updates `scaleX` and `scaleY` instead.
|
||||
|
||||
## Configuration options
|
||||
|
||||
Configure [DrawShapeUtil](?) to adjust behavior:
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
| ------------------- | -------- | ------- | -------------------------------------------------------- |
|
||||
| `maxPointsPerShape` | `number` | `600` | Maximum points before automatically starting a new shape |
|
||||
|
||||
```tsx
|
||||
import { DrawShapeUtil } from 'tldraw'
|
||||
|
||||
const ConfiguredDrawUtil = DrawShapeUtil.configure({
|
||||
maxPointsPerShape: 1000,
|
||||
})
|
||||
```
|
||||
|
||||
When a stroke exceeds the maximum point count, the draw tool completes the current shape and creates a new one at the current position. This prevents performance issues with very long strokes.
|
||||
|
||||
## Creating draw shapes programmatically
|
||||
|
||||
To create a draw shape through the editor API, you need to encode the point data:
|
||||
|
||||
```tsx
|
||||
import { b64Vecs, createShapeId } from 'tldraw'
|
||||
|
||||
// Define your points with x, y, and z (pressure)
|
||||
const points = [
|
||||
{ x: 0, y: 0, z: 0.5 },
|
||||
{ x: 50, y: 30, z: 0.5 },
|
||||
{ x: 100, y: 10, z: 0.5 },
|
||||
]
|
||||
|
||||
editor.createShape({
|
||||
id: createShapeId(),
|
||||
type: 'draw',
|
||||
x: 100,
|
||||
y: 100,
|
||||
props: {
|
||||
color: 'black',
|
||||
fill: 'none',
|
||||
dash: 'draw',
|
||||
size: 'm',
|
||||
segments: [
|
||||
{
|
||||
type: 'free',
|
||||
path: b64Vecs.encodePoints(points),
|
||||
},
|
||||
],
|
||||
isComplete: true,
|
||||
isClosed: false,
|
||||
isPen: false,
|
||||
scale: 1,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
[b64Vecs](?)`.encodePoints` converts an array of point objects to the delta-encoded base64 format; pass `2` as the second argument to store x and y only. Use `b64Vecs.decodePoints(segment.path, segment.dim)` to read points back.
|
||||
|
||||
## Stroke rendering
|
||||
|
||||
The draw shape uses a freehand stroke algorithm to render organic-looking lines. Streamlining reduces jitter by interpolating each new point toward the previous one, smoothing rounds the outline, and thinning varies stroke width by velocity (for mouse) or pressure (for pen).
|
||||
|
||||
When `dash` is set to `'draw'`, the shape renders using the full freehand algorithm. Other dash styles use simpler uniform-width strokes with the appropriate dash pattern.
|
||||
|
||||
At low zoom levels, the shape switches to solid rendering for performance. This happens when the zoom level is below 50% and also below a threshold based on the scaled stroke width.
|
||||
|
||||
## Geometry
|
||||
|
||||
The shape's geometry depends on its content:
|
||||
|
||||
| Content | Geometry |
|
||||
| -------------------------------- | ---------------------------------------------------------------- |
|
||||
| Tiny single-segment stroke (dot) | A [Circle2d](?) with a radius of roughly the scaled stroke width |
|
||||
| Closed path | A [Polygon2d](?) that can be filled |
|
||||
| Open path | A [Polyline2d](?) following the stroke's center line |
|
||||
|
||||
The geometry uses the processed stroke points (after applying streamline and smoothing), not the raw input points.
|
||||
|
||||
## Related shapes
|
||||
|
||||
- [Highlight](/sdk-features/default-shapes#Highlight) uses the same point capture system but renders semi-transparently for marking up content
|
||||
- [Line](/sdk-features/default-shapes#Line) creates editable multi-point lines with draggable handles
|
||||
|
||||
## Related articles
|
||||
|
||||
- [Default shapes](/sdk-features/default-shapes) — Overview of all built-in shapes
|
||||
- [Tools](/sdk-features/tools) — How tools handle user input
|
||||
- [Styles](/sdk-features/styles) — Working with shape styles like color and size
|
||||
96
apps/docs/content/sdk-features/edge-scrolling.mdx
Normal file
96
apps/docs/content/sdk-features/edge-scrolling.mdx
Normal file
@@ -0,0 +1,96 @@
|
||||
---
|
||||
title: Edge scrolling
|
||||
created_at: 12/20/2025
|
||||
updated_at: 12/29/2025
|
||||
keywords:
|
||||
- scroll
|
||||
- edge
|
||||
- auto-scroll
|
||||
- EdgeScrollManager
|
||||
- drag
|
||||
status: published
|
||||
date: 12/20/2025
|
||||
readability: 9
|
||||
voice: 8
|
||||
completeness: 9
|
||||
accuracy: 9
|
||||
notes: Could be more code-first in technical sections. Mention getIsEdgeScrolling() earlier for UI use cases.
|
||||
---
|
||||
|
||||
Edge scrolling pans the camera automatically when you drag shapes toward the viewport edges. This lets you move shapes across the canvas without releasing the drag to scroll manually.
|
||||
|
||||
## How it works
|
||||
|
||||
Tool states that support edge scrolling call [EdgeScrollManager#updateEdgeScrolling](?) on every tick while the user is dragging. The manager reads the pointer position from the editor's inputs, works out how close it is to each viewport edge, and moves the camera. It does nothing while the camera is locked.
|
||||
|
||||
The built-in select tool does this in its Translating (moving shapes), Brushing (selection box), and Resizing (dragging handles) states. The tool state, not the manager, decides when edge scrolling applies: the built-in states skip the call unless `editor.inputs.getIsDragging()` is true and `editor.inputs.getIsPanning()` is false.
|
||||
|
||||
## Tool integration
|
||||
|
||||
To add edge scrolling to a custom tool, call `updateEdgeScrolling()` from the tick handler of the state where dragging happens, and only while the user is dragging:
|
||||
|
||||
```typescript
|
||||
import { StateNode, TLTickEventInfo } from 'tldraw'
|
||||
|
||||
export class CustomDragState extends StateNode {
|
||||
static override id = 'dragging'
|
||||
|
||||
override onTick({ elapsed }: TLTickEventInfo) {
|
||||
const { editor } = this
|
||||
if (!editor.inputs.getIsDragging() || editor.inputs.getIsPanning()) return
|
||||
editor.edgeScrollManager.updateEdgeScrolling(elapsed)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
[EdgeScrollManager#getIsEdgeScrolling](?) returns true while the pointer is inside the edge zone, including during the start delay, and false once it leaves. Use it to show an indicator in your own UI.
|
||||
|
||||
## Configuration options
|
||||
|
||||
Customize edge scrolling through [TldrawOptions](?). Set `edgeScrollSpeed` to `0` to turn it off entirely.
|
||||
|
||||
| Option | Default | Description |
|
||||
| ------------------------ | ------- | ----------------------------------------------------------------------- |
|
||||
| `edgeScrollDelay` | 200 | Milliseconds the pointer must stay in the edge zone before scrolling |
|
||||
| `edgeScrollEaseDuration` | 200 | Milliseconds to ramp up to full speed after the delay |
|
||||
| `edgeScrollSpeed` | 25 | Base scroll speed in pixels per tick |
|
||||
| `edgeScrollDistance` | 8 | Width of the edge scroll zone in pixels |
|
||||
| `coarsePointerWidth` | 12 | Extra pointer width on each side for coarse (touch) pointers, in pixels |
|
||||
|
||||
Set these options when creating the editor:
|
||||
|
||||
```tsx
|
||||
import { Tldraw } from 'tldraw'
|
||||
import 'tldraw/tldraw.css'
|
||||
|
||||
const options = {
|
||||
edgeScrollSpeed: 50, // Double the default speed
|
||||
edgeScrollDelay: 100, // Start scrolling sooner
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0 }}>
|
||||
<Tldraw options={options} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### User preferences
|
||||
|
||||
Edge scroll speed is also a user preference. `editor.user.getEdgeScrollSpeed()` returns a multiplier that defaults to 1, and you can change it with `editor.user.updateUserPreferences({ edgeScrollSpeed: 2 })`. The preference persists across sessions and scales all edge scrolling without changing the base configuration.
|
||||
|
||||
## Edge detection and speed
|
||||
|
||||
The edge zone extends inward from each edge of the viewport by `edgeScrollDistance`. Inside it, the manager computes a signed proximity factor per axis between -1 and 1: 0 at the zone boundary, ±1 at the screen edge or beyond, with the sign giving the scroll direction. For coarse pointers (`isCoarsePointer` in the instance state) the pointer is widened by `coarsePointerWidth` on each side, so touch reaches the zone sooner.
|
||||
|
||||
Edge detection respects the instance state's `insets` array, in CSS order `[top, right, bottom, left]`. An entry is `true` when that edge of the editor is inset from the browser window rather than flush with it. For a flush edge the pointer can't move past the window, so the zone extends inward from the edge. For an inset edge the zone starts at the editor's boundary instead, and scrolling begins once the pointer moves outside the editor.
|
||||
|
||||
Once the pointer enters the zone, the manager waits `edgeScrollDelay` before moving the camera, which prevents accidental scrolling when the pointer briefly crosses the edge. It then ramps up over `edgeScrollEaseDuration` using `EASINGS.easeInCubic`. Leaving the zone stops scrolling and resets the timer.
|
||||
|
||||
The scroll delta per tick is `edgeScrollSpeed` multiplied by the user's edge scroll speed preference, the proximity factor, and a 0.612 factor on any axis where the viewport is narrower than 1000 pixels. It is divided by the zoom level so canvas-space velocity stays constant across zoom levels.
|
||||
|
||||
## Related examples
|
||||
|
||||
See the [custom tool](/examples/shapes/tools/custom-tool) example for building tools that can implement edge scrolling. For complex tools with multiple states, see the [tool with child states](/examples/shapes/tools/tool-with-child-states) example.
|
||||
464
apps/docs/content/sdk-features/editor.mdx
Normal file
464
apps/docs/content/sdk-features/editor.mdx
Normal file
@@ -0,0 +1,464 @@
|
||||
---
|
||||
title: Editor
|
||||
created_at: 12/29/2025
|
||||
updated_at: 12/29/2025
|
||||
keywords:
|
||||
- editor
|
||||
- api
|
||||
- architecture
|
||||
- state
|
||||
- manager
|
||||
status: published
|
||||
date: 12/29/2025
|
||||
readability: 9
|
||||
voice: 9
|
||||
completeness: 9
|
||||
accuracy: 10
|
||||
notes: ''
|
||||
---
|
||||
|
||||
The [Editor](?) class is the main way of controlling tldraw's editor. It has methods for creating, reading, updating, and deleting shapes, for managing selection and history, for controlling the camera, and for responding to user input. By design, the editor's surface area is very large: almost everything is available through it.
|
||||
|
||||
Need to create some shapes? Use [Editor#createShapes](?). Need to delete them? Use [Editor#deleteShapes](?). Want a sorted array of every shape on the current page? Use [Editor#getCurrentPageShapesSorted](?). The editor is your primary interface for interacting with the canvas.
|
||||
|
||||
## Accessing the editor
|
||||
|
||||
You can access the editor in two ways:
|
||||
|
||||
1. From the [Tldraw](?) component's `onMount` callback:
|
||||
|
||||
```tsx
|
||||
import { Tldraw } from 'tldraw'
|
||||
import 'tldraw/tldraw.css'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0 }}>
|
||||
<Tldraw
|
||||
onMount={(editor) => {
|
||||
// Your editor code here
|
||||
editor.createShape({ type: 'geo', x: 100, y: 100 })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
2. Via the [useEditor](?) hook, which must be called from within the [Tldraw](?) component tree:
|
||||
|
||||
```tsx
|
||||
function InsideOfContext() {
|
||||
const editor = useEditor()
|
||||
// Your editor code here
|
||||
return null
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Tldraw>
|
||||
<InsideOfContext />
|
||||
</Tldraw>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Architecture overview
|
||||
|
||||
The editor coordinates several interconnected systems.
|
||||
|
||||
### Store
|
||||
|
||||
The editor holds document data in its [Editor#store](?) property. The store is a reactive database containing records for shapes, pages, bindings, assets, and editor state. All records are JSON-serializable.
|
||||
|
||||
```ts
|
||||
// Access a shape record directly from the store
|
||||
const shape = editor.store.get(shapeId)
|
||||
|
||||
// Listen to store changes
|
||||
editor.store.listen((entry) => {
|
||||
console.log('Changed:', entry.changes)
|
||||
})
|
||||
```
|
||||
|
||||
The store is reactive: when data changes, the UI updates automatically. The editor wraps the store with higher-level methods like `createShapes()` and `deleteShapes()`, so you rarely need to interact with it directly.
|
||||
|
||||
See [Store](/sdk-features/store) for details on working with the store directly.
|
||||
|
||||
### Signals
|
||||
|
||||
Tldraw uses a signals-based reactive system. The editor exposes many of its internal values as signals—methods like `editor.getSelectedShapeIds()` and `editor.getCurrentPageShapes()` return reactive values that update automatically when the underlying state changes.
|
||||
|
||||
```tsx
|
||||
import { track, useEditor } from 'tldraw'
|
||||
|
||||
const SelectedCount = track(function SelectedCount() {
|
||||
const editor = useEditor()
|
||||
return <div>{editor.getSelectedShapeIds().length} shapes selected</div>
|
||||
})
|
||||
```
|
||||
|
||||
The `track` higher-order component automatically subscribes to signals accessed during render. When those signals change, the component re-renders.
|
||||
|
||||
See [Signals](/sdk-features/signals) for the full reactive API.
|
||||
|
||||
### State chart
|
||||
|
||||
The editor uses a hierarchical state machine for handling user interactions. Tools like the select tool, draw tool, and hand tool are implemented as [StateNode](?) instances. Each node can have child states, and the active state determines how the editor responds to events.
|
||||
|
||||
```ts
|
||||
// Change the current tool
|
||||
editor.setCurrentTool('draw')
|
||||
editor.setCurrentTool('hand')
|
||||
|
||||
// Check the current tool
|
||||
editor.getCurrentToolId() // 'select', 'draw', 'hand', etc.
|
||||
|
||||
// Check the full state path
|
||||
editor.root.getPath() // 'root.select.idle', 'root.draw.drawing', etc.
|
||||
|
||||
// Check if a state is active
|
||||
editor.isIn('select') // true if select tool is active
|
||||
editor.isIn('select.idle') // true if in idle state of select tool
|
||||
|
||||
// Check if any of several states are active
|
||||
editor.isInAny('select.idle', 'hand.idle') // true if in either state
|
||||
```
|
||||
|
||||
#### State transitions
|
||||
|
||||
Tools transition between child states as the user interacts. For example, the select tool has states like `idle`, `pointing_canvas`, `pointing_shape`, `brushing`, and `translating`. When you click and drag on empty canvas, the state flows like this:
|
||||
|
||||
1. `select.idle` — waiting for input
|
||||
2. `select.pointing_canvas` — pointer down on the canvas, waiting to see if this is a click or drag
|
||||
3. `select.brushing` — dragging to create a selection box
|
||||
4. `select.idle` — pointer up, back to waiting
|
||||
|
||||
Each state handles events differently. The `idle` state responds to `pointer_down` on the canvas by transitioning to `pointing_canvas`. The `brushing` state updates the brush bounds on `pointer_move` and completes the selection on `pointer_up`.
|
||||
|
||||
#### Event flow
|
||||
|
||||
Events flow from the root state down through active children: `root` → `select` → `idle`. Each state runs its own handler first, then passes the event to its active child. If a handler transitions to a different child, the new child does not receive the same event.
|
||||
|
||||
States define handlers for events like `onPointerDown`, `onPointerMove`, `onKeyDown`, and `onEnter`/`onExit` for state transitions. The active state chain determines which handlers run.
|
||||
|
||||
See [Tools](/sdk-features/tools) for building custom tools.
|
||||
|
||||
### Managers
|
||||
|
||||
The editor delegates specialized functionality to manager classes:
|
||||
|
||||
| Manager | Responsibility |
|
||||
| --------------------------- | ------------------------------------------------ |
|
||||
| [HistoryManager](?) | Undo/redo stack and history marks |
|
||||
| [SnapManager](?) | Shape snapping during transforms |
|
||||
| [TextManager](?) | Text measurement and layout |
|
||||
| [FontManager](?) | Font loading and management |
|
||||
| [InputsManager](?) | Pointer position and modifier key tracking |
|
||||
| [ClickManager](?) | Click, double-click, and long-press detection |
|
||||
| [ScribbleManager](?) | Scribble trails (eraser, laser, scribble select) |
|
||||
| [EdgeScrollManager](?) | Auto-scroll at viewport edges |
|
||||
| [UserPreferencesManager](?) | User settings persistence |
|
||||
|
||||
Access managers through properties on the editor instance:
|
||||
|
||||
```ts
|
||||
editor.snaps.getIndicators()
|
||||
editor.inputs.getCurrentPagePoint()
|
||||
editor.user.getUserPreferences()
|
||||
editor.scribbles.addScribble({ color: 'accent' })
|
||||
editor.edgeScrollManager.updateEdgeScrolling(elapsed)
|
||||
```
|
||||
|
||||
## Working with shapes
|
||||
|
||||
Shapes are the content on your canvas. Each shape has a type, position, rotation, and type-specific props.
|
||||
|
||||
### Creating shapes
|
||||
|
||||
```ts
|
||||
// Create a shape with auto-generated ID
|
||||
editor.createShape({
|
||||
type: 'geo',
|
||||
x: 100,
|
||||
y: 100,
|
||||
props: {
|
||||
geo: 'rectangle',
|
||||
w: 200,
|
||||
h: 150,
|
||||
color: 'blue',
|
||||
},
|
||||
})
|
||||
|
||||
// Create with a specific ID
|
||||
import { createShapeId } from 'tldraw'
|
||||
|
||||
const id = createShapeId('my-shape')
|
||||
editor.createShape({
|
||||
id,
|
||||
type: 'geo',
|
||||
x: 100,
|
||||
y: 100,
|
||||
})
|
||||
```
|
||||
|
||||
### Reading shapes
|
||||
|
||||
```ts
|
||||
// Get a shape by ID
|
||||
const shape = editor.getShape(shapeId)
|
||||
|
||||
// Get all shapes on the current page
|
||||
const shapes = editor.getCurrentPageShapes()
|
||||
|
||||
// Get selected shapes
|
||||
const selected = editor.getSelectedShapes()
|
||||
```
|
||||
|
||||
### Updating shapes
|
||||
|
||||
```ts
|
||||
editor.updateShape({
|
||||
id: shape.id,
|
||||
type: shape.type, // Required
|
||||
x: 200,
|
||||
props: {
|
||||
color: 'red',
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Deleting shapes
|
||||
|
||||
```ts
|
||||
// Delete by ID
|
||||
editor.deleteShapes([shapeId])
|
||||
|
||||
// Delete by shape record
|
||||
editor.deleteShapes([shape])
|
||||
```
|
||||
|
||||
See [Shapes](/sdk-features/shapes) for the complete shape system.
|
||||
|
||||
## Selection
|
||||
|
||||
The editor tracks which shapes are selected. Selection drives transform handles, copy/paste, and delete.
|
||||
|
||||
```ts
|
||||
// Select shapes
|
||||
editor.select(shapeId)
|
||||
editor.select(shapeId1, shapeId2)
|
||||
|
||||
// Add to selection
|
||||
editor.setSelectedShapes([...editor.getSelectedShapeIds(), newId])
|
||||
|
||||
// Clear selection
|
||||
editor.selectNone()
|
||||
|
||||
// Select all on current page
|
||||
editor.selectAll()
|
||||
|
||||
// Get selection
|
||||
editor.getSelectedShapeIds()
|
||||
editor.getSelectedShapes()
|
||||
```
|
||||
|
||||
See [Selection](/sdk-features/selection) for selection details.
|
||||
|
||||
## History
|
||||
|
||||
The editor maintains an undo/redo stack through its history manager. Changes to the store accumulate until you create a mark, which becomes an undo stopping point.
|
||||
|
||||
```ts
|
||||
// Mark before an operation
|
||||
editor.markHistoryStoppingPoint('rotate shapes')
|
||||
editor.rotateShapesBy(editor.getSelectedShapeIds(), Math.PI / 4)
|
||||
|
||||
// Undo returns to the mark
|
||||
editor.undo()
|
||||
|
||||
// Redo reapplies changes
|
||||
editor.redo()
|
||||
```
|
||||
|
||||
See [History](/sdk-features/history) for the full history API.
|
||||
|
||||
## Transactions
|
||||
|
||||
Use [Editor#run](?) to batch changes into a single transaction. This improves performance and reduces intermediate renders.
|
||||
|
||||
```ts
|
||||
editor.run(() => {
|
||||
editor.createShapes(shapes)
|
||||
editor.sendToBack(shapes)
|
||||
editor.selectNone()
|
||||
})
|
||||
```
|
||||
|
||||
The `run` method also accepts options for controlling history:
|
||||
|
||||
```ts
|
||||
// Ignore changes (don't add to undo stack)
|
||||
editor.run(
|
||||
() => {
|
||||
editor.updateShape({ id, type: 'geo', x: 100 })
|
||||
},
|
||||
{ history: 'ignore' }
|
||||
)
|
||||
|
||||
// Allow editing locked shapes
|
||||
editor.run(
|
||||
() => {
|
||||
editor.updateShapes(lockedShapes)
|
||||
},
|
||||
{ ignoreShapeLock: true }
|
||||
)
|
||||
```
|
||||
|
||||
See [History](/sdk-features/history) for more on how transactions interact with undo/redo.
|
||||
|
||||
## Camera and viewport
|
||||
|
||||
The editor manages a camera that determines which part of the infinite canvas is visible. The camera has x, y, and z (zoom) coordinates.
|
||||
|
||||
```ts
|
||||
// Move camera to specific coordinates
|
||||
editor.setCamera({ x: 0, y: 0, z: 1 })
|
||||
|
||||
// Zoom controls
|
||||
editor.zoomIn()
|
||||
editor.zoomOut()
|
||||
editor.resetZoom()
|
||||
|
||||
// Fit content in view
|
||||
editor.zoomToFit()
|
||||
editor.zoomToSelection()
|
||||
|
||||
// Center on a point
|
||||
editor.centerOnPoint({ x: 500, y: 500 })
|
||||
|
||||
// Lock the camera
|
||||
editor.setCameraOptions({ isLocked: true })
|
||||
```
|
||||
|
||||
The viewport is the visible area of the canvas. Get its bounds in screen or page coordinates:
|
||||
|
||||
```ts
|
||||
// Screen coordinates (component size)
|
||||
editor.getViewportScreenBounds()
|
||||
|
||||
// Page coordinates (what's visible on the canvas)
|
||||
editor.getViewportPageBounds()
|
||||
```
|
||||
|
||||
See [Camera](/sdk-features/camera) and [Coordinates](/sdk-features/coordinates) for the full camera API.
|
||||
|
||||
## Input state
|
||||
|
||||
The [Editor#inputs](?) object tracks the user's current input state: cursor position, pressed keys, and drag state. All values on the inputs object are reactive signals. When you read them inside a tracked component or computed, your code re-runs when they change.
|
||||
|
||||
```ts
|
||||
// Cursor position in page coordinates (reactive)
|
||||
editor.inputs.getCurrentPagePoint()
|
||||
|
||||
// Cursor position in screen coordinates (reactive)
|
||||
editor.inputs.getCurrentScreenPoint()
|
||||
|
||||
// Where the current drag started (reactive)
|
||||
editor.inputs.getOriginPagePoint()
|
||||
|
||||
// Interaction state (reactive)
|
||||
editor.inputs.getIsDragging()
|
||||
editor.inputs.getIsPointing()
|
||||
editor.inputs.getIsPinching()
|
||||
|
||||
// Modifier keys (reactive)
|
||||
editor.inputs.getShiftKey()
|
||||
editor.inputs.getCtrlKey()
|
||||
editor.inputs.getAltKey()
|
||||
```
|
||||
|
||||
See [Input handling](/sdk-features/input-handling) for input details.
|
||||
|
||||
## Instance state
|
||||
|
||||
The editor maintains per-instance state in a [TLInstance](?) record. This includes which page is current, whether the editor is in readonly mode, tool lock state, and UI state. See [Instance state](/sdk-features/instance-state) and [Readonly mode](/sdk-features/readonly).
|
||||
|
||||
```ts
|
||||
// Get instance state
|
||||
const instance = editor.getInstanceState()
|
||||
|
||||
// Update instance state
|
||||
editor.updateInstanceState({ isReadonly: true })
|
||||
|
||||
// Enable tool lock (keeps current tool active after creating shapes)
|
||||
editor.updateInstanceState({ isToolLocked: true })
|
||||
```
|
||||
|
||||
See [Tools](/sdk-features/tools#Tool-lock) for more on tool lock.
|
||||
|
||||
Each page also has instance state ([TLInstancePageState](?)) tracking selection, hovered shape, and editing shape for that page:
|
||||
|
||||
```ts
|
||||
// Get current page state
|
||||
const pageState = editor.getCurrentPageState()
|
||||
```
|
||||
|
||||
## User preferences
|
||||
|
||||
User preferences are shared across all editor instances. They control things like color scheme and locale.
|
||||
|
||||
```ts
|
||||
// Turn on dark mode
|
||||
editor.user.updateUserPreferences({ colorScheme: 'dark' })
|
||||
|
||||
// Use system color scheme
|
||||
editor.user.updateUserPreferences({ colorScheme: 'system' })
|
||||
|
||||
// Get current preferences
|
||||
editor.user.getUserPreferences()
|
||||
```
|
||||
|
||||
See [User preferences](/sdk-features/user-preferences) for all preference options.
|
||||
|
||||
## Side effects
|
||||
|
||||
Register callbacks to respond to record lifecycle events. Side effects let you maintain relationships, enforce constraints, or sync external state.
|
||||
|
||||
```ts
|
||||
// After a shape is created
|
||||
editor.sideEffects.registerAfterCreateHandler('shape', (shape) => {
|
||||
if (shape.type === 'arrow') {
|
||||
console.log('Arrow created:', shape.id)
|
||||
}
|
||||
})
|
||||
|
||||
// Before a shape is deleted
|
||||
editor.sideEffects.registerBeforeDeleteHandler('shape', (shape) => {
|
||||
// Return false to prevent deletion
|
||||
})
|
||||
```
|
||||
|
||||
See [Side effects](/sdk-features/side-effects) for the complete API.
|
||||
|
||||
## Events
|
||||
|
||||
The editor receives events through [Editor#dispatch](?). You typically don't call this directly—the canvas handles DOM events and dispatches them for you. But you can listen for events on the editor:
|
||||
|
||||
```ts
|
||||
editor.on('event', (info) => {
|
||||
if (info.name === 'pointer_down') {
|
||||
console.log('Pointer down at', info.point)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
See [Events](/sdk-features/events) for event types.
|
||||
|
||||
## Related examples
|
||||
|
||||
- **[Controlling the canvas](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/editor-api/api)** — Create and manipulate shapes, selection, and camera through the editor API.
|
||||
- **[Minimal editor](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/configuration/only-editor)** — Use `TldrawEditor` for a bare-bones editor without default shapes or UI.
|
||||
- **[Sublibraries](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/configuration/exploded)** — Compose tldraw from individual sublibraries for full customization.
|
||||
- **[Canvas events](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/events/canvas-events)** — Listen to editor events including pointer, keyboard, and shape changes.
|
||||
- **[Editor focus](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/editor-api/editor-focus)** — Control editor focus state.
|
||||
226
apps/docs/content/sdk-features/embed-shape.mdx
Normal file
226
apps/docs/content/sdk-features/embed-shape.mdx
Normal file
@@ -0,0 +1,226 @@
|
||||
---
|
||||
title: Embed shape
|
||||
created_at: 1/31/2026
|
||||
updated_at: 1/31/2026
|
||||
keywords:
|
||||
- embed
|
||||
- youtube
|
||||
- figma
|
||||
- iframe
|
||||
- codesandbox
|
||||
- codepen
|
||||
- google maps
|
||||
- spotify
|
||||
- vimeo
|
||||
status: published
|
||||
date: 1/31/2026
|
||||
---
|
||||
|
||||
The embed shape displays interactive content from external services within an iframe. When you paste a URL from a supported service onto the canvas, tldraw converts it to an embed with the appropriate dimensions and settings. See [EmbedShapeUtil](?) and [TLEmbedShape](?).
|
||||
|
||||
## Creating embeds
|
||||
|
||||
Paste a supported URL onto the canvas, or create an embed shape programmatically:
|
||||
|
||||
```tsx
|
||||
editor.createShape({
|
||||
type: 'embed',
|
||||
x: 100,
|
||||
y: 100,
|
||||
props: {
|
||||
url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
||||
w: 560,
|
||||
h: 315,
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
The embed system recognizes URLs from supported services and converts them to their embeddable equivalents. A YouTube watch URL becomes an embed URL automatically.
|
||||
|
||||
### Pasting iframe code
|
||||
|
||||
You can also paste raw `<iframe>` HTML directly onto the canvas to create an embed shape, even when the source URL doesn't match a known provider. tldraw extracts the iframe's `src` attribute and creates an embed pointing at it. For size, it reads the `width` and `height` attributes, then pixel values from the `style` attribute, and otherwise uses 425×350.
|
||||
|
||||
This covers services tldraw doesn't know about (OpenStreetMap, SoundCloud, Loom, internal tools) without a custom embed definition. Iframes pasted this way receive a stricter sandbox than the built-in providers ([unknownEmbedShapePermissionOverrides](?): no `allow-same-origin`, no forms, no popups), since tldraw can't make any safety guarantees about the source.
|
||||
|
||||
## Supported services
|
||||
|
||||
| Service | Hostnames | Resizable | Aspect ratio locked |
|
||||
| --------------- | ------------------------------------------- | --------- | ------------------- |
|
||||
| tldraw | tldraw.com, beta.tldraw.com, localhost:3000 | Yes | No |
|
||||
| Figma | figma.com | Yes | No |
|
||||
| YouTube | youtube.com, \*.youtube.com, youtu.be | Yes | Yes |
|
||||
| Google Maps | google.\* | Yes | No |
|
||||
| Google Calendar | calendar.google.\* | Yes | No |
|
||||
| Google Slides | docs.google.\* | Yes | No |
|
||||
| CodeSandbox | codesandbox.io | Yes | No |
|
||||
| CodePen | codepen.io | Yes | No |
|
||||
| Scratch | scratch.mit.edu | No | No |
|
||||
| Val Town | val.town | Yes | No |
|
||||
| GitHub Gist | gist.github.com | Yes | No |
|
||||
| Replit | replit.com | Yes | No |
|
||||
| Felt | felt.com | Yes | No |
|
||||
| Spotify | open.spotify.com | Yes | No |
|
||||
| Vimeo | vimeo.com, player.vimeo.com | Yes | Yes |
|
||||
| Observable | observablehq.com | Yes | No |
|
||||
| Desmos | desmos.com | Yes | No |
|
||||
| Canva | canva.com | Yes | No |
|
||||
|
||||
Each service has default dimensions appropriate for its content type. YouTube embeds default to 800×450 (16:9), while Spotify defaults to 720×500. Vimeo starts at 640×360 and corrects itself to the video's real aspect ratio after creation (`sizeToContentAspectRatio`). Google Maps takes an API key through `EmbedShapeUtil.configure({ embedConfig: { google_maps: { apiKey } } })`.
|
||||
|
||||
## Interacting with embeds
|
||||
|
||||
Embed shapes behave differently from other shapes because they contain live interactive content. Clicking an embed selects the shape rather than interacting with the content. Double-click it (or press Enter while it's selected) to enter editing mode. In editing mode, pointer events pass through to the iframe so you can scroll, click buttons, and use the embedded application. Click outside the shape or press Escape to exit.
|
||||
|
||||
Locking an embed doesn't block this: [ShapeUtil#canEditWhileLocked](?) returns `true` for embeds (unless the definition sets `canEditWhileLocked: false`), so a locked embed can still enter editing mode and stays put while you use it. Editing also works in readonly mode.
|
||||
|
||||
## URL transformation
|
||||
|
||||
The embed system converts user-facing URLs to embed URLs automatically. When you paste `https://www.youtube.com/watch?v=dQw4w9WgXcQ`, the embed shape stores the original URL and renders `https://www.youtube.com/embed/dQw4w9WgXcQ`.
|
||||
|
||||
Each embed definition includes two transformation functions:
|
||||
|
||||
- `toEmbedUrl`: Converts a shareable URL to an embeddable URL
|
||||
- `fromEmbedUrl`: Converts an embed URL back to the original URL
|
||||
|
||||
The shape stores the original URL and renders the embed version.
|
||||
|
||||
## Fallback to bookmarks
|
||||
|
||||
When you paste a URL that no embed definition recognizes, the default URL handler creates a [bookmark shape](/sdk-features/default-shapes#Bookmark) instead of an embed. An existing embed shape whose URL doesn't match any definition still renders the URL in an iframe, using the stricter sandbox for unknown sources.
|
||||
|
||||
Use [getEmbedInfo](?) to check whether a URL is embeddable before creating a shape:
|
||||
|
||||
```tsx
|
||||
import { getEmbedInfo, DEFAULT_EMBED_DEFINITIONS } from 'tldraw'
|
||||
|
||||
const embedInfo = getEmbedInfo(DEFAULT_EMBED_DEFINITIONS, 'https://youtube.com/watch?v=abc123')
|
||||
|
||||
if (embedInfo) {
|
||||
// URL is embeddable
|
||||
console.log(embedInfo.definition.title) // "YouTube"
|
||||
console.log(embedInfo.embedUrl) // "https://www.youtube.com/embed/abc123"
|
||||
} else {
|
||||
// URL is not embeddable, will render as bookmark
|
||||
}
|
||||
```
|
||||
|
||||
## Iframe security
|
||||
|
||||
Embeds run in sandboxed iframes with restricted permissions. The defaults live in [embedShapePermissionDefaults](?):
|
||||
|
||||
| Permission | Default | Description |
|
||||
| ----------------------------------------- | ------- | ------------------------------------------------- |
|
||||
| `allow-scripts` | Yes | Allow JavaScript execution |
|
||||
| `allow-same-origin` | Yes | Allow access to same-origin storage and APIs |
|
||||
| `allow-forms` | Yes | Allow form submission |
|
||||
| `allow-popups` | Yes | Allow opening new windows (for linking to source) |
|
||||
| `allow-popups-to-escape-sandbox` | No | Popups inherit the sandbox |
|
||||
| `allow-downloads` | No | Block file downloads |
|
||||
| `allow-downloads-without-user-activation` | No | Block automatic downloads |
|
||||
| `allow-modals` | No | Block modal dialogs like `window.prompt()` |
|
||||
| `allow-orientation-lock` | No | Block screen orientation lock |
|
||||
| `allow-pointer-lock` | No | Block pointer lock API |
|
||||
| `allow-presentation` | No | Block the Presentation API |
|
||||
| `allow-top-navigation` | No | Block navigating away from tldraw |
|
||||
| `allow-top-navigation-by-user-activation` | No | Block navigating away, even on user gesture |
|
||||
| `allow-storage-access-by-user-activation` | No | Block access to parent storage |
|
||||
|
||||
Individual embed definitions override these defaults with `overridePermissions`. YouTube and Google Maps allow `allow-presentation` for fullscreen; YouTube, Google Calendar, and Google Slides allow `allow-popups-to-escape-sandbox`; tldraw embeds allow `allow-top-navigation` so users can open rooms in new tabs.
|
||||
|
||||
GitHub Gist embeds receive special handling: they use `srcDoc` instead of `src` to load the gist script, drop `allow-same-origin`, and restrict gist IDs to hexadecimal characters. This prevents JSONP callback attacks. See the [embed permissions example](/examples/configuration/embed-permissions).
|
||||
|
||||
## Custom embed definitions
|
||||
|
||||
Replace or extend the default embed definitions using `EmbedShapeUtil.configure()`. Type custom definitions as [CustomEmbedDefinition](?) and give them an `icon` so they show up in the Insert embed dialog:
|
||||
|
||||
```tsx
|
||||
import { Tldraw, EmbedShapeUtil, DEFAULT_EMBED_DEFINITIONS, CustomEmbedDefinition } from 'tldraw'
|
||||
import 'tldraw/tldraw.css'
|
||||
|
||||
const myService: CustomEmbedDefinition = {
|
||||
type: 'myservice',
|
||||
title: 'My Service',
|
||||
hostnames: ['myservice.com'],
|
||||
width: 600,
|
||||
height: 400,
|
||||
doesResize: true,
|
||||
icon: 'https://myservice.com/favicon.png',
|
||||
toEmbedUrl: (url) => {
|
||||
const match = url.match(/myservice\.com\/item\/(\w+)/)
|
||||
if (match) {
|
||||
return `https://myservice.com/embed/${match[1]}`
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
fromEmbedUrl: (url) => {
|
||||
const match = url.match(/myservice\.com\/embed\/(\w+)/)
|
||||
if (match) {
|
||||
return `https://myservice.com/item/${match[1]}`
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
embedOnPaste: true,
|
||||
}
|
||||
|
||||
const shapeUtils = [
|
||||
EmbedShapeUtil.configure({ embedDefinitions: [...DEFAULT_EMBED_DEFINITIONS, myService] }),
|
||||
]
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0 }}>
|
||||
<Tldraw shapeUtils={shapeUtils} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Embed definition properties
|
||||
|
||||
| Property | Type | Required | Description |
|
||||
| -------------------------- | -------------------------------------- | -------- | ------------------------------------------------------------------------- |
|
||||
| `type` | `string` | Yes | Unique identifier for this embed type |
|
||||
| `title` | `string` | Yes | Display name shown in the UI |
|
||||
| `hostnames` | `string[]` | Yes | URL hostnames to match (supports glob patterns like `*.youtube.com`) |
|
||||
| `width` | `number` | Yes | Default width in pixels |
|
||||
| `height` | `number` | Yes | Default height in pixels |
|
||||
| `doesResize` | `boolean` | Yes | Whether the shape can be resized |
|
||||
| `toEmbedUrl` | `(url: string) => string \| undefined` | Yes | Convert shareable URL to embed URL |
|
||||
| `fromEmbedUrl` | `(url: string) => string \| undefined` | Yes | Convert embed URL to shareable URL |
|
||||
| `minWidth` | `number` | No | Minimum width when resizing |
|
||||
| `minHeight` | `number` | No | Minimum height when resizing |
|
||||
| `isAspectRatioLocked` | `boolean` | No | Lock aspect ratio when resizing |
|
||||
| `canEditWhileLocked` | `boolean` | No | Allow interaction when shape is locked (default: true) |
|
||||
| `overridePermissions` | `TLEmbedShapePermissions` | No | Custom iframe sandbox permissions |
|
||||
| `sizeToContentAspectRatio` | `boolean` | No | Correct the size to the content's real aspect ratio after creation |
|
||||
| `icon` | `string` | No | Icon URL for the Insert embed dialog (`CustomEmbedDefinition` only) |
|
||||
| `backgroundColor` | `string` | No | Background color for the embed container |
|
||||
| `overrideOutlineRadius` | `number` | No | Custom border radius (Spotify uses 12px) |
|
||||
| `embedOnPaste` | `boolean` | No | When true, URLs are auto-converted to embeds on paste |
|
||||
| `instructionLink` | `string` | No | Help URL for services requiring setup (like Google Calendar public links) |
|
||||
|
||||
## Embed-on-paste behavior
|
||||
|
||||
By default, pasting a URL from a supported service creates an embed shape. Set `embedOnPaste: false` in the embed definition to create a bookmark instead. Pasting while a single shape that can hold a link is selected sets that shape's link rather than creating either.
|
||||
|
||||
The tldraw embed definition sets `embedOnPaste: false`, so pasting a tldraw.com URL creates a bookmark. Separately, embed shapes refuse to render a nested tldraw canvas when the page itself is running inside an iframe, which prevents infinite nesting.
|
||||
|
||||
## Shape properties
|
||||
|
||||
| Property | Type | Description |
|
||||
| -------- | -------- | ------------------------------------------------------------------ |
|
||||
| `url` | `string` | The original URL (converted to embed URL internally); default `''` |
|
||||
| `w` | `number` | Width of the embed container; default `300` |
|
||||
| `h` | `number` | Height of the embed container; default `300` |
|
||||
|
||||
## SVG export
|
||||
|
||||
Embed shapes render as blank rectangles in SVG exports. The iframe content can't be captured directly, so exports show a placeholder with the embed's background color and border radius.
|
||||
|
||||
## Related articles
|
||||
|
||||
- [Default shapes](/sdk-features/default-shapes) — Overview of all built-in shape types
|
||||
- [Bookmark shape](/sdk-features/default-shapes#Bookmark) — URL link cards (fallback for non-embeddable URLs)
|
||||
- [External content handling](/sdk-features/external-content) — Handling pasted and dropped content
|
||||
- [Custom embeds example](/examples/configuration/custom-embed) — Add a custom embed definition for a new service
|
||||
154
apps/docs/content/sdk-features/environment.mdx
Normal file
154
apps/docs/content/sdk-features/environment.mdx
Normal file
@@ -0,0 +1,154 @@
|
||||
---
|
||||
title: Environment detection
|
||||
created_at: 12/22/2025
|
||||
updated_at: 12/29/2025
|
||||
keywords:
|
||||
- environment
|
||||
- browser
|
||||
- platform
|
||||
- tlenv
|
||||
- Safari
|
||||
- iOS
|
||||
- Android
|
||||
- Firefox
|
||||
- coarse pointer
|
||||
reviewed_by: steveruizok
|
||||
status: published
|
||||
date: 12/22/2025
|
||||
readability: 9
|
||||
voice: 9
|
||||
completeness: 8
|
||||
accuracy: 10
|
||||
notes: ''
|
||||
---
|
||||
|
||||
The tldraw SDK provides two objects for detecting the user's environment. [tlenv](?) holds fixed browser and platform information. [tlenvReactive](?) holds values that change during a session, like whether the user is currently using touch input. We use these internally to work around browser quirks, and you can use them in custom shapes or tools.
|
||||
|
||||
## Static environment: tlenv
|
||||
|
||||
The `tlenv` object contains values detected at page load. These don't change during a session.
|
||||
|
||||
```typescript
|
||||
import { tlenv } from 'tldraw'
|
||||
|
||||
// Browser detection
|
||||
tlenv.isSafari // true if Safari (excluding Chrome on iOS)
|
||||
tlenv.isFirefox // true if Firefox
|
||||
tlenv.isChromeForIos // true if Chrome running on iOS
|
||||
|
||||
// Platform detection
|
||||
tlenv.isIos // true if iPad or iPhone
|
||||
tlenv.isAndroid // true if Android device
|
||||
tlenv.isDarwin // true if macOS
|
||||
|
||||
// Capability detection
|
||||
tlenv.hasCanvasSupport // true if Promise and HTMLCanvasElement exist
|
||||
tlenv.isTouchDevice // true if the device has a touch screen
|
||||
```
|
||||
|
||||
`isTouchDevice` reflects the hardware, so it stays true on a touchscreen laptop even while the user is using a mouse. For the pointer currently in use, read `isCoarsePointer` from `tlenvReactive` below.
|
||||
|
||||
### Common patterns
|
||||
|
||||
**Platform-specific keyboard shortcuts:**
|
||||
|
||||
```typescript
|
||||
import { isAccelKey } from 'tldraw'
|
||||
|
||||
// Cmd on Mac, Ctrl elsewhere
|
||||
if (isAccelKey(e)) {
|
||||
// Handle the shortcut
|
||||
}
|
||||
```
|
||||
|
||||
**Mobile detection:**
|
||||
|
||||
```typescript
|
||||
const isMobile = tlenv.isIos || tlenv.isAndroid
|
||||
if (isMobile) {
|
||||
// Adjust UI for mobile
|
||||
}
|
||||
```
|
||||
|
||||
**Browser-specific workarounds:**
|
||||
|
||||
```typescript
|
||||
// Safari needs extra time for SVG image export
|
||||
if (tlenv.isSafari) {
|
||||
await new Promise((r) => setTimeout(r, 250))
|
||||
}
|
||||
```
|
||||
|
||||
## Reactive environment: tlenvReactive
|
||||
|
||||
The `tlenvReactive` atom contains values that can change during a session: `isCoarsePointer` (the current pointer type) and `supportsP3ColorSpace` (whether the current display supports the P3 color gamut). Use [useValue](?) to subscribe to changes in React components.
|
||||
|
||||
```tsx
|
||||
import { tlenvReactive, useValue } from 'tldraw'
|
||||
|
||||
function TouchFriendlyButton() {
|
||||
const { isCoarsePointer } = useValue(tlenvReactive)
|
||||
|
||||
return (
|
||||
<button style={{ padding: isCoarsePointer ? 16 : 8 }}>
|
||||
{/* Larger touch target when using touch input */}
|
||||
Click me
|
||||
</button>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Coarse pointer detection
|
||||
|
||||
The `isCoarsePointer` value tracks whether the user is currently using touch input. We detect this two ways: by listening to the `(any-pointer: coarse)` media query, and by checking `pointerType` on each pointer down event. Any pointer that isn't a mouse counts as coarse, so pen input switches to coarse mode too. Laptops with touchscreens can switch input methods mid-session; the pointer down check catches that.
|
||||
|
||||
```typescript
|
||||
import { tlenvReactive, react } from 'tldraw'
|
||||
|
||||
// Access the current value directly
|
||||
const isCoarse = tlenvReactive.get().isCoarsePointer
|
||||
|
||||
// Subscribe to changes outside React
|
||||
react('pointer type changed', () => {
|
||||
const { isCoarsePointer } = tlenvReactive.get()
|
||||
console.log('Coarse pointer:', isCoarsePointer)
|
||||
})
|
||||
```
|
||||
|
||||
Note: We force fine pointer mode on Firefox desktop regardless of the actual input device, since Firefox's coarse pointer reporting is unreliable there.
|
||||
|
||||
## Cross-window contexts
|
||||
|
||||
When the editor is mounted inside an iframe, an Electron pop-out window, or an Obsidian plugin, the global `document` and `window` aren't necessarily the ones the editor's DOM lives in. Reading `document.activeElement` or attaching a listener to the global `window` will silently fail or target the wrong frame.
|
||||
|
||||
Use the editor's container helpers instead of bare globals. They are marked internal, so their signatures may change, but they are what the SDK itself uses:
|
||||
|
||||
```typescript
|
||||
const doc = editor.getContainerDocument() // the document the editor is mounted in
|
||||
const win = editor.getContainerWindow() // the window the editor is mounted in
|
||||
```
|
||||
|
||||
When you have a DOM node and want the document or window that owns it (rather than where the editor lives), use the standalone helpers:
|
||||
|
||||
```typescript
|
||||
import { getOwnerDocument, getOwnerWindow } from 'tldraw'
|
||||
|
||||
const doc = getOwnerDocument(node)
|
||||
const win = getOwnerWindow(node)
|
||||
```
|
||||
|
||||
These are what the editor uses internally for export, measurement, focus tracking, and event listeners. Custom shapes and tools that touch the DOM directly should use them too. Anything that calls `document` or `window` directly will target the wrong realm once your app is embedded in an iframe or pop-out window.
|
||||
|
||||
## Browser quirks we handle
|
||||
|
||||
Here's a sample of what we use environment detection for internally.
|
||||
|
||||
**Safari** has the most workarounds. During SVG-to-image export, Safari fires the image's load event before the fonts inside the SVG have loaded, so we wait an extra 250ms—a WebKit bug that's been open for years. Text outlines are a performance problem on Safari, so we disable them there.
|
||||
|
||||
**iOS** support for coalesced pointer events is unreliable (`getCoalescedEvents` is sometimes missing entirely), so we skip coalesced events on iOS and dispatch individual pointer events instead. We also skip Safari's proprietary `GestureEvent` on iOS.
|
||||
|
||||
**Firefox** desktop's `(any-pointer: coarse)` media query reports false positives when a touchscreen is present but not in use. We force fine pointer mode on Firefox desktop to avoid jumpy UI.
|
||||
|
||||
**Chrome for iOS** has its own print implementation that doesn't trigger the standard `beforeprint` event, so we call our print handler manually before printing. Safari desktop needs `document.execCommand('print')` instead of `window.print()`.
|
||||
|
||||
See [Instance state](/sdk-features/instance-state) for `isCoarsePointer` on the instance record, which mirrors `tlenvReactive`.
|
||||
81
apps/docs/content/sdk-features/eraser.mdx
Normal file
81
apps/docs/content/sdk-features/eraser.mdx
Normal file
@@ -0,0 +1,81 @@
|
||||
---
|
||||
title: Eraser
|
||||
created_at: 1/31/2026
|
||||
updated_at: 1/31/2026
|
||||
keywords:
|
||||
- eraser
|
||||
- eraser tool
|
||||
- delete
|
||||
- remove shapes
|
||||
- scribble
|
||||
status: draft
|
||||
date: 1/31/2026
|
||||
---
|
||||
|
||||
The eraser tool deletes shapes from the canvas. Click a shape to erase it, or drag to erase every shape along the pointer's path. Users can select the eraser from the toolbar or press `E`; you can also activate it programmatically:
|
||||
|
||||
```tsx
|
||||
import { Tldraw } from 'tldraw'
|
||||
import 'tldraw/tldraw.css'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0 }}>
|
||||
<Tldraw onMount={(editor) => editor.setCurrentTool('eraser')} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Click vs. drag
|
||||
|
||||
A click erases the shapes under the pointer. Hold Cmd/Ctrl while clicking to erase only the topmost shape. A click that hits a shape inside a frame doesn't also erase the frame.
|
||||
|
||||
Dragging (or long-pressing) starts scribble erasing. A scribble trail follows the pointer, and every shape the path crosses is marked for deletion. Shapes aren't deleted until you release: marked shapes render at reduced opacity (32% of normal) so the user can see what's about to go.
|
||||
|
||||
Releasing the pointer deletes the marked shapes. Pressing Escape cancels the gesture and unmarks them.
|
||||
|
||||
The draw and highlight tools use the eraser too: hold Cmd/Ctrl and press down to erase without leaving the tool. They switch to `eraser.pointing` with `onInteractionEnd` set to their own id, and the eraser returns to that tool when the gesture ends.
|
||||
|
||||
Each erase gesture is a single history stopping point, so one undo brings back everything it deleted.
|
||||
|
||||
## What can be erased
|
||||
|
||||
Locked shapes can't be erased. The eraser also skips shapes with a locked ancestor. See [Locked shapes](/sdk-features/locked-shapes).
|
||||
|
||||
Groups are erased as a unit: hitting any shape inside a group marks the whole group. The exception is when the drag starts inside the group's bounds—then the eraser works on the shapes inside it and leaves the group alone.
|
||||
|
||||
Frames behave the same way. Start dragging inside a frame and the eraser deletes the frame's children without deleting the frame itself.
|
||||
|
||||
## Erasing state
|
||||
|
||||
While a gesture is in progress, the editor tracks marked shapes in the current page state:
|
||||
|
||||
```ts
|
||||
// Mark shapes as being erased (they render faded)
|
||||
editor.setErasingShapes([shapeId1, shapeId2])
|
||||
|
||||
// Read the marked shapes
|
||||
editor.getErasingShapeIds()
|
||||
editor.getErasingShapes()
|
||||
```
|
||||
|
||||
The eraser uses [Editor#setErasingShapes](?), [Editor#getErasingShapeIds](?), and [Editor#getErasingShapes](?) internally. You can use them from your own tools to get the same faded preview before deleting shapes with [Editor#deleteShapes](?).
|
||||
|
||||
## Tool states
|
||||
|
||||
The eraser is an [EraserTool](?) with the id `eraser` and three child states. Tool lock doesn't apply to it. Pressing Escape in `idle` returns to the select tool.
|
||||
|
||||
| State | Description |
|
||||
| ---------- | ------------------------------------------------------------------------------------------------------------------ |
|
||||
| `idle` | Waiting for input. Pointer down transitions to `pointing`. |
|
||||
| `pointing` | The pointer is down. Shapes under the point are marked; releasing deletes them. Dragging transitions to `erasing`. |
|
||||
| `erasing` | Scribble erasing. Marks every shape the pointer path crosses; releasing deletes them. |
|
||||
|
||||
Hit testing during scribble erasing uses the segment between the previous and current pointer positions, with a margin of [Editor#getHitTestMargin](?) (`hitTestMargin`, or `coarseHitTestMargin` for touch, divided by the zoom level). This catches shapes even when the pointer moves quickly. Shapes clipped by a frame are skipped unless the pointer is inside the clip.
|
||||
|
||||
## Related
|
||||
|
||||
- [Tools](/sdk-features/tools) — How tools and their child states work
|
||||
- [Scribble](/sdk-features/scribble) — The scribble system that draws the eraser trail
|
||||
- [Locked shapes](/sdk-features/locked-shapes) — Locking shapes to protect them from edits
|
||||
148
apps/docs/content/sdk-features/errors.mdx
Normal file
148
apps/docs/content/sdk-features/errors.mdx
Normal file
@@ -0,0 +1,148 @@
|
||||
---
|
||||
title: Error handling
|
||||
created_at: 12/22/2025
|
||||
updated_at: 12/29/2025
|
||||
keywords:
|
||||
- error
|
||||
- ErrorBoundary
|
||||
- crash
|
||||
- fallback
|
||||
- ErrorFallback
|
||||
- ShapeErrorFallback
|
||||
reviewed_by: steveruizok
|
||||
status: published
|
||||
date: 12/22/2025
|
||||
readability: 9
|
||||
voice: 8
|
||||
completeness: 9
|
||||
accuracy: 9
|
||||
notes: Add API reference links. Add context about common error sources.
|
||||
---
|
||||
|
||||
The editor uses multiple layers of React error boundaries to isolate failures. When a shape throws during render, only that shape shows a fallback. The rest of the editor keeps working. This matters because custom shapes are a common extension point, and third-party code should not crash the whole editor.
|
||||
|
||||
## Error boundary layers
|
||||
|
||||
Error boundaries exist at two levels.
|
||||
|
||||
At the application level, a boundary wraps the entire editor. If something throws here, the editor shows a full-screen error with options to refresh or reset local data. This is the last resort. There are actually two of these: an outer one that catches errors before the [Editor](?) exists, and an inner one that has access to the editor, so it can annotate the error and still try to render your document behind the error screen.
|
||||
|
||||
At the shape level, each shape's component (and its background component, if it has one) renders inside its own boundary. A broken shape shows a fallback, but the user can still interact with everything else. ShapeUtil code is the most likely place for bugs, especially in custom shapes.
|
||||
|
||||
## Default fallbacks
|
||||
|
||||
Each boundary level has a default fallback component.
|
||||
|
||||
[DefaultErrorFallback](?) shows a modal with the error message and stack trace. It tries to render the canvas behind the modal so users can see their work is probably still there. The modal offers buttons to copy the error, refresh the page, or reset local data.
|
||||
|
||||
The default shape fallback renders a div with the class `tl-shape-error-boundary`, which the default CSS styles as a muted box labeled "Error" in the shape's place. Override this class if you want broken shapes to look different.
|
||||
|
||||
## Customizing error components
|
||||
|
||||
Replace either fallback through the `ErrorFallback` and `ShapeErrorFallback` keys of the [TLEditorComponents](?) `components` prop:
|
||||
|
||||
```tsx
|
||||
import { Tldraw, TLErrorFallbackComponent, TLShapeErrorFallbackComponent } from 'tldraw'
|
||||
|
||||
const MyErrorFallback: TLErrorFallbackComponent = ({ error, editor }) => {
|
||||
return (
|
||||
<div className="my-error-screen">
|
||||
<h1>Oops!</h1>
|
||||
<p>{error instanceof Error ? error.message : String(error)}</p>
|
||||
<button onClick={() => window.location.reload()}>Refresh</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const MyShapeErrorFallback: TLShapeErrorFallbackComponent = ({ error }) => {
|
||||
return <div className="broken-shape">This shape failed to render</div>
|
||||
}
|
||||
|
||||
;<Tldraw
|
||||
components={{
|
||||
ErrorFallback: MyErrorFallback,
|
||||
ShapeErrorFallback: MyShapeErrorFallback,
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
Passing `null` for a fallback (with a cast, since the props are typed as components) disables the error boundary at that level. Errors propagate to the parent boundary instead.
|
||||
|
||||
## Crash handling
|
||||
|
||||
When an error is thrown inside a store transaction (anything wrapped in [Editor#run](?), which includes most editor methods), the history manager catches it, annotates it, and puts the editor into a crashed state. Listen for this with the `crash` event:
|
||||
|
||||
```tsx
|
||||
editor.on('crash', ({ error }) => {
|
||||
console.error('Editor crashed:', error)
|
||||
// Report to error tracking service
|
||||
})
|
||||
```
|
||||
|
||||
When crashed, the editor stops processing new events to prevent further damage and marks the store as possibly corrupted. The application-level error boundary then shows the fallback UI with its refresh and reset options.
|
||||
|
||||
## Error annotations
|
||||
|
||||
The SDK attaches debugging metadata to errors it catches, and you can add your own with `annotateError` from `@tldraw/utils`. Use `getErrorAnnotations` to read the tags and extras back, which is useful for error tracking services like Sentry:
|
||||
|
||||
```tsx
|
||||
import { getErrorAnnotations, TLErrorFallbackComponent } from 'tldraw'
|
||||
|
||||
const MyErrorFallback: TLErrorFallbackComponent = ({ error }) => {
|
||||
const annotations = error instanceof Error ? getErrorAnnotations(error) : null
|
||||
|
||||
// Send to error tracking
|
||||
if (annotations) {
|
||||
Sentry.setTags(annotations.tags)
|
||||
Sentry.setExtras(annotations.extras)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Something went wrong</h1>
|
||||
<pre>{JSON.stringify(annotations, null, 2)}</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Annotations include `tags` (key-value pairs for categorization) and `extras` (additional context data). `getErrorAnnotations` is currently marked internal, so its signature may change between versions.
|
||||
|
||||
## ErrorBoundary component
|
||||
|
||||
Use the exported [ErrorBoundary](?) component directly in your own code:
|
||||
|
||||
```tsx
|
||||
import { ErrorBoundary, TLErrorFallbackComponent } from 'tldraw'
|
||||
|
||||
const MyFallback: TLErrorFallbackComponent = ({ error }) => (
|
||||
<div>Error: {error instanceof Error ? error.message : String(error)}</div>
|
||||
)
|
||||
|
||||
function MyComponent() {
|
||||
return (
|
||||
<ErrorBoundary fallback={MyFallback} onError={(error) => console.error('Caught:', error)}>
|
||||
<RiskyComponent />
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
The `fallback` prop accepts a [TLErrorFallbackComponent](?), which receives `{ error: unknown; editor?: Editor }`. `ErrorBoundary` itself only passes `error`; `editor` is provided by the editor's inner application-level boundary. The `onError` callback fires when an error is caught, before the fallback renders.
|
||||
|
||||
## API reference
|
||||
|
||||
| Symbol | Description |
|
||||
| ---------------------------------- | ------------------------------------------------------------------------------ |
|
||||
| [ErrorBoundary](?) | Reusable error boundary component; see [TLErrorBoundaryProps](?) |
|
||||
| [DefaultErrorFallback](?) | Default application-level error screen |
|
||||
| [TLErrorFallbackComponent](?) | `ComponentType<{ error: unknown; editor?: Editor }>`, used for `ErrorFallback` |
|
||||
| [TLShapeErrorFallbackComponent](?) | `ComponentType<{ error: any }>`, used for `ShapeErrorFallback` |
|
||||
| `annotateError` | Attach tags and extras to an error |
|
||||
| `getErrorAnnotations(error)` | Read tags and extras from an error; marked internal |
|
||||
| `crash` event | Emitted with `{ error: unknown }` when the editor enters the crashed state |
|
||||
|
||||
## Related examples
|
||||
|
||||
- [Error boundary](/examples/ui/error-boundary): customize `ShapeErrorFallback` to display a custom message when shapes throw errors.
|
||||
- [Custom error capture](/examples/ui/custom-error-capture): override `ErrorFallback` to create a custom error screen with annotations for debugging.
|
||||
285
apps/docs/content/sdk-features/events.mdx
Normal file
285
apps/docs/content/sdk-features/events.mdx
Normal file
@@ -0,0 +1,285 @@
|
||||
---
|
||||
title: Events
|
||||
created_at: 12/26/2025
|
||||
updated_at: 12/29/2025
|
||||
keywords:
|
||||
- events
|
||||
- EventEmitter
|
||||
- TLEventMap
|
||||
- change
|
||||
- tick
|
||||
- mount
|
||||
status: published
|
||||
date: 12/26/2025
|
||||
readability: 9
|
||||
voice: 9
|
||||
completeness: 9
|
||||
accuracy: 9
|
||||
notes: Input events table could mention TLEventInfo link more prominently. Add API links for types like HistoryEntry.
|
||||
---
|
||||
|
||||
The editor emits events for input, store changes, and lifecycle moments. Subscribe with `editor.on()` and unsubscribe with `editor.off()`. Use events to build analytics, sync external state, or extend editor behavior.
|
||||
|
||||
## Subscribing to events
|
||||
|
||||
The [Editor](?) extends EventEmitter, and every event name and payload is typed by [TLEventMap](?):
|
||||
|
||||
```tsx
|
||||
import { Tldraw } from 'tldraw'
|
||||
import 'tldraw/tldraw.css'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0 }}>
|
||||
<Tldraw
|
||||
onMount={(editor) => {
|
||||
editor.on('event', (info) => {
|
||||
console.log('Event:', info.type, info.name)
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Unsubscribe by calling `off()` with the same handler:
|
||||
|
||||
```tsx
|
||||
const handleEvent: TLEventMapHandler<'event'> = (info) => {
|
||||
console.log('Event:', info.type)
|
||||
}
|
||||
|
||||
editor.on('event', handleEvent)
|
||||
editor.off('event', handleEvent)
|
||||
```
|
||||
|
||||
Unsubscribe when the listener outlives the code that registered it. In React, return a cleanup function from your effect:
|
||||
|
||||
```tsx
|
||||
useEffect(() => {
|
||||
const handleChange: TLEventMapHandler<'change'> = (entry) => {
|
||||
console.log('Store changed:', entry.changes)
|
||||
}
|
||||
|
||||
editor.on('change', handleChange)
|
||||
return () => editor.off('change', handleChange)
|
||||
}, [editor])
|
||||
```
|
||||
|
||||
## Event categories
|
||||
|
||||
### Input events
|
||||
|
||||
The `event` and `before-event` events fire for every event the editor dispatches: user input plus the `misc` events (`cancel`, `complete`, `interrupt`, `tick`) that tools use internally. Each receives a [TLEventInfo](?) object describing the event.
|
||||
|
||||
```tsx
|
||||
editor.on('event', (info) => {
|
||||
if (info.type === 'pointer' && info.name === 'pointer_down') {
|
||||
console.log('Clicked at', info.point)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
`before-event` fires before the event reaches the tool state machine, and `event` fires after the tools have handled it.
|
||||
|
||||
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` | 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 |
|
||||
| `misc` | `cancel`, `complete`, `interrupt`, `tick` | Internal tool lifecycle events |
|
||||
|
||||
Pointer events include the target—what the pointer is over: `canvas`, `shape`, `selection`, `handle`, or `overlay`.
|
||||
|
||||
```tsx
|
||||
editor.on('event', (info) => {
|
||||
if (info.type === 'pointer' && info.name === 'pointer_down') {
|
||||
switch (info.target) {
|
||||
case 'canvas':
|
||||
console.log('Clicked empty canvas')
|
||||
break
|
||||
case 'shape':
|
||||
console.log('Clicked shape:', info.shape.id)
|
||||
break
|
||||
case 'selection':
|
||||
console.log('Clicked selection bounds')
|
||||
break
|
||||
case 'handle':
|
||||
console.log('Clicked handle on:', info.shape.id)
|
||||
break
|
||||
case 'overlay':
|
||||
console.log('Clicked overlay:', info.overlay.type)
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Shape events
|
||||
|
||||
Shape events fire from [Editor#createShapes](?), [Editor#updateShapes](?), and [Editor#deleteShapes](?), just before the store is written. Changes from other sources (remote sync, undo/redo, direct `store.put` calls) don't fire them—use the `change` event for those.
|
||||
|
||||
| Event | Payload | Description |
|
||||
| ---------------- | ------------- | -------------------------------- |
|
||||
| `created-shapes` | `TLRecord[]` | Shapes about to be added |
|
||||
| `edited-shapes` | `TLRecord[]` | Shapes about to be updated |
|
||||
| `deleted-shapes` | `TLShapeId[]` | Shapes about to be removed |
|
||||
| `edit` | None | Fires alongside any of the above |
|
||||
|
||||
```tsx
|
||||
editor.on('created-shapes', (shapes) => {
|
||||
console.log('Created:', shapes.length, 'shapes')
|
||||
})
|
||||
|
||||
editor.on('deleted-shapes', (ids) => {
|
||||
console.log('Deleted:', ids.length, 'shapes')
|
||||
})
|
||||
```
|
||||
|
||||
### Store changes
|
||||
|
||||
The `change` event fires whenever the store updates. It receives a [HistoryEntry](?) containing the diff and source:
|
||||
|
||||
```tsx
|
||||
editor.on('change', (entry) => {
|
||||
const { added, updated, removed } = entry.changes
|
||||
|
||||
for (const record of Object.values(added)) {
|
||||
if (record.typeName === 'shape') {
|
||||
console.log('Added shape:', record.type)
|
||||
}
|
||||
}
|
||||
|
||||
for (const [from, to] of Object.values(updated)) {
|
||||
if (from.typeName === 'shape') {
|
||||
console.log('Updated shape:', from.id)
|
||||
}
|
||||
}
|
||||
|
||||
for (const record of Object.values(removed)) {
|
||||
if (record.typeName === 'shape') {
|
||||
console.log('Removed shape:', record.id)
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
The `source` property indicates where the change originated:
|
||||
|
||||
```tsx
|
||||
editor.on('change', (entry) => {
|
||||
if (entry.source === 'user') {
|
||||
// Change from local user interaction
|
||||
scheduleAutosave()
|
||||
} else if (entry.source === 'remote') {
|
||||
// Change from collaboration sync
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Frame events
|
||||
|
||||
Two events fire on every animation frame, both carrying the milliseconds elapsed since the previous frame:
|
||||
|
||||
| Event | Payload | Description |
|
||||
| ------- | -------- | -------------------------------------------------------- |
|
||||
| `frame` | `number` | Fires first; used internally (for example, for velocity) |
|
||||
| `tick` | `number` | Fires immediately after `frame` |
|
||||
|
||||
```tsx
|
||||
editor.on('tick', (elapsed) => {
|
||||
// Update animations, physics, etc.
|
||||
updateParticleSystem(elapsed)
|
||||
})
|
||||
```
|
||||
|
||||
These fire frequently (60+ times per second). Keep handlers fast to avoid dropping frames.
|
||||
|
||||
### Lifecycle events
|
||||
|
||||
| Event | Payload | Description |
|
||||
| --------- | -------------------- | ---------------------------- |
|
||||
| `mount` | None | Editor finished initializing |
|
||||
| `unmount` | None | Editor component unmounted |
|
||||
| `dispose` | None | Editor is being cleaned up |
|
||||
| `crash` | `{ error: unknown }` | Editor encountered an error |
|
||||
| `update` | None | A store operation completed |
|
||||
|
||||
```tsx
|
||||
editor.on('mount', () => {
|
||||
console.log('Editor ready')
|
||||
})
|
||||
|
||||
editor.on('crash', ({ error }) => {
|
||||
reportError(error)
|
||||
})
|
||||
```
|
||||
|
||||
### UI and camera events
|
||||
|
||||
| Event | Payload | Description |
|
||||
| ----------------------- | ------------------------- | -------------------------------------- |
|
||||
| `resize` | `BoxModel` | Viewport dimensions changed |
|
||||
| `stop-camera-animation` | None | Camera animation interrupted |
|
||||
| `stop-following` | None | Stopped following another user |
|
||||
| `select-all-text` | `{ shapeId: TLShapeId }` | Editing started with all text selected |
|
||||
| `place-caret` | `{ shapeId, point }` | Text caret positioned |
|
||||
| `max-shapes` | `{ name, pageId, count }` | Page reached shape limit |
|
||||
|
||||
```tsx
|
||||
editor.on('resize', (bounds) => {
|
||||
console.log('Canvas size:', bounds.w, 'x', bounds.h)
|
||||
})
|
||||
|
||||
editor.on('max-shapes', ({ pageId, count }) => {
|
||||
showWarning(`Page has reached the ${count} shape limit`)
|
||||
})
|
||||
```
|
||||
|
||||
## UI events
|
||||
|
||||
The `Tldraw` component's `onUiEvent` prop captures high-level UI interactions separately from canvas events. This includes toolbar selections, menu actions, and keyboard shortcuts.
|
||||
|
||||
```tsx
|
||||
<Tldraw
|
||||
onUiEvent={(name, data) => {
|
||||
console.log('UI event:', name, data)
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
UI events track actions like selecting tools, grouping shapes, toggling dark mode, and zooming. They fire regardless of whether the action came from a click or keyboard shortcut. For the full list of events, see [TLUiEventMap](?).
|
||||
|
||||
## Listening to store changes directly
|
||||
|
||||
The `change` event is `editor.store.listen()` with no filters. For fine-grained control, call `listen()` directly:
|
||||
|
||||
```tsx
|
||||
const cleanup = editor.store.listen(
|
||||
(entry) => {
|
||||
// Handle changes
|
||||
},
|
||||
{ source: 'user', scope: 'all' }
|
||||
)
|
||||
|
||||
// Later, unsubscribe
|
||||
cleanup()
|
||||
```
|
||||
|
||||
The `listen()` method accepts filter options:
|
||||
|
||||
- `source`: `'user'`, `'remote'`, or `'all'`—filter by change origin
|
||||
- `scope`: `'all'`, `'document'`, `'session'`, or `'presence'`—filter by record scope
|
||||
|
||||
See [Side effects](/sdk-features/side-effects) for registering handlers that can intercept and modify changes.
|
||||
|
||||
## Related examples
|
||||
|
||||
- [Canvas events](/examples/events/canvas-events) - Log pointer, keyboard, and wheel events as you interact with the canvas.
|
||||
- [Store events](/examples/events/store-events) - Track shape creation, updates, and deletion through store change events.
|
||||
- [UI events](/examples/events/ui-events) - Capture high-level UI interactions like tool selection and menu actions.
|
||||
283
apps/docs/content/sdk-features/external-content.mdx
Normal file
283
apps/docs/content/sdk-features/external-content.mdx
Normal file
@@ -0,0 +1,283 @@
|
||||
---
|
||||
title: External content handling
|
||||
created_at: 12/20/2025
|
||||
updated_at: 12/29/2025
|
||||
keywords:
|
||||
- external
|
||||
- content
|
||||
- paste
|
||||
- drop
|
||||
- embed
|
||||
- files
|
||||
status: published
|
||||
date: 12/20/2025
|
||||
readability: 9
|
||||
voice: 9
|
||||
completeness: 9
|
||||
accuracy: 9
|
||||
notes: None - previously identified issues were fixed.
|
||||
---
|
||||
|
||||
The external content system handles content from outside the editor: pasted text, dropped files, embedded URLs, and more. You register handlers for specific content types, and the editor routes incoming content to the appropriate handler.
|
||||
|
||||
```tsx
|
||||
import { Tldraw, Editor, defaultHandleExternalTextContent, toRichText } from 'tldraw'
|
||||
import 'tldraw/tldraw.css'
|
||||
|
||||
export default function App() {
|
||||
function handleMount(editor: Editor) {
|
||||
editor.registerExternalContentHandler('text', async (content) => {
|
||||
// Check if this is HTML content
|
||||
const htmlSource = content.sources?.find((s) => s.type === 'text' && s.subtype === 'html')
|
||||
if (htmlSource) {
|
||||
// Handle HTML specially
|
||||
const center = content.point ?? editor.getViewportPageBounds().center
|
||||
editor.createShape({
|
||||
type: 'text',
|
||||
x: center.x,
|
||||
y: center.y,
|
||||
props: { richText: toRichText('Custom HTML handling!') },
|
||||
})
|
||||
} else {
|
||||
// Fall back to default behavior
|
||||
await defaultHandleExternalTextContent(editor, content)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0 }}>
|
||||
<Tldraw onMount={handleMount} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## How it works
|
||||
|
||||
Two systems handle external content. Content handlers transform external content into shapes: when a user pastes text, drops an image, or embeds a URL, the content handler for that type creates shapes on the canvas. Asset handlers turn external files and URLs into asset records: when an image file arrives, the asset handler extracts dimensions, uploads the file, and returns an asset record with the uploaded URL. The content handler then creates a shape referencing that asset.
|
||||
|
||||
The flow works like this:
|
||||
|
||||
1. Content arrives (paste, drop, or API call)
|
||||
2. The editor calls `putExternalContent` with the content object (a no-op in readonly mode unless you pass `{ force: true }`)
|
||||
3. The registered handler for that content type processes it
|
||||
4. The handler creates shapes, assets, or both
|
||||
|
||||
`<Tldraw>` registers the default handlers before it runs your `onMount`, so any handler you register in `onMount` replaces the default for that type.
|
||||
|
||||
## Content types
|
||||
|
||||
Every content type carries an optional `point` (where to place the content) and `sources`, the other formats found on the clipboard alongside it (`text` with a `subtype` of `html`, `text`, `url`, or `json`, plus `tldraw`, `excalidraw`, and `error`).
|
||||
|
||||
```typescript
|
||||
interface TLBaseExternalContent {
|
||||
sources?: TLExternalContentSource[]
|
||||
point?: VecLike
|
||||
}
|
||||
```
|
||||
|
||||
### Text
|
||||
|
||||
Text content comes from clipboard paste operations. The handler receives `text` (plain text) and optional `html`. The default handler creates a text shape, converting HTML to rich text when it's present, detecting right-to-left languages, and left-aligning multi-line text.
|
||||
|
||||
```typescript
|
||||
interface TLTextExternalContent extends TLBaseExternalContent {
|
||||
type: 'text'
|
||||
text: string
|
||||
html?: string
|
||||
}
|
||||
```
|
||||
|
||||
### Files
|
||||
|
||||
File content represents one or more files dropped onto the canvas. The handler receives an array of `File` objects and validates file types and sizes before creating shapes. The `<Tldraw>` props `maxAssetSize`, `maxImageDimension`, `acceptedImageMimeTypes`, and `acceptedVideoMimeTypes` control those limits, and `editor.options.maxFilesAtOnce` caps the batch.
|
||||
|
||||
```typescript
|
||||
interface TLFilesExternalContent extends TLBaseExternalContent {
|
||||
type: 'files'
|
||||
files: File[]
|
||||
}
|
||||
```
|
||||
|
||||
The default handler creates temporary previews for images, uploads the files, and creates image or video shapes arranged horizontally from the drop point.
|
||||
|
||||
### File replace
|
||||
|
||||
File replace content swaps an existing image or video shape's asset. The Replace media action dispatches it through [Editor#replaceExternalContent](?).
|
||||
|
||||
```typescript
|
||||
interface TLFileReplaceExternalContent extends TLBaseExternalContent {
|
||||
type: 'file-replace'
|
||||
file: File
|
||||
shapeId: TLShapeId
|
||||
isImage: boolean // Deprecated: no longer used by the default handler
|
||||
}
|
||||
```
|
||||
|
||||
The default handler validates the file, creates a new asset, and updates the target shape to reference the new asset while preserving any existing crop settings.
|
||||
|
||||
### URLs
|
||||
|
||||
URL content represents a URL to insert. The default handler rejects invalid URLs with a toast, then checks if the URL matches a known embed pattern (YouTube, Figma, etc.) and creates an embed shape. Otherwise, it fetches Open Graph metadata and creates a bookmark shape.
|
||||
|
||||
```typescript
|
||||
interface TLUrlExternalContent extends TLBaseExternalContent {
|
||||
type: 'url'
|
||||
url: string
|
||||
}
|
||||
```
|
||||
|
||||
### SVG text
|
||||
|
||||
SVG text content handles raw SVG markup. The handler sanitizes and parses the SVG, extracts dimensions, creates an image asset, and inserts an image shape.
|
||||
|
||||
```typescript
|
||||
interface TLSvgTextExternalContent extends TLBaseExternalContent {
|
||||
type: 'svg-text'
|
||||
text: string
|
||||
}
|
||||
```
|
||||
|
||||
### Embeds
|
||||
|
||||
Embed content creates embed shapes for embeddable URLs like YouTube videos. This content type is usually invoked by the URL handler when it detects an embeddable URL.
|
||||
|
||||
```typescript
|
||||
interface TLEmbedExternalContent<EmbedDefinition> extends TLBaseExternalContent {
|
||||
type: 'embed'
|
||||
url: string
|
||||
embed: EmbedDefinition
|
||||
}
|
||||
```
|
||||
|
||||
### tldraw and excalidraw content
|
||||
|
||||
These handlers process serialized content from other tldraw editors or Excalidraw. The `tldraw` handler calls `putContentOntoCurrentPage` to insert shapes. The `excalidraw` handler converts Excalidraw shapes to tldraw equivalents.
|
||||
|
||||
```typescript
|
||||
interface TLTldrawExternalContent extends TLBaseExternalContent {
|
||||
type: 'tldraw'
|
||||
content: TLContent
|
||||
}
|
||||
```
|
||||
|
||||
## Asset handling
|
||||
|
||||
Asset handlers turn external files and URLs into asset records. There are two asset handler types:
|
||||
|
||||
| Type | Input | Output |
|
||||
| ------ | ------------- | ----------------------------------------------------------------------------------------------- |
|
||||
| `file` | `File` object | An asset record from whichever [AssetUtil](?) accepts the MIME type (image or video by default) |
|
||||
| `url` | URL string | Bookmark asset with Open Graph metadata |
|
||||
|
||||
The default `file` handler checks the file's type and size, sanitizes SVGs, asks the matching asset util for an asset record, uploads the file via `editor.uploadAsset`, and returns the record. To support a new file type, register a custom [AssetUtil](?) (see [Assets](/sdk-features/assets)) rather than replacing this handler. The `url` handler fetches the page's Open Graph metadata (title, description, image) and creates a bookmark asset.
|
||||
|
||||
```typescript
|
||||
import { AssetRecordType, MediaHelpers } from 'tldraw'
|
||||
|
||||
editor.registerExternalAssetHandler('file', async ({ file, assetId }) => {
|
||||
const size = await MediaHelpers.getImageSize(file)
|
||||
|
||||
const asset = {
|
||||
id: assetId ?? AssetRecordType.createId(),
|
||||
type: 'image' as const,
|
||||
typeName: 'asset' as const,
|
||||
props: {
|
||||
name: file.name,
|
||||
src: '',
|
||||
w: size.w,
|
||||
h: size.h,
|
||||
mimeType: file.type,
|
||||
isAnimated: await MediaHelpers.isAnimated(file),
|
||||
fileSize: file.size,
|
||||
},
|
||||
meta: {},
|
||||
}
|
||||
|
||||
const result = await editor.uploadAsset(asset, file)
|
||||
asset.props.src = result.src
|
||||
|
||||
return AssetRecordType.create(asset)
|
||||
})
|
||||
```
|
||||
|
||||
## API methods
|
||||
|
||||
| Method | Purpose |
|
||||
| -------------------------------- | ------------------------------------------------------- |
|
||||
| `registerExternalContentHandler` | Register a handler for a content type |
|
||||
| `registerExternalAssetHandler` | Register a handler for an asset type |
|
||||
| `putExternalContent` | Process external content through the registered handler |
|
||||
| `getAssetForExternalContent` | Create an asset from external content |
|
||||
|
||||
Use `putExternalContent` to programmatically insert content:
|
||||
|
||||
```typescript
|
||||
// Insert text at a specific point
|
||||
editor.putExternalContent({
|
||||
type: 'text',
|
||||
text: 'Hello, world!',
|
||||
point: { x: 100, y: 100 },
|
||||
})
|
||||
|
||||
// Insert a URL (creates embed or bookmark)
|
||||
editor.putExternalContent({
|
||||
type: 'url',
|
||||
url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
||||
point: { x: 200, y: 200 },
|
||||
})
|
||||
```
|
||||
|
||||
Use `getAssetForExternalContent` when you need an asset without creating a shape:
|
||||
|
||||
```typescript
|
||||
const asset = await editor.getAssetForExternalContent({
|
||||
type: 'file',
|
||||
file: myFile,
|
||||
})
|
||||
```
|
||||
|
||||
Remove a handler by passing `null`:
|
||||
|
||||
```typescript
|
||||
editor.registerExternalContentHandler('text', null)
|
||||
```
|
||||
|
||||
## Customizing handlers
|
||||
|
||||
Register a new handler to replace the default behavior for any content type. Your handler receives the content object and can create shapes, insert assets, or do anything else. To extend rather than replace, call the default handler from your custom handler, as the example at the top of this page does for text.
|
||||
|
||||
The default handlers are exported from `tldraw`. Some of them take a third [TLDefaultExternalContentHandlerOpts](?) argument carrying `toasts`, `msg`, and the file limits from `<Tldraw>`; get `toasts` and `msg` from [useToasts](?) and [useTranslation](?) (or [useDefaultHelpers](?)) inside the UI.
|
||||
|
||||
| Handler | Extra options |
|
||||
| ----------------------------------------- | ------------- |
|
||||
| `defaultHandleExternalTextContent` | No |
|
||||
| `defaultHandleExternalSvgTextContent` | No |
|
||||
| `defaultHandleExternalEmbedContent` | No |
|
||||
| `defaultHandleExternalTldrawContent` | No |
|
||||
| `defaultHandleExternalExcalidrawContent` | No |
|
||||
| `defaultHandleExternalFileContent` | Yes |
|
||||
| `defaultHandleExternalFileReplaceContent` | Yes |
|
||||
| `defaultHandleExternalUrlContent` | Yes |
|
||||
| `defaultHandleExternalFileAsset` | Yes |
|
||||
| `defaultHandleExternalUrlAsset` | Yes |
|
||||
|
||||
```typescript
|
||||
import { defaultHandleExternalFileContent, useToasts, useTranslation } from 'tldraw'
|
||||
|
||||
const toasts = useToasts()
|
||||
const msg = useTranslation()
|
||||
|
||||
editor.registerExternalContentHandler('files', async (content) => {
|
||||
const small = content.files.filter((file) => file.size < 1024 * 1024)
|
||||
await defaultHandleExternalFileContent(editor, { ...content, files: small }, { toasts, msg })
|
||||
})
|
||||
```
|
||||
|
||||
For clipboard-only hooks that run before parsing or before the handler (`onClipboardPasteRaw`, `onBeforePasteFromClipboard`, `onBeforeCopyToClipboard`), see [Clipboard](/sdk-features/clipboard).
|
||||
|
||||
## Related examples
|
||||
|
||||
- [External content sources](/examples/data/assets/external-content-sources) - Handle pasted HTML by creating custom shapes.
|
||||
- [Hosted images](/examples/data/assets/hosted-images) - Upload images to your own server using a custom asset store.
|
||||
137
apps/docs/content/sdk-features/focus.mdx
Normal file
137
apps/docs/content/sdk-features/focus.mdx
Normal file
@@ -0,0 +1,137 @@
|
||||
---
|
||||
title: Focus
|
||||
created_at: 12/20/2025
|
||||
updated_at: 12/29/2025
|
||||
keywords:
|
||||
- focus
|
||||
- blur
|
||||
- keyboard
|
||||
- FocusManager
|
||||
- container
|
||||
- input
|
||||
status: published
|
||||
date: 12/20/2025
|
||||
readability: 9
|
||||
voice: 9
|
||||
completeness: 9
|
||||
accuracy: 10
|
||||
notes: ''
|
||||
---
|
||||
|
||||
Focus determines whether the editor receives keyboard shortcuts and scroll wheel gestures. When focused, these inputs go to the editor. When unfocused, they pass through to the rest of your page.
|
||||
|
||||
```tsx
|
||||
import { Tldraw } from 'tldraw'
|
||||
import 'tldraw/tldraw.css'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0 }}>
|
||||
<Tldraw
|
||||
autoFocus={false}
|
||||
onMount={(editor) => {
|
||||
// Focus later, for example when the user clicks the canvas
|
||||
const container = editor.getContainer()
|
||||
const focus = () => editor.focus()
|
||||
container.addEventListener('pointerdown', focus)
|
||||
return () => container.removeEventListener('pointerdown', focus)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Controlling focus
|
||||
|
||||
Use [Editor#focus](?) and [Editor#blur](?) to programmatically control focus:
|
||||
|
||||
```typescript
|
||||
editor.focus()
|
||||
editor.blur()
|
||||
editor.getIsFocused() // true or false
|
||||
```
|
||||
|
||||
Both methods accept options to control whether the container element should also receive or lose DOM focus:
|
||||
|
||||
```typescript
|
||||
editor.focus({ focusContainer: false })
|
||||
editor.blur({ blurContainer: false })
|
||||
```
|
||||
|
||||
Both are no-ops when the editor is already in that state. If the editor is focused but the container has lost DOM focus, call `editor.getContainer().focus()` directly.
|
||||
|
||||
### Focus/blur options
|
||||
|
||||
| Method | Option | Default | Description |
|
||||
| ------- | ---------------- | ------- | ------------------------------------------------------- |
|
||||
| `focus` | `focusContainer` | `true` | Whether to also call `focus()` on the container element |
|
||||
| `blur` | `blurContainer` | `true` | Whether to also call `blur()` on the container element |
|
||||
|
||||
## Why focus is separate from DOM focus
|
||||
|
||||
The editor tracks focus separately from the browser's DOM focus. The browser's focus model isn't reliable enough for an editor like tldraw: iframes aren't considered descendants of their parent elements, and many menus are portalled into other parts of the document tree.
|
||||
|
||||
The editor maintains its own `isFocused` state in the instance record, readable with the reactive [Editor#getIsFocused](?). This lets you distinguish between "editor focus" (whether the editor responds to keyboard shortcuts) and "element focus" (which HTML element is active in the DOM).
|
||||
|
||||
When `isFocused` changes, the editor adds or removes the `tl-container__focused` CSS class on the container. Use this class for styling instead of `:focus` or `:focus-within` pseudo-selectors, which can't reliably detect editor focus.
|
||||
|
||||
## Auto-focus
|
||||
|
||||
The `autoFocus` prop controls whether the editor focuses when it mounts. It defaults to `true`. Set it to `false` when embedding the editor in a page where you don't want it to capture keyboard input immediately.
|
||||
|
||||
```tsx
|
||||
<Tldraw autoFocus={false} />
|
||||
```
|
||||
|
||||
## Focus ring visibility
|
||||
|
||||
The editor manages focus ring visibility for accessibility. Focus rings appear around focused elements during keyboard navigation but are hidden during mouse interactions.
|
||||
|
||||
When you press Tab, ArrowUp, or ArrowDown, the editor removes the `tl-container__no-focus-ring` class to show focus rings. Mouse clicks add the class back to hide them. Focus rings stay hidden while editing a shape, and while the container itself is focused with shapes selected (arrow keys nudge shapes then).
|
||||
|
||||
## Completing interactions on blur
|
||||
|
||||
When you call `editor.blur()`, it calls `editor.complete()` to finish any ongoing interaction like a drag or draw operation. This prevents the editor from being left mid-interaction when focus is lost.
|
||||
|
||||
## Multiple editors
|
||||
|
||||
When you have multiple editors on the same page, you'll need to manage focus yourself. The browser's DOM focus alone isn't enough to reliably switch which editor receives keyboard input.
|
||||
|
||||
```tsx
|
||||
import { useState } from 'react'
|
||||
import { Editor, Tldraw } from 'tldraw'
|
||||
import 'tldraw/tldraw.css'
|
||||
|
||||
function MultipleEditors() {
|
||||
const [editors, setEditors] = useState<Editor[]>([])
|
||||
|
||||
function handleEditorFocus(focusedEditor: Editor) {
|
||||
for (const editor of editors) {
|
||||
if (editor === focusedEditor) {
|
||||
editor.focus()
|
||||
} else {
|
||||
editor.blur()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div onFocus={() => editors[0] && handleEditorFocus(editors[0])}>
|
||||
<Tldraw autoFocus={false} onMount={(e) => setEditors((prev) => [...prev, e])} />
|
||||
</div>
|
||||
<div onFocus={() => editors[1] && handleEditorFocus(editors[1])}>
|
||||
<Tldraw autoFocus={false} onMount={(e) => setEditors((prev) => [...prev, e])} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
This switches focus when a wrapper receives DOM focus. To also blur when the user clicks elsewhere on the page, listen for pointer down on the page as the [multiple editors example](/examples/layout/multiple) does.
|
||||
|
||||
## Related examples
|
||||
|
||||
- [Editor focus](/examples/editor-api/editor-focus) - Control editor focus with focus and blur methods.
|
||||
- [Multiple editors](/examples/layout/multiple) - Manage focus between multiple tldraw instances.
|
||||
150
apps/docs/content/sdk-features/frame-shape.mdx
Normal file
150
apps/docs/content/sdk-features/frame-shape.mdx
Normal file
@@ -0,0 +1,150 @@
|
||||
---
|
||||
title: Frame shape
|
||||
created_at: 1/31/2026
|
||||
updated_at: 5/6/2026
|
||||
keywords:
|
||||
- frame
|
||||
- container
|
||||
- artboard
|
||||
- clipping
|
||||
- frame tool
|
||||
- BaseFrameLikeShapeUtil
|
||||
status: published
|
||||
date: 5/6/2026
|
||||
---
|
||||
|
||||
The frame shape is a container with a labeled header that holds other shapes. Children of a frame are clipped to its bounds, follow the frame when it moves, and export together as a single image. Frames are useful for laying out artboards, screen mockups, slides, and any visual region that should travel as one unit. See [FrameShapeUtil](?) and [TLFrameShape](?).
|
||||
|
||||
```tsx
|
||||
editor.createShape({
|
||||
type: 'frame',
|
||||
x: 100,
|
||||
y: 100,
|
||||
props: {
|
||||
w: 800,
|
||||
h: 600,
|
||||
name: 'Login screen',
|
||||
color: 'black',
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Creating frames
|
||||
|
||||
You can create a frame programmatically with [Editor#createShape](?), or interactively with the frame tool. The frame tool ships in the default toolbar; users can also press `F` to switch to it. The **Frame selection** action (`Cmd/Ctrl+Alt+G`) wraps the current selection in a new frame.
|
||||
|
||||
When the user drags a frame around existing shapes, the frame tool reparents any sibling shapes that fall fully inside the frame's bounds. Locked shapes are skipped. This is what makes "draw a frame around the things I want to group" work as a single gesture.
|
||||
|
||||
Children inside a frame use coordinates relative to the frame's origin. Moving the frame moves every descendant; rotating the frame rotates them too.
|
||||
|
||||
## Frame header
|
||||
|
||||
Every frame renders a heading above its top edge that displays the frame's `name` property. Click the heading to edit the name; pressing Enter with a frame selected does not enter edit mode. The heading rotates with the frame to stay above whichever edge is currently "up", so it remains readable.
|
||||
|
||||
Empty names render as `Frame` so unnamed frames still get a label. The shape's `name` is also exposed via [ShapeUtil#getAriaDescriptor](?) so screen readers announce it during keyboard navigation.
|
||||
|
||||
## Child clipping
|
||||
|
||||
Frames clip their children to the frame's rectangle during rendering. A shape that extends past the frame edge is rendered up to the boundary and then cut off. Clipping doesn't change the child's geometry or its own bounds, but hit testing respects the mask: you can't click the clipped-off part of a shape, and [Editor#getShapeMaskedPageBounds](?) returns only the visible portion. Arrows are exempt from clipping so connectors can leave a frame.
|
||||
|
||||
The [BaseFrameLikeShapeUtil](?) base class implements clipping via `getClipPath` and the arrow exemption via `shouldClipChild`. If you need clipping for a custom container shape, see the [Frames](/sdk-features/shapes#Frames) section in the Shapes guide.
|
||||
|
||||
## Shape properties
|
||||
|
||||
| Property | Type | Description |
|
||||
| -------- | --------------------- | ---------------------------------------------------- |
|
||||
| `w` | `number` | Frame width in pixels (default: `320`) |
|
||||
| `h` | `number` | Frame height in pixels (default: `180`) |
|
||||
| `name` | `string` | Label displayed in the frame header |
|
||||
| `color` | `TLDefaultColorStyle` | Color for the border and heading (when `showColors`) |
|
||||
|
||||
## Configuration options
|
||||
|
||||
`FrameShapeUtil` exposes two configuration options:
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
| ---------------- | --------- | ------- | ------------------------------------------------------------------------------------------------------- |
|
||||
| `showColors` | `boolean` | `false` | When true, frames display a colored border and header background based on the frame's `color` property. |
|
||||
| `resizeChildren` | `boolean` | `false` | When true, resizing a frame scales its children proportionally instead of leaving them in place. |
|
||||
|
||||
```tsx
|
||||
import { FrameShapeUtil, Tldraw } from 'tldraw'
|
||||
import 'tldraw/tldraw.css'
|
||||
|
||||
const ConfiguredFrameUtil = FrameShapeUtil.configure({
|
||||
showColors: true,
|
||||
resizeChildren: true,
|
||||
})
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0 }}>
|
||||
<Tldraw shapeUtils={[ConfiguredFrameUtil]} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
When `showColors` is on, `color` becomes a real style property: users can change it from the style panel and it syncs across selections like other styles. With `showColors` off (the default), `color` still validates and persists, but the frame renders with a neutral palette so it doesn't compete visually with the shapes inside it.
|
||||
|
||||
## Frame helpers
|
||||
|
||||
The editor exposes helpers that work with frames and any other shape that opts into frame-like behavior. Use [Editor#isShapeFrameLike](?) to check whether a shape is a frame or a custom frame-like shape.
|
||||
|
||||
### Fitting a frame to its content
|
||||
|
||||
[fitFrameToContent](?) resizes a frame so it tightly wraps its children, with a configurable padding (50 by default). Double-clicking a frame's corner handle does the same with a padding of 10; double-clicking an edge handle fits only that axis.
|
||||
|
||||
```tsx
|
||||
import { fitFrameToContent } from 'tldraw'
|
||||
|
||||
fitFrameToContent(editor, frameId)
|
||||
|
||||
// Or with a custom padding
|
||||
fitFrameToContent(editor, frameId, { padding: 24 })
|
||||
```
|
||||
|
||||
### Removing a frame
|
||||
|
||||
[removeFrame](?) deletes a frame but preserves its children: they're moved out of the frame and reselected.
|
||||
|
||||
```tsx
|
||||
import { removeFrame } from 'tldraw'
|
||||
|
||||
removeFrame(editor, [frameId])
|
||||
```
|
||||
|
||||
The **Remove frame** and **Fit frame to content** actions in the main menu and the context menu's Edit submenu call these helpers when a frame is selected. **Frame selection** (`Cmd/Ctrl+Alt+G`) also removes frames when every selected shape is a frame.
|
||||
|
||||
## Exporting frames
|
||||
|
||||
Frames are export bounds containers: [ShapeUtil#isExportBoundsContainer](?) returns `true`. When a frame contains all the other shapes in an export, the export pipeline skips the usual padding around the result and uses the frame's bounds exactly. The export still includes every descendant of the frame.
|
||||
|
||||
This suits mockup workflows where you need output at a specific aspect ratio: design the frame, drop content inside, export.
|
||||
|
||||
## Frames vs. groups
|
||||
|
||||
Frames and groups both contain other shapes, but they solve different problems:
|
||||
|
||||
| | Frame | Group |
|
||||
| ---------------------------- | ---------------------------------------- | --------------------------------------- |
|
||||
| Visible on the canvas | Yes, a bordered rectangle with a heading | No |
|
||||
| Clips children to its bounds | Yes | No |
|
||||
| Has a fixed size and shape | Yes (`w`, `h`, position) | No (geometry follows children) |
|
||||
| Used for export bounds | Yes | No |
|
||||
| Auto-deletes when empty | No | Yes (collapses at one or zero children) |
|
||||
| Created by | Frame tool or `editor.createShape` | [Editor#groupShapes](?) |
|
||||
|
||||
Reach for a frame when you want a labeled, bounded region: a slide, a screen, an artboard. Reach for a group when you need to move several shapes together without changing their visual layout.
|
||||
|
||||
## Related articles
|
||||
|
||||
- [Shapes](/sdk-features/shapes#Frames) — How to build your own container shape with `BaseFrameLikeShapeUtil`
|
||||
- [Default shapes](/sdk-features/default-shapes#Frame) — Overview of all built-in shapes
|
||||
- [Shape clipping](/sdk-features/shape-clipping) — How custom shapes can clip their children
|
||||
- [Image export](/sdk-features/image-export) — Exporting frames and selections as images
|
||||
- [Parenting](/sdk-features/parenting) — How shapes are nested inside containers
|
||||
|
||||
## Related examples
|
||||
|
||||
- [Portal shapes](/examples/shapes/tools/portal-shapes) — A custom frame-like shape that teleports children between instances
|
||||
355
apps/docs/content/sdk-features/geo-shape.mdx
Normal file
355
apps/docs/content/sdk-features/geo-shape.mdx
Normal file
@@ -0,0 +1,355 @@
|
||||
---
|
||||
title: Geo shape
|
||||
created_at: 1/31/2026
|
||||
updated_at: 1/31/2026
|
||||
keywords:
|
||||
- geo
|
||||
- geometric
|
||||
- rectangle
|
||||
- ellipse
|
||||
- triangle
|
||||
- star
|
||||
- polygon
|
||||
- shapes
|
||||
- label
|
||||
- text
|
||||
- flowchart
|
||||
- diagram
|
||||
status: published
|
||||
date: 1/31/2026
|
||||
---
|
||||
|
||||
The geo shape is one of the default shapes in tldraw. It renders one of 20 built-in geometric forms (rectangles, ellipses, stars, clouds, and more) with an optional rich text label. Geo shapes are the usual building blocks for flowcharts and diagrams.
|
||||
|
||||
## Geometric forms
|
||||
|
||||
The geo shape supports a variety of built-in forms, grouped by type:
|
||||
|
||||
### Basic shapes
|
||||
|
||||
| Form | Description |
|
||||
| ----------- | -------------------------------------------- |
|
||||
| `rectangle` | Four-sided shape with right angles (default) |
|
||||
| `ellipse` | Oval or circular shape |
|
||||
| `triangle` | Three-sided shape pointing upward |
|
||||
| `diamond` | Square rotated 45 degrees |
|
||||
| `oval` | Stadium shape (rectangle with rounded ends) |
|
||||
|
||||
### Polygons
|
||||
|
||||
| Form | Description |
|
||||
| ---------- | --------------------------- |
|
||||
| `pentagon` | Five-sided regular polygon |
|
||||
| `hexagon` | Six-sided regular polygon |
|
||||
| `octagon` | Eight-sided regular polygon |
|
||||
| `star` | Five-pointed star |
|
||||
|
||||
### Parallelograms
|
||||
|
||||
| Form | Description |
|
||||
| ----------- | --------------------------------------------- |
|
||||
| `rhombus` | Parallelogram slanted to the right |
|
||||
| `rhombus-2` | Parallelogram slanted to the left |
|
||||
| `trapezoid` | Four-sided shape with parallel top and bottom |
|
||||
|
||||
### Directional arrows
|
||||
|
||||
| Form | Description |
|
||||
| ------------- | ----------------------------- |
|
||||
| `arrow-up` | Block arrow pointing upward |
|
||||
| `arrow-down` | Block arrow pointing downward |
|
||||
| `arrow-left` | Block arrow pointing left |
|
||||
| `arrow-right` | Block arrow pointing right |
|
||||
|
||||
### Special shapes
|
||||
|
||||
| Form | Description |
|
||||
| ----------- | ---------------------------------------------- |
|
||||
| `cloud` | Organic cloud shape with randomly varied bumps |
|
||||
| `heart` | Heart shape |
|
||||
| `x-box` | Rectangle with an X through it |
|
||||
| `check-box` | Rectangle with a checkmark inside |
|
||||
|
||||
## Creating geo shapes
|
||||
|
||||
Create a geo shape using [Editor#createShape](?):
|
||||
|
||||
```tsx
|
||||
import { toRichText } from 'tldraw'
|
||||
|
||||
editor.createShape({
|
||||
type: 'geo',
|
||||
x: 100,
|
||||
y: 100,
|
||||
props: {
|
||||
geo: 'rectangle',
|
||||
w: 200,
|
||||
h: 150,
|
||||
color: 'blue',
|
||||
fill: 'solid',
|
||||
dash: 'draw',
|
||||
size: 'm',
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Adding text labels
|
||||
|
||||
Geo shapes support rich text labels positioned inside the shape:
|
||||
|
||||
```tsx
|
||||
editor.createShape({
|
||||
type: 'geo',
|
||||
x: 100,
|
||||
y: 100,
|
||||
props: {
|
||||
geo: 'ellipse',
|
||||
w: 200,
|
||||
h: 150,
|
||||
richText: toRichText('Process step'),
|
||||
labelColor: 'black',
|
||||
align: 'middle',
|
||||
verticalAlign: 'middle',
|
||||
font: 'draw',
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
The label text wraps within the shape bounds. When text overflows the shape height, the shape grows vertically and tracks the extra height in `growY`. If the label's minimum width is wider than the shape, the shape widens to fit it.
|
||||
|
||||
### Changing the geometric form
|
||||
|
||||
Switch between forms by updating the `geo` property:
|
||||
|
||||
```tsx
|
||||
// Change a rectangle to an ellipse
|
||||
editor.updateShape({
|
||||
id: shapeId,
|
||||
type: 'geo',
|
||||
props: {
|
||||
geo: 'ellipse',
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
You can also set the default geometric form for the geo tool by updating the style for the next shape:
|
||||
|
||||
```tsx
|
||||
import { GeoShapeGeoStyle } from 'tldraw'
|
||||
|
||||
// Set the default geo style to star
|
||||
editor.setStyleForNextShapes(GeoShapeGeoStyle, 'star')
|
||||
```
|
||||
|
||||
The geo tool's toolbar shortcuts are R for rectangle and O for ellipse.
|
||||
|
||||
## Using the geo tool
|
||||
|
||||
The geo tool creates shapes through click or click-and-drag interactions.
|
||||
|
||||
### Click to create
|
||||
|
||||
Click anywhere on the canvas to create a shape at the default size. The shape centers on your click position. Different geometric forms have different default sizes:
|
||||
|
||||
| Form | Default size |
|
||||
| ------- | ------------ |
|
||||
| `star` | 200 × 190 |
|
||||
| `cloud` | 300 × 180 |
|
||||
| Other | 200 × 200 |
|
||||
|
||||
### Click and drag to create
|
||||
|
||||
Click and drag to create a shape at a custom size. The shape's corner follows your pointer as you drag. Release to complete the shape.
|
||||
|
||||
### Editing labels
|
||||
|
||||
Press Enter while a geo shape is selected to edit its label. The shape enters edit mode, where you can type or modify the rich text content. Press Escape to exit edit mode.
|
||||
|
||||
### Tool lock
|
||||
|
||||
When tool lock is enabled (via the toolbar or `editor.updateInstanceState({ isToolLocked: true })`), you can create multiple shapes without returning to the select tool after each one.
|
||||
|
||||
## Shape properties
|
||||
|
||||
| Property | Type | Description |
|
||||
| --------------- | ------------------------------- | ----------------------------------------------------------- |
|
||||
| `geo` | `TLGeoShapeGeoStyle` | The geometric form |
|
||||
| `w` | `number` | Width in pixels |
|
||||
| `h` | `number` | Height in pixels |
|
||||
| `richText` | `TLRichText` | Text label displayed inside the shape |
|
||||
| `color` | `TLDefaultColorStyle` | Stroke/outline color |
|
||||
| `labelColor` | `TLDefaultColorStyle` | Text label color (separate from stroke) |
|
||||
| `fill` | `TLDefaultFillStyle` | Fill style, e.g. `none`, `semi`, `solid`, `pattern` |
|
||||
| `dash` | `TLDefaultDashStyle` | Stroke pattern: `draw`, `solid`, `dashed`, `dotted`, `none` |
|
||||
| `size` | `TLDefaultSizeStyle` | Size preset affecting stroke width |
|
||||
| `font` | `TLDefaultFontStyle` | Font family for the label |
|
||||
| `align` | `TLDefaultHorizontalAlignStyle` | Horizontal text alignment |
|
||||
| `verticalAlign` | `TLDefaultVerticalAlignStyle` | Vertical text alignment |
|
||||
| `growY` | `number` | Additional vertical space for text overflow |
|
||||
| `url` | `string` | Optional hyperlink URL |
|
||||
| `scale` | `number` | Scale factor applied to the shape |
|
||||
| `flipX` | `boolean` | Mirror the shape horizontally |
|
||||
| `flipY` | `boolean` | Mirror the shape vertically |
|
||||
|
||||
## Configuration options
|
||||
|
||||
Configure [GeoShapeUtil](?) to adjust rendering behavior:
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
| ----------------- | --------- | ------- | ------------------------------------------------------------------------------------------------ |
|
||||
| `showTextOutline` | `boolean` | `true` | Whether to show a text outline (using the canvas background color) to improve label readability. |
|
||||
|
||||
```tsx
|
||||
import { GeoShapeUtil } from 'tldraw'
|
||||
|
||||
const ConfiguredGeoUtil = GeoShapeUtil.configure({
|
||||
showTextOutline: false,
|
||||
})
|
||||
```
|
||||
|
||||
Pass the configured utility to the `shapeUtils` prop:
|
||||
|
||||
```tsx
|
||||
<Tldraw shapeUtils={[ConfiguredGeoUtil]} />
|
||||
```
|
||||
|
||||
## Custom geo types
|
||||
|
||||
Register custom geo types via `customGeoTypes` to add new forms without forking [GeoShapeUtil](?). Custom types inherit all standard geo behavior (labels, fill/dash/color styling, resizing, SVG export, and hyperlinks) and provide their own path geometry, snap behavior, creation size, and style panel icon.
|
||||
|
||||
```tsx
|
||||
import { GeoShapeUtil, PathBuilder } from 'tldraw'
|
||||
|
||||
const MyGeoShapeUtil = GeoShapeUtil.configure({
|
||||
customGeoTypes: {
|
||||
'rounded-rect': {
|
||||
getPath: (w, h, shape, strokeWidth) => {
|
||||
const r = Math.min(w, h) * 0.2
|
||||
return new PathBuilder()
|
||||
.moveTo(r, 0, { geometry: { isFilled: shape.props.fill !== 'none' } })
|
||||
.lineTo(w - r, 0)
|
||||
.circularArcTo(r, false, true, w, r)
|
||||
.lineTo(w, h - r)
|
||||
.circularArcTo(r, false, true, w - r, h)
|
||||
.lineTo(r, h)
|
||||
.circularArcTo(r, false, true, 0, h - r)
|
||||
.lineTo(0, r)
|
||||
.circularArcTo(r, false, true, r, 0)
|
||||
.close()
|
||||
},
|
||||
snapType: 'polygon',
|
||||
icon: 'geo-rectangle',
|
||||
defaultSize: { w: 200, h: 150 },
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Each entry in `customGeoTypes` is a [GeoTypeDefinition](?). Keys that collide with a built-in form are ignored with a console warning.
|
||||
|
||||
| Field | Type | Description |
|
||||
| --------------- | ------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
|
||||
| `getPath` | `(w, h, shape, strokeWidth) => PathBuilder` | Returns the path geometry for this type at the given dimensions. |
|
||||
| `snapType` | `'polygon' \| 'blobby'` | `'polygon'` snaps to vertices and center; `'blobby'` snaps to center only. |
|
||||
| `icon` | `string` | Icon name used in the style panel's geo picker. |
|
||||
| `defaultSize` | `{ w: number; h: number }` (optional) | Size used when the shape is created via click rather than drag. Defaults to 200 × 200. |
|
||||
| `onDoubleClick` | `(shape) => { props } \| void` (optional) | Custom double-click handler. Return a partial props update to mutate the shape, or nothing to no-op. |
|
||||
|
||||
Custom types appear in the style panel's geo picker alongside the built-in shapes. See the [custom geo types example](/examples/shapes/tools/custom-geo-types) for a full implementation.
|
||||
|
||||
## Label positioning
|
||||
|
||||
The `align` and `verticalAlign` properties control where labels appear within the shape:
|
||||
|
||||
### Horizontal alignment
|
||||
|
||||
| Value | Position |
|
||||
| -------- | --------------------- |
|
||||
| `start` | Left edge of shape |
|
||||
| `middle` | Horizontally centered |
|
||||
| `end` | Right edge of shape |
|
||||
|
||||
### Vertical alignment
|
||||
|
||||
| Value | Position |
|
||||
| -------- | ------------------- |
|
||||
| `start` | Top of shape |
|
||||
| `middle` | Vertically centered |
|
||||
| `end` | Bottom of shape |
|
||||
|
||||
When the label text exceeds the shape's height, the shape automatically grows by adding to `growY`. The shape never shrinks below its original `h` value to accommodate shorter text—instead, `growY` returns to 0.
|
||||
|
||||
## Resizing behavior
|
||||
|
||||
Geo shapes resize from any corner or edge handle. When resizing a shape with a label:
|
||||
|
||||
- The shape won't shrink smaller than the label's measured dimensions
|
||||
- `growY` resets to 0 when you resize, and the shape recalculates the needed height
|
||||
- Dragging a handle past the opposite edge flips the shape by toggling `flipX` or `flipY`
|
||||
|
||||
When you first add a label to a shape smaller than 51 × 51 unscaled pixels, the shape grows to at least that size and becomes square.
|
||||
|
||||
## Special interactions
|
||||
|
||||
### Rectangle/checkbox toggle
|
||||
|
||||
Double-click a rectangle while holding Alt to convert it to a checkbox. Double-click the checkbox with Alt held to convert it back to a rectangle. This lets you quickly add checkmarks to items.
|
||||
|
||||
### Cloud shape variation
|
||||
|
||||
The cloud shape generates its bumps procedurally based on the shape's ID. Each cloud has unique bump positions, giving visual variety while maintaining a consistent style. Larger clouds have more bumps; smaller clouds have fewer but at least six.
|
||||
|
||||
### Handle snap geometry
|
||||
|
||||
When you drag a line handle (or any custom handle with `snapType` set) near a geo shape, it snaps to the shape's outline. Polygon-based forms (rectangle, triangle, pentagon, and so on) also snap to each vertex and the center; curved forms (ellipse, oval, cloud, heart) snap only to the center. Arrow terminals bind to geo shapes through a separate system; see [Bindings](/sdk-features/bindings).
|
||||
|
||||
## Dynamic resize mode
|
||||
|
||||
When dynamic resize mode is enabled in user preferences, new geo shapes scale inversely with zoom level. Drawing while zoomed out creates shapes that appear the same size on screen as they would at 100% zoom.
|
||||
|
||||
```tsx
|
||||
// Enable dynamic resize mode
|
||||
editor.user.updateUserPreferences({ isDynamicSizeMode: true })
|
||||
```
|
||||
|
||||
## Hyperlinks
|
||||
|
||||
Geo shapes can link to URLs. When a shape has a URL, a link button appears on the shape:
|
||||
|
||||
```tsx
|
||||
editor.createShape({
|
||||
type: 'geo',
|
||||
x: 100,
|
||||
y: 100,
|
||||
props: {
|
||||
geo: 'rectangle',
|
||||
w: 200,
|
||||
h: 100,
|
||||
url: 'https://tldraw.dev',
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Click the link button to open the URL in a new tab.
|
||||
|
||||
## Path rendering
|
||||
|
||||
Geo shapes use a path-based rendering system. Each geometric form, built-in or custom, has a [GeoTypeDefinition](?) whose `getPath` method generates the outline used for both fills and strokes. The same path drives solid, semi-transparent, and pattern fills as well as solid, dashed, and dotted stroke styles. Path calculations are cached per shape.
|
||||
|
||||
When the `dash` property is set to `'draw'`, the shape renders with organic, hand-drawn strokes. The randomness is seeded by the shape's ID, so each shape's hand-drawn look stays stable across renders.
|
||||
|
||||
## Geometry
|
||||
|
||||
The shape's geometry returns a [Group2d](?) containing:
|
||||
|
||||
1. The outline path geometry (polygon or curve depending on the form)
|
||||
2. A label rectangle for hit testing text interactions
|
||||
|
||||
The label rectangle is excluded from the shape's bounds but used for hit testing.
|
||||
|
||||
## Related articles
|
||||
|
||||
- [Default shapes](/sdk-features/default-shapes) — Overview of all built-in shapes
|
||||
- [Rich text](/sdk-features/rich-text) — Working with formatted text content
|
||||
- [Styles](/sdk-features/styles) — Working with shape styles like color and fill
|
||||
- [Tools](/sdk-features/tools) — How tools handle user input
|
||||
525
apps/docs/content/sdk-features/geometry.mdx
Normal file
525
apps/docs/content/sdk-features/geometry.mdx
Normal file
@@ -0,0 +1,525 @@
|
||||
---
|
||||
title: Geometry
|
||||
created_at: 12/26/2025
|
||||
updated_at: 12/29/2025
|
||||
keywords:
|
||||
- geometry
|
||||
- hit testing
|
||||
- bounds
|
||||
- collision
|
||||
- Geometry2d
|
||||
- shapes
|
||||
status: published
|
||||
date: 12/26/2025
|
||||
readability: 9
|
||||
voice: 9
|
||||
completeness: 9
|
||||
accuracy: 9
|
||||
notes: Point2d margin parameter documentation could clarify its actual usage in hitTestLineSegment.
|
||||
---
|
||||
|
||||
Geometry in tldraw is a mathematical description of a shape's form. Each shape has a [Geometry2d](?) that defines its outline, bounds, and spatial properties. The editor uses it to decide whether a click hit the shape, whether a selection brush intersects it, and where an arrow should snap to its edge. For the broader shape system, see [Shapes](/sdk-features/shapes).
|
||||
|
||||
## How geometry works
|
||||
|
||||
Each [ShapeUtil](?) implements a [ShapeUtil#getGeometry](?) method that returns a [Geometry2d](?) instance. The editor calls this method to calculate bounds, test hits, find intersections, and measure distances. It receives an optional [TLGeometryOpts](?) with a `context` string, and callers can pass the same option to [Editor#getShapeGeometry](?) to request context-specific geometry.
|
||||
|
||||
```typescript
|
||||
class MyShapeUtil extends ShapeUtil<MyShape> {
|
||||
getGeometry(shape: MyShape): Geometry2d {
|
||||
return new Rectangle2d({
|
||||
width: shape.props.w,
|
||||
height: shape.props.h,
|
||||
isFilled: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `isFilled` property controls hit testing behavior. A filled geometry registers hits inside its area. An unfilled geometry only responds to hits on its outline—useful for shapes like frames where you want to click through the middle.
|
||||
|
||||
Geometry also powers [snapping](/sdk-features/snapping). When you drag shapes, the snapping system uses geometry to find edges, centers, and corners to align to. Custom shapes can provide additional snap points by implementing [ShapeUtil#getBoundsSnapGeometry](?).
|
||||
|
||||
## Geometry primitives
|
||||
|
||||
The SDK includes geometry classes for common shapes.
|
||||
|
||||
### Rectangle2d
|
||||
|
||||
Axis-aligned rectangles. The most common geometry for box-shaped elements.
|
||||
|
||||
```typescript
|
||||
new Rectangle2d({
|
||||
width: 200,
|
||||
height: 100,
|
||||
isFilled: true,
|
||||
})
|
||||
```
|
||||
|
||||
You can offset the rectangle from the origin:
|
||||
|
||||
```typescript
|
||||
new Rectangle2d({
|
||||
x: 10,
|
||||
y: 10,
|
||||
width: 200,
|
||||
height: 100,
|
||||
isFilled: true,
|
||||
})
|
||||
```
|
||||
|
||||
### Ellipse2d
|
||||
|
||||
Circles and ellipses.
|
||||
|
||||
```typescript
|
||||
new Ellipse2d({
|
||||
width: 100,
|
||||
height: 100, // circle
|
||||
isFilled: true,
|
||||
})
|
||||
|
||||
new Ellipse2d({
|
||||
width: 200,
|
||||
height: 100, // ellipse
|
||||
isFilled: true,
|
||||
})
|
||||
```
|
||||
|
||||
### Circle2d
|
||||
|
||||
A specialized circle geometry that stores radius directly. The `x` and `y` parameters offset the circle's bounding box, not its center.
|
||||
|
||||
```typescript
|
||||
new Circle2d({
|
||||
radius: 50,
|
||||
isFilled: true,
|
||||
})
|
||||
|
||||
// Offset from origin
|
||||
new Circle2d({
|
||||
x: 10,
|
||||
y: 10,
|
||||
radius: 50,
|
||||
isFilled: true,
|
||||
})
|
||||
```
|
||||
|
||||
### Polygon2d
|
||||
|
||||
Arbitrary closed polygons defined by vertices.
|
||||
|
||||
```typescript
|
||||
new Polygon2d({
|
||||
points: [new Vec(0, 50), new Vec(100, 0), new Vec(100, 100), new Vec(0, 100)],
|
||||
isFilled: true,
|
||||
})
|
||||
```
|
||||
|
||||
Polygon2d requires at least three points and automatically closes the path.
|
||||
|
||||
### Polyline2d
|
||||
|
||||
Open paths defined by vertices. Use this for lines that don't form closed shapes. Requires at least two points.
|
||||
|
||||
```typescript
|
||||
new Polyline2d({
|
||||
points: [new Vec(0, 0), new Vec(50, 100), new Vec(100, 0)],
|
||||
})
|
||||
```
|
||||
|
||||
Polylines are never filled since they don't enclose an area. Polygon2d extends Polyline2d but sets `isClosed` to true.
|
||||
|
||||
### Edge2d
|
||||
|
||||
A single line segment between two points.
|
||||
|
||||
```typescript
|
||||
new Edge2d({
|
||||
start: new Vec(0, 0),
|
||||
end: new Vec(100, 100),
|
||||
})
|
||||
```
|
||||
|
||||
Arrows use Edge2d for straight arrow bodies.
|
||||
|
||||
### Arc2d
|
||||
|
||||
A circular arc defined by center, start, end, and arc flags. All parameters are required.
|
||||
|
||||
```typescript
|
||||
new Arc2d({
|
||||
center: new Vec(50, 50),
|
||||
start: new Vec(0, 50),
|
||||
end: new Vec(100, 50),
|
||||
sweepFlag: 1,
|
||||
largeArcFlag: 0,
|
||||
})
|
||||
```
|
||||
|
||||
The `sweepFlag` and `largeArcFlag` follow SVG arc conventions: `sweepFlag` controls clockwise vs counterclockwise direction, and `largeArcFlag` chooses between the two possible arcs. Arrows use Arc2d for curved arrow bodies.
|
||||
|
||||
### Stadium2d
|
||||
|
||||
A pill or capsule shape (rectangle with semicircular ends). The shorter dimension determines the radius of the rounded ends.
|
||||
|
||||
```typescript
|
||||
new Stadium2d({
|
||||
width: 200,
|
||||
height: 50,
|
||||
isFilled: true,
|
||||
})
|
||||
```
|
||||
|
||||
### CubicBezier2d
|
||||
|
||||
A single cubic bezier curve segment.
|
||||
|
||||
```typescript
|
||||
new CubicBezier2d({
|
||||
start: new Vec(0, 0),
|
||||
cp1: new Vec(30, 100),
|
||||
cp2: new Vec(70, 100),
|
||||
end: new Vec(100, 0),
|
||||
})
|
||||
```
|
||||
|
||||
### CubicSpline2d
|
||||
|
||||
A smooth curve through multiple points, automatically generating smooth cubic bezier segments between them.
|
||||
|
||||
```typescript
|
||||
new CubicSpline2d({
|
||||
points: [new Vec(0, 0), new Vec(50, 100), new Vec(100, 50), new Vec(150, 100)],
|
||||
})
|
||||
```
|
||||
|
||||
### Point2d
|
||||
|
||||
A single point. The constructor requires both `point` and `margin` parameters.
|
||||
|
||||
```typescript
|
||||
new Point2d({
|
||||
point: new Vec(50, 50),
|
||||
margin: 10,
|
||||
})
|
||||
```
|
||||
|
||||
The `margin` option doesn't expand the hit area: hit-test margins are supplied by the editor at query time, like every other geometry.
|
||||
|
||||
### Group2d
|
||||
|
||||
Combines multiple geometries into a single composite geometry. The children don't need to be the same type.
|
||||
|
||||
```typescript
|
||||
new Group2d({
|
||||
children: [
|
||||
new Rectangle2d({ width: 100, height: 80, isFilled: true }),
|
||||
new Circle2d({ x: 50, y: -20, radius: 20, isFilled: true }),
|
||||
],
|
||||
})
|
||||
```
|
||||
|
||||
Use Group2d for shapes with multiple parts. The geo shape uses it to combine its outline with its label bounds. The arrow shape uses it to combine the arrow body with its label. Nested groups are flattened: a Group2d passed as a child contributes its own children.
|
||||
|
||||
## Geometry operations
|
||||
|
||||
All Geometry2d classes provide methods for spatial queries.
|
||||
|
||||
### Bounds and center
|
||||
|
||||
Get the axis-aligned bounding box:
|
||||
|
||||
```typescript
|
||||
const geometry = editor.getShapeGeometry(shape)
|
||||
const bounds = geometry.bounds // Box { x, y, w, h, ... }
|
||||
const center = geometry.center // Vec at center of bounds
|
||||
```
|
||||
|
||||
### Vertices
|
||||
|
||||
Get the points that define the geometry's outline:
|
||||
|
||||
```typescript
|
||||
const vertices = geometry.vertices // Vec[]
|
||||
```
|
||||
|
||||
For curves, this returns a discretized approximation. By default `vertices` excludes label geometry.
|
||||
|
||||
### Hit testing
|
||||
|
||||
Test if a point hits the geometry:
|
||||
|
||||
```typescript
|
||||
geometry.hitTestPoint(point, margin, hitInside)
|
||||
```
|
||||
|
||||
The `margin` expands the hit area. The `hitInside` parameter controls whether points inside unfilled shapes count as hits.
|
||||
|
||||
Test if a line segment passes within `distance` of the geometry:
|
||||
|
||||
```typescript
|
||||
geometry.hitTestLineSegment(A, B, distance)
|
||||
```
|
||||
|
||||
After a hit succeeds, the editor calls `geometry.ignoreHit(point)`. Override it to reject hits at specific points and let shapes behind this one be selected instead; the image shape uses this for transparent pixels.
|
||||
|
||||
### Distance and intersection
|
||||
|
||||
Find the nearest point on the geometry to a given point:
|
||||
|
||||
```typescript
|
||||
const nearest = geometry.nearestPoint(point)
|
||||
```
|
||||
|
||||
Get the distance from a point to the geometry:
|
||||
|
||||
```typescript
|
||||
const distance = geometry.distanceToPoint(point)
|
||||
```
|
||||
|
||||
Negative distances mean the point is inside a filled geometry.
|
||||
|
||||
Get intersection points with a line segment, circle, polygon, or polyline:
|
||||
|
||||
```typescript
|
||||
const intersections = geometry.intersectLineSegment(A, B)
|
||||
const circleHits = geometry.intersectCircle(center, radius)
|
||||
const polygonHits = geometry.intersectPolygon(points)
|
||||
```
|
||||
|
||||
### Length, area, and interpolation
|
||||
|
||||
Get the perimeter length and area:
|
||||
|
||||
```typescript
|
||||
const length = geometry.length // perimeter length
|
||||
const area = geometry.area // enclosed area (0 for open paths)
|
||||
```
|
||||
|
||||
A Group2d reports the area of its first child, not the union of its children.
|
||||
|
||||
Find a point at a fraction along the edge:
|
||||
|
||||
```typescript
|
||||
const point = geometry.interpolateAlongEdge(0.5) // midpoint
|
||||
```
|
||||
|
||||
Convert a point back to a fraction:
|
||||
|
||||
```typescript
|
||||
const t = geometry.uninterpolateAlongEdge(point)
|
||||
```
|
||||
|
||||
Generate an SVG path:
|
||||
|
||||
```typescript
|
||||
const pathData = geometry.toSimpleSvgPath() // "M0,0 L100,0 L100,100 L0,100 Z"
|
||||
```
|
||||
|
||||
## Implementing getGeometry
|
||||
|
||||
The `getGeometry` method receives the shape and returns geometry in shape-local coordinates (origin at top-left of shape).
|
||||
|
||||
### Simple shapes
|
||||
|
||||
For shapes with a single outline:
|
||||
|
||||
```typescript
|
||||
getGeometry(shape: MyShape) {
|
||||
return new Rectangle2d({
|
||||
width: shape.props.w,
|
||||
height: shape.props.h,
|
||||
isFilled: shape.props.fill !== 'none',
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Shapes with labels
|
||||
|
||||
Shapes that have text labels typically return a Group2d with the main geometry and a label rectangle:
|
||||
|
||||
```typescript
|
||||
getGeometry(shape: MyShape) {
|
||||
const outline = new Rectangle2d({
|
||||
width: shape.props.w,
|
||||
height: shape.props.h,
|
||||
isFilled: shape.props.fill !== 'none',
|
||||
})
|
||||
|
||||
const label = new Rectangle2d({
|
||||
x: labelX,
|
||||
y: labelY,
|
||||
width: labelWidth,
|
||||
height: labelHeight,
|
||||
isFilled: true,
|
||||
isLabel: true,
|
||||
})
|
||||
|
||||
return new Group2d({
|
||||
children: [outline, label],
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
The `isLabel` property marks geometry that represents text labels; see [Geometry filtering](#Geometry-filtering).
|
||||
|
||||
### Custom polygons
|
||||
|
||||
For non-rectangular shapes, calculate vertices and use Polygon2d:
|
||||
|
||||
```typescript
|
||||
getGeometry(shape: HouseShape) {
|
||||
const { w, h } = shape.props
|
||||
const roofPeak = h * 0.3
|
||||
|
||||
return new Polygon2d({
|
||||
points: [
|
||||
new Vec(0, roofPeak),
|
||||
new Vec(w / 2, 0),
|
||||
new Vec(w, roofPeak),
|
||||
new Vec(w, h),
|
||||
new Vec(0, h),
|
||||
],
|
||||
isFilled: true,
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Composite shapes
|
||||
|
||||
For shapes with multiple distinct parts:
|
||||
|
||||
```typescript
|
||||
getGeometry(shape: HouseShape) {
|
||||
const house = new Polygon2d({
|
||||
points: getHouseVertices(shape),
|
||||
isFilled: true,
|
||||
})
|
||||
|
||||
const door = new Rectangle2d({
|
||||
x: shape.props.w / 2 - 15,
|
||||
y: shape.props.h - 40,
|
||||
width: 30,
|
||||
height: 40,
|
||||
isFilled: true,
|
||||
})
|
||||
|
||||
return new Group2d({
|
||||
children: [house, door],
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## Geometry caching
|
||||
|
||||
The editor caches geometry computations. Without caching, dragging a selection box over hundreds of shapes would recompute each shape's geometry on every frame.
|
||||
|
||||
Access cached geometry through the editor:
|
||||
|
||||
```typescript
|
||||
const geometry = editor.getShapeGeometry(shape)
|
||||
const pageBounds = editor.getShapePageBounds(shape)
|
||||
```
|
||||
|
||||
The cache invalidates automatically when a shape's props or meta change. You don't need to manage invalidation yourself.
|
||||
|
||||
## Geometry filtering
|
||||
|
||||
Group2d supports filtering to include or exclude certain geometry children during operations. This lets you mark parts of a shape's geometry for different purposes.
|
||||
|
||||
The `isLabel` flag marks geometry that represents text label bounds. Label geometry participates in click-to-edit detection but is typically excluded from outline calculations and snapping.
|
||||
|
||||
The `isInternal` flag marks geometry that exists for internal calculations but shouldn't be part of the shape's visible outline.
|
||||
|
||||
```typescript
|
||||
// Mark geometry as a label
|
||||
new Rectangle2d({
|
||||
// ...
|
||||
isLabel: true,
|
||||
})
|
||||
|
||||
// Mark geometry as internal (not part of main outline)
|
||||
new Rectangle2d({
|
||||
// ...
|
||||
isInternal: true,
|
||||
})
|
||||
```
|
||||
|
||||
The geometry system provides filter presets for common scenarios:
|
||||
|
||||
| Filter | Includes labels | Includes internal |
|
||||
| ---------------------- | --------------- | ----------------- |
|
||||
| `EXCLUDE_NON_STANDARD` | No | No |
|
||||
| `INCLUDE_ALL` | Yes | Yes |
|
||||
| `EXCLUDE_LABELS` | No | Yes |
|
||||
| `EXCLUDE_INTERNAL` | Yes | No |
|
||||
|
||||
The `vertices` and `length` getters, and Group2d's hit tests, default to `EXCLUDE_LABELS`: labels are left out but internal geometry is included. Arrow routing passes `EXCLUDE_NON_STANDARD` to get the bare outline. Pass a filter explicitly to any method that accepts one when you need something else.
|
||||
|
||||
## Advanced options
|
||||
|
||||
Geometry2d has additional options for special cases.
|
||||
|
||||
### excludeFromShapeBounds
|
||||
|
||||
When set, the geometry won't contribute to the shape's bounding box calculation. The geometry still participates in hit testing and other operations, but `getBoundsVertices()` returns an empty array for it.
|
||||
|
||||
```typescript
|
||||
const label = new Rectangle2d({
|
||||
x: labelX,
|
||||
y: labelY,
|
||||
width: labelWidth,
|
||||
height: labelHeight,
|
||||
isFilled: true,
|
||||
isLabel: true,
|
||||
excludeFromShapeBounds: true, // label won't affect shape bounds
|
||||
})
|
||||
```
|
||||
|
||||
### ignore
|
||||
|
||||
When set on geometry inside a Group2d, that geometry is placed in an `ignoredChildren` array and won't participate in the group's hit testing, bounds, or other queries. The geometry debug view still draws it.
|
||||
|
||||
```typescript
|
||||
new Group2d({
|
||||
children: [
|
||||
mainGeometry,
|
||||
new Rectangle2d({
|
||||
// ...
|
||||
ignore: true, // won't participate in group operations
|
||||
}),
|
||||
],
|
||||
})
|
||||
```
|
||||
|
||||
### debugColor
|
||||
|
||||
A color string used when rendering geometry in the debug view. Defaults to red if not specified.
|
||||
|
||||
```typescript
|
||||
new Rectangle2d({
|
||||
width: 100,
|
||||
height: 100,
|
||||
isFilled: true,
|
||||
debugColor: 'blue', // shows as blue in geometry debugging view
|
||||
})
|
||||
```
|
||||
|
||||
Turn on the `debugGeometry` flag in the debug menu to draw shape geometry on the canvas during development.
|
||||
|
||||
## Transformed geometry
|
||||
|
||||
The [TransformedGeometry2d](?) class wraps a geometry with a transformation matrix, so you can query it in a different coordinate space without rebuilding it. Group shapes use this to combine their children's geometry in the group's local space.
|
||||
|
||||
```typescript
|
||||
const transformed = geometry.transform(matrix)
|
||||
```
|
||||
|
||||
All operations on the transformed geometry apply the transformation automatically. One limitation: transformed geometry throws from `getSvgPathData()`. Call it on the source geometry and transform the result if you need path data.
|
||||
|
||||
## Related examples
|
||||
|
||||
- **[Custom shape geometry](/examples/shapes/tools/shape-with-geometry)** - A house-shaped custom shape using Polygon2d and Group2d geometry.
|
||||
- **[Cubic bezier curve shape](/examples/shapes/tools/cubic-bezier-shape)** - Interactive bezier curve editing with CubicBezier2d geometry and custom handles.
|
||||
- **[Custom bounds snapping](/examples/shapes/tools/bounds-snapping-shape)** - Playing card shapes with custom snap geometry so they stack with visible icons.
|
||||
139
apps/docs/content/sdk-features/grid.mdx
Normal file
139
apps/docs/content/sdk-features/grid.mdx
Normal file
@@ -0,0 +1,139 @@
|
||||
---
|
||||
title: Grid
|
||||
created_at: 1/31/2026
|
||||
updated_at: 1/31/2026
|
||||
keywords:
|
||||
- grid
|
||||
- background
|
||||
- canvas grid
|
||||
- snap to grid
|
||||
- grid spacing
|
||||
status: draft
|
||||
date: 1/31/2026
|
||||
---
|
||||
|
||||
The grid is a dotted background that helps users align shapes on the canvas. When grid mode is on, the editor renders the grid behind shapes and snaps shapes to grid positions as users create, move, and resize them.
|
||||
|
||||
## Enabling grid mode
|
||||
|
||||
Grid mode is part of the editor's [instance state](/sdk-features/instance-state). Toggle it with [Editor#updateInstanceState](?):
|
||||
|
||||
```tsx
|
||||
import { Tldraw } from 'tldraw'
|
||||
import 'tldraw/tldraw.css'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0 }}>
|
||||
<Tldraw
|
||||
onMount={(editor) => {
|
||||
editor.updateInstanceState({ isGridMode: true })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Users can toggle the grid with Cmd+' (Ctrl+' on Windows) or from the Preferences submenu in the main menu. Grid mode is session state: it persists locally but doesn't sync to collaborators.
|
||||
|
||||
```tsx
|
||||
// Read the current grid mode
|
||||
const isGridMode = editor.getInstanceState().isGridMode
|
||||
```
|
||||
|
||||
## Grid size
|
||||
|
||||
Grid spacing comes from the document's `gridSize` setting, which defaults to 10 page units. Change it with [Editor#updateDocumentSettings](?):
|
||||
|
||||
```tsx
|
||||
editor.updateDocumentSettings({ gridSize: 20 })
|
||||
```
|
||||
|
||||
Because `gridSize` lives on the [TLDocument](?) record, it saves with the document and syncs to collaborators.
|
||||
|
||||
## Snap to grid
|
||||
|
||||
When grid mode is on, the editor snaps positions to multiples of `gridSize` during these interactions:
|
||||
|
||||
- Creating a shape by clicking or dragging with a box tool
|
||||
- Translating shapes with the select tool
|
||||
- Resizing and cropping shapes with the selection handles
|
||||
- Nudging shapes with the arrow keys (one grid unit per press, five with Shift)
|
||||
- Dragging unbound arrow and line handles
|
||||
- Dropping external content (like images) onto the canvas
|
||||
|
||||
Holding Cmd (Ctrl on Windows) while translating, resizing, or cropping bypasses grid snapping. During translation, grid snapping also yields to [shape snapping](/sdk-features/snapping): if the selection has snapped to another shape's alignment guides, the grid is ignored.
|
||||
|
||||
To snap your own values to the grid, use [maybeSnapToGrid](?), which returns the point snapped when grid mode is on and unchanged otherwise:
|
||||
|
||||
```tsx
|
||||
import { maybeSnapToGrid } from 'tldraw'
|
||||
|
||||
const point = maybeSnapToGrid(editor.inputs.getCurrentPagePoint(), editor)
|
||||
```
|
||||
|
||||
To snap regardless of grid mode, use [Vec#snapToGrid](?) with the size from [Editor#getDocumentSettings](?):
|
||||
|
||||
```tsx
|
||||
const gridSize = editor.getDocumentSettings().gridSize
|
||||
const point = editor.inputs.getCurrentPagePoint().clone().snapToGrid(gridSize)
|
||||
```
|
||||
|
||||
## Grid rendering
|
||||
|
||||
The [DefaultGrid](?) component draws the grid as SVG dot patterns. It renders one pattern per entry in the editor's `gridSteps` option. Each step describes a dot spacing (in multiples of `gridSize`) and the zoom range over which those dots fade in:
|
||||
|
||||
| Field | Description |
|
||||
| ------ | ----------------------------------------------------- |
|
||||
| `step` | Dot spacing as a multiple of the document's grid size |
|
||||
| `min` | Zoom level at which the dots start to fade in |
|
||||
| `mid` | Zoom level at which the dots reach full opacity |
|
||||
|
||||
The defaults show fine dots when zoomed in and progressively coarser dots when zoomed out, so the grid stays readable at any zoom level. Style the dots with the `.tl-grid-dot` CSS class. You can change the steps through the `options` prop:
|
||||
|
||||
```tsx
|
||||
<Tldraw
|
||||
options={{
|
||||
gridSteps: [
|
||||
{ min: -1, mid: 0.15, step: 64 },
|
||||
{ min: 0.05, mid: 0.375, step: 16 },
|
||||
{ min: 0.15, mid: 1, step: 4 },
|
||||
{ min: 0.7, mid: 2.5, step: 1 },
|
||||
],
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
## Custom grid
|
||||
|
||||
Replace the grid entirely by overriding the `Grid` component. Your component receives [TLGridProps](?): the camera position (`x`, `y`, `z`) and the document's grid `size`.
|
||||
|
||||
```tsx
|
||||
import { TLComponents, Tldraw } from 'tldraw'
|
||||
import 'tldraw/tldraw.css'
|
||||
|
||||
const components: TLComponents = {
|
||||
Grid: ({ x, y, z, size }) => {
|
||||
// Render your own grid using the camera position and grid size.
|
||||
// The tl-grid class positions the element correctly on the canvas.
|
||||
return <canvas className="tl-grid" />
|
||||
},
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0 }}>
|
||||
<Tldraw components={components} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
The grid component only renders while grid mode is on. See the [custom grid example](/examples/ui/custom-grid) for a complete canvas-based grid with major and minor lines.
|
||||
|
||||
## Related articles
|
||||
|
||||
- [Snapping](/sdk-features/snapping) — Shape-to-shape snapping and alignment guides
|
||||
- [Instance state](/sdk-features/instance-state) — Session state including grid mode
|
||||
- [UI components](/sdk-features/ui-components) — Overriding canvas components like the grid
|
||||
189
apps/docs/content/sdk-features/groups.mdx
Normal file
189
apps/docs/content/sdk-features/groups.mdx
Normal file
@@ -0,0 +1,189 @@
|
||||
---
|
||||
title: Groups
|
||||
created_at: 12/25/2025
|
||||
updated_at: 12/29/2025
|
||||
keywords:
|
||||
- groups
|
||||
- grouping
|
||||
- focused group
|
||||
- hierarchy
|
||||
- nesting
|
||||
status: published
|
||||
date: 12/25/2025
|
||||
readability: 9
|
||||
voice: 9
|
||||
completeness: 9
|
||||
accuracy: 9
|
||||
notes: Add API link formatting using [Editor#methodName](?) syntax. Fix blockquote formatting for examples link.
|
||||
---
|
||||
|
||||
Groups are logical containers that combine multiple shapes into a single selectable unit. Unlike frames, groups draw nothing of their own apart from a dashed outline while the group is focused. Their geometry is the union of their children's geometries, their bounds update automatically as children change, and they clean themselves up when their contents are deleted. Groups can be nested inside other groups.
|
||||
|
||||
## Creating groups
|
||||
|
||||
Use [Editor#groupShapes](?) to combine shapes into a group. Pass an array of shape IDs or shape objects, and the method creates a new group containing those shapes:
|
||||
|
||||
```typescript
|
||||
import { createShapeId } from 'tldraw'
|
||||
|
||||
// Group selected shapes
|
||||
editor.groupShapes(editor.getSelectedShapeIds())
|
||||
|
||||
// Group specific shapes
|
||||
editor.groupShapes([shapeId1, shapeId2, shapeId3])
|
||||
|
||||
// Group with options
|
||||
editor.groupShapes([shapeId1, shapeId2], {
|
||||
groupId: createShapeId('my-group'), // Custom ID for the group
|
||||
select: false, // Don't select the group after creation
|
||||
})
|
||||
```
|
||||
|
||||
Grouping requires at least two shapes. The method does nothing if you pass fewer, and it skips [locked shapes](/sdk-features/locked-shapes). Users can also group shapes with Ctrl+G (Cmd+G on Mac); when the only selected shape is a group, the same shortcut ungroups it.
|
||||
|
||||
The new group is positioned at the top-left of the combined bounds of all grouped shapes. It takes the z-index of the topmost grouped shape, so it sits where that shape was in its parent's layer stack.
|
||||
|
||||
## Ungrouping
|
||||
|
||||
Use [Editor#ungroupShapes](?) to dissolve groups and release their children:
|
||||
|
||||
```typescript
|
||||
// Ungroup selected groups
|
||||
editor.ungroupShapes(editor.getSelectedShapeIds())
|
||||
|
||||
// Ungroup specific groups
|
||||
editor.ungroupShapes([groupId])
|
||||
|
||||
// Ungroup without selecting the released children
|
||||
editor.ungroupShapes([groupId], { select: false })
|
||||
```
|
||||
|
||||
Ungrouping moves children to the group's parent and preserves their page positions and rotations. Layer order is maintained: children appear where the group was in the z-stack. If you pass a mix of groups and non-groups, only the groups are ungrouped and the non-groups stay selected. Users can ungroup with Ctrl+Shift+G (Cmd+Shift+G on Mac).
|
||||
|
||||
Ungrouping is not recursive. If a group contains other groups, those inner groups remain intact. Ungroup them separately if needed.
|
||||
|
||||
## Focused groups
|
||||
|
||||
The editor tracks a focused group that defines the current editing scope. When you're focused inside a group, you can select and manipulate the shapes within it. Without focus, clicking a shape inside a group selects the group itself, not the individual shape.
|
||||
|
||||
Use [Editor#getFocusedGroup](?), [Editor#getFocusedGroupId](?), [Editor#setFocusedGroup](?), and [Editor#popFocusedGroupId](?):
|
||||
|
||||
```typescript
|
||||
// Get the current focused group
|
||||
const focusedGroup = editor.getFocusedGroup()
|
||||
|
||||
// Get the focused group ID (returns page ID if no group is focused)
|
||||
const focusedId = editor.getFocusedGroupId()
|
||||
|
||||
// Focus a specific group
|
||||
editor.setFocusedGroup(groupId)
|
||||
|
||||
// Exit the current focused group and select it
|
||||
editor.popFocusedGroupId()
|
||||
```
|
||||
|
||||
The editor manages focus automatically based on selection:
|
||||
|
||||
- Selecting a shape inside a group focuses that group
|
||||
- Selecting shapes across multiple groups focuses their common ancestor
|
||||
- Pressing Escape pops focus one level up (or back to the page)
|
||||
- Pressing Enter with only groups selected selects their children
|
||||
- Clearing selection keeps the current focus
|
||||
|
||||
### Layered selection
|
||||
|
||||
Clicking shapes inside groups follows a layered pattern:
|
||||
|
||||
1. First click selects the outermost group
|
||||
2. Second click focuses the group and selects the parent of the target shape (or the shape if directly inside)
|
||||
3. Further clicks drill down through nested groups
|
||||
|
||||
This lets you work at different levels of the hierarchy without keyboard modifiers.
|
||||
|
||||
## Nested groups
|
||||
|
||||
Groups can contain other groups, creating hierarchies:
|
||||
|
||||
```typescript
|
||||
// Create a nested structure
|
||||
editor.select(boxA, boxB)
|
||||
editor.groupShapes(editor.getSelectedShapeIds())
|
||||
const innerGroupId = editor.getOnlySelectedShapeId()
|
||||
|
||||
editor.select(innerGroupId, boxC, boxD)
|
||||
editor.groupShapes(editor.getSelectedShapeIds())
|
||||
const outerGroupId = editor.getOnlySelectedShapeId()
|
||||
|
||||
// Result:
|
||||
// outerGroup
|
||||
// ├── innerGroup
|
||||
// │ ├── boxA
|
||||
// │ └── boxB
|
||||
// ├── boxC
|
||||
// └── boxD
|
||||
```
|
||||
|
||||
When grouping shapes that already have different parents, the editor finds their common ancestor and creates the group there. This prevents orphaning shapes from their natural hierarchy.
|
||||
|
||||
## Automatic cleanup
|
||||
|
||||
Groups maintain themselves through the [ShapeUtil#onChildrenChange](?) lifecycle hook. If you delete all children of a group, the group is removed. If a group ends up with only one child, it dissolves and reparents that child to the group's parent.
|
||||
|
||||
This cleanup happens immediately when children change, so you never end up with degenerate groups.
|
||||
|
||||
```typescript
|
||||
// Start with a group containing boxA and boxB
|
||||
editor.deleteShapes([boxA])
|
||||
// Group now has only boxB, so it auto-ungroups
|
||||
// boxB is now a direct child of the page (or the group's former parent)
|
||||
```
|
||||
|
||||
## Bounds and transforms
|
||||
|
||||
Group bounds are computed from their children's geometries. The [Group2d](?) geometry class aggregates all child geometries for hit testing and bounds calculation:
|
||||
|
||||
```typescript
|
||||
// Get a group's bounds
|
||||
const bounds = editor.getShapePageBounds(groupId)
|
||||
|
||||
// Bounds update automatically when children move
|
||||
editor.updateShape({ id: childId, type: 'geo', x: newX, y: newY })
|
||||
const updatedBounds = editor.getShapePageBounds(groupId)
|
||||
```
|
||||
|
||||
When you transform a group (move, rotate, resize), all children transform with it. The group's position and rotation are applied to children through the standard parent-child transform composition.
|
||||
|
||||
Position preservation works both ways. When you group shapes, their page positions are preserved—only their `parentId` and local coordinates change. When you ungroup, shapes return to their original page positions even if the group was rotated.
|
||||
|
||||
## Creating shapes inside groups
|
||||
|
||||
When you create new shapes while focused inside a group, those shapes become children of the focused group unless they're positioned over a container such as a frame, which takes precedence:
|
||||
|
||||
```typescript
|
||||
// Focus a group by selecting a shape inside it
|
||||
editor.select(shapeInsideGroup)
|
||||
// Now getFocusedGroupId() returns the group's ID
|
||||
|
||||
// Create a new shape - it becomes a child of the focused group
|
||||
editor.createShape({
|
||||
type: 'geo',
|
||||
x: 100,
|
||||
y: 100,
|
||||
props: { w: 50, h: 50 },
|
||||
})
|
||||
// The new shape's parentId is the focused group
|
||||
```
|
||||
|
||||
If no group is focused, new shapes are created on the current page.
|
||||
|
||||
## Limitations
|
||||
|
||||
Arrows can't bind to groups. [ShapeUtil#canBind](?) returns `false` for group shapes, so arrows must bind to individual shapes within the group.
|
||||
|
||||
Groups have no visual properties. You can't style a group itself, only its children.
|
||||
|
||||
Both grouping and ungrouping require the select tool to be active, and do nothing in readonly mode. If you're in the middle of another interaction, the editor cancels it before running the operation.
|
||||
|
||||
## Related examples
|
||||
|
||||
- [Layer panel](/examples/ui/layer-panel) - Build a hierarchical layer panel that shows shape and group structure with visibility controls.
|
||||
267
apps/docs/content/sdk-features/handles.mdx
Normal file
267
apps/docs/content/sdk-features/handles.mdx
Normal file
@@ -0,0 +1,267 @@
|
||||
---
|
||||
title: Handles
|
||||
status: published
|
||||
date: 3/22/2023
|
||||
keywords:
|
||||
- handles
|
||||
- custom
|
||||
- shapes
|
||||
- control points
|
||||
- snapping
|
||||
---
|
||||
|
||||
In tldraw, handles are interactive control points on shapes that let users manipulate shape geometry. Arrows have handles at their endpoints, lines have handles at each vertex, and notes have clone handles for quick duplication.
|
||||
|
||||
## Handle basics
|
||||
|
||||
Handles appear when a single shape is selected with the select tool. Each handle has a position, type, and optional snapping behavior. You define handles by implementing [ShapeUtil#getHandles](?) on your [ShapeUtil](?):
|
||||
|
||||
```tsx
|
||||
import { ShapeUtil, TLHandle, ZERO_INDEX_KEY } from 'tldraw'
|
||||
|
||||
class MyShapeUtil extends ShapeUtil<MyShape> {
|
||||
// ...
|
||||
|
||||
override getHandles(shape: MyShape): TLHandle[] {
|
||||
return [
|
||||
{
|
||||
id: 'point',
|
||||
type: 'vertex',
|
||||
index: ZERO_INDEX_KEY,
|
||||
x: shape.props.pointX,
|
||||
y: shape.props.pointY,
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Handle coordinates are in the shape's local coordinate system, where `(0, 0)` is the shape's top-left corner.
|
||||
|
||||
## Handle types
|
||||
|
||||
There are four handle types:
|
||||
|
||||
| Type | Description |
|
||||
| --------- | ----------------------------------------------------------------------------- |
|
||||
| `vertex` | A primary control point that defines part of the shape's geometry |
|
||||
| `virtual` | A secondary handle that isn't a vertex, like the arrow's midpoint bend handle |
|
||||
| `create` | A handle for adding new geometry, like inserting a point into a line segment |
|
||||
| `clone` | A handle for duplicating the shape, used by notes for quick adjacent copies |
|
||||
|
||||
Most custom shapes use `vertex` handles. The arrow shape uses a `virtual` handle for its midpoint, and the line shape uses `create` handles to let users add points between vertices.
|
||||
|
||||
## Responding to handle drags
|
||||
|
||||
When a user drags a handle, tldraw calls [ShapeUtil#onHandleDrag](?) with the updated handle position. Return a partial of the shape with the changed props:
|
||||
|
||||
```tsx
|
||||
import { ShapeUtil, TLHandleDragInfo } from 'tldraw'
|
||||
|
||||
class SpeechBubbleUtil extends ShapeUtil<SpeechBubbleShape> {
|
||||
// ...
|
||||
|
||||
override onHandleDrag(shape: SpeechBubbleShape, { handle }: TLHandleDragInfo<SpeechBubbleShape>) {
|
||||
return {
|
||||
props: { tailX: handle.x, tailY: handle.y },
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `handle` in [TLHandleDragInfo](?) carries the new `x` and `y` after any snapping. The info object also has these fields:
|
||||
|
||||
| Field | Description |
|
||||
| ----------------- | ----------------------------------------------------------------------- |
|
||||
| `initial` | The shape as it was when the drag started |
|
||||
| `isPrecise` | Whether the user is dragging precisely, for example by holding Alt |
|
||||
| `isCreatingShape` | Whether the handle drag is part of creating the shape, like a new arrow |
|
||||
|
||||
### Lifecycle callbacks
|
||||
|
||||
For more control over handle interactions, implement these additional methods:
|
||||
|
||||
| Method | When it's called |
|
||||
| -------------------- | ----------------------------------- |
|
||||
| `onHandleDragStart` | When the user starts dragging |
|
||||
| `onHandleDragEnd` | When the user releases the handle |
|
||||
| `onHandleDragCancel` | When the drag is cancelled (escape) |
|
||||
|
||||
## Handle snapping
|
||||
|
||||
Handles can snap to other shapes' geometry. Set `snapType` on the handle. Snapping engages while the user holds Ctrl (Cmd on Mac); with snap mode turned on in preferences, it's the reverse: snapping is on and Ctrl disables it. The older `canSnap: true` flag is deprecated; use `snapType: 'point'` instead. See [Snapping](/sdk-features/snapping) for how snapping works across the editor.
|
||||
|
||||
```tsx
|
||||
{
|
||||
id: 'end',
|
||||
type: 'vertex',
|
||||
index: ZERO_INDEX_KEY,
|
||||
x: shape.props.endX,
|
||||
y: shape.props.endY,
|
||||
snapType: 'point', // Snap to points on other shapes
|
||||
}
|
||||
```
|
||||
|
||||
The `snapType` options are:
|
||||
|
||||
| Value | Behavior |
|
||||
| --------- | -------------------------------------------------------------------------------------- |
|
||||
| `'point'` | Snaps to key points on other shapes first, then to the nearest point on their outlines |
|
||||
| `'align'` | Snaps the handle's x and y independently to the x and y of key points on other shapes |
|
||||
|
||||
### Angle snapping
|
||||
|
||||
When the user holds Shift while dragging, handles snap to 15-degree angles. By default, the angle is measured relative to the next vertex handle on the shape. You can snap relative to a specific handle by setting `snapReferenceHandleId`:
|
||||
|
||||
```tsx
|
||||
{
|
||||
id: 'controlPoint',
|
||||
type: 'vertex',
|
||||
index: getIndexAbove(ZERO_INDEX_KEY),
|
||||
x: shape.props.cpX,
|
||||
y: shape.props.cpY,
|
||||
snapType: 'align',
|
||||
snapReferenceHandleId: 'start', // Angle snaps relative to 'start' handle
|
||||
}
|
||||
```
|
||||
|
||||
Bezier curves use this so control points snap to angles relative to their associated endpoint.
|
||||
|
||||
### Custom snap geometry
|
||||
|
||||
By default, handles snap to a shape's outline (its geometry) and to no key points. Override [ShapeUtil#getHandleSnapGeometry](?) to customize what handles snap to:
|
||||
|
||||
```tsx
|
||||
import { HandleSnapGeometry, ShapeUtil } from 'tldraw'
|
||||
|
||||
class BezierCurveUtil extends ShapeUtil<BezierCurveShape> {
|
||||
// ...
|
||||
|
||||
override getHandleSnapGeometry(shape: BezierCurveShape): HandleSnapGeometry {
|
||||
return {
|
||||
// Points other shapes' handles can snap to
|
||||
points: [shape.props.start, shape.props.end],
|
||||
|
||||
// Points this shape's own handles can snap to (for self-snapping)
|
||||
getSelfSnapPoints: (handle) => {
|
||||
if (handle.id === 'controlPoint') {
|
||||
return [shape.props.start, shape.props.end]
|
||||
}
|
||||
return []
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The [HandleSnapGeometry](?) object has these properties:
|
||||
|
||||
| Property | Description |
|
||||
| -------------------- | -------------------------------------------------------------- |
|
||||
| `outline` | Custom outline geometry for snapping (default: shape geometry) |
|
||||
| `points` | Key points to snap to (default: none) |
|
||||
| `getSelfSnapOutline` | Returns outline for self-snapping given a handle |
|
||||
| `getSelfSnapPoints` | Returns points for self-snapping given a handle |
|
||||
|
||||
## Complete example
|
||||
|
||||
Here's a speech bubble shape with a draggable tail handle:
|
||||
|
||||
```tsx
|
||||
import {
|
||||
Polygon2d,
|
||||
ShapeUtil,
|
||||
TLHandle,
|
||||
TLHandleDragInfo,
|
||||
TLShape,
|
||||
Vec,
|
||||
ZERO_INDEX_KEY,
|
||||
} from 'tldraw'
|
||||
|
||||
const SPEECH_BUBBLE_TYPE = 'speech-bubble'
|
||||
|
||||
declare module 'tldraw' {
|
||||
export interface TLGlobalShapePropsMap {
|
||||
[SPEECH_BUBBLE_TYPE]: { w: number; h: number; tailX: number; tailY: number }
|
||||
}
|
||||
}
|
||||
|
||||
type SpeechBubbleShape = TLShape<typeof SPEECH_BUBBLE_TYPE>
|
||||
|
||||
class SpeechBubbleUtil extends ShapeUtil<SpeechBubbleShape> {
|
||||
static override type = SPEECH_BUBBLE_TYPE
|
||||
|
||||
getDefaultProps(): SpeechBubbleShape['props'] {
|
||||
return { w: 200, h: 100, tailX: 100, tailY: 150 }
|
||||
}
|
||||
|
||||
getGeometry(shape: SpeechBubbleShape) {
|
||||
const { w, h, tailX, tailY } = shape.props
|
||||
return new Polygon2d({
|
||||
points: [
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
override getHandles(shape: SpeechBubbleShape): TLHandle[] {
|
||||
return [
|
||||
{
|
||||
id: 'tail',
|
||||
type: 'vertex',
|
||||
label: 'Move tail', // Accessible name for the handle
|
||||
index: ZERO_INDEX_KEY,
|
||||
x: shape.props.tailX,
|
||||
y: shape.props.tailY,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
override onHandleDrag(shape: SpeechBubbleShape, { handle }: TLHandleDragInfo<SpeechBubbleShape>) {
|
||||
return {
|
||||
props: { tailX: handle.x, tailY: handle.y },
|
||||
}
|
||||
}
|
||||
|
||||
component(shape: SpeechBubbleShape) {
|
||||
const geometry = this.getGeometry(shape)
|
||||
return (
|
||||
<svg className="tl-svg-container">
|
||||
<path d={geometry.getSvgPathData()} fill="white" stroke="black" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
getIndicatorPath(shape: SpeechBubbleShape) {
|
||||
const geometry = this.getGeometry(shape)
|
||||
return new Path2D(geometry.getSvgPathData())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Reading handles
|
||||
|
||||
Use [Editor#getShapeHandles](?) to get the handles for any shape:
|
||||
|
||||
```ts
|
||||
const handles = editor.getShapeHandles(shape)
|
||||
if (handles) {
|
||||
for (const handle of handles) {
|
||||
console.log(handle.id, handle.x, handle.y)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Returns `undefined` if the shape doesn't have handles.
|
||||
|
||||
## Examples
|
||||
|
||||
- [Custom shape with handles](/examples/shapes/tools/speech-bubble) — A speech bubble shape with a draggable tail handle
|
||||
- [Cubic bezier curve shape](/examples/shapes/tools/cubic-bezier-shape) — Multiple handles with custom snapping and control point behavior
|
||||
168
apps/docs/content/sdk-features/highlighting.mdx
Normal file
168
apps/docs/content/sdk-features/highlighting.mdx
Normal file
@@ -0,0 +1,168 @@
|
||||
---
|
||||
title: Highlighting
|
||||
created_at: 1/31/2026
|
||||
updated_at: 1/31/2026
|
||||
keywords:
|
||||
- highlight
|
||||
- hover
|
||||
- hinting
|
||||
- shape indicator
|
||||
- visual feedback
|
||||
- page state
|
||||
- hoveredShapeId
|
||||
- hintingShapeIds
|
||||
status: published
|
||||
date: 1/31/2026
|
||||
---
|
||||
|
||||
Shape highlighting shows visual indicators on shapes to provide feedback during user interactions. The editor tracks two types of highlighting: **hover** (the shape under the pointer) and **hints** (shapes you want to emphasize programmatically).
|
||||
|
||||
```tsx
|
||||
import { Tldraw } from 'tldraw'
|
||||
import 'tldraw/tldraw.css'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0 }}>
|
||||
<Tldraw
|
||||
onMount={(editor) => {
|
||||
// Highlight specific shapes with a visual indicator
|
||||
const shapes = editor.getCurrentPageShapes()
|
||||
if (shapes.length > 0) {
|
||||
editor.setHintingShapes([shapes[0]])
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Hover highlighting
|
||||
|
||||
The editor automatically tracks which shape is under the pointer and displays a selection-style indicator around it. Hovering a child of a group hovers the group, unless that group is focused or selected, in which case the child itself is hovered.
|
||||
|
||||
### Reading hover state
|
||||
|
||||
Use [Editor#getHoveredShapeId](?) or [Editor#getHoveredShape](?) to check which shape is currently hovered:
|
||||
|
||||
```typescript
|
||||
// Get the hovered shape ID
|
||||
const hoveredId = editor.getHoveredShapeId()
|
||||
|
||||
// Get the full shape object
|
||||
const hoveredShape = editor.getHoveredShape()
|
||||
|
||||
if (hoveredShape) {
|
||||
console.log('Hovering over:', hoveredShape.type)
|
||||
}
|
||||
```
|
||||
|
||||
### Setting hover manually
|
||||
|
||||
Use [Editor#setHoveredShape](?) to set the hover state yourself:
|
||||
|
||||
```typescript
|
||||
// Set hover by shape or ID
|
||||
editor.setHoveredShape(myShape)
|
||||
editor.setHoveredShape(myShape.id)
|
||||
|
||||
// Clear hover
|
||||
editor.setHoveredShape(null)
|
||||
```
|
||||
|
||||
The select tool overwrites this value on the next pointer move while it's idle or editing, so a manual hover only sticks inside your own tool or state. For programmatic emphasis, use hints instead.
|
||||
|
||||
### Automatic hover detection
|
||||
|
||||
The select tool updates hover state as the pointer moves (throttled to 32ms, and paused while the camera is moving). The hover indicator appears when:
|
||||
|
||||
- The select tool is in `idle` or `editing_shape`
|
||||
- The pointer is over the canvas (not UI elements)
|
||||
- The pointer is not coarse (touch, and pen on some devices)
|
||||
- The editor is not changing styles
|
||||
- The shape is not already selected
|
||||
|
||||
Touch devices don't show hover indicators because touch has no hovering concept.
|
||||
|
||||
## Hint highlighting
|
||||
|
||||
Hints let you highlight multiple shapes programmatically. Unlike hover (single shape, automatic), hints are set explicitly and can include any number of shapes. The select tool uses them too: it hints the drop target during drag-and-drop and the shapes a new frame will enclose, so hints you set may be replaced while the user is dragging.
|
||||
|
||||
### Reading hints
|
||||
|
||||
Use [Editor#getHintingShapeIds](?) or [Editor#getHintingShape](?) to get currently hinted shapes:
|
||||
|
||||
```typescript
|
||||
// Get array of hinted shape IDs
|
||||
const hintedIds = editor.getHintingShapeIds()
|
||||
|
||||
// Get array of hinted shape objects
|
||||
const hintedShapes = editor.getHintingShape()
|
||||
```
|
||||
|
||||
### Setting hints
|
||||
|
||||
Use [Editor#setHintingShapes](?) to highlight shapes:
|
||||
|
||||
```typescript
|
||||
// Highlight shapes by ID or shape object
|
||||
editor.setHintingShapes([shape1, shape2])
|
||||
editor.setHintingShapes([shape1.id, shape2.id])
|
||||
|
||||
// Clear all hints
|
||||
editor.setHintingShapes([])
|
||||
```
|
||||
|
||||
Hinted shapes render with a thicker stroke (2.5 screen pixels) than selected or hovered shapes (1.5). Setting hints never creates an undo entry, and ids are deduplicated on write.
|
||||
|
||||
For example, to hint the arrows bound to the selected shape, react to the selection:
|
||||
|
||||
```typescript
|
||||
import { react } from 'tldraw'
|
||||
|
||||
const stop = react('hint bound arrows', () => {
|
||||
const selected = editor.getOnlySelectedShape()
|
||||
if (selected) {
|
||||
const bindings = editor.getBindingsToShape(selected, 'arrow')
|
||||
editor.setHintingShapes(bindings.map((b) => b.fromId))
|
||||
} else {
|
||||
editor.setHintingShapes([])
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Visual rendering
|
||||
|
||||
Both hover and hint indicators use the theme's selection color. The [ShapeIndicatorOverlayUtil](?) strokes them on a canvas overlay using each shape's [ShapeUtil#getIndicatorPath](?). Collaborator selections render at 1.5px in the collaborator's color at 0.7 opacity. See [Indicators](/sdk-features/indicators) for the stroke widths and how to customize them.
|
||||
|
||||
## Page state storage
|
||||
|
||||
Hover and hint state are stored in [TLInstancePageState](?), which tracks per-page interaction state. Each page maintains its own hover and hint values.
|
||||
|
||||
```typescript
|
||||
const pageState = editor.getCurrentPageState()
|
||||
|
||||
pageState.hoveredShapeId // TLShapeId | null
|
||||
pageState.hintingShapeIds // TLShapeId[]
|
||||
```
|
||||
|
||||
Both properties are ephemeral—they don't persist across sessions or sync between collaborators.
|
||||
|
||||
## Related articles
|
||||
|
||||
- [Indicators](/sdk-features/indicators) — How indicator outlines are drawn and customized
|
||||
- [Instance state](/sdk-features/instance-state) — Session state including page state
|
||||
- [Selection](/sdk-features/selection) — Working with selected shapes
|
||||
- [Cursors](/sdk-features/cursors) — Cursor types and customization
|
||||
|
||||
## API reference
|
||||
|
||||
| Method | Description |
|
||||
| ------------------------------ | -------------------------------------- |
|
||||
| [Editor#getHoveredShapeId](?) | Get the ID of the shape under pointer |
|
||||
| [Editor#getHoveredShape](?) | Get the shape object under pointer |
|
||||
| [Editor#setHoveredShape](?) | Manually set or clear hover state |
|
||||
| [Editor#getHintingShapeIds](?) | Get IDs of shapes with hint indicators |
|
||||
| [Editor#getHintingShape](?) | Get shapes with hint indicators |
|
||||
| [Editor#setHintingShapes](?) | Set shapes to show hint indicators |
|
||||
169
apps/docs/content/sdk-features/history.mdx
Normal file
169
apps/docs/content/sdk-features/history.mdx
Normal file
@@ -0,0 +1,169 @@
|
||||
---
|
||||
title: History (undo/redo)
|
||||
created_at: 12/20/2025
|
||||
updated_at: 12/29/2025
|
||||
keywords:
|
||||
- undo
|
||||
- redo
|
||||
- history
|
||||
- HistoryManager
|
||||
- marks
|
||||
- batch
|
||||
reviewed_by: steveruizok
|
||||
status: published
|
||||
date: 12/20/2025
|
||||
readability: 9
|
||||
voice: 8
|
||||
completeness: 9
|
||||
accuracy: 10
|
||||
notes: ''
|
||||
---
|
||||
|
||||
The editor's history system tracks changes to the [store](/sdk-features/store) and provides undo/redo. Changes are organized into batches separated by marks, which act as stopping points, so a complex interaction undoes as one step instead of many.
|
||||
|
||||
The history manager captures all user-initiated store changes automatically and batches rapid changes into single undo steps. You control which changes are recorded with history options. The default UI binds undo to Cmd/Ctrl+Z and redo to Cmd/Ctrl+Shift+Z.
|
||||
|
||||
## How it works
|
||||
|
||||
The history manager maintains two stacks: one for undos and one for redos. Each stack contains entries that are either diffs (record changes) or marks (stopping points).
|
||||
|
||||
When you modify the store, the history manager captures the change as a diff. Changes accumulate until you create a mark, then the pending changes are flushed to the undo stack as a single entry. This batching prevents every keystroke or mouse movement from becoming a separate undo step.
|
||||
|
||||
```typescript
|
||||
editor.updateShape({ id: myShapeId, type: 'geo', x: 100, y: 100 })
|
||||
editor.updateShape({ id: myShapeId, type: 'geo', x: 110, y: 100 })
|
||||
editor.updateShape({ id: myShapeId, type: 'geo', x: 120, y: 100 })
|
||||
// All three updates are batched together until a mark is created
|
||||
```
|
||||
|
||||
When you undo, the manager reverses all changes back to the previous mark, moves them to the redo stack, and applies the reversed diff atomically. Redo does the inverse.
|
||||
|
||||
## Marks and stopping points
|
||||
|
||||
Marks define where undo and redo operations stop. Create marks with [Editor#markHistoryStoppingPoint](?) at the start of user interactions so that complex operations can be undone in one step. The optional name only shows up in the mark id, which is useful for debugging.
|
||||
|
||||
```typescript
|
||||
const markId = editor.markHistoryStoppingPoint('rotate shapes')
|
||||
editor.rotateShapesBy(editor.getSelectedShapeIds(), Math.PI / 4)
|
||||
// Undoing will return to this mark
|
||||
```
|
||||
|
||||
Each mark has a unique identifier that you can use with [bailToMark](#bailing) or [squashToMark](#squashing). Creating a mark flushes pending changes onto the undo stack. It doesn't clear the redo stack; the next recorded change does that.
|
||||
|
||||
## Basic operations
|
||||
|
||||
### Undo and redo
|
||||
|
||||
Use [Editor#undo](?) and [Editor#redo](?) to move through history marks.
|
||||
|
||||
```typescript
|
||||
editor.undo() // Reverse to previous mark
|
||||
editor.redo() // Reapply changes
|
||||
```
|
||||
|
||||
Both methods return the editor instance for chaining.
|
||||
|
||||
The [Editor#canUndo](?) and [Editor#canRedo](?) methods are reactive, so you can use them to update UI button states automatically:
|
||||
|
||||
```tsx
|
||||
import { useEditor, useValue } from 'tldraw'
|
||||
|
||||
function UndoButton() {
|
||||
const editor = useEditor()
|
||||
const canUndo = useValue('canUndo', () => editor.canUndo(), [editor])
|
||||
return (
|
||||
<button disabled={!canUndo} onClick={() => editor.undo()}>
|
||||
Undo
|
||||
</button>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Running operations with history options
|
||||
|
||||
The [Editor#run](?) method executes a function while controlling how changes affect history. Use it to make changes that don't pollute the undo stack or that preserve the redo stack for special operations.
|
||||
|
||||
```typescript
|
||||
// Ignore changes (don't add to undo stack)
|
||||
editor.run(
|
||||
() => {
|
||||
editor.updateShape({ id: myShapeId, type: 'geo', x: 100 })
|
||||
},
|
||||
{ history: 'ignore' }
|
||||
)
|
||||
|
||||
// Record but preserve redo stack
|
||||
editor.run(
|
||||
() => {
|
||||
editor.updateShape({ id: myShapeId, type: 'geo', x: 100 })
|
||||
},
|
||||
{ history: 'record-preserveRedoStack' }
|
||||
)
|
||||
```
|
||||
|
||||
The three history modes are:
|
||||
|
||||
| Mode | Undo stack | Redo stack |
|
||||
| -------------------------- | ---------- | ---------- |
|
||||
| `record` | Add | Clear |
|
||||
| `record-preserveRedoStack` | Add | Keep |
|
||||
| `ignore` | Skip | Keep |
|
||||
|
||||
> We use `record-preserveRedoStack` when selecting shapes. This way you can undo, select some shapes, copy them, and then redo back to where you were. The selection goes on the undo stack, but existing redos aren't cleared.
|
||||
|
||||
> We use `ignore` when writing your own pointer position for collaborators to see. Where your cursor was doesn't need to be undoable. (Changes that arrive from other users are marked `'remote'` and are never recorded.)
|
||||
|
||||
Nested `run` calls keep the outer mode unless they set their own, and no mode is applied while an undo or redo is in progress.
|
||||
|
||||
## Advanced features
|
||||
|
||||
### Bailing
|
||||
|
||||
Bailing reverses changes without adding them to the redo stack. The changes are discarded entirely. Use this when canceling an interaction.
|
||||
|
||||
```typescript
|
||||
const markId = editor.markHistoryStoppingPoint('begin drag')
|
||||
// User drags shapes around
|
||||
// User presses escape to cancel
|
||||
editor.bailToMark(markId) // Roll back and discard all changes since mark
|
||||
```
|
||||
|
||||
[Editor#bail](?) reverts to the most recent mark. [Editor#bailToMark](?) reverts to a specific mark by ID.
|
||||
|
||||
> We use bailing while cloning shapes. A user can switch between translating and cloning by pressing or releasing the alt (option) key during a drag. When this changes, we bail on the changes since the interaction started, then apply the new mode's changes.
|
||||
|
||||
### Squashing
|
||||
|
||||
[Editor#squashToMark](?) combines all changes since a mark into a single undo step. Intermediate marks are removed. This simplifies the undo experience for complex multi-step operations.
|
||||
|
||||
```typescript
|
||||
const markId = editor.markHistoryStoppingPoint('bump shapes')
|
||||
editor.nudgeShapes(shapes, { x: 10, y: 0 })
|
||||
editor.nudgeShapes(shapes, { x: 0, y: 10 })
|
||||
editor.nudgeShapes(shapes, { x: -5, y: -5 })
|
||||
editor.squashToMark(markId) // All three nudges become one undo step
|
||||
```
|
||||
|
||||
Squashing doesn't change the current state, only how history is organized. If the mark isn't on the undo stack, `squashToMark` logs an error and does nothing.
|
||||
|
||||
> We use squashing during image cropping. While the user adjusts the crop, each change is recorded and can be undone individually. When the user exits crop mode, we squash the intermediate changes into one history entry. A single undo restores the image to its state before cropping began.
|
||||
|
||||
### Clearing history
|
||||
|
||||
[Editor#clearHistory](?) removes all undo and redo entries. Use this when loading new documents or resetting the editor state.
|
||||
|
||||
```typescript
|
||||
editor.loadSnapshot(snapshot)
|
||||
editor.clearHistory() // Start with clean history
|
||||
```
|
||||
|
||||
## Integration with the store
|
||||
|
||||
The history manager listens to [store](/sdk-features/store) changes through a history interceptor. It only captures changes with source `'user'`; changes merged from other clients (source `'remote'`) are ignored, and internal writes that shouldn't be undoable use `history: 'ignore'`. Only store records take part in undo/redo; state held outside the store, like your own atoms, does not.
|
||||
|
||||
The three history modes map onto three internal states: `Recording`, `RecordingPreserveRedoStack`, and `Paused`. The manager pauses itself while applying an undo or redo so those writes don't create new entries.
|
||||
|
||||
## Related examples
|
||||
|
||||
- [Timeline scrubber](/examples/use-cases/timeline-scrubber) - A visual timeline that lets users scrub through document history.
|
||||
- [Store events](/examples/events/store-events) - Listen to store changes, which is how the history manager tracks modifications.
|
||||
167
apps/docs/content/sdk-features/image-export.mdx
Normal file
167
apps/docs/content/sdk-features/image-export.mdx
Normal file
@@ -0,0 +1,167 @@
|
||||
---
|
||||
title: Image export
|
||||
created_at: 12/20/2025
|
||||
updated_at: 12/20/2025
|
||||
keywords:
|
||||
- export
|
||||
- SVG
|
||||
- image
|
||||
- PNG
|
||||
- download
|
||||
status: published
|
||||
date: 12/20/2025
|
||||
readability: 9
|
||||
voice: 8
|
||||
completeness: 9
|
||||
accuracy: 10
|
||||
notes: ''
|
||||
---
|
||||
|
||||
The export system converts shapes to SVG and raster image formats for download, embedding, or integration with external tools. The editor handles the full pipeline from rendering shapes as SVG to converting those SVGs into PNG, JPEG, or WebP images. Exports are fully self-contained: the editor embeds fonts, inlines styles, and converts media elements to data URLs.
|
||||
|
||||
## How it works
|
||||
|
||||
Export has two stages: SVG generation and optional raster conversion. The SVG stage renders shapes into a self-contained SVG document, while the raster stage converts that SVG into a bitmap image.
|
||||
|
||||
### SVG generation
|
||||
|
||||
The editor gathers the shapes to export, calculates their bounding box, creates a React tree representing the SVG, renders it into a temporary DOM element, and processes it to be self-contained.
|
||||
|
||||
Each shape defines how it renders to SVG through its [ShapeUtil](?). If the shape implements [ShapeUtil#toSvg](?) or [ShapeUtil#toBackgroundSvg](?), those methods return React SVG elements. Otherwise the editor renders the shape's normal HTML inside an SVG `<foreignObject>` element.
|
||||
|
||||
```tsx
|
||||
override toSvg(shape: MyShape, ctx: SvgExportContext) {
|
||||
const fill = ctx.isDarkMode ? '#333' : '#eee'
|
||||
return <rect width={shape.props.w} height={shape.props.h} fill={fill} />
|
||||
}
|
||||
```
|
||||
|
||||
The [SvgExportContext](?) tells you the color mode, `scale`, and `pixelRatio` of the export, resolves asset URLs at the right size via `resolveAssetUrl`, and lets you defer the snapshot with `waitUntil` while an image loads. Shapes rendered through `<foreignObject>` can do the same with [useDelaySvgExport](?).
|
||||
|
||||
The temporary render step is necessary because CSS and layout aren't computed until elements are in the document. `<foreignObject>` elements in particular need their styles and content inlined to work when the SVG is extracted.
|
||||
|
||||
### Making SVG self-contained
|
||||
|
||||
SVG files must be self-contained to work outside the document. The editor processes the rendered SVG to embed all external resources:
|
||||
|
||||
Fonts come first. The `FontEmbedder` finds `@font-face` declarations in the document's stylesheets, fetches the font files, and inlines them as data URLs, so text renders identically regardless of what the viewer has installed.
|
||||
|
||||
Then styles. The `StyleEmbedder` reads computed styles from every element inside `<foreignObject>` sections and writes them as inline styles. Pseudo-elements like `::before` and `::after` can't be inlined, so their rules go into a `<style>` tag within the SVG.
|
||||
|
||||
Finally media. `embedMedia` converts images to data URLs, videos to a single captured frame, and canvas elements to images via `toDataURL()`.
|
||||
|
||||
### Raster conversion
|
||||
|
||||
Once the editor generates the SVG, it can convert it to a raster image. The `getSvgAsImage` function loads the SVG into an `Image` element, draws it to a canvas at the requested resolution, and exports the canvas as a blob.
|
||||
|
||||
The `pixelRatio` option controls output resolution. The default of 2 is sharp on high-DPI displays; raise it for print. The editor automatically clamps dimensions to browser canvas limits to avoid out-of-memory errors.
|
||||
|
||||
## Export options
|
||||
|
||||
SVG export methods (`getSvgElement`, `getSvgString`) accept [TLSvgExportOptions](?). Raster export methods (`toImage`, `toImageDataUrl`) accept [TLImageExportOptions](?), which extends `TLSvgExportOptions` with format-specific options.
|
||||
|
||||
**Shared options (all export methods):**
|
||||
|
||||
| Option | Description |
|
||||
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `bounds` | The bounding box in page coordinates to export. If omitted, the editor calculates bounds from the shapes. |
|
||||
| `scale` | Logical scale multiplier. A scale of 2 doubles the SVG size. Defaults to `1`. |
|
||||
| `pixelRatio` | For SVG exports, passed to the asset store so it can provide appropriately sized assets. For raster exports, multiplies output dimensions. Defaults to `undefined` for SVG and `2` for raster formats. |
|
||||
| `background` | Whether to include the background color. If `false`, the export is transparent (for formats that support it). Defaults to the `exportBackground` instance state. |
|
||||
| `padding` | Space around the shape bounds: `'auto'` (default), a `number` of pixels, or `0`. See below. |
|
||||
| `darkMode` | Whether to render in dark mode. Defaults to the current theme setting. |
|
||||
| `preserveAspectRatio` | The SVG `preserveAspectRatio` attribute. |
|
||||
|
||||
In `'auto'` padding mode the editor renders with `editor.options.defaultSvgPadding` (32px), then trims to the visual content bounds. This captures overflow like thick strokes and arrowheads without extra whitespace. A numeric value adds fixed padding and clips overflow beyond it; `0` means no padding and no trimming. Padding is skipped when exporting a single frame, and when a shape whose [ShapeUtil#isExportBoundsContainer](?) returns true (images and frames by default) contains every other exported shape.
|
||||
|
||||
**`toImage` and `toImageDataUrl` options:**
|
||||
|
||||
| Option | Description |
|
||||
| --------- | --------------------------------------------------------------------------------------------------------- |
|
||||
| `format` | Output format: `'png'`, `'jpeg'`, `'webp'`, or `'svg'`. Defaults to `'png'`. `'svg'` returns an SVG blob. |
|
||||
| `quality` | Compression quality for lossy formats (JPEG, WebP) as a number between 0 and 1. |
|
||||
|
||||
## Editor export methods
|
||||
|
||||
The [Editor](?) class provides four methods for exporting shapes. All methods accept either shape IDs or shape objects, and an empty array exports all shapes on the current page.
|
||||
|
||||
### getSvgElement
|
||||
|
||||
[Editor#getSvgElement](?) returns the SVG as a DOM element along with its width and height. Use this when you need to manipulate the SVG programmatically or insert it into the DOM.
|
||||
|
||||
```typescript
|
||||
const result = await editor.getSvgElement(shapes, { scale: 2 })
|
||||
if (result) {
|
||||
document.body.appendChild(result.svg)
|
||||
}
|
||||
```
|
||||
|
||||
### getSvgString
|
||||
|
||||
[Editor#getSvgString](?) returns the SVG as a serialized string. Use this for saving to a file or sending to a server.
|
||||
|
||||
```typescript
|
||||
const result = await editor.getSvgString(shapes, { background: true })
|
||||
if (result) {
|
||||
console.log(result.svg) // SVG markup as string
|
||||
}
|
||||
```
|
||||
|
||||
### toImage
|
||||
|
||||
[Editor#toImage](?) returns a blob of the exported image in the specified format. This is the primary method for raster images. For a ready-made download or copy-to-clipboard flow, use [exportAs](?) and [copyAs](?) from `tldraw`, which wrap it.
|
||||
|
||||
```typescript
|
||||
const result = await editor.toImage(shapes, {
|
||||
format: 'png',
|
||||
pixelRatio: 2,
|
||||
background: true,
|
||||
})
|
||||
|
||||
// Download the image
|
||||
const link = document.createElement('a')
|
||||
link.href = URL.createObjectURL(result.blob)
|
||||
link.download = 'export.png'
|
||||
link.click()
|
||||
```
|
||||
|
||||
### toImageDataUrl
|
||||
|
||||
[Editor#toImageDataUrl](?) returns the exported image as a data URL string. Use this when you need the image as a base64-encoded string, for example to display in an `<img>` element or store in a database.
|
||||
|
||||
```typescript
|
||||
const result = await editor.toImageDataUrl(shapes, { format: 'png' })
|
||||
const img = document.createElement('img')
|
||||
img.src = result.url
|
||||
```
|
||||
|
||||
## Error handling
|
||||
|
||||
The SVG methods ([Editor#getSvgElement](?) and [Editor#getSvgString](?)) return `undefined` when there's nothing to export. Check the result before using it:
|
||||
|
||||
```typescript
|
||||
const result = await editor.getSvgString(shapes)
|
||||
if (!result) {
|
||||
console.error('Nothing to export')
|
||||
return
|
||||
}
|
||||
// result.svg is available
|
||||
```
|
||||
|
||||
The raster methods ([Editor#toImage](?) and [Editor#toImageDataUrl](?)) throw instead. Wrap them in a try/catch when the export might fail:
|
||||
|
||||
```typescript
|
||||
try {
|
||||
const { blob } = await editor.toImage(shapes, { format: 'png' })
|
||||
} catch (e) {
|
||||
console.error('Export failed', e)
|
||||
}
|
||||
```
|
||||
|
||||
The raster conversion automatically clamps dimensions to stay within browser canvas limits, which vary by browser. Very large exports at high pixel ratios are scaled down to fit rather than failing.
|
||||
|
||||
## Related examples
|
||||
|
||||
- [Export canvas as image](/examples/data/assets/export-canvas-as-image) - Export the entire canvas using [Editor#toImage](?) and download it.
|
||||
- [Export canvas as image (with settings)](/examples/data/assets/export-canvas-settings) - Export with configurable format, scale, background, and other options.
|
||||
- [Custom shape SVG export](/examples/shapes/tools/toSvg-method-example) - Define how custom shapes render when exported using [ShapeUtil#toSvg](?) and [ShapeUtil#toBackgroundSvg](?).
|
||||
157
apps/docs/content/sdk-features/indicators.mdx
Normal file
157
apps/docs/content/sdk-features/indicators.mdx
Normal file
@@ -0,0 +1,157 @@
|
||||
---
|
||||
title: Indicators
|
||||
created_at: 01/31/2026
|
||||
updated_at: 01/31/2026
|
||||
keywords:
|
||||
- indicator
|
||||
- selection
|
||||
- hover
|
||||
- outline
|
||||
- ShapeUtil
|
||||
- getIndicatorPath
|
||||
- hinting
|
||||
status: published
|
||||
date: 01/31/2026
|
||||
---
|
||||
|
||||
Indicators are the colored outlines that appear around shapes when they're selected, hovered, or being interacted with. Every shape defines its own indicator through the [ShapeUtil#getIndicatorPath](?) method.
|
||||
|
||||
## How indicators work
|
||||
|
||||
When you hover over or select a shape, tldraw draws an outline that matches the shape's geometry. The indicator is separate from the shape itself: the [ShapeIndicatorOverlayUtil](?) strokes it on a canvas overlay above the shapes, in the theme's selection color.
|
||||
|
||||
Indicators appear in three contexts:
|
||||
|
||||
| Context | When it appears | Stroke weight |
|
||||
| -------- | ------------------------------------------------------------------------------------------------- | ------------- |
|
||||
| Selected | Shape is in the current selection | 1.5px |
|
||||
| Hovered | Pointer is over an unselected shape while the select tool is idle (fine pointers only, not touch) | 1.5px |
|
||||
| Hinting | Shape is a drop target during drag operations | 2.5px |
|
||||
|
||||
For collaborative editing, [CollaboratorShapeIndicatorOverlayUtil](?) also shows which shapes other users have selected, in each collaborator's cursor color. Locked shapes never show indicators.
|
||||
|
||||
## Defining an indicator
|
||||
|
||||
Every [ShapeUtil](?) must implement the `getIndicatorPath` method. This method returns a `Path2D`, a richer [TLIndicatorPath](?) object for indicators that need clipping or additional stroked paths, or `undefined` to draw no indicator:
|
||||
|
||||
```tsx
|
||||
import { ShapeUtil, TLShape, Rectangle2d, T, RecordProps } from 'tldraw'
|
||||
|
||||
declare module 'tldraw' {
|
||||
export interface TLGlobalShapePropsMap {
|
||||
myshape: { w: number; h: number }
|
||||
}
|
||||
}
|
||||
|
||||
type MyShape = TLShape<'myshape'>
|
||||
|
||||
class MyShapeUtil extends ShapeUtil<MyShape> {
|
||||
static override type = 'myshape' as const
|
||||
static override props: RecordProps<MyShape> = {
|
||||
w: T.number,
|
||||
h: T.number,
|
||||
}
|
||||
|
||||
getDefaultProps() {
|
||||
return { w: 100, h: 100 }
|
||||
}
|
||||
|
||||
getGeometry(shape: MyShape) {
|
||||
return new Rectangle2d({
|
||||
width: shape.props.w,
|
||||
height: shape.props.h,
|
||||
isFilled: true,
|
||||
})
|
||||
}
|
||||
|
||||
component(shape: MyShape) {
|
||||
return <div style={{ width: shape.props.w, height: shape.props.h }} />
|
||||
}
|
||||
|
||||
getIndicatorPath(shape: MyShape) {
|
||||
const path = new Path2D()
|
||||
path.rect(0, 0, shape.props.w, shape.props.h)
|
||||
return path
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `getIndicatorPath` method receives the shape and returns paths in the shape's local coordinate space. You don't need to set stroke color or width—tldraw applies those automatically based on context.
|
||||
|
||||
### Common indicator patterns
|
||||
|
||||
For circular shapes, use an ellipse path:
|
||||
|
||||
```tsx
|
||||
getIndicatorPath(shape: MyShape) {
|
||||
const { w, h } = shape.props
|
||||
const path = new Path2D()
|
||||
path.ellipse(w / 2, h / 2, w / 2, h / 2, 0, 0, Math.PI * 2)
|
||||
return path
|
||||
}
|
||||
```
|
||||
|
||||
For complex paths, use the shape's geometry:
|
||||
|
||||
```tsx
|
||||
getIndicatorPath(shape: MyShape) {
|
||||
const geometry = this.editor.getShapeGeometry(shape)
|
||||
return new Path2D(geometry.toSimpleSvgPath())
|
||||
}
|
||||
```
|
||||
|
||||
### Indicators with labels
|
||||
|
||||
Shapes with labels may need to clip the indicator where the label appears. Arrow shapes do this to prevent the indicator from overlapping label text. Return an object with `path`, an optional `clipPath`, and optional `additionalPaths`. The clip is applied even-odd before stroking `path`, so an outer rectangle plus the label rectangle punches a hole for the label. `additionalPaths` are stroked afterwards without the clip:
|
||||
|
||||
```tsx
|
||||
override getIndicatorPath(shape: MyShape) {
|
||||
const path = new Path2D()
|
||||
path.moveTo(0, 0)
|
||||
path.lineTo(shape.props.w, shape.props.h)
|
||||
|
||||
// Even-odd: the outer rect keeps everything, the inner rect punches a hole
|
||||
const clipPath = new Path2D()
|
||||
clipPath.rect(-100, -100, shape.props.w + 200, shape.props.h + 200)
|
||||
clipPath.rect(40, 40, 20, 20)
|
||||
|
||||
return { path, clipPath }
|
||||
}
|
||||
```
|
||||
|
||||
## Hinting shapes
|
||||
|
||||
Hinting shapes are shapes that receive a highlighted indicator during drag operations. Use [Editor#setHintingShapes](?) to mark shapes as drop targets:
|
||||
|
||||
```tsx
|
||||
// Highlight a shape as a potential drop target
|
||||
editor.setHintingShapes([targetShapeId])
|
||||
|
||||
// Clear hinting
|
||||
editor.setHintingShapes([])
|
||||
```
|
||||
|
||||
## Customizing indicators
|
||||
|
||||
The stroke widths and paint order live on `ShapeIndicatorOverlayUtil.options` (`lineWidth`, `hintedLineWidth`, `zIndex`). Adjust them with `configure`, or subclass the util and override `getOverlays()` to change which shapes get indicators, then pass it through the `overlayUtils` prop. See [Overlay utils](/sdk-features/overlay-utils) for how overlay utils are registered and replaced.
|
||||
|
||||
```tsx
|
||||
import { ShapeIndicatorOverlayUtil, Tldraw } from 'tldraw'
|
||||
|
||||
const ThickIndicators = ShapeIndicatorOverlayUtil.configure({ lineWidth: 3 })
|
||||
|
||||
function App() {
|
||||
return <Tldraw overlayUtils={[ThickIndicators]} />
|
||||
}
|
||||
```
|
||||
|
||||
## Related articles
|
||||
|
||||
- [Shapes](/sdk-features/shapes) - Learn how to create custom shapes with their own indicators
|
||||
- [Selection](/sdk-features/selection) - Understand how selection state controls indicator visibility
|
||||
- [Overlay utils](/sdk-features/overlay-utils) - The overlay system that draws indicators
|
||||
|
||||
## Related examples
|
||||
|
||||
- [Custom shape](/examples/shapes/tools/custom-shape) - Create a custom shape with an indicator
|
||||
- [Custom indicators](/examples/ui/indicators-logic) - Control which shapes show indicators
|
||||
190
apps/docs/content/sdk-features/input-handling.mdx
Normal file
190
apps/docs/content/sdk-features/input-handling.mdx
Normal file
@@ -0,0 +1,190 @@
|
||||
---
|
||||
title: Input handling
|
||||
created_at: 12/20/2025
|
||||
updated_at: 12/20/2025
|
||||
keywords:
|
||||
- input
|
||||
- pointer
|
||||
- keyboard
|
||||
- InputsManager
|
||||
- events
|
||||
reviewed_by: steveruizok
|
||||
status: published
|
||||
date: 12/20/2025
|
||||
readability: 9
|
||||
voice: 8
|
||||
completeness: 9
|
||||
accuracy: 9
|
||||
notes: 'Passive constructions ("is calculated", "are added") and formulaic transition ("For each space"). Add API links like [InputsManager](?).'
|
||||
---
|
||||
|
||||
The [InputsManager](?) class tracks pointer and keyboard state for the editor. It stores pointer positions in both screen space and page space, tracks pressed keys and buttons, detects device types (mouse, touch, pen), and calculates pointer velocity. Access it through `editor.inputs`.
|
||||
|
||||
All input state is reactive. The manager stores values as atoms from `@tldraw/state`, so components that read input state automatically update when those values change. The manager updates on every input event, converting coordinates between screen space and page space.
|
||||
|
||||
## Pointer position tracking
|
||||
|
||||
The manager tracks pointer positions in two coordinate spaces. Screen space is pixels relative to the canvas container's origin. Page space is the position on the infinite canvas, adjusted for camera position and zoom.
|
||||
|
||||
In each space, the manager keeps three positions: current, previous, and origin.
|
||||
|
||||
### Current and previous positions
|
||||
|
||||
```typescript
|
||||
editor.inputs.getCurrentScreenPoint() // Current position in screen space
|
||||
editor.inputs.getCurrentPagePoint() // Current position in page space
|
||||
|
||||
editor.inputs.getPreviousScreenPoint() // Previous position in screen space
|
||||
editor.inputs.getPreviousPagePoint() // Previous position in page space
|
||||
```
|
||||
|
||||
The current position updates on every pointer event. The previous position stores where the pointer was before the most recent update. You can use these together to calculate deltas for dragging and panning:
|
||||
|
||||
```typescript
|
||||
const delta = Vec.Sub(editor.inputs.getCurrentPagePoint(), editor.inputs.getPreviousPagePoint())
|
||||
```
|
||||
|
||||
### Origin positions
|
||||
|
||||
The origin position captures where the most recent `pointer_down` event occurred:
|
||||
|
||||
```typescript
|
||||
editor.inputs.getOriginScreenPoint() // Where pointer_down occurred in screen space
|
||||
editor.inputs.getOriginPagePoint() // Where pointer_down occurred in page space
|
||||
```
|
||||
|
||||
Tools use the origin to calculate drag distances and determine whether an interaction has moved far enough to trigger behaviors like dragging. The origin resets on every `pointer_down` event and continuously while a pinch is in progress.
|
||||
|
||||
### Coordinate space conversion
|
||||
|
||||
The manager converts screen coordinates to page coordinates using the camera's position and zoom:
|
||||
|
||||
```typescript
|
||||
// Screen to page conversion
|
||||
const pageX = screenX / camera.z - camera.x
|
||||
const pageY = screenY / camera.z - camera.y
|
||||
```
|
||||
|
||||
## Pointer velocity
|
||||
|
||||
The manager tracks pointer velocity for gesture detection:
|
||||
|
||||
```typescript
|
||||
editor.inputs.getPointerVelocity() // Vec with x/y velocity in pixels per millisecond
|
||||
```
|
||||
|
||||
The manager listens to the editor's `frame` event and recomputes velocity once per frame (not on each pointer event) from the screen-space distance traveled since the previous frame. It smooths the result against the previous value and clamps components below 0.01 to zero to prevent jitter.
|
||||
|
||||
Tools use velocity to distinguish between slow, precise interactions and fast flick gestures. Velocity resets to zero on `pointer_down` events and continuously while a pinch is in progress.
|
||||
|
||||
## Input device detection
|
||||
|
||||
The manager tracks whether the most recent pointer event came from a pen (`pointerType === 'pen'`):
|
||||
|
||||
```typescript
|
||||
editor.inputs.getIsPen() // true for stylus input
|
||||
```
|
||||
|
||||
Pen mode ignores non-pen input to prevent accidental touch interactions while using a stylus. The editor only turns pen mode on automatically for direct-display pens (Apple Pencil, Surface Pen), flagged as `isPenDirect` on the pointer event; desktop graphics tablets still draw as pens without enabling it.
|
||||
|
||||
## Modifier keys and button states
|
||||
|
||||
The manager tracks modifier key states:
|
||||
|
||||
```typescript
|
||||
editor.inputs.getShiftKey()
|
||||
editor.inputs.getAltKey()
|
||||
editor.inputs.getCtrlKey()
|
||||
editor.inputs.getMetaKey()
|
||||
editor.inputs.getAccelKey() // Cmd on Mac, Ctrl elsewhere
|
||||
```
|
||||
|
||||
The `getAccelKey()` method returns true for Command on macOS and Control on other platforms. Use this for cross-platform shortcuts.
|
||||
|
||||
### Button tracking
|
||||
|
||||
The manager tracks currently pressed pointer buttons in a reactive set:
|
||||
|
||||
```typescript
|
||||
editor.inputs.buttons.has(0) // Primary button (left click)
|
||||
editor.inputs.buttons.has(1) // Middle button
|
||||
editor.inputs.buttons.has(2) // Secondary button (right click)
|
||||
```
|
||||
|
||||
Buttons are added on `pointer_down` events and removed on `pointer_up` events.
|
||||
|
||||
### Keyboard key tracking
|
||||
|
||||
The manager tracks pressed keyboard keys in a reactive set:
|
||||
|
||||
```typescript
|
||||
editor.inputs.keys.has('Space')
|
||||
editor.inputs.keys.has('ShiftLeft')
|
||||
```
|
||||
|
||||
The editor adds keys on `key_down` and removes them on `key_up`. Tools can use this to detect held keys during pointer operations. For example, the editor checks `keys.has('Space')` on pointer up to decide whether to keep spacebar panning active.
|
||||
|
||||
## Interaction state flags
|
||||
|
||||
The manager tracks the current interaction state:
|
||||
|
||||
```typescript
|
||||
editor.inputs.getIsPointing() // Pointer button is down
|
||||
editor.inputs.getIsRightPointing() // Right button is down, before the drag threshold
|
||||
editor.inputs.getIsDragging() // Pointer moved beyond drag threshold while pointing
|
||||
editor.inputs.getIsPinching() // Two-finger pinch gesture active
|
||||
editor.inputs.getIsEditing() // Editing text or other content
|
||||
editor.inputs.getIsPanning() // Panning the canvas
|
||||
editor.inputs.getIsSpacebarPanning() // Panning via spacebar (vs. other panning modes)
|
||||
```
|
||||
|
||||
The editor sets these flags during event processing. For example, `isPointing` becomes true on `pointer_down` and false on `pointer_up`. The `isDragging` flag becomes true when the pointer moves beyond the drag distance threshold (`dragDistanceSquared` or `coarseDragDistanceSquared` in the editor's [options](/sdk-features/options)) while pointing.
|
||||
|
||||
## Event processing flow
|
||||
|
||||
When an input event occurs, the editor processes it through these stages:
|
||||
|
||||
1. The browser fires a native DOM event
|
||||
2. The canvas event handlers transform it into a typed event info object and call [Editor#dispatch](?)
|
||||
3. `pointer_move`, `wheel`, and `pinch` events are queued and flushed once per frame; other events flush immediately
|
||||
4. The editor emits `before-event`, then updates modifier key state
|
||||
5. For pointer, pinch, and wheel events, the editor calls `updateFromEvent()` on the `InputsManager`
|
||||
6. For pointer events, the editor updates buttons and interaction flags, then hands the event to the `ClickManager` for double-click detection
|
||||
7. The editor sends the event to the state machine via `root.handleEvent()`, which propagates it through active tool states, then emits `event`
|
||||
|
||||
See [Events](/sdk-features/events) for subscribing to `before-event` and `event`.
|
||||
|
||||
The typed event info objects are:
|
||||
|
||||
| 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.
|
||||
|
||||
## Input normalization
|
||||
|
||||
The editor listens to Pointer Events, so mouse, touch, and pen input arrive through the same handlers, and the manager tracks the device type through the `isPen` flag.
|
||||
|
||||
Pointer positions include a `z` coordinate carrying the pointer event's pressure; synthetic events without a `z` default to 0.5. The manager subtracts the container's screen bounds from client coordinates, so the editor works correctly in nested layouts and scrolled containers.
|
||||
|
||||
## State serialization
|
||||
|
||||
The manager provides a `toJson()` method for debugging:
|
||||
|
||||
```typescript
|
||||
const state = editor.inputs.toJson()
|
||||
// Returns all position vectors, modifier key states, interaction flags,
|
||||
// device type, and the contents of the keys and buttons sets
|
||||
```
|
||||
|
||||
We use this serialized form when generating crash reports.
|
||||
|
||||
## Related examples
|
||||
|
||||
- [Reactive inputs](/examples/editor-api/reactive-inputs) - Display pointer positions, velocity, and other input state reactively
|
||||
- [Canvas events](/examples/events/canvas-events) - Log pointer, keyboard, and wheel events to see the event flow
|
||||
292
apps/docs/content/sdk-features/instance-state.mdx
Normal file
292
apps/docs/content/sdk-features/instance-state.mdx
Normal file
@@ -0,0 +1,292 @@
|
||||
---
|
||||
title: Instance state
|
||||
created_at: 1/31/2026
|
||||
updated_at: 1/31/2026
|
||||
keywords:
|
||||
- instance
|
||||
- state
|
||||
- session
|
||||
- TLInstance
|
||||
- preferences
|
||||
- debug mode
|
||||
- grid mode
|
||||
- focus mode
|
||||
- pen mode
|
||||
- readonly
|
||||
- cursor
|
||||
status: published
|
||||
date: 1/31/2026
|
||||
---
|
||||
|
||||
Instance state is the per-tab state that tracks your current session. It includes which page you're viewing, whether the grid is visible, if debug mode is on, and transient interaction state like the current cursor.
|
||||
|
||||
Unlike document state (shapes, pages, assets), instance state belongs to a single browser tab and isn't synced between collaborators.
|
||||
|
||||
```tsx
|
||||
import { Tldraw } from 'tldraw'
|
||||
import 'tldraw/tldraw.css'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0 }}>
|
||||
<Tldraw
|
||||
onMount={(editor) => {
|
||||
// Enable grid mode and debug mode
|
||||
editor.updateInstanceState({
|
||||
isGridMode: true,
|
||||
isDebugMode: true,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Reading instance state
|
||||
|
||||
Use [Editor#getInstanceState](?) to access the current instance state. The returned object contains all session properties:
|
||||
|
||||
```typescript
|
||||
const instance = editor.getInstanceState()
|
||||
|
||||
// Check modes
|
||||
instance.isGridMode // true if grid is visible
|
||||
instance.isDebugMode // true if debug overlays are shown
|
||||
instance.isFocusMode // true if UI is minimized
|
||||
instance.isPenMode // true once a direct-display stylus (e.g. Apple Pencil) is used
|
||||
instance.isToolLocked // true if tool stays active after creating shapes
|
||||
instance.isReadonly // true if editing is disabled
|
||||
|
||||
// Current state
|
||||
instance.currentPageId // which page is active
|
||||
instance.cursor // cursor type and rotation
|
||||
instance.isFocused // whether the editor has focus
|
||||
|
||||
// Screen information
|
||||
instance.screenBounds // viewport dimensions
|
||||
instance.devicePixelRatio // display scaling factor
|
||||
instance.isCoarsePointer // true for touch input
|
||||
```
|
||||
|
||||
The result is reactive. When you access it inside a `track` component or `react` callback, your code re-runs when the state changes:
|
||||
|
||||
```tsx
|
||||
import { track, useEditor } from 'tldraw'
|
||||
|
||||
const DebugIndicator = track(function DebugIndicator() {
|
||||
const editor = useEditor()
|
||||
const { isDebugMode, isGridMode } = editor.getInstanceState()
|
||||
|
||||
return (
|
||||
<div>
|
||||
Debug: {isDebugMode ? 'on' : 'off'}, Grid: {isGridMode ? 'on' : 'off'}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
```
|
||||
|
||||
## Updating instance state
|
||||
|
||||
Use [Editor#updateInstanceState](?) to modify instance properties:
|
||||
|
||||
```typescript
|
||||
// Enable grid mode
|
||||
editor.updateInstanceState({ isGridMode: true })
|
||||
|
||||
// Enable multiple options at once
|
||||
editor.updateInstanceState({
|
||||
isDebugMode: true,
|
||||
isToolLocked: true,
|
||||
})
|
||||
|
||||
// Toggle a mode
|
||||
const { isGridMode } = editor.getInstanceState()
|
||||
editor.updateInstanceState({ isGridMode: !isGridMode })
|
||||
```
|
||||
|
||||
Changes take effect immediately. By default they are not added to the undo stack; pass `{ history: 'record' }` as the second argument to record them. When using a `persistenceKey`, some properties persist across sessions (see [Session persistence](#session-persistence) below).
|
||||
|
||||
Note that `currentPageId` cannot be changed through `updateInstanceState`. Use [Editor#setCurrentPage](?) instead.
|
||||
|
||||
## Available properties
|
||||
|
||||
### Mode flags
|
||||
|
||||
These boolean flags control major editor behaviors:
|
||||
|
||||
| Property | Description | Persists |
|
||||
| -------------- | ----------------------------------------------------------------------------------------------------- | -------- |
|
||||
| `isGridMode` | Show grid overlay on the canvas | Yes |
|
||||
| `isDebugMode` | Show the debug panel (current tool state, debug menu) | Yes |
|
||||
| `isFocusMode` | Minimize the UI to just the canvas | Yes |
|
||||
| `isToolLocked` | Keep current tool active after creating a shape | Yes |
|
||||
| `isReadonly` | Prevent document edits, except for shapes whose util allows editing in readonly | No |
|
||||
| `isPenMode` | Set automatically when a direct-display stylus (e.g. Apple Pencil) is used; touch is ignored while on | No |
|
||||
| `isFocused` | Whether the editor currently has keyboard focus | No |
|
||||
|
||||
### Display state
|
||||
|
||||
Properties that track the current display environment:
|
||||
|
||||
| Property | Description | Persists |
|
||||
| ------------------ | ------------------------------------------------------------------------------------------------ | -------- |
|
||||
| `screenBounds` | Viewport position and dimensions (x, y, w, h) | No |
|
||||
| `devicePixelRatio` | Display scaling factor (e.g., 2 for Retina) | No |
|
||||
| `isCoarsePointer` | Indicates touch or low-precision input is active; mirrors `tlenvReactive` | No |
|
||||
| `isHoveringCanvas` | Whether pointer is over the canvas (null if no hover support) | No |
|
||||
| `insets` | Whether the container is inset from the document edge on each side, `[top, right, bottom, left]` | No |
|
||||
|
||||
### Navigation state
|
||||
|
||||
| Property | Description | Persists |
|
||||
| --------------- | ------------------------------------------------------ | -------- |
|
||||
| `currentPageId` | ID of the currently active page | Yes |
|
||||
| `openMenus` | Unused by the SDK; open menus are tracked by `tlmenus` | No |
|
||||
|
||||
### Interaction state
|
||||
|
||||
Temporary state used during user interactions:
|
||||
|
||||
| Property | Description | Persists |
|
||||
| ----------------- | ------------------------------------------------------------ | -------- |
|
||||
| `cursor` | Current cursor type and rotation | No |
|
||||
| `brush` | Selection brush bounds during drag selection | No |
|
||||
| `zoomBrush` | Zoom brush bounds during zoom-to-area | No |
|
||||
| `scribbles` | Active scribble animations (eraser trails, etc.) | No |
|
||||
| `isChangingStyle` | Set after a style change; resets after 1s or on pointer move | No |
|
||||
| `cameraState` | Whether the camera is `'idle'` or `'moving'` | No |
|
||||
|
||||
### Shape creation
|
||||
|
||||
Settings that affect newly created shapes:
|
||||
|
||||
| Property | Description | Persists |
|
||||
| --------------------- | ----------------------------------- | -------- |
|
||||
| `opacityForNextShape` | Opacity applied to new shapes (0-1) | No |
|
||||
| `stylesForNextShape` | Style values applied to new shapes | No |
|
||||
| `duplicateProps` | State for smart duplicate offset | No |
|
||||
|
||||
### Collaboration
|
||||
|
||||
Properties for multiplayer features:
|
||||
|
||||
| Property | Description | Persists |
|
||||
| -------------------- | ------------------------------------------ | -------- |
|
||||
| `followingUserId` | ID of user being followed, or null | No |
|
||||
| `highlightedUserIds` | IDs of users whose cursors are highlighted | No |
|
||||
| `chatMessage` | Current chat message being composed | No |
|
||||
| `isChatting` | Whether chat input is active | No |
|
||||
|
||||
### Custom data
|
||||
|
||||
| Property | Description | Persists |
|
||||
| ------------------ | ---------------------------------------- | -------- |
|
||||
| `meta` | Arbitrary JSON data for your application | No |
|
||||
| `exportBackground` | Include background color when exporting | Yes |
|
||||
|
||||
The "Persists" column shows what a `persistenceKey` saves between sessions. Separately, when you call `loadSnapshot`, a wider set of instance properties (screen bounds, pointer type, readonly, and the mode flags above) is preserved from the existing store rather than overwritten by the snapshot; see [TLSessionStateSnapshot](?).
|
||||
|
||||
## Common patterns
|
||||
|
||||
### Focus mode
|
||||
|
||||
Focus mode hides most of the UI for presentation or distraction-free editing:
|
||||
|
||||
```typescript
|
||||
editor.updateInstanceState({ isFocusMode: true })
|
||||
```
|
||||
|
||||
### Grid mode
|
||||
|
||||
Show a grid overlay to help with alignment:
|
||||
|
||||
```typescript
|
||||
editor.updateInstanceState({ isGridMode: true })
|
||||
```
|
||||
|
||||
The grid respects the camera zoom level. You can customize the grid appearance by overriding the `Grid` component via the `components` prop.
|
||||
|
||||
### Debug mode
|
||||
|
||||
Debug mode shows the debug panel: the current tool state path, an optional FPS counter, and the debug menu:
|
||||
|
||||
```typescript
|
||||
editor.updateInstanceState({ isDebugMode: true })
|
||||
```
|
||||
|
||||
### Tool lock
|
||||
|
||||
When tool lock is enabled, the current tool stays active after creating a shape instead of returning to the select tool:
|
||||
|
||||
```typescript
|
||||
editor.updateInstanceState({ isToolLocked: true })
|
||||
```
|
||||
|
||||
Custom tools should check this property to decide whether to return to select after completing their action. See [Tools](/sdk-features/tools#Tool-lock) for implementation details.
|
||||
|
||||
### Cursor customization
|
||||
|
||||
Set a custom cursor type and rotation:
|
||||
|
||||
```typescript
|
||||
// Use the setCursor helper
|
||||
editor.setCursor({ type: 'cross', rotation: 0 })
|
||||
|
||||
// Or update directly
|
||||
editor.updateInstanceState({
|
||||
cursor: { type: 'grab', rotation: 0 },
|
||||
})
|
||||
```
|
||||
|
||||
See [Cursors](/sdk-features/cursors) for the full list of cursor types and rotation handling.
|
||||
|
||||
### Detecting touch input
|
||||
|
||||
Check `isCoarsePointer` to adapt your UI for touch:
|
||||
|
||||
```tsx
|
||||
const { isCoarsePointer } = editor.getInstanceState()
|
||||
const buttonSize = isCoarsePointer ? 48 : 32 // Larger touch targets
|
||||
```
|
||||
|
||||
The value updates automatically when the user switches between mouse and touch.
|
||||
|
||||
## Session persistence
|
||||
|
||||
When you use a `persistenceKey` on the [Tldraw](?) component, some instance properties persist across browser sessions. Properties marked "Persists: Yes" in the tables above are saved and restored, along with each page's camera and selection.
|
||||
|
||||
Temporary state like cursor position, selection brushes, and scribbles always resets when the page reloads. `currentPageId` is restored only if that page still exists in the document.
|
||||
|
||||
## Instance state vs page state
|
||||
|
||||
Instance state ([TLInstance](?)) is global to the editor: there's one instance record per browser tab. Page state ([TLInstancePageState](?)) is per-page and tracks selection, hover, editing, cropping, and focused group for that page. See [Selection](/sdk-features/selection).
|
||||
|
||||
```typescript
|
||||
// Instance state: global to the editor
|
||||
const instance = editor.getInstanceState()
|
||||
instance.currentPageId // which page is active
|
||||
instance.isGridMode // applies to all pages
|
||||
|
||||
// Page state: specific to the current page
|
||||
const pageState = editor.getCurrentPageState()
|
||||
pageState.selectedShapeIds // selection on this page
|
||||
pageState.editingShapeId // shape being edited on this page
|
||||
pageState.hoveredShapeId // shape under cursor on this page
|
||||
```
|
||||
|
||||
When you switch pages, the instance's `currentPageId` changes, but each page retains its own selection and editing state.
|
||||
|
||||
## Related articles
|
||||
|
||||
- [Cursors](/sdk-features/cursors) — Cursor types and rotation handling
|
||||
- [Readonly mode](/sdk-features/readonly) — Disable editing with `isReadonly`
|
||||
- [Focus](/sdk-features/focus) — Editor focus state with `isFocused`
|
||||
- [Tools](/sdk-features/tools#Tool-lock) — Tool lock behavior with `isToolLocked`
|
||||
- [User preferences](/sdk-features/user-preferences) — Global preferences that persist across editor instances
|
||||
|
||||
## Related examples
|
||||
|
||||
- **[Focus mode](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/editor-api/focus-mode)** — Enable focus mode to minimize the UI.
|
||||
- **[Custom grid](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/ui/custom-grid)** — Use `isGridMode` with a custom grid component.
|
||||
- **[Read-only](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/configuration/readonly)** — Disable editing with `isReadonly`.
|
||||
231
apps/docs/content/sdk-features/internationalization.mdx
Normal file
231
apps/docs/content/sdk-features/internationalization.mdx
Normal file
@@ -0,0 +1,231 @@
|
||||
---
|
||||
title: Internationalization
|
||||
created_at: 12/29/2025
|
||||
updated_at: 12/29/2025
|
||||
keywords:
|
||||
- i18n
|
||||
- internationalization
|
||||
- localization
|
||||
- translations
|
||||
- language
|
||||
- locale
|
||||
- rtl
|
||||
status: published
|
||||
date: 12/29/2025
|
||||
readability: 9
|
||||
voice: 9
|
||||
completeness: 9
|
||||
accuracy: 9
|
||||
notes: Consider linking to an i18n example if one exists.
|
||||
---
|
||||
|
||||
Tldraw's UI supports 49 languages out of the box, including right-to-left languages like Arabic, Hebrew, Farsi, and Urdu. The translation system loads language files on demand, detects the user's browser language, and lets you override any translation string or add custom ones.
|
||||
|
||||
## Setting the locale
|
||||
|
||||
The user's locale is stored in [user preferences](/sdk-features/user-preferences). By default, tldraw detects the browser's language and selects the closest match from supported languages. The default main menu also includes a language submenu where users can change it.
|
||||
|
||||
To set the locale from your application, update the user's locale preference. The UI reads its language from `editor.user.getLocale()`, so this takes effect immediately:
|
||||
|
||||
```tsx
|
||||
import { Tldraw } from 'tldraw'
|
||||
import 'tldraw/tldraw.css'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0 }}>
|
||||
<Tldraw
|
||||
onMount={(editor) => {
|
||||
// Change the locale to French
|
||||
editor.user.updateUserPreferences({ locale: 'fr' })
|
||||
|
||||
// Get the current locale
|
||||
const locale = editor.user.getLocale() // "fr"
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
The `locale` value uses lowercase language codes: `'en'`, `'fr'`, `'de'`, `'ja'`, `'zh-cn'`, `'ar'`, and so on.
|
||||
|
||||
The `Tldraw` component also has a `locale` prop. Today it only sets the language for the outer translation provider (used by the loading screen); the editor UI itself keeps reading the user preference, so prefer `updateUserPreferences` to change the visible language.
|
||||
|
||||
### Automatic detection
|
||||
|
||||
When no locale is set, tldraw uses [getDefaultTranslationLocale](?) to detect the user's preferred language from the browser:
|
||||
|
||||
```ts
|
||||
import { getDefaultTranslationLocale } from 'tldraw'
|
||||
|
||||
// Returns 'fr', 'en', 'zh-cn', etc. based on browser settings
|
||||
const locale = getDefaultTranslationLocale()
|
||||
```
|
||||
|
||||
For each entry in the browser's `navigator.languages` array, in order, it:
|
||||
|
||||
1. Tries an exact match against supported languages
|
||||
2. Falls back to a language-only match (e.g., `'fr-CA'` → `'fr'`)
|
||||
3. Applies region defaults for Chinese (`'zh'` → `'zh-cn'`), Portuguese (`'pt'` → `'pt-br'`), Korean (`'ko'` → `'ko-kr'`), and Hindi (`'hi'` → `'hi-in'`)
|
||||
|
||||
The first entry that matches wins. If none match, it defaults to `'en'`.
|
||||
|
||||
## Using translations in components
|
||||
|
||||
The [useTranslation](?) hook returns a function for looking up translation strings. Unknown keys are returned as-is, so you can pass plain text or your own keys:
|
||||
|
||||
```tsx
|
||||
import { useTranslation } from 'tldraw'
|
||||
|
||||
function CopyButton() {
|
||||
const msg = useTranslation()
|
||||
return <button>{msg('action.copy')}</button>
|
||||
}
|
||||
```
|
||||
|
||||
Use [useCurrentTranslation](?) for the full translation object, including locale and text direction:
|
||||
|
||||
```tsx
|
||||
import { useCurrentTranslation } from 'tldraw'
|
||||
|
||||
function LocaleInfo() {
|
||||
const translation = useCurrentTranslation()
|
||||
return (
|
||||
<div dir={translation.dir}>
|
||||
<p>Locale: {translation.locale}</p>
|
||||
<p>Label: {translation.label}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
The [TLUiTranslation](?) object contains a `locale` code (e.g., `'fr'`), a `label` in the native script (e.g., `'Français'`), a `messages` record with all translation strings, and a `dir` indicating text direction (`'ltr'` or `'rtl'`).
|
||||
|
||||
## Overriding translations
|
||||
|
||||
Pass translation overrides through the `overrides` prop on `Tldraw`:
|
||||
|
||||
```tsx
|
||||
import { Tldraw } from 'tldraw'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Tldraw
|
||||
overrides={{
|
||||
translations: {
|
||||
en: {
|
||||
'action.copy': 'Copy to clipboard',
|
||||
'action.paste': 'Paste from clipboard',
|
||||
},
|
||||
fr: {
|
||||
'action.copy': 'Copier dans le presse-papiers',
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Overrides are merged with the base translations for each language. English is the fallback: any key missing from the target language uses the English string. If you add your own keys (for example a custom tool label), provide at least an `en` override for them.
|
||||
|
||||
### Translation keys
|
||||
|
||||
Translation keys follow a hierarchical naming convention. Common prefixes include `action.*` for user actions like copy and paste, `tool.*` for tool names, `menu.*` for menu labels, `style-panel.*` for style panel UI, and `a11y.*` for accessibility announcements.
|
||||
|
||||
The [TLUiTranslationKey](?) type provides autocomplete for all available keys:
|
||||
|
||||
```ts
|
||||
import type { TLUiTranslationKey } from 'tldraw'
|
||||
|
||||
const key: TLUiTranslationKey = 'action.copy'
|
||||
```
|
||||
|
||||
## Supported languages
|
||||
|
||||
Import [LANGUAGES](?) from `tldraw` for the complete list of supported languages:
|
||||
|
||||
```tsx
|
||||
import { LANGUAGES } from 'tldraw'
|
||||
|
||||
function LanguageSelector() {
|
||||
return (
|
||||
<select>
|
||||
{LANGUAGES.map(({ locale, label }) => (
|
||||
<option key={locale} value={locale}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Each entry in `LANGUAGES` is a [TLLanguage](?) with a `locale` code and a `label` in that language's native script. Translation files are only loaded for locales in this list, so you can't add a new language by supplying a translation file alone.
|
||||
|
||||
The supported languages include: English, Spanish, French, German, Italian, Portuguese (Brazilian and European), Dutch, Russian, Polish, Czech, Danish, Finnish, Swedish, Hungarian, Norwegian, Romanian, Turkish, Ukrainian, Greek, Croatian, Slovenian, Arabic, Hebrew, Farsi, Urdu, Hindi, Tamil, Telugu, Malayalam, Kannada, Bengali, Gujarati, Nepali, Marathi, Punjabi, Thai, Khmer, Vietnamese, Indonesian, Malay, Filipino, Somali, Japanese, Korean, Simplified Chinese, Traditional Chinese (Taiwan), Catalan, and Galician.
|
||||
|
||||
## Right-to-left support
|
||||
|
||||
Languages like Arabic, Hebrew, Farsi, and Urdu automatically set `dir: 'rtl'` in the translation object. The tldraw UI respects this direction. Layout and text alignment mirror automatically. The `dir` attribute is set on the editor's root container, and the built-in components use CSS logical properties (`margin-inline-start`, `inset-inline-end`, and so on) so they flip without per-component code.
|
||||
|
||||
When building custom UI components, use the [useDirection](?) hook to get the current text direction. It returns `'ltr'` or `'rtl'` from the active translation context, the same value as `useCurrentTranslation().dir`:
|
||||
|
||||
```tsx
|
||||
import { useDirection } from 'tldraw'
|
||||
|
||||
function CustomPanel() {
|
||||
const dir = useDirection()
|
||||
return <aside dir={dir}>{/* Panel content */}</aside>
|
||||
}
|
||||
```
|
||||
|
||||
Use `useCurrentTranslation` instead when you also need the locale, label, or `messages` from the same render.
|
||||
|
||||
## Building a language picker
|
||||
|
||||
Here's a language picker that updates the user's locale preference:
|
||||
|
||||
```tsx
|
||||
import { LANGUAGES, useEditor, useValue } from 'tldraw'
|
||||
|
||||
function LanguagePicker() {
|
||||
const editor = useEditor()
|
||||
const currentLocale = useValue('locale', () => editor.user.getLocale(), [editor])
|
||||
|
||||
return (
|
||||
<select
|
||||
value={currentLocale}
|
||||
onChange={(e) => {
|
||||
editor.user.updateUserPreferences({ locale: e.target.value })
|
||||
}}
|
||||
>
|
||||
{LANGUAGES.map(({ locale, label }) => (
|
||||
<option key={locale} value={locale}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
The language change applies immediately without a page reload. The preference persists to localStorage and synchronizes across browser tabs.
|
||||
|
||||
## Translation loading
|
||||
|
||||
Translations load asynchronously when the locale changes. The system:
|
||||
|
||||
1. Fetches the translation JSON file for the selected locale
|
||||
2. Merges it with English translations (ensuring all keys have values)
|
||||
3. Applies any custom overrides
|
||||
4. Updates the translation context
|
||||
|
||||
During loading, the UI uses the previous translations to avoid flicker. If loading fails, English remains as the fallback.
|
||||
|
||||
To use translations outside the full tldraw UI, wrap your components in [TldrawUiTranslationProvider](?) (inside an `AssetUrlsProvider`).
|
||||
|
||||
## Related examples
|
||||
|
||||
- [Custom translations and overrides](/examples/ui/custom-language-translations) - Override translation strings and use them in custom UI.
|
||||
144
apps/docs/content/sdk-features/license-key.mdx
Normal file
144
apps/docs/content/sdk-features/license-key.mdx
Normal file
@@ -0,0 +1,144 @@
|
||||
---
|
||||
title: License key
|
||||
created_at: 1/31/2026
|
||||
updated_at: 1/31/2026
|
||||
keywords:
|
||||
- license
|
||||
- license key
|
||||
- watermark
|
||||
- production
|
||||
- trial
|
||||
- commercial
|
||||
- hobby
|
||||
status: published
|
||||
date: 1/31/2026
|
||||
---
|
||||
|
||||
The tldraw SDK requires a license key to work in production. Without a valid key, the SDK runs in development mode only. License keys unlock production deployment and, depending on your license type, may remove the "made with tldraw" watermark.
|
||||
|
||||
```tsx
|
||||
import { Tldraw } from 'tldraw'
|
||||
import 'tldraw/tldraw.css'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0 }}>
|
||||
<Tldraw licenseKey="your-license-key" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## How license keys work
|
||||
|
||||
License keys are validated on the client. They can be public: you can safely include them in your frontend code. The SDK decodes and verifies the key's signature locally without making network requests to a license server.
|
||||
|
||||
Each key encodes the allowed hosts (the domains where the license is valid), the license type (trial, commercial, or hobby), and the expiration date.
|
||||
|
||||
## Using the license key
|
||||
|
||||
Pass the key to the `licenseKey` prop on [Tldraw](?), [TldrawEditor](?), or [TldrawImage](?):
|
||||
|
||||
```tsx
|
||||
<Tldraw licenseKey="tldraw-abc123..." />
|
||||
```
|
||||
|
||||
```tsx
|
||||
<TldrawImage snapshot={snapshot} licenseKey="tldraw-abc123..." />
|
||||
```
|
||||
|
||||
You can also reference an environment variable explicitly:
|
||||
|
||||
```tsx
|
||||
<Tldraw licenseKey={process.env.NEXT_PUBLIC_TLDRAW_LICENSE_KEY} />
|
||||
```
|
||||
|
||||
### Automatic environment variable detection
|
||||
|
||||
The SDK also checks for license keys in common environment variables. If you set one of these, you don't need to pass the `licenseKey` prop:
|
||||
|
||||
- `TLDRAW_LICENSE_KEY`
|
||||
- `NEXT_PUBLIC_TLDRAW_LICENSE_KEY` (Next.js)
|
||||
- `REACT_APP_TLDRAW_LICENSE_KEY` (Create React App)
|
||||
- `GATSBY_TLDRAW_LICENSE_KEY` (Gatsby)
|
||||
- `VITE_TLDRAW_LICENSE_KEY` (Vite)
|
||||
- `PUBLIC_TLDRAW_LICENSE_KEY` (SvelteKit, etc.)
|
||||
|
||||
The SDK checks both `process.env` and `import.meta.env` versions of each variable.
|
||||
|
||||
## License types
|
||||
|
||||
| Type | Watermark | Duration | Purpose |
|
||||
| ---------- | --------- | -------- | --------------------------------- |
|
||||
| Trial | No | 100 days | Evaluate before purchasing |
|
||||
| Commercial | No | Annual | Production use in commercial apps |
|
||||
| Hobby | Yes | Varies | Non-commercial projects |
|
||||
|
||||
### Trial licenses
|
||||
|
||||
Get a free 100-day trial by completing the [trial license form](https://tldraw.dev/get-a-license/trial). You'll receive a license key immediately by email. One trial per company or project.
|
||||
|
||||
### Commercial licenses
|
||||
|
||||
Request a commercial license through the [plans form](https://tldraw.dev/get-a-license/plans). The sales team will discuss your requirements and pricing. Commercial licenses remove the watermark and are required for any commercial use in production.
|
||||
|
||||
### Hobby licenses
|
||||
|
||||
For non-commercial projects, request a [hobby license](https://tldraw.dev/get-a-license/hobby). Hobby licenses keep the "made with tldraw" watermark visible. They're discretionary: we review each request.
|
||||
|
||||
## Development vs production
|
||||
|
||||
In development environments, the SDK works without a license key. The SDK treats the environment as development if any of these are true: the protocol is not HTTPS, the hostname is `localhost` or a loopback address (`127.x.x.x`, `::1`), or `NODE_ENV` is not `'production'`.
|
||||
|
||||
In production (HTTPS on a non-loopback domain with `NODE_ENV=production`), the SDK requires a valid license key. Without one, the SDK logs errors to the console and, after five seconds, stops rendering the editor.
|
||||
|
||||
Keys are only verified when `crypto.subtle` is available. On plain HTTP in development, the browser doesn't expose it, so the SDK skips verification and logs a note asking you to check the key in production separately.
|
||||
|
||||
## Domain validation
|
||||
|
||||
License keys specify which domains they work on. The SDK validates the current hostname against the allowed hosts in the key.
|
||||
|
||||
An exact host like `example.com` matches `example.com` and `www.example.com`. A wildcard like `*.example.com` matches any subdomain. Some enterprise licenses allow `*` for any domain.
|
||||
|
||||
If you deploy to a domain not covered by your license, the SDK treats it as unlicensed.
|
||||
|
||||
## Grace period
|
||||
|
||||
Annual and perpetual licenses have a 30-day grace period after expiration. During this period, the SDK continues working but logs a message to the console. This gives you time to renew without service interruption. After the grace period the SDK stops rendering the editor.
|
||||
|
||||
Evaluation (trial) licenses have no grace period. They stop working immediately on expiration.
|
||||
|
||||
## Perpetual licenses
|
||||
|
||||
Perpetual licenses don't have a time-based expiration. Instead, they're tied to a version: they work with any patch release indefinitely, but major or minor versions released after the license expiration date (plus the 30-day grace period) require renewal.
|
||||
|
||||
Early versions of tldraw were sold with a perpetual license. While we no longer sell these licenses (except in exceptional cases), we still support them for existing customers.
|
||||
|
||||
## Data collection
|
||||
|
||||
Data collection differs by license type:
|
||||
|
||||
| License type | Data sent |
|
||||
| ------------ | --------------------------------------------------- |
|
||||
| Commercial | None |
|
||||
| Hobby | License ID, license type, SDK version, and page URL |
|
||||
| Trial | License ID, license type, SDK version, and page URL |
|
||||
| Unlicensed | SDK version and page URL (production only) |
|
||||
|
||||
Trial and hobby licenses ping tldraw's servers with the license ID, license type, SDK version, build environment, and deployment URL for analytics. No user data, canvas content, or personally identifiable information is collected. Nothing is sent from development environments.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Console shows "No tldraw license key provided" and "A license is required for production deployments"**: You're in production without a key, and the editor will stop rendering after five seconds. Add a valid `licenseKey` prop or set an environment variable.
|
||||
|
||||
**Console shows "License key is not valid for this domain"**: Your key doesn't include the current domain. Contact sales@tldraw.com to update your allowed domains.
|
||||
|
||||
**Console shows "Your tldraw evaluation license has expired"**: Trial licenses have no grace period. Contact sales@tldraw.com to purchase a full license.
|
||||
|
||||
**Console shows "Your tldraw license has been expired for more than 30 days"**: Your license is past the 30-day grace period. Contact sales@tldraw.com to renew.
|
||||
|
||||
**Key works locally but not in production**: Development mode doesn't require a key. Make sure you're passing the key correctly in your production build and that the domain matches your license.
|
||||
|
||||
## Learn more
|
||||
|
||||
See the [License](/community/license) page for full details on the tldraw license terms, including information about open source usage and trademark guidelines.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user