snapshot of https://github.com/tldraw/tldraw.git (main, 2026-09-23; blobs > 131072B omitted)
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
This commit is contained in:
30
apps/docs/components/common/autorefresh.tsx
Normal file
30
apps/docs/components/common/autorefresh.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { ReactNode, useEffect } from 'react'
|
||||
|
||||
let AutoRefresh = ({ children }: { children: ReactNode }) => {
|
||||
return children
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
AutoRefresh = function AutoRefresh({ children }) {
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
const ws = new WebSocket('ws://localhost:3201')
|
||||
ws.onmessage = (event) => {
|
||||
if (event.data === 'refresh') {
|
||||
router.refresh()
|
||||
}
|
||||
}
|
||||
return () => {
|
||||
ws.close()
|
||||
}
|
||||
}, [router])
|
||||
|
||||
return children
|
||||
}
|
||||
}
|
||||
|
||||
export default AutoRefresh
|
||||
16
apps/docs/components/common/back-to-top-button.tsx
Normal file
16
apps/docs/components/common/back-to-top-button.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
'use client'
|
||||
|
||||
import { ArrowLongUpIcon } from '@heroicons/react/20/solid'
|
||||
|
||||
export function BackToTopButton() {
|
||||
return (
|
||||
<button
|
||||
id="back-to-top"
|
||||
onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
|
||||
className="flex items-center gap-1.5 text-blue-500 hover:text-blue-600 dark:hover:bg-blue-400"
|
||||
>
|
||||
<ArrowLongUpIcon className="h-3.5 -mr-px" />
|
||||
<span>Scroll to top</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
88
apps/docs/components/common/button.tsx
Normal file
88
apps/docs/components/common/button.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
'use client'
|
||||
|
||||
import { ArrowLongLeftIcon, ArrowLongRightIcon } from '@heroicons/react/20/solid'
|
||||
import Link from 'next/link'
|
||||
import { MouseEventHandler } from 'react'
|
||||
import { track } from '@/app/analytics'
|
||||
import { Icon, IconName } from '@/components/common/icon'
|
||||
import { cn } from '@/utils/cn'
|
||||
|
||||
export function Button({
|
||||
id,
|
||||
href,
|
||||
newTab,
|
||||
onClick,
|
||||
caption,
|
||||
icon,
|
||||
arrow,
|
||||
className,
|
||||
size = 'base',
|
||||
type = 'primary',
|
||||
darkRingOffset,
|
||||
}: {
|
||||
id?: string
|
||||
href?: string
|
||||
newTab?: boolean
|
||||
onClick?: MouseEventHandler<HTMLButtonElement | HTMLAnchorElement>
|
||||
caption: string
|
||||
icon?: IconName
|
||||
arrow?: 'left' | 'right'
|
||||
className?: string
|
||||
size?: 'xs' | 'sm' | 'base' | 'lg'
|
||||
type?: 'primary' | 'secondary' | 'tertiary' | 'black'
|
||||
darkRingOffset?: boolean
|
||||
}) {
|
||||
const iconSizes = { xs: 'h-3', sm: 'h-3.5', base: 'h-4', lg: 'h-5' }
|
||||
className = cn(
|
||||
'relative overflow-hidden flex items-center focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:focus:ring-offset-zinc-900',
|
||||
darkRingOffset ? 'focus:ring-offset-zinc-900' : 'focus:ring-offset-zinc-50',
|
||||
size === 'xs' && 'h-6 px-2 gap-1.5 rounded-md text-xs',
|
||||
size === 'sm' && 'h-7 px-3 gap-2 rounded-md text-sm',
|
||||
size === 'base' && 'h-9 px-4 gap-2.5 rounded-lg text-base',
|
||||
size === 'lg' && 'h-11 px-5 gap-3 rounded-xl text-lg',
|
||||
type === 'primary' && 'bg-blue-500 text-white hover:bg-blue-600 dark:hover:bg-blue-400',
|
||||
type === 'black' &&
|
||||
'bg-black text-white dark:text-black hover:bg-zinc-800 dark:bg-white dark:hover:bg-zinc-100',
|
||||
type === 'secondary' &&
|
||||
'bg-zinc-100 text-zinc-800 dark:text-zinc-100 hover:bg-zinc-200 dark:bg-zinc-800 dark:hover:bg-zinc-700',
|
||||
type === 'tertiary' &&
|
||||
'text-black dark:text-white dark:hover:text-zinc-200 hover:text-zinc-700',
|
||||
className
|
||||
)
|
||||
// no word wrapping
|
||||
const spanClassName = cn('whitespace-nowrap')
|
||||
if (href)
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
target={newTab ? '_blank' : undefined}
|
||||
rel={newTab ? 'noopener noreferrer' : undefined}
|
||||
className={className}
|
||||
onClick={(e) => {
|
||||
onClick?.(e)
|
||||
if (id) track('button', { id })
|
||||
}}
|
||||
>
|
||||
{arrow === 'left' && <ArrowLongLeftIcon className={cn(iconSizes[size])} />}
|
||||
{icon && <Icon icon={icon} className={cn(iconSizes[size])} />}
|
||||
<span className={spanClassName}>{caption}</span>
|
||||
{arrow === 'right' && <ArrowLongRightIcon className={cn(iconSizes[size])} />}
|
||||
</Link>
|
||||
)
|
||||
if (onClick)
|
||||
return (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
if (id) track('button', { id })
|
||||
onClick?.(e)
|
||||
}}
|
||||
className={className}
|
||||
>
|
||||
{arrow === 'left' && <ArrowLongLeftIcon className={cn(iconSizes[size])} />}
|
||||
{icon && <Icon icon={icon} className={cn(iconSizes[size])} />}
|
||||
<span className={spanClassName}>{caption}</span>
|
||||
{arrow === 'right' && <ArrowLongRightIcon className={cn(iconSizes[size])} />}
|
||||
</button>
|
||||
)
|
||||
return null
|
||||
}
|
||||
13
apps/docs/components/common/icon/discord.tsx
Normal file
13
apps/docs/components/common/icon/discord.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { cn } from '@/utils/cn'
|
||||
|
||||
export function DiscordIcon({ className }: { className: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 640 512"
|
||||
className={cn('fill-current', className)}
|
||||
>
|
||||
<path d="M524.5 69.8a1.5 1.5 0 0 0 -.8-.7A485.1 485.1 0 0 0 404.1 32a1.8 1.8 0 0 0 -1.9 .9 337.5 337.5 0 0 0 -14.9 30.6 447.8 447.8 0 0 0 -134.4 0 309.5 309.5 0 0 0 -15.1-30.6 1.9 1.9 0 0 0 -1.9-.9A483.7 483.7 0 0 0 116.1 69.1a1.7 1.7 0 0 0 -.8 .7C39.1 183.7 18.2 294.7 28.4 404.4a2 2 0 0 0 .8 1.4A487.7 487.7 0 0 0 176 479.9a1.9 1.9 0 0 0 2.1-.7A348.2 348.2 0 0 0 208.1 430.4a1.9 1.9 0 0 0 -1-2.6 321.2 321.2 0 0 1 -45.9-21.9 1.9 1.9 0 0 1 -.2-3.1c3.1-2.3 6.2-4.7 9.1-7.1a1.8 1.8 0 0 1 1.9-.3c96.2 43.9 200.4 43.9 295.5 0a1.8 1.8 0 0 1 1.9 .2c2.9 2.4 6 4.9 9.1 7.2a1.9 1.9 0 0 1 -.2 3.1 301.4 301.4 0 0 1 -45.9 21.8 1.9 1.9 0 0 0 -1 2.6 391.1 391.1 0 0 0 30 48.8 1.9 1.9 0 0 0 2.1 .7A486 486 0 0 0 610.7 405.7a1.9 1.9 0 0 0 .8-1.4C623.7 277.6 590.9 167.5 524.5 69.8zM222.5 337.6c-29 0-52.8-26.6-52.8-59.2S193.1 219.1 222.5 219.1c29.7 0 53.3 26.8 52.8 59.2C275.3 311 251.9 337.6 222.5 337.6zm195.4 0c-29 0-52.8-26.6-52.8-59.2S388.4 219.1 417.9 219.1c29.7 0 53.3 26.8 52.8 59.2C470.7 311 447.5 337.6 417.9 337.6z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
13
apps/docs/components/common/icon/github.tsx
Normal file
13
apps/docs/components/common/icon/github.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { cn } from '@/utils/cn'
|
||||
|
||||
export function GithubIcon({ className }: { className: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 496 512"
|
||||
className={cn('fill-current', className)}
|
||||
>
|
||||
<path d="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3 .3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5 .3-6.2 2.3zm44.2-1.7c-2.9 .7-4.9 2.6-4.6 4.9 .3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3 .7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3 .3 2.9 2.3 3.9 1.6 1 3.6 .7 4.3-.7 .7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3 .7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3 .7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
30
apps/docs/components/common/icon/index.tsx
Normal file
30
apps/docs/components/common/icon/index.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import * as HeroiconsSolid from '@heroicons/react/20/solid'
|
||||
import * as HeroiconsOutline from '@heroicons/react/24/outline'
|
||||
import { DiscordIcon } from './discord'
|
||||
import { GithubIcon } from './github'
|
||||
import { LinkedinIcon } from './linkedin'
|
||||
import { TldrawIcon } from './tldraw'
|
||||
import { TwitterIcon } from './twitter'
|
||||
|
||||
const icons = {
|
||||
check: HeroiconsOutline.CheckIcon,
|
||||
copy: HeroiconsOutline.ClipboardIcon,
|
||||
discord: DiscordIcon,
|
||||
github: GithubIcon,
|
||||
linkedin: LinkedinIcon,
|
||||
paperclip: HeroiconsOutline.PaperClipIcon,
|
||||
play: HeroiconsSolid.PlayIcon,
|
||||
tldraw: TldrawIcon,
|
||||
twitter: TwitterIcon,
|
||||
bolt: HeroiconsOutline.BoltIcon,
|
||||
cube: HeroiconsOutline.CubeIcon,
|
||||
wrench: HeroiconsOutline.WrenchScrewdriverIcon,
|
||||
shield: HeroiconsOutline.ShieldCheckIcon,
|
||||
}
|
||||
|
||||
export type IconName = keyof typeof icons
|
||||
|
||||
export function Icon({ icon, className }: { icon: IconName; className: string }) {
|
||||
const IconComponent = icons[icon]
|
||||
return <IconComponent className={className} />
|
||||
}
|
||||
13
apps/docs/components/common/icon/linkedin.tsx
Normal file
13
apps/docs/components/common/icon/linkedin.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { cn } from '@/utils/cn'
|
||||
|
||||
export function LinkedinIcon({ className }: { className: string }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={cn('fill-current', className)}
|
||||
>
|
||||
<path d="M0 1.146C0 .513.526 0 1.175 0h13.65C15.474 0 16 .513 16 1.146v13.708c0 .633-.526 1.146-1.175 1.146H1.175C.526 16 0 15.487 0 14.854zm4.943 12.248V6.169H2.542v7.225zm-1.2-8.212c.837 0 1.358-.554 1.358-1.248-.015-.709-.52-1.248-1.342-1.248S2.4 3.226 2.4 3.934c0 .694.521 1.248 1.327 1.248zm4.908 8.212V9.359c0-.216.016-.432.08-.586.173-.431.568-.878 1.232-.878.869 0 1.216.662 1.216 1.634v3.865h2.401V9.25c0-2.22-1.184-3.252-2.764-3.252-1.274 0-1.845.7-2.165 1.193v.025h-.016l.016-.025V6.169h-2.4c.03.678 0 7.225 0 7.225z"></path>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
16
apps/docs/components/common/icon/tldraw.tsx
Normal file
16
apps/docs/components/common/icon/tldraw.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { cn } from '@/utils/cn'
|
||||
|
||||
export function TldrawIcon({ className }: { className: string }) {
|
||||
return (
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 15 14"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={cn('fill-current', className)}
|
||||
>
|
||||
<path d="M8.78578 3.4513C9.15358 3.0593 9.33748 2.58208 9.33748 2.01964C9.33748 1.45722 9.15358 0.980001 8.78578 0.587998C8.41806 0.195995 7.97035 0 7.44271 0C6.89906 0 6.44337 0.195995 6.07562 0.587998C5.70786 0.980001 5.52398 1.45722 5.52398 2.01964C5.52398 2.58208 5.70786 3.0593 6.07562 3.4513C6.44337 3.84331 6.89906 4.0393 7.44271 4.0393C7.97035 4.0393 8.41806 3.84331 8.78578 3.4513Z" />
|
||||
<path d="M6.05162 7.666C5.68387 8.05801 5.5 8.53521 5.5 9.09764C5.5 9.62606 5.66788 10.0691 6.00366 10.427C6.33943 10.7849 6.66722 10.9724 6.987 10.9894C7.11492 10.9894 7.17089 11.0577 7.1549 11.194C7.10693 11.5008 7.00299 11.7991 6.8431 12.0888C6.6832 12.3786 6.48334 12.6171 6.2435 12.8046C5.81179 13.1795 5.73185 13.5034 6.00366 13.7761C6.29147 14.0658 6.65922 14.0744 7.10693 13.8017C7.92238 13.3245 8.54597 12.6342 8.97772 11.7308C9.42541 10.8276 9.58532 9.9158 9.45732 8.99537C9.39338 8.50108 9.16953 8.05801 8.78578 7.666C8.40207 7.25696 7.95436 7.05243 7.44271 7.05243C6.89906 7.05243 6.43538 7.25696 6.05162 7.666Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
13
apps/docs/components/common/icon/twitter.tsx
Normal file
13
apps/docs/components/common/icon/twitter.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { cn } from '@/utils/cn'
|
||||
|
||||
export function TwitterIcon({ className }: { className: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 512 512"
|
||||
className={cn('fill-current', className)}
|
||||
>
|
||||
<path d="M389.2 48h70.6L305.6 224.2 487 464H345L233.7 318.6 106.5 464H35.8L200.7 275.5 26.8 48H172.4L272.9 180.9 389.2 48zM364.4 421.8h39.1L151.1 88h-42L364.4 421.8z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
46
apps/docs/components/common/logo.tsx
Normal file
46
apps/docs/components/common/logo.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { cn } from '@/utils/cn'
|
||||
|
||||
export function Logo({ className, forceWhite }: { className: string; forceWhite?: boolean }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 4081 1000"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={cn(
|
||||
'fill-current',
|
||||
forceWhite ? 'text-white' : 'text-black dark:text-white',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<path
|
||||
d="M1303.69 270.972C1303.69 240.816 1328.13 216.369 1358.29 216.369H1428.49C1458.65 216.369 1483.09 240.816 1483.09 270.972V315.954C1483.09 327.011 1492.06 335.975 1503.12 335.975H1541C1561.11 335.975 1577.4 352.272 1577.4 372.376V429.579C1577.4 449.683 1561.11 465.98 1541 465.98H1503.12C1492.06 465.98 1483.09 474.944 1483.09 486.001V674.64C1483.09 681.573 1484.29 687.424 1486.67 692.19C1489.05 696.741 1492.74 700.207 1497.72 702.591C1502.7 704.758 1522.56 705.841 1530.37 705.841C1549.5 705.841 1560.72 712.765 1564.67 731.492L1577.4 791.954C1581.54 811.61 1569.85 830.984 1550.28 835.522C1535.55 838.989 1511.26 841.264 1490.9 842.347C1449.73 844.514 1415.17 840.289 1387.22 829.672C1359.26 818.838 1338.25 801.829 1324.16 778.644C1310.08 755.46 1303.25 726.425 1303.69 691.54V486.001C1303.69 474.944 1294.72 465.98 1283.67 465.98H1266.96C1246.85 465.98 1230.56 449.683 1230.56 429.579V372.376C1230.56 352.272 1246.85 335.975 1266.96 335.975H1283.67C1294.72 335.975 1303.69 327.011 1303.69 315.954V270.972Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M1974.54 814.96C1944.85 796.326 1921.02 768.049 1903.03 730.131C1885.27 692.213 1876.38 644.436 1876.38 586.8C1876.38 501.729 1898.19 403.692 1977.14 357.015C2038.76 320.844 2119.52 320.677 2175.17 368.834C2185.85 378.077 2214.4 369.799 2214.4 355.676V225.384C2214.4 195.228 2238.84 170.781 2269 170.781H2339.2C2369.36 170.781 2393.81 195.228 2393.81 225.384V781.808C2393.81 811.964 2369.36 836.411 2339.2 836.411H2249.9C2231.01 836.411 2215.7 821.099 2215.7 802.211C2215.7 791.902 2195.36 785.728 2188.33 793.263C2186.18 795.558 2184.03 797.807 2181.9 800.009C2128.34 855.383 2037.25 854.041 1974.54 814.96ZM2218.3 586.8C2218.3 537.286 2200.48 468.495 2138.99 468.495C2077.29 468.495 2060.99 537.551 2060.99 586.8C2060.99 623.113 2066.97 666.411 2096.74 691.13C2165.89 746.195 2218.3 647.886 2218.3 586.8Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M2513.66 836.412C2483.51 836.412 2459.06 811.965 2459.06 781.809V391.792C2459.06 361.636 2483.51 337.19 2513.66 337.19H2603.2C2619.81 337.19 2633.27 350.651 2633.27 367.256C2633.27 382.997 2638.97 387.954 2649.77 387.954C2668.35 387.954 2672.24 328.627 2758.31 330.689C2813.1 330.689 2820.44 385.406 2820.44 412.863C2820.44 475.557 2800.52 481.496 2737.51 481.496C2681.18 481.496 2638.47 519.992 2638.47 576.4V781.809C2638.47 811.965 2614.02 836.412 2583.87 836.412H2513.66Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M2970.68 842.913C2916.63 842.913 2859.79 826.876 2828.98 779.21C2815.11 757.759 2808.18 730.458 2808.18 697.306C2808.18 651.927 2823.66 607.404 2861.15 579.651C2898.44 552.053 2946.07 542.134 2991.49 538.699C3020.52 536.448 3107.19 538.281 3107.19 494.497C3107.19 448.71 3041.54 443.568 3011.96 464.271C2970.82 492.82 2973.83 498.397 2910.23 498.397C2834.58 498.397 2857.58 387.482 2932.33 352.791C2965.48 337.19 3006.87 329.39 3056.49 329.39C3114.93 329.39 3177.44 341.047 3225.17 376.842C3245.1 391.576 3260.27 408.91 3270.67 428.844C3281.29 448.562 3286.6 470.013 3286.6 493.197V780.51C3286.6 810.666 3262.15 835.112 3232 835.112H3152.37C3133.88 835.112 3118.89 820.125 3118.89 801.638C3118.89 792.06 3103.8 787.48 3097.15 794.364C3063.59 829.054 3017.92 842.913 2970.68 842.913ZM3097.11 696.981C3108 681.743 3109.4 662.66 3109.14 643.805C3108.97 630.868 3095.3 621.942 3082.84 625.429C3067.81 629.638 3052.39 632.57 3036.99 634.904C3017.05 638.018 2994.44 645.215 2983.36 663.505C2973.07 680.83 2975.13 706.154 2991.81 718.757C3023.43 742.651 3075.6 727.106 3097.11 696.981Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M1761.82 163C1792.33 163 1817.06 187.732 1817.06 218.24V781.158C1817.06 811.666 1792.33 836.398 1761.82 836.398H1690.8C1660.29 836.398 1635.56 811.666 1635.56 781.158V218.24C1635.56 187.732 1660.29 163 1690.8 163H1761.82Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M3480.44 836.409C3455.39 836.409 3433.55 819.357 3427.47 795.049L3329.98 405.032C3321.37 370.57 3347.43 337.187 3382.95 337.187H3447.12C3473.38 337.187 3495.92 355.886 3500.78 381.699L3534.84 562.897C3538.14 580.407 3563.06 580.816 3566.93 563.423L3607.7 379.944C3613.25 354.961 3635.4 337.187 3660.99 337.187H3748.03C3773.42 337.187 3795.45 354.684 3801.21 379.411L3843.62 561.638C3847.65 578.962 3872.52 578.345 3875.69 560.842L3908.05 382.062C3912.75 356.08 3935.37 337.187 3961.77 337.187H4025.63C4061.15 337.187 4087.21 370.57 4078.6 405.032L3981.11 795.049C3975.03 819.357 3953.19 836.409 3928.14 836.409H3818.64C3794.09 836.409 3772.56 820.015 3766.02 796.343L3719.13 626.567C3714.69 610.471 3691.83 610.567 3687.52 626.7L3642.32 795.901C3635.94 819.79 3614.3 836.409 3589.57 836.409H3480.44Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M0.557373 130C0.557373 58 58.5574 0 130.557 0H870.557C942.557 0 1000.56 58 1000.56 130V870C1000.56 942 942.557 1000 870.557 1000H130.557C58.5574 1000 0.557373 942 0.557373 870V130ZM590.557 300C590.557 350 550.557 390 500.557 390C450.557 390 410.557 350 410.557 300C410.557 250 450.557 210 500.557 210C550.557 210 590.557 250 590.557 300ZM470.557 810C513.557 810 554.557 751 569.557 719C588.557 678 599.557 605 580.557 562C567.557 532 537.557 510 497.557 510C449.557 510 410.557 549 410.557 597C410.557 640 441.557 674 481.557 681C483.557 681 485.557 684 485.557 686C481.557 711 470.557 743 453.557 759C432.557 779 438.557 810 470.557 810Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
22
apps/docs/components/common/navigation.tsx
Normal file
22
apps/docs/components/common/navigation.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/utils/cn'
|
||||
|
||||
export function Navigation({
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
className?: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<nav
|
||||
className={cn('w-52 lg:w-60 shrink-0 flex flex-col sticky top-24 overflow-hidden', className)}
|
||||
style={{
|
||||
height: 'calc(100vh - 6rem)',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
20
apps/docs/components/common/page-title.tsx
Normal file
20
apps/docs/components/common/page-title.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { cn } from '@/utils/cn'
|
||||
|
||||
export function PageTitle({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<h1
|
||||
className={cn(
|
||||
'font-black text-black dark:text-white text-3xl sm:text-4xl break-words max-w-full',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</h1>
|
||||
)
|
||||
}
|
||||
43
apps/docs/components/common/theme-switch.tsx
Normal file
43
apps/docs/components/common/theme-switch.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
'use client'
|
||||
|
||||
import { MoonIcon, SunIcon } from '@heroicons/react/20/solid'
|
||||
import { useTheme } from 'next-themes'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { cn } from '@/utils/cn'
|
||||
|
||||
const themes = [
|
||||
{ id: 'light', name: 'Light Mode', icon: SunIcon },
|
||||
{ id: 'dark', name: 'Dark Mode', icon: MoonIcon },
|
||||
// { id: 'system', name: 'System Preference', icon: Cog6ToothIcon },
|
||||
]
|
||||
|
||||
export function ThemeSwitch() {
|
||||
const { theme: initialTheme, setTheme: persistTheme } = useTheme()
|
||||
const [theme, setTheme] = useState<string>()
|
||||
const Icon = themes.find((t) => t.id === theme)?.icon ?? SunIcon
|
||||
|
||||
useEffect(() => {
|
||||
setTheme(initialTheme)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const setThemeAndPersist = () => {
|
||||
const nextTheme = theme === 'light' ? 'dark' : 'light'
|
||||
persistTheme(nextTheme)
|
||||
setTheme(nextTheme)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-end sm:flex-row sm:items-center gap-4 ml-2 md:ml-0">
|
||||
<div className="hidden md:block h-px sm:h-5 w-full sm:w-px bg-zinc-100 sm:bg-zinc-200 dark:bg-zinc-800 dark:sm:bg-zinc-700" />
|
||||
<button
|
||||
onClick={() => setThemeAndPersist()}
|
||||
className="hover:text-zinc-600 dark:hover:text-zinc-100"
|
||||
>
|
||||
<div className={cn('transition-opacity duration-300', theme ? 'opacity-100' : 'opacity-0')}>
|
||||
<Icon className="size-5" />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
69
apps/docs/components/common/tldraw-link.tsx
Normal file
69
apps/docs/components/common/tldraw-link.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
export function TldrawLink(props: React.ComponentProps<'a'>) {
|
||||
const { href, children } = props
|
||||
const windowObject = typeof window !== 'undefined' ? window : null
|
||||
// Start empty so the first client render matches the server HTML — reading
|
||||
// posthog during render appends `#session_id` to hrefs before hydration and
|
||||
// causes a hydration mismatch whenever posthog loads first.
|
||||
const [sessionId, setSessionId] = useState<string | undefined>(undefined)
|
||||
const [distinctId, setDistinctId] = useState<string | undefined>(undefined)
|
||||
|
||||
useEffect(() => {
|
||||
// Note this href check is intentionally loose, just to avoid
|
||||
// doing this setInterval for every link. We do the real check below.
|
||||
if (sessionId || !href?.includes('tldraw.com')) return
|
||||
|
||||
if (windowObject?.posthog) {
|
||||
setSessionId(windowObject.posthog.get_session_id())
|
||||
setDistinctId(windowObject.posthog.get_distinct_id())
|
||||
return
|
||||
}
|
||||
|
||||
// XXX: have to wait a bit for posthog to be ready.
|
||||
// there's unfortunately no event callback for this.
|
||||
const timeout = setInterval(() => {
|
||||
setSessionId(windowObject?.posthog?.get_session_id())
|
||||
setDistinctId(windowObject?.posthog?.get_distinct_id())
|
||||
clearTimeout(timeout)
|
||||
}, 1000)
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}, [sessionId, windowObject, href])
|
||||
|
||||
const isLocalUrl = href?.startsWith('/') || href?.startsWith('#')
|
||||
let maybeParsedUrl
|
||||
try {
|
||||
maybeParsedUrl = isLocalUrl ? null : href ? new URL(href) : null
|
||||
} catch {
|
||||
console.error(`Invalid URL: ${href}`)
|
||||
}
|
||||
const derivedTarget =
|
||||
isLocalUrl ||
|
||||
maybeParsedUrl?.host.includes('tldraw.com') ||
|
||||
maybeParsedUrl?.host.includes('localhost')
|
||||
? undefined
|
||||
: '_blank'
|
||||
const target = props.target ?? derivedTarget
|
||||
|
||||
let newHref = href
|
||||
if (
|
||||
maybeParsedUrl?.host.includes('tldraw.com') &&
|
||||
sessionId &&
|
||||
distinctId &&
|
||||
!href?.includes('#')
|
||||
) {
|
||||
newHref = `${href}#session_id=${sessionId}&distinct_id=${distinctId}`
|
||||
}
|
||||
|
||||
return (
|
||||
<Link {...props} href={newHref ?? ''} target={target}>
|
||||
{children}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
3
apps/docs/components/content/api-heading.tsx
Normal file
3
apps/docs/components/content/api-heading.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export function ApiHeading(props: any) {
|
||||
return <h4 className="!-mb-4 !mt-8 uppercase text-sm font-semibold" {...props} />
|
||||
}
|
||||
228
apps/docs/components/content/bento.tsx
Normal file
228
apps/docs/components/content/bento.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
import { assetUrl } from '@/utils/asset-url'
|
||||
import { cn } from '@/utils/cn'
|
||||
|
||||
// Content definitions
|
||||
const KITS = {
|
||||
multiplayer: {
|
||||
items: [
|
||||
{
|
||||
title: 'Collaborative whiteboards',
|
||||
desc: 'Real-time drawing and diagramming tools where multiple users can contribute simultaneously.',
|
||||
},
|
||||
{
|
||||
title: 'Educational canvases',
|
||||
desc: 'Shared learning environments for remote teaching, brainstorming, and visual collaboration.',
|
||||
},
|
||||
{
|
||||
title: 'Design review platforms',
|
||||
desc: 'Interactive design collaboration spaces with persistent sessions and asset sharing.',
|
||||
},
|
||||
{
|
||||
title: 'Project planning tools',
|
||||
desc: 'Visual project management interfaces with real-time updates across team members.',
|
||||
},
|
||||
{
|
||||
title: 'Interactive documentation',
|
||||
desc: 'Living documents that teams can annotate and modify together in real-time.',
|
||||
},
|
||||
],
|
||||
},
|
||||
agent: {
|
||||
items: [
|
||||
{
|
||||
title: 'Visual AI assistants',
|
||||
desc: 'Create AI agents that can read, interpret, and modify drawings while providing analysis, insights, and automated annotations.',
|
||||
},
|
||||
{
|
||||
title: 'Diagram generation tools',
|
||||
desc: 'Build systems that automatically generate flowcharts, system architecture diagrams, or technical illustrations from text descriptions.',
|
||||
},
|
||||
{
|
||||
title: 'Shape recognition systems',
|
||||
desc: 'Develop applications that identify and classify hand-drawn shapes, converting sketches into structured digital content.',
|
||||
},
|
||||
],
|
||||
},
|
||||
workflow: {
|
||||
items: [
|
||||
{
|
||||
title: 'AI and agentic workflows',
|
||||
desc: 'Build visual AI pipelines where agents process data through connected nodes.',
|
||||
},
|
||||
{
|
||||
title: 'Automation platforms',
|
||||
desc: 'Create no-code automation tools where users visually connect services and data transformations.',
|
||||
},
|
||||
{
|
||||
title: 'Data processing pipelines',
|
||||
desc: 'Design ETL tools where users drag nodes to transform, filter, and route data between sources and destinations.',
|
||||
},
|
||||
{
|
||||
title: 'Visual programming interfaces',
|
||||
desc: 'Build domain-specific scripting environments where complex logic becomes intuitive drag-and-drop workflows.',
|
||||
},
|
||||
{
|
||||
title: 'Interactive diagramming tools',
|
||||
desc: 'Create specialized diagram builders for database design, circuit boards, or business process flows with executable functionality.',
|
||||
},
|
||||
],
|
||||
},
|
||||
chat: {
|
||||
items: [
|
||||
{
|
||||
title: 'AI chat apps',
|
||||
desc: 'Add visual context to conversational AI by letting users sketch diagrams, annotate screenshots, and draw concepts to supplement text conversations.',
|
||||
},
|
||||
{
|
||||
title: 'Design feedback apps',
|
||||
desc: 'Add annotation to your design review tools so teams can mark up mockups, wireframes, and prototypes within chat conversations.',
|
||||
},
|
||||
{
|
||||
title: 'Educational platforms',
|
||||
desc: 'Add drawing capabilities to tutoring interfaces so students can sketch math problems, create diagrams, and get visual help.',
|
||||
},
|
||||
{
|
||||
title: 'Documentation systems',
|
||||
desc: 'Add visual markup to help desk or support chat so users can annotate screenshots and create flowcharts.',
|
||||
},
|
||||
{
|
||||
title: 'Content creation tools',
|
||||
desc: 'Add sketching and annotation to creative collaboration platforms so teams can mark up references and brainstorm visually.',
|
||||
},
|
||||
],
|
||||
},
|
||||
branching: {
|
||||
items: [
|
||||
{
|
||||
title: 'Interactive chatbots',
|
||||
desc: 'Create conversational AI interfaces with multiple dialogue paths where users can explore different response branches and see how conversations evolve in different directions.',
|
||||
},
|
||||
{
|
||||
title: 'Conversation design tools',
|
||||
desc: 'Build visual prototyping environments for UX designers to map out chatbot flows, customer service scripts, or user interaction patterns before implementation.',
|
||||
},
|
||||
{
|
||||
title: 'Interactive storytelling',
|
||||
desc: 'Develop choose-your-own-adventure applications where narrative branches visually connect, allowing readers to see story paths and backtrack through different choices.',
|
||||
},
|
||||
{
|
||||
title: 'Training simulations',
|
||||
desc: 'Create educational tools with branching scenarios for customer service training, medical diagnosis practice, or sales conversation rehearsal with AI-powered responses.',
|
||||
},
|
||||
{
|
||||
title: 'AI agent workflows',
|
||||
desc: 'Design complex AI assistant interfaces where conversation history and context branch based on user choices, enabling sophisticated multi-turn dialogue management.',
|
||||
},
|
||||
],
|
||||
},
|
||||
'image-pipeline': {
|
||||
items: [
|
||||
{
|
||||
title: 'AI image generation tools',
|
||||
desc: 'Build visual pipeline builders where users chain prompts, models, and processing steps to create and iterate on AI-generated images.',
|
||||
},
|
||||
{
|
||||
title: 'Image processing workflows',
|
||||
desc: 'Create node-based editors for multi-step image manipulation combining upscaling, style transfer, blending, and adjustments.',
|
||||
},
|
||||
{
|
||||
title: 'Creative asset pipelines',
|
||||
desc: 'Design visual tools for generating and refining creative assets through connected processing nodes with real-time preview.',
|
||||
},
|
||||
{
|
||||
title: 'Batch image generation',
|
||||
desc: 'Build interfaces that iterate over prompt variations, generating multiple images from parameterized pipelines.',
|
||||
},
|
||||
{
|
||||
title: 'Visual prompt engineering',
|
||||
desc: 'Create interactive environments for exploring how different models, parameters, and prompt combinations affect image outputs.',
|
||||
},
|
||||
],
|
||||
},
|
||||
shader: {
|
||||
items: [
|
||||
{
|
||||
title: 'Immersive creative tools',
|
||||
desc: 'Layer animated, touch-responsive backgrounds behind your drawing UI for design or storytelling apps.',
|
||||
},
|
||||
{
|
||||
title: 'Data-rich dashboards',
|
||||
desc: 'Highlight state changes or live metrics using GPU-driven gradients and particle systems that react to editor data.',
|
||||
},
|
||||
{
|
||||
title: 'Games and interactive experiences',
|
||||
desc: 'Combine tldraw shapes with shader-based effects to build puzzle overlays, interactive maps, or ambient scenes.',
|
||||
},
|
||||
{
|
||||
title: 'Live events and streaming overlays',
|
||||
desc: 'Drive real-time graphics for broadcast overlays or virtual stages that react to presenter actions.',
|
||||
},
|
||||
{
|
||||
title: 'Educational demos',
|
||||
desc: 'Teach graphics concepts with interactive examples that expose shader parameters through the config panel.',
|
||||
},
|
||||
],
|
||||
},
|
||||
} as const
|
||||
|
||||
export type StarterKitType = keyof typeof KITS
|
||||
|
||||
export function StarterKitBento({
|
||||
type,
|
||||
href,
|
||||
img,
|
||||
}: {
|
||||
type: StarterKitType
|
||||
href: string
|
||||
img?: { src: string; alt?: string }
|
||||
}) {
|
||||
const content = KITS[type]
|
||||
if (!content) return null
|
||||
|
||||
return (
|
||||
<div className="my-8 rounded-xl border border-zinc-200 bg-zinc-100 p-2 shadow-sm not-prose">
|
||||
{img && (
|
||||
<a
|
||||
href={href}
|
||||
className="block relative mb-2 overflow-hidden rounded-lg border border-zinc-200 bg-zinc-50"
|
||||
>
|
||||
<img
|
||||
src={assetUrl(img.src)}
|
||||
alt={img.alt || type}
|
||||
className="!my-0 h-auto w-full object-cover"
|
||||
/>
|
||||
</a>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-2 md:grid-cols-6">
|
||||
{content.items.map((item, i) => {
|
||||
const span = i < 3 ? 2 : 3
|
||||
|
||||
return <BentoItem key={item.title} title={item.title} span={span} desc={item.desc} />
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BentoItem({ title, desc, span }: { title: string; desc: string; span: number }) {
|
||||
const colSpan =
|
||||
{
|
||||
2: 'md:col-span-2',
|
||||
3: 'md:col-span-3',
|
||||
}[span] || 'md:col-span-2'
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col justify-between rounded-lg border border-zinc-200 bg-white p-5 shadow-sm transition-all hover:shadow-md',
|
||||
colSpan
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
<h3 className="mb-2 text-lg font-bold leading-tight text-zinc-900">{title}</h3>
|
||||
<p className="text-sm leading-relaxed text-zinc-600">{desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
5
apps/docs/components/content/blockquote.tsx
Normal file
5
apps/docs/components/content/blockquote.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Callout } from './callout'
|
||||
|
||||
export function Blockquote(props: any) {
|
||||
return <Callout>{props.children}</Callout>
|
||||
}
|
||||
44
apps/docs/components/content/callout.tsx
Normal file
44
apps/docs/components/content/callout.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import {
|
||||
ExclamationCircleIcon,
|
||||
ExclamationTriangleIcon,
|
||||
InformationCircleIcon,
|
||||
} from '@heroicons/react/20/solid'
|
||||
import { cn } from '@/utils/cn'
|
||||
|
||||
export function Callout({
|
||||
type = 'info',
|
||||
children,
|
||||
}: {
|
||||
type?: 'info' | 'warning' | 'critical' | 'quote'
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'py-1 -mx-5 md:mx-0',
|
||||
type === 'info' && 'md:px-1 md:rounded-2xl bg-blue-50 dark:bg-blue-950/75',
|
||||
type === 'warning' && 'md:px-1 md:rounded-2xl bg-amber-50 dark:bg-amber-900/50',
|
||||
type === 'critical' && 'md:px-1 md:rounded-2xl bg-rose-50 dark:bg-rose-950/50'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col sm:flex-row gap-4 w-full p-5 ',
|
||||
type === 'quote' && 'border-right border-zinc-400 dark:border-zinc-700 border-l-4',
|
||||
type === 'info' && 'md:rounded-xl shadow dark:bg-blue-900/25',
|
||||
type === 'warning' && 'md:rounded-xl shadow dark:bg-amber-900/25',
|
||||
type === 'critical' && 'md:rounded-xl shadow dark:bg-rose-900/25'
|
||||
)}
|
||||
>
|
||||
{type === 'info' && <InformationCircleIcon className="size-5 shrink-0 text-blue-500" />}
|
||||
{type === 'warning' && (
|
||||
<ExclamationTriangleIcon className="size-5 shrink-0 text-amber-500 dark:text-amber-400" />
|
||||
)}
|
||||
{type === 'critical' && (
|
||||
<ExclamationCircleIcon className="size-5 shrink-0 text-rose-500 dark:text-rose-400" />
|
||||
)}
|
||||
<div className="-mt-0.5 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
11
apps/docs/components/content/check-item.tsx
Normal file
11
apps/docs/components/content/check-item.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import type React from 'react'
|
||||
import { Icon } from '@/components/common/icon'
|
||||
|
||||
export function CheckItem({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-start gap-3 text-zinc-600 dark:text-zinc-400">
|
||||
<Icon icon="check" className="mt-1 h-5 w-5 shrink-0 text-zinc-900 dark:text-zinc-100" />
|
||||
<span>{children}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
82
apps/docs/components/content/code-files.tsx
Normal file
82
apps/docs/components/content/code-files.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { Tab, TabGroup, TabList, TabPanel, TabPanels } from '@headlessui/react'
|
||||
import { createJavaScriptRegexEngine } from '@shikijs/engine-javascript'
|
||||
import { createHighlighterCoreSync, hastToHtml } from 'shiki/core'
|
||||
import css from 'shiki/dist/langs/css.mjs'
|
||||
import tsx from 'shiki/dist/langs/tsx.mjs'
|
||||
import ts from 'shiki/dist/langs/typescript.mjs'
|
||||
import theme from 'shiki/dist/themes/github-dark.mjs'
|
||||
import { cn } from '@/utils/cn'
|
||||
import { CopyButton } from './copy-button'
|
||||
|
||||
// The normal shiki import is async, which we can't use here easily because of SSR complications.
|
||||
// So here we create a synchronous version of the highlighter.
|
||||
const shiki = createHighlighterCoreSync({
|
||||
themes: [theme],
|
||||
langs: [ts, tsx, css],
|
||||
engine: createJavaScriptRegexEngine(),
|
||||
})
|
||||
|
||||
export function CodeFiles({
|
||||
files,
|
||||
hideTabs,
|
||||
className,
|
||||
}: {
|
||||
files: { name: string; content: any }[]
|
||||
hideCopyButton?: boolean
|
||||
hideTabs?: boolean
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<TabGroup
|
||||
className={cn(
|
||||
'max-h-[550px] group relative not-prose bg-zinc-100 dark:bg-zinc-800 py-1 md:rounded-2xl -mx-5 md:-mx-1 md:px-1 my-6 flex flex-col',
|
||||
'[td_&]:m-0 [td_&]:mb-2 [td_&]:p-0 [td_&]:bg-transparent [td_&]:rounded-none',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<TabList
|
||||
className={cn(
|
||||
'bg-zinc-900 shrink-0 text-sm text-zinc-400 shadow md:rounded-t-xl border-b border-zinc-700/50 px-5 md:px-4 gap-4 flex',
|
||||
hideTabs && 'hidden'
|
||||
)}
|
||||
>
|
||||
{files.map(({ name }, index) => (
|
||||
<Tab
|
||||
key={index}
|
||||
className="h-10 data-[selected]:text-white border-b border-transparent data-[selected]:border-white -mb-px focus:outline-none"
|
||||
>
|
||||
{name}
|
||||
</Tab>
|
||||
))}
|
||||
</TabList>
|
||||
<TabPanels
|
||||
className={cn(
|
||||
'bg-zinc-900 grow text-sm text-white shadow md:rounded-b-xl overflow-x-auto px-5 md:px-4 py-0',
|
||||
hideTabs && 'md:rounded-t-xl'
|
||||
)}
|
||||
>
|
||||
{files.map(({ content, name }, index) => {
|
||||
// the typescript grammar has no JSX, so .tsx/.jsx need the tsx grammar
|
||||
const lang = name.endsWith('.css')
|
||||
? 'css'
|
||||
: name.endsWith('.tsx') || name.endsWith('.jsx')
|
||||
? 'tsx'
|
||||
: 'ts'
|
||||
const ast = shiki.codeToHast(content, { lang, theme })
|
||||
const codeElem = (ast.children[0] as any).children[0]
|
||||
return (
|
||||
<TabPanel key={index}>
|
||||
<pre
|
||||
className="overflow-y-auto p-4"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: hastToHtml(codeElem),
|
||||
}}
|
||||
></pre>
|
||||
<CopyButton copy={content} />
|
||||
</TabPanel>
|
||||
)
|
||||
})}
|
||||
</TabPanels>
|
||||
</TabGroup>
|
||||
)
|
||||
}
|
||||
180
apps/docs/components/content/code.tsx
Normal file
180
apps/docs/components/content/code.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
'use client'
|
||||
|
||||
import { assert, getOwnProperty } from '@tldraw/utils'
|
||||
import React, {
|
||||
Fragment,
|
||||
ReactElement,
|
||||
ReactNode,
|
||||
createContext,
|
||||
isValidElement,
|
||||
useContext,
|
||||
useMemo,
|
||||
} from 'react'
|
||||
import { cn } from '@/utils/cn'
|
||||
import { TldrawLink } from '../common/tldraw-link'
|
||||
|
||||
const CodeLinksContext = createContext<Record<string, string>>({})
|
||||
|
||||
export function CodeLinks({
|
||||
children,
|
||||
links,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
links: Record<string, string>
|
||||
}) {
|
||||
return <CodeLinksContext.Provider value={links}>{children}</CodeLinksContext.Provider>
|
||||
}
|
||||
|
||||
const FocusLinesContext = createContext<null | number[]>(null)
|
||||
export function FocusLines({ children, lines }: { children: ReactNode; lines: number[] }) {
|
||||
return <FocusLinesContext.Provider value={lines}>{children}</FocusLinesContext.Provider>
|
||||
}
|
||||
|
||||
const blurredLineClassName = '[&_*]:!opacity-60 [&_*]:!text-white'
|
||||
|
||||
function CodeLink({ children, href }: { children: ReactNode; href: string }) {
|
||||
return (
|
||||
<TldrawLink
|
||||
href={href}
|
||||
className="group-[.not-prose]:underline group-[.not-prose]:hover:no-underline decoration-[#667e8b]"
|
||||
>
|
||||
{children}
|
||||
</TldrawLink>
|
||||
)
|
||||
}
|
||||
|
||||
// does this token look like a JS identifier?
|
||||
function isIdentifier(token: string): boolean {
|
||||
return /^[_$a-zA-Z\xA0-\uFFFF][_$a-zA-Z0-9\xA0-\uFFFF]*$/g.test(token)
|
||||
}
|
||||
|
||||
// split the code into an array of identifiers, and the bits in between them
|
||||
function tokenize(code: string): string[] {
|
||||
const identifierRegex = /[_$a-zA-Z\xA0-\uFFFF][_$a-zA-Z0-9\xA0-\uFFFF]*/g
|
||||
|
||||
let currentIdx = 0
|
||||
const tokens = []
|
||||
for (const identifierMatch of code.matchAll(identifierRegex)) {
|
||||
const [identifier] = identifierMatch
|
||||
const idx = identifierMatch.index
|
||||
if (idx > currentIdx) {
|
||||
tokens.push(code.slice(currentIdx, idx))
|
||||
}
|
||||
tokens.push(identifier)
|
||||
currentIdx = idx + identifier.length
|
||||
}
|
||||
if (currentIdx < code.length) {
|
||||
tokens.push(code.slice(currentIdx))
|
||||
}
|
||||
|
||||
return tokens
|
||||
}
|
||||
|
||||
// this is for inline code blocks (e.g. `code`)
|
||||
// these don't get highlighted, so the children is just a string
|
||||
function InlineCodeElem({ children, ...props }: React.ComponentProps<'code'>) {
|
||||
const code = children as string
|
||||
assert(typeof code === 'string', 'InlineCodeElem children must be a string')
|
||||
const codeLinks = useContext(CodeLinksContext)
|
||||
return useMemo(
|
||||
() => (
|
||||
<code {...props}>
|
||||
{codeLinks
|
||||
? tokenize(code).map((token, i) => {
|
||||
if (isIdentifier(token)) {
|
||||
const link = getOwnProperty(codeLinks, token)
|
||||
if (link) {
|
||||
return (
|
||||
<CodeLink key={i} href={link}>
|
||||
{token}
|
||||
</CodeLink>
|
||||
)
|
||||
}
|
||||
}
|
||||
return <Fragment key={i}>{token}</Fragment>
|
||||
})
|
||||
: code}
|
||||
</code>
|
||||
),
|
||||
[code, codeLinks, props]
|
||||
)
|
||||
}
|
||||
|
||||
// This is code that has been highlighted by shikijs
|
||||
// Children is an array of spans, each representing a line and containing an array of spans which are tokens
|
||||
// There is sometimes whitespace between the lines
|
||||
function HighlightedCodeElem({ children, ...props }: React.ComponentProps<'code'>) {
|
||||
const codeLinks = useContext(CodeLinksContext)
|
||||
const focusLines = useContext(FocusLinesContext)
|
||||
|
||||
let lineNumber = 0
|
||||
return (
|
||||
<code {...props}>
|
||||
{React.Children.map(children, (line) => {
|
||||
// ignore whitespace
|
||||
if (!line || !isValidElement(line)) return line
|
||||
|
||||
assert(line.type === 'span', 'Invalid code child (not a span)')
|
||||
const lineElement = line as ReactElement<{ className?: string; children?: ReactNode }>
|
||||
assert(lineElement.props.className === 'line', 'Invalid code child (not a line)')
|
||||
|
||||
// apply line lowlights
|
||||
lineNumber++
|
||||
let updatedLine = lineElement
|
||||
if (focusLines && !focusLines?.includes(lineNumber)) {
|
||||
updatedLine = React.cloneElement(lineElement, {
|
||||
...lineElement.props,
|
||||
className: cn(lineElement.props?.className, blurredLineClassName),
|
||||
})
|
||||
}
|
||||
// inject links where needed
|
||||
if (codeLinks) {
|
||||
updatedLine = React.cloneElement(updatedLine, {
|
||||
...updatedLine.props,
|
||||
children: React.Children.map(updatedLine.props.children, (elem) => {
|
||||
if (!isValidElement(elem)) return elem
|
||||
if (elem.type !== 'span') return elem
|
||||
const spanElement = elem as ReactElement<{ children?: ReactNode }>
|
||||
const token = spanElement.props.children
|
||||
if (typeof token !== 'string') return elem
|
||||
const tokens = tokenize(token)
|
||||
return React.cloneElement(spanElement, {
|
||||
...spanElement.props,
|
||||
children: tokens.map((token, i) => {
|
||||
if (!isIdentifier(token)) return token
|
||||
const link = getOwnProperty(codeLinks, token)
|
||||
if (!link) return token
|
||||
return (
|
||||
<CodeLink href={link} key={i}>
|
||||
{token}
|
||||
</CodeLink>
|
||||
)
|
||||
}),
|
||||
})
|
||||
}),
|
||||
})
|
||||
}
|
||||
return updatedLine
|
||||
})}
|
||||
</code>
|
||||
)
|
||||
}
|
||||
|
||||
export function Code(props: React.ComponentProps<'code'>) {
|
||||
if (typeof props.children === 'string') {
|
||||
return <InlineCodeElem {...props} />
|
||||
}
|
||||
if (Array.isArray(props.children) && props.children[0].props?.className === 'line') {
|
||||
return <HighlightedCodeElem {...props} />
|
||||
}
|
||||
if (
|
||||
isValidElement<any>(props.children) &&
|
||||
props.children.type === 'span' &&
|
||||
(props.children.props as { className?: string })?.className === 'line'
|
||||
) {
|
||||
// eslint-disable-next-line react/no-children-prop
|
||||
return <HighlightedCodeElem {...props} children={[props.children]} />
|
||||
}
|
||||
console.warn('Invalid code children', props.children)
|
||||
return <code {...props} />
|
||||
}
|
||||
39
apps/docs/components/content/copy-button.tsx
Normal file
39
apps/docs/components/content/copy-button.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
'use client'
|
||||
|
||||
import { RefObject, useState } from 'react'
|
||||
import { Button } from '../common/button'
|
||||
|
||||
export function CopyButton({
|
||||
copy,
|
||||
className,
|
||||
}: {
|
||||
copy: string | RefObject<HTMLElement | null>
|
||||
className?: string
|
||||
}) {
|
||||
const [copied, setCopied] = useState<boolean>(false)
|
||||
const handleClick = () => {
|
||||
const code: string = typeof copy === 'string' ? copy : (copy.current?.innerText ?? '')
|
||||
|
||||
navigator.clipboard.writeText(code)
|
||||
|
||||
if (window.tlanalytics?.trackCopyCode) {
|
||||
window.tlanalytics.trackCopyCode({
|
||||
page_category: 'docs',
|
||||
text_snippet: code,
|
||||
})
|
||||
}
|
||||
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 1500)
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={handleClick}
|
||||
caption={'Copy'}
|
||||
icon={copied ? 'check' : 'copy'}
|
||||
size="xs"
|
||||
className={`absolute transition-all duration-100 translate-y-4 opacity-0 -top-2 right-4 group-hover:opacity-100 group-hover:translate-y-0 ${className}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
66
apps/docs/components/content/embed.tsx
Normal file
66
apps/docs/components/content/embed.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
'use client'
|
||||
import { useRef, useState } from 'react'
|
||||
import { cn } from '@/utils/cn'
|
||||
import { Button } from '../common/button'
|
||||
|
||||
export function Embed(props: {
|
||||
className?: string
|
||||
src: string
|
||||
caption?: string
|
||||
tryButton?: string
|
||||
}) {
|
||||
const [isExampleFocused, setIsExampleFocused] = useState(false)
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null)
|
||||
function handleClick() {
|
||||
setIsExampleFocused(true)
|
||||
iframeRef.current?.focus()
|
||||
}
|
||||
function handleBlur() {
|
||||
setIsExampleFocused(false)
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<div className="bg-zinc-100 dark:bg-zinc-700 py-1 md:rounded-2xl -mx-5 md:-mx-1 md:px-1">
|
||||
<div
|
||||
className={cn(
|
||||
props.className,
|
||||
'md:rounded-xl overflow-hidden shadow bg-white dark:bg-zinc-950 relative'
|
||||
)}
|
||||
>
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
className="iframe"
|
||||
src={props.src}
|
||||
width="100%"
|
||||
height={550}
|
||||
allow="autoplay; clipboard-read; clipboard-write"
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
{!isExampleFocused && (
|
||||
<div
|
||||
className="absolute inset-0 bg-[#FBFCFE]/50 dark:bg-[#101011]/50 flex items-center justify-center cursor-pointer"
|
||||
onClick={handleClick}
|
||||
>
|
||||
<Button
|
||||
onClick={handleClick}
|
||||
caption={props.tryButton ?? 'Try example'}
|
||||
icon="play"
|
||||
className="shadow"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{props.caption && <span className="block text-xs text-zinc-500 mt-3">{props.caption}</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function StarterKitEmbed(props: { id: string }) {
|
||||
return (
|
||||
<Embed
|
||||
src={`https://${props.id}.templates.tldraw.dev/?utm_source=docs-embed`}
|
||||
tryButton="Try starter kit"
|
||||
/>
|
||||
)
|
||||
}
|
||||
23
apps/docs/components/content/example.tsx
Normal file
23
apps/docs/components/content/example.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Embed } from '@/components/content/embed'
|
||||
import { Article } from '@/types/content-types'
|
||||
import { CodeFiles } from './code-files'
|
||||
|
||||
export function Example({ article }: { article: Article }) {
|
||||
const server = 'https://examples.tldraw.com'
|
||||
// both are null when the README has no `component:` frontmatter
|
||||
const additionalFiles = article.componentCodeFiles ? JSON.parse(article.componentCodeFiles) : {}
|
||||
const files = [
|
||||
...(article.componentCode ? [{ name: 'App.tsx', content: article.componentCode }] : []),
|
||||
...Object.keys(additionalFiles).map((key) => ({ name: key, content: additionalFiles[key] })),
|
||||
]
|
||||
|
||||
// article.id is in format "sectionId/categoryId/articleId", we need just the articleId
|
||||
const slug = article.id.split('/').pop()
|
||||
|
||||
return (
|
||||
<div className="w-full mt-8">
|
||||
<Embed src={`${server}/${slug}/full?utm_source=docs-embed`} />
|
||||
<CodeFiles files={files} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
23
apps/docs/components/content/feature.tsx
Normal file
23
apps/docs/components/content/feature.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import type React from 'react'
|
||||
import { Icon, type IconName } from '@/components/common/icon'
|
||||
|
||||
export function Feature({
|
||||
icon,
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
icon: IconName
|
||||
title: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1 not-prose">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon icon={icon} className="h-5 w-5 text-zinc-900 dark:text-zinc-100" />
|
||||
<h3 className="font-bold text-zinc-900 dark:text-zinc-100">{title}</h3>
|
||||
</div>
|
||||
{/* MDX renders block children as their own `<p>`s, so this wrapper must not be a `<p>` — nested `<p>`s break hydration. */}
|
||||
<div className="leading-relaxed text-zinc-600 dark:text-zinc-400">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
27
apps/docs/components/content/image.tsx
Normal file
27
apps/docs/components/content/image.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { assetUrl } from '@/utils/asset-url'
|
||||
import { TldrawLink } from '../common/tldraw-link'
|
||||
|
||||
export function Image(props: any) {
|
||||
const src = typeof props.src === 'string' ? assetUrl(props.src) : props.src
|
||||
const href = props.href ?? src
|
||||
return (
|
||||
<span className="block">
|
||||
<TldrawLink
|
||||
href={href}
|
||||
className="block bg-zinc-100 dark:bg-zinc-800 py-1 sm:rounded-2xl -mx-5 sm:mx-0 sm:px-1"
|
||||
>
|
||||
<img alt={props.title} {...props} src={src} className="w-full sm:rounded-xl !my-0 shadow" />
|
||||
</TldrawLink>
|
||||
{props.caption && (
|
||||
<span className="block text-xs text-zinc-500 mt-3 text-center text-balance">
|
||||
{props.caption}
|
||||
</span>
|
||||
)}
|
||||
{!props.caption && props.title && (
|
||||
<span className="block text-xs text-zinc-500 mt-3 text-center text-balance">
|
||||
{props.title}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
117
apps/docs/components/content/index.tsx
Normal file
117
apps/docs/components/content/index.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import shikiRehype from '@shikijs/rehype'
|
||||
import { MDXRemote } from 'next-mdx-remote-client/rsc'
|
||||
import rehypeSlug from 'rehype-slug-custom-id'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { ApiHeading } from '@/components/content/api-heading'
|
||||
import { StarterKitBento } from '@/components/content/bento'
|
||||
import { Blockquote } from '@/components/content/blockquote'
|
||||
import { Callout } from '@/components/content/callout'
|
||||
import { CheckItem } from '@/components/content/check-item'
|
||||
import { Code, CodeLinks, FocusLines } from '@/components/content/code'
|
||||
import { Embed, StarterKitEmbed } from '@/components/content/embed'
|
||||
import { Feature } from '@/components/content/feature'
|
||||
import { Image } from '@/components/content/image'
|
||||
import { MaskedArticleImage } from '@/components/content/masked-article-image'
|
||||
import { ParametersTable } from '@/components/content/parameters-table'
|
||||
import { ParametersTableDescription } from '@/components/content/parameters-table-description'
|
||||
import { ParametersTableName } from '@/components/content/parameters-table-name'
|
||||
import { ParametersTableRow } from '@/components/content/parameters-table-row'
|
||||
import { Pre } from '@/components/content/pre'
|
||||
import { SideBySideImages } from '@/components/content/side-by-side-images'
|
||||
import { ApiMemberTitle } from '@/components/content/title-with-source-link'
|
||||
import { Video } from '@/components/content/video'
|
||||
import { YouTube } from '@/components/content/youtube'
|
||||
import { cn } from '@/utils/cn'
|
||||
import { TldrawLink } from '../common/tldraw-link'
|
||||
|
||||
export function Content({
|
||||
mdx,
|
||||
type,
|
||||
className,
|
||||
}: {
|
||||
mdx: string
|
||||
type?: string
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
type === 'reference' && 'prose-hr:hidden prose-h2:!mt-8 md:prose-h2:!mt-24',
|
||||
'prose dark:prose-invert prose-sm prose-zinc text-zinc-800 dark:text-zinc-200 sm:prose-base w-full max-w-full',
|
||||
'prose-code:before:content-none prose-code:after:content-none prose-code:bg-zinc-100 dark:prose-code:!bg-zinc-800 prose-code:px-1 prose-code:py-0.5 prose-code:rounded prose-code:font-normal',
|
||||
'prose-a:no-underline prose-a:text-blue-500 hover:prose-a:text-blue-600 dark:hover:prose-a:text-blue-400 prose-a:font-normal',
|
||||
'prose-blockquote:text-zinc-800 dark:prose-blockquote:text-zinc-200 prose-blockquote:font-normal prose-blockquote:border-none prose-blockquote:px-4 prose-blockquote:leading-normal prose-blockquote:bg-zinc-50 dark:prose-blockquote:bg-zinc-900 prose-blockquote:py-3 prose-blockquote:rounded-xl',
|
||||
'prose-table:bg-zinc-50 dark:prose-table:bg-zinc-900 prose-table:rounded-xl prose-table:text-sm',
|
||||
'prose-th:text-left prose-th:font-semibold prose-th:uppercase prose-th:text-xs prose-th:border-l prose-th:border-white dark:prose-th:border-zinc-950 prose-th:py-3 prose-th:px-4',
|
||||
'prose-tr:border-t prose-tr:border-white dark:prose-tr:border-zinc-950',
|
||||
'prose-td:border-l first:prose-td:border-l-0 prose-td:border-white dark:prose-td:border-zinc-950 prose-td:py-3 prose-td:px-4',
|
||||
'prose-hr:border-zinc-100 dark:prose-hr:border-zinc-800',
|
||||
'prose-h1:scroll-mt-20 prose-h2:scroll-mt-20 prose-h3:scroll-mt-20 prose-h4:scroll-mt-20 prose-h5:scroll-mt-20',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<MDXRemote
|
||||
source={mdx}
|
||||
components={{
|
||||
a: TldrawLink,
|
||||
Embed,
|
||||
StarterKitEmbed,
|
||||
pre: Pre,
|
||||
code: Code,
|
||||
// Markdown images and `<Image />` — both resolve `/…` static URLs via `assetUrl` in `image.tsx`.
|
||||
Image,
|
||||
img: Image,
|
||||
MaskedArticleImage,
|
||||
ApiHeading,
|
||||
StarterKitBento,
|
||||
Feature,
|
||||
Callout,
|
||||
FocusLines,
|
||||
CodeLinks,
|
||||
ParametersTable,
|
||||
ParametersTableDescription,
|
||||
ParametersTableName,
|
||||
ParametersTableRow,
|
||||
ApiMemberTitle,
|
||||
CheckItem,
|
||||
blockquote: Blockquote,
|
||||
Video,
|
||||
YouTube,
|
||||
TwoImages: SideBySideImages,
|
||||
}}
|
||||
options={{
|
||||
mdxOptions: {
|
||||
remarkPlugins: [remarkGfm],
|
||||
rehypePlugins: [
|
||||
[
|
||||
rehypeSlug,
|
||||
{
|
||||
enableCustomId: true,
|
||||
maintainCase: true,
|
||||
removeAccents: true,
|
||||
},
|
||||
],
|
||||
[
|
||||
shikiRehype as any,
|
||||
{
|
||||
themes: {
|
||||
dark: 'github-dark-default',
|
||||
light: 'github-light-default',
|
||||
},
|
||||
defaultColor: false,
|
||||
// Without `langs` the plugin loads all ~300 bundled grammars (seconds and
|
||||
// >100MB per build worker / dev server). Anything not listed is still
|
||||
// loaded on demand thanks to `lazy`.
|
||||
langs: ['ts', 'tsx', 'bash', 'css', 'toml', 'html'],
|
||||
lazy: true,
|
||||
},
|
||||
],
|
||||
],
|
||||
format: 'mdx',
|
||||
},
|
||||
parseFrontmatter: true,
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
17
apps/docs/components/content/masked-article-image.tsx
Normal file
17
apps/docs/components/content/masked-article-image.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { assetUrl } from '@/utils/asset-url'
|
||||
|
||||
/**
|
||||
* Hero image with CSS mask — MDX cannot run `assetUrl` inside inline `style` strings, so paths in
|
||||
* `mask: url(/images/…)` would stay root-relative and break when docs use `ASSET_PREFIX`.
|
||||
*/
|
||||
export function MaskedArticleImage(props: { gifSrc: string; maskSrc: string; alt: string }) {
|
||||
const gif = assetUrl(props.gifSrc)
|
||||
const mask = assetUrl(props.maskSrc)
|
||||
const maskCss = `url(${mask}) luminance center 100% / 100% no-repeat`
|
||||
|
||||
return (
|
||||
<div className="article__image" style={{ border: 'none' }}>
|
||||
<img alt={props.alt} src={gif} style={{ mask: maskCss, WebkitMask: maskCss }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function ParametersTableDescription({ children }: { children: React.ReactNode }) {
|
||||
return <td>{children}</td>
|
||||
}
|
||||
3
apps/docs/components/content/parameters-table-name.tsx
Normal file
3
apps/docs/components/content/parameters-table-name.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export function ParametersTableName({ children }: { children: React.ReactNode }) {
|
||||
return <td>{children}</td>
|
||||
}
|
||||
3
apps/docs/components/content/parameters-table-row.tsx
Normal file
3
apps/docs/components/content/parameters-table-row.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export function ParametersTableRow({ children }: { children: React.ReactNode }) {
|
||||
return <tr>{children}</tr>
|
||||
}
|
||||
15
apps/docs/components/content/parameters-table.tsx
Normal file
15
apps/docs/components/content/parameters-table.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
export function ParametersTable({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="prose-p:my-0 prose-pre:hidden prose-code:!bg-zinc-200 dark:prose-code:!bg-zinc-800">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>{children}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
32
apps/docs/components/content/pre.tsx
Normal file
32
apps/docs/components/content/pre.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
'use client'
|
||||
|
||||
import { DetailedHTMLProps, HTMLAttributes, useRef } from 'react'
|
||||
import { cn } from '@/utils/cn'
|
||||
import { CopyButton } from './copy-button'
|
||||
|
||||
export function Pre(props: DetailedHTMLProps<HTMLAttributes<HTMLPreElement>, HTMLPreElement>) {
|
||||
const container = useRef<HTMLPreElement>(null)
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group relative not-prose bg-zinc-100 dark:bg-zinc-800 py-1 md:rounded-2xl -mx-5 md:-mx-1 md:px-1 my-6',
|
||||
'[td_&]:m-0 [td_&]:mb-2 [td_&]:p-0 [td_&]:bg-transparent [td_&]:rounded-none dark:[td_&]:rounded-lg'
|
||||
)}
|
||||
>
|
||||
<pre
|
||||
ref={container}
|
||||
className={cn(
|
||||
'bg-zinc-900 text-sm text-white shadow md:rounded-xl overflow-x-auto px-5 md:px-4 py-4 font-medium',
|
||||
'[td_&]:bg-zinc-200 dark:[td_&]:bg-zinc-800 [td_&]:shadow-none [td_&]:rounded-lg [td_&]:p-0 [td_&]:px-1.5',
|
||||
// this rule enables inverted dark/light mode inside of table cells
|
||||
'[&_span]:text-[--shiki-dark]',
|
||||
'[td_&_span]:!text-[--shiki-light]',
|
||||
'dark:[td_&_span]:!text-[--shiki-dark]'
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</pre>
|
||||
<CopyButton copy={container} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
10
apps/docs/components/content/side-by-side-images.tsx
Normal file
10
apps/docs/components/content/side-by-side-images.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Image } from './image'
|
||||
|
||||
export function SideBySideImages({ left, right }: { left: any; right: any }) {
|
||||
return (
|
||||
<span className="flex gap-8 sm:gap-2 flex-col sm:flex-row">
|
||||
<Image {...left} />
|
||||
<Image {...right} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
54
apps/docs/components/content/title-with-source-link.tsx
Normal file
54
apps/docs/components/content/title-with-source-link.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { ArrowRightIcon } from '@heroicons/react/16/solid'
|
||||
import { CodeBracketIcon } from '@heroicons/react/20/solid'
|
||||
import Link from 'next/link'
|
||||
import { cn } from '@/utils/cn'
|
||||
|
||||
function Tag({ children }: { children: React.ReactNode; tag: string }) {
|
||||
return <span className="border border-zinc-800 rounded-full px-1.5 text-xs">{children}</span>
|
||||
}
|
||||
|
||||
export function ApiMemberTitle({
|
||||
children,
|
||||
source,
|
||||
tags,
|
||||
inherited,
|
||||
large,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
source?: string | null
|
||||
large?: boolean
|
||||
tags?: string[]
|
||||
inherited?: { name: string; link: string }
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-end gap-2 prose-headings:mb-0">
|
||||
{children}
|
||||
<div className={cn('flex gap-2 items-center pb-1.5', !large && 'ml-auto')}>
|
||||
{tags?.map((tag) => (
|
||||
<Tag key={tag} tag={tag}>
|
||||
{tag}
|
||||
</Tag>
|
||||
))}
|
||||
{source && (
|
||||
<a href={source} target="_blank" rel="noopener noreferrer" title="Source code">
|
||||
<CodeBracketIcon className="size-20" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{inherited && (
|
||||
<div className="not-prose text-xs mb-6">
|
||||
from{' '}
|
||||
<Link
|
||||
href={inherited.link}
|
||||
className="text-blue-500 hover:text-blue-600 dark:hover:text-blue-400 inline-flex items-center"
|
||||
>
|
||||
<span>{inherited.name}</span>
|
||||
<ArrowRightIcon className="h-3 -rotate-45" />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
40
apps/docs/components/content/video.tsx
Normal file
40
apps/docs/components/content/video.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { TldrawLink } from '../common/tldraw-link'
|
||||
|
||||
export function Video({
|
||||
src,
|
||||
href,
|
||||
thumbnail,
|
||||
caption,
|
||||
lazy = false,
|
||||
autoplay = false,
|
||||
}: {
|
||||
src: string
|
||||
thumbnail?: string
|
||||
caption?: string
|
||||
autoplay?: boolean
|
||||
href?: string
|
||||
lazy?: boolean
|
||||
}) {
|
||||
return (
|
||||
<span className="block">
|
||||
<TldrawLink
|
||||
href={href ?? src}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="block bg-zinc-100 dark:bg-zinc-800 py-1 sm:rounded-2xl sm:px-1"
|
||||
>
|
||||
<video
|
||||
className="w-full sm:rounded-xl !my-0 shadow"
|
||||
src={src}
|
||||
poster={thumbnail}
|
||||
controls={false}
|
||||
preload={lazy ? 'metadata' : 'auto'}
|
||||
autoPlay={autoplay}
|
||||
muted={autoplay}
|
||||
loop={autoplay}
|
||||
/>
|
||||
</TldrawLink>
|
||||
{caption && <span className="block text-xs text-zinc-500 mt-3 text-center">{caption}</span>}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
26
apps/docs/components/content/youtube.tsx
Normal file
26
apps/docs/components/content/youtube.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
export function YouTube({ src, caption }: { src: string; caption?: string }) {
|
||||
// Extracts youtube ID from any format, eg:
|
||||
// https://youtu.be/tHQW_K2pEgI
|
||||
// https://www.youtube.com/watch?v=tHQW_K2pEgI
|
||||
// https://youtu.be/tHQW_K2pEgI?si=sKtDDhygzBezfean
|
||||
// https://www.youtube.com/watch?v=tHQW_K2pEgI&si=sKtDDhygzBezfean
|
||||
const id = ((src.split('/').pop() ?? src).split('v=').pop() ?? src)
|
||||
.split('&si=')[0]
|
||||
.split('?si=')[0]
|
||||
|
||||
return (
|
||||
<span className="block mb-5">
|
||||
<span className="block bg-zinc-100 dark:bg-zinc-800 py-1 md:rounded-2xl -mx-5 md:mx-0 md:px-1">
|
||||
<iframe
|
||||
src={`https://www.youtube.com/embed/${id}?rel=0`}
|
||||
title={caption}
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
referrerPolicy="strict-origin-when-cross-origin"
|
||||
allowFullScreen
|
||||
className="w-full aspect-video md:rounded-xl overflow-hidden !my-0 shadow"
|
||||
></iframe>
|
||||
</span>
|
||||
{caption && <span className="block text-xs text-zinc-500 mt-3 text-center">{caption}</span>}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
40
apps/docs/components/docs/copy-markdown-button.tsx
Normal file
40
apps/docs/components/docs/copy-markdown-button.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Button } from '../common/button'
|
||||
|
||||
export function CopyMarkdownButton({
|
||||
markdown,
|
||||
className,
|
||||
}: {
|
||||
markdown: string
|
||||
className?: string
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const handleClick = () => {
|
||||
navigator.clipboard.writeText(markdown)
|
||||
|
||||
if (window.tlanalytics?.trackCopyCode) {
|
||||
window.tlanalytics.trackCopyCode({
|
||||
page_category: 'docs',
|
||||
text_snippet: markdown.slice(0, 500),
|
||||
})
|
||||
}
|
||||
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 1500)
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
id="copy-markdown"
|
||||
onClick={handleClick}
|
||||
caption={copied ? 'Copy markdown' : 'Copy markdown'}
|
||||
icon={copied ? 'check' : 'copy'}
|
||||
size="sm"
|
||||
type="secondary"
|
||||
className={className}
|
||||
/>
|
||||
)
|
||||
}
|
||||
31
apps/docs/components/docs/docs-article-info.tsx
Normal file
31
apps/docs/components/docs/docs-article-info.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { format } from 'date-fns'
|
||||
import Link from 'next/link'
|
||||
import { BackToTopButton } from '@/components/common/back-to-top-button'
|
||||
import { GithubIcon } from '@/components/common/icon/github'
|
||||
import { Article } from '@/types/content-types'
|
||||
|
||||
const githubContentRoot = 'https://github.com/tldraw/tldraw/blob/main/apps/docs/content/'
|
||||
|
||||
export function DocsArticleInfo({ article }: { article: Article }) {
|
||||
if (article.sectionId === 'reference') {
|
||||
// Reference pages are source code and already link to it via 'See source code'. Keyed on
|
||||
// section, not `authorId === 'api'`: that's also the default for articles with no `author:`.
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="shrink-0 text-xs flex flex-col gap-2 mb-12">
|
||||
{article.date && <p>Last edited on {format(new Date(article.date), 'MMM dd, yyyy')}</p>}
|
||||
{article.sourceUrl && (
|
||||
<Link
|
||||
href={`${githubContentRoot}${article.sourceUrl}`}
|
||||
className="flex items-center gap-1.5 text-blue-500 hover:text-blue-600 dark:hover:text-blue-400"
|
||||
>
|
||||
<GithubIcon className="h-3.5" />
|
||||
<span>Edit this page on GitHub</span>
|
||||
</Link>
|
||||
)}
|
||||
<BackToTopButton />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
58
apps/docs/components/docs/docs-category-menu.tsx
Normal file
58
apps/docs/components/docs/docs-category-menu.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
'use client'
|
||||
|
||||
import { RocketLaunchIcon } from '@heroicons/react/16/solid'
|
||||
import { AcademicCapIcon, CommandLineIcon, CubeIcon, PlayIcon } from '@heroicons/react/20/solid'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { NavigationLink } from '@/components/navigation/link'
|
||||
|
||||
const categoryLinks = [
|
||||
{
|
||||
caption: 'Quick start',
|
||||
icon: RocketLaunchIcon,
|
||||
href: '/quick-start',
|
||||
// usePathname() is /getting-started/* on the server (prerendered path) and /quick-start
|
||||
// etc. in the browser (rewrite); match both or hydration mismatches.
|
||||
active: (pathname: string) =>
|
||||
['/quick-start', '/installation', '/releases', '/getting-started'].some((e) =>
|
||||
pathname.startsWith(e)
|
||||
),
|
||||
},
|
||||
{
|
||||
caption: 'Documentation',
|
||||
icon: AcademicCapIcon,
|
||||
href: '/docs/editor',
|
||||
active: (pathname: string) =>
|
||||
['/docs', '/sdk-features', '/community'].some((e) => pathname.startsWith(e)),
|
||||
},
|
||||
{
|
||||
caption: 'Reference',
|
||||
icon: CommandLineIcon,
|
||||
href: '/reference/editor/Editor',
|
||||
active: (pathname: string) => pathname.startsWith('/reference'),
|
||||
},
|
||||
{
|
||||
caption: 'Starter kits',
|
||||
icon: CubeIcon,
|
||||
href: '/starter-kits/overview',
|
||||
active: (pathname: string) => ['/starter-kits'].some((e) => pathname.startsWith(e)),
|
||||
},
|
||||
{
|
||||
caption: 'Examples',
|
||||
icon: PlayIcon,
|
||||
href: '/examples/basic',
|
||||
active: (pathname: string) => pathname.startsWith('/examples'),
|
||||
},
|
||||
]
|
||||
|
||||
export function DocsCategoryMenu() {
|
||||
const pathname = usePathname()
|
||||
return (
|
||||
<ul className="flex flex-col gap-3 shrink-0 pb-4">
|
||||
{categoryLinks.map((item, index) => (
|
||||
<li key={index}>
|
||||
<NavigationLink {...item} active={item.active(pathname)} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
142
apps/docs/components/docs/docs-feedback-widget.tsx
Normal file
142
apps/docs/components/docs/docs-feedback-widget.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
'use client'
|
||||
|
||||
import { Field, Label, Textarea } from '@headlessui/react'
|
||||
import { ArrowLongRightIcon, CheckCircleIcon, HandThumbDownIcon } from '@heroicons/react/20/solid'
|
||||
import { HandThumbUpIcon } from '@heroicons/react/24/solid'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { FormEventHandler, useState } from 'react'
|
||||
import { track } from '@/app/analytics'
|
||||
import { cn } from '@/utils/cn'
|
||||
import { useLocalStorageState } from '@/utils/storage'
|
||||
|
||||
const DEBUGGING = false
|
||||
|
||||
async function submitFeedback(
|
||||
_sessionId: string,
|
||||
_pathname: string,
|
||||
_feedback: 1 | -1 | 0,
|
||||
_note: string
|
||||
) {
|
||||
const feedback: { value: number; message?: string } = { value: _feedback }
|
||||
if (_note !== '') feedback.message = _note
|
||||
track('Feedback', feedback)
|
||||
return
|
||||
}
|
||||
async function submitThumbsFeedback(_sessionId: string, _pathname: string, _feedback: 1 | -1 | 0) {
|
||||
const feedback: { value: number; message?: string } = { value: _feedback }
|
||||
track('Helpful', feedback)
|
||||
return
|
||||
}
|
||||
|
||||
export function DocsFeedbackWidget({ className }: { className?: string }) {
|
||||
const pathname = usePathname()
|
||||
|
||||
const [didSubmit, setDidSubmit] = useLocalStorageState(pathname + '-feedback-submitted', false)
|
||||
|
||||
// Set the feedback session id to a random id and stash it in storage
|
||||
const [sessionId] = useLocalStorageState(
|
||||
'feedback-session-id',
|
||||
Math.random().toString(36).substring(7)
|
||||
)
|
||||
const [state, setState] = useState<
|
||||
'idle' | 'thumbs-up' | 'thumbs-down' | 'loading' | 'success' | 'error'
|
||||
>('idle')
|
||||
|
||||
// Track outside the setState updater: React double-invokes updaters in StrictMode, which
|
||||
// would double-count the vote.
|
||||
const handleThumbs = (thumb: 'thumbs-up' | 'thumbs-down') => {
|
||||
const next = state === thumb ? 'idle' : thumb
|
||||
if (next === thumb) {
|
||||
submitThumbsFeedback(sessionId, pathname, thumb === 'thumbs-up' ? 1 : -1)
|
||||
}
|
||||
setState(next)
|
||||
}
|
||||
|
||||
const handleSubmit: FormEventHandler<HTMLFormElement> = async (e) => {
|
||||
e.preventDefault()
|
||||
if (state === 'loading') return
|
||||
if (!sessionId) return
|
||||
|
||||
try {
|
||||
const form = e.currentTarget
|
||||
const formData = new FormData(form)
|
||||
const feedback = formData.get('feedback') as string
|
||||
|
||||
setState('loading')
|
||||
await submitFeedback(sessionId, pathname, state === 'thumbs-up' ? 1 : -1, feedback)
|
||||
setState('success')
|
||||
setTimeout(() => {
|
||||
setDidSubmit(true)
|
||||
}, 3000)
|
||||
} catch {
|
||||
setState('error')
|
||||
}
|
||||
}
|
||||
|
||||
// todo, improve this so that thumbs ups and thumbs downs are also captured
|
||||
if (didSubmit && !(DEBUGGING && process.env.NODE_ENV === 'development')) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'-ml-4 px-4 bg-zinc-50 dark:bg-zinc-900 rounded-lg shrink-0 text-xs h-auto',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{state !== 'success' && (
|
||||
<div className="flex justify-between items-center">
|
||||
<span>Is this page helpful?</span>
|
||||
<div className="flex">
|
||||
<button
|
||||
onClick={() => handleThumbs('thumbs-down')}
|
||||
className={cn(
|
||||
'h-9 w-7 flex items-center justify-center pt-0.5',
|
||||
state === 'thumbs-down' && 'text-blue-500',
|
||||
state !== 'thumbs-down' && 'hover:text-black dark:hover:text-white'
|
||||
)}
|
||||
>
|
||||
<HandThumbDownIcon className="h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleThumbs('thumbs-up')}
|
||||
className={cn(
|
||||
'h-9 w-7 flex items-center justify-center pb-0.5 -mr-1',
|
||||
state === 'thumbs-up' && 'text-blue-500',
|
||||
state !== 'thumbs-up' && 'hover:text-black dark:hover:text-white'
|
||||
)}
|
||||
>
|
||||
<HandThumbUpIcon className="h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(state === 'thumbs-up' || state === 'thumbs-down' || state === 'loading') && (
|
||||
<form onSubmit={handleSubmit} className="pb-3">
|
||||
<Field>
|
||||
<Label className="sr-only">Your feedback</Label>
|
||||
<Textarea
|
||||
name="feedback"
|
||||
placeholder="Your feedback..."
|
||||
rows={3}
|
||||
className="resize-none bg-zinc-200/50 dark:bg-zinc-700/50 w-full rounded-md placeholder-zinc-400 dark:placeholder-zinc-600 text-black dark:text-white px-2 py-1 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 focus:ring-offset-zinc-50 dark:focus:ring-offset-zinc-900"
|
||||
/>
|
||||
</Field>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={state === 'loading'}
|
||||
className="bg-blue-500 rounded-md h-6 px-3 flex items-center text-white gap-1.5 font-medium ml-auto mt-2 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 focus:ring-offset-zinc-50 hover:bg-blue-600 dark:hover:bg-blue-400 dark:focus:ring-offset-zinc-900"
|
||||
>
|
||||
<span>Submit</span>
|
||||
<ArrowLongRightIcon className="h-3.5" />
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
{state === 'success' && (
|
||||
<p className="h-9 flex items-center gap-1.5">
|
||||
<CheckCircleIcon className="h-4 text-emerald-500 dark:text-emerald-400" />
|
||||
<span>Thanks for your feedback!</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
45
apps/docs/components/docs/docs-footer.tsx
Normal file
45
apps/docs/components/docs/docs-footer.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { ArrowLongLeftIcon, ArrowLongRightIcon } from '@heroicons/react/20/solid'
|
||||
import Link from 'next/link'
|
||||
import { Article } from '@/types/content-types'
|
||||
import { db } from '@/utils/ContentDatabase'
|
||||
|
||||
export async function DocsFooter({ article }: { article: Article }) {
|
||||
const links = await db.getArticleLinks(article)
|
||||
|
||||
return (
|
||||
<section className="py-10 mt-12 border-t border-zinc-100 dark:border-zinc-800 flex flex-wrap justify-between gap-8">
|
||||
{links.prev ? (
|
||||
<Link
|
||||
href={links.prev.path!}
|
||||
className="group py-2 px-4 -ml-4 rounded-lg hover:bg-zinc-50 dark:hover:bg-zinc-900 flex flex-col items-start gap-0.5"
|
||||
>
|
||||
<div className="flex items-center gap-1 text-xs group-hover:text-black dark:group-hover:text-white">
|
||||
<ArrowLongLeftIcon className="h-3" />
|
||||
<span>Prev</span>
|
||||
</div>
|
||||
<span className="text-blue-500 group-hover:text-blue-600 dark:group-hover:text-blue-400">
|
||||
{links.prev.title}
|
||||
</span>
|
||||
</Link>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
{links.next ? (
|
||||
<Link
|
||||
href={links.next.path!}
|
||||
className="group py-2 px-4 -mr-4 rounded-lg hover:bg-zinc-50 dark:hover:bg-zinc-900 flex flex-col items-end gap-0.5 justify-self-end ml-auto"
|
||||
>
|
||||
<div className="flex items-center gap-1 text-xs group-hover:text-black dark:group-hover:text-white">
|
||||
<span>Next</span>
|
||||
<ArrowLongRightIcon className="h-3" />
|
||||
</div>
|
||||
<span className="text-blue-500 group-hover:text-blue-600 dark:group-hover:text-blue-400">
|
||||
{links.next.title}
|
||||
</span>
|
||||
</Link>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
97
apps/docs/components/docs/docs-header.tsx
Normal file
97
apps/docs/components/docs/docs-header.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { Button } from '@/components/common/button'
|
||||
import { PageTitle } from '@/components/common/page-title'
|
||||
import { Article } from '@/types/content-types'
|
||||
import { cn } from '@/utils/cn'
|
||||
import { version } from '@/version'
|
||||
import { CopyMarkdownButton } from './copy-markdown-button'
|
||||
|
||||
function getFileExtension(filename: string): string {
|
||||
const ext = filename.split('.').pop()?.toLowerCase() ?? ''
|
||||
switch (ext) {
|
||||
case 'tsx':
|
||||
case 'ts':
|
||||
return 'tsx'
|
||||
case 'jsx':
|
||||
case 'js':
|
||||
return 'jsx'
|
||||
case 'css':
|
||||
return 'css'
|
||||
case 'svg':
|
||||
return 'xml'
|
||||
default:
|
||||
return ext
|
||||
}
|
||||
}
|
||||
|
||||
function buildFullMarkdown(article: Article): string {
|
||||
const parts: string[] = []
|
||||
|
||||
// Add the article content
|
||||
if (article.content) {
|
||||
parts.push(article.content.trim())
|
||||
}
|
||||
|
||||
// Collect all code files
|
||||
const codeFiles: Array<{ name: string; content: string }> = []
|
||||
|
||||
// Add the main component code
|
||||
if (article.componentCode && article.componentCodeFilename) {
|
||||
codeFiles.push({ name: article.componentCodeFilename, content: article.componentCode })
|
||||
}
|
||||
|
||||
// Add additional code files
|
||||
if (article.componentCodeFiles) {
|
||||
const files = JSON.parse(article.componentCodeFiles) as Record<string, string>
|
||||
for (const [fileName, content] of Object.entries(files)) {
|
||||
codeFiles.push({ name: fileName, content })
|
||||
}
|
||||
}
|
||||
|
||||
// Add all code files with filename comments
|
||||
for (const file of codeFiles) {
|
||||
const ext = getFileExtension(file.name)
|
||||
parts.push('\n\n```' + ext + '\n// ' + file.name + '\n\n' + file.content.trim() + '\n```')
|
||||
}
|
||||
|
||||
return parts.join('')
|
||||
}
|
||||
|
||||
export async function DocsHeader({ article }: { article: Article }) {
|
||||
let sourceUrlWithVersionTag
|
||||
if (article.sectionId === 'reference' && article.sourceUrl) {
|
||||
sourceUrlWithVersionTag = article.sourceUrl.replace(
|
||||
'/tldraw/tldraw/blob/main',
|
||||
'/tldraw/tldraw/blob/v' + version
|
||||
)
|
||||
}
|
||||
|
||||
const markdown = buildFullMarkdown(article)
|
||||
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
article.sectionId === 'reference'
|
||||
? ''
|
||||
: 'pb-6 mb-6 md:mb-8 md:pb-8 border-b border-zinc-100 dark:border-zinc-800'
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-wrap justify-between gap-x-8 gap-y-3">
|
||||
<PageTitle>{article.title}</PageTitle>
|
||||
<div className="flex flex-wrap items-center gap-2 mt-1">
|
||||
{markdown && <CopyMarkdownButton markdown={markdown} />}
|
||||
{sourceUrlWithVersionTag && (
|
||||
<Button
|
||||
id="see-source-code"
|
||||
href={sourceUrlWithVersionTag}
|
||||
newTab
|
||||
caption="See source code"
|
||||
icon="github"
|
||||
size="sm"
|
||||
type="secondary"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
50
apps/docs/components/docs/docs-mobile-sidebar.tsx
Normal file
50
apps/docs/components/docs/docs-mobile-sidebar.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Popover, PopoverButton, PopoverPanel } from '@headlessui/react'
|
||||
import { Bars3Icon } from '@heroicons/react/16/solid'
|
||||
import { XMarkIcon } from '@heroicons/react/20/solid'
|
||||
import { DocsCategoryMenu } from '@/components/docs/docs-category-menu'
|
||||
import { DocsSidebarMenu } from '@/components/docs/docs-sidebar-menu'
|
||||
import { processSidebarContent } from '@/components/docs/docs-sidebar-utils'
|
||||
import { db } from '@/utils/ContentDatabase'
|
||||
|
||||
export async function DocsMobileSidebar({
|
||||
sectionId,
|
||||
categoryId,
|
||||
articleId,
|
||||
}: {
|
||||
sectionId?: string
|
||||
categoryId?: string
|
||||
articleId?: string
|
||||
}) {
|
||||
const sidebar = await db.getSidebarContentList({ sectionId, categoryId, articleId })
|
||||
const elements = processSidebarContent(sidebar, sectionId)
|
||||
|
||||
return (
|
||||
<Popover className="group/popover h-full grow">
|
||||
<PopoverButton className="group/button focus:outline-none h-full w-full flex justify-start items-center">
|
||||
<div className="flex gap-2 h-8 px-2 -ml-2 items-center justify-center rounded group-focus/button:outline-none group-focus/button:bg-zinc-100 dark:group-focus/button:bg-zinc-800 text-black dark:text-white font-semibold">
|
||||
<Bars3Icon className="h-4 shrink-0 group-data-[open]/popover:hidden" />
|
||||
<XMarkIcon className="h-4 scale-125 shrink-0 hidden group-data-[open]/popover:block" />
|
||||
<span className="text-sm">Menu</span>
|
||||
</div>
|
||||
</PopoverButton>
|
||||
<PopoverPanel>
|
||||
<div
|
||||
className="fixed left-0 top-12 bg-white dark:bg-zinc-950 w-screen px-5 py-8 overflow-y-auto z-10"
|
||||
style={{ height: 'calc(100vh - 6.5rem)' }}
|
||||
>
|
||||
<DocsCategoryMenu />
|
||||
{elements.map((menu: any, index: number) => (
|
||||
// @ts-ignore
|
||||
<DocsSidebarMenu
|
||||
key={index}
|
||||
title={menu.title}
|
||||
elements={menu.children}
|
||||
isFirst={index === 0}
|
||||
hideTitle={elements.length === 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</PopoverPanel>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
17
apps/docs/components/docs/docs-search-bar.tsx
Normal file
17
apps/docs/components/docs/docs-search-bar.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
'use client'
|
||||
|
||||
import { motion } from 'framer-motion'
|
||||
import { SearchButton } from '@/components/search/SearchButton'
|
||||
|
||||
export function DocsSearchBar() {
|
||||
return (
|
||||
<motion.div
|
||||
className="hidden md:flex mx-auto px-5 sticky top-4 z-10 xl:max-w-3xl xl:px-0"
|
||||
style={{ width: '55%', marginTop: '-56px' }}
|
||||
>
|
||||
<div className="lg:max-w-3xl lg:pl-12 lg:pr-12 grow">
|
||||
<SearchButton type="docs" layout="desktop" />
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
93
apps/docs/components/docs/docs-sidebar-menu.tsx
Normal file
93
apps/docs/components/docs/docs-sidebar-menu.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
'use client'
|
||||
|
||||
import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/react'
|
||||
import { ChevronRightIcon } from '@heroicons/react/16/solid'
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { APIGroup } from '@/types/content-types'
|
||||
import { cn } from '@/utils/cn'
|
||||
|
||||
export function DocsSidebarMenu({
|
||||
title,
|
||||
elements,
|
||||
isFirst = false,
|
||||
hideTitle = false,
|
||||
}: {
|
||||
isFirst: boolean
|
||||
title: string
|
||||
elements: {
|
||||
type: string
|
||||
articleId: string
|
||||
title: string
|
||||
url: string
|
||||
groupId: string | null
|
||||
}[]
|
||||
hideTitle?: boolean
|
||||
}) {
|
||||
const pathname = usePathname()
|
||||
const groups = elements.some((e) => e.groupId) ? Object.values(APIGroup) : null
|
||||
|
||||
return (
|
||||
<div className={cn(isFirst ? 'mt-0 md:mt-8' : 'mt-0 md:mt-12')}>
|
||||
{!hideTitle && (
|
||||
<h4 className="text-black dark:text-white uppercase text-xs w-full font-semibold md:sticky top-0 bg-white dark:bg-zinc-950 z-10 py-2">
|
||||
{title}
|
||||
</h4>
|
||||
)}
|
||||
<ul className={cn('flex flex-col gap-2 text-sm break-words', !hideTitle && 'mt-2')}>
|
||||
{groups?.map((group, index) => {
|
||||
const groupElements = elements.filter((e) => e.groupId === group)
|
||||
if (groupElements.length === 0) return null
|
||||
return (
|
||||
<li key={index}>
|
||||
<Disclosure defaultOpen={groupElements.some((e) => e.url === pathname)}>
|
||||
<DisclosureButton className="group w-full flex justify-between gap-4">
|
||||
<span className="group-data-[open]:text-black dark:group-data-[open]:text-white group-data-[open]:font-semibold">
|
||||
{group}
|
||||
</span>
|
||||
<ChevronRightIcon className="h-3.5 shrink-0 group-data-[open]:rotate-90 text-zinc-400 dark:text-zinc-600 group-data-[open]:text-black dark:group-data-[open]:text-white" />
|
||||
</DisclosureButton>
|
||||
<DisclosurePanel>
|
||||
<ul className="flex flex-col gap-2 ml-1 pl-2 pt-2 border-l border-zinc-100 dark:border-zinc-800">
|
||||
{groupElements.map(({ title, url }, index) => (
|
||||
<li key={index}>
|
||||
<Link
|
||||
href={url}
|
||||
data-active={pathname === url}
|
||||
className={cn(
|
||||
'sidebar-link w-full truncate',
|
||||
pathname === url
|
||||
? 'text-black dark:text-white font-semibold'
|
||||
: 'hover:text-zinc-800 dark:hover:text-zinc-200'
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</DisclosurePanel>
|
||||
</Disclosure>
|
||||
</li>
|
||||
)
|
||||
}) ??
|
||||
elements.map(({ title, url }, index) => (
|
||||
<li key={index}>
|
||||
<Link
|
||||
href={url}
|
||||
data-active={pathname === url}
|
||||
className={cn(
|
||||
'sidebar-link block',
|
||||
pathname === url || `/getting-started${pathname}` === url
|
||||
? 'text-black dark:text-white font-semibold'
|
||||
: 'hover:text-zinc-800 dark:hover:text-zinc-200'
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
56
apps/docs/components/docs/docs-sidebar-menus.tsx
Normal file
56
apps/docs/components/docs/docs-sidebar-menus.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
'use client'
|
||||
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { UIEventHandler, useEffect, useRef } from 'react'
|
||||
import { DocsSidebarMenu } from '@/components/docs/docs-sidebar-menu'
|
||||
|
||||
let scrollPosition = 0
|
||||
|
||||
export function DocsSidebarMenus({ menus }: { menus: any }) {
|
||||
const container = useRef<HTMLDivElement>(null)
|
||||
const pathname = usePathname()
|
||||
|
||||
useEffect(() => {
|
||||
container.current?.scrollTo(0, scrollPosition)
|
||||
const elements = document.querySelectorAll(
|
||||
'.sidebar-link[data-active=true]'
|
||||
) as NodeListOf<HTMLElement>
|
||||
for (const element of elements) {
|
||||
const aboveView = container.current && element.offsetTop < container.current.scrollTop
|
||||
const belowView =
|
||||
container.current &&
|
||||
element.offsetTop > container.current.scrollTop + container.current.clientHeight
|
||||
if (!aboveView && !belowView) {
|
||||
return
|
||||
}
|
||||
}
|
||||
const element = elements[0]
|
||||
element?.scrollIntoView({ block: 'center' })
|
||||
}, [pathname])
|
||||
|
||||
const onScroll: UIEventHandler<HTMLDivElement> = (e) => {
|
||||
e.stopPropagation()
|
||||
scrollPosition = container.current?.scrollTop ?? 0
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={container}
|
||||
onScroll={onScroll}
|
||||
className="relative pr-12 overflow-y-auto grow min-h-0 pb-24"
|
||||
>
|
||||
<div>
|
||||
{menus.map((menu: any, index: number) => (
|
||||
// @ts-ignore
|
||||
<DocsSidebarMenu
|
||||
key={index}
|
||||
title={menu.title}
|
||||
elements={menu.children}
|
||||
isFirst={index === 0}
|
||||
hideTitle={menus.length === 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
53
apps/docs/components/docs/docs-sidebar-utils.ts
Normal file
53
apps/docs/components/docs/docs-sidebar-utils.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
SidebarContentArticleLink,
|
||||
SidebarContentCategoryLink,
|
||||
SidebarContentLink,
|
||||
} from '@/types/content-types'
|
||||
|
||||
/**
|
||||
* Type guard to check if a sidebar link has children
|
||||
*/
|
||||
function hasChildren(
|
||||
link: SidebarContentLink
|
||||
): link is SidebarContentLink & { children: SidebarContentLink[] } {
|
||||
return 'children' in link
|
||||
}
|
||||
|
||||
// Examples duplicated into the examples sidebar's "Getting started" category. Matched by url,
|
||||
// not article id: ids (`examples/collaboration/sync-demo`) change when an example moves category.
|
||||
const EXAMPLES_ALSO_IN_GETTING_STARTED = ['/examples/sync-demo', '/examples/api']
|
||||
|
||||
/**
|
||||
* Processes sidebar content by extracting elements and handling special cases
|
||||
* like copying examples to the getting-started category.
|
||||
*/
|
||||
export function processSidebarContent(
|
||||
sidebar: Awaited<ReturnType<typeof import('@/utils/ContentDatabase').db.getSidebarContentList>>,
|
||||
sectionId?: string
|
||||
): SidebarContentLink[] {
|
||||
const skipFirstLevel = ['reference', 'examples'].includes(sectionId ?? '')
|
||||
const elements =
|
||||
skipFirstLevel && hasChildren(sidebar.links[0]) ? sidebar.links[0].children : sidebar.links
|
||||
|
||||
if (sectionId === 'examples') {
|
||||
const categories = elements.filter(
|
||||
(v): v is SidebarContentCategoryLink => v.type === 'category'
|
||||
)
|
||||
const gettingStartedCategory = categories.find((v) => v.url === '/examples/getting-started')
|
||||
|
||||
// An example that's been renamed or moved won't be found. Pushing `undefined` would crash
|
||||
// the sidebar, which renders every entry. The cached link tree is shared between renders,
|
||||
// so don't push the same example twice either.
|
||||
if (gettingStartedCategory) {
|
||||
for (const url of EXAMPLES_ALSO_IN_GETTING_STARTED) {
|
||||
if (gettingStartedCategory.children.some((v) => v.url === url)) continue
|
||||
const example = categories
|
||||
.flatMap((category) => category.children)
|
||||
.find((v): v is SidebarContentArticleLink => v.type === 'article' && v.url === url)
|
||||
if (example) gettingStartedCategory.children.push(example)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return elements
|
||||
}
|
||||
25
apps/docs/components/docs/docs-sidebar.tsx
Normal file
25
apps/docs/components/docs/docs-sidebar.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Navigation } from '@/components/common/navigation'
|
||||
import { DocsCategoryMenu } from '@/components/docs/docs-category-menu'
|
||||
import { DocsSidebarMenus } from '@/components/docs/docs-sidebar-menus'
|
||||
import { processSidebarContent } from '@/components/docs/docs-sidebar-utils'
|
||||
import { db } from '@/utils/ContentDatabase'
|
||||
|
||||
export async function DocsSidebar({
|
||||
sectionId,
|
||||
categoryId,
|
||||
articleId,
|
||||
}: {
|
||||
sectionId?: string
|
||||
categoryId?: string
|
||||
articleId?: string
|
||||
}) {
|
||||
const sidebar = await db.getSidebarContentList({ sectionId, categoryId, articleId })
|
||||
const elements = processSidebarContent(sidebar, sectionId)
|
||||
|
||||
return (
|
||||
<Navigation className="hidden md:flex">
|
||||
<DocsCategoryMenu />
|
||||
<DocsSidebarMenus menus={elements} />
|
||||
</Navigation>
|
||||
)
|
||||
}
|
||||
31
apps/docs/components/docs/docs-starter-sidebar.tsx
Normal file
31
apps/docs/components/docs/docs-starter-sidebar.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Button } from '@/components/common/button'
|
||||
import { DocsArticleInfo } from '@/components/docs/docs-article-info'
|
||||
import { DocsFeedbackWidget } from '@/components/docs/docs-feedback-widget'
|
||||
import { HeadingsMenu } from '@/components/navigation/headings-menu'
|
||||
import type { Article } from '@/types/content-types'
|
||||
import { db } from '@/utils/ContentDatabase'
|
||||
|
||||
export async function DocsStarterSidebar({ article }: { article: Article }) {
|
||||
const headings = await db.getArticleHeadings(article.id)
|
||||
|
||||
return (
|
||||
<nav className="lg:w-60 flex flex-col xl:pl-5 sticky top-24 shrink-0 xl:order-last xl:h-[calc(100vh-6rem)]">
|
||||
{article.githubLink && (
|
||||
<div className="mb-12 flex xl:flex-col gap-2">
|
||||
<Button
|
||||
icon="github"
|
||||
type="secondary"
|
||||
href={article.githubLink}
|
||||
newTab={true}
|
||||
caption="View on GitHub"
|
||||
></Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="hidden xl:flex flex-col">
|
||||
<HeadingsMenu headings={headings} />
|
||||
<DocsArticleInfo article={article} />
|
||||
<DocsFeedbackWidget className="mb-12" />
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
18
apps/docs/components/docs/docs-table-of-contents.tsx
Normal file
18
apps/docs/components/docs/docs-table-of-contents.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Navigation } from '@/components/common/navigation'
|
||||
import { DocsArticleInfo } from '@/components/docs/docs-article-info'
|
||||
import { DocsFeedbackWidget } from '@/components/docs/docs-feedback-widget'
|
||||
import { HeadingsMenu } from '@/components/navigation/headings-menu'
|
||||
import { Article } from '@/types/content-types'
|
||||
import { db } from '@/utils/ContentDatabase'
|
||||
|
||||
export async function DocsTableOfContents({ article }: { article: Article }) {
|
||||
const headings = await db.getArticleHeadings(article.id)
|
||||
|
||||
return (
|
||||
<Navigation className="hidden xl:flex pl-12">
|
||||
<HeadingsMenu headings={headings} />
|
||||
<DocsArticleInfo article={article} />
|
||||
<DocsFeedbackWidget className="mb-12" />
|
||||
</Navigation>
|
||||
)
|
||||
}
|
||||
93
apps/docs/components/navigation/footer.tsx
Normal file
93
apps/docs/components/navigation/footer.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { PrivacySettingsLink } from '@/app/analytics'
|
||||
import { Logo } from '@/components/common/logo'
|
||||
import { TldrawLink } from '../common/tldraw-link'
|
||||
import { SocialLink } from './social-link'
|
||||
|
||||
const menus = [
|
||||
{
|
||||
heading: 'Product',
|
||||
items: [
|
||||
{ caption: 'Whiteboard', href: '/features/out-of-the-box-whiteboard' },
|
||||
{ caption: 'Starter kits', href: '/starter-kits/overview' },
|
||||
{ caption: 'Pricing', href: '/pricing' },
|
||||
{ caption: 'FAQ', href: '/faq' },
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: 'Developers',
|
||||
items: [
|
||||
{ caption: 'Quick start guide', href: '/quick-start' },
|
||||
{ caption: 'Starter kits', href: '/starter-kits/overview' },
|
||||
{ caption: 'Examples', href: '/examples/basic' },
|
||||
{ caption: 'Releases', href: '/releases' },
|
||||
{ caption: 'Docs', href: '/quick-start' },
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: 'Community',
|
||||
items: [
|
||||
{ caption: 'GitHub', href: 'https://github.com/tldraw/tldraw' },
|
||||
{
|
||||
caption: 'Discord',
|
||||
href: 'https://discord.tldraw.com/?utm_source=docs&utm_medium=organic&utm_campaign=sociallink',
|
||||
},
|
||||
{ caption: 'X/Twitter', href: 'https://x.com/tldraw/' },
|
||||
{ caption: 'LinkedIn', href: 'https://www.linkedin.com/company/tldraw/' },
|
||||
{ caption: 'Bluesky', href: 'https://bsky.app/profile/tldraw.com' },
|
||||
{ caption: 'Mastodon', href: 'https://mas.to/@tldraw' },
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: 'Company',
|
||||
items: [
|
||||
{ caption: 'Videos', href: '/blog/events' },
|
||||
{ caption: 'Careers', href: '/careers' },
|
||||
{ caption: 'License', href: '/community/license' },
|
||||
{ caption: 'Trademarks', href: '/legal/trademark-guidelines' },
|
||||
{ caption: 'CLA', href: '/legal/contributor-license-agreement' },
|
||||
{ caption: 'Privacy settings', href: '#', isCookieSetting: true },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export function Footer() {
|
||||
return (
|
||||
<footer className="pt-16 pb-36 bg-zinc-950 text-white">
|
||||
<div className="flex flex-col w-full max-w-screen-2xl gap-12 px-5 mx-auto sm:flex-row sm:justify-between">
|
||||
<div className="flex-[2]">
|
||||
<TldrawLink href="/" className="w-28">
|
||||
<Logo className="h-6" forceWhite />
|
||||
</TldrawLink>
|
||||
<p className="mt-2 text-sm">The infinite canvas SDK</p>
|
||||
<div className="mt-4 flex gap-5">
|
||||
<SocialLink caption="GitHub" icon="github" href="https://github.com/tldraw/tldraw" />
|
||||
<SocialLink caption="X/Twitter" icon="twitter" href="https://x.com/tldraw/" />
|
||||
<SocialLink caption="Discord" icon="discord" href="https://discord.tldraw.com/" />
|
||||
<SocialLink
|
||||
caption="LinkedIn"
|
||||
icon="linkedin"
|
||||
href="https://www.linkedin.com/company/tldraw/"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{menus.map(({ heading, items }, index) => (
|
||||
<div key={index} className="flex-1">
|
||||
<h4 className="font-medium text-sm text-zinc-400">{heading}</h4>
|
||||
<ul className="flex flex-col gap-4 mt-4 text-sm">
|
||||
{items.map(({ caption, href, isCookieSetting }, index) => (
|
||||
<li key={index}>
|
||||
{isCookieSetting ? (
|
||||
<PrivacySettingsLink />
|
||||
) : (
|
||||
<TldrawLink href={href}>{caption}</TldrawLink>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-8 px-5 text-xs">© {new Date().getFullYear()} tldraw</p>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
62
apps/docs/components/navigation/header.tsx
Normal file
62
apps/docs/components/navigation/header.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
'use client'
|
||||
|
||||
import { motion, useScroll, useTransform } from 'framer-motion'
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { type IconName } from '@/components/common/icon'
|
||||
import { Logo } from '@/components/common/logo'
|
||||
import { SocialLink } from '@/components/navigation/social-link'
|
||||
import { SearchButton } from '@/components/search/SearchButton'
|
||||
import { ThemeSwitch } from '../common/theme-switch'
|
||||
|
||||
const socialLinks = [
|
||||
{
|
||||
caption: 'Twitter/X',
|
||||
icon: 'twitter' as IconName,
|
||||
href: 'https://x.com/tldraw/',
|
||||
},
|
||||
{
|
||||
caption: 'Discord',
|
||||
icon: 'discord' as IconName,
|
||||
href: 'https://discord.tldraw.com/?utm_source=docs&utm_medium=organic&utm_campaign=sociallink',
|
||||
},
|
||||
{
|
||||
caption: 'GitHub',
|
||||
icon: 'github' as IconName,
|
||||
href: 'https://github.com/tldraw/tldraw',
|
||||
},
|
||||
]
|
||||
|
||||
export function Header() {
|
||||
const pathname = usePathname()
|
||||
const { scrollY } = useScroll()
|
||||
const navOpacity = useTransform(scrollY, [0, 32], [1, 0])
|
||||
const socialsOpacityEffect = pathname === '/search'
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 w-full bg-white dark:bg-zinc-950 z-10">
|
||||
<nav className="w-full max-w-screen-2xl mx-auto px-5 h-14 md:h-[4.5rem] flex justify-between items-center text-zinc-800 dark:text-zinc-200 border-b border-zinc-100 dark:border-zinc-800 dark:md:border-transparent md:border-transparent">
|
||||
<Link href="/" className="w-28">
|
||||
<Logo className="h-6" />
|
||||
</Link>
|
||||
<motion.ul
|
||||
style={{ opacity: socialsOpacityEffect ? navOpacity : 1 }}
|
||||
className="hidden sm:flex w-28 gap-4 justify-end xl:!opacity-100"
|
||||
>
|
||||
{socialLinks.map((item, index) => (
|
||||
<li key={index}>
|
||||
<SocialLink {...item} />
|
||||
</li>
|
||||
))}
|
||||
<ThemeSwitch />
|
||||
</motion.ul>
|
||||
<div className="flex items-center sm:hidden -mr-2">
|
||||
{!pathname?.startsWith('/search') && (
|
||||
<SearchButton type={pathname?.startsWith('/blog') ? 'blog' : 'docs'} layout="mobile" />
|
||||
)}
|
||||
<ThemeSwitch />
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
53
apps/docs/components/navigation/headings-menu.tsx
Normal file
53
apps/docs/components/navigation/headings-menu.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { ArticleHeading } from '@/types/content-types'
|
||||
import { cn } from '@/utils/cn'
|
||||
|
||||
export function HeadingsMenu({ headings }: { headings: ArticleHeading[] }) {
|
||||
const [activeSlug, setActiveSlug] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
let current = ''
|
||||
for (const { slug } of headings) {
|
||||
const element = document.getElementById(slug)
|
||||
if (element && element.getBoundingClientRect().top < 100) current = slug
|
||||
}
|
||||
setActiveSlug(current)
|
||||
}
|
||||
handleScroll()
|
||||
window.addEventListener('scroll', handleScroll, { passive: true })
|
||||
return () => window.removeEventListener('scroll', handleScroll)
|
||||
}, [headings])
|
||||
|
||||
if (headings.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="relative overflow-y-auto shrink mb-12">
|
||||
<div className="sticky top-0">
|
||||
<h4 className="block text-xs font-semibold text-black uppercase bg-white dark:bg-zinc-950 dark:text-white">
|
||||
On this page
|
||||
</h4>
|
||||
<div className="w-full h-2 bg-gradient-to-b from-white dark:from-zinc-950" />
|
||||
</div>
|
||||
<ul className="flex flex-col gap-2 text-sm break-words">
|
||||
{headings.map(({ title, level, slug }, index) => (
|
||||
<li key={index} style={{ paddingLeft: `${(level - 2) * 0.75}rem` }}>
|
||||
<Link
|
||||
href={`#${slug}`}
|
||||
className={cn(
|
||||
slug === activeSlug
|
||||
? 'text-black dark:text-white font-semibold'
|
||||
: 'hover:text-zinc-800 dark:hover:text-zinc-100'
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
47
apps/docs/components/navigation/link.tsx
Normal file
47
apps/docs/components/navigation/link.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { cn } from '@/utils/cn'
|
||||
|
||||
export function NavigationLink({
|
||||
caption,
|
||||
icon,
|
||||
href,
|
||||
active,
|
||||
}: {
|
||||
caption: string
|
||||
icon?: any
|
||||
href?: string
|
||||
active: boolean
|
||||
}) {
|
||||
const Icon = icon
|
||||
|
||||
if (href)
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className={cn(
|
||||
'flex items-center gap-3',
|
||||
active
|
||||
? 'font-semibold text-black dark:text-white'
|
||||
: 'hover:text-zinc-600 dark:hover:text-zinc-100'
|
||||
)}
|
||||
>
|
||||
{icon && (
|
||||
<div
|
||||
className={cn(
|
||||
'h-6 w-6 rounded-md flex items-center justify-center',
|
||||
active
|
||||
? 'bg-black text-white dark:bg-white dark:text-black'
|
||||
: 'bg-zinc-100 dark:bg-zinc-700 dark:text-zinc-300'
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4" />
|
||||
</div>
|
||||
)}
|
||||
<span>{caption}</span>
|
||||
</Link>
|
||||
)
|
||||
|
||||
return null
|
||||
}
|
||||
19
apps/docs/components/navigation/social-link.tsx
Normal file
19
apps/docs/components/navigation/social-link.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import Link from 'next/link'
|
||||
import { Icon, IconName } from '@/components/common/icon'
|
||||
|
||||
export function SocialLink({
|
||||
caption,
|
||||
icon,
|
||||
href,
|
||||
}: {
|
||||
caption: string
|
||||
icon: IconName
|
||||
href: string
|
||||
}) {
|
||||
return (
|
||||
<Link href={href} target="_blank" rel="noreferrer">
|
||||
<span className="sr-only">{caption}</span>
|
||||
<Icon icon={icon} className="h-4" />
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
129
apps/docs/components/search/ContentHighlight.tsx
Normal file
129
apps/docs/components/search/ContentHighlight.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import { Hit } from 'instantsearch.js'
|
||||
import { getHighlightedParts, getPropertyByPath, unescape } from 'instantsearch.js/es/lib/utils'
|
||||
import { memo } from 'react'
|
||||
import { SearchEntry } from '@/utils/algolia'
|
||||
|
||||
export const ContentHighlight = memo(function ContentHighlight({ hit }: { hit: Hit<SearchEntry> }) {
|
||||
const descriptionHighlight = getHighlights(hit, 'description', 40)
|
||||
const contentHighlight = getHighlights(hit, 'content', 40)
|
||||
|
||||
let firstItem = null
|
||||
let secondItem = null
|
||||
|
||||
if (descriptionHighlight.didMatch === contentHighlight.didMatch) {
|
||||
// if both either matched or didn't, show whatever has actual content:
|
||||
if (descriptionHighlight.parts.length && contentHighlight.parts.length) {
|
||||
firstItem = descriptionHighlight
|
||||
secondItem = contentHighlight
|
||||
} else if (descriptionHighlight.parts.length) {
|
||||
firstItem = descriptionHighlight
|
||||
} else if (contentHighlight.parts.length) {
|
||||
firstItem = contentHighlight
|
||||
}
|
||||
} else if (descriptionHighlight.didMatch && !contentHighlight.didMatch) {
|
||||
// if only the description matched, show both because the description is short:
|
||||
firstItem = descriptionHighlight
|
||||
secondItem = contentHighlight
|
||||
} else {
|
||||
// otherwise, only the content matched. show just the content:
|
||||
firstItem = contentHighlight
|
||||
}
|
||||
|
||||
if (firstItem && !secondItem) {
|
||||
// if we only have the first item, show it over two lines:
|
||||
return (
|
||||
<HighlightContent
|
||||
didTruncateStart={firstItem.didTruncateStart}
|
||||
parts={firstItem.parts}
|
||||
className="line-clamp-2 text-sm"
|
||||
/>
|
||||
)
|
||||
} else {
|
||||
// otherwise, show both items on one line each:
|
||||
return (
|
||||
<>
|
||||
{firstItem && (
|
||||
<HighlightContent
|
||||
didTruncateStart={firstItem.didTruncateStart}
|
||||
parts={firstItem.parts}
|
||||
className="line-clamp-1 text-sm"
|
||||
/>
|
||||
)}
|
||||
{secondItem && (
|
||||
<HighlightContent
|
||||
didTruncateStart={secondItem.didTruncateStart}
|
||||
parts={secondItem.parts}
|
||||
className="line-clamp-1 text-sm"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
function HighlightContent({
|
||||
parts,
|
||||
didTruncateStart,
|
||||
className,
|
||||
}: {
|
||||
parts: { value: string; isHighlighted: boolean }[]
|
||||
didTruncateStart: boolean
|
||||
className: string
|
||||
}) {
|
||||
return (
|
||||
<div className={className}>
|
||||
{didTruncateStart && '…'}
|
||||
{parts.map((part, index) =>
|
||||
part.isHighlighted ? <mark key={index}>{part.value}</mark> : part.value
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function getHighlights(
|
||||
hit: Hit<SearchEntry>,
|
||||
attribute: 'description' | 'content',
|
||||
maxUnhighlightedPrefix: number
|
||||
): {
|
||||
didTruncateStart: boolean
|
||||
didMatch: boolean
|
||||
parts: { value: string; isHighlighted: boolean }[]
|
||||
} {
|
||||
const originalProperty = hit[attribute]?.trim()
|
||||
const highlightedProperty = getPropertyByPath(hit._highlightResult, attribute as string)
|
||||
if (!highlightedProperty) {
|
||||
return {
|
||||
didMatch: false,
|
||||
didTruncateStart: false,
|
||||
parts: originalProperty ? [{ value: originalProperty, isHighlighted: false }] : [],
|
||||
}
|
||||
}
|
||||
|
||||
const parts = getHighlightedParts(unescape(highlightedProperty.value || ''))
|
||||
|
||||
let didTruncateStart = false
|
||||
const didMatch = parts.some((part) => part.isHighlighted)
|
||||
|
||||
const initialPart = parts[0]
|
||||
// if the first part is unhighlighted and too long, we should cut it short
|
||||
if (initialPart && !initialPart.isHighlighted && didMatch) {
|
||||
const words = initialPart.value.split(' ').reverse()
|
||||
|
||||
let lengthSoFar = 0
|
||||
const wordsToInclude: string[] = []
|
||||
for (const word of words) {
|
||||
lengthSoFar += word.length
|
||||
if (lengthSoFar > maxUnhighlightedPrefix) {
|
||||
break
|
||||
}
|
||||
wordsToInclude.push(word)
|
||||
}
|
||||
|
||||
if (wordsToInclude.length < words.length) {
|
||||
didTruncateStart = true
|
||||
parts[0].value = `${wordsToInclude.reverse().join(' ')}`
|
||||
}
|
||||
}
|
||||
|
||||
return { didTruncateStart, didMatch, parts }
|
||||
}
|
||||
193
apps/docs/components/search/FullPageSearch.tsx
Normal file
193
apps/docs/components/search/FullPageSearch.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
'use client'
|
||||
import { Combobox, ComboboxItem, ComboboxProvider } from '@ariakit/react'
|
||||
import { MagnifyingGlassIcon } from '@heroicons/react/20/solid'
|
||||
import { debounce } from '@tldraw/utils'
|
||||
import { Hit } from 'instantsearch.js'
|
||||
import { SendEventForHits } from 'instantsearch.js/es/lib/utils'
|
||||
import Link from 'next/link'
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
||||
import { createRef, Fragment, useCallback, useEffect, useState } from 'react'
|
||||
import { Configure, Highlight, InstantSearch, useHits, useSearchBox } from 'react-instantsearch'
|
||||
import { twJoin } from 'tailwind-merge'
|
||||
import { getSearchIndexName, SearchEntry } from '@/utils/algolia'
|
||||
import { searchClient } from '@/utils/search-api'
|
||||
import { ContentHighlight } from './ContentHighlight'
|
||||
|
||||
export function FullPageSearch({ indexName }: { indexName: 'docs' | 'blog' }) {
|
||||
return (
|
||||
<InstantSearch
|
||||
indexName={getSearchIndexName(indexName)}
|
||||
searchClient={searchClient}
|
||||
insights={true}
|
||||
future={{ preserveSharedStateOnUnmount: true }}
|
||||
>
|
||||
<Configure distinct={4} hitsPerPage={40} />
|
||||
<InstantSearchInner indexName={indexName} />
|
||||
</InstantSearch>
|
||||
)
|
||||
}
|
||||
|
||||
function InstantSearchInner({ indexName }: { indexName: 'docs' | 'blog' }) {
|
||||
const { items, sendEvent } = useHits<SearchEntry>()
|
||||
const { refine } = useSearchBox()
|
||||
const router = useRouter()
|
||||
const [open, setOpen] = useState(true)
|
||||
|
||||
const searchParams = useSearchParams()
|
||||
const urlQuery = searchParams.get('query') || ''
|
||||
|
||||
const handleChange = (path: string) => {
|
||||
setOpen(false)
|
||||
router.push(path)
|
||||
}
|
||||
|
||||
const updateSearchQuery = debounce((value: string) => refine(value), 500)
|
||||
|
||||
useEffect(() => {
|
||||
refine(urlQuery)
|
||||
}, [urlQuery, refine])
|
||||
|
||||
return (
|
||||
open && (
|
||||
<SearchAutocomplete
|
||||
items={items}
|
||||
onInputChange={updateSearchQuery}
|
||||
onChange={handleChange}
|
||||
sendEvent={sendEvent}
|
||||
defaultValue={urlQuery}
|
||||
indexName={indexName}
|
||||
/>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
interface AutocompleteProps {
|
||||
items: Hit<SearchEntry>[]
|
||||
onChange(value: string): void
|
||||
onInputChange(value: string): void
|
||||
defaultValue: string
|
||||
sendEvent: SendEventForHits
|
||||
indexName: 'docs' | 'blog'
|
||||
}
|
||||
|
||||
function SearchAutocomplete({
|
||||
items,
|
||||
defaultValue,
|
||||
onInputChange,
|
||||
onChange,
|
||||
sendEvent,
|
||||
indexName,
|
||||
}: AutocompleteProps) {
|
||||
return (
|
||||
<ComboboxProvider<string>
|
||||
defaultSelectedValue=""
|
||||
defaultValue={defaultValue}
|
||||
includesBaseElement={true}
|
||||
setValue={(newValue) => onInputChange(newValue)}
|
||||
setSelectedValue={(newValue) => onChange(newValue)}
|
||||
>
|
||||
<div className="w-full mb-12 h-full">
|
||||
<div className="pointer-events-auto min-h-full">
|
||||
<SearchInput indexName={indexName} />
|
||||
<Results items={items} sendEvent={sendEvent} />
|
||||
</div>
|
||||
</div>
|
||||
</ComboboxProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function SearchInput({ indexName }: { indexName: 'docs' | 'blog' }) {
|
||||
const comboboxRef = createRef<HTMLInputElement>()
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const capitalizedIndexName = indexName.charAt(0).toUpperCase() + indexName.slice(1)
|
||||
|
||||
const updateUrl = useCallback(() => {
|
||||
const queryParam = comboboxRef.current?.value || ''
|
||||
router.push(`${pathname}?query=${encodeURIComponent(queryParam.trim())}`)
|
||||
}, [comboboxRef, router, pathname])
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key !== 'Enter') return
|
||||
updateUrl()
|
||||
},
|
||||
[updateUrl]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
|
||||
comboboxRef.current?.focus()
|
||||
comboboxRef.current?.setSelectionRange(0, comboboxRef.current.value.length)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
return () => document.removeEventListener('keydown', onKeyDown)
|
||||
}, [comboboxRef])
|
||||
|
||||
return (
|
||||
<div className="md:sticky bg-white dark:bg-zinc-950 w-full md:top-4 flex items-center z-20 md:pb-4">
|
||||
<div className="flex h-10 grow items-center gap-3 bg-zinc-50 dark:bg-zinc-900 rounded-lg px-4 focus-within:ring-blue-500 focus-within:ring-2 min-w-full">
|
||||
<MagnifyingGlassIcon className="h-4 shrink-0 text-black dark:text-white" />
|
||||
<Combobox
|
||||
ref={comboboxRef}
|
||||
className="h-full w-full mr-4 focus:outline-none text-black dark:text-white bg-transparent"
|
||||
placeholder={`Search ${capitalizedIndexName}...`}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Results({ items, sendEvent }: { items: Hit<SearchEntry>[]; sendEvent: SendEventForHits }) {
|
||||
let section = ''
|
||||
const renderedItems = items.map((hit) => {
|
||||
const showChapter = hit.section !== section
|
||||
section = hit.section
|
||||
|
||||
const href = hit.headingSlug ? `${hit.path}#${hit.headingSlug}` : hit.path
|
||||
|
||||
return (
|
||||
<Fragment key={hit.objectID}>
|
||||
{showChapter && (
|
||||
<div className="text-black dark:text-white font-semibold mt-6 mb-4">{section}</div>
|
||||
)}
|
||||
|
||||
<ComboboxItem
|
||||
className={twJoin(
|
||||
'px-4 pt-2.5 pb-3 bg-zinc-100 dark:bg-zinc-800 mt-2',
|
||||
'rounded-md cursor-pointer data-[active-item=true]:bg-blue-500',
|
||||
'dark:data-[active-item=true]:bg-blue-500 data-[active-item=true]:text-blue-200',
|
||||
'[&_mark]:bg-transparent [&_mark]:font-bold',
|
||||
'[&_mark]:text-black [&_mark]:data-[active-item=true]:text-white',
|
||||
'dark:[&_mark]:text-white',
|
||||
'[&_.ais-Highlight-nonHighlighted]:data-[active-item=true]:text-white',
|
||||
'hover:text-zinc-800 dark:hover:text-zinc-200'
|
||||
)}
|
||||
value={href}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
sendEvent('click', hit, 'Hit clicked via keyboard')
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Link href={href} onClick={() => sendEvent('click', hit, 'Hit clicked')}>
|
||||
<Highlight attribute="title" hit={hit} />
|
||||
<ContentHighlight hit={hit} />
|
||||
</Link>
|
||||
</ComboboxItem>
|
||||
</Fragment>
|
||||
)
|
||||
})
|
||||
|
||||
return items.length === 0 ? (
|
||||
<div className="sticky top-[4.5rem] text-center py-8 text-zinc-400 dark:text-zinc-600">
|
||||
No results found.
|
||||
</div>
|
||||
) : (
|
||||
renderedItems
|
||||
)
|
||||
}
|
||||
107
apps/docs/components/search/SearchButton.tsx
Normal file
107
apps/docs/components/search/SearchButton.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
'use client'
|
||||
|
||||
import { MagnifyingGlassIcon as MagnifyingGlassIconSmall } from '@heroicons/react/20/solid'
|
||||
import {
|
||||
MagnifyingGlassIcon as MagnifyingGlassIconLarge,
|
||||
XMarkIcon,
|
||||
} from '@heroicons/react/24/solid'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { twJoin } from 'tailwind-merge'
|
||||
import { cn } from '@/utils/cn'
|
||||
import { Search } from '.'
|
||||
|
||||
function isEditableElement(target: EventTarget | null) {
|
||||
if (!(target instanceof HTMLElement)) return false
|
||||
return (
|
||||
target.isContentEditable ||
|
||||
target.tagName === 'INPUT' ||
|
||||
target.tagName === 'TEXTAREA' ||
|
||||
target.tagName === 'SELECT'
|
||||
)
|
||||
}
|
||||
|
||||
export function SearchButton({
|
||||
type,
|
||||
layout,
|
||||
className,
|
||||
}: {
|
||||
type: 'docs' | 'blog'
|
||||
layout: 'mobile' | 'desktop' | 'keyboard-shortcut-only'
|
||||
className?: string
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const pathname = usePathname()
|
||||
|
||||
useEffect(() => {
|
||||
if (layout !== 'desktop' && layout !== 'keyboard-shortcut-only') return
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === '/') {
|
||||
// A bare "/" is only a shortcut outside text fields, otherwise typing a path into
|
||||
// the feedback textarea opens the dialog and steals focus mid-word.
|
||||
if (isEditableElement(e.target)) return
|
||||
e.preventDefault()
|
||||
setOpen(true)
|
||||
} else if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
|
||||
setOpen((open) => !open)
|
||||
} else if (e.key === 'Escape') {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
return () => document.removeEventListener('keydown', onKeyDown)
|
||||
}, [layout])
|
||||
|
||||
useEffect(() => {
|
||||
setOpen(false)
|
||||
}, [pathname])
|
||||
|
||||
if (layout === 'keyboard-shortcut-only') {
|
||||
return open ? <Search type={type} onClose={() => setOpen(false)} /> : null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('group', className)}>
|
||||
<button
|
||||
className={cn(
|
||||
'flex items-center text-black dark:text-white focus:outline-none',
|
||||
layout === 'desktop' &&
|
||||
twJoin(
|
||||
'w-full h-10 justify-between bg-zinc-50 dark:bg-zinc-900 px-4 cursor-text',
|
||||
'hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-lg focus:ring-2',
|
||||
'focus:ring-blue-500'
|
||||
),
|
||||
layout === 'mobile' &&
|
||||
'w-8 h-8 justify-center rounded focus:bg-zinc-100 dark:focus:bg-zinc-800'
|
||||
)}
|
||||
onClick={() => setOpen((open) => !open)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{open ? (
|
||||
<XMarkIcon className="h-6 md:hidden" />
|
||||
) : (
|
||||
<>
|
||||
<MagnifyingGlassIconSmall className={cn('h-4', layout === 'mobile' && 'hidden')} />
|
||||
<MagnifyingGlassIconLarge className={cn('h-6', layout === 'desktop' && 'hidden')} />
|
||||
</>
|
||||
)}
|
||||
{!open && (
|
||||
<span
|
||||
className={cn(
|
||||
'capitalize text-sm text-zinc-400 dark:text-zinc-600',
|
||||
layout === 'mobile' && 'hidden'
|
||||
)}
|
||||
>
|
||||
Search {type}…
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className={cn('text-xs', layout === 'mobile' && 'hidden')}>⌘K</span>
|
||||
</button>
|
||||
|
||||
{open && <Search type={type} onClose={() => setOpen(false)} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
192
apps/docs/components/search/SearchDialog.tsx
Normal file
192
apps/docs/components/search/SearchDialog.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import { Combobox, ComboboxItem, ComboboxProvider, VisuallyHidden } from '@ariakit/react'
|
||||
import { MagnifyingGlassIcon } from '@heroicons/react/20/solid'
|
||||
import { Hit } from 'instantsearch.js'
|
||||
import { SendEventForHits } from 'instantsearch.js/es/lib/utils'
|
||||
import Link from 'next/link'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import { Dialog as _Dialog } from 'radix-ui'
|
||||
import { Fragment, startTransition, useState } from 'react'
|
||||
import { Highlight } from 'react-instantsearch'
|
||||
import { twJoin } from 'tailwind-merge'
|
||||
import { SearchEntry } from '@/utils/algolia'
|
||||
import { ContentHighlight } from './ContentHighlight'
|
||||
|
||||
interface AutocompleteProps {
|
||||
items: Hit<SearchEntry>[]
|
||||
onChange(value: string): void
|
||||
onInputChange(value: string): void
|
||||
onClose(): void
|
||||
sendEvent: SendEventForHits
|
||||
}
|
||||
|
||||
export default function SearchDialog({
|
||||
items,
|
||||
onInputChange,
|
||||
onChange,
|
||||
onClose,
|
||||
sendEvent,
|
||||
}: AutocompleteProps) {
|
||||
const [open, setOpen] = useState(true)
|
||||
const [value, setValue] = useState('')
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
if (!open) {
|
||||
onClose()
|
||||
}
|
||||
setOpen(open)
|
||||
}
|
||||
|
||||
return (
|
||||
<ComboboxProvider<string>
|
||||
defaultSelectedValue=""
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
resetValueOnHide
|
||||
includesBaseElement={false}
|
||||
setValue={(newValue) => {
|
||||
startTransition(() => {
|
||||
setValue(newValue)
|
||||
onInputChange(newValue)
|
||||
})
|
||||
}}
|
||||
setSelectedValue={(newValue) => onChange(newValue)}
|
||||
>
|
||||
<div
|
||||
className={twJoin(
|
||||
'fixed w-screen h-screen left-0 top-14 sm:top-[6.5rem] md:top-0 z-40',
|
||||
'bg-white/90 dark:bg-zinc-950/90 pointer-events-none'
|
||||
)}
|
||||
>
|
||||
<SearchDialogWrapper onOpenChange={handleOpenChange}>
|
||||
<div className="w-full max-w-3xl mx-auto px-5 lg:px-12 pt-[4.5rem]">
|
||||
<div
|
||||
className={twJoin(
|
||||
'pointer-events-auto bg-zinc-50 dark:bg-zinc-900 rounded-lg',
|
||||
'overflow-hidden focus-within:ring-2 focus-within:ring-blue-500'
|
||||
)}
|
||||
>
|
||||
<SearchInput value={value} />
|
||||
<Results items={items} sendEvent={sendEvent} />
|
||||
</div>
|
||||
</div>
|
||||
</SearchDialogWrapper>
|
||||
</div>
|
||||
</ComboboxProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function SearchInput({ value }: { value: string }) {
|
||||
const router = useRouter()
|
||||
const pathName = usePathname()
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key !== 'Enter') return
|
||||
const searchPath = pathName.startsWith('/blog') ? '/search/blog' : '/search'
|
||||
router.push(`${searchPath}?query=${encodeURIComponent(value.trim())}`)
|
||||
}
|
||||
return (
|
||||
<div className="w-full h-10 flex items-center px-4">
|
||||
<div className="flex h-full grow items-center gap-3">
|
||||
<MagnifyingGlassIcon className="h-4 shrink-0" />
|
||||
<Combobox
|
||||
className="h-full w-full mr-4 focus:outline-none text-black dark:text-white bg-transparent"
|
||||
autoFocus
|
||||
placeholder="Search..."
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Results({ items, sendEvent }: { items: Hit<SearchEntry>[]; sendEvent: SendEventForHits }) {
|
||||
let section = ''
|
||||
const renderedItems = items.map((hit) => {
|
||||
const showChapter = hit.section !== section
|
||||
section = hit.section
|
||||
|
||||
const href = hit.headingSlug ? `${hit.path}#${hit.headingSlug}` : hit.path
|
||||
|
||||
return (
|
||||
<Fragment key={hit.objectID}>
|
||||
{showChapter && (
|
||||
<div className="text-black dark:text-white font-semibold mt-6 mb-4">{section}</div>
|
||||
)}
|
||||
|
||||
<ComboboxItem
|
||||
className={twJoin(
|
||||
'px-4 pt-2.5 pb-3 bg-zinc-100 dark:bg-zinc-800 mt-2',
|
||||
'rounded-md cursor-pointer data-[active-item=true]:bg-blue-500',
|
||||
'dark:data-[active-item=true]:bg-blue-500 data-[active-item=true]:text-blue-200',
|
||||
'[&_mark]:bg-transparent [&_mark]:font-bold',
|
||||
'[&_mark]:text-black [&_mark]:data-[active-item=true]:text-white',
|
||||
'dark:[&_mark]:text-white',
|
||||
'[&_.ais-Highlight-nonHighlighted]:data-[active-item=true]:text-white',
|
||||
'hover:text-zinc-800 dark:hover:text-zinc-200'
|
||||
)}
|
||||
value={href}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
sendEvent('click', hit, 'Hit clicked via keyboard')
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Link href={href} onClick={() => sendEvent('click', hit, 'Hit clicked')}>
|
||||
<Highlight attribute="title" hit={hit} />
|
||||
<ContentHighlight hit={hit} />
|
||||
</Link>
|
||||
</ComboboxItem>
|
||||
</Fragment>
|
||||
)
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
className={twJoin(
|
||||
'max-h-[32rem] overflow-y-auto p-4 pt-0',
|
||||
'border-t border-zinc-100 dark:border-zinc-800'
|
||||
)}
|
||||
>
|
||||
{items.length === 0 && (
|
||||
<div className="text-center py-8 text-zinc-400 dark:text-zinc-600">
|
||||
<p>No results found.</p>
|
||||
<p className="mt-2">
|
||||
Will you tell us what you{"'"}re looking for on our{' '}
|
||||
<a
|
||||
className="text-blue-500 hover:text-blue-600"
|
||||
target="_blank"
|
||||
href="https://discord.tldraw.com/?utm_source=blog&utm_medium=search&utm_campaign=no-results-found"
|
||||
rel="noreferrer"
|
||||
>
|
||||
discord
|
||||
</a>
|
||||
?
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{items.length !== 0 && renderedItems}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SearchDialogWrapper({
|
||||
children,
|
||||
onOpenChange,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
onOpenChange(open: boolean): void
|
||||
}) {
|
||||
return (
|
||||
<_Dialog.Root open onOpenChange={onOpenChange}>
|
||||
<_Dialog.Portal>
|
||||
<_Dialog.Overlay />
|
||||
<_Dialog.Content className="fixed inset-0 z-50" style={{ pointerEvents: 'none' }}>
|
||||
<VisuallyHidden>
|
||||
<_Dialog.Title>Search</_Dialog.Title>
|
||||
<_Dialog.Description>Search dialog</_Dialog.Description>
|
||||
</VisuallyHidden>
|
||||
{children}
|
||||
</_Dialog.Content>
|
||||
</_Dialog.Portal>
|
||||
</_Dialog.Root>
|
||||
)
|
||||
}
|
||||
43
apps/docs/components/search/index.tsx
Normal file
43
apps/docs/components/search/index.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
'use client'
|
||||
|
||||
import { debounce } from '@tldraw/utils'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { InstantSearch, useHits, useSearchBox } from 'react-instantsearch'
|
||||
import { SearchEntry, SearchIndexName, getSearchIndexName } from '@/utils/algolia'
|
||||
import { searchClient } from '@/utils/search-api'
|
||||
import SearchDialog from './SearchDialog'
|
||||
|
||||
export function Search({ type, onClose }: { type: SearchIndexName; onClose(): void }) {
|
||||
return (
|
||||
<InstantSearch
|
||||
indexName={getSearchIndexName(type)}
|
||||
searchClient={searchClient}
|
||||
insights={true}
|
||||
future={{ preserveSharedStateOnUnmount: true }}
|
||||
>
|
||||
<InstantSearchInner onClose={onClose} />
|
||||
</InstantSearch>
|
||||
)
|
||||
}
|
||||
|
||||
function InstantSearchInner({ onClose }: { onClose(): void }) {
|
||||
const { items, sendEvent } = useHits<SearchEntry>()
|
||||
const { refine } = useSearchBox()
|
||||
const router = useRouter()
|
||||
|
||||
const handleChange = (path: string) => {
|
||||
router.push(path)
|
||||
onClose()
|
||||
}
|
||||
const handleInputChange = debounce((query: string) => refine(query), 500)
|
||||
|
||||
return (
|
||||
<SearchDialog
|
||||
items={items}
|
||||
onInputChange={handleInputChange}
|
||||
onChange={handleChange}
|
||||
onClose={onClose}
|
||||
sendEvent={sendEvent}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user