fix image/asset handling
This commit is contained in:
parent
807637eae0
commit
a8c8d62e63
|
|
@ -22,7 +22,7 @@ export async function getBookmarkPreview({ url }: { url: string }): Promise<TLAs
|
|||
const response = await fetch(
|
||||
`${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
|
||||
asset.props.description = data?.description ?? ''
|
||||
|
|
@ -8,9 +8,16 @@ import {
|
|||
uniqueId,
|
||||
} from 'tldraw'
|
||||
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 shapeUtils = [ChatBoxShape]
|
||||
const tools = [ChatBoxTool]
|
||||
|
||||
export function Board() {
|
||||
// Extract the slug from the URL
|
||||
const { slug } = useParams<{ slug: string }>()
|
||||
|
|
@ -23,7 +30,9 @@ export function Board() {
|
|||
// Use the dynamic roomId in the URI
|
||||
uri: `${WORKER_URL}/connect/${roomId}`,
|
||||
// ...and how to handle static assets like images & videos
|
||||
assets: multiplayerAssets,
|
||||
assets: multiplayerAssetStore,
|
||||
shapeUtils: shapeUtils,
|
||||
schema: customSchema
|
||||
})
|
||||
|
||||
return (
|
||||
|
|
@ -32,42 +41,27 @@ export function Board() {
|
|||
// 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}
|
||||
shapeUtils={shapeUtils}
|
||||
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,
|
||||
},
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</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?
|
||||
async function unfurlBookmarkUrl({ url }: { url: string }): Promise<TLBookmarkAsset> {
|
||||
const asset: TLBookmarkAsset = {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
||||
*/
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import { BaseBoxShapeTool } from "tldraw";
|
||||
|
||||
export class ChatBoxTool extends BaseBoxShapeTool {
|
||||
shapeType = 'chatBox'
|
||||
override initial = 'idle'
|
||||
}
|
||||
|
|
@ -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)
|
||||
);
|
||||
});
|
||||
|
|
@ -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.
|
||||
*/
|
||||
|
|
@ -10,10 +10,11 @@ import {
|
|||
import { AutoRouter, IRequest, error } from 'itty-router'
|
||||
import throttle from 'lodash.throttle'
|
||||
import { Environment } from './types'
|
||||
import { ChatBoxShape } from '@/shapes/ChatBoxShape'
|
||||
|
||||
// add custom shapes and bindings here if needed:
|
||||
const schema = createTLSchema({
|
||||
shapes: { ...defaultShapeSchemas },
|
||||
export const customSchema = createTLSchema({
|
||||
shapes: { ...defaultShapeSchemas, chatBox: ChatBoxShape },
|
||||
// bindings: { ...defaultBindingSchemas },
|
||||
})
|
||||
|
||||
|
|
@ -101,7 +102,7 @@ export class TldrawDurableObject {
|
|||
// 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.
|
||||
return new TLSocketRoom<TLRecord, void>({
|
||||
schema,
|
||||
schema: customSchema,
|
||||
initialSnapshot,
|
||||
onDataChange: () => {
|
||||
// and persist whenever the data in the room changes
|
||||
|
|
|
|||
Loading…
Reference in New Issue