snapshot of https://github.com/tldraw/tldraw.git (main, 2026-09-23; blobs > 131072B omitted)
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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