fix image/asset handling

This commit is contained in:
Jeff Emmett 2024-08-31 13:06:13 +02:00
parent 807637eae0
commit a8c8d62e63
9 changed files with 273 additions and 33 deletions

View File

@ -22,7 +22,7 @@ export async function getBookmarkPreview({ url }: { url: string }): Promise<TLAs
const response = await fetch( const response = await fetch(
`${process.env.TLDRAW_WORKER_URL}/unfurl?url=${encodeURIComponent(url)}` `${process.env.TLDRAW_WORKER_URL}/unfurl?url=${encodeURIComponent(url)}`
) )
const data = await response.json() const data = await response.json() as {description: string, image: string, favicon: string, title: string}
// fill in our asset with whatever info we found // fill in our asset with whatever info we found
asset.props.description = data?.description ?? '' asset.props.description = data?.description ?? ''

View File

@ -8,9 +8,16 @@ import {
uniqueId, uniqueId,
} from 'tldraw' } from 'tldraw'
import { useParams } from 'react-router-dom' // Add this import import { useParams } from 'react-router-dom' // Add this import
import { ChatBoxTool } from '@/tools/ChatBoxTool'
import { IChatBoxShape, ChatBoxShape } from '@/shapes/ChatBoxShape'
import { multiplayerAssetStore } from '@/client/multiplayerAssetStore'
import { customSchema } from '../../worker/TldrawDurableObject'
const WORKER_URL = `https://jeffemmett-canvas.jeffemmett.workers.dev` const WORKER_URL = `https://jeffemmett-canvas.jeffemmett.workers.dev`
const shapeUtils = [ChatBoxShape]
const tools = [ChatBoxTool]
export function Board() { export function Board() {
// Extract the slug from the URL // Extract the slug from the URL
const { slug } = useParams<{ slug: string }>() const { slug } = useParams<{ slug: string }>()
@ -23,7 +30,9 @@ export function Board() {
// Use the dynamic roomId in the URI // Use the dynamic roomId in the URI
uri: `${WORKER_URL}/connect/${roomId}`, uri: `${WORKER_URL}/connect/${roomId}`,
// ...and how to handle static assets like images & videos // ...and how to handle static assets like images & videos
assets: multiplayerAssets, assets: multiplayerAssetStore,
shapeUtils: shapeUtils,
schema: customSchema
}) })
return ( return (
@ -31,43 +40,28 @@ export function Board() {
<Tldraw <Tldraw
// we can pass the connected store into the Tldraw component which will handle // we can pass the connected store into the Tldraw component which will handle
// loading states & enable multiplayer UX like cursors & a presence menu // loading states & enable multiplayer UX like cursors & a presence menu
store={store} store={store}
shapeUtils={shapeUtils}
tools={tools}
onMount={(editor) => { onMount={(editor) => {
// when the editor is ready, we need to register out bookmark unfurling service // when the editor is ready, we need to register out bookmark unfurling service
editor.registerExternalAssetHandler('url', unfurlBookmarkUrl) editor.registerExternalAssetHandler('url', unfurlBookmarkUrl)
editor.createShape<IChatBoxShape>({
type: 'chatBox',
x: 0,
y: 0,
props: {
w: 200,
h: 200,
roomId: roomId,
},
})
}} }}
/> />
</div> </div>
) )
} }
// How does our server handle assets like images and videos?
const multiplayerAssets: TLAssetStore = {
// to upload an asset, we prefix it with a unique id, POST it to our worker, and return the URL
async upload(_asset, file) {
const id = uniqueId()
const objectName = `${id}-${file.name}`
const url = `${WORKER_URL}/uploads/${encodeURIComponent(objectName)}`
const response = await fetch(url, {
method: 'PUT',
body: file,
})
if (!response.ok) {
throw new Error(`Failed to upload asset: ${response.statusText}`)
}
return url
},
// to retrieve an asset, we can just use the same URL. you could customize this to add extra
// auth, or to serve optimized versions / sizes of the asset.
resolve(asset) {
return asset.props.src
},
}
// How does our server handle bookmark unfurling? // How does our server handle bookmark unfurling?
async function unfurlBookmarkUrl({ url }: { url: string }): Promise<TLBookmarkAsset> { async function unfurlBookmarkUrl({ url }: { url: string }): Promise<TLBookmarkAsset> {
const asset: TLBookmarkAsset = { const asset: TLBookmarkAsset = {

141
src/shapes/ChatBoxShape.tsx Normal file
View File

@ -0,0 +1,141 @@
import { useEffect, useRef, useState } from "react";
import { BaseBoxShapeUtil, TLBaseBoxShape, TLBaseShape, TldrawBaseProps } from "tldraw";
export type IChatBoxShape = TLBaseShape<
'chatBox',
{
w: number
h: number
roomId: string
}
>
export class ChatBoxShape extends BaseBoxShapeUtil<IChatBoxShape> {
static override type = 'chatBox'
getDefaultProps(): IChatBoxShape['props'] {
return {
roomId: 'default-room',
w: 100,
h: 100,
}
}
indicator(shape: IChatBoxShape) {
return <rect x={0} y={0} width={shape.props.w} height={shape.props.h} />
}
component(shape: IChatBoxShape) {
return (
<ChatBox roomId={shape.props.roomId} width={shape.props.w} height={shape.props.h} />
)
}
}
interface Message {
id: string;
username: string;
content: string;
timestamp: Date;
}
// Add this new component after the ChatBoxShape class
function ChatBox({ width, height }: { roomId: string, width: number, height: number }) {
const [messages, setMessages] = useState<Message[]>([]);
const [inputMessage, setInputMessage] = useState("");
const [username, setUsername] = useState("jeff");
const messagesEndRef = useRef(null);
useEffect(() => {
const storedUsername = localStorage.getItem("chatUsername");
if (storedUsername) {
setUsername(storedUsername);
} else {
const newUsername = `User${Math.floor(Math.random() * 1000)}`;
setUsername(newUsername);
localStorage.setItem("chatUsername", newUsername);
}
fetchMessages();
const interval = setInterval(fetchMessages, 2000);
return () => clearInterval(interval);
}, []);
useEffect(() => {
if (messagesEndRef.current) {
(messagesEndRef.current as HTMLElement).scrollIntoView({ behavior: "smooth" });
}
}, [messages]);
const fetchMessages = async () => {
try {
const response = await fetch("https://jeffemmett-realtimechatappwithpolling.web.val.run?action=getMessages");
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const newMessages = await response.json() as Message[];
setMessages(newMessages.map(msg => ({ ...msg, timestamp: new Date(msg.timestamp) })));
} catch (error) {
console.error('Error fetching messages:', error);
}
};
const sendMessage = async (e: any) => {
e.preventDefault();
if (!inputMessage.trim()) return;
await sendMessageToChat(username, inputMessage);
setInputMessage("");
fetchMessages();
};
return (
<div className="chat-container" style={{ pointerEvents: 'all', width: `${width}px`, height: `${height}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>
<span className="timestamp">{new Date(msg.timestamp).toLocaleTimeString()}</span>
</div>
<div className="message-content">{msg.content}</div>
</div>
))}
<div ref={messagesEndRef} />
</div>
<form onSubmit={sendMessage} className="input-form">
<input
type="text"
value={inputMessage}
onChange={(e) => setInputMessage(e.target.value)}
placeholder="Type a message..."
className="message-input"
/>
<button type="submit" style={{ pointerEvents: 'all',}} onPointerDown={(e)=>e.stopPropagation()} className="send-button">Send</button>
</form>
</div>
);
}
async function sendMessageToChat(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({
username,
content,
}),
});
const result = await response.text();
console.log('Message sent successfully:', result);
} catch (error) {
console.error('Error sending message:', error);
}
}

View File

@ -0,0 +1,20 @@
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.
*/

6
src/tools/ChatBoxTool.ts Normal file
View File

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

11
sw.ts Normal file
View File

@ -0,0 +1,11 @@
self.addEventListener('push', function(event) {
const data = event.data.json();
const options = {
body: data.message,
icon: 'path/to/icon.png',
badge: 'path/to/badge.png'
};
event.waitUntil(
self.registration.showNotification('New Message', options)
);
});

67
ui-overrides.tsx Normal file
View File

@ -0,0 +1,67 @@
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,10 +10,11 @@ import {
import { AutoRouter, IRequest, error } from 'itty-router' import { AutoRouter, IRequest, error } from 'itty-router'
import throttle from 'lodash.throttle' import throttle from 'lodash.throttle'
import { Environment } from './types' import { Environment } from './types'
import { ChatBoxShape } from '@/shapes/ChatBoxShape'
// add custom shapes and bindings here if needed: // add custom shapes and bindings here if needed:
const schema = createTLSchema({ export const customSchema = createTLSchema({
shapes: { ...defaultShapeSchemas }, shapes: { ...defaultShapeSchemas, chatBox: ChatBoxShape },
// bindings: { ...defaultBindingSchemas }, // bindings: { ...defaultBindingSchemas },
}) })
@ -101,7 +102,7 @@ export class TldrawDurableObject {
// create a new TLSocketRoom. This handles all the sync protocol & websocket connections. // create a new TLSocketRoom. This handles all the sync protocol & websocket connections.
// it's up to us to persist the room state to R2 when needed though. // it's up to us to persist the room state to R2 when needed though.
return new TLSocketRoom<TLRecord, void>({ return new TLSocketRoom<TLRecord, void>({
schema, schema: customSchema,
initialSnapshot, initialSnapshot,
onDataChange: () => { onDataChange: () => {
// and persist whenever the data in the room changes // and persist whenever the data in the room changes