Skip to content

janwilmake/textareado

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 

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

No packages published