kopia lustrzana https://github.com/Tldraw/Tldraw
Merge branch 'main' into desmos-embed
commit
fcca4c57f2
|
@ -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:*",
|
||||
|
|
|
@ -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 })
|
||||
}
|
||||
|
||||
|
|
|
@ -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 }))
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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)
|
||||
)
|
||||
}
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -7,6 +7,9 @@
|
|||
"emitDeclarationOnly": false
|
||||
},
|
||||
"references": [
|
||||
{
|
||||
"path": "../../packages/dotcom-shared"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/store"
|
||||
},
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -26,6 +26,10 @@ exports[`the_routes 1`] = `
|
|||
"reactRouterPattern": "/r/:roomId",
|
||||
"vercelRouterPattern": "^/r/[^/]*/?$",
|
||||
},
|
||||
{
|
||||
"reactRouterPattern": "/ro/:roomId",
|
||||
"vercelRouterPattern": "^/ro/[^/]*/?$",
|
||||
},
|
||||
{
|
||||
"reactRouterPattern": "/s/:roomId",
|
||||
"vercelRouterPattern": "^/s/[^/]*/?$",
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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, too—and hey, if we decide to offer a hosted thing, then that's another stor
|
||||
risky for us and for them, too—and 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">
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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 }} />
|
||||
}
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
|
|
|
@ -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}`} />
|
||||
}
|
|
@ -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="/"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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]
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -183,6 +183,7 @@ a {
|
|||
font-weight: 500;
|
||||
color: var(--text-color-2);
|
||||
padding: 12px 4px;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* ------------------ Board history ----------------- */
|
||||
|
|
|
@ -28,6 +28,9 @@
|
|||
{
|
||||
"path": "../../packages/assets"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/dotcom-shared"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/tldraw"
|
||||
},
|
||||
|
|
|
@ -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 |
|
@ -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",
|
||||
|
|
|
@ -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)
|
||||
|
||||
```
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
it('works', () => {
|
||||
// we need a test for jest to pass.
|
||||
})
|
|
@ -0,0 +1,8 @@
|
|||
export { ROOM_OPEN_MODE, RoomOpenModeToPath, type RoomOpenMode } from './routes'
|
||||
export type {
|
||||
CreateRoomRequestBody,
|
||||
CreateSnapshotRequestBody,
|
||||
CreateSnapshotResponseBody,
|
||||
GetReadonlySlugResponseBody,
|
||||
Snapshot,
|
||||
} from './types'
|
|
@ -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',
|
||||
}
|
|
@ -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 }
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"extends": "../../config/tsconfig.base.json",
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", ".tsbuild*"],
|
||||
"compilerOptions": {
|
||||
"outDir": "./.tsbuild",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"references": [
|
||||
{
|
||||
"path": "../tldraw"
|
||||
}
|
||||
]
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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
|
@ -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'
|
||||
|
|
|
@ -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':
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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> {
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -88,7 +88,7 @@ export const arrowShapeVersions = createShapePropsMigrationIds('arrow', {
|
|||
AddLabelPosition: 3,
|
||||
})
|
||||
|
||||
/** @internal */
|
||||
/** @public */
|
||||
export const arrowShapeMigrations = createShapePropsMigrationSequence({
|
||||
sequence: [
|
||||
{
|
||||
|
|
|
@ -28,7 +28,7 @@ const Versions = createShapePropsMigrationIds('bookmark', {
|
|||
|
||||
export { Versions as bookmarkShapeVersions }
|
||||
|
||||
/** @internal */
|
||||
/** @public */
|
||||
export const bookmarkShapeMigrations = createShapePropsMigrationSequence({
|
||||
sequence: [
|
||||
{
|
||||
|
|
|
@ -43,7 +43,7 @@ const Versions = createShapePropsMigrationIds('draw', {
|
|||
|
||||
export { Versions as drawShapeVersions }
|
||||
|
||||
/** @internal */
|
||||
/** @public */
|
||||
export const drawShapeMigrations = createShapePropsMigrationSequence({
|
||||
sequence: [
|
||||
{
|
||||
|
|
|
@ -659,7 +659,7 @@ const Versions = createShapePropsMigrationIds('embed', {
|
|||
|
||||
export { Versions as embedShapeVersions }
|
||||
|
||||
/** @internal */
|
||||
/** @public */
|
||||
export const embedShapeMigrations = createShapePropsMigrationSequence({
|
||||
sequence: [
|
||||
{
|
||||
|
|
|
@ -14,7 +14,7 @@ type TLFrameShapeProps = ShapePropsType<typeof frameShapeProps>
|
|||
/** @public */
|
||||
export type TLFrameShape = TLBaseShape<'frame', TLFrameShapeProps>
|
||||
|
||||
/** @internal */
|
||||
/** @public */
|
||||
export const frameShapeMigrations = createShapePropsMigrationSequence({
|
||||
sequence: [],
|
||||
})
|
||||
|
|
|
@ -83,7 +83,7 @@ const geoShapeVersions = createShapePropsMigrationIds('geo', {
|
|||
|
||||
export { geoShapeVersions as geoShapeVersions }
|
||||
|
||||
/** @internal */
|
||||
/** @public */
|
||||
export const geoShapeMigrations = createShapePropsMigrationSequence({
|
||||
sequence: [
|
||||
{
|
||||
|
|
|
@ -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: [] })
|
||||
|
|
|
@ -20,5 +20,5 @@ export type TLHighlightShapeProps = ShapePropsType<typeof highlightShapeProps>
|
|||
/** @public */
|
||||
export type TLHighlightShape = TLBaseShape<'highlight', TLHighlightShapeProps>
|
||||
|
||||
/** @internal */
|
||||
/** @public */
|
||||
export const highlightShapeMigrations = createShapePropsMigrationSequence({ sequence: [] })
|
||||
|
|
|
@ -40,7 +40,7 @@ const Versions = createShapePropsMigrationIds('image', {
|
|||
|
||||
export { Versions as imageShapeVersions }
|
||||
|
||||
/** @internal */
|
||||
/** @public */
|
||||
export const imageShapeMigrations = createShapePropsMigrationSequence({
|
||||
sequence: [
|
||||
{
|
||||
|
|
|
@ -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: [
|
||||
{
|
||||
|
|
|
@ -41,7 +41,7 @@ const Versions = createShapePropsMigrationIds('note', {
|
|||
|
||||
export { Versions as noteShapeVersions }
|
||||
|
||||
/** @internal */
|
||||
/** @public */
|
||||
export const noteShapeMigrations = createShapePropsMigrationSequence({
|
||||
sequence: [
|
||||
{
|
||||
|
|
|
@ -34,7 +34,7 @@ const Versions = createShapePropsMigrationIds('text', {
|
|||
|
||||
export { Versions as textShapeVersions }
|
||||
|
||||
/** @internal */
|
||||
/** @public */
|
||||
export const textShapeMigrations = createShapePropsMigrationSequence({
|
||||
sequence: [
|
||||
{
|
||||
|
|
|
@ -30,7 +30,7 @@ const Versions = createShapePropsMigrationIds('video', {
|
|||
|
||||
export { Versions as videoShapeVersions }
|
||||
|
||||
/** @internal */
|
||||
/** @public */
|
||||
export const videoShapeMigrations = createShapePropsMigrationSequence({
|
||||
sequence: [
|
||||
{
|
||||
|
|
|
@ -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)
|
||||
|
||||
```
|
|
@ -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,
|
||||
|
|
|
@ -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'
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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') {
|
||||
|
|
|
@ -10,6 +10,7 @@ export const TLIncompatibilityReason = {
|
|||
ServerTooOld: 'serverTooOld',
|
||||
InvalidRecord: 'invalidRecord',
|
||||
InvalidOperation: 'invalidOperation',
|
||||
RoomNotFound: 'roomNotFound',
|
||||
} as const
|
||||
|
||||
/** @public */
|
||||
|
|
|
@ -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,
|
||||
})
|
||||
|
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
13
yarn.lock
13
yarn.lock
|
@ -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:*"
|
||||
|
|
Ładowanie…
Reference in New Issue