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]