diff --git a/package.json b/package.json index ad45f17..3dea86e 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,8 @@ "author": "Jeff Emmett", "license": "ISC", "dependencies": { + "@daily-co/daily-js": "^0.60.0", + "@daily-co/daily-react": "^0.20.0", "@tldraw/assets": "^3.6.0", "@tldraw/sync": "^3.6.0", "@tldraw/sync-core": "^3.6.0", @@ -26,11 +28,13 @@ "gray-matter": "^4.0.3", "html2canvas": "^1.4.1", "itty-router": "^5.0.17", + "jotai": "^2.6.0", "jspdf": "^2.5.2", "lodash.throttle": "^4.1.1", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^7.0.2", + "recoil": "^0.7.7", "tldraw": "^3.6.0", "vercel": "^39.1.1" }, diff --git a/src/hooks/useCameraControls.ts b/src/hooks/useCameraControls.ts index 470213f..f3e4d0d 100644 --- a/src/hooks/useCameraControls.ts +++ b/src/hooks/useCameraControls.ts @@ -53,19 +53,25 @@ export function useCameraControls(editor: Editor | null) { const frameId = searchParams.get("frameId") const isLocked = searchParams.get("isLocked") === "true" - console.log("Setting camera:", { x, y, zoom }) + console.log("Setting camera:", { x, y, zoom, frameId, isLocked }) // Set camera position if coordinates exist if (x && y && zoom) { const position = { - x: parseFloat(x), - y: parseFloat(y), - z: parseFloat(zoom), + x: Math.round(parseFloat(x)), + y: Math.round(parseFloat(y)), + z: Math.round(parseFloat(zoom)), } console.log("Camera position:", position) requestAnimationFrame(() => { editor.setCamera(position, { animation: { duration: 0 } }) + + // Apply camera lock immediately after setting position if needed + if (isLocked) { + editor.setCameraOptions({ isLocked: true }) + } + console.log("Current camera:", editor.getCamera()) }) } @@ -78,9 +84,17 @@ export function useCameraControls(editor: Editor | null) { // If x/y/zoom are not provided in URL, zoom to frame bounds if (!x || !y || !zoom) { - editor.zoomToBounds(editor.getShapePageBounds(frame)!, { + const bounds = editor.getShapePageBounds(frame)! + const viewportPageBounds = editor.getViewportPageBounds() + const targetZoom = Math.min( + viewportPageBounds.width / bounds.width, + viewportPageBounds.height / bounds.height, + 1, // Cap at 1x zoom, matching lockCameraToFrame + ) + + editor.zoomToBounds(bounds, { animation: { duration: 0 }, - targetZoom: 1, + targetZoom, }) } @@ -129,9 +143,9 @@ export function useCameraControls(editor: Editor | null) { if (!editor) return const camera = editor.getCamera() const url = new URL(window.location.href) - url.searchParams.set("x", camera.x.toString()) - url.searchParams.set("y", camera.y.toString()) - url.searchParams.set("zoom", camera.z.toString()) + url.searchParams.set("x", Math.round(camera.x).toString()) + url.searchParams.set("y", Math.round(camera.y).toString()) + url.searchParams.set("zoom", Math.round(camera.z).toString()) navigator.clipboard.writeText(url.toString()) }, diff --git a/src/shapes/VideoChatShapeUtil.tsx b/src/shapes/VideoChatShapeUtil.tsx index b00a785..6065636 100644 --- a/src/shapes/VideoChatShapeUtil.tsx +++ b/src/shapes/VideoChatShapeUtil.tsx @@ -1,6 +1,7 @@ import { BaseBoxShapeUtil, TLBaseShape } from "tldraw" -import { useEffect, useState } from "react" +import { useEffect, useState, useRef } from "react" import { WORKER_URL } from "../routes/Board" +import DailyIframe from "@daily-co/daily-js" export type IVideoChatShape = TLBaseShape< "VideoChat", @@ -12,6 +13,53 @@ export type IVideoChatShape = TLBaseShape< } > +// Simplified component using Daily Prebuilt +const VideoChatComponent = ({ roomUrl }: { roomUrl: string }) => { + const wrapperRef = useRef(null) + const callFrameRef = useRef | null>(null) + + useEffect(() => { + if (!wrapperRef.current || !roomUrl) return + + // Create and configure the Daily call frame + callFrameRef.current = DailyIframe.createFrame(wrapperRef.current, { + iframeStyle: { + width: "100%", + height: "100%", + border: "0", + borderRadius: "4px", + }, + showLeaveButton: true, + showFullscreenButton: true, + }) + + // Join the room + callFrameRef.current.join({ url: roomUrl }) + + // Cleanup + return () => { + if (callFrameRef.current) { + callFrameRef.current.destroy() + } + } + }, [roomUrl]) + + return ( +
+ ) +} + export class VideoChatShape extends BaseBoxShapeUtil { static override type = "VideoChat" @@ -33,29 +81,37 @@ export class VideoChatShape extends BaseBoxShapeUtil { return } - const response = await fetch(`${WORKER_URL}/daily/rooms`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - properties: { - enable_recording: true, - max_participants: 8, + try { + const response = await fetch(`${WORKER_URL}/daily/rooms`, { + method: "POST", + headers: { + "Content-Type": "application/json", }, - }), - }) + body: JSON.stringify({ + properties: { + enable_recording: true, + max_participants: 8, + }, + }), + }) - const data = await response.json() + if (!response.ok) { + throw new Error("Failed to create room") + } - this.editor.updateShape({ - id: shape.id, - type: "VideoChat", - props: { - ...shape.props, - roomUrl: (data as any).url, - }, - }) + const data = await response.json() + + this.editor.updateShape({ + id: shape.id, + type: "VideoChat", + props: { + ...shape.props, + roomUrl: (data as any).url, + }, + }) + } catch (error) { + console.error("Failed to create Daily room:", error) + } } component(shape: IVideoChatShape) { @@ -64,60 +120,91 @@ export class VideoChatShape extends BaseBoxShapeUtil { const [isLoading, setIsLoading] = useState(false) useEffect(() => { - if (isInRoom && shape.props.roomUrl) { - const script = document.createElement("script") - script.src = "https://www.daily.co/static/call-machine.js" - document.body.appendChild(script) + setIsLoading(true) + this.ensureRoomExists(shape) + .catch((err) => setError(err.message)) + .finally(() => setIsLoading(false)) + }, []) - script.onload = () => { - // @ts-ignore - window.DailyIframe.createFrame({ - iframeStyle: { - width: "100%", - height: "100%", - border: "0", - borderRadius: "4px", - }, - showLeaveButton: true, - showFullscreenButton: true, - }).join({ url: shape.props.roomUrl }) - } - } - }, [isInRoom, shape.props.roomUrl]) + if (isLoading) { + return ( +
+
Initializing video chat...
+
+ ) + } + + if (!shape.props.roomUrl) { + return ( +
+ Creating room... +
+ ) + } return (
{!isInRoom ? ( ) : ( -
+ + )} + {error && ( +
+ {error} +
)} - {error &&

{error}

}
) } diff --git a/src/ui/cameraUtils.ts b/src/ui/cameraUtils.ts index 09c8183..20b116b 100644 --- a/src/ui/cameraUtils.ts +++ b/src/ui/cameraUtils.ts @@ -186,7 +186,7 @@ export const lockCameraToFrame = async (editor: Editor) => { const baseUrl = `${window.location.origin}${window.location.pathname}` const url = new URL(baseUrl) - // Calculate zoom level to fit the frame + // Calculate zoom level to fit the frame (for URL only) const viewportPageBounds = editor.getViewportPageBounds() const targetZoom = Math.min( viewportPageBounds.width / bounds.width, @@ -194,17 +194,19 @@ export const lockCameraToFrame = async (editor: Editor) => { 1, // Cap at 1x zoom ) - // Set camera parameters first + // Set URL parameters without affecting the current view url.searchParams.set("x", Math.round(bounds.x).toString()) url.searchParams.set("y", Math.round(bounds.y).toString()) - url.searchParams.set("zoom", targetZoom.toString()) - - // Add frame-specific parameters last - url.searchParams.set("isLocked", "true") + url.searchParams.set( + "zoom", + (Math.round(targetZoom * 100) / 100).toString(), + ) url.searchParams.set("frameId", selectedShape.id) + url.searchParams.set("isLocked", "true") const finalUrl = url.toString() + // Copy URL to clipboard without modifying the current view if (navigator.clipboard && window.isSecureContext) { await navigator.clipboard.writeText(finalUrl) } else { diff --git a/worker/worker.ts b/worker/worker.ts index 6c0d900..a2f54d0 100644 --- a/worker/worker.ts +++ b/worker/worker.ts @@ -1,135 +1,182 @@ -import { handleUnfurlRequest } from 'cloudflare-workers-unfurl' -import { AutoRouter, cors, error, IRequest } from 'itty-router' -import { handleAssetDownload, handleAssetUpload } from './assetUploads' -import { Environment } from './types' +import { handleUnfurlRequest } from "cloudflare-workers-unfurl" +import { AutoRouter, cors, error, IRequest } from "itty-router" +import { handleAssetDownload, handleAssetUpload } from "./assetUploads" +import { Environment } from "./types" // make sure our sync durable object is made available to cloudflare -export { TldrawDurableObject } from './TldrawDurableObject' +export { TldrawDurableObject } from "./TldrawDurableObject" // Define security headers const securityHeaders = { - 'Content-Security-Policy': "default-src 'self'; connect-src 'self' wss: https:; img-src 'self' data: blob: https:; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline';", - 'X-Content-Type-Options': 'nosniff', - 'X-Frame-Options': 'DENY', - 'X-XSS-Protection': '1; mode=block', - 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains', - 'Referrer-Policy': 'strict-origin-when-cross-origin', - 'Permissions-Policy': 'camera=(), microphone=(), geolocation=()' + "Content-Security-Policy": + "default-src 'self'; connect-src 'self' wss: https:; img-src 'self' data: blob: https:; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline';", + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "DENY", + "X-XSS-Protection": "1; mode=block", + "Strict-Transport-Security": "max-age=31536000; includeSubDomains", + "Referrer-Policy": "strict-origin-when-cross-origin", + "Permissions-Policy": "camera=(), microphone=(), geolocation=()", } // we use itty-router (https://itty.dev/) to handle routing. in this example we turn on CORS because // we're hosting the worker separately to the client. you should restrict this to your own domain. const { preflight, corsify } = cors({ - origin: (origin) => { - const allowedOrigins = [ - 'https://jeffemmett.com', - 'https://www.jeffemmett.com', - 'https://jeffemmett-canvas.jeffemmett.workers.dev', - 'https://jeffemmett.com/board/*', - ]; + origin: (origin) => { + const allowedOrigins = [ + "https://jeffemmett.com", + "https://www.jeffemmett.com", + "https://jeffemmett-canvas.jeffemmett.workers.dev", + "https://jeffemmett.com/board/*", + ] - // Always allow if no origin (like from a local file) - if (!origin) return '*'; + // Always allow if no origin (like from a local file) + if (!origin) return "*" - // Check exact matches - if (allowedOrigins.includes(origin)) { - return origin; - } + // Check exact matches + if (allowedOrigins.includes(origin)) { + return origin + } - // For development - check if it's a localhost or local IP - if (origin.match(/^http:\/\/(localhost|127\.0\.0\.192\.168\.|169\.254\.|10\.)/)) { - return origin; - } + // For development - check if it's a localhost or local IP + if ( + origin.match( + /^http:\/\/(localhost|127\.0\.0\.192\.168\.|169\.254\.|10\.)/, + ) + ) { + return origin + } - return undefined; - }, - allowMethods: ['GET', 'POST', 'OPTIONS', 'UPGRADE'], - allowHeaders: [ - 'Content-Type', - 'Authorization', - 'Upgrade', - 'Connection', - 'Sec-WebSocket-Key', - 'Sec-WebSocket-Version', - 'Sec-WebSocket-Extensions', - 'Sec-WebSocket-Protocol' - ], - maxAge: 86400, - credentials: true + return undefined + }, + allowMethods: ["GET", "POST", "OPTIONS", "UPGRADE"], + allowHeaders: [ + "Content-Type", + "Authorization", + "Upgrade", + "Connection", + "Sec-WebSocket-Key", + "Sec-WebSocket-Version", + "Sec-WebSocket-Extensions", + "Sec-WebSocket-Protocol", + ], + maxAge: 86400, + credentials: true, }) const router = AutoRouter({ - before: [preflight], - finally: [(response) => { - // Add security headers to all responses except WebSocket upgrades - if (response.status !== 101) { - Object.entries(securityHeaders).forEach(([key, value]) => { - response.headers.set(key, value) - }) - } - return corsify(response) - }], - catch: (e) => { - console.error(e) - return error(e) - }, + before: [preflight], + finally: [ + (response) => { + // Add security headers to all responses except WebSocket upgrades + if (response.status !== 101) { + Object.entries(securityHeaders).forEach(([key, value]) => { + response.headers.set(key, value) + }) + } + return corsify(response) + }, + ], + catch: (e) => { + console.error(e) + return error(e) + }, }) - // requests to /connect are routed to the Durable Object, and handle realtime websocket syncing - .get('/connect/:roomId', (request, env) => { - const id = env.TLDRAW_DURABLE_OBJECT.idFromName(request.params.roomId) - const room = env.TLDRAW_DURABLE_OBJECT.get(id) - return room.fetch(request.url, { - headers: request.headers, - body: request.body, - method: request.method - }) - }) + // requests to /connect are routed to the Durable Object, and handle realtime websocket syncing + .get("/connect/:roomId", (request, env) => { + const id = env.TLDRAW_DURABLE_OBJECT.idFromName(request.params.roomId) + const room = env.TLDRAW_DURABLE_OBJECT.get(id) + return room.fetch(request.url, { + headers: request.headers, + body: request.body, + method: request.method, + }) + }) - // assets can be uploaded to the bucket under /uploads: - .post('/uploads/:uploadId', handleAssetUpload) + // assets can be uploaded to the bucket under /uploads: + .post("/uploads/:uploadId", handleAssetUpload) - // they can be retrieved from the bucket too: - .get('/uploads/:uploadId', handleAssetDownload) + // they can be retrieved from the bucket too: + .get("/uploads/:uploadId", handleAssetDownload) - // bookmarks need to extract metadata from pasted URLs: - .get('/unfurl', handleUnfurlRequest) + // bookmarks need to extract metadata from pasted URLs: + .get("/unfurl", handleUnfurlRequest) - .get('/room/:roomId', (request, env) => { - const id = env.TLDRAW_DURABLE_OBJECT.idFromName(request.params.roomId) - const room = env.TLDRAW_DURABLE_OBJECT.get(id) - return room.fetch(request.url, { - headers: request.headers, - body: request.body, - method: request.method - }) - }) + .get("/room/:roomId", (request, env) => { + const id = env.TLDRAW_DURABLE_OBJECT.idFromName(request.params.roomId) + const room = env.TLDRAW_DURABLE_OBJECT.get(id) + return room.fetch(request.url, { + headers: request.headers, + body: request.body, + method: request.method, + }) + }) - .post('/room/:roomId', async (request, env) => { - const id = env.TLDRAW_DURABLE_OBJECT.idFromName(request.params.roomId) - const room = env.TLDRAW_DURABLE_OBJECT.get(id) - return room.fetch(request.url, { - method: 'POST', - body: request.body - }) - }) + .post("/room/:roomId", async (request, env) => { + const id = env.TLDRAW_DURABLE_OBJECT.idFromName(request.params.roomId) + const room = env.TLDRAW_DURABLE_OBJECT.get(id) + return room.fetch(request.url, { + method: "POST", + body: request.body, + }) + }) - .post('/daily/rooms', async (request, env) => { - const response = await fetch('https://api.daily.co/v1/rooms', { - method: 'POST', - headers: { - 'Authorization': `Bearer ${env.DAILY_API_KEY}`, - 'Content-Type': 'application/json' - }, - body: await request.text() - }); + .post("/daily/rooms", async (request, env) => { + try { + // Log the request for debugging + console.log( + "Creating Daily room with API key:", + env.DAILY_API_KEY ? "present" : "missing", + ) - const data = await response.json() as Record; - return new Response(JSON.stringify({ - ...data, - url: `https://${env.DAILY_DOMAIN}/${data.name}` - }), { - headers: { 'Content-Type': 'application/json' } - }); - }) + const response = await fetch("https://api.daily.co/v1/rooms", { + method: "POST", + headers: { + Authorization: `Bearer ${env.DAILY_API_KEY}`, + "Content-Type": "application/json", + }, + body: await request.text(), + }) + + if (!response.ok) { + const errorText = await response.text() + console.error("Daily API error:", errorText) + return new Response(`Daily API error: ${errorText}`, { + status: response.status, + }) + } + + const data = await response.json() + + // Log successful response + console.log("Daily room created:", data) + + return new Response( + JSON.stringify({ + ...(data as Record), + url: `https://${env.DAILY_DOMAIN}/${ + (data as Record).name + }`, + }), + { + headers: { + "Content-Type": "application/json", + // Add CORS headers specifically for this endpoint if needed + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type", + }, + }, + ) + } catch (error) { + console.error("Error creating Daily room:", error) + return new Response( + JSON.stringify({ error: "Failed to create Daily room" }), + { + status: 500, + headers: { "Content-Type": "application/json" }, + }, + ) + } + }) // export our router for cloudflare export default router