lockCameraToFrame almost working

This commit is contained in:
Jeff Emmett 2024-12-08 02:43:19 -05:00
parent 5891e97ab5
commit 3c8f4d7fd1
10 changed files with 461 additions and 310 deletions

View File

@ -15,12 +15,20 @@ import { ChatBoxTool } from "./tools/ChatBoxTool"
import { VideoChatTool } from "./tools/VideoChatTool"
import { EmbedTool } from "./tools/EmbedTool"
import { EmbedShape } from "./shapes/EmbedShapeUtil"
import { MarkdownShape } from "./shapes/MarkdownShapeUtil"
import { MarkdownTool } from "./tools/MarkdownTool"
import { createRoot } from "react-dom/client"
import { handleInitialPageLoad } from "@/utils/handleInitialPageLoad"
inject()
const customShapeUtils = [ChatBoxShape, VideoChatShape, EmbedShape]
const customTools = [ChatBoxTool, VideoChatTool, EmbedTool]
const customShapeUtils = [
ChatBoxShape,
VideoChatShape,
EmbedShape,
MarkdownShape,
]
const customTools = [ChatBoxTool, VideoChatTool, EmbedTool, MarkdownTool]
export default function InteractiveShapeExample() {
return (
@ -31,7 +39,7 @@ export default function InteractiveShapeExample() {
overrides={overrides}
components={components}
onMount={(editor) => {
handleInitialShapeLoad(editor)
handleInitialPageLoad(editor)
editor.createShape({ type: "my-interactive-shape", x: 100, y: 100 })
}}
/>
@ -39,41 +47,6 @@ export default function InteractiveShapeExample() {
)
}
const handleInitialShapeLoad = (editor: Editor) => {
const url = new URL(window.location.href)
const shapeId =
url.searchParams.get("shapeId") || url.searchParams.get("frameId")
const x = url.searchParams.get("x")
const y = url.searchParams.get("y")
const zoom = url.searchParams.get("zoom")
if (shapeId) {
console.log("Found shapeId in URL:", shapeId)
const shape = editor.getShape(shapeId as TLShapeId)
if (shape) {
console.log("Found shape:", shape)
if (x && y && zoom) {
console.log("Setting camera to:", { x, y, zoom })
editor.setCamera({
x: parseFloat(x),
y: parseFloat(y),
z: parseFloat(zoom),
})
} else {
console.log("Zooming to shape bounds")
editor.zoomToBounds(editor.getShapeGeometry(shape).bounds, {
targetZoom: 1,
})
}
} else {
console.warn("Shape not found in the editor")
}
} else {
console.warn("No shapeId found in the URL")
}
}
createRoot(document.getElementById("root")!).render(<App />)
function App() {

View File

@ -9,17 +9,20 @@ import { VideoChatShape } from "@/shapes/VideoChatShapeUtil"
import { multiplayerAssetStore } from "../utils/multiplayerAssetStore"
import { EmbedShape } from "@/shapes/EmbedShapeUtil"
import { EmbedTool } from "@/tools/EmbedTool"
import { MarkdownShape } from "@/shapes/MarkdownShapeUtil"
import { MarkdownTool } from "@/tools/MarkdownTool"
import { defaultShapeUtils, defaultBindingUtils } from "tldraw"
import { useState } from "react"
import { components } from "@/ui/components"
import { overrides } from "@/ui/overrides"
import { unfurlBookmarkUrl } from "../utils/unfurlBookmarkUrl"
import { handleInitialPageLoad } from "@/utils/handleInitialPageLoad"
// Default to production URL if env var isn't available
export const WORKER_URL = "https://jeffemmett-canvas.jeffemmett.workers.dev"
const shapeUtils = [ChatBoxShape, VideoChatShape, EmbedShape]
const tools = [ChatBoxTool, VideoChatTool, EmbedTool] // Array of tools
const shapeUtils = [ChatBoxShape, VideoChatShape, EmbedShape, MarkdownShape]
const tools = [ChatBoxTool, VideoChatTool, EmbedTool, MarkdownTool] // Array of tools
export function Board() {
const { slug } = useParams<{ slug: string }>()
@ -46,10 +49,12 @@ export function Board() {
tools={tools}
components={components}
overrides={overrides}
//maxZoom={20}
onMount={(editor) => {
setEditor(editor)
editor.registerExternalAssetHandler("url", unfurlBookmarkUrl)
editor.setCurrentTool("hand")
handleInitialPageLoad(editor)
}}
/>
</div>

View File

@ -0,0 +1,32 @@
/** TODO: build this */
import { BaseBoxShapeUtil, TLBaseBoxShape, TLBaseShape } from "tldraw"
export type IMarkdownShape = TLBaseShape<
"MarkdownTool",
{
content: string
}
>
export class MarkdownShape extends BaseBoxShapeUtil<
IMarkdownShape & TLBaseBoxShape
> {
static override type = "MarkdownTool"
indicator(_shape: IMarkdownShape) {
return null
}
getDefaultProps(): IMarkdownShape["props"] & { w: number; h: number } {
return {
content: "",
w: 100,
h: 100,
}
}
component(shape: IMarkdownShape) {
return <div>{shape.props.content}</div>
}
}

View File

@ -0,0 +1,7 @@
import { BaseBoxShapeTool } from "tldraw"
export class MarkdownTool extends BaseBoxShapeTool {
static override id = "MarkdownTool"
shapeType = "MarkdownTool"
override initial = "idle"
}

View File

@ -1,37 +1,50 @@
import { TldrawUiMenuItem } from "tldraw"
import { TldrawUiMenuItem, TLShape } from "tldraw"
import { TldrawUiMenuGroup } from "tldraw"
import { DefaultContextMenuContent } from "tldraw"
import { DefaultContextMenu } from "tldraw"
import { TLUiContextMenuProps, useEditor } from "tldraw"
import {
cameraHistory,
copyLinkToCurrentView,
lockCameraToFrame,
revertCamera,
zoomToSelection,
} from "./cameraUtils"
export function CustomContextMenu(props: TLUiContextMenuProps) {
const editor = useEditor()
const hasSelection = editor.getSelectedShapeIds().length > 0
const selectedShapes = editor.getSelectedShapes()
const selectedIds = editor.getSelectedShapeIds()
// Add debug logs
console.log(
"Selected Shapes:",
selectedShapes.map((shape) => ({
id: shape.id,
type: shape.type,
})),
)
console.log(
"Selected Frame:",
selectedShapes.length === 1 && selectedShapes[0].type === "frame",
)
const hasSelection = selectedIds.length > 0
const hasCameraHistory = cameraHistory.length > 0
const selectedShape = editor.getSelectedShapes()[0]
const isFrame = selectedShape?.type === "frame"
// Check if exactly one frame is selected
const hasFrameSelected =
selectedShapes.length === 1 && selectedShapes[0].type === "frame"
return (
<DefaultContextMenu {...props}>
<DefaultContextMenuContent />
{/* Camera Controls Group */}
<TldrawUiMenuGroup id="camera-controls">
<TldrawUiMenuItem
id="zoom-to-selection"
label="Zoom to Selection"
icon="zoom-in"
kbd="z"
kbd="alt +z"
disabled={!hasSelection}
onSelect={() => zoomToSelection(editor)}
/>
@ -39,14 +52,14 @@ export function CustomContextMenu(props: TLUiContextMenuProps) {
id="copy-link-to-current-view"
label="Copy Link to Current View"
icon="link"
kbd="s"
kbd="alt+s"
onSelect={() => copyLinkToCurrentView(editor)}
/>
<TldrawUiMenuItem
id="revert-camera"
label="Revert Camera"
icon="undo"
kbd="b"
kbd="alt+b"
disabled={!hasCameraHistory}
onSelect={() => revertCamera(editor)}
/>
@ -58,7 +71,8 @@ export function CustomContextMenu(props: TLUiContextMenuProps) {
id="video-chat"
label="Create Video Chat"
icon="video"
kbd="v"
kbd="alt+v"
disabled={hasSelection}
onSelect={() => {
editor.setCurrentTool("VideoChat")
}}
@ -67,7 +81,8 @@ export function CustomContextMenu(props: TLUiContextMenuProps) {
id="chat-box"
label="Create Chat Box"
icon="chat"
kbd="c"
kbd="alt+c"
disabled={hasSelection}
onSelect={() => {
editor.setCurrentTool("ChatBox")
}}
@ -76,27 +91,36 @@ export function CustomContextMenu(props: TLUiContextMenuProps) {
id="embed"
label="Create Embed"
icon="embed"
kbd="e"
kbd="alt+e"
disabled={hasSelection}
onSelect={() => {
editor.setCurrentTool("Embed")
}}
/>
<TldrawUiMenuItem
id="markdown"
label="Create Markdown"
icon="markdown"
kbd="alt+m"
disabled={hasSelection}
onSelect={() => {
editor.setCurrentTool("Markdown")
}}
/>
</TldrawUiMenuGroup>
{/* Frame Controls */}
{isFrame && (
<TldrawUiMenuGroup id="frame-controls">
<TldrawUiMenuItem
id="lock-to-frame"
label="Lock to Frame"
icon="lock"
kbd="l"
onSelect={() => {
console.warn("lock to frame NOT IMPLEMENTED")
}}
/>
</TldrawUiMenuGroup>
)}
<TldrawUiMenuGroup id="frame-controls">
<TldrawUiMenuItem
id="lock-to-frame"
label="Lock to Frame"
icon="lock"
kbd="shift+l"
disabled={!hasFrameSelected}
onSelect={() => lockCameraToFrame(editor)}
/>
</TldrawUiMenuGroup>
<DefaultContextMenuContent />
</DefaultContextMenu>
)
}

View File

@ -34,6 +34,14 @@ export function CustomToolbar() {
isSelected={tools["Embed"].id === editor.getCurrentToolId()}
/>
)}
{tools["Markdown"] && (
<TldrawUiMenuItem
{...tools["Markdown"]}
icon="markdown"
label="Markdown"
isSelected={tools["Markdown"].id === editor.getCurrentToolId()}
/>
)}
</DefaultToolbar>
)
}

View File

@ -44,11 +44,11 @@ export const zoomToSelection = (editor: Editor) => {
// Calculate target zoom based on selection size
let targetZoom
if (widthRatio < 0.1 || heightRatio < 0.1) {
// For very small selections, zoom in up to 8x
// For very small selections, zoom in up to 20x
targetZoom = Math.min(
(viewportPageBounds.width * 0.8) / commonBounds.width,
(viewportPageBounds.height * 0.8) / commonBounds.height,
8, // Max zoom of 8x for small selections
40, // Max zoom of 20x for small selections
)
} else if (widthRatio > 1 || heightRatio > 1) {
// For selections larger than viewport, zoom out more
@ -58,11 +58,11 @@ export const zoomToSelection = (editor: Editor) => {
0.125, // Min zoom of 1/8x for large selections (reciprocal of 8)
)
} else {
// For medium-sized selections, allow up to 4x zoom
// For medium-sized selections, allow up to 10x zoom
targetZoom = Math.min(
(viewportPageBounds.width * 0.8) / commonBounds.width,
(viewportPageBounds.height * 0.8) / commonBounds.height,
4, // Medium zoom level
20, // Medium zoom level
)
}
@ -156,12 +156,8 @@ export const copyLinkToCurrentView = async (editor: Editor) => {
document.body.appendChild(textArea)
try {
await navigator.clipboard.writeText(textArea.value)
console.log("URL copied successfully")
} catch (err) {
// Fallback for older browsers
textArea.select()
document.execCommand("copy")
console.log("URL copied using fallback method")
console.error("Clipboard API failed:", err)
}
document.body.removeChild(textArea)
}
@ -171,8 +167,8 @@ export const copyLinkToCurrentView = async (editor: Editor) => {
}
}
// TODO: doesnt lock permanently
export const lockCameraToFrame = (editor: Editor) => {
/** TODO: doesnt UNlock */
export const lockCameraToFrame = async (editor: Editor) => {
const selectedShapes = editor.getSelectedShapes()
if (selectedShapes.length === 0) return
const selectedShape = selectedShapes[0]
@ -180,14 +176,38 @@ export const lockCameraToFrame = (editor: Editor) => {
const bounds = editor.getShapePageBounds(selectedShape)
if (!isFrame || !bounds) return
editor.zoomToBounds(bounds, {
animation: { duration: 300 },
targetZoom: 1,
})
editor.updateInstanceState({
meta: {
...editor.getInstanceState().meta,
lockedFrameId: selectedShape.id,
},
})
try {
const baseUrl = `${window.location.origin}${window.location.pathname}`
const url = new URL(baseUrl)
// Calculate zoom level to fit the frame
const viewportPageBounds = editor.getViewportPageBounds()
const targetZoom = Math.min(
viewportPageBounds.width / bounds.width,
viewportPageBounds.height / bounds.height,
1, // Cap at 1x zoom
)
url.searchParams.set("frameId", selectedShape.id)
url.searchParams.set("isLocked", "true")
url.searchParams.set("x", bounds.x.toString())
url.searchParams.set("y", bounds.y.toString())
url.searchParams.set("zoom", targetZoom.toString())
const finalUrl = url.toString()
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(finalUrl)
} else {
const textArea = document.createElement("textarea")
textArea.value = finalUrl
document.body.appendChild(textArea)
textArea.select()
document.execCommand("copy")
document.body.removeChild(textArea)
}
} catch (error) {
console.error("Failed to copy frame link:", error)
alert("Failed to copy frame link. Please check clipboard permissions.")
}
}

View File

@ -15,7 +15,7 @@ export const overrides: TLUiOverrides = {
id: "VideoChat",
icon: "video",
label: "Video Chat",
kbd: "v",
kbd: "alt+v",
readonlyOk: true,
onSelect: () => editor.setCurrentTool("VideoChat"),
},
@ -23,7 +23,7 @@ export const overrides: TLUiOverrides = {
id: "ChatBox",
icon: "chat",
label: "Chat",
kbd: "c",
kbd: "alt+c",
readonlyOk: true,
onSelect: () => editor.setCurrentTool("ChatBox"),
},
@ -31,10 +31,18 @@ export const overrides: TLUiOverrides = {
id: "Embed",
icon: "embed",
label: "Embed",
kbd: "e",
kbd: "alt+e",
readonlyOk: true,
onSelect: () => editor.setCurrentTool("Embed"),
},
Markdown: {
id: "Markdown",
icon: "markdown",
label: "Markdown",
kbd: "alt+m",
readonlyOk: true,
onSelect: () => editor.setCurrentTool("Markdown"),
},
}
},
actions(editor, actions) {
@ -43,7 +51,7 @@ export const overrides: TLUiOverrides = {
zoomToSelection: {
id: "zoom-to-selection",
label: "Zoom to Selection",
kbd: "z",
kbd: "alt+z",
onSelect: () => {
if (editor.getSelectedShapeIds().length > 0) {
zoomToSelection(editor)
@ -54,7 +62,7 @@ export const overrides: TLUiOverrides = {
copyLinkToCurrentView: {
id: "copy-link-to-current-view",
label: "Copy Link to Current View",
kbd: "s",
kbd: "alt+s",
onSelect: () => {
copyLinkToCurrentView(editor)
},
@ -63,7 +71,7 @@ export const overrides: TLUiOverrides = {
revertCamera: {
id: "revert-camera",
label: "Revert Camera",
kbd: "b",
kbd: "alt+b",
onSelect: () => {
if (cameraHistory.length > 0) {
revertCamera(editor)
@ -74,7 +82,7 @@ export const overrides: TLUiOverrides = {
lockToFrame: {
id: "lock-to-frame",
label: "Lock to Frame",
kbd: "l",
kbd: "shift+l",
onSelect: () => lockCameraToFrame(editor),
},
}

View File

@ -0,0 +1,63 @@
import { Editor, TLShapeId } from "tldraw"
export const handleInitialPageLoad = (editor: Editor) => {
const url = new URL(window.location.href)
const frameId = url.searchParams.get("frameId")
const shapeId = url.searchParams.get("shapeId")
const x = url.searchParams.get("x")
const y = url.searchParams.get("y")
const zoom = url.searchParams.get("zoom")
const isLocked = url.searchParams.get("isLocked") === "true"
// Wait for next tick to ensure editor is ready
requestAnimationFrame(() => {
// Set camera position if coordinates exist
if (x && y && zoom) {
editor.setCamera({
x: parseFloat(x),
y: parseFloat(y),
z: parseFloat(zoom),
})
}
// Handle frame-specific logic
if (frameId) {
const frame = editor.getShape(frameId as TLShapeId)
if (frame) {
editor.select(frameId as TLShapeId)
// If x/y/zoom are not provided in URL, zoom to frame bounds
if (!x || !y || !zoom) {
editor.zoomToBounds(editor.getShapePageBounds(frame)!, {
animation: { duration: 0 },
targetZoom: 1,
})
}
// Apply camera lock after camera is positioned
if (isLocked) {
// Use requestAnimationFrame to ensure camera is set before locking
requestAnimationFrame(() => {
editor.setCameraOptions({
isLocked: true,
// Optional: you may want to also set these options for locked frames
//shouldSnapToGrid: false,
//shouldUseEdgeScrolling: false,
})
})
}
} else {
console.warn("Frame not found:", frameId)
}
}
// Handle shape-specific logic
else if (shapeId) {
const shape = editor.getShape(shapeId as TLShapeId)
if (shape) {
editor.select(shapeId as TLShapeId)
} else {
console.warn("Shape not found:", shapeId)
}
}
})
}

View File

@ -1,38 +1,43 @@
/// <reference types="@cloudflare/workers-types" />
import { RoomSnapshot, TLSocketRoom } from '@tldraw/sync-core'
import { RoomSnapshot, TLSocketRoom } from "@tldraw/sync-core"
import {
TLRecord,
TLShape,
createTLSchema,
defaultBindingSchemas,
defaultShapeSchemas,
} from '@tldraw/tlschema'
import { AutoRouter, IRequest, error } from 'itty-router'
import throttle from 'lodash.throttle'
import { Environment } from './types'
import { ChatBoxShape } from '@/shapes/ChatBoxShapeUtil'
import { VideoChatShape } from '@/shapes/VideoChatShapeUtil'
import { EmbedShape } from '@/shapes/EmbedShapeUtil'
TLRecord,
TLShape,
createTLSchema,
defaultBindingSchemas,
defaultShapeSchemas,
} from "@tldraw/tlschema"
import { AutoRouter, IRequest, error } from "itty-router"
import throttle from "lodash.throttle"
import { Environment } from "./types"
import { ChatBoxShape } from "@/shapes/ChatBoxShapeUtil"
import { VideoChatShape } from "@/shapes/VideoChatShapeUtil"
import { EmbedShape } from "@/shapes/EmbedShapeUtil"
import { MarkdownShape } from "@/shapes/MarkdownShapeUtil"
// add custom shapes and bindings here if needed:
export const customSchema = createTLSchema({
shapes: {
...defaultShapeSchemas,
ChatBox: {
props: ChatBoxShape.props,
migrations: ChatBoxShape.migrations,
},
VideoChat: {
props: VideoChatShape.props,
migrations: VideoChatShape.migrations,
},
Embed: {
props: EmbedShape.props,
migrations: EmbedShape.migrations,
},
},
bindings: defaultBindingSchemas,
shapes: {
...defaultShapeSchemas,
ChatBox: {
props: ChatBoxShape.props,
migrations: ChatBoxShape.migrations,
},
VideoChat: {
props: VideoChatShape.props,
migrations: VideoChatShape.migrations,
},
Embed: {
props: EmbedShape.props,
migrations: EmbedShape.migrations,
},
Markdown: {
props: MarkdownShape.props,
migrations: MarkdownShape.migrations,
},
},
bindings: defaultBindingSchemas,
})
// each whiteboard room is hosted in a DurableObject:
@ -41,209 +46,215 @@ export const customSchema = createTLSchema({
// there's only ever one durable object instance per room. it keeps all the room state in memory and
// handles websocket connections. periodically, it persists the room state to the R2 bucket.
export class TldrawDurableObject {
private r2: R2Bucket
// the room ID will be missing whilst the room is being initialized
private roomId: string | null = null
// when we load the room from the R2 bucket, we keep it here. it's a promise so we only ever
// load it once.
private roomPromise: Promise<TLSocketRoom<TLRecord, void>> | null = null
private r2: R2Bucket
// the room ID will be missing whilst the room is being initialized
private roomId: string | null = null
// when we load the room from the R2 bucket, we keep it here. it's a promise so we only ever
// load it once.
private roomPromise: Promise<TLSocketRoom<TLRecord, void>> | null = null
constructor(
private readonly ctx: DurableObjectState,
env: Environment
) {
this.r2 = env.TLDRAW_BUCKET
constructor(private readonly ctx: DurableObjectState, env: Environment) {
this.r2 = env.TLDRAW_BUCKET
ctx.blockConcurrencyWhile(async () => {
this.roomId = ((await this.ctx.storage.get('roomId')) ?? null) as string | null
})
}
ctx.blockConcurrencyWhile(async () => {
this.roomId = ((await this.ctx.storage.get("roomId")) ?? null) as
| string
| null
})
}
private readonly router = AutoRouter({
catch: (e) => {
console.log(e)
return error(e)
},
})
// when we get a connection request, we stash the room id if needed and handle the connection
.get('/connect/:roomId', async (request) => {
if (!this.roomId) {
await this.ctx.blockConcurrencyWhile(async () => {
await this.ctx.storage.put('roomId', request.params.roomId)
this.roomId = request.params.roomId
})
}
return this.handleConnect(request)
})
.get('/room/:roomId', async (request) => {
const room = await this.getRoom()
const snapshot = room.getCurrentSnapshot()
return new Response(JSON.stringify(snapshot.documents), {
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': request.headers.get('Origin') || '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
'Access-Control-Max-Age': '86400',
}
})
})
.post('/room/:roomId', async (request) => {
const records = await request.json() as TLRecord[]
private readonly router = AutoRouter({
catch: (e) => {
console.log(e)
return error(e)
},
})
// when we get a connection request, we stash the room id if needed and handle the connection
.get("/connect/:roomId", async (request) => {
if (!this.roomId) {
await this.ctx.blockConcurrencyWhile(async () => {
await this.ctx.storage.put("roomId", request.params.roomId)
this.roomId = request.params.roomId
})
}
return this.handleConnect(request)
})
.get("/room/:roomId", async (request) => {
const room = await this.getRoom()
const snapshot = room.getCurrentSnapshot()
return new Response(JSON.stringify(snapshot.documents), {
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": request.headers.get("Origin") || "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
"Access-Control-Max-Age": "86400",
},
})
})
.post("/room/:roomId", async (request) => {
const records = (await request.json()) as TLRecord[]
return new Response(JSON.stringify(Array.from(records)), {
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': request.headers.get('Origin') || '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
'Access-Control-Max-Age': '86400',
}
})
})
return new Response(JSON.stringify(Array.from(records)), {
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": request.headers.get("Origin") || "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
"Access-Control-Max-Age": "86400",
},
})
})
// `fetch` is the entry point for all requests to the Durable Object
fetch(request: Request): Response | Promise<Response> {
try {
return this.router.fetch(request)
} catch (err) {
console.error('Error in DO fetch:', err);
return new Response(JSON.stringify({
error: 'Internal Server Error',
message: (err as Error).message
}), {
status: 500,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS, UPGRADE',
'Access-Control-Allow-Headers': 'Content-Type, Authorization, Upgrade, Connection',
'Access-Control-Max-Age': '86400',
'Access-Control-Allow-Credentials': 'true'
}
});
}
}
// `fetch` is the entry point for all requests to the Durable Object
fetch(request: Request): Response | Promise<Response> {
try {
return this.router.fetch(request)
} catch (err) {
console.error("Error in DO fetch:", err)
return new Response(
JSON.stringify({
error: "Internal Server Error",
message: (err as Error).message,
}),
{
status: 500,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS, UPGRADE",
"Access-Control-Allow-Headers":
"Content-Type, Authorization, Upgrade, Connection",
"Access-Control-Max-Age": "86400",
"Access-Control-Allow-Credentials": "true",
},
},
)
}
}
// what happens when someone tries to connect to this room?
async handleConnect(request: IRequest): Promise<Response> {
if (!this.roomId) {
return new Response('Room not initialized', { status: 400 });
}
// what happens when someone tries to connect to this room?
async handleConnect(request: IRequest): Promise<Response> {
if (!this.roomId) {
return new Response("Room not initialized", { status: 400 })
}
const sessionId = request.query.sessionId as string;
if (!sessionId) {
return new Response('Missing sessionId', { status: 400 });
}
const sessionId = request.query.sessionId as string
if (!sessionId) {
return new Response("Missing sessionId", { status: 400 })
}
const { 0: clientWebSocket, 1: serverWebSocket } = new WebSocketPair();
const { 0: clientWebSocket, 1: serverWebSocket } = new WebSocketPair()
try {
serverWebSocket.accept();
const room = await this.getRoom();
try {
serverWebSocket.accept()
const room = await this.getRoom()
// Handle socket connection with proper error boundaries
room.handleSocketConnect({
sessionId,
socket: {
send: serverWebSocket.send.bind(serverWebSocket),
close: serverWebSocket.close.bind(serverWebSocket),
addEventListener: serverWebSocket.addEventListener.bind(serverWebSocket),
removeEventListener: serverWebSocket.removeEventListener.bind(serverWebSocket),
readyState: serverWebSocket.readyState,
}
});
// Handle socket connection with proper error boundaries
room.handleSocketConnect({
sessionId,
socket: {
send: serverWebSocket.send.bind(serverWebSocket),
close: serverWebSocket.close.bind(serverWebSocket),
addEventListener:
serverWebSocket.addEventListener.bind(serverWebSocket),
removeEventListener:
serverWebSocket.removeEventListener.bind(serverWebSocket),
readyState: serverWebSocket.readyState,
},
})
return new Response(null, {
status: 101,
webSocket: clientWebSocket,
headers: {
'Access-Control-Allow-Origin': request.headers.get('Origin') || '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS, UPGRADE',
'Access-Control-Allow-Headers': '*',
'Access-Control-Allow-Credentials': 'true',
'Upgrade': 'websocket',
'Connection': 'Upgrade'
}
});
} catch (error) {
console.error('WebSocket connection error:', error);
serverWebSocket.close(1011, 'Failed to initialize connection');
return new Response('Failed to establish WebSocket connection', {
status: 500
});
}
}
return new Response(null, {
status: 101,
webSocket: clientWebSocket,
headers: {
"Access-Control-Allow-Origin": request.headers.get("Origin") || "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS, UPGRADE",
"Access-Control-Allow-Headers": "*",
"Access-Control-Allow-Credentials": "true",
Upgrade: "websocket",
Connection: "Upgrade",
},
})
} catch (error) {
console.error("WebSocket connection error:", error)
serverWebSocket.close(1011, "Failed to initialize connection")
return new Response("Failed to establish WebSocket connection", {
status: 500,
})
}
}
getRoom() {
const roomId = this.roomId
if (!roomId) throw new Error('Missing roomId')
getRoom() {
const roomId = this.roomId
if (!roomId) throw new Error("Missing roomId")
if (!this.roomPromise) {
this.roomPromise = (async () => {
// fetch the room from R2
const roomFromBucket = await this.r2.get(`rooms/${roomId}`)
// if it doesn't exist, we'll just create a new empty room
const initialSnapshot = roomFromBucket
? ((await roomFromBucket.json()) as RoomSnapshot)
: undefined
if (initialSnapshot) {
initialSnapshot.documents = initialSnapshot.documents.filter(record => {
const shape = record.state as TLShape
return shape.type !== "chatBox"
})
}
// create a new TLSocketRoom. This handles all the sync protocol & websocket connections.
// it's up to us to persist the room state to R2 when needed though.
return new TLSocketRoom<TLRecord, void>({
schema: customSchema,
initialSnapshot,
onDataChange: () => {
// and persist whenever the data in the room changes
this.schedulePersistToR2()
},
})
})()
}
if (!this.roomPromise) {
this.roomPromise = (async () => {
// fetch the room from R2
const roomFromBucket = await this.r2.get(`rooms/${roomId}`)
// if it doesn't exist, we'll just create a new empty room
const initialSnapshot = roomFromBucket
? ((await roomFromBucket.json()) as RoomSnapshot)
: undefined
if (initialSnapshot) {
initialSnapshot.documents = initialSnapshot.documents.filter(
(record) => {
const shape = record.state as TLShape
return shape.type !== "chatBox"
},
)
}
// create a new TLSocketRoom. This handles all the sync protocol & websocket connections.
// it's up to us to persist the room state to R2 when needed though.
return new TLSocketRoom<TLRecord, void>({
schema: customSchema,
initialSnapshot,
onDataChange: () => {
// and persist whenever the data in the room changes
this.schedulePersistToR2()
},
})
})()
}
return this.roomPromise
}
return this.roomPromise
}
// we throttle persistance so it only happens every 10 seconds
schedulePersistToR2 = throttle(async () => {
if (!this.roomPromise || !this.roomId) return
const room = await this.getRoom()
// we throttle persistance so it only happens every 10 seconds
schedulePersistToR2 = throttle(async () => {
if (!this.roomPromise || !this.roomId) return
const room = await this.getRoom()
// convert the room to JSON and upload it to R2
const snapshot = JSON.stringify(room.getCurrentSnapshot())
await this.r2.put(`rooms/${this.roomId}`, snapshot)
}, 10_000)
// convert the room to JSON and upload it to R2
const snapshot = JSON.stringify(room.getCurrentSnapshot())
await this.r2.put(`rooms/${this.roomId}`, snapshot)
}, 10_000)
// Add CORS headers for WebSocket upgrade
handleWebSocket(request: Request) {
const upgradeHeader = request.headers.get("Upgrade")
if (!upgradeHeader || upgradeHeader !== "websocket") {
return new Response("Expected Upgrade: websocket", { status: 426 })
}
// Add CORS headers for WebSocket upgrade
handleWebSocket(request: Request) {
const upgradeHeader = request.headers.get('Upgrade')
if (!upgradeHeader || upgradeHeader !== 'websocket') {
return new Response('Expected Upgrade: websocket', { status: 426 })
}
const webSocketPair = new WebSocketPair()
const [client, server] = Object.values(webSocketPair)
const webSocketPair = new WebSocketPair()
const [client, server] = Object.values(webSocketPair)
server.accept()
server.accept()
// Add error handling
server.addEventListener("error", (err) => {
console.error("WebSocket error:", err)
})
// Add error handling
server.addEventListener('error', (err) => {
console.error('WebSocket error:', err)
})
return new Response(null, {
status: 101,
webSocket: client,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': '*',
},
})
}
return new Response(null, {
status: 101,
webSocket: client,
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "*",
},
})
}
}