[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 { theme } = useTheme()
|
||||
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 =
|
||||
process.env.NODE_ENV === 'development'
|
||||
? `http://${location.hostname}:5420`
|
||||
|
@ -23,14 +29,12 @@ export default function ExampleCodeBlock({
|
|||
? `https://examples-canary.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 (
|
||||
<div className="code-example">
|
||||
<iframe src={`${SERVER}/${articleId}/full`} />
|
||||
<iframe
|
||||
src={`${SERVER}/${articleId}/full`}
|
||||
allow={`clipboard-read; clipboard-write self ${SERVER}`}
|
||||
/>
|
||||
<SandpackProvider
|
||||
className="sandpack"
|
||||
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'
|
||||
|
||||
export function ExampleWrapper({
|
||||
|
@ -15,7 +17,55 @@ export function ExampleWrapper({
|
|||
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({
|
||||
component: Component,
|
||||
example,
|
||||
|
@ -23,60 +73,56 @@ function MultiplayerExampleWrapper({
|
|||
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)
|
||||
const [params, setParams] = useSearchParams()
|
||||
const [confirm, confirmFn] = useConfirmState()
|
||||
const exampleSlug = example.path.replace(/^\/?/g, '')
|
||||
let roomId = params.get('roomId')
|
||||
if (!roomId || typeof roomId !== 'string' || encodeURIComponent(roomId) !== roomId) {
|
||||
roomId = getRoomId(exampleSlug)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
storeRoomId(exampleSlug, roomId)
|
||||
setParams({ roomId })
|
||||
}, [exampleSlug, roomId, setParams])
|
||||
|
||||
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 />
|
||||
<WifiIcon />
|
||||
<div>Live Example</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
// copy current url with roomId=roomId to clipboard
|
||||
navigator.clipboard.writeText(window.location.href.split('?')[0] + `?roomId=${roomId}`)
|
||||
confirmFn()
|
||||
}}
|
||||
aria-label="join"
|
||||
>
|
||||
{confirm ? 'Copied!' : 'Copy Share URL'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="MultiplayerExampleWrapper-example">
|
||||
<div>
|
||||
<Component roomId={`${prefix}_${encodeURIComponent(roomId)}`} />
|
||||
</div>
|
||||
<Component roomId={roomId} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ArrowIcon() {
|
||||
function WifiIcon() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="1.5"
|
||||
stroke="currentColor"
|
||||
width={16}
|
||||
>
|
||||
<path
|
||||
d="M9.25012 4.75L12.5001 8M12.5001 8L9.25012 11.25M12.5001 8H3.5"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="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>
|
||||
)
|
||||
|
|
|
@ -9,6 +9,4 @@ 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.
|
||||
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.
|
||||
|
|
|
@ -451,123 +451,35 @@ a.example__sidebar__header-link {
|
|||
color-scheme: dark;
|
||||
}
|
||||
.MultiplayerExampleWrapper {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
container: example / size;
|
||||
position: absolute;
|
||||
inset: 0px;
|
||||
}
|
||||
.MultiplayerExampleWrapper-picker {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
justify-content: flex-start;
|
||||
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;
|
||||
padding: 8px 16px;
|
||||
gap: 16px;
|
||||
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;
|
||||
border-bottom: 1px solid var(--gray-dark);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
}
|
||||
.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: 1px solid var(--gray-dark);
|
||||
border-radius: 4px;
|
||||
padding: 4px 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.MultiplayerExampleWrapper-picker button:disabled {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
.MultiplayerExampleWrapper-picker button:hover {
|
||||
border: 1px solid var(--black-transparent-lighter);
|
||||
}
|
||||
.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;
|
||||
}
|
||||
flex: 1;
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue