swapped in daily.co video and removed whereby sdk, finished zoom and copylink except for context menu display
This commit is contained in:
parent
db3205f97a
commit
d1a8407a9b
|
|
@ -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'
|
||||||
|
|
@ -174,3 +174,12 @@ dist
|
||||||
.wrangler/
|
.wrangler/
|
||||||
.*.md
|
.*.md
|
||||||
.vercel
|
.vercel
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
.dev.vars
|
||||||
|
|
||||||
|
# Keep example file
|
||||||
|
!.env.example
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
11
package.json
11
package.json
|
|
@ -17,13 +17,12 @@
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dimforge/rapier2d": "^0.11.2",
|
"@dimforge/rapier2d": "^0.11.2",
|
||||||
"@tldraw/sync": "^2.4.6",
|
"@tldraw/sync": "^3.4.1",
|
||||||
"@tldraw/sync-core": "^2.4.6",
|
"@tldraw/sync-core": "^3.4.1",
|
||||||
"@tldraw/tldraw": "^3.4.1",
|
"@tldraw/tldraw": "^3.4.1",
|
||||||
"@tldraw/tlschema": "^2.4.6",
|
"@tldraw/tlschema": "^3.4.1",
|
||||||
"@types/markdown-it": "^14.1.1",
|
"@types/markdown-it": "^14.1.1",
|
||||||
"@vercel/analytics": "^1.2.2",
|
"@vercel/analytics": "^1.2.2",
|
||||||
"@whereby.com/browser-sdk": "^3.9.2",
|
|
||||||
"cloudflare-workers-unfurl": "^0.0.7",
|
"cloudflare-workers-unfurl": "^0.0.7",
|
||||||
"crdts": "^0.2.0",
|
"crdts": "^0.2.0",
|
||||||
"gray-matter": "^4.0.3",
|
"gray-matter": "^4.0.3",
|
||||||
|
|
@ -35,13 +34,13 @@
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-error-boundary": "^4.1.2",
|
"react-error-boundary": "^4.1.2",
|
||||||
"react-router-dom": "^6.22.3",
|
"react-router-dom": "^6.22.3",
|
||||||
"tldraw": "^2.4.6",
|
"tldraw": "^3.4.1",
|
||||||
"use-local-storage-state": "^19.5.0",
|
"use-local-storage-state": "^19.5.0",
|
||||||
"vercel": "^39.1.1"
|
"vercel": "^39.1.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "1.4.1",
|
"@biomejs/biome": "1.4.1",
|
||||||
"@cloudflare/types": "^6.29.1",
|
"@cloudflare/types": "^6.0.0",
|
||||||
"@cloudflare/workers-types": "^4.20240821.1",
|
"@cloudflare/workers-types": "^4.20240821.1",
|
||||||
"@types/lodash.throttle": "^4",
|
"@types/lodash.throttle": "^4",
|
||||||
"@types/react": "^18.2.15",
|
"@types/react": "^18.2.15",
|
||||||
|
|
|
||||||
44
src/App.tsx
44
src/App.tsx
|
|
@ -16,10 +16,12 @@ import { Inbox } from './components/Inbox';
|
||||||
import { Books } from './components/Books';
|
import { Books } from './components/Books';
|
||||||
import {
|
import {
|
||||||
BindingUtil,
|
BindingUtil,
|
||||||
|
Editor,
|
||||||
IndexKey,
|
IndexKey,
|
||||||
TLBaseBinding,
|
TLBaseBinding,
|
||||||
TLBaseShape,
|
TLBaseShape,
|
||||||
Tldraw,
|
Tldraw,
|
||||||
|
TLShapeId,
|
||||||
} from 'tldraw';
|
} from 'tldraw';
|
||||||
import { components, uiOverrides } from './ui-overrides';
|
import { components, uiOverrides } from './ui-overrides';
|
||||||
import { ChatBoxShape } from './shapes/ChatBoxShapeUtil';
|
import { ChatBoxShape } from './shapes/ChatBoxShapeUtil';
|
||||||
|
|
@ -58,12 +60,12 @@ export default function InteractiveShapeExample() {
|
||||||
return (
|
return (
|
||||||
<div className="tldraw__editor">
|
<div className="tldraw__editor">
|
||||||
<Tldraw
|
<Tldraw
|
||||||
|
shapeUtils={customShapeUtils}
|
||||||
shapeUtils={customShapeUtils} // Use custom shape utils
|
tools={customTools}
|
||||||
tools={customTools} // Pass in the array of custom tool classes
|
|
||||||
overrides={uiOverrides}
|
overrides={uiOverrides}
|
||||||
components={components}
|
components={components}
|
||||||
onMount={(editor) => {
|
onMount={(editor) => {
|
||||||
|
handleInitialShapeLoad(editor);
|
||||||
editor.createShape({ type: 'my-interactive-shape', x: 100, y: 100 });
|
editor.createShape({ type: 'my-interactive-shape', x: 100, y: 100 });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
@ -71,7 +73,41 @@ export default function InteractiveShapeExample() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ... existing code ...
|
// Add this function before or after InteractiveShapeExample
|
||||||
|
const handleInitialShapeLoad = (editor: Editor) => {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
const shapeId = url.searchParams.get('shapeId') || url.searchParams.get('frameId');
|
||||||
|
const x = url.searchParams.get('x');
|
||||||
|
const y = url.searchParams.get('y');
|
||||||
|
const zoom = url.searchParams.get('zoom');
|
||||||
|
|
||||||
|
if (shapeId) {
|
||||||
|
console.log('Found shapeId in URL:', shapeId);
|
||||||
|
const shape = editor.getShape(shapeId as TLShapeId);
|
||||||
|
|
||||||
|
if (shape) {
|
||||||
|
console.log('Found shape:', shape);
|
||||||
|
if (x && y && zoom) {
|
||||||
|
console.log('Setting camera to:', { x, y, zoom });
|
||||||
|
editor.setCamera({
|
||||||
|
x: parseFloat(x),
|
||||||
|
y: parseFloat(y),
|
||||||
|
z: parseFloat(zoom)
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.log('Zooming to shape bounds');
|
||||||
|
editor.zoomToBounds(editor.getShapeGeometry(shape).bounds, {
|
||||||
|
targetZoom: 1,
|
||||||
|
//padding: 32
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn('Shape not found in the editor');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn('No shapeId found in the URL');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root")!).render(<App />);
|
ReactDOM.createRoot(document.getElementById("root")!).render(<App />);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,9 @@ import {
|
||||||
TLBookmarkAsset,
|
TLBookmarkAsset,
|
||||||
TLRecord,
|
TLRecord,
|
||||||
Tldraw,
|
Tldraw,
|
||||||
|
Editor,
|
||||||
|
TLFrameShape,
|
||||||
|
TLUiEventSource,
|
||||||
} from 'tldraw'
|
} from 'tldraw'
|
||||||
import { useParams } from 'react-router-dom'
|
import { useParams } from 'react-router-dom'
|
||||||
import useLocalStorageState from 'use-local-storage-state'
|
import useLocalStorageState from 'use-local-storage-state'
|
||||||
|
|
@ -20,6 +23,7 @@ import { EmbedTool } from '@/tools/EmbedTool'
|
||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { ChatBox } from '@/shapes/ChatBoxShapeUtil';
|
import { ChatBox } from '@/shapes/ChatBoxShapeUtil';
|
||||||
import { components, uiOverrides } from '@/ui-overrides'
|
import { components, uiOverrides } from '@/ui-overrides'
|
||||||
|
import { useCameraControls } from '@/hooks/useCameraControls'
|
||||||
|
|
||||||
//const WORKER_URL = `https://jeffemmett-canvas.jeffemmett.workers.dev`
|
//const WORKER_URL = `https://jeffemmett-canvas.jeffemmett.workers.dev`
|
||||||
export const WORKER_URL = 'https://jeffemmett-canvas.jeffemmett.workers.dev';
|
export const WORKER_URL = 'https://jeffemmett-canvas.jeffemmett.workers.dev';
|
||||||
|
|
@ -37,16 +41,44 @@ export function Board() {
|
||||||
const { slug } = useParams<{ slug: string }>();
|
const { slug } = useParams<{ slug: string }>();
|
||||||
const roomId = slug || 'default-room';
|
const roomId = slug || 'default-room';
|
||||||
const { store } = usePersistentBoard(roomId);
|
const { store } = usePersistentBoard(roomId);
|
||||||
|
const [editor, setEditor] = useState<Editor | null>(null)
|
||||||
|
const { zoomToFrame, copyFrameLink, copyLocationLink } = useCameraControls(editor)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ position: 'fixed', inset: 0 }}>
|
<div style={{ position: 'fixed', inset: 0 }}>
|
||||||
<Tldraw
|
<Tldraw
|
||||||
store={store}
|
store={store}
|
||||||
shapeUtils={shapeUtils}
|
shapeUtils={shapeUtils}
|
||||||
overrides={uiOverrides}
|
overrides={{
|
||||||
|
...uiOverrides,
|
||||||
|
tools: (_editor, baseTools) => ({
|
||||||
|
...baseTools,
|
||||||
|
frame: {
|
||||||
|
...baseTools.frame,
|
||||||
|
contextMenu: (shape: TLFrameShape) => [
|
||||||
|
{
|
||||||
|
id: 'copy-frame-link',
|
||||||
|
label: 'Copy Frame Link',
|
||||||
|
onSelect: () => copyFrameLink(shape.id),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'zoom-to-frame',
|
||||||
|
label: 'Zoom to Frame',
|
||||||
|
onSelect: () => zoomToFrame(shape.id),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'copy-location-link',
|
||||||
|
label: 'Copy Location Link',
|
||||||
|
onSelect: () => copyLocationLink(),
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}}
|
||||||
components={components}
|
components={components}
|
||||||
tools={tools}
|
tools={tools}
|
||||||
onMount={(editor) => {
|
onMount={(editor) => {
|
||||||
|
setEditor(editor)
|
||||||
editor.registerExternalAssetHandler('url', unfurlBookmarkUrl)
|
editor.registerExternalAssetHandler('url', unfurlBookmarkUrl)
|
||||||
editor.setCurrentTool('hand')
|
editor.setCurrentTool('hand')
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ const components: TLUiComponents = {
|
||||||
PageMenu: null,
|
PageMenu: null,
|
||||||
NavigationPanel: null,
|
NavigationPanel: null,
|
||||||
DebugMenu: null,
|
DebugMenu: null,
|
||||||
ContextMenu: null,
|
//ContextMenu: null,
|
||||||
ActionsMenu: null,
|
ActionsMenu: null,
|
||||||
QuickActions: null,
|
QuickActions: null,
|
||||||
MainMenu: null,
|
MainMenu: null,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { Editor, TLFrameShape, TLParentId } from 'tldraw';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
|
||||||
|
export function useCameraControls(editor: Editor | null) {
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editor) return;
|
||||||
|
|
||||||
|
const frameId = searchParams.get('frameId');
|
||||||
|
const x = searchParams.get('x');
|
||||||
|
const y = searchParams.get('y');
|
||||||
|
const zoom = searchParams.get('zoom');
|
||||||
|
|
||||||
|
console.log('Loading camera position:', { frameId, x, y, zoom });
|
||||||
|
|
||||||
|
if (x && y && zoom) {
|
||||||
|
editor.setCamera({
|
||||||
|
x: parseFloat(x),
|
||||||
|
y: parseFloat(y),
|
||||||
|
z: parseFloat(zoom)
|
||||||
|
});
|
||||||
|
console.log('Camera position set from URL params');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!frameId) return;
|
||||||
|
|
||||||
|
const frame = editor.getShape(frameId as TLParentId) as TLFrameShape;
|
||||||
|
if (!frame) {
|
||||||
|
console.warn('Frame not found:', frameId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
editor.zoomToBounds(
|
||||||
|
editor.getShapePageBounds(frame)!,
|
||||||
|
{
|
||||||
|
inset: 32,
|
||||||
|
targetZoom: editor.getCamera().z,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const newUrl = new URL(window.location.href);
|
||||||
|
newUrl.searchParams.set('frameId', frameId);
|
||||||
|
window.history.replaceState(null, '', newUrl.toString());
|
||||||
|
}, [editor, searchParams]);
|
||||||
|
|
||||||
|
const copyLocationLink = () => {
|
||||||
|
if (!editor) return;
|
||||||
|
const camera = editor.getCamera();
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.set('x', camera.x.toString());
|
||||||
|
url.searchParams.set('y', camera.y.toString());
|
||||||
|
url.searchParams.set('zoom', camera.z.toString());
|
||||||
|
console.log('Copying location link:', url.toString());
|
||||||
|
navigator.clipboard.writeText(url.toString());
|
||||||
|
};
|
||||||
|
|
||||||
|
const zoomToFrame = (frameId: string) => {
|
||||||
|
if (!editor) return;
|
||||||
|
|
||||||
|
const frame = editor.getShape(frameId as TLParentId) as TLFrameShape;
|
||||||
|
if (!frame) {
|
||||||
|
console.warn('Frame not found:', frameId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
editor.zoomToBounds(
|
||||||
|
editor.getShapePageBounds(frame)!,
|
||||||
|
{
|
||||||
|
inset: 32,
|
||||||
|
targetZoom: editor.getCamera().z,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyFrameLink = (frameId: string) => {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.set('frameId', frameId);
|
||||||
|
console.log('Copying frame link:', url.toString());
|
||||||
|
navigator.clipboard.writeText(url.toString());
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
zoomToFrame,
|
||||||
|
copyFrameLink,
|
||||||
|
copyLocationLink
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { BaseBoxShapeUtil, TLBaseShape } from "tldraw";
|
import { BaseBoxShapeUtil, TLBaseShape } from "tldraw";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { WORKER_URL } from '../components/Board';
|
||||||
const CORS_PROXY = 'https://cors-anywhere.herokuapp.com/';
|
|
||||||
|
|
||||||
export type IVideoChatShape = TLBaseShape<
|
export type IVideoChatShape = TLBaseShape<
|
||||||
'VideoChat',
|
'VideoChat',
|
||||||
|
|
@ -13,11 +12,13 @@ export type IVideoChatShape = TLBaseShape<
|
||||||
}
|
}
|
||||||
>;
|
>;
|
||||||
|
|
||||||
const WHEREBY_API_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2FjY291bnRzLmFwcGVhci5pbiIsImF1ZCI6Imh0dHBzOi8vYXBpLmFwcGVhci5pbi92MSIsImV4cCI6OTAwNzE5OTI1NDc0MDk5MSwiaWF0IjoxNzI5MTkzOTE3LCJvcmdhbml6YXRpb25JZCI6MjY2MDk5LCJqdGkiOiI0MzI0MmUxMC1kZmRjLTRhYmEtYjlhOS01ZjcwNTFlMTYwZjAifQ.RaxXpZKYl_dOWyoATQZrzyMR2XRh3fHf02mALQiuTTs'; // Replace with your actual API key
|
|
||||||
|
|
||||||
export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
|
export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
|
||||||
static override type = 'VideoChat';
|
static override type = 'VideoChat';
|
||||||
|
|
||||||
|
indicator(_shape: IVideoChatShape) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
getDefaultProps(): IVideoChatShape['props'] {
|
getDefaultProps(): IVideoChatShape['props'] {
|
||||||
return {
|
return {
|
||||||
roomUrl: null,
|
roomUrl: null,
|
||||||
|
|
@ -27,55 +28,34 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
indicator(shape: IVideoChatShape) {
|
|
||||||
return <rect x={0} y={0} width={shape.props.w} height={shape.props.h} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
async ensureRoomExists(shape: IVideoChatShape) {
|
async ensureRoomExists(shape: IVideoChatShape) {
|
||||||
|
|
||||||
if (shape.props.roomUrl !== null) {
|
if (shape.props.roomUrl !== null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const expiryDate = new Date(Date.now() + 1000 * 24 * 60 * 60 * 1000);
|
const response = await fetch(`${WORKER_URL}/daily/rooms`, {
|
||||||
|
|
||||||
const response = await fetch(`${CORS_PROXY}https://api.whereby.dev/v1/meetings`, {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${WHEREBY_API_KEY}`,
|
'Content-Type': 'application/json'
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-Requested-With': 'XMLHttpRequest', // Required by some CORS proxies
|
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
isLocked: false,
|
properties: {
|
||||||
roomMode: 'normal',
|
enable_recording: true,
|
||||||
endDate: expiryDate.toISOString(),
|
max_participants: 8
|
||||||
fields: ['hostRoomUrl'],
|
}
|
||||||
}),
|
})
|
||||||
}).catch((error) => {
|
|
||||||
console.error('Failed to create meeting:', error);
|
|
||||||
throw error;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json();
|
|
||||||
console.error('Whereby API error:', errorData);
|
|
||||||
throw new Error(`Whereby API error: ${(errorData as any).message || 'Unknown error'}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
const roomUrl = (data as any).roomUrl;
|
|
||||||
|
|
||||||
console.log('This is your roomUrl 3:', roomUrl);
|
|
||||||
|
|
||||||
this.editor.updateShape<IVideoChatShape>({
|
this.editor.updateShape<IVideoChatShape>({
|
||||||
id: shape.id,
|
id: shape.id,
|
||||||
type: 'VideoChat',
|
type: 'VideoChat',
|
||||||
props: {
|
props: {
|
||||||
...shape.props,
|
...shape.props,
|
||||||
roomUrl
|
roomUrl: (data as any).url
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
component(shape: IVideoChatShape) {
|
component(shape: IVideoChatShape) {
|
||||||
|
|
@ -84,34 +64,26 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Load the Whereby SDK only in the browser
|
if (isInRoom && shape.props.roomUrl) {
|
||||||
if (typeof window !== 'undefined') {
|
const script = document.createElement('script');
|
||||||
import("@whereby.com/browser-sdk/embed").then(() => {
|
script.src = 'https://www.daily.co/static/call-machine.js';
|
||||||
joinRoom();
|
document.body.appendChild(script);
|
||||||
}).catch(err => {
|
|
||||||
console.error("Error loading Whereby SDK:", err);
|
|
||||||
setError("Failed to load video chat component.");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const joinRoom = async () => {
|
script.onload = () => {
|
||||||
setError("");
|
// @ts-ignore
|
||||||
setIsLoading(true);
|
window.DailyIframe.createFrame({
|
||||||
try {
|
iframeStyle: {
|
||||||
await this.ensureRoomExists(shape);
|
width: '100%',
|
||||||
setIsInRoom(true);
|
height: '100%',
|
||||||
} catch (e) {
|
border: '0',
|
||||||
console.error("Error joining room:", e);
|
borderRadius: '4px'
|
||||||
setError("An error occurred. Please try again.");
|
},
|
||||||
|
showLeaveButton: true,
|
||||||
|
showFullscreenButton: true
|
||||||
|
}).join({ url: shape.props.roomUrl });
|
||||||
|
};
|
||||||
}
|
}
|
||||||
setIsLoading(false);
|
}, [isInRoom, shape.props.roomUrl]);
|
||||||
};
|
|
||||||
|
|
||||||
const leaveRoom = () => {
|
|
||||||
setIsInRoom(false);
|
|
||||||
// setRoomUrl(""); // Clear the room URL
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
|
|
@ -122,45 +94,25 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
|
||||||
top: '10px',
|
top: '10px',
|
||||||
left: '10px',
|
left: '10px',
|
||||||
zIndex: 9999,
|
zIndex: 9999,
|
||||||
padding: '15px', // Increased padding by 5px
|
padding: '15px',
|
||||||
margin: 0,
|
backgroundColor: '#F0F0F0',
|
||||||
backgroundColor: '#F0F0F0', // Light gray background
|
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
|
||||||
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)', // Added drop shadow
|
borderRadius: '4px',
|
||||||
borderRadius: '4px', // Slight border radius for softer look
|
|
||||||
}}>
|
}}>
|
||||||
<div style={{
|
{!isInRoom ? (
|
||||||
width: '100%',
|
<button
|
||||||
height: '100%',
|
onClick={() => setIsInRoom(true)}
|
||||||
border: '1px solid #D3D3D3',
|
className="bg-blue-500 text-white px-4 py-2 rounded"
|
||||||
backgroundColor: '#FFFFFF',
|
>
|
||||||
display: 'flex',
|
Join Room
|
||||||
justifyContent: 'center',
|
</button>
|
||||||
alignItems: 'center',
|
) : (
|
||||||
overflow: 'hidden',
|
<div id="daily-call-iframe-container" style={{
|
||||||
}}>
|
width: '100%',
|
||||||
{isLoading ? (
|
height: '100%'
|
||||||
<p>Joining room...</p>
|
}} />
|
||||||
) : isInRoom && shape.props.roomUrl && typeof window !== 'undefined' ? (
|
)}
|
||||||
<div className="mb-4" style={{ width: '100%', height: '100%', objectFit: 'contain' }}>
|
{error && <p className="text-red-500 mt-2">{error}</p>}
|
||||||
<whereby-embed
|
|
||||||
room={shape.props.roomUrl}
|
|
||||||
background="off"
|
|
||||||
logo="off"
|
|
||||||
chat="off"
|
|
||||||
screenshare="on"
|
|
||||||
people="on"
|
|
||||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
|
||||||
></whereby-embed>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div>
|
|
||||||
<button onClick={joinRoom} className="bg-blue-500 text-white px-4 py-2 rounded">
|
|
||||||
Join Room
|
|
||||||
</button>
|
|
||||||
{error && <p className="text-red-500 mt-2">{error}</p>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,277 @@ import {
|
||||||
TLComponents,
|
TLComponents,
|
||||||
TLUiOverrides,
|
TLUiOverrides,
|
||||||
TldrawUiMenuItem,
|
TldrawUiMenuItem,
|
||||||
useIsToolSelected,
|
useEditor,
|
||||||
useTools,
|
useTools,
|
||||||
|
TLShapeId,
|
||||||
|
DefaultContextMenu,
|
||||||
|
DefaultContextMenuContent,
|
||||||
|
TLUiContextMenuProps,
|
||||||
|
TldrawUiMenuGroup,
|
||||||
|
TLShape,
|
||||||
} from 'tldraw'
|
} from 'tldraw'
|
||||||
import { CustomMainMenu } from './components/CustomMainMenu'
|
import { CustomMainMenu } from './components/CustomMainMenu'
|
||||||
import { EmbedShape } from './shapes/EmbedShapeUtil'
|
import { Editor } from 'tldraw'
|
||||||
|
|
||||||
|
let cameraHistory: { x: number; y: number; z: number }[] = [];
|
||||||
|
const MAX_HISTORY = 10; // Keep last 10 camera positions
|
||||||
|
|
||||||
|
// Helper function to store camera position
|
||||||
|
const storeCameraPosition = (editor: Editor) => {
|
||||||
|
const currentCamera = editor.getCamera();
|
||||||
|
// Only store if there's a meaningful change from the last position
|
||||||
|
const lastPosition = cameraHistory[cameraHistory.length - 1];
|
||||||
|
if (!lastPosition ||
|
||||||
|
Math.abs(lastPosition.x - currentCamera.x) > 1 ||
|
||||||
|
Math.abs(lastPosition.y - currentCamera.y) > 1 ||
|
||||||
|
Math.abs(lastPosition.z - currentCamera.z) > 0.1) {
|
||||||
|
|
||||||
|
cameraHistory.push({ ...currentCamera });
|
||||||
|
if (cameraHistory.length > MAX_HISTORY) {
|
||||||
|
cameraHistory.shift();
|
||||||
|
}
|
||||||
|
console.log('Stored camera position:', currentCamera);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyFrameLink = async (editor: Editor, frameId: string) => {
|
||||||
|
console.log('Starting copyFrameLink with frameId:', frameId);
|
||||||
|
|
||||||
|
if (!editor.store.getSnapshot()) {
|
||||||
|
console.warn('Store not ready');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const baseUrl = `${window.location.origin}${window.location.pathname}`;
|
||||||
|
console.log('Base URL:', baseUrl);
|
||||||
|
|
||||||
|
const url = new URL(baseUrl);
|
||||||
|
url.searchParams.set('frameId', frameId);
|
||||||
|
|
||||||
|
const frame = editor.getShape(frameId as TLShapeId);
|
||||||
|
console.log('Found frame:', frame);
|
||||||
|
|
||||||
|
if (frame) {
|
||||||
|
const camera = editor.getCamera();
|
||||||
|
console.log('Camera position:', { x: camera.x, y: camera.y, zoom: camera.z });
|
||||||
|
|
||||||
|
url.searchParams.set('x', camera.x.toString());
|
||||||
|
url.searchParams.set('y', camera.y.toString());
|
||||||
|
url.searchParams.set('zoom', camera.z.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalUrl = url.toString();
|
||||||
|
console.log('Final URL to copy:', finalUrl);
|
||||||
|
|
||||||
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
|
console.log('Using modern clipboard API...');
|
||||||
|
await navigator.clipboard.writeText(finalUrl);
|
||||||
|
console.log('URL copied successfully using clipboard API');
|
||||||
|
} else {
|
||||||
|
console.log('Falling back to legacy clipboard method...');
|
||||||
|
const textArea = document.createElement('textarea');
|
||||||
|
textArea.value = finalUrl;
|
||||||
|
document.body.appendChild(textArea);
|
||||||
|
textArea.select();
|
||||||
|
document.execCommand('copy');
|
||||||
|
document.body.removeChild(textArea);
|
||||||
|
console.log('URL copied successfully using fallback method');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to copy to clipboard:', error);
|
||||||
|
alert('Failed to copy link. Please check clipboard permissions.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const zoomToShape = (editor: Editor) => {
|
||||||
|
// Store camera position before zooming
|
||||||
|
storeCameraPosition(editor);
|
||||||
|
|
||||||
|
// Get all selected shape IDs
|
||||||
|
const selectedIds = editor.getSelectedShapeIds();
|
||||||
|
if (selectedIds.length === 0) return;
|
||||||
|
|
||||||
|
// Get the common bounds that encompass all selected shapes
|
||||||
|
const commonBounds = editor.getSelectionPageBounds();
|
||||||
|
if (!commonBounds) return;
|
||||||
|
|
||||||
|
// Calculate viewport dimensions
|
||||||
|
const viewportPageBounds = editor.getViewportPageBounds();
|
||||||
|
|
||||||
|
// Calculate the ratio of selection size to viewport size
|
||||||
|
const widthRatio = commonBounds.width / viewportPageBounds.width;
|
||||||
|
const heightRatio = commonBounds.height / viewportPageBounds.height;
|
||||||
|
|
||||||
|
// Calculate target zoom based on selection size
|
||||||
|
let targetZoom;
|
||||||
|
if (widthRatio < 0.1 || heightRatio < 0.1) {
|
||||||
|
// For very small selections, zoom in up to 8x
|
||||||
|
targetZoom = Math.min(
|
||||||
|
(viewportPageBounds.width * 0.8) / commonBounds.width,
|
||||||
|
(viewportPageBounds.height * 0.8) / commonBounds.height,
|
||||||
|
8 // Max zoom of 8x for small selections
|
||||||
|
);
|
||||||
|
} else if (widthRatio > 1 || heightRatio > 1) {
|
||||||
|
// For selections larger than viewport, zoom out more
|
||||||
|
targetZoom = Math.min(
|
||||||
|
(viewportPageBounds.width * 0.7) / commonBounds.width,
|
||||||
|
(viewportPageBounds.height * 0.7) / commonBounds.height,
|
||||||
|
0.125 // Min zoom of 1/8x for large selections (reciprocal of 8)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// For medium-sized selections, allow up to 4x zoom
|
||||||
|
targetZoom = Math.min(
|
||||||
|
(viewportPageBounds.width * 0.8) / commonBounds.width,
|
||||||
|
(viewportPageBounds.height * 0.8) / commonBounds.height,
|
||||||
|
4 // Medium zoom level
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zoom to the common bounds
|
||||||
|
editor.zoomToBounds(commonBounds, {
|
||||||
|
targetZoom,
|
||||||
|
inset: widthRatio > 1 || heightRatio > 1 ? 20 : 50, // Less padding for large selections
|
||||||
|
animation: {
|
||||||
|
duration: 400,
|
||||||
|
easing: (t) => t * (2 - t)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update URL with new camera position and first selected shape ID
|
||||||
|
const newCamera = editor.getCamera();
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.set('shapeId', selectedIds[0].toString());
|
||||||
|
url.searchParams.set('x', newCamera.x.toString());
|
||||||
|
url.searchParams.set('y', newCamera.y.toString());
|
||||||
|
url.searchParams.set('zoom', newCamera.z.toString());
|
||||||
|
window.history.replaceState(null, '', url.toString());
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyLinkToCurrentView = async (editor: Editor) => {
|
||||||
|
console.log('Starting copyLinkToCurrentView');
|
||||||
|
|
||||||
|
if (!editor.store.getSnapshot()) {
|
||||||
|
console.warn('Store not ready');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const baseUrl = `${window.location.origin}${window.location.pathname}`;
|
||||||
|
console.log('Base URL:', baseUrl);
|
||||||
|
|
||||||
|
const url = new URL(baseUrl);
|
||||||
|
const camera = editor.getCamera();
|
||||||
|
console.log('Current camera position:', { x: camera.x, y: camera.y, zoom: camera.z });
|
||||||
|
|
||||||
|
// Set camera parameters
|
||||||
|
url.searchParams.set('x', camera.x.toString());
|
||||||
|
url.searchParams.set('y', camera.y.toString());
|
||||||
|
url.searchParams.set('zoom', camera.z.toString());
|
||||||
|
|
||||||
|
const finalUrl = url.toString();
|
||||||
|
console.log('Final URL to copy:', finalUrl);
|
||||||
|
|
||||||
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
|
console.log('Using modern clipboard API...');
|
||||||
|
await navigator.clipboard.writeText(finalUrl);
|
||||||
|
console.log('URL copied successfully using clipboard API');
|
||||||
|
} else {
|
||||||
|
console.log('Falling back to legacy clipboard method...');
|
||||||
|
const textArea = document.createElement('textarea');
|
||||||
|
textArea.value = finalUrl;
|
||||||
|
document.body.appendChild(textArea);
|
||||||
|
textArea.select();
|
||||||
|
document.execCommand('copy');
|
||||||
|
document.body.removeChild(textArea);
|
||||||
|
console.log('URL copied successfully using fallback method');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to copy to clipboard:', error);
|
||||||
|
alert('Failed to copy link. Please check clipboard permissions.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const revertCamera = (editor: Editor) => {
|
||||||
|
if (cameraHistory.length > 0) {
|
||||||
|
const previousCamera = cameraHistory.pop();
|
||||||
|
if (previousCamera) {
|
||||||
|
// Get current viewport bounds
|
||||||
|
const viewportPageBounds = editor.getViewportPageBounds();
|
||||||
|
|
||||||
|
// Create bounds that center on the previous camera position
|
||||||
|
const targetBounds = {
|
||||||
|
x: previousCamera.x - (viewportPageBounds.width / 2) / previousCamera.z,
|
||||||
|
y: previousCamera.y - (viewportPageBounds.height / 2) / previousCamera.z,
|
||||||
|
w: viewportPageBounds.width / previousCamera.z,
|
||||||
|
h: viewportPageBounds.height / previousCamera.z,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Use the same zoom animation as zoomToShape
|
||||||
|
editor.zoomToBounds(targetBounds, {
|
||||||
|
targetZoom: previousCamera.z,
|
||||||
|
animation: {
|
||||||
|
duration: 400,
|
||||||
|
easing: (t) => t * (2 - t)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Reverted to camera position:', previousCamera);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('No camera history available');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function CustomContextMenu(props: TLUiContextMenuProps) {
|
||||||
|
const editor = useEditor()
|
||||||
|
const hasSelection = editor.getSelectedShapeIds().length > 0
|
||||||
|
const selectedShape = editor.getSelectedShapes()[0]
|
||||||
|
const hasCameraHistory = cameraHistory.length > 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DefaultContextMenu {...props}>
|
||||||
|
<TldrawUiMenuGroup id="camera-actions">
|
||||||
|
<TldrawUiMenuItem
|
||||||
|
id="revert-camera"
|
||||||
|
label="Revert Camera"
|
||||||
|
icon="undo"
|
||||||
|
kbd="b"
|
||||||
|
readonlyOk
|
||||||
|
disabled={!hasCameraHistory}
|
||||||
|
onSelect={() => {
|
||||||
|
console.log('Reverting camera');
|
||||||
|
revertCamera(editor);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<TldrawUiMenuItem
|
||||||
|
id="zoom-to-shape"
|
||||||
|
label="Zoom to Selection"
|
||||||
|
icon="zoom-in"
|
||||||
|
kbd="z"
|
||||||
|
readonlyOk
|
||||||
|
disabled={!hasSelection}
|
||||||
|
onSelect={() => {
|
||||||
|
console.log('Zoom to Selection clicked');
|
||||||
|
zoomToShape(editor);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<TldrawUiMenuItem
|
||||||
|
id="copy-link-to-current-view"
|
||||||
|
label="Copy Link to Current View"
|
||||||
|
icon="link"
|
||||||
|
kbd="c"
|
||||||
|
readonlyOk
|
||||||
|
onSelect={() => {
|
||||||
|
console.log('Copy Link to Current View clicked');
|
||||||
|
copyLinkToCurrentView(editor);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</TldrawUiMenuGroup>
|
||||||
|
<DefaultContextMenuContent />
|
||||||
|
</DefaultContextMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export const uiOverrides: TLUiOverrides = {
|
export const uiOverrides: TLUiOverrides = {
|
||||||
tools(editor, tools) {
|
tools(editor, tools) {
|
||||||
|
|
@ -17,6 +283,7 @@ export const uiOverrides: TLUiOverrides = {
|
||||||
icon: 'color',
|
icon: 'color',
|
||||||
label: 'Video',
|
label: 'Video',
|
||||||
kbd: 'x',
|
kbd: 'x',
|
||||||
|
meta: {},
|
||||||
onSelect: () => {
|
onSelect: () => {
|
||||||
editor.setCurrentTool('VideoChat')
|
editor.setCurrentTool('VideoChat')
|
||||||
},
|
},
|
||||||
|
|
@ -26,6 +293,7 @@ export const uiOverrides: TLUiOverrides = {
|
||||||
icon: 'color',
|
icon: 'color',
|
||||||
label: 'Chat',
|
label: 'Chat',
|
||||||
kbd: 'x',
|
kbd: 'x',
|
||||||
|
meta: {},
|
||||||
onSelect: () => {
|
onSelect: () => {
|
||||||
editor.setCurrentTool('ChatBox')
|
editor.setCurrentTool('ChatBox')
|
||||||
},
|
},
|
||||||
|
|
@ -35,28 +303,140 @@ export const uiOverrides: TLUiOverrides = {
|
||||||
icon: 'embed',
|
icon: 'embed',
|
||||||
label: 'Embed',
|
label: 'Embed',
|
||||||
kbd: 'e',
|
kbd: 'e',
|
||||||
|
meta: {},
|
||||||
onSelect: () => {
|
onSelect: () => {
|
||||||
editor.setCurrentTool('Embed')
|
editor.setCurrentTool('Embed')
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
return tools
|
return tools
|
||||||
},
|
},
|
||||||
|
actions(editor, actions) {
|
||||||
|
actions['copyFrameLink'] = {
|
||||||
|
id: 'copy-frame-link',
|
||||||
|
label: 'Copy Frame Link',
|
||||||
|
onSelect: () => {
|
||||||
|
const shape = editor.getSelectedShapes()[0]
|
||||||
|
if (shape && shape.type === 'frame') {
|
||||||
|
copyFrameLink(editor, shape.id)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
readonlyOk: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
actions['zoomToFrame'] = {
|
||||||
|
id: 'zoom-to-frame',
|
||||||
|
label: 'Zoom to Frame',
|
||||||
|
onSelect: () => {
|
||||||
|
const shape = editor.getSelectedShapes()[0]
|
||||||
|
if (shape && shape.type === 'frame') {
|
||||||
|
zoomToShape(editor)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
readonlyOk: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
actions['copyLinkToCurrentView'] = {
|
||||||
|
id: 'copy-link-to-current-view',
|
||||||
|
label: 'Copy Link to Current View',
|
||||||
|
kbd: 'c',
|
||||||
|
onSelect: () => {
|
||||||
|
console.log('Creating link to current view');
|
||||||
|
copyLinkToCurrentView(editor);
|
||||||
|
},
|
||||||
|
readonlyOk: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
actions['zoomToShape'] = {
|
||||||
|
id: 'zoom-to-shape',
|
||||||
|
label: 'Zoom to Selection',
|
||||||
|
kbd: 'z',
|
||||||
|
onSelect: () => {
|
||||||
|
if (editor.getSelectedShapeIds().length > 0) {
|
||||||
|
console.log('Zooming to selection');
|
||||||
|
zoomToShape(editor);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
readonlyOk: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
actions['revertCamera'] = {
|
||||||
|
id: 'revert-camera',
|
||||||
|
label: 'Revert Camera',
|
||||||
|
kbd: 'b',
|
||||||
|
onSelect: () => {
|
||||||
|
console.log('Reverting camera position');
|
||||||
|
revertCamera(editor);
|
||||||
|
},
|
||||||
|
readonlyOk: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
return actions
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const components: TLComponents = {
|
export const components: TLComponents = {
|
||||||
Toolbar: (props) => {
|
Toolbar: function Toolbar() {
|
||||||
|
const editor = useEditor()
|
||||||
const tools = useTools()
|
const tools = useTools()
|
||||||
const isChatBoxSelected = useIsToolSelected(tools['ChatBox'])
|
|
||||||
const isVideoSelected = useIsToolSelected(tools['VideoChat'])
|
|
||||||
const isEmbedSelected = useIsToolSelected(tools['Embed'])
|
|
||||||
return (
|
return (
|
||||||
<DefaultToolbar {...props}>
|
<DefaultToolbar>
|
||||||
<TldrawUiMenuItem {...tools['VideoChat']} isSelected={isVideoSelected} />
|
{tools['VideoChat'] && (
|
||||||
<TldrawUiMenuItem {...tools['ChatBox']} isSelected={isChatBoxSelected} />
|
<TldrawUiMenuItem
|
||||||
<TldrawUiMenuItem {...tools['Embed']} isSelected={isEmbedSelected} />
|
{...tools['VideoChat']}
|
||||||
|
isSelected={tools['VideoChat'].id === editor.getCurrentToolId()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{tools['ChatBox'] && (
|
||||||
|
<TldrawUiMenuItem
|
||||||
|
{...tools['ChatBox']}
|
||||||
|
isSelected={tools['ChatBox'].id === editor.getCurrentToolId()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{tools['Embed'] && (
|
||||||
|
<TldrawUiMenuItem
|
||||||
|
{...tools['Embed']}
|
||||||
|
isSelected={tools['Embed'].id === editor.getCurrentToolId()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<DefaultToolbarContent />
|
<DefaultToolbarContent />
|
||||||
</DefaultToolbar>
|
</DefaultToolbar>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
MainMenu: CustomMainMenu,
|
MainMenu: CustomMainMenu,
|
||||||
}
|
ContextMenu: CustomContextMenu,
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleInitialShapeLoad = (editor: Editor) => {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
|
||||||
|
// Check for both shapeId and legacy frameId (for backwards compatibility)
|
||||||
|
const shapeId = url.searchParams.get('shapeId') || url.searchParams.get('frameId');
|
||||||
|
const x = url.searchParams.get('x');
|
||||||
|
const y = url.searchParams.get('y');
|
||||||
|
const zoom = url.searchParams.get('zoom');
|
||||||
|
|
||||||
|
if (shapeId) {
|
||||||
|
console.log('Found shapeId in URL:', shapeId);
|
||||||
|
const shape = editor.getShape(shapeId as TLShapeId);
|
||||||
|
|
||||||
|
if (shape) {
|
||||||
|
console.log('Found shape:', shape);
|
||||||
|
if (x && y && zoom) {
|
||||||
|
console.log('Setting camera to:', { x, y, zoom });
|
||||||
|
editor.setCamera({
|
||||||
|
x: parseFloat(x),
|
||||||
|
y: parseFloat(y),
|
||||||
|
z: parseFloat(zoom)
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.log('Zooming to shape bounds');
|
||||||
|
editor.zoomToBounds(editor.getShapeGeometry(shape).bounds, {
|
||||||
|
targetZoom: 1,
|
||||||
|
//padding: 32
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn('Shape not found:', shapeId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -5,4 +5,6 @@
|
||||||
export interface Environment {
|
export interface Environment {
|
||||||
TLDRAW_BUCKET: R2Bucket
|
TLDRAW_BUCKET: R2Bucket
|
||||||
TLDRAW_DURABLE_OBJECT: DurableObjectNamespace
|
TLDRAW_DURABLE_OBJECT: DurableObjectNamespace
|
||||||
|
DAILY_API_KEY: string;
|
||||||
|
DAILY_DOMAIN: string;
|
||||||
}
|
}
|
||||||
|
|
@ -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 our router for cloudflare
|
||||||
export default router
|
export default router
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
main = "worker/worker.ts"
|
main = "worker/worker.ts"
|
||||||
compatibility_date = "2024-07-01"
|
compatibility_date = "2024-07-01"
|
||||||
name = "jeffemmett-canvas"
|
name = "jeffemmett-canvas"
|
||||||
account_id = "0e7b3338d5278ed1b148e6456b940913"
|
account_id = "${CLOUDFLARE_ACCOUNT_ID}"
|
||||||
zone_id = "45c200f8dc2a01852e41b9bb09eb7359"
|
zone_id = "${CLOUDFLARE_ZONE_ID}"
|
||||||
|
|
||||||
[vars]
|
[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]
|
[dev]
|
||||||
port = 5172
|
port = 5172
|
||||||
|
|
@ -13,24 +15,20 @@ ip = "0.0.0.0"
|
||||||
local_protocol = "http"
|
local_protocol = "http"
|
||||||
upstream_protocol = "https"
|
upstream_protocol = "https"
|
||||||
|
|
||||||
# Set up the durable object used for each tldraw room
|
|
||||||
[durable_objects]
|
[durable_objects]
|
||||||
bindings = [
|
bindings = [
|
||||||
{ name = "TLDRAW_DURABLE_OBJECT", class_name = "TldrawDurableObject" },
|
{ name = "TLDRAW_DURABLE_OBJECT", class_name = "TldrawDurableObject" },
|
||||||
]
|
]
|
||||||
|
|
||||||
# Durable objects require migrations to create/modify/delete them
|
|
||||||
[[migrations]]
|
[[migrations]]
|
||||||
tag = "v1"
|
tag = "v1"
|
||||||
new_classes = ["TldrawDurableObject"]
|
new_classes = ["TldrawDurableObject"]
|
||||||
|
|
||||||
# We store rooms and asset uploads in an R2 bucket
|
|
||||||
[[r2_buckets]]
|
[[r2_buckets]]
|
||||||
binding = 'TLDRAW_BUCKET'
|
binding = 'TLDRAW_BUCKET'
|
||||||
bucket_name = 'jeffemmett-canvas'
|
bucket_name = '${R2_BUCKET_NAME}'
|
||||||
preview_bucket_name = 'jeffemmett-canvas-preview'
|
preview_bucket_name = '${R2_PREVIEW_BUCKET_NAME}'
|
||||||
|
|
||||||
# wrangler.toml (wrangler v3.79.0^)
|
|
||||||
[observability]
|
[observability]
|
||||||
enabled = true
|
enabled = true
|
||||||
head_sampling_rate = 1
|
head_sampling_rate = 1
|
||||||
Loading…
Reference in New Issue