Diffdown is a real-time collaborative Markdown editor/previewer built on the AT Protocol diffdown.com
at main 203 lines 20 kB view raw view rendered
1Plan to implement 2 3 Comment Anchoring via ProseMirror Marks 4 5 Context 6 7 Comments are currently anchored by positional paragraph index (p-0, p-1). When paragraphs are 8 inserted or deleted, comments drift to wrong content. This is fundamentally broken. 9 10 The industry-standard solution for ProseMirror editors is mark-based anchoring: a ProseMirror 11 mark wraps the commented text with a threadId attribute. Since marks are part of the document 12 model, they move with the text automatically — edits, undo/redo, copy/paste, and collaboration 13 all preserve the association. This is how Tiptap Comments, Remirror, and The Guardian's 14 prosemirror-noting work. 15 16 Approach: Custom commentMark in Milkdown 17 18 How it works: 19 1. A ProseMirror mark named comment wraps the highlighted text range. The mark carries a 20 threadId attribute (8-char hex). 21 2. Comment metadata (author, text, timestamp) lives in Document.Comments[] array, keyed by the 22 same threadId. 23 3. The mark serializes to markdown as <span data-thread="threadId">text</span> — Goldmark 24 already has html.WithUnsafe() enabled, so these pass through to rendered output. 25 4. When text is deleted, the mark disappears with it. Orphaned comments (threadId in Comments 26 array but no matching mark in doc) are cleaned up on save. 27 28 Key files to modify: 29 - milkdown-entry.js — export $markSchema, $markAttr from @milkdown/kit/utils 30 - static/js/comment-mark.js — new file: defines commentMarkSchema using Milkdown's $markSchema 31 API 32 - templates/document_edit.html — import comment mark, register with editor, rewrite comment UI 33 to use text selection + marks 34 - internal/model/models.go — update EmbeddedComment (replace ParagraphID with ThreadID) 35 - internal/handler/handler.go — update CommentCreate/CommentList (threadId instead of 36 paragraphId) 37 - static/css/editor.css — style for comment highlights and sidebar 38 - Dockerfile — bundle new JS file (or inline in milkdown-entry.js) 39 40 Existing code to reuse: 41 - milkdown-entry.js already exports ProseMirror APIs; link mark in 42 @milkdown/preset-commonmark/src/mark/link.ts is the exact pattern to follow 43 - CommentCreate/CommentList handlers already do read-modify-write on owner's document — just 44 change the identifier from paragraphId to threadId 45 - Comment sidebar HTML/CSS already exists — update the rendering JS 46 - randomID() helper in handler.go already generates 8-char hex IDs 47 - Goldmark html.WithUnsafe() already enabled — no renderer changes needed 48 49 Implementation Tasks 50 51 Task 1: Define the comment mark plugin 52 53 Create static/js/comment-mark.js (bundled via esbuild alongside milkdown.js): 54 55 import { $markSchema, $markAttr } from '@milkdown/kit/utils'; 56 57 export const commentAttr = $markAttr('comment'); 58 59 export const commentSchema = $markSchema('comment', (ctx) => ({ 60 attrs: { 61 threadId: { default: null, validate: 'string|null' }, 62 }, 63 inclusive: false, // new text typed at mark boundary is NOT marked 64 parseDOM: [{ 65 tag: 'span[data-thread]', 66 getAttrs: (dom) => ({ threadId: dom.getAttribute('data-thread') }), 67 }], 68 toDOM: (mark) => ['span', { 69 ...ctx.get(commentAttr.key)(mark), 70 'data-thread': mark.attrs.threadId, 71 class: 'comment-highlight', 72 }, 0], 73 parseMarkdown: { 74 match: (node) => node.type === 'html' && typeof node.value === 'string' 75 && node.value.startsWith('<span data-thread='), 76 runner: (state, node, markType) => { 77 // This won't work directly — HTML nodes in remark are opaque. 78 // Instead, use a remark plugin to transform the HTML spans. 79 }, 80 }, 81 toMarkdown: { 82 match: (mark) => mark.type.name === 'comment', 83 runner: (state, mark) => { 84 // Wrap text in <span data-thread="...">...</span> 85 }, 86 }, 87 })); 88 89 However, Milkdown's markdown↔ProseMirror round-trip for custom inline HTML marks is complex. The 90 cleaner approach is: 91 92 Alternative (simpler): Don't persist comments as marks in the markdown at all. Instead: 93 - Marks exist only in the live ProseMirror document during editing 94 - Document.Comments[] stores threadId + a quotedText field (the exact text the comment was 95 attached to) 96 - On editor load, re-anchor comments by finding quotedText in the document and applying marks 97 - On save, marks are stripped (standard markdown serialization ignores unknown marks) 98 99 This avoids the markdown serialization problem entirely, at the cost of fuzzy re-anchoring 100 (Google Docs does the same thing). 101 102 Recommended: Hybrid mark + quotedText approach 103 104 1. During editing: Comments are ProseMirror marks with threadId attrs. Marks move with the text 105 automatically. 106 2. On save: Before serializing to markdown, scan all comment marks, update Comments[].quotedText 107 with the current marked text. Serialize to clean markdown (no spans). 108 3. On load: Read Comments[], for each comment search the document for quotedText, apply the 109 mark. If exact match fails, try fuzzy matching (substring, normalized whitespace). 110 4. Orphan cleanup: Comments whose quotedText can't be found in the document are flagged as 111 orphaned (shown in sidebar as "detached" with option to delete). 112 113 Task 2: Update the data model 114 115 internal/model/models.go — change EmbeddedComment: 116 117 type EmbeddedComment struct { 118 ID string `json:"id"` 119 ThreadID string `json:"threadId"` // links to ProseMirror mark 120 QuotedText string `json:"quotedText"` // text the comment was anchored to 121 Text string `json:"text"` 122 Author string `json:"author"` 123 AuthorHandle string `json:"authorHandle"` 124 CreatedAt string `json:"createdAt"` 125 } 126 127 Task 3: Update CommentCreate handler 128 129 internal/handler/handler.go — accept threadId and quotedText instead of paragraphId: 130 131 var req struct { 132 OwnerDID string `json:"ownerDID"` 133 ThreadID string `json:"threadId"` 134 QuotedText string `json:"quotedText"` 135 Text string `json:"text"` 136 } 137 138 Generate ThreadID server-side via randomID() if not provided. Return the threadId in the 139 response so the client can apply the mark. 140 141 Task 4: Update milkdown-entry.js and bundle 142 143 Add exports for $markSchema, $markAttr, $prose from @milkdown/kit/utils so the template JS can 144 define the mark inline. Update the Dockerfile esbuild step. 145 146 Task 5: Rewrite comment UI in document_edit.html 147 148 Replace the paragraph-click comment flow with text-selection comment flow: 149 150 1. Show comment button when user selects text (use ProseMirror update listener to detect 151 non-empty selection) 152 2. On "Comment" click: capture selection.from, selection.to, extract selected text as quotedText 153 3. POST to /api/docs/{rkey}/comments with { quotedText, text, ownerDID } — server returns { 154 threadId } 155 4. Apply mark: tr.addMark(from, to, schema.marks.comment.create({ threadId })) 156 5. Sidebar click → jump: find mark with matching threadId in the doc, scroll to it 157 158 Task 6: Re-anchor comments on editor load 159 160 After Milkdown creates the editor, iterate Document.Comments[]: 161 - For each comment, search ProseMirror doc for quotedText 162 - If found, apply comment mark with threadId 163 - If not found, show as "detached" in sidebar 164 165 Task 7: Update quotedText on save 166 167 In saveDocument / autosave, before serializing markdown: 168 - Walk the ProseMirror doc, find all comment marks 169 - For each mark, extract the current text it wraps 170 - POST updated quotedText values to the server (or include in the save payload) 171 172 Task 8: Update comment sidebar rendering 173 174 - Group by threadId instead of paragraphId 175 - Show quotedText snippet as the thread label (truncated) 176 - Click scrolls to the mark in the editor (find mark by threadId, get its position, scroll) 177 - Detached comments shown with warning icon 178 179 Task 9: Style comment highlights 180 181 CSS for .comment-highlight in the editor: 182 .comment-highlight { 183 background: rgba(234, 179, 8, 0.2); 184 border-bottom: 2px solid rgba(234, 179, 8, 0.6); 185 cursor: pointer; 186 } 187 188 Task 10: Cleanup 189 190 - Remove old ParagraphID references 191 - Remove setupParagraphCommentTrigger function 192 - Remove old paragraph-index comment button positioning code 193 194 Verification 195 196 1. Create a document with several paragraphs 197 2. Select text in paragraph 3, add a comment → yellow highlight appears, sidebar shows comment 198 with quoted text 199 3. Delete paragraph 1 → comment stays on the correct text (highlight moves with it) 200 4. Save and reload → comment re-anchors to the correct text via quotedText matching 201 5. Edit the commented text slightly → mark stretches/shrinks with the edit 202 6. Delete all the commented text → mark disappears, comment becomes "detached" in sidebar 203 7. Collaborator view: both owner and collaborator can see and add comments