From d1a8407a9b0adbf6003a1fef333e7af9bf427a07 Mon Sep 17 00:00:00 2001
From: Jeff Emmett <46964190+Jeff-Emmett@users.noreply.github.com>
Date: Wed, 27 Nov 2024 10:39:33 +0700
Subject: [PATCH] swapped in daily.co video and removed whereby sdk, finished
zoom and copylink except for context menu display
---
.env.example | 19 ++
.gitignore | 9 +
package copy.json | 69 +++++
package.json | 11 +-
src/App.tsx | 44 ++-
src/components/Board.tsx | 34 ++-
src/components/Canvas.tsx | 2 +-
src/hooks/useCameraControls.ts | 90 ++++++
src/hooks/useLocalStorageRoom copy.ts | 103 +++++++
src/shapes/VideoChatShapeUtil.tsx | 148 ++++------
src/ui-overrides.tsx | 402 +++++++++++++++++++++++++-
worker/types.ts | 2 +
worker/worker.ts | 19 ++
wrangler.toml | 18 +-
14 files changed, 839 insertions(+), 131 deletions(-)
create mode 100644 .env.example
create mode 100644 package copy.json
create mode 100644 src/hooks/useCameraControls.ts
create mode 100644 src/hooks/useLocalStorageRoom copy.ts
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..343532a
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,19 @@
+# Google API Credentials
+VITE_GOOGLE_CLIENT_ID='your_google_client_id'
+VITE_GOOGLE_API_KEY='your_google_api_key'
+
+# Cloudflare Worker
+CLOUDFLARE_API_TOKEN='your_cloudflare_token'
+CLOUDFLARE_ACCOUNT_ID='your_account_id'
+CLOUDFLARE_ZONE_ID='your_zone_id'
+
+# Worker URL
+TLDRAW_WORKER_URL='your_worker_url'
+
+# R2 Bucket Configuration
+R2_BUCKET_NAME='your_bucket_name'
+R2_PREVIEW_BUCKET_NAME='your_preview_bucket_name'
+
+# Daily.co Configuration
+DAILY_API_KEY='your_daily_api_key'
+DAILY_DOMAIN='your_daily_domain'
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 981a75d..66646be 100644
--- a/.gitignore
+++ b/.gitignore
@@ -174,3 +174,12 @@ dist
.wrangler/
.*.md
.vercel
+
+# Environment files
+.env
+.env.local
+.env.*.local
+.dev.vars
+
+# Keep example file
+!.env.example
diff --git a/package copy.json b/package copy.json
new file mode 100644
index 0000000..0c4f574
--- /dev/null
+++ b/package copy.json
@@ -0,0 +1,69 @@
+{
+ "name": "jeffemmett",
+ "version": "1.0.0",
+ "description": "Jeff Emmett's personal website",
+ "type": "module",
+ "scripts": {
+ "dev": "concurrently --kill-others --names client,worker --prefix-colors blue,red \"yarn dev:client\" \"yarn dev:worker\"",
+ "dev:client": "vite --host --port 5173",
+ "dev:worker": "wrangler dev --local --port 5172 --ip 0.0.0.0",
+ "build": "tsc && vite build && wrangler deploy",
+ "lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
+ "preview": "vite preview",
+ "deploy": "yarn build && vercel deploy --prod"
+ },
+ "keywords": [],
+ "author": "Jeff Emmett",
+ "license": "ISC",
+ "dependencies": {
+ "@dimforge/rapier2d": "^0.11.2",
+ "@tldraw/assets": "^2.0.0",
+ "@tldraw/tldraw": "^3.4.1",
+ "@tldraw/sync": "^2.4.6",
+ "@tldraw/sync-core": "^2.4.6",
+ "@tldraw/tlschema": "^2.4.6",
+ "@types/markdown-it": "^14.1.1",
+ "@vercel/analytics": "^1.2.2",
+ "@whereby.com/browser-sdk": "^3.9.2",
+ "cloudflare-workers-unfurl": "^0.0.7",
+ "crdts": "^0.2.0",
+ "gray-matter": "^4.0.3",
+ "itty-router": "^5.0.17",
+ "lodash.throttle": "^4.1.1",
+ "markdown-it": "^14.1.0",
+ "markdown-it-latex2img": "^0.0.6",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0",
+ "react-error-boundary": "^4.1.2",
+ "react-router-dom": "^6.22.3",
+ "tldraw": "^2.4.6",
+ "use-local-storage-state": "^19.5.0",
+ "vercel": "^39.1.1"
+ },
+ "devDependencies": {
+ "@biomejs/biome": "1.4.1",
+ "@cloudflare/types": "^6.29.1",
+ "@cloudflare/workers-types": "^4.20240821.1",
+ "@types/lodash.throttle": "^4",
+ "@types/react": "^18.2.0",
+ "@types/react-dom": "^18.2.7",
+ "@typescript-eslint/eslint-plugin": "^5.59.0",
+ "@typescript-eslint/parser": "^5.59.0",
+ "@vitejs/plugin-react": "^4.0.3",
+ "@vitejs/plugin-react-swc": "^3.6.0",
+ "concurrently": "^8.2.2",
+ "eslint": "^8.38.0",
+ "eslint-plugin-react-hooks": "^4.6.0",
+ "eslint-plugin-react-refresh": "^0.3.4",
+ "typescript": "^5.6.3",
+ "vite": "^5.3.3",
+ "vite-plugin-static-copy": "^1.0.6",
+ "vite-plugin-top-level-await": "^1.3.1",
+ "vite-plugin-wasm": "^3.2.2",
+ "wrangler": "^3.88.0"
+ },
+ "resolutions": {
+ "react": "^18.2.0",
+ "@types/react": "^18.2.0"
+ }
+}
\ No newline at end of file
diff --git a/package.json b/package.json
index c97ba26..eb40e52 100644
--- a/package.json
+++ b/package.json
@@ -17,13 +17,12 @@
"license": "ISC",
"dependencies": {
"@dimforge/rapier2d": "^0.11.2",
- "@tldraw/sync": "^2.4.6",
- "@tldraw/sync-core": "^2.4.6",
+ "@tldraw/sync": "^3.4.1",
+ "@tldraw/sync-core": "^3.4.1",
"@tldraw/tldraw": "^3.4.1",
- "@tldraw/tlschema": "^2.4.6",
+ "@tldraw/tlschema": "^3.4.1",
"@types/markdown-it": "^14.1.1",
"@vercel/analytics": "^1.2.2",
- "@whereby.com/browser-sdk": "^3.9.2",
"cloudflare-workers-unfurl": "^0.0.7",
"crdts": "^0.2.0",
"gray-matter": "^4.0.3",
@@ -35,13 +34,13 @@
"react-dom": "^18.2.0",
"react-error-boundary": "^4.1.2",
"react-router-dom": "^6.22.3",
- "tldraw": "^2.4.6",
+ "tldraw": "^3.4.1",
"use-local-storage-state": "^19.5.0",
"vercel": "^39.1.1"
},
"devDependencies": {
"@biomejs/biome": "1.4.1",
- "@cloudflare/types": "^6.29.1",
+ "@cloudflare/types": "^6.0.0",
"@cloudflare/workers-types": "^4.20240821.1",
"@types/lodash.throttle": "^4",
"@types/react": "^18.2.15",
diff --git a/src/App.tsx b/src/App.tsx
index 832e943..6427f59 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -16,10 +16,12 @@ import { Inbox } from './components/Inbox';
import { Books } from './components/Books';
import {
BindingUtil,
+ Editor,
IndexKey,
TLBaseBinding,
TLBaseShape,
Tldraw,
+ TLShapeId,
} from 'tldraw';
import { components, uiOverrides } from './ui-overrides';
import { ChatBoxShape } from './shapes/ChatBoxShapeUtil';
@@ -58,12 +60,12 @@ export default function InteractiveShapeExample() {
return (
{
+ handleInitialShapeLoad(editor);
editor.createShape({ type: 'my-interactive-shape', x: 100, y: 100 });
}}
/>
@@ -71,7 +73,41 @@ export default function InteractiveShapeExample() {
);
}
-// ... existing code ...
+// Add this function before or after InteractiveShapeExample
+const handleInitialShapeLoad = (editor: Editor) => {
+ const url = new URL(window.location.href);
+ const shapeId = url.searchParams.get('shapeId') || url.searchParams.get('frameId');
+ const x = url.searchParams.get('x');
+ const y = url.searchParams.get('y');
+ const zoom = url.searchParams.get('zoom');
+
+ if (shapeId) {
+ console.log('Found shapeId in URL:', shapeId);
+ const shape = editor.getShape(shapeId as TLShapeId);
+
+ if (shape) {
+ console.log('Found shape:', shape);
+ if (x && y && zoom) {
+ console.log('Setting camera to:', { x, y, zoom });
+ editor.setCamera({
+ x: parseFloat(x),
+ y: parseFloat(y),
+ z: parseFloat(zoom)
+ });
+ } else {
+ console.log('Zooming to shape bounds');
+ editor.zoomToBounds(editor.getShapeGeometry(shape).bounds, {
+ targetZoom: 1,
+ //padding: 32
+ });
+ }
+ } else {
+ console.warn('Shape not found in the editor');
+ }
+ } else {
+ console.warn('No shapeId found in the URL');
+ }
+}
ReactDOM.createRoot(document.getElementById("root")!).render();
diff --git a/src/components/Board.tsx b/src/components/Board.tsx
index 600ae39..4408973 100644
--- a/src/components/Board.tsx
+++ b/src/components/Board.tsx
@@ -5,6 +5,9 @@ import {
TLBookmarkAsset,
TLRecord,
Tldraw,
+ Editor,
+ TLFrameShape,
+ TLUiEventSource,
} from 'tldraw'
import { useParams } from 'react-router-dom'
import useLocalStorageState from 'use-local-storage-state'
@@ -20,6 +23,7 @@ import { EmbedTool } from '@/tools/EmbedTool'
import React, { useState, useEffect, useCallback } from 'react';
import { ChatBox } from '@/shapes/ChatBoxShapeUtil';
import { components, uiOverrides } from '@/ui-overrides'
+import { useCameraControls } from '@/hooks/useCameraControls'
//const WORKER_URL = `https://jeffemmett-canvas.jeffemmett.workers.dev`
export const WORKER_URL = 'https://jeffemmett-canvas.jeffemmett.workers.dev';
@@ -37,16 +41,44 @@ export function Board() {
const { slug } = useParams<{ slug: string }>();
const roomId = slug || 'default-room';
const { store } = usePersistentBoard(roomId);
+ const [editor, setEditor] = useState(null)
+ const { zoomToFrame, copyFrameLink, copyLocationLink } = useCameraControls(editor)
return (
({
+ ...baseTools,
+ frame: {
+ ...baseTools.frame,
+ contextMenu: (shape: TLFrameShape) => [
+ {
+ id: 'copy-frame-link',
+ label: 'Copy Frame Link',
+ onSelect: () => copyFrameLink(shape.id),
+ },
+ {
+ id: 'zoom-to-frame',
+ label: 'Zoom to Frame',
+ onSelect: () => zoomToFrame(shape.id),
+ },
+ {
+ id: 'copy-location-link',
+ label: 'Copy Location Link',
+ onSelect: () => copyLocationLink(),
+ }
+ ]
+ },
+ })
+ }}
components={components}
tools={tools}
onMount={(editor) => {
+ setEditor(editor)
editor.registerExternalAssetHandler('url', unfurlBookmarkUrl)
editor.setCurrentTool('hand')
}}
diff --git a/src/components/Canvas.tsx b/src/components/Canvas.tsx
index 34f2a2d..59eac42 100644
--- a/src/components/Canvas.tsx
+++ b/src/components/Canvas.tsx
@@ -8,7 +8,7 @@ const components: TLUiComponents = {
PageMenu: null,
NavigationPanel: null,
DebugMenu: null,
- ContextMenu: null,
+ //ContextMenu: null,
ActionsMenu: null,
QuickActions: null,
MainMenu: null,
diff --git a/src/hooks/useCameraControls.ts b/src/hooks/useCameraControls.ts
new file mode 100644
index 0000000..e7970dc
--- /dev/null
+++ b/src/hooks/useCameraControls.ts
@@ -0,0 +1,90 @@
+import { useEffect } from 'react';
+import { Editor, TLFrameShape, TLParentId } from 'tldraw';
+import { useSearchParams } from 'react-router-dom';
+
+export function useCameraControls(editor: Editor | null) {
+ const [searchParams] = useSearchParams();
+
+ useEffect(() => {
+ if (!editor) return;
+
+ const frameId = searchParams.get('frameId');
+ const x = searchParams.get('x');
+ const y = searchParams.get('y');
+ const zoom = searchParams.get('zoom');
+
+ console.log('Loading camera position:', { frameId, x, y, zoom });
+
+ if (x && y && zoom) {
+ editor.setCamera({
+ x: parseFloat(x),
+ y: parseFloat(y),
+ z: parseFloat(zoom)
+ });
+ console.log('Camera position set from URL params');
+ return;
+ }
+
+ if (!frameId) return;
+
+ const frame = editor.getShape(frameId as TLParentId) as TLFrameShape;
+ if (!frame) {
+ console.warn('Frame not found:', frameId);
+ return;
+ }
+
+ editor.zoomToBounds(
+ editor.getShapePageBounds(frame)!,
+ {
+ inset: 32,
+ targetZoom: editor.getCamera().z,
+ }
+ );
+
+ const newUrl = new URL(window.location.href);
+ newUrl.searchParams.set('frameId', frameId);
+ window.history.replaceState(null, '', newUrl.toString());
+ }, [editor, searchParams]);
+
+ const copyLocationLink = () => {
+ if (!editor) return;
+ const camera = editor.getCamera();
+ const url = new URL(window.location.href);
+ url.searchParams.set('x', camera.x.toString());
+ url.searchParams.set('y', camera.y.toString());
+ url.searchParams.set('zoom', camera.z.toString());
+ console.log('Copying location link:', url.toString());
+ navigator.clipboard.writeText(url.toString());
+ };
+
+ const zoomToFrame = (frameId: string) => {
+ if (!editor) return;
+
+ const frame = editor.getShape(frameId as TLParentId) as TLFrameShape;
+ if (!frame) {
+ console.warn('Frame not found:', frameId);
+ return;
+ }
+
+ editor.zoomToBounds(
+ editor.getShapePageBounds(frame)!,
+ {
+ inset: 32,
+ targetZoom: editor.getCamera().z,
+ }
+ );
+ };
+
+ const copyFrameLink = (frameId: string) => {
+ const url = new URL(window.location.href);
+ url.searchParams.set('frameId', frameId);
+ console.log('Copying frame link:', url.toString());
+ navigator.clipboard.writeText(url.toString());
+ };
+
+ return {
+ zoomToFrame,
+ copyFrameLink,
+ copyLocationLink
+ };
+}
\ No newline at end of file
diff --git a/src/hooks/useLocalStorageRoom copy.ts b/src/hooks/useLocalStorageRoom copy.ts
new file mode 100644
index 0000000..7816591
--- /dev/null
+++ b/src/hooks/useLocalStorageRoom copy.ts
@@ -0,0 +1,103 @@
+import useLocalStorageState from 'use-local-storage-state';
+import { TLRecord, createTLStore, SerializedStore, Editor, StoreSchema, TLStoreProps } from '@tldraw/tldraw';
+import { customSchema } from '../../worker/TldrawDurableObject';
+import { useMemo, useCallback, useEffect, useState } from 'react';
+import { useSync } from '@tldraw/sync';
+import { WORKER_URL } from '../components/Board';
+import { TLRecord as TLSchemaRecord } from '@tldraw/tlschema'
+import { defaultAssetUrls } from '@tldraw/assets'
+
+const CACHE_VERSION = '1.0';
+
+export function useLocalStorageRoom(roomId: string) {
+ const [isOnline, setIsOnline] = useState(navigator.onLine);
+ const storageKey = `tldraw_board_${roomId}_v${CACHE_VERSION}`;
+
+ const [records, setRecords] = useLocalStorageState>(storageKey, {
+ defaultValue: createTLStore({
+ schema: customSchema as unknown as StoreSchema
+ }).serialize()
+ });
+
+ // Create a persistent store
+ const baseStore = useMemo(() => {
+ return createTLStore({
+ schema: customSchema as unknown as StoreSchema,
+ initialData: records,
+ })
+ }, [records]);
+
+ // Use sync with the base store
+ const syncedStore = useSync({
+ uri: `${WORKER_URL.replace('https://', 'wss://')}/connect/${roomId}`,
+ schema: customSchema,
+ store: baseStore,
+ assets: defaultAssetUrls
+ });
+
+ // Handle online/offline transitions
+ useEffect(() => {
+ const handleOnline = () => {
+ setIsOnline(true);
+ if (syncedStore?.store) {
+ const filteredRecords = filterNonCameraRecords(records);
+ syncedStore.store.mergeRemoteChanges(() => {
+ Object.values(filteredRecords).forEach(record => {
+ syncedStore.store.put([record as unknown as TLSchemaRecord]);
+ });
+ });
+ }
+ };
+
+ const handleOffline = () => setIsOnline(false);
+
+ window.addEventListener('online', handleOnline);
+ window.addEventListener('offline', handleOffline);
+
+ return () => {
+ window.removeEventListener('online', handleOnline);
+ window.removeEventListener('offline', handleOffline);
+ };
+ }, [records, syncedStore?.store]);
+
+ const filterNonCameraRecords = (data: SerializedStore) => {
+ return Object.fromEntries(
+ Object.entries(data).filter(([_, record]) => {
+ return (record as TLRecord).typeName !== 'camera' &&
+ (record as TLRecord).typeName !== 'instance_page_state' &&
+ (record as TLRecord).typeName !== 'instance_presence';
+ })
+ ) as SerializedStore;
+ };
+
+ // Sync with server store when online
+ useEffect(() => {
+ if (!isOnline || !syncedStore?.store) return;
+
+ const syncInterval = setInterval(() => {
+ const serverRecords = syncedStore.store.allRecords();
+ if (Object.keys(serverRecords).length > 0) {
+ setRecords(syncedStore.store.serialize() as typeof records);
+ }
+ }, 5000);
+
+ return () => clearInterval(syncInterval);
+ }, [isOnline, syncedStore?.store, setRecords]);
+
+ const store = useMemo(() => {
+ if (isOnline && syncedStore?.store) {
+ return syncedStore.store;
+ }
+ return createTLStore({
+ schema: customSchema as unknown as StoreSchema,
+ initialData: records,
+ });
+ }, [isOnline, syncedStore?.store, records]);
+
+ return {
+ store,
+ records,
+ setRecords,
+ isOnline
+ };
+}
\ No newline at end of file
diff --git a/src/shapes/VideoChatShapeUtil.tsx b/src/shapes/VideoChatShapeUtil.tsx
index be5f7bc..2536e1e 100644
--- a/src/shapes/VideoChatShapeUtil.tsx
+++ b/src/shapes/VideoChatShapeUtil.tsx
@@ -1,7 +1,6 @@
import { BaseBoxShapeUtil, TLBaseShape } from "tldraw";
import { useEffect, useState } from "react";
-
-const CORS_PROXY = 'https://cors-anywhere.herokuapp.com/';
+import { WORKER_URL } from '../components/Board';
export type IVideoChatShape = TLBaseShape<
'VideoChat',
@@ -13,11 +12,13 @@ export type IVideoChatShape = TLBaseShape<
}
>;
-const WHEREBY_API_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2FjY291bnRzLmFwcGVhci5pbiIsImF1ZCI6Imh0dHBzOi8vYXBpLmFwcGVhci5pbi92MSIsImV4cCI6OTAwNzE5OTI1NDc0MDk5MSwiaWF0IjoxNzI5MTkzOTE3LCJvcmdhbml6YXRpb25JZCI6MjY2MDk5LCJqdGkiOiI0MzI0MmUxMC1kZmRjLTRhYmEtYjlhOS01ZjcwNTFlMTYwZjAifQ.RaxXpZKYl_dOWyoATQZrzyMR2XRh3fHf02mALQiuTTs'; // Replace with your actual API key
-
export class VideoChatShape extends BaseBoxShapeUtil {
static override type = 'VideoChat';
+ indicator(_shape: IVideoChatShape) {
+ return null;
+ }
+
getDefaultProps(): IVideoChatShape['props'] {
return {
roomUrl: null,
@@ -27,55 +28,34 @@ export class VideoChatShape extends BaseBoxShapeUtil {
};
}
- indicator(shape: IVideoChatShape) {
- return ;
- }
-
async ensureRoomExists(shape: IVideoChatShape) {
-
if (shape.props.roomUrl !== null) {
return;
}
- const expiryDate = new Date(Date.now() + 1000 * 24 * 60 * 60 * 1000);
-
- const response = await fetch(`${CORS_PROXY}https://api.whereby.dev/v1/meetings`, {
+ const response = await fetch(`${WORKER_URL}/daily/rooms`, {
method: 'POST',
headers: {
- 'Authorization': `Bearer ${WHEREBY_API_KEY}`,
- 'Content-Type': 'application/json',
- 'X-Requested-With': 'XMLHttpRequest', // Required by some CORS proxies
+ 'Content-Type': 'application/json'
},
body: JSON.stringify({
- isLocked: false,
- roomMode: 'normal',
- endDate: expiryDate.toISOString(),
- fields: ['hostRoomUrl'],
- }),
- }).catch((error) => {
- console.error('Failed to create meeting:', error);
- throw error;
+ properties: {
+ enable_recording: true,
+ max_participants: 8
+ }
+ })
});
- if (!response.ok) {
- const errorData = await response.json();
- console.error('Whereby API error:', errorData);
- throw new Error(`Whereby API error: ${(errorData as any).message || 'Unknown error'}`);
- }
-
const data = await response.json();
- const roomUrl = (data as any).roomUrl;
-
- console.log('This is your roomUrl 3:', roomUrl);
this.editor.updateShape({
id: shape.id,
type: 'VideoChat',
props: {
...shape.props,
- roomUrl
+ roomUrl: (data as any).url
}
- })
+ });
}
component(shape: IVideoChatShape) {
@@ -84,34 +64,26 @@ export class VideoChatShape extends BaseBoxShapeUtil {
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
- // Load the Whereby SDK only in the browser
- if (typeof window !== 'undefined') {
- import("@whereby.com/browser-sdk/embed").then(() => {
- joinRoom();
- }).catch(err => {
- console.error("Error loading Whereby SDK:", err);
- setError("Failed to load video chat component.");
- });
- }
- }, []);
+ if (isInRoom && shape.props.roomUrl) {
+ const script = document.createElement('script');
+ script.src = 'https://www.daily.co/static/call-machine.js';
+ document.body.appendChild(script);
- const joinRoom = async () => {
- setError("");
- setIsLoading(true);
- try {
- await this.ensureRoomExists(shape);
- setIsInRoom(true);
- } catch (e) {
- console.error("Error joining room:", e);
- setError("An error occurred. Please try again.");
+ script.onload = () => {
+ // @ts-ignore
+ window.DailyIframe.createFrame({
+ iframeStyle: {
+ width: '100%',
+ height: '100%',
+ border: '0',
+ borderRadius: '4px'
+ },
+ showLeaveButton: true,
+ showFullscreenButton: true
+ }).join({ url: shape.props.roomUrl });
+ };
}
- setIsLoading(false);
- };
-
- const leaveRoom = () => {
- setIsInRoom(false);
- // setRoomUrl(""); // Clear the room URL
- };
+ }, [isInRoom, shape.props.roomUrl]);
return (
{
top: '10px',
left: '10px',
zIndex: 9999,
- padding: '15px', // Increased padding by 5px
- margin: 0,
- backgroundColor: '#F0F0F0', // Light gray background
- boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)', // Added drop shadow
- borderRadius: '4px', // Slight border radius for softer look
+ padding: '15px',
+ backgroundColor: '#F0F0F0',
+ boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
+ borderRadius: '4px',
}}>
-
- {isLoading ? (
-
Joining room...
- ) : isInRoom && shape.props.roomUrl && typeof window !== 'undefined' ? (
-
-
-
- ) : (
-
-
- {error &&
{error}
}
-
- )}
-
+ {!isInRoom ? (
+
+ ) : (
+
+ )}
+ {error &&
{error}
}
);
}
diff --git a/src/ui-overrides.tsx b/src/ui-overrides.tsx
index f00e549..d29607c 100644
--- a/src/ui-overrides.tsx
+++ b/src/ui-overrides.tsx
@@ -4,11 +4,277 @@ import {
TLComponents,
TLUiOverrides,
TldrawUiMenuItem,
- useIsToolSelected,
+ useEditor,
useTools,
+ TLShapeId,
+ DefaultContextMenu,
+ DefaultContextMenuContent,
+ TLUiContextMenuProps,
+ TldrawUiMenuGroup,
+ TLShape,
} from 'tldraw'
import { CustomMainMenu } from './components/CustomMainMenu'
-import { EmbedShape } from './shapes/EmbedShapeUtil'
+import { Editor } from 'tldraw'
+
+let cameraHistory: { x: number; y: number; z: number }[] = [];
+const MAX_HISTORY = 10; // Keep last 10 camera positions
+
+// Helper function to store camera position
+const storeCameraPosition = (editor: Editor) => {
+ const currentCamera = editor.getCamera();
+ // Only store if there's a meaningful change from the last position
+ const lastPosition = cameraHistory[cameraHistory.length - 1];
+ if (!lastPosition ||
+ Math.abs(lastPosition.x - currentCamera.x) > 1 ||
+ Math.abs(lastPosition.y - currentCamera.y) > 1 ||
+ Math.abs(lastPosition.z - currentCamera.z) > 0.1) {
+
+ cameraHistory.push({ ...currentCamera });
+ if (cameraHistory.length > MAX_HISTORY) {
+ cameraHistory.shift();
+ }
+ console.log('Stored camera position:', currentCamera);
+ }
+};
+
+const copyFrameLink = async (editor: Editor, frameId: string) => {
+ console.log('Starting copyFrameLink with frameId:', frameId);
+
+ if (!editor.store.getSnapshot()) {
+ console.warn('Store not ready');
+ return;
+ }
+
+ try {
+ const baseUrl = `${window.location.origin}${window.location.pathname}`;
+ console.log('Base URL:', baseUrl);
+
+ const url = new URL(baseUrl);
+ url.searchParams.set('frameId', frameId);
+
+ const frame = editor.getShape(frameId as TLShapeId);
+ console.log('Found frame:', frame);
+
+ if (frame) {
+ const camera = editor.getCamera();
+ console.log('Camera position:', { x: camera.x, y: camera.y, zoom: camera.z });
+
+ url.searchParams.set('x', camera.x.toString());
+ url.searchParams.set('y', camera.y.toString());
+ url.searchParams.set('zoom', camera.z.toString());
+ }
+
+ const finalUrl = url.toString();
+ console.log('Final URL to copy:', finalUrl);
+
+ if (navigator.clipboard && window.isSecureContext) {
+ console.log('Using modern clipboard API...');
+ await navigator.clipboard.writeText(finalUrl);
+ console.log('URL copied successfully using clipboard API');
+ } else {
+ console.log('Falling back to legacy clipboard method...');
+ const textArea = document.createElement('textarea');
+ textArea.value = finalUrl;
+ document.body.appendChild(textArea);
+ textArea.select();
+ document.execCommand('copy');
+ document.body.removeChild(textArea);
+ console.log('URL copied successfully using fallback method');
+ }
+ } catch (error) {
+ console.error('Failed to copy to clipboard:', error);
+ alert('Failed to copy link. Please check clipboard permissions.');
+ }
+};
+
+const zoomToShape = (editor: Editor) => {
+ // Store camera position before zooming
+ storeCameraPosition(editor);
+
+ // Get all selected shape IDs
+ const selectedIds = editor.getSelectedShapeIds();
+ if (selectedIds.length === 0) return;
+
+ // Get the common bounds that encompass all selected shapes
+ const commonBounds = editor.getSelectionPageBounds();
+ if (!commonBounds) return;
+
+ // Calculate viewport dimensions
+ const viewportPageBounds = editor.getViewportPageBounds();
+
+ // Calculate the ratio of selection size to viewport size
+ const widthRatio = commonBounds.width / viewportPageBounds.width;
+ const heightRatio = commonBounds.height / viewportPageBounds.height;
+
+ // Calculate target zoom based on selection size
+ let targetZoom;
+ if (widthRatio < 0.1 || heightRatio < 0.1) {
+ // For very small selections, zoom in up to 8x
+ targetZoom = Math.min(
+ (viewportPageBounds.width * 0.8) / commonBounds.width,
+ (viewportPageBounds.height * 0.8) / commonBounds.height,
+ 8 // Max zoom of 8x for small selections
+ );
+ } else if (widthRatio > 1 || heightRatio > 1) {
+ // For selections larger than viewport, zoom out more
+ targetZoom = Math.min(
+ (viewportPageBounds.width * 0.7) / commonBounds.width,
+ (viewportPageBounds.height * 0.7) / commonBounds.height,
+ 0.125 // Min zoom of 1/8x for large selections (reciprocal of 8)
+ );
+ } else {
+ // For medium-sized selections, allow up to 4x zoom
+ targetZoom = Math.min(
+ (viewportPageBounds.width * 0.8) / commonBounds.width,
+ (viewportPageBounds.height * 0.8) / commonBounds.height,
+ 4 // Medium zoom level
+ );
+ }
+
+ // Zoom to the common bounds
+ editor.zoomToBounds(commonBounds, {
+ targetZoom,
+ inset: widthRatio > 1 || heightRatio > 1 ? 20 : 50, // Less padding for large selections
+ animation: {
+ duration: 400,
+ easing: (t) => t * (2 - t)
+ }
+ });
+
+ // Update URL with new camera position and first selected shape ID
+ const newCamera = editor.getCamera();
+ const url = new URL(window.location.href);
+ url.searchParams.set('shapeId', selectedIds[0].toString());
+ url.searchParams.set('x', newCamera.x.toString());
+ url.searchParams.set('y', newCamera.y.toString());
+ url.searchParams.set('zoom', newCamera.z.toString());
+ window.history.replaceState(null, '', url.toString());
+};
+
+const copyLinkToCurrentView = async (editor: Editor) => {
+ console.log('Starting copyLinkToCurrentView');
+
+ if (!editor.store.getSnapshot()) {
+ console.warn('Store not ready');
+ return;
+ }
+
+ try {
+ const baseUrl = `${window.location.origin}${window.location.pathname}`;
+ console.log('Base URL:', baseUrl);
+
+ const url = new URL(baseUrl);
+ const camera = editor.getCamera();
+ console.log('Current camera position:', { x: camera.x, y: camera.y, zoom: camera.z });
+
+ // Set camera parameters
+ url.searchParams.set('x', camera.x.toString());
+ url.searchParams.set('y', camera.y.toString());
+ url.searchParams.set('zoom', camera.z.toString());
+
+ const finalUrl = url.toString();
+ console.log('Final URL to copy:', finalUrl);
+
+ if (navigator.clipboard && window.isSecureContext) {
+ console.log('Using modern clipboard API...');
+ await navigator.clipboard.writeText(finalUrl);
+ console.log('URL copied successfully using clipboard API');
+ } else {
+ console.log('Falling back to legacy clipboard method...');
+ const textArea = document.createElement('textarea');
+ textArea.value = finalUrl;
+ document.body.appendChild(textArea);
+ textArea.select();
+ document.execCommand('copy');
+ document.body.removeChild(textArea);
+ console.log('URL copied successfully using fallback method');
+ }
+ } catch (error) {
+ console.error('Failed to copy to clipboard:', error);
+ alert('Failed to copy link. Please check clipboard permissions.');
+ }
+};
+
+const revertCamera = (editor: Editor) => {
+ if (cameraHistory.length > 0) {
+ const previousCamera = cameraHistory.pop();
+ if (previousCamera) {
+ // Get current viewport bounds
+ const viewportPageBounds = editor.getViewportPageBounds();
+
+ // Create bounds that center on the previous camera position
+ const targetBounds = {
+ x: previousCamera.x - (viewportPageBounds.width / 2) / previousCamera.z,
+ y: previousCamera.y - (viewportPageBounds.height / 2) / previousCamera.z,
+ w: viewportPageBounds.width / previousCamera.z,
+ h: viewportPageBounds.height / previousCamera.z,
+ };
+
+ // Use the same zoom animation as zoomToShape
+ editor.zoomToBounds(targetBounds, {
+ targetZoom: previousCamera.z,
+ animation: {
+ duration: 400,
+ easing: (t) => t * (2 - t)
+ }
+ });
+
+ console.log('Reverted to camera position:', previousCamera);
+ }
+ } else {
+ console.log('No camera history available');
+ }
+};
+
+function CustomContextMenu(props: TLUiContextMenuProps) {
+ const editor = useEditor()
+ const hasSelection = editor.getSelectedShapeIds().length > 0
+ const selectedShape = editor.getSelectedShapes()[0]
+ const hasCameraHistory = cameraHistory.length > 0
+
+ return (
+
+
+ {
+ console.log('Reverting camera');
+ revertCamera(editor);
+ }}
+ />
+ {
+ console.log('Zoom to Selection clicked');
+ zoomToShape(editor);
+ }}
+ />
+ {
+ console.log('Copy Link to Current View clicked');
+ copyLinkToCurrentView(editor);
+ }}
+ />
+
+
+
+ )
+}
export const uiOverrides: TLUiOverrides = {
tools(editor, tools) {
@@ -17,6 +283,7 @@ export const uiOverrides: TLUiOverrides = {
icon: 'color',
label: 'Video',
kbd: 'x',
+ meta: {},
onSelect: () => {
editor.setCurrentTool('VideoChat')
},
@@ -26,6 +293,7 @@ export const uiOverrides: TLUiOverrides = {
icon: 'color',
label: 'Chat',
kbd: 'x',
+ meta: {},
onSelect: () => {
editor.setCurrentTool('ChatBox')
},
@@ -35,28 +303,140 @@ export const uiOverrides: TLUiOverrides = {
icon: 'embed',
label: 'Embed',
kbd: 'e',
+ meta: {},
onSelect: () => {
editor.setCurrentTool('Embed')
},
}
return tools
},
+ actions(editor, actions) {
+ actions['copyFrameLink'] = {
+ id: 'copy-frame-link',
+ label: 'Copy Frame Link',
+ onSelect: () => {
+ const shape = editor.getSelectedShapes()[0]
+ if (shape && shape.type === 'frame') {
+ copyFrameLink(editor, shape.id)
+ }
+ },
+ readonlyOk: true,
+ }
+
+ actions['zoomToFrame'] = {
+ id: 'zoom-to-frame',
+ label: 'Zoom to Frame',
+ onSelect: () => {
+ const shape = editor.getSelectedShapes()[0]
+ if (shape && shape.type === 'frame') {
+ zoomToShape(editor)
+ }
+ },
+ readonlyOk: true,
+ }
+
+ actions['copyLinkToCurrentView'] = {
+ id: 'copy-link-to-current-view',
+ label: 'Copy Link to Current View',
+ kbd: 'c',
+ onSelect: () => {
+ console.log('Creating link to current view');
+ copyLinkToCurrentView(editor);
+ },
+ readonlyOk: true,
+ }
+
+ actions['zoomToShape'] = {
+ id: 'zoom-to-shape',
+ label: 'Zoom to Selection',
+ kbd: 'z',
+ onSelect: () => {
+ if (editor.getSelectedShapeIds().length > 0) {
+ console.log('Zooming to selection');
+ zoomToShape(editor);
+ }
+ },
+ readonlyOk: true,
+ }
+
+ actions['revertCamera'] = {
+ id: 'revert-camera',
+ label: 'Revert Camera',
+ kbd: 'b',
+ onSelect: () => {
+ console.log('Reverting camera position');
+ revertCamera(editor);
+ },
+ readonlyOk: true,
+ }
+
+ return actions
+ },
}
export const components: TLComponents = {
- Toolbar: (props) => {
+ Toolbar: function Toolbar() {
+ const editor = useEditor()
const tools = useTools()
- const isChatBoxSelected = useIsToolSelected(tools['ChatBox'])
- const isVideoSelected = useIsToolSelected(tools['VideoChat'])
- const isEmbedSelected = useIsToolSelected(tools['Embed'])
return (
-
-
-
-
+
+ {tools['VideoChat'] && (
+
+ )}
+ {tools['ChatBox'] && (
+
+ )}
+ {tools['Embed'] && (
+
+ )}
)
},
MainMenu: CustomMainMenu,
-}
\ No newline at end of file
+ ContextMenu: CustomContextMenu,
+}
+
+const handleInitialShapeLoad = (editor: Editor) => {
+ const url = new URL(window.location.href);
+
+ // Check for both shapeId and legacy frameId (for backwards compatibility)
+ const shapeId = url.searchParams.get('shapeId') || url.searchParams.get('frameId');
+ const x = url.searchParams.get('x');
+ const y = url.searchParams.get('y');
+ const zoom = url.searchParams.get('zoom');
+
+ if (shapeId) {
+ console.log('Found shapeId in URL:', shapeId);
+ const shape = editor.getShape(shapeId as TLShapeId);
+
+ if (shape) {
+ console.log('Found shape:', shape);
+ if (x && y && zoom) {
+ console.log('Setting camera to:', { x, y, zoom });
+ editor.setCamera({
+ x: parseFloat(x),
+ y: parseFloat(y),
+ z: parseFloat(zoom)
+ });
+ } else {
+ console.log('Zooming to shape bounds');
+ editor.zoomToBounds(editor.getShapeGeometry(shape).bounds, {
+ targetZoom: 1,
+ //padding: 32
+ });
+ }
+ } else {
+ console.warn('Shape not found:', shapeId);
+ }
+ }
+};
\ No newline at end of file
diff --git a/worker/types.ts b/worker/types.ts
index 4529d5f..3a756ae 100644
--- a/worker/types.ts
+++ b/worker/types.ts
@@ -5,4 +5,6 @@
export interface Environment {
TLDRAW_BUCKET: R2Bucket
TLDRAW_DURABLE_OBJECT: DurableObjectNamespace
+ DAILY_API_KEY: string;
+ DAILY_DOMAIN: string;
}
\ No newline at end of file
diff --git a/worker/worker.ts b/worker/worker.ts
index f938bc9..50c4da9 100644
--- a/worker/worker.ts
+++ b/worker/worker.ts
@@ -109,5 +109,24 @@ const router = AutoRouter({
})
})
+ .post('/daily/rooms', async (request, env) => {
+ const response = await fetch('https://api.daily.co/v1/rooms', {
+ method: 'POST',
+ headers: {
+ 'Authorization': `Bearer ${env.DAILY_API_KEY}`,
+ 'Content-Type': 'application/json'
+ },
+ body: await request.text()
+ });
+
+ const data = await response.json() as Record;
+ return new Response(JSON.stringify({
+ ...data,
+ url: `https://${env.DAILY_DOMAIN}/${data.name}`
+ }), {
+ headers: { 'Content-Type': 'application/json' }
+ });
+ })
+
// export our router for cloudflare
export default router
diff --git a/wrangler.toml b/wrangler.toml
index 7e3c1ab..5a1cccb 100644
--- a/wrangler.toml
+++ b/wrangler.toml
@@ -1,11 +1,13 @@
main = "worker/worker.ts"
compatibility_date = "2024-07-01"
name = "jeffemmett-canvas"
-account_id = "0e7b3338d5278ed1b148e6456b940913"
-zone_id = "45c200f8dc2a01852e41b9bb09eb7359"
+account_id = "${CLOUDFLARE_ACCOUNT_ID}"
+zone_id = "${CLOUDFLARE_ZONE_ID}"
[vars]
-TLDRAW_WORKER_URL = "https://jeffemmett-canvas.jeffemmett.workers.dev"
+DAILY_API_KEY = "${DAILY_API_KEY}"
+DAILY_DOMAIN = "${DAILY_DOMAIN}"
+TLDRAW_WORKER_URL = "${TLDRAW_WORKER_URL}"
[dev]
port = 5172
@@ -13,24 +15,20 @@ ip = "0.0.0.0"
local_protocol = "http"
upstream_protocol = "https"
-# Set up the durable object used for each tldraw room
[durable_objects]
bindings = [
{ name = "TLDRAW_DURABLE_OBJECT", class_name = "TldrawDurableObject" },
]
-# Durable objects require migrations to create/modify/delete them
[[migrations]]
tag = "v1"
new_classes = ["TldrawDurableObject"]
-# We store rooms and asset uploads in an R2 bucket
[[r2_buckets]]
binding = 'TLDRAW_BUCKET'
-bucket_name = 'jeffemmett-canvas'
-preview_bucket_name = 'jeffemmett-canvas-preview'
+bucket_name = '${R2_BUCKET_NAME}'
+preview_bucket_name = '${R2_PREVIEW_BUCKET_NAME}'
-# wrangler.toml (wrangler v3.79.0^)
[observability]
enabled = true
-head_sampling_rate = 1
\ No newline at end of file
+head_sampling_rate = 1
\ No newline at end of file