snapshot of https://github.com/tldraw/tldraw.git (main, 2026-09-23; blobs > 131072B omitted)
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
This commit is contained in:
8
apps/docs/.gitignore
vendored
Normal file
8
apps/docs/.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
content/reference
|
||||
api-content.json
|
||||
content.db
|
||||
.env
|
||||
.next
|
||||
.vercel
|
||||
.envrc
|
||||
llms*.txt
|
||||
5
apps/docs/.prettierignore
Normal file
5
apps/docs/.prettierignore
Normal file
@@ -0,0 +1,5 @@
|
||||
.next/*
|
||||
.lazy/*
|
||||
content.db
|
||||
node_modules
|
||||
utils/vector-db/index.json
|
||||
9
apps/docs/LICENSE.md
Normal file
9
apps/docs/LICENSE.md
Normal 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
167
apps/docs/README.md
Normal 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).
|
||||
139
apps/docs/app/(docs)/[...slug]/page.tsx
Normal file
139
apps/docs/app/(docs)/[...slug]/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
20
apps/docs/app/(docs)/layout.tsx
Normal file
20
apps/docs/app/(docs)/layout.tsx
Normal 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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
31
apps/docs/app/.well-known/agent-skills/index.json/route.ts
Normal file
31
apps/docs/app/.well-known/agent-skills/index.json/route.ts
Normal 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': '*' } }
|
||||
)
|
||||
}
|
||||
@@ -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': '*',
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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
114
apps/docs/app/analytics.tsx
Normal 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
138
apps/docs/app/globals.css
Normal 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
102
apps/docs/app/layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
72
apps/docs/app/not-found.tsx
Normal file
72
apps/docs/app/not-found.tsx
Normal 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
4
apps/docs/app/robots.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://www.tldraw.dev/sitemap.xml
|
||||
14
apps/docs/app/search/layout.tsx
Normal file
14
apps/docs/app/search/layout.tsx
Normal 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
|
||||
}
|
||||
19
apps/docs/app/search/page.tsx
Normal file
19
apps/docs/app/search/page.tsx
Normal 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
20
apps/docs/app/sitemap.ts
Normal 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
|
||||
}
|
||||
30
apps/docs/components/common/autorefresh.tsx
Normal file
30
apps/docs/components/common/autorefresh.tsx
Normal 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
|
||||
16
apps/docs/components/common/back-to-top-button.tsx
Normal file
16
apps/docs/components/common/back-to-top-button.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
88
apps/docs/components/common/button.tsx
Normal file
88
apps/docs/components/common/button.tsx
Normal 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
|
||||
}
|
||||
13
apps/docs/components/common/icon/discord.tsx
Normal file
13
apps/docs/components/common/icon/discord.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
13
apps/docs/components/common/icon/github.tsx
Normal file
13
apps/docs/components/common/icon/github.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
30
apps/docs/components/common/icon/index.tsx
Normal file
30
apps/docs/components/common/icon/index.tsx
Normal 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} />
|
||||
}
|
||||
13
apps/docs/components/common/icon/linkedin.tsx
Normal file
13
apps/docs/components/common/icon/linkedin.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
16
apps/docs/components/common/icon/tldraw.tsx
Normal file
16
apps/docs/components/common/icon/tldraw.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
13
apps/docs/components/common/icon/twitter.tsx
Normal file
13
apps/docs/components/common/icon/twitter.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
46
apps/docs/components/common/logo.tsx
Normal file
46
apps/docs/components/common/logo.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
22
apps/docs/components/common/navigation.tsx
Normal file
22
apps/docs/components/common/navigation.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
20
apps/docs/components/common/page-title.tsx
Normal file
20
apps/docs/components/common/page-title.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
43
apps/docs/components/common/theme-switch.tsx
Normal file
43
apps/docs/components/common/theme-switch.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
69
apps/docs/components/common/tldraw-link.tsx
Normal file
69
apps/docs/components/common/tldraw-link.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
3
apps/docs/components/content/api-heading.tsx
Normal file
3
apps/docs/components/content/api-heading.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export function ApiHeading(props: any) {
|
||||
return <h4 className="!-mb-4 !mt-8 uppercase text-sm font-semibold" {...props} />
|
||||
}
|
||||
228
apps/docs/components/content/bento.tsx
Normal file
228
apps/docs/components/content/bento.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
5
apps/docs/components/content/blockquote.tsx
Normal file
5
apps/docs/components/content/blockquote.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Callout } from './callout'
|
||||
|
||||
export function Blockquote(props: any) {
|
||||
return <Callout>{props.children}</Callout>
|
||||
}
|
||||
44
apps/docs/components/content/callout.tsx
Normal file
44
apps/docs/components/content/callout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
11
apps/docs/components/content/check-item.tsx
Normal file
11
apps/docs/components/content/check-item.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
82
apps/docs/components/content/code-files.tsx
Normal file
82
apps/docs/components/content/code-files.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
180
apps/docs/components/content/code.tsx
Normal file
180
apps/docs/components/content/code.tsx
Normal 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} />
|
||||
}
|
||||
39
apps/docs/components/content/copy-button.tsx
Normal file
39
apps/docs/components/content/copy-button.tsx
Normal 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}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
66
apps/docs/components/content/embed.tsx
Normal file
66
apps/docs/components/content/embed.tsx
Normal 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"
|
||||
/>
|
||||
)
|
||||
}
|
||||
23
apps/docs/components/content/example.tsx
Normal file
23
apps/docs/components/content/example.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
23
apps/docs/components/content/feature.tsx
Normal file
23
apps/docs/components/content/feature.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
27
apps/docs/components/content/image.tsx
Normal file
27
apps/docs/components/content/image.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
117
apps/docs/components/content/index.tsx
Normal file
117
apps/docs/components/content/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
17
apps/docs/components/content/masked-article-image.tsx
Normal file
17
apps/docs/components/content/masked-article-image.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function ParametersTableDescription({ children }: { children: React.ReactNode }) {
|
||||
return <td>{children}</td>
|
||||
}
|
||||
3
apps/docs/components/content/parameters-table-name.tsx
Normal file
3
apps/docs/components/content/parameters-table-name.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export function ParametersTableName({ children }: { children: React.ReactNode }) {
|
||||
return <td>{children}</td>
|
||||
}
|
||||
3
apps/docs/components/content/parameters-table-row.tsx
Normal file
3
apps/docs/components/content/parameters-table-row.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export function ParametersTableRow({ children }: { children: React.ReactNode }) {
|
||||
return <tr>{children}</tr>
|
||||
}
|
||||
15
apps/docs/components/content/parameters-table.tsx
Normal file
15
apps/docs/components/content/parameters-table.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
32
apps/docs/components/content/pre.tsx
Normal file
32
apps/docs/components/content/pre.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
10
apps/docs/components/content/side-by-side-images.tsx
Normal file
10
apps/docs/components/content/side-by-side-images.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
54
apps/docs/components/content/title-with-source-link.tsx
Normal file
54
apps/docs/components/content/title-with-source-link.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
40
apps/docs/components/content/video.tsx
Normal file
40
apps/docs/components/content/video.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
26
apps/docs/components/content/youtube.tsx
Normal file
26
apps/docs/components/content/youtube.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
40
apps/docs/components/docs/copy-markdown-button.tsx
Normal file
40
apps/docs/components/docs/copy-markdown-button.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
31
apps/docs/components/docs/docs-article-info.tsx
Normal file
31
apps/docs/components/docs/docs-article-info.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
58
apps/docs/components/docs/docs-category-menu.tsx
Normal file
58
apps/docs/components/docs/docs-category-menu.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
142
apps/docs/components/docs/docs-feedback-widget.tsx
Normal file
142
apps/docs/components/docs/docs-feedback-widget.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
45
apps/docs/components/docs/docs-footer.tsx
Normal file
45
apps/docs/components/docs/docs-footer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
97
apps/docs/components/docs/docs-header.tsx
Normal file
97
apps/docs/components/docs/docs-header.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
50
apps/docs/components/docs/docs-mobile-sidebar.tsx
Normal file
50
apps/docs/components/docs/docs-mobile-sidebar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
17
apps/docs/components/docs/docs-search-bar.tsx
Normal file
17
apps/docs/components/docs/docs-search-bar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
93
apps/docs/components/docs/docs-sidebar-menu.tsx
Normal file
93
apps/docs/components/docs/docs-sidebar-menu.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
56
apps/docs/components/docs/docs-sidebar-menus.tsx
Normal file
56
apps/docs/components/docs/docs-sidebar-menus.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
53
apps/docs/components/docs/docs-sidebar-utils.ts
Normal file
53
apps/docs/components/docs/docs-sidebar-utils.ts
Normal 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
|
||||
}
|
||||
25
apps/docs/components/docs/docs-sidebar.tsx
Normal file
25
apps/docs/components/docs/docs-sidebar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
31
apps/docs/components/docs/docs-starter-sidebar.tsx
Normal file
31
apps/docs/components/docs/docs-starter-sidebar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
18
apps/docs/components/docs/docs-table-of-contents.tsx
Normal file
18
apps/docs/components/docs/docs-table-of-contents.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
93
apps/docs/components/navigation/footer.tsx
Normal file
93
apps/docs/components/navigation/footer.tsx
Normal 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">© {new Date().getFullYear()} tldraw</p>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
62
apps/docs/components/navigation/header.tsx
Normal file
62
apps/docs/components/navigation/header.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
53
apps/docs/components/navigation/headings-menu.tsx
Normal file
53
apps/docs/components/navigation/headings-menu.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
47
apps/docs/components/navigation/link.tsx
Normal file
47
apps/docs/components/navigation/link.tsx
Normal 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
|
||||
}
|
||||
19
apps/docs/components/navigation/social-link.tsx
Normal file
19
apps/docs/components/navigation/social-link.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
129
apps/docs/components/search/ContentHighlight.tsx
Normal file
129
apps/docs/components/search/ContentHighlight.tsx
Normal 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 }
|
||||
}
|
||||
193
apps/docs/components/search/FullPageSearch.tsx
Normal file
193
apps/docs/components/search/FullPageSearch.tsx
Normal 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
|
||||
)
|
||||
}
|
||||
107
apps/docs/components/search/SearchButton.tsx
Normal file
107
apps/docs/components/search/SearchButton.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
192
apps/docs/components/search/SearchDialog.tsx
Normal file
192
apps/docs/components/search/SearchDialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
43
apps/docs/components/search/index.tsx
Normal file
43
apps/docs/components/search/index.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
11
apps/docs/content/community/contributing.mdx
Normal file
11
apps/docs/content/community/contributing.mdx
Normal file
@@ -0,0 +1,11 @@
|
||||
---
|
||||
title: Contributing
|
||||
status: published
|
||||
author: steveruizok
|
||||
date: 3/22/2023
|
||||
order: 1
|
||||
---
|
||||
|
||||
tldraw's source code is available on GitHub at [github.com/tldraw/tldraw](https://github.com/tldraw/tldraw). While you can read the code, we are not accepting external contributions.
|
||||
|
||||
The best way to help improve tldraw is to share your feedback. To report a bug or request a feature, [open an issue](https://github.com/tldraw/tldraw/issues) on GitHub or let us know on [Discord](https://discord.tldraw.com/?utm_source=docs&utm_medium=organic&utm_campaign=sociallink). If a code example would help the discussion, please fork the repository and link to your branch in the issue.
|
||||
71
apps/docs/content/community/dependencies.mdx
Normal file
71
apps/docs/content/community/dependencies.mdx
Normal file
@@ -0,0 +1,71 @@
|
||||
---
|
||||
title: Dependencies
|
||||
description: The third-party packages that the tldraw SDK depends on, and the licenses they are published under.
|
||||
status: published
|
||||
order: 3
|
||||
keywords:
|
||||
- license
|
||||
- licenses
|
||||
- dependencies
|
||||
- third party
|
||||
- open source
|
||||
- compliance
|
||||
---
|
||||
|
||||
{/* This page is generated by `yarn license-report --prod`. Do not edit it by hand. */}
|
||||
|
||||
The tldraw SDK itself is published under the [tldraw license](/legal/tldraw-license). It also depends on the third-party packages listed below, each under its own license.
|
||||
|
||||
This covers the runtime dependencies that the `@tldraw/*` packages declare directly. It does not include their own transitive dependencies, our development and build tooling, or anything in the tldraw.com application. If you need a full transitive list for the exact versions you have installed, generate one from your own lockfile.
|
||||
|
||||
## Dependencies
|
||||
|
||||
| Dependency | Version | License | Used by |
|
||||
| -------------------------------------------------------------------- | ------- | ------- | -------------------------------------------- |
|
||||
| [@tiptap/core](https://github.com/ueberdosis/tiptap) | 3.13.0 | MIT | `commenting`, `editor`, `mentions`, `tldraw` |
|
||||
| [@tiptap/extension-code](https://github.com/ueberdosis/tiptap) | 3.13.0 | MIT | `tldraw` |
|
||||
| [@tiptap/extension-highlight](https://github.com/ueberdosis/tiptap) | 3.13.0 | MIT | `tldraw` |
|
||||
| [@tiptap/extension-list](https://github.com/ueberdosis/tiptap) | 3.13.0 | MIT | `tldraw` |
|
||||
| [@tiptap/extension-mention](https://github.com/ueberdosis/tiptap) | 3.13.0 | MIT | `mentions` |
|
||||
| [@tiptap/extension-typography](https://github.com/ueberdosis/tiptap) | 3.13.0 | MIT | `tldraw` |
|
||||
| [@tiptap/pm](https://github.com/ueberdosis/tiptap) | 3.13.0 | MIT | `commenting`, `editor`, `mentions`, `tldraw` |
|
||||
| [@tiptap/react](https://github.com/ueberdosis/tiptap) | 3.13.0 | MIT | `commenting`, `editor`, `mentions`, `tldraw` |
|
||||
| [@tiptap/starter-kit](https://github.com/ueberdosis/tiptap) | 3.13.0 | MIT | `tldraw` |
|
||||
| [@tiptap/suggestion](https://github.com/ueberdosis/tiptap) | 3.13.0 | MIT | `mentions` |
|
||||
| [classnames](https://github.com/JedWatson/classnames) | 2.5.1 | MIT | `editor`, `tldraw` |
|
||||
| [eventemitter3](https://github.com/primus/eventemitter3) | 4.0.7 | MIT | `editor` |
|
||||
| [idb](https://github.com/jakearchibald/idb) | 7.1.1 | ISC | `editor`, `tldraw` |
|
||||
| [is-plain-object](https://github.com/jonschlinkert/is-plain-object) | 5.0.0 | MIT | `editor` |
|
||||
| [lodash.isequal](https://github.com/lodash/lodash) | 4.5.0 | MIT | `utils` |
|
||||
| [lodash.isequalwith](https://github.com/lodash/lodash) | 4.4.0 | MIT | `utils` |
|
||||
| [lodash.throttle](https://github.com/lodash/lodash) | 4.1.1 | MIT | `utils` |
|
||||
| [lodash.uniq](https://github.com/lodash/lodash) | 4.5.0 | MIT | `utils` |
|
||||
| [lz-string](https://github.com/pieroxy/lz-string) | 1.5.0 | MIT | `tldraw` |
|
||||
| [mermaid](https://github.com/mermaid-js/mermaid) | 11.16.1 | MIT | `mermaid` |
|
||||
| [nanoevents](https://github.com/ai/nanoevents) | 7.0.1 | MIT | `sync`, `sync-core` |
|
||||
| [radix-ui](https://github.com/radix-ui/primitives) | 1.4.2 | MIT | `tldraw` |
|
||||
| [rbush](https://github.com/mourner/rbush) | 3.0.1 | MIT | `editor` |
|
||||
| [ws](https://github.com/websockets/ws) | 8.21.0 | MIT | `sync`, `sync-core` |
|
||||
|
||||
## Inline code
|
||||
|
||||
Some third-party code is included directly in our source rather than as a dependency. Each of these files carries its license in a comment at the top.
|
||||
|
||||
| File | License | Copyright |
|
||||
| ---------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
|
||||
| `packages/create-tldraw/src/group-select.ts` | [MIT](https://github.com/bombshell-dev/clack/blob/1adb270af5509ead3d22c4fa602b465f39c7a8c2/packages/prompts/LICENSE) | Nate Moore |
|
||||
| `packages/create-tldraw/src/wrap-ansi.ts` | [MIT](https://github.com/chalk/wrap-ansi/blob/163b878a6eb5d8c32b7bbea65036eeadc0e4def9/license) | Sindre Sorhus <sindresorhus@gmail.com> (https://sindresorhus.com) |
|
||||
| `packages/editor/src/lib/hooks/useScreenBounds.ts` | [MIT](https://github.com/excalidraw/excalidraw/blob/master/LICENSE) | |
|
||||
| `packages/editor/src/lib/hooks/useViewportHeight.ts` | [BSD](https://github.com/outline/rich-markdown-editor/blob/main/LICENSE) | 2020 General Outline, Inc (https://www.getoutline.com/) and individual contributors. |
|
||||
| `packages/editor/src/lib/utils/browserCanvasMaxSize.ts` | [MIT](https://github.com/jhildenbiddle/canvas-size/blob/master/LICENSE) | John Hildenbiddle |
|
||||
| `packages/store/src/lib/ImmutableMap.ts` | [MIT](https://github.com/immutable-js/immutable-js/blob/main/LICENSE) | 2014-present, Lee Byron and other contributors. |
|
||||
| `packages/tldraw/src/lib/shapes/shared/PathBuilder.tsx` | [Apache](https://github.com/adobe-webplatform/Snap.svg/blob/master/LICENSE) | |
|
||||
| `packages/tldraw/src/lib/ui/hooks/useKeyboardShortcuts.ts` | [MIT](https://github.com/jaywcjlove/hotkeys-js/blob/master/LICENSE) | 2015-present, Kenny Wong |
|
||||
| `packages/tldraw/src/lib/ui/kbd-utils.ts` | [MIT](https://github.com/jaywcjlove/hotkeys-js/blob/master/LICENSE) | 2015-present, Kenny Wong |
|
||||
| `packages/tldraw/src/lib/utils/svg/sanitizeSvg.ts` | MIT | 2015 Mario Heiderich |
|
||||
| `packages/utils/src/lib/bind.ts` | [MIT](https://github.com/NoHomey/bind-decorator/blob/master/License) | 2016 Ivo Stratev |
|
||||
| `packages/utils/src/lib/id.ts` | [MIT](https://github.com/ai/nanoid/blob/main/LICENSE) | 2017 Andrey Sitnik <andrey@sitnik.ru> |
|
||||
| `packages/utils/src/lib/media/apng.ts` | [MIT](https://github.com/vHeemstra/is-apng/blob/main/license) | Philip van Heemstra |
|
||||
| `packages/utils/src/lib/media/gif.ts` | MIT | 2016 Józef Sokołowski <j.k.sokolowski@gmail.com> |
|
||||
| `packages/utils/src/lib/media/png.ts` | [MIT](https://github.com/alexgorbatchev/crc/blob/master/LICENSE) | 2014 Alex Gorbatchev |
|
||||
| `packages/utils/src/lib/media/webp.ts` | [MIT](https://github.com/sindresorhus/is-webp/blob/main/license) | Sindre Sorhus <sindresorhus@gmail.com> (https://sindresorhus.com) |
|
||||
61
apps/docs/content/community/license.mdx
Normal file
61
apps/docs/content/community/license.mdx
Normal file
@@ -0,0 +1,61 @@
|
||||
---
|
||||
title: License
|
||||
status: published
|
||||
author: steveruizok
|
||||
date: 9/13/2023
|
||||
order: 2
|
||||
---
|
||||
|
||||
The tldraw SDK's source code and published packages are provided under the [tldraw license](/legal/tldraw-license).
|
||||
|
||||
Under its default terms, the tldraw SDK license permits use **only in development**.
|
||||
|
||||
To use the tldraw SDK **in production**, you need one of the following:
|
||||
|
||||
- a [trial license](#Trial-license) for evaluation
|
||||
- a [commercial license](#Commercial-license) for commercial projects
|
||||
- a [hobby license](#Hobby-license) for non-commercial projects
|
||||
|
||||
Trial, commercial, and hobby licenses each come with a license key. The SDK will work in production only when provided with a valid and active license key. See the [License keys](#License-keys) section for more information.
|
||||
|
||||
### Trial license
|
||||
|
||||
You can get a free 100-day trial license by completing [this form](https://tldraw.dev/get-a-license/trial). When you submit the form, we will immediately begin your free trial and email you a [license key](#License-keys). You are allowed only one trial license per commercial unit. Please do not abuse trial licenses and tell your friends not to, either. During your trial, the SDK [collects information](#Data-collection) about where it is used.
|
||||
|
||||
The license key will stop working when the trial ends. If you wish to continue using tldraw in production after this date, you will need to get either a [commercial license](#Commercial-license) or a [hobby license](#Hobby-license).
|
||||
|
||||
### Commercial license
|
||||
|
||||
You can request a commercial license by completing [this form](https://tldraw.dev/get-a-license/plans). When you submit the form, our sales team will be in touch to learn about your requirements and discuss pricing. Startup pricing may be available for small teams. Check our [pricing page](https://tldraw.dev/pricing) for the latest.
|
||||
|
||||
### Hobby license
|
||||
|
||||
For non-commercial projects, we also provide a discretionary hobby license. You can request a hobby license by completing [this form](https://tldraw.dev/get-a-license/hobby). When you submit the form, our team may issue a license or reach out to learn about your project. When using the tldraw SDK under a hobby license, the "made with tldraw" watermark must be shown on the canvas.
|
||||
|
||||
## License keys
|
||||
|
||||
The tldraw SDK enforces its license using **license keys**.
|
||||
|
||||
When you receive a trial, commercial, or hobby license, you will also receive a public license key that encodes information about your license. You can provide this license key to the SDK as shown below.
|
||||
|
||||
```tsx
|
||||
function App() {
|
||||
return <Tldraw licenseKey="tldraw-***********************" />
|
||||
}
|
||||
```
|
||||
|
||||
License keys are validated **on the client**. You can use them offline. They can be public. The tldraw SDK will not work in production without a valid license key.
|
||||
|
||||
## Data collection
|
||||
|
||||
When using the tldraw SDK under a commercial or hobby license, no information is sent to tldraw.
|
||||
|
||||
When using the tldraw SDK in production under a trial license, the tldraw SDK will ping tldraw's servers with a hash of the license key. This information is primarily for analytics purposes. No user data, canvas contents, or personally-identifiable information (PII) is sent or saved.
|
||||
|
||||
## Notes on open source
|
||||
|
||||
While the tldraw SDK is [source available](https://github.com/tldraw/tldraw), it is not permissively licensed. Many of our examples and demos are available under the MIT license, and you should feel free to use this code as if it were your own; however, any source code or packages covered by the tldraw SDK license would not be [Open Source](https://opensource.org/osd) by any definition.
|
||||
|
||||
If you wish to include tldraw in an open source project, you may do so but the SDK itself must remain under its original license. This means that you and your downstream users will require their own trial, commercial, or hobby license in order to use the SDK in production. In this case, please also see our [trademark guidelines](https://tldraw.dev/legal/trademark-guidelines) that limit the use of the tldraw name and branding.
|
||||
|
||||
The tldraw SDK is a product of a company. See [tldraw.dev](https://tldraw.dev/company) for more information about the company and the team.
|
||||
13
apps/docs/content/community/translations.mdx
Normal file
13
apps/docs/content/community/translations.mdx
Normal file
@@ -0,0 +1,13 @@
|
||||
---
|
||||
title: Translations
|
||||
status: published
|
||||
author: steveruizok
|
||||
date: 11/7/2023
|
||||
order: 0
|
||||
---
|
||||
|
||||
The tldraw [user interface](/docs/user-interface) is translated into more than forty languages. Where a key's translation is missing in the user's current language, the default (English) translation is used instead.
|
||||
|
||||
We manage our translations through [Lokalise](https://lokalise.com).
|
||||
|
||||
To learn how the SDK detects and applies languages, see [Internationalization](/sdk-features/internationalization).
|
||||
249
apps/docs/content/docs/ai.mdx
Normal file
249
apps/docs/content/docs/ai.mdx
Normal file
@@ -0,0 +1,249 @@
|
||||
---
|
||||
title: AI integrations
|
||||
status: published
|
||||
author: steveruizok
|
||||
date: 1/31/2026
|
||||
order: 7
|
||||
keywords:
|
||||
- ai
|
||||
- llm
|
||||
- agent
|
||||
- machine learning
|
||||
- automation
|
||||
- generative
|
||||
---
|
||||
|
||||
You can use language models to read the tldraw canvas and to create shapes on it. This page covers three patterns for doing that: using the canvas as an output surface for generated content, building visual workflows where AI models are nodes in a graph, and giving an agent direct control of the editor. It also covers exporting canvas content so a model can read it.
|
||||
|
||||
If you're feeding the docs themselves to a model, see [LLM documentation](/docs/llm-docs). To turn model-generated diagram text into shapes, see [Mermaid diagrams](/docs/mermaid). To let an agent simulate user input instead of calling editor methods, see [Driving the editor](/docs/driver).
|
||||
|
||||
## Canvas as output
|
||||
|
||||
The simplest AI integration uses the canvas as a surface for displaying generated content. When an AI model generates an image, website preview, or other visual output, you can place it on the canvas as a shape.
|
||||
|
||||
### Embedding generated content
|
||||
|
||||
Use the [EmbedShapeUtil](?) to display websites, or create custom shapes to render generated images and HTML content:
|
||||
|
||||
```tsx
|
||||
editor.createShape({
|
||||
type: 'embed',
|
||||
x: 100,
|
||||
y: 100,
|
||||
props: {
|
||||
url: 'https://generated-preview.example.com/abc123',
|
||||
w: 800,
|
||||
h: 600,
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Embeds from hosts that aren't in the util's embed definitions render in a restricted sandbox. To allow a host of your own, use `EmbedShapeUtil.configure({ embedDefinitions })`. For content you generate yourself, a custom shape (below) is usually the better fit.
|
||||
|
||||
For generated images, use the [AssetRecordType](?) to create an asset from a blob or URL, then create an image shape:
|
||||
|
||||
```tsx
|
||||
const asset = AssetRecordType.create({
|
||||
id: AssetRecordType.createId(),
|
||||
type: 'image',
|
||||
props: {
|
||||
src: generatedImageUrl,
|
||||
w: 512,
|
||||
h: 512,
|
||||
mimeType: 'image/png',
|
||||
name: 'generated-image.png',
|
||||
isAnimated: false,
|
||||
},
|
||||
})
|
||||
|
||||
editor.createAssets([asset])
|
||||
editor.createShape({
|
||||
type: 'image',
|
||||
x: 100,
|
||||
y: 100,
|
||||
props: {
|
||||
assetId: asset.id,
|
||||
w: 512,
|
||||
h: 512,
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Custom preview shapes
|
||||
|
||||
For richer AI output, create a custom shape that renders generated content. This approach works well for live HTML previews, interactive prototypes, or any content that needs special rendering:
|
||||
|
||||
```tsx
|
||||
import { HTMLContainer, ShapeUtil } from 'tldraw'
|
||||
|
||||
// Abbreviated: a full ShapeUtil also needs getDefaultProps, getGeometry, and
|
||||
// getIndicatorPath. See the Shapes docs for a complete custom shape.
|
||||
class PreviewShapeUtil extends ShapeUtil<PreviewShape> {
|
||||
static override type = 'preview' as const
|
||||
|
||||
component(shape: PreviewShape) {
|
||||
return (
|
||||
<HTMLContainer>
|
||||
<iframe
|
||||
srcDoc={shape.props.html}
|
||||
sandbox="allow-scripts"
|
||||
style={{ width: '100%', height: '100%', border: 'none' }}
|
||||
/>
|
||||
</HTMLContainer>
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This pattern is useful for "make real" style applications where users sketch a UI and an AI model generates working code to preview alongside the original drawing. See [Shapes](/docs/shapes) for how to write the rest of the util.
|
||||
|
||||
## Visual workflows
|
||||
|
||||
The tldraw binding system enables node-based visual programming where AI models can be part of a larger workflow. Shapes represent operations or data sources, and bindings connect them to form processing pipelines.
|
||||
|
||||
### Workflow architecture
|
||||
|
||||
In a visual workflow, each node is a custom shape with input and output ports. Connections between nodes are bindings that track relationships as shapes move. When data flows through the system, each node processes its inputs and produces outputs for downstream nodes.
|
||||
|
||||
See the [Workflow starter kit](/starter-kits/workflow) for a complete implementation of this pattern. The starter kit includes:
|
||||
|
||||
- Custom node shapes with configurable ports
|
||||
- A binding system for smart connections that update as nodes move
|
||||
- An execution engine that resolves dependencies and runs nodes in order
|
||||
- Tools for creating and managing connections
|
||||
|
||||
### Adding AI to workflows
|
||||
|
||||
To add AI capabilities to a workflow, create node types that call AI models:
|
||||
|
||||
```tsx
|
||||
// Abbreviated: a real NodeDefinition also declares a validator, ports, a default
|
||||
// value, and a body height. See the workflow starter kit for the full interface.
|
||||
class LLMNode extends NodeDefinition<LLMNodeData> {
|
||||
static type = 'llm'
|
||||
|
||||
async execute(shape, node, inputs) {
|
||||
const response = await fetch('/api/generate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ prompt: inputs.prompt }),
|
||||
})
|
||||
const result = await response.json()
|
||||
return { output: result.text }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Workflow systems let users compose AI operations visually: connect a prompt source to a model, route its output to other steps, and build a pipeline without writing code.
|
||||
|
||||
## AI agents
|
||||
|
||||
For full canvas control, you can give AI models direct access to read and manipulate shapes. An agent can observe what's on the canvas, understand spatial relationships, and create or modify shapes to accomplish tasks.
|
||||
|
||||
### Agent architecture
|
||||
|
||||
The [Agent starter kit](/starter-kits/agent) is a complete implementation. The agent gathers context from screenshots and structured shape data, applies the model's responses through a set of typed actions, and streams results onto the canvas as they arrive. Chat history carries across prompts.
|
||||
|
||||
### Using the agent programmatically
|
||||
|
||||
The agent exposes a simple API for triggering canvas operations:
|
||||
|
||||
```tsx
|
||||
// Inside a component wrapped by the starter kit's TldrawAgentAppProvider
|
||||
const agent = useAgent()
|
||||
|
||||
// Simple prompt
|
||||
agent.prompt('Draw a flowchart showing user authentication')
|
||||
|
||||
// With additional context
|
||||
agent.prompt({
|
||||
message: 'Add labels to these shapes',
|
||||
bounds: { x: 0, y: 0, w: 500, h: 400 },
|
||||
})
|
||||
```
|
||||
|
||||
### How agents see the canvas
|
||||
|
||||
The agent builds context from multiple sources:
|
||||
|
||||
- A screenshot of the current viewport
|
||||
- Simplified representations of shapes within view
|
||||
- Information about shape clusters outside the viewport
|
||||
- The user's current selection and recent actions
|
||||
- Conversation history from the session
|
||||
|
||||
### How agents manipulate the canvas
|
||||
|
||||
Agents perform operations through typed action schemas. Each action has a defined structure, and the agent system validates, sanitizes, and applies actions to the editor:
|
||||
|
||||
```tsx
|
||||
// Abbreviated from CreateActionUtil in the agent starter kit. The model describes
|
||||
// shapes in a simplified format that the util converts to a real shape record.
|
||||
class CreateActionUtil extends AgentActionUtil<CreateAction> {
|
||||
override applyAction(action: Streaming<CreateAction>, helpers: AgentHelpers) {
|
||||
const { shape } = action
|
||||
if (!shape || !shape._type) return
|
||||
|
||||
// Translate from the model's coordinate space back to the page
|
||||
const shapePartial = helpers.removeOffsetFromShapePartial(shape)
|
||||
const result = convertPartialFocusedShapeToTldrawShape(this.editor, shapePartial, {
|
||||
defaultShape: getDefaultShape(shape._type, action.complete),
|
||||
complete: action.complete,
|
||||
})
|
||||
if (!result.shape) return
|
||||
|
||||
this.editor.createShape(result.shape)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The sanitization layer handles common LLM mistakes. It corrects shape IDs that don't exist, ensures new IDs are unique, and normalizes coordinates.
|
||||
|
||||
## Reading canvas content
|
||||
|
||||
For applications where AI reads but doesn't modify the canvas, you can export canvas content for analysis.
|
||||
|
||||
### Screenshots
|
||||
|
||||
Use [Editor#getSvgString](?) or [Editor#toImage](?) to export the current view or specific regions:
|
||||
|
||||
```tsx
|
||||
const svg = await editor.getSvgString(editor.getCurrentPageShapes())
|
||||
const { blob } = await editor.toImage(editor.getCurrentPageShapes(), { format: 'png' })
|
||||
```
|
||||
|
||||
### Structured data
|
||||
|
||||
Access shape data directly from the store for text extraction or structured analysis. Use [ShapeUtil#getText](?) to extract a shape's text content as a plain string. It returns `undefined` for shapes with no text:
|
||||
|
||||
```tsx
|
||||
const shapes = editor.getCurrentPageShapes()
|
||||
|
||||
const textContent = shapes.map((shape) => ({
|
||||
id: shape.id,
|
||||
type: shape.type,
|
||||
text: editor.getShapeUtil(shape).getText(shape),
|
||||
bounds: editor.getShapePageBounds(shape),
|
||||
}))
|
||||
```
|
||||
|
||||
Sending both to the model works best: the image shows spatial relationships and styling, and the structured data gives exact text and positions.
|
||||
|
||||
## Starter kits
|
||||
|
||||
We provide several starter kits for AI integrations:
|
||||
|
||||
| Kit | Description |
|
||||
| ---------------------------------------------- | ---------------------------------------------------------------- |
|
||||
| [Agent](/starter-kits/agent) | Full agent system with visual context and canvas manipulation |
|
||||
| [Chat](/starter-kits/chat) | Canvas for sketching and annotation as context for chat |
|
||||
| [Branching chat](/starter-kits/branching-chat) | Visual conversation trees with AI responses |
|
||||
| [Workflow](/starter-kits/workflow) | Node-based visual programming that can incorporate AI operations |
|
||||
|
||||
## Related
|
||||
|
||||
- [LLM documentation](/docs/llm-docs) — Feeding the tldraw docs to a model
|
||||
- [Mermaid diagrams](/docs/mermaid) — Turning diagram text into shapes
|
||||
- [Driving the editor](/docs/driver) — Simulating user input programmatically
|
||||
- [Shapes](/docs/shapes) — Creating custom shapes for AI-generated content
|
||||
- [Bindings](/sdk-features/bindings) — Connecting shapes for workflow systems
|
||||
- [Image export](/sdk-features/image-export) — Exporting canvas content for AI analysis
|
||||
63
apps/docs/content/docs/assets.mdx
Normal file
63
apps/docs/content/docs/assets.mdx
Normal file
@@ -0,0 +1,63 @@
|
||||
---
|
||||
title: Assets
|
||||
status: published
|
||||
author: steveruizok
|
||||
date: 6/9/2023
|
||||
order: 5
|
||||
keywords:
|
||||
- image
|
||||
- video
|
||||
- file
|
||||
- images
|
||||
- videos
|
||||
- files
|
||||
---
|
||||
|
||||
Assets are records that store data about shared resources like images and videos. Shapes reference assets by ID rather than embedding files directly, so you can reuse the same image across multiple shapes without duplicating data.
|
||||
|
||||
For the complete guide to working with assets, see the [Assets](/sdk-features/assets) reference.
|
||||
|
||||
## Default storage behavior
|
||||
|
||||
[TLAssetStore](?) controls how assets are uploaded and resolved. The default depends on your store setup:
|
||||
|
||||
| Setup | Asset store | Where files go |
|
||||
| --------------------------------------------- | -------------------------------------------- | ----------------------------------------------------------------------------------------- |
|
||||
| In-memory only (default) | [inlineBase64AssetStore](?) | Data URLs inside the document |
|
||||
| [`persistenceKey`](/sdk-features/persistence) | Built in, overridable with the `assets` prop | The browser's [IndexedDB](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API) |
|
||||
| [Sync server](/docs/sync) | Your own `TLAssetStore`, passed to `useSync` | A storage service like S3 or R2 |
|
||||
|
||||
To use your own storage, implement `upload` and `resolve` and pass the store to `<Tldraw>` (or to `useSync`):
|
||||
|
||||
```tsx
|
||||
import { Tldraw, TLAssetStore } from 'tldraw'
|
||||
import 'tldraw/tldraw.css'
|
||||
|
||||
const myAssetStore: TLAssetStore = {
|
||||
async upload(asset, file) {
|
||||
const url = await uploadToMyServer(file)
|
||||
return { src: url }
|
||||
},
|
||||
resolve(asset) {
|
||||
return asset.props.src
|
||||
},
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0 }}>
|
||||
<Tldraw assets={myAssetStore} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
`resolve` also receives a [TLAssetContext](?) with the current screen scale and DPR, so you can serve a resized image instead of the original. Pasted and dropped files go through the [external content](/sdk-features/external-content) handlers before they become assets.
|
||||
|
||||
## Examples
|
||||
|
||||
- [Using hosted images](/examples/data/assets/hosted-images) — Reference images by URL without uploading them
|
||||
- [Customizing default asset options](/examples/configuration/asset-props) — Size and dimension limits for uploaded assets
|
||||
- [Handling pasted/dropped external content](/examples/data/assets/external-content-sources) — Turn pasted content into assets
|
||||
- [Simple asset store with server upload](https://github.com/tldraw/tldraw/blob/main/templates/simple-server-example/src/client/App.tsx) — Upload files to a Node server
|
||||
- [Asset store with image optimization](https://github.com/tldraw/tldraw/blob/main/packages/sync/src/useSyncDemo.ts) — The demo server's `createDemoAssetStore`, which resolves resized images
|
||||
105
apps/docs/content/docs/collaboration.mdx
Normal file
105
apps/docs/content/docs/collaboration.mdx
Normal file
@@ -0,0 +1,105 @@
|
||||
---
|
||||
title: Collaboration
|
||||
status: published
|
||||
author: alex
|
||||
date: 7/24/2024
|
||||
order: 6
|
||||
keywords:
|
||||
- data
|
||||
- sync
|
||||
- persistence
|
||||
- database
|
||||
- multiplayer
|
||||
- collaboration
|
||||
- server
|
||||
- websockets
|
||||
---
|
||||
|
||||
The tldraw SDK supports real-time collaboration. Add collaboration to your project with our [tldraw sync](/docs/sync) library, or use our low-level data APIs to integrate other backends.
|
||||
|
||||
<MaskedArticleImage
|
||||
gifSrc="/images/multiplayer.gif"
|
||||
maskSrc="/images/multiplayer_mask.png"
|
||||
alt="Multiplayer example using tldraw sync"
|
||||
/>
|
||||
|
||||
## Quick start
|
||||
|
||||
The fastest way to add multiplayer is with the `useSyncDemo` hook from [`@tldraw/sync`](/docs/sync). This connects to a hosted demo server that handles synchronization.
|
||||
|
||||
```bash
|
||||
npm install @tldraw/sync
|
||||
```
|
||||
|
||||
```tsx
|
||||
import { Tldraw } from 'tldraw'
|
||||
import { useSyncDemo } from '@tldraw/sync'
|
||||
import 'tldraw/tldraw.css'
|
||||
|
||||
export default function App() {
|
||||
const store = useSyncDemo({ roomId: 'my-company-my-unique-room-id' })
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0 }}>
|
||||
<Tldraw store={store} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Any apps connecting to the same room ID will enter a shared collaboration session. The room ID namespace is shared by everyone using the demo server, so prefix it with your company or project name. Open your project in an incognito window or different browser to test it out.
|
||||
|
||||
The demo server is great for prototyping, but data is deleted after about a day and rooms are publicly accessible. For production, you'll need to self-host the tldraw sync server. See our full guide on [tldraw sync](/docs/sync).
|
||||
|
||||
## Comments
|
||||
|
||||
Collaborators can also leave anchored comments on the canvas. The `@tldraw/commenting` package provides a comment tool and pins that stick to points and shapes. Each pin opens a thread popover with replies, mentions, reactions, and resolving, and a sidebar lists every thread.
|
||||
|
||||
```tsx
|
||||
import {
|
||||
CanvasComments,
|
||||
CommentAuthor,
|
||||
commentToolOverrides,
|
||||
commentTools,
|
||||
} from '@tldraw/commenting'
|
||||
import { useSync } from '@tldraw/sync'
|
||||
import { commentSchemaRecords, TLComponents, Tldraw } from 'tldraw'
|
||||
import '@tldraw/commenting/commenting.css'
|
||||
|
||||
// Your app's user directory, however you already hold it.
|
||||
const AUTHORS: Record<string, CommentAuthor> = {
|
||||
'user-123': { name: 'You', color: '#EC5E41' },
|
||||
}
|
||||
|
||||
const components: TLComponents = {
|
||||
InFrontOfTheCanvas: () => (
|
||||
<CanvasComments currentUserId="user-123" resolveAuthor={(id) => AUTHORS[id]} />
|
||||
),
|
||||
}
|
||||
|
||||
function MyApp() {
|
||||
const store = useSync({
|
||||
uri: 'wss://your-server.com/sync/my-room',
|
||||
assets: myAssetStore,
|
||||
records: commentSchemaRecords,
|
||||
})
|
||||
|
||||
return (
|
||||
<Tldraw
|
||||
store={store}
|
||||
tools={commentTools}
|
||||
overrides={[commentToolOverrides]}
|
||||
components={components}
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Comment records are opt-in, so register `commentSchemaRecords` on your server's schema too, and list the comment record types in the room's `objectTypes` (see [Comments and the object-store lane](/docs/sync#Comments-and-the-object-store-lane)). Commenting is a licensed feature. See the [commenting overview](/docs/commenting) for an introduction, or the [commenting reference](/sdk-features/commenting) for anchors, options, and server setup.
|
||||
|
||||
## Using other backends
|
||||
|
||||
While [tldraw sync](/docs/sync) is our recommended solution, the tldraw SDK works with any real-time data backend. For example, [Liveblocks offers a tldraw integration](https://liveblocks.io/examples/tldraw-whiteboard), and many teams have connected tldraw to their own infrastructure.
|
||||
|
||||
A custom backend has to handle a few things. Document changes need to get in and out of the [store](/sdk-features/store); see [Persistence](/docs/persistence) for the basics and the [collaboration deep dive](/sdk-features/collaboration) for building custom sync. Presence (cursor positions, selections, viewports) is shared through presence records, covered in the same deep dive; [Cursors](/sdk-features/cursors) explains how to customize collaborator cursors, and [User following](/sdk-features/user-following) covers tracking another user's viewport.
|
||||
|
||||
The visual side is handled by the default UI. Override components like `SharePanel` through [UI components](/sdk-features/ui-components). The [OverlayUtil](?) system renders collaborator cursors, brushes, scribbles, and selection indicators; see [Overlay utils](/sdk-features/overlay-utils).
|
||||
141
apps/docs/content/docs/commenting.mdx
Normal file
141
apps/docs/content/docs/commenting.mdx
Normal file
@@ -0,0 +1,141 @@
|
||||
---
|
||||
title: Commenting
|
||||
status: published
|
||||
author: steveruizok
|
||||
date: 7/27/2026
|
||||
order: 7
|
||||
keywords:
|
||||
- comment
|
||||
- comments
|
||||
- commenting
|
||||
- thread
|
||||
- annotation
|
||||
- feedback
|
||||
- review
|
||||
- collaboration
|
||||
---
|
||||
|
||||
In tldraw, a comment is a message pinned to a place on the canvas. Comments group into threads, one conversation per pin. Every comment records who wrote it, as an id your app resolves to a name.
|
||||
|
||||
The `@tldraw/commenting` package works at two levels. `CanvasComments` is a comments layer you render in front of the canvas, and it handles the whole flow. Everything it's built from is exported too, so you can replace any part of it.
|
||||
|
||||
Commenting is a licensed feature. It runs in development without a key. In production it needs a tldraw license that includes commenting.
|
||||
|
||||
## Quick start
|
||||
|
||||
Three pieces: register the comment record types, register the comment tool, and render the layer.
|
||||
|
||||
```tsx
|
||||
import {
|
||||
CanvasComments,
|
||||
CommentAuthor,
|
||||
commentToolOverrides,
|
||||
commentTools,
|
||||
} from '@tldraw/commenting'
|
||||
import { useMemo } from 'react'
|
||||
import { commentSchemaRecords, createTLSchema, createTLStore, TLComponents, Tldraw } from 'tldraw'
|
||||
import '@tldraw/commenting/commenting.css'
|
||||
import 'tldraw/tldraw.css'
|
||||
|
||||
const AUTHORS: Record<string, CommentAuthor> = { me: { name: 'You', color: '#EC5E41' } }
|
||||
const resolveAuthor = (id: string) => AUTHORS[id]
|
||||
|
||||
const components: TLComponents = {
|
||||
InFrontOfTheCanvas: () => <CanvasComments currentUserId="me" resolveAuthor={resolveAuthor} />,
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const store = useMemo(
|
||||
() => createTLStore({ schema: createTLSchema({ records: commentSchemaRecords }) }),
|
||||
[]
|
||||
)
|
||||
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0 }}>
|
||||
<Tldraw
|
||||
licenseKey={YOUR_LICENSE_KEY}
|
||||
store={store}
|
||||
tools={commentTools}
|
||||
overrides={[commentToolOverrides]}
|
||||
components={components}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Pick the comment tool from Quick Actions, or press `C`, then click the canvas to start a thread.
|
||||
|
||||
The layer's two required inputs are both about identity. `currentUserId` is the id stamped on whatever the user posts. `resolveAuthor` turns an author id into a name, plus an optional color and avatar image. Commenting reads no user directory of its own, so these are where you connect it to whatever your app already knows about its users. Together with the read-status and mention callbacks they make up the `CommentingContext`, which the sidebar takes too.
|
||||
|
||||
## Comments are records
|
||||
|
||||
Comment threads and comments are records in the editor's store, exactly like shapes. They aren't in the default schema, so you opt in by registering `commentSchemaRecords`. Once you have, comments persist and sync however your document already does. Adding a `persistenceKey` or a sync backend carries them along with no extra work.
|
||||
|
||||
It also means you can read and write them yourself: query the store for threads, seed a document with review notes, or build a panel that does something the built-in sidebar doesn't. Comment records aren't part of the `TLRecord` union, so use the package's typed helpers and hooks rather than `editor.store` directly.
|
||||
|
||||
```tsx
|
||||
import { useCommentThreads } from '@tldraw/commenting'
|
||||
import { useEditor } from 'tldraw'
|
||||
|
||||
function OpenThreadCount() {
|
||||
const editor = useEditor()
|
||||
const threads = useCommentThreads(editor)
|
||||
return <div>{threads.filter((thread) => !thread.resolved).length} open threads</div>
|
||||
}
|
||||
```
|
||||
|
||||
## Anchors
|
||||
|
||||
What pins a thread to the canvas is its anchor. A thread can anchor to a point on the page, to a shape it then follows as that shape moves and resizes, to a rectangular region, or to the page as a whole.
|
||||
|
||||
Clicking empty canvas gives you a point anchor and clicking a shape gives you a shape anchor. Region anchors are off by default (`enableRegions`); with them on, dragging the tool out covers an area.
|
||||
|
||||
Shape-anchored threads outlive their shape. Delete the shape and the thread converts to a point anchor where its pin last sat, so the conversation doesn't disappear along with the thing it was about.
|
||||
|
||||
## What you get
|
||||
|
||||
The comments layer covers the whole flow out of the box:
|
||||
|
||||
| Feature | Description |
|
||||
| ------------ | ----------------------------------------------------------------------- |
|
||||
| Threads | Pins on the canvas, opening to replies, edit, resolve, and delete. |
|
||||
| Shortcuts | `C` picks the tool, `Shift+C` hides the pins, `Escape` closes a thread. |
|
||||
| Mentions | `@`-mentions in composers, resolved against a roster you supply. |
|
||||
| Reactions | Emoji reactions on comments, with a pluggable palette. |
|
||||
| A sidebar | A filterable list of threads beside the canvas. |
|
||||
| Clustering | Nearby pins fold into count badges as you zoom out. |
|
||||
| Unread state | Pin badges and an unread filter, driven by your app's read data. |
|
||||
|
||||
Each of these has options, and each visible piece is a component slot you can replace. See [Commenting](/sdk-features/commenting) for the full guide.
|
||||
|
||||
## Configuring
|
||||
|
||||
Commenting options live on the tool. `CommentTool.configure()` returns a configured subclass to register, mirroring `ShapeUtil.configure`:
|
||||
|
||||
```tsx
|
||||
import { CommentTool } from '@tldraw/commenting'
|
||||
|
||||
const tools = [CommentTool.configure({ enableRegions: true })]
|
||||
```
|
||||
|
||||
The option worth knowing about first is `history`. Comment writes default to `'ignore'`, which keeps them off the undo stack, and that default matters in a shared document: an undoable resolve would revert a thread a collaborator has since reopened. Deletes are never undoable, whatever `history` says. See [Comments and undo](/sdk-features/commenting#Comments-and-undo).
|
||||
|
||||
## Permissions
|
||||
|
||||
`canComment` decides whether the viewer may participate, and the UI follows it: composers give way to a fallback slot and the action affordances hide. `canModifyComment` decides the writes that belong to someone in particular: editing a comment, deleting a comment, deleting a thread. Unset, only the comment's author may edit or delete it, and only the thread's creator may delete the thread. A callback widens that, say for a workspace admin who may remove anyone's comment, or narrows it.
|
||||
|
||||
They're UI-level controls and nothing more. Comment records carry a client-supplied author id, so rules about who may post, edit, or delete belong on your sync server, which checks each incoming record against the session's identity.
|
||||
|
||||
## Syncing
|
||||
|
||||
Comments sync like the rest of your document, with one registration on each side. Pass `records: commentSchemaRecords` to your sync hook, and the same map to `createTLSchema` on the server.
|
||||
|
||||
On the server you can go a step further and serve comments through the room's object-store lane. Lane records are gated by their own per-session permission rather than by `isReadonly`, which is how a viewer who can't edit the document can still comment. See [Syncing comments](/sdk-features/commenting#Syncing-comments).
|
||||
|
||||
## Related
|
||||
|
||||
- [Commenting](/sdk-features/commenting) — The full guide: anchors, options, components, and sync
|
||||
- [Collaboration](/docs/collaboration) — Adding multiplayer to your project
|
||||
- [tldraw sync](/docs/sync) — Running a sync server
|
||||
- [Commenting example](/examples/collaboration/commenting) — The flow end to end
|
||||
120
apps/docs/content/docs/driver.mdx
Normal file
120
apps/docs/content/docs/driver.mdx
Normal file
@@ -0,0 +1,120 @@
|
||||
---
|
||||
title: Driving the editor
|
||||
status: published
|
||||
author: steveruizok
|
||||
date: 4/28/2026
|
||||
order: 8
|
||||
keywords:
|
||||
- driver
|
||||
- automation
|
||||
- scripting
|
||||
- testing
|
||||
- repl
|
||||
- imperative
|
||||
- input
|
||||
- simulation
|
||||
---
|
||||
|
||||
You can drive the tldraw editor programmatically with the `@tldraw/driver` package. It wraps an [Editor](?) with an imperative, fluent API for simulating user input, using only public editor methods. You can use it for scripting, automation, REPL sessions, and writing tests.
|
||||
|
||||
## Quick start
|
||||
|
||||
Install the package alongside `tldraw`:
|
||||
|
||||
```bash
|
||||
npm install @tldraw/driver
|
||||
```
|
||||
|
||||
Wrap an [Editor](?) instance with a [Driver](?) and dispatch input:
|
||||
|
||||
```tsx
|
||||
import { Driver } from '@tldraw/driver'
|
||||
import { Tldraw } from 'tldraw'
|
||||
import 'tldraw/tldraw.css'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0 }}>
|
||||
<Tldraw
|
||||
onMount={(editor) => {
|
||||
const driver = new Driver(editor)
|
||||
|
||||
driver.click(100, 200).pointerDown(300, 400).pointerMove(500, 600).pointerUp()
|
||||
driver.keyPress('a')
|
||||
|
||||
return () => driver.dispose()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Every input method returns `this`, so calls can be chained. Coordinates default to the current pointer position when omitted, which is why `pointerUp()` needs none. Call `dispose` when you're done so the driver can clean up its side-effect handlers.
|
||||
|
||||
## Simulating input
|
||||
|
||||
Pointer, keyboard, wheel, and pinch events all flow through `editor.dispatch`, so they go through the editor's normal tool state machines. A pointerDown, pointerMove, pointerUp sequence with the draw tool active creates real draw shapes:
|
||||
|
||||
```ts
|
||||
editor.setCurrentTool('draw')
|
||||
driver.pointerDown(100, 100)
|
||||
driver.pointerMove(150, 120)
|
||||
driver.pointerMove(200, 140)
|
||||
driver.pointerUp()
|
||||
```
|
||||
|
||||
Pointer coordinates are in screen space. For page-space positions, use `editor.pageToScreen` to convert before dispatching.
|
||||
|
||||
Pointer methods take two optional trailing arguments. The third is either a partial `TLPointerEventInfo` or a shape id, which targets that shape. The fourth overrides modifier keys for that one event:
|
||||
|
||||
```ts
|
||||
driver.click(100, 100, shapeId, { shiftKey: true })
|
||||
```
|
||||
|
||||
Modifiers pressed with `keyDown` carry into later pointer events, because the driver reads modifier state from `editor.inputs`:
|
||||
|
||||
```ts
|
||||
driver.keyDown('Shift')
|
||||
driver.click(100, 100, { target: 'canvas' })
|
||||
driver.keyUp('Shift')
|
||||
```
|
||||
|
||||
Each input method emits a tick after dispatching. Tools that do work over several frames may need more; call `forceTick(count)` to emit extra ticks.
|
||||
|
||||
## Manipulating the selection
|
||||
|
||||
Selection helpers work in page coordinates and convert to screen space internally, so you can move, rotate, and resize the current selection without computing pointer paths by hand:
|
||||
|
||||
```ts
|
||||
driver.translateSelection(50, 0)
|
||||
driver.rotateSelection(Math.PI / 4)
|
||||
driver.resizeSelection({ scaleX: 2 }, 'bottom_right')
|
||||
```
|
||||
|
||||
## Clipboard
|
||||
|
||||
The driver keeps its own in-memory clipboard, independent of the system clipboard. Useful for scripted copy, cut, and paste flows and testing:
|
||||
|
||||
```ts
|
||||
driver.copy() // copies the current selection
|
||||
driver.paste({ x: 400, y: 400 }) // page point; ignored while Shift is held
|
||||
```
|
||||
|
||||
## Queries
|
||||
|
||||
The driver registers an `editor.sideEffects` handler that records every shape created while it's attached, whatever created it, so you can grab the most recent result of a scripted action:
|
||||
|
||||
```ts
|
||||
const shape = driver.getLastCreatedShape()
|
||||
const lastFive = driver.getLastCreatedShapes(5)
|
||||
```
|
||||
|
||||
See [Driver](?) for the full list of query helpers: shape and selection page centers, rotations, and arrows bound to a shape.
|
||||
|
||||
## Related
|
||||
|
||||
- [Driver](?) — Full reference for every input, selection, clipboard, and query method
|
||||
- [Editor](?) — The editor class the driver wraps
|
||||
- [Shapes](/docs/shapes) — Defining shape types that driver-produced input will interact with
|
||||
- [Tools](/docs/tools) — Understanding the tool state machines that process simulated events
|
||||
141
apps/docs/content/docs/editor.mdx
Normal file
141
apps/docs/content/docs/editor.mdx
Normal file
@@ -0,0 +1,141 @@
|
||||
---
|
||||
title: Editor
|
||||
status: published
|
||||
author: steveruizok
|
||||
date: 3/22/2023
|
||||
order: 1
|
||||
keywords:
|
||||
- editor
|
||||
- control
|
||||
- api
|
||||
- class
|
||||
---
|
||||
|
||||
The [Editor](?) class is the main way of controlling tldraw's editor. You can use it to manage the editor's internal state, make changes to the document, or respond to changes that have occurred.
|
||||
|
||||
By design, the editor's surface area is very large. Almost everything is available through it. Need to create some shapes? Use [Editor#createShapes](?). Need to delete them? Use [Editor#deleteShapes](?). Need a sorted array of every shape on the current page? Use [Editor#getCurrentPageShapesSorted](?).
|
||||
|
||||
## Accessing the editor
|
||||
|
||||
You can access the editor in two ways:
|
||||
|
||||
### The onMount callback
|
||||
|
||||
The [Tldraw](?) component's `onMount` callback provides the editor as the first argument.
|
||||
|
||||
```tsx
|
||||
function App() {
|
||||
return (
|
||||
<Tldraw
|
||||
onMount={(editor) => {
|
||||
// your editor code here
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### The useEditor hook
|
||||
|
||||
The [useEditor](?) hook returns the editor instance. Call it from a component rendered inside [Tldraw](?) (or [TldrawEditor](?)).
|
||||
|
||||
```tsx
|
||||
function InsideOfContext() {
|
||||
const editor = useEditor()
|
||||
// your editor code here
|
||||
return null
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Tldraw>
|
||||
<InsideOfContext />
|
||||
</Tldraw>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
> If you're using the subcomponents as shown in [this example](/examples/configuration/exploded), the editor instance is provided by the [TldrawEditor](?) component.
|
||||
|
||||
## Reactive state
|
||||
|
||||
The editor's state is reactive. Methods like [Editor#getSelectedShapeIds](?) or [Editor#getCurrentPageShapes](?) return values that automatically update when the underlying data changes. You can use these values directly in React components with the [track](?) wrapper or [useValue](?) hook.
|
||||
|
||||
```tsx
|
||||
import { track, useEditor, useValue } from 'tldraw'
|
||||
|
||||
export const SelectedShapeIdsCount = track(() => {
|
||||
const editor = useEditor()
|
||||
return <div>{editor.getSelectedShapeIds().length}</div>
|
||||
})
|
||||
|
||||
export function CurrentTool() {
|
||||
const editor = useEditor()
|
||||
const toolId = useValue('current tool', () => editor.getCurrentToolId(), [editor])
|
||||
return <div>{toolId}</div>
|
||||
}
|
||||
```
|
||||
|
||||
See the [Signals](/sdk-features/signals) article for more on tldraw's reactive state system.
|
||||
|
||||
## Batching changes
|
||||
|
||||
Each change to the editor happens within a transaction. You can batch multiple changes into a single transaction using the [Editor#run](?) method. Batching groups the changes into a single undo step and reduces overhead for persisting or distributing changes.
|
||||
|
||||
```ts
|
||||
// myShapes is an array of shape partials, each with an id from createShapeId()
|
||||
editor.run(() => {
|
||||
editor.createShapes(myShapes)
|
||||
editor.sendToBack(myShapes.map((shape) => shape.id))
|
||||
editor.selectNone()
|
||||
})
|
||||
```
|
||||
|
||||
The `run` method also accepts options to control history and locked shape behavior. Set `history` to `'ignore'` to leave undo/redo alone, or `'record-preserveRedoStack'` to record without clearing the redo stack:
|
||||
|
||||
```ts
|
||||
// Make changes without affecting undo/redo history
|
||||
editor.run(
|
||||
() => {
|
||||
editor.createShapes(myShapes)
|
||||
},
|
||||
{ history: 'ignore' }
|
||||
)
|
||||
|
||||
// Make changes to locked shapes
|
||||
editor.run(
|
||||
() => {
|
||||
editor.updateShapes(myLockedShapes)
|
||||
},
|
||||
{ ignoreShapeLock: true }
|
||||
)
|
||||
```
|
||||
|
||||
## Capabilities
|
||||
|
||||
The editor's methods and properties are organized around these areas:
|
||||
|
||||
| Area | Topic | Description |
|
||||
| ------------- | -------------------------------------------------- | ------------------------------------------------ |
|
||||
| Data | [Signals](/sdk-features/signals) | Reactive state primitives |
|
||||
| | [Store](/sdk-features/store) | The reactive database holding all records |
|
||||
| | [Shapes](/sdk-features/shapes) | Create, read, update, and delete shapes |
|
||||
| | [Bindings](/sdk-features/bindings) | Relationships between shapes |
|
||||
| | [Pages](/sdk-features/pages) | Manage document pages |
|
||||
| | [Assets](/sdk-features/assets) | Images, videos, and other media |
|
||||
| Interaction | [Tools](/sdk-features/tools) | The state machine that handles user input |
|
||||
| | [Selection](/sdk-features/selection) | Manage which shapes are selected |
|
||||
| | [Input handling](/sdk-features/input-handling) | Pointer and keyboard state |
|
||||
| | [Events](/sdk-features/events) | Subscribe to user interactions and state changes |
|
||||
| View | [Camera](/sdk-features/camera) | Control viewport position and zoom |
|
||||
| | [Coordinates](/sdk-features/coordinates) | Convert between screen and page space |
|
||||
| State | [Instance state](/sdk-features/instance-state) | Per-editor settings like current tool and focus |
|
||||
| | [Visibility](/sdk-features/visibility) | Control which shapes are shown |
|
||||
| | [History](/sdk-features/history) | Undo, redo, and history management |
|
||||
| | [Side effects](/sdk-features/side-effects) | React to record lifecycle changes |
|
||||
| Configuration | [User preferences](/sdk-features/user-preferences) | Cross-instance settings like dark mode |
|
||||
| | [Readonly mode](/sdk-features/readonly) | Disable editing |
|
||||
| | [Locked shapes](/sdk-features/locked-shapes) | Prevent changes to specific shapes |
|
||||
| Output | [Image export](/sdk-features/image-export) | Export to SVG, PNG, and other formats |
|
||||
|
||||
See the [Editor](?) API reference for the complete list of methods and properties.
|
||||
271
apps/docs/content/docs/handles.mdx
Normal file
271
apps/docs/content/docs/handles.mdx
Normal file
@@ -0,0 +1,271 @@
|
||||
---
|
||||
title: Handles
|
||||
status: published
|
||||
date: 3/22/2023
|
||||
order: 4
|
||||
keywords:
|
||||
- handles
|
||||
- custom
|
||||
- shapes
|
||||
- control points
|
||||
- snapping
|
||||
---
|
||||
|
||||
In tldraw, handles are interactive control points on shapes that let users manipulate shapes. Arrows have handles at their endpoints, lines have handles at each vertex, and notes have clone handles for quick duplication.
|
||||
|
||||
## Handle basics
|
||||
|
||||
Handles appear when a single shape is selected with the select tool. Each handle has a position, type, and optional snapping behavior. You define handles by implementing `getHandles` on your [ShapeUtil](?):
|
||||
|
||||
```tsx
|
||||
import { ShapeUtil, TLHandle, ZERO_INDEX_KEY } from 'tldraw'
|
||||
|
||||
class MyShapeUtil extends ShapeUtil<MyShape> {
|
||||
// ...
|
||||
|
||||
override getHandles(shape: MyShape): TLHandle[] {
|
||||
return [
|
||||
{
|
||||
id: 'point',
|
||||
type: 'vertex',
|
||||
index: ZERO_INDEX_KEY,
|
||||
x: shape.props.pointX,
|
||||
y: shape.props.pointY,
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Handle coordinates are in the shape's local coordinate system, where `(0, 0)` is the shape's top-left corner.
|
||||
|
||||
## Handle types
|
||||
|
||||
There are four handle types:
|
||||
|
||||
| Type | Description |
|
||||
| --------- | ----------------------------------------------------------------------------- |
|
||||
| `vertex` | A primary control point that defines part of the shape's geometry |
|
||||
| `virtual` | A secondary handle that isn't a vertex, like the arrow's midpoint bend handle |
|
||||
| `create` | A handle for adding new geometry, like inserting a point into a line segment |
|
||||
| `clone` | A handle for duplicating the shape, used by notes for quick adjacent copies |
|
||||
|
||||
Most custom shapes use `vertex` handles. The arrow shape uses a `virtual` handle for its midpoint, and the line shape uses `create` handles to let users add points between vertices.
|
||||
|
||||
## Responding to handle drags
|
||||
|
||||
When a user drags a handle, tldraw calls `onHandleDrag` with the updated handle position. Return a partial of the shape with the changed props:
|
||||
|
||||
```tsx
|
||||
import { ShapeUtil, TLHandleDragInfo } from 'tldraw'
|
||||
|
||||
class SpeechBubbleUtil extends ShapeUtil<SpeechBubbleShape> {
|
||||
// ...
|
||||
|
||||
override onHandleDrag(shape: SpeechBubbleShape, { handle }: TLHandleDragInfo<SpeechBubbleShape>) {
|
||||
return {
|
||||
props: { tailX: handle.x, tailY: handle.y },
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `handle` in [TLHandleDragInfo](?) carries the new `x` and `y` after any snapping. The info object also has these fields:
|
||||
|
||||
| Field | Description |
|
||||
| ----------------- | ----------------------------------------------------------------------- |
|
||||
| `initial` | The shape as it was when the drag started |
|
||||
| `isPrecise` | Whether the user is dragging precisely, for example by holding Alt |
|
||||
| `isCreatingShape` | Whether the handle drag is part of creating the shape, like a new arrow |
|
||||
|
||||
### Lifecycle callbacks
|
||||
|
||||
For more control over handle interactions, implement these additional methods:
|
||||
|
||||
| Method | When it's called |
|
||||
| -------------------- | ----------------------------------- |
|
||||
| `onHandleDragStart` | When the user starts dragging |
|
||||
| `onHandleDragEnd` | When the user releases the handle |
|
||||
| `onHandleDragCancel` | When the drag is cancelled (escape) |
|
||||
|
||||
## Handle snapping
|
||||
|
||||
Handles can snap to other shapes' geometry. Set `snapType` on the handle. Snapping engages while the user holds Ctrl (Cmd on Mac); with snap mode turned on in preferences, it's the reverse: snapping is on and Ctrl disables it. The older `canSnap: true` flag is deprecated; use `snapType: 'point'` instead.
|
||||
|
||||
```tsx
|
||||
{
|
||||
id: 'end',
|
||||
type: 'vertex',
|
||||
index: ZERO_INDEX_KEY,
|
||||
x: shape.props.endX,
|
||||
y: shape.props.endY,
|
||||
snapType: 'point', // Snap to points on other shapes
|
||||
}
|
||||
```
|
||||
|
||||
The `snapType` options are:
|
||||
|
||||
| Value | Behavior |
|
||||
| --------- | -------------------------------------------------------------------------------------- |
|
||||
| `'point'` | Snaps to key points on other shapes first, then to the nearest point on their outlines |
|
||||
| `'align'` | Snaps the handle's x and y independently to the x and y of key points on other shapes |
|
||||
|
||||
### Angle snapping
|
||||
|
||||
When the user holds Shift while dragging, handles snap to 15-degree angles. By default, the angle is measured relative to the next vertex handle on the shape. You can snap relative to a specific handle by setting `snapReferenceHandleId`:
|
||||
|
||||
```tsx
|
||||
{
|
||||
id: 'controlPoint',
|
||||
type: 'vertex',
|
||||
index: indices[1],
|
||||
x: shape.props.cpX,
|
||||
y: shape.props.cpY,
|
||||
snapType: 'align',
|
||||
snapReferenceHandleId: 'start', // Angle snaps relative to 'start' handle
|
||||
}
|
||||
```
|
||||
|
||||
Bezier curves use this so control points snap to angles relative to their associated endpoint.
|
||||
|
||||
### Custom snap geometry
|
||||
|
||||
By default, handles snap to a shape's outline (its geometry) and to no key points. Override `getHandleSnapGeometry` to customize what handles snap to:
|
||||
|
||||
```tsx
|
||||
import { HandleSnapGeometry, ShapeUtil } from 'tldraw'
|
||||
|
||||
class BezierCurveUtil extends ShapeUtil<BezierCurveShape> {
|
||||
// ...
|
||||
|
||||
override getHandleSnapGeometry(shape: BezierCurveShape): HandleSnapGeometry {
|
||||
return {
|
||||
// Points other shapes' handles can snap to
|
||||
points: [shape.props.start, shape.props.end],
|
||||
|
||||
// Points this shape's own handles can snap to (for self-snapping)
|
||||
getSelfSnapPoints: (handle) => {
|
||||
if (handle.id === 'controlPoint') {
|
||||
return [shape.props.start, shape.props.end]
|
||||
}
|
||||
return []
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `HandleSnapGeometry` object has these properties:
|
||||
|
||||
| Property | Description |
|
||||
| -------------------- | ------------------------------------------------------------------------- |
|
||||
| `outline` | Outline geometry to snap to (default: shape geometry; `null` disables it) |
|
||||
| `points` | Key points to snap to, like corners or centers (default: none) |
|
||||
| `getSelfSnapOutline` | Returns outline for self-snapping given a handle |
|
||||
| `getSelfSnapPoints` | Returns points for self-snapping given a handle |
|
||||
|
||||
## Reading handles
|
||||
|
||||
Use [Editor#getShapeHandles](?) to get the handles for any shape:
|
||||
|
||||
```ts
|
||||
const handles = editor.getShapeHandles(shape)
|
||||
if (handles) {
|
||||
for (const handle of handles) {
|
||||
console.log(handle.id, handle.x, handle.y)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Returns `undefined` if the shape doesn't have handles.
|
||||
|
||||
## Complete example
|
||||
|
||||
Here's a speech bubble shape with a draggable tail handle:
|
||||
|
||||
```tsx
|
||||
import {
|
||||
Polygon2d,
|
||||
ShapeUtil,
|
||||
SVGContainer,
|
||||
T,
|
||||
TLHandle,
|
||||
TLHandleDragInfo,
|
||||
TLShape,
|
||||
Vec,
|
||||
ZERO_INDEX_KEY,
|
||||
} from 'tldraw'
|
||||
|
||||
const SPEECH_BUBBLE_TYPE = 'speech-bubble'
|
||||
|
||||
declare module 'tldraw' {
|
||||
export interface TLGlobalShapePropsMap {
|
||||
[SPEECH_BUBBLE_TYPE]: { w: number; h: number; tailX: number; tailY: number }
|
||||
}
|
||||
}
|
||||
|
||||
type SpeechBubbleShape = TLShape<typeof SPEECH_BUBBLE_TYPE>
|
||||
|
||||
class SpeechBubbleUtil extends ShapeUtil<SpeechBubbleShape> {
|
||||
static override type = SPEECH_BUBBLE_TYPE
|
||||
static override props = { w: T.number, h: T.number, tailX: T.number, tailY: T.number }
|
||||
|
||||
getDefaultProps(): SpeechBubbleShape['props'] {
|
||||
return { w: 200, h: 100, tailX: 100, tailY: 150 }
|
||||
}
|
||||
|
||||
getGeometry(shape: SpeechBubbleShape) {
|
||||
const { w, h, tailX, tailY } = shape.props
|
||||
return new Polygon2d({
|
||||
points: [
|
||||
new Vec(0, 0),
|
||||
new Vec(w, 0),
|
||||
new Vec(w, h),
|
||||
new Vec(w * 0.7, h),
|
||||
new Vec(tailX, tailY),
|
||||
new Vec(w * 0.3, h),
|
||||
new Vec(0, h),
|
||||
],
|
||||
isFilled: true,
|
||||
})
|
||||
}
|
||||
|
||||
override getHandles(shape: SpeechBubbleShape): TLHandle[] {
|
||||
return [
|
||||
{
|
||||
id: 'tail',
|
||||
type: 'vertex',
|
||||
label: 'Move tail',
|
||||
index: ZERO_INDEX_KEY,
|
||||
x: shape.props.tailX,
|
||||
y: shape.props.tailY,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
override onHandleDrag(shape: SpeechBubbleShape, { handle }: TLHandleDragInfo<SpeechBubbleShape>) {
|
||||
return {
|
||||
props: { tailX: handle.x, tailY: handle.y },
|
||||
}
|
||||
}
|
||||
|
||||
component(shape: SpeechBubbleShape) {
|
||||
const geometry = this.getGeometry(shape)
|
||||
return (
|
||||
<SVGContainer>
|
||||
<path d={geometry.getSvgPathData()} fill="white" stroke="black" />
|
||||
</SVGContainer>
|
||||
)
|
||||
}
|
||||
|
||||
getIndicatorPath(shape: SpeechBubbleShape) {
|
||||
const geometry = this.getGeometry(shape)
|
||||
return new Path2D(geometry.getSvgPathData())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
- [Custom shape with handles](/examples/shapes/tools/speech-bubble) — A speech bubble shape with a draggable tail handle
|
||||
- [Cubic bezier curve shape](/examples/shapes/tools/cubic-bezier-shape) — Multiple handles with custom snapping and control point behavior
|
||||
95
apps/docs/content/docs/indicators.mdx
Normal file
95
apps/docs/content/docs/indicators.mdx
Normal file
@@ -0,0 +1,95 @@
|
||||
---
|
||||
title: Indicators
|
||||
status: published
|
||||
author: steveruizok
|
||||
date: 3/22/2023
|
||||
order: 5
|
||||
keywords:
|
||||
- indicators
|
||||
- selection
|
||||
- hover
|
||||
- ShapeUtil
|
||||
- custom shapes
|
||||
---
|
||||
|
||||
Indicators are the outlines that appear around shapes when they're selected or hovered.
|
||||
|
||||
## How indicators work
|
||||
|
||||
Indicators are drawn on a canvas overlay in the theme's selection color, separate from the shape's own rendering. Each [ShapeUtil](?) defines its indicator by implementing the required [ShapeUtil#getIndicatorPath](?) method. It returns a `Path2D` in the shape's local coordinate space; the [ShapeIndicatorOverlayUtil](?) included with `<Tldraw>` transforms it into page space and strokes it. Return `undefined` to draw no indicator for a shape.
|
||||
|
||||
```tsx
|
||||
class CardShapeUtil extends ShapeUtil<CardShape> {
|
||||
// ...
|
||||
|
||||
getIndicatorPath(shape: CardShape) {
|
||||
const path = new Path2D()
|
||||
path.rect(0, 0, shape.props.w, shape.props.h)
|
||||
return path
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For shapes with more complex outlines, build the path from the shape's geometry:
|
||||
|
||||
```tsx
|
||||
getIndicatorPath(shape: MyShape) {
|
||||
return new Path2D(this.editor.getShapeGeometry(shape).toSimpleSvgPath())
|
||||
}
|
||||
```
|
||||
|
||||
See [Shapes](/docs/shapes) for the rest of the ShapeUtil.
|
||||
|
||||
## When indicators appear
|
||||
|
||||
The select tool shows indicators in these situations:
|
||||
|
||||
| State | Description |
|
||||
| -------- | ------------------------------------------------------------------------------------------------------------------ |
|
||||
| Selected | The shape is in the current selection |
|
||||
| Hovered | The pointer is over the shape while the select tool is idle or editing a shape (fine pointers only, not touch) |
|
||||
| Hinting | The shape is in [Editor#getHintingShapeIds](?), set with [Editor#setHintingShapes](?); drawn with a thicker stroke |
|
||||
|
||||
Indicators are hidden while the user is changing styles, while another tool is active, and for locked shapes.
|
||||
|
||||
All plain `Path2D` indicators are batched into a single stroke call, so many selected shapes stay cheap to draw.
|
||||
|
||||
## Complex canvas indicators
|
||||
|
||||
For indicators that need clipping or multiple paths (like arrows with labels), return a [TLIndicatorPath](?) object instead of a plain `Path2D`. The `clipPath` is applied as an even-odd clip region before stroking `path`, so to cut a hole for a label, add an outer rectangle plus the label rectangle. `additionalPaths` are stroked afterwards without the clip:
|
||||
|
||||
```tsx
|
||||
override getIndicatorPath(shape: MyShape): TLIndicatorPath | undefined {
|
||||
const bodyPath = new Path2D()
|
||||
bodyPath.moveTo(0, 0)
|
||||
bodyPath.lineTo(100, 100)
|
||||
|
||||
const arrowheadPath = new Path2D()
|
||||
arrowheadPath.moveTo(90, 95)
|
||||
arrowheadPath.lineTo(100, 100)
|
||||
arrowheadPath.lineTo(95, 90)
|
||||
|
||||
// Even-odd: the outer rect keeps everything, the inner rect punches a hole
|
||||
const clipPath = new Path2D()
|
||||
clipPath.rect(-100, -100, 300, 300)
|
||||
clipPath.rect(40, 40, 20, 20)
|
||||
|
||||
return {
|
||||
path: bodyPath,
|
||||
clipPath,
|
||||
additionalPaths: [arrowheadPath],
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Collaborator indicators
|
||||
|
||||
In multiplayer sessions, [CollaboratorShapeIndicatorOverlayUtil](?) draws other users' selections in each collaborator's color at reduced opacity, underneath the local indicators.
|
||||
|
||||
## Related topics
|
||||
|
||||
| Topic | Description |
|
||||
| ---------------------------------------------------------- | ------------------------------------------------------------------- |
|
||||
| [Shapes](/docs/shapes) | Creating custom shapes with ShapeUtil |
|
||||
| [User interface](/docs/user-interface) | Customizing tldraw's UI components |
|
||||
| [Custom indicators example](/examples/ui/indicators-logic) | Subclass `ShapeIndicatorOverlayUtil` to change when indicators show |
|
||||
56
apps/docs/content/docs/llm-docs.mdx
Normal file
56
apps/docs/content/docs/llm-docs.mdx
Normal file
@@ -0,0 +1,56 @@
|
||||
---
|
||||
title: LLM documentation
|
||||
status: published
|
||||
author: steveruizok
|
||||
date: 2/1/2026
|
||||
order: 9
|
||||
keywords:
|
||||
- llm
|
||||
- ai
|
||||
- language model
|
||||
- markdown
|
||||
- llms.txt
|
||||
- copy
|
||||
- context
|
||||
- agent
|
||||
---
|
||||
|
||||
The tldraw docs are available in formats built for large language models (LLMs): plain-text bundles you can hand to a coding assistant or agent, and a copy-as-markdown button on every article.
|
||||
|
||||
## llms.txt
|
||||
|
||||
We publish [tldraw.dev/llms.txt](https://tldraw.dev/llms.txt), following the [llms.txt convention](https://llmstxt.org/). It indexes the SDK feature articles, the examples, and the release notes, and links to several bundles of the same content. The bundles are regenerated on every docs build.
|
||||
|
||||
| File | Contents |
|
||||
| --------------------------------------------------------- | -------------------------------------------- |
|
||||
| [llms.txt](https://tldraw.dev/llms.txt) | Index with links to all resources |
|
||||
| [llms-full.txt](https://tldraw.dev/llms-full.txt) | SDK feature articles, releases, and examples |
|
||||
| [llms-docs.txt](https://tldraw.dev/llms-docs.txt) | SDK feature articles only |
|
||||
| [llms-releases.txt](https://tldraw.dev/llms-releases.txt) | Release notes only |
|
||||
| [llms-examples.txt](https://tldraw.dev/llms-examples.txt) | Examples with full source code |
|
||||
|
||||
The guides in this section of the site (under `/docs`) aren't in the bundles. Use the copy button below for those.
|
||||
|
||||
### Using llms.txt with AI assistants
|
||||
|
||||
When working with an AI coding assistant, you can provide context by including the relevant llms.txt file in your prompt or conversation. For example:
|
||||
|
||||
- Use `llms-docs.txt` when asking about SDK features, APIs, or implementation patterns
|
||||
- Use `llms-examples.txt` when looking for code examples or implementation references
|
||||
- Use `llms-releases.txt` when asking about recent changes or migration between versions
|
||||
- Use `llms-full.txt` when you need comprehensive context across all documentation
|
||||
|
||||
Many AI tools fetch URLs directly, so you can reference these files by URL in your prompts:
|
||||
|
||||
```
|
||||
Read https://tldraw.dev/llms-docs.txt, then add a custom shape to my app.
|
||||
```
|
||||
|
||||
## Copy markdown button
|
||||
|
||||
Every article on tldraw.dev has a "Copy markdown" button in its header. Click it to copy the article as markdown, ready to paste into an LLM conversation. The copy keeps headings, code blocks, and links, and strips the site navigation.
|
||||
|
||||
## Related
|
||||
|
||||
- [AI integrations](/docs/ai) — Using the canvas with AI models: generated content, workflows, and agents
|
||||
- [Mermaid diagrams](/docs/mermaid) — Turning model-generated diagram text into shapes
|
||||
152
apps/docs/content/docs/mermaid.mdx
Normal file
152
apps/docs/content/docs/mermaid.mdx
Normal file
@@ -0,0 +1,152 @@
|
||||
---
|
||||
title: Mermaid diagrams
|
||||
status: published
|
||||
author: guillaumerichard
|
||||
date: 4/28/2026
|
||||
order: 8
|
||||
keywords:
|
||||
- mermaid
|
||||
- diagram
|
||||
- flowchart
|
||||
- sequence
|
||||
- state
|
||||
- mindmap
|
||||
- integration
|
||||
- parse
|
||||
- render
|
||||
- visualization
|
||||
---
|
||||
|
||||
You can turn [Mermaid](https://mermaid.js.org/) diagram syntax into native, editable tldraw shapes with the `@tldraw/mermaid` package. Instead of rendering a static SVG, it parses Mermaid text and creates real geo shapes, arrows, and groups on the canvas. Users can move, resize, restyle, and connect them like any other shape.
|
||||
|
||||
## Quick start
|
||||
|
||||
Install the package alongside `tldraw`:
|
||||
|
||||
```bash
|
||||
npm install @tldraw/mermaid
|
||||
```
|
||||
|
||||
Then call [createMermaidDiagram](?) with an [Editor](?) and a Mermaid source string:
|
||||
|
||||
```tsx
|
||||
import { createMermaidDiagram } from '@tldraw/mermaid'
|
||||
|
||||
await createMermaidDiagram(
|
||||
editor,
|
||||
`
|
||||
flowchart TD
|
||||
A[Start] --> B{Decision}
|
||||
B -->|Yes| C[Do something]
|
||||
B -->|No| D[Do something else]
|
||||
`
|
||||
)
|
||||
```
|
||||
|
||||
By default the diagram is centered on the viewport, or on the pointer when the user's paste-at-cursor preference is on. Pass a `blueprintRender.position` to place it at a specific page point, and set `centerOnPosition: false` to anchor its top-left corner instead of its center. The finished diagram is a single group. Pass `mermaidConfig` to override Mermaid's layout settings, such as node spacing or font size.
|
||||
|
||||
## Supported diagram types
|
||||
|
||||
| Diagram type | Mermaid keyword | What you get |
|
||||
| ---------------- | --------------------------------- | ------------------------------------------------------------------------------ |
|
||||
| Flowchart | `flowchart`, `graph` | Geo shapes, arrows, subgraph containers |
|
||||
| Sequence diagram | `sequenceDiagram` | Actor shapes, lifelines, signal arrows, fragment containers, participant boxes |
|
||||
| State diagram | `stateDiagram`, `stateDiagram-v2` | State shapes, transitions, compound state containers, fork/join, choice |
|
||||
| Mindmap | `mindmap` | Colored geo shapes, parent-child edges, tree hierarchy |
|
||||
|
||||
Subgraphs, fragments, and compound states become geo rectangles with their child shapes parented to them.
|
||||
|
||||
For unsupported types such as pie, gantt, class, and ER, pass an `onUnsupportedDiagram` callback. Here it falls back to importing Mermaid's rendered SVG:
|
||||
|
||||
```tsx
|
||||
await createMermaidDiagram(editor, text, {
|
||||
async onUnsupportedDiagram(svgString) {
|
||||
await editor.putExternalContent({ type: 'svg-text', text: svgString })
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Without a callback, `createMermaidDiagram` throws a [MermaidDiagramError](?) for unsupported types and parse failures.
|
||||
|
||||
## Handling pasted Mermaid text
|
||||
|
||||
The most common integration is converting Mermaid text when users paste it onto the canvas. Register an external content handler that sniffs for a Mermaid keyword and dynamically imports the package:
|
||||
|
||||
```tsx
|
||||
import { useEffect } from 'react'
|
||||
import { defaultHandleExternalTextContent, useEditor } from 'tldraw'
|
||||
|
||||
const MERMAID_KEYWORD =
|
||||
/^\s*(flowchart|graph|sequenceDiagram|stateDiagram|classDiagram|erDiagram|gantt|pie|gitGraph|mindmap)/
|
||||
|
||||
export function MermaidPasteHandler() {
|
||||
const editor = useEditor()
|
||||
|
||||
useEffect(() => {
|
||||
editor.registerExternalContentHandler('text', async (content) => {
|
||||
if (!MERMAID_KEYWORD.test(content.text)) {
|
||||
await defaultHandleExternalTextContent(editor, content)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const { createMermaidDiagram } = await import('@tldraw/mermaid')
|
||||
await createMermaidDiagram(editor, content.text, {
|
||||
async onUnsupportedDiagram(svgString) {
|
||||
await editor.putExternalContent({ type: 'svg-text', text: svgString })
|
||||
},
|
||||
})
|
||||
} catch {
|
||||
await defaultHandleExternalTextContent(editor, content)
|
||||
}
|
||||
})
|
||||
}, [editor])
|
||||
|
||||
return null
|
||||
}
|
||||
```
|
||||
|
||||
Drop `<MermaidPasteHandler />` inside your [Tldraw](?) component and pasting Mermaid text will render it as shapes. The [Mermaid pasting example](/examples/use-cases/mermaid-pasting) extends this with markdown fence stripping and a toast for unsupported diagrams.
|
||||
|
||||
<Callout type="info">
|
||||
The `mermaid` dependency is ~2 MB. `createMermaidDiagram` loads it lazily on first call, and the
|
||||
regex above keeps it from being called for ordinary text, so users who never paste Mermaid don't
|
||||
pay the cost.
|
||||
</Callout>
|
||||
|
||||
## Customizing node shapes
|
||||
|
||||
By default, blueprint nodes are materialized as tldraw geo shapes. To render them as your own custom shape type instead, pass `mapNodeToRenderSpec` on `blueprintRender`:
|
||||
|
||||
```tsx
|
||||
await createMermaidDiagram(editor, text, {
|
||||
blueprintRender: {
|
||||
mapNodeToRenderSpec(input) {
|
||||
if (input.diagramKind === 'flowchart') {
|
||||
return {
|
||||
variant: 'shape',
|
||||
type: 'pipeline-step',
|
||||
props: { mermaidNodeId: input.nodeId },
|
||||
}
|
||||
}
|
||||
// Return undefined to keep the package default for this node.
|
||||
return undefined
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
The callback receives `diagramKind`, `nodeId`, `kind`, and the full [MermaidBlueprintNode](?), and returns a [MermaidBlueprintNodeRenderSpec](?): either a `geo` variant or a custom `shape` type that's registered on the editor. The package sets `w`, `h`, `fill`, `color`, `dash`, `size`, `richText`, `align`, and `verticalAlign` on every node it creates, so a custom shape's props must accept those keys or validation will fail. See [createMermaidDiagram](?) and [renderBlueprint](?) in the reference for the full API.
|
||||
|
||||
## Examples
|
||||
|
||||
- [Hundreds of Mermaid diagrams](/examples/use-cases/hundred-mermaids) — A runnable demo rendering many diagram types at once
|
||||
- [Customize Mermaid diagrams](/examples/use-cases/custom-shape-mermaids) — Converting Mermaid diagram nodes into custom shapes
|
||||
- [Pasting Mermaid code as shapes](/examples/use-cases/mermaid-pasting) — The paste handler above, end to end
|
||||
|
||||
## Related
|
||||
|
||||
- [createMermaidDiagram](?), [renderBlueprint](?), [MermaidDiagramError](?) — Entry points and errors
|
||||
- [DiagramMermaidBlueprint](?), [MermaidBlueprintNode](?), [MermaidBlueprintNodeRenderSpec](?) — The data model you can customize
|
||||
- [Shapes](/docs/shapes) — Define a custom shape you can map Mermaid nodes to
|
||||
- [External content handlers](/sdk-features/external-content) — More on `registerExternalContentHandler` for the paste integration
|
||||
109
apps/docs/content/docs/persistence.mdx
Normal file
109
apps/docs/content/docs/persistence.mdx
Normal file
@@ -0,0 +1,109 @@
|
||||
---
|
||||
title: Persistence
|
||||
status: published
|
||||
author: steveruizok
|
||||
date: 3/22/2023
|
||||
order: 4
|
||||
keywords:
|
||||
- data
|
||||
- sync
|
||||
- persistence
|
||||
- database
|
||||
- indexeddb
|
||||
- localstorage
|
||||
---
|
||||
|
||||
Persistence means storing the editor's state to a database and restoring it later. The simplest option is the `persistenceKey` prop, which saves to the browser automatically. Snapshots and the `store` prop give you more control, and migrations bring old data up to date.
|
||||
|
||||
## Local persistence
|
||||
|
||||
The simplest approach is the `persistenceKey` prop. This automatically saves the document and any uploaded [assets](/docs/assets) to IndexedDB and syncs across browser tabs:
|
||||
|
||||
```tsx
|
||||
import { Tldraw } from 'tldraw'
|
||||
import 'tldraw/tldraw.css'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0 }}>
|
||||
<Tldraw persistenceKey="my-document" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Each unique key represents a separate document. Two editors with the same key share the same document and stay synchronized.
|
||||
|
||||
## Snapshots
|
||||
|
||||
For custom storage backends, use [getSnapshot](?) and [loadSnapshot](?) to save and restore editor state as JSON:
|
||||
|
||||
```tsx
|
||||
import { getSnapshot, loadSnapshot } from 'tldraw'
|
||||
|
||||
// Save
|
||||
const { document, session } = getSnapshot(editor.store)
|
||||
await saveToDatabase(document)
|
||||
|
||||
// Load
|
||||
const saved = await loadFromDatabase()
|
||||
loadSnapshot(editor.store, { document: saved })
|
||||
```
|
||||
|
||||
The snapshot has two parts: `document` (shapes, pages, bindings) which you typically save to a server, and `session` (camera, selection, UI state) which you keep per-user locally. Loading a snapshot replaces the current document.
|
||||
|
||||
To load a snapshot once on startup, pass it to the `snapshot` prop of `<Tldraw>` instead. See [Persistence](/sdk-features/persistence) for complete coverage of snapshots, async loading with [TLStoreWithStatus](?), and auto-save patterns.
|
||||
|
||||
## The store
|
||||
|
||||
The editor's [store](/sdk-features/store) is a reactive database that holds all records. You can create a standalone store with [createTLStore](?), load data into it, and pass it to the editor:
|
||||
|
||||
```tsx
|
||||
import { useState } from 'react'
|
||||
import { createTLStore, loadSnapshot, Tldraw } from 'tldraw'
|
||||
|
||||
export default function App() {
|
||||
const [store] = useState(() => {
|
||||
const store = createTLStore()
|
||||
const saved = localStorage.getItem('my-drawing')
|
||||
if (saved) {
|
||||
loadSnapshot(store, JSON.parse(saved))
|
||||
}
|
||||
return store
|
||||
})
|
||||
|
||||
return <Tldraw store={store} />
|
||||
}
|
||||
```
|
||||
|
||||
See [Store](/sdk-features/store) for details on store operations, listening to changes, queries, and transactions.
|
||||
|
||||
## Multiplayer sync
|
||||
|
||||
For real-time collaboration, use the `@tldraw/sync` package and its [useSyncDemo](?) hook. Multiple users can edit the same document simultaneously, see each other's cursors, and follow each other's viewports:
|
||||
|
||||
```tsx
|
||||
import { useSyncDemo } from '@tldraw/sync'
|
||||
import { Tldraw } from 'tldraw'
|
||||
|
||||
export default function App() {
|
||||
const store = useSyncDemo({ roomId: 'my-room-id' })
|
||||
return <Tldraw store={store} />
|
||||
}
|
||||
```
|
||||
|
||||
See [Collaboration](/sdk-features/collaboration) for production setup, custom presence, authentication, and building custom sync solutions.
|
||||
|
||||
## Migrations
|
||||
|
||||
When you load a snapshot from an older schema version, the store migrates it automatically. For custom shapes, you can define migrations to handle changes to their props over time.
|
||||
|
||||
See [Persistence](/sdk-features/persistence#Migrations) for details on shape props migrations and general migrations.
|
||||
|
||||
## Examples
|
||||
|
||||
- [Persistence key](/examples/configuration/persistence-key) — Automatic local persistence with a single prop
|
||||
- [Snapshots](/examples/editor-api/snapshots) — Saving and loading editor state
|
||||
- [Local storage](/examples/data/assets/local-storage) — Custom persistence with throttled auto-save
|
||||
- [Store events](/examples/events/store-events) — Listening to store changes
|
||||
- [Shape with migrations](/examples/shapes/tools/shape-with-migrations) — Migrations for custom shape props
|
||||
205
apps/docs/content/docs/shapes.mdx
Normal file
205
apps/docs/content/docs/shapes.mdx
Normal file
@@ -0,0 +1,205 @@
|
||||
---
|
||||
title: Shapes
|
||||
status: published
|
||||
author: steveruizok
|
||||
date: 3/22/2023
|
||||
order: 2
|
||||
keywords:
|
||||
- custom
|
||||
- shapes
|
||||
- shapeutils
|
||||
- utils
|
||||
- meta
|
||||
- metadata
|
||||
---
|
||||
|
||||
In tldraw, a shape is something that can exist on the page, like an arrow, an image, or some text.
|
||||
|
||||
## Shape basics
|
||||
|
||||
Shapes are JSON records stored in the editor's [store](/sdk-features/store). Each shape has base properties (position, rotation, opacity) plus a `props` object for shape-specific data. Each shape type has a [ShapeUtil](?) class that defines its behavior: how it renders, its geometry for hit testing, and how it responds to interactions.
|
||||
|
||||
The [Tldraw](?) component includes [default shapes](/sdk-features/default-shapes) like geo, text, arrow, and draw. The only core shape (always present) is the [group](/sdk-features/groups). See [Shapes](/sdk-features/shapes) for the full shape system architecture, including ShapeUtil methods, lifecycle hooks, and configuration.
|
||||
|
||||
## Custom shapes
|
||||
|
||||
You can create your own shapes by defining a shape type and a ShapeUtil class.
|
||||
|
||||
> For a working example, see our [custom shapes example](/examples/shapes/tools/custom-shape).
|
||||
|
||||
### Defining the shape type
|
||||
|
||||
Register your shape's props using TypeScript module augmentation:
|
||||
|
||||
```ts
|
||||
import { TLShape } from 'tldraw'
|
||||
|
||||
const CARD_TYPE = 'card'
|
||||
|
||||
declare module 'tldraw' {
|
||||
export interface TLGlobalShapePropsMap {
|
||||
[CARD_TYPE]: { w: number; h: number }
|
||||
}
|
||||
}
|
||||
|
||||
type CardShape = TLShape<typeof CARD_TYPE>
|
||||
```
|
||||
|
||||
### Creating a ShapeUtil
|
||||
|
||||
Implement the required methods: `getDefaultProps`, `getGeometry`, `component`, and `getIndicatorPath`. Set `static props` so the store validates your shape's props; without it, `props` accepts any JSON value and typos or stale data pass through unchecked.
|
||||
|
||||
```tsx
|
||||
import { HTMLContainer, Rectangle2d, ShapeUtil, T } from 'tldraw'
|
||||
|
||||
class CardShapeUtil extends ShapeUtil<CardShape> {
|
||||
static override type = CARD_TYPE
|
||||
static override props = { w: T.number, h: T.number }
|
||||
|
||||
getDefaultProps(): CardShape['props'] {
|
||||
return { w: 100, h: 100 }
|
||||
}
|
||||
|
||||
getGeometry(shape: CardShape) {
|
||||
return new Rectangle2d({
|
||||
width: shape.props.w,
|
||||
height: shape.props.h,
|
||||
isFilled: true,
|
||||
})
|
||||
}
|
||||
|
||||
component(shape: CardShape) {
|
||||
return <HTMLContainer>Hello</HTMLContainer>
|
||||
}
|
||||
|
||||
getIndicatorPath(shape: CardShape) {
|
||||
const path = new Path2D()
|
||||
path.rect(0, 0, shape.props.w, shape.props.h)
|
||||
return path
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
See [Geometry](/sdk-features/geometry) for available geometry classes.
|
||||
|
||||
### Registering your shape
|
||||
|
||||
Pass your ShapeUtil to the [Tldraw](?) component:
|
||||
|
||||
```tsx
|
||||
export default function () {
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0 }}>
|
||||
<Tldraw
|
||||
shapeUtils={[CardShapeUtil]}
|
||||
onMount={(editor) => {
|
||||
editor.createShape({ type: 'card' })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Meta
|
||||
|
||||
Every shape has a `meta` property for your own data. Tldraw stores and syncs this data but doesn't use it itself. It's an escape hatch for attaching extra information to shapes, like the name of the user who created a shape or the date it was last changed.
|
||||
|
||||
Like `props`, the data in `meta` must be JSON-serializable. Shapes aren't the only records with a `meta` property: pages, bindings, assets, and the document record have one too.
|
||||
|
||||
By default, a shape's `meta` is an empty object typed as `JsonObject`. To type your meta data, use an intersection:
|
||||
|
||||
```ts
|
||||
type ShapeWithMyMeta = TLGeoShape & { meta: { createdBy: string } }
|
||||
|
||||
const shape = editor.getShape<ShapeWithMyMeta>(myGeoShapeId)
|
||||
```
|
||||
|
||||
You can update a shape's `meta` with [Editor#updateShapes](?), the same way you update its props:
|
||||
|
||||
```ts
|
||||
editor.updateShapes<ShapeWithMyMeta>([
|
||||
{
|
||||
id: myGeoShapeId,
|
||||
type: 'geo',
|
||||
meta: { createdBy: 'Steve' },
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
### Initial meta
|
||||
|
||||
When [Editor#createShapes](?) creates a shape, it merges the result of [Editor#getInitialMetaForShape](?) with any `meta` you passed. Your explicit `meta` wins. By default this method returns an empty object. Replace it to provide your own initial meta:
|
||||
|
||||
```tsx
|
||||
editor.getInitialMetaForShape = (shape) => {
|
||||
if (shape.type === 'text') {
|
||||
return { createdBy: currentUser.id, lastModified: Date.now() }
|
||||
}
|
||||
return { createdBy: currentUser.id }
|
||||
}
|
||||
```
|
||||
|
||||
> For a working example, see our [shape meta on create example](/examples/events/meta-on-create).
|
||||
|
||||
### Updating meta with side effects
|
||||
|
||||
To keep meta up to date as shapes change, register a side effect that runs before each shape update:
|
||||
|
||||
```tsx
|
||||
editor.sideEffects.registerBeforeChangeHandler('shape', (_prev, next, source) => {
|
||||
if (source !== 'user') return next
|
||||
return {
|
||||
...next,
|
||||
meta: { updatedBy: editor.user.getExternalId(), updatedAt: Date.now() },
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
Side effects can run on create, update, and delete for shapes and other records. See [Side effects](/sdk-features/side-effects) for the full API.
|
||||
|
||||
> For a working example, see our [shape meta on change example](/examples/events/meta-on-change).
|
||||
|
||||
### Validating meta
|
||||
|
||||
By default, the store accepts any JSON value in `meta`. To validate meta data at runtime, build your own schema with [createTLSchema](?) and pass validators for each shape type's meta:
|
||||
|
||||
```tsx
|
||||
import { useState } from 'react'
|
||||
import { createTLSchema, createTLStore, defaultShapeSchemas, T, Tldraw } from 'tldraw'
|
||||
|
||||
const schema = createTLSchema({
|
||||
shapes: {
|
||||
...defaultShapeSchemas,
|
||||
geo: {
|
||||
...defaultShapeSchemas.geo,
|
||||
meta: { createdBy: T.string },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export default function App() {
|
||||
const [store] = useState(() => createTLStore({ schema }))
|
||||
return <Tldraw store={store} />
|
||||
}
|
||||
```
|
||||
|
||||
When you build the schema yourself, include your custom shapes' `props` and `migrations` in the `shapes` map too. Bindings, assets, and user records accept meta validators the same way. For user records, see our [custom user metadata example](/examples/users/custom-user).
|
||||
|
||||
## Extending shapes
|
||||
|
||||
Extend [BaseBoxShapeUtil](?) for standard rectangular shape behavior. Use [ShapeUtil#configure](?) to customize a built-in shape's options without subclassing it.
|
||||
|
||||
## Related topics
|
||||
|
||||
| Topic | Description |
|
||||
| ---------------------------------------------- | ------------------------------------------------------------------ |
|
||||
| [Shapes](/sdk-features/shapes) | Full shape system architecture, ShapeUtil methods, lifecycle hooks |
|
||||
| [Default shapes](/sdk-features/default-shapes) | Built-in shape types and their properties |
|
||||
| [Geometry](/sdk-features/geometry) | Geometry classes for hit testing and bounds |
|
||||
| [Bindings](/sdk-features/bindings) | Connecting shapes together (like arrows) |
|
||||
| [Rich text](/sdk-features/rich-text) | Adding text labels to shapes |
|
||||
| [Shape clipping](/sdk-features/shape-clipping) | Clipping children within shape boundaries |
|
||||
| [Snapping](/sdk-features/snapping) | Shape snapping behavior |
|
||||
| [Persistence](/sdk-features/persistence) | Shape migrations and data persistence |
|
||||
| [Groups](/sdk-features/groups) | Grouping shapes together |
|
||||
584
apps/docs/content/docs/sync.mdx
Normal file
584
apps/docs/content/docs/sync.mdx
Normal file
@@ -0,0 +1,584 @@
|
||||
---
|
||||
title: tldraw sync
|
||||
status: published
|
||||
author: alex
|
||||
date: 7/11/2024
|
||||
order: 7
|
||||
keywords:
|
||||
- data
|
||||
- sync
|
||||
- persistence
|
||||
- database
|
||||
- multiplayer
|
||||
- collaboration
|
||||
- server
|
||||
- websockets
|
||||
readability: 8
|
||||
voice: 8
|
||||
completeness: 8
|
||||
accuracy: 8
|
||||
notes: 'Thorough sync architecture coverage with good code examples. Migration sections are comprehensive but lengthy.'
|
||||
---
|
||||
|
||||
You can add realtime multi-user collaboration to your tldraw app by using **tldraw sync**. It's our library for fast, fault-tolerant shared document syncing. We use it in production on our flagship app [tldraw.com](https://tldraw.com).
|
||||
|
||||
We offer a [hosted demo](/docs/collaboration) of tldraw sync which is suitable for prototyping. To use tldraw sync in production, you will need to host it yourself.
|
||||
|
||||
## Deploying tldraw sync
|
||||
|
||||
There are two main ways to go about hosting tldraw sync:
|
||||
|
||||
1. Deploy a full backend to Cloudflare using our template (recommended).
|
||||
2. Integrate tldraw sync into your own JavaScript backend using our examples and docs as a guide.
|
||||
|
||||
### Use our Cloudflare template
|
||||
|
||||
The best way to get started hosting your own backend is to clone and deploy [our Cloudflare template](https://github.com/tldraw/tldraw/tree/main/templates/sync-cloudflare). The template provides the same setup that runs on tldraw.com.
|
||||
|
||||
It uses:
|
||||
|
||||
- [Durable Objects](https://developers.cloudflare.com/durable-objects/) to provide a unique WebSocket server per room. Room state is persisted automatically to the durable object's built-in SQLite storage.
|
||||
- [R2](https://developers.cloudflare.com/r2/) to store large binary assets like images and videos.
|
||||
|
||||
There are some features that we have not provided and you might want to add yourself, such as authentication and authorization, rate limiting and size limiting for asset uploads, storing snapshots of documents over time for long-term history, and listing and searching for rooms.
|
||||
|
||||
Make sure you also read the section below about [deployment concerns](#deployment-concerns).
|
||||
|
||||
[Get started with the Cloudflare template](https://github.com/tldraw/tldraw/tree/main/templates/sync-cloudflare).
|
||||
|
||||
### Integrate tldraw sync into your own backend
|
||||
|
||||
We recommend Cloudflare. The `@tldraw/sync-core` library also integrates tldraw sync into any JavaScript server environment that supports WebSockets.
|
||||
|
||||
We have a [simple Node.js server example](https://github.com/tldraw/tldraw/tree/main/templates/simple-server-example) to use as a reference for how things should be stitched together.
|
||||
|
||||
## What does a tldraw sync backend do?
|
||||
|
||||
A backend for tldraw sync consists of two or three parts:
|
||||
|
||||
- A **WebSocket server** that provides rooms for each shared document, and is responsible for synchronizing and persisting document state.
|
||||
- An **asset storage** provider for large binary files like images and videos.
|
||||
- (If using the built-in bookmark shape) An **unfurling service** to extract metadata about bookmark URLs.
|
||||
|
||||
On the frontend, there is just one part: the **sync client**, created using the [useSync](?) hook from the `@tldraw/sync` package.
|
||||
|
||||
Here's a simple client implementation:
|
||||
|
||||
```tsx
|
||||
import { Tldraw, TLAssetStore, Editor } from 'tldraw'
|
||||
import { useSync } from '@tldraw/sync'
|
||||
import 'tldraw/tldraw.css'
|
||||
import { uploadFileAndReturnUrl } from './assets'
|
||||
import { convertUrlToBookmarkAsset } from './unfurl'
|
||||
|
||||
function MyEditorComponent({ myRoomId }: { myRoomId: string }) {
|
||||
// This hook creates a sync client that manages the websocket connection to the server
|
||||
// and coordinates updates to the document state.
|
||||
const store = useSync({
|
||||
// This is how you tell the sync client which server and room to connect to.
|
||||
uri: `wss://my-custom-backend.com/connect/${myRoomId}`,
|
||||
// This is how you tell the sync client how to store and retrieve blobs.
|
||||
assets: myAssetStore,
|
||||
})
|
||||
// When the tldraw Editor mounts, you can register an asset handler for the bookmark URLs.
|
||||
return <Tldraw store={store} onMount={registerUrlHandler} />
|
||||
}
|
||||
|
||||
const myAssetStore: TLAssetStore = {
|
||||
upload(asset, file) {
|
||||
return uploadFileAndReturnUrl(file)
|
||||
},
|
||||
resolve(asset) {
|
||||
return asset.props.src
|
||||
},
|
||||
}
|
||||
|
||||
function registerUrlHandler(editor: Editor) {
|
||||
editor.registerExternalAssetHandler('url', async ({ url }) => {
|
||||
return await convertUrlToBookmarkAsset(url)
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
And [here's a full working example](https://github.com/tldraw/tldraw/blob/main/templates/simple-server-example/src/client/App.tsx) of the client-side code.
|
||||
|
||||
### WebSocket server
|
||||
|
||||
The `@tldraw/sync-core` package exports a class called [TLSocketRoom](?) that should be created server-side on a per-document basis.
|
||||
|
||||
`TLSocketRoom` stores the authoritative copy of the document, relays changes between sync clients over WebSockets, and exposes hooks for persisting the document when it changes.
|
||||
|
||||
Attach each incoming WebSocket to the room and forward its events:
|
||||
|
||||
```tsx
|
||||
room.handleSocketConnect({ sessionId, socket })
|
||||
// If your platform doesn't let the room add listeners to the socket itself
|
||||
// (Bun.serve, Cloudflare hibernation), forward events by hand:
|
||||
room.handleSocketMessage(sessionId, message)
|
||||
room.handleSocketClose(sessionId)
|
||||
```
|
||||
|
||||
<Callout type="info">
|
||||
You should make sure that there's only ever one `TLSocketRoom` globally for each room in your app.
|
||||
If there's more than one, users won't see each other and will overwrite others' changes. We use
|
||||
[Durable Objects](https://developers.cloudflare.com/durable-objects/) to achieve this on
|
||||
tldraw.com.
|
||||
</Callout>
|
||||
|
||||
Read the reference docs for [TLSocketRoom](?), and see an example of how to use it in the [simple server example](https://github.com/tldraw/tldraw/blob/main/templates/simple-server-example/src/server/rooms.ts).
|
||||
|
||||
### Storage backends
|
||||
|
||||
`TLSocketRoom` requires a storage backend to persist document state. The `@tldraw/sync-core` package provides two options:
|
||||
|
||||
#### InMemorySyncStorage (default)
|
||||
|
||||
[InMemorySyncStorage](?) keeps all document state in memory. It's simple to use, but data is lost when the process restarts. To persist data, pass an `onChange` callback to the constructor and save snapshots to your database.
|
||||
|
||||
```tsx
|
||||
import { InMemorySyncStorage, TLSocketRoom } from '@tldraw/sync-core'
|
||||
|
||||
const storage = new InMemorySyncStorage({
|
||||
snapshot: existingData, // optional: load from your database
|
||||
onChange() {
|
||||
// Save to your database when changes occur
|
||||
saveToDatabase(storage.getSnapshot())
|
||||
},
|
||||
})
|
||||
|
||||
const room = new TLSocketRoom({ storage })
|
||||
```
|
||||
|
||||
#### SQLiteSyncStorage (recommended for persistence)
|
||||
|
||||
[SQLiteSyncStorage](?) stores document state in SQLite. Data survives process restarts automatically. This is the recommended approach for production deployments.
|
||||
|
||||
##### Cloudflare Durable Objects
|
||||
|
||||
```tsx
|
||||
import { DurableObject } from 'cloudflare:workers'
|
||||
import { SQLiteSyncStorage, DurableObjectSqliteSyncWrapper, TLSocketRoom } from '@tldraw/sync-core'
|
||||
|
||||
export class TLSyncDurableObject extends DurableObject {
|
||||
private room: TLSocketRoom
|
||||
|
||||
constructor(ctx: DurableObjectState, env: Env) {
|
||||
super(ctx, env)
|
||||
const sql = new DurableObjectSqliteSyncWrapper(ctx.storage)
|
||||
const storage = new SQLiteSyncStorage({ sql })
|
||||
this.room = new TLSocketRoom({ storage })
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
##### Node.js with better-sqlite3 or node:sqlite
|
||||
|
||||
```tsx
|
||||
import Database from 'better-sqlite3' // replace with 'node:sqlite' if using
|
||||
import { SQLiteSyncStorage, NodeSqliteWrapper, TLSocketRoom } from '@tldraw/sync-core'
|
||||
|
||||
const db = new Database('rooms.db')
|
||||
const sql = new NodeSqliteWrapper(db)
|
||||
const storage = new SQLiteSyncStorage({ sql })
|
||||
const room = new TLSocketRoom({ storage })
|
||||
```
|
||||
|
||||
<Callout type="info">
|
||||
`SQLiteSyncStorage` automatically creates and manages its database tables. You can pass a
|
||||
`tablePrefix` option to the SQL wrapper to avoid conflicts if you're sharing a database with other
|
||||
data.
|
||||
</Callout>
|
||||
|
||||
### WebSocket hibernation
|
||||
|
||||
Some serverless platforms let WebSocket connections survive while the surrounding object hibernates. [Cloudflare Durable Objects](https://developers.cloudflare.com/durable-objects/best-practices/websockets/) is the most common example. The platform keeps the sockets open, but your in-memory state (including the `TLSocketRoom`) is gone when the object wakes back up. Without help, every wake forces every client to reconnect.
|
||||
|
||||
`TLSocketRoom` exposes three APIs for hibernation. The `onSessionSnapshot` callback fires when a session has had no message activity for about 5 seconds, so you can persist its state. [TLSocketRoom#getSessionSnapshot](?) returns that snapshot on demand. [TLSocketRoom#handleSocketResume](?) restores a session straight into `Connected` state when the object wakes back up.
|
||||
|
||||
Here's the pattern in a Cloudflare Durable Object that uses the WebSocket Hibernation API:
|
||||
|
||||
```tsx
|
||||
import {
|
||||
DurableObjectSqliteSyncWrapper,
|
||||
type SessionStateSnapshot,
|
||||
SQLiteSyncStorage,
|
||||
TLSocketRoom,
|
||||
} from '@tldraw/sync-core'
|
||||
import { TLRecord } from '@tldraw/tlschema'
|
||||
import { DurableObject } from 'cloudflare:workers'
|
||||
|
||||
interface SocketAttachment {
|
||||
sessionId: string
|
||||
snapshot?: SessionStateSnapshot
|
||||
}
|
||||
|
||||
export class TldrawDurableObject extends DurableObject {
|
||||
private room: TLSocketRoom<TLRecord, void> | null = null
|
||||
// Maps sessionId → ws. Populate this in your webSocketMessage handler.
|
||||
private readonly sessionIdToWs = new Map<string, WebSocket>()
|
||||
|
||||
private getOrCreateRoom() {
|
||||
if (this.room) return this.room
|
||||
|
||||
const sql = new DurableObjectSqliteSyncWrapper(this.ctx.storage)
|
||||
const storage = new SQLiteSyncStorage<TLRecord>({ sql })
|
||||
|
||||
this.room = new TLSocketRoom<TLRecord, void>({
|
||||
storage,
|
||||
// Cloudflare keeps WebSockets alive across hibernation, so let it manage timeouts.
|
||||
clientTimeout: Infinity,
|
||||
// Persist each session's snapshot to its WebSocket attachment when it goes idle.
|
||||
onSessionSnapshot: (sessionId, snapshot) => {
|
||||
const ws = this.sessionIdToWs.get(sessionId)
|
||||
if (ws) ws.serializeAttachment({ sessionId, snapshot })
|
||||
},
|
||||
})
|
||||
|
||||
// Resume any sessions whose sockets survived hibernation.
|
||||
for (const ws of this.ctx.getWebSockets()) {
|
||||
const attachment = ws.deserializeAttachment() as SocketAttachment | null
|
||||
if (attachment?.snapshot) {
|
||||
this.room.handleSocketResume({
|
||||
sessionId: attachment.sessionId,
|
||||
socket: ws,
|
||||
snapshot: attachment.snapshot,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return this.room
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Three pieces make this work. The `onSessionSnapshot` callback persists each session's state to its WebSocket's attachment so the snapshot is still around after hibernation. When the object wakes up with sockets still open, `handleSocketResume` replays each saved snapshot and the session lands straight in `Connected` state; the client never sees a reconnect. Setting `clientTimeout: Infinity` disables the room's idle timer; hibernating platforms handle keep-alive themselves and would otherwise see the room disconnect healthy clients.
|
||||
|
||||
For a complete implementation, including the hibernation event handlers that populate `sessionIdToWs`, see the [sync-cloudflare template](https://github.com/tldraw/tldraw/tree/main/templates/sync-cloudflare).
|
||||
|
||||
For non-hibernating environments (Node servers, long-lived processes), you don't need any of this. The in-memory `TLSocketRoom` outlives individual sockets, and the default `clientTimeout` (20 seconds) removes idle sessions.
|
||||
|
||||
### Asset storage
|
||||
|
||||
Tldraw also needs a way to store and retrieve large binary assets like images and videos.
|
||||
|
||||
You'll need to make sure your backend can handle asset uploads & downloads, then implement
|
||||
[TLAssetStore](?) to connect it to tldraw.
|
||||
|
||||
- Read about [how assets work in tldraw](/docs/assets).
|
||||
- Read the [TLAssetStore](?) reference docs.
|
||||
- See a complete example of an asset store in the
|
||||
[`sync-cloudflare`](https://github.com/tldraw/tldraw/blob/main/templates/sync-cloudflare/client/multiplayerAssetStore.tsx)
|
||||
template.
|
||||
|
||||
### Unfurling service
|
||||
|
||||
If you want to use the built-in bookmark shape, you'll need to use or implement an unfurling service that returns metadata about URLs.
|
||||
|
||||
This should be registered with the [Editor](?) when it loads.
|
||||
|
||||
```tsx
|
||||
<Tldraw
|
||||
store={store}
|
||||
onMount={(editor) => {
|
||||
editor.registerExternalAssetHandler('url', unfurlBookmarkUrl)
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
Refer to the simple server example for example [client](https://github.com/tldraw/tldraw/blob/main/templates/simple-server-example/src/client/App.tsx) and [server](https://github.com/tldraw/tldraw/blob/main/templates/simple-server-example/src/server/unfurl.ts) code.
|
||||
|
||||
## Using tldraw sync in your app
|
||||
|
||||
### Custom shapes & bindings
|
||||
|
||||
`@tldraw/sync` validates the contents of your document and runs migrations to make sure clients of
|
||||
different versions can collaborate without issue. To support this, you need to make sure that both
|
||||
the sync client and server know about any custom shapes or bindings you've added.
|
||||
|
||||
#### On the client
|
||||
|
||||
You can pass `shapeUtils` and `bindingUtils` props to `useSync`. Unlike `<Tldraw />`,
|
||||
these don't automatically include tldraw's default shapes like arrows and rectangles. You should
|
||||
pass those in explicitly if you're using them:
|
||||
|
||||
```tsx
|
||||
import { useMemo } from 'react'
|
||||
import { Tldraw, defaultShapeUtils, defaultBindingUtils } from 'tldraw'
|
||||
import { useSync } from '@tldraw/sync'
|
||||
|
||||
function MyApp() {
|
||||
const store = useSync({
|
||||
uri: '...',
|
||||
assets: myAssetStore,
|
||||
shapeUtils: useMemo(() => [...customShapeUtils, ...defaultShapeUtils], []),
|
||||
bindingUtils: useMemo(() => [...customBindingUtils, ...defaultBindingUtils], []),
|
||||
})
|
||||
|
||||
return <Tldraw store={store} shapeUtils={customShapeUtils} bindingUtils={customBindingUtils} />
|
||||
}
|
||||
```
|
||||
|
||||
#### On the server
|
||||
|
||||
Use [createTLSchema](?) to create a store schema, and pass that into [TLSocketRoom](?). You can
|
||||
use shape/binding utils here, but the schema only reads three properties:
|
||||
[`props`](/reference/editor/ShapeUtil#props), `meta`, and
|
||||
[`migrations`](/sdk-features/persistence#Migrations). You need to provide the default shape
|
||||
schemas if you're using them.
|
||||
|
||||
```tsx
|
||||
import { createTLSchema, defaultShapeSchemas, defaultBindingSchemas } from '@tldraw/tlschema'
|
||||
import { TLSocketRoom } from '@tldraw/sync-core'
|
||||
|
||||
const schema = createTLSchema({
|
||||
shapes: {
|
||||
...defaultShapeSchemas,
|
||||
|
||||
myCustomShape: {
|
||||
// Validations for this shape's `props`.
|
||||
props: myCustomShapeProps,
|
||||
// Migrations between versions of this shape.
|
||||
migrations: myCustomShapeMigrations,
|
||||
},
|
||||
|
||||
// The schema knows about this shape, but it has no migrations or validation.
|
||||
mySimpleShape: {},
|
||||
},
|
||||
bindings: defaultBindingSchemas,
|
||||
})
|
||||
|
||||
// Later, in your app server:
|
||||
const room = new TLSocketRoom({
|
||||
schema: schema,
|
||||
// ...
|
||||
})
|
||||
```
|
||||
|
||||
Both `props` and `migrations` are optional. If you omit `props`, you won't have any server-side
|
||||
validation for your shape, which could result in bad data being stored. If you omit `migrations`,
|
||||
clients on different versions won't be able to collaborate without errors.
|
||||
|
||||
### Comments and the object-store lane
|
||||
|
||||
A room can serve some record types through a second partition, the object-store lane, which is persisted and permissioned separately from the document. Comments are the main use for it.
|
||||
|
||||
Comment threads, comments, and reactions are record types that aren't in the default schema. Register
|
||||
them with the `records` option on both ends: `useSync` on the client, [createTLSchema](?) on the
|
||||
server. Both sides must register the same types, or the connection will fail schema validation.
|
||||
|
||||
```ts
|
||||
import { TLSocketRoom } from '@tldraw/sync-core'
|
||||
import { commentSchemaRecords, createTLSchema } from '@tldraw/tlschema'
|
||||
|
||||
const schema = createTLSchema({ records: commentSchemaRecords })
|
||||
|
||||
const room = new TLSocketRoom({
|
||||
schema,
|
||||
objectTypes: ['comment', 'comment-thread', 'comment-reaction'],
|
||||
})
|
||||
```
|
||||
|
||||
`objectTypes` serves those record types through the room's object-store lane instead of the document
|
||||
lane. Lane records are persisted separately from the document, left out of document snapshots, and
|
||||
gated by a per-session `objectAccess` permission rather than by `isReadonly`. That's how a session
|
||||
can be allowed to comment without being allowed to edit:
|
||||
|
||||
```ts
|
||||
room.handleSocketConnect({
|
||||
sessionId,
|
||||
socket,
|
||||
isReadonly: true,
|
||||
objectAccess: 'write',
|
||||
})
|
||||
```
|
||||
|
||||
`objectAccess` is `'read'` or `'write'` and defaults to `'write'` on connect. Read the lane's contents with
|
||||
[TLSocketRoom#getCurrentObjectsSnapshot](?), and use the room's `onCommittedChanges` callback to
|
||||
mirror committed records into your own database. That callback only fires for client pushes, not for
|
||||
`updateStore`, `loadSnapshot`, or direct storage writes. For per-record rules like "only the author
|
||||
may edit a comment", pass authorizers to the room's `authorizeRecord` option (see [TLRecordAuthorizers](?)).
|
||||
See [Commenting](/sdk-features/commenting) for the client side and the ready-made comment authorizers.
|
||||
|
||||
### Deployment concerns
|
||||
|
||||
<Callout type="warning">
|
||||
You must make sure that the tldraw version in your client matches the version on the server. We
|
||||
don't guarantee server backwards compatibility forever, and very occasionally we might release a
|
||||
version where the backend cannot meaningfully support older clients, in which case tldraw will
|
||||
display a "please refresh the page" message. So you should make sure that the backend is updated
|
||||
at the same time as the client, and that the new backend is up and running just before the new
|
||||
client rolls out.
|
||||
</Callout>
|
||||
|
||||
### Migrating data from a legacy system
|
||||
|
||||
If you have been using some other solution for data sync, you can migrate your existing data to the tldraw sync format.
|
||||
|
||||
[SQLiteSyncStorage](?) accepts a [TLStoreSnapshot](?) directly, and [TLSocketRoom#loadSnapshot](?) accepts one for any storage backend, so you can add a backwards-compatibility layer that lazily imports data from your old system and converts it to a `TLStoreSnapshot`.
|
||||
|
||||
#### With SQLiteSyncStorage (recommended)
|
||||
|
||||
```tsx
|
||||
import { SQLiteSyncStorage, NodeSqliteWrapper, TLSocketRoom } from '@tldraw/sync-core'
|
||||
import Database from 'better-sqlite3'
|
||||
|
||||
function loadOrMakeRoom(roomId: string, db: Database.Database) {
|
||||
const sql = new NodeSqliteWrapper(db, { tablePrefix: `room_${roomId}_` })
|
||||
|
||||
// Check if we already have data in SQLite
|
||||
if (SQLiteSyncStorage.hasBeenInitialized(sql)) {
|
||||
const storage = new SQLiteSyncStorage({ sql })
|
||||
return new TLSocketRoom({ storage })
|
||||
}
|
||||
|
||||
// Try to load from legacy system
|
||||
const legacyData = loadRoomDataFromLegacyStore(roomId)
|
||||
if (legacyData) {
|
||||
const snapshot = convertOldDataToSnapshot(legacyData)
|
||||
const storage = new SQLiteSyncStorage({ sql, snapshot })
|
||||
deleteLegacyRoomData(roomId)
|
||||
return new TLSocketRoom({ storage })
|
||||
}
|
||||
|
||||
// No data - create a new empty room
|
||||
return new TLSocketRoom({ storage: new SQLiteSyncStorage({ sql }) })
|
||||
}
|
||||
```
|
||||
|
||||
#### With InMemorySyncStorage
|
||||
|
||||
```tsx
|
||||
import { InMemorySyncStorage, TLSocketRoom } from '@tldraw/sync-core'
|
||||
|
||||
async function loadOrMakeRoom(roomId: string) {
|
||||
// InMemorySyncStorage takes a RoomSnapshot, which is what getSnapshot() returns.
|
||||
const data = await loadRoomDataFromCurrentStore(roomId)
|
||||
if (data) {
|
||||
const storage = new InMemorySyncStorage({ snapshot: data })
|
||||
return new TLSocketRoom({ storage })
|
||||
}
|
||||
const legacyData = await loadRoomDataFromLegacyStore(roomId)
|
||||
if (legacyData) {
|
||||
// Convert your old data to a TLStoreSnapshot.
|
||||
const snapshot = convertOldDataToSnapshot(legacyData)
|
||||
// Load it into the room. loadSnapshot accepts a TLStoreSnapshot.
|
||||
const storage = new InMemorySyncStorage()
|
||||
const room = new TLSocketRoom({ storage })
|
||||
room.loadSnapshot(snapshot)
|
||||
// Save an updated copy of the snapshot in the new place
|
||||
// so that next time we can load it directly.
|
||||
await saveRoomData(roomId, storage.getSnapshot())
|
||||
// Optionally delete the old data.
|
||||
await deleteLegacyRoomData(roomId)
|
||||
// And finally return the room.
|
||||
return room
|
||||
}
|
||||
// If there's no data at all, just make a new blank room.
|
||||
return new TLSocketRoom({ storage: new InMemorySyncStorage() })
|
||||
}
|
||||
```
|
||||
|
||||
### Migrating from R2 to SQLite on Cloudflare
|
||||
|
||||
If you were previously using R2 to store room snapshots (as shown in earlier versions of the sync-cloudflare template), you can migrate to SQLite storage while preserving existing data.
|
||||
|
||||
#### Step 1: Update wrangler.toml if needed
|
||||
|
||||
If your Durable Object class was originally created _without_ SQLite support, you need to add a new migration with a new Durable Object class that uses SQLite storage.
|
||||
You need to keep the old class for at least one release because it can't be deleted while it's still being used, but you can convert it to an empty class.
|
||||
|
||||
```toml
|
||||
# Keep existing migration for old class
|
||||
[[migrations]]
|
||||
tag = "v1"
|
||||
new_classes = ["TldrawDurableObject"]
|
||||
|
||||
# Add new migration for SQLite-backed class
|
||||
[[migrations]]
|
||||
tag = "v2"
|
||||
new_sqlite_classes = ["TldrawDurableObjectSqlite"]
|
||||
|
||||
[durable_objects]
|
||||
bindings = [
|
||||
# Point to the new SQLite-backed class
|
||||
{ name = "TLDRAW_DURABLE_OBJECT", class_name = "TldrawDurableObjectSqlite" },
|
||||
]
|
||||
```
|
||||
|
||||
#### Step 2: Rename your Durable Object and add fallback loading
|
||||
|
||||
Rename your existing class (e.g. `TldrawDurableObject` → `TldrawDurableObjectSqlite`) and update the loading logic to check SQLite first, then fall back to R2:
|
||||
|
||||
```tsx
|
||||
import {
|
||||
DurableObjectSqliteSyncWrapper,
|
||||
RoomSnapshot,
|
||||
SQLiteSyncStorage,
|
||||
TLSocketRoom,
|
||||
} from '@tldraw/sync-core'
|
||||
import { TLRecord, createTLSchema, defaultShapeSchemas } from '@tldraw/tlschema'
|
||||
import { AutoRouter, error, IRequest } from 'itty-router'
|
||||
|
||||
// Empty stub for the old class name - required until migration is complete
|
||||
export class TldrawDurableObject {}
|
||||
|
||||
const schema = createTLSchema({ shapes: defaultShapeSchemas })
|
||||
|
||||
// Renamed from TldrawDurableObject
|
||||
export class TldrawDurableObjectSqlite {
|
||||
private roomPromise: Promise<TLSocketRoom<TLRecord, void>> | null = null
|
||||
private roomId: string | null = null
|
||||
|
||||
constructor(
|
||||
private ctx: DurableObjectState,
|
||||
private env: Env
|
||||
) {
|
||||
// Load roomId from DO storage (needed for R2 fallback)
|
||||
ctx.blockConcurrencyWhile(async () => {
|
||||
this.roomId = (await ctx.storage.get('roomId')) as string | null
|
||||
})
|
||||
}
|
||||
|
||||
// ... handleConnect, router, etc. stay the same ...
|
||||
|
||||
private async loadRoom(): Promise<TLSocketRoom<TLRecord, void>> {
|
||||
const sql = new DurableObjectSqliteSyncWrapper(this.ctx.storage)
|
||||
|
||||
// If SQLite already has data, use it directly
|
||||
if (SQLiteSyncStorage.hasBeenInitialized(sql)) {
|
||||
const storage = new SQLiteSyncStorage<TLRecord>({ sql })
|
||||
return new TLSocketRoom<TLRecord, void>({ schema, storage })
|
||||
}
|
||||
|
||||
// Try to load from R2 (legacy storage, only happens once per room)
|
||||
if (this.roomId) {
|
||||
const r2Object = await this.env.TLDRAW_BUCKET.get(`rooms/${this.roomId}`)
|
||||
if (r2Object) {
|
||||
const snapshot = (await r2Object.json()) as RoomSnapshot
|
||||
const storage = new SQLiteSyncStorage<TLRecord>({ sql, snapshot })
|
||||
|
||||
// Optionally delete the R2 object after successful migration
|
||||
// await this.env.TLDRAW_BUCKET.delete(`rooms/${this.roomId}`)
|
||||
|
||||
return new TLSocketRoom<TLRecord, void>({ schema, storage })
|
||||
}
|
||||
}
|
||||
|
||||
// No existing data - create fresh room
|
||||
const storage = new SQLiteSyncStorage<TLRecord>({ sql })
|
||||
return new TLSocketRoom<TLRecord, void>({ schema, storage })
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Step 3: Update your worker exports
|
||||
|
||||
Make sure both classes are exported from your worker entry point:
|
||||
|
||||
```tsx
|
||||
export { TldrawDurableObject, TldrawDurableObjectSqlite } from './TldrawDurableObject'
|
||||
```
|
||||
|
||||
The `loadRoom` function checks SQLite first (fast path for already-migrated rooms), falls back to R2 if SQLite is empty (one-time migration), and creates fresh storage for new rooms. You can optionally delete R2 data after migration to save storage costs.
|
||||
|
||||
Once all your rooms have been accessed at least once, the migration is complete. You can then remove the R2 fallback code and the stub `TldrawDurableObject` class.
|
||||
72
apps/docs/content/docs/tools.mdx
Normal file
72
apps/docs/content/docs/tools.mdx
Normal file
@@ -0,0 +1,72 @@
|
||||
---
|
||||
title: Tools
|
||||
status: published
|
||||
author: steveruizok
|
||||
date: 3/22/2023
|
||||
order: 3
|
||||
keywords:
|
||||
- custom
|
||||
- tools
|
||||
- state
|
||||
---
|
||||
|
||||
In tldraw, a **tool** is a top-level state in our state chart. The select tool, draw tool, and arrow tool are all examples of tools—each defines how the editor responds to user input while that tool is active.
|
||||
|
||||
<Image
|
||||
src="/images/api/tools.png"
|
||||
alt="A diagram showing the state chart of tldraw. The top row of states (apart from the Root state) are annotated as tools."
|
||||
caption="The first level of states in the state chart are known as tools."
|
||||
/>
|
||||
|
||||
## Default and custom tools
|
||||
|
||||
The `<Tldraw>` component includes default tools like [SelectTool](?), [HandTool](?), [DrawShapeTool](?), and [ArrowShapeTool](?). The core `@tldraw/editor` package has no built-in tools. If you use `<TldrawEditor>` directly, you provide your own.
|
||||
|
||||
You can create custom tools by extending [StateNode](?) and passing them to the `tools` prop:
|
||||
|
||||
```tsx
|
||||
import { StateNode, Tldraw, toRichText } from 'tldraw'
|
||||
|
||||
class StampTool extends StateNode {
|
||||
static override id = 'stamp'
|
||||
|
||||
override onPointerDown() {
|
||||
const { x, y } = this.editor.inputs.getCurrentPagePoint()
|
||||
this.editor.createShape({ type: 'text', x, y, props: { richText: toRichText('❤️') } })
|
||||
}
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return <Tldraw tools={[StampTool]} />
|
||||
}
|
||||
```
|
||||
|
||||
Registering a tool adds it to the state chart but not to the toolbar. To add a toolbar button and keyboard shortcut, see the [add tool to toolbar example](/examples/ui/add-tool-to-toolbar). Tools with multiple states declare child states with `static children()`; see the [Tools](/sdk-features/tools) guide.
|
||||
|
||||
## Changing tools
|
||||
|
||||
Change the active tool with [Editor#setCurrentTool](?), and read it with [Editor#getCurrentToolId](?):
|
||||
|
||||
```ts
|
||||
editor.setCurrentTool('select')
|
||||
editor.setCurrentTool('hand')
|
||||
editor.setCurrentTool('draw')
|
||||
editor.getCurrentToolId() // 'draw'
|
||||
```
|
||||
|
||||
## Learn more
|
||||
|
||||
| Guide | Covers |
|
||||
| ---------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
|
||||
| [Tools](/sdk-features/tools) | State hierarchy, event handling, child states, tool lock, creating custom tools, and overriding defaults |
|
||||
| [Events](/sdk-features/events) | How the editor dispatches events and how to subscribe to them |
|
||||
| [Input handling](/sdk-features/input-handling) | Pointer tracking, keyboard state, and the `editor.inputs` API |
|
||||
| [Ticks](/sdk-features/ticks) | Frame-synchronized updates for animations and continuous interactions |
|
||||
|
||||
## Examples
|
||||
|
||||
| Example | Description |
|
||||
| ----------------------------------------------------------------------- | ---------------------------------------------------- |
|
||||
| [Custom tool](/examples/shapes/tools/custom-tool) | A simple tool that adds stickers to the canvas |
|
||||
| [Tool with child states](/examples/shapes/tools/tool-with-child-states) | A tool with multiple states for complex interactions |
|
||||
| [Screenshot tool](/examples/shapes/tools/screenshot-tool) | A tool for capturing canvas regions |
|
||||
60
apps/docs/content/docs/user-interface.mdx
Normal file
60
apps/docs/content/docs/user-interface.mdx
Normal file
@@ -0,0 +1,60 @@
|
||||
---
|
||||
title: User interface
|
||||
status: published
|
||||
author: steveruizok
|
||||
date: 3/22/2023
|
||||
order: 3
|
||||
keywords:
|
||||
- ui
|
||||
- interface
|
||||
- tools
|
||||
- shapes
|
||||
- custom
|
||||
- button
|
||||
- toolbar
|
||||
- styles
|
||||
readability: 8
|
||||
voice: 8
|
||||
completeness: 8
|
||||
accuracy: 9
|
||||
notes: 'Brief intro that links to sdk-features articles.'
|
||||
---
|
||||
|
||||
The `tldraw` package includes a complete user interface with menus, toolbars, keyboard shortcuts, and style panels. You can hide it entirely, listen to events, or customize individual components.
|
||||
|
||||
Each of those is a prop on `<Tldraw>`: `hideUi` hides everything, `components` replaces or removes individual pieces, and `onUiEvent` reports every UI interaction:
|
||||
|
||||
```tsx
|
||||
import { Tldraw } from 'tldraw'
|
||||
import 'tldraw/tldraw.css'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0 }}>
|
||||
<Tldraw
|
||||
hideUi={false}
|
||||
components={{ PageMenu: null }}
|
||||
onUiEvent={(name, data) => console.log(name, data)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Learn more
|
||||
|
||||
- [UI components](/sdk-features/ui-components) — Component architecture, overrides, and customization
|
||||
- [UI primitives](/sdk-features/ui-primitives) — Buttons, menus, dialogs for building custom interfaces
|
||||
- [Actions](/sdk-features/actions) — Add or override actions and keyboard shortcuts
|
||||
- [Styles](/sdk-features/styles) — The style panel and custom style properties
|
||||
- [Themes](/sdk-features/themes) — Color modes and custom color palettes
|
||||
- [Accessibility](/sdk-features/accessibility) — Keyboard navigation and screen reader support
|
||||
- [Internationalization](/sdk-features/internationalization) — Translations and language support
|
||||
|
||||
## Examples
|
||||
|
||||
- [Hide UI](/examples/ui/hide-ui) — Hide the entire default interface
|
||||
- [Custom UI](/examples/ui/custom-ui) — Build a completely custom interface
|
||||
- [Hide UI components](/examples/ui/ui-components-hidden) — Selectively hide specific components
|
||||
- [Action overrides](/examples/ui/action-overrides) — Customize actions and shortcuts
|
||||
- [UI events](/examples/events/ui-events) — Track user interactions
|
||||
211
apps/docs/content/getting-started/installation.mdx
Normal file
211
apps/docs/content/getting-started/installation.mdx
Normal file
@@ -0,0 +1,211 @@
|
||||
---
|
||||
title: Installation
|
||||
status: published
|
||||
author: steveruizok
|
||||
date: 3/22/2023
|
||||
order: 1
|
||||
keywords:
|
||||
- hello world
|
||||
---
|
||||
|
||||
To use the tldraw SDK, first install the `tldraw` package:
|
||||
|
||||
```bash
|
||||
npm install tldraw
|
||||
```
|
||||
|
||||
Now import and use the [Tldraw](?) component inside of any React component. You will need React 18 or 19.
|
||||
|
||||
```tsx
|
||||
import { Tldraw } from 'tldraw'
|
||||
import 'tldraw/tldraw.css'
|
||||
|
||||
export default function () {
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0 }}>
|
||||
<Tldraw />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Wrapper
|
||||
|
||||
The [Tldraw](?) component must be wrapped in a parent container with an explicit size. Its height and width are set to `100%`, so it will fill its parent container.
|
||||
|
||||
### Accessing the editor
|
||||
|
||||
Use the `onMount` prop to access the [Editor](?) instance when it's ready:
|
||||
|
||||
```tsx
|
||||
function App() {
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0 }}>
|
||||
<Tldraw
|
||||
onMount={(editor) => {
|
||||
// Do something with the editor
|
||||
editor.selectAll()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
The callback can return a cleanup function that runs when the editor unmounts.
|
||||
|
||||
### CSS
|
||||
|
||||
In addition to the [Tldraw](?) component itself, you should also import the `tldraw.css` file from the `tldraw` package.
|
||||
|
||||
```tsx
|
||||
import 'tldraw/tldraw.css'
|
||||
```
|
||||
|
||||
You can alternatively import this file inside of another CSS file using the `@import` syntax.
|
||||
|
||||
```css
|
||||
@import url('tldraw/tldraw.css');
|
||||
```
|
||||
|
||||
If you'd like to deeply change the way that tldraw looks, you can copy the `tldraw.css` file into a new CSS file, make your changes, and import that instead.
|
||||
|
||||
### Fonts
|
||||
|
||||
The tldraw SDK bundles its own fonts for shapes: IBM Plex (Sans, Serif, Mono) and Shantell Sans (for draw-style text). These are loaded automatically from the CDN or self-hosted [static assets](#Static-assets).
|
||||
|
||||
The tldraw UI will inherit its font family. You can set this to any font you like. For example, to use Inter:
|
||||
|
||||
```css
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@500;700&display=swap');
|
||||
|
||||
.tl-container {
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
```
|
||||
|
||||
### HTML
|
||||
|
||||
If you're using the [Tldraw](?) component in a full-screen app, update your `index.html`'s meta viewport element as shown below.
|
||||
|
||||
```html
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
```
|
||||
|
||||
Without these viewport options, some features like safe area positioning won't work correctly.
|
||||
|
||||
## License
|
||||
|
||||
If you have a [license key](https://tldraw.dev/pricing), you can pass your key to the `licenseKey` prop.
|
||||
|
||||
```tsx
|
||||
<Tldraw licenseKey={YOUR_LICENSE_KEY} />
|
||||
```
|
||||
|
||||
To learn more about the license and license key, visit [our pricing page](https://tldraw.dev/pricing).
|
||||
|
||||
## Static assets
|
||||
|
||||
To use the [Tldraw](?) component, the app must be able to find certain assets. These are contained in the `embed-icons`, `fonts`, `icons`, and `translations` folders. We offer a few different ways of making these assets available to your app.
|
||||
|
||||
### Using a bundler
|
||||
|
||||
If you're using a bundler like webpack or rollup, you can import the assets directly from the `@tldraw/assets` package. Your bundler will copy the assets with your bundle and insert the correct URLs in their place.
|
||||
|
||||
There are three options:
|
||||
|
||||
- `import {getAssetUrlsByImport} from '@tldraw/assets/imports'`: import asset files using import statements. You'll need to configure your bundler to treat imports of .svg, .png, .json, and .woff2 files as external assets.
|
||||
- `import {getAssetUrlsByImport} from '@tldraw/assets/imports.vite'`: import asset files, appending `?url` to the asset path. This works correctly with [vite](https://vitejs.dev/guide/assets#explicit-url-imports) without any extra configuration needed.
|
||||
- `import {getAssetUrlsByMetaUrl} from '@tldraw/assets/urls'`: get asset urls using `new URL(path, import.meta.url)`. This is a standards-based approach that works natively in most modern browsers & bundlers.
|
||||
|
||||
Call the function, and pass the resulting `assetUrls` into the `Tldraw` component:
|
||||
|
||||
```tsx
|
||||
import { getAssetUrlsByMetaUrl } from '@tldraw/assets/urls'
|
||||
|
||||
const assetUrls = getAssetUrlsByMetaUrl()
|
||||
|
||||
<Tldraw assetUrls={assetUrls} />
|
||||
```
|
||||
|
||||
### Using a public CDN
|
||||
|
||||
By default, we serve these assets from a public CDN. This works without configuration and is a good starting point.
|
||||
|
||||
If you would like to customize some of the assets you can pass the customizations to our [Tldraw](?) component. For example, to use a custom icon for the `hand` tool you can do the following:
|
||||
|
||||
```tsx
|
||||
const assetUrls = {
|
||||
icons: {
|
||||
'tool-hand': './custom-tool-hand.svg',
|
||||
},
|
||||
}
|
||||
|
||||
<Tldraw assetUrls={assetUrls} />
|
||||
```
|
||||
|
||||
This will use the custom icon for the `hand` tool and the default assets for everything else.
|
||||
|
||||
### Self-hosting assets
|
||||
|
||||
You can also host these assets yourself:
|
||||
|
||||
1. Download the `embed-icons`, `fonts`, `icons`, and `translations` folders from the [assets folder](https://github.com/tldraw/tldraw/tree/main/assets) of the tldraw repository.
|
||||
2. Place the folders in your project's public path.
|
||||
3. Pass `assetUrls` prop to our `<Tldraw/>` component to let the component know where the assets live.
|
||||
|
||||
You can use our `getAssetUrls` helper function from the `@tldraw/assets` package to generate these urls for you.
|
||||
|
||||
```tsx
|
||||
import { getAssetUrls } from '@tldraw/assets/selfHosted'
|
||||
|
||||
const assetUrls = getAssetUrls()
|
||||
|
||||
<Tldraw assetUrls={assetUrls} />
|
||||
```
|
||||
|
||||
While these files must be available, you can overwrite the individual files: for example, by placing different icons under the same name or by modifying or adding translations.
|
||||
|
||||
If you use a CDN for hosting these files you can specify the base url of your assets. To recreate the above option of serving the assets from our CDN you would do the following:
|
||||
|
||||
```ts
|
||||
import { getDefaultCdnBaseUrl } from 'tldraw'
|
||||
|
||||
const assetUrls = getAssetUrls({
|
||||
baseUrl: getDefaultCdnBaseUrl(),
|
||||
})
|
||||
```
|
||||
|
||||
## Subcomponents
|
||||
|
||||
The [Tldraw](?) component combines two lower-level components: [TldrawEditor](?) and [TldrawUi](?). If you want more granular control, you can use those lower-level components directly. See the [exploded example](/examples/configuration/exploded) for reference.
|
||||
|
||||
### Customizing components
|
||||
|
||||
You can customize the appearance of the tldraw editor and UI using the [Tldraw](?) (or [TldrawEditor](?)) component's `components` prop. This prop accepts a [TLComponents](?) object, which combines [TLEditorComponents](?) (canvas-level components like the background and grid) with [TLUiComponents](?) (UI elements like toolbar, menus, and panels).
|
||||
|
||||
```tsx
|
||||
import { TLComponents } from 'tldraw'
|
||||
|
||||
const components: TLComponents = {
|
||||
// Editor components
|
||||
Background: YourCustomBackground,
|
||||
Grid: YourCustomGrid,
|
||||
|
||||
// UI components
|
||||
Toolbar: YourCustomToolbar,
|
||||
MainMenu: YourCustomMainMenu,
|
||||
StylePanel: YourCustomStylePanel,
|
||||
ContextMenu: YourCustomContextMenu,
|
||||
}
|
||||
|
||||
<Tldraw components={components} />
|
||||
```
|
||||
|
||||
Canvas overlay elements like the selection foreground, brush, snap indicators, and scribble are rendered via the [OverlayUtil](?) system rather than React components. You can customize them by extending the built-in overlay utils and passing them via the `overlayUtils` prop.
|
||||
|
||||
See the [TLEditorComponents](?) and [TLUiComponents](?) type definitions for the complete list of customizable components.
|
||||
|
||||
## Versioning
|
||||
|
||||
The tldraw SDK does not follow semantic versioning. To learn more, read about how we do [release versioning](/releases).
|
||||
159
apps/docs/content/getting-started/quick-start.mdx
Normal file
159
apps/docs/content/getting-started/quick-start.mdx
Normal file
@@ -0,0 +1,159 @@
|
||||
---
|
||||
title: Quick start
|
||||
status: published
|
||||
author: steveruizok
|
||||
date: 3/22/2023
|
||||
order: 0
|
||||
keywords:
|
||||
- hello world
|
||||
- intro
|
||||
- start
|
||||
- installation
|
||||
---
|
||||
|
||||
Have five minutes? Run this command in your terminal to explore tldraw [starter kits](/starter-kits):
|
||||
|
||||
```bash
|
||||
npm create tldraw@latest
|
||||
```
|
||||
|
||||
Have a little more time? Let's try out the tldraw SDK in a React project.
|
||||
|
||||
If you're new to React, we recommend using a [Vite template](https://vitejs.dev/guide/#scaffolding-your-first-vite-project) as a starter. We'll assume your project is already running locally.
|
||||
|
||||
## Getting started
|
||||
|
||||
First, install the `tldraw` package from npm:
|
||||
|
||||
```bash
|
||||
npm install tldraw
|
||||
```
|
||||
|
||||
Next, in your React project, import the `Tldraw` component and tldraw's CSS styles. Then render the `Tldraw` component inside a full screen container:
|
||||
|
||||
```tsx
|
||||
import { Tldraw } from 'tldraw'
|
||||
import 'tldraw/tldraw.css'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0 }}>
|
||||
<Tldraw />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
That's pretty much it! At this point, you should have a complete working single-user canvas. You can draw, write, add images and video, zoom, pan, copy and paste, undo and redo—everything you'd expect from a canvas.
|
||||
|
||||
You'll be starting from our default [shapes](/docs/shapes), [tools](/docs/tools), and [user interface](/docs/user-interface), but you can customize all of these things for your project if you wish. For now, let's show off a few more features.
|
||||
|
||||
## Local persistence
|
||||
|
||||
Let's add local persistence by passing a `persistenceKey` prop to the `Tldraw` component:
|
||||
|
||||
<FocusLines lines={[4]}>
|
||||
|
||||
```tsx
|
||||
export default function App() {
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0 }}>
|
||||
<Tldraw persistenceKey="example" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
</FocusLines>
|
||||
|
||||
The `persistenceKey` prop saves your project to the browser's storage so that it survives a refresh. It also synchronizes the project with other instances that share the same key—even in other browser tabs! Give it a try by opening your app in a second window.
|
||||
|
||||
## Real-time collaboration
|
||||
|
||||
To add support for multiple users collaborating in real time, you can use the [**tldraw sync**](/docs/sync) library. Its demo hook connects your app to tldraw's demo server, which hosts temporary rooms.
|
||||
|
||||
First, install the `@tldraw/sync` package:
|
||||
|
||||
```bash
|
||||
npm install @tldraw/sync
|
||||
```
|
||||
|
||||
Next, import the `useSyncDemo` hook from the `@tldraw/sync` package. Call it in your component with a unique ID and pass the store that it returns to the `Tldraw` component:
|
||||
|
||||
<FocusLines lines={[2,6,10]}>
|
||||
|
||||
```tsx
|
||||
import { Tldraw } from 'tldraw'
|
||||
import { useSyncDemo } from '@tldraw/sync'
|
||||
import 'tldraw/tldraw.css'
|
||||
|
||||
export default function App() {
|
||||
const store = useSyncDemo({ roomId: 'insert-any-string-here' })
|
||||
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0 }}>
|
||||
<Tldraw store={store} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
</FocusLines>
|
||||
|
||||
Try it out by opening your project in a second incognito window, or else access it from another device. You should see all of tldraw's multiplayer features: live cursors, user names, viewport following, cursor chat, and more.
|
||||
|
||||
If you want to go further with real-time collaboration, be sure to check out our guide to the [**tldraw sync**](/docs/sync) library.
|
||||
|
||||
## Controlling the canvas
|
||||
|
||||
The tldraw [editor](/docs/editor) has a runtime JavaScript API. Everything that can happen in tldraw can be done programmatically through the [Editor](?) instance.
|
||||
|
||||
For simplicity's sake, let's roll back our persistence and sync code. We can then use the `Tldraw` component's `onMount` callback to get access to the Editor instance. We'll use the editor to create a new shape on the canvas, select it, then slowly zoom to it.
|
||||
|
||||
```tsx
|
||||
import { Editor, Tldraw, toRichText } from 'tldraw'
|
||||
import 'tldraw/tldraw.css'
|
||||
|
||||
export default function App() {
|
||||
const handleMount = (editor: Editor) => {
|
||||
editor.createShape({
|
||||
type: 'text',
|
||||
x: 200,
|
||||
y: 200,
|
||||
props: {
|
||||
richText: toRichText('Hello world!'),
|
||||
},
|
||||
})
|
||||
|
||||
editor.selectAll()
|
||||
|
||||
editor.zoomToSelection({
|
||||
animation: { duration: 5000 },
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0 }}>
|
||||
<Tldraw onMount={handleMount} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
The Editor is tldraw's main interface for controlling the canvas. Be sure to check out the [Editor](?) API documentation for more information on what you can do with it, as well as our [guide on using the editor](/docs/editor).
|
||||
|
||||
## Next steps
|
||||
|
||||
Now that you've seen how the tldraw canvas works, you can:
|
||||
|
||||
- Create your own [shapes](/docs/shapes) and [tools](/docs/tools)
|
||||
- Customize the [user interface](/docs/user-interface)
|
||||
- Learn more about the [editor](/docs/editor)
|
||||
- Explore our [examples](/examples)
|
||||
- Build with our [starter kits](/starter-kits)
|
||||
|
||||
The SDK lets you customize shapes, tools, UI, and more. In addition to our long-form docs, we have dozens of examples in our [examples section](/examples) that cover more of its functionality. You can run these locally with the tldraw [GitHub repository](https://github.com/tldraw/tldraw).
|
||||
|
||||
## Join our community
|
||||
|
||||
If you build something great, please share it with us in our [#show-and-tell](https://discord.tldraw.com/?utm_source=docs&utm_medium=organic&utm_campaign=sociallink) channel on Discord. Good luck!
|
||||
76
apps/docs/content/getting-started/releases.mdx
Normal file
76
apps/docs/content/getting-started/releases.mdx
Normal file
@@ -0,0 +1,76 @@
|
||||
---
|
||||
title: Releases
|
||||
status: published
|
||||
author: steveruizok
|
||||
order: 3
|
||||
---
|
||||
|
||||
Release notes for all tldraw SDK versions. Each minor release has its own page documenting new features, breaking changes, API additions, improvements, and bug fixes. Patch releases are listed within their parent minor release.
|
||||
|
||||
For the original releases, see the [GitHub releases page](https://github.com/tldraw/tldraw/releases).
|
||||
|
||||
## How tldraw is versioned
|
||||
|
||||
Unlike many JavaScript packages distributed on [NPM](https://www.npmjs.com/), the tldraw SDK does not follow [semantic versioning](https://semver.org/) in its release versions. Instead:
|
||||
|
||||
- Major version bumps are rare. We reserve them for fundamental changes to how the SDK works.
|
||||
- Minor version bumps are released on a regular cadence, approximately monthly. **They may contain breaking changes**. We aim to make breaking changes as minimally disruptive as possible, but tldraw is actively evolving as we add new features. We recommend updating at a similar pace and checking the release notes.
|
||||
- Patch version bumps are for bugfixes and hotfixes that can't wait for the next cadence release.
|
||||
|
||||
## Latest tldraw versions
|
||||
|
||||
- Run `npm install tldraw` to get the latest minor version of tldraw
|
||||
- Use `npm install tldraw@next` to get the latest version of tldraw that is used on [tldraw.com](https://www.tldraw.com)
|
||||
- The latest changes to the `main` branch of tldraw are available to test through our [pre-release canary builds](https://www.npmjs.com/package/tldraw?activeTab=versions).
|
||||
|
||||
## Migration skill
|
||||
|
||||
We ship an experimental `tldraw-migrate` agent skill that helps a coding agent update your project to the latest version of the SDK. It detects your current version, fetches the relevant release notes, upgrades packages, and works through any type errors using the migration guides in our release notes. See our [migration skill guide](/releases/migration-skill) for details on how to install and use the skill.
|
||||
|
||||
## Next
|
||||
|
||||
- [next](/releases/next) - Changes for the upcoming release.
|
||||
|
||||
## v5.x
|
||||
|
||||
- [v5.4](/releases/v5.4.0) - A center align operation, a tleditors registry of mounted editors, faster signals and store layers, and a broad reliability pass across the SDK
|
||||
- [v5.3](/releases/v5.3.0) - Commenting with anchored threads on the canvas, new collaboration packages, geo shape flipping, crop snapping, and faster viewport culling
|
||||
- [v5.2](/releases/v5.2.0) - Faster freehand ink, a frame selection action, SDK-wide performance improvements, and breaking changes to the collaborator user-id types and Node support
|
||||
- [v5.1](/releases/v5.1.0) - Page menu redesign, copy-styles shortcut, selectable locked shapes, public translation APIs, and performance improvements
|
||||
- [v5.0](/releases/v5.0.0) - Custom themes, overlays, performance, extensiblity, and attribution
|
||||
|
||||
## v4.x
|
||||
|
||||
- [v4.5](/releases/v4.5.0) - Click-through on transparent pixels, SVG sanitization, configurable embed definitions
|
||||
- [v4.4](/releases/v4.4.0) - Image pipeline starter kit, performance improvements, quick zoom, canvas indicators
|
||||
- [v4.3](/releases/v4.3.0) - SQLite sync storage, improved custom shape types, reactive inputs, draw shape encoding
|
||||
- [v4.2](/releases/v4.2.0) - TipTap v3, dynamic tools, custom socket implementations
|
||||
- [v4.1](/releases/v4.1.0) - Shader starter kit, localStorage atoms, minimap filtering
|
||||
- [v4.0](/releases/v4.0.0) - Starter kits, WCAG 2.2 AA compliance, licensing updates
|
||||
|
||||
## v3.x
|
||||
|
||||
- [v3.15](/releases/v3.15.0) - `npm create tldraw` CLI, accessibility improvements
|
||||
- [v3.14](/releases/v3.14.0) - Contextual toolbars, PathBuilder API
|
||||
- [v3.13](/releases/v3.13.0) - Elbow arrows for technical diagrams
|
||||
- [v3.12](/releases/v3.12.0) - Accessibility focus, keyboard navigation
|
||||
- [v3.11](/releases/v3.11.0) - Rich text fixes, zoom improvements
|
||||
- [v3.10](/releases/v3.10.0) - Rich text as first-class primitive
|
||||
- [v3.9](/releases/v3.9.0) - Layout improvements, AtomMap class
|
||||
- [v3.8](/releases/v3.8.0) - React 19 compatibility, i18n expansion
|
||||
- [v3.7](/releases/v3.7.0) - Presence sync customization
|
||||
- [v3.6](/releases/v3.6.0) - Expanded action helpers
|
||||
- [v3.5](/releases/v3.5.0) - Grid snapping, layer improvements
|
||||
- [v3.4](/releases/v3.4.0) - Excalidraw compatibility
|
||||
- [v3.3](/releases/v3.3.0) - Readonly mode, sync improvements
|
||||
- [v3.2](/releases/v3.2.0) - Version alignment release
|
||||
- [v3.1](/releases/v3.1.0) - Shape visibility, server-side updates
|
||||
- [v3.0](/releases/v3.0.0) - New licensing, deep links, custom embeds
|
||||
|
||||
## v2.x
|
||||
|
||||
- [v2.4](/releases/v2.4.0)
|
||||
- [v2.3](/releases/v2.3.0)
|
||||
- [v2.2](/releases/v2.2.0)
|
||||
- [v2.1](/releases/v2.1.0)
|
||||
- [v2.0](/releases/v2.0.0) - Initial public release
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user