Plan to implement
Comment Anchoring via ProseMirror Marks
Context
Comments are currently anchored by positional paragraph index (p-0, p-1). When paragraphs are
inserted or deleted, comments drift to wrong content. This is fundamentally broken.
The industry-standard solution for ProseMirror editors is mark-based anchoring: a ProseMirror
mark wraps the commented text with a threadId attribute. Since marks are part of the document
model, they move with the text automatically — edits, undo/redo, copy/paste, and collaboration
all preserve the association. This is how Tiptap Comments, Remirror, and The Guardian's
prosemirror-noting work.
Approach: Custom commentMark in Milkdown
How it works:
1. A ProseMirror mark named comment wraps the highlighted text range. The mark carries a
threadId attribute (8-char hex).
2. Comment metadata (author, text, timestamp) lives in Document.Comments[] array, keyed by the
same threadId.
3. The mark serializes to markdown as text — Goldmark
already has html.WithUnsafe() enabled, so these pass through to rendered output.
4. When text is deleted, the mark disappears with it. Orphaned comments (threadId in Comments
array but no matching mark in doc) are cleaned up on save.
Key files to modify:
- milkdown-entry.js — export $markSchema, $markAttr from @milkdown/kit/utils
- static/js/comment-mark.js — new file: defines commentMarkSchema using Milkdown's $markSchema
API
- templates/document_edit.html — import comment mark, register with editor, rewrite comment UI
to use text selection + marks
- internal/model/models.go — update EmbeddedComment (replace ParagraphID with ThreadID)
- internal/handler/handler.go — update CommentCreate/CommentList (threadId instead of
paragraphId)
- static/css/editor.css — style for comment highlights and sidebar
- Dockerfile — bundle new JS file (or inline in milkdown-entry.js)
Existing code to reuse:
- milkdown-entry.js already exports ProseMirror APIs; link mark in
@milkdown/preset-commonmark/src/mark/link.ts is the exact pattern to follow
- CommentCreate/CommentList handlers already do read-modify-write on owner's document — just
change the identifier from paragraphId to threadId
- Comment sidebar HTML/CSS already exists — update the rendering JS
- randomID() helper in handler.go already generates 8-char hex IDs
- Goldmark html.WithUnsafe() already enabled — no renderer changes needed
Implementation Tasks
Task 1: Define the comment mark plugin
Create static/js/comment-mark.js (bundled via esbuild alongside milkdown.js):
import { $markSchema, $markAttr } from '@milkdown/kit/utils';
export const commentAttr = $markAttr('comment');
export const commentSchema = $markSchema('comment', (ctx) => ({
attrs: {
threadId: { default: null, validate: 'string|null' },
},
inclusive: false, // new text typed at mark boundary is NOT marked
parseDOM: [{
tag: 'span[data-thread]',
getAttrs: (dom) => ({ threadId: dom.getAttribute('data-thread') }),
}],
toDOM: (mark) => ['span', {
...ctx.get(commentAttr.key)(mark),
'data-thread': mark.attrs.threadId,
class: 'comment-highlight',
}, 0],
parseMarkdown: {
match: (node) => node.type === 'html' && typeof node.value === 'string'
&& node.value.startsWith(' mark.type.name === 'comment',
runner: (state, mark) => {
// Wrap text in ...
},
},
}));
However, Milkdown's markdown↔ProseMirror round-trip for custom inline HTML marks is complex. The
cleaner approach is:
Alternative (simpler): Don't persist comments as marks in the markdown at all. Instead:
- Marks exist only in the live ProseMirror document during editing
- Document.Comments[] stores threadId + a quotedText field (the exact text the comment was
attached to)
- On editor load, re-anchor comments by finding quotedText in the document and applying marks
- On save, marks are stripped (standard markdown serialization ignores unknown marks)
This avoids the markdown serialization problem entirely, at the cost of fuzzy re-anchoring
(Google Docs does the same thing).
Recommended: Hybrid mark + quotedText approach
1. During editing: Comments are ProseMirror marks with threadId attrs. Marks move with the text
automatically.
2. On save: Before serializing to markdown, scan all comment marks, update Comments[].quotedText
with the current marked text. Serialize to clean markdown (no spans).
3. On load: Read Comments[], for each comment search the document for quotedText, apply the
mark. If exact match fails, try fuzzy matching (substring, normalized whitespace).
4. Orphan cleanup: Comments whose quotedText can't be found in the document are flagged as
orphaned (shown in sidebar as "detached" with option to delete).
Task 2: Update the data model
internal/model/models.go — change EmbeddedComment:
type EmbeddedComment struct {
ID string `json:"id"`
ThreadID string `json:"threadId"` // links to ProseMirror mark
QuotedText string `json:"quotedText"` // text the comment was anchored to
Text string `json:"text"`
Author string `json:"author"`
AuthorHandle string `json:"authorHandle"`
CreatedAt string `json:"createdAt"`
}
Task 3: Update CommentCreate handler
internal/handler/handler.go — accept threadId and quotedText instead of paragraphId:
var req struct {
OwnerDID string `json:"ownerDID"`
ThreadID string `json:"threadId"`
QuotedText string `json:"quotedText"`
Text string `json:"text"`
}
Generate ThreadID server-side via randomID() if not provided. Return the threadId in the
response so the client can apply the mark.
Task 4: Update milkdown-entry.js and bundle
Add exports for $markSchema, $markAttr, $prose from @milkdown/kit/utils so the template JS can
define the mark inline. Update the Dockerfile esbuild step.
Task 5: Rewrite comment UI in document_edit.html
Replace the paragraph-click comment flow with text-selection comment flow:
1. Show comment button when user selects text (use ProseMirror update listener to detect
non-empty selection)
2. On "Comment" click: capture selection.from, selection.to, extract selected text as quotedText
3. POST to /api/docs/{rkey}/comments with { quotedText, text, ownerDID } — server returns {
threadId }
4. Apply mark: tr.addMark(from, to, schema.marks.comment.create({ threadId }))
5. Sidebar click → jump: find mark with matching threadId in the doc, scroll to it
Task 6: Re-anchor comments on editor load
After Milkdown creates the editor, iterate Document.Comments[]:
- For each comment, search ProseMirror doc for quotedText
- If found, apply comment mark with threadId
- If not found, show as "detached" in sidebar
Task 7: Update quotedText on save
In saveDocument / autosave, before serializing markdown:
- Walk the ProseMirror doc, find all comment marks
- For each mark, extract the current text it wraps
- POST updated quotedText values to the server (or include in the save payload)
Task 8: Update comment sidebar rendering
- Group by threadId instead of paragraphId
- Show quotedText snippet as the thread label (truncated)
- Click scrolls to the mark in the editor (find mark by threadId, get its position, scroll)
- Detached comments shown with warning icon
Task 9: Style comment highlights
CSS for .comment-highlight in the editor:
.comment-highlight {
background: rgba(234, 179, 8, 0.2);
border-bottom: 2px solid rgba(234, 179, 8, 0.6);
cursor: pointer;
}
Task 10: Cleanup
- Remove old ParagraphID references
- Remove setupParagraphCommentTrigger function
- Remove old paragraph-index comment button positioning code
Verification
1. Create a document with several paragraphs
2. Select text in paragraph 3, add a comment → yellow highlight appears, sidebar shows comment
with quoted text
3. Delete paragraph 1 → comment stays on the correct text (highlight moves with it)
4. Save and reload → comment re-anchors to the correct text via quotedText matching
5. Edit the commented text slightly → mark stretches/shrinks with the edit
6. Delete all the commented text → mark disappears, comment becomes "detached" in sidebar
7. Collaborator view: both owner and collaborator can see and add comments