Merge pull request #1 from Jeff-Emmett/Video-Chat-Attempt

Video chat attempt
This commit is contained in:
Jeff Emmett 2024-10-18 17:38:25 -04:00 committed by GitHub
commit b58d357ac1
24 changed files with 786 additions and 191 deletions

View File

@ -26,7 +26,7 @@
<meta name="twitter:card" content="summary_large_image">
<meta property="twitter:domain" content="jeffemmett.com">
<meta property="twitter:url" content="https://jeffemmett.com">
<meta name="twitter:title" content="Orion Reed">
<meta name="twitter:title" content="Jeff Emmett">
<meta name="twitter:description"
content="My research doesn't investigate the intersection of computing, human-system interfaces, and emancipatory politics. I am interested in the potential of computing as a medium for thought, as a tool for collective action, and as a means of emancipation.">
<meta name="twitter:image" content="/website-embed.png">

View File

@ -12,13 +12,13 @@
"preview": "vite preview"
},
"keywords": [],
"author": "Orion Reed",
"author": "Jeff Emmett",
"license": "ISC",
"dependencies": {
"@dimforge/rapier2d": "^0.11.2",
"@tldraw/sync": "^2.4.6",
"@tldraw/sync-core": "latest",
"@tldraw/tlschema": "latest",
"@tldraw/sync-core": "^2.4.6",
"@tldraw/tlschema": "^2.4.6",
"@types/markdown-it": "^14.1.1",
"@vercel/analytics": "^1.2.2",
"cloudflare-workers-unfurl": "^0.0.7",
@ -47,11 +47,11 @@
"eslint": "^8.38.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.3.4",
"typescript": "^5.0.2",
"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.72.3"
}
}
}

View File

@ -1,37 +1,94 @@
import { inject } from '@vercel/analytics';
import "tldraw/tldraw.css";
import "@/css/style.css"
import React, { useEffect, useState } from "react";
import { useEffect, useState } from "react";
import ReactDOM from "react-dom/client";
import { Default } from "@/components/Default";
import { Canvas } from "@/components/Canvas";
import { Toggle } from "@/components/Toggle";
import { useCanvas } from "@/hooks/useCanvas"
import { createShapes } from "@/utils";
import { createShapes } from "@/utils/utils";
import { BrowserRouter, Route, Routes } from 'react-router-dom';
import { Contact } from "@/components/Contact";
import { Post } from '@/components/Post';
import { Board } from './components/Board';
import { Inbox } from './components/Inbox';
import { Books } from './components/Books';
import {
BindingUtil,
IndexKey,
TLBaseBinding,
TLBaseShape,
Tldraw,
} from 'tldraw';
import { components, uiOverrides } from './ui-overrides';
import { ChatBoxShape } from './shapes/ChatBoxShapeUtil';
import { VideoChatShape } from './shapes/VideoChatShapeUtil';
import { ChatBoxTool } from './tools/ChatBoxTool';
import { VideoChatTool } from './tools/VideoChatTool';
inject();
// The container shapes that can contain element shapes
const CONTAINER_PADDING = 24;
type ContainerShape = TLBaseShape<'element', { height: number; width: number }>;
// ... existing code for ContainerShapeUtil ...
// The element shapes that can be placed inside the container shapes
type ElementShape = TLBaseShape<'element', { color: string }>;
// ... existing code for ElementShapeUtil ...
// The binding between the element shapes and the container shapes
type LayoutBinding = TLBaseBinding<
'layout',
{
index: IndexKey;
placeholder: boolean;
}
>;
const customShapeUtils = [ChatBoxShape, VideoChatShape];
const customTools = [ChatBoxTool, VideoChatTool];
// [2]
export default function InteractiveShapeExample() {
return (
<div className="tldraw__editor">
<Tldraw
shapeUtils={customShapeUtils} // Use custom shape utils
tools={customTools} // Pass in the array of custom tool classes
overrides={uiOverrides}
components={components}
onMount={(editor) => {
editor.createShape({ type: 'my-interactive-shape', x: 100, y: 100 });
}}
/>
</div>
);
}
// ... existing code ...
ReactDOM.createRoot(document.getElementById("root")!).render(<App />);
function App() {
return (
// <React.StrictMode>
<BrowserRouter>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/card/contact" element={<Contact />} />
<Route path="/posts/:slug" element={<Post />} />
<Route path="/board/:slug" element={<Board />} />
<Route path="/inbox" element={<Inbox />} />
<Route path="/books" element={<Books />} />
</Routes>
</BrowserRouter>
<BrowserRouter>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/card/contact" element={<Contact />} />
<Route path="/posts/:slug" element={<Post />} />
<Route path="/board/:slug" element={<Board />} />
<Route path="/inbox" element={<Inbox />} />
<Route path="/books" element={<Books />} />
</Routes>
</BrowserRouter>
// </React.StrictMode>
);
};
@ -58,5 +115,6 @@ function Home() {
<div style={{ zIndex: 999999 }} className={`${isCanvasEnabled && isEditorMounted ? 'transparent' : ''}`}>
{<Default />}
</div>
{isCanvasEnabled && elementsInfo.length > 0 ? <Canvas shapes={shapes} /> : null}</>)
{isCanvasEnabled && elementsInfo.length > 0 ? <Canvas shapes={shapes} /> : null}</>
)
}

View File

@ -2,83 +2,82 @@ import { useSync } from '@tldraw/sync'
import {
AssetRecordType,
getHashForString,
TLAssetStore,
TLBookmarkAsset,
Tldraw,
uniqueId,
} from 'tldraw'
import { useParams } from 'react-router-dom' // Add this import
import { useParams } from 'react-router-dom'
import { ChatBoxTool } from '@/tools/ChatBoxTool'
import { IChatBoxShape, ChatBoxShape } from '@/shapes/ChatBoxShape'
import { multiplayerAssetStore } from '../client/multiplayerAssetStore' // Adjusted path if necessary
import { ChatBoxShape } from '@/shapes/ChatBoxShapeUtil'
import { VideoChatTool } from '@/tools/VideoChatTool'
import { VideoChatShape } from '@/shapes/VideoChatShapeUtil'
import { multiplayerAssetStore } from '../client/multiplayerAssetStore'
import { customSchema } from '../../worker/TldrawDurableObject'
import './ChatBoxStyles.css' // Add a CSS file for styles
import React, { useState } from 'react';
import { chatBox } from '@/shapes/ChatBoxShapeUtil';
import { components, uiOverrides } from '@/ui-overrides'
const WORKER_URL = `https://jeffemmett-canvas.jeffemmett.workers.dev`
const shapeUtils = [ChatBoxShape]
const tools = [ChatBoxTool]
const shapeUtils = [ChatBoxShape, VideoChatShape]
const tools = [ChatBoxTool, VideoChatTool]; // Array of tools
export function Board() {
// Extract the slug from the URL
const { slug } = useParams<{ slug: string }>()
// Use the slug as the roomId, or fallback to 'default-room' if not provided
const roomId = slug || 'default-room'
const { slug } = useParams<{ slug: string }>(); // Ensure this is inside the Board component
const roomId = slug || 'default-room'; // Declare roomId here
// Create a store connected to multiplayer.
const store = useSync({
// Use the dynamic roomId in the URI
uri: `${WORKER_URL}/connect/${roomId}`,
// ...and how to handle static assets like images & videos
assets: multiplayerAssetStore,
shapeUtils: shapeUtils,
schema: customSchema
})
shapeUtils: shapeUtils,
schema: customSchema,
});
const [isChatBoxVisible, setChatBoxVisible] = useState(false);
const [userName, setUserName] = useState('');
const [isVideoChatVisible, setVideoChatVisible] = useState(false); // Added state for video chat visibility
const handleNameChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setUserName(event.target.value);
};
return (
<div style={{ position: 'fixed', inset: 0 }}>
<Tldraw
// we can pass the connected store into the Tldraw component which will handle
// loading states & enable multiplayer UX like cursors & a presence menu
store={store}
store={store}
shapeUtils={shapeUtils}
overrides={uiOverrides}
components={components}
tools={tools}
onMount={(editor) => {
// when the editor is ready, we need to register out bookmark unfurling service
editor.registerExternalAssetHandler('url', unfurlBookmarkUrl)
editor.createShape<IChatBoxShape>({
type: 'chatBox',
x: 0,
y: 0,
props: {
w: 200,
h: 200,
roomId: roomId,
},
})
}}
/>
{isChatBoxVisible && (
<div>
<input
type="text"
value={userName}
onChange={handleNameChange}
placeholder="Enter your name"
/>
<chatBox
userName={userName}
roomId={roomId} // Added roomId
w={200} // Set appropriate width
h={200} // Set appropriate height
/>
</div>
)}
{isVideoChatVisible && ( // Render the button to join video chat
<button onClick={() => setVideoChatVisible(false)} className="bg-green-500 text-white px-4 py-2 rounded">
Join Video Call
</button>
)}
</div>
)
}
// Assuming you have a message structure like this
interface ChatMessage {
id: string;
text: string;
isUser: boolean; // New property to identify the sender
}
// Example rendering function for messages
function renderMessage(message: ChatMessage) {
return (
<div className={message.isUser ? 'user-message' : 'other-message'}>
{message.text}
</div>
)
}
// How does our server handle bookmark unfurling?
async function unfurlBookmarkUrl({ url }: { url: string }): Promise<TLBookmarkAsset> {
const asset: TLBookmarkAsset = {

View File

@ -1,6 +1,6 @@
import { Editor, Tldraw, TLShape, TLUiComponents } from "tldraw";
import { SimController } from "@/physics/PhysicsControls";
import { HTMLShapeUtil } from "@/shapes/HTMLShapeUtil";
import { HTMLShapeUtil } from "@/utils/HTMLShapeUtil";
const components: TLUiComponents = {
HelpMenu: null,

View File

@ -66,6 +66,9 @@ export class PhysicsWorld {
this.createGroup(shape as TLGroupShape);
break;
// Add cases for any new shape types here
case "VideoChat":
this.createShape (shape as TLGeoShape);
break;
}
}
}

View File

@ -1,13 +1,14 @@
import { useEffect, useRef, useState } from "react";
import { BaseBoxShapeUtil, TLBaseBoxShape, TLBaseShape, TldrawBaseProps } from "tldraw";
import { BaseBoxShapeUtil, TLBaseShape } from "tldraw";
export type IChatBoxShape = TLBaseShape<
'chatBox',
{
w: number
h: number
'chatBox',
{
w: number
h: number
roomId: string
}
userName: string
}
>
export class ChatBoxShape extends BaseBoxShapeUtil<IChatBoxShape> {
@ -18,6 +19,7 @@ export class ChatBoxShape extends BaseBoxShapeUtil<IChatBoxShape> {
roomId: 'default-room',
w: 100,
h: 100,
userName: '',
}
}
@ -27,7 +29,7 @@ export class ChatBoxShape extends BaseBoxShapeUtil<IChatBoxShape> {
component(shape: IChatBoxShape) {
return (
<ChatBox roomId={shape.props.roomId} width={shape.props.w} height={shape.props.h} />
<chatBox roomId={shape.props.roomId} w={shape.props.w} h={shape.props.h} userName="" />
)
}
}
@ -39,11 +41,14 @@ interface Message {
timestamp: Date;
}
// Add this new component after the ChatBoxShape class
function ChatBox({ roomId, width, height }: { roomId: string, width: number, height: number }) {
// Update the chatBox component to accept userName
export const chatBox: React.FC<IChatBoxShape['props']> = ({ roomId, w, h, userName }) => {
const [messages, setMessages] = useState<Message[]>([]);
const [inputMessage, setInputMessage] = useState("");
const [username, setUsername] = useState("jeff");
const [username, setUsername] = useState(userName);
const messagesEndRef = useRef(null);
useEffect(() => {
@ -89,12 +94,12 @@ function ChatBox({ roomId, width, height }: { roomId: string, width: number, hei
};
return (
<div className="chat-container" style={{ pointerEvents: 'all', width: `${width}px`, height: `${height}px`, overflow: 'auto' }}>
<div className="chat-container" style={{ pointerEvents: 'all', width: `${w}px`, height: `${h}px`, overflow: 'auto' }}>
<div className="messages-container">
{messages.map((msg) => (
<div key={msg.id} className={`message ${msg.username === username ? 'own-message' : ''}`}>
<div className="message-header">
<strong>{msg.username}</strong>
<strong>{msg.username}</strong>
<span className="timestamp">{new Date(msg.timestamp).toLocaleTimeString()}</span>
</div>
<div className="message-content">{msg.content}</div>
@ -110,7 +115,7 @@ function ChatBox({ roomId, width, height }: { roomId: string, width: number, hei
placeholder="Type a message..."
className="message-input"
/>
<button type="submit" style={{ pointerEvents: 'all',}} onPointerDown={(e)=>e.stopPropagation()} className="send-button">Send</button>
<button type="submit" style={{ pointerEvents: 'all', }} onPointerDown={(e) => e.stopPropagation()} className="send-button">Send</button>
</form>
</div>
);
@ -118,24 +123,24 @@ function ChatBox({ roomId, width, height }: { roomId: string, width: number, hei
async function sendMessageToChat(roomId: string, username: string, content: string): Promise<void> {
const apiUrl = 'https://jeffemmett-realtimechatappwithpolling.web.val.run'; // Replace with your actual Val Town URL
try {
const response = await fetch(`${apiUrl}?action=sendMessage`, {
method: 'POST',
mode: 'no-cors',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
roomId,
username,
content,
}),
});
const result = await response.text();
console.log('Message sent successfully:', result);
const response = await fetch(`${apiUrl}?action=sendMessage`, {
method: 'POST',
mode: 'no-cors',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
roomId,
username,
content,
}),
});
const result = await response.text();
console.log('Message sent successfully:', result);
} catch (error) {
console.error('Error sending message:', error);
console.error('Error sending message:', error);
}
}
}

View File

@ -0,0 +1,181 @@
import { BaseBoxShapeUtil, TLBaseShape } from "tldraw";
import { useEffect, useState } from "react";
export type IVideoChatShape = TLBaseShape<
'VideoChat',
{
w: number;
h: number;
roomUrl: string | null;
userName: string;
}
>;
const WHEREBY_API_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2FjY291bnRzLmFwcGVhci5pbiIsImF1ZCI6Imh0dHBzOi8vYXBpLmFwcGVhci5pbi92MSIsImV4cCI6OTAwNzE5OTI1NDc0MDk5MSwiaWF0IjoxNzI5MTkzOTE3LCJvcmdhbml6YXRpb25JZCI6MjY2MDk5LCJqdGkiOiI0MzI0MmUxMC1kZmRjLTRhYmEtYjlhOS01ZjcwNTFlMTYwZjAifQ.RaxXpZKYl_dOWyoATQZrzyMR2XRh3fHf02mALQiuTTs'; // Replace with your actual API key
const ROOM_PREFIX = 'test'
export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
static override type = 'VideoChat';
getDefaultProps(): IVideoChatShape['props'] {
return {
roomUrl: null,
w: 640,
h: 480,
userName: ''
};
}
indicator(shape: IVideoChatShape) {
return <rect x={0} y={0} width={shape.props.w} height={shape.props.h} />;
}
async ensureRoomExists(shape: IVideoChatShape) {
console.log('This is your roomUrl 1:', shape.props.roomUrl);
if (shape.props.roomUrl !== null) {
return
}
const expiryDate = new Date(Date.now() + 1000 * 24 * 60 * 60 * 1000)
const response = await fetch('https://api.whereby.dev/v1/meetings', {
method: 'POST',
headers: {
// 'Access-Control-Allow-Origin': 'http://localhost:5173/',
'Authorization': `Bearer ${WHEREBY_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
isLocked: false,
roomNamePrefix: ROOM_PREFIX,
roomMode: 'normal',
endDate: expiryDate.toISOString(),
fields: ['hostRoomUrl'],
}),
}).catch((error) => {
console.error('Failed to create meeting:', error);
throw error;
});
console.log('This is your response:', response);
console.log('This is your roomUrl 2:', shape.props.roomUrl);
if (!response.ok) {
const errorData = await response.json();
console.error('Whereby API error:', errorData);
throw new Error(`Whereby API error: ${(errorData as any).message || 'Unknown error'}`);
}
const data = await response.json();
const roomUrl = (data as any).roomUrl;
console.log('This is your roomUrl 3:', roomUrl);
this.editor.updateShape<IVideoChatShape>({
id: shape.id,
type: 'VideoChat',
props: {
...shape.props,
roomUrl
}
})
}
component(shape: IVideoChatShape) {
const [roomUrl, setRoomUrl] = useState(""); // Added roomUrl state
const [isInRoom, setIsInRoom] = useState(false);
const [error, setError] = useState("");
const [isLoading, setIsLoading] = useState(false);
// Automatically show the button on load
useEffect(() => {
joinRoom();
}, []);
const joinRoom = async () => {
//console.log("HI IM A CONSOLE TEST")
this.ensureRoomExists(shape);
setError("");
setIsLoading(true);
try {
// Generate a room name based on a default slug or any logic you prefer
// const roomNamePrefix = 'default-room'; // You can modify this logic as needed
// const response = await fetch('https://cors-anywhere.herokuapp.com/https://api.whereby.dev/v1/meetings', {
const response = await fetch('https://api.whereby.dev/v1/meetings', {
method: 'POST',
headers: {
'Authorization': `Bearer ${WHEREBY_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
isLocked: false,
roomNamePrefix: ROOM_PREFIX,
roomMode: 'normal',
endDate: new Date(Date.now() + 1000 * 24 * 60 * 60 * 1000).toISOString(), // 7 days from now
fields: ['hostRoomUrl'],
}),
});
if (!response.ok) {
const errorData: { message?: string } = await response.json(); // Explicitly type errorData
console.error('Whereby API error:', errorData);
throw new Error(`Whereby API error: ${errorData.message || 'Unknown error'}`);
}
const data: { roomUrl: string } = await response.json(); // Explicitly type the response
setRoomUrl(data.roomUrl); // Set the room URL
setIsInRoom(true);
} catch (e) {
console.error("Error joining room:", e);
setError("An error occurred. Please try again.");
}
setIsLoading(false);
};
const leaveRoom = () => {
setIsInRoom(false);
setRoomUrl(""); // Clear the room URL
};
return (
<div className="p-4" style={{ pointerEvents: 'all' }}>
<h1 className="text-2xl font-bold mb-4">Whereby Video Chat Room</h1>
{isLoading ? (
<p>Joining room...</p>
) : isInRoom ? (
<div className="mb-4">
<button onClick={leaveRoom} className="bg-red-500 text-white px-4 py-2 rounded mb-4">
Leave Room
</button>
<div className="aspect-w-16 aspect-h-9">
<iframe
src={`${roomUrl}?embed&iframeSource=val.town&background=off&logo=off&chat=off&screenshare=on&people=on`}
allow="camera; microphone; fullscreen; speaker; display-capture"
className="w-full h-full"
></iframe>
</div>
</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>
)}
<p className="mt-4 text-sm text-gray-600">
View source: <a href={import.meta.url.replace("esm.town", "val.town")} className="text-blue-500 hover:underline">Val Town</a>
</p>
</div>
);
}
}

210
src/snapshot.json Normal file
View File

@ -0,0 +1,210 @@
{
"store": {
"document:document": {
"gridSize": 10,
"name": "",
"meta": {},
"id": "document:document",
"typeName": "document"
},
"page:page": {
"meta": {},
"id": "page:page",
"name": "Page 1",
"index": "a1",
"typeName": "page"
},
"shape:f4LKGB_8M2qsyWGpHR5Dq": {
"x": 30.9375,
"y": 69.48828125,
"rotation": 0,
"isLocked": false,
"opacity": 1,
"meta": {},
"id": "shape:f4LKGB_8M2qsyWGpHR5Dq",
"type": "container",
"parentId": "page:page",
"index": "a1",
"props": {
"width": 644,
"height": 148
},
"typeName": "shape"
},
"shape:2oThF4kJ4v31xqKN5lvq2": {
"x": 550.9375,
"y": 93.48828125,
"rotation": 0,
"isLocked": false,
"opacity": 1,
"meta": {},
"id": "shape:2oThF4kJ4v31xqKN5lvq2",
"type": "element",
"props": {
"color": "#5BCEFA"
},
"parentId": "page:page",
"index": "a2",
"typeName": "shape"
},
"shape:K2vk_VTaNh-ANaRNOAvgY": {
"x": 426.9375,
"y": 93.48828125,
"rotation": 0,
"isLocked": false,
"opacity": 1,
"meta": {},
"id": "shape:K2vk_VTaNh-ANaRNOAvgY",
"type": "element",
"props": {
"color": "#F5A9B8"
},
"parentId": "page:page",
"index": "a3",
"typeName": "shape"
},
"shape:6uouhIK7PvyIRNQHACf-d": {
"x": 302.9375,
"y": 93.48828125,
"rotation": 0,
"isLocked": false,
"opacity": 1,
"meta": {},
"id": "shape:6uouhIK7PvyIRNQHACf-d",
"type": "element",
"props": {
"color": "#FFFFFF"
},
"parentId": "page:page",
"index": "a4",
"typeName": "shape"
},
"shape:GTQq2qxkWPHEK7KMIRtsh": {
"x": 54.9375,
"y": 93.48828125,
"rotation": 0,
"isLocked": false,
"opacity": 1,
"meta": {},
"id": "shape:GTQq2qxkWPHEK7KMIRtsh",
"type": "element",
"props": {
"color": "#5BCEFA"
},
"parentId": "page:page",
"index": "a5",
"typeName": "shape"
},
"shape:05jMujN6A0sIp6zzHMpbV": {
"x": 178.9375,
"y": 93.48828125,
"rotation": 0,
"isLocked": false,
"opacity": 1,
"meta": {},
"id": "shape:05jMujN6A0sIp6zzHMpbV",
"type": "element",
"props": {
"color": "#F5A9B8"
},
"parentId": "page:page",
"index": "a6",
"typeName": "shape"
},
"binding:iOBENBUHvzD8N7mBdIM5l": {
"meta": {},
"id": "binding:iOBENBUHvzD8N7mBdIM5l",
"type": "layout",
"fromId": "shape:f4LKGB_8M2qsyWGpHR5Dq",
"toId": "shape:05jMujN6A0sIp6zzHMpbV",
"props": {
"index": "a2",
"placeholder": false
},
"typeName": "binding"
},
"binding:YTIeOALEmHJk6dczRpQmE": {
"meta": {},
"id": "binding:YTIeOALEmHJk6dczRpQmE",
"type": "layout",
"fromId": "shape:f4LKGB_8M2qsyWGpHR5Dq",
"toId": "shape:GTQq2qxkWPHEK7KMIRtsh",
"props": {
"index": "a1",
"placeholder": false
},
"typeName": "binding"
},
"binding:n4LY_pVuLfjV1qpOTZX-U": {
"meta": {},
"id": "binding:n4LY_pVuLfjV1qpOTZX-U",
"type": "layout",
"fromId": "shape:f4LKGB_8M2qsyWGpHR5Dq",
"toId": "shape:6uouhIK7PvyIRNQHACf-d",
"props": {
"index": "a3",
"placeholder": false
},
"typeName": "binding"
},
"binding:8XayRsWB_nxAH2833SYg1": {
"meta": {},
"id": "binding:8XayRsWB_nxAH2833SYg1",
"type": "layout",
"fromId": "shape:f4LKGB_8M2qsyWGpHR5Dq",
"toId": "shape:2oThF4kJ4v31xqKN5lvq2",
"props": {
"index": "a5",
"placeholder": false
},
"typeName": "binding"
},
"binding:MTYuIRiEVTn2DyVChthry": {
"meta": {},
"id": "binding:MTYuIRiEVTn2DyVChthry",
"type": "layout",
"fromId": "shape:f4LKGB_8M2qsyWGpHR5Dq",
"toId": "shape:K2vk_VTaNh-ANaRNOAvgY",
"props": {
"index": "a4",
"placeholder": false
},
"typeName": "binding"
}
},
"schema": {
"schemaVersion": 2,
"sequences": {
"com.tldraw.store": 4,
"com.tldraw.asset": 1,
"com.tldraw.camera": 1,
"com.tldraw.document": 2,
"com.tldraw.instance": 25,
"com.tldraw.instance_page_state": 5,
"com.tldraw.page": 1,
"com.tldraw.instance_presence": 5,
"com.tldraw.pointer": 1,
"com.tldraw.shape": 4,
"com.tldraw.asset.bookmark": 2,
"com.tldraw.asset.image": 4,
"com.tldraw.asset.video": 4,
"com.tldraw.shape.group": 0,
"com.tldraw.shape.text": 2,
"com.tldraw.shape.bookmark": 2,
"com.tldraw.shape.draw": 2,
"com.tldraw.shape.geo": 9,
"com.tldraw.shape.note": 7,
"com.tldraw.shape.line": 5,
"com.tldraw.shape.frame": 0,
"com.tldraw.shape.arrow": 5,
"com.tldraw.shape.highlight": 1,
"com.tldraw.shape.embed": 4,
"com.tldraw.shape.image": 3,
"com.tldraw.shape.video": 2,
"com.tldraw.shape.container": 0,
"com.tldraw.shape.element": 0,
"com.tldraw.binding.arrow": 0,
"com.tldraw.binding.layout": 0
}
}
}

View File

@ -1,20 +0,0 @@
import { BaseBoxShapeTool, TLClickEvent } from 'tldraw'
export class CardShapeTool extends BaseBoxShapeTool {
static override id = 'card'
static override initial = 'idle'
override shapeType = 'card'
override onDoubleClick: TLClickEvent = (_info) => {
// you can handle events in handlers like this one;
// check the BaseBoxShapeTool source as an example
}
}
/*
This file contains our custom tool. The tool is a StateNode with the `id` "card".
We get a lot of functionality for free by extending the BaseBoxShapeTool. but we can
handle events in out own way by overriding methods like onDoubleClick. For an example
of a tool with more custom functionality, check out the screenshot-tool example.
*/

View File

@ -1,6 +1,7 @@
import { BaseBoxShapeTool } from "tldraw";
export class ChatBoxTool extends BaseBoxShapeTool {
shapeType = 'chatBox'
override initial = 'idle'
static override id = 'chatBox'
shapeType = 'chatBox';
override initial = 'idle';
}

View File

@ -0,0 +1,9 @@
import { BaseBoxShapeTool } from "tldraw";
export class VideoChatTool extends BaseBoxShapeTool {
static override id = 'VideoChat'
shapeType = 'VideoChat';
override initial = 'idle';
// Additional methods for handling video chat functionality can be added here
}

48
src/ui-overrides.tsx Normal file
View File

@ -0,0 +1,48 @@
import {
DefaultToolbar,
DefaultToolbarContent,
TLComponents,
TLUiOverrides,
TldrawUiMenuItem,
useIsToolSelected,
useTools,
} from 'tldraw'
export const uiOverrides: TLUiOverrides = {
tools(editor, tools) {
tools.VideoChat = {
id: 'VideoChat',
icon: 'color',
label: 'Video',
kbd: 'x',
onSelect: () => {
editor.setCurrentTool('VideoChat')
},
}
tools.chatBox = {
id: 'chatBox',
icon: 'color',
label: 'Chat',
kbd: 'x',
onSelect: () => {
editor.setCurrentTool('chatBox')
},
}
return tools
},
}
export const components: TLComponents = {
Toolbar: (props) => {
const tools = useTools()
const isChatBoxSelected = useIsToolSelected(tools['chatBox'])
const isVideoSelected = useIsToolSelected(tools['VideoChat'])
return (
<DefaultToolbar {...props}>
<TldrawUiMenuItem {...tools['VideoChat']} isSelected={isVideoSelected} />
<TldrawUiMenuItem {...tools['chatBox']} isSelected={isChatBoxSelected} />
<DefaultToolbarContent />
</DefaultToolbar>
)
},
}

View File

View File

View File

@ -0,0 +1,25 @@
import { createShapePropsMigrationIds, createShapePropsMigrationSequence } from 'tldraw'
const versions = createShapePropsMigrationIds(
// this must match the shape type in the shape definition
'card',
{
AddSomeProperty: 1,
}
)
// Migrations for the custom card shape (optional but very helpful)
export const cardShapeMigrations = createShapePropsMigrationSequence({
sequence: [
{
id: versions.AddSomeProperty,
up(props) {
// it is safe to mutate the props object here
props.someProperty = 'some value'
},
down(props) {
delete props.someProperty
},
},
],
})

View File

@ -0,0 +1,11 @@
import { DefaultColorStyle, RecordProps, T } from 'tldraw'
import { ICardShape } from './card-shape-types'
// Validation for our custom card shape's props, using one of tldraw's default styles
export const cardShapeProps: RecordProps<ICardShape> = {
w: T.number,
h: T.number,
color: DefaultColorStyle,
}
// To generate your own custom styles, check out the custom styles example.

View File

@ -0,0 +1,11 @@
import { TLBaseShape, TLDefaultColorStyle } from 'tldraw'
// A type for our custom card shape
export type ICardShape = TLBaseShape<
'card',
{
w: number
h: number
color: TLDefaultColorStyle
}
>

View File

@ -0,0 +1,120 @@
import { BaseBoxShapeUtil, HTMLContainer, RecordProps, T, TLBaseShape } from 'tldraw'
// There's a guide at the bottom of this file!
type IMyInteractiveShape = TLBaseShape<
'my-interactive-shape',
{
w: number
h: number
checked: boolean
text: string
}
>
export class myInteractiveShape extends BaseBoxShapeUtil<IMyInteractiveShape> {
static override type = 'my-interactive-shape' as const
static override props: RecordProps<IMyInteractiveShape> = {
w: T.number,
h: T.number,
checked: T.boolean,
text: T.string,
}
getDefaultProps(): IMyInteractiveShape['props'] {
return {
w: 230,
h: 230,
checked: false,
text: '',
}
}
// [1]
component(shape: IMyInteractiveShape) {
return (
<HTMLContainer
style={{
padding: 16,
height: shape.props.h,
width: shape.props.w,
// [a] This is where we allow pointer events on our shape
pointerEvents: 'all',
backgroundColor: '#efefef',
overflow: 'hidden',
}}
>
<input
type="checkbox"
checked={shape.props.checked}
onChange={() =>
this.editor.updateShape<IMyInteractiveShape>({
id: shape.id,
type: 'my-interactive-shape',
props: { checked: !shape.props.checked },
})
}
// [b] This is where we stop event propagation
onPointerDown={(e) => e.stopPropagation()}
onTouchStart={(e) => e.stopPropagation()}
onTouchEnd={(e) => e.stopPropagation()}
/>
<input
type="text"
placeholder="Enter a todo..."
readOnly={shape.props.checked}
value={shape.props.text}
onChange={(e) =>
this.editor.updateShape<IMyInteractiveShape>({
id: shape.id,
type: 'my-interactive-shape',
props: { text: e.currentTarget.value },
})
}
// [c]
onPointerDown={(e) => {
if (!shape.props.checked) {
e.stopPropagation()
}
}}
onTouchStart={(e) => {
if (!shape.props.checked) {
e.stopPropagation()
}
}}
onTouchEnd={(e) => {
if (!shape.props.checked) {
e.stopPropagation()
}
}}
/>
</HTMLContainer>
)
}
// [5]
indicator(shape: IMyInteractiveShape) {
return <rect width={shape.props.w} height={shape.props.h} />
}
}
/*
This is a custom shape, for a more in-depth look at how to create a custom shape,
see our custom shape example.
[1]
This is where we describe how our shape will render
[a] We need to set pointer-events to all so that we can interact with our shape. This CSS property is
set to "none" off by default. We need to manually opt-in to accepting pointer events by setting it to
'all' or 'auto'.
[b] We need to stop event propagation so that the editor doesn't select the shape
when we click on the checkbox. The 'canvas container' forwards events that it receives
on to the editor, so stopping propagation here prevents the event from reaching the canvas.
[c] If the shape is not checked, we stop event propagation so that the editor doesn't
select the shape when we click on the input. If the shape is checked then we allow that event to
propagate to the canvas and then get sent to the editor, triggering clicks or drags as usual.
*/

View File

@ -1,67 +0,0 @@
import React from 'react'
import {
DefaultKeyboardShortcutsDialog,
DefaultKeyboardShortcutsDialogContent,
DefaultToolbar,
DefaultToolbarContent,
TLComponents,
TLUiOverrides,
TldrawUiMenuItem,
useIsToolSelected,
useTools,
} from 'tldraw'
// There's a guide at the bottom of this file!
export const uiOverrides: TLUiOverrides = {
tools(editor, tools) {
// Create a tool item in the ui's context.
tools.card = {
id: 'card',
icon: 'color',
label: 'Card',
kbd: 'c',
onSelect: () => {
editor.setCurrentTool('card')
},
}
return tools
},
}
export const components: TLComponents = {
Toolbar: (props) => {
const tools = useTools()
const isCardSelected = useIsToolSelected(tools['card'])
return (
<DefaultToolbar {...props}>
<TldrawUiMenuItem {...tools['card']} isSelected={isCardSelected} />
<DefaultToolbarContent />
</DefaultToolbar>
)
},
KeyboardShortcutsDialog: (props) => {
const tools = useTools()
return (
<DefaultKeyboardShortcutsDialog {...props}>
<TldrawUiMenuItem {...tools['card']} />
<DefaultKeyboardShortcutsDialogContent />
</DefaultKeyboardShortcutsDialog>
)
},
}
/*
This file contains overrides for the Tldraw UI. These overrides are used to add your custom tools to
the toolbar and the keyboard shortcuts menu.
First we have to add our new tool to the tools object in the tools override. This is where we define
all the basic information about our new tool - its icon, label, keyboard shortcut, what happens when
we select it, etc.
Then, we replace the UI components for the toolbar and keyboard shortcut dialog with our own, that
add our new tool to the existing default content. Ideally, we'd interleave our new tool into the
ideal place among the default tools, but for now we're just adding it at the start to keep things
simple.
*/

View File

@ -10,11 +10,12 @@ import {
import { AutoRouter, IRequest, error } from 'itty-router'
import throttle from 'lodash.throttle'
import { Environment } from './types'
import { ChatBoxShape } from '@/shapes/ChatBoxShape'
import { ChatBoxShape } from '@/shapes/ChatBoxShapeUtil'
import { VideoChatShape } from '@/shapes/VideoChatShapeUtil'
// add custom shapes and bindings here if needed:
export const customSchema = createTLSchema({
shapes: { ...defaultShapeSchemas, chatBox: ChatBoxShape },
shapes: { ...defaultShapeSchemas, chatBox: ChatBoxShape, VideoChat: VideoChatShape },
// bindings: { ...defaultBindingSchemas },
})