videochat tool update

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

View File

@ -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"
},

View File

@ -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())
},

View File

@ -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<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> {
static override type = "VideoChat"
@ -33,29 +81,37 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
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<IVideoChatShape>({
id: shape.id,
type: "VideoChat",
props: {
...shape.props,
roomUrl: (data as any).url,
},
})
const data = await response.json()
this.editor.updateShape<IVideoChatShape>({
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<IVideoChatShape> {
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 (
<div
style={{
width: `${shape.props.w}px`,
height: `${shape.props.h}px`,
display: "flex",
alignItems: "center",
justifyContent: "center",
backgroundColor: "#f0f0f0",
borderRadius: "4px",
}}
>
<div>Initializing video chat...</div>
</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 (
<div
style={{
pointerEvents: "all",
width: `${shape.props.w}px`,
height: `${shape.props.h}px`,
position: "absolute",
top: "10px",
left: "10px",
zIndex: 9999,
padding: "15px",
backgroundColor: "#F0F0F0",
boxShadow: "0 2px 4px rgba(0, 0, 0, 0.1)",
borderRadius: "4px",
position: "relative",
}}
>
{!isInRoom ? (
<button
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>
) : (
<div
id="daily-call-iframe-container"
style={{
width: "100%",
height: "100%",
}}
/>
<VideoChatComponent roomUrl={shape.props.roomUrl} />
)}
{error && (
<div
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>
)
}

View File

@ -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 {

View File

@ -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<IRequest, [env: Environment, ctx: ExecutionContext]>({
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<string, unknown>;
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<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 default router