Merge branch 'main' into desmos-embed

pull/3608/head
fakerr 2024-04-26 08:14:33 +10:00 zatwierdzone przez GitHub
commit fcca4c57f2
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: B5690EEEBB952194
90 zmienionych plików z 787 dodań i 926 usunięć

Wyświetl plik

@ -23,6 +23,7 @@
"dependencies": {
"@supabase/auth-helpers-remix": "^0.2.2",
"@supabase/supabase-js": "^2.33.2",
"@tldraw/dotcom-shared": "workspace:*",
"@tldraw/store": "workspace:*",
"@tldraw/tlschema": "workspace:*",
"@tldraw/tlsync": "workspace:*",

Wyświetl plik

@ -2,8 +2,11 @@
/// <reference types="@cloudflare/workers-types" />
import { SupabaseClient } from '@supabase/supabase-js'
import { ROOM_OPEN_MODE, type RoomOpenMode } from '@tldraw/dotcom-shared'
import {
DBLoadResultType,
RoomSnapshot,
TLCloseEventCode,
TLServer,
TLServerEvent,
TLSyncRoom,
@ -19,6 +22,7 @@ import { PERSIST_INTERVAL_MS } from './config'
import { getR2KeyForRoom } from './r2'
import { Analytics, Environment } from './types'
import { createSupabaseClient } from './utils/createSupabaseClient'
import { getSlug } from './utils/roomOpenMode'
import { throttle } from './utils/throttle'
const MAX_CONNECTIONS = 50
@ -88,12 +92,22 @@ export class TLDrawDurableObject extends TLServer {
readonly router = Router()
.get(
'/r/:roomId',
(req) => this.extractDocumentInfoFromRequest(req),
(req) => this.extractDocumentInfoFromRequest(req, ROOM_OPEN_MODE.READ_WRITE),
(req) => this.onRequest(req)
)
.get(
'/v/:roomId',
(req) => this.extractDocumentInfoFromRequest(req, ROOM_OPEN_MODE.READ_ONLY_LEGACY),
(req) => this.onRequest(req)
)
.get(
'/ro/:roomId',
(req) => this.extractDocumentInfoFromRequest(req, ROOM_OPEN_MODE.READ_ONLY),
(req) => this.onRequest(req)
)
.post(
'/r/:roomId/restore',
(req) => this.extractDocumentInfoFromRequest(req),
(req) => this.extractDocumentInfoFromRequest(req, ROOM_OPEN_MODE.READ_WRITE),
(req) => this.onRestore(req)
)
.all('*', () => new Response('Not found', { status: 404 }))
@ -113,8 +127,11 @@ export class TLDrawDurableObject extends TLServer {
get documentInfo() {
return assertExists(this._documentInfo, 'documentInfo must be present')
}
extractDocumentInfoFromRequest = async (req: IRequest) => {
const slug = assertExists(req.params.roomId, 'roomId must be present')
extractDocumentInfoFromRequest = async (req: IRequest, roomOpenMode: RoomOpenMode) => {
const slug = assertExists(
await getSlug(this.env, req.params.roomId, roomOpenMode),
'roomId must be present'
)
if (this._documentInfo) {
assert(this._documentInfo.slug === slug, 'slug must match')
} else {
@ -226,9 +243,10 @@ export class TLDrawDurableObject extends TLServer {
const { 0: clientWebSocket, 1: serverWebSocket } = new WebSocketPair()
// Handle the connection (see TLServer)
let connectionResult: DBLoadResultType
try {
// block concurrency while initializing the room if that needs to happen
await this.controller.blockConcurrencyWhile(() =>
connectionResult = await this.controller.blockConcurrencyWhile(() =>
this.handleConnection({
socket: serverWebSocket as any,
persistenceKey: this.documentInfo.slug!,
@ -253,6 +271,12 @@ export class TLDrawDurableObject extends TLServer {
this.schedulePersist()
})
if (connectionResult === 'room_not_found') {
// If the room is not found, we need to accept and then immediately close the connection
// with our custom close code.
serverWebSocket.close(TLCloseEventCode.NOT_FOUND, 'Room not found')
}
return new Response(null, { status: 101, webSocket: clientWebSocket })
}

Wyświetl plik

@ -1,24 +1,22 @@
import { SerializedSchema, SerializedStore } from '@tldraw/store'
import { TLRecord } from '@tldraw/tlschema'
import { CreateRoomRequestBody } from '@tldraw/dotcom-shared'
import { RoomSnapshot, schema } from '@tldraw/tlsync'
import { IRequest } from 'itty-router'
import { nanoid } from 'nanoid'
import { getR2KeyForRoom } from '../r2'
import { Environment } from '../types'
import { validateSnapshot } from '../utils/validateSnapshot'
type SnapshotRequestBody = {
schema: SerializedSchema
snapshot: SerializedStore<TLRecord>
}
import { isAllowedOrigin } from '../worker'
// Sets up a new room based on a provided snapshot, e.g. when a user clicks the "Share" buttons or the "Fork project" buttons.
export async function createRoom(request: IRequest, env: Environment): Promise<Response> {
// The data sent from the client will include the data for the new room
const data = (await request.json()) as SnapshotRequestBody
const data = (await request.json()) as CreateRoomRequestBody
if (!isAllowedOrigin(data.origin)) {
return Response.json({ error: true, message: 'Not allowed' }, { status: 406 })
}
// There's a chance the data will be invalid, so we check it first
const snapshotResult = validateSnapshot(data)
const snapshotResult = validateSnapshot(data.snapshot)
if (!snapshotResult.ok) {
return Response.json({ error: true, message: snapshotResult.error }, { status: 400 })
}
@ -40,6 +38,11 @@ export async function createRoom(request: IRequest, env: Environment): Promise<R
// Bang that snapshot into the database
await env.ROOMS.put(getR2KeyForRoom(slug), JSON.stringify(snapshot))
// Create a readonly slug and store it
const readonlySlug = nanoid()
await env.SLUG_TO_READONLY_SLUG.put(slug, readonlySlug)
await env.READONLY_SLUG_TO_SLUG.put(readonlySlug, slug)
// Send back the slug so that the client can redirect to the new room
return new Response(JSON.stringify({ error: false, slug }))
}

Wyświetl plik

@ -1,5 +1,4 @@
import { SerializedSchema, SerializedStore } from '@tldraw/store'
import { TLRecord } from '@tldraw/tlschema'
import { CreateSnapshotRequestBody } from '@tldraw/dotcom-shared'
import { IRequest } from 'itty-router'
import { nanoid } from 'nanoid'
import { Environment } from '../types'
@ -7,12 +6,6 @@ import { createSupabaseClient, noSupabaseSorry } from '../utils/createSupabaseCl
import { getSnapshotsTable } from '../utils/getSnapshotsTable'
import { validateSnapshot } from '../utils/validateSnapshot'
type CreateSnapshotRequestBody = {
schema: SerializedSchema
snapshot: SerializedStore<TLRecord>
parent_slug?: string | string[] | undefined
}
export async function createRoomSnapshot(request: IRequest, env: Environment): Promise<Response> {
const data = (await request.json()) as CreateSnapshotRequestBody

Wyświetl plik

@ -0,0 +1,30 @@
import { GetReadonlySlugResponseBody } from '@tldraw/dotcom-shared'
import { lns } from '@tldraw/utils'
import { IRequest } from 'itty-router'
import { Environment } from '../types'
// Return a URL to a readonly version of the room
export async function getReadonlySlug(request: IRequest, env: Environment): Promise<Response> {
const roomId = request.params.roomId
if (!roomId) {
return new Response('Bad request', {
status: 400,
})
}
let slug = await env.SLUG_TO_READONLY_SLUG.get(roomId)
let isLegacy = false
if (!slug) {
// For all newly created rooms we add the readonly slug to the KV store.
// If it does not exist there it means we are trying to get a slug for an old room.
slug = lns(roomId)
isLegacy = true
}
return new Response(
JSON.stringify({
slug,
isLegacy,
} satisfies GetReadonlySlugResponseBody)
)
}

Wyświetl plik

@ -1,11 +1,16 @@
import { RoomOpenMode } from '@tldraw/dotcom-shared'
import { IRequest } from 'itty-router'
import { Environment } from '../types'
import { fourOhFour } from '../utils/fourOhFour'
import { isRoomIdTooLong, roomIdIsTooLong } from '../utils/roomIdIsTooLong'
import { getSlug } from '../utils/roomOpenMode'
// This is the entry point for joining an existing room
export async function joinExistingRoom(request: IRequest, env: Environment): Promise<Response> {
const roomId = request.params.roomId
export async function joinExistingRoom(
request: IRequest,
env: Environment,
roomOpenMode: RoomOpenMode
): Promise<Response> {
const roomId = await getSlug(env, request.params.roomId, roomOpenMode)
if (!roomId) return fourOhFour()
if (isRoomIdTooLong(roomId)) return roomIdIsTooLong()

Wyświetl plik

@ -17,6 +17,9 @@ export interface Environment {
ROOMS: R2Bucket
ROOMS_HISTORY_EPHEMERAL: R2Bucket
SLUG_TO_READONLY_SLUG: KVNamespace
READONLY_SLUG_TO_SLUG: KVNamespace
// env vars
SUPABASE_URL: string | undefined
SUPABASE_KEY: string | undefined

Wyświetl plik

@ -0,0 +1,17 @@
import { ROOM_OPEN_MODE, RoomOpenMode } from '@tldraw/dotcom-shared'
import { exhaustiveSwitchError, lns } from '@tldraw/utils'
import { Environment } from '../types'
export async function getSlug(env: Environment, slug: string | null, roomOpenMode: RoomOpenMode) {
if (!slug) return null
switch (roomOpenMode) {
case ROOM_OPEN_MODE.READ_WRITE:
return slug
case ROOM_OPEN_MODE.READ_ONLY:
return await env.READONLY_SLUG_TO_SLUG.get(slug)
case ROOM_OPEN_MODE.READ_ONLY_LEGACY:
return lns(slug)
default:
exhaustiveSwitchError(roomOpenMode)
}
}

Wyświetl plik

@ -1,11 +1,13 @@
/// <reference no-default-lib="true"/>
/// <reference types="@cloudflare/workers-types" />
import { ROOM_OPEN_MODE } from '@tldraw/dotcom-shared'
import { Router, createCors } from 'itty-router'
import { env } from 'process'
import Toucan from 'toucan-js'
import { createRoom } from './routes/createRoom'
import { createRoomSnapshot } from './routes/createRoomSnapshot'
import { forwardRoomRequest } from './routes/forwardRoomRequest'
import { getReadonlySlug } from './routes/getReadonlySlug'
import { getRoomHistory } from './routes/getRoomHistory'
import { getRoomHistorySnapshot } from './routes/getRoomHistorySnapshot'
import { getRoomSnapshot } from './routes/getRoomSnapshot'
@ -24,9 +26,12 @@ const router = Router()
.post('/new-room', createRoom)
.post('/snapshots', createRoomSnapshot)
.get('/snapshot/:roomId', getRoomSnapshot)
.get('/r/:roomId', joinExistingRoom)
.get('/r/:roomId', (req, env) => joinExistingRoom(req, env, ROOM_OPEN_MODE.READ_WRITE))
.get('/v/:roomId', (req, env) => joinExistingRoom(req, env, ROOM_OPEN_MODE.READ_ONLY_LEGACY))
.get('/ro/:roomId', (req, env) => joinExistingRoom(req, env, ROOM_OPEN_MODE.READ_ONLY))
.get('/r/:roomId/history', getRoomHistory)
.get('/r/:roomId/history/:timestamp', getRoomHistorySnapshot)
.get('/readonly-slug/:roomId', getReadonlySlug)
.post('/r/:roomId/restore', forwardRoomRequest)
.all('*', fourOhFour)
@ -70,7 +75,7 @@ const Worker = {
},
}
function isAllowedOrigin(origin: string) {
export function isAllowedOrigin(origin: string) {
if (origin === 'http://localhost:3000') return true
if (origin === 'http://localhost:5420') return true
if (origin.endsWith('.tldraw.com')) return true

Wyświetl plik

@ -7,6 +7,9 @@
"emitDeclarationOnly": false
},
"references": [
{
"path": "../../packages/dotcom-shared"
},
{
"path": "../../packages/store"
},

Wyświetl plik

@ -114,3 +114,36 @@ bucket_name = "rooms-history-ephemeral-preview"
[[env.production.r2_buckets]]
binding = "ROOMS_HISTORY_EPHEMERAL"
bucket_name = "rooms-history-ephemeral"
#################### Key value storage ####################
[[env.dev.kv_namespaces]]
binding = "SLUG_TO_READONLY_SLUG"
id = "847a6bded62045c6808dda6a275ef96c"
[[env.dev.kv_namespaces]]
binding = "READONLY_SLUG_TO_SLUG"
id = "0a83acab40374ccd918cc9d755741714"
[[env.preview.kv_namespaces]]
binding = "SLUG_TO_READONLY_SLUG"
id = "847a6bded62045c6808dda6a275ef96c"
[[env.preview.kv_namespaces]]
binding = "READONLY_SLUG_TO_SLUG"
id = "0a83acab40374ccd918cc9d755741714"
[[env.staging.kv_namespaces]]
binding = "SLUG_TO_READONLY_SLUG"
id = "847a6bded62045c6808dda6a275ef96c"
[[env.staging.kv_namespaces]]
binding = "READONLY_SLUG_TO_SLUG"
id = "0a83acab40374ccd918cc9d755741714"
[[env.production.kv_namespaces]]
binding = "SLUG_TO_READONLY_SLUG"
id = "2fb5fc7f7ca54a5a9dfae1b07a30a778"
[[env.production.kv_namespaces]]
binding = "READONLY_SLUG_TO_SLUG"
id = "96be6637b281412ab35b2544539d78e8"

Wyświetl plik

@ -23,6 +23,7 @@
"@sentry/integrations": "^7.34.0",
"@sentry/react": "^7.77.0",
"@tldraw/assets": "workspace:*",
"@tldraw/dotcom-shared": "workspace:*",
"@tldraw/tlsync": "workspace:*",
"@tldraw/utils": "workspace:*",
"@vercel/analytics": "^1.1.1",

Wyświetl plik

@ -8,6 +8,7 @@ import json5 from 'json5'
import { nicelog } from '../../../scripts/lib/nicelog'
import { T } from '@tldraw/validate'
import { getMultiplayerServerURL } from '../vite.config'
// We load the list of routes that should be forwarded to our SPA's index.html here.
// It uses a jest snapshot file because deriving the set of routes from our
@ -56,9 +57,7 @@ async function build() {
// rewrite api calls to the multiplayer server
{
src: '^/api(/(.*))?$',
dest: `${
process.env.MULTIPLAYER_SERVER?.replace(/^ws/, 'http') ?? 'http://127.0.0.1:8787'
}$1`,
dest: `${getMultiplayerServerURL()}$1`,
check: true,
},
// cache static assets immutably

Wyświetl plik

@ -26,6 +26,10 @@ exports[`the_routes 1`] = `
"reactRouterPattern": "/r/:roomId",
"vercelRouterPattern": "^/r/[^/]*/?$",
},
{
"reactRouterPattern": "/ro/:roomId",
"vercelRouterPattern": "^/ro/[^/]*/?$",
},
{
"reactRouterPattern": "/s/:roomId",
"vercelRouterPattern": "^/s/[^/]*/?$",

Wyświetl plik

@ -1,4 +1,5 @@
import { Link } from 'react-router-dom'
import { isInIframe } from '../../utils/iFrame'
export function ErrorPage({
icon,
@ -6,8 +7,8 @@ export function ErrorPage({
}: {
icon?: boolean
messages: { header: string; para1: string; para2?: string }
redirectTo?: string
}) {
const inIframe = isInIframe()
return (
<div className="error-page">
<div className="error-page__container">
@ -19,8 +20,8 @@ export function ErrorPage({
<p>{messages.para1}</p>
{messages.para2 && <p>{messages.para2}</p>}
</div>
<Link to={'/'}>
<a>Take me home.</a>
<Link to={'/'} target={inIframe ? '_blank' : '_self'}>
{inIframe ? 'Open tldraw.' : 'Back to tldraw.'}
</Link>
</div>
</div>

Wyświetl plik

@ -2,12 +2,13 @@ import { ReactNode, useEffect, useState } from 'react'
import { LoadingScreen } from 'tldraw'
import { version } from '../../version'
import { useUrl } from '../hooks/useUrl'
import { getParentOrigin, isInIframe } from '../utils/iFrame'
import { trackAnalyticsEvent } from '../utils/trackAnalyticsEvent'
/*
If we're in an iframe, we need to figure out whether we're on a whitelisted host (e.g. tldraw itself)
or a not-allowed host (e.g. someone else's website). Some websites embed tldraw in iframes and this is kinda
risky for us and for them, tooand hey, if we decide to offer a hosted thing, then that's another stor
risky for us and for them, tooand hey, if we decide to offer a hosted thing, then that's another story.
Figuring this out is a little tricky because the same code here is going to run on:
- the website as a top window (tldraw-top)
@ -26,33 +27,45 @@ and we should show an annoying messsage.
If we're not in an iframe, we don't need to do anything.
*/
export const ROOM_CONTEXT = {
PUBLIC_MULTIPLAYER: 'public-multiplayer',
PUBLIC_READONLY: 'public-readonly',
PUBLIC_SNAPSHOT: 'public-snapshot',
HISTORY_SNAPSHOT: 'history-snapshot',
HISTORY: 'history',
LOCAL: 'local',
} as const
type $ROOM_CONTEXT = (typeof ROOM_CONTEXT)[keyof typeof ROOM_CONTEXT]
const EMBEDDED_STATE = {
IFRAME_UNKNOWN: 'iframe-unknown',
IFRAME_NOT_ALLOWED: 'iframe-not-allowed',
NOT_IFRAME: 'not-iframe',
IFRAME_OK: 'iframe-ok',
} as const
type $EMBEDDED_STATE = (typeof EMBEDDED_STATE)[keyof typeof EMBEDDED_STATE]
// Which routes do we allow to be embedded in tldraw.com itself?
const WHITELIST_CONTEXT = ['public-multiplayer', 'public-readonly', 'public-snapshot']
const WHITELIST_CONTEXT: $ROOM_CONTEXT[] = [
ROOM_CONTEXT.PUBLIC_MULTIPLAYER,
ROOM_CONTEXT.PUBLIC_READONLY,
ROOM_CONTEXT.PUBLIC_SNAPSHOT,
]
const EXPECTED_QUESTION = 'are we cool?'
const EXPECTED_RESPONSE = 'yes' + version
const isInIframe = () => {
return typeof window !== 'undefined' && (window !== window.top || window.self !== window.parent)
}
export function IFrameProtector({
slug,
context,
children,
}: {
slug: string
context:
| 'public-multiplayer'
| 'public-readonly'
| 'public-snapshot'
| 'history-snapshot'
| 'history'
| 'local'
context: $ROOM_CONTEXT
children: ReactNode
}) {
const [embeddedState, setEmbeddedState] = useState<
'iframe-unknown' | 'iframe-not-allowed' | 'not-iframe' | 'iframe-ok'
>(isInIframe() ? 'iframe-unknown' : 'not-iframe')
const [embeddedState, setEmbeddedState] = useState<$EMBEDDED_STATE>(
isInIframe() ? EMBEDDED_STATE.IFRAME_UNKNOWN : EMBEDDED_STATE.NOT_IFRAME
)
const url = useUrl()
@ -76,24 +89,28 @@ export function IFrameProtector({
if (event.data === EXPECTED_RESPONSE) {
// todo: check the origin?
setEmbeddedState('iframe-ok')
setEmbeddedState(EMBEDDED_STATE.IFRAME_OK)
clearTimeout(timeout)
}
}
window.addEventListener('message', handleMessageEvent, false)
if (embeddedState === 'iframe-unknown') {
if (embeddedState === EMBEDDED_STATE.IFRAME_UNKNOWN) {
// We iframe embeddings on multiplayer or readonly
if (WHITELIST_CONTEXT.includes(context)) {
window.parent.postMessage(EXPECTED_QUESTION, '*') // todo: send to a specific origin?
timeout = setTimeout(() => {
setEmbeddedState('iframe-not-allowed')
trackAnalyticsEvent('connect_to_room_in_iframe', { slug, context })
setEmbeddedState(EMBEDDED_STATE.IFRAME_NOT_ALLOWED)
trackAnalyticsEvent('connect_to_room_in_iframe', {
slug,
context,
origin: getParentOrigin(),
})
}, 1000)
} else {
// We don't allow iframe embeddings on other routes
setEmbeddedState('iframe-not-allowed')
setEmbeddedState(EMBEDDED_STATE.IFRAME_NOT_ALLOWED)
}
}
@ -103,12 +120,12 @@ export function IFrameProtector({
}
}, [embeddedState, slug, context])
if (embeddedState === 'iframe-unknown') {
if (embeddedState === EMBEDDED_STATE.IFRAME_UNKNOWN) {
// We're in an iframe, but we don't know if it's a tldraw iframe
return <LoadingScreen>Loading in an iframe...</LoadingScreen>
return <LoadingScreen>Loading in an iframe</LoadingScreen>
}
if (embeddedState === 'iframe-not-allowed') {
if (embeddedState === EMBEDDED_STATE.IFRAME_NOT_ALLOWED) {
// We're in an iframe and its not one of ours
return (
<div className="tldraw__editor tl-container">

Wyświetl plik

@ -1,3 +1,4 @@
import { ROOM_OPEN_MODE, RoomOpenModeToPath, type RoomOpenMode } from '@tldraw/dotcom-shared'
import { useCallback, useEffect } from 'react'
import {
DefaultContextMenu,
@ -18,7 +19,6 @@ import {
TldrawUiMenuItem,
ViewSubmenu,
atom,
lns,
useActions,
useValue,
} from 'tldraw'
@ -104,19 +104,17 @@ const components: TLComponents = {
}
export function MultiplayerEditor({
isReadOnly,
roomOpenMode,
roomSlug,
}: {
isReadOnly: boolean
roomOpenMode: RoomOpenMode
roomSlug: string
}) {
const handleUiEvent = useHandleUiEvents()
const roomId = isReadOnly ? lns(roomSlug) : roomSlug
const storeWithStatus = useRemoteSyncClient({
uri: `${MULTIPLAYER_SERVER}/r/${roomId}`,
roomId,
uri: `${MULTIPLAYER_SERVER}/${RoomOpenModeToPath[roomOpenMode]}/${roomSlug}`,
roomId: roomSlug,
})
const isOffline =
@ -128,16 +126,22 @@ export function MultiplayerEditor({
const sharingUiOverrides = useSharing()
const fileSystemUiOverrides = useFileSystem({ isMultiplayer: true })
const cursorChatOverrides = useCursorChat()
const isReadonly =
roomOpenMode === ROOM_OPEN_MODE.READ_ONLY || roomOpenMode === ROOM_OPEN_MODE.READ_ONLY_LEGACY
const handleMount = useCallback(
(editor: Editor) => {
;(window as any).app = editor
;(window as any).editor = editor
editor.updateInstanceState({ isReadonly: isReadOnly })
if (!isReadonly) {
;(window as any).app = editor
;(window as any).editor = editor
}
editor.updateInstanceState({
isReadonly,
})
editor.registerExternalAssetHandler('file', createAssetFromFile)
editor.registerExternalAssetHandler('url', createAssetFromUrl)
},
[isReadOnly]
[isReadonly]
)
if (storeWithStatus.error) {
@ -151,7 +155,7 @@ export function MultiplayerEditor({
assetUrls={assetUrls}
onMount={handleMount}
overrides={[sharingUiOverrides, fileSystemUiOverrides, cursorChatOverrides]}
initialState={isReadOnly ? 'hand' : 'select'}
initialState={isReadonly ? 'hand' : 'select'}
onUiEvent={handleUiEvent}
components={components}
autoFocus

Wyświetl plik

@ -1,4 +1,9 @@
import * as Popover from '@radix-ui/react-popover'
import {
GetReadonlySlugResponseBody,
ROOM_OPEN_MODE,
RoomOpenModeToPath,
} from '@tldraw/dotcom-shared'
import React, { useEffect, useState } from 'react'
import {
TldrawUiMenuContextProvider,
@ -15,29 +20,71 @@ import { createQRCodeImageDataString } from '../utils/qrcode'
import { SHARE_PROJECT_ACTION, SHARE_SNAPSHOT_ACTION } from '../utils/sharing'
import { ShareButton } from './ShareButton'
const SHARE_CURRENT_STATE = {
OFFLINE: 'offline',
SHARED_READ_WRITE: 'shared-read-write',
SHARED_READ_ONLY: 'shared-read-only',
} as const
type ShareCurrentState = (typeof SHARE_CURRENT_STATE)[keyof typeof SHARE_CURRENT_STATE]
type ShareState = {
state: 'offline' | 'shared' | 'readonly'
state: ShareCurrentState
qrCodeDataUrl: string
url: string
readonlyUrl: string
readonlyUrl: string | null
readonlyQrCodeDataUrl: string
}
function isSharedReadonlyUrl(pathname: string) {
return (
pathname.startsWith(`/${RoomOpenModeToPath[ROOM_OPEN_MODE.READ_ONLY]}/`) ||
pathname.startsWith(`/${RoomOpenModeToPath[ROOM_OPEN_MODE.READ_ONLY_LEGACY]}/`)
)
}
function isSharedReadWriteUrl(pathname: string) {
return pathname.startsWith('/r/')
}
function getFreshShareState(): ShareState {
const isShared = window.location.href.includes('/r/')
const isReadOnly = window.location.href.includes('/v/')
const isSharedReadWrite = isSharedReadWriteUrl(window.location.pathname)
const isSharedReadOnly = isSharedReadonlyUrl(window.location.pathname)
return {
state: isShared ? 'shared' : isReadOnly ? 'readonly' : 'offline',
state: isSharedReadWrite
? SHARE_CURRENT_STATE.SHARED_READ_WRITE
: isSharedReadOnly
? SHARE_CURRENT_STATE.SHARED_READ_ONLY
: SHARE_CURRENT_STATE.OFFLINE,
url: window.location.href,
readonlyUrl: window.location.href.includes('/r/')
? getShareUrl(window.location.href, true)
: window.location.href,
readonlyUrl: isSharedReadOnly ? window.location.href : null,
qrCodeDataUrl: '',
readonlyQrCodeDataUrl: '',
}
}
async function getReadonlyUrl() {
const pathname = window.location.pathname
const isReadOnly = isSharedReadonlyUrl(pathname)
if (isReadOnly) return window.location.href
const segments = pathname.split('/')
const roomId = segments[2]
const result = await fetch(`/api/readonly-slug/${roomId}`)
if (!result.ok) return
const data = (await result.json()) as GetReadonlySlugResponseBody
if (!data.slug) return
segments[1] =
RoomOpenModeToPath[data.isLegacy ? ROOM_OPEN_MODE.READ_ONLY_LEGACY : ROOM_OPEN_MODE.READ_ONLY]
segments[2] = data.slug
const newPathname = segments.join('/')
return `${window.location.origin}${newPathname}${window.location.search}`
}
/** @public */
export const ShareMenu = React.memo(function ShareMenu() {
const msg = useTranslation()
@ -50,25 +97,24 @@ export const ShareMenu = React.memo(function ShareMenu() {
const [isUploading, setIsUploading] = useState(false)
const [isUploadingSnapshot, setIsUploadingSnapshot] = useState(false)
const [isReadOnlyLink, setIsReadOnlyLink] = useState(shareState.state === 'readonly')
const isReadOnlyLink = shareState.state === SHARE_CURRENT_STATE.SHARED_READ_ONLY
const currentShareLinkUrl = isReadOnlyLink ? shareState.readonlyUrl : shareState.url
const currentQrCodeUrl = isReadOnlyLink
? shareState.readonlyQrCodeDataUrl
: shareState.qrCodeDataUrl
const [didCopy, setDidCopy] = useState(false)
const [didCopyReadonlyLink, setDidCopyReadonlyLink] = useState(false)
const [didCopySnapshotLink, setDidCopySnapshotLink] = useState(false)
useEffect(() => {
if (shareState.state === 'offline') {
if (shareState.state === SHARE_CURRENT_STATE.OFFLINE) {
return
}
let cancelled = false
const shareUrl = getShareUrl(window.location.href, false)
const readonlyShareUrl = getShareUrl(window.location.href, true)
if (!shareState.qrCodeDataUrl && shareState.state === 'shared') {
if (!shareState.qrCodeDataUrl && shareState.state === SHARE_CURRENT_STATE.SHARED_READ_WRITE) {
// Fetch the QR code data URL
createQRCodeImageDataString(shareUrl).then((dataUrl) => {
if (!cancelled) {
@ -77,14 +123,16 @@ export const ShareMenu = React.memo(function ShareMenu() {
})
}
if (!shareState.readonlyQrCodeDataUrl) {
// fetch the readonly QR code data URL
createQRCodeImageDataString(readonlyShareUrl).then((dataUrl) => {
if (!cancelled) {
setShareState((s) => ({ ...s, readonlyShareUrl, readonlyQrCodeDataUrl: dataUrl }))
}
})
}
getReadonlyUrl().then((readonlyUrl) => {
if (readonlyUrl && !shareState.readonlyQrCodeDataUrl) {
// fetch the readonly QR code data URL
createQRCodeImageDataString(readonlyUrl).then((dataUrl) => {
if (!cancelled) {
setShareState((s) => ({ ...s, readonlyUrl, readonlyQrCodeDataUrl: dataUrl }))
}
})
}
})
const interval = setInterval(() => {
const url = window.location.href
@ -115,7 +163,8 @@ export const ShareMenu = React.memo(function ShareMenu() {
alignOffset={4}
>
<TldrawUiMenuContextProvider type="panel" sourceId="share-menu">
{shareState.state === 'shared' || shareState.state === 'readonly' ? (
{shareState.state === SHARE_CURRENT_STATE.SHARED_READ_WRITE ||
shareState.state === SHARE_CURRENT_STATE.SHARED_READ_ONLY ? (
<>
<button
className="tlui-share-zone__qr-code"
@ -124,41 +173,42 @@ export const ShareMenu = React.memo(function ShareMenu() {
isReadOnlyLink ? 'share-menu.copy-readonly-link' : 'share-menu.copy-link'
)}
onClick={() => {
if (!currentShareLinkUrl) return
setDidCopy(true)
setTimeout(() => setDidCopy(false), 1000)
navigator.clipboard.writeText(currentShareLinkUrl)
}}
/>
<TldrawUiMenuGroup id="copy">
<TldrawUiMenuItem
id="copy-to-clipboard"
readonlyOk
icon={didCopy ? 'clipboard-copied' : 'clipboard-copy'}
label={
isReadOnlyLink ? 'share-menu.copy-readonly-link' : 'share-menu.copy-link'
}
onSelect={() => {
setDidCopy(true)
setTimeout(() => setDidCopy(false), 750)
navigator.clipboard.writeText(currentShareLinkUrl)
}}
/>
{shareState.state === 'shared' && (
{shareState.state === SHARE_CURRENT_STATE.SHARED_READ_WRITE && (
<TldrawUiMenuItem
id="toggle-read-only"
label="share-menu.readonly-link"
icon={isReadOnlyLink ? 'check' : 'checkbox-empty'}
onSelect={async () => {
setIsReadOnlyLink(() => !isReadOnlyLink)
id="copy-to-clipboard"
readonlyOk
icon={didCopy ? 'clipboard-copied' : 'clipboard-copy'}
label="share-menu.copy-link"
onSelect={() => {
if (!shareState.url) return
setDidCopy(true)
setTimeout(() => setDidCopy(false), 750)
navigator.clipboard.writeText(shareState.url)
}}
/>
)}
<TldrawUiMenuItem
id="copy-readonly-to-clipboard"
readonlyOk
icon={didCopyReadonlyLink ? 'clipboard-copied' : 'clipboard-copy'}
label="share-menu.copy-readonly-link"
onSelect={() => {
if (!shareState.readonlyUrl) return
setDidCopyReadonlyLink(true)
setTimeout(() => setDidCopyReadonlyLink(false), 750)
navigator.clipboard.writeText(shareState.readonlyUrl)
}}
/>
<p className="tlui-menu__group tlui-share-zone__details">
{msg(
isReadOnlyLink
? 'share-menu.copy-readonly-link-note'
: 'share-menu.copy-link-note'
)}
{msg('share-menu.copy-readonly-link-note')}
</p>
</TldrawUiMenuGroup>
@ -185,6 +235,7 @@ export const ShareMenu = React.memo(function ShareMenu() {
<TldrawUiMenuGroup id="share">
<TldrawUiMenuItem
id="share-project"
readonlyOk
label="share-menu.share-project"
icon="share-1"
onSelect={async () => {
@ -197,7 +248,7 @@ export const ShareMenu = React.memo(function ShareMenu() {
/>
<p className="tlui-menu__group tlui-share-zone__details">
{msg(
shareState.state === 'offline'
shareState.state === SHARE_CURRENT_STATE.OFFLINE
? 'share-menu.offline-note'
: isReadOnlyLink
? 'share-menu.copy-readonly-link-note'
@ -208,6 +259,7 @@ export const ShareMenu = React.memo(function ShareMenu() {
<TldrawUiMenuGroup id="copy-snapshot-link">
<TldrawUiMenuItem
id="copy-snapshot-link"
readonlyOk
icon={didCopySnapshotLink ? 'clipboard-copied' : 'clipboard-copy'}
label={unwrapLabel(shareSnapshot.label)}
onSelect={async () => {

Wyświetl plik

@ -1,9 +1,11 @@
import { TLIncompatibilityReason } from '@tldraw/tlsync'
import { ErrorScreen, exhaustiveSwitchError } from 'tldraw'
import { exhaustiveSwitchError } from 'tldraw'
import { RemoteSyncError } from '../utils/remote-sync/remote-sync'
import { ErrorPage } from './ErrorPage/ErrorPage'
export function StoreErrorScreen({ error }: { error: Error }) {
let message = 'Could not connect to server.'
let header = 'Could not connect to server.'
let message = ''
if (error instanceof RemoteSyncError) {
switch (error.reason) {
@ -26,14 +28,15 @@ export function StoreErrorScreen({ error }: { error: Error }) {
'Your changes were rejected by the server. Please reload the page. If the problem persists contact the system administrator.'
break
}
case TLIncompatibilityReason.RoomNotFound: {
header = 'Room not found'
message = 'The room you are trying to connect to does not exist.'
break
}
default:
exhaustiveSwitchError(error.reason)
}
}
return (
<div className="tldraw__editor tl-container">
<ErrorScreen>{message}</ErrorScreen>
</div>
)
return <ErrorPage icon messages={{ header, para1: message }} />
}

Wyświetl plik

@ -1,4 +1,10 @@
import { TLSyncClient, schema } from '@tldraw/tlsync'
import {
TLCloseEventCode,
TLIncompatibilityReason,
TLPersistentClientSocketStatus,
TLSyncClient,
schema,
} from '@tldraw/tlsync'
import { useEffect, useState } from 'react'
import {
TAB_ID,
@ -55,6 +61,16 @@ export function useRemoteSyncClient(opts: UseSyncClientConfig): RemoteTLStoreWit
return withParams.toString()
})
socket.onStatusChange((val: TLPersistentClientSocketStatus, closeCode?: number) => {
if (val === 'error' && closeCode === TLCloseEventCode.NOT_FOUND) {
trackAnalyticsEvent(MULTIPLAYER_EVENT_NAME, { name: 'room-not-found', roomId })
setState({ error: new RemoteSyncError(TLIncompatibilityReason.RoomNotFound) })
client.close()
socket.close()
return
}
})
let didCancel = false
const client = new TLSyncClient({

Wyświetl plik

@ -2,7 +2,7 @@ import { RoomSnapshot } from '@tldraw/tlsync'
import '../../styles/globals.css'
import { BoardHistorySnapshot } from '../components/BoardHistorySnapshot/BoardHistorySnapshot'
import { ErrorPage } from '../components/ErrorPage/ErrorPage'
import { IFrameProtector } from '../components/IFrameProtector'
import { IFrameProtector, ROOM_CONTEXT } from '../components/IFrameProtector'
import { defineLoader } from '../utils/defineLoader'
const { loader, useData } = defineLoader(async (args) => {
@ -32,13 +32,12 @@ export function Component() {
header: 'Page not found',
para1: 'The page you are looking does not exist or has been moved.',
}}
redirectTo="/"
/>
)
const { data, roomId, timestamp } = result
return (
<IFrameProtector slug={roomId} context="history-snapshot">
<IFrameProtector slug={roomId} context={ROOM_CONTEXT.HISTORY_SNAPSHOT}>
<BoardHistorySnapshot data={data} roomId={roomId} timestamp={timestamp} />
</IFrameProtector>
)

Wyświetl plik

@ -1,6 +1,6 @@
import { BoardHistoryLog } from '../components/BoardHistoryLog/BoardHistoryLog'
import { ErrorPage } from '../components/ErrorPage/ErrorPage'
import { IFrameProtector } from '../components/IFrameProtector'
import { IFrameProtector, ROOM_CONTEXT } from '../components/IFrameProtector'
import { defineLoader } from '../utils/defineLoader'
const { loader, useData } = defineLoader(async (args) => {
@ -29,11 +29,10 @@ export function Component() {
header: 'Page not found',
para1: 'The page you are looking does not exist or has been moved.',
}}
redirectTo="/"
/>
)
return (
<IFrameProtector slug={data.boardId} context="history">
<IFrameProtector slug={data.boardId} context={ROOM_CONTEXT.HISTORY}>
<BoardHistoryLog data={data.data} />
</IFrameProtector>
)

Wyświetl plik

@ -0,0 +1,40 @@
import { Snapshot } from '@tldraw/dotcom-shared'
import { schema } from '@tldraw/tlsync'
import { Navigate } from 'react-router-dom'
import '../../styles/globals.css'
import { ErrorPage } from '../components/ErrorPage/ErrorPage'
import { defineLoader } from '../utils/defineLoader'
import { isInIframe } from '../utils/iFrame'
import { getNewRoomResponse } from '../utils/sharing'
const { loader, useData } = defineLoader(async (_args) => {
if (isInIframe()) return null
const res = await getNewRoomResponse({
schema: schema.serialize(),
snapshot: {},
} satisfies Snapshot)
const response = (await res.json()) as { error: boolean; slug?: string }
if (!res.ok || response.error || !response.slug) {
return null
}
return { slug: response.slug }
})
export { loader }
export function Component() {
const data = useData()
if (!data)
return (
<ErrorPage
icon
messages={{
header: 'Page not found',
para1: 'The page you are looking does not exist or has been moved.',
}}
/>
)
return <Navigate to={`/r/${data.slug}`} />
}

Wyświetl plik

@ -8,7 +8,6 @@ export function Component() {
header: 'Page not found',
para1: 'The page you are looking does not exist or has been moved.',
}}
redirectTo="/"
/>
)
}

Wyświetl plik

@ -1,13 +1,14 @@
import { ROOM_OPEN_MODE } from '@tldraw/dotcom-shared'
import { useParams } from 'react-router-dom'
import '../../styles/globals.css'
import { IFrameProtector } from '../components/IFrameProtector'
import { IFrameProtector, ROOM_CONTEXT } from '../components/IFrameProtector'
import { MultiplayerEditor } from '../components/MultiplayerEditor'
export function Component() {
const id = useParams()['roomId'] as string
return (
<IFrameProtector slug={id} context="public-multiplayer">
<MultiplayerEditor isReadOnly={false} roomSlug={id} />
<IFrameProtector slug={id} context={ROOM_CONTEXT.PUBLIC_MULTIPLAYER}>
<MultiplayerEditor roomOpenMode={ROOM_OPEN_MODE.READ_WRITE} roomSlug={id} />
</IFrameProtector>
)
}

Wyświetl plik

@ -0,0 +1,14 @@
import { ROOM_OPEN_MODE } from '@tldraw/dotcom-shared'
import { useParams } from 'react-router-dom'
import '../../styles/globals.css'
import { IFrameProtector, ROOM_CONTEXT } from '../components/IFrameProtector'
import { MultiplayerEditor } from '../components/MultiplayerEditor'
export function Component() {
const id = useParams()['roomId'] as string
return (
<IFrameProtector slug={id} context={ROOM_CONTEXT.PUBLIC_READONLY}>
<MultiplayerEditor roomOpenMode={ROOM_OPEN_MODE.READ_ONLY_LEGACY} roomSlug={id} />
</IFrameProtector>
)
}

Wyświetl plik

@ -1,13 +1,14 @@
import { ROOM_OPEN_MODE } from '@tldraw/dotcom-shared'
import { useParams } from 'react-router-dom'
import '../../styles/globals.css'
import { IFrameProtector } from '../components/IFrameProtector'
import { IFrameProtector, ROOM_CONTEXT } from '../components/IFrameProtector'
import { MultiplayerEditor } from '../components/MultiplayerEditor'
export function Component() {
const id = useParams()['roomId'] as string
return (
<IFrameProtector slug={id} context="public-readonly">
<MultiplayerEditor isReadOnly={true} roomSlug={id} />
<IFrameProtector slug={id} context={ROOM_CONTEXT.PUBLIC_READONLY}>
<MultiplayerEditor roomOpenMode={ROOM_OPEN_MODE.READ_ONLY} roomSlug={id} />
</IFrameProtector>
)
}

Wyświetl plik

@ -1,6 +1,6 @@
import { SerializedSchema, TLRecord } from 'tldraw'
import '../../styles/globals.css'
import { IFrameProtector } from '../components/IFrameProtector'
import { IFrameProtector, ROOM_CONTEXT } from '../components/IFrameProtector'
import { SnapshotsEditor } from '../components/SnapshotsEditor'
import { defineLoader } from '../utils/defineLoader'
@ -23,7 +23,7 @@ export function Component() {
if (!result) throw Error('Room not found')
const { roomId, records, schema } = result
return (
<IFrameProtector slug={roomId} context="public-snapshot">
<IFrameProtector slug={roomId} context={ROOM_CONTEXT.PUBLIC_SNAPSHOT}>
<SnapshotsEditor records={records} schema={schema} />
</IFrameProtector>
)

Wyświetl plik

@ -1,10 +1,10 @@
import '../../styles/globals.css'
import { IFrameProtector } from '../components/IFrameProtector'
import { IFrameProtector, ROOM_CONTEXT } from '../components/IFrameProtector'
import { LocalEditor } from '../components/LocalEditor'
export function Component() {
return (
<IFrameProtector slug="home" context="local">
<IFrameProtector slug="home" context={ROOM_CONTEXT.LOCAL}>
<LocalEditor />
</IFrameProtector>
)

Wyświetl plik

@ -1,7 +1,6 @@
import { captureException } from '@sentry/react'
import { nanoid } from 'nanoid'
import { useEffect } from 'react'
import { createRoutesFromElements, Outlet, redirect, Route, useRouteError } from 'react-router-dom'
import { createRoutesFromElements, Outlet, Route, useRouteError } from 'react-router-dom'
import { DefaultErrorFallback } from './components/DefaultErrorFallback/DefaultErrorFallback'
import { ErrorPage } from './components/ErrorPage/ErrorPage'
@ -30,20 +29,8 @@ export const router = createRoutesFromElements(
>
<Route errorElement={<DefaultErrorFallback />}>
<Route path="/" lazy={() => import('./pages/root')} />
<Route
path="/r"
loader={() => {
const id = 'v2' + nanoid()
return redirect(`/r/${id}`)
}}
/>
<Route
path="/new"
loader={() => {
const id = 'v2' + nanoid()
return redirect(`/r/${id}`)
}}
/>
<Route path="/r" lazy={() => import('./pages/new')} />
<Route path="/new" lazy={() => import('./pages/new')} />
<Route path="/r/:roomId" lazy={() => import('./pages/public-multiplayer')} />
<Route path="/r/:boardId/history" lazy={() => import('./pages/history')} />
<Route
@ -51,7 +38,8 @@ export const router = createRoutesFromElements(
lazy={() => import('./pages/history-snapshot')}
/>
<Route path="/s/:roomId" lazy={() => import('./pages/public-snapshot')} />
<Route path="/v/:roomId" lazy={() => import('./pages/public-readonly')} />
<Route path="/v/:roomId" lazy={() => import('./pages/public-readonly-legacy')} />
<Route path="/ro/:roomId" lazy={() => import('./pages/public-readonly')} />
</Route>
<Route path="*" lazy={() => import('./pages/not-found')} />
</Route>

Wyświetl plik

@ -0,0 +1,16 @@
export const isInIframe = () => {
return typeof window !== 'undefined' && (window !== window.top || window.self !== window.parent)
}
export function getParentOrigin() {
if (isInIframe()) {
const ancestorOrigins = window.location.ancestorOrigins
// ancestorOrigins is not supported in Firefox
if (ancestorOrigins && ancestorOrigins.length > 0) {
return ancestorOrigins[0]
} else {
return document.referrer
}
}
return document.location.origin
}

Wyświetl plik

@ -155,20 +155,33 @@ describe(ClientWebSocketAdapter, () => {
it('signals status changes', async () => {
const onStatusChange = jest.fn()
adapter.onStatusChange(onStatusChange)
await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN)
expect(onStatusChange).toHaveBeenCalledWith('online')
connectedServerSocket.terminate()
await waitFor(() => adapter._ws?.readyState === WebSocket.CLOSED)
expect(onStatusChange).toHaveBeenCalledWith('offline')
expect(onStatusChange).toHaveBeenCalledWith('offline', 1006)
await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN)
expect(onStatusChange).toHaveBeenCalledWith('online')
connectedServerSocket.terminate()
await waitFor(() => adapter._ws?.readyState === WebSocket.CLOSED)
expect(onStatusChange).toHaveBeenCalledWith('offline')
expect(onStatusChange).toHaveBeenCalledWith('offline', 1006)
await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN)
expect(onStatusChange).toHaveBeenCalledWith('online')
adapter._ws?.onerror?.({} as any)
expect(onStatusChange).toHaveBeenCalledWith('error')
expect(onStatusChange).toHaveBeenCalledWith('error', undefined)
})
it('signals the correct closeCode when a room is not found', async () => {
const onStatusChange = jest.fn()
adapter.onStatusChange(onStatusChange)
await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN)
adapter._ws!.onclose?.({ code: 4099 } as any)
expect(onStatusChange).toHaveBeenCalledWith('error', 4099)
})
it('signals status changes while restarting', async () => {
@ -181,7 +194,7 @@ describe(ClientWebSocketAdapter, () => {
await waitFor(() => onStatusChange.mock.calls.length === 2)
expect(onStatusChange).toHaveBeenCalledWith('offline')
expect(onStatusChange).toHaveBeenCalledWith('offline', undefined)
expect(onStatusChange).toHaveBeenCalledWith('online')
})
})

Wyświetl plik

@ -1,5 +1,6 @@
import {
chunk,
TLCloseEventCode,
TLPersistentClientSocket,
TLPersistentClientSocketStatus,
TLSocketClientSentEvent,
@ -68,15 +69,20 @@ export class ClientWebSocketAdapter implements TLPersistentClientSocket<TLRecord
this._reconnectManager.connected()
}
private _handleDisconnect(reason: 'closed' | 'error' | 'manual') {
private _handleDisconnect(reason: 'closed' | 'error' | 'manual', closeCode?: number) {
debug('handleDisconnect', {
currentStatus: this.connectionStatus,
closeCode,
reason,
})
let newStatus: 'offline' | 'error'
switch (reason) {
case 'closed':
if (closeCode === TLCloseEventCode.NOT_FOUND) {
newStatus = 'error'
break
}
newStatus = 'offline'
break
case 'error':
@ -94,7 +100,7 @@ export class ClientWebSocketAdapter implements TLPersistentClientSocket<TLRecord
!(newStatus === 'error' && this.connectionStatus === 'offline')
) {
this._connectionStatus.set(newStatus)
this.statusListeners.forEach((cb) => cb(newStatus))
this.statusListeners.forEach((cb) => cb(newStatus, closeCode))
}
this._reconnectManager.disconnected()
@ -120,10 +126,10 @@ export class ClientWebSocketAdapter implements TLPersistentClientSocket<TLRecord
)
this._handleConnect()
}
ws.onclose = () => {
ws.onclose = (event: CloseEvent) => {
debug('ws.onclose')
if (this._ws === ws) {
this._handleDisconnect('closed')
this._handleDisconnect('closed', event.code)
} else {
debug('ignoring onclose for an orphaned socket')
}
@ -194,8 +200,10 @@ export class ClientWebSocketAdapter implements TLPersistentClientSocket<TLRecord
}
}
private statusListeners = new Set<(status: TLPersistentClientSocketStatus) => void>()
onStatusChange(cb: (val: TLPersistentClientSocketStatus) => void) {
private statusListeners = new Set<
(status: TLPersistentClientSocketStatus, closeCode?: number) => void
>()
onStatusChange(cb: (val: TLPersistentClientSocketStatus, closeCode?: number) => void) {
assert(!this.isDisposed, 'Tried to add status listener on a disposed socket')
this.statusListeners.add(cb)

Wyświetl plik

@ -1,10 +1,14 @@
import {
CreateRoomRequestBody,
CreateSnapshotRequestBody,
CreateSnapshotResponseBody,
Snapshot,
} from '@tldraw/dotcom-shared'
import { useMemo } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import {
AssetRecordType,
Editor,
SerializedSchema,
SerializedStore,
TLAsset,
TLAssetId,
TLRecord,
@ -20,6 +24,7 @@ import { useMultiplayerAssets } from '../hooks/useMultiplayerAssets'
import { getViewportUrlQuery } from '../hooks/useUrlState'
import { cloneAssetForShare } from './cloneAssetForShare'
import { ASSET_UPLOADER_URL } from './config'
import { getParentOrigin, isInIframe } from './iFrame'
import { shouldLeaveSharedProject } from './shouldLeaveSharedProject'
import { trackAnalyticsEvent } from './trackAnalyticsEvent'
import { UI_OVERRIDE_TODO_EVENT, useHandleUiEvents } from './useHandleUiEvent'
@ -32,27 +37,6 @@ export const FORK_PROJECT_ACTION = 'fork-project' as const
const CREATE_SNAPSHOT_ENDPOINT = `/api/snapshots`
const SNAPSHOT_UPLOAD_URL = `/api/new-room`
type SnapshotRequestBody = {
schema: SerializedSchema
snapshot: SerializedStore<TLRecord>
}
type CreateSnapshotRequestBody = {
schema: SerializedSchema
snapshot: SerializedStore<TLRecord>
parent_slug?: string | string[] | undefined
}
type CreateSnapshotResponseBody =
| {
error: false
roomId: string
}
| {
error: true
message: string
}
async function getSnapshotLink(
source: string,
editor: Editor,
@ -90,11 +74,25 @@ async function getSnapshotLink(
})
}
export async function getNewRoomResponse(snapshot: Snapshot) {
return await fetch(SNAPSHOT_UPLOAD_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
origin: getParentOrigin(),
snapshot,
} satisfies CreateRoomRequestBody),
})
}
export function useSharing(): TLUiOverrides {
const navigate = useNavigate()
const id = useSearchParams()[0].get('id') ?? undefined
const uploadFileToAsset = useMultiplayerAssets(ASSET_UPLOADER_URL)
const handleUiEvent = useHandleUiEvents()
const runningInIFrame = isInIframe()
return useMemo(
(): TLUiOverrides => ({
@ -122,17 +120,10 @@ export function useSharing(): TLUiOverrides {
const data = await getRoomData(editor, addToast, msg, uploadFileToAsset)
if (!data) return
const res = await fetch(SNAPSHOT_UPLOAD_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
schema: editor.store.schema.serialize(),
snapshot: data,
} satisfies SnapshotRequestBody),
const res = await getNewRoomResponse({
schema: editor.store.schema.serialize(),
snapshot: data,
})
const response = (await res.json()) as { error: boolean; slug?: string }
if (!res.ok || response.error) {
console.error(await res.text())
@ -140,8 +131,13 @@ export function useSharing(): TLUiOverrides {
}
const query = getViewportUrlQuery(editor)
navigate(`/r/${response.slug}?${new URLSearchParams(query ?? {}).toString()}`)
const origin = window.location.origin
const pathname = `/r/${response.slug}?${new URLSearchParams(query ?? {}).toString()}`
if (runningInIFrame) {
window.open(`${origin}${pathname}`)
} else {
navigate(pathname)
}
} catch (error) {
console.error(error)
addToast({
@ -182,12 +178,12 @@ export function useSharing(): TLUiOverrides {
actions[FORK_PROJECT_ACTION] = {
...actions[SHARE_PROJECT_ACTION],
id: FORK_PROJECT_ACTION,
label: 'action.fork-project',
label: runningInIFrame ? 'action.fork-project-on-tldraw' : 'action.fork-project',
}
return actions
},
}),
[handleUiEvent, navigate, uploadFileToAsset, id]
[handleUiEvent, navigate, uploadFileToAsset, id, runningInIFrame]
)
}

Wyświetl plik

@ -183,6 +183,7 @@ a {
font-weight: 500;
color: var(--text-color-2);
padding: 12px 4px;
text-decoration: underline;
}
/* ------------------ Board history ----------------- */

Wyświetl plik

@ -28,6 +28,9 @@
{
"path": "../../packages/assets"
},
{
"path": "../../packages/dotcom-shared"
},
{
"path": "../../packages/tldraw"
},

Wyświetl plik

@ -1,4 +1,4 @@
<svg width="30" height="31" viewBox="0 0 30 31" fill="none" xmlns="http://www.w3.org/2000/svg">
<svg width="30" height="30" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M8 2V4H18V2H8ZM6 1.5C6 0.671573 6.67157 0 7.5 0H18.5C19.3284 0 20 0.671572 20 1.5V2H21C22.6569 2 24 3.34315 24 5V14H22V5C22 4.44772 21.5523 4 21 4H20V4.5C20 5.32843 19.3284 6 18.5 6H7.5C6.67157 6 6 5.32843 6 4.5V4H5C4.44771 4 4 4.44772 4 5V25C4 25.5523 4.44772 26 5 26H12V28H5C3.34315 28 2 26.6569 2 25V5C2 3.34314 3.34315 2 5 2H6V1.5Z" fill="black"/>
<path d="M27.5197 17.173C28.0099 17.4936 28.1475 18.1509 27.827 18.6411L20.6149 29.6713C20.445 29.9313 20.1696 30.1037 19.8615 30.143C19.5534 30.1823 19.2436 30.0846 19.0138 29.8757L14.3472 25.6333C13.9137 25.2393 13.8818 24.5685 14.2758 24.1351C14.6698 23.7017 15.3406 23.6697 15.774 24.0638L19.5203 27.4694L26.0516 17.4803C26.3721 16.9901 27.0294 16.8525 27.5197 17.173Z" fill="black"/>
</svg>

Przed

Szerokość:  |  Wysokość:  |  Rozmiar: 893 B

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 893 B

Wyświetl plik

@ -48,6 +48,7 @@
"action.flip-horizontal.short": "Flip H",
"action.flip-vertical.short": "Flip V",
"action.fork-project": "Fork this project",
"action.fork-project-on-tldraw": "Fork project on tldraw",
"action.group": "Group",
"action.insert-embed": "Insert embed",
"action.insert-media": "Upload media",
@ -262,7 +263,7 @@
"share-menu.copy-readonly-link": "Copy read-only link",
"share-menu.offline-note": "Create a new shared project based on your current project.",
"share-menu.copy-link-note": "Anyone with the link will be able to view and edit this project.",
"share-menu.copy-readonly-link-note": "Anyone with the link will be able to view (but not edit) this project.",
"share-menu.copy-readonly-link-note": "Anyone with the link will be able to access this project.",
"share-menu.project-too-large": "Sorry, this project can't be shared because it's too large. We're working on it!",
"share-menu.upload-failed": "Sorry, we couldn't upload your project at the moment. Please try again or let us know if the problem persists.",
"status.offline": "Offline",

Wyświetl plik

@ -1,232 +0,0 @@
## API Report File for "@tldraw/assets"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
// @public (undocumented)
export function getBundlerAssetUrls(opts?: AssetUrlOptions): {
readonly fonts: {
readonly monospace: string;
readonly sansSerif: string;
readonly serif: string;
readonly draw: string;
};
readonly icons: {
readonly 'align-bottom-center': string;
readonly 'align-bottom-left': string;
readonly 'align-bottom-right': string;
readonly 'align-bottom': string;
readonly 'align-center-center': string;
readonly 'align-center-horizontal': string;
readonly 'align-center-left': string;
readonly 'align-center-right': string;
readonly 'align-center-vertical': string;
readonly 'align-left': string;
readonly 'align-right': string;
readonly 'align-top-center': string;
readonly 'align-top-left': string;
readonly 'align-top-right': string;
readonly 'align-top': string;
readonly 'arrow-left': string;
readonly 'arrowhead-arrow': string;
readonly 'arrowhead-bar': string;
readonly 'arrowhead-diamond': string;
readonly 'arrowhead-dot': string;
readonly 'arrowhead-none': string;
readonly 'arrowhead-square': string;
readonly 'arrowhead-triangle-inverted': string;
readonly 'arrowhead-triangle': string;
readonly 'aspect-ratio': string;
readonly avatar: string;
readonly blob: string;
readonly 'bring-forward': string;
readonly 'bring-to-front': string;
readonly check: string;
readonly 'checkbox-checked': string;
readonly 'checkbox-empty': string;
readonly 'chevron-down': string;
readonly 'chevron-left': string;
readonly 'chevron-right': string;
readonly 'chevron-up': string;
readonly 'chevrons-ne': string;
readonly 'chevrons-sw': string;
readonly 'clipboard-copy': string;
readonly code: string;
readonly collab: string;
readonly color: string;
readonly comment: string;
readonly 'cross-2': string;
readonly cross: string;
readonly 'dash-dashed': string;
readonly 'dash-dotted': string;
readonly 'dash-draw': string;
readonly 'dash-solid': string;
readonly discord: string;
readonly 'distribute-horizontal': string;
readonly 'distribute-vertical': string;
readonly dot: string;
readonly 'dots-horizontal': string;
readonly 'dots-vertical': string;
readonly 'drag-handle-dots': string;
readonly duplicate: string;
readonly edit: string;
readonly 'external-link': string;
readonly file: string;
readonly 'fill-none': string;
readonly 'fill-pattern': string;
readonly 'fill-semi': string;
readonly 'fill-solid': string;
readonly follow: string;
readonly following: string;
readonly 'font-draw': string;
readonly 'font-mono': string;
readonly 'font-sans': string;
readonly 'font-serif': string;
readonly 'geo-arrow-down': string;
readonly 'geo-arrow-left': string;
readonly 'geo-arrow-right': string;
readonly 'geo-arrow-up': string;
readonly 'geo-check-box': string;
readonly 'geo-diamond': string;
readonly 'geo-ellipse': string;
readonly 'geo-hexagon': string;
readonly 'geo-octagon': string;
readonly 'geo-oval': string;
readonly 'geo-pentagon': string;
readonly 'geo-rectangle': string;
readonly 'geo-rhombus-2': string;
readonly 'geo-rhombus': string;
readonly 'geo-star': string;
readonly 'geo-trapezoid': string;
readonly 'geo-triangle': string;
readonly 'geo-x-box': string;
readonly github: string;
readonly group: string;
readonly hidden: string;
readonly image: string;
readonly 'info-circle': string;
readonly leading: string;
readonly link: string;
readonly 'lock-small': string;
readonly lock: string;
readonly menu: string;
readonly minus: string;
readonly mixed: string;
readonly pack: string;
readonly page: string;
readonly plus: string;
readonly 'question-mark-circle': string;
readonly 'question-mark': string;
readonly redo: string;
readonly 'reset-zoom': string;
readonly 'rotate-ccw': string;
readonly 'rotate-cw': string;
readonly ruler: string;
readonly search: string;
readonly 'send-backward': string;
readonly 'send-to-back': string;
readonly 'settings-horizontal': string;
readonly 'settings-vertical-1': string;
readonly 'settings-vertical': string;
readonly 'share-1': string;
readonly 'share-2': string;
readonly 'size-extra-large': string;
readonly 'size-large': string;
readonly 'size-medium': string;
readonly 'size-small': string;
readonly 'spline-cubic': string;
readonly 'spline-line': string;
readonly 'stack-horizontal': string;
readonly 'stack-vertical': string;
readonly 'stretch-horizontal': string;
readonly 'stretch-vertical': string;
readonly 'text-align-center': string;
readonly 'text-align-justify': string;
readonly 'text-align-left': string;
readonly 'text-align-right': string;
readonly 'tool-arrow': string;
readonly 'tool-embed': string;
readonly 'tool-eraser': string;
readonly 'tool-frame': string;
readonly 'tool-hand': string;
readonly 'tool-highlighter': string;
readonly 'tool-line': string;
readonly 'tool-media': string;
readonly 'tool-note': string;
readonly 'tool-pencil': string;
readonly 'tool-pointer': string;
readonly 'tool-text': string;
readonly trash: string;
readonly 'triangle-down': string;
readonly 'triangle-up': string;
readonly twitter: string;
readonly undo: string;
readonly ungroup: string;
readonly 'unlock-small': string;
readonly unlock: string;
readonly visible: string;
readonly 'warning-triangle': string;
readonly 'zoom-in': string;
readonly 'zoom-out': string;
};
readonly translations: {
readonly ar: string;
readonly ca: string;
readonly da: string;
readonly de: string;
readonly en: string;
readonly es: string;
readonly fa: string;
readonly fi: string;
readonly fr: string;
readonly gl: string;
readonly he: string;
readonly 'hi-in': string;
readonly hu: string;
readonly it: string;
readonly ja: string;
readonly 'ko-kr': string;
readonly ku: string;
readonly languages: string;
readonly main: string;
readonly my: string;
readonly ne: string;
readonly no: string;
readonly pl: string;
readonly 'pt-br': string;
readonly 'pt-pt': string;
readonly ro: string;
readonly ru: string;
readonly sv: string;
readonly te: string;
readonly th: string;
readonly tr: string;
readonly uk: string;
readonly vi: string;
readonly 'zh-cn': string;
readonly 'zh-tw': string;
};
readonly embedIcons: {
readonly codepen: string;
readonly codesandbox: string;
readonly excalidraw: string;
readonly felt: string;
readonly figma: string;
readonly github_gist: string;
readonly google_calendar: string;
readonly google_maps: string;
readonly google_slides: string;
readonly observable: string;
readonly replit: string;
readonly scratch: string;
readonly spotify: string;
readonly tldraw: string;
readonly vimeo: string;
readonly youtube: string;
};
};
// (No @packageDocumentation comment for this package)
```

Wyświetl plik

@ -0,0 +1,26 @@
{
"name": "@tldraw/dotcom-shared",
"version": "2.0.0",
"private": true,
"/* NOTE */": "These `main` and `types` fields are rewritten by the build script. They are not the actual values we publish",
"main": "./src/index.ts",
"types": "./.tsbuild/index.d.ts",
"/* GOTCHA */": "files will include ./dist and index.d.ts by default, add any others you want to include in here",
"files": [],
"dependencies": {
"tldraw": "workspace:*"
},
"peerDependencies": {
"react": "^18",
"react-dom": "^18"
},
"scripts": {
"test-ci": "lazy inherit",
"test": "yarn run -T jest",
"lint": "yarn run -T tsx ../../scripts/lint.ts"
},
"jest": {
"preset": "config/jest/node",
"testEnvironment": "jsdom"
}
}

Wyświetl plik

@ -0,0 +1,3 @@
it('works', () => {
// we need a test for jest to pass.
})

Wyświetl plik

@ -0,0 +1,8 @@
export { ROOM_OPEN_MODE, RoomOpenModeToPath, type RoomOpenMode } from './routes'
export type {
CreateRoomRequestBody,
CreateSnapshotRequestBody,
CreateSnapshotResponseBody,
GetReadonlySlugResponseBody,
Snapshot,
} from './types'

Wyświetl plik

@ -0,0 +1,14 @@
/** @public */
export const ROOM_OPEN_MODE = {
READ_ONLY: 'readonly',
READ_ONLY_LEGACY: 'readonly-legacy',
READ_WRITE: 'read-write',
} as const
export type RoomOpenMode = (typeof ROOM_OPEN_MODE)[keyof typeof ROOM_OPEN_MODE]
/** @public */
export const RoomOpenModeToPath: Record<RoomOpenMode, string> = {
[ROOM_OPEN_MODE.READ_ONLY]: 'ro',
[ROOM_OPEN_MODE.READ_ONLY_LEGACY]: 'v',
[ROOM_OPEN_MODE.READ_WRITE]: 'r',
}

Wyświetl plik

@ -0,0 +1,29 @@
import { SerializedSchema, SerializedStore, TLRecord } from 'tldraw'
export type Snapshot = {
schema: SerializedSchema
snapshot: SerializedStore<TLRecord>
}
export type CreateRoomRequestBody = {
origin: string
snapshot: Snapshot
}
export type CreateSnapshotRequestBody = {
schema: SerializedSchema
snapshot: SerializedStore<TLRecord>
parent_slug?: string | string[] | undefined
}
export type CreateSnapshotResponseBody =
| {
error: false
roomId: string
}
| {
error: true
message: string
}
export type GetReadonlySlugResponseBody = { slug: string; isLegacy: boolean }

Wyświetl plik

@ -0,0 +1,14 @@
{
"extends": "../../config/tsconfig.base.json",
"include": ["src"],
"exclude": ["node_modules", ".tsbuild*"],
"compilerOptions": {
"outDir": "./.tsbuild",
"rootDir": "src"
},
"references": [
{
"path": "../tldraw"
}
]
}

Wyświetl plik

@ -4,7 +4,7 @@ import { HistoryBuffer } from './HistoryBuffer'
import { maybeCaptureParent, startCapturingParents, stopCapturingParents } from './capture'
import { GLOBAL_START_EPOCH } from './constants'
import { EMPTY_ARRAY, equals, haveParentsChanged, singleton } from './helpers'
import { getGlobalEpoch, getIsReacting } from './transactions'
import { getGlobalEpoch } from './transactions'
import { Child, ComputeDiff, RESET_VALUE, Signal } from './types'
import { logComputedGetterWarning } from './warnings'
@ -189,15 +189,8 @@ class __UNSAFE__Computed<Value, Diff = unknown> implements Computed<Value, Diff>
__unsafe__getWithoutCapture(ignoreErrors?: boolean): Value {
const isNew = this.lastChangedEpoch === GLOBAL_START_EPOCH
const globalEpoch = getGlobalEpoch()
if (
!isNew &&
(this.lastCheckedEpoch === globalEpoch ||
(this.isActivelyListening && getIsReacting() && this.lastTraversedEpoch < globalEpoch) ||
!haveParentsChanged(this))
) {
this.lastCheckedEpoch = globalEpoch
if (!isNew && (this.lastCheckedEpoch === getGlobalEpoch() || !haveParentsChanged(this))) {
this.lastCheckedEpoch = getGlobalEpoch()
if (this.error) {
if (!ignoreErrors) {
throw this.error.thrownValue

Wyświetl plik

@ -70,10 +70,6 @@ export function getGlobalEpoch() {
return inst.globalEpoch
}
export function getIsReacting() {
return inst.globalIsReacting
}
/**
* Collect all of the reactors that need to run for an atom and run them.
*

File diff suppressed because one or more lines are too long

Wyświetl plik

@ -52,6 +52,7 @@ export type TLUiTranslationKey =
| 'action.flip-horizontal.short'
| 'action.flip-vertical.short'
| 'action.fork-project'
| 'action.fork-project-on-tldraw'
| 'action.group'
| 'action.insert-embed'
| 'action.insert-media'

Wyświetl plik

@ -52,6 +52,7 @@ export const DEFAULT_TRANSLATION = {
'action.flip-horizontal.short': 'Flip H',
'action.flip-vertical.short': 'Flip V',
'action.fork-project': 'Fork this project',
'action.fork-project-on-tldraw': 'Fork project on tldraw',
'action.group': 'Group',
'action.insert-embed': 'Insert embed',
'action.insert-media': 'Upload media',
@ -266,8 +267,7 @@ export const DEFAULT_TRANSLATION = {
'share-menu.copy-readonly-link': 'Copy read-only link',
'share-menu.offline-note': 'Create a new shared project based on your current project.',
'share-menu.copy-link-note': 'Anyone with the link will be able to view and edit this project.',
'share-menu.copy-readonly-link-note':
'Anyone with the link will be able to view (but not edit) this project.',
'share-menu.copy-readonly-link-note': 'Anyone with the link will be able to access this project.',
'share-menu.project-too-large':
"Sorry, this project can't be shared because it's too large. We're working on it!",
'share-menu.upload-failed':

Wyświetl plik

@ -27,7 +27,7 @@ export const ArrowShapeArrowheadEndStyle: EnumStyleProp<"arrow" | "bar" | "diamo
// @public (undocumented)
export const ArrowShapeArrowheadStartStyle: EnumStyleProp<"arrow" | "bar" | "diamond" | "dot" | "inverted" | "none" | "pipe" | "square" | "triangle">;
// @internal (undocumented)
// @public (undocumented)
export const arrowShapeMigrations: TLShapePropsMigrations;
// @public (undocumented)
@ -76,16 +76,16 @@ export const arrowShapeProps: {
// @public
export const assetIdValidator: T.Validator<TLAssetId>;
// @internal (undocumented)
// @public (undocumented)
export const assetMigrations: MigrationSequence;
// @public (undocumented)
export const AssetRecordType: RecordType<TLAsset, "props" | "type">;
// @internal (undocumented)
// @public (undocumented)
export const assetValidator: T.Validator<TLAsset>;
// @internal (undocumented)
// @public (undocumented)
export const bookmarkShapeMigrations: TLShapePropsMigrations;
// @public (undocumented)
@ -192,6 +192,11 @@ export const DefaultFontStyle: EnumStyleProp<"draw" | "mono" | "sans" | "serif">
// @public (undocumented)
export const DefaultHorizontalAlignStyle: EnumStyleProp<"end-legacy" | "end" | "middle-legacy" | "middle" | "start-legacy" | "start">;
// @public (undocumented)
export const defaultShapeSchemas: {
[T in TLDefaultShape['type']]: SchemaShapeInfo;
};
// @public (undocumented)
export const DefaultSizeStyle: EnumStyleProp<"l" | "m" | "s" | "xl">;
@ -201,7 +206,7 @@ export const DefaultVerticalAlignStyle: EnumStyleProp<"end" | "middle" | "start"
// @public (undocumented)
export const DocumentRecordType: RecordType<TLDocument, never>;
// @internal (undocumented)
// @public (undocumented)
export const drawShapeMigrations: TLShapePropsMigrations;
// @public (undocumented)
@ -429,7 +434,7 @@ export type EmbedDefinition = {
readonly width: number;
};
// @internal (undocumented)
// @public (undocumented)
export const embedShapeMigrations: TLShapePropsMigrations;
// @public
@ -465,7 +470,7 @@ export class EnumStyleProp<T> extends StyleProp<T> {
readonly values: readonly T[];
}
// @internal (undocumented)
// @public (undocumented)
export const frameShapeMigrations: TLShapePropsMigrations;
// @public (undocumented)
@ -478,7 +483,7 @@ export const frameShapeProps: {
// @public (undocumented)
export const GeoShapeGeoStyle: EnumStyleProp<"arrow-down" | "arrow-left" | "arrow-right" | "arrow-up" | "check-box" | "cloud" | "diamond" | "ellipse" | "hexagon" | "octagon" | "oval" | "pentagon" | "rectangle" | "rhombus-2" | "rhombus" | "star" | "trapezoid" | "triangle" | "x-box">;
// @internal (undocumented)
// @public (undocumented)
export const geoShapeMigrations: TLShapePropsMigrations;
// @public (undocumented)
@ -510,13 +515,13 @@ export function getDefaultTranslationLocale(): TLLanguage['locale'];
// @internal (undocumented)
export function getShapePropKeysByStyle(props: Record<string, T.Validatable<any>>): Map<StyleProp<unknown>, string>;
// @internal (undocumented)
// @public (undocumented)
export const groupShapeMigrations: TLShapePropsMigrations;
// @internal (undocumented)
// @public (undocumented)
export const groupShapeProps: ShapeProps<TLGroupShape>;
// @internal (undocumented)
// @public (undocumented)
export const highlightShapeMigrations: TLShapePropsMigrations;
// @public (undocumented)
@ -531,10 +536,10 @@ export const highlightShapeProps: {
size: EnumStyleProp<"l" | "m" | "s" | "xl">;
};
// @internal (undocumented)
// @public (undocumented)
export function idValidator<Id extends RecordId<UnknownRecord>>(prefix: Id['__type__']['typeName']): T.Validator<Id>;
// @internal (undocumented)
// @public (undocumented)
export const imageShapeMigrations: TLShapePropsMigrations;
// @public (undocumented)
@ -676,7 +681,7 @@ export const LANGUAGES: readonly [{
readonly locale: "zh-tw";
}];
// @internal (undocumented)
// @public (undocumented)
export const lineShapeMigrations: TLShapePropsMigrations;
// @public (undocumented)
@ -696,7 +701,7 @@ export const lineShapeProps: {
// @public (undocumented)
export const LineShapeSplineStyle: EnumStyleProp<"cubic" | "line">;
// @internal (undocumented)
// @public (undocumented)
export const noteShapeMigrations: TLShapePropsMigrations;
// @public (undocumented)
@ -712,10 +717,10 @@ export const noteShapeProps: {
verticalAlign: EnumStyleProp<"end" | "middle" | "start">;
};
// @internal (undocumented)
// @public (undocumented)
export const opacityValidator: T.Validator<number>;
// @internal (undocumented)
// @public (undocumented)
export const pageIdValidator: T.Validator<TLPageId>;
// @public (undocumented)
@ -727,7 +732,7 @@ export const parentIdValidator: T.Validator<TLParentId>;
// @public (undocumented)
export const PointerRecordType: RecordType<TLPointer, never>;
// @internal (undocumented)
// @public (undocumented)
export const rootShapeMigrations: MigrationSequence;
// @public (undocumented)
@ -737,7 +742,7 @@ export type SchemaShapeInfo = {
props?: Record<string, AnyValidator>;
};
// @internal (undocumented)
// @public (undocumented)
export const scribbleValidator: T.Validator<TLScribble>;
// @public (undocumented)
@ -780,7 +785,7 @@ export class StyleProp<Type> implements T.Validatable<Type> {
// @public (undocumented)
export type StylePropValue<T extends StyleProp<any>> = T extends StyleProp<infer U> ? U : never;
// @internal (undocumented)
// @public (undocumented)
export const textShapeMigrations: TLShapePropsMigrations;
// @public (undocumented)
@ -1299,7 +1304,7 @@ export interface VecModel {
// @public (undocumented)
export const vecModelValidator: T.Validator<VecModel>;
// @internal (undocumented)
// @public (undocumented)
export const videoShapeMigrations: TLShapePropsMigrations;
// @public (undocumented)

Wyświetl plik

@ -17,7 +17,7 @@ export type TLBookmarkAsset = TLBaseAsset<
}
>
/** @internal */
/** @public */
export const bookmarkAssetValidator: T.Validator<TLBookmarkAsset> = createAssetValidator(
'bookmark',
T.object({
@ -34,7 +34,7 @@ const Versions = createMigrationIds('com.tldraw.asset.bookmark', {
export { Versions as bookmarkAssetVersions }
/** @internal */
/** @public */
export const bookmarkAssetMigrations = createRecordMigrationSequence({
sequenceId: 'com.tldraw.asset.bookmark',
recordType: 'asset',

Wyświetl plik

@ -19,7 +19,7 @@ export type TLImageAsset = TLBaseAsset<
}
>
/** @internal */
/** @public */
export const imageAssetValidator: T.Validator<TLImageAsset> = createAssetValidator(
'image',
T.object({
@ -40,7 +40,7 @@ const Versions = createMigrationIds('com.tldraw.asset.image', {
export { Versions as imageAssetVersions }
/** @internal */
/** @public */
export const imageAssetMigrations = createRecordMigrationSequence({
sequenceId: 'com.tldraw.asset.image',
recordType: 'asset',

Wyświetl plik

@ -19,7 +19,7 @@ export type TLVideoAsset = TLBaseAsset<
}
>
/** @internal */
/** @public */
export const videoAssetValidator: T.Validator<TLVideoAsset> = createAssetValidator(
'video',
T.object({
@ -40,7 +40,7 @@ const Versions = createMigrationIds('com.tldraw.asset.video', {
export { Versions as videoAssetVersions }
/** @internal */
/** @public */
export const videoAssetMigrations = createRecordMigrationSequence({
sequenceId: 'com.tldraw.asset.video',
recordType: 'asset',

Wyświetl plik

@ -52,7 +52,8 @@ export type SchemaShapeInfo = {
/** @public */
export type TLSchema = StoreSchema<TLRecord, TLStoreProps>
const defaultShapes: { [T in TLDefaultShape['type']]: SchemaShapeInfo } = {
/** @public */
export const defaultShapeSchemas: { [T in TLDefaultShape['type']]: SchemaShapeInfo } = {
arrow: { migrations: arrowShapeMigrations, props: arrowShapeProps },
bookmark: { migrations: bookmarkShapeMigrations, props: bookmarkShapeProps },
draw: { migrations: drawShapeMigrations, props: drawShapeProps },
@ -75,7 +76,7 @@ const defaultShapes: { [T in TLDefaultShape['type']]: SchemaShapeInfo } = {
*
* @public */
export function createTLSchema({
shapes = defaultShapes,
shapes = defaultShapeSchemas,
migrations,
}: {
shapes?: Record<string, SchemaShapeInfo>

Wyświetl plik

@ -10,7 +10,12 @@ export { type TLBookmarkAsset } from './assets/TLBookmarkAsset'
export { type TLImageAsset } from './assets/TLImageAsset'
export { type TLVideoAsset } from './assets/TLVideoAsset'
export { createPresenceStateDerivation } from './createPresenceStateDerivation'
export { createTLSchema, type SchemaShapeInfo, type TLSchema } from './createTLSchema'
export {
createTLSchema,
defaultShapeSchemas,
type SchemaShapeInfo,
type TLSchema,
} from './createTLSchema'
export {
TL_CANVAS_UI_COLOR_TYPES,
canvasUiColorTypeValidator,

Wyświetl plik

@ -35,7 +35,7 @@ export const TL_CURSOR_TYPES = new Set([
* @public */
export type TLCursorType = SetValue<typeof TL_CURSOR_TYPES>
/** @internal */
/** @public */
export const cursorTypeValidator = T.setEnum(TL_CURSOR_TYPES)
/**
@ -47,7 +47,7 @@ export interface TLCursor {
rotation: number
}
/** @internal */
/** @public */
export const cursorValidator: T.Validator<TLCursor> = T.object<TLCursor>({
type: cursorTypeValidator,
rotation: T.number,

Wyświetl plik

@ -3,7 +3,7 @@ import { T } from '@tldraw/validate'
/** @public */
export type TLOpacityType = number
/** @internal */
/** @public */
export const opacityValidator = T.number.check((n) => {
if (n < 0 || n > 1) {
throw new T.ValidationError('Opacity must be between 0 and 1')

Wyświetl plik

@ -25,7 +25,7 @@ export type TLScribble = {
taper: boolean
}
/** @internal */
/** @public */
export const scribbleValidator: T.Validator<TLScribble> = T.object({
id: T.string,
points: T.arrayOf(vecModelValidator),

Wyświetl plik

@ -1,7 +1,7 @@
import type { RecordId, UnknownRecord } from '@tldraw/store'
import { T } from '@tldraw/validate'
/** @internal */
/** @public */
export function idValidator<Id extends RecordId<UnknownRecord>>(
prefix: Id['__type__']['typeName']
): T.Validator<Id> {

Wyświetl plik

@ -14,7 +14,7 @@ import { TLShape } from './TLShape'
/** @public */
export type TLAsset = TLImageAsset | TLVideoAsset | TLBookmarkAsset
/** @internal */
/** @public */
export const assetValidator: T.Validator<TLAsset> = T.model(
'asset',
T.union('type', {
@ -24,12 +24,12 @@ export const assetValidator: T.Validator<TLAsset> = T.model(
})
)
/** @internal */
/** @public */
export const assetVersions = createMigrationIds('com.tldraw.asset', {
AddMeta: 1,
} as const)
/** @internal */
/** @public */
export const assetMigrations = createRecordMigrationSequence({
sequenceId: 'com.tldraw.asset',
recordType: 'asset',

Wyświetl plik

@ -27,7 +27,7 @@ export interface TLCamera extends BaseRecord<'camera', TLCameraId> {
* @public */
export type TLCameraId = RecordId<TLCamera>
/** @internal */
/** @public */
export const cameraValidator: T.Validator<TLCamera> = T.model(
'camera',
T.object({
@ -40,12 +40,12 @@ export const cameraValidator: T.Validator<TLCamera> = T.model(
})
)
/** @internal */
/** @public */
export const cameraVersions = createMigrationIds('com.tldraw.camera', {
AddMeta: 1,
})
/** @internal */
/** @public */
export const cameraMigrations = createRecordMigrationSequence({
sequenceId: 'com.tldraw.camera',
recordType: 'camera',

Wyświetl plik

@ -19,7 +19,7 @@ export interface TLDocument extends BaseRecord<'document', RecordId<TLDocument>>
meta: JsonObject
}
/** @internal */
/** @public */
export const documentValidator: T.Validator<TLDocument> = T.model(
'document',
T.object({
@ -31,13 +31,13 @@ export const documentValidator: T.Validator<TLDocument> = T.model(
})
)
/** @internal */
/** @public */
export const documentVersions = createMigrationIds('com.tldraw.document', {
AddName: 1,
AddMeta: 2,
} as const)
/** @internal */
/** @public */
export const documentMigrations = createRecordMigrationSequence({
sequenceId: 'com.tldraw.document',
recordType: 'document',

Wyświetl plik

@ -73,7 +73,7 @@ export interface TLInstance extends BaseRecord<'instance', TLInstanceId> {
/** @public */
export type TLInstanceId = RecordId<TLInstance>
/** @internal */
/** @public */
export const instanceIdValidator = idValidator<TLInstanceId>('instance')
export function createInstanceRecordType(stylesById: Map<string, StyleProp<unknown>>) {
@ -197,7 +197,7 @@ export function createInstanceRecordType(stylesById: Map<string, StyleProp<unkno
)
}
/** @internal */
/** @public */
export const instanceVersions = createMigrationIds('com.tldraw.instance', {
AddTransparentExportBgs: 1,
RemoveDialog: 2,

Wyświetl plik

@ -23,10 +23,10 @@ export interface TLPage extends BaseRecord<'page', TLPageId> {
/** @public */
export type TLPageId = RecordId<TLPage>
/** @internal */
/** @public */
export const pageIdValidator = idValidator<TLPageId>('page')
/** @internal */
/** @public */
export const pageValidator: T.Validator<TLPage> = T.model(
'page',
T.object({
@ -38,12 +38,12 @@ export const pageValidator: T.Validator<TLPage> = T.model(
})
)
/** @internal */
/** @public */
export const pageVersions = createMigrationIds('com.tldraw.page', {
AddMeta: 1,
})
/** @internal */
/** @public */
export const pageMigrations = createRecordMigrationSequence({
sequenceId: 'com.tldraw.page',
recordType: 'page',

Wyświetl plik

@ -32,7 +32,7 @@ export interface TLInstancePageState
meta: JsonObject
}
/** @internal */
/** @public */
export const instancePageStateValidator: T.Validator<TLInstancePageState> = T.model(
'instance_page_state',
T.object({
@ -50,7 +50,7 @@ export const instancePageStateValidator: T.Validator<TLInstancePageState> = T.mo
})
)
/** @internal */
/** @public */
export const instancePageStateVersions = createMigrationIds('com.tldraw.instance_page_state', {
AddCroppingId: 1,
RemoveInstanceIdAndCameraId: 2,

Wyświetl plik

@ -24,7 +24,7 @@ export interface TLPointer extends BaseRecord<'pointer', TLPointerId> {
/** @public */
export type TLPointerId = RecordId<TLPointer>
/** @internal */
/** @public */
export const pointerValidator: T.Validator<TLPointer> = T.model(
'pointer',
T.object({
@ -37,12 +37,12 @@ export const pointerValidator: T.Validator<TLPointer> = T.model(
})
)
/** @internal */
/** @public */
export const pointerVersions = createMigrationIds('com.tldraw.pointer', {
AddMeta: 1,
})
/** @internal */
/** @public */
export const pointerMigrations = createRecordMigrationSequence({
sequenceId: 'com.tldraw.pointer',
recordType: 'pointer',

Wyświetl plik

@ -40,7 +40,7 @@ export interface TLInstancePresence extends BaseRecord<'instance_presence', TLIn
/** @public */
export type TLInstancePresenceID = RecordId<TLInstancePresence>
/** @internal */
/** @public */
export const instancePresenceValidator: T.Validator<TLInstancePresence> = T.model(
'instance_presence',
T.object({
@ -72,7 +72,7 @@ export const instancePresenceValidator: T.Validator<TLInstancePresence> = T.mode
})
)
/** @internal */
/** @public */
export const instancePresenceVersions = createMigrationIds('com.tldraw.instance_presence', {
AddScribbleDelay: 1,
RemoveInstanceId: 2,

Wyświetl plik

@ -92,7 +92,7 @@ export type TLShapeProp = keyof TLShapeProps
/** @public */
export type TLParentId = TLPageId | TLShapeId
/** @internal */
/** @public */
export const rootShapeVersions = createMigrationIds('com.tldraw.shape', {
AddIsLocked: 1,
HoistOpacity: 2,
@ -100,7 +100,7 @@ export const rootShapeVersions = createMigrationIds('com.tldraw.shape', {
AddWhite: 4,
} as const)
/** @internal */
/** @public */
export const rootShapeMigrations = createRecordMigrationSequence({
sequenceId: 'com.tldraw.shape',
recordType: 'shape',

Wyświetl plik

@ -88,7 +88,7 @@ export const arrowShapeVersions = createShapePropsMigrationIds('arrow', {
AddLabelPosition: 3,
})
/** @internal */
/** @public */
export const arrowShapeMigrations = createShapePropsMigrationSequence({
sequence: [
{

Wyświetl plik

@ -28,7 +28,7 @@ const Versions = createShapePropsMigrationIds('bookmark', {
export { Versions as bookmarkShapeVersions }
/** @internal */
/** @public */
export const bookmarkShapeMigrations = createShapePropsMigrationSequence({
sequence: [
{

Wyświetl plik

@ -43,7 +43,7 @@ const Versions = createShapePropsMigrationIds('draw', {
export { Versions as drawShapeVersions }
/** @internal */
/** @public */
export const drawShapeMigrations = createShapePropsMigrationSequence({
sequence: [
{

Wyświetl plik

@ -659,7 +659,7 @@ const Versions = createShapePropsMigrationIds('embed', {
export { Versions as embedShapeVersions }
/** @internal */
/** @public */
export const embedShapeMigrations = createShapePropsMigrationSequence({
sequence: [
{

Wyświetl plik

@ -14,7 +14,7 @@ type TLFrameShapeProps = ShapePropsType<typeof frameShapeProps>
/** @public */
export type TLFrameShape = TLBaseShape<'frame', TLFrameShapeProps>
/** @internal */
/** @public */
export const frameShapeMigrations = createShapePropsMigrationSequence({
sequence: [],
})

Wyświetl plik

@ -83,7 +83,7 @@ const geoShapeVersions = createShapePropsMigrationIds('geo', {
export { geoShapeVersions as geoShapeVersions }
/** @internal */
/** @public */
export const geoShapeMigrations = createShapePropsMigrationSequence({
sequence: [
{

Wyświetl plik

@ -7,8 +7,8 @@ export type TLGroupShapeProps = { [key in never]: undefined }
/** @public */
export type TLGroupShape = TLBaseShape<'group', TLGroupShapeProps>
/** @internal */
/** @public */
export const groupShapeProps: ShapeProps<TLGroupShape> = {}
/** @internal */
/** @public */
export const groupShapeMigrations = createShapePropsMigrationSequence({ sequence: [] })

Wyświetl plik

@ -20,5 +20,5 @@ export type TLHighlightShapeProps = ShapePropsType<typeof highlightShapeProps>
/** @public */
export type TLHighlightShape = TLBaseShape<'highlight', TLHighlightShapeProps>
/** @internal */
/** @public */
export const highlightShapeMigrations = createShapePropsMigrationSequence({ sequence: [] })

Wyświetl plik

@ -40,7 +40,7 @@ const Versions = createShapePropsMigrationIds('image', {
export { Versions as imageShapeVersions }
/** @internal */
/** @public */
export const imageShapeMigrations = createShapePropsMigrationSequence({
sequence: [
{

Wyświetl plik

@ -42,7 +42,7 @@ export type TLLineShapeProps = ShapePropsType<typeof lineShapeProps>
/** @public */
export type TLLineShape = TLBaseShape<'line', TLLineShapeProps>
/** @internal */
/** @public */
export const lineShapeVersions = createShapePropsMigrationIds('line', {
AddSnapHandles: 1,
RemoveExtraHandleProps: 2,
@ -50,7 +50,7 @@ export const lineShapeVersions = createShapePropsMigrationIds('line', {
PointIndexIds: 4,
})
/** @internal */
/** @public */
export const lineShapeMigrations = createShapePropsMigrationSequence({
sequence: [
{

Wyświetl plik

@ -41,7 +41,7 @@ const Versions = createShapePropsMigrationIds('note', {
export { Versions as noteShapeVersions }
/** @internal */
/** @public */
export const noteShapeMigrations = createShapePropsMigrationSequence({
sequence: [
{

Wyświetl plik

@ -34,7 +34,7 @@ const Versions = createShapePropsMigrationIds('text', {
export { Versions as textShapeVersions }
/** @internal */
/** @public */
export const textShapeMigrations = createShapePropsMigrationSequence({
sequence: [
{

Wyświetl plik

@ -30,7 +30,7 @@ const Versions = createShapePropsMigrationIds('video', {
export { Versions as videoShapeVersions }
/** @internal */
/** @public */
export const videoShapeMigrations = createShapePropsMigrationSequence({
sequence: [
{

Wyświetl plik

@ -1,296 +0,0 @@
## API Report File for "@tldraw/tlsync"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { Atom } from 'signia';
import { BaseRecord } from '@tldraw/tlstore';
import { MigrationFailureReason } from '@tldraw/tlstore';
import { RecordsDiff } from '@tldraw/tlstore';
import { RecordType } from '@tldraw/tlstore';
import { Result } from '@tldraw/utils';
import { SerializedSchema } from '@tldraw/tlstore';
import { Signal } from 'signia';
import { Store } from '@tldraw/tlstore';
import { StoreSchema } from '@tldraw/tlstore';
import * as WebSocket_2 from 'ws';
// @public (undocumented)
export type AppendOp = [type: ValueOpType.Append, values: unknown[], offset: number];
// @public (undocumented)
export function applyObjectDiff<T extends object>(object: T, objectDiff: ObjectDiff): T;
// @public (undocumented)
export type DeleteOp = [type: ValueOpType.Delete];
// @public (undocumented)
export function diffRecord(prev: object, next: object): null | ObjectDiff;
// @public
export const getNetworkDiff: <R extends BaseRecord<string>>(diff: RecordsDiff<R>) => NetworkDiff<R> | null;
// @public
export type NetworkDiff<R extends BaseRecord> = {
[id: string]: RecordOp<R>;
};
// @public (undocumented)
export type ObjectDiff = {
[k: string]: ValueOp;
};
// @public (undocumented)
export type PatchOp = [type: ValueOpType.Patch, diff: ObjectDiff];
// @public (undocumented)
export type PersistedRoomSnapshot = {
id: string;
slug: string;
drawing: RoomSnapshot;
};
// @public (undocumented)
export type PutOp = [type: ValueOpType.Put, value: unknown];
// @public (undocumented)
export type RecordOp<R extends BaseRecord> = [RecordOpType.Patch, ObjectDiff] | [RecordOpType.Put, R] | [RecordOpType.Remove];
// @public (undocumented)
export enum RecordOpType {
// (undocumented)
Patch = "patch",
// (undocumented)
Put = "put",
// (undocumented)
Remove = "remove"
}
// @public (undocumented)
export type RoomClient<R extends BaseRecord> = {
serializedSchema: SerializedSchema;
socket: TLRoomSocket<R>;
id: string;
};
// @public (undocumented)
export type RoomForId = {
id: string;
persistenceId?: string;
timeout?: any;
room: TLSyncRoom<any>;
clients: Map<WebSocket_2.WebSocket, RoomClient<any>>;
};
// @public (undocumented)
export type RoomSnapshot = {
clock: number;
documents: Array<{
state: BaseRecord;
lastChangedClock: number;
}>;
tombstones?: Record<string, number>;
schema?: SerializedSchema;
};
// @public
export function serializeMessage(message: Message): string;
// @public (undocumented)
export type TLConnectRequest = {
type: 'connect';
lastServerClock: number;
protocolVersion: number;
schema: SerializedSchema;
instanceId: string;
};
// @public (undocumented)
export enum TLIncompatibilityReason {
// (undocumented)
ClientTooOld = "clientTooOld",
// (undocumented)
InvalidOperation = "invalidOperation",
// (undocumented)
InvalidRecord = "invalidRecord",
// (undocumented)
ServerTooOld = "serverTooOld"
}
// @public
export type TLPersistentClientSocket<R extends BaseRecord = BaseRecord> = {
connectionStatus: 'error' | 'offline' | 'online';
sendMessage: (msg: TLSocketClientSentEvent<R>) => void;
onReceiveMessage: SubscribingFn<TLSocketServerSentEvent<R>>;
onStatusChange: SubscribingFn<TLPersistentClientSocketStatus>;
restart: () => void;
};
// @public (undocumented)
export type TLPersistentClientSocketStatus = 'error' | 'offline' | 'online';
// @public (undocumented)
export type TLPingRequest = {
type: 'ping';
};
// @public (undocumented)
export type TLPushRequest<R extends BaseRecord> = {
type: 'push';
clientClock: number;
diff: NetworkDiff<R>;
} | {
type: 'push';
clientClock: number;
presence: [RecordOpType.Patch, ObjectDiff] | [RecordOpType.Put, R];
};
// @public (undocumented)
export type TLRoomSocket<R extends BaseRecord> = {
isOpen: boolean;
sendMessage: (msg: TLSocketServerSentEvent<R>) => void;
};
// @public
export abstract class TLServer {
abstract deleteRoomForId(roomId: string): void;
abstract getRoomForId(roomId: string): RoomForId | undefined;
handleConnection: (ws: WebSocket_2.WebSocket, roomId: string) => Promise<void>;
loadFromDatabase?(roomId: string): Promise<PersistedRoomSnapshot | undefined>;
logEvent?(_event: {
roomId: string;
name: string;
clientId?: string;
}): void;
persistToDatabase?(roomId: string): Promise<void>;
abstract setRoomForId(roomId: string, roomForId: RoomForId): void;
}
// @public (undocumented)
export type TLSocketClientSentEvent<R extends BaseRecord> = TLConnectRequest | TLPingRequest | TLPushRequest<R>;
// @public (undocumented)
export type TLSocketServerSentEvent<R extends BaseRecord> = {
type: 'connect';
hydrationType: 'wipe_all' | 'wipe_presence';
protocolVersion: number;
schema: SerializedSchema;
diff: NetworkDiff<R>;
serverClock: number;
} | {
type: 'error';
error?: any;
} | {
type: 'incompatibility_error';
reason: TLIncompatibilityReason;
} | {
type: 'patch';
diff: NetworkDiff<R>;
serverClock: number;
} | {
type: 'pong';
} | {
type: 'push_result';
clientClock: number;
serverClock: number;
action: 'commit' | 'discard' | {
rebaseWithDiff: NetworkDiff<R>;
};
};
// @public (undocumented)
export const TLSYNC_PROTOCOL_VERSION = 3;
// @public
export class TLSyncClient<R extends BaseRecord, S extends Store<R> = Store<R>> {
constructor(config: {
store: S;
socket: TLPersistentClientSocket<R>;
instanceId: string;
onLoad: (self: TLSyncClient<R, S>) => void;
onLoadError: (error: Error) => void;
onSyncError: (reason: TLIncompatibilityReason) => void;
onAfterConnect?: (self: TLSyncClient<R, S>) => void;
});
// (undocumented)
close(): void;
// (undocumented)
incomingDiffBuffer: Extract<TLSocketServerSentEvent<R>, {
type: 'patch' | 'push_result';
}>[];
readonly instanceId: string;
// (undocumented)
isConnectedToRoom: boolean;
// (undocumented)
lastPushedPresenceState: null | R;
readonly onAfterConnect?: (self: TLSyncClient<R, S>) => void;
// (undocumented)
readonly onSyncError: (reason: TLIncompatibilityReason) => void;
// (undocumented)
readonly presenceState: Signal<null | R>;
// (undocumented)
readonly socket: TLPersistentClientSocket<R>;
// (undocumented)
readonly store: S;
}
// @public
export class TLSyncRoom<R extends BaseRecord> {
constructor(schema: StoreSchema<R>, snapshot?: RoomSnapshot);
addClient(client: RoomClient<R>): void;
broadcastPatch({ diff, sourceClient }: {
diff: NetworkDiff<R>;
sourceClient: RoomClient<R>;
}): this;
// (undocumented)
clientIdsToInstanceIds: Map<string, string>;
// (undocumented)
clients: Map<string, RoomClient<R>>;
// (undocumented)
clock: number;
// (undocumented)
readonly documentTypes: Set<string>;
// (undocumented)
getSnapshot(): RoomSnapshot;
handleClose: (client: RoomClient<R>) => void;
handleConnection: (client: RoomClient<R>) => this;
handleMessage: (client: RoomClient<R>, message: TLSocketClientSentEvent<R>) => Promise<this | void>;
migrateDiffForClient(client: RoomClient<R>, diff: NetworkDiff<R>): Result<NetworkDiff<R>, MigrationFailureReason>;
// (undocumented)
readonly presenceType: RecordType<R, any>;
// (undocumented)
pruneTombstones(): void;
removeClient(client: RoomClient<R>): void;
// (undocumented)
readonly schema: StoreSchema<R>;
sendMessageToClient(client: RoomClient<R>, message: TLSocketServerSentEvent<R>): this;
// (undocumented)
readonly serializedSchema: SerializedSchema;
// (undocumented)
state: Atom<{
documents: Record<string, DocumentState<R>>;
tombstones: Record<string, number>;
}, unknown>;
// (undocumented)
tombstoneHistoryStartsAtClock: number;
}
// @public (undocumented)
export type ValueOp = AppendOp | DeleteOp | PatchOp | PutOp;
// @public (undocumented)
export enum ValueOpType {
// (undocumented)
Append = "append",
// (undocumented)
Delete = "delete",
// (undocumented)
Patch = "patch",
// (undocumented)
Put = "put"
}
// (No @packageDocumentation comment for this package)
```

Wyświetl plik

@ -1,5 +1,11 @@
export { TLServer, type DBLoadResult, type TLServerEvent } from './lib/TLServer'
export {
TLServer,
type DBLoadResult,
type DBLoadResultType,
type TLServerEvent,
} from './lib/TLServer'
export {
TLCloseEventCode,
TLSyncClient,
type TLPersistentClientSocket,
type TLPersistentClientSocketStatus,

Wyświetl plik

@ -6,7 +6,7 @@ import { JsonChunkAssembler } from './chunk'
import { schema } from './schema'
import { RoomState } from './server-types'
type LoadKind = 'new' | 'reopen' | 'open'
type LoadKind = 'reopen' | 'open' | 'room_not_found'
export type DBLoadResult =
| {
type: 'error'
@ -19,6 +19,7 @@ export type DBLoadResult =
| {
type: 'room_not_found'
}
export type DBLoadResultType = DBLoadResult['type']
export type TLServerEvent =
| {
@ -54,10 +55,10 @@ export type TLServerEvent =
export abstract class TLServer {
schema = schema
async getInitialRoomState(persistenceKey: string): Promise<[RoomState, LoadKind]> {
async getInitialRoomState(persistenceKey: string): Promise<[RoomState | undefined, LoadKind]> {
let roomState = this.getRoomForPersistenceKey(persistenceKey)
let roomOpenKind = 'open' as 'open' | 'reopen' | 'new'
let roomOpenKind: LoadKind = 'open'
// If no room exists for the id, create one
if (roomState === undefined) {
@ -78,14 +79,22 @@ export abstract class TLServer {
}
}
// If we still don't have a room, create a new one
// If we still don't have a room, throw an error.
if (roomState === undefined) {
roomOpenKind = 'new'
roomState = {
persistenceKey,
room: new TLSyncRoom(this.schema),
}
// This is how it bubbles down to the client:
// 1.) From here, we send back a `room_not_found` to TLDrawDurableObject.
// 2.) In TLDrawDurableObject, we accept and then immediately close the client.
// This lets us send a TLCloseEventCode.NOT_FOUND closeCode down to the client.
// 3.) joinExistingRoom which handles the websocket upgrade is not affected.
// Again, we accept the connection, it's just that we immediately close right after.
// 4.) In ClientWebSocketAdapter, ws.onclose is called, and that calls _handleDisconnect.
// 5.) _handleDisconnect sets the status to 'error' and calls the onStatusChange callback.
// 6.) On the dotcom app in useRemoteSyncClient, we have socket.onStatusChange callback
// where we set TLIncompatibilityReason.RoomNotFound and close the client + socket.
// 7.) Finally on the dotcom app we use StoreErrorScreen to display an appropriate msg.
//
// Phew!
return [roomState, 'room_not_found']
}
const thisRoom = roomState.room
@ -138,9 +147,13 @@ export abstract class TLServer {
persistenceKey: string
sessionKey: string
storeId: string
}) => {
}): Promise<DBLoadResultType> => {
const clientId = nanoid()
const [roomState, roomOpenKind] = await this.getInitialRoomState(persistenceKey)
if (roomOpenKind === 'room_not_found' || !roomState) {
return 'room_not_found'
}
roomState.room.handleNewSession(
sessionKey,
@ -156,7 +169,7 @@ export abstract class TLServer {
})
)
if (roomOpenKind === 'new' || roomOpenKind === 'reopen') {
if (roomOpenKind === 'reopen') {
// Record that the room is now active
this.logEvent({ type: 'room', roomId: persistenceKey, name: 'room_start' })
@ -164,7 +177,7 @@ export abstract class TLServer {
this.logEvent({
type: 'client',
roomId: persistenceKey,
name: roomOpenKind === 'new' ? 'room_create' : 'room_reopen',
name: 'room_reopen',
clientId,
instanceId: sessionKey,
localClientId: storeId,
@ -237,6 +250,8 @@ export abstract class TLServer {
socket.addEventListener('message', handleMessageFromClient)
socket.addEventListener('close', handleCloseOrErrorFromClient)
socket.addEventListener('error', handleCloseOrErrorFromClient)
return 'room_found'
}
/**

Wyświetl plik

@ -24,6 +24,17 @@ import './requestAnimationFrame.polyfill'
type SubscribingFn<T> = (cb: (val: T) => void) => () => void
/**
* These are our private codes to be sent from server->client.
* They are in the private range of the websocket code range.
* See: https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent/code
*
* @public
*/
export const TLCloseEventCode = {
NOT_FOUND: 4099,
} as const
/** @public */
export type TLPersistentClientSocketStatus = 'online' | 'offline' | 'error'
/**
@ -236,6 +247,7 @@ export class TLSyncClient<R extends UnknownRecord, S extends Store<R> = Store<R>
})
)
}
// if the socket is already online before this client was instantiated
// then we should send a connect message right away
if (this.socket.connectionStatus === 'online') {

Wyświetl plik

@ -10,6 +10,7 @@ export const TLIncompatibilityReason = {
ServerTooOld: 'serverTooOld',
InvalidRecord: 'invalidRecord',
InvalidOperation: 'invalidOperation',
RoomNotFound: 'roomNotFound',
} as const
/** @public */

Wyświetl plik

@ -1,86 +1,5 @@
import {
arrowShapeMigrations,
arrowShapeProps,
bookmarkShapeMigrations,
bookmarkShapeProps,
createTLSchema,
drawShapeMigrations,
drawShapeProps,
embedShapeMigrations,
embedShapeProps,
frameShapeMigrations,
frameShapeProps,
geoShapeMigrations,
geoShapeProps,
groupShapeMigrations,
groupShapeProps,
highlightShapeMigrations,
highlightShapeProps,
imageShapeMigrations,
imageShapeProps,
lineShapeMigrations,
lineShapeProps,
noteShapeMigrations,
noteShapeProps,
textShapeMigrations,
textShapeProps,
videoShapeMigrations,
videoShapeProps,
} from '@tldraw/tlschema'
import { createTLSchema, defaultShapeSchemas } from '@tldraw/tlschema'
export const schema = createTLSchema({
shapes: {
group: {
props: groupShapeProps,
migrations: groupShapeMigrations,
},
text: {
props: textShapeProps,
migrations: textShapeMigrations,
},
bookmark: {
props: bookmarkShapeProps,
migrations: bookmarkShapeMigrations,
},
draw: {
props: drawShapeProps,
migrations: drawShapeMigrations,
},
geo: {
props: geoShapeProps,
migrations: geoShapeMigrations,
},
note: {
props: noteShapeProps,
migrations: noteShapeMigrations,
},
line: {
props: lineShapeProps,
migrations: lineShapeMigrations,
},
frame: {
props: frameShapeProps,
migrations: frameShapeMigrations,
},
arrow: {
props: arrowShapeProps,
migrations: arrowShapeMigrations,
},
highlight: {
props: highlightShapeProps,
migrations: highlightShapeMigrations,
},
embed: {
props: embedShapeProps,
migrations: embedShapeMigrations,
},
image: {
props: imageShapeProps,
migrations: imageShapeMigrations,
},
video: {
props: videoShapeProps,
migrations: videoShapeMigrations,
},
},
shapes: defaultShapeSchemas,
})

Wyświetl plik

@ -1,7 +1,17 @@
import { TLRecord, createTLStore, defaultShapeUtils } from 'tldraw'
import {
DocumentRecordType,
PageRecordType,
RecordId,
TLDocument,
TLRecord,
ZERO_INDEX_KEY,
createTLStore,
defaultShapeUtils,
} from 'tldraw'
import { type WebSocket } from 'ws'
import { RoomSessionState } from '../lib/RoomSession'
import { DBLoadResult, TLServer } from '../lib/TLServer'
import { RoomSnapshot } from '../lib/TLSyncRoom'
import { chunk } from '../lib/chunk'
import { RecordOpType } from '../lib/diff'
import { TLSYNC_PROTOCOL_VERSION, TLSocketClientSentEvent } from '../lib/protocol'
@ -17,6 +27,16 @@ const PORT = 23473
const disposables: (() => void)[] = []
const records = [
DocumentRecordType.create({ id: 'document:document' as RecordId<TLDocument> }),
PageRecordType.create({ index: ZERO_INDEX_KEY, name: 'page 2' }),
]
const makeSnapshot = (records: TLRecord[], others: Partial<RoomSnapshot> = {}) => ({
documents: records.map((r) => ({ state: r, lastChangedClock: 0 })),
clock: 0,
...others,
})
class TLServerTestImpl extends TLServer {
wsServer = new ws.Server({ port: PORT })
async close() {
@ -54,7 +74,7 @@ class TLServerTestImpl extends TLServer {
}
}
override async loadFromDatabase?(_roomId: string): Promise<DBLoadResult> {
return { type: 'room_not_found' }
return { type: 'room_found', snapshot: makeSnapshot(records) }
}
override async persistToDatabase?(_roomId: string): Promise<void> {
return
@ -84,15 +104,20 @@ beforeEach(async () => {
sockets = await server.createSocketPair()
expect(sockets.client.readyState).toBe(ws.OPEN)
expect(sockets.server.readyState).toBe(ws.OPEN)
server.loadFromDatabase = async (_roomId: string): Promise<DBLoadResult> => {
return { type: 'room_found', snapshot: makeSnapshot(records) }
}
})
const openConnection = async () => {
await server.handleConnection({
const result = await server.handleConnection({
persistenceKey: 'test-persistence-key',
sessionKey: 'test-session-key',
socket: sockets.server,
storeId: 'test-store-id',
})
return result
}
afterEach(async () => {
@ -162,4 +187,14 @@ describe('TLServer', () => {
},
})
})
it('sends a room_not_found when room is not found', async () => {
server.loadFromDatabase = async (_roomId: string): Promise<DBLoadResult> => {
return { type: 'room_not_found' }
}
const connectionResult = await openConnection()
expect(connectionResult).toBe('room_not_found')
})
})

Wyświetl plik

@ -7456,6 +7456,17 @@ __metadata:
languageName: unknown
linkType: soft
"@tldraw/dotcom-shared@workspace:*, @tldraw/dotcom-shared@workspace:packages/dotcom-shared":
version: 0.0.0-use.local
resolution: "@tldraw/dotcom-shared@workspace:packages/dotcom-shared"
dependencies:
tldraw: "workspace:*"
peerDependencies:
react: ^18
react-dom: ^18
languageName: unknown
linkType: soft
"@tldraw/dotcom-worker@workspace:apps/dotcom-worker":
version: 0.0.0-use.local
resolution: "@tldraw/dotcom-worker@workspace:apps/dotcom-worker"
@ -7463,6 +7474,7 @@ __metadata:
"@cloudflare/workers-types": "npm:^4.20230821.0"
"@supabase/auth-helpers-remix": "npm:^0.2.2"
"@supabase/supabase-js": "npm:^2.33.2"
"@tldraw/dotcom-shared": "workspace:*"
"@tldraw/store": "workspace:*"
"@tldraw/tlschema": "workspace:*"
"@tldraw/tlsync": "workspace:*"
@ -11914,6 +11926,7 @@ __metadata:
"@sentry/integrations": "npm:^7.34.0"
"@sentry/react": "npm:^7.77.0"
"@tldraw/assets": "workspace:*"
"@tldraw/dotcom-shared": "workspace:*"
"@tldraw/tlsync": "workspace:*"
"@tldraw/utils": "workspace:*"
"@tldraw/validate": "workspace:*"