videochat tool update

This commit is contained in:
Jeff Emmett 2024-12-08 18:13:47 -05:00
parent 0259ae4149
commit fb3a525340
5 changed files with 336 additions and 182 deletions

View File

@ -15,6 +15,8 @@
"author": "Jeff Emmett", "author": "Jeff Emmett",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@daily-co/daily-js": "^0.60.0",
"@daily-co/daily-react": "^0.20.0",
"@tldraw/assets": "^3.6.0", "@tldraw/assets": "^3.6.0",
"@tldraw/sync": "^3.6.0", "@tldraw/sync": "^3.6.0",
"@tldraw/sync-core": "^3.6.0", "@tldraw/sync-core": "^3.6.0",
@ -26,11 +28,13 @@
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
"html2canvas": "^1.4.1", "html2canvas": "^1.4.1",
"itty-router": "^5.0.17", "itty-router": "^5.0.17",
"jotai": "^2.6.0",
"jspdf": "^2.5.2", "jspdf": "^2.5.2",
"lodash.throttle": "^4.1.1", "lodash.throttle": "^4.1.1",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-router-dom": "^7.0.2", "react-router-dom": "^7.0.2",
"recoil": "^0.7.7",
"tldraw": "^3.6.0", "tldraw": "^3.6.0",
"vercel": "^39.1.1" "vercel": "^39.1.1"
}, },

View File

@ -53,19 +53,25 @@ export function useCameraControls(editor: Editor | null) {
const frameId = searchParams.get("frameId") const frameId = searchParams.get("frameId")
const isLocked = searchParams.get("isLocked") === "true" 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 // Set camera position if coordinates exist
if (x && y && zoom) { if (x && y && zoom) {
const position = { const position = {
x: parseFloat(x), x: Math.round(parseFloat(x)),
y: parseFloat(y), y: Math.round(parseFloat(y)),
z: parseFloat(zoom), z: Math.round(parseFloat(zoom)),
} }
console.log("Camera position:", position) console.log("Camera position:", position)
requestAnimationFrame(() => { requestAnimationFrame(() => {
editor.setCamera(position, { animation: { duration: 0 } }) 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()) 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 are not provided in URL, zoom to frame bounds
if (!x || !y || !zoom) { 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 }, animation: { duration: 0 },
targetZoom: 1, targetZoom,
}) })
} }
@ -129,9 +143,9 @@ export function useCameraControls(editor: Editor | null) {
if (!editor) return if (!editor) return
const camera = editor.getCamera() const camera = editor.getCamera()
const url = new URL(window.location.href) const url = new URL(window.location.href)
url.searchParams.set("x", camera.x.toString()) url.searchParams.set("x", Math.round(camera.x).toString())
url.searchParams.set("y", camera.y.toString()) url.searchParams.set("y", Math.round(camera.y).toString())
url.searchParams.set("zoom", camera.z.toString()) url.searchParams.set("zoom", Math.round(camera.z).toString())
navigator.clipboard.writeText(url.toString()) navigator.clipboard.writeText(url.toString())
}, },

View File

@ -1,6 +1,7 @@
import { BaseBoxShapeUtil, TLBaseShape } from "tldraw" import { BaseBoxShapeUtil, TLBaseShape } from "tldraw"
import { useEffect, useState } from "react" import { useEffect, useState, useRef } from "react"
import { WORKER_URL } from "../routes/Board" import { WORKER_URL } from "../routes/Board"
import DailyIframe from "@daily-co/daily-js"
export type IVideoChatShape = TLBaseShape< export type IVideoChatShape = TLBaseShape<
"VideoChat", "VideoChat",
@ -12,6 +13,53 @@ export type IVideoChatShape = TLBaseShape<
} }
> >
// Simplified component using Daily Prebuilt
const VideoChatComponent = ({ roomUrl }: { roomUrl: string }) => {
const wrapperRef = useRef<HTMLDivElement>(null)
const callFrameRef = useRef<ReturnType<
typeof DailyIframe.createFrame
> | 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 (
<div
ref={wrapperRef}
style={{
width: "100%",
height: "100%",
backgroundColor: "#f0f0f0",
borderRadius: "4px",
overflow: "hidden",
}}
/>
)
}
export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> { export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
static override type = "VideoChat" static override type = "VideoChat"
@ -33,29 +81,37 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
return return
} }
const response = await fetch(`${WORKER_URL}/daily/rooms`, { try {
method: "POST", const response = await fetch(`${WORKER_URL}/daily/rooms`, {
headers: { method: "POST",
"Content-Type": "application/json", headers: {
}, "Content-Type": "application/json",
body: JSON.stringify({
properties: {
enable_recording: true,
max_participants: 8,
}, },
}), 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<IVideoChatShape>({ const data = await response.json()
id: shape.id,
type: "VideoChat", this.editor.updateShape<IVideoChatShape>({
props: { id: shape.id,
...shape.props, type: "VideoChat",
roomUrl: (data as any).url, props: {
}, ...shape.props,
}) roomUrl: (data as any).url,
},
})
} catch (error) {
console.error("Failed to create Daily room:", error)
}
} }
component(shape: IVideoChatShape) { component(shape: IVideoChatShape) {
@ -64,60 +120,91 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
useEffect(() => { useEffect(() => {
if (isInRoom && shape.props.roomUrl) { setIsLoading(true)
const script = document.createElement("script") this.ensureRoomExists(shape)
script.src = "https://www.daily.co/static/call-machine.js" .catch((err) => setError(err.message))
document.body.appendChild(script) .finally(() => setIsLoading(false))
}, [])
script.onload = () => { if (isLoading) {
// @ts-ignore return (
window.DailyIframe.createFrame({ <div
iframeStyle: { style={{
width: "100%", width: `${shape.props.w}px`,
height: "100%", height: `${shape.props.h}px`,
border: "0", display: "flex",
borderRadius: "4px", alignItems: "center",
}, justifyContent: "center",
showLeaveButton: true, backgroundColor: "#f0f0f0",
showFullscreenButton: true, borderRadius: "4px",
}).join({ url: shape.props.roomUrl }) }}
} >
} <div>Initializing video chat...</div>
}, [isInRoom, shape.props.roomUrl]) </div>
)
}
if (!shape.props.roomUrl) {
return (
<div
style={{
width: `${shape.props.w}px`,
height: `${shape.props.h}px`,
display: "flex",
alignItems: "center",
justifyContent: "center",
backgroundColor: "#f0f0f0",
borderRadius: "4px",
}}
>
Creating room...
</div>
)
}
return ( return (
<div <div
style={{ style={{
pointerEvents: "all",
width: `${shape.props.w}px`, width: `${shape.props.w}px`,
height: `${shape.props.h}px`, height: `${shape.props.h}px`,
position: "absolute", position: "relative",
top: "10px",
left: "10px",
zIndex: 9999,
padding: "15px",
backgroundColor: "#F0F0F0",
boxShadow: "0 2px 4px rgba(0, 0, 0, 0.1)",
borderRadius: "4px",
}} }}
> >
{!isInRoom ? ( {!isInRoom ? (
<button <button
onClick={() => setIsInRoom(true)} onClick={() => setIsInRoom(true)}
className="bg-blue-500 text-white px-4 py-2 rounded" style={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
padding: "12px 24px",
backgroundColor: "#2563eb",
color: "white",
border: "none",
borderRadius: "4px",
cursor: "pointer",
}}
> >
Join Room Join Video Chat
</button> </button>
) : ( ) : (
<div <VideoChatComponent roomUrl={shape.props.roomUrl} />
id="daily-call-iframe-container" )}
style={{ {error && (
width: "100%", <div
height: "100%", style={{
}} position: "absolute",
/> bottom: 10,
left: 10,
right: 10,
color: "red",
textAlign: "center",
}}
>
{error}
</div>
)} )}
{error && <p className="text-red-500 mt-2">{error}</p>}
</div> </div>
) )
} }

View File

@ -186,7 +186,7 @@ export const lockCameraToFrame = async (editor: Editor) => {
const baseUrl = `${window.location.origin}${window.location.pathname}` const baseUrl = `${window.location.origin}${window.location.pathname}`
const url = new URL(baseUrl) 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 viewportPageBounds = editor.getViewportPageBounds()
const targetZoom = Math.min( const targetZoom = Math.min(
viewportPageBounds.width / bounds.width, viewportPageBounds.width / bounds.width,
@ -194,17 +194,19 @@ export const lockCameraToFrame = async (editor: Editor) => {
1, // Cap at 1x zoom 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("x", Math.round(bounds.x).toString())
url.searchParams.set("y", Math.round(bounds.y).toString()) url.searchParams.set("y", Math.round(bounds.y).toString())
url.searchParams.set("zoom", targetZoom.toString()) url.searchParams.set(
"zoom",
// Add frame-specific parameters last (Math.round(targetZoom * 100) / 100).toString(),
url.searchParams.set("isLocked", "true") )
url.searchParams.set("frameId", selectedShape.id) url.searchParams.set("frameId", selectedShape.id)
url.searchParams.set("isLocked", "true")
const finalUrl = url.toString() const finalUrl = url.toString()
// Copy URL to clipboard without modifying the current view
if (navigator.clipboard && window.isSecureContext) { if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(finalUrl) await navigator.clipboard.writeText(finalUrl)
} else { } else {

View File

@ -1,135 +1,182 @@
import { handleUnfurlRequest } from 'cloudflare-workers-unfurl' import { handleUnfurlRequest } from "cloudflare-workers-unfurl"
import { AutoRouter, cors, error, IRequest } from 'itty-router' import { AutoRouter, cors, error, IRequest } from "itty-router"
import { handleAssetDownload, handleAssetUpload } from './assetUploads' import { handleAssetDownload, handleAssetUpload } from "./assetUploads"
import { Environment } from './types' import { Environment } from "./types"
// make sure our sync durable object is made available to cloudflare // make sure our sync durable object is made available to cloudflare
export { TldrawDurableObject } from './TldrawDurableObject' export { TldrawDurableObject } from "./TldrawDurableObject"
// Define security headers // Define security headers
const securityHeaders = { 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';", "Content-Security-Policy":
'X-Content-Type-Options': 'nosniff', "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-Frame-Options': 'DENY', "X-Content-Type-Options": "nosniff",
'X-XSS-Protection': '1; mode=block', "X-Frame-Options": "DENY",
'Strict-Transport-Security': 'max-age=31536000; includeSubDomains', "X-XSS-Protection": "1; mode=block",
'Referrer-Policy': 'strict-origin-when-cross-origin', "Strict-Transport-Security": "max-age=31536000; includeSubDomains",
'Permissions-Policy': 'camera=(), microphone=(), geolocation=()' "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 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. // we're hosting the worker separately to the client. you should restrict this to your own domain.
const { preflight, corsify } = cors({ const { preflight, corsify } = cors({
origin: (origin) => { origin: (origin) => {
const allowedOrigins = [ const allowedOrigins = [
'https://jeffemmett.com', "https://jeffemmett.com",
'https://www.jeffemmett.com', "https://www.jeffemmett.com",
'https://jeffemmett-canvas.jeffemmett.workers.dev', "https://jeffemmett-canvas.jeffemmett.workers.dev",
'https://jeffemmett.com/board/*', "https://jeffemmett.com/board/*",
]; ]
// Always allow if no origin (like from a local file) // Always allow if no origin (like from a local file)
if (!origin) return '*'; if (!origin) return "*"
// Check exact matches // Check exact matches
if (allowedOrigins.includes(origin)) { if (allowedOrigins.includes(origin)) {
return origin; return origin
} }
// For development - check if it's a localhost or local IP // For development - check if it's a localhost or local IP
if (origin.match(/^http:\/\/(localhost|127\.0\.0\.192\.168\.|169\.254\.|10\.)/)) { if (
return origin; origin.match(
} /^http:\/\/(localhost|127\.0\.0\.192\.168\.|169\.254\.|10\.)/,
)
) {
return origin
}
return undefined; return undefined
}, },
allowMethods: ['GET', 'POST', 'OPTIONS', 'UPGRADE'], allowMethods: ["GET", "POST", "OPTIONS", "UPGRADE"],
allowHeaders: [ allowHeaders: [
'Content-Type', "Content-Type",
'Authorization', "Authorization",
'Upgrade', "Upgrade",
'Connection', "Connection",
'Sec-WebSocket-Key', "Sec-WebSocket-Key",
'Sec-WebSocket-Version', "Sec-WebSocket-Version",
'Sec-WebSocket-Extensions', "Sec-WebSocket-Extensions",
'Sec-WebSocket-Protocol' "Sec-WebSocket-Protocol",
], ],
maxAge: 86400, maxAge: 86400,
credentials: true credentials: true,
}) })
const router = AutoRouter<IRequest, [env: Environment, ctx: ExecutionContext]>({ const router = AutoRouter<IRequest, [env: Environment, ctx: ExecutionContext]>({
before: [preflight], before: [preflight],
finally: [(response) => { finally: [
// Add security headers to all responses except WebSocket upgrades (response) => {
if (response.status !== 101) { // Add security headers to all responses except WebSocket upgrades
Object.entries(securityHeaders).forEach(([key, value]) => { if (response.status !== 101) {
response.headers.set(key, value) Object.entries(securityHeaders).forEach(([key, value]) => {
}) response.headers.set(key, value)
} })
return corsify(response) }
}], return corsify(response)
catch: (e) => { },
console.error(e) ],
return error(e) catch: (e) => {
}, console.error(e)
return error(e)
},
}) })
// requests to /connect are routed to the Durable Object, and handle realtime websocket syncing // requests to /connect are routed to the Durable Object, and handle realtime websocket syncing
.get('/connect/:roomId', (request, env) => { .get("/connect/:roomId", (request, env) => {
const id = env.TLDRAW_DURABLE_OBJECT.idFromName(request.params.roomId) const id = env.TLDRAW_DURABLE_OBJECT.idFromName(request.params.roomId)
const room = env.TLDRAW_DURABLE_OBJECT.get(id) const room = env.TLDRAW_DURABLE_OBJECT.get(id)
return room.fetch(request.url, { return room.fetch(request.url, {
headers: request.headers, headers: request.headers,
body: request.body, body: request.body,
method: request.method method: request.method,
}) })
}) })
// assets can be uploaded to the bucket under /uploads: // assets can be uploaded to the bucket under /uploads:
.post('/uploads/:uploadId', handleAssetUpload) .post("/uploads/:uploadId", handleAssetUpload)
// they can be retrieved from the bucket too: // they can be retrieved from the bucket too:
.get('/uploads/:uploadId', handleAssetDownload) .get("/uploads/:uploadId", handleAssetDownload)
// bookmarks need to extract metadata from pasted URLs: // bookmarks need to extract metadata from pasted URLs:
.get('/unfurl', handleUnfurlRequest) .get("/unfurl", handleUnfurlRequest)
.get('/room/:roomId', (request, env) => { .get("/room/:roomId", (request, env) => {
const id = env.TLDRAW_DURABLE_OBJECT.idFromName(request.params.roomId) const id = env.TLDRAW_DURABLE_OBJECT.idFromName(request.params.roomId)
const room = env.TLDRAW_DURABLE_OBJECT.get(id) const room = env.TLDRAW_DURABLE_OBJECT.get(id)
return room.fetch(request.url, { return room.fetch(request.url, {
headers: request.headers, headers: request.headers,
body: request.body, body: request.body,
method: request.method method: request.method,
}) })
}) })
.post('/room/:roomId', async (request, env) => { .post("/room/:roomId", async (request, env) => {
const id = env.TLDRAW_DURABLE_OBJECT.idFromName(request.params.roomId) const id = env.TLDRAW_DURABLE_OBJECT.idFromName(request.params.roomId)
const room = env.TLDRAW_DURABLE_OBJECT.get(id) const room = env.TLDRAW_DURABLE_OBJECT.get(id)
return room.fetch(request.url, { return room.fetch(request.url, {
method: 'POST', method: "POST",
body: request.body body: request.body,
}) })
}) })
.post('/daily/rooms', async (request, env) => { .post("/daily/rooms", async (request, env) => {
const response = await fetch('https://api.daily.co/v1/rooms', { try {
method: 'POST', // Log the request for debugging
headers: { console.log(
'Authorization': `Bearer ${env.DAILY_API_KEY}`, "Creating Daily room with API key:",
'Content-Type': 'application/json' env.DAILY_API_KEY ? "present" : "missing",
}, )
body: await request.text()
});
const data = await response.json() as Record<string, unknown>; const response = await fetch("https://api.daily.co/v1/rooms", {
return new Response(JSON.stringify({ method: "POST",
...data, headers: {
url: `https://${env.DAILY_DOMAIN}/${data.name}` Authorization: `Bearer ${env.DAILY_API_KEY}`,
}), { "Content-Type": "application/json",
headers: { '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<string, unknown>),
url: `https://${env.DAILY_DOMAIN}/${
(data as Record<string, unknown>).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 our router for cloudflare
export default router export default router