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

feat: support multi-delta edit messages on server

+119 -9
+30 -9
internal/collaboration/client.go
··· 18 18 } 19 19 20 20 type ClientMessage struct { 21 - Type string `json:"type"` 22 - RKey string `json:"rkey,omitempty"` 23 - DID string `json:"did,omitempty"` 21 + Type string `json:"type"` 22 + RKey string `json:"rkey,omitempty"` 23 + DID string `json:"did,omitempty"` 24 + // Deltas is the new plural field — a single edit message may carry 25 + // multiple operations (e.g. one per CodeMirror ChangeDesc). 26 + Deltas []Operation `json:"deltas,omitempty"` 27 + // Delta is the legacy singular field. Kept for backward compatibility. 24 28 Delta json.RawMessage `json:"delta,omitempty"` 25 29 Cursor *CursorPos `json:"cursor,omitempty"` 26 30 Comment *CommentMsg `json:"comment,omitempty"` 31 + } 32 + 33 + // Operations returns the ops from this message, preferring Deltas over the 34 + // legacy singular Delta field. 35 + func (m *ClientMessage) Operations() []Operation { 36 + if len(m.Deltas) > 0 { 37 + return m.Deltas 38 + } 39 + if len(m.Delta) > 0 { 40 + var op Operation 41 + if err := json.Unmarshal(m.Delta, &op); err == nil { 42 + return []Operation{op} 43 + } 44 + } 45 + return nil 27 46 } 28 47 29 48 type CursorPos struct { ··· 82 101 83 102 switch msg.Type { 84 103 case "edit": 85 - var op Operation 86 - if err := json.Unmarshal(msg.Delta, &op); err != nil { 87 - log.Printf("Failed to parse delta from %s: %v", c.DID, err) 104 + ops := msg.Operations() 105 + if len(ops) == 0 { 88 106 continue 89 107 } 90 - op.Author = c.DID 91 108 room := c.hub.GetRoom(c.roomKey) 92 - if room != nil { 93 - room.ApplyEdit(op, c) 109 + if room == nil { 110 + continue 111 + } 112 + for i := range ops { 113 + ops[i].Author = c.DID 94 114 } 115 + room.ApplyEdits(ops, c) 95 116 case "ping": 96 117 pong, _ := json.Marshal(map[string]string{"type": "pong"}) 97 118 c.send <- pong
+50
internal/collaboration/client_test.go
··· 1 + package collaboration 2 + 3 + import ( 4 + "encoding/json" 5 + "testing" 6 + ) 7 + 8 + func TestClientMessage_ParseDeltas_Multiple(t *testing.T) { 9 + raw := `{"type":"edit","deltas":[{"from":0,"to":5,"insert":"hello"},{"from":10,"to":10,"insert":"!"}]}` 10 + var msg ClientMessage 11 + if err := json.Unmarshal([]byte(raw), &msg); err != nil { 12 + t.Fatalf("unmarshal: %v", err) 13 + } 14 + if len(msg.Deltas) != 2 { 15 + t.Fatalf("expected 2 deltas, got %d", len(msg.Deltas)) 16 + } 17 + if msg.Deltas[0].From != 0 || msg.Deltas[0].To != 5 || msg.Deltas[0].Insert != "hello" { 18 + t.Errorf("delta[0]: %+v", msg.Deltas[0]) 19 + } 20 + if msg.Deltas[1].From != 10 || msg.Deltas[1].Insert != "!" { 21 + t.Errorf("delta[1]: %+v", msg.Deltas[1]) 22 + } 23 + } 24 + 25 + func TestClientMessage_ParseDeltas_SingleFallback(t *testing.T) { 26 + // Old wire format: singular "delta" field — must still work. 27 + raw := `{"type":"edit","delta":{"from":3,"to":7,"insert":"xyz"}}` 28 + var msg ClientMessage 29 + if err := json.Unmarshal([]byte(raw), &msg); err != nil { 30 + t.Fatalf("unmarshal: %v", err) 31 + } 32 + ops := msg.Operations() 33 + if len(ops) != 1 { 34 + t.Fatalf("expected 1 op from fallback, got %d", len(ops)) 35 + } 36 + if ops[0].From != 3 || ops[0].To != 7 || ops[0].Insert != "xyz" { 37 + t.Errorf("op: %+v", ops[0]) 38 + } 39 + } 40 + 41 + func TestClientMessage_ParseDeltas_EmptyDeltas(t *testing.T) { 42 + raw := `{"type":"edit","deltas":[]}` 43 + var msg ClientMessage 44 + if err := json.Unmarshal([]byte(raw), &msg); err != nil { 45 + t.Fatalf("unmarshal: %v", err) 46 + } 47 + if len(msg.Operations()) != 0 { 48 + t.Errorf("expected 0 ops for empty deltas") 49 + } 50 + }
+39
internal/collaboration/hub.go
··· 126 126 r.BroadcastExcept(data, sender) 127 127 } 128 128 129 + // ApplyEdits applies a sequence of operations in order and broadcasts one 130 + // combined message to all other clients. Each op is applied to the text 131 + // resulting from the previous op, so positions in each op must be relative 132 + // to the document state after all prior ops in the same batch have been 133 + // applied — which is exactly what CodeMirror's iterChanges produces (fromA/toA 134 + // are positions in the pre-change document, already adjusted for prior changes 135 + // within the same transaction by CodeMirror itself). 136 + func (r *Room) ApplyEdits(ops []Operation, sender *Client) { 137 + if len(ops) == 0 { 138 + return 139 + } 140 + 141 + for i := range ops { 142 + r.ot.ApplyWithVersion(ops[i]) 143 + } 144 + 145 + // Include the full document text so receivers can detect and recover from 146 + // divergence without a reconnect. 147 + finalText := r.ot.GetText() 148 + type editsMsg struct { 149 + Type string `json:"type"` 150 + Deltas []Operation `json:"deltas"` 151 + Author string `json:"author"` 152 + Content string `json:"content"` 153 + } 154 + msg := editsMsg{ 155 + Type: "edit", 156 + Deltas: ops, 157 + Author: sender.DID, 158 + Content: finalText, 159 + } 160 + data, err := json.Marshal(msg) 161 + if err != nil { 162 + log.Printf("ApplyEdits: marshal: %v", err) 163 + return 164 + } 165 + r.BroadcastExcept(data, sender) 166 + } 167 + 129 168 func (r *Room) RegisterClient(client *Client) { 130 169 r.register <- client 131 170 }