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

Merge feature/comment-system-separate-collection into main

Adds a full comment system using ATProto as storage backend:
- Comments stored as separate app.diffdown.comment records on user's PDS
- Threading via replyTo (full AT URI) with root/reply grouping
- Comment marks in ProseMirror editor (yellow highlight on anchored text)
- Resolve/reopen toggle; resolved comments lose their highlight
- Comments sorted by document position
- Real-time sync via WebSocket broadcast (comments_updated message)
- Comments sidebar with toggle button in toolbar
- Collaborators can comment on shared documents

+359 -88
+1
cmd/server/main.go
··· 114 114 mux.HandleFunc("GET /docs/{rkey}/accept", h.AcceptInvite) 115 115 mux.HandleFunc("POST /api/docs/{rkey}/comments", h.CommentCreate) 116 116 mux.HandleFunc("GET /api/docs/{rkey}/comments", h.CommentList) 117 + mux.HandleFunc("PATCH /api/docs/{rkey}/comments/{commentId}", h.CommentUpdate) 117 118 mux.HandleFunc("POST /api/docs/{rkey}/steps", h.SubmitSteps) 118 119 mux.HandleFunc("GET /api/docs/{rkey}/steps", h.GetSteps) 119 120
+26 -4
internal/atproto/xrpc/client.go
··· 292 292 return nil 293 293 } 294 294 295 - const collectionDocument = "com.diffdown.document" 296 - 297 295 // GetDocument fetches a document by its rkey. 298 296 func (c *Client) GetDocument(rkey string) (*model.Document, error) { 299 - value, _, err := c.GetRecord(c.session.DID, collectionDocument, rkey) 297 + value, _, err := c.GetRecord(c.session.DID, model.CollectionDocument, rkey) 300 298 if err != nil { 301 299 return nil, err 302 300 } ··· 311 309 312 310 // PutDocument creates or updates a document. 313 311 func (c *Client) PutDocument(rkey string, doc *model.Document) (string, string, error) { 314 - return c.PutRecord(collectionDocument, rkey, doc) 312 + return c.PutRecord(model.CollectionDocument, rkey, doc) 313 + } 314 + 315 + // ListComments lists all comment records for a given document. 316 + func (c *Client) ListComments(did, docRKey string, limit int, cursor string) ([]Record, string, error) { 317 + records, nextCursor, err := c.ListRecords(did, model.CollectionComment, limit, cursor) 318 + if err != nil { 319 + return nil, "", err 320 + } 321 + var filtered []Record 322 + for _, r := range records { 323 + var comment model.CommentRecord 324 + if err := json.Unmarshal(r.Value, &comment); err != nil { 325 + continue 326 + } 327 + if comment.DocRKey == docRKey { 328 + filtered = append(filtered, r) 329 + } 330 + } 331 + return filtered, nextCursor, nil 332 + } 333 + 334 + // UpdateComment updates an existing comment record (e.g., for resolution toggle). 335 + func (c *Client) UpdateComment(rkey string, record interface{}) (uri, cid string, err error) { 336 + return c.PutRecord(model.CollectionComment, rkey, record) 315 337 }
+117 -24
internal/handler/handler.go
··· 736 736 ThreadID string `json:"threadId"` 737 737 QuotedText string `json:"quotedText"` 738 738 Text string `json:"text"` 739 + ReplyTo string `json:"replyTo,omitempty"` // parent comment URI for threading 739 740 } 740 741 if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 741 742 http.Error(w, "Invalid request", http.StatusBadRequest) ··· 764 765 ownerDID = req.OwnerDID 765 766 } 766 767 768 + // Verify document exists (but don't fetch full content) 767 769 ownerClient, err := h.xrpcClient(ownerUserID) 768 770 if err != nil { 769 771 http.Error(w, "Failed to connect to owner PDS", http.StatusInternalServerError) 770 772 return 771 773 } 772 - 773 - value, _, err := ownerClient.GetRecord(ownerDID, collectionDocument, rKey) 774 + _, _, err = ownerClient.GetRecord(ownerDID, model.CollectionDocument, rKey) 774 775 if err != nil { 775 776 log.Printf("CommentCreate: GetRecord: %v", err) 776 777 http.Error(w, "Document not found", http.StatusNotFound) 777 778 return 778 779 } 779 - var doc model.Document 780 - if err := json.Unmarshal(value, &doc); err != nil { 781 - http.Error(w, "Failed to parse document", http.StatusInternalServerError) 782 - return 783 - } 784 780 785 781 authorHandle, _ := atproto.ResolveHandleFromDID(session.DID) 786 782 ··· 789 785 threadID = randomID() 790 786 } 791 787 792 - comment := model.EmbeddedComment{ 793 - ID: randomID(), 788 + // Create standalone comment record 789 + comment := model.CommentRecord{ 794 790 ThreadID: threadID, 791 + DocRKey: rKey, 792 + DocOwnerDID: ownerDID, 795 793 QuotedText: req.QuotedText, 796 794 Text: req.Text, 797 795 Author: session.DID, 798 796 AuthorHandle: authorHandle, 799 797 CreatedAt: time.Now().UTC().Format(time.RFC3339), 798 + ReplyTo: req.ReplyTo, 799 + Resolved: false, 800 800 } 801 - doc.Comments = append(doc.Comments, comment) 802 801 803 - if _, _, err := ownerClient.PutDocument(rKey, &doc); err != nil { 804 - log.Printf("CommentCreate: PutDocument: %v", err) 805 - http.Error(w, "Failed to save comment", http.StatusInternalServerError) 802 + // Create as separate record in app.diffdown.comment collection 803 + uri, _, err := ownerClient.CreateRecord(model.CollectionComment, comment) 804 + if err != nil { 805 + log.Printf("CommentCreate: CreateRecord: %v", err) 806 + http.Error(w, "Failed to create comment", http.StatusInternalServerError) 806 807 return 807 808 } 808 809 809 - h.jsonResponse(w, comment, http.StatusCreated) 810 + // Return response with URI for potential reply linking 811 + response := struct { 812 + model.CommentRecord 813 + URI string `json:"uri"` 814 + }{CommentRecord: comment, URI: uri} 815 + 816 + h.jsonResponse(w, response, http.StatusCreated) 817 + 818 + if room := h.CollaborationHub.GetRoom(rKey); room != nil { 819 + if data, err := json.Marshal(map[string]string{"type": "comments_updated"}); err == nil { 820 + room.Broadcast(data) 821 + } 822 + } 810 823 } 811 824 812 825 func (h *Handler) CommentList(w http.ResponseWriter, r *http.Request) { ··· 846 859 return 847 860 } 848 861 849 - value, _, err := ownerClient.GetRecord(ownerDID, collectionDocument, rKey) 862 + records, _, err := ownerClient.ListComments(ownerDID, rKey, 100, "") 863 + if err != nil { 864 + log.Printf("CommentList: ListComments: %v", err) 865 + h.jsonResponse(w, []model.CommentRecord{}, http.StatusOK) 866 + return 867 + } 868 + 869 + comments := make([]model.CommentRecord, 0, len(records)) 870 + for _, rec := range records { 871 + var comment model.CommentRecord 872 + if err := json.Unmarshal(rec.Value, &comment); err != nil { 873 + log.Printf("CommentList: unmarshal comment: %v", err) 874 + continue 875 + } 876 + comment.ID = model.RKeyFromURI(rec.URI) 877 + comments = append(comments, comment) 878 + } 879 + 880 + h.jsonResponse(w, comments, http.StatusOK) 881 + } 882 + 883 + func (h *Handler) CommentUpdate(w http.ResponseWriter, r *http.Request) { 884 + user, _ := h.currentUser(r) 885 + if user == nil { 886 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 887 + return 888 + } 889 + 890 + rKey := r.PathValue("rkey") 891 + commentID := r.PathValue("commentId") 892 + if rKey == "" || commentID == "" { 893 + http.Error(w, "Invalid request", http.StatusBadRequest) 894 + return 895 + } 896 + 897 + var req struct { 898 + OwnerDID string `json:"ownerDID"` 899 + Resolved bool `json:"resolved"` 900 + } 901 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 902 + http.Error(w, "Invalid request", http.StatusBadRequest) 903 + return 904 + } 905 + 906 + session, err := h.DB.GetATProtoSession(user.ID) 907 + if err != nil || session == nil { 908 + http.Error(w, "Not authenticated with ATProto", http.StatusUnauthorized) 909 + return 910 + } 911 + 912 + ownerUserID := user.ID 913 + ownerDID := session.DID 914 + if req.OwnerDID != "" && req.OwnerDID != session.DID { 915 + ownerUser, err := h.DB.GetUserByDID(req.OwnerDID) 916 + if err != nil { 917 + http.Error(w, "Owner not found", http.StatusBadRequest) 918 + return 919 + } 920 + ownerUserID = ownerUser.ID 921 + ownerDID = req.OwnerDID 922 + } 923 + 924 + ownerClient, err := h.xrpcClient(ownerUserID) 850 925 if err != nil { 851 - log.Printf("CommentList: GetRecord: %v", err) 852 - http.Error(w, "Document not found", http.StatusNotFound) 926 + http.Error(w, "Failed to connect to owner PDS", http.StatusInternalServerError) 927 + return 928 + } 929 + 930 + value, _, err := ownerClient.GetRecord(ownerDID, model.CollectionComment, commentID) 931 + if err != nil { 932 + log.Printf("CommentUpdate: GetRecord: %v", err) 933 + http.Error(w, "Comment not found", http.StatusNotFound) 853 934 return 854 935 } 855 - var doc model.Document 856 - if err := json.Unmarshal(value, &doc); err != nil { 857 - http.Error(w, "Failed to parse document", http.StatusInternalServerError) 936 + 937 + var comment model.CommentRecord 938 + if err := json.Unmarshal(value, &comment); err != nil { 939 + http.Error(w, "Failed to parse comment", http.StatusInternalServerError) 940 + return 941 + } 942 + 943 + comment.Resolved = req.Resolved 944 + 945 + _, _, err = ownerClient.UpdateComment(commentID, comment) 946 + if err != nil { 947 + log.Printf("CommentUpdate: UpdateComment: %v", err) 948 + http.Error(w, "Failed to update comment", http.StatusInternalServerError) 858 949 return 859 950 } 860 951 861 - comments := doc.Comments 862 - if comments == nil { 863 - comments = []model.EmbeddedComment{} 952 + h.jsonResponse(w, comment, http.StatusOK) 953 + 954 + if room := h.CollaborationHub.GetRoom(rKey); room != nil { 955 + if data, err := json.Marshal(map[string]string{"type": "comments_updated"}); err == nil { 956 + room.Broadcast(data) 957 + } 864 958 } 865 - h.jsonResponse(w, comments, http.StatusOK) 866 959 } 867 960 868 961 // --- API: Render markdown ---
+19
internal/model/models.go
··· 60 60 CreatedAt string `json:"createdAt"` // RFC3339 61 61 } 62 62 63 + type CommentRecord struct { 64 + ID string `json:"id"` // record key (rkey) 65 + ThreadID string `json:"threadId"` // groups replies into threads 66 + DocRKey string `json:"docRKey"` // document rkey this comment belongs to 67 + DocOwnerDID string `json:"docOwnerDid"` // owner DID for quick filtering 68 + QuotedText string `json:"quotedText"` // text the comment was anchored to 69 + Text string `json:"text"` // comment content 70 + Author string `json:"author"` // DID 71 + AuthorHandle string `json:"authorHandle"` // resolved handle 72 + CreatedAt string `json:"createdAt"` // RFC3339 73 + ReplyTo string `json:"replyTo,omitempty"` // parent comment URI (null for root) 74 + Resolved bool `json:"resolved"` // thread resolution state 75 + } 76 + 63 77 type Invite struct { 64 78 ID string `json:"id"` 65 79 DocumentRKey string `json:"document_rkey"` ··· 77 91 } 78 92 return "" 79 93 } 94 + 95 + const ( 96 + CollectionDocument = "com.diffdown.document" 97 + CollectionComment = "app.diffdown.comment" 98 + )
+21
static/css/editor.css
··· 445 445 .invite-modal-body { 446 446 padding: 1rem; 447 447 } 448 + 449 + /* Comment threading */ 450 + .comment-thread-header { 451 + display: flex; 452 + justify-content: space-between; 453 + align-items: center; 454 + padding-bottom: 8px; 455 + border-bottom: 1px solid var(--border-color); 456 + margin-bottom: 8px; 457 + } 458 + 459 + .comment-item-reply { 460 + margin-left: 24px; 461 + padding-left: 12px; 462 + border-left: 2px solid var(--border-color); 463 + } 464 + 465 + .btn-active { 466 + background-color: var(--success-color); 467 + color: white; 468 + }
+1
templates/about.html
··· 18 18 <h2>Status</h2> 19 19 <p>This app is alpha quality. Use at your own risk. Expect bugs, breaking changes, and limited features. However, any documents you create will be stored in your AT Proto account, so even if Diffdown goes away, you will still have your documents.</p> 20 20 <p><strong class="warning">Important:</strong> Because AT Proto does not support private records (<a href="https://atproto.wiki/en/working-groups/private-data">yet</a>), any documents you create will be visible to anyone with the URL to the record (<a href="at://did:plc:za4vlvbizdstoym7lpymc5q5/com.diffdown.document/3mgncllbr7424">see this example</a>).</p> 21 + <p>The app wasn't designed for mobile, so it is likely to be a very bad UX on small screens.</p> 21 22 <h3>Roadmap</h3> 22 23 <ul> 23 24 <li>Document versioning</li>
+174 -60
templates/document_edit.html
··· 24 24 <button class="btn btn-sm btn-outline source-only" id="btn-wrap" onclick="toggleWrap()">Wrap</button> 25 25 <button class="btn btn-sm btn-outline rich-only" id="btn-undo" onclick="richUndo()" title="Undo (⌘Z)">↩</button> 26 26 <button class="btn btn-sm btn-outline rich-only" id="btn-redo" onclick="richRedo()" title="Redo (⌘⇧Z)">↪</button> 27 + {{if or .IsCollaborator .IsOwner}}<button class="btn btn-sm btn-outline active" id="btn-comments" onclick="toggleCommentSidebar()">Comments</button>{{end}} 27 28 <button class="btn btn-sm btn-outline" id="btn-source" onclick="toggleSourceMode()">Source</button> 28 29 <span id="save-status"></span> 29 30 <button class="btn btn-sm" id="btn-save" onclick="saveDocument()">Save</button> ··· 36 37 37 38 <!-- Comment button (shown on paragraph hover/selection) --> 38 39 <button id="comment-btn" class="comment-btn" style="display:none" onclick="openCommentForm()">Comment</button> 39 - 40 - <!-- Comment form (floating) --> 41 - <div id="comment-form" class="comment-form" style="display:none"> 42 - <textarea id="comment-text" placeholder="Add a comment..." rows="3"></textarea> 43 - <div class="comment-form-actions"> 44 - <button class="btn btn-sm" onclick="submitComment()">Post</button> 45 - <button class="btn btn-sm btn-outline" onclick="closeCommentForm()">Cancel</button> 46 - </div> 47 - </div> 48 40 49 41 <!-- Link editing tooltip --> 50 42 <div id="link-tooltip" class="link-tooltip"> ··· 80 72 </div> 81 73 </div> 82 74 {{end}} 75 + 76 + <!-- Comment form (floating, outside editor-page so z-index beats sidebar) --> 77 + <div id="comment-form" class="comment-form" style="display:none"> 78 + <textarea id="comment-text" placeholder="Add a comment..." rows="3"></textarea> 79 + <div class="comment-form-actions"> 80 + <button class="btn btn-sm" onclick="submitComment()">Post</button> 81 + <button class="btn btn-sm btn-outline" onclick="closeCommentForm()">Cancel</button> 82 + </div> 83 + </div> 83 84 84 85 <!-- Comment sidebar --> 85 86 {{if or .IsCollaborator .IsOwner}} ··· 447 448 } 448 449 } 449 450 451 + window.toggleCommentSidebar = function() { 452 + const sidebar = document.getElementById('comment-sidebar'); 453 + const btn = document.getElementById('btn-comments'); 454 + if (!sidebar) return; 455 + const isHidden = sidebar.style.display === 'none'; 456 + sidebar.style.display = isHidden ? '' : 'none'; 457 + // CSS :has(~ .comment-sidebar) still matches when sidebar is display:none, 458 + // so override editor-page right manually. 459 + const editorPage = document.querySelector('.editor-page'); 460 + if (editorPage) editorPage.style.right = isHidden ? '' : '0'; 461 + if (btn) btn.classList.toggle('active', isHidden); 462 + }; 463 + 450 464 window.toggleSourceMode = async function() { 451 465 const nextMode = currentMode === 'rich' ? 'source' : 'rich'; 452 466 ··· 736 750 case 'edit': 737 751 applyRemoteEdit(msg); // legacy full-replace path 738 752 break; 753 + case 'comments_updated': 754 + loadComments(); 755 + break; 739 756 case 'sync': 740 757 applyRemoteEdit(msg.content); // sync is always full-content string 741 758 break; ··· 824 841 editorEl.addEventListener('keyup', onSelectionChange); 825 842 } 826 843 844 + let pendingReplyTo = null; // set when replying to a comment 845 + let pendingThreadId = null; // threadId to inherit when replying 846 + 827 847 window.openCommentForm = function openCommentForm() { 828 848 if (!commentBtn || !commentForm || !milkdownEditor) return; 829 849 try { ··· 832 852 if (selection.empty) return; 833 853 pendingCommentRange = { from: selection.from, to: selection.to, 834 854 quotedText: doc.textBetween(selection.from, selection.to, ' ') }; 855 + pendingReplyTo = null; 856 + commentTextEl.placeholder = 'Add a comment...'; 835 857 const rect = commentBtn.getBoundingClientRect(); 836 858 commentForm.style.top = (rect.bottom + window.scrollY + 4) + 'px'; 837 859 commentForm.style.left = Math.max(8, rect.left + window.scrollX) + 'px'; ··· 845 867 if (commentForm) commentForm.style.display = 'none'; 846 868 if (commentBtn) commentBtn.style.display = 'none'; 847 869 pendingCommentRange = null; 870 + pendingReplyTo = null; 871 + pendingThreadId = null; 872 + if (commentTextEl) commentTextEl.placeholder = 'Add a comment...'; 848 873 } 849 874 850 875 window.submitComment = async function submitComment() { ··· 854 879 const { from, to, quotedText } = pendingCommentRange; 855 880 try { 856 881 const body = { quotedText, text }; 882 + if (pendingReplyTo) body.replyTo = pendingReplyTo; 883 + if (pendingThreadId) body.threadId = pendingThreadId; 857 884 if (ownerDID) body.ownerDID = ownerDID; 858 885 const resp = await fetch(`/api/docs/${rkey}/comments`, { 859 886 method: 'POST', ··· 871 898 } 872 899 } 873 900 closeCommentForm(); 901 + pendingReplyTo = null; 902 + pendingThreadId = null; 874 903 loadComments(); 875 904 } catch (e) { console.error('Comment post failed:', e); } 876 905 } ··· 964 993 } catch(e) { return false; } 965 994 } 966 995 996 + function findCommentMarkPos(threadId) { 997 + if (!milkdownEditor) return Infinity; 998 + try { 999 + const pmView = milkdownEditor.action(ctx => ctx.get(editorViewCtx)); 1000 + const { doc, schema } = pmView.state; 1001 + const markType = schema.marks.comment; 1002 + if (!markType) return Infinity; 1003 + let pos = Infinity; 1004 + doc.descendants((node, nodePos) => { 1005 + if (pos !== Infinity) return false; 1006 + if (node.marks.some(m => m.type === markType && m.attrs.threadId === threadId)) pos = nodePos; 1007 + }); 1008 + return pos; 1009 + } catch(e) { return Infinity; } 1010 + } 1011 + 1012 + function formatTime(ts) { 1013 + if (!ts) return ''; 1014 + try { return new Date(ts).toLocaleString(); } catch { return ts; } 1015 + } 1016 + 1017 + async function replyToComment(comment) { 1018 + if (!commentForm || !commentTextEl) return; 1019 + pendingCommentRange = { from: 0, to: 0, quotedText: '' }; 1020 + pendingReplyTo = `at://${comment.docOwnerDid}/app.diffdown.comment/${comment.id}`; 1021 + pendingThreadId = comment.threadId; 1022 + commentTextEl.placeholder = `Replying to ${comment.authorHandle || comment.author}...`; 1023 + commentTextEl.value = ''; 1024 + // comment-form is position:fixed; anchor to right edge of viewport near sidebar 1025 + commentForm.style.right = '8px'; 1026 + commentForm.style.left = 'auto'; 1027 + commentForm.style.top = '80px'; 1028 + commentForm.style.display = 'block'; 1029 + commentTextEl.focus(); 1030 + } 1031 + 1032 + async function toggleResolve(threadId, resolved) { 1033 + try { 1034 + const body = { resolved }; 1035 + if (ownerDID) body.ownerDID = ownerDID; 1036 + await fetch(`/api/docs/${rkey}/comments/${threadId}`, { 1037 + method: 'PATCH', 1038 + headers: { 'Content-Type': 'application/json' }, 1039 + body: JSON.stringify(body), 1040 + }); 1041 + loadComments(); 1042 + } catch (e) { 1043 + console.error('Toggle resolve failed:', e); 1044 + } 1045 + } 1046 + 967 1047 function renderCommentThreads(comments) { 968 1048 const container = document.getElementById('comment-threads'); 969 1049 if (!container) return; ··· 977 1057 return; 978 1058 } 979 1059 980 - // Group by threadId 981 - const byThread = new Map(); 1060 + const roots = []; 1061 + const replies = new Map(); 982 1062 for (const c of comments) { 983 - const tid = c.threadId || c.id; 984 - if (!byThread.has(tid)) byThread.set(tid, { comments: [], quotedText: c.quotedText }); 985 - byThread.get(tid).comments.push(c); 1063 + if (c.replyTo) { 1064 + // replyTo is a full AT URI (at://did/collection/rkey); key by rkey only 1065 + const parentId = c.replyTo.split('/').pop(); 1066 + if (!replies.has(parentId)) replies.set(parentId, []); 1067 + replies.get(parentId).push(c); 1068 + } else { 1069 + roots.push(c); 1070 + } 986 1071 } 987 1072 988 - container.textContent = ''; 989 - for (const [tid, { comments: thread, quotedText }] of byThread) { 990 - const isDetached = !findCommentMark(tid); 1073 + roots.sort((a, b) => findCommentMarkPos(a.threadId || a.id) - findCommentMarkPos(b.threadId || b.id)); 991 1074 992 - const threadDiv = document.createElement('div'); 993 - threadDiv.className = 'comment-thread' + (isDetached ? ' comment-thread-detached' : ''); 994 - threadDiv.dataset.thread = tid; 1075 + container.textContent = ''; 1076 + for (const root of roots) { 1077 + const threadId = root.threadId || root.id; 1078 + const threadReplies = replies.get(root.id) || []; 1079 + const threadEl = createCommentThreadElement(threadId, root, threadReplies); 1080 + container.appendChild(threadEl); 1081 + } 1082 + } 995 1083 996 - const labelDiv = document.createElement('div'); 997 - labelDiv.className = 'comment-thread-label'; 998 - const labelText = quotedText 999 - ? '\u201c' + (quotedText.length > 40 ? quotedText.slice(0, 40) + '\u2026' : quotedText) + '\u201d' 1000 - : '(no anchor)'; 1001 - labelDiv.textContent = labelText; 1002 - if (isDetached) { 1003 - const warn = document.createElement('span'); 1004 - warn.title = 'Text was deleted'; 1005 - warn.textContent = ' \u26a0'; 1006 - labelDiv.appendChild(warn); 1007 - } 1008 - threadDiv.appendChild(labelDiv); 1084 + function createCommentThreadElement(threadId, rootComment, replies) { 1085 + const isDetached = !findCommentMark(threadId); 1086 + const threadDiv = document.createElement('div'); 1087 + threadDiv.className = 'comment-thread' + (isDetached ? ' comment-thread-detached' : ''); 1088 + threadDiv.dataset.thread = threadId; 1009 1089 1010 - for (const c of thread) { 1011 - const item = document.createElement('div'); 1012 - item.className = 'comment-item'; 1090 + const headerDiv = document.createElement('div'); 1091 + headerDiv.className = 'comment-thread-header'; 1013 1092 1014 - const author = document.createElement('div'); 1015 - author.className = 'comment-author'; 1016 - author.textContent = c.authorHandle || c.author; 1093 + const labelDiv = document.createElement('div'); 1094 + labelDiv.className = 'comment-thread-label'; 1095 + const labelText = rootComment.quotedText 1096 + ? '\u201c' + (rootComment.quotedText.length > 40 ? rootComment.quotedText.slice(0, 40) + '\u2026' : rootComment.quotedText) + '\u201d' 1097 + : '(no anchor)'; 1098 + labelDiv.textContent = labelText; 1099 + if (isDetached) { 1100 + const warn = document.createElement('span'); 1101 + warn.title = 'Text was deleted'; 1102 + warn.textContent = ' \u26a0'; 1103 + labelDiv.appendChild(warn); 1104 + } 1105 + headerDiv.appendChild(labelDiv); 1017 1106 1018 - const body = document.createElement('div'); 1019 - body.className = 'comment-text'; 1020 - body.textContent = c.text; 1107 + const resolveBtn = document.createElement('button'); 1108 + resolveBtn.className = 'btn btn-sm ' + (rootComment.resolved ? 'btn-active' : 'btn-outline'); 1109 + resolveBtn.textContent = rootComment.resolved ? 'Reopen' : 'Resolve'; 1110 + resolveBtn.onclick = () => toggleResolve(rootComment.id, !rootComment.resolved); 1111 + headerDiv.appendChild(resolveBtn); 1021 1112 1022 - const time = document.createElement('div'); 1023 - time.className = 'comment-time'; 1024 - time.textContent = formatTime(c.createdAt); 1113 + threadDiv.appendChild(headerDiv); 1025 1114 1026 - item.appendChild(author); 1027 - item.appendChild(body); 1028 - item.appendChild(time); 1029 - threadDiv.appendChild(item); 1030 - } 1115 + threadDiv.appendChild(createCommentItem(rootComment, true)); 1031 1116 1032 - container.appendChild(threadDiv); 1117 + for (const reply of replies) { 1118 + threadDiv.appendChild(createCommentItem(reply, false)); 1033 1119 } 1120 + 1121 + return threadDiv; 1034 1122 } 1035 1123 1036 - function formatTime(ts) { 1037 - if (!ts) return ''; 1038 - try { return new Date(ts).toLocaleString(); } catch { return ts; } 1124 + function createCommentItem(comment, isRoot) { 1125 + const item = document.createElement('div'); 1126 + item.className = 'comment-item' + (isRoot ? '' : ' comment-item-reply'); 1127 + 1128 + const author = document.createElement('div'); 1129 + author.className = 'comment-author'; 1130 + author.textContent = comment.authorHandle || comment.author; 1131 + 1132 + const body = document.createElement('div'); 1133 + body.className = 'comment-text'; 1134 + body.textContent = comment.text; 1135 + 1136 + const time = document.createElement('div'); 1137 + time.className = 'comment-time'; 1138 + time.textContent = formatTime(comment.createdAt); 1139 + 1140 + if (isRoot) { 1141 + const replyBtn = document.createElement('button'); 1142 + replyBtn.className = 'btn btn-sm btn-link'; 1143 + replyBtn.textContent = 'Reply'; 1144 + replyBtn.addEventListener('click', e => { e.stopPropagation(); replyToComment(comment); }); 1145 + item.appendChild(replyBtn); 1146 + } 1147 + 1148 + item.appendChild(author); 1149 + item.appendChild(body); 1150 + item.appendChild(time); 1151 + return item; 1039 1152 } 1040 1153 1041 1154 async function loadComments() { ··· 1054 1167 1055 1168 // Re-anchor comment marks after load by searching for quotedText in the doc 1056 1169 function reanchorCommentMarks(comments) { 1057 - if (!milkdownEditor || !comments || comments.length === 0) return; 1170 + if (!milkdownEditor) return; 1058 1171 try { 1059 1172 const pmView = milkdownEditor.action(ctx => ctx.get(editorViewCtx)); 1060 1173 const { doc, schema } = pmView.state; 1061 1174 const markType = schema.marks.comment; 1062 1175 if (!markType) return; 1063 - let tr = pmView.state.tr; 1064 - let changed = false; 1065 - for (const c of comments) { 1176 + // Clear all comment marks first, then re-add only for unresolved comments. 1177 + // This ensures resolving a comment removes its highlight on the next load. 1178 + let tr = pmView.state.tr.removeMark(0, doc.content.size, markType); 1179 + for (const c of comments || []) { 1180 + if (c.resolved) continue; 1066 1181 const threadId = c.threadId || c.id; 1067 1182 if (!c.quotedText) continue; 1068 1183 const pos = findTextInDoc(doc, c.quotedText); 1069 1184 if (pos === -1) continue; 1070 1185 tr = tr.addMark(pos, pos + c.quotedText.length, markType.create({ threadId })); 1071 - changed = true; 1072 1186 } 1073 - if (changed) pmView.dispatch(tr); 1187 + pmView.dispatch(tr); 1074 1188 } catch(e) { console.error('reanchorCommentMarks:', e); } 1075 1189 } 1076 1190