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": "*",
+ },
+ })
+ }
}