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

Embed comments in document record (ATProto)

Comments are now stored as an array inside the com.diffdown.document
ATProto record on the owner's PDS, making documents fully self-contained
and portable.

Changes:
- Add EmbeddedComment type and Comments field to Document model
- Remove old com.diffdown.comment per-record approach
- CommentCreate/CommentList do read-modify-write on owner's document
- Comment sidebar shown for owners and collaborators
- Paragraph click in rich editor shows a comment button
- ownerDID threaded through comment API calls for collaborator access
- Clicking a comment scrolls to and highlights the target paragraph
(Web Animations API, yellow outline pulse)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

+621 -79
+459
docs/plans/2026-03-18-embedded-comments.md
··· 1 + # Embedded Comments Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Replace the broken per-commenter ATProto record approach with comments embedded directly in the `com.diffdown.document` record on the owner's PDS. Comments become a portable part of the document. 6 + 7 + **Architecture:** `Document.Comments []EmbeddedComment` is stored inside the ATProto record. `CommentCreate` does read-modify-write on the owner's document via the owner's XRPC client (same pattern as `AutoSave`). `CommentList` reads the document and returns the embedded array. The collaborator passes `ownerDID` in the request so the handler knows whose PDS to talk to. 8 + 9 + **Tech Stack:** Go 1.22, ATProto XRPC, html/template, ProseMirror (Milkdown) click events 10 + 11 + --- 12 + 13 + ### Task 1: Update the Document model 14 + 15 + **Files:** 16 + - Modify: `internal/model/models.go` 17 + 18 + **Step 1: Add `EmbeddedComment` struct and `Comments` field to `Document`** 19 + 20 + Add after the `MarkdownText` struct: 21 + 22 + ```go 23 + type EmbeddedComment struct { 24 + ID string `json:"id"` // random 8-char hex for dedup 25 + ParagraphID string `json:"paragraphId"` // "p-0", "p-1", ... or "general" 26 + Text string `json:"text"` 27 + Author string `json:"author"` // DID 28 + AuthorHandle string `json:"authorHandle"` // resolved handle, may be empty 29 + CreatedAt string `json:"createdAt"` // RFC3339 30 + } 31 + ``` 32 + 33 + Add `Comments []EmbeddedComment` to the `Document` struct, after `Collaborators`: 34 + 35 + ```go 36 + Comments []EmbeddedComment `json:"comments,omitempty"` 37 + ``` 38 + 39 + Keep the existing `Comment` struct for now — it will be removed in Task 2. 40 + 41 + **Step 2: Build** 42 + 43 + ```bash 44 + go build ./... 45 + ``` 46 + 47 + Expected: PASS. 48 + 49 + **Step 3: Commit** 50 + 51 + ```bash 52 + git add internal/model/models.go 53 + git commit -m "Add EmbeddedComment type and Comments field to Document" 54 + ``` 55 + 56 + --- 57 + 58 + ### Task 2: Remove old XRPC comment methods and stale types 59 + 60 + **Files:** 61 + - Modify: `internal/atproto/xrpc/client.go` 62 + - Modify: `internal/model/models.go` 63 + - Modify: `internal/handler/handler.go` 64 + 65 + **Step 1: Delete from `internal/atproto/xrpc/client.go`** 66 + 67 + Remove the `collectionComment` const (line 317) and the `CreateComment` and `ListComments` functions (lines 319-357). 68 + 69 + Check whether `strings` import is still needed elsewhere before removing it: 70 + 71 + ```bash 72 + grep -n 'strings\.' internal/atproto/xrpc/client.go 73 + ``` 74 + 75 + Remove unused import if needed. 76 + 77 + **Step 2: Delete `collectionComment` const from `internal/handler/handler.go`** 78 + 79 + Line 27: `const collectionComment = "com.diffdown.comment"` — delete it. 80 + 81 + **Step 3: Remove the old `Comment` struct from `internal/model/models.go`** 82 + 83 + Delete the `Comment` struct block. 84 + 85 + **Step 4: Build — expect errors** 86 + 87 + ```bash 88 + go build ./... 89 + ``` 90 + 91 + Expected: compile errors about `CommentCreate` and `ListComments` called in handler. These are fixed in Task 3 — do not commit yet. 92 + 93 + --- 94 + 95 + ### Task 3: Rewrite `CommentCreate` and `CommentList` handlers 96 + 97 + **Files:** 98 + - Modify: `internal/handler/handler.go` 99 + 100 + **Step 1: Add `crypto/rand` and `encoding/hex` imports** 101 + 102 + In the `import` block, add both if not already present. 103 + 104 + **Step 2: Add `randomID` helper near the top of handler.go (after imports)** 105 + 106 + ```go 107 + func randomID() string { 108 + b := make([]byte, 4) 109 + rand.Read(b) 110 + return hex.EncodeToString(b) 111 + } 112 + ``` 113 + 114 + **Step 3: Rewrite `CommentCreate`** 115 + 116 + Replace the entire function with: 117 + 118 + ```go 119 + func (h *Handler) CommentCreate(w http.ResponseWriter, r *http.Request) { 120 + user, _ := h.currentUser(r) 121 + if user == nil { 122 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 123 + return 124 + } 125 + 126 + rKey := r.PathValue("rkey") 127 + if rKey == "" { 128 + http.Error(w, "Invalid document", http.StatusBadRequest) 129 + return 130 + } 131 + 132 + var req struct { 133 + OwnerDID string `json:"ownerDID"` 134 + ParagraphID string `json:"paragraphId"` 135 + Text string `json:"text"` 136 + } 137 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 138 + http.Error(w, "Invalid request", http.StatusBadRequest) 139 + return 140 + } 141 + if req.Text == "" { 142 + http.Error(w, "Comment text required", http.StatusBadRequest) 143 + return 144 + } 145 + 146 + session, err := h.DB.GetATProtoSession(user.ID) 147 + if err != nil || session == nil { 148 + http.Error(w, "Not authenticated with ATProto", http.StatusUnauthorized) 149 + return 150 + } 151 + 152 + ownerUserID := user.ID 153 + ownerDID := session.DID 154 + if req.OwnerDID != "" && req.OwnerDID != session.DID { 155 + ownerUser, err := h.DB.GetUserByDID(req.OwnerDID) 156 + if err != nil { 157 + http.Error(w, "Owner not found", http.StatusBadRequest) 158 + return 159 + } 160 + ownerUserID = ownerUser.ID 161 + ownerDID = req.OwnerDID 162 + } 163 + 164 + ownerClient, err := h.xrpcClient(ownerUserID) 165 + if err != nil { 166 + http.Error(w, "Failed to connect to owner PDS", http.StatusInternalServerError) 167 + return 168 + } 169 + 170 + value, _, err := ownerClient.GetRecord(ownerDID, collectionDocument, rKey) 171 + if err != nil { 172 + log.Printf("CommentCreate: GetRecord: %v", err) 173 + http.Error(w, "Document not found", http.StatusNotFound) 174 + return 175 + } 176 + var doc model.Document 177 + if err := json.Unmarshal(value, &doc); err != nil { 178 + http.Error(w, "Failed to parse document", http.StatusInternalServerError) 179 + return 180 + } 181 + 182 + authorHandle, _ := atproto.ResolveHandleFromDID(session.DID) 183 + 184 + paragraphID := req.ParagraphID 185 + if paragraphID == "" { 186 + paragraphID = "general" 187 + } 188 + 189 + comment := model.EmbeddedComment{ 190 + ID: randomID(), 191 + ParagraphID: paragraphID, 192 + Text: req.Text, 193 + Author: session.DID, 194 + AuthorHandle: authorHandle, 195 + CreatedAt: time.Now().UTC().Format(time.RFC3339), 196 + } 197 + doc.Comments = append(doc.Comments, comment) 198 + 199 + if _, _, err := ownerClient.PutDocument(rKey, &doc); err != nil { 200 + log.Printf("CommentCreate: PutDocument: %v", err) 201 + http.Error(w, "Failed to save comment", http.StatusInternalServerError) 202 + return 203 + } 204 + 205 + h.jsonResponse(w, comment, http.StatusCreated) 206 + } 207 + ``` 208 + 209 + **Step 4: Rewrite `CommentList`** 210 + 211 + Replace the entire function with: 212 + 213 + ```go 214 + func (h *Handler) CommentList(w http.ResponseWriter, r *http.Request) { 215 + rKey := r.PathValue("rkey") 216 + if rKey == "" { 217 + http.Error(w, "Invalid document", http.StatusBadRequest) 218 + return 219 + } 220 + 221 + user, _ := h.currentUser(r) 222 + if user == nil { 223 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 224 + return 225 + } 226 + 227 + session, err := h.DB.GetATProtoSession(user.ID) 228 + if err != nil || session == nil { 229 + http.Error(w, "Not authenticated with ATProto", http.StatusUnauthorized) 230 + return 231 + } 232 + 233 + ownerUserID := user.ID 234 + ownerDID := session.DID 235 + if qOwner := r.URL.Query().Get("ownerDID"); qOwner != "" && qOwner != session.DID { 236 + ownerUser, err := h.DB.GetUserByDID(qOwner) 237 + if err != nil { 238 + http.Error(w, "Owner not found", http.StatusBadRequest) 239 + return 240 + } 241 + ownerUserID = ownerUser.ID 242 + ownerDID = qOwner 243 + } 244 + 245 + ownerClient, err := h.xrpcClient(ownerUserID) 246 + if err != nil { 247 + http.Error(w, "Failed to connect to owner PDS", http.StatusInternalServerError) 248 + return 249 + } 250 + 251 + value, _, err := ownerClient.GetRecord(ownerDID, collectionDocument, rKey) 252 + if err != nil { 253 + log.Printf("CommentList: GetRecord: %v", err) 254 + http.Error(w, "Document not found", http.StatusNotFound) 255 + return 256 + } 257 + var doc model.Document 258 + if err := json.Unmarshal(value, &doc); err != nil { 259 + http.Error(w, "Failed to parse document", http.StatusInternalServerError) 260 + return 261 + } 262 + 263 + comments := doc.Comments 264 + if comments == nil { 265 + comments = []model.EmbeddedComment{} 266 + } 267 + h.jsonResponse(w, comments, http.StatusOK) 268 + } 269 + ``` 270 + 271 + **Step 5: Build** 272 + 273 + ```bash 274 + go build ./... 275 + ``` 276 + 277 + Expected: PASS. 278 + 279 + **Step 6: Run tests** 280 + 281 + ```bash 282 + go test ./... 283 + ``` 284 + 285 + Expected: all pass. 286 + 287 + **Step 7: Commit** 288 + 289 + ```bash 290 + git add internal/handler/handler.go internal/model/models.go internal/atproto/xrpc/client.go 291 + git commit -m "Embed comments in document record; rewrite CommentCreate/CommentList" 292 + ``` 293 + 294 + --- 295 + 296 + ### Task 4: Fix comment sidebar visibility (show for owners too) 297 + 298 + **Files:** 299 + - Modify: `templates/document_edit.html` 300 + 301 + **Step 1: Change the sidebar guard from `IsCollaborator` to `or .IsCollaborator .IsOwner`** 302 + 303 + Find line ~85: 304 + ``` 305 + {{if .IsCollaborator}} 306 + ``` 307 + above the `comment-sidebar` div. Change to: 308 + ``` 309 + {{if or .IsCollaborator .IsOwner}} 310 + ``` 311 + 312 + **Step 2: Build** 313 + 314 + ```bash 315 + go build ./... 316 + ``` 317 + 318 + **Step 3: Commit** 319 + 320 + ```bash 321 + git add templates/document_edit.html 322 + git commit -m "Show comment sidebar for document owners, not just collaborators" 323 + ``` 324 + 325 + --- 326 + 327 + ### Task 5: Wire up paragraph click + update API calls 328 + 329 + **Files:** 330 + - Modify: `templates/document_edit.html` 331 + - Modify: `static/css/editor.css` 332 + 333 + **Step 1: Update `submitComment` to include `ownerDID` in POST body** 334 + 335 + The template already has `const ownerDID = '{{.Content.OwnerDID}}'`. 336 + 337 + In `submitComment` (around line 791), the fetch body currently sends: 338 + ```js 339 + { paragraphId: activeCommentParagraphId, text } 340 + ``` 341 + 342 + Change it to build an object, add `ownerDID` if non-empty, then send: 343 + ```js 344 + const body = { paragraphId: activeCommentParagraphId, text }; 345 + if (ownerDID) body.ownerDID = ownerDID; 346 + // then JSON.stringify(body) 347 + ``` 348 + 349 + **Step 2: Update `loadComments` to pass `ownerDID` query param** 350 + 351 + The fetch URL is currently `/api/docs/${rkey}/comments`. Change to append `?ownerDID=...` when `ownerDID` is set: 352 + ```js 353 + const qs = ownerDID ? '?ownerDID=' + encodeURIComponent(ownerDID) : ''; 354 + // then fetch(`/api/docs/${rkey}/comments${qs}`) 355 + ``` 356 + 357 + **Step 3: Add paragraph click detection after `loadComments` function** 358 + 359 + After the `loadComments` function, add a new function `setupParagraphCommentTrigger` that: 360 + 1. Finds `#editor-rich` (the Milkdown container) 361 + 2. Listens for `click` events 362 + 3. Calls `e.target.closest('.ProseMirror')` to confirm click is inside the editor 363 + 4. Calls `e.target.closest('p, h1, h2, h3, h4, h5, h6, li')` to find the paragraph 364 + 5. Computes index: `Array.from(paraEl.parentElement.children).indexOf(paraEl)` — yields 0-based index 365 + 6. Sets `activeCommentParagraphId = 'p-' + idx` 366 + 7. Positions and shows `commentBtn` to the right of the paragraph using `getBoundingClientRect()` + `window.scrollY` 367 + 368 + Call `setupParagraphCommentTrigger()` immediately after defining it. 369 + 370 + **Step 4: Add/verify `.comment-btn` CSS in `static/css/editor.css`** 371 + 372 + Check whether `.comment-btn` has `position: fixed` or `position: absolute` and `z-index`. If not present, add: 373 + 374 + ```css 375 + .comment-btn { 376 + position: fixed; 377 + z-index: 100; 378 + } 379 + ``` 380 + 381 + **Step 5: Build** 382 + 383 + ```bash 384 + go build ./... 385 + ``` 386 + 387 + **Step 6: Commit** 388 + 389 + ```bash 390 + git add templates/document_edit.html static/css/editor.css 391 + git commit -m "Wire paragraph click to comment button; pass ownerDID in comment API calls" 392 + ``` 393 + 394 + --- 395 + 396 + ### Task 6: Improve comment sidebar rendering 397 + 398 + **Files:** 399 + - Modify: `templates/document_edit.html` 400 + 401 + **Step 1: Update `renderCommentThreads` paragraph label** 402 + 403 + In the `renderCommentThreads` function, the thread label is currently: 404 + ``` 405 + ¶ ${paragraphId} 406 + ``` 407 + 408 + Change to compute a human-readable label before building the thread HTML: 409 + ```js 410 + const label = pid === 'general' 411 + ? 'General' 412 + : 'Paragraph ' + (parseInt(pid.replace('p-', ''), 10) + 1); 413 + ``` 414 + 415 + Then use `label` in the label div text content (via `escHtml(label)`). 416 + 417 + **Step 2: Update author display to prefer `authorHandle`** 418 + 419 + The comment item shows `c.authorName || c.author`. Since the new struct uses `authorHandle`, update to `c.authorHandle || c.author`. 420 + 421 + **Step 3: Build and run all tests** 422 + 423 + ```bash 424 + go build ./... && go test ./... 425 + ``` 426 + 427 + Expected: PASS — 3 test packages, all green. 428 + 429 + **Step 4: Commit** 430 + 431 + ```bash 432 + git add templates/document_edit.html 433 + git commit -m "Improve comment rendering: human-readable paragraph labels, authorHandle" 434 + ``` 435 + 436 + --- 437 + 438 + ### Task 7: Manual smoke test 439 + 440 + **Start the server from the worktree:** 441 + 442 + ```bash 443 + go run ./cmd/server 444 + ``` 445 + 446 + 1. **Owner:** Sign in, open a document. Click a paragraph body — the blue "Comment" button should appear to the right. Click it, write a comment, post. The sidebar appears with the comment under "Paragraph N". 447 + 448 + 2. **Collaborator:** Sign in as the collaborator, navigate to the shared doc via `/docs/{ownerDID}/{rkey}`. Click a paragraph, post a comment. The comment appears in the sidebar with your handle. 449 + 450 + 3. **Owner sees collaborator's comment:** Sign back in as owner on the same document — comment is visible in the sidebar. 451 + 452 + --- 453 + 454 + ## Notes 455 + 456 + - **No DB migration needed** — comments live entirely in the ATProto record. 457 + - **Concurrent comment race:** `CommentCreate` reads then writes; two concurrent comments could overwrite each other (last write wins). Acceptable trade-off; CID-based optimistic locking is a future improvement. 458 + - **Positional paragraph IDs:** `p-0`, `p-1` are index-based. Document edits may shift which content a comment refers to. Stable UUIDs embedded in ProseMirror schema would require a bigger refactor. 459 + - **Old `com.diffdown.comment` records:** Orphaned on user PDSes. Cleanup is out of scope.
-41
internal/atproto/xrpc/client.go
··· 314 314 return c.PutRecord(collectionDocument, rkey, doc) 315 315 } 316 316 317 - const collectionComment = "com.diffdown.comment" 318 - 319 - // CreateComment creates a new comment record. 320 - func (c *Client) CreateComment(comment *model.Comment) (string, error) { 321 - record := map[string]interface{}{ 322 - "$type": "com.diffdown.comment", 323 - "documentURI": comment.DocumentURI, 324 - "paragraphId": comment.ParagraphID, 325 - "text": comment.Text, 326 - "author": comment.Author, 327 - } 328 - uri, _, err := c.CreateRecord(collectionComment, record) 329 - if err != nil { 330 - return "", err 331 - } 332 - return uri, nil 333 - } 334 - 335 - // ListComments fetches all comments for a document. 336 - func (c *Client) ListComments(documentRKey string) ([]model.Comment, error) { 337 - records, _, err := c.ListRecords(c.session.DID, collectionComment, 100, "") 338 - if err != nil { 339 - return nil, err 340 - } 341 - 342 - var comments []model.Comment 343 - for _, r := range records { 344 - var comment model.Comment 345 - if err := json.Unmarshal(r.Value, &comment); err != nil { 346 - continue 347 - } 348 - // Filter by documentRKey: at://{did}/com.diffdown.document/{rkey} 349 - expectedSuffix := "/com.diffdown.document/" + documentRKey 350 - if documentRKey != "" && !strings.HasSuffix(comment.DocumentURI, expectedSuffix) { 351 - continue 352 - } 353 - comment.URI = r.URI 354 - comments = append(comments, comment) 355 - } 356 - return comments, nil 357 - }
+80 -19
internal/handler/handler.go
··· 1 1 package handler 2 2 3 3 import ( 4 + "crypto/rand" 5 + "encoding/hex" 4 6 "encoding/json" 5 7 "fmt" 6 8 "html/template" ··· 24 26 ) 25 27 26 28 const collectionDocument = "com.diffdown.document" 27 - const collectionComment = "com.diffdown.comment" 29 + 30 + func randomID() string { 31 + b := make([]byte, 4) 32 + rand.Read(b) 33 + return hex.EncodeToString(b) 34 + } 28 35 29 36 type Handler struct { 30 37 DB *db.DB ··· 725 732 } 726 733 727 734 var req struct { 735 + OwnerDID string `json:"ownerDID"` 728 736 ParagraphID string `json:"paragraphId"` 729 737 Text string `json:"text"` 730 738 } ··· 732 740 http.Error(w, "Invalid request", http.StatusBadRequest) 733 741 return 734 742 } 735 - 736 743 if req.Text == "" { 737 744 http.Error(w, "Comment text required", http.StatusBadRequest) 738 745 return ··· 744 751 return 745 752 } 746 753 747 - client, err := h.xrpcClient(user.ID) 754 + ownerUserID := user.ID 755 + ownerDID := session.DID 756 + if req.OwnerDID != "" && req.OwnerDID != session.DID { 757 + ownerUser, err := h.DB.GetUserByDID(req.OwnerDID) 758 + if err != nil { 759 + http.Error(w, "Owner not found", http.StatusBadRequest) 760 + return 761 + } 762 + ownerUserID = ownerUser.ID 763 + ownerDID = req.OwnerDID 764 + } 765 + 766 + ownerClient, err := h.xrpcClient(ownerUserID) 767 + if err != nil { 768 + http.Error(w, "Failed to connect to owner PDS", http.StatusInternalServerError) 769 + return 770 + } 771 + 772 + value, _, err := ownerClient.GetRecord(ownerDID, collectionDocument, rKey) 748 773 if err != nil { 749 - http.Error(w, "Failed to connect to ATProto", http.StatusInternalServerError) 774 + log.Printf("CommentCreate: GetRecord: %v", err) 775 + http.Error(w, "Document not found", http.StatusNotFound) 776 + return 777 + } 778 + var doc model.Document 779 + if err := json.Unmarshal(value, &doc); err != nil { 780 + http.Error(w, "Failed to parse document", http.StatusInternalServerError) 750 781 return 751 782 } 752 783 753 - comment := &model.Comment{ 754 - DocumentURI: fmt.Sprintf("at://%s/com.diffdown.document/%s", session.DID, rKey), 755 - ParagraphID: req.ParagraphID, 756 - Text: req.Text, 757 - Author: session.DID, 784 + authorHandle, _ := atproto.ResolveHandleFromDID(session.DID) 785 + 786 + paragraphID := req.ParagraphID 787 + if paragraphID == "" { 788 + paragraphID = "general" 758 789 } 759 790 760 - uri, err := client.CreateComment(comment) 761 - if err != nil { 762 - log.Printf("CommentCreate: %v", err) 763 - http.Error(w, "Failed to create comment", http.StatusInternalServerError) 791 + comment := model.EmbeddedComment{ 792 + ID: randomID(), 793 + ParagraphID: paragraphID, 794 + Text: req.Text, 795 + Author: session.DID, 796 + AuthorHandle: authorHandle, 797 + CreatedAt: time.Now().UTC().Format(time.RFC3339), 798 + } 799 + doc.Comments = append(doc.Comments, comment) 800 + 801 + if _, _, err := ownerClient.PutDocument(rKey, &doc); err != nil { 802 + log.Printf("CommentCreate: PutDocument: %v", err) 803 + http.Error(w, "Failed to save comment", http.StatusInternalServerError) 764 804 return 765 805 } 766 806 767 - h.jsonResponse(w, map[string]string{"uri": uri}, http.StatusCreated) 807 + h.jsonResponse(w, comment, http.StatusCreated) 768 808 } 769 809 770 810 func (h *Handler) CommentList(w http.ResponseWriter, r *http.Request) { ··· 786 826 return 787 827 } 788 828 789 - client, err := h.xrpcClient(user.ID) 829 + ownerUserID := user.ID 830 + ownerDID := session.DID 831 + if qOwner := r.URL.Query().Get("ownerDID"); qOwner != "" && qOwner != session.DID { 832 + ownerUser, err := h.DB.GetUserByDID(qOwner) 833 + if err != nil { 834 + http.Error(w, "Owner not found", http.StatusBadRequest) 835 + return 836 + } 837 + ownerUserID = ownerUser.ID 838 + ownerDID = qOwner 839 + } 840 + 841 + ownerClient, err := h.xrpcClient(ownerUserID) 790 842 if err != nil { 791 - http.Error(w, "Failed to connect to ATProto", http.StatusInternalServerError) 843 + http.Error(w, "Failed to connect to owner PDS", http.StatusInternalServerError) 792 844 return 793 845 } 794 846 795 - comments, err := client.ListComments(rKey) 847 + value, _, err := ownerClient.GetRecord(ownerDID, collectionDocument, rKey) 796 848 if err != nil { 797 - log.Printf("CommentList: %v", err) 798 - http.Error(w, "Failed to list comments", http.StatusInternalServerError) 849 + log.Printf("CommentList: GetRecord: %v", err) 850 + http.Error(w, "Document not found", http.StatusNotFound) 851 + return 852 + } 853 + var doc model.Document 854 + if err := json.Unmarshal(value, &doc); err != nil { 855 + http.Error(w, "Failed to parse document", http.StatusInternalServerError) 799 856 return 800 857 } 801 858 859 + comments := doc.Comments 860 + if comments == nil { 861 + comments = []model.EmbeddedComment{} 862 + } 802 863 h.jsonResponse(w, comments, http.StatusOK) 803 864 } 804 865
+12 -12
internal/model/models.go
··· 34 34 Title string `json:"title"` 35 35 Content *MarkdownContent `json:"content,omitempty"` 36 36 TextContent string `json:"textContent,omitempty"` 37 - Collaborators []string `json:"collaborators,omitempty"` 38 - CreatedAt string `json:"createdAt"` 37 + Collaborators []string `json:"collaborators,omitempty"` 38 + Comments []EmbeddedComment `json:"comments,omitempty"` 39 + CreatedAt string `json:"createdAt"` 39 40 UpdatedAt string `json:"updatedAt,omitempty"` 40 41 } 41 42 ··· 49 50 RawMarkdown string `json:"rawMarkdown"` 50 51 } 51 52 53 + type EmbeddedComment struct { 54 + ID string `json:"id"` // random 8-char hex for dedup 55 + ParagraphID string `json:"paragraphId"` // "p-0", "p-1", ... or "general" 56 + Text string `json:"text"` 57 + Author string `json:"author"` // DID 58 + AuthorHandle string `json:"authorHandle"` // resolved handle, may be empty 59 + CreatedAt string `json:"createdAt"` // RFC3339 60 + } 61 + 52 62 type Invite struct { 53 63 ID string `json:"id"` 54 64 DocumentRKey string `json:"document_rkey"` ··· 56 66 CreatedBy string `json:"created_by"` 57 67 CreatedAt time.Time `json:"created_at"` 58 68 ExpiresAt time.Time `json:"expires_at"` 59 - } 60 - 61 - type Comment struct { 62 - URI string `json:"uri"` 63 - DocumentURI string `json:"documentURI"` 64 - ParagraphID string `json:"paragraphId"` 65 - Text string `json:"text"` 66 - Author string `json:"author"` 67 - AuthorName string `json:"authorName"` 68 - CreatedAt time.Time `json:"createdAt"` 69 69 } 70 70 71 71 // RKeyFromURI extracts the rkey (last path segment) from an at:// URI.
+6
static/css/editor.css
··· 272 272 border: 1px solid var(--border); 273 273 border-radius: var(--radius); 274 274 overflow: hidden; 275 + cursor: pointer; 275 276 } 277 + 278 + .comment-thread:hover { 279 + border-color: var(--primary); 280 + } 281 + 276 282 277 283 .comment-thread-label { 278 284 font-size: 0.75rem;
+64 -7
templates/document_edit.html
··· 82 82 {{end}} 83 83 84 84 <!-- Comment sidebar --> 85 - {{if .IsCollaborator}} 85 + {{if or .IsCollaborator .IsOwner}} 86 86 <div id="comment-sidebar" class="comment-sidebar"> 87 87 <div class="comment-sidebar-header">Comments</div> 88 88 <div id="comment-threads" class="comment-threads"></div> ··· 788 788 if (!text) return; 789 789 790 790 try { 791 + const body = { paragraphId: activeCommentParagraphId, text }; 792 + if (ownerDID) body.ownerDID = ownerDID; 791 793 const resp = await fetch(`/api/docs/${rkey}/comments`, { 792 794 method: 'POST', 793 795 headers: { 'Content-Type': 'application/json' }, 794 - body: JSON.stringify({ paragraphId: activeCommentParagraphId, text }), 796 + body: JSON.stringify(body), 795 797 }); 796 798 if (!resp.ok) throw new Error(await resp.text()); 797 799 closeCommentForm(); ··· 815 817 } 816 818 }); 817 819 820 + (function attachThreadClickHandler() { 821 + const container = document.getElementById('comment-threads'); 822 + if (!container) return; 823 + container.addEventListener('click', e => { 824 + const thread = e.target.closest('.comment-thread'); 825 + if (!thread) return; 826 + jumpToParagraph(thread.dataset.paragraph || ''); 827 + }); 828 + })(); 829 + 818 830 function renderCommentThreads(comments) { 819 831 const container = document.getElementById('comment-threads'); 820 832 if (!container) return; ··· 832 844 byParagraph[pid].push(c); 833 845 } 834 846 835 - container.innerHTML = Object.entries(byParagraph).map(([pid, thread]) => ` 847 + container.innerHTML = Object.entries(byParagraph).map(([pid, thread]) => { 848 + const label = pid === 'general' ? 'General' : 'Paragraph ' + (parseInt(pid.replace('p-', ''), 10) + 1); 849 + return ` 836 850 <div class="comment-thread" data-paragraph="${escHtml(pid)}"> 837 - <div class="comment-thread-label">¶ ${escHtml(pid)}</div> 851 + <div class="comment-thread-label">¶ ${escHtml(label)}</div> 838 852 ${thread.map(c => ` 839 853 <div class="comment-item"> 840 - <div class="comment-author">${escHtml(c.authorName || c.author)}</div> 854 + <div class="comment-author">${escHtml(c.authorHandle || c.author)}</div> 841 855 <div class="comment-text">${escHtml(c.text)}</div> 842 856 <div class="comment-time">${formatTime(c.createdAt)}</div> 843 857 </div> 844 858 `).join('')} 845 859 </div> 846 - `).join(''); 860 + `; 861 + }).join(''); 847 862 } 848 863 849 864 function formatTime(ts) { ··· 854 869 async function loadComments() { 855 870 if (!accessToken) return; 856 871 try { 857 - const resp = await fetch(`/api/docs/${rkey}/comments`); 872 + const qs = ownerDID ? '?ownerDID=' + encodeURIComponent(ownerDID) : ''; 873 + const resp = await fetch(`/api/docs/${rkey}/comments${qs}`); 858 874 if (!resp.ok) return; 859 875 const comments = await resp.json(); 860 876 renderCommentThreads(comments); ··· 862 878 console.error('Load comments failed:', e); 863 879 } 864 880 } 881 + 882 + function jumpToParagraph(pid) { 883 + if (pid === 'general') return; 884 + const idx = parseInt(pid.replace('p-', ''), 10); 885 + if (isNaN(idx)) return; 886 + if (!milkdownEditor) return; 887 + const pmView = milkdownEditor.action(ctx => ctx.get(editorViewCtx)); 888 + if (!pmView) return; 889 + const pmEl = pmView.dom; 890 + const blocks = Array.from(pmEl.children); 891 + const target = blocks[idx]; 892 + if (!target) return; 893 + target.scrollIntoView({ behavior: 'smooth', block: 'center' }); 894 + target.animate( 895 + [{ outline: '3px solid rgba(234,179,8,0.9)', outlineOffset: '2px' }, 896 + { outline: '3px solid rgba(234,179,8,0)', outlineOffset: '2px' }], 897 + { duration: 1400, easing: 'ease-out' } 898 + ); 899 + } 900 + 901 + function setupParagraphCommentTrigger() { 902 + const editorEl = document.getElementById('editor-rich'); 903 + if (!editorEl) return; 904 + editorEl.addEventListener('click', e => { 905 + const pmEl = e.target.closest('.ProseMirror'); 906 + if (!pmEl) return; 907 + const paraEl = e.target.closest('p, h1, h2, h3, h4, h5, h6, li'); 908 + if (!paraEl) return; 909 + const siblings = Array.from(paraEl.parentElement.children); 910 + const idx = siblings.indexOf(paraEl); 911 + const pid = 'p-' + idx; 912 + activeCommentParagraphId = pid; 913 + if (commentBtn) { 914 + const rect = paraEl.getBoundingClientRect(); 915 + commentBtn.style.top = (rect.top + window.scrollY + rect.height / 2 - 12) + 'px'; 916 + commentBtn.style.left = (rect.right + window.scrollX + 8) + 'px'; 917 + commentBtn.style.display = 'block'; 918 + } 919 + }); 920 + } 921 + setupParagraphCommentTrigger(); 865 922 866 923 // ── Init ────────────────────────────────────────────────────────────────── 867 924