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

Fix comment system bugs and add UX improvements

- Fix duplicate renderCommentThreads declaration (caused editor crash)
- Fix reply button: stop click propagation, bypass openCommentForm, set
dummy pendingCommentRange so submitComment proceeds
- Fix reply AT URI map key mismatch: key replies by rkey not full AT URI
- Fix resolve 404: use rootComment.id (ATProto rkey) not threadId
- Fix highlight removal on resolve: reanchorCommentMarks now clears all
marks then re-adds only unresolved ones
- Sort comment threads by document position (top-to-bottom)
- Broadcast comments_updated WS message on create/update for live sync
- Add Comments toggle button to toolbar
- Move comment-form outside editor-page div to fix z-index stacking
- Change "Resolved" resolve button label to "Reopen"
- Inherit threadId in replies; fix reply form positioning

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

+85 -90
+12
internal/handler/handler.go
··· 814 814 }{CommentRecord: comment, URI: uri} 815 815 816 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 + } 817 823 } 818 824 819 825 func (h *Handler) CommentList(w http.ResponseWriter, r *http.Request) { ··· 944 950 } 945 951 946 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 + } 958 + } 947 959 } 948 960 949 961 // --- API: Render markdown ---
+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>
+72 -90
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 827 - let pendingReplyTo = null; // set when replying to a comment 844 + let pendingReplyTo = null; // set when replying to a comment 845 + let pendingThreadId = null; // threadId to inherit when replying 828 846 829 847 window.openCommentForm = function openCommentForm() { 830 848 if (!commentBtn || !commentForm || !milkdownEditor) return; ··· 850 868 if (commentBtn) commentBtn.style.display = 'none'; 851 869 pendingCommentRange = null; 852 870 pendingReplyTo = null; 871 + pendingThreadId = null; 853 872 if (commentTextEl) commentTextEl.placeholder = 'Add a comment...'; 854 873 } 855 874 ··· 861 880 try { 862 881 const body = { quotedText, text }; 863 882 if (pendingReplyTo) body.replyTo = pendingReplyTo; 883 + if (pendingThreadId) body.threadId = pendingThreadId; 864 884 if (ownerDID) body.ownerDID = ownerDID; 865 885 const resp = await fetch(`/api/docs/${rkey}/comments`, { 866 886 method: 'POST', ··· 879 899 } 880 900 closeCommentForm(); 881 901 pendingReplyTo = null; 902 + pendingThreadId = null; 882 903 loadComments(); 883 904 } catch (e) { console.error('Comment post failed:', e); } 884 905 } ··· 972 993 } catch(e) { return false; } 973 994 } 974 995 975 - function renderCommentThreads(comments) { 976 - const container = document.getElementById('comment-threads'); 977 - if (!container) return; 978 - 979 - if (!comments || comments.length === 0) { 980 - container.textContent = ''; 981 - const empty = document.createElement('p'); 982 - empty.className = 'comment-empty'; 983 - empty.textContent = 'No comments yet.'; 984 - container.appendChild(empty); 985 - return; 986 - } 987 - 988 - // Group by threadId 989 - const byThread = new Map(); 990 - for (const c of comments) { 991 - const tid = c.threadId || c.id; 992 - if (!byThread.has(tid)) byThread.set(tid, { comments: [], quotedText: c.quotedText }); 993 - byThread.get(tid).comments.push(c); 994 - } 995 - 996 - container.textContent = ''; 997 - for (const [tid, { comments: thread, quotedText }] of byThread) { 998 - const isDetached = !findCommentMark(tid); 999 - 1000 - const threadDiv = document.createElement('div'); 1001 - threadDiv.className = 'comment-thread' + (isDetached ? ' comment-thread-detached' : ''); 1002 - threadDiv.dataset.thread = tid; 1003 - 1004 - const labelDiv = document.createElement('div'); 1005 - labelDiv.className = 'comment-thread-label'; 1006 - const labelText = quotedText 1007 - ? '\u201c' + (quotedText.length > 40 ? quotedText.slice(0, 40) + '\u2026' : quotedText) + '\u201d' 1008 - : '(no anchor)'; 1009 - labelDiv.textContent = labelText; 1010 - if (isDetached) { 1011 - const warn = document.createElement('span'); 1012 - warn.title = 'Text was deleted'; 1013 - warn.textContent = ' \u26a0'; 1014 - labelDiv.appendChild(warn); 1015 - } 1016 - threadDiv.appendChild(labelDiv); 1017 - 1018 - for (const c of thread) { 1019 - const item = document.createElement('div'); 1020 - item.className = 'comment-item'; 1021 - 1022 - const author = document.createElement('div'); 1023 - author.className = 'comment-author'; 1024 - author.textContent = c.authorHandle || c.author; 1025 - 1026 - const body = document.createElement('div'); 1027 - body.className = 'comment-text'; 1028 - body.textContent = c.text; 1029 - 1030 - const time = document.createElement('div'); 1031 - time.className = 'comment-time'; 1032 - time.textContent = formatTime(c.createdAt); 1033 - 1034 - item.appendChild(author); 1035 - item.appendChild(body); 1036 - item.appendChild(time); 1037 - threadDiv.appendChild(item); 1038 - } 1039 - 1040 - container.appendChild(threadDiv); 1041 - } 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; } 1042 1010 } 1043 1011 1044 1012 function formatTime(ts) { ··· 1047 1015 } 1048 1016 1049 1017 async function replyToComment(comment) { 1018 + if (!commentForm || !commentTextEl) return; 1019 + pendingCommentRange = { from: 0, to: 0, quotedText: '' }; 1050 1020 pendingReplyTo = `at://${comment.docOwnerDid}/app.diffdown.comment/${comment.id}`; 1051 - if (commentTextEl) commentTextEl.placeholder = `Replying to ${comment.authorHandle || comment.author}...`; 1052 - openCommentForm(); 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(); 1053 1030 } 1054 1031 1055 1032 async function toggleResolve(threadId, resolved) { ··· 1084 1061 const replies = new Map(); 1085 1062 for (const c of comments) { 1086 1063 if (c.replyTo) { 1087 - if (!replies.has(c.replyTo)) replies.set(c.replyTo, []); 1088 - replies.get(c.replyTo).push(c); 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); 1089 1068 } else { 1090 1069 roots.push(c); 1091 1070 } 1092 1071 } 1093 1072 1073 + roots.sort((a, b) => findCommentMarkPos(a.threadId || a.id) - findCommentMarkPos(b.threadId || b.id)); 1074 + 1094 1075 container.textContent = ''; 1095 1076 for (const root of roots) { 1096 1077 const threadId = root.threadId || root.id; ··· 1125 1106 1126 1107 const resolveBtn = document.createElement('button'); 1127 1108 resolveBtn.className = 'btn btn-sm ' + (rootComment.resolved ? 'btn-active' : 'btn-outline'); 1128 - resolveBtn.textContent = rootComment.resolved ? 'Resolved' : 'Resolve'; 1129 - resolveBtn.onclick = () => toggleResolve(threadId, !rootComment.resolved); 1109 + resolveBtn.textContent = rootComment.resolved ? 'Reopen' : 'Resolve'; 1110 + resolveBtn.onclick = () => toggleResolve(rootComment.id, !rootComment.resolved); 1130 1111 headerDiv.appendChild(resolveBtn); 1131 1112 1132 1113 threadDiv.appendChild(headerDiv); ··· 1160 1141 const replyBtn = document.createElement('button'); 1161 1142 replyBtn.className = 'btn btn-sm btn-link'; 1162 1143 replyBtn.textContent = 'Reply'; 1163 - replyBtn.onclick = () => replyToComment(comment); 1144 + replyBtn.addEventListener('click', e => { e.stopPropagation(); replyToComment(comment); }); 1164 1145 item.appendChild(replyBtn); 1165 1146 } 1166 1147 ··· 1186 1167 1187 1168 // Re-anchor comment marks after load by searching for quotedText in the doc 1188 1169 function reanchorCommentMarks(comments) { 1189 - if (!milkdownEditor || !comments || comments.length === 0) return; 1170 + if (!milkdownEditor) return; 1190 1171 try { 1191 1172 const pmView = milkdownEditor.action(ctx => ctx.get(editorViewCtx)); 1192 1173 const { doc, schema } = pmView.state; 1193 1174 const markType = schema.marks.comment; 1194 1175 if (!markType) return; 1195 - let tr = pmView.state.tr; 1196 - let changed = false; 1197 - 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; 1198 1181 const threadId = c.threadId || c.id; 1199 1182 if (!c.quotedText) continue; 1200 1183 const pos = findTextInDoc(doc, c.quotedText); 1201 1184 if (pos === -1) continue; 1202 1185 tr = tr.addMark(pos, pos + c.quotedText.length, markType.create({ threadId })); 1203 - changed = true; 1204 1186 } 1205 - if (changed) pmView.dispatch(tr); 1187 + pmView.dispatch(tr); 1206 1188 } catch(e) { console.error('reanchorCommentMarks:', e); } 1207 1189 } 1208 1190