Merge branch 'add-camera-controls-for-link-to-frame-and-screen-position'

This commit is contained in:
Jeff Emmett 2024-11-27 11:56:36 +07:00
commit d582be49b2
24 changed files with 1237 additions and 228 deletions

19
.env.example Normal file
View File

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

12
.gitignore vendored
View File

@ -180,4 +180,14 @@ dist
# Environment variables # Environment variables
.env* .env*
!.env.example !.env.example
.vercel
# Environment files
.env
.env.local
.env.*.local
.dev.vars
# Keep example file
!.env.example

View File

@ -1,42 +1,45 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head>
<title>Jeff Emmett</title>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/x-icon" href="/favicon.ico?v=4" />
<link rel="shortcut icon" type="image/x-icon" href="/favicon.ico?v=4" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Recursive:slnt,wght,CASL,CRSV,MONO@-15..0,300..1000,0..1,0..1,0..1&display=swap"
rel="stylesheet">
<!-- Social Meta Tags --> <head>
<meta name="description" <title>Jeff Emmett</title>
content="My research investigates the intersection of computing, human-system interfaces, and emancipatory politics. I am interested in the potential of computing as a medium for thought, as a tool for collective action, and as a means of emancipation."> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<link rel="icon" type="image/x-icon" href="/favicon.ico?v=4" />
<link rel="shortcut icon" type="image/x-icon" href="/favicon.ico?v=4" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Recursive:slnt,wght,CASL,CRSV,MONO@-15..0,300..1000,0..1,0..1,0..1&display=swap"
rel="stylesheet">
<meta property="og:url" content="https://jeffemmett.com"> <!-- Social Meta Tags -->
<meta property="og:type" content="website"> <meta name="description"
<meta property="og:title" content="Jeff Emmett"> content="My research investigates the intersection of computing, human-system interfaces, and emancipatory politics. I am interested in the potential of computing as a medium for thought, as a tool for collective action, and as a means of emancipation.">
<meta property="og:description"
content="My research doesn't investigate the intersection of computing, human-system interfaces, and emancipatory politics. I am interested in the potential of computing as a medium for thought, as a tool for collective action, and as a means of emancipation.">
<meta property="og:image" content="/website-embed.png">
<meta name="twitter:card" content="summary_large_image"> <meta property="og:url" content="https://jeffemmett.com">
<meta property="twitter:domain" content="jeffemmett.com"> <meta property="og:type" content="website">
<meta property="twitter:url" content="https://jeffemmett.com"> <meta property="og:title" content="Jeff Emmett">
<meta name="twitter:title" content="Jeff Emmett"> <meta property="og:description"
<meta name="twitter:description" content="My research doesn't investigate the intersection of computing, human-system interfaces, and emancipatory politics. I am interested in the potential of computing as a medium for thought, as a tool for collective action, and as a means of emancipation.">
content="My research doesn't investigate the intersection of computing, human-system interfaces, and emancipatory politics. I am interested in the potential of computing as a medium for thought, as a tool for collective action, and as a means of emancipation."> <meta property="og:image" content="/website-embed.png">
<meta name="twitter:image" content="/website-embed.png">
<meta name="twitter:card" content="summary_large_image">
<meta property="twitter:domain" content="jeffemmett.com">
<meta property="twitter:url" content="https://jeffemmett.com">
<meta name="twitter:title" content="Jeff Emmett">
<meta name="twitter:description"
content="My research doesn't investigate the intersection of computing, human-system interfaces, and emancipatory politics. I am interested in the potential of computing as a medium for thought, as a tool for collective action, and as a means of emancipation.">
<meta name="twitter:image" content="/website-embed.png">
<!-- Analytics -->
<script data-goatcounter="https://jeff.goatcounter.com/count" async src="//gc.zgo.at/count.js"></script>
<meta name="mobile-web-app-capable" content="yes">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/App.tsx"></script>
</body>
<!-- Analytics -->
<script data-goatcounter="https://jeff.goatcounter.com/count"
async src="//gc.zgo.at/count.js"></script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/App.tsx"></script>
</body>
</html> </html>

69
package copy.json Normal file
View File

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

View File

@ -4,25 +4,27 @@
"description": "Jeff Emmett's personal website", "description": "Jeff Emmett's personal website",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "concurrently --kill-others --names client,worker --prefix-colors blue,red yarn:dev:client yarn:dev:worker", "dev": "concurrently --kill-others --names client,worker --prefix-colors blue,red \"yarn dev:client\" \"yarn dev:worker\"",
"dev:client": "vite --host", "dev:client": "vite --host --port 5173",
"dev:worker": "wrangler dev", "dev:worker": "wrangler dev --local --port 5172 --ip 0.0.0.0",
"build": "tsc && vite build", "build": "tsc && vite build && wrangler deploy",
"lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "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": [], "keywords": [],
"author": "Jeff Emmett", "author": "Jeff Emmett",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@dimforge/rapier2d": "^0.11.2", "@dimforge/rapier2d": "^0.11.2",
"@tldraw/sync": "^2.4.6", "@tldraw/sync": "^3.4.1",
"@tldraw/sync-core": "^2.4.6", "@tldraw/sync-core": "^3.4.1",
"@tldraw/tlschema": "^2.4.6", "@tldraw/tldraw": "^3.4.1",
"@tldraw/tlschema": "^3.4.1",
"@types/markdown-it": "^14.1.1", "@types/markdown-it": "^14.1.1",
"@vercel/analytics": "^1.2.2", "@vercel/analytics": "^1.2.2",
"@whereby.com/browser-sdk": "^3.9.2",
"cloudflare-workers-unfurl": "^0.0.7", "cloudflare-workers-unfurl": "^0.0.7",
"crdts": "^0.2.0",
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
"itty-router": "^5.0.17", "itty-router": "^5.0.17",
"lodash.throttle": "^4.1.1", "lodash.throttle": "^4.1.1",
@ -30,12 +32,15 @@
"markdown-it-latex2img": "^0.0.6", "markdown-it-latex2img": "^0.0.6",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-error-boundary": "^4.1.2",
"react-router-dom": "^6.22.3", "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": { "devDependencies": {
"@biomejs/biome": "1.4.1", "@biomejs/biome": "1.4.1",
"@cloudflare/types": "^6.29.1", "@cloudflare/types": "^6.0.0",
"@cloudflare/workers-types": "^4.20240821.1", "@cloudflare/workers-types": "^4.20240821.1",
"@types/lodash.throttle": "^4", "@types/lodash.throttle": "^4",
"@types/react": "^18.2.15", "@types/react": "^18.2.15",
@ -55,4 +60,4 @@
"vite-plugin-wasm": "^3.2.2", "vite-plugin-wasm": "^3.2.2",
"wrangler": "^3.88.0" "wrangler": "^3.88.0"
} }
} }

View File

@ -16,10 +16,12 @@ import { Inbox } from './components/Inbox';
import { Books } from './components/Books'; import { Books } from './components/Books';
import { import {
BindingUtil, BindingUtil,
Editor,
IndexKey, IndexKey,
TLBaseBinding, TLBaseBinding,
TLBaseShape, TLBaseShape,
Tldraw, Tldraw,
TLShapeId,
} from 'tldraw'; } from 'tldraw';
import { components, uiOverrides } from './ui-overrides'; import { components, uiOverrides } from './ui-overrides';
import { ChatBoxShape } from './shapes/ChatBoxShapeUtil'; import { ChatBoxShape } from './shapes/ChatBoxShapeUtil';
@ -58,12 +60,12 @@ export default function InteractiveShapeExample() {
return ( return (
<div className="tldraw__editor"> <div className="tldraw__editor">
<Tldraw <Tldraw
shapeUtils={customShapeUtils}
shapeUtils={customShapeUtils} // Use custom shape utils tools={customTools}
tools={customTools} // Pass in the array of custom tool classes
overrides={uiOverrides} overrides={uiOverrides}
components={components} components={components}
onMount={(editor) => { onMount={(editor) => {
handleInitialShapeLoad(editor);
editor.createShape({ type: 'my-interactive-shape', x: 100, y: 100 }); 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(<App />); ReactDOM.createRoot(document.getElementById("root")!).render(<App />);

View File

@ -1,17 +1,12 @@
import { TLAssetStore, uniqueId } from 'tldraw' 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 = { export const multiplayerAssetStore: TLAssetStore = {
// to upload an asset, we...
async upload(_asset, file) { async upload(_asset, file) {
// ...create a unique name & URL...
const id = uniqueId() const id = uniqueId()
const objectName = `${id}-${file.name}`.replace(/[^a-zA-Z0-9.]/g, '-') const objectName = `${id}-${file.name}`.replace(/[^a-zA-Z0-9.]/g, '-')
const url = `${WORKER_URL}/uploads/${objectName}` const url = `${WORKER_URL}/uploads/${objectName}`
// ...POST it to out worker to upload it...
const response = await fetch(url, { const response = await fetch(url, {
method: 'POST', method: 'POST',
body: file, body: file,
@ -21,12 +16,9 @@ export const multiplayerAssetStore: TLAssetStore = {
throw new Error(`Failed to upload asset: ${response.statusText}`) throw new Error(`Failed to upload asset: ${response.statusText}`)
} }
// ...and return the URL to be stored with the asset record.
return url 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) { resolve(asset) {
return asset.props.src return asset.props.src
}, },

View File

@ -3,9 +3,14 @@ import {
AssetRecordType, AssetRecordType,
getHashForString, getHashForString,
TLBookmarkAsset, TLBookmarkAsset,
TLRecord,
Tldraw, Tldraw,
Editor,
TLFrameShape,
TLUiEventSource,
} from 'tldraw' } from 'tldraw'
import { useParams } from 'react-router-dom' import { useParams } from 'react-router-dom'
import useLocalStorageState from 'use-local-storage-state'
import { ChatBoxTool } from '@/tools/ChatBoxTool' import { ChatBoxTool } from '@/tools/ChatBoxTool'
import { ChatBoxShape } from '@/shapes/ChatBoxShapeUtil' import { ChatBoxShape } from '@/shapes/ChatBoxShapeUtil'
import { VideoChatTool } from '@/tools/VideoChatTool' import { VideoChatTool } from '@/tools/VideoChatTool'
@ -15,68 +20,69 @@ import { customSchema } from '../../worker/TldrawDurableObject'
import { EmbedShape } from '@/shapes/EmbedShapeUtil' import { EmbedShape } from '@/shapes/EmbedShapeUtil'
import { EmbedTool } from '@/tools/EmbedTool' import { EmbedTool } from '@/tools/EmbedTool'
import React, { useState } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { ChatBox } from '@/shapes/ChatBoxShapeUtil'; import { ChatBox } from '@/shapes/ChatBoxShapeUtil';
import { components, uiOverrides } from '@/ui-overrides' import { components, uiOverrides } from '@/ui-overrides'
import { useCameraControls } from '@/hooks/useCameraControls'
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 shapeUtils = [ChatBoxShape, VideoChatShape, EmbedShape]
const tools = [ChatBoxTool, VideoChatTool, EmbedTool]; // Array of tools 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() { export function Board() {
const { slug } = useParams<{ slug: string }>(); // Ensure this is inside the Board component const { slug } = useParams<{ slug: string }>();
const roomId = slug || 'default-room'; // Declare roomId here const roomId = slug || 'default-room';
const { store } = usePersistentBoard(roomId);
const store = useSync({ const [editor, setEditor] = useState<Editor | null>(null)
uri: `${WORKER_URL}/connect/${roomId}`, const { zoomToFrame, copyFrameLink, copyLocationLink } = useCameraControls(editor)
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<HTMLInputElement>) => {
setUserName(event.target.value);
};
return ( return (
<div style={{ position: 'fixed', inset: 0 }}> <div style={{ position: 'fixed', inset: 0 }}>
<Tldraw <Tldraw
store={store} store={store}
shapeUtils={shapeUtils} shapeUtils={shapeUtils}
overrides={uiOverrides} overrides={{
...uiOverrides,
tools: (_editor, baseTools) => ({
...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} components={components}
tools={tools} tools={tools}
onMount={(editor) => { onMount={(editor) => {
setEditor(editor)
editor.registerExternalAssetHandler('url', unfurlBookmarkUrl) editor.registerExternalAssetHandler('url', unfurlBookmarkUrl)
editor.setCurrentTool('hand') editor.setCurrentTool('hand')
}} }}
/> />
{isChatBoxVisible && (
<div>
<input
type="text"
value={userName}
onChange={handleNameChange}
placeholder="Enter your name"
/>
<ChatBox
userName={userName}
roomId={roomId} // Added roomId
w={200} // Set appropriate width
h={200} // Set appropriate height
/>
</div>
)}
{isVideoChatVisible && ( // Render the button to join video chat
<button onClick={() => setVideoChatVisible(false)} className="bg-green-500 text-white px-4 py-2 rounded">
Join Video Call
</button>
)}
</div> </div>
) )
} }

View File

@ -8,7 +8,7 @@ const components: TLUiComponents = {
PageMenu: null, PageMenu: null,
NavigationPanel: null, NavigationPanel: null,
DebugMenu: null, DebugMenu: null,
ContextMenu: null, //ContextMenu: null,
ActionsMenu: null, ActionsMenu: null,
QuickActions: null, QuickActions: null,
MainMenu: null, MainMenu: null,

View File

@ -30,9 +30,11 @@ main {
h1 { h1 {
font-size: 2rem; font-size: 2rem;
} }
h2 { h2 {
font-size: 1.5rem; font-size: 1.5rem;
} }
h1, h1,
h2, h2,
h3, h3,
@ -54,7 +56,7 @@ i {
font-variation-settings: "slnt" -15; font-variation-settings: "slnt" -15;
} }
pre > code { pre>code {
width: 100%; width: 100%;
padding: 1em; padding: 1em;
display: block; display: block;
@ -82,6 +84,7 @@ blockquote {
margin-top: 1em; margin-top: 1em;
margin-bottom: 1em; margin-bottom: 1em;
border-radius: 4px; border-radius: 4px;
& p { & p {
font-variation-settings: "CASL" 1; font-variation-settings: "CASL" 1;
margin: 0; margin: 0;
@ -103,6 +106,7 @@ table {
margin-bottom: 1em; margin-bottom: 1em;
font-variation-settings: "mono" 1; font-variation-settings: "mono" 1;
font-variation-settings: "casl" 0; font-variation-settings: "casl" 0;
th, th,
td { td {
padding: 0.5em; padding: 0.5em;
@ -121,9 +125,11 @@ table {
a { a {
font-variation-settings: "CASL" 0; font-variation-settings: "CASL" 0;
&:hover { &:hover {
animation: casl-forward 0.2s ease forwards; animation: casl-forward 0.2s ease forwards;
} }
&:not(:hover) { &:not(:hover) {
/* text-decoration: none; */ /* text-decoration: none; */
animation: casl-reverse 0.2s ease backwards; animation: casl-reverse 0.2s ease backwards;
@ -136,18 +142,21 @@ a {
"CASL" 0, "CASL" 0,
"wght" 400; "wght" 400;
} }
to { to {
font-variation-settings: font-variation-settings:
"CASL" 1, "CASL" 1,
"wght" 600; "wght" 600;
} }
} }
@keyframes casl-reverse { @keyframes casl-reverse {
from { from {
font-variation-settings: font-variation-settings:
"CASL" 1, "CASL" 1,
"wght" 600; "wght" 600;
} }
to { to {
font-variation-settings: font-variation-settings:
"CASL" 0, "CASL" 0,
@ -172,6 +181,7 @@ ul {
padding-left: 0; padding-left: 0;
margin-top: 0; margin-top: 0;
font-size: 1rem; font-size: 1rem;
& li::marker { & li::marker {
color: rgba(0, 0, 0, 0.322); color: rgba(0, 0, 0, 0.322);
} }
@ -186,9 +196,11 @@ img {
main { main {
padding: 2em; padding: 2em;
} }
header { header {
margin-bottom: 1em; margin-bottom: 1em;
} }
ol { ol {
list-style-position: inside; list-style-position: inside;
} }
@ -202,6 +214,7 @@ table:not(:has(+ p)) {
p:has(+ ul) { p:has(+ ul) {
margin-bottom: 0.5em; margin-bottom: 0.5em;
} }
p:has(+ ol) { p:has(+ ol) {
margin-bottom: 0.5em; margin-bottom: 0.5em;
} }
@ -233,17 +246,21 @@ p:has(+ ol) {
border: none; border: none;
cursor: pointer; cursor: pointer;
opacity: 0.25; opacity: 0.25;
&:hover { &:hover {
opacity: 1; opacity: 1;
} }
& img { & img {
width: 100%; width: 100%;
height: 100%; height: 100%;
} }
} }
#toggle-canvas { #toggle-canvas {
top: 10px; top: 10px;
} }
#toggle-physics { #toggle-physics {
top: 60px; top: 60px;
display: none; display: none;
@ -253,6 +270,7 @@ p:has(+ ol) {
font-family: "Recursive"; font-family: "Recursive";
font-variation-settings: "MONO" 1; font-variation-settings: "MONO" 1;
font-variation-settings: "CASL" 1; font-variation-settings: "CASL" 1;
& h1, & h1,
p, p,
span, span,
@ -265,6 +283,7 @@ p:has(+ ol) {
& header { & header {
font-size: 1.5rem; font-size: 1.5rem;
} }
& p { & p {
font-size: 1.1rem; font-size: 1.1rem;
} }
@ -277,6 +296,7 @@ p:has(+ ol) {
.canvas-mode { .canvas-mode {
overflow: hidden; overflow: hidden;
& #toggle-physics { & #toggle-physics {
display: block; display: block;
} }
@ -287,6 +307,9 @@ p:has(+ ol) {
position: fixed; position: fixed;
inset: 0px; inset: 0px;
overflow: hidden; overflow: hidden;
touch-action: none;
-webkit-touch-callout: none;
-webkit-tap-highlight-color: transparent;
} }
.tl-background { .tl-background {
@ -301,4 +324,4 @@ p:has(+ ol) {
box-shadow: 0 0px 16px rgba(0, 0, 0, 0.15); box-shadow: 0 0px 16px rgba(0, 0, 0, 0.15);
overflow: hidden; overflow: hidden;
background-color: white; background-color: white;
} }

View File

@ -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
};
}

40
src/hooks/useGSetState.ts Normal file
View File

@ -0,0 +1,40 @@
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<TLRecord[]>(`gset-${roomId}`, {
defaultValue: []
});
// Keep GSet instance in a ref to persist between renders
const gsetRef = useRef<GSet<TLRecord>>();
if (!gsetRef.current) {
gsetRef.current = new GSet<TLRecord>();
// Initialize G-Set with local data
if (localSet && Array.isArray(localSet)) {
localSet.forEach(record => gsetRef.current?.add(record));
}
}
const addRecord = useCallback((record: TLRecord) => {
if (!gsetRef.current) return;
gsetRef.current.add(record);
setLocalSet(Array.from(gsetRef.current.values()));
}, [setLocalSet]);
const merge = useCallback((remoteSet: Set<TLRecord>) => {
if (!gsetRef.current) return new Set<TLRecord>();
remoteSet.forEach(record => gsetRef.current?.add(record));
setLocalSet(Array.from(gsetRef.current.values()));
return gsetRef.current.values();
}, [setLocalSet]);
return {
values: gsetRef.current.values(),
add: addRecord,
merge,
localSet
};
}

View File

@ -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<SerializedStore<TLRecord>>(storageKey, {
defaultValue: createTLStore({
schema: customSchema as unknown as StoreSchema<TLRecord, unknown>
}).serialize()
});
// Create a persistent store
const baseStore = useMemo(() => {
return createTLStore({
schema: customSchema as unknown as StoreSchema<TLRecord, unknown>,
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<TLRecord>) => {
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<TLRecord>;
};
// 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<TLRecord, unknown>,
initialData: records,
});
}, [isOnline, syncedStore?.store, records]);
return {
store,
records,
setRecords,
isOnline
};
}

View File

@ -0,0 +1,20 @@
import useLocalStorageState from 'use-local-storage-state';
import { TLRecord, createTLStore, SerializedStore } from 'tldraw';
import { customSchema } from '../../worker/TldrawDurableObject';
export function useLocalStorageRoom(roomId: string) {
const [records, setRecords] = useLocalStorageState<SerializedStore<TLRecord>>(`tldraw-room-${roomId}`, {
defaultValue: createTLStore({ schema: customSchema }).serialize()
});
const store = createTLStore({
schema: customSchema,
initialData: records,
});
return {
store,
records,
setRecords
};
}

View File

@ -0,0 +1,92 @@
import { useSync } from '@tldraw/sync'
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 { 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 initialSyncRef = useRef(false)
const mergeInProgressRef = useRef(false)
const syncedStore = useSync({
uri: `${WORKER_URL.replace('https://', 'wss://')}/connect/${roomId}`,
schema: customSchema,
assets: multiplayerAssetStore,
})
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)
}
}, [])
const mergeRecords = useCallback((records: Set<TLRecord>) => {
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))
}
const unsubscribe = syncedStore.store.listen((event) => {
if ('changes' in event) {
const changedRecords = Object.values(event.changes)
if (changedRecords.length > 0) {
mergeRecords(new Set(changedRecords))
}
}
})
return () => unsubscribe()
} else if (!isOnline) {
const currentRecords = Object.values(localStore.allRecords())
if (currentRecords.length > 0) {
mergeRecords(new Set(currentRecords))
}
}
}, [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,
mergeRecords
}
}

View File

@ -1,7 +1,6 @@
import { BaseBoxShapeUtil, TLBaseShape } from "tldraw"; import { BaseBoxShapeUtil, TLBaseShape } from "tldraw";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { WORKER_URL } from '../components/Board';
const CORS_PROXY = 'https://cors-anywhere.herokuapp.com/';
export type IVideoChatShape = TLBaseShape< export type IVideoChatShape = TLBaseShape<
'VideoChat', '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<IVideoChatShape> { export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
static override type = 'VideoChat'; static override type = 'VideoChat';
indicator(_shape: IVideoChatShape) {
return null;
}
getDefaultProps(): IVideoChatShape['props'] { getDefaultProps(): IVideoChatShape['props'] {
return { return {
roomUrl: null, roomUrl: null,
@ -27,55 +28,34 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
}; };
} }
indicator(shape: IVideoChatShape) {
return <rect x={0} y={0} width={shape.props.w} height={shape.props.h} />;
}
async ensureRoomExists(shape: IVideoChatShape) { async ensureRoomExists(shape: IVideoChatShape) {
if (shape.props.roomUrl !== null) { if (shape.props.roomUrl !== null) {
return; return;
} }
const expiryDate = new Date(Date.now() + 1000 * 24 * 60 * 60 * 1000); const response = await fetch(`${WORKER_URL}/daily/rooms`, {
const response = await fetch(`${CORS_PROXY}https://api.whereby.dev/v1/meetings`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Authorization': `Bearer ${WHEREBY_API_KEY}`, 'Content-Type': 'application/json'
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest', // Required by some CORS proxies
}, },
body: JSON.stringify({ body: JSON.stringify({
isLocked: false, properties: {
roomMode: 'normal', enable_recording: true,
endDate: expiryDate.toISOString(), max_participants: 8
fields: ['hostRoomUrl'], }
}), })
}).catch((error) => {
console.error('Failed to create meeting:', error);
throw error;
}); });
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 data = await response.json();
const roomUrl = (data as any).roomUrl;
console.log('This is your roomUrl 3:', roomUrl);
this.editor.updateShape<IVideoChatShape>({ this.editor.updateShape<IVideoChatShape>({
id: shape.id, id: shape.id,
type: 'VideoChat', type: 'VideoChat',
props: { props: {
...shape.props, ...shape.props,
roomUrl roomUrl: (data as any).url
} }
}) });
} }
component(shape: IVideoChatShape) { component(shape: IVideoChatShape) {
@ -84,34 +64,26 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
useEffect(() => { useEffect(() => {
// Load the Whereby SDK only in the browser if (isInRoom && shape.props.roomUrl) {
if (typeof window !== 'undefined') { const script = document.createElement('script');
import("@whereby.com/browser-sdk/embed").then(() => { script.src = 'https://www.daily.co/static/call-machine.js';
joinRoom(); document.body.appendChild(script);
}).catch(err => {
console.error("Error loading Whereby SDK:", err);
setError("Failed to load video chat component.");
});
}
}, []);
const joinRoom = async () => { script.onload = () => {
setError(""); // @ts-ignore
setIsLoading(true); window.DailyIframe.createFrame({
try { iframeStyle: {
await this.ensureRoomExists(shape); width: '100%',
setIsInRoom(true); height: '100%',
} catch (e) { border: '0',
console.error("Error joining room:", e); borderRadius: '4px'
setError("An error occurred. Please try again."); },
showLeaveButton: true,
showFullscreenButton: true
}).join({ url: shape.props.roomUrl });
};
} }
setIsLoading(false); }, [isInRoom, shape.props.roomUrl]);
};
const leaveRoom = () => {
setIsInRoom(false);
// setRoomUrl(""); // Clear the room URL
};
return ( return (
<div style={{ <div style={{
@ -122,45 +94,25 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
top: '10px', top: '10px',
left: '10px', left: '10px',
zIndex: 9999, zIndex: 9999,
padding: '15px', // Increased padding by 5px padding: '15px',
margin: 0, backgroundColor: '#F0F0F0',
backgroundColor: '#F0F0F0', // Light gray background boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)', // Added drop shadow borderRadius: '4px',
borderRadius: '4px', // Slight border radius for softer look
}}> }}>
<div style={{ {!isInRoom ? (
width: '100%', <button
height: '100%', onClick={() => setIsInRoom(true)}
border: '1px solid #D3D3D3', className="bg-blue-500 text-white px-4 py-2 rounded"
backgroundColor: '#FFFFFF', >
display: 'flex', Join Room
justifyContent: 'center', </button>
alignItems: 'center', ) : (
overflow: 'hidden', <div id="daily-call-iframe-container" style={{
}}> width: '100%',
{isLoading ? ( height: '100%'
<p>Joining room...</p> }} />
) : isInRoom && shape.props.roomUrl && typeof window !== 'undefined' ? ( )}
<div className="mb-4" style={{ width: '100%', height: '100%', objectFit: 'contain' }}> {error && <p className="text-red-500 mt-2">{error}</p>}
<whereby-embed
room={shape.props.roomUrl}
background="off"
logo="off"
chat="off"
screenshare="on"
people="on"
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
></whereby-embed>
</div>
) : (
<div>
<button onClick={joinRoom} className="bg-blue-500 text-white px-4 py-2 rounded">
Join Room
</button>
{error && <p className="text-red-500 mt-2">{error}</p>}
</div>
)}
</div>
</div> </div>
); );
} }

6
src/types/crdts.d.ts vendored Normal file
View File

@ -0,0 +1,6 @@
declare module 'crdts/src/G-Set' {
export default class GSet<T = any> {
add(value: T): void;
values(): Set<T>;
}
}

View File

@ -4,11 +4,277 @@ import {
TLComponents, TLComponents,
TLUiOverrides, TLUiOverrides,
TldrawUiMenuItem, TldrawUiMenuItem,
useIsToolSelected, useEditor,
useTools, useTools,
TLShapeId,
DefaultContextMenu,
DefaultContextMenuContent,
TLUiContextMenuProps,
TldrawUiMenuGroup,
TLShape,
} from 'tldraw' } from 'tldraw'
import { CustomMainMenu } from './components/CustomMainMenu' 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 (
<DefaultContextMenu {...props}>
<TldrawUiMenuGroup id="camera-actions">
<TldrawUiMenuItem
id="revert-camera"
label="Revert Camera"
icon="undo"
kbd="b"
readonlyOk
disabled={!hasCameraHistory}
onSelect={() => {
console.log('Reverting camera');
revertCamera(editor);
}}
/>
<TldrawUiMenuItem
id="zoom-to-shape"
label="Zoom to Selection"
icon="zoom-in"
kbd="z"
readonlyOk
disabled={!hasSelection}
onSelect={() => {
console.log('Zoom to Selection clicked');
zoomToShape(editor);
}}
/>
<TldrawUiMenuItem
id="copy-link-to-current-view"
label="Copy Link to Current View"
icon="link"
kbd="c"
readonlyOk
onSelect={() => {
console.log('Copy Link to Current View clicked');
copyLinkToCurrentView(editor);
}}
/>
</TldrawUiMenuGroup>
<DefaultContextMenuContent />
</DefaultContextMenu>
)
}
export const uiOverrides: TLUiOverrides = { export const uiOverrides: TLUiOverrides = {
tools(editor, tools) { tools(editor, tools) {
@ -17,6 +283,7 @@ export const uiOverrides: TLUiOverrides = {
icon: 'color', icon: 'color',
label: 'Video', label: 'Video',
kbd: 'x', kbd: 'x',
meta: {},
onSelect: () => { onSelect: () => {
editor.setCurrentTool('VideoChat') editor.setCurrentTool('VideoChat')
}, },
@ -26,6 +293,7 @@ export const uiOverrides: TLUiOverrides = {
icon: 'color', icon: 'color',
label: 'Chat', label: 'Chat',
kbd: 'x', kbd: 'x',
meta: {},
onSelect: () => { onSelect: () => {
editor.setCurrentTool('ChatBox') editor.setCurrentTool('ChatBox')
}, },
@ -35,28 +303,140 @@ export const uiOverrides: TLUiOverrides = {
icon: 'embed', icon: 'embed',
label: 'Embed', label: 'Embed',
kbd: 'e', kbd: 'e',
meta: {},
onSelect: () => { onSelect: () => {
editor.setCurrentTool('Embed') editor.setCurrentTool('Embed')
}, },
} }
return tools 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 = { export const components: TLComponents = {
Toolbar: (props) => { Toolbar: function Toolbar() {
const editor = useEditor()
const tools = useTools() const tools = useTools()
const isChatBoxSelected = useIsToolSelected(tools['ChatBox'])
const isVideoSelected = useIsToolSelected(tools['VideoChat'])
const isEmbedSelected = useIsToolSelected(tools['Embed'])
return ( return (
<DefaultToolbar {...props}> <DefaultToolbar>
<TldrawUiMenuItem {...tools['VideoChat']} isSelected={isVideoSelected} /> {tools['VideoChat'] && (
<TldrawUiMenuItem {...tools['ChatBox']} isSelected={isChatBoxSelected} /> <TldrawUiMenuItem
<TldrawUiMenuItem {...tools['Embed']} isSelected={isEmbedSelected} /> {...tools['VideoChat']}
isSelected={tools['VideoChat'].id === editor.getCurrentToolId()}
/>
)}
{tools['ChatBox'] && (
<TldrawUiMenuItem
{...tools['ChatBox']}
isSelected={tools['ChatBox'].id === editor.getCurrentToolId()}
/>
)}
{tools['Embed'] && (
<TldrawUiMenuItem
{...tools['Embed']}
isSelected={tools['Embed'].id === editor.getCurrentToolId()}
/>
)}
<DefaultToolbarContent /> <DefaultToolbarContent />
</DefaultToolbar> </DefaultToolbar>
) )
}, },
MainMenu: CustomMainMenu, MainMenu: CustomMainMenu,
} 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);
}
}
};

View File

@ -24,5 +24,16 @@
"source": "/books", "source": "/books",
"destination": "/" "destination": "/"
} }
],
"headers": [
{
"source": "/assets/(.*)",
"headers": [
{
"key": "Cache-Control",
"value": "public, max-age=31536000, immutable"
}
]
}
] ]
} }

View File

@ -5,11 +5,8 @@ import wasm from "vite-plugin-wasm";
import topLevelAwait from "vite-plugin-top-level-await"; import topLevelAwait from "vite-plugin-top-level-await";
import { viteStaticCopy } from 'vite-plugin-static-copy'; import { viteStaticCopy } from 'vite-plugin-static-copy';
export default defineConfig({ export default defineConfig({
define: { envPrefix: ['VITE_'],
'process.env.TLDRAW_WORKER_URL': JSON.stringify('https://jeffemmett-canvas.jeffemmett.workers.dev')
},
plugins: [ plugins: [
react(), react(),
wasm(), wasm(),
@ -24,6 +21,10 @@ export default defineConfig({
] ]
}) })
], ],
server: {
host: '0.0.0.0',
port: 5173,
},
build: { build: {
sourcemap: true, sourcemap: true,
}, },

View File

@ -14,6 +14,7 @@ import { Environment } from './types'
import { ChatBoxShape } from '@/shapes/ChatBoxShapeUtil' import { ChatBoxShape } from '@/shapes/ChatBoxShapeUtil'
import { VideoChatShape } from '@/shapes/VideoChatShapeUtil' import { VideoChatShape } from '@/shapes/VideoChatShapeUtil'
import { EmbedShape } from '@/shapes/EmbedShapeUtil' import { EmbedShape } from '@/shapes/EmbedShapeUtil'
import GSet from 'crdts/src/G-Set'
// add custom shapes and bindings here if needed: // add custom shapes and bindings here if needed:
export const customSchema = createTLSchema({ export const customSchema = createTLSchema({
@ -66,6 +67,16 @@ export class TldrawDurableObject {
} }
return this.handleConnect(request) 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` is the entry point for all requests to the Durable Object
fetch(request: Request): Response | Promise<Response> { fetch(request: Request): Response | Promise<Response> {
@ -136,4 +147,48 @@ export class TldrawDurableObject {
const snapshot = JSON.stringify(room.getCurrentSnapshot()) const snapshot = JSON.stringify(room.getCurrentSnapshot())
await this.r2.put(`rooms/${this.roomId}`, snapshot) await this.r2.put(`rooms/${this.roomId}`, snapshot)
}, 10_000) }, 10_000)
async mergeCrdtState(records: TLRecord[]) {
const room = await this.getRoom();
const gset = new GSet<TLRecord>();
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': '*',
},
})
}
} }

View File

@ -5,4 +5,6 @@
export interface Environment { export interface Environment {
TLDRAW_BUCKET: R2Bucket TLDRAW_BUCKET: R2Bucket
TLDRAW_DURABLE_OBJECT: DurableObjectNamespace TLDRAW_DURABLE_OBJECT: DurableObjectNamespace
DAILY_API_KEY: string;
DAILY_DOMAIN: string;
} }

View File

@ -6,12 +6,72 @@ import { Environment } from './types'
// make sure our sync durable object is made available to cloudflare // make sure our sync durable object is made available to cloudflare
export { TldrawDurableObject } from './TldrawDurableObject' export { TldrawDurableObject } from './TldrawDurableObject'
// Define security headers
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 use itty-router (https://itty.dev/) to handle routing. in this example we turn on CORS because
// we're hosting the worker separately to the client. you should restrict this to your own domain. // we're hosting the worker separately to the client. you should restrict this to your own domain.
const { preflight, corsify } = cors({ origin: '*' }) const { preflight, corsify } = cors({
origin: (origin) => {
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$/
]
// 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: [
'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<IRequest, [env: Environment, ctx: ExecutionContext]>({ const router = AutoRouter<IRequest, [env: Environment, ctx: ExecutionContext]>({
before: [preflight], 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) => { catch: (e) => {
console.error(e) console.error(e)
return error(e) return error(e)
@ -33,5 +93,40 @@ const router = AutoRouter<IRequest, [env: Environment, ctx: ExecutionContext]>({
// bookmarks need to extract metadata from pasted URLs: // bookmarks need to extract metadata from pasted URLs:
.get('/unfurl', handleUnfurlRequest) .get('/unfurl', handleUnfurlRequest)
.get('/room/:roomId', 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
})
})
.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<string, unknown>;
return new Response(JSON.stringify({
...data,
url: `https://${env.DAILY_DOMAIN}/${data.name}`
}), {
headers: { 'Content-Type': 'application/json' }
});
})
// export our router for cloudflare // export our router for cloudflare
export default router export default router

View File

@ -1,34 +1,33 @@
main = "worker/worker.ts" main = "worker/worker.ts"
compatibility_date = "2024-07-01" compatibility_date = "2024-07-01"
name = "jeffemmett-canvas" name = "jeffemmett-canvas"
account_id = "0e7b3338d5278ed1b148e6456b940913"
zone_id = "45c200f8dc2a01852e41b9bb09eb7359"
[vars] [vars]
TLDRAW_WORKER_URL = "https://jeffemmett-canvas.jeffemmett.workers.dev" # Environment variables are managed in Cloudflare Dashboard
# Workers & Pages → jeffemmett-canvas → Settings → Variables
[dev] [dev]
port = 5172 port = 5172
ip = "0.0.0.0" ip = "0.0.0.0"
local_protocol = "http"
upstream_protocol = "https"
# Set up the durable object used for each tldraw room
[durable_objects] [durable_objects]
bindings = [ bindings = [
{ name = "TLDRAW_DURABLE_OBJECT", class_name = "TldrawDurableObject" }, { name = "TLDRAW_DURABLE_OBJECT", class_name = "TldrawDurableObject" },
] ]
# Durable objects require migrations to create/modify/delete them
[[migrations]] [[migrations]]
tag = "v1" tag = "v1"
new_classes = ["TldrawDurableObject"] new_classes = ["TldrawDurableObject"]
# We store rooms and asset uploads in an R2 bucket
[[r2_buckets]] [[r2_buckets]]
binding = 'TLDRAW_BUCKET' binding = 'TLDRAW_BUCKET'
bucket_name = 'jeffemmett-canvas' bucket_name = 'jeffemmett-canvas'
preview_bucket_name = 'jeffemmett-canvas-preview' preview_bucket_name = 'jeffemmett-canvas-preview'
workers_dev = true
logpush = true
# wrangler.toml (wrangler v3.79.0^)
[observability] [observability]
enabled = true enabled = true
head_sampling_rate = 1 head_sampling_rate = 1