From 2e70d75a6636f43ea8ef89ff3af05ce813c64940 Mon Sep 17 00:00:00 2001 From: Jeff Emmett <46964190+Jeff-Emmett@users.noreply.github.com> Date: Mon, 25 Nov 2024 16:18:05 +0700 Subject: [PATCH 01/12] CRDTs working, still finalizing local board state browser storage for offline board access --- .gitignore | 3 +- index.html | 73 ++++++++++++++------------- package.json | 20 +++++--- src/components/Board.tsx | 54 ++++++-------------- src/css/style.css | 27 +++++++++- src/hooks/useGSetState.ts | 34 +++++++++++++ src/hooks/useLocalStorageRoom.ts | 36 +++++++++++++ src/hooks/usePersistentBoard.ts | 86 ++++++++++++++++++++++++++++++++ src/types/crdts.d.ts | 6 +++ vercel.json | 11 ++++ vite.config.ts | 7 ++- worker/TldrawDurableObject.ts | 55 ++++++++++++++++++++ worker/worker.ts | 61 +++++++++++++++++++++- wrangler.toml | 6 ++- 14 files changed, 388 insertions(+), 91 deletions(-) create mode 100644 src/hooks/useGSetState.ts create mode 100644 src/hooks/useLocalStorageRoom.ts create mode 100644 src/hooks/usePersistentBoard.ts create mode 100644 src/types/crdts.d.ts diff --git a/.gitignore b/.gitignore index 66b82c9..981a75d 100644 --- a/.gitignore +++ b/.gitignore @@ -172,4 +172,5 @@ dist .pnp.\* .wrangler/ -.*.md \ No newline at end of file +.*.md +.vercel diff --git a/index.html b/index.html index 063435a..f59f4da 100644 --- a/index.html +++ b/index.html @@ -1,42 +1,45 @@ - - Jeff Emmett - - - - - - - - - + + Jeff Emmett + + + + + + + - - - - - + + - - - - - - + + + + + + + + + + + + + + + + + + + +
+ + - - - - -
- - \ No newline at end of file diff --git a/package.json b/package.json index 70423e7..c97ba26 100644 --- a/package.json +++ b/package.json @@ -4,12 +4,13 @@ "description": "Jeff Emmett's personal website", "type": "module", "scripts": { - "dev": "concurrently --kill-others --names client,worker --prefix-colors blue,red yarn:dev:client yarn:dev:worker", - "dev:client": "vite --host", - "dev:worker": "wrangler dev", - "build": "tsc && vite build", + "dev": "concurrently --kill-others --names client,worker --prefix-colors blue,red \"yarn dev:client\" \"yarn dev:worker\"", + "dev:client": "vite --host --port 5173", + "dev:worker": "wrangler dev --local --port 5172 --ip 0.0.0.0", + "build": "tsc && vite build && wrangler deploy", "lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0", - "preview": "vite preview" + "preview": "vite preview", + "deploy": "yarn build && vercel deploy --prod" }, "keywords": [], "author": "Jeff Emmett", @@ -18,11 +19,13 @@ "@dimforge/rapier2d": "^0.11.2", "@tldraw/sync": "^2.4.6", "@tldraw/sync-core": "^2.4.6", + "@tldraw/tldraw": "^3.4.1", "@tldraw/tlschema": "^2.4.6", "@types/markdown-it": "^14.1.1", "@vercel/analytics": "^1.2.2", "@whereby.com/browser-sdk": "^3.9.2", "cloudflare-workers-unfurl": "^0.0.7", + "crdts": "^0.2.0", "gray-matter": "^4.0.3", "itty-router": "^5.0.17", "lodash.throttle": "^4.1.1", @@ -30,8 +33,11 @@ "markdown-it-latex2img": "^0.0.6", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-error-boundary": "^4.1.2", "react-router-dom": "^6.22.3", - "tldraw": "^2.4.6" + "tldraw": "^2.4.6", + "use-local-storage-state": "^19.5.0", + "vercel": "^39.1.1" }, "devDependencies": { "@biomejs/biome": "1.4.1", @@ -55,4 +61,4 @@ "vite-plugin-wasm": "^3.2.2", "wrangler": "^3.88.0" } -} +} \ No newline at end of file diff --git a/src/components/Board.tsx b/src/components/Board.tsx index 4fe8fba..600ae39 100644 --- a/src/components/Board.tsx +++ b/src/components/Board.tsx @@ -3,9 +3,11 @@ import { AssetRecordType, getHashForString, TLBookmarkAsset, + TLRecord, Tldraw, } from 'tldraw' import { useParams } from 'react-router-dom' +import useLocalStorageState from 'use-local-storage-state' import { ChatBoxTool } from '@/tools/ChatBoxTool' import { ChatBoxShape } from '@/shapes/ChatBoxShapeUtil' import { VideoChatTool } from '@/tools/VideoChatTool' @@ -15,33 +17,26 @@ import { customSchema } from '../../worker/TldrawDurableObject' import { EmbedShape } from '@/shapes/EmbedShapeUtil' import { EmbedTool } from '@/tools/EmbedTool' -import React, { useState } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { ChatBox } from '@/shapes/ChatBoxShapeUtil'; import { components, uiOverrides } from '@/ui-overrides' -const WORKER_URL = `https://jeffemmett-canvas.jeffemmett.workers.dev` +//const WORKER_URL = `https://jeffemmett-canvas.jeffemmett.workers.dev` +export const WORKER_URL = 'https://jeffemmett-canvas.jeffemmett.workers.dev'; const shapeUtils = [ChatBoxShape, VideoChatShape, EmbedShape] const tools = [ChatBoxTool, VideoChatTool, EmbedTool]; // Array of tools +// Add these imports +import { useGSetState } from '@/hooks/useGSetState'; +import { useLocalStorageRoom } from '@/hooks/useLocalStorageRoom'; +import { usePersistentBoard } from '@/hooks/usePersistentBoard'; + + export function Board() { - const { slug } = useParams<{ slug: string }>(); // Ensure this is inside the Board component - const roomId = slug || 'default-room'; // Declare roomId here - - const store = useSync({ - uri: `${WORKER_URL}/connect/${roomId}`, - assets: multiplayerAssetStore, - shapeUtils: shapeUtils, - schema: customSchema, - }); - - const [isChatBoxVisible, setChatBoxVisible] = useState(false); - const [userName, setUserName] = useState(''); - const [isVideoChatVisible, setVideoChatVisible] = useState(false); // Added state for video chat visibility - - const handleNameChange = (event: React.ChangeEvent) => { - setUserName(event.target.value); - }; + const { slug } = useParams<{ slug: string }>(); + const roomId = slug || 'default-room'; + const { store } = usePersistentBoard(roomId); return (
@@ -56,27 +51,6 @@ export function Board() { editor.setCurrentTool('hand') }} /> - {isChatBoxVisible && ( -
- - -
- )} - {isVideoChatVisible && ( // Render the button to join video chat - - )}
) } diff --git a/src/css/style.css b/src/css/style.css index 39fd45e..9902992 100644 --- a/src/css/style.css +++ b/src/css/style.css @@ -30,9 +30,11 @@ main { h1 { font-size: 2rem; } + h2 { font-size: 1.5rem; } + h1, h2, h3, @@ -54,7 +56,7 @@ i { font-variation-settings: "slnt" -15; } -pre > code { +pre>code { width: 100%; padding: 1em; display: block; @@ -82,6 +84,7 @@ blockquote { margin-top: 1em; margin-bottom: 1em; border-radius: 4px; + & p { font-variation-settings: "CASL" 1; margin: 0; @@ -103,6 +106,7 @@ table { margin-bottom: 1em; font-variation-settings: "mono" 1; font-variation-settings: "casl" 0; + th, td { padding: 0.5em; @@ -121,9 +125,11 @@ table { a { font-variation-settings: "CASL" 0; + &:hover { animation: casl-forward 0.2s ease forwards; } + &:not(:hover) { /* text-decoration: none; */ animation: casl-reverse 0.2s ease backwards; @@ -136,18 +142,21 @@ a { "CASL" 0, "wght" 400; } + to { font-variation-settings: "CASL" 1, "wght" 600; } } + @keyframes casl-reverse { from { font-variation-settings: "CASL" 1, "wght" 600; } + to { font-variation-settings: "CASL" 0, @@ -172,6 +181,7 @@ ul { padding-left: 0; margin-top: 0; font-size: 1rem; + & li::marker { color: rgba(0, 0, 0, 0.322); } @@ -186,9 +196,11 @@ img { main { padding: 2em; } + header { margin-bottom: 1em; } + ol { list-style-position: inside; } @@ -202,6 +214,7 @@ table:not(:has(+ p)) { p:has(+ ul) { margin-bottom: 0.5em; } + p:has(+ ol) { margin-bottom: 0.5em; } @@ -233,17 +246,21 @@ p:has(+ ol) { border: none; cursor: pointer; opacity: 0.25; + &:hover { opacity: 1; } + & img { width: 100%; height: 100%; } } + #toggle-canvas { top: 10px; } + #toggle-physics { top: 60px; display: none; @@ -253,6 +270,7 @@ p:has(+ ol) { font-family: "Recursive"; font-variation-settings: "MONO" 1; font-variation-settings: "CASL" 1; + & h1, p, span, @@ -265,6 +283,7 @@ p:has(+ ol) { & header { font-size: 1.5rem; } + & p { font-size: 1.1rem; } @@ -277,6 +296,7 @@ p:has(+ ol) { .canvas-mode { overflow: hidden; + & #toggle-physics { display: block; } @@ -287,6 +307,9 @@ p:has(+ ol) { position: fixed; inset: 0px; overflow: hidden; + touch-action: none; + -webkit-touch-callout: none; + -webkit-tap-highlight-color: transparent; } .tl-background { @@ -301,4 +324,4 @@ p:has(+ ol) { box-shadow: 0 0px 16px rgba(0, 0, 0, 0.15); overflow: hidden; background-color: white; -} +} \ No newline at end of file diff --git a/src/hooks/useGSetState.ts b/src/hooks/useGSetState.ts new file mode 100644 index 0000000..de096a9 --- /dev/null +++ b/src/hooks/useGSetState.ts @@ -0,0 +1,34 @@ +import useLocalStorageState from 'use-local-storage-state'; +import GSet from 'crdts/src/G-Set'; +import { TLRecord } from 'tldraw'; + +export function useGSetState(roomId: string) { + const [localSet, setLocalSet] = useLocalStorageState(`gset-${roomId}`, { + defaultValue: [] + }); + + const gset = new GSet(); + + // Initialize G-Set with local data + if (localSet && Array.isArray(localSet)) { + localSet.forEach(record => gset.add(record)); + } + + const addRecord = (record: TLRecord) => { + gset.add(record); + setLocalSet(Array.from(gset.values())); + }; + + const merge = (remoteSet: Set) => { + remoteSet.forEach(record => gset.add(record)); + setLocalSet(Array.from(gset.values())); + return gset.values(); + }; + + return { + values: gset.values(), + add: addRecord, + merge, + localSet + }; +} \ No newline at end of file diff --git a/src/hooks/useLocalStorageRoom.ts b/src/hooks/useLocalStorageRoom.ts new file mode 100644 index 0000000..919bc95 --- /dev/null +++ b/src/hooks/useLocalStorageRoom.ts @@ -0,0 +1,36 @@ +import useLocalStorageState from 'use-local-storage-state'; +import { TLRecord, createTLStore, SerializedStore } from 'tldraw'; +import { customSchema } from '../../worker/TldrawDurableObject'; +import { TLSocketRoom } from '@tldraw/sync-core'; + +export function useLocalStorageRoom(roomId: string) { + const [records, setRecords] = useLocalStorageState>(`tldraw-room-${roomId}`, { + defaultValue: createTLStore({ schema: customSchema }).serialize() + }); + + const store = createTLStore({ + schema: customSchema, + initialData: records, + }); + + const socketRoom = new TLSocketRoom({ + initialSnapshot: { + store: store.serialize(), + schema: customSchema.serialize(), + }, + schema: customSchema, + onDataChange: () => { + const serializedStore = store.serialize(); + setRecords(serializedStore); + // Broadcast changes to other clients + store.mergeRemoteChanges(() => Object.values(serializedStore)); + }, + }); + + return { + store, + socketRoom, + records, + setRecords + }; +} \ No newline at end of file diff --git a/src/hooks/usePersistentBoard.ts b/src/hooks/usePersistentBoard.ts new file mode 100644 index 0000000..e255ece --- /dev/null +++ b/src/hooks/usePersistentBoard.ts @@ -0,0 +1,86 @@ +import { useSync } from '@tldraw/sync' +import { useState, useEffect } from 'react' +import { customSchema } from '../../worker/TldrawDurableObject' +import { multiplayerAssetStore } from '../client/multiplayerAssetStore' +import { useGSetState } from './useGSetState' +import { useLocalStorageRoom } from './useLocalStorageRoom' +import { RecordType, BaseRecord } from '@tldraw/store' +import { TLRecord } from 'tldraw' + +export function usePersistentBoard(roomId: string) { + const [isOnline, setIsOnline] = useState(navigator.onLine) + const { store: localStore, records, setRecords } = useLocalStorageRoom(roomId) + const { values, add, merge } = useGSetState(roomId) + + const getWebSocketUrl = (baseUrl: string) => { + // Remove any trailing slashes + baseUrl = baseUrl.replace(/\/$/, '') + + // Handle different protocols + if (baseUrl.startsWith('https://')) { + return baseUrl.replace('https://', 'wss://') + } else if (baseUrl.startsWith('http://')) { + return baseUrl.replace('http://', 'ws://') + } + return baseUrl + } + + const syncedStore = useSync({ + uri: import.meta.env.TLDRAW_WORKER_URL + ? `${getWebSocketUrl(import.meta.env.TLDRAW_WORKER_URL)}/connect/${roomId}` + : `wss://jeffemmett-canvas.jeffemmett.workers.dev/connect/${roomId}`, + schema: customSchema, + assets: multiplayerAssetStore, + }) + + // Handle online/offline status + useEffect(() => { + const handleOnline = () => setIsOnline(true) + const handleOffline = () => setIsOnline(false) + + window.addEventListener('online', handleOnline) + window.addEventListener('offline', handleOffline) + + return () => { + window.removeEventListener('online', handleOnline) + window.removeEventListener('offline', handleOffline) + } + }, []) + + // Handle online/offline synchronization + useEffect(() => { + if (isOnline && syncedStore?.store) { + // Sync server records to local + const serverRecords = Object.values(syncedStore.store.allRecords()) + merge(new Set(serverRecords)) + + // Set up store change listener + const unsubscribe = syncedStore.store.listen((event) => { + if ('changes' in event) { + const changedRecords = Object.values(event.changes) + merge(new Set(changedRecords)) + // Also update local storage + setRecords(syncedStore.store.serialize()) + } + }) + + return () => unsubscribe() + } else if (!isOnline && localStore) { + // When going offline, ensure we have the latest state in local storage + const currentRecords = Object.values(localStore.allRecords()) + merge(new Set(currentRecords)) + } + }, [isOnline, syncedStore?.store, localStore]) + + return { + store: isOnline ? syncedStore?.store : localStore, + isOnline, + addRecord: (record: TLRecord) => { + add(record) + if (!isOnline) { + setRecords(localStore.serialize()) + } + }, + mergeRecords: merge + } +} \ No newline at end of file diff --git a/src/types/crdts.d.ts b/src/types/crdts.d.ts new file mode 100644 index 0000000..7ae7961 --- /dev/null +++ b/src/types/crdts.d.ts @@ -0,0 +1,6 @@ +declare module 'crdts/src/G-Set' { + export default class GSet { + add(value: T): void; + values(): Set; + } +} \ No newline at end of file diff --git a/vercel.json b/vercel.json index 9880969..5f5753c 100644 --- a/vercel.json +++ b/vercel.json @@ -24,5 +24,16 @@ "source": "/books", "destination": "/" } + ], + "headers": [ + { + "source": "/assets/(.*)", + "headers": [ + { + "key": "Cache-Control", + "value": "public, max-age=31536000, immutable" + } + ] + } ] } \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts index c10cd5c..ba7d3f4 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -5,10 +5,9 @@ import wasm from "vite-plugin-wasm"; import topLevelAwait from "vite-plugin-top-level-await"; import { viteStaticCopy } from 'vite-plugin-static-copy'; - export default defineConfig({ define: { - 'process.env.TLDRAW_WORKER_URL': JSON.stringify('https://jeffemmett-canvas.jeffemmett.workers.dev') + 'process.env.TLDRAW_WORKER_URL': JSON.stringify(process.env.TLDRAW_WORKER_URL || 'https://jeffemmett-canvas.jeffemmett.workers.dev') }, plugins: [ react(), @@ -24,6 +23,10 @@ export default defineConfig({ ] }) ], + server: { + host: '0.0.0.0', + port: 5173, + }, build: { sourcemap: true, }, diff --git a/worker/TldrawDurableObject.ts b/worker/TldrawDurableObject.ts index 6d99202..5e4a496 100644 --- a/worker/TldrawDurableObject.ts +++ b/worker/TldrawDurableObject.ts @@ -14,6 +14,7 @@ import { Environment } from './types' import { ChatBoxShape } from '@/shapes/ChatBoxShapeUtil' import { VideoChatShape } from '@/shapes/VideoChatShapeUtil' import { EmbedShape } from '@/shapes/EmbedShapeUtil' +import GSet from 'crdts/src/G-Set' // add custom shapes and bindings here if needed: export const customSchema = createTLSchema({ @@ -66,6 +67,16 @@ export class TldrawDurableObject { } return this.handleConnect(request) }) + .get('/room/:roomId', async () => { + const room = await this.getRoom() + const snapshot = room.getCurrentSnapshot() + return new Response(JSON.stringify(snapshot.documents)) + }) + .post('/room/:roomId', async (request) => { + const records = await request.json() as TLRecord[] + const mergedRecords = await this.mergeCrdtState(records) + return new Response(JSON.stringify(Array.from(mergedRecords))) + }) // `fetch` is the entry point for all requests to the Durable Object fetch(request: Request): Response | Promise { @@ -136,4 +147,48 @@ export class TldrawDurableObject { const snapshot = JSON.stringify(room.getCurrentSnapshot()) await this.r2.put(`rooms/${this.roomId}`, snapshot) }, 10_000) + + async mergeCrdtState(records: TLRecord[]) { + const room = await this.getRoom(); + const gset = new GSet(); + + const store = room.getCurrentSnapshot(); + if (!store) { + throw new Error('Room store not initialized'); + } + + // First cast to unknown, then to TLRecord + store.documents.forEach((record) => gset.add(record as unknown as TLRecord)); + + // Merge new records + records.forEach((record: TLRecord) => gset.add(record)); + return gset.values(); + } + + // 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) + + server.accept() + + // 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': '*', + }, + }) + } } diff --git a/worker/worker.ts b/worker/worker.ts index b288744..4a4e6b3 100644 --- a/worker/worker.ts +++ b/worker/worker.ts @@ -6,12 +6,53 @@ import { Environment } from './types' // make sure our sync durable object is made available to cloudflare 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=()' +} + // 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: '*' }) +const { preflight, corsify } = cors({ + origin: (origin) => { + const allowedOrigins = [ + 'http://localhost:5172', + 'http://192.168.1.7:5172', + 'https://jeffemmett.com' + ] + return allowedOrigins.includes(origin) ? origin : undefined + }, + allowMethods: ['GET', 'POST', 'OPTIONS', 'UPGRADE'], + allowHeaders: [ + 'Content-Type', + 'Authorization', + 'Upgrade', + 'Connection', + 'Sec-WebSocket-Key', + 'Sec-WebSocket-Version', + 'Sec-WebSocket-Extensions', + 'Sec-WebSocket-Protocol', + ...Object.keys(securityHeaders) + ], + maxAge: 86400, +}) const router = AutoRouter({ before: [preflight], - finally: [corsify], + 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) @@ -33,5 +74,21 @@ const router = AutoRouter({ // bookmarks need to extract metadata from pasted URLs: .get('/unfurl', handleUnfurlRequest) + .get('/room/:roomId', async (request, env) => { + const id = env.TLDRAW_DURABLE_OBJECT.idFromName(request.params.roomId) + const room = env.TLDRAW_DURABLE_OBJECT.get(id) + const response = await room.fetch(request.url) + return response + }) + + .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 + }) + }) + // export our router for cloudflare export default router diff --git a/wrangler.toml b/wrangler.toml index 0d5d04e..7e3c1ab 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -1,6 +1,8 @@ main = "worker/worker.ts" compatibility_date = "2024-07-01" name = "jeffemmett-canvas" +account_id = "0e7b3338d5278ed1b148e6456b940913" +zone_id = "45c200f8dc2a01852e41b9bb09eb7359" [vars] TLDRAW_WORKER_URL = "https://jeffemmett-canvas.jeffemmett.workers.dev" @@ -8,6 +10,8 @@ TLDRAW_WORKER_URL = "https://jeffemmett-canvas.jeffemmett.workers.dev" [dev] port = 5172 ip = "0.0.0.0" +local_protocol = "http" +upstream_protocol = "https" # Set up the durable object used for each tldraw room [durable_objects] @@ -25,8 +29,6 @@ new_classes = ["TldrawDurableObject"] binding = 'TLDRAW_BUCKET' bucket_name = 'jeffemmett-canvas' preview_bucket_name = 'jeffemmett-canvas-preview' -workers_dev = true -logpush = true # wrangler.toml (wrangler v3.79.0^) [observability] From 4a08ffd9d463d03b95cb0cdba395d5e2c119facf Mon Sep 17 00:00:00 2001 From: Jeff Emmett <46964190+Jeff-Emmett@users.noreply.github.com> Date: Mon, 25 Nov 2024 22:09:41 +0700 Subject: [PATCH 02/12] almost everything working, except maybe offline storage state (and browser reload) --- src/hooks/useGSetState.ts | 36 +++++++------ src/hooks/useLocalStorageRoom.ts | 16 ------ src/hooks/usePersistentBoard.ts | 86 +++++++++++++++++--------------- worker/worker.ts | 29 +++++++++-- 4 files changed, 91 insertions(+), 76 deletions(-) diff --git a/src/hooks/useGSetState.ts b/src/hooks/useGSetState.ts index de096a9..e825fa1 100644 --- a/src/hooks/useGSetState.ts +++ b/src/hooks/useGSetState.ts @@ -1,32 +1,38 @@ import useLocalStorageState from 'use-local-storage-state'; import GSet from 'crdts/src/G-Set'; import { TLRecord } from 'tldraw'; +import { useRef, useCallback } from 'react'; export function useGSetState(roomId: string) { const [localSet, setLocalSet] = useLocalStorageState(`gset-${roomId}`, { defaultValue: [] }); - const gset = new GSet(); - - // Initialize G-Set with local data - if (localSet && Array.isArray(localSet)) { - localSet.forEach(record => gset.add(record)); + // Keep GSet instance in a ref to persist between renders + const gsetRef = useRef>(); + if (!gsetRef.current) { + gsetRef.current = new GSet(); + // Initialize G-Set with local data + if (localSet && Array.isArray(localSet)) { + localSet.forEach(record => gsetRef.current?.add(record)); + } } - const addRecord = (record: TLRecord) => { - gset.add(record); - setLocalSet(Array.from(gset.values())); - }; + const addRecord = useCallback((record: TLRecord) => { + if (!gsetRef.current) return; + gsetRef.current.add(record); + setLocalSet(Array.from(gsetRef.current.values())); + }, [setLocalSet]); - const merge = (remoteSet: Set) => { - remoteSet.forEach(record => gset.add(record)); - setLocalSet(Array.from(gset.values())); - return gset.values(); - }; + const merge = useCallback((remoteSet: Set) => { + if (!gsetRef.current) return new Set(); + remoteSet.forEach(record => gsetRef.current?.add(record)); + setLocalSet(Array.from(gsetRef.current.values())); + return gsetRef.current.values(); + }, [setLocalSet]); return { - values: gset.values(), + values: gsetRef.current.values(), add: addRecord, merge, localSet diff --git a/src/hooks/useLocalStorageRoom.ts b/src/hooks/useLocalStorageRoom.ts index 919bc95..946fef5 100644 --- a/src/hooks/useLocalStorageRoom.ts +++ b/src/hooks/useLocalStorageRoom.ts @@ -1,7 +1,6 @@ import useLocalStorageState from 'use-local-storage-state'; import { TLRecord, createTLStore, SerializedStore } from 'tldraw'; import { customSchema } from '../../worker/TldrawDurableObject'; -import { TLSocketRoom } from '@tldraw/sync-core'; export function useLocalStorageRoom(roomId: string) { const [records, setRecords] = useLocalStorageState>(`tldraw-room-${roomId}`, { @@ -13,23 +12,8 @@ export function useLocalStorageRoom(roomId: string) { initialData: records, }); - const socketRoom = new TLSocketRoom({ - initialSnapshot: { - store: store.serialize(), - schema: customSchema.serialize(), - }, - schema: customSchema, - onDataChange: () => { - const serializedStore = store.serialize(); - setRecords(serializedStore); - // Broadcast changes to other clients - store.mergeRemoteChanges(() => Object.values(serializedStore)); - }, - }); - return { store, - socketRoom, records, setRecords }; diff --git a/src/hooks/usePersistentBoard.ts b/src/hooks/usePersistentBoard.ts index e255ece..128bf51 100644 --- a/src/hooks/usePersistentBoard.ts +++ b/src/hooks/usePersistentBoard.ts @@ -1,39 +1,25 @@ import { useSync } from '@tldraw/sync' -import { useState, useEffect } from 'react' +import { useState, useEffect, useCallback, useRef } from 'react' import { customSchema } from '../../worker/TldrawDurableObject' import { multiplayerAssetStore } from '../client/multiplayerAssetStore' import { useGSetState } from './useGSetState' import { useLocalStorageRoom } from './useLocalStorageRoom' -import { RecordType, BaseRecord } from '@tldraw/store' import { TLRecord } from 'tldraw' +import { WORKER_URL } from '../components/Board' export function usePersistentBoard(roomId: string) { const [isOnline, setIsOnline] = useState(navigator.onLine) const { store: localStore, records, setRecords } = useLocalStorageRoom(roomId) const { values, add, merge } = useGSetState(roomId) - - const getWebSocketUrl = (baseUrl: string) => { - // Remove any trailing slashes - baseUrl = baseUrl.replace(/\/$/, '') - - // Handle different protocols - if (baseUrl.startsWith('https://')) { - return baseUrl.replace('https://', 'wss://') - } else if (baseUrl.startsWith('http://')) { - return baseUrl.replace('http://', 'ws://') - } - return baseUrl - } + const initialSyncRef = useRef(false) + const mergeInProgressRef = useRef(false) const syncedStore = useSync({ - uri: import.meta.env.TLDRAW_WORKER_URL - ? `${getWebSocketUrl(import.meta.env.TLDRAW_WORKER_URL)}/connect/${roomId}` - : `wss://jeffemmett-canvas.jeffemmett.workers.dev/connect/${roomId}`, + uri: `${WORKER_URL.replace('https://', 'wss://')}/connect/${roomId}`, schema: customSchema, assets: multiplayerAssetStore, }) - // Handle online/offline status useEffect(() => { const handleOnline = () => setIsOnline(true) const handleOffline = () => setIsOnline(false) @@ -47,40 +33,60 @@ export function usePersistentBoard(roomId: string) { } }, []) - // Handle online/offline synchronization - useEffect(() => { - if (isOnline && syncedStore?.store) { - // Sync server records to local - const serverRecords = Object.values(syncedStore.store.allRecords()) - merge(new Set(serverRecords)) + const mergeRecords = useCallback((records: Set) => { + if (mergeInProgressRef.current || records.size === 0) return + + try { + mergeInProgressRef.current = true + merge(records) + if (!isOnline && localStore) { + setRecords(localStore.serialize()) + } + } finally { + mergeInProgressRef.current = false + } + }, [isOnline, localStore, merge, setRecords]) + + useEffect(() => { + if (!syncedStore?.store || !localStore) return + + if (isOnline && !initialSyncRef.current) { + initialSyncRef.current = true + const serverRecords = Object.values(syncedStore.store.allRecords()) + if (serverRecords.length > 0) { + mergeRecords(new Set(serverRecords)) + } - // Set up store change listener const unsubscribe = syncedStore.store.listen((event) => { if ('changes' in event) { const changedRecords = Object.values(event.changes) - merge(new Set(changedRecords)) - // Also update local storage - setRecords(syncedStore.store.serialize()) + if (changedRecords.length > 0) { + mergeRecords(new Set(changedRecords)) + } } }) return () => unsubscribe() - } else if (!isOnline && localStore) { - // When going offline, ensure we have the latest state in local storage + } else if (!isOnline) { const currentRecords = Object.values(localStore.allRecords()) - merge(new Set(currentRecords)) + if (currentRecords.length > 0) { + mergeRecords(new Set(currentRecords)) + } } - }, [isOnline, syncedStore?.store, localStore]) + }, [isOnline, syncedStore?.store, localStore, mergeRecords]) + + const addRecord = useCallback((record: TLRecord) => { + if (!record) return + add(record) + if (!isOnline && localStore) { + setRecords(localStore.serialize()) + } + }, [add, isOnline, localStore, setRecords]) return { store: isOnline ? syncedStore?.store : localStore, isOnline, - addRecord: (record: TLRecord) => { - add(record) - if (!isOnline) { - setRecords(localStore.serialize()) - } - }, - mergeRecords: merge + addRecord, + mergeRecords } } \ No newline at end of file diff --git a/worker/worker.ts b/worker/worker.ts index 4a4e6b3..f938bc9 100644 --- a/worker/worker.ts +++ b/worker/worker.ts @@ -21,12 +21,31 @@ const securityHeaders = { // 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 = [ - 'http://localhost:5172', - 'http://192.168.1.7:5172', - 'https://jeffemmett.com' + if (!origin) return undefined + + const allowedPatterns = [ + // Localhost with any port + /^http:\/\/localhost:\d+$/, + // 127.0.0.1 with any port + /^http:\/\/127\.0\.0\.1:\d+$/, + // 192.168.*.* with any port + /^http:\/\/192\.168\.\d+\.\d+:\d+$/, + // 169.254.*.* with any port + /^http:\/\/169\.254\.\d+\.\d+:\d+$/, + // 10.*.*.* with any port + /^http:\/\/10\.\d+\.\d+\.\d+:\d+$/, + // Production domain + /^https:\/\/jeffemmett\.com$/ ] - return allowedOrigins.includes(origin) ? origin : undefined + + // Check if origin matches any of our patterns + const isAllowed = allowedPatterns.some(pattern => + pattern instanceof RegExp + ? pattern.test(origin) + : pattern === origin + ) + + return isAllowed ? origin : undefined }, allowMethods: ['GET', 'POST', 'OPTIONS', 'UPGRADE'], allowHeaders: [ From a0d51e18b132592215268cc15ba9a54d22f4a2ac Mon Sep 17 00:00:00 2001 From: Jeff Emmett <46964190+Jeff-Emmett@users.noreply.github.com> Date: Wed, 27 Nov 2024 10:39:33 +0700 Subject: [PATCH 03/12] swapped in daily.co video and removed whereby sdk, finished zoom and copylink except for context menu display --- .env.example | 19 ++ .gitignore | 9 + package copy.json | 69 +++++ package.json | 11 +- src/App.tsx | 44 ++- src/components/Board.tsx | 34 ++- src/components/Canvas.tsx | 2 +- src/hooks/useCameraControls.ts | 90 ++++++ src/hooks/useLocalStorageRoom copy.ts | 103 +++++++ src/shapes/VideoChatShapeUtil.tsx | 148 ++++------ src/ui-overrides.tsx | 402 +++++++++++++++++++++++++- worker/types.ts | 2 + worker/worker.ts | 19 ++ wrangler.toml | 18 +- 14 files changed, 839 insertions(+), 131 deletions(-) create mode 100644 .env.example create mode 100644 package copy.json create mode 100644 src/hooks/useCameraControls.ts create mode 100644 src/hooks/useLocalStorageRoom copy.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..343532a --- /dev/null +++ b/.env.example @@ -0,0 +1,19 @@ +# Google API Credentials +VITE_GOOGLE_CLIENT_ID='your_google_client_id' +VITE_GOOGLE_API_KEY='your_google_api_key' + +# Cloudflare Worker +CLOUDFLARE_API_TOKEN='your_cloudflare_token' +CLOUDFLARE_ACCOUNT_ID='your_account_id' +CLOUDFLARE_ZONE_ID='your_zone_id' + +# Worker URL +TLDRAW_WORKER_URL='your_worker_url' + +# R2 Bucket Configuration +R2_BUCKET_NAME='your_bucket_name' +R2_PREVIEW_BUCKET_NAME='your_preview_bucket_name' + +# Daily.co Configuration +DAILY_API_KEY='your_daily_api_key' +DAILY_DOMAIN='your_daily_domain' \ No newline at end of file diff --git a/.gitignore b/.gitignore index 981a75d..66646be 100644 --- a/.gitignore +++ b/.gitignore @@ -174,3 +174,12 @@ dist .wrangler/ .*.md .vercel + +# Environment files +.env +.env.local +.env.*.local +.dev.vars + +# Keep example file +!.env.example diff --git a/package copy.json b/package copy.json new file mode 100644 index 0000000..0c4f574 --- /dev/null +++ b/package copy.json @@ -0,0 +1,69 @@ +{ + "name": "jeffemmett", + "version": "1.0.0", + "description": "Jeff Emmett's personal website", + "type": "module", + "scripts": { + "dev": "concurrently --kill-others --names client,worker --prefix-colors blue,red \"yarn dev:client\" \"yarn dev:worker\"", + "dev:client": "vite --host --port 5173", + "dev:worker": "wrangler dev --local --port 5172 --ip 0.0.0.0", + "build": "tsc && vite build && wrangler deploy", + "lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "preview": "vite preview", + "deploy": "yarn build && vercel deploy --prod" + }, + "keywords": [], + "author": "Jeff Emmett", + "license": "ISC", + "dependencies": { + "@dimforge/rapier2d": "^0.11.2", + "@tldraw/assets": "^2.0.0", + "@tldraw/tldraw": "^3.4.1", + "@tldraw/sync": "^2.4.6", + "@tldraw/sync-core": "^2.4.6", + "@tldraw/tlschema": "^2.4.6", + "@types/markdown-it": "^14.1.1", + "@vercel/analytics": "^1.2.2", + "@whereby.com/browser-sdk": "^3.9.2", + "cloudflare-workers-unfurl": "^0.0.7", + "crdts": "^0.2.0", + "gray-matter": "^4.0.3", + "itty-router": "^5.0.17", + "lodash.throttle": "^4.1.1", + "markdown-it": "^14.1.0", + "markdown-it-latex2img": "^0.0.6", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-error-boundary": "^4.1.2", + "react-router-dom": "^6.22.3", + "tldraw": "^2.4.6", + "use-local-storage-state": "^19.5.0", + "vercel": "^39.1.1" + }, + "devDependencies": { + "@biomejs/biome": "1.4.1", + "@cloudflare/types": "^6.29.1", + "@cloudflare/workers-types": "^4.20240821.1", + "@types/lodash.throttle": "^4", + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.7", + "@typescript-eslint/eslint-plugin": "^5.59.0", + "@typescript-eslint/parser": "^5.59.0", + "@vitejs/plugin-react": "^4.0.3", + "@vitejs/plugin-react-swc": "^3.6.0", + "concurrently": "^8.2.2", + "eslint": "^8.38.0", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-refresh": "^0.3.4", + "typescript": "^5.6.3", + "vite": "^5.3.3", + "vite-plugin-static-copy": "^1.0.6", + "vite-plugin-top-level-await": "^1.3.1", + "vite-plugin-wasm": "^3.2.2", + "wrangler": "^3.88.0" + }, + "resolutions": { + "react": "^18.2.0", + "@types/react": "^18.2.0" + } +} \ No newline at end of file diff --git a/package.json b/package.json index c97ba26..eb40e52 100644 --- a/package.json +++ b/package.json @@ -17,13 +17,12 @@ "license": "ISC", "dependencies": { "@dimforge/rapier2d": "^0.11.2", - "@tldraw/sync": "^2.4.6", - "@tldraw/sync-core": "^2.4.6", + "@tldraw/sync": "^3.4.1", + "@tldraw/sync-core": "^3.4.1", "@tldraw/tldraw": "^3.4.1", - "@tldraw/tlschema": "^2.4.6", + "@tldraw/tlschema": "^3.4.1", "@types/markdown-it": "^14.1.1", "@vercel/analytics": "^1.2.2", - "@whereby.com/browser-sdk": "^3.9.2", "cloudflare-workers-unfurl": "^0.0.7", "crdts": "^0.2.0", "gray-matter": "^4.0.3", @@ -35,13 +34,13 @@ "react-dom": "^18.2.0", "react-error-boundary": "^4.1.2", "react-router-dom": "^6.22.3", - "tldraw": "^2.4.6", + "tldraw": "^3.4.1", "use-local-storage-state": "^19.5.0", "vercel": "^39.1.1" }, "devDependencies": { "@biomejs/biome": "1.4.1", - "@cloudflare/types": "^6.29.1", + "@cloudflare/types": "^6.0.0", "@cloudflare/workers-types": "^4.20240821.1", "@types/lodash.throttle": "^4", "@types/react": "^18.2.15", diff --git a/src/App.tsx b/src/App.tsx index 832e943..6427f59 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -16,10 +16,12 @@ import { Inbox } from './components/Inbox'; import { Books } from './components/Books'; import { BindingUtil, + Editor, IndexKey, TLBaseBinding, TLBaseShape, Tldraw, + TLShapeId, } from 'tldraw'; import { components, uiOverrides } from './ui-overrides'; import { ChatBoxShape } from './shapes/ChatBoxShapeUtil'; @@ -58,12 +60,12 @@ export default function InteractiveShapeExample() { return (
{ + handleInitialShapeLoad(editor); editor.createShape({ type: 'my-interactive-shape', x: 100, y: 100 }); }} /> @@ -71,7 +73,41 @@ export default function InteractiveShapeExample() { ); } -// ... existing code ... +// Add this function before or after 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, + //padding: 32 + }); + } + } else { + console.warn('Shape not found in the editor'); + } + } else { + console.warn('No shapeId found in the URL'); + } +} ReactDOM.createRoot(document.getElementById("root")!).render(); diff --git a/src/components/Board.tsx b/src/components/Board.tsx index 600ae39..4408973 100644 --- a/src/components/Board.tsx +++ b/src/components/Board.tsx @@ -5,6 +5,9 @@ import { TLBookmarkAsset, TLRecord, Tldraw, + Editor, + TLFrameShape, + TLUiEventSource, } from 'tldraw' import { useParams } from 'react-router-dom' import useLocalStorageState from 'use-local-storage-state' @@ -20,6 +23,7 @@ import { EmbedTool } from '@/tools/EmbedTool' import React, { useState, useEffect, useCallback } from 'react'; import { ChatBox } from '@/shapes/ChatBoxShapeUtil'; import { components, uiOverrides } from '@/ui-overrides' +import { useCameraControls } from '@/hooks/useCameraControls' //const WORKER_URL = `https://jeffemmett-canvas.jeffemmett.workers.dev` export const WORKER_URL = 'https://jeffemmett-canvas.jeffemmett.workers.dev'; @@ -37,16 +41,44 @@ export function Board() { const { slug } = useParams<{ slug: string }>(); const roomId = slug || 'default-room'; const { store } = usePersistentBoard(roomId); + const [editor, setEditor] = useState(null) + const { zoomToFrame, copyFrameLink, copyLocationLink } = useCameraControls(editor) return (
({ + ...baseTools, + frame: { + ...baseTools.frame, + contextMenu: (shape: TLFrameShape) => [ + { + id: 'copy-frame-link', + label: 'Copy Frame Link', + onSelect: () => copyFrameLink(shape.id), + }, + { + id: 'zoom-to-frame', + label: 'Zoom to Frame', + onSelect: () => zoomToFrame(shape.id), + }, + { + id: 'copy-location-link', + label: 'Copy Location Link', + onSelect: () => copyLocationLink(), + } + ] + }, + }) + }} components={components} tools={tools} onMount={(editor) => { + setEditor(editor) editor.registerExternalAssetHandler('url', unfurlBookmarkUrl) editor.setCurrentTool('hand') }} diff --git a/src/components/Canvas.tsx b/src/components/Canvas.tsx index 34f2a2d..59eac42 100644 --- a/src/components/Canvas.tsx +++ b/src/components/Canvas.tsx @@ -8,7 +8,7 @@ const components: TLUiComponents = { PageMenu: null, NavigationPanel: null, DebugMenu: null, - ContextMenu: null, + //ContextMenu: null, ActionsMenu: null, QuickActions: null, MainMenu: null, diff --git a/src/hooks/useCameraControls.ts b/src/hooks/useCameraControls.ts new file mode 100644 index 0000000..e7970dc --- /dev/null +++ b/src/hooks/useCameraControls.ts @@ -0,0 +1,90 @@ +import { useEffect } from 'react'; +import { Editor, TLFrameShape, TLParentId } from 'tldraw'; +import { useSearchParams } from 'react-router-dom'; + +export function useCameraControls(editor: Editor | null) { + const [searchParams] = useSearchParams(); + + useEffect(() => { + if (!editor) return; + + const frameId = searchParams.get('frameId'); + const x = searchParams.get('x'); + const y = searchParams.get('y'); + const zoom = searchParams.get('zoom'); + + console.log('Loading camera position:', { frameId, x, y, zoom }); + + if (x && y && zoom) { + editor.setCamera({ + x: parseFloat(x), + y: parseFloat(y), + z: parseFloat(zoom) + }); + console.log('Camera position set from URL params'); + return; + } + + if (!frameId) return; + + const frame = editor.getShape(frameId as TLParentId) as TLFrameShape; + if (!frame) { + console.warn('Frame not found:', frameId); + return; + } + + editor.zoomToBounds( + editor.getShapePageBounds(frame)!, + { + inset: 32, + targetZoom: editor.getCamera().z, + } + ); + + const newUrl = new URL(window.location.href); + newUrl.searchParams.set('frameId', frameId); + window.history.replaceState(null, '', newUrl.toString()); + }, [editor, searchParams]); + + const copyLocationLink = () => { + 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()); + console.log('Copying location link:', url.toString()); + navigator.clipboard.writeText(url.toString()); + }; + + const zoomToFrame = (frameId: string) => { + if (!editor) return; + + const frame = editor.getShape(frameId as TLParentId) as TLFrameShape; + if (!frame) { + console.warn('Frame not found:', frameId); + return; + } + + editor.zoomToBounds( + editor.getShapePageBounds(frame)!, + { + inset: 32, + targetZoom: editor.getCamera().z, + } + ); + }; + + const copyFrameLink = (frameId: string) => { + const url = new URL(window.location.href); + url.searchParams.set('frameId', frameId); + console.log('Copying frame link:', url.toString()); + navigator.clipboard.writeText(url.toString()); + }; + + return { + zoomToFrame, + copyFrameLink, + copyLocationLink + }; +} \ No newline at end of file diff --git a/src/hooks/useLocalStorageRoom copy.ts b/src/hooks/useLocalStorageRoom copy.ts new file mode 100644 index 0000000..7816591 --- /dev/null +++ b/src/hooks/useLocalStorageRoom copy.ts @@ -0,0 +1,103 @@ +import useLocalStorageState from 'use-local-storage-state'; +import { TLRecord, createTLStore, SerializedStore, Editor, StoreSchema, TLStoreProps } from '@tldraw/tldraw'; +import { customSchema } from '../../worker/TldrawDurableObject'; +import { useMemo, useCallback, useEffect, useState } from 'react'; +import { useSync } from '@tldraw/sync'; +import { WORKER_URL } from '../components/Board'; +import { TLRecord as TLSchemaRecord } from '@tldraw/tlschema' +import { defaultAssetUrls } from '@tldraw/assets' + +const CACHE_VERSION = '1.0'; + +export function useLocalStorageRoom(roomId: string) { + const [isOnline, setIsOnline] = useState(navigator.onLine); + const storageKey = `tldraw_board_${roomId}_v${CACHE_VERSION}`; + + const [records, setRecords] = useLocalStorageState>(storageKey, { + defaultValue: createTLStore({ + schema: customSchema as unknown as StoreSchema + }).serialize() + }); + + // Create a persistent store + const baseStore = useMemo(() => { + return createTLStore({ + schema: customSchema as unknown as StoreSchema, + initialData: records, + }) + }, [records]); + + // Use sync with the base store + const syncedStore = useSync({ + uri: `${WORKER_URL.replace('https://', 'wss://')}/connect/${roomId}`, + schema: customSchema, + store: baseStore, + assets: defaultAssetUrls + }); + + // Handle online/offline transitions + useEffect(() => { + const handleOnline = () => { + setIsOnline(true); + if (syncedStore?.store) { + const filteredRecords = filterNonCameraRecords(records); + syncedStore.store.mergeRemoteChanges(() => { + Object.values(filteredRecords).forEach(record => { + syncedStore.store.put([record as unknown as TLSchemaRecord]); + }); + }); + } + }; + + const handleOffline = () => setIsOnline(false); + + window.addEventListener('online', handleOnline); + window.addEventListener('offline', handleOffline); + + return () => { + window.removeEventListener('online', handleOnline); + window.removeEventListener('offline', handleOffline); + }; + }, [records, syncedStore?.store]); + + const filterNonCameraRecords = (data: SerializedStore) => { + return Object.fromEntries( + Object.entries(data).filter(([_, record]) => { + return (record as TLRecord).typeName !== 'camera' && + (record as TLRecord).typeName !== 'instance_page_state' && + (record as TLRecord).typeName !== 'instance_presence'; + }) + ) as SerializedStore; + }; + + // Sync with server store when online + useEffect(() => { + if (!isOnline || !syncedStore?.store) return; + + const syncInterval = setInterval(() => { + const serverRecords = syncedStore.store.allRecords(); + if (Object.keys(serverRecords).length > 0) { + setRecords(syncedStore.store.serialize() as typeof records); + } + }, 5000); + + return () => clearInterval(syncInterval); + }, [isOnline, syncedStore?.store, setRecords]); + + const store = useMemo(() => { + if (isOnline && syncedStore?.store) { + return syncedStore.store; + } + return createTLStore({ + schema: customSchema as unknown as StoreSchema, + initialData: records, + }); + }, [isOnline, syncedStore?.store, records]); + + return { + store, + records, + setRecords, + isOnline + }; +} \ No newline at end of file diff --git a/src/shapes/VideoChatShapeUtil.tsx b/src/shapes/VideoChatShapeUtil.tsx index be5f7bc..2536e1e 100644 --- a/src/shapes/VideoChatShapeUtil.tsx +++ b/src/shapes/VideoChatShapeUtil.tsx @@ -1,7 +1,6 @@ import { BaseBoxShapeUtil, TLBaseShape } from "tldraw"; import { useEffect, useState } from "react"; - -const CORS_PROXY = 'https://cors-anywhere.herokuapp.com/'; +import { WORKER_URL } from '../components/Board'; export type IVideoChatShape = TLBaseShape< 'VideoChat', @@ -13,11 +12,13 @@ export type IVideoChatShape = TLBaseShape< } >; -const WHEREBY_API_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2FjY291bnRzLmFwcGVhci5pbiIsImF1ZCI6Imh0dHBzOi8vYXBpLmFwcGVhci5pbi92MSIsImV4cCI6OTAwNzE5OTI1NDc0MDk5MSwiaWF0IjoxNzI5MTkzOTE3LCJvcmdhbml6YXRpb25JZCI6MjY2MDk5LCJqdGkiOiI0MzI0MmUxMC1kZmRjLTRhYmEtYjlhOS01ZjcwNTFlMTYwZjAifQ.RaxXpZKYl_dOWyoATQZrzyMR2XRh3fHf02mALQiuTTs'; // Replace with your actual API key - export class VideoChatShape extends BaseBoxShapeUtil { static override type = 'VideoChat'; + indicator(_shape: IVideoChatShape) { + return null; + } + getDefaultProps(): IVideoChatShape['props'] { return { roomUrl: null, @@ -27,55 +28,34 @@ export class VideoChatShape extends BaseBoxShapeUtil { }; } - indicator(shape: IVideoChatShape) { - return ; - } - async ensureRoomExists(shape: IVideoChatShape) { - if (shape.props.roomUrl !== null) { return; } - const expiryDate = new Date(Date.now() + 1000 * 24 * 60 * 60 * 1000); - - const response = await fetch(`${CORS_PROXY}https://api.whereby.dev/v1/meetings`, { + const response = await fetch(`${WORKER_URL}/daily/rooms`, { method: 'POST', headers: { - 'Authorization': `Bearer ${WHEREBY_API_KEY}`, - 'Content-Type': 'application/json', - 'X-Requested-With': 'XMLHttpRequest', // Required by some CORS proxies + 'Content-Type': 'application/json' }, body: JSON.stringify({ - isLocked: false, - roomMode: 'normal', - endDate: expiryDate.toISOString(), - fields: ['hostRoomUrl'], - }), - }).catch((error) => { - console.error('Failed to create meeting:', error); - throw error; + properties: { + enable_recording: true, + max_participants: 8 + } + }) }); - if (!response.ok) { - const errorData = await response.json(); - console.error('Whereby API error:', errorData); - throw new Error(`Whereby API error: ${(errorData as any).message || 'Unknown error'}`); - } - const data = await response.json(); - const roomUrl = (data as any).roomUrl; - - console.log('This is your roomUrl 3:', roomUrl); this.editor.updateShape({ id: shape.id, type: 'VideoChat', props: { ...shape.props, - roomUrl + roomUrl: (data as any).url } - }) + }); } component(shape: IVideoChatShape) { @@ -84,34 +64,26 @@ export class VideoChatShape extends BaseBoxShapeUtil { const [isLoading, setIsLoading] = useState(false); useEffect(() => { - // Load the Whereby SDK only in the browser - if (typeof window !== 'undefined') { - import("@whereby.com/browser-sdk/embed").then(() => { - joinRoom(); - }).catch(err => { - console.error("Error loading Whereby SDK:", err); - setError("Failed to load video chat component."); - }); - } - }, []); + if (isInRoom && shape.props.roomUrl) { + const script = document.createElement('script'); + script.src = 'https://www.daily.co/static/call-machine.js'; + document.body.appendChild(script); - const joinRoom = async () => { - setError(""); - setIsLoading(true); - try { - await this.ensureRoomExists(shape); - setIsInRoom(true); - } catch (e) { - console.error("Error joining room:", e); - setError("An error occurred. Please try again."); + 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 }); + }; } - setIsLoading(false); - }; - - const leaveRoom = () => { - setIsInRoom(false); - // setRoomUrl(""); // Clear the room URL - }; + }, [isInRoom, shape.props.roomUrl]); return (
{ top: '10px', left: '10px', zIndex: 9999, - padding: '15px', // Increased padding by 5px - margin: 0, - backgroundColor: '#F0F0F0', // Light gray background - boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)', // Added drop shadow - borderRadius: '4px', // Slight border radius for softer look + padding: '15px', + backgroundColor: '#F0F0F0', + boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)', + borderRadius: '4px', }}> -
- {isLoading ? ( -

Joining room...

- ) : isInRoom && shape.props.roomUrl && typeof window !== 'undefined' ? ( -
- -
- ) : ( -
- - {error &&

{error}

} -
- )} -
+ {!isInRoom ? ( + + ) : ( +
+ )} + {error &&

{error}

}
); } diff --git a/src/ui-overrides.tsx b/src/ui-overrides.tsx index f00e549..d29607c 100644 --- a/src/ui-overrides.tsx +++ b/src/ui-overrides.tsx @@ -4,11 +4,277 @@ import { TLComponents, TLUiOverrides, TldrawUiMenuItem, - useIsToolSelected, + useEditor, useTools, + TLShapeId, + DefaultContextMenu, + DefaultContextMenuContent, + TLUiContextMenuProps, + TldrawUiMenuGroup, + TLShape, } from 'tldraw' import { CustomMainMenu } from './components/CustomMainMenu' -import { EmbedShape } from './shapes/EmbedShapeUtil' +import { Editor } from 'tldraw' + +let cameraHistory: { x: number; y: number; z: number }[] = []; +const MAX_HISTORY = 10; // Keep last 10 camera positions + +// Helper function to store camera position +const storeCameraPosition = (editor: Editor) => { + const currentCamera = editor.getCamera(); + // Only store if there's a meaningful change from the last position + const lastPosition = cameraHistory[cameraHistory.length - 1]; + if (!lastPosition || + Math.abs(lastPosition.x - currentCamera.x) > 1 || + Math.abs(lastPosition.y - currentCamera.y) > 1 || + Math.abs(lastPosition.z - currentCamera.z) > 0.1) { + + cameraHistory.push({ ...currentCamera }); + if (cameraHistory.length > MAX_HISTORY) { + cameraHistory.shift(); + } + console.log('Stored camera position:', currentCamera); + } +}; + +const copyFrameLink = async (editor: Editor, frameId: string) => { + console.log('Starting copyFrameLink with frameId:', frameId); + + if (!editor.store.getSnapshot()) { + console.warn('Store not ready'); + return; + } + + try { + const baseUrl = `${window.location.origin}${window.location.pathname}`; + console.log('Base URL:', baseUrl); + + const url = new URL(baseUrl); + url.searchParams.set('frameId', frameId); + + const frame = editor.getShape(frameId as TLShapeId); + console.log('Found frame:', frame); + + if (frame) { + const camera = editor.getCamera(); + console.log('Camera position:', { x: camera.x, y: camera.y, zoom: camera.z }); + + url.searchParams.set('x', camera.x.toString()); + url.searchParams.set('y', camera.y.toString()); + url.searchParams.set('zoom', camera.z.toString()); + } + + const finalUrl = url.toString(); + console.log('Final URL to copy:', finalUrl); + + if (navigator.clipboard && window.isSecureContext) { + console.log('Using modern clipboard API...'); + await navigator.clipboard.writeText(finalUrl); + console.log('URL copied successfully using clipboard API'); + } else { + console.log('Falling back to legacy clipboard method...'); + const textArea = document.createElement('textarea'); + textArea.value = finalUrl; + document.body.appendChild(textArea); + textArea.select(); + document.execCommand('copy'); + document.body.removeChild(textArea); + console.log('URL copied successfully using fallback method'); + } + } catch (error) { + console.error('Failed to copy to clipboard:', error); + alert('Failed to copy link. Please check clipboard permissions.'); + } +}; + +const zoomToShape = (editor: Editor) => { + // Store camera position before zooming + storeCameraPosition(editor); + + // Get all selected shape IDs + const selectedIds = editor.getSelectedShapeIds(); + if (selectedIds.length === 0) return; + + // Get the common bounds that encompass all selected shapes + const commonBounds = editor.getSelectionPageBounds(); + if (!commonBounds) return; + + // Calculate viewport dimensions + const viewportPageBounds = editor.getViewportPageBounds(); + + // Calculate the ratio of selection size to viewport size + const widthRatio = commonBounds.width / viewportPageBounds.width; + const heightRatio = commonBounds.height / viewportPageBounds.height; + + // 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 + targetZoom = Math.min( + (viewportPageBounds.width * 0.8) / commonBounds.width, + (viewportPageBounds.height * 0.8) / commonBounds.height, + 8 // Max zoom of 8x for small selections + ); + } else if (widthRatio > 1 || heightRatio > 1) { + // For selections larger than viewport, zoom out more + targetZoom = Math.min( + (viewportPageBounds.width * 0.7) / commonBounds.width, + (viewportPageBounds.height * 0.7) / commonBounds.height, + 0.125 // Min zoom of 1/8x for large selections (reciprocal of 8) + ); + } else { + // For medium-sized selections, allow up to 4x zoom + targetZoom = Math.min( + (viewportPageBounds.width * 0.8) / commonBounds.width, + (viewportPageBounds.height * 0.8) / commonBounds.height, + 4 // Medium zoom level + ); + } + + // Zoom to the common bounds + editor.zoomToBounds(commonBounds, { + targetZoom, + inset: widthRatio > 1 || heightRatio > 1 ? 20 : 50, // Less padding for large selections + animation: { + duration: 400, + easing: (t) => t * (2 - t) + } + }); + + // Update URL with new camera position and first selected shape ID + const newCamera = editor.getCamera(); + const url = new URL(window.location.href); + url.searchParams.set('shapeId', selectedIds[0].toString()); + url.searchParams.set('x', newCamera.x.toString()); + url.searchParams.set('y', newCamera.y.toString()); + url.searchParams.set('zoom', newCamera.z.toString()); + window.history.replaceState(null, '', url.toString()); +}; + +const copyLinkToCurrentView = async (editor: Editor) => { + console.log('Starting copyLinkToCurrentView'); + + if (!editor.store.getSnapshot()) { + console.warn('Store not ready'); + return; + } + + try { + const baseUrl = `${window.location.origin}${window.location.pathname}`; + console.log('Base URL:', baseUrl); + + const url = new URL(baseUrl); + const camera = editor.getCamera(); + console.log('Current camera position:', { x: camera.x, y: camera.y, zoom: camera.z }); + + // Set camera parameters + url.searchParams.set('x', camera.x.toString()); + url.searchParams.set('y', camera.y.toString()); + url.searchParams.set('zoom', camera.z.toString()); + + const finalUrl = url.toString(); + console.log('Final URL to copy:', finalUrl); + + if (navigator.clipboard && window.isSecureContext) { + console.log('Using modern clipboard API...'); + await navigator.clipboard.writeText(finalUrl); + console.log('URL copied successfully using clipboard API'); + } else { + console.log('Falling back to legacy clipboard method...'); + const textArea = document.createElement('textarea'); + textArea.value = finalUrl; + document.body.appendChild(textArea); + textArea.select(); + document.execCommand('copy'); + document.body.removeChild(textArea); + console.log('URL copied successfully using fallback method'); + } + } catch (error) { + console.error('Failed to copy to clipboard:', error); + alert('Failed to copy link. Please check clipboard permissions.'); + } +}; + +const revertCamera = (editor: Editor) => { + if (cameraHistory.length > 0) { + const previousCamera = cameraHistory.pop(); + if (previousCamera) { + // Get current viewport bounds + const viewportPageBounds = editor.getViewportPageBounds(); + + // Create bounds that center on the previous camera position + const targetBounds = { + x: previousCamera.x - (viewportPageBounds.width / 2) / previousCamera.z, + y: previousCamera.y - (viewportPageBounds.height / 2) / previousCamera.z, + w: viewportPageBounds.width / previousCamera.z, + h: viewportPageBounds.height / previousCamera.z, + }; + + // Use the same zoom animation as zoomToShape + editor.zoomToBounds(targetBounds, { + targetZoom: previousCamera.z, + animation: { + duration: 400, + easing: (t) => t * (2 - t) + } + }); + + console.log('Reverted to camera position:', previousCamera); + } + } else { + console.log('No camera history available'); + } +}; + +function CustomContextMenu(props: TLUiContextMenuProps) { + const editor = useEditor() + const hasSelection = editor.getSelectedShapeIds().length > 0 + const selectedShape = editor.getSelectedShapes()[0] + const hasCameraHistory = cameraHistory.length > 0 + + return ( + + + { + console.log('Reverting camera'); + revertCamera(editor); + }} + /> + { + console.log('Zoom to Selection clicked'); + zoomToShape(editor); + }} + /> + { + console.log('Copy Link to Current View clicked'); + copyLinkToCurrentView(editor); + }} + /> + + + + ) +} export const uiOverrides: TLUiOverrides = { tools(editor, tools) { @@ -17,6 +283,7 @@ export const uiOverrides: TLUiOverrides = { icon: 'color', label: 'Video', kbd: 'x', + meta: {}, onSelect: () => { editor.setCurrentTool('VideoChat') }, @@ -26,6 +293,7 @@ export const uiOverrides: TLUiOverrides = { icon: 'color', label: 'Chat', kbd: 'x', + meta: {}, onSelect: () => { editor.setCurrentTool('ChatBox') }, @@ -35,28 +303,140 @@ export const uiOverrides: TLUiOverrides = { icon: 'embed', label: 'Embed', kbd: 'e', + meta: {}, onSelect: () => { editor.setCurrentTool('Embed') }, } return tools }, + actions(editor, actions) { + actions['copyFrameLink'] = { + id: 'copy-frame-link', + label: 'Copy Frame Link', + onSelect: () => { + const shape = editor.getSelectedShapes()[0] + if (shape && shape.type === 'frame') { + copyFrameLink(editor, shape.id) + } + }, + readonlyOk: true, + } + + actions['zoomToFrame'] = { + id: 'zoom-to-frame', + label: 'Zoom to Frame', + onSelect: () => { + const shape = editor.getSelectedShapes()[0] + if (shape && shape.type === 'frame') { + zoomToShape(editor) + } + }, + readonlyOk: true, + } + + actions['copyLinkToCurrentView'] = { + id: 'copy-link-to-current-view', + label: 'Copy Link to Current View', + kbd: 'c', + onSelect: () => { + console.log('Creating link to current view'); + copyLinkToCurrentView(editor); + }, + readonlyOk: true, + } + + actions['zoomToShape'] = { + id: 'zoom-to-shape', + label: 'Zoom to Selection', + kbd: 'z', + onSelect: () => { + if (editor.getSelectedShapeIds().length > 0) { + console.log('Zooming to selection'); + zoomToShape(editor); + } + }, + readonlyOk: true, + } + + actions['revertCamera'] = { + id: 'revert-camera', + label: 'Revert Camera', + kbd: 'b', + onSelect: () => { + console.log('Reverting camera position'); + revertCamera(editor); + }, + readonlyOk: true, + } + + return actions + }, } export const components: TLComponents = { - Toolbar: (props) => { + Toolbar: function Toolbar() { + const editor = useEditor() const tools = useTools() - const isChatBoxSelected = useIsToolSelected(tools['ChatBox']) - const isVideoSelected = useIsToolSelected(tools['VideoChat']) - const isEmbedSelected = useIsToolSelected(tools['Embed']) return ( - - - - + + {tools['VideoChat'] && ( + + )} + {tools['ChatBox'] && ( + + )} + {tools['Embed'] && ( + + )} ) }, MainMenu: CustomMainMenu, -} \ No newline at end of file + ContextMenu: CustomContextMenu, +} + +const handleInitialShapeLoad = (editor: Editor) => { + const url = new URL(window.location.href); + + // Check for both shapeId and legacy frameId (for backwards compatibility) + 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, + //padding: 32 + }); + } + } else { + console.warn('Shape not found:', shapeId); + } + } +}; \ No newline at end of file diff --git a/worker/types.ts b/worker/types.ts index 4529d5f..3a756ae 100644 --- a/worker/types.ts +++ b/worker/types.ts @@ -5,4 +5,6 @@ export interface Environment { TLDRAW_BUCKET: R2Bucket TLDRAW_DURABLE_OBJECT: DurableObjectNamespace + DAILY_API_KEY: string; + DAILY_DOMAIN: string; } \ No newline at end of file diff --git a/worker/worker.ts b/worker/worker.ts index f938bc9..50c4da9 100644 --- a/worker/worker.ts +++ b/worker/worker.ts @@ -109,5 +109,24 @@ const router = AutoRouter({ }) }) + .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() + }); + + 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' } + }); + }) + // export our router for cloudflare export default router diff --git a/wrangler.toml b/wrangler.toml index 7e3c1ab..5a1cccb 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -1,11 +1,13 @@ main = "worker/worker.ts" compatibility_date = "2024-07-01" name = "jeffemmett-canvas" -account_id = "0e7b3338d5278ed1b148e6456b940913" -zone_id = "45c200f8dc2a01852e41b9bb09eb7359" +account_id = "${CLOUDFLARE_ACCOUNT_ID}" +zone_id = "${CLOUDFLARE_ZONE_ID}" [vars] -TLDRAW_WORKER_URL = "https://jeffemmett-canvas.jeffemmett.workers.dev" +DAILY_API_KEY = "${DAILY_API_KEY}" +DAILY_DOMAIN = "${DAILY_DOMAIN}" +TLDRAW_WORKER_URL = "${TLDRAW_WORKER_URL}" [dev] port = 5172 @@ -13,24 +15,20 @@ ip = "0.0.0.0" local_protocol = "http" upstream_protocol = "https" -# Set up the durable object used for each tldraw room [durable_objects] bindings = [ { name = "TLDRAW_DURABLE_OBJECT", class_name = "TldrawDurableObject" }, ] -# Durable objects require migrations to create/modify/delete them [[migrations]] tag = "v1" new_classes = ["TldrawDurableObject"] -# We store rooms and asset uploads in an R2 bucket [[r2_buckets]] binding = 'TLDRAW_BUCKET' -bucket_name = 'jeffemmett-canvas' -preview_bucket_name = 'jeffemmett-canvas-preview' +bucket_name = '${R2_BUCKET_NAME}' +preview_bucket_name = '${R2_PREVIEW_BUCKET_NAME}' -# wrangler.toml (wrangler v3.79.0^) [observability] enabled = true -head_sampling_rate = 1 \ No newline at end of file +head_sampling_rate = 1 \ No newline at end of file From 8a2714662e6ee4dfaf51919ba98041dc4ec0dc6a Mon Sep 17 00:00:00 2001 From: Jeff Emmett <46964190+Jeff-Emmett@users.noreply.github.com> Date: Wed, 27 Nov 2024 11:07:15 +0700 Subject: [PATCH 04/12] fixed wrangler.toml --- wrangler.toml | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/wrangler.toml b/wrangler.toml index 5a1cccb..c6397bc 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -1,13 +1,12 @@ main = "worker/worker.ts" compatibility_date = "2024-07-01" name = "jeffemmett-canvas" -account_id = "${CLOUDFLARE_ACCOUNT_ID}" -zone_id = "${CLOUDFLARE_ZONE_ID}" +account_id = "0e7b3338d5278ed1b148e6456b940913" +zone_id = "45c200f8dc2a01852e41b9bb09eb7359" [vars] -DAILY_API_KEY = "${DAILY_API_KEY}" -DAILY_DOMAIN = "${DAILY_DOMAIN}" -TLDRAW_WORKER_URL = "${TLDRAW_WORKER_URL}" +# Environment variables are managed in Cloudflare Dashboard +# Workers & Pages → jeffemmett-canvas → Settings → Variables [dev] port = 5172 @@ -26,8 +25,8 @@ new_classes = ["TldrawDurableObject"] [[r2_buckets]] binding = 'TLDRAW_BUCKET' -bucket_name = '${R2_BUCKET_NAME}' -preview_bucket_name = '${R2_PREVIEW_BUCKET_NAME}' +bucket_name = 'jeffemmett-canvas' +preview_bucket_name = 'jeffemmett-canvas-preview' [observability] enabled = true From d89624b801a8cdf8f2e7784b5f4d11768b939757 Mon Sep 17 00:00:00 2001 From: Jeff Emmett <46964190+Jeff-Emmett@users.noreply.github.com> Date: Wed, 27 Nov 2024 11:14:52 +0700 Subject: [PATCH 05/12] fix vite and asset upload --- src/client/multiplayerAssetStore.tsx | 10 +--------- src/components/Board.tsx | 2 +- vite.config.ts | 4 +++- 3 files changed, 5 insertions(+), 11 deletions(-) diff --git a/src/client/multiplayerAssetStore.tsx b/src/client/multiplayerAssetStore.tsx index 070ec7c..d2c8b4c 100644 --- a/src/client/multiplayerAssetStore.tsx +++ b/src/client/multiplayerAssetStore.tsx @@ -1,17 +1,12 @@ import { TLAssetStore, uniqueId } from 'tldraw' +import { WORKER_URL } from '../components/Board' -const WORKER_URL = process.env.TLDRAW_WORKER_URL - -// How does our server handle assets like images and videos? export const multiplayerAssetStore: TLAssetStore = { - // to upload an asset, we... async upload(_asset, file) { - // ...create a unique name & URL... const id = uniqueId() const objectName = `${id}-${file.name}`.replace(/[^a-zA-Z0-9.]/g, '-') const url = `${WORKER_URL}/uploads/${objectName}` - // ...POST it to out worker to upload it... const response = await fetch(url, { method: 'POST', body: file, @@ -21,12 +16,9 @@ export const multiplayerAssetStore: TLAssetStore = { throw new Error(`Failed to upload asset: ${response.statusText}`) } - // ...and return the URL to be stored with the asset record. return url }, - // to retrieve an asset, we can just use the same URL. you could customize this to add extra - // auth, or to serve optimized versions / sizes of the asset. resolve(asset) { return asset.props.src }, diff --git a/src/components/Board.tsx b/src/components/Board.tsx index 4408973..bd1334e 100644 --- a/src/components/Board.tsx +++ b/src/components/Board.tsx @@ -26,7 +26,7 @@ import { components, uiOverrides } from '@/ui-overrides' import { useCameraControls } from '@/hooks/useCameraControls' //const WORKER_URL = `https://jeffemmett-canvas.jeffemmett.workers.dev` -export const WORKER_URL = 'https://jeffemmett-canvas.jeffemmett.workers.dev'; +export const WORKER_URL = import.meta.env.VITE_TLDRAW_WORKER_URL || 'https://jeffemmett-canvas.jeffemmett.workers.dev'; const shapeUtils = [ChatBoxShape, VideoChatShape, EmbedShape] const tools = [ChatBoxTool, VideoChatTool, EmbedTool]; // Array of tools diff --git a/vite.config.ts b/vite.config.ts index ba7d3f4..516221a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -7,7 +7,9 @@ import { viteStaticCopy } from 'vite-plugin-static-copy'; export default defineConfig({ define: { - 'process.env.TLDRAW_WORKER_URL': JSON.stringify(process.env.TLDRAW_WORKER_URL || 'https://jeffemmett-canvas.jeffemmett.workers.dev') + 'process.env.TLDRAW_WORKER_URL': JSON.stringify( + process.env.VITE_TLDRAW_WORKER_URL || 'https://jeffemmett-canvas.jeffemmett.workers.dev' + ) }, plugins: [ react(), From 7fbf64af7e38f8a6bec62ccb8b59ae2784ba0c46 Mon Sep 17 00:00:00 2001 From: Jeff Emmett <46964190+Jeff-Emmett@users.noreply.github.com> Date: Wed, 27 Nov 2024 11:17:29 +0700 Subject: [PATCH 06/12] fix env vars in vite --- vite.config.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/vite.config.ts b/vite.config.ts index 516221a..08c847e 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -7,10 +7,10 @@ import { viteStaticCopy } from 'vite-plugin-static-copy'; export default defineConfig({ define: { - 'process.env.TLDRAW_WORKER_URL': JSON.stringify( - process.env.VITE_TLDRAW_WORKER_URL || 'https://jeffemmett-canvas.jeffemmett.workers.dev' - ) + // Remove this since we're using VITE_ prefix + // 'process.env.TLDRAW_WORKER_URL': JSON.stringify(process.env.TLDRAW_WORKER_URL) }, + envPrefix: ['VITE_'], plugins: [ react(), wasm(), From 5be8991028c9d11c3c71a4d8f66d08dfbdd25bbc Mon Sep 17 00:00:00 2001 From: Jeff Emmett <46964190+Jeff-Emmett@users.noreply.github.com> Date: Wed, 27 Nov 2024 11:21:33 +0700 Subject: [PATCH 07/12] fix durableobject --- worker/worker.ts | 2 +- wrangler.toml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/worker/worker.ts b/worker/worker.ts index 50c4da9..546fec6 100644 --- a/worker/worker.ts +++ b/worker/worker.ts @@ -4,7 +4,7 @@ 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 as jeffemmett_canvas_TldrawDurableObject } from './TldrawDurableObject' // Define security headers const securityHeaders = { diff --git a/wrangler.toml b/wrangler.toml index c6397bc..ff7c09d 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -16,12 +16,12 @@ upstream_protocol = "https" [durable_objects] bindings = [ - { name = "TLDRAW_DURABLE_OBJECT", class_name = "TldrawDurableObject" }, + { name = "TLDRAW_DURABLE_OBJECT", class_name = "jeffemmett-canvas_TldrawDurableObject" }, ] [[migrations]] tag = "v1" -new_classes = ["TldrawDurableObject"] +new_classes = ["jeffemmett-canvas_TldrawDurableObject"] [[r2_buckets]] binding = 'TLDRAW_BUCKET' From 9d184047c9f7168b960a17ae2acafab80d84ce98 Mon Sep 17 00:00:00 2001 From: Jeff Emmett <46964190+Jeff-Emmett@users.noreply.github.com> Date: Wed, 27 Nov 2024 11:23:46 +0700 Subject: [PATCH 08/12] fix underscore --- wrangler.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/wrangler.toml b/wrangler.toml index ff7c09d..29e2e1b 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -16,12 +16,12 @@ upstream_protocol = "https" [durable_objects] bindings = [ - { name = "TLDRAW_DURABLE_OBJECT", class_name = "jeffemmett-canvas_TldrawDurableObject" }, + { name = "TLDRAW_DURABLE_OBJECT", class_name = "jeffemmett_canvas_TldrawDurableObject" }, ] [[migrations]] tag = "v1" -new_classes = ["jeffemmett-canvas_TldrawDurableObject"] +new_classes = ["jeffemmett_canvas_TldrawDurableObject"] [[r2_buckets]] binding = 'TLDRAW_BUCKET' From 06cc47a23b72c797c2b2b4652de7eb4f9eaa0f94 Mon Sep 17 00:00:00 2001 From: Jeff Emmett <46964190+Jeff-Emmett@users.noreply.github.com> Date: Wed, 27 Nov 2024 11:26:25 +0700 Subject: [PATCH 09/12] fixing final --- src/components/Board.tsx | 9 ++++++++- vite.config.ts | 3 +-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/components/Board.tsx b/src/components/Board.tsx index bd1334e..463d3c3 100644 --- a/src/components/Board.tsx +++ b/src/components/Board.tsx @@ -26,7 +26,14 @@ import { components, uiOverrides } from '@/ui-overrides' import { useCameraControls } from '@/hooks/useCameraControls' //const WORKER_URL = `https://jeffemmett-canvas.jeffemmett.workers.dev` -export const WORKER_URL = import.meta.env.VITE_TLDRAW_WORKER_URL || 'https://jeffemmett-canvas.jeffemmett.workers.dev'; +export const WORKER_URL = (() => { + // During development + if (import.meta.env.DEV) { + return 'http://127.0.0.1:5172'; + } + // In production + return import.meta.env.VITE_TLDRAW_WORKER_URL || 'https://jeffemmett-canvas.jeffemmett.workers.dev'; +})(); const shapeUtils = [ChatBoxShape, VideoChatShape, EmbedShape] const tools = [ChatBoxTool, VideoChatTool, EmbedTool]; // Array of tools diff --git a/vite.config.ts b/vite.config.ts index 08c847e..9ded5e1 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -7,8 +7,7 @@ import { viteStaticCopy } from 'vite-plugin-static-copy'; export default defineConfig({ define: { - // Remove this since we're using VITE_ prefix - // 'process.env.TLDRAW_WORKER_URL': JSON.stringify(process.env.TLDRAW_WORKER_URL) + 'import.meta.env.VITE_TLDRAW_WORKER_URL': JSON.stringify(process.env.VITE_TLDRAW_WORKER_URL || 'https://jeffemmett-canvas.jeffemmett.workers.dev') }, envPrefix: ['VITE_'], plugins: [ From a8f8bb549a3c92d47b1b38a869003a09d5ff8337 Mon Sep 17 00:00:00 2001 From: Jeff Emmett <46964190+Jeff-Emmett@users.noreply.github.com> Date: Wed, 27 Nov 2024 11:27:59 +0700 Subject: [PATCH 10/12] fix board --- src/components/Board.tsx | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/components/Board.tsx b/src/components/Board.tsx index 463d3c3..27deec8 100644 --- a/src/components/Board.tsx +++ b/src/components/Board.tsx @@ -26,14 +26,10 @@ import { components, uiOverrides } from '@/ui-overrides' import { useCameraControls } from '@/hooks/useCameraControls' //const WORKER_URL = `https://jeffemmett-canvas.jeffemmett.workers.dev` -export const WORKER_URL = (() => { - // During development - if (import.meta.env.DEV) { - return 'http://127.0.0.1:5172'; - } - // In production - return import.meta.env.VITE_TLDRAW_WORKER_URL || 'https://jeffemmett-canvas.jeffemmett.workers.dev'; -})(); +const DEFAULT_WORKER_URL = 'https://jeffemmett-canvas.jeffemmett.workers.dev'; +export const WORKER_URL = typeof import.meta.env.VITE_TLDRAW_WORKER_URL === 'string' + ? import.meta.env.VITE_TLDRAW_WORKER_URL + : DEFAULT_WORKER_URL; const shapeUtils = [ChatBoxShape, VideoChatShape, EmbedShape] const tools = [ChatBoxTool, VideoChatTool, EmbedTool]; // Array of tools From 6f89446ad82250eeb4b29b1d1c3f58cd1b8ae8ee Mon Sep 17 00:00:00 2001 From: Jeff Emmett <46964190+Jeff-Emmett@users.noreply.github.com> Date: Wed, 27 Nov 2024 11:31:16 +0700 Subject: [PATCH 11/12] fix worker url --- src/components/Board.tsx | 5 +---- vite.config.ts | 3 --- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/src/components/Board.tsx b/src/components/Board.tsx index 27deec8..4408973 100644 --- a/src/components/Board.tsx +++ b/src/components/Board.tsx @@ -26,10 +26,7 @@ import { components, uiOverrides } from '@/ui-overrides' import { useCameraControls } from '@/hooks/useCameraControls' //const WORKER_URL = `https://jeffemmett-canvas.jeffemmett.workers.dev` -const DEFAULT_WORKER_URL = 'https://jeffemmett-canvas.jeffemmett.workers.dev'; -export const WORKER_URL = typeof import.meta.env.VITE_TLDRAW_WORKER_URL === 'string' - ? import.meta.env.VITE_TLDRAW_WORKER_URL - : DEFAULT_WORKER_URL; +export const WORKER_URL = 'https://jeffemmett-canvas.jeffemmett.workers.dev'; const shapeUtils = [ChatBoxShape, VideoChatShape, EmbedShape] const tools = [ChatBoxTool, VideoChatTool, EmbedTool]; // Array of tools diff --git a/vite.config.ts b/vite.config.ts index 9ded5e1..3927bd7 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -6,9 +6,6 @@ import topLevelAwait from "vite-plugin-top-level-await"; import { viteStaticCopy } from 'vite-plugin-static-copy'; export default defineConfig({ - define: { - 'import.meta.env.VITE_TLDRAW_WORKER_URL': JSON.stringify(process.env.VITE_TLDRAW_WORKER_URL || 'https://jeffemmett-canvas.jeffemmett.workers.dev') - }, envPrefix: ['VITE_'], plugins: [ react(), From 3d74f7c2e5045f25a34da70edb85d30361d2a5e7 Mon Sep 17 00:00:00 2001 From: Jeff Emmett <46964190+Jeff-Emmett@users.noreply.github.com> Date: Wed, 27 Nov 2024 11:34:02 +0700 Subject: [PATCH 12/12] fix durable object reference --- worker/worker.ts | 2 +- wrangler.toml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/worker/worker.ts b/worker/worker.ts index 546fec6..50c4da9 100644 --- a/worker/worker.ts +++ b/worker/worker.ts @@ -4,7 +4,7 @@ import { handleAssetDownload, handleAssetUpload } from './assetUploads' import { Environment } from './types' // make sure our sync durable object is made available to cloudflare -export { TldrawDurableObject as jeffemmett_canvas_TldrawDurableObject } from './TldrawDurableObject' +export { TldrawDurableObject } from './TldrawDurableObject' // Define security headers const securityHeaders = { diff --git a/wrangler.toml b/wrangler.toml index 29e2e1b..c6397bc 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -16,12 +16,12 @@ upstream_protocol = "https" [durable_objects] bindings = [ - { name = "TLDRAW_DURABLE_OBJECT", class_name = "jeffemmett_canvas_TldrawDurableObject" }, + { name = "TLDRAW_DURABLE_OBJECT", class_name = "TldrawDurableObject" }, ] [[migrations]] tag = "v1" -new_classes = ["jeffemmett_canvas_TldrawDurableObject"] +new_classes = ["TldrawDurableObject"] [[r2_buckets]] binding = 'TLDRAW_BUCKET'