-
Notifications
You must be signed in to change notification settings - Fork 0
Collaborative Textarea
janwilmake/textareado
Folders and files
| Name | Name | Last commit message | Last commit date | |
|---|---|---|---|---|
Repository files navigation
/**
This minimal example shows how to create realtime text sync between a durable object and several clients
Based on the cursor example but adapted for collaborative text editing
*/
export class TextDO {
constructor(state, env) {
this.state = state;
this.sessions = new Map();
this.textContent = "";
this.version = 0;
}
async fetch(request) {
const url = new URL(request.url);
if (url.pathname === "/websocket") {
if (request.headers.get("Upgrade") !== "websocket") {
return new Response("Expected WebSocket", { status: 400 });
}
const [client, server] = Object.values(new WebSocketPair());
await this.handleSession(server);
return new Response(null, { status: 101, webSocket: client });
}
return new Response("Not found", { status: 404 });
}
async handleSession(webSocket) {
webSocket.accept();
const sessionId = crypto.randomUUID();
const color = `hsl(${Math.floor(Math.random() * 360)}, 70%, 60%)`;
this.sessions.set(sessionId, { webSocket, color });
webSocket.send(
JSON.stringify({
type: "init",
sessionId,
text: this.textContent,
version: this.version,
sessionCount: this.sessions.size,
}),
);
webSocket.addEventListener("message", (msg) => {
try {
const data = JSON.parse(msg.data);
if (data.type === "text") {
this.textContent = data.text;
this.version = data.version;
this.broadcast(sessionId, {
type: "text",
text: data.text,
version: data.version,
fromSession: sessionId,
});
}
} catch (err) {
console.error("Error:", err);
}
});
webSocket.addEventListener("close", () => {
this.sessions.delete(sessionId);
this.broadcast(sessionId, {
type: "leave",
sessionId,
sessionCount: this.sessions.size,
});
});
// Notify others of new session
this.broadcast(sessionId, {
type: "join",
sessionId,
sessionCount: this.sessions.size,
});
}
broadcast(senderSessionId, message) {
const messageStr = JSON.stringify(message);
for (const [sessionId, session] of this.sessions.entries()) {
if (sessionId !== senderSessionId) {
try {
session.webSocket.send(messageStr);
} catch (err) {
this.sessions.delete(sessionId);
}
}
}
}
}
export default {
async fetch(request, env) {
const url = new URL(request.url);
if (request.method === "OPTIONS") {
return new Response(null, {
status: 204,
headers: { "Access-Control-Allow-Origin": "*" },
});
}
if (url.pathname === "/ws") {
const roomId = url.searchParams.get("room") || "default";
const roomObject = env.TEXT.get(env.TEXT.idFromName(roomId));
const newUrl = new URL(url);
newUrl.pathname = "/websocket";
return roomObject.fetch(new Request(newUrl, request));
}
if (url.pathname === "/" || url.pathname === "/index.html") {
return new Response(HTML_CONTENT, {
headers: { "Content-Type": "text/html" },
});
}
return new Response("Not found", { status: 404 });
},
};
const HTML_CONTENT = `<!DOCTYPE html>
<html>
<head>
<title>Collaborative Text Editor</title>
<style>
body{margin:0;background:#f5f5f5;font-family:Arial,sans-serif;padding:20px}
header{background:white;padding:15px;border-radius:8px;margin-bottom:20px;box-shadow:0 2px 10px rgba(0,0,0,0.1)}
#status{color:#666;font-size:14px}
#editor{width:100%;height:400px;padding:15px;border:2px solid #ddd;border-radius:8px;font-family:Monaco,Courier,monospace;font-size:14px;resize:vertical;background:white;box-sizing:border-box}
#editor:focus{border-color:#007bff;outline:none}
aside{position:fixed;top:20px;right:20px;background:rgba(0,0,0,0.8);color:white;padding:10px;border-radius:5px;min-width:200px;font-size:12px}
.info{margin:5px 0}
footer{margin-top:20px;text-align:center;color:#666}
footer a{color:#007bff;text-decoration:none}
</style>
</head>
<body>
<header>
<h1>Collaborative Text Editor</h1>
<div id="status">Connecting...</div>
</header>
<textarea id="editor" placeholder="Start typing... changes will sync in real-time across all connected clients."></textarea>
<aside id="info"></aside>
<footer><a href="https://github.com/janwilmake/cursordo">Based on CursorDO</a></footer>
<script>
class TextApp {
constructor() {
this.ws = null;
this.sessionId = null;
this.version = 0;
this.sessionCount = 0;
this.statusEl = document.getElementById('status');
this.infoEl = document.getElementById('info');
this.editorEl = document.getElementById('editor');
this.isUpdating = false;
this.connect();
this.setupEventListeners();
}
connect() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = protocol + '//' + window.location.host + '/ws?room=main';
this.ws = new WebSocket(wsUrl);
this.ws.onopen = () => {
this.statusEl.textContent = 'Connected - Ready to collaborate!';
this.statusEl.style.color = '#28a745';
};
this.ws.onmessage = (event) => {
const message = JSON.parse(event.data);
this.handleMessage(message);
};
this.ws.onclose = () => {
this.statusEl.textContent = 'Disconnected. Reconnecting...';
this.statusEl.style.color = '#dc3545';
setTimeout(() => this.connect(), 1000);
};
}
handleMessage(message) {
switch (message.type) {
case 'init':
this.sessionId = message.sessionId;
this.version = message.version;
this.sessionCount = message.sessionCount;
this.statusEl.textContent = 'Connected - Session: ' + this.sessionId.slice(0,8);
// Set initial text content without triggering sync
this.isUpdating = true;
const cursorPos = this.editorEl.selectionStart;
this.editorEl.value = message.text;
this.editorEl.setSelectionRange(cursorPos, cursorPos);
this.isUpdating = false;
this.updateInfo();
break;
case 'text':
if (message.fromSession !== this.sessionId) {
this.isUpdating = true;
const cursorPos = this.editorEl.selectionStart;
this.editorEl.value = message.text;
this.version = message.version;
// Try to preserve cursor position
this.editorEl.setSelectionRange(cursorPos, cursorPos);
this.isUpdating = false;
}
break;
case 'join':
this.sessionCount = message.sessionCount;
this.updateInfo();
break;
case 'leave':
this.sessionCount = message.sessionCount;
this.updateInfo();
break;
}
}
updateInfo() {
this.infoEl.innerHTML =
'<div class="info"><strong>Session ID:</strong><br>' + this.sessionId?.slice(0,8) + '</div>' +
'<div class="info"><strong>Connected Users:</strong> ' + this.sessionCount + '</div>' +
'<div class="info"><strong>Version:</strong> ' + this.version + '</div>' +
'<div class="info"><strong>Characters:</strong> ' + this.editorEl.value.length + '</div>';
}
setupEventListeners() {
let lastSendTime = 0;
const throttleDelay = 200; // Slower throttle for text
const sendText = () => {
const now = Date.now();
if (!this.isUpdating && now - lastSendTime > throttleDelay && this.ws && this.ws.readyState === WebSocket.OPEN) {
this.version++;
this.ws.send(JSON.stringify({
type: 'text',
text: this.editorEl.value,
version: this.version
}));
lastSendTime = now;
this.updateInfo();
}
};
this.editorEl.addEventListener('input', sendText);
this.editorEl.addEventListener('paste', () => {
// Small delay to let paste content settle
setTimeout(sendText, 10);
});
}
}
new TextApp();
</script>
</body>
</html>`;
About
Collaborative Textarea
Resources
Stars
Watchers
Forks
Releases
No releases published
Packages 0
No packages published