<html lang="en">
<head></head>
<body>

<form id="mainForm" method="post" action="https://stackblitz.com/run" target="_self">
<input type="hidden" name="project[files][.gitignore]" value=".actorcore
node_modules">
<input type="hidden" name="project[files][README.md]" value="# Real-time Collaborative Cursors for RivetKit

Example project demonstrating real-time cursor tracking and collaborative canvas with [RivetKit](https://rivetkit.org).

[Learn More →](https://github.com/rivet-dev/rivetkit)

[Discord](https://rivet.dev/discord) — [Documentation](https://rivetkit.org) — [Issues](https://github.com/rivet-dev/rivetkit/issues)

## Getting Started

### Prerequisites

- Node.js 18+

### Installation

```sh
git clone https://github.com/rivet-dev/rivetkit
cd rivetkit/examples/cursors
npm install
```

### Development

```sh
npm run dev
```

Open your browser to `http://localhost:5173`. Open multiple tabs or windows to see real-time cursor tracking and text placement across different users.

## Features

- Real-time cursor position tracking
- Multiple users with color-coded cursors
- Click-to-place text on canvas
- Multiple room support for different collaborative spaces
- Persistent text labels across sessions
- Event-driven architecture with RivetKit actors
- TypeScript support throughout

## License

Apache 2.0
">
<input type="hidden" name="project[files][package.json]" value="{&quot;name&quot;:&quot;example-cursors-raw-websocket&quot;,&quot;version&quot;:&quot;2.0.21&quot;,&quot;private&quot;:true,&quot;type&quot;:&quot;module&quot;,&quot;scripts&quot;:{&quot;dev&quot;:&quot;concurrently \&quot;npm run dev:backend\&quot; \&quot;npm run dev:frontend\&quot;&quot;,&quot;dev:backend&quot;:&quot;tsx --watch src/backend/server.ts&quot;,&quot;dev:frontend&quot;:&quot;vite&quot;,&quot;check-types&quot;:&quot;tsc --noEmit&quot;,&quot;test&quot;:&quot;vitest run&quot;},&quot;devDependencies&quot;:{&quot;@types/node&quot;:&quot;^22.13.9&quot;,&quot;@types/prompts&quot;:&quot;^2&quot;,&quot;@types/react&quot;:&quot;^18.2.0&quot;,&quot;@types/react-dom&quot;:&quot;^18.2.0&quot;,&quot;@vitejs/plugin-react&quot;:&quot;^4.2.0&quot;,&quot;concurrently&quot;:&quot;^8.2.2&quot;,&quot;prompts&quot;:&quot;^2.4.2&quot;,&quot;tsx&quot;:&quot;^3.12.7&quot;,&quot;typescript&quot;:&quot;^5.5.2&quot;,&quot;vite&quot;:&quot;^5.0.0&quot;,&quot;vitest&quot;:&quot;^3.1.1&quot;,&quot;@rivetkit/react&quot;:&quot;https://pkg.pr.new/rivet-dev/rivet/@rivetkit/react@1784d8f4835dfe0620f43e819ff860dd7dd02821&quot;,&quot;react&quot;:&quot;^18.2.0&quot;,&quot;react-dom&quot;:&quot;^18.2.0&quot;},&quot;dependencies&quot;:{&quot;rivetkit&quot;:&quot;https://pkg.pr.new/rivet-dev/rivet/rivetkit@1784d8f4835dfe0620f43e819ff860dd7dd02821&quot;},&quot;stableVersion&quot;:&quot;0.8.0&quot;}">
<input type="hidden" name="project[files][tsconfig.json]" value="{
	&quot;compilerOptions&quot;: {
		/* Visit https://aka.ms/tsconfig.json to read more about this file */

		/* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
		&quot;target&quot;: &quot;esnext&quot;,
		/* Specify a set of bundled library declaration files that describe the target runtime environment. */
		&quot;lib&quot;: [&quot;esnext&quot;, &quot;dom&quot;],
		/* Specify what JSX code is generated. */
		&quot;jsx&quot;: &quot;react-jsx&quot;,

		/* Specify what module code is generated. */
		&quot;module&quot;: &quot;esnext&quot;,
		/* Specify how TypeScript looks up a file from a given module specifier. */
		&quot;moduleResolution&quot;: &quot;bundler&quot;,
		/* Specify type package names to be included without being referenced in a source file. */
		&quot;types&quot;: [&quot;node&quot;, &quot;vite/client&quot;, &quot;vitest&quot;],
		/* Enable importing .json files */
		&quot;resolveJsonModule&quot;: true,

		/* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */
		&quot;allowJs&quot;: true,
		/* Enable error reporting in type-checked JavaScript files. */
		&quot;checkJs&quot;: false,

		/* Disable emitting files from a compilation. */
		&quot;noEmit&quot;: true,

		/* Ensure that each file can be safely transpiled without relying on other imports. */
		&quot;isolatedModules&quot;: true,
		/* Allow &#39;import x from y&#39; when a module doesn&#39;t have a default export. */
		&quot;allowSyntheticDefaultImports&quot;: true,
		/* Ensure that casing is correct in imports. */
		&quot;forceConsistentCasingInFileNames&quot;: true,

		/* Enable all strict type-checking options. */
		&quot;strict&quot;: true,

		/* Skip type checking all .d.ts files. */
		&quot;skipLibCheck&quot;: true
	},
	&quot;include&quot;: [&quot;src/**/*&quot;, &quot;actors/**/*&quot;, &quot;tests/**/*&quot;]
}
">
<input type="hidden" name="project[files][turbo.json]" value="{
	&quot;$schema&quot;: &quot;https://turbo.build/schema.json&quot;,
	&quot;extends&quot;: [&quot;//&quot;]
}
">
<input type="hidden" name="project[files][vite.config.ts]" value="import react from &quot;@vitejs/plugin-react&quot;;
import { defineConfig } from &quot;vite&quot;;

export default defineConfig({
	plugins: [react()],
	root: &quot;src/frontend&quot;,
	server: {
		port: 5173,
	},
});
">
<input type="hidden" name="project[files][vitest.config.ts]" value="import { defineConfig } from &quot;vitest/config&quot;;

export default defineConfig({
	server: {
		port: 5173,
	},
	test: {
		include: [&quot;tests/**/*.test.ts&quot;],
	},
});
">
<input type="hidden" name="project[files][tests/cursors.test.ts]" value="import { setupTest } from &quot;rivetkit/test&quot;;
import { expect, test } from &quot;vitest&quot;;
import { registry } from &quot;../src/backend/registry&quot;;

test(&quot;Cursor room can be created and initialized&quot;, async (ctx: any) =&gt; {
	const { client } = await setupTest(ctx, registry);
	const room = client.cursorRoom.getOrCreate([&quot;test-room&quot;]);

	// Test that the getOrCreate action works
	const result = await room.getOrCreate();
	expect(result).toEqual({ status: &quot;ok&quot; });
});

test(&quot;Cursor room can get initial room state&quot;, async (ctx: any) =&gt; {
	const { client } = await setupTest(ctx, registry);
	const room = client.cursorRoom.getOrCreate([&quot;test-state&quot;]);

	// Test initial state
	const state = await room.getRoomState();
	expect(state).toEqual({
		cursors: {},
		textLabels: [],
	});
});
">
<input type="hidden" name="project[files][src/backend/registry.ts]" value="import { actor, setup, type UniversalWebSocket } from &quot;rivetkit&quot;;

export interface CursorPosition {
	userId: string;
	x: number;
	y: number;
	timestamp: number;
}

export interface TextLabel {
	id: string;
	userId: string;
	text: string;
	x: number;
	y: number;
	timestamp: number;
}

interface Vars {
	websockets: Map&lt;
		string,
		{ socket: UniversalWebSocket; cursor: CursorPosition | null }
	&gt;;
}

export const cursorRoom = actor({
	state: {
		textLabels: [] as TextLabel[],
	},

	createVars: (): Vars =&gt; {
		return {
			websockets: new Map(),
		};
	},

	actions: {
		// Get or create the actor (for frontend to resolve actor ID)
		getOrCreate: () =&gt; {
			return { status: &quot;ok&quot; };
		},

		// Get all room state (cursors and text labels)
		getRoomState: (c) =&gt; {
			const cursors: Record&lt;string, CursorPosition&gt; = {};
			for (const [sessionId, { cursor }] of c.vars.websockets.entries()) {
				if (cursor) {
					cursors[sessionId] = cursor;
				}
			}
			return {
				cursors,
				textLabels: c.state.textLabels,
			};
		},
	},

	// Handle WebSocket connections
	onWebSocket: async (c, websocket: UniversalWebSocket) =&gt; {
		const url = new URL(request.url);
		const sessionId = url.searchParams.get(&quot;sessionId&quot;);

		if (!sessionId) {
			websocket.close(1008, &quot;Missing sessionId&quot;);
			return;
		}

		console.log(
			`websocket connected: sessionId=${sessionId}, actorId=${c.actorId}`,
		);

		// Store the websocket
		c.vars.websockets.set(sessionId, { socket: websocket, cursor: null });

		// Send initial state to the new connection
		const cursors: Record&lt;string, CursorPosition&gt; = {};
		for (const [id, { cursor }] of c.vars.websockets.entries()) {
			if (cursor) {
				cursors[id] = cursor;
			}
		}
		websocket.send(
			JSON.stringify({
				type: &quot;init&quot;,
				data: {
					cursors,
					textLabels: c.state.textLabels,
				},
			}),
		);

		// Handle incoming messages
		websocket.addEventListener(&quot;message&quot;, (event) =&gt; {
			try {
				const message = JSON.parse(event.data as string);

				switch (message.type) {
					case &quot;updateCursor&quot;: {
						const { userId, x, y } = message.data;
						const cursor: CursorPosition = {
							userId,
							x,
							y,
							timestamp: Date.now(),
						};

						// Update the cursor for this session
						const session = c.vars.websockets.get(sessionId);
						if (session) {
							session.cursor = cursor;
						}

						// Broadcast to all connections (including sender)
						for (const { socket } of c.vars.websockets.values()) {
							socket.send(
								JSON.stringify({
									type: &quot;cursorMoved&quot;,
									data: cursor,
								}),
							);
						}
						break;
					}

					case &quot;updateText&quot;: {
						const { id, userId, text, x, y } = message.data;
						const textLabel: TextLabel = {
							id,
							userId,
							text,
							x,
							y,
							timestamp: Date.now(),
						};

						// Find and update existing text label or add new one
						const existingIndex = c.state.textLabels.findIndex(
							(label) =&gt; label.id === id,
						);
						if (existingIndex &gt;= 0) {
							c.state.textLabels[existingIndex] = textLabel;
						} else {
							c.state.textLabels.push(textLabel);
						}

						// Broadcast to all connections
						for (const { socket } of c.vars.websockets.values()) {
							socket.send(
								JSON.stringify({
									type: &quot;textUpdated&quot;,
									data: textLabel,
								}),
							);
						}
						break;
					}

					case &quot;removeText&quot;: {
						const { id } = message.data;
						c.state.textLabels = c.state.textLabels.filter(
							(label) =&gt; label.id !== id,
						);

						// Broadcast to all connections
						for (const { socket } of c.vars.websockets.values()) {
							socket.send(
								JSON.stringify({
									type: &quot;textRemoved&quot;,
									data: id,
								}),
							);
						}
						break;
					}
				}
			} catch (error) {
				console.error(&quot;error handling websocket message:&quot;, error);
			}
		});

		// Handle connection close
		websocket.addEventListener(&quot;close&quot;, () =&gt; {
			console.log(`websocket disconnected: sessionId=${sessionId}`);
			const session = c.vars.websockets.get(sessionId);
			if (session?.cursor) {
				// Broadcast cursor removal to all other connections
				for (const [id, { socket }] of c.vars.websockets.entries()) {
					if (id !== sessionId) {
						socket.send(
							JSON.stringify({
								type: &quot;cursorRemoved&quot;,
								data: session.cursor,
							}),
						);
					}
				}
			}
			c.vars.websockets.delete(sessionId);
		});
	},
});

// Register actors for use: https://rivet.dev/docs/setup
export const registry = setup({
	use: { cursorRoom },
});
">
<input type="hidden" name="project[files][src/backend/server.ts]" value="import { registry } from &quot;./registry&quot;;

registry.start();
">
<input type="hidden" name="project[files][src/frontend/App.tsx]" value="import { createClient } from &quot;@rivetkit/react&quot;;
import { useEffect, useRef, useState } from &quot;react&quot;;
import type {
	CursorPosition,
	TextLabel,
	registry,
} from &quot;../backend/registry&quot;;

const rivetUrl = &quot;http://localhost:6420&quot;;

const client = createClient&lt;typeof registry&gt;(rivetUrl);

// Generate a random user ID
const generateUserId = () =&gt;
	`user-${Math.random().toString(36).substring(2, 9)}`;

// Generate a random session ID
const generateSessionId = () =&gt;
	`session-${Math.random().toString(36).substring(2, 15)}`;

// Cursor colors for different users (darker palette)
const CURSOR_COLORS = [
	&quot;#E63946&quot;,
	&quot;#2A9D8F&quot;,
	&quot;#1B8AAE&quot;,
	&quot;#F77F00&quot;,
	&quot;#06A77D&quot;,
	&quot;#D4A017&quot;,
	&quot;#9B59B6&quot;,
	&quot;#5DADE2&quot;,
];

function getColorForUser(userId: string): string {
	let hash = 0;
	for (let i = 0; i &lt; userId.length; i++) {
		hash = userId.charCodeAt(i) + ((hash &lt;&lt; 5) - hash);
	}
	return CURSOR_COLORS[Math.abs(hash) % CURSOR_COLORS.length];
}

// Virtual canvas size - all coordinates are in this space
const CANVAS_WIDTH = 1920;
const CANVAS_HEIGHT = 1080;

export function App() {
	const [roomId, setRoomId] = useState(&quot;general&quot;);
	const [userId] = useState(generateUserId());
	const [sessionId] = useState(generateSessionId());
	const [cursors, setCursors] = useState&lt;Record&lt;string, CursorPosition&gt;&gt;({});
	const [textLabels, setTextLabels] = useState&lt;TextLabel[]&gt;([]);
	const [textInput, setTextInput] = useState(&quot;&quot;);
	const [isTyping, setIsTyping] = useState(false);
	const [typingPosition, setTypingPosition] = useState({ x: 0, y: 0 });
	const [currentTextId, setCurrentTextId] = useState&lt;string | null&gt;(null);
	const [scale, setScale] = useState(1);
	const [connected, setConnected] = useState(false);
	const canvasRef = useRef&lt;HTMLDivElement&gt;(null);
	const containerRef = useRef&lt;HTMLDivElement&gt;(null);
	const wsRef = useRef&lt;WebSocket | null&gt;(null);

	// Calculate scale factor to fit canvas in viewport
	useEffect(() =&gt; {
		const updateScale = () =&gt; {
			if (!containerRef.current) return;

			const containerWidth = containerRef.current.clientWidth;
			const containerHeight = containerRef.current.clientHeight;

			// Calculate scale to fit canvas while maintaining aspect ratio
			const scaleX = containerWidth / CANVAS_WIDTH;
			const scaleY = containerHeight / CANVAS_HEIGHT;
			const newScale = Math.min(scaleX, scaleY);

			setScale(newScale);
		};

		updateScale();
		window.addEventListener(&quot;resize&quot;, updateScale);
		return () =&gt; window.removeEventListener(&quot;resize&quot;, updateScale);
	}, []);

	// Connect to WebSocket
	useEffect(() =&gt; {
		let ws: WebSocket | null = null;

		const connect = async () =&gt; {
			try {
				// Get or create the actor for this room
				const actorId = await client.cursorRoom.getOrCreate(roomId).resolve();
				console.log(&quot;found actor&quot;, actorId);

				const wsOrigin = rivetUrl.replace(/^http/, &quot;ws&quot;);
				const wsUrl = `${wsOrigin}/gateway/${actorId}/websocket?sessionId=${encodeURIComponent(sessionId)}`;

				console.log(&quot;ws url:&quot;, wsUrl);

				// Create WebSocket connection
				ws = new WebSocket(wsUrl);
				wsRef.current = ws;

				ws.addEventListener(&quot;open&quot;, () =&gt; {
					console.log(&quot;websocket connected&quot;);
					setConnected(true);
				});

				ws.addEventListener(&quot;message&quot;, (event) =&gt; {
					try {
						const message = JSON.parse(event.data);

						switch (message.type) {
							case &quot;init&quot;: {
								// Initial state from server
								setCursors(message.data.cursors);
								setTextLabels(message.data.textLabels);
								break;
							}

							case &quot;cursorMoved&quot;: {
								setCursors((prev) =&gt; ({
									...prev,
									[message.data.userId]: message.data,
								}));
								break;
							}

							case &quot;textUpdated&quot;: {
								setTextLabels((prev) =&gt; {
									const existingIndex = prev.findIndex(
										(l) =&gt; l.id === message.data.id,
									);
									if (existingIndex &gt;= 0) {
										const newLabels = [...prev];
										newLabels[existingIndex] = message.data;
										return newLabels;
									} else {
										return [...prev, message.data];
									}
								});
								break;
							}

							case &quot;textRemoved&quot;: {
								setTextLabels((prev) =&gt;
									prev.filter((label) =&gt; label.id !== message.data),
								);
								break;
							}

							case &quot;cursorRemoved&quot;: {
								setCursors((prev) =&gt; {
									const newCursors = { ...prev };
									delete newCursors[message.data.userId];
									return newCursors;
								});
								break;
							}
						}
					} catch (error) {
						console.error(&quot;error parsing websocket message:&quot;, error);
					}
				});

				ws.addEventListener(&quot;close&quot;, () =&gt; {
					console.log(&quot;websocket disconnected&quot;);
					setConnected(false);
					wsRef.current = null;
				});

				ws.addEventListener(&quot;error&quot;, (error) =&gt; {
					console.error(&quot;websocket error:&quot;, error);
				});
			} catch (error) {
				console.error(&quot;error connecting:&quot;, error);
			}
		};

		connect();

		// Cleanup
		return () =&gt; {
			if (ws) {
				ws.close();
			}
		};
	}, [roomId, sessionId]);

	// Convert screen coordinates to canvas coordinates
	const screenToCanvas = (screenX: number, screenY: number) =&gt; {
		if (!canvasRef.current) return { x: 0, y: 0 };

		const rect = canvasRef.current.getBoundingClientRect();
		const x = (screenX - rect.left) / scale;
		const y = (screenY - rect.top) / scale;

		return { x, y };
	};

	// Handle mouse movement on canvas
	const handleMouseMove = (e: React.MouseEvent&lt;HTMLDivElement&gt;) =&gt; {
		if (wsRef.current &amp;&amp; wsRef.current.readyState === WebSocket.OPEN &amp;&amp; canvasRef.current) {
			const { x, y } = screenToCanvas(e.clientX, e.clientY);
			wsRef.current.send(
				JSON.stringify({
					type: &quot;updateCursor&quot;,
					data: { userId, x, y },
				}),
			);
		}
	};

	// Handle canvas click
	const handleCanvasClick = (e: React.MouseEvent&lt;HTMLDivElement&gt;) =&gt; {
		if (!canvasRef.current) return;

		const { x, y } = screenToCanvas(e.clientX, e.clientY);
		const newTextId = `${userId}-${Date.now()}`;
		setTypingPosition({ x, y });
		setCurrentTextId(newTextId);
		setIsTyping(true);
		setTextInput(&quot;&quot;);
	};

	// Handle text input changes
	const handleTextChange = (newText: string) =&gt; {
		setTextInput(newText);
		if (wsRef.current &amp;&amp; wsRef.current.readyState === WebSocket.OPEN &amp;&amp; currentTextId &amp;&amp; newText.trim()) {
			wsRef.current.send(
				JSON.stringify({
					type: &quot;updateText&quot;,
					data: {
						id: currentTextId,
						userId,
						text: newText,
						x: typingPosition.x,
						y: typingPosition.y,
					},
				}),
			);
		}
	};

	// Handle key press while typing
	const handleKeyDown = (e: React.KeyboardEvent) =&gt; {
		if (e.key === &quot;Enter&quot;) {
			// Finalize the text
			if (textInput.trim() &amp;&amp; wsRef.current &amp;&amp; wsRef.current.readyState === WebSocket.OPEN &amp;&amp; currentTextId) {
				wsRef.current.send(
					JSON.stringify({
						type: &quot;updateText&quot;,
						data: {
							id: currentTextId,
							userId,
							text: textInput,
							x: typingPosition.x,
							y: typingPosition.y,
						},
					}),
				);
			} else if (wsRef.current &amp;&amp; wsRef.current.readyState === WebSocket.OPEN &amp;&amp; currentTextId) {
				// Remove empty text
				wsRef.current.send(
					JSON.stringify({
						type: &quot;removeText&quot;,
						data: { id: currentTextId },
					}),
				);
			}
			setTextInput(&quot;&quot;);
			setIsTyping(false);
			setCurrentTextId(null);
		} else if (e.key === &quot;Escape&quot;) {
			// Cancel typing and remove text
			if (wsRef.current &amp;&amp; wsRef.current.readyState === WebSocket.OPEN &amp;&amp; currentTextId) {
				wsRef.current.send(
					JSON.stringify({
						type: &quot;removeText&quot;,
						data: { id: currentTextId },
					}),
				);
			}
			setTextInput(&quot;&quot;);
			setIsTyping(false);
			setCurrentTextId(null);
		}
	};

	return (
		&lt;div className=&quot;app-container&quot;&gt;
			&lt;div className=&quot;controls&quot;&gt;
				&lt;div className=&quot;control-group&quot;&gt;
					&lt;label&gt;Room:&lt;/label&gt;
					&lt;input
						type=&quot;text&quot;
						value={roomId}
						onChange={(e) =&gt; setRoomId(e.target.value)}
						placeholder=&quot;Enter room name&quot;
					/&gt;
				&lt;/div&gt;
				&lt;div className=&quot;user-info&quot;&gt;
					Your ID: &lt;span style={{ color: getColorForUser(userId) }}&gt;{userId}&lt;/span&gt;
				&lt;/div&gt;
			&lt;/div&gt;

			&lt;div ref={containerRef} className=&quot;canvas-container&quot;&gt;
				&lt;div
					ref={canvasRef}
					className=&quot;canvas&quot;
					style={{
						width: `${CANVAS_WIDTH}px`,
						height: `${CANVAS_HEIGHT}px`,
						transform: `translate(-50%, -50%) scale(${scale})`,
					}}
					onMouseMove={handleMouseMove}
					onClick={handleCanvasClick}
					tabIndex={0}
					onKeyDown={handleKeyDown}
				&gt;
					{/* Render text labels */}
					{textLabels
						.filter((label) =&gt; label.id !== currentTextId)
						.map((label) =&gt; (
							&lt;div
								key={label.id}
								className=&quot;text-label&quot;
								style={{
									left: label.x,
									top: label.y,
									color: getColorForUser(label.userId),
								}}
							&gt;
								{label.text}
							&lt;/div&gt;
						))}

					{/* Render text being typed */}
					{isTyping &amp;&amp; (
						&lt;div
							className=&quot;typing-container&quot;
							style={{
								left: typingPosition.x,
								top: typingPosition.y,
							}}
						&gt;
							&lt;div
								className=&quot;typing-text&quot;
								style={{
									color: getColorForUser(userId),
								}}
							&gt;
								{textInput}
								&lt;span className=&quot;typing-cursor&quot;&gt;|&lt;/span&gt;
							&lt;/div&gt;
							&lt;div
								className=&quot;enter-hint&quot;
								style={{
									borderColor: getColorForUser(userId),
									color: getColorForUser(userId),
								}}
							&gt;
								enter
							&lt;/div&gt;
						&lt;/div&gt;
					)}

					{/* Render cursors */}
					{Object.entries(cursors).map(([id, cursor]) =&gt; {
						const color = getColorForUser(cursor.userId);
						const isOwnCursor = id === userId;
						return (
							&lt;div
								key={id}
								className=&quot;cursor&quot;
								style={{
									left: cursor.x,
									top: cursor.y,
								}}
							&gt;
								&lt;svg
									width=&quot;20&quot;
									height=&quot;24&quot;
									viewBox=&quot;0 0 20 24&quot;
									fill=&quot;none&quot;
									xmlns=&quot;http://www.w3.org/2000/svg&quot;
									className=&quot;cursor-svg&quot;
								&gt;
									&lt;path
										d=&quot;M10 4 L4 18 L16 18 Z&quot;
										fill={color}
										stroke=&quot;white&quot;
										strokeWidth=&quot;1.5&quot;
										strokeLinecap=&quot;round&quot;
										strokeLinejoin=&quot;round&quot;
										transform=&quot;rotate(-45 10 12)&quot;
									/&gt;
								&lt;/svg&gt;
								&lt;div
									className=&quot;cursor-label&quot;
									style={{
										backgroundColor: color,
										borderColor: `${color}40`,
									}}
								&gt;
									{isOwnCursor ? &quot;you&quot; : cursor.userId}
								&lt;/div&gt;
							&lt;/div&gt;
						);
					})}

					{!connected &amp;&amp; (
						&lt;div className=&quot;loading-overlay&quot;&gt;Connecting to room...&lt;/div&gt;
					)}

					{/* Hidden input to capture typing */}
					{isTyping &amp;&amp; (
						&lt;input
							type=&quot;text&quot;
							className=&quot;hidden-input&quot;
							value={textInput}
							onChange={(e) =&gt; handleTextChange(e.target.value)}
							onBlur={() =&gt; {
								if (!textInput.trim() &amp;&amp; wsRef.current &amp;&amp; wsRef.current.readyState === WebSocket.OPEN &amp;&amp; currentTextId) {
									wsRef.current.send(
										JSON.stringify({
											type: &quot;removeText&quot;,
											data: { id: currentTextId },
										}),
									);
									setCurrentTextId(null);
								}
								setIsTyping(false);
							}}
							autoFocus
						/&gt;
					)}
				&lt;/div&gt;
			&lt;/div&gt;
		&lt;/div&gt;
	);
}
">
<input type="hidden" name="project[files][src/frontend/index.html]" value="&lt;!DOCTYPE html&gt;
&lt;html lang=&quot;en&quot;&gt;
&lt;head&gt;
    &lt;meta charset=&quot;UTF-8&quot;&gt;
    &lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0&quot;&gt;
    &lt;title&gt;Cursors Example&lt;/title&gt;
    &lt;style&gt;
        * {
            box-sizing: border-box;
        }
        body {
            font-family: -apple-system, BlinkMacSystemFont, &#39;Segoe UI&#39;, Roboto, sans-serif;
            margin: 0;
            padding: 0;
            background-color: #0a0a0a;
            color: #e0e0e0;
            overflow: hidden;
            height: 100vh;
        }
        .app-container {
            position: relative;
            width: 100vw;
            height: 100vh;
        }
        .controls {
            position: absolute;
            top: 20px;
            left: 20px;
            right: 20px;
            z-index: 200;
            background: rgba(26, 26, 26, 0.9);
            backdrop-filter: blur(10px);
            padding: 16px 20px;
            border-radius: 8px;
            border: 1px solid #2a2a2a;
            display: flex;
            justify-content: space-between;
            align-items: center;
            flex-shrink: 0;
        }
        .control-group {
            display: flex;
            gap: 10px;
            align-items: center;
        }
        .control-group label {
            font-weight: 500;
            color: #a0a0a0;
        }
        .control-group input {
            padding: 8px 12px;
            border: 1px solid #333;
            border-radius: 4px;
            min-width: 200px;
            background: #2a2a2a;
            color: #e0e0e0;
        }
        .control-group input:focus {
            outline: none;
            border-color: #4a9eff;
        }
        .user-info {
            font-size: 14px;
            color: #808080;
        }
        .canvas-container {
            position: absolute;
            top: 0;
            left: 0;
            right: 0;
            bottom: 0;
            overflow: hidden;
            background: #0a0a0a;
        }
        .canvas {
            position: absolute;
            top: 50%;
            left: 50%;
            transform-origin: center center;
            background: #1a1a1a;
            cursor: none;
            overflow: hidden;
        }
        .cursor {
            position: absolute;
            pointer-events: none;
            z-index: 100;
        }
        .cursor-svg {
            filter: drop-shadow(0 2px 4px rgba(0,0,0,0.3));
        }
        .cursor-label {
            position: absolute;
            top: 20px;
            left: 18px;
            padding: 4px 8px;
            border-radius: 8px;
            color: white;
            font-size: 12px;
            white-space: nowrap;
            border: 1px solid;
        }
        .text-label {
            position: absolute;
            font-size: 16px;
            font-weight: 500;
            pointer-events: none;
            white-space: nowrap;
            transform: translate(0, -50%);
            text-shadow: 0 2px 4px rgba(0,0,0,0.5);
            line-height: 1;
        }
        .typing-container {
            position: absolute;
            display: flex;
            align-items: center;
            gap: 8px;
            pointer-events: none;
            transform: translate(0, -50%);
        }
        .typing-text {
            font-size: 16px;
            font-weight: 500;
            white-space: nowrap;
            text-shadow: 0 2px 4px rgba(0,0,0,0.5);
            line-height: 1;
        }
        .typing-cursor {
            animation: blink 1s infinite;
            margin-left: 2px;
            display: inline-block;
            position: relative;
            top: -2px;
        }
        @keyframes blink {
            0%, 50% { opacity: 1; }
            51%, 100% { opacity: 0; }
        }
        .enter-hint {
            font-size: 11px;
            font-weight: 500;
            white-space: nowrap;
            padding: 2px 6px;
            border: 1px solid;
            border-radius: 4px;
            background: rgba(0, 0, 0, 0.5);
            opacity: 0.7;
        }
        .hidden-input {
            position: absolute;
            top: -9999px;
            left: -9999px;
            width: 1px;
            height: 1px;
            opacity: 0;
            pointer-events: none;
        }
        .loading-overlay {
            display: flex;
            align-items: center;
            justify-content: center;
            height: 100%;
            font-size: 18px;
            color: #808080;
        }
        .instructions {
            background: #1a1a1a;
            padding: 20px;
            border-radius: 8px;
            border: 1px solid #2a2a2a;
            margin: 20px;
        }
        .instructions h4 {
            margin-top: 0;
            color: #4a9eff;
        }
        .instructions ul {
            margin: 0;
            padding-left: 20px;
        }
        .instructions li {
            margin-bottom: 8px;
            color: #a0a0a0;
        }
    &lt;/style&gt;
&lt;/head&gt;
&lt;body&gt;
    &lt;div id=&quot;root&quot;&gt;&lt;/div&gt;
    &lt;script type=&quot;module&quot; src=&quot;/main.tsx&quot;&gt;&lt;/script&gt;
&lt;/body&gt;
&lt;/html&gt;">
<input type="hidden" name="project[files][src/frontend/main.tsx]" value="import { StrictMode } from &quot;react&quot;;
import { createRoot } from &quot;react-dom/client&quot;;
import { App } from &quot;./App&quot;;

const root = document.getElementById(&quot;root&quot;);
if (!root) throw new Error(&quot;Root element not found&quot;);

createRoot(root).render(
	&lt;StrictMode&gt;
		&lt;App /&gt;
	&lt;/StrictMode&gt;
);
">
<input type="hidden" name="project[description]" value="generated by https://pkg.pr.new">
<input type="hidden" name="project[template]" value="node">
<input type="hidden" name="project[title]" value="example-cursors-raw-websocket">
</form>
<script>document.getElementById("mainForm").submit();</script>

</body></html>