Diffdown is a real-time collaborative Markdown editor/previewer built on the AT Protocol diffdown.com

Collaboration Feature Implementation Plan#

For agentic workers: REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Implement real-time collaboration for Markdown documents with up to 5 users, paragraph-level comments, and invite-based access control.

Architecture: Server-side collaboration hub with WebSocket connections. Server maintains canonical document state, broadcasts edits, debounces ATProto persistence. Comments stored in separate ATProto collection.

Tech Stack: Go (stdlib, gorilla/websocket), ATProto XRPC, SQLite


Chunk 1: Database Migration and Models#

Task 1.1: Create invites migration#

Files:

  • Create: migrations/005_create_invites.sql

  • Step 1: Write the migration

CREATE TABLE IF NOT EXISTS invites (
    id TEXT PRIMARY KEY,
    document_rkey TEXT NOT NULL,
    token TEXT NOT NULL UNIQUE,
    created_by_did TEXT NOT NULL,
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    expires_at DATETIME NOT NULL
);

CREATE INDEX idx_invites_document ON invites(document_rkey);
CREATE INDEX idx_invites_token ON invites(token);
  • Step 2: Commit
git add migrations/005_create_invites.sql
git commit -m "feat: add invites table migration"

Task 1.2: Update models for collaboration#

Files:

  • Modify: internal/model/models.go

  • Step 1: Write the failing test (skip - no existing tests)

  • Step 2: Add Invite and Comment types

type Invite struct {
    ID            string
    DocumentRKey  string
    Token         string
    CreatedBy     string
    CreatedAt     time.Time
    ExpiresAt     time.Time
}

type Comment struct {
    URI         string
    DocumentURI string
    ParagraphID string
    Text        string
    AuthorDID   string
    AuthorName  string
    CreatedAt   string
}
  • Step 3: Add Collaborators field to Document

In the Document struct, add:

Collaborators []string `json:"collaborators,omitempty"`
  • Step 4: Commit
git add internal/model/models.go
git commit -m "feat: add Invite, Comment models and Document.collaborators"

Chunk 2: Collaboration Package (Core Logic)#

Task 2.1: Create collaboration hub#

Files:

  • Create: internal/collaboration/hub.go

  • Step 1: Write the hub with WebSocket room management

package collaboration

import (
    "log"
    "sync"
)

type Hub struct {
    rooms map[string]*Room
    mu    sync.RWMutex
}

type Room struct {
    documentRKey string
    clients      map[*Client]bool
    broadcast    chan []byte
    register     chan *Client
    unregister   chan *Client
    mu           sync.RWMutex
}

func NewHub() *Hub {
    return &Hub{
        rooms: make(map[string]*Room),
    }
}

func (h *Hub) GetOrCreateRoom(rkey string) *Room {
    h.mu.Lock()
    defer h.mu.Unlock()
    if room, exists := h.rooms[rkey]; exists {
        return room
    }
    room := &Room{
        documentRKey: rkey,
        clients:      make(map[*Client]bool),
        broadcast:    make(chan []byte, 256),
        register:     make(chan *Client),
        unregister:   make(chan *Client),
    }
    h.rooms[rkey] = room
    go room.run()
    return room
}

func (r *Room) run() {
    for {
        select {
        case client := <-r.register:
            r.mu.Lock()
            r.clients[client] = true
            r.mu.Unlock()
            r.broadcastPresence()
        case client := <-r.unregister:
            r.mu.Lock()
            if _, ok := r.clients[client]; ok {
                delete(r.clients, client)
                close(client.send)
            }
            r.mu.Unlock()
            r.broadcastPresence()
        case message := <-r.broadcast:
            r.mu.RLock()
            for client := range r.clients {
                select {
                case client.send <- message:
                default:
                    close(client.send)
                    delete(r.clients, client)
                }
            }
            r.mu.RUnlock()
        }
    }
}

func (r *Room) Broadcast(message []byte) {
    r.broadcast <- message
}

func (r *Room) broadcastPresence() {
    // Implementation in Task 2.2
}
  • Step 2: Commit
git add internal/collaboration/hub.go
git commit -m "feat: add collaboration hub with room management"

Task 2.2: Create client representation#

Files:

  • Create: internal/collaboration/client.go

  • Step 1: Write the client struct

package collaboration

import (
    "github.com/gorilla/websocket"
)

type Client struct {
    hub       *Hub
    conn      *websocket.Conn
    send      chan []byte
    DID       string
    Name      string
    Color     string
    roomKey   string
}

type ClientMessage struct {
    Type     string          `json:"type"`
    RKey     string          `json:"rkey,omitempty"`
    DID      string          `json:"did,omitempty"`
    Delta    json.RawMessage `json:"delta,omitempty"`
    Cursor   *CursorPos     `json:"cursor,omitempty"`
    Comment  *CommentMsg    `json:"comment,omitempty"`
}

type CursorPos struct {
    Position      int `json:"position"`
    SelectionEnd  int `json:"selectionEnd"`
}

type CommentMsg struct {
    ParagraphID string `json:"paragraphId"`
    Text        string `json:"text"`
}

type PresenceUser struct {
    DID   string `json:"did"`
    Name  string `json:"name"`
    Color string `json:"color"`
}

type PresenceMessage struct {
    Type  string          `json:"type"`
    Users []PresenceUser `json:"users"`
}

func NewClient(hub *Hub, conn *websocket.Conn, did, name, color, roomKey string) *Client {
    return &Client{
        hub:     hub,
        conn:    conn,
        send:    make(chan []byte, 256),
        DID:     did,
        Name:    name,
        Color:   color,
        roomKey: roomKey,
    }
}

func (c *Client) ReadPump() {
    defer func() {
        c.hub.unregister <- c
        c.conn.Close()
    }()
    for {
        _, message, err := c.conn.ReadMessage()
        if err != nil {
            if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
                log.Printf("WebSocket error: %v", err)
            }
            break
        }
        // Handle message - dispatch to appropriate handler
    }
}

func (c *Client) WritePump() {
    defer c.conn.Close()
    for {
        message, ok := <-c.send
        if !ok {
            c.conn.WriteMessage(websocket.CloseMessage, []byte{})
            return
        }
        if err := c.conn.WriteMessage(websocket.TextMessage, message); err != nil {
            return
        }
    }
}
  • Step 2: Implement presence broadcasting in hub

Add to hub.go:

func (r *Room) GetPresence() []PresenceUser {
    r.mu.RLock()
    defer r.mu.RUnlock()
    users := make([]PresenceUser, 0, len(r.clients))
    for client := range r.clients {
        users = append(users, PresenceUser{
            DID:   client.DID,
            Name:  client.Name,
            Color: client.Color,
        })
    }
    return users
}

func (r *Room) broadcastPresence() {
    presence := PresenceMessage{
        Type:  "presence",
        Users: r.GetPresence(),
    }
    data, _ := json.Marshal(presence)
    r.Broadcast(data)
}
  • Step 3: Commit
git add internal/collaboration/client.go internal/collaboration/hub.go
git commit -m "feat: add client representation and presence broadcasting"

Task 2.3: Create invite system#

Files:

  • Create: internal/collaboration/invite.go

  • Step 1: Write the invite logic

package collaboration

import (
    "crypto/rand"
    "crypto/sha256"
    "encoding/hex"
    "time"

    "github.com/limeleaf/diffdown/internal/db"
)

func GenerateInviteToken() (string, error) {
    bytes := make([]byte, 32)
    if _, err := rand.Read(bytes); err != nil {
        return "", err
    }
    hash := sha256.Sum256(bytes)
    return hex.EncodeToString(hash[:]), nil
}

func CreateInvite(db *db.DB, documentRKey, createdByDID string) (*model.Invite, error) {
    token, err := GenerateInviteToken()
    if err != nil {
        return nil, err
    }

    invite := &model.Invite{
        ID:           db.NewID(),
        DocumentRKey: documentRKey,
        Token:        token,
        CreatedBy:    createdByDID,
        CreatedAt:    time.Now(),
        ExpiresAt:    time.Now().Add(7 * 24 * time.Hour),
    }

    err = db.CreateInvite(invite)
    return invite, err
}

func ValidateInvite(db *db.DB, token, documentRKey string) (*model.Invite, error) {
    invite, err := db.GetInviteByToken(token)
    if err != nil {
        return nil, err
    }
    if invite.DocumentRKey != documentRKey {
        return nil, fmt.Errorf("invite does not match document")
    }
    if time.Now().After(invite.ExpiresAt) {
        return nil, fmt.Errorf("invite expired")
    }
    return invite, nil
}
  • Step 2: Add DB methods

In internal/db/db.go, add:

func (db *DB) CreateInvite(invite *model.Invite) error {
    _, err := db.Exec(`
        INSERT INTO invites (id, document_rkey, token, created_by_did, created_at, expires_at)
        VALUES (?, ?, ?, ?, ?, ?)`,
        invite.ID, invite.DocumentRKey, invite.Token, invite.CreatedBy, invite.CreatedAt, invite.ExpiresAt)
    return err
}

func (db *DB) GetInviteByToken(token string) (*model.Invite, error) {
    row := db.QueryRow(`SELECT id, document_rkey, token, created_by_did, created_at, expires_at FROM invites WHERE token = ?`, token)
    var invite model.Invite
    err := row.Scan(&invite.ID, &invite.DocumentRKey, &invite.Token, &invite.CreatedBy, &invite.CreatedAt, &invite.ExpiresAt)
    if err != nil {
        return nil, err
    }
    return &invite, nil
}
  • Step 3: Commit
git add internal/collaboration/invite.go internal/db/db.go
git commit -m "feat: add invite generation and validation"

Task 2.4: Create OT helpers#

Files:

  • Create: internal/collaboration/ot.go

  • Step 1: Write simplified OT logic

package collaboration

import "sync"

type OTEngine struct {
    mu           sync.Mutex
    documentText string
    version      int
}

func NewOTEngine(initialText string) *OTEngine {
    return &OTEngine{
        documentText: initialText,
        version:      0,
    }
}

type Operation struct {
    From    int    `json:"from"`
    To      int    `json:"to"`
    Insert  string `json:"insert"`
    Author  string `json:"author"`
}

func (ot *OTEngine) Apply(op Operation) string {
    ot.mu.Lock()
    defer ot.mu.Unlock()

    // Simple last-write-wins
    if op.To > len(ot.documentText) {
        op.To = len(ot.documentText)
    }
    if op.From > len(ot.documentText) {
        op.From = len(ot.documentText)
    }

    newText := ot.documentText[:op.From] + op.Insert + ot.documentText[op.To:]
    ot.documentText = newText
    ot.version++

    return ot.documentText
}

func (ot *OTEngine) GetText() string {
    ot.mu.Lock()
    defer ot.mu.Unlock()
    return ot.documentText
}

func (ot *OTEngine) GetVersion() int {
    ot.mu.Lock()
    defer ot.mu.Unlock()
    return ot.version
}
  • Step 2: Commit
git add internal/collaboration/ot.go
git commit -m "feat: add simplified OT engine"

Chunk 3: HTTP Handlers#

Task 3.1: Document invite handler#

Files:

  • Modify: internal/handler/handler.go

  • Step 1: Add DocumentInvite handler

func (h *Handler) DocumentInvite(w http.ResponseWriter, r *http.Request) {
    user := h.currentUser(r)
    if user == nil {
        http.Redirect(w, r, "/auth/login", http.StatusSeeOther)
        return
    }

    rKey := model.RKeyFromURI(r.URL.Path)
    if rKey == "" {
        http.Error(w, "Invalid document", http.StatusBadRequest)
        return
    }

    // Get document to verify ownership
    client := h.xrpcClient(r)
    if client == nil {
        h.render(w, "document_edit.html", PageData{Error: "Not authenticated with ATProto"})
        return
    }

    doc, err := client.GetDocument(rKey)
    if err != nil {
        http.Error(w, "Document not found", http.StatusNotFound)
        return
    }

    // Verify user is creator (DID matches)
    session, _ := h.db.GetATProtoSession(user.ID)
    if session == nil || session.DID != doc.URI {
        http.Error(w, "Unauthorized", http.StatusForbidden)
        return
    }

    // Check collaborator limit (5 max)
    if len(doc.Collaborators) >= 5 {
        http.Error(w, "Maximum collaborators reached", http.StatusBadRequest)
        return
    }

    // Create invite
    invite, err := collaboration.CreateInvite(h.db, rKey, session.DID)
    if err != nil {
        log.Printf("DocumentInvite: create invite: %v", err)
        http.Error(w, "Failed to create invite", http.StatusInternalServerError)
        return
    }

    inviteLink := fmt.Sprintf("%s/doc/%s?invite=%s", os.Getenv("BASE_URL"), rKey, invite.Token)
    h.render(w, "document_edit.html", PageData{
        Content: map[string]interface{}{
            "document":    doc,
            "inviteLink":  inviteLink,
        },
    })
}
  • Step 2: Register route in main.go
mux.HandleFunc("POST /api/docs/{rkey}/invite", handler.DocumentInvite)
  • Step 3: Commit
git add internal/handler/handler.go cmd/server/main.go
git commit -m "feat: add document invite handler"

Task 3.2: Accept invite handler#

Files:

  • Modify: internal/handler/handler.go

  • Step 1: Add AcceptInvite handler

func (h *Handler) AcceptInvite(w http.ResponseWriter, r *http.Request) {
    user := h.currentUser(r)
    if user == nil {
        http.Redirect(w, r, "/auth/login", http.StatusSeeOther)
        return
    }

    rKey := model.RKeyFromURI(r.URL.Path)
    inviteToken := r.URL.Query().Get("invite")
    if inviteToken == "" {
        http.Error(w, "Invalid invite", http.StatusBadRequest)
        return
    }

    // Validate invite
    invite, err := collaboration.ValidateInvite(h.db, inviteToken, rKey)
    if err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }

    // Get ATProto session
    session, err := h.db.GetATProtoSession(user.ID)
    if err != nil || session == nil {
        http.Redirect(w, r, "/auth/atproto", http.StatusSeeOther)
        return
    }

    // Add user to collaborators via ATProto
    client, err := h.newXRPCClient(session)
    if err != nil {
        http.Error(w, "Failed to connect to ATProto", http.StatusInternalServerError)
        return
    }

    // Get current document
    doc, err := client.GetDocument(rKey)
    if err != nil {
        http.Error(w, "Document not found", http.StatusNotFound)
        return
    }

    // Check if already collaborator
    for _, c := range doc.Collaborators {
        if c == session.DID {
            http.Redirect(w, r, "/doc/"+rKey, http.StatusSeeOther)
            return
        }
    }

    // Add to collaborators
    doc.Collaborators = append(doc.Collaborators, session.DID)
    err = client.PutDocument(rKey, doc)
    if err != nil {
        log.Printf("AcceptInvite: add collaborator: %v", err)
        http.Error(w, "Failed to add collaborator", http.StatusInternalServerError)
        return
    }

    // Delete invite token after use
    h.db.DeleteInvite(invite.Token)

    http.Redirect(w, r, "/doc/"+rKey, http.StatusSeeOther)
}
  • Step 2: Register route
mux.HandleFunc("GET /doc/{rkey}/accept", handler.AcceptInvite)
  • Step 3: Commit
git add internal/handler/handler.go cmd/server/main.go
git commit -m "feat: add accept invite handler"

Task 3.3: Comment handlers#

Files:

  • Modify: internal/handler/handler.go

  • Step 1: Add CommentCreate handler

func (h *Handler) CommentCreate(w http.ResponseWriter, r *http.Request) {
    user := h.currentUser(r)
    if user == nil {
        http.Error(w, "Unauthorized", http.StatusUnauthorized)
        return
    }

    rKey := model.RKeyFromURI(r.URL.Path)
    if rKey == "" {
        http.Error(w, "Invalid document", http.StatusBadRequest)
        return
    }

    // Parse request body
    var req struct {
        ParagraphID string `json:"paragraphId"`
        Text        string `json:"text"`
    }
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        http.Error(w, "Invalid request", http.StatusBadRequest)
        return
    }

    if req.Text == "" {
        http.Error(w, "Comment text required", http.StatusBadRequest)
        return
    }

    // Get ATProto session
    session, err := h.db.GetATProtoSession(user.ID)
    if err != nil || session == nil {
        http.Error(w, "Not authenticated with ATProto", http.StatusUnauthorized)
        return
    }

    client, err := h.newXRPCClient(session)
    if err != nil {
        http.Error(w, "Failed to connect to ATProto", http.StatusInternalServerError)
        return
    }

    // Create comment record
    comment := &model.Comment{
        DocumentURI: fmt.Sprintf("at://%s/com.diffdown.document/%s", session.DID, rKey),
        ParagraphID: req.ParagraphID,
        Text:        req.Text,
        AuthorDID:   session.DID,
    }

    uri, err := client.CreateComment(comment)
    if err != nil {
        log.Printf("CommentCreate: %v", err)
        http.Error(w, "Failed to create comment", http.StatusInternalServerError)
        return
    }

    h.jsonResponse(w, map[string]string{"uri": uri}, http.StatusCreated)
}
  • Step 2: Add CommentList handler
func (h *Handler) CommentList(w http.ResponseWriter, r *http.Request) {
    rKey := model.RKeyFromURI(r.URL.Path)
    if rKey == "" {
        http.Error(w, "Invalid document", http.StatusBadRequest)
        return
    }

    user := h.currentUser(r)
    if user == nil {
        http.Error(w, "Unauthorized", http.StatusUnauthorized)
        return
    }

    session, err := h.db.GetATProtoSession(user.ID)
    if err != nil || session == nil {
        http.Error(w, "Not authenticated with ATProto", http.StatusUnauthorized)
        return
    }

    client, err := h.newXRPCClient(session)
    if err != nil {
        http.Error(w, "Failed to connect to ATProto", http.StatusInternalServerError)
        return
    }

    comments, err := client.ListComments(rKey)
    if err != nil {
        log.Printf("CommentList: %v", err)
        http.Error(w, "Failed to list comments", http.StatusInternalServerError)
        return
    }

    h.jsonResponse(w, comments, http.StatusOK)
}
  • Step 3: Register routes
mux.HandleFunc("POST /api/docs/{rkey}/comments", handler.CommentCreate)
mux.HandleFunc("GET /api/docs/{rkey}/comments", handler.CommentList)
  • Step 4: Commit
git add internal/handler/handler.go cmd/server/main.go
git commit -m "feat: add comment handlers"

Chunk 4: WebSocket Handler#

Task 4.1: WebSocket upgrade handler#

Files:

  • Modify: internal/handler/handler.go, cmd/server/main.go

  • Step 1: Add CollaboratorWebSocket handler

var upgrader = websocket.Upgrader{
    CheckOrigin: func(r *http.Request) bool { return true },
}

func (h *Handler) CollaboratorWebSocket(w http.ResponseWriter, r *http.Request) {
    rKey := model.RKeyFromURI(r.URL.Path)
    if rKey == "" {
        http.Error(w, "Invalid document", http.StatusBadRequest)
        return
    }

    // Get access token and DPoP proof from query params
    accessToken := r.URL.Query().Get("access_token")
    dpopProof := r.URL.Query().Get("dpop_proof")
    if accessToken == "" || dpopProof == "" {
        http.Error(w, "Missing auth tokens", http.StatusUnauthorized)
        return
    }

    // Validate tokens and get DID
    did, name, err := h.validateWSToken(accessToken, dpopProof)
    if err != nil {
        http.Error(w, "Invalid tokens", http.StatusUnauthorized)
        return
    }

    // Get document and verify collaborator access
    session, _ := h.db.GetATProtoSessionByDID(did)
    if session == nil {
        http.Error(w, "No ATProto session", http.StatusUnauthorized)
        return
    }

    client, err := h.newXRPCClient(session)
    if err != nil {
        http.Error(w, "Failed to connect to ATProto", http.StatusInternalServerError)
        return
    }

    doc, err := client.GetDocument(rKey)
    if err != nil {
        http.Error(w, "Document not found", http.StatusNotFound)
        return
    }

    // Check if user is collaborator
    isCollaborator := false
    for _, c := range doc.Collaborators {
        if c == did {
            isCollaborator = true
            break
        }
    }
    if !isCollaborator {
        http.Error(w, "Not a collaborator", http.StatusForbidden)
        return
    }

    // Generate color based on DID
    color := colorFromDID(did)

    // Upgrade connection
    conn, err := upgrader.Upgrade(w, r, nil)
    if err != nil {
        log.Printf("WebSocket upgrade failed: %v", err)
        return
    }

    // Get room and register client
    room := h.CollaborationHub.GetOrCreateRoom(rKey)
    wsClient := collaboration.NewClient(h.CollaborationHub, conn, did, name, color, rKey)
    room.Register <- wsClient

    // Start pumps
    go wsClient.WritePump()
    wsClient.ReadPump()
}

func (h *Handler) validateWSToken(accessToken, dpopProof string) (string, string, error) {
    // Validate JWT and DPoP proof, extract DID and name
    // Use existing ATProto token validation
    return "", "", nil
}

func colorFromDID(did string) string {
    colors := []string{"#e74c3c", "#3498db", "#2ecc71", "#9b59b6", "#f39c12"}
    hash := 0
    for _, c := range did {
        hash += int(c)
    }
    return colors[hash%len(colors)]
}
  • Step 2: Wire up Hub in main.go
// In main.go, add to Handler struct or global
var collaborationHub = collaboration.NewHub()

// Pass to handler
handler := &handler.Handler{
    DB:                db,
    Store:             store,
    Render:            r,
    CollaborationHub:  collaborationHub,
}
  • Step 3: Register WebSocket route
mux.HandleFunc("GET /ws/doc/{rkey}", handler.CollaboratorWebSocket)
  • Step 4: Commit
git add internal/handler/handler.go cmd/server/main.go
git commit -m "feat: add WebSocket collaboration handler"

Chunk 5: Frontend Updates#

Task 5.1: WebSocket client and presence#

Files:

  • Modify: templates/document_edit.html

  • Step 1: Add WebSocket connection

// Add to document_edit.html
let ws = null;
let collaborators = [];

function connectWebSocket(rkey) {
    const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
    const wsUrl = `${protocol}//${window.location.host}/ws/doc/${rkey}?access_token=${encodeURIComponent(getAccessToken())}&dpop_proof=${encodeURIComponent(getDPoPProof())}`;
    
    ws = new WebSocket(wsUrl);
    
    ws.onopen = () => {
        console.log('WebSocket connected');
        ws.send(JSON.stringify({ type: 'join', rkey: rkey, did: getCurrentDID() }));
    };
    
    ws.onmessage = (event) => {
        const msg = JSON.parse(event.data);
        handleWSMessage(msg);
    };
    
    ws.onclose = () => {
        console.log('WebSocket disconnected');
        setTimeout(() => connectWebSocket(rkey), 3000);
    };
}

function handleWSMessage(msg) {
    switch (msg.type) {
        case 'presence':
            updatePresenceSidebar(msg.users);
            break;
        case 'edit':
            applyRemoteEdit(msg.delta);
            break;
        case 'sync':
            setEditorContent(msg.content);
            break;
    }
}

function updatePresenceSidebar(users) {
    collaborators = users;
    const sidebar = document.getElementById('presence-sidebar');
    if (!sidebar) return;
    
    sidebar.innerHTML = users.map(u => `
        <div class="presence-user" style="display: flex; align-items: center; gap: 8px; padding: 8px;">
            <span class="presence-avatar" style="width: 12px; height: 12px; border-radius: 50%; background: ${u.color};"></span>
            <span>${u.name}</span>
        </div>
    `).join('');
}

function getAccessToken() {
    // Get from session storage or cookie
    return sessionStorage.getItem('atproto_access_token');
}

function getDPoPProof() {
    return sessionStorage.getItem('atproto_dpop_proof');
}

function getCurrentDID() {
    return sessionStorage.getItem('atproto_did');
}

// Connect on page load if user is collaborator
if (isCollaborator) {
    connectWebSocket(documentRKey);
}
  • Step 2: Add presence sidebar to HTML

Add to document_edit.html:

<div id="presence-sidebar" style="position: fixed; right: 0; top: 50%; transform: translateY(-50%); background: #f5f5f5; padding: 16px; border-radius: 8px; z-index: 100;">
    <h3 style="margin: 0 0 12px; font-size: 14px;">Collaborators</h3>
</div>
  • Step 3: Commit
git add templates/document_edit.html
git commit -m "feat: add WebSocket client and presence sidebar"

Task 5.2: Comment UI#

Files:

  • Modify: templates/document_edit.html

  • Step 1: Add comment functionality

// Add to document_edit.html
function addComment(paragraphId) {
    const text = prompt('Enter your comment:');
    if (!text) return;
    
    fetch(`/api/docs/${documentRKey}/comments`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ paragraphId, text })
    })
    .then(res => res.json())
    .then(data => {
        renderCommentThread(paragraphId, [{ text, author: getCurrentDID(), createdAt: new Date().toISOString() }]);
    });
}

function renderCommentThread(paragraphId, comments) {
    const container = document.getElementById(`comments-${paragraphId}`);
    if (!container) return;
    
    container.innerHTML = comments.map(c => `
        <div class="comment" style="padding: 8px; margin: 4px 0; background: #fff; border-radius: 4px;">
            <div class="comment-text">${c.text}</div>
            <div class="comment-meta" style="font-size: 12px; color: #666;">${c.author} - ${new Date(c.createdAt).toLocaleString()}</div>
        </div>
    `).join('');
}

// Load comments on page load
fetch(`/api/docs/${documentRKey}/comments`)
    .then(res => res.json())
    .then(comments => {
        // Group by paragraphId and render
        const byParagraph = {};
        comments.forEach(c => {
            if (!byParagraph[c.paragraphId]) byParagraph[c.paragraphId] = [];
            byParagraph[c.paragraphId].push(c);
        });
        Object.keys(byParagraph).forEach(pid => {
            renderCommentThread(pid, byParagraph[pid]);
        });
    });
  • Step 2: Add comment button to each paragraph

Add click handler to editor that shows comment button on paragraph selection:

editor.on('selectionChange', (data) => {
    const selectedNode = data.state.selection.$from.parent;
    if (selectedNode) {
        showCommentButton(selectedNode.attrs.id);
    }
});

function showCommentButton(nodeId) {
    // Show floating comment button near selected paragraph
}
  • Step 3: Add comment CSS
.comment-thread {
    margin-top: 8px;
    padding: 8px;
    background: #f9f9f9;
    border-left: 3px solid #3498db;
}

.comment-button {
    position: absolute;
    right: 8px;
    padding: 4px 8px;
    background: #3498db;
    color: white;
    border: none;
    border-radius: 4px;
    cursor: pointer;
}
  • Step 4: Commit
git add templates/document_edit.html static/css/editor.css
git commit -m "feat: add comment UI"

Final Review#

After completing all chunks:

  • Run go build ./... to verify compilation
  • Verify all handlers are registered in main.go
  • Ensure no conflicts with existing code
  • Test basic flow: create document, generate invite, accept invite, see presence