diff --git a/src/App.tsx b/src/App.tsx index 21c8679..4ba6a78 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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() function App() { diff --git a/src/routes/Board.tsx b/src/routes/Board.tsx index 882dd7a..503e42d 100644 --- a/src/routes/Board.tsx +++ b/src/routes/Board.tsx @@ -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) }} /> diff --git a/src/shapes/MarkdownShapeUtil.tsx b/src/shapes/MarkdownShapeUtil.tsx new file mode 100644 index 0000000..9e71fbf --- /dev/null +++ b/src/shapes/MarkdownShapeUtil.tsx @@ -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
{shape.props.content}
+ } +} diff --git a/src/tools/MarkdownTool.ts b/src/tools/MarkdownTool.ts new file mode 100644 index 0000000..30c28fb --- /dev/null +++ b/src/tools/MarkdownTool.ts @@ -0,0 +1,7 @@ +import { BaseBoxShapeTool } from "tldraw" + +export class MarkdownTool extends BaseBoxShapeTool { + static override id = "MarkdownTool" + shapeType = "MarkdownTool" + override initial = "idle" +} diff --git a/src/ui/CustomContextMenu.tsx b/src/ui/CustomContextMenu.tsx index 0cc24cb..fc94d41 100644 --- a/src/ui/CustomContextMenu.tsx +++ b/src/ui/CustomContextMenu.tsx @@ -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 ( - - {/* Camera Controls Group */} 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)} /> 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") }} /> + { + editor.setCurrentTool("Markdown") + }} + /> {/* Frame Controls */} - {isFrame && ( - - { - console.warn("lock to frame NOT IMPLEMENTED") - }} - /> - - )} + + lockCameraToFrame(editor)} + /> + + ) } diff --git a/src/ui/CustomToolbar.tsx b/src/ui/CustomToolbar.tsx index e297e48..1074c9d 100644 --- a/src/ui/CustomToolbar.tsx +++ b/src/ui/CustomToolbar.tsx @@ -34,6 +34,14 @@ export function CustomToolbar() { isSelected={tools["Embed"].id === editor.getCurrentToolId()} /> )} + {tools["Markdown"] && ( + + )} ) } diff --git a/src/ui/cameraUtils.ts b/src/ui/cameraUtils.ts index c1ecf75..4bb93d1 100644 --- a/src/ui/cameraUtils.ts +++ b/src/ui/cameraUtils.ts @@ -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.") + } } diff --git a/src/ui/overrides.tsx b/src/ui/overrides.tsx index 745171e..b483b54 100644 --- a/src/ui/overrides.tsx +++ b/src/ui/overrides.tsx @@ -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), }, } diff --git a/src/utils/handleInitialPageLoad.ts b/src/utils/handleInitialPageLoad.ts new file mode 100644 index 0000000..7cc0edb --- /dev/null +++ b/src/utils/handleInitialPageLoad.ts @@ -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) + } + } + }) +} diff --git a/worker/TldrawDurableObject.ts b/worker/TldrawDurableObject.ts index b19c6e4..c225029 100644 --- a/worker/TldrawDurableObject.ts +++ b/worker/TldrawDurableObject.ts @@ -1,38 +1,43 @@ /// -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> | 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> | 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 { - 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 { + 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 { - 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 { + 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({ - 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({ + 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": "*", + }, + }) + } }