[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:
parent
64e1fac897
commit
1c35ed27f9
4 changed files with 110 additions and 150 deletions
|
@ -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}`}
|
||||||
|
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
@ -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.
|
|
||||||
|
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue