swapped in daily.co video and removed whereby sdk, finished zoom and copylink except for context menu display

This commit is contained in:
Jeff Emmett 2024-11-27 10:39:33 +07:00
parent 4a08ffd9d4
commit a0d51e18b1
14 changed files with 839 additions and 131 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'

9
.gitignore vendored
View File

@ -174,3 +174,12 @@ dist
.wrangler/
.*.md
.vercel
# Environment files
.env
.env.local
.env.*.local
.dev.vars
# Keep example file
!.env.example

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

@ -17,13 +17,12 @@
"license": "ISC",
"dependencies": {
"@dimforge/rapier2d": "^0.11.2",
"@tldraw/sync": "^2.4.6",
"@tldraw/sync-core": "^2.4.6",
"@tldraw/sync": "^3.4.1",
"@tldraw/sync-core": "^3.4.1",
"@tldraw/tldraw": "^3.4.1",
"@tldraw/tlschema": "^2.4.6",
"@tldraw/tlschema": "^3.4.1",
"@types/markdown-it": "^14.1.1",
"@vercel/analytics": "^1.2.2",
"@whereby.com/browser-sdk": "^3.9.2",
"cloudflare-workers-unfurl": "^0.0.7",
"crdts": "^0.2.0",
"gray-matter": "^4.0.3",
@ -35,13 +34,13 @@
"react-dom": "^18.2.0",
"react-error-boundary": "^4.1.2",
"react-router-dom": "^6.22.3",
"tldraw": "^2.4.6",
"tldraw": "^3.4.1",
"use-local-storage-state": "^19.5.0",
"vercel": "^39.1.1"
},
"devDependencies": {
"@biomejs/biome": "1.4.1",
"@cloudflare/types": "^6.29.1",
"@cloudflare/types": "^6.0.0",
"@cloudflare/workers-types": "^4.20240821.1",
"@types/lodash.throttle": "^4",
"@types/react": "^18.2.15",

View File

@ -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 />);

View File

@ -5,6 +5,9 @@ import {
TLBookmarkAsset,
TLRecord,
Tldraw,
Editor,
TLFrameShape,
TLUiEventSource,
} from 'tldraw'
import { useParams } from 'react-router-dom'
import useLocalStorageState from 'use-local-storage-state'
@ -20,6 +23,7 @@ import { EmbedTool } from '@/tools/EmbedTool'
import React, { useState, useEffect, useCallback } from 'react';
import { ChatBox } from '@/shapes/ChatBoxShapeUtil';
import { components, uiOverrides } from '@/ui-overrides'
import { useCameraControls } from '@/hooks/useCameraControls'
//const WORKER_URL = `https://jeffemmett-canvas.jeffemmett.workers.dev`
export const WORKER_URL = 'https://jeffemmett-canvas.jeffemmett.workers.dev';
@ -37,16 +41,44 @@ export function Board() {
const { slug } = useParams<{ slug: string }>();
const roomId = slug || 'default-room';
const { store } = usePersistentBoard(roomId);
const [editor, setEditor] = useState<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')
}}

View File

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

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

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

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

View File

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

View File

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

View File

@ -109,5 +109,24 @@ const router = AutoRouter<IRequest, [env: Environment, ctx: ExecutionContext]>({
})
})
.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

View File

@ -1,11 +1,13 @@
main = "worker/worker.ts"
compatibility_date = "2024-07-01"
name = "jeffemmett-canvas"
account_id = "0e7b3338d5278ed1b148e6456b940913"
zone_id = "45c200f8dc2a01852e41b9bb09eb7359"
account_id = "${CLOUDFLARE_ACCOUNT_ID}"
zone_id = "${CLOUDFLARE_ZONE_ID}"
[vars]
TLDRAW_WORKER_URL = "https://jeffemmett-canvas.jeffemmett.workers.dev"
DAILY_API_KEY = "${DAILY_API_KEY}"
DAILY_DOMAIN = "${DAILY_DOMAIN}"
TLDRAW_WORKER_URL = "${TLDRAW_WORKER_URL}"
[dev]
port = 5172
@ -13,24 +15,20 @@ ip = "0.0.0.0"
local_protocol = "http"
upstream_protocol = "https"
# Set up the durable object used for each tldraw room
[durable_objects]
bindings = [
{ name = "TLDRAW_DURABLE_OBJECT", class_name = "TldrawDurableObject" },
]
# Durable objects require migrations to create/modify/delete them
[[migrations]]
tag = "v1"
new_classes = ["TldrawDurableObject"]
# We store rooms and asset uploads in an R2 bucket
[[r2_buckets]]
binding = 'TLDRAW_BUCKET'
bucket_name = 'jeffemmett-canvas'
preview_bucket_name = 'jeffemmett-canvas-preview'
bucket_name = '${R2_BUCKET_NAME}'
preview_bucket_name = '${R2_PREVIEW_BUCKET_NAME}'
# wrangler.toml (wrangler v3.79.0^)
[observability]
enabled = true
head_sampling_rate = 1
head_sampling_rate = 1