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

This commit is contained in:
upstream-mirror
2026-09-23 16:12:52 +05:00
commit a774ac673a
4541 changed files with 770499 additions and 0 deletions

8
apps/docs/.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
content/reference
api-content.json
content.db
.env
.next
.vercel
.envrc
llms*.txt

View File

@@ -0,0 +1,5 @@
.next/*
.lazy/*
content.db
node_modules
utils/vector-db/index.json

9
apps/docs/LICENSE.md Normal file
View File

@@ -0,0 +1,9 @@
MIT License
Copyright (c) 2024 tldraw Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

167
apps/docs/README.md Normal file
View File

@@ -0,0 +1,167 @@
# tldraw-docs
Welcome to the source for the [tldraw docs site](https://tldraw.dev).
This site is a [Next.js](https://nextjs.org/) app that uses [MDX](https://mdxjs.com/) for content. It contains human-written docs in the `content` folder as well as generated docs in the `api` folder.
We have several scripts that build these files into a SQLite database that is used to generate the site's pages.
To pull the most recent docs from the tldraw repo, create an .env file with a GitHub personal access token and the SHA of the commit or branch that you'd like to pull from.
```
ACCESS_TOKEN=your_github_access_token
SOURCE_SHA=main
```
The files are also provided in this repo.
## Building the content
You can build the markdown and API content using the following scripts:
- `yarn refresh-everything` to reset the database, generate the markdown from the API docs, and populate the database with articles from both the regular content and the generated API content
- `yarn refresh-content` to generate just the regular content
# Content
The docs has two types of content: regular content that is written by the team and auto-generated content that is created using [tsdoc](https://tsdoc.org/) and [API extractor](https://api-extractor.com/).
The `content` folder contains all content in the form of MDX files. All articles belong to a "section" and a "category". The `sections.json` defines each section and any categories belonging to that section.
A section looks like this:
```json
{
"id": "community",
"title": "Community",
"description": "Community resources for tldraw.",
"categories": []
}
```
The content is organized into folders for each section. The `gen` folder contains auto-generated content.
## Regular Content
The `content` folder contains all "regular" content in the form of MDX files.All articles belong to a "section" and a "category". The content is organized into folders for each "section".
An article's frontmatter looks like this:
```md
---
title: User Interface
description: How to customize the tldraw user interface using overrides.
status: published
author: steveruizok
date: 3/22/2023
order: 8
keywords:
- ui
- interface
- tools
- shapes
- custom
- button
- toolbar
- styles
---
```
### Title
The `title` is displayed in the article's header, in the page title, in the search bar, and in search results. It is used to find an article through the site's search feature.
### Description
The `description` is hidden in the article's frontmatter, but is used to populate the article's meta description tag. It is also used to find an article through the site's search feature.
### Hero
The `hero` is used for the article's social media image. It is not displayed in the article. It should refer to a page in the `public/images` folder.
### Category
An article may declare its `category` in its frontmatter. Any article that does not declare a category will be placed into the "ucg" category for "uncategorized" articles.
### Order
The `order` property defines the article's order in its category. Uncategorized articles are placed at the end of the list of categories sorted by its `order`. For a section without categories, the `order` keyword effectively defines the order that the article will appear in the section list.
### Author
The `author` must refer to an author named in the `content/authors.json` file.
An author looks like this:
```json
"steveruizok": {
"name": "Steve Ruiz",
"email": "steve@tldraw.com",
"twitter": "steveruizok",
"image": "steve_ruiz.jpg"
}
```
The image should refer to an image in `public/avatars`.
### Date
The `date` is formatted as DD/MM/YYYY.
### Status
An article's `status` may be either `draft` or `published`. A `draft` article is hidden in production.
### Keywords
The `keywords` are used to find an article through the site's search feature.
## Auto-generated content
The auto-generated docs content is created using [tsdoc](https://tsdoc.org/) and [API extractor](https://api-extractor.com/). The source is the API documentation created by `yarn build` or `yarn build-api`. The output is placed in the `gen` folder.
## Release notes
Release notes live in `content/releases/` and are the source of truth for SDK release documentation.
### File structure
- `next.mdx` - Accumulates changes for the upcoming release
- `vX.Y.0.mdx` - Published release notes (one file per minor release)
### Workflow
1. As PRs merge to main, update `next.mdx` with release notes using the `update-release-notes` skill
2. When publishing a release:
- Rename `next.mdx` to `vX.Y.0.mdx`
- Update the frontmatter `order` field
- Create a new `next.mdx` for the next version
3. The release notes can then be copied to GitHub releases
See `skills/update-release-notes/` for the maintenance workflow and `skills/write-release-notes/` for writing guidance.
## Developing the docs
You'll need to install Git LFS to get our blog post images (which can be large). See instructions [here](https://docs.github.com/en/repositories/working-with-files/managing-large-files/installing-git-large-file-storage).
When developing the docs, any change to the `content` folder will cause the page to refresh. This is a little shitty but it mostly works.
## Contribution
Found a bug? Please [submit an issue](https://github.com/tldraw/tldraw/issues/new).
## License
This project is provided under the MIT license found [here](https://github.com/tldraw/tldraw/blob/main/apps/docs/LICENSE.md). The tldraw SDK is provided under the [tldraw license](https://github.com/tldraw/tldraw/blob/main/LICENSE.md).
## Trademarks
Copyright (c) 2024-present tldraw Inc. The tldraw name and logo are trademarks of tldraw. Please see our [trademark guidelines](https://github.com/tldraw/tldraw/blob/main/TRADEMARKS.md) for info on acceptable usage.
## Contact
Find us on Twitter/X at [@tldraw](https://twitter.com/tldraw).
## Community
Have questions, comments or feedback? [Join our discord](https://discord.tldraw.com/?utm_source=github&utm_medium=readme&utm_campaign=sociallink). For the latest news and release notes, visit [tldraw.dev](https://tldraw.dev).

View File

@@ -0,0 +1,139 @@
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import { Content } from '@/components/content'
import { StarterKitEmbed } from '@/components/content/embed'
import { Example } from '@/components/content/example'
import { DocsFeedbackWidget } from '@/components/docs/docs-feedback-widget'
import { DocsFooter } from '@/components/docs/docs-footer'
import { DocsHeader } from '@/components/docs/docs-header'
import { DocsMobileSidebar } from '@/components/docs/docs-mobile-sidebar'
import { DocsSidebar } from '@/components/docs/docs-sidebar'
import { DocsStarterSidebar } from '@/components/docs/docs-starter-sidebar'
import { DocsTableOfContents } from '@/components/docs/docs-table-of-contents'
import { SearchButton } from '@/components/search/SearchButton'
import { cn } from '@/utils/cn'
import { db } from '@/utils/ContentDatabase'
import { parseMarkdown } from '@/utils/parse-markdown'
import { canonicalDocsPageUrl } from '@/utils/sitemap-canonical-paths'
export async function generateStaticParams() {
const paths = await db.getAllPaths()
return paths.map((path) => ({ slug: path.replace(/^\//, '').split('/') }))
}
export const dynamicParams = false
export async function generateMetadata(props: {
params: Promise<{ slug: string | string[] }>
}): Promise<Metadata> {
const params = await props.params
const path = typeof params.slug === 'string' ? [params.slug] : params.slug
const pagePath = `/${path.join('/')}`
const content = await db.getPageContent(pagePath)
if (!content || content.type !== 'article') notFound()
const metadata: Metadata = {
title: content.article.title,
alternates: { canonical: canonicalDocsPageUrl(pagePath) },
}
if (content.article.description) {
metadata.description = content.article.description
} else {
const parsed = parseMarkdown(content.article.content ?? '', content.article.id)
const initialContentText = parsed.initialContentText.trim()
if (initialContentText) metadata.description = initialContentText
}
return metadata
}
export default async function Page(props: { params: Promise<{ slug: string | string[] }> }) {
const params = await props.params
const path = typeof params.slug === 'string' ? [params.slug] : params.slug
const content = await db.getPageContent(`/${path.join('/')}`)
if (!content || content.type !== 'article') notFound()
return (
<div className="w-full max-w-screen-2xl mx-auto md:px-5 md:flex md:items-start md:pt-12 isolate">
<DocsSidebar
sectionId={content.article.sectionId}
categoryId={content.article.categoryId}
articleId={content.article.id}
/>
<div className="sticky top-14 z-10 flex items-center justify-between w-full h-12 px-5 bg-white border-b border-zinc-100 dark:border-zinc-800 dark:bg-zinc-950 backdrop-blur md:hidden">
<DocsMobileSidebar
sectionId={content.article.sectionId}
categoryId={content.article.categoryId}
articleId={content.article.id}
/>
<SearchButton type="docs" layout="mobile" className="hidden -mr-2 sm:block" />
</div>
{content.article.sectionId === 'examples' ? (
<main className="relative w-full px-5 pt-12 shrink md:pt-0 min-w-[1px]">
<DocsHeader article={content.article} />
{content.article.description && (
<Content mdx={content.article.description} type={content.article.sectionId} />
)}
<Example article={content.article} />
{content.article.content && (
<Content mdx={content.article.content} type={content.article.sectionId} />
)}
<div className="mx-auto w-full max-w-sm mt-8 mb-16">
<DocsFeedbackWidget />
</div>
<DocsFooter article={content.article} />
</main>
) : content.article.sectionId === 'starter-kits' &&
content.article.id !== 'starter-kits/starter-kits_ucg/overview' ? (
<main className="relative w-full px-5 pt-12 shrink md:pt-0 min-w-[1px]">
<DocsHeader article={content.article} />
<div
className={cn(
'mb-8',
'prose dark:prose-invert prose-sm prose-zinc text-zinc-800 dark:text-zinc-200 sm:prose-base w-full max-w-full',
'prose-code:before:content-none prose-code:after:content-none prose-code:bg-zinc-100 dark:prose-code:!bg-zinc-800 prose-code:px-1 prose-code:py-0.5 prose-code:rounded prose-code:font-normal',
'prose-a:no-underline prose-a:text-blue-500 hover:prose-a:text-blue-600 dark:hover:prose-a:text-blue-400 prose-a:font-normal',
'prose-blockquote:text-zinc-800 dark:prose-blockquote:text-zinc-200 prose-blockquote:font-normal prose-blockquote:border-none prose-blockquote:px-4 prose-blockquote:leading-normal prose-blockquote:bg-zinc-50 dark:prose-blockquote:bg-zinc-900 prose-blockquote:py-3 prose-blockquote:rounded-xl',
'prose-table:bg-zinc-50 dark:prose-table:bg-zinc-900 prose-table:rounded-xl prose-table:text-sm',
'prose-th:text-left prose-th:font-semibold prose-th:uppercase prose-th:text-xs prose-th:border-l prose-th:border-white dark:prose-th:border-zinc-950 prose-th:py-3 prose-th:px-4',
'prose-tr:border-t prose-tr:border-white dark:prose-tr:border-zinc-950',
'prose-td:border-l first:prose-td:border-l-0 prose-td:border-white dark:prose-td:border-zinc-950 prose-td:py-3 prose-td:px-4',
'prose-hr:border-zinc-100 dark:prose-hr:border-zinc-800',
'prose-h1:scroll-mt-20 prose-h2:scroll-mt-20 prose-h3:scroll-mt-20 prose-h4:scroll-mt-20 prose-h5:scroll-mt-20'
)}
>
{content.article.description && (
<Content
mdx={content.article.description}
type={content.article.sectionId}
className="mb-6"
/>
)}
{content.article.embed && <StarterKitEmbed id={content.article.embed} />}
</div>
<div className="flex flex-col xl:flex-row gap-6 relative">
<DocsStarterSidebar article={content.article} />
<Content mdx={content.article.content ?? ''} type={content.article.sectionId} />
</div>
<DocsFooter article={content.article} />
</main>
) : content.article.sectionId === 'starter-kits' ? (
<main className="relative w-full px-5 pt-12 shrink md:pt-0 min-w-[1px]">
<DocsHeader article={content.article} />
<Content mdx={content.article.content ?? ''} type={content.article.sectionId} />
<DocsFooter article={content.article} />
<div className="mx-auto w-full max-w-sm mt-8 mb-16">
<DocsFeedbackWidget />
</div>
</main>
) : (
<>
<main className="relative grow px-5 pt-12 md:pr-0 lg:pl-12 xl:pr-12 md:pt-0 min-w-[1px]">
<DocsHeader article={content.article} />
<Content mdx={content.article.content ?? ''} type={content.article.sectionId} />
<DocsFooter article={content.article} />
</main>
<DocsTableOfContents article={content.article} />
</>
)}
</div>
)
}

View File

@@ -0,0 +1,20 @@
import { Metadata } from 'next'
import { DocsSearchBar } from '@/components/docs/docs-search-bar'
export const metadata: Metadata = {
metadataBase: new URL('https://tldraw.dev'),
title: {
default: 'Docs',
template: `%s • tldraw Docs`,
},
description: 'Documentation for the tldraw SDK.',
}
export default async function Layout({ children }: { children: React.ReactNode }) {
return (
<>
<DocsSearchBar />
{children}
</>
)
}

View File

@@ -0,0 +1,31 @@
import {
AGENT_SKILLS_INDEX_SCHEMA,
PUBLISHED_SKILLS,
readPublishedSkill,
} from '@/utils/agent-skills'
// Read at build time, so a skill whose frontmatter has gone missing fails the build rather than
// serving a broken index.
export const dynamic = 'force-static'
export function GET() {
const skills = Object.keys(PUBLISHED_SKILLS).map((key) => {
const skill = readPublishedSkill(key as keyof typeof PUBLISHED_SKILLS)
return {
name: skill.name,
// An archive rather than `skill-md`, because these skills run their own helper scripts.
// See `SkillDocument.archive`.
type: 'archive',
description: skill.description,
url: skill.url,
digest: skill.digest,
}
})
return Response.json(
{ $schema: AGENT_SKILLS_INDEX_SCHEMA, skills },
// Readable cross-origin: an agent running in a browser is a normal consumer of this, and
// everything here is already public.
{ headers: { 'access-control-allow-origin': '*' } }
)
}

View File

@@ -0,0 +1,15 @@
import { readPublishedSkill } from '@/utils/agent-skills'
export const dynamic = 'force-static'
// The installable form of the skill: the whole directory, which the index publishes a digest over.
// A client that unpacks this and one that reads the `SKILL.md` route next door see the same
// instructions; only this one carries the helper scripts those instructions run.
export function GET() {
return new Response(new Uint8Array(readPublishedSkill('tldraw-migrate').archive), {
headers: {
'content-type': 'application/gzip',
'access-control-allow-origin': '*',
},
})
}

View File

@@ -0,0 +1,14 @@
import { readPublishedSkill } from '@/utils/agent-skills'
export const dynamic = 'force-static'
// Served verbatim from `skills/tldraw-migrate/SKILL.md` so the instructions can be read without
// unpacking anything. The index points at the archive instead — this file alone cannot run.
export function GET() {
return new Response(readPublishedSkill('tldraw-migrate').markdown, {
headers: {
'content-type': 'text/markdown; charset=utf-8',
'access-control-allow-origin': '*',
},
})
}

114
apps/docs/app/analytics.tsx Normal file
View File

@@ -0,0 +1,114 @@
'use client'
import Script from 'next/script'
import { useEffect } from 'react'
export default function Analytics() {
useEffect(() => {
window.TL_GA4_MEASUREMENT_ID = process.env.NEXT_PUBLIC_GA4_MEASUREMENT_ID
window.TL_GOOGLE_ADS_ID = process.env.NEXT_PUBLIC_GOOGLE_ADS_ID
window.TL_GTM_CONTAINER_ID = process.env.NEXT_PUBLIC_GTM_CONTAINER_ID
}, [])
useEffect(() => {
const handleCopy = (copyEvent: ClipboardEvent) => {
const isWithinCodeBlock = (copyEvent.target as HTMLElement | null)?.closest('pre, code')
if (isWithinCodeBlock) {
const copiedText = window.getSelection()?.toString() || ''
if (window.tlanalytics?.trackCopyCode) {
window.tlanalytics.trackCopyCode({
page_category: 'docs',
text_snippet: copiedText,
})
}
// Track Google Ads conversion for code block copies
if (window.tlanalytics?.gtag) {
window.tlanalytics.gtag('event', 'conversion', {
send_to: 'AW-17268182782/qIuDCMnhl_EaEP6djqpA',
value: 1.0,
currency: 'USD',
})
}
}
}
document.addEventListener('copy', handleCopy)
return () => {
document.removeEventListener('copy', handleCopy)
}
}, [])
const analyticsScriptSrc =
process.env.NODE_ENV === 'development'
? 'http://localhost:5173/tl-analytics.js'
: 'https://analytics.tldraw.com/tl-analytics.js'
return (
<>
<Script
id="tldraw-analytics"
type="text/javascript"
strategy="afterInteractive"
async
defer
src={analyticsScriptSrc}
/>
</>
)
}
interface ConsentPreferences {
analytics: 'granted' | 'denied'
marketing: 'granted' | 'denied'
}
declare global {
interface Window {
tlanalytics: {
openPrivacySettings(): void
track(name: string, data?: { [key: string]: any }): void
gtag(...args: any[]): void
getConsentState(): ConsentPreferences
onConsentUpdate(callback: (preferences: ConsentPreferences) => void): () => void
trackCopyCode(data: {
page_category: string
text_snippet: string
user_email?: string
user_email_sha256?: string
user_first_name?: string
user_last_name?: string
user_phone_number?: string
}): void
trackFormSubmission(data: {
enquiry_type: string
page_category?: string
company_size?: string
company_website?: string
user_email?: string
user_email_sha256?: string
user_first_name?: string
user_last_name?: string
user_phone_number?: string
}): void
}
TL_GA4_MEASUREMENT_ID: string | undefined
TL_GOOGLE_ADS_ID?: string
TL_GTM_CONTAINER_ID?: string
posthog: any
}
}
export function PrivacySettingsLink() {
const onCookieClick = () => window.tlanalytics.openPrivacySettings()
return (
<>
<button onClick={onCookieClick}>Privacy settings</button>
</>
)
}
export function track(name: string, data?: { [key: string]: any }) {
window.tlanalytics?.track(name, data)
}

138
apps/docs/app/globals.css Normal file
View File

@@ -0,0 +1,138 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
.prose h2,
.prose h3,
.prose h4,
.prose h5,
.prose h6 {
scroll-margin-top: 90px;
}
@media (max-width: 768px) {
.prose h2,
.prose h3,
.prose h4,
.prose h5,
.prose h6 {
scroll-margin-top: 130px;
}
}
.prose h2 a,
.prose h3 a,
.prose h4 a {
@apply font-bold;
}
.prose blockquote p {
margin: 0;
}
.prose blockquote p:before,
.prose blockquote p:after {
content: none;
}
.prose details.article__table-of-contents {
display: none !important;
}
input[type='search']::-webkit-search-decoration,
input[type='search']::-webkit-search-cancel-button,
input[type='search']::-webkit-search-results-button,
input[type='search']::-webkit-search-results-decoration {
-webkit-appearance: none;
}
@media (max-width: 768px) {
.prose table {
table-layout: fixed;
}
.prose td {
word-break: break-word;
}
}
}
.hs-form-field.hs-fieldtype-text label,
.hs-form-field.hs-fieldtype-select label,
.hs-form-field.hs-fieldtype-textarea label {
display: none;
}
/* We don't show any descriptions or lables */
.hs-field-desc {
display: block !important;
}
/* Make text areas span both columns */
div.hs-fieldtype-textarea {
@apply sm:col-span-2;
}
[data-hs-forms-root] form {
@apply flex flex-col sm:grid-cols-2 sm:grid gap-4;
}
[data-hs-forms-root] select {
@apply w-full h-9 px-4 appearance-none rounded-lg text-white bg-zinc-700/50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 focus:ring-offset-zinc-900;
}
[data-hs-forms-root] select.is-placeholder {
@apply text-white/50;
}
[data-hs-forms-root] input {
@apply w-full h-9 px-4 rounded-lg text-white bg-zinc-700/50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 focus:ring-offset-zinc-900;
}
[data-hs-forms-root] textarea {
@apply -mb-1.5 w-full px-4 py-2 rounded-lg resize-none text-white bg-zinc-700/50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 focus:ring-offset-zinc-900;
height: 112px;
}
.hs-submit {
@apply overflow-hidden items-center col-start-1 col-span-2 p-2;
}
.hs-submit input {
@apply overflow-hidden items-center h-9 rounded-lg text-base !bg-blue-500 !text-white hover:!bg-blue-600 dark:hover:!bg-blue-400;
}
.hs_error_rollup {
@apply flex items-center;
}
.masked-logo {
position: relative;
background-color: currentColor;
z-index: 1;
color: inherit;
}
mark {
margin: -1px -1px;
padding: 1px 1px;
border-radius: 3px;
background-color: #ffc80046;
color: inherit;
-webkit-box-decoration-break: clone;
box-decoration-break: clone;
}
.special {
--r: 18px;
--rl: -1;
--b: 4px;
background: none;
text-shadow:
calc(var(--r) * 1) 0px var(--b) rgba(255, 208, 0, 0.03),
calc(var(--r) * 0.75) 0px var(--b) rgba(255, 140, 0, 0.04),
calc(var(--r) * 0.5) 0px var(--b) rgba(255, 187, 0, 0.09),
calc(var(--r) * 0.25) 0px var(--b) rgba(255, 187, 0, 0.12),
calc(var(--r) * 0.12) 0px var(--b) rgba(255, 217, 0, 0.32),
calc(var(--r) * 1 * var(--rl)) 0px rgba(255, 140, 0, 0.02),
calc(var(--r) * 0.75 * var(--rl)) 0px var(--b) rgba(255, 208, 0, 0.04),
calc(var(--r) * 0.5 * var(--rl)) 0px var(--b) rgba(255, 187, 0, 0.08),
calc(var(--r) * 0.25 * var(--rl)) 0px var(--b) rgba(255, 187, 0, 0.12),
calc(var(--r) * 0.12 * var(--rl)) 0px var(--b) rgba(255, 217, 0, 0.32);
}

102
apps/docs/app/layout.tsx Normal file
View File

@@ -0,0 +1,102 @@
import { GeistSans } from 'geist/font/sans'
import { Metadata, Viewport } from 'next'
import { ThemeProvider } from 'next-themes'
import localFont from 'next/font/local'
import AutoRefresh from '@/components/common/autorefresh'
import { Footer } from '@/components/navigation/footer'
import { Header } from '@/components/navigation/header'
import { assetUrl } from '@/utils/asset-url'
import { cn } from '@/utils/cn'
import Analytics from './analytics'
import './globals.css'
function Wrapper({ children }: { children: React.ReactNode }) {
if (process.env.NODE_ENV === 'development') {
return <AutoRefresh>{children}</AutoRefresh>
}
return children
}
export const metadata: Metadata = {
metadataBase: new URL('https://tldraw.dev'),
title: {
default: 'tldraw: Build whiteboards in React with the tldraw SDK',
template: `%s • tldraw`,
},
description:
'The tldraw SDK provides tools, services, and APIs to build beautiful whiteboards and infinite canvas applications with real-time collaboration and a powerful React-based canvas.',
twitter: {
creator: '@tldraw',
},
applicationName: 'tldraw SDK',
appleWebApp: {
capable: true,
title: 'tldraw SDK',
statusBarStyle: 'black',
},
icons: [
{ rel: 'shortcut icon', url: assetUrl('/favicon.svg') },
{ rel: 'icon', url: assetUrl('/favicon-32x32.svg'), sizes: '32x32' },
{ rel: 'icon', url: assetUrl('/favicon-16x16.svg'), sizes: '16x16' },
{ rel: 'apple-touch-icon', url: assetUrl('/apple-touch-icon.png') },
{
rel: 'apple-touch-icon',
url: assetUrl('/apple-touch-icon-152x152.svg'),
sizes: '152x152',
},
{
rel: 'apple-touch-icon',
url: assetUrl('/apple-touch-icon-180x180.svg'),
sizes: '180x180',
},
{
rel: 'apple-touch-icon',
url: assetUrl('/apple-touch-icon-167x167.svg'),
sizes: '167x167',
},
],
}
export const viewport: Viewport = {
initialScale: 1,
maximumScale: 1,
width: 'device-width',
height: 'device-height',
themeColor: '#ffffff',
}
const ShantellSans = localFont({
src: './shantell-sans.woff2',
weight: '400',
display: 'swap',
variable: '--font-shantell-sans',
})
export default async function Layout({ children }: { children: React.ReactNode }) {
return (
<Wrapper>
<html
lang="en"
className={cn(GeistSans.variable, ShantellSans.variable, 'font-sans antialiased')}
>
<body className="overflow-x-hidden bg-white text-zinc-800 dark:bg-zinc-950 dark:text-zinc-300">
<ThemeProvider attribute="class" defaultTheme="light" enableSystem={false}>
<Header />
{children}
<Footer />
<Analytics />
</ThemeProvider>
<noscript>
<iframe
src="https://www.googletagmanager.com/ns.html?id=GTM-KMMLPMMN"
height="0"
width="0"
style={{ display: 'none', visibility: 'hidden' }}
/>
</noscript>
</body>
</html>
</Wrapper>
)
}

View File

@@ -0,0 +1,72 @@
/* eslint-disable react/no-unescaped-entities */
'use client'
import { RocketLaunchIcon } from '@heroicons/react/16/solid'
import { AcademicCapIcon, BookOpenIcon, NewspaperIcon, PlayIcon } from '@heroicons/react/20/solid'
import Image from 'next/image'
import Link from 'next/link'
import { DiscordIcon } from '@/components/common/icon/discord'
import { DocsSearchBar } from '@/components/docs/docs-search-bar'
import { NavigationLink } from '@/components/navigation/link'
import Handwritten404 from '@/public/images/404.svg'
const links = [
{
caption: 'Quick start',
icon: RocketLaunchIcon,
href: '/quick-start',
},
{
caption: 'Guides',
icon: AcademicCapIcon,
href: '/docs/editor',
},
{
caption: 'Reference',
icon: BookOpenIcon,
href: '/reference/editor/Editor',
},
{
caption: 'Examples',
icon: PlayIcon,
href: '/examples/basic',
},
{
caption: 'Blog',
icon: NewspaperIcon,
href: '/blog',
},
]
export default function NotFound() {
return (
<div className="min-h-screen">
<DocsSearchBar />
<section className="flex flex-col items-center justify-center py-16 md:py-32 px-5">
<Image src={Handwritten404} alt="404 - Page not found" className="w-64" />
<h1 className="font-hand text-black dark:text-white text-2xl">page not found :(</h1>
<p className="mt-8 max-w-md text-center text-balance">
There's nothing here. You may want to look at these pages to find what you're looking for:
</p>
<ul className="mt-8 flex flex-wrap justify-center gap-8">
{links.map((item, index) => (
<li key={index}>
<NavigationLink {...item} active={false} />
</li>
))}
</ul>
<p className="mt-32 max-w-md text-center text-balance text-sm">
You can always ask for help in our welcoming community on{' '}
<Link
href="https://discord.tldraw.com/?utm_source=docs&utm_medium=organic&utm_campaign=sociallink"
className="text-blue-500 hover:text-blue-600 dark:hover:text-blue-400"
>
<DiscordIcon className="h-3.5 mb-0.5 ml-0.5 inline" />
<span> Discord</span>
</Link>
.
</p>
</section>
</div>
)
}

4
apps/docs/app/robots.txt Normal file
View File

@@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://www.tldraw.dev/sitemap.xml

View File

@@ -0,0 +1,14 @@
import { Metadata } from 'next'
export const metadata: Metadata = {
metadataBase: new URL('https://tldraw.dev'),
title: {
default: 'Search',
template: `%s • tldraw SDK`,
},
description: 'Documentation for the tldraw SDK.',
}
export default async function Layout({ children }: { children: React.ReactNode }) {
return children
}

View File

@@ -0,0 +1,19 @@
import { DocsMobileSidebar } from '@/components/docs/docs-mobile-sidebar'
import { DocsSidebar } from '@/components/docs/docs-sidebar'
import { FullPageSearch } from '@/components/search/FullPageSearch'
export const dynamic = 'force-dynamic'
export default function Page() {
return (
<div className="w-full max-w-screen-2xl mx-auto md:px-5 md:flex md:pt-8 isolate md:isolation-auto">
<DocsSidebar />
<div className="sticky top-14 z-10 flex items-center justify-between w-full h-12 px-5 bg-white border-b border-zinc-100 dark:border-zinc-800 dark:bg-zinc-950 backdrop-blur md:hidden">
<DocsMobileSidebar />
</div>
<main className="relative w-full max-w-3xl px-5 pt-7 shrink md:pr-0 lg:pl-12 xl:pr-12 md:pt-0 min-w-[1px]">
<FullPageSearch indexName="docs" />
</main>
</div>
)
}

20
apps/docs/app/sitemap.ts Normal file
View File

@@ -0,0 +1,20 @@
import { MetadataRoute } from 'next'
import { db } from '@/utils/ContentDatabase'
import { canonicalizeDocsSitemapPaths } from '@/utils/sitemap-canonical-paths'
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const paths = canonicalizeDocsSitemapPaths(await db.getAllPaths())
// Docs-only sitemap. Marketing pages are now owned by the dotdev app.
const docsSitemap: MetadataRoute.Sitemap = [
{
url: 'https://tldraw.dev/',
lastModified: new Date(),
},
...paths.map((path: string) => ({
url: 'https://tldraw.dev' + path,
lastModified: new Date(),
})),
]
return docsSitemap
}

View File

@@ -0,0 +1,30 @@
'use client'
import { useRouter } from 'next/navigation'
import { ReactNode, useEffect } from 'react'
let AutoRefresh = ({ children }: { children: ReactNode }) => {
return children
}
if (process.env.NODE_ENV === 'development') {
AutoRefresh = function AutoRefresh({ children }) {
const router = useRouter()
useEffect(() => {
const ws = new WebSocket('ws://localhost:3201')
ws.onmessage = (event) => {
if (event.data === 'refresh') {
router.refresh()
}
}
return () => {
ws.close()
}
}, [router])
return children
}
}
export default AutoRefresh

View File

@@ -0,0 +1,16 @@
'use client'
import { ArrowLongUpIcon } from '@heroicons/react/20/solid'
export function BackToTopButton() {
return (
<button
id="back-to-top"
onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
className="flex items-center gap-1.5 text-blue-500 hover:text-blue-600 dark:hover:bg-blue-400"
>
<ArrowLongUpIcon className="h-3.5 -mr-px" />
<span>Scroll to top</span>
</button>
)
}

View File

@@ -0,0 +1,88 @@
'use client'
import { ArrowLongLeftIcon, ArrowLongRightIcon } from '@heroicons/react/20/solid'
import Link from 'next/link'
import { MouseEventHandler } from 'react'
import { track } from '@/app/analytics'
import { Icon, IconName } from '@/components/common/icon'
import { cn } from '@/utils/cn'
export function Button({
id,
href,
newTab,
onClick,
caption,
icon,
arrow,
className,
size = 'base',
type = 'primary',
darkRingOffset,
}: {
id?: string
href?: string
newTab?: boolean
onClick?: MouseEventHandler<HTMLButtonElement | HTMLAnchorElement>
caption: string
icon?: IconName
arrow?: 'left' | 'right'
className?: string
size?: 'xs' | 'sm' | 'base' | 'lg'
type?: 'primary' | 'secondary' | 'tertiary' | 'black'
darkRingOffset?: boolean
}) {
const iconSizes = { xs: 'h-3', sm: 'h-3.5', base: 'h-4', lg: 'h-5' }
className = cn(
'relative overflow-hidden flex items-center focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:focus:ring-offset-zinc-900',
darkRingOffset ? 'focus:ring-offset-zinc-900' : 'focus:ring-offset-zinc-50',
size === 'xs' && 'h-6 px-2 gap-1.5 rounded-md text-xs',
size === 'sm' && 'h-7 px-3 gap-2 rounded-md text-sm',
size === 'base' && 'h-9 px-4 gap-2.5 rounded-lg text-base',
size === 'lg' && 'h-11 px-5 gap-3 rounded-xl text-lg',
type === 'primary' && 'bg-blue-500 text-white hover:bg-blue-600 dark:hover:bg-blue-400',
type === 'black' &&
'bg-black text-white dark:text-black hover:bg-zinc-800 dark:bg-white dark:hover:bg-zinc-100',
type === 'secondary' &&
'bg-zinc-100 text-zinc-800 dark:text-zinc-100 hover:bg-zinc-200 dark:bg-zinc-800 dark:hover:bg-zinc-700',
type === 'tertiary' &&
'text-black dark:text-white dark:hover:text-zinc-200 hover:text-zinc-700',
className
)
// no word wrapping
const spanClassName = cn('whitespace-nowrap')
if (href)
return (
<Link
href={href}
target={newTab ? '_blank' : undefined}
rel={newTab ? 'noopener noreferrer' : undefined}
className={className}
onClick={(e) => {
onClick?.(e)
if (id) track('button', { id })
}}
>
{arrow === 'left' && <ArrowLongLeftIcon className={cn(iconSizes[size])} />}
{icon && <Icon icon={icon} className={cn(iconSizes[size])} />}
<span className={spanClassName}>{caption}</span>
{arrow === 'right' && <ArrowLongRightIcon className={cn(iconSizes[size])} />}
</Link>
)
if (onClick)
return (
<button
onClick={(e) => {
if (id) track('button', { id })
onClick?.(e)
}}
className={className}
>
{arrow === 'left' && <ArrowLongLeftIcon className={cn(iconSizes[size])} />}
{icon && <Icon icon={icon} className={cn(iconSizes[size])} />}
<span className={spanClassName}>{caption}</span>
{arrow === 'right' && <ArrowLongRightIcon className={cn(iconSizes[size])} />}
</button>
)
return null
}

View File

@@ -0,0 +1,13 @@
import { cn } from '@/utils/cn'
export function DiscordIcon({ className }: { className: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 640 512"
className={cn('fill-current', className)}
>
<path d="M524.5 69.8a1.5 1.5 0 0 0 -.8-.7A485.1 485.1 0 0 0 404.1 32a1.8 1.8 0 0 0 -1.9 .9 337.5 337.5 0 0 0 -14.9 30.6 447.8 447.8 0 0 0 -134.4 0 309.5 309.5 0 0 0 -15.1-30.6 1.9 1.9 0 0 0 -1.9-.9A483.7 483.7 0 0 0 116.1 69.1a1.7 1.7 0 0 0 -.8 .7C39.1 183.7 18.2 294.7 28.4 404.4a2 2 0 0 0 .8 1.4A487.7 487.7 0 0 0 176 479.9a1.9 1.9 0 0 0 2.1-.7A348.2 348.2 0 0 0 208.1 430.4a1.9 1.9 0 0 0 -1-2.6 321.2 321.2 0 0 1 -45.9-21.9 1.9 1.9 0 0 1 -.2-3.1c3.1-2.3 6.2-4.7 9.1-7.1a1.8 1.8 0 0 1 1.9-.3c96.2 43.9 200.4 43.9 295.5 0a1.8 1.8 0 0 1 1.9 .2c2.9 2.4 6 4.9 9.1 7.2a1.9 1.9 0 0 1 -.2 3.1 301.4 301.4 0 0 1 -45.9 21.8 1.9 1.9 0 0 0 -1 2.6 391.1 391.1 0 0 0 30 48.8 1.9 1.9 0 0 0 2.1 .7A486 486 0 0 0 610.7 405.7a1.9 1.9 0 0 0 .8-1.4C623.7 277.6 590.9 167.5 524.5 69.8zM222.5 337.6c-29 0-52.8-26.6-52.8-59.2S193.1 219.1 222.5 219.1c29.7 0 53.3 26.8 52.8 59.2C275.3 311 251.9 337.6 222.5 337.6zm195.4 0c-29 0-52.8-26.6-52.8-59.2S388.4 219.1 417.9 219.1c29.7 0 53.3 26.8 52.8 59.2C470.7 311 447.5 337.6 417.9 337.6z" />
</svg>
)
}

View File

@@ -0,0 +1,13 @@
import { cn } from '@/utils/cn'
export function GithubIcon({ className }: { className: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 496 512"
className={cn('fill-current', className)}
>
<path d="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3 .3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5 .3-6.2 2.3zm44.2-1.7c-2.9 .7-4.9 2.6-4.6 4.9 .3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3 .7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3 .3 2.9 2.3 3.9 1.6 1 3.6 .7 4.3-.7 .7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3 .7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3 .7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z" />
</svg>
)
}

View File

@@ -0,0 +1,30 @@
import * as HeroiconsSolid from '@heroicons/react/20/solid'
import * as HeroiconsOutline from '@heroicons/react/24/outline'
import { DiscordIcon } from './discord'
import { GithubIcon } from './github'
import { LinkedinIcon } from './linkedin'
import { TldrawIcon } from './tldraw'
import { TwitterIcon } from './twitter'
const icons = {
check: HeroiconsOutline.CheckIcon,
copy: HeroiconsOutline.ClipboardIcon,
discord: DiscordIcon,
github: GithubIcon,
linkedin: LinkedinIcon,
paperclip: HeroiconsOutline.PaperClipIcon,
play: HeroiconsSolid.PlayIcon,
tldraw: TldrawIcon,
twitter: TwitterIcon,
bolt: HeroiconsOutline.BoltIcon,
cube: HeroiconsOutline.CubeIcon,
wrench: HeroiconsOutline.WrenchScrewdriverIcon,
shield: HeroiconsOutline.ShieldCheckIcon,
}
export type IconName = keyof typeof icons
export function Icon({ icon, className }: { icon: IconName; className: string }) {
const IconComponent = icons[icon]
return <IconComponent className={className} />
}

View File

@@ -0,0 +1,13 @@
import { cn } from '@/utils/cn'
export function LinkedinIcon({ className }: { className: string }) {
return (
<svg
viewBox="0 0 16 16"
xmlns="http://www.w3.org/2000/svg"
className={cn('fill-current', className)}
>
<path d="M0 1.146C0 .513.526 0 1.175 0h13.65C15.474 0 16 .513 16 1.146v13.708c0 .633-.526 1.146-1.175 1.146H1.175C.526 16 0 15.487 0 14.854zm4.943 12.248V6.169H2.542v7.225zm-1.2-8.212c.837 0 1.358-.554 1.358-1.248-.015-.709-.52-1.248-1.342-1.248S2.4 3.226 2.4 3.934c0 .694.521 1.248 1.327 1.248zm4.908 8.212V9.359c0-.216.016-.432.08-.586.173-.431.568-.878 1.232-.878.869 0 1.216.662 1.216 1.634v3.865h2.401V9.25c0-2.22-1.184-3.252-2.764-3.252-1.274 0-1.845.7-2.165 1.193v.025h-.016l.016-.025V6.169h-2.4c.03.678 0 7.225 0 7.225z"></path>
</svg>
)
}

View File

@@ -0,0 +1,16 @@
import { cn } from '@/utils/cn'
export function TldrawIcon({ className }: { className: string }) {
return (
<svg
width="14"
height="14"
viewBox="0 0 15 14"
xmlns="http://www.w3.org/2000/svg"
className={cn('fill-current', className)}
>
<path d="M8.78578 3.4513C9.15358 3.0593 9.33748 2.58208 9.33748 2.01964C9.33748 1.45722 9.15358 0.980001 8.78578 0.587998C8.41806 0.195995 7.97035 0 7.44271 0C6.89906 0 6.44337 0.195995 6.07562 0.587998C5.70786 0.980001 5.52398 1.45722 5.52398 2.01964C5.52398 2.58208 5.70786 3.0593 6.07562 3.4513C6.44337 3.84331 6.89906 4.0393 7.44271 4.0393C7.97035 4.0393 8.41806 3.84331 8.78578 3.4513Z" />
<path d="M6.05162 7.666C5.68387 8.05801 5.5 8.53521 5.5 9.09764C5.5 9.62606 5.66788 10.0691 6.00366 10.427C6.33943 10.7849 6.66722 10.9724 6.987 10.9894C7.11492 10.9894 7.17089 11.0577 7.1549 11.194C7.10693 11.5008 7.00299 11.7991 6.8431 12.0888C6.6832 12.3786 6.48334 12.6171 6.2435 12.8046C5.81179 13.1795 5.73185 13.5034 6.00366 13.7761C6.29147 14.0658 6.65922 14.0744 7.10693 13.8017C7.92238 13.3245 8.54597 12.6342 8.97772 11.7308C9.42541 10.8276 9.58532 9.9158 9.45732 8.99537C9.39338 8.50108 9.16953 8.05801 8.78578 7.666C8.40207 7.25696 7.95436 7.05243 7.44271 7.05243C6.89906 7.05243 6.43538 7.25696 6.05162 7.666Z" />
</svg>
)
}

View File

@@ -0,0 +1,13 @@
import { cn } from '@/utils/cn'
export function TwitterIcon({ className }: { className: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 512 512"
className={cn('fill-current', className)}
>
<path d="M389.2 48h70.6L305.6 224.2 487 464H345L233.7 318.6 106.5 464H35.8L200.7 275.5 26.8 48H172.4L272.9 180.9 389.2 48zM364.4 421.8h39.1L151.1 88h-42L364.4 421.8z" />
</svg>
)
}

View File

@@ -0,0 +1,46 @@
import { cn } from '@/utils/cn'
export function Logo({ className, forceWhite }: { className: string; forceWhite?: boolean }) {
return (
<svg
viewBox="0 0 4081 1000"
xmlns="http://www.w3.org/2000/svg"
className={cn(
'fill-current',
forceWhite ? 'text-white' : 'text-black dark:text-white',
className
)}
>
<path
d="M1303.69 270.972C1303.69 240.816 1328.13 216.369 1358.29 216.369H1428.49C1458.65 216.369 1483.09 240.816 1483.09 270.972V315.954C1483.09 327.011 1492.06 335.975 1503.12 335.975H1541C1561.11 335.975 1577.4 352.272 1577.4 372.376V429.579C1577.4 449.683 1561.11 465.98 1541 465.98H1503.12C1492.06 465.98 1483.09 474.944 1483.09 486.001V674.64C1483.09 681.573 1484.29 687.424 1486.67 692.19C1489.05 696.741 1492.74 700.207 1497.72 702.591C1502.7 704.758 1522.56 705.841 1530.37 705.841C1549.5 705.841 1560.72 712.765 1564.67 731.492L1577.4 791.954C1581.54 811.61 1569.85 830.984 1550.28 835.522C1535.55 838.989 1511.26 841.264 1490.9 842.347C1449.73 844.514 1415.17 840.289 1387.22 829.672C1359.26 818.838 1338.25 801.829 1324.16 778.644C1310.08 755.46 1303.25 726.425 1303.69 691.54V486.001C1303.69 474.944 1294.72 465.98 1283.67 465.98H1266.96C1246.85 465.98 1230.56 449.683 1230.56 429.579V372.376C1230.56 352.272 1246.85 335.975 1266.96 335.975H1283.67C1294.72 335.975 1303.69 327.011 1303.69 315.954V270.972Z"
fill="currentColor"
/>
<path
d="M1974.54 814.96C1944.85 796.326 1921.02 768.049 1903.03 730.131C1885.27 692.213 1876.38 644.436 1876.38 586.8C1876.38 501.729 1898.19 403.692 1977.14 357.015C2038.76 320.844 2119.52 320.677 2175.17 368.834C2185.85 378.077 2214.4 369.799 2214.4 355.676V225.384C2214.4 195.228 2238.84 170.781 2269 170.781H2339.2C2369.36 170.781 2393.81 195.228 2393.81 225.384V781.808C2393.81 811.964 2369.36 836.411 2339.2 836.411H2249.9C2231.01 836.411 2215.7 821.099 2215.7 802.211C2215.7 791.902 2195.36 785.728 2188.33 793.263C2186.18 795.558 2184.03 797.807 2181.9 800.009C2128.34 855.383 2037.25 854.041 1974.54 814.96ZM2218.3 586.8C2218.3 537.286 2200.48 468.495 2138.99 468.495C2077.29 468.495 2060.99 537.551 2060.99 586.8C2060.99 623.113 2066.97 666.411 2096.74 691.13C2165.89 746.195 2218.3 647.886 2218.3 586.8Z"
fill="currentColor"
/>
<path
d="M2513.66 836.412C2483.51 836.412 2459.06 811.965 2459.06 781.809V391.792C2459.06 361.636 2483.51 337.19 2513.66 337.19H2603.2C2619.81 337.19 2633.27 350.651 2633.27 367.256C2633.27 382.997 2638.97 387.954 2649.77 387.954C2668.35 387.954 2672.24 328.627 2758.31 330.689C2813.1 330.689 2820.44 385.406 2820.44 412.863C2820.44 475.557 2800.52 481.496 2737.51 481.496C2681.18 481.496 2638.47 519.992 2638.47 576.4V781.809C2638.47 811.965 2614.02 836.412 2583.87 836.412H2513.66Z"
fill="currentColor"
/>
<path
d="M2970.68 842.913C2916.63 842.913 2859.79 826.876 2828.98 779.21C2815.11 757.759 2808.18 730.458 2808.18 697.306C2808.18 651.927 2823.66 607.404 2861.15 579.651C2898.44 552.053 2946.07 542.134 2991.49 538.699C3020.52 536.448 3107.19 538.281 3107.19 494.497C3107.19 448.71 3041.54 443.568 3011.96 464.271C2970.82 492.82 2973.83 498.397 2910.23 498.397C2834.58 498.397 2857.58 387.482 2932.33 352.791C2965.48 337.19 3006.87 329.39 3056.49 329.39C3114.93 329.39 3177.44 341.047 3225.17 376.842C3245.1 391.576 3260.27 408.91 3270.67 428.844C3281.29 448.562 3286.6 470.013 3286.6 493.197V780.51C3286.6 810.666 3262.15 835.112 3232 835.112H3152.37C3133.88 835.112 3118.89 820.125 3118.89 801.638C3118.89 792.06 3103.8 787.48 3097.15 794.364C3063.59 829.054 3017.92 842.913 2970.68 842.913ZM3097.11 696.981C3108 681.743 3109.4 662.66 3109.14 643.805C3108.97 630.868 3095.3 621.942 3082.84 625.429C3067.81 629.638 3052.39 632.57 3036.99 634.904C3017.05 638.018 2994.44 645.215 2983.36 663.505C2973.07 680.83 2975.13 706.154 2991.81 718.757C3023.43 742.651 3075.6 727.106 3097.11 696.981Z"
fill="currentColor"
/>
<path
d="M1761.82 163C1792.33 163 1817.06 187.732 1817.06 218.24V781.158C1817.06 811.666 1792.33 836.398 1761.82 836.398H1690.8C1660.29 836.398 1635.56 811.666 1635.56 781.158V218.24C1635.56 187.732 1660.29 163 1690.8 163H1761.82Z"
fill="currentColor"
/>
<path
d="M3480.44 836.409C3455.39 836.409 3433.55 819.357 3427.47 795.049L3329.98 405.032C3321.37 370.57 3347.43 337.187 3382.95 337.187H3447.12C3473.38 337.187 3495.92 355.886 3500.78 381.699L3534.84 562.897C3538.14 580.407 3563.06 580.816 3566.93 563.423L3607.7 379.944C3613.25 354.961 3635.4 337.187 3660.99 337.187H3748.03C3773.42 337.187 3795.45 354.684 3801.21 379.411L3843.62 561.638C3847.65 578.962 3872.52 578.345 3875.69 560.842L3908.05 382.062C3912.75 356.08 3935.37 337.187 3961.77 337.187H4025.63C4061.15 337.187 4087.21 370.57 4078.6 405.032L3981.11 795.049C3975.03 819.357 3953.19 836.409 3928.14 836.409H3818.64C3794.09 836.409 3772.56 820.015 3766.02 796.343L3719.13 626.567C3714.69 610.471 3691.83 610.567 3687.52 626.7L3642.32 795.901C3635.94 819.79 3614.3 836.409 3589.57 836.409H3480.44Z"
fill="currentColor"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M0.557373 130C0.557373 58 58.5574 0 130.557 0H870.557C942.557 0 1000.56 58 1000.56 130V870C1000.56 942 942.557 1000 870.557 1000H130.557C58.5574 1000 0.557373 942 0.557373 870V130ZM590.557 300C590.557 350 550.557 390 500.557 390C450.557 390 410.557 350 410.557 300C410.557 250 450.557 210 500.557 210C550.557 210 590.557 250 590.557 300ZM470.557 810C513.557 810 554.557 751 569.557 719C588.557 678 599.557 605 580.557 562C567.557 532 537.557 510 497.557 510C449.557 510 410.557 549 410.557 597C410.557 640 441.557 674 481.557 681C483.557 681 485.557 684 485.557 686C481.557 711 470.557 743 453.557 759C432.557 779 438.557 810 470.557 810Z"
fill="currentColor"
/>
</svg>
)
}

View File

@@ -0,0 +1,22 @@
'use client'
import { cn } from '@/utils/cn'
export function Navigation({
className,
children,
}: {
className?: string
children: React.ReactNode
}) {
return (
<nav
className={cn('w-52 lg:w-60 shrink-0 flex flex-col sticky top-24 overflow-hidden', className)}
style={{
height: 'calc(100vh - 6rem)',
}}
>
{children}
</nav>
)
}

View File

@@ -0,0 +1,20 @@
import { cn } from '@/utils/cn'
export function PageTitle({
children,
className,
}: {
children: React.ReactNode
className?: string
}) {
return (
<h1
className={cn(
'font-black text-black dark:text-white text-3xl sm:text-4xl break-words max-w-full',
className
)}
>
{children}
</h1>
)
}

View File

@@ -0,0 +1,43 @@
'use client'
import { MoonIcon, SunIcon } from '@heroicons/react/20/solid'
import { useTheme } from 'next-themes'
import { useEffect, useState } from 'react'
import { cn } from '@/utils/cn'
const themes = [
{ id: 'light', name: 'Light Mode', icon: SunIcon },
{ id: 'dark', name: 'Dark Mode', icon: MoonIcon },
// { id: 'system', name: 'System Preference', icon: Cog6ToothIcon },
]
export function ThemeSwitch() {
const { theme: initialTheme, setTheme: persistTheme } = useTheme()
const [theme, setTheme] = useState<string>()
const Icon = themes.find((t) => t.id === theme)?.icon ?? SunIcon
useEffect(() => {
setTheme(initialTheme)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const setThemeAndPersist = () => {
const nextTheme = theme === 'light' ? 'dark' : 'light'
persistTheme(nextTheme)
setTheme(nextTheme)
}
return (
<div className="flex flex-col items-end sm:flex-row sm:items-center gap-4 ml-2 md:ml-0">
<div className="hidden md:block h-px sm:h-5 w-full sm:w-px bg-zinc-100 sm:bg-zinc-200 dark:bg-zinc-800 dark:sm:bg-zinc-700" />
<button
onClick={() => setThemeAndPersist()}
className="hover:text-zinc-600 dark:hover:text-zinc-100"
>
<div className={cn('transition-opacity duration-300', theme ? 'opacity-100' : 'opacity-0')}>
<Icon className="size-5" />
</div>
</button>
</div>
)
}

View File

@@ -0,0 +1,69 @@
'use client'
import Link from 'next/link'
import { useEffect, useState } from 'react'
export function TldrawLink(props: React.ComponentProps<'a'>) {
const { href, children } = props
const windowObject = typeof window !== 'undefined' ? window : null
// Start empty so the first client render matches the server HTML — reading
// posthog during render appends `#session_id` to hrefs before hydration and
// causes a hydration mismatch whenever posthog loads first.
const [sessionId, setSessionId] = useState<string | undefined>(undefined)
const [distinctId, setDistinctId] = useState<string | undefined>(undefined)
useEffect(() => {
// Note this href check is intentionally loose, just to avoid
// doing this setInterval for every link. We do the real check below.
if (sessionId || !href?.includes('tldraw.com')) return
if (windowObject?.posthog) {
setSessionId(windowObject.posthog.get_session_id())
setDistinctId(windowObject.posthog.get_distinct_id())
return
}
// XXX: have to wait a bit for posthog to be ready.
// there's unfortunately no event callback for this.
const timeout = setInterval(() => {
setSessionId(windowObject?.posthog?.get_session_id())
setDistinctId(windowObject?.posthog?.get_distinct_id())
clearTimeout(timeout)
}, 1000)
return () => {
clearTimeout(timeout)
}
}, [sessionId, windowObject, href])
const isLocalUrl = href?.startsWith('/') || href?.startsWith('#')
let maybeParsedUrl
try {
maybeParsedUrl = isLocalUrl ? null : href ? new URL(href) : null
} catch {
console.error(`Invalid URL: ${href}`)
}
const derivedTarget =
isLocalUrl ||
maybeParsedUrl?.host.includes('tldraw.com') ||
maybeParsedUrl?.host.includes('localhost')
? undefined
: '_blank'
const target = props.target ?? derivedTarget
let newHref = href
if (
maybeParsedUrl?.host.includes('tldraw.com') &&
sessionId &&
distinctId &&
!href?.includes('#')
) {
newHref = `${href}#session_id=${sessionId}&distinct_id=${distinctId}`
}
return (
<Link {...props} href={newHref ?? ''} target={target}>
{children}
</Link>
)
}

View File

@@ -0,0 +1,3 @@
export function ApiHeading(props: any) {
return <h4 className="!-mb-4 !mt-8 uppercase text-sm font-semibold" {...props} />
}

View File

@@ -0,0 +1,228 @@
import { assetUrl } from '@/utils/asset-url'
import { cn } from '@/utils/cn'
// Content definitions
const KITS = {
multiplayer: {
items: [
{
title: 'Collaborative whiteboards',
desc: 'Real-time drawing and diagramming tools where multiple users can contribute simultaneously.',
},
{
title: 'Educational canvases',
desc: 'Shared learning environments for remote teaching, brainstorming, and visual collaboration.',
},
{
title: 'Design review platforms',
desc: 'Interactive design collaboration spaces with persistent sessions and asset sharing.',
},
{
title: 'Project planning tools',
desc: 'Visual project management interfaces with real-time updates across team members.',
},
{
title: 'Interactive documentation',
desc: 'Living documents that teams can annotate and modify together in real-time.',
},
],
},
agent: {
items: [
{
title: 'Visual AI assistants',
desc: 'Create AI agents that can read, interpret, and modify drawings while providing analysis, insights, and automated annotations.',
},
{
title: 'Diagram generation tools',
desc: 'Build systems that automatically generate flowcharts, system architecture diagrams, or technical illustrations from text descriptions.',
},
{
title: 'Shape recognition systems',
desc: 'Develop applications that identify and classify hand-drawn shapes, converting sketches into structured digital content.',
},
],
},
workflow: {
items: [
{
title: 'AI and agentic workflows',
desc: 'Build visual AI pipelines where agents process data through connected nodes.',
},
{
title: 'Automation platforms',
desc: 'Create no-code automation tools where users visually connect services and data transformations.',
},
{
title: 'Data processing pipelines',
desc: 'Design ETL tools where users drag nodes to transform, filter, and route data between sources and destinations.',
},
{
title: 'Visual programming interfaces',
desc: 'Build domain-specific scripting environments where complex logic becomes intuitive drag-and-drop workflows.',
},
{
title: 'Interactive diagramming tools',
desc: 'Create specialized diagram builders for database design, circuit boards, or business process flows with executable functionality.',
},
],
},
chat: {
items: [
{
title: 'AI chat apps',
desc: 'Add visual context to conversational AI by letting users sketch diagrams, annotate screenshots, and draw concepts to supplement text conversations.',
},
{
title: 'Design feedback apps',
desc: 'Add annotation to your design review tools so teams can mark up mockups, wireframes, and prototypes within chat conversations.',
},
{
title: 'Educational platforms',
desc: 'Add drawing capabilities to tutoring interfaces so students can sketch math problems, create diagrams, and get visual help.',
},
{
title: 'Documentation systems',
desc: 'Add visual markup to help desk or support chat so users can annotate screenshots and create flowcharts.',
},
{
title: 'Content creation tools',
desc: 'Add sketching and annotation to creative collaboration platforms so teams can mark up references and brainstorm visually.',
},
],
},
branching: {
items: [
{
title: 'Interactive chatbots',
desc: 'Create conversational AI interfaces with multiple dialogue paths where users can explore different response branches and see how conversations evolve in different directions.',
},
{
title: 'Conversation design tools',
desc: 'Build visual prototyping environments for UX designers to map out chatbot flows, customer service scripts, or user interaction patterns before implementation.',
},
{
title: 'Interactive storytelling',
desc: 'Develop choose-your-own-adventure applications where narrative branches visually connect, allowing readers to see story paths and backtrack through different choices.',
},
{
title: 'Training simulations',
desc: 'Create educational tools with branching scenarios for customer service training, medical diagnosis practice, or sales conversation rehearsal with AI-powered responses.',
},
{
title: 'AI agent workflows',
desc: 'Design complex AI assistant interfaces where conversation history and context branch based on user choices, enabling sophisticated multi-turn dialogue management.',
},
],
},
'image-pipeline': {
items: [
{
title: 'AI image generation tools',
desc: 'Build visual pipeline builders where users chain prompts, models, and processing steps to create and iterate on AI-generated images.',
},
{
title: 'Image processing workflows',
desc: 'Create node-based editors for multi-step image manipulation combining upscaling, style transfer, blending, and adjustments.',
},
{
title: 'Creative asset pipelines',
desc: 'Design visual tools for generating and refining creative assets through connected processing nodes with real-time preview.',
},
{
title: 'Batch image generation',
desc: 'Build interfaces that iterate over prompt variations, generating multiple images from parameterized pipelines.',
},
{
title: 'Visual prompt engineering',
desc: 'Create interactive environments for exploring how different models, parameters, and prompt combinations affect image outputs.',
},
],
},
shader: {
items: [
{
title: 'Immersive creative tools',
desc: 'Layer animated, touch-responsive backgrounds behind your drawing UI for design or storytelling apps.',
},
{
title: 'Data-rich dashboards',
desc: 'Highlight state changes or live metrics using GPU-driven gradients and particle systems that react to editor data.',
},
{
title: 'Games and interactive experiences',
desc: 'Combine tldraw shapes with shader-based effects to build puzzle overlays, interactive maps, or ambient scenes.',
},
{
title: 'Live events and streaming overlays',
desc: 'Drive real-time graphics for broadcast overlays or virtual stages that react to presenter actions.',
},
{
title: 'Educational demos',
desc: 'Teach graphics concepts with interactive examples that expose shader parameters through the config panel.',
},
],
},
} as const
export type StarterKitType = keyof typeof KITS
export function StarterKitBento({
type,
href,
img,
}: {
type: StarterKitType
href: string
img?: { src: string; alt?: string }
}) {
const content = KITS[type]
if (!content) return null
return (
<div className="my-8 rounded-xl border border-zinc-200 bg-zinc-100 p-2 shadow-sm not-prose">
{img && (
<a
href={href}
className="block relative mb-2 overflow-hidden rounded-lg border border-zinc-200 bg-zinc-50"
>
<img
src={assetUrl(img.src)}
alt={img.alt || type}
className="!my-0 h-auto w-full object-cover"
/>
</a>
)}
<div className="grid grid-cols-1 gap-2 md:grid-cols-6">
{content.items.map((item, i) => {
const span = i < 3 ? 2 : 3
return <BentoItem key={item.title} title={item.title} span={span} desc={item.desc} />
})}
</div>
</div>
)
}
function BentoItem({ title, desc, span }: { title: string; desc: string; span: number }) {
const colSpan =
{
2: 'md:col-span-2',
3: 'md:col-span-3',
}[span] || 'md:col-span-2'
return (
<div
className={cn(
'flex flex-col justify-between rounded-lg border border-zinc-200 bg-white p-5 shadow-sm transition-all hover:shadow-md',
colSpan
)}
>
<div>
<h3 className="mb-2 text-lg font-bold leading-tight text-zinc-900">{title}</h3>
<p className="text-sm leading-relaxed text-zinc-600">{desc}</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,5 @@
import { Callout } from './callout'
export function Blockquote(props: any) {
return <Callout>{props.children}</Callout>
}

View File

@@ -0,0 +1,44 @@
import {
ExclamationCircleIcon,
ExclamationTriangleIcon,
InformationCircleIcon,
} from '@heroicons/react/20/solid'
import { cn } from '@/utils/cn'
export function Callout({
type = 'info',
children,
}: {
type?: 'info' | 'warning' | 'critical' | 'quote'
children: React.ReactNode
}) {
return (
<div
className={cn(
'py-1 -mx-5 md:mx-0',
type === 'info' && 'md:px-1 md:rounded-2xl bg-blue-50 dark:bg-blue-950/75',
type === 'warning' && 'md:px-1 md:rounded-2xl bg-amber-50 dark:bg-amber-900/50',
type === 'critical' && 'md:px-1 md:rounded-2xl bg-rose-50 dark:bg-rose-950/50'
)}
>
<div
className={cn(
'flex flex-col sm:flex-row gap-4 w-full p-5 ',
type === 'quote' && 'border-right border-zinc-400 dark:border-zinc-700 border-l-4',
type === 'info' && 'md:rounded-xl shadow dark:bg-blue-900/25',
type === 'warning' && 'md:rounded-xl shadow dark:bg-amber-900/25',
type === 'critical' && 'md:rounded-xl shadow dark:bg-rose-900/25'
)}
>
{type === 'info' && <InformationCircleIcon className="size-5 shrink-0 text-blue-500" />}
{type === 'warning' && (
<ExclamationTriangleIcon className="size-5 shrink-0 text-amber-500 dark:text-amber-400" />
)}
{type === 'critical' && (
<ExclamationCircleIcon className="size-5 shrink-0 text-rose-500 dark:text-rose-400" />
)}
<div className="-mt-0.5 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">{children}</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,11 @@
import type React from 'react'
import { Icon } from '@/components/common/icon'
export function CheckItem({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-start gap-3 text-zinc-600 dark:text-zinc-400">
<Icon icon="check" className="mt-1 h-5 w-5 shrink-0 text-zinc-900 dark:text-zinc-100" />
<span>{children}</span>
</div>
)
}

View File

@@ -0,0 +1,82 @@
import { Tab, TabGroup, TabList, TabPanel, TabPanels } from '@headlessui/react'
import { createJavaScriptRegexEngine } from '@shikijs/engine-javascript'
import { createHighlighterCoreSync, hastToHtml } from 'shiki/core'
import css from 'shiki/dist/langs/css.mjs'
import tsx from 'shiki/dist/langs/tsx.mjs'
import ts from 'shiki/dist/langs/typescript.mjs'
import theme from 'shiki/dist/themes/github-dark.mjs'
import { cn } from '@/utils/cn'
import { CopyButton } from './copy-button'
// The normal shiki import is async, which we can't use here easily because of SSR complications.
// So here we create a synchronous version of the highlighter.
const shiki = createHighlighterCoreSync({
themes: [theme],
langs: [ts, tsx, css],
engine: createJavaScriptRegexEngine(),
})
export function CodeFiles({
files,
hideTabs,
className,
}: {
files: { name: string; content: any }[]
hideCopyButton?: boolean
hideTabs?: boolean
className?: string
}) {
return (
<TabGroup
className={cn(
'max-h-[550px] group relative not-prose bg-zinc-100 dark:bg-zinc-800 py-1 md:rounded-2xl -mx-5 md:-mx-1 md:px-1 my-6 flex flex-col',
'[td_&]:m-0 [td_&]:mb-2 [td_&]:p-0 [td_&]:bg-transparent [td_&]:rounded-none',
className
)}
>
<TabList
className={cn(
'bg-zinc-900 shrink-0 text-sm text-zinc-400 shadow md:rounded-t-xl border-b border-zinc-700/50 px-5 md:px-4 gap-4 flex',
hideTabs && 'hidden'
)}
>
{files.map(({ name }, index) => (
<Tab
key={index}
className="h-10 data-[selected]:text-white border-b border-transparent data-[selected]:border-white -mb-px focus:outline-none"
>
{name}
</Tab>
))}
</TabList>
<TabPanels
className={cn(
'bg-zinc-900 grow text-sm text-white shadow md:rounded-b-xl overflow-x-auto px-5 md:px-4 py-0',
hideTabs && 'md:rounded-t-xl'
)}
>
{files.map(({ content, name }, index) => {
// the typescript grammar has no JSX, so .tsx/.jsx need the tsx grammar
const lang = name.endsWith('.css')
? 'css'
: name.endsWith('.tsx') || name.endsWith('.jsx')
? 'tsx'
: 'ts'
const ast = shiki.codeToHast(content, { lang, theme })
const codeElem = (ast.children[0] as any).children[0]
return (
<TabPanel key={index}>
<pre
className="overflow-y-auto p-4"
dangerouslySetInnerHTML={{
__html: hastToHtml(codeElem),
}}
></pre>
<CopyButton copy={content} />
</TabPanel>
)
})}
</TabPanels>
</TabGroup>
)
}

View File

@@ -0,0 +1,180 @@
'use client'
import { assert, getOwnProperty } from '@tldraw/utils'
import React, {
Fragment,
ReactElement,
ReactNode,
createContext,
isValidElement,
useContext,
useMemo,
} from 'react'
import { cn } from '@/utils/cn'
import { TldrawLink } from '../common/tldraw-link'
const CodeLinksContext = createContext<Record<string, string>>({})
export function CodeLinks({
children,
links,
}: {
children: React.ReactNode
links: Record<string, string>
}) {
return <CodeLinksContext.Provider value={links}>{children}</CodeLinksContext.Provider>
}
const FocusLinesContext = createContext<null | number[]>(null)
export function FocusLines({ children, lines }: { children: ReactNode; lines: number[] }) {
return <FocusLinesContext.Provider value={lines}>{children}</FocusLinesContext.Provider>
}
const blurredLineClassName = '[&_*]:!opacity-60 [&_*]:!text-white'
function CodeLink({ children, href }: { children: ReactNode; href: string }) {
return (
<TldrawLink
href={href}
className="group-[.not-prose]:underline group-[.not-prose]:hover:no-underline decoration-[#667e8b]"
>
{children}
</TldrawLink>
)
}
// does this token look like a JS identifier?
function isIdentifier(token: string): boolean {
return /^[_$a-zA-Z\xA0-\uFFFF][_$a-zA-Z0-9\xA0-\uFFFF]*$/g.test(token)
}
// split the code into an array of identifiers, and the bits in between them
function tokenize(code: string): string[] {
const identifierRegex = /[_$a-zA-Z\xA0-\uFFFF][_$a-zA-Z0-9\xA0-\uFFFF]*/g
let currentIdx = 0
const tokens = []
for (const identifierMatch of code.matchAll(identifierRegex)) {
const [identifier] = identifierMatch
const idx = identifierMatch.index
if (idx > currentIdx) {
tokens.push(code.slice(currentIdx, idx))
}
tokens.push(identifier)
currentIdx = idx + identifier.length
}
if (currentIdx < code.length) {
tokens.push(code.slice(currentIdx))
}
return tokens
}
// this is for inline code blocks (e.g. `code`)
// these don't get highlighted, so the children is just a string
function InlineCodeElem({ children, ...props }: React.ComponentProps<'code'>) {
const code = children as string
assert(typeof code === 'string', 'InlineCodeElem children must be a string')
const codeLinks = useContext(CodeLinksContext)
return useMemo(
() => (
<code {...props}>
{codeLinks
? tokenize(code).map((token, i) => {
if (isIdentifier(token)) {
const link = getOwnProperty(codeLinks, token)
if (link) {
return (
<CodeLink key={i} href={link}>
{token}
</CodeLink>
)
}
}
return <Fragment key={i}>{token}</Fragment>
})
: code}
</code>
),
[code, codeLinks, props]
)
}
// This is code that has been highlighted by shikijs
// Children is an array of spans, each representing a line and containing an array of spans which are tokens
// There is sometimes whitespace between the lines
function HighlightedCodeElem({ children, ...props }: React.ComponentProps<'code'>) {
const codeLinks = useContext(CodeLinksContext)
const focusLines = useContext(FocusLinesContext)
let lineNumber = 0
return (
<code {...props}>
{React.Children.map(children, (line) => {
// ignore whitespace
if (!line || !isValidElement(line)) return line
assert(line.type === 'span', 'Invalid code child (not a span)')
const lineElement = line as ReactElement<{ className?: string; children?: ReactNode }>
assert(lineElement.props.className === 'line', 'Invalid code child (not a line)')
// apply line lowlights
lineNumber++
let updatedLine = lineElement
if (focusLines && !focusLines?.includes(lineNumber)) {
updatedLine = React.cloneElement(lineElement, {
...lineElement.props,
className: cn(lineElement.props?.className, blurredLineClassName),
})
}
// inject links where needed
if (codeLinks) {
updatedLine = React.cloneElement(updatedLine, {
...updatedLine.props,
children: React.Children.map(updatedLine.props.children, (elem) => {
if (!isValidElement(elem)) return elem
if (elem.type !== 'span') return elem
const spanElement = elem as ReactElement<{ children?: ReactNode }>
const token = spanElement.props.children
if (typeof token !== 'string') return elem
const tokens = tokenize(token)
return React.cloneElement(spanElement, {
...spanElement.props,
children: tokens.map((token, i) => {
if (!isIdentifier(token)) return token
const link = getOwnProperty(codeLinks, token)
if (!link) return token
return (
<CodeLink href={link} key={i}>
{token}
</CodeLink>
)
}),
})
}),
})
}
return updatedLine
})}
</code>
)
}
export function Code(props: React.ComponentProps<'code'>) {
if (typeof props.children === 'string') {
return <InlineCodeElem {...props} />
}
if (Array.isArray(props.children) && props.children[0].props?.className === 'line') {
return <HighlightedCodeElem {...props} />
}
if (
isValidElement<any>(props.children) &&
props.children.type === 'span' &&
(props.children.props as { className?: string })?.className === 'line'
) {
// eslint-disable-next-line react/no-children-prop
return <HighlightedCodeElem {...props} children={[props.children]} />
}
console.warn('Invalid code children', props.children)
return <code {...props} />
}

View File

@@ -0,0 +1,39 @@
'use client'
import { RefObject, useState } from 'react'
import { Button } from '../common/button'
export function CopyButton({
copy,
className,
}: {
copy: string | RefObject<HTMLElement | null>
className?: string
}) {
const [copied, setCopied] = useState<boolean>(false)
const handleClick = () => {
const code: string = typeof copy === 'string' ? copy : (copy.current?.innerText ?? '')
navigator.clipboard.writeText(code)
if (window.tlanalytics?.trackCopyCode) {
window.tlanalytics.trackCopyCode({
page_category: 'docs',
text_snippet: code,
})
}
setCopied(true)
setTimeout(() => setCopied(false), 1500)
}
return (
<Button
onClick={handleClick}
caption={'Copy'}
icon={copied ? 'check' : 'copy'}
size="xs"
className={`absolute transition-all duration-100 translate-y-4 opacity-0 -top-2 right-4 group-hover:opacity-100 group-hover:translate-y-0 ${className}`}
/>
)
}

View File

@@ -0,0 +1,66 @@
'use client'
import { useRef, useState } from 'react'
import { cn } from '@/utils/cn'
import { Button } from '../common/button'
export function Embed(props: {
className?: string
src: string
caption?: string
tryButton?: string
}) {
const [isExampleFocused, setIsExampleFocused] = useState(false)
const iframeRef = useRef<HTMLIFrameElement>(null)
function handleClick() {
setIsExampleFocused(true)
iframeRef.current?.focus()
}
function handleBlur() {
setIsExampleFocused(false)
}
return (
<div>
<div className="bg-zinc-100 dark:bg-zinc-700 py-1 md:rounded-2xl -mx-5 md:-mx-1 md:px-1">
<div
className={cn(
props.className,
'md:rounded-xl overflow-hidden shadow bg-white dark:bg-zinc-950 relative'
)}
>
<iframe
ref={iframeRef}
className="iframe"
src={props.src}
width="100%"
height={550}
allow="autoplay; clipboard-read; clipboard-write"
onBlur={handleBlur}
/>
{!isExampleFocused && (
<div
className="absolute inset-0 bg-[#FBFCFE]/50 dark:bg-[#101011]/50 flex items-center justify-center cursor-pointer"
onClick={handleClick}
>
<Button
onClick={handleClick}
caption={props.tryButton ?? 'Try example'}
icon="play"
className="shadow"
/>
</div>
)}
</div>
</div>
{props.caption && <span className="block text-xs text-zinc-500 mt-3">{props.caption}</span>}
</div>
)
}
export function StarterKitEmbed(props: { id: string }) {
return (
<Embed
src={`https://${props.id}.templates.tldraw.dev/?utm_source=docs-embed`}
tryButton="Try starter kit"
/>
)
}

View File

@@ -0,0 +1,23 @@
import { Embed } from '@/components/content/embed'
import { Article } from '@/types/content-types'
import { CodeFiles } from './code-files'
export function Example({ article }: { article: Article }) {
const server = 'https://examples.tldraw.com'
// both are null when the README has no `component:` frontmatter
const additionalFiles = article.componentCodeFiles ? JSON.parse(article.componentCodeFiles) : {}
const files = [
...(article.componentCode ? [{ name: 'App.tsx', content: article.componentCode }] : []),
...Object.keys(additionalFiles).map((key) => ({ name: key, content: additionalFiles[key] })),
]
// article.id is in format "sectionId/categoryId/articleId", we need just the articleId
const slug = article.id.split('/').pop()
return (
<div className="w-full mt-8">
<Embed src={`${server}/${slug}/full?utm_source=docs-embed`} />
<CodeFiles files={files} />
</div>
)
}

View File

@@ -0,0 +1,23 @@
import type React from 'react'
import { Icon, type IconName } from '@/components/common/icon'
export function Feature({
icon,
title,
children,
}: {
icon: IconName
title: string
children: React.ReactNode
}) {
return (
<div className="flex flex-col gap-1 not-prose">
<div className="flex items-center gap-2">
<Icon icon={icon} className="h-5 w-5 text-zinc-900 dark:text-zinc-100" />
<h3 className="font-bold text-zinc-900 dark:text-zinc-100">{title}</h3>
</div>
{/* MDX renders block children as their own `<p>`s, so this wrapper must not be a `<p>` — nested `<p>`s break hydration. */}
<div className="leading-relaxed text-zinc-600 dark:text-zinc-400">{children}</div>
</div>
)
}

View File

@@ -0,0 +1,27 @@
import { assetUrl } from '@/utils/asset-url'
import { TldrawLink } from '../common/tldraw-link'
export function Image(props: any) {
const src = typeof props.src === 'string' ? assetUrl(props.src) : props.src
const href = props.href ?? src
return (
<span className="block">
<TldrawLink
href={href}
className="block bg-zinc-100 dark:bg-zinc-800 py-1 sm:rounded-2xl -mx-5 sm:mx-0 sm:px-1"
>
<img alt={props.title} {...props} src={src} className="w-full sm:rounded-xl !my-0 shadow" />
</TldrawLink>
{props.caption && (
<span className="block text-xs text-zinc-500 mt-3 text-center text-balance">
{props.caption}
</span>
)}
{!props.caption && props.title && (
<span className="block text-xs text-zinc-500 mt-3 text-center text-balance">
{props.title}
</span>
)}
</span>
)
}

View File

@@ -0,0 +1,117 @@
import shikiRehype from '@shikijs/rehype'
import { MDXRemote } from 'next-mdx-remote-client/rsc'
import rehypeSlug from 'rehype-slug-custom-id'
import remarkGfm from 'remark-gfm'
import { ApiHeading } from '@/components/content/api-heading'
import { StarterKitBento } from '@/components/content/bento'
import { Blockquote } from '@/components/content/blockquote'
import { Callout } from '@/components/content/callout'
import { CheckItem } from '@/components/content/check-item'
import { Code, CodeLinks, FocusLines } from '@/components/content/code'
import { Embed, StarterKitEmbed } from '@/components/content/embed'
import { Feature } from '@/components/content/feature'
import { Image } from '@/components/content/image'
import { MaskedArticleImage } from '@/components/content/masked-article-image'
import { ParametersTable } from '@/components/content/parameters-table'
import { ParametersTableDescription } from '@/components/content/parameters-table-description'
import { ParametersTableName } from '@/components/content/parameters-table-name'
import { ParametersTableRow } from '@/components/content/parameters-table-row'
import { Pre } from '@/components/content/pre'
import { SideBySideImages } from '@/components/content/side-by-side-images'
import { ApiMemberTitle } from '@/components/content/title-with-source-link'
import { Video } from '@/components/content/video'
import { YouTube } from '@/components/content/youtube'
import { cn } from '@/utils/cn'
import { TldrawLink } from '../common/tldraw-link'
export function Content({
mdx,
type,
className,
}: {
mdx: string
type?: string
className?: string
}) {
return (
<section
className={cn(
type === 'reference' && 'prose-hr:hidden prose-h2:!mt-8 md:prose-h2:!mt-24',
'prose dark:prose-invert prose-sm prose-zinc text-zinc-800 dark:text-zinc-200 sm:prose-base w-full max-w-full',
'prose-code:before:content-none prose-code:after:content-none prose-code:bg-zinc-100 dark:prose-code:!bg-zinc-800 prose-code:px-1 prose-code:py-0.5 prose-code:rounded prose-code:font-normal',
'prose-a:no-underline prose-a:text-blue-500 hover:prose-a:text-blue-600 dark:hover:prose-a:text-blue-400 prose-a:font-normal',
'prose-blockquote:text-zinc-800 dark:prose-blockquote:text-zinc-200 prose-blockquote:font-normal prose-blockquote:border-none prose-blockquote:px-4 prose-blockquote:leading-normal prose-blockquote:bg-zinc-50 dark:prose-blockquote:bg-zinc-900 prose-blockquote:py-3 prose-blockquote:rounded-xl',
'prose-table:bg-zinc-50 dark:prose-table:bg-zinc-900 prose-table:rounded-xl prose-table:text-sm',
'prose-th:text-left prose-th:font-semibold prose-th:uppercase prose-th:text-xs prose-th:border-l prose-th:border-white dark:prose-th:border-zinc-950 prose-th:py-3 prose-th:px-4',
'prose-tr:border-t prose-tr:border-white dark:prose-tr:border-zinc-950',
'prose-td:border-l first:prose-td:border-l-0 prose-td:border-white dark:prose-td:border-zinc-950 prose-td:py-3 prose-td:px-4',
'prose-hr:border-zinc-100 dark:prose-hr:border-zinc-800',
'prose-h1:scroll-mt-20 prose-h2:scroll-mt-20 prose-h3:scroll-mt-20 prose-h4:scroll-mt-20 prose-h5:scroll-mt-20',
className
)}
>
<MDXRemote
source={mdx}
components={{
a: TldrawLink,
Embed,
StarterKitEmbed,
pre: Pre,
code: Code,
// Markdown images and `<Image />` — both resolve `/…` static URLs via `assetUrl` in `image.tsx`.
Image,
img: Image,
MaskedArticleImage,
ApiHeading,
StarterKitBento,
Feature,
Callout,
FocusLines,
CodeLinks,
ParametersTable,
ParametersTableDescription,
ParametersTableName,
ParametersTableRow,
ApiMemberTitle,
CheckItem,
blockquote: Blockquote,
Video,
YouTube,
TwoImages: SideBySideImages,
}}
options={{
mdxOptions: {
remarkPlugins: [remarkGfm],
rehypePlugins: [
[
rehypeSlug,
{
enableCustomId: true,
maintainCase: true,
removeAccents: true,
},
],
[
shikiRehype as any,
{
themes: {
dark: 'github-dark-default',
light: 'github-light-default',
},
defaultColor: false,
// Without `langs` the plugin loads all ~300 bundled grammars (seconds and
// >100MB per build worker / dev server). Anything not listed is still
// loaded on demand thanks to `lazy`.
langs: ['ts', 'tsx', 'bash', 'css', 'toml', 'html'],
lazy: true,
},
],
],
format: 'mdx',
},
parseFrontmatter: true,
}}
/>
</section>
)
}

View File

@@ -0,0 +1,17 @@
import { assetUrl } from '@/utils/asset-url'
/**
* Hero image with CSS mask — MDX cannot run `assetUrl` inside inline `style` strings, so paths in
* `mask: url(/images/…)` would stay root-relative and break when docs use `ASSET_PREFIX`.
*/
export function MaskedArticleImage(props: { gifSrc: string; maskSrc: string; alt: string }) {
const gif = assetUrl(props.gifSrc)
const mask = assetUrl(props.maskSrc)
const maskCss = `url(${mask}) luminance center 100% / 100% no-repeat`
return (
<div className="article__image" style={{ border: 'none' }}>
<img alt={props.alt} src={gif} style={{ mask: maskCss, WebkitMask: maskCss }} />
</div>
)
}

View File

@@ -0,0 +1,3 @@
export function ParametersTableDescription({ children }: { children: React.ReactNode }) {
return <td>{children}</td>
}

View File

@@ -0,0 +1,3 @@
export function ParametersTableName({ children }: { children: React.ReactNode }) {
return <td>{children}</td>
}

View File

@@ -0,0 +1,3 @@
export function ParametersTableRow({ children }: { children: React.ReactNode }) {
return <tr>{children}</tr>
}

View File

@@ -0,0 +1,15 @@
export function ParametersTable({ children }: { children: React.ReactNode }) {
return (
<div className="prose-p:my-0 prose-pre:hidden prose-code:!bg-zinc-200 dark:prose-code:!bg-zinc-800">
<table>
<thead>
<tr>
<th>Name</th>
<th>Description</th>
</tr>
</thead>
<tbody>{children}</tbody>
</table>
</div>
)
}

View File

@@ -0,0 +1,32 @@
'use client'
import { DetailedHTMLProps, HTMLAttributes, useRef } from 'react'
import { cn } from '@/utils/cn'
import { CopyButton } from './copy-button'
export function Pre(props: DetailedHTMLProps<HTMLAttributes<HTMLPreElement>, HTMLPreElement>) {
const container = useRef<HTMLPreElement>(null)
return (
<div
className={cn(
'group relative not-prose bg-zinc-100 dark:bg-zinc-800 py-1 md:rounded-2xl -mx-5 md:-mx-1 md:px-1 my-6',
'[td_&]:m-0 [td_&]:mb-2 [td_&]:p-0 [td_&]:bg-transparent [td_&]:rounded-none dark:[td_&]:rounded-lg'
)}
>
<pre
ref={container}
className={cn(
'bg-zinc-900 text-sm text-white shadow md:rounded-xl overflow-x-auto px-5 md:px-4 py-4 font-medium',
'[td_&]:bg-zinc-200 dark:[td_&]:bg-zinc-800 [td_&]:shadow-none [td_&]:rounded-lg [td_&]:p-0 [td_&]:px-1.5',
// this rule enables inverted dark/light mode inside of table cells
'[&_span]:text-[--shiki-dark]',
'[td_&_span]:!text-[--shiki-light]',
'dark:[td_&_span]:!text-[--shiki-dark]'
)}
>
{props.children}
</pre>
<CopyButton copy={container} />
</div>
)
}

View File

@@ -0,0 +1,10 @@
import { Image } from './image'
export function SideBySideImages({ left, right }: { left: any; right: any }) {
return (
<span className="flex gap-8 sm:gap-2 flex-col sm:flex-row">
<Image {...left} />
<Image {...right} />
</span>
)
}

View File

@@ -0,0 +1,54 @@
import { ArrowRightIcon } from '@heroicons/react/16/solid'
import { CodeBracketIcon } from '@heroicons/react/20/solid'
import Link from 'next/link'
import { cn } from '@/utils/cn'
function Tag({ children }: { children: React.ReactNode; tag: string }) {
return <span className="border border-zinc-800 rounded-full px-1.5 text-xs">{children}</span>
}
export function ApiMemberTitle({
children,
source,
tags,
inherited,
large,
}: {
children: React.ReactNode
source?: string | null
large?: boolean
tags?: string[]
inherited?: { name: string; link: string }
}) {
return (
<>
<div className="flex items-end gap-2 prose-headings:mb-0">
{children}
<div className={cn('flex gap-2 items-center pb-1.5', !large && 'ml-auto')}>
{tags?.map((tag) => (
<Tag key={tag} tag={tag}>
{tag}
</Tag>
))}
{source && (
<a href={source} target="_blank" rel="noopener noreferrer" title="Source code">
<CodeBracketIcon className="size-20" />
</a>
)}
</div>
</div>
{inherited && (
<div className="not-prose text-xs mb-6">
from{' '}
<Link
href={inherited.link}
className="text-blue-500 hover:text-blue-600 dark:hover:text-blue-400 inline-flex items-center"
>
<span>{inherited.name}</span>
<ArrowRightIcon className="h-3 -rotate-45" />
</Link>
</div>
)}
</>
)
}

View File

@@ -0,0 +1,40 @@
import { TldrawLink } from '../common/tldraw-link'
export function Video({
src,
href,
thumbnail,
caption,
lazy = false,
autoplay = false,
}: {
src: string
thumbnail?: string
caption?: string
autoplay?: boolean
href?: string
lazy?: boolean
}) {
return (
<span className="block">
<TldrawLink
href={href ?? src}
target="_blank"
rel="noreferrer"
className="block bg-zinc-100 dark:bg-zinc-800 py-1 sm:rounded-2xl sm:px-1"
>
<video
className="w-full sm:rounded-xl !my-0 shadow"
src={src}
poster={thumbnail}
controls={false}
preload={lazy ? 'metadata' : 'auto'}
autoPlay={autoplay}
muted={autoplay}
loop={autoplay}
/>
</TldrawLink>
{caption && <span className="block text-xs text-zinc-500 mt-3 text-center">{caption}</span>}
</span>
)
}

View File

@@ -0,0 +1,26 @@
export function YouTube({ src, caption }: { src: string; caption?: string }) {
// Extracts youtube ID from any format, eg:
// https://youtu.be/tHQW_K2pEgI
// https://www.youtube.com/watch?v=tHQW_K2pEgI
// https://youtu.be/tHQW_K2pEgI?si=sKtDDhygzBezfean
// https://www.youtube.com/watch?v=tHQW_K2pEgI&si=sKtDDhygzBezfean
const id = ((src.split('/').pop() ?? src).split('v=').pop() ?? src)
.split('&si=')[0]
.split('?si=')[0]
return (
<span className="block mb-5">
<span className="block bg-zinc-100 dark:bg-zinc-800 py-1 md:rounded-2xl -mx-5 md:mx-0 md:px-1">
<iframe
src={`https://www.youtube.com/embed/${id}?rel=0`}
title={caption}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
referrerPolicy="strict-origin-when-cross-origin"
allowFullScreen
className="w-full aspect-video md:rounded-xl overflow-hidden !my-0 shadow"
></iframe>
</span>
{caption && <span className="block text-xs text-zinc-500 mt-3 text-center">{caption}</span>}
</span>
)
}

View File

@@ -0,0 +1,40 @@
'use client'
import { useState } from 'react'
import { Button } from '../common/button'
export function CopyMarkdownButton({
markdown,
className,
}: {
markdown: string
className?: string
}) {
const [copied, setCopied] = useState(false)
const handleClick = () => {
navigator.clipboard.writeText(markdown)
if (window.tlanalytics?.trackCopyCode) {
window.tlanalytics.trackCopyCode({
page_category: 'docs',
text_snippet: markdown.slice(0, 500),
})
}
setCopied(true)
setTimeout(() => setCopied(false), 1500)
}
return (
<Button
id="copy-markdown"
onClick={handleClick}
caption={copied ? 'Copy markdown' : 'Copy markdown'}
icon={copied ? 'check' : 'copy'}
size="sm"
type="secondary"
className={className}
/>
)
}

View File

@@ -0,0 +1,31 @@
import { format } from 'date-fns'
import Link from 'next/link'
import { BackToTopButton } from '@/components/common/back-to-top-button'
import { GithubIcon } from '@/components/common/icon/github'
import { Article } from '@/types/content-types'
const githubContentRoot = 'https://github.com/tldraw/tldraw/blob/main/apps/docs/content/'
export function DocsArticleInfo({ article }: { article: Article }) {
if (article.sectionId === 'reference') {
// Reference pages are source code and already link to it via 'See source code'. Keyed on
// section, not `authorId === 'api'`: that's also the default for articles with no `author:`.
return null
}
return (
<div className="shrink-0 text-xs flex flex-col gap-2 mb-12">
{article.date && <p>Last edited on {format(new Date(article.date), 'MMM dd, yyyy')}</p>}
{article.sourceUrl && (
<Link
href={`${githubContentRoot}${article.sourceUrl}`}
className="flex items-center gap-1.5 text-blue-500 hover:text-blue-600 dark:hover:text-blue-400"
>
<GithubIcon className="h-3.5" />
<span>Edit this page on GitHub</span>
</Link>
)}
<BackToTopButton />
</div>
)
}

View File

@@ -0,0 +1,58 @@
'use client'
import { RocketLaunchIcon } from '@heroicons/react/16/solid'
import { AcademicCapIcon, CommandLineIcon, CubeIcon, PlayIcon } from '@heroicons/react/20/solid'
import { usePathname } from 'next/navigation'
import { NavigationLink } from '@/components/navigation/link'
const categoryLinks = [
{
caption: 'Quick start',
icon: RocketLaunchIcon,
href: '/quick-start',
// usePathname() is /getting-started/* on the server (prerendered path) and /quick-start
// etc. in the browser (rewrite); match both or hydration mismatches.
active: (pathname: string) =>
['/quick-start', '/installation', '/releases', '/getting-started'].some((e) =>
pathname.startsWith(e)
),
},
{
caption: 'Documentation',
icon: AcademicCapIcon,
href: '/docs/editor',
active: (pathname: string) =>
['/docs', '/sdk-features', '/community'].some((e) => pathname.startsWith(e)),
},
{
caption: 'Reference',
icon: CommandLineIcon,
href: '/reference/editor/Editor',
active: (pathname: string) => pathname.startsWith('/reference'),
},
{
caption: 'Starter kits',
icon: CubeIcon,
href: '/starter-kits/overview',
active: (pathname: string) => ['/starter-kits'].some((e) => pathname.startsWith(e)),
},
{
caption: 'Examples',
icon: PlayIcon,
href: '/examples/basic',
active: (pathname: string) => pathname.startsWith('/examples'),
},
]
export function DocsCategoryMenu() {
const pathname = usePathname()
return (
<ul className="flex flex-col gap-3 shrink-0 pb-4">
{categoryLinks.map((item, index) => (
<li key={index}>
<NavigationLink {...item} active={item.active(pathname)} />
</li>
))}
</ul>
)
}

View File

@@ -0,0 +1,142 @@
'use client'
import { Field, Label, Textarea } from '@headlessui/react'
import { ArrowLongRightIcon, CheckCircleIcon, HandThumbDownIcon } from '@heroicons/react/20/solid'
import { HandThumbUpIcon } from '@heroicons/react/24/solid'
import { usePathname } from 'next/navigation'
import { FormEventHandler, useState } from 'react'
import { track } from '@/app/analytics'
import { cn } from '@/utils/cn'
import { useLocalStorageState } from '@/utils/storage'
const DEBUGGING = false
async function submitFeedback(
_sessionId: string,
_pathname: string,
_feedback: 1 | -1 | 0,
_note: string
) {
const feedback: { value: number; message?: string } = { value: _feedback }
if (_note !== '') feedback.message = _note
track('Feedback', feedback)
return
}
async function submitThumbsFeedback(_sessionId: string, _pathname: string, _feedback: 1 | -1 | 0) {
const feedback: { value: number; message?: string } = { value: _feedback }
track('Helpful', feedback)
return
}
export function DocsFeedbackWidget({ className }: { className?: string }) {
const pathname = usePathname()
const [didSubmit, setDidSubmit] = useLocalStorageState(pathname + '-feedback-submitted', false)
// Set the feedback session id to a random id and stash it in storage
const [sessionId] = useLocalStorageState(
'feedback-session-id',
Math.random().toString(36).substring(7)
)
const [state, setState] = useState<
'idle' | 'thumbs-up' | 'thumbs-down' | 'loading' | 'success' | 'error'
>('idle')
// Track outside the setState updater: React double-invokes updaters in StrictMode, which
// would double-count the vote.
const handleThumbs = (thumb: 'thumbs-up' | 'thumbs-down') => {
const next = state === thumb ? 'idle' : thumb
if (next === thumb) {
submitThumbsFeedback(sessionId, pathname, thumb === 'thumbs-up' ? 1 : -1)
}
setState(next)
}
const handleSubmit: FormEventHandler<HTMLFormElement> = async (e) => {
e.preventDefault()
if (state === 'loading') return
if (!sessionId) return
try {
const form = e.currentTarget
const formData = new FormData(form)
const feedback = formData.get('feedback') as string
setState('loading')
await submitFeedback(sessionId, pathname, state === 'thumbs-up' ? 1 : -1, feedback)
setState('success')
setTimeout(() => {
setDidSubmit(true)
}, 3000)
} catch {
setState('error')
}
}
// todo, improve this so that thumbs ups and thumbs downs are also captured
if (didSubmit && !(DEBUGGING && process.env.NODE_ENV === 'development')) return null
return (
<div
className={cn(
'-ml-4 px-4 bg-zinc-50 dark:bg-zinc-900 rounded-lg shrink-0 text-xs h-auto',
className
)}
>
{state !== 'success' && (
<div className="flex justify-between items-center">
<span>Is this page helpful?</span>
<div className="flex">
<button
onClick={() => handleThumbs('thumbs-down')}
className={cn(
'h-9 w-7 flex items-center justify-center pt-0.5',
state === 'thumbs-down' && 'text-blue-500',
state !== 'thumbs-down' && 'hover:text-black dark:hover:text-white'
)}
>
<HandThumbDownIcon className="h-4" />
</button>
<button
onClick={() => handleThumbs('thumbs-up')}
className={cn(
'h-9 w-7 flex items-center justify-center pb-0.5 -mr-1',
state === 'thumbs-up' && 'text-blue-500',
state !== 'thumbs-up' && 'hover:text-black dark:hover:text-white'
)}
>
<HandThumbUpIcon className="h-4" />
</button>
</div>
</div>
)}
{(state === 'thumbs-up' || state === 'thumbs-down' || state === 'loading') && (
<form onSubmit={handleSubmit} className="pb-3">
<Field>
<Label className="sr-only">Your feedback</Label>
<Textarea
name="feedback"
placeholder="Your feedback..."
rows={3}
className="resize-none bg-zinc-200/50 dark:bg-zinc-700/50 w-full rounded-md placeholder-zinc-400 dark:placeholder-zinc-600 text-black dark:text-white px-2 py-1 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 focus:ring-offset-zinc-50 dark:focus:ring-offset-zinc-900"
/>
</Field>
<button
type="submit"
disabled={state === 'loading'}
className="bg-blue-500 rounded-md h-6 px-3 flex items-center text-white gap-1.5 font-medium ml-auto mt-2 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 focus:ring-offset-zinc-50 hover:bg-blue-600 dark:hover:bg-blue-400 dark:focus:ring-offset-zinc-900"
>
<span>Submit</span>
<ArrowLongRightIcon className="h-3.5" />
</button>
</form>
)}
{state === 'success' && (
<p className="h-9 flex items-center gap-1.5">
<CheckCircleIcon className="h-4 text-emerald-500 dark:text-emerald-400" />
<span>Thanks for your feedback!</span>
</p>
)}
</div>
)
}

View File

@@ -0,0 +1,45 @@
import { ArrowLongLeftIcon, ArrowLongRightIcon } from '@heroicons/react/20/solid'
import Link from 'next/link'
import { Article } from '@/types/content-types'
import { db } from '@/utils/ContentDatabase'
export async function DocsFooter({ article }: { article: Article }) {
const links = await db.getArticleLinks(article)
return (
<section className="py-10 mt-12 border-t border-zinc-100 dark:border-zinc-800 flex flex-wrap justify-between gap-8">
{links.prev ? (
<Link
href={links.prev.path!}
className="group py-2 px-4 -ml-4 rounded-lg hover:bg-zinc-50 dark:hover:bg-zinc-900 flex flex-col items-start gap-0.5"
>
<div className="flex items-center gap-1 text-xs group-hover:text-black dark:group-hover:text-white">
<ArrowLongLeftIcon className="h-3" />
<span>Prev</span>
</div>
<span className="text-blue-500 group-hover:text-blue-600 dark:group-hover:text-blue-400">
{links.prev.title}
</span>
</Link>
) : (
<span />
)}
{links.next ? (
<Link
href={links.next.path!}
className="group py-2 px-4 -mr-4 rounded-lg hover:bg-zinc-50 dark:hover:bg-zinc-900 flex flex-col items-end gap-0.5 justify-self-end ml-auto"
>
<div className="flex items-center gap-1 text-xs group-hover:text-black dark:group-hover:text-white">
<span>Next</span>
<ArrowLongRightIcon className="h-3" />
</div>
<span className="text-blue-500 group-hover:text-blue-600 dark:group-hover:text-blue-400">
{links.next.title}
</span>
</Link>
) : (
<span />
)}
</section>
)
}

View File

@@ -0,0 +1,97 @@
import { Button } from '@/components/common/button'
import { PageTitle } from '@/components/common/page-title'
import { Article } from '@/types/content-types'
import { cn } from '@/utils/cn'
import { version } from '@/version'
import { CopyMarkdownButton } from './copy-markdown-button'
function getFileExtension(filename: string): string {
const ext = filename.split('.').pop()?.toLowerCase() ?? ''
switch (ext) {
case 'tsx':
case 'ts':
return 'tsx'
case 'jsx':
case 'js':
return 'jsx'
case 'css':
return 'css'
case 'svg':
return 'xml'
default:
return ext
}
}
function buildFullMarkdown(article: Article): string {
const parts: string[] = []
// Add the article content
if (article.content) {
parts.push(article.content.trim())
}
// Collect all code files
const codeFiles: Array<{ name: string; content: string }> = []
// Add the main component code
if (article.componentCode && article.componentCodeFilename) {
codeFiles.push({ name: article.componentCodeFilename, content: article.componentCode })
}
// Add additional code files
if (article.componentCodeFiles) {
const files = JSON.parse(article.componentCodeFiles) as Record<string, string>
for (const [fileName, content] of Object.entries(files)) {
codeFiles.push({ name: fileName, content })
}
}
// Add all code files with filename comments
for (const file of codeFiles) {
const ext = getFileExtension(file.name)
parts.push('\n\n```' + ext + '\n// ' + file.name + '\n\n' + file.content.trim() + '\n```')
}
return parts.join('')
}
export async function DocsHeader({ article }: { article: Article }) {
let sourceUrlWithVersionTag
if (article.sectionId === 'reference' && article.sourceUrl) {
sourceUrlWithVersionTag = article.sourceUrl.replace(
'/tldraw/tldraw/blob/main',
'/tldraw/tldraw/blob/v' + version
)
}
const markdown = buildFullMarkdown(article)
return (
<section
className={cn(
article.sectionId === 'reference'
? ''
: 'pb-6 mb-6 md:mb-8 md:pb-8 border-b border-zinc-100 dark:border-zinc-800'
)}
>
<div className="flex flex-wrap justify-between gap-x-8 gap-y-3">
<PageTitle>{article.title}</PageTitle>
<div className="flex flex-wrap items-center gap-2 mt-1">
{markdown && <CopyMarkdownButton markdown={markdown} />}
{sourceUrlWithVersionTag && (
<Button
id="see-source-code"
href={sourceUrlWithVersionTag}
newTab
caption="See source code"
icon="github"
size="sm"
type="secondary"
/>
)}
</div>
</div>
</section>
)
}

View File

@@ -0,0 +1,50 @@
import { Popover, PopoverButton, PopoverPanel } from '@headlessui/react'
import { Bars3Icon } from '@heroicons/react/16/solid'
import { XMarkIcon } from '@heroicons/react/20/solid'
import { DocsCategoryMenu } from '@/components/docs/docs-category-menu'
import { DocsSidebarMenu } from '@/components/docs/docs-sidebar-menu'
import { processSidebarContent } from '@/components/docs/docs-sidebar-utils'
import { db } from '@/utils/ContentDatabase'
export async function DocsMobileSidebar({
sectionId,
categoryId,
articleId,
}: {
sectionId?: string
categoryId?: string
articleId?: string
}) {
const sidebar = await db.getSidebarContentList({ sectionId, categoryId, articleId })
const elements = processSidebarContent(sidebar, sectionId)
return (
<Popover className="group/popover h-full grow">
<PopoverButton className="group/button focus:outline-none h-full w-full flex justify-start items-center">
<div className="flex gap-2 h-8 px-2 -ml-2 items-center justify-center rounded group-focus/button:outline-none group-focus/button:bg-zinc-100 dark:group-focus/button:bg-zinc-800 text-black dark:text-white font-semibold">
<Bars3Icon className="h-4 shrink-0 group-data-[open]/popover:hidden" />
<XMarkIcon className="h-4 scale-125 shrink-0 hidden group-data-[open]/popover:block" />
<span className="text-sm">Menu</span>
</div>
</PopoverButton>
<PopoverPanel>
<div
className="fixed left-0 top-12 bg-white dark:bg-zinc-950 w-screen px-5 py-8 overflow-y-auto z-10"
style={{ height: 'calc(100vh - 6.5rem)' }}
>
<DocsCategoryMenu />
{elements.map((menu: any, index: number) => (
// @ts-ignore
<DocsSidebarMenu
key={index}
title={menu.title}
elements={menu.children}
isFirst={index === 0}
hideTitle={elements.length === 1}
/>
))}
</div>
</PopoverPanel>
</Popover>
)
}

View File

@@ -0,0 +1,17 @@
'use client'
import { motion } from 'framer-motion'
import { SearchButton } from '@/components/search/SearchButton'
export function DocsSearchBar() {
return (
<motion.div
className="hidden md:flex mx-auto px-5 sticky top-4 z-10 xl:max-w-3xl xl:px-0"
style={{ width: '55%', marginTop: '-56px' }}
>
<div className="lg:max-w-3xl lg:pl-12 lg:pr-12 grow">
<SearchButton type="docs" layout="desktop" />
</div>
</motion.div>
)
}

View File

@@ -0,0 +1,93 @@
'use client'
import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/react'
import { ChevronRightIcon } from '@heroicons/react/16/solid'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { APIGroup } from '@/types/content-types'
import { cn } from '@/utils/cn'
export function DocsSidebarMenu({
title,
elements,
isFirst = false,
hideTitle = false,
}: {
isFirst: boolean
title: string
elements: {
type: string
articleId: string
title: string
url: string
groupId: string | null
}[]
hideTitle?: boolean
}) {
const pathname = usePathname()
const groups = elements.some((e) => e.groupId) ? Object.values(APIGroup) : null
return (
<div className={cn(isFirst ? 'mt-0 md:mt-8' : 'mt-0 md:mt-12')}>
{!hideTitle && (
<h4 className="text-black dark:text-white uppercase text-xs w-full font-semibold md:sticky top-0 bg-white dark:bg-zinc-950 z-10 py-2">
{title}
</h4>
)}
<ul className={cn('flex flex-col gap-2 text-sm break-words', !hideTitle && 'mt-2')}>
{groups?.map((group, index) => {
const groupElements = elements.filter((e) => e.groupId === group)
if (groupElements.length === 0) return null
return (
<li key={index}>
<Disclosure defaultOpen={groupElements.some((e) => e.url === pathname)}>
<DisclosureButton className="group w-full flex justify-between gap-4">
<span className="group-data-[open]:text-black dark:group-data-[open]:text-white group-data-[open]:font-semibold">
{group}
</span>
<ChevronRightIcon className="h-3.5 shrink-0 group-data-[open]:rotate-90 text-zinc-400 dark:text-zinc-600 group-data-[open]:text-black dark:group-data-[open]:text-white" />
</DisclosureButton>
<DisclosurePanel>
<ul className="flex flex-col gap-2 ml-1 pl-2 pt-2 border-l border-zinc-100 dark:border-zinc-800">
{groupElements.map(({ title, url }, index) => (
<li key={index}>
<Link
href={url}
data-active={pathname === url}
className={cn(
'sidebar-link w-full truncate',
pathname === url
? 'text-black dark:text-white font-semibold'
: 'hover:text-zinc-800 dark:hover:text-zinc-200'
)}
>
{title}
</Link>
</li>
))}
</ul>
</DisclosurePanel>
</Disclosure>
</li>
)
}) ??
elements.map(({ title, url }, index) => (
<li key={index}>
<Link
href={url}
data-active={pathname === url}
className={cn(
'sidebar-link block',
pathname === url || `/getting-started${pathname}` === url
? 'text-black dark:text-white font-semibold'
: 'hover:text-zinc-800 dark:hover:text-zinc-200'
)}
>
{title}
</Link>
</li>
))}
</ul>
</div>
)
}

View File

@@ -0,0 +1,56 @@
'use client'
import { usePathname } from 'next/navigation'
import { UIEventHandler, useEffect, useRef } from 'react'
import { DocsSidebarMenu } from '@/components/docs/docs-sidebar-menu'
let scrollPosition = 0
export function DocsSidebarMenus({ menus }: { menus: any }) {
const container = useRef<HTMLDivElement>(null)
const pathname = usePathname()
useEffect(() => {
container.current?.scrollTo(0, scrollPosition)
const elements = document.querySelectorAll(
'.sidebar-link[data-active=true]'
) as NodeListOf<HTMLElement>
for (const element of elements) {
const aboveView = container.current && element.offsetTop < container.current.scrollTop
const belowView =
container.current &&
element.offsetTop > container.current.scrollTop + container.current.clientHeight
if (!aboveView && !belowView) {
return
}
}
const element = elements[0]
element?.scrollIntoView({ block: 'center' })
}, [pathname])
const onScroll: UIEventHandler<HTMLDivElement> = (e) => {
e.stopPropagation()
scrollPosition = container.current?.scrollTop ?? 0
}
return (
<div
ref={container}
onScroll={onScroll}
className="relative pr-12 overflow-y-auto grow min-h-0 pb-24"
>
<div>
{menus.map((menu: any, index: number) => (
// @ts-ignore
<DocsSidebarMenu
key={index}
title={menu.title}
elements={menu.children}
isFirst={index === 0}
hideTitle={menus.length === 1}
/>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,53 @@
import {
SidebarContentArticleLink,
SidebarContentCategoryLink,
SidebarContentLink,
} from '@/types/content-types'
/**
* Type guard to check if a sidebar link has children
*/
function hasChildren(
link: SidebarContentLink
): link is SidebarContentLink & { children: SidebarContentLink[] } {
return 'children' in link
}
// Examples duplicated into the examples sidebar's "Getting started" category. Matched by url,
// not article id: ids (`examples/collaboration/sync-demo`) change when an example moves category.
const EXAMPLES_ALSO_IN_GETTING_STARTED = ['/examples/sync-demo', '/examples/api']
/**
* Processes sidebar content by extracting elements and handling special cases
* like copying examples to the getting-started category.
*/
export function processSidebarContent(
sidebar: Awaited<ReturnType<typeof import('@/utils/ContentDatabase').db.getSidebarContentList>>,
sectionId?: string
): SidebarContentLink[] {
const skipFirstLevel = ['reference', 'examples'].includes(sectionId ?? '')
const elements =
skipFirstLevel && hasChildren(sidebar.links[0]) ? sidebar.links[0].children : sidebar.links
if (sectionId === 'examples') {
const categories = elements.filter(
(v): v is SidebarContentCategoryLink => v.type === 'category'
)
const gettingStartedCategory = categories.find((v) => v.url === '/examples/getting-started')
// An example that's been renamed or moved won't be found. Pushing `undefined` would crash
// the sidebar, which renders every entry. The cached link tree is shared between renders,
// so don't push the same example twice either.
if (gettingStartedCategory) {
for (const url of EXAMPLES_ALSO_IN_GETTING_STARTED) {
if (gettingStartedCategory.children.some((v) => v.url === url)) continue
const example = categories
.flatMap((category) => category.children)
.find((v): v is SidebarContentArticleLink => v.type === 'article' && v.url === url)
if (example) gettingStartedCategory.children.push(example)
}
}
}
return elements
}

View File

@@ -0,0 +1,25 @@
import { Navigation } from '@/components/common/navigation'
import { DocsCategoryMenu } from '@/components/docs/docs-category-menu'
import { DocsSidebarMenus } from '@/components/docs/docs-sidebar-menus'
import { processSidebarContent } from '@/components/docs/docs-sidebar-utils'
import { db } from '@/utils/ContentDatabase'
export async function DocsSidebar({
sectionId,
categoryId,
articleId,
}: {
sectionId?: string
categoryId?: string
articleId?: string
}) {
const sidebar = await db.getSidebarContentList({ sectionId, categoryId, articleId })
const elements = processSidebarContent(sidebar, sectionId)
return (
<Navigation className="hidden md:flex">
<DocsCategoryMenu />
<DocsSidebarMenus menus={elements} />
</Navigation>
)
}

View File

@@ -0,0 +1,31 @@
import { Button } from '@/components/common/button'
import { DocsArticleInfo } from '@/components/docs/docs-article-info'
import { DocsFeedbackWidget } from '@/components/docs/docs-feedback-widget'
import { HeadingsMenu } from '@/components/navigation/headings-menu'
import type { Article } from '@/types/content-types'
import { db } from '@/utils/ContentDatabase'
export async function DocsStarterSidebar({ article }: { article: Article }) {
const headings = await db.getArticleHeadings(article.id)
return (
<nav className="lg:w-60 flex flex-col xl:pl-5 sticky top-24 shrink-0 xl:order-last xl:h-[calc(100vh-6rem)]">
{article.githubLink && (
<div className="mb-12 flex xl:flex-col gap-2">
<Button
icon="github"
type="secondary"
href={article.githubLink}
newTab={true}
caption="View on GitHub"
></Button>
</div>
)}
<div className="hidden xl:flex flex-col">
<HeadingsMenu headings={headings} />
<DocsArticleInfo article={article} />
<DocsFeedbackWidget className="mb-12" />
</div>
</nav>
)
}

View File

@@ -0,0 +1,18 @@
import { Navigation } from '@/components/common/navigation'
import { DocsArticleInfo } from '@/components/docs/docs-article-info'
import { DocsFeedbackWidget } from '@/components/docs/docs-feedback-widget'
import { HeadingsMenu } from '@/components/navigation/headings-menu'
import { Article } from '@/types/content-types'
import { db } from '@/utils/ContentDatabase'
export async function DocsTableOfContents({ article }: { article: Article }) {
const headings = await db.getArticleHeadings(article.id)
return (
<Navigation className="hidden xl:flex pl-12">
<HeadingsMenu headings={headings} />
<DocsArticleInfo article={article} />
<DocsFeedbackWidget className="mb-12" />
</Navigation>
)
}

View File

@@ -0,0 +1,93 @@
import { PrivacySettingsLink } from '@/app/analytics'
import { Logo } from '@/components/common/logo'
import { TldrawLink } from '../common/tldraw-link'
import { SocialLink } from './social-link'
const menus = [
{
heading: 'Product',
items: [
{ caption: 'Whiteboard', href: '/features/out-of-the-box-whiteboard' },
{ caption: 'Starter kits', href: '/starter-kits/overview' },
{ caption: 'Pricing', href: '/pricing' },
{ caption: 'FAQ', href: '/faq' },
],
},
{
heading: 'Developers',
items: [
{ caption: 'Quick start guide', href: '/quick-start' },
{ caption: 'Starter kits', href: '/starter-kits/overview' },
{ caption: 'Examples', href: '/examples/basic' },
{ caption: 'Releases', href: '/releases' },
{ caption: 'Docs', href: '/quick-start' },
],
},
{
heading: 'Community',
items: [
{ caption: 'GitHub', href: 'https://github.com/tldraw/tldraw' },
{
caption: 'Discord',
href: 'https://discord.tldraw.com/?utm_source=docs&utm_medium=organic&utm_campaign=sociallink',
},
{ caption: 'X/Twitter', href: 'https://x.com/tldraw/' },
{ caption: 'LinkedIn', href: 'https://www.linkedin.com/company/tldraw/' },
{ caption: 'Bluesky', href: 'https://bsky.app/profile/tldraw.com' },
{ caption: 'Mastodon', href: 'https://mas.to/@tldraw' },
],
},
{
heading: 'Company',
items: [
{ caption: 'Videos', href: '/blog/events' },
{ caption: 'Careers', href: '/careers' },
{ caption: 'License', href: '/community/license' },
{ caption: 'Trademarks', href: '/legal/trademark-guidelines' },
{ caption: 'CLA', href: '/legal/contributor-license-agreement' },
{ caption: 'Privacy settings', href: '#', isCookieSetting: true },
],
},
]
export function Footer() {
return (
<footer className="pt-16 pb-36 bg-zinc-950 text-white">
<div className="flex flex-col w-full max-w-screen-2xl gap-12 px-5 mx-auto sm:flex-row sm:justify-between">
<div className="flex-[2]">
<TldrawLink href="/" className="w-28">
<Logo className="h-6" forceWhite />
</TldrawLink>
<p className="mt-2 text-sm">The infinite canvas SDK</p>
<div className="mt-4 flex gap-5">
<SocialLink caption="GitHub" icon="github" href="https://github.com/tldraw/tldraw" />
<SocialLink caption="X/Twitter" icon="twitter" href="https://x.com/tldraw/" />
<SocialLink caption="Discord" icon="discord" href="https://discord.tldraw.com/" />
<SocialLink
caption="LinkedIn"
icon="linkedin"
href="https://www.linkedin.com/company/tldraw/"
/>
</div>
</div>
{menus.map(({ heading, items }, index) => (
<div key={index} className="flex-1">
<h4 className="font-medium text-sm text-zinc-400">{heading}</h4>
<ul className="flex flex-col gap-4 mt-4 text-sm">
{items.map(({ caption, href, isCookieSetting }, index) => (
<li key={index}>
{isCookieSetting ? (
<PrivacySettingsLink />
) : (
<TldrawLink href={href}>{caption}</TldrawLink>
)}
</li>
))}
</ul>
</div>
))}
</div>
<p className="mt-8 px-5 text-xs">&copy; {new Date().getFullYear()} tldraw</p>
</footer>
)
}

View File

@@ -0,0 +1,62 @@
'use client'
import { motion, useScroll, useTransform } from 'framer-motion'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { type IconName } from '@/components/common/icon'
import { Logo } from '@/components/common/logo'
import { SocialLink } from '@/components/navigation/social-link'
import { SearchButton } from '@/components/search/SearchButton'
import { ThemeSwitch } from '../common/theme-switch'
const socialLinks = [
{
caption: 'Twitter/X',
icon: 'twitter' as IconName,
href: 'https://x.com/tldraw/',
},
{
caption: 'Discord',
icon: 'discord' as IconName,
href: 'https://discord.tldraw.com/?utm_source=docs&utm_medium=organic&utm_campaign=sociallink',
},
{
caption: 'GitHub',
icon: 'github' as IconName,
href: 'https://github.com/tldraw/tldraw',
},
]
export function Header() {
const pathname = usePathname()
const { scrollY } = useScroll()
const navOpacity = useTransform(scrollY, [0, 32], [1, 0])
const socialsOpacityEffect = pathname === '/search'
return (
<header className="sticky top-0 w-full bg-white dark:bg-zinc-950 z-10">
<nav className="w-full max-w-screen-2xl mx-auto px-5 h-14 md:h-[4.5rem] flex justify-between items-center text-zinc-800 dark:text-zinc-200 border-b border-zinc-100 dark:border-zinc-800 dark:md:border-transparent md:border-transparent">
<Link href="/" className="w-28">
<Logo className="h-6" />
</Link>
<motion.ul
style={{ opacity: socialsOpacityEffect ? navOpacity : 1 }}
className="hidden sm:flex w-28 gap-4 justify-end xl:!opacity-100"
>
{socialLinks.map((item, index) => (
<li key={index}>
<SocialLink {...item} />
</li>
))}
<ThemeSwitch />
</motion.ul>
<div className="flex items-center sm:hidden -mr-2">
{!pathname?.startsWith('/search') && (
<SearchButton type={pathname?.startsWith('/blog') ? 'blog' : 'docs'} layout="mobile" />
)}
<ThemeSwitch />
</div>
</nav>
</header>
)
}

View File

@@ -0,0 +1,53 @@
'use client'
import Link from 'next/link'
import { useEffect, useState } from 'react'
import { ArticleHeading } from '@/types/content-types'
import { cn } from '@/utils/cn'
export function HeadingsMenu({ headings }: { headings: ArticleHeading[] }) {
const [activeSlug, setActiveSlug] = useState('')
useEffect(() => {
const handleScroll = () => {
let current = ''
for (const { slug } of headings) {
const element = document.getElementById(slug)
if (element && element.getBoundingClientRect().top < 100) current = slug
}
setActiveSlug(current)
}
handleScroll()
window.addEventListener('scroll', handleScroll, { passive: true })
return () => window.removeEventListener('scroll', handleScroll)
}, [headings])
if (headings.length === 0) return null
return (
<div className="relative overflow-y-auto shrink mb-12">
<div className="sticky top-0">
<h4 className="block text-xs font-semibold text-black uppercase bg-white dark:bg-zinc-950 dark:text-white">
On this page
</h4>
<div className="w-full h-2 bg-gradient-to-b from-white dark:from-zinc-950" />
</div>
<ul className="flex flex-col gap-2 text-sm break-words">
{headings.map(({ title, level, slug }, index) => (
<li key={index} style={{ paddingLeft: `${(level - 2) * 0.75}rem` }}>
<Link
href={`#${slug}`}
className={cn(
slug === activeSlug
? 'text-black dark:text-white font-semibold'
: 'hover:text-zinc-800 dark:hover:text-zinc-100'
)}
>
{title}
</Link>
</li>
))}
</ul>
</div>
)
}

View File

@@ -0,0 +1,47 @@
'use client'
import Link from 'next/link'
import { cn } from '@/utils/cn'
export function NavigationLink({
caption,
icon,
href,
active,
}: {
caption: string
icon?: any
href?: string
active: boolean
}) {
const Icon = icon
if (href)
return (
<Link
href={href}
className={cn(
'flex items-center gap-3',
active
? 'font-semibold text-black dark:text-white'
: 'hover:text-zinc-600 dark:hover:text-zinc-100'
)}
>
{icon && (
<div
className={cn(
'h-6 w-6 rounded-md flex items-center justify-center',
active
? 'bg-black text-white dark:bg-white dark:text-black'
: 'bg-zinc-100 dark:bg-zinc-700 dark:text-zinc-300'
)}
>
<Icon className="h-4" />
</div>
)}
<span>{caption}</span>
</Link>
)
return null
}

View File

@@ -0,0 +1,19 @@
import Link from 'next/link'
import { Icon, IconName } from '@/components/common/icon'
export function SocialLink({
caption,
icon,
href,
}: {
caption: string
icon: IconName
href: string
}) {
return (
<Link href={href} target="_blank" rel="noreferrer">
<span className="sr-only">{caption}</span>
<Icon icon={icon} className="h-4" />
</Link>
)
}

View File

@@ -0,0 +1,129 @@
import { Hit } from 'instantsearch.js'
import { getHighlightedParts, getPropertyByPath, unescape } from 'instantsearch.js/es/lib/utils'
import { memo } from 'react'
import { SearchEntry } from '@/utils/algolia'
export const ContentHighlight = memo(function ContentHighlight({ hit }: { hit: Hit<SearchEntry> }) {
const descriptionHighlight = getHighlights(hit, 'description', 40)
const contentHighlight = getHighlights(hit, 'content', 40)
let firstItem = null
let secondItem = null
if (descriptionHighlight.didMatch === contentHighlight.didMatch) {
// if both either matched or didn't, show whatever has actual content:
if (descriptionHighlight.parts.length && contentHighlight.parts.length) {
firstItem = descriptionHighlight
secondItem = contentHighlight
} else if (descriptionHighlight.parts.length) {
firstItem = descriptionHighlight
} else if (contentHighlight.parts.length) {
firstItem = contentHighlight
}
} else if (descriptionHighlight.didMatch && !contentHighlight.didMatch) {
// if only the description matched, show both because the description is short:
firstItem = descriptionHighlight
secondItem = contentHighlight
} else {
// otherwise, only the content matched. show just the content:
firstItem = contentHighlight
}
if (firstItem && !secondItem) {
// if we only have the first item, show it over two lines:
return (
<HighlightContent
didTruncateStart={firstItem.didTruncateStart}
parts={firstItem.parts}
className="line-clamp-2 text-sm"
/>
)
} else {
// otherwise, show both items on one line each:
return (
<>
{firstItem && (
<HighlightContent
didTruncateStart={firstItem.didTruncateStart}
parts={firstItem.parts}
className="line-clamp-1 text-sm"
/>
)}
{secondItem && (
<HighlightContent
didTruncateStart={secondItem.didTruncateStart}
parts={secondItem.parts}
className="line-clamp-1 text-sm"
/>
)}
</>
)
}
})
function HighlightContent({
parts,
didTruncateStart,
className,
}: {
parts: { value: string; isHighlighted: boolean }[]
didTruncateStart: boolean
className: string
}) {
return (
<div className={className}>
{didTruncateStart && '…'}
{parts.map((part, index) =>
part.isHighlighted ? <mark key={index}>{part.value}</mark> : part.value
)}
</div>
)
}
function getHighlights(
hit: Hit<SearchEntry>,
attribute: 'description' | 'content',
maxUnhighlightedPrefix: number
): {
didTruncateStart: boolean
didMatch: boolean
parts: { value: string; isHighlighted: boolean }[]
} {
const originalProperty = hit[attribute]?.trim()
const highlightedProperty = getPropertyByPath(hit._highlightResult, attribute as string)
if (!highlightedProperty) {
return {
didMatch: false,
didTruncateStart: false,
parts: originalProperty ? [{ value: originalProperty, isHighlighted: false }] : [],
}
}
const parts = getHighlightedParts(unescape(highlightedProperty.value || ''))
let didTruncateStart = false
const didMatch = parts.some((part) => part.isHighlighted)
const initialPart = parts[0]
// if the first part is unhighlighted and too long, we should cut it short
if (initialPart && !initialPart.isHighlighted && didMatch) {
const words = initialPart.value.split(' ').reverse()
let lengthSoFar = 0
const wordsToInclude: string[] = []
for (const word of words) {
lengthSoFar += word.length
if (lengthSoFar > maxUnhighlightedPrefix) {
break
}
wordsToInclude.push(word)
}
if (wordsToInclude.length < words.length) {
didTruncateStart = true
parts[0].value = `${wordsToInclude.reverse().join(' ')}`
}
}
return { didTruncateStart, didMatch, parts }
}

View File

@@ -0,0 +1,193 @@
'use client'
import { Combobox, ComboboxItem, ComboboxProvider } from '@ariakit/react'
import { MagnifyingGlassIcon } from '@heroicons/react/20/solid'
import { debounce } from '@tldraw/utils'
import { Hit } from 'instantsearch.js'
import { SendEventForHits } from 'instantsearch.js/es/lib/utils'
import Link from 'next/link'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import { createRef, Fragment, useCallback, useEffect, useState } from 'react'
import { Configure, Highlight, InstantSearch, useHits, useSearchBox } from 'react-instantsearch'
import { twJoin } from 'tailwind-merge'
import { getSearchIndexName, SearchEntry } from '@/utils/algolia'
import { searchClient } from '@/utils/search-api'
import { ContentHighlight } from './ContentHighlight'
export function FullPageSearch({ indexName }: { indexName: 'docs' | 'blog' }) {
return (
<InstantSearch
indexName={getSearchIndexName(indexName)}
searchClient={searchClient}
insights={true}
future={{ preserveSharedStateOnUnmount: true }}
>
<Configure distinct={4} hitsPerPage={40} />
<InstantSearchInner indexName={indexName} />
</InstantSearch>
)
}
function InstantSearchInner({ indexName }: { indexName: 'docs' | 'blog' }) {
const { items, sendEvent } = useHits<SearchEntry>()
const { refine } = useSearchBox()
const router = useRouter()
const [open, setOpen] = useState(true)
const searchParams = useSearchParams()
const urlQuery = searchParams.get('query') || ''
const handleChange = (path: string) => {
setOpen(false)
router.push(path)
}
const updateSearchQuery = debounce((value: string) => refine(value), 500)
useEffect(() => {
refine(urlQuery)
}, [urlQuery, refine])
return (
open && (
<SearchAutocomplete
items={items}
onInputChange={updateSearchQuery}
onChange={handleChange}
sendEvent={sendEvent}
defaultValue={urlQuery}
indexName={indexName}
/>
)
)
}
interface AutocompleteProps {
items: Hit<SearchEntry>[]
onChange(value: string): void
onInputChange(value: string): void
defaultValue: string
sendEvent: SendEventForHits
indexName: 'docs' | 'blog'
}
function SearchAutocomplete({
items,
defaultValue,
onInputChange,
onChange,
sendEvent,
indexName,
}: AutocompleteProps) {
return (
<ComboboxProvider<string>
defaultSelectedValue=""
defaultValue={defaultValue}
includesBaseElement={true}
setValue={(newValue) => onInputChange(newValue)}
setSelectedValue={(newValue) => onChange(newValue)}
>
<div className="w-full mb-12 h-full">
<div className="pointer-events-auto min-h-full">
<SearchInput indexName={indexName} />
<Results items={items} sendEvent={sendEvent} />
</div>
</div>
</ComboboxProvider>
)
}
function SearchInput({ indexName }: { indexName: 'docs' | 'blog' }) {
const comboboxRef = createRef<HTMLInputElement>()
const router = useRouter()
const pathname = usePathname()
const capitalizedIndexName = indexName.charAt(0).toUpperCase() + indexName.slice(1)
const updateUrl = useCallback(() => {
const queryParam = comboboxRef.current?.value || ''
router.push(`${pathname}?query=${encodeURIComponent(queryParam.trim())}`)
}, [comboboxRef, router, pathname])
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key !== 'Enter') return
updateUrl()
},
[updateUrl]
)
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
comboboxRef.current?.focus()
comboboxRef.current?.setSelectionRange(0, comboboxRef.current.value.length)
}
}
document.addEventListener('keydown', onKeyDown)
return () => document.removeEventListener('keydown', onKeyDown)
}, [comboboxRef])
return (
<div className="md:sticky bg-white dark:bg-zinc-950 w-full md:top-4 flex items-center z-20 md:pb-4">
<div className="flex h-10 grow items-center gap-3 bg-zinc-50 dark:bg-zinc-900 rounded-lg px-4 focus-within:ring-blue-500 focus-within:ring-2 min-w-full">
<MagnifyingGlassIcon className="h-4 shrink-0 text-black dark:text-white" />
<Combobox
ref={comboboxRef}
className="h-full w-full mr-4 focus:outline-none text-black dark:text-white bg-transparent"
placeholder={`Search ${capitalizedIndexName}...`}
onKeyDown={handleKeyDown}
/>
</div>
</div>
)
}
function Results({ items, sendEvent }: { items: Hit<SearchEntry>[]; sendEvent: SendEventForHits }) {
let section = ''
const renderedItems = items.map((hit) => {
const showChapter = hit.section !== section
section = hit.section
const href = hit.headingSlug ? `${hit.path}#${hit.headingSlug}` : hit.path
return (
<Fragment key={hit.objectID}>
{showChapter && (
<div className="text-black dark:text-white font-semibold mt-6 mb-4">{section}</div>
)}
<ComboboxItem
className={twJoin(
'px-4 pt-2.5 pb-3 bg-zinc-100 dark:bg-zinc-800 mt-2',
'rounded-md cursor-pointer data-[active-item=true]:bg-blue-500',
'dark:data-[active-item=true]:bg-blue-500 data-[active-item=true]:text-blue-200',
'[&_mark]:bg-transparent [&_mark]:font-bold',
'[&_mark]:text-black [&_mark]:data-[active-item=true]:text-white',
'dark:[&_mark]:text-white',
'[&_.ais-Highlight-nonHighlighted]:data-[active-item=true]:text-white',
'hover:text-zinc-800 dark:hover:text-zinc-200'
)}
value={href}
onKeyDown={(e) => {
if (e.key === 'Enter') {
sendEvent('click', hit, 'Hit clicked via keyboard')
}
}}
>
<Link href={href} onClick={() => sendEvent('click', hit, 'Hit clicked')}>
<Highlight attribute="title" hit={hit} />
<ContentHighlight hit={hit} />
</Link>
</ComboboxItem>
</Fragment>
)
})
return items.length === 0 ? (
<div className="sticky top-[4.5rem] text-center py-8 text-zinc-400 dark:text-zinc-600">
No results found.
</div>
) : (
renderedItems
)
}

View File

@@ -0,0 +1,107 @@
'use client'
import { MagnifyingGlassIcon as MagnifyingGlassIconSmall } from '@heroicons/react/20/solid'
import {
MagnifyingGlassIcon as MagnifyingGlassIconLarge,
XMarkIcon,
} from '@heroicons/react/24/solid'
import { usePathname } from 'next/navigation'
import { useEffect, useState } from 'react'
import { twJoin } from 'tailwind-merge'
import { cn } from '@/utils/cn'
import { Search } from '.'
function isEditableElement(target: EventTarget | null) {
if (!(target instanceof HTMLElement)) return false
return (
target.isContentEditable ||
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.tagName === 'SELECT'
)
}
export function SearchButton({
type,
layout,
className,
}: {
type: 'docs' | 'blog'
layout: 'mobile' | 'desktop' | 'keyboard-shortcut-only'
className?: string
}) {
const [open, setOpen] = useState(false)
const pathname = usePathname()
useEffect(() => {
if (layout !== 'desktop' && layout !== 'keyboard-shortcut-only') return
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === '/') {
// A bare "/" is only a shortcut outside text fields, otherwise typing a path into
// the feedback textarea opens the dialog and steals focus mid-word.
if (isEditableElement(e.target)) return
e.preventDefault()
setOpen(true)
} else if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
setOpen((open) => !open)
} else if (e.key === 'Escape') {
setOpen(false)
}
}
document.addEventListener('keydown', onKeyDown)
return () => document.removeEventListener('keydown', onKeyDown)
}, [layout])
useEffect(() => {
setOpen(false)
}, [pathname])
if (layout === 'keyboard-shortcut-only') {
return open ? <Search type={type} onClose={() => setOpen(false)} /> : null
}
return (
<div className={cn('group', className)}>
<button
className={cn(
'flex items-center text-black dark:text-white focus:outline-none',
layout === 'desktop' &&
twJoin(
'w-full h-10 justify-between bg-zinc-50 dark:bg-zinc-900 px-4 cursor-text',
'hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-lg focus:ring-2',
'focus:ring-blue-500'
),
layout === 'mobile' &&
'w-8 h-8 justify-center rounded focus:bg-zinc-100 dark:focus:bg-zinc-800'
)}
onClick={() => setOpen((open) => !open)}
>
<div className="flex items-center gap-3">
{open ? (
<XMarkIcon className="h-6 md:hidden" />
) : (
<>
<MagnifyingGlassIconSmall className={cn('h-4', layout === 'mobile' && 'hidden')} />
<MagnifyingGlassIconLarge className={cn('h-6', layout === 'desktop' && 'hidden')} />
</>
)}
{!open && (
<span
className={cn(
'capitalize text-sm text-zinc-400 dark:text-zinc-600',
layout === 'mobile' && 'hidden'
)}
>
Search {type}…
</span>
)}
</div>
<span className={cn('text-xs', layout === 'mobile' && 'hidden')}>⌘K</span>
</button>
{open && <Search type={type} onClose={() => setOpen(false)} />}
</div>
)
}

View File

@@ -0,0 +1,192 @@
import { Combobox, ComboboxItem, ComboboxProvider, VisuallyHidden } from '@ariakit/react'
import { MagnifyingGlassIcon } from '@heroicons/react/20/solid'
import { Hit } from 'instantsearch.js'
import { SendEventForHits } from 'instantsearch.js/es/lib/utils'
import Link from 'next/link'
import { usePathname, useRouter } from 'next/navigation'
import { Dialog as _Dialog } from 'radix-ui'
import { Fragment, startTransition, useState } from 'react'
import { Highlight } from 'react-instantsearch'
import { twJoin } from 'tailwind-merge'
import { SearchEntry } from '@/utils/algolia'
import { ContentHighlight } from './ContentHighlight'
interface AutocompleteProps {
items: Hit<SearchEntry>[]
onChange(value: string): void
onInputChange(value: string): void
onClose(): void
sendEvent: SendEventForHits
}
export default function SearchDialog({
items,
onInputChange,
onChange,
onClose,
sendEvent,
}: AutocompleteProps) {
const [open, setOpen] = useState(true)
const [value, setValue] = useState('')
const handleOpenChange = (open: boolean) => {
if (!open) {
onClose()
}
setOpen(open)
}
return (
<ComboboxProvider<string>
defaultSelectedValue=""
open={open}
setOpen={setOpen}
resetValueOnHide
includesBaseElement={false}
setValue={(newValue) => {
startTransition(() => {
setValue(newValue)
onInputChange(newValue)
})
}}
setSelectedValue={(newValue) => onChange(newValue)}
>
<div
className={twJoin(
'fixed w-screen h-screen left-0 top-14 sm:top-[6.5rem] md:top-0 z-40',
'bg-white/90 dark:bg-zinc-950/90 pointer-events-none'
)}
>
<SearchDialogWrapper onOpenChange={handleOpenChange}>
<div className="w-full max-w-3xl mx-auto px-5 lg:px-12 pt-[4.5rem]">
<div
className={twJoin(
'pointer-events-auto bg-zinc-50 dark:bg-zinc-900 rounded-lg',
'overflow-hidden focus-within:ring-2 focus-within:ring-blue-500'
)}
>
<SearchInput value={value} />
<Results items={items} sendEvent={sendEvent} />
</div>
</div>
</SearchDialogWrapper>
</div>
</ComboboxProvider>
)
}
function SearchInput({ value }: { value: string }) {
const router = useRouter()
const pathName = usePathname()
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key !== 'Enter') return
const searchPath = pathName.startsWith('/blog') ? '/search/blog' : '/search'
router.push(`${searchPath}?query=${encodeURIComponent(value.trim())}`)
}
return (
<div className="w-full h-10 flex items-center px-4">
<div className="flex h-full grow items-center gap-3">
<MagnifyingGlassIcon className="h-4 shrink-0" />
<Combobox
className="h-full w-full mr-4 focus:outline-none text-black dark:text-white bg-transparent"
autoFocus
placeholder="Search..."
onKeyDown={handleKeyDown}
/>
</div>
</div>
)
}
function Results({ items, sendEvent }: { items: Hit<SearchEntry>[]; sendEvent: SendEventForHits }) {
let section = ''
const renderedItems = items.map((hit) => {
const showChapter = hit.section !== section
section = hit.section
const href = hit.headingSlug ? `${hit.path}#${hit.headingSlug}` : hit.path
return (
<Fragment key={hit.objectID}>
{showChapter && (
<div className="text-black dark:text-white font-semibold mt-6 mb-4">{section}</div>
)}
<ComboboxItem
className={twJoin(
'px-4 pt-2.5 pb-3 bg-zinc-100 dark:bg-zinc-800 mt-2',
'rounded-md cursor-pointer data-[active-item=true]:bg-blue-500',
'dark:data-[active-item=true]:bg-blue-500 data-[active-item=true]:text-blue-200',
'[&_mark]:bg-transparent [&_mark]:font-bold',
'[&_mark]:text-black [&_mark]:data-[active-item=true]:text-white',
'dark:[&_mark]:text-white',
'[&_.ais-Highlight-nonHighlighted]:data-[active-item=true]:text-white',
'hover:text-zinc-800 dark:hover:text-zinc-200'
)}
value={href}
onKeyDown={(e) => {
if (e.key === 'Enter') {
sendEvent('click', hit, 'Hit clicked via keyboard')
}
}}
>
<Link href={href} onClick={() => sendEvent('click', hit, 'Hit clicked')}>
<Highlight attribute="title" hit={hit} />
<ContentHighlight hit={hit} />
</Link>
</ComboboxItem>
</Fragment>
)
})
return (
<div
className={twJoin(
'max-h-[32rem] overflow-y-auto p-4 pt-0',
'border-t border-zinc-100 dark:border-zinc-800'
)}
>
{items.length === 0 && (
<div className="text-center py-8 text-zinc-400 dark:text-zinc-600">
<p>No results found.</p>
<p className="mt-2">
Will you tell us what you{"'"}re looking for on our{' '}
<a
className="text-blue-500 hover:text-blue-600"
target="_blank"
href="https://discord.tldraw.com/?utm_source=blog&utm_medium=search&utm_campaign=no-results-found"
rel="noreferrer"
>
discord
</a>
?
</p>
</div>
)}
{items.length !== 0 && renderedItems}
</div>
)
}
function SearchDialogWrapper({
children,
onOpenChange,
}: {
children: React.ReactNode
onOpenChange(open: boolean): void
}) {
return (
<_Dialog.Root open onOpenChange={onOpenChange}>
<_Dialog.Portal>
<_Dialog.Overlay />
<_Dialog.Content className="fixed inset-0 z-50" style={{ pointerEvents: 'none' }}>
<VisuallyHidden>
<_Dialog.Title>Search</_Dialog.Title>
<_Dialog.Description>Search dialog</_Dialog.Description>
</VisuallyHidden>
{children}
</_Dialog.Content>
</_Dialog.Portal>
</_Dialog.Root>
)
}

View File

@@ -0,0 +1,43 @@
'use client'
import { debounce } from '@tldraw/utils'
import { useRouter } from 'next/navigation'
import { InstantSearch, useHits, useSearchBox } from 'react-instantsearch'
import { SearchEntry, SearchIndexName, getSearchIndexName } from '@/utils/algolia'
import { searchClient } from '@/utils/search-api'
import SearchDialog from './SearchDialog'
export function Search({ type, onClose }: { type: SearchIndexName; onClose(): void }) {
return (
<InstantSearch
indexName={getSearchIndexName(type)}
searchClient={searchClient}
insights={true}
future={{ preserveSharedStateOnUnmount: true }}
>
<InstantSearchInner onClose={onClose} />
</InstantSearch>
)
}
function InstantSearchInner({ onClose }: { onClose(): void }) {
const { items, sendEvent } = useHits<SearchEntry>()
const { refine } = useSearchBox()
const router = useRouter()
const handleChange = (path: string) => {
router.push(path)
onClose()
}
const handleInputChange = debounce((query: string) => refine(query), 500)
return (
<SearchDialog
items={items}
onInputChange={handleInputChange}
onChange={handleChange}
onClose={onClose}
sendEvent={sendEvent}
/>
)
}

View 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.

View 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 &lt;sindresorhus@gmail.com&gt; (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 &lt;andrey@sitnik.ru&gt; |
| `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 &lt;j.k.sokolowski@gmail.com&gt; |
| `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 &lt;sindresorhus@gmail.com&gt; (https://sindresorhus.com) |

View 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.

View 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).

View 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

View 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

View 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).

View 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

View 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

View 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.

View 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

View 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 |

View 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

View 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

View 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

View 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 |

View 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.

View 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 |

View 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

View 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).

View 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!

View 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

Some files were not shown because too many files have changed in this diff Show More