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:
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user