Diffdown is a real-time collaborative Markdown editor/previewer built on the AT Protocol
diffdown.com
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