add descriptions to examples (#2375)

Adds descriptions to examples.

![Kapture 2023-12-22 at 17 08
32](https://github.com/tldraw/tldraw/assets/1489520/d78657cf-b3c3-4160-b58b-7c08ed27823d)

They show as a list on the index page, and on individual examples they
show in a three-js style sidebar. For now, this is disabled completely
on mobile. Examples can still be opened in 'standalone' mode to get rid
of the sidebar.

Note: the 'view code' link won't work until after these changes are
merged.

There's a small impact on authoring examples: each one needs to live in
a folder with a README.md. At a minimum, the readme needs to look like
this:
```md
---
title: My Example
component: ./MyExample.tsx
---

Here is a 1-liner about my example
```

Optionally, you can:
- Add `hide: true` to the frontmatter to remove the example from the
list (you can skip the description this way)
- Add `order: 3` to control the order in which the example appears.
They're alphabetical otherwise
- Add some more description or links to docs below a `---`. This won't
show in the listing, but will be visible on GitHub and on the example
page itself.

As a follow-up, I'd like to add an 'Open in CodeSandbox' link to each
example. These won't work until we've made a release with these examples
(as our special examples codesandbox is tied to our release process) but
the code is there & ready to go!

Have a play, let me know what you think!

### Change Type

- [x] `documentation` — Changes to the documentation only[^2]

---------

Co-authored-by: Steve Ruiz <steveruizok@gmail.com>
This commit is contained in:
alex 2023-12-27 17:17:18 +00:00 committed by GitHub
parent ed37bcf541
commit b373abf605
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
102 changed files with 1772 additions and 355 deletions

View file

@ -1,7 +1,7 @@
<div alt style="text-align: center; transform: scale(.5);">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/tldraw/tldraw/main/assets/github-hero-dark-2.png" />
<img alt="tldraw" src="https://raw.githubusercontent.com/tldraw/tldraw/main/assets/github-hero-light-2.png" />
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/tldraw/tldraw/main/assets/github-hero-dark-2.png"/>
<img alt="tldraw" src="https://raw.githubusercontent.com/tldraw/tldraw/main/assets/github-hero-light-2.png"/>
</picture>
</div>

View file

@ -39,6 +39,7 @@
"@tldraw/assets": "workspace:*",
"@tldraw/tldraw": "workspace:*",
"@vercel/analytics": "^1.0.1",
"classnames": "^2.3.2",
"lazyrepo": "0.0.0-alpha.27",
"react": "^18.2.0",
"react-dom": "^18.2.0",
@ -47,6 +48,10 @@
},
"devDependencies": {
"@vitejs/plugin-react": "^4.2.0",
"dotenv": "^16.0.3"
"dotenv": "^16.0.3",
"remark": "^15.0.1",
"remark-frontmatter": "^5.0.0",
"remark-html": "^16.0.1",
"vfile-matter": "^5.0.0"
}
}

View file

@ -0,0 +1,79 @@
import { assert, assertExists } from '@tldraw/tldraw'
import { useEffect, useRef } from 'react'
import { Link } from 'react-router-dom'
import ExamplesTldrawLogo from './components/ExamplesTldrawLogo'
import { ListLink } from './components/ListLink'
import { Example, examples } from './examples'
export function ExamplePage({
example,
children,
}: {
example: Example
children: React.ReactNode
}) {
const scrollElRef = useRef<HTMLUListElement>(null)
const activeElRef = useRef<HTMLLIElement>(null)
const isFirstScroll = useRef(true)
useEffect(() => {
const frame = requestAnimationFrame(() => {
if (activeElRef.current) {
const scrollEl = assertExists(scrollElRef.current)
const activeEl = activeElRef.current
assert(activeEl.offsetParent === scrollEl)
const isScrolledIntoView =
activeEl.offsetTop >= scrollEl.scrollTop &&
activeEl.offsetTop + activeEl.offsetHeight <= scrollEl.scrollTop + scrollEl.offsetHeight
if (!isScrolledIntoView) {
activeEl.scrollIntoView({
behavior: isFirstScroll.current ? 'auto' : 'smooth',
block: isFirstScroll.current ? 'start' : 'center',
})
}
isFirstScroll.current = false
}
})
return () => cancelAnimationFrame(frame)
}, [example])
return (
<div className="example">
<div className="example__info">
<Link className="example__logo" to="/">
<ExamplesTldrawLogo /> examples
</Link>
<ul className="example__info__list scroll-light" ref={scrollElRef}>
{examples
.filter((e) => !e.hide)
.filter((e) => e.order !== null)
.map((e) => (
<ListLink
key={e.path}
ref={e.path === example.path ? activeElRef : undefined}
example={e}
isActive={e.path === example.path}
/>
))}
<li>
<hr />
</li>
{examples
.filter((e) => !e.hide)
.filter((e) => e.order === null)
.map((e) => (
<ListLink
key={e.path}
ref={e.path === example.path ? activeElRef : undefined}
example={e}
isActive={e.path === example.path}
/>
))}
</ul>
</div>
<div className="example__content">{children}</div>
</div>
)
}

View file

@ -0,0 +1,35 @@
import ExamplesTldrawLogo from './components/ExamplesTldrawLogo'
import { ListLink } from './components/ListLink'
import { examples } from './examples'
export function HomePage() {
return (
<div className="examples">
<div className="examples__header">
<div className="examples__title">
<ExamplesTldrawLogo /> examples
</div>
<p>
See docs at <a href="https://tldraw.dev">tldraw.dev</a>
</p>
</div>
<ul className="examples__list">
{examples
.filter((example) => !example.hide)
.filter((example) => example.order !== null)
.map((example) => (
<ListLink key={example.path} example={example} showDescription />
))}
</ul>
<hr />
<ul className="examples__list">
{examples
.filter((example) => !example.hide)
.filter((example) => example.order === null)
.map((example) => (
<ListLink key={example.path} example={example} showDescription />
))}
</ul>
</div>
)
}

View file

@ -0,0 +1,13 @@
export function StandaloneIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg width="16" height="16" viewBox="0 0 30 30" fill="none" {...props}>
<path
d="M13 5H7C5.89543 5 5 5.89543 5 7V23C5 24.1046 5.89543 25 7 25H23C24.1046 25 25 24.1046 25 23V17M19 5H25M25 5V11M25 5L13 17"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}

View file

@ -1,14 +1,94 @@
import classNames from 'classnames'
import { ForwardedRef, forwardRef, useEffect, useId, useLayoutEffect, useRef } from 'react'
import { Link } from 'react-router-dom'
import { Example } from '../examples'
import { useMergedRefs } from '../hooks/useMegedRefs'
import { StandaloneIcon } from './Icons'
import { Markdown } from './Markdown'
export interface ListLinkProps {
title: string
route: string
}
export const ListLink = forwardRef(function ListLink(
{
example,
isActive,
showDescription,
}: { example: Example; isActive?: boolean; showDescription?: boolean },
ref: ForwardedRef<HTMLLIElement>
) {
const id = useId()
const containerRef = useRef<HTMLLIElement | null>(null)
const wasActiveRef = useRef(isActive)
useEffect(() => {
wasActiveRef.current = isActive
}, [isActive])
const heightBefore =
wasActiveRef.current !== isActive ? containerRef.current?.offsetHeight : undefined
useLayoutEffect(() => {
if (heightBefore !== undefined && containerRef.current) {
containerRef.current.animate(
[{ height: heightBefore + 'px' }, { height: containerRef.current.offsetHeight + 'px' }],
{
duration: 120,
easing: 'ease-out',
fill: 'backwards',
delay: 100,
}
)
}
}, [heightBefore])
const mainDetails = (
<>
<h3 id={id}>
{example.title}
{isActive && (
<Link
to={`${example.path}/full`}
aria-label="Standalone"
className="examples__list__item__standalone"
title="View standalone example"
>
<StandaloneIcon />
</Link>
)}
</h3>
{showDescription && <Markdown sanitizedHtml={example.description} />}
</>
)
// TODO: re-enable code sandbox links
// const codeSandboxPath = encodeURIComponent(
// `/src/examples${example.path}${example.componentFile.replace(/^\./, '')}`
// )
const extraDetails = (
<div className="examples__list__item__details" aria-hidden={!isActive}>
<Markdown sanitizedHtml={example.details} />
<div className="examples__list__item__code">
<a href={example.codeUrl} target="_blank" rel="noreferrer">
View code
</a>
{/* <a
href={`https://codesandbox.io/p/devbox/github/tldraw/tldraw/tree/examples/?file=${codeSandboxPath}`}
target="_blank"
rel="noreferrer"
>
Edit in CodeSandbox
</a> */}
</div>
</div>
)
export function ListLink({ title, route }: ListLinkProps) {
return (
<li className="examples__list__item">
<Link to={route}>{title}</Link>
<li
ref={useMergedRefs(ref, containerRef)}
className={classNames('examples__list__item', isActive && 'examples__list__item__active')}
>
{!isActive && (
<Link to={example.path} aria-labelledby={id} className="examples__list__item__link" />
)}
{mainDetails}
{extraDetails}
</li>
)
}
})

View file

@ -0,0 +1,16 @@
import classNames from 'classnames'
export function Markdown({
sanitizedHtml,
className = '',
}: {
sanitizedHtml: string
className?: string
}) {
return (
<div
className={classNames('examples__markdown', className)}
dangerouslySetInnerHTML={{ __html: sanitizedHtml }}
/>
)
}

View file

@ -0,0 +1,19 @@
export function Spinner(props: React.SVGProps<SVGSVGElement>) {
return (
<svg width={16} height={16} viewBox="0 0 16 16" {...props}>
<g strokeWidth={2} fill="none" fillRule="evenodd">
<circle strokeOpacity={0.25} cx={8} cy={8} r={7} stroke="currentColor" />
<path strokeLinecap="round" d="M15 8c0-4.5-4.5-7-7-7" stroke="currentColor">
<animateTransform
attributeName="transform"
type="rotate"
from="0 8 8"
to="360 8 8"
dur="1s"
repeatCount="indefinite"
/>
</path>
</g>
</svg>
)
}

View file

@ -0,0 +1,28 @@
import { ComponentType } from 'react'
export type Example = {
title: string
description: string
details: string
path: string
codeUrl: string
hide: boolean
order: number | null
componentFile: string
loadComponent: () => Promise<ComponentType>
}
export const examples = (
Object.values(import.meta.glob('./examples/*/README.md', { eager: true })) as Example[]
).sort((a, b) => {
// sort by order then title:
if (a.order === b.order) {
return a.title.localeCompare(b.title)
} else if (a.order === null) {
return 1
} else if (b.order === null) {
return -1
} else {
return a.order - b.order
}
})

View file

@ -0,0 +1,11 @@
---
title: Editor API
component: ./APIExample.tsx
order: 2
---
Manipulate the contents of the canvas using the editor API.
---
This example creates and updates shapes, selects and rotates them, and zooms the camera.

View file

@ -0,0 +1,10 @@
---
title: Asset props
component: ./AssetPropsExample.tsx
---
Control the assets (images, videos, etc.) that can be added to the canvas.
---
This example demonstrates the `<Tldraw/>` component's props that give you control over assets: which types are allowed, the maximum size, and maximum dimensions.

View file

@ -11,34 +11,32 @@ export default function CanvasEventsExample() {
return (
<div style={{ display: 'flex' }}>
<div style={{ width: '50vw', height: '100vh' }}>
<div style={{ width: '50%', height: '100vh' }}>
<Tldraw
onMount={(editor) => {
editor.on('event', (event) => handleEvent(event))
}}
/>
</div>
<div>
<div
style={{
width: '50vw',
height: '100vh',
padding: 8,
background: '#eee',
border: 'none',
fontFamily: 'monospace',
fontSize: 12,
borderLeft: 'solid 2px #333',
display: 'flex',
flexDirection: 'column-reverse',
overflow: 'auto',
whiteSpace: 'pre-wrap',
}}
>
{events.map((t, i) => (
<div key={i}>{t}</div>
))}
</div>
<div
style={{
width: '50%',
height: '100vh',
padding: 8,
background: '#eee',
border: 'none',
fontFamily: 'monospace',
fontSize: 12,
borderLeft: 'solid 2px #333',
display: 'flex',
flexDirection: 'column-reverse',
overflow: 'auto',
whiteSpace: 'pre-wrap',
}}
>
{events.map((t, i) => (
<div key={i}>{t}</div>
))}
</div>
</div>
)

View file

@ -0,0 +1,10 @@
---
title: Canvas events
component: ./CanvasEventsExample.tsx
---
Listen to events from tldraw's canvas.
---
These are the input events that the editor interprets. Try moving your cursor, dragging, using modifier keys, etc.

View file

@ -0,0 +1,12 @@
---
title: Canvas components
component: ./CustomComponentsExample.tsx
---
Replace tldraw's on-canvas UI with your own.
---
Tldraw's on-canvas UI is build from replaceable React components.
Try dragging to select or using the eraser tool to see the custom components in this example.

View file

@ -0,0 +1,11 @@
---
title: Custom shapes / tools
component: ./CustomConfigExample.tsx
order: 3
---
Create custom shapes / tools
---
The card shape (select ⚫️ in the toolbar) is a custom shape - but also just a normal react component.

View file

@ -0,0 +1,10 @@
---
title: Custom styles
component: ./CustomStylesExample.tsx
---
Styles are special properties that can be set on many shapes at once.
---
Create several shapes with the ⚫️ tool, then select them and try changing their filter style.

View file

@ -0,0 +1,10 @@
---
title: Custom UI
component: ./CustomUiExample.tsx
---
Replace tldraw's UI with your own.
---
This UI has keyboard shortcuts and buttons for selecting tools.

View file

@ -0,0 +1,11 @@
---
title: Basic (development)
component: ./BasicExample.tsx
order: 1
---
The easiest way to get started with tldraw.
---
The simplest use of the `<Tldraw/>` component.

View file

@ -0,0 +1,10 @@
---
title: Error boundary
component: ./ErrorBoundaryExample.tsx
---
Catch errors in shapes.
---
When something goes wrong in a shape, it won't crash the whole editor - just the shape that went wrong.

View file

@ -0,0 +1,10 @@
---
title: Sublibraries
component: ./ExplodedExample.tsx
---
Tldraw is built from several sublibraries - like the editor, default shapes & tools, and UI.
---
For full customization, you can use these sublibraries directly, or replace them with your own.

View file

@ -0,0 +1,10 @@
---
title: External content sources
component: ./ExternalContentSourcesExample.tsx
---
Control what happens when the user pastes content into the editor.
---
In this example, we register a special handler for when the user pastes in 'text/html' content. We add it to a special shape type that renders the html content directly.

View file

@ -0,0 +1,5 @@
---
title: Floaty window
hide: true
component: ./FloatyExample.tsx
---

View file

@ -0,0 +1,6 @@
---
title: Force mobile breakpoint
component: ./ForceBreakpointExample
---
Force the editor UI to render as if it were on a mobile device.

View file

@ -0,0 +1,10 @@
---
title: Hide UI
component: ./HideUiExample.tsx
---
Hide tldraw's UI with the `hideUi` prop.
---
Useful for a bare-bones editor, or if you want to build your own UI.

View file

@ -0,0 +1,5 @@
---
title: Hosted images
component: ./HostedImagesExample.tsx
hide: true
---

View file

@ -5,7 +5,7 @@ import { useCallback } from 'react'
// This is an example of how you can add an image to the editor. The image is already
// present in the `public` folder, so we can just use it directly.
// If you want to allow users to upload the images please take a look at the `HostedImagesExample.tsx`
export default function ImageExample() {
export default function LocalImagesExample() {
const handleMount = useCallback((editor: Editor) => {
// Assets are records that store data about shared assets like images, videos, etc.
// Each image has an associated asset record, so we'll create that first.
@ -22,7 +22,7 @@ export default function ImageExample() {
typeName: 'asset',
props: {
name: 'tldraw.png',
src: '/tldraw.png',
src: '/tldraw.png', // You could also use a base64 encoded string here
w: imageWidth,
h: imageHeight,
mimeType: 'image/png',
@ -51,7 +51,10 @@ export default function ImageExample() {
return (
<div className="tldraw__editor">
<Tldraw persistenceKey="tldraw_example" onMount={handleMount} />
<Tldraw
// persistenceKey="tldraw_local_images_example"
onMount={handleMount}
/>
</div>
)
}

View file

@ -0,0 +1,11 @@
---
title: Local images
component: ./LocalImagesExample.tsx
hide: false
---
How to use local images in the built-in `ImageShape` shape.
---
This example shows how to use local images in the built-in `ImageShape` shape. You must first create an asset that holds the source of the image, then create a shape that references the asset.

View file

@ -1,19 +1,16 @@
import { Tldraw } from '@tldraw/tldraw'
import '@tldraw/tldraw/tldraw.css'
export default function MetaExample() {
export default function OnChangeShapeMetaExample() {
return (
<div className="tldraw__editor">
<Tldraw
persistenceKey="tldraw_example"
onMount={(editor) => {
// There's no API for setting getInitialMetaForShape yet, but
// you can replace it at runtime like this. This will run for
// all shapes created by the user.
// See the "meta-on-create" example for more about setting the
// initial meta for a shape.
editor.getInitialMetaForShape = (_shape) => {
return {
createdBy: editor.user.getId(),
createdAt: Date.now(),
updatedBy: editor.user.getId(),
updatedAt: Date.now(),
}
@ -21,14 +18,13 @@ export default function MetaExample() {
// We can also use the sideEffects API to modify a shape before
// its change is committed to the database. This will run for
// all shapes whenever they are updated.
editor.sideEffects.registerBeforeChangeHandler('shape', (record, _prev, source) => {
if (source !== 'user') return record
record.meta = {
...record.meta,
editor.sideEffects.registerBeforeChangeHandler('shape', (_prev, next, source) => {
if (source !== 'user') return next
next.meta = {
updatedBy: editor.user.getId(),
updatedAt: Date.now(),
}
return record
return next
})
}}
/>

View file

@ -0,0 +1,10 @@
---
title: Shape Meta (on change)
component: ./OnChangeShapeMetaExample.tsx
---
Add custom metadata to shapes when they're changed.
---
In this example, we add updatedAt metadata to shapes when they're updated.

View file

@ -0,0 +1,23 @@
import { Tldraw } from '@tldraw/tldraw'
import '@tldraw/tldraw/tldraw.css'
export default function OnCreateShapeMetaExample() {
return (
<div className="tldraw__editor">
<Tldraw
persistenceKey="tldraw_example"
onMount={(editor) => {
// There's no API for setting getInitialMetaForShape yet, but
// you can replace it at runtime like this. This will run for
// all shapes created by the user.
editor.getInitialMetaForShape = (_shape) => {
return {
createdBy: editor.user.getId(),
createdAt: Date.now(),
}
}
}}
/>
</div>
)
}

View file

@ -0,0 +1,10 @@
---
title: Shape Meta (on create)
component: ./OnCreateShapeMetaExample.tsx
---
Add custom metadata to shapes when they're created.
---
In this example, we add createdBy and createdAt metadata to shapes when they're created.

View file

@ -0,0 +1,6 @@
---
title: Multiple editors
component: ./MultipleExample.tsx
---
Use multiple <Tldraw/> components on the same page.

View file

@ -0,0 +1,10 @@
---
title: Minimal
component: ./OnlyEditor.tsx
---
Use the `<TldrawEditor/>` component to render a bare-bones editor with minimal built-in shapes and tools.
---
This example show show the `<TldrawEditor/>` component can be used to render a bare-bones editor. It uses minimal built-in shapes and tools.

View file

@ -0,0 +1,11 @@
---
title: Persistence
component: ./PersistenceExample.tsx
order: 5
---
Save the contents of the editor
---
In this example, we load the contents of the editor from your browser's localStorage, and save it there when you make changes.

View file

@ -0,0 +1,10 @@
---
title: Readonly
component: ./ReadOnlyExample
---
Use the editor in readonly mode.
---
Users can still pan and zoom the canvas, but they can't change the contents.

View file

@ -0,0 +1,10 @@
---
title: Custom tool (screenshot)
component: ./ScreenshotToolExample.tsx
---
Draw a box on the canvas to capture a screenshot of that area.
---
Tools are the parts of tldraw's state chart. Most interactions in tldraw are tools.

View file

@ -4,6 +4,7 @@ import {
TLUiAssetUrlOverrides,
TLUiOverrides,
Tldraw,
Vec2d,
toolbarItem,
useEditor,
useValue,
@ -68,7 +69,10 @@ function ScreenshotBox() {
// "page space", i.e. uneffected by scale, and relative to the tldraw
// page's top left corner.
const zoomLevel = editor.getZoomLevel()
const { x, y } = editor.pageToScreen({ x: box.x, y: box.y })
const { x, y } = Vec2d.Sub(
editor.pageToScreen({ x: box.x, y: box.y }),
editor.getViewportScreenBounds()
)
return new Box2d(x, y, box.w * zoomLevel, box.h * zoomLevel)
},
[editor]

View file

@ -0,0 +1,10 @@
---
title: Scrolling container
component: ./ScrollExample.tsx
---
Use the editor inside a scrollable container.
---
Tldraw doesn't have to be full screen.

View file

@ -0,0 +1,10 @@
---
title: Shape meta
component: ./ShapeMetaExample.tsx
---
Add a label to shapes with the meta property.
---
Select a shape and try changing its label. The label is stored in the shape's meta property, which can be used to add custom data to any shape.

View file

@ -0,0 +1,10 @@
---
title: Snapshots
component: ./SnapshotExample.tsx
---
Load a snapshot of the editor's contents.
---
Use `editor.store.getSnapshot()` and `editor.store.loadSnapshot()` to save and restore the editor's contents.

View file

@ -10,7 +10,7 @@ const tools = [SpeechBubbleTool]
export default function CustomShapeWithHandles() {
return (
<div style={{ position: 'fixed', inset: 0 }}>
<div style={{ position: 'absolute', inset: 0 }}>
<Tldraw
shapeUtils={shapeUtils}
tools={tools}

View file

@ -0,0 +1,6 @@
---
title: Speech bubble
component: ./CustomShapeWithHandles.tsx
---
A custom shape with handles

View file

@ -0,0 +1,10 @@
---
title: Store events
component: ./StoreEventsExample.tsx
---
Listen to changes from tldraw's store.
---
Try creating & deleting shapes, or switching pages. The changes will be logged next to the canvas.

View file

@ -57,29 +57,27 @@ export default function StoreEventsExample() {
return (
<div style={{ display: 'flex' }}>
<div style={{ width: '60vw', height: '100vh' }}>
<div style={{ width: '60%', height: '100vh' }}>
<Tldraw onMount={setAppToState} />
</div>
<div>
<div
style={{
width: '40vw',
height: '100vh',
padding: 8,
background: '#eee',
border: 'none',
fontFamily: 'monospace',
fontSize: 12,
borderLeft: 'solid 2px #333',
display: 'flex',
flexDirection: 'column-reverse',
overflow: 'auto',
}}
>
{storeEvents.map((t, i) => (
<div key={i}>{t}</div>
))}
</div>
<div
style={{
width: '40%',
height: '100vh',
padding: 8,
background: '#eee',
border: 'none',
fontFamily: 'monospace',
fontSize: 12,
borderLeft: 'solid 2px #333',
display: 'flex',
flexDirection: 'column-reverse',
overflow: 'auto',
}}
>
{storeEvents.map((t, i) => (
<div key={i}>{t}</div>
))}
</div>
</div>
)

View file

@ -1,4 +1,11 @@
import { stopEventPropagation, Tldraw, TLEditorComponents, track, useEditor } from '@tldraw/tldraw'
import {
stopEventPropagation,
Tldraw,
TLEditorComponents,
track,
useEditor,
Vec2d,
} from '@tldraw/tldraw'
import '@tldraw/tldraw/tldraw.css'
import { useState } from 'react'
@ -55,7 +62,10 @@ const MyComponentInFront = track(() => {
if (!selectionRotatedPageBounds) return null
const pageCoordinates = editor.pageToScreen(selectionRotatedPageBounds.point)
const pageCoordinates = Vec2d.Sub(
editor.pageToScreen(selectionRotatedPageBounds.point),
editor.getViewportScreenBounds()
)
return (
<div

View file

@ -0,0 +1,10 @@
---
title: Things on the canvas
component: ./OnTheCanvas.tsx
---
Add custom components to the editor
---
Components can either float on top of the canvas unaffected by the camera, or be a part of the canvas itself.

View file

@ -0,0 +1,10 @@
---
title: UI events
component: ./UiEventsExample.tsx
---
Listen to events from tldraw's UI.
---
Try selecting tools, using keyboard shortcuts, undo/redo, etc. Events will be logged next to the canvas.

View file

@ -11,29 +11,27 @@ export default function UiEventsExample() {
return (
<div style={{ display: 'flex' }}>
<div style={{ width: '60vw', height: '100vh' }}>
<div style={{ width: '60%', height: '100vh' }}>
<Tldraw onUiEvent={handleUiEvent} />
</div>
<div>
<div
style={{
width: '40vw',
height: '100vh',
padding: 8,
background: '#eee',
border: 'none',
fontFamily: 'monospace',
fontSize: 12,
borderLeft: 'solid 2px #333',
display: 'flex',
flexDirection: 'column-reverse',
overflow: 'auto',
}}
>
{uiEvents.map((t, i) => (
<div key={i}>{t}</div>
))}
</div>
<div
style={{
width: '40%',
height: '100vh',
padding: 8,
background: '#eee',
border: 'none',
fontFamily: 'monospace',
fontSize: 12,
borderLeft: 'solid 2px #333',
display: 'flex',
flexDirection: 'column-reverse',
overflow: 'auto',
}}
>
{uiEvents.map((t, i) => (
<div key={i}>{t}</div>
))}
</div>
</div>
)

View file

@ -0,0 +1,12 @@
---
title: User presence
component: ./UserPresenceExample.tsx
---
Show other users editing the same document.
---
Here, we add fake InstancePresence records to the store to simulate other users.
If you have your own presence system, you could add real records to the store in the same way.

View file

@ -0,0 +1,12 @@
---
title: UI zones
component: ./ZonesExample.tsx
---
Inject custom components into tldraw's UI.
---
Our default UI has two empty "zones" - the `topZone` (in the top-center of the screen) and `shareZone` (in the top right).
You can set these zones to any React component you want.

View file

@ -0,0 +1,16 @@
import { ForwardedRef, useCallback } from 'react'
export function useMergedRefs<T>(...refs: ForwardedRef<T>[]) {
return useCallback(
(node: T) => {
for (const ref of refs) {
if (typeof ref === 'function') {
ref(node)
} else if (ref != null) {
ref.current = node
}
}
},
[refs]
)
}

View file

@ -5,46 +5,12 @@ import {
setDefaultEditorAssetUrls,
setDefaultUiAssetUrls,
} from '@tldraw/tldraw'
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { RouterProvider, createBrowserRouter } from 'react-router-dom'
import ExamplesTldrawLogo from './components/ExamplesTldrawLogo'
import { ListLink } from './components/ListLink'
import BasicExample from './BasicExample'
import APIExample from './examples/APIExample'
import AssetPropsExample from './examples/AssetOptionsExample'
import CanvasEventsExample from './examples/CanvasEventsExample'
import CustomComponentsExample from './examples/CustomComponentsExample'
import CustomConfigExample from './examples/CustomConfigExample/CustomConfigExample'
import CustomShapeWithHandles from './examples/CustomShapeWithHandles/CustomShapeWithHandles'
import CustomStylesExample from './examples/CustomStylesExample/CustomStylesExample'
import CustomUiExample from './examples/CustomUiExample/CustomUiExample'
import ErrorBoundaryExample from './examples/ErrorBoundaryExample/ErrorBoundaryExample'
import ExplodedExample from './examples/ExplodedExample'
import ExternalContentSourcesExample from './examples/ExternalContentSourcesExample'
import FloatyExample from './examples/FloatyExample'
import ForceMobileExample from './examples/ForceBreakpointExample'
import HideUiExample from './examples/HideUiExample'
import HostedImagesExample from './examples/HostedImagesExample'
import ImageExample from './examples/ImagesExamples'
import MetaExample from './examples/MetaExample'
import MultipleExample from './examples/MultipleExample'
import OnTheCanvasExample from './examples/OnTheCanvas'
import PersistenceExample from './examples/PersistenceExample'
import ReadOnlyExample from './examples/ReadOnlyExample'
import ScreenshotToolExample from './examples/ScreenshotToolExample/ScreenshotToolExample'
import ScrollExample from './examples/ScrollExample'
import ShapeMetaExample from './examples/ShapeMetaExample'
import SnapshotExample from './examples/SnapshotExample/SnapshotExample'
import StoreEventsExample from './examples/StoreEventsExample'
import UiEventsExample from './examples/UiEventsExample'
import UserPresenceExample from './examples/UserPresenceExample'
import ZonesExample from './examples/ZonesExample'
import EndToEnd from './examples/end-to-end/end-to-end'
import OnlyEditorExample from './examples/only-editor/OnlyEditor'
import YjsExample from './examples/yjs/YjsExample'
import { ExamplePage } from './ExamplePage'
import { HomePage } from './HomePage'
import { examples } from './examples'
import EndToEnd from './testing/end-to-end'
// This example is only used for end to end tests
@ -54,219 +20,50 @@ const assetUrls = getAssetUrlsByMetaUrl()
setDefaultEditorAssetUrls(assetUrls)
setDefaultUiAssetUrls(assetUrls)
type Example = {
path: string
title?: string
element: JSX.Element
}
export const allExamples: Example[] = [
const router = createBrowserRouter([
{
title: 'Basic (development)',
path: 'develop',
element: <BasicExample />,
path: '/',
element: <HomePage />,
},
{
title: 'Collaboration (with Yjs)',
path: 'yjs',
element: <YjsExample />,
},
{
title: 'Editor API',
path: 'api',
element: <APIExample />,
},
{
title: 'Multiple editors',
path: 'multiple',
element: <MultipleExample />,
},
{
title: 'Meta Example',
path: 'meta',
element: <MetaExample />,
},
{
title: 'Readonly Example',
path: 'readonly',
element: <ReadOnlyExample />,
},
{
title: 'Things on the canvas',
path: 'things-on-the-canvas',
element: <OnTheCanvasExample />,
},
{
title: 'Scroll example',
path: 'scroll',
element: <ScrollExample />,
},
{
title: 'Custom shapes / tools',
path: 'custom-config',
element: <CustomConfigExample />,
},
{
title: 'Sublibraries',
path: 'exploded',
element: <ExplodedExample />,
},
{
title: 'Error boundary',
path: 'error-boundary',
element: <ErrorBoundaryExample />,
},
{
title: 'Custom UI',
path: 'custom-ui',
element: <CustomUiExample />,
},
{
title: 'Custom Tool (Screenshot)',
path: 'screenshot-tool',
element: <ScreenshotToolExample />,
},
{
title: 'Hide UI',
path: 'hide-ui',
element: <HideUiExample />,
},
{
title: 'UI components',
path: 'custom-components',
element: <CustomComponentsExample />,
},
{
title: 'UI events',
path: 'ui-events',
element: <UiEventsExample />,
},
{
title: 'Canvas events',
path: 'canvas-events',
element: <CanvasEventsExample />,
},
{
title: 'Store events',
path: 'store-events',
element: <StoreEventsExample />,
},
{
title: 'User presence',
path: 'user-presence',
element: <UserPresenceExample />,
},
{
title: 'UI zones',
path: 'zones',
element: <ZonesExample />,
},
{
title: 'Persistence',
path: 'persistence',
element: <PersistenceExample />,
},
{
title: 'Snapshots',
path: 'snapshots',
element: <SnapshotExample />,
},
{
title: 'Force mobile breakpoint',
path: 'force-mobile',
element: <ForceMobileExample />,
},
{
title: 'Custom styles',
path: 'custom-styles',
element: <CustomStylesExample />,
},
{
title: 'Shape meta property',
path: 'shape-meta',
element: <ShapeMetaExample />,
},
{
title: 'Only editor',
path: 'only-editor',
element: <OnlyEditorExample />,
},
{
title: 'Adding images',
path: 'images',
element: <ImageExample />,
},
{
title: 'Hosted images example',
path: 'hosted-images',
element: <HostedImagesExample />,
},
{
title: 'Asset props',
path: 'asset-props',
element: <AssetPropsExample />,
},
{
title: 'Floaty window',
path: 'floaty-window',
element: <FloatyExample />,
},
{
title: 'External content sources',
path: 'external-content-sources',
element: <ExternalContentSourcesExample />,
},
{
title: 'Custom Shape With Handles',
path: 'custom-shape-with-handles',
element: <CustomShapeWithHandles />,
},
// not listed
{
path: 'end-to-end',
element: <EndToEnd />,
},
]
function App() {
return (
<div className="examples">
<div className="examples__header">
<ExamplesTldrawLogo />
<p>
See docs at <a href="https://tldraw.dev">tldraw.dev</a>
</p>
</div>
<ul className="examples__list">
{allExamples
.filter((example) => example.title)
.map((example) => (
<ListLink key={example.path} title={example.title!} route={example.path} />
))}
</ul>
</div>
)
}
const router = createBrowserRouter([
{
path: '/',
element: <App />,
},
...allExamples,
...examples.flatMap((example) => [
{
path: example.path,
lazy: async () => {
const Component = await example.loadComponent()
return {
element: (
<ExamplePage example={example}>
<Component />
</ExamplePage>
),
}
},
},
{
path: `${example.path}/full`,
lazy: async () => {
const Component = await example.loadComponent()
return {
element: <Component />,
}
},
},
]),
])
document.addEventListener('DOMContentLoaded', () => {
const rootElement = document.getElementById('root')!
const root = createRoot(rootElement!)
root.render(
<StrictMode>
<ErrorBoundary
fallback={(error) => <DefaultErrorFallback error={error} />}
onError={(error) => console.error(error)}
>
<RouterProvider router={router} />
</ErrorBoundary>
</StrictMode>
<ErrorBoundary
fallback={(error) => <DefaultErrorFallback error={error} />}
onError={(error) => console.error(error)}
>
<RouterProvider router={router} />
</ErrorBoundary>
)
})

View file

@ -1,4 +1,4 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;700&display=swap');
html,
body {
@ -13,6 +13,12 @@ body {
min-height: -webkit-fill-available;
height: 100%;
}
@media screen and (max-width: 600px) {
html,
body {
font-size: 14px;
}
}
html,
* {
@ -20,45 +26,258 @@ html,
}
.tldraw__editor {
position: fixed;
position: absolute;
inset: 0px;
overflow: hidden;
}
.examples {
padding: 16px;
padding: 1.5rem;
max-width: 1080px;
margin: 0 auto;
}
.examples hr {
border: none;
border-top: 1px solid #e8e8e8;
margin: 0;
}
.examples__header {
width: fit-content;
padding-bottom: 32px;
padding-bottom: 2rem;
display: flex;
align-items: center;
}
@media screen and (max-width: 500px) {
.examples__header {
flex-direction: column;
align-items: flex-start;
padding-bottom: 1rem;
}
}
.examples__title {
display: flex;
align-items: center;
margin-right: auto;
font-size: 1.45rem;
}
.examples__lockup {
height: 56px;
height: 1.875rem;
width: auto;
margin-right: 0.5rem;
}
.examples__list {
display: flex;
flex-direction: column;
display: grid;
grid-template-columns: repeat(1, 1fr);
padding: 0;
margin: 0;
margin: 0 -1.5rem 0 -1.5rem;
list-style: none;
gap: 0rem;
}
@media screen and (max-width: 800px) {
.examples__list {
grid-template-columns: repeat(1, 1fr);
}
}
@media screen and (max-width: 600px) {
.examples__list {
grid-template-columns: repeat(1, 1fr);
}
}
.examples__list__item {
padding: 8px 12px;
margin: 0px -12px;
overflow: hidden;
position: relative;
padding: 1.5rem;
}
.examples__list__item__link {
position: absolute;
inset: 0;
z-index: 1;
cursor: pointer;
}
.examples__list__item a {
padding: 8px 12px;
margin: 0px -12px;
.examples__list__item__active {
display: block;
padding: 1.5rem;
text-decoration: none;
color: inherit;
width: 100%;
position: relative;
}
.examples__list__item a:hover {
text-decoration: underline;
.examples__list__item::before {
content: ' ';
position: absolute;
inset: 0.5rem;
border-radius: 6px;
background: #f5f5f5;
z-index: -1;
opacity: 0;
transition: opacity 0.12s ease-in-out;
}
.examples__list__item:hover::before,
.examples__list__item__active::before {
opacity: 1;
}
.examples__list__item h3 {
all: unset;
font-weight: 700;
font-size: 1rem;
margin: 0.25rem 0rem;
display: flex;
align-items: center;
justify-content: space-between;
}
.examples__list__item__details {
margin-top: 1rem;
position: absolute;
left: 1rem;
right: 1rem;
opacity: 0;
transition: opacity 0.12s ease-in-out;
}
.examples__list__item__active .examples__list__item__details {
position: static;
opacity: 1;
transition-delay: 0.1s;
}
.examples__list__item__standalone {
color: inherit;
opacity: 0.8;
padding: 0.5rem;
margin: -0.5rem;
display: block;
width: 16px;
height: 16px;
box-sizing: content-box;
}
.examples__list__item__code {
margin-top: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.examples__list__item__code a {
margin-top: 0.5rem;
padding: 0.5rem 0.5rem;
border-radius: 6px;
background-color: white;
display: flex;
flex: 1 1 auto;
text-align: center;
align-items: center;
justify-content: center;
color: inherit;
text-decoration: none;
box-shadow: 0px 1px 2px rgba(0, 0, 0, 0.12), 0px 1px 3px rgba(0, 0, 0, 0.04);
}
.example {
position: absolute;
inset: 0;
display: flex;
align-items: stretch;
}
.example__info {
width: 25vw;
max-width: 300px;
position: relative;
z-index: 1;
border-right: 1px solid #e8e8e8;
display: flex;
flex-direction: column;
height: 100%;
flex: none;
}
@media screen and (max-width: 800px) {
.example__info {
display: none;
}
}
.example__logo {
all: unset;
cursor: pointer;
flex: none;
font-size: 1.15rem;
display: flex;
align-items: center;
justify-content: start;
padding: calc(1rem - 0.5px) 1rem;
}
.example__logo .examples__lockup {
height: 1.5rem;
}
.example__info__list {
flex: auto;
margin: 0;
list-style: none;
padding: 0;
overflow: auto;
position: relative;
font-size: 13px;
}
.example__info__list .examples__list__item {
padding: 0.75rem 1rem;
}
.example__info__list .examples__list__item::before {
inset-block: 0.25rem;
inset-inline: 0.5rem;
}
.example__info__list h3 {
font-size: 14px;
}
.example__content {
flex: 1 1 auto;
position: relative;
z-index: 0;
overflow: auto;
}
.examples__markdown p {
all: unset;
display: block;
}
.examples__markdown p:not(:last-child) {
margin-bottom: 0.5rem;
}
.scroll-light {
scrollbar-width: thin;
}
.scroll-light::-webkit-scrollbar {
display: block;
width: 8px;
height: 8px;
position: absolute;
top: 0;
left: 0;
background-color: inherit;
}
.scroll-light::-webkit-scrollbar-button {
display: none;
width: 0;
height: 10px;
}
.scroll-light::-webkit-scrollbar-thumb {
background-clip: padding-box;
width: 0;
min-height: 36px;
border: 2px solid transparent;
border-radius: 6px;
background-color: rgba(0, 0, 0, 0.25);
}
.scroll-light::-webkit-scrollbar-thumb:hover {
background-color: rgba(0, 0, 0, 0.3);
}
.example__info__list hr {
border: none;
border-top: 1px solid #e8e8e8;
margin: 0;
}

1
apps/examples/src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

View file

@ -1,9 +1,9 @@
import react from '@vitejs/plugin-react'
import path from 'path'
import { defineConfig } from 'vite'
import { PluginOption, defineConfig } from 'vite'
export default defineConfig({
plugins: [react()],
plugins: [react(), exampleReadmePlugin()],
root: path.join(__dirname, 'src'),
publicDir: path.join(__dirname, 'public'),
build: {
@ -21,3 +21,81 @@ export default defineConfig({
'process.env.TLDRAW_ENV': JSON.stringify(process.env.VERCEL_ENV ?? 'development'),
},
})
function exampleReadmePlugin(): PluginOption {
return {
name: 'example-readme',
async transform(src, id) {
const match = id.match(/examples\/src\/examples\/(.*)\/README.md$/)
if (!match) return
const remark = (await import('remark')).remark
const remarkFrontmatter = (await import('remark-frontmatter')).default
const remarkHtml = (await import('remark-html')).default
const matter = (await import('vfile-matter')).matter
const file = await remark()
.use(remarkFrontmatter)
.use(remarkHtml)
.use(() => (_, file) => matter(file))
.process(src)
const frontmatter = parseFrontMatter(file.data.matter, id)
const separator = '\n<hr>\n'
const parts = String(file).split(separator)
const description = parts[0]
const details = parts.slice(1).join(separator)
const path = `/${match[1]}`
const codeUrl = `https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples${path}`
const result = [
`export const title = ${JSON.stringify(frontmatter.title)};`,
`export const order = ${JSON.stringify(frontmatter.order)};`,
`export const hide = ${JSON.stringify(frontmatter.hide)};`,
`export const description = ${JSON.stringify(description)};`,
`export const details = ${JSON.stringify(details)};`,
`export const codeUrl = ${JSON.stringify(codeUrl)};`,
`export const path = ${JSON.stringify(path)};`,
`export const componentFile = ${JSON.stringify(frontmatter.component)};`,
`import {lazy} from 'react';`,
`export const loadComponent = async () => {`,
` return (await import(${JSON.stringify(frontmatter.component)})).default;`,
`};`,
]
return result.join('\n')
},
}
}
function parseFrontMatter(data: unknown, fileName: string) {
if (!data || typeof data !== 'object') {
throw new Error(`Frontmatter missing in ${fileName}`)
}
if (!('title' in data && typeof data.title === 'string')) {
throw new Error(`Frontmatter key 'title' must be string in ${fileName}`)
}
if (!('component' in data && typeof data.component === 'string')) {
throw new Error(`Frontmatter key 'component' must be string in ${fileName}`)
}
const order = 'order' in data ? data.order : null
if (order !== null && typeof order !== 'number') {
throw new Error(`Frontmatter key 'order' must be number in ${fileName}`)
}
const hide = 'hide' in data ? data.hide : false
if (hide !== false && hide !== true) {
throw new Error(`Frontmatter key 'hide' must be boolean in ${fileName}`)
}
return {
title: data.title,
component: data.component,
order,
hide,
}
}

View file

@ -37,7 +37,7 @@
"postinstall": "husky install && yarn refresh-assets",
"refresh-assets": "lazy refresh-assets",
"build": "lazy build",
"dev": "lazy run dev --filter='{,bublic/}apps/examples' --filter='{,bublic/}packages/tldraw'",
"dev": "LAZYREPO_PRETTY_OUTPUT=0 lazy run dev --filter='{,bublic/}apps/examples' --filter='{,bublic/}packages/tldraw'",
"dev-vscode": "code ./apps/vscode/extension && lazy run dev --filter='{,bublic/}apps/vscode/{extension,editor}'",
"build-types": "lazy inherit",
"build-api": "lazy build-api",

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