Skip to content

Instantly share code, notes, and snippets.

@bbuchalter
Last active April 4, 2026 00:32
Show Gist options
  • Select an option

  • Save bbuchalter/9f0d1f2cfe339e43b50d0468535790ab to your computer and use it in GitHub Desktop.

Select an option

Save bbuchalter/9f0d1f2cfe339e43b50d0468535790ab to your computer and use it in GitHub Desktop.
HedgeDoc Inline Comments — Design Spec

HedgeDoc Inline Comments — Design Spec

Problem

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.

Decision

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.

Feature Flag

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.

Scope

In scope:

  • HD_ENABLE_COMMENTS feature 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

CriticMarkup Comment Format

Single comment on highlighted text

{== highlighted text ==}{>>
---
@alice [2026-04-03T14:30Z]: This needs a citation.
<<}

Threaded replies

{== 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.
<<}

Format rules

  • 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

Reply delimiter regex

^---\n@(\w+) \[(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}Z)\]:\s

UI Design

Three-panel layout

When the comment sidebar is open, HedgeDoc displays three panels:

  1. Editor (left) — CodeMirror editor with CriticMarkup syntax hidden and highlighted text decorated with background color
  2. Preview (center) — Standard markdown-it rendered preview with comment highlights visible
  3. 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).

Highlight styling

All comment highlights use the same yellow color at different opacities:

  • Active thread (selected): #fcbc05 at 35% opacity — rgba(252, 188, 5, 0.35)
  • Passive threads: #fcbc05 at 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.

Sidebar thread display

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.

Interactions

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

Architecture

Pure frontend implementation

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.

CodeMirror 6 extension

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 StateField tracking the selected thread

State:

  • StateField<CommentThread[]> — parsed thread data, rebuilt on document changes
  • StateField<string | null> — active thread ID (derived from character position)
  • React sidebar reads these fields; user actions dispatch CM6 transactions

React comment sidebar

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

Markdown-it plugin

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

Files to create/modify

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

Real-time Collaboration

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.

Edge Cases

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".

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment