Merge branch 'add-camera-controls-for-link-to-frame-and-screen-position'
This commit is contained in:
commit
d582be49b2
|
|
@ -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'
|
||||
|
|
@ -180,4 +180,14 @@ dist
|
|||
|
||||
# Environment variables
|
||||
.env*
|
||||
!.env.example
|
||||
!.env.example
|
||||
.vercel
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
.dev.vars
|
||||
|
||||
# Keep example file
|
||||
!.env.example
|
||||
|
|
|
|||
73
index.html
73
index.html
|
|
@ -1,42 +1,45 @@
|
|||
<!DOCTYPE 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 -->
|
||||
<meta name="description"
|
||||
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.">
|
||||
<head>
|
||||
<title>Jeff Emmett</title>
|
||||
<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">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:title" content="Jeff Emmett">
|
||||
<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">
|
||||
<!-- Social Meta Tags -->
|
||||
<meta name="description"
|
||||
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 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">
|
||||
<meta property="og:url" content="https://jeffemmett.com">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:title" content="Jeff Emmett">
|
||||
<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="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>
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
29
package.json
29
package.json
|
|
@ -4,25 +4,27 @@
|
|||
"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",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@dimforge/rapier2d": "^0.11.2",
|
||||
"@tldraw/sync": "^2.4.6",
|
||||
"@tldraw/sync-core": "^2.4.6",
|
||||
"@tldraw/tlschema": "^2.4.6",
|
||||
"@tldraw/sync": "^3.4.1",
|
||||
"@tldraw/sync-core": "^3.4.1",
|
||||
"@tldraw/tldraw": "^3.4.1",
|
||||
"@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",
|
||||
"itty-router": "^5.0.17",
|
||||
"lodash.throttle": "^4.1.1",
|
||||
|
|
@ -30,12 +32,15 @@
|
|||
"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": "^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",
|
||||
|
|
@ -55,4 +60,4 @@
|
|||
"vite-plugin-wasm": "^3.2.2",
|
||||
"wrangler": "^3.88.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
44
src/App.tsx
44
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 (
|
||||
<div className="tldraw__editor">
|
||||
<Tldraw
|
||||
|
||||
shapeUtils={customShapeUtils} // Use custom shape utils
|
||||
tools={customTools} // Pass in the array of custom tool classes
|
||||
shapeUtils={customShapeUtils}
|
||||
tools={customTools}
|
||||
overrides={uiOverrides}
|
||||
components={components}
|
||||
onMount={(editor) => {
|
||||
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(<App />);
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
|
|
|
|||
|
|
@ -3,9 +3,14 @@ import {
|
|||
AssetRecordType,
|
||||
getHashForString,
|
||||
TLBookmarkAsset,
|
||||
TLRecord,
|
||||
Tldraw,
|
||||
Editor,
|
||||
TLFrameShape,
|
||||
TLUiEventSource,
|
||||
} 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,68 +20,69 @@ 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'
|
||||
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 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<HTMLInputElement>) => {
|
||||
setUserName(event.target.value);
|
||||
};
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const roomId = slug || 'default-room';
|
||||
const { store } = usePersistentBoard(roomId);
|
||||
const [editor, setEditor] = useState<Editor | null>(null)
|
||||
const { zoomToFrame, copyFrameLink, copyLocationLink } = useCameraControls(editor)
|
||||
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0 }}>
|
||||
<Tldraw
|
||||
store={store}
|
||||
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}
|
||||
tools={tools}
|
||||
onMount={(editor) => {
|
||||
setEditor(editor)
|
||||
editor.registerExternalAssetHandler('url', unfurlBookmarkUrl)
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ const components: TLUiComponents = {
|
|||
PageMenu: null,
|
||||
NavigationPanel: null,
|
||||
DebugMenu: null,
|
||||
ContextMenu: null,
|
||||
//ContextMenu: null,
|
||||
ActionsMenu: null,
|
||||
QuickActions: null,
|
||||
MainMenu: null,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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<IVideoChatShape> {
|
||||
static override type = 'VideoChat';
|
||||
|
||||
indicator(_shape: IVideoChatShape) {
|
||||
return null;
|
||||
}
|
||||
|
||||
getDefaultProps(): IVideoChatShape['props'] {
|
||||
return {
|
||||
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) {
|
||||
|
||||
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<IVideoChatShape>({
|
||||
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<IVideoChatShape> {
|
|||
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 (
|
||||
<div style={{
|
||||
|
|
@ -122,45 +94,25 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
|
|||
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',
|
||||
}}>
|
||||
<div style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
border: '1px solid #D3D3D3',
|
||||
backgroundColor: '#FFFFFF',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
{isLoading ? (
|
||||
<p>Joining room...</p>
|
||||
) : isInRoom && shape.props.roomUrl && typeof window !== 'undefined' ? (
|
||||
<div className="mb-4" style={{ width: '100%', height: '100%', objectFit: 'contain' }}>
|
||||
<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>
|
||||
{!isInRoom ? (
|
||||
<button
|
||||
onClick={() => setIsInRoom(true)}
|
||||
className="bg-blue-500 text-white px-4 py-2 rounded"
|
||||
>
|
||||
Join Room
|
||||
</button>
|
||||
) : (
|
||||
<div id="daily-call-iframe-container" style={{
|
||||
width: '100%',
|
||||
height: '100%'
|
||||
}} />
|
||||
)}
|
||||
{error && <p className="text-red-500 mt-2">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
declare module 'crdts/src/G-Set' {
|
||||
export default class GSet<T = any> {
|
||||
add(value: T): void;
|
||||
values(): Set<T>;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<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 = {
|
||||
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 (
|
||||
<DefaultToolbar {...props}>
|
||||
<TldrawUiMenuItem {...tools['VideoChat']} isSelected={isVideoSelected} />
|
||||
<TldrawUiMenuItem {...tools['ChatBox']} isSelected={isChatBoxSelected} />
|
||||
<TldrawUiMenuItem {...tools['Embed']} isSelected={isEmbedSelected} />
|
||||
<DefaultToolbar>
|
||||
{tools['VideoChat'] && (
|
||||
<TldrawUiMenuItem
|
||||
{...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 />
|
||||
</DefaultToolbar>
|
||||
)
|
||||
},
|
||||
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);
|
||||
}
|
||||
}
|
||||
};
|
||||
11
vercel.json
11
vercel.json
|
|
@ -24,5 +24,16 @@
|
|||
"source": "/books",
|
||||
"destination": "/"
|
||||
}
|
||||
],
|
||||
"headers": [
|
||||
{
|
||||
"source": "/assets/(.*)",
|
||||
"headers": [
|
||||
{
|
||||
"key": "Cache-Control",
|
||||
"value": "public, max-age=31536000, immutable"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -5,11 +5,8 @@ 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')
|
||||
},
|
||||
envPrefix: ['VITE_'],
|
||||
plugins: [
|
||||
react(),
|
||||
wasm(),
|
||||
|
|
@ -24,6 +21,10 @@ export default defineConfig({
|
|||
]
|
||||
})
|
||||
],
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 5173,
|
||||
},
|
||||
build: {
|
||||
sourcemap: true,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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<Response> {
|
||||
|
|
@ -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<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': '*',
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,4 +5,6 @@
|
|||
export interface Environment {
|
||||
TLDRAW_BUCKET: R2Bucket
|
||||
TLDRAW_DURABLE_OBJECT: DurableObjectNamespace
|
||||
DAILY_API_KEY: string;
|
||||
DAILY_DOMAIN: string;
|
||||
}
|
||||
|
|
@ -6,12 +6,72 @@ 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) => {
|
||||
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]>({
|
||||
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 +93,40 @@ const router = AutoRouter<IRequest, [env: Environment, ctx: ExecutionContext]>({
|
|||
// 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
|
||||
})
|
||||
})
|
||||
|
||||
.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 default router
|
||||
|
|
|
|||
|
|
@ -1,34 +1,33 @@
|
|||
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"
|
||||
# Environment variables are managed in Cloudflare Dashboard
|
||||
# Workers & Pages → jeffemmett-canvas → Settings → Variables
|
||||
|
||||
[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]
|
||||
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'
|
||||
workers_dev = true
|
||||
logpush = true
|
||||
|
||||
# wrangler.toml (wrangler v3.79.0^)
|
||||
[observability]
|
||||
enabled = true
|
||||
head_sampling_rate = 1
|
||||
head_sampling_rate = 1
|
||||
Loading…
Reference in New Issue