HedgeDoc lacks a commenting feature. This is the most requested collaboration gap (issues #657, #2879, #5450) and a blocker for teams evaluating HedgeDoc as a Google Docs alternative. The community and maintainers have debated inline vs. out-of-band approaches for years without converging on a design.
Inline comments using CriticMarkup syntax, stored directly in the markdown document. A CodeMirror 6 extension hides the raw syntax and provides a clean UI. No backend changes required.
This aligns with the maintainers' stated principle that markdown is the single source of truth. Comments are document content, not metadata — they survive export, require no sync infrastructure, and leverage existing Yjs real-time collaboration for free.
The feature is gated behind HD_ENABLE_COMMENTS (default false). When disabled, the CM6 extension does not load, the toolbar comment button retains its current stub behavior, and the sidebar panel does not render. CriticMarkup in documents is visible as raw text — harmless. This allows the feature to ship on 1.x without affecting existing behavior.
The flag follows the existing HedgeDoc pattern: Zod-validated env var in the backend config, exposed via /api/private/config, consumed in the frontend via useFrontendConfig().enableComments.
In scope:
HD_ENABLE_COMMENTSfeature flag (opt-in, default off)- Highlight + comment threads using CriticMarkup syntax
- CodeMirror 6 extension for syntax hiding and decoration
- React comment sidebar panel
- Markdown-it plugin for preview pane highlights
- Toolbar integration via existing comment button stub
- Thread replies with author attribution and timestamps
- Thread deletion (permanent, strips markup)
Out of scope:
- CriticMarkup tracked changes (additions, deletions, substitutions)
- Accept/reject change workflow
- Comment-only permission level
- Notifications
- Cross-note comment search/indexing
{== highlighted text ==}{>>
---
@alice [2026-04-03T14:30Z]: This needs a citation.
<<}{== highlighted text ==}{>>
---
@alice [2026-04-03T14:30Z]: This needs a citation.
The claim about performance is unsupported.
---
@bob [2026-04-03T15:30Z]: Good point, I'll add the
benchmark results from our Q3 review.
<<}- Each thread is a single
{== ... ==}{>> ... <<}block - The
{== ==}portion wraps the highlighted text - The
{>> <<}portion contains all replies - Each reply starts with
---\n@username [ISO-8601-UTC]:on its own line - Reply text can span multiple lines — the next
---or<<}terminates it - ISO 8601 timestamps in UTC (e.g.,
2026-04-03T14:30Z) - Usernames come from the logged-in user or guest display name
^---\n@(\w+) \[(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}Z)\]:\s
When the comment sidebar is open, HedgeDoc displays three panels:
- Editor (left) — CodeMirror editor with CriticMarkup syntax hidden and highlighted text decorated with background color
- Preview (center) — Standard markdown-it rendered preview with comment highlights visible
- Comment sidebar (right) — 260px fixed-width panel listing all comment threads in document order
The comment sidebar is toggled via the existing 💬 button in the editor toolbar (comment-button.tsx).
All comment highlights use the same yellow color at different opacities:
- Active thread (selected):
#fcbc05at 35% opacity —rgba(252, 188, 5, 0.35) - Passive threads:
#fcbc05at 12% opacity —rgba(252, 188, 5, 0.12) - No underlines — background color only, with 2px border-radius
Active/passive state is driven by which thread is currently selected. Selection syncs bidirectionally between editor highlights, preview highlights, and sidebar threads.
Each thread in the sidebar shows:
- Quoted highlighted text (truncated with ellipsis if long)
- Author name and relative timestamp for each reply
- Replies indented with a left border
- Reply input field
- 🗑 Delete thread button (red, right-aligned)
Active thread has a 3px left border in #fcbc05 and subtle background tint.
| Action | Trigger | Result |
|---|---|---|
| Add comment | Select text → click 💬 toolbar button | Opens sidebar, inserts {== ==}{>> <<} wrapper, shows compose input |
| Reply | Type in reply field → submit | Inserts ---\n@user [timestamp]: text before closing <<} |
| Delete thread | Click 🗑 Delete → confirm | Strips {== ==}{>> <<} markup, leaves original text. Permanent. |
| Navigate (editor → sidebar) | Click highlight in editor | Sidebar scrolls to thread, thread becomes active |
| Navigate (preview → sidebar) | Click highlight in preview | Same as above |
| Navigate (sidebar → editor) | Click thread in sidebar | Editor scrolls to highlight, highlight becomes active |
No backend changes. The document is the database. Yjs is the sync layer. When any user adds, replies to, or deletes a comment, it's a regular document edit that Yjs propagates to all connected clients.
Located in frontend/src/extensions/criticmarkup/.
Parser: Scans the document for {== ... ==}{>> ... <<} patterns. Extracts highlighted text ranges and parses comment blocks into structured thread data (replies with author, timestamp, message).
Decorations:
- Replace decorations hide the CriticMarkup syntax characters (
{==,==}{>>,<<},---\n@user [timestamp]:) - Mark decorations apply yellow background to highlighted text ranges
- Active vs. passive styling driven by a
StateFieldtracking the selected thread
State:
StateField<CommentThread[]>— parsed thread data, rebuilt on document changesStateField<string | null>— active thread ID (derived from character position)- React sidebar reads these fields; user actions dispatch CM6 transactions
Located in frontend/src/components/editor-page/comment-sidebar/.
A React component that:
- Reads parsed thread data from the CM6 state fields
- Renders thread list with reply inputs and delete buttons
- Dispatches CM6 transactions for reply/delete actions
- Syncs active thread selection with editor highlights
A markdown-it plugin that:
- Parses
{== ... ==}{>> ... <<}in the markdown source - Renders highlighted text as
<span>with yellow background class - Strips comment block content from rendered output (comments appear only in sidebar)
- Highlight spans are clickable and set the active thread
New:
frontend/src/extensions/criticmarkup/— CM6 extension (parser, decorations, state fields)frontend/src/components/editor-page/comment-sidebar/— React sidebar panel
Modify:
frontend/src/components/editor-page/editor-pane/tool-bar/buttons/comment-button.tsx— Replace stub with comment-creation logic- Editor page layout component — Add third panel slot for comment sidebar
- Markdown-it plugin registration — Add CriticMarkup comment plugin
Comments get real-time sync for free. When a user inserts CriticMarkup text (add comment, reply, delete), it's a standard document edit. Yjs propagates the change to all connected clients. Each client's CM6 extension independently parses and decorates the updated document.
No new WebSocket message types needed. No conflict resolution beyond what Yjs already provides for concurrent text edits.
Overlapping highlights: Two comments on overlapping text ranges. The CM6 extension should handle nested/overlapping {== ==} blocks gracefully. For MVP, overlapping comments are allowed but the inner comment's highlight takes precedence visually.
Highlight text edited: When a user edits text inside {== ==}, the CriticMarkup wrapper stays intact (Yjs handles this). The highlighted text in the sidebar quote updates to reflect the change.
Empty highlight: If all text between {== ==} is deleted, the comment thread becomes orphaned. The extension should auto-delete the thread by stripping the empty {== ==}{>> ... <<} block.
Large threads: A comment block with many replies could become long in the raw markdown. The CM6 replace decoration hides all of it, so it doesn't affect the editing experience. The sidebar scrolls naturally.
Guest users: Anonymous users need a display name for the @username prefix. HedgeDoc already prompts for guest display names — use that value. If unavailable, use "anonymous".