lockCameraToFrame almost working
This commit is contained in:
parent
5891e97ab5
commit
3c8f4d7fd1
49
src/App.tsx
49
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(<App />)
|
||||
|
||||
function App() {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import { BaseBoxShapeTool } from "tldraw"
|
||||
|
||||
export class MarkdownTool extends BaseBoxShapeTool {
|
||||
static override id = "MarkdownTool"
|
||||
shapeType = "MarkdownTool"
|
||||
override initial = "idle"
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -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": "*",
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue