[bemo] No public shared rooms in examples app (#4152)

As discussed in #4135 I'm doing a quick follow up to help me sleep at
night.

Sorry sorry sorry sorry

![Kapture 2024-07-12 at 12 47
45](https://github.com/user-attachments/assets/ee9babf0-6b7e-4ddb-a427-5aef9436f922)

i couldn't figure out the magic overlay css so I reverted to ugly static
toolbar. again so sorry

### Change type

- [ ] `bugfix`
- [x] `improvement`
- [ ] `feature`
- [ ] `api`
- [ ] `other`

### Test plan

1. Create a shape...
2.

- [ ] Unit tests
- [ ] End to end tests

### Release notes

- Fixed a bug with…
This commit is contained in:
David Sheldrick 2024-07-12 14:07:07 +01:00 committed by GitHub
parent 64e1fac897
commit 1c35ed27f9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 110 additions and 150 deletions

View file

@ -16,6 +16,12 @@ export default function ExampleCodeBlock({
const [isClientSide, setIsClientSide] = useState(false) const [isClientSide, setIsClientSide] = useState(false)
const { theme } = useTheme() const { theme } = useTheme()
useEffect(() => setIsClientSide(true), []) useEffect(() => setIsClientSide(true), [])
// This is to avoid hydration mismatch between the server and the client because of the useTheme.
if (!isClientSide) {
return null
}
const SERVER = const SERVER =
process.env.NODE_ENV === 'development' process.env.NODE_ENV === 'development'
? `http://${location.hostname}:5420` ? `http://${location.hostname}:5420`
@ -23,14 +29,12 @@ export default function ExampleCodeBlock({
? `https://examples-canary.tldraw.com` ? `https://examples-canary.tldraw.com`
: 'https://examples.tldraw.com' : 'https://examples.tldraw.com'
// This is to avoid hydration mismatch between the server and the client because of the useTheme.
if (!isClientSide) {
return null
}
return ( return (
<div className="code-example"> <div className="code-example">
<iframe src={`${SERVER}/${articleId}/full`} /> <iframe
src={`${SERVER}/${articleId}/full`}
allow={`clipboard-read; clipboard-write self ${SERVER}`}
/>
<SandpackProvider <SandpackProvider
className="sandpack" className="sandpack"
key={`sandpack-${theme}-${activeFile}`} key={`sandpack-${theme}-${activeFile}`}

View file

@ -1,4 +1,6 @@
import { useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { uniqueId } from 'tldraw'
import { Example } from './examples' import { Example } from './examples'
export function ExampleWrapper({ export function ExampleWrapper({
@ -15,7 +17,55 @@ export function ExampleWrapper({
return <MultiplayerExampleWrapper component={Component} example={example} /> return <MultiplayerExampleWrapper component={Component} example={example} />
} }
const hour = 60 * 1000 * 1000 interface ScopedRoomId {
roomId: string
deployId: string
}
function getExampleKey(exampleSlug: string) {
return `tldraw-example-room-${exampleSlug}`
}
function getRoomId(exampleSlug: string) {
const key = getExampleKey(exampleSlug)
const stored = JSON.parse(localStorage.getItem(key) ?? 'null') as null | ScopedRoomId
if (stored && stored.deployId === process.env.TLDRAW_DEPLOY_ID) {
return stored.roomId
}
return uniqueId()
}
function storeRoomId(exampleSlug: string, slug: string) {
const key = getExampleKey(exampleSlug)
localStorage.setItem(
key,
JSON.stringify({
roomId: slug,
deployId: process.env.TLDRAW_DEPLOY_ID!,
} satisfies ScopedRoomId)
)
}
function useConfirmState() {
const [confirm, setConfirm] = useState(false)
const [confirmTimeout, setConfirmTimeout] = useState<number | null>(null)
const confirmFn = useCallback(() => {
setConfirm(true)
setConfirmTimeout(window.setTimeout(() => setConfirm(false), 3000))
}, [])
useEffect(() => {
return () => {
if (confirmTimeout) {
clearTimeout(confirmTimeout)
}
}
}, [confirmTimeout])
return [confirm, confirmFn] as const
}
function MultiplayerExampleWrapper({ function MultiplayerExampleWrapper({
component: Component, component: Component,
example, example,
@ -23,60 +73,56 @@ function MultiplayerExampleWrapper({
example: Example example: Example
component: React.ComponentType<{ roomId?: string }> component: React.ComponentType<{ roomId?: string }>
}) { }) {
const prefix = `tldraw-example-${example.path.replace(/\//g, '-')}-${process.env.TLDRAW_DEPLOY_ID}` const [params, setParams] = useSearchParams()
const [confirm, confirmFn] = useConfirmState()
const [roomId, setRoomId] = useState(String(Math.floor(Date.now() / hour))) const exampleSlug = example.path.replace(/^\/?/g, '')
const [nextRoomId, setNextRoomId] = useState(roomId) let roomId = params.get('roomId')
if (!roomId || typeof roomId !== 'string' || encodeURIComponent(roomId) !== roomId) {
const trimmed = nextRoomId.trim() roomId = getRoomId(exampleSlug)
const canSet = trimmed && roomId !== trimmed
function setIfPossible() {
if (!canSet) return
setRoomId(trimmed)
} }
useEffect(() => {
storeRoomId(exampleSlug, roomId)
setParams({ roomId })
}, [exampleSlug, roomId, setParams])
return ( return (
<div className="MultiplayerExampleWrapper"> <div className="MultiplayerExampleWrapper">
<div className="MultiplayerExampleWrapper-picker"> <div className="MultiplayerExampleWrapper-picker">
<label> <WifiIcon />
<div>Room ID:</div> <div>Live Example</div>
<input <button
value={nextRoomId} onClick={() => {
onChange={(e) => setNextRoomId(e.target.value)} // copy current url with roomId=roomId to clipboard
onKeyDown={(e) => { navigator.clipboard.writeText(window.location.href.split('?')[0] + `?roomId=${roomId}`)
if (e.key === 'Enter' && canSet) { confirmFn()
e.preventDefault()
setIfPossible()
}
if (e.key === 'Escape') {
e.preventDefault()
setNextRoomId(roomId)
e.currentTarget.blur()
}
}} }}
/> aria-label="join"
</label> >
<button onClick={() => setIfPossible()} disabled={!canSet} aria-label="join"> {confirm ? 'Copied!' : 'Copy Share URL'}
<ArrowIcon />
</button> </button>
</div> </div>
<div className="MultiplayerExampleWrapper-example"> <div className="MultiplayerExampleWrapper-example">
<div> <Component roomId={roomId} />
<Component roomId={`${prefix}_${encodeURIComponent(roomId)}`} />
</div>
</div> </div>
</div> </div>
) )
} }
function ArrowIcon() { function WifiIcon() {
return ( return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg
<path xmlns="http://www.w3.org/2000/svg"
d="M9.25012 4.75L12.5001 8M12.5001 8L9.25012 11.25M12.5001 8H3.5" fill="none"
viewBox="0 0 24 24"
strokeWidth="1.5"
stroke="currentColor" stroke="currentColor"
width={16}
>
<path
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
d="M8.288 15.038a5.25 5.25 0 0 1 7.424 0M5.106 11.856c3.807-3.808 9.98-3.808 13.788 0M1.924 8.674c5.565-5.565 14.587-5.565 20.152 0M12.53 18.22l-.53.53-.53-.53a.75.75 0 0 1 1.06 0Z"
/> />
</svg> </svg>
) )

View file

@ -9,6 +9,4 @@ multiplayer: true
--- ---
The `useMultiplayerDemo` hook can be used to quickly prototype multiplayer experiences in tldraw, The `useMultiplayerDemo` hook can be used to quickly prototype multiplayer experiences in tldraw using a demo backend that we host. Data is wiped after one day.
although rooms will be deleted after one day. Note that the board below is shared with all visitors
to this page.

View file

@ -451,123 +451,35 @@ a.example__sidebar__header-link {
color-scheme: dark; color-scheme: dark;
} }
.MultiplayerExampleWrapper { .MultiplayerExampleWrapper {
position: absolute;
inset: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
container: example / size; container: example / size;
position: absolute;
inset: 0px;
} }
.MultiplayerExampleWrapper-picker { .MultiplayerExampleWrapper-picker {
position: absolute;
z-index: 1;
left: 0;
right: 0;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: center; justify-content: flex-start;
align-items: center; align-items: center;
padding: 8px; padding: 8px 16px;
padding-bottom: 7px; gap: 16px;
}
.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); background: var(--background);
/* Add space to the left the same as the button to keep the input centered */ border-bottom: 1px solid var(--gray-dark);
margin-left: 32px; color: var(--text);
} font-size: 14px;
.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 { .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); background: var(--black-transparent-lighter);
border: 1px solid var(--gray-dark);
border-radius: 4px; border-radius: 4px;
padding: 4px 12px;
cursor: pointer;
} }
.MultiplayerExampleWrapper-picker button:disabled { .MultiplayerExampleWrapper-picker button:hover {
opacity: 0.5; border: 1px solid var(--black-transparent-lighter);
pointer-events: none;
} }
.MultiplayerExampleWrapper-example { .MultiplayerExampleWrapper-example {
position: relative; position: relative;
height: 100%; flex: 1;
}
@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;
}
} }