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