snapshot of https://github.com/tldraw/tldraw.git (main, 2026-09-23; blobs > 131072B omitted)
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
This commit is contained in:
40
templates/sync-cloudflare/client/getBookmarkPreview.tsx
Normal file
40
templates/sync-cloudflare/client/getBookmarkPreview.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { AssetRecordType, TLAsset, TLBookmarkAsset, getHashForString } from 'tldraw'
|
||||
|
||||
// How does our server handle bookmark unfurling?
|
||||
export async function getBookmarkPreview({ url }: { url: string }): Promise<TLAsset> {
|
||||
// we start with an empty asset record
|
||||
const asset: TLBookmarkAsset = {
|
||||
id: AssetRecordType.createId(getHashForString(url)),
|
||||
typeName: 'asset',
|
||||
type: 'bookmark',
|
||||
meta: {},
|
||||
props: {
|
||||
src: url,
|
||||
description: '',
|
||||
image: '',
|
||||
favicon: '',
|
||||
title: '',
|
||||
},
|
||||
}
|
||||
|
||||
try {
|
||||
// try to fetch the preview data from the server
|
||||
const response = await fetch(`/api/unfurl?url=${encodeURIComponent(url)}`)
|
||||
const data: any = await response.json()
|
||||
|
||||
// fill in our asset with whatever info we found
|
||||
asset.props.description = data?.description ?? ''
|
||||
asset.props.image = data?.image ?? ''
|
||||
asset.props.favicon = data?.favicon ?? ''
|
||||
asset.props.title = data?.title ?? ''
|
||||
|
||||
// carry the social image's dimensions on `meta` so embeds (e.g. Vimeo/YouTube) can size
|
||||
// themselves to the content's real aspect ratio instead of staying letterboxed
|
||||
if (typeof data?.imageWidth === 'number') asset.meta.imageWidth = data.imageWidth
|
||||
if (typeof data?.imageHeight === 'number') asset.meta.imageHeight = data.imageHeight
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
|
||||
return asset
|
||||
}
|
||||
88
templates/sync-cloudflare/client/index.css
Normal file
88
templates/sync-cloudflare/client/index.css
Normal file
@@ -0,0 +1,88 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@500;700&display=swap');
|
||||
@import url('tldraw/tldraw.css');
|
||||
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
.RoomWrapper {
|
||||
--background: white;
|
||||
--text: hsl(0, 0%, 20%);
|
||||
--gray-light: #f5f5f5;
|
||||
--gray-dark: #e5e5e5;
|
||||
--black-transparent-lighter: rgba(0, 0, 0, 0.15);
|
||||
--black-transparent-light: rgba(0, 0, 0, 0.4);
|
||||
--black-transparent-dark: rgba(0, 0, 0, 0.6);
|
||||
--black-transparent-darker: rgba(0, 0, 0, 0.7);
|
||||
--focus: hsl(217, 89%, 61%);
|
||||
--whiteboard: hsl(240, 5%, 96%);
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
:root:has(.tl-theme__dark) .RoomWrapper {
|
||||
--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;
|
||||
}
|
||||
|
||||
.RoomWrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: absolute;
|
||||
inset: 0px;
|
||||
}
|
||||
|
||||
.RoomWrapper-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
padding: 8px 16px;
|
||||
gap: 16px;
|
||||
background: var(--background);
|
||||
border-bottom: 1px solid var(--gray-dark);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.RoomWrapper-copy {
|
||||
background: var(--black-transparent-lighter);
|
||||
border: 1px solid var(--gray-dark);
|
||||
border-radius: 4px;
|
||||
padding: 4px 12px;
|
||||
height: 24px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.RoomWrapper-copy:has(.RoomWrapper-copied) {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.RoomWrapper-copied {
|
||||
color: var(--text);
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.RoomWrapper-copy:hover {
|
||||
border: 1px solid var(--black-transparent-lighter);
|
||||
}
|
||||
|
||||
.RoomWrapper-content {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
}
|
||||
21
templates/sync-cloudflare/client/localStorage.ts
Normal file
21
templates/sync-cloudflare/client/localStorage.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Safe localStorage helpers that handle cases where localStorage is not available
|
||||
* or access is restricted by the browser.
|
||||
*/
|
||||
|
||||
export function getLocalStorageItem(key: string): string | null {
|
||||
try {
|
||||
return localStorage.getItem(key)
|
||||
} catch (error) {
|
||||
console.warn('Failed to access localStorage:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function setLocalStorageItem(key: string, value: string): void {
|
||||
try {
|
||||
localStorage.setItem(key, value)
|
||||
} catch (error) {
|
||||
console.warn('Failed to set localStorage item:', error)
|
||||
}
|
||||
}
|
||||
23
templates/sync-cloudflare/client/main.tsx
Normal file
23
templates/sync-cloudflare/client/main.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { createBrowserRouter, RouterProvider } from 'react-router-dom'
|
||||
import './index.css'
|
||||
import { Room } from './pages/Room'
|
||||
import { Root } from './pages/Root'
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
path: '/',
|
||||
element: <Root />,
|
||||
},
|
||||
{
|
||||
path: '/:roomId',
|
||||
element: <Room />,
|
||||
},
|
||||
])
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<RouterProvider router={router} />
|
||||
</React.StrictMode>
|
||||
)
|
||||
30
templates/sync-cloudflare/client/multiplayerAssetStore.tsx
Normal file
30
templates/sync-cloudflare/client/multiplayerAssetStore.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { TLAssetStore, uniqueId } from 'tldraw'
|
||||
|
||||
// How does our server handle assets like images and videos?
|
||||
export const multiplayerAssetStore: TLAssetStore = {
|
||||
// to upload an asset, we...
|
||||
async upload(_asset, file) {
|
||||
// ...create a unique name & URL...
|
||||
const objectName = `${uniqueId()}-${file.name}`.replace(/[^a-zA-Z0-9.]/g, '-')
|
||||
const url = `/api/uploads/${objectName}`
|
||||
|
||||
// ...POST it to out worker to upload it...
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
body: file,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to upload asset: ${response.statusText}`)
|
||||
}
|
||||
|
||||
// ...and return the URL to be stored with the asset record.
|
||||
return { src: url }
|
||||
},
|
||||
|
||||
// to retrieve an asset, we can just use the same URL. you could customize this to add extra
|
||||
// auth, or to serve optimized versions / sizes of the asset.
|
||||
resolve(asset) {
|
||||
return asset.props.src
|
||||
},
|
||||
}
|
||||
83
templates/sync-cloudflare/client/pages/Room.tsx
Normal file
83
templates/sync-cloudflare/client/pages/Room.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { useSync } from '@tldraw/sync'
|
||||
import { ReactNode, useEffect, useState } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { Tldraw } from 'tldraw'
|
||||
import { getBookmarkPreview } from '../getBookmarkPreview'
|
||||
import { multiplayerAssetStore } from '../multiplayerAssetStore'
|
||||
|
||||
export function Room() {
|
||||
const { roomId } = useParams<{ roomId: string }>()
|
||||
|
||||
// Create a store connected to multiplayer.
|
||||
const store = useSync({
|
||||
// We need to know the websockets URI...
|
||||
uri: `${window.location.origin}/api/connect/${roomId}`,
|
||||
// ...and how to handle static assets like images & videos
|
||||
assets: multiplayerAssetStore,
|
||||
})
|
||||
|
||||
return (
|
||||
<RoomWrapper roomId={roomId}>
|
||||
<Tldraw
|
||||
// we can pass the connected store into the Tldraw component which will handle
|
||||
// loading states & enable multiplayer UX like cursors & a presence menu
|
||||
store={store}
|
||||
options={{ deepLinks: true }}
|
||||
onMount={(editor) => {
|
||||
// when the editor is ready, we need to register our bookmark unfurling service
|
||||
editor.registerExternalAssetHandler('url', getBookmarkPreview)
|
||||
}}
|
||||
/>
|
||||
</RoomWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
function RoomWrapper({ children, roomId }: { children: ReactNode; roomId?: string }) {
|
||||
const [didCopy, setDidCopy] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!didCopy) return
|
||||
const timeout = setTimeout(() => setDidCopy(false), 3000)
|
||||
return () => clearTimeout(timeout)
|
||||
}, [didCopy])
|
||||
|
||||
return (
|
||||
<div className="RoomWrapper">
|
||||
<div className="RoomWrapper-header">
|
||||
<WifiIcon />
|
||||
<div>{roomId}</div>
|
||||
<button
|
||||
className="RoomWrapper-copy"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(window.location.href)
|
||||
setDidCopy(true)
|
||||
}}
|
||||
aria-label="copy room link"
|
||||
>
|
||||
Copy link
|
||||
{didCopy && <div className="RoomWrapper-copied">Copied!</div>}
|
||||
</button>
|
||||
</div>
|
||||
<div className="RoomWrapper-content">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function WifiIcon() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="1.5"
|
||||
stroke="currentColor"
|
||||
width={16}
|
||||
>
|
||||
<path
|
||||
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>
|
||||
)
|
||||
}
|
||||
10
templates/sync-cloudflare/client/pages/Root.tsx
Normal file
10
templates/sync-cloudflare/client/pages/Root.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Navigate } from 'react-router-dom'
|
||||
import { uniqueId } from 'tldraw'
|
||||
import { getLocalStorageItem, setLocalStorageItem } from '../localStorage'
|
||||
|
||||
const myLocalRoomId = getLocalStorageItem('my-local-room-id') ?? 'test-room-' + uniqueId()
|
||||
setLocalStorageItem('my-local-room-id', myLocalRoomId)
|
||||
|
||||
export function Root() {
|
||||
return <Navigate to={`/${myLocalRoomId}`} />
|
||||
}
|
||||
1
templates/sync-cloudflare/client/vite-env.d.ts
vendored
Normal file
1
templates/sync-cloudflare/client/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user