[5/5] Move bemo from dotcom to examples (#4135)

Move our bemo playground from dotcom to the examples app. In preparation
for more multiplayer examples, I built our a little bit of chrome around
example room IDs: if you create an example with `multiplayer: true`, the
examples app will render a little room ID picker above your example. The
room IDs are scoped to each example, and each deploy of the examples
app. By default people on the same example will be in the same room, but
the default ID changes every hour.

As I was doing this, I noticed you could get an ugly situation where the
docs site was in dark mode, tldraw was in dark mode, but the little bit
of examples chrome was in light mode. To fix this I through together an
extremely rough dark mode for the examples which switches on whenever
the tldraw instance inside is in dark mode.

### Change type

- [x] `other`

---------

Co-authored-by: David Sheldrick <d.j.sheldrick@gmail.com>
This commit is contained in:
alex 2024-07-12 11:36:31 +01:00 committed by GitHub
parent d684866f90
commit 64e1fac897
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 306 additions and 62 deletions

View file

@ -25,10 +25,10 @@ export class BemoDO extends DurableObject<Environment> {
r2: R2Bucket
_slug: string | null = null
analytics: AnalyticsEngineDataset
analytics?: AnalyticsEngineDataset
writeEvent({ type, origin, sessionKey, slug }: AnalyticsEvent) {
this.analytics.writeDataPoint({
this.analytics?.writeDataPoint({
blobs: [type, origin, slug, sessionKey],
})
}

View file

@ -13,5 +13,5 @@ export interface Environment {
IS_LOCAL: string | undefined
WORKER_NAME: string | undefined
BEMO_ANALYTICS: AnalyticsEngineDataset
BEMO_ANALYTICS?: AnalyticsEngineDataset
}

View file

@ -18,7 +18,7 @@ export default function ExampleCodeBlock({
useEffect(() => setIsClientSide(true), [])
const SERVER =
process.env.NODE_ENV === 'development'
? `http://${location.host}:5420`
? `http://${location.hostname}:5420`
: location.host.includes('staging') || location.host.includes('canary')
? `https://examples-canary.tldraw.com`
: 'https://examples.tldraw.com'

View file

@ -1432,6 +1432,8 @@ html[data-theme='light'] .hero__dark {
min-height: 500px;
height: 50vh;
width: 100%;
border-radius: var(--border-radius-menu);
border: 1px solid var(--color-tint-2);
}
.code-example .sandpack {

View file

@ -1,13 +0,0 @@
import { useMultiplayerDemo } from '@tldraw/sync'
import { Tldraw } from 'tldraw'
import { assetUrls } from '../utils/assetUrls'
export function TemporaryBemoDevEditor({ slug }: { slug: string }) {
const store = useMultiplayerDemo({ host: 'http://127.0.0.1:8989', roomId: slug })
return (
<div className="tldraw__editor">
<Tldraw store={store} assetUrls={assetUrls} inferDarkMode />
</div>
)
}

View file

@ -1,13 +0,0 @@
import { useParams } from 'react-router-dom'
import '../../styles/globals.css'
import { IFrameProtector, ROOM_CONTEXT } from '../components/IFrameProtector'
import { TemporaryBemoDevEditor } from '../components/TemporaryBemoDevEditor'
export function Component() {
const id = useParams()['roomId'] as string
return (
<IFrameProtector slug={id} context={ROOM_CONTEXT.PUBLIC_MULTIPLAYER}>
<TemporaryBemoDevEditor slug={id} />
</IFrameProtector>
)
}

View file

@ -10,11 +10,6 @@ import { Outlet, Route, createRoutesFromElements, useRouteError } from 'react-ro
import { DefaultErrorFallback } from './components/DefaultErrorFallback/DefaultErrorFallback'
import { ErrorPage } from './components/ErrorPage/ErrorPage'
const enableTemporaryLocalBemo =
window.location.hostname === 'localhost' &&
window.location.port === '3000' &&
typeof jest === 'undefined'
export const router = createRoutesFromElements(
<Route
element={
@ -55,9 +50,6 @@ export const router = createRoutesFromElements(
lazy={() => import('./pages/public-readonly-legacy')}
/>
<Route path={`/${READ_ONLY_PREFIX}/:roomId`} lazy={() => import('./pages/public-readonly')} />
{enableTemporaryLocalBemo && (
<Route path={`/bemo/:roomId`} lazy={() => import('./pages/temporary-bemo')} />
)}
</Route>
<Route path="*" lazy={() => import('./pages/not-found')} />
</Route>

View file

@ -37,6 +37,7 @@
"@radix-ui/react-alert-dialog": "^1.0.5",
"@tldraw/assets": "workspace:*",
"@tldraw/dotcom-shared": "workspace:*",
"@tldraw/sync": "workspace:*",
"@vercel/analytics": "^1.1.1",
"classnames": "^2.3.2",
"lazyrepo": "0.0.0-alpha.27",

View file

@ -228,12 +228,13 @@ function Dialogs() {
function SocialIcon({ icon }: { icon: string }) {
return (
<img
<div
className="example__sidebar__icon"
src={`/icons/${icon}.svg`}
style={{
mask: `url(/icons/${icon}.svg) center 100% / 100% no-repeat`,
WebkitMask: `url(/icons/${icon}.svg) center 100% / 100% no-repeat`,
width: 16,
height: 16,
}}
/>
)

View file

@ -0,0 +1,83 @@
import { useState } from 'react'
import { Example } from './examples'
export function ExampleWrapper({
example,
component: Component,
}: {
example: Example
component: React.ComponentType<{ roomId?: string }>
}) {
if (!example.multiplayer) {
return <Component />
}
return <MultiplayerExampleWrapper component={Component} example={example} />
}
const hour = 60 * 1000 * 1000
function MultiplayerExampleWrapper({
component: Component,
example,
}: {
example: Example
component: React.ComponentType<{ roomId?: string }>
}) {
const prefix = `tldraw-example-${example.path.replace(/\//g, '-')}-${process.env.TLDRAW_DEPLOY_ID}`
const [roomId, setRoomId] = useState(String(Math.floor(Date.now() / hour)))
const [nextRoomId, setNextRoomId] = useState(roomId)
const trimmed = nextRoomId.trim()
const canSet = trimmed && roomId !== trimmed
function setIfPossible() {
if (!canSet) return
setRoomId(trimmed)
}
return (
<div className="MultiplayerExampleWrapper">
<div className="MultiplayerExampleWrapper-picker">
<label>
<div>Room ID:</div>
<input
value={nextRoomId}
onChange={(e) => setNextRoomId(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && canSet) {
e.preventDefault()
setIfPossible()
}
if (e.key === 'Escape') {
e.preventDefault()
setNextRoomId(roomId)
e.currentTarget.blur()
}
}}
/>
</label>
<button onClick={() => setIfPossible()} disabled={!canSet} aria-label="join">
<ArrowIcon />
</button>
</div>
<div className="MultiplayerExampleWrapper-example">
<div>
<Component roomId={`${prefix}_${encodeURIComponent(roomId)}`} />
</div>
</div>
</div>
)
}
function ArrowIcon() {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M9.25012 4.75L12.5001 8M12.5001 8L9.25012 11.25M12.5001 8H3.5"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}

View file

@ -11,7 +11,8 @@ export interface Example {
priority: number
componentFile: string
keywords: string[]
loadComponent: () => Promise<ComponentType>
multiplayer: boolean
loadComponent: () => Promise<ComponentType<{ roomId?: string }>>
}
type Category =

View file

@ -0,0 +1,12 @@
import { useMultiplayerDemo } from '@tldraw/sync'
import { Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'
export default function MultiplayerDemoExample({ roomId }: { roomId: string }) {
const store = useMultiplayerDemo({ roomId })
return (
<div className="tldraw__editor">
<Tldraw store={store} />
</div>
)
}

View file

@ -0,0 +1,14 @@
---
title: Multiplayer demo
component: ./MultiplayerDemoExample.tsx
category: basic
priority: 3
keywords: [basic, intro, simple, quick, start, multiplayer, sync, collaboration]
multiplayer: true
---
---
The `useMultiplayerDemo` hook can be used to quickly prototype multiplayer experiences in tldraw,
although rooms will be deleted after one day. Note that the board below is shared with all visitors
to this page.

View file

@ -43,7 +43,6 @@ export default function MultipleExample() {
return (
<div
style={{
backgroundColor: '#fff',
padding: 32,
}}
// Sorry you need to do this yourself

View file

@ -8,6 +8,7 @@ import {
setDefaultUiAssetUrls,
} from 'tldraw'
import { ExamplePage } from './ExamplePage'
import { ExampleWrapper } from './ExampleWrapper'
import { examples } from './examples'
import Develop from './misc/develop'
import EndToEnd from './misc/end-to-end'
@ -24,9 +25,6 @@ if (!gettingStartedExamples) throw new Error('Could not find getting started exa
const basicExample = gettingStartedExamples.value.find((e) => e.title === 'Tldraw component')
if (!basicExample) throw new Error('Could not find initial example')
// eslint-disable-next-line no-console
console.log('bemo', process.env.TLDRAW_BEMO_URL)
const router = createBrowserRouter([
{
path: '*',
@ -39,7 +37,7 @@ const router = createBrowserRouter([
return {
element: (
<ExamplePage example={basicExample}>
<Component />
<ExampleWrapper example={basicExample} component={Component} />
</ExamplePage>
),
}
@ -62,7 +60,7 @@ const router = createBrowserRouter([
return {
element: (
<ExamplePage example={example}>
<Component />
<ExampleWrapper example={example} component={Component} />
</ExamplePage>
),
}
@ -73,7 +71,7 @@ const router = createBrowserRouter([
lazy: async () => {
const Component = await example.loadComponent()
return {
element: <Component />,
element: <ExampleWrapper example={example} component={Component} />,
}
},
},

View file

@ -1,11 +1,17 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@500;700&display=swap');
:root {
--background: white;
--text: hsl(0, 0%, 11%);
--gray-light: #f5f5f5;
--gray-dark: #e8e8e8;
--black-transparent-lighter: rgba(0, 0, 0, 0.07);
--black-transparent-light: rgba(0, 0, 0, 0.3);
--black-transparent-dark: rgba(0, 0, 0, 0.5);
--black-transparent-darker: rgba(0, 0, 0, 0.7);
--focus: hsl(214, 84%, 56%);
--whiteboard: hsl(210, 20%, 98%);
color-scheme: light;
}
html,
@ -19,6 +25,8 @@ body {
height: 100%;
/* prevent two-finger swipe to go back */
overscroll-behavior-x: none;
background-color: var(--background);
color: var(--text);
}
@media screen and (max-width: 600px) {
html,
@ -245,7 +253,7 @@ li.examples__sidebar__item {
.example__sidebar__footer-link {
padding: 8px 8px;
border-radius: 6px;
background-color: hsl(214, 84%, 56%);
background-color: var(--focus);
display: flex;
flex: 1 1 auto;
text-align: center;
@ -279,7 +287,7 @@ a.example__sidebar__header-link {
}
.example__sidebar__header__socials a {
color: black;
color: var(--text);
height: 32px;
width: 32px;
display: flex;
@ -292,8 +300,8 @@ a.example__sidebar__header-link {
flex-shrink: 0;
width: 16px;
height: 16px;
color: black;
background-color: black;
color: var(--text);
background-color: var(--text);
}
/* --------------------- Scroll --------------------- */
@ -321,7 +329,7 @@ a.example__sidebar__header-link {
min-height: 36px;
border: 2px solid transparent;
border-radius: 6px;
background-color: rgba(0, 0, 0, 0.25);
background-color: var(--black-transparent-light);
}
.scroll-light::-webkit-scrollbar-thumb:hover {
background-color: var(--black-transparent-light);
@ -428,3 +436,138 @@ a.example__sidebar__header-link {
.example__dialog__close {
all: unset;
}
:root:has(.tl-theme__dark) .MultiplayerExampleWrapper {
--background: black;
--text: hsl(0, 9%, 94%);
--gray-light: #333;
--gray-dark: #444;
--black-transparent-lighter: rgba(255, 255, 255, 0.15);
--black-transparent-light: rgba(255, 255, 255, 0.4);
--black-transparent-dark: rgba(255, 255, 255, 0.6);
--black-transparent-darker: rgba(255, 255, 255, 0.7);
--focus: hsl(217, 89%, 61%);
--whiteboard: hsl(240, 5%, 6.5%);
color-scheme: dark;
}
.MultiplayerExampleWrapper {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
container: example / size;
}
.MultiplayerExampleWrapper-picker {
position: absolute;
z-index: 1;
left: 0;
right: 0;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
padding: 8px;
padding-bottom: 7px;
}
.MultiplayerExampleWrapper-picker::before {
content: '';
position: absolute;
top: 6px;
height: 29px;
width: 232px;
margin-left: 28px;
background: var(--whiteboard);
z-index: -1;
border-radius: 6px;
}
.MultiplayerExampleWrapper-picker label {
flex: 0 1 200px;
font-size: 14px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
border: 1px solid var(--gray-dark);
color: var(--black-transparent-dark);
border-radius: 4px;
padding: 2px 8px;
background: var(--background);
/* Add space to the left the same as the button to keep the input centered */
margin-left: 32px;
}
.MultiplayerExampleWrapper-picker label:focus-within {
border-color: var(--focus);
}
.MultiplayerExampleWrapper-picker label div {
flex: 0 0 auto;
}
.MultiplayerExampleWrapper-picker input {
flex: 0 1 auto;
width: 100%;
font: inherit;
border: none;
outline: none;
color: var(--black-transparent-darker);
background: transparent;
}
.MultiplayerExampleWrapper-picker button {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: center;
border: none;
background-color: transparent;
color: var(--black-transparent-darker);
cursor: pointer;
height: 32px;
width: 32px;
padding: 0;
margin: -4px 0;
position: relative;
}
.MultiplayerExampleWrapper-picker button:hover::after {
content: ' ';
position: absolute;
inset: 3px;
background: var(--black-transparent-lighter);
border-radius: 4px;
}
.MultiplayerExampleWrapper-picker button:disabled {
opacity: 0.5;
pointer-events: none;
}
.MultiplayerExampleWrapper-example {
position: relative;
height: 100%;
}
@container example (width < 584px) {
.MultiplayerExampleWrapper-picker {
position: static;
border-bottom: 1px solid var(--gray-dark);
background: var(--background);
}
.MultiplayerExampleWrapper-picker::before {
display: none;
}
.MultiplayerExampleWrapper-picker label {
width: 100%;
flex: 1 1 auto;
margin: 0;
}
.MultiplayerExampleWrapper-picker button {
margin-right: -4px;
}
}
@container example (width <= 904px) and (width > 840px) {
.MultiplayerExampleWrapper-picker {
left: 346px;
right: 0;
justify-content: start;
}
.MultiplayerExampleWrapper-picker label {
margin-left: 0;
}
}

View file

@ -10,6 +10,9 @@
{
"path": "../../packages/assets"
},
{
"path": "../../packages/sync"
},
{
"path": "../../packages/tldraw"
},

View file

@ -42,7 +42,9 @@ const TLDRAW_BEMO_URL_STRING =
? '"https://demo.tldraw.xyz"'
: env === 'canary'
? '"https://canary-demo.tldraw.xyz"'
: `"https://pr-${PR_NUMBER}-demo.tldraw.xyz"`
: PR_NUMBER
? `"https://pr-${PR_NUMBER}-demo.tldraw.xyz"`
: undefined
export default defineConfig(({ mode }) => ({
plugins: [react({ tsDecorators: true }), exampleReadmePlugin()],
@ -68,7 +70,15 @@ export default defineConfig(({ mode }) => ({
},
define: {
'process.env.TLDRAW_ENV': JSON.stringify(process.env.VERCEL_ENV ?? 'development'),
'process.env.TLDRAW_DEPLOY_ID': JSON.stringify(
process.env.VERCEL_GIT_COMMIT_SHA ?? `local-${Date.now()}`
),
'process.env.TLDRAW_BEMO_URL': urlOrLocalFallback(mode, TLDRAW_BEMO_URL_STRING, 8989),
'process.env.TLDRAW_IMAGE_URL': urlOrLocalFallback(
mode,
env === 'development' ? undefined : 'https://images.tldraw.xyz',
8989
),
},
}))
@ -104,6 +114,7 @@ function exampleReadmePlugin(): PluginOption {
`export const priority = ${JSON.stringify(frontmatter.priority ?? '100000')};`,
`export const category = ${JSON.stringify(frontmatter.category)};`,
`export const hide = ${JSON.stringify(frontmatter.hide)};`,
`export const multiplayer = ${JSON.stringify(frontmatter.multiplayer)};`,
`export const description = ${JSON.stringify(description)};`,
`export const details = ${JSON.stringify(details)};`,
`export const codeUrl = ${JSON.stringify(codeUrl)};`,
@ -154,6 +165,11 @@ function parseFrontMatter(data: unknown, fileName: string) {
throw new Error(`Frontmatter key 'keywords' must be array in ${fileName}`)
}
const multiplayer = 'multiplayer' in data ? data.multiplayer : false
if (typeof multiplayer !== 'boolean') {
throw new Error(`Frontmatter key 'multiplayer' must be boolean in ${fileName}`)
}
return {
title: data.title,
component: data.component,
@ -161,5 +177,6 @@ function parseFrontMatter(data: unknown, fileName: string) {
category,
hide,
keywords,
multiplayer,
}
}

View file

@ -311,7 +311,7 @@ class ReconnectManager {
)
this.lastAttemptStart = Date.now()
this.socketAdapter._setNewSocket(new WebSocket(uri))
this.socketAdapter._setNewSocket(new WebSocket(httpToWs(uri)))
this.state = 'pendingAttemptResult'
})
}
@ -456,3 +456,7 @@ class ReconnectManager {
this.isDisposed = true
}
}
function httpToWs(url: string) {
return url.replace(/^http(s)?:/, 'ws$1:')
}

View file

@ -52,11 +52,7 @@ export function useMultiplayerSync(opts: UseMultiplayerSyncOptions): RemoteTLSto
schema,
} = opts
const error: NonNullable<typeof state>['error'] = state?.error ?? undefined
useEffect(() => {
if (error) return
const storeId = uniqueId()
const userPreferences = computed<{ id: string; color: string; name: string }>(
@ -134,8 +130,9 @@ export function useMultiplayerSync(opts: UseMultiplayerSyncOptions): RemoteTLSto
didCancel = true
client.close()
socket.close()
setState(null)
}
}, [assets, error, onEditorMount, prefs, roomId, setState, track, uri, schema])
}, [assets, onEditorMount, prefs, roomId, schema, setState, track, uri])
return useValue<RemoteTLStoreWithStatus>(
'remote synced store',

View file

@ -38,8 +38,8 @@ function getEnv(cb: () => string | undefined): string | undefined {
}
}
const DEMO_WORKER = getEnv(() => process.env.DEMO_WORKER) ?? 'https://demo.tldraw.xyz'
const IMAGE_WORKER = getEnv(() => process.env.IMAGE_WORKER) ?? 'https://images.tldraw.xyz'
const DEMO_WORKER = getEnv(() => process.env.TLDRAW_BEMO_URL) ?? 'https://demo.tldraw.xyz'
const IMAGE_WORKER = getEnv(() => process.env.TLDRAW_IMAGE_URL) ?? 'https://images.tldraw.xyz'
export function useMultiplayerDemo({
roomId,

View file

@ -55,6 +55,8 @@ class MiniflareMonitor {
this.restart()
} else if (!err) {
console.log(output.replace('[mf:inf]', '')) // or handle the output differently
} else {
console.error(output.replace('[mf:err]', '')) // or handle the output differently
}
}

View file

@ -11850,6 +11850,7 @@ __metadata:
"@radix-ui/react-alert-dialog": "npm:^1.0.5"
"@tldraw/assets": "workspace:*"
"@tldraw/dotcom-shared": "workspace:*"
"@tldraw/sync": "workspace:*"
"@types/lodash": "npm:^4.14.188"
"@vercel/analytics": "npm:^1.1.1"
"@vitejs/plugin-react-swc": "npm:^3.6.0"